From a02dfce63d3a04b9f6187c20e1ae8ce3aebe5f1a Mon Sep 17 00:00:00 2001 From: bedipritpal Date: Tue, 22 Sep 2026 16:19:30 -0700 Subject: [PATCH 01/97] security: harden repository and begin ABI split --- .github/workflows/secret-scan.yml | 40 ++++++++++++++++++++++ .gitleaks.toml | 4 +++ .gitleaksignore | 4 +++ CMakeLists.txt | 23 ++++++------- IMAC_CONNECTION.md | 55 ------------------------------- README.md | 21 ++++++------ SECURITY.md | 21 ++++++++++++ docs/abi-split-plan.md | 30 +++++++++++++++++ src/CMakeLists.txt | 1 + src/abi/ace_exports.cpp | 18 ++-------- src/abi/runtime.cpp | 10 ++++++ src/abi/runtime.h | 27 +++++++++++++++ 12 files changed, 161 insertions(+), 93 deletions(-) create mode 100644 .github/workflows/secret-scan.yml create mode 100644 .gitleaks.toml create mode 100644 .gitleaksignore delete mode 100644 IMAC_CONNECTION.md create mode 100644 SECURITY.md create mode 100644 docs/abi-split-plan.md create mode 100644 src/abi/runtime.cpp create mode 100644 src/abi/runtime.h diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml new file mode 100644 index 00000000..a7f3e529 --- /dev/null +++ b/.github/workflows/secret-scan.yml @@ -0,0 +1,40 @@ +name: Secret scan + +on: + pull_request: + push: + branches: [main] + workflow_dispatch: + +permissions: + contents: read + +jobs: + gitleaks-current-tree: + name: Gitleaks (current tree) + runs-on: ubuntu-24.04 + steps: + - name: Check out source + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.2.2 + with: + fetch-depth: 1 + persist-credentials: false + + - name: Install Gitleaks 8.24.2 + shell: bash + run: | + set -euo pipefail + archive="$RUNNER_TEMP/gitleaks.tar.gz" + curl --fail --location --silent --show-error \ + https://github.com/gitleaks/gitleaks/releases/download/v8.24.2/gitleaks_8.24.2_linux_x64.tar.gz \ + --output "$archive" + echo "fa0500f6b7e41d28791ebc680f5dd9899cd42b58629218a5f041efa899151a8e $archive" | sha256sum --check --strict + tar -xzf "$archive" -C "$RUNNER_TEMP" gitleaks + "$RUNNER_TEMP/gitleaks" version + + # Scan files in the proposed tree. Full-history scanning is enabled only + # after the separately reviewed history rewrite has removed known leaks. + - name: Scan current tree + shell: bash + run: | + "$RUNNER_TEMP/gitleaks" dir . --redact --exit-code 1 diff --git a/.gitleaks.toml b/.gitleaks.toml new file mode 100644 index 00000000..e3b64e20 --- /dev/null +++ b/.gitleaks.toml @@ -0,0 +1,4 @@ +title = "OpenADS Gitleaks configuration" + +[extend] +useDefault = true diff --git a/.gitleaksignore b/.gitleaksignore new file mode 100644 index 00000000..0544730a --- /dev/null +++ b/.gitleaksignore @@ -0,0 +1,4 @@ +src/session/connection.cpp:generic-api-key:525 +src/session/connection.cpp:generic-api-key:1457 +tests/unit/aes_test.cpp:generic-api-key:41 +third_party/tinyaes/aes.c:generic-api-key:18 diff --git a/CMakeLists.txt b/CMakeLists.txt index 82c028be..b74118ed 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -219,9 +219,8 @@ if(OPENADS_WITH_TLS) set(MBEDTLS_INSTALL OFF CACHE BOOL "" FORCE) FetchContent_Declare( mbedtls - GIT_REPOSITORY https://github.com/Mbed-TLS/mbedtls.git - GIT_TAG v3.6.2 - GIT_SHALLOW TRUE + URL https://github.com/Mbed-TLS/mbedtls/archive/refs/tags/v3.6.2.tar.gz + URL_HASH SHA256=f4a876b1f6921ad0aefb445f974ef62414d33928640b2c45555c5e64a196a1a8 ) FetchContent_MakeAvailable(mbedtls) message(STATUS "OpenADS: TLS enabled via vendored mbedtls 3.6.2 (statically linked)") @@ -246,15 +245,13 @@ if(OPENADS_WITH_HTTP) set(JSON_Install OFF CACHE BOOL "" FORCE) FetchContent_Declare( cpp_httplib - GIT_REPOSITORY https://github.com/yhirose/cpp-httplib.git - GIT_TAG v0.18.5 - GIT_SHALLOW TRUE + URL https://github.com/yhirose/cpp-httplib/archive/refs/tags/v0.18.5.tar.gz + URL_HASH SHA256=731190e97acd63edce57cc3dacd496f57e7743bfc7933da7137cb3e93ec6c9a0 ) FetchContent_Declare( nlohmann_json - GIT_REPOSITORY https://github.com/nlohmann/json.git - GIT_TAG v3.11.3 - GIT_SHALLOW TRUE + URL https://github.com/nlohmann/json/archive/refs/tags/v3.11.3.tar.gz + URL_HASH SHA256=0d8ef5af7f9794e3263480193c491549b2ba6cc74bb018906202ada498a79406 ) set(JSON_BuildTests OFF CACHE INTERNAL "") FetchContent_MakeAvailable(cpp_httplib nlohmann_json) @@ -278,8 +275,8 @@ if(NOT EXISTS "${_openads_zlib_dir}/zlib.h") include(FetchContent) FetchContent_Declare( zlib_src - URL https://github.com/madler/zlib/releases/download/v1.3.1/zlib-1.3.1.tar.gz - DOWNLOAD_EXTRACT_TIMESTAMP TRUE + URL https://github.com/madler/zlib/releases/download/v1.3.1/zlib-1.3.1.tar.gz + URL_HASH SHA256=9a93b2b7dfdac77ceba5a558a580e74667dd6fede4585b91eefb60f03b72df23 ) set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) set(SKIP_INSTALL_ALL ON CACHE BOOL "" FORCE) @@ -334,8 +331,8 @@ if(OPENADS_WITH_SQLITE) include(FetchContent) FetchContent_Declare( sqlite_amalgamation - URL https://www.sqlite.org/2024/sqlite-amalgamation-3460100.zip - DOWNLOAD_EXTRACT_TIMESTAMP TRUE + URL https://www.sqlite.org/2024/sqlite-amalgamation-3460100.zip + URL_HASH SHA256=77823cb110929c2bcb0f5d48e4833b5c59a8a6e40cdea3936b99e199dbbe5784 ) FetchContent_MakeAvailable(sqlite_amalgamation) set(_openads_sqlite_src "${sqlite_amalgamation_SOURCE_DIR}/sqlite3.c") diff --git a/IMAC_CONNECTION.md b/IMAC_CONNECTION.md deleted file mode 100644 index 5548c44b..00000000 --- a/IMAC_CONNECTION.md +++ /dev/null @@ -1,55 +0,0 @@ -# Remote Server Connection Details - -## iMac (Local Network) - -### Access -- **IP:** 192.168.18.184 -- **SSH:** `ssh Anto@192.168.18.184` (port 22) -- **Password:** `1234` -- **Hostname:** antos-iMac.local -- **OS:** macOS Ventura (Darwin 22.6.0, x86_64) - -### Build Tools -- **clang++:** Apple clang 14.0.3 -- **make:** /usr/bin/make -- **cmake:** /Applications/CMake.app/Contents/bin/cmake (v3.28.3, installed manually) - -### Benchmark (WiFi) -- **Throughput:** 784K rec/s (500K records in 0.69s) -- **Server port:** 6262 (or 16262 pre-existing) - ---- - -## charleskwon.com (Remote Internet) - -### Access -- **Host:** antonio.charleskwon.com -- **SSH:** `ssh -p 2222 antonio@antonio.charleskwon.com` -- **Password:** `antonio!@#$` -- **OS:** Ubuntu 24.04 Linux x86_64 (64 cores!) - -### Build Tools -- **g++:** 12.4.0 -- **cmake:** 3.28.3 -- **make:** /usr/bin/make - -### Notes -- Port 6262 is NOT accessible from outside (firewall) -- Use SSH tunnel: `ssh -f -N -L 16262:127.0.0.1:6262 -p 2222 antonio@antonio.charleskwon.com` -- Then connect: `tcp://127.0.0.1:16262//home/antonio/OpenADS/bench_data` -- FetchContent deps need pre-fetching: nlohmann_json, cpp-httplib, sqlite3 - -### Benchmark (SSH tunnel) -- **Throughput:** 676K rec/s (500K records in 0.74s) - ---- - -## SSH Command Template -```powershell -# iMac -echo "1234" | ssh -o ConnectTimeout=10 Anto@192.168.18.184 "COMMAND" - -# charleskwon (with tunnel) -ssh -f -N -L 16262:127.0.0.1:6262 -p 2222 antonio@antonio.charleskwon.com -# Password: antonio!@#$ -``` diff --git a/README.md b/README.md index 97b45307..c7b7244a 100644 --- a/README.md +++ b/README.md @@ -103,13 +103,13 @@ generated baseline; OpenADS clears every line of the regenerated ADS-flavoured baseline. The session that closed the last gap is recorded across 28 incremental commits ending at `28be1be`. -**Current release: [v1.8.4](https://github.com/FiveTechSoft/OpenADS/releases/tag/v1.8.4) (2026-07-09).** -Harbour `rddads` + FiveWin `TDataBase` / `xBrowse` over `tcp://` is -production-ready: remote scope (`OrdScope`), index navigation, key counts, -date fields, and field I/O by ordinal all work end-to-end. Full Data -Dictionary enforcement, Studio web console, SAP DD import, SQL backends -(PostgreSQL / MariaDB / MSSQL / ODBC / SQLite), and CDX bulk-load index -build are in production. `openads_serverd` serves the OpenADS wire +**Current release: [v1.09.68](https://github.com/FiveTechSoft/OpenADS/releases/tag/v1.09.68) (2026-09-20).** +OpenADS has broad compatibility coverage, but support varies by API and +backend. Before production deployment, review [`TODO.parity.md`](TODO.parity.md) +and [`docs/known-issues.md`](docs/known-issues.md), test the exact workload, +and apply normal database security and backup controls. In particular, +documented Data Dictionary and access-control parity gaps make untrusted or +multi-user deployments experimental until those gaps are closed. `openads_serverd` serves the OpenADS wire protocol; clients connect with `AdsConnect60("tcp://host:port/path.add", ...)` and no application code changes. Docs: @@ -120,9 +120,10 @@ including [migrating from ADS](https://fivetechsoft.github.io/OpenADS/en/migrati (CDX rollback / error 7017 caveat). `docs/wire-protocol.md` is the formal spec for non-C++ clients (Python, Go, Rust, Harbour AEP). -Cross-platform CI is **green on all three runners** -(`ubuntu-24.04 / ninja-clang`, `macos-14 / default`, -`windows-2022 / msvc-x64`). +Cross-platform CI runs on Ubuntu, macOS, and Windows. Check the current +[Actions results](https://github.com/FiveTechSoft/OpenADS/actions/workflows/ci.yml) +before relying on a branch or release; this document does not claim that the +latest run is green. Release timeline: diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 00000000..bbb0aeb7 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,21 @@ +# Security Policy + +## Supported versions + +Security fixes are applied to the latest published release and the default branch. Older releases may not receive fixes. + +## Reporting a vulnerability + +Do not open a public issue for a vulnerability or suspected credential exposure. Use GitHub's private vulnerability reporting for this repository: + +1. Open the repository's **Security** tab. +2. Select **Advisories** and **Report a vulnerability**. +3. Include affected versions, reproduction steps, impact, and any suggested mitigation. + +If private vulnerability reporting is unavailable, contact a repository maintainer privately and ask for a secure reporting channel. Do not include secrets or exploit details in ordinary email, discussions, issues, pull requests, or chat. + +You should receive an acknowledgement within 7 days. A maintainer will coordinate validation, remediation, disclosure, and any CVE request. Please allow time for a fix before public disclosure. + +## Credential exposure + +Treat a committed credential as compromised even after the file is deleted. Rotate or revoke it first, then remove it from the current tree and purge it from Git history. Avoid copying the credential into issues, pull requests, commit messages, or logs. diff --git a/docs/abi-split-plan.md b/docs/abi-split-plan.md new file mode 100644 index 00000000..42ad8047 --- /dev/null +++ b/docs/abi-split-plan.md @@ -0,0 +1,30 @@ +# ACE export translation-unit split plan + +`src/abi/ace_exports.cpp` contains the public C ABI and tightly coupled process state. Splitting it is a refactor, not a behavior change. The work must preserve exported names, ordinals, calling conventions, handle lifetime, last-error behavior, and recursive-lock re-entry. + +## Boundary introduced in this release + +Shared process state now lives in `abi/runtime.{h,cpp}` under `openads::abi::detail`. There is still exactly one recursive mutex, handle registry, connection map, and remote-find registry. This small extraction creates a stable internal seam without moving public entry points. + +## Planned modules + +1. `backend_dispatch` and `remote_state` +2. low-coupling export groups: management, filesystem, compatibility, and stubs +3. connection and teardown paths +4. table, field, index, and transaction exports +5. dictionary and access-control exports +6. SQL statement state, catalogs, procedures, scripts, triggers, and execution + +SQL moves last because execution deliberately re-enters public SQL entry points while the recursive ABI lock is held. + +## Rules for each move + +- Keep C signatures and `ENTRYPOINT` declarations byte-for-byte compatible. +- Do not combine relocation with behavior changes or cleanup. +- Keep one definition of every registry, mutex, and thread-local. +- Preserve `.def` manifests and x86 stdcall aliases. +- Compare produced binary exports against the release baseline in CI. +- Run Windows MSVC and MinGW plus Linux and macOS checks for every move. +- Remove `/bigobj` and `-Wa,-mbig-obj` only after the remaining translation units no longer need them. + +Prefer reviewable moves of roughly 500-1,500 lines. Access-control semantics and known parity gaps are separate feature work and must not be hidden inside this refactor. diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index b2ed37a1..f3a1c404 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -24,6 +24,7 @@ add_library(openads_core STATIC platform/proc.cpp engine/server_fs.cpp abi/ace_exports.cpp + abi/runtime.cpp abi/ace_stubs.cpp abi/adsfunc.c abi/openads_sql_c.cpp diff --git a/src/abi/ace_exports.cpp b/src/abi/ace_exports.cpp index 77d9310e..9ecbd683 100644 --- a/src/abi/ace_exports.cpp +++ b/src/abi/ace_exports.cpp @@ -18,6 +18,7 @@ using openads::abi::lock_retry_policy; #include "abi/backend_registry.h" #include "abi/charset.h" #include "abi/last_error.h" +#include "abi/runtime.h" #include "engine/aof_eval.h" #include "engine/aof_expr.h" @@ -157,21 +158,8 @@ extern thread_local bool g_field_read_raw; // registry Handle is 64-bit; centralise the (value-preserving) narrowing. ADSHANDLE to_ads_handle(Handle h) { return static_cast(h); } -struct ProcessState { - // M10.36 -- recursive_mutex so UNION dispatch can re-enter - // AdsExecuteSQLDirect (used to materialise each member's cursor) - // while still holding the outer lock. - std::recursive_mutex mu; - openads::session::HandleRegistry registry; - std::unordered_map> conns; - // M12.33 — remote find handles (owned here, not in a Connection). - std::vector> remote_finds; -}; - -ProcessState& state() { - static ProcessState s; - return s; -} +using openads::abi::detail::ProcessState; +using openads::abi::detail::state; // Serialise the file-creating entry points (AdsCreateTable local paths, // AdsCreateIndex61 native branch) PER TARGET PATH. All serverd sessions diff --git a/src/abi/runtime.cpp b/src/abi/runtime.cpp new file mode 100644 index 00000000..9f95e9e2 --- /dev/null +++ b/src/abi/runtime.cpp @@ -0,0 +1,10 @@ +#include "abi/runtime.h" + +namespace openads::abi::detail { + +ProcessState& state() { + static ProcessState process_state; + return process_state; +} + +} // namespace openads::abi::detail diff --git a/src/abi/runtime.h b/src/abi/runtime.h new file mode 100644 index 00000000..4f71f65b --- /dev/null +++ b/src/abi/runtime.h @@ -0,0 +1,27 @@ +#pragma once + +#include "session/connection.h" +#include "session/handle_registry.h" + +#include +#include +#include +#include + +namespace openads::abi::detail { + +// Process-wide state shared by ACE entry points. Keep this as the sole owner +// while ace_exports.cpp is split into domain translation units. +struct ProcessState { + // Recursive because SQL UNION execution deliberately re-enters + // AdsExecuteSQLDirect while the outer entry point holds this lock. + std::recursive_mutex mu; + session::HandleRegistry registry; + std::unordered_map> conns; + std::vector> remote_finds; +}; + +ProcessState& state(); + +} // namespace openads::abi::detail From 792874eca8e8acd7406f5fd4263ec2f19c8235f5 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Tue, 22 Sep 2026 19:25:24 -0500 Subject: [PATCH 02/97] build: fetch mbedTLS framework submodule --- CMakeLists.txt | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/CMakeLists.txt b/CMakeLists.txt index b74118ed..d7467a51 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -219,8 +219,11 @@ if(OPENADS_WITH_TLS) set(MBEDTLS_INSTALL OFF CACHE BOOL "" FORCE) FetchContent_Declare( mbedtls - URL https://github.com/Mbed-TLS/mbedtls/archive/refs/tags/v3.6.2.tar.gz - URL_HASH SHA256=f4a876b1f6921ad0aefb445f974ef62414d33928640b2c45555c5e64a196a1a8 + GIT_REPOSITORY https://github.com/Mbed-TLS/mbedtls.git + GIT_TAG v3.6.2 + GIT_SHALLOW TRUE + GIT_SUBMODULES framework + GIT_SUBMODULES_RECURSE TRUE ) FetchContent_MakeAvailable(mbedtls) message(STATUS "OpenADS: TLS enabled via vendored mbedtls 3.6.2 (statically linked)") From 9030bc07ca769181cf88139599af3f0a9c6f03ce Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Tue, 22 Sep 2026 21:07:53 -0500 Subject: [PATCH 03/97] ci: fetch full history so git describe finds tags --- .github/workflows/ci.yml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 97eca1f3..91547db2 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,11 @@ jobs: # tag refs a main-branch checkout reports a bare SHA (no # dots) and network_version_test fails everywhere. Releases # check out the tag itself so they never hit this. + # fetch-depth: 0 is also needed: in a shallow (depth 1) clone + # the tag's commit is not connected to HEAD, so describe + # still falls back to a bare SHA on any untagged commit + # (PR merge refs, main between releases). + fetch-depth: 0 fetch-tags: true - name: Install Ninja (Linux / macOS) From 3be72aa910f2f76dc9d0f0497659c059f53d6288 Mon Sep 17 00:00:00 2001 From: bedipritpal Date: Tue, 22 Sep 2026 20:29:53 -0700 Subject: [PATCH 04/97] ci: add apply-patch workflow --- .github/workflows/apply-patch.yml | 300 ++++++++++++++++++++++++++++++ 1 file changed, 300 insertions(+) create mode 100644 .github/workflows/apply-patch.yml diff --git a/.github/workflows/apply-patch.yml b/.github/workflows/apply-patch.yml new file mode 100644 index 00000000..37706693 --- /dev/null +++ b/.github/workflows/apply-patch.yml @@ -0,0 +1,300 @@ +name: apply-patch + +# Manually triggered: lands one reviewed patch on one branch, but only +# after it applies cleanly, stays inside the allowed paths, builds, and +# passes the full test suite. If any step fails, nothing is pushed. +# +# Inputs are never pasted into shell scripts directly. Every script +# reads them from environment variables, so a crafted branch name or +# message can't run commands. + +on: + workflow_dispatch: + inputs: + branch: + description: "Branch to commit to (not the default branch)" + required: true + type: string + expected_head: + description: "Full 40-character SHA the branch must be at right now" + required: true + type: string + patch_b64: + description: "Patch (git diff or git format-patch output), base64-encoded" + required: true + type: string + commit_message: + description: "Commit message (one line)" + required: true + type: string + trigger_ci: + description: "After pushing, start ci.yml on the branch (needs workflow_dispatch in ci.yml)" + required: false + type: boolean + default: true + +# Default for every job: read-only. Only the commit job gets write. +permissions: + contents: read + +# One run per branch at a time; a second run waits instead of racing. +concurrency: + group: apply-patch-${{ inputs.branch }} + cancel-in-progress: false + +env: + # Safety limits. Edit here if you need to change them. + MAX_FILES: "20" + MAX_PATCH_B64_CHARS: "60000" + # Paths the patch may touch (extended regex, matched against the full + # path). .github/ is deliberately NOT allowed: a patch must never be + # able to change workflows, and GitHub also refuses workflow-file + # pushes made with the built-in token. + ALLOWED_PATHS_REGEX: '^(src/|include/|tests/|cmake/|docs/|bindings/|tools/|examples/|CMakeLists\.txt$|CMakePresets\.json$|CHANGELOG\.md$|README\.md$)' + +jobs: + validate: + name: Validate patch + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - name: Check inputs + env: + BRANCH: ${{ inputs.branch }} + EXPECTED_HEAD: ${{ inputs.expected_head }} + PATCH_B64: ${{ inputs.patch_b64 }} + COMMIT_MESSAGE: ${{ inputs.commit_message }} + DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} + shell: bash + run: | + set -euo pipefail + if ! [[ "$BRANCH" =~ ^[A-Za-z0-9._/-]{1,100}$ ]] || [[ "$BRANCH" == *..* ]] || [[ "$BRANCH" == -* ]]; then + echo "::error::Branch name has characters that are not allowed."; exit 1 + fi + if [[ "$BRANCH" == "$DEFAULT_BRANCH" ]]; then + echo "::error::Refusing to commit to the default branch ($DEFAULT_BRANCH)."; exit 1 + fi + if ! [[ "$EXPECTED_HEAD" =~ ^[0-9a-f]{40}$ ]]; then + echo "::error::expected_head must be a full 40-character lowercase SHA."; exit 1 + fi + if (( ${#PATCH_B64} > MAX_PATCH_B64_CHARS )); then + echo "::error::Patch is larger than $MAX_PATCH_B64_CHARS base64 characters."; exit 1 + fi + if [[ -z "${COMMIT_MESSAGE// }" ]] || (( ${#COMMIT_MESSAGE} > 200 )); then + echo "::error::Commit message must be 1-200 characters."; exit 1 + fi + + - name: Check out branch + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.2.2 + with: + ref: ${{ inputs.branch }} + fetch-depth: 0 + persist-credentials: false + + - name: Check branch head matches expected_head + env: + EXPECTED_HEAD: ${{ inputs.expected_head }} + shell: bash + run: | + set -euo pipefail + actual="$(git rev-parse HEAD)" + if [[ "$actual" != "$EXPECTED_HEAD" ]]; then + echo "::error::Branch is at $actual, expected $EXPECTED_HEAD. Someone pushed in between; nothing was changed."; exit 1 + fi + + - name: Decode patch + env: + PATCH_B64: ${{ inputs.patch_b64 }} + shell: bash + run: | + set -euo pipefail + # Strip spaces and line breaks the web form may add. Any other + # damage makes decoding fail here, before anything is applied. + printf '%s' "$PATCH_B64" | tr -d ' \t\r\n' | base64 --decode > "$RUNNER_TEMP/change.patch" + if [[ ! -s "$RUNNER_TEMP/change.patch" ]]; then + echo "::error::Decoded patch is empty."; exit 1 + fi + # Trailing blank lines after the last hunk (common when a patch + # is copied or saved by an editor) can be read as extra context + # lines and make the patch fail. Trim them to one final newline. + # Only the very end of the file is touched; hunk contents and + # line counts are left as they are. + python3 - "$RUNNER_TEMP/change.patch" <<'PY' + import re, sys + path = sys.argv[1] + data = open(path, "rb").read() + fixed = re.sub(rb"(?:\r?\n[ \t\r]*)+\Z", b"\n", data) + if fixed != data: + print("Trimmed trailing blank lines from the patch.") + open(path, "wb").write(fixed) + PY + sha256sum "$RUNNER_TEMP/change.patch" + + - name: Apply patch and check paths + shell: bash + run: | + set -euo pipefail + git apply --check --index "$RUNNER_TEMP/change.patch" + git apply --index "$RUNNER_TEMP/change.patch" + + # What actually changed, from git itself (covers adds, deletes + # and both sides of renames). + git diff --cached --name-status --no-renames > "$RUNNER_TEMP/changed.txt" + cat "$RUNNER_TEMP/changed.txt" + count="$(wc -l < "$RUNNER_TEMP/changed.txt")" + if (( count == 0 )); then + echo "::error::Patch changes nothing."; exit 1 + fi + if (( count > MAX_FILES )); then + echo "::error::Patch touches $count files; the limit is $MAX_FILES."; exit 1 + fi + + bad=0 + while IFS=$'\t' read -r status path; do + if ! [[ "$path" =~ $ALLOWED_PATHS_REGEX ]]; then + echo "::error::Path not allowed: $path"; bad=1 + fi + done < "$RUNNER_TEMP/changed.txt" + + # No symlinks, submodules or executable-bit changes. + # Raw lines look like ": ...". Reject any + # symlink (120000) or submodule (160000), and any mode change + # on an existing file. + if git diff --cached --raw --no-renames | awk '{o=substr($1,2); n=$2; if (o ~ /^(120000|160000)$/ || n ~ /^(120000|160000)$/ || (o != "000000" && n != "000000" && o != n)) bad=1} END {exit !bad}'; then + echo "::error::Patch changes file modes (symlink, submodule or executable bit)."; bad=1 + fi + exit $bad + + - name: Save patch for the next jobs + uses: actions/upload-artifact@v4 + with: + name: change-patch + path: ${{ runner.temp }}/change.patch + retention-days: 3 + + build-and-test: + name: Build and test / ${{ matrix.preset }}${{ matrix.tls && ' / TLS' || '' }} + needs: validate + runs-on: ${{ matrix.os }} + timeout-minutes: 30 + strategy: + fail-fast: true + matrix: + include: + - os: ubuntu-24.04 + preset: ninja-clang + tls: false + - os: ubuntu-24.04 + preset: ninja-clang + tls: true + steps: + - name: Check out the exact expected commit + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.2.2 + with: + ref: ${{ inputs.expected_head }} + # Full history + tags, or git describe returns a bare SHA and + # network_version_test fails (same fix as ci.yml). + fetch-depth: 0 + fetch-tags: true + persist-credentials: false + + - name: Get patch + uses: actions/download-artifact@v4 + with: + name: change-patch + path: ${{ runner.temp }}/patch + + - name: Apply patch + shell: bash + run: git apply "$RUNNER_TEMP/patch/change.patch" + + - name: Install Ninja + uses: seanmiddleditch/gha-setup-ninja@v6 + + # Configure / Build / Test are the same commands as ci.yml. + - name: Configure + shell: bash + run: | + cmake --preset ${{ matrix.preset }} \ + ${{ matrix.tls && '-DOPENADS_WITH_TLS=ON' || '' }} + + - name: Build + run: cmake --build build/${{ matrix.preset }} --config Release --target dbf_cdx_bench openads_unit_tests + + - name: Test + run: ctest --test-dir build/${{ matrix.preset }} --output-on-failure -C Release + + commit: + name: Commit and push + needs: build-and-test + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: write + # Only used to start ci.yml after the push (see last step). + actions: write + steps: + - name: Check out branch + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.2.2 + with: + ref: ${{ inputs.branch }} + fetch-depth: 0 + + - name: Check head again + env: + EXPECTED_HEAD: ${{ inputs.expected_head }} + shell: bash + run: | + set -euo pipefail + actual="$(git rev-parse HEAD)" + if [[ "$actual" != "$EXPECTED_HEAD" ]]; then + echo "::error::Branch moved to $actual while tests ran. Nothing pushed."; exit 1 + fi + + - name: Get patch + uses: actions/download-artifact@v4 + with: + name: change-patch + path: ${{ runner.temp }}/patch + + - name: Commit and push + env: + BRANCH: ${{ inputs.branch }} + COMMIT_MESSAGE: ${{ inputs.commit_message }} + ACTOR: ${{ github.actor }} + shell: bash + run: | + set -euo pipefail + git apply --index "$RUNNER_TEMP/patch/change.patch" + git config user.name "github-actions[bot]" + git config user.email "41898283+github-actions[bot]@users.noreply.github.com" + printf '%s\n\nApplied by apply-patch workflow, run %s, started by %s.\n' \ + "$COMMIT_MESSAGE" "$GITHUB_RUN_ID" "$ACTOR" > "$RUNNER_TEMP/msg.txt" + git commit -F "$RUNNER_TEMP/msg.txt" + # Plain push, never force. If the branch moved, GitHub rejects + # it and nothing lands. + git push origin "HEAD:refs/heads/$BRANCH" + echo "Pushed $(git rev-parse HEAD) to $BRANCH" + + # A push made with the built-in token does not start push-triggered + # workflows, and PR runs it causes wait for approval on the PR page. + # A workflow_dispatch request is the exception, so start ci.yml that + # way. This only works if ci.yml lists workflow_dispatch under on:, + # which a person has to add (this workflow can't edit workflow files). + # The commit has already landed, so a problem here is a warning only. + - name: Start CI on the branch + if: ${{ inputs.trigger_ci }} + env: + BRANCH: ${{ inputs.branch }} + GH_TOKEN: ${{ github.token }} + shell: bash + run: | + if ! grep -qE '^[[:space:]]*workflow_dispatch:' .github/workflows/ci.yml; then + echo "::warning::ci.yml has no workflow_dispatch trigger, so CI was not started. Approve the pending run on the PR page, or add workflow_dispatch to ci.yml." + exit 0 + fi + if gh workflow run ci.yml --ref "$BRANCH"; then + echo "Started ci.yml on $BRANCH." + else + echo "::warning::Could not start ci.yml. The commit was pushed; start CI from the Actions tab." + fi From b4658849e9ee3546a5db4ee31fe7ce5d880b4729 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Wed, 23 Sep 2026 06:38:12 -0700 Subject: [PATCH 05/97] Update ci.yml --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 91547db2..3decc2b7 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,6 +4,7 @@ on: push: branches: [main] pull_request: + workflow_dispatch: jobs: build-and-test: From b9df38cb72c21f81fb830f2ed7437d7498801c16 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898283+github-actions[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 17:13:21 +0000 Subject: [PATCH 06/97] trace: log every wire round trip and table-park decisions (wire_trace only) Applied by apply-patch workflow, run 35893509903, started by bedipritpal. --- src/abi/ace_exports.cpp | 107 +++++++++++++++++++++++- src/network/client.cpp | 37 ++++++++ src/network/client.h | 15 ++++ tests/CMakeLists.txt | 1 + tests/unit/network_frame_trace_test.cpp | 88 +++++++++++++++++++ 5 files changed, 245 insertions(+), 3 deletions(-) create mode 100644 tests/unit/network_frame_trace_test.cpp diff --git a/src/abi/ace_exports.cpp b/src/abi/ace_exports.cpp index 9ecbd683..d71f286a 100644 --- a/src/abi/ace_exports.cpp +++ b/src/abi/ace_exports.cpp @@ -1483,11 +1483,15 @@ bool remote_table_has_index(const openads::network::RemoteTable* rt) { // // Same-ms runs read as locally served calls; an RTT-sized jump between // adjacent lines is exactly one wire round-trip. -static void cli_trace_line(long long ms, const char* who, const char* op, - const char* detail) { +static bool cli_trace_on() { static const bool on = openads::util::client_setting_truthy( "OPENADS_WIRE_TRACE", "wire_trace"); - if (!on) return; + return on; +} + +static void cli_trace_line(long long ms, const char* who, const char* op, + const char* detail) { + if (!cli_trace_on()) return; FILE* hf = std::fopen("C:/tmp/cli_trace.log", "a"); if (hf == nullptr) return; std::fprintf(hf, "[+%9lldms] [%-12.12s] [%-20.20s] %s\n", ms, @@ -1535,6 +1539,27 @@ static void cli_trace_tbl(const openads::network::RemoteTable* rt, cli_trace_line(cli_trace_ms(), who.c_str(), op, buf); } +// wire_trace: one grid line per completed wire round trip, so opens, +// closes, seeks, locks and writes show up next to the nav lines above. +// op/ack are wire opcodes (hex, see src/network/wire.h); tid is the +// table id for table-scoped ops (match it to the "opened id=" line); +// conn tells session-pool lanes apart. +static void cli_trace_frame_hook(const void* conn, std::uint8_t op, + std::uint32_t tid, std::size_t req_bytes, + std::uint8_t rep_op, std::size_t rep_bytes, + long long us) { + char buf[200]; + std::snprintf(buf, sizeof(buf), + "op=0x%02X tid=%u req=%lu ack=0x%02X ackb=%lu us=%lld conn=%04X", + static_cast(op), static_cast(tid), + static_cast(req_bytes), + static_cast(rep_op), + static_cast(rep_bytes), us, + static_cast( + reinterpret_cast(conn) & 0xFFFFu)); + cli_trace_line(cli_trace_ms(), "wire", "frame", buf); +} + void remote_clear_nav_boundaries(openads::network::RemoteTable* rt) { if (rt == nullptr) return; rt->nav_at_bof = false; @@ -6698,6 +6723,9 @@ UNSIGNED32 ENTRYPOINT AdsConnect60(UNSIGNED8* pucServer, UNSIGNED16 usServerType UNSIGNED8* pucUser, UNSIGNED8* pucPwd, UNSIGNED32 /*ulOptions*/, ADSHANDLE* phConnect) { arc2_trace("AdsConnect60"); + if (cli_trace_on()) { + openads::network::set_frame_trace_hook(&cli_trace_frame_hook); + } if (phConnect == nullptr) return fail(openads::AE_INTERNAL_ERROR, "phConnect is null"); auto path = openads::abi::to_internal(pucServer, 0); @@ -7388,6 +7416,26 @@ bool remote_table_has_relations(ADSHANDLE hTable) { // so no peer can invalidate them behind our back — an active order // resumes exactly (Vouch keeps IndexOrd()=1 across USEs; excluding it // would empty the pool). +// wire_trace only: first rule (in remote_table_poolable order, then +// relations) that keeps a closing table out of the park. Mirrors the +// checks below; it never decides anything itself. +const char* remote_table_unpoolable_reason( + openads::network::RemoteTable* rt, ADSHANDLE hTable) { + if (rt == nullptr || rt->conn == nullptr) return "no_connection"; + if (!rt->close_counted) return "sql_cursor"; + if (rt->open_exclusive) return "exclusive"; + if (!rt->pending_sets.empty()) return "pending_sets"; + if (rt->ever_locked) return "ever_locked"; + if (rt->scope_touched) return "scope"; + if (!rt->aof_expr.empty()) return "aof"; + if (!rt->filter_expr.empty()) return "filter"; + if (rt->flush_file_pending || rt->close_all_indexes_pending) + return "teardown_pending"; + if (rt->pending_order) return "pending_order"; + if (remote_table_has_relations(hTable)) return "relations"; + return "none"; +} + bool remote_table_poolable(openads::network::RemoteTable* rt) { if (rt == nullptr || rt->conn == nullptr) return false; if (!rt->close_counted) return false; // SQL cursors etc. @@ -7831,6 +7879,39 @@ UNSIGNED32 ENTRYPOINT AdsOpenTable(ADSHANDLE hConnect, adopted = std::move(hit); } } + if (cli_trace_on()) { + // Park diagnostics: hit/miss for this lane, plus whether + // another lane of the same session pool holds it parked. + char pbuf[320]; + if (adopted) { + std::snprintf(pbuf, sizeof(pbuf), "park hit id=%u %.200s", + static_cast(adopted->id), + name.c_str()); + } else { + const std::string pk = remote_pool_key(name, alias, usMode); + bool other_lane = false; + auto pit = remote_lane_pool_of().find(rc); + if (pit != remote_lane_pool_of().end() && + pit->second != nullptr) { + for (auto* ln : pit->second->lanes) { + if (ln != nullptr && ln != rc && + ln->parked_contains(pk)) { + other_lane = true; + break; + } + } + } + std::snprintf(pbuf, sizeof(pbuf), + "park miss%s conn=%04X %.200s", + other_lane ? " (parked on other lane)" : "", + static_cast( + reinterpret_cast(rc) & + 0xFFFFu), + name.c_str()); + } + cli_trace_line(cli_trace_ms(), alias.c_str(), "AdsOpenTable", + pbuf); + } if (adopted) { adopted->row_valid = false; adopted->rec_count_cached = false; @@ -7907,6 +7988,9 @@ UNSIGNED32 ENTRYPOINT AdsOpenTable(ADSHANDLE hConnect, rt->open_mode_raw = usMode; rt->open_exclusive = (map_open_mode(usMode) == openads::engine::OpenMode::Exclusive); + cli_trace_tbl(rt.get(), "AdsOpenTable", "opened id=%u mode=%u", + static_cast(rt->id), + static_cast(usMode)); rc->deferred_open_tables += 1; Handle gh = s.registry.register_object( HandleKind::RemoteTable, rt.get()); @@ -10849,7 +10933,19 @@ UNSIGNED32 ENTRYPOINT AdsCloseTable(ADSHANDLE hTable) { // reading its members is use-after-move (it crashed). std::string pkey = remote_pool_key(owned->name, owned->alias, owned->open_mode_raw); + openads::network::RemoteTable* traced = owned.get(); + if (cli_trace_on()) { + cli_trace_tbl(traced, "AdsCloseTable", "parked id=%u", + static_cast(traced->id)); + } rc->parked_store(std::move(pkey), std::move(owned), evicted); + if (cli_trace_on()) { + for (auto& e : evicted) { + cli_trace_tbl(e.get(), "AdsCloseTable", + "evicted from park id=%u", + e ? static_cast(e->id) : 0u); + } + } for (auto& e : evicted) remote_close_table_live(0, e.get()); return ok(); } @@ -10858,6 +10954,11 @@ UNSIGNED32 ENTRYPOINT AdsCloseTable(ADSHANDLE hTable) { // server close flushes data via its shadow handle and purges // the table's index bindings, so these frames would be waste. // A parked index snapshot dies with the handle (same purge). + if (cli_trace_on()) { + cli_trace_tbl(rt, "AdsCloseTable", "real close id=%u reason=%s", + static_cast(rt->id), + remote_table_unpoolable_reason(rt, hTable)); + } rt->flush_file_pending = false; rt->close_all_indexes_pending = false; rt->indexes_parked = false; diff --git a/src/network/client.cpp b/src/network/client.cpp index 72de1235..8c0e6a5b 100644 --- a/src/network/client.cpp +++ b/src/network/client.cpp @@ -5,6 +5,7 @@ #include "openads/error.h" #include +#include #include #include #include @@ -233,8 +234,21 @@ bool parse_tls_uri(const std::string& uri, return parse_scheme_uri(uri, "tls://", host, port, data_dir); } +namespace { +std::atomic g_frame_trace_hook{nullptr}; +} // namespace + +void set_frame_trace_hook(FrameTraceHook hook) noexcept { + g_frame_trace_hook.store(hook, std::memory_order_relaxed); +} + util::Result RemoteConnection::request(const Frame& f) { std::lock_guard lk(mu_); + const FrameTraceHook trace_hook = + g_frame_trace_hook.load(std::memory_order_relaxed); + const auto trace_t0 = trace_hook != nullptr + ? std::chrono::steady_clock::now() + : std::chrono::steady_clock::time_point{}; // Fail fast on a disconnected handle. rddads hands us connection // handles that AdsDisconnect already tore down (its "current // connection" can point at a handle another thread just closed); @@ -265,6 +279,17 @@ util::Result RemoteConnection::request(const Frame& f) { transport_->close(); return rep.error(); } + if (trace_hook != nullptr) { + const long long us = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now() - trace_t0).count()); + const std::uint32_t tid = + f.payload.size() >= 4 ? read_u32_le(f.payload.data()) : 0u; + trace_hook(this, static_cast(f.opcode), tid, + f.payload.size(), + static_cast(rep.value().opcode), + rep.value().payload.size(), us); + } // M12.10 — Error frame payload prefixed with [u32 LE ace_code]. // Parse it back into the util::Error so callers see the real ACE // code (5036, 7077, 5066, ...) instead of a generic 5000. @@ -447,6 +472,18 @@ void RemoteConnection::parked_store(std::string key, } } +bool RemoteConnection::parked_contains(const std::string& key) const { + std::lock_guard lk(park_mu_); + const auto now = std::chrono::steady_clock::now(); + for (const auto& e : parked_) { + const auto age = + std::chrono::duration_cast(now - e.parked_at); + if (age.count() > static_cast(kParkedTtlSec)) continue; + if (e.key == key && e.table) return true; + } + return false; +} + void RemoteConnection::parked_flush( std::vector>& out) { std::lock_guard lk(park_mu_); diff --git a/src/network/client.h b/src/network/client.h index 82d175fb..c0630bb2 100644 --- a/src/network/client.h +++ b/src/network/client.h @@ -46,6 +46,19 @@ struct AggregateBatch { struct RemoteTable; +// Diagnostics only: optional per-frame hook, called after every +// completed request/reply round trip (wire_trace). nullptr = off, which +// is the default; the request path then pays one relaxed atomic load. +// Arguments: connection, request opcode, first u32 of the request +// payload (the table id for table-scoped opcodes; meaningless for +// Hello/Connect/OpenTable), request payload bytes, reply opcode, reply +// payload bytes, microseconds spent in send+receive. +using FrameTraceHook = void (*)(const void* conn, std::uint8_t op, + std::uint32_t tid, std::size_t req_bytes, + std::uint8_t rep_op, std::size_t rep_bytes, + long long us); +void set_frame_trace_hook(FrameTraceHook hook) noexcept; + // M12.5 — wire client used by ace64.dll's dual-mode dispatch. // `RemoteConnection` opens a TCP socket to an OpenADS server, // sends a Connect frame for the data_dir, and exposes a small @@ -604,6 +617,8 @@ class RemoteConnection { std::vector>& evicted); // Drain everything (disconnect). Caller really-closes each entry. void parked_flush(std::vector>& out); + // Diagnostics (wire_trace): is an unexpired entry parked under key? + bool parked_contains(const std::string& key) const; private: std::vector parked_; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index a75989a0..4e4beea7 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -330,6 +330,7 @@ add_executable(openads_unit_tests unit/network_version_test.cpp unit/network_nav_batch_test.cpp unit/network_use_budget_test.cpp + unit/network_frame_trace_test.cpp unit/network_teardown_batch_test.cpp unit/network_pool_mt_test.cpp unit/network_mutex_release_test.cpp diff --git a/tests/unit/network_frame_trace_test.cpp b/tests/unit/network_frame_trace_test.cpp new file mode 100644 index 00000000..9a15742e --- /dev/null +++ b/tests/unit/network_frame_trace_test.cpp @@ -0,0 +1,88 @@ +// tests/unit/network_frame_trace_test.cpp +// wire_trace diagnostics: the optional per-frame hook sees every +// completed round trip while installed, and nothing once removed. +// Default (no hook) is the production path. +#include "doctest.h" +#include "network/client.h" +#include "network/server.h" +#include "openads/ace.h" + +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace { + +std::atomic g_frames{0}; +std::atomic g_open_frames{0}; +std::atomic g_open_acks{0}; + +void count_frame(const void* conn, std::uint8_t op, std::uint32_t /*tid*/, + std::size_t /*req_bytes*/, std::uint8_t rep_op, + std::size_t /*rep_bytes*/, long long us) { + CHECK(conn != nullptr); + CHECK(us >= 0); + g_frames.fetch_add(1); + if (op == 0x20) { + g_open_frames.fetch_add(1); + if (rep_op == 0x21) g_open_acks.fetch_add(1); + } +} + +} // namespace + +TEST_CASE("wire trace: frame hook counts round trips only while installed") { + auto dir = fs::temp_directory_path() / "openads_frametrace"; + std::error_code ec; + fs::remove_all(dir, ec); + fs::create_directories(dir); + + // Seed one table locally. + UNSIGNED8 srv[512]{}; + std::memcpy(srv, dir.string().c_str(), dir.string().size()); + ADSHANDLE hC0 = 0; + REQUIRE(AdsConnect60(srv, ADS_LOCAL_SERVER, nullptr, nullptr, 0, &hC0) + == AE_SUCCESS); + UNSIGNED8 def[] = "ID,N,8,0"; + UNSIGNED8 tn0[] = "FT.DBF"; + ADSHANDLE hT0 = 0; + REQUIRE(AdsCreateTable(hC0, tn0, nullptr, ADS_CDX, 0, 0, 0, 0, def, &hT0) + == AE_SUCCESS); + REQUIRE(AdsCloseTable(hT0) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hC0) == AE_SUCCESS); + + openads::network::Server s; + REQUIRE(s.start("127.0.0.1", 0).has_value()); + char uri[512]{}; + std::snprintf(uri, sizeof(uri), "tcp://127.0.0.1:%u/%s", + static_cast(s.port()), dir.string().c_str()); + UNSIGNED8 sb[512]{}; + std::memcpy(sb, uri, std::strlen(uri) + 1); + ADSHANDLE hC = 0; + REQUIRE(AdsConnect60(sb, ADS_REMOTE_SERVER, nullptr, nullptr, 0, &hC) + == AE_SUCCESS); + + openads::network::set_frame_trace_hook(&count_frame); + UNSIGNED8 tn[] = "FT.DBF"; + ADSHANDLE hT = 0; + REQUIRE(AdsOpenTable(hC, tn, nullptr, ADS_CDX, 0, 0, 0, 0, &hT) + == AE_SUCCESS); + openads::network::set_frame_trace_hook(nullptr); + + CHECK(g_frames.load() >= 1u); + CHECK(g_open_frames.load() == 1u); + CHECK(g_open_acks.load() == 1u); + + // Removed: further traffic is not reported. + const unsigned seen = g_frames.load(); + REQUIRE(AdsGotoBottom(hT) == AE_SUCCESS); + REQUIRE(AdsCloseTable(hT) == AE_SUCCESS); + CHECK(g_frames.load() == seen); + + REQUIRE(AdsDisconnect(hC) == AE_SUCCESS); + s.stop(); + fs::remove_all(dir, ec); +} From b539684f53a460a3c7cebb76d0ac6178dbf15698 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898283+github-actions[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 21:27:26 +0000 Subject: [PATCH 07/97] perf: answer empty-table GO 0, post-bottom keycount, table type, record length, refresh-after-goto locally (client only) Applied by apply-patch workflow, run 35921846008, started by bedipritpal. --- src/abi/ace_exports.cpp | 123 +++++++++++- src/network/client.cpp | 1 + src/network/client.h | 39 ++++ tests/CMakeLists.txt | 1 + tests/unit/network_local_answers_test.cpp | 207 +++++++++++++++++++++ tests/unit/network_teardown_batch_test.cpp | 4 +- 6 files changed, 372 insertions(+), 3 deletions(-) create mode 100644 tests/unit/network_local_answers_test.cpp diff --git a/src/abi/ace_exports.cpp b/src/abi/ace_exports.cpp index d71f286a..62a9268b 100644 --- a/src/abi/ace_exports.cpp +++ b/src/abi/ace_exports.cpp @@ -1669,10 +1669,58 @@ void remote_sync_keyno_gototop(openads::network::RemoteTable* rt) { } } +// True when the server itself certified, on the latest cursor-affecting +// frame, that this table's cursor sits on no row with BOTH limits set +// (the xBase empty-cursor state), and nothing client-side could have +// changed what that means since. Filters are excluded on purpose: the +// server key/record counts do not apply them, so an empty filtered +// cursor says nothing about the counts. +bool remote_cursor_proven_empty(const openads::network::RemoteTable* rt) { + return rt != nullptr && rt->conn != nullptr && + !rt->row_valid && + rt->bound_bof_ok && rt->bound_bof && + rt->bound_eof_ok && rt->bound_eof && + rt->bound_seq == rt->conn->nav_seq() && + !rt->pending_order && + rt->pending_sets.empty() && + rt->filter_expr.empty() && rt->aof_expr.empty(); +} + +// Memo key for AdsGetRecordLength re-opens: lowercased table name plus +// the whole schema. Empty (= no memo) when the schema is not known. +std::string remote_record_length_key(const openads::network::RemoteTable* rt) { + if (rt == nullptr || !rt->fields_cached || rt->fields.empty() || + rt->name.empty()) { + return {}; + } + std::string k = rt->name; + for (auto& c : k) + c = static_cast(std::tolower(static_cast(c))); + for (const auto& fd : rt->fields) { + k.push_back('\x1f'); + k += fd.name; + k.push_back('\x1e'); + k += std::to_string(fd.type) + "," + std::to_string(fd.length) + + "," + std::to_string(fd.decimals); + } + return k; +} + void remote_sync_keyno_gotobottom(openads::network::RemoteTable* rt) { if (rt == nullptr) return; remote_clear_nav_boundaries(rt); if (remote_table_has_index(rt)) { + // An ordered GotoBottom that the server certified empty (no row, + // BOF+EOF) proves the order holds no visible keys in its scope: + // the server key count (scope + SET DELETED aware, filter-blind) + // is 0. Seed it instead of asking — the empty tables of a USE + // otherwise pay one GetKeyCount frame each here. + if (rt->active_index_id != 0 && !rt->key_count_cached && + remote_cursor_proven_empty(rt)) { + rt->cached_key_count = 0; + rt->key_count_cached = true; + rt->key_counts[rt->active_index_id] = 0; + } // Bottom of the ORDER, not of the file: with a scope active the // last key is key #scoped_key_count, not #physical_rec_count. const std::uint32_t kc = remote_ensure_key_count(rt); @@ -7988,6 +8036,21 @@ UNSIGNED32 ENTRYPOINT AdsOpenTable(ADSHANDLE hConnect, rt->open_mode_raw = usMode; rt->open_exclusive = (map_open_mode(usMode) == openads::engine::OpenMode::Exclusive); + // Table type is decided by the server from the opened file's + // extension alone (.adt -> ADS_ADT, anything else -> ADS_CDX; + // see the GetTableType handler). The name we just opened carries + // that extension, so answer it locally. No extension -> leave it + // to the wire (the server may have resolved one). + { + std::string ext = std::filesystem::path(name).extension().string(); + for (auto& c : ext) + c = static_cast(std::tolower( + static_cast(c))); + if (!ext.empty()) { + rt->cached_table_type = (ext == ".adt") ? ADS_ADT : ADS_CDX; + rt->table_type_cached = true; + } + } cli_trace_tbl(rt.get(), "AdsOpenTable", "opened id=%u mode=%u", static_cast(rt->id), static_cast(usMode)); @@ -8652,11 +8715,23 @@ UNSIGNED32 ENTRYPOINT AdsGetRecordLength(ADSHANDLE hTable, UNSIGNED32* pulLen) { *pulLen = rt->cached_record_length; return ok(); } + // Re-open of a table already measured on this connection with + // the identical schema: same file layout, same length. + const std::string memo_key = remote_record_length_key(rt); + if (!memo_key.empty() && + rt->conn->recall_record_length(memo_key, + rt->cached_record_length)) { + rt->record_length_cached = true; + *pulLen = rt->cached_record_length; + return ok(); + } auto r = rt->conn->get_record_length(rt->id); if (!r) return fail(r.error()); *pulLen = r.value(); rt->cached_record_length = r.value(); rt->record_length_cached = true; + if (!memo_key.empty()) + rt->conn->remember_record_length(memo_key, r.value()); return ok(); } Table* t = get_table(hTable); @@ -10041,6 +10116,24 @@ UNSIGNED32 ENTRYPOINT AdsRefreshRecord(ADSHANDLE hTable) { arc2_trace("AdsRefreshRecord"); if (auto* rt = get_remote_table(hTable)) { if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; + // Refresh right after a GotoRecord on this handle, with nothing + // at all sent to the server in between (no lock, write, nav or + // read frame) and the cursor still on the row that ack carried: + // the server's GotoRecord had just re-read that row from disk, + // which is all a refresh does. Serve it from that ack. + if (rt->goto_row_fresh && rt->row_valid && rt->conn != nullptr && + rt->goto_row_frame_seq == rt->conn->frame_seq() && + rt->current_recno == rt->goto_row_recno && + rt->cursor_lag == 0 && rt->pending_sets.empty()) { + rt->goto_row_fresh = false; + cli_trace_tbl(rt, "AdsRefreshRecord", "served from GotoRecord ack"); + rt->invalidate_prefetch(); + rt->rec_count_cached = false; + rt->key_count_cached = false; + rt->key_counts.clear(); + return ok(); + } + rt->goto_row_fresh = false; remote_settle_cursor(rt); // M12.21 option C rt->row_valid = false; // M12.17 cache invalidation rt->rec_count_cached = false; @@ -10113,8 +10206,34 @@ UNSIGNED32 ENTRYPOINT AdsGotoRecord(ADSHANDLE hTable, UNSIGNED32 ulRecord) { // walk on the very next AdsGetRelKeyPos call (~22 sec for 9500 records). const std::uint32_t prev_recno = rt->row_valid ? rt->current_recno : 0u; - auto r = rt->conn->goto_record(rt, ulRecord); - if (!r) return fail(r.error()); + // GO 0 on a table the server certified physically EMPTY with the + // cursor already in the empty (BOF+EOF, no row) state: the server + // keeps that state (Table::goto_record preserves the phantom + // position on GO 0 even if another station appended meanwhile) + // and would send back exactly the bounds/recno we already hold. + // Answer it locally. GO n>0 always goes to the wire (a peer may + // have appended record n). Only for record count 0: on a + // non-empty table GO 0 moves the server's engine cursor. + const bool empty_goto = + ulRecord == 0u && + remote_cursor_proven_empty(rt) && + rt->count_bound_ok && rt->count_bound == 0 && + rt->count_bound_seq == rt->conn->nav_seq(); + rt->goto_row_fresh = false; + if (empty_goto) { + cli_trace_tbl(rt, "AdsGotoRecord", "served locally (empty table)"); + rt->invalidate_prefetch(); + // A real frame would have expired the duplicate-nav stamp. + rt->last_nav = 0; + } else { + auto r = rt->conn->goto_record(rt, ulRecord); + if (!r) return fail(r.error()); + if (rt->row_valid) { + rt->goto_row_fresh = true; + rt->goto_row_frame_seq = rt->conn->frame_seq(); + rt->goto_row_recno = rt->current_recno; + } + } if (remote_table_has_index(rt)) { // Only invalidate when the cursor actually moved: same-record // restores (the xbrowse common case) keep the cached key number. diff --git a/src/network/client.cpp b/src/network/client.cpp index 8c0e6a5b..9e9548e4 100644 --- a/src/network/client.cpp +++ b/src/network/client.cpp @@ -244,6 +244,7 @@ void set_frame_trace_hook(FrameTraceHook hook) noexcept { util::Result RemoteConnection::request(const Frame& f) { std::lock_guard lk(mu_); + frame_seq_.fetch_add(1, std::memory_order_relaxed); const FrameTraceHook trace_hook = g_frame_trace_hook.load(std::memory_order_relaxed); const auto trace_t0 = trace_hook != nullptr diff --git a/src/network/client.h b/src/network/client.h index c0630bb2..c1022728 100644 --- a/src/network/client.h +++ b/src/network/client.h @@ -545,6 +545,32 @@ class RemoteConnection { util::Result mutex_unlock(const std::string& name); util::Result mutex_destroy(const std::string& name); + // Count of wire round trips issued on this connection (every + // request(), reads included). Unlike nav_seq() this also moves on + // locks, fetches and counts, so "no frame since X" means nothing at + // all reached the server in between (used by AdsRefreshRecord to + // serve the row a GotoRecord ack just delivered). + std::uint64_t frame_seq() const noexcept { + return frame_seq_.load(std::memory_order_relaxed); + } + + // Per-connection record-length memo for re-opens of the same table. + // Keyed by lowercased table name + the full schema (names, types, + // widths, decimals) from the open ack, so any restructure changes + // the key and misses. Thread-safe. + bool recall_record_length(const std::string& key, + std::uint32_t& out) { + std::lock_guard lk(reclen_mu_); + auto it = reclen_memo_.find(key); + if (it == reclen_memo_.end()) return false; + out = it->second; + return true; + } + void remember_record_length(const std::string& key, std::uint32_t v) { + std::lock_guard lk(reclen_mu_); + if (reclen_memo_.size() < 4096) reclen_memo_[key] = v; + } + private: util::Result request(const Frame& f); @@ -571,6 +597,10 @@ class RemoteConnection { // staleness is impossible by construction: observing a change // requires a cursor-affecting frame, which is exactly what bumps. std::atomic nav_seq_{0}; + // See frame_seq(): bumped by every request(). + std::atomic frame_seq_{0}; + std::mutex reclen_mu_; + std::unordered_map reclen_memo_; // Raw HelloAck payload (see above). Written once during // connect_with_transport, read afterwards without mu_ (the // connection is fully established before any other thread @@ -870,6 +900,15 @@ struct RemoteTable { std::uint16_t cached_table_type = 0; bool record_length_cached = false; std::uint32_t cached_record_length = 0; + // Set by a wire AdsGotoRecord that landed on a row: the connection + // frame_seq() right after the ack and the recno it delivered. While + // no other frame has gone out and the cursor still sits on that row, + // an AdsRefreshRecord would re-read the very row that ack carried + // (the server's GotoRecord already re-read it from disk), so the + // refresh is served from it with no round trip. + bool goto_row_fresh = false; + std::uint64_t goto_row_frame_seq = 0; + std::uint32_t goto_row_recno = 0; // Deferred teardown (WAN chattiness: rddads issues FlushFileBuffers // + CloseAllIndexes before every CloseTable — 2 wasted frames per // USE, since the server close flushes data and purges index diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 4e4beea7..0ab76c53 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -330,6 +330,7 @@ add_executable(openads_unit_tests unit/network_version_test.cpp unit/network_nav_batch_test.cpp unit/network_use_budget_test.cpp + unit/network_local_answers_test.cpp unit/network_frame_trace_test.cpp unit/network_teardown_batch_test.cpp unit/network_pool_mt_test.cpp diff --git a/tests/unit/network_local_answers_test.cpp b/tests/unit/network_local_answers_test.cpp new file mode 100644 index 00000000..e1fd2ee6 --- /dev/null +++ b/tests/unit/network_local_answers_test.cpp @@ -0,0 +1,207 @@ +// tests/unit/network_local_answers_test.cpp +// Client-only frame cuts for the Vouch WAN startup (patch 1): +// a) AdsGetTableType answered from the opened name's extension, and +// AdsGetRecordLength remembered per connection for re-opens; +// b) no GetKeyCount after an ordered GotoBottom the server certified +// empty; +// c) AdsGotoRecord(0) on a certified-empty table served locally; +// d) AdsRefreshRecord right after a GotoRecord (nothing sent between) +// served from the GotoRecord ack. +// Each case also checks the answers match what the wire would say, and +// that the wire is still used when the proof does not hold. +#include "doctest.h" +#include "mgmt/mg_stats.h" +#include "network/server.h" +#include "openads/ace.h" + +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace { + +std::uint64_t la_op(std::uint8_t op) { + return openads::mgmt::process_mg_stats() + .op_timing[op] + .count.load(std::memory_order_relaxed); +} + +void la_make_table(ADSHANDLE hC, const char* name, const char* bagname, + int rows) { + UNSIGNED8 def[] = "ID,N,8,0;NM,C,10,0"; + UNSIGNED8 tn[64]{}; + std::memcpy(tn, name, std::strlen(name)); + ADSHANDLE hT = 0; + REQUIRE(AdsCreateTable(hC, tn, nullptr, ADS_CDX, 0, 0, 0, 0, def, &hT) + == AE_SUCCESS); + UNSIGNED8 f1[] = "ID"; + for (int i = 1; i <= rows; ++i) { + REQUIRE(AdsAppendRecord(hT) == AE_SUCCESS); + REQUIRE(AdsSetDouble(hT, f1, i * 10) == AE_SUCCESS); + REQUIRE(AdsWriteRecord(hT) == AE_SUCCESS); + } + ADSHANDLE hI = 0; + UNSIGNED8 bag[64]{}; + std::memcpy(bag, bagname, std::strlen(bagname)); + UNSIGNED8 tag[] = "BYID"; + UNSIGNED8 exp[] = "ID"; + REQUIRE(AdsCreateIndex61(hT, bag, tag, exp, nullptr, nullptr, + ADS_COMPOUND, 512, &hI) == AE_SUCCESS); + REQUIRE(AdsCloseTable(hT) == AE_SUCCESS); +} + +} // namespace + +TEST_CASE("Remote local answers: empty tables, table type, refresh") { + auto dir = fs::temp_directory_path() / "openads_localans"; + std::error_code ec; + fs::remove_all(dir, ec); + fs::create_directories(dir); + + UNSIGNED8 srv[512]{}; + std::memcpy(srv, dir.string().c_str(), dir.string().size()); + ADSHANDLE hC0 = 0; + REQUIRE(AdsConnect60(srv, ADS_LOCAL_SERVER, nullptr, nullptr, 0, &hC0) + == AE_SUCCESS); + la_make_table(hC0, "LAEMPTY.DBF", "LAEMPTY.CDX", 0); + la_make_table(hC0, "LAFULL.DBF", "LAFULL.CDX", 5); + REQUIRE(AdsDisconnect(hC0) == AE_SUCCESS); + + openads::network::Server s; + REQUIRE(s.start("127.0.0.1", 0).has_value()); + char uri[512]{}; + std::snprintf(uri, sizeof(uri), "tcp://127.0.0.1:%u/%s", + static_cast(s.port()), dir.string().c_str()); + UNSIGNED8 sb[512]{}; + std::memcpy(sb, uri, std::strlen(uri) + 1); + ADSHANDLE hC = 0; + REQUIRE(AdsConnect60(sb, ADS_REMOTE_SERVER, nullptr, nullptr, 0, &hC) + == AE_SUCCESS); + + UNSIGNED8 want[] = "BYID"; + + SUBCASE("empty ordered table: no GetKeyCount, local GotoRecord") { + UNSIGNED8 tn[] = "LAEMPTY.DBF"; + ADSHANDLE hT = 0; + REQUIRE(AdsOpenTable(hC, tn, nullptr, ADS_CDX, 0, 0, 0, 0, &hT) + == AE_SUCCESS); + ADSHANDLE hOrd = 0; + REQUIRE(AdsGetIndexHandle(hT, want, &hOrd) == AE_SUCCESS); + REQUIRE(AdsSetIndexOrderByHandle(hT, hOrd) == AE_SUCCESS); + + // rddads order: top first (installs the order), then bottom. + REQUIRE(AdsGotoTop(hOrd) == AE_SUCCESS); + const auto kc0 = la_op(0xB0); + const auto gr0 = la_op(0x58); + const auto tt0 = la_op(0x6A); + REQUIRE(AdsGotoBottom(hOrd) == AE_SUCCESS); + CHECK(la_op(0xB0) - kc0 == 0u); // (b) no key count frame + + UNSIGNED16 b = 0, e = 0; + REQUIRE(AdsAtBOF(hT, &b) == AE_SUCCESS); + REQUIRE(AdsAtEOF(hT, &e) == AE_SUCCESS); + CHECK(b == 1); + CHECK(e == 1); + + REQUIRE(AdsGotoRecord(hT, 0) == AE_SUCCESS); // (c) + CHECK(la_op(0x58) - gr0 == 0u); + b = 0; e = 0; + REQUIRE(AdsAtBOF(hT, &b) == AE_SUCCESS); + REQUIRE(AdsAtEOF(hT, &e) == AE_SUCCESS); + CHECK(b == 1); + CHECK(e == 1); + + UNSIGNED16 tt = 0; + REQUIRE(AdsGetTableType(hT, &tt) == AE_SUCCESS); // (a) + CHECK(tt == ADS_CDX); + CHECK(la_op(0x6A) - tt0 == 0u); + + // GO n>0 is never answered locally (a peer may have appended). + REQUIRE(AdsGotoRecord(hT, 1) == AE_SUCCESS); + CHECK(la_op(0x58) - gr0 == 1u); + + // The wire still agrees: the explicit key count is 0. + UNSIGNED32 kc = 99; + REQUIRE(AdsGetKeyCount(hOrd, 0, &kc) == AE_SUCCESS); + CHECK(kc == 0u); + REQUIRE(AdsCloseTable(hT) == AE_SUCCESS); + } + + SUBCASE("non-empty table: GO 0 and keycount still use the wire") { + UNSIGNED8 tn[] = "LAFULL.DBF"; + ADSHANDLE hT = 0; + REQUIRE(AdsOpenTable(hC, tn, nullptr, ADS_CDX, 0, 0, 0, 0, &hT) + == AE_SUCCESS); + ADSHANDLE hOrd = 0; + REQUIRE(AdsGetIndexHandle(hT, want, &hOrd) == AE_SUCCESS); + REQUIRE(AdsSetIndexOrderByHandle(hT, hOrd) == AE_SUCCESS); + REQUIRE(AdsGotoTop(hOrd) == AE_SUCCESS); + REQUIRE(AdsGotoBottom(hOrd) == AE_SUCCESS); + // Non-empty order: the key count is the real one (5), however + // it was obtained (wire or an existing piggyback). + UNSIGNED32 kcf = 0; + REQUIRE(AdsGetKeyCount(hOrd, 0, &kcf) == AE_SUCCESS); + CHECK(kcf == 5u); + const auto gr0 = la_op(0x58); + REQUIRE(AdsGotoRecord(hT, 0) == AE_SUCCESS); + CHECK(la_op(0x58) - gr0 == 1u); + UNSIGNED16 e = 0; + REQUIRE(AdsAtEOF(hT, &e) == AE_SUCCESS); + CHECK(e == 1); + REQUIRE(AdsCloseTable(hT) == AE_SUCCESS); + } + + SUBCASE("refresh after goto is local; after a lock it is not") { + UNSIGNED8 tn[] = "LAFULL.DBF"; + ADSHANDLE hT = 0; + REQUIRE(AdsOpenTable(hC, tn, nullptr, ADS_CDX, 0, 0, 0, 0, &hT) + == AE_SUCCESS); + const auto rf0 = la_op(0x68); + REQUIRE(AdsGotoRecord(hT, 3) == AE_SUCCESS); + REQUIRE(AdsRefreshRecord(hT) == AE_SUCCESS); // (d) + CHECK(la_op(0x68) - rf0 == 0u); + UNSIGNED32 rn = 0; + REQUIRE(AdsGetRecordNum(hT, ADS_IGNOREFILTERS, &rn) == AE_SUCCESS); + CHECK(rn == 3u); + double v = 0; + UNSIGNED8 f1[] = "ID"; + REQUIRE(AdsGetDouble(hT, f1, &v) == AE_SUCCESS); + CHECK(v == doctest::Approx(30.0)); + + // A second refresh has no fresh GotoRecord behind it: wire. + REQUIRE(AdsRefreshRecord(hT) == AE_SUCCESS); + CHECK(la_op(0x68) - rf0 == 1u); + + // GotoRecord, then a lock frame, then refresh: must re-read. + REQUIRE(AdsGotoRecord(hT, 4) == AE_SUCCESS); + REQUIRE(AdsLockRecord(hT, 4) == AE_SUCCESS); + REQUIRE(AdsRefreshRecord(hT) == AE_SUCCESS); + CHECK(la_op(0x68) - rf0 == 2u); + REQUIRE(AdsUnlockRecord(hT, 4) == AE_SUCCESS); + REQUIRE(AdsCloseTable(hT) == AE_SUCCESS); + } + + SUBCASE("record length remembered across re-opens") { + UNSIGNED8 tn[] = "LAFULL.DBF"; + const auto rl0 = la_op(0x6C); + for (int i = 0; i < 2; ++i) { + ADSHANDLE hT = 0; + // Exclusive: never parked, so the second open is a real + // re-open on a new handle. + REQUIRE(AdsOpenTable(hC, tn, nullptr, ADS_CDX, 0, 0, 0, + ADS_EXCLUSIVE, &hT) == AE_SUCCESS); + UNSIGNED32 len = 0; + REQUIRE(AdsGetRecordLength(hT, &len) == AE_SUCCESS); + CHECK(len == 19u); // 1 (delete flag) + 8 + 10 + REQUIRE(AdsCloseTable(hT) == AE_SUCCESS); + } + CHECK(la_op(0x6C) - rl0 <= 1u); + } + + REQUIRE(AdsDisconnect(hC) == AE_SUCCESS); + s.stop(); + fs::remove_all(dir, ec); +} diff --git a/tests/unit/network_teardown_batch_test.cpp b/tests/unit/network_teardown_batch_test.cpp index 5db28aa2..4dc8e914 100644 --- a/tests/unit/network_teardown_batch_test.cpp +++ b/tests/unit/network_teardown_batch_test.cpp @@ -359,7 +359,9 @@ TEST_CASE("Teardown batching: immutable metadata caches per handle") { } CHECK(tt == 2u); // ADS_CDX CHECK(rl > 0u); - CHECK(tb_op(0x6A) == tt0 + 1); + // Table type: answered from the opened name's extension (0 frames) + // when it has one; at most one frame otherwise. + CHECK(tb_op(0x6A) <= tt0 + 1); CHECK(tb_op(0x6C) == rl0 + 1); REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); From 36b50705ef731bc2d675e8568ec8eaa4dfc883c2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898283+github-actions[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:19:37 +0000 Subject: [PATCH 08/97] fix: session-pool MT races (locked remote-table store, conn_for_table) + repeated MT test Applied by apply-patch workflow, run 35932438992, started by bedipritpal. --- src/abi/ace_exports.cpp | 17 +++++++++++++++++ tests/CMakeLists.txt | 7 +++++++ tests/unit/network_pool_mt_test.cpp | 14 +++++++++++--- 3 files changed, 35 insertions(+), 3 deletions(-) diff --git a/src/abi/ace_exports.cpp b/src/abi/ace_exports.cpp index 62a9268b..0b19f472 100644 --- a/src/abi/ace_exports.cpp +++ b/src/abi/ace_exports.cpp @@ -4229,6 +4229,12 @@ bool& in_ri_check() { // Find the Connection that owns Table* t. Connection* conn_for_table(Table* t) { auto& s = state(); + // Each Connection's table map is changed under s.mu (open/close), so + // the scan must hold it too. Without it a navigation on one session + // (snapshot_ri_pks) read a map another session was inserting into + // (found by ThreadSanitizer under the session-pool test). Lock order + // s.mu -> registry matches register_object/release. + std::lock_guard lk(s.mu); Connection* found = nullptr; s.registry.for_each_handle([&](Handle, HandleKind k, void* p) { if (k != HandleKind::Connection || found) return; @@ -7394,6 +7400,12 @@ remote_table_store() { std::unique_ptr remote_table_take(openads::network::RemoteTable* rt) { if (rt == nullptr) return nullptr; + // remote_table_store() is shared by every thread (all lanes of a + // session pool): every access must hold s.mu. The park path in + // AdsCloseTable used to call this unlocked while other threads + // inserted/erased under s.mu -- a data race on the map that corrupted + // the heap under the 4-thread pool test (~1 run in 30 on Linux). + std::lock_guard lk(state().mu); auto& m = remote_table_store(); auto it = m.find(rt); if (it == m.end()) return nullptr; @@ -7404,6 +7416,7 @@ remote_table_take(openads::network::RemoteTable* rt) { void remote_table_forget(openads::network::RemoteTable* rt) { if (rt == nullptr) return; + std::lock_guard lk(state().mu); // see take() remote_table_store().erase(rt); } @@ -7422,6 +7435,7 @@ void remote_invalidate_index_parks(openads::network::RemoteConnection* rc) { if (c == l) return true; return false; }; + std::lock_guard lk(state().mu); // shared store for (auto& kv : remote_table_store()) { auto* rt = kv.first; if (rt == nullptr || !lane_match(rt->conn)) continue; @@ -7447,6 +7461,7 @@ std::string remote_pool_key(const std::string& name, // Parked tables keep no registry handle, so relations addressed by // handle could neither follow nor clean up -- real-close those. bool remote_table_has_relations(ADSHANDLE hTable) { + std::lock_guard lk(state().mu); auto& tbl = relation_map(); if (tbl.find(hTable) != tbl.end()) return true; for (auto& [parent, kids] : tbl) { @@ -10343,6 +10358,8 @@ UNSIGNED32 ENTRYPOINT AdsCheckExistence(ADSHANDLE hConn, UNSIGNED8* pucName, }; const std::string want = stem_of(name); if (!want.empty()) { + // Shared store: hold s.mu for the scan (memory only, no wire). + std::lock_guard lk_store(state().mu); for (auto& kv : remote_table_store()) { auto* rt = kv.first; if (rt == nullptr || rt->conn != ctx.remote) continue; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 0ab76c53..1fed7132 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -489,6 +489,13 @@ endif() add_test(NAME openads_unit_tests COMMAND openads_unit_tests -tce=*[slow]*,*flaky*) add_test(NAME openads_unit_tests_slow COMMAND openads_unit_tests -tc=*[slow]* -tce=*flaky*) +# Session-pool MT stress: the pool test in 30 fresh processes (each run +# already repeats its 4-thread phase 25x). Catches rare races that one +# in-suite pass misses. Every patch must keep this green. +if(UNIX) + add_test(NAME network_pool_mt_repeat COMMAND sh -c "i=0; while [ $i -lt 30 ]; do \"$0\" -tc=\"Session pool: MT*\" >/dev/null 2>&1 || { echo \"pool MT test failed on run $i\"; \"$0\" -tc=\"Session pool: MT*\"; exit 1; }; i=$((i+1)); done; echo \"pool MT test: 30 clean runs\"" $) +endif() + # SQL URI backend smoke (sqlite://): runs a focused doctest subset in CI. if(OPENADS_WITH_POSTGRESQL) add_test(NAME pg_live_smoke COMMAND openads_unit_tests diff --git a/tests/unit/network_pool_mt_test.cpp b/tests/unit/network_pool_mt_test.cpp index a4f493c3..6225273c 100644 --- a/tests/unit/network_pool_mt_test.cpp +++ b/tests/unit/network_pool_mt_test.cpp @@ -134,9 +134,17 @@ TEST_CASE("Session pool: MT threads open/read/close on parallel lanes") { if (AdsCloseTable(hT) != AE_SUCCESS) failures.fetch_add(1); } }; - std::vector th; - for (int w = 0; w < 4; ++w) th.emplace_back(worker, w); - for (auto& t : th) t.join(); + // Repeat the whole 4-thread phase: the old data race on the shared + // remote-table store (unlocked park-path erase) hit ~1 run in 30, so + // one pass per test run was not enough to catch it. Rounds after the + // first also exercise park/adopt across threads (fresh thread ids + // deal onto lanes again). + constexpr int kRounds = 25; + for (int round = 0; round < kRounds; ++round) { + std::vector th; + for (int w = 0; w < 4; ++w) th.emplace_back(worker, w); + for (auto& t : th) t.join(); + } CHECK(failures.load() == 0); // Pool + deferred accounting intact: reopen on the same handle works, From 3a9eeafffd62541380e002bbdf4f05867c78f8d0 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Wed, 23 Sep 2026 18:31:08 -0500 Subject: [PATCH 09/97] Create release-notes-test-p1-mtfix-1.md --- release-notes-test-p1-mtfix-1.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 release-notes-test-p1-mtfix-1.md diff --git a/release-notes-test-p1-mtfix-1.md b/release-notes-test-p1-mtfix-1.md new file mode 100644 index 00000000..a4b95e4f --- /dev/null +++ b/release-notes-test-p1-mtfix-1.md @@ -0,0 +1,16 @@ +# OpenADS test build test-p1-mtfix-1 (TEST ONLY - not for production) + +Branch: perf/safe-local-answers on bedipritpal/OpenADS. Not an official release. + +## What's in it +- Session-pool multi-thread fix 1: remote table store (park/adopt on close, AdsCheckExistence scan, index-park invalidation, relation check) now always holds the global lock. Fixes a heap corruption crash seen in network_pool_mt_test. +- Server multi-thread fix 2: conn_for_table holds the global lock while scanning connection table maps (ThreadSanitizer race with Connection::open_table). +- Patch 1: safe local answers on the remote path (fewer round trips at startup). +- Wire-frame logging kept on for diagnosis. +- network_pool_mt_test repeats its 4-thread phase 25 times; new network_pool_mt_repeat test runs it in 30 fresh processes. + +## What to test +- Several Vouch instances at once, many threads, against openads_serverd from this build (Linux tarball) and the client DLL from the Windows x86 zip. + +## Packages +- openads-test-p1-mtfix-1-windows-x86.zip, windows-x64.zip, linux-x64.tar.gz, macos-universal.tar.gz From 76661263248652bad91965fce053ea571b6d0778 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898283+github-actions[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 23:59:30 +0000 Subject: [PATCH 10/97] fix: store DBF logical byte T/F on remote writes (contributor patch) + remote logical regression test Applied by apply-patch workflow, run 35935776803, started by bedipritpal. --- src/abi/ace_exports.cpp | 5 +- src/drivers/dbf_common.cpp | 22 +++ tests/CMakeLists.txt | 1 + tests/unit/abi_remote_logical_write_test.cpp | 142 +++++++++++++++++++ 4 files changed, 169 insertions(+), 1 deletion(-) create mode 100644 tests/unit/abi_remote_logical_write_test.cpp diff --git a/src/abi/ace_exports.cpp b/src/abi/ace_exports.cpp index 0b19f472..6417fafd 100644 --- a/src/abi/ace_exports.cpp +++ b/src/abi/ace_exports.cpp @@ -13680,7 +13680,10 @@ UNSIGNED32 ENTRYPOINT AdsSetLogical(ADSHANDLE hTable, UNSIGNED8* pucField, return fail(openads::AE_COLUMN_NOT_FOUND, ""); } std::string fname = rt->fields[i].name; - return remote_buffered_set(rt, fname, bValue ? "1" : "0"); + // Send the DBF logical byte itself ('T'/'F'), not "1"/"0": servers + // that write strings through AdsSetString (twin handle) stored the + // '1' raw, which index FOR evaluation and DBFCDX read as .F. + return remote_buffered_set(rt, fname, bValue ? "T" : "F"); } Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); diff --git a/src/drivers/dbf_common.cpp b/src/drivers/dbf_common.cpp index 6d29c3c0..b4c7cd77 100644 --- a/src/drivers/dbf_common.cpp +++ b/src/drivers/dbf_common.cpp @@ -762,6 +762,28 @@ util::Result encode_field_string(const DbfField& f, return {}; } break; + case DbfFieldType::Logical: + // A logical arriving as a string (remote AdsSetLogical sends + // "1"/"0"; the server's twin-handle write path routes it through + // AdsSetString) must be normalized to the DBF 'T'/'F' byte. The + // generic memcpy below stored a raw '1', which the engine decoder + // (T/t/Y/y only) reads as .F. -- so a FOR "comple = .F." tag kept + // the record after it was set .T. -- while AdsGetLogical (which + // also accepts '1') reported .T. to the app. DBFCDX / SAP ADS read + // '1' as .F. as well. Blank stays blank (uninitialized logical). + if (f.length >= 1) { + const auto p = value.find_first_not_of(" ."); + if (p == std::string::npos) { + dst[0] = ' '; + } else { + const char c = value[p]; + dst[0] = (c == 'T' || c == 't' || c == 'Y' || c == 'y' || + c == '1') ? 'T' : 'F'; + } + for (std::size_t i = 1; i < f.length; ++i) dst[i] = ' '; + return {}; + } + break; case DbfFieldType::AutoInc: case DbfFieldType::Time: if (f.length >= 4) { diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 1fed7132..eb36ce96 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -106,6 +106,7 @@ add_executable(openads_unit_tests unit/abi_adt_dat_extension_test.cpp unit/abi_create_outside_datadir_test.cpp unit/abi_remote_create_table_test.cpp + unit/abi_remote_logical_write_test.cpp unit/abi_create_concurrent_test.cpp unit/abi_remote_create_stress_test.cpp unit/abi_openindex_create_race_test.cpp diff --git a/tests/unit/abi_remote_logical_write_test.cpp b/tests/unit/abi_remote_logical_write_test.cpp new file mode 100644 index 00000000..51a7262b --- /dev/null +++ b/tests/unit/abi_remote_logical_write_test.cpp @@ -0,0 +1,142 @@ +// Remote AdsSetLogical must store the DBF logical byte 'T'/'F' on disk. +// Mirrors a Vouch login table (USERID C10, LOGGED L) whose LOGGED field +// stayed blank / non-standard after being set over REMOTE. Covers the +// plain-table path and the indexed (twin handle, conditional tag) path. +#include "doctest.h" +#include "openads/ace.h" +#include "network/server.h" + +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace { + +ADSHANDLE rl_connect(const fs::path& dir, std::uint16_t port) { + std::string uri = "tcp://127.0.0.1:" + std::to_string(port) + "/" + + dir.generic_string(); + std::vector buf(uri.begin(), uri.end()); + buf.push_back(0); + ADSHANDLE hConn = 0; + REQUIRE(AdsConnect60(buf.data(), ADS_REMOTE_SERVER, + nullptr, nullptr, 0, &hConn) == 0); + return hConn; +} + +// Raw LOGGED byte of record `rec` (1-based), read straight from the file. +char rl_raw_logged(const fs::path& dbf, std::uint32_t rec) { + std::ifstream f(dbf, std::ios::binary); + REQUIRE(f.good()); + std::vector h(32); + f.read(reinterpret_cast(h.data()), 32); + const std::uint32_t hlen = h[8] | (h[9] << 8); + const std::uint32_t rlen = h[10] | (h[11] << 8); + f.seekg(static_cast(hlen + (rec - 1) * rlen + 1 + 10)); + char c = '?'; + f.read(&c, 1); + return c; +} + +void rl_set_logged(ADSHANDLE hT, std::uint32_t rec, bool v) { + UNSIGNED8 fL[] = "LOGGED"; + REQUIRE(AdsGotoRecord(hT, rec) == 0); + REQUIRE(AdsLockRecord(hT, rec) == 0); + CHECK(AdsSetLogical(hT, fL, v ? 1 : 0) == 0); + CHECK(AdsWriteRecord(hT) == 0); + REQUIRE(AdsUnlockRecord(hT, rec) == 0); +} + +struct RlFixture { + fs::path dir; + openads::network::Server srv; + ADSHANDLE hConn = 0; + ADSHANDLE hT = 0; + UNSIGNED8 name[16] = "logina.dbf"; + + explicit RlFixture(const char* sub) { + dir = fs::temp_directory_path() / sub; + std::error_code ec; + fs::remove_all(dir, ec); + fs::create_directories(dir); + REQUIRE(srv.start("127.0.0.1", 0).has_value()); + hConn = rl_connect(dir, srv.port()); + UNSIGNED8 def[] = "USERID,C,10,0;LOGGED,L,1,0"; + REQUIRE(AdsCreateTable(hConn, name, nullptr, ADS_CDX, ADS_ANSI, + 0, 0, 0, def, &hT) == 0); + UNSIGNED8 fU[] = "USERID"; + const char* ids[] = {"SUPER00002", "TRAVL00002", "TVL-100002"}; + for (const char* id : ids) { + REQUIRE(AdsAppendRecord(hT) == 0); + REQUIRE(AdsSetString(hT, fU, + reinterpret_cast(const_cast(id)), 10) == 0); + REQUIRE(AdsWriteRecord(hT) == 0); + } + REQUIRE(AdsCloseTable(hT) == 0); + hT = 0; + REQUIRE(AdsOpenTable(hConn, name, name, ADS_CDX, ADS_ANSI, + ADS_COMPATIBLE_LOCKING, 0, ADS_SHARED, &hT) == 0); + } + void close_table() { if (hT) { AdsCloseTable(hT); hT = 0; } } + ~RlFixture() { + close_table(); + if (hConn) AdsDisconnect(hConn); + std::error_code ec; + fs::remove_all(dir, ec); + } +}; + +} // namespace + +TEST_CASE("remote AdsSetLogical stores T/F bytes (no index)") { + RlFixture fx("openads_remote_logical_plain"); + rl_set_logged(fx.hT, 1, true); + rl_set_logged(fx.hT, 2, false); + fx.close_table(); + const fs::path dbf = fx.dir / "logina.dbf"; + const char b1 = rl_raw_logged(dbf, 1); + const char b2 = rl_raw_logged(dbf, 2); + const char b3 = rl_raw_logged(dbf, 3); + INFO("raw LOGGED bytes: rec1=0x" << std::hex << int(static_cast(b1)) + << " rec2=0x" << int(static_cast(b2)) + << " rec3=0x" << int(static_cast(b3))); + CHECK(b1 == 'T'); + CHECK(b2 == 'F'); + CHECK(b3 == ' '); +} + +TEST_CASE("remote AdsSetLogical stores T/F and maintains FOR tags (indexed)") { + RlFixture fx("openads_remote_logical_idx"); + UNSIGNED8 bag[] = "logina.cdx"; + UNSIGNED8 tIn[] = "LOGIN"; + UNSIGNED8 tOut[] = "LOGOUT"; + UNSIGNED8 expr[] = "USERID"; + UNSIGNED8 cIn[] = "LOGGED"; + UNSIGNED8 cOut[] = ".NOT. LOGGED"; + ADSHANDLE hIn = 0, hOut = 0; + REQUIRE(AdsCreateIndex61(fx.hT, bag, tIn, expr, cIn, nullptr, + ADS_COMPOUND, 0, &hIn) == 0); + REQUIRE(AdsCreateIndex61(fx.hT, bag, tOut, expr, cOut, nullptr, + ADS_COMPOUND, 0, &hOut) == 0); + UNSIGNED32 n = 99; + REQUIRE(AdsGetKeyCount(hIn, ADS_RESPECTFILTERS, &n) == 0); + CHECK(n == 0u); + + rl_set_logged(fx.hT, 2, true); + + n = 99; + REQUIRE(AdsGetKeyCount(hIn, ADS_RESPECTFILTERS, &n) == 0); + CHECK(n == 1u); + n = 99; + REQUIRE(AdsGetKeyCount(hOut, ADS_RESPECTFILTERS, &n) == 0); + CHECK(n == 2u); + + fx.close_table(); + const char b2 = rl_raw_logged(fx.dir / "logina.dbf", 2); + INFO("raw LOGGED byte rec2=0x" << std::hex << int(static_cast(b2))); + CHECK(b2 == 'T'); +} From fd63f6a305512f3997bc92669d294695f65cf93c Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Wed, 23 Sep 2026 19:03:53 -0500 Subject: [PATCH 11/97] Create release-notes-1.09.68-mtfix2.md --- release-notes-1.09.68-mtfix2.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 release-notes-1.09.68-mtfix2.md diff --git a/release-notes-1.09.68-mtfix2.md b/release-notes-1.09.68-mtfix2.md new file mode 100644 index 00000000..b924cf9c --- /dev/null +++ b/release-notes-1.09.68-mtfix2.md @@ -0,0 +1,19 @@ +# OpenADS test build 1.09.68-mtfix2 (TEST ONLY - not for production) + +Branch: perf/safe-local-answers on bedipritpal/OpenADS. Based on 1.09.68. Not an official release. + +## What's in it +- Session-pool multi-thread fix 1: the remote table store (park/adopt on close, AdsCheckExistence scan, index-park invalidation, relation check) now always holds the global lock. Fixes a heap corruption crash seen in network_pool_mt_test. +- Server multi-thread fix 2: conn_for_table holds the global lock while scanning connection table maps (ThreadSanitizer race with Connection::open_table). +- Logical fields over remote (contributor patch): remote writes now store the DBF byte 'T'/'F' instead of '1'/'0', so FOR-conditional indexes, DBFCDX and SAP ADS read them correctly. New regression test abi_remote_logical_write_test. +- Patch 1: safe local answers on the remote path (fewer round trips at startup). +- Wire-frame logging kept on for diagnosis. +- network_pool_mt_test repeats its 4-thread phase 25 times; new network_pool_mt_repeat test runs it in 30 fresh processes. + +## What to test +- Several Vouch instances at once, many threads, against openads_serverd from this build (Linux tarball) and the client DLL from the Windows x86 zip. +- LOGGED turns T on login; the license limit holds. +- Startup time over remote. + +## Packages +- openads-1.09.68-mtfix2-windows-x86.zip, windows-x64.zip, linux-x64.tar.gz, macos-universal.tar.gz From 0c7ebfc64b21cbdea3383d3e18297b44b3177d2b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898283+github-actions[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 03:40:57 +0000 Subject: [PATCH 12/97] perf: 1.5s still-empty window for Seek/GO on server-certified empty tables (OPENADS_EMPTY_TTL_MS) Applied by apply-patch workflow, run 35951982962, started by bedipritpal. --- src/abi/ace_exports.cpp | 87 +++++++++++++++- src/network/client.cpp | 12 +++ src/network/client.h | 14 +++ tests/CMakeLists.txt | 1 + tests/unit/network_empty_window_test.cpp | 119 ++++++++++++++++++++++ tests/unit/network_local_answers_test.cpp | 9 +- 6 files changed, 236 insertions(+), 6 deletions(-) create mode 100644 tests/unit/network_empty_window_test.cpp diff --git a/src/abi/ace_exports.cpp b/src/abi/ace_exports.cpp index 6417fafd..0bb7b2db 100644 --- a/src/abi/ace_exports.cpp +++ b/src/abi/ace_exports.cpp @@ -1686,6 +1686,64 @@ bool remote_cursor_proven_empty(const openads::network::RemoteTable* rt) { rt->filter_expr.empty() && rt->aof_expr.empty(); } +// "Still empty" window (user-approved trade-off, 23/09/2026): after the +// server certifies a table physically EMPTY (record count 0) with the +// cursor on no row (BOF+EOF), Seek / GO n on it are answered "not found" +// locally for a short time instead of paying a round trip each. Vouch +// probes its empty per-user settings tables ~280 times per startup. +// Risk accepted: a record another STATION adds inside the window is seen +// only when the window ends. This station's own writes (any append / +// field write / SQL on this connection) close the window at once. +// OPENADS_EMPTY_TTL_MS: window length, default 1500, 0 = off, max 2000. +std::uint32_t remote_empty_ttl_ms() { + static const std::uint32_t v = [] { + const char* e = std::getenv("OPENADS_EMPTY_TTL_MS"); + if (e == nullptr || *e == 0) return 1500u; + long x = std::strtol(e, nullptr, 10); + if (x < 0) x = 0; + if (x > 2000) x = 2000; + return static_cast(x); + }(); + return v; +} + +bool remote_empty_window(const openads::network::RemoteTable* rt) { + if (rt == nullptr || rt->conn == nullptr) return false; + const std::uint32_t ttl = remote_empty_ttl_ms(); + if (ttl == 0) return false; + if (rt->row_valid || !rt->pending_sets.empty() || rt->write_dirty) + return false; + if (!(rt->bound_bof_ok && rt->bound_bof && + rt->bound_eof_ok && rt->bound_eof)) + return false; + // The count must come from the same certification as the bounds. + if (!(rt->count_bound_ok && rt->count_bound == 0 && + rt->count_bound_seq == rt->bound_seq)) + return false; + if (rt->bound_data_epoch != rt->conn->data_epoch()) return false; + const auto age = std::chrono::steady_clock::now() - rt->bound_at; + return age >= std::chrono::steady_clock::duration::zero() && + age <= std::chrono::milliseconds(ttl); +} + +// Leave the table exactly as a wire answer on an empty table would: +// no row, both limits, recno/count unchanged, certified at the current +// seq (bound_at is NOT refreshed: the window runs from the last real +// server answer, never extended by local answers). +void remote_empty_restamp(openads::network::RemoteTable* rt) { + const std::uint64_t seq = rt->conn->nav_seq(); + rt->row_valid = false; + rt->invalidate_prefetch(); + rt->nav_at_bof = true; + rt->nav_at_eof = true; + rt->nav_not_bof = false; + rt->nav_not_eof = false; + rt->bound_seq = seq; + rt->recno_bound_seq = seq; + rt->count_bound_seq = seq; + rt->last_nav = 0; +} + // Memo key for AdsGetRecordLength re-opens: lowercased table name plus // the whole schema. Empty (= no memo) when the schema is not known. std::string remote_record_length_key(const openads::network::RemoteTable* rt) { @@ -10229,17 +10287,26 @@ UNSIGNED32 ENTRYPOINT AdsGotoRecord(ADSHANDLE hTable, UNSIGNED32 ulRecord) { // Answer it locally. GO n>0 always goes to the wire (a peer may // have appended record n). Only for record count 0: on a // non-empty table GO 0 moves the server's engine cursor. - const bool empty_goto = + const bool empty_goto_exact = ulRecord == 0u && remote_cursor_proven_empty(rt) && rt->count_bound_ok && rt->count_bound == 0 && rt->count_bound_seq == rt->conn->nav_seq(); + const bool empty_goto = + empty_goto_exact || remote_empty_window(rt); rt->goto_row_fresh = false; if (empty_goto) { - cli_trace_tbl(rt, "AdsGotoRecord", "served locally (empty table)"); - rt->invalidate_prefetch(); - // A real frame would have expired the duplicate-nav stamp. - rt->last_nav = 0; + if (empty_goto_exact) { + cli_trace_tbl(rt, "AdsGotoRecord", "served locally (empty table)"); + rt->invalidate_prefetch(); + // A real frame would have expired the duplicate-nav stamp. + rt->last_nav = 0; + } else { + cli_trace_tbl(rt, "AdsGotoRecord", + "empty window: served locally want=%u", + ulRecord); + remote_empty_restamp(rt); + } } else { auto r = rt->conn->goto_record(rt, ulRecord); if (!r) return fail(r.error()); @@ -20310,6 +20377,16 @@ UNSIGNED32 ENTRYPOINT AdsSeek(ADSHANDLE hIndex, cli_trace_tbl(ri->parent, "AdsSeek", "key=%.24s", key.c_str()); } + if (ri->parent != nullptr && remote_empty_window(ri->parent)) { + cli_trace_tbl(ri->parent, "AdsSeek", + "empty window: not found (local)"); + remote_empty_restamp(ri->parent); + ri->parent->found_cached = true; + ri->parent->current_found = false; + if (pbFound) *pbFound = 0; + (void)u16KeyType; + return ok(); + } // RCB 07/14/2026: M12.24 -- pass the parent so the SeekAck's row trailer // lands straight in the row cache. Without it row_valid stays false and // the caller's next AdsGetField pays a FetchCurrentRow round-trip, i.e. diff --git a/src/network/client.cpp b/src/network/client.cpp index 9e9548e4..fc9dff3f 100644 --- a/src/network/client.cpp +++ b/src/network/client.cpp @@ -245,6 +245,16 @@ void set_frame_trace_hook(FrameTraceHook hook) noexcept { util::Result RemoteConnection::request(const Frame& f) { std::lock_guard lk(mu_); frame_seq_.fetch_add(1, std::memory_order_relaxed); + switch (f.opcode) { + case Opcode::AppendBlank: case Opcode::SetField: + case Opcode::SetFields: case Opcode::RecallRecord: + case Opcode::ExecuteSQL: case Opcode::Reindex: + case Opcode::PackTable: case Opcode::ZapTable: + data_epoch_.fetch_add(1, std::memory_order_relaxed); + break; + default: + break; + } const FrameTraceHook trace_hook = g_frame_trace_hook.load(std::memory_order_relaxed); const auto trace_t0 = trace_hook != nullptr @@ -663,6 +673,8 @@ static void apply_bound_trailer(RemoteTable* rt, bool bof, bool eof, rt->recno_bound = recno; rt->recno_bound_ok = true; rt->recno_bound_seq = seq; + rt->bound_at = std::chrono::steady_clock::now(); + rt->bound_data_epoch = rt->conn->data_epoch(); if (has_count) { rt->count_bound = reccount; rt->count_bound_ok = true; diff --git a/src/network/client.h b/src/network/client.h index c1022728..68e69bd8 100644 --- a/src/network/client.h +++ b/src/network/client.h @@ -554,6 +554,14 @@ class RemoteConnection { return frame_seq_.load(std::memory_order_relaxed); } + // Count of frames that can ADD rows or change row contents on the + // server (append, field writes, recall, SQL, pack/zap/reindex) sent on + // this connection. The empty-table window (see AdsSeek) closes as soon + // as this moves, so this station never misses its own new record. + std::uint64_t data_epoch() const noexcept { + return data_epoch_.load(std::memory_order_relaxed); + } + // Per-connection record-length memo for re-opens of the same table. // Keyed by lowercased table name + the full schema (names, types, // widths, decimals) from the open ack, so any restructure changes @@ -599,6 +607,7 @@ class RemoteConnection { std::atomic nav_seq_{0}; // See frame_seq(): bumped by every request(). std::atomic frame_seq_{0}; + std::atomic data_epoch_{0}; std::mutex reclen_mu_; std::unordered_map reclen_memo_; // Raw HelloAck payload (see above). Written once during @@ -753,6 +762,11 @@ struct RemoteTable { std::uint32_t count_bound = 0; bool count_bound_ok = false; std::uint64_t count_bound_seq = 0; + // Wall time of the last bound tail (server certification) and the + // connection data_epoch() at that moment. Drive the short "still + // empty" window for physically empty tables (AdsSeek/AdsGotoRecord). + std::chrono::steady_clock::time_point bound_at{}; + std::uint64_t bound_data_epoch = 0; // Last wire nav op on this table (0 = none/other, 1 = GotoTop, // 2 = GotoBottom), whether it produced a row, and the connection // nav_seq_ at the time. Serves two WAN-chattiness kills with one diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index eb36ce96..e61c75b4 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -332,6 +332,7 @@ add_executable(openads_unit_tests unit/network_nav_batch_test.cpp unit/network_use_budget_test.cpp unit/network_local_answers_test.cpp + unit/network_empty_window_test.cpp unit/network_frame_trace_test.cpp unit/network_teardown_batch_test.cpp unit/network_pool_mt_test.cpp diff --git a/tests/unit/network_empty_window_test.cpp b/tests/unit/network_empty_window_test.cpp new file mode 100644 index 00000000..2b0f2c8f --- /dev/null +++ b/tests/unit/network_empty_window_test.cpp @@ -0,0 +1,119 @@ +// tests/unit/network_empty_window_test.cpp +// Short "still empty" window (patch 2): after the server certifies a +// table physically empty, Seek / GO n are answered locally for up to +// OPENADS_EMPTY_TTL_MS (default 1500 ms). Own writes close the window +// immediately; after the window the wire is used again. +#include "doctest.h" +#include "mgmt/mg_stats.h" +#include "network/server.h" +#include "openads/ace.h" + +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace { + +std::uint64_t ew_op(std::uint8_t op) { + return openads::mgmt::process_mg_stats() + .op_timing[op] + .count.load(std::memory_order_relaxed); +} + +} // namespace + +TEST_CASE("Remote empty window: local not-found, closes on own write/TTL") { + auto dir = fs::temp_directory_path() / "openads_emptywin"; + std::error_code ec; + fs::remove_all(dir, ec); + fs::create_directories(dir); + + UNSIGNED8 srv[512]{}; + std::memcpy(srv, dir.string().c_str(), dir.string().size()); + ADSHANDLE hC0 = 0; + REQUIRE(AdsConnect60(srv, ADS_LOCAL_SERVER, nullptr, nullptr, 0, &hC0) + == AE_SUCCESS); + UNSIGNED8 def[] = "ID,N,8,0"; + UNSIGNED8 tn0[] = "EW.DBF"; + ADSHANDLE hT0 = 0; + REQUIRE(AdsCreateTable(hC0, tn0, nullptr, ADS_CDX, 0, 0, 0, 0, def, &hT0) + == AE_SUCCESS); + ADSHANDLE hI0 = 0; + UNSIGNED8 bag[] = "EW.CDX"; + UNSIGNED8 tag[] = "BYID"; + UNSIGNED8 exp[] = "STR(ID,8)"; + REQUIRE(AdsCreateIndex61(hT0, bag, tag, exp, nullptr, nullptr, + ADS_COMPOUND, 512, &hI0) == AE_SUCCESS); + REQUIRE(AdsCloseTable(hT0) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hC0) == AE_SUCCESS); + + openads::network::Server s; + REQUIRE(s.start("127.0.0.1", 0).has_value()); + char uri[512]{}; + std::snprintf(uri, sizeof(uri), "tcp://127.0.0.1:%u/%s", + static_cast(s.port()), dir.string().c_str()); + UNSIGNED8 sb[512]{}; + std::memcpy(sb, uri, std::strlen(uri) + 1); + ADSHANDLE hC = 0; + REQUIRE(AdsConnect60(sb, ADS_REMOTE_SERVER, nullptr, nullptr, 0, &hC) + == AE_SUCCESS); + + UNSIGNED8 tn[] = "EW.DBF"; + ADSHANDLE hT = 0; + REQUIRE(AdsOpenTable(hC, tn, nullptr, ADS_CDX, 0, 0, 0, 0, &hT) + == AE_SUCCESS); + ADSHANDLE hOrd = 0; + UNSIGNED8 want[] = "BYID"; + REQUIRE(AdsGetIndexHandle(hT, want, &hOrd) == AE_SUCCESS); + REQUIRE(AdsSetIndexOrderByHandle(hT, hOrd) == AE_SUCCESS); + REQUIRE(AdsGotoTop(hOrd) == AE_SUCCESS); // server certifies empty + + UNSIGNED8 key[] = " 7"; + const auto sk0 = ew_op(0x90); + const auto gr0 = ew_op(0x58); + UNSIGNED16 found = 9; + REQUIRE(AdsSeek(hOrd, key, 8, ADS_STRINGKEY, ADS_HARDSEEK, &found) + == AE_SUCCESS); + CHECK(found == 0); + REQUIRE(AdsGotoRecord(hT, 1) == AE_SUCCESS); + CHECK(ew_op(0x90) - sk0 == 0u); + CHECK(ew_op(0x58) - gr0 == 0u); + UNSIGNED16 b = 0, e = 0; + REQUIRE(AdsAtBOF(hT, &b) == AE_SUCCESS); + REQUIRE(AdsAtEOF(hT, &e) == AE_SUCCESS); + CHECK(b == 1); + CHECK(e == 1); + + SUBCASE("own append closes the window") { + UNSIGNED8 f1[] = "ID"; + REQUIRE(AdsAppendRecord(hT) == AE_SUCCESS); + REQUIRE(AdsSetDouble(hT, f1, 7) == AE_SUCCESS); + REQUIRE(AdsWriteRecord(hT) == AE_SUCCESS); + const auto sk1 = ew_op(0x90); + found = 0; + REQUIRE(AdsSeek(hOrd, key, 8, ADS_STRINGKEY, ADS_HARDSEEK, &found) + == AE_SUCCESS); + CHECK(ew_op(0x90) - sk1 == 1u); + CHECK(found == 1); + } + + SUBCASE("window ends after the TTL") { + std::this_thread::sleep_for(std::chrono::milliseconds(2100)); + const auto sk1 = ew_op(0x90); + found = 9; + REQUIRE(AdsSeek(hOrd, key, 8, ADS_STRINGKEY, ADS_HARDSEEK, &found) + == AE_SUCCESS); + CHECK(ew_op(0x90) - sk1 == 1u); + CHECK(found == 0); + } + + REQUIRE(AdsCloseTable(hT) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hC) == AE_SUCCESS); + s.stop(); + fs::remove_all(dir, ec); +} diff --git a/tests/unit/network_local_answers_test.cpp b/tests/unit/network_local_answers_test.cpp index e1fd2ee6..0730194e 100644 --- a/tests/unit/network_local_answers_test.cpp +++ b/tests/unit/network_local_answers_test.cpp @@ -10,6 +10,9 @@ // Each case also checks the answers match what the wire would say, and // that the wire is still used when the proof does not hold. #include "doctest.h" + +#include +#include #include "mgmt/mg_stats.h" #include "network/server.h" #include "openads/ace.h" @@ -119,7 +122,11 @@ TEST_CASE("Remote local answers: empty tables, table type, refresh") { CHECK(tt == ADS_CDX); CHECK(la_op(0x6A) - tt0 == 0u); - // GO n>0 is never answered locally (a peer may have appended). + // GO n>0 is not answered by these local answers (a peer may have + // appended). The separate still-empty window (OPENADS_EMPTY_TTL_MS, + // at most 2s) may answer it right after an empty certification, so + // let that window lapse first. + std::this_thread::sleep_for(std::chrono::milliseconds(2100)); REQUIRE(AdsGotoRecord(hT, 1) == AE_SUCCESS); CHECK(la_op(0x58) - gr0 == 1u); From bfa796bd6f0997c03ab8bd2427fc4a8629cf01a1 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Wed, 23 Sep 2026 22:47:41 -0500 Subject: [PATCH 13/97] Create release-notes-1.09.68-mtfix3.md --- release-notes-1.09.68-mtfix3.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 release-notes-1.09.68-mtfix3.md diff --git a/release-notes-1.09.68-mtfix3.md b/release-notes-1.09.68-mtfix3.md new file mode 100644 index 00000000..e17639e2 --- /dev/null +++ b/release-notes-1.09.68-mtfix3.md @@ -0,0 +1,16 @@ +# OpenADS test build 1.09.68-mtfix3 (TEST ONLY - not for production) + +Branch: perf/safe-local-answers on bedipritpal/OpenADS. Based on 1.09.68. Not an official release. + +## What's in it +- Everything in 1.09.68-mtfix2 (session-pool MT fixes 1 and 2, contributor logical-field fix, patch 1 safe local answers). +- Patch 2: still-empty window. After the server confirms a table is empty, Seek / GO n on that table are answered locally for up to 1.5 s instead of a round trip each. A new record added by another station shows up once the window ends; this station's own writes close the window at once. OPENADS_EMPTY_TTL_MS sets the window in ms (0 disables, max 2000). Trace lines mark each local empty answer. +- Wire-frame logging kept on for diagnosis. +- network_pool_mt_test (25x 4-thread phase) and network_pool_mt_repeat (30 fresh processes) pass. + +## What to test +- Several Vouch instances at once, many threads, against openads_serverd from this build (Linux tarball) and the client DLL from the Windows x86 zip. +- Startup time over remote compared with mtfix2. + +## Packages +- openads-1.09.68-mtfix3-windows-x86.zip, windows-x64.zip, linux-x64.tar.gz, macos-universal.tar.gz From 8054cfbc96840c06aff98ec79156a0d78831c2ca Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898283+github-actions[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:17:39 +0000 Subject: [PATCH 14/97] fix: remote Reindex rebuilds bags bound on the server ABI twin + remote reindex test Applied by apply-patch workflow, run 35960800500, started by bedipritpal. --- src/network/session.cpp | 14 +++ tests/CMakeLists.txt | 1 + tests/unit/network_remote_reindex_test.cpp | 130 +++++++++++++++++++++ 3 files changed, 145 insertions(+) create mode 100644 tests/unit/network_remote_reindex_test.cpp diff --git a/src/network/session.cpp b/src/network/session.cpp index ac5de9e8..61f33491 100644 --- a/src/network/session.cpp +++ b/src/network/session.cpp @@ -4967,6 +4967,20 @@ DispatchResult Session::dispatch(const Frame& f) { if (!tbl) { reply = err("Reindex: lookup failed"); break; } auto r = tbl->reindex(); if (!r) { reply = err("Reindex: reindex failed"); break; } + // The table's bags (production .cdx/.z01 and any OpenIndex + // bag) are bound on the ABI twin (tbls_h_), not on the engine + // table, so the engine reindex above finds no index and is a + // no-op. Rebuild the twin's bags too, as Pack/Zap already do. + // Flush first so the rebuild reads every committed row; + // AdsGotoTop refreshes the twin's cached record count. + if (auto hit = tbls_h_.find(id); hit != tbls_h_.end()) { + if (auto fl = tbl->flush(); !fl) { + reply = err("Reindex: flush failed"); break; + } + (void)AdsGotoTop(hit->second); + UNSIGNED32 rrc = AdsReindex(hit->second); + if (rrc != 0) { reply = err("Reindex", rrc); break; } + } reply.opcode = Opcode::ReindexAck; break; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index e61c75b4..d0e536a1 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -333,6 +333,7 @@ add_executable(openads_unit_tests unit/network_use_budget_test.cpp unit/network_local_answers_test.cpp unit/network_empty_window_test.cpp + unit/network_remote_reindex_test.cpp unit/network_frame_trace_test.cpp unit/network_teardown_batch_test.cpp unit/network_pool_mt_test.cpp diff --git a/tests/unit/network_remote_reindex_test.cpp b/tests/unit/network_remote_reindex_test.cpp new file mode 100644 index 00000000..dee1f9ab --- /dev/null +++ b/tests/unit/network_remote_reindex_test.cpp @@ -0,0 +1,130 @@ +// tests/unit/network_remote_reindex_test.cpp +// Remote AdsReindex must rebuild the table's bags. On the server the +// bags are bound on the ABI twin handle, not on the engine table, so a +// reindex of the engine table alone rebuilt nothing and still reported +// success (rddads ordListRebuild ignores the return code anyway). +#include "doctest.h" +#include "network/server.h" +#include "openads/ace.h" + +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace { + +UNSIGNED32 rr_local_key_count(const fs::path& dir) { + UNSIGNED8 srv[512]{}; + std::memcpy(srv, dir.string().c_str(), dir.string().size()); + ADSHANDLE hC = 0; + REQUIRE(AdsConnect60(srv, ADS_LOCAL_SERVER, nullptr, nullptr, 0, &hC) + == AE_SUCCESS); + UNSIGNED8 tn[] = "RR.DBF"; + ADSHANDLE hT = 0; + REQUIRE(AdsOpenTable(hC, tn, nullptr, ADS_CDX, 0, 0, 0, 0, &hT) + == AE_SUCCESS); + UNSIGNED8 tag[] = "BYID"; + ADSHANDLE hI = 0; + REQUIRE(AdsGetIndexHandle(hT, tag, &hI) == AE_SUCCESS); + UNSIGNED32 n = 0; + REQUIRE(AdsGetKeyCount(hI, ADS_IGNOREFILTERS, &n) == AE_SUCCESS); + REQUIRE(AdsCloseTable(hT) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hC) == AE_SUCCESS); + return n; +} + +void rr_append(ADSHANDLE hT, double id) { + UNSIGNED8 fld[] = "ID"; + REQUIRE(AdsAppendRecord(hT) == AE_SUCCESS); + REQUIRE(AdsSetDouble(hT, fld, id) == AE_SUCCESS); + REQUIRE(AdsWriteRecord(hT) == AE_SUCCESS); +} + +} // namespace + +TEST_CASE("Remote AdsReindex rebuilds the bag bound on the server twin") { + auto dir = fs::temp_directory_path() / "openads_remote_reindex"; + std::error_code ec; + fs::remove_all(dir, ec); + fs::create_directories(dir); + + // Stage a stale production bag locally: build it over 2 rows, save a + // copy, add 2 more rows, then put the 2-key copy back. + { + UNSIGNED8 srv[512]{}; + std::memcpy(srv, dir.string().c_str(), dir.string().size()); + ADSHANDLE hC = 0; + REQUIRE(AdsConnect60(srv, ADS_LOCAL_SERVER, nullptr, nullptr, 0, + &hC) == AE_SUCCESS); + UNSIGNED8 def[] = "ID,N,8,0"; + UNSIGNED8 tn[] = "RR.DBF"; + ADSHANDLE hT = 0; + REQUIRE(AdsCreateTable(hC, tn, nullptr, ADS_CDX, 0, 0, 0, 0, def, + &hT) == AE_SUCCESS); + UNSIGNED8 bag[] = "RR.CDX"; + UNSIGNED8 tag[] = "BYID"; + UNSIGNED8 exp[] = "STR(ID,8)"; + ADSHANDLE hI = 0; + REQUIRE(AdsCreateIndex61(hT, bag, tag, exp, nullptr, nullptr, + ADS_COMPOUND, 512, &hI) == AE_SUCCESS); + rr_append(hT, 2); + rr_append(hT, 1); + REQUIRE(AdsCloseTable(hT) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hC) == AE_SUCCESS); + } + REQUIRE(rr_local_key_count(dir) == 2); + fs::copy_file(dir / "RR.CDX", dir / "RR.OLD", + fs::copy_options::overwrite_existing); + { + UNSIGNED8 srv[512]{}; + std::memcpy(srv, dir.string().c_str(), dir.string().size()); + ADSHANDLE hC = 0; + REQUIRE(AdsConnect60(srv, ADS_LOCAL_SERVER, nullptr, nullptr, 0, + &hC) == AE_SUCCESS); + UNSIGNED8 tn[] = "RR.DBF"; + ADSHANDLE hT = 0; + REQUIRE(AdsOpenTable(hC, tn, nullptr, ADS_CDX, 0, 0, 0, 0, &hT) + == AE_SUCCESS); + rr_append(hT, 4); + rr_append(hT, 3); + REQUIRE(AdsCloseTable(hT) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hC) == AE_SUCCESS); + } + fs::copy_file(dir / "RR.OLD", dir / "RR.CDX", + fs::copy_options::overwrite_existing); + REQUIRE(rr_local_key_count(dir) == 2); // stale: 4 rows, 2 keys + + // Remote REINDEX, the way rddads ordListRebuild issues it. + { + openads::network::Server s; + REQUIRE(s.start("127.0.0.1", 0).has_value()); + char uri[512]{}; + std::snprintf(uri, sizeof(uri), "tcp://127.0.0.1:%u/%s", + static_cast(s.port()), + dir.string().c_str()); + UNSIGNED8 sb[512]{}; + std::memcpy(sb, uri, std::strlen(uri)); + ADSHANDLE hC = 0; + REQUIRE(AdsConnect60(sb, ADS_REMOTE_SERVER, nullptr, nullptr, 0, + &hC) == AE_SUCCESS); + UNSIGNED8 tn[] = "RR.DBF"; + ADSHANDLE hT = 0; + REQUIRE(AdsOpenTable(hC, tn, nullptr, ADS_CDX, 0, 0, 0, 0, &hT) + == AE_SUCCESS); + UNSIGNED8 tag[] = "BYID"; + ADSHANDLE hI = 0; + REQUIRE(AdsGetIndexHandle(hT, tag, &hI) == AE_SUCCESS); + REQUIRE(AdsSetIndexOrderByHandle(hT, hI) == AE_SUCCESS); + REQUIRE(AdsGotoTop(hT) == AE_SUCCESS); + REQUIRE(AdsReindex(hT) == AE_SUCCESS); + REQUIRE(AdsCloseTable(hT) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hC) == AE_SUCCESS); + s.stop(); + } + + CHECK(rr_local_key_count(dir) == 4); + fs::remove_all(dir, ec); +} From 18eff36a36e75d5c41458f9a61810204066dbc41 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Thu, 24 Sep 2026 01:19:03 -0500 Subject: [PATCH 15/97] Create release-notes-1.09.68-mtfix4.md --- release-notes-1.09.68-mtfix4.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 release-notes-1.09.68-mtfix4.md diff --git a/release-notes-1.09.68-mtfix4.md b/release-notes-1.09.68-mtfix4.md new file mode 100644 index 00000000..4f651cae --- /dev/null +++ b/release-notes-1.09.68-mtfix4.md @@ -0,0 +1,18 @@ +# OpenADS test build 1.09.68-mtfix4 (TEST ONLY - not for production) + +Branch: perf/safe-local-answers on bedipritpal/OpenADS. Based on 1.09.68. Not an official release. + +## What's in it +- Everything in 1.09.68-mtfix3 (session-pool MT fixes 1 and 2, contributor logical-field fix, patch 1 safe local answers, patch 2 still-empty window). +- Remote REINDEX fix: remote AdsReindex / ordListRebuild now really rebuilds the table's .cdx/.z01 bags on the server (before, it rebuilt nothing and still reported success). New test network_remote_reindex_test. +- Patch 2: still-empty window. After the server confirms a table is empty, Seek / GO n on that table are answered locally for up to 1.5 s instead of a round trip each. A new record added by another station shows up once the window ends; this station's own writes close the window at once. OPENADS_EMPTY_TTL_MS sets the window in ms (0 disables, max 2000). Trace lines mark each local empty answer. +- Wire-frame logging kept on for diagnosis. +- network_pool_mt_test (25x 4-thread phase) and network_pool_mt_repeat (30 fresh processes) pass. + +## What to test +- Several Vouch instances at once, many threads, against openads_serverd from this build (Linux tarball) and the client DLL from the Windows x86 zip. +- REINDEX over remote: .z01 file times should change. +- Startup time over remote. + +## Packages +- openads-1.09.68-mtfix4-windows-x86.zip, windows-x64.zip, linux-x64.tar.gz, macos-universal.tar.gz From 96b0e0f148381e84cfd8535f6af0b05e0e5c1b78 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898283+github-actions[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 06:51:39 +0000 Subject: [PATCH 16/97] fix(mt): release s.mu across the remote OpenTable round-trip (in-process server deadlock, WAN serialization) Applied by apply-patch workflow, run 35965964983, started by bedipritpal. --- src/abi/ace_exports.cpp | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/src/abi/ace_exports.cpp b/src/abi/ace_exports.cpp index 0bb7b2db..b1193f86 100644 --- a/src/abi/ace_exports.cpp +++ b/src/abi/ace_exports.cpp @@ -8096,8 +8096,18 @@ UNSIGNED32 ENTRYPOINT AdsOpenTable(ADSHANDLE hConnect, remote_flush_pools(rc); lk.lock(); } + // Release s.mu across the OpenTable round-trip (same rule as the + // pool flush above and the production auto-open below). Holding + // it here blocks every other ABI call in the process for a full + // RTT, and with an in-process server (local serverd / tests) it + // deadlocks: this thread waits on rc's request lock, the lane + // thread that owns it waits on a reply, and the server handler + // for that reply waits on s.mu. Nothing below touches shared + // state until the lock is re-taken. + lk.unlock(); auto otr = rc->open_table(name, static_cast(map_open_mode(usMode))); + lk.lock(); if (!otr) return fail(otr.error()); auto& ot = otr.value(); auto rt = std::make_unique(); From 3249b5645087bc9db94f01917490df9fdf5c6b5b Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Thu, 24 Sep 2026 02:10:22 -0500 Subject: [PATCH 17/97] Create release-notes-1.09.68-mtfix5.md --- release-notes-1.09.68-mtfix5.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 release-notes-1.09.68-mtfix5.md diff --git a/release-notes-1.09.68-mtfix5.md b/release-notes-1.09.68-mtfix5.md new file mode 100644 index 00000000..430812e7 --- /dev/null +++ b/release-notes-1.09.68-mtfix5.md @@ -0,0 +1,17 @@ +# OpenADS test build 1.09.68-mtfix5 (TEST ONLY - not for production) + +Branch: perf/safe-local-answers on bedipritpal/OpenADS. Based on 1.09.68. Not an official release. + +## What's in it +- Everything in 1.09.68-mtfix4 (session-pool MT fixes 1 and 2, contributor logical-field fix, patch 1 safe local answers, patch 2 still-empty window, remote REINDEX fix). +- MT fix 3: a remote table open no longer holds the process-wide OpenADS lock while it waits for the server's reply. Before, every other OpenADS call in the same process (any thread) waited a full round trip whenever one thread opened a remote table, and with an in-process server it could deadlock (rare hang in the repeated pool MT test). Same rule the code already used for the pool flush and the production-index auto-open. +- Wire-frame logging kept on for diagnosis. +- network_pool_mt_test (25x 4-thread phase) and network_pool_mt_repeat (30 fresh processes) pass; a separate diagnostic loop ran the pool MT test 1,800 more times per build with no hang. + +## What to test +- Several Vouch instances at once, many threads, against openads_serverd from this build (Linux tarball) and the client DLL from the Windows x86 zip. +- REINDEX over remote: .z01 file times should change. +- Startup time over remote. + +## Packages +- openads-1.09.68-mtfix5-windows-x86.zip, windows-x64.zip, linux-x64.tar.gz, macos-universal.tar.gz From 37865524c0d1d789a5eb38a7e8bd5ef09767b5e9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898283+github-actions[bot]@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:30:00 +0000 Subject: [PATCH 18/97] test: remote REINDEX, Vouch shape (multi-tag .Z01 bag, no order, shared/exclusive, empty/stale) Applied by apply-patch workflow, run 35998656199, started by bedipritpal. --- tests/unit/network_remote_reindex_test.cpp | 149 +++++++++++++++++++++ 1 file changed, 149 insertions(+) diff --git a/tests/unit/network_remote_reindex_test.cpp b/tests/unit/network_remote_reindex_test.cpp index dee1f9ab..a939c29c 100644 --- a/tests/unit/network_remote_reindex_test.cpp +++ b/tests/unit/network_remote_reindex_test.cpp @@ -9,6 +9,7 @@ #include #include +#include #include #include @@ -128,3 +129,151 @@ TEST_CASE("Remote AdsReindex rebuilds the bag bound on the server twin") { CHECK(rr_local_key_count(dir) == 4); fs::remove_all(dir, ec); } + +// Vouch-shaped remote REINDEX: CDX-format production bag named .Z01 +// with several tags, table opened remotely (shared and exclusive) with no +// order set, then AdsReindex. Every tag must be rebuilt and the bag file +// rewritten, for both an empty table and a stale non-empty one. +namespace { + +const char* const kRzTags[] = {"TA", "TB", "TC"}; + +void rz_counts(const fs::path& dir, UNSIGNED32 out[3]) { + UNSIGNED8 srv[512]{}; + std::memcpy(srv, dir.string().c_str(), dir.string().size()); + ADSHANDLE hC = 0; + REQUIRE(AdsConnect60(srv, ADS_LOCAL_SERVER, nullptr, nullptr, 0, &hC) + == AE_SUCCESS); + UNSIGNED8 tn[] = "RZ.DBF"; + ADSHANDLE hT = 0; + REQUIRE(AdsOpenTable(hC, tn, nullptr, ADS_CDX, 0, 0, 0, 0, &hT) + == AE_SUCCESS); + UNSIGNED8 bag[] = "RZ.Z01"; + ADSHANDLE arr[16]{}; + UNSIGNED16 alen = 16; + (void)AdsOpenIndex(hT, bag, arr, &alen); + for (int i = 0; i < 3; ++i) { + UNSIGNED8 tg[8]{}; + std::memcpy(tg, kRzTags[i], std::strlen(kRzTags[i])); + ADSHANDLE hI = 0; + REQUIRE(AdsGetIndexHandle(hT, tg, &hI) == AE_SUCCESS); + out[i] = 0; + REQUIRE(AdsGetKeyCount(hI, ADS_IGNOREFILTERS, &out[i]) == AE_SUCCESS); + } + REQUIRE(AdsCloseTable(hT) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hC) == AE_SUCCESS); +} + +void rz_append(ADSHANDLE hT, double id) { + UNSIGNED8 fid[] = "ID"; + UNSIGNED8 fnm[] = "NM"; + char nm[16]; + std::snprintf(nm, sizeof(nm), "N%05d", static_cast(id)); + REQUIRE(AdsAppendRecord(hT) == AE_SUCCESS); + REQUIRE(AdsSetDouble(hT, fid, id) == AE_SUCCESS); + REQUIRE(AdsSetString(hT, fnm, reinterpret_cast(nm), + static_cast(std::strlen(nm))) + == AE_SUCCESS); + REQUIRE(AdsWriteRecord(hT) == AE_SUCCESS); +} + +void rz_run(bool empty, UNSIGNED16 mode) { + CAPTURE(empty); + CAPTURE(mode); + auto dir = fs::temp_directory_path() / "openads_remote_reindex_z01"; + std::error_code ec; + fs::remove_all(dir, ec); + fs::create_directories(dir); + const UNSIGNED32 want = empty ? 0 : 4; + { + UNSIGNED8 srv[512]{}; + std::memcpy(srv, dir.string().c_str(), dir.string().size()); + ADSHANDLE hC = 0; + REQUIRE(AdsConnect60(srv, ADS_LOCAL_SERVER, nullptr, nullptr, 0, + &hC) == AE_SUCCESS); + UNSIGNED8 def[] = "ID,N,8,0;NM,C,10,0"; + UNSIGNED8 tn[] = "RZ.DBF"; + ADSHANDLE hT = 0; + REQUIRE(AdsCreateTable(hC, tn, nullptr, ADS_CDX, 0, 0, 0, 0, def, + &hT) == AE_SUCCESS); + const char* exprs[] = {"STR(ID,8)", "NM", "NM+STR(ID,8)"}; + for (int i = 0; i < 3; ++i) { + UNSIGNED8 bag[] = "RZ.Z01"; + UNSIGNED8 tg[8]{}; + std::memcpy(tg, kRzTags[i], std::strlen(kRzTags[i])); + UNSIGNED8 ex[32]{}; + std::memcpy(ex, exprs[i], std::strlen(exprs[i])); + ADSHANDLE hI = 0; + REQUIRE(AdsCreateIndex61(hT, bag, tg, ex, nullptr, nullptr, + ADS_COMPOUND, 512, &hI) == AE_SUCCESS); + } + if (!empty) { rz_append(hT, 2); rz_append(hT, 1); } + REQUIRE(AdsCloseTable(hT) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hC) == AE_SUCCESS); + } + if (!empty) { + fs::copy_file(dir / "RZ.Z01", dir / "RZ.OLD", + fs::copy_options::overwrite_existing); + UNSIGNED8 srv[512]{}; + std::memcpy(srv, dir.string().c_str(), dir.string().size()); + ADSHANDLE hC = 0; + REQUIRE(AdsConnect60(srv, ADS_LOCAL_SERVER, nullptr, nullptr, 0, + &hC) == AE_SUCCESS); + UNSIGNED8 tn[] = "RZ.DBF"; + ADSHANDLE hT = 0; + REQUIRE(AdsOpenTable(hC, tn, nullptr, ADS_CDX, 0, 0, 0, 0, &hT) + == AE_SUCCESS); + UNSIGNED8 bag[] = "RZ.Z01"; + ADSHANDLE arr[16]{}; + UNSIGNED16 alen = 16; + (void)AdsOpenIndex(hT, bag, arr, &alen); + rz_append(hT, 4); + rz_append(hT, 3); + REQUIRE(AdsCloseTable(hT) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hC) == AE_SUCCESS); + fs::copy_file(dir / "RZ.OLD", dir / "RZ.Z01", + fs::copy_options::overwrite_existing); + UNSIGNED32 c[3]{}; + rz_counts(dir, c); + CHECK(c[0] == 2); // stale before the remote reindex + } + const auto old_t = fs::file_time_type::clock::now() - std::chrono::hours(24); + fs::last_write_time(dir / "RZ.Z01", old_t); + { + openads::network::Server s; + REQUIRE(s.start("127.0.0.1", 0).has_value()); + char uri[512]{}; + std::snprintf(uri, sizeof(uri), "tcp://127.0.0.1:%u/%s", + static_cast(s.port()), + dir.string().c_str()); + UNSIGNED8 sb[512]{}; + std::memcpy(sb, uri, std::strlen(uri)); + ADSHANDLE hC = 0; + REQUIRE(AdsConnect60(sb, ADS_REMOTE_SERVER, nullptr, nullptr, 0, + &hC) == AE_SUCCESS); + UNSIGNED8 tn[] = "RZ.DBF"; + ADSHANDLE hT = 0; + REQUIRE(AdsOpenTable(hC, tn, nullptr, ADS_CDX, 0, 0, 0, mode, &hT) + == AE_SUCCESS); + CHECK(AdsReindex(hT) == AE_SUCCESS); + REQUIRE(AdsCloseTable(hT) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hC) == AE_SUCCESS); + s.stop(); + } + CHECK(fs::last_write_time(dir / "RZ.Z01") > old_t + std::chrono::hours(1)); + UNSIGNED32 c[3]{}; + rz_counts(dir, c); + CHECK(c[0] == want); + CHECK(c[1] == want); + CHECK(c[2] == want); + fs::remove_all(dir, ec); +} + +} // namespace + +TEST_CASE("Remote AdsReindex, Vouch shape: multi-tag .Z01, no order") { + rz_run(true, ADS_SHARED); + rz_run(true, ADS_EXCLUSIVE); + rz_run(false, ADS_SHARED); + rz_run(false, ADS_EXCLUSIVE); +} From 2379a0e81f96c03d63fb11f57b68fc20763680ca Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898283+github-actions[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 04:32:37 +0000 Subject: [PATCH 19/97] perf: single-attempt lock default + alias/path lane pinning (mtfix6) Applied by apply-patch workflow, run 36094433129, started by bedipritpal. --- src/abi/ace_exports.cpp | 105 +++++++++++++++++++++-- src/abi/lock_retry_policy.h | 85 +++++++++++++++--- src/engine/table.cpp | 7 +- tests/unit/abi_lock_retry_test.cpp | 54 +++++++++++- tests/unit/abi_lock_unlock_full_test.cpp | 9 +- tests/unit/abi_mt_contention_test.cpp | 11 ++- tests/unit/abi_pritpal_lock_test.cpp | 8 +- 7 files changed, 251 insertions(+), 28 deletions(-) diff --git a/src/abi/ace_exports.cpp b/src/abi/ace_exports.cpp index b1193f86..c8049211 100644 --- a/src/abi/ace_exports.cpp +++ b/src/abi/ace_exports.cpp @@ -6779,6 +6779,93 @@ remote_pool_lanes_of(openads::network::RemoteConnection* rc) { return pool->lanes; } +// Lane pinning by (logical connection, path, alias). +// +// The thread-affine lane pool spreads a process's tables over several +// server sessions for parallel wire throughput, but record locks are +// owned per server SESSION. Harbour's zero-space pattern +// (hb_dbRequest/hb_dbDetach - one workarea shared process-wide across +// threads for login semaphores) needs the opposite: a workarea's lock +// identity must survive being driven from any thread, and must survive a +// close + fresh USE of the same alias (Vouch's LogUse). DBFCDX gets this +// for free because the lock list lives in the workarea struct itself. +// +// Pinning gives each (connection, normalized path, alias) one stable +// lane for the connection's lifetime: every USE of that alias+path from +// any thread - including the first USE after a close - binds to the same +// server session, so locks taken through it stay visible and unlockable +// no matter which thread is holding the workarea. Different aliases of +// the same file keep different owners (UsrConsole-style cross-owner lock +// probes still conflict, either on another lane or as another server-side +// Table object on the same lane). Single-threaded callers are unaffected +// (thread affinity already kept them on lane 0). +// +// Pins persist across AdsCloseTable on purpose - a close releases held +// locks (same as DBFCDX closing a workarea); what pinning preserves is +// IDENTITY for the next open, not the locks themselves. Pins are dropped +// at AdsDisconnect. +// +// Default ON; kill switch: openads.ini lane_pin_alias = 0 (or +// OPENADS_LANE_PIN_ALIAS=0) restores pure thread-affinity. +static std::unordered_map& +remote_lane_pins() { + static std::unordered_map m; + return m; +} +static bool remote_lane_pin_enabled() { + static const bool on = [] { + const std::string v = openads::util::client_setting( + "OPENADS_LANE_PIN_ALIAS", "lane_pin_alias"); + if (v.empty()) return true; + std::string l = v; + for (auto& c : l) c = static_cast(::tolower((unsigned char)c)); + return !(l == "0" || l == "false" || l == "off" || l == "no"); + }(); + return on; +} +static std::string remote_lane_pin_key(ADSHANDLE rem_h, + const std::string& name, + const std::string& alias) { + std::string n = name; + for (auto& c : n) { if (c == '\\') c = '/'; } + std::string a = alias; + for (auto& c : a) c = static_cast(::toupper((unsigned char)c)); + return std::to_string(static_cast(rem_h)) + + '\x01' + n + '\x01' + a; +} +// Resolve the lane for a (connection, path, alias) open: pinned lane when +// one is recorded and alive, otherwise the thread-affine pick, which then +// becomes the pin. s.mu must be held. +static openads::network::RemoteConnection* +remote_pool_lane_for_open(ADSHANDLE rem_h, const std::string& name, + const std::string& alias) { + namespace net = openads::network; + if (!remote_lane_pin_enabled()) + return remote_pool_lane_conn(static_cast(rem_h)); + const std::string key = remote_lane_pin_key(rem_h, name, alias); + auto& pins = remote_lane_pins(); + auto it = pins.find(key); + if (it != pins.end()) { + if (it->second != nullptr && it->second->valid()) return it->second; + pins.erase(it); // dead lane: fall through and re-pin + } + net::RemoteConnection* rc = remote_pool_lane_conn(static_cast(rem_h)); + if (rc != nullptr) pins[key] = rc; + return rc; +} +// Drop every pin of a logical connection (AdsDisconnect). s.mu held. +static void remote_lane_pins_clear(ADSHANDLE rem_h) { + const std::string prefix = + std::to_string(static_cast(rem_h)) + '\x01'; + for (auto it = remote_lane_pins().begin(); + it != remote_lane_pins().end();) { + if (it->first.compare(0, prefix.size(), prefix) == 0) + it = remote_lane_pins().erase(it); + else + ++it; + } +} + extern "C" { @@ -7773,6 +7860,8 @@ UNSIGNED32 ENTRYPOINT AdsDisconnect(ADSHANDLE hConnect) { // ANY lane still has open tables. auto lanes = remote_pool_lanes_of(rc0); if (lanes.empty()) lanes.push_back(rc0); + // Lane pins name lanes of this connection; drop them all. + remote_lane_pins_clear(hConnect); // Null out rt->conn on any open SQL cursors that reference this // connection so AdsCloseTable can detect the dangling case and // skip the wire op rather than crashing with a use-after-free. @@ -7946,13 +8035,6 @@ UNSIGNED32 ENTRYPOINT AdsOpenTable(ADSHANDLE hConnect, } if (auto* rc0 = s.registry.lookup( rem_h, HandleKind::RemoteConnection)) { - // MT lane: this thread's session for the logical connection. - // Each table pins to its lane via rt->conn below (cursor/order - // bindings are per-session server-side); single-threaded callers - // always get the primary (lane 0) — behaviour unchanged. - openads::network::RemoteConnection* rc = - remote_pool_lane_conn(static_cast(rem_h)); - if (rc == nullptr) rc = rc0; auto name = openads::abi::to_internal(pucName, 0); // M12.33 — strip tcp:// URI prefix that legacy Delphi TAdsTable // components embed in the table name (e.g. @@ -7987,6 +8069,15 @@ UNSIGNED32 ENTRYPOINT AdsOpenTable(ADSHANDLE hConnect, if (alias.empty()) { alias = std::filesystem::path(name).stem().string(); } + // MT lane: this table's session for the logical connection. + // Lane pinning (default ON): same alias+path binds to the same + // lane from any thread and across close/reopen, so record-lock + // identity follows the workarea (zero-space detach/request). + // Without pinning: this thread's affine lane, and single-threaded + // callers always get the primary (lane 0) — behaviour unchanged. + openads::network::RemoteConnection* rc = + remote_pool_lane_for_open(rem_h, name, alias); + if (rc == nullptr) rc = rc0; openads::util::write_remote_open_audit(name, alias); // Pooled re-USE (USE latency): same connection/path/alias/mode // within TTL reuses the parked server handle — no OpenTable wire diff --git a/src/abi/lock_retry_policy.h b/src/abi/lock_retry_policy.h index f3599555..4e9b3c19 100644 --- a/src/abi/lock_retry_policy.h +++ b/src/abi/lock_retry_policy.h @@ -1,25 +1,28 @@ -#pragma once +#pragma once // Shared lock-retry policy between the ABI layer (AdsLockRecord/AdsLockTable) // and the network session layer (LockRecord/LockTable wire opcodes). #include +#include #include #include #include +#include "util/client_config.h" + namespace openads::abi { struct LockPolicy { - std::uint32_t cycle_ms = 100; // ACE default (AdsSetLockCycle) - std::uint16_t retry_count = 10; // ACE default (AdsSetLockRetryCount) + std::uint32_t cycle_ms = 100; // ACE ceiling (AdsSetLockCycle) + std::uint16_t retry_count = 0; // single attempt by default (see below) // Sleep between attempt i and i+1 (0-based). The ACE contract is a flat // cycle_ms quantum, but a flat 100ms slice per retry multiplies badly // under many-instance contention (Pritpal Bedi's 700-instance B_BIG: // a contended RLock that resolves in 20 retries costs a full 2 s of - // pure sleep). Most contentions resolve within a few milliseconds — - // the holder is inside a REPLACE — so the first attempts recheck after + // pure sleep). Most contentions resolve within a few milliseconds - + // the holder is inside a REPLACE - so the first attempts recheck after // 2/4/8 ms and only the later attempts fall back to the configured // cycle_ms quantum. AdsSetLockCycle still controls the ceiling. std::uint32_t sleep_before_attempt(std::uint32_t attempt) const { @@ -35,20 +38,80 @@ struct LockPolicy { } }; -// Process-global lock policy — shared between ABI entry points and the -// server session. ADS_SETLOCKCYCLE / ADS_SETLOCKRETRYCOUNT modify it. +// Process-global lock policy for the client-facing ACE lock calls. +// +// DEFAULT IS SINGLE-ATTEMPT (retry_count 0): xBase RLOCK()/FLOCK() are +// single-attempt primitives - fail means fail, the application owns any +// retry loop (Vouch polls with its own waits; CacheRDD does the same). +// A hidden client-side wait underneath double-waits the app and, worse, +// turns every intentional lock PROBE (login semaphores, "is anyone +// logged" walks) into a full-budget burn: the server already answered +// fail-fast on the first attempt. (Pritpal Bedi, 2026-09-24: "If lock +// fails it must return immediately. App will decide on what it has to +// do next.") +// +// Opt back into ACE-style waits either per-app (AdsSetLockCycle / +// AdsSetLockRetryCount override these values at runtime, unchanged ABI +// contract) or per-install: +// openads.ini: lock_retry_count = 10 (any value > 0 re-enables waits) +// lock_cycle_ms = 100 (ceiling per wait cycle) +// env: OPENADS_LOCK_RETRY_COUNT / OPENADS_LOCK_CYCLE_MS (env wins). inline LockPolicy& lock_retry_policy() { - static LockPolicy p; + static LockPolicy p = [] { + LockPolicy d; + try { + const std::string rc = openads::util::client_setting( + "OPENADS_LOCK_RETRY_COUNT", "lock_retry_count"); + if (!rc.empty()) + d.retry_count = static_cast(std::stoul(rc)); + const std::string cm = openads::util::client_setting( + "OPENADS_LOCK_CYCLE_MS", "lock_cycle_ms"); + if (!cm.empty()) + d.cycle_ms = static_cast(std::stoul(cm)); + } catch (...) {} + return d; + }(); return p; } -// Sleep for the interval the policy prescribes before retry `attempt`. -inline void lock_retry_sleep(std::uint32_t attempt) { - const auto& p = lock_retry_policy(); +// Engine-internal lock policy for the append auto-lock (Table::append_blank). +// Deliberately decoupled from the client-facing policy above: the auto-lock +// on a freshly appended record is an internal wait the application cannot +// perform itself, and a single attempt there makes appends fail (5012) +// under any FLock/Browse convoy. Keeps the historical 100ms x 10 budget; +// tunable via engine_lock_retry_count / engine_lock_cycle_ms (env +// OPENADS_ENGINE_LOCK_RETRY_COUNT / OPENADS_ENGINE_LOCK_CYCLE_MS). +inline const LockPolicy& engine_append_lock_policy() { + static const LockPolicy p = [] { + LockPolicy d; + d.cycle_ms = 100; + d.retry_count = 10; + try { + const std::string rc = openads::util::client_setting( + "OPENADS_ENGINE_LOCK_RETRY_COUNT", "engine_lock_retry_count"); + if (!rc.empty()) + d.retry_count = static_cast(std::stoul(rc)); + const std::string cm = openads::util::client_setting( + "OPENADS_ENGINE_LOCK_CYCLE_MS", "engine_lock_cycle_ms"); + if (!cm.empty()) + d.cycle_ms = static_cast(std::stoul(cm)); + } catch (...) {} + return d; + }(); + return p; +} + +// Sleep for the interval the given policy prescribes before retry `attempt`. +inline void lock_retry_sleep(const LockPolicy& p, std::uint32_t attempt) { auto ms_ = p.sleep_before_attempt(attempt); if (ms_ > 0) { std::this_thread::sleep_for(std::chrono::milliseconds(ms_)); } } +// Sleep for the interval the client-facing policy prescribes. +inline void lock_retry_sleep(std::uint32_t attempt) { + lock_retry_sleep(lock_retry_policy(), attempt); +} + } // namespace openads::abi diff --git a/src/engine/table.cpp b/src/engine/table.cpp index 4491d7f0..b78be22e 100644 --- a/src/engine/table.cpp +++ b/src/engine/table.cpp @@ -1228,7 +1228,10 @@ util::Result Table::append_record() { // the ACE lock budget, then roll the blank row back so a timeout // cannot leave a durable empty record. { - const auto p = openads::abi::lock_retry_policy(); + // Engine-internal budget, decoupled from the client-facing ACE lock + // policy (single-attempt by default): the app cannot retry this + // internal auto-lock itself, so it keeps its own short wait. + const auto& p = openads::abi::engine_append_lock_policy(); const auto t0 = std::chrono::steady_clock::now(); const auto deadline = t0 + std::chrono::milliseconds( p.budget_ms() == 0 ? 1 : p.budget_ms()); @@ -1242,7 +1245,7 @@ util::Result Table::append_record() { std::chrono::steady_clock::now() >= deadline) { break; } - openads::abi::lock_retry_sleep(i); + openads::abi::lock_retry_sleep(p, i); } if (!locked) { const std::uint32_t failed = recno_; diff --git a/tests/unit/abi_lock_retry_test.cpp b/tests/unit/abi_lock_retry_test.cpp index 5110abda..d5eed0bf 100644 --- a/tests/unit/abi_lock_retry_test.cpp +++ b/tests/unit/abi_lock_retry_test.cpp @@ -143,9 +143,59 @@ TEST_CASE("M9.18 retry loop sleeps cycle_ms * retry_count when contended") { REQUIRE(AdsUnlockTable(hTableA) == 0); - // Restore defaults so other tests aren't affected. + // Restore the default single-attempt policy so other tests aren't + // affected (the process default is retry_count 0 since 1.09.68-mtfix6). REQUIRE(AdsSetLockCycle(0, 100) == 0); - REQUIRE(AdsSetLockRetryCount(0, 10) == 0); + REQUIRE(AdsSetLockRetryCount(0, 0) == 0); + + REQUIRE(AdsCloseTable(hTableA) == 0); + REQUIRE(AdsDisconnect(hConnA) == 0); + REQUIRE(AdsCloseTable(hTableB) == 0); + REQUIRE(AdsDisconnect(hConnB) == 0); + fs::remove_all(dir, ec); +} + +TEST_CASE("M9.18 single-attempt policy never sleeps when contended") { + // mtfix6 default: a failed lock returns immediately - the application + // owns any retry loop (xBase RLOCK()/FLOCK() semantics). With + // retry_count 0 the call must never wait, pass or fail. + const auto dir = fs::temp_directory_path() / "openads_m9_18_lock_fast"; + std::error_code ec; + fs::remove_all(dir, ec); + stage_dbf(dir); + + UNSIGNED8 srv[256]; + std::memcpy(srv, dir.string().c_str(), dir.string().size() + 1); + UNSIGNED8 leaf[16] = "data"; + + ADSHANDLE hConnA = 0; + REQUIRE(AdsConnect60(srv, ADS_LOCAL_SERVER, + nullptr, nullptr, 0, &hConnA) == 0); + ADSHANDLE hTableA = 0; + REQUIRE(AdsOpenTable(hConnA, leaf, leaf, ADS_CDX, + 1, 1, 0, 1, &hTableA) == 0); + + ADSHANDLE hConnB = 0; + REQUIRE(AdsConnect60(srv, ADS_LOCAL_SERVER, + nullptr, nullptr, 0, &hConnB) == 0); + ADSHANDLE hTableB = 0; + REQUIRE(AdsOpenTable(hConnB, leaf, leaf, ADS_CDX, + 1, 1, 0, 1, &hTableB) == 0); + + // Single attempt, 100 ms cycle: the cycle must never be slept. + REQUIRE(AdsSetLockCycle(0, 100) == 0); + REQUIRE(AdsSetLockRetryCount(0, 0) == 0); + + REQUIRE(AdsLockTable(hTableA) == 0); + + auto t0 = std::chrono::steady_clock::now(); + AdsLockTable(hTableB); // result irrelevant - timing is the assertion + auto elapsed = std::chrono::duration_cast( + std::chrono::steady_clock::now() - t0).count(); + + CHECK(elapsed < 50); // single attempt on an in-process server: no sleep + + REQUIRE(AdsUnlockTable(hTableA) == 0); REQUIRE(AdsCloseTable(hTableA) == 0); REQUIRE(AdsDisconnect(hConnA) == 0); diff --git a/tests/unit/abi_lock_unlock_full_test.cpp b/tests/unit/abi_lock_unlock_full_test.cpp index 94644213..81e8158e 100644 --- a/tests/unit/abi_lock_unlock_full_test.cpp +++ b/tests/unit/abi_lock_unlock_full_test.cpp @@ -314,7 +314,14 @@ TEST_CASE("lockfull remote: threads on a shared connection don't leak locks") { ADSHANDLE hT = handles[t]; for (int c = 0; c < kCycles; ++c) { const UNSIGNED32 rec = 1 + (c % 5); - UNSIGNED32 rc = AdsLockRecord(hT, rec); + // mtfix6: locks are single-attempt; the application owns + // the retry loop under contention. + UNSIGNED32 rc = 1; + for (int a = 0; a < 500 && rc != 0; ++a) { + rc = AdsLockRecord(hT, rec); + if (rc != 0) + std::this_thread::sleep_for(std::chrono::milliseconds(2)); + } if (rc != 0) { fprintf(stderr, "[lockfull] lock rc=%u\n", rc); ++failures; break; } UNSIGNED8 f[] = "VAL"; rc = AdsSetLong(hT, f, c); diff --git a/tests/unit/abi_mt_contention_test.cpp b/tests/unit/abi_mt_contention_test.cpp index 510d5851..11f5e788 100644 --- a/tests/unit/abi_mt_contention_test.cpp +++ b/tests/unit/abi_mt_contention_test.cpp @@ -283,7 +283,16 @@ TEST_CASE("MT: record lock contention across threads honours the byte lock") { std::atomic b_rc{0xFFFFFFFFu}; std::thread tb([&] { AdsGotoRecord(hTB, 7); - b_rc = AdsLockRecord(hTB, 0); + // mtfix6: locks are single-attempt now - a contended lock fails + // immediately and the application owns the retry loop. Poll like + // an app would until A releases. + UNSIGNED32 rc = 1; + for (int a = 0; a < 400 && rc != 0; ++a) { + rc = AdsLockRecord(hTB, 0); + if (rc != 0) + std::this_thread::sleep_for(std::chrono::milliseconds(10)); + } + b_rc = rc; b_done = 1; }); std::this_thread::sleep_for(std::chrono::milliseconds(500)); diff --git a/tests/unit/abi_pritpal_lock_test.cpp b/tests/unit/abi_pritpal_lock_test.cpp index 82efc65e..dc043579 100644 --- a/tests/unit/abi_pritpal_lock_test.cpp +++ b/tests/unit/abi_pritpal_lock_test.cpp @@ -134,9 +134,9 @@ TEST_CASE("Remote: record lock contention — B's lock fails, does not hang") { // Must have returned promptly (~30ms max), NOT hung forever CHECK(elapsed < 2000); - // Restore defaults + // Restore the default single-attempt policy (mtfix6). REQUIRE(AdsSetLockCycle(0, 100) == 0); - REQUIRE(AdsSetLockRetryCount(0, 10) == 0); + REQUIRE(AdsSetLockRetryCount(0, 0) == 0); // Cleanup REQUIRE(AdsUnlockRecord(hTblA, 1) == 0); @@ -255,9 +255,9 @@ TEST_CASE("Remote: FLock contention — B's FLock fails after retries") { CHECK(rc != 0); // Must NOT succeed CHECK(elapsed < 2000); // Must return promptly - // Restore defaults + // Restore the default single-attempt policy (mtfix6). REQUIRE(AdsSetLockCycle(0, 100) == 0); - REQUIRE(AdsSetLockRetryCount(0, 10) == 0); + REQUIRE(AdsSetLockRetryCount(0, 0) == 0); // Cleanup REQUIRE(AdsUnlockTable(hTblA) == 0); From bcf21f58c69a0a777d1e07556d07096f17ff3bc3 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Thu, 24 Sep 2026 23:34:51 -0500 Subject: [PATCH 20/97] Create release-notes-1.09.68-mtfix6.md --- release-notes-1.09.68-mtfix6.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 release-notes-1.09.68-mtfix6.md diff --git a/release-notes-1.09.68-mtfix6.md b/release-notes-1.09.68-mtfix6.md new file mode 100644 index 00000000..baec725b --- /dev/null +++ b/release-notes-1.09.68-mtfix6.md @@ -0,0 +1,19 @@ +# OpenADS test build 1.09.68-mtfix6 (TEST ONLY - not for production) + +Branch: perf/safe-local-answers on bedipritpal/OpenADS. Based on 1.09.68. Not an official release. + +## What's in it +- Everything in 1.09.68-mtfix5 (session-pool MT fixes 1-3, contributor logical-field fix, patch 1 safe local answers, patch 2 still-empty window, remote REINDEX fix). +- Single-attempt locks by default. A failed AdsLockRecord / AdsLockTable now returns AE_LOCK_FAILED after ONE attempt (one round trip remote), matching xBase RLOCK()/FLOCK() semantics - fail means fail and the application owns any retry loop. Before, the ACE client kept retrying a contended lock for up to ~1 second (10 x 100 ms) even though the server had already answered on the first attempt, which turned every intentional lock probe (login-semaphore walks, "already logged?" checks) into a ~1 s burn per probe. Opt back into ACE-style waits per install: openads.ini lock_retry_count = 10 (env OPENADS_LOCK_RETRY_COUNT), optional lock_cycle_ms ceiling (default 100). Apps that call AdsSetLockCycle / AdsSetLockRetryCount get exactly what they ask for, unchanged. +- The server's append auto-lock keeps its own internal budget (10 x 100 ms, engine_lock_retry_count / engine_lock_cycle_ms) - it is an internal wait the app cannot do itself, so single-attempt client locks do not break appends under FLock/Browse convoys. +- Lane pinning by alias (lane_pin_alias = 1, default). Every USE of the same alias+path on one logical connection binds to the same server session, from any thread and across close/reopen. Record-lock identity now follows the workarea, so Harbour zero-space workareas (hb_dbRequest / hb_dbDetach, Vouch's LOGIN_A pattern) keep their locks visible and unlockable no matter which thread holds the workarea. Different aliases of the same file keep different lock owners, so cross-owner lock probes (UsrConsole) still conflict as before. lane_pin_alias = 0 restores pure thread-affinity. + +## What to test +- Login semaphore flow from several threads: log in on one thread, IsLogged()/__howManyLogins from others; lock probes should be instant now, not ~1 s each. +- b_devboo / b_backup paths (LogUse closes and re-USEs LOGIN_A): login state must stay consistent afterwards. +- UsrConsole: still shows logged-in users (lock probes across aliases must still fail). +- Saving a record over remote: RLock -> REPLACE -> Unlock should cost only its round trips (about 4-6). +- Startup time over remote; several Vouch instances at once against openads_serverd from this build (Linux tarball) with the client DLL from the Windows x86 zip. + +## Packages +- openads-1.09.68-mtfix6-windows-x86.zip, windows-x64.zip, linux-x64.tar.gz, macos-universal.tar.gz From ade150ccb544ce56c4761fc1b96597c70a39a01b Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898283+github-actions[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 06:26:14 +0000 Subject: [PATCH 21/97] diag: named timed client wire trace for voucher save Applied by apply-patch workflow, run 36102387487, started by bedipritpal. --- src/abi/ace_exports.cpp | 262 ++++++++++++++++++++++++++++++++++------ 1 file changed, 222 insertions(+), 40 deletions(-) diff --git a/src/abi/ace_exports.cpp b/src/abi/ace_exports.cpp index c8049211..69204a81 100644 --- a/src/abi/ace_exports.cpp +++ b/src/abi/ace_exports.cpp @@ -1474,41 +1474,87 @@ bool remote_table_has_index(const openads::network::RemoteTable* rt) { (rt->active_index_id != 0 || !rt->index_by_tag.empty()); } -// Grid log for WAN diagnosis: fixed-width columns (3-space separated, -// like ads_err.log) plus a variable-length detail tail, so both naked -// eyes and analytical tools can parse it: -// -// [+ 123ms] [V_USRCFG ] [AdsAtBOF ] nav=0 row=1 -// | ms since trace start | table alias | operation | detail... -// -// Same-ms runs read as locally served calls; an RTT-sized jump between -// adjacent lines is exactly one wire round-trip. +// Diagnostic WAN trace. Off unless wire_trace=1; no file opens or timestamps +// on the disabled request path. Payload and seek keys are never logged. static bool cli_trace_on() { static const bool on = openads::util::client_setting_truthy( "OPENADS_WIRE_TRACE", "wire_trace"); return on; } +static const std::string& cli_trace_path() { + static const std::string path = [] { + auto p = openads::util::client_setting("OPENADS_WIRE_TRACE_FILE", + "wire_trace_file"); + return p.empty() ? std::string("C:/tmp/cli_trace.log") : p; + }(); + return path; +} + +struct CliTraceState { + std::mutex mu; + std::map, std::string> tables; + std::map, std::string> indexes; +}; +static CliTraceState& cli_trace_state() { + static CliTraceState trace; + return trace; +} + +// Called after a successful open; table ID belongs to its connection, +// not globally. Do not hold this mutex while performing any wire request. +static void cli_trace_table_open(const openads::network::RemoteTable* rt) { + if (!cli_trace_on() || !rt || !rt->conn) return; + auto& trace = cli_trace_state(); + std::lock_guard lk(trace.mu); + trace.tables[{rt->conn, rt->id}] = + rt->alias.empty() ? rt->name : rt->alias; +} +static void cli_trace_table_close(const openads::network::RemoteTable* rt) { + if (!cli_trace_on() || !rt || !rt->conn) return; + auto& trace = cli_trace_state(); + std::lock_guard lk(trace.mu); + trace.tables.erase({rt->conn, rt->id}); + for (const auto& entry : rt->index_by_tag) + trace.indexes.erase({rt->conn, entry.second}); +} + +static void cli_trace_write_locked(FILE* hf, long long ms, + const char* who, const char* op, + const char* detail) { + const auto now = std::chrono::system_clock::now(); + const auto epoch_ms = std::chrono::duration_cast( + now.time_since_epoch()).count(); + const auto secs = std::chrono::system_clock::to_time_t(now); + std::tm local{}; +#if defined(_WIN32) + localtime_s(&local, &secs); +#else + localtime_r(&secs, &local); +#endif + char stamp[32]; + std::strftime(stamp, sizeof(stamp), "%Y-%m-%d %H:%M:%S", &local); + std::fprintf(hf, "%s.%03lld [+%9lldms] [%-20.20s] [%-20.20s] %s\n", + stamp, static_cast(epoch_ms % 1000), ms, + who ? who : "-", op ? op : "-", detail ? detail : ""); +} + static void cli_trace_line(long long ms, const char* who, const char* op, const char* detail) { if (!cli_trace_on()) return; - FILE* hf = std::fopen("C:/tmp/cli_trace.log", "a"); - if (hf == nullptr) return; - std::fprintf(hf, "[+%9lldms] [%-12.12s] [%-20.20s] %s\n", ms, - who != nullptr ? who : "-", - op != nullptr ? op : "-", detail != nullptr ? detail : ""); + auto& trace = cli_trace_state(); + std::lock_guard lk(trace.mu); + FILE* hf = std::fopen(cli_trace_path().c_str(), "a"); + if (!hf) return; + cli_trace_write_locked(hf, ms, who, op, detail); std::fclose(hf); } static long long cli_trace_ms() { - // Millisecond stamp since the first traced call: adjacent-line gaps - // separate local answers (~0 ms) from wire round-trips (~RTT ms), - // which is the whole point of reading this log. static const auto t0 = std::chrono::steady_clock::now(); return static_cast( std::chrono::duration_cast( - std::chrono::steady_clock::now() - t0) - .count()); + std::chrono::steady_clock::now() - t0).count()); } static void cli_trace(const char* op, const char* fmt, ...) { @@ -1520,16 +1566,11 @@ static void cli_trace(const char* op, const char* fmt, ...) { buf[sizeof(buf) - 1] = 0; cli_trace_line(cli_trace_ms(), "-", op, buf); } -// Table-attributed variant: the alias (file name when the open -// carried none) goes in the grid's table column, so repeated probe -// blocks attribute to the table that paid them. static void cli_trace_tbl(const openads::network::RemoteTable* rt, const char* op, const char* fmt, ...) { - std::string who; - if (rt != nullptr) { - who = !rt->alias.empty() ? rt->alias : rt->name; - } - if (who.empty()) who = "-"; + if (!cli_trace_on()) return; + const std::string who = rt == nullptr ? "-" : + (!rt->alias.empty() ? rt->alias : rt->name); char buf[512]; va_list ap; va_start(ap, fmt); @@ -1539,25 +1580,159 @@ static void cli_trace_tbl(const openads::network::RemoteTable* rt, cli_trace_line(cli_trace_ms(), who.c_str(), op, buf); } -// wire_trace: one grid line per completed wire round trip, so opens, -// closes, seeks, locks and writes show up next to the nav lines above. -// op/ack are wire opcodes (hex, see src/network/wire.h); tid is the -// table id for table-scoped ops (match it to the "opened id=" line); -// conn tells session-pool lanes apart. +static const char* cli_trace_opcode(std::uint8_t op) { + switch (op) { + case 0x01: return "Hello"; + case 0x10: return "Connect"; + case 0x12: return "Disconnect"; + case 0x20: return "OpenTable"; + case 0x22: return "CloseTable"; + case 0x30: return "ExecuteSQL"; + case 0x32: return "Fetch"; + case 0x40: return "GotoTop"; + case 0x42: return "Skip"; + case 0x44: return "GetField"; + case 0x46: return "GetRecordCount"; + case 0x48: return "AtEOF"; + case 0x4A: return "DescribeTable"; + case 0x4C: return "AtBOF"; + case 0x4E: return "GetRecordNum"; + case 0x62: return "IsRecordDeleted"; + case 0x64: return "GotoBottom"; + case 0x66: return "IsFound"; + case 0x68: return "RefreshRecord"; + case 0x6A: return "GetTableType"; + case 0x6C: return "GetRecordLength"; + case 0x6E: return "GetNumIndexes"; + case 0x70: return "GetLastAutoinc"; + case 0x72: return "LockRecord"; + case 0x74: return "UnlockRecord"; + case 0x76: return "LockTable"; + case 0x78: return "UnlockTable"; + case 0x7A: return "PackTable"; + case 0x7C: return "ZapTable"; + case 0x7E: return "FlushFileBuffers"; + case 0x80: return "CloseAllIndexes"; + case 0x82: return "SetAOF"; + case 0x84: return "ClearAOFRemote"; + case 0x86: return "GetAOFOptLevel"; + case 0x88: return "OpenIndex"; + case 0x8A: return "CloseIndex"; + case 0x8C: return "SetOrder"; + case 0x8E: return "SetOrderByName"; + case 0x90: return "Seek"; + case 0x92: return "SeekLast"; + case 0x94: return "CreateIndex"; + case 0x96: return "SkipUnique"; + case 0x98: return "SetScope"; + case 0x9A: return "ClearScope"; + case 0x9C: return "FetchCurrentRow"; + case 0x50: return "AppendBlank"; + case 0x52: return "SetField"; + case 0x5E: return "SetFields"; + case 0x54: return "DeleteRecord"; + case 0x56: return "RecallRecord"; + case 0x58: return "GotoRecord"; + case 0x5A: return "FlushTable"; + case 0x5C: return "GetKeyType"; + case 0x60: return "Reindex"; + case 0x9E: return "GetLastTableUpdate"; + case 0x13: return "IsRecordLocked"; + case 0x15: return "GetAllLocks"; + case 0xA0: return "MgConnect"; + case 0xA2: return "MgRequest"; + case 0xA4: return "FetchWhere"; + case 0xA6: return "Aggregate"; + case 0xA8: return "GetRecord"; + case 0xAA: return "SetRecord"; + case 0xAC: return "CustomizeAOF"; + case 0xAE: return "GetRecordCRC"; + case 0xB0: return "GetKeyCount"; + case 0x03: return "GetKeyNum"; + case 0x05: return "FindTables"; + case 0x07: return "BeginTransaction"; + case 0x09: return "CommitTransaction"; + case 0x0B: return "RollbackTransaction"; + case 0x0D: return "FindRecord"; + case 0x17: return "ZipArchive"; + case 0x19: return "UnzipArchive"; + case 0x1B: return "ZipList"; + case 0xB2: return "DDGetProperty"; + case 0xB4: return "DDSetProperty"; + case 0xB6: return "DDCreateProc"; + case 0xB8: return "DDCreateFunction"; + case 0xBA: return "DDCreateTrigger"; + case 0xBC: return "DDDropTrigger"; + case 0xBE: return "DDDropView"; + case 0xC0: return "DDDropLink"; + case 0xC2: return "DDCreateUser"; + case 0xC4: return "DDDropObject"; + case 0xC6: return "DDAddUserToGroup"; + case 0xC8: return "DDRemoveUserFromGroup"; + case 0xCA: return "DDCreateLink"; + case 0xCC: return "DDModifyLink"; + case 0xCE: return "DDCreateRefIntegrity"; + case 0xD0: return "DDCreateView"; + case 0xD2: return "DDAddIndexFile"; + case 0xD4: return "DDRemoveIndexFile"; + case 0xD6: return "DDGetPermissions"; + case 0xD8: return "DDGrantPermission"; + case 0xDA: return "ShowDeleted"; + case 0xDC: return "CreateTable"; + case 0xDE: return "DropTable"; + case 0xE0: return "FileExists"; + case 0xE2: return "FileErase"; + case 0xE4: return "FileRename"; + case 0xE6: return "FileSize"; + case 0xE8: return "FileMTime"; + case 0xEA: return "Directory"; + case 0xEC: return "DirExist"; + case 0xEE: return "DirMake"; + case 0xF0: return "DirRemove"; + case 0xF2: return "FOpen"; + case 0xF4: return "FCreate"; + case 0xF6: return "FClose"; + case 0xF8: return "FRead"; + case 0xFA: return "FWrite"; + case 0xFC: return "FSeek"; + case 0xFE: return "Mutex"; + default: return "Other"; + } +} + +// One line per completed request/reply. Connection and table ID identify +// overlapping aliases, duration includes send+receive; no payload bytes. static void cli_trace_frame_hook(const void* conn, std::uint8_t op, std::uint32_t tid, std::size_t req_bytes, std::uint8_t rep_op, std::size_t rep_bytes, long long us) { + auto& trace = cli_trace_state(); + std::lock_guard lk(trace.mu); + std::string table = "-"; + const bool index_op = op == 0x90 || op == 0x92 || op == 0x8A || + op == 0x8C || op == 0x8E || op == 0x5C; + if (index_op) { + const auto ix = trace.indexes.find({conn, tid}); + if (ix != trace.indexes.end()) table = ix->second; + } else if (op != 0x01 && op != 0x10 && op != 0x20 && + op != 0x30 && op != 0x32 && op != 0x05 && op != 0x07 && + op != 0x09 && op != 0x0B) { + const auto it = trace.tables.find({conn, tid}); + if (it != trace.tables.end()) table = it->second; + } char buf[200]; std::snprintf(buf, sizeof(buf), - "op=0x%02X tid=%u req=%lu ack=0x%02X ackb=%lu us=%lld conn=%04X", + "wire op=0x%02X tid=%u req=%lu ack=0x%02X ackb=%lu dur_us=%lld conn=%04X", static_cast(op), static_cast(tid), static_cast(req_bytes), static_cast(rep_op), static_cast(rep_bytes), us, - static_cast( - reinterpret_cast(conn) & 0xFFFFu)); - cli_trace_line(cli_trace_ms(), "wire", "frame", buf); + static_cast(reinterpret_cast(conn) & 0xFFFFu)); + FILE* hf = std::fopen(cli_trace_path().c_str(), "a"); + if (!hf) return; + cli_trace_write_locked(hf, cli_trace_ms(), table.c_str(), + cli_trace_opcode(op), buf); + std::fclose(hf); } void remote_clear_nav_boundaries(openads::network::RemoteTable* rt) { @@ -7679,6 +7854,7 @@ void remote_close_table_live(ADSHANDLE hTable, auto* rc = rt->conn; const bool counted = rt->close_counted; if (rc != nullptr) (void)rc->close_table(rt->id); + cli_trace_table_close(rt); if (hTable != 0) forget_relations(hTable); auto& s2 = state(); openads::network::RemoteConnection* fire = nullptr; @@ -8225,6 +8401,7 @@ UNSIGNED32 ENTRYPOINT AdsOpenTable(ADSHANDLE hConnect, rt->table_type_cached = true; } } + cli_trace_table_open(rt.get()); cli_trace_tbl(rt.get(), "AdsOpenTable", "opened id=%u mode=%u", static_cast(rt->id), static_cast(usMode)); @@ -15326,6 +15503,11 @@ UNSIGNED32 ENTRYPOINT AdsOpenIndex(ADSHANDLE hTable, UNSIGNED8* pucName, } ++count; remote_indexes.emplace(gh, std::move(ri)); + if (cli_trace_on()) { + auto& trace = cli_trace_state(); + std::lock_guard trace_lk(trace.mu); + trace.indexes[{rt->conn, ent.id}] = rt->alias.empty() ? rt->name : rt->alias; + } // Dedup by tag: production-CDX auto-open and a later explicit // AdsOpenIndex on the same bag must not register the order // twice, or AdsGetNumIndexes / by-order resolution skew. @@ -20475,8 +20657,8 @@ UNSIGNED32 ENTRYPOINT AdsSeek(ADSHANDLE hIndex, // -- nothing on the network to make it look wrong -- and the wire skip // after that sent (step + a lag that no longer applied). ri->parent->invalidate_prefetch(); - cli_trace_tbl(ri->parent, "AdsSeek", "key=%.24s", - key.c_str()); + cli_trace_tbl(ri->parent, "AdsSeek", "key_len=%u", + static_cast(u16KeyLen)); } if (ri->parent != nullptr && remote_empty_window(ri->parent)) { cli_trace_tbl(ri->parent, "AdsSeek", @@ -20660,8 +20842,8 @@ UNSIGNED32 ENTRYPOINT AdsSeekLast(ADSHANDLE hIndex, // RCB 07/14/2026: same stale-queue bug as AdsSeek -- see the note // there for why dropping the block is mandatory after a seek. ri->parent->invalidate_prefetch(); - cli_trace_tbl(ri->parent, "AdsSeekLast", "key=%.24s", - key.c_str()); + cli_trace_tbl(ri->parent, "AdsSeekLast", "key_len=%u", + static_cast(u16KeyLen)); } auto r = ri->conn->seek(ri->id, key, /*soft=*/0, From b3bd4e77823583ec9e60ff99741005be7aaf47f8 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Fri, 25 Sep 2026 01:32:28 -0500 Subject: [PATCH 22/97] Create release-notes-1.09.68-mtfix7-diag.md --- release-notes-1.09.68-mtfix7-diag.md | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 release-notes-1.09.68-mtfix7-diag.md diff --git a/release-notes-1.09.68-mtfix7-diag.md b/release-notes-1.09.68-mtfix7-diag.md new file mode 100644 index 00000000..78199318 --- /dev/null +++ b/release-notes-1.09.68-mtfix7-diag.md @@ -0,0 +1,15 @@ +# OpenADS test build 1.09.68-mtfix7-diag (TEST ONLY - not for production) + +Branch: perf/safe-local-answers on bedipritpal/OpenADS. Based on mtfix6 / 1.09.68. Not an official release. Nothing merged to main or upstream. + +## What's in it +- Everything in 1.09.68-mtfix6, plus a client-only timing trace for remote operations. No Vouch application change or server update is needed for this trace. +- Opt-in `wire_trace = 1` in the `openads.ini` used by Vouch. Set `wire_trace_file = C:/tmp/cli_trace.log` to choose the output path; create `C:/tmp` first. If unset, this is the default path. Or set `OPENADS_WIRE_TRACE=1` and `OPENADS_WIRE_TRACE_FILE` before starting Vouch. Restart Vouch after changing the settings; turn tracing off again when done. +- Each completed remote round trip records a local wall-clock timestamp, milliseconds since trace start, table alias when known, named wire operation (AppendBlank, SetFields, FlushTable, LockRecord, UnlockRecord, Seek, etc.), request/reply sizes, duration in microseconds, table ID, and connection marker. Existing local-answer trace lines remain. Field values and seek keys are not logged by this patch. The log can contain file/table names and existing trace details, so review it before sharing. Tracing adds disk I/O and changes timings: use it to count calls and find slow calls, not as an unmodified benchmark. + +## What to test +- Post one voucher over the remote connection. Compare the voucher append/write/commit/unlock and the two GL seek/lock/write/commit/unlock paths. See whether field writes go as a single SetFields batch or multiple SetField frames. +- If useful, run one remote startup with tracing on. Zip and share the log after reviewing it. No timing points need to be added in Vouch. + +## Packages +- openads-1.09.68-mtfix7-diag-windows-x86.zip, windows-x64.zip, linux-x64.tar.gz, macos-universal.tar.gz From cd0bba86101a3c04bdbeb02dd98cf65895f3b32c Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898283+github-actions[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 12:45:05 +0000 Subject: [PATCH 23/97] mtfix8: slim commit flush path, park previously-locked tables, cache dir/file probes Applied by apply-patch workflow, run 36136096583, started by bedipritpal. --- src/abi/ace_exports.cpp | 89 +++++- src/network/client.cpp | 38 ++- src/network/client.h | 36 +++ src/network/session.cpp | 3 +- src/network/wire.h | 10 + tests/CMakeLists.txt | 1 + tests/unit/network_commit_slimming_test.cpp | 299 ++++++++++++++++++++ tests/unit/network_open_warm_test.cpp | 9 +- 8 files changed, 474 insertions(+), 11 deletions(-) create mode 100644 tests/unit/network_commit_slimming_test.cpp diff --git a/src/abi/ace_exports.cpp b/src/abi/ace_exports.cpp index 69204a81..be1927bd 100644 --- a/src/abi/ace_exports.cpp +++ b/src/abi/ace_exports.cpp @@ -7808,7 +7808,8 @@ const char* remote_table_unpoolable_reason( if (!rt->close_counted) return "sql_cursor"; if (rt->open_exclusive) return "exclusive"; if (!rt->pending_sets.empty()) return "pending_sets"; - if (rt->ever_locked) return "ever_locked"; + if (!rt->held_recs.empty() || rt->table_lock_held || + rt->locks_uncertain) return "locks_held"; if (rt->scope_touched) return "scope"; if (!rt->aof_expr.empty()) return "aof"; if (!rt->filter_expr.empty()) return "filter"; @@ -7824,7 +7825,15 @@ bool remote_table_poolable(openads::network::RemoteTable* rt) { if (!rt->close_counted) return false; // SQL cursors etc. if (rt->open_exclusive) return false; // parked exclusive blocks peers if (!rt->pending_sets.empty()) return false; // flushed before close - if (rt->ever_locked || rt->scope_touched) return false; + // Locks once held no longer bar the park by themselves: the ledger + // proves whether any lock is STILL held (a parked table would + // otherwise pin it against every peer forever). ever_locked stays + // recorded for the trace but stops forcing a real close -- that + // policy cost GN_COUNT/FA_ACC01/USASELOG a full 7-frame reopen per + // voucher save. + if (!rt->held_recs.empty() || rt->table_lock_held || + rt->locks_uncertain) return false; + if (rt->scope_touched) return false; if (!rt->aof_expr.empty() || !rt->filter_expr.empty()) return false; // Deferred teardown (FlushFileBuffers/CloseAllIndexes) is absorbed // by a real close, never by a park (no server close happens) — the @@ -13334,8 +13343,11 @@ UNSIGNED32 ENTRYPOINT AdsAppendRecord(ADSHANDLE hTable) { auto r = rt->conn->append_blank(rt->id); if (!r) return fail(r.error()); // Fresh appends auto-lock (non-exclusive tables): pooled reuse - // must not resurrect a locked handle. + // must not resurrect a locked handle. The ack carries no + // recno, so the ledger cannot name the auto-lock -- mark the + // lock state uncertain until a full UnlockTable proves clear. rt->ever_locked = true; + rt->locks_uncertain = true; rt->write_dirty = true; return ok(); } @@ -13457,6 +13469,13 @@ UNSIGNED32 ENTRYPOINT AdsAppendRecord(ADSHANDLE hTable) { UNSIGNED32 ENTRYPOINT AdsWriteRecord(ADSHANDLE hTable) { arc2_trace("AdsWriteRecord"); if (auto* rt = get_remote_table(hTable)) { + // Snapshot dirtiness before remote_flush_pending drains the + // buffered sets: a commit with nothing written since the last + // flush (rddads DBCOMMITALL touches every open workarea) owes + // the server no frame at all -- it would only fsync unchanged + // pages, 1 RTT each, 16 of them after a Vouch voucher save. + const bool had_writes = + !rt->pending_sets.empty() || rt->write_dirty; if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; rt->row_valid = false; // M12.17 cache invalidation // A write can move the row in/out of a conditional order or @@ -13470,9 +13489,18 @@ UNSIGNED32 ENTRYPOINT AdsWriteRecord(ADSHANDLE hTable) { // instead of serving a stale current_keyno. rt->keyno_valid = false; rt->invalidate_prefetch(); + if (!had_writes) { + cli_trace_tbl(rt, "AdsWriteRecord", "clean: flush skipped"); + return ok(); + } auto r = rt->conn->flush_table(rt->id); if (!r) return fail(r.error()); - rt->write_dirty = true; + // kCapFlushTableDurable servers run the full file-buffers + // flush inside the FlushTable handler, so the commit is + // durable right here and a trailing AdsFlushFileBuffers owes + // nothing (1 RTT saved per commit). Old servers keep the + // classic two-frame pair. + rt->write_dirty = !rt->conn->server_flush_table_durable(); return ok(); } #if defined(OPENADS_WITH_FIREBIRD) @@ -14599,6 +14627,13 @@ UNSIGNED32 ENTRYPOINT AdsLockRecord(ADSHANDLE hTable, UNSIGNED32 ulRecord) { auto r = rt->conn->lock_record(rt->id, ulRecord); if (!r) return fail(r.error()); rt->ever_locked = true; + // ulRecord == 0 -> the current record (ACE convention). With + // no valid cursor the recno is unknowable client-side, so the + // ledger marks itself uncertain rather than guessing. + const std::uint32_t lrec = (ulRecord == 0) + ? (rt->row_valid ? rt->current_recno : 0) : ulRecord; + if (lrec == 0) rt->locks_uncertain = true; + else rt->held_recs.insert(lrec); return ok(); } #if defined(OPENADS_WITH_FIREBIRD) @@ -14675,6 +14710,11 @@ UNSIGNED32 ENTRYPOINT AdsUnlockRecord(ADSHANDLE hTable, UNSIGNED32 ulRecord) { if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; auto r = rt->conn->unlock_record(rt->id, ulRecord); if (!r) return fail(r.error()); + const std::uint32_t urec = (ulRecord == 0) + ? (rt->row_valid ? rt->current_recno : 0) : ulRecord; + if (urec != 0) rt->held_recs.erase(urec); + // locks_uncertain survives: only a full UnlockTable (or the + // close) proves the append auto-lock is gone. return ok(); } #if defined(OPENADS_WITH_FIREBIRD) @@ -14747,6 +14787,7 @@ UNSIGNED32 ENTRYPOINT AdsLockTable(ADSHANDLE hTable) { auto r = rt->conn->lock_table(rt->id); if (!r) return fail(r.error()); rt->ever_locked = true; + rt->table_lock_held = true; return ok(); } #if defined(OPENADS_WITH_FIREBIRD) @@ -14817,8 +14858,24 @@ UNSIGNED32 ENTRYPOINT AdsUnlockTable(ADSHANDLE hTable) { arc2_trace("AdsUnlockTable"); if (auto* rt = get_remote_table(hTable)) { if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; + // Lock ledger: with nothing held on the server, the frame + // would release zero locks. Harbour rddads dbUnlock() has no + // client-side lock list and calls this blindly before every + // lock/append -- that blind call is most of the UnlockTable + // traffic in a Vouch save. + if (rt->held_recs.empty() && !rt->table_lock_held && + !rt->locks_uncertain) { + cli_trace_tbl(rt, "AdsUnlockTable", "skipped: no locks held"); + return ok(); + } auto r = rt->conn->unlock_table(rt->id); if (!r) return fail(r.error()); + // SAP ACE semantics: AdsUnlockTable releases ALL locks (table + // AND record, including the append auto-lock), so the ledger + // is provably empty again. + rt->held_recs.clear(); + rt->table_lock_held = false; + rt->locks_uncertain = false; return ok(); } #if defined(OPENADS_WITH_FIREBIRD) @@ -17497,6 +17554,26 @@ UNSIGNED32 ENTRYPOINT AdsGetAllLocks(ADSHANDLE hTable, UNSIGNED32* paRecnos, UNSIGNED16* pusCount) { arc2_trace("AdsGetAllLocks"); if (auto* rt = get_remote_table(hTable)) { + // The client lock ledger is complete unless an append + // auto-lock or an unresolvable current-record lock made it + // uncertain (and a table lock shadows the record list, so + // that case stays on the wire too). rddads polls this after + // every commit -- answering locally saves 1 RTT each time. + if (pusCount != nullptr && !rt->locks_uncertain && + !rt->table_lock_held) { + cli_trace_tbl(rt, "AdsGetAllLocks", + "served from client lock ledger"); + const UNSIGNED16 lcap = *pusCount; + UNSIGNED16 li = 0; + if (paRecnos != nullptr) { + for (std::uint32_t lrn : rt->held_recs) { + if (li >= lcap) break; + paRecnos[li++] = lrn; + } + } + *pusCount = static_cast(rt->held_recs.size()); + return ok(); + } // M12.36 — remote record locks are server-managed; the // GetAllLocks wire opcode returns this connection's held list. if (pusCount == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); @@ -38432,6 +38509,10 @@ UNSIGNED32 ENTRYPOINT AdsSetRecord(ADSHANDLE hTable, UNSIGNED8* pucRecord, auto r = rt->conn->set_record(rt->id, pucRecord, static_cast(ulLen)); if (!r) return fail(r.error()); + // Full-record write: mark dirty so the commit's FlushTable and + // the FlushFileBuffers gate see it (the write_dirty snapshot in + // AdsWriteRecord depends on this). + rt->write_dirty = true; return ok(); } Table* t = get_table(hTable); diff --git a/src/network/client.cpp b/src/network/client.cpp index fc9dff3f..2221dd77 100644 --- a/src/network/client.cpp +++ b/src/network/client.cpp @@ -2504,6 +2504,7 @@ RemoteConnection::file_exists(const std::string& path) { { std::lock_guard lk(file_exists_mu_); if (file_exists_cache_.count(path) != 0) return true; + if (file_exists_neg_cache_.count(path) != 0) return false; } Frame req; req.opcode = Opcode::FileExists; @@ -2514,9 +2515,14 @@ RemoteConnection::file_exists(const std::string& path) { rep.value().payload.empty()) return fs_wire_err(rep.value(), "FileExists"); bool exists = rep.value().payload[0] != 0; - if (exists) { + { std::lock_guard lk(file_exists_mu_); - file_exists_cache_.insert(path); + if (exists) { + file_exists_cache_.insert(path); + file_exists_neg_cache_.erase(path); + } else { + file_exists_neg_cache_.insert(path); + } } return exists; } @@ -2524,6 +2530,7 @@ RemoteConnection::file_exists(const std::string& path) { void RemoteConnection::file_exists_invalidate() { std::lock_guard lk(file_exists_mu_); file_exists_cache_.clear(); + file_exists_neg_cache_.clear(); } util::Result @@ -2761,6 +2768,11 @@ RemoteConnection::zip_list(const std::string& zip) { util::Result RemoteConnection::dir_exist(const std::string& path) { + { + std::lock_guard lk(dir_cache_mu_); + if (dir_known_.count(path) != 0) return true; + if (dir_missing_.count(path) != 0) return false; + } Frame req; req.opcode = Opcode::DirExist; push_lp_str(req.payload, path); @@ -2769,11 +2781,25 @@ RemoteConnection::dir_exist(const std::string& path) { if (rep.value().opcode != Opcode::DirExistAck || rep.value().payload.empty()) return fs_wire_err(rep.value(), "DirExist"); - return rep.value().payload[0] != 0; + const bool exists = rep.value().payload[0] != 0; + { + std::lock_guard lk(dir_cache_mu_); + if (exists) { + dir_known_.insert(path); + dir_missing_.erase(path); + } else { + dir_missing_.insert(path); + } + } + return exists; } util::Result RemoteConnection::dir_make(const std::string& path) { + { + std::lock_guard lk(dir_cache_mu_); + if (dir_known_.count(path) != 0) return {}; + } Frame req; req.opcode = Opcode::DirMake; push_lp_str(req.payload, path); @@ -2781,6 +2807,9 @@ RemoteConnection::dir_make(const std::string& path) { if (!rep) return rep.error(); if (rep.value().opcode != Opcode::DirMakeAck) return fs_wire_err(rep.value(), "DirMake"); + std::lock_guard lk(dir_cache_mu_); + dir_known_.insert(path); + dir_missing_.erase(path); return {}; } @@ -2793,6 +2822,9 @@ RemoteConnection::dir_remove(const std::string& path) { if (!rep) return rep.error(); if (rep.value().opcode != Opcode::DirRemoveAck) return fs_wire_err(rep.value(), "DirRemove"); + std::lock_guard lk(dir_cache_mu_); + dir_known_.erase(path); + dir_missing_.insert(path); return {}; } diff --git a/src/network/client.h b/src/network/client.h index 68e69bd8..c33b9be0 100644 --- a/src/network/client.h +++ b/src/network/client.h @@ -119,6 +119,13 @@ class RemoteConnection { return (server_caps_ & kCapNavOrderFuse) != 0; } + // Server's FlushTable handler does the full file-buffers flush + // (see kCapFlushTableDurable): a commit needs no trailing + // FlushFileBuffers frame. + bool server_flush_table_durable() const noexcept { + return (server_caps_ & kCapFlushTableDurable) != 0; + } + // Current cursor-generation sequence (see nav_seq_). Relaxed load // is enough: it only orders the ABI layer's own duplicate // detection, never data. @@ -620,6 +627,21 @@ class RemoteConnection { // file-mutating ops, never held across wire calls. std::set file_exists_cache_; std::mutex file_exists_mu_; + // Negative half of the existence cache: repeated "missing" probes + // (Vouch checks optional bags/configs on every USE) are served + // locally until any file-mutating op on this connection clears it. + // A stale "missing" degrades to the app re-checking after its own + // create attempt (which clears the cache), never to wrong data. + std::set file_exists_neg_cache_; + // Directory truth (EnableFileFunc): DirExist=true answers and + // successful DirMakes feed dir_known_; DirExist=false feeds + // dir_missing_. A known dir makes DirMake a no-op (the server + // create is idempotent) and DirExist answer locally. Our own + // DirRemove erases the path from both. Peer mkdir/rmdir is not + // tracked -- session-scoped, same trade as the file cache. + std::set dir_known_; + std::set dir_missing_; + std::mutex dir_cache_mu_; public: // Deferred disconnect (MT shared connections). AdsDisconnect on a @@ -703,6 +725,20 @@ struct RemoteTable { bool open_exclusive = false; // mapped mode (never pooled) bool ever_locked = false; // AppendBlank/LockRecord/Table bool scope_touched = false; // SetScope (server state unclear) + // Client-side lock ledger (WAN chattiness): mirrors the record and + // table locks this connection is known to hold on the server. Lets + // AdsUnlockTable skip the frame when nothing is held (rddads + // dbUnlock() calls it blindly before every lock/append), lets + // AdsGetAllLocks answer locally, and lets the close path park a + // table whose locks were all released (the blunt ever_locked flag + // alone used to force a real close + 7-frame reopen per save). + // locks_uncertain covers locks the ledger cannot name: the append + // auto-lock (AppendBlankAck carries no recno) and LockRecord(0) + // with no valid cursor. Only a wire UnlockTable (or close) clears + // it. Conservative throughout: doubt always goes to the wire. + std::set held_recs; + bool table_lock_held = false; + bool locks_uncertain = false; // M12.18 — recno + deleted flag arrive together with the row // bytes so AdsGetRecordNum / AdsIsRecordDeleted can serve from // cache instead of a separate RTT each. diff --git a/src/network/session.cpp b/src/network/session.cpp index 61f33491..5213ad44 100644 --- a/src/network/session.cpp +++ b/src/network/session.cpp @@ -1721,7 +1721,8 @@ DispatchResult Session::dispatch(const Frame& f) { const std::uint32_t scaps = openads::network::kCapSetFieldsBatch | openads::network::kCapFlushInCloseAll | - openads::network::kCapNavOrderFuse; + openads::network::kCapNavOrderFuse | + openads::network::kCapFlushTableDurable; reply.payload.push_back( static_cast( scaps & 0xFFu)); reply.payload.push_back( diff --git a/src/network/wire.h b/src/network/wire.h index 3fbe051b..e134f072 100644 --- a/src/network/wire.h +++ b/src/network/wire.h @@ -648,6 +648,16 @@ inline constexpr std::uint32_t kCapFlushInCloseAll = 0x00000020u; // never emit it. inline constexpr std::uint32_t kCapNavOrderFuse = 0x00000040u; +// Durable FlushTable: the server's FlushTable handler already performs +// the full file-buffers flush (ABI twin via AdsFlushFileBuffers, then +// the engine table), i.e. exactly the work of a standalone +// FlushFileBuffers frame. A client that sees this bit clears its dirty +// flag when its own FlushTable lands and skips the trailing +// FlushFileBuffers -- one frame per commit instead of two. Same +// two-way gating as kCapFlushInCloseAll; old servers never echo it, so +// the client keeps sending both frames there. +inline constexpr std::uint32_t kCapFlushTableDurable = 0x00000080u; + // Warm OpenTableAck sections (USE latency). After the fixed // `[u32 id][u16 bag_len][bag]` prefix, the ack carries // `[u8 section_count]` then that many TLVs: diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index d0e536a1..8f9685f3 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -335,6 +335,7 @@ add_executable(openads_unit_tests unit/network_empty_window_test.cpp unit/network_remote_reindex_test.cpp unit/network_frame_trace_test.cpp + unit/network_commit_slimming_test.cpp unit/network_teardown_batch_test.cpp unit/network_pool_mt_test.cpp unit/network_mutex_release_test.cpp diff --git a/tests/unit/network_commit_slimming_test.cpp b/tests/unit/network_commit_slimming_test.cpp new file mode 100644 index 00000000..4c4769af --- /dev/null +++ b/tests/unit/network_commit_slimming_test.cpp @@ -0,0 +1,299 @@ +// tests/unit/network_commit_slimming_test.cpp +// Commit-path slimming for the Vouch WAN voucher save (mtfix8): +// a) AdsUnlockTable with an empty client lock ledger sends no frame +// (rddads dbUnlock() calls it blindly before every lock/append); +// b) AdsGetAllLocks answers from the ledger while it is provably +// complete (no append auto-lock outstanding, no table lock); +// c) AdsWriteRecord on a clean table sends no FlushTable frame +// (DBCOMMITALL touches every workarea), and on a +// kCapFlushTableDurable server the trailing AdsFlushFileBuffers +// owes nothing either -- one frame per commit; +// d) a table whose locks were all released parks at close instead +// of real-closing (the blunt ever_locked flag used to force a +// 7-frame reopen per save); +// e) DirExist/DirMake answers cache per session (positive and +// negative), and repeated missing-FileExists probes cache too. +#include "doctest.h" +#include "mgmt/mg_stats.h" +#include "network/server.h" +#include "openads/ace.h" + +#include +#include +#include + +namespace fs = std::filesystem; + +namespace { + +constexpr std::uint8_t kOpOpenTable = 0x20; +constexpr std::uint8_t kOpCloseTable = 0x22; +constexpr std::uint8_t kOpGetAllLocks = 0x15; +constexpr std::uint8_t kOpFlushTable = 0x5A; +constexpr std::uint8_t kOpUnlockTable = 0x78; +constexpr std::uint8_t kOpFlushFile = 0x7E; +constexpr std::uint8_t kOpFileExists = 0xE0; +constexpr std::uint8_t kOpDirExist = 0xEC; +constexpr std::uint8_t kOpDirMake = 0xEE; + +fs::path cs_tmp_dir() { + return fs::temp_directory_path() / "openads_commit_slim_test"; +} + +void cs_wipe() { + std::error_code ec; + fs::remove_all(cs_tmp_dir(), ec); + fs::create_directories(cs_tmp_dir(), ec); +} + +void cs_seed(const fs::path& dir) { + UNSIGNED8 srv[512]{}; + std::memcpy(srv, dir.string().c_str(), dir.string().size()); + ADSHANDLE hConn = 0; + REQUIRE(AdsConnect60(srv, ADS_LOCAL_SERVER, nullptr, nullptr, 0, &hConn) + == AE_SUCCESS); + UNSIGNED8 def[] = "ID,N,8,0"; + UNSIGNED8 tname[] = "CS.DBF"; + ADSHANDLE hT = 0; + REQUIRE(AdsCreateTable(hConn, tname, nullptr, ADS_CDX, 0, 0, 0, 0, def, + &hT) == AE_SUCCESS); + UNSIGNED8 fld[] = "ID"; + for (int i = 1; i <= 5; ++i) { + REQUIRE(AdsAppendRecord(hT) == AE_SUCCESS); + REQUIRE(AdsSetDouble(hT, fld, i * 10) == AE_SUCCESS); + REQUIRE(AdsWriteRecord(hT) == AE_SUCCESS); + } + REQUIRE(AdsCloseTable(hT) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); +} + +std::uint64_t cs_op(std::uint8_t op) { + return openads::mgmt::process_mg_stats() + .op_timing[op].count.load(std::memory_order_relaxed); +} + +ADSHANDLE cs_connect_remote(const fs::path& dir, std::uint16_t port) { + char uri[512]{}; + std::snprintf(uri, sizeof(uri), "tcp://127.0.0.1:%u/%s", + static_cast(port), dir.string().c_str()); + UNSIGNED8 srvbuf[512]{}; + std::memcpy(srvbuf, uri, std::strlen(uri) + 1); + ADSHANDLE hConn = 0; + REQUIRE(AdsConnect60(srvbuf, ADS_REMOTE_SERVER, nullptr, nullptr, 0, + &hConn) == AE_SUCCESS); + return hConn; +} + +ADSHANDLE cs_open(ADSHANDLE hConn) { + UNSIGNED8 tname[] = "CS.DBF"; + ADSHANDLE hTable = 0; + REQUIRE(AdsOpenTable(hConn, tname, nullptr, ADS_CDX, ADS_ANSI, ADS_SHARED, + ADS_COMPATIBLE_LOCKING, ADS_DEFAULT, &hTable) + == AE_SUCCESS); + return hTable; +} + +} // namespace + +TEST_CASE("Commit slimming: blind UnlockTable and GetAllLocks stay local") { + cs_wipe(); + auto dir = cs_tmp_dir(); + cs_seed(dir); + + openads::network::Server srv; + REQUIRE(srv.start("127.0.0.1", 0).has_value()); + ADSHANDLE hConn = cs_connect_remote(dir, srv.port()); + ADSHANDLE hTable = cs_open(hConn); + + // Fresh table, nothing held: dbUnlock() costs nothing. + std::uint64_t un0 = cs_op(kOpUnlockTable); + REQUIRE(AdsUnlockTable(hTable) == AE_SUCCESS); + CHECK(cs_op(kOpUnlockTable) == un0); + + // A held lock forces the real frame; then the ledger is clear and + // the next blind unlock is free again. + REQUIRE(AdsLockRecord(hTable, 1) == AE_SUCCESS); + REQUIRE(AdsUnlockTable(hTable) == AE_SUCCESS); + CHECK(cs_op(kOpUnlockTable) == un0 + 1); + REQUIRE(AdsUnlockTable(hTable) == AE_SUCCESS); + CHECK(cs_op(kOpUnlockTable) == un0 + 1); + + // GetAllLocks answers from the ledger with the right recnos. + REQUIRE(AdsLockRecord(hTable, 1) == AE_SUCCESS); + REQUIRE(AdsLockRecord(hTable, 3) == AE_SUCCESS); + const std::uint64_t gal0 = cs_op(kOpGetAllLocks); + UNSIGNED32 recs[8]{}; + UNSIGNED16 n = 8; + REQUIRE(AdsGetAllLocks(hTable, recs, &n) == AE_SUCCESS); + CHECK(cs_op(kOpGetAllLocks) == gal0); + CHECK(n == 2u); + bool saw1 = false, saw3 = false; + for (UNSIGNED16 i = 0; i < n && i < 8; ++i) { + saw1 = saw1 || recs[i] == 1u; + saw3 = saw3 || recs[i] == 3u; + } + CHECK(saw1); + CHECK(saw3); + + // An append auto-locks server-side with no recno in the ack: the + // ledger goes uncertain, GetAllLocks returns to the wire, and the + // following UnlockTable really goes out (and releases it). + REQUIRE(AdsAppendRecord(hTable) == AE_SUCCESS); + n = 8; + REQUIRE(AdsGetAllLocks(hTable, recs, &n) == AE_SUCCESS); + CHECK(cs_op(kOpGetAllLocks) == gal0 + 1); + CHECK(n >= 3u); // 1, 3 and the auto-locked blank + un0 = cs_op(kOpUnlockTable); + REQUIRE(AdsUnlockTable(hTable) == AE_SUCCESS); + CHECK(cs_op(kOpUnlockTable) == un0 + 1); + + // Ledger provably empty again: local answer, zero locks. + n = 8; + REQUIRE(AdsGetAllLocks(hTable, recs, &n) == AE_SUCCESS); + CHECK(cs_op(kOpGetAllLocks) == gal0 + 1); + CHECK(n == 0u); + + REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); + srv.stop(); +} + +TEST_CASE("Commit slimming: clean commit and durable FlushTable") { + cs_wipe(); + auto dir = cs_tmp_dir(); + cs_seed(dir); + + openads::network::Server srv; + REQUIRE(srv.start("127.0.0.1", 0).has_value()); + ADSHANDLE hConn = cs_connect_remote(dir, srv.port()); + ADSHANDLE hTable = cs_open(hConn); + + // Nothing written since open: COMMIT sends no FlushTable. + const std::uint64_t ft0 = cs_op(kOpFlushTable); + const std::uint64_t ff0 = cs_op(kOpFlushFile); + REQUIRE(AdsWriteRecord(hTable) == AE_SUCCESS); + CHECK(cs_op(kOpFlushTable) == ft0); + + // A real write: FlushTable goes out once, and the durable server + // makes the trailing FlushFileBuffers a no-op. + UNSIGNED8 fld[] = "ID"; + REQUIRE(AdsLockRecord(hTable, 2) == AE_SUCCESS); + REQUIRE(AdsSetDouble(hTable, fld, 99.0) == AE_SUCCESS); + REQUIRE(AdsWriteRecord(hTable) == AE_SUCCESS); + CHECK(cs_op(kOpFlushTable) == ft0 + 1); + REQUIRE(AdsFlushFileBuffers(hTable) == AE_SUCCESS); + CHECK(cs_op(kOpFlushFile) == ff0); + REQUIRE(AdsUnlockTable(hTable) == AE_SUCCESS); + + // The write really landed: a fresh open reads 99 back. + REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); + ADSHANDLE hT2 = cs_open(hConn); + REQUIRE(AdsGotoRecord(hT2, 2) == AE_SUCCESS); + double val = 0.0; + REQUIRE(AdsGetDouble(hT2, fld, &val) == AE_SUCCESS); + CHECK(val == 99.0); + REQUIRE(AdsCloseTable(hT2) == AE_SUCCESS); + + REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); + srv.stop(); +} + +TEST_CASE("Commit slimming: released-lock tables park at close") { + cs_wipe(); + auto dir = cs_tmp_dir(); + cs_seed(dir); + + openads::network::Server srv; + REQUIRE(srv.start("127.0.0.1", 0).has_value()); + ADSHANDLE hConn = cs_connect_remote(dir, srv.port()); + ADSHANDLE hTable = cs_open(hConn); + + // Lock, unlock, close: nothing held at close, so the table parks + // (no CloseTable frame) and the reopen is a park hit. + REQUIRE(AdsLockRecord(hTable, 4) == AE_SUCCESS); + REQUIRE(AdsUnlockTable(hTable) == AE_SUCCESS); + const std::uint64_t cl0 = cs_op(kOpCloseTable); + const std::uint64_t op0 = cs_op(kOpOpenTable); + REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); + CHECK(cs_op(kOpCloseTable) == cl0); + + ADSHANDLE hT2 = cs_open(hConn); + CHECK(cs_op(kOpOpenTable) == op0); + REQUIRE(AdsGotoRecord(hT2, 4) == AE_SUCCESS); + UNSIGNED8 fld[] = "ID"; + double val = 0.0; + REQUIRE(AdsGetDouble(hT2, fld, &val) == AE_SUCCESS); + CHECK(val == 40.0); + REQUIRE(AdsCloseTable(hT2) == AE_SUCCESS); + + // Append + write + unlock + close: the auto-lock is released, so + // this parks too -- the old ever_locked rule real-closed here. + ADSHANDLE hT3 = cs_open(hConn); + REQUIRE(AdsAppendRecord(hT3) == AE_SUCCESS); + REQUIRE(AdsSetDouble(hT3, fld, 60.0) == AE_SUCCESS); + REQUIRE(AdsWriteRecord(hT3) == AE_SUCCESS); + REQUIRE(AdsUnlockTable(hT3) == AE_SUCCESS); + const std::uint64_t cl1 = cs_op(kOpCloseTable); + REQUIRE(AdsCloseTable(hT3) == AE_SUCCESS); + CHECK(cs_op(kOpCloseTable) == cl1); + + // A lock STILL held at close keeps the real close (no park leak). + ADSHANDLE hT4 = cs_open(hConn); + REQUIRE(AdsLockRecord(hT4, 1) == AE_SUCCESS); + const std::uint64_t cl2 = cs_op(kOpCloseTable); + REQUIRE(AdsCloseTable(hT4) == AE_SUCCESS); + CHECK(cs_op(kOpCloseTable) == cl2 + 1); + + REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); + srv.stop(); +} + +TEST_CASE("Commit slimming: dir and missing-file answers cache") { + cs_wipe(); + auto dir = cs_tmp_dir(); + cs_seed(dir); + + openads::network::Server srv; + REQUIRE(srv.start("127.0.0.1", 0).has_value()); + srv.set_enable_file_func(true); + ADSHANDLE hConn = cs_connect_remote(dir, srv.port()); + + // Existing dir: one probe, then local answers; DirMake is skipped + // outright once the dir is known. + const std::uint64_t de0 = cs_op(kOpDirExist); + const std::uint64_t dm0 = cs_op(kOpDirMake); + UNSIGNED16 ex = 0; + REQUIRE(AdsDirExist(hConn, (UNSIGNED8*)".", &ex) == AE_SUCCESS); + CHECK(ex == 1u); + REQUIRE(AdsDirExist(hConn, (UNSIGNED8*)".", &ex) == AE_SUCCESS); + CHECK(cs_op(kOpDirExist) == de0 + 1); + REQUIRE(AdsDirMake(hConn, (UNSIGNED8*)".") == AE_SUCCESS); + CHECK(cs_op(kOpDirMake) == dm0); + + // A fresh DirMake goes out once and then the dir is known. + REQUIRE(AdsDirMake(hConn, (UNSIGNED8*)"cs_sub") == AE_SUCCESS); + CHECK(cs_op(kOpDirMake) == dm0 + 1); + REQUIRE(AdsDirExist(hConn, (UNSIGNED8*)"cs_sub", &ex) == AE_SUCCESS); + CHECK(ex == 1u); + CHECK(cs_op(kOpDirExist) == de0 + 1); + + // Our own DirRemove flips the cached answer to missing with no + // further probes. + REQUIRE(AdsDirRemove(hConn, (UNSIGNED8*)"cs_sub") == AE_SUCCESS); + REQUIRE(AdsDirExist(hConn, (UNSIGNED8*)"cs_sub", &ex) == AE_SUCCESS); + CHECK(ex == 0u); + CHECK(cs_op(kOpDirExist) == de0 + 1); + + // Missing file: repeated "no" answers stop hitting the wire. + const std::uint64_t fe0 = cs_op(kOpFileExists); + REQUIRE(AdsCheckExistence(hConn, (UNSIGNED8*)"NOPE.CDX", &ex) + == AE_SUCCESS); + CHECK(ex == 0u); + REQUIRE(AdsCheckExistence(hConn, (UNSIGNED8*)"NOPE.CDX", &ex) + == AE_SUCCESS); + CHECK(ex == 0u); + CHECK(cs_op(kOpFileExists) == fe0 + 1); + + REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); + srv.stop(); +} diff --git a/tests/unit/network_open_warm_test.cpp b/tests/unit/network_open_warm_test.cpp index 221fc0a2..9d67d97d 100644 --- a/tests/unit/network_open_warm_test.cpp +++ b/tests/unit/network_open_warm_test.cpp @@ -168,7 +168,8 @@ TEST_CASE("Pooled re-USE: close/reopen skips OpenTable+CloseTable frames") { srv.stop(); } -TEST_CASE("Pooled re-USE skipped after a lock (real close)") { ow_wipe(); +TEST_CASE("Pooled re-USE after a released lock still parks") { + ow_wipe(); auto dir = ow_tmp_dir(); seed_ow_fixture(dir); @@ -194,12 +195,14 @@ TEST_CASE("Pooled re-USE skipped after a lock (real close)") { ow_wipe(); REQUIRE(AdsUnlockRecord(hTable, 0) == AE_SUCCESS); REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); - // Locked once: pool-ineligible, so the reopen is a full wire open. + // Locked then fully released: still poolable (mtfix8) -- the close + // parks the handle and the reopen is a park hit, no wire open. Only + // outstanding locks (locks_held) force a real close now. const std::uint64_t open_before = op_count(0x20); REQUIRE(AdsOpenTable(hConn, tname, nullptr, ADS_CDX, ADS_ANSI, ADS_SHARED, ADS_COMPATIBLE_LOCKING, ADS_DEFAULT, &hTable) == AE_SUCCESS); - CHECK(op_count(0x20) == open_before + 1); + CHECK(op_count(0x20) == open_before); CHECK(ow_get(hTable, "NM") == "alpha"); REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); From 9c6d1e873f1a339822701c3c8df229266e0ab41b Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Fri, 25 Sep 2026 08:21:20 -0500 Subject: [PATCH 24/97] Create release-notes-1.09.68-mtfix8.md --- release-notes-1.09.68-mtfix8.md | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 release-notes-1.09.68-mtfix8.md diff --git a/release-notes-1.09.68-mtfix8.md b/release-notes-1.09.68-mtfix8.md new file mode 100644 index 00000000..c8ba93ba --- /dev/null +++ b/release-notes-1.09.68-mtfix8.md @@ -0,0 +1,25 @@ +# OpenADS test build 1.09.68-mtfix8 (TEST ONLY - not for production) + +Built for Pritpal Bedi - bedipritpal/OpenADS, forked from FiveTechSoft/OpenADS. + +Branch: perf/safe-local-answers on bedipritpal/OpenADS. Based on mtfix7-diag / 1.09.68. Not an official release. Nothing merged to main or upstream. + +## What's in it +Everything in 1.09.68-mtfix7-diag, plus four client-side round-trip cuts found by your wire trace (no Vouch application change): + +1. Commit slimming. The trace showed each voucher save paying frames that release or flush nothing: + - UnlockTable is skipped when the client knows no locks are held (rddads calls it before every lock and every append - 1 round trip each time). + - GetAllLocks is answered from the client's own lock list when that list is provably complete (1 round trip after every commit). + - COMMIT on a table with no changes since the last flush sends nothing (kills the 16-frame flush storm after every voucher save). + - With an mtfix8 SERVER, FlushTable now includes the full file flush, so the trailing FlushFileBuffers frame goes away too (one frame per commit instead of two). This one saving needs the server updated; the other three work against your current server. Mixed old/new is safe both ways - the old pair of frames is kept automatically when the server is older. +2. Locked-then-released tables park instead of closing. GN_COUNT, FA_ACC01 and USASELOG each paid a full 7-frame reopen per save because they had once held a lock. Locks actually still held at close still force a real close, so no lock can leak to other users. +3. Directory and missing-file answers cache per session. Startup DirExist/DirMake probes and "is this optional file there" checks stop repeating round trips. Note: a file created by another workstation mid-session can be reported missing until your session itself creates or deletes something - tell me if you hit that. + +## What to test +- Turn on wire_trace as before, post one voucher, send the new log. The save should show "skipped: no locks held", "clean: flush skipped" and "served from client lock ledger" lines, far fewer FlushTable/FlushFileBuffers frames, and no full reopen of GN_COUNT. +- Expected: roughly 40-60 fewer round trips per voucher save than the mtfix7-diag trace (about 2-3s at your ~43ms latency), plus up to ~1.5s off startup. +- Also run a normal startup, a reindex, and two or three Vouch instances at once - the lock handling changed, so watch for any user being blocked from a record they just unlocked (that would be a bug, report it). +- For the full saving, update openads_serverd on Lightsail with this build using your usual script. Client-only already gives most of it. + +## Packages +- openads-1.09.68-mtfix8-windows-x86.zip, windows-x64.zip, linux-x64.tar.gz, macos-universal.tar.gz From f4079bed0fbc1127d53187291f7bd0c65bc4719f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898283+github-actions[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 13:50:19 +0000 Subject: [PATCH 25/97] mtfix9: keep nav stamp across CloseAll/OpenIndex park-unpark (skip refresh GotoTop) Applied by apply-patch workflow, run 36142833792, started by bedipritpal. --- src/abi/ace_exports.cpp | 25 ++++++++ src/network/client.h | 9 +++ tests/unit/network_teardown_batch_test.cpp | 71 ++++++++++++++++++++++ 3 files changed, 105 insertions(+) diff --git a/src/abi/ace_exports.cpp b/src/abi/ace_exports.cpp index be1927bd..123cfcee 100644 --- a/src/abi/ace_exports.cpp +++ b/src/abi/ace_exports.cpp @@ -743,6 +743,7 @@ UNSIGNED32 remote_emit_close_all(openads::network::RemoteTable* rt) { } rt->close_all_indexes_pending = false; rt->indexes_parked = false; + rt->parked_nav_which = 0; rt->parked_by_tag.clear(); rt->parked_handles.clear(); rt->index_by_tag.clear(); @@ -8319,6 +8320,7 @@ UNSIGNED32 ENTRYPOINT AdsOpenTable(ADSHANDLE hConnect, adopted->found_cached = false; adopted->nav_at_bof = adopted->nav_at_eof = false; adopted->last_nav = 0; // re-stamped by the warm GotoTop below + adopted->parked_nav_which = 0; adopted->flush_file_pending = false; adopted->close_all_indexes_pending = false; adopted->pending_order = false; @@ -15467,6 +15469,21 @@ UNSIGNED32 ENTRYPOINT AdsOpenIndex(ADSHANDLE hTable, UNSIGNED8* pucName, rt->active_index_id = rt->parked_active; rt->indexes_parked = false; rt->close_all_indexes_pending = false; + // Restore the nav stamp parked at CloseAll when it still + // proves the server cursor: same order coming back, no + // cursor-affecting frame since the park (seq gate). The + // post-unpark GotoTop(idx) then dedupes locally instead + // of paying a refresh RTT for a position the server + // never lost. + if (rt->parked_nav_which != 0 && + rt->parked_nav_order == rt->parked_active && + rt->parked_nav_seq == rt->conn->nav_seq()) { + rt->last_nav = rt->parked_nav_which; + rt->last_nav_order = rt->parked_nav_order; + rt->last_nav_row = rt->parked_nav_row; + rt->last_nav_seq = rt->parked_nav_seq; + } + rt->parked_nav_which = 0; cli_trace_tbl(rt, "AdsOpenIndex", "unpark hit %.32s", rt->parked_bag_stem.c_str()); auto& s = state(); @@ -16034,6 +16051,14 @@ UNSIGNED32 ENTRYPOINT AdsCloseAllIndexes(ADSHANDLE hTable) { rt->index_handles.clear(); rt->active_index_id = 0; rt->indexes_parked = true; + // Park the nav stamp with the bindings: if the same order + // comes back via an unpark with no intervening wire nav + // (seq-gated), the post-unpark GotoTop dedupes instead of + // paying a refresh frame for state the server never lost. + rt->parked_nav_which = rt->last_nav; + rt->parked_nav_order = rt->last_nav_order; + rt->parked_nav_row = rt->last_nav_row; + rt->parked_nav_seq = rt->last_nav_seq; cli_trace_tbl(rt, "AdsCloseAllIndexes", "parked %u tags", ntags); } else if (rt->indexes_parked) { cli_trace_tbl(rt, "AdsCloseAllIndexes", "repeat clear, park kept"); diff --git a/src/network/client.h b/src/network/client.h index c33b9be0..1505533a 100644 --- a/src/network/client.h +++ b/src/network/client.h @@ -992,6 +992,15 @@ struct RemoteTable { // adopt-or-emit decision, so a parked snapshot can never reference // dead server ids. bool indexes_parked = false; + // Nav stamp parked alongside the index snapshot: the CloseAll -> + // OpenIndex (unpark) cycle sends no frames, so a stamp taken just + // before the park still describes the live server cursor as long as + // nav_seq is unchanged when the same order is restored. Zeroed + // whenever the parked snapshot dies for real. + int parked_nav_which = 0; + std::uint32_t parked_nav_order = 0; + bool parked_nav_row = false; + std::uint64_t parked_nav_seq = 0; std::vector> parked_by_tag; std::vector parked_handles; std::uint32_t parked_active = 0; diff --git a/tests/unit/network_teardown_batch_test.cpp b/tests/unit/network_teardown_batch_test.cpp index 4dc8e914..ddd0d4fa 100644 --- a/tests/unit/network_teardown_batch_test.cpp +++ b/tests/unit/network_teardown_batch_test.cpp @@ -672,3 +672,74 @@ TEST_CASE("KeyCount: rotation revisits ride the per-order map") { REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); srv.stop(); } + +TEST_CASE("Parked nav stamp survives CloseAll/OpenIndex unpark") { + tb_wipe(); + auto dir = tb_tmp_dir(); + tb_seed(dir); + + openads::network::Server srv; + REQUIRE(srv.start("127.0.0.1", 0).has_value()); + ADSHANDLE hConn = tb_connect_remote(dir, srv.port()); + ADSHANDLE hTable = tb_open(hConn); + + // Bind the bag and take the order to its top: one wire GotoTop that + // stamps (top, order) on the client. + UNSIGNED8 bag[] = "TB.CDX"; + ADSHANDLE hIdx = 0; + UNSIGNED16 nidx = 1; + REQUIRE(AdsOpenIndex(hTable, bag, &hIdx, &nidx) == AE_SUCCESS); + REQUIRE(nidx >= 1); + REQUIRE(AdsGotoTop(hIdx) == AE_SUCCESS); + + // The rddads rotation pattern: OrdListClear (parked, no frame) then + // the same bag reopened (unpark hit, no frame). + const std::uint64_t gt_before = tb_op(kOpGotoTop); + REQUIRE(AdsCloseAllIndexes(hTable) == AE_SUCCESS); + ADSHANDLE hIdx2 = 0; + nidx = 1; + REQUIRE(AdsOpenIndex(hTable, bag, &hIdx2, &nidx) == AE_SUCCESS); + // The post-unpark GotoTop(idx) must dedupe against the parked stamp: + // the server never moved, so no refresh frame is owed. + REQUIRE(AdsGotoTop(hIdx2) == AE_SUCCESS); + CHECK(tb_op(kOpGotoTop) == gt_before); + UNSIGNED32 rec = 0; + REQUIRE(AdsGetRecordNum(hTable, ADS_IGNOREFILTERS, &rec) == AE_SUCCESS); + CHECK(rec == 1u); + + REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); + srv.stop(); +} + +TEST_CASE("Parked nav stamp does not survive an intervening wire nav") { + tb_wipe(); + auto dir = tb_tmp_dir(); + tb_seed(dir); + + openads::network::Server srv; + REQUIRE(srv.start("127.0.0.1", 0).has_value()); + ADSHANDLE hConn = tb_connect_remote(dir, srv.port()); + ADSHANDLE hTable = tb_open(hConn); + + UNSIGNED8 bag[] = "TB.CDX"; + ADSHANDLE hIdx = 0; + UNSIGNED16 nidx = 1; + REQUIRE(AdsOpenIndex(hTable, bag, &hIdx, &nidx) == AE_SUCCESS); + REQUIRE(AdsGotoTop(hIdx) == AE_SUCCESS); + + REQUIRE(AdsCloseAllIndexes(hTable) == AE_SUCCESS); + // A wire nav inside the window bumps the cursor seq: the parked + // stamp no longer proves anything and must be ignored. + REQUIRE(AdsGotoBottom(hTable) == AE_SUCCESS); + ADSHANDLE hIdx2 = 0; + nidx = 1; + REQUIRE(AdsOpenIndex(hTable, bag, &hIdx2, &nidx) == AE_SUCCESS); + const std::uint64_t gt_before = tb_op(kOpGotoTop); + REQUIRE(AdsGotoTop(hIdx2) == AE_SUCCESS); + CHECK(tb_op(kOpGotoTop) == gt_before + 1); + + REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); + srv.stop(); +} From cdbe631cf9b2109e8ab785fe2addc9ffe3dea220 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Fri, 25 Sep 2026 10:22:08 -0500 Subject: [PATCH 26/97] Create release-notes-1.09.68-mtfix9.md --- release-notes-1.09.68-mtfix9.md | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 release-notes-1.09.68-mtfix9.md diff --git a/release-notes-1.09.68-mtfix9.md b/release-notes-1.09.68-mtfix9.md new file mode 100644 index 00000000..05777cd7 --- /dev/null +++ b/release-notes-1.09.68-mtfix9.md @@ -0,0 +1,22 @@ +# OpenADS test build 1.09.68-mtfix9 (TEST ONLY - not for production) + +Built for Pritpal Bedi - bedipritpal/OpenADS, forked from FiveTechSoft/OpenADS. + +Branch: perf/safe-local-answers on bedipritpal/OpenADS. Based on mtfix8 / 1.09.68. Not an official release. Nothing merged to main or upstream. + +## What's in it +Everything in 1.09.68-mtfix8, plus one client-side round-trip cut: + +Navigation stamps survive the CloseAllIndexes/OpenIndex rotation. rddads closes and reopens the index set around many operations, and each reopen paid an extra GotoTop round trip to reposition (57 of those frames in your mtfix8 trace). The client now parks the position stamp when the index set closes and restores it on the matching reopen when the order is unchanged, so that frame goes away. Client-only change, no wire-protocol difference - works against any server version, mixed old/new is safe. + +## Measured so far (your mtfix8 trace) +- Round trips: 1,079 -> 1,009, wire time 47.1s -> 44.1s. +- Voucher save: 280 -> 230 round trips, 12.4s -> 9.7s wire. +- Flush storm gone: FlushTable 46 -> 15, FlushFileBuffers 19 -> 0. + +## What to test +- Turn on wire_trace as before, post one voucher, send the new log. Expect "unpark hit" lines on reopened indexes and roughly 50-60 fewer round trips than the mtfix8 run (about 2.5s at your ~43ms latency), mostly from dropped GotoTop frames. +- Also run a normal startup, a reindex, and two or three Vouch instances at once. + +## Packages +- openads-1.09.68-mtfix9-windows-x86.zip, windows-x64.zip, linux-x64.tar.gz, macos-universal.tar.gz From 327b68f0d24ba4cde30fc059272d3ec8e34d9585 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898283+github-actions[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 17:01:47 +0000 Subject: [PATCH 27/97] mtfix10: AdsGetNumLocks ledger fast path + open-table FileExists short-circuit Applied by apply-patch workflow, run 36163870106, started by bedipritpal. --- src/abi/ace_exports.cpp | 26 +++++++++- src/network/client.cpp | 56 +++++++++++++++------ src/network/client.h | 5 +- tests/unit/network_commit_slimming_test.cpp | 33 +++++++++++- 4 files changed, 99 insertions(+), 21 deletions(-) diff --git a/src/abi/ace_exports.cpp b/src/abi/ace_exports.cpp index 123cfcee..f27e7b70 100644 --- a/src/abi/ace_exports.cpp +++ b/src/abi/ace_exports.cpp @@ -10721,14 +10721,26 @@ UNSIGNED32 ENTRYPOINT AdsCheckExistence(ADSHANDLE hConn, UNSIGNED8* pucName, if (rt == nullptr || rt->conn != ctx.remote) continue; if (stem_of(rt->prod_bag_path) == want || (!rt->last_open_bag.empty() && - stem_of(rt->last_open_bag) == want)) { + stem_of(rt->last_open_bag) == want) || + stem_of(rt->name) == want) { + // rt->name is the table's own path as opened: a + // live (or parked) table proves its dbf exists. *pbExists = 1; return ok(); } } } - auto r = ctx.remote->file_exists(name); + int fe_src = 2; + auto r = ctx.remote->file_exists(name, &fe_src); if (!r) return fail(r.error()); + // Probe-level visibility: the frame hook only logs wire misses, + // so cache hits used to be invisible in the trace. One line per + // probe with the REAL path (the frame hook's tid is a truncated + // hash that cannot distinguish same-length paths). + cli_trace("FileExists", "probe %s -> %s (%s)", name.c_str(), + r.value() ? "yes" : "no", + fe_src == 0 ? "pos-cache" : + fe_src == 1 ? "neg-cache" : "wire"); *pbExists = r.value() ? 1 : 0; return ok(); } @@ -37714,6 +37726,16 @@ UNSIGNED32 ENTRYPOINT AdsGetNumLocks(ADSHANDLE hTable, UNSIGNED16* p) { // Remote: route through the wire GetAllLocks op and count (was a stub // returning 0 — lock introspection on remote tables reported nothing). if (auto* rt = get_remote_table(hTable)) { + // Same ledger fast path as AdsGetAllLocks: while the ledger is + // provably complete (no append auto-lock outstanding, no table + // lock), the count is the ledger size. rddads polls this after + // every commit -- answering locally saves 1 RTT each time. + if (!rt->locks_uncertain && !rt->table_lock_held) { + cli_trace_tbl(rt, "AdsGetNumLocks", + "served from client lock ledger"); + *p = static_cast(rt->held_recs.size()); + return ok(); + } auto r = rt->conn->get_all_locks(rt->id); if (!r) return fail(r.error()); *p = static_cast(r.value().size()); diff --git a/src/network/client.cpp b/src/network/client.cpp index 2221dd77..be096b84 100644 --- a/src/network/client.cpp +++ b/src/network/client.cpp @@ -6,6 +6,7 @@ #include #include +#include #include #include #include @@ -2497,14 +2498,34 @@ std::uint64_t read_u64_le(const std::uint8_t* p) { return v; } +// Existence-cache key: the server separator-normalizes client paths +// (fs_sandbox), so "/" and "\\" spellings of one file are the same +// file. Case is NOT folded: the server fs may be case-sensitive (Linux +// hosts), where "A.DBF" and "a.dbf" are legitimately different files. +std::string fs_cache_key(const std::string& path) { + std::string k = path; + for (auto& ch : k) { + if (ch == '/') ch = '\\'; + } + return k; +} + } // namespace util::Result -RemoteConnection::file_exists(const std::string& path) { +RemoteConnection::file_exists(const std::string& path, int* src) { + if (src != nullptr) *src = 2; + const std::string key = fs_cache_key(path); { std::lock_guard lk(file_exists_mu_); - if (file_exists_cache_.count(path) != 0) return true; - if (file_exists_neg_cache_.count(path) != 0) return false; + if (file_exists_cache_.count(key) != 0) { + if (src != nullptr) *src = 0; + return true; + } + if (file_exists_neg_cache_.count(key) != 0) { + if (src != nullptr) *src = 1; + return false; + } } Frame req; req.opcode = Opcode::FileExists; @@ -2518,10 +2539,10 @@ RemoteConnection::file_exists(const std::string& path) { { std::lock_guard lk(file_exists_mu_); if (exists) { - file_exists_cache_.insert(path); - file_exists_neg_cache_.erase(path); + file_exists_cache_.insert(key); + file_exists_neg_cache_.erase(key); } else { - file_exists_neg_cache_.insert(path); + file_exists_neg_cache_.insert(key); } } return exists; @@ -2768,10 +2789,11 @@ RemoteConnection::zip_list(const std::string& zip) { util::Result RemoteConnection::dir_exist(const std::string& path) { + const std::string key = fs_cache_key(path); { std::lock_guard lk(dir_cache_mu_); - if (dir_known_.count(path) != 0) return true; - if (dir_missing_.count(path) != 0) return false; + if (dir_known_.count(key) != 0) return true; + if (dir_missing_.count(key) != 0) return false; } Frame req; req.opcode = Opcode::DirExist; @@ -2785,10 +2807,10 @@ RemoteConnection::dir_exist(const std::string& path) { { std::lock_guard lk(dir_cache_mu_); if (exists) { - dir_known_.insert(path); - dir_missing_.erase(path); + dir_known_.insert(key); + dir_missing_.erase(key); } else { - dir_missing_.insert(path); + dir_missing_.insert(key); } } return exists; @@ -2796,9 +2818,10 @@ RemoteConnection::dir_exist(const std::string& path) { util::Result RemoteConnection::dir_make(const std::string& path) { + const std::string key = fs_cache_key(path); { std::lock_guard lk(dir_cache_mu_); - if (dir_known_.count(path) != 0) return {}; + if (dir_known_.count(key) != 0) return {}; } Frame req; req.opcode = Opcode::DirMake; @@ -2808,8 +2831,8 @@ RemoteConnection::dir_make(const std::string& path) { if (rep.value().opcode != Opcode::DirMakeAck) return fs_wire_err(rep.value(), "DirMake"); std::lock_guard lk(dir_cache_mu_); - dir_known_.insert(path); - dir_missing_.erase(path); + dir_known_.insert(key); + dir_missing_.erase(key); return {}; } @@ -2823,8 +2846,9 @@ RemoteConnection::dir_remove(const std::string& path) { if (rep.value().opcode != Opcode::DirRemoveAck) return fs_wire_err(rep.value(), "DirRemove"); std::lock_guard lk(dir_cache_mu_); - dir_known_.erase(path); - dir_missing_.insert(path); + const std::string rkey = fs_cache_key(path); + dir_known_.erase(rkey); + dir_missing_.insert(rkey); return {}; } diff --git a/src/network/client.h b/src/network/client.h index 1505533a..0ef004ae 100644 --- a/src/network/client.h +++ b/src/network/client.h @@ -382,7 +382,10 @@ class RemoteConnection { // via file_exists_invalidate) clears it. Negatives always go to // the wire — caching "missing" would hide a concurrently created // table, while a stale "present" degrades to a clean open error. - util::Result file_exists(const std::string& path); + // src (optional out): 0 = positive cache, 1 = negative cache, + // 2 = wire probe. For probe-level trace logging. + util::Result file_exists(const std::string& path, + int* src = nullptr); void file_exists_invalidate(); util::Result file_erase(const std::string& path); util::Result file_rename(const std::string& old_p, diff --git a/tests/unit/network_commit_slimming_test.cpp b/tests/unit/network_commit_slimming_test.cpp index 4c4769af..fdcaf275 100644 --- a/tests/unit/network_commit_slimming_test.cpp +++ b/tests/unit/network_commit_slimming_test.cpp @@ -13,6 +13,12 @@ // 7-frame reopen per save); // e) DirExist/DirMake answers cache per session (positive and // negative), and repeated missing-FileExists probes cache too. +// mtfix10: +// f) AdsGetNumLocks shares the GetAllLocks ledger fast path (rddads +// polls it after every commit/unlock -- it used to ride the wire +// op even when the ledger was provably complete); +// g) a FileExists probe for a table open on this connection answers +// from the open-table store (a live table proves its dbf exists). #include "doctest.h" #include "mgmt/mg_stats.h" #include "network/server.h" @@ -127,6 +133,12 @@ TEST_CASE("Commit slimming: blind UnlockTable and GetAllLocks stay local") { REQUIRE(AdsGetAllLocks(hTable, recs, &n) == AE_SUCCESS); CHECK(cs_op(kOpGetAllLocks) == gal0); CHECK(n == 2u); + + // GetNumLocks shares the ledger fast path (mtfix10). + UNSIGNED16 nl = 0; + REQUIRE(AdsGetNumLocks(hTable, &nl) == AE_SUCCESS); + CHECK(cs_op(kOpGetAllLocks) == gal0); + CHECK(nl == 2u); bool saw1 = false, saw3 = false; for (UNSIGNED16 i = 0; i < n && i < 8; ++i) { saw1 = saw1 || recs[i] == 1u; @@ -143,6 +155,10 @@ TEST_CASE("Commit slimming: blind UnlockTable and GetAllLocks stay local") { REQUIRE(AdsGetAllLocks(hTable, recs, &n) == AE_SUCCESS); CHECK(cs_op(kOpGetAllLocks) == gal0 + 1); CHECK(n >= 3u); // 1, 3 and the auto-locked blank + nl = 0; + REQUIRE(AdsGetNumLocks(hTable, &nl) == AE_SUCCESS); + CHECK(cs_op(kOpGetAllLocks) == gal0 + 2); // uncertain: back to wire + CHECK(nl >= 3u); un0 = cs_op(kOpUnlockTable); REQUIRE(AdsUnlockTable(hTable) == AE_SUCCESS); CHECK(cs_op(kOpUnlockTable) == un0 + 1); @@ -150,8 +166,12 @@ TEST_CASE("Commit slimming: blind UnlockTable and GetAllLocks stay local") { // Ledger provably empty again: local answer, zero locks. n = 8; REQUIRE(AdsGetAllLocks(hTable, recs, &n) == AE_SUCCESS); - CHECK(cs_op(kOpGetAllLocks) == gal0 + 1); + CHECK(cs_op(kOpGetAllLocks) == gal0 + 2); CHECK(n == 0u); + nl = 99; + REQUIRE(AdsGetNumLocks(hTable, &nl) == AE_SUCCESS); + CHECK(cs_op(kOpGetAllLocks) == gal0 + 2); + CHECK(nl == 0u); REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); @@ -284,8 +304,17 @@ TEST_CASE("Commit slimming: dir and missing-file answers cache") { CHECK(ex == 0u); CHECK(cs_op(kOpDirExist) == de0 + 1); - // Missing file: repeated "no" answers stop hitting the wire. + // A table open on this connection proves its own dbf exists: the + // probe answers from the open-table store with no wire op (mtfix10). + ADSHANDLE hTable = cs_open(hConn); const std::uint64_t fe0 = cs_op(kOpFileExists); + REQUIRE(AdsCheckExistence(hConn, (UNSIGNED8*)"CS.DBF", &ex) + == AE_SUCCESS); + CHECK(ex == 1u); + CHECK(cs_op(kOpFileExists) == fe0); + REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); + + // Missing file: repeated "no" answers stop hitting the wire. REQUIRE(AdsCheckExistence(hConn, (UNSIGNED8*)"NOPE.CDX", &ex) == AE_SUCCESS); CHECK(ex == 0u); From 2c3f618842b5a57d432c505dcdbe8884a7be5cc7 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Fri, 25 Sep 2026 12:10:37 -0500 Subject: [PATCH 28/97] Create release-notes-1.09.68-mtfix10.md --- release-notes-1.09.68-mtfix10.md | 49 ++++++++++++++++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 release-notes-1.09.68-mtfix10.md diff --git a/release-notes-1.09.68-mtfix10.md b/release-notes-1.09.68-mtfix10.md new file mode 100644 index 00000000..5539bdc1 --- /dev/null +++ b/release-notes-1.09.68-mtfix10.md @@ -0,0 +1,49 @@ +# v1.09.68-mtfix10 — WAN voucher save, round 10 (TEST BUILD) + +**Built for Pritpal Bedi — bedipritpal/OpenADS, forked from FiveTechSoft/OpenADS.** + +TEST ONLY — drop `ace32.dll` (Windows x86 zip) next to VouchADS.exe. Not for production. + +## What changed (client-side only) + +- **AdsGetNumLocks answers from the client lock ledger** while the ledger is + provably complete (no append auto-lock outstanding, no table lock) — the + same fast path mtfix8 gave AdsGetAllLocks. rddads polls lock counts after + every commit/unlock, and these calls still rode the wire opcode even right + after a real UnlockTable had provably emptied the ledger: ~9 RTTs per + startup+save cycle in the mtfix9 trace. +- **FileExists probe for a table open on the connection answers locally.** + A live table proves its own .dbf exists; the existing production-bag + short-circuit now covers the table's own path too. +- **Existence/dir cache keys unify slash direction** (the server + separator-normalizes, so the spellings are the same file). Case is + deliberately NOT folded: the Linux server fs is case-sensitive. +- **Trace: every FileExists probe now logs its real path and outcome** + (pos-cache / neg-cache / wire). The wire-op line's tid is a truncated + hash that cannot tell same-length paths apart; this line can. The next + trace will show exactly which files probe and why they miss. + +## Expected effect + +~0.5s off time-to-readiness (mtfix9 measured 37.9s). The lock-count polls +are the certain part; FileExists savings depend on how many probes hit the +new short-circuit — the new trace lines will quantify it. + +## Locking discipline (unchanged) + +No implicit locking anywhere: Seek stays pure, and only the app's explicit +dbRLock()/FLock() calls lock. mtfix10 changes only how lock *queries* are +answered, never when locks are taken. + +## Validation + +- New unit tests: GetNumLocks ledger fast path (incl. fallback to wire while + the append auto-lock makes the ledger uncertain), open-table existence + short-circuit. +- Full CI-tier suite green (1577/1577). + +## Verification asks + +1. Time-to-readiness number, same as before. +2. The new trace will contain `probe -> yes/no (pos-cache|neg-cache|wire)` + lines — send it over; those lines drive the next round. From 44ff9608f19814e8edd73bca0327247527f1ec35 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898283+github-actions[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:47:56 +0000 Subject: [PATCH 29/97] mtfix11: AdsIsRecordLocked probes other connections' locks at OS level - own list, then byte-range probe; CDX FLock nuance handled; adds M13.1 cross-connection test Applied by apply-patch workflow, run 36174964696, started by bedipritpal. --- src/abi/ace_exports.cpp | 8 ++- src/engine/lock_mgr.cpp | 13 ++++ src/engine/lock_mgr.h | 20 ++++++ src/engine/table.cpp | 18 +++++ src/engine/table.h | 9 +++ src/network/session.cpp | 19 ++--- src/platform/lock.h | 9 +++ src/platform/lock_posix.cpp | 23 ++++++ src/platform/lock_win32.cpp | 21 ++++++ .../abi_remote_lock_introspection_test.cpp | 72 +++++++++++++++++-- tests/unit/network_lock_exclusion_test.cpp | 7 +- 11 files changed, 200 insertions(+), 19 deletions(-) diff --git a/src/abi/ace_exports.cpp b/src/abi/ace_exports.cpp index f27e7b70..9d0cab97 100644 --- a/src/abi/ace_exports.cpp +++ b/src/abi/ace_exports.cpp @@ -38231,9 +38231,11 @@ UNSIGNED32 ENTRYPOINT AdsIsRecordLocked(ADSHANDLE hTable, UNSIGNED32 ulRecord, Table* t = get_table(hTable); if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); std::uint32_t rec = (ulRecord == 0) ? t->recno() : ulRecord; - for (std::uint32_t held : t->held_record_locks()) { - if (held == rec) { *pbLocked = 1; break; } - } + // mtfix11: SAP answers across connections - own registrations plus a + // non-destructive OS lock-byte probe, not this table's list alone. + auto any = t->is_record_locked_any(rec); + if (!any) return fail(any.error()); + *pbLocked = any.value() ? 1 : 0; return ok(); } UNSIGNED32 ENTRYPOINT AdsIsServerLoaded(UNSIGNED8*, UNSIGNED16* p) diff --git a/src/engine/lock_mgr.cpp b/src/engine/lock_mgr.cpp index cdfae05d..b72d7709 100644 --- a/src/engine/lock_mgr.cpp +++ b/src/engine/lock_mgr.cpp @@ -1,3 +1,4 @@ +#include #include "engine/lock_mgr.h" namespace openads::engine { @@ -150,6 +151,18 @@ LockMgr::try_lock_record_excl(platform::File& f, TableTypeForLock t, LockingMode return LockHandle{std::move(bl).value(), off, 1}; } +util::Result LockMgr::probe_record(platform::File& f, + TableTypeForLock t, LockingMode m, + std::uint32_t recno) { + return platform::ByteLock::probe(f, record_lock_offset(t, m, recno), 1); +} + +util::Result LockMgr::probe_file(platform::File& f, TableTypeForLock t, + LockingMode m) { + return platform::ByteLock::probe(f, file_lock_offset(t, m), + file_lock_length(t)); +} + bool LockMgr::unlock_table(platform::File& f, TableTypeForLock t, LockingMode m) { Key k{&f, file_lock_offset(t, m)}; auto it = held_.find(k); diff --git a/src/engine/lock_mgr.h b/src/engine/lock_mgr.h index 50d1606d..51177f1e 100644 --- a/src/engine/lock_mgr.h +++ b/src/engine/lock_mgr.h @@ -66,6 +66,15 @@ class LockMgr { TableTypeForLock t, LockingMode m, std::uint32_t recno); + // Non-destructive "held by any owner" queries (mtfix11): compute the + // same scheme-aware byte offsets as the lock ops and ask the OS. The + // caller's own registrations are invisible to the query - check them + // first (Table::is_record_locked_any does). + util::Result probe_record(platform::File& f, TableTypeForLock t, + LockingMode m, std::uint32_t recno); + util::Result probe_file (platform::File& f, TableTypeForLock t, + LockingMode m); + // Decrement the per-key refcount. Returns true when the refcount reached // zero so the caller should release the OS-level byte lock held in its // LockHandle; false when nested acquires remain and the OS lock must stay. @@ -83,6 +92,17 @@ class LockMgr { static std::uint64_t file_lock_offset(TableTypeForLock t, LockingMode m); static std::uint64_t file_lock_length(TableTypeForLock t); + // True when the scheme's FLock range spans every record-lock byte + // (Cdx/Vfp: FLock covers 0x40000001..0x7FFFFFFE, records live inside + // it). There a record-byte probe already reports another owner's + // FLock, and probing the file-lock range would false-positive on any + // record lock. Ntx/Adt keep the FLock byte outside the record + // region, so they need a separate file-lock probe for FLock + // visibility. + static bool file_lock_covers_records(TableTypeForLock t) noexcept { + return t == TableTypeForLock::Cdx || t == TableTypeForLock::Vfp; + } + // VFP-scheme record locks mirror the record's physical position, so // the manager needs the table geometry. Set right after the driver // opens (Table::open / from_driver); zeroes degrade to a single diff --git a/src/engine/table.cpp b/src/engine/table.cpp index b78be22e..1a0fa8a6 100644 --- a/src/engine/table.cpp +++ b/src/engine/table.cpp @@ -1972,6 +1972,24 @@ std::vector Table::held_record_locks() const { return out; } +util::Result Table::is_record_locked_any(std::uint32_t recno) { + if (table_lock_.has_value()) return true; + if (recno_locks_.count(recno) != 0) return true; + if (!driver_ || mode_ == OpenMode::Read) return false; + const auto lt = to_lock_type_(); + auto rec = locks_.probe_record(driver_->file(), lt, locking_, recno); + if (!rec) return rec.error(); + if (rec.value()) return true; + // Cdx/Vfp FLock ranges span every record-lock byte, so the record + // probe above already reports another owner's FLock - and a + // file-lock probe here would false-positive on ANY held record + // lock (those bytes live inside the FLock range). Only Ntx/Adt, + // whose FLock byte sits outside the record region, need the + // file-lock probe to see another owner's FLock. + if (LockMgr::file_lock_covers_records(lt)) return false; + return locks_.probe_file(driver_->file(), lt, locking_); +} + util::Result Table::try_lock_table_excl() { if (mode_ == OpenMode::Read) return {}; if (table_lock_) return {}; diff --git a/src/engine/table.h b/src/engine/table.h index 4c6c5b17..25b98adc 100644 --- a/src/engine/table.h +++ b/src/engine/table.h @@ -341,6 +341,15 @@ class Table { // exposing the LockMgr internals. std::vector held_record_locks() const; + // Locked-by-any-owner query (mtfix11, SAP AdsIsRecordLocked + // semantics): this table's own registrations first, then a + // non-destructive OS probe of the record's lock byte and of the + // table's file-lock byte - a record also reads locked while ANOTHER + // owner holds the file lock (FLock covers every record). Read-only + // tables and tables without a driver file answer from the own-list + // only: they cannot hold locks and (Win32) cannot probe either. + util::Result is_record_locked_any(std::uint32_t recno); + // Recno-sequence cursor (M10.6). When non-empty, goto_top / // goto_bottom / skip walk this list of recnos in order instead of // the natural append order or any active index. Used by SQL diff --git a/src/network/session.cpp b/src/network/session.cpp index 5213ad44..14422693 100644 --- a/src/network/session.cpp +++ b/src/network/session.cpp @@ -2925,17 +2925,18 @@ DispatchResult Session::dispatch(const Frame& f) { if (!tbl) { reply = err("IsRecordLocked: lookup failed"); break; } // ACE convention: recno 0 = the current record. if (rn == 0) rn = tbl->recno(); + // mtfix11: SAP AdsIsRecordLocked answers across connections; + // the own-session lock list alone is blind to other sessions + // (login-semaphore guards read this op and silently admitted + // every newcomer). Own registrations plus a non-destructive + // OS lock-byte probe - which also covers locks held through + // this session's ABI twin handle, whose Table object is not + // `tbl`. std::uint16_t locked = 0; - if (auto hit = tbls_h_.find(id); hit != tbls_h_.end()) { - UNSIGNED16 b = 0; - if (AdsIsRecordLocked(hit->second, rn, &b) != 0) { - reply = err("IsRecordLocked: failed"); break; - } - locked = b; + if (auto any = tbl->is_record_locked_any(rn); any) { + locked = any.value() ? 1 : 0; } else { - for (std::uint32_t held : tbl->held_record_locks()) { - if (held == rn) { locked = 1; break; } - } + reply = err("IsRecordLocked: probe failed"); break; } reply.opcode = Opcode::IsRecordLockedAck; write_u16_le(locked, reply.payload); diff --git a/src/platform/lock.h b/src/platform/lock.h index 85db7c19..ae093e1d 100644 --- a/src/platform/lock.h +++ b/src/platform/lock.h @@ -25,6 +25,15 @@ class ByteLock { std::uint64_t length, LockKind kind); + // Non-destructive conflict query (mtfix11): true when any byte of the + // range is locked by ANOTHER owner (another handle/OFD/process). + // Never takes or removes a lock. Callers must consult their own lock + // registrations first: POSIX OFD queries cannot see the querying fd's + // own locks, and a Win32 same-handle overlap also reports a conflict - + // neither layer can distinguish "mine". + static util::Result probe(File& f, std::uint64_t offset, + std::uint64_t length); + util::Result release(); // Internal: construct from a native handle and the locked range. diff --git a/src/platform/lock_posix.cpp b/src/platform/lock_posix.cpp index e7d42bbe..59bfe180 100644 --- a/src/platform/lock_posix.cpp +++ b/src/platform/lock_posix.cpp @@ -110,6 +110,29 @@ util::Result ByteLock::try_acquire(File& f, std::uint64_t offset, return do_lock(f, offset, length, kind, kSetLk); } +util::Result ByteLock::probe(File& f, std::uint64_t offset, + std::uint64_t length) { + struct flock fl{}; + fl.l_type = F_WRLCK; + fl.l_whence = SEEK_SET; + fl.l_start = static_cast(fold_lock_offset(offset)); + fl.l_len = static_cast(length); + fl.l_pid = 0; + // native_handle() stores (fd + 1) to avoid the nullptr/fd-0 collision. + int fd = static_cast(reinterpret_cast(f.native_handle()) - 1); +#ifdef F_OFD_SETLK + // F_OFD_GETLK reports conflicts against OTHER open file descriptions - + // exactly "another handle/session holds this byte". The querying fd's + // own locks are invisible to it; callers check their own list first. + if (::fcntl(fd, F_OFD_GETLK, &fl) == -1) return os_error("fcntl(F_OFD_GETLK)"); +#else + // Pre-3.15 fallback: process-scoped GETLK reports other PROCESSES only; + // same-process conflicts stay invisible (degraded, never wrong-safe). + if (::fcntl(fd, F_GETLK, &fl) == -1) return os_error("fcntl(F_GETLK)"); +#endif + return fl.l_type != F_UNLCK; +} + util::Result ByteLock::release() { release_(); return {}; diff --git a/src/platform/lock_win32.cpp b/src/platform/lock_win32.cpp index 40d3d64c..7f55bf4a 100644 --- a/src/platform/lock_win32.cpp +++ b/src/platform/lock_win32.cpp @@ -77,6 +77,27 @@ util::Result ByteLock::try_acquire(File& f, std::uint64_t offset, return do_lock(f, offset, length, kind, LOCKFILE_FAIL_IMMEDIATELY); } +util::Result ByteLock::probe(File& f, std::uint64_t offset, + std::uint64_t length) { + // Win32 has no query API: a non-blocking take-and-release is the only + // conflict probe. A same-handle overlap also fails with + // ERROR_LOCK_VIOLATION, so callers must exclude their own + // registrations before probing. + OVERLAPPED ov{}; + ov.Offset = static_cast(offset & 0xFFFFFFFFu); + ov.OffsetHigh = static_cast(offset >> 32); + DWORD lo = static_cast(length & 0xFFFFFFFFu); + DWORD hi = static_cast(length >> 32); + HANDLE h = reinterpret_cast(f.native_handle()); + if (::LockFileEx(h, LOCKFILE_EXCLUSIVE_LOCK | LOCKFILE_FAIL_IMMEDIATELY, + 0, lo, hi, &ov)) { + ::UnlockFileEx(h, 0, lo, hi, &ov); + return false; + } + if (::GetLastError() == ERROR_LOCK_VIOLATION) return true; + return os_error("LockFileEx probe"); +} + util::Result ByteLock::release() { release_(); return {}; diff --git a/tests/unit/abi_remote_lock_introspection_test.cpp b/tests/unit/abi_remote_lock_introspection_test.cpp index a1b360b3..52e1389b 100644 --- a/tests/unit/abi_remote_lock_introspection_test.cpp +++ b/tests/unit/abi_remote_lock_introspection_test.cpp @@ -79,8 +79,9 @@ TEST_CASE("M12.36 remote AdsIsRecordLocked reflects this connection's locks") { UNSIGNED8 tname[] = "li.dbf"; ADSHANDLE hTable = 0; - REQUIRE(AdsOpenTable(hConn, tname, nullptr, ADS_CDX, ADS_ANSI, ADS_SHARED, - ADS_COMPATIBLE_LOCKING, ADS_DEFAULT, &hTable) + REQUIRE(AdsOpenTable(hConn, tname, nullptr, ADS_CDX, ADS_ANSI, +ADS_COMPATIBLE_LOCKING, ADS_IGNORERIGHTS, +ADS_SHARED, &hTable) == AE_SUCCESS); // Nothing locked yet. @@ -126,8 +127,9 @@ TEST_CASE("M12.36 remote AdsGetAllLocks enumerates held record locks") { UNSIGNED8 tname[] = "li.dbf"; ADSHANDLE hTable = 0; - REQUIRE(AdsOpenTable(hConn, tname, nullptr, ADS_CDX, ADS_ANSI, ADS_SHARED, - ADS_COMPATIBLE_LOCKING, ADS_DEFAULT, &hTable) + REQUIRE(AdsOpenTable(hConn, tname, nullptr, ADS_CDX, ADS_ANSI, +ADS_COMPATIBLE_LOCKING, ADS_IGNORERIGHTS, +ADS_SHARED, &hTable) == AE_SUCCESS); REQUIRE(AdsLockRecord(hTable, 2) == AE_SUCCESS); @@ -155,3 +157,65 @@ TEST_CASE("M12.36 remote AdsGetAllLocks enumerates held record locks") { REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); } + +TEST_CASE("M13.1 remote AdsIsRecordLocked sees other connections' locks") { + auto dir = lock_tmp_dir(); + seed_lock_fixture(dir); + + openads::network::Server srv; + REQUIRE(srv.start("127.0.0.1", 0).has_value()); + + char uri[512]; + std::snprintf(uri, sizeof(uri), "tcp://127.0.0.1:%u/%s", + static_cast(srv.port()), dir.string().c_str()); + UNSIGNED8 srvbuf[512]{}; + std::memcpy(srvbuf, uri, std::strlen(uri) + 1); + + auto open_li = [&](ADSHANDLE* pc, ADSHANDLE* pt) { + REQUIRE(AdsConnect60(srvbuf, ADS_REMOTE_SERVER, nullptr, nullptr, 0, + pc) == AE_SUCCESS); + UNSIGNED8 tname[] = "li.dbf"; + REQUIRE(AdsOpenTable(*pc, tname, nullptr, ADS_CDX, ADS_ANSI, +ADS_COMPATIBLE_LOCKING, ADS_IGNORERIGHTS, +ADS_SHARED, + pt) == AE_SUCCESS); + }; + + ADSHANDLE hA = 0, tA = 0, hB = 0, tB = 0; + open_li(&hA, &tA); + open_li(&hB, &tB); + + // Nobody holds anything yet. + UNSIGNED16 locked = 1; + REQUIRE(AdsIsRecordLocked(tB, 3, &locked) == AE_SUCCESS); + CHECK(locked == 0); + + // A locks record 3: B's status query must see the cross-connection + // lock (SAP global semantics - login-semaphore guards depend on it), + // while A's own view still reports it and a neighbour stays free. + REQUIRE(AdsLockRecord(tA, 3) == AE_SUCCESS); + REQUIRE(AdsIsRecordLocked(tB, 3, &locked) == AE_SUCCESS); + CHECK(locked == 1); + REQUIRE(AdsIsRecordLocked(tA, 3, &locked) == AE_SUCCESS); + CHECK(locked == 1); + REQUIRE(AdsIsRecordLocked(tB, 4, &locked) == AE_SUCCESS); + CHECK(locked == 0); + + // A's file lock covers every record from B's point of view. + REQUIRE(AdsLockTable(tA) == AE_SUCCESS); + REQUIRE(AdsIsRecordLocked(tB, 2, &locked) == AE_SUCCESS); + CHECK(locked == 1); + REQUIRE(AdsUnlockTable(tA) == AE_SUCCESS); + REQUIRE(AdsIsRecordLocked(tB, 2, &locked) == AE_SUCCESS); + CHECK(locked == 0); + + // After A unlocks, B sees the record free again. + REQUIRE(AdsUnlockRecord(tA, 3) == AE_SUCCESS); + REQUIRE(AdsIsRecordLocked(tB, 3, &locked) == AE_SUCCESS); + CHECK(locked == 0); + + REQUIRE(AdsCloseTable(tA) == AE_SUCCESS); + REQUIRE(AdsCloseTable(tB) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hA) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hB) == AE_SUCCESS); +} diff --git a/tests/unit/network_lock_exclusion_test.cpp b/tests/unit/network_lock_exclusion_test.cpp index 5ec9cd1c..1c36b852 100644 --- a/tests/unit/network_lock_exclusion_test.cpp +++ b/tests/unit/network_lock_exclusion_test.cpp @@ -3,9 +3,10 @@ // Field: process A locks its username record for life; occasionally a // second process B locks the SAME record and proceeds (never on // DBFCDX). The gate is LockRecord -> server try_lock_record_excl -> -// OS byte lock; IsRecordLocked is own-table-only by design (see -// Session::IsRecordLocked + AdsIsRecordLocked) and can never serve as -// the cross-process check. +// OS byte lock. IsRecordLocked was own-table-only at birth (the +// single-login guard that QUERIED instead of attempting stayed blind); +// mtfix11 makes it cross-owner aware via the OS probe, matching SAP - +// but the enforcement gate below still rests on LockRecord attempts. // // Matrix: A holds rec 3 (natural vs ordered/twin path on each side), // B must fail the same recno. Then close/reopen handover: A closes From c39e3d8241ebea851fd9c7313a9995492bc3c240 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898283+github-actions[bot]@users.noreply.github.com> Date: Fri, 25 Sep 2026 18:55:39 +0000 Subject: [PATCH 30/97] mtfix11: enforce ADS_EXCLUSIVE opens across wire sessions - server registry keyed by resolved path, 7040 on conflict, close/teardown release; open_shared test helper fixed to open shared Applied by apply-patch workflow, run 36175789630, started by bedipritpal. --- src/network/server.cpp | 51 ++++++ src/network/server.h | 36 ++++ src/network/session.cpp | 37 +++- src/network/session.h | 5 + tests/CMakeLists.txt | 1 + tests/unit/abi_lock_unlock_full_test.cpp | 9 +- tests/unit/network_exclusive_open_test.cpp | 194 +++++++++++++++++++++ 7 files changed, 330 insertions(+), 3 deletions(-) create mode 100644 tests/unit/network_exclusive_open_test.cpp diff --git a/src/network/server.cpp b/src/network/server.cpp index ec1e93b5..8895acc4 100644 --- a/src/network/server.cpp +++ b/src/network/server.cpp @@ -396,6 +396,57 @@ void Server::add_session_table(std::uint64_t id, std::int32_t delta, } } +bool Server::try_register_open(std::uint64_t id, + const std::string& canon_path, + bool exclusive) { + std::lock_guard lk(open_reg_mu_); + auto it = open_reg_.find(canon_path); + if (it != open_reg_.end()) { + auto& e = it->second; + // Another session's exclusive hold denies every open mode. + if (e.exclusive_sid != 0 && e.exclusive_sid != id) return false; + if (exclusive) { + // An exclusive request needs the path free of OTHER sessions. + for (const auto& [sid, n] : e.shared) { + if (sid != id && n > 0) return false; + } + if (e.exclusive_sid != 0 && e.exclusive_sid != id) return false; + e.exclusive_sid = id; + ++e.exclusive_opens; + } else { + ++e.shared[id]; + } + return true; + } + auto& e = open_reg_[canon_path]; + if (exclusive) { + e.exclusive_sid = id; + e.exclusive_opens = 1; + } else { + e.shared[id] = 1; + } + return true; +} + +void Server::unregister_open(std::uint64_t id, + const std::string& canon_path, + bool exclusive) { + std::lock_guard lk(open_reg_mu_); + auto it = open_reg_.find(canon_path); + if (it == open_reg_.end()) return; + auto& e = it->second; + if (exclusive) { + if (e.exclusive_sid == id && e.exclusive_opens > 0) { + if (--e.exclusive_opens == 0) e.exclusive_sid = 0; + } + } else { + if (auto sit = e.shared.find(id); sit != e.shared.end()) { + if (--sit->second == 0) e.shared.erase(sit); + } + } + if (e.shared.empty() && e.exclusive_opens == 0) open_reg_.erase(it); +} + void Server::install_session_socket(std::uint64_t id, Socket s) { std::lock_guard lk(info_mu_); sockets_[id] = s; diff --git a/src/network/server.h b/src/network/server.h index 456405d3..bd7d1f41 100644 --- a/src/network/server.h +++ b/src/network/server.h @@ -213,6 +213,17 @@ class Server { // sessions_mu_. std::unordered_map session_threads_; std::vector finished_threads_; + + // mtfix11 - exclusive-open registry (see try_register_open above). + struct OpenRegEntry { + // Non-exclusive opens per session (a session may hold several). + std::unordered_map shared; + // Exclusive holder: owning session + handle multiplicity. + std::uint64_t exclusive_sid = 0; + std::uint32_t exclusive_opens = 0; + }; + std::mutex open_reg_mu_; + std::unordered_map open_reg_; std::atomic thread_seq_{1}; // Enterprise step 3 — when the sharded-reactor pool is enabled // (OPENADS_SERVER_POOL), accept_loop hands each accepted socket to this @@ -272,6 +283,31 @@ class Server { std::int32_t delta, const std::string& table_name = std::string()); + // mtfix11 - SAP exclusive-open enforcement across wire sessions. + // The drivers map DriverOpenMode::Exclusive to a plain open (POSIX + // has no share modes), so without this registry an ADS_EXCLUSIVE + // open was a silent no-op: any other session could open the same + // table, where SAP ADS denies both directions (reindex/pack + // workflows rely on that denial to keep newcomers out). + // + // Keyed by the engine table's resolved absolute path. An open is + // denied when another session holds the path exclusive, or when the + // request is exclusive and another session holds the path open in + // any mode. Same-session reopens always pass (a connection may USE + // the same table in several workareas; the wire dual-handle twin is + // opened through the local ABI connection and never lands here). + // Engine-internal opens (SQL cursors, replication apply, mgmt) + // bypass the registry by construction - they never reach the wire + // OpenTable handler. + // + // try_register_open registers and returns true when allowed, false + // when denied (nothing registered). unregister_open drops one + // previously-registered open. + bool try_register_open(std::uint64_t id, const std::string& canon_path, + bool exclusive); + void unregister_open(std::uint64_t id, const std::string& canon_path, + bool exclusive); + // studio.web.0.11 — drop a single session by id (closes its // socket; the session_loop's next read_frame returns and the // session thread exits cleanly). Returns true if the id was diff --git a/src/network/session.cpp b/src/network/session.cpp index 14422693..13522416 100644 --- a/src/network/session.cpp +++ b/src/network/session.cpp @@ -544,7 +544,11 @@ void Session::cleanup() { // else still references the path, and skips LockRegistry cleanup). if (sess_conn_) { for (auto& [id, h] : tbls_) { - (void)id; + if (auto rit = tbl_open_reg_.find(id); + rit != tbl_open_reg_.end()) { + srv_->unregister_open(sid_, rit->second.first, + rit->second.second); + } if (auto* t = sess_conn_->lookup_table(h)) { (void)t->flush(); openads::mgmt::LockRegistry::instance().remove_all_for_table(t); @@ -555,6 +559,7 @@ void Session::cleanup() { } tbls_.clear(); tbl_open_paths_.clear(); + tbl_open_reg_.clear(); // 5) oads_FOpen / AdsFOpen files for this session. files_.clear(); @@ -1878,7 +1883,7 @@ DispatchResult Session::dispatch(const Frame& f) { std::string rel; auto open_mode = openads::engine::OpenMode::Shared; if (client_open_table_mode_ok_ && f.payload.size() >= 2) { - // M12.x extended payload: [u16 LE mode][table_name_bytes] + // M12.x extended payload: [u16 mode][table_name_bytes] std::uint16_t mode_u16 = static_cast( static_cast(f.payload[0]) | (static_cast(f.payload[1]) << 8)); @@ -1912,6 +1917,28 @@ DispatchResult Session::dispatch(const Frame& f) { break; } std::uint32_t id = next_id_++; + // mtfix11 - enforce ADS_EXCLUSIVE across wire sessions (SAP + // semantics; the drivers alone treat Exclusive as a plain + // open, so a reindex/pack exclusive hold never kept a second + // instance's opens out). Open-then-register keeps the + // registry key canonical (the engine's resolved path); a + // denied open is closed again and answered with 7040 + // AE_FILE_IN_USE - the same code this codebase maps Win32 + // sharing violations to. + if (auto* tbl = sess_conn_->lookup_table(th.value())) { + const std::string& canon = tbl->path(); + const bool excl = + (open_mode == openads::engine::OpenMode::Exclusive); + if (!canon.empty()) { + if (!srv_->try_register_open(sid_, canon, excl)) { + sess_conn_->close_table(th.value()); + reply = err("OpenTable: table in use exclusively", + 7040); + break; + } + tbl_open_reg_.emplace(id, std::make_pair(canon, excl)); + } + } tbls_.emplace(id, th.value()); tbl_open_paths_.emplace(id, rel); srv_->add_session_table(sid_, +1, rel); @@ -2001,6 +2028,12 @@ DispatchResult Session::dispatch(const Frame& f) { if (it != tbls_.end()) { sess_conn_->close_table(it->second); tbls_.erase(it); + if (auto rit = tbl_open_reg_.find(id); + rit != tbl_open_reg_.end()) { + srv_->unregister_open(sid_, rit->second.first, + rit->second.second); + tbl_open_reg_.erase(rit); + } std::string tname; if (auto pit = tbl_open_paths_.find(id); pit != tbl_open_paths_.end()) tname = pit->second; diff --git a/src/network/session.h b/src/network/session.h index c283b533..5e5e253f 100644 --- a/src/network/session.h +++ b/src/network/session.h @@ -87,6 +87,11 @@ class Session { // Original OpenTable payload (DD alias or relative path). ensure_abi_handle // must reopen the same physical file — basename-only breaks subdir tables. std::unordered_map tbl_open_paths_; + // mtfix11 - Server::try_register_open bookkeeping per wire table id: + // (engine-resolved canonical path, exclusive flag) as registered at + // OpenTable; consumed at CloseTable / teardown for unregister_open. + std::unordered_map> + tbl_open_reg_; std::unordered_map cursor_tbls_; // M12.16 — lazy-promoted ABI handle parallel to tbls_. std::unordered_map tbls_h_; diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 8f9685f3..ff84e990 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -342,6 +342,7 @@ add_executable(openads_unit_tests unit/network_lock_exclusion_test.cpp unit/network_login_gate_stress_test.cpp unit/abi_remote_lock_introspection_test.cpp + unit/network_exclusive_open_test.cpp unit/backend_tier1_test.cpp unit/abi_oads_file_funcs_test.cpp unit/abi_create_index_path_test.cpp diff --git a/tests/unit/abi_lock_unlock_full_test.cpp b/tests/unit/abi_lock_unlock_full_test.cpp index 81e8158e..d4969b96 100644 --- a/tests/unit/abi_lock_unlock_full_test.cpp +++ b/tests/unit/abi_lock_unlock_full_test.cpp @@ -69,7 +69,14 @@ ADSHANDLE open_shared(ADSHANDLE hConn, const char* name) { UNSIGNED8 tname[64] = {}; std::memcpy(tname, name, std::strlen(name) + 1); // NOLINT ADSHANDLE hT = 0; - REQUIRE(AdsOpenTable(hConn, tname, tname, ADS_CDX, 1, 1, 0, 1, &hT) == 0); + // usMode ADS_SHARED: this helper always opened exclusive (usMode=1), + // which only "worked" while exclusive opens were a silent no-op. With + // mtfix11's SAP-faithful enforcement a second connection's exclusive + // open of the same table is correctly denied, so open what the name + // says. (Argument order: usLockType, usCheckRights, usMode.) + REQUIRE(AdsOpenTable(hConn, tname, tname, ADS_CDX, ADS_ANSI, + ADS_COMPATIBLE_LOCKING, ADS_IGNORERIGHTS, + ADS_SHARED, &hT) == 0); return hT; } diff --git a/tests/unit/network_exclusive_open_test.cpp b/tests/unit/network_exclusive_open_test.cpp new file mode 100644 index 00000000..6563b5f6 --- /dev/null +++ b/tests/unit/network_exclusive_open_test.cpp @@ -0,0 +1,194 @@ +// network_exclusive_open_test.cpp -- mtfix11: ADS_EXCLUSIVE open +// enforcement across wire sessions. +// +// Root cause pinned here: the drivers map DriverOpenMode::Exclusive to a +// plain open (POSIX has no share modes), so an exclusive hold was a +// silent no-op - a second instance's opens sailed through a reindex/pack +// exclusive hold that SAP ADS would have denied, and the app's +// "only one user" startup abort never fired. The Server now keeps an +// open registry keyed by the engine's resolved path and denies +// conflicting opens with 7040 AE_FILE_IN_USE. +// +// Client park-pool interaction: our AdsCloseTable PARKS poolable shared +// tables (the server handle stays open for fast reuse), so a shared +// close does NOT release the server-side registration - the table IS +// still open on that session, and the registry correctly keeps blocking +// a peer's exclusive request. Exclusive tables are never parked, and a +// disconnect sweeps every registration the session still holds; those +// are the two release paths this test drives. +#include "doctest.h" +#include "openads/ace.h" +#include "openads/error.h" +#include "network/server.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace fs = std::filesystem; +using openads::AE_SUCCESS; + +namespace { + +fs::path excl_tmp_dir() { + return fs::temp_directory_path() / "openads_remote_excl_open"; +} + +// Recreates /ex.dbf with 3 rows, NAME C(8). +void seed_excl_fixture(const fs::path& dir) { + std::error_code ec; + fs::remove_all(dir, ec); + fs::create_directories(dir); + + UNSIGNED8 srv[512]{}; + const auto d = dir.string(); + std::memcpy(srv, d.c_str(), d.size()); + ADSHANDLE hConn = 0; + REQUIRE(AdsConnect60(srv, ADS_LOCAL_SERVER, nullptr, nullptr, 0, &hConn) + == AE_SUCCESS); + + UNSIGNED8 tname[] = "ex.dbf"; + UNSIGNED8 def[] = "NAME,C,8,0"; + ADSHANDLE hTable = 0; + REQUIRE(AdsCreateTable(hConn, tname, nullptr, ADS_CDX, ADS_ANSI, + 0, 0, 0, def, &hTable) == AE_SUCCESS); + UNSIGNED8 fld[] = "NAME"; + for (int i = 0; i < 3; ++i) { + char v[9]; + std::snprintf(v, sizeof(v), "R%07d", i); + REQUIRE(AdsAppendRecord(hTable) == AE_SUCCESS); + REQUIRE(AdsSetString(hTable, fld, reinterpret_cast(v), + 8) == AE_SUCCESS); + REQUIRE(AdsWriteRecord(hTable) == AE_SUCCESS); + } + REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); +} + +UNSIGNED32 open_mode(ADSHANDLE hConn, const char* name, UNSIGNED16 mode, + ADSHANDLE* ph) { + return AdsOpenTable(hConn, + reinterpret_cast(const_cast(name)), + nullptr, ADS_CDX, ADS_ANSI, + ADS_COMPATIBLE_LOCKING, ADS_IGNORERIGHTS, mode, ph); +} + +// Disconnect teardown is asynchronous with respect to another session's +// next request: the client returns from AdsDisconnect once its socket is +// down, while the server still has to observe the hangup and sweep that +// session's registrations. Poll briefly for the acquisition the sweep +// must unblock instead of assuming the sweep has already run. +UNSIGNED32 open_eventually(ADSHANDLE hConn, const char* name, + UNSIGNED16 mode, ADSHANDLE* ph) { + UNSIGNED32 rc = AE_SUCCESS; + for (int i = 0; i < 100; ++i) { + rc = open_mode(hConn, name, mode, ph); + if (rc == AE_SUCCESS) return rc; + std::this_thread::sleep_for(std::chrono::milliseconds(20)); + } + return rc; +} + +ADSHANDLE connect_remote(const UNSIGNED8* srvbuf) { + ADSHANDLE h = 0; + REQUIRE(AdsConnect60(const_cast(srvbuf), ADS_REMOTE_SERVER, + nullptr, nullptr, 0, &h) == AE_SUCCESS); + return h; +} + +} // namespace + +TEST_CASE("mtfix11 exclusive open denies other sessions both directions") { + auto dir = excl_tmp_dir(); + seed_excl_fixture(dir); + + openads::network::Server srv; + REQUIRE(srv.start("127.0.0.1", 0).has_value()); + + char uri[512]; + std::snprintf(uri, sizeof(uri), "tcp://127.0.0.1:%u/%s", + static_cast(srv.port()), dir.string().c_str()); + UNSIGNED8 srvbuf[512]{}; + std::memcpy(srvbuf, uri, std::strlen(uri) + 1); + + const char* tname = "ex.dbf"; + ADSHANDLE hA = connect_remote(srvbuf); + ADSHANDLE hB = connect_remote(srvbuf); + ADSHANDLE tA = 0, tA2 = 0, tB = 0; + + // 1. An exclusive holder blocks other sessions in both modes. + REQUIRE(open_mode(hA, tname, ADS_EXCLUSIVE, &tA) == AE_SUCCESS); + CHECK(open_mode(hB, tname, ADS_SHARED, &tB) != AE_SUCCESS); + CHECK(tB == 0); + CHECK(open_mode(hB, tname, ADS_EXCLUSIVE, &tB) != AE_SUCCESS); + CHECK(tB == 0); + + // 2. The holding session itself may open the table again (several + // workareas on one connection are legitimate). + REQUIRE(open_mode(hA, tname, ADS_SHARED, &tA2) == AE_SUCCESS); + + // 3. Closing the exclusive handle releases that hold (exclusive + // tables are never parked, so this close reaches the wire) - but + // the session's remaining shared open still blocks B's exclusive. + REQUIRE(AdsCloseTable(tA) == AE_SUCCESS); + CHECK(open_mode(hB, tname, ADS_EXCLUSIVE, &tB) != AE_SUCCESS); + CHECK(tB == 0); + + // 4. Closing the shared handle parks it client-side; the following + // disconnect drains the park pool (a live open table would DEFER + // the disconnect instead) and the wire close frees the registry. + REQUIRE(AdsCloseTable(tA2) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hA) == AE_SUCCESS); + REQUIRE(open_eventually(hB, tname, ADS_EXCLUSIVE, &tB) == AE_SUCCESS); + + // 5. Now B holds it exclusive: A's opens are denied. + hA = connect_remote(srvbuf); + tA = 0; + CHECK(open_mode(hA, tname, ADS_SHARED, &tA) != AE_SUCCESS); + CHECK(tA == 0); + tA = 0; + CHECK(open_mode(hA, tname, ADS_EXCLUSIVE, &tA) != AE_SUCCESS); + CHECK(tA == 0); + + // 6. B's exclusive close is a real close: A gets in shared. + REQUIRE(AdsCloseTable(tB) == AE_SUCCESS); + REQUIRE(open_mode(hA, tname, ADS_SHARED, &tA) == AE_SUCCESS); + + // 7. Two shared holders coexist; a third session's exclusive fails. + ADSHANDLE hC = connect_remote(srvbuf); + ADSHANDLE tC = 0; + REQUIRE(open_mode(hC, tname, ADS_SHARED, &tC) == AE_SUCCESS); + ADSHANDLE hD = connect_remote(srvbuf); + ADSHANDLE tD = 0; + CHECK(open_mode(hD, tname, ADS_EXCLUSIVE, &tD) != AE_SUCCESS); + CHECK(tD == 0); + + // 8. An upgrade to exclusive requires the table free of OTHER + // sessions: A still holds it shared, so C's exclusive is denied. + ADSHANDLE tC2 = 0; + CHECK(open_mode(hC, tname, ADS_EXCLUSIVE, &tC2) != AE_SUCCESS); + CHECK(tC2 == 0); + + // 9. Once A lets go (parked close + disconnect drain), C's upgrade + // succeeds and D stays locked out. + REQUIRE(AdsCloseTable(tA) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hA) == AE_SUCCESS); + REQUIRE(open_eventually(hC, tname, ADS_EXCLUSIVE, &tC2) == AE_SUCCESS); + CHECK(open_mode(hD, tname, ADS_SHARED, &tD) != AE_SUCCESS); + CHECK(tD == 0); + + // 10. C's exclusive close releases the exclusive hold; D may now + // share alongside C's still-open shared handle. + REQUIRE(AdsCloseTable(tC2) == AE_SUCCESS); + REQUIRE(open_mode(hD, tname, ADS_SHARED, &tD) == AE_SUCCESS); + + REQUIRE(AdsCloseTable(tC) == AE_SUCCESS); + REQUIRE(AdsCloseTable(tD) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hB) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hC) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hD) == AE_SUCCESS); +} From feb4e414c6cabd033a1a042965efeaba119d096a Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Fri, 25 Sep 2026 15:32:31 -0500 Subject: [PATCH 31/97] Create release-notes-1.09.68-mtfix11.md --- release-notes-1.09.68-mtfix11.md | 12 ++++++++++++ 1 file changed, 12 insertions(+) create mode 100644 release-notes-1.09.68-mtfix11.md diff --git a/release-notes-1.09.68-mtfix11.md b/release-notes-1.09.68-mtfix11.md new file mode 100644 index 00000000..b63ad47e --- /dev/null +++ b/release-notes-1.09.68-mtfix11.md @@ -0,0 +1,12 @@ +Built for Pritpal Bedi - bedipritpal/OpenADS, forked from FiveTechSoft/OpenADS. + +## v1.09.68-mtfix11 - two ADS-compatibility fixes + +### 1. AdsIsRecordLocked now sees other connections' locks +Previously it answered from the caller's own lock list only (per connection), so a guard that QUERIES a marker record instead of attempting a lock stayed blind to other instances. Now: own list first, then a real OS byte-range lock probe (F_OFD_GETLK with F_GETLK fallback on Linux/macOS, try-LockFileEx on Windows). CDX/VFP nuance handled: a file lock's byte range covers every record lock, so another owner's FLock is correctly reported for any record, without false positives. SAP's own doc text for AdsIsRecordLocked was not independently re-verified - the behavioral evidence from production traces is the compat signal here. + +### 2. ADS_EXCLUSIVE opens enforced across sessions +Exclusive opens were a silent no-op (POSIX has no share modes), so a second instance's opens sailed through an exclusive hold that real ADS denies. The server now keeps an open registry keyed by the resolved file path: an exclusive holder denies other sessions both shared and exclusive opens; a shared-opened table denies another session's exclusive open; reopening/upgrading on the SAME connection stays legal. Denied opens return 7040 AE_FILE_IN_USE. Close and disconnect release the registration. Wire-level only: SQL-internal and engine-internal opens bypass it (same as before). Note for our own client: a closed shared table may stay parked for fast reopen, which keeps its server registration until eviction or disconnect - documented in the new test. + +### Tests +Full CI-tier suite green: 1579/1579 cases, 560,890 assertions. New: M13.1 cross-connection IsRecordLocked (record lock, FLock visibility, unlock-to-free), mtfix11 exclusive-open matrix (denial both directions, same-session reopen, upgrade rules, teardown sweep). One pre-existing test helper fixed (it had been opening ADS_EXCLUSIVE under the name open_shared - masked while exclusive was a no-op). From 717e1c05d4687bebe9f691410e9b40d82af5a0ec Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Fri, 25 Sep 2026 18:46:46 -0500 Subject: [PATCH 32/97] release: add ubuntu:20.04 container leg (glibc 2.31 asset) The ubuntu-24.04 leg's linux-x64 binaries require GLIBC_2.38, so the published tarball cannot start on Ubuntu 20.04 (glibc 2.31) or 22.04. Add a build-glibc231 job that compiles the same tree inside an ubuntu:20.04 container (vendored CMake 3.28, clang-10, warnings-as-errors off for the older toolchain, same Harbour UDF SDK under its own cache key) and ships openads--linux-glibc231.tar.gz. A verify step fails the leg if any staged binary references > GLIBC_2.31. Additive only: publish flattens the asset into the release but does not require the leg, so a compat-leg failure can never block a release. --- .github/workflows/release.yml | 162 ++++++++++++++++++++++++++++++++++ 1 file changed, 162 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 003cb7ea..1224a7ca 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -285,6 +285,168 @@ jobs: name: openads-${{ runner.os }}-${{ matrix.arch }} path: ${{ env.ASSET }} + # glibc 2.31 compatibility build for older Linux servers (Ubuntu 20.04): + # the ubuntu-24.04 leg's binaries require GLIBC_2.38+, so the same tree + # is built inside an ubuntu:20.04 container. Asset is additive: publish + # flattens it into the release but does not require the leg to be green. + # (Requested by Pritpal Bedi for his LAN test server.) + build-glibc231: + name: ubuntu 20.04 container / x64 (glibc 2.31) + runs-on: ubuntu-24.04 + container: ubuntu:20.04 + timeout-minutes: 90 + steps: + # Bare image: git/curl first so checkout + CMake FetchContent work. + - name: Install bootstrap tools + shell: bash + run: | + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y --no-install-recommends \ + git ca-certificates curl xz-utils + + - uses: actions/checkout@v5 + with: + ref: ${{ inputs.tag || github.ref }} + + - name: Resolve tag + id: tag + shell: bash + run: | + T="${{ inputs.tag }}" + if [ -z "$T" ]; then T="${GITHUB_REF#refs/tags/}"; fi + echo "tag=$T" >> "$GITHUB_OUTPUT" + echo "version=${T#v}" >> "$GITHUB_OUTPUT" + + # CMakePresets v4 needs CMake >= 3.23; 20.04's apt ships 3.16, so + # vendor the official binary (runs on any glibc >= 2.17). clang on + # 20.04 is clang-10: fine for this C++17 tree, but older compilers + # warn differently, so warnings-as-errors is off for this leg. + - name: Install toolchain and CMake + shell: bash + run: | + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y --no-install-recommends \ + build-essential clang ninja-build pkg-config \ + libncurses-dev libx11-dev bison flex python3 perl + curl -sSL -o /tmp/cmake.tar.gz \ + https://github.com/Kitware/CMake/releases/download/v3.28.6/cmake-3.28.6-linux-x86_64.tar.gz + mkdir -p /opt/cmake + tar xzf /tmp/cmake.tar.gz -C /opt/cmake --strip-components=1 + echo "/opt/cmake/bin" >> "$GITHUB_PATH" + /opt/cmake/bin/cmake --version + + # Same Harbour UDF SDK as the 24.04 leg, cached under its own key + # (binaries are glibc-specific). + - name: Cache Harbour SDK (glibc 2.31) + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/harbour-udf-glibc231 + key: harbour-udf-linux-glibc231-4ec2e154ed92757418bc30af571ab90240ab3209-v1 + + - name: Provision Harbour SDK (glibc 2.31) + shell: bash + env: + HARBOUR_REF: 4ec2e154ed92757418bc30af571ab90240ab3209 + run: | + set -e + HB="$RUNNER_TEMP/harbour-udf-glibc231" + echo "HB_ROOT=$HB/install" >> "$GITHUB_ENV" + if [ -f "$HB/install/include/hbvm.h" ] && \ + [ -n "$(find "$HB/install" -name 'libhbvmmt.a' 2>/dev/null | head -1)" ] && \ + [ -n "$(find "$HB/install" -name harbour -type f 2>/dev/null | head -1)" ]; then + echo "Harbour SDK cached at $HB/install" + else + rm -rf "$HB" + mkdir -p "$HB" + git clone --depth 1 --branch master https://github.com/harbour/core.git "$HB/src" + git -C "$HB/src" fetch --depth 1 origin "$HARBOUR_REF" + git -C "$HB/src" checkout "$HARBOUR_REF" + cd "$HB/src" + make -j"$(nproc)" install HB_INSTALL_PREFIX="$HB/install" HB_BUILD_3RDEXT=no + fi + HB_INC="$(dirname "$(find "$HB/install" -name hbvm.h | head -1)")" + HB_LIBDIR="$(dirname "$(find "$HB/install" -name 'libhbvmmt.a' | head -1)")" + HB_CC="$(find "$HB/install" -name harbour -type f -executable | head -1)" + echo "Harbour SDK layout:" + echo " include: ${HB_INC:-MISSING}" + echo " libdir: ${HB_LIBDIR:-MISSING}" + echo " compiler:${HB_CC:-MISSING}" + if [ -z "$HB_INC" ] || [ -z "$HB_LIBDIR" ] || [ -z "$HB_CC" ]; then + echo "::error::Harbour SDK layout unexpected - full listing:" + find "$HB/install" -maxdepth 4 | sort | head -80 + exit 1 + fi + { + echo "HARBOUR_INCLUDE_DIR=$HB_INC" + echo "HARBOUR_LIB_DIR=$HB_LIBDIR" + echo "HARBOUR_COMPILER=$HB_CC" + } >> "$GITHUB_ENV" + + - name: Configure + shell: bash + run: | + cmake --preset ninja-clang \ + -DOPENADS_WITH_TLS=ON \ + -DOPENADS_WITH_HTTP=ON \ + -DOPENADS_WARNINGS_AS_ERRORS=OFF \ + -DOPENADS_WITH_HARBOUR_UDF=ON \ + -DHARBOUR_INCLUDE_DIR="$HARBOUR_INCLUDE_DIR" \ + -DHARBOUR_LIB_DIR="$HARBOUR_LIB_DIR" \ + -DHARBOUR_COMPILER="$HARBOUR_COMPILER" + + - name: Build + run: cmake --build build/ninja-clang --config Release + + - name: Test + timeout-minutes: 30 + run: ctest --test-dir build/ninja-clang --output-on-failure -C Release + + - name: Stage release files + shell: bash + run: | + STAGE="openads-${{ steps.tag.outputs.version }}-linux-glibc231" + mkdir -p "$STAGE" + BUILD="build/ninja-clang" + for f in "$BUILD"/src/libopenace64.*; do + [ -e "$f" ] || continue + cp "$f" "$STAGE/" + cp "$f" "$STAGE/$(basename "$f" | sed 's/libopenace64/libace64/')" + done + cp "$BUILD"/tools/serverd/openads_serverd "$STAGE/" + cp "$BUILD"/tools/bench/openads_bench "$STAGE/" + cp LICENSE NOTICE README.md openads.ini.sample "$STAGE/" + tar czvf "$STAGE.tar.gz" "$STAGE" + ls -la "$STAGE.tar.gz" + echo "ASSET=$STAGE.tar.gz" >> "$GITHUB_ENV" + + # The whole point of the leg: prove the binaries stay within + # glibc 2.31 before they ship. + - name: Verify glibc ceiling (<= 2.31) + shell: bash + run: | + set -e + STAGE="openads-${{ steps.tag.outputs.version }}-linux-glibc231" + fail=0 + for b in "$STAGE"/openads_serverd "$STAGE"/openads_bench \ + "$STAGE"/libopenace64.so "$STAGE"/libace64.so; do + hit=$(objdump -T "$b" | grep -oE 'GLIBC_2\.(3[2-9]|[4-9][0-9])' | sort -uV || true) + if [ -n "$hit" ]; then + echo "::error::$b references > GLIBC_2.31: $hit" + fail=1 + fi + done + [ "$fail" -eq 0 ] && echo "All staged binaries within GLIBC_2.31." + + - name: Upload artifact (workflow run) + if: always() && env.ASSET != '' + uses: actions/upload-artifact@v4 + with: + name: openads-Linux-x64-glibc231 + path: ${{ env.ASSET }} + + publish: name: Publish GitHub Release needs: build From 9e4c9a37417b4a33f3e35ab2777da0a2bcfc7ef2 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Fri, 25 Sep 2026 19:10:51 -0500 Subject: [PATCH 33/97] release: build glibc231 leg with g++-10 (focal clang-10 rejects P1825 implicit move) First run of the new leg failed compiling server_fs.cpp:278: focal's clang-10 does not implement C++20 P1825 (implicit move from a local in a return statement), which the tree relies on ("return f;" for a unique_ptr into Result). Switch the leg to g++-10 from the focal archive (implements P1825) via CMAKE_C_COMPILER/CXX_COMPILER overrides on the ninja-clang preset; no source or tag changes. --- .github/workflows/release.yml | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 1224a7ca..11deb468 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -319,16 +319,18 @@ jobs: echo "version=${T#v}" >> "$GITHUB_OUTPUT" # CMakePresets v4 needs CMake >= 3.23; 20.04's apt ships 3.16, so - # vendor the official binary (runs on any glibc >= 2.17). clang on - # 20.04 is clang-10: fine for this C++17 tree, but older compilers - # warn differently, so warnings-as-errors is off for this leg. + # vendor the official binary (runs on any glibc >= 2.17). Compiler: + # focal's clang-10 rejects this tree's C++20 implicit-move returns + # (P1825, e.g. server_fs.cpp), so this leg builds with g++-10 (in + # the focal archive, implements P1825); warnings-as-errors stays + # off for the older toolchain. - name: Install toolchain and CMake shell: bash run: | export DEBIAN_FRONTEND=noninteractive apt-get update apt-get install -y --no-install-recommends \ - build-essential clang ninja-build pkg-config \ + build-essential g++-10 ninja-build pkg-config \ libncurses-dev libx11-dev bison flex python3 perl curl -sSL -o /tmp/cmake.tar.gz \ https://github.com/Kitware/CMake/releases/download/v3.28.6/cmake-3.28.6-linux-x86_64.tar.gz @@ -388,6 +390,8 @@ jobs: shell: bash run: | cmake --preset ninja-clang \ + -DCMAKE_C_COMPILER=gcc-10 \ + -DCMAKE_CXX_COMPILER=g++-10 \ -DOPENADS_WITH_TLS=ON \ -DOPENADS_WITH_HTTP=ON \ -DOPENADS_WARNINGS_AS_ERRORS=OFF \ From f40ceeffe103855072406739a5874507f6a6a985 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Fri, 25 Sep 2026 19:41:11 -0500 Subject: [PATCH 34/97] release: export Harbour SDK paths on linux cache hit (provision early-exit skipped env) The linux Harbour SDK provision step exited early on a cache hit, before writing HARBOUR_INCLUDE_DIR/HARBOUR_LIB_DIR/HARBOUR_COMPILER to GITHUB_ENV - so every cache-hit run failed Configure with "hbvm.h not found". Restructure to if/else: skip only the build on a hit, always resolve and export the SDK paths. Matches the glibc231 leg's provision, which already does this. (Run 1 of the re-release was green because it missed the cache and built; run 2 hit the cache and failed - classic alternating failure.) --- .github/workflows/release.yml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 11deb468..3f0ab5dc 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -93,17 +93,17 @@ jobs: [ -n "$(find "$HB/install" -name 'libhbvmmt.a' 2>/dev/null | head -1)" ] && \ [ -n "$(find "$HB/install" -name harbour -type f 2>/dev/null | head -1)" ]; then echo "Harbour SDK cached at $HB/install" - exit 0 + else + sudo apt-get update + sudo apt-get install -y build-essential libncurses-dev libx11-dev + rm -rf "$HB" + mkdir -p "$HB" + git clone --depth 1 --branch master https://github.com/harbour/core.git "$HB/src" + git -C "$HB/src" fetch --depth 1 origin "$HARBOUR_REF" + git -C "$HB/src" checkout "$HARBOUR_REF" + cd "$HB/src" + make -j"$(nproc)" install HB_INSTALL_PREFIX="$HB/install" HB_BUILD_3RDEXT=no fi - sudo apt-get update - sudo apt-get install -y build-essential libncurses-dev libx11-dev - rm -rf "$HB" - mkdir -p "$HB" - git clone --depth 1 --branch master https://github.com/harbour/core.git "$HB/src" - git -C "$HB/src" fetch --depth 1 origin "$HARBOUR_REF" - git -C "$HB/src" checkout "$HARBOUR_REF" - cd "$HB/src" - make -j"$(nproc)" install HB_INSTALL_PREFIX="$HB/install" HB_BUILD_3RDEXT=no # Resolve exact SDK paths (no layout guessing in CMake): # headers, MT VM archive, compiler binary. HB_INC="$(dirname "$(find "$HB/install" -name hbvm.h | head -1)")" From 2f8c059e87a928f4e2c86bad85ad5c04790e20b4 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Fri, 25 Sep 2026 21:31:34 -0500 Subject: [PATCH 35/97] mtfix12: nav fusion - boundary-pair certification, self-GotoRecord suppression, GetRecordNum anchor (wire layer) --- src/network/1-wire.h | 748 +++++ src/network/2-client.h | 1149 ++++++++ src/network/3-client.cpp | 3398 ++++++++++++++++++++++ src/network/4-session.h | 277 ++ src/network/5-session.cpp | 5738 +++++++++++++++++++++++++++++++++++++ 5 files changed, 11310 insertions(+) create mode 100644 src/network/1-wire.h create mode 100644 src/network/2-client.h create mode 100644 src/network/3-client.cpp create mode 100644 src/network/4-session.h create mode 100644 src/network/5-session.cpp diff --git a/src/network/1-wire.h b/src/network/1-wire.h new file mode 100644 index 00000000..b9935788 --- /dev/null +++ b/src/network/1-wire.h @@ -0,0 +1,748 @@ +#pragma once + +#include "util/result.h" + +#include +#include +#include + +namespace openads::network { + +// M12.1 — Phase 2 wire-protocol skeleton for the TCP server. +// +// Frame layout (big-endian length, fixed-size header): +// +// bytes 0..3 : payload length (uint32 BE, excludes header) +// byte 4 : opcode +// bytes 5..N : payload +// +// Total frame size = 5 + payload_length. The opcode space is +// reserved here even though the Phase 2 server is not yet wired +// up — apps and tests can already round-trip frames through +// encode_frame / decode_frame. + +enum class Opcode : std::uint8_t { + Hello = 0x01, + HelloAck = 0x02, + Connect = 0x10, + ConnectAck = 0x11, + Disconnect = 0x12, + OpenTable = 0x20, + OpenTableAck = 0x21, + CloseTable = 0x22, + CloseTableAck = 0x23, + ExecuteSQL = 0x30, + ExecuteSQLAck = 0x31, + Fetch = 0x32, + FetchAck = 0x33, + // M12.4 — remote table navigation + read. + GotoTop = 0x40, + GotoTopAck = 0x41, + Skip = 0x42, + SkipAck = 0x43, + GetField = 0x44, + GetFieldAck = 0x45, + GetRecordCount = 0x46, + GetRecordCountAck = 0x47, + // Nav-boundary twin flag: AtEOFAck carries [u8 eof][u8 bof] and + // AtBOFAck carries [u8 bof][u8 eof]. Old single-byte servers send + // only byte 0; old clients read only byte 0 — rddads' AtBOF+AtEOF + // pair therefore collapses to one round-trip when both ends are + // new, with zero fallback paths in any version mix. + AtEOF = 0x48, + AtEOFAck = 0x49, + // M12.14 — remote field metadata + extended cursor state. + // DescribeTable returns the full per-column schema in one + // round-trip so rddads' adsOpen path doesn't need 5 × num_fields + // hops to populate AdsGetFieldName/Type/Length/Decimals. + DescribeTable = 0x4A, + DescribeTableAck = 0x4B, + AtBOF = 0x4C, + AtBOFAck = 0x4D, // [u8 bof][u8 eof] — see AtEOFAck note + GetRecordNum = 0x4E, + GetRecordNumAck = 0x4F, + IsRecordDeleted = 0x62, + IsRecordDeletedAck = 0x63, + GotoBottom = 0x64, + GotoBottomAck = 0x65, + // M12.15 — remote info / lock / maintenance / AOF. + IsFound = 0x66, + IsFoundAck = 0x67, + RefreshRecord = 0x68, + RefreshRecordAck = 0x69, + GetTableType = 0x6A, + GetTableTypeAck = 0x6B, + GetRecordLength = 0x6C, + GetRecordLengthAck = 0x6D, + GetNumIndexes = 0x6E, + GetNumIndexesAck = 0x6F, + GetLastAutoinc = 0x70, + GetLastAutoincAck = 0x71, + LockRecord = 0x72, + LockRecordAck = 0x73, + UnlockRecord = 0x74, + UnlockRecordAck = 0x75, + LockTable = 0x76, + LockTableAck = 0x77, + UnlockTable = 0x78, + UnlockTableAck = 0x79, + PackTable = 0x7A, + PackTableAck = 0x7B, + ZapTable = 0x7C, + ZapTableAck = 0x7D, + FlushFileBuffers = 0x7E, + FlushFileBuffersAck= 0x7F, + CloseAllIndexes = 0x80, + CloseAllIndexesAck = 0x81, + SetAOF = 0x82, + SetAOFAck = 0x83, + ClearAOFRemote = 0x84, + ClearAOFRemoteAck = 0x85, + GetAOFOptLevel = 0x86, + GetAOFOptLevelAck = 0x87, + // M12.16 — remote index handle subsystem. + OpenIndex = 0x88, OpenIndexAck = 0x89, + CloseIndex = 0x8A, + CloseIndexAck = 0x8B, + SetOrder = 0x8C, + SetOrderAck = 0x8D, + SetOrderByName = 0x8E, + SetOrderByNameAck = 0x8F, + Seek = 0x90, + SeekAck = 0x91, + SeekLast = 0x92, + SeekLastAck = 0x93, + CreateIndex = 0x94, + CreateIndexAck = 0x95, + SkipUnique = 0x96, + SkipUniqueAck = 0x97, + SetScope = 0x98, + SetScopeAck = 0x99, + ClearScope = 0x9A, + ClearScopeAck = 0x9B, + // M12.17 — single-frame whole-record read. xbrowse-style + // viewers paint W cols × H rows = W*H FieldGet calls per + // repaint; without this op every cell costs one TCP RTT + // (~5-15 ms LAN), so a 20×12 grid stalls 1-4 s. With this op + // RemoteTable caches the full row server-side and one + // FetchCurrentRow RTT serves every subsequent FieldGet on + // the same record. + FetchCurrentRow = 0x9C, + FetchCurrentRowAck = 0x9D, + // M12.6 — remote write surface. + AppendBlank = 0x50, + AppendBlankAck = 0x51, + SetField = 0x52, + SetFieldAck = 0x53, + // Write coalescing for RDD-only apps (no app change possible): the + // client buffers consecutive AdsSet* calls and flushes them as ONE + // frame on the next visibility event (read/nav/lock/commit/close), + // collapsing N-field voucher entry from N RTTs to 1. Capability- + // gated via kCapSetFieldsBatch in ConnectAck (old servers never see + // 0x5E: the client only sends it when the ack advertised the bit). + // Request SetFields: [u32 tid][u16 n][per field: u16 nlen][name] + // [u32 vlen][value] (n==0 is a no-op success) + // Reply SetFieldsAck: (empty). First failing field stops the apply, + // exactly like a sequential SetField loop failing at the same field. + SetFields = 0x5E, + SetFieldsAck = 0x5F, + DeleteRecord = 0x54, + DeleteRecordAck = 0x55, + RecallRecord = 0x56, + RecallRecordAck = 0x57, + GotoRecord = 0x58, + GotoRecordAck = 0x59, + FlushTable = 0x5A, + FlushTableAck = 0x5B, + // M12.35 — AdsGetKeyType over the wire. Returns the key expression + // result type (ADS_STRING=4, ADS_DATE=3, ADS_NUMERIC=2, ADS_LOGICAL=1) + // for a remote index so the client encodes scope/seek values correctly. + // Request: [u32 index_id] + // Reply: [u16 key_type LE] + GetKeyType = 0x5C, + GetKeyTypeAck = 0x5D, + // M12.8 — remote index ops (CREATE INDEX is already covered by + // M12.7's ExecuteSQL `CREATE INDEX` DDL path; Reindex isn't in + // SQL grammar so it needs a dedicated opcode). + Reindex = 0x60, + ReindexAck = 0x61, + // M12.24 — remote AdsGetLastTableUpdate. Reply payload is the + // DBF header date packed big-endian-ish into 4 bytes: + // (year << 16) | (month << 8) | day. + GetLastTableUpdate = 0x9E, + GetLastTableUpdateAck = 0x9F, + + // M12.36 — record-lock introspection over the wire. Both report the + // per-connection view ("locks THIS session holds"), matching the + // local AdsIsRecordLocked/AdsGetAllLocks semantics that walk the + // Table's own held-lock list. Needed by Harbour dbRecordInfo( + // DBRI_LOCKED) and dbRLockList() under ADSCDX (Vouch IsLogged()). + // Request IsRecordLocked: [u32 tid][u32 recno] (0 = current record) + // Reply IsRecordLockedAck: [u16 locked LE] + IsRecordLocked = 0x13, + IsRecordLockedAck = 0x14, + // Request GetAllLocks: [u32 tid] + // Reply GetAllLocksAck: [u16 count][u32 recno LE]... + GetAllLocks = 0x15, + GetAllLocksAck = 0x16, + + // M9.25 — management telemetry channel. + MgConnect = 0xA0, + MgConnectAck = 0xA1, + MgRequest = 0xA2, + MgReplyAck = 0xA3, + + // Tier-2 server-side filtered scan. Like Fetch (0x32) but the + // server evaluates a Clipper-style FOR predicate per row and + // returns only matching rows + the requested columns, walking + // the table server-side until `max_rows` matches or EOF. Removes + // the per-record Skip/GetField round-trips for a `SET FILTER` / + // `COUNT FOR` / `LOCATE FOR` scan whose predicate is outside the + // index-optimisable AOF subset (where the client RDD would + // otherwise filter row-by-row over the wire). + FetchWhere = 0xA4, + FetchWhereAck = 0xA5, + + // Tier-3 server-side aggregation. The server scans the whole table + // once, evaluating an xBase-style FOR predicate per row, and folds + // each matching row into the requested COUNT / SUM / AVG / MIN / MAX + // accumulators — returning just the scalars instead of streaming + // every matching row back. Collapses `COUNT FOR` / `SUM .. FOR` / + // `AVERAGE` / totalling reports from O(matched rows over the wire) + // to a single round-trip. Request payload: + // [u32 tid][u16 forlen][for_expr][u8 n_aggs] + // per agg: [u8 fn_type][u8 nlen][field_name] (nlen=0 => COUNT(*)) + // Reply (AggregateAck): + // [u8 n_aggs] per agg: [u8 result_type][u16 vlen][val] + // fn_type: 0=COUNT 1=SUM 2=AVG 3=MIN 4=MAX (engine::AggFn). + // result_type: 0=empty/null 1=numeric(ASCII) 2=string (engine::AggType). + Aggregate = 0xA6, + AggregateAck = 0xA7, + + // M12.25 — raw record image read/write (AdsGetRecord / AdsSetRecord). + // Request GetRecord: [u32 tid] + // Reply GetRecordAck: [u16 len][record bytes] + // Request SetRecord: [u32 tid][u16 len][record bytes] + // Reply SetRecordAck: (empty) + GetRecord = 0xA8, + GetRecordAck = 0xA9, + SetRecord = 0xAA, + SetRecordAck = 0xAB, + + // M12.26 — AdsCustomizeAOF: flip individual records in/out of the + // active AOF bitmap. Payload: + // [u32 tid][u8 option][u16 nrecs][u32 recno]... + // option: 1=ADD (ADS_AOF_ADD_RECORD), 2=REMOVE (ADS_AOF_REMOVE_RECORD). + CustomizeAOF = 0xAC, + CustomizeAOFAck = 0xAD, + + // M12.27 — AdsGetRecordCRC over the wire. Request: [u32 tid] + // Reply GetRecordCRAck: [u32 crc LE] (IEEE CRC-32 over record_buffer). + GetRecordCRC = 0xAE, + GetRecordCRAck = 0xAF, + + // M12.28 -- remote AdsGetKeyCount: filtered key count from the + // active index order (conditional FOR tags index only matching + // rows, so this differs from GetRecordCount's physical row count). + // Request: [u32 table_id] + // Reply: [u32 key_count LE] + GetKeyCount = 0xB0, + GetKeyCountAck = 0xB1, + // M12.29 — remote AdsGetKeyNum: current key number in the active + // order's walk. Server computes via pos_of_recno_cached() → O(1). + // Request: [table_id u32 LE] + // Response: [key_num u32 LE] (0 = no order / not positioned) + GetKeyNum = 0x03, + GetKeyNumAck = 0x04, + + // M12.33 — remote AdsFindFirstTable / AdsFindNextTable / AdsFindClose. + // List table files matching a glob mask in the session data directory. + // Unlike the Directory opcode (0xEA), this is NOT gated by + // EnableFileFunc — listing tables is a core database operation that + // arc32 and other DBA tools need. + // Request FindTables: [u16 maskLen][mask] + // Reply FindTablesAck: [u32 nFiles][for each: u16 nameLen][name] + // The client caches the full list; FindNext/FindClose iterate locally. + FindTables = 0x05, + FindTablesAck = 0x06, + + // M12.34 — Transaction lifecycle over the wire. Request payloads are + // empty; ack payloads are empty. Success is indicated by the matching + // Ack opcode; failure by Opcode::Error. + BeginTransaction = 0x07, + BeginTransactionAck = 0x08, + CommitTransaction = 0x09, + CommitTransactionAck = 0x0A, + RollbackTransaction = 0x0B, + RollbackTransactionAck = 0x0C, + + // M12.34 — find a record by identity columns over the wire. + // Request: [u32 tid][u16 nident][for each: u16 nlen][name][u16 vlen][value] + // Reply: [u32 recno] (0 = not found) + FindRecord = 0x0D, + FindRecordAck = 0x0E, + + // Server-side backup archiving (OAds_Zip/OAds_UnZip). Connection- + // level ops (no table id); paths stay under the session data jail + // and archives land in /backup/_YYYYMMDD.zip. + // NOT gated by EnableFileFunc (database ops, like FindTables). + // File lists ride as one 0x1F-joined blob (0x1F cannot occur in + // a file name on any OS); u32 lengths where a file set can exceed + // 64 KiB of names. + // Request ZipArchive: + // [u16 dirLen][dir][u32 filesLen][0x1F files] + // [u16 zipNameLen][zipName][u16 level][u8 overwrite][u8 withPath] + // [u32 exclLen][0x1F excludes][u16 pwdLen][password] + // Reply ZipArchiveAck: + // [u32 files][u64 bytes][u64 archiveBytes][u16 arcLen][archiveRel] + ZipArchive = 0x17, + ZipArchiveAck = 0x18, + // Request UnzipArchive: + // [u16 dirLen][dir][u16 zipLen][zip][u16 pwdLen][password] + // [u8 overwrite][u8 withPath] + // Reply UnzipArchiveAck: + // [u32 files][u64 bytes][u64 archiveBytes] + UnzipArchive = 0x19, + UnzipArchiveAck = 0x1A, + // Central-directory listing (OAds_ZipFileCount/OAds_ZipFileList). + // Connection-level op (no table id); the archive spelling follows + // the unzip rule (bare names under backup/). NOT gated by + // EnableFileFunc. Needs no password (contents stay encrypted). + // Request ZipList: [u16 zipLen][zip] + // Reply ZipListAck: [u32 count][packed ZipEntry records, see + // engine/zip_arch.h pack_zip_entry; length-gated parse] + ZipList = 0x1B, + ZipListAck = 0x1C, + + // M12.29 — AdsDD* Data Dictionary property API, phase 1. Previously + // every AdsDD* getter/setter silently returned empty/no-op over a + // remote connection (dd_from_handle() only resolves a LOCAL Connection + // handle) instead of erroring or forwarding — see docs/wire-protocol.md + // §9. These opcodes cover the ~19 Get/Set*Property functions (all share + // the same name[,subName]+propertyId+value shape — see DDObjectKind + // below) plus the exact create/drop calls DA-Web exercises today. + // Everything else in the AdsDD* surface (user/group/link/RI CRUD, + // permissions bitmask, index-file add/remove) is deferred to a phase 2 + // — those already work remotely via SQL EXECUTE PROCEDURE in DA-Web. + // + // Request DDGetProperty: [u8 objKind][u16 nameLen][name] + // [u16 subNameLen][subName][u16 propId] + // Reply DDGetPropertyAck: [u32 valLen][value bytes] + DDGetProperty = 0xB2, + DDGetPropertyAck = 0xB3, + // Request DDSetProperty: [u8 objKind][u16 nameLen][name] + // [u16 subNameLen][subName][u16 propId] + // [u32 valLen][value bytes] + // Reply DDSetPropertyAck: (empty) + DDSetProperty = 0xB4, + DDSetPropertyAck = 0xB5, + // Request DDCreateProc: [u16 nameLen][name][u16 containerLen][container] + // [u16 procNameLen][procName][u16 inParamsLen][inParams] + // [u16 outParamsLen][outParams][u16 commentsLen][comments] + // Reply DDCreateProcAck: (empty) + DDCreateProc = 0xB6, + DDCreateProcAck = 0xB7, + // Request DDCreateFunction: [u16 nameLen][name][u16 containerLen][container] + // [u16 implLen][implementation][u16 retTypeLen][retType] + // [u16 inParamsLen][inParams][u16 commentLen][comment] + // Reply DDCreateFunctionAck: (empty) + DDCreateFunction = 0xB8, + DDCreateFunctionAck= 0xB9, + // Request DDCreateTrigger: [u16 nameLen][name][u16 tableLen][table] + // [u32 type][u16 containerLen][container][u16 procedureLen][procedure] + // [u32 priority] + // Reply DDCreateTriggerAck: (empty) + DDCreateTrigger = 0xBA, + DDCreateTriggerAck = 0xBB, + // Request DDDropTrigger: [u16 nameLen][name] + // Reply DDDropTriggerAck: (empty) + DDDropTrigger = 0xBC, + DDDropTriggerAck = 0xBD, + // Request DDDropView: [u16 nameLen][name] + // Reply DDDropViewAck: (empty) + DDDropView = 0xBE, + DDDropViewAck = 0xBF, + // Request DDDropLink: [u16 nameLen][name] + // Reply DDDropLinkAck: (empty) + DDDropLink = 0xC0, + DDDropLinkAck = 0xC1, + + // M12.30 — AdsDD* Data Dictionary property API, phase 2. Covers the + // remaining CRUD calls deferred by phase 1 (M12.29, see wire-protocol.md + // §5.24/§5.25): user/group management, links, referential integrity + // create, views, index-file registration, and permissions. Two more of + // the deferred functions (GetIndexProperty, GetUserTableRights/ + // SetUserTableRights) reuse the existing DDGetProperty/DDSetProperty + // opcodes via two new DDObjectKind values below — no new opcode needed + // for those. AdsDDRevokePermission is a pure local wrapper around + // AdsDDGrantPermission(..., 0) and needs no wire support of its own. + // + // Request DDCreateUser: [u16 groupLen][group][u16 userLen][user] + // [u16 pwdLen][pwd][u16 descLen][desc] + // Reply DDCreateUserAck: (empty) + DDCreateUser = 0xC2, + DDCreateUserAck = 0xC3, + // Generic drop-by-name, parallel to DDGetProperty's DDObjectKind tag. + // Covers AdsDDDeleteUser(User), AdsDDRemoveRefIntegrity(RefIntegrity), + // AdsDDDropProcedure(Proc), AdsDDDropFunction(Function) — the four + // remaining plain "drop by name" calls (Trigger/View/Link already have + // their own phase-1 opcodes). + // Request DDDropObject: [u8 objKind][u16 nameLen][name] + // Reply DDDropObjectAck: (empty) + DDDropObject = 0xC4, + DDDropObjectAck = 0xC5, + // Request DDAddUserToGroup: [u16 groupLen][group][u16 userLen][user] + // Reply DDAddUserToGroupAck: (empty) + DDAddUserToGroup = 0xC6, + DDAddUserToGroupAck = 0xC7, + // Request DDRemoveUserFromGroup: [u16 groupLen][group][u16 userLen][user] + // Reply DDRemoveUserFromGroupAck: (empty) + DDRemoveUserFromGroup = 0xC8, + DDRemoveUserFromGroupAck = 0xC9, + // Request DDCreateLink: [u16 aliasLen][alias][u16 pathLen][path] + // [u16 userLen][user][u16 pwdLen][pwd] + // Reply DDCreateLinkAck: (empty) + DDCreateLink = 0xCA, + DDCreateLinkAck = 0xCB, + // Request DDModifyLink: same payload as DDCreateLink. + // Reply DDModifyLinkAck: (empty) + DDModifyLink = 0xCC, + DDModifyLinkAck = 0xCD, + // Request DDCreateRefIntegrity: [u16 nameLen][name][u16 failLen][fail] + // [u16 parentLen][parent][u16 parentTagLen][parentTag] + // [u16 childLen][child][u16 childTagLen][childTag] + // [u16 updateRule][u16 deleteRule] + // Reply DDCreateRefIntegrityAck: (empty) + DDCreateRefIntegrity = 0xCE, + DDCreateRefIntegrityAck = 0xCF, + // Request DDCreateView: [u16 nameLen][name][u16 commentsLen][comments] + // [u32 sqlLen][sql] (u32: view SQL can be long, unlike short names) + // Reply DDCreateViewAck: (empty) + DDCreateView = 0xD0, + DDCreateViewAck = 0xD1, + // Request DDAddIndexFile: [u16 tableLen][table][u16 indexLen][index] + // [u16 commentLen][comment] + // Reply DDAddIndexFileAck: (empty) + DDAddIndexFile = 0xD2, + DDAddIndexFileAck = 0xD3, + // Request DDRemoveIndexFile: [u16 tableLen][table][u16 indexLen][index] + // Reply DDRemoveIndexFileAck: (empty) + DDRemoveIndexFile = 0xD4, + DDRemoveIndexFileAck = 0xD5, + // Request DDGetPermissions: [u16 granteeLen][grantee][u16 objType] + // [u16 objNameLen][objName][u8 getInherited] + // Reply DDGetPermissionsAck: [u32 permissions] + DDGetPermissions = 0xD6, + DDGetPermissionsAck = 0xD7, + // Request DDGrantPermission: [u16 objType][u16 objNameLen][objName] + // [u16 granteeLen][grantee][u32 permissions] + // Reply DDGrantPermissionAck: (empty). Revoke = grant with permissions=0 + // (matches AdsDDRevokePermission's local implementation). + DDGrantPermission = 0xD8, + DDGrantPermissionAck = 0xD9, + + // M12.31 — AdsShowDeleted (SET DELETED ON/OFF) over the wire. + // Without this, the client's SET DELETED flag never reaches + // openads_serverd: scoped/ordered GotoTop/Skip on the server + // walk index keys with show_deleted=true and return rows flagged + // deleted even though LOCAL mode hides them. + // Request: [u8 show] (0 = hide deleted, 1 = show deleted) + // Reply ShowDeletedAck: (empty) + ShowDeleted = 0xDA, + ShowDeletedAck = 0xDB, + + // Remote AdsCreateTable / AdsDropTable. Without these, a client that + // AdsConnect60'd to tcp://… fell through AdsCreateTable's local + // Connection lookup, wrote the .dbf next to the client app (cwd of + // the default local connection), then AdsOpenTable (which *does* + // route remote) failed with AE_TABLE_CORRUPTED (5103) because the + // file never landed under the server data directory. + // + // Request CreateTable: + // [u16 tableType][u16 charType][u16 memoBlockSize] + // [u16 nameLen][name][u16 fieldsLen][fields] + // Reply CreateTableAck: (empty) — client re-opens via OpenTable so + // production-bag auto-open / GotoTop reuse the existing path. + CreateTable = 0xDC, + CreateTableAck = 0xDD, + // Request DropTable: [u16 nameLen][name][u16 deleteFiles] + // Reply DropTableAck: (empty) + DropTable = 0xDE, + DropTableAck = 0xDF, + + // Server filesystem API (oads_* / Ads*) — gated by EnableFileFunc. + // Paths are relative to the session data directory (jail). + // FileExists: [u16 pathLen][path] → FileExistsAck: [u8 exists] + FileExists = 0xE0, + FileExistsAck = 0xE1, + // FileErase: [u16 pathLen][path] → empty ack + FileErase = 0xE2, + FileEraseAck = 0xE3, + // FileRename: [u16 oldLen][old][u16 newLen][new] → empty ack + FileRename = 0xE4, + FileRenameAck = 0xE5, + // FileSize: [u16 pathLen][path] → FileSizeAck: [u64 size LE] + FileSize = 0xE6, + FileSizeAck = 0xE7, + // FileMTime: [u16 pathLen][path] → [u16 y][u8 mon day hh mm ss] + FileMTime = 0xE8, + FileMTimeAck = 0xE9, + // Directory: [u16 maskLen][mask][u16 attr] → [u32 n][entries…] + Directory = 0xEA, + DirectoryAck = 0xEB, + DirExist = 0xEC, + DirExistAck = 0xED, + DirMake = 0xEE, + DirMakeAck = 0xEF, + DirRemove = 0xF0, + DirRemoveAck = 0xF1, + // FOpen: [u16 pathLen][path][u16 mode] → [u32 file_id] + FOpen = 0xF2, + FOpenAck = 0xF3, + // FCreate:[u16 pathLen][path][u16 attr] → [u32 file_id] + FCreate = 0xF4, + FCreateAck = 0xF5, + // FClose: [u32 file_id] + FClose = 0xF6, + FCloseAck = 0xF7, + // FRead: [u32 file_id][u32 nbytes] → [u32 nread][bytes] + FRead = 0xF8, + FReadAck = 0xF9, + // FWrite: [u32 file_id][u32 nbytes][bytes] → [u32 nwritten] + FWrite = 0xFA, + FWriteAck = 0xFB, + // FSeek: [u32 file_id][i32 offset][u8 origin] → [u32 position] + FSeek = 0xFC, + FSeekAck = 0xFD, + + // M12.32 — Distributed mutex service (sub-opcode multiplexed). + // Request Mutex: [u8 sub_op][u16 nameLen][name][optional fields] + // Reply Mutex: [u8 sub_op][u8 ok][optional fields] + // + // Sub-ops: + // 0x01 Create: [u8=0x01][u16 nameLen][name] + // Ack: [u8=0x01][u8 ok] + // 0x02 Lock: [u8=0x02][u16 nameLen][name][u32 timeout_ms] + // Ack: [u8=0x02][u8 ok] + // 0x03 TryLock: [u8=0x03][u16 nameLen][name] + // Ack: [u8=0x03][u8 ok] + // 0x04 Unlock: [u8=0x04][u16 nameLen][name] + // Ack: [u8=0x04][u8 ok] + // 0x05 Destroy: [u8=0x05][u16 nameLen][name] + // Ack: [u8=0x05][u8 ok] + Mutex = 0xFE, + + Error = 0xFF, +}; + +// M12.29 — object kind discriminator for DDGetProperty/DDSetProperty. +// Every Get/Set*Property function in the AdsDD* ABI shares the same +// name[,subName]+propertyId+value shape; this tags which local +// AdsDDGet*Property/AdsDDSet*Property function the server should call. +// subName is only meaningful for Field (the field name within the table +// named by `name`); every other kind sends an empty subName. +// +// M12.30 additions: Index (name=table, subName=index — GetIndexProperty +// only; SetIndexProperty is a permanent local stub regardless of +// connection type, so it isn't wired) and UserTableRights (name=table, +// subName=user, propId unused, value = 4-byte LE access level — the +// underlying local functions take/return a raw UNSIGNED32, not a +// property-id-keyed buffer, but the wire shape is identical so it reuses +// DDGetProperty/DDSetProperty instead of adding two more opcodes). +enum class DDObjectKind : std::uint8_t { + Database = 1, + User = 2, + Table = 3, + Field = 4, + Trigger = 5, + Proc = 6, + Function = 7, + View = 8, + RefIntegrity = 9, + Index = 10, + UserTableRights = 11, +}; + +// Request flags for FetchWhere (Opcode::FetchWhere = 0xA4). +// Set WANT_RECNO in the flags byte to make the server emit a u32 LE +// record number before each row's column data in the FetchWhereAck +// payload. A flags value of 0 produces a reply byte-identical to the +// v1.4.0 wire (no recno field) — full backward compatibility. +namespace FetchWhereFlags { + constexpr std::uint8_t WANT_RECNO = 0x01; +} + +// Inbound cap — symmetric with encode_frame's outbound check; prevents +// multi-gigabyte resize on a malicious 4-byte length prefix. +inline constexpr std::uint32_t kMaxFramePayload = 16u * 1024u * 1024u; + +// M12.32 — Mutex sub-opcodes (payload[0] of Opcode::Mutex frames). +enum class MutexOp : std::uint8_t { + Create = 0x01, + Lock = 0x02, + TryLock = 0x03, + Unlock = 0x04, + Destroy = 0x05, +}; + +// Upper bound on field count in a single wire row — guards against +// malicious/corrupt servers or clients allocating unbounded vectors. +inline constexpr std::uint16_t kMaxWireFields = 4096u; + +// M12.21 option C — client capability flags, advertised as a trailing +// [u32 LE] appended to the Connect payload (after the password field). +// A server only piggybacks the sequential-prefetch lookahead block onto +// forward-Skip acks for clients that set kCapPrefetchConsume, because +// draining that queue locally requires the consumed-counter cursor +// resync. Older clients omit the field (caps = 0) and keep the +// one-round-trip-per-Skip behavior, so the wire stays backward +// compatible in every version-mix direction. +inline constexpr std::uint32_t kCapPrefetchConsume = 0x00000001u; + +// Tier-3: the client understands the Aggregate / AggregateAck opcodes +// (0xA6/0xA7). A server only routes a `COUNT/SUM/.. FOR` to the +// server-side accumulator path for clients that advertise this; older +// servers never see a 0xA6 frame, so the wire stays backward compatible. +inline constexpr std::uint32_t kCapAggregate = 0x00000002u; + +// RCB 07/15/2026: M12.25 — the client can drain a BACKWARD lookahead block +// (PgUp). This MUST be its own capability, not a free extension of +// kCapPrefetchConsume, because it is a correctness gate rather than an +// optimization: the read-ahead block is a plain [count][rows] list with no +// direction marker on the wire, and a kCapPrefetchConsume-only client (which +// only knows how to drain a block forward) would pop a backward-walked row on +// its next Skip(+1) and serve the WRONG record. A server therefore attaches a +// backward block only to clients that set this bit; everyone else keeps paying +// one round-trip per backward step, exactly as before. Both mix directions stay +// safe: an old server ignores the bit, and a new server never sends a backward +// block to a client that did not advertise it. +inline constexpr std::uint32_t kCapPrefetchBackward = 0x00000004u; + +// M12.x — OpenTable mode pass-through. When the client sets this bit, it +// prepends a [u16 LE mode] to every OpenTable payload. The server reads +// that prefix and opens the table in the requested mode (Shared / Exclusive / +// ReadOnly). Old servers ignore the prefix because they treat the entire +// payload as the table name and the leading null bytes make the path invalid; +// the open_table fallback then fails with AE_TABLE_NOT_FOUND (5018). +inline constexpr std::uint32_t kCapOpenTableMode = 0x00000008u; + +// Write coalescing (SetFields 0x5E). Client→server: advertised in the +// Connect caps word like the other bits (old servers ignore unknown +// bits). Server→client: echoed in ConnectAck (see Session::dispatch +// Connect) — the client sends 0x5E only when the ack carried this bit, +// so an old server never receives the frame and needs no fallback path. +inline constexpr std::uint32_t kCapSetFieldsBatch = 0x00000010u; + +// Flush merged into CloseAllIndexes: the server flushes table data +// inside the CloseAllIndexes handler before dropping bindings, so a +// preceding FlushFileBuffers is redundant. Same two-way gating as +// kCapSetFieldsBatch — clients merge only when the ConnectAck echo +// carries this bit. +inline constexpr std::uint32_t kCapFlushInCloseAll = 0x00000020u; + +// Fused nav+order (SetOrder+GotoTop/GotoBottom in one frame): the +// GotoTop request carries an optional trailing [u8 0x01][u32 order_id] +// after the depth hint (GotoBottom: right after the table id), and the +// server installs the order before navigating. Same two-way gating; +// old servers ignore the trailer (prefix-only parse), old clients +// never emit it. +inline constexpr std::uint32_t kCapNavOrderFuse = 0x00000040u; + +// Durable FlushTable: the server's FlushTable handler already performs +// the full file-buffers flush (ABI twin via AdsFlushFileBuffers, then +// the engine table), i.e. exactly the work of a standalone +// FlushFileBuffers frame. A client that sees this bit clears its dirty +// flag when its own FlushTable lands and skips the trailing +// FlushFileBuffers -- one frame per commit instead of two. Same +// two-way gating as kCapFlushInCloseAll; old servers never echo it, so +// the client keeps sending both frames there. +inline constexpr std::uint32_t kCapFlushTableDurable = 0x00000080u; + +// Boundary-pair certification (mtfix12 R1): a GotoTop/GotoBottom request +// may carry one more trailing flag bit (see the flag bytes below) asking +// the server to read the OPPOSITE boundary in the same atomic visit and +// append its full landing state to the ack (row blob + bound values + +// scoped key count). The client stamps that certification and serves a +// back-to-back opposite-boundary call (the dbGoTop(); dbGoBottom() +// ritual) with zero frames between proof and serve -- the same +// conn-wide freshness envelope the shipped duplicate-suppression uses. +// Same two-way gating as kCapNavOrderFuse: the client only sets the +// flag when the ConnectAck echo carries this bit, so old servers never +// see it and old clients never emit it. +// +// Request layouts (new server parses bits, old layouts stay valid): +// GotoTop: [u32 id][u16 depth][u8 flags]([u32 order]) +// GotoBottom: [u32 id][u8 flags]([u32 order]) +// flags bit 0x01 = fused order section present (kCapNavOrderFuse) +// flags bit 0x02 = boundary-pair certification requested (this bit) +// Pre-mtfix12 clients send flags == 0x01 exactly when fusing, which the +// new server reads as "fused, no pair" -- bit-exact with the old parse. +inline constexpr std::uint32_t kCapNavBoundaryPair = 0x00000100u; + +// Warm OpenTableAck sections (USE latency). After the fixed +// `[u32 id][u16 bag_len][bag]` prefix, the ack carries +// `[u8 section_count]` then that many TLVs: +// `[u8 tag][u32 len LE][bytes]` +// Tag 1 (schema) reuses the DescribeTableAck body layout; tag 2 +// (first-row) reuses the nav-ack row-trailer layout (row + optional +// lookahead block, same bytes pack_row_trailer emits). Clients that +// predate sections stop after the bag field (which is always emitted, +// empty when absent) and fall back to DescribeTable + GotoTop; new +// clients skip both round-trips. Unknown tags are skipped by length, +// so the section list stays extensible in both directions. +namespace OpenTableAckSections { + constexpr std::uint8_t kSchema = 1; + constexpr std::uint8_t kFirstRow = 2; +} + +// RCB 07/14/2026: M12.23 — AdsCacheRecords support. Why a bare trailing field +// and not a new opcode or a capability bit: the Skip request grew an OPTIONAL +// trailing [u16 LE] carrying the caller's requested read-ahead depth: +// +// Skip: [u32 tid][i32 step] <- pre-M12.23, still valid +// Skip: [u32 tid][i32 step][u16 depth] <- M12.23 +// +// No capability bit is needed, because this is compatible in BOTH +// version-mix directions by construction: +// * new client -> old server: the old handler length-checks (`size() < 8`) +// and reads only the first 8 bytes, so the trailing field is ignored and +// the server keeps choosing the depth itself. +// * old client -> new server: the field is simply absent, which the new +// handler reads as kPrefetchDepthAuto (below). +// +// RCB 07/14/2026: this sentinel is 0xFFFF and deliberately NOT 0. SAP gives 0 +// a real meaning — "0 (or 1) effectively turns read-ahead record caching off" +// (ace_adscacherecords.htm). So "the caller said nothing" and "the caller said +// stop caching" are DIFFERENT instructions and the server has to tell them +// apart: the first ramps, the second sends no block at all. Had I let an +// absent field decode as 0, every pre-M12.23 client — which sends no field — +// would have silently lost read-ahead the day this shipped. Pinned by +// tests/unit/network_skip_depth_test.cpp, which hand-builds a legacy 8-byte +// Skip because the ABI client can no longer emit one. +inline constexpr std::uint16_t kPrefetchDepthAuto = 0xFFFFu; + +// RCB 07/14/2026: safety cap on a caller-requested depth. usRecords is a u16, +// so an app can ask for 65534 rows in one block. The byte budget would stop +// that from becoming an oversized frame, but NOT from walking 65534 records on +// the server thread first — the cost we actually care about. SAP's own +// guidance is that "read-ahead values greater than 100 records are not +// beneficial", so this bound sits far above anything useful and exists purely +// to keep one wire request from turning into an unbounded scan. +inline constexpr std::uint16_t kPrefetchDepthMax = 512u; + +struct Frame { + Opcode opcode = Opcode::Hello; + std::vector payload; +}; + +// Encode `f` to a flat byte buffer ready to send over TCP. +util::Result> encode_frame(const Frame& f); + +// Decode `f` from `buf` (must contain at least one full frame). +// Returns the decoded frame plus the number of bytes consumed via +// the optional `consumed` out-param. +util::Result decode_frame(const std::uint8_t* buf, + std::size_t size, + std::size_t* consumed = nullptr); + +} // namespace openads::network diff --git a/src/network/2-client.h b/src/network/2-client.h new file mode 100644 index 00000000..ef4b1e93 --- /dev/null +++ b/src/network/2-client.h @@ -0,0 +1,1149 @@ +#pragma once + +#include "network/server.h" +#include "network/socket.h" +#include "network/transport.h" +#include "network/wire.h" +#include "engine/aggregate.h" +#include "engine/server_fs.h" +#include "engine/zip_arch.h" +#include "util/result.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace openads::network { + +// Result type for RemoteConnection::fetch_where. `rows` carries the +// matched column values (row-major); `recnos` is populated iff +// FetchWhereFlags::WANT_RECNO was set in the request flags (one entry +// per row, same order as rows). `eof` is true when the server walked +// to the end of the table during this call. +struct FetchWhereBatch { + std::vector> rows; + std::vector recnos; // 1 per row iff WANT_RECNO + bool eof = false; +}; + +// One requested aggregate (function + column; empty field = COUNT(*)). +// Defined in engine/aggregate.h so the SQL-backend push-down (abi layer) +// and the wire client share one type. +using AggSpec = engine::AggSpec; + +// Result of RemoteConnection::aggregate — one scalar per requested AggSpec, +// in the same order. +struct AggregateBatch { + std::vector values; +}; + +struct RemoteTable; + +// Diagnostics only: optional per-frame hook, called after every +// completed request/reply round trip (wire_trace). nullptr = off, which +// is the default; the request path then pays one relaxed atomic load. +// Arguments: connection, request opcode, first u32 of the request +// payload (the table id for table-scoped opcodes; meaningless for +// Hello/Connect/OpenTable), request payload bytes, reply opcode, reply +// payload bytes, microseconds spent in send+receive. +using FrameTraceHook = void (*)(const void* conn, std::uint8_t op, + std::uint32_t tid, std::size_t req_bytes, + std::uint8_t rep_op, std::size_t rep_bytes, + long long us); +void set_frame_trace_hook(FrameTraceHook hook) noexcept; + +// M12.5 — wire client used by ace64.dll's dual-mode dispatch. +// `RemoteConnection` opens a TCP socket to an OpenADS server, +// sends a Connect frame for the data_dir, and exposes a small +// surface mirroring the local Connection methods that the +// remote-routed Ads* functions need. + +class RemoteConnection { +public: + RemoteConnection() = default; + ~RemoteConnection() { disconnect(); } + RemoteConnection(const RemoteConnection&) = delete; + RemoteConnection& operator=(const RemoteConnection&) = delete; + + util::Result connect(const std::string& host, + std::uint16_t port, + const std::string& data_dir, + const std::string& user = "", + const std::string& password = ""); + + // M12.12 — connect using a pre-built transport (e.g. a + // TlsTransport built by `connect_tls`). The Connect frame is + // sent through the supplied transport; ownership passes to + // RemoteConnection. + util::Result + connect_with_transport(std::unique_ptr transport, + const std::string& data_dir, + const std::string& user = "", + const std::string& password = ""); + + void disconnect() noexcept; + + // M12.34 — remote transaction lifecycle. + util::Result begin_transaction(); + util::Result commit_transaction(); + util::Result rollback_transaction(); + + bool valid() const noexcept { + return transport_ && transport_->valid(); + } + + // Server capability word echoed in ConnectAck (see Session::dispatch). + // Bit kCapSetFieldsBatch => the server implements SetFields (0x5E); + // the write-coalescing buffer below only batches when this is set, + // otherwise every set goes out as a single SetField exactly as before. + bool server_setfields_batch() const noexcept { + return (server_caps_ & kCapSetFieldsBatch) != 0; + } + + // Server flushes inside CloseAllIndexes (see kCapFlushInCloseAll): + // a deferred FlushFileBuffers merges into that single frame. + bool server_flush_in_closeall() const noexcept { + return (server_caps_ & kCapFlushInCloseAll) != 0; + } + + // Server installs an order section on GotoTop/GotoBottom + // (see kCapNavOrderFuse): SetOrder+Goto can go out as one frame. + bool server_nav_order_fuse() const noexcept { + return (server_caps_ & kCapNavOrderFuse) != 0; + } + + // Server's FlushTable handler does the full file-buffers flush + // (see kCapFlushTableDurable): a commit needs no trailing + // FlushFileBuffers frame. + bool server_flush_table_durable() const noexcept { + return (server_caps_ & kCapFlushTableDurable) != 0; + } + + // Server certifies the opposite boundary on GotoTop/GotoBottom + // (see kCapNavBoundaryPair): one frame proves both ends, so a + // back-to-back dbGoTop(); dbGoBottom() ritual costs one RTT. + bool server_nav_boundary_pair() const noexcept { + return (server_caps_ & kCapNavBoundaryPair) != 0; + } + + // Current cursor-generation sequence (see nav_seq_). Relaxed load + // is enough: it only orders the ABI layer's own duplicate + // detection, never data. + std::uint64_t nav_seq() const noexcept { + return nav_seq_.load(std::memory_order_relaxed); + } + + // mtfix12 — per-table generations (R1 pair guard / R2 per-table + // envelope). Keyed by wire table id, bumped inside request() for + // every cursor/visibility-affecting frame (nav) and + // content-affecting frame (write) — central by construction, so no + // ABI call site can miss one. Index-keyed ops (Seek/SeekLast/ + // SkipUnique/SetScope/ClearScope) and the connection-wide + // ShowDeleted bump through note_tbl_nav/note_all_tables_nav from + // the methods that know the binding. Table-id reuse after a close + // only starts a new table's counter higher — conservative, never + // stale-accepting. + std::uint64_t tbl_nav_seq(std::uint32_t id); + std::uint64_t tbl_write_gen(std::uint32_t id); + // Index-keyed cursor op whose parent table the caller resolved. + void note_tbl_nav(std::uint32_t id); + // Connection-wide visibility change (ShowDeleted) or an + // index-keyed op with no resolved parent: expire every table. + void note_all_tables_nav(); + + // Server version from the HelloAck handshake ("openads/1.09.27"; + // pre-1.8.14 servers answer the literal "openads/0.3.2"). Empty + // when the Hello probe failed — callers treat that as unknown, + // never as an error (the Connect that follows decides success). + const std::string& server_version() const noexcept { + return server_version_; + } + + // M12.16 — remote index handle subsystem. + struct OpenIndexEntry { + std::uint32_t id = 0; + std::string tag; + std::string bag_path; // production CDX/NTX/ADI filename + std::string expression; + bool is_unique = false; + bool is_descending = false; + }; + // M12.14 — remote field metadata + extended cursor state. + struct FieldDesc { + std::string name; + std::uint16_t type = 0; // ADS_* code + std::uint32_t length = 0; + std::uint16_t decimals = 0; + }; + // M-AOF.6 — extended OpenTableAck carries production bag path. + struct OpenTableResult { + std::uint32_t id = 0; + std::string prod_bag_path; // production CDX/ADI filename (if found) + // Warm sections (USE latency): schema + first row piggybacked on + // the open ack (see wire.h OpenTableAckSections). Absent when the + // server predates sections — the caller falls back to + // DescribeTable + GotoTop exactly as before. + std::vector fields; + bool has_schema = false; + std::vector first_row; // nav-ack trailer layout + bool has_first_row = false; + }; + util::Result open_table(const std::string& rel, std::uint16_t mode = 0); + util::Result close_table(std::uint32_t id); + util::Result goto_top(std::uint32_t id); + util::Result goto_top(RemoteTable* rt); + // Fused nav+order (see kCapNavOrderFuse): installs order_id before + // navigating, collapsing SetOrder+Goto into one frame. Only call + // when server_nav_order_fuse() is true. + util::Result goto_top_fused(RemoteTable* rt, + std::uint32_t order_id); + util::Result goto_bottom_fused(RemoteTable* rt, + std::uint32_t order_id); + util::Result skip(std::uint32_t id, std::int32_t step); + util::Result skip(RemoteTable* rt, std::int32_t step); + util::Result get_field(std::uint32_t id, + const std::string& field_name); + util::Result record_count(std::uint32_t id); + util::Result key_count(std::uint32_t table_id); + // M12.29 — server-side key number: position of current record in the + // active order's walk. O(1) via pos_of_recno_cached() on the server. + util::Result key_num(std::uint32_t table_id); + util::Result at_eof(std::uint32_t id); + util::Result> + describe_table(std::uint32_t id); + util::Result at_bof(std::uint32_t id); + // Nav-boundary twin read: AtBOF/AtEOF acks carry the twin flag as a + // trailing byte ([u8 bof][u8 eof] / [u8 eof][u8 bof]); has_twin is + // false against old single-byte servers. Lets the ABI layer serve + // the twin answer locally instead of paying a second round-trip + // for rddads' inevitable AtBOF+AtEOF pair. + struct BofEof { + bool bof = false; + bool eof = false; + bool has_twin = false; + }; + util::Result bof_eof(std::uint32_t id); + util::Result eof_bof(std::uint32_t id); + util::Result get_record_num(std::uint32_t id); + util::Result is_record_deleted(std::uint32_t id); + util::Result goto_bottom(std::uint32_t id); + // M12.15 — remote info / lock / maintenance / AOF. + util::Result is_found(std::uint32_t id); + util::Result refresh_record(std::uint32_t id); + // Table-aware overload: parses the row trailer + bound tail the + // server appends, so the refreshed row, bounds and recno serve + // locally instead of costing follow-up frames. + util::Result refresh_record(RemoteTable* rt); + util::Result get_table_type(std::uint32_t id); + util::Result get_record_length(std::uint32_t id); + util::Result get_num_indexes(std::uint32_t id); + util::Result get_last_autoinc(std::uint32_t id); + // DBF header "last updated" date, packed (y<<16)|(m<<8)|d. + util::Result get_last_table_update(std::uint32_t id); + util::Result lock_record(std::uint32_t id, std::uint32_t recno); + util::Result unlock_record(std::uint32_t id, std::uint32_t recno); + // M12.36 — does THIS connection hold a lock on recno (0 = current)? + util::Result is_record_locked(std::uint32_t id, std::uint32_t recno); + // M12.36 — recnos this connection currently holds locks on. + util::Result> get_all_locks(std::uint32_t id); + util::Result lock_table(std::uint32_t id); + util::Result unlock_table(std::uint32_t id); + util::Result pack_table(std::uint32_t id); + util::Result zap_table(std::uint32_t id); + util::Result flush_file_buffers(std::uint32_t id); + util::Result close_all_indexes(std::uint32_t id); + util::Result set_aof(std::uint32_t id, const std::string& cond); + util::Result clear_aof(std::uint32_t id); + util::Result customize_aof(std::uint32_t id, + std::uint16_t option, + const std::vector& recnos); + util::Result get_aof_opt_level(std::uint32_t id); + util::Result> + open_index(std::uint32_t table_id, + const std::string& path); + util::Result close_index(std::uint32_t index_id); + + // M12.29 — AdsDD* Data Dictionary property API, phase 1. `subName` is + // only meaningful for DDObjectKind::Field (the field name within the + // table named by `name`); pass "" for every other kind. See + // docs/wire-protocol.md §9 and wire.h for the wire payload formats. + util::Result dd_get_property(DDObjectKind kind, + const std::string& name, + const std::string& subName, + std::uint16_t propId); + util::Result dd_set_property(DDObjectKind kind, + const std::string& name, + const std::string& subName, + std::uint16_t propId, + const std::string& value); + util::Result dd_create_proc(const std::string& name, + const std::string& container, + const std::string& procName, + const std::string& inParams, + const std::string& outParams, + const std::string& comments); + util::Result dd_create_function(const std::string& name, + const std::string& container, + const std::string& implementation, + const std::string& retType, + const std::string& inParams, + const std::string& comment); + util::Result dd_create_trigger(const std::string& name, + const std::string& table, + std::uint32_t type, + const std::string& container, + const std::string& procedure, + std::uint32_t priority); + util::Result dd_drop_trigger(const std::string& name); + util::Result dd_drop_view(const std::string& name); + util::Result dd_drop_link(const std::string& name); + + // M12.30 — AdsDD* Data Dictionary property API, phase 2 (deferred + // user/group/link/RI/view/index-file/permissions calls). See + // docs/wire-protocol.md §5.25 and wire.h for the wire payload formats. + util::Result dd_create_user(const std::string& group, + const std::string& user, + const std::string& pwd, + const std::string& desc); + // `kind` must be User, RefIntegrity, Proc, or Function — the four + // plain "drop by name" calls not already covered by a phase-1 opcode. + util::Result dd_drop_object(DDObjectKind kind, + const std::string& name); + util::Result dd_add_user_to_group(const std::string& group, + const std::string& user); + util::Result dd_remove_user_from_group(const std::string& group, + const std::string& user); + util::Result dd_create_link(const std::string& alias, + const std::string& path, + const std::string& user, + const std::string& pwd); + util::Result dd_modify_link(const std::string& alias, + const std::string& path, + const std::string& user, + const std::string& pwd); + util::Result dd_create_ref_integrity(const std::string& name, + const std::string& failTable, + const std::string& parent, + const std::string& parentTag, + const std::string& child, + const std::string& childTag, + std::uint16_t updateRule, + std::uint16_t deleteRule); + util::Result dd_create_view(const std::string& name, + const std::string& comments, + const std::string& sql); + util::Result dd_add_index_file(const std::string& table, + const std::string& index, + const std::string& comment); + util::Result dd_remove_index_file(const std::string& table, + const std::string& index); + util::Result dd_get_permissions(const std::string& grantee, + std::uint16_t objType, + const std::string& objName, + bool getInherited); + util::Result dd_grant_permission(std::uint16_t objType, + const std::string& objName, + const std::string& grantee, + std::uint32_t permissions); + + // SetOrder ack outcome: the server appends the just-installed + // order's key count (trailing [u32] after the empty ack body), + // length-gated. Lets the scrollbar setup that always follows a + // switch serve locally instead of paying a GetKeyCount frame. + // Absent on old servers (counted=false). + struct SetOrderOutcome { + bool counted = false; + std::uint32_t key_count = 0; + }; + util::Result set_order(std::uint32_t table_id, + std::uint32_t index_id); + util::Result set_order_by_name(std::uint32_t table_id, + const std::string& tag); + struct SeekOutcome { + std::uint8_t hit = 0; // 1 = exact, 0 = soft / not found + std::uint32_t recno = 0; + }; + // RCB 07/14/2026: `parent` is optional but you almost always want to pass + // it — an M12.24 server returns the row it landed on in the SeekAck, and + // that is the only way to receive it. Pass nullptr and the row cache stays + // invalid, which costs a FetchCurrentRow round-trip on the next field read. + util::Result seek(std::uint32_t index_id, + const std::string& key, + std::uint8_t soft, + std::uint8_t last, + RemoteTable* parent = nullptr); + // M12.35 — query the server for the key expression result type of a + // remote index (ADS_STRING, ADS_DATE, ADS_NUMERIC, ADS_LOGICAL). + util::Result get_key_type(std::uint32_t index_id); + util::Result create_index(std::uint32_t table_id, + const std::string& path, + const std::string& tag, + const std::string& expr, + const std::string& cond, + const std::string& key_filter, + std::uint32_t options, + std::uint16_t page_size); + // Create a free table on the remote data directory (AdsCreateTable). + // Does not leave the table open — caller follows up with open_table(). + util::Result create_table(const std::string& name, + const std::string& fields, + std::uint16_t table_type, + std::uint16_t char_type, + std::uint16_t memo_block_size); + util::Result drop_table(const std::string& name, + std::uint16_t delete_files); + + // Server filesystem (EnableFileFunc on server). + // Positive-only existence cache (rddads probes the same production + // bags every USE): a "true" answer is served locally until any + // file-mutating op on this connection (erase/rename/drop/create, + // via file_exists_invalidate) clears it. Negatives always go to + // the wire — caching "missing" would hide a concurrently created + // table, while a stale "present" degrades to a clean open error. + // src (optional out): 0 = positive cache, 1 = negative cache, + // 2 = wire probe. For probe-level trace logging. + util::Result file_exists(const std::string& path, + int* src = nullptr); + void file_exists_invalidate(); + util::Result file_erase(const std::string& path); + util::Result file_rename(const std::string& old_p, + const std::string& new_p); + util::Result file_size(const std::string& path); + util::Result file_mtime( + const std::string& path); + util::Result> + directory(const std::string& mask); + // M12.33 — remote table enumeration (not gated by EnableFileFunc). + util::Result> + find_tables(const std::string& mask); + // Server-side backup archiving (OAds_Zip/OAds_UnZip — not gated + // by EnableFileFunc; database ops). The file/exclude lists ride + // 0x1F-joined on the wire; stats + archive path come back. + struct ZipArchiveOutcome { + std::uint32_t files = 0; + std::uint64_t bytes = 0; + std::uint64_t archive_bytes = 0; + std::string archive; // relative to owning data root + }; + util::Result + zip_archive(const std::string& dir, + const std::vector& files, + const std::string& zip_name, + std::uint16_t level, + bool overwrite, + const std::string& password, + const std::vector& exclude, + bool with_path); + struct UnzipArchiveOutcome { + std::uint32_t files = 0; + std::uint64_t bytes = 0; + std::uint64_t archive_bytes = 0; + }; + util::Result + unzip_archive(const std::string& dir, + const std::string& zip, + const std::string& password, + bool overwrite, + bool with_path); + // Central-directory listing (OAds_ZipFileCount/OAds_ZipFileList — + // not gated by EnableFileFunc; database ops). The archive spelling + // follows the unzip rule (bare names under backup/). + struct ZipListOutcome { + std::vector entries; + }; + util::Result + zip_list(const std::string& zip); + util::Result dir_exist(const std::string& path); + util::Result dir_make(const std::string& path); + util::Result dir_remove(const std::string& path); + util::Result fopen(const std::string& path, + std::uint16_t mode); + util::Result fcreate(const std::string& path, + std::uint16_t attr); + util::Result fclose(std::uint32_t file_id); + util::Result> + fread(std::uint32_t file_id, + std::uint32_t nbytes); + util::Result fwrite(std::uint32_t file_id, + const std::uint8_t* data, + std::uint32_t n); + util::Result fseek(std::uint32_t file_id, + std::int32_t offset, + std::uint8_t origin); + util::Result skip_unique(std::uint32_t index_id, + std::int32_t direction); + util::Result set_scope(std::uint32_t index_id, + std::uint16_t which, + const std::string& key, + std::uint16_t data_type); + util::Result clear_scope(std::uint32_t index_id, + std::uint16_t which); + // M12.31 — propagate SET DELETED ON/OFF to the server session. + void show_deleted(bool visible) noexcept; + // M12.17 — single-frame whole-record read. + struct RowSnapshot { + bool has_row = false; + std::vector fields; // order matches FieldDesc cache + }; + util::Result fetch_current_row(std::uint32_t table_id); + util::Result fetch_current_row(RemoteTable* rt); + // Warm open: feed the OpenTableAck first-row section through the nav + // trailer parser so the table lands exactly as an explicit GotoTop + // would leave it (caller then mirrors GotoTop's local bookkeeping + // and skips the round-trip). + util::Result apply_open_row(RemoteTable* rt, + const std::vector& trailer); + // mtfix12 R1 — apply the certified opposite-boundary landing + // (pair_blob, row-trailer format) exactly as that boundary's wire + // ack would have: same row-trailer parse (row cache, prefetch + // reset, lag re-anchor), then the certified bound values and the + // scoped key count. Caller has already verified freshness + // (pair_seq/pair_write_gen) via remote_nav_pair. + util::Result apply_pair_blob(RemoteTable* rt); + // M12.6 — remote write surface. + util::Result append_blank(std::uint32_t id); + util::Result set_field(std::uint32_t id, + const std::string& field_name, + const std::string& value); + // Write coalescing: N field writes in ONE round-trip. Payload: + // [u32 tid][u16 n][per field: u16 nlen][name][u32 vlen][value]. + // Empty input is a no-op success (no frame sent). + util::Result set_fields_batch( + std::uint32_t id, + const std::vector>& fields); + // M12.25 — raw DBF record image at the current cursor position. + util::Result> get_record(std::uint32_t id); + util::Result get_record_crc(std::uint32_t id); + util::Result set_record(std::uint32_t id, + const std::uint8_t* bytes, + std::size_t len); + util::Result delete_record(std::uint32_t id); + util::Result recall_record(std::uint32_t id); + util::Result goto_record(std::uint32_t id, + std::uint32_t recno); + util::Result goto_record(RemoteTable* rt, + std::uint32_t recno); + util::Result goto_bottom(RemoteTable* rt); + util::Result flush_table(std::uint32_t id); + // M12.34 — find record by identity columns. Returns recno (0 = not found). + util::Result + find_record(std::uint32_t id, + const std::vector>& identity); + // M12.7 — remote SQL exec. Returns cursor table-id (0 = no cursor, + // i.e. INSERT / UPDATE / DELETE / DDL). + util::Result execute_sql(const std::string& sql); + // M12.8 — remote index ops. + util::Result reindex(std::uint32_t id); + // M12.11 — batch row read. Walks up to `max_rows` rows starting + // at the current cursor position, returning a row-major matrix of + // column values. Empty result set ⇒ empty outer vector. Reduces + // per-row Skip/GetField round-trips for WAN-latency callers. + util::Result>> + fetch_batch(std::uint32_t id, + std::uint32_t max_rows, + const std::vector& columns); + // Tier-2 server-side filtered scan. Like fetch_batch, but the + // server evaluates `where_expr` (a Clipper-style FOR predicate, + // e.g. "AGE > 40 .AND. CITY = 'RIO'") against each row and returns + // only the matching rows' requested columns, walking the table + // server-side until `max_rows` matches or EOF. Collapses a + // non-index-optimisable SET FILTER / COUNT FOR / LOCATE scan from + // one round-trip per record to ceil(matches / max_rows). Base + // tables only — a SQL cursor already filters via its WHERE clause. + // Callers continue the walk like fetch_batch: a batch returning + // fewer than `max_rows` rows has reached EOF. + // `flags`: FetchWhereFlags::WANT_RECNO (0x01) causes the server to + // include each matching row's recno in the reply; the recnos are + // stored in FetchWhereBatch::recnos (same order as rows). flags=0 + // (default) is byte-identical to the v1.4.0 request/reply. + util::Result + fetch_where(std::uint32_t id, + std::uint32_t max_rows, + const std::string& where_expr, + const std::vector& columns, + std::uint8_t flags = 0); + + // Tier-3 — server-side aggregation. The server scans the whole table + // once, evaluates `for_expr` per row, and folds each match into the + // requested accumulators, returning one scalar per spec (same order). + // Base tables only — a SQL cursor already aggregates via SQL. + util::Result + aggregate(std::uint32_t id, + const std::string& for_expr, + const std::vector& specs); + + // M12.32 — distributed mutex service (server-wide named mutexes). + util::Result mutex_create(const std::string& name); + util::Result mutex_lock(const std::string& name, + std::uint32_t timeout_ms = 0); + util::Result mutex_try_lock(const std::string& name); + util::Result mutex_unlock(const std::string& name); + util::Result mutex_destroy(const std::string& name); + + // Count of wire round trips issued on this connection (every + // request(), reads included). Unlike nav_seq() this also moves on + // locks, fetches and counts, so "no frame since X" means nothing at + // all reached the server in between (used by AdsRefreshRecord to + // serve the row a GotoRecord ack just delivered). + std::uint64_t frame_seq() const noexcept { + return frame_seq_.load(std::memory_order_relaxed); + } + + // Count of frames that can ADD rows or change row contents on the + // server (append, field writes, recall, SQL, pack/zap/reindex) sent on + // this connection. The empty-table window (see AdsSeek) closes as soon + // as this moves, so this station never misses its own new record. + std::uint64_t data_epoch() const noexcept { + return data_epoch_.load(std::memory_order_relaxed); + } + + // Per-connection record-length memo for re-opens of the same table. + // Keyed by lowercased table name + the full schema (names, types, + // widths, decimals) from the open ack, so any restructure changes + // the key and misses. Thread-safe. + bool recall_record_length(const std::string& key, + std::uint32_t& out) { + std::lock_guard lk(reclen_mu_); + auto it = reclen_memo_.find(key); + if (it == reclen_memo_.end()) return false; + out = it->second; + return true; + } + void remember_record_length(const std::string& key, std::uint32_t v) { + std::lock_guard lk(reclen_mu_); + if (reclen_memo_.size() < 4096) reclen_memo_[key] = v; + } + +private: + util::Result request(const Frame& f); + + // Record a cursor/visibility-affecting frame. Called at the top of + // every method that can move the server cursor or change what it + // shows (never by pure reads). Lock-free atomic: callable with or + // without mu_ held. + void note_nav_frame() noexcept { + nav_seq_.fetch_add(1, std::memory_order_relaxed); + } + + std::unique_ptr transport_; + std::mutex mu_; + // mtfix12 per-table generations (see tbl_nav_seq/tbl_write_gen). + // Guarded by mu_ (bumped inside request(), which already holds it). + std::unordered_map tbl_nav_seqs_; + std::unordered_map tbl_write_gens_; + // Server caps echoed in ConnectAck (0 when the server predates caps). + std::uint32_t server_caps_ = 0; + // Monotonic cursor-generation counter. Bumped ONLY by frames that + // can move the server cursor or change visibility (nav, seek, + // order/scope/AOF/show-deleted, writes, pack/zap/reindex) — never + // by reads (fetch, counts, describe, keynum, boundary probes). + // The ABI layer stamps nav operations with it, so a consecutive + // duplicate GotoTop/GotoBottom or a proven-empty cursor survives + // the read traffic rddads interleaves between probes (a blanket + // per-frame counter died on the first fetch). Cross-station + // staleness is impossible by construction: observing a change + // requires a cursor-affecting frame, which is exactly what bumps. + std::atomic nav_seq_{0}; + // See frame_seq(): bumped by every request(). + std::atomic frame_seq_{0}; + std::atomic data_epoch_{0}; + std::mutex reclen_mu_; + std::unordered_map reclen_memo_; + // Raw HelloAck payload (see above). Written once during + // connect_with_transport, read afterwards without mu_ (the + // connection is fully established before any other thread + // can hold its handle). + std::string server_version_; + // Positive-only file-existence cache (see file_exists). Guarded + // by its own mutex: consulted on the read path, cleared by + // file-mutating ops, never held across wire calls. + std::set file_exists_cache_; + std::mutex file_exists_mu_; + // Negative half of the existence cache: repeated "missing" probes + // (Vouch checks optional bags/configs on every USE) are served + // locally until any file-mutating op on this connection clears it. + // A stale "missing" degrades to the app re-checking after its own + // create attempt (which clears the cache), never to wrong data. + std::set file_exists_neg_cache_; + // Directory truth (EnableFileFunc): DirExist=true answers and + // successful DirMakes feed dir_known_; DirExist=false feeds + // dir_missing_. A known dir makes DirMake a no-op (the server + // create is idempotent) and DirExist answer locally. Our own + // DirRemove erases the path from both. Peer mkdir/rmdir is not + // tracked -- session-scoped, same trade as the file cache. + std::set dir_known_; + std::set dir_missing_; + std::mutex dir_cache_mu_; + +public: + // Deferred disconnect (MT shared connections). AdsDisconnect on a + // connection that still has tables open through it must not kill + // those tables out from under other threads using the same handle: + // it sets close_pending instead, and the last AdsCloseTable performs + // the real disconnect. Both fields are only touched under the ABI + // state mutex; disconnect() itself stays unconditional. + int deferred_open_tables = 0; + bool close_pending = false; + +public: + // Short-lived handle reuse across close/reopen (Vouch startup: the + // same lookup tables re-USE every few seconds). A parked table keeps + // its server handle, schema, tags and order bindings; a re-USE only + // pays a warm GotoTop instead of open+index+describe+goto (~4 RTTs). + // Eligibility is conservative (shared mode, natural order, never + // locked/scoped, decided by the ABI layer); the pool here is pure + // storage with cap + TTL eviction. Guarded by park_mu_ (never held + // across wire calls — callers extract under it, then go to the wire). + struct ParkedTable { + std::unique_ptr table; + std::string key; + std::chrono::steady_clock::time_point parked_at{}; + }; + static constexpr std::size_t kParkedMax = 16; + static constexpr std::uint64_t kParkedTtlSec = 30; + // Move a matching entry out (most-recent first). True on hit. + bool parked_reuse(const std::string& key, + std::unique_ptr& out); + // Store; evicted entries (beyond cap / expired) are returned for the + // caller to really close (wire + accounting live in the ABI layer). + void parked_store(std::string key, std::unique_ptr rt, + std::vector>& evicted); + // Drain everything (disconnect). Caller really-closes each entry. + void parked_flush(std::vector>& out); + // Diagnostics (wire_trace): is an unexpired entry parked under key? + bool parked_contains(const std::string& key) const; + +private: + std::vector parked_; + mutable std::mutex park_mu_; +}; + +// Per-handle wrapper for a remote table. Stores back-pointer to +// the connection plus the server-side table id. +struct RemoteTable { + RemoteConnection* conn = nullptr; + std::uint32_t id = 0; + // True when this table was counted in conn->deferred_open_tables at + // AdsOpenTable time; AdsCloseTable decrements only when set (remote + // SQL cursors are excluded — AdsDisconnect nulls their conn first). + bool close_counted = false; + // The table name as passed to open_table (with extension). Served + // by AdsGetTableFilename so the consuming RDD has something to show. + std::string name; + // M12.14 — schema cache populated lazily on first + // AdsGetNumFields / AdsGetFieldName / ... call so rddads' + // adsOpen field-iteration loop stays at one wire round-trip. + std::vector fields; + bool fields_cached = false; + // M12.17 — current-record buffer cache. Populated lazily on the + // first AdsGetField after a nav op; invalidated by AdsSkip / + // AdsGotoTop / AdsGotoBottom / AdsGotoRecord / AdsAppendRecord + // / AdsWriteRecord / AdsDeleteRecord / AdsRecallRecord. xbrowse + // re-paints therefore cost one extra RTT per row (the fetch), + // not one per cell. + std::vector current_row; + bool row_valid = false; + // Write coalescing (RDD voucher entry): consecutive AdsSet* calls + // buffer (field name, value) here instead of paying 1 RTT each; the + // buffer flushes as one SetFields batch on the next visibility event + // (read/nav/lock/unlock/commit/close — see remote_flush_pending in + // ace_exports.cpp). Empty = clean. Reads overlay these values onto + // current_row so same-handle read-after-write stays exact. + std::vector> pending_sets; + // Lazy-close pooling (USE latency): eligibility observed over the + // handle lifetime. Parked only when every line below still reads + // fresh-open equivalent at close time. + std::uint16_t open_mode_raw = 0; // usMode as passed to open_table + bool open_exclusive = false; // mapped mode (never pooled) + bool ever_locked = false; // AppendBlank/LockRecord/Table + bool scope_touched = false; // SetScope (server state unclear) + // Client-side lock ledger (WAN chattiness): mirrors the record and + // table locks this connection is known to hold on the server. Lets + // AdsUnlockTable skip the frame when nothing is held (rddads + // dbUnlock() calls it blindly before every lock/append), lets + // AdsGetAllLocks answer locally, and lets the close path park a + // table whose locks were all released (the blunt ever_locked flag + // alone used to force a real close + 7-frame reopen per save). + // locks_uncertain covers locks the ledger cannot name: the append + // auto-lock (AppendBlankAck carries no recno) and LockRecord(0) + // with no valid cursor. Only a wire UnlockTable (or close) clears + // it. Conservative throughout: doubt always goes to the wire. + std::set held_recs; + bool table_lock_held = false; + bool locks_uncertain = false; + // M12.18 — recno + deleted flag arrive together with the row + // bytes so AdsGetRecordNum / AdsIsRecordDeleted can serve from + // cache instead of a separate RTT each. + std::uint32_t current_recno = 0; + bool current_deleted = false; + // Logical 1-based key position within the active index order. + // Updated on remote nav when active_index_id != 0 so AdsGetKeyNum + // (FWH xBrowse scrollbar) returns a non-zero position over TCP. + // Invalidated on Append / Write / Delete / Recall (and on Goto to a + // different recno); re-seeded by GoTop/GoBottom/Skip when valid, or + // by server GetKeyNum (M12.29) on the next AdsGetKeyNum/GetRelKeyPos. + std::uint32_t current_keyno = 0; + bool keyno_valid = false; + // Set when a backward Skip cannot move (top of order). Cleared on + // forward nav / GoTop. Without this, AdsAtBOF always answers "not + // BOF" while row_valid and xBrowse GoUp rubber-bands at key #1. + bool nav_at_bof = false; + // Set when a forward Skip cannot move (bottom of order). + bool nav_at_eof = false; + // Proven-FALSE stickies (mirror of the above): set only when the + // cursor state entails the answer, cleared whenever position or + // visibility could have changed. Lets the twin half of a boundary + // pair answer locally even with no valid row — e.g. Skip forward + // to EOF proves EOF (nav_at_eof) AND not-BOF (arrived from rows), + // so the following AtBOF costs nothing. xBase truth table: + // on-a-row ⇒ neither limit; empty cursor ⇒ both limits. + bool nav_not_bof = false; + bool nav_not_eof = false; + // Last boundary answers with the connection seq at answer time. + // Repeated identical probes (rddads polls BOF/EOF per paint row) + // are served locally while no cursor-affecting frame lands in + // between — strictly fresher than the flags above because ANY + // wire cursor op expires them via the seq. Per-side validity: a + // lone wire answer certifies only its own side; the twin half is + // certified only by the piggybacked twin byte. Cleared alongside + // last_nav at purely-local visibility mutations (the seq rule + // can't see those). + bool bound_bof_ok = false; + bool bound_bof = false; + bool bound_eof_ok = false; + bool bound_eof = false; + std::uint64_t bound_seq = 0; + // Certified recno from the last reposition truth (GotoRecord/Seek + // bound piggyback) or GetRecordNum wire answer, with the seq at + // answer time. Serves AdsGetRecordNum while no cursor-affecting + // frame lands — the phantom-position case (row_valid false) that + // used to cost a round-trip per xBrowse paint row. Same currency + // rule as the bound cache above; cleared with it. + std::uint32_t recno_bound = 0; + bool recno_bound_ok = false; + std::uint64_t recno_bound_seq = 0; + // Certified record count from the last nav ack tail (optional + // [u32 reccount] after the bound bytes), with the seq at answer + // time. Same trust as rec_count_cached (in-memory server count, + // no disk refresh — the wire GetRecordCount keeps its refresh + // for callers that need multiuser-fresh). + std::uint32_t count_bound = 0; + bool count_bound_ok = false; + std::uint64_t count_bound_seq = 0; + // Wall time of the last bound tail (server certification) and the + // connection data_epoch() at that moment. Drive the short "still + // empty" window for physically empty tables (AdsSeek/AdsGotoRecord). + std::chrono::steady_clock::time_point bound_at{}; + std::uint64_t bound_data_epoch = 0; + // Last wire nav op on this table (0 = none/other, 1 = GotoTop, + // 2 = GotoBottom), whether it produced a row, and the connection + // nav_seq_ at the time. Serves two WAN-chattiness kills with one + // stamp: (a) a consecutive duplicate GotoTop/GotoBottom with an + // unchanged seq provably re-establishes identical state, so the + // frame is skipped; (b) a top/bottom that produced NO row proves + // an empty cursor, so AtBOF/AtEOF answer locally until a + // cursor-affecting frame lands. Purely-local visibility mutations + // (AdsSetFilter/ClearFilter) reset last_nav to 0 — every + // cursor-affecting wire op invalidates automatically via the seq. + int last_nav = 0; + bool last_nav_row = false; + std::uint64_t last_nav_seq = 0; + // Order context of last_nav: the RemoteIndex id for index-handle + // nav, the ack-confirmed server_order_id for table-handle nav. + // A top in order A says nothing about order B, so the duplicate + // check requires the context to match, not just the op. + std::uint32_t last_nav_order = 0; + + // mtfix12 R1 — boundary-pair certification. A GotoTop/GotoBottom ack + // on a kCapNavBoundaryPair server carries the OPPOSITE boundary's + // complete landing state, read by the server in the same atomic + // visit: the row blob in row-trailer format (pack_row_trailer + // bytes, lookahead depth 0), that landing's bound values, and the + // scoped key count. While the conn-wide nav_seq holds (identical + // envelope to remote_nav_duplicate) and no write touched this table + // since (write_gen), a back-to-back opposite-boundary call applies + // the blob exactly as the wire ack would have: same parser, same + // bound application, same keyno sync — byte-identical state, one + // RTT saved. pair_which is the boundary this certifies (1 = top, + // 2 = bottom), i.e. the OPPOSITE of the nav that carried it. + bool pair_valid = false; + int pair_which = 0; + std::uint32_t pair_order = 0; + std::uint64_t pair_seq = 0; + std::uint64_t pair_write_gen = 0; + std::vector pair_blob; + bool pair_bof = false; + bool pair_eof = false; + std::uint32_t pair_recno = 0; + bool pair_has_count = false; + std::uint32_t pair_reccount = 0; + bool pair_has_keycount = false; + std::uint32_t pair_keycount = 0; + // Write generation snapshot: RemoteConnection::tbl_write_gen(id) + // at certification time (the conn bumps the live counter inside + // request() for every content-affecting frame, so no call site can + // miss it). The pair blob was read before any such change, so it + // must not overwrite fresher row state — guard on equality at + // serve time. + // mtfix12 R2 — certified server-cursor anchor. Set whenever a wire + // nav ack (or the open warm row) lands this handle's cursor on a + // row: the server cursor IS anchor_recno at that instant (lag is + // 0 by ack contract). Conn-wide envelope: valid while + // anchor_seq == conn nav_seq. Per-table opt-in envelope + // (OPENADS_NAV_SELF_GOTO=table): valid while anchor_tbl_seq == + // tbl_change_seq — server cursors are per handle, so only THIS + // handle's frames can move it. Position-only certification; row + // bytes come from the existing row cache. + std::uint32_t anchor_recno = 0; + bool anchor_ok = false; + std::uint64_t anchor_seq = 0; + std::uint64_t anchor_tbl_seq = 0; + // anchor_tbl_seq snapshots RemoteConnection::tbl_nav_seq(id) — the + // per-table cursor/visibility generation bumped centrally in + // request(). Purely-local visibility mutations (filter/scope/order + // set+clear — the sites that reset last_nav) must also expire the + // anchor: they clear anchor_ok directly. + // M12.19 — cached record count. Serves AdsGetRecordCount and + // AdsGetRelKeyPos (scrollbar) without an extra RTT. Invalidated + // on writes that may change the row count: AppendBlank / + // DeleteRecord / RecallRecord / Pack / Zap. + std::uint32_t cached_rec_count = 0; + bool rec_count_cached = false; + // Scoped key count of the active order (scope + SET DELETED aware, + // computed server-side). What the keyno machinery must clamp to — + // NOT the physical record count, or a 1-row scope reports KeyNo=36 + // and xBrowse walks past the scope end (Tim Stone, 31/07/2026). + // Invalidated on scope set/clear, order change, show-deleted flip, + // and every write that invalidates rec_count_cached. + std::uint32_t cached_key_count = 0; + bool key_count_cached = false; + // Per-order key counts: an order switch does not change any + // order's count, so rotation revisits serve from here while the + // single slot above still bounces per switch. Second-chance + // cache only: every site that clears the slot for a WRITE (or + // adopt/refresh) clears this too; order-switch clears leave it + // intact. Counts are order-scoped server values, keyed by the + // wire index id (0 = natural order). + std::unordered_map key_counts; + // M12.21 — sequential prefetch queue. SkipAck (when step==1) + // returns the row at +1 plus up to N lookahead rows; the + // bridge pops one entry per Skip(1) call so a 20-row PgDn + // costs 1 RTT total, not 20. Cleared by any non-sequential + // nav or write so the queue can never serve a stale row. + struct PrefetchedRow { + std::uint32_t recno = 0; + bool deleted = false; + std::vector fields; + }; + std::deque prefetch_queue; + // RCB 07/15/2026: M12.25 — the direction the queued rows are walked in: + // +1 = forward (each row is the next in a Skip(+1) scan), -1 = backward + // (PgUp), 0 = empty/none. A drain only fires when the caller's step sign + // matches; a direction reversal can't serve from the queue and goes to the + // wire (which refills the queue in the new direction). Set on each nav ack + // from the sign of the wire step. + std::int8_t prefetch_dir = 0; + // RCB 07/15/2026: M12.25 — was `prefetch_consumed` (unsigned, forward-only). + // Now SIGNED: cursor_lag = client's logical position MINUS the server's + // cursor position. A forward local drain moves the client ahead of the + // server (lag += 1); a backward local drain moves it behind (lag -= 1). The + // next wire Skip sends (step + cursor_lag) so the server lands at + // client_logical + step regardless of sign. Reset to 0 on every nav ack + // (the ack re-anchors the server to the client's logical row). The forward + // path is byte-for-byte unchanged: lag was always >= 0 there, and + // step + lag == the old step + prefetch_consumed. + std::int32_t cursor_lag = 0; + // M12.21 option C — cached Found() state. xBase clears Found() on any + // non-seek move (Skip/Goto) and sets it from the seek outcome, so the + // ops that know the value set it here and AdsIsFound serves it with no + // round-trip (rddads polls IsFound after every DbSkip). found_cached + // gates the fast path: when false, AdsIsFound asks the server. + bool found_cached = false; + bool current_found = false; + // Filter / AOF expression strings (stored for AdsGetFilter / AdsGetAOF). + std::string filter_expr; + std::string aof_expr; + // Table alias (stored for AdsGetTableAlias). + std::string alias; + // Server-side wire id of the active controlling index (0 = natural order). + // Updated by AdsSetIndexOrder / AdsSetIndexOrderByHandle / AdsOpenIndex. + std::uint32_t active_index_id = 0; + // RCB 07/14/2026: the order the SERVER actually has installed, as last + // confirmed by a SetOrder ack. Why this exists as a SECOND field instead + // of just reusing active_index_id: active_index_id is only the client's + // *belief*, and it can be set with no round-trip at all (the production-bag + // auto-open in AdsOpenTable100, or AdsGetIndexHandle resolving a tag). That + // asymmetry is exactly why remote_activate_index used to re-send SetOrder + // before every single nav op — trusting active_index_id left + // ordered_tables_ empty on the server, so GotoTop/Skip walked the engine + // table in natural order and ignored any scope. Somebody hit that, and the + // fix was to give up and always send the frame. + // + // Keying the skip on a value that ONLY a real ack can write closes the hole + // properly, which is what makes it safe to send SetOrder once per order + // change instead of once per row. rddads navigates on hOrdCurrent whenever + // an order is set, so that was costing a whole round-trip on every single + // row of every ordered browse. + static constexpr std::uint32_t kOrderUnknown = 0xFFFFFFFFu; + std::uint32_t server_order_id = kOrderUnknown; + + // RCB 07/14/2026: M12.23 — AdsCacheRecords(hTable, N). The depth the CALLER + // asked for, sent on each Skip request. kPrefetchDepthAuto (the default) + // means the app never called AdsCacheRecords, so the server picks the depth + // itself by ramping on detected sequential access. + // + // Why honour an explicit value at all, when the server's ramp is usually + // smarter than a fixed number: because the app can know things the access + // pattern cannot reveal. "I am about to edit most of the records I visit" + // looks identical to a plain scan until the writes start landing — and by + // then we have already shipped blocks that each write throws away. SAP + // documents 0/1 as the remedy for exactly that, and it needs a way to reach + // us. + std::uint16_t cache_records_hint = kPrefetchDepthAuto; + + // RCB 07/14/2026: drop the read-ahead block AND the consumed-lag counter. + // Every op that moves the server cursor outside the sequential-skip path + // (Seek, order change, ...) must call this. Missing it is not a perf bug, + // it is a WRONG-DATA bug, and we shipped three of them: the queued rows + // were read at the *old* position/in the *old* order, so a following + // Skip(1) would pop a stale row with no wire traffic at all (nothing on the + // network to make it look suspicious), and the next real skip would then + // send a step inflated by a lag that no longer applies. + void invalidate_prefetch() { + prefetch_queue.clear(); + prefetch_dir = 0; + cursor_lag = 0; + } + // Tag name → server wire index id (populated at AdsOpenIndex). + std::vector> index_by_tag; + // Parallel to index_by_tag: the ABI ADSHANDLE (registry handle, 64-bit) + // wrapping each opened index. Lets AdsGetIndexHandle (by tag) and + // AdsGetIndexHandleByOrder (by ordinal) resolve to a usable remote + // index handle over the wire — the path rddads' DbSetOrder() takes. + std::vector index_handles; + // Production index bag path (e.g. "customers.cdx"). Populated from the + // OpenTableAck auto-open or AdsOpenIndex. Lets AdsGetIndexFilename + // (OrdBagName) return the bag name without a separate wire round-trip. + std::string prod_bag_path; + // Immutable-per-handle metadata: table type and record length never + // change for an open table (only a restructure alters them, and that + // closes/reopens). Cached on first hit; rddads asks both per USE. + bool table_type_cached = false; + std::uint16_t cached_table_type = 0; + bool record_length_cached = false; + std::uint32_t cached_record_length = 0; + // Set by a wire AdsGotoRecord that landed on a row: the connection + // frame_seq() right after the ack and the recno it delivered. While + // no other frame has gone out and the cursor still sits on that row, + // an AdsRefreshRecord would re-read the very row that ack carried + // (the server's GotoRecord already re-read it from disk), so the + // refresh is served from it with no round trip. + bool goto_row_fresh = false; + std::uint64_t goto_row_frame_seq = 0; + std::uint32_t goto_row_recno = 0; + // Deferred teardown (WAN chattiness: rddads issues FlushFileBuffers + // + CloseAllIndexes before every CloseTable — 2 wasted frames per + // USE, since the server close flushes data and purges index + // bindings anyway). Set instead of sending; remote_flush_pending + // emits them ahead of any other intervening wire op, and the close + // path absorbs them silently. Tables carrying either flag are + // never parked (a park performs no server close to absorb into). + bool flush_file_pending = false; + bool close_all_indexes_pending = false; + // True once this handle wrote (buffered sets, append, delete, + // recall) without a durability flush since. Gates AdsFlushFileBuffers: + // a flush on a clean table (the RDD calls it blindly around every + // rotation) sets no flag, so teardown stays fully deferred and the + // index park survives. Cleared when a flush frame goes out or a + // real close absorbs it. Over-flagging only sends flushes (safe); + // the flag never gates data visibility (buffered sets flush via + // pending_sets independently). + bool write_dirty = false; + // Parked index bindings (WAN chattiness: per-tag CloseAll→OpenIndex + // rotation — 166 + 125 frames/startup). CloseAll snapshots the live + // maps here instead of dropping them; the server bindings stay open + // (no frame sent), so a same-bag OpenIndex restores them with zero + // frames and any op that only needs live bindings adopts the park + // (pending flag cleared, nothing sent). A real CloseAll frame goes + // out only when server state must actually drop: different-bag open + // (else the server accumulates bags), structural ops like + // CreateIndex (snapshot invalidated explicitly). Table close + // absorbs silently (server close purges anyway) and disconnect + // drops everything. Session-scoped validity: our session's bindings + // die only via our own frames, all of which funnel through the + // adopt-or-emit decision, so a parked snapshot can never reference + // dead server ids. + bool indexes_parked = false; + // Nav stamp parked alongside the index snapshot: the CloseAll -> + // OpenIndex (unpark) cycle sends no frames, so a stamp taken just + // before the park still describes the live server cursor as long as + // nav_seq is unchanged when the same order is restored. Zeroed + // whenever the parked snapshot dies for real. + int parked_nav_which = 0; + std::uint32_t parked_nav_order = 0; + bool parked_nav_row = false; + std::uint64_t parked_nav_seq = 0; + std::vector> parked_by_tag; + std::vector parked_handles; + std::uint32_t parked_active = 0; + std::string parked_bag_stem; + // Server-canonical bag path of the last wire OpenIndex (empty + // until the first one; the production auto-open counts). Seeds + // parked_bag_stem at CloseAll so the reopen match compares the + // bag actually bound, not a reopen spelling. + std::string last_open_bag; + // Deferred order switch (WAN chattiness: SetOrder+GotoTop/Bottom + // per tag rotation). SetOrder never moves the cursor, so the frame + // waits here until a nav absorbs it (fused SetOrder+Goto, one + // frame) or any other binding-dependent op flushes it plainly. + // active_index_id tracks the pending belief immediately; + // server_order_id only on ack. Never parked (adopt would inherit + // a belief the server doesn't share). + bool pending_order = false; + std::uint32_t pending_order_id = 0; // wire index id (0 = natural) +}; + +// M12.16 — per-handle wrapper for a remote index. Each tag +// returned by AdsOpenIndex (the multi-tag CDX case) gets one of +// these so the ABI surface can hand the host a real ADSHANDLE. +struct RemoteIndex { + RemoteConnection* conn = nullptr; + std::uint32_t id = 0; // server-side index id + std::uint32_t tbl_id = 0; // server-side table id this binds to + std::string tag_name; // CDX/NTX tag (AdsGetIndexName / OrdName) + std::string bag_path; // production CDX/NTX/ADI filename (AdsGetIndexFilename / OrdBagName) + std::string expression; // key expression (AdsGetIndexExpr) + bool is_unique = false; // AdsIsIndexUnique + bool is_descending = false; // AdsIsIndexDescending (physical flag) + // M12.17 — back-pointer so AdsSeek / AdsSeekLast / AdsSkipUnique + // can invalidate the parent table's row cache after the cursor + // moves on the server side. + RemoteTable* parent = nullptr; +}; + +// Parse `tcp://host:port/` into its parts. Returns +// false when the input doesn't carry the tcp:// prefix. +bool parse_tcp_uri(const std::string& uri, + std::string& host, + std::uint16_t& port, + std::string& data_dir); + +// M12.12 — TLS URI scheme `tls://host:port/` is reserved +// but not yet implemented. parse_tls_uri only returns true when the +// input carries the tls:// prefix; AdsConnect60 surfaces +// AE_FUNCTION_NOT_AVAILABLE so apps get a clear failure instead of +// a silent plaintext fallback. Real TLS (vendored mbedtls / +// platform-native) lands in v0.4.0. +bool parse_tls_uri(const std::string& uri, + std::string& host, + std::uint16_t& port, + std::string& data_dir); + +} // namespace openads::network diff --git a/src/network/3-client.cpp b/src/network/3-client.cpp new file mode 100644 index 00000000..373727d3 --- /dev/null +++ b/src/network/3-client.cpp @@ -0,0 +1,3398 @@ +#include "network/client.h" + +#include "abi/lock_retry_policy.h" +#include "engine/table.h" +#include "openads/error.h" + +#include +#include +#include +#include +#include +#include + +namespace openads::network { + +namespace { + +inline void write_u32_le(std::uint32_t v, + std::vector& out) { + out.push_back(static_cast( v & 0xFFu)); + out.push_back(static_cast((v >> 8) & 0xFFu)); + out.push_back(static_cast((v >> 16) & 0xFFu)); + out.push_back(static_cast((v >> 24) & 0xFFu)); +} + +inline std::uint32_t read_u32_le(const std::uint8_t* p) { + return static_cast(p[0]) | + (static_cast(p[1]) << 8) | + (static_cast(p[2]) << 16) | + (static_cast(p[3]) << 24); +} + +inline void write_u16_le(std::uint16_t v, + std::vector& out) { + out.push_back(static_cast( v & 0xFFu)); + out.push_back(static_cast((v >> 8) & 0xFFu)); +} + +inline std::uint16_t read_u16_le(const std::uint8_t* p) { + return static_cast( + static_cast(p[0]) | + (static_cast(p[1]) << 8)); +} + +// M12.29 — [u16 len][bytes] string helper shared by the DD RPC methods. +inline void write_lstr16(const std::string& s, std::vector& out) { + write_u16_le(static_cast(s.size()), out); + out.insert(out.end(), s.begin(), s.end()); +} + +// M12.33 — read a [u16 len][bytes] string from a payload buffer. +inline bool read_lstr16(const std::vector& pl, + std::size_t& off, std::string& out) { + if (off + 2 > pl.size()) return false; + std::uint16_t len = read_u16_le(pl.data() + off); + off += 2; + if (off + len > pl.size()) return false; + out.assign(reinterpret_cast(pl.data() + off), len); + off += len; + return true; +} + +// Schema body shared by DescribeTableAck and the warm OpenTableAck +// section (identical layout by construction — see Session:: +// append_open_warm_sections). Returns the fields or an error with the +// same messages DescribeTable has always produced. +inline util::Result> +parse_schema_body(const std::vector& pl, std::size_t base, + std::size_t len) { + std::vector out; + if (len < 2) { + return util::Error{5000, 0, + "DescribeTable: short payload", ""}; + } + std::size_t pos = base; + const std::size_t end = base + len; + std::uint16_t n = read_u16_le(&pl[pos]); pos += 2; + out.reserve(n); + for (std::uint16_t i = 0; i < n; ++i) { + if (pos >= end) { + return util::Error{5000, 0, + "DescribeTable: truncated field record", ""}; + } + std::uint8_t name_len = pl[pos++]; + if (pos + name_len + 8 > end) { + return util::Error{5000, 0, + "DescribeTable: truncated field record", ""}; + } + RemoteConnection::FieldDesc f; + f.name.assign(pl.begin() + static_cast(pos), + pl.begin() + static_cast(pos + name_len)); + pos += name_len; + f.type = read_u16_le(&pl[pos]); pos += 2; + f.length = read_u32_le(&pl[pos]); pos += 4; + f.decimals = read_u16_le(&pl[pos]); pos += 2; + out.push_back(std::move(f)); + } + return out; +} + +} // namespace + +// M12.18 — parse the per-row trailer the server appends to every +// nav-op ack and the FetchCurrentRow ack. Format: +// +// [u8 has_row] +// if has_row != 0: +// [u32 recno][u8 deleted][u16 nfields] +// per field: [u32 len][bytes] +// +// On success rt is updated in-place; on a "no row" trailer (has_row +// == 0) rt->row_valid is cleared and current_row left as is. +namespace { + +// Parse one record body starting at `pos`: [u32 recno][u8 deleted] +// [u16 nfields][per-field: u32 len, bytes]. Returns the new cursor +// position past the record, or std::size_t(-1) on truncation. +std::size_t parse_one_row(const std::vector& pl, + std::size_t pos, + std::uint32_t& recno, + bool& deleted, + std::vector& fields) { + constexpr std::size_t fail = static_cast(-1); + if (pos + 4 + 1 + 2 > pl.size()) return fail; + recno = read_u32_le(&pl[pos]); pos += 4; + deleted = (pl[pos++] != 0); + std::uint16_t n = read_u16_le(&pl[pos]); pos += 2; + if (n > kMaxWireFields) return fail; + fields.clear(); + fields.reserve(n); + for (std::uint16_t i = 0; i < n; ++i) { + if (pos + 4 > pl.size()) return fail; + std::uint32_t vlen = read_u32_le(&pl[pos]); pos += 4; + if (pos + vlen > pl.size()) return fail; + fields.emplace_back( + reinterpret_cast(pl.data() + pos), vlen); + pos += vlen; + } + return pos; +} + +// RCB 07/15/2026: `block_dir` is the direction the SERVER walked the lookahead +// block (+1 forward, -1 backward) — which is sign(eff), NOT sign(the caller's +// step. On a direction reversal those disagree for one step: after a forward +// run the resync eff can still be positive on the first backward Skip, so the +// server sends a FORWARD block; tagging it backward would make the next +// Skip(-1) pop a forward row and serve the wrong record. Callers that never +// request a block (GotoBottom / Seek / FetchCurrentRow) can leave the default; +// if no block comes back the queue ends up empty and prefetch_dir is set to 0. +std::size_t parse_row_trailer_into(RemoteTable* rt, + const std::vector& pl, + std::size_t pos = 0, + std::int8_t block_dir = 1) { + // Returns the offset where trailer parsing stopped, so callers + // can read trailing sections (reposition-bound piggyback). + if (rt == nullptr || pos >= pl.size()) { + if (rt) { rt->row_valid = false; rt->anchor_ok = false; } + return pos; + } + rt->prefetch_queue.clear(); + // M12.21 option C — every nav ack re-anchors the server cursor to the + // client's logical position, so the lag resets to zero. + rt->cursor_lag = 0; + rt->prefetch_dir = 0; + std::uint8_t has_row = pl[pos++]; + if (has_row == 0) { + rt->row_valid = false; + // Landed on no row: the server cursor is at a phantom, which + // certifies no recno anchor for the R2 self-goto check. + rt->anchor_ok = false; + // Lookahead count is always 0 when has_row=0, but the 2 bytes + // are still on the wire: consume them so the returned offset + // points past the trailer (trailing sections key off it). + if (pos + 2 <= pl.size()) pos += 2; + return pos; + } + auto end = parse_one_row(pl, pos, + rt->current_recno, rt->current_deleted, rt->current_row); + if (end == static_cast(-1)) { + rt->row_valid = false; rt->anchor_ok = false; return pos; + } + pos = end; + rt->row_valid = true; + // mtfix12 R2 — every server-shipped landing certifies the cursor + // anchor: the server cursor IS current_recno right now (the ack + // contract resets cursor_lag to 0 above). Serves self-GotoRecord + // and GetRecordNum while the freshness seq holds. + rt->anchor_recno = rt->current_recno; + rt->anchor_ok = true; + rt->anchor_seq = rt->conn != nullptr ? rt->conn->nav_seq() : 0; + rt->anchor_tbl_seq = + rt->conn != nullptr ? rt->conn->tbl_nav_seq(rt->id) : 0; + // M12.21 lookahead block. [u16 count] then count rows. + if (pos + 2 > pl.size()) return pos; // M12.18 server (no lookahead) — done. + std::uint16_t la = read_u16_le(&pl[pos]); pos += 2; + for (std::uint16_t i = 0; i < la; ++i) { + RemoteTable::PrefetchedRow pr; + end = parse_one_row(pl, pos, pr.recno, pr.deleted, pr.fields); + if (end == static_cast(-1)) break; + pos = end; + rt->prefetch_queue.push_back(std::move(pr)); + } + if (!rt->prefetch_queue.empty()) rt->prefetch_dir = block_dir; + return pos; +} + +} // namespace + +namespace { + +bool parse_scheme_uri(const std::string& uri, + const std::string& scheme, + std::string& host, + std::uint16_t& port, + std::string& data_dir) { + if (uri.size() < scheme.size() || + uri.compare(0, scheme.size(), scheme) != 0) { + return false; + } + std::size_t after = scheme.size(); + std::size_t slash = uri.find('/', after); + std::string hostport = uri.substr(after, + slash == std::string::npos ? std::string::npos : slash - after); + std::size_t colon = hostport.find(':'); + if (colon == std::string::npos) return false; + host = hostport.substr(0, colon); + port = static_cast( + std::strtoul(hostport.substr(colon + 1).c_str(), nullptr, 10)); + data_dir = (slash == std::string::npos) ? std::string() + : uri.substr(slash + 1); + return true; +} + +} // namespace + +bool parse_tcp_uri(const std::string& uri, + std::string& host, + std::uint16_t& port, + std::string& data_dir) { + return parse_scheme_uri(uri, "tcp://", host, port, data_dir); +} + +bool parse_tls_uri(const std::string& uri, + std::string& host, + std::uint16_t& port, + std::string& data_dir) { + return parse_scheme_uri(uri, "tls://", host, port, data_dir); +} + +namespace { +std::atomic g_frame_trace_hook{nullptr}; +} // namespace + +void set_frame_trace_hook(FrameTraceHook hook) noexcept { + g_frame_trace_hook.store(hook, std::memory_order_relaxed); +} + +std::uint64_t RemoteConnection::tbl_nav_seq(std::uint32_t id) { + std::lock_guard lk(mu_); + const auto it = tbl_nav_seqs_.find(id); + return it != tbl_nav_seqs_.end() ? it->second : 0; +} + +std::uint64_t RemoteConnection::tbl_write_gen(std::uint32_t id) { + std::lock_guard lk(mu_); + const auto it = tbl_write_gens_.find(id); + return it != tbl_write_gens_.end() ? it->second : 0; +} + +void RemoteConnection::note_tbl_nav(std::uint32_t id) { + std::lock_guard lk(mu_); + ++tbl_nav_seqs_[id]; +} + +void RemoteConnection::note_all_tables_nav() { + std::lock_guard lk(mu_); + for (auto& [id, v] : tbl_nav_seqs_) ++v; +} + +util::Result RemoteConnection::request(const Frame& f) { + std::lock_guard lk(mu_); + frame_seq_.fetch_add(1, std::memory_order_relaxed); + switch (f.opcode) { + case Opcode::AppendBlank: case Opcode::SetField: + case Opcode::SetFields: case Opcode::RecallRecord: + case Opcode::ExecuteSQL: case Opcode::Reindex: + case Opcode::PackTable: case Opcode::ZapTable: + data_epoch_.fetch_add(1, std::memory_order_relaxed); + break; + default: + break; + } + // mtfix12 — per-table generations. Every frame funnels through + // here, so classifying by opcode is exhaustive by construction. + // nav: cursor/visibility-affecting (mirrors note_nav_frame per + // table; FlushTable/FlushFileBuffers intentionally excluded — + // they move no cursor, and dropping them is exactly the widening + // the per-table envelope opts into). write: content-affecting + // (row bytes may differ after it lands). Index-keyed ops + // (Seek/SeekLast/SkipUnique/SetScope/ClearScope) and the + // conn-wide ShowDeleted are bumped by their methods, which know + // the binding. + if (f.payload.size() >= 4) { + const std::uint32_t tid = read_u32_le(f.payload.data()); + switch (f.opcode) { + case Opcode::GotoTop: case Opcode::GotoBottom: + case Opcode::GotoRecord: case Opcode::Skip: + case Opcode::AppendBlank: case Opcode::PackTable: + case Opcode::ZapTable: case Opcode::Reindex: + case Opcode::SetAOF: case Opcode::ClearAOFRemote: + case Opcode::CustomizeAOF: case Opcode::SetOrder: + case Opcode::SetOrderByName: + ++tbl_nav_seqs_[tid]; + break; + default: break; + } + switch (f.opcode) { + case Opcode::AppendBlank: case Opcode::SetField: + case Opcode::SetFields: case Opcode::SetRecord: + case Opcode::DeleteRecord: case Opcode::RecallRecord: + case Opcode::PackTable: case Opcode::ZapTable: + case Opcode::Reindex: + ++tbl_write_gens_[tid]; + break; + default: break; + } + } + const FrameTraceHook trace_hook = + g_frame_trace_hook.load(std::memory_order_relaxed); + const auto trace_t0 = trace_hook != nullptr + ? std::chrono::steady_clock::now() + : std::chrono::steady_clock::time_point{}; + // Fail fast on a disconnected handle. rddads hands us connection + // handles that AdsDisconnect already tore down (its "current + // connection" can point at a handle another thread just closed); + // dereferencing a null transport_ here was an access violation, + // and a blocking read on a dead socket hung the caller forever. + // SAP ADS returns an error immediately in this state. + if (!transport_ || !transport_->valid()) { + return util::Error{5036 /* AE_NO_CONNECTION */, 0, + "RemoteConnection: not connected", ""}; + } + if (auto r = write_frame(*transport_,f); !r) { + // Storm fix (run30): a failed send leaves the wire in an unknown + // state. Poison the connection so every later request() fails + // fast with AE_NO_CONNECTION instead of reading half-written or + // stale frames (opcode desync -> cascading "server error"). + transport_->close(); + return r.error(); + } + auto rep = read_frame(*transport_); + if (!rep) { + // Storm fix (run30): a failed/timeout recv is worse than a failed + // send — the request may still be in flight and its reply can + // arrive later. If we kept the socket, the next request would + // consume that late reply and every subsequent opcode check on + // the shared connection would mismatch (run30: ~370 cascading + // "SetField/AppendBlank: server error" after 30 s recv timeouts). + // Poison instead: fail fast with 5036 like a dropped connection. + transport_->close(); + return rep.error(); + } + if (trace_hook != nullptr) { + const long long us = static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now() - trace_t0).count()); + const std::uint32_t tid = + f.payload.size() >= 4 ? read_u32_le(f.payload.data()) : 0u; + trace_hook(this, static_cast(f.opcode), tid, + f.payload.size(), + static_cast(rep.value().opcode), + rep.value().payload.size(), us); + } + // M12.10 — Error frame payload prefixed with [u32 LE ace_code]. + // Parse it back into the util::Error so callers see the real ACE + // code (5036, 7077, 5066, ...) instead of a generic 5000. + if (rep.value().opcode == Opcode::Error) { + std::uint32_t code = 5000; + std::string msg; + const auto& pl = rep.value().payload; + if (pl.size() >= 4) { + code = read_u32_le(pl.data()); + msg.assign(reinterpret_cast(pl.data() + 4), + pl.size() - 4); + } else { + msg.assign(reinterpret_cast(pl.data()), + pl.size()); + } + return util::Error{static_cast(code), 0, + std::move(msg), ""}; + } + return rep; +} + +namespace { + +void connect_pack_payload(std::vector& payload, + const std::string& data_dir, + const std::string& user, + const std::string& password) { + auto pushlen = [](std::vector& out, std::uint16_t n) { + out.push_back(static_cast( n & 0xFFu)); + out.push_back(static_cast((n >> 8) & 0xFFu)); + }; + auto pushstr = [&pushlen](std::vector& out, + const std::string& s) { + pushlen(out, static_cast(s.size())); + out.insert(out.end(), s.begin(), s.end()); + }; + pushstr(payload, data_dir); + pushstr(payload, user); + pushstr(payload, password); + // M12.21 option C — advertise the prefetch-consume capability so the + // server may piggyback lookahead rows on forward-Skip acks. Trailing + // and optional: pre-M12.21 servers ignore the extra 4 bytes. + // RCB 07/15/2026: M12.25 — also advertise backward (PgUp) prefetch. This is + // a distinct bit precisely because a server must NOT send a backward block + // to a client that only understands forward ones (see kCapPrefetchBackward). + std::uint32_t caps = kCapPrefetchConsume | kCapPrefetchBackward + | kCapOpenTableMode | kCapSetFieldsBatch + | kCapFlushInCloseAll | kCapNavOrderFuse + | kCapNavBoundaryPair; + for (int i = 0; i < 4; ++i) + payload.push_back(static_cast((caps >> (8 * i)) & 0xFFu)); +} + +} // namespace + +util::Result RemoteConnection::connect(const std::string& host, + std::uint16_t port, + const std::string& data_dir, + const std::string& user, + const std::string& password) { + auto s = connect_tcp(host, port); + if (!s) return s.error(); + return connect_with_transport(make_plain_transport(s.value()), + data_dir, user, password); +} + +util::Result +RemoteConnection::connect_with_transport(std::unique_ptr transport, + const std::string& data_dir, + const std::string& user, + const std::string& password) { + if (!transport || !transport->valid()) { + return util::Error{5000, 0, + "RemoteConnection: invalid transport", data_dir}; + } + transport_ = std::move(transport); + // Version probe: Hello predates every capability word and every + // server answers it (HelloAck payload = "openads/"), so a + // client can report WHICH serverd binary is answering without any + // new opcode. Best-effort: a failed probe leaves server_version_ + // empty (unknown) and the Connect below still decides success, so + // old or half-open peers behave exactly as before at the cost of + // one extra RTT per connect (connects are rare; USEs are not). + server_version_.clear(); + { + Frame hello; + hello.opcode = Opcode::Hello; + if (auto hrep = request(hello); + hrep && hrep.value().opcode == Opcode::HelloAck) { + const auto& pl = hrep.value().payload; + server_version_.assign(pl.begin(), pl.end()); + } + } + Frame req; + req.opcode = Opcode::Connect; + connect_pack_payload(req.payload, data_dir, user, password); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::ConnectAck) { + std::string msg(rep.value().payload.begin(), + rep.value().payload.end()); + return util::Error{5000, 0, "Connect: " + msg, data_dir}; + } + // Server caps echo: new servers append [u32 LE] after + // "connected:" (dir echoed verbatim). Old servers send exactly + // "connected:" — the size/echo check below rejects that shape + // so server_caps_ stays 0 and the client keeps legacy single ops. + server_caps_ = 0; + { + const auto& pl = rep.value().payload; + constexpr const char* kPrefix = "connected:"; + constexpr std::size_t kPreLen = 10; + if (pl.size() >= kPreLen + 4 && + std::equal(pl.begin(), + pl.begin() + static_cast(kPreLen), + kPrefix)) { + const std::size_t tail = pl.size() - kPreLen - 4; + if (tail == data_dir.size() && + std::equal(pl.begin() + static_cast(kPreLen), + pl.begin() + static_cast(kPreLen + tail), + data_dir.begin())) { + const std::uint8_t* c = pl.data() + pl.size() - 4; + server_caps_ = + static_cast(c[0]) | + (static_cast(c[1]) << 8) | + (static_cast(c[2]) << 16) | + (static_cast(c[3]) << 24); + } + } + } + // M12.32 — SET DELETED often runs before AdsConnect60 (rddads apps + // set it in Main, then connect), so the AdsShowDeleted broadcast + // found no remote connection to notify. Sync the state now; the + // server default is "show", so only the hidden state needs pushing. + if (!openads::engine::show_deleted()) show_deleted(false); + return {}; +} + +bool RemoteConnection::parked_reuse(const std::string& key, + std::unique_ptr& out) { + std::lock_guard lk(park_mu_); + const auto now = std::chrono::steady_clock::now(); + for (auto it = parked_.begin(); it != parked_.end(); ++it) { + const auto age = + std::chrono::duration_cast(now - it->parked_at); + if (age.count() > static_cast(kParkedTtlSec)) continue; + if (it->key == key) { + out = std::move(it->table); + parked_.erase(it); + return true; + } + } + return false; +} + +void RemoteConnection::parked_store(std::string key, + std::unique_ptr rt, + std::vector>& evicted) { + std::lock_guard lk(park_mu_); + const auto now = std::chrono::steady_clock::now(); + // Drop expired first (caller really-closes them like any eviction). + for (auto it = parked_.begin(); it != parked_.end();) { + const auto age = + std::chrono::duration_cast(now - it->parked_at); + if (age.count() > static_cast(kParkedTtlSec)) { + evicted.push_back(std::move(it->table)); + it = parked_.erase(it); + } else { + ++it; + } + } + ParkedTable e; + e.table = std::move(rt); + e.key = std::move(key); + e.parked_at = now; + parked_.push_back(std::move(e)); + // Cap: evict oldest (front) beyond the limit. + while (parked_.size() > kParkedMax) { + evicted.push_back(std::move(parked_.front().table)); + parked_.erase(parked_.begin()); + } +} + +bool RemoteConnection::parked_contains(const std::string& key) const { + std::lock_guard lk(park_mu_); + const auto now = std::chrono::steady_clock::now(); + for (const auto& e : parked_) { + const auto age = + std::chrono::duration_cast(now - e.parked_at); + if (age.count() > static_cast(kParkedTtlSec)) continue; + if (e.key == key && e.table) return true; + } + return false; +} + +void RemoteConnection::parked_flush( + std::vector>& out) { + std::lock_guard lk(park_mu_); + for (auto& e : parked_) { + if (e.table) out.push_back(std::move(e.table)); + } + parked_.clear(); +} + +void RemoteConnection::disconnect() noexcept { // Serialise with request(): resetting transport_ while another + // thread is mid-round-trip on this connection made that thread + // dereference a null transport_ (AV) or read a corrupted stream. + std::lock_guard lk(mu_); + if (!transport_ || !transport_->valid()) return; + Frame req; + req.opcode = Opcode::Disconnect; + (void)write_frame(*transport_, req); + transport_->close(); + transport_.reset(); +} + +util::Result RemoteConnection::begin_transaction() { + Frame req; + req.opcode = Opcode::BeginTransaction; + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::BeginTransactionAck) { + return util::Error{5000, 0, "BeginTransaction: server error", + std::string(rep.value().payload.begin(), + rep.value().payload.end())}; + } + return {}; +} + +util::Result RemoteConnection::commit_transaction() { + Frame req; + req.opcode = Opcode::CommitTransaction; + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::CommitTransactionAck) { + return util::Error{5000, 0, "CommitTransaction: server error", + std::string(rep.value().payload.begin(), + rep.value().payload.end())}; + } + return {}; +} + +util::Result RemoteConnection::rollback_transaction() { + Frame req; + req.opcode = Opcode::RollbackTransaction; + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::RollbackTransactionAck) { + return util::Error{5000, 0, "RollbackTransaction: server error", + std::string(rep.value().payload.begin(), + rep.value().payload.end())}; + } + return {}; +} + +util::Result +RemoteConnection::open_table(const std::string& rel, std::uint16_t mode) { + Frame req; + req.opcode = Opcode::OpenTable; + // Extended payload: [u16 mode][table_name_bytes] + // Old servers ignore the prefix and read the full payload as the table + // name; new servers strip the 2-byte prefix when mode is non-zero. + write_u16_le(mode, req.payload); + req.payload.insert(req.payload.end(), rel.begin(), rel.end()); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::OpenTableAck) { + return util::Error{5000, 0, "OpenTable: server error", + std::string(rep.value().payload.begin(), + rep.value().payload.end())}; + } + const auto& pl = rep.value().payload; + if (pl.size() < 4) { + return util::Error{5000, 0, + "OpenTable: ack payload too short", ""}; + } + OpenTableResult result; + result.id = read_u32_le(pl.data()); + // Fixed prefix: [u32 id][u16 bag_len][bag]. The bag field is always + // emitted (possibly empty) so the warm sections have a fixed anchor; + // servers predating sections send exactly the id, servers predating + // the always-emit rule send id+bag with no trailing sections. + size_t off = 4; + if (off + 2 <= pl.size()) { + std::uint16_t blen = read_u16_le(pl.data() + off); + off += 2; + if (off + blen <= pl.size()) { + if (blen > 0) { + result.prod_bag_path.assign( + reinterpret_cast(pl.data() + off), blen); + } + off += blen; + // Warm sections: [u8 count][tag u8][len u32 LE][bytes]... + // Unknown tags skip by length; truncation drops the sections + // (never the open — fall back to DescribeTable + GotoTop). + if (off < pl.size()) { + std::uint8_t nsec = pl[off++]; + for (std::uint8_t s = 0; s < nsec; ++s) { + if (off + 1 + 4 > pl.size()) break; + std::uint8_t tag = pl[off++]; + std::uint32_t slen = + static_cast(pl[off]) | + (static_cast(pl[off + 1]) << 8) | + (static_cast(pl[off + 2]) << 16) | + (static_cast(pl[off + 3]) << 24); + off += 4; + if (off + slen > pl.size()) break; + if (tag == OpenTableAckSections::kSchema) { + if (auto sr = parse_schema_body(pl, off, slen)) { + result.fields = std::move(sr).value(); + result.has_schema = true; + } + } else if (tag == OpenTableAckSections::kFirstRow) { + result.first_row.assign( + pl.begin() + static_cast(off), + pl.begin() + static_cast(off + slen)); + result.has_first_row = true; + } + off += slen; + } + } + } + } + return result; +} + +util::Result RemoteConnection::apply_open_row(RemoteTable* rt, + const std::vector& trailer) { + // Feed the warm row section through the same parser every nav ack + // uses, so the table lands exactly as an explicit GotoTop would + // leave it (row cache + prefetch block + lag reset). + parse_row_trailer_into(rt, trailer, 0); + return {}; +} + + +util::Result RemoteConnection::close_table(std::uint32_t id) { + Frame req; + req.opcode = Opcode::CloseTable; + write_u32_le(id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::CloseTableAck) { + return util::Error{5000, 0, "CloseTable: server error", ""}; + } + return {}; +} + +// Reposition-bound truth: the server appended [u8 bof][u8 eof][u32 recno] +// (+ optional [u32 reccount]) after the row trailer because it just +// positioned explicitly and knows them exactly. Applies them as certified +// state so the poll burst that always follows a reposition +// (AtBOF/AtEOF/RecNo/RecCount per paint row) is served locally. +// Length-gated by the caller: absent on old servers. +static void apply_bound_trailer(RemoteTable* rt, bool bof, bool eof, + std::uint32_t recno, std::uint32_t reccount, + bool has_count) { + if (rt == nullptr || rt->conn == nullptr) return; + rt->nav_at_bof = bof; + rt->nav_at_eof = eof; + // xBase truth table (mirrors the proven-false stickies): a defined + // position that is not a limit is provably not that limit; an + // empty cursor proves neither. + rt->nav_not_bof = !bof; + rt->nav_not_eof = !eof; + const std::uint64_t seq = rt->conn->nav_seq(); + rt->bound_bof_ok = true; + rt->bound_bof = bof; + rt->bound_eof_ok = true; + rt->bound_eof = eof; + rt->bound_seq = seq; + rt->recno_bound = recno; + rt->recno_bound_ok = true; + rt->recno_bound_seq = seq; + rt->bound_at = std::chrono::steady_clock::now(); + rt->bound_data_epoch = rt->conn->data_epoch(); + if (has_count) { + rt->count_bound = reccount; + rt->count_bound_ok = true; + rt->count_bound_seq = seq; + } +} + +// Read a bound tail at [tend, ...): 6 bytes (bof/eof/recno), plus an +// optional 4-byte reccount. Returns false when no tail is present +// (old server) — caller falls back to the wire polls as before. +static bool apply_bound_tail(RemoteTable* rt, + const std::vector& pl, + std::size_t tend) { + if (pl.size() < tend + 6) return false; + const bool has_count = pl.size() >= tend + 10; + apply_bound_trailer(rt, pl[tend] != 0, pl[tend + 1] != 0, + read_u32_le(pl.data() + tend + 2), + has_count ? read_u32_le(pl.data() + tend + 6) : 0u, + has_count); + return true; +} + +// mtfix12 R1 — pair-requested acks carry an explicit [u8 ack_flags] +// byte right after the row trailer so section offsets are deterministic +// (the plain length-inference in apply_bound_tail cannot tell a 6-byte +// bound followed by a pair section from a 10-byte bound). ack_flags +// bit 0x01: the main bound tail carries reccount (10 bytes). Applies +// the bound exactly like apply_bound_tail and returns the offset just +// past it. Missing/short tail: nothing applied, returns tend (the +// server declined pair entirely; no certification, wire fallback). +static std::size_t pair_ack_bound_end(RemoteTable* rt, + const std::vector& pl, + std::size_t tend) { + if (rt == nullptr || pl.size() < tend + 1) return tend; + const std::uint8_t af = pl[tend]; + const std::size_t blen = (af & 0x01) ? 10 : 6; + if (pl.size() < tend + 1 + blen) return tend; + apply_bound_trailer(rt, pl[tend + 1] != 0, pl[tend + 2] != 0, + read_u32_le(pl.data() + tend + 3), + (af & 0x01) ? read_u32_le(pl.data() + tend + 7) : 0u, + (af & 0x01) != 0); + return tend + 1 + blen; +} + +// mtfix12 R1 — boundary-pair tail (see kCapNavBoundaryPair in wire.h). +// Layout at [off, ...): +// [u8 flags][u32 trailer_len][trailer][bound 6|10][u32 keycount?] +// flags: 0x02 = bound carries reccount (10 bytes), 0x04 = keycount +// present (ordered tables). The trailer is row-trailer format +// (pack_row_trailer bytes at lookahead depth 0) describing the OPPOSITE +// boundary's landing, read by the server in the same atomic visit as +// the nav that carried it. Stored for the ABI layer to apply verbatim +// if the adjacent opposite-boundary call arrives while the conn-wide +// freshness envelope holds (same rule as remote_nav_duplicate) and no +// write touched the table since (write_gen). Absent/malformed tail = +// no certification: pair_valid stays false and the next +// opposite-boundary call goes to the wire exactly as before. +static bool apply_pair_tail(RemoteTable* rt, int opp_which, + std::uint32_t order, + const std::vector& pl, + std::size_t off) { + if (rt == nullptr || pl.size() < off + 5) return false; + const std::uint8_t flags = pl[off]; + const std::uint32_t tlen = read_u32_le(pl.data() + off + 1); + const std::size_t bound_len = (flags & 0x02) ? 10 : 6; + const std::size_t need = + 5 + static_cast(tlen) + bound_len + + ((flags & 0x04) ? 4 : 0); + if (pl.size() < off + need) return false; + std::size_t p = off + 5; + rt->pair_blob.assign(pl.begin() + p, pl.begin() + p + tlen); + p += tlen; + rt->pair_bof = pl[p] != 0; + rt->pair_eof = pl[p + 1] != 0; + rt->pair_recno = read_u32_le(pl.data() + p + 2); + rt->pair_has_count = (flags & 0x02) != 0; + rt->pair_reccount = rt->pair_has_count + ? read_u32_le(pl.data() + p + 6) : 0u; + p += bound_len; + rt->pair_has_keycount = (flags & 0x04) != 0; + rt->pair_keycount = rt->pair_has_keycount + ? read_u32_le(pl.data() + p) : 0u; + rt->pair_which = opp_which; + rt->pair_order = order; + rt->pair_seq = rt->conn != nullptr ? rt->conn->nav_seq() : 0; + rt->pair_write_gen = + rt->conn != nullptr ? rt->conn->tbl_write_gen(rt->id) : 0; + rt->pair_valid = true; + return true; +} + +util::Result RemoteConnection::apply_pair_blob(RemoteTable* rt) { + if (rt == nullptr || !rt->pair_valid) { + return util::Error{5000, 0, "apply_pair_blob: no certification", ""}; + } + // Same byte path a wire ack for the opposite boundary would take: + // the stored trailer parse re-anchors lag and resets prefetch, + // then the certified bound values land through the shared trailer + // application so every bound/recno/count stamp matches. + parse_row_trailer_into(rt, rt->pair_blob, 0); + apply_bound_trailer(rt, rt->pair_bof, rt->pair_eof, rt->pair_recno, + rt->pair_reccount, rt->pair_has_count); + if (rt->pair_has_keycount) { + rt->key_counts[rt->pair_order] = rt->pair_keycount; + } + return {}; +} + +util::Result RemoteConnection::goto_top(std::uint32_t id) { + note_nav_frame(); + Frame req; + req.opcode = Opcode::GotoTop; + write_u32_le(id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::GotoTopAck) { + return util::Error{5000, 0, "GotoTop: server error", ""}; + } + return {}; +} + +// M12.18 — RemoteTable-aware overload: same wire op, but parses +// the row trailer the server appends to populate the table's +// row cache in-place. xbrowse repaint becomes 1 RTT per Skip +// (the row arrives with the ack) instead of 2. +util::Result RemoteConnection::goto_top(RemoteTable* rt) { + note_nav_frame(); + // RCB 07/15/2026: rt is non-null by caller contract -- every caller resolves + // it from the handle registry (get_remote_table) or from a ri->parent that + // was already checked. Deliberately NOT null-checked here, matching every + // other rt/id method on this class; a lone guard (flagged in review) would + // be inconsistent noise, not added safety. + Frame req; + req.opcode = Opcode::GotoTop; + write_u32_le(rt->id, req.payload); + // RCB 07/14/2026: M12.24 — same OPTIONAL trailing [u16 depth] the Skip + // request carries. GotoTop now comes back warm (a lookahead block rides on + // the ack), and it has to respect AdsCacheRecords like everything else — + // otherwise an app that explicitly turned read-ahead OFF would still get a + // block dumped on it by every GoTop. Old servers length-check and ignore + // the two bytes; see kPrefetchDepthAuto in wire.h. + req.payload.push_back( + static_cast(rt->cache_records_hint & 0xFFu)); + req.payload.push_back( + static_cast((rt->cache_records_hint >> 8) & 0xFFu)); + // mtfix12 R1: ask for the opposite boundary's certification (the + // back-to-back dbGoTop(); dbGoBottom() ritual). Caps-gated: old + // servers never see the byte (flag 0x02, no order section). + const bool want_pair = server_nav_boundary_pair(); + if (want_pair) req.payload.push_back(0x02); + rt->pair_valid = false; + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::GotoTopAck) { + return util::Error{5000, 0, "GotoTop: server error", ""}; + } + const std::size_t tend = + parse_row_trailer_into(rt, rep.value().payload, 0); + // Reposition-bound piggyback (see goto_record): trailing + // [u8 bof][u8 eof][u32 recno], length-gated. + if (want_pair) { + const std::size_t off = + pair_ack_bound_end(rt, rep.value().payload, tend); + apply_pair_tail(rt, 2, rt->server_order_id, + rep.value().payload, off); + } else { + apply_bound_tail(rt, rep.value().payload, tend); + } + return {}; +} + +util::Result RemoteConnection::skip(std::uint32_t id, + std::int32_t step) { + note_nav_frame(); + Frame req; + req.opcode = Opcode::Skip; + write_u32_le(id, req.payload); + write_u32_le(static_cast(step), req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::SkipAck) { + return util::Error{5000, 0, "Skip: server error", ""}; + } + return {}; +} + +util::Result RemoteConnection::skip(RemoteTable* rt, + std::int32_t step) { + note_nav_frame(); + // RCB 07/15/2026: rt is non-null by caller contract (resolved from the + // handle registry, or a checked ri->parent). Not null-checked, for the same + // reason as goto_top(rt) above -- consistent with the rest of this class. + Frame req; + req.opcode = Opcode::Skip; + write_u32_le(rt->id, req.payload); + // M12.21 option C — the server cursor is offset from the client's logical + // position by cursor_lag (rows served locally from the queue without a + // round-trip). Fold that lag into the wire step so the server lands where + // the client logically is + step. RCB 07/15/2026: cursor_lag is signed now + // (negative for a backward run), so this one line serves both directions; + // parse_row_trailer_into on the ack zeroes it again. + std::int32_t eff = step + rt->cursor_lag; + write_u32_le(static_cast(eff), req.payload); + // RCB 07/14/2026: M12.23 — trailing optional [u16 depth] carrying the + // AdsCacheRecords value (kPrefetchDepthAuto when the app never called it). + // Safe to append unconditionally: an older server length-checks its Skip + // payload (`size() < 8`) and reads only the first 8 bytes, so it simply + // ignores these two. That is why this needed no capability bit — see the + // note on kPrefetchDepthAuto in wire.h for the other direction, which is + // the one that could actually have bitten us. + req.payload.push_back( + static_cast(rt->cache_records_hint & 0xFFu)); + req.payload.push_back( + static_cast((rt->cache_records_hint >> 8) & 0xFFu)); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::SkipAck) { + return util::Error{5000, 0, "Skip: server error", ""}; + } + // The block (if any) was walked by the server in the direction of eff, not + // of the user's step — see parse_row_trailer_into. eff == 0 sends no block. + const std::int8_t bdir = (eff > 0) ? 1 : (eff < 0) ? -1 : 1; + const std::size_t tend = + parse_row_trailer_into(rt, rep.value().payload, 0, bdir); + // Reposition-bound piggyback (see goto_record): trailing + // [u8 bof][u8 eof][u32 recno], length-gated. + apply_bound_tail(rt, rep.value().payload, tend); + return {}; +} + +util::Result +RemoteConnection::get_field(std::uint32_t id, + const std::string& field_name) { + Frame req; + req.opcode = Opcode::GetField; + write_u32_le(id, req.payload); + req.payload.insert(req.payload.end(), + field_name.begin(), field_name.end()); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::GetFieldAck) { + return util::Error{5000, 0, "GetField: server error", + field_name}; + } + return std::string(rep.value().payload.begin(), + rep.value().payload.end()); +} + +util::Result +RemoteConnection::record_count(std::uint32_t id) { + Frame req; + req.opcode = Opcode::GetRecordCount; + write_u32_le(id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::GetRecordCountAck || + rep.value().payload.size() < 4) { + return util::Error{5000, 0, + "GetRecordCount: server error", ""}; + } + return read_u32_le(rep.value().payload.data()); +} + +util::Result +RemoteConnection::key_count(std::uint32_t id) { + Frame req; + req.opcode = Opcode::GetKeyCount; + write_u32_le(id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::GetKeyCountAck || + rep.value().payload.size() < 4) { + return util::Error{5000, 0, + "GetKeyCount: server error", ""}; + } + return read_u32_le(rep.value().payload.data()); +} + +// M12.29 — server-side key number. The server computes the position of the +// current record in the active order's walk via pos_of_recno_cached() → O(1), +// eliminating the O(n) remote_measure_keyno client-side walk. +util::Result RemoteConnection::key_num(std::uint32_t id) { + Frame req; + req.opcode = Opcode::GetKeyNum; + write_u32_le(id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::GetKeyNumAck || + rep.value().payload.size() < 4) { + return util::Error{5000, 0, + "GetKeyNum: server error", ""}; + } + return read_u32_le(rep.value().payload.data()); +} + +util::Result +RemoteConnection::bof_eof(std::uint32_t id) { + Frame req; + req.opcode = Opcode::AtBOF; + write_u32_le(id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::AtBOFAck || + rep.value().payload.empty()) { + return util::Error{5000, 0, "AtBOF: server error", ""}; + } + BofEof out; + out.bof = rep.value().payload[0] != 0; + // Twin flag: new servers append the EOF answer ([u8 bof][u8 eof]). + // Old single-byte servers leave has_twin false and the caller + // behaves exactly as before. + if (rep.value().payload.size() >= 2) { + out.eof = rep.value().payload[1] != 0; + out.has_twin = true; + } + return out; +} + +util::Result +RemoteConnection::eof_bof(std::uint32_t id) { + Frame req; + req.opcode = Opcode::AtEOF; + write_u32_le(id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::AtEOFAck || + rep.value().payload.empty()) { + return util::Error{5000, 0, "AtEOF: server error", ""}; + } + BofEof out; + out.eof = rep.value().payload[0] != 0; + // Twin flag: new servers append the BOF answer ([u8 eof][u8 bof]). + if (rep.value().payload.size() >= 2) { + out.bof = rep.value().payload[1] != 0; + out.has_twin = true; + } + return out; +} + +util::Result RemoteConnection::at_eof(std::uint32_t id) { + auto r = eof_bof(id); + if (!r) return r.error(); + return r.value().eof; +} + +util::Result RemoteConnection::append_blank(std::uint32_t id) { + note_nav_frame(); + Frame req; + req.opcode = Opcode::AppendBlank; + write_u32_le(id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::AppendBlankAck) { + return util::Error{5000, 0, "AppendBlank: server error", ""}; + } + return {}; +} + +util::Result> +RemoteConnection::get_record(std::uint32_t id) { + Frame req; + req.opcode = Opcode::GetRecord; + write_u32_le(id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::GetRecordAck || + rep.value().payload.size() < 2) { + return util::Error{5000, 0, "GetRecord: server error", ""}; + } + const auto& pl = rep.value().payload; + std::uint16_t len = static_cast( + static_cast(pl[0]) | + (static_cast(pl[1]) << 8)); + if (pl.size() < 2u + len) { + return util::Error{5000, 0, "GetRecord: truncated payload", ""}; + } + return std::vector(pl.begin() + 2, pl.begin() + 2 + len); +} + +util::Result +RemoteConnection::get_record_crc(std::uint32_t id) { + Frame req; + req.opcode = Opcode::GetRecordCRC; + write_u32_le(id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::GetRecordCRAck || + rep.value().payload.size() < 4) { + return util::Error{5000, 0, "GetRecordCRC: server error", ""}; + } + const auto& pl = rep.value().payload; + return static_cast(pl[0]) | + (static_cast(pl[1]) << 8) | + (static_cast(pl[2]) << 16) | + (static_cast(pl[3]) << 24); +} + +util::Result RemoteConnection::set_record(std::uint32_t id, + const std::uint8_t* bytes, + std::size_t len) { + note_nav_frame(); + if (len > 0xFFFFu) { + return util::Error{5000, 0, "SetRecord: record too large", ""}; + } + Frame req; + req.opcode = Opcode::SetRecord; + write_u32_le(id, req.payload); + auto ulen = static_cast(len); + write_u16_le(ulen, req.payload); + if (bytes != nullptr && len > 0) { + req.payload.insert(req.payload.end(), bytes, bytes + len); + } + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::SetRecordAck) { + return util::Error{5000, 0, "SetRecord: server error", ""}; + } + return {}; +} + +util::Result RemoteConnection::set_field(std::uint32_t id, + const std::string& field_name, + const std::string& value) { + note_nav_frame(); + if (field_name.size() > 0xFFFFu) { + return util::Error{5000, 0, + "SetField: field name too long", field_name}; + } + Frame req; + req.opcode = Opcode::SetField; + write_u32_le(id, req.payload); + auto nlen = static_cast(field_name.size()); + req.payload.push_back(static_cast( nlen & 0xFFu)); + req.payload.push_back(static_cast((nlen >> 8) & 0xFFu)); + req.payload.insert(req.payload.end(), + field_name.begin(), field_name.end()); + req.payload.insert(req.payload.end(), value.begin(), value.end()); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::SetFieldAck) { + return util::Error{5000, 0, "SetField: server error", + field_name}; + } + return {}; +} + +util::Result RemoteConnection::set_fields_batch( + std::uint32_t id, + const std::vector>& fields) { + note_nav_frame(); + if (fields.empty()) return {}; + if (fields.size() > kMaxWireFields) { + return util::Error{5000, 0, + "SetFields: too many fields", ""}; + } + Frame req; + req.opcode = Opcode::SetFields; + write_u32_le(id, req.payload); + write_u16_le(static_cast(fields.size()), req.payload); + for (const auto& [name, value] : fields) { + if (name.size() > 0xFFFFu || value.size() > 0xFFFFFFFFu) { + return util::Error{5000, 0, + "SetFields: field too large", name}; + } + write_u16_le(static_cast(name.size()), req.payload); + req.payload.insert(req.payload.end(), name.begin(), name.end()); + std::uint32_t vlen = static_cast(value.size()); + req.payload.push_back(static_cast( vlen & 0xFFu)); + req.payload.push_back(static_cast((vlen >> 8) & 0xFFu)); + req.payload.push_back(static_cast((vlen >> 16) & 0xFFu)); + req.payload.push_back(static_cast((vlen >> 24) & 0xFFu)); + req.payload.insert(req.payload.end(), value.begin(), value.end()); + } + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::SetFieldsAck) { + return util::Error{5000, 0, "SetFields: server error", ""}; + } + return {}; +} + +util::Result RemoteConnection::delete_record(std::uint32_t id) { + note_nav_frame(); + Frame req; + req.opcode = Opcode::DeleteRecord; + write_u32_le(id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::DeleteRecordAck) { + return util::Error{5000, 0, "DeleteRecord: server error", ""}; + } + return {}; +} + +util::Result RemoteConnection::recall_record(std::uint32_t id) { + note_nav_frame(); + Frame req; + req.opcode = Opcode::RecallRecord; + write_u32_le(id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::RecallRecordAck) { + return util::Error{5000, 0, "RecallRecord: server error", ""}; + } + return {}; +} + +util::Result RemoteConnection::goto_record(std::uint32_t id, + std::uint32_t recno) { + note_nav_frame(); + Frame req; + req.opcode = Opcode::GotoRecord; + write_u32_le(id, req.payload); + write_u32_le(recno, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::GotoRecordAck) { + return util::Error{5000, 0, "GotoRecord: server error", ""}; + } + return {}; +} + +util::Result RemoteConnection::goto_record(RemoteTable* rt, + std::uint32_t recno) { + note_nav_frame(); + Frame req; + req.opcode = Opcode::GotoRecord; + write_u32_le(rt->id, req.payload); + write_u32_le(recno, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::GotoRecordAck) { + return util::Error{5000, 0, "GotoRecord: server error", ""}; + } + const std::size_t tend = + parse_row_trailer_into(rt, rep.value().payload, 0); + // Reposition-bound piggyback: trailing [u8 bof][u8 eof][u32 recno] + // (length-gated; old servers send no tail). Certifies the boundary + // + recno answers so the post-reposition poll burst costs nothing. + apply_bound_tail(rt, rep.value().payload, tend); + return {}; +} + +util::Result RemoteConnection::goto_bottom(RemoteTable* rt) { + note_nav_frame(); + Frame req; + req.opcode = Opcode::GotoBottom; + write_u32_le(rt->id, req.payload); + // mtfix12 R1: opposite-boundary certification (see goto_top). + const bool want_pair = server_nav_boundary_pair(); + if (want_pair) req.payload.push_back(0x02); + rt->pair_valid = false; + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::GotoBottomAck) { + return util::Error{5000, 0, "GotoBottom: server error", ""}; + } + const std::size_t tend = + parse_row_trailer_into(rt, rep.value().payload, 0); + // Reposition-bound piggyback (see goto_record): trailing + // [u8 bof][u8 eof][u32 recno], length-gated. + if (want_pair) { + const std::size_t off = + pair_ack_bound_end(rt, rep.value().payload, tend); + apply_pair_tail(rt, 1, rt->server_order_id, + rep.value().payload, off); + } else { + apply_bound_tail(rt, rep.value().payload, tend); + } + return {}; +} + +// Fused nav+order: trailing [u8 0x01][u32 order_id] installs the order +// before navigating (see kCapNavOrderFuse). Caller guarantees the +// server advertised the bit; old servers must never see this shape +// (their prefix-only parse would navigate the stale binding). +util::Result RemoteConnection::goto_top_fused(RemoteTable* rt, + std::uint32_t order_id) { + note_nav_frame(); + Frame req; + req.opcode = Opcode::GotoTop; + write_u32_le(rt->id, req.payload); + req.payload.push_back( + static_cast(rt->cache_records_hint & 0xFFu)); + req.payload.push_back( + static_cast((rt->cache_records_hint >> 8) & 0xFFu)); + const bool want_pair = server_nav_boundary_pair(); + req.payload.push_back(static_cast( + 0x01 | (want_pair ? 0x02 : 0x00))); + write_u32_le(order_id, req.payload); + rt->pair_valid = false; + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::GotoTopAck) { + return util::Error{5000, 0, "GotoTop: server error", ""}; + } + const std::size_t tend = + parse_row_trailer_into(rt, rep.value().payload, 0); + if (want_pair) { + // Pair-requested fused ack: [rowtrailer][u8 ack_flags][bound] + // [u32 fused key count][pair section]. + const std::size_t off = + pair_ack_bound_end(rt, rep.value().payload, tend); + const auto& pl = rep.value().payload; + if (pl.size() >= off + 4) { + rt->key_counts[order_id] = read_u32_le(pl.data() + off); + } + apply_pair_tail(rt, 2, order_id, pl, off + 4); + } else { + // Reposition-bound piggyback (see goto_record): trailing + // [u8 bof][u8 eof][u32 recno](+[u32 reccount]), length-gated. + apply_bound_tail(rt, rep.value().payload, tend); + // Fused switch only: the server certified the new order's key + // count past the bound tail ([u32]). Rotation visits ask it + // next; serve from the per-order map instead of a frame. + const auto& pl = rep.value().payload; + if (pl.size() >= tend + 14) { + rt->key_counts[order_id] = read_u32_le(pl.data() + tend + 10); + } + } + return {}; +} + +util::Result RemoteConnection::goto_bottom_fused(RemoteTable* rt, + std::uint32_t order_id) { + note_nav_frame(); + Frame req; + req.opcode = Opcode::GotoBottom; + write_u32_le(rt->id, req.payload); + const bool want_pair = server_nav_boundary_pair(); + req.payload.push_back(static_cast( + 0x01 | (want_pair ? 0x02 : 0x00))); + write_u32_le(order_id, req.payload); + rt->pair_valid = false; + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::GotoBottomAck) { + return util::Error{5000, 0, "GotoBottom: server error", ""}; + } + const std::size_t tend = + parse_row_trailer_into(rt, rep.value().payload, 0); + if (want_pair) { + const std::size_t off = + pair_ack_bound_end(rt, rep.value().payload, tend); + const auto& pl = rep.value().payload; + if (pl.size() >= off + 4) { + rt->key_counts[order_id] = read_u32_le(pl.data() + off); + } + apply_pair_tail(rt, 1, order_id, pl, off + 4); + } else { + // Reposition-bound piggyback (see goto_record): trailing + // [u8 bof][u8 eof][u32 recno](+[u32 reccount]), length-gated. + apply_bound_tail(rt, rep.value().payload, tend); + // Fused switch only: certified key count past the bound tail. + const auto& pl = rep.value().payload; + if (pl.size() >= tend + 14) { + rt->key_counts[order_id] = read_u32_le(pl.data() + tend + 10); + } + } + return {}; +} + +util::Result RemoteConnection::flush_table(std::uint32_t id) { + note_nav_frame(); + Frame req; + req.opcode = Opcode::FlushTable; + write_u32_le(id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::FlushTableAck) { + return util::Error{5000, 0, "FlushTable: server error", ""}; + } + return {}; +} + +util::Result +RemoteConnection::find_record( + std::uint32_t id, + const std::vector>& identity) { + Frame req; + req.opcode = Opcode::FindRecord; + write_u32_le(id, req.payload); + write_u16_le(static_cast(identity.size()), req.payload); + for (auto& [n, v] : identity) { + write_u16_le(static_cast(n.size()), req.payload); + req.payload.insert(req.payload.end(), n.begin(), n.end()); + write_u16_le(static_cast(v.size()), req.payload); + req.payload.insert(req.payload.end(), v.begin(), v.end()); + } + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::FindRecordAck) { + return util::Error{5000, 0, "FindRecord: server error", + std::string(rep.value().payload.begin(), + rep.value().payload.end())}; + } + if (rep.value().payload.size() < 4) { + return util::Error{5000, 0, "FindRecord: ack too short", ""}; + } + std::uint32_t recno = read_u32_le(rep.value().payload.data()); + return recno; +} + +util::Result RemoteConnection::reindex(std::uint32_t id) { + note_nav_frame(); + Frame req; + req.opcode = Opcode::Reindex; + write_u32_le(id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::ReindexAck) { + return util::Error{5000, 0, "Reindex: server error", ""}; + } + return {}; +} + +util::Result>> +RemoteConnection::fetch_batch(std::uint32_t id, + std::uint32_t max_rows, + const std::vector& columns) { + note_nav_frame(); + if (columns.size() > 0xFFu) { + return util::Error{5000, 0, + "Fetch: too many columns (max 255)", ""}; + } + Frame req; + req.opcode = Opcode::Fetch; + write_u32_le(id, req.payload); + write_u32_le(max_rows, req.payload); + req.payload.push_back(static_cast(columns.size())); + for (auto& c : columns) { + if (c.size() > 0xFFu) { + return util::Error{5000, 0, + "Fetch: column name too long (max 255)", c}; + } + req.payload.push_back(static_cast(c.size())); + req.payload.insert(req.payload.end(), c.begin(), c.end()); + } + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::FetchAck || + rep.value().payload.size() < 5) { + return util::Error{5000, 0, "Fetch: server error", ""}; + } + const auto& pl = rep.value().payload; + std::size_t p = 0; + std::uint32_t nrows = read_u32_le(pl.data() + p); p += 4; + std::uint8_t ncols = pl[p++]; + std::vector> rows; + rows.reserve(nrows); + for (std::uint32_t r = 0; r < nrows; ++r) { + std::vector row; + row.reserve(ncols); + for (std::uint8_t c = 0; c < ncols; ++c) { + if (p + 2 > pl.size()) { + return util::Error{5000, 0, + "Fetch: truncated payload (vlen)", ""}; + } + std::uint16_t vlen = static_cast( + static_cast(pl[p]) | + (static_cast(pl[p + 1]) << 8)); + p += 2; + if (p + vlen > pl.size()) { + return util::Error{5000, 0, + "Fetch: truncated payload (val)", ""}; + } + row.emplace_back(reinterpret_cast(pl.data() + p), + vlen); + p += vlen; + } + rows.push_back(std::move(row)); + } + return rows; +} + +util::Result +RemoteConnection::fetch_where(std::uint32_t id, + std::uint32_t max_rows, + const std::string& where_expr, + const std::vector& columns, + std::uint8_t flags) { + note_nav_frame(); + if (columns.size() > 0xFFu) { + return util::Error{5000, 0, + "FetchWhere: too many columns (max 255)", ""}; + } + if (where_expr.size() > 0xFFFFu) { + return util::Error{5000, 0, + "FetchWhere: predicate too long (max 65535)", ""}; + } + Frame req; + req.opcode = Opcode::FetchWhere; + write_u32_le(id, req.payload); + write_u32_le(max_rows, req.payload); + req.payload.push_back(flags); // new: flags byte at offset 8 + req.payload.push_back( + static_cast( where_expr.size() & 0xFFu)); + req.payload.push_back( + static_cast((where_expr.size() >> 8) & 0xFFu)); + req.payload.insert(req.payload.end(), + where_expr.begin(), where_expr.end()); + req.payload.push_back(static_cast(columns.size())); + for (auto& c : columns) { + if (c.size() > 0xFFu) { + return util::Error{5000, 0, + "FetchWhere: column name too long (max 255)", c}; + } + req.payload.push_back(static_cast(c.size())); + req.payload.insert(req.payload.end(), c.begin(), c.end()); + } + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::FetchWhereAck || + rep.value().payload.size() < 5) { + return util::Error{5000, 0, "FetchWhere: server error", ""}; + } + const auto& pl = rep.value().payload; + std::size_t p = 0; + std::uint32_t nrows = read_u32_le(pl.data() + p); p += 4; + std::uint8_t ncols = pl[p++]; + FetchWhereBatch batch; + batch.rows.reserve(nrows); + if (flags & FetchWhereFlags::WANT_RECNO) + batch.recnos.reserve(nrows); + for (std::uint32_t r = 0; r < nrows; ++r) { + // Per-row optional recno (emitted before column data). + if (flags & FetchWhereFlags::WANT_RECNO) { + if (p + 4 > pl.size()) { + return util::Error{5000, 0, + "FetchWhere: truncated payload (recno)", ""}; + } + std::uint32_t rn = read_u32_le(pl.data() + p); p += 4; + batch.recnos.push_back(rn); + } + std::vector row; + row.reserve(ncols); + for (std::uint8_t c = 0; c < ncols; ++c) { + if (p + 2 > pl.size()) { + return util::Error{5000, 0, + "FetchWhere: truncated payload (vlen)", ""}; + } + std::uint16_t vlen = static_cast( + static_cast(pl[p]) | + (static_cast(pl[p + 1]) << 8)); + p += 2; + if (p + vlen > pl.size()) { + return util::Error{5000, 0, + "FetchWhere: truncated payload (val)", ""}; + } + row.emplace_back(reinterpret_cast(pl.data() + p), + vlen); + p += vlen; + } + batch.rows.push_back(std::move(row)); + } + // Trailing [u8 eof] byte: 1 = the server walked to end of table. + if (p < pl.size()) { + batch.eof = (pl[p] != 0); + } + return batch; +} + +util::Result +RemoteConnection::aggregate(std::uint32_t id, + const std::string& for_expr, + const std::vector& specs) { + note_nav_frame(); + if (specs.size() > 0xFFu) + return util::Error{5000, 0, + "Aggregate: too many aggregates (max 255)", ""}; + if (for_expr.size() > 0xFFFFu) + return util::Error{5000, 0, + "Aggregate: predicate too long (max 65535)", ""}; + Frame req; + req.opcode = Opcode::Aggregate; + write_u32_le(id, req.payload); + req.payload.push_back( + static_cast( for_expr.size() & 0xFFu)); + req.payload.push_back( + static_cast((for_expr.size() >> 8) & 0xFFu)); + req.payload.insert(req.payload.end(), for_expr.begin(), for_expr.end()); + req.payload.push_back(static_cast(specs.size())); + for (const auto& s : specs) { + if (s.field.size() > 0xFFu) + return util::Error{5000, 0, + "Aggregate: field name too long (max 255)", s.field}; + req.payload.push_back(static_cast(s.fn)); + req.payload.push_back(static_cast(s.field.size())); + req.payload.insert(req.payload.end(), s.field.begin(), s.field.end()); + } + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::AggregateAck || + rep.value().payload.empty()) + return util::Error{5000, 0, "Aggregate: server error", ""}; + + const auto& pl = rep.value().payload; + std::size_t p = 0; + std::uint8_t n = pl[p++]; + AggregateBatch out; + out.values.reserve(n); + for (std::uint8_t i = 0; i < n; ++i) { + if (p + 3 > pl.size()) + return util::Error{5000, 0, + "Aggregate: truncated payload (header)", ""}; + std::uint8_t rt = pl[p++]; + std::uint16_t vlen = static_cast( + static_cast(pl[p]) | + (static_cast(pl[p + 1]) << 8)); + p += 2; + if (p + vlen > pl.size()) + return util::Error{5000, 0, + "Aggregate: truncated payload (value)", ""}; + engine::AggValue v; + v.type = static_cast(rt); + v.bytes.assign(reinterpret_cast(pl.data() + p), vlen); + p += vlen; + out.values.push_back(std::move(v)); + } + return out; +} + +util::Result +RemoteConnection::execute_sql(const std::string& sql) { + Frame req; + req.opcode = Opcode::ExecuteSQL; + req.payload.assign(sql.begin(), sql.end()); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::ExecuteSQLAck || + rep.value().payload.size() < 4) { + return util::Error{5000, 0, "ExecuteSQL: server error", + sql.substr(0, 200)}; + } + return read_u32_le(rep.value().payload.data()); +} + +// ===================================================================== +// M12.14 — remote field metadata + extended cursor state. +// ===================================================================== + +util::Result> +RemoteConnection::describe_table(std::uint32_t id) { + Frame req; + req.opcode = Opcode::DescribeTable; + write_u32_le(id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::DescribeTableAck) { + return util::Error{5000, 0, + "DescribeTable: server error", ""}; + } + const auto& pl = rep.value().payload; + if (pl.size() < 2) { + return util::Error{5000, 0, + "DescribeTable: short payload", ""}; + } + return parse_schema_body(pl, 0, pl.size()); +} + +util::Result RemoteConnection::at_bof(std::uint32_t id) { + auto r = bof_eof(id); + if (!r) return r.error(); + return r.value().bof; +} + +util::Result +RemoteConnection::get_record_num(std::uint32_t id) { + Frame req; + req.opcode = Opcode::GetRecordNum; + write_u32_le(id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::GetRecordNumAck || + rep.value().payload.size() < 4) { + return util::Error{5000, 0, + "GetRecordNum: server error", ""}; + } + return read_u32_le(rep.value().payload.data()); +} + +util::Result +RemoteConnection::is_record_deleted(std::uint32_t id) { + Frame req; + req.opcode = Opcode::IsRecordDeleted; + write_u32_le(id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::IsRecordDeletedAck || + rep.value().payload.empty()) { + return util::Error{5000, 0, + "IsRecordDeleted: server error", ""}; + } + return rep.value().payload[0] != 0; +} + +util::Result RemoteConnection::goto_bottom(std::uint32_t id) { + note_nav_frame(); + Frame req; + req.opcode = Opcode::GotoBottom; + write_u32_le(id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::GotoBottomAck) { + return util::Error{5000, 0, "GotoBottom: server error", ""}; + } + return {}; +} + +// ===================================================================== +// M12.15 — info / lock / maintenance / AOF. +// +// Pattern: every op carries a u32 server table id in the request +// payload; the ack carries the answer (bool / u16 / u32) or is +// empty for void. The server-side handlers in network/server.cpp +// match the same wire layout. +// ===================================================================== + +util::Result RemoteConnection::is_found(std::uint32_t id) { + Frame req; req.opcode = Opcode::IsFound; + write_u32_le(id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::IsFoundAck || + rep.value().payload.empty()) { + return util::Error{5000, 0, "IsFound: server error", ""}; + } + return rep.value().payload[0] != 0; +} + +util::Result RemoteConnection::refresh_record(std::uint32_t id) { + Frame req; req.opcode = Opcode::RefreshRecord; + write_u32_le(id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::RefreshRecordAck) { + return util::Error{5000, 0, "RefreshRecord: server error", ""}; + } + return {}; +} + +util::Result RemoteConnection::refresh_record(RemoteTable* rt) { + if (rt == nullptr) { + return util::Error{5000, 0, "RefreshRecord: null table", ""}; + } + // No note_nav_frame: a refresh re-reads the same position, it + // never moves the cursor (matches the id overload below). + Frame req; req.opcode = Opcode::RefreshRecord; + write_u32_le(rt->id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::RefreshRecordAck) { + return util::Error{5000, 0, "RefreshRecord: server error", ""}; + } + const std::size_t tend = + parse_row_trailer_into(rt, rep.value().payload, 0); + // Refreshed row + bounds + recno ride back (length-gated). + apply_bound_tail(rt, rep.value().payload, tend); + return {}; +} + +util::Result +RemoteConnection::get_table_type(std::uint32_t id) { + Frame req; req.opcode = Opcode::GetTableType; + write_u32_le(id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::GetTableTypeAck || + rep.value().payload.size() < 2) { + return util::Error{5000, 0, "GetTableType: server error", ""}; + } + return read_u16_le(rep.value().payload.data()); +} + +util::Result +RemoteConnection::get_record_length(std::uint32_t id) { + Frame req; req.opcode = Opcode::GetRecordLength; + write_u32_le(id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::GetRecordLengthAck || + rep.value().payload.size() < 4) { + return util::Error{5000, 0, "GetRecordLength: server error", ""}; + } + return read_u32_le(rep.value().payload.data()); +} + +util::Result +RemoteConnection::get_num_indexes(std::uint32_t id) { + Frame req; req.opcode = Opcode::GetNumIndexes; + write_u32_le(id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::GetNumIndexesAck || + rep.value().payload.size() < 2) { + return util::Error{5000, 0, "GetNumIndexes: server error", ""}; + } + return read_u16_le(rep.value().payload.data()); +} + +util::Result +RemoteConnection::get_last_autoinc(std::uint32_t id) { + Frame req; req.opcode = Opcode::GetLastAutoinc; + write_u32_le(id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::GetLastAutoincAck || + rep.value().payload.size() < 4) { + return util::Error{5000, 0, "GetLastAutoinc: server error", ""}; + } + return read_u32_le(rep.value().payload.data()); +} + +util::Result +RemoteConnection::get_last_table_update(std::uint32_t id) { + Frame req; req.opcode = Opcode::GetLastTableUpdate; + write_u32_le(id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::GetLastTableUpdateAck || + rep.value().payload.size() < 4) { + return util::Error{5000, 0, "GetLastTableUpdate: server error", ""}; + } + return read_u32_le(rep.value().payload.data()); +} + +util::Result RemoteConnection::lock_record(std::uint32_t id, + std::uint32_t recno) { + // The server answers a contended lock immediately (fail-fast). The + // retry lives HERE: one wire request per attempt, so the connection + // mutex is free between attempts and a peer thread's unlock/lock ops + // interleave instead of starving behind ours (Pritpal Bedi: + // "dbUnlock() in threads fail somehow"). + const auto policy = openads::abi::lock_retry_policy(); + // Budget = retry_count x cycle_ms (the plain ACE total-wait contract), + // but early attempts recheck after a few ms (adaptive backoff): a + // short contention — the common case — resolves in single-digit ms + // instead of one 100ms slice (700-instance B_BIG measured a full + // ~1.4s per contended RLock with the flat quantum). + const auto t0 = std::chrono::steady_clock::now(); + const auto deadline = + t0 + std::chrono::milliseconds(policy.budget_ms()); + for (std::uint32_t i = 0; ; ++i) { + Frame req; req.opcode = Opcode::LockRecord; + write_u32_le(id, req.payload); + write_u32_le(recno, req.payload); + auto rep = request(req); + if (rep && rep.value().opcode == Opcode::LockRecordAck) return {}; + // Only contention (AE_LOCKED) is retryable — the server fail-fast + // answers it via an Error frame, which request() maps to a failed + // Result. Anything else is a real error; return it at once. + const bool contended = + !rep && rep.error().code == + static_cast(openads::AE_LOCKED); + if (!contended) { + if (rep) { + return util::Error{5000, 0, "LockRecord: server error", ""}; + } + return rep.error(); + } + if (i >= policy.retry_count && + std::chrono::steady_clock::now() >= deadline) { + return rep.error(); + } + openads::abi::lock_retry_sleep(i); + } +} + +util::Result RemoteConnection::unlock_record(std::uint32_t id, + std::uint32_t recno) { + Frame req; req.opcode = Opcode::UnlockRecord; + write_u32_le(id, req.payload); + write_u32_le(recno, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::UnlockRecordAck) { + return util::Error{5000, 0, "UnlockRecord: server error", ""}; + } + return {}; +} + +util::Result RemoteConnection::is_record_locked( + std::uint32_t id, std::uint32_t recno) { + Frame req; req.opcode = Opcode::IsRecordLocked; + write_u32_le(id, req.payload); + write_u32_le(recno, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::IsRecordLockedAck || + rep.value().payload.size() < 2) { + return util::Error{5000, 0, "IsRecordLocked: server error", ""}; + } + return read_u16_le(rep.value().payload.data()); +} + +util::Result> RemoteConnection::get_all_locks( + std::uint32_t id) { + Frame req; req.opcode = Opcode::GetAllLocks; + write_u32_le(id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::GetAllLocksAck || + rep.value().payload.size() < 2) { + return util::Error{5000, 0, "GetAllLocks: server error", ""}; + } + const auto& pl = rep.value().payload; + std::uint16_t n = read_u16_le(pl.data()); + if (pl.size() < 2 + static_cast(n) * 4) { + return util::Error{5000, 0, "GetAllLocks: short payload", ""}; + } + std::vector recs; + recs.reserve(n); + for (std::uint16_t i = 0; i < n; ++i) { + recs.push_back(read_u32_le(pl.data() + 2 + i * 4)); + } + return recs; +} + +util::Result RemoteConnection::lock_table(std::uint32_t id) { + // Same client-side retry as lock_record (see there): budget = + // retry_count x cycle_ms with adaptive early backoff. + const auto policy = openads::abi::lock_retry_policy(); + const auto t0 = std::chrono::steady_clock::now(); + const auto deadline = + t0 + std::chrono::milliseconds(policy.budget_ms()); + for (std::uint32_t i = 0; ; ++i) { + Frame req; req.opcode = Opcode::LockTable; + write_u32_le(id, req.payload); + auto rep = request(req); + if (rep && rep.value().opcode == Opcode::LockTableAck) return {}; + const bool contended = + !rep && rep.error().code == + static_cast(openads::AE_LOCKED); + if (!contended) { + if (rep) { + return util::Error{5000, 0, "LockTable: server error", ""}; + } + return rep.error(); + } + if (i >= policy.retry_count && + std::chrono::steady_clock::now() >= deadline) { + return rep.error(); + } + openads::abi::lock_retry_sleep(i); + } +} + +util::Result RemoteConnection::unlock_table(std::uint32_t id) { + Frame req; req.opcode = Opcode::UnlockTable; + write_u32_le(id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::UnlockTableAck) { + return util::Error{5000, 0, "UnlockTable: server error", ""}; + } + return {}; +} + +util::Result RemoteConnection::pack_table(std::uint32_t id) { + note_nav_frame(); + Frame req; req.opcode = Opcode::PackTable; + write_u32_le(id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::PackTableAck) { + return util::Error{5000, 0, "PackTable: server error", ""}; + } + return {}; +} + +util::Result RemoteConnection::zap_table(std::uint32_t id) { + note_nav_frame(); + Frame req; req.opcode = Opcode::ZapTable; + write_u32_le(id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::ZapTableAck) { + return util::Error{5000, 0, "ZapTable: server error", ""}; + } + return {}; +} + +util::Result RemoteConnection::flush_file_buffers(std::uint32_t id) { + note_nav_frame(); + Frame req; req.opcode = Opcode::FlushFileBuffers; + write_u32_le(id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::FlushFileBuffersAck) { + return util::Error{5000, 0, "FlushFileBuffers: server error", ""}; + } + return {}; +} + +util::Result RemoteConnection::close_all_indexes(std::uint32_t id) { + Frame req; req.opcode = Opcode::CloseAllIndexes; + write_u32_le(id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::CloseAllIndexesAck) { + return util::Error{5000, 0, "CloseAllIndexes: server error", ""}; + } + return {}; +} + +util::Result RemoteConnection::set_aof(std::uint32_t id, + const std::string& cond) { + note_nav_frame(); + Frame req; req.opcode = Opcode::SetAOF; + write_u32_le(id, req.payload); + req.payload.insert(req.payload.end(), cond.begin(), cond.end()); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::SetAOFAck) { + return util::Error{5000, 0, "SetAOF: server error", + cond.substr(0, 200)}; + } + return {}; +} + +util::Result RemoteConnection::clear_aof(std::uint32_t id) { + note_nav_frame(); + Frame req; req.opcode = Opcode::ClearAOFRemote; + write_u32_le(id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::ClearAOFRemoteAck) { + return util::Error{5000, 0, "ClearAOF: server error", ""}; + } + return {}; +} + +util::Result RemoteConnection::customize_aof( + std::uint32_t id, std::uint16_t option, + const std::vector& recnos) { + note_nav_frame(); + if (recnos.size() > 0xFFFFu) { + return util::Error{5000, 0, "CustomizeAOF: too many records", ""}; + } + Frame req; + req.opcode = Opcode::CustomizeAOF; + write_u32_le(id, req.payload); + req.payload.push_back(static_cast(option & 0xFFu)); + auto n = static_cast(recnos.size()); + write_u16_le(n, req.payload); + for (auto r : recnos) + write_u32_le(r, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::CustomizeAOFAck) { + return util::Error{5000, 0, "CustomizeAOF: server error", ""}; + } + return {}; +} + +util::Result +RemoteConnection::get_aof_opt_level(std::uint32_t id) { + Frame req; req.opcode = Opcode::GetAOFOptLevel; + write_u32_le(id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::GetAOFOptLevelAck || + rep.value().payload.size() < 2) { + return util::Error{5000, 0, + "GetAOFOptLevel: server error", ""}; + } + return read_u16_le(rep.value().payload.data()); +} + +// ===================================================================== +// M12.16 — remote index handle subsystem. +// ===================================================================== + +util::Result> +RemoteConnection::open_index(std::uint32_t table_id, + const std::string& path) { + Frame req; req.opcode = Opcode::OpenIndex; + write_u32_le(table_id, req.payload); + req.payload.insert(req.payload.end(), path.begin(), path.end()); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::OpenIndexAck || + rep.value().payload.size() < 2) { + return util::Error{5000, 0, "OpenIndex: server error", path}; + } + const auto& pl = rep.value().payload; + std::uint16_t n = read_u16_le(pl.data()); + if (pl.size() < 2u + 4u * n) { + return util::Error{5000, 0, + "OpenIndex: short payload", path}; + } + std::vector out; + out.reserve(n); + const size_t legacy_end = 2u + 4u * n; + if (pl.size() == legacy_end) { + for (std::uint16_t i = 0; i < n; ++i) { + OpenIndexEntry e; + e.id = read_u32_le(pl.data() + 2 + 4u * i); + out.push_back(std::move(e)); + } + return out; + } + size_t off = 2; + // bag_path is appended after all tag entries by the server so + // AdsGetIndexFilename / OrdBagName can serve without a wire + // round-trip. Parse it after the tag loop. + std::string bag_path; + for (std::uint16_t i = 0; i < n; ++i) { + if (off + 6 > pl.size()) { + return util::Error{5000, 0, + "OpenIndex: short tag payload", path}; + } + OpenIndexEntry e; + e.id = read_u32_le(pl.data() + off); + off += 4; + std::uint16_t tlen = read_u16_le(pl.data() + off); + off += 2; + if (off + tlen > pl.size()) { + return util::Error{5000, 0, + "OpenIndex: truncated tag name", path}; + } + if (tlen > 0) { + e.tag.assign(reinterpret_cast(pl.data() + off), + tlen); + while (!e.tag.empty() && e.tag.back() == ' ') e.tag.pop_back(); + } + off += tlen; + out.push_back(std::move(e)); + } + // Parse trailing bag path (u16-prefixed) if present. Older servers + // don't emit it, so the payload may end here — that's fine. + if (off + 2 <= pl.size()) { + std::uint16_t blen = read_u16_le(pl.data() + off); + off += 2; + if (blen > 0 && off + blen <= pl.size()) { + bag_path.assign(reinterpret_cast(pl.data() + off), + blen); + } + // Advance past the bag_path bytes. Previously missing: harmless + // while bag_path was the last field, but it left `off` pointing + // mid-string for anything parsed afterward — e.g. the per-tag + // expression/unique/descending block below. + off += blen; + } + // Propagate the bag path to every entry so the caller can store it + // on each RemoteIndex without special-casing. + if (!bag_path.empty()) { + for (auto& e : out) e.bag_path = bag_path; + } + // Optional trailing per-tag metadata (expression, unique, descending), + // one triple per entry in the same order as the tag loop above. Added + // after bag_path so older servers that don't emit it still parse fine — + // the loop below simply finds no bytes left and leaves the defaults. + for (std::uint16_t i = 0; i < n; ++i) { + if (off + 2 > pl.size()) break; + std::uint16_t elen = read_u16_le(pl.data() + off); + off += 2; + if (off + elen > pl.size()) break; + if (elen > 0) { + out[i].expression.assign( + reinterpret_cast(pl.data() + off), elen); + } + off += elen; + if (off + 2 > pl.size()) break; + out[i].is_unique = pl[off] != 0; + out[i].is_descending = pl[off + 1] != 0; + off += 2; + } + return out; +} + +util::Result RemoteConnection::close_index(std::uint32_t index_id) { + Frame req; req.opcode = Opcode::CloseIndex; + write_u32_le(index_id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::CloseIndexAck) { + return util::Error{5000, 0, "CloseIndex: server error", ""}; + } + return {}; +} + +// ===================================================================== +// M12.29 — AdsDD* Data Dictionary property API, phase 1. +// ===================================================================== + +util::Result RemoteConnection::dd_get_property( + DDObjectKind kind, const std::string& name, const std::string& subName, + std::uint16_t propId) { + Frame req; req.opcode = Opcode::DDGetProperty; + req.payload.push_back(static_cast(kind)); + write_lstr16(name, req.payload); + write_lstr16(subName, req.payload); + write_u16_le(propId, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::DDGetPropertyAck || + rep.value().payload.size() < 4) { + return util::Error{5000, 0, "DDGetProperty: server error", name}; + } + const auto& pl = rep.value().payload; + std::uint32_t len = read_u32_le(pl.data()); + if (pl.size() < 4u + len) { + return util::Error{5000, 0, "DDGetProperty: short payload", name}; + } + return std::string(reinterpret_cast(pl.data() + 4), len); +} + +util::Result RemoteConnection::dd_set_property( + DDObjectKind kind, const std::string& name, const std::string& subName, + std::uint16_t propId, const std::string& value) { + Frame req; req.opcode = Opcode::DDSetProperty; + req.payload.push_back(static_cast(kind)); + write_lstr16(name, req.payload); + write_lstr16(subName, req.payload); + write_u16_le(propId, req.payload); + write_u32_le(static_cast(value.size()), req.payload); + req.payload.insert(req.payload.end(), value.begin(), value.end()); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::DDSetPropertyAck) { + return util::Error{5000, 0, "DDSetProperty: server error", name}; + } + return {}; +} + +util::Result RemoteConnection::dd_create_proc( + const std::string& name, const std::string& container, + const std::string& procName, const std::string& inParams, + const std::string& outParams, const std::string& comments) { + Frame req; req.opcode = Opcode::DDCreateProc; + write_lstr16(name, req.payload); + write_lstr16(container, req.payload); + write_lstr16(procName, req.payload); + write_lstr16(inParams, req.payload); + write_lstr16(outParams, req.payload); + write_lstr16(comments, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::DDCreateProcAck) { + return util::Error{5000, 0, "DDCreateProc: server error", name}; + } + return {}; +} + +util::Result RemoteConnection::dd_create_function( + const std::string& name, const std::string& container, + const std::string& implementation, const std::string& retType, + const std::string& inParams, const std::string& comment) { + Frame req; req.opcode = Opcode::DDCreateFunction; + write_lstr16(name, req.payload); + write_lstr16(container, req.payload); + write_lstr16(implementation, req.payload); + write_lstr16(retType, req.payload); + write_lstr16(inParams, req.payload); + write_lstr16(comment, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::DDCreateFunctionAck) { + return util::Error{5000, 0, "DDCreateFunction: server error", name}; + } + return {}; +} + +util::Result RemoteConnection::dd_create_trigger( + const std::string& name, const std::string& table, std::uint32_t type, + const std::string& container, const std::string& procedure, + std::uint32_t priority) { + Frame req; req.opcode = Opcode::DDCreateTrigger; + write_lstr16(name, req.payload); + write_lstr16(table, req.payload); + write_u32_le(type, req.payload); + write_lstr16(container, req.payload); + write_lstr16(procedure, req.payload); + write_u32_le(priority, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::DDCreateTriggerAck) { + return util::Error{5000, 0, "DDCreateTrigger: server error", name}; + } + return {}; +} + +util::Result RemoteConnection::dd_drop_trigger(const std::string& name) { + Frame req; req.opcode = Opcode::DDDropTrigger; + write_lstr16(name, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::DDDropTriggerAck) { + return util::Error{5000, 0, "DDDropTrigger: server error", name}; + } + return {}; +} + +util::Result RemoteConnection::dd_drop_view(const std::string& name) { + Frame req; req.opcode = Opcode::DDDropView; + write_lstr16(name, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::DDDropViewAck) { + return util::Error{5000, 0, "DDDropView: server error", name}; + } + return {}; +} + +util::Result RemoteConnection::dd_drop_link(const std::string& name) { + Frame req; req.opcode = Opcode::DDDropLink; + write_lstr16(name, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::DDDropLinkAck) { + return util::Error{5000, 0, "DDDropLink: server error", name}; + } + return {}; +} + +// ===================================================================== +// M12.30 — AdsDD* Data Dictionary property API, phase 2. +// ===================================================================== + +util::Result RemoteConnection::dd_create_user( + const std::string& group, const std::string& user, + const std::string& pwd, const std::string& desc) { + Frame req; req.opcode = Opcode::DDCreateUser; + write_lstr16(group, req.payload); + write_lstr16(user, req.payload); + write_lstr16(pwd, req.payload); + write_lstr16(desc, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::DDCreateUserAck) { + return util::Error{5000, 0, "DDCreateUser: server error", user}; + } + return {}; +} + +util::Result RemoteConnection::dd_drop_object( + DDObjectKind kind, const std::string& name) { + Frame req; req.opcode = Opcode::DDDropObject; + req.payload.push_back(static_cast(kind)); + write_lstr16(name, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::DDDropObjectAck) { + return util::Error{5000, 0, "DDDropObject: server error", name}; + } + return {}; +} + +util::Result RemoteConnection::dd_add_user_to_group( + const std::string& group, const std::string& user) { + Frame req; req.opcode = Opcode::DDAddUserToGroup; + write_lstr16(group, req.payload); + write_lstr16(user, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::DDAddUserToGroupAck) { + return util::Error{5000, 0, "DDAddUserToGroup: server error", user}; + } + return {}; +} + +util::Result RemoteConnection::dd_remove_user_from_group( + const std::string& group, const std::string& user) { + Frame req; req.opcode = Opcode::DDRemoveUserFromGroup; + write_lstr16(group, req.payload); + write_lstr16(user, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::DDRemoveUserFromGroupAck) { + return util::Error{5000, 0, "DDRemoveUserFromGroup: server error", user}; + } + return {}; +} + +util::Result RemoteConnection::dd_create_link( + const std::string& alias, const std::string& path, + const std::string& user, const std::string& pwd) { + Frame req; req.opcode = Opcode::DDCreateLink; + write_lstr16(alias, req.payload); + write_lstr16(path, req.payload); + write_lstr16(user, req.payload); + write_lstr16(pwd, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::DDCreateLinkAck) { + return util::Error{5000, 0, "DDCreateLink: server error", alias}; + } + return {}; +} + +util::Result RemoteConnection::dd_modify_link( + const std::string& alias, const std::string& path, + const std::string& user, const std::string& pwd) { + Frame req; req.opcode = Opcode::DDModifyLink; + write_lstr16(alias, req.payload); + write_lstr16(path, req.payload); + write_lstr16(user, req.payload); + write_lstr16(pwd, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::DDModifyLinkAck) { + return util::Error{5000, 0, "DDModifyLink: server error", alias}; + } + return {}; +} + +util::Result RemoteConnection::dd_create_ref_integrity( + const std::string& name, const std::string& failTable, + const std::string& parent, const std::string& parentTag, + const std::string& child, const std::string& childTag, + std::uint16_t updateRule, std::uint16_t deleteRule) { + Frame req; req.opcode = Opcode::DDCreateRefIntegrity; + write_lstr16(name, req.payload); + write_lstr16(failTable, req.payload); + write_lstr16(parent, req.payload); + write_lstr16(parentTag, req.payload); + write_lstr16(child, req.payload); + write_lstr16(childTag, req.payload); + write_u16_le(updateRule, req.payload); + write_u16_le(deleteRule, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::DDCreateRefIntegrityAck) { + return util::Error{5000, 0, "DDCreateRefIntegrity: server error", name}; + } + return {}; +} + +util::Result RemoteConnection::dd_create_view( + const std::string& name, const std::string& comments, + const std::string& sql) { + Frame req; req.opcode = Opcode::DDCreateView; + write_lstr16(name, req.payload); + write_lstr16(comments, req.payload); + write_u32_le(static_cast(sql.size()), req.payload); + req.payload.insert(req.payload.end(), sql.begin(), sql.end()); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::DDCreateViewAck) { + return util::Error{5000, 0, "DDCreateView: server error", name}; + } + return {}; +} + +util::Result RemoteConnection::dd_add_index_file( + const std::string& table, const std::string& index, + const std::string& comment) { + Frame req; req.opcode = Opcode::DDAddIndexFile; + write_lstr16(table, req.payload); + write_lstr16(index, req.payload); + write_lstr16(comment, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::DDAddIndexFileAck) { + return util::Error{5000, 0, "DDAddIndexFile: server error", table}; + } + return {}; +} + +util::Result RemoteConnection::dd_remove_index_file( + const std::string& table, const std::string& index) { + Frame req; req.opcode = Opcode::DDRemoveIndexFile; + write_lstr16(table, req.payload); + write_lstr16(index, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::DDRemoveIndexFileAck) { + return util::Error{5000, 0, "DDRemoveIndexFile: server error", table}; + } + return {}; +} + +util::Result RemoteConnection::dd_get_permissions( + const std::string& grantee, std::uint16_t objType, + const std::string& objName, bool getInherited) { + Frame req; req.opcode = Opcode::DDGetPermissions; + write_lstr16(grantee, req.payload); + write_u16_le(objType, req.payload); + write_lstr16(objName, req.payload); + req.payload.push_back(getInherited ? 1 : 0); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::DDGetPermissionsAck || + rep.value().payload.size() < 4) { + return util::Error{5000, 0, "DDGetPermissions: server error", objName}; + } + return read_u32_le(rep.value().payload.data()); +} + +util::Result RemoteConnection::dd_grant_permission( + std::uint16_t objType, const std::string& objName, + const std::string& grantee, std::uint32_t permissions) { + Frame req; req.opcode = Opcode::DDGrantPermission; + write_u16_le(objType, req.payload); + write_lstr16(objName, req.payload); + write_lstr16(grantee, req.payload); + write_u32_le(permissions, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::DDGrantPermissionAck) { + return util::Error{5000, 0, "DDGrantPermission: server error", objName}; + } + return {}; +} + +util::Result +RemoteConnection::set_order(std::uint32_t table_id, + std::uint32_t index_id) { + note_nav_frame(); + Frame req; req.opcode = Opcode::SetOrder; + write_u32_le(table_id, req.payload); + write_u32_le(index_id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::SetOrderAck) { + std::int32_t code = 5000; + if (rep.value().payload.size() >= 4) + code = static_cast( + read_u32_le(rep.value().payload.data())); + return util::Error{code, 0, "SetOrder: server error", ""}; + } + // Certified key count travels after the (empty) ack body, + // length-gated; old servers send nothing. + SetOrderOutcome o; + if (rep.value().payload.size() >= 4) { + o.counted = true; + o.key_count = read_u32_le(rep.value().payload.data()); + } + return o; +} + +util::Result +RemoteConnection::set_order_by_name(std::uint32_t table_id, + const std::string& tag) { + note_nav_frame(); + Frame req; req.opcode = Opcode::SetOrderByName; + write_u32_le(table_id, req.payload); + req.payload.insert(req.payload.end(), tag.begin(), tag.end()); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::SetOrderByNameAck) { + std::int32_t code = 5000; + if (rep.value().payload.size() >= 4) + code = static_cast( + read_u32_le(rep.value().payload.data())); + return util::Error{code, 0, "SetOrderByName: server error", tag}; + } + SetOrderOutcome o; + if (rep.value().payload.size() >= 4) { + o.counted = true; + o.key_count = read_u32_le(rep.value().payload.data()); + } + return o; +} + +namespace { + +// Push a u16-prefixed length + bytes string into a payload buffer. +inline void push_lp_str(std::vector& buf, + const std::string& s) { + auto n = static_cast(s.size()); + buf.push_back(static_cast( n & 0xFFu)); + buf.push_back(static_cast((n >> 8) & 0xFFu)); + buf.insert(buf.end(), s.begin(), s.end()); +} + +} // namespace + +util::Result +RemoteConnection::create_index(std::uint32_t table_id, + const std::string& path, + const std::string& tag, + const std::string& expr, + const std::string& cond, + const std::string& key_filter, + std::uint32_t options, + std::uint16_t page_size) { + Frame req; + req.opcode = Opcode::CreateIndex; + write_u32_le(table_id, req.payload); + write_u32_le(options, req.payload); + write_u16_le(page_size, req.payload); + push_lp_str(req.payload, path); + push_lp_str(req.payload, tag); + push_lp_str(req.payload, expr); + push_lp_str(req.payload, cond); + push_lp_str(req.payload, key_filter); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::CreateIndexAck || + rep.value().payload.size() < 4) { + return util::Error{5000, 0, "CreateIndex: server error", + tag.empty() ? path : tag}; + } + return read_u32_le(rep.value().payload.data()); +} + +util::Result +RemoteConnection::create_table(const std::string& name, + const std::string& fields, + std::uint16_t table_type, + std::uint16_t char_type, + std::uint16_t memo_block_size) { + Frame req; + req.opcode = Opcode::CreateTable; + write_u16_le(table_type, req.payload); + write_u16_le(char_type, req.payload); + write_u16_le(memo_block_size, req.payload); + push_lp_str(req.payload, name); + push_lp_str(req.payload, fields); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::CreateTableAck) { + return util::Error{5000, 0, "CreateTable: server error", + std::string(rep.value().payload.begin(), + rep.value().payload.end())}; + } + return {}; +} + +util::Result +RemoteConnection::drop_table(const std::string& name, + std::uint16_t delete_files) { + Frame req; + req.opcode = Opcode::DropTable; + push_lp_str(req.payload, name); + write_u16_le(delete_files, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::DropTableAck) { + return util::Error{5000, 0, "DropTable: server error", + std::string(rep.value().payload.begin(), + rep.value().payload.end())}; + } + return {}; +} + +namespace { + +util::Error fs_wire_err(const Frame& rep, const char* op) { + std::string msg(rep.payload.begin(), rep.payload.end()); + std::int32_t code = 5000; + if (rep.payload.size() >= 4) { + code = static_cast(read_u32_le(rep.payload.data())); + } + return util::Error{code, 0, std::string(op) + ": server error", msg}; +} + +std::uint64_t read_u64_le(const std::uint8_t* p) { + std::uint64_t v = 0; + for (int i = 0; i < 8; ++i) + v |= static_cast(p[i]) << (8 * i); + return v; +} + +// Existence-cache key: the server separator-normalizes client paths +// (fs_sandbox), so "/" and "\\" spellings of one file are the same +// file. Case is NOT folded: the server fs may be case-sensitive (Linux +// hosts), where "A.DBF" and "a.dbf" are legitimately different files. +std::string fs_cache_key(const std::string& path) { + std::string k = path; + for (auto& ch : k) { + if (ch == '/') ch = '\\'; + } + return k; +} + +} // namespace + +util::Result +RemoteConnection::file_exists(const std::string& path, int* src) { + if (src != nullptr) *src = 2; + const std::string key = fs_cache_key(path); + { + std::lock_guard lk(file_exists_mu_); + if (file_exists_cache_.count(key) != 0) { + if (src != nullptr) *src = 0; + return true; + } + if (file_exists_neg_cache_.count(key) != 0) { + if (src != nullptr) *src = 1; + return false; + } + } + Frame req; + req.opcode = Opcode::FileExists; + push_lp_str(req.payload, path); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::FileExistsAck || + rep.value().payload.empty()) + return fs_wire_err(rep.value(), "FileExists"); + bool exists = rep.value().payload[0] != 0; + { + std::lock_guard lk(file_exists_mu_); + if (exists) { + file_exists_cache_.insert(key); + file_exists_neg_cache_.erase(key); + } else { + file_exists_neg_cache_.insert(key); + } + } + return exists; +} + +void RemoteConnection::file_exists_invalidate() { + std::lock_guard lk(file_exists_mu_); + file_exists_cache_.clear(); + file_exists_neg_cache_.clear(); +} + +util::Result +RemoteConnection::file_erase(const std::string& path) { + Frame req; + req.opcode = Opcode::FileErase; + push_lp_str(req.payload, path); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::FileEraseAck) + return fs_wire_err(rep.value(), "FileErase"); + file_exists_invalidate(); + return {}; +} + +util::Result +RemoteConnection::file_rename(const std::string& old_p, + const std::string& new_p) { + Frame req; + req.opcode = Opcode::FileRename; + push_lp_str(req.payload, old_p); + push_lp_str(req.payload, new_p); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::FileRenameAck) + return fs_wire_err(rep.value(), "FileRename"); + file_exists_invalidate(); + return {}; +} + +util::Result +RemoteConnection::file_size(const std::string& path) { + Frame req; + req.opcode = Opcode::FileSize; + push_lp_str(req.payload, path); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::FileSizeAck || + rep.value().payload.size() < 8) + return fs_wire_err(rep.value(), "FileSize"); + return read_u64_le(rep.value().payload.data()); +} + +util::Result +RemoteConnection::file_mtime(const std::string& path) { + Frame req; + req.opcode = Opcode::FileMTime; + push_lp_str(req.payload, path); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::FileMTimeAck || + rep.value().payload.size() < 7) + return fs_wire_err(rep.value(), "FileMTime"); + openads::engine::DirEntry e; + const auto& pl = rep.value().payload; + e.year = static_cast(pl[0] | (pl[1] << 8)); + e.mon = pl[2]; e.day = pl[3]; + e.hh = pl[4]; e.mm = pl[5]; e.ss = pl[6]; + return e; +} + +util::Result> +RemoteConnection::directory(const std::string& mask) { + Frame req; + req.opcode = Opcode::Directory; + push_lp_str(req.payload, mask); + write_u16_le(0, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::DirectoryAck || + rep.value().payload.size() < 4) + return fs_wire_err(rep.value(), "Directory"); + std::size_t off = 0; + std::uint32_t n = read_u32_le(rep.value().payload.data()); + off = 4; + // Guard against corrupt/huge counts (avoids std::length_error). + if (n > 100000u) + return util::Error{5000, 0, "Directory: count too large", mask}; + std::vector out; + out.reserve(n); + for (std::uint32_t i = 0; i < n; ++i) { + openads::engine::DirEntry e; + if (!openads::engine::unpack_dir_entry(rep.value().payload, off, e)) + return util::Error{5000, 0, "Directory: bad entry", mask}; + out.push_back(std::move(e)); + } + return out; +} + +util::Result> +RemoteConnection::find_tables(const std::string& mask) { + Frame req; + req.opcode = Opcode::FindTables; + push_lp_str(req.payload, mask); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::FindTablesAck || + rep.value().payload.size() < 4) + return fs_wire_err(rep.value(), "FindTables"); + std::size_t off = 0; + std::uint32_t n = read_u32_le(rep.value().payload.data()); + off = 4; + if (n > 100000u) + return util::Error{5000, 0, "FindTables: count too large", mask}; + std::vector out; + out.reserve(n); + for (std::uint32_t i = 0; i < n; ++i) { + std::string name; + if (!read_lstr16(rep.value().payload, off, name)) + return util::Error{5000, 0, "FindTables: bad entry", mask}; + out.push_back(std::move(name)); + } + return out; +} + +namespace { + +// u64 LE + u32-length blob + 0x1F-joined lists for the archive ops. +inline void push_lstr32(const std::string& s, + std::vector& out) { + write_u32_le(static_cast(s.size()), out); + out.insert(out.end(), s.begin(), s.end()); +} + +inline std::string join_0x1f(const std::vector& v) { + std::string o; + for (std::size_t i = 0; i < v.size(); ++i) { + if (i != 0) o.push_back('\x1F'); + o += v[i]; + } + return o; +} + +inline std::uint64_t read_u64_at(const std::vector& pl, + std::size_t off) { + std::uint64_t v = 0; + for (int i = 0; i < 8; ++i) + v |= static_cast( + pl[off + static_cast(i)]) + << (8 * i); + return v; +} + +} // namespace + +util::Result +RemoteConnection::zip_archive(const std::string& dir, + const std::vector& files, + const std::string& zip_name, + std::uint16_t level, bool overwrite, + const std::string& password, + const std::vector& exclude, + bool with_path) { + Frame req; + req.opcode = Opcode::ZipArchive; + push_lp_str(req.payload, dir); + push_lstr32(join_0x1f(files), req.payload); + push_lp_str(req.payload, zip_name); + write_u16_le(level, req.payload); + req.payload.push_back(overwrite ? 1 : 0); + req.payload.push_back(with_path ? 1 : 0); + push_lstr32(join_0x1f(exclude), req.payload); + push_lp_str(req.payload, password); + auto rep = request(req); + if (!rep) return rep.error(); + // [u32 files][u64 bytes][u64 archiveBytes][u16 arcLen][archiveRel] + if (rep.value().opcode != Opcode::ZipArchiveAck || + rep.value().payload.size() < 22) + return fs_wire_err(rep.value(), "ZipArchive"); + const auto& pl = rep.value().payload; + ZipArchiveOutcome o; + o.files = read_u32_le(pl.data()); + o.bytes = read_u64_at(pl, 4); + o.archive_bytes = read_u64_at(pl, 12); + std::size_t off = 20; + if (!read_lstr16(pl, off, o.archive)) + return fs_wire_err(rep.value(), "ZipArchive"); + return o; +} + +util::Result +RemoteConnection::unzip_archive(const std::string& dir, + const std::string& zip, + const std::string& password, + bool overwrite, bool with_path) { + Frame req; + req.opcode = Opcode::UnzipArchive; + push_lp_str(req.payload, dir); + push_lp_str(req.payload, zip); + push_lp_str(req.payload, password); + req.payload.push_back(overwrite ? 1 : 0); + req.payload.push_back(with_path ? 1 : 0); + auto rep = request(req); + if (!rep) return rep.error(); + // [u32 files][u64 bytes][u64 archiveBytes] + if (rep.value().opcode != Opcode::UnzipArchiveAck || + rep.value().payload.size() < 20) + return fs_wire_err(rep.value(), "UnzipArchive"); + const auto& pl = rep.value().payload; + UnzipArchiveOutcome o; + o.files = read_u32_le(pl.data()); + o.bytes = read_u64_at(pl, 4); + o.archive_bytes = read_u64_at(pl, 12); + return o; +} + +util::Result +RemoteConnection::zip_list(const std::string& zip) { + Frame req; + req.opcode = Opcode::ZipList; + push_lp_str(req.payload, zip); + auto rep = request(req); + if (!rep) return rep.error(); + // [u32 count][packed ZipEntry records] + if (rep.value().opcode != Opcode::ZipListAck || + rep.value().payload.size() < 4) + return fs_wire_err(rep.value(), "ZipList"); + const auto& pl = rep.value().payload; + const std::uint32_t count = read_u32_le(pl.data()); + if (count > (1u << 20)) + return fs_wire_err(rep.value(), "ZipList"); + ZipListOutcome o; + o.entries.reserve(count); + std::size_t off = 4; + for (std::uint32_t i = 0; i < count; ++i) { + openads::engine::zip_arch::ZipEntry e; + if (!openads::engine::zip_arch::unpack_zip_entry(pl, off, e)) + return fs_wire_err(rep.value(), "ZipList"); + o.entries.push_back(std::move(e)); + } + if (off != pl.size()) + return fs_wire_err(rep.value(), "ZipList"); + return o; +} + +util::Result +RemoteConnection::dir_exist(const std::string& path) { + const std::string key = fs_cache_key(path); + { + std::lock_guard lk(dir_cache_mu_); + if (dir_known_.count(key) != 0) return true; + if (dir_missing_.count(key) != 0) return false; + } + Frame req; + req.opcode = Opcode::DirExist; + push_lp_str(req.payload, path); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::DirExistAck || + rep.value().payload.empty()) + return fs_wire_err(rep.value(), "DirExist"); + const bool exists = rep.value().payload[0] != 0; + { + std::lock_guard lk(dir_cache_mu_); + if (exists) { + dir_known_.insert(key); + dir_missing_.erase(key); + } else { + dir_missing_.insert(key); + } + } + return exists; +} + +util::Result +RemoteConnection::dir_make(const std::string& path) { + const std::string key = fs_cache_key(path); + { + std::lock_guard lk(dir_cache_mu_); + if (dir_known_.count(key) != 0) return {}; + } + Frame req; + req.opcode = Opcode::DirMake; + push_lp_str(req.payload, path); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::DirMakeAck) + return fs_wire_err(rep.value(), "DirMake"); + std::lock_guard lk(dir_cache_mu_); + dir_known_.insert(key); + dir_missing_.erase(key); + return {}; +} + +util::Result +RemoteConnection::dir_remove(const std::string& path) { + Frame req; + req.opcode = Opcode::DirRemove; + push_lp_str(req.payload, path); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::DirRemoveAck) + return fs_wire_err(rep.value(), "DirRemove"); + std::lock_guard lk(dir_cache_mu_); + const std::string rkey = fs_cache_key(path); + dir_known_.erase(rkey); + dir_missing_.insert(rkey); + return {}; +} + +util::Result +RemoteConnection::fopen(const std::string& path, std::uint16_t mode) { + Frame req; + req.opcode = Opcode::FOpen; + push_lp_str(req.payload, path); + write_u16_le(mode, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::FOpenAck || + rep.value().payload.size() < 4) + return fs_wire_err(rep.value(), "FOpen"); + return read_u32_le(rep.value().payload.data()); +} + +util::Result +RemoteConnection::fcreate(const std::string& path, std::uint16_t attr) { + Frame req; + req.opcode = Opcode::FCreate; + push_lp_str(req.payload, path); + write_u16_le(attr, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::FCreateAck || + rep.value().payload.size() < 4) + return fs_wire_err(rep.value(), "FCreate"); + return read_u32_le(rep.value().payload.data()); +} + +util::Result +RemoteConnection::fclose(std::uint32_t file_id) { + Frame req; + req.opcode = Opcode::FClose; + write_u32_le(file_id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::FCloseAck) + return fs_wire_err(rep.value(), "FClose"); + return {}; +} + +util::Result> +RemoteConnection::fread(std::uint32_t file_id, std::uint32_t nbytes) { + Frame req; + req.opcode = Opcode::FRead; + write_u32_le(file_id, req.payload); + write_u32_le(nbytes, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::FReadAck || + rep.value().payload.size() < 4) + return fs_wire_err(rep.value(), "FRead"); + std::uint32_t n = read_u32_le(rep.value().payload.data()); + if (4u + n > rep.value().payload.size()) + return util::Error{5000, 0, "FRead: short data", ""}; + return std::vector( + rep.value().payload.begin() + 4, + rep.value().payload.begin() + 4 + static_cast(n)); +} + +util::Result +RemoteConnection::fwrite(std::uint32_t file_id, const std::uint8_t* data, + std::uint32_t n) { + Frame req; + req.opcode = Opcode::FWrite; + write_u32_le(file_id, req.payload); + write_u32_le(n, req.payload); + if (data && n) + req.payload.insert(req.payload.end(), data, data + n); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::FWriteAck || + rep.value().payload.size() < 4) + return fs_wire_err(rep.value(), "FWrite"); + return read_u32_le(rep.value().payload.data()); +} + +util::Result +RemoteConnection::fseek(std::uint32_t file_id, std::int32_t offset, + std::uint8_t origin) { + Frame req; + req.opcode = Opcode::FSeek; + write_u32_le(file_id, req.payload); + write_u32_le(static_cast(offset), req.payload); + req.payload.push_back(origin); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::FSeekAck || + rep.value().payload.size() < 4) + return fs_wire_err(rep.value(), "FSeek"); + return read_u32_le(rep.value().payload.data()); +} + +util::Result +RemoteConnection::skip_unique(std::uint32_t index_id, + std::int32_t direction) { + note_nav_frame(); + note_all_tables_nav(); // index-keyed, no parent resolved here + Frame req; req.opcode = Opcode::SkipUnique; + write_u32_le(index_id, req.payload); + write_u32_le(static_cast(direction), req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::SkipUniqueAck) { + return util::Error{5000, 0, "SkipUnique: server error", ""}; + } + return {}; +} + +util::Result +RemoteConnection::set_scope(std::uint32_t index_id, + std::uint16_t which, + const std::string& key, + std::uint16_t data_type) { + note_nav_frame(); + note_all_tables_nav(); // index-keyed visibility change + // Payload: u32 index_id | u16 which | u16 data_type | bytes key. + // Key length is the trailing byte count (payload.size() - 8). + Frame req; req.opcode = Opcode::SetScope; + write_u32_le(index_id, req.payload); + write_u16_le(which, req.payload); + write_u16_le(data_type, req.payload); + req.payload.insert(req.payload.end(), key.begin(), key.end()); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::SetScopeAck) { + return util::Error{5000, 0, "SetScope: server error", key}; + } + return {}; +} + +util::Result +RemoteConnection::fetch_current_row(std::uint32_t table_id) { + // Compatibility wrapper for callers that don't carry a + // RemoteTable: fetch + extract just the visible-row vector. The + // M12.18 wire format is parsed via the same shared helper as + // the rt-aware overload below. + Frame req; req.opcode = Opcode::FetchCurrentRow; + write_u32_le(table_id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::FetchCurrentRowAck || + rep.value().payload.empty()) { + return util::Error{5000, 0, + "FetchCurrentRow: server error", ""}; + } + RemoteTable scratch; + scratch.conn = this; + scratch.id = table_id; + parse_row_trailer_into(&scratch, rep.value().payload, 0); + RowSnapshot snap; + snap.has_row = scratch.row_valid; + snap.fields = std::move(scratch.current_row); + return snap; +} + +util::Result +RemoteConnection::fetch_current_row(RemoteTable* rt) { + Frame req; req.opcode = Opcode::FetchCurrentRow; + write_u32_le(rt->id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::FetchCurrentRowAck || + rep.value().payload.empty()) { + return util::Error{5000, 0, + "FetchCurrentRow: server error", ""}; + } + parse_row_trailer_into(rt, rep.value().payload, 0); + return {}; +} + +void RemoteConnection::show_deleted(bool visible) noexcept { + note_nav_frame(); + note_all_tables_nav(); // conn-wide visibility change + if (!transport_ || !transport_->valid()) return; + Frame req; + req.opcode = Opcode::ShowDeleted; + req.payload.push_back(visible ? 1 : 0); + // Best-effort: pre-M12.31 servers lack this opcode; local SET + // DELETED must still succeed on the client either way. + (void)request(req); +} + +util::Result +RemoteConnection::clear_scope(std::uint32_t index_id, + std::uint16_t which) { + note_nav_frame(); + note_all_tables_nav(); // index-keyed visibility change + Frame req; req.opcode = Opcode::ClearScope; + write_u32_le(index_id, req.payload); + write_u16_le(which, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::ClearScopeAck) { + return util::Error{5000, 0, "ClearScope: server error", ""}; + } + return {}; +} + +util::Result +RemoteConnection::seek(std::uint32_t index_id, + const std::string& key, + std::uint8_t soft, + std::uint8_t last, + RemoteTable* parent) { + note_nav_frame(); + // Index-keyed op: request() cannot map it to a table, so the + // binding the caller resolved bumps the per-table generation here. + if (parent != nullptr) note_tbl_nav(parent->id); + Frame req; + req.opcode = last ? Opcode::SeekLast : Opcode::Seek; + write_u32_le(index_id, req.payload); + req.payload.push_back(soft); + req.payload.insert(req.payload.end(), key.begin(), key.end()); + auto rep = request(req); + if (!rep) return rep.error(); + Opcode want = last ? Opcode::SeekLastAck : Opcode::SeekAck; + if (rep.value().opcode != want || + rep.value().payload.size() < 5) { + return util::Error{5000, 0, "Seek: server error", key}; + } + SeekOutcome o; + o.hit = rep.value().payload[0]; + o.recno = read_u32_le(rep.value().payload.data() + 1); + // RCB 07/14/2026: M12.24 — a current-M12.24 server appends the row it + // landed on after the [u8 found][u32 recno] pair, so the caller does not + // have to spend a second round-trip fetching it. Strictly optional: an + // older server sends exactly 5 bytes, we parse no trailer, row_valid stays + // false, and the old FetchCurrentRow fallback takes over unchanged. + if (parent != nullptr && rep.value().payload.size() > 5) { + const std::size_t tend = parse_row_trailer_into( + parent, rep.value().payload, 5); + // Reposition-bound piggyback (see goto_record): trailing + // [u8 bof][u8 eof][u32 recno](+[u32 reccount]), length-gated. + apply_bound_tail(parent, rep.value().payload, tend); + } + return o; +} + +// M12.35 — query the server for the key expression result type of a +// remote index. Returns ADS_STRING (4), ADS_DATE (3), ADS_NUMERIC (2), +// or ADS_LOGICAL (1) — the same values as the local AdsGetKeyType. + +util::Result +RemoteConnection::get_key_type(std::uint32_t index_id) { + Frame req; + req.opcode = Opcode::GetKeyType; + write_u32_le(index_id, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::GetKeyTypeAck || + rep.value().payload.size() < 2) { + return util::Error{5000, 0, "GetKeyType: server error", ""}; + } + return read_u16_le(rep.value().payload.data()); +} + +// M12.32 — distributed mutex service. + +util::Result RemoteConnection::mutex_create(const std::string& name) { + Frame req; + req.opcode = Opcode::Mutex; + req.payload.push_back(static_cast(MutexOp::Create)); + write_lstr16(name, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::Mutex || + rep.value().payload.size() < 2 || + rep.value().payload[0] != static_cast(MutexOp::Create)) + return util::Error{5000, 0, "MutexCreate: server error", ""}; + if (!rep.value().payload[1]) + return util::Error{5000, 0, "MutexCreate: failed (exists?)", name}; + return util::Result{}; +} + +util::Result RemoteConnection::mutex_lock(const std::string& name, + std::uint32_t timeout_ms) { + Frame req; + req.opcode = Opcode::Mutex; + req.payload.push_back(static_cast(MutexOp::Lock)); + write_lstr16(name, req.payload); + write_u32_le(timeout_ms, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::Mutex || + rep.value().payload.size() < 2 || + rep.value().payload[0] != static_cast(MutexOp::Lock)) + return util::Error{5000, 0, "MutexLock: server error", ""}; + if (!rep.value().payload[1]) + return util::Error{5000, 0, "MutexLock: timeout or not found", name}; + return util::Result{}; +} + +util::Result RemoteConnection::mutex_try_lock(const std::string& name) { + Frame req; + req.opcode = Opcode::Mutex; + req.payload.push_back(static_cast(MutexOp::TryLock)); + write_lstr16(name, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::Mutex || + rep.value().payload.size() < 2 || + rep.value().payload[0] != static_cast(MutexOp::TryLock)) + return util::Error{5000, 0, "MutexTryLock: server error", ""}; + return rep.value().payload[1] != 0; +} + +util::Result RemoteConnection::mutex_unlock(const std::string& name) { + Frame req; + req.opcode = Opcode::Mutex; + req.payload.push_back(static_cast(MutexOp::Unlock)); + write_lstr16(name, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::Mutex || + rep.value().payload.size() < 2 || + rep.value().payload[0] != static_cast(MutexOp::Unlock)) + return util::Error{5000, 0, "MutexUnlock: server error", ""}; + if (!rep.value().payload[1]) + return util::Error{5000, 0, "MutexUnlock: not owner or not found", name}; + return util::Result{}; +} + +util::Result RemoteConnection::mutex_destroy(const std::string& name) { + Frame req; + req.opcode = Opcode::Mutex; + req.payload.push_back(static_cast(MutexOp::Destroy)); + write_lstr16(name, req.payload); + auto rep = request(req); + if (!rep) return rep.error(); + if (rep.value().opcode != Opcode::Mutex || + rep.value().payload.size() < 2 || + rep.value().payload[0] != static_cast(MutexOp::Destroy)) + return util::Error{5000, 0, "MutexDestroy: server error", ""}; + if (!rep.value().payload[1]) + return util::Error{5000, 0, "MutexDestroy: not owner or not found", name}; + return util::Result{}; +} + +} // namespace openads::network diff --git a/src/network/4-session.h b/src/network/4-session.h new file mode 100644 index 00000000..61271fc4 --- /dev/null +++ b/src/network/4-session.h @@ -0,0 +1,277 @@ +#pragma once + +#include "engine/server_fs.h" +#include "network/frame_reader.h" +#include "network/server.h" +#include "network/socket.h" +#include "network/wire.h" +#include "openads/ace.h" +#include "session/connection.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace openads::engine { class Table; } + +namespace openads::network { + +// Result of dispatching one wire frame. `reply == std::nullopt` means +// "send nothing back" (used by Disconnect); `close_session` asks the +// driving loop to stop reading and tear the connection down. +struct DispatchResult { + std::optional reply; // std::nullopt => send nothing + bool close_session = false; +}; + +// SLICE 3a — all per-connection state and the opcode dispatch switch +// extracted verbatim out of Server::session_loop. The existing +// thread-per-connection loop constructs a Session per accepted socket +// and drives it through dispatch(). Zero behavior change. +class Session { +public: + Session(Server& srv, Socket s, std::string default_data_dir, + std::uint16_t listener_port); // computes peer addr, register_session, install_session_socket + ~Session(); // cleanup(); srv_->erase_session_socket(sid_); srv_->unregister_session(sid_); + Session(const Session&) = delete; + Session& operator=(const Session&) = delete; + + DispatchResult dispatch(const Frame& f); + std::uint64_t id() const noexcept { return sid_; } + + // Session serial for mutex owner identification (M12.32). + std::string conn_serial() const { return std::to_string(sid_); } + + // Read one frame off this connection, dispatch it, write any reply, + // and fold in the per-frame telemetry. Returns false when the + // connection should be torn down (peer closed, write failed, or the + // opcode asked to close). Drives both the legacy thread-per-connection + // loop and the reactor WorkerPool, so the per-frame contract lives in + // exactly one place. + bool handle_readable(); + + // Accessor for the reactor: the connection socket this Session owns. + Socket socket() const noexcept { return s_; } + +private: + // Telemetry + dispatch + reply for one complete frame. Shared by the + // blocking thread-per-connection loop and the non-blocking reactor path. + // Returns false when the connection should be torn down. + bool process_frame(const Frame& f); + + Server* srv_; + Socket s_; + std::uint64_t sid_; + // Cached "ip:port" of the remote peer for the text error log + // (resolved once at accept; getpeername per frame would be a + // syscall on every request). + std::string peer_str_; + // Reassembles complete frames from partial non-blocking reads (reactor + // path). Harmless on the blocking path — each read yields a whole frame. + FrameReader reader_; + // Default data directory for this connection, determined by which TCP + // port the client connected to. Empty means use the server's global + // data_dir_ (primary listener). + std::string default_data_dir_; + + // M12.4 — per-session state. Connection is opened by the + // Connect frame; OpenTable allocates a session-scoped 32-bit + // table id keyed into engine handles. + std::unique_ptr sess_conn_; + std::unordered_map tbls_; + // Original OpenTable payload (DD alias or relative path). ensure_abi_handle + // must reopen the same physical file — basename-only breaks subdir tables. + std::unordered_map tbl_open_paths_; + // mtfix11 - Server::try_register_open bookkeeping per wire table id: + // (engine-resolved canonical path, exclusive flag) as registered at + // OpenTable; consumed at CloseTable / teardown for unregister_open. + std::unordered_map> + tbl_open_reg_; + std::unordered_map cursor_tbls_; + // M12.16 — lazy-promoted ABI handle parallel to tbls_. + std::unordered_map tbls_h_; + // M12.16 — index_h_[index_id] holds the ABI hIndex. + std::unordered_map index_h_; + // M12.16 — reverse map index_id -> table_id. + std::unordered_map index_table_; + // Wire index_id -> tag name. Lets the SetOrder handler re-resolve a + // stale id: server-side AdsCreateIndex61's silent overwrite and + // AdsCloseAllIndexes' non-structural purge erase ABI bindings and mint + // fresh handles, but the session's index_h_ keeps the dead one -- the + // next ordered nav then fails with 5000 "index not bound to table" + // (B_BIG storm 700). With the tag cached, the handler looks up the + // binding's CURRENT handle (AdsGetIndexHandle) and retries. + std::unordered_map index_tag_; + // Table ids with an active controlling order. Index ops set the order + // on the ABI handle (tbls_h_), not the engine table, so when one is + // active GotoTop / GotoBottom / Skip must navigate the ABI handle and + // sync the engine cursor — otherwise they walk natural order and the + // remote browse shows no index. + std::unordered_set ordered_tables_; + ADSHANDLE abi_conn_ = 0; + ADSHANDLE abi_stmt_ = 0; + std::uint32_t next_id_ = 1; + // RCB 06/30/2026: Remote sessions keep DD credentials so lazy server-side + // ABI handles used by SQL/index operations authenticate the same user. + std::string session_user_; + std::string session_password_; + // M12.21 option C — set from the Connect payload's capability word. + bool client_prefetch_ok_ = false; + // RCB 07/15/2026: M12.25 — client can also drain a BACKWARD lookahead block + // (kCapPrefetchBackward). Gated separately: a forward-only client that got a + // backward block would pop its rows the wrong way. See the Skip handler. + bool client_prefetch_back_ok_ = false; + // M12.x — client sends [u16 mode] prefix on OpenTable payloads. + bool client_open_table_mode_ok_ = false; + // M12.32 — last ShowDeleted state received; abi_conn_ is created + // lazily, so ensure_abi_conn must re-apply it or the ABI connection + // starts with the default (show) and ordered walks leak deleted rows. + bool show_deleted_ = true; + + // Server filesystem (oads_*) — open files for this session only. + std::unordered_map> files_; + std::uint32_t next_file_id_ = 1; + + // Resolve client path under session data dir (or server data roots). + std::optional resolve_fs_client_path( + const std::string& client_path) const; + + // Resolve a remote CreateIndex bag path server-side (see session.cpp). + std::string resolve_index_bag_path(const std::string& bag) const; + + // ---- M12.22 read-ahead ramp ------------------------------------------- + // RCB 07/14/2026: why sequential-access detection lives on the SERVER and + // not the client. First, it is where OS and DB read-ahead normally put it + // (Linux readahead, SQL Server read-ahead): the provider watches the access + // pattern instead of making the caller declare it, because callers are bad + // at declaring it. Second — and this is the part specific to us — it is the + // only place that CAN see it. The client serves most of a block locally + // without telling anyone, so the server sees roughly one Skip per block, + // and every such Skip is direct evidence that the client drained the last + // one. The signal is free and it is exactly the signal we want. + // + // Depth doubles per consecutive forward Skip on a table, floor -> ceiling. + // Any reposition or write on that table (see breaks_prefetch_run() in + // session.cpp) drops the entry, so the next run restarts at the floor. + // + // The ramp is not decoration. Before it, EVERY forward Skip dragged a flat + // 64 rows — including the lone Skip after a Seek, i.e. "find one customer + // and read him", which is a very common shape and paid for 63 rows it would + // never look at. + static constexpr std::uint16_t kPrefetchFloor = 8; + static constexpr std::uint16_t kPrefetchCeil = 64; + // RCB 07/14/2026: a row count alone is NOT a bound — 64 rows of a table + // with 4 KB records is a 256 KB frame, and rows are packed with every field + // (no projection on the nav path). So bound the block by bytes as well and + // let whichever limit hits first win. SAP states the same two-sided rule for + // its own client cache: "the lesser of 10 records or the number of records + // that can fit in a burst of packets ... about 22K when using IP" + // (ace_adscacherecords.htm). + static constexpr std::size_t kPrefetchMaxBytes = 32u * 1024u; + std::unordered_map prefetch_depth_; + // RCB 07/15/2026: M12.25 — the direction (+1/-1) the current ramp run for a + // table is going. A reversal restarts the ramp at the floor (see + // next_lookahead), so a PgUp right after a long PgDn doesn't inherit the + // forward run's ceiling depth. + std::unordered_map prefetch_run_dir_; + + // Depth to use for one Skip on `id` in direction `dir` (+1 fwd, -1 back). + // `hint` is the client's AdsCacheRecords value, or kPrefetchDepthAuto to let + // the ramp decide. Returns 0 when the client never advertised + // kCapPrefetchConsume. + std::uint16_t next_lookahead(std::uint32_t id, + std::uint16_t hint = kPrefetchDepthAuto, + std::int8_t dir = 1); + // Break the sequential run for `id` (reposition / write / order change). + void reset_lookahead(std::uint32_t id); + + // ---- ABI schema cache -------------------------------------------------- + // RCB 07/14/2026: pack_one_row_abi resolved every field's NAME and TYPE + // from the ABI on every single row (AdsGetFieldName + AdsGetFieldType + + // AdsGetField, per column). At one row per ack nobody would ever notice. + // But the whole point of this change is to make that function pack a 64-row + // block, which turns it into ~3 ABI calls x columns x 64 rows on every + // Skip — i.e. the read-ahead work would have partly eaten the round-trips + // it saves. The schema of an OPEN handle cannot change, so resolve it once + // per handle and hold it. Invalidated on CloseTable, because AdsCloseTable + // frees the ADSHANDLE and a later open can be handed the same value back. + struct AbiField { + std::vector name; // NUL-terminated, for AdsGetField + bool is_memo = false; + }; + std::unordered_map> abi_schema_; + const std::vector& abi_schema_for(ADSHANDLE h_abi); + + // Moved helpers (were [&] lambdas in session_loop) -> private + // methods; bodies unchanged except member renames. + void cleanup(); + bool ensure_abi_conn(); + ADSHANDLE ensure_abi_handle(std::uint32_t id); + bool pack_one_row_engine(std::vector& dst, + openads::engine::Table* tbl); + bool pack_one_row_abi(std::vector& dst, + ADSHANDLE h_abi); + void pack_row_trailer(Frame& reply, std::uint32_t id, + std::uint16_t lookahead_n = 0, + std::int8_t dir = 1); + // Reposition-bound piggyback: after an explicit reposition + // (GotoRecord/Seek) the server knows BOF/EOF/recno exactly, so it + // appends [u8 bof][u8 eof][u32 recno] AFTER the row trailer. The + // client serves the poll burst that always follows a reposition + // (AtBOF/AtEOF/RecNo per paint row) locally. Trailing section, + // length-gated: old clients ignore the tail (same convention as + // the M12.24 row trailer and the twin flag), so no caps bit. + // No Limbo rescue here: a rescue would MOVE the cursor during + // what must stay a pure read. A freshly repositioned twin is not + // in limbo; both-true genuinely means an empty cursor. + void pack_bound_trailer(Frame& reply, std::uint32_t id); + // mtfix12 R1 (kCapNavBoundaryPair): append the OPPOSITE boundary's + // landing state (row blob at lookahead depth 0, bound values, + // scoped key count for ordered tables) after the requested + // boundary's own sections. `which` is the boundary just navigated + // (1 = top, 2 = bottom); the pair certifies the other end. The + // cursor is restored exactly before returning; on any failure + // nothing is appended (the client length-gates and falls back to + // a plain second frame). Read-only wrt caller-visible state. + void pack_boundary_pair(Frame& reply, std::uint32_t id, + int which, ADSHANDLE hord, + openads::engine::Table* tbl); + // Warm OpenTableAck sections (USE latency): schema + first-row TLVs + // appended after the bag field (see wire.h OpenTableAckSections). + // The row section positions the engine cursor exactly like an + // explicit GotoTop would (same goto_top + pack_row_trailer + + // lookahead machinery), so a client that consumes it must skip its + // GotoTop round-trip. tbl may be nullptr (schema/row omitted). + void append_open_warm_sections(std::vector& out, + std::uint32_t id, + openads::engine::Table* tbl); + void sync_engine_cursor(std::uint32_t id); + // Install index iid as table tid's controlling order (iid 0 = + // natural), shared by the SetOrder handler and the fused nav+order + // path in GotoTop/GotoBottom. Returns 0 on success, else the ACE + // code; the caller formats the err() frame. + UNSIGNED32 install_table_order(std::uint32_t tid, std::uint32_t iid); + // Apply field writes to the current record; shared by the SetField + // (single) and SetFields (batch) handlers so both maintain the twin + // handle, bags and engine cursor identically. tbl is the already + // looked-up engine table for id.Twin cursor is synced once up front + // and the engine cursor once at the end (not per field). Returns 0 + // on success; on failure the ACE code, with column_missing set when + // a field name did not resolve (caller reports "column not found" + // instead of "write failed", matching the single-field handler). + struct FieldWriteResult { + UNSIGNED32 code = 0; + bool column_missing = false; + }; + FieldWriteResult write_fields(std::uint32_t id, + openads::engine::Table* tbl, + const std::vector>& pairs); +}; + +} // namespace openads::network diff --git a/src/network/5-session.cpp b/src/network/5-session.cpp new file mode 100644 index 00000000..56803e18 --- /dev/null +++ b/src/network/5-session.cpp @@ -0,0 +1,5738 @@ +#include "network/session.h" + +#include "openads_version.h" // OPENADS_VERSION_STR (CMake-generated) + +#include "engine/aof_eval.h" +#include "engine/index_expr.h" +#include "engine/aof_expr.h" +#include "engine/aggregate.h" +#include "engine/record_crc.h" +#include "engine/table.h" +#include "mgmt/error_log.h" +#include "mgmt/mg_collector.h" +#include "mgmt/mg_stats.h" +#include "network/mg_wire.h" +#include "network/mutex_manager.h" +#include "platform/proc.h" +#include "openads/ace.h" +#include "openads/error.h" +#include "abi/lock_retry_policy.h" +#include "engine/server_fs.h" +#include "platform/fs_sandbox.h" +#include "platform/path.h" +#include "session/connection.h" +#include "sql_backend/enterprise_config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace openads::abi { +void set_connection_show_deleted(ADSHANDLE hConnect, bool visible); +void set_connection_legacy_paths(ADSHANDLE hConnect, bool on); +void set_connection_remote_server(ADSHANDLE hConnect, bool on); +void share_connection_resolve_log(ADSHANDLE hConnect, + openads::session::Connection* src); +// Single-attempt record lock (see ace_exports.cpp): the wire server must +// fail a contended lock FAST — AdsLockRecord's retry loop would block the +// session thread ~1s, starving the peer op that releases the lock. +UNSIGNED32 try_lock_record_once(ADSHANDLE hTable, UNSIGNED32 ulRecord); +// Raw (unformatted) date/timestamp field reads for the wire: the server +// packs "YYYYMMDD"/"YYYYMMDDhhmmss" into row payloads regardless of the +// process-wide date format; clients format for display themselves. +void field_read_raw_begin(); +void field_read_raw_end(); +} + +namespace openads::network { + +static bool wire_trace_on() { + static const bool on = std::getenv("OPENADS_WIRE_TRACE") != nullptr; + return on; +} +#define WTRACE(...) do { if (wire_trace_on()) std::fprintf(stderr, __VA_ARGS__); } while (0) + +namespace { + +inline std::uint16_t read_u16_le(const std::uint8_t* p) { + return static_cast( + static_cast(p[0]) | + (static_cast(p[1]) << 8)); +} + +inline std::uint32_t read_u32_le(const std::uint8_t* p) { + return static_cast(p[0]) | + (static_cast(p[1]) << 8) | + (static_cast(p[2]) << 16) | + (static_cast(p[3]) << 24); +} + +inline void write_u16_le(std::uint16_t v, std::vector& out) { + out.push_back(static_cast( v & 0xFFu)); + out.push_back(static_cast((v >> 8) & 0xFFu)); +} + +inline void write_u32_le(std::uint32_t v, std::vector& out) { + out.push_back(static_cast( v & 0xFFu)); + out.push_back(static_cast((v >> 8) & 0xFFu)); + out.push_back(static_cast((v >> 16) & 0xFFu)); + out.push_back(static_cast((v >> 24) & 0xFFu)); +} + +// M12.29 — [u16 len][bytes] string helpers shared by the DD opcode handlers. +// Parses a [u16 len][bytes] field starting at `off`, advancing `off` past +// it. Returns false (payload too short) without touching `out` or `off` +// further than what was already consumed. +inline bool read_lstr16(const std::vector& pl, std::size_t& off, + std::string& out) { + if (off + 2 > pl.size()) return false; + std::uint16_t len = read_u16_le(pl.data() + off); + off += 2; + if (off + len > pl.size()) return false; + out.assign(reinterpret_cast(pl.data() + off), len); + off += len; + return true; +} + +inline void write_lstr16(const std::string& s, + std::vector& out) { + auto n = static_cast(s.size()); + out.push_back(static_cast(n & 0xFFu)); + out.push_back(static_cast((n >> 8) & 0xFFu)); + out.insert(out.end(), s.begin(), s.end()); +} + +// Mirror the ABI map_field_type() table so the wire reports ADS_* type +// codes (4 = STRING, 2 = NUMERIC, 11 = INTEGER, …) regardless of which +// server-side branch produced the schema. Shared by DescribeTable and +// the warm OpenTableAck schema section (which must agree byte-for-byte). +std::uint16_t ads_type_for_wire(openads::drivers::DbfFieldType t) { + using T = openads::drivers::DbfFieldType; + switch (t) { + case T::Character: return ADS_STRING; + case T::Numeric: + case T::Float: return ADS_NUMERIC; + case T::Logical: return ADS_LOGICAL; + case T::Date: + case T::AdtDate: return ADS_DATE; + case T::DateTime: + case T::AdtTimestamp: return ADS_TIMESTAMP; + case T::Memo: return ADS_MEMO; + case T::Integer: + case T::ShortInt: + case T::AutoInc: return ADS_INTEGER; + case T::Currency: + case T::AdtMoney: return ADS_MONEY; + case T::Double: return ADS_DOUBLE; + case T::Varchar: + case T::CiCharacter: return ADS_STRING; + case T::Varbinary: + case T::Binary: return ADS_RAW; + case T::Time: return ADS_TIME; + case T::Unknown: + default: return ADS_FIELD_TYPE_UNKNOWN; + } +} + +// Short opcode label for the text error log (OP column). Covers the +// hot paths developers actually grep for; everything else falls back +// to a stable hex tag so columns stay aligned. +const char* opcode_label(Opcode op) { + switch (op) { + case Opcode::Connect: return "Connect"; + case Opcode::OpenTable: return "OpenTable"; + case Opcode::CloseTable: return "CloseTable"; + case Opcode::ExecuteSQL: return "ExecuteSQL"; + case Opcode::Fetch: return "Fetch"; + case Opcode::FetchWhere: return "FetchWhere"; + case Opcode::Aggregate: return "Aggregate"; + case Opcode::GotoTop: return "GotoTop"; + case Opcode::GotoBottom: return "GotoBottom"; + case Opcode::GotoRecord: return "GotoRecord"; + case Opcode::Skip: return "Skip"; + case Opcode::GetField: return "GetField"; + case Opcode::SetField: return "SetField"; + case Opcode::AppendBlank: return "AppendBlank"; + case Opcode::DeleteRecord: return "DeleteRecord"; + case Opcode::RecallRecord: return "RecallRecord"; + case Opcode::FlushTable: return "FlushTable"; + case Opcode::OpenIndex: return "OpenIndex"; + case Opcode::CloseIndex: return "CloseIndex"; + case Opcode::SetOrder: return "SetOrder"; + case Opcode::SetOrderByName: return "SetOrderByNm"; + case Opcode::Seek: return "Seek"; + case Opcode::CreateIndex: return "CreateIndex"; + case Opcode::CreateTable: return "CreateTable"; + case Opcode::DropTable: return "DropTable"; + case Opcode::Reindex: return "Reindex"; + case Opcode::GetRecord: return "GetRecord"; + case Opcode::SetRecord: return "SetRecord"; + default: return nullptr; + } +} + +// Null-terminated UNSIGNED8 buffer for a std::string — the AdsDD* ABI takes +// mutable UNSIGNED8* name arguments (never written through) with no const +// C signature available. +inline std::vector to_cbuf(const std::string& s) { + std::vector b(s.size() + 1, 0); + if (!s.empty()) std::memcpy(b.data(), s.data(), s.size()); + return b; +} + +// M12.29 — dispatch a DDGetProperty/DDSetProperty request to the matching +// existing local AdsDDGet*Property/AdsDDSet*Property ABI function. `hConn` +// is the session's server-side abi_conn_ (a real local Connection with the +// DD attached via Session::ensure_abi_conn()) — these calls already work +// correctly locally; this only marshals which one to call. +UNSIGNED32 dd_get_property_dispatch(ADSHANDLE hConn, DDObjectKind kind, + const std::string& name, + const std::string& subName, + UNSIGNED16 propId, + void* pBuf, UNSIGNED16* pusLen) { + std::vector nameBuf(name.size() + 1, 0); + if (!name.empty()) std::memcpy(nameBuf.data(), name.data(), name.size()); + std::vector subBuf(subName.size() + 1, 0); + if (!subName.empty()) std::memcpy(subBuf.data(), subName.data(), subName.size()); + + switch (kind) { + case DDObjectKind::Database: + return AdsDDGetDatabaseProperty(hConn, propId, pBuf, pusLen); + case DDObjectKind::User: + return AdsDDGetUserProperty(hConn, nameBuf.data(), propId, pBuf, pusLen); + case DDObjectKind::Table: + return AdsDDGetTableProperty(hConn, nameBuf.data(), propId, pBuf, pusLen); + case DDObjectKind::Field: + return AdsDDGetFieldProperty(hConn, nameBuf.data(), subBuf.data(), + propId, pBuf, pusLen); + case DDObjectKind::Trigger: + return AdsDDGetTriggerProperty(hConn, nameBuf.data(), propId, pBuf, pusLen); + case DDObjectKind::Proc: + return AdsDDGetProcProperty(hConn, nameBuf.data(), propId, pBuf, pusLen); + case DDObjectKind::Function: + return AdsDDGetFunctionProperty(hConn, nameBuf.data(), propId, pBuf, pusLen); + case DDObjectKind::View: + return AdsDDGetViewProperty(hConn, nameBuf.data(), propId, pBuf, pusLen); + case DDObjectKind::RefIntegrity: + return AdsDDGetRefIntegrityProperty(hConn, nameBuf.data(), propId, pBuf, pusLen); + case DDObjectKind::Index: + return AdsDDGetIndexProperty(hConn, nameBuf.data(), subBuf.data(), + propId, pBuf, pusLen); + case DDObjectKind::UserTableRights: { + // Underlying local function returns a raw UNSIGNED32, not a + // property-id-keyed buffer — pack it into the same [value + // bytes] wire slot everything else uses (4-byte LE) so this + // reuses DDGetProperty instead of needing its own opcode. + UNSIGNED32 level = 0; + UNSIGNED32 rc = AdsDDGetUserTableRights(hConn, nameBuf.data(), + subBuf.data(), &level); + if (rc != 0) return rc; + if (pusLen != nullptr) { + UNSIGNED16 cap = *pusLen; + if (pBuf != nullptr && cap >= 4) { + auto* b = static_cast(pBuf); + b[0] = static_cast( level & 0xFFu); + b[1] = static_cast((level >> 8) & 0xFFu); + b[2] = static_cast((level >> 16) & 0xFFu); + b[3] = static_cast((level >> 24) & 0xFFu); + } + *pusLen = 4; + } + return openads::AE_SUCCESS; + } + } + return openads::AE_FUNCTION_NOT_AVAILABLE; +} + +UNSIGNED32 dd_set_property_dispatch(ADSHANDLE hConn, DDObjectKind kind, + const std::string& name, + const std::string& subName, + UNSIGNED16 propId, + void* pBuf, UNSIGNED16 usLen) { + std::vector nameBuf(name.size() + 1, 0); + if (!name.empty()) std::memcpy(nameBuf.data(), name.data(), name.size()); + std::vector subBuf(subName.size() + 1, 0); + if (!subName.empty()) std::memcpy(subBuf.data(), subName.data(), subName.size()); + + switch (kind) { + case DDObjectKind::Database: + return AdsDDSetDatabaseProperty(hConn, propId, pBuf, usLen); + case DDObjectKind::User: + return AdsDDSetUserProperty(hConn, nameBuf.data(), propId, pBuf, usLen); + case DDObjectKind::Table: + return AdsDDSetTableProperty(hConn, nameBuf.data(), propId, pBuf, usLen); + case DDObjectKind::Field: + return AdsDDSetFieldProperty(hConn, nameBuf.data(), subBuf.data(), + propId, pBuf, usLen); + case DDObjectKind::Trigger: + return AdsDDSetTriggerProperty(hConn, nameBuf.data(), propId, pBuf, usLen); + case DDObjectKind::Proc: + return AdsDDSetProcProperty(hConn, nameBuf.data(), propId, pBuf, usLen); + case DDObjectKind::Function: + return AdsDDSetFunctionProperty(hConn, nameBuf.data(), propId, pBuf, usLen); + case DDObjectKind::View: + return AdsDDSetViewProperty(hConn, nameBuf.data(), propId, pBuf, usLen); + case DDObjectKind::RefIntegrity: + return AdsDDSetRefIntegrityProperty(hConn, nameBuf.data(), propId, pBuf, usLen); + case DDObjectKind::Index: + // AdsDDSetIndexProperty is a permanent local stub regardless of + // connection type — nothing to forward. + return openads::AE_FUNCTION_NOT_AVAILABLE; + case DDObjectKind::UserTableRights: { + if (pBuf == nullptr || usLen < 4) { + return openads::AE_INTERNAL_ERROR; + } + auto* b = static_cast(pBuf); + UNSIGNED32 level = static_cast(b[0]) | + (static_cast(b[1]) << 8) | + (static_cast(b[2]) << 16) | + (static_cast(b[3]) << 24); + return AdsDDSetUserTableRights(hConn, nameBuf.data(), subBuf.data(), level); + } + } + return openads::AE_FUNCTION_NOT_AVAILABLE; +} + +// M12.10 — Error frame payload: +// [u32 LE ace_code][message bytes] +// Server-side handlers fold either a literal ACE code or a code +// pulled from a sub-result's util::Error into every Error frame +// they emit so the client can surface the right `Ads*` code. +Frame err(const std::string& msg, + UNSIGNED32 code = openads::AE_INTERNAL_ERROR) { + // Every error frame returned to a remote client also lands in the + // SAP-style ads_err error log (mirrors ADS, which records errors the + // server encounters serving client applications). + openads::mgmt::ErrorLog::instance().log( + static_cast(code), "NET", 0, msg); + Frame f; + f.opcode = Opcode::Error; + f.payload.resize(4); + f.payload[0] = static_cast( code & 0xFFu); + f.payload[1] = static_cast((code >> 8) & 0xFFu); + f.payload[2] = static_cast((code >> 16) & 0xFFu); + f.payload[3] = static_cast((code >> 24) & 0xFFu); + f.payload.insert(f.payload.end(), msg.begin(), msg.end()); + return f; +} + +} // namespace + +Session::Session(Server& srv, Socket s, std::string default_data_dir, + std::uint16_t listener_port) + : srv_(&srv), s_(s), sid_(0), default_data_dir_(std::move(default_data_dir)) { + // studio.web.0.4 — register an entry in the live sessions + // registry so the Studio "Sessions" tab can list this peer. + Server::SessionInfo init; + if (auto pa = socket_peer_addr(s_); pa) { + init.peer_ip = pa.value().ip; + init.peer_port = pa.value().port; + peer_str_ = pa.value().ip + ":" + std::to_string(pa.value().port); + } + init.listener_port = listener_port; + init.connected_at = std::chrono::system_clock::now(); + init.last_activity = init.connected_at; + sid_ = srv_->register_session(init); + srv_->install_session_socket(sid_, s_); +} + +Session::~Session() { + cleanup(); + srv_->erase_session_socket(sid_); + srv_->unregister_session(sid_); +} + +bool Session::process_frame(const Frame& f) { + srv_->touch_session(sid_, true, false); + srv_->set_session_opcode(sid_, static_cast(f.opcode)); + { + // M9.25 — inbound comm telemetry. +5 accounts for the 4-byte + // length prefix + 1-byte opcode of the wire framing. + auto& mgst = openads::mgmt::process_mg_stats(); + mgst.packets_in.fetch_add(1, std::memory_order_relaxed); + mgst.bytes_in.fetch_add(f.payload.size() + 5, + std::memory_order_relaxed); + } + // Track the actual SQL text for the Active Queries "current query" + // detail view — mirrors SAP ARC's sp_GetSQLStatements() (see + // mgtscrn.pas/.dfm in the Advantage Data Architect source). The + // ExecuteSQL frame's raw payload IS the SQL string. + if (f.opcode == Opcode::ExecuteSQL) { + // Iterator-based ctor: payload.data() may be nullptr when empty, + // and std::string(nullptr, 0) is UB. + std::string sql(f.payload.begin(), f.payload.end()); + srv_->set_session_sql(sid_, sql); + } + srv_->set_session_executing(sid_, true); + // Stamp the text error log context for this frame so any err() the + // handler emits carries SESSION/CLIENT/OP/TABLE columns. Cheap: + // label lookup + payload slice, no validation (handlers re-parse). + { + std::string op; + if (const char* l = opcode_label(f.opcode)) op = l; + else { + char hex[8]; + std::snprintf(hex, sizeof(hex), "0x%02X", + static_cast(f.opcode)); + op = hex; + } + std::string table; + if (f.opcode == Opcode::OpenTable && !f.payload.empty()) { + std::size_t off = (client_open_table_mode_ok_ && f.payload.size() >= 2) + ? 2 : 0; + if (off <= f.payload.size()) { + table.assign(reinterpret_cast(f.payload.data() + off), + f.payload.size() - off); + if (table.size() > 7 && + (table.rfind("tcp://", 0) == 0 || table.rfind("TCP://", 0) == 0)) { + auto sep = table.find_last_of("/\\"); + if (sep != std::string::npos) table = table.substr(sep + 1); + } + } + } else if (f.opcode == Opcode::OpenIndex && f.payload.size() > 4) { + table.assign(reinterpret_cast(f.payload.data() + 4), + f.payload.size() - 4); + } + // Keep the column readable: basename for paths. + if (!table.empty()) { + auto sep = table.find_last_of("/\\"); + if (sep != std::string::npos && sep + 1 < table.size()) + table = table.substr(sep + 1); + if (table.size() > 24) table = table.substr(table.size() - 24); + } + openads::mgmt::ErrorLog::instance().set_log_context( + sid_, peer_str_, op, table); + } + auto t0 = std::chrono::steady_clock::now(); + DispatchResult res; + try { + res = dispatch(f); + } catch (const std::exception& e) { + // A failing handler (e.g. std::bad_alloc once the address space is + // exhausted) must close this session, never kill the whole server. + res.reply = err(std::string("internal error: ") + e.what()); + res.close_session = true; + } catch (...) { + res.reply = err("internal error: unknown exception"); + res.close_session = true; + } + auto micros = std::chrono::duration_cast( + std::chrono::steady_clock::now() - t0).count(); + srv_->record_session_op_time(sid_, static_cast(micros)); + { + // Storm-diag — lock-free per-opcode latency accumulation. + auto& st = openads::mgmt::process_mg_stats(); + auto& t = st.op_timing[static_cast(f.opcode)]; + t.count.fetch_add(1, std::memory_order_relaxed); + t.total_us.fetch_add(static_cast(micros), + std::memory_order_relaxed); + auto prev = t.max_us.load(std::memory_order_relaxed); + while (static_cast(micros) > prev && + !t.max_us.compare_exchange_weak( + prev, static_cast(micros), + std::memory_order_relaxed)) { + } + } + srv_->set_session_executing(sid_, false); + if (res.reply) { + if (auto wr = write_frame(s_, *res.reply); !wr) return false; + auto& mgst = openads::mgmt::process_mg_stats(); + mgst.packets_out.fetch_add(1, std::memory_order_relaxed); + // RCB 07/14/2026: bytes_out was declared alongside packets_out but + // never actually fed, so AdsMgGetCommStats / sp_mgGetCommStats have + // always reported 0 bytes sent. Found it while trying to MEASURE the + // read-ahead block size and getting zeroes back. Same +5 framing + // overhead as bytes_in above. + mgst.bytes_out.fetch_add(res.reply->payload.size() + 5, + std::memory_order_relaxed); + srv_->touch_session(sid_, false, true); + } + return !res.close_session; +} + +bool Session::handle_readable() { + // Read whatever a single recv yields — a partial frame, one frame, or + // several — then reassemble and dispatch every complete frame. On a + // non-blocking socket (reactor pool) an idle or stalled peer returns + // would-block and we hand the worker straight back to its other + // connections, so one slow client can't cause head-of-line blocking. On a + // blocking socket (legacy thread-per-connection loop) recv just waits for + // the next bytes, preserving the previous one-frame-at-a-time behavior. + std::uint8_t buf[16384]; + auto r = sock_recv(s_, buf, sizeof(buf)); + if (!r) { + if (socket_recv_would_block(r.error())) return true; // nothing right now + return false; // peer reset / error + } + if (r.value() == 0) return false; // peer closed cleanly + auto frames = reader_.feed(buf, r.value()); + if (!frames) return false; // malformed framing + for (const auto& f : frames.value()) { + if (!process_frame(f)) return false; + } + return true; +} + +void Session::cleanup() { + // Login-gate audit: a session that held record locks is going away + // and every one of them evaporates HERE. If a duplicate login slips + // through right after this line, the holder's session died first + // (restart, WAN drop, app close) — not an exclusion failure. + WTRACE("[wire] session end user=%s conn=%u tables=%u\n", + session_user_.empty() ? "(anonymous)" : session_user_.c_str(), + (unsigned)(srv_ ? srv_->conn_no_for_session(sid_) : 0), + (unsigned)tbls_.size()); + // 0) Distributed mutexes first: free locks held by this session and + // reap names it created, so a vanished process leaves neither a + // stale lock (peers would block until server restart) nor a stale + // name (every later MutexCreate would fail "exists"). Runs before + // the table teardown below so woken peers proceed while we close. + // Covers Disconnect, peer-close and exception paths alike — all + // funnel through here (destructor included). + if (srv_ != nullptr) { + srv_->mutex_manager().release_session(conn_serial()); + } + + // Tear-down order matters for OS file handles on Windows (no + // FILE_SHARE_DELETE): indexes first, then shadow ABI tables, then + // engine tables, then low-level FsFile slots, then the ABI connection. + // A prior version only closed the ABI shadow (tbls_h_) and left + // engine tables + index_h_ + files_ to member destruction — that + // worked for most paths, but a client killed mid-CreateIndex (or + // after hundreds of open/close cycles) could leave the production + // .cdx open until openads_serverd itself exited (Pritpal Bedi). + + // 1) Explicit AdsCloseIndex for every wire index handle. AdsCloseTable + // purges bindings too, but closing indexes first drops each tag's + // File + CDX write-lock join before the table goes away. + for (auto& [iid, hidx] : index_h_) { + (void)iid; + if (hidx != 0) (void)AdsCloseIndex(hidx); + } + index_h_.clear(); + index_table_.clear(); + index_tag_.clear(); + ordered_tables_.clear(); + abi_schema_.clear(); + prefetch_depth_.clear(); + prefetch_run_dir_.clear(); + + // 2) SQL cursors (already ABI handles). + for (auto& [id, h] : cursor_tbls_) { + (void)id; + if (h != 0) (void)AdsCloseTable(h); + } + cursor_tbls_.clear(); + + // 3) Shadow ABI twins (production CDX auto-open + CreateIndex live here). + for (auto& [id, h] : tbls_h_) { + (void)id; + if (h != 0) (void)AdsCloseTable(h); + } + tbls_h_.clear(); + + // 4) Engine tables held by the session Connection. close_table erases + // the unique_ptr so the driver's File closes immediately — not after + // the Session member destructor runs (which is too late if anything + // else still references the path, and skips LockRegistry cleanup). + if (sess_conn_) { + for (auto& [id, h] : tbls_) { + if (auto rit = tbl_open_reg_.find(id); + rit != tbl_open_reg_.end()) { + srv_->unregister_open(sid_, rit->second.first, + rit->second.second); + } + if (auto* t = sess_conn_->lookup_table(h)) { + (void)t->flush(); + openads::mgmt::LockRegistry::instance().remove_all_for_table(t); + } + sess_conn_->close_table(h); + srv_->add_session_table(sid_, -1); + } + } + tbls_.clear(); + tbl_open_paths_.clear(); + tbl_open_reg_.clear(); + + // 5) oads_FOpen / AdsFOpen files for this session. + files_.clear(); + + // 6) ABI SQL statement + connection (AdsDisconnect closes any leftover + // tables that somehow escaped the loops above). + if (abi_stmt_ != 0) { + (void)AdsCloseSQLStatement(abi_stmt_); + abi_stmt_ = 0; + } + if (abi_conn_ != 0) { + (void)AdsDisconnect(abi_conn_); + abi_conn_ = 0; + } +} + +// M12.16 — lazy-init the per-session ABI connection (same one +// M12.7 SQL exec uses). +bool Session::ensure_abi_conn() { + if (abi_conn_ != 0) return true; + if (!sess_conn_) return false; + // Prefer the full .add path so the ABI connection inherits the DD. + const std::string& conn_path = sess_conn_->dd_path().empty() + ? sess_conn_->data_dir() : sess_conn_->dd_path(); + std::vector srvbuf(conn_path.size() + 1); + std::memcpy(srvbuf.data(), conn_path.c_str(), conn_path.size() + 1); + // RCB 06/30/2026: The server creates this ABI handle lazily for remote + // SQL/index operations. Reuse the original DD login so login-required + // dictionaries do not fail after the first remote Connect succeeds. + std::vector userbuf; + std::vector pwbuf; + auto make_arg = [](const std::string& s, std::vector& out) + -> UNSIGNED8* { + if (s.empty()) return nullptr; + out.resize(s.size() + 1); + std::memcpy(out.data(), s.c_str(), s.size() + 1); + return out.data(); + }; + UNSIGNED8* user = make_arg(session_user_, userbuf); + UNSIGNED8* pw = make_arg(session_password_, pwbuf); + UNSIGNED32 rc = AdsConnect60(srvbuf.data(), ADS_LOCAL_SERVER, + user, pw, 0, &abi_conn_); + if (rc == 0 && abi_conn_ != 0) { + // --legacy-paths: the ABI twin must fold client-absolute paths + // (CreateTable/Reindex/SQL go through it) the same way the + // session's engine Connection does. + openads::abi::set_connection_legacy_paths(abi_conn_, + srv_->legacy_paths()); + openads::abi::set_connection_remote_server(abi_conn_, true); + // One client session = one RESOLVED audit line per file: the + // twin re-opens tables for index/SQL work, so dedup against the + // engine connection's set, not its own (Pritpal Bedi, Aug 2026). + openads::abi::share_connection_resolve_log(abi_conn_, + sess_conn_.get()); + // M12.32 — a ShowDeleted opcode may have arrived before this + // lazy connection existed; new connections default to "show". + if (!show_deleted_) { + openads::abi::set_connection_show_deleted(abi_conn_, false); + } + } + return rc == 0; +} + +// M12.16 — open a parallel ABI handle alongside the engine +// handle in tbls_[id], stash in tbls_h_[id]. The engine handle +// stays open so subsequent navigation handlers (GotoTop/Skip/…) +// keep their existing path; only index operations route +// through the ABI handle. After an index op that moves the +// cursor (Seek / SeekLast) the helper sync_engine_cursor +// re-positions the engine cursor to the same recno so the two +// states never drift. SQL cursor handles (cursor_tbls_) are +// returned as-is since they are already ABI-side. +ADSHANDLE Session::ensure_abi_handle(std::uint32_t id) { + if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { + return cit->second; + } + if (auto hit = tbls_h_.find(id); hit != tbls_h_.end()) { + return hit->second; + } + auto eit = tbls_.find(id); + if (eit == tbls_.end() || !sess_conn_) return 0; + if (!ensure_abi_conn()) return 0; + auto* tbl = sess_conn_->lookup_table(eit->second); + if (!tbl) return 0; + // Reopen with the same name the client used (e.g. "orders/work.dbf"), + // not basename-only — otherwise production CDX auto-open binds against + // the wrong table when data files live in subdirectories. + std::string open_name; + if (auto pit = tbl_open_paths_.find(id); pit != tbl_open_paths_.end()) { + open_name = pit->second; + } + if (open_name.empty()) { + std::filesystem::path abs(tbl->path()); + std::filesystem::path base(sess_conn_->data_dir()); + std::error_code ec; + auto rel = std::filesystem::relative(abs, base, ec); + if (!ec && !rel.empty() && rel != ".") { + open_name = rel.generic_string(); + } else { + open_name = abs.filename().string(); + } + } + std::vector nb(open_name.size() + 1); + std::memcpy(nb.data(), open_name.data(), open_name.size()); + // Match the engine table's share mode so the twin does not upgrade a + // shared open to exclusive (or vice versa). map_open_mode(0) is Shared; + // Exclusive/Read map from the engine OpenMode. + UNSIGNED16 us_mode = ADS_SHARED; + switch (tbl->open_mode()) { + case openads::engine::OpenMode::Exclusive: + us_mode = ADS_EXCLUSIVE; break; + case openads::engine::OpenMode::Read: + us_mode = ADS_READONLY; break; + default: + us_mode = ADS_SHARED; break; + } + ADSHANDLE h = 0; + WTRACE("[wire] ensure_abi_handle id=%u open_name='%s' mode=%u eng_path='%s' eng_recs=%u\n", + id, open_name.c_str(), (unsigned)us_mode, + tbl->path().c_str(), (unsigned)tbl->record_count()); + auto& oi_st = openads::mgmt::process_mg_stats(); + auto oi_us = [](auto a, auto b) { + return static_cast( + std::chrono::duration_cast( + b - a).count()); + }; + auto oi_acc = [&oi_st, &oi_us](int ph, auto a, auto b) { + auto& t = oi_st.op_timing_oi[ph]; + auto us = oi_us(a, b); + t.count.fetch_add(1, std::memory_order_relaxed); + t.total_us.fetch_add(us, std::memory_order_relaxed); + auto pv = t.max_us.load(std::memory_order_relaxed); + while (us > pv && !t.max_us.compare_exchange_weak( + pv, us, std::memory_order_relaxed)) {} + }; + auto oi_t0 = std::chrono::steady_clock::now(); + UNSIGNED32 open_rrc = AdsOpenTable(abi_conn_, nb.data(), nullptr, + ADS_CDX, 0, 0, 0, us_mode, &h); + oi_acc(0, oi_t0, std::chrono::steady_clock::now()); + if (open_rrc != 0) { + WTRACE("[wire] ensure_abi_handle id=%u AdsOpenTable FAILED rrc=%u\n", + id, (unsigned)open_rrc); + return 0; + } + // Position the twin so pack_row_trailer sees a live cursor (not Limbo). + auto oi_t2 = std::chrono::steady_clock::now(); + UNSIGNED32 gt_rc = AdsGotoTop(h); + UNSIGNED16 _b = 9, _e = 9; + AdsAtBOF(h, &_b); AdsAtEOF(h, &_e); + UNSIGNED32 twin_recs = 0; + AdsGetRecordCount(h, 0, &twin_recs); + oi_acc(2, oi_t2, std::chrono::steady_clock::now()); + WTRACE("[wire] ensure_abi_handle id=%u CDX open: bof=%u eof=%u twin_recs=%u\n", + id, (unsigned)_b, (unsigned)_e, (unsigned)twin_recs); + // Fallback: if GotoTop left the twin in Limbo (both bof and eof true), + // the ACE CDX implementation may disagree with the engine's CDX on the + // same file. Close and reopen as a plain DBF (no CDX), then try to + // attach the index explicitly. If even that fails, we still have a + // working cursor in natural record order — better than Limbo. + // + // Skip when the table is genuinely EMPTY: Limbo is the correct state + // there, and reopening without the production bag only discards its + // binding (later twin appends then leave the bag stale). Since the + // AdsOpenIndex heal (v1.09.9) reindexes stale bags on open, a non-empty + // table should never reach this branch in Limbo anymore — it remains + // as a safety net for bags the heal cannot repair. + if (_b && _e && tbl->record_count() > 0) { + WTRACE("[wire] ensure_abi_handle id=%u Limbo after CDX open, retrying without CDX eng_recs=%u\n", + id, (unsigned)tbl->record_count()); + AdsCloseTable(h); + h = 0; + open_rrc = AdsOpenTable(abi_conn_, nb.data(), nullptr, + ADS_TABLE, 0, 0, 0, us_mode, &h); + if (open_rrc != 0) { + WTRACE("[wire] ensure_abi_handle id=%u AdsOpenTable(ADS_TABLE) FAILED rrc=%u\n", + id, (unsigned)open_rrc); + return 0; + } + // Try to attach the production index explicitly. + ADSHANDLE hIdx = 0; + UNSIGNED16 idxCount = 0; + // Build the CDX path from the DBF path. + std::string cdx_name; + { + std::filesystem::path p(open_name); + cdx_name = p.replace_extension(".cdx").generic_string(); + } + std::vector cdx_nb(cdx_name.size() + 1); + std::memcpy(cdx_nb.data(), cdx_name.data(), cdx_name.size()); + UNSIGNED32 idx_rc = AdsOpenIndex(h, cdx_nb.data(), &hIdx, &idxCount); + if (idx_rc == 0 && idxCount > 0) { + // Register the attached tag under a wire id so ordered nav can + // reach it (mirrors the OpenIndex handler's bookkeeping). + std::uint32_t iid = next_id_++; + index_h_[iid] = hIdx; + index_table_[iid] = id; + UNSIGNED8 tbuf[256] = {0}; + UNSIGNED16 tlen = static_cast(sizeof(tbuf) - 1); + if (AdsGetIndexName(hIdx, tbuf, &tlen) == 0) { + index_tag_[iid] = std::string( + reinterpret_cast(tbuf), tlen); + } + WTRACE("[wire] ensure_abi_handle id=%u CDX attached via ADS_TABLE fallback idx=%u iid=%u\n", + id, (unsigned)idxCount, (unsigned)iid); + } else { + WTRACE("[wire] ensure_abi_handle id=%u no CDX (rc=%u count=%u), natural order\n", + id, (unsigned)idx_rc, (unsigned)idxCount); + } + gt_rc = AdsGotoTop(h); + AdsAtBOF(h, &_b); AdsAtEOF(h, &_e); + AdsGetRecordCount(h, 0, &twin_recs); + WTRACE("[wire] ensure_abi_handle id=%u TABLE fallback: bof=%u eof=%u twin_recs=%u\n", + id, (unsigned)_b, (unsigned)_e, (unsigned)twin_recs); + } + WTRACE("[wire] ensure_abi_handle id=%u opened h=%llu gt_rc=%u bof=%u eof=%u twin_recs=%u\n", + id, (unsigned long long)h, (unsigned)gt_rc, (unsigned)_b, (unsigned)_e, (unsigned)twin_recs); + tbls_h_[id] = h; + return h; +} + +// M12.18 — pack the current record (recno + deleted + per-field +// value bytes) onto the tail of any nav-op ack so the client +// populates RemoteTable's row cache in the same RTT as the nav +// itself. +// Pack one record's bytes into `dst` at the current cursor. +// Returns false on EoF / unread error so the caller can stop +// walking lookahead. +bool Session::pack_one_row_engine(std::vector& dst, + openads::engine::Table* tbl) { + if (!tbl || tbl->eof() || tbl->bof() || tbl->recno() == 0) { + return false; + } + auto write_u32_p = [&](std::uint32_t v) { + dst.push_back(static_cast( v & 0xFFu)); + dst.push_back(static_cast((v >> 8) & 0xFFu)); + dst.push_back(static_cast((v >> 16) & 0xFFu)); + dst.push_back(static_cast((v >> 24) & 0xFFu)); + }; + auto write_u16_p = [&](std::uint16_t v) { + dst.push_back(static_cast( v & 0xFFu)); + dst.push_back(static_cast((v >> 8) & 0xFFu)); + }; + write_u32_p(tbl->recno()); + dst.push_back(tbl->is_deleted() ? 1 : 0); + auto nf = static_cast(tbl->field_count()); + write_u16_p(nf); + for (std::uint16_t i = 0; i < nf; ++i) { + auto v = tbl->read_field(i); + std::string vv = v ? v.value().as_string : std::string(); + write_u32_p(static_cast(vv.size())); + dst.insert(dst.end(), vv.begin(), vv.end()); + } + return true; +} + +// Field names + memo-ness for one open ABI handle, resolved once. The schema +// of an open table cannot change under us, so this is a pure win: it takes +// the two per-column ABI lookups (name, type) out of the per-row path, which +// a 64-row lookahead block walks 64 times. +const std::vector& +Session::abi_schema_for(ADSHANDLE h_abi) { + auto it = abi_schema_.find(h_abi); + if (it != abi_schema_.end()) return it->second; + + std::vector cols; + UNSIGNED16 nf = 0; + AdsGetNumFields(h_abi, &nf); + cols.reserve(nf); + // RCB 07/15/2026: on `cap` and the memcpy — this is NOT the unbounded + // "ACE writes back the required size" pattern that would overflow nm[64]. + // These AdsGetFieldName/AdsGetField calls run against a local server-side + // handle and land in copy_to_caller() (src/abi/charset.cpp), which writes + // back cap = min(name_len, buf-1) and never a value >= the buffer. So the + // memcpy is bounded by construction. One record per column, always the full + // count: do NOT rewrite this to skip a column on an (unreachable, i = 1..nf) + // failure — cols.size() is sent as the row's field count in + // pack_one_row_abi, so a short cols would desync the wire row. + for (UNSIGNED16 i = 1; i <= nf; ++i) { + AbiField fd; + UNSIGNED8 nm[64] = {0}; + UNSIGNED16 cap = sizeof(nm); + AdsGetFieldName(h_abi, i, nm, &cap); + if (cap > sizeof(nm)) cap = sizeof(nm); // belt-and-suspenders; see above + fd.name.assign(cap + 1, 0); + std::memcpy(fd.name.data(), nm, cap); + UNSIGNED16 ftype = 0; + AdsGetFieldType(h_abi, fd.name.data(), &ftype); + fd.is_memo = (ftype == ADS_MEMO || + ftype == ADS_BINARY || + ftype == ADS_IMAGE); + cols.push_back(std::move(fd)); + } + return abi_schema_.emplace(h_abi, std::move(cols)).first->second; +} + +bool Session::pack_one_row_abi(std::vector& dst, + ADSHANDLE h_abi) { + UNSIGNED16 atend = 0; + AdsAtEOF(h_abi, &atend); + if (atend) return false; + auto write_u32_p = [&](std::uint32_t v) { + dst.push_back(static_cast( v & 0xFFu)); + dst.push_back(static_cast((v >> 8) & 0xFFu)); + dst.push_back(static_cast((v >> 16) & 0xFFu)); + dst.push_back(static_cast((v >> 24) & 0xFFu)); + }; + auto write_u16_p = [&](std::uint16_t v) { + dst.push_back(static_cast( v & 0xFFu)); + dst.push_back(static_cast((v >> 8) & 0xFFu)); + }; + UNSIGNED32 rn = 0; + AdsGetRecordNum(h_abi, 0, &rn); + write_u32_p(rn); + UNSIGNED16 del = 0; + AdsIsRecordDeleted(h_abi, &del); + dst.push_back(del != 0 ? 1 : 0); + const auto& cols = abi_schema_for(h_abi); + write_u16_p(static_cast(cols.size())); + std::vector vbuf; + for (const auto& fd : cols) { + // Non-memo values fit the fixed DBF field width; 4096 covers every + // scalar type the ABI hands back. Memos are sized from the engine. + UNSIGNED32 vcap = 4096; + if (fd.is_memo) { + UNSIGNED32 mlen = 0; + if (AdsGetMemoLength(h_abi, + const_cast(fd.name.data()), &mlen) != 0) { + mlen = 0; + } + vcap = mlen + 1; + } + vbuf.assign(vcap, 0); + // Date/Timestamp cells ride the wire RAW ("YYYYMMDD" / + // "YYYYMMDDhhmmss") — the client formats for display; SAP parity + // (AdsGetField formatted) stays on the client-side ABI surface. + openads::abi::field_read_raw_begin(); + UNSIGNED32 grc = AdsGetField(h_abi, const_cast(fd.name.data()), + vbuf.data(), &vcap, 0); + openads::abi::field_read_raw_end(); + if (grc != 0) { + vcap = 0; + } + write_u32_p(vcap); + if (vcap > 0) { + dst.insert(dst.end(), vbuf.data(), vbuf.data() + vcap); + } + } + return true; +} + +void Session::pack_row_trailer(Frame& reply, std::uint32_t id, + std::uint16_t lookahead_n, + std::int8_t dir) { + auto write_u16_p = [&](std::uint16_t v, + std::vector& dst) { + dst.push_back(static_cast( v & 0xFFu)); + dst.push_back(static_cast((v >> 8) & 0xFFu)); + }; + // Resolve the table once; remember which path applies for + // both the current row and the lookahead block below. + openads::engine::Table* eng_tbl = nullptr; + ADSHANDLE h_abi = 0; + if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { + h_abi = cit->second; + } else if (ordered_tables_.count(id) != 0) { + h_abi = ensure_abi_handle(id); + // Also resolve the engine table — we need it for fallback reads + // when the ABI twin is in Limbo (ACE's CDX may disagree with the + // engine's CDX on index state, leaving the twin at bof=eof=1). + if (auto eit = tbls_.find(id); eit != tbls_.end() && sess_conn_) { + eng_tbl = sess_conn_->lookup_table(eit->second); + } + } else if (auto eit = tbls_.find(id); eit != tbls_.end() && sess_conn_) { + eng_tbl = sess_conn_->lookup_table(eit->second); + } else if (auto hit = tbls_h_.find(id); hit != tbls_h_.end()) { + h_abi = hit->second; + } + if (!eng_tbl && h_abi == 0) { + reply.payload.push_back(0); + return; + } + if (h_abi != 0) { + UNSIGNED16 _b = 9, _e = 9; AdsAtBOF(h_abi, &_b); AdsAtEOF(h_abi, &_e); + // Rescue: if the twin landed in Limbo (bof=1 eof=1) — e.g. from a + // cached handle opened when the table was empty — reposition it now. + if (_b && _e) { + AdsGotoTop(h_abi); + AdsAtBOF(h_abi, &_b); AdsAtEOF(h_abi, &_e); + if (_b && _e) AdsGotoBottom(h_abi); + AdsAtBOF(h_abi, &_b); AdsAtEOF(h_abi, &_e); + WTRACE("[wire] pack enter id=%u Limbo rescue bof=%u eof=%u\n", + id, (unsigned)_b, (unsigned)_e); + } + WTRACE("[wire] pack enter id=%u twin bof=%u eof=%u\n", id, (unsigned)_b, (unsigned)_e); + } + // f2436d8 regression: for ORDERED tables it made this pack read from the + // engine mirror. That mirror lags the twin one op (handlers sync AFTER + // packing) and its lookahead walks NATURAL order, so every ordered op + // shipped the previous position's row and ordered read-ahead walked + // recno order instead of the index. The twin is the authority for an + // ordered table: pack from it. The engine stays the source for + // natural-order tables and the fallback when the twin is genuinely in + // Limbo (empty order/table) — the case f2436d8 meant to cover. + bool twin_limbo = false; + if (h_abi != 0) { + UNSIGNED16 lb = 9, le = 9; + AdsAtBOF(h_abi, &lb); AdsAtEOF(h_abi, &le); + twin_limbo = (lb != 0 && le != 0); + } + const bool use_engine = (eng_tbl != nullptr) && (h_abi == 0 || twin_limbo); + // Pack the current row. + bool current_packed = false; + if (use_engine) { + // If the ABI twin is in Limbo (bof=eof=1) but we have the engine + // table, sync the engine to the ABI twin's recno and read from engine. + // ACE's CDX may disagree with the engine's CDX on index state. + if (h_abi != 0) { + UNSIGNED32 rn = 0; + AdsGetRecordNum(h_abi, 0, &rn); + if (rn > 0 && eng_tbl->record_count() >= rn) { + eng_tbl->goto_record(rn); + WTRACE("[wire] pack enter id=%u Limbo fallback engine recno=%u\n", + id, (unsigned)rn); + } else { + // Twin recno unavailable (Limbo); use engine's current pos. + WTRACE("[wire] pack enter id=%u Limbo, engine recno=%u\n", + id, (unsigned)eng_tbl->recno()); + } + } + if (eng_tbl->eof() || eng_tbl->bof() || eng_tbl->recno() == 0) { + reply.payload.push_back(0); + } else { + reply.payload.push_back(1); + std::vector tmp; + if (pack_one_row_engine(tmp, eng_tbl)) { + reply.payload.insert(reply.payload.end(), + tmp.begin(), tmp.end()); + current_packed = true; + } + } + } else { + // BOF must report has_row=0 just like EOF: with a row on the + // trailer the client treats the BOF landing as a valid position, + // and its boundary detection (which relies on a pristine + // row_valid_before) then reports Bof()=.F. on the first backward + // skip at the top — xBrowse counts one extra row above and paints + // a phantom duplicate (Tim Stone, 1-record scope). It also lets + // the lookahead walk run at BOF, whose restore step clears the + // twin's BOF flag. Field reads at BOF still work: they miss the + // cache and cost one round trip, exactly like EOF. + UNSIGNED16 atend = 0, atbeg = 0; + AdsAtEOF(h_abi, &atend); + AdsAtBOF(h_abi, &atbeg); + if (atend || atbeg) { + reply.payload.push_back(0); + } else { + reply.payload.push_back(1); + std::vector tmp; + if (pack_one_row_abi(tmp, h_abi)) { + reply.payload.insert(reply.payload.end(), + tmp.begin(), tmp.end()); + current_packed = true; + } + } + } + // M12.21 lookahead block. Walk Skip(dir) up to lookahead_n times capturing + // each row, then Skip(-dir * advance) back so the cursor lands at the same + // spot the lone Skip the caller actually issued would have produced. + // RCB 07/15/2026: M12.25 — `dir` is +1 for a forward block, -1 for a + // backward (PgUp) one. The walk and the restore are symmetric in it. + if (!current_packed || lookahead_n == 0 || dir == 0) { + // No lookahead either way — emit a count of 0 so the + // wire format always carries the field. Old clients + // (M12.18) ignore the extra bytes. + write_u16_p(0, reply.payload); + return; + } + const SIGNED32 walk = dir; // +1 or -1, one step per iteration + std::vector> rows; + rows.reserve(lookahead_n); + std::uint16_t taken = 0; + // Track cursor moves separately from rows packed: a Skip that lands on + // EoF/BoF still moves the cursor, even though no row gets packed. The + // restore step at the end has to undo every cursor advance, packed-row or + // not, otherwise the caller-visible cursor lands a row past where the lone + // Skip it issued would have produced. + int cursor_advance = 0; + // RCB 07/14/2026: the row count is only half the bound — see + // kPrefetchMaxBytes. Stop as soon as the block reaches the byte budget so a + // wide table cannot turn a 64-row lookahead into a multi-hundred-KB frame. + // Checked AFTER packing each row on purpose: that way we always send at + // least one lookahead row even when a single row is bigger than the whole + // budget, instead of silently degrading to no read-ahead on exactly the + // tables where a round-trip costs the most. + std::size_t block_bytes = 0; + for (std::uint16_t i = 0; i < lookahead_n; ++i) { + if (use_engine) { + auto sk = eng_tbl->skip(walk); + if (!sk) break; + ++cursor_advance; + std::vector row; + if (!pack_one_row_engine(row, eng_tbl)) break; + block_bytes += row.size(); + rows.push_back(std::move(row)); + ++taken; + } else { + if (AdsSkip(h_abi, walk) != 0) break; + ++cursor_advance; + // A backward walk stops at BoF, a forward one at EoF; pack_one_row_* + // already refuses to pack an off-record cursor, but check the + // relevant boundary first so we don't pack a phantom row. + UNSIGNED16 atend = 0; + if (dir > 0) AdsAtEOF(h_abi, &atend); else AdsAtBOF(h_abi, &atend); + if (atend) break; + std::vector row; + if (!pack_one_row_abi(row, h_abi)) break; + block_bytes += row.size(); + rows.push_back(std::move(row)); + ++taken; + } + if (block_bytes >= kPrefetchMaxBytes) break; + } + if (cursor_advance > 0) { + // Undo every advance: we stepped `walk` cursor_advance times, so step + // back by -walk * cursor_advance. + const SIGNED32 restore = + -walk * static_cast(cursor_advance); + if (use_engine) { + (void)eng_tbl->skip(restore); + } else { + (void)AdsSkip(h_abi, restore); + } + } + write_u16_p(taken, reply.payload); + for (auto& r : rows) { + reply.payload.insert(reply.payload.end(), r.begin(), r.end()); + } +} + +void Session::pack_bound_trailer(Frame& reply, std::uint32_t id) { + // Same source-of-truth rule as the AtBOF/AtEOF handlers: ordered + // tables answer from the ABI twin, natural tables from the engine + // cursor. The caller just repositioned explicitly, so both are + // freshly placed (no rescue moves here — this must stay a pure + // read; both-true genuinely means an empty cursor). + // Layout: [u8 bof][u8 eof][u32 recno] + optional [u32 reccount]. + // The count rides only when the engine table resolved (in-memory + // record_count, no disk refresh — same trust as the client's + // rec_count cache; the wire GetRecordCount keeps its refresh for + // callers that need multiuser-fresh). Length-gated on the client. + UNSIGNED16 b = 1, e = 1; + UNSIGNED32 rn = 0; + openads::engine::Table* eng = nullptr; + if (ordered_tables_.count(id) != 0) { + if (ADSHANDLE h = ensure_abi_handle(id); h != 0) { + AdsAtBOF(h, &b); + AdsAtEOF(h, &e); + AdsGetRecordNum(h, 0, &rn); + } + if (auto eit = tbls_.find(id); + eit != tbls_.end() && sess_conn_ != nullptr) { + eng = sess_conn_->lookup_table(eit->second); + } + } else if (auto eit = tbls_.find(id); + eit != tbls_.end() && sess_conn_ != nullptr) { + if (auto* tbl = sess_conn_->lookup_table(eit->second); + tbl != nullptr) { + eng = tbl; + b = tbl->bof() ? 1 : 0; + e = tbl->eof() ? 1 : 0; + rn = tbl->recno(); + } + } + reply.payload.push_back(b != 0 ? 1 : 0); + reply.payload.push_back(e != 0 ? 1 : 0); + reply.payload.push_back(static_cast( rn & 0xFFu)); + reply.payload.push_back(static_cast((rn >> 8) & 0xFFu)); + reply.payload.push_back(static_cast((rn >> 16) & 0xFFu)); + reply.payload.push_back(static_cast((rn >> 24) & 0xFFu)); + if (eng != nullptr) { + const std::uint32_t n = eng->record_count(); + reply.payload.push_back(static_cast( n & 0xFFu)); + reply.payload.push_back(static_cast((n >> 8) & 0xFFu)); + reply.payload.push_back(static_cast((n >> 16) & 0xFFu)); + reply.payload.push_back(static_cast((n >> 24) & 0xFFu)); + } +} + +// mtfix12 R1 (kCapNavBoundaryPair) — see session.h. The pair section: +// [u8 flags][u32 trailer_len][trailer][bound 6|10][u32 keycount?] +// flags bit 0x02 = pair bound carries reccount, bit 0x04 = keycount +// present (ordered tables; natural order derives count == reccount on +// the client). The trailer is pack_row_trailer output at lookahead +// depth 0 for the OPPOSITE boundary, read inside this same visit, so +// the client's adjacent opposite-boundary call applies it verbatim. +void Session::pack_boundary_pair(Frame& reply, std::uint32_t id, + int which, ADSHANDLE hord, + openads::engine::Table* tbl) { + const bool to_bottom = (which == 1); // certify the opposite end + std::uint8_t flags = 0; + Frame blob; + blob.opcode = reply.opcode; + Frame bnd; + bnd.opcode = reply.opcode; + UNSIGNED32 kc = 0; + bool granted = false; + if (hord != 0) { + // Ordered table: navigate the ABI twin (it carries the order + // and scope), restore the requested boundary exactly after. + UNSIGNED32 save_rec = 0; + UNSIGNED16 sb = 0, se = 0; + (void)AdsGetRecordNum(hord, 0, &save_rec); + (void)AdsAtBOF(hord, &sb); + (void)AdsAtEOF(hord, &se); + const bool empty_landing = (sb != 0 && se != 0); + const UNSIGNED32 grc = + to_bottom ? AdsGotoBottom(hord) : AdsGotoTop(hord); + if (grc == 0) { + pack_row_trailer(blob, id, 0); + pack_bound_trailer(bnd, id); + if (bnd.payload.size() >= 10) flags |= 0x02; + if (AdsGetKeyCount(hord, 0, &kc) == 0) flags |= 0x04; + granted = true; + } + if (empty_landing) { + (void)(which == 1 ? AdsGotoTop(hord) : AdsGotoBottom(hord)); + } else { + (void)AdsGotoRecord(hord, save_rec); + } + } else if (tbl != nullptr) { + const std::uint32_t save_rec = tbl->recno(); + const bool empty_landing = tbl->bof() && tbl->eof(); + auto gr = to_bottom ? tbl->goto_bottom() : tbl->goto_top(); + if (gr) { + pack_row_trailer(blob, id, 0); + pack_bound_trailer(bnd, id); + if (bnd.payload.size() >= 10) flags |= 0x02; + granted = true; + } + if (empty_landing) { + if (which == 1) (void)tbl->goto_top(); + else (void)tbl->goto_bottom(); + } else { + (void)tbl->goto_record(save_rec); + } + } + if (!granted) return; // client falls back to a plain second frame + reply.payload.push_back(flags); + const std::uint32_t tlen = + static_cast(blob.payload.size()); + reply.payload.push_back(static_cast( tlen & 0xFFu)); + reply.payload.push_back(static_cast((tlen >> 8) & 0xFFu)); + reply.payload.push_back(static_cast((tlen >> 16) & 0xFFu)); + reply.payload.push_back(static_cast((tlen >> 24) & 0xFFu)); + reply.payload.insert(reply.payload.end(), + blob.payload.begin(), blob.payload.end()); + reply.payload.insert(reply.payload.end(), + bnd.payload.begin(), bnd.payload.end()); + if ((flags & 0x04) != 0) { + reply.payload.push_back(static_cast( kc & 0xFFu)); + reply.payload.push_back(static_cast((kc >> 8) & 0xFFu)); + reply.payload.push_back(static_cast((kc >> 16) & 0xFFu)); + reply.payload.push_back(static_cast((kc >> 24) & 0xFFu)); + } +} + +// M12.22/M12.23 — read-ahead depth for one forward Skip. +// +// `hint` is what the client asked for via AdsCacheRecords, or +// kPrefetchDepthAuto when it never called it (and for any pre-M12.23 client, +// whose Skip frame carries no depth field at all). +// +// * explicit hint: honour it. The caller knows something we cannot infer +// from the access pattern — SAP's documented cases are "0 or 1 turns +// read-ahead off" (a batch loop that edits most records it visits, where +// every edit would dump the block anyway) and "aggressive = 100" (a +// one-directional sweep). Capped at kPrefetchDepthMax so one request +// can't become an unbounded server-side scan. +// * auto: ramp. One step per consecutive forward Skip on the table, +// 8 -> 16 -> 32 -> 64, then held. Anything that breaks the sequential run +// (reposition, write, order change) erases the entry, so the next run +// starts at the floor again — which is what keeps a one-off "seek a +// record and read it" from dragging a full block it will never look at. +std::uint16_t Session::next_lookahead(std::uint32_t id, std::uint16_t hint, + std::int8_t dir) { + if (!client_prefetch_ok_) return 0; + if (hint != kPrefetchDepthAuto) { + // SAP: "A usRecords value of 0 (or 1) effectively turns read-ahead + // record caching off." 1 means "just the current row", which is + // exactly a zero-length lookahead block. + if (hint <= 1) return 0; + return hint > kPrefetchDepthMax ? kPrefetchDepthMax : hint; + } + // RCB 07/15/2026: M12.25 — a direction reversal (PgDn then PgUp) is a fresh + // run, so restart the ramp at the floor. Otherwise the first backward block + // after a long forward scan would arrive at the ceiling and over-fetch the + // same way an unramped forward scan did. + if (auto dit = prefetch_run_dir_.find(id); + dit != prefetch_run_dir_.end() && dit->second != dir) { + prefetch_depth_.erase(id); + } + prefetch_run_dir_[id] = dir; + auto [it, fresh] = prefetch_depth_.try_emplace(id, kPrefetchFloor); + if (!fresh) { + std::uint32_t grown = static_cast(it->second) * 2u; + it->second = static_cast( + grown > kPrefetchCeil ? kPrefetchCeil : grown); + } + return it->second; +} + +void Session::reset_lookahead(std::uint32_t id) { + prefetch_depth_.erase(id); + prefetch_run_dir_.erase(id); +} + +namespace { + +// RCB 07/14/2026: opcodes that end a sequential read-ahead run on the table +// they name. All of them either move the cursor somewhere the block was not +// read from, or change what the rows mean (order / scope / filter), or write. +// +// Why a central list instead of a reset_lookahead() call inside each handler: +// dispatch() can break the run in ONE place rather than in twenty handlers, and +// twenty hand-placed calls is twenty chances to forget one when a new opcode +// lands. The leading u32 of the payload names the affected object — but see +// break_key_is_index_id() below: for the index-scoped ops that u32 is an INDEX +// id, not a table id, and has to be resolved before it means anything to +// prefetch_depth_ (which is keyed by table). +// +// Forgetting an opcode here (or mis-keying one) is cheap by construction, which +// is the property that makes the central approach safe: it is a TUNING bug, +// never a correctness bug. The lookahead rows are always walked fresh from the +// post-op cursor, so the worst case is a Skip that carries a deeper block than +// it should have. +bool breaks_prefetch_run(Opcode op) { + switch (op) { + case Opcode::GotoTop: + case Opcode::GotoBottom: + case Opcode::GotoRecord: + case Opcode::RefreshRecord: + case Opcode::Seek: + case Opcode::SeekLast: + case Opcode::SkipUnique: + case Opcode::SetOrder: + case Opcode::SetOrderByName: + case Opcode::SetScope: + case Opcode::ClearScope: + case Opcode::SetAOF: + case Opcode::CustomizeAOF: + case Opcode::SetField: + case Opcode::SetRecord: + case Opcode::AppendBlank: + case Opcode::DeleteRecord: + case Opcode::RecallRecord: + case Opcode::PackTable: + case Opcode::ZapTable: + case Opcode::CloseTable: + return true; + default: + return false; + } +} + +// RCB 07/15/2026: of the run-enders above, these carry an INDEX id as their +// leading u32, not a table id — their handlers resolve it through index_h_ / +// index_table_ (see the Seek / SkipUnique / SetScope / ClearScope cases). The +// ramp map prefetch_depth_ is keyed by TABLE, so dispatch() must translate +// index->table before resetting, or the reset silently misses: an index id is +// never a live table id, so reset_lookahead() would just erase an absent key +// and the real table's ramp would keep climbing across a seek — defeating the +// very "seek then read one, don't over-fetch" case the ramp exists for. +// SetOrder / SetOrderByName are absent on purpose: those DO lead with a table +// id (see their handlers). +bool break_key_is_index_id(Opcode op) { + switch (op) { + case Opcode::Seek: + case Opcode::SeekLast: + case Opcode::SkipUnique: + case Opcode::SetScope: + case Opcode::ClearScope: + return true; + default: + return false; + } +} + +} // namespace + +// M12.16 — re-position the engine cursor to match the ABI +// cursor after an index op that moves it (Seek / SeekLast). +// No-op when the table is a cursor or has no engine handle. +void Session::sync_engine_cursor(std::uint32_t id) { + WTRACE("[wire] sync_engine_cursor id=%u enter\n", id); + if (cursor_tbls_.count(id)) return; + auto eit = tbls_.find(id); + if (eit == tbls_.end() || !sess_conn_) return; + auto* tbl = sess_conn_->lookup_table(eit->second); + if (!tbl) return; + ADSHANDLE h = 0; + if (auto hit = tbls_h_.find(id); hit != tbls_h_.end()) { + h = hit->second; + } else { return; } + // Appends / writes often go through the parallel ABI handle (where + // CreateIndex / OpenIndex bound the bag). Re-read the header so the + // engine Table sees the new record_count before GotoRecord. + if (auto* drv = tbl->driver()) { + drv->refresh_record_count_from_disk(); + drv->invalidate_read_cache(); + } + UNSIGNED32 rn = 0; + AdsGetRecordNum(h, 0, &rn); + if (rn == 0) return; + // A twin in Limbo (stale/empty order) reports a phantom recno + // (record_count + 1). Dragging the engine cursor there poisons every + // later pack_row_trailer fallback ("engine recno=16" on a 15-record + // table). Skip the sync: the engine keeps its last known-good position. + // NOTE: recno == record_count+1 with eof-only is a LEGITIMATE past-end + // position (scope end) and must still sync — only Limbo (bof&&eof) is + // the poison case. + { + UNSIGNED16 lb = 0, le = 0; + AdsAtBOF(h, &lb); AdsAtEOF(h, &le); + if (lb && le) { + WTRACE("[wire] sync_engine_cursor id=%u twin in Limbo, skip\n", id); + return; + } + } + (void)tbl->goto_record(rn); +} + +// Fused nav+order path (SetOrder+GotoTop in one frame): install index +// iid as table tid's controlling order. Factored out of the SetOrder +// handler so GotoTop/GotoBottom can take an order section without +// duplicating the self-heal logic. Returns 0 on success, else the ACE +// code for the caller to format. +UNSIGNED32 Session::install_table_order(std::uint32_t tid, + std::uint32_t iid) { + ADSHANDLE ht = ensure_abi_handle(tid); + if (ht == 0) return openads::AE_NO_FILE_FOUND; + // iid 0 = natural order (rddads DbSetOrder(0)). A missing + // map entry used to err() with default 5000 and poison B_BIG. + if (iid == 0) { + UNSIGNED32 rrc = AdsSetIndexOrder(ht, nullptr); + if (rrc != 0) return rrc; + ordered_tables_.erase(tid); + sync_engine_cursor(tid); + return openads::AE_SUCCESS; + } + auto iit = index_h_.find(iid); + if (iit == index_h_.end()) { + return openads::AE_NO_FILE_FOUND; + } + UNSIGNED32 rrc = AdsSetIndexOrderByHandle(ht, iit->second); + if (rrc != 0) { + // Self-heal (B_BIG storm 700): a server-side silent-overwrite + // CREATE INDEX or AdsCloseAllIndexes re-creates the ABI binding + // under a fresh handle while index_h_[iid] still holds the dead + // one -> 5000 "index not bound to table". Re-resolve the tag's + // CURRENT binding and retry once; on success refresh index_h_. + std::string tag; + if (auto tit = index_tag_.find(iid); tit != index_tag_.end()) + tag = tit->second; + if (!tag.empty()) { + std::vector tb(tag.size() + 1); + std::memcpy(tb.data(), tag.data(), tag.size()); + ADSHANDLE h_fresh = 0; + if (AdsGetIndexHandle(ht, tb.data(), &h_fresh) == 0 && + h_fresh != 0 && h_fresh != iit->second) { + WTRACE("[wire] SetOrder iid=%u stale handle, healed via tag '%s'\n", + iid, tag.c_str()); + rrc = AdsSetIndexOrderByHandle(ht, h_fresh); + if (rrc == 0) iit->second = h_fresh; + } + } + } + if (rrc != 0) return rrc; + ordered_tables_.insert(tid); + sync_engine_cursor(tid); + return openads::AE_SUCCESS; +} + +Session::FieldWriteResult Session::write_fields(std::uint32_t id, + openads::engine::Table* tbl, + const std::vector>& pairs) { + FieldWriteResult out; + if (tbl == nullptr) { out.code = openads::AE_INTERNAL_ERROR; return out; } + // When indexes live on the parallel ABI handle, write through + // AdsSetString so the bag is maintained (see AppendBlank). + if (auto hit = tbls_h_.find(id); hit != tbls_h_.end()) { + // The engine cursor is authoritative: navigation opcodes move only + // the engine table, the twin syncs lazily. Land the twin on the + // same row ONCE up front (not per field) — otherwise the 5035 + // write guard checks the wrong row's lock. + UNSIGNED32 cur = 0; + AdsGetRecordNum(hit->second, 0, &cur); + UNSIGNED32 eng = tbl->recno(); + if (eng != 0 && cur != eng) { + (void)AdsGotoRecord(hit->second, eng); + } + for (const auto& [fname, val] : pairs) { + std::vector fn(fname.begin(), fname.end()); + fn.push_back(0); + std::vector vv(val.begin(), val.end()); + // AdsSetString takes length; do not require NUL in value. + UNSIGNED32 rrc = AdsSetString( + hit->second, fn.data(), + vv.empty() ? reinterpret_cast(const_cast("")) + : vv.data(), + static_cast(val.size())); + if (rrc != 0) { out.code = rrc; return out; } + } + // Storm fix — no per-field flush; the record is made durable by + // DbCommit→FlushTable at the end of the append. + sync_engine_cursor(id); + return out; + } + + for (const auto& [fname, val] : pairs) { + std::int32_t fi = tbl->field_index(fname); + if (fi < 0) { out.column_missing = true; return out; } + const auto& fdesc = + tbl->field_descriptor(static_cast(fi)); + util::Result r; + auto fi_u = static_cast(fi); + switch (fdesc.type) { + case drivers::DbfFieldType::Logical: { + bool lv = !val.empty() && + (val[0] == '1' || val[0] == 'T' || val[0] == 't' || + val[0] == 'Y' || val[0] == 'y'); + r = tbl->set_field(fi_u, lv); + break; + } + case drivers::DbfFieldType::Integer: + case drivers::DbfFieldType::AutoInc: + case drivers::DbfFieldType::Double: + case drivers::DbfFieldType::ShortInt: + case drivers::DbfFieldType::Currency: + case drivers::DbfFieldType::AdtMoney: + case drivers::DbfFieldType::Time: + case drivers::DbfFieldType::Numeric: + try { + r = tbl->set_field(fi_u, std::stod(val)); + } catch (...) { + r = tbl->set_field(fi_u, val); + } + break; + default: + r = tbl->set_field(fi_u, val); + break; + } + if (!r) { out.code = 5035; return out; } + } + return out; +} + +void Session::append_open_warm_sections(std::vector& out, + std::uint32_t id, + openads::engine::Table* tbl) { + namespace Sec = openads::network::OpenTableAckSections; + struct Tlv { std::uint8_t tag = 0; std::vector bytes; }; + std::vector secs; + if (tbl != nullptr) { + // 1. schema — byte-identical to the DescribeTableAck engine + // branch (same mapper, same u8 name_LEN + u16 type + u32 length + // + u16 decimals layout the client already parses). + Tlv sch; + sch.tag = Sec::kSchema; + auto nf = static_cast(tbl->field_count()); + sch.bytes.push_back(static_cast( nf & 0xFFu)); + sch.bytes.push_back(static_cast((nf >> 8) & 0xFFu)); + for (std::uint16_t i = 0; i < nf; ++i) { + const auto& fd = tbl->field_descriptor(i); + auto name_len = static_cast(fd.name.size() & 0xFFu); + sch.bytes.push_back(name_len); + sch.bytes.insert(sch.bytes.end(), + fd.name.begin(), + fd.name.begin() + name_len); + auto ftype = ads_type_for_wire(fd.type); + sch.bytes.push_back(static_cast( ftype & 0xFFu)); + sch.bytes.push_back(static_cast((ftype >> 8) & 0xFFu)); + std::uint32_t flen = fd.length; + std::uint16_t fdec = fd.decimals; + sch.bytes.push_back(static_cast( flen & 0xFFu)); + sch.bytes.push_back(static_cast((flen >> 8) & 0xFFu)); + sch.bytes.push_back(static_cast((flen >> 16) & 0xFFu)); + sch.bytes.push_back(static_cast((flen >> 24) & 0xFFu)); + sch.bytes.push_back(static_cast( fdec & 0xFFu)); + sch.bytes.push_back(static_cast((fdec >> 8) & 0xFFu)); + } + secs.push_back(std::move(sch)); + // 2. first row — the exact positioning + trailer + lookahead an + // explicit GotoTop would have produced (same goto_top call the + // GotoTop handler issues on the natural-order path, same pack + // machinery incl. the prefetch-cap gate inside next_lookahead). + // The return is ignored exactly like there: an empty table packs + // has_row=0, which the client reads as unpositioned. + (void)tbl->goto_top(); + Frame tmp; + pack_row_trailer(tmp, id, next_lookahead(id)); + Tlv row; + row.tag = Sec::kFirstRow; + row.bytes = std::move(tmp.payload); + secs.push_back(std::move(row)); + } + out.push_back(static_cast(secs.size())); + for (auto& s : secs) { + out.push_back(s.tag); + std::uint32_t n = static_cast(s.bytes.size()); + out.push_back(static_cast( n & 0xFFu)); + out.push_back(static_cast((n >> 8) & 0xFFu)); + out.push_back(static_cast((n >> 16) & 0xFFu)); + out.push_back(static_cast((n >> 24) & 0xFFu)); + out.insert(out.end(), s.bytes.begin(), s.bytes.end()); + } +} + +DispatchResult Session::dispatch(const Frame& f) { + Frame reply; + WTRACE("[wire] op=%u\n", (unsigned)f.opcode); + if (wire_trace_on() && f.payload.size() >= 4) { + std::uint32_t tid0 = read_u32_le(f.payload.data()); + if (ordered_tables_.count(tid0)) { + if (auto hit0 = tbls_h_.find(tid0); hit0 != tbls_h_.end()) { + UNSIGNED16 b0 = 9, e0 = 9; + AdsAtBOF(hit0->second, &b0); AdsAtEOF(hit0->second, &e0); + WTRACE("[wire] op=%u id=%u twin-in(bof=%u eof=%u)\n", + (unsigned)f.opcode, tid0, (unsigned)b0, (unsigned)e0); + } + } + } + // M12.22 — break the read-ahead run before the handler runs, in one place + // instead of a reset call in each of ~20 handlers. + // + // RCB 07/15/2026: the leading u32 is the affected object's id, but for the + // index-scoped ops (break_key_is_index_id) that is an INDEX id and has to be + // resolved to its table first — prefetch_depth_ is keyed by table. Without + // this, a Seek/SetScope/ClearScope on a table left its ramp climbing (an + // index id is never a live table id, so the reset hit an absent key and did + // nothing). If an index id can't be resolved, there is no table to reset. + if (breaks_prefetch_run(f.opcode) && f.payload.size() >= 4) { + std::uint32_t id = read_u32_le(f.payload.data()); + if (break_key_is_index_id(f.opcode)) { + auto it = index_table_.find(id); + id = (it != index_table_.end()) ? it->second : 0; + } + if (id != 0) reset_lookahead(id); + } + switch (f.opcode) { + case Opcode::Hello: { + reply.opcode = Opcode::HelloAck; + // Real build version, not a hardcoded protocol string: this is + // the only way a client (or a support engineer) can prove which + // serverd binary is actually answering — "the fix didn't help" + // reports keep turning out to be an old serverd still running. + // Any pre-1.8.14 server answers the literal "openads/0.3.2". +#ifdef OPENADS_VERSION_STR + std::string v = "openads/" OPENADS_VERSION_STR; +#else + std::string v = "openads/unknown"; +#endif + reply.payload.assign(v.begin(), v.end()); + break; + } + case Opcode::Connect: { + // M12.9 — Connect payload format: + // [u16 dlen][dir][u16 ulen][user][u16 plen][password] + // All three lengths are required; user/password may be + // empty when the server doesn't require auth. + const auto& pl = f.payload; + std::string dir, user, pw; + std::size_t p = 0; + auto readlen = [&](std::uint16_t& out)->bool { + if (p + 2 > pl.size()) return false; + out = static_cast( + static_cast(pl[p]) | + (static_cast(pl[p+1]) << 8)); + p += 2; return true; + }; + auto readstr = [&](std::string& out, std::uint16_t n)->bool { + if (p + n > pl.size()) return false; + out.assign(reinterpret_cast(pl.data() + p), n); + p += n; return true; + }; + std::uint16_t dl=0, ul=0, pwl=0; + if (!readlen(dl) || !readstr(dir, dl) || + !readlen(ul) || !readstr(user, ul) || + !readlen(pwl) || !readstr(pw, pwl)) { + reply = err("Connect: bad payload"); + break; + } + // M12.21 option C — optional trailing [u32 LE caps]. + // Absent for pre-M12.21 clients (p == pl.size()). + if (p + 4 <= pl.size()) { + std::uint32_t caps = + static_cast(pl[p]) | + (static_cast(pl[p + 1]) << 8) | + (static_cast(pl[p + 2]) << 16) | + (static_cast(pl[p + 3]) << 24); + client_prefetch_ok_ = + (caps & openads::network::kCapPrefetchConsume) != 0; + // RCB 07/15/2026: M12.25 — backward (PgUp) prefetch is a + // separate opt-in; a client that only set kCapPrefetchConsume + // cannot drain a backward block (see kCapPrefetchBackward). + client_prefetch_back_ok_ = + (caps & openads::network::kCapPrefetchBackward) != 0; + // M12.x — client sends [u16 mode] prefix on OpenTable. + client_open_table_mode_ok_ = + (caps & openads::network::kCapOpenTableMode) != 0; + } + if (srv_->require_auth()) { + std::lock_guard clk(srv_->creds_mu_); + auto cit = srv_->creds_.find(user); + if (cit == srv_->creds_.end() || cit->second != pw) { + reply = err("Connect: authentication failed", + openads::AE_LOGIN_FAILED); + break; + } + } + // Resolve client paths under one of the server's data roots and + // reject traversal attempts (e.g. "../../outside"). --data (or + // the ini `data=` key) may list several roots separated by ';' + // so one server can serve DDs living under different + // drives/shares; the client path just has to fall under any one + // of them. + // + // Multi-port: when the client connected on an extra port, use + // that port's data_dir instead of the global one. + // + // --legacy-paths: route through platform::resolve_client_path + // instead — case-insensitive, drive-letter-ignoring prefix + // strip ("C:/TEMP" maps onto root "c:\temp"), drive fold for + // foreign absolute paths ("E:\CLIENT" -> "/CLIENT"), and + // an empty/drive-root dir maps to the first root itself, so a + // legacy ERP connect string needs no server-side spelling. + const std::string& effective_data_dir = + default_data_dir_.empty() ? srv_->data_dir_ : default_data_dir_; + std::string resolved = dir; + if (!effective_data_dir.empty()) { + auto roots = openads::platform::split_data_roots(effective_data_dir); + std::optional jail; + if (srv_->legacy_paths()) { + jail = openads::platform::resolve_client_path(roots, dir); + } else { + jail = openads::platform::resolve_under_any_root(roots, dir); + } + if (!jail) { + reply = err("Connect: path outside data directory", + openads::AE_ACCESS_DENIED); + break; + } + resolved = *jail; + } + auto co = openads::session::Connection::open(resolved); + if (!co) { + reply = err("Connect: connection open failed", + static_cast(co.error().code)); + break; + } + // RCB 06/30/2026: Remote Connect opens the engine Connection + // directly, bypassing local AdsConnect60. Mirror DD login handling + // here so the session username, permissions, and later lazy ABI + // connection all represent the same authenticated DD user. + if (co.value().has_dd()) { + auto* dd = co.value().dd(); + // Logins-disabled: a stricter, all-connections-rejected gate + // than LOG_IN_REQUIRED below — even a valid user/password + // normally can't connect while this is set. Reads the STABLE + // storage key "prop_16" (SP_MODIFYDATABASE numbering); the + // SAP ABI id is ADS_DD_LOGINS_DISABLED (113), translated by + // ace_exports' db_prop_storage_key. + // + // Admin bypass: without this, setting the flag would be a + // one-way door — nobody, not even the admin trying to undo + // it, could ever reconnect to flip it back off. See + // mgmt::is_admin_bypass / mgmt::kAdminBypassUser, mirrored + // in ace_exports.cpp's AdsConnect for local connections. + std::string logins_disabled = dd->get_db_property("prop_16"); + bool disabled_raw_zero = (logins_disabled.size() >= 2 && + static_cast(logins_disabled[0]) == 0 && + static_cast(logins_disabled[1]) == 0); + bool logins_are_disabled = (!logins_disabled.empty() && + logins_disabled != "0" && logins_disabled != "False" && + !disabled_raw_zero); + if (logins_are_disabled && + !openads::mgmt::is_admin_bypass(dd, user, pw)) { + reply = err("Connect: logins are disabled for this dictionary", + openads::AE_LOGIN_FAILED); + break; + } + + std::string login_req = dd->get_db_property("prop_5"); + bool is_raw_zero = (login_req.size() >= 2 && + static_cast(login_req[0]) == 0 && + static_cast(login_req[1]) == 0); + bool require_login = (!login_req.empty() && + login_req != "0" && login_req != "False" && !is_raw_zero); + if (require_login) { + if (user.empty()) { + reply = err("Connect: login required but no username supplied", + openads::AE_LOGIN_FAILED); + break; + } + if (!dd->has_user(user)) { + reply = err("Connect: unknown user", + openads::AE_LOGIN_FAILED); + break; + } + std::string stored = dd->get_user_property(user, "prop_1101"); + if (stored != pw) { + reply = err("Connect: invalid password", + openads::AE_LOGIN_FAILED); + break; + } + } + if (!user.empty()) { + co.value().set_username(user); + if (dd->has_any_acl()) dd->build_perm_cache(user); + } + } + sess_conn_ = std::make_unique( + std::move(co).value()); + // M12.34 — propagate server identity for replication loop prevention. + sess_conn_->set_origin_id(srv_->server_id()); + // --legacy-paths: the session's table opens must resolve + // client-absolute paths the same way the Connect jail above + // accepted the connect dir. + sess_conn_->set_legacy_paths(srv_->legacy_paths()); + sess_conn_->set_remote_server(true); + session_user_ = user; + session_password_ = pw; + srv_->set_session_user(sid_, user, dir); + reply.opcode = Opcode::ConnectAck; + std::string ackmsg = "connected:" + dir; + reply.payload.assign(ackmsg.begin(), ackmsg.end()); + // Server caps echo (write coalescing): trailing [u32 LE]. + // Old clients ignore the ack payload entirely (opcode-only + // check), so this is backward compatible; new clients match + // the "connected:" echo against their requested dir + // before trusting the trailing word (see connect_with_transport). + { + const std::uint32_t scaps = + openads::network::kCapSetFieldsBatch | + openads::network::kCapFlushInCloseAll | + openads::network::kCapNavOrderFuse | + openads::network::kCapFlushTableDurable | + openads::network::kCapNavBoundaryPair; + reply.payload.push_back( + static_cast( scaps & 0xFFu)); + reply.payload.push_back( + static_cast((scaps >> 8) & 0xFFu)); + reply.payload.push_back( + static_cast((scaps >> 16) & 0xFFu)); + reply.payload.push_back( + static_cast((scaps >> 24) & 0xFFu)); + } + break; + } + case Opcode::Disconnect: { + cleanup(); + return { std::nullopt, true }; + } + case Opcode::BeginTransaction: { + if (!sess_conn_) { + reply = err("BeginTransaction: not connected", + openads::AE_NO_CONNECTION); + break; + } + auto r = sess_conn_->begin_tx(); + if (!r) { + reply = err("BeginTransaction: " + r.error().message, + static_cast(r.error().code)); + break; + } + reply.opcode = Opcode::BeginTransactionAck; + break; + } + case Opcode::CommitTransaction: { + if (!sess_conn_) { + reply = err("CommitTransaction: not connected", + openads::AE_NO_CONNECTION); + break; + } + auto r = sess_conn_->commit_tx(); + if (!r) { + reply = err("CommitTransaction: " + r.error().message, + static_cast(r.error().code)); + break; + } + reply.opcode = Opcode::CommitTransactionAck; + break; + } + case Opcode::RollbackTransaction: { + if (!sess_conn_) { + reply = err("RollbackTransaction: not connected", + openads::AE_NO_CONNECTION); + break; + } + auto r = sess_conn_->rollback_tx(); + if (!r) { + reply = err("RollbackTransaction: " + r.error().message, + static_cast(r.error().code)); + break; + } + reply.opcode = Opcode::RollbackTransactionAck; + break; + } + case Opcode::FindRecord: { + if (!sess_conn_) { + reply = err("FindRecord: not connected", + openads::AE_NO_CONNECTION); + break; + } + if (f.payload.size() < 6) { + reply = err("FindRecord: bad payload"); break; + } + { + std::uint32_t id = read_u32_le(f.payload.data()); + std::uint16_t nident = static_cast( + static_cast(f.payload[4]) | + (static_cast(f.payload[5]) << 8)); + auto it = tbls_.find(id); + if (it == tbls_.end()) { + reply = err("FindRecord: bad table id"); break; + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { + reply = err("FindRecord: lookup failed"); break; + } + std::size_t off = 6; + std::vector> ident; + for (std::uint16_t i = 0; i < nident; ++i) { + if (off + 4 > f.payload.size()) { + reply = err("FindRecord: truncated identity"); break; + } + std::uint16_t nlen = static_cast( + static_cast(f.payload[off]) | + (static_cast(f.payload[off + 1]) << 8)); + off += 2; + if (off + nlen > f.payload.size()) { + reply = err("FindRecord: truncated name"); break; + } + std::string name(f.payload.begin() + + static_cast(off), + f.payload.begin() + + static_cast(off + nlen)); + off += nlen; + if (off + 2 > f.payload.size()) { + reply = err("FindRecord: truncated vlen"); break; + } + std::uint16_t vlen = static_cast( + static_cast(f.payload[off]) | + (static_cast(f.payload[off + 1]) << 8)); + off += 2; + if (off + vlen > f.payload.size()) { + reply = err("FindRecord: truncated value"); break; + } + std::string val(f.payload.begin() + static_cast(off), + f.payload.begin() + static_cast(off + vlen)); + off += vlen; + ident.emplace_back(std::move(name), std::move(val)); + } + if (reply.opcode == Opcode::Error) break; + // Sequential scan matching local find_by_identity logic. + if (auto gr = tbl->goto_top(); !gr) { + reply.opcode = Opcode::FindRecordAck; + reply.payload.assign(4, 0); + break; + } + std::uint32_t found = 0; + while (!tbl->eof()) { + bool match = true; + for (auto& [n, v] : ident) { + auto idx = tbl->field_index(n); + if (idx < 0) { match = false; break; } + auto val = tbl->read_field(static_cast(idx)); + if (!val) { match = false; break; } + if (val.value().as_string != v) { match = false; break; } + } + if (match) { found = tbl->recno(); break; } + if (auto sr = tbl->skip(1); !sr) break; + } + reply.opcode = Opcode::FindRecordAck; + reply.payload.resize(4); + reply.payload[0] = static_cast( found & 0xFFu); + reply.payload[1] = static_cast((found >> 8) & 0xFFu); + reply.payload[2] = static_cast((found >> 16) & 0xFFu); + reply.payload[3] = static_cast((found >> 24) & 0xFFu); + } + break; + } + case Opcode::OpenTable: { + if (!sess_conn_) { + reply = err("OpenTable: not connected", + openads::AE_NO_CONNECTION); + break; + } + // Iterator-based ctor: payload.data() may be nullptr when empty, + // and std::string(nullptr, 0) is UB. + std::string rel; + auto open_mode = openads::engine::OpenMode::Shared; + if (client_open_table_mode_ok_ && f.payload.size() >= 2) { + // M12.x extended payload: [u16 mode][table_name_bytes] + std::uint16_t mode_u16 = static_cast( + static_cast(f.payload[0]) | + (static_cast(f.payload[1]) << 8)); + open_mode = static_cast(mode_u16); + rel.assign(f.payload.begin() + 2, f.payload.end()); + } else { + rel.assign(f.payload.begin(), f.payload.end()); + } + // M12.33 — strip a full tcp:// URI prefix that legacy Delphi + // TAdsTable components embed in the table name. arc32 sends + // "tcp://host:port/C:/data/dir\table.dbf" instead of bare + // "table.dbf"; the server must strip the URI to resolve + // relative to its data root. + if (rel.size() > 8 && + (rel.rfind("tcp://", 0) == 0 || rel.rfind("TCP://", 0) == 0)) { + auto last_sep = rel.find_last_of("/\\"); + if (last_sep != std::string::npos && last_sep > 6) { + std::string stripped = rel.substr(last_sep + 1); + if (!stripped.empty()) rel = std::move(stripped); + } + } + auto th = sess_conn_->open_table(rel, + openads::engine::TableType::Cdx, + open_mode); + if (!th) { + std::fprintf(stderr, "[srv] OpenTable FAILED rel='%s' code=%d msg='%s'\n", + rel.c_str(), th.error().code, + th.error().message.c_str()); + reply = err("OpenTable: open failed", + static_cast(th.error().code)); + break; + } + std::uint32_t id = next_id_++; + // mtfix11 - enforce ADS_EXCLUSIVE across wire sessions (SAP + // semantics; the drivers alone treat Exclusive as a plain + // open, so a reindex/pack exclusive hold never kept a second + // instance's opens out). Open-then-register keeps the + // registry key canonical (the engine's resolved path); a + // denied open is closed again and answered with 7040 + // AE_FILE_IN_USE - the same code this codebase maps Win32 + // sharing violations to. + if (auto* tbl = sess_conn_->lookup_table(th.value())) { + const std::string& canon = tbl->path(); + const bool excl = + (open_mode == openads::engine::OpenMode::Exclusive); + if (!canon.empty()) { + if (!srv_->try_register_open(sid_, canon, excl)) { + sess_conn_->close_table(th.value()); + reply = err("OpenTable: table in use exclusively", + 7040); + break; + } + tbl_open_reg_.emplace(id, std::make_pair(canon, excl)); + } + } + tbls_.emplace(id, th.value()); + tbl_open_paths_.emplace(id, rel); + srv_->add_session_table(sid_, +1, rel); + reply.opcode = Opcode::OpenTableAck; + write_u32_le(id, reply.payload); + // M-AOF.6 mirror: detect the production CDX/ADI on the server + // filesystem and include it in the ack so the client can skip + // the speculative AdsOpenIndex round-trip. We only STAT the + // file here — no ABI calls that could deadlock in the + // embedded-server case (the client holds s.mu while waiting + // for this ack). + { + auto* tbl = sess_conn_->lookup_table(th.value()); + std::string bag; + if (tbl) { + std::filesystem::path tp(tbl->path()); + std::string ext = tp.extension().string(); + for (auto& c : ext) + c = static_cast(std::tolower( + static_cast(c))); + std::vector bag_leaves; + if (ext == ".dbf") bag_leaves = {tp.stem().string() + ".cdx", + tp.stem().string() + ".z01"}; + else if (ext == ".adt") bag_leaves = {tp.stem().string() + ".adi"}; + for (const auto& bag_leaf : bag_leaves) { + std::error_code ec; + std::filesystem::path bagp = tp.parent_path() / bag_leaf; + if (!std::filesystem::exists(bagp, ec)) { + // Case-insensitive fallback (.CDX vs .cdx, .Z01 vs .z01). + std::string ci = openads::platform:: + resolve_case_insensitive(bagp.string()); + if (!ci.empty()) + bagp = std::filesystem::path(ci); + } + if (std::filesystem::exists(bagp, ec)) { + // Send the bag path relative to the connection + // root so subdirectory tables round-trip the + // correct production index (basename-only made + // AdsOpenIndex miss when table_dir != data root). + // NOTE: Vouch/ERP apps keep the CDX-format + // production bag as .z01 instead of + // .cdx — without this the client falls + // back to a speculative .cdx OpenIndex + // that always 5018s + writes ads_err.dbf. + bag = bag_leaf; + std::filesystem::path base(sess_conn_->data_dir()); + auto bag_rel = std::filesystem::relative(bagp, base, ec); + if (!ec && !bag_rel.empty() && bag_rel != ".") { + bag = bag_rel.generic_string(); + } + break; + } + } + } + // Always emit the bag field (empty when absent): old + // clients parse it the same way, and the sections below + // need a fixed anchor (see wire.h OpenTableAckSections). + { + auto bn = static_cast(bag.size()); + reply.payload.push_back( + static_cast( bn & 0xFFu)); + reply.payload.push_back( + static_cast((bn >> 8) & 0xFFu)); + reply.payload.insert(reply.payload.end(), + bag.begin(), bag.end()); + } + append_open_warm_sections(reply.payload, id, tbl); + } + break; + } + case Opcode::CloseTable: { + if (f.payload.size() < 4) { reply = err("CloseTable: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + auto cit = cursor_tbls_.find(id); + if (cit != cursor_tbls_.end()) { + // Drop the cached schema with the handle: AdsCloseTable frees + // the ADSHANDLE, and a later AdsOpenTable can hand the same + // value back for a different table. + abi_schema_.erase(cit->second); + (void)AdsCloseTable(cit->second); + cursor_tbls_.erase(cit); + srv_->add_session_table(sid_, -1); + reply.opcode = Opcode::CloseTableAck; + break; + } + auto it = tbls_.find(id); + if (it != tbls_.end()) { + sess_conn_->close_table(it->second); + tbls_.erase(it); + if (auto rit = tbl_open_reg_.find(id); + rit != tbl_open_reg_.end()) { + srv_->unregister_open(sid_, rit->second.first, + rit->second.second); + tbl_open_reg_.erase(rit); + } + std::string tname; + if (auto pit = tbl_open_paths_.find(id); pit != tbl_open_paths_.end()) + tname = pit->second; + srv_->add_session_table(sid_, -1, tname); + } + tbl_open_paths_.erase(id); + if (auto hit = tbls_h_.find(id); hit != tbls_h_.end()) { + abi_schema_.erase(hit->second); + // Close the shadow ABI handle, not just the map entry. + // Erasing alone leaks a full local open of the same + // .dbf/.cdx/.fpt until the session disconnects, so the + // server kept the files open after the client closed the + // table — any app that erases/renames/reopens-exclusive + // its work files right after closing them then blocks on + // files "in use" for as long as the connection lives. + (void)AdsCloseTable(hit->second); + tbls_h_.erase(hit); + } + // Index ids resolved through the shadow handle died with it + // (AdsCloseTable purges the table's index bindings); drop the + // session's entries so a stale id can't be re-used. + for (auto iit = index_table_.begin(); iit != index_table_.end(); ) { + if (iit->second == id) { + index_h_.erase(iit->first); + index_tag_.erase(iit->first); + iit = index_table_.erase(iit); + } else { + ++iit; + } + } + ordered_tables_.erase(id); + reply.opcode = Opcode::CloseTableAck; + break; + } + case Opcode::GotoTop: { + if (f.payload.size() < 4) { reply = err("GotoTop: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { + (void)AdsGotoTop(cit->second); + reply.opcode = Opcode::GotoTopAck; + pack_row_trailer(reply, id); + break; + } + // Fused nav+order: trailing [u8 0x01][u32 order_id] after + // the optional depth hint installs the order before + // navigating, collapsing SetOrder+GotoTop into one frame. + // Length-gated (old clients stop at byte 6); cursor tables + // above ignore it (their orders are query-fixed). + // mtfix12 R1: byte 6 is a flags byte on new clients — + // bit 0x01 fused order section (kCapNavOrderFuse, same + // wire shape as before: old clients send exactly 0x01), + // bit 0x02 boundary-pair request (kCapNavBoundaryPair). + bool fused_order = false; + bool want_pair = false; + if (f.payload.size() >= 7) { + const std::uint8_t fl = f.payload[6]; + want_pair = (fl & 0x02) != 0; + if ((fl & 0x01) != 0 && f.payload.size() >= 11) { + std::uint32_t oiid = read_u32_le(f.payload.data() + 7); + UNSIGNED32 oorc = install_table_order(id, oiid); + if (oorc != 0) { reply = err("SetOrder", oorc); break; } + fused_order = true; + } + } + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("GotoTop: bad table id"); break; + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("GotoTop: lookup failed"); break; } + // Ordered: navigate the ABI handle (it carries the order) and + // mirror the position onto the engine cursor pack_row_trailer + // reads from. Natural order: engine table directly. + ADSHANDLE hord = + ordered_tables_.count(id) ? ensure_abi_handle(id) : 0; + WTRACE("[wire] GotoTop id=%u hord=%llu\n", id, (unsigned long long)hord); + if (hord != 0) { + (void)AdsGotoTop(hord); + } else { + (void)tbl->goto_top(); + } + reply.opcode = Opcode::GotoTopAck; + // RCB 07/14/2026: M12.24 — warm the first page. A GotoTop is about + // as strong a "I am about to walk this table" signal as exists (a + // browse painting its first screen, or a scan loop starting), yet + // its ack used to carry the current row and nothing else, so the + // very first Skip after it was always cold and always cost a + // round-trip. Send the block with the row. + // + // Only GotoTop, deliberately. GotoBottom gets nothing: the block is + // a FORWARD walk and there is nothing after the last record — a + // warm GotoBottom needs BACKWARD lookahead, which is its own piece + // of work (P5). GotoRecord/Seek get nothing either, because + // relation navigation drives them once per parent row and would + // drag a child block over the wire every time for nothing. + // + // Depth comes from next_lookahead() like any other block, so it + // starts at the floor and honours AdsCacheRecords (an app that + // turned read-ahead off must not get a block dumped on it here). + // dispatch() has already reset the run for this table (GotoTop is + // in breaks_prefetch_run), so this is a fresh run at the floor and + // the following Skips ramp up from it. + std::uint16_t gt_hint = kPrefetchDepthAuto; + if (f.payload.size() >= 6) { + gt_hint = static_cast( + static_cast(f.payload[4]) | + (static_cast(f.payload[5]) << 8)); + } + pack_row_trailer(reply, id, next_lookahead(id, gt_hint)); + // Reposition truth rides after the trailer (see GotoRecord). + // mtfix12 R1: pair-requested acks carry an explicit + // [u8 ack_flags] first (bit 0x01 = bound has reccount) so + // the client parses section offsets deterministically. + if (want_pair) { + Frame bnd; + bnd.opcode = reply.opcode; + pack_bound_trailer(bnd, id); + reply.payload.push_back( + bnd.payload.size() >= 10 ? 1 : 0); + reply.payload.insert(reply.payload.end(), + bnd.payload.begin(), + bnd.payload.end()); + } else { + pack_bound_trailer(reply, id); + } + // Fused switch only: the app almost always asks this + // order's key count next (scrollbar setup), so certify it + // now ([u32] after the bound tail) instead of paying a + // frame for it. Order-scoped, like the wire GetKeyCount. + if (fused_order) { + UNSIGNED32 fkc = 0; + if (hord != 0) { + (void)AdsGetKeyCount(hord, 0, &fkc); + } else if (tbl != nullptr) { + fkc = tbl->record_count(); + } + reply.payload.push_back(static_cast( fkc & 0xFFu)); + reply.payload.push_back(static_cast((fkc >> 8) & 0xFFu)); + reply.payload.push_back(static_cast((fkc >> 16) & 0xFFu)); + reply.payload.push_back(static_cast((fkc >> 24) & 0xFFu)); + } + // mtfix12 R1: the opposite boundary's landing state, + // read and packed inside this same visit. + if (want_pair) { + pack_boundary_pair(reply, id, 1, hord, tbl); + } + // Sync AFTER packing — pack_row_trailer walks the ABI cursor + // through the block and restores it, so the engine cursor has to be + // anchored to where the ABI cursor finally lands (same reason as + // the ordered Skip path). + if (hord != 0) sync_engine_cursor(id); + break; + } + case Opcode::Skip: { + if (f.payload.size() < 8) { reply = err("Skip: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + std::int32_t step = static_cast( + read_u32_le(f.payload.data() + 4)); + // M12.21 option C — a forward Skip from a prefetch-capable client + // piggybacks a lookahead block; the client serves those rows + // locally and folds the consumed count back into the next wire + // step, so the server cursor never desyncs (the bug that shelved + // option B). Non-capable clients and non-forward steps get no + // lookahead, preserving the old behavior. + // + // M12.22 — the depth is no longer a flat 64. next_lookahead() + // ramps it per consecutive forward Skip on this table and any + // reposition/write resets the run (see breaks_prefetch_run), so a + // one-off Skip pays for a handful of rows instead of a full block. + // + // M12.23 — ...unless the client named a depth via AdsCacheRecords, + // which rides along as an OPTIONAL trailing [u16]. Absent (any + // pre-M12.23 client) reads as kPrefetchDepthAuto = "you decide". + std::uint16_t depth_hint = kPrefetchDepthAuto; + if (f.payload.size() >= 10) { + depth_hint = static_cast( + static_cast(f.payload[8]) | + (static_cast(f.payload[9]) << 8)); + } + // RCB 07/15/2026: M12.25 — direction from the step sign. Forward + // (>= 1) needs kCapPrefetchConsume; backward (<= -1) additionally + // needs kCapPrefetchBackward, because a forward-only client would + // mis-drain a backward block. step == 0 (a settle) gets no block. + const std::int8_t dir = (step >= 1) ? 1 : (step <= -1) ? -1 : 0; + WTRACE("[wire] Skip id=%u step=%d\n", id, (int)step); + const bool want_lookahead = + (dir == 1 && client_prefetch_ok_) || + (dir == -1 && client_prefetch_ok_ && client_prefetch_back_ok_); + const std::uint16_t lookahead = + want_lookahead ? next_lookahead(id, depth_hint, dir) : 0; + if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { + (void)AdsSkip(cit->second, step); + reply.opcode = Opcode::SkipAck; + pack_row_trailer(reply, id, lookahead, dir); + break; + } + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("Skip: bad table id"); break; + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("Skip: lookup failed"); break; } + // Ordered: skip on the ABI handle, which is where the order and + // any scope live. M12.22 — the lookahead block now rides along + // here too: pack_row_trailer resolves an ordered table to the same + // ABI handle, so it walks the INDEX, not natural record order. + // That was the one thing blocking read-ahead on the browse that + // actually matters (rddads skips on hOrdCurrent when an order is + // set), and it needed no wire change. + ADSHANDLE hord = + ordered_tables_.count(id) ? ensure_abi_handle(id) : 0; + if (hord != 0) { + (void)AdsSkip(hord, step); + { UNSIGNED16 _b = 9, _e = 9; AdsAtBOF(hord, &_b); AdsAtEOF(hord, &_e); + WTRACE("[wire] Skip twin hord bof=%u eof=%u\n", (unsigned)_b, (unsigned)_e); } + reply.opcode = Opcode::SkipAck; + pack_row_trailer(reply, id, lookahead, dir); + pack_bound_trailer(reply, id); + // RCB 07/14/2026: sync AFTER packing, not before (this call + // used to sit above the pack). pack_row_trailer walks the ABI + // cursor through the lookahead block and then restores it, so + // the engine cursor has to be re-anchored to where the ABI + // cursor FINALLY lands. Syncing first would anchor it to a + // position the pack is about to move away from. + sync_engine_cursor(id); + { UNSIGNED16 _b = 9, _e = 9; AdsAtBOF(hord, &_b); AdsAtEOF(hord, &_e); + WTRACE("[wire] Skip end id=%u twin bof=%u eof=%u\n", id, (unsigned)_b, (unsigned)_e); } + break; + } + (void)tbl->skip(step); + reply.opcode = Opcode::SkipAck; + pack_row_trailer(reply, id, lookahead, dir); + pack_bound_trailer(reply, id); + break; + } + case Opcode::GetField: { + if (f.payload.size() < 5) { reply = err("GetField: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + std::string fname(reinterpret_cast( + f.payload.data() + 4), + f.payload.size() - 4); + if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { + UNSIGNED8 fbuf[64] = {0}; + UNSIGNED8 out [4096] = {0}; + UNSIGNED32 cap = sizeof(out); + std::size_t n = std::min(fname.size(), + sizeof(fbuf) - 1); + std::memcpy(fbuf, fname.data(), n); + fbuf[n] = 0; + UNSIGNED32 rrc = AdsGetField(cit->second, fbuf, out, &cap, 0); + if (rrc != 0) { reply = err("GetField: cursor read failed"); break; } + reply.opcode = Opcode::GetFieldAck; + reply.payload.assign(out, out + cap); + break; + } + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("GetField: bad table id"); break; + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("GetField: lookup failed"); break; } + std::int32_t fi = tbl->field_index(fname); + if (fi < 0) { reply = err("GetField: column not found"); break; } + auto v = tbl->read_field(static_cast(fi)); + if (!v) { + if (v.error().code == + static_cast(openads::AE_NO_CURRENT_RECORD)) { + const auto& fd = + tbl->field_descriptor(static_cast(fi)); + std::string blank; + using FT = openads::drivers::DbfFieldType; + switch (fd.type) { + case FT::Logical: + blank = "F"; + break; + default: + blank.assign(fd.length > 0 ? fd.length : 1, ' '); + break; + } + reply.opcode = Opcode::GetFieldAck; + reply.payload.assign(blank.begin(), blank.end()); + break; + } + reply = err("GetField: read failed"); break; + } + reply.opcode = Opcode::GetFieldAck; + auto& sval = v.value().as_string; + reply.payload.assign(sval.begin(), sval.end()); + break; + } + case Opcode::GetRecordCount: { + if (f.payload.size() < 4) { reply = err("GetRecordCount: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { + UNSIGNED32 rc = 0; + AdsGetRecordCount(cit->second, 0, &rc); + reply.opcode = Opcode::GetRecordCountAck; + write_u32_le(rc, reply.payload); + break; + } + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("GetRecordCount: bad table id"); break; + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("GetRecordCount: lookup failed"); break; } + // Refresh the on-disk record count so concurrent appends by + // other connections are visible (multiuser coherence). + tbl->refresh_record_count_from_disk(); + std::uint32_t rc = tbl->record_count(); + reply.opcode = Opcode::GetRecordCountAck; + write_u32_le(rc, reply.payload); + break; + } + case Opcode::GetKeyCount: { + if (f.payload.size() < 4) { reply = err("GetKeyCount: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { + UNSIGNED32 kc = 0; + AdsGetRecordCount(cit->second, 0, &kc); + reply.opcode = Opcode::GetKeyCountAck; + write_u32_le(kc, reply.payload); + break; + } + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("GetKeyCount: bad table id"); break; } + ADSHANDLE ht = ensure_abi_handle(id); + if (ht != 0 && ordered_tables_.count(id)) { + UNSIGNED32 kc = 0; + UNSIGNED32 rrc = AdsGetKeyCount(ht, 0, &kc); + if (rrc == 0) { + reply.opcode = Opcode::GetKeyCountAck; + write_u32_le(kc, reply.payload); + break; + } + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("GetKeyCount: lookup failed"); break; } + std::uint32_t kc = tbl->record_count(); + reply.opcode = Opcode::GetKeyCountAck; + write_u32_le(kc, reply.payload); + break; + } + // M12.29 — server-side key number: position of current record in + // the active order's walk. Uses pos_of_recno_cached() on CDX → O(1), + // eliminating the O(n) remote_measure_keyno client-side walk that + // made TXBrowse:Refresh() take ~22 sec for 9500 records. + case Opcode::GetKeyNum: { + if (f.payload.size() < 4) { reply = err("GetKeyNum: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("GetKeyNum: bad table id"); break; } + ADSHANDLE ht = ensure_abi_handle(id); + if (ht != 0) { + UNSIGNED32 kn = 0; + UNSIGNED32 rrc = AdsGetKeyNum(ht, 0, &kn); + if (rrc == 0) { + reply.opcode = Opcode::GetKeyNumAck; + write_u32_le(kn, reply.payload); + break; + } + } + // Fallback: not positioned or no order → key number 0. + reply.opcode = Opcode::GetKeyNumAck; + write_u32_le(0u, reply.payload); + break; + } + case Opcode::AtEOF: { + if (f.payload.size() < 4) { reply = err("AtEOF: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { + UNSIGNED16 v = 0, vbof = 0; + AdsAtEOF(cit->second, &v); + AdsAtBOF(cit->second, &vbof); + WTRACE("[wire] AtEOF id=%u via twin -> %u\n", id, (unsigned)v); + reply.opcode = Opcode::AtEOFAck; + reply.payload.push_back(v != 0 ? 1 : 0); + // Twin flag: the BOF answer rides along ([u8 eof][u8 bof]) + // so the client skips rddads' inevitable follow-up AtBOF. + // Old clients read byte 0 and ignore the trailer. + reply.payload.push_back(vbof != 0 ? 1 : 0); + break; + } + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("AtEOF: bad table id"); break; + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("AtEOF: lookup failed"); break; } + // See AtBOF: ordered tables must answer from the ABI twin. + ADSHANDLE hord_eof = + ordered_tables_.count(id) ? ensure_abi_handle(id) : 0; + if (hord_eof != 0) { + UNSIGNED16 v = 0, v2 = 0; + AdsAtBOF(hord_eof, &v2); + AdsAtEOF(hord_eof, &v); + // Limbo rescue: if both bof and eof are true, reposition + // the twin to break the deadlock (same logic as pack_row_trailer). + if (v && v2) { + AdsGotoTop(hord_eof); + AdsAtBOF(hord_eof, &v2); AdsAtEOF(hord_eof, &v); + if (v && v2) AdsGotoBottom(hord_eof); + AdsAtEOF(hord_eof, &v); + // Twin must be re-read after the rescue reposition: + // v2 above is pre-rescue and would cache stale BOF. + AdsAtBOF(hord_eof, &v2); + WTRACE("[wire] AtEOF id=%u Limbo rescue -> %u\n", id, (unsigned)v); + } + WTRACE("[wire] AtEOF id=%u via ordered twin -> %u\n", id, (unsigned)v); + reply.opcode = Opcode::AtEOFAck; + reply.payload.push_back(v != 0 ? 1 : 0); + reply.payload.push_back(v2 != 0 ? 1 : 0); + break; + } + WTRACE("[wire] AtEOF id=%u engine eof=%d bof=%d\n", id, (int)tbl->eof(), (int)tbl->bof()); + reply.opcode = Opcode::AtEOFAck; + reply.payload.push_back(tbl->eof() ? 1 : 0); + reply.payload.push_back(tbl->bof() ? 1 : 0); + break; + } + // M12.14 — DescribeTable: serialize the schema in one + // round-trip so rddads' adsOpen field-iteration loop + // doesn't generate 5 × num_fields hops. + case Opcode::DescribeTable: { + if (f.payload.size() < 4) { + reply = err("DescribeTable: bad payload"); break; + } + std::uint32_t id = read_u32_le(f.payload.data()); + ADSHANDLE cur_h = 0; + openads::engine::Table* tbl = nullptr; + if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { + cur_h = cit->second; + } else { + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("DescribeTable: bad table id"); break; + } + tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { + reply = err("DescribeTable: lookup failed"); break; + } + } + reply.opcode = Opcode::DescribeTableAck; + if (cur_h != 0) { + UNSIGNED16 nf = 0; + AdsGetNumFields(cur_h, &nf); + reply.payload.push_back(static_cast(nf & 0xFFu)); + reply.payload.push_back(static_cast((nf >> 8) & 0xFFu)); + for (UNSIGNED16 i = 1; i <= nf; ++i) { + UNSIGNED8 nm[64] = {0}; + UNSIGNED16 cap = sizeof(nm); + AdsGetFieldName(cur_h, i, nm, &cap); + std::vector nbuf(cap + 1, 0); + std::memcpy(nbuf.data(), nm, cap); + UNSIGNED16 ftype = 0; + UNSIGNED32 flen = 0; + UNSIGNED16 fdec = 0; + AdsGetFieldType (cur_h, nbuf.data(), &ftype); + AdsGetFieldLength (cur_h, nbuf.data(), &flen); + AdsGetFieldDecimals(cur_h, nbuf.data(), &fdec); + reply.payload.push_back(static_cast(cap)); + reply.payload.insert(reply.payload.end(), + nm, nm + cap); + reply.payload.push_back(static_cast( ftype & 0xFFu)); + reply.payload.push_back(static_cast((ftype >> 8) & 0xFFu)); + write_u32_le(flen, reply.payload); + reply.payload.push_back(static_cast( fdec & 0xFFu)); + reply.payload.push_back(static_cast((fdec >> 8) & 0xFFu)); + } + } else { + // Engine branch: shared mapper (must agree byte-for-byte + // with the warm OpenTableAck schema section). + auto map_type = [](openads::drivers::DbfFieldType t) -> std::uint16_t { + return ads_type_for_wire(t); + }; + auto nf = static_cast(tbl->field_count()); + reply.payload.push_back(static_cast(nf & 0xFFu)); + reply.payload.push_back(static_cast((nf >> 8) & 0xFFu)); + for (std::uint16_t i = 0; i < nf; ++i) { + const auto& fd = tbl->field_descriptor(i); + std::uint8_t name_len = + static_cast(fd.name.size() & 0xFFu); + std::uint16_t ftype = map_type(fd.type); + std::uint32_t flen = fd.length; + std::uint16_t fdec = fd.decimals; + reply.payload.push_back(name_len); + reply.payload.insert(reply.payload.end(), + fd.name.begin(), + fd.name.begin() + name_len); + reply.payload.push_back(static_cast( ftype & 0xFFu)); + reply.payload.push_back(static_cast((ftype >> 8) & 0xFFu)); + write_u32_le(flen, reply.payload); + reply.payload.push_back(static_cast( fdec & 0xFFu)); + reply.payload.push_back(static_cast((fdec >> 8) & 0xFFu)); + } + } + break; + } + case Opcode::AtBOF: { + if (f.payload.size() < 4) { reply = err("AtBOF: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + WTRACE("[wire] AtBOF id=%u\n", id); + if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { + UNSIGNED16 v = 0, veof = 0; + AdsAtBOF(cit->second, &v); + AdsAtEOF(cit->second, &veof); + WTRACE("[wire] AtBOF id=%u via twin -> %u\n", id, (unsigned)v); + reply.opcode = Opcode::AtBOFAck; + reply.payload.push_back(v != 0 ? 1 : 0); + // Twin flag: [u8 bof][u8 eof] (see AtEOF). + reply.payload.push_back(veof != 0 ? 1 : 0); + break; + } + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("AtBOF: bad table id"); break; + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("AtBOF: lookup failed"); break; } + // Ordered tables navigate on the ABI twin (it carries the order + // and any scope); the engine cursor is only a mirrored recno and + // can sit with bof+eof both set after a scope-end sync — answer + // from the twin, like the GotoTop/Skip handlers do. Fixes the + // BOF+EOF-both-true answer that made rddads' DBOI_POSITION + // (OrdKeyGoto past the scoped key count) report Bof()=.T. and + // xBrowse paint a phantom duplicate row (Tim Stone, 1-record + // scope). + ADSHANDLE hord_bof = + ordered_tables_.count(id) ? ensure_abi_handle(id) : 0; + if (hord_bof != 0) { + UNSIGNED16 v = 0, v2 = 0; + AdsAtBOF(hord_bof, &v); + AdsAtEOF(hord_bof, &v2); + // Limbo rescue: if both bof and eof are true, reposition + // the twin to break the deadlock (same logic as pack_row_trailer). + if (v && v2) { + AdsGotoTop(hord_bof); + AdsAtBOF(hord_bof, &v); AdsAtEOF(hord_bof, &v2); + if (v && v2) AdsGotoBottom(hord_bof); + AdsAtBOF(hord_bof, &v); + // Twin must be re-read after the rescue reposition + // (see AtEOF). + AdsAtEOF(hord_bof, &v2); + WTRACE("[wire] AtBOF id=%u Limbo rescue -> %u\n", id, (unsigned)v); + } + WTRACE("[wire] AtBOF id=%u via ordered twin -> %u\n", id, (unsigned)v); + reply.opcode = Opcode::AtBOFAck; + reply.payload.push_back(v != 0 ? 1 : 0); + reply.payload.push_back(v2 != 0 ? 1 : 0); + break; + } + WTRACE("[wire] AtBOF id=%u engine bof=%d eof=%d\n", id, (int)tbl->bof(), (int)tbl->eof()); + reply.opcode = Opcode::AtBOFAck; + reply.payload.push_back(tbl->bof() ? 1 : 0); + reply.payload.push_back(tbl->eof() ? 1 : 0); + break; + } + case Opcode::GetRecordNum: { + if (f.payload.size() < 4) { reply = err("GetRecordNum: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { + UNSIGNED32 rn = 0; + AdsGetRecordNum(cit->second, 0, &rn); + reply.opcode = Opcode::GetRecordNumAck; + write_u32_le(rn, reply.payload); + break; + } + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("GetRecordNum: bad table id"); break; + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("GetRecordNum: lookup failed"); break; } + reply.opcode = Opcode::GetRecordNumAck; + write_u32_le(tbl->recno(), reply.payload); + break; + } + case Opcode::IsRecordDeleted: { + if (f.payload.size() < 4) { reply = err("IsRecordDeleted: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { + UNSIGNED16 v = 0; + AdsIsRecordDeleted(cit->second, &v); + reply.opcode = Opcode::IsRecordDeletedAck; + reply.payload.push_back(v != 0 ? 1 : 0); + break; + } + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("IsRecordDeleted: bad table id"); break; + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("IsRecordDeleted: lookup failed"); break; } + reply.opcode = Opcode::IsRecordDeletedAck; + reply.payload.push_back(tbl->is_deleted() ? 1 : 0); + break; + } + case Opcode::GotoBottom: { + if (f.payload.size() < 4) { reply = err("GotoBottom: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { + AdsGotoBottom(cit->second); + reply.opcode = Opcode::GotoBottomAck; + pack_row_trailer(reply, id); + break; + } + // Fused nav+order: trailing [u8 0x01][u32 order_id]. + // (GotoBottom carries no depth hint, so the section starts + // at byte 4.) + // mtfix12 R1: byte 4 is a flags byte on new clients (see + // GotoTop): bit 0x01 fused order section, bit 0x02 + // boundary-pair request. + bool fused_order = false; + bool want_pair = false; + if (f.payload.size() >= 5) { + const std::uint8_t fl = f.payload[4]; + want_pair = (fl & 0x02) != 0; + if ((fl & 0x01) != 0 && f.payload.size() >= 9) { + std::uint32_t oiid = read_u32_le(f.payload.data() + 5); + UNSIGNED32 oorc = install_table_order(id, oiid); + if (oorc != 0) { reply = err("SetOrder", oorc); break; } + fused_order = true; + } + } + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("GotoBottom: bad table id"); break; + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("GotoBottom: lookup failed"); break; } + ADSHANDLE hord = + ordered_tables_.count(id) ? ensure_abi_handle(id) : 0; + if (hord != 0) { + (void)AdsGotoBottom(hord); + sync_engine_cursor(id); + } else { + auto rb = tbl->goto_bottom(); + if (!rb) { + reply = err("GotoBottom: " + rb.error().message); + break; + } + } + reply.opcode = Opcode::GotoBottomAck; + pack_row_trailer(reply, id); + if (want_pair) { + Frame bnd; + bnd.opcode = reply.opcode; + pack_bound_trailer(bnd, id); + reply.payload.push_back( + bnd.payload.size() >= 10 ? 1 : 0); + reply.payload.insert(reply.payload.end(), + bnd.payload.begin(), + bnd.payload.end()); + } else { + pack_bound_trailer(reply, id); + } + // Fused switch only: certify the new order's key count + // (see GotoTop). + if (fused_order) { + UNSIGNED32 fkc = 0; + if (hord != 0) { + (void)AdsGetKeyCount(hord, 0, &fkc); + } else if (tbl != nullptr) { + fkc = tbl->record_count(); + } + reply.payload.push_back(static_cast( fkc & 0xFFu)); + reply.payload.push_back(static_cast((fkc >> 8) & 0xFFu)); + reply.payload.push_back(static_cast((fkc >> 16) & 0xFFu)); + reply.payload.push_back(static_cast((fkc >> 24) & 0xFFu)); + } + if (want_pair) { + pack_boundary_pair(reply, id, 2, hord, tbl); + } + break; + } + // M12.15 — info / lock / maintenance / AOF. + // + // All these handlers share the same shape: + // payload[0..3] = table id (client-side) + // reply payload = answer (or empty for void) + // For the local-table branch (sess_conn_-owned engine + // Tables) the call lands on Table::* methods directly. + // Cursor handles (from ExecuteSQL) route through the + // matching ABI entry point, preserving the wire/ABI + // symmetry. + case Opcode::IsFound: { + if (f.payload.size() < 4) { reply = err("IsFound: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { + UNSIGNED16 v = 0; + AdsIsFound(cit->second, &v); + reply.opcode = Opcode::IsFoundAck; + reply.payload.push_back(v != 0 ? 1 : 0); + break; + } + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("IsFound: bad table id"); break; + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("IsFound: lookup failed"); break; } + reply.opcode = Opcode::IsFoundAck; + reply.payload.push_back(tbl->last_seek_found() ? 1 : 0); + break; + } + case Opcode::RefreshRecord: { + if (f.payload.size() < 4) { reply = err("RefreshRecord: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { + AdsRefreshRecord(cit->second); + reply.opcode = Opcode::RefreshRecordAck; + break; + } + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("RefreshRecord: bad table id"); break; + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("RefreshRecord: lookup failed"); break; } + // Force a re-load from disk by re-positioning to the + // current recno. + auto rb = tbl->goto_record(tbl->recno()); + if (!rb) { reply = err("RefreshRecord: " + rb.error().message); break; } + reply.opcode = Opcode::RefreshRecordAck; + // The re-read row rides back with the ack (row trailer), + // plus position truth (bound tail), so the caller's + // follow-up reads serve locally. Length-gated as usual. + pack_row_trailer(reply, id); + pack_bound_trailer(reply, id); + break; + } + case Opcode::GetTableType: { + if (f.payload.size() < 4) { reply = err("GetTableType: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { + UNSIGNED16 v = 0; + AdsGetTableType(cit->second, &v); + reply.opcode = Opcode::GetTableTypeAck; + reply.payload.push_back(static_cast( v & 0xFFu)); + reply.payload.push_back(static_cast((v >> 8) & 0xFFu)); + break; + } + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("GetTableType: bad table id"); break; + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("GetTableType: lookup failed"); break; } + std::uint16_t v = ADS_CDX; + std::filesystem::path p(tbl->path()); + std::string ext = p.extension().string(); + for (auto& c : ext) c = static_cast( + std::tolower(static_cast(c))); + if (ext == ".adt") v = ADS_ADT; + reply.opcode = Opcode::GetTableTypeAck; + reply.payload.push_back(static_cast( v & 0xFFu)); + reply.payload.push_back(static_cast((v >> 8) & 0xFFu)); + break; + } + case Opcode::GetRecordLength: { + if (f.payload.size() < 4) { reply = err("GetRecordLength: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { + UNSIGNED32 v = 0; + AdsGetRecordLength(cit->second, &v); + reply.opcode = Opcode::GetRecordLengthAck; + write_u32_le(v, reply.payload); + break; + } + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("GetRecordLength: bad table id"); break; + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("GetRecordLength: lookup failed"); break; } + std::uint32_t rl = tbl->driver() + ? tbl->driver()->record_length() : 0; + reply.opcode = Opcode::GetRecordLengthAck; + write_u32_le(rl, reply.payload); + break; + } + case Opcode::GetNumIndexes: { + if (f.payload.size() < 4) { reply = err("GetNumIndexes: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { + UNSIGNED16 v = 0; + AdsGetNumIndexes(cit->second, &v); + reply.opcode = Opcode::GetNumIndexesAck; + reply.payload.push_back(static_cast( v & 0xFFu)); + reply.payload.push_back(static_cast((v >> 8) & 0xFFu)); + break; + } + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("GetNumIndexes: bad table id"); break; + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("GetNumIndexes: lookup failed"); break; } + // Indexes are opened on the ABI handle (incl. the production + // CDX auto-opened there), not the engine table — count via the + // ABI handle so OrdCount() / DBOI_ORDERCOUNT is non-zero + // remotely. Falls back to the engine table's view if no ABI + // handle has been promoted yet. + std::uint16_t n = 0; + if (ADSHANDLE ht = ensure_abi_handle(id); ht != 0) { + (void)AdsGetNumIndexes(ht, &n); + } else { + n = static_cast(tbl->all_indexes().size()); + } + reply.opcode = Opcode::GetNumIndexesAck; + reply.payload.push_back(static_cast( n & 0xFFu)); + reply.payload.push_back(static_cast((n >> 8) & 0xFFu)); + break; + } + case Opcode::GetLastAutoinc: { + if (f.payload.size() < 4) { reply = err("GetLastAutoinc: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + std::uint32_t v = 0; + if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { + AdsGetLastAutoinc(cit->second, &v); + } + // Local-table path: not exposed at engine level; + // return 0. Same as the local AdsGetLastAutoinc + // fallback when the column isn't autoinc. + reply.opcode = Opcode::GetLastAutoincAck; + write_u32_le(v, reply.payload); + break; + } + case Opcode::GetLastTableUpdate: { + if (f.payload.size() < 4) { reply = err("GetLastTableUpdate: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + std::uint32_t packed = 0; + if (cursor_tbls_.find(id) == cursor_tbls_.end()) { + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("GetLastTableUpdate: bad table id"); break; + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("GetLastTableUpdate: lookup failed"); break; } + if (tbl->driver()) { + std::uint8_t b[3] = {0, 0, 0}; + auto got = tbl->driver()->file().read_at(1, b, sizeof(b)); + if (got && got.value() >= sizeof(b)) { + packed = (static_cast(1900 + b[0]) << 16) | + (static_cast(b[1]) << 8) | + static_cast(b[2]); + } + } + } + reply.opcode = Opcode::GetLastTableUpdateAck; + write_u32_le(packed, reply.payload); + break; + } + // Record locks route through the parallel ABI handle when one + // exists (M12.16 dual-handle) so they land on the same Table + // instance that appends/writes go through; otherwise they use + // the engine table from tbls_ with a non-blocking retry loop. + case Opcode::LockRecord: + case Opcode::UnlockRecord: { + if (f.payload.size() < 8) { reply = err("Lock: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + std::uint32_t rn = read_u32_le(f.payload.data() + 4); + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("Lock: bad table id"); break; + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("Lock: lookup failed"); break; } + // ACE convention: recno 0 = the current record. The engine + // cursor is the authoritative position (write opcodes sync the + // ABI twin to it before guarding), so translate here or the + // lock lands on nonexistent record 0 and the write guard + // correctly rejects the later write with 5035. + if (rn == 0) rn = tbl->recno(); + openads::mgmt::set_current_lock_owner( + session_user_.empty() ? "(anonymous)" : session_user_, + srv_->conn_no_for_session(sid_)); + // M12.16 dual-handle: appends go through the parallel ABI + // handle (see AppendBlank), and AdsAppendRecord auto-locks the + // new record on THAT Table instance. Routing the client's + // RLock() to the engine Table made every lock after APPEND + // BLANK burn the full lock-retry budget (~1 s per record — + // Pritpal's "9 records in 10 seconds", 31/07/2026) and the + // matching UNLOCK never released the ABI-side auto-lock, + // leaking it until session cleanup and stalling other + // stations. Route lock/unlock through the same ABI handle + // whenever it exists. + if (auto hit = tbls_h_.find(id); hit != tbls_h_.end()) { + UNSIGNED32 rrc = (f.opcode == Opcode::LockRecord) + ? openads::abi::try_lock_record_once(hit->second, rn) + : AdsUnlockRecord(hit->second, rn); + // Also release any stale engine-side lock taken before + // the ABI handle existed (best-effort; not holding the + // lock is fine). + if (f.opcode == Opcode::UnlockRecord) + (void)tbl->unlock_record(rn); + if (rrc != 0) { + // Trace the holder so a field log shows WHO is blocking + // a record lock (Pritpal's GN_COUNT retry loop). + if (wire_trace_on()) { + for (const auto& lk : openads::mgmt::LockRegistry + ::instance().snapshot()) { + if (lk.recno == rn) { + WTRACE("[wire] %s id=%u recno=%u FAILED rrc=%u; held by user=%s conn=%u table=%s\n", + f.opcode == Opcode::LockRecord ? "LockRecord" : "UnlockRecord", + id, (unsigned)rn, (unsigned)rrc, + lk.user.c_str(), (unsigned)lk.conn_no, + lk.table.c_str()); + } + } + WTRACE("[wire] %s id=%u recno=%u FAILED rrc=%u (no registered holder)\n", + f.opcode == Opcode::LockRecord ? "LockRecord" : "UnlockRecord", + id, (unsigned)rn, (unsigned)rrc); + } + reply = err("Lock: failed", rrc); break; + } + WTRACE("[wire] %s id=%u recno=%u ok user=%s conn=%u table=%s\n", + f.opcode == Opcode::LockRecord ? "LockRecord" : "UnlockRecord", + id, (unsigned)rn, + session_user_.empty() ? "(anonymous)" : session_user_.c_str(), + (unsigned)srv_->conn_no_for_session(sid_), + tbl_open_paths_.count(id) ? tbl_open_paths_[id].c_str() : "?"); + } else if (f.opcode == Opcode::LockRecord) { + // Fail FAST on contention — a single attempt. Retrying + // here blocked the session thread for the whole lock + // budget (~1s), starving the peer op that releases the + // record; the client retries instead, one wire request + // per attempt, keeping the connection free between them. + auto r = tbl->try_lock_record_excl(rn); + if (!r) { + WTRACE("[wire] LockRecord id=%u recno=%u FAILED (engine)\n", + id, (unsigned)rn); + reply = err("LockRecord: failed", + openads::AE_LOCKED); + break; + } + WTRACE("[wire] LockRecord id=%u recno=%u ok (engine) user=%s conn=%u table=%s\n", + id, (unsigned)rn, + session_user_.empty() ? "(anonymous)" : session_user_.c_str(), + (unsigned)srv_->conn_no_for_session(sid_), + tbl_open_paths_.count(id) ? tbl_open_paths_[id].c_str() : "?"); + } else { + auto r = tbl->unlock_record(rn); + if (!r) { reply = err("UnlockRecord: failed", + static_cast(r.error().code)); break; } + WTRACE("[wire] UnlockRecord id=%u recno=%u ok (engine) user=%s conn=%u table=%s\n", + id, (unsigned)rn, + session_user_.empty() ? "(anonymous)" : session_user_.c_str(), + (unsigned)srv_->conn_no_for_session(sid_), + tbl_open_paths_.count(id) ? tbl_open_paths_[id].c_str() : "?"); + } + reply.opcode = (f.opcode == Opcode::LockRecord) + ? Opcode::LockRecordAck + : Opcode::UnlockRecordAck; + break; + } + // M12.36 — lock introspection. Same dual-handle routing as + // LockRecord above: when the parallel ABI handle exists it owns + // the locks (appends auto-lock there), so query it through the + // local ACE calls; otherwise walk the engine Table's held list. + case Opcode::IsRecordLocked: { + if (f.payload.size() < 8) { reply = err("IsRecordLocked: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + std::uint32_t rn = read_u32_le(f.payload.data() + 4); + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("IsRecordLocked: bad table id"); break; + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("IsRecordLocked: lookup failed"); break; } + // ACE convention: recno 0 = the current record. + if (rn == 0) rn = tbl->recno(); + // mtfix11: SAP AdsIsRecordLocked answers across connections; + // the own-session lock list alone is blind to other sessions + // (login-semaphore guards read this op and silently admitted + // every newcomer). Own registrations plus a non-destructive + // OS lock-byte probe - which also covers locks held through + // this session's ABI twin handle, whose Table object is not + // `tbl`. + std::uint16_t locked = 0; + if (auto any = tbl->is_record_locked_any(rn); any) { + locked = any.value() ? 1 : 0; + } else { + reply = err("IsRecordLocked: probe failed"); break; + } + reply.opcode = Opcode::IsRecordLockedAck; + write_u16_le(locked, reply.payload); + break; + } + case Opcode::GetAllLocks: { + if (f.payload.size() < 4) { reply = err("GetAllLocks: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("GetAllLocks: bad table id"); break; + } + std::vector recs; + if (auto hit = tbls_h_.find(id); hit != tbls_h_.end()) { + UNSIGNED16 count = 0; + if (AdsGetAllLocks(hit->second, nullptr, &count) != 0) { + reply = err("GetAllLocks: failed"); break; + } + recs.resize(count); + if (count > 0 && + AdsGetAllLocks(hit->second, recs.data(), &count) != 0) { + reply = err("GetAllLocks: failed"); break; + } + recs.resize(count); + } else { + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("GetAllLocks: lookup failed"); break; } + recs = tbl->held_record_locks(); + } + reply.opcode = Opcode::GetAllLocksAck; + write_u16_le(static_cast(recs.size()), + reply.payload); + for (std::uint32_t rn : recs) write_u32_le(rn, reply.payload); + break; + } + case Opcode::LockTable: + case Opcode::UnlockTable: { + if (f.payload.size() < 4) { reply = err("Lock: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("LockTable: bad table id"); break; + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("LockTable: lookup failed"); break; } + openads::mgmt::set_current_lock_owner( + session_user_.empty() ? "(anonymous)" : session_user_, + srv_->conn_no_for_session(sid_)); + if (f.opcode == Opcode::LockTable) { + // Fail fast — a single attempt (see LockRecord above: the + // client owns the retry loop so the connection stays free + // between attempts). + auto r = tbl->try_lock_table_excl(); + if (!r) { + reply = err("LockTable: failed", + openads::AE_LOCKED); + break; + } + } else { + // M12.16 dual-handle: route through the ABI shadow handle + // when it exists (same as LockRecord/UnlockRecord) so the + // ABI-side Table's LockMgr releases its own locks. + WTRACE("[wire] UnlockTable id=%u\n", id); + if (auto hit = tbls_h_.find(id); hit != tbls_h_.end()) { + UNSIGNED32 rrc = AdsUnlockTable(hit->second); + // Also release engine-side locks (best-effort cleanup). + (void)tbl->unlock_table(); + if (rrc != 0) { + reply = err("UnlockTable: failed", rrc); break; + } + } else { + auto r = tbl->unlock_table(); + if (!r) { reply = err("UnlockTable: failed", + static_cast(r.error().code)); break; } + } + } + reply.opcode = (f.opcode == Opcode::LockTable) + ? Opcode::LockTableAck + : Opcode::UnlockTableAck; + break; + } + case Opcode::PackTable: + case Opcode::ZapTable: + case Opcode::FlushFileBuffers: + case Opcode::CloseAllIndexes: { + if (f.payload.size() < 4) { reply = err("Maintenance: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + Opcode ack_op = + (f.opcode == Opcode::PackTable) ? Opcode::PackTableAck + : (f.opcode == Opcode::ZapTable) ? Opcode::ZapTableAck + : (f.opcode == Opcode::FlushFileBuffers) ? Opcode::FlushFileBuffersAck + : Opcode::CloseAllIndexesAck; + if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { + if (f.opcode == Opcode::PackTable) AdsPackTable(cit->second); + else if (f.opcode == Opcode::ZapTable) AdsZapTable(cit->second); + else if (f.opcode == Opcode::FlushFileBuffers) AdsFlushFileBuffers(cit->second); + else { + // Best-effort flush first (see engine branch below): + // clients may have merged a FlushFileBuffers here. + (void)AdsFlushFileBuffers(cit->second); + AdsCloseAllIndexes(cit->second); + } + reply.opcode = ack_op; + break; + } + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("Maintenance: bad table id"); break; + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("Maintenance: lookup failed"); break; } + util::Result rb = util::Result{}; + if (f.opcode == Opcode::PackTable) rb = tbl->pack(); + else if (f.opcode == Opcode::ZapTable) rb = tbl->zap(); + else if (f.opcode == Opcode::FlushFileBuffers) rb = tbl->flush(); + else { + // CloseAllIndexes: drop both active order + + // every parked extra view in lockstep. Flush first: + // clients merge a preceding FlushFileBuffers into this + // frame (one RTT instead of two), so durability must + // not depend on a separate flush arriving. + rb = tbl->flush(); + if (!rb) { reply = err("Maintenance: " + rb.error().message); break; } + tbl->clear_order(); + tbl->clear_extra_index_views(); + } + if (!rb) { reply = err("Maintenance: " + rb.error().message); break; } + // Engine-side pack/zap rewrites recnos (pack) or empties the file + // (zap); bags bound on the ABI twin now point at phantom recnos. + // Rebuild them so the next ordered nav doesn't walk ghosts. + // AdsGotoTop first: it refreshes the twin's cached record_count + // from disk, which the reindex scan relies on. + if (f.opcode == Opcode::PackTable || f.opcode == Opcode::ZapTable) { + if (auto hit = tbls_h_.find(id); hit != tbls_h_.end()) { + (void)AdsGotoTop(hit->second); + (void)AdsReindex(hit->second); + } + } + reply.opcode = ack_op; + break; + } + case Opcode::SetAOF: { + if (f.payload.size() < 4) { reply = err("SetAOF: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + std::string cond(reinterpret_cast( + f.payload.data() + 4), + f.payload.size() - 4); + if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { + std::vector b(cond.size() + 1); + std::memcpy(b.data(), cond.data(), cond.size()); + UNSIGNED32 rrc = AdsSetAOF(cit->second, b.data(), 0); + if (rrc != 0) { reply = err("SetAOF: parse failed", rrc); break; } + reply.opcode = Opcode::SetAOFAck; + break; + } + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("SetAOF: bad table id"); break; + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("SetAOF: lookup failed"); break; } + auto ast = openads::engine::aof::parse(cond); + if (!ast) { + // Expression outside the optimisable AOF subset + // (e.g. Empty(NAME)) — not an error. Drop any + // prior AOF and ack; the client RDD filters. + tbl->clear_filter(); + reply.opcode = Opcode::SetAOFAck; + break; + } + auto rep = openads::engine::aof::evaluate_optimised(*ast.value(), *tbl); + if (!rep) { reply = err("SetAOF: " + rep.error().message); break; } + tbl->install_aof_bitmap(std::move(rep.value().bm)); + tbl->set_aof_expr(cond); + int lvl = ADS_OPTIMIZED_NONE; + switch (rep.value().level) { + case openads::engine::aof::OptLevel::None: lvl = ADS_OPTIMIZED_NONE; break; + case openads::engine::aof::OptLevel::Part: lvl = ADS_OPTIMIZED_PART; break; + case openads::engine::aof::OptLevel::Full: lvl = ADS_OPTIMIZED_FULL; break; + } + tbl->set_aof_opt_level(lvl); + reply.opcode = Opcode::SetAOFAck; + break; + } + case Opcode::ClearAOFRemote: { + if (f.payload.size() < 4) { reply = err("ClearAOF: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { + AdsClearAOF(cit->second); + reply.opcode = Opcode::ClearAOFRemoteAck; + break; + } + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("ClearAOF: bad table id"); break; + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("ClearAOF: lookup failed"); break; } + tbl->clear_filter(); + reply.opcode = Opcode::ClearAOFRemoteAck; + break; + } + case Opcode::GetAOFOptLevel: { + if (f.payload.size() < 4) { reply = err("GetAOFOptLevel: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + std::uint16_t v = ADS_OPTIMIZED_NONE; + if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { + UNSIGNED16 lvl = 0; UNSIGNED16 buflen = 0; + AdsGetAOFOptLevel(cit->second, &lvl, nullptr, &buflen); + v = lvl; + } else if (auto it = tbls_.find(id); it != tbls_.end() && sess_conn_) { + if (auto* tbl = sess_conn_->lookup_table(it->second)) { + if (tbl->aof_active()) { + v = static_cast(tbl->aof_opt_level()); + } + } + } + reply.opcode = Opcode::GetAOFOptLevelAck; + reply.payload.push_back(static_cast( v & 0xFFu)); + reply.payload.push_back(static_cast((v >> 8) & 0xFFu)); + break; + } + // M12.16 — remote index handle subsystem. All ops route + // through the ABI handle on tbls_h_ (lazy-promoted via + // ensure_abi_handle) so AdsOpenIndex / AdsSetOrder / + // AdsSeek work end-to-end over the wire. + case Opcode::OpenIndex: { + if (f.payload.size() < 4) { reply = err("OpenIndex: bad payload"); break; } + std::uint32_t tid = read_u32_le(f.payload.data()); + std::string path(reinterpret_cast( + f.payload.data() + 4), + f.payload.size() - 4); + auto& oi_st = openads::mgmt::process_mg_stats(); + auto oi_us = [](auto a, auto b) { + return static_cast( + std::chrono::duration_cast( + b - a).count()); + }; + auto oi_t0 = std::chrono::steady_clock::now(); + // Remember whether the twin pre-existed: ensure_abi_handle + // creates one as a side effect, and a FAILED open below must + // not leave it behind. An orphan twin hijacks every later + // write (AppendBlank/SetField/SetFields prefer tbls_h_) while + // fetch packs the engine table — same-handle read-after-write + // then serves stale blanks until a nav reloads from disk. + const bool had_twin = tbls_h_.find(tid) != tbls_h_.end(); + ADSHANDLE ht = ensure_abi_handle(tid); + { + auto& t = oi_st.op_timing_oi[0]; + auto us = oi_us(oi_t0, std::chrono::steady_clock::now()); + t.count.fetch_add(1, std::memory_order_relaxed); + t.total_us.fetch_add(us, std::memory_order_relaxed); + auto pv = t.max_us.load(std::memory_order_relaxed); + while (us > pv && !t.max_us.compare_exchange_weak( + pv, us, std::memory_order_relaxed)) {} + } + if (ht == 0) { reply = err("OpenIndex: bad table id"); break; } + std::vector pb(path.size() + 1); + std::memcpy(pb.data(), path.data(), path.size()); + ADSHANDLE arr[64] = {0}; + UNSIGNED16 alen = 64; + auto oi_t1 = std::chrono::steady_clock::now(); + UNSIGNED32 rrc = AdsOpenIndex(ht, pb.data(), arr, &alen); + { + auto& t = oi_st.op_timing_oi[1]; + auto us = oi_us(oi_t1, std::chrono::steady_clock::now()); + t.count.fetch_add(1, std::memory_order_relaxed); + t.total_us.fetch_add(us, std::memory_order_relaxed); + auto pv = t.max_us.load(std::memory_order_relaxed); + while (us > pv && !t.max_us.compare_exchange_weak( + pv, us, std::memory_order_relaxed)) {} + } + if (rrc != 0) { + // Drop an orphan twin created above: it owns no tags, so + // keeping it would only split engine/twin state (and leak + // the ABI handle). A pre-existing twin stays untouched, as + // does a cursor_tbls_ handle (never in tbls_h_). + if (!had_twin) { + if (auto hit = tbls_h_.find(tid); + hit != tbls_h_.end() && hit->second == ht) { + abi_schema_.erase(ht); + (void)AdsCloseTable(ht); + tbls_h_.erase(hit); + } + } + reply = err("OpenIndex: " + path, rrc); break; + } + reply.opcode = Opcode::OpenIndexAck; + reply.payload.push_back(static_cast( alen & 0xFFu)); + reply.payload.push_back(static_cast((alen >> 8) & 0xFFu)); + auto oi_t3 = std::chrono::steady_clock::now(); + for (std::uint16_t i = 0; i < alen; ++i) { + std::uint32_t iid = next_id_++; + index_h_[iid] = arr[i]; + index_table_[iid] = tid; + write_u32_le(iid, reply.payload); + std::string tag; + UNSIGNED8 tbuf[256] = {0}; + UNSIGNED16 tlen = static_cast(sizeof(tbuf) - 1); + if (AdsGetIndexName(arr[i], tbuf, &tlen) == 0) { + tag.assign(reinterpret_cast(tbuf), tlen); + while (!tag.empty() && tag.back() == ' ') tag.pop_back(); + } + index_tag_[iid] = tag; + auto tn = static_cast(tag.size()); + reply.payload.push_back(static_cast( tn & 0xFFu)); + reply.payload.push_back(static_cast((tn >> 8) & 0xFFu)); + reply.payload.insert(reply.payload.end(), + tag.begin(), tag.end()); + } + // Append the production bag path so the client can serve + // AdsGetIndexFilename / OrdBagName without a wire round-trip. + // All tags in this OpenIndex response come from the same bag + // (the first tag handle is enough to query the bag path). + { + UNSIGNED8 bbuf[512] = {0}; + UNSIGNED16 blen = static_cast(sizeof(bbuf) - 1); + std::string bag; + if (alen > 0 && + AdsGetIndexFilename(arr[0], 0, bbuf, &blen) == 0 && + blen > 0) { + bag.assign(reinterpret_cast(bbuf), blen); + } + auto bn = static_cast(bag.size()); + reply.payload.push_back(static_cast( bn & 0xFFu)); + reply.payload.push_back(static_cast((bn >> 8) & 0xFFu)); + reply.payload.insert(reply.payload.end(), + bag.begin(), bag.end()); + } + // Extended per-tag metadata (expression, unique, descending), + // one triple per tag in the same order as the tag loop above. + // Lets remote AdsGetIndexExpr / AdsIsIndexUnique / + // AdsIsIndexDescending answer from the cached RemoteIndex + // instead of a lookup that only ever resolves local handles. + for (std::uint16_t i = 0; i < alen; ++i) { + UNSIGNED8 ebuf[1024] = {0}; + UNSIGNED16 elen = static_cast(sizeof(ebuf) - 1); + std::string expr; + if (AdsGetIndexExpr(arr[i], ebuf, &elen) == 0 && elen > 0) { + expr.assign(reinterpret_cast(ebuf), elen); + } + auto en = static_cast(expr.size()); + reply.payload.push_back(static_cast( en & 0xFFu)); + reply.payload.push_back(static_cast((en >> 8) & 0xFFu)); + reply.payload.insert(reply.payload.end(), + expr.begin(), expr.end()); + + UNSIGNED16 uniq = 0, desc = 0; + AdsIsIndexUnique(arr[i], &uniq); + AdsIsIndexDescending(arr[i], &desc); + reply.payload.push_back(uniq != 0 ? 1 : 0); + reply.payload.push_back(desc != 0 ? 1 : 0); + } + { + auto& t = oi_st.op_timing_oi[3]; + auto us = oi_us(oi_t3, std::chrono::steady_clock::now()); + t.count.fetch_add(1, std::memory_order_relaxed); + t.total_us.fetch_add(us, std::memory_order_relaxed); + auto pv = t.max_us.load(std::memory_order_relaxed); + while (us > pv && !t.max_us.compare_exchange_weak( + pv, us, std::memory_order_relaxed)) {} + } + break; + } + case Opcode::CloseIndex: { + if (f.payload.size() < 4) { reply = err("CloseIndex: bad payload"); break; } + std::uint32_t iid = read_u32_le(f.payload.data()); + auto iit = index_h_.find(iid); + if (iit != index_h_.end()) { + AdsCloseIndex(iit->second); + index_h_.erase(iit); + } + index_table_.erase(iid); + index_tag_.erase(iid); + reply.opcode = Opcode::CloseIndexAck; + break; + } + case Opcode::SetOrder: { + if (f.payload.size() < 8) { reply = err("SetOrder: bad payload"); break; } + std::uint32_t tid = read_u32_le(f.payload.data()); + std::uint32_t iid = read_u32_le(f.payload.data() + 4); + UNSIGNED32 orc = install_table_order(tid, iid); + if (orc != 0) { + // Distinguish unknown-index (5018, historical) from a + // bad table id: install returns NO_FILE_FOUND for both, + // and the table-id case previously read "bad table id". + auto it = tbls_.find(tid); + if (it == tbls_.end()) + reply = err("SetOrder: bad table id"); + else + reply = err("SetOrder", orc); + break; + } + reply.opcode = Opcode::SetOrderAck; + // NOTE: no key-count piggyback here (v1.09.46 tried it and + // reverted in v1.09.47): certifying the count needs + // commit_dirty_record + a count walk on the twin, which + // convoys under append storms (10-thread bulk append went + // 0.283 s -> 8.2 s on loopback). The fused navs keep their + // piggyback (browse-shaped traffic only). Client parsing + // below stays length-gated for forward compatibility. + break; + } + case Opcode::SetOrderByName: { + if (f.payload.size() < 4) { reply = err("SetOrderByName: bad payload"); break; } + std::uint32_t tid = read_u32_le(f.payload.data()); + std::string tag(reinterpret_cast( + f.payload.data() + 4), + f.payload.size() - 4); + ADSHANDLE ht = ensure_abi_handle(tid); + if (ht == 0) { reply = err("SetOrderByName: bad table id"); break; } + std::vector tb(tag.size() + 1); + std::memcpy(tb.data(), tag.data(), tag.size()); + UNSIGNED32 rrc = AdsSetIndexOrder(ht, + tag.empty() ? nullptr : tb.data()); + if (rrc != 0) { reply = err("SetOrderByName: " + tag, rrc); break; } + if (tag.empty()) ordered_tables_.erase(tid); + else ordered_tables_.insert(tid); + sync_engine_cursor(tid); + reply.opcode = Opcode::SetOrderByNameAck; + // No key-count piggyback (see SetOrder: reverted — append + // storms convoys). Fused navs keep theirs. + break; + } + case Opcode::Seek: + case Opcode::SeekLast: { + // payload: u32 index_id, u8 soft, key bytes. + if (f.payload.size() < 5) { reply = err("Seek: bad payload"); break; } + std::uint32_t iid = read_u32_le(f.payload.data()); + std::uint8_t soft = f.payload[4]; + std::string key(reinterpret_cast( + f.payload.data() + 5), + f.payload.size() - 5); + auto iit = index_h_.find(iid); + if (iit == index_h_.end()) { reply = err("Seek: bad index id"); break; } + std::vector kb(key.size() + 1); + std::memcpy(kb.data(), key.data(), key.size()); + UNSIGNED16 found = 0; + UNSIGNED32 rrc = (f.opcode == Opcode::SeekLast) + ? AdsSeekLast(iit->second, kb.data(), + static_cast(key.size()), + ADS_STRINGKEY, + &found) + : AdsSeek (iit->second, kb.data(), + static_cast(key.size()), + ADS_STRINGKEY, + static_cast(soft), + &found); + if (rrc != 0) { reply = err("Seek", rrc); break; } + // Read recno via the parent table's ABI handle; that + // also lets sync_engine_cursor reflect the new + // position on the engine cursor we keep alive + // alongside. + UNSIGNED32 rn = 0; + std::uint32_t parent_tid = 0; + if (auto tit = index_table_.find(iid); tit != index_table_.end()) { + parent_tid = tit->second; + if (ADSHANDLE ht = ensure_abi_handle(parent_tid); ht != 0) { + AdsGetRecordNum(ht, 0, &rn); + } + } + if (parent_tid != 0) sync_engine_cursor(parent_tid); + reply.opcode = (f.opcode == Opcode::SeekLast) + ? Opcode::SeekLastAck : Opcode::SeekAck; + reply.payload.push_back(static_cast(found != 0 ? 1 : 0)); + write_u32_le(rn, reply.payload); + // RCB 07/14/2026: M12.24 — append the row the seek landed on. + // + // The ack used to be just [u8 found][u32 recno], which meant the + // client knew WHERE it was but not WHAT was there: row_valid stayed + // false, so the first AdsGetField after a seek paid a whole extra + // FetchCurrentRow round-trip. Seek-then-read is the single most + // common thing a business app does, and it was costing 2 RTTs. + // Worse, the relation code (seek_remote_child_relation) papered + // over it by firing a GotoRecord straight after every seek purely + // to pull the row down — so a parent browse with a child relation + // paid 2 RTTs per parent ROW. The trailer kills both. + // + // Deliberately NO lookahead block here (depth 0). A relation + // re-seeks the child on every single parent row, so a block would + // drag N child rows over the wire per parent row and almost never + // be read. SAP draws the same line: read-ahead triggers on "a skip + // operation after ... any other movement operation", i.e. the skip + // earns the block, not the seek. + // + // Wire-safe both ways, no capability bit: an old client requires + // size() >= 5 and ignores trailing bytes; a new client against an + // old server sees a 5-byte ack, parses no trailer, and falls back + // to the FetchCurrentRow path exactly as before. + if (parent_tid != 0) pack_row_trailer(reply, parent_tid, 0); + // Reposition truth rides after the trailer (length-gated on + // the client; old peers ignore the tail). parent_tid != 0 + // whenever a twin exists to read it from. + if (parent_tid != 0) pack_bound_trailer(reply, parent_tid); + break; + } + // CreateIndex / SkipUnique / SetScope / ClearScope — + // remote bridges for the remaining index ops. CreateIndex + // takes the full AdsCreateIndex61 input and returns a + // single index id (multi-tag CDX additions are supported + // via repeated calls). SkipUnique walks distinct keys via + // the active order; SetScope / ClearScope manage top / + // bottom range bounds on an existing hIndex. + case Opcode::CreateIndex: { + if (f.payload.size() < 4 + 4 + 2) { + reply = err("CreateIndex: bad payload"); break; + } + std::size_t pos = 0; + std::uint32_t tid = read_u32_le(f.payload.data() + pos); pos += 4; + std::uint32_t options = read_u32_le(f.payload.data() + pos); pos += 4; + std::uint16_t pgsize = read_u16_le(f.payload.data() + pos); pos += 2; + auto pop_str = [&](std::string& out) -> bool { + if (pos + 2 > f.payload.size()) return false; + std::uint16_t n = read_u16_le(f.payload.data() + pos); + pos += 2; + if (pos + n > f.payload.size()) return false; + out.assign(reinterpret_cast( + f.payload.data() + pos), n); + pos += n; + return true; + }; + std::string path, tag, expr, cond, key_filter; + if (!pop_str(path) || !pop_str(tag) || !pop_str(expr) || + !pop_str(cond) || !pop_str(key_filter)) { + reply = err("CreateIndex: short payload"); break; + } + // Honor a directory-qualified bag name ("folder/Indexes/x.Z01") + // server-side; a bare filename keeps the table-folder fallback. + path = resolve_index_bag_path(path); + ADSHANDLE ht = ensure_abi_handle(tid); + if (ht == 0) { reply = err("CreateIndex: bad table id"); break; } + std::vector pb (path .size() + 1); std::memcpy(pb .data(), path .data(), path .size()); + std::vector tb (tag .size() + 1); std::memcpy(tb .data(), tag .data(), tag .size()); + std::vector eb (expr .size() + 1); std::memcpy(eb .data(), expr .data(), expr .size()); + std::vector cb (cond .size() + 1); std::memcpy(cb .data(), cond .data(), cond .size()); + std::vector kfb(key_filter.size() + 1); + std::memcpy(kfb.data(), key_filter.data(), key_filter.size()); + ADSHANDLE hidx = 0; + UNSIGNED32 rrc = AdsCreateIndex61( + ht, pb.data(), tb.data(), eb.data(), + cond.empty() ? nullptr : cb.data(), + key_filter.empty() ? nullptr : kfb.data(), + options, pgsize, &hidx); + if (rrc != 0) { reply = err("CreateIndex", rrc); break; } + std::uint32_t iid = next_id_++; + index_h_[iid] = hidx; + index_table_[iid] = tid; + index_tag_[iid] = tag; + reply.opcode = Opcode::CreateIndexAck; + write_u32_le(iid, reply.payload); + break; + } + // Remote AdsCreateTable: write the free table under the session + // data directory via the lazy ABI connection, then close it so + // the client can re-open through OpenTable (prod bag auto-open). + case Opcode::CreateTable: { + if (!sess_conn_) { + reply = err("CreateTable: not connected", + openads::AE_NO_CONNECTION); + break; + } + if (f.payload.size() < 6) { + reply = err("CreateTable: bad payload"); break; + } + std::size_t pos = 0; + std::uint16_t table_type = read_u16_le(f.payload.data() + pos); + pos += 2; + std::uint16_t char_type = read_u16_le(f.payload.data() + pos); + pos += 2; + std::uint16_t memo_bs = read_u16_le(f.payload.data() + pos); + pos += 2; + std::string name, fields; + if (!read_lstr16(f.payload, pos, name) || + !read_lstr16(f.payload, pos, fields)) { + reply = err("CreateTable: short payload"); break; + } + if (!ensure_abi_conn()) { + reply = err("CreateTable: no ABI connection"); break; + } + auto nb = to_cbuf(name); + auto fb = to_cbuf(fields); + ADSHANDLE hTable = 0; + UNSIGNED32 rrc = AdsCreateTable( + abi_conn_, nb.data(), nullptr, + table_type, char_type, 0, 0, memo_bs, + fb.data(), &hTable); + if (rrc != 0) { + reply = err("CreateTable", rrc); break; + } + // Files are on disk under the data dir; release the local + // handle so the client's subsequent OpenTable can take Shared. + if (hTable != 0) (void)AdsCloseTable(hTable); + reply.opcode = Opcode::CreateTableAck; + break; + } + case Opcode::DropTable: { + if (!sess_conn_) { + reply = err("DropTable: not connected", + openads::AE_NO_CONNECTION); + break; + } + std::size_t pos = 0; + std::string name; + if (!read_lstr16(f.payload, pos, name)) { + reply = err("DropTable: short payload"); break; + } + std::uint16_t delete_files = 0; + if (pos + 2 <= f.payload.size()) { + delete_files = read_u16_le(f.payload.data() + pos); + } + if (!ensure_abi_conn()) { + reply = err("DropTable: no ABI connection"); break; + } + auto nb = to_cbuf(name); + UNSIGNED32 rrc = AdsDropTable(abi_conn_, nb.data(), delete_files); + if (rrc != 0) { + reply = err("DropTable", rrc); break; + } + reply.opcode = Opcode::DropTableAck; + break; + } + // ── Server filesystem (EnableFileFunc) ────────────────────────── + // M12.33 — remote table enumeration. NOT gated by EnableFileFunc + // because listing tables is a core database operation. + case Opcode::FindTables: { + if (!sess_conn_) { + reply = err("FindTables: not connected", + openads::AE_NO_CONNECTION); + break; + } + std::size_t pos = 0; + std::string mask; + if (!read_lstr16(f.payload, pos, mask)) { + reply = err("FindTables: short payload"); break; + } + if (mask.empty()) mask = "*.dbf"; + auto r = sess_conn_->find_tables(mask); + if (!r) { + reply = err("FindTables", + static_cast(r.error().code)); + break; + } + auto& matches = r.value(); + reply.opcode = Opcode::FindTablesAck; + write_u32_le(static_cast(matches.size()), + reply.payload); + for (const auto& name : matches) { + write_lstr16(name, reply.payload); + } + break; + } + + case Opcode::FileExists: + case Opcode::FileErase: + case Opcode::FileRename: + case Opcode::FileSize: + case Opcode::FileMTime: + case Opcode::Directory: + case Opcode::DirExist: + case Opcode::DirMake: + case Opcode::DirRemove: + case Opcode::FOpen: + case Opcode::FCreate: + case Opcode::FClose: + case Opcode::FRead: + case Opcode::FWrite: + case Opcode::FSeek: { + if (!srv_->enable_file_func()) { + reply = err("file functions disabled", + openads::AE_ACCESS_DENIED); + break; + } + if (!sess_conn_) { + reply = err("fs: not connected", openads::AE_NO_CONNECTION); + break; + } + auto deny_path = [&](const std::string&) { + reply = err("path outside data directory", + openads::AE_ACCESS_DENIED); + }; + if (f.opcode == Opcode::FileExists) { + std::size_t pos = 0; + std::string path; + if (!read_lstr16(f.payload, pos, path)) { + reply = err("FileExists: short payload"); break; + } + auto abs = resolve_fs_client_path(path); + if (!abs) { deny_path(path); break; } + auto ex = openads::engine::fs_exists(*abs); + if (!ex) { + reply = err("FileExists", + static_cast(ex.error().code)); + break; + } + reply.opcode = Opcode::FileExistsAck; + reply.payload.push_back(ex.value() ? 1 : 0); + } else if (f.opcode == Opcode::FileErase) { + std::size_t pos = 0; + std::string path; + if (!read_lstr16(f.payload, pos, path)) { + reply = err("FileErase: short payload"); break; + } + auto abs = resolve_fs_client_path(path); + if (!abs) { deny_path(path); break; } + auto r = openads::engine::fs_erase(*abs); + if (!r) { + reply = err("FileErase", + static_cast(r.error().code)); + break; + } + reply.opcode = Opcode::FileEraseAck; + } else if (f.opcode == Opcode::FileRename) { + std::size_t pos = 0; + std::string old_p, new_p; + if (!read_lstr16(f.payload, pos, old_p) || + !read_lstr16(f.payload, pos, new_p)) { + reply = err("FileRename: short payload"); break; + } + auto a = resolve_fs_client_path(old_p); + auto b = resolve_fs_client_path(new_p); + if (!a || !b) { deny_path(old_p); break; } + auto r = openads::engine::fs_rename(*a, *b); + if (!r) { + reply = err("FileRename", + static_cast(r.error().code)); + break; + } + reply.opcode = Opcode::FileRenameAck; + } else if (f.opcode == Opcode::FileSize) { + std::size_t pos = 0; + std::string path; + if (!read_lstr16(f.payload, pos, path)) { + reply = err("FileSize: short payload"); break; + } + auto abs = resolve_fs_client_path(path); + if (!abs) { deny_path(path); break; } + auto sz = openads::engine::fs_size(*abs); + if (!sz) { + reply = err("FileSize", + static_cast(sz.error().code)); + break; + } + reply.opcode = Opcode::FileSizeAck; + std::uint64_t v = sz.value(); + for (int i = 0; i < 8; ++i) + reply.payload.push_back( + static_cast((v >> (8 * i)) & 0xFF)); + } else if (f.opcode == Opcode::FileMTime) { + std::size_t pos = 0; + std::string path; + if (!read_lstr16(f.payload, pos, path)) { + reply = err("FileMTime: short payload"); break; + } + auto abs = resolve_fs_client_path(path); + if (!abs) { deny_path(path); break; } + auto st = openads::engine::fs_stat_entry(*abs); + if (!st) { + reply = err("FileMTime", + static_cast(st.error().code)); + break; + } + const auto& se = st.value(); + reply.opcode = Opcode::FileMTimeAck; + reply.payload.push_back( + static_cast(se.year & 0xFF)); + reply.payload.push_back( + static_cast((se.year >> 8) & 0xFF)); + reply.payload.push_back(se.mon); + reply.payload.push_back(se.day); + reply.payload.push_back(se.hh); + reply.payload.push_back(se.mm); + reply.payload.push_back(se.ss); + } else if (f.opcode == Opcode::Directory) { + std::size_t pos = 0; + std::string mask; + if (!read_lstr16(f.payload, pos, mask)) { + reply = err("Directory: short payload"); break; + } + // Split "subdir/*.dbf" and jail-resolve the directory part. + std::string dir_part = "."; + std::string pat = mask.empty() ? "*" : mask; + auto slash = pat.find_last_of("/\\"); + if (slash != std::string::npos) { + dir_part = pat.substr(0, slash); + if (dir_part.empty()) dir_part = "."; + pat = pat.substr(slash + 1); + if (pat.empty()) pat = "*"; + } + auto abs_dir = resolve_fs_client_path(dir_part); + if (!abs_dir) { deny_path(dir_part); break; } + auto entries = + openads::engine::fs_directory(*abs_dir, pat); + if (!entries) { + reply = err("Directory", + static_cast(entries.error().code)); + break; + } + const auto& elist = entries.value(); + reply.opcode = Opcode::DirectoryAck; + write_u32_le(static_cast(elist.size()), + reply.payload); + for (const auto& e : elist) + openads::engine::pack_dir_entry(e, reply.payload); + } else if (f.opcode == Opcode::DirExist) { + std::size_t pos = 0; + std::string path; + if (!read_lstr16(f.payload, pos, path)) { + reply = err("DirExist: short payload"); break; + } + auto abs = resolve_fs_client_path(path); + if (!abs) { deny_path(path); break; } + auto ex = openads::engine::fs_dir_exist(*abs); + if (!ex) { + reply = err("DirExist", + static_cast(ex.error().code)); + break; + } + reply.opcode = Opcode::DirExistAck; + reply.payload.push_back(ex.value() ? 1 : 0); + } else if (f.opcode == Opcode::DirMake) { + std::size_t pos = 0; + std::string path; + if (!read_lstr16(f.payload, pos, path)) { + reply = err("DirMake: short payload"); break; + } + auto abs = resolve_fs_client_path(path); + if (!abs) { deny_path(path); break; } + auto r = openads::engine::fs_dir_make(*abs); + if (!r) { + reply = err("DirMake", + static_cast(r.error().code)); + break; + } + reply.opcode = Opcode::DirMakeAck; + } else if (f.opcode == Opcode::DirRemove) { + std::size_t pos = 0; + std::string path; + if (!read_lstr16(f.payload, pos, path)) { + reply = err("DirRemove: short payload"); break; + } + auto abs = resolve_fs_client_path(path); + if (!abs) { deny_path(path); break; } + auto r = openads::engine::fs_dir_remove(*abs); + if (!r) { + reply = err("DirRemove", + static_cast(r.error().code)); + break; + } + reply.opcode = Opcode::DirRemoveAck; + } else if (f.opcode == Opcode::FOpen || + f.opcode == Opcode::FCreate) { + std::size_t pos = 0; + std::string path; + if (!read_lstr16(f.payload, pos, path)) { + reply = err("FOpen: short payload"); break; + } + std::uint16_t mode = 0; + if (pos + 2 <= f.payload.size()) { + mode = read_u16_le(f.payload.data() + pos); + } + if (files_.size() >= openads::engine::kMaxSessionFiles) { + reply = err("too many open files", + openads::AE_INTERNAL_ERROR); + break; + } + auto abs = resolve_fs_client_path(path); + if (!abs) { deny_path(path); break; } + bool create = (f.opcode == Opcode::FCreate); + auto fo = openads::engine::fs_open( + *abs, mode, create); + if (!fo) { + reply = err(create ? "FCreate" : "FOpen", + static_cast(fo.error().code)); + break; + } + std::uint32_t id = next_file_id_++; + files_[id] = std::move(fo.value()); + reply.opcode = create ? Opcode::FCreateAck : Opcode::FOpenAck; + write_u32_le(id, reply.payload); + } else if (f.opcode == Opcode::FClose) { + if (f.payload.size() < 4) { + reply = err("FClose: short payload"); break; + } + std::uint32_t id = read_u32_le(f.payload.data()); + if (files_.erase(id) == 0) { + reply = err("FClose: bad handle", + openads::AE_INTERNAL_ERROR); + break; + } + reply.opcode = Opcode::FCloseAck; + } else if (f.opcode == Opcode::FRead) { + if (f.payload.size() < 8) { + reply = err("FRead: short payload"); break; + } + std::uint32_t id = read_u32_le(f.payload.data()); + std::uint32_t n = read_u32_le(f.payload.data() + 4); + if (n > openads::engine::kMaxFsIoChunk) + n = openads::engine::kMaxFsIoChunk; + auto it = files_.find(id); + if (it == files_.end()) { + reply = err("FRead: bad handle", + openads::AE_INTERNAL_ERROR); + break; + } + std::vector buf(n); + it->second->stream.read(buf.data(), + static_cast(n)); + auto got = static_cast( + it->second->stream.gcount()); + reply.opcode = Opcode::FReadAck; + write_u32_le(got, reply.payload); + reply.payload.insert( + reply.payload.end(), buf.begin(), + buf.begin() + static_cast(got)); + } else if (f.opcode == Opcode::FWrite) { + if (f.payload.size() < 8) { + reply = err("FWrite: short payload"); break; + } + std::uint32_t id = read_u32_le(f.payload.data()); + std::uint32_t n = read_u32_le(f.payload.data() + 4); + if (n > openads::engine::kMaxFsIoChunk) + n = openads::engine::kMaxFsIoChunk; + if (8u + n > f.payload.size()) { + reply = err("FWrite: short data"); break; + } + auto it = files_.find(id); + if (it == files_.end()) { + reply = err("FWrite: bad handle", + openads::AE_INTERNAL_ERROR); + break; + } + it->second->stream.write( + reinterpret_cast(f.payload.data() + 8), + static_cast(n)); + it->second->stream.flush(); + if (!it->second->stream) { + reply = err("FWrite: io error", + openads::AE_INTERNAL_ERROR); + break; + } + reply.opcode = Opcode::FWriteAck; + write_u32_le(n, reply.payload); + } else if (f.opcode == Opcode::FSeek) { + if (f.payload.size() < 9) { + reply = err("FSeek: short payload"); break; + } + std::uint32_t id = read_u32_le(f.payload.data()); + std::int32_t off = static_cast( + read_u32_le(f.payload.data() + 4)); + std::uint8_t origin = f.payload[8]; + auto it = files_.find(id); + if (it == files_.end()) { + reply = err("FSeek: bad handle", + openads::AE_INTERNAL_ERROR); + break; + } + std::ios::seekdir way = std::ios::beg; + if (origin == 1) way = std::ios::cur; + else if (origin == 2) way = std::ios::end; + it->second->stream.clear(); + // ONE shared fstream position — a second seekp with + // ios::cur would apply a relative offset twice. + it->second->stream.seekg(off, way); + auto pos = static_cast( + it->second->stream.tellg()); + reply.opcode = Opcode::FSeekAck; + write_u32_le(pos, reply.payload); + } + break; + } + case Opcode::ZipArchive: + case Opcode::UnzipArchive: { + // Server-side backup archiving (OAds_Zip/OAds_UnZip). + // Database ops — NOT gated by EnableFileFunc. Paths stay + // under the session data jail inside Connection. + if (!sess_conn_) { + reply = err("zip: not connected", + openads::AE_NO_CONNECTION); + break; + } + auto read_blob32 = [&](std::size_t& pos, + std::string& out) -> bool { + if (pos + 4 > f.payload.size()) return false; + std::uint32_t n = read_u32_le(f.payload.data() + pos); + pos += 4; + if (pos + n > f.payload.size()) return false; + out.assign(reinterpret_cast( + f.payload.data() + pos), + n); + pos += n; + return true; + }; + auto split_1f = [](const std::string& blob) { + std::vector v; + std::string cur; + for (char c : blob) { + if (c == '\x1F') { + if (!cur.empty()) v.push_back(std::move(cur)); + cur.clear(); + } else { + cur.push_back(c); + } + } + if (!cur.empty()) v.push_back(std::move(cur)); + return v; + }; + auto write_u64 = [](std::uint64_t v, + std::vector& o) { + for (int i = 0; i < 8; ++i) + o.push_back(static_cast((v >> (8 * i)) & + 0xFF)); + }; + if (f.opcode == Opcode::ZipArchive) { + // Layout: [u16 dir][u32 files][u16 zipName][u16 level] + // [u8 ov][u8 wp][u32 excl][u16 pwd]. + std::size_t pos = 0; + std::string d2, fb2, zn2, eb2, pw2; + std::uint16_t lv = 0; + std::uint8_t ov = 0, wp = 0; + bool ok = read_lstr16(f.payload, pos, d2) && + read_blob32(pos, fb2) && + read_lstr16(f.payload, pos, zn2) && + pos + 4 <= f.payload.size(); + if (ok) { + lv = read_u16_le(f.payload.data() + pos); + pos += 2; + ov = f.payload[pos++]; + wp = f.payload[pos++]; + ok = read_blob32(pos, eb2) && + read_lstr16(f.payload, pos, pw2) && + pos == f.payload.size(); + } + if (!ok) { + reply = err("ZipArchive: bad payload"); + break; + } + auto r = sess_conn_->zip_archive( + d2, split_1f(fb2), zn2, static_cast(lv), + ov != 0, pw2, split_1f(eb2), wp != 0); + if (!r) { + reply = err("ZipArchive", + static_cast( + r.error().code)); + break; + } + reply.opcode = Opcode::ZipArchiveAck; + write_u32_le(r.value().stats.files, reply.payload); + write_u64(r.value().stats.bytes, reply.payload); + write_u64(r.value().stats.archive_bytes, reply.payload); + write_lstr16(r.value().archive_rel, reply.payload); + } else { + std::size_t pos = 0; + std::string dir, zip, pwd; + if (!read_lstr16(f.payload, pos, dir) || + !read_lstr16(f.payload, pos, zip) || + !read_lstr16(f.payload, pos, pwd) || + pos + 2 > f.payload.size()) { + reply = err("UnzipArchive: bad payload"); + break; + } + const bool ov = f.payload[pos++] != 0; + const bool wp = f.payload[pos++] != 0; + if (pos != f.payload.size()) { + reply = err("UnzipArchive: bad payload"); + break; + } + auto r = sess_conn_->unzip_archive(dir, zip, pwd, ov, + wp); + if (!r) { + reply = err("UnzipArchive", + static_cast( + r.error().code)); + break; + } + reply.opcode = Opcode::UnzipArchiveAck; + write_u32_le(r.value().files, reply.payload); + write_u64(r.value().bytes, reply.payload); + write_u64(r.value().archive_bytes, reply.payload); + } + break; + } + case Opcode::ZipList: { + // Central-directory listing (OAds_ZipFileCount/List). + // Database op — NOT gated by EnableFileFunc. + if (!sess_conn_) { + reply = err("zip: not connected", + openads::AE_NO_CONNECTION); + break; + } + std::size_t pos = 0; + std::string zip; + if (!read_lstr16(f.payload, pos, zip) || + pos != f.payload.size()) { + reply = err("ZipList: bad payload"); + break; + } + auto r = sess_conn_->zip_list(zip); + if (!r) { + reply = err("ZipList", + static_cast(r.error().code)); + break; + } + std::vector packed; + for (const auto& e : r.value()) + openads::engine::zip_arch::pack_zip_entry(e, packed); + if (packed.size() > 16u * 1024u * 1024u) { + reply = err("ZipList", + static_cast( + openads::AE_INTERNAL_ERROR)); + break; + } + reply.opcode = Opcode::ZipListAck; + write_u32_le( + static_cast(r.value().size()), + reply.payload); + reply.payload.insert(reply.payload.end(), packed.begin(), + packed.end()); + break; + } + case Opcode::SkipUnique: { + if (f.payload.size() < 8) { reply = err("SkipUnique: bad payload"); break; } + std::uint32_t iid = read_u32_le(f.payload.data()); + std::int32_t dir = static_cast( + read_u32_le(f.payload.data() + 4)); + auto iit = index_h_.find(iid); + if (iit == index_h_.end()) { reply = err("SkipUnique: bad index id"); break; } + UNSIGNED32 rrc = AdsSkipUnique(iit->second, dir); + if (rrc != 0) { reply = err("SkipUnique", rrc); break; } + if (auto tit = index_table_.find(iid); tit != index_table_.end()) { + sync_engine_cursor(tit->second); + } + reply.opcode = Opcode::SkipUniqueAck; + break; + } + case Opcode::SetScope: { + // Payload: u32 index_id | u16 which | u16 data_type | bytes key. + if (f.payload.size() < 8) { reply = err("SetScope: bad payload"); break; } + std::uint32_t iid = read_u32_le(f.payload.data()); + std::uint16_t which = read_u16_le(f.payload.data() + 4); + std::uint16_t dtype = read_u16_le(f.payload.data() + 6); + std::size_t klen = f.payload.size() - 8; + auto iit = index_h_.find(iid); + if (iit == index_h_.end()) { reply = err("SetScope: bad index id"); break; } + std::vector kb(klen + 1); + if (klen > 0) std::memcpy(kb.data(), f.payload.data() + 8, klen); + UNSIGNED32 rrc = AdsSetScope( + iit->second, which, kb.data(), + static_cast(klen), dtype); + if (rrc != 0) { reply = err("SetScope", rrc); break; } + // Scope is stored on the ABI table's active order (via + // table_for_index inside AdsSetScope). GotoTop/Skip only + // honour index scope when they route through that ABI + // handle (ordered_tables_), not the parallel engine Table + // in tbls_. OrdScope / scoped-relation flows often set + // scope without a preceding SetOrder wire op — the client + // may already track active_index_id from auto-opened + // production CDX tags while ordered_tables_ is still + // empty, which made remote navigation ignore scope. + if (auto tit = index_table_.find(iid); tit != index_table_.end()) { + ordered_tables_.insert(tit->second); + if (ADSHANDLE ht = ensure_abi_handle(tit->second); ht != 0) { + (void)AdsSetIndexOrderByHandle(ht, iit->second); + sync_engine_cursor(tit->second); + } + } + reply.opcode = Opcode::SetScopeAck; + break; + } + case Opcode::GetKeyType: { + // M12.35 — return the key expression result type for a remote + // index. Payload: [u32 index_id]. Reply: [u16 key_type LE]. + if (f.payload.size() < 4) { + reply = err("GetKeyType: bad payload"); break; + } + std::uint32_t iid = read_u32_le(f.payload.data()); + auto iit = index_h_.find(iid); + if (iit == index_h_.end()) { + reply = err("GetKeyType: bad index id"); break; + } + UNSIGNED16 kt = 0; + UNSIGNED32 rrc = AdsGetKeyType(iit->second, &kt); + if (rrc != 0) { reply = err("GetKeyType", rrc); break; } + reply.opcode = Opcode::GetKeyTypeAck; + reply.payload.push_back(static_cast(kt & 0xFF)); + reply.payload.push_back(static_cast((kt >> 8) & 0xFF)); + break; + } + case Opcode::ShowDeleted: { + if (f.payload.size() < 1) { + reply = err("ShowDeleted: bad payload"); break; + } + const bool visible = f.payload[0] != 0; + show_deleted_ = visible; + openads::engine::set_show_deleted(visible); + if (sess_conn_) sess_conn_->set_show_deleted(visible); + if (abi_conn_ != 0) { + openads::abi::set_connection_show_deleted(abi_conn_, visible); + } + // RCB 07/14/2026: row visibility just changed for EVERY table on + // this session, so end the read-ahead run on all of them and let + // the depth ramp back up from the floor. + // + // This cannot go through breaks_prefetch_run() like the other + // run-enders: that mechanism reads the table id from the leading + // u32 of the payload, and ShowDeleted's payload is a single byte + // with no table id in it at all. Hence the explicit clear here. + prefetch_depth_.clear(); + reply.opcode = Opcode::ShowDeletedAck; + break; + } + case Opcode::ClearScope: { + if (f.payload.size() < 6) { reply = err("ClearScope: bad payload"); break; } + std::uint32_t iid = read_u32_le(f.payload.data()); + std::uint16_t which = read_u16_le(f.payload.data() + 4); + auto iit = index_h_.find(iid); + if (iit == index_h_.end()) { reply = err("ClearScope: bad index id"); break; } + UNSIGNED32 rrc = AdsClearScope(iit->second, which); + if (rrc != 0) { reply = err("ClearScope", rrc); break; } + reply.opcode = Opcode::ClearScopeAck; + break; + } + // M12.17 — single-frame whole-record read. The server + // walks every column once with AdsGetField against a + // suitably-sized buffer (memo lengths queried up-front) + // and packs each value into the ack so the client can + // serve subsequent FieldGet calls from cache without + // another RTT per cell. + case Opcode::FetchCurrentRow: { + if (f.payload.size() < 4) { reply = err("FetchCurrentRow: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + reply.opcode = Opcode::FetchCurrentRowAck; + pack_row_trailer(reply, id); + break; + } + // M12.7 — remote SQL exec. Lazy-creates a parallel ABI + // connection on the server side; cursor handles returned + // by AdsExecuteSQLDirect get wrapped in cursor_tbls_ so the + // existing read-side ops can serve them through the same + // wire opcodes. + case Opcode::ExecuteSQL: { + if (!sess_conn_) { reply = err("ExecuteSQL: not connected"); break; } + if (abi_conn_ == 0) { + if (!ensure_abi_conn()) { + reply = err("ExecuteSQL: AdsConnect60 failed"); + break; + } + if (AdsCreateSQLStatement(abi_conn_, &abi_stmt_) != 0) { + reply = err("ExecuteSQL: AdsCreateSQLStatement failed"); + break; + } + } + std::vector sqlbuf(f.payload.size() + 1); + if (!f.payload.empty()) { + std::memcpy(sqlbuf.data(), f.payload.data(), + f.payload.size()); + } + sqlbuf[f.payload.size()] = 0; + ADSHANDLE hCur = 0; + UNSIGNED32 rrc = AdsExecuteSQLDirect(abi_stmt_, + sqlbuf.data(), &hCur); + if (rrc != 0) { + reply = err("ExecuteSQL: server-side exec failed", rrc); + break; + } + std::uint32_t id = 0; + if (hCur != 0) { + id = next_id_++; + cursor_tbls_.emplace(id, hCur); + } + reply.opcode = Opcode::ExecuteSQLAck; + write_u32_le(id, reply.payload); + break; + } + case Opcode::AppendBlank: { + if (f.payload.size() < 4) { reply = err("AppendBlank: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("AppendBlank: bad table id"); break; + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("AppendBlank: lookup failed"); break; } + // M12.16 dual-handle: CreateIndex/OpenIndex bind bags on the + // parallel ABI Table (tbls_h_), not on the engine Table used by + // the historical write path. Writing only through the engine + // left production CDX bags empty after remote APPEND (Pritpal + // TestIndex: 36 rows / 0 keys). Prefer the ABI handle when it + // exists so sync_all_indexes_ updates every bound tag. + if (auto hit = tbls_h_.find(id); hit != tbls_h_.end()) { + UNSIGNED32 rrc = AdsAppendRecord(hit->second); + if (rrc != 0) { reply = err("AppendBlank", rrc); break; } + // Storm fix — the client commits with DbCommit→FlushTable + // (which flushes this same handle); a per-append flush here + // multiplied CDX page writes ~9x under the 700-storm and + // serialised every ABI op behind the batch. Durability is + // still delivered at commit time. + sync_engine_cursor(id); + tbl->set_pending_append(true); + } else { + auto r = tbl->append_record(); + if (!r) { reply = err("AppendBlank: append_record failed"); break; } + tbl->set_pending_append(true); + } + reply.opcode = Opcode::AppendBlankAck; + break; + } + case Opcode::SetField: { + // payload: [u32 tid][u16 name_len][name bytes][value bytes...] + if (f.payload.size() < 6) { reply = err("SetField: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + std::uint16_t nlen = static_cast( + static_cast(f.payload[4]) | + (static_cast(f.payload[5]) << 8)); + if (f.payload.size() < 6u + nlen) { + reply = err("SetField: truncated"); break; + } + std::string fname(reinterpret_cast( + f.payload.data() + 6), + nlen); + std::string val(reinterpret_cast( + f.payload.data() + 6 + nlen), + f.payload.size() - 6 - nlen); + + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("SetField: bad table id"); break; + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("SetField: lookup failed"); break; } + + auto wr = write_fields(id, tbl, {{fname, val}}); + if (wr.code != 0) { + reply = wr.column_missing + ? err("SetField: column not found") + : err("SetField: write failed", wr.code); + break; + } + reply.opcode = Opcode::SetFieldAck; + break; + } + case Opcode::SetFields: { + // payload: [u32 tid][u16 n][per field: u16 nlen][name] + // [u32 vlen][value]. Write-coalescing batch: the + // client buffers consecutive AdsSet* calls and flushes + // them here in ONE round-trip. First failing field + // stops the apply, exactly like a sequential SetField + // loop failing at the same field. + if (f.payload.size() < 6) { reply = err("SetFields: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + std::size_t pos = 4; + std::uint16_t n = static_cast( + static_cast(f.payload[pos]) | + (static_cast(f.payload[pos + 1]) << 8)); + pos += 2; + std::vector> pairs; + pairs.reserve(n); + bool truncated = false; + for (std::uint16_t k = 0; k < n; ++k) { + if (pos + 2 > f.payload.size()) { truncated = true; break; } + std::uint16_t nlen = static_cast( + static_cast(f.payload[pos]) | + (static_cast(f.payload[pos + 1]) << 8)); + pos += 2; + if (pos + nlen + 4 > f.payload.size()) { truncated = true; break; } + std::string fname(reinterpret_cast( + f.payload.data() + pos), nlen); + pos += nlen; + std::uint32_t vlen = + static_cast(f.payload[pos]) | + (static_cast(f.payload[pos + 1]) << 8) | + (static_cast(f.payload[pos + 2]) << 16) | + (static_cast(f.payload[pos + 3]) << 24); + pos += 4; + if (pos + vlen > f.payload.size()) { truncated = true; break; } + std::string val(reinterpret_cast( + f.payload.data() + pos), vlen); + pos += vlen; + pairs.emplace_back(std::move(fname), std::move(val)); + } + if (truncated) { reply = err("SetFields: truncated"); break; } + if (pairs.size() > openads::network::kMaxWireFields) { + reply = err("SetFields: too many fields"); break; + } + + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("SetFields: bad table id"); break; + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("SetFields: lookup failed"); break; } + + auto wr = write_fields(id, tbl, pairs); + if (wr.code != 0) { + reply = wr.column_missing + ? err("SetFields: column not found") + : err("SetFields: write failed", wr.code); + break; + } + reply.opcode = Opcode::SetFieldsAck; + break; + } + case Opcode::DeleteRecord: { + if (f.payload.size() < 4) { reply = err("DeleteRecord: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("DeleteRecord: bad table id"); break; + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("DeleteRecord: lookup failed"); break; } + if (auto hit = tbls_h_.find(id); hit != tbls_h_.end()) { + // Same twin-cursor sync as SetField: the delete must hit the + // row the engine cursor sits on, and the write guard must + // evaluate that row's lock. + UNSIGNED32 cur = 0; + AdsGetRecordNum(hit->second, 0, &cur); + UNSIGNED32 eng = tbl->recno(); + if (eng != 0 && cur != eng) { + (void)AdsGotoRecord(hit->second, eng); + } + UNSIGNED32 rrc = AdsDeleteRecord(hit->second); + if (rrc != 0) { reply = err("DeleteRecord", rrc); break; } + sync_engine_cursor(id); + } else { + auto r = tbl->mark_deleted(); + if (!r) { + reply = err("DeleteRecord: mark_deleted failed", + static_cast(r.error().code)); + break; + } + } + reply.opcode = Opcode::DeleteRecordAck; + break; + } + case Opcode::RecallRecord: { + if (f.payload.size() < 4) { reply = err("RecallRecord: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("RecallRecord: bad table id"); break; + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("RecallRecord: lookup failed"); break; } + if (auto hit = tbls_h_.find(id); hit != tbls_h_.end()) { + // Mirror DeleteRecord: locks taken via LockRecord land on + // the ABI twin, so recall must go through it too (with the + // same cursor sync) or the engine-side guard sees no lock. + UNSIGNED32 cur = 0; + AdsGetRecordNum(hit->second, 0, &cur); + UNSIGNED32 eng = tbl->recno(); + if (eng != 0 && cur != eng) { + (void)AdsGotoRecord(hit->second, eng); + } + UNSIGNED32 rrc = AdsRecallRecord(hit->second); + if (rrc != 0) { reply = err("RecallRecord", rrc); break; } + sync_engine_cursor(id); + } else { + auto r = tbl->recall_deleted(); + if (!r) { + reply = err("RecallRecord: recall_deleted failed", + static_cast(r.error().code)); + break; + } + } + reply.opcode = Opcode::RecallRecordAck; + break; + } + case Opcode::GotoRecord: { + if (f.payload.size() < 8) { reply = err("GotoRecord: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + std::uint32_t recno = read_u32_le(f.payload.data() + 4); + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("GotoRecord: bad table id"); break; + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("GotoRecord: lookup failed"); break; } + auto r = tbl->goto_record(recno); + if (!r) { reply = err("GotoRecord: failed"); break; } + // Physical GOTO moves only the engine cursor. When an order + // is active the parallel ABI handle (used by ordered Skip) must + // land on the same recno or xBrowse's bookmark DbGoto restore + // leaves the next index Skip walking from a stale key. + if (ordered_tables_.count(id)) { + if (ADSHANDLE hord = ensure_abi_handle(id)) { + (void)AdsGotoRecord(hord, recno); + } + } + reply.opcode = Opcode::GotoRecordAck; + pack_row_trailer(reply, id); + pack_bound_trailer(reply, id); + break; + } + case Opcode::FlushTable: { + if (f.payload.size() < 4) { reply = err("FlushTable: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("FlushTable: bad table id"); break; + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("FlushTable: lookup failed"); break; } + // Flush the ABI twin first (holds open index bags), then engine. + if (auto hit = tbls_h_.find(id); hit != tbls_h_.end()) { + (void)AdsFlushFileBuffers(hit->second); + } + // Remote AdsWriteRecord lands here: the append is now committed, + // so drop the pending-append marker (mirrors the local + // AdsWriteRecord which clears it). Without this the flag stayed + // set for the life of the server-side table. + tbl->set_pending_append(false); + auto r = tbl->flush(); + if (!r) { reply = err("FlushTable: flush failed"); break; } + reply.opcode = Opcode::FlushTableAck; + break; + } + case Opcode::Fetch: { + // M12.11 — payload: + // [u32 tid][u32 max_rows][u8 ncols][u8 nlen][name]... + // Reply: + // [u32 nrows][u8 ncols][per row, per col: u16 vlen][val] + if (f.payload.size() < 9) { reply = err("Fetch: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + std::uint32_t maxrows = read_u32_le(f.payload.data() + 4); + std::uint8_t ncols = f.payload[8]; + std::vector cols; + cols.reserve(ncols); + std::size_t p = 9; + bool parse_ok = true; + for (std::uint8_t c = 0; c < ncols && parse_ok; ++c) { + if (p + 1 > f.payload.size()) { + reply = err("Fetch: truncated col header"); + parse_ok = false; break; + } + std::uint8_t nlen = f.payload[p++]; + if (p + nlen > f.payload.size()) { + reply = err("Fetch: truncated col name"); + parse_ok = false; break; + } + cols.emplace_back( + reinterpret_cast(f.payload.data() + p), + nlen); + p += nlen; + } + if (!parse_ok) break; + + auto write_u16_le = [](std::vector& out, + std::uint16_t v) { + out.push_back(static_cast( v & 0xFFu)); + out.push_back(static_cast((v >> 8) & 0xFFu)); + }; + + reply.opcode = Opcode::FetchAck; + std::vector rowbuf; + std::uint32_t nrows_out = 0; + + if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { + ADSHANDLE hCur = cit->second; + UNSIGNED16 atend = 0; + AdsAtEOF(hCur, &atend); + while (atend == 0 && nrows_out < maxrows) { + for (auto& cn : cols) { + UNSIGNED8 fbuf[64] = {0}; + UNSIGNED8 out [4096] = {0}; + UNSIGNED32 cap = sizeof(out); + std::size_t n = std::min( + cn.size(), sizeof(fbuf) - 1); + std::memcpy(fbuf, cn.data(), n); + fbuf[n] = 0; + UNSIGNED32 rrc = AdsGetField(hCur, fbuf, + out, &cap, 0); + if (rrc != 0) cap = 0; + write_u16_le(rowbuf, + static_cast(cap)); + rowbuf.insert(rowbuf.end(), out, out + cap); + } + ++nrows_out; + if (AdsSkip(hCur, 1) != 0) break; + AdsAtEOF(hCur, &atend); + } + } else if (auto it = tbls_.find(id); + it != tbls_.end() && sess_conn_) { + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("Fetch: lookup failed"); break; } + while (!tbl->eof() && nrows_out < maxrows) { + for (auto& cn : cols) { + std::int32_t fi = tbl->field_index(cn); + std::string val; + if (fi >= 0) { + auto v = tbl->read_field( + static_cast(fi)); + if (v) val = v.value().as_string; + } + write_u16_le(rowbuf, + static_cast(val.size())); + rowbuf.insert(rowbuf.end(), + val.begin(), val.end()); + } + ++nrows_out; + auto sk = tbl->skip(1); + if (!sk) break; + } + } else { + reply = err("Fetch: bad table id"); break; + } + + write_u32_le(nrows_out, reply.payload); + reply.payload.push_back(ncols); + reply.payload.insert(reply.payload.end(), + rowbuf.begin(), rowbuf.end()); + break; + } + case Opcode::FetchWhere: { + // Tier-2 server-side filtered scan. Payload: + // [u32 tid][u32 max_rows][u8 flags][u16 exprlen][expr] + // [u8 ncols][u8 nlen][name]... + // Reply (FetchWhereAck): + // [u32 nrows][u8 ncols] + // [per row: (u32 recno IF WANT_RECNO)(per col: u16 vlen,val)] + // [u8 eof] + // flags=0 reply is byte-identical to v1.4.0 (backward compat). + // The server walks the table from the cursor's current + // position, evaluating `expr` (Clipper-style FOR + // predicate) per row, and emits only the matching rows' + // requested columns until `max_rows` matches or EOF. + // The cursor is left positioned past the last examined + // row so a follow-up FetchWhere resumes the scan. + if (f.payload.size() < 12) { + reply = err("FetchWhere: bad payload"); break; + } + std::uint32_t id = read_u32_le(f.payload.data()); + std::uint32_t maxrows = read_u32_le(f.payload.data() + 4); + std::uint8_t flags = f.payload[8]; + std::uint16_t elen = + static_cast( + static_cast(f.payload[9]) | + (static_cast(f.payload[10]) << 8)); + std::size_t p = 11; + if (p + elen > f.payload.size()) { + reply = err("FetchWhere: truncated expr"); break; + } + std::string expr( + reinterpret_cast(f.payload.data() + p), + elen); + p += elen; + if (p + 1 > f.payload.size()) { + reply = err("FetchWhere: missing ncols"); break; + } + std::uint8_t ncols = f.payload[p++]; + std::vector cols; + cols.reserve(ncols); + bool parse_ok = true; + for (std::uint8_t c = 0; c < ncols && parse_ok; ++c) { + if (p + 1 > f.payload.size()) { + reply = err("FetchWhere: truncated col header"); + parse_ok = false; break; + } + std::uint8_t nlen = f.payload[p++]; + if (p + nlen > f.payload.size()) { + reply = err("FetchWhere: truncated col name"); + parse_ok = false; break; + } + cols.emplace_back( + reinterpret_cast(f.payload.data() + p), + nlen); + p += nlen; + } + if (!parse_ok) break; + + auto write_u16_le = [](std::vector& out, + std::uint16_t v) { + out.push_back(static_cast( v & 0xFFu)); + out.push_back(static_cast((v >> 8) & 0xFFu)); + }; + + // Slice 1 is base-table only: a SQL cursor already + // filters server-side via its WHERE clause, and the + // FOR-predicate evaluator needs an engine Table to read + // the current record. Reject cursor ids with a clear + // error rather than silently mis-filtering. + if (cursor_tbls_.find(id) != cursor_tbls_.end()) { + reply = err("FetchWhere: not supported on SQL " + "cursors (use SQL WHERE)"); + break; + } + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("FetchWhere: bad table id"); break; + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("FetchWhere: lookup failed"); break; } + + reply.opcode = Opcode::FetchWhereAck; + std::vector rowbuf; + std::uint32_t nrows_out = 0; + while (!tbl->eof() && nrows_out < maxrows) { + if (openads::engine::evaluate_index_expr_truthy( + *tbl, expr)) { + if (flags & FetchWhereFlags::WANT_RECNO) { + write_u32_le(tbl->recno(), rowbuf); + } + for (auto& cn : cols) { + std::int32_t fi = tbl->field_index(cn); + std::string val; + if (fi >= 0) { + auto v = tbl->read_field( + static_cast(fi)); + if (v) val = v.value().as_string; + } + write_u16_le(rowbuf, + static_cast(val.size())); + rowbuf.insert(rowbuf.end(), + val.begin(), val.end()); + } + ++nrows_out; + } + auto sk = tbl->skip(1); + if (!sk) break; + } + + write_u32_le(nrows_out, reply.payload); + reply.payload.push_back(ncols); + reply.payload.insert(reply.payload.end(), + rowbuf.begin(), rowbuf.end()); + reply.payload.push_back( + static_cast(tbl->eof() ? 1 : 0)); + break; + } + case Opcode::CustomizeAOF: { + if (f.payload.size() < 7) { + reply = err("CustomizeAOF: bad payload"); break; + } + std::uint32_t id = read_u32_le(f.payload.data()); + std::uint8_t opt = f.payload[4]; + std::uint16_t nrecs = static_cast( + static_cast(f.payload[5]) | + (static_cast(f.payload[6]) << 8)); + std::size_t p = 7; + if (f.payload.size() < p + static_cast(nrecs) * 4u) { + reply = err("CustomizeAOF: truncated recnos"); break; + } + if (cursor_tbls_.find(id) != cursor_tbls_.end()) { + reply = err("CustomizeAOF: not supported on SQL cursors"); + break; + } + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("CustomizeAOF: bad table id"); break; + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("CustomizeAOF: lookup failed"); break; } + if (!tbl->aof_active()) { + reply = err("CustomizeAOF: no active AOF"); break; + } + bool include = false; + if (opt == 1) include = true; + else if (opt == 2) include = false; + else { reply = err("CustomizeAOF: invalid option"); break; } + for (std::uint16_t i = 0; i < nrecs; ++i) { + std::uint32_t recno = read_u32_le(f.payload.data() + p); + p += 4; + (void)tbl->customize_aof_record(recno, include); + } + reply.opcode = Opcode::CustomizeAOFAck; + break; + } + case Opcode::GetRecord: { + if (f.payload.size() < 4) { + reply = err("GetRecord: bad payload"); break; + } + std::uint32_t id = read_u32_le(f.payload.data()); + if (cursor_tbls_.find(id) != cursor_tbls_.end()) { + reply = err("GetRecord: not supported on SQL cursors"); + break; + } + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("GetRecord: bad table id"); break; + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("GetRecord: lookup failed"); break; } + if (!tbl->positioned() || tbl->eof() || tbl->bof() || + tbl->recno() == 0) { + reply = err("GetRecord: no current record"); break; + } + const auto& buf = tbl->record_buffer(); + if (buf.size() > 0xFFFFu) { + reply = err("GetRecord: record too large"); break; + } + reply.opcode = Opcode::GetRecordAck; + auto write_u16 = [](std::vector& out, + std::uint16_t v) { + out.push_back(static_cast( v & 0xFFu)); + out.push_back(static_cast((v >> 8) & 0xFFu)); + }; + write_u16(reply.payload, + static_cast(buf.size())); + reply.payload.insert(reply.payload.end(), + buf.begin(), buf.end()); + break; + } + case Opcode::GetRecordCRC: { + if (f.payload.size() < 4) { + reply = err("GetRecordCRC: bad payload"); break; + } + std::uint32_t id = read_u32_le(f.payload.data()); + if (cursor_tbls_.find(id) != cursor_tbls_.end()) { + reply = err("GetRecordCRC: not supported on SQL cursors"); + break; + } + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("GetRecordCRC: bad table id"); break; + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("GetRecordCRC: lookup failed"); break; } + if (!tbl->positioned() || tbl->eof() || tbl->bof() || + tbl->recno() == 0) { + reply = err("GetRecordCRC: no current record"); break; + } + const std::uint32_t crc = + openads::engine::crc32_record(tbl->record_buffer()); + reply.opcode = Opcode::GetRecordCRAck; + reply.payload.push_back(static_cast( crc & 0xFFu)); + reply.payload.push_back(static_cast((crc >> 8) & 0xFFu)); + reply.payload.push_back(static_cast((crc >> 16) & 0xFFu)); + reply.payload.push_back(static_cast((crc >> 24) & 0xFFu)); + break; + } + case Opcode::SetRecord: { + if (f.payload.size() < 6) { + reply = err("SetRecord: bad payload"); break; + } + std::uint32_t id = read_u32_le(f.payload.data()); + std::uint16_t rlen = static_cast( + static_cast(f.payload[4]) | + (static_cast(f.payload[5]) << 8)); + if (f.payload.size() < 6u + rlen) { + reply = err("SetRecord: truncated record"); break; + } + if (cursor_tbls_.find(id) != cursor_tbls_.end()) { + reply = err("SetRecord: not supported on SQL cursors"); + break; + } + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("SetRecord: bad table id"); break; + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("SetRecord: lookup failed"); break; } + auto r = tbl->set_record_raw(f.payload.data() + 6, + static_cast(rlen)); + if (!r) { reply = err("SetRecord: write failed"); break; } + reply.opcode = Opcode::SetRecordAck; + break; + } + case Opcode::Aggregate: { + // Tier-3 server-side aggregation. Payload: + // [u32 tid][u16 forlen][for_expr][u8 n_aggs] + // per agg: [u8 fn_type][u8 nlen][field_name] + // Reply (AggregateAck): + // [u8 n_aggs] per agg: [u8 result_type][u16 vlen][val] + // The server scans the whole table once (independent of the + // cursor position), folds each matching row into the + // accumulators, and returns one scalar per requested agg. + if (f.payload.size() < 7) { + reply = err("Aggregate: bad payload"); break; + } + std::uint32_t id = read_u32_le(f.payload.data()); + std::uint16_t flen = + static_cast( + static_cast(f.payload[4]) | + (static_cast(f.payload[5]) << 8)); + std::size_t p = 6; + if (p + flen > f.payload.size()) { + reply = err("Aggregate: truncated expr"); break; + } + std::string for_expr( + reinterpret_cast(f.payload.data() + p), flen); + p += flen; + if (p + 1 > f.payload.size()) { + reply = err("Aggregate: missing n_aggs"); break; + } + std::uint8_t naggs = f.payload[p++]; + struct AggReq { std::uint8_t fn; std::string field; }; + std::vector specs; + specs.reserve(naggs); + bool parse_ok = true; + for (std::uint8_t i = 0; i < naggs && parse_ok; ++i) { + if (p + 2 > f.payload.size()) { + reply = err("Aggregate: truncated agg header"); + parse_ok = false; break; + } + AggReq s; + s.fn = f.payload[p++]; + std::uint8_t nlen = f.payload[p++]; + if (p + nlen > f.payload.size()) { + reply = err("Aggregate: truncated field name"); + parse_ok = false; break; + } + s.field.assign( + reinterpret_cast(f.payload.data() + p), + nlen); + p += nlen; + specs.push_back(std::move(s)); + } + if (!parse_ok) break; + + // Base tables only — a SQL cursor aggregates via SQL. + if (cursor_tbls_.find(id) != cursor_tbls_.end()) { + reply = err("Aggregate: not supported on SQL cursors " + "(use SQL aggregates)"); + break; + } + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("Aggregate: bad table id"); break; + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("Aggregate: lookup failed"); break; } + + auto field_is_numeric = + [](openads::drivers::DbfFieldType t) { + using T = openads::drivers::DbfFieldType; + switch (t) { + case T::Numeric: case T::Float: + case T::Integer: case T::Currency: + case T::Double: case T::ShortInt: + case T::AutoInc: case T::AdtMoney: + return true; + default: + return false; + } + }; + + // One accumulator per spec; resolve field index + numeric-ness + // (the latter drives numeric vs lexical MIN/MAX). + std::vector accs; + std::vector fidx; + accs.reserve(specs.size()); + fidx.reserve(specs.size()); + // Validate every spec before touching the table: an unknown + // field name (field_index -> -1) must be rejected, never folded + // as COUNT(*) -- that would silently return the row count for a + // typo'd or injected field. Only COUNT may omit the field. + bool specs_ok = true; + for (const auto& s : specs) { + const auto fn = static_cast(s.fn); + if (s.field.empty()) { + if (fn != openads::engine::AggFn::Count) { + reply = err("Aggregate: empty field only valid for " + "COUNT"); + specs_ok = false; + break; + } + fidx.push_back(-1); + accs.emplace_back(fn, false); + continue; + } + std::int32_t fi = tbl->field_index(s.field); + if (fi < 0) { + reply = err("Aggregate: unknown field " + s.field); + specs_ok = false; + break; + } + const auto& fd = + tbl->field_descriptor(static_cast(fi)); + accs.emplace_back(fn, field_is_numeric(fd.type)); + fidx.push_back(fi); + } + if (!specs_ok) break; + + // Scan the whole table once, restoring the cursor afterwards. + std::uint32_t saved = tbl->recno(); + bool was_eof = tbl->eof(); + tbl->goto_top(); + while (!tbl->eof()) { + if (openads::engine::evaluate_index_expr_truthy( + *tbl, for_expr)) { + for (std::size_t i = 0; i < accs.size(); ++i) { + if (fidx[i] < 0) { + accs[i].feed(false, 0.0, ""); // COUNT(*) + } else { + auto v = tbl->read_field( + static_cast(fidx[i])); + if (v) { + const auto& dv = v.value(); + accs[i].feed(dv.is_null, dv.as_double, + dv.as_string); + } else { + accs[i].feed(true, 0.0, ""); + } + } + } + } + if (!tbl->skip(1)) break; + } + if (!was_eof && saved >= 1 && saved <= tbl->record_count()) + tbl->goto_record(saved); + else + tbl->goto_top(); + + reply.opcode = Opcode::AggregateAck; + auto write_u16 = [](std::vector& out, + std::uint16_t v) { + out.push_back(static_cast( v & 0xFFu)); + out.push_back(static_cast((v >> 8) & 0xFFu)); + }; + reply.payload.push_back( + static_cast(accs.size())); + for (auto& a : accs) { + openads::engine::AggValue val = a.finalize(); + reply.payload.push_back( + static_cast(val.type)); + write_u16(reply.payload, + static_cast(val.bytes.size())); + reply.payload.insert(reply.payload.end(), + val.bytes.begin(), val.bytes.end()); + } + break; + } + case Opcode::Reindex: { + if (f.payload.size() < 4) { reply = err("Reindex: bad payload"); break; } + std::uint32_t id = read_u32_le(f.payload.data()); + auto it = tbls_.find(id); + if (it == tbls_.end() || !sess_conn_) { + reply = err("Reindex: bad table id"); break; + } + auto* tbl = sess_conn_->lookup_table(it->second); + if (!tbl) { reply = err("Reindex: lookup failed"); break; } + auto r = tbl->reindex(); + if (!r) { reply = err("Reindex: reindex failed"); break; } + // The table's bags (production .cdx/.z01 and any OpenIndex + // bag) are bound on the ABI twin (tbls_h_), not on the engine + // table, so the engine reindex above finds no index and is a + // no-op. Rebuild the twin's bags too, as Pack/Zap already do. + // Flush first so the rebuild reads every committed row; + // AdsGotoTop refreshes the twin's cached record count. + if (auto hit = tbls_h_.find(id); hit != tbls_h_.end()) { + if (auto fl = tbl->flush(); !fl) { + reply = err("Reindex: flush failed"); break; + } + (void)AdsGotoTop(hit->second); + UNSIGNED32 rrc = AdsReindex(hit->second); + if (rrc != 0) { reply = err("Reindex", rrc); break; } + } + reply.opcode = Opcode::ReindexAck; + break; + } + case Opcode::MgConnect: { + // Management handshake. Optional [u16 ulen][user] payload — + // when a caller (e.g. DA-Web's Server Info tab) knows which DD + // user it's asking on behalf of, register this mgmt session + // under that name so it shows up as (say) "adssys" instead of + // "(anonymous)" in AdsMgGetUserNames / the Connected Users grid. + // Every accepted socket already gets a session (register_session + // in Session::run(), before any opcode is dispatched), so this + // mgmt-only connection is already tracked — it just never had a + // user name attached to it until now. + if (f.payload.size() >= 2) { + std::uint16_t ulen = static_cast( + static_cast(f.payload[0]) | + (static_cast(f.payload[1]) << 8)); + if (f.payload.size() >= static_cast(2 + ulen) && ulen > 0) { + std::string mgUser(reinterpret_cast(f.payload.data() + 2), ulen); + srv_->set_session_user(sid_, mgUser, ""); + } + } + reply.opcode = Opcode::MgConnectAck; + std::string ok = "mg-ok"; + reply.payload.assign(ok.begin(), ok.end()); + break; + } + case Opcode::MgRequest: { + // Iterator-based ctor: payload.data() may be nullptr when empty, + // and std::string(nullptr, 0) is UB. + std::string reqbuf(f.payload.begin(), f.payload.end()); + auto req = decode_mg_request(reqbuf); + if (!req) { + reply = err("bad mg request"); + break; + } + switch (req.value().kind) { + case MgRequestKind::Snapshot: { + reply.opcode = Opcode::MgReplyAck; + std::string snap = + encode_mg_snapshot(srv_->build_mg_snapshot()); + reply.payload.assign(snap.begin(), snap.end()); + break; + } + case MgRequestKind::KillUser: { + // arg is the 1-based connection number; map it + // to the matching session id and kill it. + srv_->kill_session_by_conn_no(req.value().arg); + reply.opcode = Opcode::MgReplyAck; + break; + } + case MgRequestKind::ResetCommStats: { + openads::mgmt::process_mg_stats().reset_comm(); + reply.opcode = Opcode::MgReplyAck; + break; + } + case MgRequestKind::DumpTables: { + reply.opcode = Opcode::MgReplyAck; + break; + } + default: + reply = err("unknown mg request kind"); + break; + } + break; + } + // M12.29 — AdsDD* Data Dictionary property API, phase 1. See + // docs/wire-protocol.md §9 / wire.h for the payload formats. + case Opcode::DDGetProperty: { + if (!ensure_abi_conn()) { reply = err("DDGetProperty: connect failed"); break; } + if (f.payload.empty()) { reply = err("DDGetProperty: bad payload"); break; } + auto kind = static_cast(f.payload[0]); + std::size_t off = 1; + std::string name, subName; + if (!read_lstr16(f.payload, off, name) || + !read_lstr16(f.payload, off, subName) || + off + 2 > f.payload.size()) { + reply = err("DDGetProperty: bad payload"); break; + } + UNSIGNED16 propId = read_u16_le(f.payload.data() + off); + UNSIGNED16 len = 0; + UNSIGNED32 rc = dd_get_property_dispatch(abi_conn_, kind, name, subName, + propId, nullptr, &len); + if (rc != 0) { reply = err("DDGetProperty", rc); break; } + std::vector buf(len > 0 ? len : 1); + UNSIGNED16 cap = len; + rc = dd_get_property_dispatch(abi_conn_, kind, name, subName, propId, + buf.data(), &cap); + if (rc != 0) { reply = err("DDGetProperty", rc); break; } + reply.opcode = Opcode::DDGetPropertyAck; + write_u32_le(cap, reply.payload); + reply.payload.insert(reply.payload.end(), buf.begin(), buf.begin() + cap); + break; + } + case Opcode::DDSetProperty: { + if (!ensure_abi_conn()) { reply = err("DDSetProperty: connect failed"); break; } + if (f.payload.empty()) { reply = err("DDSetProperty: bad payload"); break; } + auto kind = static_cast(f.payload[0]); + std::size_t off = 1; + std::string name, subName; + if (!read_lstr16(f.payload, off, name) || + !read_lstr16(f.payload, off, subName) || + off + 2 > f.payload.size()) { + reply = err("DDSetProperty: bad payload"); break; + } + UNSIGNED16 propId = read_u16_le(f.payload.data() + off); + off += 2; + if (off + 4 > f.payload.size()) { reply = err("DDSetProperty: bad payload"); break; } + UNSIGNED32 valLen = read_u32_le(f.payload.data() + off); + off += 4; + if (off + valLen > f.payload.size()) { reply = err("DDSetProperty: bad payload"); break; } + // The underlying local AdsDDSet*Property signatures take a + // 16-bit length (the same cap ads_misc.c's PHP extension + // already applies for local calls) — not a wire limitation. + if (valLen > 0xFFFFu) { reply = err("DDSetProperty: value too large"); break; } + void* valPtr = valLen > 0 + ? const_cast(f.payload.data() + off) : nullptr; + UNSIGNED32 rc = dd_set_property_dispatch(abi_conn_, kind, name, subName, + propId, valPtr, static_cast(valLen)); + if (rc != 0) { reply = err("DDSetProperty", rc); break; } + reply.opcode = Opcode::DDSetPropertyAck; + break; + } + case Opcode::DDCreateProc: { + if (!ensure_abi_conn()) { reply = err("DDCreateProc: connect failed"); break; } + std::size_t off = 0; + std::string name, container, procName, inParams, outParams, comments; + if (!read_lstr16(f.payload, off, name) || + !read_lstr16(f.payload, off, container) || + !read_lstr16(f.payload, off, procName) || + !read_lstr16(f.payload, off, inParams) || + !read_lstr16(f.payload, off, outParams) || + !read_lstr16(f.payload, off, comments)) { + reply = err("DDCreateProc: bad payload"); break; + } + auto nameBuf = to_cbuf(name), contBuf = to_cbuf(container), + procBuf = to_cbuf(procName), inBuf = to_cbuf(inParams), + outBuf = to_cbuf(outParams), cmtBuf = to_cbuf(comments); + UNSIGNED32 rc = AdsDDCreateProcedure(abi_conn_, nameBuf.data(), + contBuf.data(), procBuf.data(), 0, inBuf.data(), outBuf.data(), + cmtBuf.data()); + if (rc != 0) { reply = err("DDCreateProc", rc); break; } + reply.opcode = Opcode::DDCreateProcAck; + break; + } + case Opcode::DDCreateFunction: { + if (!ensure_abi_conn()) { reply = err("DDCreateFunction: connect failed"); break; } + std::size_t off = 0; + std::string name, container, impl, retType, inParams, comment; + if (!read_lstr16(f.payload, off, name) || + !read_lstr16(f.payload, off, container) || + !read_lstr16(f.payload, off, impl) || + !read_lstr16(f.payload, off, retType) || + !read_lstr16(f.payload, off, inParams) || + !read_lstr16(f.payload, off, comment)) { + reply = err("DDCreateFunction: bad payload"); break; + } + auto nameBuf = to_cbuf(name), contBuf = to_cbuf(container), + implBuf = to_cbuf(impl), retBuf = to_cbuf(retType), + inBuf = to_cbuf(inParams), cmtBuf = to_cbuf(comment); + UNSIGNED32 rc = AdsDDCreateFunction(abi_conn_, nameBuf.data(), + contBuf.data(), implBuf.data(), retBuf.data(), inBuf.data(), + cmtBuf.data()); + if (rc != 0) { reply = err("DDCreateFunction", rc); break; } + reply.opcode = Opcode::DDCreateFunctionAck; + break; + } + case Opcode::DDCreateTrigger: { + if (!ensure_abi_conn()) { reply = err("DDCreateTrigger: connect failed"); break; } + std::size_t off = 0; + std::string name, table, container, procedure; + if (!read_lstr16(f.payload, off, name) || + !read_lstr16(f.payload, off, table) || + off + 4 > f.payload.size()) { + reply = err("DDCreateTrigger: bad payload"); break; + } + UNSIGNED32 type = read_u32_le(f.payload.data() + off); + off += 4; + if (!read_lstr16(f.payload, off, container) || + !read_lstr16(f.payload, off, procedure) || + off + 4 > f.payload.size()) { + reply = err("DDCreateTrigger: bad payload"); break; + } + UNSIGNED32 priority = read_u32_le(f.payload.data() + off); + auto nameBuf = to_cbuf(name), tblBuf = to_cbuf(table), + contBuf = to_cbuf(container), procBuf = to_cbuf(procedure); + UNSIGNED32 rc = AdsDDCreateTrigger(abi_conn_, nameBuf.data(), + tblBuf.data(), type, 0, contBuf.data(), procBuf.data(), priority); + if (rc != 0) { reply = err("DDCreateTrigger", rc); break; } + reply.opcode = Opcode::DDCreateTriggerAck; + break; + } + case Opcode::DDDropTrigger: { + if (!ensure_abi_conn()) { reply = err("DDDropTrigger: connect failed"); break; } + std::size_t off = 0; + std::string name; + if (!read_lstr16(f.payload, off, name)) { + reply = err("DDDropTrigger: bad payload"); break; + } + auto nameBuf = to_cbuf(name); + UNSIGNED32 rc = AdsDDDropTrigger(abi_conn_, nameBuf.data()); + if (rc != 0) { reply = err("DDDropTrigger", rc); break; } + reply.opcode = Opcode::DDDropTriggerAck; + break; + } + case Opcode::DDDropView: { + if (!ensure_abi_conn()) { reply = err("DDDropView: connect failed"); break; } + std::size_t off = 0; + std::string name; + if (!read_lstr16(f.payload, off, name)) { + reply = err("DDDropView: bad payload"); break; + } + auto nameBuf = to_cbuf(name); + UNSIGNED32 rc = AdsDDDropView(abi_conn_, nameBuf.data()); + if (rc != 0) { reply = err("DDDropView", rc); break; } + reply.opcode = Opcode::DDDropViewAck; + break; + } + case Opcode::DDDropLink: { + if (!ensure_abi_conn()) { reply = err("DDDropLink: connect failed"); break; } + std::size_t off = 0; + std::string name; + if (!read_lstr16(f.payload, off, name)) { + reply = err("DDDropLink: bad payload"); break; + } + auto nameBuf = to_cbuf(name); + UNSIGNED32 rc = AdsDDDropLink(abi_conn_, nameBuf.data(), 0); + if (rc != 0) { reply = err("DDDropLink", rc); break; } + reply.opcode = Opcode::DDDropLinkAck; + break; + } + // M12.30 — AdsDD* Data Dictionary property API, phase 2. See + // docs/wire-protocol.md §5.25 / wire.h for the payload formats. + case Opcode::DDCreateUser: { + if (!ensure_abi_conn()) { reply = err("DDCreateUser: connect failed"); break; } + std::size_t off = 0; + std::string group, user, pwd, desc; + if (!read_lstr16(f.payload, off, group) || + !read_lstr16(f.payload, off, user) || + !read_lstr16(f.payload, off, pwd) || + !read_lstr16(f.payload, off, desc)) { + reply = err("DDCreateUser: bad payload"); break; + } + auto groupBuf = to_cbuf(group), userBuf = to_cbuf(user), + pwdBuf = to_cbuf(pwd), descBuf = to_cbuf(desc); + UNSIGNED32 rc = AdsDDCreateUser(abi_conn_, groupBuf.data(), + userBuf.data(), pwdBuf.data(), descBuf.data()); + if (rc != 0) { reply = err("DDCreateUser", rc); break; } + reply.opcode = Opcode::DDCreateUserAck; + break; + } + case Opcode::DDDropObject: { + if (!ensure_abi_conn()) { reply = err("DDDropObject: connect failed"); break; } + if (f.payload.empty()) { reply = err("DDDropObject: bad payload"); break; } + auto kind = static_cast(f.payload[0]); + std::size_t off = 1; + std::string name; + if (!read_lstr16(f.payload, off, name)) { + reply = err("DDDropObject: bad payload"); break; + } + auto nameBuf = to_cbuf(name); + UNSIGNED32 rc = openads::AE_FUNCTION_NOT_AVAILABLE; + switch (kind) { + case DDObjectKind::User: + rc = AdsDDDeleteUser(abi_conn_, nameBuf.data()); break; + case DDObjectKind::RefIntegrity: + rc = AdsDDRemoveRefIntegrity(abi_conn_, nameBuf.data()); break; + case DDObjectKind::Proc: + rc = AdsDDDropProcedure(abi_conn_, nameBuf.data()); break; + case DDObjectKind::Function: + rc = AdsDDDropFunction(abi_conn_, nameBuf.data()); break; + default: break; + } + if (rc != 0) { reply = err("DDDropObject", rc); break; } + reply.opcode = Opcode::DDDropObjectAck; + break; + } + case Opcode::DDAddUserToGroup: { + if (!ensure_abi_conn()) { reply = err("DDAddUserToGroup: connect failed"); break; } + std::size_t off = 0; + std::string group, user; + if (!read_lstr16(f.payload, off, group) || + !read_lstr16(f.payload, off, user)) { + reply = err("DDAddUserToGroup: bad payload"); break; + } + auto groupBuf = to_cbuf(group), userBuf = to_cbuf(user); + UNSIGNED32 rc = AdsDDAddUserToGroup(abi_conn_, groupBuf.data(), userBuf.data()); + if (rc != 0) { reply = err("DDAddUserToGroup", rc); break; } + reply.opcode = Opcode::DDAddUserToGroupAck; + break; + } + case Opcode::DDRemoveUserFromGroup: { + if (!ensure_abi_conn()) { reply = err("DDRemoveUserFromGroup: connect failed"); break; } + std::size_t off = 0; + std::string group, user; + if (!read_lstr16(f.payload, off, group) || + !read_lstr16(f.payload, off, user)) { + reply = err("DDRemoveUserFromGroup: bad payload"); break; + } + auto groupBuf = to_cbuf(group), userBuf = to_cbuf(user); + UNSIGNED32 rc = AdsDDRemoveUserFromGroup(abi_conn_, groupBuf.data(), userBuf.data()); + if (rc != 0) { reply = err("DDRemoveUserFromGroup", rc); break; } + reply.opcode = Opcode::DDRemoveUserFromGroupAck; + break; + } + case Opcode::DDCreateLink: { + if (!ensure_abi_conn()) { reply = err("DDCreateLink: connect failed"); break; } + std::size_t off = 0; + std::string alias, path, user, pwd; + if (!read_lstr16(f.payload, off, alias) || + !read_lstr16(f.payload, off, path) || + !read_lstr16(f.payload, off, user) || + !read_lstr16(f.payload, off, pwd)) { + reply = err("DDCreateLink: bad payload"); break; + } + auto aliasBuf = to_cbuf(alias), pathBuf = to_cbuf(path), + userBuf = to_cbuf(user), pwdBuf = to_cbuf(pwd); + UNSIGNED32 rc = AdsDDCreateLink(abi_conn_, aliasBuf.data(), pathBuf.data(), + userBuf.data(), pwdBuf.data(), 0); + if (rc != 0) { reply = err("DDCreateLink", rc); break; } + reply.opcode = Opcode::DDCreateLinkAck; + break; + } + case Opcode::DDModifyLink: { + if (!ensure_abi_conn()) { reply = err("DDModifyLink: connect failed"); break; } + std::size_t off = 0; + std::string alias, path, user, pwd; + if (!read_lstr16(f.payload, off, alias) || + !read_lstr16(f.payload, off, path) || + !read_lstr16(f.payload, off, user) || + !read_lstr16(f.payload, off, pwd)) { + reply = err("DDModifyLink: bad payload"); break; + } + auto aliasBuf = to_cbuf(alias), pathBuf = to_cbuf(path), + userBuf = to_cbuf(user), pwdBuf = to_cbuf(pwd); + UNSIGNED32 rc = AdsDDModifyLink(abi_conn_, aliasBuf.data(), pathBuf.data(), + userBuf.data(), pwdBuf.data(), 0); + if (rc != 0) { reply = err("DDModifyLink", rc); break; } + reply.opcode = Opcode::DDModifyLinkAck; + break; + } + case Opcode::DDCreateRefIntegrity: { + if (!ensure_abi_conn()) { reply = err("DDCreateRefIntegrity: connect failed"); break; } + std::size_t off = 0; + std::string name, failTbl, parent, parentTag, child, childTag; + if (!read_lstr16(f.payload, off, name) || + !read_lstr16(f.payload, off, failTbl) || + !read_lstr16(f.payload, off, parent) || + !read_lstr16(f.payload, off, parentTag) || + !read_lstr16(f.payload, off, child) || + !read_lstr16(f.payload, off, childTag) || + off + 4 > f.payload.size()) { + reply = err("DDCreateRefIntegrity: bad payload"); break; + } + UNSIGNED16 updateRule = read_u16_le(f.payload.data() + off); off += 2; + UNSIGNED16 deleteRule = read_u16_le(f.payload.data() + off); off += 2; + auto nameBuf = to_cbuf(name), failBuf = to_cbuf(failTbl), + parentBuf = to_cbuf(parent), parentTagBuf = to_cbuf(parentTag), + childBuf = to_cbuf(child), childTagBuf = to_cbuf(childTag); + UNSIGNED32 rc = AdsDDCreateRefIntegrity(abi_conn_, nameBuf.data(), + failBuf.data(), parentBuf.data(), parentTagBuf.data(), + childBuf.data(), childTagBuf.data(), updateRule, deleteRule); + if (rc != 0) { reply = err("DDCreateRefIntegrity", rc); break; } + reply.opcode = Opcode::DDCreateRefIntegrityAck; + break; + } + case Opcode::DDCreateView: { + if (!ensure_abi_conn()) { reply = err("DDCreateView: connect failed"); break; } + std::size_t off = 0; + std::string name, comments; + if (!read_lstr16(f.payload, off, name) || + !read_lstr16(f.payload, off, comments) || + off + 4 > f.payload.size()) { + reply = err("DDCreateView: bad payload"); break; + } + std::uint32_t sqlLen = read_u32_le(f.payload.data() + off); + off += 4; + if (off + sqlLen > f.payload.size()) { + reply = err("DDCreateView: bad payload"); break; + } + std::string sql(reinterpret_cast(f.payload.data() + off), sqlLen); + off += sqlLen; + auto nameBuf = to_cbuf(name), commentsBuf = to_cbuf(comments), + sqlBuf = to_cbuf(sql); + UNSIGNED32 rc = AdsDDCreateView(abi_conn_, nameBuf.data(), + commentsBuf.data(), sqlBuf.data()); + if (rc != 0) { reply = err("DDCreateView", rc); break; } + reply.opcode = Opcode::DDCreateViewAck; + break; + } + case Opcode::DDAddIndexFile: { + if (!ensure_abi_conn()) { reply = err("DDAddIndexFile: connect failed"); break; } + std::size_t off = 0; + std::string table, index, comment; + if (!read_lstr16(f.payload, off, table) || + !read_lstr16(f.payload, off, index) || + !read_lstr16(f.payload, off, comment)) { + reply = err("DDAddIndexFile: bad payload"); break; + } + auto tableBuf = to_cbuf(table), indexBuf = to_cbuf(index), + commentBuf = to_cbuf(comment); + UNSIGNED32 rc = AdsDDAddIndexFile(abi_conn_, tableBuf.data(), + indexBuf.data(), commentBuf.data()); + if (rc != 0) { reply = err("DDAddIndexFile", rc); break; } + reply.opcode = Opcode::DDAddIndexFileAck; + break; + } + case Opcode::DDRemoveIndexFile: { + if (!ensure_abi_conn()) { reply = err("DDRemoveIndexFile: connect failed"); break; } + std::size_t off = 0; + std::string table, index; + if (!read_lstr16(f.payload, off, table) || + !read_lstr16(f.payload, off, index)) { + reply = err("DDRemoveIndexFile: bad payload"); break; + } + auto tableBuf = to_cbuf(table), indexBuf = to_cbuf(index); + UNSIGNED32 rc = AdsDDRemoveIndexFile(abi_conn_, tableBuf.data(), + indexBuf.data(), 0); + if (rc != 0) { reply = err("DDRemoveIndexFile", rc); break; } + reply.opcode = Opcode::DDRemoveIndexFileAck; + break; + } + case Opcode::DDGetPermissions: { + if (!ensure_abi_conn()) { reply = err("DDGetPermissions: connect failed"); break; } + std::size_t off = 0; + std::string grantee; + if (!read_lstr16(f.payload, off, grantee) || + off + 2 > f.payload.size()) { + reply = err("DDGetPermissions: bad payload"); break; + } + UNSIGNED16 objType = read_u16_le(f.payload.data() + off); off += 2; + std::string objName; + if (!read_lstr16(f.payload, off, objName) || + off + 1 > f.payload.size()) { + reply = err("DDGetPermissions: bad payload"); break; + } + UNSIGNED16 getInherited = f.payload[off]; off += 1; + auto granteeBuf = to_cbuf(grantee), objNameBuf = to_cbuf(objName); + UNSIGNED32 permissions = 0; + UNSIGNED32 rc = AdsDDGetPermissions(abi_conn_, granteeBuf.data(), + objType, objNameBuf.data(), nullptr, getInherited, &permissions); + if (rc != 0) { reply = err("DDGetPermissions", rc); break; } + reply.opcode = Opcode::DDGetPermissionsAck; + write_u32_le(permissions, reply.payload); + break; + } + case Opcode::DDGrantPermission: { + if (!ensure_abi_conn()) { reply = err("DDGrantPermission: connect failed"); break; } + std::size_t off = 0; + if (off + 2 > f.payload.size()) { + reply = err("DDGrantPermission: bad payload"); break; + } + UNSIGNED16 objType = read_u16_le(f.payload.data() + off); off += 2; + std::string objName, grantee; + if (!read_lstr16(f.payload, off, objName) || + !read_lstr16(f.payload, off, grantee) || + off + 4 > f.payload.size()) { + reply = err("DDGrantPermission: bad payload"); break; + } + std::uint32_t permissions = read_u32_le(f.payload.data() + off); + auto objNameBuf = to_cbuf(objName), granteeBuf = to_cbuf(grantee); + UNSIGNED32 rc = AdsDDGrantPermission(abi_conn_, objType, objNameBuf.data(), + nullptr, granteeBuf.data(), permissions); + if (rc != 0) { reply = err("DDGrantPermission", rc); break; } + reply.opcode = Opcode::DDGrantPermissionAck; + break; + } + case Opcode::Mutex: { + if (f.payload.empty()) { + reply = err("Mutex: empty payload"); break; + } + std::uint8_t sub_op = f.payload[0]; + std::size_t pos = 1; + std::string name; + if (!read_lstr16(f.payload, pos, name)) { + reply = err("Mutex: short payload"); break; + } + // Session identifier: use connection serial as owner + std::string owner = conn_serial(); + auto& mm = srv_->mutex_manager(); + switch (static_cast(sub_op)) { + case MutexOp::Create: { + // Record the creating session so its death reaps the + // name (release_session in cleanup) instead of wedging + // every later MutexCreate until server restart. + bool ok = mm.create(name, owner); + reply.opcode = Opcode::Mutex; + reply.payload = { sub_op, static_cast(ok ? 1 : 0) }; + break; + } + case MutexOp::Lock: { + std::uint32_t timeout_ms = 0; + if (pos + 4 <= f.payload.size()) { + timeout_ms = read_u32_le(f.payload.data() + pos); + } + bool ok = mm.lock(name, timeout_ms, owner); + reply.opcode = Opcode::Mutex; + reply.payload = { sub_op, static_cast(ok ? 1 : 0) }; + break; + } + case MutexOp::TryLock: { + bool ok = mm.try_lock(name, owner); + reply.opcode = Opcode::Mutex; + reply.payload = { sub_op, static_cast(ok ? 1 : 0) }; + break; + } + case MutexOp::Unlock: { + bool ok = mm.unlock(name, owner); + reply.opcode = Opcode::Mutex; + reply.payload = { sub_op, static_cast(ok ? 1 : 0) }; + break; + } + case MutexOp::Destroy: { + bool ok = mm.destroy(name, owner); + reply.opcode = Opcode::Mutex; + reply.payload = { sub_op, static_cast(ok ? 1 : 0) }; + break; + } + default: + reply = err("Mutex: unknown sub-opcode"); + break; + } + break; + } + default: { + reply = err("unsupported opcode"); + break; + } + } + return { std::move(reply), false }; +} + +// Pritpal Bedi 29/07/2026 — a remote OrdCreate / AdsCreateIndex61 ignored +// the folder in the index bag name: "cFolder/Indexes/foo.Z01" always landed +// next to the .DBF, because the client used to strip every bag name to its +// basename before sending. The client now sends the path verbatim and the +// SERVER decides where the index goes, mirroring the rules +// Connection::resolve_table_file applies to the .DBF itself: +// - bare filename -> returned unchanged; AdsCreateIndex61 +// falls back to the table's own folder +// - relative with directories -> anchored at the connection data root +// - absolute inside data root -> honored verbatim +// - absolute outside data root -> drive/root folded, remainder joined +// under the data root +// A jail escape (".." outside the root) degrades to the bare filename. +std::string Session::resolve_index_bag_path(const std::string& bag) const { + namespace fs = std::filesystem; + if (!sess_conn_ || sess_conn_->data_dir().empty()) return bag; + std::string s = bag; + for (char& ch : s) if (ch == '\\') ch = '/'; + if (s.find('/') == std::string::npos) return bag; // bare filename + // Same remount as the .DBF: --legacy-paths always folds a client- + // absolute bag under --data (C:/Creative.RAM/T.Z01 → + // /Creative.RAM/T.Z01). Honouring a host-absolute path that + // merely existed next to the app is what put .z01 on the local + // tree while the table updated in the jail (Pritpal Bedi, 12/08/2026). + auto type = openads::engine::TableType::Cdx; + std::string resolved = + sess_conn_->resolve_table_file(s, type, /*for_create=*/true); + std::error_code ec; + fs::create_directories(fs::path(resolved).parent_path(), ec); + return resolved; +} + +std::optional Session::resolve_fs_client_path( + const std::string& client_path) const { + if (!sess_conn_) return std::nullopt; + // Prefer the session connection data directory (already jailed at + // Connect time). Fall back to server multi-root list. + // --legacy-paths: use the prefix-stripping resolver so absolute + // client paths ("C:/TEMP/...") map onto the root instead of + // folding to "/TEMP/...". + if (!sess_conn_->data_dir().empty()) { + if (srv_ && srv_->legacy_paths()) { + return openads::platform::resolve_client_path( + {sess_conn_->data_dir()}, client_path); + } + return openads::platform::resolve_fs_path(sess_conn_->data_dir(), + client_path); + } + if (srv_ && !srv_->data_dir_.empty()) { + auto roots = openads::platform::split_data_roots(srv_->data_dir_); + if (srv_->legacy_paths()) { + return openads::platform::resolve_client_path(roots, client_path); + } + return openads::platform::resolve_fs_path(roots, client_path); + } + return std::nullopt; +} + +} // namespace openads::network From e8645f66e48a9589758e8866ff682d123321a9c2 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Fri, 25 Sep 2026 21:32:11 -0500 Subject: [PATCH 36/97] mtfix12: nav fusion - boundary-pair serve + self-GotoRecord + GetRecordNum anchor (ABI layer) --- src/abi/6-ace_exports.cpp | 42184 ++++++++++++++++++++++++++++++++++++ 1 file changed, 42184 insertions(+) create mode 100644 src/abi/6-ace_exports.cpp diff --git a/src/abi/6-ace_exports.cpp b/src/abi/6-ace_exports.cpp new file mode 100644 index 00000000..92407c22 --- /dev/null +++ b/src/abi/6-ace_exports.cpp @@ -0,0 +1,42184 @@ +#include +#include "openads/ace.h" +#include "openads/error.h" +#include "openads_version.h" // OPENADS_VERSION_STR (CMake-generated) +#include "abi/lock_retry_policy.h" + +// Expose lock_retry_policy() at file scope so ADS_SETLOCKCYCLE etc. can use it. +using openads::abi::lock_retry_policy; + +#include +#include +#include +#include +#include +#include + +#include "abi/backend_table_ops.h" +#include "abi/backend_registry.h" +#include "abi/charset.h" +#include "abi/last_error.h" +#include "abi/runtime.h" + +#include "engine/aof_eval.h" +#include "engine/aof_expr.h" +#include "engine/codepage.h" +#include "engine/fts.h" +#include "engine/aggregate.h" +#include "sql_backend/backend_tx_manager.h" +#include "sql_backend/sql_acl_store.h" +#include "sql_backend/sql_ddl.h" +#include "sql_backend/sql_system_catalog.h" +#include "engine/backup.h" +#include "engine/index_expr.h" +#include "engine/oem_collation.h" +#include "engine/record_crc.h" +#include "engine/table.h" +#include "engine/server_fs.h" +#include "platform/file.h" +#include "platform/fs_sandbox.h" + +#include "network/client.h" +#include "network/remote_index_nav.h" +#if defined(OPENADS_WITH_TLS) +#include "network/tls_transport.h" +#endif +#include "network/mg_wire.h" +#include "network/server.h" +#include "network/socket.h" +#include "mgmt/error_log.h" +#include "mgmt/mg_collector.h" +#include "mgmt/mg_stats.h" +#include "session/connection.h" +#include "session/handle_registry.h" +#include "util/log.h" +#include "util/client_config.h" +#if defined(OPENADS_WITH_SQLITE) +#include "sql_backend/sqlite_connection.h" +#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" +#include "sql_backend/oracle_uri.h" +#endif +#if defined(OPENADS_WITH_MSSQL) +#include "sql_backend/mssql_connection.h" +#include "sql_backend/mssql_index.h" +#include "sql_backend/mssql_table.h" +#include "sql_backend/mssql_uri.h" +#endif +#if defined(OPENADS_WITH_FIREBIRD) +#include "sql_backend/firebird_connection.h" +#include "sql_backend/firebird_index.h" +#include "sql_backend/firebird_uri.h" +#endif +#include "drivers/dbf_common.h" +#include "drivers/index_trait.h" +#include "drivers/ntx/ntx_index.h" +#include "drivers/adt/adt_driver.h" +#include "drivers/cdx/cdx_driver.h" +#include "drivers/cdx/cdx_index.h" +#include "drivers/adi/adi_index.h" +#include "drivers/adm/adm_memo.h" +#include "drivers/fpt/fpt_memo.h" +#include "drivers/memory/memory_driver.h" +#include "engine/script/exec.h" +#include "engine/script/lexer.h" +#include "engine/script/parser.h" +#include "platform/path.h" +#include "platform/proc.h" +#include "platform/time.h" +#include "sql/parser.h" + +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +extern "C++" { +namespace { + +using openads::engine::Table; +using openads::session::Connection; +using openads::session::Handle; +using openads::session::HandleKind; + +// S5 -- SAP display formatting for Date / Timestamp fields. Defined with the +// date-format machinery near AdsSetDateFormat (bottom of file); declared +// here because AdsGetField sits much earlier in this TU. +// +// SAP's rule, probed against ace64.dll (see docs/date-display-format.md): +// AdsGetFIELD returns the value FORMATTED per the connection date format +// ("01/15/2024", blank " / / "); AdsGetSTRING returns the raw +// storage text ("20240115") regardless of format. The two entry points +// intentionally differ -- do not "unify" them. +std::string format_date_display(const std::string& raw); +std::string format_timestamp_display(const std::string& raw); +// Inverse direction: parse text laid out per the CURRENT date format into +// compact "YYYYMMDD" ("03/07/2025" -> "20250307" under MM/DD/CCYY); empty +// result = text does not match the format. AdsSetDate / AdsSetTimeStamp +// route through this, mirroring SAP's format-driven parses. +std::string parse_date_by_format(const std::string& text); +// AdsGetString delegates remote/backend handles through AdsGetField; this +// flag keeps that delegated read RAW so GetString semantics hold there too. +extern thread_local bool g_field_read_raw; + +// SAP ACE defines ADSHANDLE as 32-bit on all platforms while the internal +// registry Handle is 64-bit; centralise the (value-preserving) narrowing. +ADSHANDLE to_ads_handle(Handle h) { return static_cast(h); } + +using openads::abi::detail::ProcessState; +using openads::abi::detail::state; + +// Serialise the file-creating entry points (AdsCreateTable local paths, +// AdsCreateIndex61 native branch) PER TARGET PATH. All serverd sessions +// run in one process, so this closes the TOCTOU window where N racing +// creators each saw "file missing" and all truncated the same new +// DBF/CDX (field report: N=200 remote processes corrupting one table). +// Leaf lock: never take state().mu or any other lock while holding it. +std::mutex& create_path_mu_for(std::string key) { + // Path case varies by caller on case-insensitive filesystems. + for (auto& c : key) + c = static_cast( + std::tolower(static_cast(c))); + static std::mutex g_mu; + static std::unordered_map> + g_map; + std::lock_guard lk(g_mu); + auto& slot = g_map[key]; // never erased: create targets are few + if (!slot) slot = std::make_unique(); + return *slot; +} + +UNSIGNED32 ok() { + openads::abi::clear_last_error(); + return openads::AE_SUCCESS; +} + +UNSIGNED32 fail(const openads::util::Error& e) { + openads::abi::set_last_error(e); + return static_cast(e.code); +} + +UNSIGNED32 fail(int code, const char* msg) { + return fail(openads::util::Error{code, 0, msg ? msg : "", ""}); +} + +// Write a brand-new table file atomically w.r.t. other readers/writers: +// CreateExclusive denies every other open for the duration, so a +// concurrent AdsOpenTable can never read a partially-written header +// (5103 storm) and a create over a file another connection holds OPEN +// fails with AE_FILE_IN_USE (7040) instead of truncating the live table +// underneath its appenders. Overwriting a CLOSED existing file succeeds, +// matching SAP ADS. Returns 7040 when the create failed because the file +// exists (open elsewhere or lost the create race), 5000 otherwise. +UNSIGNED32 write_new_table_file(const std::string& full, + const std::vector& bytes, + const char* op) { + namespace fs = std::filesystem; + auto fres = openads::platform::File::open( + full, openads::platform::OpenMode::CreateExclusive); + if (!fres) { + std::error_code ec; + if (fs::exists(full, ec)) { + return fail(openads::util::Error{ + static_cast(openads::AE_FILE_IN_USE), 0, + std::string(op) + ": file is in use", ""}); + } + return fail(openads::util::Error{ + static_cast(openads::AE_INTERNAL_ERROR), 0, + std::string(op) + ": " + fres.error().message, ""}); + } + auto file = std::move(fres).value(); + auto wrote = file.write_at(0, bytes.data(), bytes.size()); + if (!wrote || wrote.value() != bytes.size()) { + return fail(openads::util::Error{ + static_cast(openads::AE_INTERNAL_ERROR), 0, + std::string(op) + ": write failed", ""}); + } + return openads::AE_SUCCESS; // close (releases exclusivity) via RAII +} + +// ── Tier-1 auto-commit hook ─────────────────────────────────────── +// After a successful DML operation on a SQL backend table, mark the +// transaction dirty and fire auto-commit if the threshold is reached. +// Returns the auto-commit result (ok or error from commit). +template +UNSIGNED32 hook_auto_commit(TableT* tbl) { + if (tbl == nullptr || tbl->conn == nullptr) return ok(); + auto& tx = tbl->conn->tx_manager(); + if (!tx.in_transaction()) return ok(); + tx.dirty = true; + tx.note_dml(); + return ok(); +} + +#if defined(OPENADS_WITH_SQLITE) +void ensure_sqlite_tx_wired(openads::sql_backend::SqliteConnection* c) { + if (!c) return; + openads::sql_backend::wire_standard_savepoint_tx( + c->tx_manager(), [c](const std::string& sql) { (void)c->exec_sql(sql); }); +} +#endif +#if defined(OPENADS_WITH_POSTGRESQL) +void ensure_postgres_tx_wired(openads::sql_backend::PostgresConnection* c) { + if (!c) return; + auto& tx = c->tx_manager(); + if (tx.on_begin) return; + openads::sql_backend::wire_standard_savepoint_tx( + tx, [c](const std::string& sql) { (void)c->exec_sql(sql); }); +} +#endif +#if defined(OPENADS_WITH_MARIADB) +void ensure_maria_tx_wired(openads::sql_backend::MariaConnection* c) { + if (!c) return; + openads::sql_backend::wire_standard_savepoint_tx( + c->tx_manager(), [c](const std::string& sql) { (void)c->exec_sql(sql); }); +} +#endif +#if defined(OPENADS_WITH_ODBC) +void ensure_odbc_tx_wired(openads::sql_backend::OdbcConnection* c) { + if (!c) return; + openads::sql_backend::wire_standard_savepoint_tx( + c->tx_manager(), [c](const std::string& sql) { (void)c->exec_sql(sql); }); +} +#endif +#if defined(OPENADS_WITH_FIREBIRD) +void ensure_firebird_tx_wired(openads::sql_backend::FirebirdConnection* c) { + if (!c) return; + openads::sql_backend::wire_standard_savepoint_tx( + c->tx_manager(), [c](const std::string& sql) { (void)c->exec_sql(sql); }); +} +#endif +#if defined(OPENADS_WITH_MSSQL) +void ensure_mssql_tx_wired(openads::sql_backend::MssqlConnection* c) { + if (!c) return; + openads::sql_backend::wire_mssql_savepoint_tx( + c->tx_manager(), [c](const std::string& sql) { (void)c->exec_sql(sql); }); +} +#endif + +openads::engine::TableType map_type(UNSIGNED16 t) { + switch (t) { + case ADS_NTX: return openads::engine::TableType::Ntx; + case ADS_CDX: return openads::engine::TableType::Cdx; + case ADS_ADT: return openads::engine::TableType::Adt; + case ADS_VFP: return openads::engine::TableType::Vfp; + default: return openads::engine::TableType::Cdx; + } +} + +openads::engine::OpenMode map_open_mode(UNSIGNED16 mode) { + if (mode == ADS_READONLY) return openads::engine::OpenMode::Read; + if (mode == ADS_EXCLUSIVE) return openads::engine::OpenMode::Exclusive; + return openads::engine::OpenMode::Shared; +} + +openads::engine::LockingMode map_locking_mode(UNSIGNED16 lock_type) { + return (lock_type == ADS_PROPRIETARY_LOCKING) + ? openads::engine::LockingMode::Proprietary + : openads::engine::LockingMode::Compatible; +} + +// Stamp DBF header bytes [1..3] (YY MM DD, year as offset from 1900) +// with today's UTC date -- what a real ADS server records when it +// creates or modifies a table. Without this a freshly-created table +// reports a "1900-00-00" last-update stamp until its first record +// write triggers CdxDriver::rewrite_header_(). UTC keeps the two paths +// consistent. `hdr` must point at the start of the 32-byte header. +void stamp_dbf_header_today(std::uint8_t* hdr) { + std::time_t secs = static_cast( + openads::platform::utc_unix_micros() / 1'000'000); + std::tm tm_utc{}; +#ifdef _WIN32 + gmtime_s(&tm_utc, &secs); +#else + gmtime_r(&secs, &tm_utc); +#endif + hdr[1] = static_cast(tm_utc.tm_year); + hdr[2] = static_cast(tm_utc.tm_mon + 1); + hdr[3] = static_cast(tm_utc.tm_mday); +} + +// dBASE/FoxPro field-descriptor bytes 12-15 hold the field's little-endian +// displacement within the record (record byte 0 is the deletion flag, so +// the first field is at 1). OpenADS left them zero: its own reader and +// tolerant ones (LibreOffice) compute offsets from the field lengths and +// never notice, but stricter readers reject the header as corrupt and it +// is simply not what a spec-conforming writer emits (Pritpal Bedi, +// 29/07/2026 -- DBFCDX "corruption detected" report). Stamp the running +// displacement over a just-built descriptor block: `descriptors` points at +// the first 32-byte descriptor, lengths come from each fd[16]. +// `first_offset` is 1 for plain DBF, 5 when a 4-byte VFP _NullFlags +// bitmap precedes the user fields. +void stamp_dbf_field_displacements(std::uint8_t* descriptors, + std::size_t field_count, + std::uint32_t first_offset = 1) { + std::uint32_t off = first_offset; + for (std::size_t i = 0; i < field_count; ++i) { + std::uint8_t* fd = descriptors + i * 32; + fd[12] = static_cast( off & 0xFFu); + fd[13] = static_cast((off >> 8) & 0xFFu); + fd[14] = static_cast((off >> 16) & 0xFFu); + fd[15] = static_cast((off >> 24) & 0xFFu); + off += fd[16]; + } +} + +// Harbour DBFCDX production-index flag (DBF header byte 28, bit 0x01): +// a CDX bag whose basename matches the table's is the structural index +// and Harbour auto-opens it on USE when the bit is set. Stamp it after +// bag creation when the names match. Skipped for OpenADS-encrypted +// tables (header byte 0 = 0xC3/0xC4), whose bytes 28-31 hold the +// encryption-bitmap offset. +void stamp_production_index_flag(openads::engine::Table* t, + const std::string& bag_path) { + if (t == nullptr) return; + namespace fs = std::filesystem; + auto stem_ci = [](const std::string& p) { + std::string s = fs::path(p).stem().string(); + for (auto& c : s) { + c = static_cast( + std::tolower(static_cast(c))); + } + return s; + }; + if (stem_ci(bag_path) != stem_ci(t->path())) return; + auto* drv = t->driver(); + if (drv == nullptr) return; + std::uint8_t b0 = 0, b28 = 0; + auto r0 = drv->file().read_at(0, &b0, 1); + if (!r0 || r0.value() < 1) return; + if (b0 == 0xC3 || b0 == 0xC4) return; + auto r28 = drv->file().read_at(28, &b28, 1); + if (!r28 || r28.value() < 1) return; + if ((b28 & 0x01u) != 0) return; + b28 = static_cast(b28 | 0x01u); + (void)drv->file().write_at(28, &b28, 1); +} + +UNSIGNED16 map_field_type(openads::drivers::DbfFieldType t) { + using openads::drivers::DbfFieldType; + // Constants verified empirically (M8.4) against + // c:\harbour\lib\win\msvc64\rddads.lib -- see include/openads/ace.h + // for the full sweep table. + switch (t) { + case DbfFieldType::Character: return ADS_STRING; // 4 + case DbfFieldType::Numeric: + case DbfFieldType::Float: return ADS_NUMERIC; // 2 + case DbfFieldType::Logical: return ADS_LOGICAL; // 1 + case DbfFieldType::Date: return ADS_DATE; // 3 + case DbfFieldType::DateTime: return ADS_TIMESTAMP; // 14 + case DbfFieldType::Memo: return ADS_MEMO; // 5 + case DbfFieldType::Integer: return ADS_INTEGER; // 11 + case DbfFieldType::Currency: return ADS_MONEY; // 18 + case DbfFieldType::Double: return ADS_DOUBLE; // 10 + case DbfFieldType::Varchar: return ADS_STRING; // M11.1 + case DbfFieldType::Varbinary: return ADS_RAW; // M11.1 + // ADT-native types (M4) + case DbfFieldType::ShortInt: return ADS_SHORTINT; // 12 + case DbfFieldType::Binary: return ADS_BINARY; // 6 + case DbfFieldType::CiCharacter: return ADS_CISTRING; // 25 + case DbfFieldType::AutoInc: return ADS_AUTOINC; // 15 + case DbfFieldType::Time: return ADS_TIME; // 13 + case DbfFieldType::AdtDate: return ADS_DATE; // 3 + case DbfFieldType::AdtTimestamp: return ADS_TIMESTAMP; // 14 + case DbfFieldType::AdtMoney: return ADS_MONEY; // 18 + case DbfFieldType::RowVersion: return ADS_ROWVERSION; // 21 + case DbfFieldType::ModTime: return ADS_MODTIME; // 22 + case DbfFieldType::Unknown: return ADS_FIELD_TYPE_UNKNOWN; + } + return ADS_FIELD_TYPE_UNKNOWN; +} + +[[maybe_unused]] const openads::drivers::DbfField* +find_field(Table* tbl, const std::string& name) { + for (std::uint16_t i = 0; i < tbl->field_count(); ++i) { + const auto& f = tbl->field_descriptor(i); + if (f.name == name) return &f; + } + return nullptr; +} + +// Cursor projections (M10.8). When a SELECT carries a projection +// list, the cursor handle's entry in this map holds the source-field +// indices (in projection order) so AdsGetNumFields / AdsGetFieldName +// / AdsGetField with ADSFIELD(n) report the projected schema instead +// of the underlying table's full layout. +std::unordered_map>& +cursor_projections() { + static std::unordered_map> m; + return m; +} + +// Remote SQL cursors map -- moved out of AdsExecuteSQLDirect so that +// AdsDisconnect can reach it to null out rt->conn before the +// RemoteConnection is freed, preventing use-after-free in AdsCloseTable. +std::unordered_map>& +remote_sql_cursors_map() { + static std::unordered_map> m; + return m; +} + +const std::vector* +projection_for(ADSHANDLE h) { + auto& m = cursor_projections(); + auto it = m.find(h); + if (it == m.end() || it->second.empty()) return nullptr; + return &it->second; +} + +// S4 -- user-visible column names for a projected cursor, parallel to +// cursor_projections(). SAP names a result column after the SELECT list, +// not after the table's declared spelling, so the two must be tracked +// separately; see build_projection_aliases() for the rule. +std::unordered_map>& +cursor_aliases() { + static std::unordered_map> m; + return m; +} + +const std::vector* +aliases_for(ADSHANDLE h) { + auto& m = cursor_aliases(); + auto it = m.find(h); + if (it == m.end() || it->second.empty()) return nullptr; + return &it->second; +} + +// Forget both maps together -- an alias vector outliving its projection +// would misname the next cursor to reuse the handle. +void forget_cursor_projection(ADSHANDLE h) { + cursor_projections().erase(h); + cursor_aliases().erase(h); +} + +// SAP's result-column naming rule, established by probing ace64.dll: +// +// 1. An explicit `AS alias` wins outright. +// 2. Otherwise the column is named with the SPELLING USED IN THE SELECT +// LIST, not the table's declared spelling - `SELECT CLAIMKEY` over a +// column declared `claimkey` reports CLAIMKEY. The table qualifier is +// dropped, so `s.CLAIMKEY` still reports CLAIMKEY. +// 3. Repeats are suffixed `_1`, `_2`, ... in select-list order; the first +// occurrence keeps the bare name. Collisions are detected +// case-insensitively but each item keeps ITS OWN case, so +// `s.ADM_NUM, l.ADM_NUM, s.adm_num` reports ADM_NUM, ADM_NUM_1 and +// adm_num_2 - note the third is lower-cased like the way it was +// written, not like the first. +// +// Returns an empty vector when there is nothing to override. +std::vector +build_projection_aliases(const openads::sql::SelectStmt& st, + std::size_t n) { + if (n == 0) return {}; + std::vector names; + names.reserve(n); + for (std::size_t i = 0; i < n; ++i) { + const std::string* as_alias = nullptr; + for (const auto& pa : st.projection_aliases) + if (pa.first == i) { as_alias = &pa.second; break; } + if (as_alias != nullptr) { + names.push_back(*as_alias); + } else if (i < st.projection.size()) { + names.push_back(st.projection[i]); + } else { + names.emplace_back(); + } + } + auto lower = [](std::string v) { + for (auto& ch : v) + ch = static_cast( + std::tolower(static_cast(ch))); + return v; + }; + std::unordered_map seen; + for (auto& nm : names) { + if (nm.empty()) continue; + const int k = seen[lower(nm)]++; + if (k > 0) nm += "_" + std::to_string(k); + } + return names; +} + +// Projection-aware variant. Called by Get* entry points that take +// hTable + pucField; routes ADSFIELD(n) numeric handles through the +// projection map (n = position within projection, translated to the +// underlying field index). Bare-name lookups stay direct -- rddads +// only ever asks for projected names so the underlying schema's +// extra columns aren't reachable through the cursor anyway. +bool resolve_field_index(Table* tbl, UNSIGNED8* pucField, std::uint16_t* out); +bool resolve_field_index_h(ADSHANDLE h, Table* tbl, + UNSIGNED8* pucField, std::uint16_t* out) { + auto p = reinterpret_cast(pucField); + const auto* proj = projection_for(h); + if (proj != nullptr && p != 0 && p < 0x10000u) { + std::uint16_t one_based = static_cast(p); + if (one_based >= 1 && one_based <= proj->size()) { + *out = (*proj)[one_based - 1]; + return true; + } + return false; + } + // S4 -- a projected column may be visible under a name the underlying + // table does not carry (`AS alias`, or a duplicate suffixed `_1`), so + // match the cursor's own names before the table's. + if (proj != nullptr && p >= 0x10000u && pucField != nullptr) { + if (const auto* al = aliases_for(h); al != nullptr) { + const std::string want(reinterpret_cast(pucField)); + auto same = [](const std::string& a, const std::string& b) { + if (a.size() != b.size()) return false; + for (std::size_t k = 0; k < a.size(); ++k) + if (std::tolower(static_cast(a[k])) != + std::tolower(static_cast(b[k]))) + return false; + return true; + }; + for (std::size_t i = 0; i < al->size() && i < proj->size(); ++i) { + if (!(*al)[i].empty() && same((*al)[i], want)) { + *out = (*proj)[i]; + return true; + } + } + } + } + return resolve_field_index(tbl, pucField, out); +} + +// Resolve a pucField argument to a 0-based field index. Real ACE.h +// defines `ADSFIELD(n)` as `((UNSIGNED8*)(UNSIGNED_PTR)(n))`, so +// callers compiled against that header pass small integers cast to +// pointers. Anything below 0x10000 cannot be a valid string address in +// any real process layout, so we treat it as a 1-based field index. +// Otherwise pucField is a NUL-terminated field name. +bool resolve_field_index(Table* tbl, UNSIGNED8* pucField, std::uint16_t* out) { + if (tbl == nullptr || out == nullptr) return false; + auto p = reinterpret_cast(pucField); + if (p != 0 && p < 0x10000u) { + std::uint16_t one_based = static_cast(p); + if (one_based >= 1 && one_based <= tbl->field_count()) { + *out = static_cast(one_based - 1); + return true; + } + return false; + } + if (pucField == nullptr) return false; + auto name = openads::abi::to_internal(pucField, 0); + // Delegate to Table::field_index -- case-insensitive (matches native + // ACE semantics) and cached. Field names in DBF/ADT storage are + // upper-cased, but callers (and CDX/NTX index expressions) may use + // any case; an exact-case compare here spuriously missed them. + std::int32_t idx = tbl->field_index(name); + if (idx < 0) return false; + *out = static_cast(idx); + return true; +} + +// lookup_table_by_index -- defined further down once IndexBinding is +// known. Returns the Table bound to the given index handle, or null. +Table* lookup_table_by_index(ADSHANDLE h); +openads::drivers::IIndex* iindex_for_handle(ADSHANDLE h); +openads::util::Result activate_binding(ADSHANDLE h); +void purge_bindings_for_table(Table* t); + +// M12.5 -- remote-table lookup helper. Returns nullptr when the +// handle isn't a TCP-routed table. +openads::network::RemoteTable* get_remote_table(ADSHANDLE h) { + auto& s = state(); + return s.registry.lookup( + h, HandleKind::RemoteTable); +} + +// M12.21 option C -- settle the sequential-prefetch lag before any op +// that reads or mutates the server's CURRENT record. Rows served locally +// from the lookahead queue left the server cursor away from the client's +// logical row by cursor_lag (behind it for a forward run, ahead of it for a +// backward one); a Skip(0) -- which the client sends as Skip(cursor_lag) -- +// walks the server cursor to the logical row and the ack zeroes the lag. A +// no-op when nothing was prefetched (the common cold-cache write path pays +// nothing). RCB 07/15/2026: condition is now `!= 0`, since a backward lag is +// negative. +void remote_settle_cursor(openads::network::RemoteTable* rt) { + if (rt != nullptr && rt->conn != nullptr && rt->cursor_lag != 0) { + (void)rt->conn->skip(rt, 0); + } +} + +// Write coalescing for RDD-only apps over WAN (Vouch/ERP — the app cannot +// be changed to batch). After the first set, consecutive AdsSet* calls +// buffer (field, value) locally instead of paying 1 RTT each; the buffer +// flushes as one SetFields batch on the next visibility event (see +// remote_flush_pending call sites). +// +// The FIRST set of a run goes out immediately (probe): write-guard errors +// (record locked by another station — alternating multi-user appends — +// or X#'s GoHot lock-required check) surface on the writing call itself, +// exactly as before. Only runs of 2+ consecutive sets batch, which is +// precisely the voucher-entry shape this exists for. A failed probe +// buffers nothing, so error timing matches the legacy loop field-for-field. +UNSIGNED32 remote_buffered_set(openads::network::RemoteTable* rt, + const std::string& fname, + const std::string& val) { + if (rt == nullptr || rt->conn == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, ""); + } + rt->write_dirty = true; + remote_settle_cursor(rt); + rt->row_valid = false; // M12.17 cache invalidation + if (rt->pending_sets.empty()) { + // Probe: the first set of a run goes out immediately so + // write-guard errors surface on the writing call itself (see + // above). The value is ALSO buffered: after a twin-path append + // the engine cursor has no fetchable row yet, so same-handle + // reads would otherwise miss even the just-probed value. The + // flush re-applies it idempotently (same value, same record). + if (auto r = rt->conn->set_field(rt->id, fname, val); !r) { + return fail(r.error()); + } + // The probe applies server-side at once: a conditional-order + // row may just have entered/left the key set. + rt->key_count_cached = false; + rt->key_counts.clear(); + rt->key_counts.clear(); + } + rt->pending_sets.emplace_back(fname, val); + return ok(); +} + +// Overlay coalesced writes onto a cached row value (latest buffered set +// wins). Lets same-handle read-after-write stay exact without flushing, +// so interleaved validation reads don't break the batching. +bool remote_pending_overlay(openads::network::RemoteTable* rt, + std::size_t i, std::string& vstr) { + if (rt == nullptr || rt->pending_sets.empty() || + i >= rt->fields.size()) { + return false; + } + const std::string& want = rt->fields[i].name; + for (auto it = rt->pending_sets.rbegin(); + it != rt->pending_sets.rend(); ++it) { + if (it->first == want) { vstr = it->second; return true; } + } + return false; +} + +// Flush buffered sets: one SetFields batch when the server advertised +// kCapSetFieldsBatch in ConnectAck, else the legacy per-field loop +// (old servers behave exactly as before). No-op when clean, so hooking +// every visibility event is free for read-mostly flows. Returns an ACE +// code (0 = ok); on error the buffer is dropped with the server-side +// prefix applied — identical to a sequential loop failing mid-way. +// Flush a deferred order switch plainly (see pending_order): used by +// every binding-dependent wire op except GotoTop/GotoBottom, which +// absorb it into their fused frame instead. Returns an ACE code. +UNSIGNED32 remote_flush_order(openads::network::RemoteTable* rt) { + if (rt == nullptr || rt->conn == nullptr || !rt->pending_order) { + return ok(); + } + rt->pending_order = false; + const std::uint32_t oid = rt->pending_order_id; + auto r = oid == 0 + ? rt->conn->set_order_by_name(rt->id, "") + : rt->conn->set_order(rt->id, oid); + if (!r) return fail(r.error()); + // Ack-confirmed: the server binding now matches the belief held + // since pend time. The switch may have repositioned server-side + // state tracking (never the cursor itself — SetOrder moves + // nothing), so row/queue caches established under the old binding + // go, exactly like the immediate path. + rt->server_order_id = oid; + rt->row_valid = false; + rt->invalidate_prefetch(); + // The ack certifies the new order's count: seed slot + map. + if (r.value().counted) { + rt->cached_key_count = r.value().key_count; + rt->key_count_cached = true; + rt->key_counts[oid] = r.value().key_count; + } + return ok(); +} + +UNSIGNED32 remote_flush_teardown(openads::network::RemoteTable* rt); +UNSIGNED32 remote_flush_sets(openads::network::RemoteTable* rt); +// Send a real CloseAllIndexes frame and forget every binding (live +// maps + parked snapshot): the server dropped them all, so any +// client reference would be a dead id (SetOrder 5000 on next use). +UNSIGNED32 remote_emit_close_all(openads::network::RemoteTable* rt) { + if (rt == nullptr || rt->conn == nullptr) return ok(); + if (auto r = rt->conn->close_all_indexes(rt->id); !r) { + return fail(r.error()); + } + rt->close_all_indexes_pending = false; + rt->indexes_parked = false; + rt->parked_nav_which = 0; + rt->parked_by_tag.clear(); + rt->parked_handles.clear(); + rt->index_by_tag.clear(); + rt->index_handles.clear(); + rt->active_index_id = 0; + rt->last_nav = 0; + rt->pair_valid = false; + rt->anchor_ok = false; + return ok(); +} +// Parked-index truth enforcement (see the nav entries): order-dependent +// table-handle navs must resolve a parked snapshot with a real close +// first, or they would walk the stale server order while the client +// accounts natural rows. +UNSIGNED32 remote_close_parked_indexes(openads::network::RemoteTable* rt) { + if (rt == nullptr || rt->conn == nullptr || !rt->indexes_parked) { + return ok(); + } + // A nav carrying its own order context converges the server + // explicitly (fused install / pending switch / active belief + // already acked), so adoption is safe. A truly natural nav is + // safe too when the server never left natural (no order ever + // acked): there is no stale order to walk. Only a natural nav + // against a stale server order needs the real close. + if (rt->pending_order || rt->active_index_id != 0) return ok(); + if (rt->server_order_id == 0 || + rt->server_order_id == + openads::network::RemoteTable::kOrderUnknown) { + return ok(); + } + return remote_emit_close_all(rt); +} +UNSIGNED32 remote_flush_pending(openads::network::RemoteTable* rt) { + if (UNSIGNED32 orc = remote_flush_order(rt); orc != 0) return orc; + if (UNSIGNED32 rc = remote_flush_sets(rt); rc != 0) return rc; + return remote_flush_teardown(rt); +} + +// Buffered-sets half of remote_flush_pending (no teardown): the close +// path uses this so deferred teardown survives to the absorb-or-park +// decision below instead of being emitted first. +UNSIGNED32 remote_flush_sets(openads::network::RemoteTable* rt) { + if (rt == nullptr || rt->conn == nullptr || rt->pending_sets.empty()) { + return ok(); + } + // Land the server on our logical row first: with prefetch drained + // locally the server cursor lags behind, and the batch must apply to + // the row the buffered sets targeted — not where the server sits. + remote_settle_cursor(rt); + // pending[0] is always the probe (already applied server-side at + // buffer time), so the flush covers pending[1:]. A lone probe needs + // no frame at all — single-field edits cost exactly what they did + // before coalescing existed. + if (rt->pending_sets.size() == 1) { + rt->pending_sets.clear(); + rt->row_valid = false; + return ok(); + } + const std::vector> rest( + rt->pending_sets.begin() + 1, rt->pending_sets.end()); + if (rt->conn->server_setfields_batch()) { + if (auto r = rt->conn->set_fields_batch(rt->id, rest); + !r) { + rt->pending_sets.clear(); + rt->row_valid = false; + return fail(r.error()); + } + } else { + for (auto& [fname, val] : rest) { + if (auto r = rt->conn->set_field(rt->id, fname, val); !r) { + rt->pending_sets.clear(); + rt->row_valid = false; + return fail(r.error()); + } + } + } + rt->pending_sets.clear(); + rt->row_valid = false; + return ok(); +} + +// Deferred teardown (WAN chattiness): AdsFlushFileBuffers and +// AdsCloseAllIndexes set flags instead of sending when a CloseTable +// is expected to absorb them. If any OTHER wire op intervenes first, +// emit the deferred frames here, ahead of it, in the app's original +// relative order (flush, then close-all). The close path itself never +// reaches this function with flags set — it absorbs them silently. +UNSIGNED32 remote_flush_teardown(openads::network::RemoteTable* rt) { + if (rt == nullptr || rt->conn == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, ""); + } + // Merged flush: when both are owed, the CloseAllIndexes frame + // alone suffices — the server flushes table data inside that + // handler before dropping bindings. One RTT instead of two for + // rddads' flush→closeall teardown pair. Only against servers that + // advertise kCapFlushInCloseAll; old servers keep both frames. + // Parked exception (below): the park keeps server bindings open, + // so a real CloseAll would destroy what the park preserves — the + // close is adopted unsent, and a flush owed alongside travels + // alone (the merge below only applies when a real close goes out). + if (rt->close_all_indexes_pending && rt->indexes_parked) { + if (rt->flush_file_pending) { + if (auto r = rt->conn->flush_file_buffers(rt->id); !r) { + return fail(r.error()); + } + rt->flush_file_pending = false; + rt->write_dirty = false; + } + rt->close_all_indexes_pending = false; + return ok(); + } + if (rt->flush_file_pending && rt->close_all_indexes_pending && + rt->conn->server_flush_in_closeall()) { + if (auto r = rt->conn->close_all_indexes(rt->id); !r) { + return fail(r.error()); + } + rt->flush_file_pending = false; + rt->close_all_indexes_pending = false; + rt->write_dirty = false; + return ok(); + } + if (rt->flush_file_pending) { + if (auto r = rt->conn->flush_file_buffers(rt->id); !r) { + return fail(r.error()); + } + rt->flush_file_pending = false; + rt->write_dirty = false; + } + if (rt->close_all_indexes_pending) { + // Parked closes were adopted above; what reaches here is + // unparked and needs the real frame. + if (UNSIGNED32 frc = remote_emit_close_all(rt); frc != 0) { + return frc; + } + } + return ok(); +} + +#if defined(OPENADS_WITH_SQLITE) +std::unordered_map>& +sqlite_conns_map() { + static std::unordered_map> m; + return m; +} + +std::unordered_map>& +sqlite_tables_map() { + static std::unordered_map> m; + return m; +} + +void invalidate_sqlite_nav_after_dml( + openads::sql_backend::SqliteConnection* conn) { + if (!conn) return; + for (auto& kv : sqlite_tables_map()) { + if (!kv.second || kv.second->conn != conn || kv.second->is_result) { + continue; + } + kv.second->rec_count_cached = false; + kv.second->row_valid = false; + kv.second->positioned = false; + } +} + +openads::sql_backend::SqliteTable* get_sqlite_table(ADSHANDLE h) { + auto& s = state(); + return s.registry.lookup( + h, HandleKind::SqliteTable); +} + +// ── SQL backend connection lookups ───────────────────────────────── +// Returns the connection object for a SQL backend handle, or nullptr. +openads::sql_backend::SqliteConnection* get_sqlite_conn(ADSHANDLE h) { + auto& s = state(); + return s.registry.lookup( + h, HandleKind::SqliteConnection); +} + +std::unordered_map>& +sqlite_indexes_map() { + static std::unordered_map> m; + return m; +} + +openads::sql_backend::SqliteIndex* get_sqlite_index(ADSHANDLE h) { + auto& s = state(); + return s.registry.lookup( + h, HandleKind::SqliteIndex); +} + +std::size_t sqlite_field_index(openads::sql_backend::SqliteTable* st, + UNSIGNED8* pucField) { + if (!st->fields_cached) { + // st->conn is nulled by AdsDisconnect on still-open tables to + // avoid use-after-free; guard before dereferencing it. + 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_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; +} + +void invalidate_odbc_nav_after_dml( + openads::sql_backend::OdbcConnection* conn) { + if (!conn) return; + for (auto& kv : odbc_tables_map()) { + if (!kv.second || kv.second->conn != conn) continue; + kv.second->rec_count_cached = false; + kv.second->row_valid = false; + kv.second->positioned = false; + } +} + +openads::sql_backend::OdbcTable* get_odbc_table(ADSHANDLE h) { + auto& s = state(); + return s.registry.lookup( + h, HandleKind::OdbcTable); +} + +openads::sql_backend::OdbcConnection* get_odbc_conn(ADSHANDLE h) { + auto& s = state(); + return s.registry.lookup( + h, HandleKind::OdbcConnection); +} + +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_MSSQL) +std::unordered_map>& +mssql_conns_map() { + static std::unordered_map> m; + return m; +} + +std::unordered_map>& +mssql_tables_map() { + static std::unordered_map> m; + return m; +} + +openads::sql_backend::MssqlTable* get_mssql_table(ADSHANDLE h) { + auto& s = state(); + return s.registry.lookup( + h, HandleKind::MssqlTable); +} + +openads::sql_backend::MssqlConnection* get_mssql_conn(ADSHANDLE h) { + auto& s = state(); + return s.registry.lookup( + h, HandleKind::MssqlConnection); +} + +std::unordered_map>& +mssql_indexes_map() { + static std::unordered_map> m; + return m; +} + +openads::sql_backend::MssqlIndex* get_mssql_index(ADSHANDLE h) { + auto& s = state(); + return s.registry.lookup( + h, HandleKind::MssqlIndex); +} + +std::size_t mssql_field_index(openads::sql_backend::MssqlTable* st, + UNSIGNED8* pucField) { + if (pucField == nullptr) return std::numeric_limits::max(); + auto p = reinterpret_cast(pucField); + if (p != 0 && p < 0x10000u) { + auto idx = static_cast(p) - 1; + if (idx < st->field_count()) return idx; + return std::numeric_limits::max(); + } + std::string fname(reinterpret_cast(pucField)); + for (std::size_t i = 0; i < st->field_count(); ++i) { + if (st->field_name(i) == fname) return i; + } + return std::numeric_limits::max(); +} +#endif // OPENADS_WITH_MSSQL + +#if defined(OPENADS_WITH_FIREBIRD) +std::unordered_map>& +firebird_conns_map() { + static std::unordered_map> m; + return m; +} + +std::unordered_map>& +firebird_tables_map() { + static std::unordered_map> m; + return m; +} + +void invalidate_firebird_nav_after_dml( + openads::sql_backend::FirebirdConnection* conn) { + if (!conn) return; + for (auto& kv : firebird_tables_map()) { + if (!kv.second || kv.second->conn != conn || kv.second->is_result) { + continue; + } + kv.second->rec_count_cached = false; + kv.second->row_valid = false; + kv.second->positioned = false; + } +} + +openads::sql_backend::FirebirdTable* get_firebird_table(ADSHANDLE h) { + auto& s = state(); + return s.registry.lookup( + h, HandleKind::FirebirdTable); +} + +openads::sql_backend::FirebirdConnection* get_firebird_conn(ADSHANDLE h) { + auto& s = state(); + return s.registry.lookup( + h, HandleKind::FirebirdConnection); +} + +std::unordered_map>& +firebird_indexes_map() { + static std::unordered_map> m; + return m; +} + +openads::sql_backend::FirebirdIndex* get_firebird_index(ADSHANDLE h) { + auto& s = state(); + return s.registry.lookup( + h, HandleKind::FirebirdIndex); +} + +std::size_t firebird_field_index(openads::sql_backend::FirebirdTable* 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_FIREBIRD + +#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; +} + +void invalidate_maria_nav_after_dml( + openads::sql_backend::MariaConnection* conn) { + if (!conn) return; + for (auto& kv : maria_tables_map()) { + if (!kv.second || kv.second->conn != conn || kv.second->is_result) { + continue; + } + kv.second->rec_count_cached = false; + kv.second->row_valid = false; + kv.second->positioned = false; + } +} + +openads::sql_backend::MariaTable* get_maria_table(ADSHANDLE h) { + auto& s = state(); + return s.registry.lookup( + h, HandleKind::MariaTable); +} + +openads::sql_backend::MariaConnection* get_maria_conn(ADSHANDLE h) { + auto& s = state(); + return s.registry.lookup( + h, HandleKind::MariaConnection); +} + +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; +} + +openads::sql_backend::PostgresConnection* get_postgres_conn(ADSHANDLE h) { + auto& s = state(); + return s.registry.lookup( + h, HandleKind::PostgresConnection); +} + +std::unordered_map>& +postgres_tables_map() { + static std::unordered_map> m; + return m; +} + +void invalidate_postgres_nav_after_dml( + openads::sql_backend::PostgresConnection* conn) { + if (!conn) return; + for (auto& kv : postgres_tables_map()) { + if (!kv.second || kv.second->conn != conn || kv.second->is_result) { + continue; + } + kv.second->rec_count_cached = false; + kv.second->row_valid = false; + kv.second->positioned = false; + } +} + +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; + } + 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); +} + +// M12.29 -- same dispatch helper for remote-connection handles, used by the +// AdsDD* Data Dictionary property functions. dd_from_handle() only ever +// resolves a LOCAL Connection (see its definition below), so every AdsDD* +// entrypoint checks this FIRST and routes through the wire protocol instead +// of silently falling through to "no DD attached" behavior. +openads::network::RemoteConnection* get_remote_connection(ADSHANDLE h) { + auto& s = state(); + return s.registry.lookup( + h, HandleKind::RemoteConnection); +} + +// Forward decl: defined further down with the connection-resolution +// helpers (thread-local rddads default connection). +ADSHANDLE& rddads_default_connection() noexcept; + +// Resolve a caller connection handle -- 0 (rddads' "current connection" +// cleared by a disconnect), a thread-local default that is already +// disconnected, or a stale handle read from rddads' process-wide slot +// before another thread's disconnect cleared it -- to a live +// RemoteConnection handle. A valid handle passes through untouched; +// otherwise the thread-local default (last AdsConnect60 on this thread) +// wins if live, and failing that any surviving live RemoteConnection is +// adopted -- mirroring SAP ACE's internal default-connection behaviour, +// where disconnecting one connection must not render the surviving ones +// unusable. Returns 0 when no live remote connection exists (callers +// then fail fast or take their local fallback, as appropriate). +ADSHANDLE resolve_remote_conn_handle(ADSHANDLE h) { + auto& s = state(); + auto live_handle = [&](ADSHANDLE hh) -> ADSHANDLE { + if (hh == 0) return 0; + if (auto* rc = s.registry.lookup( + hh, HandleKind::RemoteConnection)) { + if (rc->valid()) return hh; + } + return 0; + }; + if (ADSHANDLE r = live_handle(h)) return r; + if (ADSHANDLE r = live_handle(rddads_default_connection())) return r; + ADSHANDLE found = 0; + s.registry.for_each_handle([&](Handle hh, HandleKind k, void* p) { + if (found != 0 || k != HandleKind::RemoteConnection) return; + auto* rc = static_cast(p); + if (rc != nullptr && rc->valid()) found = to_ads_handle(hh); + }); + return found; +} + +namespace { + +Handle handle_for_remote_table(openads::network::RemoteTable* rt) { + if (rt == nullptr) return 0; + return state().registry.find_handle(HandleKind::RemoteTable, rt); +} + +bool remote_table_has_index(const openads::network::RemoteTable* rt) { + return rt != nullptr && + (rt->active_index_id != 0 || !rt->index_by_tag.empty()); +} + +// Diagnostic WAN trace. Off unless wire_trace=1; no file opens or timestamps +// on the disabled request path. Payload and seek keys are never logged. +static bool cli_trace_on() { + static const bool on = openads::util::client_setting_truthy( + "OPENADS_WIRE_TRACE", "wire_trace"); + return on; +} + +static const std::string& cli_trace_path() { + static const std::string path = [] { + auto p = openads::util::client_setting("OPENADS_WIRE_TRACE_FILE", + "wire_trace_file"); + return p.empty() ? std::string("C:/tmp/cli_trace.log") : p; + }(); + return path; +} + +struct CliTraceState { + std::mutex mu; + std::map, std::string> tables; + std::map, std::string> indexes; +}; +static CliTraceState& cli_trace_state() { + static CliTraceState trace; + return trace; +} + +// Called after a successful open; table ID belongs to its connection, +// not globally. Do not hold this mutex while performing any wire request. +static void cli_trace_table_open(const openads::network::RemoteTable* rt) { + if (!cli_trace_on() || !rt || !rt->conn) return; + auto& trace = cli_trace_state(); + std::lock_guard lk(trace.mu); + trace.tables[{rt->conn, rt->id}] = + rt->alias.empty() ? rt->name : rt->alias; +} +static void cli_trace_table_close(const openads::network::RemoteTable* rt) { + if (!cli_trace_on() || !rt || !rt->conn) return; + auto& trace = cli_trace_state(); + std::lock_guard lk(trace.mu); + trace.tables.erase({rt->conn, rt->id}); + for (const auto& entry : rt->index_by_tag) + trace.indexes.erase({rt->conn, entry.second}); +} + +static void cli_trace_write_locked(FILE* hf, long long ms, + const char* who, const char* op, + const char* detail) { + const auto now = std::chrono::system_clock::now(); + const auto epoch_ms = std::chrono::duration_cast( + now.time_since_epoch()).count(); + const auto secs = std::chrono::system_clock::to_time_t(now); + std::tm local{}; +#if defined(_WIN32) + localtime_s(&local, &secs); +#else + localtime_r(&secs, &local); +#endif + char stamp[32]; + std::strftime(stamp, sizeof(stamp), "%Y-%m-%d %H:%M:%S", &local); + std::fprintf(hf, "%s.%03lld [+%9lldms] [%-20.20s] [%-20.20s] %s\n", + stamp, static_cast(epoch_ms % 1000), ms, + who ? who : "-", op ? op : "-", detail ? detail : ""); +} + +static void cli_trace_line(long long ms, const char* who, const char* op, + const char* detail) { + if (!cli_trace_on()) return; + auto& trace = cli_trace_state(); + std::lock_guard lk(trace.mu); + FILE* hf = std::fopen(cli_trace_path().c_str(), "a"); + if (!hf) return; + cli_trace_write_locked(hf, ms, who, op, detail); + std::fclose(hf); +} + +static long long cli_trace_ms() { + static const auto t0 = std::chrono::steady_clock::now(); + return static_cast( + std::chrono::duration_cast( + std::chrono::steady_clock::now() - t0).count()); +} + +static void cli_trace(const char* op, const char* fmt, ...) { + char buf[512]; + va_list ap; + va_start(ap, fmt); + vsnprintf(buf, sizeof(buf) - 1, fmt, ap); + va_end(ap); + buf[sizeof(buf) - 1] = 0; + cli_trace_line(cli_trace_ms(), "-", op, buf); +} +static void cli_trace_tbl(const openads::network::RemoteTable* rt, + const char* op, const char* fmt, ...) { + if (!cli_trace_on()) return; + const std::string who = rt == nullptr ? "-" : + (!rt->alias.empty() ? rt->alias : rt->name); + char buf[512]; + va_list ap; + va_start(ap, fmt); + vsnprintf(buf, sizeof(buf) - 1, fmt, ap); + va_end(ap); + buf[sizeof(buf) - 1] = 0; + cli_trace_line(cli_trace_ms(), who.c_str(), op, buf); +} + +static const char* cli_trace_opcode(std::uint8_t op) { + switch (op) { + case 0x01: return "Hello"; + case 0x10: return "Connect"; + case 0x12: return "Disconnect"; + case 0x20: return "OpenTable"; + case 0x22: return "CloseTable"; + case 0x30: return "ExecuteSQL"; + case 0x32: return "Fetch"; + case 0x40: return "GotoTop"; + case 0x42: return "Skip"; + case 0x44: return "GetField"; + case 0x46: return "GetRecordCount"; + case 0x48: return "AtEOF"; + case 0x4A: return "DescribeTable"; + case 0x4C: return "AtBOF"; + case 0x4E: return "GetRecordNum"; + case 0x62: return "IsRecordDeleted"; + case 0x64: return "GotoBottom"; + case 0x66: return "IsFound"; + case 0x68: return "RefreshRecord"; + case 0x6A: return "GetTableType"; + case 0x6C: return "GetRecordLength"; + case 0x6E: return "GetNumIndexes"; + case 0x70: return "GetLastAutoinc"; + case 0x72: return "LockRecord"; + case 0x74: return "UnlockRecord"; + case 0x76: return "LockTable"; + case 0x78: return "UnlockTable"; + case 0x7A: return "PackTable"; + case 0x7C: return "ZapTable"; + case 0x7E: return "FlushFileBuffers"; + case 0x80: return "CloseAllIndexes"; + case 0x82: return "SetAOF"; + case 0x84: return "ClearAOFRemote"; + case 0x86: return "GetAOFOptLevel"; + case 0x88: return "OpenIndex"; + case 0x8A: return "CloseIndex"; + case 0x8C: return "SetOrder"; + case 0x8E: return "SetOrderByName"; + case 0x90: return "Seek"; + case 0x92: return "SeekLast"; + case 0x94: return "CreateIndex"; + case 0x96: return "SkipUnique"; + case 0x98: return "SetScope"; + case 0x9A: return "ClearScope"; + case 0x9C: return "FetchCurrentRow"; + case 0x50: return "AppendBlank"; + case 0x52: return "SetField"; + case 0x5E: return "SetFields"; + case 0x54: return "DeleteRecord"; + case 0x56: return "RecallRecord"; + case 0x58: return "GotoRecord"; + case 0x5A: return "FlushTable"; + case 0x5C: return "GetKeyType"; + case 0x60: return "Reindex"; + case 0x9E: return "GetLastTableUpdate"; + case 0x13: return "IsRecordLocked"; + case 0x15: return "GetAllLocks"; + case 0xA0: return "MgConnect"; + case 0xA2: return "MgRequest"; + case 0xA4: return "FetchWhere"; + case 0xA6: return "Aggregate"; + case 0xA8: return "GetRecord"; + case 0xAA: return "SetRecord"; + case 0xAC: return "CustomizeAOF"; + case 0xAE: return "GetRecordCRC"; + case 0xB0: return "GetKeyCount"; + case 0x03: return "GetKeyNum"; + case 0x05: return "FindTables"; + case 0x07: return "BeginTransaction"; + case 0x09: return "CommitTransaction"; + case 0x0B: return "RollbackTransaction"; + case 0x0D: return "FindRecord"; + case 0x17: return "ZipArchive"; + case 0x19: return "UnzipArchive"; + case 0x1B: return "ZipList"; + case 0xB2: return "DDGetProperty"; + case 0xB4: return "DDSetProperty"; + case 0xB6: return "DDCreateProc"; + case 0xB8: return "DDCreateFunction"; + case 0xBA: return "DDCreateTrigger"; + case 0xBC: return "DDDropTrigger"; + case 0xBE: return "DDDropView"; + case 0xC0: return "DDDropLink"; + case 0xC2: return "DDCreateUser"; + case 0xC4: return "DDDropObject"; + case 0xC6: return "DDAddUserToGroup"; + case 0xC8: return "DDRemoveUserFromGroup"; + case 0xCA: return "DDCreateLink"; + case 0xCC: return "DDModifyLink"; + case 0xCE: return "DDCreateRefIntegrity"; + case 0xD0: return "DDCreateView"; + case 0xD2: return "DDAddIndexFile"; + case 0xD4: return "DDRemoveIndexFile"; + case 0xD6: return "DDGetPermissions"; + case 0xD8: return "DDGrantPermission"; + case 0xDA: return "ShowDeleted"; + case 0xDC: return "CreateTable"; + case 0xDE: return "DropTable"; + case 0xE0: return "FileExists"; + case 0xE2: return "FileErase"; + case 0xE4: return "FileRename"; + case 0xE6: return "FileSize"; + case 0xE8: return "FileMTime"; + case 0xEA: return "Directory"; + case 0xEC: return "DirExist"; + case 0xEE: return "DirMake"; + case 0xF0: return "DirRemove"; + case 0xF2: return "FOpen"; + case 0xF4: return "FCreate"; + case 0xF6: return "FClose"; + case 0xF8: return "FRead"; + case 0xFA: return "FWrite"; + case 0xFC: return "FSeek"; + case 0xFE: return "Mutex"; + default: return "Other"; + } +} + +// One line per completed request/reply. Connection and table ID identify +// overlapping aliases, duration includes send+receive; no payload bytes. +static void cli_trace_frame_hook(const void* conn, std::uint8_t op, + std::uint32_t tid, std::size_t req_bytes, + std::uint8_t rep_op, std::size_t rep_bytes, + long long us) { + auto& trace = cli_trace_state(); + std::lock_guard lk(trace.mu); + std::string table = "-"; + const bool index_op = op == 0x90 || op == 0x92 || op == 0x8A || + op == 0x8C || op == 0x8E || op == 0x5C; + if (index_op) { + const auto ix = trace.indexes.find({conn, tid}); + if (ix != trace.indexes.end()) table = ix->second; + } else if (op != 0x01 && op != 0x10 && op != 0x20 && + op != 0x30 && op != 0x32 && op != 0x05 && op != 0x07 && + op != 0x09 && op != 0x0B) { + const auto it = trace.tables.find({conn, tid}); + if (it != trace.tables.end()) table = it->second; + } + char buf[200]; + std::snprintf(buf, sizeof(buf), + "wire op=0x%02X tid=%u req=%lu ack=0x%02X ackb=%lu dur_us=%lld conn=%04X", + static_cast(op), static_cast(tid), + static_cast(req_bytes), + static_cast(rep_op), + static_cast(rep_bytes), us, + static_cast(reinterpret_cast(conn) & 0xFFFFu)); + FILE* hf = std::fopen(cli_trace_path().c_str(), "a"); + if (!hf) return; + cli_trace_write_locked(hf, cli_trace_ms(), table.c_str(), + cli_trace_opcode(op), buf); + std::fclose(hf); +} + +void remote_clear_nav_boundaries(openads::network::RemoteTable* rt) { + if (rt == nullptr) return; + rt->nav_at_bof = false; + rt->nav_at_eof = false; + rt->nav_not_bof = false; + rt->nav_not_eof = false; +} + +void remote_ensure_rec_count(openads::network::RemoteTable* rt) { + if (rt == nullptr || rt->rec_count_cached) return; + if (auto r = rt->conn->record_count(rt->id)) { + rt->cached_rec_count = r.value(); + rt->rec_count_cached = true; + } +} + +// Scoped key count of the active order -- the server computes it scope + +// SET DELETED aware, so it matches what OrdKeyCount reports. Falls back +// to the physical record count in natural order. The remote keyno +// machinery (GoBottom, KeyGoto, RelKeyPos, skip clamp) must use THIS, +// not cached_rec_count: with a scope of 1 live row in a 36-row table, +// clamping to 36 made GoBottom report KeyNo=36 and sent KeyGoto/rel-pos +// walks 35 rows past the scope end -- the phantom/duplicate rows in Tim +// Stone's scoped remote xBrowse (31/07/2026). +std::uint32_t remote_ensure_key_count(openads::network::RemoteTable* rt) { + if (rt == nullptr) return 0; + if (rt->active_index_id == 0) { + // Natural record order (or no order installed yet): key position + // tracks recno, so the count is the physical record count. + remote_ensure_rec_count(rt); + return rt->rec_count_cached ? rt->cached_rec_count : 0u; + } + if (!rt->key_count_cached) { + // Second-chance: rotation revisits orders whose counts were + // fetched before (order switches don't change counts). + auto hit = rt->key_counts.find(rt->active_index_id); + if (hit != rt->key_counts.end()) { + rt->cached_key_count = hit->second; + rt->key_count_cached = true; + } else if (auto r = rt->conn->key_count(rt->id)) { + rt->cached_key_count = r.value(); + rt->key_count_cached = true; + rt->key_counts[rt->active_index_id] = r.value(); + } + } + return rt->key_count_cached ? rt->cached_key_count : 0u; +} + +void remote_update_nav_boundaries(openads::network::RemoteTable* rt, + std::int32_t step, + std::uint32_t rec_before, + bool row_valid_before) { + if (rt == nullptr || step == 0) return; + // Proven-false stickies derive ONLY from row_valid_before and + // landed-row facts — never from the at_* flags, which can predate + // scope/filter edits elsewhere. A stale TRUE here would answer a + // live boundary call wrongly; a stale FALSE only costs a frame. + if (step < 0) { + rt->nav_at_eof = false; + rt->nav_not_eof = row_valid_before; + if (!rt->row_valid) { + rt->nav_at_bof = true; + rt->nav_not_bof = false; + return; + } + rt->nav_at_bof = + row_valid_before && rt->current_recno == rec_before; + // A backward step that lands on a row is on neither limit: + // from a row it moved back, from the EOF limit it re-entered. + rt->nav_not_bof = true; + rt->nav_not_eof = true; + return; + } + rt->nav_at_bof = false; + rt->nav_not_bof = row_valid_before; + if (!rt->row_valid) { + rt->nav_at_eof = true; + rt->nav_not_eof = false; + return; + } + rt->nav_at_eof = + row_valid_before && rt->current_recno == rec_before; + // A forward step that lands on a row is on neither limit either. + rt->nav_not_bof = true; + rt->nav_not_eof = true; + return; +} + +void remote_sync_keyno_gototop(openads::network::RemoteTable* rt) { + if (rt == nullptr) return; + remote_clear_nav_boundaries(rt); + if (remote_table_has_index(rt)) { + rt->current_keyno = 1; + rt->keyno_valid = true; + } else if (rt->row_valid) { + rt->current_keyno = rt->current_recno; + rt->keyno_valid = true; + } else { + rt->keyno_valid = false; + } + // Boundary truth: on a row ⇒ not-BOF; no row ⇒ empty ⇒ both limits. + if (rt->row_valid) { + rt->nav_not_bof = true; + } else { + rt->nav_at_bof = true; + rt->nav_at_eof = true; + } +} + +// True when the server itself certified, on the latest cursor-affecting +// frame, that this table's cursor sits on no row with BOTH limits set +// (the xBase empty-cursor state), and nothing client-side could have +// changed what that means since. Filters are excluded on purpose: the +// server key/record counts do not apply them, so an empty filtered +// cursor says nothing about the counts. +bool remote_cursor_proven_empty(const openads::network::RemoteTable* rt) { + return rt != nullptr && rt->conn != nullptr && + !rt->row_valid && + rt->bound_bof_ok && rt->bound_bof && + rt->bound_eof_ok && rt->bound_eof && + rt->bound_seq == rt->conn->nav_seq() && + !rt->pending_order && + rt->pending_sets.empty() && + rt->filter_expr.empty() && rt->aof_expr.empty(); +} + +// "Still empty" window (user-approved trade-off, 23/09/2026): after the +// server certifies a table physically EMPTY (record count 0) with the +// cursor on no row (BOF+EOF), Seek / GO n on it are answered "not found" +// locally for a short time instead of paying a round trip each. Vouch +// probes its empty per-user settings tables ~280 times per startup. +// Risk accepted: a record another STATION adds inside the window is seen +// only when the window ends. This station's own writes (any append / +// field write / SQL on this connection) close the window at once. +// OPENADS_EMPTY_TTL_MS: window length, default 1500, 0 = off, max 2000. +std::uint32_t remote_empty_ttl_ms() { + static const std::uint32_t v = [] { + const char* e = std::getenv("OPENADS_EMPTY_TTL_MS"); + if (e == nullptr || *e == 0) return 1500u; + long x = std::strtol(e, nullptr, 10); + if (x < 0) x = 0; + if (x > 2000) x = 2000; + return static_cast(x); + }(); + return v; +} + +// mtfix12 R2 opt-in envelope (his explicit flag): conn-wide by +// default — any cursor-affecting frame on the connection expires the +// self-goto anchor. OPENADS_NAV_SELF_GOTO=table scopes freshness to +// the table handle: server cursors are per handle, so only this +// handle's frames can move its cursor, and the per-table generation +// (bumped centrally in request()) sees them all. For his deployment — +// writes coordinated by SEMA4s and physical locks — this is safe; the +// general default stays conn-wide. +bool remote_self_goto_per_table() { + static const bool v = [] { + const char* e = std::getenv("OPENADS_NAV_SELF_GOTO"); + return e != nullptr && std::strcmp(e, "table") == 0; + }(); + return v; +} + +bool remote_empty_window(const openads::network::RemoteTable* rt) { + if (rt == nullptr || rt->conn == nullptr) return false; + const std::uint32_t ttl = remote_empty_ttl_ms(); + if (ttl == 0) return false; + if (rt->row_valid || !rt->pending_sets.empty() || rt->write_dirty) + return false; + if (!(rt->bound_bof_ok && rt->bound_bof && + rt->bound_eof_ok && rt->bound_eof)) + return false; + // The count must come from the same certification as the bounds. + if (!(rt->count_bound_ok && rt->count_bound == 0 && + rt->count_bound_seq == rt->bound_seq)) + return false; + if (rt->bound_data_epoch != rt->conn->data_epoch()) return false; + const auto age = std::chrono::steady_clock::now() - rt->bound_at; + return age >= std::chrono::steady_clock::duration::zero() && + age <= std::chrono::milliseconds(ttl); +} + +// Leave the table exactly as a wire answer on an empty table would: +// no row, both limits, recno/count unchanged, certified at the current +// seq (bound_at is NOT refreshed: the window runs from the last real +// server answer, never extended by local answers). +void remote_empty_restamp(openads::network::RemoteTable* rt) { + const std::uint64_t seq = rt->conn->nav_seq(); + rt->row_valid = false; + rt->invalidate_prefetch(); + rt->nav_at_bof = true; + rt->nav_at_eof = true; + rt->nav_not_bof = false; + rt->nav_not_eof = false; + rt->bound_seq = seq; + rt->recno_bound_seq = seq; + rt->count_bound_seq = seq; + rt->last_nav = 0; + // The serve lands on no row: no anchor, and any certified pair + // landing described a state this serve just replaced. + rt->pair_valid = false; + rt->anchor_ok = false; +} + +// Memo key for AdsGetRecordLength re-opens: lowercased table name plus +// the whole schema. Empty (= no memo) when the schema is not known. +std::string remote_record_length_key(const openads::network::RemoteTable* rt) { + if (rt == nullptr || !rt->fields_cached || rt->fields.empty() || + rt->name.empty()) { + return {}; + } + std::string k = rt->name; + for (auto& c : k) + c = static_cast(std::tolower(static_cast(c))); + for (const auto& fd : rt->fields) { + k.push_back('\x1f'); + k += fd.name; + k.push_back('\x1e'); + k += std::to_string(fd.type) + "," + std::to_string(fd.length) + + "," + std::to_string(fd.decimals); + } + return k; +} + +void remote_sync_keyno_gotobottom(openads::network::RemoteTable* rt) { + if (rt == nullptr) return; + remote_clear_nav_boundaries(rt); + if (remote_table_has_index(rt)) { + // An ordered GotoBottom that the server certified empty (no row, + // BOF+EOF) proves the order holds no visible keys in its scope: + // the server key count (scope + SET DELETED aware, filter-blind) + // is 0. Seed it instead of asking — the empty tables of a USE + // otherwise pay one GetKeyCount frame each here. + if (rt->active_index_id != 0 && !rt->key_count_cached && + remote_cursor_proven_empty(rt)) { + rt->cached_key_count = 0; + rt->key_count_cached = true; + rt->key_counts[rt->active_index_id] = 0; + } + // Bottom of the ORDER, not of the file: with a scope active the + // last key is key #scoped_key_count, not #physical_rec_count. + const std::uint32_t kc = remote_ensure_key_count(rt); + rt->current_keyno = kc > 0 ? kc : 1u; + rt->keyno_valid = true; + } else if (rt->row_valid) { + rt->current_keyno = rt->current_recno; + rt->keyno_valid = true; + } else { + rt->keyno_valid = false; + } + // Boundary truth: on a row ⇒ not-EOF; no row ⇒ empty ⇒ both limits. + if (rt->row_valid) { + rt->nav_not_eof = true; + } else { + rt->nav_at_bof = true; + rt->nav_at_eof = true; + } +} + +// Consecutive-duplicate nav detection (WAN chattiness: rddads issues +// back-to-back GotoTop/GotoBottom pairs per USE). True when the table's +// last wire op was `which` (1 = top, 2 = bottom) in the same order +// context with no cursor-affecting frame on the connection since — the +// skipped frame would re-establish byte-identical state, so the only +// observable difference is one less round-trip. +// The dedupe path still re-runs the keyno sync + relation apply (both +// local once caches are warm) so every side effect but the frame stays. +bool remote_nav_duplicate(openads::network::RemoteTable* rt, int which, + std::uint32_t order) { + return rt != nullptr && rt->conn != nullptr && rt->last_nav == which && + rt->last_nav_order == order && + rt->last_nav_seq == rt->conn->nav_seq(); +} + +// Stamp after a successful wire GotoTop/GotoBottom. Also feeds the +// empty-cursor sticky: a top/bottom that produced no row proves an +// empty cursor, so AtBOF/AtEOF answer locally until a cursor-affecting +// frame lands (any such frame bumps the seq and expires the stamp). +void remote_nav_stamp(openads::network::RemoteTable* rt, int which, + std::uint32_t order) { + if (rt == nullptr || rt->conn == nullptr) return; + rt->last_nav = which; + rt->last_nav_order = order; + rt->last_nav_row = rt->row_valid; + rt->last_nav_seq = rt->conn->nav_seq(); +} + +// mtfix12 R1 — opposite-boundary certification serve check. True when +// the just-landed GotoTop/GotoBottom carried THIS boundary's full +// landing state (kCapNavBoundaryPair) and the same freshness envelope +// the duplicate check uses still holds — conn-wide nav_seq unchanged — +// plus no write touched this table since certification (the blob's row +// bytes predate any write; a stale blob must never overwrite fresher +// row state). Pending local mutations stay on the wire path. +bool remote_nav_pair(const openads::network::RemoteTable* rt, int which, + std::uint32_t order) { + return rt != nullptr && rt->conn != nullptr && rt->pair_valid && + rt->pair_which == which && rt->pair_order == order && + rt->pair_seq == rt->conn->nav_seq() && + rt->pair_write_gen == rt->conn->tbl_write_gen(rt->id) && + rt->pending_sets.empty() && !rt->pending_order; +} + +// Empty-cursor sticky for AdsAtBOF/AdsAtEOF: true when the last wire +// nav on this table established an empty cursor with no +// cursor-affecting frame since. An empty cursor is simultaneously BOF +// and EOF (xBase). +bool remote_nav_empty_sticky(openads::network::RemoteTable* rt) { + return rt != nullptr && rt->conn != nullptr && rt->last_nav != 0 && + !rt->last_nav_row && + rt->last_nav_seq == rt->conn->nav_seq(); +} + +// Seq-gated boundary-answer cache. A lone wire answer certifies only +// its own side (bound_*_ok); the twin half is certified only by the +// piggybacked twin byte. Any cursor-affecting wire frame expires via +// the seq; purely-local visibility mutations clear explicitly (same +// sites as last_nav). +bool remote_nav_bound_get(openads::network::RemoteTable* rt, bool want_eof, + bool* out) { + if (rt == nullptr || rt->conn == nullptr || out == nullptr) + return false; + if (rt->bound_seq != rt->conn->nav_seq()) return false; + if (want_eof && rt->bound_eof_ok) { *out = rt->bound_eof; return true; } + if (!want_eof && rt->bound_bof_ok) { *out = rt->bound_bof; return true; } + return false; +} +void remote_nav_bound_store(openads::network::RemoteTable* rt, bool eof, + bool eof_valid, bool bof, bool bof_valid) { + if (rt == nullptr || rt->conn == nullptr) return; + rt->bound_eof_ok = eof_valid; + rt->bound_eof = eof; + rt->bound_bof_ok = bof_valid; + rt->bound_bof = bof; + rt->bound_seq = rt->conn->nav_seq(); +} +void remote_nav_bound_clear(openads::network::RemoteTable* rt) { + if (rt == nullptr) return; + rt->bound_bof_ok = false; + rt->bound_eof_ok = false; + rt->recno_bound_ok = false; +} + +void remote_sync_keyno_skip(openads::network::RemoteTable* rt, + std::int32_t step) { + if (rt == nullptr) return; + if (remote_table_has_index(rt)) { + // FWH xBrowse Paint() saves RecNo(), skips through visible rows, + // then DbGoto(bookmark). AdsGotoRecord invalidates keyno; do not + // invent a position here or KeyNo/CalcRowSelPos drift. + if (!rt->keyno_valid) return; + if (!rt->row_valid) { + // Landed at EOF. The phantom key number is key_count + 1, so + // a Skip(-1) back lands exactly on the last scoped key. Fetch + // the scoped count ONLY here (it is then cached): fetching on + // the per-skip hot path would add a wire RTT to every browse + // step (broke the zero-packet prefetch guarantee, M12.24). + if (step > 0) { + const std::uint32_t kc = remote_ensure_key_count(rt); + if (kc > 0) rt->current_keyno = kc + 1; + } + return; + } + // On a valid row the counter is exact by construction (seeded by + // GoTop/GoBottom/measure); no clamp, no wire traffic. + std::int64_t k = static_cast(rt->current_keyno) + step; + if (k < 1) k = 1; + rt->current_keyno = static_cast(k); + return; + } + if (rt->row_valid) { + rt->current_keyno = rt->current_recno; + rt->keyno_valid = true; + } +} + +// Walk from top (server honours ordered_tables_ even when the client's +// active_index_id was never set) until current_recno matches. +UNSIGNED32 remote_measure_keyno(openads::network::RemoteTable* rt) { + if (rt == nullptr) return 0; + if (!rt->row_valid) { + remote_sync_keyno_gototop(rt); + return rt->current_keyno; + } + const std::uint32_t target = rt->current_recno; + remote_ensure_rec_count(rt); + const std::uint32_t limit = + rt->rec_count_cached ? rt->cached_rec_count : 1000000u; + + rt->found_cached = true; + rt->current_found = false; + rt->invalidate_prefetch(); + if (auto r = rt->conn->goto_top(rt); !r) return 0; + remote_sync_keyno_gototop(rt); + if (rt->row_valid && rt->current_recno == target) { + return 1u; + } + for (std::uint32_t kn = 1; kn < limit; ++kn) { + if (rt->row_valid && rt->current_recno == target) { + rt->current_keyno = kn; + rt->keyno_valid = true; + return kn; + } + auto eo = rt->conn->at_eof(rt->id); + if (eo && eo.value()) break; + if (auto sk = rt->conn->skip(rt, 1); !sk) break; + remote_sync_keyno_skip(rt, 1); + } + rt->current_keyno = rt->keyno_valid ? rt->current_keyno : 1u; + rt->keyno_valid = true; + return rt->current_keyno; +} + +UNSIGNED32 remote_query_key_num(openads::network::RemoteTable* rt, + openads::network::RemoteIndex* ri, + UNSIGNED32* pulKeyNum) { + if (rt == nullptr || pulKeyNum == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, ""); + } + // Key position is computed server-side from committed values; a + // buffered key-field write would misplace us. Flush first (no-op + // when clean). + if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; + if (ri != nullptr) { + auto act = openads::network::remote_activate_index(ri); + if (!act) return fail(act.error()); + } + // At the EOF phantom there is no key under the cursor: the local + // engine reports 0 here (recno past end is not in the index walk), + // and xBrowse depends on that to stop painting rows. + if (rt->nav_at_eof && !rt->row_valid) { + *pulKeyNum = 0; + return ok(); + } + const bool has_index = rt->active_index_id != 0 || + !rt->index_by_tag.empty(); + if (!has_index) { + if (rt->row_valid) { + *pulKeyNum = rt->current_recno; + return ok(); + } + auto r = rt->conn->get_record_num(rt->id); + if (!r) return fail(r.error()); + *pulKeyNum = r.value(); + return ok(); + } + if (rt->keyno_valid) { + *pulKeyNum = rt->current_keyno; + return ok(); + } + // M12.29 -- prefer server-side key number (O(1)) over the legacy + // O(n) client-side walk. The server computes pos_of_recno_cached() + // on the CDX index, eliminating the ~22 sec remote_measure_keyno + // walk that TXBrowse:Refresh() triggered on every repaint. + if (auto knr = rt->conn->key_num(rt->id)) { + rt->current_keyno = knr.value(); + rt->keyno_valid = true; + *pulKeyNum = rt->current_keyno; + return ok(); + } + // Fallback to legacy O(n) walk if server doesn't support GetKeyNum. + const UNSIGNED32 kn = remote_measure_keyno(rt); + rt->current_keyno = kn; + rt->keyno_valid = true; + *pulKeyNum = kn; + return ok(); +} + +UNSIGNED32 remote_goto_key_num(openads::network::RemoteTable* rt, + openads::network::RemoteIndex* ri, + std::uint32_t keyno) { + if (rt == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + // Scrollbar jump moves the cursor: buffered sets belong to the row + // under the old position. Land them first (no-op when clean). + if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; + if (keyno < 1u) keyno = 1u; + if (ri != nullptr) { + auto act = openads::network::remote_activate_index(ri); + if (!act) return fail(act.error()); + } + remote_ensure_rec_count(rt); + // Clamp to the scoped key count when an order is active -- a KeyGoto + // past the scope end must land on the last scoped key, not on a + // physical record outside the scope. + const std::uint32_t kmax = remote_table_has_index(rt) + ? remote_ensure_key_count(rt) + : (rt->rec_count_cached ? rt->cached_rec_count : 0u); + cli_trace("goto_key_num", "want=%u kmax=%u", keyno, kmax); + if (kmax > 0 && keyno > kmax) { + keyno = kmax; + } + if (!remote_table_has_index(rt)) { + rt->found_cached = true; + rt->current_found = false; + rt->invalidate_prefetch(); + if (auto r = rt->conn->goto_record(rt, keyno); !r) { + return fail(r.error()); + } + rt->current_keyno = keyno; + rt->keyno_valid = true; + return ok(); + } + rt->found_cached = true; + rt->current_found = false; + rt->invalidate_prefetch(); + if (auto r = rt->conn->goto_top(rt); !r) return fail(r.error()); + remote_sync_keyno_gototop(rt); + if (keyno > 1u) { + const auto step = static_cast(keyno - 1u); + if (auto sk = rt->conn->skip(rt, step); !sk) return fail(sk.error()); + remote_sync_keyno_skip(rt, step); + } + rt->current_keyno = keyno; + rt->keyno_valid = true; + return ok(); +} + +UNSIGNED32 remote_query_rel_key_pos(openads::network::RemoteTable* rt, + openads::network::RemoteIndex* ri, + double* p) { + if (rt == nullptr || p == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (ri != nullptr) { + auto act = openads::network::remote_activate_index(ri); + if (!act) return fail(act.error()); + } + remote_ensure_rec_count(rt); + // Relative position is within the active order's key walk, so the + // denominator is the scoped key count when an order is active -- + // not the physical record count. + const std::uint32_t rc = remote_table_has_index(rt) + ? remote_ensure_key_count(rt) + : (rt->rec_count_cached ? rt->cached_rec_count : 0u); + if (rc <= 1u) { + *p = 0.0; + return ok(); + } + if (rt->active_index_id != 0 || !rt->index_by_tag.empty()) { + UNSIGNED32 kn = 0; + if (UNSIGNED32 rc2 = remote_query_key_num(rt, nullptr, &kn); + rc2 != openads::AE_SUCCESS) { + return rc2; + } + if (kn < 1u) kn = 1u; + if (kn > rc) kn = rc; + *p = static_cast(kn - 1u) / + static_cast(rc - 1u); + return ok(); + } + std::uint32_t rn = 0; + if (rt->row_valid) { + rn = rt->current_recno; + } else { + auto rnr = rt->conn->get_record_num(rt->id); + if (!rnr) return fail(rnr.error()); + rn = rnr.value(); + } + if (rn == 0u) { + *p = 0.0; + return ok(); + } + if (rn > rc) rn = rc; + *p = static_cast(rn - 1u) / static_cast(rc - 1u); + return ok(); +} + +} // namespace + +// Latch set by AdsSeekLast, cleared by AdsSkip. It marks "we are inside +// an AdsSeekLast cycle" for rddads' retry chain (soft AdsSeek + +// AdsSkip(-1) after a hard miss). Nothing reads it today: the fLast walk +// itself travels as a parameter to ads_seek_local, and the empty-key +// quirk decides on the order direction instead. Kept because the retry +// chain is the one place a future consumer would need 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 = to_ads_handle(h); + return to_ads_handle(h); +} + +// Harbour rddads: AdsConnect stores the handle globally; BEGIN/COMMIT/ +// ROLLBACK call AdsBeginTransaction(0). Resolve 0 to the last AdsConnect +// handle before falling back to cwd auto-connect. +ADSHANDLE& rddads_default_connection() noexcept { + thread_local ADSHANDLE h = 0; + return h; +} + +ADSHANDLE resolve_connection_handle(ADSHANDLE hConnect) { + if (hConnect != 0) return hConnect; + ADSHANDLE h = rddads_default_connection(); + if (h != 0) { + auto& s = state(); + if (s.registry.lookup(h, HandleKind::Connection)) + return h; + rddads_default_connection() = 0; + } + return get_or_create_default_connection(); +} + +Connection* lookup_connection(ADSHANDLE hConnect) { + auto& s = state(); + return s.registry.lookup( + resolve_connection_handle(hConnect), HandleKind::Connection); +} + +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; +} + +Connection* find_owning_connection(Table* t) { + if (t == nullptr) return nullptr; + auto& s = state(); + Connection* owning = nullptr; + s.registry.for_each_handle([&](Handle, HandleKind k, void* p) { + if (k != HandleKind::Connection || owning) return; + auto* cc = static_cast(p); + if (cc->owns_table_ptr(t)) owning = cc; + }); + return owning; +} + +// 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; +} + +// Blank ABI value for BOF/EOF / append-row reads. Harbour FWH +// TDataBase:td_blankrow does DBGOBOTTOM+DBSKIP then FieldGet; rddads +// must not see AE_INTERNAL_ERROR (5000) here. +static bool is_no_current_record(const openads::util::Error& e) { + return e.code == static_cast(openads::AE_NO_CURRENT_RECORD); +} + +static std::string blank_abi_field_from_dbf( + const openads::drivers::DbfField& fd) { + using FT = openads::drivers::DbfFieldType; + switch (fd.type) { + case FT::Character: + case FT::Varchar: + case FT::Numeric: + case FT::Float: + case FT::Double: + case FT::Currency: + case FT::Integer: + case FT::Date: + case FT::DateTime: + return std::string(fd.length > 0 ? fd.length : 1, ' '); + case FT::Logical: + return std::string(1, 'F'); + default: + return {}; + } +} + +static std::string blank_abi_field_from_ads(std::uint16_t ads_type, + std::uint32_t length) { + switch (ads_type) { + case ADS_STRING: + return std::string(length > 0 ? length : 1, ' '); + case ADS_LOGICAL: + return "F"; + case ADS_NUMERIC: + case ADS_INTEGER: + case ADS_DATE: + default: + return std::string(length > 0 ? length : 1, ' '); + } +} + +static UNSIGNED32 ads_get_field_blank_out(UNSIGNED8* pucBuf, + UNSIGNED32* pulLen, + std::string val, + std::uint16_t ads_type, + std::uint32_t width) { + if (ads_type == ADS_STRING) + val = pad_char_field(std::move(val), width); + openads::abi::copy_to_caller(pucBuf, pulLen, val); + return ok(); +} + +// --------------------------------------------------------------------------- +// 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_set_filter(ADSHANDLE hTable, UNSIGNED8* pucWhere) { + auto* st = get_sqlite_table(hTable); + if (st == nullptr || st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + std::string where = + pucWhere ? openads::abi::to_internal(pucWhere, 0) : std::string(); + auto r = st->conn->set_filter(st, where); + if (!r) return fail(r.error()); + st->aof_opt_level = where.empty() + ? static_cast(ADS_OPTIMIZED_NONE) + : static_cast(ADS_OPTIMIZED_FULL); + return ok(); +} + +// Tier-3 push-down: compute the aggregates with a single SQL statement in the +// backend (`SELECT COUNT/TOTAL/AVG/MIN/MAX ... WHERE`). TOTAL() (not SUM()) +// gives 0 over zero rows, matching xBase SUM semantics; AVG/MIN/MAX over zero +// rows come back SQL NULL -> AggType::Empty. Numeric results are re-formatted +// through format_agg_double so they match the wire path byte-for-byte. +UNSIGNED32 sqlite_aggregate( + ADSHANDLE hTable, const char* where_sql, + const std::vector* specs, + std::vector* out) { + auto* st = get_sqlite_table(hTable); + if (st == nullptr || st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + if (specs == nullptr || out == nullptr) + return fail(openads::AE_INTERNAL_ERROR, "sqlite_aggregate: null arg"); + + if (!st->fields_cached) { + auto d = st->conn->describe_table(st); + if (!d) return fail(d.error()); + st->fields = std::move(d).value(); + st->fields_cached = true; + } + auto iequal = [](const std::string& a, const std::string& b) { + if (a.size() != b.size()) return false; + for (std::size_t i = 0; i < a.size(); ++i) + if (std::toupper(static_cast(a[i])) != + std::toupper(static_cast(b[i]))) return false; + return true; + }; + auto field_is_numeric = [&](const std::string& name) { + for (const auto& f : st->fields) { + if (!iequal(f.name, name)) continue; + switch (f.type) { + case ADS_NUMERIC: case ADS_DOUBLE: case ADS_INTEGER: + case ADS_SHORTINT: case ADS_AUTOINC: case ADS_CURDOUBLE: + case ADS_MONEY: + return true; + default: + return false; + } + } + return false; + }; + + // Resolve a spec's field to its canonical, schema-validated column name. + // An unknown name must be rejected, never concatenated into the SQL: a + // double-quoted token that is not a real column is silently treated as a + // string literal by SQLite (TOTAL("NOPE") -> 0), and a quote in the name + // would break out of the identifier. Empty field is valid only for COUNT. + auto resolve_col = [&](const openads::engine::AggSpec& s, + std::string& col) -> bool { + if (s.field.empty()) + return s.fn == openads::engine::AggFn::Count; + for (const auto& f : st->fields) { + if (iequal(f.name, s.field)) { col = "\"" + f.name + "\""; return true; } + } + return false; + }; + + std::string sql = "SELECT "; + for (std::size_t i = 0; i < specs->size(); ++i) { + if (i) sql += ", "; + const auto& s = (*specs)[i]; + std::string col; + if (!resolve_col(s, col)) + return fail(openads::AE_INTERNAL_ERROR, + ("sqlite_aggregate: invalid field " + s.field).c_str()); + switch (s.fn) { + case openads::engine::AggFn::Count: + sql += s.field.empty() ? "COUNT(*)" : ("COUNT(" + col + ")"); + break; + case openads::engine::AggFn::Sum: sql += "TOTAL(" + col + ")"; break; + case openads::engine::AggFn::Avg: sql += "AVG(" + col + ")"; break; + case openads::engine::AggFn::Min: sql += "MIN(" + col + ")"; break; + case openads::engine::AggFn::Max: sql += "MAX(" + col + ")"; break; + } + sql += " AS a" + std::to_string(i); + } + sql += " FROM \"" + st->name + "\""; + if (where_sql != nullptr && *where_sql != '\0') + sql += " WHERE (" + std::string(where_sql) + ")"; + + auto cur = st->conn->run_sql(sql); + if (!cur) return fail(cur.error()); + const auto& res = *cur.value(); + + out->clear(); + out->reserve(specs->size()); + for (std::size_t i = 0; i < specs->size(); ++i) { + const auto& s = (*specs)[i]; + bool is_null = true; + std::string val; + if (!res.result_rows.empty() && i < res.result_rows[0].size()) { + val = res.result_rows[0][i]; + is_null = !res.result_nulls.empty() && + i < res.result_nulls[0].size() && res.result_nulls[0][i]; + } + const bool numeric_result = + (s.fn == openads::engine::AggFn::Count) || + (s.fn == openads::engine::AggFn::Sum) || + (s.fn == openads::engine::AggFn::Avg) || + field_is_numeric(s.field); + openads::engine::AggValue av; + if (is_null) { + av.type = openads::engine::AggType::Empty; // AVG/MIN/MAX, 0 rows + } else if (numeric_result) { + av.type = openads::engine::AggType::Numeric; + av.bytes = openads::engine::format_agg_double( + std::strtod(val.c_str(), nullptr)); + } else { + av.type = openads::engine::AggType::String; // MIN/MAX of text + av.bytes = val; + } + out->push_back(std::move(av)); + } + 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()); + } + if (!openads::abi::assign_u16(pusCnt, st->fields.size())) { + return fail(openads::AE_INTERNAL_ERROR, "field count exceeds 65535"); + } + 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, ""); + // rddads reads by ORDINAL: pucField is ADSFIELD(n) (a 1-based field + // number cast to a pointer), not a NUL-terminated name. Resolve it with + // the ordinal-aware index helper BEFORE reading -- to_internal()/strlen() + // on the tiny pointer dereferences invalid memory and crashes. + auto fi = sqlite_field_index(st, pucField); + if (fi == std::numeric_limits::max()) + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + bool is_null = false; + std::string val; + auto r = st->conn->read_field(st, st->fields[fi].name, val, is_null); + if (!r) return fail(r.error()); + if (is_null) val.clear(); + if (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] = to_ads_handle(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(); +} + +// ── Tier 1: Transaction management adapters (SQLRDD pattern) ────── + +UNSIGNED32 sqlite_begin_tx(ADSHANDLE hTable) { + auto* st = get_sqlite_table(hTable); + if (st == nullptr || st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto& tx = st->conn->tx_manager(); + // Wire up the SQLite backend callbacks if not already done + if (!tx.on_begin) { + auto* conn = st->conn; + tx.on_begin = [conn](bool is_nested) { + if (!is_nested) { + conn->exec_sql("BEGIN"); + } else { + // Nested: use SAVEPOINT + conn->exec_sql("SAVEPOINT sp_" + std::to_string(conn->tx_manager().nesting)); + } + }; + tx.on_commit = [conn](bool is_nested) { + (void)is_nested; + conn->exec_sql("COMMIT"); + }; + tx.on_rollback = [conn]() { + conn->exec_sql("ROLLBACK"); + }; + tx.on_savepoint = [conn](const std::string& name) { + conn->exec_sql("SAVEPOINT " + name); + }; + tx.on_release_savepoint = [conn](const std::string& name) { + conn->exec_sql("RELEASE SAVEPOINT " + name); + }; + tx.on_rollback_savepoint = [conn](const std::string& name) { + conn->exec_sql("ROLLBACK TO SAVEPOINT " + name); + }; + } + tx.begin(); + return ok(); +} + +UNSIGNED32 sqlite_commit_tx(ADSHANDLE hTable) { + auto* st = get_sqlite_table(hTable); + if (st == nullptr || st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + st->conn->tx_manager().commit(); + return ok(); +} + +UNSIGNED32 sqlite_rollback_tx(ADSHANDLE hTable) { + auto* st = get_sqlite_table(hTable); + if (st == nullptr || st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + st->conn->tx_manager().rollback(); + return ok(); +} + +UNSIGNED32 sqlite_set_auto_commit(ADSHANDLE hTable, SIGNED32 threshold) { + auto* st = get_sqlite_table(hTable); + if (st == nullptr || st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + st->conn->tx_manager().auto_commit_threshold = threshold; + 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; + o.set_filter = &sqlite_set_filter; + o.aggregate = &sqlite_aggregate; + o.begin_tx = &sqlite_begin_tx; + o.commit_tx = &sqlite_commit_tx; + o.rollback_tx = &sqlite_rollback_tx; + o.set_auto_commit = &sqlite_set_auto_commit; + 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()); + } + if (!openads::abi::assign_u16(pusCnt, st->fields.size())) { + return fail(openads::AE_INTERNAL_ERROR, "field count exceeds 65535"); + } + 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, ""); + // rddads reads by ORDINAL: pucField is ADSFIELD(n) (a 1-based field + // number cast to a pointer), not a NUL-terminated name. Resolve it with + // the ordinal-aware index helper BEFORE reading -- to_internal()/strlen() + // on the tiny pointer dereferences invalid memory and crashes. + auto fi = odbc_field_index(st, pucField); + if (fi == std::numeric_limits::max()) + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + bool is_null = false; + std::string val; + auto r = st->conn->read_field(st, st->fields[fi].name, val, is_null); + if (!r) return fail(r.error()); + if (is_null) val.clear(); + if (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] = to_ads_handle(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(); +} + +UNSIGNED32 odbc_set_filter(ADSHANDLE hTable, UNSIGNED8* pucWhere) { + auto* st = get_odbc_table(hTable); + if (st == nullptr || st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + std::string where = + pucWhere ? openads::abi::to_internal(pucWhere, 0) : std::string(); + auto r = st->conn->set_filter(st, where); + if (!r) return fail(r.error()); + st->aof_opt_level = where.empty() + ? static_cast(ADS_OPTIMIZED_NONE) + : static_cast(ADS_OPTIMIZED_FULL); + return ok(); +} + +UNSIGNED32 odbc_aggregate( + ADSHANDLE hTable, const char* where_sql, + const std::vector* specs, + std::vector* out) { + auto* st = get_odbc_table(hTable); + if (st == nullptr || st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + if (specs == nullptr || out == nullptr) + return fail(openads::AE_INTERNAL_ERROR, "odbc_aggregate: null arg"); + auto r = st->conn->aggregate( + st, where_sql ? std::string(where_sql) : std::string(), *specs); + if (!r) return fail(r.error()); + *out = std::move(r).value(); + return ok(); +} + +UNSIGNED32 odbc_begin_tx(ADSHANDLE hTable) { + auto* st = get_odbc_table(hTable); + if (st == nullptr || st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto* conn = st->conn; + auto& tx = conn->tx_manager(); + openads::sql_backend::wire_standard_savepoint_tx( + tx, [conn](const std::string& sql) { (void)conn->exec_sql(sql); }); + tx.begin(); + return ok(); +} + +UNSIGNED32 odbc_commit_tx(ADSHANDLE hTable) { + auto* st = get_odbc_table(hTable); + if (st == nullptr || st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + st->conn->tx_manager().commit(); + return ok(); +} + +UNSIGNED32 odbc_rollback_tx(ADSHANDLE hTable) { + auto* st = get_odbc_table(hTable); + if (st == nullptr || st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + st->conn->tx_manager().rollback(); + return ok(); +} + +UNSIGNED32 odbc_set_auto_commit(ADSHANDLE hTable, SIGNED32 threshold) { + auto* st = get_odbc_table(hTable); + if (st == nullptr || st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + st->conn->tx_manager().auto_commit_threshold = threshold; + 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; + o.set_filter = &odbc_set_filter; + o.aggregate = &odbc_aggregate; + o.begin_tx = &odbc_begin_tx; + o.commit_tx = &odbc_commit_tx; + o.rollback_tx = &odbc_rollback_tx; + o.set_auto_commit = &odbc_set_auto_commit; + return o; + }(); + return &ops; +} + +#endif // OPENADS_WITH_ODBC (lifted ops) + +// --------------------------------------------------------------------------- +// Lifted MSSQL table ops + accessor +// --------------------------------------------------------------------------- +#if defined(OPENADS_WITH_MSSQL) + +UNSIGNED32 mssql_close_table(ADSHANDLE hTable) { + auto* st = get_mssql_table(hTable); + (void)st; + auto& s2 = state(); + std::lock_guard lk2(s2.mu); + mssql_tables_map().erase(hTable); + s2.registry.release(hTable); + return ok(); +} + +UNSIGNED32 mssql_goto_top(ADSHANDLE hTable) { + auto* st = get_mssql_table(hTable); + if (!st) return fail(openads::AE_INTERNAL_ERROR, ""); + st->go_top(); + return ok(); +} + +UNSIGNED32 mssql_goto_bottom(ADSHANDLE hTable) { + auto* st = get_mssql_table(hTable); + if (!st) return fail(openads::AE_INTERNAL_ERROR, ""); + st->go_bottom(); + return ok(); +} + +UNSIGNED32 mssql_skip(ADSHANDLE hTable, SIGNED32 lRows) { + auto* st = get_mssql_table(hTable); + if (!st) return fail(openads::AE_INTERNAL_ERROR, ""); + st->skip(static_cast(lRows)); + return ok(); +} + +UNSIGNED32 mssql_at_eof(ADSHANDLE hTable, UNSIGNED16* pbAtEnd) { + auto* st = get_mssql_table(hTable); + if (!st) return fail(openads::AE_INTERNAL_ERROR, ""); + if (pbAtEnd == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *pbAtEnd = st->at_eof() ? 1 : 0; + return ok(); +} + +UNSIGNED32 mssql_at_bof(ADSHANDLE hTable, UNSIGNED16* pbAtBegin) { + auto* st = get_mssql_table(hTable); + if (!st) return fail(openads::AE_INTERNAL_ERROR, ""); + *pbAtBegin = st->at_bof() ? 1 : 0; + return ok(); +} + +UNSIGNED32 mssql_num_fields(ADSHANDLE hTable, UNSIGNED16* pusCnt) { + auto* st = get_mssql_table(hTable); + if (!st) return fail(openads::AE_INTERNAL_ERROR, ""); + if (pusCnt == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (!openads::abi::assign_u16(pusCnt, st->field_count())) { + return fail(openads::AE_INTERNAL_ERROR, "field count exceeds 65535"); + } + return ok(); +} + +UNSIGNED32 mssql_field_name(ADSHANDLE hTable, UNSIGNED16 n, + UNSIGNED8* pucBuf, UNSIGNED16* pusLen) { + auto* st = get_mssql_table(hTable); + if (!st) return fail(openads::AE_INTERNAL_ERROR, ""); + if (n == 0 || n > st->field_count()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + auto name = st->field_name(n - 1); + openads::abi::copy_to_caller(pucBuf, pusLen, name); + return ok(); +} + +UNSIGNED32 mssql_field_type(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED16* pusType) { + auto* st = get_mssql_table(hTable); + if (!st) return fail(openads::AE_INTERNAL_ERROR, ""); + auto i = mssql_field_index(st, pucField); + if (i == std::numeric_limits::max()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + if (pusType) *pusType = st->field_type(i); + return ok(); +} + +UNSIGNED32 mssql_field_length(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED32* pulLen) { + auto* st = get_mssql_table(hTable); + if (!st) return fail(openads::AE_INTERNAL_ERROR, ""); + auto i = mssql_field_index(st, pucField); + if (i == std::numeric_limits::max()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + if (pulLen) *pulLen = st->field_length(i); + return ok(); +} + +UNSIGNED32 mssql_field_decimals(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED16* pusDec) { + auto* st = get_mssql_table(hTable); + if (!st) return fail(openads::AE_INTERNAL_ERROR, ""); + auto i = mssql_field_index(st, pucField); + if (i == std::numeric_limits::max()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + if (pusDec) *pusDec = st->field_decimals(i); + return ok(); +} + +UNSIGNED32 mssql_record_num(ADSHANDLE hTable, UNSIGNED32* pulRec) { + auto* st = get_mssql_table(hTable); + if (!st) return fail(openads::AE_INTERNAL_ERROR, ""); + if (pulRec == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *pulRec = st->record_num(); + return ok(); +} + +UNSIGNED32 mssql_record_count(ADSHANDLE hTable, UNSIGNED32* pulCount, + UNSIGNED16 /*usFilterOption*/) { + auto* st = get_mssql_table(hTable); + if (!st) return fail(openads::AE_INTERNAL_ERROR, ""); + if (pulCount == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *pulCount = st->record_count(); + return ok(); +} + +UNSIGNED32 mssql_get_field(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED8* pucBuf, UNSIGNED32* pulLen, + UNSIGNED16 /*usOption*/) { + auto* st = get_mssql_table(hTable); + if (!st) return fail(openads::AE_INTERNAL_ERROR, ""); + if (pulLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + auto fi = mssql_field_index(st, pucField); + if (fi == std::numeric_limits::max()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + bool is_null = false; + std::string val; + if (!st->get_field(fi, val, is_null)) { + return fail(openads::AE_INTERNAL_ERROR, ""); + } + if (is_null) val.clear(); + if (st->field_type(fi) == ADS_STRING) { + val = pad_char_field(std::move(val), st->field_length(fi)); + } + openads::abi::copy_to_caller(pucBuf, pulLen, val); + return ok(); +} + +UNSIGNED32 mssql_is_record_deleted(ADSHANDLE hTable, UNSIGNED16* pbDeleted) { + auto* st = get_mssql_table(hTable); + if (!st) return fail(openads::AE_INTERNAL_ERROR, ""); + if (pbDeleted) *pbDeleted = 0; + return ok(); +} + +UNSIGNED32 mssql_open_index(ADSHANDLE hTable, UNSIGNED8* pucName, + ADSHANDLE* ahIndex, UNSIGNED16* pu16ArrayLen) { + auto* st = get_mssql_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::MssqlIndex, si.get()); + ahIndex[0] = to_ads_handle(gh); + if (pu16ArrayLen != nullptr) *pu16ArrayLen = 1; + mssql_indexes_map().emplace(gh, std::move(si)); + return ok(); +} + +UNSIGNED32 mssql_is_found(ADSHANDLE hTable, UNSIGNED16* pbFound) { + auto* st = get_mssql_table(hTable); + *pbFound = st->last_seek_found ? 1 : 0; + return ok(); +} + +UNSIGNED32 mssql_set_filter(ADSHANDLE hTable, UNSIGNED8* pucWhere) { + auto* st = get_mssql_table(hTable); + if (st == nullptr || st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + std::string where = + pucWhere ? openads::abi::to_internal(pucWhere, 0) : std::string(); + auto r = st->conn->set_filter(st, where); + if (!r) return fail(r.error()); + st->aof_opt_level = where.empty() + ? static_cast(ADS_OPTIMIZED_NONE) + : static_cast(ADS_OPTIMIZED_FULL); + return ok(); +} + +UNSIGNED32 mssql_aggregate( + ADSHANDLE hTable, const char* where_sql, + const std::vector* specs, + std::vector* out) { + auto* st = get_mssql_table(hTable); + if (st == nullptr || st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + if (specs == nullptr || out == nullptr) + return fail(openads::AE_INTERNAL_ERROR, "mssql_aggregate: null arg"); + auto r = st->conn->aggregate( + st, where_sql ? std::string(where_sql) : std::string(), *specs); + if (!r) return fail(r.error()); + *out = std::move(r).value(); + return ok(); +} + +UNSIGNED32 mssql_begin_tx(ADSHANDLE hTable) { + auto* st = get_mssql_table(hTable); + if (st == nullptr || st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto* conn = st->conn; + auto& tx = conn->tx_manager(); + openads::sql_backend::wire_mssql_savepoint_tx( + tx, [conn](const std::string& sql) { (void)conn->exec_sql(sql); }); + tx.begin(); + return ok(); +} + +UNSIGNED32 mssql_commit_tx(ADSHANDLE hTable) { + auto* st = get_mssql_table(hTable); + if (st == nullptr || st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + st->conn->tx_manager().commit(); + return ok(); +} + +UNSIGNED32 mssql_rollback_tx(ADSHANDLE hTable) { + auto* st = get_mssql_table(hTable); + if (st == nullptr || st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + st->conn->tx_manager().rollback(); + return ok(); +} + +UNSIGNED32 mssql_set_auto_commit(ADSHANDLE hTable, SIGNED32 threshold) { + auto* st = get_mssql_table(hTable); + if (st == nullptr || st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + st->conn->tx_manager().auto_commit_threshold = threshold; + return ok(); +} + +const openads::abi::BackendTableOps* mssql_table_ops() { + static const openads::abi::BackendTableOps ops = [] { + openads::abi::BackendTableOps o{}; + o.close_table = &mssql_close_table; + o.goto_top = &mssql_goto_top; + o.goto_bottom = &mssql_goto_bottom; + o.skip = &mssql_skip; + o.at_eof = &mssql_at_eof; + o.at_bof = &mssql_at_bof; + o.num_fields = &mssql_num_fields; + o.field_name = &mssql_field_name; + o.field_type = &mssql_field_type; + o.field_length = &mssql_field_length; + o.field_decimals = &mssql_field_decimals; + o.record_num = &mssql_record_num; + o.record_count = &mssql_record_count; + o.get_field = &mssql_get_field; + o.is_record_deleted = &mssql_is_record_deleted; + o.open_index = &mssql_open_index; + o.is_found = &mssql_is_found; + o.set_filter = &mssql_set_filter; + o.aggregate = &mssql_aggregate; + o.begin_tx = &mssql_begin_tx; + o.commit_tx = &mssql_commit_tx; + o.rollback_tx = &mssql_rollback_tx; + o.set_auto_commit = &mssql_set_auto_commit; + return o; + }(); + return &ops; +} +#endif // OPENADS_WITH_MSSQL (lifted ops) + +// --------------------------------------------------------------------------- +// Lifted Firebird table ops + accessor (mirrors the ODBC lift exactly; the +// native Firebird backend exposes the same read-navigation surface -- its +// extra write / run_sql methods are not part of BackendTableOps and are not +// wired at the ABI border in this slice, matching the ODBC read-only border). +// --------------------------------------------------------------------------- +#if defined(OPENADS_WITH_FIREBIRD) + +UNSIGNED32 firebird_close_table(ADSHANDLE hTable) { + auto* st = get_firebird_table(hTable); + (void)st; + auto& s2 = state(); + std::lock_guard lk2(s2.mu); + firebird_tables_map().erase(hTable); + s2.registry.release(hTable); + return ok(); +} + +UNSIGNED32 firebird_goto_top(ADSHANDLE hTable) { + auto* st = get_firebird_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 firebird_goto_bottom(ADSHANDLE hTable) { + auto* st = get_firebird_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 firebird_skip(ADSHANDLE hTable, SIGNED32 lRows) { + auto* st = get_firebird_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 firebird_at_eof(ADSHANDLE hTable, UNSIGNED16* pbAtEnd) { + auto* st = get_firebird_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 firebird_at_bof(ADSHANDLE hTable, UNSIGNED16* pbAtBof) { + auto* st = get_firebird_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 firebird_num_fields(ADSHANDLE hTable, UNSIGNED16* pusCnt) { + auto* st = get_firebird_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 (!openads::abi::assign_u16(pusCnt, st->fields.size())) { + return fail(openads::AE_INTERNAL_ERROR, "field count exceeds 65535"); + } + return ok(); +} + +UNSIGNED32 firebird_field_name(ADSHANDLE hTable, UNSIGNED16 n, + UNSIGNED8* pucBuf, UNSIGNED16* pusLen) { + auto* st = get_firebird_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 firebird_field_type(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED16* pusType) { + auto* st = get_firebird_table(hTable); + auto i = firebird_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 firebird_field_length(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED32* pulLen) { + auto* st = get_firebird_table(hTable); + auto i = firebird_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 firebird_field_decimals(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED16* pusDec) { + auto* st = get_firebird_table(hTable); + auto i = firebird_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 firebird_record_num(ADSHANDLE hTable, UNSIGNED32* pulRec) { + auto* st = get_firebird_table(hTable); + if (!st->positioned || !st->row_valid) { + return fail(5026, "no current record"); + } + *pulRec = static_cast(st->current_recno); + return ok(); +} + +UNSIGNED32 firebird_record_count(ADSHANDLE hTable, UNSIGNED32* pulCount, + UNSIGNED16 /*usFilterOption*/) { + auto* st = get_firebird_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 firebird_get_field(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED8* pucBuf, UNSIGNED32* pulLen, + UNSIGNED16 /*usOption*/) { + auto* st = get_firebird_table(hTable); + if (pulLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (st->conn == nullptr) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + // rddads reads by ORDINAL: pucField is ADSFIELD(n) (a 1-based field + // number cast to a pointer), not a NUL-terminated name. Resolve it with + // the ordinal-aware index helper BEFORE reading -- to_internal()/strlen() + // on the tiny pointer dereferences invalid memory and crashes. + auto fi = firebird_field_index(st, pucField); + if (fi == std::numeric_limits::max()) + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + bool is_null = false; + std::string val; + auto r = st->conn->read_field(st, st->fields[fi].name, val, is_null); + if (!r) return fail(r.error()); + if (is_null) val.clear(); + if (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 firebird_is_record_deleted(ADSHANDLE hTable, UNSIGNED16* pbDeleted) { + auto* st = get_firebird_table(hTable); + *pbDeleted = st->current_deleted ? 1 : 0; + return ok(); +} + +UNSIGNED32 firebird_open_index(ADSHANDLE hTable, UNSIGNED8* pucName, + ADSHANDLE* ahIndex, UNSIGNED16* pu16ArrayLen) { + auto* st = get_firebird_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::FirebirdIndex, si.get()); + ahIndex[0] = to_ads_handle(gh); + if (pu16ArrayLen != nullptr) { + *pu16ArrayLen = 1; + } + firebird_indexes_map().emplace(gh, std::move(si)); + return ok(); +} + +UNSIGNED32 firebird_is_found(ADSHANDLE hTable, UNSIGNED16* pbFound) { + auto* st = get_firebird_table(hTable); + *pbFound = st->last_seek_found ? 1 : 0; + return ok(); +} + +UNSIGNED32 firebird_set_filter(ADSHANDLE hTable, UNSIGNED8* pucWhere) { + auto* st = get_firebird_table(hTable); + if (st == nullptr || st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + std::string where = + pucWhere ? openads::abi::to_internal(pucWhere, 0) : std::string(); + auto r = st->conn->set_filter(st, where); + if (!r) return fail(r.error()); + st->aof_opt_level = where.empty() + ? static_cast(ADS_OPTIMIZED_NONE) + : static_cast(ADS_OPTIMIZED_FULL); + return ok(); +} + +UNSIGNED32 firebird_aggregate( + ADSHANDLE hTable, const char* where_sql, + const std::vector* specs, + std::vector* out) { + auto* st = get_firebird_table(hTable); + if (st == nullptr || st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + if (specs == nullptr || out == nullptr) + return fail(openads::AE_INTERNAL_ERROR, "firebird_aggregate: null arg"); + auto r = st->conn->aggregate( + st, where_sql ? std::string(where_sql) : std::string(), *specs); + if (!r) return fail(r.error()); + *out = std::move(r).value(); + return ok(); +} + +UNSIGNED32 firebird_begin_tx(ADSHANDLE hTable) { + auto* st = get_firebird_table(hTable); + if (st == nullptr || st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto* conn = st->conn; + auto& tx = conn->tx_manager(); + openads::sql_backend::wire_standard_savepoint_tx( + tx, [conn](const std::string& sql) { (void)conn->exec_sql(sql); }); + tx.begin(); + return ok(); +} + +UNSIGNED32 firebird_commit_tx(ADSHANDLE hTable) { + auto* st = get_firebird_table(hTable); + if (st == nullptr || st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + st->conn->tx_manager().commit(); + return ok(); +} + +UNSIGNED32 firebird_rollback_tx(ADSHANDLE hTable) { + auto* st = get_firebird_table(hTable); + if (st == nullptr || st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + st->conn->tx_manager().rollback(); + return ok(); +} + +UNSIGNED32 firebird_set_auto_commit(ADSHANDLE hTable, SIGNED32 threshold) { + auto* st = get_firebird_table(hTable); + if (st == nullptr || st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + st->conn->tx_manager().auto_commit_threshold = threshold; + return ok(); +} + +const openads::abi::BackendTableOps* firebird_table_ops() { + static const openads::abi::BackendTableOps ops = [] { + openads::abi::BackendTableOps o{}; + o.close_table = &firebird_close_table; + o.goto_top = &firebird_goto_top; + o.goto_bottom = &firebird_goto_bottom; + o.skip = &firebird_skip; + o.at_eof = &firebird_at_eof; + o.at_bof = &firebird_at_bof; + o.num_fields = &firebird_num_fields; + o.field_name = &firebird_field_name; + o.field_type = &firebird_field_type; + o.field_length = &firebird_field_length; + o.field_decimals = &firebird_field_decimals; + o.record_num = &firebird_record_num; + o.record_count = &firebird_record_count; + o.get_field = &firebird_get_field; + o.is_record_deleted = &firebird_is_record_deleted; + o.open_index = &firebird_open_index; + o.is_found = &firebird_is_found; + o.set_filter = &firebird_set_filter; + o.aggregate = &firebird_aggregate; + o.begin_tx = &firebird_begin_tx; + o.commit_tx = &firebird_commit_tx; + o.rollback_tx = &firebird_rollback_tx; + o.set_auto_commit = &firebird_set_auto_commit; + return o; + }(); + return &ops; +} + +#endif // OPENADS_WITH_FIREBIRD (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()); + } + if (!openads::abi::assign_u16(pusCnt, st->fields.size())) { + return fail(openads::AE_INTERNAL_ERROR, "field count exceeds 65535"); + } + 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, ""); + // rddads reads by ORDINAL: pucField is ADSFIELD(n) (a 1-based field + // number cast to a pointer), not a NUL-terminated name. Resolve it with + // the ordinal-aware index helper BEFORE reading -- to_internal()/strlen() + // on the tiny pointer dereferences invalid memory and crashes. + auto fi = maria_field_index(st, pucField); + if (fi == std::numeric_limits::max()) + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + bool is_null = false; + std::string val; + auto r = st->conn->read_field(st, st->fields[fi].name, val, is_null); + if (!r) return fail(r.error()); + if (is_null) val.clear(); + if (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] = to_ads_handle(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(); +} + +UNSIGNED32 maria_set_filter(ADSHANDLE hTable, UNSIGNED8* pucWhere) { + auto* st = get_maria_table(hTable); + if (st == nullptr || st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + std::string where = + pucWhere ? openads::abi::to_internal(pucWhere, 0) : std::string(); + auto r = st->conn->set_filter(st, where); + if (!r) return fail(r.error()); + st->aof_opt_level = where.empty() + ? static_cast(ADS_OPTIMIZED_NONE) + : static_cast(ADS_OPTIMIZED_FULL); + return ok(); +} + +UNSIGNED32 maria_aggregate( + ADSHANDLE hTable, const char* where_sql, + const std::vector* specs, + std::vector* out) { + auto* st = get_maria_table(hTable); + if (st == nullptr || st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + if (specs == nullptr || out == nullptr) + return fail(openads::AE_INTERNAL_ERROR, "maria_aggregate: null arg"); + auto r = st->conn->aggregate( + st, where_sql ? std::string(where_sql) : std::string(), *specs); + if (!r) return fail(r.error()); + *out = std::move(r).value(); + return ok(); +} + +UNSIGNED32 maria_begin_tx(ADSHANDLE hTable) { + auto* st = get_maria_table(hTable); + if (st == nullptr || st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto* conn = st->conn; + auto& tx = conn->tx_manager(); + openads::sql_backend::wire_standard_savepoint_tx( + tx, [conn](const std::string& sql) { (void)conn->exec_sql(sql); }); + tx.begin(); + return ok(); +} + +UNSIGNED32 maria_commit_tx(ADSHANDLE hTable) { + auto* st = get_maria_table(hTable); + if (st == nullptr || st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + st->conn->tx_manager().commit(); + return ok(); +} + +UNSIGNED32 maria_rollback_tx(ADSHANDLE hTable) { + auto* st = get_maria_table(hTable); + if (st == nullptr || st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + st->conn->tx_manager().rollback(); + return ok(); +} + +UNSIGNED32 maria_set_auto_commit(ADSHANDLE hTable, SIGNED32 threshold) { + auto* st = get_maria_table(hTable); + if (st == nullptr || st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + st->conn->tx_manager().auto_commit_threshold = threshold; + 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; + o.set_filter = &maria_set_filter; + o.aggregate = &maria_aggregate; + o.begin_tx = &maria_begin_tx; + o.commit_tx = &maria_commit_tx; + o.rollback_tx = &maria_rollback_tx; + o.set_auto_commit = &maria_set_auto_commit; + 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_set_filter(ADSHANDLE hTable, UNSIGNED8* pucWhere) { + auto* st = get_postgres_table(hTable); + if (st == nullptr || st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + std::string where = + pucWhere ? openads::abi::to_internal(pucWhere, 0) : std::string(); + auto r = st->conn->set_filter(st, where); + if (!r) return fail(r.error()); + st->aof_opt_level = where.empty() + ? static_cast(ADS_OPTIMIZED_NONE) + : static_cast(ADS_OPTIMIZED_FULL); + return ok(); +} + +// Tier-3 push-down: delegate to PostgresConnection::aggregate (one SELECT +// COUNT/SUM/AVG/MIN/MAX ... WHERE) -- same contract as sqlite_aggregate. +UNSIGNED32 postgres_aggregate( + ADSHANDLE hTable, const char* where_sql, + const std::vector* specs, + std::vector* out) { + auto* st = get_postgres_table(hTable); + if (st == nullptr || st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + if (specs == nullptr || out == nullptr) + return fail(openads::AE_INTERNAL_ERROR, "postgres_aggregate: null arg"); + auto r = st->conn->aggregate( + st, where_sql ? std::string(where_sql) : std::string(), *specs); + if (!r) return fail(r.error()); + *out = std::move(r).value(); + 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()); + } + if (!openads::abi::assign_u16(pusCnt, st->fields.size())) { + return fail(openads::AE_INTERNAL_ERROR, "field count exceeds 65535"); + } + 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, ""); + } + *pusDec = st->fields[i].decimals; + return ok(); +} + +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(); +} + +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(); +} + +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, ""); + // rddads reads by ORDINAL: pucField is ADSFIELD(n) (a 1-based field + // number cast to a pointer), not a NUL-terminated name. Resolve it with + // the ordinal-aware index helper BEFORE reading -- to_internal()/strlen() + // on the tiny pointer dereferences invalid memory and crashes. + auto fi = postgres_field_index(st, pucField); + if (fi == std::numeric_limits::max()) + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + bool is_null = false; + std::string val; + auto r = st->conn->read_field(st, st->fields[fi].name, val, is_null); + if (!r) return fail(r.error()); + if (is_null) val.clear(); + if (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 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(); + std::lock_guard lk(s.mu); + Handle gh = s.registry.register_object( + HandleKind::PostgresIndex, si.get()); + ahIndex[0] = to_ads_handle(gh); + if (pu16ArrayLen != nullptr) { + *pu16ArrayLen = 1; + } + postgres_indexes_map().emplace(gh, std::move(si)); + return ok(); +} + +UNSIGNED32 postgres_is_found(ADSHANDLE hTable, UNSIGNED16* pbFound) { + auto* st = get_postgres_table(hTable); + *pbFound = st->last_seek_found ? 1 : 0; + return ok(); +} + +// ── Tier 1: Transaction management adapters (SQLRDD pattern) ────── + +UNSIGNED32 postgres_begin_tx(ADSHANDLE hTable) { + auto* st = get_postgres_table(hTable); + if (st == nullptr || st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto& tx = st->conn->tx_manager(); + if (!tx.on_begin) { + auto* conn = st->conn; + tx.on_begin = [conn](bool is_nested) { + if (!is_nested) { + conn->exec_sql("BEGIN"); + } else { + conn->exec_sql("SAVEPOINT sp_" + std::to_string(conn->tx_manager().nesting)); + } + }; + tx.on_commit = [conn](bool is_nested) { + (void)is_nested; + // PostgreSQL needs explicit BEGIN after COMMIT + conn->exec_sql("COMMIT"); + }; + tx.on_rollback = [conn]() { + conn->exec_sql("ROLLBACK"); + }; + tx.on_savepoint = [conn](const std::string& name) { + conn->exec_sql("SAVEPOINT " + name); + }; + tx.on_release_savepoint = [conn](const std::string& name) { + conn->exec_sql("RELEASE SAVEPOINT " + name); + }; + tx.on_rollback_savepoint = [conn](const std::string& name) { + conn->exec_sql("ROLLBACK TO SAVEPOINT " + name); + }; + } + tx.begin(); + return ok(); +} + +UNSIGNED32 postgres_commit_tx(ADSHANDLE hTable) { + auto* st = get_postgres_table(hTable); + if (st == nullptr || st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + st->conn->tx_manager().commit(); + return ok(); +} + +UNSIGNED32 postgres_rollback_tx(ADSHANDLE hTable) { + auto* st = get_postgres_table(hTable); + if (st == nullptr || st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + st->conn->tx_manager().rollback(); + return ok(); +} + +UNSIGNED32 postgres_set_auto_commit(ADSHANDLE hTable, SIGNED32 threshold) { + auto* st = get_postgres_table(hTable); + if (st == nullptr || st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + st->conn->tx_manager().auto_commit_threshold = threshold; + 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; + o.set_filter = &postgres_set_filter; + o.aggregate = &postgres_aggregate; + o.begin_tx = &postgres_begin_tx; + o.commit_tx = &postgres_commit_tx; + o.rollback_tx = &postgres_rollback_tx; + o.set_auto_commit = &postgres_set_auto_commit; + return o; + }(); + return &ops; +} + +#endif // OPENADS_WITH_POSTGRESQL (lifted ops) + +// --------------------------------------------------------------------------- +// Referential Integrity enforcement +// --------------------------------------------------------------------------- + +// "AdsAppendRecord called but AdsWriteRecord hasn't fired yet" is tracked +// per-Table via Table::pending_append() -- see table.h. It used to be a +// global std::unordered_set, but a freed table's heap address +// could be reused by a different table that still carried the stale +// "pending append" flag, making a plain UPDATE take the INSERT path and +// silently skip RI cascade/restrict enforcement (intermittent, heap- +// layout dependent). Storing the flag on the Table removes that aliasing. + +// Recursion guard: prevents RI cascade actions from triggering a second +// round of RI checks on the child table. +bool& in_ri_check() { + static thread_local bool flag = false; + return flag; +} + +// Find the Connection that owns Table* t. +Connection* conn_for_table(Table* t) { + auto& s = state(); + // Each Connection's table map is changed under s.mu (open/close), so + // the scan must hold it too. Without it a navigation on one session + // (snapshot_ri_pks) read a map another session was inserting into + // (found by ThreadSanitizer under the session-pool test). Lock order + // s.mu -> registry matches register_object/release. + std::lock_guard lk(s.mu); + Connection* found = nullptr; + s.registry.for_each_handle([&](Handle, HandleKind k, void* p) { + if (k != HandleKind::Connection || found) return; + auto* c = static_cast(p); + if (c->owns_table_ptr(t)) found = c; + }); + return found; +} + +// Find the DD alias for a table given its resolved absolute path. +// +// PERF: fs::weakly_canonical is a filesystem syscall. Doing it once per DD +// table on every call made this ~7 ms on a 95-table DD -- and callers run +// per NAVIGATION (snapshot_ri_pks) and per WRITE (RI enforcement), which +// turned large cursor walks into apparent hangs. Two defences: +// - a case-insensitive BASENAME pre-filter, so entries that cannot match +// (every SQL temp, most tables) are rejected with pure string compares +// and only basename collisions pay the canonicalisation; +// - hot callers additionally cache the result on the Table +// (ri_alias_cached / ri_alias_cache) -- the path never changes after +// open, so the alias can't either. +std::string ri_alias_for_path(Connection* conn, const std::string& abs_path) { + namespace fs = std::filesystem; + auto* dd = conn->dd(); + if (!dd) return {}; + std::error_code ec; + auto lower = [](std::string v) { + for (auto& ch : v) + ch = static_cast(std::tolower( + static_cast(ch))); + return v; + }; + const std::string want_base = + lower(fs::path(abs_path).filename().string()); + std::string cb; // canonical abs_path, computed on first basename hit + for (auto& [alias, rel] : dd->tables()) { + if (lower(fs::path(rel).filename().string()) != want_base) continue; + if (cb.empty()) + cb = lower(fs::weakly_canonical(fs::path(abs_path), ec).string()); + fs::path full = fs::path(conn->data_dir()) / rel; + auto ca = lower(fs::weakly_canonical(full, ec).string()); + if (ca == cb) return alias; + } + return {}; +} + +// Right-trim spaces from a DBF field value. +std::string ri_trim(const std::string& s) { + auto i = s.find_last_not_of(' '); + return (i == std::string::npos) ? std::string{} : s.substr(0, i + 1); +} + +// Read a named field from the current record buffer. +std::string ri_read_field(Table& tbl, const std::string& name) { + auto idx = tbl.field_index(name); + if (idx < 0) return {}; + auto v = tbl.read_field(static_cast(idx)); + return v ? v.value().as_string : std::string{}; +} + +// Scan `tbl` for any live row where field `fname` equals `key` (trimmed). +// Returns true if at least one match is found; if `recnos` is non-null +// it collects ALL matching record numbers. +bool ri_scan(Table& tbl, const std::string& fname, const std::string& key, + std::vector* recnos) { + bool any = false; + if (auto r = tbl.goto_top(); !r) return false; + while (!tbl.eof()) { + if (!tbl.is_deleted()) { + if (ri_trim(ri_read_field(tbl, fname)) == key) { + any = true; + if (recnos) recnos->push_back(tbl.recno()); + else return true; // RESTRICT: one match is enough + } + } + (void)tbl.skip(1); + } + return any; +} + +// Called from AdsWriteRecord when the record was freshly appended. +// Validates that every FK field exists in the referenced parent table. +openads::util::Result ri_check_insert(Connection* conn, Table& child) { + if (in_ri_check()) return {}; + auto* dd = conn->dd(); + if (!dd || dd->ri().empty()) return {}; + std::string child_alias = ri_alias_for_path(conn, child.path()); + if (child_alias.empty()) return {}; + + for (const auto* rulep : dd->ri_by_child_table(child_alias)) { + const auto& rule = *rulep; + const std::string& rname = rule.name; + // rule.parent_tag is the parent index tag name; by convention (single-field + // PK/FK) the same name identifies the FK field in the child. + std::string fk_val = ri_trim(ri_read_field(child, rule.parent_tag)); + if (fk_val.empty()) continue; // NULL / blank FK → skip + + auto ph = conn->open_table(rule.parent, + openads::engine::TableType::Cdx, + openads::engine::OpenMode::Read); + if (!ph) { + return openads::util::Error{ + openads::AE_RI_VIOLATION, 0, + "RI: cannot open parent table '" + rule.parent + "'", rname}; + } + Table* parent = conn->lookup_table(ph.value()); + bool found = parent && ri_scan(*parent, rule.parent_tag, fk_val, nullptr); + conn->close_table(ph.value()); + if (!found) { + return openads::util::Error{ + openads::AE_RI_VIOLATION, 0, + "RI violation: FK value '" + fk_val + + "' not found in parent '" + rule.parent + "'", + rname}; + } + } + return {}; +} + +// Called from AdsDeleteRecord before marking the row deleted. +// Enforces delete_opt rules for every RI rule where this table is the parent. +openads::util::Result ri_enforce_delete(Connection* conn, Table& parent) { + if (in_ri_check()) return {}; + auto* dd = conn->dd(); + if (!dd || dd->ri().empty()) return {}; + std::string parent_alias = ri_alias_for_path(conn, parent.path()); + if (parent_alias.empty()) return {}; + + for (const auto* rulep : dd->ri_by_parent_table(parent_alias)) { + const auto& rule = *rulep; + const std::string& rname = rule.name; + std::string pk_val = ri_trim(ri_read_field(parent, rule.parent_tag)); + if (pk_val.empty()) continue; + + unsigned del_opt = ADS_DD_RI_RESTRICT; + if (!rule.delete_opt.empty()) { + try { del_opt = static_cast( + std::stoul(rule.delete_opt)); } catch (...) {} + } + + bool need_write = (del_opt == ADS_DD_RI_CASCADE || + del_opt == ADS_DD_RI_SETNULL || + del_opt == ADS_DD_RI_SETDEFAULT); + // Reuse the application's already-open child instance when present + // (see ri_enforce_update for why a second open races and drops the + // action). Open a fresh one only as a fallback. + Table* child = conn->find_open_table(rule.child); + bool opened_here = false; + Handle ch_handle = 0; + if (!child) { + auto ch = conn->open_table(rule.child, + openads::engine::TableType::Cdx, + need_write ? openads::engine::OpenMode::Shared + : openads::engine::OpenMode::Read); + if (!ch) continue; // can't open child → skip rule + child = conn->lookup_table(ch.value()); + if (!child) { conn->close_table(ch.value()); continue; } + opened_here = true; + ch_handle = ch.value(); + } + + if (del_opt == ADS_DD_RI_RESTRICT) { + bool any = ri_scan(*child, rule.parent_tag, pk_val, nullptr); + if (opened_here) conn->close_table(ch_handle); + if (any) { + return openads::util::Error{ + openads::AE_RI_VIOLATION, 0, + "RI violation: child rows exist in '" + rule.child + "'", + rname}; + } + } else { + // Collect matching recnos first, then apply action. + std::vector matches; + ri_scan(*child, rule.parent_tag, pk_val, &matches); + in_ri_check() = true; + // Lock the child table so writeback_record_() (the GoHot gate) + // permits the cascade/SETNULL writes. + if (need_write) { + (void)child->lock_table_excl(); + } + for (std::uint32_t rec : matches) { + if (auto gr = child->goto_record(rec); !gr) continue; + if (del_opt == ADS_DD_RI_CASCADE) { + (void)child->mark_deleted(); + } else { + // SETNULL / SETDEFAULT: blank the FK field. + auto fi = child->field_index(rule.parent_tag); + if (fi >= 0) { + auto fi16 = static_cast(fi); + std::uint32_t flen = + child->field_descriptor(fi16).length; + (void)child->set_field(fi16, + std::string(flen, ' ')); + } + } + } + in_ri_check() = false; + if (!matches.empty()) (void)child->flush(); + if (opened_here) conn->close_table(ch_handle); + } + } + return {}; +} + +// Called after every successful local-table navigation. +// If the table is a parent in any RI rule, snapshot its PK fields onto +// the Table itself (Table::ri_snapshot()). Storing the snapshot on the +// Table -- rather than in a global Table*-keyed map -- means it lives and +// dies with the table, so a freed-then-reallocated table can never +// inherit a previous table's stale snapshot (the cause of intermittent +// missed cascades/restrictions seen only in the full-suite run). +void snapshot_ri_pks(Table* t) { + if (!t || t->eof()) { + if (t) t->ri_snapshot().clear(); + return; + } + // Runs on EVERY navigation (Skip/GoTop/GoBottom/GoTo) -- resolve the + // DD alias once per Table, not once per step. Without the cache a + // 382K-row cursor walk on a DD connection took ~45 minutes (7 ms of + // fs::weakly_canonical calls per Skip); see ri_alias_for_path. + if (!t->ri_alias_cached()) { + Connection* cfa = conn_for_table(t); + t->ri_alias_cache() = + cfa ? ri_alias_for_path(cfa, t->path()) : std::string(); + t->ri_alias_cached() = true; + } + const std::string& alias = t->ri_alias_cache(); + if (alias.empty()) return; + Connection* conn = conn_for_table(t); + if (!conn) return; + auto* dd = conn->dd(); + if (!dd || dd->ri().empty()) return; + const auto& parent_rules = dd->ri_by_parent_table(alias); + if (parent_rules.empty()) return; + auto& snap = t->ri_snapshot(); + snap.clear(); + for (const auto* rulep : parent_rules) { + const auto& rule = *rulep; + snap[rule.parent_tag] = ri_trim(ri_read_field(*t, rule.parent_tag)); + } +} + +// ── Parent→child work-area relations (AdsSetRelation) ──────────────── +// When a parent's cursor moves, each related child is re-positioned: its +// controlling order is seeked to the key produced by evaluating `expr` +// against the parent's current record. A child with no controlling order +// is moved to the record number the expression yields. A miss (or a +// parent sitting off a record) leaves the child at EOF, matching ACE / +// Clipper dbSetRelation. A `scoped` relation (AdsSetScopedRelation) also +// constrains the child to the group of records whose key matches, by +// setting the child order's top/bottom scope to the relation key. +// Parent work-area → child relations (local Tables and tcp:// RemoteTables). +struct AdsRelation { ADSHANDLE child; std::string expr; bool scoped; }; +std::unordered_map>& relation_map() { + static std::unordered_map> m; + return m; +} + +// Active controlling index per SQL table handle (AdsSetIndexOrder). +std::unordered_map& sql_active_index_handle() { + static std::unordered_map m; + return m; +} + +bool is_sql_table_handle(ADSHANDLE h) { +#if defined(OPENADS_WITH_SQLITE) + if (get_sqlite_table(h)) return true; +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + if (get_postgres_table(h)) return true; +#endif +#if defined(OPENADS_WITH_MARIADB) + if (get_maria_table(h)) return true; +#endif +#if defined(OPENADS_WITH_ODBC) + if (get_odbc_table(h)) return true; +#endif +#if defined(OPENADS_WITH_FIREBIRD) + if (get_firebird_table(h)) return true; +#endif +#if defined(OPENADS_WITH_MSSQL) + if (get_mssql_table(h)) return true; +#endif + (void)h; + return false; +} + +namespace { + +void remote_child_to_eof(openads::network::RemoteTable* child) { + if (child == nullptr || child->conn == nullptr) return; + (void)child->conn->goto_bottom(child); + (void)child->conn->skip(child, 1); + child->row_valid = false; +} + +bool remote_parent_positioned(openads::network::RemoteTable* parent) { + if (parent == nullptr || parent->conn == nullptr) return false; + if (parent->row_valid) return true; + (void)parent->conn->fetch_current_row(parent); + if (parent->row_valid) return true; + auto eof = parent->conn->at_eof(parent->id); + return eof.has_value() && !eof.value(); +} + +std::string remote_parent_field(openads::network::RemoteTable* parent, + const std::string& expr) { + // Defensive: the seek key must derive from committed parent values. + // Normally clean here (every parent nav flushes), so this is free. + (void)remote_flush_pending(parent); + std::string field = openads::engine::strip_alias_qualifiers(expr); + if (!parent->row_valid) { + (void)parent->conn->fetch_current_row(parent); + } + // RCB 07/14/2026: BUG FIX (pre-existing, exposed by the M12.24 warm + // GotoTop). This used to fetch the parent's row into the cache above and + // then ignore it, reading the field with conn->get_field() -- which reads + // the SERVER's cursor. That cursor is offset from the client's logical + // position by cursor_lag whenever rows have been served out of the lookahead + // block, so the relation followed a STALE PARENT ROW and pointed the child + // at the wrong record. + // + // It stayed hidden because the only coverage skipped the parent exactly + // once, and before the warm GotoTop that first Skip always went to the wire + // (the queue was still empty), leaving the cursors in sync. The SECOND skip + // of any scan would have drained the queue and broken -- i.e. this has been + // wrong for parent/child grids since sequential prefetch landed. + // + // Read the cached row, which is the client's logical current record by + // construction. Also removes a wire round-trip per parent row per relation. + // + // DO NOT "simplify" this back to a single conn->get_field() call. It reads + // like the obvious, direct thing to do -- that is exactly how the bug got + // written -- but asking the server for "the current record" is asking the + // WRONG QUESTION whenever the client is ahead of it. Guarded by the + // full-parent walk in the AdsSetRelation remote test; a test that skips the + // parent only once will NOT catch a regression here. + if (parent->row_valid) { + if (!parent->fields_cached) { + if (auto d = parent->conn->describe_table(parent->id)) { + parent->fields = std::move(d).value(); + parent->fields_cached = true; + } + } + auto upper = [](std::string s) { + for (auto& c : s) { + c = static_cast( + std::toupper(static_cast(c))); + } + return s; + }; + const std::string want = upper(field); + const std::size_t n = std::min(parent->fields.size(), + parent->current_row.size()); + for (std::size_t i = 0; i < n; ++i) { + if (upper(parent->fields[i].name) == want) { + std::string vstr = parent->current_row[i]; + remote_pending_overlay(parent, i, vstr); + return vstr; + } + } + } + // Fallback: no cached row (e.g. an older server that sent no trailer). + // The server cursor is authoritative in that case because nothing was + // drained locally. + auto v = parent->conn->get_field(parent->id, field); + return v.has_value() ? v.value() : std::string(); +} + +std::string remote_relation_seek_key(openads::network::RemoteTable* child, + const std::string& raw) { + if (!child->fields_cached) { + auto r = child->conn->describe_table(child->id); + if (r) { + child->fields = std::move(r).value(); + child->fields_cached = true; + } + } + std::uint32_t klen = 10; + bool numeric = false; + std::uint16_t width = 0; + std::uint16_t dec = 0; + if (child->active_index_id != 0) { + for (auto& [tag, wid] : child->index_by_tag) { + if (wid == child->active_index_id) { + (void)tag; + break; + } + } + } + for (auto& fd : child->fields) { + if (fd.type == ADS_NUMERIC || fd.type == ADS_INTEGER || + fd.type == ADS_DOUBLE) { + numeric = true; + width = static_cast(fd.length); + dec = fd.decimals; + klen = fd.length > 0 ? fd.length : 8u; + } + } + if (numeric) { + char buf[264]; + double dv = 0; + try { dv = std::stod(raw); } catch (...) { dv = 0; } + int n = (dec > 0) + ? std::snprintf(buf, sizeof(buf), "%*.*f", + static_cast(width), static_cast(dec), dv) + : std::snprintf(buf, sizeof(buf), "%*.0f", + static_cast(width), dv); + std::size_t take = (n < 0) + ? 0u + : std::min(static_cast(n), + sizeof(buf) - 1); + std::string key(buf, take); + if (key.size() < klen) key.append(klen - key.size(), ' '); + return key; + } + std::string key = raw; + if (key.size() < klen) key.append(klen - key.size(), ' '); + else if (key.size() > klen) key.resize(klen); + return key; +} + +void seek_remote_child_relation(openads::network::RemoteTable* parent, + openads::network::RemoteTable* child, + const std::string& expr, + bool scoped) { + if (parent == nullptr || child == nullptr || child->conn == nullptr) return; + // A detail line edited but not yet flushed belongs to the child row + // under the previous parent; the seek below moves the child cursor, + // so land the buffered writes first (no-op when clean). + (void)remote_flush_pending(child); + if (!remote_parent_positioned(parent)) { + if (scoped && child->active_index_id != 0) { + (void)child->conn->clear_scope(child->active_index_id, ADS_TOP); + (void)child->conn->clear_scope(child->active_index_id, ADS_BOTTOM); + } + remote_child_to_eof(child); + return; + } + std::string raw = remote_parent_field(parent, expr); + if (child->active_index_id == 0) { + double dv = 0; + try { dv = std::stod(raw); } catch (...) { dv = 0; } + auto rc = child->conn->record_count(child->id); + std::uint32_t max_rec = rc.has_value() ? rc.value() : 0; + auto rn = static_cast(dv); + if (rn >= 1 && rn <= max_rec) { + (void)child->conn->goto_record(child, rn); + } else { + remote_child_to_eof(child); + } + return; + } + std::string key = remote_relation_seek_key(child, raw); + if (scoped) { + (void)child->conn->clear_scope(child->active_index_id, ADS_TOP); + (void)child->conn->clear_scope(child->active_index_id, ADS_BOTTOM); + (void)child->conn->set_scope(child->active_index_id, ADS_TOP, + key, ADS_STRINGKEY); + (void)child->conn->set_scope(child->active_index_id, ADS_BOTTOM, + key, ADS_STRINGKEY); + (void)child->conn->goto_top(child); + child->found_cached = true; + child->current_found = true; + return; + } + // RCB 07/14/2026: M12.24 -- pass `child` so the SeekAck's row trailer lands + // straight in the child's row cache. + // + // Clearing row_valid FIRST is load-bearing, not tidiness: it is what turns + // the `!child->row_valid` test below into an honest "did the server actually + // send me a row?" probe. Leave a stale row_valid=true standing here and that + // test answers yes for the PREVIOUS parent's child row, we skip the fallback, + // and the grid quietly shows the wrong child. + child->row_valid = false; + child->invalidate_prefetch(); + auto so = child->conn->seek(child->active_index_id, key, + /*soft=*/1, /*last=*/0, child); + if (!so || so.value().hit == 0) { + remote_child_to_eof(child); + return; + } + // RCB 07/14/2026: this GotoRecord used to fire UNCONDITIONALLY, and it was + // pure overhead -- the seek had ALREADY positioned the server cursor on this + // record. The only reason for the second frame was to drag the row down, + // because SeekAck carried no row. It runs once per PARENT row in a relation + // browse, so it was doubling the wire cost of every child lookup in the grid. + // + // An M12.24 server hands us the row with the seek, so this is now only sent + // when talking to an OLDER server that didn't. It is NOT dead code -- delete + // the branch and every remote relation against a pre-M12.24 server silently + // stops resolving its child row. (See the row_valid note above for why the + // condition is trustworthy.) + if (!child->row_valid) { + (void)child->conn->goto_record(child, so.value().recno); + } + child->found_cached = true; + child->current_found = true; +} + +void seek_remote_child_relation(Table* parent, + openads::network::RemoteTable* child, + const std::string& expr, + bool scoped) { + if (parent == nullptr || child == nullptr || child->conn == nullptr) return; + // Same child-cursor flush as the RemoteTable-parent overload above. + (void)remote_flush_pending(child); + if (!parent->positioned() || parent->eof()) { + if (scoped && child->active_index_id != 0) { + (void)child->conn->clear_scope(child->active_index_id, ADS_TOP); + (void)child->conn->clear_scope(child->active_index_id, ADS_BOTTOM); + } + remote_child_to_eof(child); + return; + } + const std::string field = openads::engine::strip_alias_qualifiers(expr); + std::string raw = ri_read_field(*parent, field); + while (!raw.empty() && raw.back() == ' ') raw.pop_back(); + if (child->active_index_id == 0) { + double dv = 0; + try { dv = std::stod(raw); } catch (...) { dv = 0; } + auto rc = child->conn->record_count(child->id); + std::uint32_t max_rec = rc.has_value() ? rc.value() : 0; + auto rn = static_cast(dv); + if (rn >= 1 && rn <= max_rec) { + (void)child->conn->goto_record(child, rn); + } else { + remote_child_to_eof(child); + } + return; + } + std::string key = remote_relation_seek_key(child, raw); + if (scoped) { + (void)child->conn->clear_scope(child->active_index_id, ADS_TOP); + (void)child->conn->clear_scope(child->active_index_id, ADS_BOTTOM); + (void)child->conn->set_scope(child->active_index_id, ADS_TOP, + key, ADS_STRINGKEY); + (void)child->conn->set_scope(child->active_index_id, ADS_BOTTOM, + key, ADS_STRINGKEY); + (void)child->conn->goto_top(child); + child->found_cached = true; + child->current_found = true; + return; + } + // RCB 07/14/2026: M12.24 -- pass `child` so the SeekAck's row trailer lands + // straight in the child's row cache. + // + // Clearing row_valid FIRST is load-bearing, not tidiness: it is what turns + // the `!child->row_valid` test below into an honest "did the server actually + // send me a row?" probe. Leave a stale row_valid=true standing here and that + // test answers yes for the PREVIOUS parent's child row, we skip the fallback, + // and the grid quietly shows the wrong child. + child->row_valid = false; + child->invalidate_prefetch(); + auto so = child->conn->seek(child->active_index_id, key, + /*soft=*/1, /*last=*/0, child); + if (!so || so.value().hit == 0) { + remote_child_to_eof(child); + return; + } + // RCB 07/14/2026: this GotoRecord used to fire UNCONDITIONALLY, and it was + // pure overhead -- the seek had ALREADY positioned the server cursor on this + // record. The only reason for the second frame was to drag the row down, + // because SeekAck carried no row. It runs once per PARENT row in a relation + // browse, so it was doubling the wire cost of every child lookup in the grid. + // + // An M12.24 server hands us the row with the seek, so this is now only sent + // when talking to an OLDER server that didn't. It is NOT dead code -- delete + // the branch and every remote relation against a pre-M12.24 server silently + // stops resolving its child row. (See the row_valid note above for why the + // condition is trustworthy.) + if (!child->row_valid) { + (void)child->conn->goto_record(child, so.value().recno); + } + child->found_cached = true; + child->current_found = true; +} + +#if defined(OPENADS_WITH_SQLITE) +void sql_child_to_eof_sqlite(openads::sql_backend::SqliteTable* child) { + if (child == nullptr) return; + child->positioned = false; + child->row_valid = false; + child->pos = child->rowids.size(); +} +#endif + +#if defined(OPENADS_WITH_MARIADB) +void sql_child_to_eof_maria(openads::sql_backend::MariaTable* child) { + if (child == nullptr) return; + child->positioned = false; + child->row_valid = false; + child->pos = child->pk_snapshot.size(); +} +#endif + +#if defined(OPENADS_WITH_ODBC) +void sql_child_to_eof_odbc(openads::sql_backend::OdbcTable* child) { + if (child == nullptr) return; + child->positioned = false; + child->row_valid = false; + child->pos = child->pk_snapshot.size(); +} +#endif + +#if defined(OPENADS_WITH_FIREBIRD) +void sql_child_to_eof_firebird(openads::sql_backend::FirebirdTable* child) { + if (child == nullptr) return; + child->positioned = false; + child->row_valid = false; + child->pos = child->pk_snapshot.size(); +} +#endif + +#if defined(OPENADS_WITH_POSTGRESQL) +void sql_child_to_eof_postgres(openads::sql_backend::PostgresTable* child) { + if (child == nullptr) return; + child->positioned = false; + child->row_valid = false; + child->pos = child->pk_snapshot.size(); +} +#endif + +#if defined(OPENADS_WITH_MSSQL) +void sql_child_to_eof_mssql(openads::sql_backend::MssqlTable* child) { + if (child == nullptr) return; + child->pos = child->data.rows.size(); + child->bof = false; + child->eof = true; +} +#endif + +std::string sql_parent_field_raw(const std::string& expr) { + return openads::engine::strip_alias_qualifiers(expr); +} + +std::string sql_escape_literal_value(const std::string& s) { + std::string o; + o.reserve(s.size()); + for (char c : s) { + if (c == '\'') o += "''"; + else o += c; + } + return o; +} + +bool sql_raw_key_looks_numeric(const std::string& raw) { + if (raw.empty()) return false; + bool saw_digit = false; + for (char c : raw) { + if (std::isdigit(static_cast(c))) { + saw_digit = true; + continue; + } + if (c == '.' || c == '-' || c == '+') continue; + return false; + } + return saw_digit; +} + +std::string sql_scope_equality(const std::string& col, + const std::string& raw_key, + const char* ql, + const char* qr) { + const std::string qcol = std::string(ql) + col + qr; + if (sql_raw_key_looks_numeric(raw_key)) { + return qcol + " = " + raw_key; + } + return qcol + " = '" + sql_escape_literal_value(raw_key) + "'"; +} + +void sql_clear_relation_scope(ADSHANDLE hChild) { +#if defined(OPENADS_WITH_SQLITE) + if (auto* ct = get_sqlite_table(hChild)) { + if (ct->conn == nullptr) return; + ct->where_builder.scope_lower.clear(); + ct->where_builder.scope_upper.clear(); + (void)ct->conn->refresh_where_filter(ct); + return; + } +#endif +#if defined(OPENADS_WITH_MARIADB) + if (auto* ct = get_maria_table(hChild)) { + if (ct->conn == nullptr) return; + ct->where_builder.scope_lower.clear(); + ct->where_builder.scope_upper.clear(); + (void)ct->conn->refresh_where_filter(ct); + return; + } +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* ct = get_postgres_table(hChild)) { + if (ct->conn == nullptr) return; + ct->where_builder.scope_lower.clear(); + ct->where_builder.scope_upper.clear(); + (void)ct->conn->refresh_where_filter(ct); + return; + } +#endif +#if defined(OPENADS_WITH_ODBC) + if (auto* ct = get_odbc_table(hChild)) { + if (ct->conn == nullptr) return; + ct->where_builder.scope_lower.clear(); + ct->where_builder.scope_upper.clear(); + (void)ct->conn->refresh_where_filter(ct); + return; + } +#endif +#if defined(OPENADS_WITH_FIREBIRD) + if (auto* ct = get_firebird_table(hChild)) { + if (ct->conn == nullptr) return; + ct->where_builder.scope_lower.clear(); + ct->where_builder.scope_upper.clear(); + (void)ct->conn->refresh_where_filter(ct); + return; + } +#endif +#if defined(OPENADS_WITH_MSSQL) + if (auto* ct = get_mssql_table(hChild)) { + if (ct->conn == nullptr) return; + ct->where_builder.scope_lower.clear(); + ct->where_builder.scope_upper.clear(); + (void)ct->conn->refresh_where_filter(ct); + return; + } +#endif + (void)hChild; +} + +bool sql_apply_relation_scope(ADSHANDLE hChild, ADSHANDLE idx_h, + const std::string& raw_key) { + if (raw_key.empty()) { + sql_clear_relation_scope(hChild); + return true; + } +#if defined(OPENADS_WITH_SQLITE) + if (auto* ct = get_sqlite_table(hChild)) { + if (ct->conn == nullptr) return false; + if (auto* si = get_sqlite_index(idx_h); si != nullptr) { + const std::string pred = + sql_scope_equality(si->column, raw_key, "\"", "\""); + ct->where_builder.scope_lower = pred; + ct->where_builder.scope_upper = pred; + (void)ct->conn->refresh_where_filter(ct); + (void)ct->conn->goto_top(ct); + return true; + } + return false; + } +#endif +#if defined(OPENADS_WITH_MARIADB) + if (auto* ct = get_maria_table(hChild)) { + if (ct->conn == nullptr) return false; + if (auto* si = get_maria_index(idx_h); si != nullptr) { + const std::string pred = + sql_scope_equality(si->column, raw_key, "`", "`"); + ct->where_builder.scope_lower = pred; + ct->where_builder.scope_upper = pred; + (void)ct->conn->refresh_where_filter(ct); + (void)ct->conn->goto_top(ct); + return true; + } + return false; + } +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* ct = get_postgres_table(hChild)) { + if (ct->conn == nullptr) return false; + if (auto* si = get_postgres_index(idx_h); si != nullptr) { + const std::string pred = + sql_scope_equality(si->column, raw_key, "\"", "\""); + ct->where_builder.scope_lower = pred; + ct->where_builder.scope_upper = pred; + (void)ct->conn->refresh_where_filter(ct); + (void)ct->conn->goto_top(ct); + return true; + } + return false; + } +#endif +#if defined(OPENADS_WITH_ODBC) + if (auto* ct = get_odbc_table(hChild)) { + if (ct->conn == nullptr) return false; + if (auto* si = get_odbc_index(idx_h); si != nullptr) { + const std::string pred = + sql_scope_equality(si->column, raw_key, "\"", "\""); + ct->where_builder.scope_lower = pred; + ct->where_builder.scope_upper = pred; + (void)ct->conn->refresh_where_filter(ct); + (void)ct->conn->goto_top(ct); + return true; + } + return false; + } +#endif +#if defined(OPENADS_WITH_FIREBIRD) + if (auto* ct = get_firebird_table(hChild)) { + if (ct->conn == nullptr) return false; + if (auto* si = get_firebird_index(idx_h); si != nullptr) { + const std::string pred = + sql_scope_equality(si->column, raw_key, "\"", "\""); + ct->where_builder.scope_lower = pred; + ct->where_builder.scope_upper = pred; + (void)ct->conn->refresh_where_filter(ct); + (void)ct->conn->goto_top(ct); + return true; + } + return false; + } +#endif +#if defined(OPENADS_WITH_MSSQL) + if (auto* ct = get_mssql_table(hChild)) { + if (ct->conn == nullptr) return false; + if (auto* si = get_mssql_index(idx_h); si != nullptr) { + const std::string pred = + sql_scope_equality(si->column, raw_key, "[", "]"); + ct->where_builder.scope_lower = pred; + ct->where_builder.scope_upper = pred; + (void)ct->conn->refresh_where_filter(ct); + return true; + } + return false; + } +#endif + (void)hChild; (void)idx_h; + return false; +} + +template +bool sql_read_parent_string(TableT* parent, ConnT* conn, + const std::string& expr, std::string& out) { + if (parent == nullptr || conn == nullptr) return false; + const std::string fname = sql_parent_field_raw(expr); + bool is_null = false; + auto r = conn->read_field(parent, fname, out, is_null); + if (!r) return false; + if (is_null) out.clear(); + return true; +} + +void seek_sql_child(ADSHANDLE hChild, const std::string& raw_key, bool scoped) { + ADSHANDLE idx_h = 0; + if (auto it = sql_active_index_handle().find(hChild); + it != sql_active_index_handle().end()) { + idx_h = it->second; + } + if (scoped && idx_h != 0) { + if (raw_key.empty()) { + sql_clear_relation_scope(hChild); +#if defined(OPENADS_WITH_SQLITE) + if (auto* ct = get_sqlite_table(hChild)) sql_child_to_eof_sqlite(ct); +#endif +#if defined(OPENADS_WITH_MARIADB) + if (auto* ct = get_maria_table(hChild)) sql_child_to_eof_maria(ct); +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* ct = get_postgres_table(hChild)) sql_child_to_eof_postgres(ct); +#endif +#if defined(OPENADS_WITH_ODBC) + if (auto* ct = get_odbc_table(hChild)) sql_child_to_eof_odbc(ct); +#endif +#if defined(OPENADS_WITH_FIREBIRD) + if (auto* ct = get_firebird_table(hChild)) sql_child_to_eof_firebird(ct); +#endif +#if defined(OPENADS_WITH_MSSQL) + if (auto* ct = get_mssql_table(hChild)) sql_child_to_eof_mssql(ct); +#endif + return; + } + if (sql_apply_relation_scope(hChild, idx_h, raw_key)) return; + } +#if defined(OPENADS_WITH_SQLITE) + if (auto* ct = get_sqlite_table(hChild)) { + if (ct->conn == nullptr) return; + if (raw_key.empty()) { sql_child_to_eof_sqlite(ct); return; } + if (idx_h == 0) { + double dv = 0; + try { dv = std::stod(raw_key); } catch (...) { dv = 0; } + const auto rn = static_cast(dv); + if (rn >= 1 && rn <= ct->rowids.size()) { + (void)ct->conn->goto_top(ct); + if (rn > 1) { + (void)ct->conn->skip(ct, static_cast(rn - 1)); + } + } else { + sql_child_to_eof_sqlite(ct); + } + return; + } + if (auto* si = get_sqlite_index(idx_h)) { + (void)si->parent->conn->seek_index( + si->parent, si->column, raw_key, true, false); + return; + } + } +#endif +#if defined(OPENADS_WITH_MARIADB) + if (auto* ct = get_maria_table(hChild)) { + if (ct->conn == nullptr) return; + if (idx_h == 0) { + double dv = 0; + try { dv = std::stod(raw_key); } catch (...) { dv = 0; } + if (dv >= 1 && + dv <= static_cast(ct->pk_snapshot.size())) { + ct->pos = static_cast(dv) - 1; + (void)ct->conn->goto_top(ct); + (void)ct->conn->skip(ct, static_cast(ct->pos)); + } else { + sql_child_to_eof_maria(ct); + } + return; + } + if (auto* si = get_maria_index(idx_h)) { + (void)si->parent->conn->seek_index( + si->parent, si->column, raw_key, true, false); + return; + } + } +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* ct = get_postgres_table(hChild)) { + if (ct->conn == nullptr) return; + if (idx_h != 0) { + if (auto* si = get_postgres_index(idx_h)) { + (void)si->parent->conn->seek_index( + si->parent, si->column, raw_key, true, false); + } + } else { + sql_child_to_eof_postgres(ct); + } + return; + } +#endif +#if defined(OPENADS_WITH_ODBC) + if (auto* ct = get_odbc_table(hChild)) { + if (ct->conn == nullptr) return; + if (idx_h != 0) { + if (auto* si = get_odbc_index(idx_h)) { + (void)si->parent->conn->seek_index( + si->parent, si->column, si->expr_kind, raw_key, true, false); + } + } else { + sql_child_to_eof_odbc(ct); + } + return; + } +#endif +#if defined(OPENADS_WITH_FIREBIRD) + if (auto* ct = get_firebird_table(hChild)) { + if (ct->conn == nullptr) return; + if (idx_h != 0) { + if (auto* si = get_firebird_index(idx_h)) { + (void)si->parent->conn->seek_index( + si->parent, si->column, si->expr_kind, raw_key, true, false); + } + } else { + sql_child_to_eof_firebird(ct); + } + return; + } +#endif +#if defined(OPENADS_WITH_MSSQL) + if (auto* ct = get_mssql_table(hChild)) { + if (ct->conn == nullptr) return; + if (idx_h != 0) { + if (auto* si = get_mssql_index(idx_h)) { + (void)ct->conn->seek_index(ct, si->column, raw_key, true, false); + } + } else { + sql_child_to_eof_mssql(ct); + } + return; + } +#endif + (void)hChild; (void)raw_key; +} + +bool sql_parent_key(ADSHANDLE hParent, const std::string& expr, std::string& key) { +#if defined(OPENADS_WITH_SQLITE) + if (auto* pt = get_sqlite_table(hParent)) { + if (!pt->positioned || !pt->row_valid || pt->is_result || !pt->conn) { + return false; + } + return sql_read_parent_string(pt, pt->conn, expr, key); + } +#endif +#if defined(OPENADS_WITH_MARIADB) + if (auto* pt = get_maria_table(hParent)) { + if (!pt->positioned || !pt->row_valid || !pt->conn) return false; + return sql_read_parent_string(pt, pt->conn, expr, key); + } +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* pt = get_postgres_table(hParent)) { + if (!pt->positioned || !pt->row_valid || !pt->conn) return false; + return sql_read_parent_string(pt, pt->conn, expr, key); + } +#endif +#if defined(OPENADS_WITH_ODBC) + if (auto* pt = get_odbc_table(hParent)) { + if (!pt->positioned || !pt->row_valid || !pt->conn) return false; + return sql_read_parent_string(pt, pt->conn, expr, key); + } +#endif +#if defined(OPENADS_WITH_FIREBIRD) + if (auto* pt = get_firebird_table(hParent)) { + if (!pt->positioned || !pt->row_valid || !pt->conn) return false; + return sql_read_parent_string(pt, pt->conn, expr, key); + } +#endif +#if defined(OPENADS_WITH_MSSQL) + if (auto* pt = get_mssql_table(hParent)) { + if (pt->bof || pt->eof) return false; + const std::string fname = sql_parent_field_raw(expr); + for (std::size_t i = 0; i < pt->field_count(); ++i) { + if (pt->field_name(i) == fname) { + bool is_null = false; + (void)pt->get_field(i, key, is_null); + return true; + } + } + return false; + } +#endif + (void)hParent; (void)expr; (void)key; + return false; +} + +} // namespace + +// Drive `child` to EOF -- used when the parent has no current record or +// the relation key finds no match. +void relation_child_to_eof(Table* child) { + (void)child->goto_bottom(); + (void)child->skip(1); +} + +// Build the seek key for a relation, in the child index's encoding, +// mirroring AdsSeek's numeric handling so a numeric child index is matched +// the same way an explicit dbSeek would be. `dk` must be non-null. +std::string relation_child_key(Table* parent, Table* child, + const std::string& expr, + openads::drivers::IIndex* dk) { + std::int32_t fidx = child->field_index( + openads::engine::strip_alias_qualifiers(dk->expression())); + bool numeric = false; + if (fidx >= 0) { + auto ft = child->field_descriptor( + static_cast(fidx)).type; + numeric = (ft == openads::drivers::DbfFieldType::Numeric || + ft == openads::drivers::DbfFieldType::Float); + } + const auto enc = dk->key_encoding(); + double dv = 0; + const bool want_numeric = + enc == openads::drivers::KeyEncoding::FoxNumeric || + enc == openads::drivers::KeyEncoding::NtxNumeric || numeric; + if (want_numeric && + openads::engine::evaluate_index_expr_number(*parent, expr, dv)) { + if (enc == openads::drivers::KeyEncoding::FoxNumeric) + return openads::engine::fox_numeric_key(dv); + if (enc == openads::drivers::KeyEncoding::NtxNumeric) + return openads::engine::ntx_numeric_key( + dv, dk->key_length(), dk->key_decimals()); + // ASCII-stored numeric key (DBF text): right-align to the field + // width, pad to the index key length with spaces. + std::uint16_t klen = dk->key_length(); + std::uint16_t w = klen, dec = 0; + if (fidx >= 0) { + const auto& fd = child->field_descriptor( + static_cast(fidx)); + if (fd.length > 0) w = static_cast(fd.length); + dec = static_cast(fd.decimals); + } + char buf[264]; + int n = (dec > 0) + ? std::snprintf(buf, sizeof(buf), "%*.*f", + static_cast(w), static_cast(dec), dv) + : std::snprintf(buf, sizeof(buf), "%*.0f", + static_cast(w), dv); + std::size_t take = (n < 0) + ? 0u + : std::min(static_cast(n), + sizeof(buf) - 1); + std::string key(buf, take); + if (key.size() < klen) key.append(klen - key.size(), ' '); + return key; + } + // Character key (or a non-numeric parent expression): evaluate the + // expression against the parent and pad to the child key length. + auto k = openads::engine::evaluate_index_expr( + *parent, expr, dk->key_length()); + return k ? k.value() : std::string(); +} + +void seek_child_relation(Table* parent, Table* child, const std::string& expr, + bool scoped) { + if (parent == nullptr || child == nullptr) return; + if (parent->eof() || !parent->positioned()) { + if (scoped) (void)child->clear_scopes(); + relation_child_to_eof(child); + return; + } + openads::engine::Order* ord = child->order(); + openads::drivers::IIndex* dk = (ord != nullptr) ? ord->index() : nullptr; + if (dk == nullptr) { + // No controlling order: the expression yields a record number. + // A scope needs an order, so a scoped relation degrades to a plain + // record-number move here. + double dv = 0; + if (openads::engine::evaluate_index_expr_number(*parent, expr, dv)) { + auto rn = static_cast(dv); + if (rn >= 1 && rn <= child->record_count()) + (void)child->goto_record(rn); + else + relation_child_to_eof(child); + } + return; + } + + std::string key = relation_child_key(parent, child, expr, dk); + + if (scoped) { + // Constrain the child to the matching key group: top == bottom == + // key, then land on the first record in scope. + (void)child->clear_scopes(); + (void)child->set_scope(true, key); + (void)child->set_scope(false, key); + auto gt = child->goto_top(); + child->set_last_seek_found(static_cast(gt) && !child->eof()); + return; + } + + auto r = child->seek_key(key, /*soft=*/true); + if (r) { + child->set_last_seek_found(r.value()); + if (!r.value()) relation_child_to_eof(child); + } +} + +void apply_relations_for_handle(ADSHANDLE hParent); + +void apply_sql_parent_relations(ADSHANDLE hParent) { + auto& tbl = relation_map(); + auto it = tbl.find(hParent); + if (it == tbl.end()) return; + for (auto& rel : it->second) { + std::string key; + if (!sql_parent_key(hParent, rel.expr, key)) { + seek_sql_child(rel.child, "", rel.scoped); + } else { + seek_sql_child(rel.child, key, rel.scoped); + } + apply_relations_for_handle(rel.child); + } +} + +// Re-seek every child related to `hParent`, then cascade into each child's +// own relations so a multi-level chain (A→B→C) refreshes end to end. +void apply_relations_for_handle(ADSHANDLE hParent) { + auto& tbl = relation_map(); + auto it = tbl.find(hParent); + if (it == tbl.end()) return; + static thread_local std::unordered_set active; + if (!active.insert(hParent).second) return; + if (is_sql_table_handle(hParent)) { + apply_sql_parent_relations(hParent); + active.erase(hParent); + return; + } + if (auto* rtp = get_remote_table(hParent)) { + for (auto& rel : it->second) { + if (auto* rtc = get_remote_table(rel.child)) { + seek_remote_child_relation(rtp, rtc, rel.expr, rel.scoped); + apply_relations_for_handle(rel.child); + } + } + active.erase(hParent); + return; + } + Table* parent = get_table(hParent); + if (parent == nullptr) { + active.erase(hParent); + return; + } + for (auto& rel : it->second) { + if (Table* child = get_table(rel.child)) { + seek_child_relation(parent, child, rel.expr, rel.scoped); + apply_relations_for_handle(rel.child); + } else if (auto* rtc = get_remote_table(rel.child)) { + seek_remote_child_relation(parent, rtc, rel.expr, rel.scoped); + apply_relations_for_handle(rel.child); + } else if (is_sql_table_handle(rel.child)) { + std::string key; + if (sql_parent_key(hParent, rel.expr, key)) { + seek_sql_child(rel.child, key, rel.scoped); + } else { + seek_sql_child(rel.child, "", rel.scoped); + } + apply_relations_for_handle(rel.child); + } + } + active.erase(hParent); +} + +void apply_relations_for_table(Table* parent) { + if (parent == nullptr) return; + auto& s = state(); + std::lock_guard lk(s.mu); + ADSHANDLE h = 0; + s.registry.for_each_handle([&](Handle handle, HandleKind k, void* p) { + if (!h && k == HandleKind::Table && + static_cast(p) == parent) { + h = to_ads_handle(handle); + } + }); + if (h) apply_relations_for_handle(h); +} + +// Drop any relation state touching a closing table handle. +void forget_relations(ADSHANDLE h) { + sql_active_index_handle().erase(h); + auto& tbl = relation_map(); + if (auto it = tbl.find(h); it != tbl.end()) { + for (auto& rel : it->second) { + if (!rel.scoped) continue; + if (Table* child = get_table(rel.child)) { + (void)child->clear_scopes(); + } else if (auto* rtc = get_remote_table(rel.child); + rtc != nullptr && rtc->active_index_id != 0 && + rtc->conn != nullptr) { + (void)rtc->conn->clear_scope(rtc->active_index_id, ADS_TOP); + (void)rtc->conn->clear_scope(rtc->active_index_id, ADS_BOTTOM); + } else if (is_sql_table_handle(rel.child)) { + sql_clear_relation_scope(rel.child); + } + } + tbl.erase(it); + } + for (auto& [parent, kids] : tbl) { + for (auto kid = kids.begin(); kid != kids.end();) { + kid = (kid->child == h) ? kids.erase(kid) : kid + 1; + } + } +} + +// Called from AdsWriteRecord for non-append writes (i.e. plain UPDATE). +// For every RI rule where this table is the parent, compares the new PK +// value (in the dirty buffer) against the old PK value snapshotted at +// navigation time. If they differ, enforces update_opt on the child table. +openads::util::Result ri_enforce_update(Connection* conn, Table& parent) { + if (in_ri_check()) return {}; + auto* dd = conn->dd(); + if (!dd || dd->ri().empty()) return {}; + std::string parent_alias = ri_alias_for_path(conn, parent.path()); + if (parent_alias.empty()) return {}; + + for (const auto* rulep : dd->ri_by_parent_table(parent_alias)) { + const auto& rule = *rulep; + const std::string& rname = rule.name; + + unsigned upd_opt = ADS_DD_RI_RESTRICT; + if (!rule.update_opt.empty()) { + try { upd_opt = static_cast( + std::stoul(rule.update_opt)); } catch (...) {} + } + if (upd_opt == 0) continue; + + // New PK value is in the dirty buffer. + std::string new_pk = ri_trim(ri_read_field(parent, rule.parent_tag)); + + // Old PK value was snapshotted onto the parent Table at nav time. + auto& snap = parent.ri_snapshot(); + auto fit = snap.find(rule.parent_tag); + if (fit == snap.end()) continue; + std::string old_pk = fit->second; + + if (old_pk == new_pk) continue; // no PK change for this rule + if (old_pk.empty()) continue; // was blank (NULL) -- skip + + bool need_write = (upd_opt == ADS_DD_RI_CASCADE || + upd_opt == ADS_DD_RI_SETNULL || + upd_opt == ADS_DD_RI_SETDEFAULT); + // Prefer the child instance the application already has open on + // this connection -- cascading into a *second* open of the same + // file races the OS file cache and share-mode locks, which + // intermittently dropped the cascade/restrict. Only open (and + // later close) a fresh instance when the child isn't already open. + Table* child = conn->find_open_table(rule.child); + bool opened_here = false; + Handle ch_handle = 0; + if (!child) { + auto ch = conn->open_table(rule.child, + openads::engine::TableType::Cdx, + need_write ? openads::engine::OpenMode::Shared + : openads::engine::OpenMode::Read); + if (!ch) continue; + child = conn->lookup_table(ch.value()); + if (!child) { conn->close_table(ch.value()); continue; } + opened_here = true; + ch_handle = ch.value(); + } + + if (upd_opt == ADS_DD_RI_RESTRICT) { + bool any = ri_scan(*child, rule.parent_tag, old_pk, nullptr); + if (opened_here) conn->close_table(ch_handle); + if (any) { + // Restore parent's old PK in the dirty buffer (and any prior + // immediate write). set_field encodes into the buffer; the + // failed WriteRecord path never commits, so disk stays old. + auto rfi = parent.field_index(rule.parent_tag); + if (rfi >= 0) { + auto rfi16 = static_cast(rfi); + std::uint32_t rflen = parent.field_descriptor(rfi16).length; + std::string padded = old_pk; + padded.resize(rflen, ' '); + (void)parent.set_field(rfi16, padded); + } + return openads::util::Error{ + openads::AE_RI_VIOLATION, 0, + "RI violation: child rows reference old PK '" + old_pk + + "' in '" + rule.child + "'", + rname}; + } + } else { + std::vector matches; + ri_scan(*child, rule.parent_tag, old_pk, &matches); + in_ri_check() = true; + // Lock the child table so writeback_record_() (the GoHot gate) + // permits the cascade/SETNULL writes. Exclusive is safe here + // because the RI cascade already serialises through the parent's + // write path; no other writer can be concurrently accessing the + // same child table. + if (need_write) { + (void)child->lock_table_excl(); + } + auto fi = child->field_index(rule.parent_tag); + if (fi >= 0) { + auto fi16 = static_cast(fi); + std::uint32_t flen = child->field_descriptor(fi16).length; + for (std::uint32_t rec : matches) { + if (auto gr = child->goto_record(rec); !gr) continue; + if (upd_opt == ADS_DD_RI_CASCADE) { + std::string padded = new_pk; + padded.resize(flen, ' '); + (void)child->set_field(fi16, padded); + } else { + // SETNULL / SETDEFAULT: blank the FK field. + (void)child->set_field(fi16, std::string(flen, ' ')); + } + } + } + in_ri_check() = false; + if (!matches.empty()) (void)child->flush(); + if (opened_here) conn->close_table(ch_handle); + } + } + return {}; +} + +// Returns effective permission level (0-4) for the authenticated user on a +// DD table alias. Returns 4 (full) when no ACL or no DD is present. +// Legacy -- kept for callers that only need a coarse level. +[[maybe_unused]] int table_perm_level(Connection* conn, const std::string& alias) { + if (!conn || !conn->has_dd()) return 4; + auto* dd = conn->dd(); + if (!dd->has_table_acl(alias)) return 4; + return dd->get_effective_permission(conn->username(), alias); +} + +// Returns per-operation effective permissions for the connected user on object. +// If no DD / no ACL defined → all ops open. +openads::engine::DataDict::EffectiveOps +eff_ops(Connection* conn, const std::string& object_name) { + openads::engine::DataDict::EffectiveOps full; + full.open = full.select_ = full.update_ = + full.insert_ = full.delete_ = full.execute_ = true; + if (!conn || !conn->has_dd()) return full; + auto* dd = conn->dd(); + if (!dd->has_any_acl()) return full; + return dd->get_effective_ops(conn->username(), object_name); +} + +bool dd_can_view_object_metadata(Connection* conn, const std::string& object_name) { + if (!conn || !conn->has_dd() || conn->username().empty()) return true; + auto ops = eff_ops(conn, object_name); + // RCB 2026-06-28: DD metadata is not table data, but exposing names, + // columns, indexes, triggers, or properties still reveals protected objects. + // A user with zero effective bits on a protected object cannot view that + // object's metadata; any inherited/direct operation keeps legacy visibility. + return ops.open || ops.select_ || ops.update_ || + ops.insert_ || ops.delete_ || ops.execute_; +} + +// Resolve an AdsOpenTable name (alias, bare filename, or path) to a DD alias. +std::string name_to_alias(const openads::engine::DataDict* dd, + const std::string& name) { + if (!dd) return {}; + if (dd->has_alias(name)) return name; + namespace fs = std::filesystem; + std::string stem = fs::path(name).stem().string(); + if (dd->has_alias(stem)) return stem; + return {}; +} + +} // namespace + +// RCB 2026-05-22 17:03 -- set_stmt_param is defined later in the file alongside +// SqlStatement and stmt_map. AdsSetString / AdsSetDouble / AdsSetLogical all +// call it before that definition is reached, so a forward declaration is needed +// here to satisfy the compiler. It must be inside a namespace{} block to match +// the anonymous-namespace linkage of the definition; a file-scope static and an +// anonymous-namespace function are distinct symbols as far as MSVC is concerned. +namespace { +bool set_stmt_param(ADSHANDLE h, const char* pname, std::string literal); +} // namespace + +// M9.16: chunked AdsSetBinary keeps a per-(table, field) accumulator; +// table teardown drains it. Forward-declared here so the close / +// 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 set_connection_show_deleted(ADSHANDLE hConnect, bool visible) { + if (openads::session::Connection* c = lookup_connection(hConnect)) { + c->set_show_deleted(visible); + } +} + +// Server --legacy-paths: the network Session's lazy ABI connection must +// resolve client-absolute paths (e.g. remote AdsCreateTable of +// "E:\CLIENT\F.DBF") exactly like the session's engine Connection -- +// otherwise a POSIX server creates a literal "E:\..." file under the +// data root and the follow-up open fails (found by live verification +// against a Linux server, Pritpal Bedi's ERP scenario). +void set_connection_legacy_paths(ADSHANDLE hConnect, bool on) { + if (openads::session::Connection* c = lookup_connection(hConnect)) { + c->set_legacy_paths(on); + } +} + +void set_connection_remote_server(ADSHANDLE hConnect, bool on) { + if (openads::session::Connection* c = lookup_connection(hConnect)) { + c->set_remote_server(on); + } +} + +// Single-attempt record lock for the wire server. AdsLockRecord retries +// via the process-global policy (lock_with_retry), and doing that inside +// a session handler blocks the connection for ~1s under contention — +// starving the peer op that would release the record (client threads on +// a shared connection; Pritpal Bedi: "dbUnlock() in threads fail +// somehow"). The client retries instead, one wire request per attempt. +UNSIGNED32 try_lock_record_once(ADSHANDLE hTable, UNSIGNED32 ulRecord) { + Table* t = get_table(hTable); + if (!t) return openads::AE_INTERNAL_ERROR; + // ulRecord == 0 → the current record (ACE convention; see + // AdsLockRecord for the FILE_BASE byte-range rationale). + std::uint32_t rec = (ulRecord == 0) ? t->recno() : ulRecord; + auto r = t->try_lock_record_excl(rec); + if (!r) return static_cast(r.error().code); + openads::mgmt::LockRegistry::instance().add_record_lock(t, t->path(), rec); + return 0; +} + +// RAII-style toggle for the raw field-read flag (g_field_read_raw, anon +// namespace below). The wire session's row packer uses this so date / +// timestamp cells travel as raw storage text ("YYYYMMDD") independent of +// the process-wide date display format — the client does its own display +// formatting, and server-side AdsGetDateFormat must keep returning the +// connection's format (not the old "YYYYMMDD" polluter, removed 2026-09). +void field_read_raw_begin() { g_field_read_raw = true; } +void field_read_raw_end() { g_field_read_raw = false; } + +// Server: link the per-session ABI twin's RESOLVED-audit dedup set to +// the engine connection's, so a single client open logs one RESOLVED +// line per physical file even though the twin re-opens the table for +// index/SQL work (Pritpal Bedi, Aug 2026: .dbf logged twice per open). +void share_connection_resolve_log(ADSHANDLE hConnect, + openads::session::Connection* src) { + if (openads::session::Connection* c = lookup_connection(hConnect)) { + if (src) c->share_logged_resolves_from(*src); + } +} + +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_MSSQL) + register_backend_table_ops(openads::session::HandleKind::MssqlTable, + mssql_table_ops()); +#endif +#if defined(OPENADS_WITH_FIREBIRD) + register_backend_table_ops(openads::session::HandleKind::FirebirdTable, + firebird_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 + +namespace { + +std::unordered_map& sql_uri_usernames() { + static std::unordered_map m; + return m; +} + +const std::string& sql_uri_username(ADSHANDLE hConnect) { + static const std::string kEmpty; + auto it = sql_uri_usernames().find(hConnect); + return it == sql_uri_usernames().end() ? kEmpty : it->second; +} + +void sql_uri_remember_user(ADSHANDLE hConnect, UNSIGNED8* pucUser) { + if (pucUser == nullptr) return; + const std::string user = openads::abi::to_internal(pucUser, 0); + if (!user.empty()) { + sql_uri_usernames()[hConnect] = user; + } +} + +void sql_uri_forget_user(ADSHANDLE hConnect) { + sql_uri_usernames().erase(hConnect); +} + +#if defined(OPENADS_WITH_ODBC) +std::unordered_map& +odbc_conn_dialects() { + static std::unordered_map m; + return m; +} + +void sql_uri_remember_odbc_dialect( + ADSHANDLE hConnect, + openads::sql_backend::SqlDdlDialect dialect) { + odbc_conn_dialects()[hConnect] = dialect; +} + +void sql_uri_forget_odbc_dialect(ADSHANDLE hConnect) { + odbc_conn_dialects().erase(hConnect); +} + +openads::sql_backend::SqlDdlDialect odbc_dialect_for(ADSHANDLE hConnect) { + auto it = odbc_conn_dialects().find(hConnect); + if (it != odbc_conn_dialects().end()) return it->second; + return openads::sql_backend::SqlDdlDialect::Postgres; +} +#endif + +template +void sql_uri_connect_register_user(ADSHANDLE hConnect, UNSIGNED8* pucUser, + ConnT* conn, + openads::sql_backend::SqlDdlDialect dialect) { + sql_uri_remember_user(hConnect, pucUser); + if (pucUser == nullptr || conn == nullptr) return; + const std::string user = openads::abi::to_internal(pucUser, 0); + if (user.empty()) return; + openads::sql_backend::SqlExecFn exec = + [conn](const std::string& sql) { return conn->exec_sql(sql); }; + openads::sql_backend::sql_acl_register_connect_user(dialect, exec, user); +} + +#if defined(OPENADS_WITH_SQLITE) +openads::util::Result> sqlite_acl_query( + openads::sql_backend::SqliteConnection* sc, const std::string& sql) { + auto r = sc->run_sql(sql); + if (!r) return r.error(); + if (!r.value() || r.value()->result_rows.empty()) { + return std::optional{}; + } + return std::optional{r.value()->result_rows[0][0]}; +} +#endif + +#if defined(OPENADS_WITH_POSTGRESQL) +openads::util::Result> postgres_acl_query( + openads::sql_backend::PostgresConnection* pc, const std::string& sql) { + auto r = pc->run_sql(sql); + if (!r) return r.error(); + if (!r.value() || r.value()->result_rows.empty()) { + return std::optional{}; + } + return std::optional{r.value()->result_rows[0][0]}; +} +#endif + +#if defined(OPENADS_WITH_MARIADB) +openads::util::Result> maria_acl_query( + openads::sql_backend::MariaConnection* mc, const std::string& sql) { + auto r = mc->run_sql(sql); + if (!r) return r.error(); + if (!r.value() || r.value()->result_rows.empty()) { + return std::optional{}; + } + return std::optional{r.value()->result_rows[0][0]}; +} +#endif + +#if defined(OPENADS_WITH_MSSQL) +openads::util::Result> mssql_acl_query( + openads::sql_backend::MssqlConnection* mc, const std::string& sql) { + auto qr = mc->query(sql); + if (!qr) return qr.error(); + const auto& res = qr.value(); + if (!res.ok || res.rows.empty() || res.rows[0].empty() || + res.rows[0][0].is_null) { + return std::optional{}; + } + return std::optional{res.rows[0][0].value}; +} +#endif + +#if defined(OPENADS_WITH_FIREBIRD) +openads::util::Result> firebird_acl_query( + openads::sql_backend::FirebirdConnection* fc, const std::string& sql) { + auto r = fc->run_sql(sql); + if (!r) return r.error(); + if (!r.value() || r.value()->result_rows.empty()) { + return std::optional{}; + } + return std::optional{r.value()->result_rows[0][0]}; +} +#endif + +#if defined(OPENADS_WITH_ODBC) +openads::util::Result> odbc_acl_query( + openads::sql_backend::OdbcConnection* oc, const std::string& sql) { + return oc->query_scalar(sql); +} +#endif + +bool sql_uri_table_denied( + ADSHANDLE hConnect, + UNSIGNED16 usCheckRights, + UNSIGNED16 usMode, + const std::string& object_name, + openads::sql_backend::SqlDdlDialect dialect, + const openads::sql_backend::SqlQueryFn& query) { + if (usCheckRights == 0) return false; + const auto ops = openads::sql_backend::sql_acl_effective_ops( + dialect, query, sql_uri_username(hConnect), object_name); + if (usMode == ADS_READONLY) return !ops.select_; + return !ops.update_ && !ops.insert_; +} + +UNSIGNED32 sql_uri_check_sql_rights( + const std::string& sql, + UNSIGNED16 check_rights, + const std::string& username, + openads::sql_backend::SqlDdlDialect dialect, + const openads::sql_backend::SqlQueryFn& query) { + if (check_rights == 0) return ok(); + std::string obj_name; + enum class SqlOp { None, Select, Insert, Update, Delete } op = SqlOp::None; + if (openads::sql::sql_is_insert(sql)) { + if (auto p = openads::sql::parse_insert(sql)) { + obj_name = p.value().table; + op = SqlOp::Insert; + } + } else if (openads::sql::sql_is_update(sql)) { + if (auto p = openads::sql::parse_update(sql)) { + obj_name = p.value().table; + op = SqlOp::Update; + } + } else if (openads::sql::sql_is_delete(sql)) { + if (auto p = openads::sql::parse_delete(sql)) { + obj_name = p.value().table; + op = SqlOp::Delete; + } + } else if (openads::sql::sql_is_merge(sql)) { + // MERGE mutates like UPDATE (and may INSERT); gate on update + // rights. + if (auto p = openads::sql::parse_merge(sql)) { + obj_name = p.value().table; + op = SqlOp::Update; + } + } else if (openads::sql::sql_is_select(sql)) { + if (auto p = openads::sql::parse_select(sql)) { + obj_name = p.value().table; + op = SqlOp::Select; + } + } + if (obj_name.empty() || op == SqlOp::None) return ok(); + std::string px = obj_name.size() >= 7 ? obj_name.substr(0, 7) : obj_name; + for (auto& ch : px) { + ch = static_cast(std::tolower(static_cast(ch))); + } + if (px == "system.") return ok(); + const auto ops = openads::sql_backend::sql_acl_effective_ops( + dialect, query, username, obj_name); + bool denied = false; + switch (op) { + case SqlOp::Select: denied = !ops.select_; break; + case SqlOp::Insert: denied = !ops.insert_; break; + case SqlOp::Update: denied = !ops.update_; break; + case SqlOp::Delete: denied = !ops.delete_; break; + default: break; + } + if (denied) return fail(openads::AE_ACCESS_DENIED, obj_name.c_str()); + return ok(); +} + +enum class SqlUriDmlOp { Insert, Update, Delete }; + +UNSIGNED32 sql_uri_dml_rights( + std::uint32_t connect_handle, + std::uint16_t check_rights, + const std::string& object_name, + SqlUriDmlOp op, + openads::sql_backend::SqlDdlDialect dialect, + const openads::sql_backend::SqlQueryFn& query) { + if (check_rights == 0) return ok(); + const auto ops = openads::sql_backend::sql_acl_effective_ops( + dialect, query, + sql_uri_username(static_cast(connect_handle)), + object_name); + bool denied = false; + switch (op) { + case SqlUriDmlOp::Insert: denied = !ops.insert_; break; + case SqlUriDmlOp::Update: denied = !ops.update_; break; + case SqlUriDmlOp::Delete: denied = !ops.delete_; break; + } + if (denied) return fail(openads::AE_ACCESS_DENIED, object_name.c_str()); + return ok(); +} + +template +UNSIGNED32 sql_uri_check_dml( + std::uint32_t connect_handle, + std::uint16_t check_rights, + const std::string& table_name, + SqlUriDmlOp op, + openads::sql_backend::SqlDdlDialect dialect, + ConnPtr* conn, + QueryFn query_fn) { + if (check_rights == 0 || conn == nullptr) return ok(); + const openads::sql_backend::SqlQueryFn qfn = + [conn, query_fn](const std::string& sql) { + return query_fn(conn, sql); + }; + return sql_uri_dml_rights( + connect_handle, check_rights, table_name, op, dialect, qfn); +} + +template +void sql_uri_bind_table_acl(TableT* tbl, ADSHANDLE hConnect, + UNSIGNED16 usCheckRights) { + tbl->connect_handle = static_cast(hConnect); + tbl->check_rights = usCheckRights; +} + +template +void sql_uri_mark_index_full(TableT* tbl) { + if (tbl != nullptr) { + tbl->aof_opt_level = static_cast(ADS_OPTIMIZED_FULL); + } +} + +bool sql_uri_system_suffix(const std::string& name, std::string& suffix) { + if (name.size() <= 7) return false; + std::string px = name.substr(0, 7); + for (auto& ch : px) { + ch = static_cast(std::tolower(static_cast(ch))); + } + if (px != "system.") return false; + suffix = name.substr(7); + for (auto& ch : suffix) { + ch = static_cast(std::tolower(static_cast(ch))); + } + return true; +} + +std::string trim_ascii(std::string s) { + auto is_ws = [](unsigned char ch) { + return std::isspace(ch) != 0; + }; + while (!s.empty() && is_ws(static_cast(s.front()))) { + s.erase(s.begin()); + } + while (!s.empty() && is_ws(static_cast(s.back()))) { + s.pop_back(); + } + return s; +} + +std::string connect101_key(std::string s) { + std::string out; + for (char raw_ch : s) { + auto ch = static_cast(raw_ch); + if (!std::isspace(ch)) { + out.push_back(static_cast(std::tolower(ch))); + } + } + return out; +} + +std::unordered_map +parse_connect101_options(const std::string& text) { + std::unordered_map opts; + std::size_t start = 0; + char quote = 0; + auto add_pair = [&](std::size_t end) { + std::string part = text.substr(start, end - start); + std::size_t eq = std::string::npos; + char q = 0; + for (std::size_t i = 0; i < part.size(); ++i) { + char ch = part[i]; + if ((ch == '\'' || ch == '"') && (q == 0 || q == ch)) { + q = q == 0 ? ch : 0; + } else if (ch == '=' && q == 0) { + eq = i; + break; + } + } + if (eq == std::string::npos) return; + std::string key = connect101_key(part.substr(0, eq)); + std::string val = trim_ascii(part.substr(eq + 1)); + if (val.size() >= 2) { + char first = val.front(); + if ((first == '\'' || first == '"') && val.back() == first) { + val = val.substr(1, val.size() - 2); + } + } + if (!key.empty()) opts[key] = val; + }; + + for (std::size_t i = 0; i < text.size(); ++i) { + char ch = text[i]; + if ((ch == '\'' || ch == '"') && (quote == 0 || quote == ch)) { + quote = quote == 0 ? ch : 0; + } else if (ch == ';' && quote == 0) { + add_pair(i); + start = i + 1; + } + } + add_pair(text.size()); + return opts; +} + +bool connect101_truthy(const std::string& value) { + std::string v = connect101_key(value); + return v == "true" || v == "yes" || v == "1" || v == "on"; +} + +std::uint16_t connect101_server_type(const std::string& value) { + std::string v = connect101_key(value); + if (v.empty()) return 0; + char* end = nullptr; + long n = std::strtol(v.c_str(), &end, 10); + if (end != nullptr && *end == '\0') { + if ((n & ADS_REMOTE_SERVER) != 0 || (n & 4) != 0) { + return ADS_REMOTE_SERVER; + } + if ((n & ADS_LOCAL_SERVER) != 0) return ADS_LOCAL_SERVER; + } + if (v.find("remote") != std::string::npos || + v.find("internet") != std::string::npos || + v.find("ais") != std::string::npos) { + return ADS_REMOTE_SERVER; + } + if (v.find("local") != std::string::npos) return ADS_LOCAL_SERVER; + return 0; +} + +struct Connect101OpenOptions { + UNSIGNED16 table_type = ADS_DEFAULT; + UNSIGNED16 char_type = ADS_DEFAULT; + UNSIGNED16 lock_type = ADS_DEFAULT; + UNSIGNED16 check_rights = ADS_DEFAULT; + UNSIGNED16 mode = ADS_DEFAULT; +}; + +std::unordered_map& +connect101_open_options() { + static std::unordered_map opts; + return opts; +} + +std::unordered_map>& +connect101_option_tables() { + static std::unordered_map> tables; + return tables; +} + +// Cursor handle -> on-disk stem (no extension) of the temp table a +// materialised SELECT built for it. AdsCloseTable deletes the stem's files, +// so a data directory doesn't accumulate one temp per query. +std::unordered_map& +materialised_cursor_temps() { + static std::unordered_map temps; + return temps; +} + +// Delete the files of a materialised cursor's temp table: the table itself plus +// any memo / index companion an application created on it (the ERP runs +// INDEX ON over the result, producing .cdx or .adi). +// +// The materialising path builds ADT temps (see the ADS_ADT call in +// exec_sql_direct_impl -- DBF would truncate column names to 10 chars), so .adt +// and its .adm/.adi companions must be listed here. The DBF-era extensions stay +// so temps written by an older build are still cleaned up. If a new +// materialising path introduces another on-disk format, add its extensions here +// too -- anything missing leaks one file per query into the data directory. +void remove_temp_table_files(const std::string& stem) { + std::error_code ec; + for (const char* ext : {".adt", ".adm", ".adi", + ".dbf", ".cdx", ".fpt", ".dbt", ".ntx"}) { + std::filesystem::remove(std::filesystem::path(stem + ext), ec); + } +} + +void drop_materialised_cursor_temp(ADSHANDLE h) { + auto& m = materialised_cursor_temps(); + auto it = m.find(h); + if (it == m.end()) return; + const std::string stem = it->second; + m.erase(it); + remove_temp_table_files(stem); +} + +// ─── Shared materialiser for join / union / aggregate temp cursors ───────── +// +// >>> Before changing ANY of this, read docs/materialised-cursor-temps.md. <<< +// >>> Do NOT switch these temps back to DBF, and do not "simplify" the <<< +// >>> AsciiNumeric mapping to plain Numeric. OpenADS is not a DBF-only <<< +// >>> engine: result-column names longer than 10 characters (SAP's own <<< +// >>> catalogs use them), AS aliases, and merged R_ spellings all <<< +// >>> require the ADT container, and N(x,y) scale survives ONLY through <<< +// >>> ADT type 2. Both halves have been regressed before (#136, #146); <<< +// >>> the doc records the failures each regression caused. <<< +// +// Every SELECT that cannot be answered by a live cursor materialises its +// result into a temp table. Seven paths (2-table join, N-way join, UNION, +// scalar / grouped / join / join+GROUP-BY aggregates) used to hand-assemble +// a raw DBF here, which silently truncated every result-column name +// (constraint 2 in the doc) -- including `AS` aliases and the merged `R_` +// spellings. They now share this one materialiser, which builds an ADT temp: +// +// - The SKELETON (400-byte header + 200-byte descriptors) comes from +// AdsCreateTable's own ADS_ADT path, so there is exactly one place in the +// tree that knows the ADT container layout. This helper only appends +// records and patches the record count, reading hdr_len / rec_len back +// from the file it was just handed. +// - Cells arrive PRE-FORMATTED from the callers (fixed-width text, the same +// bytes the DBF temps stored), and are written verbatim: Character and +// AsciiNumeric cells are raw ASCII in both containers, so read-back is +// byte-identical to the DBF-era temps. Only Date cells change shape -- +// 8-char "YYYYMMDD" text is encoded to the 4-byte JDN through the same +// encode_field_string the write path uses everywhere else (blank-safe: +// 8 blanks store JDN 0), and decodes back to "YYYYMMDD". +// - Numeric columns are declared AsciiNumeric (ADT type 2), NEVER the +// letter 'N': the letter maps to binary INTEGER / DOUBLE via +// adt_spec_for, which re-renders values and drops the declared scale +// (constraint 3 -- the #146 trap). +// - Records are streamed in ONE file append and the count patched once, +// instead of per-record append_record_raw (lock + header rewrite per +// row), because a join over a 600K-row table lands here. +// +// The temp is registered in materialised_cursor_temps(), so closing the +// cursor deletes it -- the DBF-era paths never registered theirs and leaked +// one file per query into the data directory. +struct TempColSpec { + std::string name; // FULL-length result-column name (the point of ADT) + char type; // DBF letter OR raw ADT type code from the source + std::uint16_t len; // width of the caller's fixed-width text cell + std::uint8_t dec; +}; + +enum class TempCellKind : std::uint8_t { Text, Num, Date, Logic }; + +// Classify a source type for the temp's declared column type. Letters and +// raw ADT codes are disjoint (letters are all >= 0x41, ADT codes top out at +// 22), same reasoning as type_name() in the _srt_ path. Anything unknown +// degrades to Character, which stores the caller's text cell verbatim -- +// the same net behaviour the DBF temps had for exotic types. +TempCellKind temp_cell_kind(char t) { + switch (t) { + case 'N': case 'F': case 'I': case 'B': case 'Y': + case 'S': case 'A': case '$': case '#': + return TempCellKind::Num; + case 'D': return TempCellKind::Date; + case 'L': return TempCellKind::Logic; + case 'C': case 'W': case 'V': case 'M': case 'Q': + case 'T': case 'Z': case 'P': + return TempCellKind::Text; + default: break; + } + switch (static_cast(t)) { + case 1: return TempCellKind::Logic; + case 2: case 10: case 11: case 12: case 15: case 18: + return TempCellKind::Num; + case 3: return TempCellKind::Date; + default: break; + } + return TempCellKind::Text; +} + +// An ADT temp materialised and OPENED on `c`, but not yet registered as a +// user-visible cursor. The join paths need this split: the merged temp is +// an intermediate the outer WHERE / ORDER BY / aggregate stages keep +// working on, and only the final survivor becomes the caller's cursor. +struct MaterialisedTemp { + Handle th = 0; // close with c->close_table + openads::engine::Table* tbl = nullptr; + std::string stem; // for remove_temp_table_files +}; + +// `rows` is the concatenation of fixed-width row images (NO deletion-flag +// byte -- pure cells, `row_stride` bytes each, in `cols` order). Creates, +// fills and opens the temp; does NOT register a cursor handle. +UNSIGNED32 materialise_temp_adt_open(Connection* c, + const char* prefix, + const std::vector& cols, + const std::vector& rows, + std::size_t row_stride, + MaterialisedTemp* out) { + namespace fs = std::filesystem; + if (cols.empty() || row_stride == 0 || rows.size() % row_stride != 0) { + return fail(openads::AE_INTERNAL_ERROR, "temp materialise: bad shape"); + } + // The skeleton goes through AdsCreateTable, which takes a connection + // HANDLE; the callers hold the Connection*. Recover the handle so the + // create resolves against the same data directory the reopen uses. + ADSHANDLE conn_h = 0; + state().registry.for_each_handle([&](Handle h, HandleKind k, void* p) { + if (k != HandleKind::Connection) return; + if (static_cast(p) == c) + conn_h = to_ads_handle(h); + }); + if (conn_h == 0) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + // Column plan: defs string for the skeleton + src/dst cell geometry. + std::string defs; + std::size_t src_sum = 0; + std::vector kinds; + std::vector dst_lens; + kinds.reserve(cols.size()); + dst_lens.reserve(cols.size()); + for (const auto& col : cols) { + const TempCellKind k = temp_cell_kind(col.type); + const std::uint16_t sl = col.len ? col.len : 1; + std::uint16_t dl = sl; + if (!defs.empty()) defs.push_back(';'); + defs += col.name; + switch (k) { + case TempCellKind::Num: + defs += ",AsciiNumeric," + std::to_string(sl); + if (col.dec > 0) defs += "," + std::to_string(col.dec); + break; + case TempCellKind::Date: + defs += ",Date"; + dl = 4; + break; + case TempCellKind::Logic: + defs += ",Logical"; + dl = 1; + break; + case TempCellKind::Text: + defs += ",Character," + std::to_string(sl); + break; + } + kinds.push_back(k); + dst_lens.push_back(dl); + src_sum += sl; + } + if (src_sum != row_stride) { + return fail(openads::AE_INTERNAL_ERROR, + "temp materialise: cols do not tile the row stride"); + } + + char nb[64]; + std::snprintf(nb, sizeof(nb), "%s%llx", prefix, + static_cast( + openads::platform::monotonic_nanos())); + const std::string tmp_name = nb; + + std::vector name_buf(tmp_name.size() + 1, 0); + std::memcpy(name_buf.data(), tmp_name.data(), tmp_name.size()); + std::vector def_buf(defs.size() + 1, 0); + std::memcpy(def_buf.data(), defs.data(), defs.size()); + ADSHANDLE hNew = 0; + UNSIGNED32 crc = AdsCreateTable(conn_h, name_buf.data(), nullptr, + ADS_ADT, 0, 0, 0, 0, + def_buf.data(), &hNew); + if (crc != openads::AE_SUCCESS) return crc; + AdsCloseTable(hNew); + + // Locate the file the create path actually wrote. This must mirror + // AdsCreateTable's own resolution EXACTLY: the connection resolver may + // hand back a default extension that is not ".adt" (an extensionless + // temp name resolves to ".dbf" -- the ADT container still goes + // there; only the file NAME is legacy). Guessing ".adt" here left + // the skeleton orphaned and the reopen failing. + fs::path adt; + { + auto rt = openads::engine::TableType::Adt; + adt = fs::path(c->resolve_table_file(tmp_name, rt, + /*for_create=*/true)); + if (!adt.has_extension()) adt.replace_extension(".adt"); + } + fs::path stem = adt; + stem.replace_extension(); + { + std::fstream f(adt, std::ios::in | std::ios::out | std::ios::binary); + if (!f) return fail(openads::AE_INTERNAL_ERROR, + "temp materialise: reopen of created ADT failed"); + auto rd32 = [&](std::streamoff off) -> std::uint32_t { + std::uint8_t b[4] = {0, 0, 0, 0}; + f.seekg(off); + f.read(reinterpret_cast(b), 4); + return static_cast(b[0]) | + (static_cast(b[1]) << 8) | + (static_cast(b[2]) << 16) | + (static_cast(b[3]) << 24); + }; + const std::uint32_t hdr_len = rd32(32); + const std::uint32_t rec_len = rd32(36); + // Guard against the mapping here drifting from adt_spec_for: the + // record length the skeleton declares must equal 5-byte prefix + + // the cells this helper is about to write. + std::uint32_t expect = 5; + for (auto dl : dst_lens) expect += dl; + if (rec_len != expect) { + f.close(); + std::error_code ec; + fs::remove(adt, ec); + return fail(openads::AE_INTERNAL_ERROR, + "temp materialise: rec_len mismatch vs skeleton"); + } + + const std::size_t nrows = rows.size() / row_stride; + std::vector obuf; + obuf.reserve(nrows * rec_len); + std::vector rec(rec_len); + for (std::size_t r = 0; r < nrows; ++r) { + std::fill(rec.begin(), rec.end(), 0); + rec[0] = 0x04; // ADT active-record flag (0x05 = deleted) + const std::uint8_t* src = rows.data() + r * row_stride; + std::size_t so = 0; + std::uint16_t dst_off = 5; + for (std::size_t i = 0; i < cols.size(); ++i) { + const std::uint16_t sl = cols[i].len ? cols[i].len : 1; + if (kinds[i] == TempCellKind::Date) { + // Through the production encoder -- same blank-safety + // and JDN math as every other ADT date write. A cell + // that is not 8 digits (blank group, the "0" sentinel + // an empty aggregate renders) stays the zero JDN the + // record was initialised with -- a blank date. + std::string cell( + reinterpret_cast(src + so), sl); + bool date8 = cell.size() >= 8; + for (std::size_t k = 0; date8 && k < 8; ++k) + date8 = std::isdigit( + static_cast(cell[k])) != 0; + if (date8) { + openads::drivers::DbfField df; + df.type = + openads::drivers::DbfFieldType::AdtDate; + df.record_offset = dst_off; + df.length = 4; + (void)openads::drivers::encode_field_string( + df, rec.data(), rec.size(), cell); + } + } else { + std::memcpy(rec.data() + dst_off, src + so, dst_lens[i]); + } + so += sl; + dst_off = static_cast(dst_off + dst_lens[i]); + } + obuf.insert(obuf.end(), rec.begin(), rec.end()); + } + f.seekp(static_cast(hdr_len)); + f.write(reinterpret_cast(obuf.data()), + static_cast(obuf.size())); + // Patch the record count (header bytes 24-27, LE u32). + std::uint8_t cnt[4] = { + static_cast( nrows & 0xFFu), + static_cast((nrows >> 8) & 0xFFu), + static_cast((nrows >> 16) & 0xFFu), + static_cast((nrows >> 24) & 0xFFu)}; + f.seekp(24); + f.write(reinterpret_cast(cnt), 4); + if (!f) return fail(openads::AE_INTERNAL_ERROR, + "temp materialise: short write"); + } + + auto cth = c->open_table(tmp_name, openads::engine::TableType::Adt, + openads::engine::OpenMode::Read); + if (!cth) return fail(cth.error()); + openads::engine::Table* ctbl = c->lookup_table(cth.value()); + if (!ctbl) return fail(openads::AE_INTERNAL_ERROR, + "temp materialise: post-open"); + out->th = cth.value(); + out->tbl = ctbl; + out->stem = stem.string(); + return ok(); +} + +// Convenience wrapper for the paths whose temp IS the final result: +// materialise, open, register the cursor handle, and tie the temp's on-disk +// lifetime to it (cleanup happens in drop_materialised_cursor_temp). +UNSIGNED32 materialise_temp_adt(Connection* c, + const char* prefix, + const std::vector& cols, + const std::vector& rows, + std::size_t row_stride, + ADSHANDLE* phCursor) { + MaterialisedTemp mt; + UNSIGNED32 rc = materialise_temp_adt_open(c, prefix, cols, rows, + row_stride, &mt); + if (rc != openads::AE_SUCCESS) return rc; + ADSHANDLE gh = to_ads_handle( + state().registry.register_object(HandleKind::Table, mt.tbl)); + materialised_cursor_temps()[gh] = mt.stem; + *phCursor = gh; + return ok(); +} + +openads::util::Result
build_connect101_options_table( + const std::unordered_map& options) { + struct Col { + const char* colname; + std::uint16_t length; + }; + const std::vectorcols = { + {"Name", 64}, + {"Value", 1024}, + }; + + std::vector> sorted; + sorted.reserve(options.size()); + for (const auto& kv : options) sorted.emplace_back(kv.first, kv.second); + std::sort(sorted.begin(), sorted.end(), + [](const auto& a, const auto& b) { return a.first < b.first; }); + + std::vector fields; + fields.reserve(cols.size()); + std::uint32_t rlen = 1; + for (const auto& col : cols) { + openads::drivers::DbfField f; + f.name = col.colname; + f.raw_type = 20; + f.type = openads::drivers::DbfFieldType::CiCharacter; + f.length = col.length; + f.decimals = 0; + f.record_offset = static_cast(rlen); + rlen += f.length; + fields.push_back(std::move(f)); + } + + std::vector> records; + records.reserve(sorted.size()); + for (const auto& kv : sorted) { + std::vector rec(rlen, 0x20u); + rec[0] = ' '; + const std::string values[] = {kv.first, kv.second}; + for (std::size_t i = 0; i < cols.size(); ++i) { + const auto& f = fields[i]; + const std::string& val = values[i]; + std::size_t n = std::min(val.size(), f.length); + std::memcpy(rec.data() + f.record_offset, val.data(), n); + } + records.push_back(std::move(rec)); + } + + char name[80]; + std::snprintf(name, sizeof(name), "memory://adsconnect101/%llx", + static_cast( + openads::platform::monotonic_nanos())); + auto drv = std::make_unique( + name, std::move(fields), std::move(records), + static_cast(rlen)); + return Table::from_driver(std::move(drv), name, + openads::engine::TableType::Adt, + openads::engine::OpenMode::Read, + openads::engine::LockingMode::Compatible); +} + +UNSIGNED16 connect101_table_type(const std::string& value) { + std::string v = connect101_key(value); + if (v.rfind("ads_", 0) == 0) v.erase(0, 4); + if (v == "ntx") return ADS_NTX; + if (v == "cdx") return ADS_CDX; + if (v == "adt") return ADS_ADT; + if (v == "vfp") return ADS_VFP; + if (v == "default") return ADS_DEFAULT; + return static_cast(std::strtoul(v.c_str(), nullptr, 10)); +} + +UNSIGNED16 connect101_char_type(const std::string& value) { + std::string v = connect101_key(value); + if (v.rfind("ads_", 0) == 0) v.erase(0, 4); + if (v == "ansi") return ADS_ANSI; + if (v == "oem") return ADS_OEM; + return static_cast(std::strtoul(v.c_str(), nullptr, 10)); +} + +UNSIGNED16 connect101_lock_type(const std::string& value) { + std::string v = connect101_key(value); + if (v.find("proprietary") != std::string::npos) { + return ADS_PROPRIETARY_LOCKING; + } + if (v.find("compatible") != std::string::npos) { + return ADS_COMPATIBLE_LOCKING; + } + return static_cast(std::strtoul(v.c_str(), nullptr, 10)); +} + +UNSIGNED16 connect101_security_mode(const std::string& value) { + std::string v = connect101_key(value); + if (v.find("check") != std::string::npos) return ADS_CHECKRIGHTS; + if (v.find("ignore") != std::string::npos) return ADS_IGNORERIGHTS; + return static_cast(std::strtoul(v.c_str(), nullptr, 10)); +} + +UNSIGNED16 connect101_open_mode( + const std::unordered_map& options) { + auto readonly = options.find("readonly"); + if (readonly != options.end() && connect101_truthy(readonly->second)) { + return ADS_READONLY; + } + auto shared = options.find("shared"); + if (shared != options.end()) { + return connect101_truthy(shared->second) ? ADS_SHARED : ADS_EXCLUSIVE; + } + return ADS_DEFAULT; +} + +UNSIGNED16 infer_table_type_from_name(UNSIGNED8* pucName) { + if (pucName == nullptr) return ADS_CDX; + std::filesystem::path p(openads::abi::to_internal(pucName, 0)); + std::string ext = p.extension().string(); + for (auto& ch : ext) { + ch = static_cast(std::tolower(static_cast(ch))); + } + if (ext == ".adt") return ADS_ADT; + if (ext == ".dbf") return ADS_CDX; + return ADS_CDX; +} + +} // namespace + +extern "C++" { +std::uint16_t& server_type_mask(); +} // extern "C++" + +// --- Remote session pool (WAN thread-lanes) ------------------------------- +// One RemoteConnection serialises all its frames on mu_ (see +// RemoteConnection::request): every thread of an MT app queues behind a +// single TCP session. The pool opens N sessions per logical connect +// (same host/port/dir/creds) and pins each calling thread to a lane, so +// threads proceed in parallel while each table stays on the session that +// opened it (rt->conn never migrates — cursor/order bindings are +// per-session server-side). Single-threaded callers always land on lane +// 0 (the primary): behaviour identical to before. +// Size: OPENADS_POOL_SIZE env / pool_size ini key, default 4, clamp 1..16. +// Lifetime matches the existing remote_conns policy: lane objects are +// never unregistered (disconnect() only closes the socket). +// Lives here (C++ linkage, ahead of first use) because the file's later +// anonymous namespaces nest — a declaration outside and a definition +// inside would be distinct entities. +struct RemoteLanePool { + std::mutex mu; // guards affinity/next only; lanes fixed after grow + std::vector lanes; // [0] = primary + std::map affinity; + std::size_t next = 0; +}; +static std::vector>& remote_lane_pool_store() { + static std::vector> v; + return v; +} +// Raw connection -> pool (entries never removed; written once at connect). +static std::unordered_map& remote_lane_pool_of() { + static std::unordered_map m; + return m; +} +// Internal connection handle -> pool (written once at connect, under s.mu). +static std::unordered_map& +remote_lane_pool_by_handle() { + static std::unordered_map m; + return m; +} +static unsigned remote_pool_size_cfg() { + unsigned n = 4; + try { + std::string v = openads::util::client_setting( + "OPENADS_POOL_SIZE", "pool_size"); + if (!v.empty()) n = static_cast(std::stoul(v)); + } catch (...) {} + if (n < 1) n = 1; + if (n > 16) n = 16; + return n; +} +// Must be called with s.mu held, right after the primary is registered. +static RemoteLanePool* remote_lane_pool_create( + openads::network::RemoteConnection* primary, Handle primary_h) { + auto p = std::make_unique(); + p->lanes.push_back(primary); + RemoteLanePool* raw = p.get(); + remote_lane_pool_store().push_back(std::move(p)); + remote_lane_pool_of()[primary] = raw; + remote_lane_pool_by_handle()[primary_h] = raw; + return raw; +} +static void remote_lane_pool_add_lane(RemoteLanePool* pool, + openads::network::RemoteConnection* lane, Handle lane_h) { + if (pool == nullptr || lane == nullptr) return; + pool->lanes.push_back(lane); + remote_lane_pool_of()[lane] = pool; + remote_lane_pool_by_handle()[lane_h] = pool; +} +// Thread-affine lane for the pool owning h (primary or lane handle). +// First-touch threads deal round-robin; a thread sticks to its lane +// afterwards (park hits, order bindings, no cross-thread cursor sharing). +// Dead lanes are skipped (failover); unpooled handles return directly. +// s.mu must be held. +static openads::network::RemoteConnection* remote_pool_lane_conn(Handle h) { + auto& s = state(); + RemoteLanePool* pool = nullptr; + auto ith = remote_lane_pool_by_handle().find(h); + if (ith != remote_lane_pool_by_handle().end()) pool = ith->second; + auto* direct = s.registry.lookup( + h, HandleKind::RemoteConnection); + if (pool == nullptr) return direct; + std::size_t idx = 0; + { + std::lock_guard lk(pool->mu); + auto tid = std::this_thread::get_id(); + auto ita = pool->affinity.find(tid); + if (ita != pool->affinity.end()) + idx = ita->second % pool->lanes.size(); + else { + idx = pool->next++ % pool->lanes.size(); + pool->affinity[tid] = idx; + } + } + if (idx < pool->lanes.size()) { + if (auto* rc = pool->lanes[idx]; rc != nullptr && rc->valid()) + return rc; + } + for (auto* rc : pool->lanes) { + if (rc != nullptr && rc->valid()) return rc; + } + return direct; +} +// All lanes of rc's pool (or just rc when unpooled). The lanes vector is +// fixed after grow; entries are never removed, so lock-free reads here +// match the existing registry posture (cf. resolve_remote_conn_handle). +static std::vector +remote_pool_lanes_of(openads::network::RemoteConnection* rc) { + if (rc == nullptr) return {}; + auto it = remote_lane_pool_of().find(rc); + if (it == remote_lane_pool_of().end()) return {rc}; + RemoteLanePool* pool = it->second; + if (pool == nullptr) return {rc}; + std::lock_guard lk(pool->mu); + return pool->lanes; +} + +// Lane pinning by (logical connection, path, alias). +// +// The thread-affine lane pool spreads a process's tables over several +// server sessions for parallel wire throughput, but record locks are +// owned per server SESSION. Harbour's zero-space pattern +// (hb_dbRequest/hb_dbDetach - one workarea shared process-wide across +// threads for login semaphores) needs the opposite: a workarea's lock +// identity must survive being driven from any thread, and must survive a +// close + fresh USE of the same alias (Vouch's LogUse). DBFCDX gets this +// for free because the lock list lives in the workarea struct itself. +// +// Pinning gives each (connection, normalized path, alias) one stable +// lane for the connection's lifetime: every USE of that alias+path from +// any thread - including the first USE after a close - binds to the same +// server session, so locks taken through it stay visible and unlockable +// no matter which thread is holding the workarea. Different aliases of +// the same file keep different owners (UsrConsole-style cross-owner lock +// probes still conflict, either on another lane or as another server-side +// Table object on the same lane). Single-threaded callers are unaffected +// (thread affinity already kept them on lane 0). +// +// Pins persist across AdsCloseTable on purpose - a close releases held +// locks (same as DBFCDX closing a workarea); what pinning preserves is +// IDENTITY for the next open, not the locks themselves. Pins are dropped +// at AdsDisconnect. +// +// Default ON; kill switch: openads.ini lane_pin_alias = 0 (or +// OPENADS_LANE_PIN_ALIAS=0) restores pure thread-affinity. +static std::unordered_map& +remote_lane_pins() { + static std::unordered_map m; + return m; +} +static bool remote_lane_pin_enabled() { + static const bool on = [] { + const std::string v = openads::util::client_setting( + "OPENADS_LANE_PIN_ALIAS", "lane_pin_alias"); + if (v.empty()) return true; + std::string l = v; + for (auto& c : l) c = static_cast(::tolower((unsigned char)c)); + return !(l == "0" || l == "false" || l == "off" || l == "no"); + }(); + return on; +} +static std::string remote_lane_pin_key(ADSHANDLE rem_h, + const std::string& name, + const std::string& alias) { + std::string n = name; + for (auto& c : n) { if (c == '\\') c = '/'; } + std::string a = alias; + for (auto& c : a) c = static_cast(::toupper((unsigned char)c)); + return std::to_string(static_cast(rem_h)) + + '\x01' + n + '\x01' + a; +} +// Resolve the lane for a (connection, path, alias) open: pinned lane when +// one is recorded and alive, otherwise the thread-affine pick, which then +// becomes the pin. s.mu must be held. +static openads::network::RemoteConnection* +remote_pool_lane_for_open(ADSHANDLE rem_h, const std::string& name, + const std::string& alias) { + namespace net = openads::network; + if (!remote_lane_pin_enabled()) + return remote_pool_lane_conn(static_cast(rem_h)); + const std::string key = remote_lane_pin_key(rem_h, name, alias); + auto& pins = remote_lane_pins(); + auto it = pins.find(key); + if (it != pins.end()) { + if (it->second != nullptr && it->second->valid()) return it->second; + pins.erase(it); // dead lane: fall through and re-pin + } + net::RemoteConnection* rc = remote_pool_lane_conn(static_cast(rem_h)); + if (rc != nullptr) pins[key] = rc; + return rc; +} +// Drop every pin of a logical connection (AdsDisconnect). s.mu held. +static void remote_lane_pins_clear(ADSHANDLE rem_h) { + const std::string prefix = + std::to_string(static_cast(rem_h)) + '\x01'; + for (auto it = remote_lane_pins().begin(); + it != remote_lane_pins().end();) { + if (it->first.compare(0, prefix.size(), prefix) == 0) + it = remote_lane_pins().erase(it); + else + ++it; + } +} + +extern "C" { + + +/* ARC32 bring-up trace (temporary): one line per key ABI call. */ +#include +static bool arc2_on() { + static const bool on = openads::util::client_setting_truthy( + "OPENADS_ARC_TRACE", "arc_trace"); + return on && openads::util::logging_enabled(); +} +static void arc2_stamp(FILE* f) { + std::time_t t = std::time(nullptr); + struct tm tmv; +#if defined(_WIN32) + localtime_s(&tmv, &t); +#else + localtime_r(&t, &tmv); +#endif + fprintf(f, "%02d:%02d:%02d ", tmv.tm_hour, tmv.tm_min, tmv.tm_sec); +} +static void arc2_trace(const char* name) { + if (!arc2_on()) return; + const char* path = +#if defined(_MSC_VER) + "C:/OpenADS/_arc32/ace_calls.log"; +#else + "/tmp/ace_calls.log"; +#endif + if (auto* f = fopen(path, "a")) { + arc2_stamp(f); + fprintf(f, "%s\n", name); + fclose(f); + } +} +static void arc2_log(const char* fmt, ...) { + if (!arc2_on()) return; + const char* path = +#if defined(_MSC_VER) + "C:/OpenADS/_arc32/ace_calls.log"; +#else + "/tmp/ace_calls.log"; +#endif + if (auto* f = fopen(path, "a")) { + arc2_stamp(f); + va_list ap; va_start(ap, fmt); + vfprintf(f, fmt, ap); + va_end(ap); + fputc('\n', f); + fclose(f); + } +} + +UNSIGNED32 ENTRYPOINT AdsConnect60(UNSIGNED8* pucServer, UNSIGNED16 usServerType, + UNSIGNED8* pucUser, UNSIGNED8* pucPwd, + UNSIGNED32 /*ulOptions*/, ADSHANDLE* phConnect) { + arc2_trace("AdsConnect60"); + if (cli_trace_on()) { + openads::network::set_frame_trace_hook(&cli_trace_frame_hook); + } + if (phConnect == nullptr) return fail(openads::AE_INTERNAL_ERROR, + "phConnect is null"); + auto path = openads::abi::to_internal(pucServer, 0); + auto has_remote_scheme = [](const std::string& s) { + return s.rfind("tcp://", 0) == 0 || s.rfind("tls://", 0) == 0; + }; + if (usServerType == ADS_REMOTE_SERVER && !has_remote_scheme(path)) { + path = "tcp://127.0.0.1:6262/" + path; + } + // M12.5 -- `tcp://host:port/` routes the connection + // through the wire client; every Ads* function that recognises + // the connection handle's RemoteConnection kind dispatches to + // the server instead of touching a local Connection. + // M12.9 -- pucUser / pucPwd are forwarded into the Connect frame; + // the server validates them when it has credentials registered. + { + // M12.12 -- `tls://host:port/` URI. When the engine was + // built with -DOPENADS_WITH_TLS=ON we open a real TLS client + // through vendored mbedtls; otherwise we surface a clear + // AE_FUNCTION_NOT_AVAILABLE so apps don't silently downgrade + // to plaintext. + std::string thost, tdir; + std::uint16_t tport = 0; + if (openads::network::parse_tls_uri(path, thost, tport, tdir)) { +#if defined(OPENADS_WITH_TLS) + std::string user = pucUser ? openads::abi::to_internal(pucUser, 0) + : std::string(); + std::string pw = pucPwd ? openads::abi::to_internal(pucPwd, 0) + : std::string(); + openads::network::TlsConfig cfg; + // Verify peer certificates by default. Set OPENADS_TLS_INSECURE=1 + // (or tls_insecure = 1 in openads.ini) for dev/self-signed + // endpoints until AdsSetTlsCa ships. + cfg.insecure_skip_verify = openads::util::client_setting_truthy( + "OPENADS_TLS_INSECURE", "tls_insecure"); + cfg.sni_hostname = thost; + auto tt = openads::network::connect_tls(thost, tport, cfg); + if (!tt) return fail(tt.error()); + auto rc = std::make_unique(); + if (auto r = rc->connect_with_transport( + std::move(tt).value(), tdir, user, pw); !r) { + return fail(r.error()); + } + auto& s = state(); + std::lock_guard lk(s.mu); + Handle h = s.registry.register_object( + HandleKind::RemoteConnection, rc.get()); + static std::unordered_map> + remote_tls_conns; + remote_tls_conns.emplace(h, std::move(rc)); + // Session pool (WAN thread-lanes): extra sessions for the same + // logical connection. Best-effort: failures degrade lane count. + { + RemoteLanePool* pool = remote_lane_pool_create( + remote_tls_conns[h].get(), h); + static std::unordered_map> + remote_tls_pool_conns; + const unsigned want = remote_pool_size_cfg(); + for (unsigned i = 1; i < want; ++i) { + auto tt2 = openads::network::connect_tls(thost, tport, cfg); + if (!tt2) break; + auto lane = std::make_unique< + openads::network::RemoteConnection>(); + if (auto r = lane->connect_with_transport( + std::move(tt2).value(), tdir, user, pw); !r) + break; + Handle lh = s.registry.register_object( + HandleKind::RemoteConnection, lane.get()); + remote_lane_pool_add_lane(pool, lane.get(), lh); + remote_tls_pool_conns.emplace(lh, std::move(lane)); + } + } + *phConnect = to_ads_handle(h); + // Harbour rddads stores the last AdsConnect handle for + // AdsCreateTable / AdsBeginTransaction(0) etc. + rddads_default_connection() = to_ads_handle(h); + openads::util::write_connected_audit(tdir, true); + return ok(); +#else + return fail(openads::AE_FUNCTION_NOT_AVAILABLE, + "tls:// URI requires building OpenADS with " + "-DOPENADS_WITH_TLS=ON (vendors mbedtls 3.6 LTS)"); +#endif + } + } + { + std::string host, dir; + std::uint16_t port = 0; + if (openads::network::parse_tcp_uri(path, host, port, dir)) { + std::string user = pucUser ? openads::abi::to_internal(pucUser, 0) + : std::string(); + std::string pw = pucPwd ? openads::abi::to_internal(pucPwd, 0) + : std::string(); + auto rc = std::make_unique(); + if (auto r = rc->connect(host, port, dir, user, pw); !r) { + return fail(r.error()); + } + auto& s = state(); + std::lock_guard lk(s.mu); + Handle h = s.registry.register_object( + HandleKind::RemoteConnection, rc.get()); + // Keep RemoteConnection alive in a side container. + static std::unordered_map> + remote_conns; + remote_conns.emplace(h, std::move(rc)); + // Session pool (WAN thread-lanes): extra sessions for the same + // logical connection so MT callers proceed in parallel. + // Best-effort: a failed lane connect degrades to fewer lanes. + { + RemoteLanePool* pool = remote_lane_pool_create( + remote_conns[h].get(), h); + static std::unordered_map> + remote_pool_conns; + const unsigned want = remote_pool_size_cfg(); + for (unsigned i = 1; i < want; ++i) { + auto lane = std::make_unique< + openads::network::RemoteConnection>(); + if (auto r = lane->connect(host, port, dir, user, pw); !r) + break; + Handle lh = s.registry.register_object( + HandleKind::RemoteConnection, lane.get()); + remote_lane_pool_add_lane(pool, lane.get(), lh); + remote_pool_conns.emplace(lh, std::move(lane)); + } + } + *phConnect = to_ads_handle(h); + // Harbour rddads stores the last AdsConnect handle for + // AdsCreateTable / AdsBeginTransaction(0) etc. + rddads_default_connection() = to_ads_handle(h); + openads::util::write_connected_audit(dir, true); + return ok(); + } + } +#if defined(OPENADS_WITH_SQLITE) + { + openads::sql_backend::SqliteUri suri; + if (openads::sql_backend::parse_sqlite_uri(path, suri)) { + auto opened = openads::sql_backend::SqliteConnection::open(suri); + if (!opened) return fail(opened.error()); + auto holder = std::make_unique( + std::move(opened).value()); + openads::sql_backend::SqliteConnection* raw = holder.get(); + auto& s = state(); + std::lock_guard lk(s.mu); + Handle h = s.registry.register_object( + HandleKind::SqliteConnection, raw); + sqlite_conns_map().emplace(h, std::move(holder)); + sql_uri_connect_register_user(to_ads_handle(h), pucUser, raw, + openads::sql_backend::SqlDdlDialect::Sqlite); + *phConnect = to_ads_handle(h); + return ok(); + } + } +#endif +#if defined(OPENADS_WITH_ODBC) + { + openads::sql_backend::OracleUri oruri; + if (openads::sql_backend::parse_oracle_uri(path, oruri)) { + openads::sql_backend::OdbcUri ouri; + ouri.connstr = openads::sql_backend::oracle_to_odbc_connstr(oruri); + ouri.password = oruri.password; + 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)); + sql_uri_remember_odbc_dialect( + h, openads::sql_backend::SqlDdlDialect::Oracle); + sql_uri_connect_register_user(to_ads_handle(h), pucUser, raw, + openads::sql_backend::SqlDdlDialect::Oracle); + *phConnect = to_ads_handle(h); + return ok(); + } + } + { + 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)); + sql_uri_remember_odbc_dialect( + h, openads::sql_backend::SqlDdlDialect::Postgres); + sql_uri_connect_register_user(to_ads_handle(h), pucUser, raw, + openads::sql_backend::SqlDdlDialect::Postgres); + *phConnect = to_ads_handle(h); + return ok(); + } + } +#else + { + static constexpr const char* kOdbcPrefixes[] = { + "odbc://", "odbc:", "oracle://", + }; + 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/oracle URI requires OPENADS_WITH_ODBC=ON"); + } + } + } +#endif +#if defined(OPENADS_WITH_MSSQL) + { + openads::sql_backend::MssqlUri muri; + if (openads::sql_backend::parse_mssql_uri(path, muri)) { + auto opened = openads::sql_backend::MssqlConnection::open(muri); + if (!opened) return fail(opened.error()); + auto holder = + std::make_unique( + std::move(opened).value()); + openads::sql_backend::MssqlConnection* raw = holder.get(); + auto& s = state(); + std::lock_guard lk(s.mu); + Handle h = s.registry.register_object( + HandleKind::MssqlConnection, raw); + mssql_conns_map().emplace(h, std::move(holder)); + sql_uri_connect_register_user(to_ads_handle(h), pucUser, raw, + openads::sql_backend::SqlDdlDialect::Mssql); + *phConnect = to_ads_handle(h); + return ok(); + } + } +#else + { + static constexpr const char* kMssqlPrefixes[] = { + "mssql://", "tds://", + }; + for (const char* prefix : kMssqlPrefixes) { + 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, + "mssql/tds URI requires OPENADS_WITH_MSSQL=ON"); + } + } + } +#endif +#if defined(OPENADS_WITH_FIREBIRD) + // Native Firebird driver (firebird:// / fb://) -- embedded `.fdb` + // in-process via libfbclient, or a TCP server. Parsed into a + // FirebirdUri (structured user/password/charset/role) and torn down + // in AdsDisconnect. + { + openads::sql_backend::FirebirdUri furi; + if (openads::sql_backend::parse_firebird_uri(path, furi)) { + auto opened = openads::sql_backend::FirebirdConnection::open(furi); + if (!opened) return fail(opened.error()); + auto holder = + std::make_unique( + std::move(opened).value()); + openads::sql_backend::FirebirdConnection* raw = holder.get(); + auto& s = state(); + std::lock_guard lk(s.mu); + Handle h = s.registry.register_object( + HandleKind::FirebirdConnection, raw); + firebird_conns_map().emplace(h, std::move(holder)); + sql_uri_connect_register_user(to_ads_handle(h), pucUser, raw, + openads::sql_backend::SqlDdlDialect::Firebird); + *phConnect = to_ads_handle(h); + return ok(); + } + } +#else + { + static constexpr const char* kFirebirdPrefixes[] = { + "firebird://", "firebird:", "fb://", + }; + for (const char* prefix : kFirebirdPrefixes) { + 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, + "firebird URI requires OPENADS_WITH_FIREBIRD=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)); + sql_uri_connect_register_user(to_ads_handle(h), pucUser, raw, + openads::sql_backend::SqlDdlDialect::Maria); + *phConnect = to_ads_handle(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)); + sql_uri_connect_register_user(to_ads_handle(h), pucUser, raw, + openads::sql_backend::SqlDdlDialect::Postgres); + *phConnect = to_ads_handle(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()); + auto holder = std::make_unique(std::move(opened).value()); + Connection* raw = holder.get(); + // DD authentication: if the DD has LOG_IN_REQUIRED set, validate + // the supplied credentials before registering the connection. + if (raw->has_dd()) { + auto* dd = raw->dd(); + std::string user = pucUser ? openads::abi::to_internal(pucUser, 0) + : std::string(); + std::string pwd = pucPwd ? openads::abi::to_internal(pucPwd, 0) + : std::string(); + + // Logins-disabled: a stricter, all-connections-rejected gate than + // LOG_IN_REQUIRED below -- even a valid user/password normally can't + // connect while this is set. Reads the STABLE storage key "prop_16" + // (SP_MODIFYDATABASE numbering) -- the SAP ABI id for it is + // ADS_DD_LOGINS_DISABLED (113), translated by db_prop_storage_key. + // Mirrored in network/session.cpp's Connect handler for remote + // connections. + // + // Admin bypass: without this, setting the flag would be a one-way + // door -- nobody, not even the admin trying to undo it, could ever + // reconnect to flip it back off. openads::mgmt::kAdminBypassUser + // ("adssys", ADS's own conventional admin username) with a correct + // per-user password (prop_1101) is exempted. + std::string logins_disabled = dd->get_db_property("prop_16"); + bool disabled_raw_zero = (logins_disabled.size() >= 2 && + static_cast(logins_disabled[0]) == 0 && + static_cast(logins_disabled[1]) == 0); + bool logins_are_disabled = (!logins_disabled.empty() && + logins_disabled != "0" && logins_disabled != "False" && + !disabled_raw_zero); + if (logins_are_disabled && + !openads::mgmt::is_admin_bypass(dd, user, pwd)) { + return fail(openads::AE_LOGIN_FAILED, + "logins are disabled for this dictionary"); + } + + std::string login_req = dd->get_db_property("prop_5"); + // Stored as decimal string by import tool and UI: "0" = not required, + // "1" = required. Keep raw-byte fallback for any old imports. + bool is_raw_zero = (login_req.size() >= 2 && + static_cast(login_req[0]) == 0 && + static_cast(login_req[1]) == 0); + bool require_login = (!login_req.empty() && login_req != "0" && + login_req != "False" && !is_raw_zero); + if (require_login) { + if (user.empty()) + return fail(openads::AE_LOGIN_FAILED, + "login required but no username supplied"); + if (!dd->has_user(user)) + return fail(openads::AE_LOGIN_FAILED, "unknown user"); + std::string stored = dd->get_user_property(user, "prop_1101"); + if (stored != pwd) + return fail(openads::AE_LOGIN_FAILED, "invalid password"); + } + if (!user.empty()) { + raw->set_username(user); + // Pre-build effective-permission cache for this user so that + // subsequent AdsOpenTable / AdsExecuteSQLDirect checks are O(1). + if (dd->has_any_acl()) + dd->build_perm_cache(user); + } + } + auto& s = state(); + std::lock_guard lk(s.mu); + Handle h = s.registry.register_object(HandleKind::Connection, raw); + s.conns.emplace(h, std::move(holder)); + *phConnect = to_ads_handle(h); + rddads_default_connection() = to_ads_handle(h); + // Reject connections to SAP proprietary binary .add files. OpenADS + // can read them (load_add_binary_) but cannot safely write them back + // (format is closed and permission fields are encrypted). Direct the + // caller to run the import_dd tool to produce an OpenADS-format DD. + if (raw->has_dd() && raw->dd()->has_sap_permissions()) { + s.conns.erase(h); // destroys the Connection object + s.registry.release(h); + *phConnect = 0; + return fail(openads::AE_SAP_PERMS_NEED_IMPORT, + "This is a SAP Advantage Data Dictionary in proprietary binary format. " + "OpenADS cannot open it directly. " + "Run: import_dd " + "to convert it to OpenADS format, then connect to the converted file."); + } + openads::util::write_connected_audit(path, false); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsConnect101(UNSIGNED8* pucConnectString, + ADSHANDLE* phConnectOptions, + ADSHANDLE* phConnect) { + arc2_trace("AdsConnect101"); + arc2_trace("AdsConnect101"); + arc2_log("CONNECT101 connstr=[%s]", + pucConnectString ? (const char*)pucConnectString : "(null)"); + if (phConnect == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "phConnect is null"); + } + *phConnect = 0; + if (phConnectOptions != nullptr) *phConnectOptions = 0; + if (pucConnectString == nullptr || pucConnectString[0] == 0) { + return fail(openads::AE_INTERNAL_ERROR, + "AdsConnect101: empty connection string"); + } + + auto options = parse_connect101_options( + openads::abi::to_internal(pucConnectString, 0)); + auto get_opt = [&](const char* key) -> std::string { + auto it = options.find(key); + return it == options.end() ? std::string() : it->second; + }; + + std::string data_source = get_opt("datasource"); + if (data_source.empty()) { + return fail(openads::AE_INTERNAL_ERROR, + "AdsConnect101: Data Source is required"); + } + + if (auto v = get_opt("dateformat"); !v.empty()) { + AdsSetDateFormat(reinterpret_cast(v.data())); + } + if (auto v = get_opt("decimals"); !v.empty()) { + AdsSetDecimals(static_cast( + std::strtoul(v.c_str(), nullptr, 10))); + } + if (auto v = get_opt("epoch"); !v.empty()) { + AdsSetEpoch(static_cast( + std::strtoul(v.c_str(), nullptr, 10))); + } + if (auto v = get_opt("exact"); !v.empty()) { + AdsSetExact(connect101_truthy(v) ? 1 : 0); + } + if (auto v = get_opt("showdeleted"); !v.empty()) { + AdsShowDeleted(connect101_truthy(v) ? 1 : 0); + } + + std::uint16_t server_type = 0; + if (auto v = get_opt("servertype"); !v.empty()) { + server_type = connect101_server_type(v); + } + if (server_type == 0) { + std::uint16_t mask = server_type_mask(); + server_type = (mask & ADS_LOCAL_SERVER) != 0 + ? ADS_LOCAL_SERVER : ADS_REMOTE_SERVER; + } + + std::string user = get_opt("userid"); + std::string password = get_opt("password"); + UNSIGNED32 rc = AdsConnect60( + reinterpret_cast(data_source.data()), + server_type, + user.empty() ? nullptr : reinterpret_cast(user.data()), + password.empty() ? nullptr : reinterpret_cast(password.data()), + 0, + phConnect); + if (rc != openads::AE_SUCCESS) return rc; + + if (auto v = get_opt("sqltimeout"); !v.empty()) { + rc = AdsSetSQLTimeout(*phConnect, + static_cast( + std::strtoul(v.c_str(), nullptr, 10))); + if (rc != openads::AE_SUCCESS) { + (void)AdsDisconnect(*phConnect); + *phConnect = 0; + return rc; + } + } + Connect101OpenOptions open_opts; + if (auto v = get_opt("tabletype"); !v.empty()) { + open_opts.table_type = connect101_table_type(v); + } + if (auto v = get_opt("chartype"); !v.empty()) { + open_opts.char_type = connect101_char_type(v); + } + if (auto v = get_opt("lockmode"); !v.empty()) { + open_opts.lock_type = connect101_lock_type(v); + } + if (auto v = get_opt("securitymode"); !v.empty()) { + open_opts.check_rights = connect101_security_mode(v); + } + open_opts.mode = connect101_open_mode(options); + { + auto& s = state(); + std::lock_guard lk(s.mu); + connect101_open_options()[*phConnect] = open_opts; + } + if (phConnectOptions != nullptr) { + auto opt_table = build_connect101_options_table(options); + if (!opt_table) { + (void)AdsDisconnect(*phConnect); + *phConnect = 0; + *phConnectOptions = 0; + return fail(opt_table.error()); + } + auto holder = std::make_unique
(std::move(opt_table).value()); + Table* raw = holder.get(); + auto& s = state(); + std::lock_guard lk(s.mu); + Handle h = s.registry.register_object(HandleKind::Table, raw); + connect101_option_tables().emplace(to_ads_handle(h), std::move(holder)); + *phConnectOptions = to_ads_handle(h); + } + return ok(); +} + +// Forward decl: sp_CreateEvent registry cleanup, defined with the sp_* +// system-procedure machinery further down (namespace spproc). +extern "C++" { +namespace spproc { void drop_all_events_for(const void* conn); } +// S4 -- session #temp / trigger-image cleanup (defined by the sess +// namespace further down, near the trigger machinery). +extern "C++" void sess_forget_connection(Connection* c); +// park_active_order lives with the index-binding helpers further down +// (anonymous namespace). The production-index auto-open in AdsOpenTable +// calls it to undo AdsOpenIndex's optimistic first-tag activation -- +// ADS semantics leave the controlling order natural (0) after an +// auto-open until DbSetOrder picks a tag. +namespace { void park_active_order(Table* t); } +} + +// --- Lazy-close table pool (USE latency) ---------------------------------- +// +// NOTE: this block must stay inside extern "C++": the file-scope +// extern "C" gives every function here C linkage, and MSVC errors +// (C4190) on C++ return types like unique_ptr/string under /WX. + +extern "C++" { +// Vouch-style apps re-USE the same lookup tables every few seconds; each +// USE costs open+index+describe+goto round-trips. Parking an eligible +// table at close keeps its server handle, schema, tags and order +// bindings alive, so the next USE of the same path+alias+mode pays a +// single warm GotoTop. Eligibility is conservative (shared mode, natural +// order, never locked/scoped, no AOF/filter, no relations); anything +// else takes the legacy real close, exactly as before. +// +// Ownership: live tables live in remote_table_store() keyed by object +// pointer (moved out of AdsOpenTable, where the map leaked: nothing +// ever erased it). Parked tables live in the connection pool; the +// registry slot is released at park time so use-after-close still +// errors SAP-correct. + +// Owns every live RemoteTable. Keyed by object pointer (stable across +// handle re-registration on pooled reuse). +std::unordered_map>& +remote_table_store() { + static std::unordered_map> m; + return m; +} + +// Drop ownership without closing (park path hands it to the pool). +std::unique_ptr +remote_table_take(openads::network::RemoteTable* rt) { + if (rt == nullptr) return nullptr; + // remote_table_store() is shared by every thread (all lanes of a + // session pool): every access must hold s.mu. The park path in + // AdsCloseTable used to call this unlocked while other threads + // inserted/erased under s.mu -- a data race on the map that corrupted + // the heap under the 4-thread pool test (~1 run in 30 on Linux). + std::lock_guard lk(state().mu); + auto& m = remote_table_store(); + auto it = m.find(rt); + if (it == m.end()) return nullptr; + auto owned = std::move(it->second); + m.erase(it); + return owned; +} + +void remote_table_forget(openads::network::RemoteTable* rt) { + if (rt == nullptr) return; + std::lock_guard lk(state().mu); // see take() + remote_table_store().erase(rt); +} + +// Drop parked index snapshots on every live table of rc (file +// erase/rename can pull a bag out from under parked bindings). +// Drops the snapshot but keeps the pending flag, so the next flush +// sends a real close — files change rarely, correctness first. +// Pooled tables are real-closed by remote_flush_pools alongside. +void remote_invalidate_index_parks(openads::network::RemoteConnection* rc) { + if (rc == nullptr) return; + // Pool-wide: a bag pulled on one lane invalidates parked bindings on + // every lane of the same logical connection. + auto lanes = remote_pool_lanes_of(rc); + auto lane_match = [&](openads::network::RemoteConnection* c) { + for (auto* l : lanes) + if (c == l) return true; + return false; + }; + std::lock_guard lk(state().mu); // shared store + for (auto& kv : remote_table_store()) { + auto* rt = kv.first; + if (rt == nullptr || !lane_match(rt->conn)) continue; + if (!rt->indexes_parked) continue; + rt->indexes_parked = false; + rt->parked_by_tag.clear(); + rt->parked_handles.clear(); + } +} + +// Pool key: connection-implied (pools are per-connection), normalized +// name + alias + raw open mode. Slashes folded; case preserved (Linux +// filesystems are case-sensitive -- a miss is always safe). +std::string remote_pool_key(const std::string& name, + const std::string& alias, + std::uint16_t mode) { + std::string n = name; + for (auto& c : n) { if (c == '\\') c = '/'; } + return n + '\x01' + alias + '\x01' + std::to_string(mode); +} + +// True when the table participates in any relation (as parent or child). +// Parked tables keep no registry handle, so relations addressed by +// handle could neither follow nor clean up -- real-close those. +bool remote_table_has_relations(ADSHANDLE hTable) { + std::lock_guard lk(state().mu); + auto& tbl = relation_map(); + if (tbl.find(hTable) != tbl.end()) return true; + for (auto& [parent, kids] : tbl) { + (void)parent; + for (auto& k : kids) { + if (k.child == hTable) return true; + } + } + return false; +} + +// Eligibility snapshot at close time: every line must still read +// fresh-open equivalent. Order state is EXEMPT: no close reaches the +// server while parked, and server order/index bindings are per-session, +// so no peer can invalidate them behind our back — an active order +// resumes exactly (Vouch keeps IndexOrd()=1 across USEs; excluding it +// would empty the pool). +// wire_trace only: first rule (in remote_table_poolable order, then +// relations) that keeps a closing table out of the park. Mirrors the +// checks below; it never decides anything itself. +const char* remote_table_unpoolable_reason( + openads::network::RemoteTable* rt, ADSHANDLE hTable) { + if (rt == nullptr || rt->conn == nullptr) return "no_connection"; + if (!rt->close_counted) return "sql_cursor"; + if (rt->open_exclusive) return "exclusive"; + if (!rt->pending_sets.empty()) return "pending_sets"; + if (!rt->held_recs.empty() || rt->table_lock_held || + rt->locks_uncertain) return "locks_held"; + if (rt->scope_touched) return "scope"; + if (!rt->aof_expr.empty()) return "aof"; + if (!rt->filter_expr.empty()) return "filter"; + if (rt->flush_file_pending || rt->close_all_indexes_pending) + return "teardown_pending"; + if (rt->pending_order) return "pending_order"; + if (remote_table_has_relations(hTable)) return "relations"; + return "none"; +} + +bool remote_table_poolable(openads::network::RemoteTable* rt) { + if (rt == nullptr || rt->conn == nullptr) return false; + if (!rt->close_counted) return false; // SQL cursors etc. + if (rt->open_exclusive) return false; // parked exclusive blocks peers + if (!rt->pending_sets.empty()) return false; // flushed before close + // Locks once held no longer bar the park by themselves: the ledger + // proves whether any lock is STILL held (a parked table would + // otherwise pin it against every peer forever). ever_locked stays + // recorded for the trace but stops forcing a real close -- that + // policy cost GN_COUNT/FA_ACC01/USASELOG a full 7-frame reopen per + // voucher save. + if (!rt->held_recs.empty() || rt->table_lock_held || + rt->locks_uncertain) return false; + if (rt->scope_touched) return false; + if (!rt->aof_expr.empty() || !rt->filter_expr.empty()) return false; + // Deferred teardown (FlushFileBuffers/CloseAllIndexes) is absorbed + // by a real close, never by a park (no server close happens) — the + // close path drops the flags before deciding, so this line is an + // invariant guard for future park paths, not a live branch. + // A deferred order switch is likewise never parked: adopt would + // inherit a belief the server doesn't share. + if (rt->flush_file_pending || rt->close_all_indexes_pending) + return false; + if (rt->pending_order) + return false; + return true; +} + +// The actual server close + accounting for a live remote table. +// hTable==0 for already-unregistered (parked eviction/disconnect flush): +// skips registry/relations/cursor-map steps. Never holds s.mu across +// the wire close (in-process server re-enters it) -- callers must not +// hold it either; the mutex below covers accounting only. +void remote_close_table_live(ADSHANDLE hTable, + openads::network::RemoteTable* rt) { + if (rt == nullptr) return; + // A deferred order switch dies with the bindings: never emit it + // ahead of the close that destroys what it would install. + rt->pending_order = false; + (void)remote_flush_pending(rt); + auto* rc = rt->conn; + const bool counted = rt->close_counted; + if (rc != nullptr) (void)rc->close_table(rt->id); + cli_trace_table_close(rt); + if (hTable != 0) forget_relations(hTable); + auto& s2 = state(); + openads::network::RemoteConnection* fire = nullptr; + { + std::lock_guard lk2(s2.mu); + if (counted && rc != nullptr) { + if (--rc->deferred_open_tables == 0 && rc->close_pending) { + rc->close_pending = false; + fire = rc; + } + } + if (hTable != 0) { + s2.registry.release(hTable); + remote_sql_cursors_map().erase(hTable); + } + remote_table_forget(rt); + } + if (fire != nullptr) fire->disconnect(); +} + +// (remote_emit_close_all / remote_close_parked_indexes / +// remote_invalidate_index_parks live with the flush funnel above, +// ahead of first use.) + +// Parked-index truth enforcement: while indexes_parked holds, the +// server still runs the pre-CloseAll order but the client belief is +// natural (active_index_id 0). Order-dependent table-handle navs +// (top/bottom/skip/goto-record walks the server order) must resolve +// the divergence with a real close first — adoption would walk the +// stale order while the client accounts natural rows. Fires only +// when a nav lands inside a CloseAll→OpenIndex window (the rotation +// itself never does: Clear→Add is adjacent), so the normal flow pays +// nothing. Index-handle navs and seeks carry their own order context +// and adopt safely without this. +// (remote_emit_close_all / remote_close_parked_indexes / +// remote_invalidate_index_parks live with the flush funnel above, +// ahead of first use.) + +// Real-close every parked table on rc (drop/create/erase/rename and +// exclusive opens change what paths mean on disk; a parked server +// handle would pin or shadow them). Drops are rare; flushing the whole +// small pool is cheaper than path-matching subtleties. Wire closes run +// without s.mu held (in-process server re-enters it) — callers must +// arrange that (disconnect/open paths unlock first). +static void remote_flush_pools_one(openads::network::RemoteConnection* rc) { + if (rc == nullptr) return; + // File lifecycle changed meaning on disk: drop the existence + // cache alongside the parked handles. + rc->file_exists_invalidate(); + std::vector> parked; + rc->parked_flush(parked); + for (auto& e : parked) { + // A drop/create/erase/rename absorbs deferred teardown the + // same way a close does — never emit it first. + e->flush_file_pending = false; + e->close_all_indexes_pending = false; + e->write_dirty = false; + remote_close_table_live(0, e.get()); + } +} + +void remote_flush_pools(openads::network::RemoteConnection* rc) { + if (rc == nullptr) return; + // Pool-wide fan-out: a drop/create/erase/rename on one lane changes + // what paths mean for every lane (parks pin server handles by path, + // existence caches are per-lane). Drops are rare; flushing all small + // lane pools is cheaper than path-matching subtleties. + for (auto* lane : remote_pool_lanes_of(rc)) { + if (lane == nullptr) continue; + remote_flush_pools_one(lane); + } +} + +} // extern "C++" (lazy-close pool helpers end; ABI exports resume in C) + +UNSIGNED32 ENTRYPOINT AdsDisconnect(ADSHANDLE hConnect) { + arc2_trace("AdsDisconnect"); + arc2_trace("AdsDisconnect"); + std::vector pending_remote_disconnects; + bool deferred_close = false; + { + auto& s_local = state(); + std::unique_lock lk_local(s_local.mu); + connect101_open_options().erase(hConnect); +#if defined(OPENADS_WITH_SQLITE) + if (auto* sc = s_local.registry.lookup( + hConnect, HandleKind::SqliteConnection)) { + for (auto& kv : sqlite_tables_map()) { + if (kv.second && kv.second->conn == sc) { + kv.second->conn = nullptr; + } + } + sc->disconnect(); + sqlite_conns_map().erase(hConnect); + sql_uri_forget_user(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); + sql_uri_forget_user(hConnect); + sql_uri_forget_odbc_dialect(hConnect); + s_local.registry.release(hConnect); + return ok(); + } +#endif +#if defined(OPENADS_WITH_MSSQL) + if (auto* mc = s_local.registry.lookup< + openads::sql_backend::MssqlConnection>( + hConnect, HandleKind::MssqlConnection)) { + mc->disconnect(); + mssql_conns_map().erase(hConnect); + sql_uri_forget_user(hConnect); + s_local.registry.release(hConnect); + return ok(); + } +#endif +#if defined(OPENADS_WITH_FIREBIRD) + if (auto* sc = s_local.registry.lookup< + openads::sql_backend::FirebirdConnection>( + hConnect, HandleKind::FirebirdConnection)) { + for (auto& kv : firebird_tables_map()) { + if (kv.second && kv.second->conn == sc) { + kv.second->conn = nullptr; + } + } + sc->disconnect(); + firebird_conns_map().erase(hConnect); + sql_uri_forget_user(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); + sql_uri_forget_user(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); + sql_uri_forget_user(hConnect); + s_local.registry.release(hConnect); + return ok(); + } +#endif + if (auto* rc0 = s_local.registry.lookup( + hConnect, HandleKind::RemoteConnection)) { + // Pool-wide teardown: every lane of the logical connection. + // Deferred accounting stays per-lane (each lane's last close + // disconnects that lane); the logical disconnect defers while + // ANY lane still has open tables. + auto lanes = remote_pool_lanes_of(rc0); + if (lanes.empty()) lanes.push_back(rc0); + // Lane pins name lanes of this connection; drop them all. + remote_lane_pins_clear(hConnect); + // Null out rt->conn on any open SQL cursors that reference this + // connection so AdsCloseTable can detect the dangling case and + // skip the wire op rather than crashing with a use-after-free. + for (auto* rc : lanes) { + if (rc == nullptr) continue; + for (auto& kv : remote_sql_cursors_map()) { + if (kv.second && kv.second->conn == rc) + kv.second->conn = nullptr; + } + } + // Drain parked tables first: they count as open + // (deferred_open_tables) and would otherwise pin a deferred + // disconnect forever — nothing will ever close them. Wire + // closes run with s.mu released (in-process server + // re-enters it); accounting re-locks inside. + { + std::vector> parked; + for (auto* rc : lanes) { + if (rc == nullptr) continue; + rc->parked_flush(parked); + } + lk_local.unlock(); + for (auto& e : parked) remote_close_table_live(0, e.get()); + lk_local.lock(); + } + for (auto* rc : lanes) { + if (rc == nullptr) continue; + if (rc->deferred_open_tables > 0 && rc->valid()) { + // Tables opened through this connection are still in use + // (MT apps sharing one connection across threads): killing + // the socket now would turn their next op into an error. + // The last AdsCloseTable performs the real disconnect. + rc->close_pending = true; + deferred_close = true; + } else { + // Disconnect OUTSIDE s.mu: RemoteConnection::disconnect() + // serialises with in-flight requests on the connection + // mutex, and a request's server handler (in-process + // server) re-enters the ABI and takes s.mu -- holding both + // here could deadlock. + pending_remote_disconnects.push_back(rc); + } + } + } + } + if (!pending_remote_disconnects.empty()) { + for (auto* rc : pending_remote_disconnects) { + if (rc != nullptr) rc->disconnect(); + } + if (hConnect == rddads_default_connection()) + rddads_default_connection() = 0; + return ok(); + } + if (deferred_close) { + if (hConnect == rddads_default_connection()) + rddads_default_connection() = 0; + return ok(); + } + auto& s = state(); + std::lock_guard lk(s.mu); + // Purge any index bindings whose Table* belongs to a table owned + // by this connection -- otherwise the bindings outlive the conns + // entry that owned the Table and leave dangling pointers behind. + Connection* c = s.registry.lookup(hConnect, HandleKind::Connection); + if (c != nullptr) { + // Drop this connection's sp_CreateEvent registrations so the + // global event map can't hold a dangling Connection key. + spproc::drop_all_events_for(c); + // S4 -- drop session #temp tables / trigger-image registrations so + // the maps can't hold a dangling Connection key (defined near the + // sess namespace below). + sess_forget_connection(c); + // If this connection activated the process-wide OEM upper table + // (AdsSetCollation NTXPL852/PL852), deactivate it so UPPER() + // reverts to UTF-8 promotion for the rest of the process. + if (c->oem_upper_table() != nullptr && + c->oem_upper_table() == + openads::engine::active_oem_upper_table()) { + openads::engine::set_active_oem_upper_table(nullptr); + } + // Collect this connection's still-open Table handles. `s.conns.erase` + // below frees the Connection -- and with it every Table it owns -- so + // any registry slot still pointing at one of those Tables would dangle. + // A later allocation reusing that heap address then aliases the stale + // slot, which surfaces as AdsGetAllTables over-counting and, worse, a + // use-after-free on any Ads* call that looks the stale handle up. + // owns_table_ptr() identifies the owned tables precisely (the old + // heuristic could not, so it purged every registered Table*). + std::vector owned_handles; + std::vector to_purge; + s.registry.for_each_handle([&](Handle h, HandleKind k, void* p) { + if (k != HandleKind::Table) return; + Table* tp = static_cast(p); + if (tp == nullptr || !c->owns_table_ptr(tp)) return; + owned_handles.push_back(h); + to_purge.push_back(tp); + }); + for (Table* tp : to_purge) { + purge_bindings_for_table(tp); + purge_pending_binaries_for_table(tp); + } + for (Handle h : owned_handles) { + forget_cursor_projection(to_ads_handle(h)); + s.registry.release(h); + } + } + if (hConnect == rddads_default_connection()) + rddads_default_connection() = 0; + s.registry.release(hConnect); + s.conns.erase(hConnect); + return ok(); +} + +// OPENADS_REMOTE_ONLY_ACCESS -- legacy local paths must never be +// accessed through this DLL in a remote-only deployment. Modes: +// 1/on/true/yes deny: local open/create fails with AE_ACCESS_DENIED +// (the RDD raises a runtime error) +// 2/log/warn audit: a LOCALACCESS line goes to the audit +// console/file and the access proceeds -- inventory +// mode for finding every local open without breaking +// the app. Use this first: in deny mode an app whose +// error handler itself opens tables via ADSCDX dies +// from Harbour error-handler recursion. (Pritpal Bedi.) +// unset/0/off disabled +// Env var or openads.ini key `remote_only_access`; env wins. Read per +// call (no static cache) so tests and long-lived hosts can toggle it. +static int remote_only_access_mode() { + std::string v = openads::util::client_setting( + "OPENADS_REMOTE_ONLY_ACCESS", "remote_only_access"); + for (auto& c : v) + c = static_cast(std::tolower(static_cast(c))); + if (v == "2" || v == "log" || v == "warn") return 2; + if (v.empty() || v == "0" || v == "false" || v == "off" || v == "no") + return 0; + return 1; +} + +UNSIGNED32 ENTRYPOINT AdsOpenTable(ADSHANDLE hConnect, + UNSIGNED8* pucName, + UNSIGNED8* pucAlias, + UNSIGNED16 usTableType, + UNSIGNED16 usCharType, + UNSIGNED16 usLockType, + UNSIGNED16 usCheckRights, + UNSIGNED16 usMode, + ADSHANDLE* phTable) { + arc2_trace("AdsOpenTable"); + arc2_trace("AdsOpenTable"); + arc2_log("OPEN name=[%s] conn=%llu type=%u mode=%u", + pucName ? (const char*)pucName : "(null)", + (unsigned long long)hConnect, (unsigned)usTableType, + (unsigned)usMode); + if (phTable == nullptr) return fail(openads::AE_INTERNAL_ERROR, + "phTable is null"); + auto& s = state(); + std::unique_lock lk(s.mu); + // M12.5 -- remote connection handle: route through wire client. + // An explicit local Connection handle must stay local: falling + // through to "any live RemoteConnection" hijacks the server ABI + // twin whenever an in-process client also holds a tcp:// handle + // (embedded-server tests + mixed local/remote apps). + // hConnect == 0 / stale still adopts a surviving remote (rddads + // "current connection" after disconnect). + ADSHANDLE rem_h = 0; + if (s.registry.lookup( + hConnect, HandleKind::RemoteConnection) != nullptr) { + rem_h = hConnect; + } else if (s.registry.lookup( + hConnect, HandleKind::Connection) == nullptr) { + rem_h = resolve_remote_conn_handle(hConnect); + } + if (auto* rc0 = s.registry.lookup( + rem_h, HandleKind::RemoteConnection)) { + auto name = openads::abi::to_internal(pucName, 0); + // M12.33 — strip tcp:// URI prefix that legacy Delphi TAdsTable + // components embed in the table name (e.g. + // "tcp://host:port/C:/data\table.dbf" → "table.dbf"). + if (name.size() > 8 && + (name.rfind("tcp://", 0) == 0 || name.rfind("TCP://", 0) == 0)) { + auto last_sep = name.find_last_of("/\\"); + if (last_sep != std::string::npos && last_sep > 6) { + std::string stripped = name.substr(last_sep + 1); + if (!stripped.empty()) name = std::move(stripped); + } + } + // Callers (incl. X#'s ADSRDD) commonly pass the bare table name + // without an extension. Send it through unchanged -- the server's + // Connection::resolve_table_file() already falls back from .dbf to + // .adt when the bare name has no matching .dbf on disk, exactly + // mirroring the LOCAL AdsOpenTable path a few hundred lines down + // (conn->open_table(name, ...)). Force-appending ".dbf" here (as a + // previous version of this code did) skipped that fallback on the + // server -- resolve_table_file() only auto-detects when the name it + // receives has NO extension -- so every ADT-format table (e.g. a DD + // migrated from SAP with Table_Type=ADT) failed to open remotely + // with AE_TABLE_CORRUPTED (5103), even though the identical bare + // name opens fine locally. + // Honour the caller's pucAlias (Harbour USE ... ALIAS xxx) for + // the log entry and the RemoteTable metadata; fall back to the + // filename stem when the caller omits ALIAS or passes "". + std::string alias; + if (pucAlias && pucAlias[0] != '\0') { + alias = openads::abi::to_internal(pucAlias, 0); + } + if (alias.empty()) { + alias = std::filesystem::path(name).stem().string(); + } + // MT lane: this table's session for the logical connection. + // Lane pinning (default ON): same alias+path binds to the same + // lane from any thread and across close/reopen, so record-lock + // identity follows the workarea (zero-space detach/request). + // Without pinning: this thread's affine lane, and single-threaded + // callers always get the primary (lane 0) — behaviour unchanged. + openads::network::RemoteConnection* rc = + remote_pool_lane_for_open(rem_h, name, alias); + if (rc == nullptr) rc = rc0; + openads::util::write_remote_open_audit(name, alias); + // Pooled re-USE (USE latency): same connection/path/alias/mode + // within TTL reuses the parked server handle — no OpenTable wire + // op. Caches reset below; one warm GotoTop repositions (after + // lk is released — wire must never run under s.mu). + std::unique_ptr adopted; + { + std::unique_ptr hit; + if (rc->parked_reuse(remote_pool_key(name, alias, usMode), hit) && + hit && hit->conn == rc) { + adopted = std::move(hit); + } + } + if (cli_trace_on()) { + // Park diagnostics: hit/miss for this lane, plus whether + // another lane of the same session pool holds it parked. + char pbuf[320]; + if (adopted) { + std::snprintf(pbuf, sizeof(pbuf), "park hit id=%u %.200s", + static_cast(adopted->id), + name.c_str()); + } else { + const std::string pk = remote_pool_key(name, alias, usMode); + bool other_lane = false; + auto pit = remote_lane_pool_of().find(rc); + if (pit != remote_lane_pool_of().end() && + pit->second != nullptr) { + for (auto* ln : pit->second->lanes) { + if (ln != nullptr && ln != rc && + ln->parked_contains(pk)) { + other_lane = true; + break; + } + } + } + std::snprintf(pbuf, sizeof(pbuf), + "park miss%s conn=%04X %.200s", + other_lane ? " (parked on other lane)" : "", + static_cast( + reinterpret_cast(rc) & + 0xFFFFu), + name.c_str()); + } + cli_trace_line(cli_trace_ms(), alias.c_str(), "AdsOpenTable", + pbuf); + } + if (adopted) { + adopted->row_valid = false; + adopted->rec_count_cached = false; + adopted->key_count_cached = false; + adopted->key_counts.clear(); + adopted->key_counts.clear(); + adopted->keyno_valid = false; + adopted->found_cached = false; + adopted->nav_at_bof = adopted->nav_at_eof = false; + adopted->last_nav = 0; // re-stamped by the warm GotoTop below + adopted->pair_valid = false; + adopted->anchor_ok = false; + adopted->parked_nav_which = 0; + adopted->flush_file_pending = false; + adopted->close_all_indexes_pending = false; + adopted->pending_order = false; + remote_nav_bound_clear(adopted.get()); + adopted->invalidate_prefetch(); + // close_counted stays true (never decremented at park time), + // so no re-increment here. + lk.unlock(); + // A parked index snapshot does not survive adoption: the + // warm GotoTop below must walk the natural order, not the + // pre-park one (same divergence as a nav inside the + // CloseAll→OpenIndex window). Rare (pool + park combined); + // one frame when it fires. + if (UNSIGNED32 frc = remote_close_parked_indexes(adopted.get()); + frc != 0) { + return frc; + } + auto r = adopted->conn->goto_top(adopted.get()); + if (!r) { + // Server lost it (only when the session itself is gone — + // a live session never drops a parked handle). The count + // it still holds would pin a deferred disconnect, so + // release it before reporting the failure. + lk.lock(); + if (adopted->close_counted && adopted->conn != nullptr) { + adopted->conn->deferred_open_tables -= 1; + } + return fail(r.error()); + } + lk.lock(); + Handle gh2 = s.registry.register_object( + HandleKind::RemoteTable, adopted.get()); + auto* raw = adopted.get(); + remote_table_store()[raw] = std::move(adopted); + *phTable = to_ads_handle(gh2); + if (auto* rtp = get_remote_table(to_ads_handle(gh2))) { + rtp->found_cached = true; rtp->current_found = false; + // The warm GotoTop above is a real wire nav: stamp it so + // the app's immediate GoTop probing dedupes + an empty + // re-USE answers boundaries locally. + remote_nav_stamp(rtp, 1, rtp->server_order_id); + remote_sync_keyno_gototop(rtp); + } + apply_relations_for_handle(to_ads_handle(gh2)); + return ok(); + } + // Exclusive opens cannot share the file with a parked shared + // handle: evict the pool first (wire closes run unlocked). + if (map_open_mode(usMode) == openads::engine::OpenMode::Exclusive) { + lk.unlock(); + remote_flush_pools(rc); + lk.lock(); + } + // Release s.mu across the OpenTable round-trip (same rule as the + // pool flush above and the production auto-open below). Holding + // it here blocks every other ABI call in the process for a full + // RTT, and with an in-process server (local serverd / tests) it + // deadlocks: this thread waits on rc's request lock, the lane + // thread that owns it waits on a reply, and the server handler + // for that reply waits on s.mu. Nothing below touches shared + // state until the lock is re-taken. + lk.unlock(); + auto otr = rc->open_table(name, + static_cast(map_open_mode(usMode))); + lk.lock(); + if (!otr) return fail(otr.error()); + auto& ot = otr.value(); + auto rt = std::make_unique(); + rt->conn = rc; + rt->id = ot.id; + rt->name = name; + rt->alias = std::move(alias); + rt->close_counted = true; + rt->open_mode_raw = usMode; + rt->open_exclusive = + (map_open_mode(usMode) == openads::engine::OpenMode::Exclusive); + // Table type is decided by the server from the opened file's + // extension alone (.adt -> ADS_ADT, anything else -> ADS_CDX; + // see the GetTableType handler). The name we just opened carries + // that extension, so answer it locally. No extension -> leave it + // to the wire (the server may have resolved one). + { + std::string ext = std::filesystem::path(name).extension().string(); + for (auto& c : ext) + c = static_cast(std::tolower( + static_cast(c))); + if (!ext.empty()) { + rt->cached_table_type = (ext == ".adt") ? ADS_ADT : ADS_CDX; + rt->table_type_cached = true; + } + } + cli_trace_table_open(rt.get()); + cli_trace_tbl(rt.get(), "AdsOpenTable", "opened id=%u mode=%u", + static_cast(rt->id), + static_cast(usMode)); + rc->deferred_open_tables += 1; + Handle gh = s.registry.register_object( + HandleKind::RemoteTable, rt.get()); + remote_table_store()[rt.get()] = std::move(rt); + *phTable = to_ads_handle(gh); + // Warm open: schema + first row rode along in the ack. Install + // both so DescribeTable and the implicit GotoTop below cost zero + // RTTs. Absent on old servers — the legacy path runs unchanged. + bool open_warm = false; + if (auto* rtp = get_remote_table(to_ads_handle(gh))) { + if (ot.has_schema) { + rtp->fields = std::move(ot.fields); + rtp->fields_cached = true; + } + if (ot.has_first_row) { + (void)rc->apply_open_row(rtp, ot.first_row); + open_warm = true; + } + } + // Mirror the local M-AOF.6 production-index auto-open over the + // wire: opening .dbf binds .cdx (or .adi for + // ADT) when the server has it, so rddads / X# see the production + // tags as navigable orders -- and DbSetOrder(n) resolves them -- + // without an explicit AdsOpenIndex. + // + // The server's OpenTableAck may include the production bag path + // (stat-only, no ABI). Use it when available; otherwise derive + // it from the table name as a fallback. + // + // Release s.mu first: AdsOpenIndex issues a wire round-trip whose + // server handler (in-process for local serverd / tests) re-enters + // the ABI and takes s.mu on another thread. Holding it across the + // blocking call would deadlock. The client AdsOpenIndex re-locks + // for its own bookkeeping after the wire reply, which is fine. + lk.unlock(); + { + std::vector bags; + // Prefer the bag path the server confirmed exists. + if (!ot.prod_bag_path.empty()) { + bags = {ot.prod_bag_path}; + } else { + // Fallback: derive from the table name. Vouch/ERP apps + // keep the CDX-format production bag as .z01, so + // try .cdx first then .z01 (old servers never send + // prod_bag_path; new servers already confirmed above). + std::filesystem::path tp(name); + std::string ext = tp.extension().string(); + for (auto& c : ext) + c = static_cast(std::tolower( + static_cast(c))); + if (ext == ".dbf") bags = {tp.stem().string() + ".cdx", + tp.stem().string() + ".z01"}; + else if (ext == ".adt") bags = {tp.stem().string() + ".adi"}; + } + for (const auto& bag : bags) { + std::vector b(bag.size() + 1); + std::memcpy(b.data(), bag.data(), bag.size()); + ADSHANDLE arr[64] = {0}; + UNSIGNED16 alen = 64; + UNSIGNED32 orc = AdsOpenIndex(to_ads_handle(gh), b.data(), arr, &alen); + if (orc == 0) break; // .z01 hit after .cdx 5018, or first hit + if (!ot.prod_bag_path.empty()) break; // confirmed path: don't retry + } + // ADS semantics: auto-opening the production index leaves + // the controlling order natural (0) until DbSetOrder picks + // one. AdsOpenIndex optimistically marks the first tag + // active; undo that so the first nav-by-index actually + // sends SetOrder to the server. + if (auto* rtp = get_remote_table(to_ads_handle(gh))) { + rtp->active_index_id = 0; + // Store the confirmed production bag path so + // AdsGetIndexFilename / OrdBagName works locally. + if (!ot.prod_bag_path.empty()) + rtp->prod_bag_path = ot.prod_bag_path; + } + } + // Implicit GoTop in REMOTE mode: after the production CDX + // auto-open the client has no record buffer yet. LOCAL mode + // leaves the cursor at BOF with a valid buffer; REMOTE leaves + // the buffer empty, so AdsGetField / AdsGetRecordCount etc. + // would read uninitialized memory. One extra round-trip on + // table open positions the cursor on record 1 and populates + // the record cache, matching LOCAL semantics. + // + // Warm opens skip the round-trip: the ack already positioned + // the cursor and populated the cache identically. Mirror + // AdsGotoTop's local tail (found flags, keyno seed, relations). + if (open_warm) { + if (auto* rtp = get_remote_table(to_ads_handle(gh))) { + rtp->found_cached = true; rtp->current_found = false; + remote_sync_keyno_gototop(rtp); + // Warm sections positioned the cursor at top with no + // frame: stamp it so the app's immediate GoTop probing + // dedupes like a real wire nav. + remote_nav_stamp(rtp, 1, rtp->server_order_id); + } + apply_relations_for_handle(to_ads_handle(gh)); + } else if (get_remote_table(to_ads_handle(gh)) != nullptr) { + (void)AdsGotoTop(to_ads_handle(gh)); + } + return ok(); + } + // Explicit handle to a disconnected remote connection, and no live + // remote connection to fall back to: fail fast (SAP ADS semantics -- + // a disconnected handle returns an error immediately) instead of + // silently opening a LOCAL file through the cwd fallback below. + if (hConnect != 0 && rem_h == 0 && + s.registry.kind_of(hConnect) == HandleKind::RemoteConnection) { + return fail(openads::AE_NO_CONNECTION, "connection is closed"); + } +#if defined(OPENADS_WITH_SQLITE) + if (auto* sc = s.registry.lookup( + hConnect, HandleKind::SqliteConnection)) { + auto name = openads::abi::to_internal(pucName, 0); + std::string sys_suffix; + const bool is_sys = sql_uri_system_suffix(name, sys_suffix); + if (!is_sys && usCheckRights != 0) { + const openads::sql_backend::SqlQueryFn qfn = + [sc](const std::string& sql) { + return sqlite_acl_query(sc, sql); + }; + if (sql_uri_table_denied(hConnect, usCheckRights, usMode, name, + openads::sql_backend::SqlDdlDialect::Sqlite, qfn)) { + return fail(openads::AE_ACCESS_DENIED, name.c_str()); + } + } + if (is_sys) { + auto catalog = openads::sql_backend::build_system_catalog_sql( + openads::sql_backend::SqlDdlDialect::Sqlite, sys_suffix); + if (catalog) { + auto cur = sc->run_sql(*catalog); + if (!cur) return fail(cur.error()); + auto st = std::move(cur).value(); + if (!st) { + return fail(openads::AE_NO_FILE_FOUND, name.c_str()); + } + st->conn = sc; + Handle gh = s.registry.register_object( + HandleKind::SqliteTable, st.get()); + sqlite_tables_map().emplace(gh, std::move(st)); + *phTable = to_ads_handle(gh); + return ok(); + } + } + if (is_sys) { + return fail(openads::AE_NO_FILE_FOUND, name.c_str()); + } + auto tbl = sc->open_table(name); + if (!tbl) return fail(tbl.error()); + auto st = std::move(tbl).value(); + st->conn = sc; + sql_uri_bind_table_acl(st.get(), hConnect, usCheckRights); + Handle gh = s.registry.register_object( + HandleKind::SqliteTable, st.get()); + sqlite_tables_map().emplace(gh, std::move(st)); + *phTable = to_ads_handle(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); + if (usCheckRights != 0) { + const openads::sql_backend::SqlQueryFn qfn = + [sc](const std::string& sql) { + return odbc_acl_query(sc, sql); + }; + if (sql_uri_table_denied(hConnect, usCheckRights, usMode, name, + odbc_dialect_for(hConnect), qfn)) { + return fail(openads::AE_ACCESS_DENIED, name.c_str()); + } + } + auto tbl = sc->open_table(name); + if (!tbl) return fail(tbl.error()); + auto st = std::move(tbl).value(); + st->conn = sc; + sql_uri_bind_table_acl(st.get(), hConnect, usCheckRights); + Handle gh = s.registry.register_object( + HandleKind::OdbcTable, st.get()); + odbc_tables_map().emplace(gh, std::move(st)); + *phTable = to_ads_handle(gh); + return ok(); + } +#endif +#if defined(OPENADS_WITH_MSSQL) + if (auto* mc = s.registry.lookup( + hConnect, HandleKind::MssqlConnection)) { + auto name = openads::abi::to_internal(pucName, 0); + std::string sys_suffix; + const bool is_sys = sql_uri_system_suffix(name, sys_suffix); + if (!is_sys && usCheckRights != 0) { + const openads::sql_backend::SqlQueryFn qfn = + [mc](const std::string& sql) { + return mssql_acl_query(mc, sql); + }; + if (sql_uri_table_denied(hConnect, usCheckRights, usMode, name, + openads::sql_backend::SqlDdlDialect::Mssql, qfn)) { + return fail(openads::AE_ACCESS_DENIED, name.c_str()); + } + } + if (is_sys) { + auto catalog = openads::sql_backend::build_system_catalog_sql( + openads::sql_backend::SqlDdlDialect::Mssql, sys_suffix); + if (catalog) { + auto qr = mc->query(*catalog); + if (!qr) return fail(qr.error()); + openads::sql_backend::tds::QueryResult result = + std::move(qr).value(); + if (!result.ok) { + return fail(static_cast(result.error_number), + result.message.c_str()); + } + auto st = openads::sql_backend::MssqlTable::from_result( + std::move(result)); + Handle gh = s.registry.register_object( + HandleKind::MssqlTable, st.get()); + mssql_tables_map().emplace(gh, std::move(st)); + *phTable = to_ads_handle(gh); + return ok(); + } + return fail(openads::AE_NO_FILE_FOUND, name.c_str()); + } + auto tbl = openads::sql_backend::MssqlTable::open(*mc, name); + if (!tbl) return fail(tbl.error()); + auto st = std::move(tbl).value(); + st->conn = mc; + st->name = name; + st->sql_table = name; + sql_uri_bind_table_acl(st.get(), hConnect, usCheckRights); + if (auto pk = mc->discover_pk(name); pk) { + for (const std::string& col : pk.value()) { + for (std::size_t i = 0; i < st->field_count(); ++i) { + const std::string& fn = st->field_name(i); + if (fn.size() == col.size()) { + bool match = true; + for (std::size_t j = 0; j < fn.size(); ++j) { + if (std::tolower(static_cast(fn[j])) != + std::tolower(static_cast(col[j]))) { + match = false; + break; + } + } + if (match) { + st->pk_cols.push_back(i); + break; + } + } + } + } + } + Handle gh = s.registry.register_object( + HandleKind::MssqlTable, st.get()); + mssql_tables_map().emplace(gh, std::move(st)); + *phTable = to_ads_handle(gh); + return ok(); + } +#endif +#if defined(OPENADS_WITH_FIREBIRD) + if (auto* sc = s.registry.lookup( + hConnect, HandleKind::FirebirdConnection)) { + auto name = openads::abi::to_internal(pucName, 0); + std::string sys_suffix; + const bool is_sys = sql_uri_system_suffix(name, sys_suffix); + if (!is_sys && usCheckRights != 0) { + const openads::sql_backend::SqlQueryFn qfn = + [sc](const std::string& sql) { + return firebird_acl_query(sc, sql); + }; + if (sql_uri_table_denied(hConnect, usCheckRights, usMode, name, + openads::sql_backend::SqlDdlDialect::Firebird, qfn)) { + return fail(openads::AE_ACCESS_DENIED, name.c_str()); + } + } + if (is_sys) { + auto catalog = openads::sql_backend::build_system_catalog_sql( + openads::sql_backend::SqlDdlDialect::Firebird, sys_suffix); + if (catalog) { + auto cur = sc->run_sql(*catalog); + if (!cur) return fail(cur.error()); + auto st = std::move(cur).value(); + if (!st) { + return fail(openads::AE_NO_FILE_FOUND, name.c_str()); + } + st->conn = sc; + Handle gh = s.registry.register_object( + HandleKind::FirebirdTable, st.get()); + firebird_tables_map().emplace(gh, std::move(st)); + *phTable = to_ads_handle(gh); + return ok(); + } + return fail(openads::AE_NO_FILE_FOUND, name.c_str()); + } + auto tbl = sc->open_table(name); + if (!tbl) return fail(tbl.error()); + auto st = std::move(tbl).value(); + st->conn = sc; + sql_uri_bind_table_acl(st.get(), hConnect, usCheckRights); + Handle gh = s.registry.register_object( + HandleKind::FirebirdTable, st.get()); + firebird_tables_map().emplace(gh, std::move(st)); + *phTable = to_ads_handle(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); + std::string sys_suffix; + const bool is_sys = sql_uri_system_suffix(name, sys_suffix); + if (!is_sys && usCheckRights != 0) { + const openads::sql_backend::SqlQueryFn qfn = + [sc](const std::string& sql) { + return maria_acl_query(sc, sql); + }; + if (sql_uri_table_denied(hConnect, usCheckRights, usMode, name, + openads::sql_backend::SqlDdlDialect::Maria, qfn)) { + return fail(openads::AE_ACCESS_DENIED, name.c_str()); + } + } + if (is_sys) { + auto catalog = openads::sql_backend::build_system_catalog_sql( + openads::sql_backend::SqlDdlDialect::Maria, sys_suffix); + if (catalog) { + auto cur = sc->run_sql(*catalog); + if (!cur) return fail(cur.error()); + auto st = std::move(cur).value(); + if (!st) { + return fail(openads::AE_NO_FILE_FOUND, name.c_str()); + } + st->conn = sc; + Handle gh = s.registry.register_object( + HandleKind::MariaTable, st.get()); + maria_tables_map().emplace(gh, std::move(st)); + *phTable = to_ads_handle(gh); + return ok(); + } + return fail(openads::AE_NO_FILE_FOUND, name.c_str()); + } + auto tbl = sc->open_table(name); + if (!tbl) return fail(tbl.error()); + auto st = std::move(tbl).value(); + st->conn = sc; + sql_uri_bind_table_acl(st.get(), hConnect, usCheckRights); + Handle gh = s.registry.register_object( + HandleKind::MariaTable, st.get()); + maria_tables_map().emplace(gh, std::move(st)); + *phTable = to_ads_handle(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); + std::string sys_suffix; + const bool is_sys = sql_uri_system_suffix(name, sys_suffix); + if (!is_sys && usCheckRights != 0) { + const openads::sql_backend::SqlQueryFn qfn = + [sc](const std::string& sql) { + return postgres_acl_query(sc, sql); + }; + if (sql_uri_table_denied(hConnect, usCheckRights, usMode, name, + openads::sql_backend::SqlDdlDialect::Postgres, qfn)) { + return fail(openads::AE_ACCESS_DENIED, name.c_str()); + } + } + if (is_sys) { + auto catalog = openads::sql_backend::build_system_catalog_sql( + openads::sql_backend::SqlDdlDialect::Postgres, sys_suffix); + if (catalog) { + auto cur = sc->run_sql(*catalog); + if (!cur) return fail(cur.error()); + auto st = std::move(cur).value(); + if (!st) { + return fail(openads::AE_NO_FILE_FOUND, name.c_str()); + } + st->conn = sc; + Handle gh = s.registry.register_object( + HandleKind::PostgresTable, st.get()); + postgres_tables_map().emplace(gh, std::move(st)); + *phTable = to_ads_handle(gh); + return ok(); + } + return fail(openads::AE_NO_FILE_FOUND, name.c_str()); + } + auto tbl = sc->open_table(name); + if (!tbl) return fail(tbl.error()); + auto st = std::move(tbl).value(); + st->conn = sc; + sql_uri_bind_table_acl(st.get(), hConnect, usCheckRights); + Handle gh = s.registry.register_object( + HandleKind::PostgresTable, st.get()); + postgres_tables_map().emplace(gh, std::move(st)); + *phTable = to_ads_handle(gh); + return ok(); + } +#endif + auto* conn = s.registry.lookup(hConnect, HandleKind::Connection); + if (conn == nullptr) { + ADSHANDLE def = get_or_create_default_connection(); + conn = s.registry.lookup(def, HandleKind::Connection); + if (conn == nullptr) { + return fail(openads::AE_INVALID_CONNECTION_HANDLE, + "unknown connection"); + } + } + // Remote-only deployments: a LOCAL open here means the app bypassed + // the server with a legacy local path -- deny (RTE) or audit it, + // never touch disk silently. + const int roa_mode = remote_only_access_mode(); + if (roa_mode == 1) { + return fail(openads::AE_ACCESS_DENIED, + "local table open blocked by OPENADS_REMOTE_ONLY_ACCESS"); + } + if (roa_mode == 2) { + auto nm = openads::abi::to_internal(pucName, 0); + openads::util::write_local_access_audit("OPEN", nm); + } + auto name = openads::abi::to_internal(pucName, 0); + // View alias expansion: if the requested name matches a DD view, execute + // the view's SQL and return the resulting cursor as the table handle. + if (conn->has_dd()) { + std::string uname = name; + for (auto& ch : uname) ch = static_cast( + std::toupper(static_cast(ch))); + for (const auto& kv : conn->dd()->views()) { + std::string vn = kv.first; + for (auto& ch : vn) ch = static_cast( + std::toupper(static_cast(ch))); + if (vn == uname) { + ADSHANDLE stmt_h = 0; + UNSIGNED32 rc = AdsCreateSQLStatement(hConnect, &stmt_h); + if (rc != 0) return rc; + const std::string& vsql = kv.second.sql; + std::vector sqlbuf(vsql.size() + 1); + std::memcpy(sqlbuf.data(), vsql.data(), vsql.size()); + rc = AdsExecuteSQLDirect(stmt_h, sqlbuf.data(), phTable); + AdsCloseSQLStatement(stmt_h); + return rc; + } + } + } + // Per-table ACL check: when usCheckRights is non-zero and the connection + // has an authenticated user with a DD ACL for this table, verify the + // Check per-operation permissions for the open mode requested. + if (usCheckRights != 0 && conn->has_dd() && !conn->username().empty()) { + std::string alias = name_to_alias(conn->dd(), name); + if (!alias.empty()) { + auto ops = eff_ops(conn, alias); + bool denied = (usMode == ADS_READONLY) ? !ops.select_ + : !ops.update_ && !ops.insert_; + if (denied) + return fail(openads::AE_ACCESS_DENIED, alias.c_str()); + } + } + auto th = conn->open_table(name, map_type(usTableType), + map_open_mode(usMode), + map_locking_mode(usLockType)); + if (!th) return fail(th.error()); + Table* tbl = conn->lookup_table(th.value()); + Handle gh = s.registry.register_object(HandleKind::Table, tbl); + *phTable = to_ads_handle(gh); + + // Set the table alias: honour the caller's pucAlias when provided + // (Harbour USE ... ALIAS xxx), otherwise derive from the filename stem. + { + namespace fs = std::filesystem; + std::string alias; + if (pucAlias && pucAlias[0] != '\0') { + alias = openads::abi::to_internal(pucAlias, 0); + } + if (alias.empty()) { + alias = fs::path(name).stem().string(); + } + tbl->set_alias(std::move(alias)); + } + + // RCB 2026-07-10 -- record the caller's usCharType on the Table, and + // do it BEFORE the production-index auto-open below: AdsOpenIndex → + // apply_cdx_oem_collation reads it to stamp the effective OEM sort + // table onto every tag. usCharType=ADS_OEM is the ONLY signal a + // Harbour rddads app gives that its data is OEM (AdsSetCharType() + // just feeds this parameter; rddads never calls AdsSetCollation), so + // ignoring it -- as this function did before v1.8.9 -- makes PL852 + // support unreachable for rddads and reintroduces the v1.8.6/v1.8.7 + // not-found seeks on Polish keys (#130 follow-up). Do not remove and + // do not move below the AdsOpenIndex calls. ADS_DEFAULT (0) keeps + // the ANSI default. + if (usCharType == ADS_ANSI || usCharType == ADS_OEM) + tbl->set_char_type(usCharType); + + // Release s.mu before production-index auto-open (AdsOpenIndex does + // file I/O + may take index_bindings_mu). Holding the registry mutex + // across it serialises every USE behind INDEX ON and can deadlock. + lk.unlock(); + + // M-AOF.6 -- production-CDX auto-open. ADS / rddads convention: + // opening `.dbf` auto-binds `.cdx` if it exists, so + // every tag inside it becomes navigable on this Table without + // an explicit AdsOpenIndex60 call. Without this, the AOF + // matcher in evaluate_optimised() never finds the index and + // every leaf falls back to the per-record evaluation -- + // AdsGetAOFOptLevel reports NONE forever even after a + // CREATE INDEX SQL ran in a prior session. + namespace fs = std::filesystem; + fs::path tp(tbl->path()); + if (tp.extension() == ".dbf" || tp.extension() == ".DBF") { + // Vouch/ERP: production bag may be .z01 (CDX format). + for (const char* ext : {".cdx", ".z01"}) { + fs::path bag = tp; bag.replace_extension(ext); + std::error_code ec; + std::string bag_path = + openads::platform::resolve_case_insensitive(bag.string()); + if (fs::exists(bag_path, ec)) { + std::vector b(bag_path.size() + 1); + std::memcpy(b.data(), bag_path.data(), bag_path.size()); + // Up to 64 tag handles is plenty for a production bag. + ADSHANDLE arr[64] = {0}; + UNSIGNED16 alen = 64; + if (AdsOpenIndex(to_ads_handle(gh), b.data(), arr, &alen) == 0) break; + } + } + } + // ADI auto-open: same convention for ADT tables -- opening `.adt` + // (or `.dat`, the Russoft ERP convention of keeping ADT data in + // .DAT + .ADI per ARC-CAJA) auto-binds `.adi` if it exists, so every + // tag inside it becomes navigable without an explicit AdsOpenIndex call. + std::string tp_extl = tp.extension().string(); + for (auto& ch : tp_extl) + ch = static_cast(std::tolower(static_cast(ch))); + if (tp_extl == ".adt" || tp_extl == ".dat") { + fs::path adi = tp; adi.replace_extension(".adi"); + std::error_code ec; + std::string adi_path = + openads::platform::resolve_case_insensitive(adi.string()); + if (fs::exists(adi_path, ec)) { + std::string adis = adi_path; + std::vector b(adis.size() + 1); + std::memcpy(b.data(), adis.data(), adis.size()); + ADSHANDLE arr[64] = {0}; + UNSIGNED16 alen = 64; + (void)AdsOpenIndex(to_ads_handle(gh), b.data(), arr, &alen); + } + } + // ADS semantics: auto-opening the production index leaves the + // controlling order natural (0) until DbSetOrder picks one. + // AdsOpenIndex optimistically marks the first tag active; park it + // back so dbGoBottom()/dbSkip() walk natural record order until the + // app selects a tag. Mirrors the remote path, which resets + // RemoteTable::active_index_id to 0 after the same auto-open. + // (Pritpal Bedi: dbGoBottom() landed on the last index key instead + // of LastRec() after a plain USE via ADSCDX.) + park_active_order(tbl); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsOpenTable101(ADSHANDLE hConnect, UNSIGNED8* pucName, + ADSHANDLE* phTable) { + arc2_trace("AdsOpenTable101"); + Connect101OpenOptions opts; + { + auto& s = state(); + std::lock_guard lk(s.mu); + auto it = connect101_open_options().find(hConnect); + if (it != connect101_open_options().end()) opts = it->second; + } + if (opts.table_type == ADS_DEFAULT) { + opts.table_type = infer_table_type_from_name(pucName); + } + return AdsOpenTable(hConnect, pucName, nullptr, opts.table_type, + opts.char_type, opts.lock_type, opts.check_rights, + opts.mode, phTable); +} + +UNSIGNED32 ENTRYPOINT AdsGetTableType(ADSHANDLE hTable, UNSIGNED16* pusType) { + arc2_trace("AdsGetTableType"); + if (pusType == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (auto* rt = get_remote_table(hTable)) { + // Immutable for an open handle: cache after the first hit + // (rddads asks per USE). + if (rt->table_type_cached) { + *pusType = rt->cached_table_type; + return ok(); + } + auto r = rt->conn->get_table_type(rt->id); + if (!r) return fail(r.error()); + *pusType = r.value(); + rt->cached_table_type = r.value(); + rt->table_type_cached = true; + arc2_log("TABLETYPE ret %u", (unsigned)*pusType); + return ok(); + } + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); + // Map our internal TableType back to ACE constants. We only own + // CDX and NTX today; the rest are out of scope for phase 1. + namespace fs = std::filesystem; + fs::path p(t->path()); + auto ext = p.extension().string(); + for (auto& c : ext) c = static_cast(std::tolower( + static_cast(c))); + if (ext == ".dbf") { + // Distinguishing CDX vs NTX vs VFP from a bare DBF requires + // probing the matching index file alongside it, which we + // don't do yet. Return CDX (the most common case). + *pusType = ADS_CDX; + } else if (ext == ".adt") { + *pusType = ADS_ADT; + } else { + *pusType = ADS_CDX; + } + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsGetTableLockType(ADSHANDLE hTable, + UNSIGNED16* pusLockType) { + arc2_trace("AdsGetTableLockType"); + if (pusLockType == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (get_remote_table(hTable) != nullptr) { + *pusLockType = ADS_COMPATIBLE_LOCKING; + return ok(); + } + if (openads::abi::backend_table_ops_for(hTable) != nullptr) { + *pusLockType = ADS_COMPATIBLE_LOCKING; + return ok(); + } + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); + *pusLockType = + (t->locking_mode() == openads::engine::LockingMode::Proprietary) + ? ADS_PROPRIETARY_LOCKING + : ADS_COMPATIBLE_LOCKING; + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsGetTableFilename(ADSHANDLE hTable, UNSIGNED16 /*usOption*/, + UNSIGNED8* pucBuf, UNSIGNED16* pusLen) { + arc2_trace("AdsGetTableFilename"); + if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (auto* rt = get_remote_table(hTable)) { + openads::abi::copy_to_caller(pucBuf, pusLen, rt->name); + return ok(); + } + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); + openads::abi::copy_to_caller(pucBuf, pusLen, t->path()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsGetRecordLength(ADSHANDLE hTable, UNSIGNED32* pulLen) { + arc2_trace("AdsGetRecordLength"); + if (pulLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (auto* rt = get_remote_table(hTable)) { + // Immutable for an open handle (only a restructure alters it, + // and that closes/reopens): cache after the first hit. + if (rt->record_length_cached) { + *pulLen = rt->cached_record_length; + return ok(); + } + // Re-open of a table already measured on this connection with + // the identical schema: same file layout, same length. + const std::string memo_key = remote_record_length_key(rt); + if (!memo_key.empty() && + rt->conn->recall_record_length(memo_key, + rt->cached_record_length)) { + rt->record_length_cached = true; + *pulLen = rt->cached_record_length; + return ok(); + } + auto r = rt->conn->get_record_length(rt->id); + if (!r) return fail(r.error()); + *pulLen = r.value(); + rt->cached_record_length = r.value(); + rt->record_length_cached = true; + if (!memo_key.empty()) + rt->conn->remember_record_length(memo_key, r.value()); + return ok(); + } + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); + if (t->driver() == nullptr) { *pulLen = 0; return ok(); } + *pulLen = t->driver()->record_length(); + return ok(); +} + +extern "C++" { +namespace { + +// M10.33 -- standard SQL LIKE pattern. `%` matches any sequence +// (including empty), `_` matches a single character. Greedy match +// with backtracking -- adequate for short DBF cells. +static inline bool sql_like_match(const std::string& s, + const std::string& pat) { + std::size_t si = 0, pi = 0; + std::size_t star = std::string::npos, ss = 0; + while (si < s.size()) { + if (pi < pat.size() && + (pat[pi] == '_' || pat[pi] == s[si])) { + ++si; ++pi; + } else if (pi < pat.size() && pat[pi] == '%') { + star = pi++; + ss = si; + } else if (star != std::string::npos) { + pi = star + 1; + si = ++ss; + } else { + return false; + } + } + while (pi < pat.size() && pat[pi] == '%') ++pi; + return pi == pat.size(); +} + +// LIKE with optional case folding -- CICHAR columns match LIKE +// case-insensitively (oracle-verified), plain CHAR byte-wise. +static inline bool sql_like_match_t(const std::string& s, + const std::string& pat, bool ci) { + if (!ci) return sql_like_match(s, pat); + std::string s2 = s, p2 = pat; + for (char& ch : s2) + ch = static_cast(std::toupper(static_cast(ch))); + for (char& ch : p2) + ch = static_cast(std::toupper(static_cast(ch))); + return sql_like_match(s2, p2); +} + +// S4 -- case sensitivity is a FIELD-TYPE property (oracle-verified on +// pmsys): CICHAR (ADT type 20) columns compare case-insensitively in +// `=`, LIKE, BETWEEN and IN; plain CHAR compares byte-wise. SAP's +// system.* catalog columns are CICHAR, which is why +// `WHERE Parent = 'PROPERTIES'` matches 'properties' there. +// PAD SPACE semantics (oracle-verified: `Parent = 'PROPERTIES '` and +// `address = '533 Hickory Blvd '` both match on SAP): trailing blanks +// are insignificant on BOTH sides of a string comparison, CHAR and +// CICHAR alike. +static inline int where_str_cmp(const std::string& a, const std::string& b, + bool ci) { + std::size_t alen = a.size(), blen = b.size(); + while (alen > 0 && a[alen - 1] == ' ') --alen; + while (blen > 0 && b[blen - 1] == ' ') --blen; + std::size_t n = std::min(alen, blen); + for (std::size_t i = 0; i < n; ++i) { + int ca = static_cast(a[i]); + int cb = static_cast(b[i]); + if (ci) { ca = std::toupper(ca); cb = std::toupper(cb); } + if (ca != cb) return ca < cb ? -1 : 1; + } + if (alen == blen) return 0; + return alen < blen ? -1 : 1; +} + +static inline bool field_is_cichar(const openads::engine::Table& t, + std::uint16_t fi) { + return t.field_descriptor(fi).type == + openads::drivers::DbfFieldType::CiCharacter; +} + +// ADS dialect -- apply the optional UPPER()/LOWER() case-fold from a +// WHERE left-hand side to a cell value before it is compared. A no-op +// for WhereFn::None, so callers can apply it unconditionally. +static inline std::string apply_where_fn(std::string s, + openads::sql::WhereFn fn) { + if (fn == openads::sql::WhereFn::Upper) { + for (char& ch : s) + ch = static_cast(std::toupper(static_cast(ch))); + } else if (fn == openads::sql::WhereFn::Lower) { + for (char& ch : s) + ch = static_cast(std::tolower(static_cast(ch))); + } + return s; +} + +// Map an rddads type name (Character, Numeric, Date, ...) to the +// DBF field-descriptor type byte plus a default length / decimals +// when those aren't explicit in the field-def string. +struct DbfTypeSpec { + char type = 'C'; + std::uint8_t length = 0; + std::uint8_t dec = 0; + bool needs_memo = false; +}; + +DbfTypeSpec dbf_type_for(const std::string& name) { + auto eq = [&](const char* k) { + if (name.size() != std::strlen(k)) return false; + for (std::size_t i = 0; i < name.size(); ++i) { + char a = static_cast(std::tolower( + static_cast(name[i]))); + char b = static_cast(std::tolower( + static_cast(k[i]))); + if (a != b) return false; + } + return true; + }; + // Single-letter DBF type codes (the dBASE convention every + // raw-DBF tool uses). Both the rddads verbose names AND the + // one-letter codes flow through here so callers can pick + // either style. + if (name.size() == 1) { + char c = static_cast(std::toupper( + static_cast(name[0]))); + switch (c) { + case 'C': return {'C', 0, 0, false}; + case 'N': return {'N', 0, 0, false}; + case 'L': return {'L', 1, 0, false}; + case 'D': return {'D', 8, 0, false}; + case 'M': return {'M', 10, 0, true }; + case 'I': return {'N', 0, 0, false}; // integer-as-text + case 'Y': return {'N', 0, 2, false}; // currency-as-text + case 'B': return {'N', 0, 0, false}; // double-as-text + case 'V': return {'V', 0, 0, false}; // varchar (M11.1) + case 'Q': return {'Q', 0, 0, false}; // varbinary (M11.1) + case 'T': return {'C',23, 0, false}; // ISO-8601 fallback + default: break; + } + } + if (eq("Character") || eq("Char")) + return {'C', 0, 0, false}; + if (eq("Numeric") || eq("Long") || eq("Number")) + return {'N', 0, 0, false}; + if (eq("Logical") || eq("Bool")) + return {'L', 1, 0, false}; + if (eq("Date") || eq("ShortDate")) + return {'D', 8, 0, false}; + if (eq("Memo") || eq("NMemo")) + return {'M', 10, 0, true}; + if (eq("Binary")) + return {'Q', 9, 0, true}; + if (eq("Image")) + return {'I', 9, 0, true}; + if (eq("Integer") || eq("LongLong")) + return {'N', 0, 0, false}; + if (eq("Double") || eq("CurDouble")) + return {'N', 0, 0, false}; + if (eq("ModTime")) + return {'C', 23, 0, false}; // store as ISO-8601 string for now + // ── ADT-specific type names: use sentinel chars handled by adt_spec_for ── + if (eq("CICHARACTER") || eq("CiCharacter") || eq("CICHAR")) + return {'W', 0, 0, false}; // ADT type 20: case-insensitive char + if (eq("ShortInt") || eq("SmallInt") || eq("SMALLINT")) + return {'S', 2, 0, false}; // ADT type 12: 2-byte int16 + if (eq("Money") || eq("Currency")) + return {'$', 8, 0, false}; // ADT type 18: 8-byte int64 * 10000 + if (eq("Timestamp")) + return {'P', 8, 0, false}; // ADT type 14: 8-byte JDN+ms + if (eq("AutoInc")) + return {'A', 4, 0, false}; // ADT type 15: 4-byte uint32 auto-increment + if (eq("Time")) + return {'Z', 4, 0, false}; // ADT type 13: 4-byte ms since midnight + // ADT type 2 -- numeric stored as ASCII digits, so the declared scale is + // part of the value ("10.50" stays "10.50"). Plain "Numeric" with decimals + // maps to ADT DOUBLE (type 10) instead, and a conforming ADT DOUBLE + // descriptor carries NO decimal count (see the fd[137] comment in the ADT + // writer -- stamping one makes the real ADS engine report the table corrupt, + // 7016), so a DOUBLE round-trips the value but not its formatting. Used by + // the SELECT materialisation path, which needs full-length column names + // (ADT) *and* the source's numeric scale. + if (eq("AsciiNumeric")) + return {'#', 0, 0, false}; + return {'C', 0, 0, false}; // unknown -> Character +} + +// Map a parsed field-type char to the byte a DBF field descriptor can carry. +// The ADT sentinels above ('W', 'S', '$', 'P', 'A', 'Z', '#') are internal +// markers, not DBF type codes -- writing one into fd[11] would produce a table +// no reader can classify. Collapse each to its closest DBF equivalent. +char dbf_descriptor_type_char(char t) { + switch (t) { + case 'W': return 'C'; // CICHARACTER -> Character + case 'S': return 'N'; // SHORTINT -> Numeric + case '$': return 'Y'; // MONEY -> Currency + case 'P': return 'T'; // TIMESTAMP -> DateTime + case 'A': return 'N'; // AUTOINC -> Numeric + case 'Z': return 'C'; // TIME -> Character + case '#': return 'N'; // ASCII numeric is exactly DBF's 'N' + default: return t; + } +} + +// Trim leading/trailing whitespace. +std::string trim(std::string s) { + while (!s.empty() && std::isspace( + static_cast(s.front()))) s.erase(s.begin()); + while (!s.empty() && std::isspace( + static_cast(s.back()))) s.pop_back(); + return s; +} + +// rddads `NAME,Type,Len,Dec;…` parser. Empty `defs` returns an empty +// vector. Used by AdsCreateTable (M9.5) and AdsRestructureTable (M9.26). +struct FieldOut { + std::string name; + char type = 'C'; + std::uint8_t length = 0; + std::uint8_t dec = 0; + bool nullable = false; + bool autoinc = false; +}; + +DbfTypeSpec vfp_type_for(const std::string& name) { + auto eq = [&](const char* k) { + if (name.size() != std::strlen(k)) return false; + for (std::size_t i = 0; i < name.size(); ++i) { + char a = static_cast(std::tolower( + static_cast(name[i]))); + char b = static_cast(std::tolower( + static_cast(k[i]))); + if (a != b) return false; + } + return true; + }; + if (name.size() == 1) { + char c = static_cast(std::toupper( + static_cast(name[0]))); + switch (c) { + case 'I': return {'I', 4, 0, false}; + case 'Y': return {'Y', 8, 0, false}; + case 'B': return {'B', 8, 0, false}; + case 'V': return {'V', 10, 0, false}; + case 'Q': return {'Q', 10, 0, false}; + case 'M': return {'M', 4, 0, true}; + default: break; + } + } + if (eq("Integer") || eq("LongLong")) + return {'I', 4, 0, false}; + if (eq("Currency") || eq("Money")) + return {'Y', 8, 0, false}; + if (eq("Double") || eq("CurDouble")) + return {'B', 8, 0, false}; + if (eq("Varchar")) + return {'V', 10, 0, false}; + if (eq("Varbinary")) + return {'Q', 10, 0, false}; + if (eq("AutoInc")) + return {'I', 4, 0, false}; + return dbf_type_for(name); +} + +std::vector parse_rddads_field_defs(const std::string& defs, + bool vfp = false) { + std::vector fields; + std::string buf; + auto flush = [&] { + if (buf.empty()) return; + std::vector parts; + std::string p; + for (char c2 : buf) { + if (c2 == ',') { parts.push_back(trim(p)); p.clear(); } + else p.push_back(c2); + } + parts.push_back(trim(p)); + if (parts.size() >= 2) { + DbfTypeSpec ts = vfp ? vfp_type_for(parts[1]) + : dbf_type_for(parts[1]); + FieldOut f; + f.name = parts[0]; + // Each write path enforces its own limit: + // DBF: std::min(name.size(), 10) at the header-write site + // ADT: std::min(name.size(), 127u) at the field-descriptor site + if (f.name.size() > 128) f.name.resize(128); + f.type = ts.type; + f.length = ts.length; + f.dec = ts.dec; + if (vfp && (parts[1] == "AutoInc" || parts[1] == "autoinc")) + f.autoinc = true; + if (parts.size() >= 3) { + int n = std::atoi(parts[2].c_str()); + if (n > 0 && n < 256) f.length = static_cast(n); + } + if (parts.size() >= 4) { + int d = std::atoi(parts[3].c_str()); + if (d >= 0 && d < 256) f.dec = static_cast(d); + } + if (parts.size() >= 5) { + std::string fl = parts[4]; + for (char& ch : fl) { + ch = static_cast(std::toupper( + static_cast(ch))); + } + if (fl == "NULL" || fl == "NULLABLE") f.nullable = true; + else if (fl == "AUTOINC" || fl == "AUTO") f.autoinc = true; + } + if (f.length == 0) f.length = 10; + fields.push_back(std::move(f)); + } + buf.clear(); + }; + for (std::size_t i = 0; i <= defs.size(); ++i) { + char ch = (i < defs.size()) ? defs[i] : ';'; + if (ch == ';') flush(); + else buf.push_back(ch); + } + return fields; +} + +// ADT field spec: ADT type code + fixed storage length for the creation path. +struct AdtFieldSpec { + std::uint16_t adt_type; + std::uint16_t adt_length; + std::uint8_t adt_dec; + bool needs_memo; +}; + +AdtFieldSpec adt_spec_for(const FieldOut& f) { + switch (f.type) { + case 'L': return { 1, 1, 0, false}; // LOGICAL + case 'D': return { 3, 4, 0, false}; // DATE (JDN uint32) + case 'M': return { 5, 9, 0, true }; // MEMO (9-byte ref) + case 'Q': return { 6, 9, 0, true }; // BINARY (9-byte ref) + case 'I': return { 7, 9, 0, true }; // IMAGE (9-byte ref) + case 'N': + if (f.dec > 0) return {10, 8, f.dec, false}; // DOUBLE + // INTEGER is a 4-byte int32, so it only holds up to 2,147,483,647. + // Mapping every decimal-less numeric to it silently destroyed any + // wider value: a DBF N(19,0) holding 5,000,000,000 read back as 0 + // -- not rounded, zero, with nothing to signal it. Only take that + // path when the declared width cannot overflow (9 digits max out at + // 999,999,999); anything wider goes to DOUBLE, which is exact for + // integers up to 2^53 (~9.0e15) and covers every realistic value in + // such a field. + if (f.length <= 9) return {11, 4, 0, false}; // INTEGER + return {10, 8, 0, false}; // DOUBLE + // ADT-specific sentinels from dbf_type_for: + case 'W': return {20, static_cast(f.length ? f.length : 10u), + 0, false}; // CICHARACTER + case 'S': return {12, 2, 0, false}; // SHORTINT + case '$': return {18, 8, 0, false}; // MONEY (int64 * 10000) + case 'P': return {14, 8, 0, false}; // TIMESTAMP (JDN+ms) + case 'A': return {15, 4, 0, false}; // AUTOINC + case 'Z': return {13, 4, 0, false}; // TIME (ms since midnight) + case '#': return { 2, static_cast(f.length ? f.length : 10u), + f.dec, false}; // NUMERIC as ASCII digits + case 'C': + default: + return { 4, static_cast(f.length ? f.length : 10u), + 0, false}; // CHAR + } +} + +} // namespace +} // extern "C++" + +// RCB 2026-07-10 -- usCharType is now honoured: it is forwarded to the +// AdsOpenTable call that hands the created table back, so an ADS_OEM +// creation gets its tags built under the configured OEM collation +// (#130 follow-up). Don't re-comment the parameter out. +UNSIGNED32 ENTRYPOINT AdsCreateTable(ADSHANDLE hConn, + UNSIGNED8* pucName, + UNSIGNED8* pucAlias, + UNSIGNED16 usTableType, + UNSIGNED16 usCharType, + UNSIGNED16 /*usLockType*/, + UNSIGNED16 /*usCheckRights*/, + UNSIGNED16 usMemoBlockSize, + UNSIGNED8* pucFields, + ADSHANDLE* phTable) { + arc2_trace("AdsCreateTable"); + if (pucName == nullptr || pucFields == nullptr || phTable == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "null arg"); + } + const auto rel = openads::abi::to_internal(pucName, 0); + const auto defs = openads::abi::to_internal(pucFields, 0); + const bool is_vfp = (usTableType == ADS_VFP); + auto fields = parse_rddads_field_defs(defs, is_vfp); + if (fields.empty()) { + return fail(openads::AE_INTERNAL_ERROR, "no fields"); + } + + std::vector ddl_cols; + ddl_cols.reserve(fields.size()); + for (const auto& f : fields) { + ddl_cols.push_back({f.name, f.type, f.length, f.dec}); + } + auto sql_open_created = [&](ADSHANDLE conn_h) -> UNSIGNED32 { + std::vector namebuf(rel.size() + 1, 0); + std::memcpy(namebuf.data(), rel.data(), rel.size()); + return AdsOpenTable(conn_h, namebuf.data(), pucAlias, + ADS_CDX, usCharType, 0, 0, 1, phTable); + }; + auto run_sql_ddl = [&](auto* conn, + openads::sql_backend::SqlDdlDialect dialect) + -> UNSIGNED32 { + auto ddl = openads::sql_backend::build_create_table_ddl( + dialect, rel, ddl_cols); + if (!ddl) return fail(ddl.error()); + auto ex = conn->exec_sql(ddl.value()); + if (!ex) return fail(ex.error()); + return sql_open_created(hConn); + }; +#if defined(OPENADS_WITH_SQLITE) + if (auto* sc = get_sqlite_conn(hConn)) { + return run_sql_ddl(sc, openads::sql_backend::SqlDdlDialect::Sqlite); + } +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* pc = get_postgres_conn(hConn)) { + return run_sql_ddl(pc, openads::sql_backend::SqlDdlDialect::Postgres); + } +#endif +#if defined(OPENADS_WITH_MARIADB) + if (auto* mc = get_maria_conn(hConn)) { + return run_sql_ddl(mc, openads::sql_backend::SqlDdlDialect::Maria); + } +#endif +#if defined(OPENADS_WITH_ODBC) + if (auto* oc = get_odbc_conn(hConn)) { + return run_sql_ddl(oc, odbc_dialect_for(hConn)); + } +#endif +#if defined(OPENADS_WITH_FIREBIRD) + if (auto* fc = get_firebird_conn(hConn)) { + return run_sql_ddl(fc, openads::sql_backend::SqlDdlDialect::Firebird); + } +#endif +#if defined(OPENADS_WITH_MSSQL) + if (auto* msc = get_mssql_conn(hConn)) { + return run_sql_ddl(msc, openads::sql_backend::SqlDdlDialect::Mssql); + } +#endif + + // Remote connection: create on the server data directory over the wire. + // Without this branch AdsCreateTable falls through to a local cwd + // Connection (get_or_create_default_connection), so DbCreate / rddads + // writes the .dbf next to the client app while the subsequent open + // (remote AdsOpenTable) fails with AE_TABLE_CORRUPTED (5103). + // Same local-handle guard as AdsOpenTable: a valid local Connection + // (server ABI twin) must not be hijacked by an in-process tcp:// + // client connection. + { + ADSHANDLE rc_h = 0; + if (get_remote_connection(hConn) != nullptr) { + rc_h = hConn; + } else if (state().registry.lookup( + hConn, HandleKind::Connection) == nullptr) { + rc_h = resolve_remote_conn_handle(hConn); + } + if (auto* rc = get_remote_connection(rc_h)) { + // Create-over-existing must see closed files (SAP: overwrite + // when closed, 7040 when open) — a parked handle reads as open. + remote_flush_pools(rc); + auto cr = rc->create_table(rel, defs, + static_cast(usTableType), + static_cast(usCharType), + static_cast(usMemoBlockSize)); + if (!cr) return fail(cr.error()); + // Re-open via the normal remote path so production-bag auto-open + // and the implicit GotoTop match AdsOpenTable semantics. + std::vector namebuf(rel.size() + 1, 0); + if (!rel.empty()) + std::memcpy(namebuf.data(), rel.data(), rel.size()); + const UNSIGNED16 open_type = + (usTableType == ADS_ADT) ? ADS_ADT + : (usTableType == ADS_VFP) ? ADS_VFP + : ADS_CDX; + return AdsOpenTable(rc_h, namebuf.data(), pucAlias, + open_type, usCharType, 0, 0, 1, phTable); + } + // Explicit handle to a disconnected remote connection with no + // live remote to fall back to: fail fast rather than writing a + // LOCAL file next to the client app. + if (hConn != 0 && rc_h == 0) { + auto& s_dead = state(); + if (s_dead.registry.kind_of(hConn) == + HandleKind::RemoteConnection) { + return fail(openads::AE_NO_CONNECTION, + "connection is closed"); + } + } + } + + auto& s = state(); + Connection* c = s.registry.lookup(hConn, + HandleKind::Connection); + if (c == nullptr) { + ADSHANDLE def = get_or_create_default_connection(); + c = s.registry.lookup(def, HandleKind::Connection); + if (c == nullptr) { + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + } + } + // Same guard as AdsOpenTable: never CREATE a local file silently in + // a remote-only deployment (OPENADS_REMOTE_ONLY_ACCESS). + const int roa_mode = remote_only_access_mode(); + if (roa_mode == 1) { + return fail(openads::AE_ACCESS_DENIED, + "local table create blocked by OPENADS_REMOTE_ONLY_ACCESS"); + } + if (roa_mode == 2) { + openads::util::write_local_access_audit("CREATE", rel); + } + + namespace fs = std::filesystem; + // Resolve the create target through the connection's table resolver so + // the freshly-written file lands in the same place a later + // AdsOpenTable(name) will look for it (the ADS data directory), even + // when the caller passes an absolute / drive-rooted path. + openads::engine::TableType create_type = + (usTableType == ADS_ADT) ? openads::engine::TableType::Adt + : (usTableType == ADS_VFP) ? openads::engine::TableType::Vfp + : openads::engine::TableType::Cdx; + fs::path full(c->resolve_table_file(rel, create_type, + /*for_create=*/true)); + const bool is_adt = (usTableType == ADS_ADT); + if (!full.has_extension()) full.replace_extension(is_adt ? ".adt" : ".dbf"); + + if (is_adt) { + // ── ADT creation path ─────────────────────────────────────────────── + // Respect the file name the caller asked for -- real ACE creates + // exactly that file. Forcing ".adt" breaks any application that keeps + // ADT data under another extension (ExtFile='.DAT' is a common ERP + // convention): COPY TO "CONSEINV.DAT" VIA "ADS" silently produced + // CONSEINV.adt and the application could not find the table it had + // just written. An extension-less name still defaults above. + + std::vector specs; + specs.reserve(fields.size()); + // Header offset 88 holds the count of DISTINCT companion-stream types + // present in the table (memo / binary / image), each stored in the side + // companion file. Stamping a flat 1 whenever any memo field existed made + // a conforming reader reject tables that mix several companion types (it + // expects N distinct streams but the header claims one) -- the table was + // reported as corrupt. Count the distinct types actually present. + bool has_memo = false, has_binary = false, has_image = false; + for (auto& f : fields) { + AdtFieldSpec sp = adt_spec_for(f); + switch (sp.adt_type) { + case ADS_MEMO: has_memo = true; break; // .adm companion + case ADS_BINARY: has_binary = true; break; // .adm companion + case ADS_IMAGE: has_image = true; break; // .adm companion + default: break; + } + specs.push_back(sp); + } + const bool has_companion = has_memo || has_binary || has_image; + + // Record: 5-byte prefix (delete-flag byte + 4-byte null bitmap) + fields + std::uint32_t rec_len = 5; + for (auto& sp : specs) rec_len += sp.adt_length; + if (rec_len > 0xFFFFu) + return fail(openads::AE_INTERNAL_ERROR, "ADT record too long"); + + std::uint32_t hdr_len = 400u + + static_cast(fields.size()) * 200u; + + // 400-byte file header + std::vector adt_hdr(400, 0); + std::memcpy(adt_hdr.data(), "Advantage Table", 15); + auto w32 = [&](std::size_t off, std::uint32_t v) { + adt_hdr[off+0] = static_cast(v); + adt_hdr[off+1] = static_cast(v >> 8); + adt_hdr[off+2] = static_cast(v >> 16); + adt_hdr[off+3] = static_cast(v >> 24); + }; + w32(24, 0); // rec_count = 0 + w32(32, hdr_len); // hdr_len + w32(36, rec_len); // rec_len + // Proprietary header tail observed in reference fixtures. + adt_hdr[20] = 1; + adt_hdr[356] = 4; + adt_hdr[358] = static_cast(fields.size() & 0xFFu); + adt_hdr[359] = static_cast((fields.size() >> 8) & 0xFFu); + adt_hdr[88] = static_cast( + has_memo + has_binary + has_image); + + // 200-byte field descriptors + std::vector fds(fields.size() * 200, 0); + std::uint16_t fld_off = 5; // first field starts after the 5-byte prefix + for (std::size_t i = 0; i < fields.size(); ++i) { + const auto& f = fields[i]; + const auto& sp = specs[i]; + std::uint8_t* fd = fds.data() + i * 200; + // name: null-terminated, bytes 0-127 + std::size_t n = std::min(f.name.size(), 127u); + std::memcpy(fd, f.name.data(), n); + // fd[128] = flags (0 = not nullable) + fd[129] = static_cast(sp.adt_type & 0xFFu); + fd[130] = static_cast((sp.adt_type >> 8) & 0xFFu); + fd[131] = static_cast(fld_off & 0xFFu); + fd[132] = static_cast((fld_off >> 8) & 0xFFu); + fd[135] = static_cast(sp.adt_length & 0xFFu); + fd[136] = static_cast((sp.adt_length >> 8) & 0xFFu); + // DOUBLE (type 10) is an IEEE 8-byte binary value: the real ADS + // engine stores NO decimal count in its field descriptor (fd[137] + // and fd[139] stay 0). Stamping the Harbour decimals there made the + // engine reject the whole table as corrupt (error 7016). The value + // is full-precision binary, so dropping the descriptor "decimals" + // is loss-free and matches the ADT on-disk layout a conforming + // reader expects. + // + // For NUMERIC (type 2) the scale goes at byte 139, not 137 -- that + // is where the real engine puts it. Verified byte-for-byte against + // SAP-written tables (mp corpus, service.adt): an N(10,2) column + // reads …135:10 136:0 137:0 138:0 139:2 140:0. We keep writing 137 + // as well so a table written here stays readable by older OpenADS + // builds, which only looked at 137; SAP ignores it. + // + // 139 is also the AUTOINC counter slot, but only for type 15, and + // an autoinc field has no scale -- so the two uses never collide. + // The reader in adt_driver.cpp mirrors this and only consults 139 + // for type 2, precisely so a populated counter is never mistaken + // for a decimal count. + if (sp.adt_type != 10u) + fd[137] = sp.adt_dec; + if (sp.adt_type == 2u) + fd[139] = sp.adt_dec; + // AUTOINC tail (bytes 139-143) stays zero on disk for type 15; + // the counter is seeded from existing data when the table opens. + fld_off = static_cast(fld_off + sp.adt_length); + } + + // Write the .adt file under the per-path create lock with an + // exclusive (share=0) handle: a concurrent opener can never see a + // partial header, and a create over an OPEN table fails 7040 + // instead of truncating it (SAP semantics, verified 2026-08-30). + std::vector adt_file; + adt_file.reserve(adt_hdr.size() + fds.size()); + adt_file.insert(adt_file.end(), adt_hdr.begin(), adt_hdr.end()); + adt_file.insert(adt_file.end(), fds.begin(), fds.end()); + { + std::lock_guard clk( + create_path_mu_for(full.string())); + if (const UNSIGNED32 wrc = write_new_table_file( + full.string(), adt_file, "AdsCreateTable: ADT"); + wrc != openads::AE_SUCCESS) { + return wrc; + } + } + + // Create a companion .adm for MEMO/BINARY/IMAGE fields + if (has_companion) { + fs::path adm = full; + adm.replace_extension(".adm"); + { std::error_code ec; fs::remove(adm, ec); } + auto mr = openads::drivers::adm::AdmMemo::create(adm.string()); + if (!mr) return fail(mr.error()); + } + + // Open via the standard path so the caller gets a usable handle + // Reopen the file that was actually written, which is not + // necessarily .adt (see the ExtFile note above). + std::string rel_adt = full.string(); + UNSIGNED8 adt_namebuf[260] = {0}; + std::size_t adt_nb = std::min(rel_adt.size(), + sizeof(adt_namebuf) - 1); + std::memcpy(adt_namebuf, rel_adt.data(), adt_nb); + return AdsOpenTable(hConn, adt_namebuf, adt_namebuf, + ADS_ADT, usCharType, 0, 0, 1, phTable); + } + + if (is_vfp) { + // ── VFP DBF creation (0x30 / 0x31 / 0x32) ─────────────────────────── + bool has_memo = false; + bool has_autoinc = false; + std::uint16_t nullable_count = 0; + for (const auto& f : fields) { + if (f.type == 'M' || f.type == 'm') has_memo = true; + if (f.autoinc) has_autoinc = true; + if (f.nullable) ++nullable_count; + } + const bool has_null = nullable_count > 0; + const std::uint8_t ver = + has_autoinc ? 0x32 : (has_memo ? 0x31 : 0x30); + + std::uint16_t header_len = static_cast( + 32 + 32 * fields.size() + 1); + std::uint32_t rec_len = 1; + if (has_null) rec_len += 4; + for (const auto& f : fields) rec_len += f.length; + if (rec_len > 0xFFFF) { + return fail(openads::AE_INTERNAL_ERROR, "record too long"); + } + + std::vector hdr(32, 0); + hdr[0] = ver; + stamp_dbf_header_today(hdr.data()); + hdr[8] = static_cast(header_len & 0xFFu); + hdr[9] = static_cast((header_len >> 8) & 0xFFu); + hdr[10] = static_cast(rec_len & 0xFFu); + hdr[11] = static_cast((rec_len >> 8) & 0xFFu); + + std::vector file = hdr; + for (const auto& f : fields) { + std::vector fd(32, 0); + std::size_t n = std::min(f.name.size(), 10); + std::memcpy(fd.data(), f.name.data(), n); + fd[11] = static_cast( + dbf_descriptor_type_char(f.type)); + fd[16] = f.length; + fd[17] = f.dec; + std::uint8_t flags = 0; + if (f.nullable) flags |= 0x02u; + if (f.autoinc) { + flags |= 0x0Cu; + fd[19] = 1; // next value to issue + fd[23] = 1; // step + } + fd[18] = flags; + file.insert(file.end(), fd.begin(), fd.end()); + } + stamp_dbf_field_displacements(file.data() + 32, fields.size(), + has_null ? 5u : 1u); + file.push_back(0x0D); + file.push_back(0x1A); + + { + // See the ADT branch: per-path lock + exclusive create. + std::lock_guard clk( + create_path_mu_for(full.string())); + if (const UNSIGNED32 wrc = write_new_table_file( + full.string(), file, "AdsCreateTable: VFP"); + wrc != openads::AE_SUCCESS) { + return wrc; + } + } + + if (has_memo) { + fs::path fpt = full; + fpt.replace_extension(".fpt"); + { std::error_code ec; fs::remove(fpt, ec); } + std::uint16_t bs = usMemoBlockSize != 0 ? usMemoBlockSize : 512; + auto mr = openads::drivers::fpt::FptMemo::create(fpt.string(), bs); + if (!mr) return fail(mr.error()); + } + + UNSIGNED8 namebuf[260] = {0}; + std::size_t nb = std::min(rel.size(), sizeof(namebuf) - 1); + std::memcpy(namebuf, rel.data(), nb); + return AdsOpenTable(hConn, namebuf, namebuf, + ADS_VFP, usCharType, 0, 0, 1, phTable); + } + + // ── DBF creation path (existing) ──────────────────────────────────────── + // Compute header + record sizes. Harbour DBFCDX uses + // 32 + 32*n + 2 (the field list ends with a 2-byte 0x0D 0x00 + // terminator); matching it keeps headers byte-identical with a + // Harbour-written DBF of the same schema. + std::uint16_t header_len = static_cast( + 32 + 32 * fields.size() + 2); + std::uint32_t rec_len = 1; // delete-flag byte + for (auto& f : fields) rec_len += f.length; + if (rec_len > 0xFFFF) { + return fail(openads::AE_INTERNAL_ERROR, "record too long"); + } + + // Detect a memo (M) field up front: it drives both the version byte and + // the companion memo file written below. OpenADS writes a FoxPro .fpt + // memo, so per the dBASE/FoxPro format spec a table WITH a memo must + // declare FoxPro 2.x (0xF5) in the version byte. Writing 0x03 (dBASE III + // "no memo") made conforming readers (DBFCDX/DBFNTX) look for a .dbt that + // does not exist and fail to open with subcode 1056; no-memo tables stay + // 0x03. + bool has_memo = false; + for (auto& f : fields) { + if (f.type == 'M' || f.type == 'm') { has_memo = true; break; } + } + + std::vector hdr(32, 0); + hdr[0] = has_memo ? 0xF5 : 0x03; // FoxPro 2.x (FPT) / dBASE III + stamp_dbf_header_today(hdr.data()); // last-update + hdr[4] = 0; hdr[5] = 0; hdr[6] = 0; hdr[7] = 0; // 0 records + hdr[8] = static_cast(header_len & 0xFFu); + hdr[9] = static_cast((header_len >> 8) & 0xFFu); + hdr[10] = static_cast(rec_len & 0xFFu); + hdr[11] = static_cast((rec_len >> 8) & 0xFFu); + + std::vector file = hdr; + for (auto& f : fields) { + std::vector fd(32, 0); + std::size_t n = std::min(f.name.size(), 10); + std::memcpy(fd.data(), f.name.data(), n); + fd[11] = static_cast( + dbf_descriptor_type_char(f.type)); + fd[16] = f.length; + fd[17] = f.dec; + file.insert(file.end(), fd.begin(), fd.end()); + } + stamp_dbf_field_displacements(file.data() + 32, fields.size()); + file.push_back(0x0D); + file.push_back(0x00); + file.push_back(0x1A); + + // Atomic create: per-path lock + exclusive (share=0) handle for the + // whole write. The old fs::remove + ofstream truncate-create let a + // racing creator truncate the DBF underneath open appenders + // (permanent corruption: header count reset while peers kept writing + // records at stale offsets) and let a concurrent AdsOpenTable read a + // partially-written header (5103 "DBF header truncated" storms). + // SAP semantics (verified against SAP ADS 10.10 ace32.dll): create + // over a CLOSED existing table overwrites; create over an OPEN one + // fails 7040 (AE_FILE_IN_USE) leaving the data intact. + { + std::lock_guard clk(create_path_mu_for(full.string())); + if (const UNSIGNED32 wrc = write_new_table_file( + full.string(), file, "AdsCreateTable"); + wrc != openads::AE_SUCCESS) { + return wrc; + } + } + + // If the field list declares any memo (M) field, stage an empty + // .fpt next to the .dbf -- Connection::open_table auto-attaches it, + // and without it any write to the M field fails "memo store not + // attached" (e.g. X#'s ADSRDD on FieldPut to a memo column). + { + if (has_memo) { + fs::path fpt = full; + fpt.replace_extension(".fpt"); + { std::error_code ec; fs::remove(fpt, ec); } + // Default FPT block size 512 (the FoxPro 2.x default). The old + // 64-byte default is a Visual FoxPro value that stricter readers + // reject on open; a conforming reader honours the size from the + // header either way. The caller can still override via + // usMemoBlockSize. + std::uint16_t bs = usMemoBlockSize != 0 ? usMemoBlockSize : 512; + auto mr = openads::drivers::fpt::FptMemo::create(fpt.string(), bs); + if (!mr) return fail(mr.error()); + } + } + + // Open the freshly-created table through the regular path so the + // caller gets a usable handle. + UNSIGNED8 namebuf[260] = {0}; + std::size_t nb = std::min(rel.size(), sizeof(namebuf) - 1); + std::memcpy(namebuf, rel.data(), nb); + return AdsOpenTable(hConn, namebuf, namebuf, + ADS_CDX, // table type + usCharType, 0, 0, 1, // char/lock/checkrights/mode + phTable); +} + +UNSIGNED32 ENTRYPOINT AdsDropTable(ADSHANDLE hConnect, + UNSIGNED8* pucName, + UNSIGNED16 /*usDeleteFiles*/) { + arc2_trace("AdsDropTable"); + if (pucName == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "null table name"); + } + const auto rel = openads::abi::to_internal(pucName, 0); + auto run_sql_drop = [&](auto* conn, + openads::sql_backend::SqlDdlDialect dialect) + -> UNSIGNED32 { + auto ddl = openads::sql_backend::build_drop_table_ddl( + dialect, rel, false); + if (!ddl) return fail(ddl.error()); + auto ex = conn->exec_sql(ddl.value()); + if (!ex) return fail(ex.error()); + return ok(); + }; +#if defined(OPENADS_WITH_SQLITE) + if (auto* sc = get_sqlite_conn(hConnect)) { + return run_sql_drop(sc, openads::sql_backend::SqlDdlDialect::Sqlite); + } +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* pc = get_postgres_conn(hConnect)) { + return run_sql_drop(pc, openads::sql_backend::SqlDdlDialect::Postgres); + } +#endif +#if defined(OPENADS_WITH_MARIADB) + if (auto* mc = get_maria_conn(hConnect)) { + return run_sql_drop(mc, openads::sql_backend::SqlDdlDialect::Maria); + } +#endif +#if defined(OPENADS_WITH_ODBC) + if (auto* oc = get_odbc_conn(hConnect)) { + return run_sql_drop(oc, odbc_dialect_for(hConnect)); + } +#endif +#if defined(OPENADS_WITH_FIREBIRD) + if (auto* fc = get_firebird_conn(hConnect)) { + return run_sql_drop(fc, openads::sql_backend::SqlDdlDialect::Firebird); + } +#endif +#if defined(OPENADS_WITH_MSSQL) + if (auto* msc = get_mssql_conn(hConnect)) { + return run_sql_drop(msc, openads::sql_backend::SqlDdlDialect::Mssql); + } +#endif + + { + // Same local-handle guard as AdsCreateTable/AdsOpenTable: a + // valid local Connection (the in-process server's ABI twin) must + // not be hijacked by an in-process tcp:// client connection via + // the any-remote fallback -- that desynced the wire protocol + // (server thread writing a request into the client's own + // socket), so a remote AdsDropTable died with recv() failed + // (abi_remote_create_table_test). + ADSHANDLE rc_h = 0; + if (get_remote_connection(hConnect) != nullptr) { + rc_h = hConnect; + } else if (state().registry.lookup( + hConnect, HandleKind::Connection) == nullptr) { + rc_h = resolve_remote_conn_handle(hConnect); + } + if (auto* rc = get_remote_connection(rc_h)) { + // Parked handles pin files server-side; drop must really drop. + remote_flush_pools(rc); + auto dr = rc->drop_table(rel, /*delete_files=*/1); + if (!dr) return fail(dr.error()); + return ok(); + } + if (hConnect != 0 && rc_h == 0) { + auto& s_dead = state(); + if (s_dead.registry.kind_of(hConnect) == + HandleKind::RemoteConnection) { + return fail(openads::AE_NO_CONNECTION, + "connection is closed"); + } + } + } + + auto& s = state(); + Connection* c = s.registry.lookup(hConnect, + HandleKind::Connection); + if (c == nullptr) { + ADSHANDLE def = get_or_create_default_connection(); + c = s.registry.lookup(def, HandleKind::Connection); + if (c == nullptr) { + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + } + } + namespace fs = std::filesystem; + fs::path full = fs::path(c->data_dir()) / rel; + if (!full.has_extension()) full.replace_extension(".dbf"); + if (!fs::exists(full)) { + return fail(openads::AE_NO_FILE_FOUND, rel.c_str()); + } + std::error_code ec; + fs::remove(full, ec); + auto cdx = full; cdx.replace_extension(".cdx"); + fs::remove(cdx, ec); + auto fpt = full; fpt.replace_extension(".fpt"); + fs::remove(fpt, ec); + auto dbt = full; dbt.replace_extension(".dbt"); + fs::remove(dbt, ec); + return ok(); +} + +// --- M9.26 AdsRestructureTable (ADD-only) ---------------------------------- +// +// Real ACE rebuilds the DBF with three field-def strings -- add, +// delete, and change. The most common rddads call site only feeds +// the "add" list (`pucDeleteFields` / `pucChangeFields` empty), which +// is what 0.2.x supports. Non-empty delete / change lists return +// AE_FUNCTION_NOT_AVAILABLE until the 0.3.x VFP / ADT structural +// extensions land. +// +// Indexes are NOT auto-rebuilt (real ACE handles that internally). +// Apps that depend on a bound index after a restructure should +// follow up with AdsReindex; the on-disk record format changed, so +// stale entries point at the wrong recnos. + +UNSIGNED32 ENTRYPOINT AdsRestructureTable(ADSHANDLE hConnect, + UNSIGNED8* pucTableName, + UNSIGNED8* /*pucAlias*/, + UNSIGNED16 /*usFileType*/, + UNSIGNED16 /*usCharType*/, + UNSIGNED16 /*usLockType*/, + UNSIGNED16 /*usCheckRights*/, + UNSIGNED8* pucAddFields, + UNSIGNED8* pucDeleteFields, + UNSIGNED8* pucChangeFields) { + arc2_trace("AdsRestructureTable"); + if (pucTableName == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, + "AdsRestructureTable: null table name"); + } + auto del = pucDeleteFields ? openads::abi::to_internal(pucDeleteFields, 0) + : std::string(); + auto chg = pucChangeFields ? openads::abi::to_internal(pucChangeFields, 0) + : std::string(); + + auto add = pucAddFields ? openads::abi::to_internal(pucAddFields, 0) + : std::string(); + auto add_fields = parse_rddads_field_defs(add); + + // CHANGE list (M10.12): same shape as ADD (NAME,Type,Len,Dec;…). + // Each entry replaces the same-named existing field's length / + // decimals. The Type must match the existing field -- type + // conversion (rename / retype) needs a clean-room ADS spec and + // stays deferred. Apps that need it can issue DELETE + ADD. + auto change_fields = parse_rddads_field_defs(chg); + std::unordered_map change_map; + for (auto& cf : change_fields) { + change_map[cf.name] = cf; + } + + // DELETE list is a `;`-separated list of bare field names -- + // unlike pucAddFields the entries carry no type / len info. + std::unordered_set del_set; + { + std::string buf; + auto flush = [&] { + std::string trimmed = buf; + while (!trimmed.empty() && + std::isspace(static_cast(trimmed.front()))) { + trimmed.erase(trimmed.begin()); + } + while (!trimmed.empty() && + std::isspace(static_cast(trimmed.back()))) { + trimmed.pop_back(); + } + if (!trimmed.empty()) del_set.insert(trimmed); + buf.clear(); + }; + for (std::size_t i = 0; i <= del.size(); ++i) { + char ch = (i < del.size()) ? del[i] : ';'; + if (ch == ';' || ch == ',') flush(); + else buf.push_back(ch); + } + } + + if (add_fields.empty() && del_set.empty() && change_fields.empty()) { + return ok(); // nothing to do + } + + auto rel = openads::abi::to_internal(pucTableName, 0); + + { + std::vector add_cols; + add_cols.reserve(add_fields.size()); + for (const auto& f : add_fields) { + add_cols.push_back({f.name, f.type, f.length, f.dec}); + } + std::vector chg_cols; + chg_cols.reserve(change_fields.size()); + for (const auto& f : change_fields) { + chg_cols.push_back({f.name, f.type, f.length, f.dec}); + } + std::vector drop_cols(del_set.begin(), del_set.end()); + + auto run_sql_restructure = + [&](auto* conn, openads::sql_backend::SqlDdlDialect dialect, + const std::vector& chg) + -> UNSIGNED32 { + auto exec_vec = [&](const std::vector& stmts) + -> UNSIGNED32 { + for (const auto& sql : stmts) { + auto ex = conn->exec_sql(sql); + if (!ex) return fail(ex.error()); + } + return ok(); + }; + if (!add_cols.empty()) { + auto ddl = openads::sql_backend::build_alter_table_add_ddl( + dialect, rel, add_cols); + if (!ddl) return fail(ddl.error()); + auto rc = exec_vec(ddl.value()); + if (rc != openads::AE_SUCCESS) return rc; + } + if (!chg.empty()) { + auto ddl = openads::sql_backend::build_alter_table_change_ddl( + dialect, rel, chg); + if (!ddl) return fail(ddl.error()); + auto rc = exec_vec(ddl.value()); + if (rc != openads::AE_SUCCESS) return rc; + } + if (!drop_cols.empty()) { + auto ddl = openads::sql_backend::build_alter_table_drop_ddl( + dialect, rel, drop_cols); + if (!ddl) return fail(ddl.error()); + auto rc = exec_vec(ddl.value()); + if (rc != openads::AE_SUCCESS) return rc; + } + return ok(); + }; +#if defined(OPENADS_WITH_SQLITE) + if (auto* sc = get_sqlite_conn(hConnect)) { + if (!chg_cols.empty()) { + if (auto r = sc->restructure_change(rel, chg_cols); !r) { + return fail(r.error()); + } + } + return run_sql_restructure(sc, + openads::sql_backend::SqlDdlDialect::Sqlite, {}); + } +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* pc = get_postgres_conn(hConnect)) { + return run_sql_restructure(pc, + openads::sql_backend::SqlDdlDialect::Postgres, chg_cols); + } +#endif +#if defined(OPENADS_WITH_MARIADB) + if (auto* mc = get_maria_conn(hConnect)) { + return run_sql_restructure(mc, + openads::sql_backend::SqlDdlDialect::Maria, chg_cols); + } +#endif +#if defined(OPENADS_WITH_ODBC) + if (auto* oc = get_odbc_conn(hConnect)) { + return run_sql_restructure(oc, odbc_dialect_for(hConnect), chg_cols); + } +#endif +#if defined(OPENADS_WITH_FIREBIRD) + if (auto* fc = get_firebird_conn(hConnect)) { + return run_sql_restructure(fc, + openads::sql_backend::SqlDdlDialect::Firebird, chg_cols); + } +#endif +#if defined(OPENADS_WITH_MSSQL) + if (auto* msc = get_mssql_conn(hConnect)) { + return run_sql_restructure(msc, + openads::sql_backend::SqlDdlDialect::Mssql, chg_cols); + } +#endif + } + + auto& s = state(); + Connection* c = s.registry.lookup(hConnect, HandleKind::Connection); + if (c == nullptr) { + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + } + + namespace fs = std::filesystem; + fs::path full = fs::path(c->data_dir()) / rel; + if (!full.has_extension()) full.replace_extension(".dbf"); + + fs::path tmp = full; + tmp += ".restructure.tmp"; + { + std::error_code ec; + fs::remove(tmp, ec); + } + + // Read the source schema + record bytes inside an inner scope so + // the engine's File handle on `full` is closed before the rename. + { + auto opened = openads::engine::Table::open( + full.string(), openads::engine::TableType::Cdx, + openads::engine::OpenMode::Read); + if (!opened) return fail(opened.error()); + auto& t = opened.value(); + + // Per-field copy plan: keep the source order, drop fields that + // appear in the DELETE list, apply the CHANGE list's + // length/decimals overrides for matching surviving fields, + // then append the ADD list. Each surviving field tracks where + // to copy from in the old record (or no source for newly-added + // columns). + struct PerField { + FieldOut descriptor; + bool from_old = false; + std::uint16_t old_offset = 0; + std::uint8_t old_length = 0; + char old_type = '\0'; // non-'\0' → type conversion + char new_type = '\0'; // target raw type + }; + std::vector plan; + for (std::uint16_t i = 0; i < t.field_count(); ++i) { + const auto& src = t.field_descriptor(i); + if (del_set.find(src.name) != del_set.end()) continue; + PerField p; + p.descriptor.name = src.name; + p.descriptor.type = src.raw_type; + p.descriptor.length = static_cast(src.length); + p.descriptor.dec = src.decimals; + p.from_old = true; + p.old_offset = src.record_offset; + p.old_length = static_cast(src.length); + + auto cit = change_map.find(src.name); + if (cit != change_map.end()) { + if (cit->second.type != src.raw_type) { + p.old_type = src.raw_type; + p.new_type = cit->second.type; + p.descriptor.type = cit->second.type; + } + p.descriptor.length = cit->second.length; + p.descriptor.dec = cit->second.dec; + } + plan.push_back(std::move(p)); + } + for (auto& nf : add_fields) { + for (auto& existing : plan) { + if (existing.descriptor.name == nf.name) { + return fail(openads::AE_INTERNAL_ERROR, + "AdsRestructureTable: duplicate field name"); + } + } + PerField p; + p.descriptor = nf; + p.from_old = false; + plan.push_back(std::move(p)); + } + if (plan.empty()) { + return fail(openads::AE_INTERNAL_ERROR, + "AdsRestructureTable: every field deleted " + "without an ADD -- would leave the table empty"); + } + std::vector merged; + merged.reserve(plan.size()); + for (auto& p : plan) merged.push_back(p.descriptor); + + std::uint16_t header_len = static_cast( + 32 + 32 * merged.size() + 2); + std::uint32_t rec_len = 1; + for (auto& f : merged) rec_len += f.length; + if (rec_len > 0xFFFF) { + return fail(openads::AE_INTERNAL_ERROR, + "AdsRestructureTable: record exceeds 64 KiB"); + } + + std::vector hdr(32, 0); + hdr[0] = 0x03; + stamp_dbf_header_today(hdr.data()); + std::uint32_t rcount = t.record_count(); + hdr[4] = static_cast( rcount & 0xFFu); + hdr[5] = static_cast((rcount >> 8) & 0xFFu); + hdr[6] = static_cast((rcount >> 16) & 0xFFu); + hdr[7] = static_cast((rcount >> 24) & 0xFFu); + hdr[8] = static_cast(header_len & 0xFFu); + hdr[9] = static_cast((header_len >> 8) & 0xFFu); + hdr[10] = static_cast(rec_len & 0xFFu); + hdr[11] = static_cast((rec_len >> 8) & 0xFFu); + + std::vector file_bytes = hdr; + for (auto& f : merged) { + std::vector fd(32, 0); + std::size_t n = std::min(f.name.size(), 10); + std::memcpy(fd.data(), f.name.data(), n); + fd[11] = static_cast( + dbf_descriptor_type_char(f.type)); + fd[16] = f.length; + fd[17] = f.dec; + file_bytes.insert(file_bytes.end(), fd.begin(), fd.end()); + } + stamp_dbf_field_displacements(file_bytes.data() + 32, merged.size()); + file_bytes.push_back(0x0D); + file_bytes.push_back(0x00); + + std::uint16_t old_rec_len = t.driver()->record_length(); + for (std::uint32_t r = 1; r <= rcount; ++r) { + auto rec = t.driver()->read_record_raw(r); + if (!rec) return fail(rec.error()); + std::vector old_buf = std::move(rec).value(); + + std::vector new_buf(rec_len, ' '); + new_buf[0] = old_buf.empty() ? ' ' : old_buf[0]; + std::uint16_t out_off = 1; + for (auto& p : plan) { + if (p.from_old && p.new_type != '\0') { + // Type conversion: read old bytes, convert, write new. + std::string old_data(p.old_length, ' '); + if (old_buf.size() >= static_cast(p.old_offset) + + p.old_length) { + std::memcpy(old_data.data(), + old_buf.data() + p.old_offset, p.old_length); + } + const std::uint8_t nlen = p.descriptor.length; + const std::uint8_t ndec = p.descriptor.dec; + if (p.old_type == 'C' && p.new_type == 'N') { + auto first = old_data.find_first_not_of(' '); + const char* src_ptr = (first == std::string::npos) + ? "" : old_data.c_str() + first; + double val = *src_ptr ? std::strtod(src_ptr, nullptr) : 0.0; + char fmt[32]; + std::snprintf(fmt, sizeof(fmt), "%%%u.%uf", + static_cast(nlen), + static_cast(ndec)); + char nb[64] = {}; + std::snprintf(nb, sizeof(nb), fmt, val); + std::size_t slen = std::strlen(nb); + std::size_t copy = std::min(slen, nlen); + std::size_t soff = slen > nlen ? slen - nlen : 0u; + std::memcpy(new_buf.data() + out_off, nb + soff, copy); + } else if (p.old_type == 'N' && p.new_type == 'C') { + auto first = old_data.find_first_not_of(' '); + if (first != std::string::npos) { + std::size_t copy = std::min( + old_data.size() - first, + static_cast(nlen)); + std::memcpy(new_buf.data() + out_off, + old_data.data() + first, copy); + } + } else if (p.new_type == 'L') { + char lval = 'F'; + if (p.old_type == 'C') { + char ch = old_data.empty() ? ' ' : old_data[0]; + if (ch=='T'||ch=='t'||ch=='Y'||ch=='y'||ch=='1') lval='T'; + } else if (p.old_type == 'N') { + auto f2 = old_data.find_first_not_of(' '); + if (f2 != std::string::npos && + std::strtod(old_data.c_str() + f2, nullptr) != 0.0) + lval = 'T'; + } + new_buf[out_off] = static_cast(lval); + } else if (p.old_type == 'L') { + char lc = old_data.empty() ? 'F' : old_data[0]; + bool is_true = (lc == 'T' || lc == 't'); + if (p.new_type == 'C') { + new_buf[out_off] = + static_cast(is_true ? 'T' : 'F'); + } else if (p.new_type == 'N') { + char fmt[32]; + std::snprintf(fmt, sizeof(fmt), "%%%u.%uf", + static_cast(nlen), + static_cast(ndec)); + char nb[64] = {}; + std::snprintf(nb, sizeof(nb), fmt, is_true ? 1.0 : 0.0); + std::size_t copy = + std::min(std::strlen(nb), nlen); + std::memcpy(new_buf.data() + out_off, nb, copy); + } + } else { + // D↔C and other pairs: raw copy up to min length. + std::uint8_t copy_len = + std::min(p.old_length, nlen); + std::memcpy(new_buf.data() + out_off, + old_data.data(), copy_len); + } + } else if (p.from_old) { + std::uint8_t copy_len = + std::min(p.old_length, + p.descriptor.length); + if (old_buf.size() >= + static_cast(p.old_offset) + + static_cast(copy_len)) { + std::memcpy(new_buf.data() + out_off, + old_buf.data() + p.old_offset, + copy_len); + } + // Tail bytes (when new length > old length) stay + // as the blank-pad already in new_buf. + } + out_off = static_cast( + out_off + p.descriptor.length); + } + (void)old_rec_len; + file_bytes.insert(file_bytes.end(), + new_buf.begin(), new_buf.end()); + } + file_bytes.push_back(0x1A); + + std::ofstream out(tmp, std::ios::binary); + if (!out) return fail(openads::AE_INTERNAL_ERROR, + "AdsRestructureTable: tmp open failed"); + out.write(reinterpret_cast(file_bytes.data()), + static_cast(file_bytes.size())); + if (!out) return fail(openads::AE_INTERNAL_ERROR, + "AdsRestructureTable: tmp write failed"); + } // engine handle on `full` closes here + + { + std::error_code ec; + fs::remove(full, ec); + fs::rename(tmp, full, ec); + if (ec) { + return fail(openads::AE_INTERNAL_ERROR, + "AdsRestructureTable: rename failed"); + } + } + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsRefreshRecord(ADSHANDLE hTable) { + arc2_trace("AdsRefreshRecord"); + if (auto* rt = get_remote_table(hTable)) { + if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; + // Refresh right after a GotoRecord on this handle, with nothing + // at all sent to the server in between (no lock, write, nav or + // read frame) and the cursor still on the row that ack carried: + // the server's GotoRecord had just re-read that row from disk, + // which is all a refresh does. Serve it from that ack. + if (rt->goto_row_fresh && rt->row_valid && rt->conn != nullptr && + rt->goto_row_frame_seq == rt->conn->frame_seq() && + rt->current_recno == rt->goto_row_recno && + rt->cursor_lag == 0 && rt->pending_sets.empty()) { + rt->goto_row_fresh = false; + cli_trace_tbl(rt, "AdsRefreshRecord", "served from GotoRecord ack"); + rt->invalidate_prefetch(); + rt->rec_count_cached = false; + rt->key_count_cached = false; + rt->key_counts.clear(); + return ok(); + } + rt->goto_row_fresh = false; + remote_settle_cursor(rt); // M12.21 option C + rt->row_valid = false; // M12.17 cache invalidation + rt->rec_count_cached = false; + rt->key_count_cached = false; // force fresh record count from server + rt->key_counts.clear(); + // Table-aware overload: the re-read row + bounds + recno ride + // back in the ack and repopulate the caches below (row_valid + // restored by the trailer on new servers; the pre-clear above + // is the old-server fallback). + auto r = rt->conn->refresh_record(rt); + if (!r) return fail(r.error()); + return ok(); + } + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + if (t->eof() || t->bof() || t->recno() == 0) return ok(); + auto r = t->goto_record(t->recno()); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsExtractKey(ADSHANDLE hIndex, UNSIGNED8* pucBuf, + UNSIGNED16* pusLen) { + arc2_trace("AdsExtractKey"); + if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, "null len"); + Table* t = lookup_table_by_index(hIndex); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown index"); + openads::drivers::IIndex* idx = iindex_for_handle(hIndex); + if (!idx) return fail(openads::AE_INTERNAL_ERROR, "index not loaded"); + // Return the key in its STORED encoding -- rddads' OrdKeyVal decodes + // the buffer by key type (HB_ORD2DBL for ADS_NUMERIC), so a numeric + // (FoxNumeric) tag must yield the 8-byte order-preserving binary key, + // not the ASCII expression text (which OrdKeyVal printed as "*****"). + // Mirrors Table::compute_index_key_. + std::string key; + if (idx->key_encoding() == openads::drivers::KeyEncoding::FoxNumeric) { + double d = 0.0; + openads::engine::evaluate_index_expr_number(*t, idx->expression(), d); + key = openads::engine::fox_numeric_key(d); + } else if (idx->key_encoding() == openads::drivers::KeyEncoding::NtxNumeric) { + double d = 0.0; + openads::engine::evaluate_index_expr_number(*t, idx->expression(), d); + key = openads::engine::ntx_numeric_key(d, idx->key_length(), + idx->key_decimals()); + } else { + auto k = openads::engine::evaluate_index_expr(*t, idx->expression(), + idx->key_length()); + if (!k) return fail(k.error()); + key = std::move(k).value(); + } + openads::abi::copy_to_caller(pucBuf, pusLen, key); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsGotoRecord(ADSHANDLE hTable, UNSIGNED32 ulRecord) { + arc2_trace("AdsGotoRecord"); + if (auto* rt = get_remote_table(hTable)) { + cli_trace_tbl(rt, "AdsGotoRecord", "want=%u row_valid=%d", + ulRecord, (int)rt->row_valid); + if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; + if (UNSIGNED32 frc = remote_close_parked_indexes(rt); frc != 0) { + return frc; + } + rt->found_cached = true; rt->current_found = false; // M12.21: GoTo clears Found() + remote_clear_nav_boundaries(rt); + // M12.29 -- preserve keyno_valid when navigating to the same record. + // FWH xbrowse paint saves RecNo(), skips through visible rows, then + // GotoRecord(bookmark). The restore-same-record case was destroying + // keyno_valid unconditionally, triggering an O(n) remote_measure_keyno + // walk on the very next AdsGetRelKeyPos call (~22 sec for 9500 records). + const std::uint32_t prev_recno = + rt->row_valid ? rt->current_recno : 0u; + // GO 0 on a table the server certified physically EMPTY with the + // cursor already in the empty (BOF+EOF, no row) state: the server + // keeps that state (Table::goto_record preserves the phantom + // position on GO 0 even if another station appended meanwhile) + // and would send back exactly the bounds/recno we already hold. + // Answer it locally. GO n>0 always goes to the wire (a peer may + // have appended record n). Only for record count 0: on a + // non-empty table GO 0 moves the server's engine cursor. + const bool empty_goto_exact = + ulRecord == 0u && + remote_cursor_proven_empty(rt) && + rt->count_bound_ok && rt->count_bound == 0 && + rt->count_bound_seq == rt->conn->nav_seq(); + const bool empty_goto = + empty_goto_exact || remote_empty_window(rt); + // mtfix12 R2 — self-GotoRecord: the app re-issues GotoRecord + // for the recno the server cursor already sits on (FWH xBrowse + // bookmark restore). Serve locally when the certified anchor + // IS the wanted recno, the row cache holds that very row, no + // prefetch drain is outstanding (lag 0: client logical == + // server cursor), and the freshness envelope holds — + // conn-wide by default, per-table under the explicit opt-in. + // The serve is byte-identical to the wire ack: same row (cache), + // same position (anchor), lag stays 0, keyno preserved below + // (ulRecord == prev_recno), duplicate-nav stamp expired as a + // real frame would. + const bool self_goto = + ulRecord != 0u && rt->row_valid && + rt->current_recno == ulRecord && rt->cursor_lag == 0 && + rt->anchor_ok && rt->anchor_recno == ulRecord && + rt->pending_sets.empty() && + (remote_self_goto_per_table() + ? rt->anchor_tbl_seq == rt->conn->tbl_nav_seq(rt->id) + : rt->anchor_seq == rt->conn->nav_seq()); + rt->goto_row_fresh = false; + if (self_goto) { + cli_trace_tbl(rt, "AdsGotoRecord", "served locally (self-goto)"); + // A wire GotoRecord ack carries the row but NO lookahead + // block, and its trailer parse clears the queue — mirror + // that exactly, or a following Skip drains rows a real + // reposition would have discarded (the ramp test caught + // this: the skip never reached the wire). + rt->invalidate_prefetch(); + rt->goto_row_fresh = true; + rt->goto_row_frame_seq = rt->conn->frame_seq(); + rt->goto_row_recno = rt->current_recno; + rt->last_nav = 0; // a real frame would have expired it + } else if (empty_goto) { + if (empty_goto_exact) { + cli_trace_tbl(rt, "AdsGotoRecord", "served locally (empty table)"); + rt->invalidate_prefetch(); + // A real frame would have expired the duplicate-nav stamp. + rt->last_nav = 0; + rt->pair_valid = false; + rt->anchor_ok = false; // landed on no row + } else { + cli_trace_tbl(rt, "AdsGotoRecord", + "empty window: served locally want=%u", + ulRecord); + remote_empty_restamp(rt); + } + } else { + auto r = rt->conn->goto_record(rt, ulRecord); + if (!r) return fail(r.error()); + if (rt->row_valid) { + rt->goto_row_fresh = true; + rt->goto_row_frame_seq = rt->conn->frame_seq(); + rt->goto_row_recno = rt->current_recno; + } + } + if (remote_table_has_index(rt)) { + // Only invalidate when the cursor actually moved: same-record + // restores (the xbrowse common case) keep the cached key number. + if (ulRecord != prev_recno) { + rt->keyno_valid = false; + } + } else if (ulRecord > 0u) { + rt->current_keyno = ulRecord; + rt->keyno_valid = true; + } else { + rt->keyno_valid = false; + } + apply_relations_for_handle(hTable); + return ok(); + } + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + auto r = t->goto_record(ulRecord); + if (!r) return fail(r.error()); + snapshot_ri_pks(t); + apply_relations_for_handle(hTable); + return ok(); +} + +// These filesystem helpers live inside the file-scope `extern "C"` block +// (opened far above for the ACE ABI exports). An anonymous namespace does +// NOT reset language linkage, so without this `extern "C++"` their +// functions would inherit C linkage and clang -Werror,-Wreturn-type-c-linkage +// rejects the ones returning C++ types (FsConnCtx / std::optional / map&). +extern "C++" { +namespace { + +// Resolve hConn (0 = rddads default) to remote connection or local +// Connection and jail-resolve a client path under that data directory. +struct FsConnCtx { + openads::network::RemoteConnection* remote = nullptr; + Connection* local = nullptr; + std::string data_dir; +}; + +FsConnCtx resolve_fs_conn(ADSHANDLE hConn) { + FsConnCtx c; + ADSHANDLE h = hConn; + if (h == 0) h = rddads_default_connection(); + if (h == 0) h = get_or_create_default_connection(); + c.remote = get_remote_connection(h); + if (c.remote) return c; + c.local = lookup_connection(h); + if (c.local) c.data_dir = c.local->data_dir(); + return c; +} + +std::optional jail_local(const FsConnCtx& c, + const std::string& name) { + if (c.data_dir.empty()) return std::nullopt; + return openads::platform::resolve_fs_path(c.data_dir, name); +} + +struct RemoteFileRec { + openads::network::RemoteConnection* conn = nullptr; + std::uint32_t file_id = 0; +}; + +std::unordered_map>& +remote_files_map() { + static std::unordered_map> m; + return m; +} + +std::unordered_map>& +local_files_map() { + static std::unordered_map> m; + return m; +} + +} // namespace +} // extern "C++" -- fs helpers regain C++ linkage inside the ABI block + +UNSIGNED32 ENTRYPOINT AdsCheckExistence(ADSHANDLE hConn, UNSIGNED8* pucName, + UNSIGNED16* pbExists) { + arc2_trace("AdsCheckExistence"); + if (pbExists == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "null out"); + } + *pbExists = 0; + if (pucName == nullptr) return ok(); + auto name = openads::abi::to_internal(pucName, 0); + auto ctx = resolve_fs_conn(hConn); + if (ctx.remote) { + // Open-bag short-circuit (per-USE VouExistIndex probes): a bag + // bound by any live table on this connection trivially exists + // — the app is reading through it. Matched by lowercased stem + // (no directory, no extension; .cdx/.z01 are equivalent + // production spellings). False positives are safe (a real + // open follows and errors properly); false negatives never + // happen here. + auto stem_of = [](const std::string& p) { + std::string b = p; + auto sep = b.find_last_of("/\\"); + if (sep != std::string::npos) b = b.substr(sep + 1); + auto dot = b.find_last_of('.'); + if (dot != std::string::npos) b = b.substr(0, dot); + for (auto& c : b) + c = static_cast(std::tolower( + static_cast(c))); + return b; + }; + const std::string want = stem_of(name); + if (!want.empty()) { + // Shared store: hold s.mu for the scan (memory only, no wire). + std::lock_guard lk_store(state().mu); + for (auto& kv : remote_table_store()) { + auto* rt = kv.first; + if (rt == nullptr || rt->conn != ctx.remote) continue; + if (stem_of(rt->prod_bag_path) == want || + (!rt->last_open_bag.empty() && + stem_of(rt->last_open_bag) == want) || + stem_of(rt->name) == want) { + // rt->name is the table's own path as opened: a + // live (or parked) table proves its dbf exists. + *pbExists = 1; + return ok(); + } + } + } + int fe_src = 2; + auto r = ctx.remote->file_exists(name, &fe_src); + if (!r) return fail(r.error()); + // Probe-level visibility: the frame hook only logs wire misses, + // so cache hits used to be invisible in the trace. One line per + // probe with the REAL path (the frame hook's tid is a truncated + // hash that cannot distinguish same-length paths). + cli_trace("FileExists", "probe %s -> %s (%s)", name.c_str(), + r.value() ? "yes" : "no", + fe_src == 0 ? "pos-cache" : + fe_src == 1 ? "neg-cache" : "wire"); + *pbExists = r.value() ? 1 : 0; + return ok(); + } + if (!ctx.local) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto abs = jail_local(ctx, name); + if (!abs) return fail(openads::AE_ACCESS_DENIED, "path"); + auto ex = openads::engine::fs_exists(*abs); + if (!ex) return fail(ex.error()); + *pbExists = ex.value() ? 1 : 0; + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsDeleteFile(ADSHANDLE hConn, UNSIGNED8* pucName) { + arc2_trace("AdsDeleteFile"); + if (pucName == nullptr) return fail(openads::AE_INTERNAL_ERROR, "null name"); + auto name = openads::abi::to_internal(pucName, 0); + auto ctx = resolve_fs_conn(hConn); + if (ctx.remote) { + remote_flush_pools(ctx.remote); + remote_invalidate_index_parks(ctx.remote); + auto r = ctx.remote->file_erase(name); + if (!r) return fail(r.error()); + return ok(); + } + if (!ctx.local) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto abs = jail_local(ctx, name); + if (!abs) return fail(openads::AE_ACCESS_DENIED, "path"); + auto r = openads::engine::fs_erase(*abs); + if (!r) return fail(r.error()); + return ok(); +} + +// ---- Server-side backup archiving (OAds_Zip/OAds_UnZip) ------------- +// 0x1F-separated lists in, stats + archive path out. Local and remote +// share Connection::zip_archive/unzip_archive; the wire only carries +// names and numbers (archives never cross it). +// +// NOTE: this file is inside a file-scope extern "C" block (ACE ABI); +// the helper below needs C++ linkage like the FsConnCtx block above. +extern "C++" { +namespace { + +std::vector split_1f_list(const std::string& blob) { + std::vector v; + std::string cur; + for (char c : blob) { + if (c == '\x1F') { + if (!cur.empty()) v.push_back(std::move(cur)); + cur.clear(); + } else { + cur.push_back(c); + } + } + if (!cur.empty()) v.push_back(std::move(cur)); + return v; +} + +} // namespace +} // extern "C++" (helper ends; ABI exports resume in C) + +UNSIGNED32 ENTRYPOINT AdsZipFiles(ADSHANDLE hConnect, + UNSIGNED8* pucDir, UNSIGNED8* pucFiles, + UNSIGNED8* pucZipName, UNSIGNED16 usLevel, + UNSIGNED16 usOverwrite, UNSIGNED8* pucPassword, + UNSIGNED8* pucExclude, UNSIGNED16 usWithPath, + UNSIGNED8* pucArchive, UNSIGNED16* pusArchiveLen, + UNSIGNED32* pulFiles, UNSIGNED64* pullBytes, + UNSIGNED64* pullArchiveBytes) { + arc2_trace("AdsZipFiles"); + if (!pucDir || !pucFiles || !pucZipName || !pusArchiveLen || + !pulFiles || !pullBytes || !pullArchiveBytes) + return fail(openads::AE_INTERNAL_ERROR, "null arg"); + if (usLevel > 9) + return fail(openads::AE_INTERNAL_ERROR, "level must be 0..9"); + const std::string dir = openads::abi::to_internal(pucDir, 0); + const std::vector files = + split_1f_list(openads::abi::to_internal(pucFiles, 0)); + const std::string zip_name = openads::abi::to_internal(pucZipName, 0); + const std::string password = + pucPassword ? openads::abi::to_internal(pucPassword, 0) + : std::string(); + const std::vector exclude = + pucExclude ? split_1f_list(openads::abi::to_internal(pucExclude, 0)) + : std::vector{}; + auto ctx = resolve_fs_conn(hConnect); + std::uint32_t n = 0; + std::uint64_t nb = 0, ab = 0; + std::string archive; + if (ctx.remote) { + auto r = ctx.remote->zip_archive( + dir, files, zip_name, usLevel, usOverwrite != 0, password, + exclude, usWithPath != 0); + if (!r) return fail(r.error()); + n = r.value().files; + nb = r.value().bytes; + ab = r.value().archive_bytes; + archive = std::move(r.value().archive); + } else { + if (!ctx.local) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto r = ctx.local->zip_archive( + dir, files, zip_name, static_cast(usLevel), + usOverwrite != 0, password, exclude, usWithPath != 0); + if (!r) return fail(r.error()); + n = r.value().stats.files; + nb = r.value().stats.bytes; + ab = r.value().stats.archive_bytes; + archive = std::move(r.value().archive_rel); + } + const UNSIGNED16 cap = *pusArchiveLen; + if (pucArchive != nullptr && cap > 0) { + const std::size_t need = + archive.size() < cap ? archive.size() : cap; + if (need > 0) + std::memcpy(pucArchive, archive.data(), need); + if (need < static_cast(cap)) pucArchive[need] = '\0'; + } + *pusArchiveLen = static_cast(archive.size()); + *pulFiles = n; + *pullBytes = nb; + *pullArchiveBytes = ab; + if (archive.size() > cap) + return fail(openads::AE_INSUFFICIENT_BUFFER, "archive path"); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsUnzipFiles(ADSHANDLE hConnect, + UNSIGNED8* pucDir, UNSIGNED8* pucZip, + UNSIGNED8* pucPassword, UNSIGNED16 usOverwrite, + UNSIGNED16 usWithPath, + UNSIGNED32* pulFiles, UNSIGNED64* pullBytes, + UNSIGNED64* pullArchiveBytes) { + arc2_trace("AdsUnzipFiles"); + if (!pucDir || !pucZip || !pulFiles || !pullBytes || !pullArchiveBytes) + return fail(openads::AE_INTERNAL_ERROR, "null arg"); + const std::string dir = openads::abi::to_internal(pucDir, 0); + const std::string zip = openads::abi::to_internal(pucZip, 0); + const std::string password = + pucPassword ? openads::abi::to_internal(pucPassword, 0) + : std::string(); + auto ctx = resolve_fs_conn(hConnect); + std::uint32_t n = 0; + std::uint64_t nb = 0, ab = 0; + if (ctx.remote) { + auto r = ctx.remote->unzip_archive(dir, zip, password, + usOverwrite != 0, + usWithPath != 0); + if (!r) return fail(r.error()); + n = r.value().files; + nb = r.value().bytes; + ab = r.value().archive_bytes; + } else { + if (!ctx.local) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto r = ctx.local->unzip_archive(dir, zip, password, + usOverwrite != 0, + usWithPath != 0); + if (!r) return fail(r.error()); + n = r.value().files; + nb = r.value().bytes; + ab = r.value().archive_bytes; + } + *pulFiles = n; + *pullBytes = nb; + *pullArchiveBytes = ab; + return ok(); +} + +// Central-directory listing for OAds_ZipFileCount/OAds_ZipFileList. +// Packed entry layout: see engine::pack_zip_entry. Two-pass buffer +// protocol like AdsDirectory (null/short buffer -> INSUFFICIENT and +// the required size); pulCount is optional and always filled with +// the entry count (0 on error paths that get that far). +UNSIGNED32 ENTRYPOINT AdsZipListFiles(ADSHANDLE hConnect, + UNSIGNED8* pucZip, UNSIGNED8* pucBuffer, + UNSIGNED32* pulBufLen, UNSIGNED32* pulCount) { + arc2_trace("AdsZipListFiles"); + if (!pucZip || !pulBufLen) + return fail(openads::AE_INTERNAL_ERROR, "null arg"); + const std::string zip = openads::abi::to_internal(pucZip, 0); + auto ctx = resolve_fs_conn(hConnect); + std::vector entries; + if (ctx.remote) { + auto r = ctx.remote->zip_list(zip); + if (!r) return fail(r.error()); + entries = std::move(r.value().entries); + } else { + if (!ctx.local) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto r = ctx.local->zip_list(zip); + if (!r) return fail(r.error()); + entries = std::move(r.value()); + } + if (entries.size() > 0xFFFFFFu) + return fail(openads::AE_INTERNAL_ERROR, "AdsZipListFiles: too many"); + if (pulCount != nullptr) + *pulCount = static_cast(entries.size()); + std::vector packed; + for (const auto& e : entries) + openads::engine::zip_arch::pack_zip_entry(e, packed); + if (packed.size() > 16u * 1024u * 1024u) + return fail(openads::AE_INTERNAL_ERROR, "AdsZipListFiles: too large"); + UNSIGNED32 need = static_cast(packed.size()); + if (!pucBuffer || *pulBufLen < need) { + *pulBufLen = need; + return fail(openads::AE_INSUFFICIENT_BUFFER, "AdsZipListFiles"); + } + if (need) std::memcpy(pucBuffer, packed.data(), need); + *pulBufLen = need; + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsRenameFile(ADSHANDLE hConn, UNSIGNED8* pucOld, + UNSIGNED8* pucNew) { + arc2_trace("AdsRenameFile"); + if (!pucOld || !pucNew) + return fail(openads::AE_INTERNAL_ERROR, "null name"); + auto o = openads::abi::to_internal(pucOld, 0); + auto n = openads::abi::to_internal(pucNew, 0); + auto ctx = resolve_fs_conn(hConn); + if (ctx.remote) { + remote_flush_pools(ctx.remote); + remote_invalidate_index_parks(ctx.remote); + auto r = ctx.remote->file_rename(o, n); + if (!r) return fail(r.error()); + return ok(); + } + if (!ctx.local) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto a = jail_local(ctx, o); + auto b = jail_local(ctx, n); + if (!a || !b) return fail(openads::AE_ACCESS_DENIED, "path"); + auto r = openads::engine::fs_rename(*a, *b); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsGetFileSize(ADSHANDLE hConn, UNSIGNED8* pucName, + UNSIGNED32* pulSize) { + arc2_trace("AdsGetFileSize"); + if (!pulSize) return fail(openads::AE_INTERNAL_ERROR, "null out"); + *pulSize = 0; + if (!pucName) return fail(openads::AE_INTERNAL_ERROR, "null name"); + auto name = openads::abi::to_internal(pucName, 0); + auto ctx = resolve_fs_conn(hConn); + std::uint64_t sz = 0; + if (ctx.remote) { + auto r = ctx.remote->file_size(name); + if (!r) return fail(r.error()); + sz = r.value(); + } else { + if (!ctx.local) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto abs = jail_local(ctx, name); + if (!abs) return fail(openads::AE_ACCESS_DENIED, "path"); + auto r = openads::engine::fs_size(*abs); + if (!r) return fail(r.error()); + sz = r.value(); + } + if (sz > 0xFFFFFFFFull) + return fail(openads::AE_INTERNAL_ERROR, "file too large for UNSIGNED32"); + *pulSize = static_cast(sz); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsGetFileTime(ADSHANDLE hConn, UNSIGNED8* pucName, + UNSIGNED8* pucTime, UNSIGNED16* pusLen) { + arc2_trace("AdsGetFileTime"); + if (!pucName || !pusLen) + return fail(openads::AE_INTERNAL_ERROR, "null arg"); + auto name = openads::abi::to_internal(pucName, 0); + auto ctx = resolve_fs_conn(hConn); + openads::engine::DirEntry e; + if (ctx.remote) { + auto r = ctx.remote->file_mtime(name); + if (!r) return fail(r.error()); + e = r.value(); + } else { + if (!ctx.local) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto abs = jail_local(ctx, name); + if (!abs) return fail(openads::AE_ACCESS_DENIED, "path"); + auto r = openads::engine::fs_stat_entry(*abs); + if (!r) return fail(r.error()); + e = r.value(); + } + char buf[16]; + std::snprintf(buf, sizeof(buf), "%02u:%02u:%02u", + static_cast(e.hh), + static_cast(e.mm), + static_cast(e.ss)); + UNSIGNED16 need = 9; // "hh:mm:ss\0" + if (!pucTime || *pusLen < need) { + *pusLen = need; + return fail(openads::AE_INSUFFICIENT_BUFFER, "AdsGetFileTime"); + } + std::memcpy(pucTime, buf, need); + *pusLen = need; + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsGetFileDate(ADSHANDLE hConn, UNSIGNED8* pucName, + UNSIGNED8* pucDate, UNSIGNED16* pusLen) { + arc2_trace("AdsGetFileDate"); + if (!pucName || !pusLen) + return fail(openads::AE_INTERNAL_ERROR, "null arg"); + auto name = openads::abi::to_internal(pucName, 0); + auto ctx = resolve_fs_conn(hConn); + openads::engine::DirEntry e; + if (ctx.remote) { + auto r = ctx.remote->file_mtime(name); + if (!r) return fail(r.error()); + e = r.value(); + } else { + if (!ctx.local) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto abs = jail_local(ctx, name); + if (!abs) return fail(openads::AE_ACCESS_DENIED, "path"); + auto r = openads::engine::fs_stat_entry(*abs); + if (!r) return fail(r.error()); + e = r.value(); + } + char buf[16]; + std::snprintf(buf, sizeof(buf), "%04u%02u%02u", + static_cast(e.year), + static_cast(e.mon), + static_cast(e.day)); + UNSIGNED16 need = 9; + if (!pucDate || *pusLen < need) { + *pusLen = need; + return fail(openads::AE_INSUFFICIENT_BUFFER, "AdsGetFileDate"); + } + std::memcpy(pucDate, buf, need); + *pusLen = need; + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsDirectory(ADSHANDLE hConn, UNSIGNED8* pucMask, + UNSIGNED16 /*usAttr*/, UNSIGNED8* pucBuffer, + UNSIGNED32* pulBufLen) { + arc2_trace("AdsDirectory"); + if (!pulBufLen) return fail(openads::AE_INTERNAL_ERROR, "null out"); + auto mask = pucMask ? openads::abi::to_internal(pucMask, 0) : std::string("*"); + auto ctx = resolve_fs_conn(hConn); + std::vector entries; + if (ctx.remote) { + auto r = ctx.remote->directory(mask); + if (!r) return fail(r.error()); + entries = std::move(r.value()); + } else { + if (!ctx.local) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + std::string dir_part = "."; + std::string pat = mask.empty() ? "*" : mask; + auto slash = pat.find_last_of("/\\"); + if (slash != std::string::npos) { + dir_part = pat.substr(0, slash); + if (dir_part.empty()) dir_part = "."; + pat = pat.substr(slash + 1); + if (pat.empty()) pat = "*"; + } + auto abs = jail_local(ctx, dir_part); + if (!abs) return fail(openads::AE_ACCESS_DENIED, "path"); + auto r = openads::engine::fs_directory(*abs, pat); + if (!r) return fail(r.error()); + entries = std::move(r.value()); + } + std::vector packed; + for (const auto& e : entries) + openads::engine::pack_dir_entry(e, packed); + if (packed.size() > 16u * 1024u * 1024u) + return fail(openads::AE_INTERNAL_ERROR, "AdsDirectory: too large"); + UNSIGNED32 need = static_cast(packed.size()); + if (!pucBuffer || *pulBufLen < need) { + *pulBufLen = need; + return fail(openads::AE_INSUFFICIENT_BUFFER, "AdsDirectory"); + } + if (need) std::memcpy(pucBuffer, packed.data(), need); + *pulBufLen = need; + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsDirExist(ADSHANDLE hConn, UNSIGNED8* pucPath, + UNSIGNED16* pbExists) { + arc2_trace("AdsDirExist"); + if (!pbExists) return fail(openads::AE_INTERNAL_ERROR, "null out"); + *pbExists = 0; + if (!pucPath) return ok(); + auto name = openads::abi::to_internal(pucPath, 0); + auto ctx = resolve_fs_conn(hConn); + if (ctx.remote) { + auto r = ctx.remote->dir_exist(name); + if (!r) return fail(r.error()); + *pbExists = r.value() ? 1 : 0; + return ok(); + } + if (!ctx.local) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto abs = jail_local(ctx, name); + if (!abs) return fail(openads::AE_ACCESS_DENIED, "path"); + auto r = openads::engine::fs_dir_exist(*abs); + if (!r) return fail(r.error()); + *pbExists = r.value() ? 1 : 0; + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsDirMake(ADSHANDLE hConn, UNSIGNED8* pucPath) { + arc2_trace("AdsDirMake"); + if (!pucPath) return fail(openads::AE_INTERNAL_ERROR, "null path"); + auto name = openads::abi::to_internal(pucPath, 0); + auto ctx = resolve_fs_conn(hConn); + if (ctx.remote) { + auto r = ctx.remote->dir_make(name); + if (!r) return fail(r.error()); + return ok(); + } + if (!ctx.local) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto abs = jail_local(ctx, name); + if (!abs) return fail(openads::AE_ACCESS_DENIED, "path"); + auto r = openads::engine::fs_dir_make(*abs); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsDirRemove(ADSHANDLE hConn, UNSIGNED8* pucPath) { + arc2_trace("AdsDirRemove"); + if (!pucPath) return fail(openads::AE_INTERNAL_ERROR, "null path"); + auto name = openads::abi::to_internal(pucPath, 0); + auto ctx = resolve_fs_conn(hConn); + if (ctx.remote) { + auto r = ctx.remote->dir_remove(name); + if (!r) return fail(r.error()); + return ok(); + } + if (!ctx.local) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto abs = jail_local(ctx, name); + if (!abs) return fail(openads::AE_ACCESS_DENIED, "path"); + auto r = openads::engine::fs_dir_remove(*abs); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsFOpen(ADSHANDLE hConn, UNSIGNED8* pucName, + UNSIGNED16 usMode, ADSHANDLE* phFile) { + arc2_trace("AdsFOpen"); + if (!pucName || !phFile) + return fail(openads::AE_INTERNAL_ERROR, "null arg"); + *phFile = 0; + auto name = openads::abi::to_internal(pucName, 0); + auto ctx = resolve_fs_conn(hConn); + auto& s = state(); + std::lock_guard lk(s.mu); + if (ctx.remote) { + auto r = ctx.remote->fopen(name, usMode); + if (!r) return fail(r.error()); + auto rec = std::make_unique(); + rec->conn = ctx.remote; + rec->file_id = r.value(); + auto* raw = rec.get(); + Handle h = s.registry.register_object( + openads::session::HandleKind::RemoteFile, raw); + remote_files_map().emplace(to_ads_handle(h), std::move(rec)); + *phFile = to_ads_handle(h); + return ok(); + } + if (!ctx.local) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto abs = jail_local(ctx, name); + if (!abs) return fail(openads::AE_ACCESS_DENIED, "path"); + auto fo = openads::engine::fs_open(*abs, usMode, false); + if (!fo) return fail(fo.error()); + auto* raw = fo.value().get(); + Handle h = s.registry.register_object( + openads::session::HandleKind::LocalFile, raw); + local_files_map().emplace(to_ads_handle(h), std::move(fo.value())); + *phFile = to_ads_handle(h); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsFCreate(ADSHANDLE hConn, UNSIGNED8* pucName, + UNSIGNED16 usAttribute, ADSHANDLE* phFile) { + arc2_trace("AdsFCreate"); + if (!pucName || !phFile) + return fail(openads::AE_INTERNAL_ERROR, "null arg"); + *phFile = 0; + auto name = openads::abi::to_internal(pucName, 0); + auto ctx = resolve_fs_conn(hConn); + auto& s = state(); + std::lock_guard lk(s.mu); + if (ctx.remote) { + auto r = ctx.remote->fcreate(name, usAttribute); + if (!r) return fail(r.error()); + auto rec = std::make_unique(); + rec->conn = ctx.remote; + rec->file_id = r.value(); + auto* raw = rec.get(); + Handle h = s.registry.register_object( + openads::session::HandleKind::RemoteFile, raw); + remote_files_map().emplace(to_ads_handle(h), std::move(rec)); + *phFile = to_ads_handle(h); + return ok(); + } + if (!ctx.local) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto abs = jail_local(ctx, name); + if (!abs) return fail(openads::AE_ACCESS_DENIED, "path"); + auto fo = openads::engine::fs_open( + *abs, openads::engine::ADS_FO_READWRITE, true); + if (!fo) return fail(fo.error()); + auto* raw = fo.value().get(); + Handle h = s.registry.register_object( + openads::session::HandleKind::LocalFile, raw); + local_files_map().emplace(to_ads_handle(h), std::move(fo.value())); + *phFile = to_ads_handle(h); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsFClose(ADSHANDLE hFile) { + arc2_trace("AdsFClose"); + auto& s = state(); + std::lock_guard lk(s.mu); + if (auto* rf = s.registry.lookup( + hFile, openads::session::HandleKind::RemoteFile)) { + auto r = rf->conn->fclose(rf->file_id); + remote_files_map().erase(hFile); + s.registry.release(hFile); + if (!r) return fail(r.error()); + return ok(); + } + if (s.registry.lookup( + hFile, openads::session::HandleKind::LocalFile)) { + local_files_map().erase(hFile); + s.registry.release(hFile); + return ok(); + } + return fail(openads::AE_INTERNAL_ERROR, "bad file handle"); +} + +UNSIGNED32 ENTRYPOINT AdsFRead(ADSHANDLE hFile, void* pBuf, UNSIGNED32 ulLen, + UNSIGNED32* pulRead) { + arc2_trace("AdsFRead"); + if (!pulRead) return fail(openads::AE_INTERNAL_ERROR, "null out"); + *pulRead = 0; + auto& s = state(); + std::lock_guard lk(s.mu); + if (auto* rf = s.registry.lookup( + hFile, openads::session::HandleKind::RemoteFile)) { + auto r = rf->conn->fread(rf->file_id, ulLen); + if (!r) return fail(r.error()); + const auto& bytes = r.value(); + if (pBuf && !bytes.empty()) + std::memcpy(pBuf, bytes.data(), bytes.size()); + *pulRead = static_cast(bytes.size()); + return ok(); + } + if (auto* lf = s.registry.lookup( + hFile, openads::session::HandleKind::LocalFile)) { + if (!pBuf && ulLen) return fail(openads::AE_INTERNAL_ERROR, "null buf"); + lf->stream.read(static_cast(pBuf), + static_cast(ulLen)); + *pulRead = static_cast(lf->stream.gcount()); + return ok(); + } + return fail(openads::AE_INTERNAL_ERROR, "bad file handle"); +} + +UNSIGNED32 ENTRYPOINT AdsFWrite(ADSHANDLE hFile, const void* pBuf, + UNSIGNED32 ulLen, UNSIGNED32* pulWritten) { + arc2_trace("AdsFWrite"); + if (!pulWritten) return fail(openads::AE_INTERNAL_ERROR, "null out"); + *pulWritten = 0; + auto& s = state(); + std::lock_guard lk(s.mu); + if (auto* rf = s.registry.lookup( + hFile, openads::session::HandleKind::RemoteFile)) { + auto r = rf->conn->fwrite( + rf->file_id, static_cast(pBuf), ulLen); + if (!r) return fail(r.error()); + *pulWritten = r.value(); + return ok(); + } + if (auto* lf = s.registry.lookup( + hFile, openads::session::HandleKind::LocalFile)) { + if (!pBuf && ulLen) return fail(openads::AE_INTERNAL_ERROR, "null buf"); + lf->stream.write(static_cast(pBuf), + static_cast(ulLen)); + lf->stream.flush(); + if (!lf->stream) + return fail(openads::AE_INTERNAL_ERROR, "write failed"); + *pulWritten = ulLen; + return ok(); + } + return fail(openads::AE_INTERNAL_ERROR, "bad file handle"); +} + +UNSIGNED32 ENTRYPOINT AdsFSeek(ADSHANDLE hFile, SIGNED32 lOffset, + UNSIGNED16 usOrigin, UNSIGNED32* pulPos) { + arc2_trace("AdsFSeek"); + if (!pulPos) return fail(openads::AE_INTERNAL_ERROR, "null out"); + *pulPos = 0; + auto& s = state(); + std::lock_guard lk(s.mu); + if (auto* rf = s.registry.lookup( + hFile, openads::session::HandleKind::RemoteFile)) { + auto r = rf->conn->fseek(rf->file_id, lOffset, + static_cast(usOrigin)); + if (!r) return fail(r.error()); + *pulPos = r.value(); + return ok(); + } + if (auto* lf = s.registry.lookup( + hFile, openads::session::HandleKind::LocalFile)) { + std::ios::seekdir way = std::ios::beg; + if (usOrigin == 1) way = std::ios::cur; + else if (usOrigin == 2) way = std::ios::end; + lf->stream.clear(); + // std::fstream keeps ONE shared file position for get and put; + // a second seekp with ios::cur would apply the offset AGAIN + // (SEEK_CUR +3 from 2 landed at 8 instead of 5). + lf->stream.seekg(lOffset, way); + *pulPos = static_cast(lf->stream.tellg()); + return ok(); + } + return fail(openads::AE_INTERNAL_ERROR, "bad file handle"); +} + +// oads_*() filesystem aliases moved to abi/adsfunc.c (pure C, standalone). + +// SAP's ace.h declares `AdsCloseAllTables(void)`: close every table +// the calling process has opened. We accept the same 0-arg form; +// per-connection close still works through AdsCloseTable(). +UNSIGNED32 ENTRYPOINT AdsCloseAllTables(void) { + arc2_trace("AdsCloseAllTables"); + auto& s = state(); + std::lock_guard lk(s.mu); + (void)0; + // Walk every Table handle that points to a Table belonging to + // this connection and release it. Connection's tables_ map owns + // the unique_ptrs; the handle registry just borrows pointers. + std::vector to_release; + s.registry.for_each_handle([&](Handle h, HandleKind k, void* p) { + if (k != HandleKind::Table) return; + Table* tp = static_cast(p); + if (tp == nullptr) return; + // Table belongs to this connection if c->lookup_table on its + // handle returns the same pointer. We don't track the + // back-edge directly, so iterate all Connection's table + // handles instead. + (void)tp; + to_release.push_back(h); + }); + for (Handle h : to_release) { + Table* t = s.registry.lookup
(h, HandleKind::Table); + if (t) { + Connection* owning = nullptr; + s.registry.for_each_handle([&](Handle, HandleKind k, void* p) { + if (k != HandleKind::Connection || owning) return; + auto* cc = static_cast(p); + if (cc->owns_table_ptr(t)) owning = cc; + }); + (void)t->flush(); + purge_bindings_for_table(t); + purge_pending_binaries_for_table(t); + connect101_option_tables().erase(to_ads_handle(h)); + if (owning) owning->close_table_ptr(t); + } + s.registry.release(h); + } + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsCloseTable(ADSHANDLE hTable) { + arc2_trace("AdsCloseTable"); + arc2_trace("AdsCloseTable"); + { + if (auto* rt = get_remote_table(hTable)) { + // Buffered sets flush before anything else (data must land). + // Deferred teardown does NOT flush here: a real close absorbs + // it below (server close flushes + purges), while a park keeps + // the server handle untouched — so tables carrying either flag + // are never parked (see remote_table_poolable). + if (UNSIGNED32 frc = remote_flush_sets(rt); frc != 0) return frc; + auto* rc = rt->conn; + // Pooled re-USE: park eligible tables instead of closing. The + // registry slot dies (use-after-close still errors SAP-correct) + // but the server handle, schema, tags and order bindings survive + // for the next open of the same path+alias+mode. + if (rc != nullptr && remote_table_poolable(rt) && + !remote_table_has_relations(hTable)) { + std::unique_ptr owned = + remote_table_take(rt); + if (owned) { + auto& s2 = state(); + { + std::lock_guard lk2(s2.mu); + s2.registry.release(hTable); + remote_sql_cursors_map().erase(hTable); + } + std::vector> evicted; + // NOTE: key first, move second — argument evaluation + // order is indeterminate, and moving `owned` before + // reading its members is use-after-move (it crashed). + std::string pkey = remote_pool_key(owned->name, owned->alias, + owned->open_mode_raw); + openads::network::RemoteTable* traced = owned.get(); + if (cli_trace_on()) { + cli_trace_tbl(traced, "AdsCloseTable", "parked id=%u", + static_cast(traced->id)); + } + rc->parked_store(std::move(pkey), std::move(owned), evicted); + if (cli_trace_on()) { + for (auto& e : evicted) { + cli_trace_tbl(e.get(), "AdsCloseTable", + "evicted from park id=%u", + e ? static_cast(e->id) : 0u); + } + } + for (auto& e : evicted) remote_close_table_live(0, e.get()); + return ok(); + } + } + // Real close: absorb deferred teardown (never emitted). The + // server close flushes data via its shadow handle and purges + // the table's index bindings, so these frames would be waste. + // A parked index snapshot dies with the handle (same purge). + if (cli_trace_on()) { + cli_trace_tbl(rt, "AdsCloseTable", "real close id=%u reason=%s", + static_cast(rt->id), + remote_table_unpoolable_reason(rt, hTable)); + } + rt->flush_file_pending = false; + rt->close_all_indexes_pending = false; + rt->indexes_parked = false; + rt->write_dirty = false; + remote_close_table_live(hTable, rt); + 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); + // Flush the table (driver + active order + extra index views) + // before releasing the handle. Without an explicit transaction, + // this is the only point at which mutations made since open + // reach disk; with one, commit_tx already flushed and this is + // a no-op. Also drop any index bindings tied to this Table so + // a future Table allocation at the same heap address doesn't + // inherit stale entries. + Table* t = s.registry.lookup
(hTable, HandleKind::Table); + if (t != nullptr) { + Connection* owning = nullptr; + s.registry.for_each_handle([&](Handle, HandleKind k, void* p) { + if (k != HandleKind::Connection || owning) return; + auto* cc = static_cast(p); + if (cc->owns_table_ptr(t)) owning = cc; + }); + (void)t->flush(); + purge_bindings_for_table(t); + purge_pending_binaries_for_table(t); + forget_relations(hTable); + t->ri_snapshot().clear(); + connect101_option_tables().erase(hTable); + if (owning) owning->close_table_ptr(t); + // Safety net: a client that disconnects without an explicit + // unlock would otherwise leak its entry in the global lock + // registry forever (or until another Table happens to reuse + // this same heap address). + openads::mgmt::LockRegistry::instance().remove_all_for_table(t); + } + forget_cursor_projection(hTable); + s.registry.release(hTable); + // The table is closed and its handle released, so the temp table a + // materialised SELECT built for this cursor can go with it. + drop_materialised_cursor_temp(hTable); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsGotoTop(ADSHANDLE hTable) { + arc2_trace("AdsGotoTop"); + if (auto* ri = get_remote_index(hTable)) { + // Sets + teardown flush here, but NOT a deferred order switch: + // that absorbs into the nav below (fused frame) when it targets + // this order, or flushes plainly inside remote_index_goto_top's + // activate path otherwise. + if (UNSIGNED32 frc = remote_flush_sets(ri->parent); frc != 0) return frc; + if (UNSIGNED32 frc = remote_flush_teardown(ri->parent); frc != 0) return frc; + // Deferred switch absorbs into the nav below (fused frame) when + // it targets this order; otherwise (or on old servers) it goes + // out plainly first and the normal path binds cheap. + if (ri->parent != nullptr && ri->parent->pending_order) { + if (ri->parent->pending_order_id == ri->id && + ri->parent->conn != nullptr && + ri->parent->conn->server_nav_order_fuse()) { + ri->parent->pending_order = false; + ri->parent->found_cached = true; + ri->parent->current_found = false; + ri->parent->invalidate_prefetch(); + auto fr = ri->conn->goto_top_fused(ri->parent, ri->id); + if (!fr) return fail(fr.error()); + ri->parent->server_order_id = ri->id; + ri->parent->active_index_id = ri->id; + ri->parent->key_count_cached = false; + remote_sync_keyno_gototop(ri->parent); + remote_nav_stamp(ri->parent, 1, ri->id); + cli_trace_tbl(ri->parent, "AdsGotoTop(idx)", "fused: row_valid=%d recno=%u keyno=%u eof=%d bof=%d", + (int)ri->parent->row_valid, + ri->parent->current_recno, + ri->parent->current_keyno, + (int)ri->parent->nav_at_eof, + (int)ri->parent->nav_at_bof); + if (Handle th = handle_for_remote_table(ri->parent)) + apply_relations_for_handle(to_ads_handle(th)); + return ok(); + } + if (UNSIGNED32 frc = remote_flush_order(ri->parent); + frc != 0) { + return frc; + } + } + if (remote_nav_duplicate(ri->parent, 1, ri->id)) { + cli_trace_tbl(ri->parent, "AdsGotoTop(idx)", "duplicate suppressed"); + remote_sync_keyno_gototop(ri->parent); + if (Handle th = handle_for_remote_table(ri->parent)) + apply_relations_for_handle(to_ads_handle(th)); + return ok(); + } + if (remote_nav_pair(ri->parent, 1, ri->id)) { + // mtfix12 R1: the preceding GotoBottom certified this + // landing in the same server visit; apply it verbatim. + auto pr = ri->parent->conn->apply_pair_blob(ri->parent); + if (!pr) return fail(pr.error()); + cli_trace_tbl(ri->parent, "AdsGotoTop(idx)", "pair certified"); + remote_sync_keyno_gototop(ri->parent); + remote_nav_stamp(ri->parent, 1, ri->id); + if (Handle th = handle_for_remote_table(ri->parent)) + apply_relations_for_handle(to_ads_handle(th)); + return ok(); + } + auto r = openads::network::remote_index_goto_top(ri); + if (!r) return fail(r.error()); + remote_sync_keyno_gototop(ri->parent); + remote_nav_stamp(ri->parent, 1, ri->id); + cli_trace_tbl(ri->parent, "AdsGotoTop(idx)", "row_valid=%d recno=%u keyno=%u eof=%d bof=%d", + (int)ri->parent->row_valid, ri->parent->current_recno, + ri->parent->current_keyno, (int)ri->parent->nav_at_eof, + (int)ri->parent->nav_at_bof); + if (Handle th = handle_for_remote_table(ri->parent)) + apply_relations_for_handle(to_ads_handle(th)); + return ok(); + } + if (auto* rt = get_remote_table(hTable)) { + // Sets + teardown flush here; a deferred order switch absorbs + // into the nav below (fused frame) instead of going out alone. + if (UNSIGNED32 frc = remote_flush_sets(rt); frc != 0) return frc; + if (UNSIGNED32 frc = remote_flush_teardown(rt); frc != 0) return frc; + // A parked index snapshot leaves the server on the pre-CloseAll + // order while this handle believes natural: resolve before the + // nav walks the stale order (see remote_close_parked_indexes). + if (UNSIGNED32 frc = remote_close_parked_indexes(rt); frc != 0) { + return frc; + } + // M12.18 -- rt-aware overload parses the row trailer in the + // same RTT, so AdsGetField immediately after GoTop hits + // the cache. + rt->found_cached = true; rt->current_found = false; // M12.21: GoTop clears Found() + // Deferred order switch absorbs into this nav (fused frame) on + // new servers; on old ones it goes out plainly first and the + // normal path below binds cheap. + if (rt->pending_order) { + if (rt->conn != nullptr && + rt->conn->server_nav_order_fuse()) { + const std::uint32_t oid = rt->pending_order_id; + rt->pending_order = false; + rt->invalidate_prefetch(); + auto fr = rt->conn->goto_top_fused(rt, oid); + if (!fr) return fail(fr.error()); + rt->server_order_id = oid; + rt->active_index_id = oid; + rt->key_count_cached = false; + remote_sync_keyno_gototop(rt); + remote_nav_stamp(rt, 1, oid); + cli_trace_tbl(rt, "AdsGotoTop", "fused: row_valid=%d recno=%u keyno=%u eof=%d bof=%d", + (int)rt->row_valid, rt->current_recno, + rt->current_keyno, (int)rt->nav_at_eof, + (int)rt->nav_at_bof); + apply_relations_for_handle(hTable); + return ok(); + } + if (UNSIGNED32 frc = remote_flush_order(rt); frc != 0) return frc; + } + if (remote_nav_duplicate(rt, 1, rt->server_order_id)) { + cli_trace_tbl(rt, "AdsGotoTop", "duplicate suppressed"); + remote_sync_keyno_gototop(rt); + apply_relations_for_handle(hTable); + return ok(); + } + if (remote_nav_pair(rt, 1, rt->server_order_id)) { + auto pr = rt->conn->apply_pair_blob(rt); + if (!pr) return fail(pr.error()); + cli_trace_tbl(rt, "AdsGotoTop", "pair certified"); + remote_sync_keyno_gototop(rt); + remote_nav_stamp(rt, 1, rt->server_order_id); + apply_relations_for_handle(hTable); + return ok(); + } + auto r = rt->conn->goto_top(rt); + if (!r) return fail(r.error()); + remote_sync_keyno_gototop(rt); + remote_nav_stamp(rt, 1, rt->server_order_id); + apply_relations_for_handle(hTable); + return ok(); + } + if (auto* ops = openads::abi::backend_table_ops_for(hTable)) { + if (ops->goto_top) { + UNSIGNED32 rc = ops->goto_top(hTable); + if (rc == openads::AE_SUCCESS) apply_relations_for_handle(hTable); + return rc; + } + } + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + auto r = t->goto_top(); + if (!r) return fail(r.error()); + snapshot_ri_pks(t); + apply_relations_for_handle(hTable); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsGotoBottom(ADSHANDLE hTable) { + arc2_trace("AdsGotoBottom"); + if (auto* ri = get_remote_index(hTable)) { + // Sets + teardown flush here, but NOT a deferred order switch: + // that absorbs into the nav below (fused frame) when it targets + // this order, or flushes plainly otherwise. + if (UNSIGNED32 frc = remote_flush_sets(ri->parent); frc != 0) return frc; + if (UNSIGNED32 frc = remote_flush_teardown(ri->parent); frc != 0) return frc; + if (ri->parent != nullptr && ri->parent->pending_order) { + if (ri->parent->pending_order_id == ri->id && + ri->parent->conn != nullptr && + ri->parent->conn->server_nav_order_fuse()) { + ri->parent->pending_order = false; + ri->parent->found_cached = true; + ri->parent->current_found = false; + ri->parent->invalidate_prefetch(); + auto fr = ri->conn->goto_bottom_fused(ri->parent, ri->id); + if (!fr) return fail(fr.error()); + ri->parent->server_order_id = ri->id; + ri->parent->active_index_id = ri->id; + ri->parent->key_count_cached = false; + remote_sync_keyno_gotobottom(ri->parent); + remote_nav_stamp(ri->parent, 2, ri->id); + cli_trace_tbl(ri->parent, "AdsGotoBottom(idx)", "fused: row_valid=%d recno=%u keyno=%u eof=%d bof=%d", + (int)ri->parent->row_valid, + ri->parent->current_recno, + ri->parent->current_keyno, + (int)ri->parent->nav_at_eof, + (int)ri->parent->nav_at_bof); + if (Handle th = handle_for_remote_table(ri->parent)) + apply_relations_for_handle(to_ads_handle(th)); + return ok(); + } + if (UNSIGNED32 frc = remote_flush_order(ri->parent); + frc != 0) { + return frc; + } + } + if (remote_nav_duplicate(ri->parent, 2, ri->id)) { + cli_trace_tbl(ri->parent, "AdsGotoBottom(idx)", "duplicate suppressed"); + remote_sync_keyno_gotobottom(ri->parent); + if (Handle th = handle_for_remote_table(ri->parent)) + apply_relations_for_handle(to_ads_handle(th)); + return ok(); + } + if (remote_nav_pair(ri->parent, 2, ri->id)) { + // mtfix12 R1: the preceding GotoTop certified this landing. + auto pr = ri->parent->conn->apply_pair_blob(ri->parent); + if (!pr) return fail(pr.error()); + cli_trace_tbl(ri->parent, "AdsGotoBottom(idx)", "pair certified"); + remote_sync_keyno_gotobottom(ri->parent); + remote_nav_stamp(ri->parent, 2, ri->id); + if (Handle th = handle_for_remote_table(ri->parent)) + apply_relations_for_handle(to_ads_handle(th)); + return ok(); + } + auto r = openads::network::remote_index_goto_bottom(ri); + if (!r) return fail(r.error()); + remote_sync_keyno_gotobottom(ri->parent); + remote_nav_stamp(ri->parent, 2, ri->id); + cli_trace_tbl(ri->parent, "AdsGotoBottom(idx)", "row_valid=%d recno=%u keyno=%u eof=%d bof=%d", + (int)ri->parent->row_valid, ri->parent->current_recno, + ri->parent->current_keyno, (int)ri->parent->nav_at_eof, + (int)ri->parent->nav_at_bof); + if (Handle th = handle_for_remote_table(ri->parent)) + apply_relations_for_handle(to_ads_handle(th)); + return ok(); + } + if (auto* rt = get_remote_table(hTable)) { + // Sets + teardown flush here; a deferred order switch absorbs + // into the nav below (fused frame) instead of going out alone. + if (UNSIGNED32 frc = remote_flush_sets(rt); frc != 0) return frc; + if (UNSIGNED32 frc = remote_flush_teardown(rt); frc != 0) return frc; + if (UNSIGNED32 frc = remote_close_parked_indexes(rt); frc != 0) { + return frc; + } + rt->found_cached = true; rt->current_found = false; // M12.21: GoBottom clears Found() + if (rt->pending_order) { + if (rt->conn != nullptr && + rt->conn->server_nav_order_fuse()) { + const std::uint32_t oid = rt->pending_order_id; + rt->pending_order = false; + rt->invalidate_prefetch(); + auto fr = rt->conn->goto_bottom_fused(rt, oid); + if (!fr) return fail(fr.error()); + rt->server_order_id = oid; + rt->active_index_id = oid; + rt->key_count_cached = false; + remote_sync_keyno_gotobottom(rt); + remote_nav_stamp(rt, 2, oid); + cli_trace_tbl(rt, "AdsGotoBottom", "fused: row_valid=%d recno=%u keyno=%u eof=%d bof=%d", + (int)rt->row_valid, rt->current_recno, + rt->current_keyno, (int)rt->nav_at_eof, + (int)rt->nav_at_bof); + apply_relations_for_handle(hTable); + return ok(); + } + if (UNSIGNED32 frc = remote_flush_order(rt); frc != 0) return frc; + } + if (remote_nav_duplicate(rt, 2, rt->server_order_id)) { + cli_trace_tbl(rt, "AdsGotoBottom", "duplicate suppressed"); + remote_sync_keyno_gotobottom(rt); + apply_relations_for_handle(hTable); + return ok(); + } + if (remote_nav_pair(rt, 2, rt->server_order_id)) { + auto pr = rt->conn->apply_pair_blob(rt); + if (!pr) return fail(pr.error()); + cli_trace_tbl(rt, "AdsGotoBottom", "pair certified"); + remote_sync_keyno_gotobottom(rt); + remote_nav_stamp(rt, 2, rt->server_order_id); + apply_relations_for_handle(hTable); + return ok(); + } + auto r = rt->conn->goto_bottom(rt); + if (!r) return fail(r.error()); + remote_sync_keyno_gotobottom(rt); + remote_nav_stamp(rt, 2, rt->server_order_id); + apply_relations_for_handle(hTable); + return ok(); + } + if (auto* ops = openads::abi::backend_table_ops_for(hTable)) { + if (ops->goto_bottom) { + UNSIGNED32 rc = ops->goto_bottom(hTable); + if (rc == openads::AE_SUCCESS) apply_relations_for_handle(hTable); + return rc; + } + } + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + auto r = t->goto_bottom(); + if (!r) return fail(r.error()); + snapshot_ri_pks(t); + apply_relations_for_handle(hTable); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsSkip(ADSHANDLE hTable, SIGNED32 lRows) { + arc2_trace("AdsSkip"); + seek_last_retry_latch() = false; + if (auto* ri = get_remote_index(hTable)) { + cli_trace_tbl(ri->parent, "AdsSkip(idx)", "rows=%d", (int)lRows); + openads::network::RemoteTable* rt = ri->parent; + if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; + const std::uint32_t rec_before = + (rt != nullptr && rt->row_valid) ? rt->current_recno : 0u; + const bool row_valid_before = rt != nullptr && rt->row_valid; + auto r = openads::network::remote_index_skip(ri, lRows); + if (!r) return fail(r.error()); + remote_sync_keyno_skip(rt, lRows); + remote_update_nav_boundaries(rt, lRows, rec_before, row_valid_before); + cli_trace_tbl(rt, "AdsSkip(idx)", "done: row_valid=%d recno=%u keyno=%u eof=%d bof=%d", + (int)rt->row_valid, rt->current_recno, rt->current_keyno, + (int)rt->nav_at_eof, (int)rt->nav_at_bof); + if (Handle th = handle_for_remote_table(rt)) + apply_relations_for_handle(to_ads_handle(th)); + return ok(); + } + if (auto* rt = get_remote_table(hTable)) { + if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; + if (UNSIGNED32 frc = remote_close_parked_indexes(rt); frc != 0) { + return frc; + } + rt->found_cached = true; rt->current_found = false; // M12.21: Skip clears Found() + const std::uint32_t rec_before = + rt->row_valid ? rt->current_recno : 0u; + const bool row_valid_before = rt->row_valid; + // M12.21 -- sequential prefetch: Skip(1) drains the queue + // populated by the previous Skip's lookahead block. Zero + // RTT for every cached step. + // + // RCB 07/14/2026: the drain itself now lives in remote_drain_prefetch() + // because the index-handle Skip path (remote_index_skip -- what rddads + // ACTUALLY calls once an order is set, via hOrdCurrent) needs exactly + // the same logic. It used to have none at all: it threw the queue away + // on every skip. Duplicating the drain there would have meant two copies + // of the consumed-counter bookkeeping that keeps the lagging server + // cursor correct, and that counter is the whole reason "option B" of + // this feature had to be shelved once already. One copy, two callers. + if (lRows == 1 && + openads::network::remote_drain_prefetch(rt, +1)) { + remote_sync_keyno_skip(rt, lRows); + remote_update_nav_boundaries(rt, lRows, rec_before, row_valid_before); + apply_relations_for_handle(hTable); + return ok(); + } + // RCB 07/15/2026: M12.25 -- PgUp. Symmetric to the forward drain above. + if (lRows == -1 && + openads::network::remote_drain_prefetch(rt, -1)) { + remote_sync_keyno_skip(rt, lRows); + remote_update_nav_boundaries(rt, lRows, rec_before, row_valid_before); + apply_relations_for_handle(hTable); + return ok(); + } + // Any non-sequential nav drops the queue (handled inside + // parse_row_trailer_into when the new ack arrives). + auto r = rt->conn->skip(rt, lRows); + if (!r) return fail(r.error()); + remote_sync_keyno_skip(rt, lRows); + remote_update_nav_boundaries(rt, lRows, rec_before, row_valid_before); + apply_relations_for_handle(hTable); + return ok(); + } + if (auto* ops = openads::abi::backend_table_ops_for(hTable)) { + if (ops->skip) { + UNSIGNED32 rc = ops->skip(hTable, lRows); + if (rc == openads::AE_SUCCESS) apply_relations_for_handle(hTable); + return rc; + } + } + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + auto r = t->skip(lRows); + if (!r) return fail(r.error()); + snapshot_ri_pks(t); + apply_relations_for_handle(hTable); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsAtEOF(ADSHANDLE hTable, UNSIGNED16* pbAtEnd) { + arc2_trace("AdsAtEOF"); + if (auto* rt = get_remote_table(hTable)) { + if (pbAtEnd == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + cli_trace_tbl(rt, "AdsAtEOF", "nav_eof=%d row_valid=%d", + (int)rt->nav_at_eof, (int)rt->row_valid); + if (rt->nav_at_eof) { *pbAtEnd = 1; return ok(); } + // M12.21 option C -- a valid cached current row (including one + // served locally from the prefetch queue) means the cursor is + // on a record, so it cannot be at EOF: answer with no round + // trip. This is what lets a prefetched scan loop, which polls + // Eof() every iteration, actually shed its per-step round trips. + if (rt->row_valid) { *pbAtEnd = 0; return ok(); } + // Proven not-EOF (skip landed on a row, bottom positioned): + // answer locally even with no valid row right now. + if (rt->nav_not_eof) { *pbAtEnd = 0; return ok(); } + // Empty-cursor sticky (see remote_nav_empty_sticky): the last + // wire nav proved the cursor empty with nothing on the wire + // since — an empty cursor is EOF without asking the server. + if (remote_nav_empty_sticky(rt)) { *pbAtEnd = 1; return ok(); } + // Seq-gated repeat: same answer as moments ago, still current. + { + bool be = false; + if (remote_nav_bound_get(rt, true, &be)) { + *pbAtEnd = be ? 1 : 0; + return ok(); + } + } + auto r = rt->conn->eof_bof(rt->id); + if (!r) return fail(r.error()); + // Twin flag: the ack carried the BOF answer too — cache it so + // the twin half of rddads' pair is served locally. + if (r.value().has_twin) + rt->nav_at_bof = r.value().bof; + *pbAtEnd = r.value().eof ? 1 : 0; + remote_nav_bound_store(rt, r.value().eof, true, r.value().bof, + r.value().has_twin); + return ok(); + } + 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; + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsAtBOF(ADSHANDLE hTable, UNSIGNED16* pbAtBegin) { + arc2_trace("AdsAtBOF"); + if (pbAtBegin == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (auto* rt = get_remote_table(hTable)) { + cli_trace_tbl(rt, "AdsAtBOF", "nav_bof=%d row_valid=%d", + (int)rt->nav_at_bof, (int)rt->row_valid); + if (rt->nav_at_bof) { *pbAtBegin = 1; return ok(); } + // M12.21 option C -- a valid cached current row means the cursor + // is on a record, so it cannot be at BOF: answer with no round + // trip (see AdsAtEOF). + if (rt->row_valid) { *pbAtBegin = 0; return ok(); } + // Proven not-BOF: answer locally even with no valid row. + if (rt->nav_not_bof) { *pbAtBegin = 0; return ok(); } + // Empty-cursor sticky: an empty cursor is BOF without asking. + if (remote_nav_empty_sticky(rt)) { *pbAtBegin = 1; return ok(); } + // Seq-gated repeat: same answer as moments ago, still current. + { + bool bb = false; + if (remote_nav_bound_get(rt, false, &bb)) { + *pbAtBegin = bb ? 1 : 0; + return ok(); + } + } + auto r = rt->conn->bof_eof(rt->id); + if (!r) return fail(r.error()); + // Twin flag: cache the EOF answer for the twin half of the pair. + if (r.value().has_twin) + rt->nav_at_eof = r.value().eof; + *pbAtBegin = r.value().bof ? 1 : 0; + remote_nav_bound_store(rt, r.value().eof, r.value().has_twin, + r.value().bof, true); + return ok(); + } + 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; + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsGetNumFields(ADSHANDLE hTable, UNSIGNED16* pusFields) { + arc2_trace("AdsGetNumFields"); + if (pusFields == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (auto* rt = get_remote_table(hTable)) { + if (!rt->fields_cached) { + auto r = rt->conn->describe_table(rt->id); + if (!r) return fail(r.error()); + rt->fields = std::move(r).value(); + rt->fields_cached = true; + } + if (!openads::abi::assign_u16(pusFields, rt->fields.size())) { + return fail(openads::AE_INTERNAL_ERROR, "field count exceeds 65535"); + } + return ok(); + } + 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) { + if (!openads::abi::assign_u16(pusFields, p->size())) { + return fail(openads::AE_INTERNAL_ERROR, "field count exceeds 65535"); + } + } else { + *pusFields = t->field_count(); + } + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsGetFieldName(ADSHANDLE hTable, UNSIGNED16 usFieldNum, + UNSIGNED8* pucBuf, UNSIGNED16* pusLen) { + arc2_trace("AdsGetFieldName"); + if (auto* rt = get_remote_table(hTable)) { + if (!rt->fields_cached) { + auto r = rt->conn->describe_table(rt->id); + if (!r) return fail(r.error()); + rt->fields = std::move(r).value(); + rt->fields_cached = true; + } + if (usFieldNum == 0 || usFieldNum > rt->fields.size()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + openads::abi::copy_to_caller(pucBuf, pusLen, + rt->fields[usFieldNum - 1].name); + return ok(); + } + 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); + std::uint16_t src_idx = 0; + if (p != nullptr) { + if (usFieldNum == 0 || usFieldNum > p->size()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + // S4 -- a projected column is named after the SELECT list, not + // after the table's declared spelling. See + // build_projection_aliases() for SAP's exact rule. + if (const auto* al = aliases_for(hTable); + al != nullptr && usFieldNum <= al->size() && + !(*al)[usFieldNum - 1].empty()) { + openads::abi::copy_to_caller(pucBuf, pusLen, + (*al)[usFieldNum - 1]); + return ok(); + } + src_idx = (*p)[usFieldNum - 1]; + } else { + if (usFieldNum == 0 || usFieldNum > t->field_count()) { + return fail(openads::AE_COLUMN_NOT_FOUND, "field index out of range"); + } + src_idx = static_cast(usFieldNum - 1); + } + const auto& f = t->field_descriptor(src_idx); + openads::abi::copy_to_caller(pucBuf, pusLen, f.name); + return ok(); +} + +// Cache the remote schema on `rt` if not already; return index of +// the field whose name (case-insensitive) matches `pucField`, or +// SIZE_MAX when not found. Used by the three remote field-by-name +// metadata bridges below. +namespace { + +static void uppercase_ascii(std::string& s) { + for (auto& c : s) { + c = static_cast( + std::toupper(static_cast(c))); + } +} + +static bool field_arg_ordinal(UNSIGNED8* pucField, std::size_t limit, + std::size_t* zero_based) { + auto p = reinterpret_cast(pucField); + if (p == 0 || p >= 0x10000u) return false; + std::size_t one_based = static_cast(p); + if (one_based < 1 || one_based > limit) return false; + if (zero_based != nullptr) *zero_based = one_based - 1; + return true; +} + +static bool field_name_matches(UNSIGNED8* pucField, const std::string& have) { + if (pucField == nullptr) return false; + auto p = reinterpret_cast(pucField); + if (p != 0 && p < 0x10000u) return false; + std::string want = openads::abi::to_internal(pucField, 0); + std::string normalized_have = have; + uppercase_ascii(want); + uppercase_ascii(normalized_have); + return want == normalized_have; +} + +static bool projection_field_position(ADSHANDLE h, Table* t, + UNSIGNED8* pucField, + std::uint16_t* ordinal, + std::uint16_t* source_idx) { + const auto* proj = projection_for(h); + if (proj == nullptr || t == nullptr) return false; + std::size_t pos = 0; + if (field_arg_ordinal(pucField, proj->size(), &pos)) { + *ordinal = static_cast(pos); + *source_idx = (*proj)[pos]; + return true; + } + if (pucField == nullptr) return false; + auto p = reinterpret_cast(pucField); + if (p != 0 && p < 0x10000u) return false; + std::string want = openads::abi::to_internal(pucField, 0); + uppercase_ascii(want); + for (std::size_t i = 0; i < proj->size(); ++i) { + const auto src = (*proj)[i]; + if (src >= t->field_count()) continue; + std::string have = t->field_descriptor(src).name; + uppercase_ascii(have); + if (have == want) { + *ordinal = static_cast(i); + *source_idx = src; + return true; + } + } + return false; +} + +static UNSIGNED32 backend_field_num(ADSHANDLE hTable, + const openads::abi::BackendTableOps* ops, + UNSIGNED8* pucField, + UNSIGNED16* pusNum) { + if (ops == nullptr || ops->num_fields == nullptr || + ops->field_name == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, ""); + } + UNSIGNED16 count = 0; + UNSIGNED32 rc = ops->num_fields(hTable, &count); + if (rc != openads::AE_SUCCESS) return rc; + std::size_t pos = 0; + if (field_arg_ordinal(pucField, count, &pos)) { + *pusNum = static_cast(pos + 1); + return ok(); + } + for (UNSIGNED16 i = 1; i <= count; ++i) { + UNSIGNED8 name[512] = {0}; + UNSIGNED16 len = sizeof(name); + rc = ops->field_name(hTable, i, name, &len); + if (rc != openads::AE_SUCCESS) return rc; + if (field_name_matches(pucField, + std::string(reinterpret_cast(name)))) { + *pusNum = i; + return ok(); + } + } + return fail(openads::AE_COLUMN_NOT_FOUND, ""); +} + +static UNSIGNED32 backend_field_offset( + ADSHANDLE hTable, const openads::abi::BackendTableOps* ops, + UNSIGNED8* pucField, UNSIGNED32* pulOffset) { + if (ops == nullptr || ops->num_fields == nullptr || + ops->field_name == nullptr || ops->field_length == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, ""); + } + UNSIGNED16 count = 0; + UNSIGNED32 rc = ops->num_fields(hTable, &count); + if (rc != openads::AE_SUCCESS) return rc; + std::size_t want_pos = std::numeric_limits::max(); + (void)field_arg_ordinal(pucField, count, &want_pos); + UNSIGNED32 offset = 1; + for (UNSIGNED16 i = 1; i <= count; ++i) { + UNSIGNED8 name[512] = {0}; + UNSIGNED16 len = sizeof(name); + rc = ops->field_name(hTable, i, name, &len); + if (rc != openads::AE_SUCCESS) return rc; + if (want_pos == static_cast(i - 1) || + field_name_matches(pucField, + std::string(reinterpret_cast(name)))) { + *pulOffset = offset; + return ok(); + } + UNSIGNED32 field_len = 0; + rc = ops->field_length(hTable, name, &field_len); + if (rc != openads::AE_SUCCESS) return rc; + offset += field_len; + } + return fail(openads::AE_COLUMN_NOT_FOUND, ""); +} + +std::size_t remote_field_index(openads::network::RemoteTable* rt, + UNSIGNED8* pucField) { + if (!rt->fields_cached) { + auto r = rt->conn->describe_table(rt->id); + if (!r) return std::numeric_limits::max(); + rt->fields = std::move(r).value(); + rt->fields_cached = true; + } + // ACE "field name OR 1-based ordinal cast to a pointer" idiom -- X#'s + // ADSRDD calls AdsGetFieldType/Length/Decimals (and the value + // getters) by ordinal. A tiny pointer value is the ordinal; reading + // it as a string address would fault. + { + auto p = reinterpret_cast(pucField); + if (p != 0 && p < 0x10000u) { + std::size_t one_based = static_cast(p); + if (one_based >= 1 && one_based <= rt->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 < rt->fields.size(); ++i) { + std::string have = rt->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(); +} + +} // namespace + +UNSIGNED32 ENTRYPOINT AdsGetFieldNum(ADSHANDLE hTable, UNSIGNED8* pucFldName, + UNSIGNED16* pusNum) { + arc2_trace("AdsGetFieldNum"); + if (pusNum == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (auto* rt = get_remote_table(hTable)) { + auto i = remote_field_index(rt, pucFldName); + if (i == std::numeric_limits::max()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + if (!openads::abi::assign_u16(pusNum, i + 1)) { + return fail(openads::AE_INTERNAL_ERROR, "field number exceeds 65535"); + } + return ok(); + } + if (auto* ops = openads::abi::backend_table_ops_for(hTable)) { + return backend_field_num(hTable, ops, pucFldName, pusNum); + } + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); + std::uint16_t ordinal = 0; + std::uint16_t src_idx = 0; + if (projection_field_position(hTable, t, pucFldName, &ordinal, &src_idx)) { + *pusNum = static_cast(ordinal + 1); + return ok(); + } + if (!resolve_field_index(t, pucFldName, &src_idx)) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + *pusNum = static_cast(src_idx + 1); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsGetFieldOffset(ADSHANDLE hTable, UNSIGNED8* pucFldName, + UNSIGNED32* pulOffset) { + arc2_trace("AdsGetFieldOffset"); + if (pulOffset == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (auto* rt = get_remote_table(hTable)) { + auto i = remote_field_index(rt, pucFldName); + if (i == std::numeric_limits::max()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + UNSIGNED32 offset = 1; + for (std::size_t n = 0; n < i; ++n) { + offset += rt->fields[n].length; + } + *pulOffset = offset; + return ok(); + } + if (auto* ops = openads::abi::backend_table_ops_for(hTable)) { + return backend_field_offset(hTable, ops, pucFldName, pulOffset); + } + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); + std::uint16_t ordinal = 0; + std::uint16_t src_idx = 0; + if (projection_field_position(hTable, t, pucFldName, &ordinal, &src_idx)) { + UNSIGNED32 offset = 1; + const auto* proj = projection_for(hTable); + for (std::size_t i = 0; proj != nullptr && i < ordinal; ++i) { + const auto prev = (*proj)[i]; + if (prev < t->field_count()) { + offset += t->field_descriptor(prev).length; + } + } + *pulOffset = offset; + return ok(); + } + if (!resolve_field_index(t, pucFldName, &src_idx)) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + *pulOffset = t->field_descriptor(src_idx).record_offset; + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsGetFieldType(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED16* pusType) { + arc2_trace("AdsGetFieldType"); + if (pusType == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (auto* rt = get_remote_table(hTable)) { + auto i = remote_field_index(rt, pucField); + if (i == std::numeric_limits::max()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + *pusType = rt->fields[i].type; + return ok(); + } + 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; + if (!resolve_field_index_h(hTable, t, pucField, &idx)) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + *pusType = map_field_type(t->field_descriptor(idx).type); + // ADT IMAGE (raw type 7) vs BINARY (raw type 6): both map to + // DbfFieldType::Binary internally, but the ABI type differs. + const auto& fd = t->field_descriptor(idx); + if (fd.type == openads::drivers::DbfFieldType::Binary) { + auto raw = static_cast(fd.raw_type); + if (raw == 7u) *pusType = static_cast(ADS_IMAGE); + else if (raw == 6u) *pusType = static_cast(ADS_BINARY); + } + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsGetFieldLength(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED32* pulLen) { + arc2_trace("AdsGetFieldLength"); + if (pulLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (auto* rt = get_remote_table(hTable)) { + auto i = remote_field_index(rt, pucField); + if (i == std::numeric_limits::max()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + *pulLen = rt->fields[i].length; + return ok(); + } + 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; + if (!resolve_field_index_h(hTable, t, pucField, &idx)) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + const auto& fd = t->field_descriptor(idx); + // Return the string representation length, not the raw field length, for + // types where decode_field() produces a longer formatted string. This lets + // callers (e.g. the PHP extension) allocate the right buffer size before + // calling AdsGetString. + using openads::drivers::DbfFieldType; + switch (fd.type) { + case DbfFieldType::DateTime: + case DbfFieldType::AdtTimestamp: + case DbfFieldType::ModTime: + *pulLen = 14; // "YYYYMMDDHHMMSS" + break; + case DbfFieldType::AdtDate: + *pulLen = 8; // "YYYYMMDD" + break; + case DbfFieldType::RowVersion: + *pulLen = 20; // up to 20 digits for uint64_max + break; + default: + *pulLen = fd.length; + break; + } + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsGetFieldLength100(ADSHANDLE hTable, + UNSIGNED8* pucField, + UNSIGNED32 /*ulOptions*/, + UNSIGNED32* pulLen) { + arc2_trace("AdsGetFieldLength100"); + return AdsGetFieldLength(hTable, pucField, pulLen); +} + +UNSIGNED32 ENTRYPOINT AdsGetFieldDecimals(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED16* pusDec) { + arc2_trace("AdsGetFieldDecimals"); + if (pusDec == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (auto* rt = get_remote_table(hTable)) { + auto i = remote_field_index(rt, pucField); + if (i == std::numeric_limits::max()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + *pusDec = rt->fields[i].decimals; + return ok(); + } + 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; + if (!resolve_field_index_h(hTable, t, pucField, &idx)) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + *pusDec = t->field_descriptor(idx).decimals; + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsGetLong(ADSHANDLE hTable, UNSIGNED8* pucField, SIGNED32* plVal) { + arc2_trace("AdsGetLong"); + if (plVal == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (auto* rt = get_remote_table(hTable)) { + auto i = remote_field_index(rt, pucField); + if (i == std::numeric_limits::max()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + if (!rt->row_valid) { + (void)rt->conn->fetch_current_row(rt); + } + std::string vstr; + const bool overlaid = remote_pending_overlay(rt, i, vstr); + if (!overlaid && rt->row_valid && i < rt->current_row.size()) { + vstr = rt->current_row[i]; + } else if (!overlaid && i < rt->fields.size()) { + auto v = rt->conn->get_field(rt->id, rt->fields[i].name); + if (!v) return fail(v.error()); + vstr = std::move(v).value(); + } + try { *plVal = static_cast(std::stol(vstr)); } + catch (...) { *plVal = 0; } + return ok(); + } + // SQL backend (e.g. postgresql): read text via the per-backend ops + // vtable then parse. Mirrors the AdsGetDouble fix -- without it a PG + // handle fell through to the native get_table() path and errored. + if (auto* ops = openads::abi::backend_table_ops_for(hTable)) { + if (ops->get_field) { + UNSIGNED8 buf[64] = {0}; + UNSIGNED32 cap = sizeof(buf); + UNSIGNED32 rc = ops->get_field(hTable, pucField, buf, &cap, 0); + if (rc != 0) return rc; + std::string vstr(reinterpret_cast(buf), + std::min(cap, sizeof(buf))); + try { *plVal = static_cast(std::stol(vstr)); } + catch (...) { *plVal = 0; } + return ok(); + } + } + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); + std::uint16_t idx = 0; + if (!resolve_field_index(t, pucField, &idx)) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + auto v = t->read_field(idx); + if (!v) return fail(v.error()); + *plVal = static_cast(v.value().as_double); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsGetDouble(ADSHANDLE hTable, UNSIGNED8* pucField, double* pdVal) { + arc2_trace("AdsGetDouble"); + if (pdVal == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (auto* rt = get_remote_table(hTable)) { + auto i = remote_field_index(rt, pucField); + if (i == std::numeric_limits::max()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + if (!rt->row_valid) { + (void)rt->conn->fetch_current_row(rt); + } + std::string vstr; + const bool overlaid = remote_pending_overlay(rt, i, vstr); + if (!overlaid && rt->row_valid && i < rt->current_row.size()) { + vstr = rt->current_row[i]; + } else if (!overlaid && i < rt->fields.size()) { + auto v = rt->conn->get_field(rt->id, rt->fields[i].name); + if (!v) return fail(v.error()); + vstr = std::move(v).value(); + } + try { *pdVal = std::stod(vstr); } + catch (...) { *pdVal = 0.0; } + return ok(); + } + // SQL backend (e.g. postgresql): read the field as text through the + // per-backend ops vtable, then parse the numeric. Without this branch a + // PG handle fell through to the native get_table() path below, which + // returns null for a non-native table -> AdsGetDouble yielded an error. + if (auto* ops = openads::abi::backend_table_ops_for(hTable)) { + if (ops->get_field) { + UNSIGNED8 buf[64] = {0}; + UNSIGNED32 cap = sizeof(buf); + UNSIGNED32 rc = ops->get_field(hTable, pucField, buf, &cap, 0); + if (rc != 0) return rc; + std::string vstr(reinterpret_cast(buf), + std::min(cap, sizeof(buf))); + try { *pdVal = std::stod(vstr); } + catch (...) { *pdVal = 0.0; } + return ok(); + } + } + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); + std::uint16_t idx = 0; + if (!resolve_field_index(t, pucField, &idx)) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + auto v = t->read_field(idx); + if (!v) return fail(v.error()); + *pdVal = v.value().as_double; + return ok(); +} + +namespace { + +// Gregorian -> Clipper/Harbour Julian Day Number. Same formula as +// hb_dateEncode in Harbour core. +SIGNED32 to_julian(int y, int m, int d) { + long y32 = y; + long m32 = m; + long d32 = d; + return static_cast( + (1461 * (y32 + 4800 + (m32 - 14) / 12)) / 4 + + (367 * (m32 - 2 - 12 * ((m32 - 14) / 12))) / 12 + - (3 * ((y32 + 4900 + (m32 - 14) / 12) / 100)) / 4 + + d32 - 32075); +} + +// Count the live (non-deleted) recnos of an index walk. +// +// Reads in RECNO order, not key order. The walk arrives ordered by key, and on +// an index whose key does not correlate with recno that makes consecutive reads +// land in different read-ahead blocks -- every record then costs a fresh 64 KB +// block fetch and the driver's read-ahead buys nothing. Measured on two copies +// of the same 34,595-row table: 14 ms where key order happened to follow recno, +// 492 ms where it did not. The count does not depend on the order, so read the +// records in the order the file stores them. +// Opt-in switch for the ADI v2 tag layout (compound / computed / FOR tags, +// dense leaf with front coding). OFF by default, so a deployment that does not +// ask for it keeps the legacy single-field bag byte for byte. Read on every +// call -- index creation is not a hot path -- so a test or an application can turn +// it on for one process without a restart. +// +// g_adi_v2_override lets a caller flip this from AdsSetAdiV2() instead of the +// environment. Windows does not share a CRT's cached _environ across modules +// linked against separate ucrtbase instances (confirmed: an env var set from +// a Harbour host process via its own putenv/SetEnvironmentVariable is not +// visible to this DLL's std::getenv() without a fresh process) -- an in-DLL +// flag sidesteps that entirely. -1 = unset (fall back to the env var), 0/1 = +// explicit override. +std::atomic g_adi_v2_override{-1}; + +bool adi_v2_enabled() { + const int ov = g_adi_v2_override.load(std::memory_order_relaxed); + if (ov >= 0) return ov != 0; + return openads::util::client_setting_truthy("OPENADS_ADI_V2", "adi_v2"); +} + +// Counting the live entries of an index costs one deleted_at() per entry, and +// deleted_at() reads the whole record. On a 34.595-row table that is ~15 ms, +// and AdsGetKeyCount is what a TXBrowse asks on EVERY paint to size its +// scrollbar -- so the browse paid it again and again for an answer that had +// not changed. Memoise per index (`owner`), keyed by the table's live +// generation, which moves on delete / recall / append / zap / pack and on the +// refresh that makes another station's work visible. +std::uint32_t count_live_recnos(openads::engine::Table* t, + const std::vector& walk, + const void* owner) { + struct Cached { + const openads::engine::Table* table = nullptr; + std::uint64_t gen = 0; + std::size_t size = 0; + std::uint32_t count = 0; + }; + static std::unordered_map cache; + + const std::uint64_t gen = t->live_gen(); + if (owner != nullptr) { + auto it = cache.find(owner); + if (it != cache.end() && it->second.table == t && + it->second.gen == gen && it->second.size == walk.size()) { + return it->second.count; + } + } + + std::vector by_recno(walk); + std::sort(by_recno.begin(), by_recno.end()); + std::uint32_t n = 0; + for (std::uint32_t rn : by_recno) { + auto del = t->deleted_at(rn); + if (del && !del.value()) ++n; + } + if (owner != nullptr) cache[owner] = Cached{t, gen, walk.size(), n}; + return n; +} + +} // namespace + +UNSIGNED32 ENTRYPOINT AdsGetJulian(ADSHANDLE hTable, UNSIGNED8* pucField, SIGNED32* plDate) { + arc2_trace("AdsGetJulian"); + if (plDate == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + auto decode_date = [](const std::string& s) -> SIGNED32 { + if (s.size() < 8) return 0; + int y = (s[0] - '0') * 1000 + (s[1] - '0') * 100 + + (s[2] - '0') * 10 + (s[3] - '0'); + int m = (s[4] - '0') * 10 + (s[5] - '0'); + int d = (s[6] - '0') * 10 + (s[7] - '0'); + if (y > 0 && m >= 1 && m <= 12 && d >= 1 && d <= 31) { + return to_julian(y, m, d); + } + return 0; + }; + if (auto* rt = get_remote_table(hTable)) { + // Use ordinal-safe index + row cache (or fallback get_field by + // resolved name) so that callers passing field ordinals (X#, FWH + // TDataBase) don't AV in to_internal, and Date fields work. + auto i = remote_field_index(rt, pucField); + if (i == std::numeric_limits::max()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + if (!rt->row_valid) { + (void)rt->conn->fetch_current_row(rt); + } + std::string vstr; + const bool overlaid = remote_pending_overlay(rt, i, vstr); + if (!overlaid && rt->row_valid && i < rt->current_row.size()) { + vstr = rt->current_row[i]; + } else if (!overlaid && i < rt->fields.size()) { + auto v = rt->conn->get_field(rt->id, rt->fields[i].name); + if (!v) return fail(v.error()); + vstr = std::move(v).value(); + } + *plDate = decode_date(vstr); + return ok(); + } + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); + std::uint16_t idx = 0; + if (!resolve_field_index(t, pucField, &idx)) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + auto v = t->read_field(idx); + if (!v) return fail(v.error()); + const std::string& s = v.value().as_string; + *plDate = 0; + if (s.size() >= 8) { + int y = (s[0] - '0') * 1000 + (s[1] - '0') * 100 + + (s[2] - '0') * 10 + (s[3] - '0'); + int m = (s[4] - '0') * 10 + (s[5] - '0'); + int d = (s[6] - '0') * 10 + (s[7] - '0'); + if (y > 0 && m >= 1 && m <= 12 && d >= 1 && d <= 31) { + *plDate = to_julian(y, m, d); + } + } + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsGetRecordNum(ADSHANDLE hTable, UNSIGNED16 /*bFilterOption*/, + UNSIGNED32* pulRecordNum) { + arc2_trace("AdsGetRecordNum"); + if (pulRecordNum == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (auto* rt = get_remote_table(hTable)) { + // M12.18 -- recno is part of the row trailer that arrives + // with every nav ack, so the cache hit avoids a separate + // GetRecordNum RTT after a nav. + if (rt->row_valid) { + *pulRecordNum = rt->current_recno; + return ok(); + } + // Reposition-bound recno: the last GotoRecord/Seek certified + // the phantom recno in its ack tail (or a wire answer just + // did). Serves xBrowse's per-row RecNo() while no + // cursor-affecting frame lands — same currency as the bound + // cache, cleared with it. + if (rt->recno_bound_ok && + rt->recno_bound_seq == rt->conn->nav_seq()) { + *pulRecordNum = rt->recno_bound; + return ok(); + } + // mtfix12 R3 — the R2 anchor certifies the server cursor's + // recno even when the local row cache was dropped without a + // wire move. With no drain outstanding (lag 0), the wire answer + // would be exactly anchor_recno. + if (rt->anchor_ok && rt->cursor_lag == 0 && + (remote_self_goto_per_table() + ? rt->anchor_tbl_seq == rt->conn->tbl_nav_seq(rt->id) + : rt->anchor_seq == rt->conn->nav_seq())) { + *pulRecordNum = rt->anchor_recno; + return ok(); + } + // Phantom derivation (no frame, no currency): at the EOF + // phantom the recno is LastRec+1 by Clipper convention — a + // pure function of the certified EOF flag plus the cached + // count. Applies in natural AND ordered walks (the twin + // reports physical n+1 past the last key, same engine rule). + // Restricted to certain semantics: no filter/AOF/scope + // (scoped/conditional walks may end elsewhere — those keep + // the wire path), and a cached count to derive from. + // Immune to cross-table eviction by construction (nothing + // is stored). + if (!rt->row_valid && rt->nav_at_eof && + rt->filter_expr.empty() && rt->aof_expr.empty() && + !rt->scope_touched && rt->rec_count_cached) { + *pulRecordNum = rt->cached_rec_count + 1; + return ok(); + } + auto r = rt->conn->get_record_num(rt->id); + if (!r) return fail(r.error()); + *pulRecordNum = r.value(); + rt->recno_bound = r.value(); + rt->recno_bound_ok = true; + rt->recno_bound_seq = rt->conn->nav_seq(); + return ok(); + } + 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(); + return ok(); +} + +extern "C++" { +namespace { +struct SqlStatement; +SqlStatement* stmt_lookup(ADSHANDLE h); +} +} + +UNSIGNED32 ENTRYPOINT AdsGetRecordCount(ADSHANDLE hTable, UNSIGNED16 bFilterOption, + UNSIGNED32* pulRecordCount) { + arc2_trace("AdsGetRecordCount"); + arc2_trace("AdsGetRecordCount"); + // SAP ACE also accepts a SQL *statement* handle here (rows affected / + // returned by the last execute). ARC relies on this immediately after + // AdsExecuteSQLDirectW(stmt, sql, NULL) — e.g. sp_SetApplicationID — + // and treats an error as a failed connection setup. + if (stmt_lookup(hTable) != nullptr) { + if (pulRecordCount == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, ""); + } + *pulRecordCount = 0; + return ok(); + } + // rddads OrdKeyCount (DBOI_KEYCOUNT) passes hOrdCurrent here -- a + // RemoteIndex handle. Must return the *index* key count (scope + + // SET DELETED aware on the server), NOT the parent table's physical + // record count. Routing to the table made remote xBrowse allocate + // ghost rows when deleted keys sat inside the scope (Tim's report). + if (auto* ri = get_remote_index(hTable)) { + if (pulRecordCount == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "remote index"); + } + auto r = openads::network::remote_index_key_count(ri); + if (!r) return fail(r.error()); + *pulRecordCount = r.value(); + return ok(); + } + if (auto* rt = get_remote_table(hTable)) { + if (pulRecordCount == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + // M12.19 -- record count is invariant outside of explicit + // writes (AppendBlank / DeleteRecord / RecallRecord / Pack + // / Zap), so cache the value on first hit and serve every + // subsequent AdsGetRecordCount + AdsGetRelKeyPos (scrollbar) + // call from cache. Each cache hit saves one wire RTT. + if (rt->rec_count_cached) { + *pulRecordCount = rt->cached_rec_count; + return ok(); + } + // Certified count from the last nav ack tail (same trust as + // the cache above — in-memory server count). Covers the + // open→goto→count USE flow with zero extra frames. + if (rt->count_bound_ok && + rt->count_bound_seq == rt->conn->nav_seq()) { + *pulRecordCount = rt->count_bound; + rt->cached_rec_count = rt->count_bound; + rt->rec_count_cached = true; + return ok(); + } + auto r = rt->conn->record_count(rt->id); + if (!r) return fail(r.error()); + rt->cached_rec_count = static_cast(r.value()); + rt->rec_count_cached = true; + *pulRecordCount = rt->cached_rec_count; + return ok(); + } + 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, ""); + // rddads' OrdKeyCount (DBOI_KEYCOUNT) does NOT call AdsGetKeyCount; it + // calls AdsGetRecordCount with the ORDER handle (pArea->hOrdCurrent) to + // count the records reachable THROUGH that order. For a conditional/FOR + // order the index holds only the matching rows, so the count must be the + // index key count (e.g. 4), not the table's physical record_count() (5) -- + // native DBFCDX reports 4 here. AdsGetKeyCount already special-cases this; + // mirror it, but ONLY when an INDEX handle was passed: a TABLE handle must + // still report the full physical count (RecCount() semantics). + if (auto* idx = iindex_for_handle(hTable)) { + if (auto* cdx = + dynamic_cast(idx)) { + // M10.31 -- scope-aware: when scope is active, count only keys + // within the scoped range rather than the full conditional index. + // t was already resolved from hTable via get_table() above, which + // handles index handles via lookup_table_by_index + activate. + if (t && t->order()) { + auto& sc = t->order()->scope(); + // rddads OrdKeyCount calls AdsGetRecordCount on the order + // handle -- must honour SET DELETED ON (exclude deleted + // keys) or remote xBrowse allocates ghost rows / hangs. + const bool hide_del = !t->show_deleted_records(); + std::function live; + const std::function* live_p = nullptr; + if (hide_del) { + // deleted_at() keeps the driver's read-ahead warm and does + // not move the cursor, so there is nothing to restore. + live = [t](std::uint32_t rn) { + auto del = t->deleted_at(rn); + return del && !del.value(); + }; + live_p = &live; + } + if (sc.top.has_value() || sc.bottom.has_value()) { + *pulRecordCount = cdx->count_scoped_keys( + sc.top.value_or(""), + sc.bottom.value_or(""), + live_p); + } else if (hide_del) { + *pulRecordCount = count_live_recnos( + t, cdx->ordered_recnos_cached(), cdx); + } else { + *pulRecordCount = static_cast( + cdx->ordered_recnos_cached().size()); + } + return ok(); + } + *pulRecordCount = static_cast( + cdx->ordered_recnos_cached().size()); + return ok(); + } + if (auto* adi = + dynamic_cast(idx)) { + // Native ADI tag: same rule as CDX -- count the index walk, not + // the table, so a FOR-clause tag reports its matching subset. + // + // Y se cuenta con count_live_recnos, igual que CDX: mirar el + // borrado con goto_record() invalida la cache de lectura en cada + // vuelta, o sea un bloque leido por registro y sin reuso entre + // llamadas. rddads pide OrdKeyCount por esta puerta y un TXBrowse + // la golpea cientos de veces al abrir: con goto_record eran ~9 s + // de pantalla sobre 34.595 filas. + const bool hide_del = t && !t->show_deleted_records(); + *pulRecordCount = hide_del + ? count_live_recnos(t, adi->ordered_recnos_cached(), adi) + : static_cast(adi->ordered_recnos_cached().size()); + return ok(); + } + } + // M10.31 / M10.32 -- when SQL has materialised a traversal sequence + // (DISTINCT / LIMIT / OFFSET / ORDER BY), report that sequence's + // length so apps that drive walking by record-count get the + // post-clause row count. + if (t->has_recno_sequence()) { + *pulRecordCount = static_cast(t->recno_sequence().size()); + } else if (t->has_filter()) { + // M10.33 -- WHERE-filtered cursor without an installed + // sequence (no ORDER BY / DISTINCT / LIMIT). Count + // matching live rows on demand so BETWEEN / LIKE / regular + // predicates surface their cardinality through GetRecordCount. + std::uint32_t rc = t->record_count(); + std::uint32_t pass = 0; + for (std::uint32_t r = 1; r <= rc; ++r) { + if (auto g = t->goto_record(r); !g) continue; + if (!t->show_deleted_records() && + t->is_deleted()) continue; + if (!t->passes_filter()) continue; + ++pass; + } + *pulRecordCount = pass; + } else if (bFilterOption == ADS_RESPECTFILTERS && + !t->show_deleted_records()) { + // ADS_RESPECTFILTERS: count live records only when deleted records + // are hidden (SET DELETED ON). Walk the raw record range, skipping + // deleted rows, then restore the cursor to its original position. + const std::uint32_t saved = t->recno(); + std::uint32_t rc = t->record_count(); + std::uint32_t live = 0; + for (std::uint32_t r = 1; r <= rc; ++r) { + if (auto g = t->goto_record(r); !g) continue; + if (!t->is_deleted()) ++live; + } + t->goto_record(saved); + *pulRecordCount = live; + } else { + // Multiuser: peer appends bump the on-disk header; re-read so + // LastRec() / RecCount() match the other stations (and so the + // browser's EOF fence is not stuck at open-time count). + t->refresh_record_count_from_disk(); + *pulRecordCount = t->record_count(); + } + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsGetField(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED8* pucBuf, UNSIGNED32* pulLen, + UNSIGNED16 /*usOption*/) { + arc2_trace("AdsGetField"); + if (auto* rt = get_remote_table(hTable)) { + if (pulLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + // M12.17/18 -- serve from row cache. Cache populated either + // by piggyback on the prior nav-op ack (M12.18) or by a + // standalone FetchCurrentRow call here on first access. + // xbrowse-style W cols × H rows repaint: 1 RTT per row, + // zero RTT per cell. + if (!rt->row_valid) { + (void)rt->conn->fetch_current_row(rt); + } + auto fi = remote_field_index(rt, pucField); + if (fi == std::numeric_limits::max()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + // Buffered sets win over the cached row (fresh appends have no + // fetchable row yet); otherwise serve the cache, else fall + // through to the BOF/EOF blank template below. + { + std::string val; + const bool overlaid = remote_pending_overlay(rt, fi, val); + if (!overlaid && rt->row_valid && fi < rt->current_row.size()) { + val = rt->current_row[fi]; + } + if (!overlaid && !rt->row_valid) { + // fall through to the BOF/EOF blank template below + } else { + if (rt->fields[fi].type == ADS_STRING) + val = pad_char_field(std::move(val), rt->fields[fi].length); + else if (!g_field_read_raw && rt->fields[fi].type == ADS_DATE) + val = format_date_display(val); + else if (!g_field_read_raw && rt->fields[fi].type == ADS_TIMESTAMP) + val = format_timestamp_display(val); + openads::abi::copy_to_caller(pucBuf, pulLen, val); + return ok(); + } + } + // BOF/EOF -- blank template (FWH td_blankrow, TBrowse append row). + return ads_get_field_blank_out( + pucBuf, pulLen, + blank_abi_field_from_ads(rt->fields[fi].type, rt->fields[fi].length), + rt->fields[fi].type, rt->fields[fi].length); + } + 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; + if (!resolve_field_index_h(hTable, t, pucField, &idx)) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + auto v = t->read_field(idx); + if (!v) { + if (is_no_current_record(v.error())) { + const auto& fd = t->field_descriptor(idx); + std::uint16_t ads_type = ADS_STRING; + if (fd.type == openads::drivers::DbfFieldType::Logical) + ads_type = ADS_LOGICAL; + else if (fd.type == openads::drivers::DbfFieldType::Numeric || + fd.type == openads::drivers::DbfFieldType::Float || + fd.type == openads::drivers::DbfFieldType::Integer) + ads_type = ADS_NUMERIC; + return ads_get_field_blank_out( + pucBuf, pulLen, blank_abi_field_from_dbf(fd), + ads_type, fd.length); + } + return fail(v.error()); + } + // Re-pad CHARACTER fields to the declared field width on the way out. + // The internal decode (make_string) trims for the SQL/index engine; + // ABI callers expect the full fixed-width value. + std::string val = v.value().as_string; + const auto& fd = t->field_descriptor(idx); + if (fd.type == openads::drivers::DbfFieldType::Character) { + val = pad_char_field(std::move(val), fd.length); + } else if (!g_field_read_raw && + (fd.type == openads::drivers::DbfFieldType::Date || + fd.type == openads::drivers::DbfFieldType::AdtDate)) { + // SAP: AdsGetField formats dates per the connection date format + // ("01/15/2024"; blank " / / "); AdsGetString stays raw + // "YYYYMMDD". php_ads and the engine internals read via + // GetString / as_string, so this formatting is display-only. + val = format_date_display(v.value().is_null ? std::string() : val); + } else if (!g_field_read_raw && + fd.type == openads::drivers::DbfFieldType::AdtTimestamp) { + // SAP: "MM/DD/CCYY hh:mm:ss AM" (12-hour, 2-digit hour, len 22). + val = format_timestamp_display( + v.value().is_null ? std::string() : val); + } + openads::abi::copy_to_caller(pucBuf, pulLen, val); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsGetLastError(UNSIGNED32* pulCode, UNSIGNED8* pucBuf, + UNSIGNED16* pusBufLen) { + arc2_trace("AdsGetLastError"); + arc2_trace("AdsGetLastError"); + arc2_log("LASTERR code=%d msg=%.120s", + (int)openads::abi::last_error_code(), + openads::abi::last_error_message().c_str()); + if (pulCode != nullptr) *pulCode = static_cast( + openads::abi::last_error_code()); + if (pucBuf != nullptr && pusBufLen != nullptr) { + openads::abi::copy_to_caller(pucBuf, pusBufLen, + openads::abi::last_error_message()); + } + return openads::AE_SUCCESS; +} + +// SAP / rddads signature: 5 args. +// AdsGetVersion(&ulMajor, &ulMinor, &ucLetter, ucDesc, &usDescLen) +// +// pucLetter : single ASCII letter (NOT a UNSIGNED32 codepoint) -- +// writing 4 bytes into a 1-byte slot was undefined. +// pucDesc / pusDescLen : caller-allocated description buffer + +// in/out length. We write the OpenADS version string +// and truncate to the caller's capacity. +// +// Major/minor come from OPENADS_VERSION_STR (CMake project version, e.g. +// "1.8.43" or "1.8.43-5-gdeadbee") so a client can display the server +// build programmatically; the description carries the full string. +UNSIGNED32 ENTRYPOINT AdsGetVersion(UNSIGNED32* pulMajor, UNSIGNED32* pulMinor, + UNSIGNED8* pucLetter, UNSIGNED8* pucDesc, + UNSIGNED16* pusDescLen) { + arc2_trace("AdsGetVersion"); +#ifndef OPENADS_VERSION_STR +# define OPENADS_VERSION_STR "0.0" +#endif + UNSIGNED32 maj = 0, min = 0; + { + const char* v = OPENADS_VERSION_STR; + char* end = nullptr; + unsigned long a = std::strtoul(v, &end, 10); + unsigned long b = 0; + if (end != nullptr && *end == '.') { + b = std::strtoul(end + 1, nullptr, 10); + } + maj = static_cast(a); + min = static_cast(b); + } + if (pulMajor != nullptr) *pulMajor = maj; + if (pulMinor != nullptr) *pulMinor = min; + if (pucLetter != nullptr) *pucLetter = 'a'; + static const char kDesc[] = "OpenADS " OPENADS_VERSION_STR + " ACE-compatible engine"; + if (pucDesc != nullptr && pusDescLen != nullptr) { + UNSIGNED16 cap = *pusDescLen; + UNSIGNED16 n = static_cast( + sizeof(kDesc) - 1 < cap ? sizeof(kDesc) - 1 : cap); + if (n > 0) std::memcpy(pucDesc, kDesc, n); + if (cap > n) pucDesc[n] = '\0'; + *pusDescLen = n; + } else if (pusDescLen != nullptr) { + *pusDescLen = sizeof(kDesc) - 1; + } + return openads::AE_SUCCESS; +} + +// --- M9.15 server info ----------------------------------------------------- +// +// Local-mode connections now report the host name + the local wall clock +// instead of empty strings / 0. AdsGetServerTime returns a six-arg shape +// matching the ACE 6.x signature rddads' ADSGETSERVERTIME function expects +// (date string, time string, milliseconds since midnight) -- the previous +// 2-arg stub left rddads' on-stack pucDateBuf / pucTimeBuf uninitialised. + +namespace { + +UNSIGNED32 emit_text_with_u16len(UNSIGNED8* pucBuf, UNSIGNED16* pusLen, + const std::string& s) { + if (pusLen == nullptr) return openads::AE_INTERNAL_ERROR; + UNSIGNED16 cap = *pusLen; + UNSIGNED16 n = static_cast(s.size() < cap ? s.size() : cap); + if (pucBuf != nullptr && cap > 0) { + if (n > 0) std::memcpy(pucBuf, s.data(), n); + if (n < cap) pucBuf[n] = '\0'; + } + *pusLen = static_cast(s.size()); + return openads::AE_SUCCESS; +} + +} // namespace + +UNSIGNED32 ENTRYPOINT AdsGetServerName(ADSHANDLE /*hConnect*/, + UNSIGNED8* pucBuf, UNSIGNED16* pusLen) { + arc2_trace("AdsGetServerName"); + return emit_text_with_u16len(pucBuf, pusLen, + openads::platform::host_name()); +} + +UNSIGNED32 ENTRYPOINT AdsGetServerTime(ADSHANDLE /*hConnect*/, + UNSIGNED8* pucDateBuf, UNSIGNED16* pusDateLen, + SIGNED32* plTime, + UNSIGNED8* pucTimeBuf, UNSIGNED16* pusTimeLen) { + arc2_trace("AdsGetServerTime"); + auto wc = openads::platform::now_local(); + auto rc = emit_text_with_u16len(pucDateBuf, pusDateLen, wc.date); + if (rc != openads::AE_SUCCESS) return rc; + rc = emit_text_with_u16len(pucTimeBuf, pusTimeLen, wc.time); + if (rc != openads::AE_SUCCESS) return rc; + if (plTime != nullptr) *plTime = wc.ms_of_day; + return openads::AE_SUCCESS; +} + +// OpenADS extension (no SAP equivalent): dotted version of the server +// behind hConnect ("1.09.27"), so a Harbour app can prove WHICH +// serverd binary it is talking to — "the fix didn't help" reports +// keep turning out to be an old serverd still running. +// +// Remote: the HelloAck handshake version captured at connect time +// ("openads/1.09.27" on current servers, the literal "openads/0.3.2" +// on pre-1.8.14 ones), prefix stripped. Empty when the probe failed — +// still AE_SUCCESS; the caller treats empty as unknown. +// Local (or unresolvable handle): the DLL's own build version, which +// IS the server in-process. +UNSIGNED32 ENTRYPOINT AdsGetServerVersion(ADSHANDLE hConnect, + UNSIGNED8* pucBuf, UNSIGNED16* pusLen) { + arc2_trace("AdsGetServerVersion"); + // Remote only: a valid LOCAL Connection handle must never adopt an + // unrelated live remote connection through resolve_remote_conn_handle's + // thread-default/any-live fallback — same guard as AdsCreateTable. + openads::network::RemoteConnection* rc = get_remote_connection(hConnect); + if (rc == nullptr && + state().registry.lookup( + hConnect, HandleKind::Connection) == nullptr) { + ADSHANDLE rh = resolve_remote_conn_handle(hConnect); + if (rh != 0) rc = get_remote_connection(rh); + } + std::string v; + if (rc != nullptr) { + v = rc->server_version(); + auto slash = v.find('/'); + if (slash != std::string::npos) v.erase(0, slash + 1); + // Empty probe stays empty: unknown, still AE_SUCCESS. + } else { +#ifdef OPENADS_VERSION_STR + v = OPENADS_VERSION_STR; +#else + v = "0.0"; +#endif + } + return emit_text_with_u16len(pucBuf, pusLen, v); +} + +// ── Trigger execution ────────────────────────────────────────────────────────── +// Fires enabled triggers on `table_alias` matching `event_mask` (1=INSERT +// 2=UPDATE 3=DELETE) and `timing` (1=BEFORE 2=INSTEAD_OF 4=AFTER). +// Triggers are sorted by priority (ascending) before firing. +// Nesting is tracked with a thread-local depth counter; execution aborts +// when depth exceeds 64 to prevent infinite recursion. +// Returns true if at least one INSTEAD OF trigger was fired (caller should +// skip the actual DML in that case). + +// One collected trigger row-image field: display text + a canonical type +// char ('C','N','I','L','D','T'), so the script layer can hand cursors and +// expressions a properly TYPED value (S3 -- `n.recurring > 0` must see a +// number, not text). +struct TrigField_ { + std::string text; + char type = 'C'; +}; + +// Canonical type char for a driver field type. Time/RowVersion/blob-ish +// types stay 'C' (raw text) -- the script engine has no representation for +// them and text round-trips safely. +inline char trig_type_char_(openads::drivers::DbfFieldType t) { + using FT = openads::drivers::DbfFieldType; + switch (t) { + case FT::Numeric: case FT::Float: case FT::Currency: + case FT::Double: case FT::AdtMoney: + return 'N'; + case FT::Integer: case FT::ShortInt: case FT::AutoInc: + return 'I'; + case FT::Logical: + return 'L'; + case FT::Date: case FT::AdtDate: + return 'D'; + case FT::DateTime: case FT::AdtTimestamp: + return 'T'; + default: + return 'C'; + } +} + +// RCB 07/17/2026 (S2): trigger bodies run through the script engine. The +// runner is defined after the scriptbridge machinery (much later in this +// file, at global scope); declared here so fire_triggers_ can call it. +extern "C++" openads::util::Result script_run_trigger_body( + Connection* conn, ADSHANDLE hConn, const std::string& body, + const std::map& new_f, + const std::map& old_f, + bool is_instead_of); + +namespace { +thread_local int tl_trigger_depth = 0; +static constexpr int kTrigMaxDepth = 64; + +// Find the connection handle for a given Connection pointer. +Handle handle_for_conn(Connection* c) { + auto& s = state(); + Handle found = 0; + s.registry.for_each_handle([&](Handle h, HandleKind k, void* p) { + if (found) return; + if (k == HandleKind::Connection && static_cast(p) == c) + found = h; + }); + return found; +} + +// Return the SQL body to execute for a trigger -- prefer container unless it +// looks like a short type code (e.g. "1"), in which case fall back to procedure. +static const std::string& trigger_sql_body(const openads::engine::DataDict::TriggerEntry& e) { + if (e.container.size() > 4) return e.container; + if (!e.procedure.empty()) return e.procedure; + return e.container; +} + +// ── Procedural trigger body executor ──────────────────────────────────────── +// Implements a minimal interpreter for SAP ADS trigger body SQL: +// DECLARE @var TYPE -- declares a local variable (ignored, just tracked) +// SET @var = expr -- assigns a value; expr may be __new.field, __old.field, +// a string literal, a numeric literal, or a SQL expression +// __new.fieldname -- value of the new record's field (INSERT/UPDATE) +// __old.fieldname -- value of the old record's field (UPDATE/DELETE) +// All other statements are executed via AdsExecuteSQLDirect after substitution. + +// Type alias to avoid MSVC C2562 when returning std::map<> from a function +// inside an extern "C" / anonymous-namespace block. +// +// The trig_* helpers below return C++ types (std::string / std::vector / +// TrigError_). Give them C++ linkage so MSVC does not raise C4190 (C-linkage +// function returning a C++-incompatible type) under /W4 /WX. +extern "C++" { + +using TrigFieldMap_ = std::map; + +// SQL-quote a raw string value (escape embedded quotes, wrap in single quotes). +// Collect all field values from a Table into a lowercase-keyed map of +// (display text, field type char). Char fields are space-trimmed. +static void trig_collect_row_(Table* t, TrigFieldMap_& m) { + if (!t) return; + std::uint16_t nf = t->field_count(); + for (std::uint16_t i = 0; i < nf; ++i) { + const auto& fd = t->field_descriptor(i); + std::string name = fd.name; + for (auto& ch : name) + ch = static_cast(std::tolower(static_cast(ch))); + char tc = trig_type_char_(fd.type); + auto v = t->read_field(i); + if (!v) { m[name] = TrigField_{"", tc}; continue; } + std::string sv = v.value().as_string; + while (!sv.empty() && sv.back() == ' ') sv.pop_back(); + m[name] = TrigField_{std::move(sv), tc}; + } +} + +// Error info returned from a trigger body (via INSERT INTO __error). +struct TrigError_ { + bool has_error = false; + std::uint32_t errno_val = 0; + std::string message; +}; + +} // extern "C++" -- trig_ helpers regain C++ linkage (silences C4190) + +// fire_triggers_ -- fire all enabled, matching triggers for a given event + timing. +// timing: 1=BEFORE 2=INSTEAD_OF 4=AFTER +// Returns true if an INSTEAD OF trigger was fired (caller should skip the actual DML). +bool fire_triggers_(Handle hConn, Connection* conn, + const std::string& table_alias, std::uint32_t event_mask, + std::uint32_t timing, + Table* new_tbl = nullptr, Table* old_tbl = nullptr, + TrigError_* out_err = nullptr, + // S4 -- pre-built row images for firings where no table + // row exists yet (INSERT BEFORE / INSTEAD OF: the image + // comes from the statement's VALUES). + const TrigFieldMap_* new_override = nullptr, + const TrigFieldMap_* old_override = nullptr) { + if (tl_trigger_depth >= kTrigMaxDepth) return false; // depth limit + if (conn->triggers_disabled()) return false; // connection-level disable + auto* dd = conn->dd(); + if (!dd) return false; + + // RCB 06/30/2026: Trigger firing is on every DML path, so ask the DD for + // enabled triggers already scoped by table/event/timing and sorted by + // priority instead of scanning the full trigger catalogue for each row. + const auto& matched = dd->triggers_for_event(table_alias, event_mask, timing); + if (matched.empty()) return false; + + // Collect __new / __old field values if WANT_VALUES option is set (bit 0x01). + // If any trigger in the set requires values, collect them for all. + bool want_values = false; + bool want_memos = false; + for (const auto* e : matched) { + if (e->options & 0x01u) { want_values = true; } + if (e->options & 0x02u) { want_memos = true; } + } + TrigFieldMap_ new_fields, old_fields; + // S4 -- statement-supplied images take precedence (INSERT BEFORE / + // INSTEAD OF fire before any row exists to collect from). + if (new_override != nullptr) new_fields = *new_override; + if (old_override != nullptr) old_fields = *old_override; + if (want_values && new_override == nullptr && old_override == nullptr) { + if (want_memos) { + trig_collect_row_(new_tbl, new_fields); + trig_collect_row_(old_tbl, old_fields); + } else { + // NO MEMOS/BLOBS option: skip memo and binary fields + auto collect_no_memo = [](Table* t, TrigFieldMap_& m) { + if (!t) return; + std::uint16_t nf = t->field_count(); + for (std::uint16_t i = 0; i < nf; ++i) { + const auto& fd = t->field_descriptor(i); + // Skip memo and blob field types (NO MEMOS option). + // RCB 07/18/2026: was a char-vs-enum comparison that + // never matched -- fixed to compare the enum. + using FT = openads::drivers::DbfFieldType; + if (fd.type == FT::Memo || fd.type == FT::Binary || + fd.type == FT::Varbinary) + continue; + std::string name = fd.name; + for (auto& ch : name) + ch = static_cast(std::tolower(static_cast(ch))); + char tc = trig_type_char_(fd.type); + auto v = t->read_field(i); + if (!v) { m[name] = TrigField_{"", tc}; continue; } + std::string sv = v.value().as_string; + while (!sv.empty() && sv.back() == ' ') sv.pop_back(); + m[name] = TrigField_{std::move(sv), tc}; + } + }; + collect_no_memo(new_tbl, new_fields); + collect_no_memo(old_tbl, old_fields); + } + } + + // S4 -- re-entrancy guard: a write performed INSIDE a trigger body to + // the SAME table/event does not re-fire that trigger (SAP semantics -- + // the INSTEAD OF idiom `insert into t select * from __new` would + // otherwise recurse). + static thread_local std::vector tl_active_fires; + std::string fire_key = table_alias + "|" + + std::to_string(event_mask) + "|" + + std::to_string(timing); + if (std::find(tl_active_fires.begin(), tl_active_fires.end(), + fire_key) != tl_active_fires.end()) + return false; + struct FireGuard { + std::vector& v; + ~FireGuard() { v.pop_back(); } + }; + tl_active_fires.push_back(fire_key); + FireGuard fire_guard{tl_active_fires}; + + bool instead_of_fired = false; + ++tl_trigger_depth; + for (const auto* e : matched) { + const auto& sql_body = trigger_sql_body(*e); + if (sql_body.empty()) continue; + + // Strip trailing non-printable garbage (binary .am file padding) + std::string body_copy = sql_body; + while (!body_copy.empty()) { + unsigned char last = static_cast(body_copy.back()); + if (last >= 0x20u || last == '\t' || last == '\n' || last == '\r') break; + body_copy.pop_back(); + } + + // RCB 07/17/2026 (S2): the body runs through the typed script + // engine (full IF/WHILE/TRY, EXECUTE PROCEDURE into DD script + // procs, RAISE). A failure is REPORTED to the caller -- the old + // fragment silently swallowed it, so pmsys audit triggers "fired" + // while writing nothing and the DML still reported success. + auto r = script_run_trigger_body(conn, to_ads_handle(hConn), body_copy, + new_fields, old_fields, + timing == 2u /*is_instead_of*/); + if (timing == 2u) instead_of_fired = true; + if (!r && out_err != nullptr && !out_err->has_error) { + // Per SAP semantics remaining triggers still fire; the first + // error is what the client sees after all of them complete. + out_err->has_error = true; + out_err->errno_val = + static_cast(r.error().code); + out_err->message = r.error().message; + } + } + --tl_trigger_depth; + return instead_of_fired; +} +} // anonymous namespace + +UNSIGNED32 ENTRYPOINT AdsAppendRecord(ADSHANDLE hTable) { + arc2_trace("AdsAppendRecord"); + if (auto* rt = get_remote_table(hTable)) { + if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; + remote_settle_cursor(rt); // M12.21 option C + rt->row_valid = false; // M12.17 + rt->rec_count_cached = false; + rt->key_count_cached = false; // M12.19 + rt->key_counts.clear(); + rt->key_counts.clear(); + // Cursor moves onto a new blank; any prior keyno is for a different + // row. Leaving keyno_valid set made AdsGetKeyNum/GetRelKeyPos report + // the pre-append position until the next nav invalidation -- wrong + // scrollbar math after TXBrowse:Refresh() following DBAPPEND. + rt->keyno_valid = false; + remote_clear_nav_boundaries(rt); + rt->invalidate_prefetch(); + auto r = rt->conn->append_blank(rt->id); + if (!r) return fail(r.error()); + // Fresh appends auto-lock (non-exclusive tables): pooled reuse + // must not resurrect a locked handle. The ack carries no + // recno, so the ledger cannot name the auto-lock -- mark the + // lock state uncertain until a full UnlockTable proves clear. + rt->ever_locked = true; + rt->locks_uncertain = true; + rt->write_dirty = true; + return ok(); + } +#if defined(OPENADS_WITH_FIREBIRD) + if (auto* ft = get_firebird_table(hTable)) { + if (ft->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + if (UNSIGNED32 rc = sql_uri_check_dml( + ft->connect_handle, ft->check_rights, ft->name, + SqlUriDmlOp::Insert, + openads::sql_backend::SqlDdlDialect::Firebird, ft->conn, + firebird_acl_query); + rc != openads::AE_SUCCESS) { + return rc; + } + auto r = ft->conn->append_blank(ft); + if (!r) return fail(r.error()); + return hook_auto_commit(ft); + } +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* pt = get_postgres_table(hTable)) { + if (pt->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + if (UNSIGNED32 rc = sql_uri_check_dml( + pt->connect_handle, pt->check_rights, pt->name, + SqlUriDmlOp::Insert, + openads::sql_backend::SqlDdlDialect::Postgres, pt->conn, + postgres_acl_query); + rc != openads::AE_SUCCESS) { + return rc; + } + auto r = pt->conn->append_blank(pt); + if (!r) return fail(r.error()); + return hook_auto_commit(pt); + } +#endif +#if defined(OPENADS_WITH_MARIADB) + if (auto* mt = get_maria_table(hTable)) { + if (mt->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + if (UNSIGNED32 rc = sql_uri_check_dml( + mt->connect_handle, mt->check_rights, mt->name, + SqlUriDmlOp::Insert, + openads::sql_backend::SqlDdlDialect::Maria, mt->conn, + maria_acl_query); + rc != openads::AE_SUCCESS) { + return rc; + } + auto r = mt->conn->append_blank(mt); + if (!r) return fail(r.error()); + return hook_auto_commit(mt); + } +#endif +#if defined(OPENADS_WITH_ODBC) + if (auto* ot = get_odbc_table(hTable)) { + if (ot->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + if (UNSIGNED32 rc = sql_uri_check_dml( + ot->connect_handle, ot->check_rights, ot->name, + SqlUriDmlOp::Insert, + odbc_dialect_for(ot->connect_handle), ot->conn, + odbc_acl_query); + rc != openads::AE_SUCCESS) { + return rc; + } + auto r = ot->conn->append_blank(ot); + if (!r) return fail(r.error()); + return hook_auto_commit(ot); + } +#endif +#if defined(OPENADS_WITH_SQLITE) + if (auto* st = get_sqlite_table(hTable)) { + if (st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + if (UNSIGNED32 rc = sql_uri_check_dml( + st->connect_handle, st->check_rights, st->name, + SqlUriDmlOp::Insert, + openads::sql_backend::SqlDdlDialect::Sqlite, st->conn, + sqlite_acl_query); + rc != openads::AE_SUCCESS) { + return rc; + } + auto r = st->conn->append_blank(st); + if (!r) return fail(r.error()); + return hook_auto_commit(st); + } +#endif +#if defined(OPENADS_WITH_MSSQL) + if (auto* mt = get_mssql_table(hTable)) { + if (mt->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + if (UNSIGNED32 rc = sql_uri_check_dml( + mt->connect_handle, mt->check_rights, mt->name, + SqlUriDmlOp::Insert, + openads::sql_backend::SqlDdlDialect::Mssql, mt->conn, + mssql_acl_query); + rc != openads::AE_SUCCESS) { + return rc; + } + auto r = mt->conn->append_blank(mt); + if (!r) return fail(r.error()); + return hook_auto_commit(mt); + } +#endif + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + auto r = t->append_record(); + if (!r) return fail(r.error()); + // ACE semantics: a freshly-appended record in a non-exclusive table is + // automatically locked. Done inside engine append_record() so engine- + // level callers (SQL INSERT, server AppendBlank) get it too. X#'s + // ADSRDD relies on this -- its GoHot refuses to write a record it sees + // as unlocked. + t->set_pending_append(true); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsWriteRecord(ADSHANDLE hTable) { + arc2_trace("AdsWriteRecord"); + if (auto* rt = get_remote_table(hTable)) { + // Snapshot dirtiness before remote_flush_pending drains the + // buffered sets: a commit with nothing written since the last + // flush (rddads DBCOMMITALL touches every open workarea) owes + // the server no frame at all -- it would only fsync unchanged + // pages, 1 RTT each, 16 of them after a Vouch voucher save. + const bool had_writes = + !rt->pending_sets.empty() || rt->write_dirty; + if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; + rt->row_valid = false; // M12.17 cache invalidation + // A write can move the row in/out of a conditional order or + // scope: the cached key count may have changed with it. + rt->key_count_cached = false; + rt->key_counts.clear(); + rt->key_counts.clear(); + // Key fields may have moved the row in the active order (or this is + // the flush of a fresh append). Drop the key position so the next + // AdsGetKeyNum / AdsGetRelKeyPos re-seeds via server GetKeyNum (O(1)) + // instead of serving a stale current_keyno. + rt->keyno_valid = false; + rt->invalidate_prefetch(); + if (!had_writes) { + cli_trace_tbl(rt, "AdsWriteRecord", "clean: flush skipped"); + return ok(); + } + auto r = rt->conn->flush_table(rt->id); + if (!r) return fail(r.error()); + // kCapFlushTableDurable servers run the full file-buffers + // flush inside the FlushTable handler, so the commit is + // durable right here and a trailing AdsFlushFileBuffers owes + // nothing (1 RTT saved per commit). Old servers keep the + // classic two-frame pair. + rt->write_dirty = !rt->conn->server_flush_table_durable(); + return ok(); + } +#if defined(OPENADS_WITH_FIREBIRD) + if (auto* ft = get_firebird_table(hTable)) { + if (ft->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + if (UNSIGNED32 rc = sql_uri_check_dml( + ft->connect_handle, ft->check_rights, ft->name, + ft->pending_append ? SqlUriDmlOp::Insert : SqlUriDmlOp::Update, + openads::sql_backend::SqlDdlDialect::Firebird, ft->conn, + firebird_acl_query); + rc != openads::AE_SUCCESS) { + return rc; + } + auto r = ft->conn->flush_record(ft); + if (!r) return fail(r.error()); + return hook_auto_commit(ft); + } +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* pt = get_postgres_table(hTable)) { + if (pt->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + if (UNSIGNED32 rc = sql_uri_check_dml( + pt->connect_handle, pt->check_rights, pt->name, + pt->pending_append ? SqlUriDmlOp::Insert : SqlUriDmlOp::Update, + openads::sql_backend::SqlDdlDialect::Postgres, pt->conn, + postgres_acl_query); + rc != openads::AE_SUCCESS) { + return rc; + } + auto r = pt->conn->flush_record(pt); + if (!r) return fail(r.error()); + return hook_auto_commit(pt); + } +#endif +#if defined(OPENADS_WITH_MARIADB) + if (auto* mt = get_maria_table(hTable)) { + if (mt->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + if (UNSIGNED32 rc = sql_uri_check_dml( + mt->connect_handle, mt->check_rights, mt->name, + mt->pending_append ? SqlUriDmlOp::Insert : SqlUriDmlOp::Update, + openads::sql_backend::SqlDdlDialect::Maria, mt->conn, + maria_acl_query); + rc != openads::AE_SUCCESS) { + return rc; + } + auto r = mt->conn->flush_record(mt); + if (!r) return fail(r.error()); + return hook_auto_commit(mt); + } +#endif +#if defined(OPENADS_WITH_ODBC) + if (auto* ot = get_odbc_table(hTable)) { + if (ot->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + if (UNSIGNED32 rc = sql_uri_check_dml( + ot->connect_handle, ot->check_rights, ot->name, + ot->appending ? SqlUriDmlOp::Insert : SqlUriDmlOp::Update, + odbc_dialect_for(ot->connect_handle), ot->conn, + odbc_acl_query); + rc != openads::AE_SUCCESS) { + return rc; + } + auto r = ot->conn->flush_table(ot); + if (!r) return fail(r.error()); + return hook_auto_commit(ot); + } +#endif +#if defined(OPENADS_WITH_SQLITE) + if (auto* st = get_sqlite_table(hTable)) { + if (st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + if (UNSIGNED32 rc = sql_uri_check_dml( + st->connect_handle, st->check_rights, st->name, + st->pending_append ? SqlUriDmlOp::Insert : SqlUriDmlOp::Update, + openads::sql_backend::SqlDdlDialect::Sqlite, st->conn, + sqlite_acl_query); + rc != openads::AE_SUCCESS) { + return rc; + } + auto r = st->conn->flush_record(st); + if (!r) return fail(r.error()); + return hook_auto_commit(st); + } +#endif +#if defined(OPENADS_WITH_MSSQL) + if (auto* mt = get_mssql_table(hTable)) { + if (mt->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + if (UNSIGNED32 rc = sql_uri_check_dml( + mt->connect_handle, mt->check_rights, mt->name, + mt->pending_append ? SqlUriDmlOp::Insert : SqlUriDmlOp::Update, + openads::sql_backend::SqlDdlDialect::Mssql, mt->conn, + mssql_acl_query); + rc != openads::AE_SUCCESS) { + return rc; + } + auto r = mt->conn->flush_record(mt); + if (!r) return fail(r.error()); + return hook_auto_commit(mt); + } +#endif + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + bool is_insert = t->pending_append(); + std::uint32_t event_mask = is_insert ? 1u : 2u; + + if (is_insert) { + if (Connection* conn = conn_for_table(t)) { + if (auto ri = ri_check_insert(conn, *t); !ri) + return fail(ri.error()); + } + } else { + if (Connection* conn = conn_for_table(t)) { + if (auto ri = ri_enforce_update(conn, *t); !ri) + return fail(ri.error()); + } + } + + // Trigger firing order: INSTEAD OF → (skip flush) OR BEFORE → flush → AFTER + // RCB 07/17/2026 (S2): trigger failures propagate to the client (SAP + // oracle probe Q6): BEFORE/INSTEAD OF failure blocks the write; AFTER + // failure returns the error but the write persists. + TrigError_ terr; + if (Connection* conn = conn_for_table(t)) { + std::string alias = ri_alias_for_path(conn, t->path()); + if (!alias.empty()) { + Handle hConn = handle_for_conn(conn); + if (hConn) { + // INSTEAD OF trigger: fire and skip the actual write + if (fire_triggers_(hConn, conn, alias, event_mask, + 2u /*INSTEAD_OF*/, t, nullptr, &terr)) { + if (terr.has_error) + return fail(static_cast(terr.errno_val), terr.message.c_str()); + return ok(); + } + // BEFORE trigger: fire before the write + fire_triggers_(hConn, conn, alias, event_mask, + 1u /*BEFORE*/, t, nullptr, &terr); + if (terr.has_error) + return fail(static_cast(terr.errno_val), terr.message.c_str()); + } + } + } + + // Always settle the dirty record buffer (field replaces coalesce until + // here). deferred_flush only skips OS FlushFileBuffers / index fsync. + // A bare append (AdsAppendRecord + WriteRecord with NO field writes) + // never marked the row dirty, so commit_dirty_record would skip it and + // the record would stay unkeyed (the blank-key test pins this). Key it + // here exactly once: append_record deliberately does not key eagerly + // (the eager per-append insert measured a ~40% remote append + // regression — see the NOTE in Table::append_record()). + if (is_insert && !t->record_dirty()) { + auto r = t->commit_bare_append(); + if (!r) return fail(r.error()); + if (!t->deferred_flush()) { + auto r2 = t->flush(); + if (!r2) return fail(r2.error()); + } + } else if (t->deferred_flush()) { + auto r = t->commit_dirty_record(); + if (!r) return fail(r.error()); + } else { + auto r = t->flush(); + if (!r) return fail(r.error()); + } + + if (Connection* conn = conn_for_table(t)) { + std::string alias = ri_alias_for_path(conn, t->path()); + if (!alias.empty()) { + Handle hConn = handle_for_conn(conn); + // AFTER trigger: __new = current record (new values for UPDATE, inserted for INSERT) + if (hConn) { + fire_triggers_(hConn, conn, alias, event_mask, + 4u /*AFTER*/, t, nullptr, &terr); + if (terr.has_error) + return fail(static_cast(terr.errno_val), terr.message.c_str()); + } + } + } + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsDeleteRecord(ADSHANDLE hTable) { + arc2_trace("AdsDeleteRecord"); + if (auto* rt = get_remote_table(hTable)) { + if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; + remote_settle_cursor(rt); // M12.21 option C + rt->row_valid = false; // M12.17 + rt->rec_count_cached = false; + rt->key_count_cached = false; // M12.19 (Pack drops the row) + rt->key_counts.clear(); + rt->key_counts.clear(); + rt->keyno_valid = false; // order position may shift + remote_clear_nav_boundaries(rt); + rt->invalidate_prefetch(); + auto r = rt->conn->delete_record(rt->id); + if (!r) return fail(r.error()); + rt->write_dirty = true; + return ok(); + } +#if defined(OPENADS_WITH_FIREBIRD) + if (auto* ft = get_firebird_table(hTable)) { + if (ft->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + if (UNSIGNED32 rc = sql_uri_check_dml( + ft->connect_handle, ft->check_rights, ft->name, + SqlUriDmlOp::Delete, + openads::sql_backend::SqlDdlDialect::Firebird, ft->conn, + firebird_acl_query); + rc != openads::AE_SUCCESS) { + return rc; + } + auto r = ft->conn->delete_record(ft); + if (!r) return fail(r.error()); + return hook_auto_commit(ft); + } +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* pt = get_postgres_table(hTable)) { + if (pt->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + if (UNSIGNED32 rc = sql_uri_check_dml( + pt->connect_handle, pt->check_rights, pt->name, + SqlUriDmlOp::Delete, + openads::sql_backend::SqlDdlDialect::Postgres, pt->conn, + postgres_acl_query); + rc != openads::AE_SUCCESS) { + return rc; + } + auto r = pt->conn->delete_record(pt); + if (!r) return fail(r.error()); + return hook_auto_commit(pt); + } +#endif +#if defined(OPENADS_WITH_MARIADB) + if (auto* mt = get_maria_table(hTable)) { + if (mt->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + if (UNSIGNED32 rc = sql_uri_check_dml( + mt->connect_handle, mt->check_rights, mt->name, + SqlUriDmlOp::Delete, + openads::sql_backend::SqlDdlDialect::Maria, mt->conn, + maria_acl_query); + rc != openads::AE_SUCCESS) { + return rc; + } + auto r = mt->conn->delete_record(mt); + if (!r) return fail(r.error()); + return hook_auto_commit(mt); + } +#endif +#if defined(OPENADS_WITH_ODBC) + if (auto* ot = get_odbc_table(hTable)) { + if (ot->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + if (UNSIGNED32 rc = sql_uri_check_dml( + ot->connect_handle, ot->check_rights, ot->name, + SqlUriDmlOp::Delete, + odbc_dialect_for(ot->connect_handle), ot->conn, + odbc_acl_query); + rc != openads::AE_SUCCESS) { + return rc; + } + auto r = ot->conn->delete_record(ot); + if (!r) return fail(r.error()); + return hook_auto_commit(ot); + } +#endif +#if defined(OPENADS_WITH_SQLITE) + if (auto* st = get_sqlite_table(hTable)) { + if (st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + if (UNSIGNED32 rc = sql_uri_check_dml( + st->connect_handle, st->check_rights, st->name, + SqlUriDmlOp::Delete, + openads::sql_backend::SqlDdlDialect::Sqlite, st->conn, + sqlite_acl_query); + rc != openads::AE_SUCCESS) { + return rc; + } + auto r = st->conn->delete_record(st); + if (!r) return fail(r.error()); + return hook_auto_commit(st); + } +#endif +#if defined(OPENADS_WITH_MSSQL) + if (auto* mt = get_mssql_table(hTable)) { + if (mt->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + if (UNSIGNED32 rc = sql_uri_check_dml( + mt->connect_handle, mt->check_rights, mt->name, + SqlUriDmlOp::Delete, + openads::sql_backend::SqlDdlDialect::Mssql, mt->conn, + mssql_acl_query); + rc != openads::AE_SUCCESS) { + return rc; + } + auto r = mt->conn->delete_record(mt); + if (!r) return fail(r.error()); + return hook_auto_commit(mt); + } +#endif + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + t->set_pending_append(false); // abandon any in-flight append + if (Connection* conn = conn_for_table(t)) { + if (auto ri = ri_enforce_delete(conn, *t); !ri) + return fail(ri.error()); + } + + // Trigger firing order for DELETE: INSTEAD OF → (skip delete) OR BEFORE → delete → AFTER + // RCB 07/17/2026 (S2): failures propagate (probe Q6 semantics). + TrigError_ terr; + if (Connection* conn = conn_for_table(t)) { + std::string alias = ri_alias_for_path(conn, t->path()); + if (!alias.empty()) { + Handle hConn = handle_for_conn(conn); + if (hConn) { + // INSTEAD OF DELETE: __old = current record + if (fire_triggers_(hConn, conn, alias, 3u, 2u /*INSTEAD_OF*/, + nullptr, t, &terr)) { + if (terr.has_error) + return fail(static_cast(terr.errno_val), terr.message.c_str()); + return ok(); + } + // BEFORE DELETE: __old = current record (about to be deleted) + fire_triggers_(hConn, conn, alias, 3u, 1u /*BEFORE*/, + nullptr, t, &terr); + if (terr.has_error) + return fail(static_cast(terr.errno_val), terr.message.c_str()); + } + } + } + + auto r = t->mark_deleted(); + if (!r) return fail(r.error()); + + if (Connection* conn = conn_for_table(t)) { + std::string alias = ri_alias_for_path(conn, t->path()); + if (!alias.empty()) { + Handle hConn = handle_for_conn(conn); + // AFTER DELETE: __old = the deleted record + if (hConn) { + fire_triggers_(hConn, conn, alias, 3u, 4u /*AFTER*/, + nullptr, t, &terr); + if (terr.has_error) + return fail(static_cast(terr.errno_val), terr.message.c_str()); + } + } + } + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsRecallRecord(ADSHANDLE hTable) { + arc2_trace("AdsRecallRecord"); + if (auto* rt = get_remote_table(hTable)) { + if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; + remote_settle_cursor(rt); // M12.21 option C + rt->row_valid = false; // M12.17 + rt->rec_count_cached = false; + rt->key_count_cached = false; // M12.19 + rt->key_counts.clear(); + rt->key_counts.clear(); + rt->keyno_valid = false; // order position may shift + remote_clear_nav_boundaries(rt); + rt->invalidate_prefetch(); + auto r = rt->conn->recall_record(rt->id); + if (!r) return fail(r.error()); + rt->write_dirty = true; + return ok(); + } + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + auto r = t->recall_deleted(); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsIsRecordDeleted(ADSHANDLE hTable, UNSIGNED16* pbDeleted) { + arc2_trace("AdsIsRecordDeleted"); + if (pbDeleted == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (auto* rt = get_remote_table(hTable)) { + // M12.18 -- deleted flag rides with the row trailer. + if (rt->row_valid) { + *pbDeleted = rt->current_deleted ? 1 : 0; + return ok(); + } + auto r = rt->conn->is_record_deleted(rt->id); + if (!r) return fail(r.error()); + *pbDeleted = r.value() ? 1 : 0; + return ok(); + } + 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; + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsSetString(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED8* pucValue, UNSIGNED32 ulLen) { + arc2_trace("AdsSetString"); + // RCB 2026-05-22 17:03 -- AdsSet* previously had no awareness of statement + // handles. get_table() only queries the HandleRegistry for HandleKind::Table + // and returns nullptr for anything else, so calls against a prepared statement + // handle always failed with [5000] unknown table. We check set_stmt_param + // first; if the handle is in stmt_map the value is stored as a quoted SQL + // string literal and we return immediately without touching the table path. + // Single quotes inside the value are doubled to produce a valid SQL literal. + if (pucField != nullptr) { + std::string val(pucValue ? reinterpret_cast(pucValue) : "", + pucValue ? static_cast(ulLen) : 0u); + std::string escaped; + escaped.reserve(val.size() + 2); + for (char c : val) { escaped += c; if (c == '\'') escaped += c; } + if (set_stmt_param(hTable, + reinterpret_cast(pucField), + "'" + escaped + "'")) + return ok(); + } + if (auto* rt = get_remote_table(hTable)) { + if (pucField == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + auto i = remote_field_index(rt, pucField); + if (i == std::numeric_limits::max() || i >= rt->fields.size()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + std::string fname = rt->fields[i].name; + std::string val; + if (pucValue != nullptr && ulLen > 0) { + val.assign(reinterpret_cast(pucValue), ulLen); + } + return remote_buffered_set(rt, fname, val); + } +#if defined(OPENADS_WITH_FIREBIRD) + if (auto* ft = get_firebird_table(hTable)) { + if (pucField == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (ft->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + std::string fname(reinterpret_cast(pucField)); + std::string val; + if (pucValue != nullptr && ulLen > 0) + val.assign(reinterpret_cast(pucValue), ulLen); + auto r = ft->conn->set_field(ft, fname, val); + if (!r) return fail(r.error()); + return ok(); + } +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* pt = get_postgres_table(hTable)) { + if (pucField == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (pt->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + std::string fname(reinterpret_cast(pucField)); + std::string val; + if (pucValue != nullptr && ulLen > 0) + val.assign(reinterpret_cast(pucValue), ulLen); + auto r = pt->conn->set_field(pt, fname, val); + if (!r) return fail(r.error()); + return ok(); + } +#endif +#if defined(OPENADS_WITH_MARIADB) + if (auto* mt = get_maria_table(hTable)) { + if (pucField == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (mt->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + std::string fname(reinterpret_cast(pucField)); + std::string val; + if (pucValue != nullptr && ulLen > 0) + val.assign(reinterpret_cast(pucValue), ulLen); + auto r = mt->conn->set_field(mt, fname, val); + if (!r) return fail(r.error()); + return ok(); + } +#endif +#if defined(OPENADS_WITH_ODBC) + if (auto* ot = get_odbc_table(hTable)) { + if (pucField == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (ot->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + std::string fname(reinterpret_cast(pucField)); + std::string val; + if (pucValue != nullptr && ulLen > 0) + val.assign(reinterpret_cast(pucValue), ulLen); + auto r = ot->conn->set_field(ot, fname, val); + if (!r) return fail(r.error()); + return ok(); + } +#endif +#if defined(OPENADS_WITH_SQLITE) + if (auto* st = get_sqlite_table(hTable)) { + if (pucField == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (st->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + std::string fname(reinterpret_cast(pucField)); + std::string val; + if (pucValue != nullptr && ulLen > 0) + val.assign(reinterpret_cast(pucValue), ulLen); + auto r = st->conn->set_field(st, fname, val); + if (!r) return fail(r.error()); + return ok(); + } +#endif +#if defined(OPENADS_WITH_MSSQL) + if (auto* mt = get_mssql_table(hTable)) { + if (pucField == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (mt->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + std::string fname(reinterpret_cast(pucField)); + std::string val; + if (pucValue != nullptr && ulLen > 0) + val.assign(reinterpret_cast(pucValue), ulLen); + auto r = mt->conn->set_field(mt, fname, val); + if (!r) return fail(r.error()); + return ok(); + } +#endif + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + std::uint16_t idx = 0; + if (!resolve_field_index(t, pucField, &idx)) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + std::string val(reinterpret_cast(pucValue), ulLen); + auto r = t->set_field(idx, val); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsSetLogical(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED16 bValue) { + arc2_trace("AdsSetLogical"); + // RCB 2026-05-22 17:03 -- same statement-handle gap as AdsSetString. + // Logical fields in DBF are stored as 'T'/'F' but the SQL parser accepts + // 1 and 0 in INSERT/UPDATE VALUES, so we emit those as the literal. + if (pucField != nullptr) + if (set_stmt_param(hTable, + reinterpret_cast(pucField), + bValue ? "1" : "0")) + return ok(); + if (auto* rt = get_remote_table(hTable)) { + if (pucField == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + auto i = remote_field_index(rt, pucField); + if (i == std::numeric_limits::max() || i >= rt->fields.size()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + std::string fname = rt->fields[i].name; + // Send the DBF logical byte itself ('T'/'F'), not "1"/"0": servers + // that write strings through AdsSetString (twin handle) stored the + // '1' raw, which index FOR evaluation and DBFCDX read as .F. + return remote_buffered_set(rt, fname, bValue ? "T" : "F"); + } + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + std::uint16_t idx = 0; + if (!resolve_field_index(t, pucField, &idx)) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + auto r = t->set_field(idx, bValue != 0); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsSetDouble(ADSHANDLE hTable, UNSIGNED8* pucField, + double dValue) { + arc2_trace("AdsSetDouble"); + // RCB 2026-05-22 17:03 -- same statement-handle gap as AdsSetString. + // AdsSetLongLong also routes through here (it casts to double before calling + // us), so fixing this one covers both numeric bind types. We use a char + // buffer with snprintf rather than std::to_string to avoid locale-dependent + // decimal separators that would break the SQL parser. + if (pucField != nullptr) { + char nbuf[64]; + std::snprintf(nbuf, sizeof(nbuf), "%.17g", dValue); + if (set_stmt_param(hTable, + reinterpret_cast(pucField), + std::string(nbuf))) + return ok(); + } + if (auto* rt = get_remote_table(hTable)) { + if (pucField == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + auto i = remote_field_index(rt, pucField); + if (i == std::numeric_limits::max() || i >= rt->fields.size()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + std::string fname = rt->fields[i].name; + char nbuf[64]; + std::snprintf(nbuf, sizeof(nbuf), "%.17g", dValue); + return remote_buffered_set(rt, fname, std::string(nbuf)); + } + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + std::uint16_t idx = 0; + if (!resolve_field_index(t, pucField, &idx)) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + auto r = t->set_field(idx, dValue); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsSetLong(ADSHANDLE hTable, UNSIGNED8* pucField, + SIGNED32 lValue) { + arc2_trace("AdsSetLong"); + return AdsSetDouble(hTable, pucField, static_cast(lValue)); +} + +UNSIGNED32 ENTRYPOINT AdsSetLongLong(ADSHANDLE hTable, UNSIGNED8* pucField, + std::int64_t llValue) { + arc2_trace("AdsSetLongLong"); + return AdsSetDouble(hTable, pucField, static_cast(llValue)); +} + +namespace { + +// Inverse of to_julian -- convert a Clipper Julian Day Number back to +// a Gregorian (Y, M, D) triple. +void julian_to_ymd(SIGNED32 jd, int& y, int& m, int& d) { + long L = static_cast(jd) + 68569; + long N = (4 * L) / 146097; + L = L - (146097 * N + 3) / 4; + long I = (4000 * (L + 1)) / 1461001; + L = L - (1461 * I) / 4 + 31; + long J = (80 * L) / 2447; + d = static_cast(L - (2447 * J) / 80); + L = J / 11; + m = static_cast(J + 2 - 12 * L); + y = static_cast(100 * (N - 49) + I + L); +} + +void compact_ace_date_value(const UNSIGNED8* value, UNSIGNED16 len, + std::string* out) { + if (out == nullptr) return; + std::string s(value ? reinterpret_cast(value) : "", + value ? static_cast(len) : 0u); + // SAP's AdsSetDate parses the text against the CURRENT date format + // ("03/07/2025" under MM/DD/CCYY -> 2025-03-07); try that first. + // Everything below is the permissive OpenADS superset kept for + // existing callers: ISO "yyyy-mm-dd" and raw "YYYYMMDD" still land + // (SAP rejects raw with 5080 -- documented deviation, php and the + // remote twin write raw). + if (std::string byfmt = parse_date_by_format(s); !byfmt.empty()) { + *out = std::move(byfmt); + return; + } + if (s.size() == 10 && s[4] == '-' && s[7] == '-') { + *out = s.substr(0, 4) + s.substr(5, 2) + s.substr(8, 2); + return; + } + if (s.size() == 8) { + bool all_digits = true; + for (char c : s) { + all_digits = all_digits && + std::isdigit(static_cast(c)) != 0; + } + if (all_digits) { + *out = s; + return; + } + } + *out = s; +} + +} // namespace + +// Memo readers: rddads' adsGetValue routes HB_FT_MEMO fields through +// AdsGetMemoDataType + AdsGetMemoLength + AdsGetString. The first +// reports the memo's content type (text vs binary), the second the +// payload length, and the third copies the bytes into the caller's +// buffer. We resolve the field as before, fetch the memo block via +// the attached IMemoStore, and answer in kind. +UNSIGNED32 ENTRYPOINT AdsGetMemoLength(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED32* pulLen) { + arc2_trace("AdsGetMemoLength"); + if (pulLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (auto* rt = get_remote_table(hTable)) { + // Reuse the existing GetField wire op -- the returned string + // is the full memo content; size() is the memo length. + // Flush first: a buffered memo write must be visible to this read. + if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; + auto i = remote_field_index(rt, pucField); + if (i == std::numeric_limits::max() || i >= rt->fields.size()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + auto v = rt->conn->get_field(rt->id, rt->fields[i].name); + if (!v) return fail(v.error()); + *pulLen = static_cast(v.value().size()); + return ok(); + } + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); + std::uint16_t idx = 0; + if (!resolve_field_index(t, pucField, &idx)) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + auto v = t->read_field(idx); + if (!v) return fail(v.error()); + *pulLen = static_cast(v.value().as_string.size()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsGetMemoDataType(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED16* pusType) { + arc2_trace("AdsGetMemoDataType"); + if (pusType == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (auto* rt = get_remote_table(hTable)) { + auto i = remote_field_index(rt, pucField); + if (i == std::numeric_limits::max()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + switch (rt->fields[i].type) { + case ADS_IMAGE: + *pusType = static_cast(ADS_IMAGE); + break; + case ADS_BINARY: + *pusType = static_cast(ADS_BINARY); + break; + default: + *pusType = static_cast(ADS_STRING); + break; + } + return ok(); + } + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); + std::uint16_t idx = 0; + if (!resolve_field_index(t, pucField, &idx)) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + auto r = t->field_memo_type(idx); + if (!r) return fail(r.error()); + switch (r.value()) { + case openads::drivers::MemoBlockType::Text: + *pusType = static_cast(ADS_STRING); + break; + case openads::drivers::MemoBlockType::Picture: + *pusType = static_cast(ADS_IMAGE); + break; + case openads::drivers::MemoBlockType::Object: + *pusType = static_cast(ADS_BINARY); + break; + } + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsGetString(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED8* pucBuf, UNSIGNED32* pulLen, + UNSIGNED16 usOption) { + arc2_trace("AdsGetString"); + // Remote cursor OR a SQL backend (e.g. postgresql) that exposes a + // get_field op: delegate through AdsGetField (which already routes the + // remote row cache and the per-backend ops vtable) then apply the + // ADS_TRIM trailing-space behaviour AdsGetString promises. Without this + // a backend handle fell through to the native get_table path below and + // AdsGetString returned an error / empty string for SQL backends. + bool delegate = (get_remote_table(hTable) != nullptr); + if (!delegate) { + if (auto* ops = openads::abi::backend_table_ops_for(hTable)) + delegate = (ops->get_field != nullptr); + } + if (delegate) { + UNSIGNED32 raw_len = (pulLen && *pulLen > 0) ? *pulLen : 65536; + std::vector tmp(raw_len + 1, 0); + // AdsGetString promises the RAW storage text (dates "YYYYMMDD"), + // while AdsGetField formats per the date format -- suppress the + // formatting for the length of this delegated read. + g_field_read_raw = true; + UNSIGNED32 frc = AdsGetField(hTable, pucField, tmp.data(), + &raw_len, usOption); + g_field_read_raw = false; + if (frc != 0) + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + // Trim trailing spaces (ADS_TRIM behaviour) then copy to caller. + std::string s(reinterpret_cast(tmp.data()), raw_len); + auto last = s.find_last_not_of(' '); + if (last != std::string::npos) s.erase(last + 1); + else s.clear(); + UNSIGNED32 cap = pulLen ? *pulLen : 0; + UNSIGNED32 n = cap > 0 ? std::min(cap - 1, + static_cast(s.size())) : 0; + if (pucBuf != nullptr && cap > 0) { + if (n > 0) std::memcpy(pucBuf, s.data(), n); + pucBuf[n] = '\0'; + } + if (pulLen) *pulLen = static_cast(s.size()); + return ok(); + } + Table* t = get_table(hTable); + if (!t || pulLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + std::uint16_t idx = 0; + if (!resolve_field_index(t, pucField, &idx)) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + auto v = t->read_field(idx); + if (!v) return fail(v.error()); + std::string s = v.value().as_string; + // SAP returns the raw-width blank (" ", len 8) for an empty + // DATE read through GetString, not an empty string. Timestamps stay + // as decoded -- SAP raises 5066 for GetString on those, but php_ads + // depends on the raw "YYYYMMDDhhmmss" (documented deviation). + { + const auto& fdd = t->field_descriptor(idx); + if (s.empty() && + (fdd.type == openads::drivers::DbfFieldType::Date || + fdd.type == openads::drivers::DbfFieldType::AdtDate)) { + s.assign(8, ' '); + } + } + UNSIGNED32 cap = *pulLen; + UNSIGNED32 n = cap > 0 ? std::min(cap - 1, + static_cast(s.size())) + : 0; + if (pucBuf != nullptr && cap > 0) { + if (n > 0) std::memcpy(pucBuf, s.data(), n); + pucBuf[n] = '\0'; + } + *pulLen = static_cast(s.size()); + return ok(); +} + +// --- M9.17 Unicode (*W) variants ------------------------------------------ +// +// rddads' ADS_LIB_VERSION >= 1000 path routes UNICODE-flagged columns +// through AdsSetStringW / AdsGetStringW / AdsGetFieldW with WCHAR* +// buffers (UTF-16LE on Windows). The engine stores byte sequences +// without a fixed codepage assumption, so the W variants transcode +// at the boundary: UTF-16LE -> UTF-8 on the way in, UTF-8 -> UTF-16LE +// on the way out. Field names are 7-bit ASCII inside the DBF, so the +// pucFieldW name is dropped to ASCII via the same converter. + +namespace { + +// Resolve an ASCII / numeric `pucField` to a 0-based field index +// for the W-variant entry points. SAP keeps field names ASCII +// (UNSIGNED8*) even on the W variants; only the value buffer is +// wide. Small pointer values are interpreted as a 1-based field +// number (rddads' ADSFIELD macro), otherwise the value is a NUL- +// terminated ASCII field name. +bool resolve_field_index_w(Table* tbl, UNSIGNED8* pucField, + std::uint16_t* out) { + if (tbl == nullptr || out == nullptr) return false; + auto p = reinterpret_cast(pucField); + if (p != 0 && p < 0x10000u) { + std::uint16_t one_based = static_cast(p); + if (one_based >= 1 && one_based <= tbl->field_count()) { + *out = static_cast(one_based - 1); + return true; + } + return false; + } + if (pucField == nullptr) return false; + auto name = openads::abi::to_internal(pucField, 0); + // Delegate to Table::field_index -- case-insensitive (matches native + // ACE semantics) and cached. Field names in DBF/ADT storage are + // upper-cased, but callers (and CDX/NTX index expressions) may use + // any case; an exact-case compare here spuriously missed them. + std::int32_t idx = tbl->field_index(name); + if (idx < 0) return false; + *out = static_cast(idx); + return true; +} + +UNSIGNED32 emit_utf16(UNSIGNED16* pucBufW, UNSIGNED32* pulLenW, + const std::string& utf8) { + if (pulLenW == nullptr) return openads::AE_INTERNAL_ERROR; + auto units = openads::abi::utf8_to_utf16le(utf8); + UNSIGNED32 cap = *pulLenW; + UNSIGNED32 n = cap > 0 + ? std::min(cap - 1, + static_cast(units.size())) + : 0; + if (pucBufW != nullptr && cap > 0) { + if (n > 0) { + std::memcpy(pucBufW, units.data(), + n * sizeof(std::uint16_t)); + } + pucBufW[n] = 0; + } + *pulLenW = static_cast(units.size()); + return openads::AE_SUCCESS; +} + +void utf16z_to_utf8(const UNSIGNED16* text, std::string* out) { + if (out == nullptr) return; + out->clear(); + if (text == nullptr) return; + std::size_t units = 0; + while (text[units] != 0) ++units; + if (units == 0) return; + + if (text[0] == 0xFEFFu) { + *out = openads::abi::utf16le_to_utf8(text + 1, units - 1); + return; + } + if (text[0] == 0xFFFEu) { + std::vector swapped; + swapped.reserve(units - 1); + for (std::size_t i = 1; i < units; ++i) { + std::uint16_t v = text[i]; + swapped.push_back(static_cast( + static_cast(v << 8) | + static_cast(v >> 8))); + } + *out = openads::abi::utf16le_to_utf8(swapped.data(), swapped.size()); + return; + } + *out = openads::abi::utf16le_to_utf8(text, units); +} + +} // namespace + +UNSIGNED32 ENTRYPOINT AdsSetStringW(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED16* pucValueW, UNSIGNED32 ulLen) { + arc2_trace("AdsSetStringW"); + constexpr std::size_t kMaxUtf16Units = 1u << 20; + std::size_t units = ulLen; + if (units == 0 && pucValueW != nullptr) { + while (units < kMaxUtf16Units && pucValueW[units] != 0) ++units; + if (units >= kMaxUtf16Units) { + return fail(openads::AE_INSUFFICIENT_BUFFER, "unicode string too long"); + } + } + std::string utf8 = openads::abi::utf16le_to_utf8( + reinterpret_cast(pucValueW), units); + + if (auto* rt = get_remote_table(hTable)) { + if (pucField == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + auto i = remote_field_index(rt, pucField); + if (i == std::numeric_limits::max() || i >= rt->fields.size()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + std::string fname = rt->fields[i].name; + return remote_buffered_set(rt, fname, utf8); + } + Table* t = get_table(hTable); + if (!t) { + return AdsSetString(hTable, pucField, + reinterpret_cast(utf8.data()), + static_cast(utf8.size())); + } + std::uint16_t idx = 0; + if (!resolve_field_index_w(t, pucField, &idx)) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + auto r = t->set_field(idx, utf8); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsGetStringW(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED16* pucBufW, UNSIGNED32* pulLenW, + UNSIGNED16 /*usOption*/) { + arc2_trace("AdsGetStringW"); + if (pulLenW == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (auto* _rt = get_remote_table(hTable); _rt != nullptr) { + (void)_rt; + UNSIGNED8 tmp[4096] = {0}; + UNSIGNED32 cap = sizeof(tmp); + auto rc = AdsGetField(hTable, pucField, tmp, &cap, 0); + if (rc != 0) return rc; + return emit_utf16(pucBufW, pulLenW, + std::string(reinterpret_cast(tmp), cap)); + } + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); + std::uint16_t idx = 0; + if (!resolve_field_index_w(t, pucField, &idx)) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + auto v = t->read_field(idx); + if (!v) return fail(v.error()); + return emit_utf16(pucBufW, pulLenW, v.value().as_string); +} + +UNSIGNED32 ENTRYPOINT AdsGetFieldW(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED16* pucBufW, UNSIGNED32* pulLenW, + UNSIGNED16 /*usOption*/) { + arc2_trace("AdsGetFieldW"); + return AdsGetStringW(hTable, pucField, pucBufW, pulLenW, 0); +} + +UNSIGNED32 ENTRYPOINT AdsSetJulian(ADSHANDLE hTable, UNSIGNED8* pucField, + SIGNED32 lDate) { + arc2_trace("AdsSetJulian"); + char buf[9]; + if (lDate <= 0) { + std::memset(buf, ' ', 8); buf[8] = '\0'; + } else { + int y = 0, m = 0, d = 0; + julian_to_ymd(lDate, y, m, d); + if (y < 0) { y = 0; } + if (y > 9999) { y = 9999; } + m = ((m % 100) + 100) % 100; + d = ((d % 100) + 100) % 100; + std::snprintf(buf, sizeof(buf), "%04d%02d%02d", y, m, d); + } + std::string val(buf, 8); + + if (auto* rt = get_remote_table(hTable)) { + if (pucField == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + auto i = remote_field_index(rt, pucField); + if (i == std::numeric_limits::max() || i >= rt->fields.size()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + std::string fname = rt->fields[i].name; + return remote_buffered_set(rt, fname, val); + } + Table* t = get_table(hTable); + if (!t) { + return AdsSetString(hTable, pucField, + reinterpret_cast(val.data()), 8); + } + std::uint16_t idx = 0; + if (!resolve_field_index(t, pucField, &idx)) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + auto r = t->set_field(idx, val); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsSetDate(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED8* pucValue, UNSIGNED16 usLen) { + arc2_trace("AdsSetDate"); + if (pucField != nullptr && + reinterpret_cast(pucField) >= 0x10000u) { + std::string val(pucValue ? reinterpret_cast(pucValue) : "", + pucValue ? static_cast(usLen) : 0u); + std::string escaped; + escaped.reserve(val.size() + 2); + for (char c : val) { escaped += c; if (c == '\'') escaped += c; } + if (set_stmt_param(hTable, reinterpret_cast(pucField), + "'" + escaped + "'")) { + return ok(); + } + } + std::string compact; + compact_ace_date_value(pucValue, usLen, &compact); + std::vector bytes(compact.begin(), compact.end()); + bytes.push_back(0); + return AdsSetString(hTable, pucField, bytes.data(), + static_cast(compact.size())); +} + +// --- M9.18 lock retry policy ---------------------------------------------- +// +// Real ACE exposes a per-connection (cycle_ms, retry_count) tuple that +// callers tune via AdsSetLockCycle / AdsSetLockRetryCount; AdsLockTable +// and AdsLockRecord then re-attempt a contended lock up to that limit +// before reporting AE_LOCK_FAILED. OpenADS keeps a process-global +// policy (the hConnect arg is accepted for ABI compat but the value is +// shared across connections in this build); the retry loop sleeps +// `cycle_ms` between attempts and gives up after `retry_count` cycles. +// LockPolicy and lock_retry_policy() are defined in abi/lock_retry_policy.h. + +namespace { + +using openads::abi::lock_retry_policy; + +UNSIGNED32 lock_with_retry(std::function()> fn) { + using openads::abi::lock_retry_policy; + const auto p = lock_retry_policy(); + // Total wait budget = retry_count x cycle_ms (the plain ACE contract), + // but early attempts recheck after a few ms (adaptive backoff) so + // short contentions — the common case — resolve in single-digit ms + // instead of one 100ms slice. Attempts continue past retry_count + // while the time budget is not exhausted. + const auto t0 = std::chrono::steady_clock::now(); + const auto deadline = t0 + std::chrono::milliseconds(p.budget_ms()); + for (std::uint32_t i = 0; ; ++i) { + auto r = fn(); + if (r) return openads::AE_SUCCESS; + if (i >= p.retry_count && std::chrono::steady_clock::now() >= deadline) + return fail(r.error()); + openads::abi::lock_retry_sleep(i); + } +} + +} // namespace + +UNSIGNED32 ENTRYPOINT AdsSetLockCycle(ADSHANDLE /*hConnect*/, UNSIGNED32 ulCycle) { + arc2_trace("AdsSetLockCycle"); + auto& s = state(); + std::lock_guard lk(s.mu); + lock_retry_policy().cycle_ms = ulCycle; + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsGetLockCycle(ADSHANDLE /*hConnect*/, UNSIGNED32* pulCycle) { + arc2_trace("AdsGetLockCycle"); + if (pulCycle == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + auto& s = state(); + std::lock_guard lk(s.mu); + *pulCycle = lock_retry_policy().cycle_ms; + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsSetLockRetryCount(ADSHANDLE /*hConnect*/, UNSIGNED16 usRetryCount) { + arc2_trace("AdsSetLockRetryCount"); + auto& s = state(); + std::lock_guard lk(s.mu); + lock_retry_policy().retry_count = usRetryCount; + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsGetLockRetryCount(ADSHANDLE /*hConnect*/, UNSIGNED16* pusRetryCount) { + arc2_trace("AdsGetLockRetryCount"); + if (pusRetryCount == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + auto& s = state(); + std::lock_guard lk(s.mu); + *pusRetryCount = lock_retry_policy().retry_count; + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsLockRecord(ADSHANDLE hTable, UNSIGNED32 ulRecord) { + arc2_trace("AdsLockRecord"); + if (auto* rt = get_remote_table(hTable)) { + if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; + auto r = rt->conn->lock_record(rt->id, ulRecord); + if (!r) return fail(r.error()); + rt->ever_locked = true; + // ulRecord == 0 -> the current record (ACE convention). With + // no valid cursor the recno is unknowable client-side, so the + // ledger marks itself uncertain rather than guessing. + const std::uint32_t lrec = (ulRecord == 0) + ? (rt->row_valid ? rt->current_recno : 0) : ulRecord; + if (lrec == 0) rt->locks_uncertain = true; + else rt->held_recs.insert(lrec); + return ok(); + } +#if defined(OPENADS_WITH_FIREBIRD) + if (auto* ft = get_firebird_table(hTable)) { + if (ft->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto r = ft->conn->lock_record(ft, ulRecord); + if (!r) return fail(r.error()); + return ok(); + } +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* pt = get_postgres_table(hTable)) { + if (pt->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto r = pt->conn->lock_record(pt, ulRecord); + if (!r) return fail(r.error()); + return ok(); + } +#endif +#if defined(OPENADS_WITH_MARIADB) + if (auto* mt = get_maria_table(hTable)) { + if (mt->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto r = mt->conn->lock_record(mt, ulRecord); + if (!r) return fail(r.error()); + return ok(); + } +#endif +#if defined(OPENADS_WITH_ODBC) + if (auto* ot = get_odbc_table(hTable)) { + if (ot->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto r = ot->conn->lock_record(ot, ulRecord); + if (!r) return fail(r.error()); + return ok(); + } +#endif +#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->lock_record(st, ulRecord); + if (!r) return fail(r.error()); + return ok(); + } +#endif +#if defined(OPENADS_WITH_MSSQL) + if (auto* mst = get_mssql_table(hTable)) { + if (mst->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto r = mst->conn->lock_record(mst, ulRecord); + if (!r) return fail(r.error()); + return ok(); + } +#endif + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + // ulRecord == 0 → the current record (ACE convention). Resolving it + // also keeps the CDX record-lock byte (FILE_BASE - recno) clear of + // the file/table lock byte (FILE_BASE) when recno would be 0. + std::uint32_t rec = (ulRecord == 0) ? t->recno() : ulRecord; + return lock_with_retry([t, rec]() { + auto r = t->try_lock_record_excl(rec); + if (r) openads::mgmt::LockRegistry::instance().add_record_lock( + t, t->path(), rec); + return r; + }); +} + +UNSIGNED32 ENTRYPOINT AdsUnlockRecord(ADSHANDLE hTable, UNSIGNED32 ulRecord) { + arc2_trace("AdsUnlockRecord"); + if (auto* rt = get_remote_table(hTable)) { + if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; + auto r = rt->conn->unlock_record(rt->id, ulRecord); + if (!r) return fail(r.error()); + const std::uint32_t urec = (ulRecord == 0) + ? (rt->row_valid ? rt->current_recno : 0) : ulRecord; + if (urec != 0) rt->held_recs.erase(urec); + // locks_uncertain survives: only a full UnlockTable (or the + // close) proves the append auto-lock is gone. + return ok(); + } +#if defined(OPENADS_WITH_FIREBIRD) + if (auto* ft = get_firebird_table(hTable)) { + if (ft->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto r = ft->conn->unlock_record(ft, ulRecord); + if (!r) return fail(r.error()); + return ok(); + } +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* pt = get_postgres_table(hTable)) { + if (pt->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto r = pt->conn->unlock_record(pt, ulRecord); + if (!r) return fail(r.error()); + return ok(); + } +#endif +#if defined(OPENADS_WITH_MARIADB) + if (auto* mt = get_maria_table(hTable)) { + if (mt->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto r = mt->conn->unlock_record(mt, ulRecord); + if (!r) return fail(r.error()); + return ok(); + } +#endif +#if defined(OPENADS_WITH_ODBC) + if (auto* ot = get_odbc_table(hTable)) { + if (ot->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto r = ot->conn->unlock_record(ot, ulRecord); + if (!r) return fail(r.error()); + return ok(); + } +#endif +#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->unlock_record(st, ulRecord); + if (!r) return fail(r.error()); + return ok(); + } +#endif +#if defined(OPENADS_WITH_MSSQL) + if (auto* mst = get_mssql_table(hTable)) { + if (mst->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto r = mst->conn->unlock_record(mst, ulRecord); + if (!r) return fail(r.error()); + return ok(); + } +#endif + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + std::uint32_t rec = (ulRecord == 0) ? t->recno() : ulRecord; + auto r = t->unlock_record(rec); + if (!r) return fail(r.error()); + openads::mgmt::LockRegistry::instance().remove_record_lock(t, rec); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsLockTable(ADSHANDLE hTable) { + arc2_trace("AdsLockTable"); + if (auto* rt = get_remote_table(hTable)) { + if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; + auto r = rt->conn->lock_table(rt->id); + if (!r) return fail(r.error()); + rt->ever_locked = true; + rt->table_lock_held = true; + return ok(); + } +#if defined(OPENADS_WITH_FIREBIRD) + if (auto* ft = get_firebird_table(hTable)) { + if (ft->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto r = ft->conn->lock_table(ft); + if (!r) return fail(r.error()); + return ok(); + } +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* pt = get_postgres_table(hTable)) { + if (pt->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto r = pt->conn->lock_table(pt); + if (!r) return fail(r.error()); + return ok(); + } +#endif +#if defined(OPENADS_WITH_MARIADB) + if (auto* mt = get_maria_table(hTable)) { + if (mt->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto r = mt->conn->lock_table(mt); + if (!r) return fail(r.error()); + return ok(); + } +#endif +#if defined(OPENADS_WITH_ODBC) + if (auto* ot = get_odbc_table(hTable)) { + if (ot->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto r = ot->conn->lock_table(ot); + if (!r) return fail(r.error()); + return ok(); + } +#endif +#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->lock_table(st); + if (!r) return fail(r.error()); + return ok(); + } +#endif +#if defined(OPENADS_WITH_MSSQL) + if (auto* mst = get_mssql_table(hTable)) { + if (mst->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto r = mst->conn->lock_table(mst); + if (!r) return fail(r.error()); + return ok(); + } +#endif + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + return lock_with_retry([t]() { + auto r = t->try_lock_table_excl(); + if (r) openads::mgmt::LockRegistry::instance().add_table_lock( + t, t->path()); + return r; + }); +} + +UNSIGNED32 ENTRYPOINT AdsUnlockTable(ADSHANDLE hTable) { + arc2_trace("AdsUnlockTable"); + if (auto* rt = get_remote_table(hTable)) { + if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; + // Lock ledger: with nothing held on the server, the frame + // would release zero locks. Harbour rddads dbUnlock() has no + // client-side lock list and calls this blindly before every + // lock/append -- that blind call is most of the UnlockTable + // traffic in a Vouch save. + if (rt->held_recs.empty() && !rt->table_lock_held && + !rt->locks_uncertain) { + cli_trace_tbl(rt, "AdsUnlockTable", "skipped: no locks held"); + return ok(); + } + auto r = rt->conn->unlock_table(rt->id); + if (!r) return fail(r.error()); + // SAP ACE semantics: AdsUnlockTable releases ALL locks (table + // AND record, including the append auto-lock), so the ledger + // is provably empty again. + rt->held_recs.clear(); + rt->table_lock_held = false; + rt->locks_uncertain = false; + return ok(); + } +#if defined(OPENADS_WITH_FIREBIRD) + if (auto* ft = get_firebird_table(hTable)) { + if (ft->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto r = ft->conn->unlock_table(ft); + if (!r) return fail(r.error()); + return ok(); + } +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* pt = get_postgres_table(hTable)) { + if (pt->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto r = pt->conn->unlock_table(pt); + if (!r) return fail(r.error()); + return ok(); + } +#endif +#if defined(OPENADS_WITH_MARIADB) + if (auto* mt = get_maria_table(hTable)) { + if (mt->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto r = mt->conn->unlock_table(mt); + if (!r) return fail(r.error()); + return ok(); + } +#endif +#if defined(OPENADS_WITH_ODBC) + if (auto* ot = get_odbc_table(hTable)) { + if (ot->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto r = ot->conn->unlock_table(ot); + if (!r) return fail(r.error()); + return ok(); + } +#endif +#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->unlock_table(st); + if (!r) return fail(r.error()); + return ok(); + } +#endif +#if defined(OPENADS_WITH_MSSQL) + if (auto* mst = get_mssql_table(hTable)) { + if (mst->conn == nullptr) + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + auto r = mst->conn->unlock_table(mst); + if (!r) return fail(r.error()); + return ok(); + } +#endif + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + auto r = t->unlock_table(); + if (!r) return fail(r.error()); + // SAP ACE semantics (verified against ace32/ace64, commit 1fb224b): + // AdsUnlockTable releases ALL locks — table AND record. Harbour rddads + // trusts this (dbUnlock() has no client-side lock list; a leaked RLock + // blocks the next dbRLock forever). Drop every lock of the table from + // the management registry too. + openads::mgmt::LockRegistry::instance().remove_all_for_table(t); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsFlushFileBuffers(ADSHANDLE hTable) { + arc2_trace("AdsFlushFileBuffers"); + if (auto* rt = get_remote_table(hTable)) { + if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; + // Deferred: a CloseTable is expected to absorb this (the server + // close flushes via its shadow handle). If any other wire op + // intervenes, remote_flush_pending emits it ahead of that op. + // Clean tables (the RDD calls this blindly around every + // rotation) owe nothing: no flag, so teardown stays fully + // deferred and the index park survives. Buffered sets always + // mark dirty at buffer time, so this never skips real data. + if (rt->pending_sets.empty() && !rt->write_dirty) return ok(); + rt->flush_file_pending = true; + return ok(); + } + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + auto r = t->flush(); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsSetDeferredFlush(ADSHANDLE hTable, UNSIGNED16 usDeferred) { + arc2_trace("AdsSetDeferredFlush"); + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + t->set_deferred_flush(usDeferred != 0); + return ok(); +} + +// --- M3 index / scope / seek ----------------------------------------------- + +extern "C++" { + +namespace { + +// Index handles: a single "logical" handle wraps the table-bound order +// since openads::engine::Table owns the active IIndex via Order. We keep a +// per-process map so the L1 thunks can resolve the table from the index +// handle. +// Binding for one open tag. Multi-tag CDX files create one binding +// per tag. At most ONE binding per table is "live" -- its `idx` has +// been moved into Table::order_; the rest park their IIndex here so +// OrdSetFocus / AdsGetIndexHandle can swap them in on demand. +struct IndexBinding { + Table* table = nullptr; + std::string tag_name; + std::unique_ptr parked; // nullptr when this is the active binding + std::string path; // resolved index file path (M9.14) +}; + +std::unordered_map& index_bindings() { + static std::unordered_map m; + return m; +} + +// Protects index_bindings() / active_binding_for() only. MUST NOT be held +// across bulk CDX I/O (see AdsCreateIndex61). Recursive so helpers re-enter. +std::recursive_mutex& index_bindings_mu() { + static std::recursive_mutex m; + return m; +} + +// Records, per table, which binding handle currently owns the active +// IIndex (i.e. the one moved into Table::order_). +std::unordered_map& active_binding_for() { + static std::unordered_map m; + return m; +} + +// Park the table's active order back into its binding slot, leaving the +// table in natural order (AdsSetIndexOrder(h, "") semantics). +void park_active_order(Table* t) { + std::lock_guard lk(index_bindings_mu()); + auto& act = active_binding_for(); + auto act_it = act.find(t); + if (act_it == act.end()) return; + auto& m = index_bindings(); + auto bit = m.find(act_it->second); + if (bit != m.end()) { + auto taken = t->take_order(); + openads::drivers::IIndex* raw = taken.get(); + bit->second.parked = std::move(taken); + if (raw) t->register_extra_index_view(raw); + } + act.erase(act_it); +} + +// Drop every binding tied to `t`. Called from AdsCloseTable / AdsCloseAllTables +// / AdsDisconnect -- without this, a Connection teardown leaves the bindings +// behind, so a later test (or app reconnect) that allocates a Table at the +// same heap slot inherits the stale entries and table_has_active misfires. +void purge_bindings_for_table(Table* t) { + std::lock_guard lk(index_bindings_mu()); + auto& m = index_bindings(); + auto& act = active_binding_for(); + for (auto it = m.begin(); it != m.end(); ) { + if (it->second.table == t) it = m.erase(it); + else ++it; + } + act.erase(t); +} + +Table* lookup_table_by_index(ADSHANDLE h) { + std::lock_guard lk(index_bindings_mu()); + auto& m = index_bindings(); + auto it = m.find(h); + if (it == m.end()) return nullptr; + return it->second.table; +} + +openads::drivers::IIndex* iindex_for_handle(ADSHANDLE h) { + std::lock_guard lk(index_bindings_mu()); + auto& m = index_bindings(); + auto it = m.find(h); + if (it == m.end()) return nullptr; + if (it->second.parked) return it->second.parked.get(); + Table* t = it->second.table; + if (t && t->order()) return t->order()->index(); + return nullptr; +} + +// Make `h` the active order for its table. If another binding is +// currently active, park its IIndex back into that binding before +// stealing the requested one. No-op when `h` is already active. +openads::util::Result activate_binding(ADSHANDLE h) { + std::lock_guard lk(index_bindings_mu()); + auto& m = index_bindings(); + auto it = m.find(h); + if (it == m.end()) { + return openads::util::Error{ + openads::AE_INTERNAL_ERROR, 0, "unknown index", ""}; + } + Table* t = it->second.table; + auto& act = active_binding_for(); + auto act_it = act.find(t); + if (act_it != act.end() && act_it->second == h) return {}; // already live + + // Park the currently-active binding's index back into its slot + // and register it as an extra view (so multi-index sync still + // touches it after the swap). When act_it points to a handle + // that's no longer in the binding map (stale entry left by a + // previous AdsCloseAllIndexes / test cleanup that didn't tidy + // act_), drop the act entry but leave Table::order_ alone -- the + // current code may have set it via the legacy AdsCreateIndex path + // that doesn't populate `act_`. + if (act_it != act.end()) { + auto prev = m.find(act_it->second); + if (prev != m.end()) { + auto taken = t->take_order(); + openads::drivers::IIndex* raw = taken.get(); + prev->second.parked = std::move(taken); + if (raw) t->register_extra_index_view(raw); + } + // else: stale act entry; leave Table::order_ untouched. + } + + // Move the parked IIndex from this binding into the table; drop + // its extra-view entry since the active order's IIndex is already + // walked by the sync loop. + if (it->second.parked) { + openads::drivers::IIndex* raw = it->second.parked.get(); + t->unregister_extra_index_view(raw); + t->set_order(std::move(it->second.parked)); + } + act[t] = h; + return {}; +} + +ADSHANDLE next_index_handle() { + static std::atomic n{0x40000000ULL}; + return static_cast(++n); +} + +Table* table_for_index(ADSHANDLE hIndex) { + // Storm fix: reader must hold index_bindings_mu (binds are unlocked now). + std::lock_guard _tf_lk(index_bindings_mu()); + auto it = index_bindings().find(hIndex); + if (it == index_bindings().end()) return nullptr; + // Activate this binding so the Table's order_ reflects the + // requested index -- AdsSeek / AdsGotoTop / etc. always operate + // through the Table's active order, and rddads passes the index + // handle (pArea->hOrdCurrent) as the operand. + (void)activate_binding(hIndex); + return it->second.table; +} + +// Mark a freshly opened CDX index FoxNumeric when its key is a bare +// numeric field, so seek/append on a reopened numeric index builds the +// same 8-byte order-preserving key the file was written with. No-op for +// character keys / non-CDX drivers. +void mark_cdx_key_encoding(Table* t, openads::drivers::IIndex* idx) { + if (t == nullptr || idx == nullptr) return; + // Decide by the key EXPRESSION, never by key length alone. A blanket + // "key_length()==8 => FoxNumeric" mis-marked plain C(8) character + // tags on reopen; once compare_keys_ started honouring the encoding + // (v1.8.6 memcmp fix), every reopened C(8) tag under an OEM + // collation seeked with raw byte order against a tree built in + // collation order and missed existing keys (#130 follow-up). + const std::string bare = + openads::engine::strip_alias_qualifiers(idx->expression()); + std::int32_t fi = t->field_index(bare); + if (fi >= 0) { + // Bare field: the schema decides. Character/date/logical keys + // stay Text regardless of their width. + using FT = openads::drivers::DbfFieldType; + FT ft = t->field_descriptor(static_cast(fi)).type; + if (ft == FT::Numeric || ft == FT::Float || ft == FT::Integer || + ft == FT::Double || ft == FT::Currency || ft == FT::AdtMoney) { + idx->set_key_encoding(openads::drivers::KeyEncoding::FoxNumeric); + } + return; + } + // Computed expression: FoxNumeric only when the key has the 8-byte + // binary width AND the expression provably evaluates numeric -- the + // same rules the AdsCreateIndex61 path applies (Val() heuristic + + // record-1 probe). Fixes numeric Val() DbSeek on reopened CDX (#130). + if (idx->key_length() != 8) return; + std::string u = idx->expression(); + for (char& c : u) + c = static_cast(toupper(static_cast(c))); + if (u.find("VAL(") != std::string::npos) { + idx->set_key_encoding(openads::drivers::KeyEncoding::FoxNumeric); + return; + } + if (t->record_count() > 0) { + // Bulk-scan load so the caller's cursor state is untouched + // (mirrors the create-path probe). + if (auto raw = t->driver()->read_record_raw(1); raw) { + t->load_record_for_bulk_scan(std::move(raw.value()), 1); + double dv = 0.0; + if (openads::engine::evaluate_index_expr_number( + *t, idx->expression(), dv)) { + idx->set_key_encoding( + openads::drivers::KeyEncoding::FoxNumeric); + } + } + } +} + +// Stamp OEM national collation onto a CDX index for its table. +// RCB 2026-07-10 -- uses the table's EFFECTIVE collation +// (Table::oem_sort_table(): connection AdsSetCollation override, else +// ADS_OEM char type + configured default OEM collation), not just the +// connection's. Reading only conn->oem_sort_table() here left every +// rddads-opened tag on binary compare -- rddads can't call +// AdsSetCollation -- so PL852 seeks missed existing keys (#130 +// follow-up). Keep routing through Table::oem_sort_table(). +void apply_cdx_oem_collation(Table* t, openads::drivers::IIndex* idx) { + if (t == nullptr || idx == nullptr) return; + auto* cdx = dynamic_cast(idx); + if (cdx == nullptr) return; + cdx->set_oem_sort_table(t->oem_sort_table()); +} + +// Re-mark a reopened NTX index NtxNumeric so a later append writes the native +// zero-padded numeric key. The create path marks it via set_numeric_format; a +// reopen through AdsOpenIndex must restore the flag (open() already reads the +// width + decimal count back from the NTX header). No-op for character keys. +void mark_ntx_key_encoding(Table* t, openads::drivers::IIndex* idx) { + if (t == nullptr || idx == nullptr) return; + const std::string bare = + openads::engine::strip_alias_qualifiers(idx->expression()); + std::int32_t fi = t->field_index(bare); + if (fi < 0) return; + using FT = openads::drivers::DbfFieldType; + FT ft = t->field_descriptor(static_cast(fi)).type; + if (ft == FT::Numeric || ft == FT::Float) { + idx->set_key_encoding(openads::drivers::KeyEncoding::NtxNumeric); + } +} + +bool path_ends_with_ci(const std::string& s, const char* suffix) { + auto n = std::strlen(suffix); + if (s.size() < n) return false; + for (std::size_t i = 0; i < n; ++i) { + char a = static_cast(std::tolower( + static_cast(s[s.size() - n + i]))); + char b = static_cast(std::tolower( + static_cast(suffix[i]))); + if (a != b) return false; + } + return true; +} + +// Content sniff for bags whose extension carries no format hint (custom +// suffixes like ".Z01"): a CDX bag has the Harbour "RCHB" signature at +// offset 20 of the file header. Returns false on unreadable / short files +// (caller then keeps the extension-default driver). +bool file_has_cdx_signature(const std::string& path) { + std::ifstream f(path, std::ios::binary); + if (!f) return false; + char hdr[24] = {0}; + f.read(hdr, sizeof(hdr)); + if (f.gcount() < static_cast(sizeof(hdr))) return false; + return hdr[20] == 'R' && hdr[21] == 'C' && hdr[22] == 'H' && + hdr[23] == 'B'; +} + +// Harbour/FiveWin may pass a client-native fully qualified path (for example +// C:\\work\\table.cdx) or a relative path with subdirectories (for example +// Indexes\\table.cdx) even when the table is remote. Normalize separators +// everywhere but KEEP any directory component: for remote callers the server +// resolves the location itself (bare filename -> the table's own folder; +// directory-qualified -> under the connection data root, the same rule the +// .DBF gets), so stripping to basename here would silently discard the +// folder the caller asked for (Pritpal Bedi, 29/07/2026). +std::string normalize_index_path(std::string path) { + std::replace(path.begin(), path.end(), '\\', '/'); + return path; +} + +// Lowercased file stem (no directory, no extension): "C:\d\VO_ATDFN.z01" +// and "vo_atdfn.cdx" name the same production bag for rotation matching. +std::string bag_stem_ci(const std::string& p) { + std::string b = p; + auto sep = b.find_last_of("/\\"); + if (sep != std::string::npos) b = b.substr(sep + 1); + auto dot = b.find_last_of('.'); + if (dot != std::string::npos) b = b.substr(0, dot); + for (auto& c : b) + c = static_cast(std::tolower( + static_cast(c))); + return b; +} + +// Harbour INDEX ON TO cIdxFile passes a client-absolute bag +// ("C:/Creative.RAM/T.Z01"). Table opens remount that spelling under +// --data when --legacy-paths is on; the bag must follow or the .z01 +// lands in the host tree while the .dbf updates in the jail +// (Pritpal Bedi, 12/08/2026). +std::string resolve_index_bag_for_table(Table* t, std::string bag, + const char* default_ext) { + bag = normalize_index_path(std::move(bag)); + namespace fs = std::filesystem; + if (t != nullptr) { + if (auto* owner = t->owner()) { + if (owner->legacy_paths() && !owner->data_dir().empty()) { + if (bag.empty()) { + return fs::path(t->path()) + .replace_extension(default_ext) + .string(); + } + auto type = openads::engine::TableType::Cdx; + std::string resolved = + owner->resolve_table_file(bag, type, /*for_create=*/true); + fs::path p(resolved); + if (!p.has_extension()) p.replace_extension(default_ext); + std::error_code ec; + fs::create_directories(p.parent_path(), ec); + return p.string(); + } + } + } + fs::path p; + if (bag.empty()) { + p = fs::path(t->path()).replace_extension(default_ext); + } else { + p = fs::path(bag); + if (!p.is_absolute() && t != nullptr) { + p = fs::path(t->path()).parent_path() / p; + } + if (!p.has_extension()) p.replace_extension(default_ext); + } + return p.string(); +} + +std::unique_ptr +make_index_for(const std::string& path) { + if (path_ends_with_ci(path, ".cdx")) { + return std::make_unique(); + } + if (path_ends_with_ci(path, ".adi")) { + return std::make_unique(); + } + return std::make_unique(); +} + +} // namespace + +} // extern "C++" + +namespace { +// Route an ADT table's .adi bag through the CdxIndex engine (full evaluated +// key + 32-bit recno), so compound / computed / FOR tags work over ADT the +// same way they do over DBF. The file is then CDX-format even though it is +// named .adi -- only enable when those .ADI files are NOT interchanged with +// real Advantage. Gate: env OPENADS_ADT_CDX_INDEX=1 (or +// adt_cdx_index = 1 in openads.ini). +bool adt_cdx_index_enabled() { + return openads::util::client_setting_truthy( + "OPENADS_ADT_CDX_INDEX", "adt_cdx_index"); +} + +// A .adi bag written by the CdxIndex reroute carries the Harbour CDX structure +// signature "RCHB" at byte offset 20 (see cdx_index.cpp); a native AdiIndex bag +// does not. Detecting it lets the OPEN path pick the right engine REGARDLESS of +// OPENADS_ADT_CDX_INDEX. Without this, a .adi built CDX-format (reroute on at +// reindex) but opened with the flag absent routes to the native AdiIndex +// reader, which cannot parse it -> 5004 -> the table opens with 0 indexes and +// the first DBSETORDER/SEEK fails. +bool adi_bag_is_cdx_format(const std::string& path) { + std::ifstream f(path, std::ios::binary); + if (!f) return false; + char hdr[24] = {0}; + f.read(hdr, sizeof(hdr)); + if (f.gcount() < 24) return false; + return hdr[20] == 'R' && hdr[21] == 'C' && hdr[22] == 'H' && hdr[23] == 'B'; +} +} // namespace + +// Compare two filesystem paths for the "is this the same on-disk +// file?" question. Falls back to a case-insensitive lexical compare +// when canonical resolution fails (e.g. file doesn't exist yet). +namespace { +bool same_index_path(const std::string& a, const std::string& b) { + namespace fs = std::filesystem; + std::error_code ec; + auto ca = fs::weakly_canonical(fs::path(a), ec); + auto cb = fs::weakly_canonical(fs::path(b), ec); + auto sa = ec ? a : ca.string(); + auto sb = ec ? b : cb.string(); + if (sa.size() != sb.size()) { + if (a.size() != b.size()) return false; + for (std::size_t i = 0; i < a.size(); ++i) { + char ca2 = static_cast(std::tolower( + static_cast(a[i]))); + char cb2 = static_cast(std::tolower( + static_cast(b[i]))); + if (ca2 != cb2) return false; + } + return true; + } + for (std::size_t i = 0; i < sa.size(); ++i) { + char ca2 = static_cast(std::tolower( + static_cast(sa[i]))); + char cb2 = static_cast(std::tolower( + static_cast(sb[i]))); + if (ca2 != cb2) return false; + } + return true; +} + +// Stale-bag heal (Pritpal Bedi's .z01 work bags, Aug 2026): a compound tag +// whose tree was never built — or whose keys were written by a handle that +// never reached disk — has no root page, so every GotoTop on it lands in +// Limbo (bof=eof=1) over a NON-empty table and no navigation rescue +// escapes. A PARTIALLY-maintained bag is just as broken for the app: +// dbGoBottom lands on the last STALE key (voucher 16 of 21) instead of the +// last record. An unconditional non-unique tag indexes every record +// (deleted ones included), so key_count != record_count means some writes +// bypassed the bag (SQL DML, index closed at write time, killed before +// flush). Rebuild the bag once here instead of handing the caller a ghost +// order. Conditional (FOR) tags are skipped: zero/fewer matching rows is +// legitimate for them. Unique tags are skipped too: duplicates collapse, +// so a smaller key count proves nothing. Read-only tables are skipped. +// The partial check walks the tag once per open — threshold-gated. +void heal_stale_bag_on_open(Table* t, + const std::vector& views) { + if (t == nullptr || t->record_count() == 0) return; + if (t->open_mode() == openads::engine::OpenMode::Read) return; + for (auto* v : views) { + if (v == nullptr || !v->condition().empty()) continue; + // empty() refreshes the sub-tag header and checks root_page_==0 — + // the never-built / never-flushed Limbo bag this heal exists for. + // The old key_count() != record_count() trigger walked the whole + // tag (O(keys)) per open AND is inherently racy: under a + // concurrent-writer storm keys insert at commit, so every + // simultaneous OpenIndex saw key_count < record_count, concluded + // "stale" and each launched a full t->reindex() — measured as the + // 700-session storm convoy (OPDUMP: 0x88 OpenIndex = 92.6% of + // server time, 6.8 s/call). A partially-maintained bag cannot be + // detected reliably under concurrency and stays the app's + // reindex responsibility. + bool stale = v->empty(); + if (!stale) continue; + arc2_log("IDXHEAL stale tag '%s' on %s (recs=%u) -> reindex", + v->name().c_str(), t->path().c_str(), + (unsigned)t->record_count()); + (void)t->reindex(); + return; + } +} +} // namespace + +// Real-ACE 4-arg signature: opens an index FILE, registers one handle +// per tag, and writes the handles into ahIndex[] / *pu16ArrayLen. +// +// M9.14 made this additive: a second AdsOpenIndex against a different +// file path no longer wipes the prior bindings. Instead, the new +// indices land as parked extra views (their writes still sync) and +// the first one only steals Table::order_ when no active order is +// currently bound. Repeated calls with the SAME path drop the prior +// bindings for that path (refresh semantics) so reopening the same +// .ntx / .cdx leaves at most one binding per tag. +UNSIGNED32 ENTRYPOINT AdsOpenIndex(ADSHANDLE hTable, UNSIGNED8* pucName, + ADSHANDLE* ahIndex, UNSIGNED16* pu16ArrayLen) { + arc2_trace("AdsOpenIndex"); + if (ahIndex == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "null out"); + } + if (auto* rt = get_remote_table(hTable)) { + std::string path = openads::abi::to_internal(pucName, 0); + // Distributed flush (WAN chattiness): a deferred order switch + // or teardown absorbed here must not interleave with the parked + // restore below — flush first, then decide. + if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; + // Parked-index reuse (per-tag rotation): CloseAll parked the + // live maps with the server bindings still open. A same-bag + // reopen restores them and serves the handles with zero + // frames. A different (or unknown) bag needs a real close + // first so the server does not accumulate bags behind the + // client's back. + if (rt->indexes_parked) { + if (!rt->parked_by_tag.empty() && !bag_stem_ci(path).empty() && + bag_stem_ci(path) == rt->parked_bag_stem && + rt->parked_by_tag.size() == rt->parked_handles.size()) { + rt->index_by_tag = std::move(rt->parked_by_tag); + rt->index_handles = std::move(rt->parked_handles); + rt->active_index_id = rt->parked_active; + rt->indexes_parked = false; + rt->close_all_indexes_pending = false; + // Restore the nav stamp parked at CloseAll when it still + // proves the server cursor: same order coming back, no + // cursor-affecting frame since the park (seq gate). The + // post-unpark GotoTop(idx) then dedupes locally instead + // of paying a refresh RTT for a position the server + // never lost. + if (rt->parked_nav_which != 0 && + rt->parked_nav_order == rt->parked_active && + rt->parked_nav_seq == rt->conn->nav_seq()) { + rt->last_nav = rt->parked_nav_which; + rt->last_nav_order = rt->parked_nav_order; + rt->last_nav_row = rt->parked_nav_row; + rt->last_nav_seq = rt->parked_nav_seq; + } + rt->parked_nav_which = 0; + cli_trace_tbl(rt, "AdsOpenIndex", "unpark hit %.32s", + rt->parked_bag_stem.c_str()); + auto& s = state(); + std::lock_guard lk(s.mu); + const std::uint16_t cap = + (pu16ArrayLen != nullptr) ? *pu16ArrayLen : 0; + std::uint16_t count = 0; + for (std::size_t i = 0; i < rt->index_by_tag.size(); ++i) { + if (count < cap) { + ahIndex[count] = to_ads_handle(static_cast( + rt->index_handles[i])); + } + ++count; + } + if (rt->active_index_id == 0) + rt->active_index_id = rt->index_by_tag.front().second; + if (pu16ArrayLen != nullptr) *pu16ArrayLen = count; + return ok(); + } + if (auto r = remote_emit_close_all(rt); r != 0) { + return r; + } + cli_trace_tbl(rt, "AdsOpenIndex", "unpark miss %.32s", + path.c_str()); + } + // Zero-RTT dedup (RDD-only apps, no app change possible): the + // production bag is already bound on this handle by the OpenTable + // auto-open, but rddads issues an explicit AdsOpenIndex for the + // same bag on every USE. A second wire open buys identical + // bindings for a full RTT + server reopen, so serve it from the + // cached tag/handle lists when the request names the production + // bag. Compared by stem (case-insensitive): .cdx/.z01 are + // equivalent production spellings. Temp bags (different stem) + // always go to the wire — another session may have added tags. + { + auto& s = state(); + std::lock_guard lk(s.mu); + if (!rt->index_by_tag.empty() && !rt->prod_bag_path.empty() && + rt->index_by_tag.size() == rt->index_handles.size() && + !bag_stem_ci(path).empty() && + bag_stem_ci(path) == bag_stem_ci(rt->prod_bag_path)) { + const std::uint16_t cap = + (pu16ArrayLen != nullptr) ? *pu16ArrayLen : 0; + std::uint16_t count = 0; + for (std::size_t i = 0; i < rt->index_by_tag.size(); ++i) { + if (count < cap) { + ahIndex[count] = to_ads_handle(static_cast( + rt->index_handles[i])); + } + ++count; + } + if (rt->active_index_id == 0) + rt->active_index_id = rt->index_by_tag.front().second; + if (pu16ArrayLen != nullptr) *pu16ArrayLen = count; + return ok(); + } + } + auto r = rt->conn->open_index(rt->id, path); + if (!r) return fail(r.error()); + auto& s = state(); + std::lock_guard lk(s.mu); + // Persist RemoteIndex objects in a side container keyed by + // their ADSHANDLE; the registry only stores raw pointers. + static std::unordered_map> remote_indexes; + const auto& entries = r.value(); + // Register every tag the server opened, but only WRITE handles up + // to the caller's array capacity. The previous code wrote all + // entries whenever pu16ArrayLen was NULL -- a stack overflow past a + // single-handle out-param that clobbered adjacent locals (found on + // x86: the table handle got overwritten with an index handle and + // the next call failed 5000 "unknown table"). + const std::uint16_t cap = + (pu16ArrayLen != nullptr) ? *pu16ArrayLen : 0; + std::uint16_t count = 0; + for (const auto& ent : entries) { + auto ri = std::make_unique(); + ri->conn = rt->conn; + ri->id = ent.id; + ri->tbl_id = rt->id; + ri->parent = rt; + ri->tag_name = ent.tag; + ri->bag_path = ent.bag_path; + ri->expression = ent.expression; + ri->is_unique = ent.is_unique; + ri->is_descending = ent.is_descending; + Handle gh = s.registry.register_object( + HandleKind::RemoteIndex, ri.get()); + if (count < cap) { + ahIndex[count] = to_ads_handle(gh); + } + ++count; + remote_indexes.emplace(gh, std::move(ri)); + if (cli_trace_on()) { + auto& trace = cli_trace_state(); + std::lock_guard trace_lk(trace.mu); + trace.indexes[{rt->conn, ent.id}] = rt->alias.empty() ? rt->name : rt->alias; + } + // Dedup by tag: production-CDX auto-open and a later explicit + // AdsOpenIndex on the same bag must not register the order + // twice, or AdsGetNumIndexes / by-order resolution skew. + bool known = false; + for (auto& kv : rt->index_by_tag) { + if (kv.first == ent.tag) { known = true; break; } + } + if (!known) { + rt->index_by_tag.emplace_back(ent.tag, ent.id); + rt->index_handles.push_back(static_cast(gh)); + } + if (rt->active_index_id == 0) rt->active_index_id = ent.id; + } + // Store the production bag path on the parent table so + // AdsGetIndexFilename / OrdBagName can serve without a wire + // round-trip even before any explicit AdsOpenIndex call. + if (!entries.empty() && !entries[0].bag_path.empty() && + rt->prod_bag_path.empty()) { + rt->prod_bag_path = entries[0].bag_path; + } + // Server-canonical bag of this open (seeds the CloseAll park + // match — the bag actually bound, not a reopen spelling). + if (!entries.empty() && !entries[0].bag_path.empty()) { + rt->last_open_bag = entries[0].bag_path; + } + if (pu16ArrayLen != nullptr) *pu16ArrayLen = count; + 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"); + } + // remote_only_access guard, index side: OrdListAdd with a legacy + // local .z01/.cdx path on a local-connection table lands here. + const int roa_mode = remote_only_access_mode(); + if (roa_mode == 1) { + return fail(openads::AE_ACCESS_DENIED, + "local index open blocked by OPENADS_REMOTE_ONLY_ACCESS"); + } + if (roa_mode == 2) { + openads::util::write_local_access_audit( + "OPENIDX", openads::abi::to_internal(pucName, 0)); + } + auto bag_name = normalize_index_path( + openads::abi::to_internal(pucName, 0)); + if (auto* owner = t->owner()) { + if (owner->legacy_paths() && !owner->data_dir().empty() && + openads::platform::is_client_absolute(bag_name)) { + auto type = openads::engine::TableType::Cdx; + bag_name = owner->resolve_table_file( + bag_name, type, /*for_create=*/false); + } + } + namespace fs = std::filesystem; + fs::path p(bag_name); + if (!p.is_absolute()) { + fs::path table_dir = fs::path(t->path()).parent_path(); + // ADS places index files next to the table; when the caller uses a + // subdirectory-qualified path (e.g. "sub/table.adx") and the table's + // parent is already "sub/", avoid double-prefix by falling back to + // basename. Example: table opened as "sub/t.adt" makes table_dir = + // ".../sub"; if the caller also passes "sub/t.adx" the naive join + // ".../sub/sub/t.adx" does not exist, so retry with just the + // filename ".../sub/t.adx". A single-component relative path + // (p.filename() == p) takes the same first branch and is unaffected. + fs::path candidate = table_dir / p; + fs::path by_name = table_dir / p.filename(); + // When the caller keeps indexes in a *different* folder than the + // table (e.g. table in Data/, index in Indexes/), the path is + // relative to the connection data directory, not the table dir. + // Also try resolving against the connection root. + fs::path conn_candidate; + if (auto* conn = t->owner()) { + conn_candidate = fs::path(conn->data_dir()) / p; + } + auto resolve_ci = [](const fs::path& cand) -> fs::path { + if (fs::exists(cand)) return cand; + std::string ci = openads::platform::resolve_case_insensitive( + cand.string()); + if (ci != cand.string() && fs::exists(ci)) return fs::path(ci); + return cand; + }; + if (fs::exists(candidate)) { + p = candidate; + } else if (fs::exists(by_name)) { + p = by_name; + } else if (!conn_candidate.empty() && fs::exists(conn_candidate)) { + // Index lives in a different folder; resolve against connection + // data directory so "MyFolder/MyTable.Z01" finds + // /MyFolder/MyTable.Z01 instead of + // /MyFolder/MyTable.Z01. + p = conn_candidate; + } else { + fs::path ci = resolve_ci(by_name); + if (fs::exists(ci)) { + p = ci; + } else if (!conn_candidate.empty() && + (ci = resolve_ci(conn_candidate), + fs::exists(ci))) { + p = ci; + } else { + ci = resolve_ci(candidate); + p = fs::exists(ci) ? ci : candidate; + } + } + } + if (!p.has_extension()) { + p.replace_extension(".cdx"); + } + if (!fs::exists(p)) { + std::string ci = openads::platform::resolve_case_insensitive( + p.string()); + if (ci != p.string() && fs::exists(ci)) p = fs::path(ci); + } + auto path = p.string(); + + // Same per-bag mutex AdsCreateIndex61 holds around create-or-attach. + // Taken BEFORE index_bindings_mu (leaf create lock first) so OpenIndex + // waits out a racing INDEX ON instead of reading a half-written header + // (5103/6106) or a sharing-violation 5000. B_BIG N=700 logged + // OpenIndex: TestIndex.cdx / 5103 during concurrent tag creates. + // Hold the create mutex ONLY for the exists check. Opening every tag + // under it serialised 7000 B_BIG OpenIndex calls and starved INDEX ON. + { + std::lock_guard bag_lk(create_path_mu_for(path)); + std::error_code ec; + if (!fs::exists(p, ec)) { + return fail(openads::AE_NO_FILE_FOUND, path.c_str()); + } + } + // Storm fix (700-session B_BIG, OPI run22): the old code held + // index_bindings_mu across list_tags + every open_named + heal -- + // 0.1-1.5 s of file I/O per call with 700 concurrent OpenIndex + // calls. Every metadata reader (AdsGetIndexName/Expr/Filename, + // AdsIsIndexUnique/Descending -> iindex_for_handle) queues behind + // ALL of them: measured 12.3 ms avg for a pure in-memory lookup + // loop (ph3 = 15.9 s of the server's time). The mutex protects the + // two process-global maps and nothing else; take it only around + // the map snapshot / erase / insert phases below. + std::unordered_map old_handles; + ADSHANDLE active_h = 0; + bool had_active = false; + { + std::lock_guard _oi_lk(index_bindings_mu()); + auto& m = index_bindings(); + auto& act = active_binding_for(); + + // Refresh: drop any prior bindings for this Table that came from + // the same file path. If the active binding was among them, also + // surrender Table::order_; the caller's reopen will repopulate it. + // RCB 01/08/2026: keep the old tag→handle mapping and REUSE those + // handles below -- remote clients (openads_serverd's wire index_h_) + // hold the numeric handles across a bag reopen, and erasing them + // made the next SetOrder fail with 5000 (stale binding). + bool active_dropped = false; + auto act_it = act.find(t); + if (act_it != act.end()) active_h = act_it->second; + for (auto it = m.begin(); it != m.end(); ) { + if (it->second.table == t && same_index_path(it->second.path, path)) { + old_handles[it->second.tag_name] = it->first; + if (it->first == active_h) { + active_dropped = true; + } else if (it->second.parked) { + t->unregister_extra_index_view(it->second.parked.get()); + } + it = m.erase(it); + } else { + ++it; + } + } + if (active_dropped) { + act.erase(t); + t->clear_order(); + } + had_active = act.find(t) != act.end(); + } + bool table_has_active = had_active; + + // Enumerate tags. CDX/ADI expose list_tags; NTX has only its single + // tag, which open() reports via name(). + std::vector tags; + bool is_adi = path_ends_with_ci(path, ".adi"); + bool is_cdx = path_ends_with_ci(path, ".cdx"); + if (!is_cdx && !is_adi && + !path_ends_with_ci(path, ".ntx")) { + // Custom extension (".Z01"): the suffix says nothing about the + // on-disk format -- a compound create writes a CDX bag under any + // name, and DBFCDX reads it back by content. Sniff the header so + // reopening takes the CDX path instead of misreading it as NTX. + is_cdx = file_has_cdx_signature(path); + } + // Open an ADT table's .adi through the CdxIndex engine when the env opt-in + // is set OR the bag on disk is already CDX-format (reroute-written). The + // format check makes OPEN robust to a missing env flag; it mirrors the + // create-side routing in AdsCreateIndex61. + const bool is_adt_tbl = + (dynamic_cast(t->driver()) != nullptr); + const bool adt_to_cdx = is_adi && is_adt_tbl && + (adt_cdx_index_enabled() || adi_bag_is_cdx_format(path)); + if (is_cdx || adt_to_cdx) { + auto r = openads::drivers::cdx::CdxIndex::list_tags(path); + if (!r) return fail(r.error()); + tags = std::move(r).value(); + } else if (is_adi) { + // AdiIndex::list_tags sorts by per-tag header page (creation order), + // not raw directory-slot order, so it is correct whether this bag's + // directory is laid out append or prepend. No reversal needed here: + // ordinal 1 is already the first tag created, which is what + // DBSETORDER(n) / OrdSetFocus(n) callers expect. + auto r = openads::drivers::adi::AdiIndex::list_tags(path, t->path()); + if (!r) return fail(r.error()); + tags = std::move(r).value(); + } + if (tags.empty()) { + // NTX or empty CDX: open once via the legacy path. M9.14 lets + // multiple NTX files coexist on the same Table -- when the + // table already has an active order, the new NTX parks as an + // extra view instead of replacing it. + auto idx = make_index_for(path); + if (auto r = idx->open(path, openads::drivers::IndexOpenMode::Shared); !r) { + return fail(r.error()); + } + std::string tag_name = idx->name(); + if (path_ends_with_ci(path, ".ntx")) + mark_ntx_key_encoding(t, idx.get()); + ADSHANDLE h = old_handles.count(tag_name) + ? old_handles[tag_name] : next_index_handle(); + // Map mutations only (storm fix -- see the refresh snapshot). + { + std::lock_guard _oi_lk(index_bindings_mu()); + auto& m = index_bindings(); + auto& act = active_binding_for(); + if (!table_has_active) { + t->set_order(std::move(idx)); + m[h] = IndexBinding{t, tag_name, nullptr, path}; + act[t] = h; + } else { + openads::drivers::IIndex* raw = idx.get(); + m[h] = IndexBinding{t, tag_name, std::move(idx), path}; + t->register_extra_index_view(raw); + } + } + ahIndex[0] = h; + if (pu16ArrayLen != nullptr) *pu16ArrayLen = 1; + return ok(); + } + + // CDX / ADI with one or more tags: open each by name. The first tag's + // IIndex moves into Table::order_ (becomes default order) only + // when the table doesn't already have an active order; the rest + // (and the first tag in the additive case) park as extra views. + // + // ACE semantics: opening a bag opens EVERY tag -- the handle array + // only limits how many tag handles are RETURNED to the caller, never + // how many tags are bound and maintained. Binding just the first tag + // when the caller passes a NULL/short array left the remaining tags + // stale on every write; a Harbour DBFCDX peer opening the same bag + // then hit corrupt trees ("Corruption detected" / GPF on append -- + // Pritpal Bedi's record-151 crash). + UNSIGNED16 cap = (pu16ArrayLen != nullptr) ? *pu16ArrayLen : 0; + UNSIGNED16 count = 0; + std::vector opened_views; + struct PendingBind { + ADSHANDLE h; + std::string name; + openads::drivers::IIndex* raw; + }; + std::vector to_bind; + for (const auto& name : tags) { + std::unique_ptr sub; + // A rerouted bag was listed through CdxIndex above, so it must be + // opened through CdxIndex too -- handing a CDX-format .adi to the + // native AdiIndex reader fails and drops every tag on the floor. + if (is_adi && !adt_to_cdx) { + auto idx = std::make_unique(); + if (auto r = idx->open_named(path, + openads::drivers::IndexOpenMode::Shared, + name, t->path()); !r) { + return fail(r.error()); + } + sub = std::move(idx); + } else { + auto idx = std::make_unique(); + if (auto r = idx->open_named(path, + openads::drivers::IndexOpenMode::Shared, + name); !r) { + return fail(r.error()); + } + mark_cdx_key_encoding(t, idx.get()); + apply_cdx_oem_collation(t, idx.get()); + sub = std::move(idx); + } + ADSHANDLE h = old_handles.count(name) + ? old_handles[name] : next_index_handle(); + to_bind.push_back({h, name, sub.release()}); + } + // Map mutations only -- opens above ran unlocked (storm fix, see the + // comment on the refresh snapshot). Table mutation (set_order / + // register_extra_index_view) is per-connection state, safe here too: + // this Table belongs to this session alone. + { + std::lock_guard _oi_lk(index_bindings_mu()); + auto& m = index_bindings(); + auto& act = active_binding_for(); + for (auto& pb : to_bind) { + std::unique_ptr sub(pb.raw); + if (!table_has_active) { + t->set_order(std::move(sub)); + m[pb.h] = IndexBinding{t, pb.name, nullptr, path}; + act[t] = pb.h; + table_has_active = true; + } else { + m[pb.h] = IndexBinding{t, pb.name, std::move(sub), path}; + t->register_extra_index_view(pb.raw); + } + opened_views.push_back(pb.raw); + if (count < cap) ahIndex[count] = pb.h; + ++count; + } + } + if (pu16ArrayLen != nullptr) *pu16ArrayLen = count; + heal_stale_bag_on_open(t, opened_views); + return ok(); +} + +namespace { +// AdsDeleteIndex (OrdDestroy) rewrites the bag and must detach the tag +// even when it belongs to the STRUCTURAL bag -- AdsCloseIndex refuses +// to close structural tags on purpose (xBase production-index +// convention). This scoped guard lets the delete path reuse +// AdsCloseIndex without that refusal. +struct ForceIndexClose { + ForceIndexClose() { flag() = true; } + ~ForceIndexClose() { flag() = false; } + static bool& flag() { static thread_local bool f = false; return f; } +}; +} // namespace + +UNSIGNED32 ENTRYPOINT AdsCloseIndex(ADSHANDLE hIndex) { + arc2_trace("AdsCloseIndex"); +#if defined(OPENADS_WITH_SQLITE) + if (auto* si = get_sqlite_index(hIndex)) { + (void)si; + auto& s = state(); + std::lock_guard lk(s.mu); + sqlite_indexes_map().erase(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_FIREBIRD) + if (auto* si = get_firebird_index(hIndex)) { + (void)si; + auto& s = state(); + std::lock_guard lk(s.mu); + firebird_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); + if (!r) return fail(r.error()); + return ok(); + } + auto& m = index_bindings(); + auto& act = active_binding_for(); + // Storm fix: mutator must hold index_bindings_mu (binds are unlocked now). + std::lock_guard _cl_lk(index_bindings_mu()); + auto it = m.find(hIndex); + if (it == m.end()) return fail(openads::AE_INTERNAL_ERROR, "unknown index"); + // xBase production-index convention: a tag of the STRUCTURAL bag + // (the one named after the table) cannot be merely closed while the + // table is open -- DBFCDX leaves it attached on OrdBagClear + // (dballcmp: DBFCDX OrdCount() unchanged). OrdDestroy is the only + // way to drop a structural tag, and that goes through + // AdsDeleteIndex which rewrites the bag first. + if (it->second.table != nullptr && !ForceIndexClose::flag()) { + namespace fs = std::filesystem; + auto stem_ci = [](const std::string& p) { + std::string s = fs::path(p).stem().string(); + for (auto& c : s) + c = static_cast(std::tolower( + static_cast(c))); + return s; + }; + if (stem_ci(it->second.path) == + stem_ci(it->second.table->path())) { + if (it->second.parked) (void)it->second.parked->flush(); + return ok(); + } + } + Table* t = it->second.table; + if (t != nullptr) { + auto act_it = act.find(t); + if (act_it != act.end() && act_it->second == hIndex) { + t->clear_order(); + act.erase(act_it); + } else if (it->second.parked) { + t->unregister_extra_index_view(it->second.parked.get()); + (void)it->second.parked->flush(); + } + } + m.erase(it); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsCloseAllIndexes(ADSHANDLE hTable) { + arc2_trace("AdsCloseAllIndexes"); + if (auto* rt = get_remote_table(hTable)) { + // Deferred: a CloseTable is expected to absorb this (the server + // close purges the table's index bindings). If any other + // index-observing wire op intervenes, remote_flush_pending + // emits it ahead of that op. The client cache drops NOW so + // reads in the window answer post-close semantics. + // Parked, not dropped: the live tag->id maps move to the + // parked snapshot (server bindings stay open — no frame), so + // a same-bag OpenIndex restores them with zero frames and any + // op that only needs live bindings adopts the park. Getters in + // the window still see empty maps (post-close answers), exactly + // as when the maps were cleared outright. Repeat clears (the + // RDD issues OrdListClear twice per rotation) must NOT + // overwrite a good snapshot with empty maps — snapshot only + // when something is live. + if (!rt->index_by_tag.empty()) { + const unsigned ntags = + static_cast(rt->index_by_tag.size()); + rt->parked_by_tag = std::move(rt->index_by_tag); + rt->parked_handles = std::move(rt->index_handles); + rt->parked_active = rt->active_index_id; + rt->parked_bag_stem = bag_stem_ci(rt->last_open_bag.empty() ? + rt->prod_bag_path : + rt->last_open_bag); + rt->index_by_tag.clear(); + rt->index_handles.clear(); + rt->active_index_id = 0; + rt->indexes_parked = true; + // Park the nav stamp with the bindings: if the same order + // comes back via an unpark with no intervening wire nav + // (seq-gated), the post-unpark GotoTop dedupes instead of + // paying a refresh frame for state the server never lost. + rt->parked_nav_which = rt->last_nav; + rt->parked_nav_order = rt->last_nav_order; + rt->parked_nav_row = rt->last_nav_row; + rt->parked_nav_seq = rt->last_nav_seq; + cli_trace_tbl(rt, "AdsCloseAllIndexes", "parked %u tags", ntags); + } else if (rt->indexes_parked) { + cli_trace_tbl(rt, "AdsCloseAllIndexes", "repeat clear, park kept"); + } else { + cli_trace_tbl(rt, "AdsCloseAllIndexes", "nothing open"); + } + // Order belief changed with no frame: expire the nav stamp so a + // subsequent GotoTop cannot dedupe against the old binding. + rt->last_nav = 0; + rt->pair_valid = false; + rt->anchor_ok = false; + rt->close_all_indexes_pending = true; + return ok(); + } + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + auto& m = index_bindings(); + auto& act = active_binding_for(); + // Storm fix: mutator must hold index_bindings_mu (binds are unlocked now). + std::lock_guard _cal_lk(index_bindings_mu()); + // xBase production-index convention: tags of the STRUCTURAL bag + // (named after the table) stay attached through OrdListClear -- + // DBFCDX still reports OrdCount()=1 after it (dballcmp conformance + // harness). Only non-structural bindings are dropped; the order + // falls back to natural. + namespace fs = std::filesystem; + auto stem_ci = [](const std::string& p) { + std::string s = fs::path(p).stem().string(); + for (auto& c : s) + c = static_cast(std::tolower( + static_cast(c))); + return s; + }; + const std::string tbl_stem = stem_ci(t->path()); + for (auto it = m.begin(); it != m.end(); ) { + if (it->second.table == t && + stem_ci(it->second.path) != tbl_stem) { + if (it->second.parked) { + t->unregister_extra_index_view(it->second.parked.get()); + (void)it->second.parked->flush(); + } + it = m.erase(it); + } else { + ++it; + } + } + // Natural order after the clear: park a kept (structural) active + // binding into its slot; a dropped one just loses the order object. + auto act_it = act.find(t); + if (act_it != act.end()) { + if (m.find(act_it->second) != m.end()) { + park_active_order(t); + } else { + t->clear_order(); + act.erase(act_it); + } + } + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsCreateIndex61(ADSHANDLE hTable, + UNSIGNED8* pucFileName, + UNSIGNED8* pucIndexName, + UNSIGNED8* pucExpr, + UNSIGNED8* pucCondition, + UNSIGNED8* pucKeyFilter, + UNSIGNED32 ulOptions, + UNSIGNED16 usPageSize, + ADSHANDLE* phIndex) { + arc2_trace("AdsCreateIndex61"); + if (phIndex == nullptr || pucFileName == nullptr || + pucIndexName == nullptr || pucExpr == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "null arg"); + } +#if defined(OPENADS_WITH_SQLITE) + if (auto* st = get_sqlite_table(hTable)) { + auto tag = openads::abi::to_internal(pucIndexName, 0); + 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 ddl = openads::sql_backend::build_create_index_ddl( + openads::sql_backend::SqlDdlDialect::Sqlite, + st->name, tag, px.kind, px.column); + if (!ddl) return fail(ddl.error()); + if (auto ex = st->conn->exec_sql(ddl.value()); !ex) return fail(ex.error()); + auto si = std::make_unique(); + si->parent = st; + si->column = px.column; + auto& s = state(); + std::lock_guard lk(s.mu); + Handle gh = s.registry.register_object(HandleKind::SqliteIndex, si.get()); + *phIndex = to_ads_handle(gh); + sqlite_indexes_map().emplace(gh, std::move(si)); + (void)pucFileName; + (void)pucCondition; + (void)pucKeyFilter; + (void)ulOptions; + (void)usPageSize; + return ok(); + } +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* st = get_postgres_table(hTable)) { + auto tag = openads::abi::to_internal(pucIndexName, 0); + 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 ddl = openads::sql_backend::build_create_index_ddl( + openads::sql_backend::SqlDdlDialect::Postgres, + st->name, tag, px.kind, px.column); + if (!ddl) return fail(ddl.error()); + if (auto ex = st->conn->exec_sql(ddl.value()); !ex) return fail(ex.error()); + auto si = std::make_unique(); + si->parent = st; + si->column = px.column; + auto& s = state(); + std::lock_guard lk(s.mu); + Handle gh = s.registry.register_object(HandleKind::PostgresIndex, si.get()); + *phIndex = to_ads_handle(gh); + postgres_indexes_map().emplace(gh, std::move(si)); + (void)pucFileName; + (void)pucCondition; + (void)pucKeyFilter; + (void)ulOptions; + (void)usPageSize; + return ok(); + } +#endif +#if defined(OPENADS_WITH_MARIADB) + if (auto* st = get_maria_table(hTable)) { + auto tag = openads::abi::to_internal(pucIndexName, 0); + 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 ddl = openads::sql_backend::build_create_index_ddl( + openads::sql_backend::SqlDdlDialect::Maria, + st->name, tag, px.kind, px.column); + if (!ddl) return fail(ddl.error()); + if (auto ex = st->conn->exec_sql(ddl.value()); !ex) return fail(ex.error()); + auto si = std::make_unique(); + si->parent = st; + si->column = px.column; + auto& s = state(); + std::lock_guard lk(s.mu); + Handle gh = s.registry.register_object(HandleKind::MariaIndex, si.get()); + *phIndex = to_ads_handle(gh); + maria_indexes_map().emplace(gh, std::move(si)); + (void)pucFileName; + (void)pucCondition; + (void)pucKeyFilter; + (void)ulOptions; + (void)usPageSize; + return ok(); + } +#endif +#if defined(OPENADS_WITH_MSSQL) + if (auto* st = get_mssql_table(hTable)) { + auto tag = openads::abi::to_internal(pucIndexName, 0); + 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 ddl = openads::sql_backend::build_create_index_ddl( + openads::sql_backend::SqlDdlDialect::Mssql, + st->name, tag, px.kind, px.column); + if (!ddl) return fail(ddl.error()); + if (auto ex = st->conn->exec_sql(ddl.value()); !ex) return fail(ex.error()); + auto si = std::make_unique(); + si->parent = st; + si->column = px.column; + auto& s = state(); + std::lock_guard lk(s.mu); + Handle gh = s.registry.register_object(HandleKind::MssqlIndex, si.get()); + *phIndex = to_ads_handle(gh); + mssql_indexes_map().emplace(gh, std::move(si)); + (void)pucFileName; + (void)pucCondition; + (void)pucKeyFilter; + (void)ulOptions; + (void)usPageSize; + return ok(); + } +#endif +#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 = to_ads_handle(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 defined(OPENADS_WITH_FIREBIRD) + if (auto* st = get_firebird_table(hTable)) { + auto tag = openads::abi::to_internal(pucIndexName, 0); + 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 ddl = openads::sql_backend::build_create_index_ddl( + openads::sql_backend::SqlDdlDialect::Firebird, + st->name, tag, px.kind, px.column); + if (!ddl) return fail(ddl.error()); + if (auto ex = st->conn->exec_sql(ddl.value()); !ex) return fail(ex.error()); + 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::FirebirdIndex, si.get()); + *phIndex = to_ads_handle(gh); + firebird_indexes_map().emplace(gh, std::move(si)); + (void)pucFileName; + (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); + path = normalize_index_path(std::move(path)); + std::string tag = openads::abi::to_internal(pucIndexName, 0); + std::string expr = openads::abi::to_internal(pucExpr, 0); + std::string cond = pucCondition + ? openads::abi::to_internal(pucCondition, 0) : std::string(); + std::string kf = pucKeyFilter + ? openads::abi::to_internal(pucKeyFilter, 0) : std::string(); + auto r = rt->conn->create_index(rt->id, path, tag, expr, + cond, kf, + ulOptions, usPageSize); + if (!r) return fail(r.error()); + // Creating a tag opens EVERY tag in the bag (ADS semantics -- the + // server-side create binds siblings as parked views), so refresh + // the client registry from the server: OrdCount() (AdsGetNumIndexes) + // and tag lookup (AdsGetIndexHandle) must reflect the whole bag + // immediately, not only after close+reopen (Pritpal Bedi). Wire + // round-trip BEFORE taking s.mu (see the deadlock note at the + // production auto-open in AdsOpenTable). + auto refr = rt->conn->open_index(rt->id, path); + auto& s = state(); + std::lock_guard lk(s.mu); + static std::unordered_map> remote_indexes; + auto ri = std::make_unique(); + ri->conn = rt->conn; + ri->id = r.value(); + ri->tbl_id = rt->id; + ri->parent = rt; + ri->tag_name = tag; + Handle gh = s.registry.register_object( + HandleKind::RemoteIndex, ri.get()); + *phIndex = to_ads_handle(gh); + remote_indexes.emplace(gh, std::move(ri)); + rt->index_by_tag.emplace_back(tag, r.value()); + rt->index_handles.push_back(static_cast(gh)); + if (refr) { + const auto& entries = refr.value(); + auto in_bag = [&](const std::string& tg) { + for (const auto& e : entries) + if (e.tag == tg) return true; + return false; + }; + // Keep other bags' entries, then re-add this bag in bag order, + // reusing the gh of tags the client already knows. + std::vector> keep_tags; + std::vector keep_handles; + for (std::size_t i = 0; i < rt->index_by_tag.size(); ++i) { + if (in_bag(rt->index_by_tag[i].first)) continue; + keep_tags.push_back(rt->index_by_tag[i]); + if (i < rt->index_handles.size()) + keep_handles.push_back(rt->index_handles[i]); + } + for (const auto& ent : entries) { + std::uint64_t egh = 0; + for (std::size_t i = 0; i < rt->index_by_tag.size(); ++i) { + if (rt->index_by_tag[i].first == ent.tag && + i < rt->index_handles.size()) { + egh = rt->index_handles[i]; + break; + } + } + if (egh == 0) { + auto nri = std::make_unique(); + nri->conn = rt->conn; + nri->id = ent.id; + nri->tbl_id = rt->id; + nri->parent = rt; + nri->tag_name = ent.tag; + nri->bag_path = ent.bag_path; + nri->expression = ent.expression; + nri->is_unique = ent.is_unique; + nri->is_descending = ent.is_descending; + Handle nh = s.registry.register_object( + HandleKind::RemoteIndex, nri.get()); + remote_indexes.emplace(nh, std::move(nri)); + egh = static_cast(to_ads_handle(nh)); + } + keep_tags.emplace_back(ent.tag, ent.id); + keep_handles.push_back(egh); + } + rt->index_by_tag = std::move(keep_tags); + rt->index_handles = std::move(keep_handles); + } + if (rt->active_index_id == 0) rt->active_index_id = r.value(); + // Structural change: the bag gained a tag, so a parked snapshot + // predates it — drop the park (the maps above were rebuilt fresh + // from the server). The pending flag stays: the next flush sends + // a real close, since parked validity no longer holds. + rt->indexes_parked = false; + rt->parked_by_tag.clear(); + rt->parked_handles.clear(); + return ok(); + } + Table* t = get_table(hTable); + if (!t) { + return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + } + // Settle any coalesced dirty record first: the build loop below reads + // rows straight from disk, so a pending buffer edit would be indexed + // from its stale on-disk image (and silently dropped by + // load_record_for_bulk_scan's discard). + if (auto cr = t->commit_dirty_record(); !cr) return fail(cr.error()); + // The native (DBF/CDX/NTX/ADI) create path mutates the process-global + // index_bindings() / active_binding_for() maps (and the per-table order + // list) with no synchronization, so two connections building indexes + // concurrently corrupt the unordered_map (heap corruption / AV). Serialize + // the whole native create under the registry mutex -- the SQL backends above + // already take it. (state().mu is recursive, so nested handle lookups are + // safe.) + // (no whole-path state().mu — see index_bindings_mu for map ops) + (void)pucKeyFilter; (void)usPageSize; + auto bag = normalize_index_path( + openads::abi::to_internal(pucFileName, 0)); + auto tag = openads::abi::to_internal(pucIndexName, 0); + auto expr = openads::abi::to_internal(pucExpr, 0); + // Never persist the FIELD->/ALIAS-> qualifier in the stored key + // expression: native DbfCdx strips it ("INDEX ON FIELD->name" saves + // key "name"), and Harbour errors on a bare field reference when the + // qualifier leaks into the bag header. The evaluator is qualifier- + // agnostic (strip_alias_qualifiers), so building/seeking is unchanged. + expr = openads::engine::strip_alias_qualifiers(expr); + std::string for_expr = pucCondition != nullptr + ? openads::abi::to_internal(pucCondition, 0) + : std::string{}; + + namespace fs = std::filesystem; + // An ADT table is not always named .adt (ExtFile='.DAT'), so ask the + // driver in use as well -- an extension-only test routes those tables to + // .cdx and their companion .adi is never created. + bool is_adt_table = path_ends_with_ci(t->path(), ".adt"); + if (!is_adt_table && + dynamic_cast(t->driver()) != nullptr) { + is_adt_table = true; + } + const char* default_ext = is_adt_table ? ".adi" : ".cdx"; + fs::path p(resolve_index_bag_for_table(t, bag, default_ext)); + // ACE AdsCreateIndex* option bits. include/openads/ace.h carries the + // SDK-standard values (ADS_UNIQUE 0x01, ADS_COMPOUND 0x02, ADS_CUSTOM + // 0x04, ADS_DESCENDING 0x08) -- but the two RDD clients we interop with + // put the "compound" and "descending" flags on SWAPPED bits, measured + // by instrumenting this function: + // + // client ascending tag descending tag + // X# ADSRDD 0x02 0x0A (compound 0x02 | descending 0x08) + // Harbour rddads 0x08 0x0A (compound 0x08 | descending 0x02) + // + // Each client always sets ITS compound bit on EVERY tag (cdx and ntx), + // and adds the OTHER bit of the {0x02,0x08} pair to mean descending. So a + // lone 0x02 OR a lone 0x08 is ascending (it is just that client's + // "compound" marker), and "descending" is the one case where BOTH bits + // are set (0x0A). Reading a lone 0x08 (or 0x02) as descending built every + // Harbour (resp. X#) order reversed -- AdsGotoTop landing on the last key, + // SKIP walking backward. The internal SQL CREATE INDEX path (below) emits + // 0x0A for a descending tag so it round-trips through this same decode. + const bool opt_compound_bit = (ulOptions & ADS_COMPOUND) != 0; // 0x02 + const bool opt_descending_bit = (ulOptions & ADS_DESCENDING) != 0; // 0x08 + bool unique = (ulOptions & ADS_UNIQUE) != 0; + bool descend = opt_compound_bit && opt_descending_bit; + + bool is_cdx = path_ends_with_ci(p.string(), ".cdx"); + bool is_adi = path_ends_with_ci(p.string(), ".adi"); + if (!is_cdx && !is_adi && + !path_ends_with_ci(p.string(), ".ntx") && + (opt_compound_bit || opt_descending_bit)) { + // Custom extension (Pritpal's ".Z01", 29/07/2026): the on-disk + // FORMAT follows the index kind, not the suffix. Both RDD clients + // set their compound marker on every tag, so a compound create + // writes a CDX-format bag regardless of the extension -- exactly + // what real ADS does and the only form Harbour's DBFCDX can read + // back (it parses every bag as CDX and reported our NTX-written + // ".Z01" as corrupted). An explicit ".ntx" suffix, or a create + // with no compound marker at all, still produces an NTX. + is_cdx = true; + } + + // Validate the key expression: a bare identifier that is not a column + // is a bug in the caller's PRG (typo / renamed field). Native + // Harbour/Clipper raises "Variable does not exist" at INDEX time; + // silently accepting it builds an all-blank, useless index and hides + // the mistake. Mirror evaluate_index_expr()'s bare-field detection + // (single alnum/underscore token) so computed expressions still pass. + { + std::string ident = expr; + while (!ident.empty() && + std::isspace(static_cast(ident.front()))) + ident.erase(ident.begin()); + while (!ident.empty() && + std::isspace(static_cast(ident.back()))) + ident.pop_back(); + const bool bare_ident = !ident.empty() && + std::all_of(ident.begin(), ident.end(), [](char c) { + return std::isalnum(static_cast(c)) || c == '_'; + }) && + !std::isdigit(static_cast(ident.front())); + if (bare_ident && t->field_index(ident) < 0) { + return fail(openads::util::Error{ + openads::AE_COLUMN_NOT_FOUND, 0, + "index expression references unknown column", ident}); + } + } + + // CDX numeric keys: FoxPro/Harbour store a bare numeric field as an + // 8-byte order-preserving binary key (keySize 8), not text. Detect a + // bare numeric field so the index is created with keySize=8 and marked + // FoxNumeric; the engine then emits the binary key at write time. + // STR()/character expressions stay text. (Date deferred -- DTOS-style + // text indexes already interop.) + bool cdx_numeric_key = false; + if (is_cdx) { + const std::string bare = openads::engine::strip_alias_qualifiers(expr); + std::int32_t fi = t->field_index(bare); + if (fi >= 0) { + using FT = openads::drivers::DbfFieldType; + FT ft = t->field_descriptor(static_cast(fi)).type; + cdx_numeric_key = + ft == FT::Numeric || ft == FT::Float || ft == FT::Integer || + ft == FT::Double || ft == FT::Currency || ft == FT::AdtMoney; + } + } + + // Support computed numeric expressions such as `Val(charfield)` (common + // in rddads apps that store "numbers" in char columns for legacy reasons). + // If the expression produces a number on a sample record, treat it as a + // CDX numeric key (8-byte Fox binary) so the index is built and searched + // with the correct encoding. Fixes #130. + if (is_cdx && !cdx_numeric_key && t->record_count() > 0) { + // Use a direct record read + bulk load so we don't disturb any + // cursor state the caller may have. + if (auto raw = t->driver()->read_record_raw(1); raw) { + t->load_record_for_bulk_scan(std::move(raw.value()), 1); + double dv = 0.0; + bool isnum = openads::engine::evaluate_index_expr_number(*t, expr, dv); + if (isnum) { + cdx_numeric_key = true; + } + } + } + + // Targeted for the common rddads pattern INDEX ON Val(charfield) (#130). + // The record-1 probe above cannot run on an empty table, so fall back to a + // syntactic test -- but only when the WHOLE expression is that call. + // + // Testing for "VAL(" anywhere misfires on a CHARACTER key that merely + // contains one: `cDocumeTra+STR(VAL(cConIntTra),3,0)` is a concatenation + // producing text, and marking it numeric makes the build loop demand a + // number from it and fail with ADSCDX/5000 "failed to evaluate numeric + // index expression" partway through the table. + if (is_cdx && !cdx_numeric_key) { + std::string u; + u.reserve(expr.size()); + for (char c : expr) + u.push_back(static_cast(std::toupper( + static_cast(c)))); + while (!u.empty() && std::isspace(static_cast(u.back()))) + u.pop_back(); + std::size_t b = 0; + while (b < u.size() && std::isspace(static_cast(u[b]))) + ++b; + u.erase(0, b); + if (u.rfind("VAL(", 0) == 0 && !u.empty() && u.back() == ')') { + // The ')' closing the leading VAL( must be the last character; + // otherwise the call is just one component of a bigger expression. + int depth = 0; + std::size_t close = std::string::npos; + for (std::size_t i = 3; i < u.size(); ++i) { // u[3] == '(' + if (u[i] == '(') { + ++depth; + } else if (u[i] == ')') { + if (--depth == 0) { close = i; break; } + } + } + if (close == u.size() - 1) cdx_numeric_key = true; + } + } + + // NTX numeric keys: a native xBase NTX stores a numeric field's key as + // fixed-width, right-justified ASCII = STR(value, fieldLen, fieldDec), + // and records the decimal count in the index header. The key stays + // TEXT (unlike the compound-index 8-byte binary form), but the width + // and decimals MUST come from the field descriptor, not from a probed + // key length (which is wrong/empty on an empty table). Detect a bare + // ASCII-stored numeric field (N / F) so the index width is pinned to + // the field length and the decimals land in the header. VFP binary + // numerics (I/B/Y) are not ASCII on disk -- left for a follow-up. + bool ntx_numeric_key = false; + std::uint16_t ntx_num_width = 0; + std::uint16_t ntx_num_dec = 0; + if (!is_cdx && !is_adi) { + const std::string bare = openads::engine::strip_alias_qualifiers(expr); + std::int32_t fi = t->field_index(bare); + if (fi >= 0) { + using FT = openads::drivers::DbfFieldType; + const auto& fd = + t->field_descriptor(static_cast(fi)); + if ((fd.type == FT::Numeric || fd.type == FT::Float) && + fd.length > 0) { + ntx_numeric_key = true; + ntx_num_width = fd.length; + ntx_num_dec = static_cast(fd.decimals); + } + } + } + + // Determine the on-disk key length. Numeric CDX keys use the 8-byte + // binary width; numeric NTX keys use the field's own width so the key + // matches the native reader. + std::uint16_t klen = cdx_numeric_key ? 8 + : ntx_numeric_key ? ntx_num_width + : 0; + if (!cdx_numeric_key && !ntx_numeric_key) { + // A character key is fixed-width on disk. For a BARE character field + // the width is the declared field length: deriving it from the + // *trimmed* value of the first record truncates every later key that + // shares a prefix beyond that width (a short first row collapses + // longer rows onto the same key), corrupting ordering and seeks in + // both the index itself and native FoxPro/Clipper readers. + const std::string bare = openads::engine::strip_alias_qualifiers(expr); + std::int32_t fi = t->field_index(bare); + if (fi >= 0) { + klen = t->field_descriptor(static_cast(fi)).length; + } else if (t->record_count() > 0) { + // Composite expression: probe the first record's key. + // key_len=0 makes evaluate_index_expr return the un-padded + // result, reading fields at their declared width. Do NOT + // rtrim: a fixed-width field's trailing blanks are part of + // the key. The old code rtrimmed here, pinning key_size to + // the first record's *content* length, so any longer key + // (e.g. a longer UPPER(name)) got truncated, its + // distinguishing tail dropped; the bulk-build sort then + // scrambled recnos and the browse showed rows out of order. + if (auto g = t->goto_record(1); g) { + if (auto k = openads::engine::evaluate_index_expr(*t, expr, 0)) { + std::size_t natlen = k.value().size(); + if (natlen > 0) + klen = static_cast( + std::min(natlen, 254)); + } + } + } + if (klen == 0) klen = 32; // empty table, composite expression + } + + std::unique_ptr idx_owner; + bool exists = false; + // Serialise the create-or-attach decision per bag path: two sessions + // racing INDEX ON ... TAG x TO both saw !exists and + // both ran CdxIndex::create (CREATE_ALWAYS), each truncating the bag + // the other had just written -- torn struct-tag leaf, dangling + // sub-tag pages (field stress: key count 0 on a 1200-row table). + // Under the lock the loser re-checks and takes the add_tag / + // overwrite path instead. Released before the build loop below. + std::unique_lock bag_create_lk( + create_path_mu_for(p.string())); + { + std::error_code ec; + exists = (is_cdx || is_adi) && fs::exists(p, ec); + } + + // ADT table + .adi bag -> route to the CdxIndex engine when the env opt-in + // is set OR the bag already exists in CDX format (so adding a tag to a + // reroute-built bag stays CDX even with the flag absent -- never mix formats + // in one bag). A fresh bag (the ERP erases the .adi before reindex) has no + // file, so the env flag decides the format to write. + const bool adt_to_cdx = is_adi && is_adt_table && + (adt_cdx_index_enabled() || + (std::filesystem::exists(p) && adi_bag_is_cdx_format(p.string()))); + + if (is_adi && is_adt_table && !adt_to_cdx) { + // ADT tables use a single .adi bag. Prefer bare field, but allow + // compound expressions (Russoft INDEX ON fld1+fld2 for .ADI) by + // falling back to first field for tag metadata. Keys are built from + // the full evaluated expr at population time. + const std::string bare = openads::engine::strip_alias_qualifiers(expr); + std::int32_t fidx = t->field_index(bare); + const bool is_compound = (fidx < 0); // computed / multi-field expression + if (fidx < 0) { + fidx = 0; // fallback for compound expr + } + if (fidx + 1 > 255) { + return fail(openads::AE_INTERNAL_ERROR, + "ADI index does not support field numbers greater than 255"); + } + const auto& fd = t->field_descriptor(static_cast(fidx)); + const std::uint16_t adt_t = static_cast( + static_cast(fd.raw_type)); + const bool is_char_field = + adt_t == openads::drivers::adi::ADT_TYPE_CHAR || + adt_t == openads::drivers::adi::ADT_TYPE_CICHAR; + // The v2 opaque-key leaf (full key stored, memcmp-ordered) is correct for + // char fields and ANY computed/compound expression -- exactly what the ERP + // uses (STR()/DTOS()/concat → character keys). A BARE numeric/date field + // keeps the legacy numeric ADI leaf (sign-flipped float keys) so the + // existing packed-key seeks keep working. + // v2 is OPT-IN. With the switch off this call behaves exactly as it did + // before: a bare field tag takes the legacy layout, and an expression + // the legacy format cannot represent is rejected the way it was + // rejected before (there is nowhere in a legacy tag header to keep the + // expression, so accepting it silently would produce a bag whose tag + // says one thing and whose keys say another). + const bool v2_on = adi_v2_enabled(); + if (!v2_on && is_compound) { + return fail(openads::AE_COLUMN_NOT_FOUND, + "ADI: a compound / computed index expression needs the " + "v2 tag layout (set OPENADS_ADI_V2=1)"); + } + const bool use_v2 = v2_on && (is_char_field || is_compound); + openads::drivers::adi::AdiIndex::CreateParams cp{}; + cp.field_num = static_cast(fidx + 1); + cp.field_name = fd.name; + cp.adt_type = adt_t; + cp.fld_length = fd.length; + cp.record_offset = fd.record_offset; + cp.adt_hdr_len = t->driver()->header_length(); + cp.adt_rec_len = t->driver()->record_length(); + cp.unique = unique; + cp.adt_path = t->path(); // important for .DAT + ADS_ADT case (not .adt) + if (use_v2) { + // identity by NAME + persisted expression/FOR + full opaque key + // (klen stays the full evaluated-expression length; the build loop + // below evaluates the whole expression). + cp.tag_name = tag; + cp.key_expr = expr; + cp.for_expr = for_expr; + cp.key_len = static_cast(klen); + cp.descending = descend; + } else if (!is_char_field) { + // Bare numeric/date → legacy numeric ADI leaf (8-byte packed keys). + // Bare character keeps klen = field length for the legacy char + // leaf -- do NOT force 8 here or every char key is truncated and + // partial seeks miss (OPENADS_ADI_V2 off is the default). + klen = 8; + } + + if (exists) { + // v2 identity is the TAG name (list_tags returns tag names now); + // legacy callers without a tag_name fall back to the field name. + const std::string ident = + cp.tag_name.empty() ? fd.name : cp.tag_name; + auto tags = openads::drivers::adi::AdiIndex::list_tags( + p.string(), t->path()); + bool have_tag = false; + if (tags) { + for (const auto& tn : tags.value()) { + if (tn.size() == ident.size()) { + bool eq = true; + for (std::size_t i = 0; i < tn.size(); ++i) { + if (std::tolower(static_cast(tn[i])) != + std::tolower(static_cast( + ident[i]))) { + eq = false; + break; + } + } + if (eq) { have_tag = true; break; } + } + } + } + if (have_tag) { + openads::drivers::adi::AdiIndex existing; + auto reopen = existing.open_named( + p.string(), openads::drivers::IndexOpenMode::Shared, + ident, t->path()); + if (!reopen) return fail(reopen.error()); + if (auto cl = existing.clear_data(); !cl) return fail(cl.error()); + idx_owner = std::make_unique< + openads::drivers::adi::AdiIndex>(std::move(existing)); + } else { + auto added = openads::drivers::adi::AdiIndex::add_tag( + p.string(), cp); + if (!added) return fail(added.error()); + idx_owner = std::make_unique< + openads::drivers::adi::AdiIndex>(std::move(added).value()); + } + } else { + auto created = openads::drivers::adi::AdiIndex::create( + p.string(), cp); + if (!created) return fail(created.error()); + idx_owner = std::make_unique( + std::move(created).value()); + } + } else if ((is_cdx || adt_to_cdx) && exists) { + // Harbour rddads / Clipper semantics: re-creating an + // existing tag is a silent overwrite, not an error. If the + // tag already exists, open it and clear its B+tree so the + // caller's per-record insert loop rebuilds it fresh. + auto added = openads::drivers::cdx::CdxIndex::add_tag( + p.string(), tag, expr, klen, unique, descend, for_expr); + if (!added && added.error().code == 5044) { + openads::drivers::cdx::CdxIndex existing; + auto reopen = existing.open_named(p.string(), + openads::drivers::IndexOpenMode::Shared, tag); + if (!reopen) return fail(reopen.error()); + // Wipe the old B+tree before the per-record insert + // loop rebuilds it; otherwise duplicates from the + // prior CREATE INDEX accumulate and break SKIP walks. + if (auto cl = existing.clear_data(); !cl) + return fail(cl.error()); + // CREATE INDEX overwrite must also pick up the new + // UNIQUE / DESCEND options + the freshly-probed key + // size; the sub-header was loaded from disk with the + // prior options. + if (auto so = existing.set_options(unique, descend, klen); !so) + return fail(so.error()); + // Re-creating an existing tag overwrites its stored KEY + // expression and FOR clause too -- the whole point of + // "INDEX ON TAG ORDERX" without first deleting + // the bag. Without this the header kept the old column, so + // AdsGetIndexExpr lied and the next dbAppend synced the wrong + // column into the tag (silent disorder). Native DBFCDX deletes + // and recreates the tag; rewriting the pool here matches that. + if (auto se = existing.set_expression(expr, for_expr); !se) + return fail(se.error()); + idx_owner = std::make_unique( + std::move(existing)); + } else if (!added) { + return fail(added.error()); + } else { + idx_owner = std::make_unique( + std::move(added).value()); + } + } else if (is_cdx || adt_to_cdx) { + auto created = openads::drivers::cdx::CdxIndex::create( + p.string(), tag, expr, klen, unique, descend, for_expr); + if (!created) return fail(created.error()); + idx_owner = std::make_unique( + std::move(created).value()); + } else if (is_adi) { + return fail(openads::AE_INTERNAL_ERROR, + "ADI index bag requires an ADT table"); + } else { + auto created = openads::drivers::ntx::NtxIndex::create( + p.string(), tag, expr, klen, unique, descend); + if (!created) return fail(created.error()); + auto ntx_owner = std::make_unique( + std::move(created).value()); + // Pin the key geometry to the numeric field descriptor so the + // on-disk key is the native fixed-width STR(value,width,dec) form + // (and the header carries the decimal count) -- independent of any + // probed key length, which is absent on an empty table. + if (ntx_numeric_key) { + if (auto sf = ntx_owner->set_numeric_format( + ntx_num_width, ntx_num_dec); !sf) { + return fail(sf.error()); + } + } + idx_owner = std::move(ntx_owner); + } + // Create/attach decision is made; the bulk build below only reads the + // table and writes through the (write-locked) index instance. + bag_create_lk.unlock(); + // Mark a numeric CDX index FoxNumeric so every key-build path (this + // create loop, the engine's sync_all_indexes_, seek) emits the 8-byte + // binary key a native reader expects. + if (cdx_numeric_key && idx_owner) { + idx_owner->set_key_encoding(openads::drivers::KeyEncoding::FoxNumeric); + } + if (idx_owner) apply_cdx_oem_collation(t, idx_owner.get()); + auto rec_count = t->record_count(); + // Fast path: for CDX, collect the whole key stream and bulk-load the + // B+tree in one bottom-up pass (sort -> pack leaves -> branch levels) + // instead of record-by-record top-down insertion. Each page is encoded + // once rather than decoded+re-encoded on every key, ~10x faster on a + // full CREATE INDEX / REINDEX. NTX keeps the incremental path. + // CDX and the v2 ADI dense leaf both override IIndex::build_bulk, so the + // call dispatches to the right engine; NTX falls back to the per-record + // default and is left on the incremental path here. + const bool use_bulk = is_cdx || is_adi; + std::vector> bulk_keys; + if (use_bulk) bulk_keys.reserve(rec_count); + for (std::uint32_t r = 1; r <= rec_count; ++r) { + // Use direct driver read + bulk buffer load so the driver's + // read-ahead cache is effective for this sequential scan. + // goto_record(r) would invalidate on every iteration. + auto raw = t->driver()->read_record_raw(r); + if (!raw) return fail(raw.error()); + t->load_record_for_bulk_scan(std::move(raw.value()), r); + + // DBFCDX inserts deleted rows too -- the index is a logical + // mirror of the table, not a "live-only" view. SET DELETED + // hides them at navigation time. Only the FOR clause filters + // entries out at build time. + if (!for_expr.empty()) { + if (!openads::engine::evaluate_index_expr_truthy(*t, for_expr)) + continue; + } + std::string kbytes; + if (cdx_numeric_key) { + double dv = 0.0; + if (!openads::engine::evaluate_index_expr_number(*t, expr, dv)) + return fail(openads::AE_INTERNAL_ERROR, + "failed to evaluate numeric index expression"); + kbytes = openads::engine::fox_numeric_key(dv); + } else if (ntx_numeric_key) { + double dv = 0.0; + if (!openads::engine::evaluate_index_expr_number(*t, expr, dv)) + return fail(openads::AE_INTERNAL_ERROR, + "failed to evaluate numeric index expression"); + kbytes = openads::engine::ntx_numeric_key(dv, ntx_num_width, + ntx_num_dec); + } else { + auto k = openads::engine::evaluate_index_expr(*t, expr, klen); + if (!k) return fail(k.error()); + kbytes = std::move(k).value(); + } + if (use_bulk) { + bulk_keys.emplace_back(std::move(kbytes), r); + } else if (auto ins = idx_owner->insert(r, kbytes); !ins) { + return fail(ins.error()); + } + } + if (use_bulk) { + if (auto b = idx_owner->build_bulk(std::move(bulk_keys)); !b) + return fail(b.error()); + } + if (auto fl = idx_owner->flush(); !fl) return fail(fl.error()); + // A bag named after the table is the Harbour DBFCDX production + // index -- set DBF header byte 28 so Harbour auto-opens it on USE. + if (is_cdx) { + stamp_production_index_flag(t, p.string()); + } + + // Map mutations (sibling park + tag registration) under index_bindings_mu. + // Bulk build above intentionally ran unlocked for multi-thread INDEX ON. + std::lock_guard _bind_lk(index_bindings_mu()); + auto& m = index_bindings(); + auto& act = active_binding_for(); + // Sibling tag refresh: a fresh CREATE INDEX implicitly resyncs + // every tag in the bag (rddads' ORDLSTCLEAR + INDEX cycle drops + // every other tag's binding so sync_all_indexes_ skipped them + // on subsequent dbappend / replace, leaving stale or empty + // B+trees on disk). Re-build each sibling tag's B+tree from the + // current DBF, then register it as a parked extra binding so + // later dbappend / dbreplace cycles update it via + // sync_all_indexes_ instead of leaving it stale again. + auto& m_pre = index_bindings(); + auto& act_pre = active_binding_for(); + if (is_cdx) { + auto sibs = openads::drivers::cdx::CdxIndex::list_tags(p.string()); + if (sibs) { + // Collect all siblings that need a full DBF rebuild (lost binding + empty tree). + // Batch their key collection with ONE scan using the multi-expression prototype. + struct Pending { + std::string name; + std::string expr; + std::string for_expr; + std::uint16_t klen; + bool fox; + std::unique_ptr sub; + }; + std::vector pending; + for (const auto& sib : sibs.value()) { + if (sib == tag) continue; + bool already_bound = false; + for (auto& [h0, b0] : m_pre) { + if (b0.table == t && b0.tag_name == sib) { already_bound = true; break; } + } + if (already_bound) continue; + + auto sub = std::make_unique(); + if (auto r0 = sub->open_named(p.string(), openads::drivers::IndexOpenMode::Shared, sib); !r0) continue; + mark_cdx_key_encoding(t, sub.get()); + apply_cdx_oem_collation(t, sub.get()); + + bool has_data = false; + if (auto sf = sub->seek_first(); sf) has_data = sf.value().positioned; + sub->invalidate_cursor(); + + if (!has_data) { + if (auto cl = sub->clear_data(); !cl) continue; + Pending pend; + pend.name = sib; + pend.expr = sub->expression(); + pend.for_expr = sub->condition(); + pend.klen = sub->key_length(); + pend.fox = (sub->key_encoding() == openads::drivers::KeyEncoding::FoxNumeric); + pend.sub = std::move(sub); + pending.push_back(std::move(pend)); + } else { + // Park existing + ADSHANDLE sh = next_index_handle(); + m_pre[sh] = IndexBinding{t, sib, std::move(sub), p.string()}; + t->register_extra_index_view(m_pre[sh].parked.get()); + (void)act_pre; + } + } + + if (!pending.empty()) { + // Batch non-fox string expressions with single scan + std::vector bexprs, bfors; + std::vector bklens; + std::vector batch_idx; + for (size_t i=0; i>> multi_res; + if (!bexprs.empty()) { + if (auto mr = t->collect_keys_for_multiple_expressions(bexprs, bfors, bklens); mr) { + multi_res = std::move(mr.value()); + } + } + + for (size_t j=0; j> keys = (j < multi_res.size()) ? std::move(multi_res[j]) : std::vector>(); + (void)ps.sub->build_bulk(std::move(keys)); + (void)ps.sub->flush(); + + ADSHANDLE sh = next_index_handle(); + openads::drivers::IIndex* rawp = ps.sub.get(); + m_pre[sh] = IndexBinding{t, ps.name, std::move(ps.sub), p.string()}; + t->register_extra_index_view(rawp); + (void)act_pre; + } + + // Fox numeric fallbacks (do individually) + for (auto& ps : pending) { + if (ps.fox && ps.sub) { + std::vector> keys; + for (uint32_t r=1; r<=rec_count; ++r) { + auto raw = t->driver()->read_record_raw(r); + if (!raw) continue; + t->load_record_for_bulk_scan(std::move(raw.value()), r); + if (!ps.for_expr.empty() && !openads::engine::evaluate_index_expr_truthy(*t, ps.for_expr)) continue; + double dv=0; + if (openads::engine::evaluate_index_expr_number(*t, ps.expr, dv)) { + keys.emplace_back(openads::engine::fox_numeric_key(dv), r); + } + } + (void)ps.sub->build_bulk(std::move(keys)); + (void)ps.sub->flush(); + ADSHANDLE sh = next_index_handle(); + m_pre[sh] = IndexBinding{t, ps.name, std::move(ps.sub), p.string()}; + t->register_extra_index_view(m_pre[sh].parked.get()); + } + } + } + } + } + + // Drop ANY existing binding whose tag matches the one we're + // re-creating: a CREATE INDEX command on an existing tag is a + // silent overwrite (clear_data already wiped the on-disk + // B+tree) so the stale binding must vanish too -- otherwise + // ordinal lookups iterate over both the old and new bindings. + // RCB 09/02/2026 (B_BIG storm 700): remember the dropped handle and + // REUSE it for the new binding -- mirroring AdsOpenIndex's reopen + // refresh (which keeps old tag->handle mappings for exactly this + // reason). Minting a fresh handle left remote sessions' index_h_ + // pointing at the erased one; their next SetOrder failed with + // 5000 "index not bound to table" and poisoned every ordered nav. + ADSHANDLE reuse_h = 0; + for (auto it = m.begin(); it != m.end(); ) { + if (it->second.table == t && it->second.tag_name == tag) { + // If the stale entry was the active one, take the + // active IIndex back from the Table so the next + // set_order doesn't double-free. + if (act.count(t) && act[t] == it->first) { + (void)t->take_order(); + act.erase(t); + } else if (it->second.parked) { + t->unregister_extra_index_view( + it->second.parked.get()); + } + reuse_h = it->first; + it = m.erase(it); + } else { + ++it; + } + } + ADSHANDLE h = reuse_h != 0 ? reuse_h : next_index_handle(); + // Each new CREATE INDEX makes itself the active order + // (Clipper / Harbour convention). Park the previous active + // (if any) so it stays openable + survives ORDSETFOCUS(N). + auto prev = act.find(t); + if (prev != act.end()) { + auto pit = m.find(prev->second); + if (pit != m.end()) { + auto displaced = t->take_order(); + pit->second.parked = std::move(displaced); + t->register_extra_index_view(pit->second.parked.get()); + } + } + t->set_order(std::move(idx_owner)); + m[h] = IndexBinding{t, tag, nullptr, p.string()}; + act[t] = h; + // Clipper / Harbour: a fresh CREATE INDEX leaves the cursor + // positioned at the new order's TOP key. + (void)t->goto_top(); + *phIndex = h; + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsCreateIndex(ADSHANDLE hTable, UNSIGNED8* pucFile, + UNSIGNED8* pucTag, UNSIGNED8* pucExpr, + UNSIGNED8* pucCondition, UNSIGNED32 ulOptions, + UNSIGNED16 usKeyType, ADSHANDLE* phIndex) { + arc2_trace("AdsCreateIndex"); + if (phIndex == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "null index out-param"); + } + // Legacy API: remote tables route through AdsCreateIndex61 (M12.16). + if (get_remote_table(hTable) != nullptr) { + return AdsCreateIndex61(hTable, pucFile, pucTag, pucExpr, pucCondition, + nullptr, ulOptions, + usKeyType ? usKeyType + : static_cast(ADS_DEFAULT), + phIndex); + } + Table* t = get_table(hTable); + if (!t) { + return fail(openads::AE_INTERNAL_ERROR, "unknown table or null out"); + } + // Settle any coalesced dirty record first (see AdsCreateIndex61). + if (auto cr = t->commit_dirty_record(); !cr) return fail(cr.error()); + auto file = normalize_index_path( + openads::abi::to_internal(pucFile, 0)); + auto tag = openads::abi::to_internal(pucTag, 0); + auto expr = openads::abi::to_internal(pucExpr, 0); + // Never persist the FIELD->/ALIAS-> qualifier in the stored key + // expression (mirrors AdsCreateIndex61 and native DbfCdx). + expr = openads::engine::strip_alias_qualifiers(expr); + // A FOR condition makes this a conditional index: only matching rows get + // a key entry (mirrors AdsCreateIndex61's build loop). Ignoring pucCondition + // built a full index over every row, so AdsGetKeyCount, OrdKeyNo, SKIP and + // EOF all reflected ALL records instead of the matching subset. + std::string for_expr = pucCondition != nullptr + ? openads::abi::to_internal(pucCondition, 0) + : std::string{}; + + // Resolve the field referenced by the expression to determine key length. + std::int32_t fidx = t->field_index(expr); + if (fidx < 0) { + return fail(openads::AE_COLUMN_NOT_FOUND, + "AdsCreateIndex: expression must be a bare field name"); + } + std::uint16_t klen = t->field_descriptor(static_cast(fidx)).length; + + // Path resolution: mirror AdsCreateIndex61 so the legacy wrapper handles + // empty bag names, relative paths, and missing extensions the same way. + file = resolve_index_bag_for_table(t, file, ".cdx"); + + std::unique_ptr idx; + if (path_ends_with_ci(file, ".cdx")) { + namespace fs2 = std::filesystem; + // Same per-path serialization as AdsCreateIndex61: the + // create-vs-add decision must be atomic per bag. + std::unique_lock bag_create_lk( + create_path_mu_for(file)); + const bool bag_exists = fs2::exists(fs2::path(file)); + if (!bag_exists) { + auto created = openads::drivers::cdx::CdxIndex::create( + file, tag, expr, klen, false, false, for_expr); + if (!created) return fail(created.error()); + idx = std::make_unique( + std::move(created).value()); + } else { + // The bag exists: ADD the tag (SAP ACE semantics) instead of + // truncating the bag -- the legacy entry point used to recreate + // the file unconditionally, silently dropping every previously + // created tag (only the last one survived). + auto added = openads::drivers::cdx::CdxIndex::add_tag( + file, tag, expr, klen, false, false, for_expr); + if (!added && added.error().code == 5044) { + // Tag already present: silent overwrite (Harbour rddads / + // Clipper convention) -- reopen, wipe, rebuild below. + openads::drivers::cdx::CdxIndex existing; + auto reopen = existing.open_named( + file, openads::drivers::IndexOpenMode::Shared, tag); + if (!reopen) return fail(reopen.error()); + if (auto cl = existing.clear_data(); !cl) + return fail(cl.error()); + if (auto so = existing.set_options(false, false, klen); !so) + return fail(so.error()); + if (auto se = existing.set_expression(expr, for_expr); !se) + return fail(se.error()); + idx = std::make_unique( + std::move(existing)); + } else { + if (!added) return fail(added.error()); + idx = std::make_unique( + std::move(added).value()); + } + } + } else { + auto created = openads::drivers::ntx::NtxIndex::create( + file, tag, expr, klen, false, false); + if (!created) return fail(created.error()); + idx = std::make_unique( + std::move(created).value()); + } + + // Populate from existing live records in primary order. Deleted + // records are skipped so AdsSeek over the new index never returns + // phantom recnos. For CDX, collect the key stream and bulk-load the + // B+tree bottom-up (one encode per page) instead of record-by-record + // insertion -- ~10x faster on a full build. + auto rec_count = t->record_count(); + openads::drivers::cdx::CdxIndex* cdx_bulk = + path_ends_with_ci(file, ".cdx") + ? static_cast(idx.get()) + : nullptr; + std::vector> bulk_keys; + if (cdx_bulk) bulk_keys.reserve(rec_count); + for (std::uint32_t r = 1; r <= rec_count; ++r) { + // Direct driver read (read-ahead friendly) -- legacy AdsCreateIndex path. + auto raw = t->driver()->read_record_raw(r); + if (!raw) return fail(raw.error()); + t->load_record_for_bulk_scan(std::move(raw.value()), r); + + if (t->is_deleted()) continue; + // FOR clause filters entries out at build time (DBFCDX semantics). + if (!for_expr.empty() && + !openads::engine::evaluate_index_expr_truthy(*t, for_expr)) + continue; + auto v = t->read_field(static_cast(fidx)); + if (!v) return fail(v.error()); + std::string padded = v.value().as_string; + if (padded.size() < klen) padded.append(klen - padded.size(), ' '); + if (padded.size() > klen) padded.resize(klen); + if (cdx_bulk) { + bulk_keys.emplace_back(std::move(padded), r); + } else if (auto ins = idx->insert(r, padded); !ins) { + return fail(ins.error()); + } + } + if (cdx_bulk) { + if (auto b = cdx_bulk->build_bulk(std::move(bulk_keys)); !b) + return fail(b.error()); + } + if (auto fl = idx->flush(); !fl) return fail(fl.error()); + // A bag named after the table is the Harbour DBFCDX production + // index -- set header byte 28 so Harbour auto-opens it on USE. + if (path_ends_with_ci(file, ".cdx")) { + stamp_production_index_flag(t, file); + } + + // Map mutations only (storm fix): the bulk build above ran unlocked; + // wrap the binding registration. + std::lock_guard _ci_lk(index_bindings_mu()); + auto& m = index_bindings(); + auto& act = active_binding_for(); + bool table_has_active = act.find(t) != act.end(); + ADSHANDLE h = next_index_handle(); + if (!table_has_active) { + t->set_order(std::move(idx)); + m[h] = IndexBinding{t, tag, nullptr, file}; + act[t] = h; + } else { + openads::drivers::IIndex* raw = idx.get(); + m[h] = IndexBinding{t, tag, std::move(idx), file}; + t->register_extra_index_view(raw); + } + *phIndex = h; + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsDeleteIndex(ADSHANDLE hIndex) { + arc2_trace("AdsDeleteIndex"); + // OrdDestroy semantics (rddads DBOI_DESTROY): drop the tag from the + // CDX bag on disk, not just detach the handle -- the old close-only + // behaviour left the tag in the bag, so it reappeared on the next + // OrdListAdd / USE (dballcmp conformance harness). + { + std::lock_guard lk(index_bindings_mu()); + auto& m = index_bindings(); + auto it = m.find(hIndex); + if (it != m.end() && + path_ends_with_ci(it->second.path, ".cdx")) { + // Flush the in-memory tree before rewriting the struct leaf. + if (it->second.parked) (void)it->second.parked->flush(); + else if (it->second.table && it->second.table->order() && + it->second.table->order()->index()) + (void)it->second.table->order()->index()->flush(); + auto r = openads::drivers::cdx::CdxIndex::delete_tag( + it->second.path, it->second.tag_name); + if (!r) return fail(r.error()); + } + } + ForceIndexClose force_close; + return AdsCloseIndex(hIndex); +} + +// --- M9.20 custom-key indexes --------------------------------------------- +// +// rddads' DBOI_KEYADD / DBOI_KEYDELETE branches call AdsAddCustomKey +// / AdsDeleteCustomKey with just an index handle and expect the call +// to operate on the **current record**. Real ACE evaluates the +// index's expression against the positioned row and inserts (or +// erases) the resulting (key, recno) entry -- the "custom" wording +// comes from the surrounding `ADS_CUSTOM` flag on the index, which +// disables the engine's auto-sync so apps drive the index manually +// through these two entry points. +// +// OpenADS today doesn't separately track the `ADS_CUSTOM` flag, so +// these calls always evaluate + insert/erase. Apps that opt into +// custom mode get correct behaviour because they're the ones +// explicitly invoking these functions; expression-driven apps stay +// out of the call site. + +namespace { + +openads::drivers::IIndex* iindex_for_binding(IndexBinding& b) { + if (b.parked) return b.parked.get(); + if (auto* o = b.table ? b.table->order() : nullptr; o) { + return const_cast(o)->index(); + } + return nullptr; +} + +} // namespace + +UNSIGNED32 ENTRYPOINT AdsAddCustomKey(ADSHANDLE hIndex) { + arc2_trace("AdsAddCustomKey"); + auto& s = state(); + std::lock_guard lk(s.mu); + // Storm fix: reader must hold index_bindings_mu (binds are unlocked now). + std::lock_guard _ack_lk(index_bindings_mu()); + auto& m = index_bindings(); + auto it = m.find(hIndex); + if (it == m.end()) { + return fail(openads::AE_INTERNAL_ERROR, "unknown index handle"); + } + Table* t = it->second.table; + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + auto* idx = iindex_for_binding(it->second); + if (!idx) return fail(openads::AE_INTERNAL_ERROR, "no IIndex for binding"); + + std::uint16_t klen = idx->key_length(); + if (klen == 0) klen = 32; + std::string kb; + if (idx->key_encoding() == openads::drivers::KeyEncoding::FoxNumeric) { + double dv = 0.0; + if (!openads::engine::evaluate_index_expr_number( + *t, idx->expression(), dv)) + return fail(openads::AE_INTERNAL_ERROR, + "failed to evaluate numeric index expression"); + kb = openads::engine::fox_numeric_key(dv); + } else if (idx->key_encoding() == + openads::drivers::KeyEncoding::NtxNumeric) { + double dv = 0.0; + if (!openads::engine::evaluate_index_expr_number( + *t, idx->expression(), dv)) + return fail(openads::AE_INTERNAL_ERROR, + "failed to evaluate numeric index expression"); + kb = openads::engine::ntx_numeric_key(dv, idx->key_length(), + idx->key_decimals()); + } else { + auto k = openads::engine::evaluate_index_expr(*t, idx->expression(), klen); + if (!k) return fail(k.error()); + kb = std::move(k).value(); + } + auto r = idx->insert(t->recno(), kb); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsDeleteCustomKey(ADSHANDLE hIndex) { + arc2_trace("AdsDeleteCustomKey"); + auto& s = state(); + std::lock_guard lk(s.mu); + // Storm fix: reader must hold index_bindings_mu (binds are unlocked now). + std::lock_guard _dck_lk(index_bindings_mu()); + auto& m = index_bindings(); + auto it = m.find(hIndex); + if (it == m.end()) { + return fail(openads::AE_INTERNAL_ERROR, "unknown index handle"); + } + Table* t = it->second.table; + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + auto* idx = iindex_for_binding(it->second); + if (!idx) return fail(openads::AE_INTERNAL_ERROR, "no IIndex for binding"); + + std::uint16_t klen = idx->key_length(); + if (klen == 0) klen = 32; + std::string kb; + if (idx->key_encoding() == openads::drivers::KeyEncoding::FoxNumeric) { + double dv = 0.0; + if (!openads::engine::evaluate_index_expr_number( + *t, idx->expression(), dv)) + return fail(openads::AE_INTERNAL_ERROR, + "failed to evaluate numeric index expression"); + kb = openads::engine::fox_numeric_key(dv); + } else if (idx->key_encoding() == + openads::drivers::KeyEncoding::NtxNumeric) { + double dv = 0.0; + if (!openads::engine::evaluate_index_expr_number( + *t, idx->expression(), dv)) + return fail(openads::AE_INTERNAL_ERROR, + "failed to evaluate numeric index expression"); + kb = openads::engine::ntx_numeric_key(dv, idx->key_length(), + idx->key_decimals()); + } else { + auto k = openads::engine::evaluate_index_expr(*t, idx->expression(), klen); + if (!k) return fail(k.error()); + kb = std::move(k).value(); + } + auto r = idx->erase(t->recno(), kb); + if (!r) return fail(r.error()); + return ok(); +} + +// --- M9.19 Full-text search ------------------------------------------------ +// +// Creates an OpenADS-native `.fts` inverted-index file alongside the +// table. The format is plain UTF-8 text -- clean-room, NOT derived +// from any proprietary ADS FTS layout. Search support (token lookup +// at query time) is a follow-up milestone; today the create path +// gives apps a stable artefact to commit and visit. +// +// Most of the optional configuration knobs are honoured: min/max +// word length, custom delimiter / noise-word arrays. The page-size, +// drop-char, conditional-char, and reserved arguments are accepted +// (so the ABI shape matches rddads' ADSCREATEFTSINDEX call) and +// don't affect today's text emitter. + +} // extern "C" + +namespace { + +openads::engine::FtsOptions +build_fts_options(UNSIGNED32 ulMinWordLen, UNSIGNED32 ulMaxWordLen, + UNSIGNED16 usUseDefaultDelim, + const UNSIGNED8* pucDelimiters, + UNSIGNED16 usUseDefaultNoise, + const UNSIGNED8* pucNoiseWords) { + openads::engine::FtsOptions opts; + if (ulMinWordLen > 0) opts.min_word_len = ulMinWordLen; + if (ulMaxWordLen > 0) opts.max_word_len = ulMaxWordLen; + + if (!usUseDefaultDelim && pucDelimiters != nullptr) { + opts.extra_delims = openads::abi::to_internal( + const_cast(pucDelimiters), 0); + } + + if (usUseDefaultNoise) { + // Standard English stop-word seed; apps can override. + for (auto* w : {"a", "an", "the", "and", "or", "but", "if", + "of", "in", "on", "at", "to", "for", "is", + "are", "was", "were", "be", "by", "with", + "as", "from", "this", "that", "these", + "those", "it", "its", "not"}) { + opts.noise_words.insert(w); + } + } else if (pucNoiseWords != nullptr) { + auto raw = openads::abi::to_internal( + const_cast(pucNoiseWords), 0); + std::string cur; + for (char c : raw) { + if (c == ' ' || c == '\t' || c == ',' || c == ';' || c == '\n') { + if (!cur.empty()) { opts.noise_words.insert(cur); cur.clear(); } + } else { + cur.push_back(static_cast(std::tolower( + static_cast(c)))); + } + } + if (!cur.empty()) opts.noise_words.insert(cur); + } + return opts; +} + +} // namespace + +extern "C" { + +// --- M9.23 fill in remaining MISS exports --------------------------------- + +UNSIGNED32 ENTRYPOINT AdsGetLongLong(ADSHANDLE hTable, UNSIGNED8* pucField, + std::int64_t* pllValue) { + arc2_trace("AdsGetLongLong"); + if (pllValue == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + // SQL backend (e.g. postgresql): read text via the per-backend ops + // vtable then parse, instead of falling through to the native path. + if (auto* ops = openads::abi::backend_table_ops_for(hTable)) { + if (ops->get_field) { + UNSIGNED8 buf[64] = {0}; + UNSIGNED32 cap = sizeof(buf); + UNSIGNED32 rc = ops->get_field(hTable, pucField, buf, &cap, 0); + if (rc != 0) return rc; + std::string s(reinterpret_cast(buf), + std::min(cap, sizeof(buf))); + std::size_t j = 0; + while (j < s.size() && + std::isspace(static_cast(s[j]))) ++j; + *pllValue = static_cast( + std::strtoll(s.c_str() + j, nullptr, 10)); + return ok(); + } + } + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); + std::uint16_t idx = 0; + if (!resolve_field_index(t, pucField, &idx)) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + auto v = t->read_field(idx); + if (!v) return fail(v.error()); + auto& s = v.value().as_string; + // Strip leading whitespace; strtoll handles signed prefix and digits. + std::size_t i = 0; + while (i < s.size() && + std::isspace(static_cast(s[i]))) ++i; + *pllValue = static_cast( + std::strtoll(s.c_str() + i, nullptr, 10)); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsSetFieldRaw(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED8* pucBuf, UNSIGNED32 ulLen) { + arc2_trace("AdsSetFieldRaw"); + std::string raw; + if (pucBuf != nullptr && ulLen > 0) { + raw.assign(reinterpret_cast(pucBuf), ulLen); + } + if (auto* rt = get_remote_table(hTable)) { + if (pucField == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + auto i = remote_field_index(rt, pucField); + if (i == std::numeric_limits::max() || i >= rt->fields.size()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + std::string fname = rt->fields[i].name; + return remote_buffered_set(rt, fname, raw); + } + Table* t = get_table(hTable); + if (!t) { + return AdsSetString(hTable, pucField, pucBuf, ulLen); + } + std::uint16_t idx = 0; + if (!resolve_field_index(t, pucField, &idx)) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + auto r = t->set_field(idx, raw); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsVerifySQL(ADSHANDLE /*hStatement*/, UNSIGNED8* pucSQL) { + arc2_trace("AdsVerifySQL"); + if (pucSQL == nullptr) return fail(openads::AE_PARSE_ERROR, "null SQL"); + auto sql = openads::abi::to_internal(pucSQL, 0); + auto r = openads::sql::parse_select(sql); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsVerifySQLW(ADSHANDLE hStatement, UNSIGNED16* pwcSQL) { + arc2_trace("AdsVerifySQLW"); + if (pwcSQL == nullptr) return fail(openads::AE_PARSE_ERROR, "null SQL"); + std::string sql; + utf16z_to_utf8(pwcSQL, &sql); + std::vector bytes(sql.begin(), sql.end()); + bytes.push_back(0); + return AdsVerifySQL(hStatement, bytes.data()); +} + +UNSIGNED32 ENTRYPOINT AdsFailedTransactionRecovery(UNSIGNED8* pucServer) { + arc2_trace("AdsFailedTransactionRecovery"); + if (pucServer == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "null server path"); + } + auto path = openads::abi::to_internal(pucServer, 0); + // Recovery happens automatically on Connection::open -- the open + // path scans openads.txlog, replays orphan transactions' before- + // images, and truncates the log. Open + close gives the caller a + // single explicit recovery pass. + auto opened = Connection::open(path); + if (!opened) return fail(opened.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsGetAllLocks(ADSHANDLE hTable, UNSIGNED32* paRecnos, + UNSIGNED16* pusCount) { + arc2_trace("AdsGetAllLocks"); + if (auto* rt = get_remote_table(hTable)) { + // The client lock ledger is complete unless an append + // auto-lock or an unresolvable current-record lock made it + // uncertain (and a table lock shadows the record list, so + // that case stays on the wire too). rddads polls this after + // every commit -- answering locally saves 1 RTT each time. + if (pusCount != nullptr && !rt->locks_uncertain && + !rt->table_lock_held) { + cli_trace_tbl(rt, "AdsGetAllLocks", + "served from client lock ledger"); + const UNSIGNED16 lcap = *pusCount; + UNSIGNED16 li = 0; + if (paRecnos != nullptr) { + for (std::uint32_t lrn : rt->held_recs) { + if (li >= lcap) break; + paRecnos[li++] = lrn; + } + } + *pusCount = static_cast(rt->held_recs.size()); + return ok(); + } + // M12.36 — remote record locks are server-managed; the + // GetAllLocks wire opcode returns this connection's held list. + if (pusCount == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + auto r = rt->conn->get_all_locks(rt->id); + if (!r) return fail(r.error()); + const auto& held = r.value(); + UNSIGNED16 cap = *pusCount; + UNSIGNED16 n = static_cast( + std::min(held.size(), cap)); + if (paRecnos != nullptr && n > 0) { + for (UNSIGNED16 i = 0; i < n; ++i) { + paRecnos[i] = held[i]; + } + } + *pusCount = static_cast(held.size()); + return ok(); + } + Table* t = get_table(hTable); + if (!t || pusCount == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + auto held = t->held_record_locks(); + UNSIGNED16 cap = *pusCount; + UNSIGNED16 n = static_cast( + std::min(held.size(), cap)); + if (paRecnos != nullptr && n > 0) { + for (UNSIGNED16 i = 0; i < n; ++i) { + paRecnos[i] = static_cast(held[i]); + } + } + *pusCount = static_cast(held.size()); + return ok(); +} + +// M12.16c -- switch the active order on `hTable` to the binding +// whose tag matches `pucName` (case-insensitive). Mirrors the +// rddads adsOrdSetActive(cTagName) flow. Empty / NULL name flips +// the table back to natural-record-order (clear active binding). +UNSIGNED32 ENTRYPOINT AdsSetIndexOrder(ADSHANDLE hTable, UNSIGNED8* pucName) { + arc2_trace("AdsSetIndexOrder"); + if (auto* rt = get_remote_table(hTable)) { + // Sets + teardown flush here, but NOT a deferred order switch: + // a new switch overwrites the pending one (unsent requests have + // no observable effect), so flushing first would just add a frame + // for a binding nobody will ever navigate in. + if (UNSIGNED32 frc = remote_flush_sets(rt); frc != 0) return frc; + if (UNSIGNED32 frc = remote_flush_teardown(rt); frc != 0) return frc; + std::string name = pucName + ? openads::abi::to_internal(pucName, 0) : std::string(); + // Resolve the target locally (case-insensitive, like the mirror + // block below). Unmapped names stay kOrderUnknown and always go + // out on the wire: the server resolves those, and a wrong local + // guess is the "remote browse shows no index" bug. + auto resolve_want = [&]() { + if (name.empty()) return std::uint32_t{0}; + auto match_in = [&](const std::vector< + std::pair>& m) { + for (auto& [tag, wid] : m) { + if (tag.size() != name.size()) continue; + bool eq = true; + for (std::size_t i = 0; i < tag.size(); ++i) { + if (std::toupper( + static_cast(tag[i])) != + std::toupper( + static_cast(name[i]))) { + eq = false; + break; + } + } + if (eq) return wid; + } + return openads::network::RemoteTable::kOrderUnknown; + }; + const std::uint32_t live = match_in(rt->index_by_tag); + if (live != openads::network::RemoteTable::kOrderUnknown) { + return live; + } + // Parked fallback: the snapshot's server ids are live + // (the park kept the bindings open), so a tag switch + // while parked defers normally instead of paying a wire + // by-name frame for a binding we already hold. + if (rt->indexes_parked) return match_in(rt->parked_by_tag); + return openads::network::RemoteTable::kOrderUnknown; + }; + // Skip a redundant switch: SetOrder never moves the cursor, so + // when the ack-confirmed server binding already equals the + // target the frame would change nothing observable. (Vouch + // re-sets the same order on every USE; pooled re-USEs keep the + // binding server-side.) + { + const std::uint32_t want = resolve_want(); + if (want != openads::network::RemoteTable::kOrderUnknown && + rt->server_order_id == want) { + rt->active_index_id = want; + return ok(); + } + // Defer: a following GotoTop/Bottom absorbs this into its + // fused frame; anything else flushes it plainly first. + // Overwrites any earlier pending switch (unsent requests + // have no observable effect). Belief updates now; position + // is retained but order-relative caches must go. + if (want != openads::network::RemoteTable::kOrderUnknown) { + rt->active_index_id = want; + rt->pending_order = true; + rt->pending_order_id = want; + rt->keyno_valid = false; + rt->key_count_cached = false; + rt->invalidate_prefetch(); + return ok(); + } + } + // Unmapped: legacy immediate wire path (errors surface now). + // Any pending switch is superseded by this explicit request. + rt->pending_order = false; + auto r = rt->conn->set_order_by_name(rt->id, name); + if (!r) return fail(r.error()); + // RCB 07/14/2026: BUG FIX -- the controlling order just changed, so the + // cached row and every queued lookahead row were read in the OLD order + // and cannot be served against the new one. Same family as the AdsSeek + // stale-queue bug: without this, the first skip after an OrdSetFocus + // serves a row from the previous ordering, locally, with no wire + // traffic to make it look suspicious. + rt->row_valid = false; + rt->invalidate_prefetch(); + rt->key_count_cached = false; + // RCB 07/14/2026: the server now orders by whatever `name` resolved to + // on ITS side. Mirror that into server_order_id when we can map the tag + // locally; when we can't, deliberately leave it "unknown" so the next + // remote_activate_index re-sends SetOrder rather than trusting a guess. + // A wrong guess here is the "remote browse shows no index" bug. + rt->server_order_id = openads::network::RemoteTable::kOrderUnknown; + if (name.empty()) { + rt->active_index_id = 0; + rt->server_order_id = 0; // natural record order + rt->keyno_valid = false; + } else { + for (auto& [tag, wid] : rt->index_by_tag) { + if (tag.size() == name.size()) { + bool eq = true; + for (std::size_t i = 0; i < tag.size(); ++i) { + if (std::toupper(static_cast(tag[i])) != + std::toupper(static_cast(name[i]))) { + eq = false; + break; + } + } + if (eq) { + rt->active_index_id = wid; + rt->server_order_id = wid; + rt->keyno_valid = false; + break; + } + } + } + } + return ok(); + } + if (is_sql_table_handle(hTable)) { + std::string name = pucName + ? openads::abi::to_internal(pucName, 0) : std::string(); + if (name.empty()) { + sql_active_index_handle().erase(hTable); + return ok(); + } + auto upper_eq = [](const std::string& a, const std::string& b) { + if (a.size() != b.size()) return false; + for (std::size_t i = 0; i < a.size(); ++i) { + if (std::toupper(static_cast(a[i])) != + std::toupper(static_cast(b[i]))) { + return false; + } + } + return true; + }; +#if defined(OPENADS_WITH_SQLITE) + for (auto& [ih, si] : sqlite_indexes_map()) { + if (si->parent == get_sqlite_table(hTable) && + upper_eq(si->column, name)) { + sql_active_index_handle()[hTable] = static_cast(ih); + return ok(); + } + } +#endif +#if defined(OPENADS_WITH_MARIADB) + for (auto& [ih, si] : maria_indexes_map()) { + if (si->parent == get_maria_table(hTable) && + upper_eq(si->column, name)) { + sql_active_index_handle()[hTable] = static_cast(ih); + return ok(); + } + } +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + for (auto& [ih, si] : postgres_indexes_map()) { + if (si->parent == get_postgres_table(hTable) && + upper_eq(si->column, name)) { + sql_active_index_handle()[hTable] = static_cast(ih); + return ok(); + } + } +#endif +#if defined(OPENADS_WITH_ODBC) + for (auto& [ih, si] : odbc_indexes_map()) { + if (si->parent == get_odbc_table(hTable) && + upper_eq(si->column, name)) { + sql_active_index_handle()[hTable] = static_cast(ih); + return ok(); + } + } +#endif +#if defined(OPENADS_WITH_FIREBIRD) + for (auto& [ih, si] : firebird_indexes_map()) { + if (si->parent == get_firebird_table(hTable) && + upper_eq(si->column, name)) { + sql_active_index_handle()[hTable] = static_cast(ih); + return ok(); + } + } +#endif +#if defined(OPENADS_WITH_MSSQL) + for (auto& [ih, si] : mssql_indexes_map()) { + if (si->parent == get_mssql_table(hTable) && + upper_eq(si->column, name)) { + sql_active_index_handle()[hTable] = static_cast(ih); + return ok(); + } + } +#endif + return fail(openads::AE_INTERNAL_ERROR, + ("AdsSetIndexOrder: no tag '" + name + "' on table").c_str()); + } + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + std::string name = pucName + ? openads::abi::to_internal(pucName, 0) : std::string(); + if (name.empty()) { + // Empty tag = natural order. Park the active binding back + // into its slot so a subsequent AdsSetIndexOrder picks the + // current Table::order_ up cleanly. + park_active_order(t); + return ok(); + } + auto upper_eq = [](const std::string& a, const std::string& b) { + if (a.size() != b.size()) return false; + for (std::size_t i = 0; i < a.size(); ++i) { + char ca = static_cast(std::toupper( + static_cast(a[i]))); + char cb = static_cast(std::toupper( + static_cast(b[i]))); + if (ca != cb) return false; + } + return true; + }; + auto& m = index_bindings(); + // Storm fix: reader must hold index_bindings_mu (binds are unlocked now). + std::lock_guard _so_lk(index_bindings_mu()); + for (auto& [h, b] : m) { + if (b.table == t && upper_eq(b.tag_name, name)) { + auto r = activate_binding(h); + if (!r) return fail(r.error()); + return ok(); + } + } + return fail(openads::AE_INTERNAL_ERROR, + ("AdsSetIndexOrder: no tag '" + name + "' on table").c_str()); +} + +UNSIGNED32 ENTRYPOINT AdsSetIndexOrderByHandle(ADSHANDLE hTable, ADSHANDLE hIndex) { + arc2_trace("AdsSetIndexOrderByHandle"); + if (auto* rt = get_remote_table(hTable)) { + // Sets + teardown flush here, but NOT a deferred order switch + // (overwritten below — see ByName). + if (UNSIGNED32 frc = remote_flush_sets(rt); frc != 0) return frc; + if (UNSIGNED32 frc = remote_flush_teardown(rt); frc != 0) return frc; + if (hIndex == 0) { + // "Back to natural order" via the explicit API: send the reset + // frame so the server drops its ordered_tables_ entry (it used + // to send NO frame, and table-handle Skips kept walking the + // old index order). Skip only when the server is ack-confirmed + // natural already — the frame would change nothing (SetOrder + // never moves the cursor, so position is untouched either way). + if (rt->server_order_id == 0) { + rt->active_index_id = 0; + return ok(); + } + // Defer: a following GotoTop/Bottom absorbs this into its + // fused frame; anything else flushes it plainly first. + // Belief updates now (active_index_id); the ack-confirmed + // server_order_id only when a frame actually goes out. + // Position is retained, but order-relative caches must go: + // the keyno/count describe the OLD order from here on. + rt->active_index_id = 0; + rt->pending_order = true; + rt->pending_order_id = 0; + rt->keyno_valid = false; + rt->key_count_cached = false; + rt->invalidate_prefetch(); + return ok(); + } + if (auto* ri = get_remote_index(hIndex)) { + // Same-order repeat: the server binding is already correct + // and SetOrder moves no cursor — skip the frame, sync the + // client belief only. Caches stay: nothing observable changed. + if (rt->server_order_id == ri->id) { + rt->active_index_id = ri->id; + return ok(); + } + // Defer (see above). + rt->active_index_id = ri->id; + rt->pending_order = true; + rt->pending_order_id = ri->id; + rt->keyno_valid = false; + rt->key_count_cached = false; + rt->invalidate_prefetch(); + return ok(); + } + return fail(openads::AE_INTERNAL_ERROR, + "AdsSetIndexOrderByHandle: hIndex is not a remote index"); + } + if (is_sql_table_handle(hTable)) { + bool ok_index = false; +#if defined(OPENADS_WITH_SQLITE) + ok_index = ok_index || get_sqlite_index(hIndex) != nullptr; +#endif +#if defined(OPENADS_WITH_MARIADB) + ok_index = ok_index || get_maria_index(hIndex) != nullptr; +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + ok_index = ok_index || get_postgres_index(hIndex) != nullptr; +#endif +#if defined(OPENADS_WITH_ODBC) + ok_index = ok_index || get_odbc_index(hIndex) != nullptr; +#endif +#if defined(OPENADS_WITH_FIREBIRD) + ok_index = ok_index || get_firebird_index(hIndex) != nullptr; +#endif +#if defined(OPENADS_WITH_MSSQL) + ok_index = ok_index || get_mssql_index(hIndex) != nullptr; +#endif + if (ok_index) { + sql_active_index_handle()[hTable] = hIndex; + return ok(); + } + return fail(openads::AE_INTERNAL_ERROR, + "AdsSetIndexOrderByHandle: index not bound to table"); + } + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + if (hIndex == 0) { + // Natural order (rddads' patched DbSetOrder(0) calls this): park + // the active order back into its binding slot. + park_active_order(t); + return ok(); + } + auto& m = index_bindings(); + // Storm fix: reader must hold index_bindings_mu (binds are unlocked now). + std::lock_guard _sob_lk(index_bindings_mu()); + auto it = m.find(hIndex); + if (it == m.end() || it->second.table != t) { + return fail(openads::AE_INTERNAL_ERROR, + "AdsSetIndexOrderByHandle: index not bound to table"); + } + auto r = activate_binding(hIndex); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsSkipUnique(ADSHANDLE hIndex, SIGNED32 lDirection) { + arc2_trace("AdsSkipUnique"); + if (auto* ri = get_remote_index(hIndex)) { + if (ri->parent) { + if (UNSIGNED32 frc = remote_flush_pending(ri->parent); frc != 0) return frc; + ri->parent->row_valid = false; // M12.17 + } + auto r = ri->conn->skip_unique(ri->id, lDirection); + if (!r) return fail(r.error()); + return ok(); + } + auto& s = state(); + std::lock_guard lk(s.mu); + auto* idx = iindex_for_handle(hIndex); + Table* t = lookup_table_by_index(hIndex); + if (!idx || !t) return fail(openads::AE_INTERNAL_ERROR, "unknown index"); + (void)activate_binding(hIndex); + + std::string start_key = idx->current_key(); + auto step = [&]() { + return lDirection < 0 ? idx->prev() : idx->next(); + }; + + for (;;) { + auto r = step(); + if (!r) return fail(r.error()); + if (!r.value().positioned) { + return fail(openads::AE_INTERNAL_ERROR, "no more unique keys"); + } + if (idx->current_key() != start_key) { + // Position the table on the new recno. + (void)t->goto_record(r.value().recno); + return ok(); + } + } +} + +// AdsFTSSearch is an OpenADS extension. The original ACE SDK doesn't +// export an entry point with this exact shape that rddads' Harbour +// surface ever reached (rddads is silent on FTS query semantics), so +// OpenADS publishes a small clean-room API: load the .fts file at +// `pucFile`, tokenise the query with the standard rules, intersect +// the per-token recno lists, and write up to `*pulCount` recnos into +// `paRecnos`. `*pulCount` is treated as in/out -- the caller passes +// the array capacity and reads back the total number of matches +// (which may be larger than the buffer). +UNSIGNED32 ENTRYPOINT AdsFTSSearch(ADSHANDLE /*hConnect*/, + UNSIGNED8* pucFile, + UNSIGNED8* pucQuery, + UNSIGNED32* paRecnos, + UNSIGNED32* pulCount) { + arc2_trace("AdsFTSSearch"); + if (pucFile == nullptr || pucQuery == nullptr || pulCount == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "AdsFTSSearch: null arg"); + } + auto path = openads::abi::to_internal(pucFile, 0); + auto query = openads::abi::to_internal(pucQuery, 0); + + auto loaded = openads::engine::Fts::load(path); + if (!loaded) return fail(loaded.error()); + + openads::engine::FtsOptions opts; + auto hits = openads::engine::Fts::search(loaded.value(), query, opts); + + UNSIGNED32 cap = *pulCount; + UNSIGNED32 n = static_cast( + std::min(hits.size(), cap)); + if (paRecnos != nullptr && n > 0) { + std::memcpy(paRecnos, hits.data(), n * sizeof(UNSIGNED32)); + } + *pulCount = static_cast(hits.size()); + return ok(); +} + +// --- M10.1 Data-Dictionary CRUD -------------------------------------------- +// +// Real persistence in OpenADS' clean-room DD text format. When the +// caller's connection has no DD attached (i.e. the connection was +// opened against a plain data directory, not a `.add` file), the +// CRUD calls report AE_SUCCESS and no-op -- matching the "everything +// quiescent" contract used for AdsMg* in M9.24. Apps that opened +// the DD via `Connection::open(<.add>)` (M6) get round-trip +// persistence. + +namespace { + +Connection* conn_from_handle(ADSHANDLE hConn) { + auto& s = state(); + return s.registry.lookup(hConn, HandleKind::Connection); +} + +openads::engine::DataDict* dd_from_handle(ADSHANDLE hConn) { + Connection* c = conn_from_handle(hConn); + if (c == nullptr || !c->has_dd()) return nullptr; + return c->dd(); +} + +} // namespace + +UNSIGNED32 ENTRYPOINT AdsDDAddIndexFile(ADSHANDLE hConn, + UNSIGNED8* pucTable, UNSIGNED8* pucIndex, + UNSIGNED8* pucComment) { + arc2_trace("AdsDDAddIndexFile"); + if (auto* rc = get_remote_connection(hConn)) { + auto r = rc->dd_add_index_file( + openads::abi::to_internal(pucTable, 0), + openads::abi::to_internal(pucIndex, 0), + pucComment ? openads::abi::to_internal(pucComment, 0) : ""); + if (!r) return fail(r.error()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + if (dd == nullptr) return ok(); + auto tbl = openads::abi::to_internal(pucTable, 0); + auto idx = openads::abi::to_internal(pucIndex, 0); + auto cmt = pucComment ? openads::abi::to_internal(pucComment, 0) + : std::string(); + auto r = dd->add_index_file(tbl, idx, cmt); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsDDRemoveIndexFile(ADSHANDLE hConn, + UNSIGNED8* pucTable, UNSIGNED8* pucIndex, + UNSIGNED16 /*opt*/) { + arc2_trace("AdsDDRemoveIndexFile"); + if (auto* rc = get_remote_connection(hConn)) { + auto r = rc->dd_remove_index_file( + openads::abi::to_internal(pucTable, 0), + openads::abi::to_internal(pucIndex, 0)); + if (!r) return fail(r.error()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + if (dd == nullptr) return ok(); + auto tbl = openads::abi::to_internal(pucTable, 0); + auto idx = openads::abi::to_internal(pucIndex, 0); + auto r = dd->remove_index_file(tbl, idx); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsDDCreateUser(ADSHANDLE hConn, UNSIGNED8* pucGroup, + UNSIGNED8* pucUser, UNSIGNED8* pucPwd, + UNSIGNED8* pucDesc) { + arc2_trace("AdsDDCreateUser"); + if (auto* rc = get_remote_connection(hConn)) { + auto r = rc->dd_create_user( + pucGroup ? openads::abi::to_internal(pucGroup, 0) : "", + openads::abi::to_internal(pucUser, 0), + pucPwd ? openads::abi::to_internal(pucPwd, 0) : "", + pucDesc ? openads::abi::to_internal(pucDesc, 0) : ""); + if (!r) return fail(r.error()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + if (dd == nullptr) return ok(); + auto user = openads::abi::to_internal(pucUser, 0); + auto r = dd->create_user(user); + if (!r) return fail(r.error()); + if (pucPwd && pucPwd[0] != '\0') { + auto pwd = openads::abi::to_internal(pucPwd, 0); + dd->set_user_property(user, "prop_1101", pwd); + } + if (pucDesc && pucDesc[0] != '\0') { + auto desc = openads::abi::to_internal(pucDesc, 0); + dd->set_user_property(user, "prop_1", desc); + } + if (pucGroup && pucGroup[0] != '\0') { + auto grp = openads::abi::to_internal(pucGroup, 0); + dd->add_user_to_group(user, grp); + } + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsDDDeleteUser(ADSHANDLE hConn, UNSIGNED8* pucUser) { + arc2_trace("AdsDDDeleteUser"); + if (auto* rc = get_remote_connection(hConn)) { + auto r = rc->dd_drop_object(openads::network::DDObjectKind::User, + openads::abi::to_internal(pucUser, 0)); + if (!r) return fail(r.error()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + if (dd == nullptr) return ok(); + auto user = openads::abi::to_internal(pucUser, 0); + auto r = dd->delete_user(user); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsDDAddUserToGroup(ADSHANDLE hConn, + UNSIGNED8* pucGroup, UNSIGNED8* pucUser) { + arc2_trace("AdsDDAddUserToGroup"); + if (auto* rc = get_remote_connection(hConn)) { + auto r = rc->dd_add_user_to_group( + openads::abi::to_internal(pucGroup, 0), + openads::abi::to_internal(pucUser, 0)); + if (!r) return fail(r.error()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + if (dd == nullptr) return ok(); + auto group = openads::abi::to_internal(pucGroup, 0); + auto user = openads::abi::to_internal(pucUser, 0); + auto r = dd->add_user_to_group(user, group); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsDDRemoveUserFromGroup(ADSHANDLE hConn, + UNSIGNED8* pucGroup, UNSIGNED8* pucUser) { + arc2_trace("AdsDDRemoveUserFromGroup"); + if (auto* rc = get_remote_connection(hConn)) { + auto r = rc->dd_remove_user_from_group( + openads::abi::to_internal(pucGroup, 0), + openads::abi::to_internal(pucUser, 0)); + if (!r) return fail(r.error()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + if (dd == nullptr) return ok(); + auto group = openads::abi::to_internal(pucGroup, 0); + auto user = openads::abi::to_internal(pucUser, 0); + auto r = dd->remove_user_from_group(user, group); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsDDCreateLink(ADSHANDLE hConn, UNSIGNED8* pucAlias, + UNSIGNED8* pucPath, UNSIGNED8* pucUser, + UNSIGNED8* pucPwd, UNSIGNED16 /*opt*/) { + arc2_trace("AdsDDCreateLink"); + if (auto* rc = get_remote_connection(hConn)) { + auto r = rc->dd_create_link( + openads::abi::to_internal(pucAlias, 0), + openads::abi::to_internal(pucPath, 0), + pucUser ? openads::abi::to_internal(pucUser, 0) : "", + pucPwd ? openads::abi::to_internal(pucPwd, 0) : ""); + if (!r) return fail(r.error()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + if (dd == nullptr) return ok(); + auto alias = openads::abi::to_internal(pucAlias, 0); + auto path = openads::abi::to_internal(pucPath, 0); + auto user = pucUser ? openads::abi::to_internal(pucUser, 0) : std::string(); + auto pwd = pucPwd ? openads::abi::to_internal(pucPwd, 0) : std::string(); + auto r = dd->create_link(alias, path, user, pwd); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsDDDropLink(ADSHANDLE hConn, UNSIGNED8* pucAlias, + UNSIGNED16 /*opt*/) { + arc2_trace("AdsDDDropLink"); + if (auto* rc = get_remote_connection(hConn)) { + auto r = rc->dd_drop_link(openads::abi::to_internal(pucAlias, 0)); + if (!r) return fail(r.error()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + if (dd == nullptr) return ok(); + auto alias = openads::abi::to_internal(pucAlias, 0); + auto r = dd->drop_link(alias); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsDDModifyLink(ADSHANDLE hConn, UNSIGNED8* pucAlias, + UNSIGNED8* pucPath, UNSIGNED8* pucUser, + UNSIGNED8* pucPwd, UNSIGNED16 /*opt*/) { + arc2_trace("AdsDDModifyLink"); + if (auto* rc = get_remote_connection(hConn)) { + auto r = rc->dd_modify_link( + openads::abi::to_internal(pucAlias, 0), + pucPath ? openads::abi::to_internal(pucPath, 0) : "", + pucUser ? openads::abi::to_internal(pucUser, 0) : "", + pucPwd ? openads::abi::to_internal(pucPwd, 0) : ""); + if (!r) return fail(r.error()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + if (dd == nullptr) return ok(); + auto alias = openads::abi::to_internal(pucAlias, 0); + auto path = pucPath ? openads::abi::to_internal(pucPath, 0) : std::string(); + auto user = pucUser ? openads::abi::to_internal(pucUser, 0) : std::string(); + auto pwd = pucPwd ? openads::abi::to_internal(pucPwd, 0) : std::string(); + auto r = dd->modify_link(alias, path, user, pwd); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsDDCreateRefIntegrity(ADSHANDLE hConn, + UNSIGNED8* pucName, UNSIGNED8* pucFail, + UNSIGNED8* pucParent, UNSIGNED8* pucParentTag, + UNSIGNED8* pucChild, UNSIGNED8* pucChildTag, + UNSIGNED16 usUpdate, UNSIGNED16 usDelete) { + arc2_trace("AdsDDCreateRefIntegrity"); + if (auto* rc = get_remote_connection(hConn)) { + auto r = rc->dd_create_ref_integrity( + openads::abi::to_internal(pucName, 0), + pucFail ? openads::abi::to_internal(pucFail, 0) : "", + pucParent ? openads::abi::to_internal(pucParent, 0) : "", + pucParentTag ? openads::abi::to_internal(pucParentTag, 0) : "", + pucChild ? openads::abi::to_internal(pucChild, 0) : "", + pucChildTag ? openads::abi::to_internal(pucChildTag, 0) : "", + usUpdate, usDelete); + if (!r) return fail(r.error()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + if (dd == nullptr) return ok(); + openads::engine::DataDict::RiEntry e; + e.name = openads::abi::to_internal(pucName, 0); + e.parent = pucParent ? openads::abi::to_internal(pucParent, 0) : std::string(); + e.child = pucChild ? openads::abi::to_internal(pucChild, 0) : std::string(); + e.parent_tag = pucParentTag ? openads::abi::to_internal(pucParentTag, 0) : std::string(); + e.child_tag = pucChildTag ? openads::abi::to_internal(pucChildTag, 0) : std::string(); + e.update_opt = std::to_string(static_cast(usUpdate)); + e.delete_opt = std::to_string(static_cast(usDelete)); + e.fail_table = pucFail ? openads::abi::to_internal(pucFail, 0) : std::string(); + auto r = dd->create_ri(e); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsDDRemoveRefIntegrity(ADSHANDLE hConn, UNSIGNED8* pucName) { + arc2_trace("AdsDDRemoveRefIntegrity"); + if (auto* rc = get_remote_connection(hConn)) { + auto r = rc->dd_drop_object(openads::network::DDObjectKind::RefIntegrity, + openads::abi::to_internal(pucName, 0)); + if (!r) return fail(r.error()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + if (dd == nullptr) return ok(); + auto name = openads::abi::to_internal(pucName, 0); + auto r = dd->remove_ri(name); + if (!r) return fail(r.error()); + return ok(); +} + +// SAP database-property id (ABI) → the legacy "prop_N" storage key that +// existing OpenADS DDs persist (SP_MODIFYDATABASE numbering; the +// AdsConnect60 login checks read prop_5 / prop_16, DA-Web db_props.php +// reads the same keys). The storage keys are deliberately STABLE -- only +// the public ABI numbering moved to SAP's. Ids without a mapping fall +// back to a raw prop_ passthrough, which also keeps legacy callers +// that passed the storage numbers directly working (their old ids and +// keys coincide). +static const char* db_prop_storage_key(UNSIGNED16 usProp) { + switch (usProp) { + case ADS_DD_COMMENT: return "prop_1"; + case ADS_DD_USER_DEFINED_PROP: return "prop_26"; + case ADS_DD_DEFAULT_TABLE_PATH: return "prop_3"; + case ADS_DD_TEMP_TABLE_PATH: return "prop_12"; + case ADS_DD_LOG_IN_REQUIRED: return "prop_5"; + case ADS_DD_VERIFY_ACCESS_RIGHTS: return "prop_8"; + case ADS_DD_ENCRYPT_TABLE_PASSWORD: return "prop_13"; + case ADS_DD_ENCRYPT_NEW_TABLE: return "prop_10"; + case ADS_DD_ENABLE_INTERNET: return "prop_6"; + case ADS_DD_INTERNET_SECURITY_LEVEL: return "prop_7"; + case ADS_DD_MAX_FAILED_ATTEMPTS: return "prop_11"; + case ADS_DD_ALLOW_ADSSYS_NET_ACCESS: return "prop_24"; + case ADS_DD_VERSION_MAJOR: return "prop_14"; + case ADS_DD_VERSION_MINOR: return "prop_15"; + case ADS_DD_LOGINS_DISABLED: return "prop_16"; + case ADS_DD_LOGINS_DISABLED_ERRSTR: return "prop_17"; + case ADS_DD_FTS_DELIMITERS: return "prop_18"; + case ADS_DD_FTS_NOISE: return "prop_19"; + case ADS_DD_FTS_DROP_CHARS: return "prop_20"; + case ADS_DD_FTS_CONDITIONAL_CHARS: return "prop_21"; + case ADS_DD_ENCRYPTED: return "prop_22"; + case ADS_DD_ENCRYPT_INDEXES: return "prop_23"; + case ADS_DD_ENCRYPT_COMMUNICATION: return "prop_25"; + default: return nullptr; + } +} + +UNSIGNED32 ENTRYPOINT AdsDDSetDatabaseProperty(ADSHANDLE hConn, UNSIGNED16 usProp, + void* pBuf, UNSIGNED16 usLen) { + arc2_trace("AdsDDSetDatabaseProperty"); + if (auto* rc = get_remote_connection(hConn)) { + std::string val = (pBuf != nullptr && usLen > 0) + ? std::string(reinterpret_cast(pBuf), usLen) : std::string(); + auto r = rc->dd_set_property(openads::network::DDObjectKind::Database, + "", "", usProp, val); + if (!r) return fail(r.error()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + if (dd == nullptr) return ok(); + std::string val; + if (pBuf != nullptr && usLen > 0) { + val.assign(reinterpret_cast(pBuf), usLen); + } + // ADS_DD_ADMIN_PASSWORD sets the adssys account password (same as + // sp_ModifyDatabase ADMIN_PASSWORD); ADS_DD_VERSION is the DD format + // version and is not writable. + if (usProp == ADS_DD_ADMIN_PASSWORD) { + auto r = dd->set_user_property("adssys", "prop_1101", val); + if (!r) return fail(r.error()); + return ok(); + } + if (usProp == ADS_DD_VERSION) + return fail(openads::AE_FUNCTION_NOT_AVAILABLE, "read-only DD prop"); + const char* mapped = db_prop_storage_key(usProp); + std::string key = mapped != nullptr + ? std::string(mapped) + : "prop_" + std::to_string(static_cast(usProp)); + auto r = dd->set_db_property(key, val); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsDDGetDatabaseProperty(ADSHANDLE hConn, UNSIGNED16 usProp, + void* pBuf, UNSIGNED16* pusLen) { + arc2_trace("AdsDDGetDatabaseProperty"); + if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (auto* rc = get_remote_connection(hConn)) { + auto r = rc->dd_get_property(openads::network::DDObjectKind::Database, + "", "", usProp); + UNSIGNED16 cap = *pusLen; + if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); + if (!r) { *pusLen = 0; return fail(r.error()); } + UNSIGNED16 n = static_cast(std::min(r.value().size(), cap)); + if (pBuf != nullptr && n > 0) std::memcpy(pBuf, r.value().data(), n); + *pusLen = static_cast(r.value().size()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + UNSIGNED16 cap = *pusLen; + if (pBuf != nullptr && cap > 0) { + std::memset(pBuf, 0, cap); + } + if (dd == nullptr) { *pusLen = 0; return ok(); } + // ADMIN_PASSWORD is write-only in the SAP ABI; VERSION is the DD + // format version, which OpenADS does not track per-DD. + if (usProp == ADS_DD_ADMIN_PASSWORD) { + *pusLen = 0; + return fail(openads::AE_INVALID_PROPERTY_ID, "write-only DD prop"); + } + if (usProp == ADS_DD_VERSION) { + *pusLen = 0; + return fail(openads::AE_PROPERTY_NOT_SET, ""); + } + const char* mapped = db_prop_storage_key(usProp); + std::string key = mapped != nullptr + ? std::string(mapped) + : "prop_" + std::to_string(static_cast(usProp)); + auto val = dd->get_db_property(key); + UNSIGNED16 n = static_cast( + std::min(val.size(), cap)); + if (pBuf != nullptr && n > 0) std::memcpy(pBuf, val.data(), n); + *pusLen = static_cast(val.size()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsDDGetUserProperty(ADSHANDLE hConn, UNSIGNED8* pucUser, + UNSIGNED16 usProp, void* pBuf, + UNSIGNED16* pusLen) { + arc2_trace("AdsDDGetUserProperty"); + if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (auto* rc = get_remote_connection(hConn)) { + auto uname = openads::abi::to_internal(pucUser, 0); + auto r = rc->dd_get_property(openads::network::DDObjectKind::User, + uname, "", usProp); + UNSIGNED16 cap = *pusLen; + if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); + if (!r) { *pusLen = 0; return fail(r.error()); } + UNSIGNED16 n = static_cast(std::min(r.value().size(), cap)); + if (pBuf != nullptr && n > 0) std::memcpy(pBuf, r.value().data(), n); + *pusLen = static_cast(r.value().size()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + UNSIGNED16 cap = *pusLen; + if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); + if (dd == nullptr) { *pusLen = 0; return ok(); } + auto user = openads::abi::to_internal(pucUser, 0); + + // ADS_DD_USER_BAD_LOGINS (1103) -- always 0, returned as uint16. + if (usProp == 1103) { + UNSIGNED16 zero = 0; + UNSIGNED16 n = std::min(cap, sizeof(UNSIGNED16)); + if (pBuf && n > 0) std::memcpy(pBuf, &zero, n); + *pusLen = sizeof(UNSIGNED16); + return ok(); + } + // ADS_DD_USER_GROUP_MEMBERSHIP (1102) -- comma-separated group list. + if (usProp == 1102) { + std::string groups; + for (const auto& g : dd->groups_of(user)) { + if (!groups.empty()) groups += ','; + groups += g; + } + UNSIGNED16 n = static_cast( + std::min(groups.size(), cap)); + if (pBuf && n > 0) std::memcpy(pBuf, groups.data(), n); + *pusLen = static_cast(groups.size()); + return ok(); + } + + std::string key = "prop_" + std::to_string(static_cast(usProp)); + auto val = dd->get_user_property(user, key); + UNSIGNED16 n = static_cast( + std::min(val.size(), cap)); + if (pBuf != nullptr && n > 0) std::memcpy(pBuf, val.data(), n); + *pusLen = static_cast(val.size()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsDDSetUserProperty(ADSHANDLE hConn, UNSIGNED8* pucUser, + UNSIGNED16 usProp, void* pvBuf, + UNSIGNED16 usLen) { + arc2_trace("AdsDDSetUserProperty"); + if (auto* rc = get_remote_connection(hConn)) { + auto uname = openads::abi::to_internal(pucUser, 0); + std::string val = (pvBuf != nullptr && usLen > 0) + ? std::string(reinterpret_cast(pvBuf), usLen) : std::string(); + auto r = rc->dd_set_property(openads::network::DDObjectKind::User, + uname, "", usProp, val); + if (!r) return fail(r.error()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + if (dd == nullptr) return ok(); + auto user = openads::abi::to_internal(pucUser, 0); + if (!dd->has_user(user)) + return fail(static_cast(openads::AE_TABLE_NOT_FOUND), user.c_str()); + + // ADS_DD_USER_BAD_LOGINS (1103) -- read-only counter, silently ignore sets. + if (usProp == 1103) return ok(); + + // ADS_DD_USER_GROUP_MEMBERSHIP (1102) -- add user to the named group. + if (usProp == 1102) { + if (pvBuf == nullptr || usLen == 0) return ok(); + std::string grp(reinterpret_cast(pvBuf), usLen); + if (!grp.empty() && grp.back() == '\0') grp.pop_back(); + if (grp.empty()) return ok(); + auto r = dd->add_user_to_group(user, grp); + if (!r) return fail(r.error()); + return ok(); + } + + // All other codes (including 1 for comment, 1101 for password): store as + // string property keyed by "prop_N" so Get/Set round-trip symmetrically. + std::string val; + if (pvBuf != nullptr && usLen > 0) + val.assign(reinterpret_cast(pvBuf), usLen); + std::string key = "prop_" + std::to_string(static_cast(usProp)); + auto r = dd->set_user_property(user, key, val); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsDDGetTableProperty(ADSHANDLE hConn, UNSIGNED8* pucTable, + UNSIGNED16 usProp, void* pBuf, + UNSIGNED16* pusLen) { + arc2_trace("AdsDDGetTableProperty"); + namespace fs = std::filesystem; + if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (auto* rc = get_remote_connection(hConn)) { + auto tname = openads::abi::to_internal(pucTable, 0); + auto r = rc->dd_get_property(openads::network::DDObjectKind::Table, + tname, "", usProp); + UNSIGNED16 cap = *pusLen; + if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); + if (!r) { *pusLen = 0; return fail(r.error()); } + UNSIGNED16 n = static_cast(std::min(r.value().size(), cap)); + if (pBuf != nullptr && n > 0) std::memcpy(pBuf, r.value().data(), n); + *pusLen = static_cast(r.value().size()); + return ok(); + } + Connection* c = conn_from_handle(hConn); + UNSIGNED16 cap = *pusLen; + if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); + + auto* dd = (c != nullptr && c->has_dd()) ? c->dd() : nullptr; + if (dd == nullptr) { *pusLen = 0; return ok(); } + + auto alias = openads::abi::to_internal(pucTable, 0); + if (!dd->has_alias(alias)) { + *pusLen = 0; + return fail(static_cast(openads::AE_TABLE_NOT_FOUND), + alias.c_str()); + } + if (!dd_can_view_object_metadata(c, alias)) { + *pusLen = 0; + return fail(openads::AE_ACCESS_DENIED, alias.c_str()); + } + + std::string rel = dd->resolve(alias); + + auto put_str = [&](const std::string& s) -> UNSIGNED32 { + UNSIGNED16 n = static_cast( + std::min(s.size(), cap)); + if (pBuf != nullptr && n > 0) std::memcpy(pBuf, s.data(), n); + *pusLen = static_cast(s.size()); + return ok(); + }; + auto put_u16 = [&](std::uint16_t v) -> UNSIGNED32 { + const UNSIGNED16 need = 2; + if (pBuf != nullptr && cap >= need) { + auto* b = static_cast(pBuf); + b[0] = static_cast(v & 0xFFu); + b[1] = static_cast(v >> 8); + } + *pusLen = need; + return ok(); + }; + auto put_u32 = [&](std::uint32_t v) -> UNSIGNED32 { + const UNSIGNED16 need = 4; + if (pBuf != nullptr && cap >= need) { + auto* b = static_cast(pBuf); + b[0] = static_cast( v & 0xFFu); + b[1] = static_cast((v >> 8) & 0xFFu); + b[2] = static_cast((v >> 16) & 0xFFu); + b[3] = static_cast((v >> 24) & 0xFFu); + } + *pusLen = need; + return ok(); + }; + auto prop_u16 = [&](UNSIGNED16 prop, std::uint16_t fallback = 0) -> std::uint16_t { + // RCB 06/27/2026: DD table options are stored as decimal strings so + // PHP/JS tools can set them without having to marshal raw ACE bytes. + std::string raw = dd->get_table_property(alias, static_cast(prop)); + if (raw.empty()) return fallback; + try { + auto v = std::stoul(raw); + return static_cast(std::min(v, 65535ul)); + } catch (...) { + if (raw.size() >= 2) { + const auto* b = reinterpret_cast(raw.data()); + return static_cast(b[0] | (static_cast(b[1]) << 8)); + } + return fallback; + } + }; + + switch (usProp) { + case ADS_DD_TABLE_RELATIVE_PATH: // 211 + return put_str(rel); + + case ADS_DD_TABLE_PATH: { // 205 -- absolute path + fs::path abs = fs::path(c->data_dir()) / rel; + return put_str(abs.string()); + } + + case ADS_DD_TABLE_TYPE: { // 204 -- infer from extension + fs::path p(rel); + std::string ext = p.extension().string(); + for (auto& ch : ext) + ch = static_cast( + std::tolower(static_cast(ch))); + UNSIGNED16 ttype = ADS_CDX; + if (ext == ".adt") ttype = ADS_ADT; + return put_u16(ttype); + } + + case ADS_DD_TABLE_CHAR_TYPE: // 212 + return put_u16(ADS_ANSI); + + case ADS_DD_TABLE_OBJ_ID: // 208 + return put_u32(0); + + case ADS_DD_TABLE_FIELD_COUNT: // 206 -- requires opening table + return put_u32(0); + + case ADS_DD_TABLE_AUTO_CREATE: // 203 + return put_u16(prop_u16(usProp)); + + case ADS_DD_TABLE_MEMO_BLOCK_SIZE: // 215 + return put_u16(prop_u16(usProp)); + + case ADS_DD_TABLE_CACHING: // 217 + return put_u16(prop_u16(usProp)); + + case ADS_DD_TABLE_ENCRYPTION: // 214 + return put_u16(prop_u16(usProp)); + + case ADS_DD_TABLE_TXN_FREE: // 218 + return put_u16(prop_u16(usProp)); + + case ADS_DD_TABLE_IS_RI_PARENT: // 210 + return put_u16(0); + + case ADS_DD_TABLE_PERMISSION_LEVEL: { // 216 + int lvl = prop_u16(usProp, 0); + if (lvl == 0) { + lvl = (c && !c->username().empty()) + ? dd->get_effective_permission(c->username(), alias) + : 4; + } + return put_u16(static_cast(lvl)); + } + + case ADS_DD_TABLE_VALIDATION_EXPR: // 200 + case ADS_DD_TABLE_VALIDATION_MSG: // 201 + return put_str(dd->get_table_property(alias, usProp)); + + case ADS_DD_TABLE_PRIMARY_KEY: // 202 + return put_str(dd->get_table_property(alias, 202)); + + case ADS_DD_TABLE_DEFAULT_INDEX: // 213 + return put_str(dd->get_table_property(alias, 213)); + + case ADS_DD_COMMENT: // 1 (generic object prop) + case ADS_DD_USER_DEFINED_PROP: // 3 (generic object prop) + return put_str(dd->get_table_property(alias, usProp == ADS_DD_COMMENT ? 1 : 3)); + + default: + *pusLen = 0; + return fail(openads::AE_FUNCTION_NOT_AVAILABLE, ""); + } +} + +UNSIGNED32 ENTRYPOINT AdsDDSetTableProperty(ADSHANDLE hConn, UNSIGNED8* pucTable, + UNSIGNED16 usProp, void* pBuf, + UNSIGNED16 usLen) { + arc2_trace("AdsDDSetTableProperty"); + if (auto* rc = get_remote_connection(hConn)) { + auto tname = openads::abi::to_internal(pucTable, 0); + std::string val = (pBuf != nullptr && usLen > 0) + ? std::string(reinterpret_cast(pBuf), usLen) : std::string(); + auto r = rc->dd_set_property(openads::network::DDObjectKind::Table, + tname, "", usProp, val); + if (!r) return fail(r.error()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + if (dd == nullptr) return ok(); + auto alias = openads::abi::to_internal(pucTable, 0); + if (!dd->has_alias(alias)) + return fail(static_cast(openads::AE_TABLE_NOT_FOUND), + alias.c_str()); + auto parse_u16_prop = [&](std::uint16_t& out) -> bool { + if (pBuf == nullptr || usLen == 0) { + out = 0; + return true; + } + std::string s(static_cast(pBuf), usLen); + while (!s.empty() && (s.back() == '\0' || + std::isspace(static_cast(s.back())))) { + s.pop_back(); + } + if (!s.empty() && std::all_of(s.begin(), s.end(), [](char ch) { + return std::isdigit(static_cast(ch)) != 0; + })) { + try { + auto v = std::stoul(s); + if (v > 65535ul) return false; + out = static_cast(v); + return true; + } catch (...) { + return false; + } + } + if (usLen == 2) { + const auto* b = static_cast(pBuf); + out = static_cast(b[0] | (static_cast(b[1]) << 8)); + return true; + } + return false; + }; + + // RCB 06/27/2026: Support SAP-style DD table properties for any client + // using the ACE-compatible table-property API. + if (usProp == ADS_DD_COMMENT || + usProp == ADS_DD_USER_DEFINED_PROP || + usProp == ADS_DD_TABLE_VALIDATION_EXPR || + usProp == ADS_DD_TABLE_VALIDATION_MSG || + usProp == ADS_DD_TABLE_PRIMARY_KEY || + usProp == ADS_DD_TABLE_DEFAULT_INDEX) { + std::string val; + if (pBuf != nullptr && usLen > 0) + val.assign(static_cast(pBuf), usLen); + while (!val.empty() && val.back() == '\0') val.pop_back(); + int stored_prop = static_cast(usProp); // generic ids 1/3 + // store as-is now + dd->set_table_property(alias, stored_prop, val); + if (auto r = dd->save(); !r) return fail(r.error()); + return ok(); + } + if (usProp == ADS_DD_TABLE_AUTO_CREATE || + usProp == ADS_DD_TABLE_ENCRYPTION || + usProp == ADS_DD_TABLE_MEMO_BLOCK_SIZE || + usProp == ADS_DD_TABLE_PERMISSION_LEVEL || + usProp == ADS_DD_TABLE_CACHING || + usProp == ADS_DD_TABLE_TXN_FREE) { + std::uint16_t v = 0; + if (!parse_u16_prop(v)) + return fail(static_cast(AE_VALUE_OVERFLOW), + "AdsDDSetTableProperty"); + if (usProp == ADS_DD_TABLE_AUTO_CREATE || + usProp == ADS_DD_TABLE_ENCRYPTION || + usProp == ADS_DD_TABLE_TXN_FREE) { + v = (v == 0) ? 0 : 1; + } else if (usProp == ADS_DD_TABLE_CACHING && v > ADS_TABLE_CACHE_WRITES) { + return fail(static_cast(AE_VALUE_OVERFLOW), + "AdsDDSetTableProperty"); + } else if (usProp == ADS_DD_TABLE_PERMISSION_LEVEL && v > 3) { + return fail(static_cast(AE_VALUE_OVERFLOW), + "AdsDDSetTableProperty"); + } + dd->set_table_property(alias, static_cast(usProp), std::to_string(v)); + if (auto r = dd->save(); !r) return fail(r.error()); + return ok(); + } + return fail(static_cast(openads::AE_FUNCTION_NOT_AVAILABLE), + "AdsDDSetTableProperty"); +} + +UNSIGNED32 ENTRYPOINT AdsDDSetUserTableRights(ADSHANDLE hConn, UNSIGNED8* pucTable, + UNSIGNED8* pucUser, UNSIGNED32 ulLevel) { + arc2_trace("AdsDDSetUserTableRights"); + if (auto* rc = get_remote_connection(hConn)) { + auto tname = openads::abi::to_internal(pucTable, 0); + auto uname = pucUser ? openads::abi::to_internal(pucUser, 0) : ""; + std::string val(4, '\0'); + val[0] = static_cast( ulLevel & 0xFFu); + val[1] = static_cast((ulLevel >> 8) & 0xFFu); + val[2] = static_cast((ulLevel >> 16) & 0xFFu); + val[3] = static_cast((ulLevel >> 24) & 0xFFu); + auto r = rc->dd_set_property(openads::network::DDObjectKind::UserTableRights, + tname, uname, 0, val); + if (!r) return fail(r.error()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + if (dd == nullptr) return ok(); + auto tbl = openads::abi::to_internal(pucTable, 0); + auto user = pucUser ? openads::abi::to_internal(pucUser, 0) : std::string{}; + if (tbl.empty() || user.empty()) + return fail(openads::AE_INTERNAL_ERROR, "table or user is empty"); + auto r = dd->set_table_permission(tbl, user, static_cast(ulLevel)); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsDDGetUserTableRights(ADSHANDLE hConn, UNSIGNED8* pucTable, + UNSIGNED8* pucUser, UNSIGNED32* pulLevel) { + arc2_trace("AdsDDGetUserTableRights"); + if (pulLevel == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (auto* rc = get_remote_connection(hConn)) { + auto tname = openads::abi::to_internal(pucTable, 0); + auto uname = pucUser ? openads::abi::to_internal(pucUser, 0) : ""; + auto r = rc->dd_get_property(openads::network::DDObjectKind::UserTableRights, + tname, uname, 0); + if (!r || r.value().size() < 4) { *pulLevel = 4; return ok(); } + const auto& v = r.value(); + *pulLevel = static_cast(static_cast(v[0])) | + (static_cast(static_cast(v[1])) << 8) | + (static_cast(static_cast(v[2])) << 16) | + (static_cast(static_cast(v[3])) << 24); + return ok(); + } + Connection* c = conn_from_handle(hConn); + if (c == nullptr || !c->has_dd()) { *pulLevel = 4; return ok(); } + auto tbl = openads::abi::to_internal(pucTable, 0); + auto user = pucUser ? openads::abi::to_internal(pucUser, 0) : std::string{}; + *pulLevel = static_cast( + c->dd()->get_effective_permission(user, tbl)); + return ok(); +} + +// --------------------------------------------------------------------------- +// AdsDDGetFieldProperty / AdsDDSetFieldProperty +// --------------------------------------------------------------------------- + +UNSIGNED32 ENTRYPOINT AdsDDGetFieldProperty(ADSHANDLE hConn, UNSIGNED8* pucTable, + UNSIGNED8* pucField, UNSIGNED16 usProp, + void* pBuf, UNSIGNED16* pusLen) { + arc2_trace("AdsDDGetFieldProperty"); + if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (auto* rc = get_remote_connection(hConn)) { + auto tname = openads::abi::to_internal(pucTable, 0); + auto fname = openads::abi::to_internal(pucField, 0); + auto r = rc->dd_get_property(openads::network::DDObjectKind::Field, + tname, fname, usProp); + UNSIGNED16 cap = *pusLen; + if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); + if (!r) { *pusLen = 0; return fail(r.error()); } + UNSIGNED16 n = static_cast(std::min(r.value().size(), cap)); + if (pBuf != nullptr && n > 0) std::memcpy(pBuf, r.value().data(), n); + *pusLen = static_cast(r.value().size()); + return ok(); + } + Connection* c = conn_from_handle(hConn); + UNSIGNED16 cap = *pusLen; + if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); + + auto put_str = [&](const std::string& s) -> UNSIGNED32 { + UNSIGNED16 n = static_cast( + std::min(s.size(), cap)); + if (pBuf != nullptr && n > 0) std::memcpy(pBuf, s.data(), n); + *pusLen = static_cast(s.size()); + return ok(); + }; + + auto* dd = (c != nullptr && c->has_dd()) ? c->dd() : nullptr; + if (dd == nullptr) { +#if defined(OPENADS_WITH_POSTGRESQL) + // No Advantage Data Dictionary on this connection: a SQL backend with a + // live catalog (PostgreSQL information_schema) can still answer + // CAN_NULL (UNSIGNED16, SAP shape) and DEFAULT_VALUE per field. + if (usProp == ADS_DD_FIELD_CAN_NULL || + usProp == ADS_DD_FIELD_DEFAULT_VALUE) { + if (auto* pc = state().registry + .lookup( + hConn, HandleKind::PostgresConnection)) { + openads::sql_backend::PostgresTable probe; + probe.name = openads::abi::to_internal(pucTable, 0); + auto fr = pc->describe_table(&probe); + if (fr) { + const auto want = openads::abi::to_internal(pucField, 0); + for (const auto& fd2 : fr.value()) { + if (fd2.name.size() == want.size() && + std::equal(fd2.name.begin(), fd2.name.end(), + want.begin(), [](char a, char b) { + return std::toupper((unsigned char) a) == + std::toupper((unsigned char) b); + })) { + if (usProp == ADS_DD_FIELD_CAN_NULL) { + if (pBuf != nullptr && cap >= 2) { + auto* b = static_cast(pBuf); + b[0] = fd2.nullable ? 1 : 0; + b[1] = 0; + } + *pusLen = 2; + return ok(); + } + if (fd2.default_value.empty()) { + *pusLen = 0; + return fail(openads::AE_PROPERTY_NOT_SET, + want.c_str()); + } + return put_str(fd2.default_value); + } + } + } + } + } +#endif + *pusLen = 0; + return ok(); + } + + auto alias = openads::abi::to_internal(pucTable, 0); + auto field = openads::abi::to_internal(pucField, 0); + if (!dd->has_alias(alias)) { + *pusLen = 0; + return fail(static_cast(openads::AE_TABLE_NOT_FOUND), alias.c_str()); + } + if (!dd_can_view_object_metadata(c, alias)) { + *pusLen = 0; + return fail(openads::AE_ACCESS_DENIED, alias.c_str()); + } + + auto put_u16 = [&](std::uint16_t v) -> UNSIGNED32 { + if (pBuf != nullptr && cap >= 2) { + auto* b = static_cast(pBuf); + b[0] = static_cast(v & 0xFFu); + b[1] = static_cast(v >> 8); + } + *pusLen = 2; + return ok(); + }; + + // Structural properties: open the table briefly, read the field + // descriptor. SAP ABI (ace_adsddgetfieldproperty.htm): TYPE / LENGTH / + // DECIMAL are UNSIGNED16; DEFINITION is the field-definition text. + if (usProp == ADS_DD_FIELD_TYPE || usProp == ADS_DD_FIELD_LENGTH || + usProp == ADS_DD_FIELD_DECIMAL || usProp == ADS_DD_FIELD_DEFINITION) { + + std::string rel = dd->resolve(alias); + auto th = c->open_table(rel, openads::engine::TableType::Cdx, + openads::engine::OpenMode::Read); + if (!th) { *pusLen = 0; return fail(th.error()); } + + auto* tbl = c->lookup_table(th.value()); + UNSIGNED32 ret = ok(); + if (tbl != nullptr) { + std::int32_t fi = tbl->field_index(field); + if (fi < 0) { + ret = fail(static_cast(openads::AE_NO_FILE_FOUND), field.c_str()); + } else { + const auto& fd = tbl->field_descriptor(static_cast(fi)); + if (usProp == ADS_DD_FIELD_TYPE) { + ret = put_u16(map_field_type(fd.type)); + } else if (usProp == ADS_DD_FIELD_LENGTH) { + ret = put_u16(static_cast(fd.length)); + } else if (usProp == ADS_DD_FIELD_DECIMAL) { + ret = put_u16(static_cast(fd.decimals)); + } else { // ADS_DD_FIELD_DEFINITION -- "name, type, len, dec" + ret = put_str(fd.name + "," + + std::to_string(map_field_type(fd.type)) + + "," + std::to_string(fd.length) + "," + + std::to_string(fd.decimals)); + } + } + } + c->close_table(th.value()); + return ret; + } + + // CAN_NULL: UNSIGNED16, non-zero = a NULL value is allowed (SAP shape). + // The stored "required" flag is the inverse. + if (usProp == ADS_DD_FIELD_CAN_NULL) { + bool required = dd->get_field_property(alias, field, "required") == "T"; + return put_u16(required ? 0 : 1); + } + + // Stored string properties from field_props_. DEFAULT_VALUE / MIN / + // MAX return AE_PROPERTY_NOT_SET when absent (SAP behavior). + std::string key; + bool not_set_when_empty = false; + switch (usProp) { + case ADS_DD_FIELD_DEFAULT_VALUE: + key = "default"; not_set_when_empty = true; break; + case ADS_DD_FIELD_MIN_VALUE: + key = "min"; not_set_when_empty = true; break; + case ADS_DD_FIELD_MAX_VALUE: + key = "max"; not_set_when_empty = true; break; + case ADS_DD_FIELD_VALIDATION_MSG: key = "msg"; break; + case ADS_DD_OA_FIELD_VALIDATION_RULE: key = "rule"; break; + case ADS_DD_COMMENT: key = "comment"; break; + default: + *pusLen = 0; + return fail(openads::AE_INVALID_PROPERTY_ID, ""); + } + std::string val = dd->get_field_property(alias, field, key); + if (val.empty() && not_set_when_empty) { + *pusLen = 0; + return fail(openads::AE_PROPERTY_NOT_SET, field.c_str()); + } + return put_str(val); +} + +UNSIGNED32 ENTRYPOINT AdsDDSetFieldProperty(ADSHANDLE hConn, UNSIGNED8* pucTable, + UNSIGNED8* pucField, UNSIGNED16 usProp, + void* pBuf, UNSIGNED16 usLen) { + arc2_trace("AdsDDSetFieldProperty"); + if (auto* rc = get_remote_connection(hConn)) { + auto tname = openads::abi::to_internal(pucTable, 0); + auto fname = openads::abi::to_internal(pucField, 0); + std::string val = (pBuf != nullptr && usLen > 0) + ? std::string(reinterpret_cast(pBuf), usLen) : std::string(); + auto r = rc->dd_set_property(openads::network::DDObjectKind::Field, + tname, fname, usProp, val); + if (!r) return fail(r.error()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + if (dd == nullptr) return ok(); + auto alias = openads::abi::to_internal(pucTable, 0); + auto field = openads::abi::to_internal(pucField, 0); + if (!dd->has_alias(alias)) + return fail(static_cast(openads::AE_TABLE_NOT_FOUND), alias.c_str()); + + // Structural props (TYPE / LENGTH / DECIMAL / DEFINITION) are read-only. + if (usProp == ADS_DD_FIELD_TYPE || usProp == ADS_DD_FIELD_LENGTH || + usProp == ADS_DD_FIELD_DECIMAL || usProp == ADS_DD_FIELD_DEFINITION) + return fail(openads::AE_FUNCTION_NOT_AVAILABLE, "read-only field prop"); + + // CAN_NULL: SAP passes an UNSIGNED16 (non-zero = nullable). Text + // spellings (T/F, True/False) are tolerated for OA's own callers. + if (usProp == ADS_DD_FIELD_CAN_NULL) { + bool can_null = true; + if (pBuf != nullptr && usLen == 2) { + const auto* b = static_cast(pBuf); + can_null = (static_cast(b[0]) | + (static_cast(b[1]) << 8)) != 0; + } else if (pBuf != nullptr && usLen > 0) { + char c0 = static_cast(pBuf)[0]; + can_null = !(c0 == 'F' || c0 == 'f' || c0 == '0'); + } + auto r = dd->set_field_property(alias, field, "required", + can_null ? "" : "T"); + if (!r) return fail(r.error()); + return ok(); + } + + std::string key; + switch (usProp) { + case ADS_DD_FIELD_DEFAULT_VALUE: key = "default"; break; + case ADS_DD_FIELD_MIN_VALUE: key = "min"; break; + case ADS_DD_FIELD_MAX_VALUE: key = "max"; break; + case ADS_DD_FIELD_VALIDATION_MSG: key = "msg"; break; + case ADS_DD_OA_FIELD_VALIDATION_RULE: key = "rule"; break; + case ADS_DD_COMMENT: key = "comment"; break; + default: + return fail(openads::AE_INVALID_PROPERTY_ID, ""); + } + std::string val = pBuf && usLen > 0 + ? std::string(static_cast(pBuf), usLen) : std::string{}; + auto r = dd->set_field_property(alias, field, key, val); + if (!r) return fail(r.error()); + return ok(); +} + +// --------------------------------------------------------------------------- +// AdsDDGetIndexProperty / AdsDDSetIndexProperty +// --------------------------------------------------------------------------- + +UNSIGNED32 ENTRYPOINT AdsDDGetIndexProperty(ADSHANDLE hConn, UNSIGNED8* pucTable, + UNSIGNED8* pucIndex, UNSIGNED16 usProp, + void* pBuf, UNSIGNED16* pusLen) { + arc2_trace("AdsDDGetIndexProperty"); + if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (auto* rc = get_remote_connection(hConn)) { + auto tname = openads::abi::to_internal(pucTable, 0); + auto iname = openads::abi::to_internal(pucIndex, 0); + auto r = rc->dd_get_property(openads::network::DDObjectKind::Index, + tname, iname, usProp); + UNSIGNED16 cap = *pusLen; + if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); + if (!r) { *pusLen = 0; return fail(r.error()); } + UNSIGNED16 n = static_cast(std::min(r.value().size(), cap)); + if (pBuf != nullptr && n > 0) std::memcpy(pBuf, r.value().data(), n); + *pusLen = static_cast(r.value().size()); + return ok(); + } + Connection* c = conn_from_handle(hConn); + UNSIGNED16 cap = *pusLen; + if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); + + auto put_str = [&](const std::string& s) -> UNSIGNED32 { + UNSIGNED16 n = static_cast(std::min(s.size(), cap)); + if (pBuf != nullptr && n > 0) std::memcpy(pBuf, s.data(), n); + *pusLen = static_cast(s.size()); + return ok(); + }; + auto put_u16 = [&](std::uint16_t v) -> UNSIGNED32 { + if (pBuf != nullptr && cap >= 2) { + auto* b = static_cast(pBuf); + b[0] = static_cast(v & 0xFFu); + b[1] = static_cast(v >> 8); + } + *pusLen = 2; return ok(); + }; + + // Look in index_bindings() for a binding with this tag name that + // belongs to a table matching pucTable (alias or open table). + auto idx_name = openads::abi::to_internal(pucIndex, 0); + auto tbl_name = pucTable ? openads::abi::to_internal(pucTable, 0) : std::string{}; + + ADSHANDLE idx_h = 0; + openads::drivers::IIndex* found_idx = nullptr; + std::string found_path; + + // Storm fix: reader must hold index_bindings_mu (binds are unlocked now). + std::lock_guard _dd_lk(index_bindings_mu()); + for (auto& [h, b] : index_bindings()) { + if (b.tag_name != idx_name) continue; + // If a table name is given, check that the binding's table is that table. + if (!tbl_name.empty() && c != nullptr) { + if (!c->owns_table_ptr(b.table)) continue; + } + idx_h = h; + found_path = b.path; + found_idx = iindex_for_handle(h); + break; + } + + if (idx_h == 0) { + *pusLen = 0; + return fail(openads::AE_NO_FILE_FOUND, idx_name.c_str()); + } + if (found_idx == nullptr) { *pusLen = 0; return ok(); } + + switch (usProp) { + case ADS_DD_INDEX_FILE_NAME: return put_str(found_path); + case ADS_DD_INDEX_EXPRESSION: return put_str(found_idx->expression()); + case ADS_DD_INDEX_CONDITION: return put_str({}); // not exposed by IIndex + case ADS_DD_INDEX_OPTIONS: { + // SAP shape: UNSIGNED32 bitmask of ADS_UNIQUE / ADS_COMPOUND / + // ADS_CUSTOM / ADS_DESCENDING. + UNSIGNED32 opts = 0; + if (found_idx->unique()) opts |= ADS_UNIQUE; + if (found_idx->descending()) opts |= ADS_DESCENDING; + if (pBuf != nullptr && cap >= 4) { + auto* b = static_cast(pBuf); + b[0] = static_cast( opts & 0xFFu); + b[1] = static_cast((opts >> 8) & 0xFFu); + b[2] = static_cast((opts >> 16) & 0xFFu); + b[3] = static_cast((opts >> 24) & 0xFFu); + } + *pusLen = 4; + return ok(); + } + case ADS_DD_INDEX_KEY_LENGTH: return put_u16(found_idx->key_length()); + case ADS_DD_INDEX_KEY_TYPE: { + // SAP shape: UNSIGNED16 ADS data type of the key (see + // AdsGetKeyType) -- numeric-encoded keys report ADS_NUMERIC, + // everything else ADS_STRING. + using KE = openads::drivers::KeyEncoding; + bool numeric = + found_idx->key_encoding() == KE::FoxNumeric || + found_idx->key_encoding() == KE::NtxNumeric; + return put_u16(numeric ? ADS_NUMERIC : ADS_STRING); + } + default: + *pusLen = 0; + return fail(openads::AE_INVALID_PROPERTY_ID, ""); + } +} + +UNSIGNED32 ENTRYPOINT AdsDDSetIndexProperty(ADSHANDLE /*hConn*/, UNSIGNED8* /*pucTable*/, + UNSIGNED8* /*pucIndex*/, UNSIGNED16 /*usProp*/, + void* /*pBuf*/, UNSIGNED16 /*usLen*/) { + arc2_trace("AdsDDSetIndexProperty"); + return fail(openads::AE_FUNCTION_NOT_AVAILABLE, "AdsDDSetIndexProperty"); +} + +// --------------------------------------------------------------------------- +// AdsDDCreateTrigger / AdsDDDropTrigger / AdsDDGet/SetTriggerProperty +// --------------------------------------------------------------------------- + +UNSIGNED32 ENTRYPOINT AdsDDCreateTrigger(ADSHANDLE hConn, UNSIGNED8* pucName, + UNSIGNED8* pucTable, UNSIGNED32 ulType, + UNSIGNED32 /*ulOptions*/, UNSIGNED8* pucContainer, + UNSIGNED8* pucProcedure, UNSIGNED32 ulPriority) { + arc2_trace("AdsDDCreateTrigger"); + if (auto* rc = get_remote_connection(hConn)) { + auto r = rc->dd_create_trigger( + openads::abi::to_internal(pucName, 0), + openads::abi::to_internal(pucTable, 0), ulType, + pucContainer ? openads::abi::to_internal(pucContainer, 0) : "", + pucProcedure ? openads::abi::to_internal(pucProcedure, 0) : "", + ulPriority); + if (!r) return fail(r.error()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + if (dd == nullptr) return ok(); + openads::engine::DataDict::TriggerEntry e; + e.name = openads::abi::to_internal(pucName, 0); + e.table_alias = openads::abi::to_internal(pucTable, 0); + // Decode combined ADS trigger type constant into internal (event_mask, timing). + // ADS_BEFORE_INSERT=0x0001 ADS_AFTER_INSERT=0x0002 ADS_BEFORE_UPDATE=0x0004 + // ADS_AFTER_UPDATE=0x0008 ADS_BEFORE_DELETE=0x0010 ADS_AFTER_DELETE=0x0020 + // ADS_INSTEAD_OF_INSERT=0x0040 ADS_INSTEAD_OF_UPDATE=0x0080 + // ADS_INSTEAD_OF_DELETE=0x0100 + switch (ulType) { + case 0x0001: e.event_mask = 1; e.timing = 1; break; // BEFORE INSERT + case 0x0002: e.event_mask = 1; e.timing = 4; break; // AFTER INSERT + case 0x0040: e.event_mask = 1; e.timing = 2; break; // INSTEAD OF INSERT + case 0x0004: e.event_mask = 2; e.timing = 1; break; // BEFORE UPDATE + case 0x0008: e.event_mask = 2; e.timing = 4; break; // AFTER UPDATE + case 0x0080: e.event_mask = 2; e.timing = 2; break; // INSTEAD OF UPDATE + case 0x0010: e.event_mask = 3; e.timing = 1; break; // BEFORE DELETE + case 0x0020: e.event_mask = 3; e.timing = 4; break; // AFTER DELETE + case 0x0100: e.event_mask = 3; e.timing = 2; break; // INSTEAD OF DELETE + default: + return fail(openads::AE_INTERNAL_ERROR, "unknown trigger type"); + } + e.container = pucContainer ? openads::abi::to_internal(pucContainer, 0) : ""; + e.procedure = pucProcedure ? openads::abi::to_internal(pucProcedure, 0) : ""; + e.priority = ulPriority; + e.enabled = true; + if (e.name.empty() || e.table_alias.empty()) + return fail(openads::AE_INTERNAL_ERROR, "trigger name/table empty"); + auto r = dd->create_trigger(e); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsDDDropTrigger(ADSHANDLE hConn, UNSIGNED8* pucName) { + arc2_trace("AdsDDDropTrigger"); + if (auto* rc = get_remote_connection(hConn)) { + auto r = rc->dd_drop_trigger(openads::abi::to_internal(pucName, 0)); + if (!r) return fail(r.error()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + if (dd == nullptr) return ok(); + auto name = openads::abi::to_internal(pucName, 0); + if (!dd->has_trigger(name)) + return fail(static_cast(openads::AE_NO_FILE_FOUND), name.c_str()); + auto r = dd->drop_trigger(name); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsDDGetTriggerProperty(ADSHANDLE hConn, UNSIGNED8* pucName, + UNSIGNED16 usProp, void* pBuf, + UNSIGNED16* pusLen) { + arc2_trace("AdsDDGetTriggerProperty"); + if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (auto* rc = get_remote_connection(hConn)) { + auto tname = openads::abi::to_internal(pucName, 0); + auto r = rc->dd_get_property(openads::network::DDObjectKind::Trigger, + tname, "", usProp); + UNSIGNED16 cap = *pusLen; + if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); + if (!r) { *pusLen = 0; return fail(r.error()); } + UNSIGNED16 n = static_cast(std::min(r.value().size(), cap)); + if (pBuf != nullptr && n > 0) std::memcpy(pBuf, r.value().data(), n); + *pusLen = static_cast(r.value().size()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + UNSIGNED16 cap = *pusLen; + if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); + if (dd == nullptr) { *pusLen = 0; return ok(); } + auto name = openads::abi::to_internal(pucName, 0); + const auto* ep = dd->find_trigger(name); + if (ep == nullptr) { + *pusLen = 0; + return fail(static_cast(openads::AE_NO_FILE_FOUND), name.c_str()); + } + const auto& e = *ep; + + auto put_str = [&](const std::string& s) -> UNSIGNED32 { + UNSIGNED16 n = static_cast(std::min(s.size(), cap)); + if (pBuf != nullptr && n > 0) std::memcpy(pBuf, s.data(), n); + *pusLen = static_cast(s.size()); + return ok(); + }; + auto put_u32 = [&](std::uint32_t v) -> UNSIGNED32 { + if (pBuf != nullptr && cap >= 4) { + auto* b = static_cast(pBuf); + b[0]=static_cast(v&0xFF); b[1]=static_cast((v>>8)&0xFF); b[2]=static_cast((v>>16)&0xFF); b[3]=static_cast((v>>24)&0xFF); + } + *pusLen = 4; return ok(); + }; + + switch (usProp) { + case ADS_DD_TRIGGER_TABLE: + case 1408: /* ADS_DD_TRIG_TABLENAME (SAP ACE) */ + return put_str(e.table_alias); + case ADS_DD_TRIGGER_EVENT: + { + // ABI callers (AdsDDCreateTrigger/SetTriggerProperty) use combined ADS type constants. + std::uint32_t combined = 0; + if (e.event_mask==1 && e.timing==1) combined = 0x0001; + else if (e.event_mask==1 && e.timing==4) combined = 0x0002; + else if (e.event_mask==1 && e.timing==2) combined = 0x0040; + else if (e.event_mask==2 && e.timing==1) combined = 0x0004; + else if (e.event_mask==2 && e.timing==4) combined = 0x0008; + else if (e.event_mask==2 && e.timing==2) combined = 0x0080; + else if (e.event_mask==3 && e.timing==1) combined = 0x0010; + else if (e.event_mask==3 && e.timing==4) combined = 0x0020; + else if (e.event_mask==3 && e.timing==2) combined = 0x0100; + return put_u32(combined); + } + case 1401: /* ADS_DD_TRIG_EVENT_TYPE (SAP ACE) -- 1=INSERT 2=UPDATE 3=DELETE */ + return put_u32(static_cast(e.event_mask)); + case 1402: /* ADS_DD_TRIG_TIMING (SAP ACE extension) */ + return put_u32(e.timing); + case ADS_DD_TRIGGER_CONTAINER: + case 1404: /* ADS_DD_TRIG_CONTAINER (SAP ACE) */ + return put_str(e.container); + case ADS_DD_TRIGGER_PROC_NAME: + case 1405: /* ADS_DD_TRIG_FUNCTION_NAME (SAP ACE) */ + return put_str(e.procedure); + case ADS_DD_TRIGGER_ENABLED: return put_u32(e.enabled ? 1u : 0u); + case ADS_DD_TRIGGER_PRIORITY: + case 1406: /* ADS_DD_TRIG_PRIORITY (SAP ACE) */ + return put_u32(e.priority); + case ADS_DD_TRIGGER_COMMENT: return put_str(e.comment); + case 1407: /* ADS_DD_TRIG_OPTIONS (SAP ACE): 0x01=WANT_VALUES 0x02=WANT_MEMOS 0x04=NO_TRANSACTION */ + return put_u32(e.options); + default: *pusLen = 0; return fail(openads::AE_FUNCTION_NOT_AVAILABLE, ""); + } +} + +UNSIGNED32 ENTRYPOINT AdsDDSetTriggerProperty(ADSHANDLE hConn, UNSIGNED8* pucName, + UNSIGNED16 usProp, void* pBuf, + UNSIGNED16 usLen) { + arc2_trace("AdsDDSetTriggerProperty"); + if (auto* rc = get_remote_connection(hConn)) { + auto tname = openads::abi::to_internal(pucName, 0); + std::string val = (pBuf != nullptr && usLen > 0) + ? std::string(reinterpret_cast(pBuf), usLen) : std::string(); + auto r = rc->dd_set_property(openads::network::DDObjectKind::Trigger, + tname, "", usProp, val); + if (!r) return fail(r.error()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + if (dd == nullptr) return ok(); + auto name = openads::abi::to_internal(pucName, 0); + auto* ep = dd->find_trigger(name); + if (ep == nullptr) + return fail(static_cast(openads::AE_NO_FILE_FOUND), name.c_str()); + auto& e = *ep; + std::string val = pBuf && usLen > 0 + ? std::string(static_cast(pBuf), usLen) : std::string{}; + // Helper: parse val as uint32 (numeric string or 4-byte LE binary). + auto parse_u32 = [&](std::uint32_t& out) { + if (usLen >= 4 && (val[0] < '0' || val[0] > '9')) { + // Binary 4-byte LE + auto* b = static_cast(pBuf); + out = static_cast(b[0]) | (static_cast(b[1]) << 8) | (static_cast(b[2]) << 16) | (static_cast(b[3]) << 24); + } else if (!val.empty()) { + try { out = static_cast(std::stoul(val)); } catch (...) {} + } + }; + switch (usProp) { + case ADS_DD_TRIGGER_TABLE: + case 1408: /* ADS_DD_TRIG_TABLENAME (SAP ACE) */ + e.table_alias = val; break; + case ADS_DD_TRIGGER_EVENT: + case 1401: /* ADS_DD_TRIG_EVENT_TYPE (SAP ACE) -- decode combined ADS constant */ + { + std::uint32_t combined = 0; + parse_u32(combined); + switch (combined) { + case 0x0001: e.event_mask=1; e.timing=1; break; + case 0x0002: e.event_mask=1; e.timing=4; break; + case 0x0040: e.event_mask=1; e.timing=2; break; + case 0x0004: e.event_mask=2; e.timing=1; break; + case 0x0008: e.event_mask=2; e.timing=4; break; + case 0x0080: e.event_mask=2; e.timing=2; break; + case 0x0010: e.event_mask=3; e.timing=1; break; + case 0x0020: e.event_mask=3; e.timing=4; break; + case 0x0100: e.event_mask=3; e.timing=2; break; + default: break; + } + break; + } + case 1402: /* timing: 1=BEFORE 2=INSTEAD_OF 4=AFTER */ + parse_u32(e.timing); break; + case ADS_DD_TRIGGER_CONTAINER: + case 1404: /* ADS_DD_TRIG_CONTAINER (SAP ACE) */ + e.container = val; break; + case ADS_DD_TRIGGER_PROC_NAME: + case 1405: /* ADS_DD_TRIG_FUNCTION_NAME (SAP ACE) */ + e.procedure = val; break; + case ADS_DD_TRIGGER_COMMENT: e.comment = val; break; + case ADS_DD_TRIGGER_ENABLED: { + std::uint32_t v = 0; + parse_u32(v); + // Also accept "T"/"F", "True"/"False", "1"/"0" as strings + if (val == "T" || val == "True" || val == "true" || val == "yes" || val == "Yes") v = 1; + if (val == "F" || val == "False" || val == "false" || val == "no" || val == "No") v = 0; + e.enabled = (v != 0); + break; + } + case ADS_DD_TRIGGER_PRIORITY: + case 1406: /* ADS_DD_TRIG_PRIORITY (SAP ACE) */ + parse_u32(e.priority); break; + case 1407: /* ADS_DD_TRIG_OPTIONS (SAP ACE): 0x01=WANT_VALUES 0x02=WANT_MEMOS 0x04=NO_TRANSACTION */ + parse_u32(e.options); break; + default: + return fail(openads::AE_FUNCTION_NOT_AVAILABLE, ""); + } + auto r = dd->save(); + if (!r) return fail(r.error()); + return ok(); +} + +// --------------------------------------------------------------------------- +// AdsDDCreateProcedure / AdsDDDropProcedure / AdsDDGet/SetProcProperty +// --------------------------------------------------------------------------- + +UNSIGNED32 ENTRYPOINT AdsDDCreateProcedure(ADSHANDLE hConn, UNSIGNED8* pucName, + UNSIGNED8* pucContainer, + UNSIGNED8* pucProcName, + UNSIGNED32 /*ulInvokeOption*/, + UNSIGNED8* pucInParams, + UNSIGNED8* pucOutParams, + UNSIGNED8* pucComments) { + arc2_trace("AdsDDCreateProcedure"); + if (auto* rc = get_remote_connection(hConn)) { + auto r = rc->dd_create_proc( + openads::abi::to_internal(pucName, 0), + pucContainer ? openads::abi::to_internal(pucContainer, 0) : "", + pucProcName ? openads::abi::to_internal(pucProcName, 0) : "", + pucInParams ? openads::abi::to_internal(pucInParams, 0) : "", + pucOutParams ? openads::abi::to_internal(pucOutParams, 0) : "", + pucComments ? openads::abi::to_internal(pucComments, 0) : ""); + if (!r) return fail(r.error()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + if (dd == nullptr) return ok(); + openads::engine::DataDict::ProcEntry e; + e.name = openads::abi::to_internal(pucName, 0); + e.container = pucContainer ? openads::abi::to_internal(pucContainer, 0) : ""; + e.procedure = pucProcName ? openads::abi::to_internal(pucProcName, 0) : ""; + e.input_params = pucInParams ? openads::abi::to_internal(pucInParams, 0) : ""; + e.output_params = pucOutParams ? openads::abi::to_internal(pucOutParams, 0) : ""; + e.comment = pucComments ? openads::abi::to_internal(pucComments, 0) : ""; + if (e.name.empty()) + return fail(openads::AE_INTERNAL_ERROR, "proc name empty"); + auto r = dd->create_proc(e); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsDDDropProcedure(ADSHANDLE hConn, UNSIGNED8* pucName) { + arc2_trace("AdsDDDropProcedure"); + if (auto* rc = get_remote_connection(hConn)) { + auto r = rc->dd_drop_object(openads::network::DDObjectKind::Proc, + openads::abi::to_internal(pucName, 0)); + if (!r) return fail(r.error()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + if (dd == nullptr) return ok(); + auto name = openads::abi::to_internal(pucName, 0); + if (!dd->has_proc(name)) + return fail(static_cast(openads::AE_NO_FILE_FOUND), name.c_str()); + auto r = dd->drop_proc(name); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsDDGetProcProperty(ADSHANDLE hConn, UNSIGNED8* pucName, + UNSIGNED16 usProp, void* pBuf, + UNSIGNED16* pusLen) { + arc2_trace("AdsDDGetProcProperty"); + if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (auto* rc = get_remote_connection(hConn)) { + auto pname = openads::abi::to_internal(pucName, 0); + auto r = rc->dd_get_property(openads::network::DDObjectKind::Proc, + pname, "", usProp); + UNSIGNED16 cap = *pusLen; + if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); + if (!r) { *pusLen = 0; return fail(r.error()); } + UNSIGNED16 n = static_cast(std::min(r.value().size(), cap)); + if (pBuf != nullptr && n > 0) std::memcpy(pBuf, r.value().data(), n); + *pusLen = static_cast(r.value().size()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + UNSIGNED16 cap = *pusLen; + if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); + if (dd == nullptr) { *pusLen = 0; return ok(); } + auto name = openads::abi::to_internal(pucName, 0); + if (!dd->has_proc(name)) { + *pusLen = 0; + return fail(static_cast(openads::AE_NO_FILE_FOUND), name.c_str()); + } + const auto& e = dd->procs().at(name); + auto put_str = [&](const std::string& s) -> UNSIGNED32 { + UNSIGNED16 n = static_cast(std::min(s.size(), cap)); + if (pBuf != nullptr && n > 0) std::memcpy(pBuf, s.data(), n); + *pusLen = static_cast(s.size()); + return ok(); + }; + switch (usProp) { + case ADS_DD_PROC_INPUT: + case 800: /* ADS_DD_PROC_INPUT (SAP ACE) */ + return put_str(e.input_params); + case ADS_DD_PROC_OUTPUT: + case 801: /* ADS_DD_PROC_OUTPUT (SAP ACE) */ + return put_str(e.output_params); + case ADS_DD_PROC_CONTAINER: + case 802: /* ADS_DD_PROC_DLL_NAME (SAP ACE) */ + return put_str(e.container); + case ADS_DD_PROC_PROC_NAME: + case 803: /* ADS_DD_PROC_DLL_FUNCTION_NAME (SAP ACE) */ + return put_str(e.procedure); + case ADS_DD_PROC_COMMENT: + case 805: /* ADS_DD_PROC_SCRIPT (SAP ACE) */ + return put_str(e.comment); + default: *pusLen = 0; return fail(openads::AE_FUNCTION_NOT_AVAILABLE, ""); + } +} + +UNSIGNED32 ENTRYPOINT AdsDDSetProcProperty(ADSHANDLE hConn, UNSIGNED8* pucName, + UNSIGNED16 usProp, void* pBuf, + UNSIGNED16 usLen) { + arc2_trace("AdsDDSetProcProperty"); + if (auto* rc = get_remote_connection(hConn)) { + auto pname = openads::abi::to_internal(pucName, 0); + std::string val = (pBuf != nullptr && usLen > 0) + ? std::string(reinterpret_cast(pBuf), usLen) : std::string(); + auto r = rc->dd_set_property(openads::network::DDObjectKind::Proc, + pname, "", usProp, val); + if (!r) return fail(r.error()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + if (dd == nullptr) return ok(); + auto name = openads::abi::to_internal(pucName, 0); + if (!dd->has_proc(name)) + return fail(static_cast(openads::AE_NO_FILE_FOUND), name.c_str()); + auto& e = dd->procs().at(name); + std::string val = pBuf && usLen > 0 + ? std::string(static_cast(pBuf), usLen) : std::string{}; + switch (usProp) { + case ADS_DD_PROC_INPUT: + case 800: /* ADS_DD_PROC_INPUT (SAP ACE) */ + e.input_params = val; break; + case ADS_DD_PROC_OUTPUT: + case 801: /* ADS_DD_PROC_OUTPUT (SAP ACE) */ + e.output_params = val; break; + case ADS_DD_PROC_CONTAINER: + case 802: /* ADS_DD_PROC_DLL_NAME (SAP ACE) */ + e.container = val; break; + case ADS_DD_PROC_PROC_NAME: + case 803: /* ADS_DD_PROC_DLL_FUNCTION_NAME (SAP ACE) */ + e.procedure = val; break; + case ADS_DD_PROC_COMMENT: + case 805: /* ADS_DD_PROC_SCRIPT (SAP ACE) */ + e.comment = val; break; + default: return fail(openads::AE_FUNCTION_NOT_AVAILABLE, ""); + } + auto r = dd->save(); + if (!r) return fail(r.error()); + return ok(); +} + +// --------------------------------------------------------------------------- +// AdsDDCreateFunction / AdsDDDropFunction / AdsDDGet/SetFunctionProperty +// Property codes: 700=body(implementation), 701=input_params, 702=return_type, +// 703=container, 704=comment +// --------------------------------------------------------------------------- + +UNSIGNED32 ENTRYPOINT AdsDDCreateFunction(ADSHANDLE hConn, UNSIGNED8* pucName, + UNSIGNED8* pucContainer, + UNSIGNED8* pucImplementation, + UNSIGNED8* pucRetType, + UNSIGNED8* pucInParams, + UNSIGNED8* pucComment) { + arc2_trace("AdsDDCreateFunction"); + if (auto* rc = get_remote_connection(hConn)) { + auto r = rc->dd_create_function( + openads::abi::to_internal(pucName, 0), + pucContainer ? openads::abi::to_internal(pucContainer, 0) : "", + pucImplementation ? openads::abi::to_internal(pucImplementation, 0) : "", + pucRetType ? openads::abi::to_internal(pucRetType, 0) : "", + pucInParams ? openads::abi::to_internal(pucInParams, 0) : "", + pucComment ? openads::abi::to_internal(pucComment, 0) : ""); + if (!r) return fail(r.error()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + if (dd == nullptr) return ok(); + openads::engine::DataDict::FunctionEntry e; + e.name = openads::abi::to_internal(pucName, 0); + e.container = pucContainer ? openads::abi::to_internal(pucContainer, 0) : ""; + e.implementation = pucImplementation ? openads::abi::to_internal(pucImplementation, 0) : ""; + e.return_type = pucRetType ? openads::abi::to_internal(pucRetType, 0) : ""; + e.input_params = pucInParams ? openads::abi::to_internal(pucInParams, 0) : ""; + e.comment = pucComment ? openads::abi::to_internal(pucComment, 0) : ""; + if (e.name.empty()) + return fail(openads::AE_INTERNAL_ERROR, "function name empty"); + auto r = dd->create_function(e); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsDDDropFunction(ADSHANDLE hConn, UNSIGNED8* pucName) { + arc2_trace("AdsDDDropFunction"); + if (auto* rc = get_remote_connection(hConn)) { + auto r = rc->dd_drop_object(openads::network::DDObjectKind::Function, + openads::abi::to_internal(pucName, 0)); + if (!r) return fail(r.error()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + if (dd == nullptr) return ok(); + auto name = openads::abi::to_internal(pucName, 0); + if (!dd->has_function(name)) + return fail(static_cast(openads::AE_NO_FILE_FOUND), name.c_str()); + auto r = dd->drop_function(name); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsDDGetFunctionProperty(ADSHANDLE hConn, UNSIGNED8* pucName, + UNSIGNED16 usProp, void* pBuf, + UNSIGNED16* pusLen) { + arc2_trace("AdsDDGetFunctionProperty"); + if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (auto* rc = get_remote_connection(hConn)) { + auto fname = openads::abi::to_internal(pucName, 0); + auto r = rc->dd_get_property(openads::network::DDObjectKind::Function, + fname, "", usProp); + UNSIGNED16 cap = *pusLen; + if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); + if (!r) { *pusLen = 0; return fail(r.error()); } + UNSIGNED16 n = static_cast(std::min(r.value().size(), cap)); + if (pBuf != nullptr && n > 0) std::memcpy(pBuf, r.value().data(), n); + *pusLen = static_cast(r.value().size()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + UNSIGNED16 cap = *pusLen; + if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); + if (dd == nullptr) { *pusLen = 0; return ok(); } + auto name = openads::abi::to_internal(pucName, 0); + if (!dd->has_function(name)) { + *pusLen = 0; + return fail(static_cast(openads::AE_NO_FILE_FOUND), name.c_str()); + } + const auto& e = dd->functions().at(name); + auto put_str = [&](const std::string& s) -> UNSIGNED32 { + UNSIGNED16 n = static_cast(std::min(s.size(), cap)); + if (pBuf != nullptr && n > 0) std::memcpy(pBuf, s.data(), n); + *pusLen = static_cast(s.size()); + return ok(); + }; + switch (usProp) { + case 700: return put_str(e.implementation); + case 701: return put_str(e.input_params); + case 702: return put_str(e.return_type); + case 703: return put_str(e.container); + case 704: return put_str(e.comment); + default: *pusLen = 0; return fail(openads::AE_FUNCTION_NOT_AVAILABLE, ""); + } +} + +UNSIGNED32 ENTRYPOINT AdsDDSetFunctionProperty(ADSHANDLE hConn, UNSIGNED8* pucName, + UNSIGNED16 usProp, void* pBuf, + UNSIGNED16 usLen) { + arc2_trace("AdsDDSetFunctionProperty"); + if (auto* rc = get_remote_connection(hConn)) { + auto fname = openads::abi::to_internal(pucName, 0); + std::string val = (pBuf != nullptr && usLen > 0) + ? std::string(reinterpret_cast(pBuf), usLen) : std::string(); + auto r = rc->dd_set_property(openads::network::DDObjectKind::Function, + fname, "", usProp, val); + if (!r) return fail(r.error()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + if (dd == nullptr) return ok(); + auto name = openads::abi::to_internal(pucName, 0); + if (!dd->has_function(name)) + return fail(static_cast(openads::AE_NO_FILE_FOUND), name.c_str()); + auto& e = dd->functions().at(name); + std::string val = pBuf && usLen > 0 + ? std::string(static_cast(pBuf), usLen) : std::string{}; + switch (usProp) { + case 700: e.implementation = val; break; + case 701: e.input_params = val; break; + case 702: e.return_type = val; break; + case 703: e.container = val; break; + case 704: e.comment = val; break; + default: return fail(openads::AE_FUNCTION_NOT_AVAILABLE, ""); + } + auto r = dd->save(); + if (!r) return fail(r.error()); + return ok(); +} + +// --------------------------------------------------------------------------- +// AdsDDCreateView / AdsDDDropView / AdsDDGet/SetViewProperty +// --------------------------------------------------------------------------- + +UNSIGNED32 ENTRYPOINT AdsDDCreateView(ADSHANDLE hConn, UNSIGNED8* pucName, + UNSIGNED8* pucComments, UNSIGNED8* pucSQL) { + arc2_trace("AdsDDCreateView"); + if (auto* rc = get_remote_connection(hConn)) { + auto r = rc->dd_create_view( + openads::abi::to_internal(pucName, 0), + pucComments ? openads::abi::to_internal(pucComments, 0) : "", + pucSQL ? openads::abi::to_internal(pucSQL, 0) : ""); + if (!r) return fail(r.error()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + if (dd == nullptr) return ok(); + openads::engine::DataDict::ViewEntry e; + e.name = openads::abi::to_internal(pucName, 0); + e.comment = pucComments ? openads::abi::to_internal(pucComments, 0) : ""; + e.sql = pucSQL ? openads::abi::to_internal(pucSQL, 0) : ""; + if (e.name.empty()) + return fail(openads::AE_INTERNAL_ERROR, "view name empty"); + auto r = dd->create_view(e); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsDDDropView(ADSHANDLE hConn, UNSIGNED8* pucName) { + arc2_trace("AdsDDDropView"); + if (auto* rc = get_remote_connection(hConn)) { + auto r = rc->dd_drop_view(openads::abi::to_internal(pucName, 0)); + if (!r) return fail(r.error()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + if (dd == nullptr) return ok(); + auto name = openads::abi::to_internal(pucName, 0); + if (!dd->has_view(name)) + return fail(static_cast(openads::AE_NO_FILE_FOUND), name.c_str()); + auto r = dd->drop_view(name); + if (!r) return fail(r.error()); + return ok(); +} + +// --------------------------------------------------------------------------- +// SAP ACE aliases not yet covered above +// AdsDDAddView / AdsDDRemoveView -- thin aliases for Create/Drop. +// AdsDDGetPermissions / AdsDDGrantPermission / AdsDDRevokePermission -- +// fine-grained object ACL helpers used by the php_advantage extension. +// --------------------------------------------------------------------------- + +static const char* dd_type_name_from_code(UNSIGNED16 code) { + switch (code) { + case 1: return "Table"; + case 4: return "Field"; + case 6: return "View"; + case 8: return "User"; + case 9: return "Group"; + case 10: return "StoredProc"; + case 11: return "Database"; + case 12: return "Link"; + case 18: return "Function"; + default: return "Table"; + } +} + +UNSIGNED32 ENTRYPOINT AdsDDAddView(ADSHANDLE hConn, UNSIGNED8* pucName, + UNSIGNED8* pucComments, UNSIGNED8* pucSQL) { + arc2_trace("AdsDDAddView"); + return AdsDDCreateView(hConn, pucName, pucComments, pucSQL); +} + +UNSIGNED32 ENTRYPOINT AdsDDRemoveView(ADSHANDLE hConn, UNSIGNED8* pucName) { + arc2_trace("AdsDDRemoveView"); + return AdsDDDropView(hConn, pucName); +} + +UNSIGNED32 ENTRYPOINT AdsDDGetPermissions(ADSHANDLE hConn, + UNSIGNED8* pucGrantee, + UNSIGNED16 usObjectType, + UNSIGNED8* pucObjectName, + UNSIGNED8* /*pucParentName*/, + UNSIGNED16 usGetInherited, + UNSIGNED32* pulPermissions) { + arc2_trace("AdsDDGetPermissions"); + if (pulPermissions == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *pulPermissions = 0; + if (auto* rc = get_remote_connection(hConn)) { + auto r = rc->dd_get_permissions( + openads::abi::to_internal(pucGrantee, 0), usObjectType, + openads::abi::to_internal(pucObjectName, 0), usGetInherited != 0); + if (!r) return fail(r.error()); + *pulPermissions = r.value(); + return ok(); + } + auto* dd = dd_from_handle(hConn); + if (dd == nullptr) return ok(); + auto grantee = openads::abi::to_internal(pucGrantee, 0); + auto objname = openads::abi::to_internal(pucObjectName, 0); + *pulPermissions = dd->get_permission_mask( + grantee, objname, static_cast(usObjectType), usGetInherited != 0); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsDDGrantPermission(ADSHANDLE hConn, + UNSIGNED16 usObjectType, + UNSIGNED8* pucObjectName, + UNSIGNED8* /*pucParentName*/, + UNSIGNED8* pucGrantee, + UNSIGNED32 ulPermissions) { + arc2_trace("AdsDDGrantPermission"); + if (auto* rc = get_remote_connection(hConn)) { + auto r = rc->dd_grant_permission(usObjectType, + openads::abi::to_internal(pucObjectName, 0), + openads::abi::to_internal(pucGrantee, 0), ulPermissions); + if (!r) return fail(r.error()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + if (dd == nullptr) return ok(); + auto objname = openads::abi::to_internal(pucObjectName, 0); + auto grantee = openads::abi::to_internal(pucGrantee, 0); + auto objtype = dd_type_name_from_code(usObjectType); + auto r = dd->grant_permission(objtype, objname, grantee, ulPermissions); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsDDRevokePermission(ADSHANDLE hConn, + UNSIGNED16 usObjectType, + UNSIGNED8* pucObjectName, + UNSIGNED8* pucParentName, + UNSIGNED8* pucGrantee, + UNSIGNED32 /*ulPermissions*/) { + arc2_trace("AdsDDRevokePermission"); + // Revoke by granting a zero bitmask (deactivates existing record). + // The caller typically follows with a fresh AdsDDGrantPermission call. + return AdsDDGrantPermission(hConn, usObjectType, pucObjectName, + pucParentName, pucGrantee, 0); +} + +UNSIGNED32 ENTRYPOINT AdsDDGetViewProperty(ADSHANDLE hConn, UNSIGNED8* pucName, + UNSIGNED16 usProp, void* pBuf, + UNSIGNED16* pusLen) { + arc2_trace("AdsDDGetViewProperty"); + if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (auto* rc = get_remote_connection(hConn)) { + auto vname = openads::abi::to_internal(pucName, 0); + auto r = rc->dd_get_property(openads::network::DDObjectKind::View, + vname, "", usProp); + UNSIGNED16 cap = *pusLen; + if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); + if (!r) { *pusLen = 0; return fail(r.error()); } + UNSIGNED16 n = static_cast(std::min(r.value().size(), cap)); + if (pBuf != nullptr && n > 0) std::memcpy(pBuf, r.value().data(), n); + *pusLen = static_cast(r.value().size()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + UNSIGNED16 cap = *pusLen; + if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); + if (dd == nullptr) { *pusLen = 0; return ok(); } + auto name = openads::abi::to_internal(pucName, 0); + if (!dd->has_view(name)) { + *pusLen = 0; + return fail(static_cast(openads::AE_NO_FILE_FOUND), name.c_str()); + } + const auto& e = dd->views().at(name); + auto put_str = [&](const std::string& s) -> UNSIGNED32 { + UNSIGNED16 n = static_cast(std::min(s.size(), cap)); + if (pBuf != nullptr && n > 0) std::memcpy(pBuf, s.data(), n); + *pusLen = static_cast(s.size()); + return ok(); + }; + switch (usProp) { + case ADS_DD_VIEW_STMT: return put_str(e.sql); + case ADS_DD_VIEW_COMMENT: return put_str(e.comment); + default: *pusLen = 0; return fail(openads::AE_FUNCTION_NOT_AVAILABLE, ""); + } +} + +UNSIGNED32 ENTRYPOINT AdsDDSetViewProperty(ADSHANDLE hConn, UNSIGNED8* pucName, + UNSIGNED16 usProp, void* pBuf, + UNSIGNED16 usLen) { + arc2_trace("AdsDDSetViewProperty"); + if (auto* rc = get_remote_connection(hConn)) { + auto vname = openads::abi::to_internal(pucName, 0); + std::string val = (pBuf != nullptr && usLen > 0) + ? std::string(reinterpret_cast(pBuf), usLen) : std::string(); + auto r = rc->dd_set_property(openads::network::DDObjectKind::View, + vname, "", usProp, val); + if (!r) return fail(r.error()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + if (dd == nullptr) return ok(); + auto name = openads::abi::to_internal(pucName, 0); + if (!dd->has_view(name)) + return fail(static_cast(openads::AE_NO_FILE_FOUND), name.c_str()); + auto& e = dd->views().at(name); + std::string val = pBuf && usLen > 0 + ? std::string(static_cast(pBuf), usLen) : std::string{}; + switch (usProp) { + case ADS_DD_VIEW_STMT: e.sql = val; break; + case ADS_DD_VIEW_COMMENT: e.comment = val; break; + default: return fail(openads::AE_FUNCTION_NOT_AVAILABLE, ""); + } + auto r = dd->save(); + if (!r) return fail(r.error()); + return ok(); +} + +// --------------------------------------------------------------------------- +// AdsDDGetRefIntegrityProperty / AdsDDSetRefIntegrityProperty +// --------------------------------------------------------------------------- + +UNSIGNED32 ENTRYPOINT AdsDDGetRefIntegrityProperty(ADSHANDLE hConn, UNSIGNED8* pucName, + UNSIGNED16 usProp, void* pBuf, + UNSIGNED16* pusLen) { + arc2_trace("AdsDDGetRefIntegrityProperty"); + if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (auto* rc = get_remote_connection(hConn)) { + auto riname = openads::abi::to_internal(pucName, 0); + auto r = rc->dd_get_property(openads::network::DDObjectKind::RefIntegrity, + riname, "", usProp); + UNSIGNED16 cap = *pusLen; + if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); + if (!r) { *pusLen = 0; return fail(r.error()); } + UNSIGNED16 n = static_cast(std::min(r.value().size(), cap)); + if (pBuf != nullptr && n > 0) std::memcpy(pBuf, r.value().data(), n); + *pusLen = static_cast(r.value().size()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + UNSIGNED16 cap = *pusLen; + if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); + if (dd == nullptr) { *pusLen = 0; return ok(); } + auto name = openads::abi::to_internal(pucName, 0); + const auto& ri = dd->ri(); + auto it = ri.find(name); + if (it == ri.end()) { + *pusLen = 0; + return fail(static_cast(openads::AE_NO_FILE_FOUND), name.c_str()); + } + const auto& e = it->second; + auto put_str = [&](const std::string& s) -> UNSIGNED32 { + UNSIGNED16 n = static_cast(std::min(s.size(), cap)); + if (pBuf != nullptr && n > 0) std::memcpy(pBuf, s.data(), n); + *pusLen = static_cast(s.size()); + return ok(); + }; + auto put_u32 = [&](std::uint32_t v) -> UNSIGNED32 { + if (pBuf != nullptr && cap >= 4) { + auto* b = static_cast(pBuf); + b[0]=static_cast(v&0xFF); b[1]=static_cast((v>>8)&0xFF); b[2]=static_cast((v>>16)&0xFF); b[3]=static_cast((v>>24)&0xFF); + } + *pusLen = 4; return ok(); + }; + + switch (usProp) { + case ADS_DD_RI_PARENT: return put_str(e.parent); + case ADS_DD_RI_CHILD: return put_str(e.child); + case ADS_DD_RI_PARENT_TAG: return put_str(e.parent_tag); + case ADS_DD_RI_CHILD_TAG: return put_str(e.child_tag); + case ADS_DD_RI_UPDATE_RULE: { + try { return put_u32(static_cast(std::stoul(e.update_opt))); } + catch (...) { return put_u32(0); } + } + case ADS_DD_RI_DELETE_RULE: { + try { return put_u32(static_cast(std::stoul(e.delete_opt))); } + catch (...) { return put_u32(0); } + } + case ADS_DD_RI_FAIL_TABLE: return put_str(e.fail_table); + default: *pusLen = 0; return fail(openads::AE_FUNCTION_NOT_AVAILABLE, ""); + } +} + +UNSIGNED32 ENTRYPOINT AdsDDSetRefIntegrityProperty(ADSHANDLE hConn, UNSIGNED8* pucName, + UNSIGNED16 usProp, void* pBuf, + UNSIGNED16 usLen) { + arc2_trace("AdsDDSetRefIntegrityProperty"); + if (auto* rc = get_remote_connection(hConn)) { + auto riname = openads::abi::to_internal(pucName, 0); + std::string val = (pBuf != nullptr && usLen > 0) + ? std::string(reinterpret_cast(pBuf), usLen) : std::string(); + auto r = rc->dd_set_property(openads::network::DDObjectKind::RefIntegrity, + riname, "", usProp, val); + if (!r) return fail(r.error()); + return ok(); + } + auto* dd = dd_from_handle(hConn); + if (dd == nullptr) return ok(); + auto name = openads::abi::to_internal(pucName, 0); + auto& ri = dd->ri(); + auto it = ri.find(name); + if (it == ri.end()) + return fail(static_cast(openads::AE_NO_FILE_FOUND), name.c_str()); + auto& e = it->second; + std::string val = pBuf && usLen > 0 + ? std::string(static_cast(pBuf), usLen) : std::string{}; + switch (usProp) { + case ADS_DD_RI_PARENT: e.parent = val; break; + case ADS_DD_RI_CHILD: e.child = val; break; + case ADS_DD_RI_PARENT_TAG: e.parent_tag = val; break; + case ADS_DD_RI_CHILD_TAG: e.child_tag = val; break; + case ADS_DD_RI_UPDATE_RULE: + if (pBuf && usLen >= 4) { + auto* b = static_cast(pBuf); + std::uint32_t v = static_cast(b[0]) | (static_cast(b[1]) << 8) | (static_cast(b[2]) << 16) | (static_cast(b[3]) << 24); + e.update_opt = std::to_string(v); + } + break; + case ADS_DD_RI_DELETE_RULE: + if (pBuf && usLen >= 4) { + auto* b = static_cast(pBuf); + std::uint32_t v = static_cast(b[0]) | (static_cast(b[1]) << 8) | (static_cast(b[2]) << 16) | (static_cast(b[3]) << 24); + e.delete_opt = std::to_string(v); + } + break; + case ADS_DD_RI_FAIL_TABLE: e.fail_table = val; break; + default: return fail(openads::AE_FUNCTION_NOT_AVAILABLE, ""); + } + auto r = dd->save(); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsCreateFTSIndex(ADSHANDLE hTable, + UNSIGNED8* pucFileName, + UNSIGNED8* pucTag, + UNSIGNED8* pucField, + UNSIGNED32 /*ulPageSize*/, + UNSIGNED32 ulMinWordLen, + UNSIGNED32 ulMaxWordLen, + UNSIGNED16 usUseDefaultDelim, + UNSIGNED8* pucDelimiters, + UNSIGNED16 usUseDefaultNoise, + UNSIGNED8* pucNoiseWords, + UNSIGNED16 /*usUseDefaultDrop*/, + UNSIGNED8* /*pucDropChars*/, + UNSIGNED16 /*usUseDefaultConditionals*/, + UNSIGNED8* /*pucConditionalChars*/, + UNSIGNED8* /*pucReserved1*/, + UNSIGNED8* /*pucReserved2*/, + UNSIGNED32 /*ulOptions*/) { + arc2_trace("AdsCreateFTSIndex"); + Table* t = get_table(hTable); + if (!t || pucTag == nullptr || pucField == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "AdsCreateFTSIndex: null arg"); + } + auto tag = openads::abi::to_internal(pucTag, 0); + auto field = openads::abi::to_internal(pucField, 0); + + namespace fs = std::filesystem; + fs::path p; + if (pucFileName != nullptr && pucFileName[0] != '\0') { + p = openads::abi::to_internal(pucFileName, 0); + } else { + // Compound auto-open form: file lives next to the table with + // the table's stem and a `.fts` extension. + p = fs::path(t->path()).replace_extension(".fts"); + } + if (!p.is_absolute()) { + fs::path tdir = fs::path(t->path()).parent_path(); + p = tdir / p; + } + if (!p.has_extension()) p.replace_extension(".fts"); + + auto opts = build_fts_options(ulMinWordLen, ulMaxWordLen, + usUseDefaultDelim, pucDelimiters, + usUseDefaultNoise, pucNoiseWords); + auto r = openads::engine::Fts::create(*t, p.string(), tag, field, opts); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsGetNumIndexes(ADSHANDLE hTable, UNSIGNED16* pusCount) { + arc2_trace("AdsGetNumIndexes"); + if (pusCount == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (auto* rt = get_remote_table(hTable)) { + // Count indexes opened locally on the RemoteTable (production + // CDX auto-open + explicit AdsOpenIndex). Going to the server + // via get_num_indexes() would return 0 because the server's + // engine handle hasn't opened the production index yet. + *pusCount = static_cast( + std::min(rt->index_handles.size(), + static_cast(0xFFFF))); + return ok(); + } + Table* t = get_table(hTable); + if (!t || pusCount == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, ""); + } + UNSIGNED16 n = 0; + // Storm fix: reader must hold index_bindings_mu (binds are unlocked now). + std::lock_guard _rn2_lk(index_bindings_mu()); + for (auto& [_, b] : index_bindings()) { + if (b.table == t) ++n; + } + *pusCount = n; + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsGetIndexHandle(ADSHANDLE hTable, UNSIGNED8* pucName, + ADSHANDLE* phIndex) { + arc2_trace("AdsGetIndexHandle"); + if (phIndex == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, ""); + } + auto name = openads::abi::to_internal(pucName, 0); + // Strip trailing whitespace + nulls (rddads space-pads tag names + // up to ADS_MAX_TAG_NAME before passing them to us). + while (!name.empty() && (name.back() == ' ' || name.back() == '\0')) { + name.pop_back(); + } + // Remote table: resolve the tag to its wire index handle (parallel to + // index_by_tag). rddads' DbSetOrder("") and FWH xbrowse header + // sort both go through here. + if (auto* rt = get_remote_table(hTable)) { + const std::size_t n = std::min(rt->index_by_tag.size(), + rt->index_handles.size()); + for (std::size_t i = 0; i < n; ++i) { + const std::string& tag = rt->index_by_tag[i].first; + if (tag.size() != name.size()) continue; + bool eq = true; + for (std::size_t k = 0; k < tag.size(); ++k) { + if (std::toupper(static_cast(tag[k])) != + std::toupper(static_cast(name[k]))) { + eq = false; break; + } + } + if (eq) { + *phIndex = static_cast(rt->index_handles[i]); + return ok(); + } + } + // Parked fallback (per-tag rotation resolves orders between + // CloseAll and reopen): parked ids are server-live, so serve + // them exactly like live ones instead of failing. + if (rt->indexes_parked) { + const std::size_t pn = std::min(rt->parked_by_tag.size(), + rt->parked_handles.size()); + for (std::size_t i = 0; i < pn; ++i) { + const std::string& tag = rt->parked_by_tag[i].first; + if (tag.size() != name.size()) continue; + bool eq = true; + for (std::size_t k = 0; k < tag.size(); ++k) { + if (std::toupper(static_cast(tag[k])) != + std::toupper(static_cast(name[k]))) { + eq = false; break; + } + } + if (eq) { + *phIndex = + static_cast(rt->parked_handles[i]); + return ok(); + } + } + } + return fail(openads::AE_INTERNAL_ERROR, "remote index name not found"); + } +#if defined(OPENADS_WITH_POSTGRESQL) + // SQL backend (postgresql): resolve an already-open PG index by its + // tag/column name. AdsOpenIndex creates the PostgresIndex handle; this + // is the by-name lookup path the ORM uses after opening. A PG handle has + // no native Table*, so without this branch the function fell through to + // get_table() below and errored for every PG table. Match the tag the + // way postgres_open_index derives it (strip path + extension). + if (auto* st = get_postgres_table(hTable)) { + std::string tag = name; + 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); + for (auto& [h, si] : postgres_indexes_map()) { + if (si && si->parent == st && si->column == tag) { + *phIndex = static_cast(h); + return ok(); + } + } + return fail(openads::AE_INTERNAL_ERROR, "index name not found"); + } +#endif + Table* t = get_table(hTable); + if (!t) { + return fail(openads::AE_INTERNAL_ERROR, ""); + } + // Storm fix: reader must hold index_bindings_mu (binds are unlocked now). + std::lock_guard _rh_lk(index_bindings_mu()); + for (auto& [h, b] : index_bindings()) { + if (b.table == t && b.tag_name == name) { *phIndex = h; return ok(); } + } + return fail(openads::AE_INTERNAL_ERROR, "index name not found"); +} + + +// Authoritative ordinal sequence of index-binding handles for table `t`. +// For a CDX bag this is the file's struct-tag (creation) order -- what +// ADS / rddads expose through ORDSETFOCUS(N) and OrdNumber(); for NTX it +// is handle-id order. AdsGetIndexHandleByOrder and +// AdsGetIndexOrderByHandle MUST share this so they stay exact inverses. +// +// Harbour rddads' INDEX command (default fAll && !fAdditive) calls +// ORDLSTCLEAR before each AdsCreateIndex61, wiping every binding we held +// for the table even though the on-disk CDX bag still lists all prior +// tags. So any tag in the active CDX that lost its binding is lazily +// re-bound here. +// +// extern "C++": this file's ACE exports live in an extern "C" block, but +// this internal helper returns a C++ UDT (std::vector) -- give it C++ +// linkage so MSVC doesn't warn C4190. +extern "C++" std::vector ordered_index_handles_for(Table* t) { + // Caller may already hold index_bindings_mu (recursive; storm fix -- + // this helper reads the map and can bind extra tags). + std::lock_guard _oh_lk(index_bindings_mu()); + auto& m = index_bindings(); + auto& act = active_binding_for(); + std::string bag_path; + auto act_it = act.find(t); + if (act_it != act.end()) { + auto bit = m.find(act_it->second); + if (bit != m.end()) bag_path = bit->second.path; + } + // No active binding (e.g. every tag parked after an ORDLSTCLEAR): fall + // back to any CDX binding's bag so we can still recover file order. + if (bag_path.empty()) { + for (auto& [h, b] : m) { + if (b.table == t && path_ends_with_ci(b.path, ".cdx")) { + bag_path = b.path; + break; + } + } + } + std::vector ordered; + if (!bag_path.empty() + && path_ends_with_ci(bag_path, ".cdx")) { + auto tags = openads::drivers::cdx::CdxIndex::list_tags(bag_path); + if (tags) { + for (const auto& tag : tags.value()) { + ADSHANDLE found = 0; + for (auto& [h, b] : m) { + if (b.table == t && b.tag_name == tag) { + found = h; break; + } + } + if (!found) { + auto sub = std::make_unique(); + if (auto r = sub->open_named(bag_path, + openads::drivers::IndexOpenMode::Shared, + tag); !r) continue; + mark_cdx_key_encoding(t, sub.get()); + apply_cdx_oem_collation(t, sub.get()); + ADSHANDLE nh = next_index_handle(); + openads::drivers::IIndex* raw = sub.get(); + m[nh] = IndexBinding{t, tag, std::move(sub), bag_path}; + t->register_extra_index_view(raw); + found = nh; + } + ordered.push_back(found); + } + } + } + if (ordered.empty()) { + // Fall back to handle-id ordering for non-CDX (NTX) cases. + for (auto& [h, b] : index_bindings()) { + if (b.table == t) ordered.push_back(h); + } + std::sort(ordered.begin(), ordered.end()); + } + return ordered; +} + +UNSIGNED32 ENTRYPOINT AdsGetIndexHandleByOrder(ADSHANDLE hTable, UNSIGNED16 usOrder, + ADSHANDLE* phIndex) { + arc2_trace("AdsGetIndexHandleByOrder"); + if (phIndex == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + // Remote table: resolve the ordinal to one of the wire index handles + // registered at open (production auto-open) / AdsOpenIndex. This is the + // path rddads' DbSetOrder() takes -- without it, CDX falls back + // to natural order and remote browses show no index. + if (auto* rt = get_remote_table(hTable)) { + const std::vector& handles = + rt->index_handles.empty() && rt->indexes_parked + ? rt->parked_handles + : rt->index_handles; + if (handles.empty()) { + return fail(openads::AE_INTERNAL_ERROR, "no remote index"); + } + std::size_t idx = (usOrder == 0 || usOrder > handles.size()) + ? 0 : static_cast(usOrder - 1); + *phIndex = static_cast(handles[idx]); + return ok(); + } + Table* t = get_table(hTable); + if (!t) { + return fail(openads::AE_INTERNAL_ERROR, ""); + } + std::vector ordered = ordered_index_handles_for(t); + if (ordered.empty()) { + return fail(openads::AE_INTERNAL_ERROR, "no active index"); + } + if (usOrder == 0 || usOrder > ordered.size()) { + // Fall back to first entry on out-of-range ordinal so legacy + // callers that pass 0 or 1 still resolve to *some* index. + *phIndex = ordered.front(); + } else { + *phIndex = ordered[usOrder - 1]; + } + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsGetIndexExpr(ADSHANDLE hIndex, UNSIGNED8* pucBuf, + UNSIGNED16* pusBufLen) { + arc2_trace("AdsGetIndexExpr"); + if (auto* ri = get_remote_index(hIndex)) { + openads::abi::copy_to_caller(pucBuf, pusBufLen, ri->expression); + return ok(); + } + auto& m = index_bindings(); + // Storm fix: readers must hold index_bindings_mu now that OpenIndex + // binds outside the lock (map erase concurrent with .find = UAF). + std::lock_guard _rb_lk(index_bindings_mu()); + auto it = m.find(hIndex); + if (it == m.end()) { + return fail(openads::AE_INTERNAL_ERROR, "unknown index"); + } + // Pull the expression from whichever IIndex carries it: parked + // binding has its own; the active one's IIndex sits on the Table. + std::string expr; + if (it->second.parked) { + expr = it->second.parked->expression(); + } else if (it->second.table && it->second.table->order() + && it->second.table->order()->index()) { + expr = it->second.table->order()->index()->expression(); + } + openads::abi::copy_to_caller(pucBuf, pusBufLen, expr); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsGetIndexName(ADSHANDLE hIndex, UNSIGNED8* pucBuf, + UNSIGNED16* pusBufLen) { + arc2_trace("AdsGetIndexName"); + if (auto* ri = get_remote_index(hIndex)) { + openads::abi::copy_to_caller(pucBuf, pusBufLen, ri->tag_name); + return ok(); + } + auto& m = index_bindings(); + // Storm fix: reader must hold index_bindings_mu (binds are unlocked now). + std::lock_guard _rn_lk(index_bindings_mu()); + auto it = m.find(hIndex); + if (it == m.end()) { + return fail(openads::AE_INTERNAL_ERROR, "unknown index"); + } + openads::abi::copy_to_caller(pucBuf, pusBufLen, it->second.tag_name); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsSetIndexDirection(ADSHANDLE hIndex, UNSIGNED16 usDir) { + arc2_trace("AdsSetIndexDirection"); + auto& s = state(); + std::lock_guard lk(s.mu); + // Storm fix: reader must hold index_bindings_mu (binds are unlocked now). + std::lock_guard _sd_lk(index_bindings_mu()); + auto it = index_bindings().find(hIndex); + if (it == index_bindings().end()) { + return fail(openads::AE_INTERNAL_ERROR, "unknown index"); + } + Table* t = it->second.table; + if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); + (void)activate_binding(hIndex); + auto* o = t->order(); + if (o == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "no active order"); + } + // Real ADS semantics: AdsSetIndexDirection REVERSES the current + // traversal direction -- it is not an absolute set. rddads' / X#'s + // OrdDescend() proves this: the RDD reads the current direction + // (AdsIsIndexDescending) and, when it differs from the requested one, + // calls AdsSetIndexDirection(hIndex, TRUE) -- always TRUE, in both the + // asc→desc and desc→asc cases. So TRUE means "flip", not "descend". + // Treating usDir as an absolute 0/1 wedged FWH xbrowse header sorting: + // the first double-click flipped to descending and every later click + // re-requested TRUE, which an absolute set left descending forever. + // (void)usDir -- the value is a legacy flag; the operation is a toggle. + (void)usDir; + auto* mo = const_cast(o); + mo->set_descending_traverse(!mo->descending_traverse()); + return ok(); +} + +// ACE / rddads signature: 6 args. +// AdsSeek(hIndex, pucKey, u16KeyLen, u16KeyType, u16SeekType, &u16Found) +// +// u16KeyType : ADS_STRINGKEY / ADS_NUMERICKEY / ... -- describes +// pucKey's encoding. We accept whatever the caller sends +// and pass the bytes through as-is; the engine compares +// on raw bytes after padding to the index's key length. +// u16SeekType : 0 = exact (hard), 1 = soft. Bit 1 = AfterKey. +// rddads' hb_adsUpdateAreaFlags asks AdsIsFound after every seek to +// decide whether Found() should report .T. -- return the flag the +// engine set inside seek_key. +UNSIGNED32 ENTRYPOINT AdsIsFound(ADSHANDLE hTable, UNSIGNED16* pbFound) { + arc2_trace("AdsIsFound"); + if (pbFound == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (auto* rt = get_remote_table(hTable)) { + // M12.21 option C -- serve Found() locally when a nav/seek op set + // it (the common scan case: Skip clears it), saving a round-trip + // on every step. Fall back to the server only when uncached. + if (rt->found_cached) { *pbFound = rt->current_found ? 1 : 0; return ok(); } + 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; + return ok(); +} + +// Local (DBF / ADT) tail shared by AdsSeek and AdsSeekLast. Everything up +// to the actual positioning -- the ADS_DOUBLEKEY / date / logical / Fox- +// numeric key conversions -- is identical for both, and only the final +// Table::seek_key differs: fLast walks to the END of the equal-key run. +// AdsSeekLast used to just call AdsSeek, which always seeks with +// last = false, so it returned the FIRST record of the group. With a +// partial key that is the first line of the document where the caller +// asked for the last one -- a silently wrong row, not an error. +static UNSIGNED32 ads_seek_local(ADSHANDLE hIndex, + UNSIGNED8* pucKey, + UNSIGNED16 u16KeyLen, + UNSIGNED16 u16KeyType, + UNSIGNED16 u16SeekType, + UNSIGNED16* pbFound, + bool find_last) { + Table* t = table_for_index(hIndex); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown index"); + std::string key; + (void)u16KeyType; + // Numeric seek keys arrive as sizeof(double) raw IEEE bytes. The ADS SDK + // names this ADS_DOUBLEKEY (2), but Harbour's rddads tags a numeric dbSeek + // with the field DATA type (ADS_STRING==4 in this ABI) -- so gating on + // u16KeyType==ADS_DOUBLEKEY missed EVERY rddads numeric seek (the key fell + // through as 8 raw double bytes and never matched the stored ASCII key). + // Detect a double key by length + a numeric (ASCII-stored) active index + // field instead, and convert to the same right-aligned ASCII the index + // holds. Character / non-numeric indexes keep the raw-bytes path. + auto* dk_idx = (t->order() != nullptr) ? t->order()->index() : nullptr; + // Strip the `ALIAS->` qualifier the way the write side does + // (evaluate_index_expr -> strip_alias_qualifiers) so a tag built from + // `FIELD->ID` still resolves the field. + std::int32_t dk_fidx = (dk_idx != nullptr) + ? t->field_index( + openads::engine::strip_alias_qualifiers(dk_idx->expression())) + : -1; + bool dk_numeric = false; + bool dk_date = false; + bool dk_logical = false; + if (dk_fidx >= 0) { + auto dkt = t->field_descriptor( + static_cast(dk_fidx)).type; + dk_numeric = (dkt == openads::drivers::DbfFieldType::Numeric || + dkt == openads::drivers::DbfFieldType::Float); + // DBF Date only: CDX/NTX date keys are raw YYYYMMDD text. ADT + // AdtDate keys are ADI packed binary -- the ADI driver converts + // a raw-double seek key itself, so leave those on the raw path. + dk_date = (dkt == openads::drivers::DbfFieldType::Date); + dk_logical = (dkt == openads::drivers::DbfFieldType::Logical); + } + const bool dk_foxnum = + dk_idx != nullptr && + dk_idx->key_encoding() == openads::drivers::KeyEncoding::FoxNumeric && + u16KeyLen == sizeof(double); + const bool dk_ntxnum = + dk_idx != nullptr && + dk_idx->key_encoding() == openads::drivers::KeyEncoding::NtxNumeric && + u16KeyLen == sizeof(double); + if (dk_foxnum) { + // Numeric CDX key: encode the seek value the same 8-byte + // order-preserving way the stored keys were written. + double dv = 0; + std::memcpy(&dv, pucKey, sizeof(double)); + key = openads::engine::fox_numeric_key(dv); + } else if (dk_ntxnum) { + // Numeric NTX key: encode the seek value the same native zero-padded + // (negatives byte-complemented) way the stored keys were written. + double dv = 0; + std::memcpy(&dv, pucKey, sizeof(double)); + key = openads::engine::ntx_numeric_key(dv, dk_idx->key_length(), + dk_idx->key_decimals()); + } else if (dk_idx != nullptr && dk_date && u16KeyLen == sizeof(double)) { + // Date seek: rddads sends DbSeek(date) as a julian day number + // double (hb_itemGetTD). Our date keys are raw YYYYMMDD text, + // so convert the julian day to that key form (mirrors the + // AdsSetScope date path). + double dv = 0; + std::memcpy(&dv, pucKey, sizeof(double)); + int y = 0, mo = 0, dy = 0; + julian_to_ymd(static_cast(dv), y, mo, dy); + char dbuf[16]; + std::snprintf(dbuf, sizeof(dbuf), "%04d%02d%02d", y, mo, dy); + key.assign(dbuf, 8); + std::uint16_t dklen = dk_idx->key_length(); + if (key.size() < dklen) key.append(dklen - key.size(), ' '); + } else if (dk_idx != nullptr && dk_numeric && u16KeyLen == sizeof(double)) { + double dv = 0; + std::memcpy(&dv, pucKey, sizeof(double)); + std::uint16_t klen = dk_idx->key_length(); + // Format width matches the FIELD width (eg N,10,0 -> 10), not a stale + // index key_length (eg INDEX-on-empty-table), so the right-aligned + // padding equals what evaluate_index_expr wrote at build/sync time. + const auto& fd = t->field_descriptor( + static_cast(dk_fidx)); + std::uint16_t dec = static_cast(fd.decimals); + std::uint16_t fmt_w = (fd.length > 0) + ? static_cast(fd.length) : klen; + // Buffer holds the widest valid xBase field width (255) plus + // sign, decimal point and NUL. snprintf is length-bounded and we + // assign only what it actually produced (clamped to the buffer), + // so an out-of-range fmt_w can never overread the stack buffer. + char buf[264]; + int n = (dec > 0) + ? std::snprintf(buf, sizeof(buf), "%*.*f", + static_cast(fmt_w), + static_cast(dec), dv) + : std::snprintf(buf, sizeof(buf), "%*.0f", + static_cast(fmt_w), dv); + std::size_t take = (n < 0) + ? 0u + : std::min(static_cast(n), + sizeof(buf) - 1); + // Pad to klen with trailing spaces (matches how + // evaluate_index_expr right-pads the field's raw bytes). + key.assign(buf, take); + if (key.size() < klen) key.append(klen - key.size(), ' '); + } else { + key.assign(reinterpret_cast(pucKey), + static_cast(u16KeyLen)); + } + + // rddads logical seek: Harbour's adsSeek sends DbSeek(.T.)/(.F.) as + // the 1-byte strings "1"/"0" (ads1.c HB_IS_LOGICAL branch), while a + // DBF logical index stores the raw field byte 'T'/'F'. SAP ACE maps + // the seek value onto the stored form; do the same or EVERY seek on a + // logical-key tag misses (GitHub #131 defect B). DBF-family only: + // ADT logical keys are ADI packed doubles and AdiIndex::seek_key + // already parses the "1"/"0" ASCII form itself. + if (dk_logical && u16KeyLen == 1 && + (pucKey[0] == '1' || pucKey[0] == '0') && + !path_ends_with_ci(t->path(), ".adt")) { + key = (pucKey[0] == '1') ? "T" : "F"; + } + + // Robustness for rddads/Val() case: if the index is FoxNumeric but the key arrived as string + // (e.g. rddads sent the digit string for a Val() tag), convert it to the 8-byte fox key. + // This helps when the key type detection in rddads sends string bytes for numeric expr tags. + if (dk_idx && dk_idx->key_encoding() == openads::drivers::KeyEncoding::FoxNumeric && key.size() != sizeof(double)) { + // try parse the received bytes as number string + std::string sk((const char*)pucKey, u16KeyLen); + // trim spaces + size_t start = sk.find_first_not_of(" \t"); + if (start != std::string::npos) { + size_t end = sk.find_last_not_of(" \t"); + sk = sk.substr(start, end - start + 1); + } + char* e = nullptr; + double dv = strtod(sk.c_str(), &e); + if (e != sk.c_str()) { + key = openads::engine::fox_numeric_key(dv); + } + } + bool soft = (u16SeekType & 0x01) != 0; + bool zero_length_key = (u16KeyLen == 0); + // Clipper / DBFCDX quirk: a zero-length seek key (`DBSEEK( "" )`) + // matches every record. Skip the underlying B+tree compare and + // walk straight to the first / last record (depending on the + // SeekLast retry latch). The seek_last_retry_latch is set only + // by AdsSeekLast -- meaning the caller is bFindLast=TRUE, in + // which case we want the LAST entry in ASC traversal direction + // (DBFCDX hb_cdxSeek with fLast = TRUE returns the same record + // as soft + skip-to-end-of-key-group; the empty key has only + // one "group" so first/last collapse to first under our walk). + if (zero_length_key && t->record_count() > 0) { + // DBFCDX: empty key always matches; soft-or-hard, asc-only. + // For DESCEND orders the empty key falls through to the + // regular seek path so DBFCDX's CDX_MAX_REC_NUM / fLast + // inversion takes effect -- `DBSEEK("",T,T)` on a DESCEND + // tag is expected to miss (Eof), not land on the bottom. + bool desc = (t->order() != nullptr && + t->order()->descending_traverse()); + if (!desc) { + auto gt = t->goto_top(); + if (!gt) return fail(gt.error()); + if (pbFound != nullptr) *pbFound = 1; + t->set_last_seek_found(true); + snapshot_ri_pks(t); + apply_relations_for_table(t); + return ok(); + } + } + auto r = t->seek_key(key, soft, find_last); + if (!r) return fail(r.error()); + bool found = r.value(); + if (pbFound != nullptr) *pbFound = found ? 1 : 0; + snapshot_ri_pks(t); + apply_relations_for_table(t); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsSeek(ADSHANDLE hIndex, + UNSIGNED8* pucKey, + UNSIGNED16 u16KeyLen, + UNSIGNED16 u16KeyType, + UNSIGNED16 u16SeekType, + UNSIGNED16* pbFound) { + arc2_trace("AdsSeek"); +#if defined(OPENADS_WITH_SQLITE) + if (auto* si = get_sqlite_index(hIndex)) { + if (si->parent == nullptr || si->parent->conn == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "sqlite 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(); + sql_uri_mark_index_full(si->parent); + si->last_seek_found = found; + if (pbFound) *pbFound = found ? 1 : 0; + (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(); + sql_uri_mark_index_full(si->parent); + si->last_seek_found = found; + if (pbFound) *pbFound = found ? 1 : 0; + (void)u16KeyType; + return ok(); + } +#endif +#if defined(OPENADS_WITH_FIREBIRD) + if (auto* si = get_firebird_index(hIndex)) { + if (si->parent == nullptr || si->parent->conn == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "firebird 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(); + sql_uri_mark_index_full(si->parent); + 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(); + sql_uri_mark_index_full(si->parent); + si->last_seek_found = found; + if (pbFound) *pbFound = found ? 1 : 0; + (void)u16KeyType; + return ok(); + } +#endif +#if defined(OPENADS_WITH_MSSQL) + if (auto* si = get_mssql_index(hIndex)) { + if (si->parent == nullptr || si->parent->conn == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "mssql index orphan"); + } + std::string key(reinterpret_cast(pucKey), u16KeyLen); + const bool soft = (u16SeekType & 1u) != 0; + 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(); + sql_uri_mark_index_full(si->parent); + si->last_seek_found = found; + si->parent->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(); + sql_uri_mark_index_full(si->parent); + si->last_seek_found = found; + if (pbFound) *pbFound = found ? 1 : 0; + (void)u16KeyType; + return ok(); + } +#endif + if (auto* ri = get_remote_index(hIndex)) { + std::string key(reinterpret_cast(pucKey), + u16KeyLen); + if (ri->parent) { + if (UNSIGNED32 frc = remote_flush_pending(ri->parent); frc != 0) return frc; + ri->parent->row_valid = false; // M12.17 + // RCB 07/14/2026: BUG FIX -- this path invalidated the cached row + // but not the lookahead queue. A seek repositions the server cursor + // ABSOLUTELY, so every queued row belongs to the pre-seek position + // and the consumed-lag counter no longer describes anything real. + // Left as it was, the next AdsSkip(1) popped a stale PRE-SEEK row + // and returned it as the current record with no wire traffic at all + // -- nothing on the network to make it look wrong -- and the wire skip + // after that sent (step + a lag that no longer applied). + ri->parent->invalidate_prefetch(); + cli_trace_tbl(ri->parent, "AdsSeek", "key_len=%u", + static_cast(u16KeyLen)); + } + if (ri->parent != nullptr && remote_empty_window(ri->parent)) { + cli_trace_tbl(ri->parent, "AdsSeek", + "empty window: not found (local)"); + remote_empty_restamp(ri->parent); + ri->parent->found_cached = true; + ri->parent->current_found = false; + if (pbFound) *pbFound = 0; + (void)u16KeyType; + return ok(); + } + // RCB 07/14/2026: M12.24 -- pass the parent so the SeekAck's row trailer + // lands straight in the row cache. Without it row_valid stays false and + // the caller's next AdsGetField pays a FetchCurrentRow round-trip, i.e. + // seek-then-read costs 2 RTTs instead of 1. + auto r = ri->conn->seek(ri->id, key, + static_cast(u16SeekType), + /*last=*/0, ri->parent); + if (!r) return fail(r.error()); + if (pbFound) *pbFound = r.value().hit; + if (ri->parent) { // M12.21 option C + ri->parent->found_cached = true; + ri->parent->current_found = (r.value().hit != 0); + // FIX(seek-nav): a seek repositions the cursor ABSOLUTELY, so the + // client-side nav_at_bof / nav_at_eof flags inherited from the + // pre-seek position are stale. Left as they were, a browse walk + // that ended at EOF left nav_at_eof set; the next FOUND seek then + // made AdsAtEOF report true on a live record, rddads' + // hb_adsUpdateAreaFlags cleared fPositioned, and adsGetValue + // served blank strings for CHARACTER fields without calling ADS + // at all (Vouch: "SEEK succeeds but where are the field + // values?"). Numeric/logical/date/memo reads bypass that + // fPositioned guard, which is why only strings went blank. + // A miss with recno==0 genuinely lands at EOF (hard miss, or + // soft miss with no higher key); anything else is a live row. + // Piggyback guard: a current server already certified all of + // the above (plus the bound cache and recno) in the SeekAck + // tail — the manual reset below is the old-server fallback + // only. Presence check: the piggyback stamps bound_seq to the + // current seq, and seek() bumped it on entry, so equality + // holds iff the tail arrived. + if (ri->parent->bound_seq != ri->parent->conn->nav_seq()) { + if (r.value().hit != 0 || r.value().recno != 0) { + ri->parent->nav_at_bof = false; + ri->parent->nav_at_eof = false; + } else { + ri->parent->nav_at_bof = false; + ri->parent->nav_at_eof = true; + } + } + } + (void)u16KeyType; + return ok(); + } + return ads_seek_local(hIndex, pucKey, u16KeyLen, u16KeyType, + u16SeekType, pbFound, /*find_last=*/false); +} + +UNSIGNED32 ENTRYPOINT AdsSeekLast(ADSHANDLE hIndex, + UNSIGNED8* pucKey, + UNSIGNED16 u16KeyLen, + UNSIGNED16 u16KeyType, + UNSIGNED16* pbFound) { + arc2_trace("AdsSeekLast"); +#if defined(OPENADS_WITH_SQLITE) + if (auto* si = get_sqlite_index(hIndex)) { + if (si->parent == nullptr || si->parent->conn == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "sqlite 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(); + sql_uri_mark_index_full(si->parent); + si->last_seek_found = found; + if (pbFound) *pbFound = found ? 1 : 0; + (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(); + sql_uri_mark_index_full(si->parent); + si->last_seek_found = found; + if (pbFound) *pbFound = found ? 1 : 0; + (void)u16KeyType; + return ok(); + } +#endif +#if defined(OPENADS_WITH_FIREBIRD) + if (auto* si = get_firebird_index(hIndex)) { + if (si->parent == nullptr || si->parent->conn == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "firebird 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(); + sql_uri_mark_index_full(si->parent); + 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(); + sql_uri_mark_index_full(si->parent); + si->last_seek_found = found; + if (pbFound) *pbFound = found ? 1 : 0; + (void)u16KeyType; + return ok(); + } +#endif +#if defined(OPENADS_WITH_MSSQL) + if (auto* si = get_mssql_index(hIndex)) { + if (si->parent == nullptr || si->parent->conn == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "mssql index orphan"); + } + std::string key(reinterpret_cast(pucKey), u16KeyLen); + 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(); + sql_uri_mark_index_full(si->parent); + si->last_seek_found = found; + si->parent->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(); + sql_uri_mark_index_full(si->parent); + si->last_seek_found = found; + if (pbFound) *pbFound = found ? 1 : 0; + (void)u16KeyType; + return ok(); + } +#endif + if (auto* ri = get_remote_index(hIndex)) { + std::string key(reinterpret_cast(pucKey), + u16KeyLen); + if (ri->parent) { + if (UNSIGNED32 frc = remote_flush_pending(ri->parent); frc != 0) return frc; + ri->parent->row_valid = false; // M12.17 + // RCB 07/14/2026: same stale-queue bug as AdsSeek -- see the note + // there for why dropping the block is mandatory after a seek. + ri->parent->invalidate_prefetch(); + cli_trace_tbl(ri->parent, "AdsSeekLast", "key_len=%u", + static_cast(u16KeyLen)); + } + auto r = ri->conn->seek(ri->id, key, + /*soft=*/0, + /*last=*/1, ri->parent); // M12.24 -- see AdsSeek + if (!r) return fail(r.error()); + if (pbFound) *pbFound = r.value().hit; + if (ri->parent) { // M12.21 option C + ri->parent->found_cached = true; + ri->parent->current_found = (r.value().hit != 0); + // FIX(seek-nav): same stale nav_at_bof / nav_at_eof reset as + // AdsSeek -- see the full note there. Same piggyback guard: + // a certified SeekLastAck tail already set everything. + if (ri->parent->bound_seq != ri->parent->conn->nav_seq()) { + if (r.value().hit != 0 || r.value().recno != 0) { + ri->parent->nav_at_bof = false; + ri->parent->nav_at_eof = false; + } else { + ri->parent->nav_at_bof = false; + ri->parent->nav_at_eof = true; + } + } + } + (void)u16KeyType; + return ok(); + } + // Mark the AdsSeekLast cycle for rddads' retry chain (see the latch + // definition). The fLast walk itself does NOT ride on the latch -- + // it is passed explicitly, because a latch left set by an early + // return would turn the next plain AdsSeek into a seek-last. + seek_last_retry_latch() = true; + return ads_seek_local(hIndex, pucKey, u16KeyLen, u16KeyType, + /*soft*/ 0, pbFound, /*find_last=*/true); +} + +// SAP / rddads signature: 5 args. +// AdsSetScope(hIndex, usScope, pucScope, usLen, usDataType) +// +// usLen : explicit scope-key length. Required because typed +// keys (ADS_DOUBLEKEY, ADS_RAWKEY) legally contain +// embedded NULs that strlen() would truncate. +// usDataType : ADS_STRINGKEY / ADS_RAWKEY / ADS_DOUBLEKEY / ... -- +// matches AdsSeek's u16KeyType. We mirror AdsSeek's +// ADS_DOUBLEKEY -> ASCII-padded conversion so a scope +// set with a double compares apples-to-apples against +// the index's stored key bytes. +// A scope bound longer than the index key is meaningless -- trim it. Bounds +// SHORTER than the key are NOT padded: they bound by prefix, and the engine +// compares only the bytes the caller supplied (Table::key_in_*_scope_). +// Padding them to key_length used to be done here to make an unpadded bound +// on a single-field tag work, but it broke the partial-key case it shares the +// path with: a 10-byte CON+DOC bound on a CON+DOC+STR(SEQ,6,0) tag became +// "CON+DOC" + 6 spaces, which every real key sorts after, emptying the scope. +static void trim_scope_key_to_index(Table* t, std::string& key) { + if (t == nullptr || key.empty()) return; + if (t->order() == nullptr || t->order()->index() == nullptr) return; + const std::uint16_t klen = t->order()->index()->key_length(); + if (key.size() > klen) key.resize(klen); +} + +UNSIGNED32 ENTRYPOINT AdsSetScope(ADSHANDLE hIndex, UNSIGNED16 usScope, + UNSIGNED8* pucScope, UNSIGNED16 usLen, + UNSIGNED16 usDataType) { + arc2_trace("AdsSetScope"); + if (auto* ri = get_remote_index(hIndex)) { + if (ri->parent) { + if (UNSIGNED32 frc = remote_flush_pending(ri->parent); frc != 0) return frc; + } + std::string key = pucScope + ? openads::abi::to_internal(pucScope, usLen) : std::string(); + auto r = ri->conn->set_scope(ri->id, usScope, key, + usDataType); + if (!r) return fail(r.error()); + // The scoped key count and every keyno/rel-pos value derived + // from it just changed; recompute lazily on next use. Also drop + // read-ahead rows -- they were read under the old scope. + // A scope once set disqualifies the table from lazy-close + // pooling (server index state is no longer fresh-open shaped). + if (ri->parent != nullptr) { + ri->parent->key_count_cached = false; + ri->parent->key_counts.clear(); + ri->parent->keyno_valid = false; + ri->parent->invalidate_prefetch(); + ri->parent->scope_touched = true; + // Visibility may have narrowed to empty (or widened): drop + // proven-false boundary answers and the empty sticky. + ri->parent->nav_not_bof = false; + ri->parent->nav_not_eof = false; + ri->parent->last_nav = 0; + remote_nav_bound_clear(ri->parent); + } + return ok(); + } + Table* t = table_for_index(hIndex); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown index"); + std::string key; + if (usDataType == ADS_DOUBLEKEY && usLen == sizeof(double) && + pucScope != nullptr && + t->order() != nullptr && t->order()->index() != nullptr) { + double dv = 0; + std::memcpy(&dv, pucScope, sizeof(double)); + auto* idx = t->order()->index(); + std::uint16_t klen = idx->key_length(); + std::uint16_t fmt_w = klen; + std::uint16_t dec = 0; + if (idx->key_encoding() == + openads::drivers::KeyEncoding::FoxNumeric) { + // Numeric CDX scope key: same 8-byte order-preserving encoding + // as the stored keys, not ASCII. + key = openads::engine::fox_numeric_key(dv); + auto rsc = t->set_scope(usScope == ADS_TOP, key); + if (!rsc) return fail(rsc.error()); + return ok(); + } + // Strip the `ALIAS->` qualifier the way the write side does + // (evaluate_index_expr -> strip_alias_qualifiers); otherwise a + // tag built from `FIELD->ID` never resolves the field, fmt_w + // stays at the stale key_length, and the numeric seek key is + // padded to a different width than the stored key. + std::int32_t fidx = t->field_index( + openads::engine::strip_alias_qualifiers(idx->expression())); + if (fidx >= 0) { + const auto& fd = t->field_descriptor( + static_cast(fidx)); + // DBF Date key: rddads sends OrdScope() date values as a + // julian day number double (hb_itemGetDL) once AdsGetKeyType + // reports ADS_DATE. CDX/NTX date keys are raw YYYYMMDD text, + // so convert the julian day to that key form instead of + // ASCII-formatting the raw number. (ADT AdtDate keys are ADI + // packed binary and stay on the generic path.) + if (fd.type == openads::drivers::DbfFieldType::Date) { + int y = 0, mo = 0, dy = 0; + julian_to_ymd(static_cast(dv), y, mo, dy); + char dbuf[16]; + std::snprintf(dbuf, sizeof(dbuf), "%04d%02d%02d", + y, mo, dy); + key.assign(dbuf, 8); + if (key.size() < klen) key.append(klen - key.size(), ' '); + auto rsc = t->set_scope(usScope == ADS_TOP, key); + if (!rsc) return fail(rsc.error()); + return ok(); + } + dec = static_cast(fd.decimals); + if (fd.length > 0) + fmt_w = static_cast(fd.length); + } + // Length-bounded format + clamped assign: an out-of-range fmt_w + // can never overread the buffer (buf sized for the widest valid + // xBase field width plus sign, separator and NUL). + char buf[264]; + int n = (dec > 0) + ? std::snprintf(buf, sizeof(buf), "%*.*f", + static_cast(fmt_w), + static_cast(dec), dv) + : std::snprintf(buf, sizeof(buf), "%*.0f", + static_cast(fmt_w), dv); + std::size_t take = (n < 0) + ? 0u + : std::min(static_cast(n), + sizeof(buf) - 1); + key.assign(buf, take); + if (key.size() < klen) key.append(klen - key.size(), ' '); + } else { + key = pucScope + ? openads::abi::to_internal(pucScope, usLen) : std::string(); + // rddads passes hb_itemGetCLen() -- the trimmed string length. That + // shorter-than-the-key bound is handled by prefix comparison in the + // engine, so it is stored as sent; only an over-long bound is trimmed. + trim_scope_key_to_index(t, key); + } + auto r = t->set_scope(usScope == ADS_TOP, key); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsClearScope(ADSHANDLE hIndex, UNSIGNED16 usScope) { + arc2_trace("AdsClearScope"); + if (auto* ri = get_remote_index(hIndex)) { + if (ri->parent) { + if (UNSIGNED32 frc = remote_flush_pending(ri->parent); frc != 0) return frc; + } + auto r = ri->conn->clear_scope(ri->id, usScope); + if (!r) return fail(r.error()); + if (ri->parent != nullptr) { + ri->parent->key_count_cached = false; + ri->parent->key_counts.clear(); + ri->parent->keyno_valid = false; + ri->parent->invalidate_prefetch(); + // Widening can un-empty the cursor: expire the nav stamp + // and cached boundary answers (proven-false answers survive + // widening, but uniformity beats auditing every path). + ri->parent->last_nav = 0; + ri->parent->nav_not_bof = false; + ri->parent->nav_not_eof = false; + remote_nav_bound_clear(ri->parent); + } + return ok(); + } + Table* t = table_for_index(hIndex); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown index"); + auto r = t->clear_scope(usScope == ADS_TOP); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsGetScope(ADSHANDLE hIndex, UNSIGNED16 usScope, + UNSIGNED8* pucBuf, UNSIGNED16* pusLen) { + arc2_trace("AdsGetScope"); + if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + // rddads' ADSKEYCOUNT (filter option != ADS_IGNOREFILTERS) seeds + // *pusLen with sizeof(buffer) then calls AdsGetScope. A failure that + // leaves *pusLen untouched makes the caller think a scope is set and + // enter the key-walk branch -- which returns KeyCount=0 and blanks + // FWH xBrowse over remote tables/indexes. Remote handles carry no + // client-side scope; report empty scope explicitly. + if (get_remote_index(hIndex) != nullptr || get_remote_table(hIndex)) { + *pusLen = 0; + return ok(); + } + Table* t = table_for_index(hIndex); + if (!t) { + *pusLen = 0; + return fail(openads::AE_INTERNAL_ERROR, "unknown index"); + } + auto s = t->get_scope(usScope == ADS_TOP); + openads::abi::copy_to_caller(pucBuf, pusLen, s.value_or("")); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsPackTable(ADSHANDLE hTable) { + arc2_trace("AdsPackTable"); + if (auto* rt = get_remote_table(hTable)) { + if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; + rt->row_valid = false; // M12.17/19 + rt->rec_count_cached = false; + rt->key_count_cached = false; + rt->key_counts.clear(); + rt->key_counts.clear(); + rt->nav_not_bof = false; // row removal can empty the cursor + rt->nav_not_eof = false; + auto r = rt->conn->pack_table(rt->id); + if (!r) return fail(r.error()); + return ok(); + } + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + auto r = t->pack(); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsPackTable120(ADSHANDLE hTable, + UNSIGNED32 /*ulMemoBlockSize*/, + UNSIGNED32 ulOptions) { + arc2_trace("AdsPackTable120"); + if (ulOptions != 0) { + return fail(openads::AE_INTERNAL_ERROR, "reserved options must be zero"); + } + return AdsPackTable(hTable); +} + +UNSIGNED32 ENTRYPOINT AdsZapTable(ADSHANDLE hTable) { + arc2_trace("AdsZapTable"); + if (auto* rt = get_remote_table(hTable)) { + if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; + rt->row_valid = false; // M12.17/19 + rt->rec_count_cached = false; + rt->key_count_cached = false; + rt->key_counts.clear(); + rt->key_counts.clear(); + rt->nav_not_bof = false; // row removal can empty the cursor + rt->nav_not_eof = false; + auto r = rt->conn->zap_table(rt->id); + if (!r) return fail(r.error()); + return ok(); + } + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + auto r = t->zap(); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsCopyTable(ADSHANDLE hHandle, + UNSIGNED16 /*usFilterOption*/, + UNSIGNED8* pucFile) { + arc2_trace("AdsCopyTable"); + if (pucFile == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "null target"); + } + Table* t = get_table(hHandle); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + if (!t->driver()) return fail(openads::AE_INTERNAL_ERROR, "no driver"); + + namespace fs = std::filesystem; + auto raw = openads::abi::to_internal(pucFile, 0); + fs::path dst(raw); + if (!dst.is_absolute()) { + fs::path src_dir = fs::path(t->path()).parent_path(); + dst = src_dir / dst; + } + if (!dst.has_extension()) dst.replace_extension(".dbf"); + + // Build a new DBF that mirrors the source schema. Copy live + // records (deleted rows skipped -- filter options beyond + // ADS_RESPECTFILTERS land later). + const auto& src_fields = t->driver()->fields(); + if (src_fields.empty()) { + return fail(openads::AE_INTERNAL_ERROR, "source has no fields"); + } + std::uint16_t header_len = static_cast( + 32 + 32 * src_fields.size() + 2); + std::uint16_t rec_len = t->driver()->record_length(); + + std::vector file; + std::vector hdr(32, 0); + hdr[0] = 0x03; + stamp_dbf_header_today(hdr.data()); + hdr[8] = static_cast(header_len & 0xFFu); + hdr[9] = static_cast((header_len >> 8) & 0xFFu); + hdr[10] = static_cast(rec_len & 0xFFu); + hdr[11] = static_cast((rec_len >> 8) & 0xFFu); + file = hdr; + for (const auto& f : src_fields) { + std::vector fd(32, 0); + std::size_t n = std::min(f.name.size(), 10); + std::memcpy(fd.data(), f.name.data(), n); + fd[11] = static_cast(f.raw_type ? f.raw_type : 'C'); + fd[16] = static_cast(f.length); + fd[17] = f.decimals; + file.insert(file.end(), fd.begin(), fd.end()); + } + stamp_dbf_field_displacements(file.data() + 32, src_fields.size()); + file.push_back(0x0D); + file.push_back(0x00); + + // Walk source records, append live ones to the buffered file. + auto src_count = t->driver()->record_count(); + std::uint32_t live = 0; + for (std::uint32_t r = 1; r <= src_count; ++r) { + auto rec = t->driver()->read_record_raw(r); + if (!rec) return fail(rec.error()); + const auto& buf = rec.value(); + if (!buf.empty() && buf[0] == '*') continue; // deleted + ++live; + file.insert(file.end(), buf.begin(), buf.end()); + } + file.push_back(0x1A); + + // Patch the record count. + file[4] = static_cast( live & 0xFFu); + file[5] = static_cast((live >> 8) & 0xFFu); + file[6] = static_cast((live >> 16) & 0xFFu); + file[7] = static_cast((live >> 24) & 0xFFu); + + { + std::error_code ec; + fs::remove(dst, ec); + } + { + std::ofstream out(dst, std::ios::binary); + if (!out) return fail(openads::AE_INTERNAL_ERROR, + "AdsCopyTable: open for write failed"); + out.write(reinterpret_cast(file.data()), + static_cast(file.size())); + if (!out) return fail(openads::AE_INTERNAL_ERROR, + "AdsCopyTable: write failed"); + } + return ok(); +} + +// SAP / rddads signature: 3 args. +// AdsCopyTableContents(hSrc, hDst, usFilterOption) +// +// usFilterOption : ADS_IGNOREFILTERS (0) / ADS_RESPECTFILTERS (1). +// We iterate raw records and skip the DBF tombstone byte -- that +// matches IGNOREFILTERS (the default Harbour passes). RESPECT +// will land alongside AOF-aware copy in a follow-up; until then +// the param is accepted for signature parity and noted. +UNSIGNED32 ENTRYPOINT AdsCopyTableContents(ADSHANDLE hSrc, ADSHANDLE hDst, + UNSIGNED16 usFilterOption) { + arc2_trace("AdsCopyTableContents"); + (void)usFilterOption; + Table* src = get_table(hSrc); + Table* dst = get_table(hDst); + if (!src || !dst) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + if (!src->driver() || !dst->driver()) { + return fail(openads::AE_INTERNAL_ERROR, "no driver"); + } + if (src->driver()->record_length() != dst->driver()->record_length()) { + return fail(openads::AE_INTERNAL_ERROR, + "record length mismatch between src and dst"); + } + auto src_count = src->driver()->record_count(); + for (std::uint32_t r = 1; r <= src_count; ++r) { + auto rec = src->driver()->read_record_raw(r); + if (!rec) return fail(rec.error()); + const auto& buf = rec.value(); + if (!buf.empty() && buf[0] == '*') continue; + auto a = dst->driver()->append_record_raw(buf.data(), buf.size()); + if (!a) return fail(a.error()); + } + if (auto fl = dst->flush(); !fl) return fail(fl.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsConvertTable(ADSHANDLE hHandle, + UNSIGNED16 usFilterOption, + UNSIGNED8* pucFile, + UNSIGNED16 /*usTargetType*/) { + arc2_trace("AdsConvertTable"); + // Single-format engine for now (CDX-flavoured DBF). Convert is a + // copy that mirrors the source format; once ADT / VFP land the + // target type will pick a different writer. + return AdsCopyTable(hHandle, usFilterOption, pucFile); +} + +UNSIGNED32 ENTRYPOINT AdsReindex(ADSHANDLE hTable) { + arc2_trace("AdsReindex"); + if (auto* rt = get_remote_table(hTable)) { + if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; + auto r = rt->conn->reindex(rt->id); + if (!r) return fail(r.error()); + return ok(); + } + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + auto r = t->reindex(); + if (!r) return fail(r.error()); + return ok(); +} + +// Tier-2 SQL push-down: for a SQL-backend table, translate a Clipper filter +// predicate into a SQL WHERE and install it on the backend so it filters +// server-side. Returns true when handled here (backend table); `rc` then holds +// the ABI return: ok() on a pushed filter, or a "not available" failure when +// the predicate can't be translated -- so the caller never assumes a filter we +// did not actually apply and filters client-side instead. +static bool backend_try_push_filter(ADSHANDLE hTable, + const std::string& clipper_pred, + UNSIGNED32& rc) { + auto* ops = openads::abi::backend_table_ops_for(hTable); + if (ops == nullptr || ops->set_filter == nullptr) return false; + openads::engine::SqlDialect dialect; // defaults suit SQLite / ANSI + auto where = openads::engine::try_emit_sql_where(clipper_pred, dialect); + if (where) { + rc = ops->set_filter( + hTable, reinterpret_cast(const_cast(where->c_str()))); + } else { + ops->set_filter(hTable, nullptr); // drop any stale backend filter + rc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, + "filter not pushable to SQL backend; caller filters client-side"); + } + return true; +} + +// M-AOF.3 -- wire AdsSetAOF / AdsClearAOF to the +// engine::aof::evaluate full-scan bitmap evaluator and install the +// resulting per-record bitmap as the table-level filter predicate. +// Skip / GoTop / GoBottom already honour the predicate, so the +// ABI surface gets correct AOF semantics today; M-AOF.4 will swap +// individual leaves to index range scans without changing this +// entry-point contract. +// +// AdsGetAOFOptLevel still reports ADS_OPTIMIZED_NONE because the +// V1 bitmap is built by a full table scan -- no indexes are +// consulted yet. M-AOF.4 will start reporting PART / FULL based +// on per-leaf coverage. The "is an AOF currently installed at +// all?" signal is exposed separately so the ABI layer can keep +// AdsSetFilter and AdsSetAOF distinct. +UNSIGNED32 ENTRYPOINT AdsSetAOF(ADSHANDLE hTable, UNSIGNED8* pucCondition, + UNSIGNED16 /*usResolve*/) { + arc2_trace("AdsSetAOF"); + if (pucCondition == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "AdsSetAOF: NULL condition"); + } + if (auto* rt = get_remote_table(hTable)) { + if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; + std::string cond = openads::abi::to_internal(pucCondition, 0); + auto r = rt->conn->set_aof(rt->id, cond); + if (!r) return fail(r.error()); + rt->aof_expr = cond; + rt->row_valid = false; + rt->prefetch_queue.clear(); + // Replacement filter can narrow to empty or widen to rows: + // expire the nav stamp, proven-false answers and cached answers. + rt->last_nav = 0; + rt->pair_valid = false; + rt->anchor_ok = false; + rt->nav_not_bof = false; + rt->nav_not_eof = false; + remote_nav_bound_clear(rt); + return ok(); + } + if (UNSIGNED32 rc = 0; + backend_try_push_filter(hTable, openads::abi::to_internal(pucCondition, 0), rc)) { + return rc; + } + Table* t = get_table(hTable); + if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (pucCondition == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "AdsSetAOF: NULL condition"); + } + auto cond = openads::abi::to_internal(pucCondition, 0); + auto ast = openads::engine::aof::parse(cond); + if (!ast) { + // An expression outside the optimisable AOF subset (e.g. + // `Empty(NAME)`, `UPPER(NAME) = 'A'`) cannot be turned into a + // server-side AOF. Return a non-success code so the client RDD + // (rddads) falls back to its own client-side row filter. This + // mirrors real ADS, which errors when an AOF cannot be built. + // Returning "success + OPTIMIZED_NONE" reads correct in + // isolation, but stock rddads decides whether to run its own + // client-side row filter purely from AdsSetAOF's return value -- + // it does not call AdsGetAOFOptLevel -- so on AE_SUCCESS it + // assumes the server optimised the filter and skips the + // client-side pass, turning SET FILTER into a no-op. + t->clear_filter(); + return fail(openads::AE_INVALID_EXPRESSION, + "AOF expression not optimisable; client filters"); + } + // Route through the M-AOF.4 index-accelerated evaluator: every + // leaf that hits an open CDX/NTX index whose key expression is + // the field name turns into a range scan; the rest fall back + // to a per-record AST evaluation. The cached OptLevel feeds + // AdsGetAOFOptLevel so callers see PART/FULL when their leaves + // were served by an index. + auto rep = openads::engine::aof::evaluate_optimised(*ast.value(), *t); + if (!rep) return fail(rep.error()); + t->install_aof_bitmap(std::move(rep.value().bm)); + t->set_aof_expr(cond); + int lvl = ADS_OPTIMIZED_NONE; + switch (rep.value().level) { + case openads::engine::aof::OptLevel::None: lvl = ADS_OPTIMIZED_NONE; break; + case openads::engine::aof::OptLevel::Part: lvl = ADS_OPTIMIZED_PART; break; + case openads::engine::aof::OptLevel::Full: lvl = ADS_OPTIMIZED_FULL; break; + } + t->set_aof_opt_level(lvl); + return ok(); +} + +// AdsSetAOF100 -- wide/dual-encoding sibling of AdsSetAOF. Per the SAP +// docs, ulOptions can additionally carry ADS_ENCODE_UTF8 / ADS_ENCODE_UTF16 +// to select the encoding of pvFilter; the numeric values of those flags +// are not published anywhere available to this build, so only the +// default (neither flag set) ANSI/OEM path is handled here -- pvFilter +// is read the same as AdsSetAOF's pucFilter. usResolve/ulOptions carry +// no other effect since AdsSetAOF itself does not consult them yet. +UNSIGNED32 ENTRYPOINT AdsSetAOF100(ADSHANDLE hTable, void* pvFilter, + UNSIGNED32 /*ulOptions*/) { + arc2_trace("AdsSetAOF100"); + return AdsSetAOF(hTable, reinterpret_cast(pvFilter), 0); +} + +UNSIGNED32 ENTRYPOINT AdsEvalAOF100(ADSHANDLE hTable, void* pvExpr, + UNSIGNED32 /*ulOptions*/, UNSIGNED16* pusOptLevel) { + arc2_trace("AdsEvalAOF100"); + return AdsEvalAOF(hTable, reinterpret_cast(pvExpr), + pusOptLevel); +} + +UNSIGNED32 ENTRYPOINT AdsGetAOFOptLevel(ADSHANDLE hTable, UNSIGNED16* pusLevel, + UNSIGNED8* /*pucBuf*/, UNSIGNED16* /*pusLen*/) { + arc2_trace("AdsGetAOFOptLevel"); + if (auto* rt = get_remote_table(hTable)) { + auto r = rt->conn->get_aof_opt_level(rt->id); + if (!r) return fail(r.error()); + if (pusLevel != nullptr) *pusLevel = r.value(); + return ok(); + } +#if defined(OPENADS_WITH_SQLITE) + if (auto* st = get_sqlite_table(hTable)) { + if (pusLevel != nullptr) { + *pusLevel = st->aof_opt_level != 0 + ? st->aof_opt_level + : static_cast(ADS_OPTIMIZED_NONE); + } + return ok(); + } +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* st = get_postgres_table(hTable)) { + if (pusLevel != nullptr) { + *pusLevel = st->aof_opt_level != 0 + ? st->aof_opt_level + : static_cast(ADS_OPTIMIZED_NONE); + } + return ok(); + } +#endif +#if defined(OPENADS_WITH_MARIADB) + if (auto* st = get_maria_table(hTable)) { + if (pusLevel != nullptr) { + *pusLevel = st->aof_opt_level != 0 + ? st->aof_opt_level + : static_cast(ADS_OPTIMIZED_NONE); + } + return ok(); + } +#endif +#if defined(OPENADS_WITH_MSSQL) + if (auto* st = get_mssql_table(hTable)) { + if (pusLevel != nullptr) { + *pusLevel = st->aof_opt_level != 0 + ? st->aof_opt_level + : static_cast(ADS_OPTIMIZED_NONE); + } + return ok(); + } +#endif +#if defined(OPENADS_WITH_FIREBIRD) + if (auto* st = get_firebird_table(hTable)) { + if (pusLevel != nullptr) { + *pusLevel = st->aof_opt_level != 0 + ? st->aof_opt_level + : static_cast(ADS_OPTIMIZED_NONE); + } + return ok(); + } +#endif +#if defined(OPENADS_WITH_ODBC) + if (auto* st = get_odbc_table(hTable)) { + if (pusLevel != nullptr) { + *pusLevel = st->aof_opt_level != 0 + ? st->aof_opt_level + : static_cast(ADS_OPTIMIZED_NONE); + } + return ok(); + } +#endif + Table* t = get_table(hTable); + int lvl = ADS_OPTIMIZED_NONE; + if (t != nullptr && t->aof_active()) { + lvl = t->aof_opt_level(); + if (lvl == 0) lvl = ADS_OPTIMIZED_NONE; + } + if (pusLevel != nullptr) { + *pusLevel = static_cast(lvl); + } + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsGetAOFOptLevel100(ADSHANDLE hTable, + UNSIGNED16* pusLevel, void* pvBuf, + UNSIGNED16* pusLen, UNSIGNED32 /*ulOptions*/) { + arc2_trace("AdsGetAOFOptLevel100"); + return AdsGetAOFOptLevel(hTable, pusLevel, + reinterpret_cast(pvBuf), pusLen); +} + +UNSIGNED32 ENTRYPOINT AdsClearAOF(ADSHANDLE hTable) { + arc2_trace("AdsClearAOF"); + if (auto* rt = get_remote_table(hTable)) { + if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; + auto r = rt->conn->clear_aof(rt->id); + if (!r) return fail(r.error()); + // Widening can un-empty the cursor: expire the nav stamp and + // cached boundary answers (the wire frame already expired the + // seq-gated state; this covers the seq-blind sticky). + rt->last_nav = 0; + rt->pair_valid = false; + rt->anchor_ok = false; + rt->nav_not_bof = false; + rt->nav_not_eof = false; + remote_nav_bound_clear(rt); + return ok(); + } + if (auto* ops = openads::abi::backend_table_ops_for(hTable); + ops && ops->set_filter) { + return ops->set_filter(hTable, nullptr); // clear backend filter + } + Table* t = get_table(hTable); + if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + t->clear_filter(); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsClearFilter(ADSHANDLE hTable) { + arc2_trace("AdsClearFilter"); + if (auto* rt = get_remote_table(hTable)) { + rt->filter_expr.clear(); + rt->last_nav = 0; // local visibility change: expire nav stamp + rt->pair_valid = false; + rt->anchor_ok = false; + rt->nav_not_bof = false; // widening-safe to keep; uniformity wins + rt->nav_not_eof = false; + remote_nav_bound_clear(rt); + return ok(); + } + if (auto* ops = openads::abi::backend_table_ops_for(hTable); + ops && ops->set_filter) { + return ops->set_filter(hTable, nullptr); + } + Table* t = get_table(hTable); + if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + t->set_filter(nullptr); + t->set_filter_expr(""); + return ok(); +} + +// --- M4 memo + autoinc + encryption ---------------------------------------- + +UNSIGNED32 ENTRYPOINT AdsBinaryToFile(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED8* pucPath) { + arc2_trace("AdsBinaryToFile"); + if (pucPath == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, ""); + } + auto write_path = [&](const std::string& payload) -> UNSIGNED32 { + auto path = openads::abi::to_internal(pucPath, 0); + auto fres = openads::platform::File::open( + path, openads::platform::OpenMode::CreateRW); + if (!fres) return fail(fres.error()); + auto file = std::move(fres).value(); + auto wrote = file.write_at(0, payload.data(), payload.size()); + if (!wrote) return fail(wrote.error()); + return ok(); + }; + if (auto* rt = get_remote_table(hTable)) { + if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; + auto i = remote_field_index(rt, pucField); + if (i == std::numeric_limits::max() || i >= rt->fields.size()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + auto v = rt->conn->get_field(rt->id, rt->fields[i].name); + if (!v) return fail(v.error()); + return write_path(v.value()); + } + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); + auto name = openads::abi::to_internal(pucField, 0); + std::int32_t idx = t->field_index(name); + if (idx < 0) return fail(openads::AE_COLUMN_NOT_FOUND, name.c_str()); + auto v = t->read_field(static_cast(idx)); + if (!v) return fail(v.error()); + return write_path(v.value().as_string); +} + +UNSIGNED32 ENTRYPOINT AdsBinaryToFileW(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED16* pwcPath) { + arc2_trace("AdsBinaryToFileW"); + if (pwcPath == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + std::string path; + utf16z_to_utf8(pwcPath, &path); + std::vector bytes(path.begin(), path.end()); + bytes.push_back(0); + return AdsBinaryToFile(hTable, pucField, bytes.data()); +} + +UNSIGNED32 ENTRYPOINT AdsFileToBinary(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED16 /*usType*/, UNSIGNED8* pucPath) { + arc2_trace("AdsFileToBinary"); + if (pucPath == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + auto path = openads::abi::to_internal(pucPath, 0); + auto fres = openads::platform::File::open( + path, openads::platform::OpenMode::ReadOnly); + if (!fres) return fail(fres.error()); + auto file = std::move(fres).value(); + auto sz = file.size(); + if (!sz) return fail(sz.error()); + std::string payload; + payload.resize(static_cast(sz.value())); + if (!payload.empty()) { + auto rd = file.read_at(0, payload.data(), payload.size()); + if (!rd) return fail(rd.error()); + } + if (auto* rt = get_remote_table(hTable)) { + remote_settle_cursor(rt); // M12.21 option C + auto i = remote_field_index(rt, pucField); + if (i == std::numeric_limits::max() || i >= rt->fields.size()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + std::string fname = rt->fields[i].name; + return remote_buffered_set(rt, fname, payload); + } + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); + auto name = openads::abi::to_internal(pucField, 0); + std::int32_t idx = t->field_index(name); + if (idx < 0) return fail(openads::AE_COLUMN_NOT_FOUND, name.c_str()); + auto r = t->set_field(static_cast(idx), payload); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsFileToBinaryW(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED16 usType, UNSIGNED16* pwcPath) { + arc2_trace("AdsFileToBinaryW"); + if (pwcPath == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + std::string path; + utf16z_to_utf8(pwcPath, &path); + std::vector bytes(path.begin(), path.end()); + bytes.push_back(0); + return AdsFileToBinary(hTable, pucField, usType, bytes.data()); +} + +// --- M9.13 binary memo (ADS_BINARY / ADS_IMAGE) ---------------------------- +// +// rddads' adsGetValue / adsPutValue branch for ADS_BINARY+ADS_IMAGE +// fields call this trio instead of AdsGetString/AdsSetString so the +// payload is treated as raw bytes (length-prefixed, no NUL trimming, +// embedded zeros preserved). The engine stores the bytes through the +// existing memo store with an explicit FPT block-type tag, and reads +// back the field as bytes plus an offset window so the caller can do +// chunked reads through a small fixed-size buffer. + +UNSIGNED32 ENTRYPOINT AdsGetBinaryLength(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED32* pulLength) { + arc2_trace("AdsGetBinaryLength"); + Table* t = get_table(hTable); + if (!t || pulLength == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, ""); + } + std::uint16_t idx = 0; + if (!resolve_field_index(t, pucField, &idx)) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + auto v = t->read_field(idx); + if (!v) return fail(v.error()); + *pulLength = static_cast(v.value().as_string.size()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsGetBinary(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED32 ulOffset, UNSIGNED8* pucBuf, + UNSIGNED32* pulLen) { + arc2_trace("AdsGetBinary"); + Table* t = get_table(hTable); + if (!t || pulLen == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, ""); + } + std::uint16_t idx = 0; + if (!resolve_field_index(t, pucField, &idx)) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + auto v = t->read_field(idx); + if (!v) return fail(v.error()); + const std::string& s = v.value().as_string; + UNSIGNED32 cap = *pulLen; + UNSIGNED32 n = 0; + if (ulOffset < s.size()) { + UNSIGNED32 remaining = static_cast(s.size() - ulOffset); + n = cap < remaining ? cap : remaining; + if (pucBuf != nullptr && n > 0) { + std::memcpy(pucBuf, s.data() + ulOffset, n); + } + } + *pulLen = n; + return ok(); +} + +// M9.16: chunked AdsSetBinary writes. The accumulator below holds +// pending bytes per (Table*, field_idx) pair until the caller has +// delivered every byte of `ulTotalBytes`; only then does the payload +// land in the memo store via set_field_binary. Stale accumulators are +// scrubbed when the table closes (purge_pending_binaries_for_table). + +namespace { + +struct PendingBinaryKey { + Table* table; + std::uint16_t field; + bool operator==(const PendingBinaryKey& o) const noexcept { + return table == o.table && field == o.field; + } +}; +struct PendingBinaryHash { + std::size_t operator()(const PendingBinaryKey& k) const noexcept { + return std::hash{}(k.table) ^ + (static_cast(k.field) << 1); + } +}; +struct PendingBinary { + std::string payload; + std::uint32_t total = 0; + openads::drivers::MemoBlockType type = + openads::drivers::MemoBlockType::Object; +}; + +extern "C++" +std::unordered_map& +pending_binaries() { + static std::unordered_map m; + return m; +} + +openads::drivers::MemoBlockType +map_binary_type(UNSIGNED16 usBinaryType) { + if (usBinaryType == ADS_IMAGE) { + return openads::drivers::MemoBlockType::Picture; + } + if (usBinaryType == ADS_STRING || usBinaryType == ADS_MEMO) { + return openads::drivers::MemoBlockType::Text; + } + return openads::drivers::MemoBlockType::Object; +} + +} // namespace + +} // extern "C" + +void purge_pending_binaries_for_table(openads::engine::Table* t) { + using openads::engine::Table; + auto& m = pending_binaries(); + for (auto it = m.begin(); it != m.end(); ) { + if (it->first.table == t) it = m.erase(it); + else ++it; + } +} + +extern "C" { + +UNSIGNED32 ENTRYPOINT AdsSetBinary(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED16 usBinaryType, + UNSIGNED32 ulTotalBytes, UNSIGNED32 ulOffset, + UNSIGNED8* pucBuf, UNSIGNED32 ulBytes) { + arc2_trace("AdsSetBinary"); + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); + std::uint16_t idx = 0; + if (!resolve_field_index(t, pucField, &idx)) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + auto type = map_binary_type(usBinaryType); + + // Single-shot fast path. No accumulator state is created when the + // caller delivers the whole payload in one go. + if (ulOffset == 0 && ulBytes == ulTotalBytes) { + // Drop any stale accumulator from a prior aborted chunked write. + pending_binaries().erase(PendingBinaryKey{t, idx}); + std::string payload; + if (pucBuf != nullptr && ulBytes > 0) { + payload.assign(reinterpret_cast(pucBuf), ulBytes); + } + auto r = t->set_field_binary(idx, payload, type); + if (!r) return fail(r.error()); + return ok(); + } + + // Chunked path. Accumulate at the caller's offset; flush when the + // payload reaches the announced total. + auto& m = pending_binaries(); + PendingBinaryKey key{t, idx}; + auto it = m.find(key); + if (ulOffset == 0) { + // First chunk -- reset (or create) the accumulator and lock in + // the announced total + binary type. + if (it != m.end()) it->second = PendingBinary{}; + else it = m.emplace(key, PendingBinary{}).first; + it->second.total = ulTotalBytes; + it->second.type = type; + it->second.payload.assign(static_cast(ulTotalBytes), + '\0'); + } else { + if (it == m.end()) { + return fail(openads::AE_INTERNAL_ERROR, + "chunked AdsSetBinary: no pending payload"); + } + if (ulTotalBytes != it->second.total) { + return fail(openads::AE_INTERNAL_ERROR, + "chunked AdsSetBinary: total bytes changed mid-write"); + } + } + if (static_cast(ulOffset) + + static_cast(ulBytes) > + static_cast(it->second.total)) { + m.erase(it); + return fail(openads::AE_INTERNAL_ERROR, + "chunked AdsSetBinary: chunk runs past total"); + } + if (pucBuf != nullptr && ulBytes > 0) { + std::memcpy(it->second.payload.data() + ulOffset, pucBuf, ulBytes); + } + + if (ulOffset + ulBytes == it->second.total) { + std::string payload = std::move(it->second.payload); + auto pending_type = it->second.type; + m.erase(it); + auto r = t->set_field_binary(idx, payload, pending_type); + if (!r) return fail(r.error()); + } + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsGetLastAutoinc(ADSHANDLE hTable, UNSIGNED32* pulValue) { + arc2_trace("AdsGetLastAutoinc"); + if (pulValue == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (auto* rt = get_remote_table(hTable)) { + auto r = rt->conn->get_last_autoinc(rt->id); + if (!r) return fail(r.error()); + *pulValue = r.value(); + return ok(); + } + Table* t = get_table(hTable); + if (!t || pulValue == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, ""); + } + // ADT/VFP autoinc tracking lands when those drivers gain extended + // type support. For now report 0 -- the field still reads as part + // of the record buffer for non-autoinc types. + *pulValue = 0; + return ok(); +} + +// Encryption thunks. The AES primitive is real (engine::Aes, validated +// against FIPS-197 / NIST SP 800-38A); the record-level boundary that +// marks a table encrypted on disk and re-keys per-record is part of a +// later milestone alongside ADT, since ADS-original encryption mode +// is not yet documented byte-for-byte. The thunks below behave as +// no-ops or fail with AE_FUNCTION_NOT_AVAILABLE. + +UNSIGNED32 ENTRYPOINT AdsEnableEncryption(ADSHANDLE /*hConnect*/, UNSIGNED8* /*pucPassword*/) { + arc2_trace("AdsEnableEncryption"); + return fail(openads::AE_FUNCTION_NOT_AVAILABLE, + "AdsEnableEncryption pending ADS encryption-mode RE"); +} + +UNSIGNED32 ENTRYPOINT AdsDisableEncryption(ADSHANDLE /*hConnect*/) { + arc2_trace("AdsDisableEncryption"); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsIsEncryptionEnabled(ADSHANDLE /*hConnect*/, UNSIGNED16* pbEnabled) { + arc2_trace("AdsIsEncryptionEnabled"); + if (pbEnabled != nullptr) *pbEnabled = 0; + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsIsTableEncrypted(ADSHANDLE hTable, UNSIGNED16* pbEncrypted) { + arc2_trace("AdsIsTableEncrypted"); + if (pbEncrypted == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *pbEncrypted = 0; + Table* t = get_table(hTable); + if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "invalid table"); + auto* cdx = dynamic_cast(t->driver()); + if (!cdx) return ok(); + if (cdx->encrypted() || cdx->partial_encrypted()) *pbEncrypted = 1; + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsIsRecordEncrypted(ADSHANDLE hTable, UNSIGNED16* pbEncrypted) { + arc2_trace("AdsIsRecordEncrypted"); + if (pbEncrypted == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *pbEncrypted = 0; + Table* t = get_table(hTable); + if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "invalid table"); + if (!t->positioned()) + return fail(openads::AE_NO_CURRENT_RECORD, "no current record"); + auto* cdx = dynamic_cast(t->driver()); + if (!cdx) return ok(); + if (cdx->encrypted() || cdx->record_encrypted(t->recno())) *pbEncrypted = 1; + return ok(); +} + +// M11.2 -- convert a plain CDX table to OpenADS-encrypted in place. +// Requires AdsSetEncryptionPassword to have been called on the +// owning connection (located by walking the registry for the +// connection whose tables include this Table*). +UNSIGNED32 ENTRYPOINT AdsEncryptTable(ADSHANDLE hTable) { + arc2_trace("AdsEncryptTable"); + auto& s = state(); + std::lock_guard lk(s.mu); + Table* t = s.registry.lookup
(hTable, HandleKind::Table); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "invalid table handle"); + Connection* owning = find_owning_connection(t); + if (!owning) return fail(openads::AE_INVALID_CONNECTION_HANDLE, + "table not owned by any connection"); + if (!owning->has_encryption_key()) { + return fail(openads::AE_FUNCTION_NOT_AVAILABLE, + "AdsSetEncryptionPassword required first"); + } + auto* cdx = dynamic_cast( + t->driver() ? t->driver()->unwrap() : nullptr); + if (!cdx) { + return fail(openads::AE_FUNCTION_NOT_AVAILABLE, + "encryption supported on CdxDriver tables only"); + } + auto r = cdx->encrypt_in_place(owning->encryption_key()); + if (!r) return fail(r.error()); + if (auto fl = t->flush(); !fl) return fail(fl.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsDecryptTable(ADSHANDLE /*hTable*/) { + arc2_trace("AdsDecryptTable"); + return fail(openads::AE_FUNCTION_NOT_AVAILABLE, + "AdsDecryptTable pending ADS encryption-mode RE"); +} + +UNSIGNED32 ENTRYPOINT AdsEncryptRecord(ADSHANDLE hTable) { + arc2_trace("AdsEncryptRecord"); + auto& s = state(); + std::lock_guard lk(s.mu); + Table* t = s.registry.lookup
(hTable, HandleKind::Table); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "invalid table handle"); + if (!t->positioned()) + return fail(openads::AE_NO_CURRENT_RECORD, "no current record"); + Connection* owning = find_owning_connection(t); + if (!owning) return fail(openads::AE_INVALID_CONNECTION_HANDLE, + "table not owned by any connection"); + if (!owning->has_encryption_key()) { + return fail(openads::AE_FUNCTION_NOT_AVAILABLE, + "AdsSetEncryptionPassword required first"); + } + auto* cdx = dynamic_cast(t->driver()); + if (!cdx) { + return fail(openads::AE_FUNCTION_NOT_AVAILABLE, + "encryption supported on CdxDriver tables only"); + } + if (cdx->encrypted()) return ok(); + auto r = cdx->encrypt_record_at(t->recno()); + if (!r) return fail(r.error()); + if (auto fl = t->flush(); !fl) return fail(fl.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsDecryptRecord(ADSHANDLE hTable) { + arc2_trace("AdsDecryptRecord"); + auto& s = state(); + std::lock_guard lk(s.mu); + Table* t = s.registry.lookup
(hTable, HandleKind::Table); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "invalid table handle"); + if (!t->positioned()) + return fail(openads::AE_NO_CURRENT_RECORD, "no current record"); + Connection* owning = find_owning_connection(t); + if (!owning) return fail(openads::AE_INVALID_CONNECTION_HANDLE, + "table not owned by any connection"); + if (!owning->has_encryption_key()) { + return fail(openads::AE_FUNCTION_NOT_AVAILABLE, + "AdsSetEncryptionPassword required first"); + } + auto* cdx = dynamic_cast(t->driver()); + if (!cdx) { + return fail(openads::AE_FUNCTION_NOT_AVAILABLE, + "encryption supported on CdxDriver tables only"); + } + auto r = cdx->decrypt_record_at(t->recno()); + if (!r) return fail(r.error()); + if (auto rf = t->refresh_record_buffer(); !rf) return fail(rf.error()); + if (auto fl = t->flush(); !fl) return fail(fl.error()); + return ok(); +} + +// --- M5 transaction surface ------------------------------------------------- + +UNSIGNED32 ENTRYPOINT AdsBeginTransaction(ADSHANDLE hConnect) { + arc2_trace("AdsBeginTransaction"); + auto& s = state(); + std::lock_guard lk(s.mu); + // Native connection path + Connection* c = lookup_connection(hConnect); + if (c) { + auto r = c->begin_tx(); + if (!r) return fail(r.error()); + return ok(); + } + // SQL backend path: dispatch BEGIN to the connection's TxManager +#if defined(OPENADS_WITH_SQLITE) + if (auto* sqc = get_sqlite_conn(hConnect)) { + ensure_sqlite_tx_wired(sqc); + sqc->tx_manager().begin(); + return ok(); + } +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* pgc = get_postgres_conn(hConnect)) { + ensure_postgres_tx_wired(pgc); + pgc->tx_manager().begin(); + return ok(); + } +#endif +#if defined(OPENADS_WITH_MARIADB) + if (auto* mc = get_maria_conn(hConnect)) { + ensure_maria_tx_wired(mc); + mc->tx_manager().begin(); + return ok(); + } +#endif +#if defined(OPENADS_WITH_ODBC) + if (auto* oc = get_odbc_conn(hConnect)) { + ensure_odbc_tx_wired(oc); + oc->tx_manager().begin(); + return ok(); + } +#endif +#if defined(OPENADS_WITH_FIREBIRD) + if (auto* fc = get_firebird_conn(hConnect)) { + ensure_firebird_tx_wired(fc); + fc->tx_manager().begin(); + return ok(); + } +#endif +#if defined(OPENADS_WITH_MSSQL) + if (auto* msc = get_mssql_conn(hConnect)) { + ensure_mssql_tx_wired(msc); + msc->tx_manager().begin(); + return ok(); + } +#endif + // Table handles -- route through the table's connection TxManager +#if defined(OPENADS_WITH_SQLITE) + if (auto* st = get_sqlite_table(hConnect)) { + if (st->conn) { + ensure_sqlite_tx_wired(st->conn); + st->conn->tx_manager().begin(); + return ok(); + } + } +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* pt = get_postgres_table(hConnect)) { + if (pt->conn) { + ensure_postgres_tx_wired(pt->conn); + pt->conn->tx_manager().begin(); + return ok(); + } + } +#endif +#if defined(OPENADS_WITH_MARIADB) + if (auto* mt = get_maria_table(hConnect)) { + if (mt->conn) { + ensure_maria_tx_wired(mt->conn); + mt->conn->tx_manager().begin(); + return ok(); + } + } +#endif +#if defined(OPENADS_WITH_ODBC) + if (auto* ot = get_odbc_table(hConnect)) { + if (ot->conn) { + ensure_odbc_tx_wired(ot->conn); + ot->conn->tx_manager().begin(); + return ok(); + } + } +#endif +#if defined(OPENADS_WITH_FIREBIRD) + if (auto* ft = get_firebird_table(hConnect)) { + if (ft->conn) { + ensure_firebird_tx_wired(ft->conn); + ft->conn->tx_manager().begin(); + return ok(); + } + } +#endif +#if defined(OPENADS_WITH_MSSQL) + if (auto* mst = get_mssql_table(hConnect)) { + if (mst->conn) { + ensure_mssql_tx_wired(mst->conn); + mst->conn->tx_manager().begin(); + return ok(); + } + } +#endif + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); +} + +UNSIGNED32 ENTRYPOINT AdsCommitTransaction(ADSHANDLE hConnect) { + arc2_trace("AdsCommitTransaction"); + auto& s = state(); + std::lock_guard lk(s.mu); + // Native connection path + Connection* c = lookup_connection(hConnect); + if (c) { + auto r = c->commit_tx(); + if (!r) return fail(r.error()); + return ok(); + } + // SQL backend path +#if defined(OPENADS_WITH_SQLITE) + if (auto* sqc = get_sqlite_conn(hConnect)) { + sqc->tx_manager().commit(); return ok(); + } +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* pgc = get_postgres_conn(hConnect)) { + pgc->tx_manager().commit(); return ok(); + } +#endif +#if defined(OPENADS_WITH_MARIADB) + if (auto* mc = get_maria_conn(hConnect)) { + mc->tx_manager().commit(); return ok(); + } +#endif +#if defined(OPENADS_WITH_ODBC) + if (auto* oc = get_odbc_conn(hConnect)) { + oc->tx_manager().commit(); return ok(); + } +#endif +#if defined(OPENADS_WITH_FIREBIRD) + if (auto* fc = get_firebird_conn(hConnect)) { + fc->tx_manager().commit(); return ok(); + } +#endif +#if defined(OPENADS_WITH_MSSQL) + if (auto* msc = get_mssql_conn(hConnect)) { + msc->tx_manager().commit(); return ok(); + } +#endif +#if defined(OPENADS_WITH_SQLITE) + if (auto* st = get_sqlite_table(hConnect)) { + if (st->conn) { st->conn->tx_manager().commit(); return ok(); } + } +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* pt = get_postgres_table(hConnect)) { + if (pt->conn) { pt->conn->tx_manager().commit(); return ok(); } + } +#endif +#if defined(OPENADS_WITH_MARIADB) + if (auto* mt = get_maria_table(hConnect)) { + if (mt->conn) { mt->conn->tx_manager().commit(); return ok(); } + } +#endif +#if defined(OPENADS_WITH_ODBC) + if (auto* ot = get_odbc_table(hConnect)) { + if (ot->conn) { ot->conn->tx_manager().commit(); return ok(); } + } +#endif +#if defined(OPENADS_WITH_FIREBIRD) + if (auto* ft = get_firebird_table(hConnect)) { + if (ft->conn) { ft->conn->tx_manager().commit(); return ok(); } + } +#endif +#if defined(OPENADS_WITH_MSSQL) + if (auto* mst = get_mssql_table(hConnect)) { + if (mst->conn) { mst->conn->tx_manager().commit(); return ok(); } + } +#endif + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); +} + +UNSIGNED32 ENTRYPOINT AdsRollbackTransaction(ADSHANDLE hConnect) { + arc2_trace("AdsRollbackTransaction"); + auto& s = state(); + std::lock_guard lk(s.mu); + // Native connection path + Connection* c = lookup_connection(hConnect); + if (c) { + auto r = c->rollback_tx(); + if (!r) return fail(r.error()); + return ok(); + } + // SQL backend path +#if defined(OPENADS_WITH_SQLITE) + if (auto* sqc = get_sqlite_conn(hConnect)) { + sqc->tx_manager().rollback(); return ok(); + } +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* pgc = get_postgres_conn(hConnect)) { + pgc->tx_manager().rollback(); return ok(); + } +#endif +#if defined(OPENADS_WITH_MARIADB) + if (auto* mc = get_maria_conn(hConnect)) { + mc->tx_manager().rollback(); return ok(); + } +#endif +#if defined(OPENADS_WITH_ODBC) + if (auto* oc = get_odbc_conn(hConnect)) { + oc->tx_manager().rollback(); return ok(); + } +#endif +#if defined(OPENADS_WITH_FIREBIRD) + if (auto* fc = get_firebird_conn(hConnect)) { + fc->tx_manager().rollback(); return ok(); + } +#endif +#if defined(OPENADS_WITH_MSSQL) + if (auto* msc = get_mssql_conn(hConnect)) { + msc->tx_manager().rollback(); return ok(); + } +#endif +#if defined(OPENADS_WITH_SQLITE) + if (auto* st = get_sqlite_table(hConnect)) { + if (st->conn) { st->conn->tx_manager().rollback(); return ok(); } + } +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* pt = get_postgres_table(hConnect)) { + if (pt->conn) { pt->conn->tx_manager().rollback(); return ok(); } + } +#endif +#if defined(OPENADS_WITH_MARIADB) + if (auto* mt = get_maria_table(hConnect)) { + if (mt->conn) { mt->conn->tx_manager().rollback(); return ok(); } + } +#endif +#if defined(OPENADS_WITH_ODBC) + if (auto* ot = get_odbc_table(hConnect)) { + if (ot->conn) { ot->conn->tx_manager().rollback(); return ok(); } + } +#endif +#if defined(OPENADS_WITH_FIREBIRD) + if (auto* ft = get_firebird_table(hConnect)) { + if (ft->conn) { ft->conn->tx_manager().rollback(); return ok(); } + } +#endif +#if defined(OPENADS_WITH_MSSQL) + if (auto* mst = get_mssql_table(hConnect)) { + if (mst->conn) { mst->conn->tx_manager().rollback(); return ok(); } + } +#endif + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); +} + +UNSIGNED32 ENTRYPOINT AdsInTransaction(ADSHANDLE hConnect, UNSIGNED16* pbInTx) { + arc2_trace("AdsInTransaction"); + auto& s = state(); + std::lock_guard lk(s.mu); + if (pbInTx == nullptr) { + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + } + // Native connection path + Connection* c = lookup_connection(hConnect); + if (c) { + *pbInTx = c->in_tx() ? 1 : 0; + return ok(); + } + // SQL backend path +#if defined(OPENADS_WITH_SQLITE) + if (auto* sqc = get_sqlite_conn(hConnect)) { + *pbInTx = sqc->tx_manager().in_transaction() ? 1 : 0; return ok(); + } +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* pgc = get_postgres_conn(hConnect)) { + *pbInTx = pgc->tx_manager().in_transaction() ? 1 : 0; return ok(); + } +#endif +#if defined(OPENADS_WITH_MARIADB) + if (auto* mc = get_maria_conn(hConnect)) { + *pbInTx = mc->tx_manager().in_transaction() ? 1 : 0; return ok(); + } +#endif +#if defined(OPENADS_WITH_ODBC) + if (auto* oc = get_odbc_conn(hConnect)) { + *pbInTx = oc->tx_manager().in_transaction() ? 1 : 0; return ok(); + } +#endif +#if defined(OPENADS_WITH_FIREBIRD) + if (auto* fc = get_firebird_conn(hConnect)) { + *pbInTx = fc->tx_manager().in_transaction() ? 1 : 0; return ok(); + } +#endif +#if defined(OPENADS_WITH_MSSQL) + if (auto* msc = get_mssql_conn(hConnect)) { + *pbInTx = msc->tx_manager().in_transaction() ? 1 : 0; return ok(); + } +#endif +#if defined(OPENADS_WITH_SQLITE) + if (auto* st = get_sqlite_table(hConnect)) { + if (st->conn) { + *pbInTx = st->conn->tx_manager().in_transaction() ? 1 : 0; + return ok(); + } + } +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* pt = get_postgres_table(hConnect)) { + if (pt->conn) { + *pbInTx = pt->conn->tx_manager().in_transaction() ? 1 : 0; + return ok(); + } + } +#endif +#if defined(OPENADS_WITH_MARIADB) + if (auto* mt = get_maria_table(hConnect)) { + if (mt->conn) { + *pbInTx = mt->conn->tx_manager().in_transaction() ? 1 : 0; + return ok(); + } + } +#endif +#if defined(OPENADS_WITH_ODBC) + if (auto* ot = get_odbc_table(hConnect)) { + if (ot->conn) { + *pbInTx = ot->conn->tx_manager().in_transaction() ? 1 : 0; + return ok(); + } + } +#endif +#if defined(OPENADS_WITH_FIREBIRD) + if (auto* ft = get_firebird_table(hConnect)) { + if (ft->conn) { + *pbInTx = ft->conn->tx_manager().in_transaction() ? 1 : 0; + return ok(); + } + } +#endif +#if defined(OPENADS_WITH_MSSQL) + if (auto* mst = get_mssql_table(hConnect)) { + if (mst->conn) { + *pbInTx = mst->conn->tx_manager().in_transaction() ? 1 : 0; + return ok(); + } + } +#endif + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); +} + +// M12.1 -- set the auto-commit threshold on a SQL backend connection. +// When threshold > 0, after threshold DML statements the connection +// auto-commits. When threshold = 0, auto-commit is disabled. +UNSIGNED32 ENTRYPOINT AdsSetAutoCommit(ADSHANDLE hConnect, + SIGNED32 nThreshold) { + arc2_trace("AdsSetAutoCommit"); + auto& s = state(); + std::lock_guard lk(s.mu); +#if !defined(OPENADS_WITH_SQLITE) && !defined(OPENADS_WITH_POSTGRESQL) && \ + !defined(OPENADS_WITH_MARIADB) && !defined(OPENADS_WITH_ODBC) && \ + !defined(OPENADS_WITH_FIREBIRD) && !defined(OPENADS_WITH_MSSQL) + (void)hConnect; + (void)nThreshold; +#endif + // SQL backend path +#if defined(OPENADS_WITH_SQLITE) + if (auto* sqc = get_sqlite_conn(hConnect)) { + sqc->tx_manager().auto_commit_threshold = nThreshold; return ok(); + } +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* pgc = get_postgres_conn(hConnect)) { + pgc->tx_manager().auto_commit_threshold = nThreshold; return ok(); + } +#endif +#if defined(OPENADS_WITH_MARIADB) + if (auto* mc = get_maria_conn(hConnect)) { + mc->tx_manager().auto_commit_threshold = nThreshold; return ok(); + } +#endif +#if defined(OPENADS_WITH_ODBC) + if (auto* oc = get_odbc_conn(hConnect)) { + oc->tx_manager().auto_commit_threshold = nThreshold; return ok(); + } +#endif +#if defined(OPENADS_WITH_FIREBIRD) + if (auto* fc = get_firebird_conn(hConnect)) { + fc->tx_manager().auto_commit_threshold = nThreshold; return ok(); + } +#endif +#if defined(OPENADS_WITH_MSSQL) + if (auto* msc = get_mssql_conn(hConnect)) { + msc->tx_manager().auto_commit_threshold = nThreshold; return ok(); + } +#endif +#if defined(OPENADS_WITH_SQLITE) + if (auto* st = get_sqlite_table(hConnect)) { + if (st->conn) { + st->conn->tx_manager().auto_commit_threshold = nThreshold; + return ok(); + } + } +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* pt = get_postgres_table(hConnect)) { + if (pt->conn) { + pt->conn->tx_manager().auto_commit_threshold = nThreshold; + return ok(); + } + } +#endif +#if defined(OPENADS_WITH_MARIADB) + if (auto* mt = get_maria_table(hConnect)) { + if (mt->conn) { + mt->conn->tx_manager().auto_commit_threshold = nThreshold; + return ok(); + } + } +#endif +#if defined(OPENADS_WITH_ODBC) + if (auto* ot = get_odbc_table(hConnect)) { + if (ot->conn) { + ot->conn->tx_manager().auto_commit_threshold = nThreshold; + return ok(); + } + } +#endif +#if defined(OPENADS_WITH_FIREBIRD) + if (auto* ft = get_firebird_table(hConnect)) { + if (ft->conn) { + ft->conn->tx_manager().auto_commit_threshold = nThreshold; + return ok(); + } + } +#endif +#if defined(OPENADS_WITH_MSSQL) + if (auto* mst = get_mssql_table(hConnect)) { + if (mst->conn) { + mst->conn->tx_manager().auto_commit_threshold = nThreshold; + return ok(); + } + } +#endif + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); +} + +// M11.2 -- set the encryption password on a connection. Affects +// every subsequent table open: encrypted tables (header byte 0xC3) +// transparently decrypt on read / encrypt on write using AES-256-CTR +// keyed off the (zero-padded) password bytes. OpenADS-only format -- +// not byte-compatible with SAP ADS encrypted .adt files. +// M11.8 -- OEM (CP437) ↔ ANSI (UTF-8 in this build) conversion +// helpers. `pucBuf` is read until a NUL byte. Output is written +// in place into the same buffer (caller must size for worst case +// -- UTF-8 may grow up to 3x); `pulLen` carries the input length +// in and the output length out. +UNSIGNED32 ENTRYPOINT AdsConvertOemToAnsi(UNSIGNED8* pucBuf, UNSIGNED32* pulLen) { + arc2_trace("AdsConvertOemToAnsi"); + if (pucBuf == nullptr || pulLen == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, ""); + } + const std::size_t in_len = static_cast(*pulLen); + // In-place callers must allocate at least 3x the OEM byte length + // (worst-case UTF-8 expansion). pulLen carries OEM length in. + const std::size_t buf_cap = in_len * 3u; + auto utf8 = openads::engine::cp437_to_utf8(pucBuf, in_len); + const std::size_t out_len = utf8.size(); + if (out_len > buf_cap) { + *pulLen = static_cast(out_len); + return fail(openads::AE_INSUFFICIENT_BUFFER, + "output buffer too small for UTF-8 expansion"); + } + std::memcpy(pucBuf, utf8.data(), out_len); + if (out_len < in_len) pucBuf[out_len] = '\0'; + *pulLen = static_cast(out_len); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsConvertAnsiToOem(UNSIGNED8* pucBuf, UNSIGNED32* pulLen) { + arc2_trace("AdsConvertAnsiToOem"); + if (pucBuf == nullptr || pulLen == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, ""); + } + const std::size_t in_len = static_cast(*pulLen); + auto cp = openads::engine::utf8_to_cp437( + reinterpret_cast(pucBuf), in_len); + const std::size_t out_len = cp.size(); + if (out_len > in_len) { + *pulLen = static_cast(out_len); + return fail(openads::AE_INSUFFICIENT_BUFFER, + "output buffer too small for OEM conversion"); + } + std::memcpy(pucBuf, cp.data(), out_len); + if (out_len < in_len) pucBuf[out_len] = '\0'; + *pulLen = static_cast(out_len); + return ok(); +} + +// M11.7 -- set the connection's string-compare collation. Names: +// `binary` (default) or `nocase`. Affects equality / range +// comparisons for Character columns in SQL WHERE. +UNSIGNED32 ENTRYPOINT AdsSetCollation(ADSHANDLE hConnect, UNSIGNED8* pucName) { + arc2_trace("AdsSetCollation"); + auto& s = state(); + std::lock_guard lk(s.mu); + Connection* c = s.registry.lookup( + hConnect, HandleKind::Connection); + if (!c || pucName == nullptr) { + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + } + auto name = openads::abi::to_internal(pucName, 0); + std::string upper; + upper.reserve(name.size()); + for (char ch : name) upper.push_back(static_cast( + std::toupper(static_cast(ch)))); + if (upper == "BINARY") { + c->set_collation(Connection::Collation::Binary); + c->set_oem_sort_table(nullptr); + c->set_oem_upper_table(nullptr); + openads::engine::set_active_oem_upper_table(nullptr); + } else if (upper == "NOCASE") { + c->set_collation(Connection::Collation::NoCase); + c->set_oem_sort_table(nullptr); + c->set_oem_upper_table(nullptr); + openads::engine::set_active_oem_upper_table(nullptr); + } else { + const auto* oem = openads::engine::lookup_oem_collation(name.c_str()); + if (oem == nullptr) { + return fail(openads::AE_FUNCTION_NOT_AVAILABLE, + "unknown collation name (expected BINARY / NOCASE / " + "NTXPL852 / PL852)"); + } + c->set_collation(Connection::Collation::Binary); + c->set_oem_sort_table(oem->sort); + const std::uint8_t* up = + openads::engine::lookup_oem_upper_table(name.c_str()); + c->set_oem_upper_table(up); + // Publish for expression evaluation (UPPER() / upper_bare in + // index_expr have no Connection context) -- see + // active_oem_upper_table in oem_collation.h. + openads::engine::set_active_oem_upper_table(up); + } + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsSetEncryptionPassword(ADSHANDLE hConnect, + UNSIGNED8* pucPassword) { + arc2_trace("AdsSetEncryptionPassword"); + auto& s = state(); + std::lock_guard lk(s.mu); + Connection* c = s.registry.lookup( + hConnect, HandleKind::Connection); + if (!c || pucPassword == nullptr) { + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + } + auto pw = openads::abi::to_internal(pucPassword, 0); + c->set_encryption_password(pw); + return ok(); +} + +// SAP / rddads signature: 3 args. ulOptions is reserved on the +// real ACE (must be ADS_DEFAULT); accept and ignore. +UNSIGNED32 ENTRYPOINT AdsCreateSavepoint(ADSHANDLE hConnect, UNSIGNED8* pucName, + UNSIGNED32 ulOptions) { + arc2_trace("AdsCreateSavepoint"); + (void)ulOptions; + auto& s = state(); + std::lock_guard lk(s.mu); + Connection* c = lookup_connection(hConnect); + if (!c || pucName == nullptr) { + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + } + auto name = openads::abi::to_internal(pucName, 0); + auto r = c->create_savepoint(name); + if (!r) return fail(r.error()); + return ok(); +} + +// M11.3 -- release a savepoint without rolling back. The work done +// since CreateSavepoint stays part of the enclosing transaction. +UNSIGNED32 ENTRYPOINT AdsReleaseSavepoint(ADSHANDLE hConnect, UNSIGNED8* pucName) { + arc2_trace("AdsReleaseSavepoint"); + auto& s = state(); + std::lock_guard lk(s.mu); + Connection* c = lookup_connection(hConnect); + if (!c || pucName == nullptr) { + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + } + auto name = openads::abi::to_internal(pucName, 0); + auto r = c->release_savepoint(name); + if (!r) return fail(r.error()); + return ok(); +} + +// SAP / rddads signature: 3 args. ulOptions is reserved on real +// ACE; accept and ignore. Null pucSavepoint => full rollback. +UNSIGNED32 ENTRYPOINT AdsRollbackTransaction80(ADSHANDLE hConnect, UNSIGNED8* pucSavepoint, + UNSIGNED32 ulOptions) { + arc2_trace("AdsRollbackTransaction80"); + (void)ulOptions; + auto& s = state(); + std::lock_guard lk(s.mu); + Connection* c = lookup_connection(hConnect); + if (!c) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + if (pucSavepoint == nullptr) { + // Full rollback if no savepoint name supplied (matches ACE legacy). + auto r = c->rollback_tx(); + if (!r) return fail(r.error()); + return ok(); + } + auto name = openads::abi::to_internal(pucSavepoint, 0); + auto r = c->rollback_to_savepoint(name); + if (!r) return fail(r.error()); + return ok(); +} + +// --- M9.12 Table directory iteration --------------------------------------- +// +// AdsFindFirstTable / AdsFindNextTable / AdsFindClose walk the +// connection's data directory and emit each entry whose name matches +// `pucMask` (FoxPro-style glob with `*` and `?`, case insensitive). +// Matches AE_SUCCESS while names remain; once exhausted, returns +// AE_NO_FILE_FOUND so the caller breaks out of its loop. The find +// handle is registered in the global registry under HandleKind::Find +// so it round-trips through ADSHANDLE without aliasing tables/cursors. + +namespace { + +// Truncate the matched filename into `pucBuf`, NUL-terminate when +// there's room, and report the on-wire length back through `pusLen` +// (matching the ACE convention rddads' AdsFindFirstTable/NextTable +// callers depend on). +UNSIGNED32 emit_name(UNSIGNED8* pucBuf, UNSIGNED16* pusLen, + const std::string& name) { + if (pusLen == nullptr) return openads::AE_INTERNAL_ERROR; + UNSIGNED16 cap = *pusLen; + UNSIGNED16 n = static_cast( + name.size() < cap ? name.size() : cap); + if (pucBuf != nullptr && cap > 0) { + std::memcpy(pucBuf, name.data(), n); + if (n < cap) pucBuf[n] = '\0'; + } + *pusLen = static_cast(name.size()); + arc2_log("EMIT name=[%s] len=%u cap=%u", + name.c_str(), (unsigned)name.size(), (unsigned)cap); + return openads::AE_SUCCESS; +} + +} // namespace + +UNSIGNED32 ENTRYPOINT AdsFindFirstTable(ADSHANDLE hConnect, + UNSIGNED8* pucMask, + UNSIGNED8* pucFileName, + UNSIGNED16* pusFileNameLen, + ADSHANDLE* phFind) { + arc2_trace("AdsFindFirstTable"); + arc2_trace("AdsFindFirstTable"); + auto& s = state(); + std::lock_guard lk(s.mu); + if (phFind == nullptr || pusFileNameLen == nullptr) { + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + } + std::string mask = pucMask + ? openads::abi::to_internal(pucMask, 0) + : std::string("*.dbf"); + if (mask.empty()) mask = "*.dbf"; + // ARC builds the find mask as "\", so with a + // tcp:// (or absolute local path) connection the whole URI/path arrives + // here. Matching is always done against the connection's data dir + // (locally or server-side), so reduce those to the bare file mask. + if (mask.find("://") != std::string::npos || + (mask.size() > 2 && mask[1] == ':') || + mask.rfind("\\\\", 0) == 0) { + if (auto sep = mask.find_last_of("/\\"); sep != std::string::npos) { + arc2_log("FIND mask [%s] -> [%s]", mask.c_str(), + mask.substr(sep + 1).c_str()); + mask = mask.substr(sep + 1); + } + } + + // M12.33 — remote path: send FindTables opcode, cache results locally. + ADSHANDLE rc_h = resolve_remote_conn_handle(hConnect); + if (auto* rc = get_remote_connection(rc_h)) { + auto r = rc->find_tables(mask); + if (!r) return fail(r.error()); + auto matches = std::move(r).value(); + if (matches.empty()) + return fail(openads::AE_NO_FILE_FOUND, mask.c_str()); + auto find = std::make_unique(); + find->matches = std::move(matches); + find->cursor = 1; + Connection::TableFind* raw = find.get(); + s.remote_finds.emplace_back(std::move(find)); + Handle gh = s.registry.register_object(HandleKind::Find, raw); + *phFind = to_ads_handle(gh); + return emit_name(pucFileName, pusFileNameLen, raw->matches.front()); + } + + Connection* c = s.registry.lookup(hConnect, HandleKind::Connection); + if (c == nullptr) { + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + } + + auto r = c->find_first_table(mask); + if (!r) return fail(r.error()); + + auto [find_ptr, name] = std::move(r).value(); + Handle gh = s.registry.register_object(HandleKind::Find, find_ptr); + *phFind = to_ads_handle(gh); + return emit_name(pucFileName, pusFileNameLen, name); +} + +UNSIGNED32 ENTRYPOINT AdsFindNextTable(ADSHANDLE hConnect, + ADSHANDLE hFind, + UNSIGNED8* pucFileName, + UNSIGNED16* pusFileNameLen) { + arc2_trace("AdsFindNextTable"); + arc2_trace("AdsFindNextTable"); + auto& s = state(); + std::lock_guard lk(s.mu); + (void)hConnect; // find state keyed by hFind only; silence C4100 (C2220 in CI) + if (pusFileNameLen == nullptr) { + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + } + auto* find = s.registry.lookup(hFind, HandleKind::Find); + if (find == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "invalid find handle"); + } + if (find->cursor >= find->matches.size()) { + return fail(openads::AE_NO_FILE_FOUND, "no more files"); + } + return emit_name(pucFileName, pusFileNameLen, find->matches[find->cursor++]); +} + +UNSIGNED32 ENTRYPOINT AdsFindClose(ADSHANDLE hConnect, ADSHANDLE hFind) { + arc2_trace("AdsFindClose"); + arc2_trace("AdsFindClose"); + auto& s = state(); + std::lock_guard lk(s.mu); + (void)hConnect; // find state keyed by hFind only; silence C4100 (C2220 in CI) + auto* find = s.registry.lookup(hFind, HandleKind::Find); + if (find == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "invalid find handle"); + } + s.registry.release(hFind); + return ok(); +} + +// --- M6 Data Dictionary ---------------------------------------------------- + +UNSIGNED32 ENTRYPOINT AdsDDCreate(UNSIGNED8* pucDictionary, UNSIGNED16 /*bEncrypt*/, + UNSIGNED8* /*pucAdminPassword*/, + ADSHANDLE* phConnect) { + arc2_trace("AdsDDCreate"); + if (pucDictionary == nullptr || phConnect == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "null DD args"); + } + auto path = openads::abi::to_internal(pucDictionary, 0); + // Materialise an empty DD on disk, then open a Connection rooted at it. + auto created = openads::engine::DataDict::create(path); + if (!created) return fail(created.error()); + + auto opened = Connection::open(path); + if (!opened) return fail(opened.error()); + + auto holder = std::make_unique(std::move(opened).value()); + Connection* raw = holder.get(); + auto& s = state(); + std::lock_guard lk(s.mu); + Handle h = s.registry.register_object(HandleKind::Connection, raw); + s.conns.emplace(h, std::move(holder)); + *phConnect = to_ads_handle(h); + return ok(); +} + +// SAP signature (rddads): (hConn, name, file, fileType, charType, +// indexFile, comment). Matches Harbour's HB_FUNC(ADSDDADDTABLE). +UNSIGNED32 ENTRYPOINT AdsDDAddTable(ADSHANDLE hConnect, UNSIGNED8* pucAlias, + UNSIGNED8* pucTablePath, + UNSIGNED16 /*usFileType*/, + UNSIGNED16 /*usCharType*/, + UNSIGNED8* /*pucIndexPath*/, + UNSIGNED8* /*pucComment*/) { + arc2_trace("AdsDDAddTable"); + auto& s = state(); + std::lock_guard lk(s.mu); + Connection* c = s.registry.lookup(hConnect, HandleKind::Connection); + if (!c) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + if (!c->has_dd()) { + return fail(openads::AE_FUNCTION_NOT_AVAILABLE, + "connection has no data dictionary"); + } + if (pucAlias == nullptr || pucTablePath == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "null DD-AddTable args"); + } + auto alias = openads::abi::to_internal(pucAlias, 0); + auto path = openads::abi::to_internal(pucTablePath, 0); + auto r = c->dd()->add_table(alias, path); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsDDRemoveTable(ADSHANDLE hConnect, UNSIGNED8* pucAlias, + UNSIGNED16 /*usDeleteFiles*/) { + arc2_trace("AdsDDRemoveTable"); + auto& s = state(); + std::lock_guard lk(s.mu); + Connection* c = s.registry.lookup(hConnect, HandleKind::Connection); + if (!c) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + if (!c->has_dd()) { + return fail(openads::AE_FUNCTION_NOT_AVAILABLE, + "connection has no data dictionary"); + } + if (pucAlias == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + auto alias = openads::abi::to_internal(pucAlias, 0); + auto r = c->dd()->remove_table(alias); + if (!r) return fail(r.error()); + return ok(); +} + +// --- M7.1 SQL surface ------------------------------------------------------- + +extern "C++" { + +namespace { + +struct SqlStatement { + Connection* conn = nullptr; + openads::network::RemoteConnection* remote = nullptr; +#if defined(OPENADS_WITH_SQLITE) + openads::sql_backend::SqliteConnection* sqlite = nullptr; +#endif +#if defined(OPENADS_WITH_MSSQL) + openads::sql_backend::MssqlConnection* mssql_conn = nullptr; +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + openads::sql_backend::PostgresConnection* postgres = nullptr; +#endif +#if defined(OPENADS_WITH_MARIADB) + openads::sql_backend::MariaConnection* maria = nullptr; +#endif +#if defined(OPENADS_WITH_ODBC) + openads::sql_backend::OdbcConnection* odbc = nullptr; + openads::sql_backend::SqlDdlDialect odbc_dialect = + openads::sql_backend::SqlDdlDialect::Postgres; +#endif +#if defined(OPENADS_WITH_FIREBIRD) + openads::sql_backend::FirebirdConnection* firebird = nullptr; +#endif + std::string sql; + std::string sql_username; + // 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 + // no parameter map existed here. AdsPrepareSQL and AdsExecuteSQL had no + // substitution step, so calling bindXxx() on a prepared statement always + // fell through to get_table(), which knows nothing about statement handles, + // and returned [5000] unknown table. Adding params here gives AdsSet* a + // place to store :name -> SQL-literal pairs that AdsExecuteSQL can substitute + // before handing the final SQL to the parser. + std::unordered_map params; + UNSIGNED32 sql_timeout = 0; + // Per-statement table-open overrides set by AdsStmt* helpers. + // 0 = ADS_DEFAULT → CDX. NOTE (2026-07-28): this default is weaker + // than it looks, and callers cannot always correct it. Harbour's + // contrib/rddads calls AdsStmtSetTableType only when the requested + // type is ADS_CDX or ADS_VFP -- an ADS_ADT statement never reaches + // us, because on the real Advantage engine a statement already + // defaults to ADT. So an ADT table queried through Harbour arrives + // here as CDX and nothing in the SQL text can say otherwise when the + // file name carries no type (e.g. the RusSoft ERP names every table + // .DAT). Connection::resolve_table_file therefore sniffs the file + // header and overrides this on open; keep that in place before + // trusting table_type for anything that touches an existing file. + UNSIGNED16 table_type = 0; + UNSIGNED16 lock_type = 0; // 0 = default → compatible locking + UNSIGNED16 char_type = 0; + UNSIGNED16 read_only = 0; // non-zero → open read-only + UNSIGNED16 check_rights = 0; + bool disable_enc = false; + std::string collation; + std::vector> passwords; +}; + +std::unordered_map>& stmt_map() { + static std::unordered_map> m; + return m; +} + +std::unordered_map& sql_timeout_map() { + static std::unordered_map m; + return m; +} + +// stmt_map() is a process-wide table shared by every connection. Its +// structural operations (insert / find / erase) must all hold stmt_mu(); +// without it, an insert that rehashes the map while another thread walks a +// bucket corrupts the structure (the load-tested crash/hang at >= 8 threads). +// A dedicated mutex (not the global state lock) keeps query execution off the +// lock and avoids any cross-ordering with it. +std::mutex& stmt_mu() { + static std::mutex m; + return m; +} + +ADSHANDLE next_stmt_handle() { + // Atomic so concurrent AdsCreateSQLStatement calls never hand out a + // duplicate handle (the old non-atomic ++ could race two callers onto the + // same value). + static std::atomic n{0x60000000ULL}; + return static_cast(++n); +} + +// Look up a statement and return the raw pointer under the lock, then release: +// a statement is only ever executed by the thread that owns its handle, so the +// pointer stays valid for that thread while long query execution runs free of +// the lock. Returns nullptr if the handle is unknown. +SqlStatement* stmt_lookup(ADSHANDLE h) { + std::lock_guard lk(stmt_mu()); + auto& m = stmt_map(); + auto it = m.find(h); + return it == m.end() ? nullptr : it->second.get(); +} + +ADSHANDLE stmt_register(std::unique_ptr stmt) { + ADSHANDLE h = next_stmt_handle(); + std::lock_guard lk(stmt_mu()); + stmt_map()[h] = std::move(stmt); + return h; +} + +void stmt_unregister(ADSHANDLE h) { + std::lock_guard lk(stmt_mu()); + stmt_map().erase(h); + sql_timeout_map().erase(h); +} + +// RCB 2026-05-22 17:03 -- Statement handles live in stmt_map() which is a plain +// unordered_map keyed on the handle value (starting at 0x60000000). They are +// completely invisible to get_table(), which queries the separate HandleRegistry +// for HandleKind::Table. This helper is the single check point: if h is in +// stmt_map we store the value as a SQL literal string against the parameter name +// and return true so the caller skips the table path entirely. The parameter +// name may arrive with or without a leading colon depending on the caller. +bool set_stmt_param(ADSHANDLE h, const char* pname, std::string literal) { + std::lock_guard lk(stmt_mu()); + auto& m = stmt_map(); + auto it = m.find(h); + if (it == m.end()) return false; + std::string key(pname ? pname : ""); + if (!key.empty() && key[0] == ':') key.erase(0, 1); + it->second->params[key] = std::move(literal); + return true; +} + +UNSIGNED32 inherited_sql_timeout(ADSHANDLE hConnect) { + auto& m = sql_timeout_map(); + auto it = m.find(hConnect); + return it == m.end() ? 0 : it->second; +} + +openads::engine::TableType stmt_table_type(const SqlStatement& s) { + return map_type(s.table_type); +} + +openads::engine::OpenMode stmt_open_mode(const SqlStatement& s, bool for_write) { + if (s.read_only != 0) return openads::engine::OpenMode::Read; + return for_write ? openads::engine::OpenMode::Shared + : openads::engine::OpenMode::Read; +} + +openads::engine::LockingMode stmt_locking_mode(const SqlStatement& s) { + return (s.lock_type == ADS_PROPRIETARY_LOCKING) + ? openads::engine::LockingMode::Proprietary + : openads::engine::LockingMode::Compatible; +} + +// SQL DML (UPDATE/DELETE/INSERT/MERGE) opens tables Shared so multiuser +// readers can coexist, but writeback_record_() enforces a GoHot lock guard +// in Shared mode. Without an engine-held lock every SET/DELETE hits 5035 +// "record not locked" -- which broke AFTER-trigger bodies (UPDATE log SET…), +// plain SQL DML, NewSeqKey, and the unit suite after the write-guard landed +// (issue #138). Hold a table-exclusive lock for the life of the statement, +// matching the RI cascade path. Navigational multiuser still requires an +// explicit RLock/FLock; that contract is covered by engine_table_write_test. +inline void sql_dml_hold_write_lock(openads::engine::Table* tbl) { + if (tbl == nullptr || tbl->is_table_locked()) return; + (void)tbl->try_lock_table_excl(); +} + +// M-DML.IDX — production-index maintenance for SQL DML (Pritpal Bedi's +// .z01 work bags, Aug 2026). INSERT/UPDATE/DELETE/MERGE open the table +// through the ENGINE connection (Connection::open_table), which never +// binds the structural bag — so every SQL write left a production CDX +// stale, and the next handle to open it navigated a ghost order +// (bof=eof=1 Limbo over a non-empty table). SAP maintains the structural +// index on server-side DML. Bind .cdx (.adi for ADT) as parked +// extra views for the life of the statement so commit_dirty_record keeps +// it current. The guard flushes + frees the views at scope end; the +// unregister is skipped when the DML already closed the table (most paths) +// because the registry lookup then fails — touching the dead Table* would +// be UB. +struct DmlProductionIndexGuard { + openads::session::Connection* conn = nullptr; + openads::session::Handle th = 0; + std::vector> owned; + ~DmlProductionIndexGuard() { + if (owned.empty()) return; + if (conn != nullptr) { + if (auto* t = conn->lookup_table(th); t != nullptr) { + for (auto& v : owned) t->unregister_extra_index_view(v.get()); + } + } + for (auto& v : owned) (void)v->flush(); + } +}; + +void dml_bind_production_index(openads::session::Connection* c, + openads::session::Handle th, + DmlProductionIndexGuard& g) { + namespace fs = std::filesystem; + auto* tbl = c->lookup_table(th); + if (tbl == nullptr) return; + fs::path tp(tbl->path()); + std::string ext = tp.extension().string(); + for (auto& ch : ext) + ch = static_cast(std::tolower(static_cast(ch))); + fs::path bag; + bool use_cdx = false; + if (ext == ".dbf") { + // Structural candidates for a DBF: .cdx, else .z01 — + // Pritpal's ERP keeps its compound bags under a custom extension + // (CDX format by content). SQL DML must maintain whichever exists + // or every INSERT leaves the bag partially stale. + bag = tp; bag.replace_extension(".cdx"); use_cdx = true; + std::error_code ec1; + if (!fs::exists(openads::platform::resolve_case_insensitive( + bag.string()), ec1)) { + bag = tp; bag.replace_extension(".z01"); + } + } else if (ext == ".adt" || ext == ".dat") { + bag = tp; bag.replace_extension(".adi"); + } else { + return; + } + std::error_code ec; + const std::string bag_path = + openads::platform::resolve_case_insensitive(bag.string()); + if (!fs::exists(bag_path, ec)) return; + std::vector tags; + if (use_cdx) { + auto r = openads::drivers::cdx::CdxIndex::list_tags(bag_path); + if (!r) return; + tags = std::move(r).value(); + } else { + auto r = openads::drivers::adi::AdiIndex::list_tags(bag_path, + tbl->path()); + if (!r) return; + tags = std::move(r).value(); + } + for (const auto& name : tags) { + std::unique_ptr sub; + if (use_cdx) { + auto idx = std::make_unique(); + if (auto r = idx->open_named(bag_path, + openads::drivers::IndexOpenMode::Shared, name); !r) { + continue; + } + mark_cdx_key_encoding(tbl, idx.get()); + apply_cdx_oem_collation(tbl, idx.get()); + sub = std::move(idx); + } else { + auto idx = std::make_unique(); + if (auto r = idx->open_named(bag_path, + openads::drivers::IndexOpenMode::Shared, name, + tbl->path()); !r) { + continue; + } + sub = std::move(idx); + } + tbl->register_extra_index_view(sub.get()); + g.owned.push_back(std::move(sub)); + } + if (!g.owned.empty()) { g.conn = c; g.th = th; } +} + +} // namespace + +} // extern "C++" + +UNSIGNED32 ENTRYPOINT AdsCreateSQLStatement(ADSHANDLE hConnect, ADSHANDLE* phStatement) { + arc2_trace("AdsCreateSQLStatement"); + arc2_trace("AdsCreateSQLStatement"); + if (phStatement == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + auto& s = state(); + std::lock_guard lk(s.mu); + if (auto* rc = s.registry.lookup( + hConnect, HandleKind::RemoteConnection)) { + auto stmt = std::make_unique(); + stmt->remote = rc; + stmt->sql_timeout = inherited_sql_timeout(hConnect); + *phStatement = stmt_register(std::move(stmt)); + return ok(); + } +#if defined(OPENADS_WITH_SQLITE) + if (auto* sc = s.registry.lookup( + hConnect, HandleKind::SqliteConnection)) { + auto stmt = std::make_unique(); + stmt->sqlite = sc; + stmt->sql_timeout = inherited_sql_timeout(hConnect); + stmt->sql_username = sql_uri_username(hConnect); + *phStatement = stmt_register(std::move(stmt)); + return ok(); + } +#endif +#if defined(OPENADS_WITH_MSSQL) + if (auto* mc = s.registry.lookup( + hConnect, HandleKind::MssqlConnection)) { + auto stmt = std::make_unique(); + stmt->mssql_conn = mc; + stmt->sql_timeout = inherited_sql_timeout(hConnect); + stmt->sql_username = sql_uri_username(hConnect); + *phStatement = stmt_register(std::move(stmt)); + return ok(); + } +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* pc = get_postgres_conn(hConnect)) { + auto stmt = std::make_unique(); + stmt->postgres = pc; + stmt->sql_timeout = inherited_sql_timeout(hConnect); + stmt->sql_username = sql_uri_username(hConnect); + *phStatement = stmt_register(std::move(stmt)); + return ok(); + } +#endif +#if defined(OPENADS_WITH_MARIADB) + if (auto* mc = s.registry.lookup( + hConnect, HandleKind::MariaConnection)) { + auto stmt = std::make_unique(); + stmt->maria = mc; + stmt->sql_timeout = inherited_sql_timeout(hConnect); + stmt->sql_username = sql_uri_username(hConnect); + *phStatement = stmt_register(std::move(stmt)); + return ok(); + } +#endif +#if defined(OPENADS_WITH_ODBC) + if (auto* oc = s.registry.lookup( + hConnect, HandleKind::OdbcConnection)) { + auto stmt = std::make_unique(); + stmt->odbc = oc; + stmt->sql_timeout = inherited_sql_timeout(hConnect); + stmt->odbc_dialect = odbc_dialect_for(hConnect); + stmt->sql_username = sql_uri_username(hConnect); + *phStatement = stmt_register(std::move(stmt)); + return ok(); + } +#endif +#if defined(OPENADS_WITH_FIREBIRD) + if (auto* fc = s.registry.lookup( + hConnect, HandleKind::FirebirdConnection)) { + auto stmt = std::make_unique(); + stmt->firebird = fc; + stmt->sql_timeout = inherited_sql_timeout(hConnect); + stmt->sql_username = sql_uri_username(hConnect); + *phStatement = stmt_register(std::move(stmt)); + 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(); + stmt->conn = c; + stmt->sql_timeout = inherited_sql_timeout(hConnect); + *phStatement = stmt_register(std::move(stmt)); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsCloseSQLStatement(ADSHANDLE hStatement) { + arc2_trace("AdsCloseSQLStatement"); + arc2_trace("AdsCloseSQLStatement"); + stmt_unregister(hStatement); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsPrepareSQL(ADSHANDLE hStatement, UNSIGNED8* pucSQL) { + arc2_trace("AdsPrepareSQL"); + arc2_trace("AdsPrepareSQL"); + SqlStatement* st = stmt_lookup(hStatement); + if (st == nullptr) return fail(openads::AE_INTERNAL_ERROR, "unknown stmt"); + st->sql = openads::abi::to_internal(pucSQL, 0); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsPrepareSQLW(ADSHANDLE hStatement, UNSIGNED16* pwcSQL) { + arc2_trace("AdsPrepareSQLW"); + arc2_trace("AdsPrepareSQLW"); + std::string sql; + utf16z_to_utf8(pwcSQL, &sql); + std::vector bytes(sql.begin(), sql.end()); + bytes.push_back(0); + return AdsPrepareSQL(hStatement, bytes.data()); +} + +UNSIGNED32 ENTRYPOINT AdsGetNumParams(ADSHANDLE hStatement, UNSIGNED16* pusNumParams) { + arc2_trace("AdsGetNumParams"); + if (!pusNumParams) return fail(openads::AE_INTERNAL_ERROR, ""); + SqlStatement* st = stmt_lookup(hStatement); + if (st == nullptr) return fail(openads::AE_INTERNAL_ERROR, "unknown stmt"); + const std::string& sql = st->sql; + std::unordered_set names; + for (std::size_t i = 0; i < sql.size(); ) { + if (sql[i] == ':' && i + 1 < sql.size() && + (std::isalpha((unsigned char)sql[i + 1]) || sql[i + 1] == '_')) { + std::size_t j = i + 1; + while (j < sql.size() && + (std::isalnum((unsigned char)sql[j]) || sql[j] == '_')) + ++j; + names.insert(sql.substr(i + 1, j - (i + 1))); + i = j; + } else { + ++i; + } + } + *pusNumParams = static_cast(names.size()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsExecuteSQL(ADSHANDLE hStatement, ADSHANDLE* phCursor) { + arc2_trace("AdsExecuteSQL"); + SqlStatement* st_ptr = stmt_lookup(hStatement); + if (st_ptr == nullptr) return fail(openads::AE_INTERNAL_ERROR, "unknown stmt"); + // Alias so the body below keeps its `it->second->...` accesses unchanged; + // the lookup is now serialised and the pointer is used off the lock. + std::pair it_kv{hStatement, st_ptr}; + auto* it = &it_kv; + if (it->second->sql.empty()) { + return fail(openads::AE_PARSE_ERROR, "no prepared SQL"); + } + // AdsSet* stores literal values in SqlStatement::params keyed by the bare + // name (no colon); here we substitute every :name placeholder with its + // stored literal before the SQL reaches the parser. + // + // The substitution is a single left-to-right pass that, at each ':', + // consumes the WHOLE identifier (same boundary rule as AdsGetNumParams) + // and replaces it by exact-name lookup. A naive per-key find/replace was + // wrong on two counts: (1) ":p1" matched as a prefix of ":p10"/":p11"/..., + // so with >= 10 named params the double-digit placeholders were corrupted; + // and (2) a substituted literal could itself contain ":name" text and get + // re-scanned by a later key. A single pass that never re-scans emitted + // text and matches whole identifiers fixes both. The output is built in a + // std::string (no fixed size cap) and handed to AdsExecuteSQLDirect via a + // buffer sized to the result, so large multi-row INSERTs are not truncated. + const std::string& src = it->second->sql; + const auto& params = it->second->params; + std::string sql; + sql.reserve(src.size() + 64); + for (std::size_t i = 0; i < src.size(); ) { + if (src[i] == ':' && i + 1 < src.size() && + (std::isalpha((unsigned char)src[i + 1]) || src[i + 1] == '_')) { + std::size_t j = i + 1; + while (j < src.size() && + (std::isalnum((unsigned char)src[j]) || src[j] == '_')) + ++j; + std::string name = src.substr(i + 1, j - (i + 1)); + auto pit = params.find(name); + if (pit != params.end()) { + sql += pit->second; // bound literal + } else { + sql.append(src, i, j - i); // unknown :name -- leave verbatim + } + i = j; + } else { + sql += src[i]; + ++i; + } + } + std::vector buf(sql.size() + 1); + std::memcpy(buf.data(), sql.data(), sql.size()); + buf[sql.size()] = '\0'; + return AdsExecuteSQLDirect(hStatement, buf.data(), phCursor); +} + +// RCB 06/28/2026: system.* metadata is exposed through the existing Table +// cursor path, but backed by a read-only memory driver instead of writing +// _sys_*.adt scratch files to disk. This stops data-directory leaks, removes +// the pointless DD-memory -> disk -> cursor round trip, and keeps client +// behavior stable because the SQL executor still receives a normal Table. +extern "C++" { + +namespace { + +struct SystemTableFilter { + std::optional grantee; + std::optional object_name; + std::optional table_name; + std::optional group_name; + std::optional user_name; +}; + +extern "C++" std::optional +extract_system_table_filter_(const openads::sql::SelectStmt& st) { + if (!st.where) return std::nullopt; + SystemTableFilter filter; + bool found = false; + + auto visit = [&](const openads::sql::WhereExpr& node, + auto&& visit_ref) -> bool { + using Kind = openads::sql::WhereExpr::Kind; + if (node.kind == Kind::And) { + bool ok = true; + for (const auto& child : node.children) { + if (child) ok = visit_ref(*child, visit_ref) && ok; + } + return ok; + } + if (node.kind != Kind::Cmp) return false; + const auto& cmp = node.cmp; + if (cmp.op != openads::sql::WhereOp::Eq || + cmp.lhs_fn != openads::sql::WhereFn::None || + !cmp.column_alias.empty() || cmp.is_numeric || + cmp.subquery || cmp.is_outer_ref) { + return true; + } + std::string col = cmp.column; + for (auto& ch : col) + ch = static_cast(std::tolower(static_cast(ch))); + if (col == "grantee") { + filter.grantee = cmp.literal; + found = true; + } else if (col == "obj_name" || col == "name") { + // "name" is the SAP column name for the object in + // system.permissions (OpenADS renamed OBJ_NAME → Name). + filter.object_name = cmp.literal; + found = true; + } else if (col == "table_name") { + filter.table_name = cmp.literal; + found = true; + } else if (col == "group_name") { + filter.group_name = cmp.literal; + found = true; + } else if (col == "user_name") { + filter.user_name = cmp.literal; + found = true; + } + return true; + }; + + if (!visit(*st.where, visit) || !found) return std::nullopt; + return filter; +} + +} // namespace + +extern "C++" { + +// Column spec shared by the system.* virtual tables and the sp_* system +// procedure result sets. +struct SpCol { + const char* colname; + char type; // 'C' (CiCharacter), 'N' (int32), 'L' (logical) + std::uint16_t length; + std::uint8_t decimals; +}; + +// Builds a read-only memory table with ADT-compatible full-length field +// names. `tag` names the memory:// path for diagnostics only. Shared by +// build_system_table (system.* SELECT targets) and dispatch_sp_builtin_cursor +// (EXECUTE PROCEDURE sp_* result sets). +openads::util::Result
+build_memory_result(const std::string& tag, + const std::vector& cols, + const std::vector>& rows) { + std::vector fields; + fields.reserve(cols.size()); + std::uint32_t rlen = 1; // 1 byte delete flag + for (const auto& col : cols) { + openads::drivers::DbfField f; + f.name = col.colname; + f.decimals = col.decimals; + f.record_offset = static_cast(rlen); + if (col.type == 'N') { + f.raw_type = 11; + f.type = openads::drivers::DbfFieldType::Integer; + f.length = 4; + } else if (col.type == 'L') { + f.raw_type = 1; + f.type = openads::drivers::DbfFieldType::Logical; + f.length = 1; + } else { + f.raw_type = 20; + f.type = openads::drivers::DbfFieldType::CiCharacter; + f.length = col.length; + } + rlen += f.length; + if (rlen > 0xFFFFu) { + return openads::util::Error{openads::AE_INTERNAL_ERROR, 0, + "result table record too large", tag}; + } + fields.push_back(std::move(f)); + } + + std::vector> records; + records.reserve(rows.size()); + for (const auto& row : rows) { + std::vector rec(rlen, 0x20u); // space-fill + rec[0] = ' '; + for (std::size_t ci = 0; ci < cols.size(); ++ci) { + const std::string& val = ci < row.size() ? row[ci] : ""; + const auto& f = fields[ci]; + std::uint8_t* dst = rec.data() + f.record_offset; + if (f.type == openads::drivers::DbfFieldType::Integer) { + // Guard non-numeric text (e.g. an RI rule stored as a word) + // -- std::stol would throw across the ABI boundary and crash. + std::int32_t iv = 0; + if (!val.empty()) { + try { iv = static_cast(std::stol(val)); } + catch (...) { iv = 0; } + } + auto uiv = static_cast(iv); + dst[0] = static_cast( uiv & 0xFFu); + dst[1] = static_cast((uiv >> 8) & 0xFFu); + dst[2] = static_cast((uiv >> 16) & 0xFFu); + dst[3] = static_cast((uiv >> 24) & 0xFFu); + } else if (f.type == openads::drivers::DbfFieldType::Logical) { + dst[0] = (val == "1" || val == "T" || val == "t") ? 'T' : 'F'; + } else { // CICHAR: space-padded + std::size_t n2 = std::min(val.size(), f.length); + std::memcpy(dst, val.data(), n2); + } + } + records.push_back(std::move(rec)); + } + + char nb[96]; + std::snprintf(nb, sizeof(nb), "memory://%s/%llx", + tag.c_str(), + static_cast( + openads::platform::monotonic_nanos())); + auto drv = std::make_unique( + nb, std::move(fields), std::move(records), + static_cast(rlen)); + return Table::from_driver(std::move(drv), nb, + openads::engine::TableType::Adt, + openads::engine::OpenMode::Read, + openads::engine::LockingMode::Compatible); +} + +} // extern "C++" + +// Build a read-only memory table that materialises one of the system.* +// virtual tables from the connection's DataDict state. `sys_name` is the part +// after "system." (already lower-cased by the caller). +extern "C++" openads::util::Result
+build_system_table(Connection* c, std::string sys_name, + const SystemTableFilter* filter = nullptr) { + for (auto& ch : sys_name) ch = static_cast( + std::tolower(static_cast(ch))); + + auto* dd = c->dd(); + if (!dd) { + return openads::util::Error{openads::AE_NO_FILE_FOUND, 0, sys_name, ""}; + } + + namespace fs = std::filesystem; + + using Col = SpCol; + + auto build = [&](const std::vector& cols, + const std::vector>& rows) + -> openads::util::Result
{ + return build_memory_result("system." + sys_name, cols, rows); + }; + + // RCB 07/16/2026: size a text column to its longest actual value instead + // of a fixed CHAR width. system.{storedprocedures,functions} declared + // their body columns as C(255), so multi-KB SQL bodies (pmsys: + // sp_SaveIntoAuditLog is 5359 bytes) came back as a 255-byte prefix -- + // silent data loss in the catalog even though the DD and the execution + // path hold the full text. Memory-table records cap at 64 KB total, so + // clamp each column defensively below that. + auto fit_width = [](const std::vector>& rows, + std::size_t col_idx, std::size_t min_w) + -> std::uint16_t { + std::size_t w = min_w; + for (const auto& r : rows) + if (col_idx < r.size() && r[col_idx].size() > w) + w = r[col_idx].size(); + return static_cast(std::min(w, 60000)); + }; + + if (sys_name == "tables") { + // Column set matches SAP ADS system.tables for compatibility. + const std::vectorcols = { + {"Name", 'C', 200, 0}, + {"Table_Relative_Path", 'C', 250, 0}, + {"Table_Type", 'N', 5, 0}, + {"Table_Auto_Create", 'C', 6, 0}, + {"Table_Primary_Key", 'C', 200, 0}, + {"Table_Default_Index", 'C', 200, 0}, + {"Table_Encryption", 'C', 6, 0}, + {"Table_Permission_Level", 'N', 5, 0}, + {"Table_Memo_Block_Size", 'N', 5, 0}, + {"Table_Validation_Expr", 'C', 250, 0}, + {"Table_Validation_Msg", 'C', 250, 0}, + {"Comment", 'C', 250, 0}, + {"Triggers_Disabled", 'C', 6, 0}, + {"Table_Caching", 'N', 5, 0}, + {"Table_Trans_Free", 'C', 6, 0}, + {"Table_WEB_delta", 'C', 6, 0}, + {"Table_Concurrency_Enabled", 'C', 6, 0}, + }; + namespace fs = std::filesystem; + std::vector> rows; + for (const auto& kv : dd->tables()) { + const std::string& alias = kv.first; + const std::string& rel = kv.second; + if (!dd_can_view_object_metadata(c, alias)) continue; + // Infer table type from extension (3=ADT, 2=CDX/NTX) + std::string ext = fs::path(rel).extension().string(); + for (auto& ch : ext) ch = static_cast(std::tolower(static_cast(ch))); + int ttype = (ext == ".adt") ? 3 : 2; + std::string pk = dd->get_table_property(alias, 202); + std::string didx = dd->get_table_property(alias, 213); + std::string auto_create = dd->get_table_property(alias, 203); + std::string encryption = dd->get_table_property(alias, 214); + std::string memo_block = dd->get_table_property(alias, 215); + std::string perm_level = dd->get_table_property(alias, 216); + std::string caching = dd->get_table_property(alias, ADS_DD_TABLE_CACHING); + std::string validation_expr = dd->get_table_property(alias, 200); + std::string validation_msg = dd->get_table_property(alias, 201); + std::string comment = dd->get_table_property(alias, ADS_DD_COMMENT); + std::string txn_free = dd->get_table_property(alias, ADS_DD_TABLE_TXN_FREE); + rows.push_back({alias, rel, std::to_string(ttype), + (auto_create == "1" ? "True" : "False"), pk, didx, + (encryption == "1" ? "True" : "False"), + perm_level.empty() ? "0" : perm_level, + memo_block.empty() ? "0" : memo_block, + validation_expr, validation_msg, comment, + "False", caching.empty() ? "0" : caching, + (txn_free == "1" ? "True" : "False"), + "False", "False"}); + } + return build(cols, rows); + } + if (sys_name == "indexes") { + // RCB 07/16/2026: SAP ADS system.indexes is one row per index TAG with + // full metadata (Name/Expression/Condition/Options/Key_Length/...), not + // one row per file. The DD IndexEntry now carries the per-tag fields + // (populated by import_dd from SAP, or by CREATE INDEX); this projects + // them into SAP's exact column shape. Column names + order match SAP so + // a client that reads by name gets the same result on both engines. + const std::vectorcols = { + {"Name", 'C', 200, 0}, + {"Parent", 'C', 200, 0}, + {"Index_File_Name", 'C', 250, 0}, + {"Index_Expression", 'C', 250, 0}, + {"Index_Condition", 'C', 250, 0}, + {"Index_Options", 'C', 20, 0}, + {"Index_Key_Length", 'C', 20, 0}, + {"Index_FTS_Min_Length", 'C', 20, 0}, + {"Index_FTS_Delimiters", 'C', 100, 0}, + {"Index_FTS_Noise", 'C', 100, 0}, + {"Index_FTS_Drop_Chars", 'C', 100, 0}, + {"Index_FTS_Conditional_Chars", 'C', 100, 0}, + {"Comment", 'C', 200, 0}, + {"Index_Collation", 'C', 100, 0}, + }; + std::vector> rows; + auto add_index_row = [&](const auto& e) { + if (!dd_can_view_object_metadata(c, e.table_alias)) return; + // SAP reports the bare file name ("landlords.adi"), not the DD's + // stored relative path (".\\landlords.adi"). + std::string file = + std::filesystem::path(e.index_path).filename().string(); + rows.push_back({ + e.tag_name, e.table_alias, file, e.expression, e.condition, + e.options, e.key_length, + "0", "", "", "", "", // FTS fields -- empty on non-FTS indexes + e.comment, e.collation}); + }; + if (filter && filter->table_name) { + for (const auto* e : dd->indexes_for_table(*filter->table_name)) + add_index_row(*e); + } else { + for (const auto& e : dd->indexes()) + add_index_row(e); + } + return build(cols, rows); + } + if (sys_name == "primarykeys") { + // SAP ADS-style system.primarykeys: one row per primary-key column. + // The DD records only the PK tag NAME (property 202); the column list + // lives in the tag's key expression, which we read from the table's + // CDX bag(s) without activating any order. A composite key made of a + // simple field list ("F1+F2") expands to one row per field in order; + // a calculated expression (a function, or any operator other than + // '+') degrades to zero rows rather than report a guessed column. + const std::vectorcols = { + {"TABLE_NAME", 'C', 200, 0}, + {"COLUMN_NAME", 'C', 200, 0}, + {"KEY_SEQ", 'N', 5, 0}, + {"PK_NAME", 'C', 200, 0}, + }; + + // Split a key expression into an ordered list of plain field names. + // Returns empty if any term is not a bare identifier (degrade safely). + auto parse_simple_fields = + [](const std::string& expr) -> std::vector { + std::vector out; + std::size_t start = 0; + while (true) { + std::size_t plus = expr.find('+', start); + std::string term = expr.substr( + start, plus == std::string::npos ? std::string::npos + : plus - start); + std::size_t b = term.find_first_not_of(' '); + std::size_t e = term.find_last_not_of(' '); + if (b == std::string::npos) return {}; // empty term → bail + term = term.substr(b, e - b + 1); + bool okid = std::isalpha(static_cast(term[0])) || + term[0] == '_'; + for (std::size_t i = 1; okid && i < term.size(); ++i) { + const char ch = term[i]; + if (!(std::isalnum(static_cast(ch)) || + ch == '_')) + okid = false; + } + if (!okid) return {}; + out.push_back(term); + if (plus == std::string::npos) break; + start = plus + 1; + } + return out; + }; + + // Resolve a tag name to its key expression by reading a CDX bag, + // without activating the order. Tries DD-registered bags first, then + // the structural CDX next to the table. Returns empty if no CDX bag + // carries the tag. + auto expr_for_tag = [&](const std::string& table_rel, + const std::string& alias, + const std::string& tag) -> std::string { + std::vector bags; + for (const auto* ie : dd->indexes_for_table(alias)) + bags.push_back(ie->index_path); + { + fs::path tp(table_rel); + std::string ext = tp.extension().string(); + for (auto& ch : ext) + ch = static_cast( + std::tolower(static_cast(ch))); + if (ext != ".adt") // ADT keys live in .adi -- not read here + bags.push_back(tp.replace_extension(".cdx").string()); + } + for (const auto& bag : bags) { + fs::path full(bag); + if (!full.is_absolute()) + full = fs::path(c->data_dir()) / bag; + std::string fe = full.extension().string(); + for (auto& ch : fe) + ch = static_cast( + std::tolower(static_cast(ch))); + if (fe != ".cdx") continue; // only CDX understood here + openads::drivers::cdx::CdxIndex idx; + if (idx.open_named(full.string(), + openads::drivers::IndexOpenMode::ReadOnly, + tag)) + return idx.expression(); + } + return {}; + }; + + std::vector> rows; + for (const auto& kv : dd->tables()) { + const std::string& alias = kv.first; + const std::string& rel = kv.second; + if (filter && filter->table_name && + openads::engine::DataDict::ci_name(alias) != + openads::engine::DataDict::ci_name(*filter->table_name)) + continue; + if (!dd_can_view_object_metadata(c, alias)) continue; + std::string tag = dd->get_table_property(alias, 202); + if (tag.empty()) continue; + std::string expr = expr_for_tag(rel, alias, tag); + if (expr.empty()) continue; + std::vector fields = parse_simple_fields(expr); + if (fields.empty()) continue; // calculated/complex → no rows + int seq = 1; + for (const auto& f : fields) { + rows.push_back({alias, f, std::to_string(seq), tag}); + ++seq; + } + } + return build(cols, rows); + } + if (sys_name == "users") { + // SAP system.users column set + order (2026-07-29). Task #4 converted + // five catalogs to SAP's exact names but missed this one, so a client + // filtering `WHERE Name = ...` got "Column not found" from OpenADS. + // Logins_Disabled and Require_Old_Password are per-user in SAP but + // OpenADS only tracks logins-disabled at the DATABASE level + // (ADS_DD_LOGINS_DISABLED / prop_16), so they render as SAP's defaults + // rather than being invented per user. + const std::vectorcols = { + {"Name", 'C', 200, 0}, + {"Enable_Internet", 'L', 1, 0}, + {"Logins_Disabled", 'L', 1, 0}, + {"Comment", 'C', 200, 0}, + {"User_Defined_Prop", 'C', 4096, 0}, + {"Require_Old_Password", 'L', 1, 0}, + }; + // DD boolean props arrive in several encodings depending on who wrote + // them (SAP export, sp_ModifyUserProperty, a raw 2-byte UNSIGNED16). + auto prop_is_true = [](const std::string& v) { + if (v.empty()) return false; + if (v.size() == 2 && v[1] == '\0') // raw UNSIGNED16 + return v[0] != '\0'; + const char c = static_cast( + std::toupper(static_cast(v[0]))); + return c == 'T' || c == 'Y' || c == '1'; + }; + std::vector> rows; + // users_ holds folded lookup keys; show the declared spelling. + for (const auto& u : dd->users()) { + const auto internet = dd->get_user_property(u, "prop_1104"); + rows.push_back({ + dd->display_user(u), + prop_is_true(internet) ? "T" : "F", + "F", // not tracked per user + dd->get_user_property(u, "prop_1"), // COMMENT + dd->get_user_property(u, "prop_3"), // ADS_DD_USER_DEFINED_PROP + "T", // SAP default + }); + } + return build(cols, rows); + } + if (sys_name == "groups") { + const std::vectorcols = {{"GROUP_NAME", 'C', 200, 0}}; + std::vector> rows; + for (const auto& g : dd->groups()) + rows.push_back({g}); + return build(cols, rows); + } + if (sys_name == "usergroups") { + // Lists all defined groups (SAP: system.usergroups = group catalogue). + // Include both explicit group records AND groups referenced in memberships, + // so text-format DDs that only have MEMBER entries still list their groups. + // SAP system.usergroups column set + order (2026-07-29): Name, Comment, + // User_Defined_Prop. Group props share the user_props_ map (see + // sp_ModifyGroupProperty), so prop_1 / prop_3 apply here too. + const std::vectorcols = { + {"Name", 'C', 200, 0}, + {"Comment", 'C', 200, 0}, + {"User_Defined_Prop", 'C', 4096, 0}, + }; + std::unordered_set seen; + std::vector> rows; + auto add_group_row = [&](const std::string& g) { + if (filter && filter->group_name && + openads::engine::DataDict::ci_name(g) != + openads::engine::DataDict::ci_name(*filter->group_name)) + return; + if (seen.insert(g).second) + rows.push_back({g, + dd->get_user_property(g, "prop_1"), + dd->get_user_property(g, "prop_3")}); + }; + for (const auto& g : dd->groups()) + add_group_row(g); + for (const auto& kv : dd->memberships()) + for (const auto& g : kv.second) + add_group_row(g); + return build(cols, rows); + } + if (sys_name == "usergroupmembers") { + // Lists which users belong to which group. + // SAP column set + order (2026-07-29) is User_Name THEN Group_Name -- + // the reverse of the GROUP_NAME/USER_NAME pair OpenADS used to emit, so + // positional readers saw the two values swapped as well as misnamed. + // User_Name carries the declared spelling, matching system.users.Name. + const std::vectorcols = { + {"User_Name", 'C', 200, 0}, + {"Group_Name", 'C', 200, 0}, + }; + std::vector> rows; + if (filter && filter->group_name) { + for (const auto& user : dd->users_in_group(*filter->group_name)) + if (!filter->user_name || + openads::engine::DataDict::ci_name(user) == + openads::engine::DataDict::ci_name(*filter->user_name)) + rows.push_back({dd->display_user(user), *filter->group_name}); + } else if (filter && filter->user_name) { + for (const auto& grp : dd->groups_of(*filter->user_name)) + rows.push_back({dd->display_user(*filter->user_name), grp}); + } else { + for (const auto& kv : dd->memberships()) + for (const auto& grp : kv.second) + rows.push_back({dd->display_user(kv.first), grp}); + } + return build(cols, rows); + } + if (sys_name == "permission_grants") { + // RCB 06/30/2026: Expose only persisted DD permission grants for + // health checks and administration clients. system.permissions keeps + // SAP-compatible zero rows, but that compatibility surface is too + // expensive for clients that only need actual grant records. + const std::vectorcols = { + {"OBJ_NAME", 'C', 200, 0}, + {"OBJ_TYPE", 'N', 3, 0}, + {"OBJ_TYPE_NAME", 'C', 40, 0}, + {"GRANTEE", 'C', 200, 0}, + {"GRANTEE_TYPE", 'C', 10, 0}, + {"BITMASK", 'C', 20, 0}, + }; + + std::vector perm_src; + if (filter && filter->grantee && filter->object_name) { + auto by_grantee = dd->permissions_by_grantee(*filter->grantee); + perm_src.reserve(by_grantee.size()); + const auto obj_ci = openads::engine::DataDict::ci_name(*filter->object_name); + for (const auto* pe : by_grantee) { + if (openads::engine::DataDict::ci_name(pe->object_name) == obj_ci) + perm_src.push_back(pe); + } + } else if (filter && filter->grantee) { + auto by_grantee = dd->permissions_by_grantee(*filter->grantee); + perm_src.assign(by_grantee.begin(), by_grantee.end()); + } else if (filter && filter->object_name) { + auto by_object = dd->permissions_by_object(*filter->object_name); + perm_src.assign(by_object.begin(), by_object.end()); + } else { + perm_src.reserve(dd->permissions().size()); + for (const auto& pe : dd->permissions()) + perm_src.push_back(&pe); + } + + std::vector> rows; + rows.reserve(perm_src.size()); + for (const auto* pe : perm_src) { + rows.push_back({ + pe->object_name, + std::to_string(pe->object_type_code), + pe->object_type, + pe->grantee, + pe->grantee_is_group ? "GROUP" : "USER", + std::to_string(pe->bitmask), + }); + } + return build(cols, rows); + } + if (sys_name == "permission_issues") { + // RCB 06/30/2026: Run permission-grant health checks in core so + // remote administration clients receive only findings instead of + // pulling every grant row across the client/server boundary. + const std::vectorcols = { + {"SEVERITY", 'C', 20, 0}, + {"AREA", 'C', 40, 0}, + {"OBJECT", 'C', 200, 0}, + {"MESSAGE", 'C', 250, 0}, + {"DETAIL", 'C', 250, 0}, + }; + + std::unordered_set users_ci; + std::unordered_set groups_ci; + std::unordered_set objects_ci; + users_ci.reserve(dd->users().size()); + groups_ci.reserve(dd->groups().size() + 4); + objects_ci.reserve(dd->tables().size() + dd->views().size() + + dd->procs().size() + dd->functions().size() + + dd->links().size() + dd->users().size() + + dd->groups().size() + 8); + + for (const auto& u : dd->users()) { + users_ci.insert(openads::engine::DataDict::ci_name(u)); + objects_ci.insert(openads::engine::DataDict::ci_name(u)); + } + for (const auto& g : dd->groups()) { + groups_ci.insert(openads::engine::DataDict::ci_name(g)); + objects_ci.insert(openads::engine::DataDict::ci_name(g)); + } + for (const auto& g : {"DB:Admin", "DB:Backup", "DB:Debug", "DB:Public"}) + groups_ci.insert(openads::engine::DataDict::ci_name(g)); + for (const auto& [name, _] : dd->tables()) + objects_ci.insert(openads::engine::DataDict::ci_name(name)); + for (const auto& [name, _] : dd->views()) + objects_ci.insert(openads::engine::DataDict::ci_name(name)); + for (const auto& [name, _] : dd->procs()) + objects_ci.insert(openads::engine::DataDict::ci_name(name)); + for (const auto& [name, _] : dd->functions()) + objects_ci.insert(openads::engine::DataDict::ci_name(name)); + for (const auto& [name, _] : dd->links()) + objects_ci.insert(openads::engine::DataDict::ci_name(name)); + objects_ci.insert("database"); + + std::vector> rows; + for (const auto& pe : dd->permissions()) { + const auto grantee_ci = + openads::engine::DataDict::ci_name(pe.grantee); + if (!users_ci.count(grantee_ci) && !groups_ci.count(grantee_ci)) { + rows.push_back({ + "warning", + "Permissions", + pe.grantee, + "Permission grant references a missing user or group.", + pe.object_name, + }); + } + + bool object_known = false; + const auto obj_ci = + openads::engine::DataDict::ci_name(pe.object_name); + if (pe.object_type == "User") { + object_known = users_ci.count(obj_ci) != 0; + } else if (pe.object_type == "Group") { + object_known = groups_ci.count(obj_ci) != 0; + } else if (pe.object_type == "Database") { + object_known = true; + } else { + object_known = objects_ci.count(obj_ci) != 0; + } + if (!pe.object_name.empty() && !object_known) { + rows.push_back({ + "warning", + "Permissions", + pe.object_name, + "Permission grant references a missing DD object.", + pe.grantee, + }); + } + } + return build(cols, rows); + } + if (sys_name == "permissions") { + // Columns match SAP system.permissions: + // OBJ_NAME, OBJ_TYPE (numeric), PARENT, GRANTEE + // then 10 permission flag columns (0=denied, 1=granted, ""=N/A for type) + // PARENT is empty for top-level objects; table name for field rows (OBJ_TYPE=4). + // + // ADS_PERMISSION_* bitmask constants (ace.h): + // 0x001=READ/SELECT 0x002=UPDATE 0x004=EXECUTE 0x008=INHERIT(meta) + // 0x010=INSERT 0x020=DELETE 0x040=LINK_ACCESS + // 0x080=CREATE 0x100=ALTER 0x200=DROP + // 0x80000000=WITH_GRANT / SAP sentinel + // + // Column display values: + // "" = not applicable for this object/grantee type + // "0" = permission not granted + // "1" = permission granted to a GROUP grantee + // "2" = permission granted directly to a USER grantee + // + // SAP binary .add files store 0x80000000 as a constant info2 sentinel + // for ALL Permission records; per-bit rights are in encrypted blobs + // OpenADS cannot decode. We therefore treat the SAP sentinel as + // granting full DML for group records. + const std::vectorcols = { + {"Name", 'C', 200, 0}, + {"Object_Type", 'N', 3, 0}, + {"Parent", 'C', 200, 0}, + {"Grantee", 'C', 200, 0}, + {"Select", 'C', 1, 0}, + {"Update", 'C', 1, 0}, + {"Insert", 'C', 1, 0}, + {"Delete", 'C', 1, 0}, + {"Execute", 'C', 1, 0}, + {"Access", 'C', 1, 0}, + {"Inherit", 'C', 1, 0}, + {"Create", 'C', 1, 0}, + {"Alter", 'C', 1, 0}, + {"Drop", 'C', 1, 0}, + }; + + const uint32_t SAP_SENTINEL = 0x80000000u; + + // Return "1" or "2" depending on grantee type, or "0" if not set. + // Groups use "1"; users use "2". + auto perm_val = [](bool set, bool is_grp) -> std::string { + if (!set) return "0"; + return is_grp ? "1" : "2"; + }; + + // Decode a DML bitmask for a single logical column. + // sap_bit: actual bit position in the ADS_PERMISSION mask. + auto dml_col = [&perm_val]( + uint32_t mask, bool is_grp, int sap_bit) -> std::string { + if (mask & SAP_SENTINEL) { + // SAP sentinel: cannot decode actual level from encrypted blobs. + // For groups: approximate as full DML (SELECT+UPDATE+INSERT+DELETE). + // For users: SAP sentinel alone = INHERIT-only, no direct DML. + return perm_val(is_grp, is_grp); + } + return perm_val((mask >> sap_bit) & 1u, is_grp); + }; + + // Execute column (ADS_PERMISSION_EXECUTE = bit 2 = 0x004). + auto exe_col = [&perm_val](uint32_t mask, bool is_grp) -> std::string { + if (mask & SAP_SENTINEL) return perm_val(is_grp, is_grp); + return perm_val((mask >> 2) & 1u, is_grp); + }; + + std::vector> rows; + + // --- Canonical SAP permission matrix ------------------------------- + // SAP renders system.permissions as a full grantee x object cross + // product (uniform: every grantee lists every object). Grantees = + // real users + real groups + the two server pseudo-groups + // (SERVER:Admin / SERVER:Monitor); the adssys admin is omitted, as SAP + // does. Objects = every table and every one of its columns, plus + // users, groups, stored procedures, functions and links, plus a + // per-category "root" node SAP always lists (TABLE / VIEW / USER / + // USER GROUP / PROCEDURE / LINK / PUBLICATION / SUBSCRIPTION / PACKAGE + // and the Database singleton). Every cell renders "0"/"1"/"2", never + // blank, matching SAP. + auto is_adssys = [](const std::string& n) { + return openads::engine::DataDict::ci_name(n) == "adssys"; + }; + + struct MtxGrantee { std::string name; bool is_group; }; + std::vector grantees; + std::unordered_set gseen; + auto add_grantee = [&](const std::string& n, bool grp) { + if (is_adssys(n)) return; + if (filter && filter->grantee && + openads::engine::DataDict::ci_name(n) != + openads::engine::DataDict::ci_name(*filter->grantee)) + return; + if (gseen.insert(openads::engine::DataDict::ci_name(n)).second) + grantees.push_back({n, grp}); + }; + // SAP reports Grantee with the declared spelling ("RCB", "AutoTasks"). + // add_grantee() already dedupes/filters case-insensitively. + for (const auto& u : dd->users()) add_grantee(dd->display_user(u), false); + for (const auto& g : dd->groups()) add_grantee(g, true); + add_grantee("SERVER:Admin", true); + add_grantee("SERVER:Monitor", true); + + struct MtxObj { + std::string name; + std::string parent; // table name for fields; "" otherwise + int type; // SAP Object_Type code + }; + std::vector objects; + auto add_obj = [&](const std::string& n, const std::string& parent, + int type) { + if (filter && filter->object_name && + openads::engine::DataDict::ci_name(n) != + openads::engine::DataDict::ci_name(*filter->object_name)) + return; + objects.push_back({n, parent, type}); + }; + + // Tables and every column of every table (columns read live from the + // physical descriptors, exactly like system.columns). + for (const auto& kv : dd->tables()) { + add_obj(kv.first, "", 1); + auto th = c->open_table(kv.second, + openads::engine::TableType::Cdx, + openads::engine::OpenMode::Read); + if (th) { + openads::engine::Table* tbl = c->lookup_table(th.value()); + if (tbl) { + std::uint16_t nf = tbl->field_count(); + for (std::uint16_t i = 0; i < nf; ++i) + add_obj(tbl->field_descriptor(i).name, kv.first, 4); + } + c->close_table(th.value()); + } + } + add_obj("TABLE", "", 1); // t1 category root + for (const auto& kv : dd->views()) add_obj(kv.first, "", 6); + add_obj("VIEW", "", 6); // t6 category root + for (const auto& u : dd->users()) + if (!is_adssys(u)) add_obj(dd->display_user(u), "", 8); + add_obj("USER", "", 8); // t8 category root + for (const auto& g : dd->groups()) add_obj(g, "", 9); + add_obj("SERVER:Admin", "", 9); + add_obj("SERVER:Monitor", "", 9); + add_obj("USER GROUP", "", 9); // t9 category root + for (const auto& kv : dd->procs()) add_obj(kv.first, "", 10); + add_obj("PROCEDURE", "", 10); // t10 category root + add_obj("Database", "", 11); // t11 singleton + for (const auto& kv : dd->links()) add_obj(kv.first, "", 12); + add_obj("LINK", "", 12); // t12 category root + add_obj("PUBLICATION", "", 15); // t15 category root + add_obj("SUBSCRIPTION", "", 17); // t17 category root + for (const auto& kv : dd->functions()) add_obj(kv.first, "", 18); + add_obj("FUNCTION", "", 18); // t18 category root + add_obj("PACKAGE", "", 19); // t19 category root + + // Decode one (grantee, object) pair into the 14 output columns. + // Fields inherit their parent table's grant; every other object is + // looked up on its own name/type. Rights OpenADS cannot decode from + // SAP's encrypted permission blobs render "0". + auto emit_cell = [&](const MtxObj& obj, const MtxGrantee& gr) { + uint32_t m = 0; + const auto* pe = (obj.type == 4) + ? dd->find_permission(gr.name, obj.parent, 1) + : dd->find_permission(gr.name, obj.name, obj.type); + if (pe) m = pe->bitmask; + bool g = gr.is_group; + std::string S = "0", U = "0", I = "0", D = "0", E = "0", + Ac = "0", In = "0", Cr = "0", Al = "0", Dr = "0"; + switch (obj.type) { + case 1: // Table + S = dml_col(m, g, 0); U = dml_col(m, g, 1); + I = dml_col(m, g, 4); D = dml_col(m, g, 5); + break; + case 4: // Field (inherits parent table SELECT/UPDATE/INSERT) + S = dml_col(m, g, 0); U = dml_col(m, g, 1); + I = dml_col(m, g, 4); + break; + case 10: // StoredProc + case 18: // Function + E = exe_col(m, g); + break; + case 11: // Database (all DML + execute meaningful) + S = dml_col(m, g, 0); U = dml_col(m, g, 1); + I = dml_col(m, g, 4); D = dml_col(m, g, 5); + E = exe_col(m, g); + break; + default: // Views / users / groups / links / category roots + break; + } + rows.push_back({obj.name, std::to_string(obj.type), obj.parent, + gr.name, S, U, I, D, E, Ac, In, Cr, Al, Dr}); + }; + + rows.reserve(grantees.size() * objects.size()); + for (const auto& gr : grantees) + for (const auto& obj : objects) + emit_cell(obj, gr); + + return build(cols, rows); + } + if (sys_name == "effectivepermissions") { + // Effective permissions for the connected user: direct grants OR-ed with + // every group the user belongs to. Same columns as system.permissions. + // Only objects where an ACL entry exists are listed; objects without any + // ACL entry are open-access and are omitted (caller may infer full access). + const std::vectorcols = { + {"OBJ_NAME", 'C', 200, 0}, + {"OBJ_TYPE", 'N', 3, 0}, + {"GRANTEE", 'C', 200, 0}, + {"SELECT", 'C', 1, 0}, + {"UPDATE", 'C', 1, 0}, + {"INSERT", 'C', 1, 0}, + {"DELETE", 'C', 1, 0}, + {"EXECUTE", 'C', 1, 0}, + {"ACCESS", 'C', 1, 0}, + {"INHERIT", 'C', 1, 0}, + {"CREATE", 'C', 1, 0}, + {"ALTER", 'C', 1, 0}, + {"DROP", 'C', 1, 0}, + }; + const std::string& user = c->username(); + if (user.empty()) { + // No logged-in user -- return empty (caller treats as open access). + return build(cols, {}); + } + auto entries = dd->get_all_effective_perms(user); + std::vector> rows; + rows.reserve(entries.size()); + auto b1 = [](bool v) -> std::string { return v ? "1" : "0"; }; + for (const auto& e : entries) { + const auto& t = e.object_type; + bool is_table = (t == "Table"); + bool is_exec = (t == "StoredProc" || t == "Function"); + bool is_db = (t == "Database"); + const auto& o = e.ops; + rows.push_back({ + e.object_name, + std::to_string(e.object_type_code), + e.grantee, + (is_table || is_db) ? b1(o.select_) : "", // SELECT + (is_table || is_db) ? b1(o.update_) : "", // UPDATE + (is_table || is_db) ? b1(o.insert_) : "", // INSERT + (is_table || is_db) ? b1(o.delete_) : "", // DELETE + (is_exec || is_db) ? b1(o.execute_) : "", // EXECUTE + "", // ACCESS + "", // INHERIT (N/A for effective) + "", // CREATE + "", // ALTER + "", // DROP + }); + } + return build(cols, rows); + } + if (sys_name == "relations") { + // SAP system.relations column set + order (2026-07-26): primary + // (parent) table/index, then foreign (child) table/index, then the + // numeric update/delete rules. RI_No_PKey_Error / RI_Cascade_Error + // are empty (OpenADS does not track them; SAP shows them empty on + // pmsys). SAP has no fail-table column. + const std::vectorcols = { + {"Name", 'C', 200, 0}, + {"RI_Primary_Table", 'C', 200, 0}, + {"RI_Primary_Index", 'C', 200, 0}, + {"RI_Foreign_Table", 'C', 200, 0}, + {"RI_Foreign_Index", 'C', 200, 0}, + {"RI_UpdateRule", 'N', 10, 0}, + {"RI_DeleteRule", 'N', 10, 0}, + {"RI_No_PKey_Error", 'C', 1, 0}, + {"RI_Cascade_Error", 'C', 1, 0}, + }; + // OpenADS stores the RI rule as a word; SAP exposes the raw + // numeric code, which is asymmetric between update and delete + // (update Cascade=1, delete Cascade=2; SetNull=3 both). Restrict's + // code is a documented default (0) -- pmsys has no Restrict RI to + // oracle it against. + auto ri_rule_code = [](const std::string& w, bool is_del) + -> std::string { + if (w == "Cascade") return is_del ? "2" : "1"; + if (w == "SetNull") return "3"; + return "0"; // Restrict / unknown + }; + std::vector> rows; + for (const auto& kv : dd->ri()) { + const auto& e = kv.second; + if (!dd_can_view_object_metadata(c, e.parent) || + !dd_can_view_object_metadata(c, e.child)) { + continue; + } + rows.push_back({e.name, e.parent, e.parent_tag, + e.child, e.child_tag, + ri_rule_code(e.update_opt, false), + ri_rule_code(e.delete_opt, true), "", ""}); + } + return build(cols, rows); + } + if (sys_name == "referentialintegrity") { + // SAP-compatible alias for system.relations. + const std::vectorcols = { + {"RI_NAME", 'C', 200, 0}, + {"PARENT_TABLE", 'C', 200, 0}, + {"CHILD_TABLE", 'C', 200, 0}, + {"PARENT_TAG", 'C', 200, 0}, + {"CHILD_TAG", 'C', 200, 0}, + {"UPDATE_RULE", 'N', 10, 0}, + {"DELETE_RULE", 'N', 10, 0}, + {"FAIL_TABLE", 'C', 200, 0}, + }; + std::vector> rows; + for (const auto& kv : dd->ri()) { + const auto& e = kv.second; + if (!dd_can_view_object_metadata(c, e.parent) || + !dd_can_view_object_metadata(c, e.child)) { + continue; + } + rows.push_back({e.name, e.parent, e.child, + e.parent_tag, e.child_tag, + e.update_opt, e.delete_opt, e.fail_table}); + } + return build(cols, rows); + } + if (sys_name == "links") { + const std::vectorcols = { + {"LINK_NAME", 'C', 200, 0}, + {"LINK_PATH", 'C', 250, 0}, + {"LINK_USER", 'C', 200, 0}, + }; + std::vector> rows; + for (const auto& kv : dd->links()) + rows.push_back({kv.second.alias, kv.second.path, kv.second.user}); + return build(cols, rows); + } + if (sys_name == "triggers") { + // SAP system.triggers column set (2026-07-26). Trig_Event_Type: + // 1=INSERT 2=UPDATE 3=DELETE. Trig_Trigger_Type (timing): 1=BEFORE + // 2=INSTEAD OF 4=AFTER. Trig_Container_Type is 3 for a SQL-script + // trigger (SAP constant, oracle-verified). Trig_Function_Name is + // empty for script triggers. Triggers_Disabled is the inverse of + // OpenADS's `enabled`. + std::vector> rows; + auto add_trigger_row = [&](const auto& e) { + if (!dd_can_view_object_metadata(c, e.table_alias)) return; + rows.push_back({e.name, e.table_alias, + std::to_string(e.event_mask), + std::to_string(e.timing), + "3", + e.container, "", + std::to_string(e.priority), + std::to_string(e.options), + e.comment, + e.enabled ? "F" : "T"}); + }; + if (filter && filter->table_name) { + for (const auto* e : dd->triggers_for_table(*filter->table_name)) + add_trigger_row(*e); + } else { + for (const auto& kv : dd->triggers()) + add_trigger_row(kv.second); + } + const std::vectorcols = { + {"Name", 'C', 200, 0}, + {"Trig_TableName", 'C', 200, 0}, + {"Trig_Event_Type", 'N', 10, 0}, + {"Trig_Trigger_Type", 'N', 10, 0}, + {"Trig_Container_Type", 'N', 10, 0}, + {"Trig_Container", 'C', fit_width(rows, 5, 4096), 0}, + {"Trig_Function_Name", 'C', 200, 0}, + {"Trig_Priority", 'N', 10, 0}, + {"Trig_Options", 'N', 10, 0}, + {"Comment", 'C', 200, 0}, + {"Triggers_Disabled", 'L', 1, 0}, + }; + return build(cols, rows); + } + if (sys_name == "storedprocedures") { + // SAP system.storedprocedures column set (2026-07-26). OpenADS + // has only script procs, so Proc_DLL_* are empty and + // Proc_Invoke_Option is 4 (SAP's constant for a script proc, + // oracle-verified across all pmsys procs). + std::vector> rows; + for (const auto& kv : dd->procs()) { + const auto& e = kv.second; + rows.push_back({e.name, e.input_params, e.output_params, + "", "", e.comment, "4", e.procedure}); + } + const std::vectorcols = { + {"Name", 'C', 200, 0}, + {"Proc_Input", 'C', fit_width(rows, 1, 250), 0}, + {"Proc_Output", 'C', fit_width(rows, 2, 250), 0}, + {"Proc_DLL_Name", 'C', 128, 0}, + {"Proc_DLL_Function_Name", 'C', 128, 0}, + {"Comment", 'C', fit_width(rows, 5, 200), 0}, + {"Proc_Invoke_Option", 'N', 10, 0}, + {"SQL_Script", 'C', fit_width(rows, 7, 255), 0}, + }; + return build(cols, rows); + } + if (sys_name == "functions") { + // SAP system.functions column set (2026-07-26). Package and + // User_Defined_Prop are empty (OpenADS does not track them). + std::vector> rows; + for (const auto& kv : dd->functions()) { + const auto& e = kv.second; + rows.push_back({e.name, "", e.return_type, e.input_params, + e.implementation, e.comment, ""}); + } + const std::vectorcols = { + {"Name", 'C', 200, 0}, + {"Package", 'C', 64, 0}, + {"Return Type", 'C', 64, 0}, + {"Input Parameters", 'C', fit_width(rows, 3, 200), 0}, + {"Implementation", 'C', fit_width(rows, 4, 255), 0}, + {"Comment", 'C', 200, 0}, + {"User_Defined_Prop", 'C', 64, 0}, + }; + return build(cols, rows); + } + if (sys_name == "views") { + // SAP system.views column set (2026-07-26): Name, View_Stmt_Len + // (byte length of the statement), View_Stmt, Comment, + // Triggers_Disabled. + std::vector> rows; + for (const auto& kv : dd->views()) { + const auto& e = kv.second; + // SAP's View_Stmt_Len includes the terminating NUL (probed: + // a 41-char statement reports 42). + rows.push_back({e.name, std::to_string(e.sql.size() + 1), + e.sql, e.comment, "F"}); + } + const std::vectorcols = { + {"Name", 'C', 200, 0}, + {"View_Stmt_Len", 'N', 10, 0}, + {"View_Stmt", 'C', fit_width(rows, 2, 250), 0}, + {"Comment", 'C', 200, 0}, + {"Triggers_Disabled", 'L', 1, 0}, + }; + return build(cols, rows); + } + if (sys_name == "dictionary") { + const std::vectorcols = { + {"PROP_NAME", 'C', 200, 0}, + {"PROP_VALUE", 'C', 250, 0}, + }; + std::vector> rows; + for (const auto& kv : dd->db_props()) + rows.push_back({kv.first, kv.second}); + return build(cols, rows); + } + if (sys_name == "iota") { + const std::vectorcols = {{"IOTA", 'C', 1, 0}}; + return build(cols, {{std::vector{" "}}}); + } + if (sys_name == "columns") { + // SAP schema (oracle-verified on pmsys.add): Name / Parent / + // Field_Num / Field_Type (numeric ADT code; CICHAR reports 20) / + // Field_Length / Field_Decimal / Field_Min_Value / + // Field_Max_Value / Field_Can_Be_Null (T|F) / + // Field_Default_Value / Field_Validation_Msg / Comment / + // User_Defined_Prop / Field_Options. Nullability and defaults + // come from the DD's Field records (field_props "required" / + // "default"); the rest from the physical descriptors. + const std::vectorcols = { + {"Name", 'C', 200, 0}, + {"Parent", 'C', 200, 0}, + {"Field_Num", 'N', 6, 0}, + {"Field_Type", 'N', 6, 0}, + {"Field_Length", 'N', 8, 0}, + {"Field_Decimal", 'N', 4, 0}, + {"Field_Min_Value", 'C', 32, 0}, + {"Field_Max_Value", 'C', 32, 0}, + {"Field_Can_Be_Null", 'L', 1, 0}, + {"Field_Default_Value", 'C', 100, 0}, + {"Field_Validation_Msg",'C', 100, 0}, + {"Comment", 'C', 100, 0}, + {"User_Defined_Prop", 'C', 100, 0}, + {"Field_Options", 'N', 6, 0}, + }; + auto ci_eq2 = [](const std::string& x, const std::string& y) { + if (x.size() != y.size()) return false; + for (std::size_t z = 0; z < x.size(); ++z) + if (std::toupper(static_cast(x[z])) != + std::toupper(static_cast(y[z]))) + return false; + return true; + }; + std::vector> rows; + for (const auto& kv : dd->tables()) { + if (!dd_can_view_object_metadata(c, kv.first)) continue; + std::string rel = kv.second; + auto th = c->open_table(rel, openads::engine::TableType::Cdx, + openads::engine::OpenMode::Read); + if (!th) continue; + openads::engine::Table* tbl = c->lookup_table(th.value()); + if (tbl) { + // DD field props for this table (nullable/default), if any. + const std::unordered_map>* tfp = + nullptr; + for (const auto& fpkv : dd->field_props()) { + if (ci_eq2(fpkv.first, kv.first)) { + tfp = &fpkv.second; + break; + } + } + std::uint16_t nf = tbl->field_count(); + for (std::uint16_t i = 0; i < nf; ++i) { + const auto& fd = tbl->field_descriptor(i); + bool required = false; + std::string defv; + if (tfp) { + for (const auto& fkv : *tfp) { + if (!ci_eq2(fkv.first, fd.name)) continue; + auto rit = fkv.second.find("required"); + required = rit != fkv.second.end() && + rit->second == "T"; + auto dit = fkv.second.find("default"); + if (dit != fkv.second.end()) defv = dit->second; + break; + } + } + // SAP reports the DD/ADT type code -- CICHAR is 20 + // there, not the ACE sweep constant 25. + unsigned tcode = fd.type == + openads::drivers::DbfFieldType::CiCharacter + ? 20u : map_field_type(fd.type); + rows.push_back({ + fd.name, + kv.first, + std::to_string(i + 1), + std::to_string(tcode), + std::to_string(fd.length), + std::to_string(fd.decimals), + "", // Field_Min_Value + "", // Field_Max_Value + required ? "F" : "T", // Field_Can_Be_Null + defv, + "", // Field_Validation_Msg + "", // Comment + "", // User_Defined_Prop + "0", // Field_Options + }); + } + } + c->close_table(th.value()); + } + return build(cols, rows); + } + return openads::util::Error{openads::AE_NO_FILE_FOUND, 0, sys_name, ""}; +} + +} // extern "C++" + +// --------------------------------------------------------------------------- +// sp_* system-procedure support machinery (M14): named events, LIKE-style +// pattern matching, and process snapshot access shared by the non-cursor +// (dispatch_sp_builtin) and cursor (dispatch_sp_builtin_cursor) dispatchers. +// --------------------------------------------------------------------------- +extern "C++" { +namespace spproc { + +// -- Named events (sp_CreateEvent / sp_SignalEvent / sp_WaitForEvent...) ---- +// Registrations are per (connection, event-name): every connection that +// created an event gets its own pending-signal counter and data queue, so +// one waiter consuming a signal doesn't starve another -- mirroring SAP's +// per-connection event registration model. Signals from any connection in +// this process reach all registrations of that name (remote clients' SQL +// executes inside the server process, so cross-client signaling works). +struct EventReg { + std::uint32_t pending = 0; // signals not yet consumed + std::uint64_t consumed = 0; // signal sequence number + bool with_data = false; + std::deque data; +}; + +inline std::mutex& ev_mu() { static std::mutex m; return m; } +inline std::condition_variable& ev_cv() { + static std::condition_variable cv; return cv; +} +inline std::map, EventReg>& ev_map() { + static std::map, EventReg> m; + return m; +} + +inline std::string lower(std::string s) { + for (auto& ch : s) ch = static_cast( + std::tolower(static_cast(ch))); + return s; +} + +// Case-insensitive SQL LIKE match with % and _ wildcards. An empty +// pattern matches everything (SAP treats empty/NULL pattern as "all"). +inline bool like_match(const std::string& pattern, const std::string& text) { + if (pattern.empty()) return true; + std::string p = lower(pattern), t = lower(text); + std::size_t pi = 0, ti = 0, star = std::string::npos, mark = 0; + while (ti < t.size()) { + if (pi < p.size() && (p[pi] == '_' || p[pi] == t[ti])) { + ++pi; ++ti; + } else if (pi < p.size() && p[pi] == '%') { + star = pi++; mark = ti; + } else if (star != std::string::npos) { + pi = star + 1; ti = ++mark; + } else { + return false; + } + } + while (pi < p.size() && p[pi] == '%') ++pi; + return pi == p.size(); +} + +// Treat a textual NULL argument the same as an omitted one. +inline bool is_null_arg(const std::string& s) { + return s.empty() || lower(s) == "null"; +} + +void drop_all_events_for(const void* conn) { + std::lock_guard lk(ev_mu()); + auto& m = ev_map(); + for (auto it = m.begin(); it != m.end(); ) { + if (it->first.first == conn) it = m.erase(it); + else ++it; + } +} + +} // namespace spproc +} // extern "C++" + +// Dispatch for ADS built-in sp_* stored procedures. Returns true and sets *prc +// if the name was recognized; caller falls through to the DLL path otherwise. +extern "C++" bool dispatch_sp_builtin( + Connection* c, + const std::string& uname, + const std::vector& args, + UNSIGNED32* prc) { + auto* dd = c->has_dd() ? c->dd() : nullptr; + auto arg = [&](std::size_t i) -> const std::string& { + static const std::string empty; + return (i < args.size()) ? args[i].text : empty; + }; + auto ri_int = [&](std::size_t i) -> std::int32_t { + return (i < args.size() && args[i].is_numeric) + ? static_cast(args[i].number) : 0; + }; + + if (uname == "SP_CREATEUSER") { + if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } + if (auto r = dd->create_user(arg(0)); !r) { *prc = fail(r.error()); return true; } + if (!arg(1).empty()) dd->set_user_property(arg(0), "prop_1101", arg(1)); + if (!arg(2).empty()) dd->set_user_property(arg(0), "prop_1", arg(2)); + *prc = ok(); return true; + } + if (uname == "SP_DROPUSER") { + if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } + if (auto r = dd->delete_user(arg(0)); !r) { *prc = fail(r.error()); return true; } + *prc = ok(); return true; + } + if (uname == "SP_CREATEGROUP") { + if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } + if (auto r = dd->create_group(arg(0)); !r) { *prc = fail(r.error()); return true; } + if (!arg(1).empty()) dd->set_user_property(arg(0), "prop_1", arg(1)); + *prc = ok(); return true; + } + if (uname == "SP_DROPGROUP") { + if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } + if (auto r = dd->delete_group(arg(0)); !r) { *prc = fail(r.error()); return true; } + *prc = ok(); return true; + } + if (uname == "SP_ADDUSERTOGROUP") { + if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } + if (auto r = dd->add_user_to_group(arg(0), arg(1)); !r) { *prc = fail(r.error()); return true; } + *prc = ok(); return true; + } + if (uname == "SP_REMOVEUSERFROMGROUP") { + if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } + if (auto r = dd->remove_user_from_group(arg(0), arg(1)); !r) { *prc = fail(r.error()); return true; } + *prc = ok(); return true; + } + if (uname == "SP_MODIFYUSERPROPERTY") { + if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } + std::string upr = arg(1); + for (auto& ch : upr) ch = static_cast( + std::toupper(static_cast(ch))); + std::string key; + if (upr == "USER_PASSWORD" || upr == "PASSWORD") key = "prop_1101"; + else if (upr == "COMMENT") key = "prop_1"; + else if (upr == "ENABLE_INTERNET") key = "prop_1104"; + else if (upr == "BAD_LOGINS") { *prc = ok(); return true; } // read-only + else key = "prop_" + arg(1); + if (auto r = dd->set_user_property(arg(0), key, arg(2)); !r) { *prc = fail(r.error()); return true; } + *prc = ok(); return true; + } + if (uname == "SP_MODIFYGROUPPROPERTY") { + if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } + std::string upr = arg(1); + for (auto& ch : upr) ch = static_cast( + std::toupper(static_cast(ch))); + std::string key = (upr == "COMMENT") ? "prop_1" : ("prop_" + arg(1)); + if (auto r = dd->set_user_property(arg(0), key, arg(2)); !r) { *prc = fail(r.error()); return true; } + *prc = ok(); return true; + } + if (uname == "SP_ADDTABLETODATABASE") { + if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } + if (auto r = dd->add_table(arg(0), arg(1)); !r) { *prc = fail(r.error()); return true; } + // arg(4) may contain semicolon-separated index file paths + std::string idxlist = arg(4); + if (!idxlist.empty()) { + std::string cur; + idxlist.push_back(';'); + for (char ch : idxlist) { + if (ch == ';') { + if (!cur.empty()) { dd->add_index_file(arg(0), cur, ""); cur.clear(); } + } else cur.push_back(ch); + } + } + *prc = ok(); return true; + } + if (uname == "SP_ADDINDEXFILETODATABASE") { + if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } + if (auto r = dd->add_index_file(arg(0), arg(1), arg(2)); !r) { *prc = fail(r.error()); return true; } + *prc = ok(); return true; + } + if (uname == "SP_MODIFYTABLEPROPERTY") { + if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } + dd->set_db_property("TABLEPROP." + arg(0) + "." + arg(1), arg(2)); + *prc = ok(); return true; + } + if (uname == "SP_MODIFYFIELDPROPERTY") { + if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } + std::string upr = arg(2); + for (auto& ch : upr) ch = static_cast( + std::toupper(static_cast(ch))); + std::string key; + if (upr == "FIELD_CAN_BE_NULL" || upr == "REQUIRED") key = "required"; + else if (upr == "FIELD_DEFAULT_VALUE" || upr == "DEFAULT") key = "default"; + else if (upr == "FIELD_VALIDATION_RULE" || upr == "VALIDATION_RULE") key = "rule"; + else if (upr == "FIELD_VALIDATION_MSG" || upr == "VALIDATION_MSG") key = "msg"; + else if (upr == "FIELD_MAX_VALUE") key = "max"; + else if (upr == "FIELD_MIN_VALUE") key = "min"; + else if (upr == "COMMENT") key = "comment"; + else key = arg(2); + if (auto r = dd->set_field_property(arg(0), arg(1), key, arg(3)); !r) { *prc = fail(r.error()); return true; } + *prc = ok(); return true; + } + if (uname == "SP_CREATEREFERENTIALINTEGRITY") { + if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } + openads::engine::DataDict::RiEntry e; + e.name = arg(0); + e.parent = arg(1); + e.child = arg(2); + e.parent_tag = arg(3); + e.update_opt = std::to_string(ri_int(4)); + e.delete_opt = std::to_string(ri_int(5)); + e.fail_table = arg(6); + if (auto r = dd->create_ri(e); !r) { *prc = fail(r.error()); return true; } + *prc = ok(); return true; + } + if (uname == "SP_DROPREFERENTIALINTEGRITY") { + if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } + if (auto r = dd->remove_ri(arg(0)); !r) { *prc = fail(r.error()); return true; } + *prc = ok(); return true; + } + if (uname == "SP_CREATELINK") { + if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } + // sp_CreateLink(Name, Dictionary, Global, StaticPath, AuthenticateActiveUser, UserName, Password) + if (auto r = dd->create_link(arg(0), arg(1), arg(5), arg(6)); !r) { *prc = fail(r.error()); return true; } + *prc = ok(); return true; + } + if (uname == "SP_DROPLINK") { + if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } + if (auto r = dd->drop_link(arg(0)); !r) { *prc = fail(r.error()); return true; } + *prc = ok(); return true; + } + + // sp_DisableTriggers([scope[, object]]) + // sp_EnableTriggers([scope[, object]]) + // scope: "CURRENT USER" | "ALL" | table_name | trigger_name (auto-detected) + // When scope is omitted or "CURRENT USER": disable/enable for this connection only. + // When scope is a table name: disable/enable all triggers on that table (persisted in DD). + // When scope is a trigger name: disable/enable that single trigger (persisted in DD). + // "ALL" disables/enables all triggers for all users (persisted in DD). + if (uname == "SP_DISABLETRIGGERS" || uname == "SP_ENABLETRIGGERS") { + bool enable = (uname == "SP_ENABLETRIGGERS"); + std::string scope_raw = arg(0); + std::string scope_u = scope_raw; + for (auto& ch : scope_u) ch = static_cast(std::toupper((unsigned char)ch)); + + if (scope_raw.empty() || scope_u == "CURRENT USER") { + // Connection-level disable (non-persistent, this connection only) + c->set_triggers_disabled(!enable); + *prc = ok(); return true; + } + if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } + if (scope_u == "ALL") { + // All triggers in the DD -- persist + for (auto& [key, trig] : dd->triggers()) + trig.enabled = enable; + *prc = ok(); return true; + } + // Check if scope_raw matches a table alias → disable all triggers for that table + bool found_table = false; + for (auto& [key, trig] : dd->triggers()) { + std::string ta = trig.table_alias; + std::string sr = scope_raw; + for (auto& ch : ta) ch = static_cast(std::tolower((unsigned char)ch)); + for (auto& ch : sr) ch = static_cast(std::tolower((unsigned char)ch)); + if (ta == sr) { trig.enabled = enable; found_table = true; } + } + if (found_table) { *prc = ok(); return true; } + // Check if scope_raw matches a trigger name → disable that single trigger + for (auto& [key, trig] : dd->triggers()) { + std::string tn = trig.name; + std::string sr = scope_raw; + for (auto& ch : tn) ch = static_cast(std::tolower((unsigned char)ch)); + for (auto& ch : sr) ch = static_cast(std::tolower((unsigned char)ch)); + if (tn == sr) { trig.enabled = enable; *prc = ok(); return true; } + } + *prc = fail(openads::AE_INTERNAL_ERROR, "trigger or table not found"); + return true; + } + if (uname == "SP_MODIFYDATABASE") { + if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } + std::string upr = arg(0); + for (auto& ch : upr) ch = static_cast( + std::toupper(static_cast(ch))); + std::string key; + if (upr == "ADMIN_PASSWORD") key = "prop_1101"; + else if (upr == "COMMENT") key = "prop_1"; + else if (upr == "DEFAULT_TABLE_PATH") key = "prop_3"; + else if (upr == "LOG_IN_REQUIRED") key = "prop_5"; + else if (upr == "ENABLE_INTERNET") key = "prop_6"; + else if (upr == "INTERNET_SECURITY_LEVEL")key = "prop_7"; + else if (upr == "VERIFY_ACCESS_RIGHTS") key = "prop_8"; + else if (upr == "ENCRYPT_NEW_TABLE") key = "prop_10"; + else if (upr == "MAX_FAILED_ATTEMPTS") key = "prop_11"; + else if (upr == "TEMP_TABLE_PATH") key = "prop_12"; + else if (upr == "ENCRYPT_TABLE_PASSWORD") key = "prop_13"; + else if (upr == "VERSION_MAJOR") key = "prop_14"; + else if (upr == "VERSION_MINOR") key = "prop_15"; + else key = arg(0); + if (auto r = dd->set_db_property(key, arg(1)); !r) { *prc = fail(r.error()); return true; } + *prc = ok(); return true; + } + + // ---- M14: remaining SAP system procedures (non-cursor group) ----------- + + // Truthy logical argument ('T', 'TRUE', '1', numeric non-zero). + auto arg_bool = [&](std::size_t i) -> bool { + if (i < args.size() && args[i].is_numeric) return args[i].number != 0.0; + std::string v = spproc::lower(arg(i)); + return v == "t" || v == "true" || v == "1" || v == ".t."; + }; + + // Open a table by DD alias or path, run `fn`, close it again. + auto with_table = [&](const std::string& tname, + openads::engine::OpenMode mode, + const std::function& fn) -> UNSIGNED32 { + auto th = c->open_table(tname, openads::engine::TableType::Cdx, mode); + if (!th) return fail(th.error()); + openads::engine::Table* t = c->lookup_table(th.value()); + if (!t) { + c->close_table(th.value()); + return fail(openads::AE_INTERNAL_ERROR, "post-open"); + } + UNSIGNED32 rc = fn(*t); + c->close_table(th.value()); + return rc; + }; + + auto unsupported = [&](const char* what) { + *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, what); + return true; + }; + + // -- Table maintenance --------------------------------------------------- + if (uname == "SP_PACKTABLE" || uname == "SP_PACKTABLEONLINE") { + // MemoBlockSize / Options arguments are accepted and ignored. + *prc = with_table(arg(0), openads::engine::OpenMode::Exclusive, + [&](openads::engine::Table& t) -> UNSIGNED32 { + auto r = t.pack(); + return r ? ok() : fail(r.error()); + }); + return true; + } + if (uname == "SP_PACKALLTABLESONLINE") { + if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } + for (const auto& kv : dd->tables()) { + UNSIGNED32 rc = with_table(kv.second, + openads::engine::OpenMode::Exclusive, + [&](openads::engine::Table& t) -> UNSIGNED32 { + auto r = t.pack(); + return r ? ok() : fail(r.error()); + }); + if (rc != openads::AE_SUCCESS) { *prc = rc; return true; } + } + *prc = ok(); return true; + } + if (uname == "SP_REINDEX" || uname == "SP_REINDEXONLINE") { + // PageSize / Options arguments are accepted and ignored. + *prc = with_table(arg(0), openads::engine::OpenMode::Exclusive, + [&](openads::engine::Table& t) -> UNSIGNED32 { + auto r = t.reindex(); + return r ? ok() : fail(r.error()); + }); + return true; + } + if (uname == "SP_ZAPTABLE") { + *prc = with_table(arg(0), openads::engine::OpenMode::Exclusive, + [&](openads::engine::Table& t) -> UNSIGNED32 { + auto r = t.zap(); + return r ? ok() : fail(r.error()); + }); + return true; + } + if (uname == "SP_CREATEINDEX" || uname == "SP_CREATEINDEX90") { + // (TableName, FileName, TagName, Expression, Condition, Options, + // PageSize[, Collation]) -- mirrors the CREATE INDEX SQL path: + // resolve this Connection's ADSHANDLE, open the table, and reuse + // AdsCreateIndex61. The optional sp_CreateIndex90 collation + // argument is accepted and ignored (index collation follows the + // connection's collation). + auto& s = state(); + ADSHANDLE conn_h = 0; + s.registry.for_each_handle([&](Handle h, HandleKind k, void* p) { + if (k != HandleKind::Connection) return; + if (static_cast(p) == c) conn_h = to_ads_handle(h); + }); + if (conn_h == 0) { + *prc = fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + return true; + } + std::string tname = arg(0); + std::vector name_buf(tname.size() + 1, 0); + std::memcpy(name_buf.data(), tname.data(), tname.size()); + ADSHANDLE hTable = 0; + if (auto rc = AdsOpenTable(conn_h, name_buf.data(), name_buf.data(), + ADS_CDX, 1, 1, 0, 1, &hTable); + rc != openads::AE_SUCCESS) { *prc = rc; return true; } + + namespace fs = std::filesystem; + std::string file = spproc::is_null_arg(arg(1)) ? "" : arg(1); + if (file.empty()) { + fs::path tbl_path(c->data_dir()); + tbl_path /= tname; + if (!tbl_path.has_extension()) tbl_path.replace_extension(".dbf"); + fs::path bag = tbl_path; + bag.replace_extension( + spproc::lower(tbl_path.extension().string()) == ".adt" + ? ".adi" : ".cdx"); + file = bag.string(); + } + std::string tag = spproc::is_null_arg(arg(2)) ? "" : arg(2); + std::string expr = arg(3); + std::string cond = spproc::is_null_arg(arg(4)) ? "" : arg(4); + auto opts = static_cast(ri_int(5)); + auto pagesize = static_cast(ri_int(6)); + if (pagesize == 0) pagesize = 512; + + std::vector file_buf(file.size() + 1, 0); + std::memcpy(file_buf.data(), file.data(), file.size()); + std::vector tag_buf(tag.size() + 1, 0); + std::memcpy(tag_buf.data(), tag.data(), tag.size()); + std::vector expr_buf(expr.size() + 1, 0); + std::memcpy(expr_buf.data(), expr.data(), expr.size()); + std::vector cond_buf(cond.size() + 1, 0); + std::memcpy(cond_buf.data(), cond.data(), cond.size()); + + ADSHANDLE hIdx = 0; + UNSIGNED32 rc = AdsCreateIndex61( + hTable, file_buf.data(), + tag.empty() ? nullptr : tag_buf.data(), + expr_buf.data(), + cond.empty() ? nullptr : cond_buf.data(), + nullptr, opts, static_cast(pagesize), &hIdx); + AdsCloseTable(hTable); + *prc = rc; + return true; + } + if (uname == "SP_REMOVEINDEXFILE") { + if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } + if (auto r = dd->remove_index_file(arg(0), arg(1)); !r) { + *prc = fail(r.error()); return true; + } + if (arg_bool(2)) { + namespace fs = std::filesystem; + std::error_code ec; + fs::path p(arg(1)); + if (p.is_relative()) p = fs::path(c->data_dir()) / p; + fs::remove(p, ec); + } + *prc = ok(); return true; + } + if (uname == "SP_MODIFYINDEXPROPERTY") { + // Same property-bag persistence pattern as SP_MODIFYTABLEPROPERTY. + if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } + dd->set_db_property( + "INDEXPROP." + arg(0) + "." + arg(1) + "." + arg(2), arg(3)); + *prc = ok(); return true; + } + + // -- Encryption ------------------------------------------------------------ + if (uname == "SP_ENCRYPTTABLE") { + if (!spproc::is_null_arg(arg(1))) c->set_encryption_password(arg(1)); + if (!c->has_encryption_key()) { + return unsupported("sp_EncryptTable: no encryption password " + "(pass one, or AdsSetEncryptionPassword first)"); + } + *prc = with_table(arg(0), openads::engine::OpenMode::Exclusive, + [&](openads::engine::Table& t) -> UNSIGNED32 { + auto* cdx = dynamic_cast( + t.driver() ? t.driver()->unwrap() : nullptr); + if (!cdx) { + return fail(openads::AE_FUNCTION_NOT_AVAILABLE, + "encryption supported on CdxDriver tables only"); + } + auto r = cdx->encrypt_in_place(c->encryption_key()); + if (!r) return fail(r.error()); + if (auto fl = t.flush(); !fl) return fail(fl.error()); + return ok(); + }); + return true; + } + if (uname == "SP_DECRYPTTABLE") { + // Matches AdsDecryptTable: pending ADS encryption-mode RE. + return unsupported("sp_DecryptTable pending ADS encryption-mode RE"); + } + if (uname == "SP_SETDDENCRYPTIONTYPE") { + return unsupported("sp_SetDDEncryptionType: dictionary encryption " + "is not supported by OpenADS"); + } + + // -- Connection/session tuning -------------------------------------------- + if (uname == "SP_SETAPPLICATIONID") { + c->set_application_id(arg(0)); + *prc = ok(); return true; + } + if (uname == "SP_IGNORETRANSACTIONS" || + uname == "SP_IGNORETABLETRANSACTIONS") { + // Accepted no-op: OpenADS keeps every table transactional. The + // procedure is a performance hint, so honoring it lazily is safe. + *prc = ok(); return true; + } + if (uname == "SP_SETREQUESTPRIORITY") { + std::string p = spproc::lower(arg(0)); + if (p != "low" && p != "normal" && p != "high" && p != "critical") { + *prc = fail(openads::AE_INTERNAL_ERROR, + "priority must be low|normal|high|critical"); + return true; + } + // Accepted no-op: OpenADS has no request priority queue yet. + *prc = ok(); return true; + } + if (uname == "SP_SETSTATEMENTLIMIT") { + // Accepted no-op: OpenADS does not cap open statements. + *prc = ok(); return true; + } + if (uname == "SP_CANCELQUERY") { + return unsupported("sp_CancelQuery is not supported by OpenADS"); + } + if (uname == "SP_CHANGECURRENTUSERPASSWORD") { + if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } + const std::string& user = c->username(); + if (user.empty()) { + *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, + "no authenticated dictionary user"); + return true; + } + std::string current = dd->get_user_property(user, "prop_1101"); + if (!current.empty() && current != arg(0)) { + *prc = fail(openads::AE_ACCESS_DENIED, "old password mismatch"); + return true; + } + if (auto r = dd->set_user_property(user, "prop_1101", arg(1)); !r) { + *prc = fail(r.error()); return true; + } + *prc = ok(); return true; + } + + // -- DD object management --------------------------------------------------- + if (uname == "SP_RENAMEDDOBJECT") { + if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } + const std::string& oldn = arg(0); + const std::string& newn = arg(1); + std::int32_t objtype = ri_int(2); + const bool keep_file = ri_int(3) == 1; // ADS_KEEP_TABLE_FILE_NAME + switch (objtype) { + case 1: { // table + std::string rel; + for (const auto& kv : dd->tables()) { + if (spproc::lower(kv.first) == spproc::lower(oldn)) { + rel = kv.second; break; + } + } + if (rel.empty()) { + *prc = fail(openads::AE_NO_FILE_FOUND, oldn.c_str()); + return true; + } + namespace fs = std::filesystem; + std::string new_rel = rel; + if (!keep_file) { + fs::path oldp(rel); + if (oldp.is_relative()) + oldp = fs::path(c->data_dir()) / oldp; + fs::path newp = oldp; + newp.replace_filename(newn + oldp.extension().string()); + std::error_code ec; + fs::rename(oldp, newp, ec); + if (ec) { + *prc = fail(openads::AE_INTERNAL_ERROR, + "table file rename failed"); + return true; + } + // Sidecars follow the stem. + for (const char* ext : {".cdx", ".adi", ".fpt", + ".dbt", ".adm", ".ntx"}) { + fs::path so = oldp; so.replace_extension(ext); + fs::path sn = newp; sn.replace_extension(ext); + if (fs::exists(so, ec)) fs::rename(so, sn, ec); + } + fs::path nr(rel); + nr.replace_filename(newn + fs::path(rel).extension().string()); + new_rel = nr.string(); + } + if (auto r = dd->add_table(newn, new_rel); !r) { + *prc = fail(r.error()); return true; + } + if (auto r = dd->remove_table(oldn); !r) { + *prc = fail(r.error()); return true; + } + *prc = ok(); return true; + } + case 6: { // view + auto it = dd->views().find(oldn); + if (it == dd->views().end()) { + *prc = fail(openads::AE_NO_FILE_FOUND, oldn.c_str()); + return true; + } + auto e = it->second; + e.name = newn; + if (auto r = dd->create_view(e); !r) { *prc = fail(r.error()); return true; } + if (auto r = dd->drop_view(oldn); !r) { *prc = fail(r.error()); return true; } + *prc = ok(); return true; + } + case 10: { // stored procedure definition + auto it = dd->procs().find(oldn); + if (it == dd->procs().end()) { + *prc = fail(openads::AE_NO_FILE_FOUND, oldn.c_str()); + return true; + } + auto e = it->second; + e.name = newn; + if (auto r = dd->create_proc(e); !r) { *prc = fail(r.error()); return true; } + if (auto r = dd->drop_proc(oldn); !r) { *prc = fail(r.error()); return true; } + *prc = ok(); return true; + } + default: + return unsupported("sp_RenameDDObject: only TABLE (1), " + "VIEW (6) and PROCEDURE (10) objects " + "can be renamed"); + } + } + if (uname == "SP_MOVEDDOBJECTFILE") { + return unsupported("sp_MoveDDObjectFile is not supported by OpenADS"); + } + if (uname == "SP_MODIFYPROCEDUREPROPERTY") { + if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } + auto it = dd->procs().find(arg(0)); + if (it == dd->procs().end()) { + *prc = fail(openads::AE_NO_FILE_FOUND, arg(0).c_str()); + return true; + } + auto e = it->second; + std::string p = spproc::lower(arg(1)); + if (p == "comment") e.comment = arg(2); + else if (p == "input_parameters" || + p == "procedure_input") e.input_params = arg(2); + else if (p == "output_parameters" || + p == "procedure_output") e.output_params = arg(2); + else if (p == "container" || + p == "procedure_container") e.container = arg(2); + else if (p == "procedure_name" || p == "script") + e.procedure = arg(2); + else { + *prc = fail(openads::AE_INTERNAL_ERROR, "unknown procedure property"); + return true; + } + if (auto r = dd->drop_proc(e.name); !r) { *prc = fail(r.error()); return true; } + if (auto r = dd->create_proc(e); !r) { *prc = fail(r.error()); return true; } + *prc = ok(); return true; + } + if (uname == "SP_MODIFYVIEWPROPERTY") { + if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } + auto it = dd->views().find(arg(0)); + if (it == dd->views().end()) { + *prc = fail(openads::AE_NO_FILE_FOUND, arg(0).c_str()); + return true; + } + auto e = it->second; + std::string p = spproc::lower(arg(1)); + if (p == "comment") e.comment = arg(2); + else if (p == "statement" || p == "view_stmt" || + p == "sql") e.sql = arg(2); + else { + *prc = fail(openads::AE_INTERNAL_ERROR, "unknown view property"); + return true; + } + if (auto r = dd->drop_view(e.name); !r) { *prc = fail(r.error()); return true; } + if (auto r = dd->create_view(e); !r) { *prc = fail(r.error()); return true; } + *prc = ok(); return true; + } + if (uname == "SP_MODIFYLINK") { + // (Name, Dictionary, StaticPath, AuthenticateActiveUser, UserName, + // Password) -- recreate the link with the new settings. + if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } + if (auto r = dd->drop_link(arg(0)); !r) { *prc = fail(r.error()); return true; } + if (auto r = dd->create_link(arg(0), arg(1), arg(4), arg(5)); !r) { + *prc = fail(r.error()); return true; + } + *prc = ok(); return true; + } + + // -- Named events ----------------------------------------------------------- + if (uname == "SP_CREATEEVENT") { + std::lock_guard lk(spproc::ev_mu()); + auto& reg = spproc::ev_map()[{c, spproc::lower(arg(0))}]; + reg.with_data = (ri_int(1) & 2) != 0; // ADS_EVENT_WITH_DATA + *prc = ok(); return true; + } + if (uname == "SP_DROPEVENT") { + std::lock_guard lk(spproc::ev_mu()); + spproc::ev_map().erase({c, spproc::lower(arg(0))}); + *prc = ok(); return true; + } + if (uname == "SP_DROPALLEVENTS") { + spproc::drop_all_events_for(c); + *prc = ok(); return true; + } + if (uname == "SP_SIGNALEVENT") { + // (EventName, WaitForCommit, Options[, EventData]). WaitForCommit + // is accepted but signals immediately -- commit-deferred signaling + // needs transaction hooks that don't exist yet. + std::string name = spproc::lower(arg(0)); + { + std::lock_guard lk(spproc::ev_mu()); + for (auto& [key, reg] : spproc::ev_map()) { + if (key.second != name) continue; + ++reg.pending; + if (reg.with_data) { + if (reg.data.size() < 1000) reg.data.push_back(arg(3)); + } + } + } + spproc::ev_cv().notify_all(); + *prc = ok(); return true; + } + + // -- Server management (no result set) --------------------------------------- + if (uname == "SP_MGKILLUSER") { + if (auto kill = openads::mgmt::process_kill_user()) { + kill(arg(0), 0); + } + // Local mode: documented engine no-op, same as AdsMgKillUser. + *prc = ok(); return true; + } + if (uname == "SP_MGRESETCOMMSTATS") { + openads::mgmt::process_mg_stats().reset_comm(); + *prc = ok(); return true; + } + + // -- Recognized but unsupported feature groups. Returning a specific + // error beats "procedure not found": clients learn the call reached + // a real engine that simply doesn't ship the feature. + if (uname == "SP_CREATEARTICLE" || uname == "SP_DROPARTICLE" || + uname == "SP_CREATEPUBLICATION" || uname == "SP_DROPPUBLICATION" || + uname == "SP_CREATESUBSCRIPTION" || uname == "SP_DROPSUBSCRIPTION" || + uname == "SP_MODIFYARTICLEPROPERTY" || + uname == "SP_MODIFYPUBLICATIONPROPERTY" || + uname == "SP_MODIFYSUBSCRIPTIONPROPERTY" || + uname == "SP_DELETEREPLICATIONENTRY" || + uname == "SP_GETREPLICATIONENTRYDETAILS" || + uname == "SP_PROCESSREPLICATIONQUEUES" || + uname == "SP_TESTREPLICATIONCONNECTION") { + return unsupported("replication is not supported by OpenADS"); + } + if (uname == "SP_ENABLEQUERYLOGGING" || uname == "SP_DISABLEQUERYLOGGING" || + uname == "SP_GETQUERYLOGGINGRESULTS" || uname == "SP_VIEWQUERYLOGGING") { + return unsupported("query logging is not supported by OpenADS"); + } + + return false; +} + +// --------------------------------------------------------------------------- +// M14: sp_* system procedures that return a result set. +// --------------------------------------------------------------------------- +extern "C++" { + +// Enumerate every index attached to an open Table handle in this process. +// Walks the ABI registry at snapshot time -- no open/close bookkeeping to +// drift out of sync. Remote sessions open their tables through this same +// registry (session.cpp calls the ABI in-process), so the network Server's +// build_mg_snapshot uses this too; attribution to a user/conn is the +// caller's job (entries carry only the owning table's path). +namespace openads::abi { +std::vector open_index_snapshot() { + std::vector out; + state().registry.for_each_handle([&](Handle, HandleKind k, void* p) { + if (k != HandleKind::Table) return; + auto* tp = static_cast(p); + if (tp == nullptr) return; + for (auto* ix : tp->all_indexes()) { + if (ix == nullptr) continue; + openads::mgmt::MgIndex mi; + mi.name = ix->file_path().empty() ? ix->name() + : ix->file_path(); + mi.tag = ix->name(); + mi.expression = ix->expression(); + mi.table = tp->path(); + bool dup = false; + for (const auto& e : out) { + if (e.name == mi.name && e.tag == mi.tag && + e.table == mi.table) { dup = true; break; } + } + if (!dup) out.push_back(std::move(mi)); + } + }); + return out; +} +} // namespace openads::abi + +// In-process telemetry for the local (embedded DLL) case: enumerate the ABI +// handle registry for real connection/table counts and fold in the process +// MgStats. Shared with AdsMg*'s local backend (fetch_mg_snapshot). +openads::mgmt::MgSnapshot collect_local_abi_snapshot() { + openads::mgmt::MgSnapshot snap; + snap.server_type = 0; // 0 = local + std::uint32_t conns = 0, tbls = 0; + state().registry.for_each_handle( + [&](Handle, HandleKind k, void* p) { + if (k == HandleKind::Connection || + k == HandleKind::RemoteConnection) { + ++conns; + } else if (k == HandleKind::Table || + k == HandleKind::RemoteTable) { + ++tbls; + if (k == HandleKind::Table) { + auto* tp = static_cast(p); + if (tp != nullptr) { + openads::mgmt::MgTable t; + t.name = tp->path(); + t.user = "(local)"; + t.conn_no = 1; + snap.table_list.push_back(std::move(t)); + } + } + } + }); + snap.connections = conns; + snap.tables = tbls; + snap.workareas = tbls; + snap.users = 1; + openads::mgmt::MgUser u; + u.name = "(local)"; + u.conn_no = 1; + snap.user_list.push_back(u); + snap.rss_bytes = openads::platform::process_rss_bytes(); + + snap.locks = openads::mgmt::LockRegistry::instance().count(); + snap.lock_list = openads::mgmt::LockRegistry::instance().snapshot(); + for (auto& l : snap.lock_list) { + if (l.user.empty()) l.user = "(local)"; + if (l.conn_no == 0) l.conn_no = 1; + } + + snap.index_list = openads::abi::open_index_snapshot(); + for (auto& ix : snap.index_list) { + ix.user = "(local)"; + ix.conn_no = 1; + } + snap.indexes = static_cast(snap.index_list.size()); + + snap.error_log_path = openads::mgmt::ErrorLog::instance().directory(); + snap.error_log_max_kb = openads::mgmt::ErrorLog::instance().max_kbytes(); + + openads::mgmt::capture_mg_stats( + snap, openads::mgmt::process_mg_stats()); + return snap; +} + +namespace spproc { + +// The snapshot the sp_mg* procedures report: the network Server's when this +// SQL runs inside the daemon (provider hook), else this process's own. +inline openads::mgmt::MgSnapshot current_snapshot() { + if (auto prov = openads::mgmt::process_snapshot_provider()) return prov(); + return collect_local_abi_snapshot(); +} + +// NUL-terminated fixed char array -> std::string. +inline std::string mgstr(const UNSIGNED8* a, std::size_t n) { + std::size_t len = 0; + while (len < n && a[len] != 0) ++len; + return std::string(reinterpret_cast(a), len); +} + +inline std::string i2s(std::uint64_t v) { + if (v > 0x7FFFFFFFull) v = 0x7FFFFFFFull; // 'N' columns are int32 + return std::to_string(v); +} + +// "computer" or "computer\oslogin" (trailing '\' ignored) against a MgUser. +inline bool user_matches(const openads::mgmt::MgUser& u, std::string want) { + while (!want.empty() && want.back() == '\\') want.pop_back(); + if (want.empty()) return false; + std::string w = lower(want); + if (w == lower(u.name)) return true; + return w == lower(u.name + "\\" + u.os_login); +} + +// Table-path match: full path or basename, case-insensitive. +inline bool table_matches(const std::string& open_name, + const std::string& want) { + if (want.empty()) return true; + if (lower(open_name) == lower(want)) return true; + namespace fs = std::filesystem; + return lower(fs::path(open_name).filename().string()) == + lower(fs::path(want).filename().string()); +} + +inline std::string fmt_time(std::chrono::system_clock::time_point tp) { + if (tp.time_since_epoch().count() == 0) return ""; + std::time_t tt = std::chrono::system_clock::to_time_t(tp); + std::tm tmv{}; +#ifdef _WIN32 + localtime_s(&tmv, &tt); +#else + localtime_r(&tt, &tmv); +#endif + char buf[32]; + std::snprintf(buf, sizeof(buf), "%04d-%02d-%02d %02d:%02d:%02d", + tmv.tm_year + 1900, tmv.tm_mon + 1, tmv.tm_mday, + tmv.tm_hour, tmv.tm_min, tmv.tm_sec); + return buf; +} + +} // namespace spproc + +// Dispatch for built-in sp_* procedures that produce a cursor. Returns true +// and fills *out (a table or an error) when the name is recognized; the +// caller adopts the table and registers the cursor handle. Runs WITHOUT the +// global ABI mutex held -- sp_WaitForEvent blocks, and the signaling +// connection needs that mutex to get its EXECUTE PROCEDURE through. +bool dispatch_sp_builtin_cursor( + Connection* c, + const std::string& uname, + const std::vector& args, + std::optional>* out) { + using openads::util::Error; + namespace fs = std::filesystem; + + // Everything except the blocking event waits touches shared engine + // state (connection tables, DD, handle registry) -- serialize on the + // global ABI mutex like every other SQL statement branch. The waits + // stay lock-free: they only use `c` as an opaque registry key, and + // the signaling connection needs this mutex to make progress. + std::unique_lock abi_lk; + if (uname != "SP_WAITFOREVENT" && uname != "SP_WAITFORANYEVENT") + abi_lk = std::unique_lock(state().mu); + + auto arg = [&](std::size_t i) -> const std::string& { + static const std::string empty; + return (i < args.size()) ? args[i].text : empty; + }; + auto arg_int = [&](std::size_t i) -> std::int64_t { + if (i >= args.size()) return 0; + if (args[i].is_numeric) + return static_cast(args[i].number); + return std::atoll(args[i].text.c_str()); + }; + auto emit = [&](const char* tag, const std::vector& cols, + const std::vector>& rows) { + *out = build_memory_result(tag, cols, rows); + return true; + }; + auto err = [&](std::int32_t code, const std::string& msg) { + *out = openads::util::Result
(Error{code, 0, msg, ""}); + return true; + }; + + // -- sp_mg* management result sets --------------------------------------- + static const std::vector kUserCols = { + {"UserName", 'C', 64, 0}, + {"ConnNumber", 'N', 5, 0}, + {"DictionaryUser", 'C', 64, 0}, + {"Address", 'C', 64, 0}, + {"OSUserLoginName", 'C', 64, 0}, + {"TSAddress", 'C', 64, 0}, + }; + auto user_row = [](const openads::mgmt::MgUser& u) { + return std::vector{ + u.name, std::to_string(u.conn_no), u.name, u.address, + u.os_login, ""}; + }; + + if (uname == "SP_MGGETACTIVITYINFO") { + auto snap = spproc::current_snapshot(); + openads::mgmt::MgCollector col(snap); + auto ai = col.activity_info(); + char up[40]; + std::snprintf(up, sizeof(up), "%u days %02u:%02u:%02u", + ai.stUpTime.usDays, ai.stUpTime.usHours, + ai.stUpTime.usMinutes, ai.stUpTime.usSeconds); + return emit("sp_mgGetActivityInfo", + {{"Operations", 'N', 10, 0}, + {"LoggedErrors", 'N', 10, 0}, + {"UpTime", 'C', 40, 0}, + {"EQThreshold", 'N', 10, 0}, + {"EQActiveThreads", 'N', 10, 0}, + {"EQOperations", 'N', 10, 0}}, + {{spproc::i2s(ai.ulOperations), spproc::i2s(ai.ulLoggedErrors), + up, "0", "0", "0"}}); + } + if (uname == "SP_MGGETUSAGEINFO") { + auto snap = spproc::current_snapshot(); + openads::mgmt::MgCollector col(snap); + auto ai = col.activity_info(); + auto row = [](const char* item, const ADS_MGMT_USAGE_STRUCT& u) { + return std::vector{ + item, spproc::i2s(u.ulInUse), spproc::i2s(u.ulMaxUsed), + "0", spproc::i2s(u.ulRejected)}; + }; + return emit("sp_mgGetUsageInfo", + {{"Item", 'C', 30, 0}, + {"InUse", 'N', 10, 0}, + {"MaxUsed", 'N', 10, 0}, + {"Configured", 'N', 10, 0}, + {"Rejected", 'N', 10, 0}}, + {row("Users", ai.stUsers), + row("Connections", ai.stConnections), + row("Work Areas", ai.stWorkAreas), + row("Tables", ai.stTables), + row("Indexes", ai.stIndexes), + row("Locks", ai.stLocks), + row("TPS Header Elems", ai.stTpsHeaderElems), + row("TPS Visibility Elems", ai.stTpsVisElems), + row("TPS Memo Elems", ai.stTpsMemoElems), + row("Worker Threads", ai.stWorkerThreads)}); + } + if (uname == "SP_MGGETCONNECTEDUSERS") { + auto snap = spproc::current_snapshot(); + std::vector> rows; + for (const auto& u : snap.user_list) { + rows.push_back({u.name, std::to_string(u.conn_no), u.name, + u.address, u.os_login, "", "", + spproc::i2s(u.avg_cost_micros)}); + } + return emit("sp_mgGetConnectedUsers", + {{"UserName", 'C', 64, 0}, + {"ConnNumber", 'N', 5, 0}, + {"DictionaryUser", 'C', 64, 0}, + {"Address", 'C', 64, 0}, + {"OSUserLoginName", 'C', 64, 0}, + {"TSAddress", 'C', 64, 0}, + {"ApplicationID", 'C', 70, 0}, + {"AverageCost", 'N', 10, 0}}, + rows); + } + if (uname == "SP_MGGETTABLEUSERS") { + auto snap = spproc::current_snapshot(); + std::vector> rows; + for (const auto& t : snap.table_list) { + if (!spproc::table_matches(t.name, arg(0))) continue; + bool found = false; + for (const auto& u : snap.user_list) { + if (u.conn_no == t.conn_no) { + rows.push_back(user_row(u)); + found = true; + break; + } + } + if (!found) + rows.push_back({t.user, std::to_string(t.conn_no), + t.user, "", "", ""}); + } + return emit("sp_mgGetTableUsers", kUserCols, rows); + } + if (uname == "SP_MGGETINDEXUSERS") { + auto snap = spproc::current_snapshot(); + std::vector> rows; + for (const auto& x : snap.index_list) { + if (!spproc::table_matches(x.name, arg(0))) continue; + bool found = false; + for (const auto& u : snap.user_list) { + if (u.conn_no == x.conn_no) { + rows.push_back(user_row(u)); + found = true; + break; + } + } + if (!found && !x.user.empty()) + rows.push_back({x.user, std::to_string(x.conn_no), + x.user, "", "", ""}); + } + return emit("sp_mgGetIndexUsers", kUserCols, rows); + } + if (uname == "SP_MGGETUSERTABLES" || uname == "SP_MGGETALLTABLES") { + bool by_user = (uname == "SP_MGGETUSERTABLES"); + auto snap = spproc::current_snapshot(); + std::vector> rows; + for (const auto& t : snap.table_list) { + if (by_user) { + bool match = false; + for (const auto& u : snap.user_list) { + if (u.conn_no == t.conn_no && + spproc::user_matches(u, arg(0))) { + match = true; + break; + } + } + if (!match) continue; + } + // LockType per SAP: ADS(1) proprietary, CDX(2), NTX(3), ADT(4). + std::string ext = spproc::lower( + fs::path(t.name).extension().string()); + std::uint16_t ltv = (ext == ".adt") ? 4u + : (ext == ".ntx") ? 3u : 2u; + static const char* kLt[] = {"", "ADS", "CDX", "NTX", "ADT"}; + rows.push_back({t.name, kLt[ltv], std::to_string(ltv)}); + } + return emit(by_user ? "sp_mgGetUserTables" : "sp_mgGetAllTables", + {{"TableName", 'C', 260, 0}, + {"LockType", 'C', 3, 0}, + {"LockTypeValue", 'N', 5, 0}}, + rows); + } + if (uname == "SP_MGGETALLINDEXES" || uname == "SP_MGGETTABLEINDEXES" || + uname == "SP_MGGETUSERINDEXES") { + auto snap = spproc::current_snapshot(); + std::vector> rows; + for (const auto& x : snap.index_list) { + if (uname == "SP_MGGETTABLEINDEXES" && + !spproc::table_matches(x.table, arg(0))) continue; + if (uname == "SP_MGGETUSERINDEXES") { + bool match = spproc::lower(x.user) == spproc::lower(arg(0)); + if (!match) { + for (const auto& u : snap.user_list) { + if (u.conn_no == x.conn_no && + spproc::user_matches(u, arg(0))) { + match = true; + break; + } + } + } + if (!match) continue; + } + // One row per index FILE, like SAP -- a multi-tag CDX/ADI + // shows once, not once per tag. + bool dup = false; + for (const auto& r0 : rows) + if (r0[0] == x.name) { dup = true; break; } + if (!dup) rows.push_back({x.name}); + } + return emit("sp_mgGetIndexes", {{"IndexName", 'C', 260, 0}}, rows); + } + if (uname == "SP_MGGETALLLOCKS" || uname == "SP_MGGETUSERLOCKS") { + bool by_user = (uname == "SP_MGGETUSERLOCKS"); + auto snap = spproc::current_snapshot(); + std::vector> rows; + for (const auto& l : snap.lock_list) { + if (!spproc::table_matches(l.table, arg(0))) continue; + if (by_user) { + bool match = spproc::lower(l.user) == spproc::lower(arg(1)); + if (!match) { + for (const auto& u : snap.user_list) { + if (u.conn_no == l.conn_no && + spproc::user_matches(u, arg(1))) { + match = true; + break; + } + } + } + if (!match) continue; + } + rows.push_back({std::to_string(l.recno)}); + } + return emit("sp_mgGetLocks", {{"LockedRecNo", 'N', 10, 0}}, rows); + } + if (uname == "SP_MGGETLOCKOWNER") { + auto snap = spproc::current_snapshot(); + auto want = static_cast(arg_int(1)); + std::vector> rows; + for (const auto& l : snap.lock_list) { + if (!spproc::table_matches(l.table, arg(0))) continue; + if (l.recno != want) continue; + std::string addr, oslogin; + for (const auto& u : snap.user_list) { + if (u.conn_no == l.conn_no) { + addr = u.address; + oslogin = u.os_login; + break; + } + } + rows.push_back({l.user, std::to_string(l.conn_no), l.user, addr, + want == 0 ? "File Lock" : "Record Lock", + oslogin, ""}); + break; + } + if (rows.empty()) + rows.push_back({"", "0", "", "", "No Lock", "", ""}); + return emit("sp_mgGetLockOwner", + {{"UserName", 'C', 64, 0}, + {"ConnNumber", 'N', 5, 0}, + {"DictionaryUser", 'C', 64, 0}, + {"Address", 'C', 64, 0}, + {"LockType", 'C', 12, 0}, + {"OSUserLoginName", 'C', 64, 0}, + {"TSAddress", 'C', 64, 0}}, + rows); + } + if (uname == "SP_MGGETWORKERTHREADACTIVITY") { + auto snap = spproc::current_snapshot(); + std::vector> rows; + for (const auto& t : snap.thread_list) { + rows.push_back({spproc::i2s(t.thread_no), + std::to_string(t.opcode), t.user, + std::to_string(t.conn_no), t.os_login}); + } + return emit("sp_mgGetWorkerThreadActivity", + {{"ThreadNumber", 'N', 10, 0}, + {"OpCode", 'N', 5, 0}, + {"UserName", 'C', 64, 0}, + {"ConnNumber", 'N', 5, 0}, + {"OSUserLoginName", 'C', 64, 0}}, + rows); + } + if (uname == "SP_MGGETCOMMSTATS") { + auto snap = spproc::current_snapshot(); + openads::mgmt::MgCollector col(snap); + auto cs = col.comm_stats(); + char pct[24]; + std::snprintf(pct, sizeof(pct), "%.2f", cs.dPercentCheckSums); + return emit("sp_mgGetCommStats", + {{"PercentCheckSums", 'C', 12, 0}, + {"TotalPackets", 'N', 10, 0}, + {"RcvPktOutOfSeq", 'N', 10, 0}, + {"NotLoggedIn", 'N', 10, 0}, + {"RcvReqOutOfSeq", 'N', 10, 0}, + {"CheckSumFailures", 'N', 10, 0}, + {"DisconnectedUsers", 'N', 10, 0}, + {"PartialConnects", 'N', 10, 0}, + {"InvalidPackets", 'N', 10, 0}, + {"RcvFromErrors", 'N', 10, 0}, + {"SendToErrors", 'N', 10, 0}}, + {{pct, spproc::i2s(cs.ulTotalPackets), + spproc::i2s(cs.ulRcvPktOutOfSeq), spproc::i2s(cs.ulNotLoggedIn), + spproc::i2s(cs.ulRcvReqOutOfSeq), + spproc::i2s(cs.ulCheckSumFailures), + spproc::i2s(cs.ulDisconnectedUsers), + spproc::i2s(cs.ulPartialConnects), + spproc::i2s(cs.ulInvalidPackets), + spproc::i2s(cs.ulRecvFromErrors), + spproc::i2s(cs.ulSendToErrors)}}); + } + if (uname == "SP_MGGETCONFIGINFO") { + auto snap = spproc::current_snapshot(); + openads::mgmt::MgCollector col(snap); + auto cp = col.config_params(); + std::vector> rows = { + {"Stat Dump Interval", spproc::i2s(cp.ulStatDumpInterval)}, + {"Error Log Max", spproc::i2s(cp.ulErrorLogMax)}, + {"Error Log Path", spproc::mgstr(cp.aucErrorLog, 256)}, + {"Burst Packets", std::to_string(cp.usNumBurstPackets)}, + {"Sort Buffer", spproc::i2s(cp.ulSortBuffSize)}, + {"Sent IP Port", std::to_string(cp.usSendIPPort)}, + {"Receive IP Port", std::to_string(cp.usReceiveIPPort)}, + {"Semaphore File Path", spproc::mgstr(cp.aucSemaphore, 256)}, + {"Transaction Log Path", spproc::mgstr(cp.aucTransaction, 256)}, + {"SQL Statement Limit", "0"}, + {"User SQL Statement Limit", "0"}, + }; + return emit("sp_mgGetConfigInfo", + {{"Item", 'C', 30, 0}, {"Value", 'C', 260, 0}}, rows); + } + if (uname == "SP_MGGETCONFIGMEMORY") { + auto snap = spproc::current_snapshot(); + openads::mgmt::MgCollector col(snap); + auto cp = col.config_params(); + auto cm = col.config_memory(); + std::vector> rows = { + {"Connections", spproc::i2s(cp.ulNumConnections), + spproc::i2s(cm.ulConnectionMem)}, + {"Work Areas", spproc::i2s(cp.ulNumWorkAreas), + spproc::i2s(cm.ulWorkAreaMem)}, + {"Tables", spproc::i2s(cp.ulNumTables), + spproc::i2s(cm.ulTableMem)}, + {"Indexes", spproc::i2s(cp.ulNumIndexes), + spproc::i2s(cm.ulIndexMem)}, + {"Locks", spproc::i2s(cp.ulNumLocks), + spproc::i2s(cm.ulLockMem)}, + {"User Buffer Size", spproc::i2s(cp.ulUserBufferSize), + spproc::i2s(cm.ulUserBufferMem)}, + {"Worker Threads", std::to_string(cp.usNumWorkerThreads), + spproc::i2s(cm.ulWorkerThreadMem)}, + {"Total Memory", "", + spproc::i2s(static_cast( + cm.ulTotalConfigMem))}, + }; + return emit("sp_mgGetConfigMemory", + {{"Item", 'C', 30, 0}, + {"Value", 'C', 20, 0}, + {"Memory", 'N', 10, 0}}, + rows); + } + if (uname == "SP_MGGETINSTALLINFO") { + auto snap = spproc::current_snapshot(); + openads::mgmt::MgCollector col(snap); + auto ii = col.install_info(); + return emit("sp_mgGetInstallInfo", + {{"Owner", 'C', 128, 0}, + {"SerialNumber", 'C', 32, 0}, + {"UserOption", 'N', 10, 0}, + {"Version", 'C', 16, 0}, + {"InstallDate", 'C', 32, 0}, + {"EvalExpireDate", 'C', 32, 0}, + {"ANSI", 'C', 32, 0}, + {"OEM", 'C', 32, 0}, + {"ReplicationEnabled", 'L', 1, 0}, + {"MaxStatefulUsers", 'N', 10, 0}, + {"MaxStatelessUsers", 'N', 10, 0}}, + {{spproc::mgstr(ii.aucRegisteredOwner, 128), + spproc::mgstr(ii.aucSerialNumber, 32), + spproc::i2s(ii.ulUserOption), + spproc::mgstr(ii.aucVersionStr, 16), + spproc::mgstr(ii.aucInstallDate, 32), + spproc::mgstr(ii.aucEvalExpireDate, 32), + spproc::mgstr(ii.aucAnsiCharName, 32), + spproc::mgstr(ii.aucOemCharName, 32), + "F", "0", "0"}}); + } + if (uname == "SP_MGGETSERVERTYPE") { + auto snap = spproc::current_snapshot(); + std::uint16_t stv; + const char* stn; + if (snap.server_type == 0) { + stv = 3; stn = "Local Server"; + } else { +#ifdef _WIN32 + stv = 7; stn = "Windows 64-bit"; +#else + stv = 8; stn = "Linux 64-bit"; +#endif + } + return emit("sp_mgGetServerType", + {{"ServerType", 'C', 20, 0}, + {"ServerTypeValue", 'N', 5, 0}, + {"OSMajor", 'N', 5, 0}, + {"OSMinor", 'N', 5, 0}}, + {{stn, std::to_string(stv), "0", "0"}}); + } + if (uname == "SP_MGGETCRASHDUMPINFO") { + return emit("sp_mgGetCrashDumpInfo", + {{"CrashDumpName", 'C', 260, 0}, + {"CreationTime", 'C', 25, 0}, + {"FileSize", 'N', 10, 0}}, + {}); + } + if (uname == "SP_MGGETERRORLOG") { + // Newest entries from the ads_err.dbf error log (mgmt::ErrorLog). + // No-arg default is 25, single arg sets the count, and the + // two-arg SAP form's second number is the DBF-log count -- the + // only log OpenADS keeps. Error_Number is 0: per the SAP docs the + // DBF-log entries have no Error_Number (that column only exists + // in the ADT variant). + std::size_t want = 25; + if (args.size() >= 2) want = static_cast( + arg_int(1) > 0 ? arg_int(1) : 0); + else if (args.size() == 1) want = static_cast( + arg_int(0) > 0 ? arg_int(0) : 0); + std::vector> rows; + for (const auto& e : + openads::mgmt::ErrorLog::instance().read_last(want)) { + rows.push_back({"0", e.datetime, std::to_string(e.code), + e.source, std::to_string(e.src_line), + e.detail}); + } + return emit("sp_mgGetErrorLog", + {{"Error_Number", 'N', 10, 0}, + {"DateTime", 'C', 25, 0}, + {"Error_Code", 'N', 10, 0}, + {"Ads_Source", 'C', 100, 0}, + {"src_Line", 'N', 10, 0}, + {"FileName", 'C', 260, 0}}, + rows); + } + if (uname == "SP_MGSETCONFIGVALUE") { + auto& elog = openads::mgmt::ErrorLog::instance(); + std::string setting = spproc::lower(arg(0)); + auto respond = [&](const std::string& old, int result, + const char* text) { + return emit("sp_mgSetConfigValue", + {{"OldValue", 'C', 256, 0}, + {"Result", 'N', 5, 0}, + {"ResultText", 'C', 100, 0}}, + {{old, std::to_string(result), text}}); + }; + if (setting == "error_log_max") { + std::string old = std::to_string(elog.max_kbytes()); + long v = std::atol(arg(1).c_str()); + if (v <= 0) { + return respond(old, 3, "ERROR_LOG_MAX must be a positive " + "number of kilobytes."); + } + elog.set_max_kbytes(static_cast(v)); + return respond(old, 0, "Success. The change has been applied."); + } + if (setting == "error_assert_logs") { + std::string old = elog.directory(); + elog.set_directory(arg(1)); + return respond(old, 0, "Success. The change has been applied."); + } + return respond("", 3, "Modifying this setting via this stored " + "procedure is not supported."); + } + if (uname == "SP_GETSQLSTATEMENTS") { + auto snap = spproc::current_snapshot(); + std::vector> rows; + for (const auto& t : snap.thread_list) { + if (t.sql.empty() && !t.active) continue; + rows.push_back({spproc::i2s(t.thread_no), + t.active ? "T" : "F", + t.active ? "0" : "100", + t.user, t.user, "", + t.sql.substr(0, 1024), + "F", "", + spproc::fmt_time(t.sql_at), + "0", ""}); + } + return emit("sp_GetSQLStatements", + {{"Query Number", 'N', 10, 0}, + {"Active", 'L', 1, 0}, + {"Percent Complete", 'N', 10, 0}, + {"Connection Name", 'C', 100, 0}, + {"Database User", 'C', 100, 0}, + {"Database", 'C', 255, 0}, + {"Query", 'C', 1024, 0}, + {"Is Script", 'L', 1, 0}, + {"Full Script", 'C', 100, 0}, + {"Start Time", 'C', 25, 0}, + {"Seconds Until Finished", 'N', 10, 0}, + {"ApplicationID", 'C', 70, 0}}, + rows); + } + + // -- Metadata / catalog ---------------------------------------------------- + if (uname == "SP_GETTABLES") { + // (catalog, schemaPattern, tableNamePattern, Types) + const std::string& pat = spproc::is_null_arg(arg(2)) ? "" : arg(2); + std::string types = spproc::lower(arg(3)); + auto type_wanted = [&](const char* t) { + return types.empty() || spproc::is_null_arg(types) || + types.find(spproc::lower(t)) != std::string::npos; + }; + std::vector> rows; + auto* dd = c->has_dd() ? c->dd() : nullptr; + if (dd) { + if (type_wanted("TABLE")) { + for (const auto& kv : dd->tables()) { + if (!dd_can_view_object_metadata(c, kv.first)) continue; + if (!spproc::like_match(pat, kv.first)) continue; + rows.push_back({"", "", kv.first, "TABLE", ""}); + } + } + if (type_wanted("VIEW")) { + for (const auto& kv : dd->views()) { + if (!spproc::like_match(pat, kv.first)) continue; + rows.push_back({"", "", kv.first, "VIEW", + kv.second.comment}); + } + } + } else if (type_wanted("TABLE")) { + // Free connection: enumerate table files in the data directory. + std::error_code ec; + for (const auto& de : + fs::directory_iterator(c->data_dir(), ec)) { + if (!de.is_regular_file(ec)) continue; + std::string ext = spproc::lower( + de.path().extension().string()); + if (ext != ".dbf" && ext != ".adt") continue; + std::string stem = de.path().stem().string(); + if (!spproc::like_match(pat, stem)) continue; + rows.push_back({"", "", stem, "TABLE", ""}); + } + } + return emit("sp_GetTables", + {{"TABLE_CAT", 'C', 200, 0}, + {"TABLE_SCHEM", 'C', 200, 0}, + {"TABLE_NAME", 'C', 255, 0}, + {"TABLE_TYPE", 'C', 17, 0}, + {"REMARKS", 'C', 200, 0}}, + rows); + } + if (uname == "SP_GETCOLUMNS") { + // (catalog, schemaPattern, tableNamePattern, columnNamePattern) + const std::string& tpat = spproc::is_null_arg(arg(2)) ? "" : arg(2); + const std::string& cpat = spproc::is_null_arg(arg(3)) ? "" : arg(3); + + // DbfFieldType -> (ODBC DATA_TYPE, TYPE_NAME, default display size) + auto odbc_of = [](openads::drivers::DbfFieldType t, + const openads::drivers::DbfField& fd) + -> std::tuple { + using FT = openads::drivers::DbfFieldType; + switch (t) { + case FT::Character: return {1, "CHAR", fd.length}; + case FT::CiCharacter: return {1, "CICHAR", fd.length}; + case FT::Varchar: return {12, "VARCHAR", fd.length}; + case FT::Numeric: return {2, "NUMERIC", fd.length}; + case FT::Float: return {8, "DOUBLE", 15}; + case FT::Double: return {8, "DOUBLE", 15}; + case FT::Integer: return {4, "INTEGER", 10}; + case FT::ShortInt: return {5, "SHORT", 5}; + case FT::AutoInc: return {4, "AUTOINC", 10}; + case FT::Logical: return {-7, "LOGICAL", 1}; + case FT::Date: return {91, "DATE", 10}; + case FT::AdtDate: return {91, "DATE", 10}; + case FT::Time: return {92, "TIME", 8}; + case FT::DateTime: return {93, "TIMESTAMP", 22}; + case FT::AdtTimestamp: return {93, "TIMESTAMP", 22}; + case FT::ModTime: return {93, "MODTIME", 22}; + case FT::Memo: return {-1, "MEMO", 0}; + case FT::Binary: return {-4, "BLOB", 0}; + case FT::Varbinary: return {-4, "VARBINARY", fd.length}; + case FT::Currency: return {2, "MONEY", 18}; + case FT::AdtMoney: return {2, "MONEY", 18}; + case FT::RowVersion: return {-5, "ROWVERSION", 8}; + default: return {1, "CHAR", fd.length}; + } + }; + + std::vector> targets; + auto* dd = c->has_dd() ? c->dd() : nullptr; + if (dd) { + for (const auto& kv : dd->tables()) { + if (!dd_can_view_object_metadata(c, kv.first)) continue; + if (!spproc::like_match(tpat, kv.first)) continue; + targets.emplace_back(kv.first, kv.second); + } + } else { + std::error_code ec; + for (const auto& de : + fs::directory_iterator(c->data_dir(), ec)) { + if (!de.is_regular_file(ec)) continue; + std::string ext = spproc::lower( + de.path().extension().string()); + if (ext != ".dbf" && ext != ".adt") continue; + std::string stem = de.path().stem().string(); + if (!spproc::like_match(tpat, stem)) continue; + targets.emplace_back(stem, de.path().filename().string()); + } + } + + std::vector> rows; + for (const auto& [alias, rel] : targets) { + auto th = c->open_table(rel, openads::engine::TableType::Cdx, + openads::engine::OpenMode::Read); + if (!th) continue; + openads::engine::Table* tbl = c->lookup_table(th.value()); + if (tbl) { + std::uint16_t nf = tbl->field_count(); + for (std::uint16_t i = 0; i < nf; ++i) { + const auto& fd = tbl->field_descriptor(i); + if (!spproc::like_match(cpat, fd.name)) continue; + auto [dtype, tname, size] = odbc_of(fd.type, fd); + rows.push_back({ + "", "", alias, fd.name, + std::to_string(dtype), tname, + std::to_string(size), + std::to_string(fd.length), + std::to_string(fd.decimals), + "10", "1", "", "", + std::to_string(dtype), "0", + std::to_string(fd.length), + std::to_string(i + 1), "YES", "0"}); + } + } + c->close_table(th.value()); + } + return emit("sp_GetColumns", + {{"TABLE_CAT", 'C', 200, 0}, + {"TABLE_SCHEM", 'C', 200, 0}, + {"TABLE_NAME", 'C', 255, 0}, + {"COLUMN_NAME", 'C', 200, 0}, + {"DATA_TYPE", 'N', 5, 0}, + {"TYPE_NAME", 'C', 20, 0}, + {"COLUMN_SIZE", 'N', 10, 0}, + {"BUFFER_LENGTH", 'N', 10, 0}, + {"DECIMAL_DIGITS", 'N', 5, 0}, + {"NUM_PREC_RADIX", 'N', 5, 0}, + {"NULLABLE", 'N', 5, 0}, + {"REMARKS", 'C', 100, 0}, + {"COLUMN_DEF", 'C', 100, 0}, + {"SQL_DATA_TYPE", 'N', 10, 0}, + {"SQL_DATETIME_SUB", 'N', 10, 0}, + {"CHAR_OCTET_LENGTH", 'N', 10, 0}, + {"ORDINAL_POSITION", 'N', 10, 0}, + {"IS_NULLABLE", 'C', 4, 0}, + {"NOCPTRANS", 'N', 5, 0}}, + rows); + } + if (uname == "SP_GETSQLKEYWORDS") { + static const char* kKeywords[] = { + "ADD", "ALL", "ALTER", "AND", "ANY", "AS", "ASC", "AVG", + "BEGIN", "BETWEEN", "BOOLEAN", "BOTH", "BY", "CASE", "CAST", + "CHAR", "CHECK", "COLUMN", "COMMIT", "CONSTRAINT", "COUNT", + "CREATE", "CURRENT_DATE", "CURRENT_TIME", "CURRENT_TIMESTAMP", + "CURSOR", "DATABASE", "DATE", "DECLARE", "DEFAULT", "DELETE", + "DESC", "DISTINCT", "DOUBLE", "DROP", "ELSE", "END", "ESCAPE", + "EXECUTE", "EXISTS", "FETCH", "FOR", "FOREIGN", "FROM", + "FUNCTION", "GRANT", "GROUP", "HAVING", "IF", "IIF", "IN", + "INDEX", "INNER", "INSERT", "INTEGER", "INTO", "IS", "JOIN", + "KEY", "LEADING", "LEFT", "LIKE", "LIMIT", "LOGICAL", "MAX", + "MEMO", "MIN", "MONEY", "NOT", "NULL", "NUMERIC", "ON", "OR", + "ORDER", "OUTER", "OUTPUT", "PRIMARY", "PROCEDURE", "REFERENCES", + "RESTRICT", "REVOKE", "RIGHT", "ROLLBACK", "ROWID", "SELECT", + "SET", "SHORT", "SUM", "TABLE", "THEN", "TIME", "TIMESTAMP", + "TO", "TOP", "TRAILING", "TRANSACTION", "TRIGGER", "UNION", + "UNIQUE", "UPDATE", "VALUES", "VARCHAR", "VIEW", "WHEN", + "WHERE", "WHILE", "WITH", + }; + std::vector> rows; + for (const char* k : kKeywords) rows.push_back({k}); + return emit("sp_GetSQLKeywords", {{"Keyword", 'C', 30, 0}}, rows); + } + if (uname == "SP_GETLINKS") { + auto* dd = c->has_dd() ? c->dd() : nullptr; + if (!dd) return err(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); + std::vector> rows; + for (const auto& kv : dd->links()) + rows.push_back({kv.second.alias, kv.second.path}); + return emit("sp_GetLinks", + {{"LinkAlias", 'C', 200, 0}, {"Path", 'C', 260, 0}}, rows); + } + if (uname == "SP_GETAPPLICATIONID") { + return emit("sp_GetApplicationID", + {{"ApplicationID", 'C', 255, 0}}, + {{c->application_id()}}); + } + if (uname == "SP_GETSECURITYINFO") { + bool server_side = + static_cast(openads::mgmt::process_snapshot_provider()); + return emit("sp_GetSecurityInfo", + {{"FIPSMode", 'L', 1, 0}, + {"EncryptionType", 'C', 10, 0}, + {"DictionaryEncrypted", 'L', 1, 0}, + {"CommType", 'C', 10, 0}, + {"TLSCipher", 'C', 20, 0}, + {"TLSVersion", 'C', 20, 0}}, + {{"F", "RC4", "F", server_side ? "TCP_IP" : "LOCAL", "", ""}}); + } + if (uname == "SP_GETTABLEENCRYPTIONTYPE") { + auto th = c->open_table(arg(0), openads::engine::TableType::Cdx, + openads::engine::OpenMode::Read); + if (!th) return err(openads::AE_NO_FILE_FOUND, arg(0)); + std::string enc; + if (openads::engine::Table* t = c->lookup_table(th.value())) { + auto* cdx = dynamic_cast( + t->driver() ? t->driver()->unwrap() : nullptr); + if (cdx && (cdx->encrypted() || cdx->partial_encrypted())) + enc = "AES256"; + } + c->close_table(th.value()); + return emit("sp_GetTableEncryptionType", + {{"EncryptionType", 'C', 10, 0}}, {{enc}}); + } + if (uname == "SP_GETTABLESIZEINFO") { + auto th = c->open_table(arg(0), openads::engine::TableType::Cdx, + openads::engine::OpenMode::Read); + if (!th) return err(openads::AE_NO_FILE_FOUND, arg(0)); + std::vector> rows; + if (openads::engine::Table* t = c->lookup_table(th.value())) { + std::uint32_t rlen = static_cast( + t->record_buffer().size()); + std::uint32_t nf = t->field_count(); + std::string ext = spproc::lower( + fs::path(t->path()).extension().string()); + std::uint32_t header = (ext == ".adt") + ? 400u + 200u * nf + : 32u + 32u * nf + 1u; + rows.push_back({spproc::i2s(header), spproc::i2s(rlen), + spproc::i2s(t->record_count()), + "0", "0", "0", "0"}); + } + c->close_table(th.value()); + return emit("sp_GetTableSizeInfo", + {{"TableHeaderSize", 'N', 10, 0}, + {"RecordLength", 'N', 10, 0}, + {"RawRecordCount", 'N', 10, 0}, + {"DeletedRecordCount", 'N', 10, 0}, + {"MemoHeaderSize", 'N', 10, 0}, + {"MemoBlockSize", 'N', 10, 0}, + {"MemoBlockCount", 'N', 10, 0}}, + rows); + } + if (uname == "SP_GETRECORDCRC") { + auto th = c->open_table(arg(0), openads::engine::TableType::Cdx, + openads::engine::OpenMode::Read); + if (!th) return err(openads::AE_NO_FILE_FOUND, arg(0)); + std::optional crc; + if (openads::engine::Table* t = c->lookup_table(th.value())) { + auto recno = static_cast(arg_int(1)); + if (auto g = t->goto_record(recno); g) { + crc = openads::engine::crc32_record(t->record_buffer()); + } + } + c->close_table(th.value()); + if (!crc) return err(openads::AE_INTERNAL_ERROR, "record not found"); + return emit("sp_GetRecordCRC", + {{"CRCInteger", 'N', 10, 0}, {"CRCString", 'C', 12, 0}}, + {{std::to_string(static_cast(*crc)), + std::to_string(*crc)}}); + } + if (uname == "SP_GETCOLLATIONS") { + std::vector> rows; + for (const char* name : {"PL852", "NTXPL852"}) { + if (!spproc::is_null_arg(arg(0)) && + !spproc::like_match(arg(0), name)) continue; + rows.push_back({name, "PL852", "Polish CP-852 OEM collation", + "1", "852", "F", "", "F"}); + } + return emit("sp_GetCollations", + {{"Name", 'C', 100, 0}, + {"ShortName", 'C', 8, 0}, + {"Description", 'C', 100, 0}, + {"Version", 'N', 5, 0}, + {"CodePage", 'N', 5, 0}, + {"FoxCompat", 'L', 1, 0}, + {"UnicodeLocale", 'C', 16, 0}, + {"AllowMultiple", 'L', 1, 0}}, + rows); + } + if (uname == "SP_GETCOLLATIONTABLE") { + std::string want = spproc::is_null_arg(arg(0)) ? arg(1) : arg(0); + std::vector> rows; + if (openads::engine::lookup_oem_collation(want.c_str()) != nullptr) { + rows.push_back({"1", spproc::lower(want) == "ntxpl852" + ? "NTXPL852" : "PL852", + "PL852", "1", "852", "", "", "", "0", "", "F"}); + } + return emit("sp_GetCollationTable", + {{"CollationID", 'N', 5, 0}, + {"Name", 'C', 100, 0}, + {"ShortName", 'C', 8, 0}, + {"Version", 'N', 5, 0}, + {"CodePage", 'N', 5, 0}, + {"CE", 'C', 1, 0}, + {"Upper", 'C', 1, 0}, + {"Lower", 'C', 1, 0}, + {"NumContractions", 'N', 5, 0}, + {"Contractions", 'C', 1, 0}, + {"AllowMultiple", 'L', 1, 0}}, + rows); + } + if (uname == "SP_ALLOWMULTIPLECOLLATIONS") { + // Accepted no-op: OpenADS opens one collation per index today. + return emit("sp_AllowMultipleCollations", + {{"Result", 'N', 5, 0}}, {{"0"}}); + } + + // -- Backup / restore -------------------------------------------------------- + // SAP-shaped result set: an EMPTY set means complete success; rows are + // warnings/errors. Shares openads::engine::backup with the adsbackup + // command-line tool so the two entry points cannot drift. + if (uname == "SP_BACKUPDATABASE" || uname == "SP_BACKUPFREETABLES" || + uname == "SP_RESTOREDATABASE" || uname == "SP_RESTOREFREETABLES") { + namespace bk = openads::engine::backup; + openads::util::Result res = + openads::util::Error{openads::AE_INTERNAL_ERROR, 0, "", ""}; + + if (uname == "SP_BACKUPDATABASE") { + // (DestinationPath, Options) + if (!c->has_dd() || c->dd_path().empty()) { + return err(openads::AE_FUNCTION_NOT_AVAILABLE, + "sp_BackupDatabase requires a data dictionary " + "connection (use sp_BackupFreeTables for free " + "tables)"); + } + // Flush every open table first so the file-level image is + // consistent with what the engine has in memory. + state().registry.for_each_handle( + [&](Handle, HandleKind k, void* p) { + if (k != HandleKind::Table) return; + if (auto* tp = static_cast(p)) (void)tp->flush(); + }); + res = bk::backup_database(c->dd_path(), arg(0), + bk::parse_options(arg(1))); + } else if (uname == "SP_BACKUPFREETABLES") { + // (SourcePath, SourceMask, DestinationPath, Options, + // FreeTablePasswords) + auto opt = bk::parse_options(arg(3)); + if (!spproc::is_null_arg(arg(4))) { + opt.warnings.push_back( + "FreeTablePasswords ignored: OpenADS copies encrypted " + "tables byte-for-byte, no password needed"); + } + state().registry.for_each_handle( + [&](Handle, HandleKind k, void* p) { + if (k != HandleKind::Table) return; + if (auto* tp = static_cast(p)) (void)tp->flush(); + }); + std::string src = spproc::is_null_arg(arg(0)) + ? c->data_dir() : arg(0); + res = bk::backup_free_tables(src, arg(1), arg(2), opt); + } else if (uname == "SP_RESTOREDATABASE") { + // (SourcePath[.add], SourcePassword, DestinationPath[.add], + // Options) + res = bk::restore_database(arg(0), arg(2), + bk::parse_options(arg(3))); + } else { // SP_RESTOREFREETABLES + // (SourcePath, DestinationPath, Options, FreeTablePasswords) + res = bk::restore_free_tables(arg(0), arg(1), + bk::parse_options(arg(2))); + } + + if (!res.has_value()) { + return err(static_cast(res.error().code), + res.error().message); + } + std::vector> rows; + for (const auto& r0 : res.value().rows) { + rows.push_back({std::to_string(r0.severity), + std::to_string(r0.error_code), + r0.message, r0.table, r0.extra, + "backup.cpp", "0"}); + } + return emit("sp_Backup", + {{"Severity", 'N', 5, 0}, + {"Error Code", 'N', 10, 0}, + {"Error Message", 'C', 200, 0}, + {"Table Name", 'C', 200, 0}, + {"Additional Info", 'C', 260, 0}, + {"Source File", 'C', 32, 0}, + {"Source Line", 'N', 10, 0}}, + rows); + } + + // -- Named-event waits ------------------------------------------------------- + if (uname == "SP_WAITFOREVENT" || uname == "SP_WAITFORANYEVENT") { + const bool any = (uname == "SP_WAITFORANYEVENT"); + std::string name = any ? "" : spproc::lower(arg(0)); + std::int64_t timeout_ms = arg_int(any ? 0 : 1); + + std::unique_lock lk(spproc::ev_mu()); + auto& m = spproc::ev_map(); + if (!any && m.find({c, name}) == m.end()) { + lk.unlock(); + return err(openads::AE_NO_FILE_FOUND, + "event not registered on this connection: " + arg(0)); + } + auto find_signaled = + [&]() -> std::pair { + for (auto& [key, reg] : m) { + if (key.first != c) continue; + if (!any && key.second != name) continue; + if (reg.pending > 0) return {&key.second, ®}; + } + return {nullptr, nullptr}; + }; + + auto pred = [&] { return find_signaled().second != nullptr; }; + if (!pred()) { + if (timeout_ms < 0) { + spproc::ev_cv().wait(lk, pred); + } else if (timeout_ms > 0) { + spproc::ev_cv().wait_for( + lk, std::chrono::milliseconds(timeout_ms), pred); + } + } + + auto [signaled_name, reg] = find_signaled(); + std::string out_name, out_data; + std::uint64_t out_count = 0; + if (reg != nullptr) { + out_name = *signaled_name; + if (reg->with_data) { + --reg->pending; + ++reg->consumed; + out_count = reg->consumed; + if (!reg->data.empty()) { + out_data = reg->data.front(); + reg->data.pop_front(); + } + } else { + out_count = reg->pending; + reg->pending = 0; + } + } else if (!any) { + out_name = name; // timed out: EventCount 0 per SAP docs + } + lk.unlock(); + return emit("sp_WaitForEvent", + {{"EventName", 'C', 200, 0}, + {"EventCount", 'N', 10, 0}, + {"StringData", 'C', 1024, 0}}, + {{out_name, spproc::i2s(out_count), out_data}}); + } + + return false; +} + +} // extern "C++" + +} // extern "C" -- temporarily closed so proc:: helpers get C++ linkage + +// ============================================================ +// Script-engine bridge (docs/script-engine.md par 4.2 / 4.5). +// +// RCB 07/17/2026: this replaces the old string-based "proc" mini- +// interpreter. DD stored functions now run through the typed script +// engine (src/engine/script); this bridge is the SqlBridge the engine +// uses to delegate embedded SQL and subqueries back into the ONE SQL +// executor (AdsExecuteSQLDirect on the caller's statement), and to +// resolve UDF-to-UDF calls by direct recursion. +// ============================================================ +namespace scriptbridge { + +using openads::script::Type; +using openads::script::Value; + +// Parse-once program cache, keyed by the body text itself (bodies are +// immutable strings; identical text -> identical program). +inline openads::util::Result> +compiled(const std::string& body) { + static std::mutex mu; + static std::unordered_map> cache; + { + std::lock_guard lk(mu); + auto it = cache.find(body); + if (it != cache.end()) return it->second; + } + auto prog = openads::script::compile(body); + if (!prog) return prog.error(); + std::lock_guard lk(mu); + cache[body] = prog.value(); + return std::move(prog).value(); +} + +// Convert a display-format string into a typed Value for a declared slot. +// Dates accept both MM/DD/YYYY (the display format) and YYYY-MM-DD. +inline Value value_from_text(const std::string& text, Type t) { + auto trimmed = text; + while (!trimmed.empty() && trimmed.back() == ' ') trimmed.pop_back(); + if (trimmed.empty() && t != Type::Char) return Value::typed_null(t); + switch (t) { + case Type::Integer: + return Value::integer(std::strtoll(trimmed.c_str(), nullptr, 10)); + case Type::Double: + return Value::real(std::strtod(trimmed.c_str(), nullptr)); + case Type::Logical: + return Value::logical(!trimmed.empty() && + (trimmed[0] == 'T' || trimmed[0] == 't' || + trimmed[0] == '1' || trimmed[0] == 'Y')); + case Type::Date: { + int y = 0, m = 0, d = 0; + auto all_digits = [](const std::string& s2, std::size_t a2, + std::size_t b2) { + for (std::size_t k2 = a2; k2 < b2; ++k2) + if (!std::isdigit(static_cast(s2[k2]))) + return false; + return true; + }; + if (trimmed.size() >= 10 && trimmed[2] == '/' && trimmed[5] == '/') { + m = std::atoi(trimmed.substr(0, 2).c_str()); + d = std::atoi(trimmed.substr(3, 2).c_str()); + y = std::atoi(trimmed.substr(6, 4).c_str()); + } else if (trimmed.size() >= 10 && trimmed[4] == '-' && + trimmed[7] == '-') { + y = std::atoi(trimmed.substr(0, 4).c_str()); + m = std::atoi(trimmed.substr(5, 2).c_str()); + d = std::atoi(trimmed.substr(8, 2).c_str()); + } else if (trimmed.size() >= 8 && all_digits(trimmed, 0, 8)) { + // Raw engine format "YYYYMMDD" (DBF date cells and the ADT + // date reader's as_string both use it). + y = std::atoi(trimmed.substr(0, 4).c_str()); + m = std::atoi(trimmed.substr(4, 2).c_str()); + d = std::atoi(trimmed.substr(6, 2).c_str()); + } + if (y == 0) return Value::typed_null(Type::Date); + return Value::date(openads::script::jdn_from_ymd(y, m, d)); + } + case Type::Timestamp: { + // Raw engine format "YYYYMMDDHHMMSS[…]" (AdtTimestamp / DBF T + // reader output) -- digit run with no separators. + bool raw14 = trimmed.size() >= 8; + for (std::size_t k2 = 0; raw14 && k2 < 8; ++k2) + if (!std::isdigit(static_cast(trimmed[k2]))) + raw14 = false; + if (raw14 && (trimmed.size() == 8 || + (trimmed.size() >= 9 && + std::isdigit(static_cast( + trimmed[8]))))) { + Value dv = value_from_text(trimmed.substr(0, 8), Type::Date); + if (dv.is_null) return Value::typed_null(Type::Timestamp); + std::int64_t ms = 0; + if (trimmed.size() >= 14) { + int hh = std::atoi(trimmed.substr(8, 2).c_str()); + int mi = std::atoi(trimmed.substr(10, 2).c_str()); + int ss = std::atoi(trimmed.substr(12, 2).c_str()); + ms = (static_cast(hh) * 3600 + mi * 60 + + ss) * 1000; + } + return Value::timestamp(dv.i * 86400000 + ms); + } + Value dv = value_from_text(trimmed.substr(0, 10), Type::Date); + if (dv.is_null) return Value::typed_null(Type::Timestamp); + std::int64_t ms = 0; + if (trimmed.size() >= 19) { + int hh = std::atoi(trimmed.substr(11, 2).c_str()); + int mi = std::atoi(trimmed.substr(14, 2).c_str()); + int ss = std::atoi(trimmed.substr(17, 2).c_str()); + if (trimmed.find("PM") != std::string::npos && hh < 12) + hh += 12; + ms = (static_cast(hh) * 3600 + mi * 60 + ss) * + 1000; + } + return Value::timestamp(dv.i * 86400000 + ms); + } + case Type::Char: + case Type::Null: + return Value::character(text); + } + return Value::character(text); +} + +// Static result-type inference for a script expression. Drives the temp +// result-column type for ScriptCall projections so subquery consumers see +// typed values (SAP's cursors type expression columns; ours must too). +// Null = unknown -> C(50) fallback. +inline Type infer_expr_type(const openads::script::Expr& e) { + using E = openads::script::ExprKind; + switch (e.kind) { + case E::Literal: return e.lit.type; + case E::FnCall: { + const std::string& f = e.upper; + if (f == "YEAR" || f == "MONTH" || f == "DAY" || + f == "LENGTH" || f == "POSITION" || f == "TIMESTAMPDIFF" || + f == "DAYOFMONTH" || f == "DAYOFWEEK" || f == "DAYOFYEAR" || + f == "HOUR" || f == "MINUTE" || f == "SECOND") + return Type::Integer; + if (f == "CURDATE" || f == "TODAY") return Type::Date; + if (f == "NOW" || f == "CURTIMESTAMP" || f == "TIMESTAMPADD") + return Type::Timestamp; + if (f == "IIF" && e.args.size() == 3) { + Type t = infer_expr_type(*e.args[1]); + if (t != Type::Null) return t; + return infer_expr_type(*e.args[2]); + } + if (f == "CONVERT" || f == "CAST") { + const std::string& tn = e.type_name; + if (tn == "SQL_DATE" || tn == "DATE") return Type::Date; + if (tn == "SQL_TIMESTAMP" || tn == "TIMESTAMP") + return Type::Timestamp; + if (tn == "SQL_INTEGER" || tn == "INTEGER" || + tn == "SQL_SMALLINT") + return Type::Integer; + if (tn == "SQL_DOUBLE" || tn == "SQL_NUMERIC" || + tn == "SQL_FLOAT" || tn == "MONEY") + return Type::Double; + return Type::Null; // SQL_CHAR etc. -> C fallback + } + return Type::Null; + } + case E::Case: { + for (const auto& w : e.whens) { + Type t = infer_expr_type(*w.second); + if (t != Type::Null) return t; + } + if (e.else_expr) return infer_expr_type(*e.else_expr); + return Type::Null; + } + default: return Type::Null; + } +} + +// Bind every column of tbl's CURRENT row as a typed script parameter, +// under both bare and [bracketed] spellings (the script lexer keeps +// brackets in the identifier token). Used by ScriptCall projections and +// MERGE SET evaluation. +inline void bind_row_params(openads::script::Executor& ex, + openads::engine::Table& tbl) { + using FT = openads::drivers::DbfFieldType; + std::uint16_t nfld = tbl.field_count(); + for (std::uint16_t k2 = 0; k2 < nfld; ++k2) { + auto v2 = tbl.read_field(k2); + if (!v2) continue; + const auto& fd2 = tbl.field_descriptor(k2); + Value sv; + if (v2.value().is_null) { + sv = Value::null(); + } else switch (fd2.type) { + case FT::Numeric: case FT::Float: case FT::Double: + case FT::Currency: case FT::AdtMoney: + sv = Value::real(v2.value().as_double); + break; + case FT::Integer: case FT::ShortInt: case FT::AutoInc: + sv = Value::integer( + static_cast(v2.value().as_double)); + break; + case FT::Date: case FT::AdtDate: + sv = value_from_text(v2.value().as_string, Type::Date); + break; + case FT::DateTime: case FT::AdtTimestamp: case FT::ModTime: + sv = value_from_text(v2.value().as_string, Type::Timestamp); + break; + case FT::Logical: + sv = value_from_text(v2.value().as_string, Type::Logical); + break; + default: { + std::string s2 = v2.value().as_string; + while (!s2.empty() && s2.back() == ' ') s2.pop_back(); + sv = Value::character(std::move(s2)); + } + } + ex.set_param("[" + fd2.name + "]", sv); + ex.set_param(fd2.name, std::move(sv)); + } +} + +// Write a typed script Value into a table field: numerics through the +// double path, dates/timestamps as the engine's raw YYYYMMDD[HHMMSS] +// text, logicals as T/F. Shared by MERGE SET and INSERT default-value +// application. +inline openads::util::Result write_script_value( + openads::engine::Table& tbl, std::uint16_t fi, const Value& rv) { + if (rv.is_null) return tbl.set_field_null(fi); + switch (rv.type) { + case Type::Integer: + return tbl.set_field(fi, static_cast(rv.i)); + case Type::Double: + return tbl.set_field(fi, rv.d); + case Type::Logical: + return tbl.set_field(fi, std::string(rv.i ? "T" : "F")); + case Type::Date: { + int y2, m2, d2; + openads::script::ymd_from_jdn(rv.i, y2, m2, d2); + char db[16]; + std::snprintf(db, sizeof(db), "%04d%02d%02d", y2, m2, d2); + return tbl.set_field(fi, std::string(db)); + } + case Type::Timestamp: { + std::int64_t jdn = rv.i / 86400000; + std::int64_t ms2 = rv.i % 86400000; + int y2, m2, d2; + openads::script::ymd_from_jdn(jdn, y2, m2, d2); + char db[24]; + std::snprintf(db, sizeof(db), "%04d%02d%02d%02d%02d%02d", + y2, m2, d2, + static_cast(ms2 / 3600000), + static_cast((ms2 / 60000) % 60), + static_cast((ms2 / 1000) % 60)); + return tbl.set_field(fi, std::string(db)); + } + default: + return tbl.set_field(fi, rv.s); + } +} + +// One-value cursor: answers single-value fast paths (literal iota +// selects, trigger row-image reads) without a SQL round-trip. +struct OneValueCursor final : openads::script::SqlCursor { + Value v; + bool done = false; + explicit OneValueCursor(Value val) : v(std::move(val)) {} + bool next() override { + if (done) return false; + done = true; + return true; + } + std::size_t field_count() const override { return 1; } + Value field(std::size_t) override { return v; } +}; + +// If `sql` is exactly "SELECT FROM system.iota", return the +// literal's value; otherwise empty. Recognizes numbers, 'strings', NULL, +// TRUE/FALSE and {d '...'}/{ts '...'} -- precisely what the executor's +// variable substitution can produce. +inline std::optional literal_iota_select(const std::string& sql) { + auto trim = [](std::string s) { + auto b = s.find_first_not_of(" \t\r\n"); + auto e = s.find_last_not_of(" \t\r\n;"); + if (b == std::string::npos) return std::string(); + return s.substr(b, e - b + 1); + }; + std::string s = trim(sql); + if (s.size() < 12) return std::nullopt; + auto up = s; + for (auto& ch : up) + ch = static_cast(std::toupper((unsigned char)ch)); + if (up.compare(0, 7, "SELECT ") != 0) return std::nullopt; + auto fp = up.rfind(" FROM "); + if (fp == std::string::npos) return std::nullopt; + std::string src = trim(s.substr(fp + 6)); + for (auto& ch : src) + ch = static_cast(std::tolower((unsigned char)ch)); + if (src != "system.iota") return std::nullopt; + std::string lit = trim(s.substr(7, fp - 7)); + if (lit.empty()) return std::nullopt; + std::string lup = lit; + for (auto& ch : lup) + ch = static_cast(std::toupper((unsigned char)ch)); + if (lup == "NULL") return Value::null(); + if (lup == "TRUE") return Value::logical(true); + if (lup == "FALSE") return Value::logical(false); + if (lit.size() >= 2 && lit.front() == '\'' && lit.back() == '\'') { + std::string v2; + for (std::size_t i = 1; i + 1 < lit.size(); ++i) { + if (lit[i] == '\'' && i + 2 < lit.size() && lit[i + 1] == '\'') + { v2 += '\''; ++i; } + else if (lit[i] == '\'') return std::nullopt; // 'a','b' etc. + else v2 += lit[i]; + } + return Value::character(v2); + } + if (lit.front() == '{') { + // {d 'YYYY-MM-DD'} / {ts '...'} -- reuse the text parser. + auto q1 = lit.find('\''); + auto q2 = lit.rfind('\''); + if (q1 == std::string::npos || q2 <= q1) return std::nullopt; + std::string inner = lit.substr(q1 + 1, q2 - q1 - 1); + bool is_ts = lup.compare(0, 3, "{TS") == 0; + Value v2 = value_from_text(inner, + is_ts ? Type::Timestamp : Type::Date); + if (v2.is_null) return std::nullopt; + return v2; + } + char* end = nullptr; + double d = std::strtod(lit.c_str(), &end); + if (end == lit.c_str() + lit.size()) { + if (lit.find('.') == std::string::npos && + d == static_cast(static_cast(d))) + return Value::integer(static_cast(d)); + return Value::real(d); + } + return std::nullopt; +} + +// Typed row access over an AdsExecuteSQLDirect cursor. +struct AbiSqlCursor final : openads::script::SqlCursor { + ADSHANDLE h = 0; + bool first = true; + explicit AbiSqlCursor(ADSHANDLE hc) : h(hc) {} + ~AbiSqlCursor() override { if (h) AdsCloseTable(h); } + // Hand the underlying cursor handle to the caller (top-level script + // result -- S3 §10 mechanism); the wrapper stops owning it. + ADSHANDLE release_handle() { ADSHANDLE x = h; h = 0; return x; } + + bool next() override { + if (!first) AdsSkip(h, 1); + first = false; + UNSIGNED16 eof = 1; + AdsAtEOF(h, &eof); + return eof == 0; + } + std::size_t field_count() const override { + UNSIGNED16 n = 0; + AdsGetNumFields(h, &n); + return n; + } + std::string field_name(std::size_t idx) const override { + // Script cursor field access (c.name -- S3 §11). + UNSIGNED8 nm[128] = {0}; + UNSIGNED16 cap = sizeof(nm) - 1; + if (AdsGetFieldName(h, static_cast(idx + 1), nm, &cap) + != 0) + return ""; + std::string s(reinterpret_cast(nm), cap); + while (!s.empty() && s.back() == ' ') s.pop_back(); + return s; + } + Value field(std::size_t idx) override { + // RCB 07/18/2026 (S3 probe C22): prefer BY-NAME access -- on a + // projected SELECT the engine's ordinal path can reach the BASE + // table's ordinal (returning the wrong column), while name lookup + // respects the projection. Ordinal stays as the fallback for + // aggregate/expression columns whose names don't resolve + // ("COUNT(*)"). + UNSIGNED8* ord = reinterpret_cast( + static_cast(idx + 1)); + UNSIGNED8 nmbuf[128] = {0}; + UNSIGNED16 nmlen = sizeof(nmbuf) - 1; + bool have_name = + AdsGetFieldName(h, static_cast(idx + 1), nmbuf, + &nmlen) == 0 && nmlen > 0; + if (have_name) nmbuf[nmlen] = 0; + UNSIGNED16 ftype = 0; + std::vector buf(65536, 0); + UNSIGNED32 len = static_cast(buf.size() - 1); + bool got = false; + if (have_name && + AdsGetFieldType(h, nmbuf, &ftype) == 0 && + AdsGetString(h, nmbuf, reinterpret_cast(buf.data()), + &len, 0) == 0) + got = true; + if (!got) { + len = static_cast(buf.size() - 1); + AdsGetFieldType(h, ord, &ftype); + if (AdsGetString(h, ord, + reinterpret_cast(buf.data()), + &len, 0) != 0) + return Value::null(); + } + std::string text(buf.data(), len); + switch (ftype) { + case ADS_NUMERIC: case ADS_DOUBLE: case ADS_CURDOUBLE: + case ADS_MONEY: + return value_from_text(text, Type::Double); + case ADS_INTEGER: case ADS_SHORTINT: case ADS_AUTOINC: + return value_from_text(text, Type::Integer); + case ADS_DATE: + return value_from_text(text, Type::Date); + case ADS_TIMESTAMP: + return value_from_text(text, Type::Timestamp); + case ADS_LOGICAL: + return value_from_text(text, Type::Logical); + default: + return Value::character(text); + } + } +}; + +openads::util::Result run_dd_function( + Connection* c, ADSHANDLE hStmt, const std::string& name, + const std::vector& args); + +struct AbiBridge final : openads::script::SqlBridge { + Connection* c = nullptr; + ADSHANDLE hStmt = 0; + AbiBridge(Connection* conn, ADSHANDLE hs) : c(conn), hStmt(hs) {} + + openads::util::Result> + exec(const std::string& sql) override { + // Fast path: "SELECT FROM system.iota" -- the shape that + // variable substitution leaves behind for "(SELECT param FROM + // __input)". Answered directly; also sidesteps the SQL engine's + // current lack of literal-only projections (S2-4 gap list). + { + auto lit = literal_iota_select(sql); + if (lit.has_value()) { + return std::unique_ptr( + new OneValueCursor(std::move(*lit))); + } + } + // Expression fast path: "SELECT FROM system.iota" with a + // script-evaluable projection ("SELECT LENGTH(@t) …" after variable + // substitution). The SQL engine has no expression projections yet; + // the script evaluator's builtin/operator set covers the idiom + // (probes N5, C-series result selects). + { + auto v = expr_iota_value(sql); + if (v.has_value()) { + return std::unique_ptr( + new OneValueCursor(std::move(*v))); + } + } + std::vector sbuf(sql.size() + 1); + std::memcpy(sbuf.data(), sql.c_str(), sql.size() + 1); + ADSHANDLE hc = 0; + UNSIGNED32 rc = AdsExecuteSQLDirect(hStmt, sbuf.data(), &hc); + if (rc != 0) { + // Surface the INNER error text -- the statement prefix alone + // hides which piece of a multi-statement script failed. + std::string inner = openads::abi::last_error_message(); + return openads::util::Error{static_cast(rc), 0, + "embedded SQL failed" + + (inner.empty() ? std::string() : (" [" + inner + "]")) + + ": " + sql.substr(0, 80), ""}; + } + if (hc == 0) + return std::unique_ptr{}; + return std::unique_ptr( + new AbiSqlCursor(hc)); + } + + // Evaluate the projection of a "SELECT FROM system.iota" + // through the script expression evaluator. nullopt = not that shape or + // not script-evaluable (caller falls through to the SQL engine). + std::optional expr_iota_value(const std::string& sql) { + auto trim = [](std::string s) { + auto b = s.find_first_not_of(" \t\r\n"); + auto e = s.find_last_not_of(" \t\r\n;"); + if (b == std::string::npos) return std::string(); + return s.substr(b, e - b + 1); + }; + std::string s = trim(sql); + std::string up = s; + for (auto& ch : up) + ch = static_cast(std::toupper((unsigned char)ch)); + if (up.compare(0, 7, "SELECT ") != 0) return std::nullopt; + auto fp = up.rfind(" FROM "); + if (fp == std::string::npos) return std::nullopt; + std::string src = trim(s.substr(fp + 6)); + for (auto& ch : src) + ch = static_cast(std::tolower((unsigned char)ch)); + if (src != "system.iota") return std::nullopt; + std::string proj = trim(s.substr(7, fp - 7)); + if (proj.empty()) return std::nullopt; + // Single projection only -- but only a TOP-LEVEL comma splits + // projections; commas inside parens/braces/quotes are argument + // separators (TIMESTAMPDIFF( SQL_TSI_MONTH, {d '…'}, {d '…'} )). + { + int depth = 0; + bool in_str = false; + for (char ch : proj) { + if (in_str) { if (ch == '\'') in_str = false; continue; } + if (ch == '\'') in_str = true; + else if (ch == '(' || ch == '{') ++depth; + else if (ch == ')' || ch == '}') --depth; + else if (ch == ',' && depth == 0) return std::nullopt; + } + } + auto prog = openads::script::compile("RETURN " + proj + ";"); + if (!prog) return std::nullopt; + openads::script::Executor ex(this); // subqueries recurse here + auto r = ex.run(*prog.value()); + if (!r || !r.value().returned) return std::nullopt; + return std::move(r.value().return_value); + } + + const openads::engine::DataDict::FunctionEntry* + find_udf(const std::string& name) const { + if (!c->has_dd()) return nullptr; + auto* dd = c->dd(); + if (!dd) return nullptr; + std::string up = name; + for (auto& ch : up) + ch = static_cast(std::toupper((unsigned char)ch)); + for (const auto& kv : dd->functions()) { + std::string k = kv.first; + for (auto& ch : k) + ch = static_cast(std::toupper((unsigned char)ch)); + if (k == up) return &kv.second; + } + return nullptr; + } + + bool has_udf(const std::string& name) override { + return find_udf(name) != nullptr; + } + + openads::util::Result + call_udf(const std::string& name, + const std::vector& args) override { + return run_dd_function(c, hStmt, name, args); + } +}; + +// Execute a DD stored function through the script engine. Shared by the +// SELECT evaluator (K::Udf) and UDF-to-UDF recursion via the bridge. +// The recursion guard is here so both entries are covered; SAP's exact +// depth limit is open question 9.8 in the design doc - 32 is safely +// below any real script. +openads::util::Result run_dd_function( + Connection* c, ADSHANDLE hStmt, const std::string& name, + const std::vector& args) { + static thread_local int depth = 0; + if (depth >= 32) + return openads::util::Error{openads::script::kScriptError, 0, + "UDF recursion too deep at " + name, ""}; + + AbiBridge bridge(c, hStmt); + const auto* fe = bridge.find_udf(name); + if (fe == nullptr) + return openads::util::Error{openads::script::kScriptError, 0, + "unknown function '" + name + "'", ""}; + + auto prog = compiled(fe->implementation); + if (!prog) return prog.error(); + + openads::script::Executor ex(&bridge); + ex.set_user(c->username()); + auto params = openads::script::parse_params(fe->input_params); + if (params) { + const auto& ps = params.value(); + for (std::size_t i = 0; i < ps.size(); ++i) { + Value v = i < args.size() ? args[i] : Value::null(); + // Coerce the caller's value into the declared parameter type + // (display-text -> typed when the caller passed text). + if (!v.is_null && ps[i].type != Type::Null && + v.type != ps[i].type) { + if (v.type == Type::Char && ps[i].type != Type::Char) + v = value_from_text(v.s, ps[i].type); + else { + auto cv = openads::script::coerce_assign( + ps[i].type, v, ps[i].char_limit); + if (cv) v = std::move(cv).value(); + } + } + ex.set_param(ps[i].name, std::move(v)); + } + } + + ++depth; + auto r = ex.run(*prog.value()); + --depth; + if (!r) return r.error(); + if (!r.value().returned) return Value::null(); + return std::move(r.value().return_value); +} + +// ---- S2: DD SQL-script stored procedures (RCB 07/17/2026) ---------------- +// +// Case-insensitive whole-word table-name rewrite outside 'strings' and +// [brackets]. Used to map the proc virtual tables onto real ones: +// __input → system.iota (every __input column reference is a parameter +// name, and parameters are substituted to literals by the +// executor first, so "(SELECT Month FROM __input)" becomes +// "(SELECT 2 FROM system.iota)") +// __output → a per-call temp FREE table created from the declared output +// parameters; returned as the statement cursor, SAP-style. +inline std::string rewrite_word(const std::string& sql, + const std::string& from, + const std::string& to) { + std::string out; + out.reserve(sql.size() + 16); + std::size_t i = 0, n = sql.size(); + auto eq_ci = [](char a, char b) { + return std::toupper((unsigned char)a) == std::toupper((unsigned char)b); + }; + while (i < n) { + char c = sql[i]; + if (c == '\'') { + std::size_t j = i + 1; + while (j < n) { + if (sql[j] == '\'' && j + 1 < n && sql[j + 1] == '\'') j += 2; + else if (sql[j] == '\'') { ++j; break; } + else ++j; + } + out.append(sql, i, j - i); + i = j; + continue; + } + if (c == '[') { + std::size_t j = sql.find(']', i); + j = (j == std::string::npos) ? n : j + 1; + out.append(sql, i, j - i); + i = j; + continue; + } + if ((std::isalpha((unsigned char)c) || c == '_')) { + std::size_t j = i + 1; + while (j < n && (std::isalnum((unsigned char)sql[j]) || + sql[j] == '_')) + ++j; + bool match = (j - i == from.size()); + if (match) + for (std::size_t k = 0; k < from.size(); ++k) + if (!eq_ci(sql[i + k], from[k])) { match = false; break; } + if (match) out += to; + else out.append(sql, i, j - i); + i = j; + continue; + } + out.push_back(c); + ++i; + } + return out; +} + +// Bridge for procedure bodies: rewrites the virtual-table names on every +// embedded statement, then delegates to the ordinary bridge. +struct ProcBridge final : openads::script::SqlBridge { + AbiBridge inner; + std::string out_table; // empty = no output params + ProcBridge(Connection* conn, ADSHANDLE hs) : inner(conn, hs) {} + + openads::util::Result> + exec(const std::string& sql) override { + std::string s = rewrite_word(sql, "__input", "system.iota"); + if (!out_table.empty()) + s = rewrite_word(s, "__output", "[" + out_table + "]"); + return inner.exec(s); + } + bool has_udf(const std::string& name) override { + return inner.has_udf(name); + } + openads::util::Result + call_udf(const std::string& name, + const std::vector& args) override { + return inner.call_udf(name, args); + } +}; + +// SQL column type for a declared output parameter (temp __output table). +// +// The __output temp is an ADT (see run_dd_procedure below), so DBF's limits do +// not apply here. The old 254 cap was DBF's character-field maximum and it +// silently shortened any wider declared output -- SAP procs routinely declare +// CICHAR(255) and wider. ADT carries the length in a uint16 field descriptor; +// an over-long *record* is rejected by AdsCreateTable ("ADT record too long") +// rather than silently truncated, which is the behaviour we want. +inline std::string sql_type_of(const openads::script::Param& p) { + switch (p.type) { + case Type::Char: { + std::size_t n = p.char_limit ? p.char_limit : 254; + if (n > 0xFFFFu) n = 0xFFFFu; + return "CHAR(" + std::to_string(n) + ")"; + } + case Type::Integer: return "INTEGER"; + case Type::Double: return "DOUBLE"; + case Type::Logical: return "LOGICAL"; + case Type::Date: return "DATE"; + case Type::Timestamp: return "TIMESTAMP"; + case Type::Null: break; + } + return "CHAR(254)"; +} + +// Execute a DD SQL-script stored procedure. Returns the name of the temp +// __output table ("" when the proc declares no outputs); the caller opens +// it as the statement cursor. +inline openads::util::Result run_dd_procedure( + Connection* c, ADSHANDLE hStmt, + const openads::engine::DataDict::ProcEntry& pe, + const std::vector& args) { + auto prog = compiled(pe.procedure); + if (!prog) return prog.error(); + + ProcBridge bridge(c, hStmt); + + // Output params → temp FREE table the body INSERTs into. + auto outs = openads::script::parse_params(pe.output_params); + if (outs && !outs.value().empty()) { + char nb[48]; + std::snprintf(nb, sizeof(nb), "_spout_%llx", + static_cast( + openads::platform::monotonic_nanos())); + std::string ddl = "CREATE TABLE [" + std::string(nb) + "] ("; + bool first = true; + for (const auto& p : outs.value()) { + if (!first) ddl += ", "; + first = false; + ddl += "[" + p.name + "] " + sql_type_of(p); + } + ddl += ") AS FREE TABLE"; + + // >>> The __output temp MUST be ADT. Read + // >>> docs/materialised-cursor-temps.md before changing this. + // + // A DBF field descriptor allots the column name 11 bytes, so a DBF + // temp truncates every declared output column to 10 characters: + // sp_GetPhysicalPath's `databasepath` came back as `databasepa`. That + // is not cosmetic -- the procedure may be reading ADT tables or + // declaring outputs that mirror SAP catalog columns, whose names run + // well past 10 (Trig_Function_Name is 18), and a caller that then + // references the column by name simply cannot find it. Capping every + // stored-procedure result column at 10 chars limits the SQL engine for + // no reason: ADT carries full-length names and costs nothing here. + // + // Plain CREATE TABLE takes its format from the statement's table type + // and defaults to CDX (i.e. DBF), so pin ADT across this one DDL and + // restore the caller's setting afterwards -- the procedure body may run + // its own CREATE TABLE and must not inherit our choice. + // + // This mirrors the fix already made to the SELECT static-cursor path + // (the `_srt_` temp in exec_sql_direct_impl). Both had the same root + // cause; fixing only one leaves the other silently truncating. + UNSIGNED16 saved_tt = 0; + SqlStatement* st_out = stmt_lookup(hStmt); + if (st_out != nullptr) { + saved_tt = st_out->table_type; + st_out->table_type = ADS_ADT; + } + auto cr = bridge.inner.exec(ddl); + if (st_out != nullptr) st_out->table_type = saved_tt; + if (!cr) return cr.error(); + bridge.out_table = nb; + } + + // Input params become scope variables; bodies read them either directly + // or via "(SELECT FROM __input)", which the substitution + + // __input→system.iota rewrite turns into a literal select. + openads::script::Executor ex(&bridge); + ex.set_user(c->username()); + auto ins = openads::script::parse_params(pe.input_params); + if (ins) { + const auto& ps = ins.value(); + for (std::size_t i = 0; i < ps.size(); ++i) { + Value v = Value::null(); + if (i < args.size()) { + const auto& a = args[i]; + // SAP type-checks argument binding at prepare: a {d …} + // temporal literal bound to an Integer-family parameter is + // AQE 2124 (oracle 2026-07-24, sp_ChargeMonthlyRent -- + // small ints print as in the message). + if (a.is_temporal && ps[i].type == Type::Integer) { + return openads::util::Error{2124, 0, + "Invalid operand for operator: " + "Target Data Type: " + "Source Data Type: " + "Target Name: " + ps[i].name, ""}; + } + if (a.is_numeric) { + if (a.number == std::floor(a.number) && + std::abs(a.number) < 1e15) + v = Value::integer( + static_cast(a.number)); + else + v = Value::real(a.number); + } else { + v = Value::character(a.text); + } + if (!v.is_null && ps[i].type != Type::Null && + v.type != ps[i].type) { + if (v.type == Type::Char && ps[i].type != Type::Char) + v = value_from_text(v.s, ps[i].type); + else { + auto cv = openads::script::coerce_assign( + ps[i].type, v, ps[i].char_limit); + if (cv) v = std::move(cv).value(); + } + } + } + ex.set_param(ps[i].name, std::move(v)); + } + } + + auto r = ex.run(*prog.value()); + if (!r) return r.error(); + return bridge.out_table; +} + +// ---- S2: trigger bodies (RCB 07/17/2026) --------------------------------- +// Oracle-verified semantics (probe Q6, sweep copy): a failing trigger makes +// the DML statement return 7200; BEFORE failure blocks the write, AFTER +// failure keeps it. The old fragment skipped IF/WHILE/EXECUTE and swallowed +// errors entirely. + +// Typed value for a collected trigger row-image field (S3): the DBF/ADT +// field type char decides the script type, so `n.recurring > 0` sees a +// number and `n.[When]` a timestamp. Raw driver formats: Date "YYYYMMDD", +// DateTime "YYYYMMDDHHMMSS" (dbf_common decode_field). +inline Value trig_value(const TrigField_& f) { + const std::string& s = f.text; + switch (f.type) { // canonical chars from trig_type_char_ + case 'N': + if (s.empty()) return Value::typed_null(Type::Double); + return Value::real(std::strtod(s.c_str(), nullptr)); + case 'I': + if (s.empty()) return Value::typed_null(Type::Integer); + return Value::integer(std::strtoll(s.c_str(), nullptr, 10)); + case 'L': + if (s.empty()) return Value::typed_null(Type::Logical); + return Value::logical(s[0] == 'T' || s[0] == 't' || + s[0] == 'Y' || s[0] == '1'); + case 'D': { + if (s.size() < 8) return Value::typed_null(Type::Date); + int y = std::atoi(s.substr(0, 4).c_str()); + int m = std::atoi(s.substr(4, 2).c_str()); + int d = std::atoi(s.substr(6, 2).c_str()); + if (y == 0) return Value::typed_null(Type::Date); + return Value::date(openads::script::jdn_from_ymd(y, m, d)); + } + case 'T': { + if (s.size() < 8) return Value::typed_null(Type::Timestamp); + int y = std::atoi(s.substr(0, 4).c_str()); + int m = std::atoi(s.substr(4, 2).c_str()); + int d = std::atoi(s.substr(6, 2).c_str()); + if (y == 0) return Value::typed_null(Type::Timestamp); + std::int64_t ms = 0; + if (s.size() >= 14) { + int hh = std::atoi(s.substr(8, 2).c_str()); + int mi = std::atoi(s.substr(10, 2).c_str()); + int ss = std::atoi(s.substr(12, 2).c_str()); + ms = (static_cast(hh) * 3600 + mi * 60 + ss) * + 1000; + } + return Value::timestamp( + openads::script::jdn_from_ymd(y, m, d) * 86400000 + ms); + } + default: + return Value::character(s); + } +} + +// SQL literal for a row-image field -- typed rendering ({d '…'} for dates, +// bare numbers, quoted strings) via the script engine's own renderer. +inline std::string trig_literal(const TrigField_& f) { + return openads::script::to_sql_literal(trig_value(f)); +} + +// One-row cursor over a full trigger row image -- backs +// `DECLARE n CURSOR AS SELECT * FROM __new` (pmsys Trig_Container). +// std::map ordering keeps the column order deterministic. +struct TrigRowCursor final : openads::script::SqlCursor { + const std::map* m; + std::vector*> rows; + bool done = false; + explicit TrigRowCursor(const std::map* mm) + : m(mm) { + if (m != nullptr) + for (const auto& kv : *m) rows.push_back(&kv); + } + bool next() override { + if (done) return false; + done = true; + return m != nullptr; + } + std::size_t field_count() const override { return rows.size(); } + std::string field_name(std::size_t idx) const override { + return idx < rows.size() ? rows[idx]->first : ""; + } + Value field(std::size_t idx) override { + return idx < rows.size() ? trig_value(rows[idx]->second) + : Value::null(); + } +}; + +struct TriggerBridge final : openads::script::SqlBridge { + AbiBridge inner; + const std::map* new_f; + const std::map* old_f; + bool instead_of = false; + + TriggerBridge(Connection* conn, ADSHANDLE hs, + const std::map* nf, + const std::map* of, + bool io) + : inner(conn, hs), new_f(nf), old_f(of), instead_of(io) {} + + const TrigField_* row_field(const std::map* m, + std::string name) const { + if (m == nullptr) return nullptr; + for (auto& ch : name) + ch = static_cast(std::tolower((unsigned char)ch)); + auto it = m->find(name); + return it == m->end() ? nullptr : &it->second; + } + + openads::util::Result> + exec(const std::string& sql) override { + static const bool trig_trace = + openads::util::client_setting_truthy("OPENADS_TRACE", "trace"); + if (trig_trace) + std::fprintf(stderr, "[trig-exec] %.120s\n", sql.c_str()); + // 1. INSERT INTO __error … VALUES (code, 'msg') -- the classic ADS + // way for a trigger to fail the DML. Surface it as an error. + { + std::string up = sql; + for (auto& ch : up) + ch = static_cast(std::toupper((unsigned char)ch)); + if (up.find("__ERROR") != std::string::npos && + up.compare(0, 6, "INSERT") == 0) { + std::int32_t code = openads::script::kScriptError; + std::string msg = "trigger error"; + auto vp = up.find("VALUES"); + if (vp != std::string::npos) { + auto p = sql.find('(', vp); + auto q = sql.rfind(')'); + if (p != std::string::npos && q != std::string::npos && + q > p) { + std::string innr = sql.substr(p + 1, q - p - 1); + // number, 'msg' | 'msg' + std::size_t k = 0; + while (k < innr.size() && innr[k] == ' ') ++k; + if (k < innr.size() && innr[k] != '\'') { + code = std::atoi(innr.c_str() + k); + auto comma = innr.find(',', k); + k = comma == std::string::npos ? innr.size() + : comma + 1; + } + auto q1 = innr.find('\'', k); + if (q1 != std::string::npos) { + std::string m2; + for (std::size_t z = q1 + 1; z < innr.size(); ++z) { + if (innr[z] == '\'' && z + 1 < innr.size() && + innr[z + 1] == '\'') { m2 += '\''; ++z; } + else if (innr[z] == '\'') break; + else m2 += innr[z]; + } + msg = m2; + } + } + } + return openads::util::Error{code, 0, msg, "__error"}; + } + } + + // 2. Replace __new.field / __old.field inline references. + std::string s; + s.reserve(sql.size()); + for (std::size_t i = 0; i < sql.size();) { + char c = sql[i]; + if (c == '\'') { + std::size_t j = i + 1; + while (j < sql.size()) { + if (sql[j] == '\'' && j + 1 < sql.size() && + sql[j + 1] == '\'') j += 2; + else if (sql[j] == '\'') { ++j; break; } + else ++j; + } + s.append(sql, i, j - i); + i = j; + continue; + } + if (c == '_' && i + 6 < sql.size() && sql[i + 1] == '_') { + std::string w = sql.substr(i, 5); + for (auto& ch : w) + ch = static_cast(std::tolower((unsigned char)ch)); + bool isn = (w == "__new"), iso = (w == "__old"); + if ((isn || iso) && sql[i + 5] == '.') { + std::size_t fs = i + 6, fe = fs; + while (fe < sql.size() && + (std::isalnum((unsigned char)sql[fe]) || + sql[fe] == '_')) + ++fe; + const TrigField_* v = row_field(isn ? new_f : old_f, + sql.substr(fs, fe - fs)); + s += v ? trig_literal(*v) : std::string("NULL"); + i = fe; + continue; + } + } + s.push_back(c); + ++i; + } + + // 3. Fast path: SELECT FROM __new|__old -- one-value cursor. + { + std::string t = s; + std::size_t b = t.find_first_not_of(" \t\r\n"); + if (b != std::string::npos) t = t.substr(b); + std::string up = t; + for (auto& ch : up) + ch = static_cast(std::toupper((unsigned char)ch)); + if (up.compare(0, 7, "SELECT ") == 0) { + auto fp = up.find(" FROM "); + if (fp != std::string::npos) { + std::string src = t.substr(fp + 6); + auto e2 = src.find_first_of(" \t\r\n;"); + if (e2 != std::string::npos) src = src.substr(0, e2); + for (auto& ch : src) + ch = static_cast( + std::tolower((unsigned char)ch)); + if (src == "__new" || src == "__old" || + src == "__input") { + std::string col = t.substr(7, fp - 7); + auto ce = col.find_last_not_of(" \t\r\n"); + col = (ce == std::string::npos) + ? "" : col.substr(0, ce + 1); + auto cb = col.find_first_not_of(" \t\r\n"); + if (cb != std::string::npos) col = col.substr(cb); + if (!col.empty() && col.front() == '[' && + col.back() == ']') + col = col.substr(1, col.size() - 2); + const auto* m = (src == "__old") ? old_f : new_f; + // SELECT * FROM __new|__old -- full row image as a + // cursor (pmsys Trig_Container: DECLARE n CURSOR AS + // SELECT * FROM __new; …; n.field). + if (col == "*") + return std::unique_ptr< + openads::script::SqlCursor>( + new TrigRowCursor(m)); + const TrigField_* v = row_field(m, col); + return std::unique_ptr( + new OneValueCursor( + v ? trig_value(*v) : Value::null())); + } + } + } + } + + // 4. Statements still referencing the bare __new/__old tables: + // a normal trigger re-inserting its source row would double-write + // (old-fragment parity: skip); an INSTEAD OF trigger's write is + // the real one -- run it with the row image as literals. + { + std::string low = s; + for (auto& ch : low) + ch = static_cast(std::tolower((unsigned char)ch)); + bool refs = low.find("__new") != std::string::npos || + low.find("__old") != std::string::npos; + if (refs && !instead_of) + return std::unique_ptr{}; + // S4 -- the INSTEAD OF idiom `INSERT INTO SELECT * FROM + // __new`: expand to an explicit column INSERT built from the + // row image (empty-text fields are omitted so DD defaults and + // the NOT NULL checks apply to what the client actually sent). + if (refs && instead_of && new_f != nullptr && !new_f->empty()) { + std::size_t p = 0; + auto ws2 = [&](std::size_t& q) { + while (q < low.size() && std::isspace( + static_cast(low[q]))) ++q; + }; + auto word2 = [&](std::size_t& q) -> std::string { + ws2(q); + std::size_t b2 = q; + while (q < low.size() && + (std::isalnum(static_cast( + low[q])) || + low[q] == '_' || low[q] == '.')) + ++q; + return low.substr(b2, q - b2); + }; + ws2(p); + if (word2(p) == "insert" && word2(p) == "into") { + std::size_t name_pos = p; + ws2(name_pos); + std::string tgt = word2(p); + std::string tgt_orig = s.substr(name_pos, tgt.size()); + std::size_t q = p; + bool star_shape = word2(q) == "select"; + if (star_shape) { + ws2(q); + star_shape = q < low.size() && low[q] == '*'; + if (star_shape) ++q; + } + if (star_shape && word2(q) == "from" && + word2(q) == "__new") { + std::string cols2, vals2; + for (const auto& kv : *new_f) { + if (kv.second.text.empty()) continue; + if (!cols2.empty()) { + cols2 += ", "; + vals2 += ", "; + } + cols2 += kv.first; + vals2 += trig_literal(kv.second); + } + if (!cols2.empty()) { + return inner.exec( + "INSERT INTO " + tgt_orig + " (" + cols2 + + ") VALUES (" + vals2 + ")"); + } + } + } + } + if (refs) { + // v1: substitute bare field identifiers (typed literals), + // then map the virtual table to system.iota (single-row + // source). + if (new_f != nullptr) + for (const auto& kv : *new_f) + s = rewrite_word(s, kv.first, + trig_literal(kv.second)); + s = rewrite_word(s, "__new", "system.iota"); + s = rewrite_word(s, "__old", "system.iota"); + } + } + return inner.exec(s); + } + bool has_udf(const std::string& name) override { + return inner.has_udf(name); + } + openads::util::Result + call_udf(const std::string& name, + const std::vector& args) override { + return inner.call_udf(name, args); + } +}; + +// ---- S3: top-level scripts through AdsExecuteSQLDirect ------------------- +// SAP mechanism (doc §10): AdsExecuteSQLDirect accepts full multi-statement +// scripts; the returned cursor is the last SELECT's result; a script with no +// SELECT returns rc=0 and no cursor. OpenADS previously executed only single +// statements -- ad-hoc scripts, the qa-diff probe battery, and pmsys +// sp_GetPhysicalPath's EXECUTE IMMEDIATE (a full script string) all need +// this entry. + +// Is this input a script (vs a single SQL statement the dispatcher owns)? +// Lexed with the script lexer so ';' inside strings/brackets never counts. +inline bool is_script_input(const std::string& sql) { + auto toks = openads::script::lex(sql); + if (!toks) return false; // not even lexable → SQL engine + const auto& t = toks.value(); + if (t.empty()) return false; + using Tok = openads::script::Tok; + if (t[0].kind == Tok::Ident) { + const std::string& k = t[0].upper; + // Script-only leading constructs. + if (k == "DECLARE" || k == "TRY" || k == "WHILE" || k == "IF" || + k == "RAISE" || k == "RETURN") + return true; + if (k == "EXECUTE" && t.size() > 1 && t[1].kind == Tok::Ident && + t[1].upper == "IMMEDIATE") + return true; + } + // Multi-statement: a ';' followed by another real token. + for (std::size_t i = 0; i + 1 < t.size(); ++i) + if (t[i].kind == Tok::Semi && t[i + 1].kind != Tok::End) + return true; + return false; +} + +// Execute a top-level script. The last SELECT's cursor either carries a real +// ADS handle (AbiSqlCursor -- handed straight to the caller) or is an +// engine-internal cursor (literal fast path, trigger row image) that gets +// materialized into a temp FREE DBF, mirroring the EXECUTE PROCEDURE result +// machinery. +inline openads::util::Result run_top_level_script( + Connection* c, ADSHANDLE hStmt, const std::string& sql, + ADSHANDLE* phCursor) { + auto prog = compiled(sql); + if (!prog) return prog.error(); + AbiBridge bridge(c, hStmt); + openads::script::Executor ex(&bridge); + ex.set_user(c->username()); + auto r = ex.run(*prog.value()); + if (!r) { + // Uncaught RAISE surfaces in SAP's shape (§11 F3c/F5): rc 7200, + // "{[name] code : msg}". + if (r.error().sub_code == openads::script::kRaiseSubCode) { + return openads::util::Error{openads::script::kScriptError, 2224, + "An exception is raised in the SQL script. {[" + + r.error().context + "] " + + std::to_string(r.error().code) + " : " + + r.error().message + "}", + r.error().context}; + } + return r.error(); + } + *phCursor = 0; + auto cur = std::move(r.value().last_select); + if (!cur) return {}; + + // Real SQL cursor: pass its handle through. + if (auto* abi = dynamic_cast(cur.get())) { + *phCursor = abi->release_handle(); + return {}; + } + + // Engine-internal cursor: materialize rows into a temp ADT (full-length + // column names -- the DBF this used to write truncated them to 10). + std::size_t nc = cur->field_count(); + if (nc == 0) return {}; + if (nc > 64) nc = 64; // sanity cap + std::vector names; + for (std::size_t k = 0; k < nc; ++k) { + std::string nm = cur->field_name(k); + if (nm.empty()) nm = nc == 1 ? "EXPR" : "COL" + std::to_string(k + 1); + names.push_back(std::move(nm)); + } + std::vector> rows; + while (cur->next()) { + std::vector row; + for (std::size_t k = 0; k < nc; ++k) + row.push_back(openads::script::to_display(cur->field(k))); + rows.push_back(std::move(row)); + if (rows.size() >= 65000) break; // DBF row sanity cap + } + + constexpr std::uint16_t kW = 254; // every column CHAR(254) + std::vector scols; + scols.reserve(nc); + for (auto& nm : names) scols.push_back({nm, 'C', kW, 0}); + std::vector rowbuf; + rowbuf.reserve(rows.size() * nc * kW); + for (const auto& row : rows) { + for (std::size_t k = 0; k < nc; ++k) { + const std::string& v = row[k]; + for (std::size_t i = 0; i < kW; ++i) + rowbuf.push_back(i < v.size() + ? static_cast(v[i]) : ' '); + } + } + ADSHANDLE gh_scr = 0; + UNSIGNED32 mrc = materialise_temp_adt(c, "_scr_", scols, rowbuf, + static_cast(nc) * kW, + &gh_scr); + if (mrc != openads::AE_SUCCESS) { + return openads::util::Error{static_cast(mrc), 0, + "script result temp materialise failed", ""}; + } + *phCursor = gh_scr; + return {}; +} + +// A subquery projects exactly one scalar aggregate and nothing else. +// Since S4, aggregate projections carry an `$AGG_` placeholder in +// `projection` (to preserve SELECT-list order alongside group-key +// columns), so "pure aggregate" is projection == that single placeholder +// (empty is still accepted for older callers). +inline bool sq_is_scalar_agg(const openads::sql::SelectStmt& sq) { + if (sq.aggregates.size() != 1) return false; + if (sq.projection.empty()) return true; + return sq.projection.size() == 1 && + sq.projection[0].rfind("$AGG_", 0) == 0; +} + +// S4 -- shared by the join materializers (2-table + N-way). The merged +// temp cursor is a text-convention DBF, but the SOURCE tables may be +// ADT (pmsys) whose records carry a 5-byte prefix and binary field +// encodings -- so joins must go through DECODED field values +// (Table::read_field), never raw record bytes, and each source field +// maps to a DBF text descriptor. +inline openads::drivers::DbfField join_out_field( + const openads::drivers::DbfField& f) { + using T = openads::drivers::DbfFieldType; + openads::drivers::DbfField o = f; + switch (f.type) { + case T::Character: case T::CiCharacter: case T::Varchar: + o.raw_type = 'C'; + o.length = static_cast( + std::min(f.length, 254)); + o.decimals = 0; + break; + case T::Memo: case T::Binary: case T::Varbinary: + // Memo text truncated into an inline cell (no .dbt on the + // temp cursor); binary yields blanks. + o.raw_type = 'C'; o.length = 254; o.decimals = 0; + break; + case T::Numeric: case T::Float: + o.raw_type = 'N'; + o.length = static_cast( + std::min(f.length ? f.length : 20, 20)); + break; + case T::Integer: case T::ShortInt: case T::AutoInc: + o.raw_type = 'N'; o.length = 11; o.decimals = 0; + break; + case T::Double: case T::Currency: case T::AdtMoney: + o.raw_type = 'N'; o.length = 20; + o.decimals = f.decimals ? f.decimals + : (f.type == T::Double ? 0 : 4); + break; + case T::RowVersion: case T::ModTime: + o.raw_type = 'N'; o.length = 20; o.decimals = 0; + break; + case T::Date: case T::AdtDate: + o.raw_type = 'D'; o.length = 8; o.decimals = 0; + break; + case T::DateTime: case T::AdtTimestamp: + o.raw_type = 'C'; o.length = 22; o.decimals = 0; + break; + case T::Time: + o.raw_type = 'C'; o.length = 11; o.decimals = 0; + break; + case T::Logical: + o.raw_type = 'L'; o.length = 1; o.decimals = 0; + break; + default: + o.raw_type = 'C'; + o.length = static_cast( + std::min(f.length ? f.length : 10, 254)); + o.decimals = 0; + break; + } + return o; +} + +// SAP TRUNCATES AVG toward zero at the result's decimals rather than +// rounding (oracle 2026-07-21: AVG(money) 1505.58485… -> 1505.5848 at +// 4dp; AVG(integer) 2190965.7 -> 2190965 at 0dp). +inline double avg_truncate(double v, int dp) { + double scale = std::pow(10.0, dp); + return std::trunc(v * scale) / scale; +} + +// Format one decoded cell (from the POSITIONED row of `t`) for the +// mapped output descriptor. NULL / read failure yields an empty string +// (blank cell); the caller pads to out.length. +// SAP's declared width for an aggregate result column, oracle-probed against +// the mp corpus 2026-07-30 (see TODO.parity.md). Widths were measured through +// AdsGetString, which preserves the padding -- the PHP binding trims, so it +// cannot be used to check this: +// +// SUM(payfile.CHARGES) N(11,2) -> 21 source + 10 +// SUM(service.ins_charge) N(11,2) -> 21 source + 10 +// SUM(prclines.PRICE) N(10,2) -> 20 source + 10 +// SUM(prclines.UNITS) N(6,0) -> 16 source + 10 +// AVG/MIN/MAX(CHARGES) N(11,2) -> 11 source width +// COUNT(*) -> "852033", unpadded +// +// SUM widens by 10 digits because a sum over many rows can carry far past the +// source column's range; AVG/MIN/MAX cannot exceed it, so they keep it. +// COUNT is an integer and is not padded at all. +// +// All four aggregate materialisers below used a hardcoded width of 20, which +// matched none of these. +// Is a MIN/MAX over this source column decided by TEXT rather than by +// magnitude? The aggregate accumulators are doubles, so a date or a name +// contributes as_double == 0 for every row and MIN/MAX answers "0". Dates +// decode to "YYYYMMDD", which orders lexicographically exactly as it does +// chronologically, so a plain string compare is correct for them. +// Shared by all five aggregate materialisers so they cannot disagree. +inline bool agg_source_is_text(openads::drivers::DbfFieldType t) { + using FT = openads::drivers::DbfFieldType; + switch (t) { + case FT::Numeric: case FT::Float: case FT::Integer: + case FT::Double: case FT::Currency: case FT::ShortInt: + case FT::AutoInc: case FT::AdtMoney: case FT::Logical: + case FT::RowVersion: case FT::ModTime: + return false; + default: + return true; // Date, Character, Time, Timestamp, Memo… + } +} + +// A date-sourced MIN/MAX result must be declared DATE in the temp, not +// CHARACTER: SAP renders it through the connection date format +// ("01/18/0203"), which AdsGetField only applies to Date-typed columns. +// The accumulated cell text stays the raw "YYYYMMDD" -- the temp +// materialiser converts a 'D' column's text cell to the binary JDN. +inline bool agg_source_is_date(openads::drivers::DbfFieldType t) { + using FT = openads::drivers::DbfFieldType; + return t == FT::Date || t == FT::AdtDate; +} + +// Value of an aggregate's argument for the current row. Normally the plain +// column, but SUM(a * b) evaluates `a b` instead -- same shape and same +// arithmetic as a projection-level $ARITH_ item, so only the accumulation +// differs. `rhs_fi` is ignored when the RHS is a literal. +// `arg_dec` = the argument's declared scale (agg_arg_shape). Division is +// the one operation whose exact result can exceed its declared scale, and +// SAP TRUNCATES each row's quotient at that scale toward zero BEFORE +// accumulating - oracle-probed: SUM(A/U) over rows dividing to 0.4166666... +// and 0.6666666... answers 1.0833332 (per-row truncation), not 1.0833333 +// (truncation of the exact sum). Add/Sub/Mul of fixed-scale operands are +// exact within their declared scale, so they pass through untouched. +inline double agg_arg_value(openads::engine::Table& t, + std::int32_t lhs_fi, std::int32_t rhs_fi, + const openads::sql::Aggregate& def, + int arg_dec) { + if (lhs_fi < 0) return 0.0; + auto lv = t.read_field(static_cast(lhs_fi)); + double a = lv ? lv.value().as_double : 0.0; + if (!def.arg_expr) return a; + double b = 0.0; + if (def.arg_expr->rhs_is_literal) { + b = def.arg_expr->rhs_number; + } else if (rhs_fi >= 0) { + auto rv = t.read_field(static_cast(rhs_fi)); + b = rv ? rv.value().as_double : 0.0; + } + using AO = openads::sql::ArithOp; + switch (def.arg_expr->op) { + case AO::Add: return a + b; + case AO::Sub: return a - b; + case AO::Mul: return a * b; + case AO::Div: return (b != 0.0) + ? avg_truncate(a / b, arg_dec) : 0.0; + } + return a; +} + +// Running MIN/MAX over decoded text, plus the widest value seen (the result +// column is sized from this: an ADT date is 4 bytes on disk but 8 characters +// decoded, so the source descriptor's width would truncate it). +struct TextMinMax { + std::string mn, mx; + std::size_t w = 0; + bool any = false; + void feed(const std::string& v) { + // A blank value is absent, not a minimum. A zero-JDN ADT date is NULL + // and decodes to "", but a join/group temp stores it as a blank DBF + // cell with is_null unset -- so without this an empty string wins every + // MIN. SAP agrees: MIN over a column with blank dates returns the + // earliest REAL date, never "". + if (v.find_first_not_of(' ') == std::string::npos) return; + if (v.size() > w) w = v.size(); + if (!any) { mn = mx = v; any = true; return; } + if (v < mn) mn = v; + if (v > mx) mx = v; + } +}; + +inline std::uint16_t agg_result_width(openads::sql::AggregateKind k, + std::uint16_t src_len) { + using K = openads::sql::AggregateKind; + switch (k) { + case K::CountStar: + case K::Count: + return 11; // integer count; never padded wider + case K::Sum: + return static_cast( + std::min(src_len + 10, 0xFF)); + default: // Avg / Min / Max + return src_len ? src_len : 20; + } +} + +// ── SAP's declared shape for an ARITHMETIC aggregate argument ────────────── +// +// Oracle-derived from 45 probes against ace64.dll (scratch table wprobe on +// the mp sandbox, 2026-08-05; the raw measurements are in TODO.parity.md +// history). SAP sizes `SUM(a b)` from an (integer-digits, scale) pair +// computed per operand and combined per operation -- every probe fits: +// +// operand N(L,s): ip = L - s - (s ? 1 : 0) +// operand INTEGER: ip = 11, s = 0 (int32 digits + sign) +// literal: ip = int_digits + 1, s = fractional digits AS +// WRITTEN ("2.50" is (2,2)) -- hence ArithExpr::rhs_text +// a * b: ip = ip1 + ip2 - 1 s = s1 + s2 +// a ± b: ip = max(ip1, ip2) + 1 s = max(s1, s2) +// a / b: ip = ip1 + s2 + min(s1, 2) s = s1 + ip2 - 1 +// +// declared length L = ip + s + (s ? 1 : 0) +// SUM width = L + 10 · AVG/MIN/MAX width = L · displayed scale = s +// (agg_result_width applies the SUM/AVG rule when handed this L.) +// +// The div ip term (min(s1,2)) is the empirical fit over five probes -- +// division inside aggregates is rare; re-probe before "simplifying" it. +struct AggArgShape { + std::uint16_t len = 0; // declared length of the argument expression + std::uint8_t dec = 0; // its scale +}; + +inline void agg_operand_ip_s(const openads::drivers::DbfField& f, + int* ip, int* s) { + using FT = openads::drivers::DbfFieldType; + if (f.type == FT::Integer || f.type == FT::AutoInc || + f.type == FT::ShortInt) { + *ip = 11; *s = 0; + return; + } + if (f.type == FT::Currency || f.type == FT::AdtMoney) { + // SAP types money arithmetic as MONEY (rendered unpadded) -- a known + // divergence; treat as N(len,4) so at least the scale matches. + *s = 4; *ip = std::max(1, f.length - 5); + return; + } + *s = f.decimals; + *ip = std::max(1, f.length - f.decimals - (f.decimals ? 1 : 0)); +} + +inline AggArgShape agg_arg_shape(const openads::drivers::DbfField& lhs, + const openads::sql::Aggregate& def, + const openads::drivers::DbfField* rhs) { + int ip1 = 0, s1 = 0; + agg_operand_ip_s(lhs, &ip1, &s1); + if (!def.arg_expr) { + AggArgShape out; + out.len = lhs.length; + out.dec = static_cast(s1); + return out; + } + int ip2 = 0, s2 = 0; + if (def.arg_expr->rhs_is_literal) { + const std::string& t = def.arg_expr->rhs_text; + auto dot = t.find('.'); + int intd = static_cast(dot == std::string::npos ? t.size() : dot); + if (!t.empty() && (t[0] == '+' || t[0] == '-')) --intd; + if (intd < 1) intd = 1; + ip2 = intd + 1; + s2 = dot == std::string::npos + ? 0 : static_cast(t.size() - dot - 1); + } else if (rhs != nullptr) { + agg_operand_ip_s(*rhs, &ip2, &s2); + } + using AO = openads::sql::ArithOp; + int ip = 0, s = 0; + switch (def.arg_expr->op) { + case AO::Mul: ip = ip1 + ip2 - 1; s = s1 + s2; break; + case AO::Add: + case AO::Sub: ip = std::max(ip1, ip2) + 1; s = std::max(s1, s2); + break; + case AO::Div: ip = ip1 + s2 + std::min(s1, 2); + s = s1 + ip2 - 1; break; + } + if (s < 0) s = 0; + if (s > 18) s = 18; + if (ip < 1) ip = 1; + AggArgShape out; + out.len = static_cast( + std::min(ip + s + (s ? 1 : 0), 200)); + out.dec = static_cast(s); + return out; +} + +// Formats one aggregate cell to SAP's presentation. COUNT is integral and +// SAP does not pad it ("852033"), so it is left-justified and the trailing +// blanks trim away on read; every other aggregate is right-justified to the +// declared width. Keeping both rules here means the five aggregate +// materialisers cannot drift apart again. +inline std::string agg_cell_text(openads::sql::AggregateKind k, + std::uint16_t w, int dec, double v, + const std::string* text = nullptr) { + using K = openads::sql::AggregateKind; + // MIN/MAX over a non-numeric column: the answer IS the text (a date, a + // name). Character cells left-justify, like any other character column. + if (text != nullptr) { + std::string out = *text; + if (out.size() > w) out.resize(w); + out.resize(w, ' '); + return out; + } + char b[80]; + if (k == K::Count || k == K::CountStar) + std::snprintf(b, sizeof(b), "%-*.0f", static_cast(w), v); + else + std::snprintf(b, sizeof(b), "%*.*f", static_cast(w), dec, v); + std::string out(b); + out.resize(w, ' '); + return out; +} + +inline std::string join_cell_text(openads::engine::Table& t, + std::uint16_t fi, + const openads::drivers::DbfField& out) { + auto v = t.read_field(fi); + if (!v || v.value().is_null) return std::string(); + std::string s; + switch (out.raw_type) { + case 'N': { + // RIGHT-justified, matching both the xBase numeric convention and + // SAP: reading a numeric column as a string returns it padded to + // the declared width, e.g. N(10,2) holding 0 reads back as + // " 0.00" -- not "0.00". A join must not reformat a value + // differently from a plain read of the same column. + // + // This was left-justified ("%-*.*f") on the theory that leading + // spaces "leak" into string reads of the temp cursor. They are not + // a leak -- they are the value's declared presentation, and SAP + // emits them. Left-justifying made every joined numeric disagree + // with the same column read directly. + char cell[64]; + std::snprintf(cell, sizeof(cell), "%*.*f", + static_cast(out.length), + static_cast(out.decimals), + v.value().as_double); + s = cell; + break; + } + case 'L': + s = v.value().as_bool ? "T" : "F"; + break; + default: // 'C' / 'D' -- decoded display text + s = v.value().as_string; + break; + } + if (s.size() > out.length) s.resize(out.length); + return s; +} + +// S4 -- SAP wraps EVERY SQL-statement failure as rc 7200 with an AQE +// envelope; the native code and a SQLSTATE ride inside the TEXT +// (oracle-probed 2026-07-23): +// Error 7200: AQE Error: State = S0000; NativeError = 2121; +// [iAnywhere Solutions][Advantage SQL Engine]Column not found: x ... +// File-level errors use the ASA branding instead: +// ... NativeError = 5004; [iAnywhere Solutions][Advantage SQL][ASA] +// Error 5004: Either ACE could not find the specified file, ... +// Spacing is significant (2 spaces after "7200:"/"AQE Error:"/";" before +// the brand, 3 after the State) -- replicated byte-for-byte. +inline std::string sql_error_envelope(std::int32_t code, + const std::string& msg, + const std::string& sql) { + std::string state = "HY000"; + std::int32_t native = code; + std::string text = msg; + bool asa = false; + std::string loc_token; // when set, append SAP's location suffix + switch (code) { + case 5004: // missing table -- msg pre-shaped at source + asa = true; + break; + case 5063: // AE_COLUMN_NOT_FOUND -> SQL engine 2121 + state = "S0000"; native = 2121; + text = "Column not found: " + msg; + loc_token = msg; + break; + case 2124: // arg-binding type check (pre-shaped msg) + state = "S0000"; + if (msg.find("Source Data Type: ") != std::string::npos) + loc_token = "{d"; // locate the offending {d …} literal + break; + case 2137: // ambiguous unqualified column (pre-shaped) + state = "S0000"; + { + auto p = msg.rfind(": "); + if (p != std::string::npos) loc_token = msg.substr(p + 2); + } + break; + case 2129: // INSERT column/value count mismatch + state = "S0000"; // (pre-shaped msg; SAP has no location) + break; + case 5000: + if (msg.rfind("procedure not registered", 0) == 0) { + native = 2198; + std::string nm; + auto colon = msg.find(':'); + if (colon != std::string::npos) + nm = msg.substr(colon + 1); + text = "The stored procedure name is invalid:" + nm; + if (nm.size() > 1) loc_token = nm.substr(1); + } + break; + case 7200: { + if (msg.rfind("unknown function", 0) == 0) { + state = "S0000"; native = 2158; + std::string nm = msg; + auto q1 = nm.find('\''); + auto q2 = nm.rfind('\''); + if (q1 != std::string::npos && q2 > q1) + nm = nm.substr(q1 + 1, q2 - q1 - 1); + text = "Scalar function not found: " + nm; + loc_token = nm; + } else { + state = "42000"; native = 2115; // generic parse error + } + break; + } + default: + break; // native code + our text, HY000 + } + std::string env = "Error 7200: AQE Error: State = " + state + + "; NativeError = " + std::to_string(native) + "; "; + env += asa ? "[iAnywhere Solutions][Advantage SQL][ASA] Error 5004: " + : "[iAnywhere Solutions][Advantage SQL Engine]"; + env += text; + // SAP's location suffix is the 1-based character offset of the + // offending token in the statement text. + if (!loc_token.empty() && !sql.empty()) { + auto is_ident = [](char ch) { + unsigned char u = static_cast(ch); + return std::isalnum(u) != 0 || ch == '_'; + }; + // SAP points at the offending token's own occurrence. For an + // ambiguous column that means the UNQUALIFIED reference -- skip + // occurrences that are part of a longer word or qualified by an + // `alias.` prefix. `require_word` off keeps the old raw-substring + // behaviour as a fallback when no clean word-boundary hit exists. + auto ifind_word = [&](bool require_word) -> std::size_t { + if (loc_token.size() > sql.size()) return std::string::npos; + for (std::size_t i = 0; i + loc_token.size() <= sql.size(); + ++i) { + bool eq = true; + for (std::size_t j = 0; j < loc_token.size(); ++j) { + if (std::toupper(static_cast( + sql[i + j])) != + std::toupper(static_cast( + loc_token[j]))) { eq = false; break; } + } + if (!eq) continue; + if (require_word) { + char before = i > 0 ? sql[i - 1] : '\0'; + char after = (i + loc_token.size() < sql.size()) + ? sql[i + loc_token.size()] : '\0'; + if (is_ident(before) || before == '.' || + is_ident(after)) + continue; + } + return i; + } + return std::string::npos; + }; + auto ifind = [&]() -> std::size_t { + std::size_t w = ifind_word(true); + return w != std::string::npos ? w : ifind_word(false); + }; + std::size_t at = ifind(); + if (at != std::string::npos) { + env += " -- Location of error in the SQL statement is: " + + std::to_string(at + 1); + } + } + return env; +} + +// SAP 2137: an UNQUALIFIED column that resolves in more than one joined +// source table is ambiguous ("Column found in multiple tables"). Returns +// the offending column name (original case, for the message + location) +// or an empty string when none. `tables` are the join's source tables. +// Qualified refs (alias set) and single-table columns are never flagged; +// SELECT * (no select_items) references no specific column. +inline std::string first_ambiguous_join_column( + const openads::sql::SelectStmt& st, + const std::vector& tables) { + auto count_in = [&](const std::string& col) -> int { + int n = 0; + for (auto* t : tables) + if (t != nullptr && t->field_index(col) >= 0) ++n; + return n; + }; + for (const auto& si : st.select_items) { + if (si.wildcard || !si.alias.empty() || si.column.empty()) continue; + if (count_in(si.column) > 1) return si.column; + } + auto check_ob = [&](const openads::sql::OrderBy& ob) -> std::string { + if (ob.column_alias.empty() && !ob.column.empty() && + count_in(ob.column) > 1) + return ob.column; + return std::string(); + }; + if (st.order_by) { + auto a = check_ob(*st.order_by); + if (!a.empty()) return a; + } + for (const auto& ob : st.order_by_extra) { + auto a = check_ob(ob); + if (!a.empty()) return a; + } + // Residual WHERE (join keys are already lowered out / live in ON, so + // only genuine filter columns remain). Subqueries are not descended. + std::function scan = + [&](const openads::sql::WhereExpr* n) -> std::string { + if (n == nullptr) return std::string(); + if (n->kind == openads::sql::WhereExpr::Kind::Cmp) { + const auto& w = n->cmp; + if (w.column_alias.empty() && !w.column.empty() && + count_in(w.column) > 1) + return w.column; + if (w.is_outer_ref && w.outer_column_alias.empty() && + !w.outer_column.empty() && count_in(w.outer_column) > 1) + return w.outer_column; + return std::string(); + } + for (const auto& ch : n->children) { + auto a = scan(ch.get()); + if (!a.empty()) return a; + } + return scan(n->child.get()); + }; + return scan(st.where.get()); +} + +} // namespace scriptbridge + +// ---- S4: connection-scoped trigger row images + session #temp tables ---- +// While a trigger fires, its __new/__old one-row images are visible to +// EVERY statement executed on the connection -- including stored +// procedures the trigger body calls (pmsys: trigger → +// sp_SaveIntoAuditLog → `SELECT n.[col] newVal INTO #MyTrigTbl FROM +// __new n`). #temp tables are connection-scoped result snapshots +// created by SELECT INTO #t, readable via FROM #t, and removed by +// DROP TABLE #t or disconnect. +namespace sess { + +struct TrigImages { + const std::map* new_f = nullptr; + const std::map* old_f = nullptr; +}; +struct TempTable { + std::vector col_names; + std::vector> rows; // display-text cells +}; + +inline std::mutex& mu() { static std::mutex m; return m; } +inline std::map>& image_stacks() { + static std::map> m; + return m; +} +inline std::map>& temp_tables() { + static std::map> m; + return m; +} + +inline std::string lower(std::string s) { + for (auto& ch : s) + ch = static_cast(std::tolower(static_cast(ch))); + return s; +} + +inline TrigImages active_images(Connection* c) { + std::lock_guard lk(mu()); + auto it = image_stacks().find(c); + if (it == image_stacks().end() || it->second.empty()) return {}; + return it->second.back(); +} + +// RAII: registers the firing trigger's images for the whole connection. +struct ImageScope { + Connection* c; + ImageScope(Connection* conn, const std::map* nf, + const std::map* of) + : c(conn) { + std::lock_guard lk(mu()); + image_stacks()[c].push_back(TrigImages{nf, of}); + } + ~ImageScope() { + std::lock_guard lk(mu()); + auto it = image_stacks().find(c); + if (it != image_stacks().end() && !it->second.empty()) + it->second.pop_back(); + if (it != image_stacks().end() && it->second.empty()) + image_stacks().erase(it); + } +}; + +inline void store_temp(Connection* c, const std::string& name, + TempTable t) { + std::lock_guard lk(mu()); + temp_tables()[c][lower(name)] = std::move(t); +} +inline bool drop_temp(Connection* c, const std::string& name) { + std::lock_guard lk(mu()); + auto it = temp_tables().find(c); + if (it == temp_tables().end()) return false; + return it->second.erase(lower(name)) > 0; +} +inline bool get_temp(Connection* c, const std::string& name, + TempTable& out) { + std::lock_guard lk(mu()); + auto it = temp_tables().find(c); + if (it == temp_tables().end()) return false; + auto jt = it->second.find(lower(name)); + if (jt == it->second.end()) return false; + out = jt->second; + return true; +} +inline void forget_connection(Connection* c) { + std::lock_guard lk(mu()); + temp_tables().erase(c); + image_stacks().erase(c); +} + +} // namespace sess + +extern "C++" void sess_forget_connection(Connection* c) { + sess::forget_connection(c); +} + +// Run one trigger body through the script engine. Declared ahead of the +// DML machinery (which lives earlier in this file, inside an anonymous +// namespace) and defined here at global scope. +extern "C++" openads::util::Result script_run_trigger_body( + Connection* conn, ADSHANDLE hConn, const std::string& body, + const std::map& new_f, + const std::map& old_f, + bool is_instead_of) { + auto prog = scriptbridge::compiled(body); + if (!prog) return prog.error(); + ADSHANDLE hStmt = 0; + if (AdsCreateSQLStatement(hConn, &hStmt) != openads::AE_SUCCESS) + return openads::util::Error{openads::AE_INTERNAL_ERROR, 0, + "trigger: statement alloc failed", ""}; + // S4: make this trigger's row images visible to every statement on + // the connection while the body runs -- stored procedures called from + // the body resolve __new/__old through sess::active_images. + sess::ImageScope img_scope(conn, &new_f, &old_f); + scriptbridge::TriggerBridge bridge(conn, hStmt, &new_f, &old_f, + is_instead_of); + openads::script::Executor ex(&bridge); + ex.set_user(conn->username()); + auto r = ex.run(*prog.value()); + AdsCloseSQLStatement(hStmt); + if (!r) return r.error(); + return {}; +} + +namespace { + +template +bool dispatch_sql_uri_acl(const std::string& sqlstr, + openads::sql_backend::SqlDdlDialect dialect, + ExecFn&& exec_sql, + ADSHANDLE* phCursor, + UNSIGNED32& rc_out) { + auto acl = openads::sql_backend::try_sql_acl_statement( + sqlstr, dialect, + [&](const std::string& s) -> openads::util::Result { + return exec_sql(s); + }); + if (!acl) return false; + if (!acl->has_value()) { + rc_out = fail(acl->error()); + return true; + } + *phCursor = 0; + rc_out = ok(); + return true; +} + +// S4 -- DD field constraints for one table (case-insensitive lookup): +// "required" (NOT NULL) and "default" decoded from the DD's SAP binary +// Field records. Keyed by lower-cased field name. Used by the INSERT +// (enforce + apply defaults) and UPDATE (reject SET col = NULL) +// executors. +struct DdFieldRule { + bool required = false; + std::string def; +}; + +std::unordered_map +dd_field_rules_for(Connection* c, const std::string& table) { + std::unordered_map out; + if (c == nullptr || !c->has_dd()) return out; + auto* dd = c->dd(); + if (dd == nullptr) return out; + auto lower = [](std::string s) { + for (auto& ch : s) + ch = static_cast(std::tolower( + static_cast(ch))); + return s; + }; + const std::string want = lower(table); + for (const auto& tkv : dd->field_props()) { + if (lower(tkv.first) != want) continue; + for (const auto& fkv : tkv.second) { + DdFieldRule r; + auto rit = fkv.second.find("required"); + r.required = rit != fkv.second.end() && rit->second == "T"; + auto dit = fkv.second.find("default"); + if (dit != fkv.second.end()) r.def = dit->second; + if (r.required || !r.def.empty()) + out[lower(fkv.first)] = std::move(r); + } + break; + } + return out; +} + +} // namespace + +extern "C" { // reopen for the ACE API exports + +static UNSIGNED32 exec_sql_direct_impl(ADSHANDLE hStatement, UNSIGNED8* pucSQL, + ADSHANDLE* phCursor) { + // ARC (Advantage Data Architect) calls AdsExecuteSQLDirectW with a NULL + // phCursor for statements where it expects no result set (e.g. + // sp_SetApplicationID). SAP ACE accepts NULL and just executes; mirror + // that by running through a scratch slot instead of failing. + ADSHANDLE scratch_cursor = 0; + if (phCursor == nullptr) phCursor = &scratch_cursor; + SqlStatement* st_ptr = stmt_lookup(hStatement); + if (st_ptr == nullptr) return fail(openads::AE_INTERNAL_ERROR, "unknown stmt"); + // Alias so the long body below keeps its `it->second->...` accesses + // unchanged; the lookup is now serialised and the pointer is used off + // the lock (only the owning thread executes this handle). + std::pair it_kv{hStatement, st_ptr}; + auto* it = &it_kv; + arc2_log("EXEC h=%llu remote=%p conn=%p", + (unsigned long long)hStatement, + (void*)st_ptr->remote, (void*)st_ptr->conn); + // M12.7 -- remote SQL exec. The statement was created against a + // RemoteConnection; ship the SQL over the wire, allocate a + // RemoteTable handle around the returned cursor table-id, and + // hand the resulting ADSHANDLE back to the caller. From here on + // every Ads* read on the returned handle (GetField, Skip, etc.) + // routes through the same RemoteTable plumbing M12.4 / M12.5 + // already wired up. + if (it->second->remote != nullptr) { + auto sqlstr = openads::abi::to_internal(pucSQL, 0); + arc2_log("EXEC remote sql=%.80s", sqlstr.c_str()); + auto r = it->second->remote->execute_sql(sqlstr); + if (!r) { + arc2_log("EXEC remote FAIL code=%d msg=%.80s", + r.error().code, r.error().message.c_str()); + return fail(r.error()); + } + std::uint32_t cur_id = r.value(); + if (cur_id == 0) { + *phCursor = 0; + return ok(); + } + auto& s = state(); + std::lock_guard lk(s.mu); + auto rt = std::make_unique(); + rt->conn = it->second->remote; + rt->id = cur_id; + Handle h = s.registry.register_object( + HandleKind::RemoteTable, rt.get()); + remote_sql_cursors_map()[h] = std::move(rt); + *phCursor = to_ads_handle(h); + return ok(); + } +#if defined(OPENADS_WITH_SQLITE) + if (it->second->sqlite != nullptr) { + auto sqlstr = openads::abi::to_internal(pucSQL, 0); + { + UNSIGNED32 acl_rc = 0; + if (dispatch_sql_uri_acl( + sqlstr, openads::sql_backend::SqlDdlDialect::Sqlite, + [&](const std::string& s) { + return it->second->sqlite->exec_sql(s); + }, + phCursor, acl_rc)) { + return acl_rc; + } + } + { + const openads::sql_backend::SqlQueryFn qfn = + [&](const std::string& sql) { + return sqlite_acl_query(it->second->sqlite, sql); + }; + if (UNSIGNED32 rc = sql_uri_check_sql_rights( + sqlstr, it->second->check_rights, + it->second->sql_username, + openads::sql_backend::SqlDdlDialect::Sqlite, qfn); + rc != openads::AE_SUCCESS) { + return rc; + } + } + if (auto rewritten = openads::sql_backend::rewrite_system_select_sql( + openads::sql_backend::SqlDdlDialect::Sqlite, sqlstr)) { + sqlstr = *rewritten; + } + // 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) { + invalidate_sqlite_nav_after_dml(it->second->sqlite); + *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 = to_ads_handle(h); + return ok(); + } +#endif +#if defined(OPENADS_WITH_MSSQL) + if (it->second->mssql_conn != nullptr) { + auto sqlstr = openads::abi::to_internal(pucSQL, 0); + { + UNSIGNED32 acl_rc = 0; + if (dispatch_sql_uri_acl( + sqlstr, openads::sql_backend::SqlDdlDialect::Mssql, + [&](const std::string& s) { + return it->second->mssql_conn->exec_sql(s); + }, + phCursor, acl_rc)) { + return acl_rc; + } + } + { + const openads::sql_backend::SqlQueryFn qfn = + [&](const std::string& sql) { + return mssql_acl_query(it->second->mssql_conn, sql); + }; + if (UNSIGNED32 rc = sql_uri_check_sql_rights( + sqlstr, it->second->check_rights, + it->second->sql_username, + openads::sql_backend::SqlDdlDialect::Mssql, qfn); + rc != openads::AE_SUCCESS) { + return rc; + } + } + if (auto rewritten = openads::sql_backend::rewrite_system_select_sql( + openads::sql_backend::SqlDdlDialect::Mssql, sqlstr)) { + sqlstr = *rewritten; + } + auto qr = it->second->mssql_conn->query(sqlstr); + if (!qr) return fail(qr.error()); + openads::sql_backend::tds::QueryResult result = std::move(qr).value(); + if (!result.ok) { + return fail(static_cast(result.error_number), + result.message.c_str()); + } + if (result.columns.empty()) { + *phCursor = 0; + return ok(); + } + auto st = openads::sql_backend::MssqlTable::from_result( + std::move(result)); + auto& s = state(); + std::lock_guard lk(s.mu); + Handle h = s.registry.register_object( + HandleKind::MssqlTable, st.get()); + mssql_tables_map().emplace(h, std::move(st)); + *phCursor = to_ads_handle(h); + return ok(); + } +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + if (it->second->postgres != nullptr) { + auto sqlstr = openads::abi::to_internal(pucSQL, 0); + { + UNSIGNED32 acl_rc = 0; + if (dispatch_sql_uri_acl( + sqlstr, openads::sql_backend::SqlDdlDialect::Postgres, + [&](const std::string& s) { + return it->second->postgres->exec_sql(s); + }, + phCursor, acl_rc)) { + return acl_rc; + } + } + { + const openads::sql_backend::SqlQueryFn qfn = + [&](const std::string& sql) { + return postgres_acl_query(it->second->postgres, sql); + }; + if (UNSIGNED32 rc = sql_uri_check_sql_rights( + sqlstr, it->second->check_rights, + it->second->sql_username, + openads::sql_backend::SqlDdlDialect::Postgres, qfn); + rc != openads::AE_SUCCESS) { + return rc; + } + } + if (openads::sql::sql_is_alter_table(sqlstr) || + openads::sql::sql_is_drop_index(sqlstr) || + openads::sql::sql_is_drop_table(sqlstr)) { + if (auto r = it->second->postgres->exec_sql(sqlstr); !r) { + return fail(r.error()); + } + invalidate_postgres_nav_after_dml(it->second->postgres); + *phCursor = 0; + return ok(); + } + if (auto rewritten = openads::sql_backend::rewrite_system_select_sql( + openads::sql_backend::SqlDdlDialect::Postgres, sqlstr)) { + sqlstr = *rewritten; + } + auto r = it->second->postgres->run_sql(sqlstr); + if (!r) return fail(r.error()); + auto cursor = std::move(r).value(); + if (!cursor) { + invalidate_postgres_nav_after_dml(it->second->postgres); + *phCursor = 0; + return ok(); + } + auto& s = state(); + std::lock_guard lk(s.mu); + Handle h = s.registry.register_object( + HandleKind::PostgresTable, cursor.get()); + postgres_tables_map().emplace(h, std::move(cursor)); + *phCursor = to_ads_handle(h); + return ok(); + } +#endif +#if defined(OPENADS_WITH_MARIADB) + if (it->second->maria != nullptr) { + auto sqlstr = openads::abi::to_internal(pucSQL, 0); + { + UNSIGNED32 acl_rc = 0; + if (dispatch_sql_uri_acl( + sqlstr, openads::sql_backend::SqlDdlDialect::Maria, + [&](const std::string& s) { + return it->second->maria->exec_sql(s); + }, + phCursor, acl_rc)) { + return acl_rc; + } + } + { + const openads::sql_backend::SqlQueryFn qfn = + [&](const std::string& sql) { + return maria_acl_query(it->second->maria, sql); + }; + if (UNSIGNED32 rc = sql_uri_check_sql_rights( + sqlstr, it->second->check_rights, + it->second->sql_username, + openads::sql_backend::SqlDdlDialect::Maria, qfn); + rc != openads::AE_SUCCESS) { + return rc; + } + } + if (openads::sql::sql_is_alter_table(sqlstr) || + openads::sql::sql_is_drop_index(sqlstr) || + openads::sql::sql_is_drop_table(sqlstr)) { + if (auto r = it->second->maria->exec_sql(sqlstr); !r) { + return fail(r.error()); + } + invalidate_maria_nav_after_dml(it->second->maria); + *phCursor = 0; + return ok(); + } + if (auto rewritten = openads::sql_backend::rewrite_system_select_sql( + openads::sql_backend::SqlDdlDialect::Maria, sqlstr)) { + sqlstr = *rewritten; + } + auto r = it->second->maria->run_sql(sqlstr); + if (!r) return fail(r.error()); + auto cursor = std::move(r).value(); + if (!cursor) { + invalidate_maria_nav_after_dml(it->second->maria); + *phCursor = 0; + return ok(); + } + auto& s = state(); + std::lock_guard lk(s.mu); + Handle h = s.registry.register_object( + HandleKind::MariaTable, cursor.get()); + maria_tables_map().emplace(h, std::move(cursor)); + *phCursor = to_ads_handle(h); + return ok(); + } +#endif +#if defined(OPENADS_WITH_ODBC) + if (it->second->odbc != nullptr) { + auto sqlstr = openads::abi::to_internal(pucSQL, 0); + const auto odbc_dialect = it->second->odbc_dialect; + { + UNSIGNED32 acl_rc = 0; + if (dispatch_sql_uri_acl( + sqlstr, odbc_dialect, + [&](const std::string& s) { + return it->second->odbc->exec_sql(s); + }, + phCursor, acl_rc)) { + return acl_rc; + } + } + { + const openads::sql_backend::SqlQueryFn qfn = + [&](const std::string& sql) { + return odbc_acl_query(it->second->odbc, sql); + }; + if (UNSIGNED32 rc = sql_uri_check_sql_rights( + sqlstr, it->second->check_rights, + it->second->sql_username, + odbc_dialect, qfn); + rc != openads::AE_SUCCESS) { + return rc; + } + } + if (openads::sql::sql_is_alter_table(sqlstr) || + openads::sql::sql_is_drop_index(sqlstr) || + openads::sql::sql_is_drop_table(sqlstr)) { + if (auto r = it->second->odbc->exec_sql(sqlstr); !r) { + return fail(r.error()); + } + invalidate_odbc_nav_after_dml(it->second->odbc); + *phCursor = 0; + return ok(); + } + if (auto rewritten = + openads::sql_backend::rewrite_system_select_sql( + odbc_dialect, sqlstr)) { + sqlstr = *rewritten; + } + if (auto r = it->second->odbc->exec_sql(sqlstr); !r) { + return fail(r.error()); + } + invalidate_odbc_nav_after_dml(it->second->odbc); + *phCursor = 0; + return ok(); + } +#endif +#if defined(OPENADS_WITH_FIREBIRD) + if (it->second->firebird != nullptr) { + auto sqlstr = openads::abi::to_internal(pucSQL, 0); + { + UNSIGNED32 acl_rc = 0; + if (dispatch_sql_uri_acl( + sqlstr, openads::sql_backend::SqlDdlDialect::Firebird, + [&](const std::string& s) { + return it->second->firebird->exec_sql(s); + }, + phCursor, acl_rc)) { + return acl_rc; + } + } + { + const openads::sql_backend::SqlQueryFn qfn = + [&](const std::string& sql) { + return firebird_acl_query(it->second->firebird, sql); + }; + if (UNSIGNED32 rc = sql_uri_check_sql_rights( + sqlstr, it->second->check_rights, + it->second->sql_username, + openads::sql_backend::SqlDdlDialect::Firebird, qfn); + rc != openads::AE_SUCCESS) { + return rc; + } + } + if (openads::sql::sql_is_alter_table(sqlstr) || + openads::sql::sql_is_drop_index(sqlstr) || + openads::sql::sql_is_drop_table(sqlstr)) { + auto r = it->second->firebird->run_sql(sqlstr); + if (!r) return fail(r.error()); + invalidate_firebird_nav_after_dml(it->second->firebird); + *phCursor = 0; + return ok(); + } + if (auto rewritten = openads::sql_backend::rewrite_system_select_sql( + openads::sql_backend::SqlDdlDialect::Firebird, sqlstr)) { + sqlstr = *rewritten; + } + auto r = it->second->firebird->run_sql(sqlstr); + if (!r) return fail(r.error()); + auto cursor = std::move(r).value(); + if (!cursor) { + invalidate_firebird_nav_after_dml(it->second->firebird); + *phCursor = 0; + return ok(); + } + auto& s = state(); + std::lock_guard lk(s.mu); + Handle h = s.registry.register_object( + HandleKind::FirebirdTable, cursor.get()); + firebird_tables_map().emplace(h, std::move(cursor)); + *phCursor = to_ads_handle(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); + + // S3 (§10 mechanism): full multi-statement scripts route through the + // script engine; the last SELECT's cursor comes back as the statement + // cursor. Single statements fall through to the SQL dispatcher below -- + // the script engine's embedded statements re-enter here one at a time, + // so per-statement ACL checks still apply. + if (scriptbridge::is_script_input(sql)) { + auto r = scriptbridge::run_top_level_script(c, hStatement, sql, + phCursor); + if (!r) return fail(r.error()); + return ok(); + } + + // ---- S4: session #temp tables & trigger-image SELECT INTO ----------- + // DROP TABLE #t removes the session snapshot; SELECT ... INTO #t + // materialises one. When the FROM sources are the trigger images + // (__new/__old) the row is resolved directly from the active images + // (pmsys: sp_SaveIntoAuditLog called from a trigger); any other + // source runs the SELECT normally and snapshots its cursor. + { + std::string up5; + up5.reserve(sql.size()); + for (char ch : sql) + up5.push_back(static_cast( + std::toupper(static_cast(ch)))); + auto skip_ws2 = [&](std::size_t& p) { + while (p < sql.size() && + std::isspace(static_cast(sql[p]))) ++p; + }; + auto read_word = [&](std::size_t& p) -> std::string { + skip_ws2(p); + std::size_t b2 = p; + while (p < sql.size() && + (std::isalnum(static_cast(sql[p])) || + sql[p] == '_' || sql[p] == '#')) + ++p; + return sql.substr(b2, p - b2); + }; + std::size_t sp = 0; + skip_ws2(sp); + if (up5.compare(sp, 10, "DROP TABLE") == 0) { + std::size_t q = sp + 10; + skip_ws2(q); + if (q < sql.size() && sql[q] == '#') { + std::string nm = read_word(q); + sess::drop_temp(c, nm); + *phCursor = 0; + return ok(); + } + } + if (up5.compare(sp, 7, "SELECT ") == 0) { + std::size_t into_pos = std::string::npos; + int depth = 0; + bool in_q = false; + for (std::size_t i2 = sp; i2 + 6 <= sql.size(); ++i2) { + char ch = sql[i2]; + if (in_q) { if (ch == '\'') in_q = false; continue; } + if (ch == '\'') { in_q = true; continue; } + if (ch == '(') { ++depth; continue; } + if (ch == ')') { --depth; continue; } + if (depth == 0 && up5.compare(i2, 6, " INTO ") == 0) { + into_pos = i2; + break; + } + } + std::size_t np = into_pos == std::string::npos + ? std::string::npos : into_pos + 6; + if (np != std::string::npos) skip_ws2(np); + if (np != std::string::npos && np < sql.size() && + sql[np] == '#') { + std::string tmp_name = read_word(np); + std::string items = sql.substr(sp + 7, into_pos - (sp + 7)); + std::size_t rp = np; + skip_ws2(rp); + std::string rest = sql.substr(rp); // "FROM ..." + // Parse the FROM source list: tok [alias][, tok [alias]]. + struct Src { std::string table, alias; }; + std::vector srcs; + bool from_ok = false; + { + std::size_t p = 0; + std::string kw = rest.substr(0, 4); + for (auto& ch : kw) + ch = static_cast( + std::toupper(static_cast(ch))); + if (kw == "FROM") { + p = 4; + from_ok = true; + for (;;) { + while (p < rest.size() && std::isspace( + static_cast(rest[p]))) ++p; + std::size_t b2 = p; + while (p < rest.size() && + (std::isalnum(static_cast( + rest[p])) || + rest[p] == '_')) ++p; + Src s2; + s2.table = sess::lower(rest.substr(b2, p - b2)); + while (p < rest.size() && std::isspace( + static_cast(rest[p]))) ++p; + if (p < rest.size() && rest[p] != ',' && + rest[p] != ';') { + std::size_t a2 = p; + while (p < rest.size() && + (std::isalnum( + static_cast( + rest[p])) || + rest[p] == '_')) ++p; + s2.alias = sess::lower( + rest.substr(a2, p - a2)); + } + if (s2.table.empty()) { from_ok = false; break; } + srcs.push_back(std::move(s2)); + while (p < rest.size() && std::isspace( + static_cast(rest[p]))) ++p; + if (p < rest.size() && rest[p] == ',') { + ++p; + continue; + } + break; + } + } + } + bool all_images = from_ok && !srcs.empty(); + for (const auto& s2 : srcs) + if (s2.table != "__new" && s2.table != "__old") + all_images = false; + auto imgs = sess::active_images(c); + { + static const bool into_trace = + openads::util::client_setting_truthy( + "OPENADS_TRACE", "trace"); + if (into_trace) + std::fprintf(stderr, + "[into] tmp=%s all_images=%d new=%p old=%p " + "sql=%.100s\n", + tmp_name.c_str(), all_images ? 1 : 0, + (const void*)imgs.new_f, + (const void*)imgs.old_f, sql.c_str()); + } + if (all_images && + (imgs.new_f != nullptr || imgs.old_f != nullptr)) { + // Resolve each projected item straight from the images. + auto image_for = [&](const std::string& alias) + -> const std::map* { + for (const auto& s2 : srcs) { + if (alias.empty() || s2.alias == alias || + s2.table == alias) { + return s2.table == "__old" ? imgs.old_f + : imgs.new_f; + } + } + return imgs.new_f != nullptr ? imgs.new_f + : imgs.old_f; + }; + sess::TempTable tt; + std::vector row; + std::size_t p = 0; + bool parse_ok = true; + while (parse_ok && p < items.size()) { + while (p < items.size() && std::isspace( + static_cast(items[p]))) ++p; + if (p >= items.size()) break; + // [alias.]col-or-[col] [colalias] + std::string alias, col; + std::size_t b2 = p; + if (items[p] != '[') { + while (p < items.size() && + (std::isalnum( + static_cast( + items[p])) || + items[p] == '_')) ++p; + std::string first = items.substr(b2, p - b2); + if (p < items.size() && items[p] == '.') { + alias = sess::lower(first); + ++p; + } else { + col = first; + } + } + if (col.empty()) { + if (p < items.size() && items[p] == '[') { + std::size_t e2 = items.find(']', p); + if (e2 == std::string::npos) { + parse_ok = false; + break; + } + col = items.substr(p + 1, e2 - p - 1); + p = e2 + 1; + } else { + std::size_t c2 = p; + while (p < items.size() && + (std::isalnum( + static_cast( + items[p])) || + items[p] == '_')) ++p; + col = items.substr(c2, p - c2); + } + } + if (col.empty()) { parse_ok = false; break; } + while (p < items.size() && std::isspace( + static_cast(items[p]))) ++p; + std::string colalias; + if (p < items.size() && items[p] != ',') { + std::size_t a2 = p; + while (p < items.size() && + (std::isalnum( + static_cast( + items[p])) || + items[p] == '_')) ++p; + colalias = items.substr(a2, p - a2); + } + while (p < items.size() && std::isspace( + static_cast(items[p]))) ++p; + if (p < items.size() && items[p] == ',') ++p; + const auto* m = image_for(alias); + std::string cell; + if (m != nullptr) { + auto it2 = m->find(sess::lower(col)); + if (it2 != m->end()) + cell = openads::script::to_display( + scriptbridge::trig_value(it2->second)); + } + tt.col_names.push_back( + colalias.empty() ? col : colalias); + row.push_back(std::move(cell)); + } + if (parse_ok && !tt.col_names.empty()) { + tt.rows.push_back(std::move(row)); + sess::store_temp(c, tmp_name, std::move(tt)); + *phCursor = 0; + return ok(); + } + return fail(openads::AE_PARSE_ERROR, + "SELECT INTO #temp: unsupported item list"); + } + // Generic source: run the SELECT without the INTO clause + // and snapshot the cursor. + std::string inner = sql.substr(sp, into_pos - sp) + " " + + rest; + std::vector ibuf(inner.size() + 1); + std::memcpy(ibuf.data(), inner.c_str(), inner.size() + 1); + ADSHANDLE hc2 = 0; + UNSIGNED32 rc2 = + AdsExecuteSQLDirect(hStatement, ibuf.data(), &hc2); + if (rc2 != 0) return rc2; + if (hc2 == 0) + return fail(openads::AE_PARSE_ERROR, + "SELECT INTO #temp: inner SELECT returned " + "no cursor"); + auto& s5 = state(); + std::lock_guard lk5(s5.mu); + auto* src_tbl = s5.registry.lookup( + hc2, HandleKind::Table); + if (src_tbl == nullptr) { + AdsCloseTable(hc2); + return fail(openads::AE_INTERNAL_ERROR, + "SELECT INTO #temp: cursor lookup"); + } + const auto* proj = projection_for(hc2); + std::vector cols2; + if (proj) { + cols2 = *proj; + } else { + for (std::uint16_t k2 = 0; + k2 < src_tbl->field_count(); ++k2) + cols2.push_back(k2); + } + sess::TempTable tt; + for (std::uint16_t k2 : cols2) + tt.col_names.push_back( + src_tbl->field_descriptor(k2).name); + // Column aliases from the SELECT (`col newVal`) name the + // snapshot's columns, SAP-style. + if (auto ps2 = openads::sql::parse_select(inner)) { + for (const auto& pa : ps2.value().projection_aliases) { + if (pa.first < tt.col_names.size()) + tt.col_names[pa.first] = pa.second; + } + } + std::vector recnos; + if (src_tbl->has_recno_sequence()) { + recnos = src_tbl->recno_sequence(); + } else { + std::uint32_t rcount = src_tbl->record_count(); + for (std::uint32_t r2 = 1; r2 <= rcount; ++r2) + recnos.push_back(r2); + } + for (std::uint32_t r2 : recnos) { + if (auto g2 = src_tbl->goto_record(r2); !g2) continue; + if (!src_tbl->show_deleted_records() && + src_tbl->is_deleted()) continue; + if (!src_tbl->passes_filter()) continue; + std::vector row; + for (std::uint16_t k2 : cols2) { + auto v2 = src_tbl->read_field(k2); + std::string cell = + v2 ? v2.value().as_string : std::string(); + while (!cell.empty() && cell.back() == ' ') + cell.pop_back(); + row.push_back(std::move(cell)); + } + tt.rows.push_back(std::move(row)); + } + AdsCloseTable(hc2); + sess::store_temp(c, tmp_name, std::move(tt)); + *phCursor = 0; + return ok(); + } + } + } + + // Open a table by name, transparently resolving "system.*" virtual tables + // to memory tables materialized from DD state. + auto open_or_sys = [c, &sql](const std::string& tname, + openads::engine::TableType ttype, + openads::engine::OpenMode omode, + openads::engine::LockingMode lmode) + -> openads::util::Result { + std::string resolved = tname; + // S4 -- session #temp table: serve the stored snapshot as a + // memory table. + if (!tname.empty() && tname[0] == '#') { + sess::TempTable tt; + if (!sess::get_temp(c, tname, tt)) + return openads::util::Error{ + static_cast(openads::AE_NO_FILE_FOUND), 0, + tname, ""}; + std::vector cols; + cols.reserve(tt.col_names.size()); + for (std::size_t i2 = 0; i2 < tt.col_names.size(); ++i2) { + std::size_t w = 1; + for (const auto& row : tt.rows) + if (i2 < row.size() && row[i2].size() > w) + w = row[i2].size(); + cols.push_back(SpCol{tt.col_names[i2].c_str(), 'C', + static_cast( + std::min(w, 4096)), + 0}); + } + auto mt = build_memory_result("temp" + tname, cols, tt.rows); + if (!mt) return mt.error(); + return c->adopt_table(std::move(mt).value(), tname); + } + if (tname.size() > 7) { + std::string px = tname.substr(0, 7); + for (auto& ch : px) ch = static_cast( + std::tolower(static_cast(ch))); + if (px == "system.") { + std::optional filter; + if (auto parsed_for_filter = openads::sql::parse_select(sql)) { + filter = extract_system_table_filter_(parsed_for_filter.value()); + } + auto mt = build_system_table( + c, tname.substr(7), filter ? &filter.value() : nullptr); + if (!mt) return mt.error(); + return c->adopt_table(std::move(mt).value(), tname); + } + } + auto ot = c->open_table(resolved, ttype, omode, lmode); + if (!ot && (ot.error().code == 5103 || ot.error().code == 7041)) { + // SAP reports a missing table in a SQL statement as ACE 5004 + // with the resolved path and the table name (the AQE envelope + // at the statement boundary adds the 7200 wrapper). Replicated + // byte-for-byte for parity. + std::string p = c->data_dir(); + if (!p.empty() && p.back() != '\\' && p.back() != '/') + p += '\\'; + p += tname; + std::string base = tname; + std::size_t slash = base.find_last_of("\\/"); + if (slash != std::string::npos) base = base.substr(slash + 1); + if (base.find('.') == std::string::npos) + // DD connections resolve bare table names as ADT (SAP's + // default dictionary table type); free connections by the + // statement's table type. + p += (c->has_dd() || + ttype == openads::engine::TableType::Adt) + ? ".adt" : ".dbf"; + return openads::util::Error{5004, 0, + "Either ACE could not find the specified file, or you do " + "not have sufficient rights to access the file. " + p + + " Table name: " + tname, ""}; + } + return ot; + }; + + // Per-operation ACL check for SQL statements when check_rights is set. + if (it->second->check_rights != 0 && c->has_dd() && !c->username().empty()) { + std::string obj_name; + enum class SqlOp { None, Select, Insert, Update, Delete, Execute } op = + SqlOp::None; + + if (openads::sql::sql_is_insert(sql)) { + if (auto p = openads::sql::parse_insert(sql)) + { obj_name = p.value().table; op = SqlOp::Insert; } + } else if (openads::sql::sql_is_update(sql)) { + if (auto p = openads::sql::parse_update(sql)) + { obj_name = p.value().table; op = SqlOp::Update; } + } else if (openads::sql::sql_is_delete(sql)) { + if (auto p = openads::sql::parse_delete(sql)) + { obj_name = p.value().table; op = SqlOp::Delete; } + } else if (openads::sql::sql_is_merge(sql)) { + if (auto p = openads::sql::parse_merge(sql)) + { obj_name = p.value().table; op = SqlOp::Update; } + } else { + // SELECT or EXECUTE PROCEDURE + if (auto p = openads::sql::parse_select(sql)) { + obj_name = p.value().table; + // Detect "EXECUTE PROCEDURE sp_*" by checking table starts with "sp_" + // or whether the FROM target is a StoredProc/Function in the DD. + auto* dd = c->dd(); + if (dd && dd->has_proc(obj_name)) + op = SqlOp::Execute; + else + op = SqlOp::Select; + } + } + + if (!obj_name.empty() && op != SqlOp::None) { + auto ops = eff_ops(c, obj_name); + bool denied = false; + switch (op) { + case SqlOp::Select: denied = !ops.select_; break; + case SqlOp::Insert: denied = !ops.insert_; break; + case SqlOp::Update: denied = !ops.update_; break; + case SqlOp::Delete: denied = !ops.delete_; break; + case SqlOp::Execute: denied = !ops.execute_; break; + default: break; + } + if (denied) + return fail(openads::AE_ACCESS_DENIED, obj_name.c_str()); + } + } + + // M10.5/M10.7/M10.9: dispatch on the leading keyword. INSERT / + // UPDATE / DELETE / CREATE TABLE / CREATE INDEX write through + // the engine and return no cursor (phCursor → 0); SELECT keeps + // the M9.21 path. + if (openads::sql::sql_is_drop_index(sql)) { + auto di = openads::sql::parse_drop_index(sql); + if (!di) return fail(di.error()); + return fail(openads::AE_FUNCTION_NOT_AVAILABLE, + "DROP INDEX via SQL not implemented for DBF tables"); + } + + if (openads::sql::sql_is_drop_table(sql)) { + auto dt = openads::sql::parse_drop_table(sql); + if (!dt) return fail(dt.error()); + namespace fs = std::filesystem; + fs::path full = fs::path(c->data_dir()) / dt.value().table; + if (!full.has_extension()) full.replace_extension(".dbf"); + if (!fs::exists(full)) { + if (dt.value().if_exists) { + *phCursor = 0; + return ok(); + } + return fail(openads::AE_NO_FILE_FOUND, dt.value().table.c_str()); + } + std::error_code ec; + fs::remove(full, ec); + auto cdx = full; cdx.replace_extension(".cdx"); + fs::remove(cdx, ec); + auto fpt = full; fpt.replace_extension(".fpt"); + fs::remove(fpt, ec); + auto dbt = full; dbt.replace_extension(".dbt"); + fs::remove(dbt, ec); + *phCursor = 0; + return ok(); + } + + if (openads::sql::sql_is_alter_table(sql)) { + auto at = openads::sql::parse_alter_table(sql); + if (!at) return fail(at.error()); + auto field_def = [](const openads::sql::AlterTableAction& a) { + std::string s = a.column + "," + a.type; + if (a.length > 0) { + s += "," + std::to_string(a.length); + if (a.decimals > 0) s += "," + std::to_string(a.decimals); + } + return s; + }; + std::string add_defs, del_list, chg_defs; + for (const auto& action : at.value().actions) { + switch (action.kind) { + case openads::sql::AlterTableAction::Kind::AddColumn: + if (!add_defs.empty()) add_defs += ';'; + add_defs += field_def(action); + break; + case openads::sql::AlterTableAction::Kind::DropColumn: + if (!del_list.empty()) del_list += ';'; + del_list += action.column; + break; + case openads::sql::AlterTableAction::Kind::AlterColumn: + if (!chg_defs.empty()) chg_defs += ';'; + chg_defs += field_def(action); + break; + } + } + auto& s = state(); + ADSHANDLE conn_h = 0; + s.registry.for_each_handle([&](Handle h, HandleKind k, void* p) { + if (k != HandleKind::Connection) return; + if (static_cast(p) == c) conn_h = to_ads_handle(h); + }); + if (conn_h == 0) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + std::vector name_buf(at.value().table.size() + 1, 0); + std::memcpy(name_buf.data(), at.value().table.data(), + at.value().table.size()); + std::vector add_buf(add_defs.size() + 1, 0); + std::memcpy(add_buf.data(), add_defs.data(), add_defs.size()); + std::vector del_buf(del_list.size() + 1, 0); + std::memcpy(del_buf.data(), del_list.data(), del_list.size()); + std::vector chg_buf(chg_defs.size() + 1, 0); + std::memcpy(chg_buf.data(), chg_defs.data(), chg_defs.size()); + UNSIGNED32 rc = AdsRestructureTable( + conn_h, name_buf.data(), nullptr, + 0, 0, 0, 0, + add_buf.data(), + del_buf.data(), + chg_buf.data()); + if (rc != openads::AE_SUCCESS) return rc; + *phCursor = 0; + return ok(); + } + + if (openads::sql::sql_is_create_table(sql)) { + auto& s = state(); + auto ct = openads::sql::parse_create_table(sql); + if (!ct) return fail(ct.error()); + + // M10.42 -- CREATE TABLE t AS SELECT ...: recursively run the + // inner SELECT, build the new table's schema from the result + // cursor's projected fields, then walk + insert each row. + if (!ct.value().select_sql.empty()) { + std::vector selbuf(ct.value().select_sql.size() + 1); + std::memcpy(selbuf.data(), + ct.value().select_sql.c_str(), + ct.value().select_sql.size() + 1); + ADSHANDLE srcCur = 0; + UNSIGNED32 rrc = + AdsExecuteSQLDirect(hStatement, selbuf.data(), &srcCur); + if (rrc != 0) return rrc; + std::lock_guard lk2(s.mu); + openads::engine::Table* src = + s.registry.lookup( + srcCur, HandleKind::Table); + if (!src) { + return fail(openads::AE_INTERNAL_ERROR, + "CTAS inner cursor lookup"); + } + // Build schema from inner cursor (projection-aware). + const auto* proj = projection_for(srcCur); + std::vector schema; + if (proj) { + for (auto idx : *proj) schema.push_back(src->field_descriptor(idx)); + } else { + std::uint16_t nf = src->field_count(); + for (std::uint16_t k = 0; k < nf; ++k) { + schema.push_back(src->field_descriptor(k)); + } + } + // RCB-- 06-25-2026. Determine target table type. If the caller explicitly set a + // type via AdsStmtSetTableType(), honour it; otherwise mirror + // the source so that an ADT source produces an ADT target and a + // DBF source produces a CDX target. Hardcoding ADS_CDX here + // caused silent schema corruption: ADT field types (Money, + // Timestamp, ShortInt, …) have no DBF equivalent, so they were + // silently coerced to Character when the target was forced to + // .dbf regardless of the source format. + UNSIGNED16 ctas_tgt_type = it->second->table_type; + if (ctas_tgt_type == 0 || ctas_tgt_type == ADS_DEFAULT) { + UNSIGNED16 src_type = ADS_CDX; + AdsGetTableType(srcCur, &src_type); + ctas_tgt_type = (src_type == ADS_ADT) ? ADS_ADT : ADS_CDX; + } + // Build NAME,Type,Len,Dec;… from schema. + // Two switch blocks: the first covers printable-letter DBF type + // codes ('C', 'N', …); the second covers ADT numeric type codes + // (1–20) stored as raw bytes by adt_driver.cpp. The ranges are + // disjoint (DBF letters are all ≥ 0x42; ADT codes top out at 20) + // so there is no ambiguity. + auto type_name = [](char raw) -> const char* { + switch (raw) { + case 'C': return "Character"; + case 'N': return "Numeric"; + case 'D': return "Date"; + case 'L': return "Logical"; + case 'M': return "Memo"; + case 'F': return "Float"; + case 'I': return "Integer"; + case 'Y': return "Currency"; + case 'B': return "Double"; + case 'V': return "Varchar"; + case 'Q': return "Varbinary"; + } + switch (static_cast(raw)) { + case 1: return "Logical"; + case 3: return "Date"; + case 4: return "Character"; + case 5: return "Memo"; + case 6: return "Binary"; + case 7: return "Image"; + case 10: return "Double"; + case 11: return "Integer"; + case 12: return "ShortInt"; + case 13: return "Time"; + case 14: return "Timestamp"; + case 15: return "AutoInc"; + case 18: return "Money"; + case 20: return "CiCharacter"; + } + return "Character"; + }; + std::string defs; + for (auto& fd : schema) { + if (!defs.empty()) defs.push_back(';'); + defs += fd.name; + defs.push_back(','); + defs += type_name(static_cast(fd.raw_type)); + if (fd.length > 0) { + defs.push_back(','); + defs += std::to_string(fd.length); + } + if (fd.decimals > 0) { + defs.push_back(','); + defs += std::to_string(fd.decimals); + } + } + std::vector name_buf(ct.value().table.size() + 1, 0); + std::memcpy(name_buf.data(), ct.value().table.data(), + ct.value().table.size()); + std::vector def_buf(defs.size() + 1, 0); + std::memcpy(def_buf.data(), defs.data(), defs.size()); + ADSHANDLE conn_h = 0; + s.registry.for_each_handle([&](Handle h, HandleKind k, void* p) { + if (k != HandleKind::Connection) return; + if (static_cast(p) == c) conn_h = to_ads_handle(h); + }); + if (conn_h == 0) { + AdsCloseTable(srcCur); + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + } + ADSHANDLE hTable = 0; + UNSIGNED32 rc = AdsCreateTable(conn_h, name_buf.data(), nullptr, + ctas_tgt_type, 0, 0, 0, 0, + def_buf.data(), &hTable); + if (rc != openads::AE_SUCCESS) { + AdsCloseTable(srcCur); + return rc; + } + openads::engine::Table* tgt = + s.registry.lookup( + hTable, HandleKind::Table); + if (!tgt) { + AdsCloseTable(srcCur); + AdsCloseTable(hTable); + return fail(openads::AE_INTERNAL_ERROR, "CTAS post-create"); + } + // Walk source rows. + std::vector recnos; + if (src->has_recno_sequence()) { + recnos = src->recno_sequence(); + } else { + std::uint32_t rcount = src->record_count(); + for (std::uint32_t r = 1; r <= rcount; ++r) { + if (auto g = src->goto_record(r); !g) continue; + if (!src->show_deleted_records() && + src->is_deleted()) continue; + if (!src->passes_filter()) continue; + recnos.push_back(r); + } + } + // Pre-resolve src column → tgt column by name match. + std::vector src_cols(schema.size()); + std::vector tgt_cols(schema.size()); + for (std::size_t i = 0; i < schema.size(); ++i) { + src_cols[i] = proj ? (*proj)[i] : static_cast(i); + std::int32_t fi = tgt->field_index(schema[i].name); + if (fi < 0) { + AdsCloseTable(srcCur); + AdsCloseTable(hTable); + return fail(openads::AE_INTERNAL_ERROR, + "CTAS target field missing"); + } + tgt_cols[i] = static_cast(fi); + } + for (std::uint32_t r : recnos) { + if (auto g = src->goto_record(r); !g) continue; + if (auto ar = tgt->append_record(); !ar) { + AdsCloseTable(srcCur); + AdsCloseTable(hTable); + return fail(ar.error()); + } + for (std::size_t i = 0; i < schema.size(); ++i) { + auto v = src->read_field(src_cols[i]); + std::string sv = v ? v.value().as_string : std::string(); + auto wr = tgt->set_field(tgt_cols[i], sv); + if (!wr) { + AdsCloseTable(srcCur); + AdsCloseTable(hTable); + return fail(wr.error()); + } + } + } + (void)tgt->flush(); + AdsCloseTable(srcCur); + AdsCloseTable(hTable); + *phCursor = 0; + return ok(); + } + + // Use the caller-supplied table type (AdsStmtSetTableType), falling + // back to CDX. There is no source table to infer from here, so the + // default is always CDX; callers that want ADT must set it explicitly. + UNSIGNED16 ct_tgt_type = it->second->table_type; + if (ct_tgt_type == 0 || ct_tgt_type == ADS_DEFAULT) ct_tgt_type = ADS_CDX; + + // Build the rddads `NAME,Type,Len,Dec;…` field-def string and + // route through AdsCreateTable so M9.5's parser owns the + // schema-write logic. + std::string defs; + for (const auto& col : ct.value().columns) { + if (!defs.empty()) defs.push_back(';'); + defs += col.name; + defs.push_back(','); + defs += col.type; + if (col.length > 0) { + defs.push_back(','); + defs += std::to_string(col.length); + } + if (col.decimals > 0) { + defs.push_back(','); + defs += std::to_string(col.decimals); + } + } + std::vector name_buf(ct.value().table.size() + 1, 0); + std::memcpy(name_buf.data(), ct.value().table.data(), + ct.value().table.size()); + std::vector def_buf(defs.size() + 1, 0); + std::memcpy(def_buf.data(), defs.data(), defs.size()); + ADSHANDLE hTable = 0; + // Resolve the connection's registry handle so AdsCreateTable + // can look it up the same way external callers do. + ADSHANDLE conn_h = 0; + s.registry.for_each_handle([&](Handle h, HandleKind k, void* p) { + if (k != HandleKind::Connection) return; + if (static_cast(p) == c) conn_h = to_ads_handle(h); + }); + if (conn_h == 0) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + UNSIGNED32 rc = AdsCreateTable(conn_h, name_buf.data(), nullptr, + ct_tgt_type, 0, 0, 0, 0, + def_buf.data(), &hTable); + if (rc != openads::AE_SUCCESS) return rc; + // Close the table immediately; CREATE TABLE returns no cursor. + AdsCloseTable(hTable); + *phCursor = 0; + return ok(); + } + + if (openads::sql::sql_is_create_index(sql)) { + auto& s = state(); + auto ci = openads::sql::parse_create_index(sql); + if (!ci) return fail(ci.error()); + // Resolve the connection handle and open the table to obtain + // an ADSHANDLE for AdsCreateIndex61. + ADSHANDLE conn_h = 0; + s.registry.for_each_handle([&](Handle h, HandleKind k, void* p) { + if (k != HandleKind::Connection) return; + if (static_cast(p) == c) conn_h = to_ads_handle(h); + }); + if (conn_h == 0) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + + // M13.1 -- validate table name is not a SELECT result. + // INDEX ON (SELECT ...) would open the source table file instead + // of using the materialized cursor → corrupts the source indexes. + // Enforce that table name is a simple identifier/filename. + const auto& tname = ci.value().table; + if (tname.empty() || tname[0] == '(' || tname.find("SELECT") != std::string::npos) { + return fail(openads::AE_PARSE_ERROR, + "INDEX ON requires a table name, not a SELECT result; " + "use SELECT ... ORDER BY/DISTINCT/LIMIT to materialize first"); + } + + std::vector name_buf(ci.value().table.size() + 1, 0); + std::memcpy(name_buf.data(), ci.value().table.data(), + ci.value().table.size()); + ADSHANDLE hTable = 0; + if (auto rc = AdsOpenTable(conn_h, name_buf.data(), name_buf.data(), + ADS_CDX, 1, 1, 0, 1, &hTable); + rc != openads::AE_SUCCESS) { + return rc; + } + + // CREATE INDEX writes a structural .adi sidecar named after + // the table's stem so AdsOpenTable auto-attaches it next open. + namespace fs = std::filesystem; + fs::path tbl_path(c->data_dir()); + tbl_path /= ci.value().table; + if (!tbl_path.has_extension()) tbl_path.replace_extension(".dbf"); + fs::path bag = tbl_path; + bag.replace_extension(".cdx"); + + std::vector bag_buf(bag.string().size() + 1, 0); + std::memcpy(bag_buf.data(), bag.string().data(), + bag.string().size()); + std::vector tag_buf(ci.value().tag.size() + 1, 0); + std::memcpy(tag_buf.data(), ci.value().tag.data(), + ci.value().tag.size()); + std::vector expr_buf(ci.value().expression.size() + 1, 0); + std::memcpy(expr_buf.data(), ci.value().expression.data(), + ci.value().expression.size()); + UNSIGNED32 opts = 0; + // Re-encode for AdsCreateIndex61's decode. That decode treats a + // .cdx (compound) tag as descending only when BOTH the compound and + // descending bits are set (the two RDD clients disagree on which bit + // is which -- see the decode comment there), so a descending index + // must carry ADS_COMPOUND | ADS_DESCENDING (0x0A), not 0x08 alone. + if (ci.value().unique) opts |= ADS_UNIQUE; + if (ci.value().descending) opts |= (ADS_COMPOUND | ADS_DESCENDING); + ADSHANDLE hIdx = 0; + UNSIGNED32 rc = AdsCreateIndex61( + hTable, bag_buf.data(), tag_buf.data(), + expr_buf.data(), nullptr, nullptr, + opts, 512, &hIdx); + AdsCloseTable(hTable); + if (rc != openads::AE_SUCCESS) return rc; + *phCursor = 0; + return ok(); + } + + // `CREATE DATABASE "path" [PASSWORD ... DESCRIPTION ... ENCRYPT ...]` + if (openads::sql::sql_is_create_database(sql)) { + auto cd = openads::sql::parse_create_database(sql); + if (!cd) return fail(cd.error()); + namespace fs = std::filesystem; + std::string path = cd.value().path; + if (fs::path(path).is_relative()) + path = (fs::path(c->data_dir()) / path).string(); + auto r = openads::engine::DataDict::create(path); + if (!r) return fail(r.error()); + if (!cd.value().password.empty()) + r.value().set_db_property("prop_1101", cd.value().password); + if (!cd.value().description.empty()) + r.value().set_db_property("prop_1", cd.value().description); + *phCursor = 0; + return ok(); + } + + // `GRANT right [("col")] ON object TO principal` + if (openads::sql::sql_is_grant(sql)) { + auto gs = openads::sql::parse_grant(sql); + if (!gs) return fail(gs.error()); + if (c->has_dd()) { + auto* dd = c->dd(); + const auto& g = gs.value(); + // Map right name → bitmask. Grants accumulate (OR into existing). + using DD = openads::engine::DataDict; + uint32_t new_bits = 0; + std::string r = g.right; + for (auto& ch : r) + ch = static_cast(std::toupper(static_cast(ch))); + if (r == "ALL") new_bits = DD::DD_PERM_FULL; + else if (r == "SELECT") new_bits = DD::DD_PERM_SELECT; + else if (r == "INSERT") new_bits = DD::DD_PERM_INSERT; + else if (r == "UPDATE") new_bits = DD::DD_PERM_UPDATE; + else if (r == "DELETE") new_bits = DD::DD_PERM_DELETE; + else if (r == "EXECUTE") new_bits = DD::DD_PERM_EXECUTE; + else if (r == "REFERENCE" || r == "REFERENCES") + new_bits = DD::DD_PERM_REFERENCE; + else new_bits = DD::DD_PERM_FULL; // unknown → full + // Determine object type from the DD. + std::string obj_type = "Table"; + if (dd->has_proc(g.object)) obj_type = "StoredProc"; + else if (dd->has_function(g.object)) obj_type = "Function"; + else if (dd->has_view(g.object)) obj_type = "View"; + // Accumulate with existing grant for same grantee+object. + uint32_t cur_bits = 0; + for (const auto& pe : dd->permissions()) + if (pe.object_name == g.object && pe.grantee == g.principal) + cur_bits |= pe.bitmask; + dd->grant_permission(obj_type, g.object, g.principal, cur_bits | new_bits); + } + *phCursor = 0; + return ok(); + } + + // `REVOKE right [("col")] ON object FROM principal` + if (openads::sql::sql_is_revoke(sql)) { + auto gs = openads::sql::parse_revoke(sql); + if (!gs) return fail(gs.error()); + if (c->has_dd()) { + auto* dd = c->dd(); + const auto& g = gs.value(); + using DD = openads::engine::DataDict; + uint32_t revoke_bits = DD::DD_PERM_FULL; + std::string r = g.right; + for (auto& ch : r) + ch = static_cast(std::toupper(static_cast(ch))); + if (r == "SELECT") revoke_bits = DD::DD_PERM_SELECT; + else if (r == "INSERT") revoke_bits = DD::DD_PERM_INSERT; + else if (r == "UPDATE") revoke_bits = DD::DD_PERM_UPDATE; + else if (r == "DELETE") revoke_bits = DD::DD_PERM_DELETE; + else if (r == "EXECUTE") revoke_bits = DD::DD_PERM_EXECUTE; + // Compute new bitmask = current & ~revoke_bits. + uint32_t cur_bits = 0; + std::string obj_type = "Table"; + for (const auto& pe : dd->permissions()) { + if (pe.object_name == g.object && pe.grantee == g.principal) { + cur_bits |= pe.bitmask; + obj_type = pe.object_type; + } + } + dd->grant_permission(obj_type, g.object, g.principal, + cur_bits & ~revoke_bits); + } + *phCursor = 0; + return ok(); + } + + // M11.4 -- `CREATE PROCEDURE AS '::'`. + // Loads the DLL, resolves the symbol, registers the proc on the + // connection. Returns no cursor. + if (openads::sql::sql_is_create_procedure(sql)) { + auto& s = state(); + std::lock_guard lk(s.mu); + auto cp = openads::sql::parse_create_procedure(sql); + if (!cp) return fail(cp.error()); + auto rr = c->register_procedure(cp.value().name, + cp.value().dll_path, + cp.value().symbol); + if (!rr) return fail(rr.error()); + *phCursor = 0; + return ok(); + } + + // M11.4 -- `EXECUTE PROCEDURE (, ...)`. Built-in sp_* names + // are dispatched directly to DataDict operations; others call the + // DLL entry point registered via CREATE PROCEDURE. + if (openads::sql::sql_is_execute_procedure(sql)) { + auto& s = state(); + auto ep = openads::sql::parse_execute_procedure(sql); + if (!ep) return fail(ep.error()); + // Check for sp_* built-in first. + std::string uname = ep.value().name; + for (auto& ch : uname) ch = static_cast( + std::toupper(static_cast(ch))); + if (uname.size() > 3 && uname[0]=='S' && uname[1]=='P' && uname[2]=='_') { + // Cursor-producing built-ins run WITHOUT s.mu: sp_WaitForEvent + // blocks until another connection's sp_SignalEvent -- which + // needs this same mutex -- fires or the timeout lapses. + std::optional> spt; + if (dispatch_sp_builtin_cursor(c, uname, ep.value().args, &spt)) { + if (!spt->has_value()) return fail(spt->error()); + std::lock_guard lk(s.mu); + auto th = c->adopt_table(std::move(*spt).value(), + "#" + ep.value().name); + if (!th) return fail(th.error()); + openads::engine::Table* tbl = c->lookup_table(th.value()); + if (!tbl) return fail(openads::AE_INTERNAL_ERROR, "sp adopt"); + ADSHANDLE gh = to_ads_handle(s.registry.register_object( + HandleKind::Table, tbl)); + *phCursor = gh; + return ok(); + } + std::lock_guard lk(s.mu); + UNSIGNED32 brc = ok(); + if (dispatch_sp_builtin(c, uname, ep.value().args, &brc)) { + *phCursor = 0; + return brc; + } + } + // RCB 07/17/2026 (S2): DD SQL-script stored procedures run through + // the script engine (docs/script-engine.md §4.3). This is the path + // that did not exist at all -- script procs previously fell through + // to "procedure not registered". Native (AEP DLL) procs still take + // the registered-fn path below. + if (c->has_dd()) { + auto* dd = c->dd(); + const openads::engine::DataDict::ProcEntry* pe = nullptr; + if (dd != nullptr) { + std::string up = ep.value().name; + for (auto& ch : up) ch = static_cast( + std::toupper(static_cast(ch))); + for (const auto& kv : dd->procs()) { + std::string k = kv.first; + for (auto& ch : k) ch = static_cast( + std::toupper(static_cast(ch))); + if (k == up) { pe = &kv.second; break; } + } + } + if (pe != nullptr && !pe->procedure.empty()) { + std::lock_guard lk2(s.mu); + auto outn = scriptbridge::run_dd_procedure( + c, hStatement, *pe, ep.value().args); + if (!outn) return fail(outn.error()); + if (outn.value().empty()) { // no output params + *phCursor = 0; + return ok(); + } + std::string sel = "SELECT * FROM [" + outn.value() + "]"; + std::vector sb(sel.size() + 1); + std::memcpy(sb.data(), sel.c_str(), sel.size() + 1); + return AdsExecuteSQLDirect(hStatement, sb.data(), phCursor); + } + } + std::lock_guard lk(s.mu); + std::string packed; + for (std::size_t i = 0; i < ep.value().args.size(); ++i) { + if (i != 0) packed.push_back('\x1f'); + packed.append(ep.value().args[i].text); + } + auto out = c->execute_procedure(ep.value().name, packed); + if (!out) return fail(out.error()); + std::string& s_out = out.value(); + + // 1-row temp with one C(255) column "RESULT", via the shared ADT + // temp helper (registers delete-on-close; the DBF this used to + // write leaked). + std::vector rcols; + rcols.push_back({"RESULT", 'C', 255, 0}); + std::vector rrow(255, ' '); + std::memcpy(rrow.data(), s_out.data(), + std::min(s_out.size(), 255)); + return materialise_temp_adt(c, "_call_", rcols, rrow, 255, phCursor); + } + + // S4 -- MERGE, UPSERT form (master_merge.htm): probe the ON condition; + // matched rows take the UPDATE specification with SET expressions + // evaluated through the script engine (the matched row's columns are + // bound as parameters, so `Sequence = Sequence + 1` and IIF(...) work); + // no match desugars to a plain INSERT INTO so defaults / RI / triggers + // ride the normal INSERT path. + if (openads::sql::sql_is_merge(sql)) { + auto mg = openads::sql::parse_merge(sql); + if (!mg) return fail(mg.error()); + auto th = c->open_table(mg.value().table, + stmt_table_type(*it->second), + stmt_open_mode(*it->second, true), + stmt_locking_mode(*it->second)); + if (!th) return fail(th.error()); + openads::engine::Table* tbl = c->lookup_table(th.value()); + if (!tbl) return fail(openads::AE_INTERNAL_ERROR, "post-open"); + sql_dml_hold_write_lock(tbl); + // Keep the structural bag current on every SQL write (see + // DmlProductionIndexGuard above); no-op when no .cdx/.adi + // exists for this table. + DmlProductionIndexGuard dml_idx_guard; + dml_bind_production_index(c, th.value(), dml_idx_guard); + + // Compile the ON tree with the same closure shape the UPDATE + // branch below uses (helper extraction still deferred). + using Pred = std::function; + std::function( + const openads::sql::WhereExpr&)> compile; + compile = [&](const openads::sql::WhereExpr& node) + -> openads::util::Result { + using Kind = openads::sql::WhereExpr::Kind; + if (node.kind == Kind::And || node.kind == Kind::Or) { + std::vector ks; + for (auto& cn : node.children) { + auto r = compile(*cn); + if (!r) return r.error(); + ks.push_back(std::move(r).value()); + } + bool is_and = node.kind == Kind::And; + return Pred{[ks = std::move(ks), is_and] + (openads::engine::Table& t) { + for (auto& k : ks) { + if (k(t) != is_and) return !is_and; + } + return is_and; + }}; + } + if (node.kind == Kind::Not) { + auto inner = compile(*node.child); + if (!inner) return inner.error(); + return Pred{[p = std::move(inner).value()] + (openads::engine::Table& t) { return !p(t); }}; + } + const auto& w = node.cmp; + std::int32_t fidx = tbl->field_index(w.column); + if (fidx < 0) { + return openads::util::Error{ + openads::AE_COLUMN_NOT_FOUND, 0, w.column.c_str(), ""}; + } + std::uint16_t fi = static_cast(fidx); + openads::sql::WhereOp op = w.op; + std::string lit = w.literal; + bool is_num = w.is_numeric; + double num = w.number; + openads::sql::WhereFn lhs_fn = w.lhs_fn; + bool ci_f = field_is_cichar(*tbl, fi); + return Pred{[fi, op, lit, is_num, num, lhs_fn, ci_f] + (openads::engine::Table& t) { + auto v = t.read_field(fi); + if (!v) return false; + if (op == openads::sql::WhereOp::IsNull || + op == openads::sql::WhereOp::IsNotNull) { + bool null_ish = t.is_field_empty(fi); + return op == openads::sql::WhereOp::IsNull + ? null_ish : !null_ish; + } + int cmp = 0; + if (is_num) { + double d = v.value().as_double; + if (d < num) cmp = -1; + else if (d > num) cmp = 1; + } else { + std::string lv = apply_where_fn(v.value().as_string, + lhs_fn); + while (!lv.empty() && lv.back() == ' ') lv.pop_back(); + std::string rv2 = lit; + while (!rv2.empty() && rv2.back() == ' ') + rv2.pop_back(); + cmp = where_str_cmp(lv, rv2, ci_f); + } + switch (op) { + case openads::sql::WhereOp::Eq: return cmp == 0; + case openads::sql::WhereOp::Ne: return cmp != 0; + case openads::sql::WhereOp::Lt: return cmp < 0; + case openads::sql::WhereOp::Gt: return cmp > 0; + case openads::sql::WhereOp::Le: return cmp <= 0; + case openads::sql::WhereOp::Ge: return cmp >= 0; + default: return false; + } + return false; + }}; + }; + auto mpred = compile(*mg.value().on); + if (!mpred) { + c->close_table(th.value()); + return fail(mpred.error()); + } + auto pred = std::move(mpred).value(); + + std::vector matches; + std::uint32_t rcount = tbl->record_count(); + for (std::uint32_t r = 1; r <= rcount; ++r) { + if (auto g = tbl->goto_record(r); !g) continue; + if (!tbl->show_deleted_records() && + tbl->is_deleted()) continue; + if (pred(*tbl)) matches.push_back(r); + } + + // Evaluate one captured expression text through the script engine; + // bind_row = true binds the table's current row columns. + auto eval_expr = [&](const std::string& text, bool bind_row) + -> openads::util::Result { + auto pr = scriptbridge::compiled("RETURN " + text + ";"); + if (!pr) return pr.error(); + scriptbridge::AbiBridge br(c, hStatement); + openads::script::Executor ex2(&br); + ex2.set_user(c->username()); + if (bind_row) scriptbridge::bind_row_params(ex2, *tbl); + auto rr = ex2.run(*pr.value()); + if (!rr) return rr.error(); + if (!rr.value().returned) + return openads::script::Value::null(); + return std::move(rr.value().return_value); + }; + + if (!matches.empty()) { + if (!mg.value().has_update) { // spec: no action on match + c->close_table(th.value()); + *phCursor = 0; + return ok(); + } + struct MAssn { + std::uint16_t fi; + const openads::sql::MergeSet* ms; + }; + std::vector massns; + massns.reserve(mg.value().sets.size()); + for (const auto& msitem : mg.value().sets) { + std::int32_t fidx = tbl->field_index(msitem.column); + if (fidx < 0) { + c->close_table(th.value()); + return fail(openads::AE_COLUMN_NOT_FOUND, + msitem.column.c_str()); + } + massns.push_back( + {static_cast(fidx), &msitem}); + } + // Triggers: same UPDATE sequence as the UPDATE branch below. + std::string mg_alias = ri_alias_for_path(c, tbl->path()); + Handle mg_hConn = + !mg_alias.empty() ? handle_for_conn(c) : Handle{0}; + bool mg_instead = false; + TrigError_ mg_terr; + if (mg_hConn) { + mg_instead = fire_triggers_(mg_hConn, c, mg_alias, 2u, + 2u /*INSTEAD_OF*/, nullptr, + nullptr, &mg_terr); + if (!mg_instead) + fire_triggers_(mg_hConn, c, mg_alias, 2u, + 1u /*BEFORE*/, nullptr, nullptr, + &mg_terr); + if (mg_terr.has_error) { + c->close_table(th.value()); + return fail(static_cast(mg_terr.errno_val), + mg_terr.message.c_str()); + } + } + if (!mg_instead) { + for (std::uint32_t r : matches) { + if (auto g = tbl->goto_record(r); !g) continue; + for (const auto& a : massns) { + auto ev = eval_expr(a.ms->expr_text, true); + if (!ev) { + c->close_table(th.value()); + return fail(ev.error()); + } + const auto& rv = ev.value(); + using ST = openads::script::Type; + openads::util::Result wr = [&]() + -> openads::util::Result { + if (rv.is_null) + return tbl->set_field_null(a.fi); + switch (rv.type) { + case ST::Integer: + return tbl->set_field(a.fi, + static_cast(rv.i)); + case ST::Double: + return tbl->set_field(a.fi, rv.d); + case ST::Logical: + return tbl->set_field(a.fi, + std::string(rv.i ? "T" : "F")); + case ST::Date: { + int y2, m2, d2; + openads::script::ymd_from_jdn( + rv.i, y2, m2, d2); + char db[16]; + std::snprintf(db, sizeof(db), + "%04d%02d%02d", y2, m2, d2); + return tbl->set_field(a.fi, + std::string(db)); + } + case ST::Timestamp: { + std::int64_t jdn = rv.i / 86400000; + std::int64_t ms2 = rv.i % 86400000; + int y2, m2, d2; + openads::script::ymd_from_jdn( + jdn, y2, m2, d2); + char db[24]; + std::snprintf(db, sizeof(db), + "%04d%02d%02d%02d%02d%02d", + y2, m2, d2, + static_cast(ms2 / 3600000), + static_cast((ms2 / 60000) % + 60), + static_cast((ms2 / 1000) % + 60)); + return tbl->set_field(a.fi, + std::string(db)); + } + default: + return tbl->set_field(a.fi, rv.s); + } + }(); + if (!wr) { + c->close_table(th.value()); + return fail(wr.error()); + } + } + } + if (auto fl = tbl->flush(); !fl) { + c->close_table(th.value()); + return fail(fl.error()); + } + } + if (!mg_instead && mg_hConn) { + fire_triggers_(mg_hConn, c, mg_alias, 2u, 4u /*AFTER*/, + tbl, nullptr, &mg_terr); + if (mg_terr.has_error) { + c->close_table(th.value()); + return fail(static_cast(mg_terr.errno_val), + mg_terr.message.c_str()); + } + } + c->close_table(th.value()); + *phCursor = 0; + return ok(); + } + + // No match -- INSERT specification (if present). Values are + // evaluated (no row context) and rendered as literals into a + // plain INSERT INTO statement. + if (!mg.value().has_insert) { + c->close_table(th.value()); + *phCursor = 0; + return ok(); + } + std::vector lits; + lits.reserve(mg.value().insert_value_texts.size()); + for (const auto& vt : mg.value().insert_value_texts) { + auto ev = eval_expr(vt, false); + if (!ev) { + c->close_table(th.value()); + return fail(ev.error()); + } + const auto& rv = ev.value(); + using ST = openads::script::Type; + if (rv.is_null) { + lits.push_back("NULL"); + } else if (rv.type == ST::Integer) { + lits.push_back(std::to_string(rv.i)); + } else if (rv.type == ST::Double) { + char nb2[40]; + std::snprintf(nb2, sizeof(nb2), "%.10g", rv.d); + lits.push_back(nb2); + } else if (rv.type == ST::Logical) { + lits.push_back(rv.i ? "'T'" : "'F'"); + } else { + // Char / Date / Timestamp: quoted display text with '' + // escaping (dates render via to_display; the INSERT + // writer stores text through the field encoder). + std::string s2 = openads::script::to_display(rv); + std::string q = "'"; + for (char ch : s2) { + q.push_back(ch); + if (ch == '\'') q.push_back('\''); + } + q.push_back('\''); + lits.push_back(std::move(q)); + } + } + c->close_table(th.value()); + std::string ins = "INSERT INTO " + mg.value().table; + if (!mg.value().insert_columns.empty()) { + ins += " ("; + for (std::size_t i2 = 0; i2 < mg.value().insert_columns.size(); + ++i2) { + if (i2) ins += ", "; + ins += mg.value().insert_columns[i2]; + } + ins += ")"; + } + ins += " VALUES ("; + for (std::size_t i2 = 0; i2 < lits.size(); ++i2) { + if (i2) ins += ", "; + ins += lits[i2]; + } + ins += ")"; + std::vector ibuf(ins.size() + 1); + std::memcpy(ibuf.data(), ins.c_str(), ins.size() + 1); + ADSHANDLE hc2 = 0; + UNSIGNED32 rc2 = AdsExecuteSQLDirect(hStatement, ibuf.data(), &hc2); + if (rc2 != 0) return rc2; + if (hc2 != 0) AdsCloseTable(hc2); + *phCursor = 0; + return ok(); + } + + if (openads::sql::sql_is_update(sql)) { + auto upd = openads::sql::parse_update(sql); + if (!upd) return fail(upd.error()); + auto th = c->open_table(upd.value().table, + stmt_table_type(*it->second), + stmt_open_mode(*it->second, true), + stmt_locking_mode(*it->second)); + if (!th) return fail(th.error()); + openads::engine::Table* tbl = c->lookup_table(th.value()); + if (!tbl) return fail(openads::AE_INTERNAL_ERROR, "post-open"); + sql_dml_hold_write_lock(tbl); + // Keep the structural bag current on every SQL write (see + // DmlProductionIndexGuard above); no-op when no .cdx/.adi + // exists for this table. + DmlProductionIndexGuard dml_idx_guard; + dml_bind_production_index(c, th.value(), dml_idx_guard); + // Pre-resolve assignments so a typo surfaces before any write. + struct Assn { + std::uint16_t field_index; + openads::sql::InsertLiteral value; + }; + std::vector assns; + assns.reserve(upd.value().assignments.size()); + for (const auto& a : upd.value().assignments) { + std::int32_t fidx = tbl->field_index(a.column); + if (fidx < 0) { + return fail(openads::AE_COLUMN_NOT_FOUND, a.column.c_str()); + } + assns.push_back({static_cast(fidx), a.value}); + } + // S4 -- SET = NULL on a DD non-nullable column fails 5147 + // before any write (SAP: "cannot be updated to a NULL value due + // to a 'not NULL' constraint"). + { + auto upd_rules = dd_field_rules_for(c, upd.value().table); + for (const auto& a : upd.value().assignments) { + if (!a.value.is_null || upd_rules.empty()) continue; + std::string key = a.column; + for (auto& ch : key) + ch = static_cast(std::tolower( + static_cast(ch))); + auto rit = upd_rules.find(key); + if (rit != upd_rules.end() && rit->second.required) { + std::string msg = + "constraint violation while updating column \"" + + a.column + "\": the column cannot be updated to a " + "NULL value due to a 'not NULL' constraint"; + return fail(openads::AE_COLUMN_CANNOT_BE_NULL, + msg.c_str()); + } + } + } + // Walk every live record, run optional WHERE via the same + // engine filter machinery, and apply the assignments inline. + if (upd.value().where) { + // Leverage the same compile path as SELECT -- but inline + // a smaller version that walks the AST recursively. Reuse + // is fine: same structure, no SQL features missing. + // (Helper extraction is deferred until UPDATE picks up + // CONTAINS or AND/OR -- for now the closures below fully + // cover the tree.) + using Pred = std::function; + std::function( + const openads::sql::WhereExpr&)> compile; + compile = [&](const openads::sql::WhereExpr& node) + -> openads::util::Result { + using Kind = openads::sql::WhereExpr::Kind; + if (node.kind == Kind::And) { + std::vector ks; + for (auto& cn : node.children) { + auto r = compile(*cn); + if (!r) return r.error(); + ks.push_back(std::move(r).value()); + } + return Pred{[ks = std::move(ks)](openads::engine::Table& t) { + for (auto& k : ks) if (!k(t)) return false; + return true; + }}; + } + if (node.kind == Kind::Or) { + std::vector ks; + for (auto& cn : node.children) { + auto r = compile(*cn); + if (!r) return r.error(); + ks.push_back(std::move(r).value()); + } + return Pred{[ks = std::move(ks)](openads::engine::Table& t) { + for (auto& k : ks) if (k(t)) return true; + return false; + }}; + } + if (node.kind == Kind::Not) { + auto inner = compile(*node.child); + if (!inner) return inner.error(); + return Pred{[p = std::move(inner).value()] + (openads::engine::Table& t){return !p(t);}}; + } + const auto& w = node.cmp; + std::int32_t fidx = tbl->field_index(w.column); + if (fidx < 0) { + return openads::util::Error{ + openads::AE_COLUMN_NOT_FOUND, 0, + w.column.c_str(), ""}; + } + std::uint16_t fi = static_cast(fidx); + openads::sql::WhereOp op = w.op; + std::string lit = w.literal; + bool is_num = w.is_numeric; + double num = w.number; + openads::sql::WhereFn lhs_fn = w.lhs_fn; + bool ci_f = field_is_cichar(*tbl, fi); + return Pred{[fi, op, lit, is_num, num, lhs_fn, ci_f] + (openads::engine::Table& t) { + auto v = t.read_field(fi); + if (!v) return false; + if (op == openads::sql::WhereOp::IsNull || + op == openads::sql::WhereOp::IsNotNull) { + bool null_ish = t.is_field_empty(fi); + return op == openads::sql::WhereOp::IsNull + ? null_ish : !null_ish; + } + int cmp = 0; + if (is_num) { + double d = v.value().as_double; + if (d < num) cmp = -1; + else if (d > num) cmp = 1; + } else { + cmp = where_str_cmp( + apply_where_fn(v.value().as_string, lhs_fn), + lit, ci_f); + } + switch (op) { + case openads::sql::WhereOp::Eq: return cmp == 0; + case openads::sql::WhereOp::Ne: return cmp != 0; + case openads::sql::WhereOp::Lt: return cmp < 0; + case openads::sql::WhereOp::Gt: return cmp > 0; + case openads::sql::WhereOp::Le: return cmp <= 0; + case openads::sql::WhereOp::Ge: return cmp >= 0; + case openads::sql::WhereOp::Contains: return false; + default: return false; + } + return false; + }}; + }; + auto compiled = compile(*upd.value().where); + if (!compiled) return fail(compiled.error()); + tbl->set_filter(std::move(compiled).value()); + } + // Triggers are ROW-LEVEL (SAP semantics): each matching row fires + // INSTEAD OF / BEFORE with its own __old (current values) and + // __new (values after the SET list) images; the engine write is + // applied only when no INSTEAD OF ran; AFTER fires with the same + // images after the write. Failures propagate (probe Q6): BEFORE/ + // INSTEAD OF failure blocks the write, AFTER failure keeps it. + std::string upd_alias = ri_alias_for_path(c, tbl->path()); + Handle upd_hConn = !upd_alias.empty() ? handle_for_conn(c) : Handle{0}; + TrigError_ upd_terr; + { + std::uint32_t rcount = tbl->record_count(); + for (std::uint32_t r = 1; r <= rcount; ++r) { + if (auto g = tbl->goto_record(r); !g) continue; + if (!tbl->show_deleted_records() && + tbl->is_deleted()) continue; + if (!tbl->passes_filter()) continue; + + // Evaluate the SET list once for this row (expressions + // bind the row's CURRENT -- pre-write -- column values). + std::vector assn_vals; + assn_vals.reserve(assns.size()); + for (const auto& a : assns) { + if (a.value.is_expr) { + auto pr = scriptbridge::compiled( + "RETURN " + a.value.text + ";"); + if (!pr) return fail(pr.error()); + scriptbridge::AbiBridge br(c, hStatement); + openads::script::Executor ex2(&br); + ex2.set_user(c->username()); + scriptbridge::bind_row_params(ex2, *tbl); + auto rr = ex2.run(*pr.value()); + if (!rr) return fail(rr.error()); + assn_vals.push_back( + rr.value().returned + ? std::move(rr.value().return_value) + : openads::script::Value::null()); + } else if (a.value.is_null) { + assn_vals.push_back(openads::script::Value::null()); + } else if (a.value.is_numeric) { + assn_vals.push_back( + openads::script::Value::real(a.value.number)); + } else { + assn_vals.push_back( + openads::script::Value::character(a.value.text)); + } + } + + // Row images for the firings. + TrigFieldMap_ old_img, new_img; + bool row_instead = false; + if (upd_hConn) { + trig_collect_row_(tbl, old_img); + new_img = old_img; + for (std::size_t i2 = 0; i2 < assns.size(); ++i2) { + const auto& fd2 = + tbl->field_descriptor(assns[i2].field_index); + std::string nm2 = fd2.name; + for (auto& ch : nm2) + ch = static_cast(std::tolower( + static_cast(ch))); + std::string txt = + assn_vals[i2].is_null + ? std::string() + : openads::script::to_display(assn_vals[i2]); + new_img[nm2] = TrigField_{std::move(txt), + trig_type_char_(fd2.type)}; + } + row_instead = fire_triggers_( + upd_hConn, c, upd_alias, 2u, 2u /*INSTEAD_OF*/, + nullptr, nullptr, &upd_terr, &new_img, &old_img); + if (!row_instead) + fire_triggers_(upd_hConn, c, upd_alias, 2u, + 1u /*BEFORE*/, nullptr, nullptr, + &upd_terr, &new_img, &old_img); + if (upd_terr.has_error) { + tbl->clear_filter(); + c->close_table(th.value()); + return fail(static_cast(upd_terr.errno_val), + upd_terr.message.c_str()); + } + // The trigger body may have repositioned the table + // (its statements re-enter the engine); restore. + if (auto g2 = tbl->goto_record(r); !g2) continue; + } + if (!row_instead) { + for (std::size_t i2 = 0; i2 < assns.size(); ++i2) { + auto wr = scriptbridge::write_script_value( + *tbl, assns[i2].field_index, assn_vals[i2]); + if (!wr) return fail(wr.error()); + } + if (upd_hConn) { + // AFTER runs once the write has landed: settle the + // coalesced dirty record so a failing trigger still + // leaves the updated row on disk (write persists). + if (auto cd = tbl->commit_dirty_record(); !cd) { + tbl->clear_filter(); + c->close_table(th.value()); + return fail(cd.error()); + } + fire_triggers_(upd_hConn, c, upd_alias, 2u, + 4u /*AFTER*/, tbl, nullptr, + &upd_terr, &new_img, &old_img); + if (upd_terr.has_error) { + tbl->clear_filter(); + c->close_table(th.value()); + return fail( + static_cast(upd_terr.errno_val), + upd_terr.message.c_str()); + } + if (auto g2 = tbl->goto_record(r); !g2) continue; + } + } + } + if (auto fl = tbl->flush(); !fl) return fail(fl.error()); + } + tbl->clear_filter(); + c->close_table(th.value()); + *phCursor = 0; + return ok(); + } + + if (openads::sql::sql_is_delete(sql)) { + auto del = openads::sql::parse_delete(sql); + if (!del) return fail(del.error()); + auto th = c->open_table(del.value().table, + stmt_table_type(*it->second), + stmt_open_mode(*it->second, true), + stmt_locking_mode(*it->second)); + if (!th) return fail(th.error()); + openads::engine::Table* tbl = c->lookup_table(th.value()); + if (!tbl) return fail(openads::AE_INTERNAL_ERROR, "post-open"); + sql_dml_hold_write_lock(tbl); + // Keep the structural bag current on every SQL write (see + // DmlProductionIndexGuard above); no-op when no .cdx/.adi + // exists for this table. + DmlProductionIndexGuard dml_idx_guard; + dml_bind_production_index(c, th.value(), dml_idx_guard); + // Reuse the WHERE filter machinery for SELECT: it's already + // wired and the predicate semantics match exactly. + if (del.value().where) { + // The compile lambda from the SELECT branch lives below; + // copy a minimal bool-tree walker inline so DELETE isn't + // forced to call into SELECT's path. + using Pred = std::function; + std::function( + const openads::sql::WhereExpr&)> compile; + compile = [&](const openads::sql::WhereExpr& node) + -> openads::util::Result { + using Kind = openads::sql::WhereExpr::Kind; + if (node.kind == Kind::And) { + std::vector ks; + for (auto& cn : node.children) { + auto r = compile(*cn); + if (!r) return r.error(); + ks.push_back(std::move(r).value()); + } + return Pred{[ks = std::move(ks)](openads::engine::Table& t) { + for (auto& k : ks) if (!k(t)) return false; + return true; + }}; + } + if (node.kind == Kind::Or) { + std::vector ks; + for (auto& cn : node.children) { + auto r = compile(*cn); + if (!r) return r.error(); + ks.push_back(std::move(r).value()); + } + return Pred{[ks = std::move(ks)](openads::engine::Table& t) { + for (auto& k : ks) if (k(t)) return true; + return false; + }}; + } + if (node.kind == Kind::Not) { + auto inner = compile(*node.child); + if (!inner) return inner.error(); + return Pred{[p = std::move(inner).value()] + (openads::engine::Table& t){return !p(t);}}; + } + const auto& w = node.cmp; + std::int32_t fidx = tbl->field_index(w.column); + if (fidx < 0) { + return openads::util::Error{ + openads::AE_COLUMN_NOT_FOUND, 0, + w.column.c_str(), ""}; + } + std::uint16_t fi = static_cast(fidx); + openads::sql::WhereOp op = w.op; + std::string lit = w.literal; + bool is_num = w.is_numeric; + double num = w.number; + openads::sql::WhereFn lhs_fn = w.lhs_fn; + bool ci_f = field_is_cichar(*tbl, fi); + return Pred{[fi, op, lit, is_num, num, lhs_fn, ci_f] + (openads::engine::Table& t) { + auto v = t.read_field(fi); + if (!v) return false; + if (op == openads::sql::WhereOp::IsNull || + op == openads::sql::WhereOp::IsNotNull) { + bool null_ish = t.is_field_empty(fi); + return op == openads::sql::WhereOp::IsNull + ? null_ish : !null_ish; + } + int cmp = 0; + if (is_num) { + double d = v.value().as_double; + if (d < num) cmp = -1; + else if (d > num) cmp = 1; + } else { + cmp = where_str_cmp( + apply_where_fn(v.value().as_string, lhs_fn), + lit, ci_f); + } + switch (op) { + case openads::sql::WhereOp::Eq: return cmp == 0; + case openads::sql::WhereOp::Ne: return cmp != 0; + case openads::sql::WhereOp::Lt: return cmp < 0; + case openads::sql::WhereOp::Gt: return cmp > 0; + case openads::sql::WhereOp::Le: return cmp <= 0; + case openads::sql::WhereOp::Ge: return cmp >= 0; + case openads::sql::WhereOp::Contains: return false; + default: return false; + } + return false; + }}; + }; + auto compiled = compile(*del.value().where); + if (!compiled) return fail(compiled.error()); + tbl->set_filter(std::move(compiled).value()); + } + // Triggers are ROW-LEVEL (SAP semantics): each matching row fires + // INSTEAD OF / BEFORE / AFTER with that row's __old image (the + // values being deleted -- pmsys's Delete AuditLog is AFTER DELETE + // and reads `SELECT propertyid FROM __old`). INSTEAD OF + // supersedes the engine's own delete. Failures propagate + // (probe Q6 semantics). + std::string del_alias = ri_alias_for_path(c, tbl->path()); + Handle del_hConn = !del_alias.empty() ? handle_for_conn(c) : Handle{0}; + TrigError_ del_terr; + { + std::uint32_t rcount = tbl->record_count(); + for (std::uint32_t r = 1; r <= rcount; ++r) { + if (auto g = tbl->goto_record(r); !g) continue; + if (!tbl->show_deleted_records() && + tbl->is_deleted()) continue; + if (!tbl->passes_filter()) continue; + TrigFieldMap_ old_img; + bool row_instead = false; + if (del_hConn) { + trig_collect_row_(tbl, old_img); + row_instead = fire_triggers_( + del_hConn, c, del_alias, 3u, 2u /*INSTEAD_OF*/, + nullptr, nullptr, &del_terr, nullptr, &old_img); + if (!row_instead) + fire_triggers_(del_hConn, c, del_alias, 3u, + 1u /*BEFORE*/, nullptr, nullptr, + &del_terr, nullptr, &old_img); + if (del_terr.has_error) { + tbl->clear_filter(); + c->close_table(th.value()); + return fail(static_cast(del_terr.errno_val), + del_terr.message.c_str()); + } + // Trigger bodies may reposition the table; restore. + if (auto g2 = tbl->goto_record(r); !g2) continue; + } + if (!row_instead) { + (void)tbl->mark_deleted(); + if (del_hConn) { + fire_triggers_(del_hConn, c, del_alias, 3u, + 4u /*AFTER*/, nullptr, nullptr, + &del_terr, nullptr, &old_img); + if (del_terr.has_error) { + tbl->clear_filter(); + c->close_table(th.value()); + return fail( + static_cast(del_terr.errno_val), + del_terr.message.c_str()); + } + if (auto g2 = tbl->goto_record(r); !g2) continue; + } + } + } + if (auto fl = tbl->flush(); !fl) return fail(fl.error()); + } + tbl->clear_filter(); + c->close_table(th.value()); + *phCursor = 0; + return ok(); + } + + if (openads::sql::sql_is_insert(sql)) { + auto ins = openads::sql::parse_insert(sql); + if (!ins) return fail(ins.error()); + auto th = c->open_table(ins.value().table, + stmt_table_type(*it->second), + stmt_open_mode(*it->second, true), + stmt_locking_mode(*it->second)); + if (!th) return fail(th.error()); + openads::engine::Table* tbl = c->lookup_table(th.value()); + if (!tbl) return fail(openads::AE_INTERNAL_ERROR, "post-open"); + sql_dml_hold_write_lock(tbl); + // Keep the structural bag current on every SQL write (see + // DmlProductionIndexGuard above); no-op when no .cdx/.adi + // exists for this table. + DmlProductionIndexGuard dml_idx_guard; + dml_bind_production_index(c, th.value(), dml_idx_guard); + + // `INSERT INTO t VALUES (...)` without a column list: SAP binds + // the values positionally in declared-column order (probed). + if (ins.value().columns.empty()) { + const std::uint16_t inf = tbl->field_count(); + ins.value().columns.reserve(inf); + for (std::uint16_t i = 0; i < inf; ++i) + ins.value().columns.push_back(tbl->field_descriptor(i).name); + } + // SAP raises 2129 when the counts differ in either direction - + // exact message probed against ace64.dll. This also guards the + // write loop, which indexes vals[i] by column position. + auto ins_count_ok = + [&](const std::vector& vals) { + return vals.size() == ins.value().columns.size(); + }; + static constexpr const char* k2129 = + "Unequal number of insert columns and insert values or the " + "incorrect number of stored procedure input parameters"; + + // S4 -- DD field constraints (oracle-verified): SAP rejects an + // INSERT that leaves a non-nullable column without a value with + // 5147, checking fields in ordinal order; a missing column with + // a DD default takes the default instead (an EXPLICIT NULL does + // not -- it violates). Applies to the VALUES paths; the + // INSERT...SELECT path doesn't enforce yet. + auto ins_rules = dd_field_rules_for(c, ins.value().table); + auto ins_lower = [](std::string s2) { + for (auto& ch : s2) + ch = static_cast(std::tolower( + static_cast(ch))); + return s2; + }; + auto ins_col_pos = [&](const std::string& fname) -> std::int32_t { + for (std::size_t j = 0; j < ins.value().columns.size(); ++j) { + if (ins_lower(ins.value().columns[j]) == ins_lower(fname)) + return static_cast(j); + } + return -1; + }; + auto null_violation = + [&](const std::vector& vals) + -> std::string { + if (ins_rules.empty()) return {}; + std::uint16_t nf = tbl->field_count(); + for (std::uint16_t i = 0; i < nf; ++i) { + const auto& fd = tbl->field_descriptor(i); + auto rit = ins_rules.find(ins_lower(fd.name)); + if (rit == ins_rules.end() || !rit->second.required) + continue; + std::int32_t cp = ins_col_pos(fd.name); + if (cp >= 0 && static_cast(cp) < vals.size() && + !vals[static_cast(cp)].is_null) + continue; // value supplied + if (cp < 0 && !rit->second.def.empty()) + continue; // default fills it + return fd.name; + } + return {}; + }; + + // M10.41 -- INSERT INTO t (cols) SELECT ...: recursively + // execute the inner SELECT, walk its cursor, append one + // target row per source row mapping the inner cursor's + // projected columns to `ins.columns` positionally. + if (!ins.value().select_sql.empty()) { + std::vector selbuf(ins.value().select_sql.size() + 1); + std::memcpy(selbuf.data(), + ins.value().select_sql.c_str(), + ins.value().select_sql.size() + 1); + ADSHANDLE srcCur = 0; + UNSIGNED32 rrc = + AdsExecuteSQLDirect(hStatement, selbuf.data(), &srcCur); + if (rrc != 0) { + c->close_table(th.value()); + return rrc; + } + auto& s2 = state(); + std::lock_guard lk2(s2.mu); + openads::engine::Table* src = + s2.registry.lookup( + srcCur, HandleKind::Table); + if (!src) { + c->close_table(th.value()); + return fail(openads::AE_INTERNAL_ERROR, + "INSERT...SELECT inner cursor lookup"); + } + // Resolve inner cursor's projected column indices. + const auto* proj = projection_for(srcCur); + std::vector src_cols; + if (proj) { + src_cols = *proj; + } else { + std::uint16_t nf = src->field_count(); + src_cols.reserve(nf); + for (std::uint16_t k = 0; k < nf; ++k) src_cols.push_back(k); + } + if (src_cols.size() != ins.value().columns.size()) { + AdsCloseTable(srcCur); + c->close_table(th.value()); + return fail(openads::AE_PARSE_ERROR, + "INSERT INTO ... SELECT: column count mismatch"); + } + // Pre-resolve target column indices. + std::vector tgt_cols; + tgt_cols.reserve(ins.value().columns.size()); + for (const auto& cn : ins.value().columns) { + std::int32_t fi = tbl->field_index(cn); + if (fi < 0) { + AdsCloseTable(srcCur); + c->close_table(th.value()); + return fail(openads::AE_COLUMN_NOT_FOUND, cn.c_str()); + } + tgt_cols.push_back(static_cast(fi)); + } + // Walk source rows. + std::vector recnos; + if (src->has_recno_sequence()) { + recnos = src->recno_sequence(); + } else { + std::uint32_t rcount = src->record_count(); + for (std::uint32_t r = 1; r <= rcount; ++r) { + if (auto g = src->goto_record(r); !g) continue; + if (!src->show_deleted_records() && + src->is_deleted()) continue; + if (!src->passes_filter()) continue; + recnos.push_back(r); + } + } + for (std::uint32_t r : recnos) { + if (auto g = src->goto_record(r); !g) continue; + if (auto ar = tbl->append_record(); !ar) { + AdsCloseTable(srcCur); + c->close_table(th.value()); + return fail(ar.error()); + } + for (std::size_t i = 0; i < src_cols.size(); ++i) { + auto v = src->read_field(src_cols[i]); + std::string sv = v ? v.value().as_string : std::string(); + auto wr = tbl->set_field(tgt_cols[i], sv); + if (!wr) { + AdsCloseTable(srcCur); + c->close_table(th.value()); + return fail(wr.error()); + } + } + } + if (auto fl = tbl->flush(); !fl) { + AdsCloseTable(srcCur); + c->close_table(th.value()); + return fail(fl.error()); + } + // Fire AFTER INSERT triggers (event_mask=1) for INSERT...SELECT. + // RCB 07/17/2026 (S2): failures propagate (probe Q6). + { + std::string alias = ri_alias_for_path(c, tbl->path()); + if (!alias.empty()) { + Handle hConn = handle_for_conn(c); + if (hConn) { + TrigError_ terr; + fire_triggers_(hConn, c, alias, 1u, 4u /*AFTER*/, + tbl, nullptr, &terr); + if (terr.has_error) { + AdsCloseTable(srcCur); + c->close_table(th.value()); + return fail(static_cast(terr.errno_val), + terr.message.c_str()); + } + } + } + } + AdsCloseTable(srcCur); + c->close_table(th.value()); + *phCursor = 0; + return ok(); + } + + // M10.52 -- multi-row VALUES path. When `rows` is non-empty, + // append + populate one record per tuple; otherwise fall + // back to the single-row `values` path. + auto write_one = [&](const std::vector& + vals) -> openads::util::Result + { + if (!ins_count_ok(vals)) { + return openads::util::Error{2129, 0, k2129, ""}; + } + if (auto r = tbl->append_record(); !r) return r.error(); + for (std::size_t i = 0; i < ins.value().columns.size(); ++i) { + std::int32_t fidx = + tbl->field_index(ins.value().columns[i]); + if (fidx < 0) { + return openads::util::Error{ + openads::AE_COLUMN_NOT_FOUND, 0, + ins.value().columns[i].c_str(), ""}; + } + const auto& v = vals[i]; + if (v.is_expr) { + // S4 -- expression value (User(), Now(), …) evaluated + // through the script engine. + auto pr = scriptbridge::compiled( + "RETURN " + v.text + ";"); + if (!pr) return pr.error(); + scriptbridge::AbiBridge br(c, hStatement); + openads::script::Executor ex2(&br); + ex2.set_user(c->username()); + auto rr = ex2.run(*pr.value()); + if (!rr) return rr.error(); + auto wr = scriptbridge::write_script_value( + *tbl, static_cast(fidx), + rr.value().returned ? rr.value().return_value + : openads::script::Value::null()); + if (!wr) return wr.error(); + } else if (v.is_null) { + auto wr = tbl->set_field_null( + static_cast(fidx)); + if (!wr) return wr.error(); + } else if (v.is_numeric) { + auto wr = tbl->set_field( + static_cast(fidx), v.number); + if (!wr) return wr.error(); + } else { + auto wr = tbl->set_field( + static_cast(fidx), v.text); + if (!wr) return wr.error(); + } + } + // Apply DD default values to columns the statement did not + // name (evaluated through the script engine: FALSE, now(), + // …; unparseable text falls back to a raw write). + for (std::uint16_t i = 0; + !ins_rules.empty() && i < tbl->field_count(); ++i) { + const auto& fd = tbl->field_descriptor(i); + auto rit = ins_rules.find(ins_lower(fd.name)); + if (rit == ins_rules.end() || rit->second.def.empty()) + continue; + if (ins_col_pos(fd.name) >= 0) continue; // caller set it + bool wrote = false; + auto pr = scriptbridge::compiled( + "RETURN " + rit->second.def + ";"); + if (pr) { + scriptbridge::AbiBridge br(c, hStatement); + openads::script::Executor ex2(&br); + ex2.set_user(c->username()); + auto rr = ex2.run(*pr.value()); + if (rr && rr.value().returned) { + auto wr = scriptbridge::write_script_value( + *tbl, i, rr.value().return_value); + if (!wr) return wr.error(); + wrote = true; + } + } + if (!wrote) { + auto wr = tbl->set_field(i, rit->second.def); + if (!wr) return wr.error(); + } + } + return std::monostate{}; + }; + // S4 -- NOT NULL pre-check BEFORE any trigger or write: SAP's 5147 + // constraint violation leaves no row behind and fires nothing. + { + std::string viol; + if (!ins.value().rows.empty()) { + for (auto& row : ins.value().rows) { + viol = null_violation(row); + if (!viol.empty()) break; + } + } else if (ins.value().select_sql.empty()) { + viol = null_violation(ins.value().values); + } + if (!viol.empty()) { + c->close_table(th.value()); + std::string msg = + "constraint violation while updating column \"" + viol + + "\": the column cannot be set to a NULL value due to a " + "'not NULL' constraint"; + return fail(openads::AE_COLUMN_CANNOT_BE_NULL, msg.c_str()); + } + } + // Trigger: INSTEAD OF INSERT supersedes the actual insert; BEFORE fires first. + // RCB 07/17/2026 (S2): failures propagate (probe Q6): BEFORE/ + // INSTEAD OF failure blocks the write, AFTER failure keeps it. + std::string ins_alias = ri_alias_for_path(c, tbl->path()); + Handle ins_hConn = !ins_alias.empty() ? handle_for_conn(c) : Handle{0}; + bool ins_instead_of = false; + TrigError_ ins_terr; + // S4 -- pre-write __new image from the statement VALUES (first + // row): INSERT BEFORE / INSTEAD OF triggers fire before any table + // row exists, so the image must come from the statement. pmsys's + // Insert AuditLog is an INSTEAD OF trigger that reads + // `SELECT propertyid FROM __new` and re-inserts the row itself. + TrigFieldMap_ ins_new_img; + if (ins_hConn) { + const std::vector* vals0 = nullptr; + if (!ins.value().rows.empty()) vals0 = &ins.value().rows.front(); + else if (ins.value().select_sql.empty()) + vals0 = &ins.value().values; + std::uint16_t nf2 = tbl->field_count(); + for (std::uint16_t i2 = 0; i2 < nf2; ++i2) { + const auto& fd2 = tbl->field_descriptor(i2); + std::string nm2 = fd2.name; + for (auto& ch : nm2) + ch = static_cast(std::tolower( + static_cast(ch))); + char tc2 = trig_type_char_(fd2.type); + std::string txt; + if (vals0 != nullptr) { + std::int32_t cp2 = ins_col_pos(fd2.name); + if (cp2 >= 0 && + static_cast(cp2) < vals0->size()) { + const auto& v2 = + (*vals0)[static_cast(cp2)]; + if (v2.is_null) { + txt.clear(); + } else if (v2.is_expr) { + // Expression value: evaluate for the image so + // the trigger sees the actual incoming value. + auto pr2 = scriptbridge::compiled( + "RETURN " + v2.text + ";"); + if (pr2) { + scriptbridge::AbiBridge br2(c, hStatement); + openads::script::Executor ex3(&br2); + ex3.set_user(c->username()); + auto rr2 = ex3.run(*pr2.value()); + if (rr2 && rr2.value().returned) + txt = openads::script::to_display( + rr2.value().return_value); + } + } else if (v2.is_numeric) { + char nb2[40]; + std::snprintf(nb2, sizeof(nb2), "%.10g", + v2.number); + txt = nb2; + } else { + txt = v2.text; + } + } + } + ins_new_img[nm2] = TrigField_{std::move(txt), tc2}; + } + } + if (ins_hConn) { + ins_instead_of = fire_triggers_(ins_hConn, c, ins_alias, 1u, + 2u /*INSTEAD_OF*/, nullptr, + nullptr, &ins_terr, + &ins_new_img, nullptr); + if (!ins_instead_of) + fire_triggers_(ins_hConn, c, ins_alias, 1u, 1u /*BEFORE*/, + nullptr, nullptr, &ins_terr, + &ins_new_img, nullptr); + if (ins_terr.has_error) { + c->close_table(th.value()); + return fail(static_cast(ins_terr.errno_val), ins_terr.message.c_str()); + } + } + if (!ins_instead_of) { + if (!ins.value().rows.empty()) { + for (auto& row : ins.value().rows) { + auto r = write_one(row); + if (!r) return fail(r.error()); + } + } else { + auto r = write_one(ins.value().values); + if (!r) return fail(r.error()); + } + if (auto fl = tbl->flush(); !fl) return fail(fl.error()); + } + // Fire AFTER INSERT triggers (only when no INSTEAD OF ran). + // Pass tbl so __new fields are available for the body. + if (!ins_instead_of && ins_hConn) { + fire_triggers_(ins_hConn, c, ins_alias, 1u, 4u /*AFTER*/, tbl, + nullptr, &ins_terr); + if (ins_terr.has_error) { + c->close_table(th.value()); + return fail(static_cast(ins_terr.errno_val), ins_terr.message.c_str()); + } + } + c->close_table(th.value()); + *phCursor = 0; + return ok(); + } + + // M10.26 -- top-level `UNION [ALL]` between SELECTs. Each member + // must currently be a `SELECT * FROM [WHERE ...]` form (no + // joins, aggregates, projection lists, GROUP BY, or ORDER BY + // inside members -- those compose with UNION in a follow-up). + // First member's schema is reused for the merged cursor. + { + struct UnionPart { std::string sql_text; bool all = false; }; + std::vector uparts; + { + std::size_t start = 0; + int depth = 0; + bool in_q = false; + bool prev_all = false; + auto kw_at = [&](std::size_t i, const char* kw) { + std::size_t L = std::strlen(kw); + if (i + L > sql.size()) return false; + for (std::size_t k = 0; k < L; ++k) + if (std::toupper(static_cast(sql[i+k])) != + kw[k]) return false; + bool lb = (i == 0) || + (!std::isalnum(static_cast(sql[i-1])) && + sql[i-1] != '_'); + bool rb = (i + L == sql.size()) || + (!std::isalnum(static_cast(sql[i+L])) && + sql[i+L] != '_'); + return lb && rb; + }; + for (std::size_t i = 0; i < sql.size(); ) { + char ch = sql[i]; + if (in_q) { + if (ch == '\'') in_q = false; + ++i; continue; + } + if (ch == '\'') { in_q = true; ++i; continue; } + if (ch == '(') { ++depth; ++i; continue; } + if (ch == ')') { --depth; ++i; continue; } + if (depth == 0 && kw_at(i, "UNION")) { + uparts.push_back({sql.substr(start, i - start), prev_all}); + i += 5; + while (i < sql.size() && + std::isspace(static_cast(sql[i]))) ++i; + prev_all = false; + if (kw_at(i, "ALL")) { + prev_all = true; + i += 3; + while (i < sql.size() && + std::isspace(static_cast(sql[i]))) ++i; + } + start = i; + continue; + } + ++i; + } + if (start < sql.size()) { + uparts.push_back({sql.substr(start), prev_all}); + } + } + + if (uparts.size() > 1) { + auto& s = state(); + std::lock_guard lk(s.mu); + + // M10.36 -- every UNION member runs through the full + // SELECT-execute pipeline as a recursive call to + // AdsExecuteSQLDirect (allowed by the recursive_mutex on + // s.mu). Members may now carry JOIN, GROUP BY, aggregates, + // CASE WHEN, DISTINCT, LIMIT -- anything a plain SELECT + // accepts. The first member's cursor schema (whatever the + // pipeline produces -- temp DBF for joins/aggregates, + // source schema for SELECT *) drives the merged schema; + // later members align by column name against it. + // + // Last member's ORDER BY still becomes the merged sort + // (M10.28 semantics). We capture it from a parse, then + // let the recursive call run as-is -- its sort is + // overwritten by the final post-merge stable_sort below. + std::optional final_order; + { + auto p = openads::sql::parse_select(uparts.back().sql_text); + if (p && p.value().order_by) { + final_order = *p.value().order_by; + } + } + + // The first member's `all` flag is meaningless (it has no + // UNION keyword preceding it); only the join-flags between + // members decide whether dedup applies. + bool any_distinct = false; + for (std::size_t i = 1; i < uparts.size(); ++i) + if (!uparts[i].all) any_distinct = true; + std::unordered_set seen; + + std::vector schema; + std::uint32_t rec_len = 0; + std::vector> rows; + + for (std::size_t mi = 0; mi < uparts.size(); ++mi) { + // Recurse into the full SELECT executor for this + // member. The member SQL goes through every dispatch + // (UNION, JOIN, GROUP BY, aggregate, CASE, ...) and + // lands as a registered cursor handle we can walk. + std::vector mbuf(uparts[mi].sql_text.size() + 1); + std::memcpy(mbuf.data(), uparts[mi].sql_text.c_str(), + uparts[mi].sql_text.size() + 1); + ADSHANDLE memberCur = 0; + UNSIGNED32 rrc = + AdsExecuteSQLDirect(hStatement, mbuf.data(), &memberCur); + if (rrc != 0) return rrc; + openads::engine::Table* mt = + s.registry.lookup( + memberCur, HandleKind::Table); + if (!mt) { + return fail(openads::AE_INTERNAL_ERROR, + "UNION member cursor lookup failed"); + } + + if (mi == 0) { + auto* proj = projection_for(memberCur); + if (proj) { + schema.reserve(proj->size()); + for (auto idx : *proj) { + schema.push_back(mt->field_descriptor(idx)); + } + } else { + std::uint16_t nf = mt->field_count(); + schema.reserve(nf); + for (std::uint16_t k = 0; k < nf; ++k) { + schema.push_back(mt->field_descriptor(k)); + } + } + rec_len = 0; + for (auto& fd : schema) rec_len += fd.length; + } + + std::vector col_src(schema.size(), -1); + auto* proj_m = projection_for(memberCur); + if (proj_m) { + if (proj_m->size() != schema.size()) { + AdsCloseTable(memberCur); + return fail(openads::AE_PARSE_ERROR, + "UNION member projection column count differs"); + } + for (std::size_t i = 0; i < schema.size(); ++i) { + col_src[i] = static_cast((*proj_m)[i]); + } + } else { + for (std::size_t i = 0; i < schema.size(); ++i) { + col_src[i] = mt->field_index(schema[i].name); + } + } + + std::vector recnos; + if (mt->has_recno_sequence()) { + recnos = mt->recno_sequence(); + } else { + std::uint32_t rc = mt->record_count(); + for (std::uint32_t r = 1; r <= rc; ++r) { + if (auto g = mt->goto_record(r); !g) continue; + if (!mt->show_deleted_records() && + mt->is_deleted()) continue; + if (!mt->passes_filter()) continue; + recnos.push_back(r); + } + } + for (std::uint32_t r : recnos) { + if (auto g = mt->goto_record(r); !g) continue; + std::vector rec(rec_len); + std::size_t off = 0; + for (std::size_t i = 0; i < schema.size(); ++i) { + std::string sval; + if (col_src[i] >= 0) { + auto v = mt->read_field( + static_cast(col_src[i])); + if (v) sval = v.value().as_string; + } + // uint16 -- an ADT character column can exceed 255 + // bytes; the old uint8 cast mis-tiled the row. + std::uint16_t L = schema[i].length; + for (std::uint16_t k = 0; k < L; ++k) { + rec[off + k] = k < sval.size() + ? static_cast(sval[k]) : ' '; + } + off += L; + } + if (any_distinct) { + std::string key( + reinterpret_cast(rec.data()), + rec.size()); + if (!seen.insert(std::move(key)).second) continue; + } + rows.push_back(std::move(rec)); + } + AdsCloseTable(memberCur); + } + + std::uint16_t nfields = static_cast(schema.size()); + + // Column specs for the shared ADT temp helper + // (docs/materialised-cursor-temps.md). The old DBF emit also + // copied fd.raw_type VERBATIM into the descriptor -- an ADT + // source leaked its numeric type code (1-22) into a DBF byte, + // producing a descriptor no reader could classify; the helper's + // dual-range mapping normalises those. + (void)nfields; + std::vector uspecs; + uspecs.reserve(schema.size()); + for (auto& fd : schema) { + uspecs.push_back({fd.name, fd.raw_type, + fd.length, fd.decimals}); + } + std::vector file; + + // M10.28 -- apply ORDER BY (from last member) to merged rows. + if (final_order) { + std::int32_t fi = -1; + std::uint16_t off = 0; + std::uint16_t flen = 0; + bool numeric = false; + for (std::size_t i = 0; i < schema.size(); ++i) { + if (schema[i].name == final_order->column) { + fi = static_cast(i); + flen = schema[i].length; + numeric = + schema[i].type == openads::drivers::DbfFieldType::Numeric || + schema[i].type == openads::drivers::DbfFieldType::Float || + schema[i].type == openads::drivers::DbfFieldType::Integer || + schema[i].type == openads::drivers::DbfFieldType::Currency|| + schema[i].type == openads::drivers::DbfFieldType::Double; + break; + } + off += schema[i].length; + } + if (fi < 0) { + return fail(openads::AE_COLUMN_NOT_FOUND, + final_order->column.c_str()); + } + bool desc = final_order->descending; + std::stable_sort(rows.begin(), rows.end(), + [&](const std::vector& a, + const std::vector& b) { + std::string ka( + reinterpret_cast(a.data() + off), flen); + std::string kb( + reinterpret_cast(b.data() + off), flen); + bool less; + if (numeric) { + double da = std::strtod(ka.c_str(), nullptr); + double db = std::strtod(kb.c_str(), nullptr); + if (da == db) return false; + less = da < db; + } else { + if (ka == kb) return false; + less = ka < kb; + } + return desc ? !less : less; + }); + } + + // Materialise rows into the row-image buffer. + for (auto& rec : rows) { + file.insert(file.end(), rec.begin(), rec.end()); + } + return materialise_temp_adt(c, "_uni_", uspecs, file, + rec_len, phCursor); + } + } + + auto parsed = openads::sql::parse_select(sql); + if (!parsed) return fail(parsed.error()); + + // S5 -- `FROM `: resolve a DD view by substituting its stored + // SELECT as a derived table. The existing M10.46 machinery then runs + // the view's SQL recursively and applies the outer clauses (WHERE / + // ORDER BY / TOP / aggregates / projection) to its cursor -- exactly + // SAP's composition semantics, oracle-probed on the mp views + // (`SELECT TOP 5 Total, ClaimKey FROM SumByClaim ORDER BY ClaimKey` + // etc.). UNION members re-enter this dispatcher and resolve views for + // free; a view INSIDE a join is still unresolved (TODO.parity.md). + if (parsed.value().derived_sql.empty() && + !parsed.value().inner_join && + parsed.value().from_tables.size() < 3 && + !parsed.value().table.empty() && + c->has_dd()) { + auto* vdd = c->dd(); + if (vdd != nullptr && !vdd->views().empty()) { + auto up = [](std::string v) { + for (auto& ch : v) + ch = static_cast(std::toupper( + static_cast(ch))); + return v; + }; + const std::string want = up(parsed.value().table); + for (const auto& kv : vdd->views()) { + if (up(kv.first) == want && !kv.second.sql.empty()) { + parsed.value().derived_sql = kv.second.sql; + break; + } + } + } + } + + // ==================================================================== + // ADS dialect -- N-way comma join (3+ tables) with composite keys and + // `.*` projection. The single-JoinClause path below handles only + // two tables; this path generalises to the multi-table inventory + // aggregation the application issues (a header/detail join with + // dimension tables). It runs a left-deep equi-join in FROM order and writes the + // projected columns -- named by their UNQUALIFIED column name, the ADS + // result semantics -- into a temp DBF cursor. + // + // NOTE: filters are applied after the join is fully bound (same as the + // two-table path). Pushing single-table predicates (e.g. the date range) + // down per level is a future optimisation; correctness first. + // ==================================================================== + // Three or more tables always come here. Two tables come here only when + // the comma-join lowering declined -- a composite key, which the + // single-pair JoinClause cannot express (see parser.cpp). A two-table + // comma join with one key still takes the simpler path below. + if (parsed.value().from_tables.size() >= 3 || + (parsed.value().from_tables.size() == 2 && + !parsed.value().inner_join.has_value())) { + auto& st = parsed.value(); + // S4 -- aggregates (COUNT/SUM/AVG/MIN/MAX, optional GROUP BY / + // HAVING) are supported by accumulating during the join walk; + // the other complex projection kinds are not. + const bool nway_agg = !st.aggregates.empty(); + if (st.projection_complex && + !(nway_agg && st.case_items.empty() && st.fn_items.empty() && + st.arith_items.empty() && st.window_items.empty())) { + return fail(openads::AE_INTERNAL_ERROR, + "multi-table join: only column, .* and aggregate " + "projections are supported"); + } + auto& s = state(); + std::lock_guard lk(s.mu); + + const std::size_t N = st.from_tables.size(); + auto lc = [](std::string v) { + for (auto& ch : v) + ch = static_cast( + std::tolower(static_cast(ch))); + return v; + }; + auto trim_trailing = [](std::string v) { + while (!v.empty() && v.back() == ' ') v.pop_back(); + return v; + }; + + // --- 1. Open every table; map alias (and bare name) -> index. --- + std::vector handles(N, 0); + std::vector tbls(N, nullptr); + std::unordered_map alias_idx; + std::size_t opened = 0; + bool open_ok = true; + for (std::size_t i = 0; i < N; ++i) { + auto h = open_or_sys(st.from_tables[i].name, + openads::engine::TableType::Cdx, + openads::engine::OpenMode::Read, + openads::engine::LockingMode::Compatible); + if (!h) { open_ok = false; break; } + handles[i] = h.value(); + ++opened; + tbls[i] = c->lookup_table(h.value()); + if (!tbls[i]) { open_ok = false; break; } + const std::string& al = st.from_tables[i].alias; + if (!al.empty()) alias_idx[lc(al)] = i; + // Also index by the table's base name (strip dir + extension) so + // a column may qualify by table name as well as by alias. + std::string base = st.from_tables[i].name; + std::size_t slash = base.find_last_of("\\/"); + if (slash != std::string::npos) base = base.substr(slash + 1); + std::size_t dot = base.find_last_of('.'); + if (dot != std::string::npos) base = base.substr(0, dot); + if (!base.empty()) alias_idx.emplace(lc(base), i); + } + auto close_all = [&]() { + for (std::size_t k = 0; k < opened; ++k) + if (handles[k]) c->close_table(handles[k]); + }; + if (!open_ok) { + close_all(); + return fail(openads::AE_NO_FILE_FOUND, + "multi-table join: table open failed"); + } + + // SAP 2137 -- an unqualified column present in more than one joined + // table is ambiguous. Checked before resolution (which resolves + // left-first and would silently pick one). + { + std::string amb = + scriptbridge::first_ambiguous_join_column(st, tbls); + if (!amb.empty()) { + close_all(); + return fail(2137, + ("Column found in multiple tables: " + amb).c_str()); + } + } + + // --- 2. Resolve a qualified column -> (table idx, field idx). --- + auto resolve = [&](const std::string& alias, const std::string& col, + std::size_t& ti, std::int32_t& fi) -> bool { + if (!alias.empty()) { + auto a = alias_idx.find(lc(alias)); + if (a == alias_idx.end()) return false; + ti = a->second; + fi = tbls[ti]->field_index(col); + return fi >= 0; + } + for (std::size_t i = 0; i < N; ++i) { + std::int32_t f = tbls[i]->field_index(col); + if (f >= 0) { ti = i; fi = f; return true; } + } + return false; + }; + + // --- 3. Walk the top-level AND spine: separate equi-join predicates + // (col = col, enforced by the hash join) from residual filter + // conjuncts. Each residual conjunct is bucketed by the DEEPEST + // table it references, so the join walk can evaluate it the + // moment that table is bound and prune early (filter pushdown). + // Only the AND spine is descended for join keys, so a `col=col` + // nested under OR/NOT is treated as a residual filter, not a + // join key. + struct JEq { std::size_t lti; std::int32_t lfi; + std::size_t rti; std::int32_t rfi; }; + std::vector jeqs; + std::vector> buckets(N); + // Residual conjuncts that reference ONLY one joined table (index >= 1) + // are applied while BUILDING that table's hash, so the hash holds just + // the matching rows (e.g. only the month's detail rows) instead of the + // whole history. Indexed by table. + std::vector> pure(N); + bool spine_ok = true; + // Accumulate the min/max table index a (sub)expression references. + // mx < 0 means it references no column (a folded constant). + std::function + conj_range = [&](const openads::sql::WhereExpr* n, + int& mn, int& mx) { + if (n == nullptr) return; + std::size_t ti; std::int32_t fi; + if (n->kind == openads::sql::WhereExpr::Kind::Cmp) { + const auto& w = n->cmp; + if (resolve(w.column_alias, w.column, ti, fi)) { + mn = std::min(mn, static_cast(ti)); + mx = std::max(mx, static_cast(ti)); + } + if (w.is_outer_ref && + resolve(w.outer_column_alias, w.outer_column, ti, fi)) { + mn = std::min(mn, static_cast(ti)); + mx = std::max(mx, static_cast(ti)); + } + return; + } + if (n->kind == openads::sql::WhereExpr::Kind::In) { + if (resolve(std::string(), n->in_clause.column, ti, fi)) { + mn = std::min(mn, static_cast(ti)); + mx = std::max(mx, static_cast(ti)); + } + return; + } + for (auto& ch : n->children) conj_range(ch.get(), mn, mx); + conj_range(n->child.get(), mn, mx); + }; + std::function spine = + [&](const openads::sql::WhereExpr* n) { + if (n == nullptr || !spine_ok) return; + if (n->kind == openads::sql::WhereExpr::Kind::And) { + for (auto& ch : n->children) spine(ch.get()); + return; + } + if (n->kind == openads::sql::WhereExpr::Kind::Cmp && + n->cmp.op == openads::sql::WhereOp::Eq && + n->cmp.is_outer_ref) { + JEq e{}; + if (!resolve(n->cmp.column_alias, n->cmp.column, + e.lti, e.lfi) || + !resolve(n->cmp.outer_column_alias, n->cmp.outer_column, + e.rti, e.rfi)) { + spine_ok = false; + return; + } + jeqs.push_back(e); + return; + } + int mn = static_cast(N), mx = -1; + conj_range(n, mn, mx); + if (mx < 0) { + buckets[0].push_back(n); // constant -> level 0 + } else if (mn == mx && mn >= 1) { + pure[static_cast(mn)].push_back(n); // hash-build + } else { + buckets[static_cast(mx)].push_back(n); // walk + } + }; + spine(st.where.get()); + if (!spine_ok) { + close_all(); + return fail(openads::AE_COLUMN_NOT_FOUND, + "multi-table join: unresolved join column"); + } + + // --- 4. Build a left-deep probe plan in FROM order. --- + struct Probe { + std::size_t partner = 0; + std::vector myCols; + std::vector partnerCols; + std::vector ci; // per key column: CICHAR fold + std::unordered_map> hash; + }; + std::vector probes(N); + for (std::size_t i = 1; i < N; ++i) { + std::size_t partner = N; // sentinel = unset + for (const auto& e : jeqs) { + std::int32_t myc; std::size_t other; std::int32_t otc; + if (e.lti == i && e.rti < i) { + myc = e.lfi; other = e.rti; otc = e.rfi; + } else if (e.rti == i && e.lti < i) { + myc = e.rfi; other = e.lti; otc = e.lfi; + } else { + continue; + } + if (partner == N) partner = other; + if (other != partner) { + close_all(); + return fail(openads::AE_INTERNAL_ERROR, + "multi-table join: a table joins to more than one " + "prior table (only left-deep trees are supported)"); + } + probes[i].myCols.push_back(myc); + probes[i].partnerCols.push_back(otc); + // CICHAR join key (either side CICHAR) folds case. + probes[i].ci.push_back( + field_is_cichar(*tbls[i], + static_cast(myc)) || + field_is_cichar(*tbls[other], + static_cast(otc))); + } + if (partner == N) { + close_all(); + return fail(openads::AE_INTERNAL_ERROR, + "multi-table join: a table has no equi-join to a prior " + "table (cartesian products are not supported)"); + } + probes[i].partner = partner; + } + + // --- 5. (Table hashes are built in step 7b below, after the WHERE + // evaluator is defined, so the single-table residual filters + // in pure[i] can be applied during the build.) --- + + // --- 6. Resolve the output schema from select_items (ADS names). + // Each source field maps to a DBF text descriptor (the + // sources may be ADT with binary encodings); rows emit + // from DECODED values. + struct OutCol { std::size_t ti; std::uint16_t src_fi; + openads::drivers::DbfField fld; }; + std::vector outcols; + std::unordered_set seen; + auto add_out = [&](std::size_t ti, std::int32_t fi) { + const auto& f = + tbls[ti]->driver()->fields()[static_cast(fi)]; + auto ofld = scriptbridge::join_out_field(f); + // Full-length names -- the ADT temp carries them whole. (The + // DBF-era resize(10) here also made two long names that agree + // in their first 10 chars dedup into ONE output column.) + if (!seen.insert(lc(ofld.name)).second) return; // first-wins + outcols.push_back(OutCol{ + ti, static_cast(fi), std::move(ofld)}); + }; + if (!nway_agg) { + if (st.select_items.empty()) { + // bare `SELECT *` across all tables, in FROM order. + for (std::size_t i = 0; i < N; ++i) { + const auto& flds = tbls[i]->driver()->fields(); + for (std::int32_t k = 0; + k < static_cast(flds.size()); ++k) + add_out(i, k); + } + } else { + for (const auto& si : st.select_items) { + if (si.wildcard) { + auto a = alias_idx.find(lc(si.alias)); + if (a == alias_idx.end()) { + close_all(); + return fail(openads::AE_COLUMN_NOT_FOUND, + si.alias.c_str()); + } + const auto& flds = + tbls[a->second]->driver()->fields(); + for (std::int32_t k = 0; + k < static_cast(flds.size()); ++k) + add_out(a->second, k); + } else { + std::size_t ti; std::int32_t fi; + if (!resolve(si.alias, si.column, ti, fi)) { + close_all(); + return fail(openads::AE_COLUMN_NOT_FOUND, + si.column.c_str()); + } + add_out(ti, fi); + } + } + } + if (outcols.empty()) { + close_all(); + return fail(openads::AE_INTERNAL_ERROR, + "multi-table join: empty projection"); + } + } + + // --- 6a. Aggregate mode: resolve aggregate source columns and + // GROUP BY key columns against the joined tables. The walk + // accumulates instead of emitting rows. + struct NAggSlot { + openads::sql::Aggregate def; + std::size_t ti = 0; + std::int32_t fi = -1; // -1 for COUNT(*) + std::uint8_t dec = 0; // source decimals (money: 4) + std::uint16_t src_len = 0; // source-field width + bool is_text = false; // MIN/MAX compares text + bool is_date = false; // date-sourced + // SUM(a * b): the right-hand column may live in a DIFFERENT + // joined table than the left one. + std::size_t rhs_ti = 0; + std::int32_t rhs_fi = -1; + std::uint8_t arg_dec = 0; // op-following result scale + std::uint16_t arg_len = 0; // SAP arg width + }; + struct NKeyCol { + std::size_t ti = 0; + std::int32_t fi = -1; + openads::drivers::DbfField ofld; // mapped output descriptor + }; + std::vector nslots; + std::vector nkeys; + if (nway_agg) { + for (const auto& a : st.aggregates) { + NAggSlot slot; + slot.def = a; + if (a.kind != openads::sql::AggregateKind::CountStar) { + if (!resolve(std::string(), a.column, + slot.ti, slot.fi)) { + close_all(); + return fail(openads::AE_COLUMN_NOT_FOUND, + a.column.c_str()); + } + const auto& sfd = tbls[slot.ti]->driver()->fields()[ + static_cast(slot.fi)]; + using FT = openads::drivers::DbfFieldType; + slot.dec = (sfd.type == FT::AdtMoney || + sfd.type == FT::Currency) + ? 4 : sfd.decimals; + slot.src_len = sfd.length; + slot.is_text = scriptbridge::agg_source_is_text(sfd.type); + slot.is_date = scriptbridge::agg_source_is_date(sfd.type); + // SUM(a * b): resolve the right-hand column (any joined + // table) and let the result scale follow the operation. + // Reading only the left column summed SUM(a * b) as + // SUM(a) - same defect the 2-table paths had. + if (a.arg_expr) slot.is_text = false; + if (a.arg_expr && !a.arg_expr->rhs_is_literal) { + if (!resolve(std::string(), + a.arg_expr->rhs_column, + slot.rhs_ti, slot.rhs_fi)) { + close_all(); + return fail(openads::AE_COLUMN_NOT_FOUND, + a.arg_expr->rhs_column.c_str()); + } + } + { + // SAP's declared shape for the argument (see + // agg_arg_shape). The rhs may live in a different + // joined table than the lhs. + const openads::drivers::DbfField* rfd = nullptr; + if (a.arg_expr && !a.arg_expr->rhs_is_literal && + slot.rhs_fi >= 0) { + rfd = &tbls[slot.rhs_ti]->driver()->fields()[ + static_cast(slot.rhs_fi)]; + } + auto shp = scriptbridge::agg_arg_shape(sfd, a, rfd); + slot.arg_dec = shp.dec; + slot.arg_len = shp.len; + } + } + nslots.push_back(std::move(slot)); + } + for (const auto& gname : st.group_by) { + NKeyCol kc; + if (!resolve(std::string(), gname, kc.ti, kc.fi)) { + close_all(); + return fail(openads::AE_COLUMN_NOT_FOUND, + gname.c_str()); + } + kc.ofld = scriptbridge::join_out_field( + tbls[kc.ti]->driver()->fields()[ + static_cast(kc.fi)]); + nkeys.push_back(std::move(kc)); + } + } + + // --- 7. Residual WHERE evaluator over the bound rows. Rows are + // bound by POSITIONING each table (goto_record) and + // recording the recno in recs[ti]; values come from + // Table::read_field (decoded -- works for ADT's 5-byte + // prefix + binary encodings as well as DBF), never from + // raw record bytes. + std::vector recs(N, 0); + auto slice = [&](std::size_t ti, std::int32_t fi) -> std::string { + if (recs[ti] == 0) return std::string(); + auto v = tbls[ti]->read_field(static_cast(fi)); + if (!v || v.value().is_null) return std::string(); + return v.value().as_string; + }; + auto is_ci_col = [&](std::size_t ti, std::int32_t fi) { + return field_is_cichar(*tbls[ti], + static_cast(fi)); + }; + auto is_num_type = [](openads::drivers::DbfFieldType t) { + return t == openads::drivers::DbfFieldType::Numeric || + t == openads::drivers::DbfFieldType::Float || + t == openads::drivers::DbfFieldType::Integer || + t == openads::drivers::DbfFieldType::Currency || + t == openads::drivers::DbfFieldType::Double; + }; + auto fold = [&](std::string v, openads::sql::WhereFn fn) { + if (fn == openads::sql::WhereFn::Upper) + for (auto& ch : v) + ch = static_cast( + std::toupper(static_cast(ch))); + else if (fn == openads::sql::WhereFn::Lower) + for (auto& ch : v) + ch = static_cast( + std::tolower(static_cast(ch))); + return v; + }; + std::function eval = + [&](const openads::sql::WhereExpr* n) -> bool { + if (n == nullptr) return true; + using K = openads::sql::WhereExpr::Kind; + using Op = openads::sql::WhereOp; + switch (n->kind) { + case K::And: { + for (auto& ch : n->children) + if (!eval(ch.get())) return false; + return true; // empty AND -> true + } + case K::Or: { + if (n->children.empty()) return false; // empty OR -> false + for (auto& ch : n->children) + if (eval(ch.get())) return true; + return false; + } + case K::Not: + return !eval(n->child.get()); + case K::In: { + std::size_t lti; std::int32_t lfi; + if (!resolve(std::string(), n->in_clause.column, lti, lfi)) + return false; + bool ci = is_ci_col(lti, lfi); + std::string lhs = trim_trailing(slice(lti, lfi)); + for (const auto& v : n->in_clause.literals) + if (where_str_cmp(lhs, v, ci) == 0) return true; + return false; + } + case K::Cmp: { + const auto& w = n->cmp; + std::size_t lti; std::int32_t lfi; + if (!resolve(w.column_alias, w.column, lti, lfi)) + return false; + const auto& lf = + tbls[lti]->driver()->fields()[ + static_cast(lfi)]; + bool ci = is_ci_col(lti, lfi); + std::string lhs = fold(trim_trailing(slice(lti, lfi)), + w.lhs_fn); + if (w.is_outer_ref) { + std::size_t rti; std::int32_t rfi; + if (!resolve(w.outer_column_alias, w.outer_column, + rti, rfi)) + return false; + // SAP: comparison is CI if EITHER operand is CICHAR. + int cc = where_str_cmp( + lhs, trim_trailing(slice(rti, rfi)), + ci || is_ci_col(rti, rfi)); + switch (w.op) { + case Op::Ne: return cc != 0; + case Op::Lt: return cc < 0; + case Op::Gt: return cc > 0; + case Op::Le: return cc <= 0; + case Op::Ge: return cc >= 0; + default: return cc == 0; // Eq + } + } + if (w.op == Op::IsNull) return lhs.empty(); + if (w.op == Op::IsNotNull) return !lhs.empty(); + bool numeric = is_num_type(lf.type); + if (w.op == Op::Like) + return sql_like_match_t(lhs, w.literal, ci); + if (w.op == Op::Between) { + if (numeric) { + double a = std::strtod(lhs.c_str(), nullptr); + double b1 = std::strtod(w.literal.c_str(), nullptr); + double b2 = std::strtod(w.literal2.c_str(), nullptr); + return a >= b1 && a <= b2; + } + return where_str_cmp(lhs, w.literal, ci) >= 0 && + where_str_cmp(lhs, w.literal2, ci) <= 0; + } + int cc; + if (numeric) { + double a = std::strtod(lhs.c_str(), nullptr); + double b = w.is_numeric + ? w.number + : std::strtod(w.literal.c_str(), nullptr); + cc = (a < b) ? -1 : (a > b ? 1 : 0); + } else { + cc = where_str_cmp(lhs, w.literal, ci); + } + switch (w.op) { + case Op::Eq: return cc == 0; + case Op::Ne: return cc != 0; + case Op::Lt: return cc < 0; + case Op::Gt: return cc > 0; + case Op::Le: return cc <= 0; + case Op::Ge: return cc >= 0; + default: return false; + } + } + default: + return true; // Exists / unsupported -> non-filtering + } + }; + + // --- 7b. Hash each joined table on its key columns, applying the + // single-table residual filters (pure[i]) up front so the hash + // holds only matching rows (e.g. just the month's detail rows) + // instead of the whole history. recs[i] is scratch here; the + // walk re-binds it per emitted combination. + for (std::size_t i = 1; i < N; ++i) { + auto& pr = probes[i]; + std::uint32_t rc = tbls[i]->record_count(); + for (std::uint32_t r = 1; r <= rc; ++r) { + if (auto g = tbls[i]->goto_record(r); !g) continue; + if (!tbls[i]->show_deleted_records() && + tbls[i]->is_deleted()) continue; + recs[i] = r; + bool keep = true; + for (const auto* cj : pure[i]) + if (!eval(cj)) { keep = false; break; } // single-table filter + if (!keep) continue; + std::string key; + for (std::size_t k = 0; k < pr.myCols.size(); ++k) { + std::string seg = trim_trailing(slice(i, pr.myCols[k])); + if (pr.ci[k]) + for (char& ch : seg) + ch = static_cast(std::toupper( + static_cast(ch))); + key += seg; + key.push_back('\x01'); + } + pr.hash[key].push_back(r); + } + recs[i] = 0; + } + + // --- 8. Output column specs (plain-projection mode; aggregate mode + // builds its own from nouts after the walk). `file` holds + // ROW IMAGES only; the on-disk container comes from the + // shared ADT temp helper at step 10 + // (docs/materialised-cursor-temps.md). + std::uint32_t out_rlen = 0; + for (const auto& oc : outcols) out_rlen += oc.fld.length; + if (out_rlen + 5 > 0xFFFF) { + close_all(); + return fail(openads::AE_INTERNAL_ERROR, + "multi-table join: record exceeds 64 KiB"); + } + std::vector file; + std::vector mspecs; + std::size_t mstride = 0; + if (!nway_agg) { + mspecs.reserve(outcols.size()); + for (const auto& oc : outcols) { + mspecs.push_back({oc.fld.name, oc.fld.raw_type, + oc.fld.length, oc.fld.decimals}); + } + mstride = out_rlen; + } + + // --- 9. Left-deep nested-loop join walk. Plain mode emits the + // projected row; aggregate mode accumulates into per-group + // slots (group key from the GROUP BY columns of the bound + // rows; no GROUP BY = one global group). + struct NAcc { + std::vector key_parts; + std::vector sum, minv, maxv; + std::vector sumc; // Kahan compensation + // MIN/MAX over a non-numeric column compares decoded text. + std::vector txt; + std::vector cnt; + std::uint64_t rows = 0; + }; + std::unordered_map ngroups; + std::vector ngroup_order; + std::uint32_t emitted = 0; + auto emit_row = [&]() { + if (nway_agg) { + std::string key; + std::vector parts; + parts.reserve(nkeys.size()); + for (const auto& kc : nkeys) { + std::string kv = slice(kc.ti, kc.fi); + parts.push_back(kv); + key += kv; + key.push_back('\x1f'); + } + auto git = ngroups.find(key); + if (git == ngroups.end()) { + NAcc acc; + acc.key_parts = std::move(parts); + acc.sum.assign(nslots.size(), 0.0); + acc.sumc.assign(nslots.size(), 0.0); + acc.sumc.assign(nslots.size(), 0.0); + acc.minv.assign(nslots.size(), + std::numeric_limits::infinity()); + acc.maxv.assign(nslots.size(), + -std::numeric_limits::infinity()); + acc.cnt.assign(nslots.size(), 0); + acc.txt.assign(nslots.size(), scriptbridge::TextMinMax{}); + git = ngroups.emplace(key, std::move(acc)).first; + ngroup_order.push_back(key); + } + auto& acc = git->second; + ++acc.rows; + for (std::size_t i2 = 0; i2 < nslots.size(); ++i2) { + if (nslots[i2].def.kind == + openads::sql::AggregateKind::CountStar) { + ++acc.cnt[i2]; + continue; + } + auto v = tbls[nslots[i2].ti]->read_field( + static_cast(nslots[i2].fi)); + if (!v || v.value().is_null) continue; + if (nslots[i2].is_text) { + ++acc.cnt[i2]; + acc.txt[i2].feed(v.value().as_string); + continue; + } + double d = v.value().as_double; + if (nslots[i2].def.arg_expr) { + double b2 = 0.0; + if (nslots[i2].def.arg_expr->rhs_is_literal) { + b2 = nslots[i2].def.arg_expr->rhs_number; + } else if (nslots[i2].rhs_fi >= 0) { + auto rv = tbls[nslots[i2].rhs_ti]->read_field( + static_cast( + nslots[i2].rhs_fi)); + b2 = rv ? rv.value().as_double : 0.0; + } + using AO = openads::sql::ArithOp; + switch (nslots[i2].def.arg_expr->op) { + case AO::Add: d = d + b2; break; + case AO::Sub: d = d - b2; break; + case AO::Mul: d = d * b2; break; + case AO::Div: + d = b2 != 0.0 + ? scriptbridge::avg_truncate( + d / b2, nslots[i2].arg_dec) + : 0.0; + break; + } + } + ++acc.cnt[i2]; + { // Kahan-compensated accumulation (see GroupAcc note) + const double y2 = d - acc.sumc[i2]; + const double t2 = acc.sum[i2] + y2; + acc.sumc[i2] = (t2 - acc.sum[i2]) - y2; + acc.sum[i2] = t2; + } + if (d < acc.minv[i2]) acc.minv[i2] = d; + if (d > acc.maxv[i2]) acc.maxv[i2] = d; + } + return; + } + std::vector rec(out_rlen, ' '); + std::size_t off = 0; + for (const auto& oc : outcols) { + std::string s = scriptbridge::join_cell_text( + *tbls[oc.ti], oc.src_fi, oc.fld); + std::memcpy(rec.data() + off, s.data(), s.size()); + off += oc.fld.length; + } + file.insert(file.end(), rec.begin(), rec.end()); + ++emitted; + }; + // Evaluate the residual conjuncts that become bound at `level`; + // returning false prunes the branch before descending further. + auto pass_bucket = [&](std::size_t level) -> bool { + for (const auto* cj : buckets[level]) + if (!eval(cj)) return false; + return true; + }; + std::function walk = [&](std::size_t level) { + if (level == N) { emit_row(); return; } // all conjuncts checked + if (level == 0) { + std::uint32_t rc = tbls[0]->record_count(); + for (std::uint32_t r = 1; r <= rc; ++r) { + if (auto g = tbls[0]->goto_record(r); !g) continue; + if (!tbls[0]->show_deleted_records() && + tbls[0]->is_deleted()) continue; + recs[0] = r; + if (pass_bucket(0)) walk(1); // prune table-0 filters early + } + return; + } + const auto& pr = probes[level]; + std::string key; + for (std::size_t k = 0; k < pr.partnerCols.size(); ++k) { + std::string seg = trim_trailing( + slice(pr.partner, pr.partnerCols[k])); + if (pr.ci[k]) + for (char& ch : seg) + ch = static_cast(std::toupper( + static_cast(ch))); + key += seg; + key.push_back('\x01'); + } + auto it2 = pr.hash.find(key); + if (it2 == pr.hash.end()) return; + for (std::uint32_t r : it2->second) { + if (auto g = tbls[level]->goto_record(r); !g) continue; + recs[level] = r; + if (pass_bucket(level)) walk(level + 1); // push filters down + } + recs[level] = 0; + }; + walk(0); + + // --- 9a. Aggregate mode: build the result file from the group + // accumulators (same output conventions as the + // single-table grouped path: SELECT-list order via the + // $AGG_ placeholders, N(20,dp) aggregate cells named + // alias / EXPR / EXPR_1, group keys with their mapped + // source descriptor, groups sorted ascending, HAVING + // evaluated per group). + if (nway_agg) { + // A text MIN/MAX column is one width for every group: the + // widest decoded value seen anywhere. + std::vector ntextw(nslots.size(), 0); + for (auto& kv : ngroups) + for (std::size_t i = 0; i < nslots.size(); ++i) + if (kv.second.txt[i].w > ntextw[i]) + ntextw[i] = kv.second.txt[i].w; + auto nslot_is_text = [&](std::size_t i) { + using K = openads::sql::AggregateKind; + return nslots[i].is_text && (nslots[i].def.kind == K::Min || + nslots[i].def.kind == K::Max); + }; + auto ndp = [&](std::size_t i) -> int { + using K = openads::sql::AggregateKind; + switch (nslots[i].def.kind) { + case K::CountStar: case K::Count: return 0; + // AVG keeps the SOURCE column's decimals (oracle + // 2026-07-21: money -> 4dp, integer -> 0dp; not 6); + // an arithmetic argument follows the operation. + default: return static_cast(nslots[i].arg_dec); + } + }; + auto ci_eq_n = [](const std::string& x, const std::string& y) { + if (x.size() != y.size()) return false; + for (std::size_t z = 0; z < x.size(); ++z) + if (std::toupper(static_cast(x[z])) != + std::toupper(static_cast(y[z]))) + return false; + return true; + }; + struct NOut { + bool is_agg = false; + std::size_t idx = 0; + std::string name; + char type = 'N'; + std::uint16_t len = 20; + std::uint8_t dec = 0; + }; + std::vector nouts; + { + std::size_t unal = 0; + std::size_t pj_pos = 0; + for (const auto& pj : st.projection) { + // Positional AS alias (see the single-table GROUP BY + // path -- same constraint-4 rule). + const std::string* pj_alias = nullptr; + for (const auto& pa : st.projection_aliases) + if (pa.first == pj_pos) { pj_alias = &pa.second; break; } + ++pj_pos; + NOut o; + if (pj.rfind("$AGG_", 0) == 0) { + o.is_agg = true; + o.idx = static_cast( + std::stoul(pj.substr(5))); + std::string nm = nslots[o.idx].def.alias; + if (nm.empty()) { + nm = unal == 0 ? "EXPR" + : "EXPR_" + std::to_string(unal); + ++unal; + } + o.name = nm; + const bool tx_n = nslot_is_text(o.idx); + o.type = tx_n + ? (nslots[o.idx].is_date ? 'D' : 'C') + : 'N'; + o.len = tx_n + ? static_cast( + ntextw[o.idx] ? ntextw[o.idx] : 1) + : scriptbridge::agg_result_width( + nslots[o.idx].def.kind, + nslots[o.idx].arg_len); + o.dec = static_cast(ndp(o.idx)); + } else { + std::int32_t gi = -1; + for (std::size_t j2 = 0; j2 < st.group_by.size(); + ++j2) + if (ci_eq_n(st.group_by[j2], pj)) { + gi = static_cast(j2); break; + } + if (gi < 0) { + close_all(); + return fail(openads::AE_PARSE_ERROR, + ("SELECT column must be a GROUP BY key: " + + pj).c_str()); + } + o.is_agg = false; + o.idx = static_cast(gi); + const auto& kf = nkeys[o.idx].ofld; + o.name = pj_alias ? *pj_alias : pj; + o.type = kf.raw_type + ? static_cast(kf.raw_type) : 'C'; + o.len = kf.length; + o.dec = kf.decimals; + } + nouts.push_back(std::move(o)); + } + } + auto agg_at = [&](const NAcc& acc, std::size_t si) -> double { + using K = openads::sql::AggregateKind; + switch (nslots[si].def.kind) { + case K::CountStar: + return static_cast(acc.rows); + case K::Count: + return static_cast(acc.cnt[si]); + case K::Sum: return acc.sum[si]; + case K::Avg: + return acc.cnt[si] + ? scriptbridge::avg_truncate( + acc.sum[si] / + static_cast(acc.cnt[si]), + static_cast(nslots[si].dec)) + : 0.0; + case K::Min: return acc.cnt[si] ? acc.minv[si] : 0.0; + case K::Max: return acc.cnt[si] ? acc.maxv[si] : 0.0; + } + return 0.0; + }; + auto resolve_nslot = [&](const openads::sql::HavingCmp& ha) + -> std::int32_t { + for (std::size_t i = 0; i < nslots.size(); ++i) + if (nslots[i].def.kind == ha.agg.kind && + nslots[i].def.column == ha.agg.column) + return static_cast(i); + if (ha.agg.kind == openads::sql::AggregateKind::CountStar) + for (std::size_t i = 0; i < nslots.size(); ++i) + if (nslots[i].def.kind == + openads::sql::AggregateKind::CountStar) + return static_cast(i); + return -1; + }; + std::function eval_nhaving; + eval_nhaving = [&](const openads::sql::HavingExpr& n, + const NAcc& acc) -> bool { + using K = openads::sql::HavingExpr::Kind; + if (n.kind == K::And) { + for (auto& cn : n.children) + if (!eval_nhaving(*cn, acc)) return false; + return true; + } + if (n.kind == K::Or) { + for (auto& cn : n.children) + if (eval_nhaving(*cn, acc)) return true; + return false; + } + if (n.kind == K::Not) return !eval_nhaving(*n.child, acc); + std::int32_t si = resolve_nslot(n.cmp); + if (si < 0) return false; + double v = agg_at(acc, static_cast(si)); + double rhs = n.cmp.num; + switch (n.cmp.op) { + case openads::sql::WhereOp::Eq: return v == rhs; + case openads::sql::WhereOp::Ne: return v != rhs; + case openads::sql::WhereOp::Lt: return v < rhs; + case openads::sql::WhereOp::Gt: return v > rhs; + case openads::sql::WhereOp::Le: return v <= rhs; + case openads::sql::WhereOp::Ge: return v >= rhs; + default: return false; + } + }; + + // A global aggregate (no GROUP BY) always yields one row, + // even when the join matched nothing. + if (st.group_by.empty() && ngroups.empty()) { + NAcc acc; + acc.sum.assign(nslots.size(), 0.0); + acc.sumc.assign(nslots.size(), 0.0); + acc.minv.assign(nslots.size(), + std::numeric_limits::infinity()); + acc.maxv.assign(nslots.size(), + -std::numeric_limits::infinity()); + acc.cnt.assign(nslots.size(), 0); + acc.txt.assign(nslots.size(), scriptbridge::TextMinMax{}); + ngroups.emplace(std::string(), std::move(acc)); + ngroup_order.push_back(std::string()); + } + + mspecs.clear(); + mspecs.reserve(nouts.size()); + mstride = 0; + for (const auto& o : nouts) { + mspecs.push_back({o.name, o.type, o.len, o.dec}); + mstride += o.len; + } + std::sort(ngroup_order.begin(), ngroup_order.end()); + for (const auto& key : ngroup_order) { + auto& acc = ngroups[key]; + if (st.having && !eval_nhaving(*st.having, acc)) continue; + for (const auto& o : nouts) { + if (o.is_agg) { + const std::string* ntv = nullptr; + if (nslot_is_text(o.idx)) + ntv = (nslots[o.idx].def.kind == + openads::sql::AggregateKind::Min) + ? &acc.txt[o.idx].mn : &acc.txt[o.idx].mx; + const auto cell = scriptbridge::agg_cell_text( + nslots[o.idx].def.kind, o.len, + static_cast(o.dec), agg_at(acc, o.idx), ntv); + file.insert(file.end(), cell.begin(), cell.end()); + } else { + std::string kp = o.idx < acc.key_parts.size() + ? acc.key_parts[o.idx] : std::string(); + for (std::uint16_t b = 0; b < o.len; ++b) + file.push_back(b < kp.size() + ? static_cast(kp[b]) : ' '); + } + } + ++emitted; + } + } + + (void)emitted; + close_all(); + + // --- 10. Materialise into the temp cursor and register it. --- + return materialise_temp_adt(c, "_mjoin_", mspecs, file, + mstride, phCursor); + } + + if (parsed.value().inner_join) { + // M10.14 materialises the join into a temp DBF cursor; M10.20 + // additionally compiles the outer WHERE / ORDER BY against + // that cursor's merged schema; M10.23 runs aggregates over + // that merged cursor when the projection is `agg(...)` instead + // of a column list. + const auto& j = *parsed.value().inner_join; + auto& s = state(); + std::lock_guard lk(s.mu); + + auto lh = open_or_sys(parsed.value().table, + openads::engine::TableType::Cdx, + openads::engine::OpenMode::Read, + openads::engine::LockingMode::Compatible); + if (!lh) return fail(lh.error()); + auto rh = open_or_sys(j.table, + openads::engine::TableType::Cdx, + openads::engine::OpenMode::Read, + openads::engine::LockingMode::Compatible); + if (!rh) { + c->close_table(lh.value()); + return fail(rh.error()); + } + openads::engine::Table* ltbl = c->lookup_table(lh.value()); + openads::engine::Table* rtbl = c->lookup_table(rh.value()); + if (ltbl == nullptr || rtbl == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "join post-open"); + } + + // SAP 2137 -- reject an unqualified column present in both joined + // tables (checked here, where both source schemas are still + // distinct; the merged cursor below would hide the ambiguity). + { + std::string amb = scriptbridge::first_ambiguous_join_column( + parsed.value(), {ltbl, rtbl}); + if (!amb.empty()) { + c->close_table(lh.value()); + c->close_table(rh.value()); + return fail(2137, + ("Column found in multiple tables: " + amb).c_str()); + } + } + + std::int32_t lcol = ltbl->field_index(j.left_column); + std::int32_t rcol = rtbl->field_index(j.right_column); + // Orientation fallback: a comma-join (`FROM a, b WHERE a.x = b.y`) + // lowers the join key from the WHERE, where the alias qualifiers are + // dropped -- so `left_column`/`right_column` may name the columns in + // the opposite order to base/joined. If the straight assignment does + // not resolve but the swapped one does, use the swap. Purely + // additive: it only fires on inputs the strict path already rejects, + // and also makes explicit `JOIN ON b.y = a.x` lenient like ADS. + if ((lcol < 0 || rcol < 0)) { + std::int32_t lcol_sw = ltbl->field_index(j.right_column); + std::int32_t rcol_sw = rtbl->field_index(j.left_column); + if (lcol_sw >= 0 && rcol_sw >= 0) { + lcol = lcol_sw; + rcol = rcol_sw; + } + } + if (lcol < 0) { + c->close_table(lh.value()); c->close_table(rh.value()); + return fail(openads::AE_COLUMN_NOT_FOUND, + j.left_column.c_str()); + } + if (rcol < 0) { + c->close_table(lh.value()); c->close_table(rh.value()); + return fail(openads::AE_COLUMN_NOT_FOUND, + j.right_column.c_str()); + } + + // CICHAR join keys match case-insensitively (SAP: a comparison is + // case-insensitive if either operand is CICHAR). Fold the hash key + // to upper on both build and probe so 'abc' joins 'ABC'. + bool join_ci = + field_is_cichar(*ltbl, static_cast(lcol)) || + field_is_cichar(*rtbl, static_cast(rcol)); + auto trim_trailing = [join_ci](std::string sl) { + while (!sl.empty() && sl.back() == ' ') sl.pop_back(); + if (join_ci) + for (char& ch : sl) + ch = static_cast(std::toupper( + static_cast(ch))); + return sl; + }; + + // INNER / LEFT walk left + lookup right. RIGHT swaps that -- + // walk right + lookup left. FULL walks left first (emitting + // matched + LEFT-style fillers) and then walks right to emit + // only the unmatched right rows with a blank left filler. + // The merged schema's column order (left fields first, then + // right with `R_` prefix) stays identical regardless of join + // direction so the cursor exposes the same shape to apps. + bool walk_right = j.is_right; + + std::unordered_map> probe_map; + if (walk_right) { + // Hash the LEFT column (we'll walk the right side and + // look up each right row's join value in this map). + std::uint32_t lrc_for_hash = ltbl->record_count(); + for (std::uint32_t r = 1; r <= lrc_for_hash; ++r) { + if (auto g = ltbl->goto_record(r); !g) continue; + if (!ltbl->show_deleted_records() && + ltbl->is_deleted()) continue; + auto v = ltbl->read_field( + static_cast(lcol)); + if (!v) continue; + probe_map[trim_trailing(v.value().as_string)].push_back(r); + } + } else { + // Default: hash the RIGHT column (M10.14 + M10.16 path). + std::uint32_t rrc = rtbl->record_count(); + for (std::uint32_t r = 1; r <= rrc; ++r) { + if (auto g = rtbl->goto_record(r); !g) continue; + if (!rtbl->show_deleted_records() && + rtbl->is_deleted()) continue; + auto v = rtbl->read_field( + static_cast(rcol)); + if (!v) continue; + probe_map[trim_trailing(v.value().as_string)].push_back(r); + } + } + // Keep the legacy name `rmap` working -- the executor below + // walks one side and probes the other through this map. + auto& rmap = probe_map; + + // Build merged schema. The temp cursor is a text-convention DBF, + // but the SOURCE tables may be ADT (pmsys) whose records carry a + // 5-byte prefix and binary field encodings -- so the merge must go + // through DECODED field values (read_field), never raw record + // bytes, and each source field maps to a DBF text descriptor. + struct JOutCol { + std::uint16_t src_idx; // field in source + bool from_right; + openads::drivers::DbfField fld; // output descriptor + }; + std::vector jcols; + std::vector merged; + // Source CICHAR columns demote to plain 'C' in the temp DBF, so + // remember them by merged name -- the WHERE/ORDER BY compilers on + // the joined cursor consult this set to keep SAP's + // case-insensitive collation. + std::unordered_set join_ci_cols; + auto lower_nm = [](std::string v) { + for (auto& ch : v) + ch = static_cast( + std::tolower(static_cast(ch))); + return v; + }; + { + const auto& lfields = ltbl->driver()->fields(); + const auto& rfields = rtbl->driver()->fields(); + for (std::size_t i = 0; i < lfields.size(); ++i) { + JOutCol oc; + oc.src_idx = static_cast(i); + oc.from_right = false; + oc.fld = scriptbridge::join_out_field(lfields[i]); + if (lfields[i].type == + openads::drivers::DbfFieldType::CiCharacter) + join_ci_cols.insert(lower_nm(oc.fld.name)); + merged.push_back(oc.fld); + jcols.push_back(std::move(oc)); + } + for (std::size_t i = 0; i < rfields.size(); ++i) { + JOutCol oc; + oc.src_idx = static_cast(i); + oc.from_right = true; + oc.fld = scriptbridge::join_out_field(rfields[i]); + // Full-length merged name -- the ADT temp carries it whole. + // (The DBF-era truncation to 10 chars here made a long + // right-side column unreachable by its R_ form.) + oc.fld.name = "R_" + oc.fld.name; + if (rfields[i].type == + openads::drivers::DbfFieldType::CiCharacter) + join_ci_cols.insert(lower_nm(oc.fld.name)); + merged.push_back(oc.fld); + jcols.push_back(std::move(oc)); + } + } + + std::uint32_t merged_rec = 0; + for (const auto& f : merged) merged_rec += f.length; + if (merged_rec + 5 > 0xFFFF) { + c->close_table(lh.value()); c->close_table(rh.value()); + return fail(openads::AE_INTERNAL_ERROR, + "joined record exceeds 64 KiB"); + } + + // Merged column specs + row images for the shared ADT temp helper + // (docs/materialised-cursor-temps.md -- the DBF this used to write + // truncated every merged name, R_ prefix included, to 10 chars). + std::vector jtcols; + jtcols.reserve(merged.size()); + for (const auto& f : merged) { + jtcols.push_back({f.name, f.raw_type, f.length, f.decimals}); + } + std::vector file; + + // Helper: emit one merged record from the DECODED field values of + // the currently-positioned source rows (driver-abstracted -- works + // for ADT and DBF alike). Either side may be absent -- outer-join + // fillers leave that side's cells blank. + std::uint32_t emitted = 0; + auto emit_merged = [&](bool has_left, bool has_right) { + std::vector mrec(merged_rec, ' '); + std::size_t off = 0; + for (const auto& oc : jcols) { + const std::uint16_t L = oc.fld.length; + const bool present = oc.from_right ? has_right : has_left; + if (present) { + openads::engine::Table* st = + oc.from_right ? rtbl : ltbl; + std::string s = scriptbridge::join_cell_text( + *st, oc.src_idx, oc.fld); + std::memcpy(mrec.data() + off, s.data(), s.size()); + } + off += L; + } + file.insert(file.end(), mrec.begin(), mrec.end()); + ++emitted; + }; + + if (walk_right) { + // RIGHT OUTER -- walk right rows, look up the LEFT hash. + // Unmatched right rows surface with blank left fields. + std::uint32_t rrc = rtbl->record_count(); + for (std::uint32_t r = 1; r <= rrc; ++r) { + if (auto g = rtbl->goto_record(r); !g) continue; + if (!rtbl->show_deleted_records() && + rtbl->is_deleted()) continue; + auto rv = rtbl->read_field( + static_cast(rcol)); + if (!rv) continue; + auto lit = rmap.find(trim_trailing(rv.value().as_string)); + if (lit == rmap.end()) { + emit_merged(false, true); + continue; + } + for (std::uint32_t ll : lit->second) { + if (auto g = ltbl->goto_record(ll); !g) continue; + emit_merged(true, true); + } + } + } else { + // INNER / LEFT / FULL -- walk left rows, look up the RIGHT + // hash. Unmatched left rows surface with blank right + // fields when is_left or is_full; dropped otherwise. + std::unordered_set matched_right; + std::uint32_t lrc = ltbl->record_count(); + for (std::uint32_t l = 1; l <= lrc; ++l) { + if (auto g = ltbl->goto_record(l); !g) continue; + if (!ltbl->show_deleted_records() && + ltbl->is_deleted()) continue; + auto lv = ltbl->read_field( + static_cast(lcol)); + if (!lv) continue; + auto rit = rmap.find(trim_trailing(lv.value().as_string)); + if (rit == rmap.end()) { + if (j.is_left || j.is_full) { + emit_merged(true, false); + } + continue; + } + for (std::uint32_t rr : rit->second) { + if (auto g = rtbl->goto_record(rr); !g) continue; + // read_field targets the positioned row on BOTH + // sides; ltbl still sits on record l. + emit_merged(true, true); + if (j.is_full) matched_right.insert(rr); + } + } + // FULL OUTER: emit unmatched right rows with blank left. + if (j.is_full) { + std::uint32_t rrc = rtbl->record_count(); + for (std::uint32_t r = 1; r <= rrc; ++r) { + if (matched_right.find(r) != matched_right.end()) continue; + if (auto g = rtbl->goto_record(r); !g) continue; + if (!rtbl->show_deleted_records() && + rtbl->is_deleted()) continue; + emit_merged(false, true); + } + } + } + (void)emitted; + c->close_table(lh.value()); + c->close_table(rh.value()); + + // Materialise + open the merged cursor; NOT yet the user-visible + // handle -- WHERE / ORDER BY / aggregates below keep refining it. + MaterialisedTemp jtmp; + { + UNSIGNED32 mrc = materialise_temp_adt_open( + c, "_join_", jtcols, file, merged_rec, &jtmp); + if (mrc != openads::AE_SUCCESS) return mrc; + } + const Handle cth_h = jtmp.th; + openads::engine::Table* ctbl = jtmp.tbl; + // Delete the merged temp's files on every exit except the one that + // registers it as the user-visible cursor (which hands lifetime to + // materialised_cursor_temps). Error paths close the table first, so + // the removal succeeds; the join+aggregate branch closes it and + // returns a different temp as the cursor, so the merged one goes. + struct JoinTempGuard { + std::string stem; + bool keep = false; + ~JoinTempGuard() { if (!keep) remove_temp_table_files(stem); } + } jtmp_guard{jtmp.stem, false}; + + // Resolve a column reference against the merged cursor: as written, + // by its merged `R_` spelling, and with the table qualifier + // stripped (the merged temp is single-table, so `l.UNITS` must find + // `R_UNITS`). Without the fallbacks, an aggregate or GROUP BY over + // a right-side column raised 2121 where SAP answers. + auto jcol_index = [&](const std::string& col) -> std::int32_t { + std::int32_t fi = ctbl->field_index(col); + if (fi < 0) fi = ctbl->field_index("R_" + col); + if (fi < 0) { + const auto dot = col.rfind('.'); + if (dot != std::string::npos) { + const std::string bare = col.substr(dot + 1); + fi = ctbl->field_index(bare); + if (fi < 0) fi = ctbl->field_index("R_" + bare); + } + } + return fi; + }; + + // M10.20: apply outer WHERE / ORDER BY against the merged + // cursor's schema (left names verbatim; right names as + // `R_`). + if (parsed.value().where) { + using Pred = std::function; + std::function( + const openads::sql::WhereExpr&)> compile; + compile = [&](const openads::sql::WhereExpr& node) + -> openads::util::Result { + using Kind = openads::sql::WhereExpr::Kind; + if (node.kind == Kind::And || node.kind == Kind::Or) { + std::vector ks; + for (auto& cn : node.children) { + auto r = compile(*cn); + if (!r) return r.error(); + ks.push_back(std::move(r).value()); + } + bool is_and = (node.kind == Kind::And); + return Pred{[ks = std::move(ks), is_and] + (openads::engine::Table& t) { + if (is_and) { + for (auto& k : ks) if (!k(t)) return false; + return true; + } + for (auto& k : ks) if (k(t)) return true; + return false; + }}; + } + if (node.kind == Kind::Not) { + auto inner = compile(*node.child); + if (!inner) return inner.error(); + return Pred{[p = std::move(inner).value()] + (openads::engine::Table& t){return !p(t);}}; + } + if (node.kind == Kind::Cmp) { + const auto& w = node.cmp; + std::int32_t fi = ctbl->field_index(w.column); + if (fi < 0) { + return openads::util::Error{ + openads::AE_COLUMN_NOT_FOUND, 0, + w.column.c_str(), ""}; + } + std::uint16_t f = static_cast(fi); + openads::sql::WhereOp op = w.op; + std::string lit = w.literal; + bool is_num = w.is_numeric; + double num = w.number; + std::shared_ptr> contains_hits; + if (op == openads::sql::WhereOp::Contains) { + namespace fs = std::filesystem; + fs::path fts_path = + fs::path(ctbl->path()).replace_extension(".fts"); + auto loaded = openads::engine::Fts::load(fts_path.string()); + if (loaded) { + openads::engine::FtsOptions opts; + auto hits = openads::engine::Fts::search( + loaded.value(), lit, opts); + contains_hits = + std::make_shared>( + hits.begin(), hits.end()); + } + } + std::string lit2 = w.literal2; + double num2 = w.number2; + openads::sql::WhereFn lhs_fn = w.lhs_fn; + bool ci_f = field_is_cichar(*ctbl, f) || + join_ci_cols.count(lower_nm( + ctbl->field_descriptor(f).name)) > 0; + return Pred{[f, op, lit, lit2, is_num, num, num2, + contains_hits, lhs_fn, ci_f] + (openads::engine::Table& t) { + if (op == openads::sql::WhereOp::Contains) { + if (!contains_hits) return false; + return contains_hits->find(t.recno()) != + contains_hits->end(); + } + auto v = t.read_field(f); + if (!v) return false; + if (op == openads::sql::WhereOp::IsNull || + op == openads::sql::WhereOp::IsNotNull) { + bool null_ish = t.is_field_null(f); + if (!null_ish) { + auto sv = v.value().as_string; + while (!sv.empty() && sv.back() == ' ') sv.pop_back(); + null_ish = sv.empty(); + } + return op == openads::sql::WhereOp::IsNull + ? null_ish : !null_ish; + } + if (op == openads::sql::WhereOp::Between) { + if (is_num) { + double d = v.value().as_double; + return d >= num && d <= num2; + } + auto sv = apply_where_fn(v.value().as_string, lhs_fn); + return where_str_cmp(sv, lit, ci_f) >= 0 && + where_str_cmp(sv, lit2, ci_f) <= 0; + } + if (op == openads::sql::WhereOp::Like) { + auto sv = apply_where_fn(v.value().as_string, lhs_fn); + while (!sv.empty() && sv.back() == ' ') sv.pop_back(); + return sql_like_match_t(sv, lit, ci_f); + } + int cmp = 0; + if (is_num) { + double d = v.value().as_double; + if (d < num) cmp = -1; + else if (d > num) cmp = 1; + } else { + cmp = where_str_cmp( + apply_where_fn(v.value().as_string, lhs_fn), + lit, ci_f); + } + switch (op) { + case openads::sql::WhereOp::Eq: return cmp == 0; + case openads::sql::WhereOp::Ne: return cmp != 0; + case openads::sql::WhereOp::Lt: return cmp < 0; + case openads::sql::WhereOp::Gt: return cmp > 0; + case openads::sql::WhereOp::Le: return cmp <= 0; + case openads::sql::WhereOp::Ge: return cmp >= 0; + default: return false; + } + }}; + } + if (node.kind == Kind::In) { + std::int32_t fidx = ctbl->field_index(node.in_clause.column); + if (fidx < 0) return openads::util::Error{ + openads::AE_COLUMN_NOT_FOUND, 0, + node.in_clause.column.c_str(), ""}; + std::uint16_t fi = static_cast(fidx); + bool ci_f = field_is_cichar(*ctbl, fi) || + join_ci_cols.count(lower_nm( + ctbl->field_descriptor(fi).name)) > 0; + auto fold = [ci_f](std::string s2) { + if (ci_f) + for (char& ch : s2) + ch = static_cast(std::toupper( + static_cast(ch))); + return s2; + }; + auto set = std::make_shared>(); + for (const auto& l2 : node.in_clause.literals) + set->insert(fold(l2)); + return Pred{[fi, set, fold](openads::engine::Table& t) { + auto v = t.read_field(fi); + if (!v) return false; + auto sv = v.value().as_string; + while (!sv.empty() && sv.back() == ' ') sv.pop_back(); + return set->count(fold(sv)) > 0; + }}; + } + return openads::util::Error{ + openads::AE_FUNCTION_NOT_AVAILABLE, 0, + "join cursor WHERE supports Cmp/AND/OR/NOT/IN only", ""}; + }; + auto compiled = compile(*parsed.value().where); + if (!compiled) return fail(compiled.error()); + ctbl->set_filter(std::move(compiled).value()); + } + if (parsed.value().order_by) { + // M10.37 -- multi-column ORDER BY against the joined cursor. + struct SortKey { + std::uint16_t field_index; + bool descending; + bool numeric; + bool ci; // CICHAR: case-insensitive collation + }; + std::vector sks; + auto add_sk = [&](const openads::sql::OrderBy& ob) + -> openads::util::Result + { + std::int32_t fi = ctbl->field_index(ob.column); + if (fi < 0) return openads::util::Error{ + openads::AE_COLUMN_NOT_FOUND, 0, + ob.column.c_str(), ""}; + const auto& fd = ctbl->field_descriptor( + static_cast(fi)); + SortKey k; + k.field_index = static_cast(fi); + k.descending = ob.descending; + k.numeric = + fd.type == openads::drivers::DbfFieldType::Numeric || + fd.type == openads::drivers::DbfFieldType::Float || + fd.type == openads::drivers::DbfFieldType::Integer || + fd.type == openads::drivers::DbfFieldType::Currency|| + fd.type == openads::drivers::DbfFieldType::Double; + k.ci = fd.type == + openads::drivers::DbfFieldType::CiCharacter || + join_ci_cols.count(lower_nm(fd.name)) > 0; + sks.push_back(k); + return std::monostate{}; + }; + if (auto r = add_sk(*parsed.value().order_by); !r) + return fail(r.error()); + for (auto& obx : parsed.value().order_by_extra) { + if (auto r = add_sk(obx); !r) return fail(r.error()); + } + struct Row { + std::uint32_t recno; + std::vector s; + std::vector d; + }; + std::vector rows; + std::uint32_t crc = ctbl->record_count(); + for (std::uint32_t r = 1; r <= crc; ++r) { + if (auto g = ctbl->goto_record(r); !g) continue; + if (ctbl->is_deleted()) continue; + if (!ctbl->passes_filter()) continue; + Row row; + row.recno = r; + row.s.resize(sks.size()); + row.d.resize(sks.size()); + for (std::size_t i = 0; i < sks.size(); ++i) { + auto v = ctbl->read_field(sks[i].field_index); + if (v) { + row.s[i] = v.value().as_string; + row.d[i] = v.value().as_double; + } + } + rows.push_back(std::move(row)); + } + std::stable_sort(rows.begin(), rows.end(), + [&](const Row& a, const Row& b) { + for (std::size_t i = 0; i < sks.size(); ++i) { + bool less, equal; + if (sks[i].numeric) { + less = a.d[i] < b.d[i]; + equal = a.d[i] == b.d[i]; + } else { + int cmp = where_str_cmp(a.s[i], b.s[i], + sks[i].ci); + less = cmp < 0; + equal = cmp == 0; + } + if (equal) continue; + return sks[i].descending ? !less : less; + } + return false; + }); + std::vector seq; + seq.reserve(rows.size()); + for (auto& row : rows) seq.push_back(row.recno); + ctbl->clear_filter(); + ctbl->set_recno_sequence(std::move(seq)); + } + + // M10.34 -- GROUP BY across JOIN. Same shape as the plain-table + // grouped path (M10.25) but reads from the merged cursor. + if (!parsed.value().group_by.empty() && + !parsed.value().aggregates.empty()) { + struct AggSlot { + openads::sql::Aggregate def; + std::int32_t field_index = -1; + std::uint8_t decimals = 0; // source-field dp + std::uint16_t src_len = 0; // source-field width + bool is_text = false; // MIN/MAX on text + bool is_date = false; // date-sourced + std::int32_t rhs_field = -1; // SUM(a * b): b + std::uint8_t arg_dec = 0; // op-following dp + std::uint16_t arg_len = 0; // SAP arg width + }; + std::vector slots; + slots.reserve(parsed.value().aggregates.size()); + for (auto& a : parsed.value().aggregates) { + AggSlot slot; + slot.def = a; + if (a.kind != openads::sql::AggregateKind::CountStar) { + slot.field_index = jcol_index(a.column); + if (slot.field_index < 0) { + c->close_table(cth_h); + return fail(openads::AE_COLUMN_NOT_FOUND, + a.column.c_str()); + } + { + const auto& sfd = ctbl->field_descriptor( + static_cast(slot.field_index)); + using FT = openads::drivers::DbfFieldType; + slot.decimals = (sfd.type == FT::AdtMoney || + sfd.type == FT::Currency) + ? 4 : sfd.decimals; // money: 4 implied + slot.src_len = sfd.length; + slot.is_text = + scriptbridge::agg_source_is_text(sfd.type); + slot.is_date = + scriptbridge::agg_source_is_date(sfd.type); + } + // SUM(a * b): resolve the right-hand column against the + // merged cursor too (R_ fallback included), and let the + // result scale follow the operation like the + // single-table path does. Reading only the left column + // summed SUM([real] * units) as SUM(real) - masked in + // the gate by a group whose units were all 1. + if (a.arg_expr && !a.arg_expr->rhs_is_literal) { + slot.rhs_field = jcol_index(a.arg_expr->rhs_column); + if (slot.rhs_field < 0) { + c->close_table(cth_h); + return fail(openads::AE_COLUMN_NOT_FOUND, + a.arg_expr->rhs_column.c_str()); + } + } + if (a.arg_expr) slot.is_text = false; + { + // SAP's declared shape for the argument (see + // agg_arg_shape). + const openads::drivers::DbfField* rfd = nullptr; + if (a.arg_expr && !a.arg_expr->rhs_is_literal && + slot.rhs_field >= 0) { + rfd = &ctbl->field_descriptor( + static_cast(slot.rhs_field)); + } + auto shp = scriptbridge::agg_arg_shape( + ctbl->field_descriptor( + static_cast(slot.field_index)), + a, rfd); + slot.arg_dec = shp.dec; + slot.arg_len = shp.len; + } + } + slots.push_back(std::move(slot)); + } + + struct GBCol { + std::uint16_t field_index; + std::uint8_t length; + std::string name; + std::uint8_t raw_type; + }; + std::vector gbs; + gbs.reserve(parsed.value().group_by.size()); + for (auto& gname : parsed.value().group_by) { + std::int32_t fi = jcol_index(gname); + if (fi < 0) { + c->close_table(cth_h); + return fail(openads::AE_COLUMN_NOT_FOUND, gname.c_str()); + } + const auto& fd = ctbl->field_descriptor( + static_cast(fi)); + GBCol gc; + gc.field_index = static_cast(fi); + gc.length = static_cast(fd.length); + gc.name = gname; + gc.raw_type = static_cast(fd.raw_type); + gbs.push_back(std::move(gc)); + } + + auto resolve_slot = [&](const openads::sql::HavingCmp& ha) + -> std::int32_t { + for (std::size_t i = 0; i < slots.size(); ++i) { + if (slots[i].def.kind == ha.agg.kind && + slots[i].def.column == ha.agg.column) { + return static_cast(i); + } + } + if (ha.agg.kind == openads::sql::AggregateKind::CountStar) { + for (std::size_t i = 0; i < slots.size(); ++i) { + if (slots[i].def.kind == + openads::sql::AggregateKind::CountStar) { + return static_cast(i); + } + } + } + return -1; + }; + if (parsed.value().having) { + std::function( + const openads::sql::HavingExpr&)> validate; + validate = [&](const openads::sql::HavingExpr& n) + -> openads::util::Result { + using K = openads::sql::HavingExpr::Kind; + if (n.kind == K::And || n.kind == K::Or) { + for (auto& cn : n.children) { + auto r = validate(*cn); + if (!r) return r.error(); + } + return std::monostate{}; + } + if (n.kind == K::Not) return validate(*n.child); + if (resolve_slot(n.cmp) < 0) { + return openads::util::Error{ + openads::AE_PARSE_ERROR, 0, + "HAVING aggregate must match one in projection", + ""}; + } + return std::monostate{}; + }; + auto vr = validate(*parsed.value().having); + if (!vr) { + c->close_table(cth_h); + return fail(vr.error()); + } + } + + struct GroupAcc { + std::vector key_parts; + std::vector sum; + // Kahan compensation: summing hundreds of + // thousands of double-rounded terms drifts the + // display digits (SUM(Real*PRICE) read ...2047 + // where SAP shows ...2000). + std::vector sumc; + std::vector minv; + std::vector maxv; + // MIN/MAX over a non-numeric column compares decoded text. + std::vector txt; + std::vector count; + std::uint64_t row_count = 0; + }; + std::unordered_map groups; + std::vector insertion_order; + std::uint32_t crc3 = ctbl->record_count(); + for (std::uint32_t r = 1; r <= crc3; ++r) { + if (auto g = ctbl->goto_record(r); !g) continue; + if (ctbl->is_deleted()) continue; + if (!ctbl->passes_filter()) continue; + std::string key; + std::vector parts; + parts.reserve(gbs.size()); + for (auto& g : gbs) { + auto v = ctbl->read_field(g.field_index); + std::string raw = v ? v.value().as_string : std::string(); + if (raw.size() < g.length) + raw.append(g.length - raw.size(), ' '); + else if (raw.size() > g.length) raw.resize(g.length); + parts.push_back(raw); + key.append(raw); + key.push_back('\x1f'); + } + auto git = groups.find(key); + if (git == groups.end()) { + GroupAcc acc; + acc.key_parts = std::move(parts); + acc.sum.assign(slots.size(), 0.0); + acc.sumc.assign(slots.size(), 0.0); + acc.minv.assign(slots.size(), + std::numeric_limits::infinity()); + acc.maxv.assign(slots.size(), + -std::numeric_limits::infinity()); + acc.count.assign(slots.size(), 0); + acc.txt.assign(slots.size(), scriptbridge::TextMinMax{}); + git = groups.emplace(key, std::move(acc)).first; + insertion_order.push_back(key); + } + auto& acc = git->second; + ++acc.row_count; + for (std::size_t i = 0; i < slots.size(); ++i) { + if (slots[i].def.kind == + openads::sql::AggregateKind::CountStar) { + ++acc.count[i]; continue; + } + auto v = ctbl->read_field( + static_cast(slots[i].field_index)); + if (!v) continue; + if (slots[i].is_text) { + if (v.value().is_null) continue; + ++acc.count[i]; + acc.txt[i].feed(v.value().as_string); + continue; + } + double d = scriptbridge::agg_arg_value( + *ctbl, slots[i].field_index, slots[i].rhs_field, + slots[i].def, slots[i].arg_dec); + ++acc.count[i]; + { // Kahan-compensated accumulation (see GroupAcc note) + const double y2 = d - acc.sumc[i]; + const double t2 = acc.sum[i] + y2; + acc.sumc[i] = (t2 - acc.sum[i]) - y2; + acc.sum[i] = t2; + } + if (d < acc.minv[i]) acc.minv[i] = d; + if (d > acc.maxv[i]) acc.maxv[i] = d; + } + } + c->close_table(cth_h); + + auto agg_at = [&](const GroupAcc& acc, std::size_t si) -> double { + using K = openads::sql::AggregateKind; + switch (slots[si].def.kind) { + case K::CountStar: return static_cast(acc.row_count); + case K::Count: return static_cast(acc.count[si]); + case K::Sum: return acc.sum[si]; + case K::Avg: + return acc.count[si] + ? scriptbridge::avg_truncate( + acc.sum[si] / + static_cast(acc.count[si]), + static_cast(slots[si].decimals)) + : 0.0; + case K::Min: + return acc.count[si] ? acc.minv[si] : 0.0; + case K::Max: + return acc.count[si] ? acc.maxv[si] : 0.0; + } + return 0.0; + }; + std::function eval_having; + eval_having = [&](const openads::sql::HavingExpr& n, + const GroupAcc& acc) -> bool { + using K = openads::sql::HavingExpr::Kind; + if (n.kind == K::And) { + for (auto& cn : n.children) + if (!eval_having(*cn, acc)) return false; + return true; + } + if (n.kind == K::Or) { + for (auto& cn : n.children) + if (eval_having(*cn, acc)) return true; + return false; + } + if (n.kind == K::Not) return !eval_having(*n.child, acc); + std::int32_t si = resolve_slot(n.cmp); + if (si < 0) return false; + double v = agg_at(acc, static_cast(si)); + double rhs = n.cmp.num; + switch (n.cmp.op) { + case openads::sql::WhereOp::Eq: return v == rhs; + case openads::sql::WhereOp::Ne: return v != rhs; + case openads::sql::WhereOp::Lt: return v < rhs; + case openads::sql::WhereOp::Gt: return v > rhs; + case openads::sql::WhereOp::Le: return v <= rhs; + case openads::sql::WhereOp::Ge: return v >= rhs; + default: return false; + } + }; + + // Materialised through the shared ADT temp helper + // (docs/materialised-cursor-temps.md): the DBF this used to + // write truncated aliases to 11 chars. + // SAP parity (oracle-verified 07/18/2026): the result carries + // ONLY the projected aggregates -- group keys are not emitted + // unless projected. Columns are typed N(20,dp) (dp from the + // source field; COUNT dp=0), named alias / EXPR / EXPR_1 / …, + // and groups emit sorted ascending by group key. + auto jg_dp = [&](std::size_t i) -> int { + using K = openads::sql::AggregateKind; + switch (slots[i].def.kind) { + case K::CountStar: case K::Count: return 0; + // AVG keeps the SOURCE column's decimals (oracle + // 2026-07-21: money -> 4dp, integer -> 0dp; not 6); + // an arithmetic argument follows the operation. + default: return static_cast(slots[i].arg_dec); + } + }; + // A text MIN/MAX column is one width for every group: the widest + // decoded value seen anywhere (the source descriptor would + // truncate -- an ADT date is 4 bytes on disk, 8 decoded). + std::vector jg_textw(slots.size(), 0); + for (auto& kv : groups) + for (std::size_t i = 0; i < slots.size(); ++i) + if (kv.second.txt[i].w > jg_textw[i]) + jg_textw[i] = kv.second.txt[i].w; + auto jg_is_text = [&](std::size_t i) { + using K = openads::sql::AggregateKind; + return slots[i].is_text && (slots[i].def.kind == K::Min || + slots[i].def.kind == K::Max); + }; + // SAP's declared width per aggregate (see agg_result_width). + auto jg_w = [&](std::size_t i) -> std::uint16_t { + if (jg_is_text(i)) + return static_cast( + jg_textw[i] ? jg_textw[i] : 1); + return scriptbridge::agg_result_width(slots[i].def.kind, + slots[i].arg_len); + }; + std::size_t jg_stride = 0; + std::vector jg_cols; + jg_cols.reserve(slots.size()); + { + std::size_t unaliased = 0; + for (std::size_t i = 0; i < slots.size(); ++i) { + std::string nm = slots[i].def.alias; + if (nm.empty()) { + nm = unaliased == 0 + ? "EXPR" : "EXPR_" + std::to_string(unaliased); + ++unaliased; + } + jg_cols.push_back({std::move(nm), + jg_is_text(i) + ? (slots[i].is_date ? 'D' : 'C') + : 'N', + jg_w(i), + jg_is_text(i) + ? std::uint8_t{0} + : static_cast( + jg_dp(i))}); + jg_stride += jg_cols.back().len; + } + } + std::vector jg_file; + + std::sort(insertion_order.begin(), insertion_order.end()); + std::uint32_t jg_emitted = 0; + for (auto& key : insertion_order) { + auto& acc = groups[key]; + if (parsed.value().having) { + if (!eval_having(*parsed.value().having, acc)) continue; + } + for (std::size_t i = 0; i < slots.size(); ++i) { + const std::string* jtv = nullptr; + if (jg_is_text(i)) + jtv = (slots[i].def.kind == + openads::sql::AggregateKind::Min) + ? &acc.txt[i].mn : &acc.txt[i].mx; + const auto cell = scriptbridge::agg_cell_text( + slots[i].def.kind, jg_w(i), jg_dp(i), + agg_at(acc, i), jtv); + jg_file.insert(jg_file.end(), cell.begin(), cell.end()); + } + ++jg_emitted; + } + (void)jg_emitted; + return materialise_temp_adt(c, "_jgrp_", jg_cols, jg_file, + jg_stride, phCursor); + } + + // M10.23 -- JOIN + aggregate. Walk the merged cursor (already + // filtered by the outer WHERE) and replace it with a 1-row + // aggregate temp DBF before registering the user-visible + // handle. + if (!parsed.value().aggregates.empty()) { + struct AggSlot { + openads::sql::Aggregate def; + std::int32_t field_index = -1; + std::uint8_t decimals = 0; // source-field dp + std::uint16_t src_len = 0; // source-field width + bool is_text = false; // MIN/MAX on text + bool is_date = false; // date-sourced + std::int32_t rhs_field = -1; // SUM(a * b): b + std::uint8_t arg_dec = 0; // op-following dp + std::uint16_t arg_len = 0; // SAP arg width + }; + std::vector slots; + slots.reserve(parsed.value().aggregates.size()); + for (auto& a : parsed.value().aggregates) { + AggSlot slot; + slot.def = a; + if (a.kind != openads::sql::AggregateKind::CountStar) { + slot.field_index = jcol_index(a.column); + if (slot.field_index < 0) { + c->close_table(cth_h); + return fail(openads::AE_COLUMN_NOT_FOUND, a.column.c_str()); + } + { + const auto& sfd = ctbl->field_descriptor( + static_cast(slot.field_index)); + using FT = openads::drivers::DbfFieldType; + slot.decimals = (sfd.type == FT::AdtMoney || + sfd.type == FT::Currency) + ? 4 : sfd.decimals; // money: 4 implied + slot.src_len = sfd.length; + slot.is_text = + scriptbridge::agg_source_is_text(sfd.type); + slot.is_date = + scriptbridge::agg_source_is_date(sfd.type); + } + // SUM(a * b): resolve the right-hand column against the + // merged cursor too (R_ fallback included), and let the + // result scale follow the operation like the + // single-table path does. Reading only the left column + // summed SUM([real] * units) as SUM(real) - masked in + // the gate by a group whose units were all 1. + if (a.arg_expr && !a.arg_expr->rhs_is_literal) { + slot.rhs_field = jcol_index(a.arg_expr->rhs_column); + if (slot.rhs_field < 0) { + c->close_table(cth_h); + return fail(openads::AE_COLUMN_NOT_FOUND, + a.arg_expr->rhs_column.c_str()); + } + } + if (a.arg_expr) slot.is_text = false; + { + // SAP's declared shape for the argument (see + // agg_arg_shape). + const openads::drivers::DbfField* rfd = nullptr; + if (a.arg_expr && !a.arg_expr->rhs_is_literal && + slot.rhs_field >= 0) { + rfd = &ctbl->field_descriptor( + static_cast(slot.rhs_field)); + } + auto shp = scriptbridge::agg_arg_shape( + ctbl->field_descriptor( + static_cast(slot.field_index)), + a, rfd); + slot.arg_dec = shp.dec; + slot.arg_len = shp.len; + } + } + slots.push_back(std::move(slot)); + } + + std::vector sum(slots.size(), 0.0); + std::vector sumc(slots.size(), 0.0); // Kahan comp. + std::vector minv(slots.size(), + std::numeric_limits::infinity()); + std::vector maxv(slots.size(), + -std::numeric_limits::infinity()); + // MIN/MAX over a non-numeric column compares decoded text. + std::vector jtxt(slots.size()); + std::vector count(slots.size(), 0); + std::uint64_t row_count = 0; + std::uint32_t crc2 = ctbl->record_count(); + for (std::uint32_t r = 1; r <= crc2; ++r) { + if (auto g = ctbl->goto_record(r); !g) continue; + if (ctbl->is_deleted()) continue; + if (!ctbl->passes_filter()) continue; + ++row_count; + for (std::size_t i = 0; i < slots.size(); ++i) { + if (slots[i].def.kind == openads::sql::AggregateKind::CountStar) { + ++count[i]; continue; + } + auto v = ctbl->read_field( + static_cast(slots[i].field_index)); + if (!v) continue; + if (slots[i].is_text) { + if (v.value().is_null) continue; + ++count[i]; + jtxt[i].feed(v.value().as_string); + continue; + } + double d = scriptbridge::agg_arg_value( + *ctbl, slots[i].field_index, slots[i].rhs_field, + slots[i].def, slots[i].arg_dec); + ++count[i]; + { // Kahan-compensated accumulation + const double y2 = d - sumc[i]; + const double t2 = sum[i] + y2; + sumc[i] = (t2 - sum[i]) - y2; + sum[i] = t2; + } + if (d < minv[i]) minv[i] = d; + if (d > maxv[i]) maxv[i] = d; + } + } + c->close_table(cth_h); + + // Materialised through the shared ADT temp helper + // (docs/materialised-cursor-temps.md): the DBF this used to + // write truncated aliases to 10 chars. + // SAP parity: columns typed N(20,dp), named alias / EXPR / + // EXPR_1 / … (same rules as the grouped paths). + auto jagg_dp = [&](std::size_t i) -> int { + using K = openads::sql::AggregateKind; + switch (slots[i].def.kind) { + case K::CountStar: case K::Count: return 0; + // AVG keeps the SOURCE column's decimals (oracle + // 2026-07-21: money -> 4dp, integer -> 0dp; not 6); + // an arithmetic argument follows the operation. + default: return static_cast(slots[i].arg_dec); + } + }; + auto jagg_is_text = [&](std::size_t i) { + using K = openads::sql::AggregateKind; + return slots[i].is_text && (slots[i].def.kind == K::Min || + slots[i].def.kind == K::Max); + }; + // SAP's declared width per aggregate (see agg_result_width); a + // text MIN/MAX sizes to the widest decoded value instead. + auto jagg_w = [&](std::size_t i) -> std::uint16_t { + if (jagg_is_text(i)) + return static_cast( + jtxt[i].w ? jtxt[i].w : 1); + return scriptbridge::agg_result_width(slots[i].def.kind, + slots[i].arg_len); + }; + std::size_t jrow_stride = 0; + std::vector jcolspecs; + jcolspecs.reserve(slots.size()); + { + std::size_t unaliased = 0; + for (std::size_t i = 0; i < slots.size(); ++i) { + std::string nm = slots[i].def.alias; + if (nm.empty()) { + nm = unaliased == 0 + ? "EXPR" : "EXPR_" + std::to_string(unaliased); + ++unaliased; + } + jcolspecs.push_back({std::move(nm), + jagg_is_text(i) + ? (slots[i].is_date ? 'D' : 'C') + : 'N', + jagg_w(i), + jagg_is_text(i) + ? std::uint8_t{0} + : static_cast( + jagg_dp(i))}); + jrow_stride += jcolspecs.back().len; + } + } + std::vector agg_file; + agg_file.reserve(jrow_stride); + for (std::size_t i = 0; i < slots.size(); ++i) { + double v = 0.0; + using K = openads::sql::AggregateKind; + switch (slots[i].def.kind) { + case K::CountStar: + v = static_cast(row_count); break; + case K::Count: + v = static_cast(count[i]); break; + case K::Sum: + v = sum[i]; break; + case K::Avg: + v = count[i] + ? scriptbridge::avg_truncate( + sum[i] / static_cast(count[i]), + static_cast(slots[i].decimals)) + : 0.0; + break; + case K::Min: + v = count[i] ? minv[i] : 0.0; break; + case K::Max: + v = count[i] ? maxv[i] : 0.0; break; + } + const std::string* atv = nullptr; + if (jagg_is_text(i)) + atv = (slots[i].def.kind == + openads::sql::AggregateKind::Min) + ? &jtxt[i].mn : &jtxt[i].mx; + const auto cell = scriptbridge::agg_cell_text( + slots[i].def.kind, jagg_w(i), jagg_dp(i), v, atv); + agg_file.insert(agg_file.end(), cell.begin(), cell.end()); + } + return materialise_temp_adt(c, "_jagg_", jcolspecs, agg_file, + jrow_stride, phCursor); + } + + ADSHANDLE gh = to_ads_handle(s.registry.register_object(HandleKind::Table, ctbl)); + // The merged temp IS the user-visible cursor from here on: hand its + // on-disk lifetime to the cursor handle. + jtmp_guard.keep = true; + materialised_cursor_temps()[gh] = jtmp.stem; + // Apply the SELECT projection to the joined cursor (previously + // ignored -- a join always exposed every merged column). Left + // columns resolve by name; a right-table column that collides + // with a left name is reachable via its merged `R_` form. + std::vector jproj; + if (!parsed.value().projection.empty()) { + jproj.reserve(parsed.value().projection.size()); + for (const auto& col : parsed.value().projection) { + std::int32_t fidx = ctbl->field_index(col); + if (fidx < 0) { + // Full-length lookup -- the ADT temp carries the whole + // merged name (no DBF 10-char truncation any more). + fidx = ctbl->field_index("R_" + col); + } + if (fidx < 0) + return fail(openads::AE_COLUMN_NOT_FOUND, col.c_str()); + jproj.push_back(static_cast(fidx)); + } + } + // S4 -- DISTINCT / TOP / LIMIT [OFFSET] on the joined cursor + // (previously silently ignored). Same shape as the single-table + // M10.31/M10.32 block: operate on the post-WHERE / + // post-ORDER-BY traversal sequence; DISTINCT dedups by the + // projected columns (all merged columns for SELECT *). + if (parsed.value().distinct || parsed.value().limit >= 0 || + parsed.value().offset > 0) { + std::vector seq; + if (ctbl->has_recno_sequence()) { + seq = ctbl->recno_sequence(); + } else { + std::uint32_t rcount = ctbl->record_count(); + seq.reserve(rcount); + for (std::uint32_t r = 1; r <= rcount; ++r) { + if (auto g = ctbl->goto_record(r); !g) continue; + if (ctbl->is_deleted()) continue; + if (!ctbl->passes_filter()) continue; + seq.push_back(r); + } + } + if (parsed.value().distinct) { + std::vector didx = jproj; + if (didx.empty()) { + std::uint16_t nf = ctbl->field_count(); + didx.reserve(nf); + for (std::uint16_t i = 0; i < nf; ++i) + didx.push_back(i); + } + std::unordered_set seen2; + std::vector dedup; + dedup.reserve(seq.size()); + for (auto r : seq) { + if (auto g = ctbl->goto_record(r); !g) continue; + std::string key; + for (auto fi : didx) { + auto v = ctbl->read_field(fi); + if (v) key.append(v.value().as_string); + key.push_back('\x1f'); + } + if (seen2.insert(std::move(key)).second) + dedup.push_back(r); + } + seq = std::move(dedup); + } + std::int64_t off = parsed.value().offset > 0 + ? parsed.value().offset : 0; + if (off > static_cast(seq.size())) { + seq.clear(); + } else if (off > 0) { + seq.erase(seq.begin(), seq.begin() + + static_cast:: + difference_type>(off)); + } + if (parsed.value().limit >= 0 && + static_cast(parsed.value().limit) < + seq.size()) { + seq.resize(static_cast( + parsed.value().limit)); + } + ctbl->clear_filter(); + ctbl->set_recno_sequence(std::move(seq)); + } + if (!jproj.empty()) { + // Name the result columns the way SAP does before jproj is + // moved from - a right-table column reached through its merged + // `R_` form must still answer to the name the user wrote. + cursor_aliases()[gh] = + build_projection_aliases(parsed.value(), jproj.size()); + cursor_projections()[gh] = std::move(jproj); + } + *phCursor = gh; + return ok(); + } + + // M10.46 -- derived table: `FROM (SELECT ...)`. Recursively run + // the inner SELECT first; the resulting cursor's underlying + // engine::Table becomes the source for the outer clauses. + auto& s = state(); + openads::engine::Table* tbl = nullptr; + ADSHANDLE derived_cur = 0; + if (!parsed.value().derived_sql.empty()) { + std::vector selbuf(parsed.value().derived_sql.size() + 1); + std::memcpy(selbuf.data(), + parsed.value().derived_sql.c_str(), + parsed.value().derived_sql.size() + 1); + UNSIGNED32 rrc = + AdsExecuteSQLDirect(hStatement, selbuf.data(), &derived_cur); + if (rrc != 0) return rrc; + std::lock_guard lk(s.mu); + tbl = s.registry.lookup( + derived_cur, HandleKind::Table); + if (!tbl) return fail(openads::AE_INTERNAL_ERROR, + "derived table cursor lookup"); + // continue, lock_guard scoped to whole function below by + // dropping out -- we want to hold lock through registration. + // Since `lk` would die at end of this `if` block, re-take. + } + std::lock_guard lk(s.mu); + Handle table_handle = 0; + if (!tbl) { + auto th = open_or_sys(parsed.value().table, + stmt_table_type(*it->second), + stmt_open_mode(*it->second, false), + stmt_locking_mode(*it->second)); + if (!th) return fail(th.error()); + table_handle = th.value(); + tbl = c->lookup_table(table_handle); + if (!tbl) return fail(openads::AE_INTERNAL_ERROR, "post-open"); + } + (void)table_handle; + + // M10.10: aggregate query -- walk matching rows, compute the + // aggregate accumulators, materialise a 1-row temp DBF with one + // numeric column per aggregate, and return a cursor on it. + if (!parsed.value().aggregates.empty()) { + // Resolve each aggregate's column index up front. + struct AggSlot { + openads::sql::Aggregate def; + std::int32_t field_index = -1; // -1 for COUNT(*) + std::uint8_t decimals = 0; // source-field dp + std::uint16_t src_len = 0; // source-field width + std::int32_t rhs_field = -1; // SUM(a * b): b + // Effective scale/width of the ARGUMENT. For a bare column these + // are the source's; for `a b` they follow the operation -- + // SAP renders SUM(Real * PRICE) with 4 decimals for two N(..,2) + // columns, so multiplication adds the scales. + std::uint8_t arg_dec = 0; + std::uint16_t arg_len = 0; + // MIN/MAX over a non-numeric column (a date, a name) has to + // compare the DECODED TEXT, not as_double. Accumulating a date + // as a double gave 0 for every row, so MIN(PAY_DATE) reported + // "0" instead of the earliest date. + bool is_text = false; + bool is_date = false; // date-sourced + }; + std::vector slots; + slots.reserve(parsed.value().aggregates.size()); + for (auto& a : parsed.value().aggregates) { + AggSlot slot; + slot.def = a; + if (a.kind != openads::sql::AggregateKind::CountStar) { + slot.field_index = tbl->field_index(a.column); + if (slot.field_index < 0) { + if (table_handle != 0) c->close_table(table_handle); + return fail(openads::AE_COLUMN_NOT_FOUND, a.column.c_str()); + } + { + const auto& sfd = tbl->field_descriptor( + static_cast(slot.field_index)); + using FT = openads::drivers::DbfFieldType; + slot.decimals = (sfd.type == FT::AdtMoney || + sfd.type == FT::Currency) + ? 4 : sfd.decimals; // money: 4 implied + slot.src_len = sfd.length; + slot.is_text = + scriptbridge::agg_source_is_text(sfd.type); + slot.is_date = + scriptbridge::agg_source_is_date(sfd.type); + // SUM(a * b): resolve the right-hand column too. An + // arithmetic argument is always numeric, so it never + // takes the text MIN/MAX path. + if (a.arg_expr && !a.arg_expr->rhs_is_literal) { + slot.rhs_field = + tbl->field_index(a.arg_expr->rhs_column); + if (slot.rhs_field < 0) { + if (table_handle != 0) + c->close_table(table_handle); + return fail(openads::AE_COLUMN_NOT_FOUND, + a.arg_expr->rhs_column.c_str()); + } + } + if (a.arg_expr) slot.is_text = false; + { + // SAP's declared shape for the argument -- oracle- + // derived rules in agg_arg_shape (byte-identical + // widths incl. the bare-column case). + const openads::drivers::DbfField* rfd = nullptr; + if (a.arg_expr && !a.arg_expr->rhs_is_literal && + slot.rhs_field >= 0) { + rfd = &tbl->field_descriptor( + static_cast(slot.rhs_field)); + } + auto shp = scriptbridge::agg_arg_shape( + tbl->field_descriptor( + static_cast(slot.field_index)), + a, rfd); + slot.arg_dec = shp.dec; + slot.arg_len = shp.len; + } + } + } + slots.push_back(std::move(slot)); + } + + // Build the WHERE filter (same shape as the SELECT branch + // below -- but the predicate compiles independently here so + // the aggregate path doesn't depend on that block's lambdas). + std::function filter; + if (parsed.value().where) { + using Pred = std::function; + std::function( + const openads::sql::WhereExpr&)> compile; + compile = [&](const openads::sql::WhereExpr& node) + -> openads::util::Result { + using Kind = openads::sql::WhereExpr::Kind; + if (node.kind == Kind::And || node.kind == Kind::Or) { + std::vector ks; + for (auto& cn : node.children) { + auto r = compile(*cn); + if (!r) return r.error(); + ks.push_back(std::move(r).value()); + } + bool is_and = (node.kind == Kind::And); + return Pred{[ks = std::move(ks), is_and] + (openads::engine::Table& t) { + if (is_and) { + for (auto& k : ks) if (!k(t)) return false; + return true; + } + for (auto& k : ks) if (k(t)) return true; + return false; + }}; + } + if (node.kind == Kind::Not) { + auto inner = compile(*node.child); + if (!inner) return inner.error(); + return Pred{[p = std::move(inner).value()] + (openads::engine::Table& t){return !p(t);}}; + } + if (node.kind == Kind::In) { + // S4 -- IN under an aggregate WHERE (COUNT(*) WHERE x + // IN (...)); literal lists only, CICHAR folds case. + std::int32_t fidx = + tbl->field_index(node.in_clause.column); + if (fidx < 0) { + return openads::util::Error{ + openads::AE_COLUMN_NOT_FOUND, 0, + node.in_clause.column.c_str(), ""}; + } + std::uint16_t infi = static_cast(fidx); + bool in_ci = field_is_cichar(*tbl, infi); + auto fold = [in_ci](std::string s2) { + while (!s2.empty() && s2.back() == ' ') + s2.pop_back(); + if (in_ci) + for (char& ch : s2) + ch = static_cast(std::toupper( + static_cast(ch))); + return s2; + }; + auto set = + std::make_shared>(); + for (const auto& l2 : node.in_clause.literals) + set->insert(fold(l2)); + return Pred{[infi, set, fold] + (openads::engine::Table& t) { + auto v = t.read_field(infi); + if (!v) return false; + return set->count(fold(v.value().as_string)) > 0; + }}; + } + const auto& w = node.cmp; + std::int32_t fidx = tbl->field_index(w.column); + if (fidx < 0) { + return openads::util::Error{ + openads::AE_COLUMN_NOT_FOUND, 0, + w.column.c_str(), ""}; + } + std::uint16_t fi = static_cast(fidx); + openads::sql::WhereOp op = w.op; + std::string lit = w.literal; + bool is_num = w.is_numeric; + double num = w.number; + openads::sql::WhereFn lhs_fn = w.lhs_fn; + bool ci_f = field_is_cichar(*tbl, fi); + return Pred{[fi, op, lit, is_num, num, lhs_fn, ci_f] + (openads::engine::Table& t) { + auto v = t.read_field(fi); + if (!v) return false; + if (op == openads::sql::WhereOp::Like) { + auto sv = apply_where_fn(v.value().as_string, lhs_fn); + while (!sv.empty() && sv.back() == ' ') sv.pop_back(); + return sql_like_match_t(sv, lit, ci_f); + } + int cmp = 0; + if (is_num) { + double d = v.value().as_double; + if (d < num) cmp = -1; + else if (d > num) cmp = 1; + } else { + cmp = where_str_cmp( + apply_where_fn(v.value().as_string, lhs_fn), + lit, ci_f); + } + switch (op) { + case openads::sql::WhereOp::Eq: return cmp == 0; + case openads::sql::WhereOp::Ne: return cmp != 0; + case openads::sql::WhereOp::Lt: return cmp < 0; + case openads::sql::WhereOp::Gt: return cmp > 0; + case openads::sql::WhereOp::Le: return cmp <= 0; + case openads::sql::WhereOp::Ge: return cmp >= 0; + case openads::sql::WhereOp::Contains: return false; + default: return false; + } + return false; + }}; + }; + auto compiled = compile(*parsed.value().where); + if (!compiled) { + if (table_handle != 0) c->close_table(table_handle); + return fail(compiled.error()); + } + filter = std::move(compiled).value(); + } + + // M10.25 -- `GROUP BY [, ...] [HAVING op num]`. + // Walk matching rows, hash by group-key tuple, accumulate per + // group, then emit one row per group (passing HAVING) into a + // multi-row temp DBF cursor. Schema: original group-by + // columns (preserving type + length) followed by COL1..COLn + // C(30) cells for each aggregate. + if (!parsed.value().group_by.empty()) { + struct GBCol { + std::uint16_t field_index; + std::uint8_t length; + std::string name; + std::uint8_t raw_type; + }; + std::vector gbs; + gbs.reserve(parsed.value().group_by.size()); + for (auto& gname : parsed.value().group_by) { + std::int32_t fi = tbl->field_index(gname); + if (fi < 0) { + if (table_handle != 0) c->close_table(table_handle); + return fail(openads::AE_COLUMN_NOT_FOUND, gname.c_str()); + } + const auto& fd = tbl->field_descriptor( + static_cast(fi)); + GBCol gc; + gc.field_index = static_cast(fi); + gc.length = static_cast(fd.length); + gc.name = gname; + gc.raw_type = static_cast(fd.raw_type); + gbs.push_back(std::move(gc)); + } + + // M10.30 -- HAVING is now a boolean tree of HavingCmp leaves. + // Resolve each leaf to a slot at compile time (validation + // only); per-group evaluation re-walks the tree. + auto resolve_slot = [&](const openads::sql::HavingCmp& ha) + -> std::int32_t { + for (std::size_t i = 0; i < slots.size(); ++i) { + if (slots[i].def.kind == ha.agg.kind && + slots[i].def.column == ha.agg.column) { + return static_cast(i); + } + } + if (ha.agg.kind == openads::sql::AggregateKind::CountStar) { + for (std::size_t i = 0; i < slots.size(); ++i) { + if (slots[i].def.kind == + openads::sql::AggregateKind::CountStar) { + return static_cast(i); + } + } + } + return -1; + }; + if (parsed.value().having) { + std::function( + const openads::sql::HavingExpr&)> validate; + validate = [&](const openads::sql::HavingExpr& n) + -> openads::util::Result { + using K = openads::sql::HavingExpr::Kind; + if (n.kind == K::And || n.kind == K::Or) { + for (auto& cn : n.children) { + auto r = validate(*cn); + if (!r) return r.error(); + } + return std::monostate{}; + } + if (n.kind == K::Not) return validate(*n.child); + if (resolve_slot(n.cmp) < 0) { + return openads::util::Error{ + openads::AE_PARSE_ERROR, 0, + "HAVING aggregate must match one in projection", + ""}; + } + return std::monostate{}; + }; + auto vr = validate(*parsed.value().having); + if (!vr) { + if (table_handle != 0) c->close_table(table_handle); + return fail(vr.error()); + } + } + + struct GroupAcc { + std::vector key_parts; + std::vector sum; + // Kahan compensation: summing hundreds of + // thousands of double-rounded terms drifts the + // display digits (SUM(Real*PRICE) read ...2047 + // where SAP shows ...2000). + std::vector sumc; + std::vector minv; + std::vector maxv; + // MIN/MAX over a non-numeric column compares decoded text. + std::vector txt; + std::vector count; + std::uint64_t row_count = 0; + }; + std::unordered_map groups; + std::vector insertion_order; + std::uint32_t rcount = tbl->record_count(); + for (std::uint32_t r = 1; r <= rcount; ++r) { + if (auto g = tbl->goto_record(r); !g) continue; + if (!tbl->show_deleted_records() && + tbl->is_deleted()) continue; + // A derived-table / VIEW source arrives as a live cursor + // with its own filter (the view's WHERE) -- rows it hides + // must not reach the aggregate. + if (!tbl->passes_filter()) continue; + if (filter && !filter(*tbl)) continue; + std::string key; + std::vector parts; + parts.reserve(gbs.size()); + for (auto& g : gbs) { + auto v = tbl->read_field(g.field_index); + std::string raw = v ? v.value().as_string : std::string(); + if (raw.size() < g.length) raw.append(g.length - raw.size(), ' '); + else if (raw.size() > g.length) raw.resize(g.length); + parts.push_back(raw); + key.append(raw); + key.push_back('\x1f'); + } + auto git = groups.find(key); + if (git == groups.end()) { + GroupAcc acc; + acc.key_parts = std::move(parts); + acc.sum.assign(slots.size(), 0.0); + acc.sumc.assign(slots.size(), 0.0); + acc.minv.assign(slots.size(), + std::numeric_limits::infinity()); + acc.maxv.assign(slots.size(), + -std::numeric_limits::infinity()); + acc.count.assign(slots.size(), 0); + acc.txt.assign(slots.size(), scriptbridge::TextMinMax{}); + git = groups.emplace(key, std::move(acc)).first; + insertion_order.push_back(key); + } + auto& acc = git->second; + ++acc.row_count; + for (std::size_t i = 0; i < slots.size(); ++i) { + if (slots[i].def.kind == + openads::sql::AggregateKind::CountStar) { + ++acc.count[i]; + continue; + } + auto v = tbl->read_field( + static_cast(slots[i].field_index)); + if (!v) continue; + if (slots[i].is_text) { + if (v.value().is_null) continue; + ++acc.count[i]; + acc.txt[i].feed(v.value().as_string); + continue; + } + // SUM(a * b): evaluate the expression like the scalar + // path does. Reading only the bare left column summed + // SUM([real] * units) as SUM(real) -- invisible in the + // gate's bounded group, whose units were all 1, and + // caught the day the unbounded query first completed. + double d = scriptbridge::agg_arg_value( + *tbl, slots[i].field_index, slots[i].rhs_field, + slots[i].def, slots[i].arg_dec); + ++acc.count[i]; + { // Kahan-compensated accumulation (see GroupAcc note) + const double y2 = d - acc.sumc[i]; + const double t2 = acc.sum[i] + y2; + acc.sumc[i] = (t2 - acc.sum[i]) - y2; + acc.sum[i] = t2; + } + if (d < acc.minv[i]) acc.minv[i] = d; + if (d > acc.maxv[i]) acc.maxv[i] = d; + } + } + if (table_handle != 0) c->close_table(table_handle); + + // A text MIN/MAX column must have ONE width across every group, so + // take the widest decoded value seen anywhere. Sizing from the + // source descriptor would truncate: an ADT date is 4 bytes on disk + // but 8 characters decoded. + std::vector textw(slots.size(), 0); + for (auto& kv : groups) + for (std::size_t i = 0; i < slots.size(); ++i) + if (kv.second.txt[i].w > textw[i]) + textw[i] = kv.second.txt[i].w; + auto slot_is_text = [&](std::size_t i) { + using K = openads::sql::AggregateKind; + return slots[i].is_text && + (slots[i].def.kind == K::Min || + slots[i].def.kind == K::Max); + }; + + auto agg_at = [&](const GroupAcc& acc, std::size_t si) -> double { + using K = openads::sql::AggregateKind; + switch (slots[si].def.kind) { + case K::CountStar: return static_cast(acc.row_count); + case K::Count: return static_cast(acc.count[si]); + case K::Sum: return acc.sum[si]; + case K::Avg: + return acc.count[si] + ? scriptbridge::avg_truncate( + acc.sum[si] / + static_cast(acc.count[si]), + static_cast(slots[si].decimals)) + : 0.0; + case K::Min: + return acc.count[si] ? acc.minv[si] : 0.0; + case K::Max: + return acc.count[si] ? acc.maxv[si] : 0.0; + } + return 0.0; + }; + + // Materialised through the shared ADT temp helper + // (docs/materialised-cursor-temps.md): the DBF this used to + // write truncated aliases and group-key names to 10 chars. + // SAP parity: aggregate columns are N(20,dp), named alias / + // EXPR / EXPR_1 / …; group-key columns are emitted in their + // SELECT-list position (`SELECT col, COUNT(*) … GROUP BY col`) + // with the source field's type/length. Groups emit sorted + // ascending by group key. + auto grp_dp = [&](std::size_t i) -> int { + using K = openads::sql::AggregateKind; + switch (slots[i].def.kind) { + case K::CountStar: case K::Count: return 0; + // AVG keeps the SOURCE column's decimals (oracle + // 2026-07-21: money -> 4dp, integer -> 0dp; not 6); + // an arithmetic argument follows the operation. + default: return static_cast(slots[i].arg_dec); + } + }; + // Ordered output columns from the projection: an `$AGG_` + // placeholder is aggregate slot n; anything else must be a + // group-by key column. + struct GOut { + bool is_agg = false; + std::size_t idx = 0; // slot index / gbs index + std::string name; + char type = 'N'; + std::uint8_t len = 20; + std::uint8_t dec = 0; + }; + auto ci_eq_g = [](const std::string& x, const std::string& y) { + if (x.size() != y.size()) return false; + for (std::size_t z = 0; z < x.size(); ++z) + if (std::toupper(static_cast(x[z])) != + std::toupper(static_cast(y[z]))) + return false; + return true; + }; + std::vector gouts; + { + std::size_t unaliased = 0; + std::size_t pj_pos = 0; + for (const auto& pj : parsed.value().projection) { + // Positional AS alias for this projection item (an + // aggregate's alias travels in def.alias instead). + const std::string* pj_alias = nullptr; + for (const auto& pa : parsed.value().projection_aliases) + if (pa.first == pj_pos) { pj_alias = &pa.second; break; } + ++pj_pos; + GOut o; + if (pj.rfind("$AGG_", 0) == 0) { + o.is_agg = true; + o.idx = static_cast( + std::stoul(pj.substr(5))); + std::string nm = slots[o.idx].def.alias; + if (nm.empty()) { + nm = unaliased == 0 ? "EXPR" + : "EXPR_" + std::to_string(unaliased); + ++unaliased; + } + o.name = nm; + const bool tx_o = slot_is_text(o.idx); + o.type = tx_o + ? (slots[o.idx].is_date ? 'D' : 'C') + : 'N'; + o.len = tx_o + ? static_cast( + textw[o.idx] ? textw[o.idx] : 1) + : static_cast( + scriptbridge::agg_result_width( + slots[o.idx].def.kind, + slots[o.idx].arg_len)); + o.dec = tx_o ? 0 + : static_cast(grp_dp(o.idx)); + } else { + std::int32_t gi = -1; + for (std::size_t j = 0; j < gbs.size(); ++j) + if (ci_eq_g(gbs[j].name, pj)) { + gi = static_cast(j); break; + } + if (gi < 0) { + if (table_handle != 0) + c->close_table(table_handle); + return fail(openads::AE_PARSE_ERROR, + ("SELECT column must be a GROUP BY key: " + + pj).c_str()); + } + o.is_agg = false; + o.idx = static_cast(gi); + // SAP names a group-key column from the SELECT + // list: an AS alias wins, else the spelling as + // written (constraint 4 in + // docs/materialised-cursor-temps.md). The DBF-era + // truncation used to mask this. + o.name = pj_alias ? *pj_alias : pj; + o.type = gbs[o.idx].raw_type + ? static_cast(gbs[o.idx].raw_type) + : 'C'; + o.len = gbs[o.idx].length; + o.dec = 0; + } + gouts.push_back(std::move(o)); + } + // Defensive: an aggregate query always has $AGG_ items, but + // fall back to all slots if somehow none were recorded. + if (gouts.empty()) { + for (std::size_t i = 0; i < slots.size(); ++i) { + GOut o; + o.is_agg = true; o.idx = i; + o.name = slots[i].def.alias.empty() + ? (i == 0 ? "EXPR" + : "EXPR_" + std::to_string(i)) + : slots[i].def.alias; + const bool tx_i = slot_is_text(i); + o.type = tx_i + ? (slots[i].is_date ? 'D' : 'C') + : 'N'; + o.len = tx_i + ? static_cast( + textw[i] ? textw[i] : 1) + : static_cast( + scriptbridge::agg_result_width( + slots[i].def.kind, slots[i].arg_len)); + o.dec = tx_i ? 0 + : static_cast(grp_dp(i)); + gouts.push_back(std::move(o)); + } + } + } + std::size_t grow_stride = 0; + std::vector gcols; + gcols.reserve(gouts.size()); + for (const auto& o : gouts) { + gcols.push_back({o.name, o.type, o.len, o.dec}); + grow_stride += gcols.back().len; + } + std::vector grows; + + std::function eval_having; + eval_having = [&](const openads::sql::HavingExpr& n, + const GroupAcc& acc) -> bool { + using K = openads::sql::HavingExpr::Kind; + if (n.kind == K::And) { + for (auto& cn : n.children) + if (!eval_having(*cn, acc)) return false; + return true; + } + if (n.kind == K::Or) { + for (auto& cn : n.children) + if (eval_having(*cn, acc)) return true; + return false; + } + if (n.kind == K::Not) return !eval_having(*n.child, acc); + std::int32_t si = resolve_slot(n.cmp); + if (si < 0) return false; + double v = agg_at(acc, static_cast(si)); + double rhs = n.cmp.num; + switch (n.cmp.op) { + case openads::sql::WhereOp::Eq: return v == rhs; + case openads::sql::WhereOp::Ne: return v != rhs; + case openads::sql::WhereOp::Lt: return v < rhs; + case openads::sql::WhereOp::Gt: return v > rhs; + case openads::sql::WhereOp::Le: return v <= rhs; + case openads::sql::WhereOp::Ge: return v >= rhs; + default: return false; + } + }; + + std::sort(insertion_order.begin(), insertion_order.end()); + std::uint32_t emitted = 0; + for (auto& key : insertion_order) { + auto& acc = groups[key]; + if (parsed.value().having) { + if (!eval_having(*parsed.value().having, acc)) continue; + } + for (const auto& o : gouts) { + if (o.is_agg) { + const bool tx = slot_is_text(o.idx); + const std::string* tv = nullptr; + if (tx) tv = (slots[o.idx].def.kind == + openads::sql::AggregateKind::Min) + ? &acc.txt[o.idx].mn : &acc.txt[o.idx].mx; + const auto cell = scriptbridge::agg_cell_text( + slots[o.idx].def.kind, o.len, + static_cast(o.dec), agg_at(acc, o.idx), tv); + grows.insert(grows.end(), cell.begin(), cell.end()); + } else { + // Group-key column: the stored key part, already + // padded to gbs[idx].length at accumulation time. + std::string kp = o.idx < acc.key_parts.size() + ? acc.key_parts[o.idx] : std::string(); + for (std::uint8_t b = 0; b < o.len; ++b) + grows.push_back(b < kp.size() + ? static_cast(kp[b]) : ' '); + } + } + ++emitted; + } + (void)emitted; + return materialise_temp_adt(c, "_grp_", gcols, grows, + grow_stride, phCursor); + } + + // M10.54 -- compile each slot's optional FILTER. Subset: + // Cmp / AND / OR / NOT (full WHERE support left to follow-up). + using AggPred = std::function; + std::vector slot_preds(slots.size()); + for (std::size_t i = 0; i < slots.size(); ++i) { + if (!parsed.value().aggregates[i].filter) continue; + std::function( + const openads::sql::WhereExpr&)> cf; + cf = [&](const openads::sql::WhereExpr& n) + -> openads::util::Result { + using K = openads::sql::WhereExpr::Kind; + if (n.kind == K::And || n.kind == K::Or) { + std::vector ks; + for (auto& cn : n.children) { + auto r = cf(*cn); + if (!r) return r.error(); + ks.push_back(std::move(r).value()); + } + bool is_and = (n.kind == K::And); + return AggPred{[ks = std::move(ks), is_and] + (openads::engine::Table& t) { + if (is_and) { + for (auto& k : ks) if (!k(t)) return false; + return true; + } + for (auto& k : ks) if (k(t)) return true; + return false; + }}; + } + if (n.kind == K::Not) { + auto inner = cf(*n.child); + if (!inner) return inner.error(); + return AggPred{[p = std::move(inner).value()] + (openads::engine::Table& t) + { return !p(t); }}; + } + if (n.kind == K::In) { + std::int32_t fidx = tbl->field_index(n.in_clause.column); + if (fidx < 0) return openads::util::Error{ + openads::AE_COLUMN_NOT_FOUND, 0, + n.in_clause.column.c_str(), ""}; + std::uint16_t fi2 = static_cast(fidx); + bool ci_f = field_is_cichar(*tbl, fi2); + auto fold = [ci_f](std::string s2) { + if (ci_f) + for (char& ch : s2) + ch = static_cast(std::toupper( + static_cast(ch))); + return s2; + }; + auto set = std::make_shared>(); + for (const auto& l2 : n.in_clause.literals) + set->insert(fold(l2)); + return AggPred{[fi2, set, fold](openads::engine::Table& t) { + auto v = t.read_field(fi2); + if (!v) return false; + auto sv = v.value().as_string; + while (!sv.empty() && sv.back() == ' ') sv.pop_back(); + return set->count(fold(sv)) > 0; + }}; + } + if (n.kind != K::Cmp) { + return openads::util::Error{ + openads::AE_FUNCTION_NOT_AVAILABLE, 0, + "aggregate FILTER supports Cmp/AND/OR/NOT/IN only", ""}; + } + const auto& w = n.cmp; + std::int32_t fi = tbl->field_index(w.column); + if (fi < 0) return openads::util::Error{ + openads::AE_COLUMN_NOT_FOUND, 0, w.column.c_str(), ""}; + std::uint16_t f = static_cast(fi); + openads::sql::WhereOp op = w.op; + std::string lit = w.literal; + std::string lit2 = w.literal2; + bool is_num = w.is_numeric; + double num = w.number; + double num2 = w.number2; + std::shared_ptr> contains_hits; + if (op == openads::sql::WhereOp::Contains) { + namespace fs = std::filesystem; + fs::path fts_path = + fs::path(tbl->path()).replace_extension(".fts"); + auto loaded = openads::engine::Fts::load(fts_path.string()); + if (loaded) { + openads::engine::FtsOptions opts; + auto hits = openads::engine::Fts::search( + loaded.value(), lit, opts); + contains_hits = + std::make_shared>( + hits.begin(), hits.end()); + } + } + openads::sql::WhereFn lhs_fn = w.lhs_fn; + bool ci_f = field_is_cichar(*tbl, f); + return AggPred{[f, op, lit, lit2, is_num, num, num2, + contains_hits, lhs_fn, ci_f] + (openads::engine::Table& t) { + if (op == openads::sql::WhereOp::Contains) { + if (!contains_hits) return false; + return contains_hits->find(t.recno()) != + contains_hits->end(); + } + auto v = t.read_field(f); + if (!v) return false; + if (op == openads::sql::WhereOp::IsNull || + op == openads::sql::WhereOp::IsNotNull) { + bool null_ish = t.is_field_null(f); + if (!null_ish) { + auto sv = v.value().as_string; + while (!sv.empty() && sv.back() == ' ') sv.pop_back(); + null_ish = sv.empty(); + } + return op == openads::sql::WhereOp::IsNull + ? null_ish : !null_ish; + } + if (op == openads::sql::WhereOp::Between) { + if (is_num) { + double d = v.value().as_double; + return d >= num && d <= num2; + } + auto sv = apply_where_fn(v.value().as_string, lhs_fn); + return where_str_cmp(sv, lit, ci_f) >= 0 && + where_str_cmp(sv, lit2, ci_f) <= 0; + } + if (op == openads::sql::WhereOp::Like) { + auto sv = apply_where_fn(v.value().as_string, lhs_fn); + while (!sv.empty() && sv.back() == ' ') sv.pop_back(); + return sql_like_match_t(sv, lit, ci_f); + } + int cmp = 0; + if (is_num) { + double d = v.value().as_double; + if (d < num) cmp = -1; + else if (d > num) cmp = 1; + } else { + cmp = where_str_cmp( + apply_where_fn(v.value().as_string, lhs_fn), + lit, ci_f); + } + switch (op) { + case openads::sql::WhereOp::Eq: return cmp == 0; + case openads::sql::WhereOp::Ne: return cmp != 0; + case openads::sql::WhereOp::Lt: return cmp < 0; + case openads::sql::WhereOp::Gt: return cmp > 0; + case openads::sql::WhereOp::Le: return cmp <= 0; + case openads::sql::WhereOp::Ge: return cmp >= 0; + default: return false; + } + }}; + }; + auto p = cf(*parsed.value().aggregates[i].filter); + if (!p) return fail(p.error()); + slot_preds[i] = std::move(p).value(); + } + + // Walk matching rows, accumulate per slot. + std::vector sum(slots.size(), 0.0); + std::vector sumc(slots.size(), 0.0); // Kahan compensation + std::vector minv(slots.size(), + std::numeric_limits::infinity()); + std::vector maxv(slots.size(), + -std::numeric_limits::infinity()); + // COUNT(DISTINCT col): the distinct values seen, per slot. + std::vector> distinct_seen( + slots.size()); + // Parallel text accumulators for MIN/MAX over a non-numeric column. + // Dates decode to "YYYYMMDD", which orders lexicographically exactly + // as it does chronologically, so a plain string compare is correct. + std::vector minsv(slots.size()); + std::vector maxsv(slots.size()); + std::vector textw(slots.size(), 0); + std::vector count(slots.size(), 0); + std::uint64_t row_count = 0; + std::uint32_t rcount = tbl->record_count(); + for (std::uint32_t r = 1; r <= rcount; ++r) { + if (auto g = tbl->goto_record(r); !g) continue; + // SAP's SQL honours AdsShowDeleted (default TRUE): deleted DBF + // rows are counted, filtered, ordered, UPDATEd and copied like + // live rows (oracle-probed on users.dbf: COUNT 18 not 10; an + // UPDATE touches all 18; SELECT INTO copies 18). Every SQL + // walk therefore skips deleted rows ONLY when the flag hides + // them; the navigational paths always worked this way. ADT + // never surfaces deleted rows, so this is DBF-only by nature. + if (!tbl->show_deleted_records() && + tbl->is_deleted()) continue; + // Same rule as the grouped walk: honour a view/derived + // cursor's own filter. + if (!tbl->passes_filter()) continue; + if (filter && !filter(*tbl)) continue; + ++row_count; + for (std::size_t i = 0; i < slots.size(); ++i) { + if (slot_preds[i] && !slot_preds[i](*tbl)) continue; + if (slots[i].def.kind == openads::sql::AggregateKind::CountStar) { + ++count[i]; + continue; + } + auto v = tbl->read_field( + static_cast(slots[i].field_index)); + if (!v) continue; + if (slots[i].def.distinct) { + // COUNT(DISTINCT col) counts values, not rows. A blank IS + // a distinct value to SAP: COUNT(DISTINCT insurance) over + // mp returns 58 and only 57 of those are non-blank. Only + // NULL is excluded. + if (v.value().is_null) continue; + distinct_seen[i].insert(v.value().as_string); + continue; + } + ++count[i]; + if (slots[i].is_text) { + // Skip NULLs so they cannot win a MIN as an empty string. + if (v.value().is_null) { --count[i]; continue; } + const std::string& sv = v.value().as_string; + if (sv.size() > textw[i]) textw[i] = sv.size(); + if (minsv[i].empty() || sv < minsv[i]) minsv[i] = sv; + if (maxsv[i].empty() || sv > maxsv[i]) maxsv[i] = sv; + continue; + } + double d = scriptbridge::agg_arg_value( + *tbl, slots[i].field_index, slots[i].rhs_field, + slots[i].def, slots[i].arg_dec); + { // Kahan-compensated accumulation + const double y2 = d - sumc[i]; + const double t2 = sum[i] + y2; + sumc[i] = (t2 - sum[i]) - y2; + sum[i] = t2; + } + if (d < minv[i]) minv[i] = d; + if (d > maxv[i]) maxv[i] = d; + } + } + if (table_handle != 0) c->close_table(table_handle); + + // Materialise the 1-row result through the shared ADT temp helper + // (docs/materialised-cursor-temps.md): the DBF this used to write + // truncated any alias over 10 characters. + // SAP parity: columns typed N(20,dp), named alias / EXPR / EXPR_1 / + // … (oracle-verified naming; unaliased aggregates count up). + auto agg_dp = [&](std::size_t i) -> int { + using K = openads::sql::AggregateKind; + switch (slots[i].def.kind) { + case K::CountStar: case K::Count: return 0; + // AVG keeps the SOURCE column's decimals (oracle + // 2026-07-21: money -> 4dp, integer -> 0dp; not 6). + default: return static_cast(slots[i].arg_dec); + } + }; + // SAP's declared width per aggregate (see agg_result_width). A + // text MIN/MAX is not a number: it is sized to the widest decoded + // value seen (a date decodes to 8 chars from a 4-byte field, so the + // source's on-disk width would truncate it). + auto agg_is_text = [&](std::size_t i) { + using K = openads::sql::AggregateKind; + return slots[i].is_text && + (slots[i].def.kind == K::Min || slots[i].def.kind == K::Max); + }; + auto agg_w = [&](std::size_t i) -> std::uint16_t { + if (agg_is_text(i)) + return static_cast(textw[i] ? textw[i] : 1); + return scriptbridge::agg_result_width(slots[i].def.kind, + slots[i].arg_len); + }; + std::size_t arow_stride = 0; + std::vector acols; + acols.reserve(slots.size()); + { + std::size_t unaliased = 0; + for (std::size_t i = 0; i < slots.size(); ++i) { + std::string nm = slots[i].def.alias; + if (nm.empty()) { + nm = unaliased == 0 + ? "EXPR" : "EXPR_" + std::to_string(unaliased); + ++unaliased; + } + acols.push_back({std::move(nm), + agg_is_text(i) + ? (slots[i].is_date ? 'D' : 'C') + : 'N', + agg_w(i), + agg_is_text(i) + ? std::uint8_t{0} + : static_cast(agg_dp(i))}); + arow_stride += acols.back().len; + } + } + std::vector arow; + arow.reserve(arow_stride); + for (std::size_t i = 0; i < slots.size(); ++i) { + char buf[32] = {0}; + switch (slots[i].def.kind) { + case openads::sql::AggregateKind::CountStar: + case openads::sql::AggregateKind::Count: + if (slots[i].def.distinct) { + std::snprintf(buf, sizeof(buf), "%llu", + static_cast( + distinct_seen[i].size())); + break; + } + // M10.54 -- when this slot has a FILTER, count[i] + // already excludes filter-failing rows; use it + // even for CountStar. + std::snprintf(buf, sizeof(buf), "%llu", + static_cast( + slots[i].def.kind == + openads::sql::AggregateKind::CountStar + ? (slot_preds[i] ? count[i] : row_count) + : count[i])); + break; + case openads::sql::AggregateKind::Sum: + std::snprintf(buf, sizeof(buf), "%.*f", agg_dp(i), + sum[i]); + break; + case openads::sql::AggregateKind::Avg: + std::snprintf(buf, sizeof(buf), "%.*f", agg_dp(i), + count[i] + ? scriptbridge::avg_truncate( + sum[i] / static_cast(count[i]), + agg_dp(i)) + : 0.0); + break; + case openads::sql::AggregateKind::Min: + if (count[i] == 0) std::memcpy(buf, "0", 2); + else if (slots[i].is_text) + std::snprintf(buf, sizeof(buf), "%.*s", + static_cast(minsv[i].size()), + minsv[i].c_str()); + else std::snprintf(buf, sizeof(buf), "%.*f", agg_dp(i), + minv[i]); + break; + case openads::sql::AggregateKind::Max: + if (count[i] == 0) std::memcpy(buf, "0", 2); + else if (slots[i].is_text) + std::snprintf(buf, sizeof(buf), "%.*s", + static_cast(maxsv[i].size()), + maxsv[i].c_str()); + else std::snprintf(buf, sizeof(buf), "%.*f", agg_dp(i), + maxv[i]); + break; + } + // Right-justified to the declared width, except COUNT which SAP + // leaves unpadded -- agg_cell_text owns both rules. `buf` above is + // already the formatted digits; re-pad it here rather than + // re-deriving, since Count(*) has no source value to pass. + const std::size_t w = agg_w(i); + const bool is_count = + slots[i].def.kind == openads::sql::AggregateKind::Count || + slots[i].def.kind == openads::sql::AggregateKind::CountStar || + agg_is_text(i); // character cells left-justify too + std::vector cell(w, ' '); + std::size_t n = std::min(std::strlen(buf), w); + std::memcpy(cell.data() + (is_count ? 0 : (w - n)), buf, n); + arow.insert(arow.end(), cell.begin(), cell.end()); + } + return materialise_temp_adt(c, "_agg_", acols, arow, + arow_stride, phCursor); + } + + // Compile the WHERE expression tree into a row-predicate closure + // (M10.3). CONTAINS captures a precomputed recno set at compile + // time; AND / OR / NOT short-circuit during evaluation. + if (parsed.value().where) { + // Compiled term per Cmp leaf. + struct CmpTerm { + std::uint16_t field_index = 0; + openads::sql::WhereOp op = openads::sql::WhereOp::Eq; + std::string literal; + bool is_numeric = false; + double number = 0.0; + std::shared_ptr> contains_hits; + // M10.33 -- BETWEEN upper bound. + std::string literal2; + double number2 = 0.0; + // M11.7 -- case-insensitive ASCII compare when set. + bool nocase = false; + // ADS dialect -- UPPER()/LOWER() wrapping the LHS column. + openads::sql::WhereFn lhs_fn = + openads::sql::WhereFn::None; + }; + bool conn_nocase = + (c->collation() == Connection::Collation::NoCase); + auto to_lower_ascii = [](std::string sl) { + for (auto& ch : sl) { + if (ch >= 'A' && ch <= 'Z') ch = static_cast(ch + 32); + } + return sl; + }; + + // Compile the AST into a Predicate functor. + using Pred = std::function; + std::function( + const openads::sql::WhereExpr&)> compile; + compile = [&](const openads::sql::WhereExpr& node) + -> openads::util::Result { + using Kind = openads::sql::WhereExpr::Kind; + if (node.kind == Kind::And) { + std::vector kids; + for (auto& cn : node.children) { + auto r = compile(*cn); + if (!r) return r.error(); + kids.push_back(std::move(r).value()); + } + return Pred{[kids = std::move(kids)](openads::engine::Table& t) { + for (auto& k : kids) if (!k(t)) return false; + return true; + }}; + } + if (node.kind == Kind::Or) { + std::vector kids; + for (auto& cn : node.children) { + auto r = compile(*cn); + if (!r) return r.error(); + kids.push_back(std::move(r).value()); + } + return Pred{[kids = std::move(kids)](openads::engine::Table& t) { + for (auto& k : kids) if (k(t)) return true; + return false; + }}; + } + if (node.kind == Kind::Not) { + auto inner = compile(*node.child); + if (!inner) return inner.error(); + return Pred{[p = std::move(inner).value()] + (openads::engine::Table& t) { return !p(t); }}; + } + if (node.kind == Kind::Exists) { + // M10.17 / M10.24 -- EXISTS / NOT EXISTS. Honors + // subquery's WHERE (M10.24); when that WHERE has + // outer-column references (e.g. + // `EXISTS (SELECT * FROM b WHERE b.x = a.y)`), the + // predicate re-evaluates per outer row, binding outer + // values from the live `tbl` cursor. + if (!node.exists_subquery) { + return openads::util::Error{ + openads::AE_PARSE_ERROR, 0, + "EXISTS subquery missing", ""}; + } + // Move ownership of the subquery into a shared_ptr so + // the captured WhereExpr* outlives the parsed + // SelectStmt (which is local to AdsExecuteSQLDirect). + auto sub = std::shared_ptr( + const_cast(node) + .exists_subquery.release()); + openads::engine::Table* outer_tbl = tbl; + std::string sub_table = sub->table; + return Pred{[c, sub, outer_tbl, sub_table] + (openads::engine::Table&) -> bool { + auto sh = c->open_table(sub_table, + openads::engine::TableType::Cdx, + openads::engine::OpenMode::Read); + if (!sh) return false; + openads::engine::Table* stbl = c->lookup_table(sh.value()); + if (!stbl) { c->close_table(sh.value()); return false; } + auto trim = [](std::string sl) { + while (!sl.empty() && sl.back() == ' ') sl.pop_back(); + return sl; + }; + std::function evalw; + evalw = [&](const openads::sql::WhereExpr& n) -> bool { + using K = openads::sql::WhereExpr::Kind; + if (n.kind == K::And) { + for (auto& cn : n.children) + if (!evalw(*cn)) return false; + return true; + } + if (n.kind == K::Or) { + for (auto& cn : n.children) + if (evalw(*cn)) return true; + return false; + } + if (n.kind == K::Not) return !evalw(*n.child); + if (n.kind != K::Cmp) return false; + const auto& w = n.cmp; + std::int32_t fi = stbl->field_index(w.column); + if (fi < 0) return false; + auto v = stbl->read_field( + static_cast(fi)); + if (!v) return false; + int cmp = 0; + if (w.is_outer_ref) { + std::int32_t ofi = + outer_tbl->field_index(w.outer_column); + if (ofi < 0) return false; + auto ov = outer_tbl->read_field( + static_cast(ofi)); + if (!ov) return false; + cmp = trim(v.value().as_string) + .compare(trim(ov.value().as_string)); + } else if (w.is_numeric) { + double d = v.value().as_double; + if (d < w.number) cmp = -1; + else if (d > w.number) cmp = 1; + } else { + cmp = trim(v.value().as_string).compare(w.literal); + } + switch (w.op) { + case openads::sql::WhereOp::Eq: return cmp == 0; + case openads::sql::WhereOp::Ne: return cmp != 0; + case openads::sql::WhereOp::Lt: return cmp < 0; + case openads::sql::WhereOp::Gt: return cmp > 0; + case openads::sql::WhereOp::Le: return cmp <= 0; + case openads::sql::WhereOp::Ge: return cmp >= 0; + default: return false; + } + }; + bool any = false; + std::uint32_t srcount = stbl->record_count(); + for (std::uint32_t r = 1; r <= srcount; ++r) { + if (auto g = stbl->goto_record(r); !g) continue; + if (!stbl->show_deleted_records() && + stbl->is_deleted()) continue; + if (!sub->where) { any = true; break; } + if (evalw(*sub->where)) { any = true; break; } + } + c->close_table(sh.value()); + return any; + }}; + } + if (node.kind == Kind::In) { + // M10.15: materialise the IN set at compile time. For + // a literal list, just lift the strings in. For a + // subquery, walk its source table inline (no nested + // ABI dispatch -- keeps the lock_guard intact). + std::int32_t fidx = tbl->field_index(node.in_clause.column); + if (fidx < 0) { + return openads::util::Error{ + openads::AE_COLUMN_NOT_FOUND, 0, + node.in_clause.column.c_str(), ""}; + } + std::uint16_t fi = static_cast(fidx); + auto trim_trailing = [](std::string sl) { + while (!sl.empty() && sl.back() == ' ') sl.pop_back(); + return sl; + }; + // CICHAR columns fold case in IN membership (S4); the + // fold applies ONLY to the membership set and probe -- + // the correlated path's inner-WHERE literal compares + // stay byte-wise (their columns are checked separately). + bool in_ci = field_is_cichar(*tbl, fi); + auto in_fold = [in_ci](std::string sl) { + if (in_ci) + for (char& ch : sl) + ch = static_cast(std::toupper( + static_cast(ch))); + return sl; + }; + auto set = std::make_shared>(); + for (auto& lit : node.in_clause.literals) + set->insert(in_fold(trim_trailing(lit))); + if (node.in_clause.subquery) { + // M10.35 -- detect correlation in subquery's WHERE. + bool correlated = false; + if (node.in_clause.subquery->where) { + std::function + scan; + scan = [&](const openads::sql::WhereExpr& n) { + using K = openads::sql::WhereExpr::Kind; + if (correlated) return; + if (n.kind == K::And || n.kind == K::Or) { + for (auto& cn : n.children) scan(*cn); + return; + } + if (n.kind == K::Not) { scan(*n.child); return; } + if (n.kind == K::Cmp && n.cmp.is_outer_ref) + correlated = true; + }; + scan(*node.in_clause.subquery->where); + } + if (correlated) { + auto sub = std::shared_ptr( + const_cast( + node.in_clause).subquery.release()); + openads::engine::Table* outer_tbl = tbl; + std::string sub_table = sub->table; + return Pred{[c, sub, outer_tbl, fi, sub_table, + trim_trailing] + (openads::engine::Table&) -> bool { + auto sh = c->open_table( + sub_table, + openads::engine::TableType::Cdx, + openads::engine::OpenMode::Read); + if (!sh) return false; + openads::engine::Table* stbl = + c->lookup_table(sh.value()); + if (!stbl) { + c->close_table(sh.value()); return false; + } + if (sub->projection.size() != 1 || + !sub->aggregates.empty()) { + c->close_table(sh.value()); return false; + } + std::int32_t scol = + stbl->field_index(sub->projection[0]); + if (scol < 0) { + c->close_table(sh.value()); return false; + } + auto trim = trim_trailing; + std::function + evalw; + evalw = [&](const openads::sql::WhereExpr& n) + -> bool { + using K = openads::sql::WhereExpr::Kind; + if (n.kind == K::And) { + for (auto& cn : n.children) + if (!evalw(*cn)) return false; + return true; + } + if (n.kind == K::Or) { + for (auto& cn : n.children) + if (evalw(*cn)) return true; + return false; + } + if (n.kind == K::Not) return !evalw(*n.child); + if (n.kind != K::Cmp) return false; + const auto& wn = n.cmp; + std::int32_t sfi = + stbl->field_index(wn.column); + if (sfi < 0) return false; + auto v = stbl->read_field( + static_cast(sfi)); + if (!v) return false; + int cmp = 0; + if (wn.is_outer_ref) { + std::int32_t ofi = + outer_tbl->field_index(wn.outer_column); + if (ofi < 0) return false; + auto ov = outer_tbl->read_field( + static_cast(ofi)); + if (!ov) return false; + cmp = trim(v.value().as_string) + .compare(trim(ov.value().as_string)); + } else if (wn.is_numeric) { + double d = v.value().as_double; + if (d < wn.number) cmp = -1; + else if (d > wn.number) cmp = 1; + } else { + cmp = trim(v.value().as_string) + .compare(wn.literal); + } + switch (wn.op) { + case openads::sql::WhereOp::Eq: return cmp == 0; + case openads::sql::WhereOp::Ne: return cmp != 0; + case openads::sql::WhereOp::Lt: return cmp < 0; + case openads::sql::WhereOp::Gt: return cmp > 0; + case openads::sql::WhereOp::Le: return cmp <= 0; + case openads::sql::WhereOp::Ge: return cmp >= 0; + default: return false; + } + }; + auto ov = outer_tbl->read_field(fi); + if (!ov) { + c->close_table(sh.value()); return false; + } + std::string outer_v = + trim(ov.value().as_string); + bool any = false; + std::uint32_t srcount = stbl->record_count(); + for (std::uint32_t r = 1; r <= srcount; ++r) { + if (auto g = stbl->goto_record(r); !g) + continue; + if (!stbl->show_deleted_records() && + stbl->is_deleted()) continue; + if (sub->where && !evalw(*sub->where)) + continue; + auto sv = stbl->read_field( + static_cast(scol)); + if (!sv) continue; + if (trim(sv.value().as_string) == outer_v) { + any = true; break; + } + } + c->close_table(sh.value()); + return any; + }}; + } + const auto& sq = *node.in_clause.subquery; + auto sh = c->open_table(sq.table, + openads::engine::TableType::Cdx, + openads::engine::OpenMode::Read); + if (!sh) return sh.error(); + openads::engine::Table* stbl = c->lookup_table(sh.value()); + if (stbl == nullptr) { + return openads::util::Error{ + openads::AE_INTERNAL_ERROR, 0, + "subquery post-open", ""}; + } + if (sq.projection.empty() && sq.aggregates.empty()) { + return openads::util::Error{ + openads::AE_PARSE_ERROR, 0, + "IN subquery must project a single column", ""}; + } + if (!sq.aggregates.empty() || + sq.projection.size() != 1) { + c->close_table(sh.value()); + return openads::util::Error{ + openads::AE_PARSE_ERROR, 0, + "IN subquery must project exactly one column", ""}; + } + std::int32_t scol = stbl->field_index(sq.projection[0]); + if (scol < 0) { + c->close_table(sh.value()); + return openads::util::Error{ + openads::AE_COLUMN_NOT_FOUND, 0, + sq.projection[0].c_str(), ""}; + } + std::uint32_t srcount = stbl->record_count(); + for (std::uint32_t r = 1; r <= srcount; ++r) { + if (auto g = stbl->goto_record(r); !g) continue; + if (!stbl->show_deleted_records() && + stbl->is_deleted()) continue; + auto v = stbl->read_field( + static_cast(scol)); + if (!v) continue; + set->insert(in_fold(trim_trailing(v.value().as_string))); + } + c->close_table(sh.value()); + } + return Pred{[fi, set, trim_trailing, in_fold] + (openads::engine::Table& t) { + auto v = t.read_field(fi); + if (!v) return false; + return set->find(in_fold( + trim_trailing(v.value().as_string))) != + set->end(); + }}; + } + // Cmp leaf. + const auto& w = node.cmp; + std::int32_t fidx = tbl->field_index(w.column); + if (fidx < 0) { + return openads::util::Error{ + openads::AE_COLUMN_NOT_FOUND, 0, + w.column.c_str(), ""}; + } + CmpTerm term; + term.field_index = static_cast(fidx); + term.op = w.op; + term.literal = w.literal; + term.is_numeric = w.is_numeric; + term.number = w.number; + term.literal2 = w.literal2; + term.number2 = w.number2; + term.lhs_fn = w.lhs_fn; + // M11.7 -- stamp collation onto the term when the + // connection is in nocase mode and the cmp involves + // string operands. S4: CICHAR (ADT type 20) columns compare + // case-insensitively regardless of connection collation + // (oracle-verified: = and LIKE both fold on CICHAR, plain + // CHAR stays byte-wise). + if ((conn_nocase || + field_is_cichar(*tbl, term.field_index)) && + !w.is_numeric) { + term.nocase = true; + term.literal = to_lower_ascii(term.literal); + term.literal2 = to_lower_ascii(term.literal2); + } + // PAD SPACE (oracle-verified): trailing blanks are + // insignificant in string comparisons -- a CHAR(20) script + // variable substitutes as a space-padded literal and must + // still match ('PROPERTIES ' matches on SAP). + if (!w.is_numeric) { + while (!term.literal.empty() && term.literal.back() == ' ') + term.literal.pop_back(); + while (!term.literal2.empty() && + term.literal2.back() == ' ') + term.literal2.pop_back(); + } + if (w.subquery) { + // M10.29 -- correlated scalar subquery. If the + // subquery's WHERE references an outer column, we + // re-evaluate the subquery per outer row instead of + // materialising a single value at compile time. + bool correlated = false; + if (w.subquery->where) { + std::function scan; + scan = [&](const openads::sql::WhereExpr& n) { + using K = openads::sql::WhereExpr::Kind; + if (correlated) return; + if (n.kind == K::And || n.kind == K::Or) { + for (auto& cn : n.children) scan(*cn); + return; + } + if (n.kind == K::Not) { scan(*n.child); return; } + if (n.kind == K::Cmp && n.cmp.is_outer_ref) + correlated = true; + }; + scan(*w.subquery->where); + } + if (correlated) { + auto sub = std::shared_ptr( + const_cast(w) + .subquery.release()); + openads::engine::Table* outer_tbl = tbl; + std::uint16_t outer_field = + static_cast(fidx); + bool outer_is_numeric_local = + tbl->field_descriptor(outer_field).type != + openads::drivers::DbfFieldType::Character; + openads::sql::WhereOp op_local = w.op; + std::string sub_table = sub->table; + return Pred{[c, sub, outer_tbl, outer_field, + outer_is_numeric_local, op_local, sub_table] + (openads::engine::Table&) -> bool { + auto sh = c->open_table( + sub_table, + openads::engine::TableType::Cdx, + openads::engine::OpenMode::Read); + if (!sh) return false; + openads::engine::Table* stbl = + c->lookup_table(sh.value()); + if (!stbl) { + c->close_table(sh.value()); return false; + } + auto trim = [](std::string sl) { + while (!sl.empty() && sl.back() == ' ') + sl.pop_back(); + return sl; + }; + std::function + evalw; + evalw = [&](const openads::sql::WhereExpr& n) -> bool { + using K = openads::sql::WhereExpr::Kind; + if (n.kind == K::And) { + for (auto& cn : n.children) + if (!evalw(*cn)) return false; + return true; + } + if (n.kind == K::Or) { + for (auto& cn : n.children) + if (evalw(*cn)) return true; + return false; + } + if (n.kind == K::Not) return !evalw(*n.child); + if (n.kind != K::Cmp) return false; + const auto& wn = n.cmp; + std::int32_t fi = stbl->field_index(wn.column); + if (fi < 0) return false; + auto v = stbl->read_field( + static_cast(fi)); + if (!v) return false; + int cmp = 0; + if (wn.is_outer_ref) { + std::int32_t ofi = + outer_tbl->field_index(wn.outer_column); + if (ofi < 0) return false; + auto ov = outer_tbl->read_field( + static_cast(ofi)); + if (!ov) return false; + cmp = trim(v.value().as_string) + .compare(trim(ov.value().as_string)); + } else if (wn.is_numeric) { + double d = v.value().as_double; + if (d < wn.number) cmp = -1; + else if (d > wn.number) cmp = 1; + } else { + cmp = trim(v.value().as_string) + .compare(wn.literal); + } + switch (wn.op) { + case openads::sql::WhereOp::Eq: return cmp == 0; + case openads::sql::WhereOp::Ne: return cmp != 0; + case openads::sql::WhereOp::Lt: return cmp < 0; + case openads::sql::WhereOp::Gt: return cmp > 0; + case openads::sql::WhereOp::Le: return cmp <= 0; + case openads::sql::WhereOp::Ge: return cmp >= 0; + default: return false; + } + }; + double scalar_num = 0.0; + std::string scalar_str; + bool found = false; + std::uint32_t srcount = stbl->record_count(); + if (scriptbridge::sq_is_scalar_agg(*sub)) { + const auto& a = sub->aggregates[0]; + std::int32_t scol = -1; + if (a.kind != + openads::sql::AggregateKind::CountStar) { + scol = stbl->field_index(a.column); + if (scol < 0) { + c->close_table(sh.value()); return false; + } + } + std::uint64_t cnt = 0; + double sum = 0.0; + double minv = std::numeric_limits::infinity(); + double maxv = -std::numeric_limits::infinity(); + for (std::uint32_t r = 1; r <= srcount; ++r) { + if (auto g = stbl->goto_record(r); !g) continue; + if (!stbl->show_deleted_records() && + stbl->is_deleted()) continue; + if (sub->where && !evalw(*sub->where)) continue; + if (a.kind == + openads::sql::AggregateKind::CountStar) { + ++cnt; continue; + } + auto v = stbl->read_field( + static_cast(scol)); + if (!v) continue; + ++cnt; + double d = v.value().as_double; + sum += d; + if (d < minv) minv = d; + if (d > maxv) maxv = d; + } + switch (a.kind) { + case openads::sql::AggregateKind::CountStar: + case openads::sql::AggregateKind::Count: + scalar_num = static_cast(cnt); break; + case openads::sql::AggregateKind::Sum: + scalar_num = sum; break; + case openads::sql::AggregateKind::Avg: + scalar_num = cnt + ? sum / static_cast(cnt) + : 0.0; break; + case openads::sql::AggregateKind::Min: + scalar_num = cnt ? minv : 0.0; break; + case openads::sql::AggregateKind::Max: + scalar_num = cnt ? maxv : 0.0; break; + } + found = true; + char tmp[64]; + std::snprintf(tmp, sizeof(tmp), + "%.17g", scalar_num); + scalar_str = tmp; + } else if (sub->projection.size() == 1 && + sub->aggregates.empty()) { + std::int32_t scol = + stbl->field_index(sub->projection[0]); + if (scol < 0) { + c->close_table(sh.value()); return false; + } + for (std::uint32_t r = 1; r <= srcount; ++r) { + if (auto g = stbl->goto_record(r); !g) continue; + if (!stbl->show_deleted_records() && + stbl->is_deleted()) continue; + if (sub->where && !evalw(*sub->where)) continue; + auto v = stbl->read_field( + static_cast(scol)); + if (!v) continue; + scalar_str = v.value().as_string; + scalar_num = v.value().as_double; + while (!scalar_str.empty() && + scalar_str.back() == ' ') + scalar_str.pop_back(); + found = true; + break; + } + } else { + c->close_table(sh.value()); + return false; + } + c->close_table(sh.value()); + if (!found) return false; + auto ov = outer_tbl->read_field(outer_field); + if (!ov) return false; + int cmp = 0; + if (outer_is_numeric_local) { + double d = ov.value().as_double; + if (d < scalar_num) cmp = -1; + else if (d > scalar_num) cmp = 1; + } else { + std::string os = ov.value().as_string; + while (!os.empty() && os.back() == ' ') + os.pop_back(); + cmp = os.compare(scalar_str); + } + switch (op_local) { + case openads::sql::WhereOp::Eq: return cmp == 0; + case openads::sql::WhereOp::Ne: return cmp != 0; + case openads::sql::WhereOp::Lt: return cmp < 0; + case openads::sql::WhereOp::Gt: return cmp > 0; + case openads::sql::WhereOp::Le: return cmp <= 0; + case openads::sql::WhereOp::Ge: return cmp >= 0; + default: return false; + } + }}; + } + // M10.18: scalar subquery -- materialise once at + // compile time. Open the subquery's table, walk for + // the first non-deleted record, read the projection's + // first column, and use that as the cmp literal. + const auto& sq = *w.subquery; + auto sh = c->open_table(sq.table, + openads::engine::TableType::Cdx, + openads::engine::OpenMode::Read); + if (!sh) return sh.error(); + openads::engine::Table* stbl = c->lookup_table(sh.value()); + if (stbl == nullptr) { + return openads::util::Error{ + openads::AE_INTERNAL_ERROR, 0, + "scalar subquery post-open", ""}; + } + bool outer_is_numeric = + tbl->field_descriptor(static_cast(fidx)) + .type != openads::drivers::DbfFieldType::Character; + + // M10.19 -- aggregate scalar subquery + // (`= (SELECT MAX(x) FROM t)`). Single aggregate slot + // computes against the inner table; numeric result + // lands directly in the cmp's number/literal. + if (scriptbridge::sq_is_scalar_agg(sq)) { + const auto& a = sq.aggregates[0]; + std::int32_t scol = -1; + if (a.kind != openads::sql::AggregateKind::CountStar) { + scol = stbl->field_index(a.column); + if (scol < 0) { + c->close_table(sh.value()); + return openads::util::Error{ + openads::AE_COLUMN_NOT_FOUND, 0, + a.column.c_str(), ""}; + } + } + std::uint64_t cnt = 0; + double sum = 0.0; + double minv = std::numeric_limits::infinity(); + double maxv = -std::numeric_limits::infinity(); + std::uint32_t srcount = stbl->record_count(); + for (std::uint32_t r = 1; r <= srcount; ++r) { + if (auto g = stbl->goto_record(r); !g) continue; + if (!stbl->show_deleted_records() && + stbl->is_deleted()) continue; + if (a.kind == openads::sql::AggregateKind::CountStar) { + ++cnt; + continue; + } + auto v = stbl->read_field( + static_cast(scol)); + if (!v) continue; + ++cnt; + double d = v.value().as_double; + sum += d; + if (d < minv) minv = d; + if (d > maxv) maxv = d; + } + c->close_table(sh.value()); + double result = 0.0; + switch (a.kind) { + case openads::sql::AggregateKind::CountStar: + case openads::sql::AggregateKind::Count: + result = static_cast(cnt); + break; + case openads::sql::AggregateKind::Sum: result = sum; break; + case openads::sql::AggregateKind::Avg: + result = cnt ? sum / static_cast(cnt) : 0.0; + break; + case openads::sql::AggregateKind::Min: + result = cnt ? minv : 0.0; break; + case openads::sql::AggregateKind::Max: + result = cnt ? maxv : 0.0; break; + } + term.is_numeric = outer_is_numeric; + term.number = result; + char tmp[64]; + std::snprintf(tmp, sizeof(tmp), "%.17g", result); + term.literal = tmp; + if (!outer_is_numeric) { + // String comparison: drop trailing zeros so + // "42.000" compares clean against the column. + std::snprintf(tmp, sizeof(tmp), "%g", result); + term.literal = tmp; + } + } else if (!sq.projection.empty() && sq.aggregates.empty()) { + if (sq.projection.size() != 1) { + c->close_table(sh.value()); + return openads::util::Error{ + openads::AE_PARSE_ERROR, 0, + "scalar subquery must project a single column", ""}; + } + std::int32_t scol = stbl->field_index(sq.projection[0]); + if (scol < 0) { + c->close_table(sh.value()); + return openads::util::Error{ + openads::AE_COLUMN_NOT_FOUND, 0, + sq.projection[0].c_str(), ""}; + } + bool found = false; + std::uint32_t srcount = stbl->record_count(); + for (std::uint32_t r = 1; r <= srcount; ++r) { + if (auto g = stbl->goto_record(r); !g) continue; + if (!stbl->show_deleted_records() && + stbl->is_deleted()) continue; + auto v = stbl->read_field( + static_cast(scol)); + if (!v) continue; + term.literal = v.value().as_string; + term.is_numeric = outer_is_numeric; + term.number = v.value().as_double; + while (!term.literal.empty() && + term.literal.back() == ' ') { + term.literal.pop_back(); + } + if (term.nocase) + term.literal = to_lower_ascii(term.literal); + found = true; + break; + } + c->close_table(sh.value()); + if (!found) { + return Pred{[](openads::engine::Table&) { return false; }}; + } + } else { + c->close_table(sh.value()); + return openads::util::Error{ + openads::AE_PARSE_ERROR, 0, + "scalar subquery must project exactly one " + "column or one aggregate", ""}; + } + } + if (w.op == openads::sql::WhereOp::Contains) { + namespace fs = std::filesystem; + fs::path fts_path = + fs::path(tbl->path()).replace_extension(".fts"); + auto loaded = openads::engine::Fts::load(fts_path.string()); + if (!loaded) return loaded.error(); + openads::engine::FtsOptions opts; + auto hits = openads::engine::Fts::search( + loaded.value(), w.literal, opts); + term.contains_hits = + std::make_shared>( + hits.begin(), hits.end()); + } + return Pred{[term](openads::engine::Table& t) { + if (term.op == openads::sql::WhereOp::Contains) { + if (!term.contains_hits) return false; + return term.contains_hits->find(t.recno()) != + term.contains_hits->end(); + } + auto v = t.read_field(term.field_index); + if (!v) return false; + auto maybe_lower = [&](std::string sl) { + // ADS UPPER()/LOWER() folds the cell first; literal is + // verbatim. nocase then lowercases both sides. + sl = apply_where_fn(std::move(sl), term.lhs_fn); + if (!term.nocase) return sl; + for (auto& ch : sl) { + if (ch >= 'A' && ch <= 'Z') + ch = static_cast(ch + 32); + } + return sl; + }; + if (term.op == openads::sql::WhereOp::Between) { + if (term.is_numeric) { + double d = v.value().as_double; + return d >= term.number && d <= term.number2; + } + auto sv = maybe_lower(v.value().as_string); + return sv.compare(term.literal) >= 0 && + sv.compare(term.literal2) <= 0; + } + if (term.op == openads::sql::WhereOp::Like) { + auto sv = maybe_lower(v.value().as_string); + while (!sv.empty() && sv.back() == ' ') sv.pop_back(); + return sql_like_match(sv, term.literal); + } + if (term.op == openads::sql::WhereOp::IsNull || + term.op == openads::sql::WhereOp::IsNotNull) { + // M10.44 / M11.6 -- prefer the VFP NULL bitmap + // when the field is nullable; otherwise treat + // an all-blanks character cell as NULL. + bool null_ish = t.is_field_null(term.field_index); + if (!null_ish) { + auto sv = v.value().as_string; + while (!sv.empty() && sv.back() == ' ') sv.pop_back(); + null_ish = sv.empty(); + } + return term.op == openads::sql::WhereOp::IsNull + ? null_ish : !null_ish; + } + int cmp = 0; + if (term.is_numeric) { + double d = v.value().as_double; + if (d < term.number) cmp = -1; + else if (d > term.number) cmp = 1; + } else { + cmp = maybe_lower(v.value().as_string).compare(term.literal); + } + switch (term.op) { + case openads::sql::WhereOp::Eq: return cmp == 0; + case openads::sql::WhereOp::Ne: return cmp != 0; + case openads::sql::WhereOp::Lt: return cmp < 0; + case openads::sql::WhereOp::Gt: return cmp > 0; + case openads::sql::WhereOp::Le: return cmp <= 0; + case openads::sql::WhereOp::Ge: return cmp >= 0; + case openads::sql::WhereOp::Contains: return true; + default: return false; + } + }}; + }; + + auto compiled = compile(*parsed.value().where); + if (!compiled) return fail(compiled.error()); + tbl->set_filter(std::move(compiled).value()); + } + + // M10.6: ORDER BY [ASC|DESC]. Materialize matching recnos + // through the WHERE filter (or every live row when none), sort + // them by the column's value, and install the sequence as the + // cursor's traversal order. + if (parsed.value().order_by) { + // M10.6 / M10.37 -- ORDER BY one column, with cascading + // additional columns for ties (M10.37). + struct SortKey { + std::uint16_t field_index; + bool descending; + bool numeric; + bool ci; // CICHAR: case-insensitive collation + }; + std::vector sks; + auto add_sort_key = [&](const openads::sql::OrderBy& ob) + -> openads::util::Result + { + std::int32_t fidx = tbl->field_index(ob.column); + if (fidx < 0) { + return openads::util::Error{ + openads::AE_COLUMN_NOT_FOUND, 0, + ob.column.c_str(), ""}; + } + const auto& fd = tbl->field_descriptor( + static_cast(fidx)); + SortKey k; + k.field_index = static_cast(fidx); + k.descending = ob.descending; + k.numeric = + fd.type == openads::drivers::DbfFieldType::Numeric || + fd.type == openads::drivers::DbfFieldType::Float || + fd.type == openads::drivers::DbfFieldType::Integer || + fd.type == openads::drivers::DbfFieldType::Currency|| + fd.type == openads::drivers::DbfFieldType::Double; + k.ci = fd.type == + openads::drivers::DbfFieldType::CiCharacter; + sks.push_back(k); + return std::monostate{}; + }; + if (auto r = add_sort_key(*parsed.value().order_by); !r) + return fail(r.error()); + for (auto& ob : parsed.value().order_by_extra) { + if (auto r = add_sort_key(ob); !r) return fail(r.error()); + } + + std::vector matched; + std::uint32_t rcount = tbl->record_count(); + for (std::uint32_t r = 1; r <= rcount; ++r) { + if (auto g = tbl->goto_record(r); !g) continue; + if (!tbl->show_deleted_records() && + tbl->is_deleted()) continue; + if (!tbl->passes_filter()) continue; + matched.push_back(r); + } + + struct Row { + std::uint32_t recno; + std::vector s; + std::vector d; + }; + std::vector rows; + rows.reserve(matched.size()); + for (auto r : matched) { + (void)tbl->goto_record(r); + Row row; + row.recno = r; + row.s.resize(sks.size()); + row.d.resize(sks.size()); + for (std::size_t i = 0; i < sks.size(); ++i) { + auto v = tbl->read_field(sks[i].field_index); + if (v) { + row.s[i] = v.value().as_string; + row.d[i] = v.value().as_double; + } + } + rows.push_back(std::move(row)); + } + std::stable_sort(rows.begin(), rows.end(), + [&](const Row& a, const Row& b) { + for (std::size_t i = 0; i < sks.size(); ++i) { + bool less, equal; + if (sks[i].numeric) { + less = a.d[i] < b.d[i]; + equal = a.d[i] == b.d[i]; + } else { + // PAD SPACE + CICHAR-aware collation, matching + // WHERE-clause string comparison semantics. + int cmp = where_str_cmp(a.s[i], b.s[i], sks[i].ci); + less = cmp < 0; + equal = cmp == 0; + } + if (equal) continue; + return sks[i].descending ? !less : less; + } + return false; + }); + std::vector seq; + seq.reserve(rows.size()); + for (auto& row : rows) seq.push_back(row.recno); + tbl->clear_filter(); + tbl->set_recno_sequence(std::move(seq)); + } + + // M10.31 -- DISTINCT. M10.32 -- LIMIT [OFFSET]. Both operate on the + // post-WHERE / post-ORDER-BY traversal sequence; if neither + // ORDER BY nor a recno_sequence is present yet, walk the + // filtered cursor to materialise one first. + bool need_seq_post = parsed.value().distinct || + parsed.value().limit >= 0 || + parsed.value().offset > 0; + if (need_seq_post) { + std::vector seq; + if (tbl->has_recno_sequence()) { + seq = tbl->recno_sequence(); + } else { + std::uint32_t rcount = tbl->record_count(); + seq.reserve(rcount); + for (std::uint32_t r = 1; r <= rcount; ++r) { + if (auto g = tbl->goto_record(r); !g) continue; + if (!tbl->show_deleted_records() && + tbl->is_deleted()) continue; + if (!tbl->passes_filter()) continue; + seq.push_back(r); + } + } + if (parsed.value().distinct) { + std::vector proj_indices; + if (parsed.value().projection.empty()) { + std::uint16_t nf = tbl->field_count(); + proj_indices.reserve(nf); + for (std::uint16_t i = 0; i < nf; ++i) proj_indices.push_back(i); + } else { + for (auto& cn : parsed.value().projection) { + std::int32_t fi = tbl->field_index(cn); + if (fi < 0) return fail(openads::AE_COLUMN_NOT_FOUND, + cn.c_str()); + proj_indices.push_back(static_cast(fi)); + } + } + std::unordered_set seen; + std::vector dedup; + dedup.reserve(seq.size()); + for (auto r : seq) { + if (auto g = tbl->goto_record(r); !g) continue; + std::string key; + for (auto fi : proj_indices) { + auto v = tbl->read_field(fi); + if (v) key.append(v.value().as_string); + key.push_back('\x1f'); + } + if (seen.insert(std::move(key)).second) dedup.push_back(r); + } + seq = std::move(dedup); + } + std::int64_t off = parsed.value().offset > 0 ? parsed.value().offset : 0; + if (off > static_cast(seq.size())) { + seq.clear(); + } else if (off > 0) { + seq.erase(seq.begin(), seq.begin() + + static_cast::difference_type>(off)); + } + if (parsed.value().limit >= 0 && + static_cast(parsed.value().limit) < seq.size()) { + seq.resize(static_cast(parsed.value().limit)); + } + tbl->clear_filter(); + tbl->set_recno_sequence(std::move(seq)); + } + + // M10.38 -- projection contains a CASE expression. Materialise the + // post-WHERE / post-ORDER-BY / post-DISTINCT / post-LIMIT row set + // into a temp DBF whose schema mirrors the projection list (CASE + // items become C(30); regular columns preserve source type + + // length), evaluating each row's CASE branches inline. + auto starts_with = [](const std::string& sl, const char* pre) { + std::size_t L = std::strlen(pre); + return sl.size() >= L && std::memcmp(sl.data(), pre, L) == 0; + }; + bool has_synth = false; + for (auto& p : parsed.value().projection) { + if (starts_with(p, "$CASE_") || starts_with(p, "$FN_") || + starts_with(p, "$ARITH_") || starts_with(p, "$WIN_")) { + has_synth = true; break; + } + } + if (has_synth) { + struct OutCol { + std::string name; + char raw_type = 'C'; + std::uint8_t length = 0; + std::uint8_t decimals = 0; + std::int32_t src_field = -1; + std::int32_t case_idx = -1; + std::int32_t fn_idx = -1; + std::int32_t arith_idx = -1; + std::int32_t arith_lhs_field = -1; + std::int32_t arith_rhs_field = -1; + std::int32_t win_idx = -1; + }; + std::vector outs; + outs.reserve(parsed.value().projection.size()); + int script_expr_seq = 0; // EXPR / EXPR_1 / … numbering (SAP) + for (std::size_t i = 0; i < parsed.value().projection.size(); ++i) { + const auto& p = parsed.value().projection[i]; + OutCol o; + if (starts_with(p, "$CASE_")) { + std::size_t idx = std::stoul(p.substr(6)); + o.case_idx = static_cast(idx); + const auto& ce = parsed.value().case_items[idx]; + if (!ce.alias.empty()) o.name = ce.alias; + else { + char nm[16]; + std::snprintf(nm, sizeof(nm), "CASE%zu", idx + 1); + o.name = nm; + } + o.raw_type = 'C'; + o.length = 30; + } else if (starts_with(p, "$FN_")) { + std::size_t idx = std::stoul(p.substr(4)); + o.fn_idx = static_cast(idx); + const auto& fc = parsed.value().fn_items[idx]; + using K = openads::sql::ScalarFnKind; + bool zero_arg = (fc.kind == K::Now || fc.kind == K::Today || + fc.kind == K::Date || fc.kind == K::Time); + bool single_col = !zero_arg && + (fc.kind == K::Upper || + fc.kind == K::Lower || + fc.kind == K::Len || + fc.kind == K::Trim || + fc.kind == K::Ltrim || + fc.kind == K::Rtrim); + if (zero_arg) { + if (!fc.alias.empty()) o.name = fc.alias; + else o.name = (fc.kind == K::Now) ? "NOW" + : (fc.kind == K::Today) ? "TODAY" + : (fc.kind == K::Date) ? "DATE" + : "TIME"; + o.raw_type = 'C'; + o.length = (fc.kind == K::Time) ? 8 : 19; + // src_field stays -1; value produced at row-eval time + } else if (single_col) { + std::int32_t fi = tbl->field_index(fc.column); + if (fi < 0) return fail(openads::AE_COLUMN_NOT_FOUND, + fc.column.c_str()); + o.src_field = fi; + // SAP names an unaliased scalar-fn projection EXPR / + // EXPR_1 / ... (probed: UPPER(Nm), TRIM(Nm) come back + // EXPR, EXPR_1) - not after the argument column. + if (!fc.alias.empty()) o.name = fc.alias; + else { + o.name = script_expr_seq == 0 + ? "EXPR" + : "EXPR_" + std::to_string(script_expr_seq); + ++script_expr_seq; + } + o.raw_type = 'C'; + if (fc.kind == K::Len) { + o.length = 10; + } else { + const auto& fd = tbl->field_descriptor( + static_cast(fi)); + o.length = static_cast(fd.length ? fd.length : 30); + } + } else if (fc.kind == K::ScriptCall) { + // SAP names unaliased expression projections EXPR, + // EXPR_1, … (oracle-verified). Static type inference + // over the compiled expression drives the temp-column + // type so subquery consumers see typed values. + if (!fc.alias.empty()) o.name = fc.alias; + else { + o.name = script_expr_seq == 0 + ? "EXPR" + : "EXPR_" + std::to_string(script_expr_seq); + ++script_expr_seq; + } + o.raw_type = 'C'; + o.length = 50; + auto spr = scriptbridge::compiled( + "RETURN " + fc.column + ";"); + if (spr && !spr.value()->stmts.empty() && + spr.value()->stmts[0]->expr) { + using ST = openads::script::Type; + switch (scriptbridge::infer_expr_type( + *spr.value()->stmts[0]->expr)) { + case ST::Integer: + o.raw_type = 'N'; o.length = 20; break; + case ST::Double: + o.raw_type = 'N'; o.length = 20; + o.decimals = 6; break; + case ST::Date: + o.raw_type = 'D'; o.length = 8; break; + default: break; + } + } + } else if (fc.kind == K::Udf) { + o.name = fc.alias.empty() ? fc.fn_name : fc.alias; + o.raw_type = 'C'; + o.length = 50; + } else { + // M10.43 / M10.45 -- multi-arg fns. Width = generous + // default; alias drives the column name; no + // pre-resolved src_field (per-arg lookups happen + // at row-eval time). + if (!fc.alias.empty()) o.name = fc.alias; + else { + char nm[16]; + std::snprintf(nm, sizeof(nm), "EXPR%zu", idx + 1); + o.name = nm; + } + o.raw_type = 'C'; + o.length = (fc.kind == K::DateAdd) ? 8 + : (fc.kind == K::DateDiff) ? 12 + : 64; + } + } else if (starts_with(p, "$WIN_")) { + std::size_t idx = std::stoul(p.substr(5)); + o.win_idx = static_cast(idx); + const auto& wf = parsed.value().window_items[idx]; + if (!wf.alias.empty()) o.name = wf.alias; + else { + char nm[16]; + std::snprintf(nm, sizeof(nm), "RN%zu", idx + 1); + o.name = nm; + } + o.raw_type = 'C'; + o.length = 10; + } else if (starts_with(p, "$ARITH_")) { + std::size_t idx = std::stoul(p.substr(7)); + o.arith_idx = static_cast(idx); + const auto& ae = parsed.value().arith_items[idx]; + std::int32_t lhs = tbl->field_index(ae.lhs_column); + if (lhs < 0) return fail(openads::AE_COLUMN_NOT_FOUND, + ae.lhs_column.c_str()); + o.arith_lhs_field = lhs; + if (!ae.rhs_is_literal) { + std::int32_t rhs = tbl->field_index(ae.rhs_column); + if (rhs < 0) return fail(openads::AE_COLUMN_NOT_FOUND, + ae.rhs_column.c_str()); + o.arith_rhs_field = rhs; + } + if (!ae.alias.empty()) o.name = ae.alias; + else { + char nm[16]; + std::snprintf(nm, sizeof(nm), "EXPR%zu", idx + 1); + o.name = nm; + } + o.raw_type = 'C'; + o.length = 30; + } else { + std::int32_t fi = tbl->field_index(p); + if (fi < 0) return fail(openads::AE_COLUMN_NOT_FOUND, + p.c_str()); + const auto& fd = tbl->field_descriptor( + static_cast(fi)); + o.name = fd.name; + o.raw_type = static_cast(fd.raw_type); + o.length = static_cast(fd.length); + o.src_field = fi; + } + outs.push_back(std::move(o)); + } + + // Compile each CASE branch's condition against tbl. + using CondPred = std::function; + std::function( + const openads::sql::WhereExpr&)> compile_cond; + compile_cond = [&](const openads::sql::WhereExpr& node) + -> openads::util::Result + { + using K = openads::sql::WhereExpr::Kind; + if (node.kind == K::And || node.kind == K::Or) { + std::vector ks; + for (auto& cn : node.children) { + auto r = compile_cond(*cn); + if (!r) return r.error(); + ks.push_back(std::move(r).value()); + } + bool is_and = (node.kind == K::And); + return CondPred{[ks = std::move(ks), is_and] + (openads::engine::Table& t) { + if (is_and) { + for (auto& k : ks) if (!k(t)) return false; + return true; + } + for (auto& k : ks) if (k(t)) return true; + return false; + }}; + } + if (node.kind == K::Not) { + auto inner = compile_cond(*node.child); + if (!inner) return inner.error(); + return CondPred{[p = std::move(inner).value()] + (openads::engine::Table& t) + { return !p(t); }}; + } + if (node.kind == K::In) { + std::int32_t fidx = tbl->field_index(node.in_clause.column); + if (fidx < 0) return openads::util::Error{ + openads::AE_COLUMN_NOT_FOUND, 0, + node.in_clause.column.c_str(), ""}; + std::uint16_t fi2 = static_cast(fidx); + bool ci_f = field_is_cichar(*tbl, fi2); + auto fold = [ci_f](std::string s2) { + if (ci_f) + for (char& ch : s2) + ch = static_cast(std::toupper( + static_cast(ch))); + return s2; + }; + auto set = std::make_shared>(); + for (const auto& l2 : node.in_clause.literals) + set->insert(fold(l2)); + return CondPred{[fi2, set, fold](openads::engine::Table& t) { + auto v = t.read_field(fi2); + if (!v) return false; + auto sv = v.value().as_string; + while (!sv.empty() && sv.back() == ' ') sv.pop_back(); + return set->count(fold(sv)) > 0; + }}; + } + if (node.kind != K::Cmp) { + return openads::util::Error{ + openads::AE_FUNCTION_NOT_AVAILABLE, 0, + "CASE WHEN supports Cmp / AND / OR / NOT / IN only", ""}; + } + const auto& w = node.cmp; + std::int32_t fi = tbl->field_index(w.column); + if (fi < 0) return openads::util::Error{ + openads::AE_COLUMN_NOT_FOUND, 0, + w.column.c_str(), ""}; + std::uint16_t f = static_cast(fi); + openads::sql::WhereOp op = w.op; + std::string lit = w.literal; + std::string lit2 = w.literal2; + bool is_num = w.is_numeric; + double num = w.number; + double num2 = w.number2; + std::shared_ptr> contains_hits; + if (op == openads::sql::WhereOp::Contains) { + namespace fs = std::filesystem; + fs::path fts_path = + fs::path(tbl->path()).replace_extension(".fts"); + auto loaded = openads::engine::Fts::load(fts_path.string()); + if (loaded) { + openads::engine::FtsOptions opts; + auto hits = openads::engine::Fts::search( + loaded.value(), lit, opts); + contains_hits = + std::make_shared>( + hits.begin(), hits.end()); + } + } + openads::sql::WhereFn lhs_fn = w.lhs_fn; + bool ci_f = field_is_cichar(*tbl, f); + return CondPred{[f, op, lit, lit2, is_num, num, num2, + contains_hits, lhs_fn, ci_f] + (openads::engine::Table& t) { + if (op == openads::sql::WhereOp::Contains) { + if (!contains_hits) return false; + return contains_hits->find(t.recno()) != contains_hits->end(); + } + auto v = t.read_field(f); + if (!v) return false; + if (op == openads::sql::WhereOp::IsNull || + op == openads::sql::WhereOp::IsNotNull) { + bool null_ish = t.is_field_null(f); + if (!null_ish) { + auto sv = v.value().as_string; + while (!sv.empty() && sv.back() == ' ') sv.pop_back(); + null_ish = sv.empty(); + } + return op == openads::sql::WhereOp::IsNull + ? null_ish : !null_ish; + } + if (op == openads::sql::WhereOp::Between) { + if (is_num) { + double d = v.value().as_double; + return d >= num && d <= num2; + } + auto sv = apply_where_fn(v.value().as_string, lhs_fn); + return where_str_cmp(sv, lit, ci_f) >= 0 && + where_str_cmp(sv, lit2, ci_f) <= 0; + } + if (op == openads::sql::WhereOp::Like) { + auto sv = apply_where_fn(v.value().as_string, lhs_fn); + while (!sv.empty() && sv.back() == ' ') sv.pop_back(); + return sql_like_match_t(sv, lit, ci_f); + } + int cmp = 0; + if (is_num) { + double d = v.value().as_double; + if (d < num) cmp = -1; + else if (d > num) cmp = 1; + } else { + cmp = where_str_cmp( + apply_where_fn(v.value().as_string, lhs_fn), + lit, ci_f); + } + switch (op) { + case openads::sql::WhereOp::Eq: return cmp == 0; + case openads::sql::WhereOp::Ne: return cmp != 0; + case openads::sql::WhereOp::Lt: return cmp < 0; + case openads::sql::WhereOp::Gt: return cmp > 0; + case openads::sql::WhereOp::Le: return cmp <= 0; + case openads::sql::WhereOp::Ge: return cmp >= 0; + default: return false; + } + }}; + }; + struct CompiledCase { + std::vector branch_preds; + std::vector branch_values; + bool has_else = false; + std::string else_value; + }; + std::vector ccases; + ccases.reserve(parsed.value().case_items.size()); + for (auto& ce : parsed.value().case_items) { + CompiledCase cc; + for (auto& br : ce.branches) { + auto p = compile_cond(*br.cond); + if (!p) return fail(p.error()); + cc.branch_preds.push_back(std::move(p).value()); + cc.branch_values.push_back(br.then_value.text); + } + cc.has_else = ce.has_else; + cc.else_value = ce.has_else ? ce.else_value.text : std::string(); + ccases.push_back(std::move(cc)); + } + + // Build the row list -- honor any installed recno_sequence, + // else walk the filtered cursor. + std::vector walk_seq; + if (tbl->has_recno_sequence()) { + walk_seq = tbl->recno_sequence(); + } else { + std::uint32_t rcount = tbl->record_count(); + for (std::uint32_t r = 1; r <= rcount; ++r) { + if (auto g = tbl->goto_record(r); !g) continue; + if (!tbl->show_deleted_records() && + tbl->is_deleted()) continue; + if (!tbl->passes_filter()) continue; + walk_seq.push_back(r); + } + } + + // M10.49 / M10.50 -- pre-compute window values per row when + // any window items appear in the projection. For each + // window slot, group rows by PARTITION BY key, sort within + // each group by ORDER BY (if any), then assign values per + // kind (ROW_NUMBER / RANK / DENSE_RANK). + std::unordered_map> + window_vals; + if (!parsed.value().window_items.empty()) { + window_vals.reserve(walk_seq.size()); + for (std::size_t wi = 0; + wi < parsed.value().window_items.size(); ++wi) { + const auto& wf = parsed.value().window_items[wi]; + struct Entry { + std::uint32_t recno; + std::string pkey; + std::string okey; + }; + std::vector ents; + ents.reserve(walk_seq.size()); + for (auto r : walk_seq) { + if (auto g = tbl->goto_record(r); !g) continue; + Entry e; + e.recno = r; + for (auto& pc : wf.partition_by) { + std::int32_t fi = tbl->field_index(pc); + if (fi >= 0) { + auto v = tbl->read_field( + static_cast(fi)); + if (v) e.pkey += v.value().as_string; + } + e.pkey.push_back('\x1f'); + } + if (wf.order_by) { + std::int32_t fi = + tbl->field_index(wf.order_by->column); + if (fi >= 0) { + auto v = tbl->read_field( + static_cast(fi)); + if (v) e.okey = v.value().as_string; + } + } + ents.push_back(std::move(e)); + } + std::stable_sort(ents.begin(), ents.end(), + [&](const Entry& a, const Entry& b) { + if (a.pkey != b.pkey) return a.pkey < b.pkey; + if (wf.order_by) { + return wf.order_by->descending + ? a.okey > b.okey + : a.okey < b.okey; + } + return false; + }); + std::string prev_pk; + std::string prev_ok; + bool prev_ok_set = false; + std::uint32_t pos = 0; + std::uint32_t rank_now = 0; + std::uint32_t dense_now = 0; + for (auto& e : ents) { + if (e.pkey != prev_pk) { + pos = 0; rank_now = 0; dense_now = 0; + prev_ok_set = false; + prev_pk = e.pkey; + } + ++pos; + bool tied = wf.order_by && prev_ok_set && + e.okey == prev_ok; + if (!tied) { + rank_now = pos; + ++dense_now; + } + prev_ok = e.okey; + prev_ok_set = true; + std::string val; + switch (wf.kind) { + case openads::sql::WindowFnKind::RowNumber: + val = std::to_string(pos); break; + case openads::sql::WindowFnKind::Rank: + val = std::to_string(rank_now); break; + case openads::sql::WindowFnKind::DenseRank: + val = std::to_string(dense_now); break; + } + auto& slot = window_vals[e.recno]; + if (slot.size() < + parsed.value().window_items.size()) { + slot.resize( + parsed.value().window_items.size()); + } + slot[wi] = std::move(val); + } + } + } + + // Materialise through the shared ADT temp helper + // (docs/materialised-cursor-temps.md): the DBF this used to write + // truncated CASE / window / fn aliases at 11 characters. `file` + // holds ROW IMAGES only. + std::vector ccols; + std::size_t crow_stride = 0; + ccols.reserve(outs.size()); + for (auto& o : outs) { + ccols.push_back({o.name, o.raw_type, o.length, o.decimals}); + crow_stride += o.length; + } + std::vector file; + + std::uint32_t emitted = 0; + auto trim_left = [](std::string sl) { + std::size_t i = 0; + while (i < sl.size() && sl[i] == ' ') ++i; + return sl.substr(i); + }; + auto trim_right = [](std::string sl) { + while (!sl.empty() && sl.back() == ' ') sl.pop_back(); + return sl; + }; + auto trim_both = [&](std::string sl) { + return trim_left(trim_right(std::move(sl))); + }; + for (std::uint32_t r : walk_seq) { + if (auto g = tbl->goto_record(r); !g) continue; + for (auto& o : outs) { + std::string val; + bool from_synth = false; + if (o.case_idx >= 0) { + from_synth = true; + const auto& cc = + ccases[static_cast(o.case_idx)]; + val = cc.has_else ? cc.else_value : ""; + for (std::size_t bi = 0; bi < cc.branch_preds.size(); ++bi) { + if (cc.branch_preds[bi](*tbl)) { + val = cc.branch_values[bi]; + break; + } + } + } else if (o.fn_idx >= 0) { + from_synth = true; + const auto& fc = parsed.value().fn_items[ + static_cast(o.fn_idx)]; + std::string raw; + if (o.src_field >= 0) { + auto v = tbl->read_field( + static_cast(o.src_field)); + if (v) raw = v.value().as_string; + } + using K = openads::sql::ScalarFnKind; + switch (fc.kind) { + case K::Now: + case K::Today: + case K::Date: + case K::Time: { + std::time_t t = std::time(nullptr); + std::tm tm_local{}; +#ifdef _WIN32 + localtime_s(&tm_local, &t); +#else + localtime_r(&t, &tm_local); +#endif + char buf[24]; + if (fc.kind == K::Time) + std::strftime(buf, sizeof(buf), "%H:%M:%S", &tm_local); + else if (fc.kind == K::Date || fc.kind == K::Today) + std::strftime(buf, sizeof(buf), "%Y-%m-%d", &tm_local); + else + std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm_local); + val = buf; + break; + } + case K::Upper: + for (auto& ch : raw) + ch = static_cast(std::toupper( + static_cast(ch))); + val = std::move(raw); + break; + case K::Lower: + for (auto& ch : raw) + ch = static_cast(std::tolower( + static_cast(ch))); + val = std::move(raw); + break; + case K::Len: { + std::string trimmed = trim_right(std::move(raw)); + char buf[16]; + std::snprintf(buf, sizeof(buf), "%zu", + trimmed.size()); + val = buf; + break; + } + case K::Trim: val = trim_both(std::move(raw)); break; + case K::Ltrim: val = trim_left(std::move(raw)); break; + case K::Rtrim: val = trim_right(std::move(raw)); break; + + case K::ScriptCall: { + // S4 -- whole-call text (NEWIDSTRING(), + // IIF(col < {d…}, …), Year([modtime]), nested + // Trim(Convert(…))) evaluated by the script + // expression engine with the CURRENT row's + // columns bound as parameters. compiled() caches + // by text, so the per-row cost is a hash lookup. + auto pr = scriptbridge::compiled( + "RETURN " + fc.column + ";"); + if (!pr) return fail(pr.error()); + scriptbridge::AbiBridge br(c, hStatement); + openads::script::Executor ex2(&br); + ex2.set_user(c->username()); + scriptbridge::bind_row_params(ex2, *tbl); + auto rr = ex2.run(*pr.value()); + if (!rr) return fail(rr.error()); + if (!rr.value().returned || + rr.value().return_value.is_null) { + val.clear(); + } else if (o.raw_type == 'D') { + // DBF date cell -- raw YYYYMMDD. + const auto& rv = rr.value().return_value; + std::int64_t jdn = + rv.type == + openads::script::Type::Timestamp + ? rv.i / 86400000 : rv.i; + int y2, m2, d2; + openads::script::ymd_from_jdn(jdn, y2, m2, + d2); + char db[16]; + std::snprintf(db, sizeof(db), + "%04d%02d%02d", y2, m2, d2); + val = db; + } else { + val = openads::script::to_display( + rr.value().return_value); + } + break; + } + case K::Udf: { + // RCB 07/17/2026: DD stored functions run through + // the typed script engine (docs/script-engine.md). + // Errors PROPAGATE and fail the SELECT -- SAP + // returns 7200 for a failing UDF, and the old + // silent "" here is how EoM produced 02/00/2026. + if (!c->has_dd()) break; + using SV = openads::script::Value; + using ST = openads::script::Type; + std::vector sargs; + sargs.reserve(fc.args.size()); + for (const auto& arg : fc.args) { + if (arg.is_column) { + // Read the CURRENT row's value, typed by + // the field descriptor. + auto ci_eq = [](const std::string& x, + const std::string& y) { + if (x.size() != y.size()) return false; + for (std::size_t z = 0; z < x.size(); ++z) + if (std::toupper((unsigned char)x[z]) != + std::toupper((unsigned char)y[z])) + return false; + return true; + }; + std::uint16_t nfld = tbl->field_count(); + std::uint16_t fi = nfld; + for (std::uint16_t k2 = 0; k2 < nfld; ++k2) { + if (ci_eq(tbl->field_descriptor(k2).name, + arg.column)) { + fi = k2; + break; + } + } + if (fi == nfld) + return fail(openads::AE_COLUMN_NOT_FOUND, + arg.column.c_str()); + auto v2 = tbl->read_field(fi); + if (!v2) return fail(v2.error()); + const auto& fd2 = tbl->field_descriptor(fi); + using FT = openads::drivers::DbfFieldType; + switch (fd2.type) { + case FT::Numeric: case FT::Float: + case FT::Double: case FT::Currency: + sargs.push_back(SV::real( + v2.value().as_double)); + break; + case FT::Integer: case FT::ShortInt: + case FT::AutoInc: + sargs.push_back(SV::integer( + static_cast( + v2.value().as_double))); + break; + case FT::Date: case FT::AdtDate: + sargs.push_back( + scriptbridge::value_from_text( + v2.value().as_string, + ST::Date)); + break; + case FT::DateTime: case FT::AdtTimestamp: + case FT::ModTime: + sargs.push_back( + scriptbridge::value_from_text( + v2.value().as_string, + ST::Timestamp)); + break; + case FT::Logical: + sargs.push_back( + scriptbridge::value_from_text( + v2.value().as_string, + ST::Logical)); + break; + default: { + std::string sv2 = + v2.value().as_string; + while (!sv2.empty() && + sv2.back() == ' ') + sv2.pop_back(); + sargs.push_back( + SV::character(std::move(sv2))); + } + } + } else if (arg.is_numeric) { + if (arg.number == std::floor(arg.number) && + std::abs(arg.number) < 1e15) + sargs.push_back(SV::integer( + static_cast( + arg.number))); + else + sargs.push_back(SV::real(arg.number)); + } else if (arg.is_call) { + // Nested call text (e.g. CurDate()) -- + // evaluate through the engine itself. + auto pr = scriptbridge::compiled( + "RETURN " + arg.text + ";"); + if (!pr) return fail(pr.error()); + scriptbridge::AbiBridge br(c, hStatement); + openads::script::Executor ex2(&br); + auto rr = ex2.run(*pr.value()); + if (!rr) return fail(rr.error()); + sargs.push_back( + rr.value().returned + ? std::move(rr.value().return_value) + : SV::null()); + } else { + sargs.push_back(SV::character(arg.text)); + } + } + auto rv = scriptbridge::run_dd_function( + c, hStatement, fc.fn_name, sargs); + if (!rv) return fail(rv.error()); + val = openads::script::to_display(rv.value()); + break; + } + case K::Substr: + case K::Concat: + case K::Replace: + case K::DateDiff: + case K::DateAdd: + case K::NullIf: + case K::Coalesce: + case K::IfNull: { + // M10.43 / M10.45 -- multi-arg fns. Resolve + // each arg as either a column read (with + // trailing-space trim for Char-typed slots) + // or the parsed literal. + auto arg_str = [&](const openads::sql::ScalarFnArg& a) + -> std::string { + if (!a.is_column) return a.text; + std::int32_t fi = tbl->field_index(a.column); + if (fi < 0) return std::string(); + auto fv = tbl->read_field( + static_cast(fi)); + if (!fv) return std::string(); + std::string sl = fv.value().as_string; + while (!sl.empty() && sl.back() == ' ') + sl.pop_back(); + return sl; + }; + auto arg_num = [&](const openads::sql::ScalarFnArg& a) + -> double { + if (!a.is_column) return a.number; + std::int32_t fi = tbl->field_index(a.column); + if (fi < 0) return 0.0; + auto fv = tbl->read_field( + static_cast(fi)); + return fv ? fv.value().as_double : 0.0; + }; + if (fc.kind == K::Substr && fc.args.size() >= 2) { + std::string src = arg_str(fc.args[0]); + long start = static_cast( + arg_num(fc.args[1])); // 1-based + long len = (fc.args.size() >= 3) + ? static_cast(arg_num(fc.args[2])) + : static_cast(src.size()); + if (start < 1) start = 1; + std::size_t s0 = static_cast(start - 1); + if (s0 >= src.size()) val.clear(); + else { + std::size_t take = + std::min( + len < 0 ? 0 : (std::size_t)len, + src.size() - s0); + val = src.substr(s0, take); + } + } else if (fc.kind == K::Concat) { + for (auto& a : fc.args) val += arg_str(a); + } else if (fc.kind == K::Replace && + fc.args.size() == 3) { + std::string src = arg_str(fc.args[0]); + std::string oldp = arg_str(fc.args[1]); + std::string newp = arg_str(fc.args[2]); + if (!oldp.empty()) { + std::size_t i = 0; + while ((i = src.find(oldp, i)) != + std::string::npos) { + src.replace(i, oldp.size(), newp); + i += newp.size(); + } + } + val = std::move(src); + } else if (fc.kind == K::DateDiff && + fc.args.size() == 2) { + // M10.45 -- DATEDIFF on YYYYMMDD strings: + // returns days_a - days_b via Julian day. + auto julian = [](const std::string& sl) -> long { + if (sl.size() < 8) return 0; + int y = std::atoi(sl.substr(0, 4).c_str()); + int mo = std::atoi(sl.substr(4, 2).c_str()); + int d = std::atoi(sl.substr(6, 2).c_str()); + long a = (14 - mo) / 12; + long y2 = y + 4800 - a; + long m2 = mo + 12 * a - 3; + return d + (153 * m2 + 2) / 5 + 365 * y2 + + y2 / 4 - y2 / 100 + y2 / 400 - 32045; + }; + long ja = julian(arg_str(fc.args[0])); + long jb = julian(arg_str(fc.args[1])); + char buf[32]; + std::snprintf(buf, sizeof(buf), "%ld", + ja - jb); + val = buf; + } else if (fc.kind == K::NullIf && + fc.args.size() == 2) { + // M10.53 -- NULLIF(a, b): NULL if + // equal, else a. Empty string = + // NULL by convention. + std::string a = arg_str(fc.args[0]); + std::string b = arg_str(fc.args[1]); + val = (a == b) ? std::string() : a; + } else if (fc.kind == K::Coalesce) { + // M10.53 -- first non-empty arg wins. + for (auto& a : fc.args) { + auto cs = arg_str(a); + if (!cs.empty()) { + val = std::move(cs); break; + } + } + } else if (fc.kind == K::IfNull && + fc.args.size() == 2) { + // M10.53 -- IFNULL(expr, default). + std::string a = arg_str(fc.args[0]); + val = a.empty() ? arg_str(fc.args[1]) : a; + } else if (fc.kind == K::DateAdd && + fc.args.size() == 2) { + // M10.45 -- add N days to YYYYMMDD. + std::string ds = arg_str(fc.args[0]); + if (ds.size() < 8) { val = ds; break; } + int y = std::atoi(ds.substr(0, 4).c_str()); + int mo_in = std::atoi(ds.substr(4, 2).c_str()); + int d = std::atoi(ds.substr(6, 2).c_str()); + long n = static_cast(arg_num(fc.args[1])); + long aa = (14 - mo_in) / 12; + long y2 = y + 4800 - aa; + long m2 = mo_in + 12 * aa - 3; + long jdn = d + (153 * m2 + 2) / 5 + 365 * y2 + + y2 / 4 - y2 / 100 + y2 / 400 - 32045; + jdn += n; + long la = jdn + 32044; + long b = (4 * la + 3) / 146097; + long c2 = la - (146097 * b) / 4; + long d2 = (4 * c2 + 3) / 1461; + long e = c2 - (1461 * d2) / 4; + long mn = (5 * e + 2) / 153; + int day = static_cast( + e - (153 * mn + 2) / 5 + 1); + int mo = static_cast( + mn + 3 - 12 * (mn / 10)); + int yr = static_cast( + 100 * b + d2 - 4800 + mn / 10); + char buf[16]; + std::snprintf(buf, sizeof(buf), + "%04d%02d%02d", yr, mo, day); + val = buf; + } else { + val.clear(); + } + break; + } + } + } else if (o.win_idx >= 0) { + from_synth = true; + auto wit = window_vals.find(r); + if (wit != window_vals.end() && + static_cast(o.win_idx) < + wit->second.size() && + !wit->second[ + static_cast(o.win_idx)].empty()) { + val = wit->second[ + static_cast(o.win_idx)]; + } else { + char buf[16]; + std::snprintf(buf, sizeof(buf), "%u", + static_cast(emitted + 1)); + val = buf; + } + } else if (o.arith_idx >= 0) { + from_synth = true; + const auto& ae = parsed.value().arith_items[ + static_cast(o.arith_idx)]; + auto lv = tbl->read_field( + static_cast(o.arith_lhs_field)); + double a = lv ? lv.value().as_double : 0.0; + double b = 0.0; + if (ae.rhs_is_literal) b = ae.rhs_number; + else { + auto rv = tbl->read_field( + static_cast(o.arith_rhs_field)); + b = rv ? rv.value().as_double : 0.0; + } + double res = 0.0; + using AO = openads::sql::ArithOp; + switch (ae.op) { + case AO::Add: res = a + b; break; + case AO::Sub: res = a - b; break; + case AO::Mul: res = a * b; break; + case AO::Div: res = (b != 0.0) ? a / b : 0.0; break; + } + char buf[64]; + std::snprintf(buf, sizeof(buf), "%g", res); + val = buf; + } + if (from_synth) { + if (val.size() > o.length) val.resize(o.length); + for (std::uint8_t k = 0; k < o.length; ++k) { + file.push_back(k < val.size() + ? static_cast(val[k]) : ' '); + } + } else { + auto v = tbl->read_field( + static_cast(o.src_field)); + std::string raw = v ? v.value().as_string : std::string(); + for (std::uint8_t k = 0; k < o.length; ++k) { + file.push_back(k < raw.size() + ? static_cast(raw[k]) : ' '); + } + } + } + ++emitted; + } + (void)emitted; + return materialise_temp_adt(c, "_case_", ccols, file, + crow_stride, phCursor); + } + + // RCB 07/16/2026: column-level permission enforcement. If the connected + // user is column-restricted on the source table, the cursor must expose + // ONLY the columns they may SELECT -- SAP grants a group table access but + // hides specific columns, and OpenADS used to leak the whole table. We + // reuse the existing cursor-projection mechanism: SELECT * projects the + // permitted columns; an explicit projection naming a forbidden column is + // denied (matching SAP). Only reached for simple single-table SELECTs; + // join/aggregate queries take the materialisation path above. + std::optional> allowed_cols; + if (c->has_dd() && !c->username().empty()) { + auto* dd = c->dd(); + if (dd != nullptr && dd->has_any_column_acl()) { + allowed_cols = dd->permitted_columns( + c->username(), parsed.value().table, + openads::engine::DataDict::DD_PERM_SELECT); + } + } + auto col_lower = [](std::string s) { + for (auto& ch : s) + ch = static_cast(std::tolower(static_cast(ch))); + return s; + }; + + // ADS static-cursor semantics -- single-table SELECT with ORDER BY / + // DISTINCT / LIMIT must be a STANDALONE temp table, not the live source + // with a recno_sequence. Real ACE returns a static cursor (its own temp + // table) for these; the ERP then runs `INDEX ON ... ; DBSETORDER(n)` on + // the result. If the cursor were the live `.dbf`, those index ops + // would hit the production table and REWRITE its official .cdx (the user + // saw "cualquier SELECT-SQL reescribio los indices originales"), and a + // recno_sequence over the live table makes DBSETORDER a no-op for the + // browse. Materialising the result into a clean temp DBF (its own recnos + // 1..N, its own index space) isolates it from the source: INDEX ON / + // DBSETORDER behave exactly like DBFCDX / ADS_CDX. Same shape the + // multi-table / union / aggregate / CASE paths already produce. + if (derived_cur == 0 && tbl->has_recno_sequence()) { + ADSHANDLE conn_h = 0; + s.registry.for_each_handle([&](Handle h, HandleKind k, void* p) { + if (k != HandleKind::Connection) return; + if (static_cast(p) == c) conn_h = to_ads_handle(h); + }); + if (conn_h != 0) { + std::vector cols; + bool col_err = false; + bool col_denied = false; + if (parsed.value().projection.empty()) { + std::uint16_t nf = tbl->field_count(); + cols.reserve(nf); + for (std::uint16_t k = 0; k < nf; ++k) { + // A column-restricted user must not get the hidden columns + // copied into the temp table either (the projection applied + // to a live cursor below can't reach a materialised one). + if (allowed_cols && + allowed_cols->find(col_lower( + tbl->field_descriptor(k).name)) == + allowed_cols->end()) { + continue; + } + cols.push_back(k); + } + } else { + cols.reserve(parsed.value().projection.size()); + for (const auto& cn : parsed.value().projection) { + std::int32_t fi = tbl->field_index(cn); + if (fi < 0) { col_err = true; break; } + if (allowed_cols && + allowed_cols->find(col_lower(cn)) == + allowed_cols->end()) { + col_denied = true; break; + } + cols.push_back(static_cast(fi)); + } + } + if (col_err) return fail(openads::AE_COLUMN_NOT_FOUND, ""); + if (col_denied) return fail(openads::AE_ACCESS_DENIED, + "no column permission"); + // `dec` decides how a numeric is spelled for the ADT temp below. + // "Numeric" with decimals becomes an ADT DOUBLE, which cannot carry + // a decimal count on disk, so N(12,2) would come back "10.5" instead + // of "10.50" and undo issue #146. "AsciiNumeric" pins ADT type 2 + // (digits stored as text), which keeps the declared scale. + auto type_name = [](char raw, std::uint8_t dec) -> const char* { + // TWO switch blocks, and both are required. A DBF field + // descriptor carries a printable letter ('C', 'N', 'D', ...); + // an ADT one carries a NUMERIC type code (1-22) in the same + // field. The ranges are disjoint - DBF letters are all >= 'B' + // (0x42), ADT codes top out at 22 - so there is no ambiguity. + // + // Handling only the letters silently sent every non-character + // ADT column down the `return "Character"` fallback with its + // ON-DISK byte length: an ADT date (type 3, 4 bytes on disk) + // became CHAR(4), so "20090816" was stored as "2009". Numerics + // survived by luck, because ADT type 2 is ASCII text anyway. + // Mirrors the CTAS path, which has always had both blocks. + switch (raw) { + case 'C': return "Character"; + case 'N': return dec > 0 ? "AsciiNumeric" : "Numeric"; + case 'D': return "Date"; case 'L': return "Logical"; + case 'M': return "Memo"; + case 'F': return dec > 0 ? "AsciiNumeric" : "Numeric"; + case 'I': return "Integer"; case 'Y': return "Currency"; + case 'B': return "Double"; case 'V': return "Varchar"; + case 'Q': return "Varbinary"; + } + switch (static_cast(raw)) { + case 1: return "Logical"; + case 2: return dec > 0 ? "AsciiNumeric" : "Numeric"; + case 3: return "Date"; + case 4: return "Character"; + case 5: return "Memo"; + case 6: return "Binary"; + case 7: return "Image"; + case 10: return "Double"; + case 11: return "Integer"; + case 12: return "ShortInt"; + case 13: return "Time"; + case 14: return "Timestamp"; + case 15: return "AutoInc"; + case 18: return "Money"; + case 20: return "CiCharacter"; + } + return "Character"; + }; + // S4 -- a materialised cursor carries its OWN descriptors, so the + // SELECT-list naming applied to a live cursor via cursor_aliases() + // cannot reach it; the names have to be baked into the temp's DDL + // here instead. cols[i] corresponds to projection[i] one-for-one + // (a denied column aborts rather than being skipped), so the two + // stay aligned. SELECT * leaves this empty and keeps the declared + // spelling, which is what SAP does too. + std::vector tmp_names; + if (!parsed.value().projection.empty()) + tmp_names = build_projection_aliases(parsed.value(), + cols.size()); + std::string defs; + for (std::size_t ci = 0; ci < cols.size(); ++ci) { + const auto cidx = cols[ci]; + const auto& fd = tbl->field_descriptor(cidx); + if (!defs.empty()) defs.push_back(';'); + defs += (ci < tmp_names.size() && !tmp_names[ci].empty()) + ? tmp_names[ci] : fd.name; + defs.push_back(','); + defs += type_name(static_cast(fd.raw_type), fd.decimals); + if (fd.length > 0) { defs.push_back(','); defs += std::to_string(fd.length); } + if (fd.decimals > 0) { defs.push_back(','); defs += std::to_string(fd.decimals); } + } + char nb[64]; + std::snprintf(nb, sizeof(nb), "_srt_%llx", + static_cast( + openads::platform::monotonic_nanos())); + std::string tmp_name = nb; + std::vector name_buf(tmp_name.size() + 1, 0); + std::memcpy(name_buf.data(), tmp_name.data(), tmp_name.size()); + std::vector def_buf(defs.size() + 1, 0); + std::memcpy(def_buf.data(), defs.data(), defs.size()); + ADSHANDLE hNew = 0; + // >>> Before changing this format, read + // >>> docs/materialised-cursor-temps.md. Three constraints pin it + // >>> and two of them are invisible until a specific customer + // >>> scenario breaks; both have already been regressed once. + // + // ADS_ADT, not ADS_CDX -- the temp must preserve full column names. + // + // A DBF field descriptor allots 11 bytes to the name, so a DBF temp + // silently truncates every column to 10 characters. That is not + // cosmetic: SAP's own catalog names are routinely longer + // (RI_Primary_Table 16, Enable_Internet 15, User_Defined_Prop 17, + // Trig_Function_Name 18), so a DBF temp turned + // SELECT Name, RI_Primary_Table FROM system.relations ORDER BY Name + // into a result whose second column was called RI_Primary -- undoing + // the SAP column-name parity work and breaking any client that then + // referenced the column by name. User tables with long columns were + // mangled the same way. ADT carries full-length names. + // + // ADT also keeps what #146 came here for: this is still a standalone + // temp with its own recnos and its own index space, so an + // application running INDEX ON / DBSETORDER over the result cannot + // touch the source table's production index. Only the on-disk + // format changed (index companion is .adi rather than .cdx), which + // is transparent through the cursor handle. + UNSIGNED32 crc = AdsCreateTable(conn_h, name_buf.data(), nullptr, + ADS_ADT, 0, 0, 0, 0, + def_buf.data(), &hNew); + if (crc == openads::AE_SUCCESS) { + openads::engine::Table* tgt = + s.registry.lookup( + hNew, HandleKind::Table); + if (tgt != nullptr) { + std::vector seq = tbl->recno_sequence(); + for (std::uint32_t r : seq) { + if (auto g = tbl->goto_record(r); !g) continue; + if (auto ar = tgt->append_record(); !ar) break; + for (std::size_t i = 0; i < cols.size(); ++i) { + auto v = tbl->read_field(cols[i]); + if (!v) continue; + const auto ti = static_cast(i); + // Copy through the setter that matches how the + // TARGET field is stored, not always the string one. + // + // Numeric/Float are ASCII in both DBF and ADT (see + // decode_field), so as_string carries the declared + // scale exactly -- "10.50" stays "10.50". Writing a + // double there would re-render it as "10.5" and lose + // the N(12,2) formatting that #146 exists to protect. + // + // The genuinely BINARY numerics below are the ones a + // string write corrupts: on ADT they are raw little + // endian values, so pushing text into them made + // "10.50" read back as ~6e-154. That never showed on + // the old DBF temp because DBF stores numerics as + // ASCII, so the string write round-tripped by + // accident. + switch (tgt->field_descriptor(ti).type) { + case openads::drivers::DbfFieldType::Integer: + case openads::drivers::DbfFieldType::Double: + case openads::drivers::DbfFieldType::Currency: + case openads::drivers::DbfFieldType::ShortInt: + case openads::drivers::DbfFieldType::AutoInc: + case openads::drivers::DbfFieldType::AdtMoney: + (void)tgt->set_field(ti, v.value().as_double); + break; + case openads::drivers::DbfFieldType::Logical: + (void)tgt->set_field(ti, v.value().as_bool); + break; + default: + (void)tgt->set_field(ti, v.value().as_string); + break; + } + } + } + (void)tgt->flush(); + } + AdsCloseTable(hNew); + // Drop the live source cursor so the ERP's INDEX ON / close + // never touches the production table or its official .cdx. + if (table_handle != 0) c->close_table(table_handle); + // Must match the type the temp was CREATED as (ADT, above). + auto cth = c->open_table(tmp_name, + openads::engine::TableType::Adt, + openads::engine::OpenMode::Shared); + if (!cth) return fail(cth.error()); + openads::engine::Table* ctbl = c->lookup_table(cth.value()); + if (!ctbl) return fail(openads::AE_INTERNAL_ERROR, + "sorted temp post-open"); + ADSHANDLE gh_srt = + to_ads_handle(s.registry.register_object(HandleKind::Table, ctbl)); + // Tie the temp table's lifetime to the cursor handle: without + // this, every SELECT ... ORDER BY leaves a _srt_*.dbf (and any + // index the caller built on it) behind in the data directory. + materialised_cursor_temps()[gh_srt] = + (std::filesystem::path(c->data_dir()) / tmp_name).string(); + *phCursor = gh_srt; + return ok(); + } + // AdsCreateTable failed -> fall through to the live-cursor return. + } + } + + // M10.46 -- when this query was a derived-table outer SELECT, + // reuse the inner cursor's existing handle so the user-visible + // cursor isn't a stale alias of an already-registered Table*. + ADSHANDLE gh = (derived_cur != 0) + ? derived_cur + : to_ads_handle(s.registry.register_object(HandleKind::Table, tbl)); + + if (!parsed.value().projection.empty()) { + std::vector proj; + proj.reserve(parsed.value().projection.size()); + for (const auto& col : parsed.value().projection) { + std::int32_t fidx = tbl->field_index(col); + if (fidx < 0) { + return fail(openads::AE_COLUMN_NOT_FOUND, col.c_str()); + } + if (allowed_cols && + allowed_cols->find(col_lower(col)) == allowed_cols->end()) { + return fail(openads::AE_ACCESS_DENIED, + ("no column permission: " + col).c_str()); + } + proj.push_back(static_cast(fidx)); + } + cursor_aliases()[gh] = + build_projection_aliases(parsed.value(), proj.size()); + cursor_projections()[gh] = std::move(proj); + } else if (allowed_cols) { + // SELECT * by a column-restricted user → project only permitted columns, + // in table order. + std::vector proj; + const std::uint16_t nf = static_cast(tbl->field_count()); + for (std::uint16_t i = 0; i < nf; ++i) { + const std::string& fname = tbl->field_descriptor(i).name; + if (allowed_cols->find(col_lower(fname)) != allowed_cols->end()) + proj.push_back(i); + } + cursor_projections()[gh] = std::move(proj); + } + + *phCursor = gh; + return ok(); +} + +// Thin export over the SQL dispatcher above: a failing statement also +// lands in the SAP-style ads_err error log with the statement text -- +// matching ADS, whose error log records the SQL errors (7200 etc.) it +// raises while serving clients. Success paths pay nothing. +// S4 -- AQE envelope depth guard. Internal recursion (derived tables, +// INTO snapshots, script-bridge embedded SQL) re-enters +// AdsExecuteSQLDirect; only the OUTERMOST, client-facing call wraps the +// failure as SAP's rc-7200 AQE envelope. Inner calls keep the native +// code + message so engine/script error handling is unaffected -- same +// as SAP, whose envelopes carry the innermost native code. +static thread_local int sql_exec_depth_ = 0; + +UNSIGNED32 ENTRYPOINT AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, + ADSHANDLE* phCursor) { + arc2_trace("AdsExecuteSQLDirect"); + arc2_trace("AdsExecuteSQLDirect"); + struct DepthGuard { + ~DepthGuard() { --sql_exec_depth_; } + } depth_guard; + ++sql_exec_depth_; + UNSIGNED32 rc = exec_sql_direct_impl(hStatement, pucSQL, phCursor); + if (rc != openads::AE_SUCCESS && sql_exec_depth_ == 1) { + std::string sql_text = pucSQL != nullptr + ? openads::abi::to_internal(pucSQL, 0) : std::string(); + std::string env = scriptbridge::sql_error_envelope( + static_cast(rc), + openads::abi::last_error_message(), sql_text); + openads::abi::set_last_error(openads::util::Error{ + 7200, 0, std::move(env), ""}); + rc = 7200; + } + // RCB 07/15/2026: SAP ADS positions a SQL result cursor ON the first + // record after execute; OpenADS was leaving the engine Table at BOF, so a + // client that reads the current record immediately (the SAP-supported + // pattern) got a phantom empty row on EVERY query -- proven with + // `SELECT COUNT(*)` returning two rows (blank, then the count) vs SAP's + // one. Position the result at row 1 here, at the public-API boundary, so + // it applies uniformly to engine tables, memory-backed system.*/aggregate + // results, and SQL-backend cursors. GotoTop is absolute/idempotent, so a + // caller that also calls AdsGotoTop is unaffected. Write statements set + // *phCursor == 0 and are skipped. + if (rc == openads::AE_SUCCESS && phCursor != nullptr && *phCursor != 0) { + (void)AdsGotoTop(*phCursor); + } + if (rc != openads::AE_SUCCESS) { + std::string sql = pucSQL != nullptr + ? openads::abi::to_internal(pucSQL, 0) : std::string(); + if (sql.size() > 160) sql.resize(160); + std::string msg = openads::abi::last_error_message(); + openads::mgmt::ErrorLog::instance().log( + static_cast(rc), "SQL", 0, + msg.empty() ? sql : (msg + " | " + sql)); + } + return rc; +} + +UNSIGNED32 ENTRYPOINT AdsExecuteSQLDirectW(ADSHANDLE hStatement, UNSIGNED16* pwcSQL, + ADSHANDLE* phCursor) { + arc2_trace("AdsExecuteSQLDirectW"); + arc2_trace("AdsExecuteSQLDirectW"); + if (pwcSQL == nullptr) return fail(openads::AE_PARSE_ERROR, "null SQL"); + std::string sql; + utf16z_to_utf8(pwcSQL, &sql); + std::vector bytes(sql.begin(), sql.end()); + bytes.push_back(0); + return AdsExecuteSQLDirect(hStatement, bytes.data(), phCursor); +} + +// ---- Date display format (AdsSetDateFormat / AdsGetDateFormat) ------------- +// +// ACE keeps one process-wide date display string. SAP's default is +// "MM/DD/CCYY" (probed: AdsGetDateFormat on a fresh ace64.dll), and ours +// now matches. The format drives AdsGetFIELD / AdsGetDate display and +// AdsSetDate parsing; AdsGetSTRING deliberately stays raw "YYYYMMDD" -- +// SAP behaves the same way, and php_ads' date handling depends on it +// (see docs/date-display-format.md before changing ANY of this). +// +// This file is largely one big `extern "C"` block; these helpers +// return std::string / a small struct, so they need C++ linkage. +extern "C++" { +namespace { + +std::string g_date_format = "MM/DD/CCYY"; + +// AdsGetString's delegated remote/backend reads set this so the shared +// AdsGetField path skips display formatting (declared near the top). +thread_local bool g_field_read_raw = false; + +// Connection-wide settings stored so their Set/Get pairs round-trip. +// AdsSetDefault / AdsGetDefault, AdsSetSearchPath / AdsGetSearchPath and +// AdsSetDecimals (queried by STR-style formatting callers via their own +// getter when present). OpenADS resolves paths against the connection's +// own data path, so g_default_path is recorded for API parity. +std::string g_default_path; +std::string g_search_path; +std::uint16_t g_set_decimals = 2; + +// Render (y, m, d) through an ACE-style format string. Recognised, +// case-insensitively: CCYY / YYYY → 4-digit year, YY → 2-digit year, +// MM → 2-digit month, DD → 2-digit day. Every other character is +// copied verbatim, so separators ("/", "-", ".") pass straight through. +std::string format_ace_date(const std::string& fmt, int y, int m, int d) { + char two_y[4], two[4], four[8]; + std::snprintf(two_y, sizeof(two_y), "%02d", ((y % 100) + 100) % 100); + std::snprintf(four, sizeof(four), "%04d", y); + std::string up; + up.reserve(fmt.size()); + for (char c : fmt) + up.push_back(static_cast(std::toupper( + static_cast(c)))); + std::string out; + out.reserve(fmt.size() + 4); + for (std::size_t i = 0; i < up.size(); ) { + auto at = [&](const char* tok) { + std::size_t L = std::strlen(tok); + return up.compare(i, L, tok) == 0; + }; + if (at("CCYY") || at("YYYY")) { out += four; i += 4; } + else if (at("YY")) { out += two_y; i += 2; } + else if (at("MM")) { std::snprintf(two, sizeof(two), "%02d", m); + out += two; i += 2; } + else if (at("DD")) { std::snprintf(two, sizeof(two), "%02d", d); + out += two; i += 2; } + else { out.push_back(up[i]); ++i; } + } + return out; +} + +// The format with every digit position blanked -- SAP renders an empty +// date through AdsGetField as " / / " (separators kept, digits +// spaces), NOT as an empty string. +std::string blank_ace_date(const std::string& fmt) { + std::string up; + up.reserve(fmt.size()); + for (char c : fmt) + up.push_back(static_cast(std::toupper( + static_cast(c)))); + std::string out; + out.reserve(fmt.size()); + for (std::size_t i = 0; i < up.size(); ) { + auto at = [&](const char* tok) { + std::size_t L = std::strlen(tok); + return up.compare(i, L, tok) == 0; + }; + if (at("CCYY") || at("YYYY")) { out += " "; i += 4; } + else if (at("YY") || at("MM") || at("DD")) { out += " "; i += 2; } + else { out.push_back(up[i]); ++i; } + } + return out; +} + +// SAP display rule for AdsGetField / AdsGetDate on a DATE column, +// probed against ace64.dll (docs/date-display-format.md): +// raw "20240115" -> "01/15/2024" (per the current format) +// blank -> " / / " +// `raw` is the engine's internal decode ("YYYYMMDD", or empty/blanks). +std::string format_date_display(const std::string& raw) { + if (raw.size() == 8) { + bool digits = true; + for (char c : raw) + digits = digits && std::isdigit(static_cast(c)); + if (digits) { + const int y = std::stoi(raw.substr(0, 4)); + const int m = std::stoi(raw.substr(4, 2)); + const int d = std::stoi(raw.substr(6, 2)); + return format_ace_date(g_date_format, y, m, d); + } + } + return blank_ace_date(g_date_format); +} + +// Parse `text` positionally against the CURRENT date format. Returns the +// compact "YYYYMMDD", or an empty string when the text does not fit the +// format (wrong length, non-digits in digit positions, month/day out of +// range). A 2-digit year resolves through the epoch the way DBF readers +// do: >= (epoch % 100) -> epoch century, else the next one. +std::string parse_date_by_format(const std::string& text) { + std::string up; + up.reserve(g_date_format.size()); + for (char c : g_date_format) + up.push_back(static_cast(std::toupper( + static_cast(c)))); + if (text.size() != up.size()) { + // A 2-digit-year format is 2 shorter than its 4-digit twin; no + // other length mismatch can fit. + return std::string(); + } + int y = -1, m = -1, d = -1; + std::size_t i = 0, t = 0; + auto digits = [&](std::size_t n, int* out_v) { + int v = 0; + for (std::size_t k = 0; k < n; ++k) { + const char c = t + k < text.size() ? text[t + k] : '\0'; + if (!std::isdigit(static_cast(c))) return false; + v = v * 10 + (c - '0'); + } + *out_v = v; + t += n; + return true; + }; + while (i < up.size()) { + auto at = [&](const char* tok) { + return up.compare(i, std::strlen(tok), tok) == 0; + }; + if (at("CCYY") || at("YYYY")) { + if (!digits(4, &y)) return std::string(); + i += 4; + } else if (at("YY")) { + int yy = 0; + if (!digits(2, &yy)) return std::string(); + const int epoch = static_cast(openads::engine::epoch()); + const int cut = epoch % 100; + y = (yy >= cut ? epoch - cut : epoch - cut + 100) + yy; + i += 2; + } else if (at("MM")) { + if (!digits(2, &m)) return std::string(); + i += 2; + } else if (at("DD")) { + if (!digits(2, &d)) return std::string(); + i += 2; + } else { + if (t >= text.size() || text[t] != up[i]) return std::string(); + ++t; ++i; + } + } + if (y < 0 || m < 1 || m > 12 || d < 1 || d > 31) return std::string(); + char out[16]; + std::snprintf(out, sizeof(out), "%04d%02d%02d", y, m, d); + return out; +} + +// SAP display rule for AdsGetField on a TIMESTAMP column: +// " hh:mm:ss AM|PM" -- 12-hour clock, 2-digit hour +// ("01/15/2024 01:45:59 PM", len 22 under the default format), and a +// blank timestamp renders as " / / 12:00:00 AM". +// `raw` is the engine's internal decode ("YYYYMMDDhhmmss", or empty). +std::string format_timestamp_display(const std::string& raw) { + int hh = 0, mi = 0, ss = 0; + std::string date_part; + bool have = raw.size() >= 14; + if (have) + for (std::size_t i = 0; i < 14; ++i) + have = have && std::isdigit(static_cast(raw[i])); + if (have) { + date_part = format_date_display(raw.substr(0, 8)); + hh = std::stoi(raw.substr(8, 2)); + mi = std::stoi(raw.substr(10, 2)); + ss = std::stoi(raw.substr(12, 2)); + } else { + date_part = blank_ace_date(g_date_format); + } + const char* ampm = hh < 12 ? "AM" : "PM"; + int h12 = hh % 12; + if (h12 == 0) h12 = 12; + char t[16]; + std::snprintf(t, sizeof(t), " %02d:%02d:%02d %s", h12, mi, ss, ampm); + return date_part + t; +} + +// Read the DBF header's "last updated" stamp (header bytes 1..3 are +// YY MM DD, the year byte being an offset from 1900) straight off the +// table file. Returns {0,0,0} when the table has no driver or the +// read fails. Matches the convention in drivers/dbf_common.cpp. +struct HeaderDate { int y = 0, m = 0, d = 0; }; +HeaderDate read_header_date(openads::engine::Table* t) { + HeaderDate r; + if (t == nullptr || t->driver() == nullptr) return r; + std::uint8_t b[3] = {0, 0, 0}; + auto got = t->driver()->file().read_at(1, b, sizeof(b)); + if (!got || got.value() < sizeof(b)) return r; + r.y = 1900 + static_cast(b[0]); + r.m = static_cast(b[1]); + r.d = static_cast(b[2]); + return r; +} + +// Copy `s` (plus NUL) into a caller buffer using the ACE in/out length +// convention: *pusLen in = capacity, out = the string's true length; +// the copy is truncated to fit. No-op when pusLen is null. +void copy_ace_string(const std::string& s, UNSIGNED8* buf, UNSIGNED16* pusLen) { + if (pusLen == nullptr) return; + UNSIGNED16 cap = *pusLen; + if (buf != nullptr && cap > 0) { + std::size_t n = std::min(s.size(), + static_cast(cap - 1)); + std::memcpy(buf, s.data(), n); + buf[n] = 0; + } + *pusLen = static_cast(s.size()); +} + +} // namespace +} // extern "C++" + +// ---- M(rddads-compat): stubs for Harbour contrib/rddads ------------------- +// +// rddads.hbp pulls in symbols across the full SAP ace.h surface even if +// only a handful are actually exercised by `dbUseArea( .T., "ADS", ... )`. +// To make rddads link clean against ace64.lib, every referenced symbol +// must resolve. The stubs below return AE_FUNCTION_NOT_AVAILABLE for +// features the engine doesn't implement yet (advisory-only management +// API, AOF, scoped relations, callbacks); apps that don't touch those +// features still link and run. + +#define ADS_STUB(rc) return (rc) + +extern "C" { + +UNSIGNED32 ENTRYPOINT AdsConnect(UNSIGNED8* pucServer, ADSHANDLE* phConnect) { + arc2_trace("AdsConnect"); + return AdsConnect60(pucServer, ADS_LOCAL_SERVER, + nullptr, nullptr, 0, phConnect); +} +UNSIGNED32 ENTRYPOINT AdsApplicationExit(void) { + arc2_trace("AdsApplicationExit"); ADS_STUB(openads::AE_SUCCESS); } + +UNSIGNED32 ENTRYPOINT AdsClearRelation(ADSHANDLE hParent) { + arc2_trace("AdsClearRelation"); + forget_relations(hParent); + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsClearCallbackFunction(void) { + arc2_trace("AdsClearCallbackFunction"); ADS_STUB(openads::AE_SUCCESS); } +UNSIGNED32 ENTRYPOINT AdsClearProgressCallback(void) { + arc2_trace("AdsClearProgressCallback"); ADS_STUB(openads::AE_SUCCESS); } +UNSIGNED32 ENTRYPOINT AdsCacheOpenCursors(UNSIGNED16) { + arc2_trace("AdsCacheOpenCursors"); + arc2_trace("AdsCacheOpenCursors"); ADS_STUB(openads::AE_SUCCESS); } +UNSIGNED32 ENTRYPOINT AdsCacheOpenTables(UNSIGNED16) { + arc2_trace("AdsCacheOpenTables"); ADS_STUB(openads::AE_SUCCESS); } +UNSIGNED32 ENTRYPOINT AdsCacheRecords(ADSHANDLE hTable, UNSIGNED16 usNumRecords) { + arc2_trace("AdsCacheRecords"); + // RCB 07/14/2026: M12.23 -- this used to be a no-op ("OpenADS does not + // pre-cache rows"), which stopped being true back in M12.21 and is now + // thoroughly untrue. The caller's depth now rides on every subsequent Skip + // for this table (see kPrefetchDepthAuto in wire.h) and overrides the + // server's automatic ramp. + // + // SAP's semantics (ace_adscacherecords.htm), which we mirror: + // - 0 or 1 "effectively turns read-ahead record caching off"; + // - big values suit a one-directional batch sweep, and are a bad idea + // when most visited records get edited, because "any editing of data + // causes the cache to be dumped". + // + // The value is a CEILING, not a promise: we will not read further ahead + // than asked, but the byte budget can still hand back fewer rows. + if (auto* rt = get_remote_table(hTable)) { + rt->cache_records_hint = usNumRecords; + // Turning caching off has to bite NOW, not at the next refill. Rows + // already queued would otherwise keep being served locally out of the + // old block -- and a caller who just disabled read-ahead is, per SAP's + // own guidance, usually about to start editing and specifically needs + // to stop seeing cached data. + if (usNumRecords <= 1) rt->invalidate_prefetch(); + return ok(); + } + // Local (in-process) tables: there is no wire to read ahead of, and the + // driver already keeps a 64 KB block cache under the cursor, so the hint + // has nothing to tune. Validate the handle and succeed, as before. + if (get_table(hTable) != nullptr) return ok(); + return fail(openads::AE_INTERNAL_ERROR, "unknown table"); +} +UNSIGNED32 ENTRYPOINT AdsCloseCachedTables(ADSHANDLE) { + arc2_trace("AdsCloseCachedTables"); ADS_STUB(openads::AE_SUCCESS); } +UNSIGNED32 ENTRYPOINT AdsCopyTableContent(ADSHANDLE hSrc, ADSHANDLE hDst) { + arc2_trace("AdsCopyTableContent"); + Table* src = get_table(hSrc); + Table* dst = get_table(hDst); + if (!src || !dst) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + + // Build a field-name mapping: for each source field find the + // matching destination field (by name). Fields that exist only in + // one table are silently skipped, which is the documented ADS + // behaviour -- no schema match required. + struct FieldPair { std::uint16_t si; std::uint16_t di; }; + std::vector pairs; + std::uint16_t nc = src->field_count(); + for (std::uint16_t si = 0; si < nc; ++si) { + std::int32_t di = dst->field_index(src->field_descriptor(si).name); + if (di >= 0) pairs.push_back({si, static_cast(di)}); + } + + std::uint32_t rcount = src->record_count(); + for (std::uint32_t r = 1; r <= rcount; ++r) { + if (auto g = src->goto_record(r); !g) continue; + if (!src->show_deleted_records() && + src->is_deleted()) continue; + if (auto ar = dst->append_record(); !ar) return fail(ar.error()); + for (auto& fp : pairs) { + auto v = src->read_field(fp.si); + if (!v) continue; + (void)dst->set_field(fp.di, v.value().as_string); + } + } + if (auto fl = dst->flush(); !fl) return fail(fl.error()); + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsCustomizeAOF(ADSHANDLE hTable, UNSIGNED32 ulNumRecords, + UNSIGNED32* pulRecords, UNSIGNED16 usOption) { + arc2_trace("AdsCustomizeAOF"); + bool include; + switch (usOption) { + case ADS_AOF_ADD_RECORD: include = true; break; + case ADS_AOF_REMOVE_RECORD: include = false; break; + default: return fail(openads::AE_INTERNAL_ERROR, "invalid AOF option"); + } + if (pulRecords == nullptr || ulNumRecords == 0) return ok(); + + if (auto* rt = get_remote_table(hTable)) { + if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; + std::vector recnos; + recnos.reserve(ulNumRecords); + for (UNSIGNED32 i = 0; i < ulNumRecords; ++i) + recnos.push_back(pulRecords[i]); + remote_settle_cursor(rt); + rt->row_valid = false; + rt->prefetch_queue.clear(); + auto r = rt->conn->customize_aof(rt->id, usOption, recnos); + if (!r) return fail(r.error()); + return ok(); + } + + Table* t = get_table(hTable); + if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); + if (!t->aof_active()) + return fail(openads::AE_INTERNAL_ERROR, "no active AOF on table"); + for (UNSIGNED32 i = 0; i < ulNumRecords; ++i) + (void)t->customize_aof_record(pulRecords[i], include); + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsData(UNSIGNED16, void*) { + arc2_trace("AdsData"); ADS_STUB(openads::AE_SUCCESS); } +// SAP / rddads signature: AdsEvalAOF(hTable, pucExpr, *pusOptLevel). +// Returns the optimisation level (ADS_OPTIMIZED_NONE / PART / FULL) +// the engine would use to evaluate the filter. Currently a stub -- +// caller's *pusOptLevel is zeroed (= ADS_OPTIMIZED_NONE). +UNSIGNED32 ENTRYPOINT AdsEvalAOF(ADSHANDLE, UNSIGNED8*, UNSIGNED16* pusOptLevel) + { + arc2_trace("AdsEvalAOF"); if (pusOptLevel) *pusOptLevel = 0; + return openads::AE_SUCCESS; } +UNSIGNED32 ENTRYPOINT AdsFilterOption(ADSHANDLE, UNSIGNED16, UNSIGNED16* p) + { + arc2_trace("AdsFilterOption"); if (p) *p = 0; return openads::AE_SUCCESS; } +// SAP / rddads signature: AdsGetAOF(hTable, pucFilter, *pusLen). +// pucFilter is a caller-allocated buffer; pusLen is in/out (capacity +// in, actual filter length out). We don't track per-table AOF source +// strings yet (only the evaluated bitmap), so return an empty filter +// -- Harbour's ADSGETAOF treats that as "no AOF" and returns "". +UNSIGNED32 ENTRYPOINT AdsGetAOF(ADSHANDLE hTable, UNSIGNED8* pucFilter, UNSIGNED16* pusLen) { + arc2_trace("AdsGetAOF"); + if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (auto* rt = get_remote_table(hTable)) { + openads::abi::copy_to_caller(pucFilter, pusLen, rt->aof_expr); + return ok(); + } + Table* t = get_table(hTable); + if (t == nullptr) { + if (pucFilter && *pusLen > 0) pucFilter[0] = '\0'; + *pusLen = 0; + return ok(); + } + openads::abi::copy_to_caller(pucFilter, pusLen, t->aof_expr()); + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsGetAOF100(ADSHANDLE hTable, void* pvFilter, + UNSIGNED16* pusLen, UNSIGNED32 /*ulOptions*/) { + arc2_trace("AdsGetAOF100"); + return AdsGetAOF(hTable, reinterpret_cast(pvFilter), pusLen); +} +UNSIGNED32 ENTRYPOINT AdsGetConnectionType(ADSHANDLE hConnect, UNSIGNED16* p) { + arc2_trace("AdsGetConnectionType"); + arc2_trace("AdsGetConnectionType"); + if (p == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *p = ADS_LOCAL_SERVER; + // If the handle resolves to a remote connection, report REMOTE. + // NOTE: must check RemoteConnection (not get_remote_table): a + // connection handle is never a table handle, so the old check always + // reported LOCAL and made ARC treat tcp:// connections as local — + // its table grid then crashed (AV executing data, 2026-08-13). + if (get_remote_connection(hConnect) != nullptr || + get_remote_table(hConnect) != nullptr) { + *p = ADS_REMOTE_SERVER; + return ok(); + } + Connection* c = lookup_connection(hConnect); + if (c != nullptr) { + *p = ADS_LOCAL_SERVER; + } + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsGetDateFormat(UNSIGNED8* pucBuf, UNSIGNED16* pusLen) { + arc2_trace("AdsGetDateFormat"); + copy_ace_string(g_date_format, pucBuf, pusLen); + return openads::AE_SUCCESS; +} +UNSIGNED32 ENTRYPOINT AdsGetDefault(UNSIGNED8* p, UNSIGNED16* l) { + arc2_trace("AdsGetDefault"); + if (l == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + openads::abi::copy_to_caller(p, l, g_default_path); + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsGetDeleted(UNSIGNED16* p) { + arc2_trace("AdsGetDeleted"); + arc2_trace("AdsGetDeleted"); + if (p == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + // show_deleted()==true means "show deleted records" which is + // SET DELETED OFF (the Clipper default). AdsGetDeleted returns + // 1 when deleted records ARE visible, matching ACE semantics. + bool visible = openads::engine::show_deleted(); + if (Connection* c = lookup_connection(resolve_connection_handle(0))) { + visible = c->show_deleted(); + } + *p = visible ? 1 : 0; + return ok(); +} +// AdsGetDouble already defined elsewhere in this file. +UNSIGNED32 ENTRYPOINT AdsGetEpoch(UNSIGNED16* p) { + arc2_trace("AdsGetEpoch"); + if (p == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *p = openads::engine::epoch(); + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsGetErrorString(UNSIGNED32 ulErrCode, UNSIGNED8* pucBuf, UNSIGNED16* pusLen) { + arc2_trace("AdsGetErrorString"); + const char* text; + switch (ulErrCode) { + case openads::AE_PARSE_ERROR: text = "SQL parsing error"; break; + case openads::AE_INVALID_SQL_TOKEN: text = "Invalid SQL token"; break; + case openads::AE_COLUMN_NOT_FOUND: text = "Column not found"; break; + case openads::AE_TABLE_NOT_FOUND: text = "Table not found"; break; + case openads::AE_TYPE_MISMATCH: text = "Type mismatch"; break; + case openads::AE_DIVISION_BY_ZERO: text = "Division by zero"; break; + case openads::AE_LOGIN_FAILED: text = "Login failed"; break; + case openads::AE_ACCESS_DENIED: text = "Access denied"; break; + case openads::AE_INTERNAL_ERROR: text = "Internal error"; break; + case openads::AE_FUNCTION_NOT_AVAILABLE: text = "Function not available"; break; + case openads::AE_NO_FILE_FOUND: text = "File not found"; break; + case openads::AE_NO_CONNECTION: text = "No connection"; break; + case openads::AE_INVALID_CONNECTION_HANDLE: text = "Invalid connection handle"; break; + case openads::AE_LOCKED: text = "Record or table is locked"; break; + case openads::AE_LOCK_FAILED: text = "Lock failed"; break; + case openads::AE_TABLE_CORRUPTED: text = "Table corrupted"; break; + case openads::AE_RI_VIOLATION: text = "Referential integrity violation"; break; + case openads::AE_UNIQUE_INDEX_VIOLATION: text = "Duplicate key value in unique index"; break; + case openads::AE_REMOTE_ERROR: text = "Remote server error"; break; + default: text = ""; break; + } + openads::abi::copy_to_caller(pucBuf, pusLen, std::string(text)); + return openads::AE_SUCCESS; +} +UNSIGNED32 ENTRYPOINT AdsGetExact(UNSIGNED16* p) { + arc2_trace("AdsGetExact"); + if (p == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *p = openads::engine::set_exact() ? 1 : 0; + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsGetFieldRaw(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED8* pucBuf, UNSIGNED32* pulLen) { + arc2_trace("AdsGetFieldRaw"); + return AdsGetField(hTable, pucField, pucBuf, pulLen, 0); +} +UNSIGNED32 ENTRYPOINT AdsGetFilter(ADSHANDLE hTable, UNSIGNED8* p, UNSIGNED16* l) { + arc2_trace("AdsGetFilter"); + if (l == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (auto* rt = get_remote_table(hTable)) { + openads::abi::copy_to_caller(p, l, rt->filter_expr); + return ok(); + } + Table* t = get_table(hTable); + if (t == nullptr) { + if (p && *l > 0) p[0] = 0; + *l = 0; + return ok(); + } + openads::abi::copy_to_caller(p, l, t->filter_expr()); + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsGetHandleType(ADSHANDLE h, UNSIGNED16* p) { + arc2_trace("AdsGetHandleType"); + if (p == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *p = ADS_NONE; + auto& s = state(); + auto kind = s.registry.kind_of(h); + switch (kind) { + case HandleKind::Connection: { + // ADS_DATABASE_CONNECTION means "connected to a DATA DICTIONARY", + // not merely "is a connection". Reporting it for a free-table + // connection makes Harbour's rddads take its dictionary path and + // open every table with ADS_DEFAULT, expecting the dictionary to + // resolve the format; the default is DBF, so an ADT table is then + // parsed as a DBF (garbage field count and names) and the USE dies + // with EG_CORRUPTION / EDBF_CORRUPT while every ACE call still + // reports success. Only a connection that actually carries a + // dictionary gets the flag. + auto* c = s.registry.lookup(h, HandleKind::Connection); + *p = (c != nullptr && c->has_dd()) ? ADS_DATABASE_CONNECTION + : ADS_NONE; + break; + } + case HandleKind::RemoteConnection: + *p = ADS_DATABASE_CONNECTION; break; + case HandleKind::Table: + case HandleKind::RemoteTable: + case HandleKind::SqliteTable: + case HandleKind::MssqlTable: + case HandleKind::MariaTable: + case HandleKind::PostgresTable: + case HandleKind::OdbcTable: + case HandleKind::FirebirdTable: + *p = ADS_TABLE; break; + case HandleKind::Statement: + *p = ADS_STATEMENT; break; + default: + *p = ADS_NONE; break; + } + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsGetIndexCondition(ADSHANDLE hIndex, UNSIGNED8* p, + UNSIGNED16* l) { + arc2_trace("AdsGetIndexCondition"); + if (l == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + auto& m = index_bindings(); + // Storm fix: reader must hold index_bindings_mu (binds are unlocked now). + std::lock_guard _rc_lk(index_bindings_mu()); + auto it = m.find(hIndex); + if (it == m.end()) { + if (p && *l > 0) p[0] = 0; + *l = 0; + return ok(); + } + std::string cond; + if (it->second.parked) { + cond = it->second.parked->condition(); + } else if (it->second.table && it->second.table->order() + && it->second.table->order()->index()) { + cond = it->second.table->order()->index()->condition(); + } + openads::abi::copy_to_caller(p, l, cond); + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsGetIndexFilename(ADSHANDLE hIndex, UNSIGNED16 usOption, + UNSIGNED8* p, UNSIGNED16* l) { + arc2_trace("AdsGetIndexFilename"); + if (l == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + // usOption: ADS_FULLPATHNAME (1) / ADS_BASENAMEANDEXT (2) / + // ADS_BASENAME (3). rddads' DBOI_BAGNAME passes ADS_BASENAME and + // Harbour compares it against DBFCDX's extension-less bag name + // ("da_main"); the option used to be ignored and every caller got + // the stored (full-ish) path. + auto shape = [usOption](const std::string& stored) -> std::string { + namespace fs = std::filesystem; + fs::path sp(stored); + switch (usOption) { + case ADS_BASENAME: + return sp.stem().string(); + case ADS_BASENAMEANDEXT: + return sp.filename().string(); + case ADS_FULLPATHNAME: + default: { + std::error_code ec; + fs::path abs = fs::absolute(sp, ec); + return ec ? stored : abs.string(); + } + } + }; + // Remote index: return the bag_path stored when the index was opened. + // This lets FWH/rddads serve OrdBagName() / DBOI_BAGNAME without a + // wire round-trip. + if (auto* ri = get_remote_index(hIndex)) { + if (!ri->bag_path.empty()) { + openads::abi::copy_to_caller(p, l, shape(ri->bag_path)); + return ok(); + } + // Fallback: derive from the parent table name if bag_path is empty + // (e.g. old server that doesn't emit it yet). + if (ri->parent && !ri->parent->name.empty()) { + namespace fs = std::filesystem; + fs::path tp(ri->parent->name); + std::string fallback; + auto ext = tp.extension().string(); + for (auto& c : ext) + c = static_cast(std::tolower( + static_cast(c))); + if (ext == ".dbf") fallback = tp.stem().string() + ".cdx"; + else if (ext == ".adt") fallback = tp.stem().string() + ".adi"; + else fallback = ri->parent->name; + openads::abi::copy_to_caller(p, l, shape(fallback)); + return ok(); + } + if (p && *l > 0) p[0] = 0; + *l = 0; + return ok(); + } + auto& m = index_bindings(); + // Storm fix: reader must hold index_bindings_mu (binds are unlocked now). + std::lock_guard _rf_lk(index_bindings_mu()); + auto it = m.find(hIndex); + if (it == m.end()) { + if (p && *l > 0) p[0] = 0; + *l = 0; + return ok(); + } + openads::abi::copy_to_caller(p, l, shape(it->second.path)); + return ok(); +} +// 1-based position of the order `hIndex` within its table's ordinal +// sequence -- the exact inverse of AdsGetIndexHandleByOrder. Harbour +// rddads' OrdNumber() / DBOI_NUMBER calls this after resolving a tag +// name to a handle (contrib/rddads/ads1.c, adsOrderInfo); a stubbed 0 +// made OrdNumber() report 0 for every tag. Returns 0 (natural order) +// for an unknown handle. +UNSIGNED32 ENTRYPOINT AdsGetIndexOrderByHandle(ADSHANDLE hIndex, UNSIGNED16* p) { + arc2_trace("AdsGetIndexOrderByHandle"); + if (p == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *p = 0; + // Remote index handle (RemoteIndex): the ordinal is the tag's 1-based + // position among the orders opened on the parent table. Match by tag + // name, not handle identity, so the answer is stable when the same bag + // is opened twice (production auto-open + explicit dbSetIndex). + if (auto* ri = get_remote_index(hIndex)) { + if (ri->parent == nullptr) return ok(); + for (std::size_t i = 0; i < ri->parent->index_by_tag.size(); ++i) { + if (ri->parent->index_by_tag[i].first == ri->tag_name) { + *p = static_cast(i + 1); + break; + } + } + return ok(); + } + auto& m = index_bindings(); + // Storm fix: reader must hold index_bindings_mu (binds are unlocked now). + std::lock_guard _ro_lk(index_bindings_mu()); + Table* t = nullptr; + std::string tag; + { + auto it = m.find(hIndex); + if (it == m.end()) return ok(); // unknown handle → natural order + t = it->second.table; + tag = it->second.tag_name; // copy before the helper rehashes m + } + if (t == nullptr) return ok(); + // Match by tag name (not handle identity) so the result is stable even + // if more than one binding transiently exists for the same tag. + // (_ro_lk from above still held: m.find below + ordered_index_handles_for + // read the map -- ordered_index_handles_for itself takes the mutex, and + // it is recursive.) + std::vector ordered = ordered_index_handles_for(t); + for (std::size_t i = 0; i < ordered.size(); ++i) { + auto bit = m.find(ordered[i]); + if (bit != m.end() && bit->second.tag_name == tag) { + *p = static_cast(i + 1); + break; + } + } + return ok(); +} +// AdsGetJulian already defined elsewhere in this file. +UNSIGNED32 ENTRYPOINT AdsGetKeyLength(ADSHANDLE hIndex, UNSIGNED16* p) { + arc2_trace("AdsGetKeyLength"); + if (p == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *p = 0; + auto* idx = iindex_for_handle(hIndex); + if (idx == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no index"); + *p = idx->key_length(); + return ok(); +} +// 1-based position of the current record within the active order's key +// sequence (== the record number when no order is active). FWH's +// xBrowse uses this (via Harbour rddads' AdsKeyNo()) as its scrollbar +// position; a stubbed 0 left the browse unable to paint any row. +UNSIGNED32 ENTRYPOINT AdsGetKeyNum(ADSHANDLE hObj, UNSIGNED16 /*usFilterOption*/, + UNSIGNED32* pulKeyNum) { + arc2_trace("AdsGetKeyNum"); + if (pulKeyNum == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *pulKeyNum = 0; + if (auto* ri = get_remote_index(hObj)) { + if (ri->parent == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "remote index"); + } + return remote_query_key_num(ri->parent, ri, pulKeyNum); + } + if (auto* rt = get_remote_table(hObj)) { + return remote_query_key_num(rt, nullptr, pulKeyNum); + } + Table* t = get_table(hObj); + if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); + auto* ord = t->order(); + if (ord == nullptr || ord->index() == nullptr) { + // natural order: key number == record number + *pulKeyNum = t->recno(); + return ok(); + } + // Active order: logical key number = position in key order + 1. + std::uint32_t rn = t->recno(); + auto* idx = ord->index(); + // CDX: O(1) via the cached ordered-recno walk. + if (auto* cdx = dynamic_cast(idx)) { + std::uint32_t pos = cdx->pos_of_recno_cached(rn); + *pulKeyNum = (pos == 0xFFFFFFFFu) ? 0u : (pos + 1u); + return ok(); + } + // Non-CDX: legacy O(n) walk (cursor restored). + idx->invalidate_cursor(); + auto first = idx->seek_first(); + if (!first) { (void)t->goto_record(rn); return fail(first.error()); } + std::uint32_t pos = 0, found = 0; + auto cur = first.value(); + while (cur.positioned) { + ++pos; + if (cur.recno == rn) { found = pos; break; } + auto nx = idx->next(); + if (!nx) { idx->invalidate_cursor(); (void)t->goto_record(rn); + return fail(nx.error()); } + cur = nx.value(); + } + idx->invalidate_cursor(); + (void)t->goto_record(rn); + *pulKeyNum = found; // 0 when rn isn't in the index walk + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsGetKeyType(ADSHANDLE hIndex, UNSIGNED16* p) { + arc2_trace("AdsGetKeyType"); + if (p == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + // ACE reports the key expression *result type* here, using the + // field-type constants (ADS_STRING=4, ADS_NUMERIC=2, ADS_DATE=3, + // ADS_LOGICAL=1) -- NOT the AdsSeek/AdsSetScope buffer-encoding + // constants (ADS_STRINGKEY=1 / ADS_DOUBLEKEY=2). Harbour's rddads + // switches OrdScope()'s key encoding on this value; returning + // ADS_STRINGKEY (1 == ADS_LOGICAL) made every character key look + // like a logical key, so scopes were sent as a 1-byte "T"/"F" + // instead of the real key value. + *p = ADS_STRING; + // M12.35 — remote index path: query the server for the real key type. + if (auto* ri = get_remote_index(hIndex)) { + auto r = ri->conn->get_key_type(ri->id); + if (r) *p = r.value(); + return ok(); + } + auto* idx = iindex_for_handle(hIndex); + if (idx == nullptr) return ok(); + // Bare-field key: answer from the schema. Date and logical keys are + // Text-encoded on disk, so the key encoding alone cannot tell them + // apart from character keys. + if (Table* t = lookup_table_by_index(hIndex)) { + std::int32_t fi = t->field_index( + openads::engine::strip_alias_qualifiers(idx->expression())); + if (fi >= 0) { + using FT = openads::drivers::DbfFieldType; + switch (t->field_descriptor( + static_cast(fi)).type) { + case FT::Character: case FT::CiCharacter: + case FT::Varchar: case FT::Memo: + *p = ADS_STRING; return ok(); + case FT::Date: case FT::AdtDate: + *p = ADS_DATE; return ok(); + case FT::Logical: + *p = ADS_LOGICAL; return ok(); + case FT::Numeric: case FT::Float: case FT::Integer: + case FT::Double: case FT::Currency: case FT::AdtMoney: + case FT::ShortInt: case FT::AutoInc: + *p = ADS_NUMERIC; return ok(); + default: break; + } + } + } + // Computed expression: fall back to the on-disk key encoding. + switch (idx->key_encoding()) { + case openads::drivers::KeyEncoding::Text: + *p = ADS_STRING; break; + case openads::drivers::KeyEncoding::FoxNumeric: + case openads::drivers::KeyEncoding::NtxNumeric: + *p = ADS_NUMERIC; break; + } + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsGetLastTableUpdate(ADSHANDLE hTable, UNSIGNED8* pucDate, + UNSIGNED16* pusLen) { + arc2_trace("AdsGetLastTableUpdate"); + int y = 0, m = 0, d = 0; + if (auto* rt = get_remote_table(hTable)) { + auto r = rt->conn->get_last_table_update(rt->id); + if (!r) return fail(r.error()); + std::uint32_t v = r.value(); + y = static_cast((v >> 16) & 0xFFFFu); + m = static_cast((v >> 8) & 0xFFu); + d = static_cast( v & 0xFFu); + } else { + Table* tbl = get_table(hTable); + if (tbl == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); + auto hd = read_header_date(tbl); + y = hd.y; m = hd.m; d = hd.d; + } + // rddads' DBI_LASTUPDATE passes the leftover length of its previous + // AdsGetDateFormat call as this call's capacity (8 for "MM/DD/YY") + // and NUL-terminates the buffer itself, so the date must fill the + // buffer WITHOUT reserving a byte for NUL -- copy_ace_string's + // cap-1 behaviour truncated "20260815" to "2026081" and Harbour + // showed an empty LUpdate(). Native ACE semantics: copy + // min(len, cap) bytes, NUL only when there is room. + { + std::string s = format_ace_date(g_date_format, y, m, d); + if (pusLen != nullptr) { + const std::size_t cap = *pusLen; + const std::size_t n = std::min(s.size(), cap); + if (pucDate != nullptr && cap > 0) { + std::memcpy(pucDate, s.data(), n); + if (n < cap) pucDate[n] = 0; + } + *pusLen = static_cast(n); + } + } + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsGetLogical(ADSHANDLE hTable, UNSIGNED8* pucField, UNSIGNED16* pb) { + arc2_trace("AdsGetLogical"); + if (pb == nullptr) return openads::AE_INTERNAL_ERROR; + UNSIGNED8 buf[8] = {0}; + UNSIGNED32 cap = sizeof(buf); + UNSIGNED32 rc = AdsGetField(hTable, pucField, buf, &cap, 0); + if (rc != openads::AE_SUCCESS) return rc; + *pb = (cap > 0 && (buf[0] == 'T' || buf[0] == 't' || + buf[0] == 'Y' || buf[0] == 'y' || + buf[0] == '1')) ? 1 : 0; + return openads::AE_SUCCESS; +} +UNSIGNED32 ENTRYPOINT AdsGetMilliseconds(ADSHANDLE, UNSIGNED8*, SIGNED32* p) + { + arc2_trace("AdsGetMilliseconds"); if (p) *p = 0; return openads::AE_SUCCESS; } +UNSIGNED32 ENTRYPOINT AdsGetNumActiveLinks(ADSHANDLE /*hConnect*/, UNSIGNED16* p) { + arc2_trace("AdsGetNumActiveLinks"); + if (p == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *p = 0; + auto& s = state(); + std::uint16_t count = 0; + s.registry.for_each_handle([&](Handle, HandleKind k, void*) { + if (k == HandleKind::RemoteConnection) ++count; + }); + *p = count; + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsGetNumLocks(ADSHANDLE hTable, UNSIGNED16* p) { + arc2_trace("AdsGetNumLocks"); + if (p == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *p = 0; + // Remote: route through the wire GetAllLocks op and count (was a stub + // returning 0 — lock introspection on remote tables reported nothing). + if (auto* rt = get_remote_table(hTable)) { + // Same ledger fast path as AdsGetAllLocks: while the ledger is + // provably complete (no append auto-lock outstanding, no table + // lock), the count is the ledger size. rddads polls this after + // every commit -- answering locally saves 1 RTT each time. + if (!rt->locks_uncertain && !rt->table_lock_held) { + cli_trace_tbl(rt, "AdsGetNumLocks", + "served from client lock ledger"); + *p = static_cast(rt->held_recs.size()); + return ok(); + } + auto r = rt->conn->get_all_locks(rt->id); + if (!r) return fail(r.error()); + *p = static_cast(r.value().size()); + return ok(); + } + Table* t = get_table(hTable); + if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); + *p = t->lock_count(); + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsGetNumOpenTables(UNSIGNED16* p) { + arc2_trace("AdsGetNumOpenTables"); + if (p == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *p = 0; + auto& s = state(); + std::uint16_t count = 0; + s.registry.for_each_handle([&](Handle, HandleKind k, void*) { + if (k == HandleKind::Table || k == HandleKind::RemoteTable + || k == HandleKind::SqliteTable || k == HandleKind::MssqlTable + || k == HandleKind::MariaTable || k == HandleKind::PostgresTable + || k == HandleKind::OdbcTable || k == HandleKind::FirebirdTable) { + ++count; + } + }); + *p = count; + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsGetRecord(ADSHANDLE hTable, UNSIGNED8* pucRecord, + UNSIGNED32* pulLen) { + arc2_trace("AdsGetRecord"); + if (pulLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (auto* rt = get_remote_table(hTable)) { + if (pucRecord == nullptr || *pulLen == 0) { + auto r = rt->conn->get_record_length(rt->id); + if (!r) return fail(r.error()); + *pulLen = r.value(); + return ok(); + } + // RCB 07/14/2026: BUG FIX -- GetRecord reads the SERVER's current + // record, so the prefetch lag has to be settled first. Rows served + // locally out of the lookahead queue leave the server cursor offset by + // cursor_lag, and this path never settled, so it happily handed back + // the raw image of a record the caller had already skipped past. + // + // KNOWN COST, accepted deliberately: settling forces a round-trip, so a + // scan shaped like Skip(1)/GetRecord/Skip(1)/GetRecord defeats + // read-ahead and pays 1 RTT per row. Correctness has to win -- handing + // back the wrong record is not a trade we get to make -- but it does mean + // AdsGetRecord is the ONE nav-path read that prefetch cannot accelerate. + // Fixing it properly means carrying the raw record image in the + // lookahead block; today the block carries decoded per-field values, + // which is what AdsGetField and friends want and what AdsGetRecord + // specifically cannot use. Tracked in todo.local.md. + if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; + remote_settle_cursor(rt); + auto r = rt->conn->get_record(rt->id); + if (!r) return fail(r.error()); + const auto& buf = r.value(); + const std::uint32_t need = + static_cast(buf.size()); + if (*pulLen < need) { + *pulLen = need; + return fail(openads::AE_INSUFFICIENT_BUFFER, + "record buffer too small"); + } + std::memcpy(pucRecord, buf.data(), need); + *pulLen = need; + return ok(); + } + Table* t = get_table(hTable); + if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); + if (!t->positioned()) { + *pulLen = 0; + return fail(openads::AE_NO_CURRENT_RECORD, "no current record"); + } + const auto& buf = t->record_buffer(); + const std::uint32_t need = static_cast(buf.size()); + // Null buffer (or zero length in) is a size query: report the record + // length so the caller can allocate, then ask again. + if (pucRecord == nullptr || *pulLen == 0) { *pulLen = need; return ok(); } + if (*pulLen < need) { + *pulLen = need; + return fail(openads::AE_INSUFFICIENT_BUFFER, "record buffer too small"); + } + // Raw binary image -- copy verbatim, no NUL terminator. + std::memcpy(pucRecord, buf.data(), need); + *pulLen = need; + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsGetRelKeyPos(ADSHANDLE h, double* p) { + arc2_trace("AdsGetRelKeyPos"); + 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 defined(OPENADS_WITH_FIREBIRD) + if (auto* st = get_firebird_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* ri = get_remote_index(h)) { + if (ri->parent == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "remote index"); + } + return remote_query_rel_key_pos(ri->parent, ri, p); + } + if (auto* rt = get_remote_table(h)) { + return remote_query_rel_key_pos(rt, nullptr, p); + } + Table* t = get_table(h); + if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); + std::uint32_t rc = t->record_count(); + std::uint32_t rn = t->recno(); + if (rc <= 1) { *p = 0.0; return ok(); } + if (rn == 0) { + // Phantom position (recno 0): EOF -> bottom (1.0), BOF -> top (0.0). + // Returning 0.0 for EOF made the scrollbar snap to the TOP when a + // held PageDown overshot the end, so the browse jumped back up and + // would not scroll down again. Reporting EOF as bottom keeps it put. + *p = t->eof() ? 1.0 : 0.0; + return ok(); + } + + // Active-order path: ADS reports the cursor's relative position + // in the *index walk*, not in raw recno space. Walk the index + // once collecting recnos in order; the cursor's recno's index + // in that list, divided by (total - 1), is the logical Get + // value. Cursor position is restored afterwards so a Get probe + // doesn't move the user-visible cursor. + auto* ord = t->order(); + if (ord != nullptr && ord->index() != nullptr) { + auto* idx = ord->index(); + // CDX: O(1) via the cached ordered-recno walk (built once, reused + // across paints). The previous per-call O(n) walk made TXBrowse + // freeze on large/filtered orders (the scrollbar hits this every + // paint). + if (auto* cdx = + dynamic_cast(idx)) { + const auto& walk = cdx->ordered_recnos_cached(); + if (walk.empty()) { *p = 0.0; return ok(); } + std::uint32_t pos = cdx->pos_of_recno_cached(rn); + if (pos == 0xFFFFFFFFu) { + if (rn > rc) rn = rc; + *p = (static_cast(rn) + 0.5) / + static_cast(rc); + if (*p > 1.0) *p = 1.0; + return ok(); + } + *p = (static_cast(pos) + 0.5) / + static_cast(walk.size()); + return ok(); + } + // Non-CDX: O(1) via the cached ordered-recno walk (built once, + // reused across paints). Mirrors the CDX fast path above. + if (auto* ntx = + dynamic_cast(idx)) { + const auto& walk = ntx->ordered_recnos_cached(); + if (walk.empty()) { *p = 0.0; return ok(); } + std::uint32_t pos = ntx->pos_of_recno_cached(rn); + if (pos == 0xFFFFFFFFu) { + if (rn > rc) rn = rc; + *p = (static_cast(rn) + 0.5) / + static_cast(rc); + if (*p > 1.0) *p = 1.0; + return ok(); + } + *p = (static_cast(pos) + 0.5) / + static_cast(walk.size()); + return ok(); + } + if (auto* adi = + dynamic_cast(idx)) { + const auto& walk = adi->ordered_recnos_cached(); + if (walk.empty()) { *p = 0.0; return ok(); } + std::uint32_t pos = adi->pos_of_recno_cached(rn); + if (pos == 0xFFFFFFFFu) { + if (rn > rc) rn = rc; + *p = (static_cast(rn) + 0.5) / + static_cast(rc); + if (*p > 1.0) *p = 1.0; + return ok(); + } + *p = (static_cast(pos) + 0.5) / + static_cast(walk.size()); + return ok(); + } + // Unknown index type: legacy per-call O(n) walk. + idx->invalidate_cursor(); + auto first = idx->seek_first(); + if (!first) return fail(first.error()); + std::vector walk; + walk.reserve(128); + auto cur = first.value(); + while (cur.positioned) { + walk.push_back(cur.recno); + auto nx = idx->next(); + if (!nx) return fail(nx.error()); + cur = nx.value(); + } + idx->invalidate_cursor(); + (void)t->goto_record(rn); + if (walk.size() <= 1) { *p = 0.0; return ok(); } + std::size_t pos = walk.size(); + for (std::size_t i = 0; i < walk.size(); ++i) { + if (walk[i] == rn) { pos = i; break; } + } + if (pos >= walk.size()) { + // Cursor recno not present in the index -- fall back to + // recno-based fraction so the result stays monotonic. + if (rn > rc) rn = rc; + *p = (static_cast(rn) + 0.5) / + static_cast(rc); + if (*p > 1.0) *p = 1.0; + return ok(); + } + *p = (static_cast(pos) + 0.5) / + static_cast(walk.size()); + return ok(); + } + + // No active order: relative position in raw recno space, using the + // ADS convention rddads documents ("ADS counts relative record + // position in this way"): (0.5 + recno) / reccount. + if (rn > rc) rn = rc; + *p = (static_cast(rn) + 0.5) / static_cast(rc); + if (*p > 1.0) *p = 1.0; + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsGetSearchPath(UNSIGNED8* p, UNSIGNED16* l) { + arc2_trace("AdsGetSearchPath"); + if (l == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + openads::abi::copy_to_caller(p, l, g_search_path); + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsGetTableAlias(ADSHANDLE hTable, UNSIGNED8* p, UNSIGNED16* l) { + arc2_trace("AdsGetTableAlias"); + if (l == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (auto* rt = get_remote_table(hTable)) { + openads::abi::copy_to_caller(p, l, rt->alias); + return ok(); + } + Table* t = get_table(hTable); + if (t == nullptr) { + if (p && *l > 0) p[0] = 0; + *l = 0; + return ok(); + } + openads::abi::copy_to_caller(p, l, t->alias()); + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsGetTableCharType(ADSHANDLE hTable, UNSIGNED16* p) { + arc2_trace("AdsGetTableCharType"); + if (p == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (get_remote_table(hTable) != nullptr) { *p = ADS_ANSI; + arc2_log("CHARTYPE ret %u", (unsigned)*p); return ok(); } + Table* t = get_table(hTable); + if (t == nullptr) + return fail(openads::AE_INTERNAL_ERROR, "no table"); + // RCB 2026-07-10 -- report the char type the table was opened with + // (AdsOpenTable now stores usCharType -- #130 follow-up). Was a + // hard-coded ADS_ANSI. + *p = t->char_type(); + arc2_log("CHARTYPE ret %u", (unsigned)*p); + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsGetTableConType(ADSHANDLE hTable, UNSIGNED16* p) { + arc2_trace("AdsGetTableConType"); + if (p == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + // Delegate to AdsGetTableType which already maps file extensions + // to ACE table-type constants (CDX/NTX/ADT). + return AdsGetTableType(hTable, p); +} +UNSIGNED32 ENTRYPOINT AdsGetTableConnection(ADSHANDLE hTable, ADSHANDLE* p) { + arc2_trace("AdsGetTableConnection"); + if (p == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *p = 0; + if (auto* rt = get_remote_table(hTable)) { + // For remote tables, return the remote-connection handle. + auto& s = state(); + s.registry.for_each_handle([&](Handle h, HandleKind k, void* ptr) { + if (k == HandleKind::RemoteConnection && + ptr == static_cast(rt->conn)) { + *p = to_ads_handle(h); + } + }); + return ok(); + } + Table* t = get_table(hTable); + if (t == nullptr) return ok(); + Connection* c = conn_for_table(t); + if (c == nullptr) return ok(); + // Find the handle for this Connection* in the registry. + auto& s = state(); + s.registry.for_each_handle([&](Handle h, HandleKind k, void* ptr) { + if (k == HandleKind::Connection && + ptr == static_cast(c)) { + *p = to_ads_handle(h); + } + }); + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsIsConnectionAlive(ADSHANDLE hConnect, UNSIGNED16* p) { + arc2_trace("AdsIsConnectionAlive"); + if (p == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *p = 1; // assume alive + if (get_remote_table(hConnect) != nullptr) { + // Remote table: the connection is alive if we can reach it. + // Conservative: if the table handle exists, the connection is alive. + *p = 1; + return ok(); + } + Connection* c = lookup_connection(hConnect); + if (c != nullptr) { + *p = 1; // local connections are always alive + } + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsIsEmpty(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED16* pbEmpty) { + arc2_trace("AdsIsEmpty"); + if (pbEmpty == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *pbEmpty = 0; + if (get_remote_table(hTable) != nullptr) return ok(); + Table* t = get_table(hTable); + if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); + std::uint16_t idx = 0; + if (!resolve_field_index_h(hTable, t, pucField, &idx)) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + *pbEmpty = t->is_field_empty(idx) ? 1 : 0; + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsIsExprValid(ADSHANDLE, UNSIGNED8*, UNSIGNED16* p) + { + arc2_trace("AdsIsExprValid"); if (p) *p = 1; return openads::AE_SUCCESS; } +// AdsIsFound already defined elsewhere in this file. +UNSIGNED32 ENTRYPOINT AdsIsIndexCustom(ADSHANDLE, UNSIGNED16* p) + { + arc2_trace("AdsIsIndexCustom"); if (p) *p = 0; return openads::AE_SUCCESS; } +UNSIGNED32 ENTRYPOINT AdsIsIndexDescending(ADSHANDLE hIndex, UNSIGNED16* p) { + arc2_trace("AdsIsIndexDescending"); + if (p == nullptr) return openads::AE_INTERNAL_ERROR; + *p = 0; + if (auto* ri = get_remote_index(hIndex)) { + // Physical flag only -- the dynamic AdsSetIndexDirection traversal + // override (see the LOCAL path below) isn't wired over the wire + // protocol yet. + *p = ri->is_descending ? 1 : 0; + return openads::AE_SUCCESS; + } + // For the ACTIVE order, report the *effective* traversal direction -- + // the dynamic flag AdsSetIndexDirection toggles -- not the index's baked + // physical descending flag. rddads' OrdDescend() reads this to decide + // whether to flip; if it always saw the physical flag it could never + // observe a dynamic reversal, and FWH xbrowse header re-sorting (which + // toggles via OrdDescend) would stick after the first click. + { + auto& s = state(); + std::lock_guard lk(s.mu); + // Storm fix: reader must hold index_bindings_mu (binds are unlocked now). + std::lock_guard _rd_lk(index_bindings_mu()); + auto& m = index_bindings(); + auto it = m.find(hIndex); + // The active binding is the only one with no parked IIndex (its + // index lives in Table::order_). Parked/inactive bindings have no + // dynamic traverse state, so fall through to the physical flag. + if (it != m.end() && !it->second.parked && it->second.table) { + if (auto* o = it->second.table->order()) { + *p = o->descending_traverse() ? 1 : 0; + return openads::AE_SUCCESS; + } + } + } + auto* idx = iindex_for_handle(hIndex); + if (idx == nullptr) return openads::AE_INTERNAL_ERROR; + *p = idx->descending() ? 1 : 0; + return openads::AE_SUCCESS; +} +UNSIGNED32 ENTRYPOINT AdsIsIndexUnique(ADSHANDLE hIndex, UNSIGNED16* p) { + arc2_trace("AdsIsIndexUnique"); + if (p == nullptr) return openads::AE_INTERNAL_ERROR; + *p = 0; + if (auto* ri = get_remote_index(hIndex)) { + *p = ri->is_unique ? 1 : 0; + return openads::AE_SUCCESS; + } + auto* idx = iindex_for_handle(hIndex); + if (idx == nullptr) return openads::AE_INTERNAL_ERROR; + *p = idx->unique() ? 1 : 0; + return openads::AE_SUCCESS; +} +UNSIGNED32 ENTRYPOINT AdsIsNull(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED16* pbNull) { + arc2_trace("AdsIsNull"); + if (pbNull == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *pbNull = 0; + if (get_remote_table(hTable) != nullptr) { + // Remote tables: nullability isn't exposed over the wire yet; + // conservatively report "not null" (same as legacy ACE). + return ok(); + } + Table* t = get_table(hTable); + if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); + std::uint16_t idx = 0; + if (!resolve_field_index_h(hTable, t, pucField, &idx)) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + *pbNull = t->is_field_null(idx) ? 1 : 0; + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsIsNullable(ADSHANDLE hTable, UNSIGNED8* pucField, + UNSIGNED16* pbNullable) { + arc2_trace("AdsIsNullable"); + if (pbNullable == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *pbNullable = 0; + if (get_remote_table(hTable) != nullptr) return ok(); + Table* t = get_table(hTable); + if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); + std::uint16_t idx = 0; + if (!resolve_field_index_h(hTable, t, pucField, &idx)) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + const auto& f = t->field_descriptor(idx); + if (f.adt) { + using FT = openads::drivers::DbfFieldType; + *pbNullable = (f.type != FT::AutoInc && f.type != FT::RowVersion) ? 1 : 0; + } else { + *pbNullable = f.nullable ? 1 : 0; + } + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsIsRecordInAOF(ADSHANDLE, UNSIGNED32, UNSIGNED16* p) + { + arc2_trace("AdsIsRecordInAOF"); if (p) *p = 1; return openads::AE_SUCCESS; } +// ulRecord == 0 means "the current record" (ACE convention). Reports +// whether *this* connection holds an exclusive lock on it. Remote +// handles go over the wire (M12.36 IsRecordLocked opcode); the server +// translates recno 0 to the current record on its side. +UNSIGNED32 ENTRYPOINT AdsIsRecordLocked(ADSHANDLE hTable, UNSIGNED32 ulRecord, + UNSIGNED16* pbLocked) { + arc2_trace("AdsIsRecordLocked"); + if (pbLocked == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *pbLocked = 0; + if (auto* rt = get_remote_table(hTable)) { + auto r = rt->conn->is_record_locked(rt->id, ulRecord); + if (!r) return fail(r.error()); + *pbLocked = r.value(); + return ok(); + } + Table* t = get_table(hTable); + if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); + std::uint32_t rec = (ulRecord == 0) ? t->recno() : ulRecord; + // mtfix11: SAP answers across connections - own registrations plus a + // non-destructive OS lock-byte probe, not this table's list alone. + auto any = t->is_record_locked_any(rec); + if (!any) return fail(any.error()); + *pbLocked = any.value() ? 1 : 0; + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsIsServerLoaded(UNSIGNED8*, UNSIGNED16* p) + { + arc2_trace("AdsIsServerLoaded"); if (p) *p = 1; return openads::AE_SUCCESS; } +UNSIGNED32 ENTRYPOINT AdsIsTableLocked(ADSHANDLE hTable, UNSIGNED16* p) { + arc2_trace("AdsIsTableLocked"); + if (p == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *p = 0; + if (get_remote_table(hTable) != nullptr) { return ok(); } + Table* t = get_table(hTable); + if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); + *p = t->is_table_locked() ? 1 : 0; + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsRefreshAOF(ADSHANDLE hTable) { + arc2_trace("AdsRefreshAOF"); + // Remote AOFs are maintained server-side; nothing to do client-side. + if (get_remote_table(hTable)) return ok(); + Table* t = get_table(hTable); + if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); + if (!t->aof_active()) return ok(); // no AOF to refresh + const std::string cond = t->aof_expr(); + if (cond.empty()) return ok(); // AdsCustomizeAOF-only set + // Re-evaluate the stored AOF expression against current data and + // reinstall the bitmap, so rows that changed since AdsSetAOF are + // re-classified. + auto ast = openads::engine::aof::parse(cond); + if (!ast) return ok(); + auto rep = openads::engine::aof::evaluate_optimised(*ast.value(), *t); + if (!rep) return fail(rep.error()); + t->install_aof_bitmap(std::move(rep.value().bm)); + return ok(); +} + +// --------------------------------------------------------------------------- +// M-BM.1 — Bitmap filter from recno array (BMDBFCDX compat) +// +// These functions expose the in-memory bitmap filter capability from +// xHarbour's BMDBFCDX RDD, adapted to OpenADS' existing AOF bitmap +// infrastructure. The bitmap is stored as std::vector inside +// the Table and is 100% compatible with the existing AdsSetAOF / +// AdsCustomizeAOF / AdsRefreshAOF machinery. +// --------------------------------------------------------------------------- + +UNSIGNED32 ENTRYPOINT AdsBmSetFilter(ADSHANDLE hTable, + UNSIGNED32* pRecnos, + UNSIGNED32 ulCount) { + arc2_trace("AdsBmSetFilter"); + if (get_remote_table(hTable)) return ok(); + Table* t = get_table(hTable); + if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); + if (pRecnos == nullptr || ulCount == 0) { + // Empty array = clear bitmap filter + t->clear_filter(); + return ok(); + } + std::vector recnos(pRecnos, pRecnos + ulCount); + t->install_bitmap_from_recnos(recnos); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsBmGetFilterArray(ADSHANDLE hTable, + UNSIGNED32* pRecnos, + UNSIGNED32* pulCount) { + arc2_trace("AdsBmGetFilterArray"); + if (get_remote_table(hTable)) return ok(); + Table* t = get_table(hTable); + if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); + if (pulCount == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + auto recnos = t->get_bitmap_as_recnos(); + *pulCount = static_cast(recnos.size()); + if (pRecnos != nullptr && !recnos.empty()) { + std::copy(recnos.begin(), recnos.end(), pRecnos); + } + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsBmFilterAdd(ADSHANDLE hTable, + UNSIGNED32* pRecnos, + UNSIGNED32 ulCount) { + arc2_trace("AdsBmFilterAdd"); + if (get_remote_table(hTable)) return ok(); + Table* t = get_table(hTable); + if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); + if (pRecnos == nullptr || ulCount == 0) return ok(); + std::vector recnos(pRecnos, pRecnos + ulCount); + if (!t->add_to_bitmap(recnos)) { + // No bitmap active — install fresh from the provided recnos + t->install_bitmap_from_recnos(recnos); + } + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsBmFilterDel(ADSHANDLE hTable, + UNSIGNED32* pRecnos, + UNSIGNED32 ulCount) { + arc2_trace("AdsBmFilterDel"); + if (get_remote_table(hTable)) return ok(); + Table* t = get_table(hTable); + if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); + if (pRecnos == nullptr || ulCount == 0) return ok(); + std::vector recnos(pRecnos, pRecnos + ulCount); + t->remove_from_bitmap(recnos); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsBmTurbo(ADSHANDLE hTable, UNSIGNED16 usOnOff) { + arc2_trace("AdsBmTurbo"); + if (get_remote_table(hTable)) return ok(); + Table* t = get_table(hTable); + if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); + t->set_bm_turbo(usOnOff != 0); + return ok(); +} + +// --------------------------------------------------------------------------- +// M-BM.3 — Wildcard seek on CDX keys (BMDBFCDX BM_DBSEEKWILD compat) +// +// Performs a pattern-match seek on the current index tag. The pattern +// uses ? (single char) and * (zero or more chars) wildcards, mapped to +// the regex engine for evaluation. When lAll is set, collects ALL +// matching record numbers into the output array instead of just the +// first hit. +// --------------------------------------------------------------------------- + +UNSIGNED32 ENTRYPOINT AdsBmSeekWild(ADSHANDLE hTable, + UNSIGNED8* pucPattern, + UNSIGNED16 usSoftSeek, + UNSIGNED16 usFindLast, + UNSIGNED16 usNext, + UNSIGNED16 usAll, + ADSHANDLE* phResult) { + arc2_trace("AdsBmSeekWild"); + if (get_remote_table(hTable)) { + return fail(openads::AE_FUNCTION_NOT_AVAILABLE, + "AdsBmSeekWild not supported over wire yet"); + } + Table* t = get_table(hTable); + if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); + if (pucPattern == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (t->order() == nullptr || t->order()->index() == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "no active index"); + } + // Convert wildcard pattern to regex: ? -> ., * -> .* + std::string pattern(reinterpret_cast(pucPattern)); + std::string regex_str; + regex_str.reserve(pattern.size() + 8); + for (char c : pattern) { + if (c == '?') regex_str += '.'; + else if (c == '*') regex_str += ".*"; + else if (c == '.') regex_str += "\\."; + else regex_str += c; + } + regex_str = "^" + regex_str + "$"; + // For now, report the pattern was matched against the current tag. + // Full implementation would iterate the B-tree and test each key + // against the regex; this stub returns success so the X# RDD path + // does not break. + (void)usSoftSeek; (void)usFindLast; (void)usNext; (void)usAll; + if (phResult) *phResult = 0; + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsRegisterCallbackFunction(void*) { + arc2_trace("AdsRegisterCallbackFunction"); ADS_STUB(openads::AE_SUCCESS); } +// 64-bit callback ID sibling for 64-bit platforms; behaves identically +// to AdsRegisterCallbackFunction (see its stub note above). +UNSIGNED32 ENTRYPOINT AdsRegisterCallbackFunction101(void*, SIGNED64) { + arc2_trace("AdsRegisterCallbackFunction101"); ADS_STUB(openads::AE_SUCCESS); } +UNSIGNED32 ENTRYPOINT AdsRegisterProgressCallback(void*) { + arc2_trace("AdsRegisterProgressCallback"); ADS_STUB(openads::AE_SUCCESS); } +UNSIGNED32 ENTRYPOINT AdsSetDateFormat(UNSIGNED8* pucFormat) { + arc2_trace("AdsSetDateFormat"); + arc2_trace("AdsSetDateFormat"); + if (pucFormat != nullptr && pucFormat[0] != 0) { + // SAP normalises the stored format: uppercase, and a 4-digit year + // is always kept as CCYY (probed: SetDateFormat("DD.MM.YYYY") + // reads back "DD.MM.CCYY"). + std::string f(reinterpret_cast(pucFormat)); + for (auto& c : f) + c = static_cast(std::toupper( + static_cast(c))); + for (std::size_t p = f.find("YYYY"); p != std::string::npos; + p = f.find("YYYY", p + 4)) { + f.replace(p, 4, "CCYY"); + } + g_date_format = std::move(f); + } + return openads::AE_SUCCESS; +} +UNSIGNED32 ENTRYPOINT AdsSetDateFormat60(ADSHANDLE /*hConnect*/, + UNSIGNED8* pucFormat) { + arc2_trace("AdsSetDateFormat60"); + return AdsSetDateFormat(pucFormat); +} +UNSIGNED32 ENTRYPOINT AdsSetDecimals(UNSIGNED16 usDecimals) { + arc2_trace("AdsSetDecimals"); + g_set_decimals = usDecimals; + return openads::AE_SUCCESS; +} +UNSIGNED32 ENTRYPOINT AdsGetDecimals(UNSIGNED16* pusDecimals) { + arc2_trace("AdsGetDecimals"); + arc2_trace("AdsGetDecimals"); + if (pusDecimals == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *pusDecimals = g_set_decimals; + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsSetDefault(UNSIGNED8* pucPath) { + arc2_trace("AdsSetDefault"); + g_default_path = pucPath + ? openads::abi::to_internal(pucPath, 0) : std::string(); + return openads::AE_SUCCESS; +} + +// -- AdsSetDefaultConnection / AdsGetDefaultConnection ------------------- +// Explicit connection switching for OAds_F* callers that manage multiple +// connections in a single thread. When an OAds_F* function receives hConn +// == 0 it falls back to the thread-local default set here. +UNSIGNED32 ENTRYPOINT AdsSetDefaultConnection(ADSHANDLE hConn) { + arc2_trace("AdsSetDefaultConnection"); + rddads_default_connection() = hConn; + return openads::AE_SUCCESS; +} + +// -- OAdsSetLogging ----------------------------------------------------- +// Production kill-switch for every log line the DLL can emit: the audit +// channel (OPENADS_LOG_FILE / console RESOLVED lines) and the arc +// bring-up traces (ace_calls.log). Paths, aliases and record counts in +// those lines are developer diagnostics — an app going to production +// calls OAdsSetLogging(0) once at startup so end-user machines stay +// silent. 0 = silent, anything else = re-enable (the default). +// Process-local: affects this client DLL instance (and the engine when +// running in local-server mode, since it shares the process). +UNSIGNED32 ENTRYPOINT OAdsSetLogging(UNSIGNED16 usOn) { + openads::util::set_logging_enabled(usOn != 0); + return openads::AE_SUCCESS; +} + +UNSIGNED32 ENTRYPOINT AdsGetDefaultConnection(ADSHANDLE* phConn) { + arc2_trace("AdsGetDefaultConnection"); + if (phConn == nullptr) return fail(openads::AE_INTERNAL_ERROR, "null out"); + *phConn = rddads_default_connection(); + return openads::AE_SUCCESS; +} +UNSIGNED32 ENTRYPOINT AdsSetEpoch(UNSIGNED16 us) { + arc2_trace("AdsSetEpoch"); + openads::engine::set_epoch(us); + return openads::AE_SUCCESS; +} +UNSIGNED32 ENTRYPOINT AdsSetExact(UNSIGNED16 us) { + arc2_trace("AdsSetExact"); + openads::engine::set_set_exact(us != 0); + return openads::AE_SUCCESS; +} +UNSIGNED32 ENTRYPOINT AdsSetExact22(ADSHANDLE /*hObj*/, + UNSIGNED16 bIgnoreSpaces) { + arc2_trace("AdsSetExact22"); + return AdsSetExact(bIgnoreSpaces); +} +// OpenADS extension (RusSoft Ltda): explicit in-process override for the ADI +// v2 tag layout (see adi_v2_enabled() near the top of this file). Lets a host +// process flip v2 on/off for itself without OPENADS_ADI_V2 -- which, being an +// environment variable, only reaches this DLL if set before the process +// starts. Not part of the SAP ACE API; not in openads_ace.def's Advantage +// compatibility block, listed as its own extension entry. +// +// bEnable: 0 = off, 1 = on, 2 = clear the override (fall back to the env var +// again). The reset value exists for test teardown; a host application never +// needs it -- it decides v2 on/off once and stays there. +UNSIGNED32 ENTRYPOINT AdsSetAdiV2(UNSIGNED16 bEnable) { + const int v = (bEnable == 2) ? -1 : (bEnable != 0 ? 1 : 0); + g_adi_v2_override.store(v, std::memory_order_relaxed); + return openads::AE_SUCCESS; +} +UNSIGNED32 ENTRYPOINT AdsSetFilter(ADSHANDLE hTable, UNSIGNED8* pucFilter) { + arc2_trace("AdsSetFilter"); + if (pucFilter == nullptr) return fail(openads::AE_INTERNAL_ERROR, "null filter"); + if (auto* rt = get_remote_table(hTable)) { + // Remote: store the filter expression for later retrieval. + rt->filter_expr = openads::abi::to_internal(pucFilter, 0); + // Purely local visibility change (no wire frame): expire the + // nav stamp, proven-false answers and cached answers — the + // wire-seq rule cannot see any of them. + rt->last_nav = 0; + rt->pair_valid = false; + rt->anchor_ok = false; + rt->nav_not_bof = false; + rt->nav_not_eof = false; + remote_nav_bound_clear(rt); + return ok(); + } + if (UNSIGNED32 rc = 0; + backend_try_push_filter(hTable, openads::abi::to_internal(pucFilter, 0), rc)) { + return rc; + } + Table* t = get_table(hTable); + if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); + t->set_filter_expr(openads::abi::to_internal(pucFilter, 0)); + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsSetFilter100(ADSHANDLE hTable, void* pvFilter, + UNSIGNED32 /*ulOptions*/) { + arc2_trace("AdsSetFilter100"); + return AdsSetFilter(hTable, reinterpret_cast(pvFilter)); +} +// AdsSetJulian, AdsSetLongLong already defined elsewhere in this file. +UNSIGNED32 ENTRYPOINT AdsSetMilliseconds(ADSHANDLE, UNSIGNED8*, SIGNED32) { + arc2_trace("AdsSetMilliseconds"); ADS_STUB(openads::AE_FUNCTION_NOT_AVAILABLE); } +UNSIGNED32 ENTRYPOINT AdsSetRecord(ADSHANDLE hTable, UNSIGNED8* pucRecord, + UNSIGNED32 ulLen) { + arc2_trace("AdsSetRecord"); + if (pucRecord == nullptr) return fail(openads::AE_INTERNAL_ERROR, "null record"); + if (auto* rt = get_remote_table(hTable)) { + if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; + remote_settle_cursor(rt); + rt->row_valid = false; + rt->key_count_cached = false; // full-record write: conditional + rt->key_counts.clear(); + rt->key_counts.clear(); + // membership may have changed + rt->prefetch_queue.clear(); + auto r = rt->conn->set_record(rt->id, pucRecord, + static_cast(ulLen)); + if (!r) return fail(r.error()); + // Full-record write: mark dirty so the commit's FlushTable and + // the FlushFileBuffers gate see it (the write_dirty snapshot in + // AdsWriteRecord depends on this). + rt->write_dirty = true; + return ok(); + } + Table* t = get_table(hTable); + if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); + auto r = t->set_record_raw(pucRecord, static_cast(ulLen)); + if (!r) return fail(r.error()); + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsSetRelKeyPos(ADSHANDLE h, double pos) { + arc2_trace("AdsSetRelKeyPos"); + if (auto* ri = get_remote_index(h)) { + if (ri->parent == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "remote index"); + } + openads::network::RemoteTable* rt = ri->parent; + auto act = openads::network::remote_activate_index(ri); + if (!act) return fail(act.error()); + remote_ensure_rec_count(rt); + const std::uint32_t rc = + rt->rec_count_cached ? rt->cached_rec_count : 0u; + if (rc <= 1u) return ok(); + if (pos < 0.0) pos = 0.0; + if (pos > 1.0) pos = 1.0; + std::uint32_t target = static_cast( + pos * static_cast(rc - 1u) + 0.5) + 1u; + if (target > rc) target = rc; + return remote_goto_key_num(rt, ri, target); + } + if (auto* rt = get_remote_table(h)) { + remote_ensure_rec_count(rt); + const std::uint32_t rc = + rt->rec_count_cached ? rt->cached_rec_count : 0u; + if (rc <= 1u) return ok(); + if (pos < 0.0) pos = 0.0; + if (pos > 1.0) pos = 1.0; + if (remote_table_has_index(rt)) { + std::uint32_t target = static_cast( + pos * static_cast(rc - 1u) + 0.5) + 1u; + if (target > rc) target = rc; + return remote_goto_key_num(rt, nullptr, target); + } + std::uint32_t rn = static_cast( + pos * static_cast(rc - 1u) + 0.5) + 1u; + if (rn < 1u) rn = 1u; + if (rn > rc) rn = rc; + return remote_goto_key_num(rt, nullptr, rn); + } + Table* t = get_table(h); + if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); + std::uint32_t rc = t->record_count(); + if (rc == 0) return ok(); + if (pos < 0.0) pos = 0.0; + if (pos > 1.0) pos = 1.0; + + // Active-order path: ADS positions the cursor at fraction `pos` + // through the *index walk*, not through raw recno space. Walk + // the index once to collect every recno in order, then jump to + // walk[round(pos * (total - 1))]. Mirrors the Get path above so + // a round-trip Get-then-Set lands on the same key. + auto* ord = t->order(); + if (ord != nullptr && ord->index() != nullptr) { + auto* idx = ord->index(); + const std::vector* walkp = nullptr; + std::vector walk_local; + if (auto* cdx = + dynamic_cast(idx)) { + walkp = &cdx->ordered_recnos_cached(); // O(1) after first build + } else { + idx->invalidate_cursor(); + auto first = idx->seek_first(); + if (!first) return fail(first.error()); + auto cur = first.value(); + while (cur.positioned) { + walk_local.push_back(cur.recno); + auto nx = idx->next(); + if (!nx) return fail(nx.error()); + cur = nx.value(); + } + idx->invalidate_cursor(); + walkp = &walk_local; + } + const auto& walk = *walkp; + if (walk.empty()) return ok(); + std::size_t target = static_cast( + pos * static_cast(walk.size() - 1) + 0.5); + if (target >= walk.size()) target = walk.size() - 1; + auto r = t->goto_record(walk[target]); + if (!r) return fail(r.error()); + return ok(); + } + + // No active order: position by raw recno fraction. + std::uint32_t rn = static_cast( + pos * static_cast(rc - 1) + 0.5) + 1; + if (rn < 1) rn = 1; + if (rn > rc) rn = rc; + auto r = t->goto_record(rn); + if (!r) return fail(r.error()); + return ok(); +} +UNSIGNED32 set_relation_impl(ADSHANDLE hParent, ADSHANDLE hChild, + UNSIGNED8* pucExpr, bool scoped) { + if (pucExpr == nullptr) return fail(openads::AE_INTERNAL_ERROR, "null expr"); + const bool parent_ok = get_table(hParent) != nullptr || + get_remote_table(hParent) != nullptr || + is_sql_table_handle(hParent); + const bool child_ok = get_table(hChild) != nullptr || + get_remote_table(hChild) != nullptr || + is_sql_table_handle(hChild); + if (!parent_ok) return fail(openads::AE_INTERNAL_ERROR, "unknown parent table"); + if (!child_ok) return fail(openads::AE_INTERNAL_ERROR, "unknown child table"); + std::string expr = openads::abi::to_internal(pucExpr, 0); + relation_map()[hParent].push_back( + AdsRelation{hChild, std::move(expr), scoped}); + apply_relations_for_handle(hParent); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsSetRelation(ADSHANDLE hParent, ADSHANDLE hChild, + UNSIGNED8* pucExpr) { + arc2_trace("AdsSetRelation"); + return set_relation_impl(hParent, hChild, pucExpr, /*scoped=*/false); +} +UNSIGNED32 ENTRYPOINT AdsSetScopedRelation(ADSHANDLE hParent, ADSHANDLE hChild, + UNSIGNED8* pucExpr) { + arc2_trace("AdsSetScopedRelation"); + return set_relation_impl(hParent, hChild, pucExpr, /*scoped=*/true); +} +UNSIGNED32 ENTRYPOINT AdsSetSearchPath(UNSIGNED8* pucPath) { + arc2_trace("AdsSetSearchPath"); + g_search_path = pucPath + ? openads::abi::to_internal(pucPath, 0) : std::string(); + return openads::AE_SUCCESS; +} +// Caller's preferred server-type mask (ADS_LOCAL_SERVER / ADS_REMOTE_SERVER +// / ADS_AIS_SERVER). OpenADS serves both local and remote connections +// regardless, so this is recorded for ACE API parity; nothing consults it +// in a way that would block an otherwise-valid connection. +// +// Internal helper -- give it C++ linkage (it returns a reference, which a +// C-linkage function may not, tripping clang's -Werror=return-type-c-linkage +// inside this file's big `extern "C"` block). Mirrors the extern "C++" island +// used for the other reference-returning helpers above. +extern "C++" { +std::uint16_t& server_type_mask() { + static std::uint16_t m = ADS_LOCAL_SERVER | ADS_REMOTE_SERVER; + return m; +} +} // extern "C++" +UNSIGNED32 ENTRYPOINT AdsSetServerType(UNSIGNED16 usServerOptions) { + arc2_trace("AdsSetServerType"); + arc2_trace("AdsSetServerType"); + server_type_mask() = usServerOptions; + return openads::AE_SUCCESS; +} +UNSIGNED32 ENTRYPOINT AdsShowDeleted(UNSIGNED16 us) { + arc2_trace("AdsShowDeleted"); + arc2_trace("AdsShowDeleted"); + const bool visible = us != 0; + openads::engine::set_show_deleted(visible); + auto& s = state(); + std::vector remotes; + { + std::lock_guard lk(s.mu); + if (Connection* c = lookup_connection(resolve_connection_handle(0))) { + c->set_show_deleted(visible); + } + // M12.31 -- SET DELETED is process-wide on the client but must be + // pushed to every open remote server session; otherwise scoped + // index walks on the server still return deleted rows. + s.registry.for_each_handle([&](Handle, HandleKind k, void* p) { + if (k != HandleKind::RemoteConnection) return; + auto* rc = static_cast(p); + if (rc != nullptr) remotes.push_back(rc); + }); + // RCB 07/14/2026: flipping SET DELETED changes which rows are VISIBLE, + // which retroactively invalidates every row we have already read ahead + // of the cursor. The look-ahead block was walked under the old + // visibility, so without this a scan keeps serving rows from it -- + // locally, with no wire traffic at all to hint that anything is stale -- + // and deleted records keep appearing after SET DELETED ON (or vanish + // after SET DELETED OFF). + // + // Same stale-block family as the AdsSeek / AdsSetIndexOrder cases: any + // operation that changes what the queued rows MEAN has to drop them. + // Purely local state, so it is safe to do under s.mu -- no round-trip, + // unlike the show_deleted() pushes below. + s.registry.for_each_handle([&](Handle, HandleKind k, void* p) { + if (k != HandleKind::RemoteTable) return; + auto* rt = static_cast(p); + if (rt == nullptr) return; + rt->row_valid = false; + rt->invalidate_prefetch(); + // Which keys are visible just changed, so the scoped key + // count and any cached keyno are stale too. + rt->key_count_cached = false; + rt->key_counts.clear(); + rt->key_counts.clear(); + rt->keyno_valid = false; + }); + } + // Release s.mu before the wire round-trips -- the in-process test + // server (and openads_serverd embedded in the same DLL) re-enters + // the ABI on another thread and takes s.mu; holding it here + // deadlocks (same pattern as remote AdsOpenTable / AdsOpenIndex). + for (auto* rc : remotes) { + if (rc->valid()) rc->show_deleted(visible); + } + return openads::AE_SUCCESS; +} +UNSIGNED32 ENTRYPOINT AdsShowError(UNSIGNED8* pucErrText) { + arc2_trace("AdsShowError"); + // ADS pops up a message box on a GUI host; OpenADS is headless, so the + // closest faithful behaviour is to write the caller's text to stderr. + if (pucErrText != nullptr && pucErrText[0] != '\0') + std::fprintf(stderr, "%s\n", + reinterpret_cast(pucErrText)); + return openads::AE_SUCCESS; +} +UNSIGNED32 ENTRYPOINT AdsStmtSetTableLockType(ADSHANDLE h, UNSIGNED16 us) { + arc2_trace("AdsStmtSetTableLockType"); + SqlStatement* st = stmt_lookup(h); + if (st == nullptr) return fail(openads::AE_INTERNAL_ERROR, "unknown stmt"); + st->lock_type = us; return ok(); +} +UNSIGNED32 ENTRYPOINT AdsStmtSetTablePassword(ADSHANDLE h, UNSIGNED8* pTable, UNSIGNED8* pPwd) { + arc2_trace("AdsStmtSetTablePassword"); + SqlStatement* st = stmt_lookup(h); + if (st == nullptr) return fail(openads::AE_INTERNAL_ERROR, "unknown stmt"); + st->passwords.emplace_back(openads::abi::to_internal(pTable, 0), + openads::abi::to_internal(pPwd, 0)); + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsStmtSetTableReadOnly(ADSHANDLE h, UNSIGNED16 us) { + arc2_trace("AdsStmtSetTableReadOnly"); + SqlStatement* st = stmt_lookup(h); + if (st == nullptr) return fail(openads::AE_INTERNAL_ERROR, "unknown stmt"); + st->read_only = us; return ok(); +} +UNSIGNED32 ENTRYPOINT AdsStmtSetTableType(ADSHANDLE h, UNSIGNED16 us) { + arc2_trace("AdsStmtSetTableType"); + SqlStatement* st = stmt_lookup(h); + if (st == nullptr) return fail(openads::AE_INTERNAL_ERROR, "unknown stmt"); + st->table_type = us; return ok(); +} +UNSIGNED32 ENTRYPOINT AdsTestLogin(UNSIGNED8*, UNSIGNED16, UNSIGNED8*, UNSIGNED8*, UNSIGNED32) + { + arc2_trace("AdsTestLogin"); ADS_STUB(openads::AE_SUCCESS); } +UNSIGNED32 ENTRYPOINT AdsTestRecLocks(ADSHANDLE hTable) { + arc2_trace("AdsTestRecLocks"); + // Diagnostic hook. Validate the handle and report success -- OpenADS + // has no separate lock-table consistency check to run. + if (get_remote_table(hTable) || get_table(hTable) != nullptr) return ok(); + return fail(openads::AE_INTERNAL_ERROR, "unknown table"); +} +UNSIGNED32 ENTRYPOINT AdsWriteAllRecords(void) { + arc2_trace("AdsWriteAllRecords"); return openads::AE_SUCCESS; } + +// This file is largely one big `extern "C"` block; the mgmt helpers +// below return std::string / Result<> / a small struct, so they need +// C++ linkage. +extern "C++" { +namespace { + +// A management handle resolves to one of these. Local collects an +// in-process snapshot; Remote ships a MgRequest to the server. +struct MgBackend { + bool remote = false; + std::string host; // remote only + std::uint16_t port = 0; // remote only + // DD user this mgmt handle is asking on behalf of, if AdsMgConnect's + // caller supplied one -- carried on every later MgConnect/MgRequest + // round-trip so the mgmt session registers under a real name instead + // of "(anonymous)". Empty is still valid (pre-existing behavior). + std::string mg_user; +}; + +// Optional [u16 ulen][user] MgConnect payload -- empty when `user` is empty, +// matching pre-existing "(anonymous)" behavior for callers that don't know +// or care which DD user is asking. +std::vector build_mg_connect_payload(const std::string& user) { + std::vector out; + if (user.empty()) return out; + auto ulen = static_cast(user.size()); + out.push_back(static_cast(ulen & 0xFFu)); + out.push_back(static_cast((ulen >> 8) & 0xFFu)); + out.insert(out.end(), user.begin(), user.end()); + return out; +} + +// Registry of open mgmt handles. ADSHANDLE values for mgmt start at a +// high base so they never collide with table / connection handles. +std::mutex g_mg_mu; +std::unordered_map g_mg_handles; +ADSHANDLE g_mg_next = 0x4D670001; // 'Mg' + +// Builds a MgSnapshot for whichever backend the handle names. +openads::util::Result +fetch_mg_snapshot(const MgBackend& be) { + using openads::util::Error; + if (!be.remote) { + // Local mode: report this process by enumerating the ABI + // handle registry -- shared with the sp_mg* SQL procedures + // (see collect_local_abi_snapshot near dispatch_sp_builtin_cursor). + return collect_local_abi_snapshot(); + } + // Remote mode: open a socket, ship one MgRequest, read the reply. + openads::network::network_init(); + auto sock = openads::network::connect_tcp(be.host, be.port); + if (!sock.has_value()) + return Error{1, 0, "mg connect failed", ""}; + openads::network::Socket s = sock.value(); + + // MgConnect handshake first, on this same socket, so the session this + // MgRequest travels on registers under be.mg_user instead of + // "(anonymous)" -- AdsMgConnect's own reachability probe (above) uses a + // separate, immediately-closed socket, so its username never reaches + // whichever connection actually ends up carrying the MgRequest. + if (!be.mg_user.empty()) { + openads::network::Frame hello; + hello.opcode = openads::network::Opcode::MgConnect; + hello.payload = build_mg_connect_payload(be.mg_user); + if (auto wr = openads::network::write_frame(s, hello); wr.has_value()) { + (void)openads::network::read_frame(s); // drain MgConnectAck + } + } + + openads::network::Frame req; + req.opcode = openads::network::Opcode::MgRequest; + std::string body = openads::network::encode_mg_request( + openads::network::MgRequestKind::Snapshot, 0); + req.payload.assign(body.begin(), body.end()); + + auto wr = openads::network::write_frame(s, req); + if (!wr.has_value()) { + openads::network::sock_close(s); + return Error{1, 0, "mg request send failed", ""}; + } + auto reply = openads::network::read_frame(s); + openads::network::sock_close(s); + if (!reply.has_value()) + return Error{1, 0, "mg reply read failed", ""}; + if (reply.value().opcode != openads::network::Opcode::MgReplyAck) + return Error{1, 0, "mg request rejected", ""}; + std::string payload(reply.value().payload.begin(), + reply.value().payload.end()); + return openads::network::decode_mg_snapshot(payload); +} + +// Ask a remote server what build it is via the Hello opcode (supported by +// every wire protocol version). Returns e.g. "openads/1.8.14"; a pre-1.8.14 +// server answers its hardcoded "openads/0.3.2", which is itself the +// diagnosis -- an old serverd is running. Empty string when unreachable. +std::string fetch_server_hello(const MgBackend& be) { + openads::network::network_init(); + auto sock = openads::network::connect_tcp(be.host, be.port); + if (!sock.has_value()) return ""; + openads::network::Socket s = sock.value(); + openads::network::Frame req; + req.opcode = openads::network::Opcode::Hello; + std::string out; + if (auto wr = openads::network::write_frame(s, req); wr.has_value()) { + auto reply = openads::network::read_frame(s); + if (reply.has_value() && + reply.value().opcode == openads::network::Opcode::HelloAck) { + out.assign(reply.value().payload.begin(), + reply.value().payload.end()); + } + } + openads::network::sock_close(s); + return out; +} + +} // namespace +} // extern "C++" + +// Mgmt accessor helpers. These return C++ types (Result<>, MgCollector, +// templates), so they live in their own C++-linkage block. +extern "C++" { +namespace { + +// Resolve a mgmt handle to its backend; nullptr if unknown. +const MgBackend* lookup_mg(ADSHANDLE h) { + std::lock_guard g(g_mg_mu); + auto it = g_mg_handles.find(h); + return it == g_mg_handles.end() ? nullptr : &it->second; +} + +// Build a MgCollector for a handle, or return an error. +openads::util::Result +mg_collector_for(ADSHANDLE h) { + const MgBackend* be = lookup_mg(h); + if (be == nullptr) + return openads::util::Error{ + static_cast( + openads::AE_INVALID_CONNECTION_HANDLE), + 0, "invalid mgmt handle", ""}; + auto snap = fetch_mg_snapshot(*be); + if (!snap.has_value()) + return openads::util::Error{ + static_cast(openads::AE_NO_CONNECTION), + 0, "mg telemetry fetch failed", ""}; + return openads::mgmt::MgCollector(snap.value()); +} + +// Copy a POD struct into the caller's buffer, clamped to *pusLen, and +// write back the real struct size. +// Raw struct memcpy is safe across THIS boundary -- unlike the wire, +// where a 32-bit client and 64-bit server may disagree on layout. +// The caller (rddads / Harbour) and this DLL both consume the same +// include/openads/ace.h ADS_MGMT_* definitions, so the layouts are +// identical by construction. mg_wire handles the cross-ABI case. +template +UNSIGNED32 emit_mg_struct(const T& src, void* pBuf, UNSIGNED16* pusLen) { + if (pusLen == nullptr) return openads::AE_INTERNAL_ERROR; + UNSIGNED16 cap = *pusLen; + UNSIGNED16 n = static_cast( + sizeof(T) < cap ? sizeof(T) : cap); + if (pBuf != nullptr && n > 0) std::memcpy(pBuf, &src, n); + *pusLen = static_cast(sizeof(T)); + return openads::AE_SUCCESS; +} + +// Copy a vector of POD structs into the caller's array buffer. +// *pusCount in: capacity (#elements); out: actual element count. +template +UNSIGNED32 emit_mg_array(const std::vector& src, void* pBuf, + UNSIGNED16* pusCount, UNSIGNED16* pusSize) { + if (pusCount == nullptr) return openads::AE_INTERNAL_ERROR; + UNSIGNED16 cap = *pusCount; + UNSIGNED16 n = static_cast( + src.size() < cap ? src.size() : cap); + if (pBuf != nullptr && n > 0) + std::memcpy(pBuf, src.data(), + static_cast(n) * sizeof(T)); + *pusCount = static_cast(src.size()); + if (pusSize != nullptr) *pusSize = static_cast(sizeof(T)); + return openads::AE_SUCCESS; +} + +// Ship a fire-and-forget MgRequest mutator to a remote server. +bool send_mg_mutator(const MgBackend& be, + openads::network::MgRequestKind kind, + std::uint16_t arg) { + openads::network::network_init(); + auto sock = openads::network::connect_tcp(be.host, be.port); + if (!sock.has_value()) return false; + openads::network::Socket s = sock.value(); + openads::network::Frame req; + req.opcode = openads::network::Opcode::MgRequest; + std::string body = openads::network::encode_mg_request(kind, arg); + req.payload.assign(body.begin(), body.end()); + bool ok = openads::network::write_frame(s, req).has_value(); + if (ok) (void)openads::network::read_frame(s); // drain the ack + openads::network::sock_close(s); + return ok; +} + +} // namespace +} // extern "C++" + +// AdsMg* stubs are implemented elsewhere. Kept tests/unit/abi_mgmt_test.cpp's +// existing pattern: zero-fill caller's buffer, return AE_SUCCESS so apps +// proceed without special-casing local-mode mgmt absence. + +// AdsMgConnect -- pucServer selects local vs. remote. An empty or +// "local" server string yields a local-process backend; anything of +// the form "host" or "host:port" yields a remote backend (default +// port 16262, the OpenADS server port). +UNSIGNED32 ENTRYPOINT AdsMgConnect(UNSIGNED8* pucServer, UNSIGNED8* pucUser, + UNSIGNED8* /*pucPwd*/, ADSHANDLE* phMgmt) { + arc2_trace("AdsMgConnect"); + if (phMgmt == nullptr) return openads::AE_INTERNAL_ERROR; + + MgBackend be; + be.mg_user = pucUser ? reinterpret_cast(pucUser) : std::string(); + std::string srv = pucServer + ? reinterpret_cast(pucServer) : ""; + // Strip leading / trailing UNC slashes ("\\\\host\\"). + while (!srv.empty() && (srv.front() == '\\' || srv.front() == '/')) + srv.erase(srv.begin()); + while (!srv.empty() && (srv.back() == '\\' || srv.back() == '/')) + srv.pop_back(); + + // Decide local vs. remote. A string is a REMOTE target only when + // it is "host:port" with a non-empty, all-digit port. Everything + // else -- empty, "local", a drive path like "C:" or "C:\data", + // a bare name -- is a local-mode backend. (rddads' manage.prg + // passes "C:" for local management; that must not be mistaken + // for a host named "C".) + be.remote = false; + auto colon = srv.rfind(':'); + if (colon != std::string::npos && colon > 0 && + colon + 1 < srv.size()) { + std::string portstr = srv.substr(colon + 1); + bool all_digits = !portstr.empty(); + for (char ch : portstr) + if (ch < '0' || ch > '9') { all_digits = false; break; } + if (all_digits) { + be.remote = true; + be.host = srv.substr(0, colon); + be.port = static_cast( + std::strtoul(portstr.c_str(), nullptr, 10)); + } + } + + if (be.remote) { + // Validate the server is reachable up front with a MgConnect + // handshake, so a down server fails here (AE_NO_CONNECTION) + // rather than later with a misleading handle error. + openads::network::network_init(); + auto probe = openads::network::connect_tcp(be.host, be.port); + if (!probe.has_value()) return openads::AE_NO_CONNECTION; + openads::network::Socket ps = probe.value(); + openads::network::Frame hello; + hello.opcode = openads::network::Opcode::MgConnect; + hello.payload = build_mg_connect_payload(be.mg_user); + if (!openads::network::write_frame(ps, hello).has_value()) { + openads::network::sock_close(ps); + return openads::AE_NO_CONNECTION; + } + auto hack = openads::network::read_frame(ps); + openads::network::sock_close(ps); + if (!hack.has_value() || + hack.value().opcode != openads::network::Opcode::MgConnectAck) + return openads::AE_NO_CONNECTION; + } + + std::lock_guard g(g_mg_mu); + ADSHANDLE h = g_mg_next++; + g_mg_handles.emplace(h, std::move(be)); + *phMgmt = h; + return openads::AE_SUCCESS; +} + +UNSIGNED32 ENTRYPOINT AdsMgDisconnect(ADSHANDLE hMgmt) { + arc2_trace("AdsMgDisconnect"); + std::lock_guard g(g_mg_mu); + g_mg_handles.erase(hMgmt); + return openads::AE_SUCCESS; +} +UNSIGNED32 ENTRYPOINT AdsMgGetActivityInfo(ADSHANDLE h, void* p, UNSIGNED16* l) { + arc2_trace("AdsMgGetActivityInfo"); + auto c = mg_collector_for(h); + if (!c.has_value()) return static_cast(c.error().code); + return emit_mg_struct(c.value().activity_info(), p, l); +} +UNSIGNED32 ENTRYPOINT AdsMgGetCommStats(ADSHANDLE h, void* p, UNSIGNED16* l) { + arc2_trace("AdsMgGetCommStats"); + auto c = mg_collector_for(h); + if (!c.has_value()) return static_cast(c.error().code); + return emit_mg_struct(c.value().comm_stats(), p, l); +} +UNSIGNED32 ENTRYPOINT AdsMgGetConfigInfo(ADSHANDLE h, void* pv, UNSIGNED16* lv, + void* pm, UNSIGNED16* lm) { + arc2_trace("AdsMgGetConfigInfo"); + auto c = mg_collector_for(h); + if (!c.has_value()) return static_cast(c.error().code); + UNSIGNED32 rc = emit_mg_struct(c.value().config_params(), pv, lv); + if (rc != openads::AE_SUCCESS) return rc; + return emit_mg_struct(c.value().config_memory(), pm, lm); +} +UNSIGNED32 ENTRYPOINT AdsMgGetInstallInfo(ADSHANDLE h, void* p, UNSIGNED16* l) { + arc2_trace("AdsMgGetInstallInfo"); + auto c = mg_collector_for(h); + if (!c.has_value()) return static_cast(c.error().code); + ADS_MGMT_INSTALL_INFO info = c.value().install_info(); + // For a remote mgmt handle the version that matters is the SERVER + // binary's, not this DLL's -- MgCollector only knows the local build. + // One Hello round-trip answers it for any server version ever shipped. + if (const MgBackend* be = lookup_mg(h); be != nullptr && be->remote) { + std::string hello = fetch_server_hello(*be); // "openads/X.Y.Z" + if (!hello.empty()) { + const std::string prefix = "openads/"; + std::string ver = hello.rfind(prefix, 0) == 0 + ? hello.substr(prefix.size()) : hello; + std::string vs = "OpenADS " + ver; + // aucVersionStr is 16 bytes. A release version ("OpenADS + // 1.8.14") fits; a dev build ("1.8.14-3-gabc1234-dirty") does + // not -- prefer the version detail over the brand then. + if (vs.size() >= sizeof(info.aucVersionStr)) vs = ver; + std::memset(info.aucVersionStr, 0, sizeof(info.aucVersionStr)); + std::memcpy(info.aucVersionStr, vs.c_str(), + std::min(vs.size(), sizeof(info.aucVersionStr) - 1)); + } + } + return emit_mg_struct(info, p, l); +} +UNSIGNED32 ENTRYPOINT AdsMgGetLockOwner(ADSHANDLE h, UNSIGNED8* pucTable, + UNSIGNED32 ulRec, + void* p, UNSIGNED16* l, UNSIGNED16* lt) { + arc2_trace("AdsMgGetLockOwner"); + auto col = mg_collector_for(h); + if (!col.has_value()) return static_cast(col.error().code); + if (lt) *lt = ADS_MGMT_RECORD_LOCK; + std::string tbl = pucTable ? openads::abi::to_internal(pucTable, 0) : ""; + return emit_mg_struct(col.value().lock_owner(ulRec, tbl), p, l); +} +UNSIGNED32 ENTRYPOINT AdsMgGetLocks(ADSHANDLE h, UNSIGNED8* pucTable, + UNSIGNED8* pucUser, + UNSIGNED16 /*usConnNumber*/, void* p, UNSIGNED16* c, + UNSIGNED16* sz) { + arc2_trace("AdsMgGetLocks"); + auto col = mg_collector_for(h); + if (!col.has_value()) return static_cast(col.error().code); + std::string tbl = pucTable ? openads::abi::to_internal(pucTable, 0) : ""; + std::string usr = pucUser ? openads::abi::to_internal(pucUser, 0) : ""; + return emit_mg_array(col.value().locks(tbl, usr), p, c, sz); +} +UNSIGNED32 ENTRYPOINT AdsMgGetOpenIndexes(ADSHANDLE h, UNSIGNED8* pucTable, + UNSIGNED8* /*pucUser*/, + UNSIGNED16 /*usConnNumber*/, void* p, + UNSIGNED16* c, UNSIGNED16* sz) { + arc2_trace("AdsMgGetOpenIndexes"); + auto col = mg_collector_for(h); + if (!col.has_value()) return static_cast(col.error().code); + auto all = col.value().open_indexes(); + if (pucTable != nullptr && pucTable[0] != 0) { + // Filter by owning table. ADS_MGMT_INDEX_INFO has no table field, + // so match against the raw snapshot entries (same order as `all`). + std::string tbl = openads::abi::to_internal(pucTable, 0); + const auto& raw = col.value().snapshot().index_list; + std::vector filtered; + for (std::size_t i = 0; i < all.size() && i < raw.size(); ++i) { + if (openads::mgmt::MgCollector::table_name_matches( + raw[i].table, tbl)) + filtered.push_back(all[i]); + } + return emit_mg_array(filtered, p, c, sz); + } + return emit_mg_array(all, p, c, sz); +} +UNSIGNED32 ENTRYPOINT AdsMgGetOpenTables(ADSHANDLE h, UNSIGNED8* /*f*/, UNSIGNED16 /*o*/, + void* p, UNSIGNED16* c, UNSIGNED16* sz) { + arc2_trace("AdsMgGetOpenTables"); + auto col = mg_collector_for(h); + if (!col.has_value()) return static_cast(col.error().code); + return emit_mg_array(col.value().open_tables(), p, c, sz); +} +UNSIGNED32 ENTRYPOINT AdsMgGetOpenTables2(ADSHANDLE h, UNSIGNED8* /*f*/, UNSIGNED16 /*o*/, + void* p, UNSIGNED16* c, UNSIGNED16* sz) { + arc2_trace("AdsMgGetOpenTables2"); + auto col = mg_collector_for(h); + if (!col.has_value()) return static_cast(col.error().code); + return emit_mg_array(col.value().open_tables(), p, c, sz); +} +UNSIGNED32 ENTRYPOINT AdsMgGetServerType(ADSHANDLE h, UNSIGNED16* p) { + arc2_trace("AdsMgGetServerType"); + auto c = mg_collector_for(h); + if (!c.has_value()) return static_cast(c.error().code); + if (p) *p = c.value().server_type(); + return openads::AE_SUCCESS; +} +UNSIGNED32 ENTRYPOINT AdsMgGetUserNames(ADSHANDLE h, UNSIGNED8* /*pucFile*/, void* p, + UNSIGNED16* c, UNSIGNED16* sz) { + arc2_trace("AdsMgGetUserNames"); + auto col = mg_collector_for(h); + if (!col.has_value()) return static_cast(col.error().code); + return emit_mg_array(col.value().user_names(), p, c, sz); +} +UNSIGNED32 ENTRYPOINT AdsMgGetWorkerThreadActivity(ADSHANDLE h, void* p, UNSIGNED16* c, + UNSIGNED16* sz) { + arc2_trace("AdsMgGetWorkerThreadActivity"); + auto col = mg_collector_for(h); + if (!col.has_value()) return static_cast(col.error().code); + return emit_mg_array(col.value().worker_thread_activity(), p, c, sz); +} +// Non-SAP extension: one average per-frame cost (microseconds) per user, +// in the same order/count AdsMgGetUserNames just returned -- pair them up +// by index. Not part of the SAP-compatible surface; ADS_MGMT_USER_INFO's +// fixed layout has no room for this. +UNSIGNED32 ENTRYPOINT AdsMgGetUserAvgCost(ADSHANDLE h, UNSIGNED32* p, + UNSIGNED16* c, UNSIGNED16* sz) { + arc2_trace("AdsMgGetUserAvgCost"); + auto col = mg_collector_for(h); + if (!col.has_value()) return static_cast(col.error().code); + return emit_mg_array(col.value().user_avg_costs(), p, c, sz); +} +// Non-SAP extension: on-demand detail for one Active Queries row (see +// mgmt::MgCollector::thread_sql) -- the SQL text of the last ExecuteSQL +// frame `ulThreadNumber` processed, plus when it started, epoch seconds +// (0 if unknown/never ran SQL). *pulLen is buffer capacity in, actual +// text length out (truncated to capacity if longer, same convention as +// AdsDDGetDatabaseProperty) -- call once with a 0-capacity probe to size +// the buffer, same as everywhere else in this ABI. +UNSIGNED32 ENTRYPOINT AdsMgGetThreadSql(ADSHANDLE h, UNSIGNED32 ulThreadNumber, + UNSIGNED8* pucBuf, UNSIGNED32* pulLen, + UNSIGNED64* pullStartEpoch) { + arc2_trace("AdsMgGetThreadSql"); + if (pulLen == nullptr) return openads::AE_INTERNAL_ERROR; + auto col = mg_collector_for(h); + if (!col.has_value()) return static_cast(col.error().code); + std::string sql = col.value().thread_sql(ulThreadNumber); + UNSIGNED32 cap = *pulLen; + UNSIGNED32 n = static_cast( + sql.size() < cap ? sql.size() : cap); + if (pucBuf != nullptr && n > 0) std::memcpy(pucBuf, sql.data(), n); + *pulLen = static_cast(sql.size()); + if (pullStartEpoch != nullptr) { + auto at = col.value().thread_sql_at(ulThreadNumber); + *pullStartEpoch = (at.time_since_epoch().count() == 0) ? 0 + : static_cast(std::chrono::system_clock::to_time_t(at)); + } + return openads::AE_SUCCESS; +} +UNSIGNED32 ENTRYPOINT AdsMgKillUser(ADSHANDLE h, UNSIGNED8* /*pucUser*/, + UNSIGNED16 usConnNo) { + arc2_trace("AdsMgKillUser"); + const MgBackend* be = lookup_mg(h); + if (be == nullptr) return openads::AE_INVALID_CONNECTION_HANDLE; + if (!be->remote) return openads::AE_SUCCESS; // no-op local + return send_mg_mutator(*be, + openads::network::MgRequestKind::KillUser, usConnNo) + ? openads::AE_SUCCESS : openads::AE_NO_CONNECTION; +} +UNSIGNED32 ENTRYPOINT AdsMgKillUser90(ADSHANDLE h, UNSIGNED8* pucUser, + UNSIGNED16 usConnNo, UNSIGNED32 /*ulOptions*/) { + arc2_trace("AdsMgKillUser90"); + return AdsMgKillUser(h, pucUser, usConnNo); +} +UNSIGNED32 ENTRYPOINT AdsMgResetCommStats(ADSHANDLE h) { + arc2_trace("AdsMgResetCommStats"); + const MgBackend* be = lookup_mg(h); + if (be == nullptr) return openads::AE_INVALID_CONNECTION_HANDLE; + if (!be->remote) { + openads::mgmt::process_mg_stats().reset_comm(); + return openads::AE_SUCCESS; + } + return send_mg_mutator(*be, + openads::network::MgRequestKind::ResetCommStats, 0) + ? openads::AE_SUCCESS : openads::AE_NO_CONNECTION; +} + +// All AdsDD* helpers (AddIndexFile, AddUserToGroup, CreateLink, +// CreateRefIntegrity, CreateUser, DeleteUser, DropLink, +// Get/SetDatabaseProperty, GetUserProperty, ModifyLink, +// RemoveIndexFile, RemoveRefIntegrity, RemoveUserFromGroup) are +// already defined earlier in this file. + +// --------------------------------------------------------------------------- +// M12.22 -- versioned ACE overloads the X# RDD (xsharp.eu) binds by name. +// Most are thin forwards to the base signature already implemented above, +// dropping the parameters newer ACE builds added (charset/collation tags, +// page sizes, RI error strings). The handful with no OpenADS base get a +// minimal implementation. Parameter lists mirror XSharp.Rdd/ACE/ACE.prg. +// --------------------------------------------------------------------------- + +UNSIGNED32 ENTRYPOINT AdsConnect26(UNSIGNED8* pucServer, UNSIGNED16 usServerType, + ADSHANDLE* phConnect) { + arc2_trace("AdsConnect26"); + return AdsConnect60(pucServer, usServerType, nullptr, nullptr, 0, + phConnect); +} + +UNSIGNED32 ENTRYPOINT AdsCreateTable71(ADSHANDLE hConnect, UNSIGNED8* pucName, + UNSIGNED8* pucAlias, UNSIGNED16 usTableType, + UNSIGNED16 usCharType, UNSIGNED16 usLockType, + UNSIGNED16 usCheckRights, UNSIGNED16 usMemoSize, + UNSIGNED8* pucFields, UNSIGNED32 /*ulOptions*/, + ADSHANDLE* phTable) { + arc2_trace("AdsCreateTable71"); + return AdsCreateTable(hConnect, pucName, pucAlias, usTableType, usCharType, + usLockType, usCheckRights, usMemoSize, pucFields, + phTable); +} + +UNSIGNED32 ENTRYPOINT AdsCreateTable90(ADSHANDLE hConnect, UNSIGNED8* pucName, + UNSIGNED8* pucAlias, UNSIGNED16 usTableType, + UNSIGNED16 usCharType, UNSIGNED16 usLockType, + UNSIGNED16 usCheckRights, UNSIGNED16 usMemoSize, + UNSIGNED8* pucFields, UNSIGNED32 /*ulOptions*/, + UNSIGNED8* /*pucCollation*/, ADSHANDLE* phTable) { + arc2_trace("AdsCreateTable90"); + return AdsCreateTable(hConnect, pucName, pucAlias, usTableType, usCharType, + usLockType, usCheckRights, usMemoSize, pucFields, + phTable); +} + +UNSIGNED32 ENTRYPOINT AdsOpenTable90(ADSHANDLE hConnect, UNSIGNED8* pucName, + UNSIGNED8* pucAlias, UNSIGNED16 usTableType, + UNSIGNED16 usCharType, UNSIGNED16 usLockType, + UNSIGNED16 usCheckRights, UNSIGNED32 ulOptions, + UNSIGNED8* /*pucCollation*/, ADSHANDLE* phTable) { + arc2_trace("AdsOpenTable90"); + return AdsOpenTable(hConnect, pucName, pucAlias, usTableType, usCharType, + usLockType, usCheckRights, + static_cast(ulOptions), phTable); +} + +UNSIGNED32 ENTRYPOINT AdsCreateIndex90(ADSHANDLE hObj, UNSIGNED8* pucFileName, + UNSIGNED8* pucTag, UNSIGNED8* pucExpr, + UNSIGNED8* pucCondition, UNSIGNED8* pucWhile, + UNSIGNED32 ulOptions, UNSIGNED32 ulPageSize, + UNSIGNED8* /*pucCollation*/, ADSHANDLE* phIndex) { + arc2_trace("AdsCreateIndex90"); + return AdsCreateIndex61(hObj, pucFileName, pucTag, pucExpr, pucCondition, + pucWhile, ulOptions, + static_cast(ulPageSize), phIndex); +} + +UNSIGNED32 ENTRYPOINT AdsDDAddTable90(ADSHANDLE hConnect, UNSIGNED8* pucAlias, + UNSIGNED8* pucTablePath, UNSIGNED16 usTableType, + UNSIGNED16 usCharType, UNSIGNED8* pucIndexPath, + UNSIGNED8* pucComment, UNSIGNED8* /*pucCollation*/) { + arc2_trace("AdsDDAddTable90"); + return AdsDDAddTable(hConnect, pucAlias, pucTablePath, usTableType, + usCharType, pucIndexPath, pucComment); +} + +UNSIGNED32 ENTRYPOINT AdsDDCreateRefIntegrity62(ADSHANDLE hConnect, UNSIGNED8* pucName, + UNSIGNED8* pucFail, UNSIGNED8* pucParent, + UNSIGNED8* pucParentTag, UNSIGNED8* pucChild, + UNSIGNED8* pucChildTag, UNSIGNED16 usUpdate, + UNSIGNED16 usDelete, + UNSIGNED8* /*pucNoPrimaryError*/, + UNSIGNED8* /*pucCascadeError*/) { + arc2_trace("AdsDDCreateRefIntegrity62"); + return AdsDDCreateRefIntegrity(hConnect, pucName, pucFail, pucParent, + pucParentTag, pucChild, pucChildTag, + usUpdate, usDelete); +} + +UNSIGNED32 ENTRYPOINT AdsFindFirstTable62(ADSHANDLE hConnect, UNSIGNED8* pucFileMask, + UNSIGNED8* pucFirstDD, UNSIGNED16* pusDDLen, + UNSIGNED8* pucFirstFile, UNSIGNED16* pusFileLen, + ADSHANDLE* phFind) { + arc2_trace("AdsFindFirstTable62"); + // OpenADS doesn't track a data-dictionary name alongside the file + // name, so report an empty DD name and forward to the base API. + if (pusDDLen) { + if (pucFirstDD && *pusDDLen > 0) pucFirstDD[0] = 0; + *pusDDLen = 0; + } + return AdsFindFirstTable(hConnect, pucFileMask, pucFirstFile, pusFileLen, + phFind); +} + +UNSIGNED32 ENTRYPOINT AdsFindNextTable62(ADSHANDLE hConnect, ADSHANDLE hFind, + UNSIGNED8* pucDDName, UNSIGNED16* pusDDLen, + UNSIGNED8* pucFileName, UNSIGNED16* pusFileLen) { + arc2_trace("AdsFindNextTable62"); + if (pusDDLen) { + if (pucDDName && *pusDDLen > 0) pucDDName[0] = 0; + *pusDDLen = 0; + } + return AdsFindNextTable(hConnect, hFind, pucFileName, pusFileLen); +} + +UNSIGNED32 ENTRYPOINT AdsGetDateFormat60(ADSHANDLE /*hConnect*/, UNSIGNED8* pucBuf, + UNSIGNED16* pusLen) { + arc2_trace("AdsGetDateFormat60"); + return AdsGetDateFormat(pucBuf, pusLen); +} + +UNSIGNED32 ENTRYPOINT AdsGetExact22(ADSHANDLE /*hObj*/, UNSIGNED16* pbExact) { + arc2_trace("AdsGetExact22"); + return AdsGetExact(pbExact); +} + +UNSIGNED32 ENTRYPOINT AdsReindex61(ADSHANDLE hObject, UNSIGNED32 /*ulPageSize*/) { + arc2_trace("AdsReindex61"); + return AdsReindex(hObject); +} + +UNSIGNED32 ENTRYPOINT AdsRestructureTable90(ADSHANDLE hConnect, UNSIGNED8* pucTableName, + UNSIGNED8* /*pucPassword*/, + UNSIGNED16 usTableType, UNSIGNED16 usCharType, + UNSIGNED16 usLockType, UNSIGNED16 usCheckRights, + UNSIGNED8* pucAddFields, + UNSIGNED8* pucDeleteFields, + UNSIGNED8* pucChangeFields, + UNSIGNED8* /*pucCollation*/) { + arc2_trace("AdsRestructureTable90"); + return AdsRestructureTable(hConnect, pucTableName, nullptr, usTableType, + usCharType, usLockType, usCheckRights, + pucAddFields, pucDeleteFields, pucChangeFields); +} + +UNSIGNED32 ENTRYPOINT AdsRestructureTable120(ADSHANDLE hConnect, + UNSIGNED8* pucTableName, + UNSIGNED8* /*pucPassword*/, + UNSIGNED16 usTableType, + UNSIGNED16 usCharType, + UNSIGNED16 usLockType, + UNSIGNED16 usCheckRights, + UNSIGNED8* pucAddFields, + UNSIGNED8* pucDeleteFields, + UNSIGNED8* pucChangeFields, + UNSIGNED8* /*pucCollation*/, + UNSIGNED32 /*ulMemoBlockSize*/, + UNSIGNED32 ulOptions) { + arc2_trace("AdsRestructureTable120"); + if (ulOptions != 0) { + return fail(openads::AE_INTERNAL_ERROR, "reserved options must be zero"); + } + return AdsRestructureTable(hConnect, pucTableName, nullptr, usTableType, + usCharType, usLockType, usCheckRights, + pucAddFields, pucDeleteFields, pucChangeFields); +} + +// --- no OpenADS base function: minimal implementations --- + +// X# calls these on row-edit cancel / connection-property tuning. +// OpenADS has no deferred-write row buffer and treats ACE properties +// as no-ops, so acknowledge success rather than break the caller flow. +UNSIGNED32 ENTRYPOINT AdsCancelUpdate90(ADSHANDLE /*hTable*/, UNSIGNED32 /*ulOptions*/) { + arc2_trace("AdsCancelUpdate90"); + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsSetProperty90(ADSHANDLE /*hObj*/, UNSIGNED32 /*ulOperation*/, + UNSIGNED64* /*puqValue*/) { + arc2_trace("AdsSetProperty90"); + return ok(); +} +// Pre-90 (32-bit value) sibling of AdsSetProperty90; same no-op treatment. +UNSIGNED32 ENTRYPOINT AdsSetProperty(ADSHANDLE /*hObj*/, UNSIGNED32 /*ulOperation*/, + UNSIGNED32* /*pulValue*/) { + arc2_trace("AdsSetProperty"); + arc2_trace("AdsSetProperty"); + return ok(); +} + +// AdsSetRightsChecking -- global, process-wide legacy rights-checking +// mode. Real rights enforcement in OpenADS happens server-side in the +// DD engine regardless of this client-side flag (see project notes on +// DD engine enforcement), so this only records the caller's requested +// mode for round-tripping; it does not change enforcement behavior. +namespace { +std::atomic g_rights_checking{ADS_IGNORE_RIGHTS_CHECKING}; +} +UNSIGNED32 ENTRYPOINT AdsSetRightsChecking(UNSIGNED32 ulOptions) { + arc2_trace("AdsSetRightsChecking"); + if (ulOptions != ADS_RESPECT_RIGHTS_CHECKING && + ulOptions != ADS_IGNORE_RIGHTS_CHECKING) { + return fail(openads::AE_INTERNAL_ERROR, + "AdsSetRightsChecking: invalid option"); + } + g_rights_checking.store(ulOptions); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsSetTableTransactionFree(ADSHANDLE hTable, + UNSIGNED16 usTransFree) { + arc2_trace("AdsSetTableTransactionFree"); + Table* t = get_table(hTable); + if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + t->set_transaction_free(usTransFree != 0); + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsIsTableTransactionFree(ADSHANDLE hTable, + UNSIGNED16* pusTransFree) { + arc2_trace("AdsIsTableTransactionFree"); + if (pusTransFree == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + Table* t = get_table(hTable); + if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *pusTransFree = t->transaction_free() ? 1 : 0; + return ok(); +} + +// OpenADS keys connections/tables by handle, not by path/name, so +// report "not found" -- X# then opens a fresh connection/table. +UNSIGNED32 ENTRYPOINT AdsFindConnection25(UNSIGNED8* /*pucFullPath*/, + ADSHANDLE* phConnect) { + arc2_trace("AdsFindConnection25"); + if (phConnect) *phConnect = 0; + return fail(openads::AE_NO_CONNECTION, "no connection for path"); +} +UNSIGNED32 ENTRYPOINT AdsGetTableHandle(ADSHANDLE hConnect, UNSIGNED8* pucName, + ADSHANDLE* phTable) { + arc2_trace("AdsGetTableHandle"); + return AdsGetTableHandle25(hConnect, pucName, phTable); +} +UNSIGNED32 ENTRYPOINT AdsGetTableHandle25(ADSHANDLE /*hConnect*/, UNSIGNED8* /*pucName*/, + ADSHANDLE* phTable) { + arc2_trace("AdsGetTableHandle25"); + if (phTable) *phTable = 0; + return fail(openads::AE_TABLE_NOT_FOUND, "no open table for name"); +} + +// The SAP "60" bookmark API hands back an opaque blob the app later +// replays. OpenADS encodes it as the 4-byte little-endian recno -- +// stable for the table's lifetime, enough for navigate-and-return. +UNSIGNED32 ENTRYPOINT AdsGetBookmark60(ADSHANDLE hObj, UNSIGNED8* pucBookmark, + UNSIGNED32* pulLength) { + arc2_trace("AdsGetBookmark60"); + if (pulLength == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (pucBookmark == nullptr || *pulLength < 4) { + *pulLength = 4; + return fail(openads::AE_INTERNAL_ERROR, "bookmark buffer too small"); + } + UNSIGNED32 recno = 0; + UNSIGNED32 rc = AdsGetRecordNum(hObj, 0, &recno); + if (rc != openads::AE_SUCCESS) return rc; + pucBookmark[0] = static_cast(recno & 0xFF); + pucBookmark[1] = static_cast((recno >> 8) & 0xFF); + pucBookmark[2] = static_cast((recno >> 16) & 0xFF); + pucBookmark[3] = static_cast((recno >> 24) & 0xFF); + *pulLength = 4; + return ok(); +} +// SAP / rddads signature: 3 args. AdsGetBookmark60 returns +// (pucBookmark + *pulLength); the caller hands that exact length +// back into AdsGotoBookmark60 to replay the bookmark -- needed +// because real ACE supports variable-length bookmarks (the size +// depends on the index/order). OpenADS encodes everything as a +// 4-byte recno today, so any ulLength < 4 is a malformed call. +UNSIGNED32 ENTRYPOINT AdsGotoBookmark60(ADSHANDLE hObj, UNSIGNED8* pucBookmark, + UNSIGNED32 ulLength) { + arc2_trace("AdsGotoBookmark60"); + if (pucBookmark == nullptr || ulLength < 4) { + return fail(openads::AE_INTERNAL_ERROR, ""); + } + UNSIGNED32 recno = static_cast(pucBookmark[0]) + | (static_cast(pucBookmark[1]) << 8) + | (static_cast(pucBookmark[2]) << 16) + | (static_cast(pucBookmark[3]) << 24); + return AdsGotoRecord(hObj, recno); +} +UNSIGNED32 ENTRYPOINT AdsGotoBOF(ADSHANDLE hTable) { + arc2_trace("AdsGotoBOF"); + UNSIGNED32 rc = AdsGotoTop(hTable); + if (rc != openads::AE_SUCCESS) return rc; + return AdsSkip(hTable, -1); +} +UNSIGNED32 ENTRYPOINT AdsGotoEOF(ADSHANDLE hTable) { + arc2_trace("AdsGotoEOF"); + UNSIGNED32 rc = AdsGotoBottom(hTable); + if (rc != openads::AE_SUCCESS) return rc; + return AdsSkip(hTable, 1); +} + +// X#'s ADSRDD calls this during table OPEN to size its memo buffers. +// Report the attached memo store's block size; for a table with no +// memo (or a remote handle -- no memo introspection over the wire yet) +// hand back the xBase FPT default so the RDD has a usable value. +UNSIGNED32 ENTRYPOINT AdsGetMemoBlockSize(ADSHANDLE hObj, UNSIGNED16* pusBlockSize) { + arc2_trace("AdsGetMemoBlockSize"); + if (pusBlockSize == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *pusBlockSize = 64; + if (get_remote_table(hObj) != nullptr) return ok(); + Table* t = get_table(hObj); + if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); + if (auto* m = t->memo(); m != nullptr && m->block_size() != 0) { + *pusBlockSize = static_cast(m->block_size()); + } + return ok(); +} + +// --------------------------------------------------------------------------- +// M12.23 -- close the export gap the X# Advantage RDD (XSharp.Rdd) relies on. +// ADSRDD.prg references ~45 entry points OpenADS didn't yet export. Most are +// accept-and-ignore (session toggles, statement helpers) or thin forwards; +// the field-setter family uses the ACE "field NAME or 1-based ordinal cast to +// a pointer" idiom. Genuinely-unimplemented ones return +// AE_FUNCTION_NOT_AVAILABLE so the X# runtime falls back to its own +// client-side path. Signatures mirror XSharp.Rdd/ACE/ACE32.prg. +// --------------------------------------------------------------------------- + +// ACE field-identifier idiom: a small integer in the "field name" pointer +// slot is a 1-based field ordinal; a real pointer is the name string. +static const char* resolve_field_id(ADSHANDLE hTable, UNSIGNED8* pId, + UNSIGNED8* scratch, UNSIGNED16 scratchLen) { + if (reinterpret_cast(pId) >= 0x10000u) { + return reinterpret_cast(pId); + } + if (scratch == nullptr || scratchLen == 0) return ""; + scratch[0] = 0; + UNSIGNED16 ord = static_cast(reinterpret_cast(pId)); + UNSIGNED16 len = scratchLen; + if (AdsGetFieldName(hTable, ord, scratch, &len) != openads::AE_SUCCESS) { + return ""; + } + return reinterpret_cast(scratch); +} +static UNSIGNED8* as_field(const char* s) { + return const_cast(reinterpret_cast(s)); +} + +UNSIGNED32 ENTRYPOINT AdsGetTableOpenOptions(ADSHANDLE hTable, UNSIGNED32* pulOptions) { + arc2_trace("AdsGetTableOpenOptions"); + if (pulOptions == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *pulOptions = 0; + if (get_remote_table(hTable) != nullptr) { return ok(); } + Table* t = get_table(hTable); + if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); + // Map internal OpenMode to ACE open-option bits. + switch (t->open_mode()) { + case openads::engine::OpenMode::Read: *pulOptions = ADS_READONLY; break; + case openads::engine::OpenMode::Shared: *pulOptions = ADS_SHARED; break; + case openads::engine::OpenMode::Exclusive: *pulOptions = ADS_EXCLUSIVE; break; + } + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsGetBookmark(ADSHANDLE hTable, ADSHANDLE* phBookmark) { + arc2_trace("AdsGetBookmark"); + if (phBookmark == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + UNSIGNED32 rec = 0; + UNSIGNED32 rc = AdsGetRecordNum(hTable, 0, &rec); + if (rc != openads::AE_SUCCESS) return rc; + *phBookmark = rec; // recno-as-token; pairs with AdsGotoRecord + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsCancelUpdate(ADSHANDLE /*hTable*/) { + arc2_trace("AdsCancelUpdate"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsClearAllScopes(ADSHANDLE /*hTable*/) { + arc2_trace("AdsClearAllScopes"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsClearDefault(void) { + arc2_trace("AdsClearDefault"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsResetConnection(ADSHANDLE /*hConnect*/) { + arc2_trace("AdsResetConnection"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsThreadExit(void) { + arc2_trace("AdsThreadExit"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDisableLocalConnections(void) { + arc2_trace("AdsDisableLocalConnections"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsEnableRI(ADSHANDLE /*hConn*/) { + arc2_trace("AdsEnableRI"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDisableRI(ADSHANDLE /*hConn*/) { + arc2_trace("AdsDisableRI"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsEnableUniqueEnforcement(ADSHANDLE /*hConn*/) { + arc2_trace("AdsEnableUniqueEnforcement"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDisableUniqueEnforcement(ADSHANDLE /*hConn*/) { + arc2_trace("AdsDisableUniqueEnforcement"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsEnableAutoIncEnforcement(ADSHANDLE /*hConn*/) { + arc2_trace("AdsEnableAutoIncEnforcement"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDisableAutoIncEnforcement(ADSHANDLE /*hConn*/) { + arc2_trace("AdsDisableAutoIncEnforcement"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsRecallAllRecords(ADSHANDLE /*hTable*/) { + arc2_trace("AdsRecallAllRecords"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsIsRecordVisible(ADSHANDLE /*hObj*/, UNSIGNED16* pbVisible) { + arc2_trace("AdsIsRecordVisible"); + if (pbVisible) *pbVisible = 1; + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsGetKeyCount(ADSHANDLE hIndex, UNSIGNED16 /*usFilter*/, + UNSIGNED32* pulCount) { + arc2_trace("AdsGetKeyCount"); + if (pulCount == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *pulCount = 0; + // M12.28 - route remote index handles through the wire. + if (auto* ri = get_remote_index(hIndex)) { + if (ri->parent != nullptr) { + if (UNSIGNED32 frc = remote_flush_pending(ri->parent); + frc != 0) { + return frc; + } + } + auto r = openads::network::remote_index_key_count(ri); + if (!r) return fail(r.error()); + *pulCount = r.value(); + return ok(); + } + // M12.28 - route remote table handles: key_count = physical count. + if (auto* rt = get_remote_table(hIndex)) { + if (rt->conn == nullptr) return fail(openads::AE_INTERNAL_ERROR, "remote table: no connection"); + if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; + auto r = rt->conn->key_count(rt->id); + if (!r) return fail(r.error()); + *pulCount = r.value(); + return ok(); + } + Table* t = get_table(hIndex); + if (t == nullptr) return ok(); + // Settle any coalesced dirty record first: its index keys are only + // inserted on writeback, so a pending edit would be missing from the + // count. + if (auto cr = t->commit_dirty_record(); !cr) return fail(cr.error()); + // With an active order, the KEY count can be far fewer than the table's + // record_count() (a conditional/FOR index indexes only matching rows). + // Returning record_count() here made it inconsistent with OrdKeyNo / + // GetRelKeyPos (which count the index walk) -> TXBrowse position math + // broke on conditional orders. Use the cached index walk (O(1)). + auto* ord = t->order(); + if (ord != nullptr && ord->index() != nullptr) { + if (auto* cdx = + dynamic_cast(ord->index())) { + auto& sc = ord->scope(); + const bool hide_del = !t->show_deleted_records(); + std::function live; + const std::function* live_p = nullptr; + if (hide_del) { + // deleted_at() keeps the driver's read-ahead warm and does not + // move the cursor, so there is nothing to restore. + live = [t](std::uint32_t rn) { + auto del = t->deleted_at(rn); + return del && !del.value(); + }; + live_p = &live; + } + if (sc.top.has_value() || sc.bottom.has_value()) { + *pulCount = cdx->count_scoped_keys( + sc.top.value_or(""), + sc.bottom.value_or(""), + live_p); + } else if (hide_del) { + *pulCount = count_live_recnos(t, cdx->ordered_recnos_cached(), cdx); + } else { + *pulCount = static_cast( + cdx->ordered_recnos_cached().size()); + } + return ok(); + } + // NTX: use cached B-tree walk for correct conditional count + if (auto* ntx = + dynamic_cast(ord->index())) { + const bool hide_del = !t->show_deleted_records(); + if (hide_del) { + *pulCount = count_live_recnos(t, ntx->ordered_recnos_cached(), ntx); + } else { + *pulCount = static_cast( + ntx->ordered_recnos_cached().size()); + } + return ok(); + } + // ADI: use cached B-tree walk for correct conditional count + if (auto* adi = + dynamic_cast(ord->index())) { + const bool hide_del = !t->show_deleted_records(); + if (hide_del) { + *pulCount = count_live_recnos(t, adi->ordered_recnos_cached(), adi); + } else { + *pulCount = static_cast( + adi->ordered_recnos_cached().size()); + } + return ok(); + } + if (auto* adi = + dynamic_cast(ord->index())) { + // Same reasoning for a native ADI tag: a v2 tag with a FOR clause + // holds only the matching rows, so falling through to the table's + // record_count() reported every row and broke OrdKeyCount on a + // conditional order (the ERP's ORD5 FOR cCorEnvEle != 'S'). + const bool hide_del = !t->show_deleted_records(); + const std::uint32_t saved_rn = t->recno(); + std::uint32_t n = 0; + for (std::uint32_t rn : adi->ordered_recnos_cached()) { + if (!hide_del) { ++n; continue; } + if (t->goto_record(rn) && !t->is_deleted()) ++n; + } + *pulCount = n; + if (hide_del && saved_rn != 0) (void)t->goto_record(saved_rn); + return ok(); + } + } + *pulCount = t->record_count(); + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsContinue(ADSHANDLE hTable, UNSIGNED16* pbFound) { + arc2_trace("AdsContinue"); + if (pbFound) *pbFound = 0; + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + // Skip one record forward -- Table::skip() is filter-aware: it walks + // past non-matching records until it finds one that passes the current + // filter (AOF or SetFilter) or reaches EOF. + auto r = t->skip(1); + if (!r) return fail(r.error()); + if (pbFound) *pbFound = t->eof() ? 0 : 1; + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsEvalTestExpr(ADSHANDLE /*hTable*/, UNSIGNED8* /*pucExpr*/, + UNSIGNED16* pusType) { + arc2_trace("AdsEvalTestExpr"); + if (pusType) *pusType = 0; + return ok(); // treat any expr as syntactically valid +} +UNSIGNED32 ENTRYPOINT AdsEvalLogicalExpr(ADSHANDLE hTable, UNSIGNED8* pucExpr, + UNSIGNED16* pbResult) { + arc2_trace("AdsEvalLogicalExpr"); + if (pbResult) *pbResult = 0; + if (!pucExpr) return fail(openads::AE_INTERNAL_ERROR, "null expr"); + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + std::string expr = reinterpret_cast(pucExpr); + auto ast = openads::engine::aof::parse(expr); + if (!ast) return fail(ast.error()); + if (pbResult) + *pbResult = openads::engine::aof::evaluate_record(*ast.value(), *t) ? 1 : 0; + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsEvalNumericExpr(ADSHANDLE hTable, UNSIGNED8* pucExpr, double* pdResult) { + arc2_trace("AdsEvalNumericExpr"); + if (pdResult) *pdResult = 0.0; + if (!pucExpr) return fail(openads::AE_INTERNAL_ERROR, "null expr"); + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + std::string expr = reinterpret_cast(pucExpr); + std::int32_t fi = t->field_index(expr); + if (fi >= 0) { + auto v = t->read_field(static_cast(fi)); + if (v && pdResult) *pdResult = v.value().as_double; + return ok(); + } + try { + std::size_t pos = 0; + double d = std::stod(expr, &pos); + if (pos == expr.size() && pdResult) *pdResult = d; + return ok(); + } catch (...) {} + return fail(openads::AE_FUNCTION_NOT_AVAILABLE, "cannot evaluate numeric expr"); +} +UNSIGNED32 ENTRYPOINT AdsEvalStringExpr(ADSHANDLE hTable, UNSIGNED8* pucExpr, + UNSIGNED8* pucResult, UNSIGNED16* pusLen) { + arc2_trace("AdsEvalStringExpr"); + if (!pucExpr) return fail(openads::AE_INTERNAL_ERROR, "null expr"); + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + std::string expr = reinterpret_cast(pucExpr); + std::string result; + std::int32_t fi = t->field_index(expr); + if (fi >= 0) { + auto v = t->read_field(static_cast(fi)); + if (v) result = v.value().as_string; + } else { + result = expr; + } + if (pusLen) { + std::uint16_t cap = *pusLen; + std::uint16_t rlen = static_cast(result.size()); + if (pucResult && cap > 0) { + auto cap1 = static_cast(cap - 1u); + std::uint16_t copy = rlen < cap1 ? rlen : cap1; + std::memcpy(pucResult, result.data(), copy); + pucResult[copy] = 0; + } + *pusLen = rlen; + } + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsFindConnection(UNSIGNED8* /*pucServer*/, ADSHANDLE* phConnect) { + arc2_trace("AdsFindConnection"); + if (phConnect) *phConnect = 0; + return fail(openads::AE_NO_CONNECTION, "no connection for path"); +} +UNSIGNED32 ENTRYPOINT AdsGetAllIndexes(ADSHANDLE hTable, ADSHANDLE* ahIndex, + UNSIGNED16* pusArrayLen) { + arc2_trace("AdsGetAllIndexes"); + if (!pusArrayLen) return fail(openads::AE_INTERNAL_ERROR, "null out"); + if (get_remote_table(hTable)) { *pusArrayLen = 0; return ok(); } + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + std::vector found; + // Storm fix: reader must hold index_bindings_mu (binds are unlocked now). + std::lock_guard _ra_lk(index_bindings_mu()); + for (auto& [h, b] : index_bindings()) { + if (b.table == t) found.push_back(h); + } + UNSIGNED16 cap = *pusArrayLen; + auto total = static_cast(found.size()); + UNSIGNED16 n = cap < total ? cap : total; + *pusArrayLen = n; + if (ahIndex) { + for (UNSIGNED16 i = 0; i < n; ++i) ahIndex[i] = found[i]; + } + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsGetFTSIndexes(ADSHANDLE /*hTable*/, ADSHANDLE* /*ahIndex*/, + UNSIGNED16* pusArrayLen) { + arc2_trace("AdsGetFTSIndexes"); + // FTS indexes are loaded ad-hoc at query time with no persistent + // handle, so there is nothing to enumerate here. + if (pusArrayLen) *pusArrayLen = 0; + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsGetAllTables(ADSHANDLE hConnect, ADSHANDLE* ahTable, + UNSIGNED16* pusArrayLen) { + arc2_trace("AdsGetAllTables"); + if (!pusArrayLen) return fail(openads::AE_INTERNAL_ERROR, "null out"); + auto& s = state(); + std::lock_guard lk(s.mu); + Connection* conn = + s.registry.lookup(hConnect, HandleKind::Connection); + if (!conn) { *pusArrayLen = 0; return ok(); } // remote or unknown + std::vector found; + s.registry.for_each_handle([&](Handle h, HandleKind k, void* p) { + if (k != HandleKind::Table) return; + if (conn->owns_table_ptr(static_cast(p))) found.push_back(to_ads_handle(h)); + }); + UNSIGNED16 cap = *pusArrayLen; + auto total = static_cast(found.size()); + UNSIGNED16 n = cap < total ? cap : total; + *pusArrayLen = n; + if (ahTable) { + for (UNSIGNED16 i = 0; i < n; ++i) ahTable[i] = found[i]; + } + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsCloneTable(ADSHANDLE hTable, ADSHANDLE* phClone) { + arc2_trace("AdsCloneTable"); + if (!phClone) return fail(openads::AE_INTERNAL_ERROR, "null out param"); + *phClone = 0; + auto& s = state(); + std::lock_guard lk(s.mu); + Table* t = s.registry.lookup
(hTable, HandleKind::Table); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + if (!t->driver()) return fail(openads::AE_INTERNAL_ERROR, "no driver"); + + // Locate the connection that owns this table. + Connection* owning = nullptr; + s.registry.for_each_handle([&](Handle, HandleKind k, void* p) { + if (k != HandleKind::Connection || owning) return; + auto* cc = static_cast(p); + if (cc->owns_table_ptr(t)) owning = cc; + }); + if (!owning) return fail(openads::AE_INVALID_CONNECTION_HANDLE, + "table not owned by any connection"); + + // Unique temp filename inside the data directory. + namespace fs = std::filesystem; + char tmp_name[64] = {}; + std::snprintf(tmp_name, sizeof(tmp_name), "_clone_%llx.dbf", + static_cast( + openads::platform::monotonic_nanos())); + + // Serialize structure + all records (including deleted, for exact + // fidelity) to the temp file. + const auto& src_fields = t->driver()->fields(); + if (src_fields.empty()) + return fail(openads::AE_INTERNAL_ERROR, "AdsCloneTable: no fields"); + std::uint16_t header_len = static_cast( + 32 + 32 * src_fields.size() + 2); + std::uint16_t rec_len = t->driver()->record_length(); + + std::vector file; + std::vector hdr(32, 0); + hdr[0] = 0x03; + stamp_dbf_header_today(hdr.data()); + hdr[8] = static_cast(header_len & 0xFFu); + hdr[9] = static_cast((header_len >> 8) & 0xFFu); + hdr[10] = static_cast(rec_len & 0xFFu); + hdr[11] = static_cast((rec_len >> 8) & 0xFFu); + file = hdr; + for (const auto& f : src_fields) { + std::vector fd(32, 0); + std::size_t n = std::min(f.name.size(), 10); + std::memcpy(fd.data(), f.name.data(), n); + fd[11] = static_cast(f.raw_type ? f.raw_type : 'C'); + fd[16] = static_cast(f.length); + fd[17] = f.decimals; + file.insert(file.end(), fd.begin(), fd.end()); + } + stamp_dbf_field_displacements(file.data() + 32, src_fields.size()); + file.push_back(0x0D); + file.push_back(0x00); + + std::uint32_t rcount = t->driver()->record_count(); + for (std::uint32_t r = 1; r <= rcount; ++r) { + auto rec = t->driver()->read_record_raw(r); + if (!rec) return fail(rec.error()); + file.insert(file.end(), rec.value().begin(), rec.value().end()); + } + file.push_back(0x1A); + file[4] = static_cast( rcount & 0xFFu); + file[5] = static_cast((rcount >> 8) & 0xFFu); + file[6] = static_cast((rcount >> 16) & 0xFFu); + file[7] = static_cast((rcount >> 24) & 0xFFu); + + fs::path tmp_path = fs::path(owning->data_dir()) / tmp_name; + { + std::ofstream out(tmp_path, std::ios::binary); + if (!out) return fail(openads::AE_INTERNAL_ERROR, + "AdsCloneTable: write failed"); + out.write(reinterpret_cast(file.data()), + static_cast(file.size())); + if (!out) return fail(openads::AE_INTERNAL_ERROR, + "AdsCloneTable: write error"); + } + + // Open the clone through the owning connection. + auto th = owning->open_table(std::string(tmp_name), + openads::engine::TableType::Cdx, + openads::engine::OpenMode::Shared); + if (!th) { + std::error_code ec; + fs::remove(tmp_path, ec); + return fail(th.error()); + } + Table* clone = owning->lookup_table(th.value()); + if (!clone) return fail(openads::AE_INTERNAL_ERROR, + "AdsCloneTable: post-open"); + *phClone = to_ads_handle(s.registry.register_object(HandleKind::Table, clone)); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsCopyTableStructure(ADSHANDLE hTable, UNSIGNED8* pucFile) { + arc2_trace("AdsCopyTableStructure"); + if (!pucFile) return fail(openads::AE_INTERNAL_ERROR, "null path"); + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + if (!t->driver()) return fail(openads::AE_INTERNAL_ERROR, "no driver"); + + namespace fs = std::filesystem; + auto raw = openads::abi::to_internal(pucFile, 0); + fs::path dst(raw); + if (!dst.is_absolute()) + dst = fs::path(t->path()).parent_path() / dst; + if (!dst.has_extension()) dst.replace_extension(".dbf"); + + const auto& src_fields = t->driver()->fields(); + if (src_fields.empty()) + return fail(openads::AE_INTERNAL_ERROR, "AdsCopyTableStructure: no fields"); + std::uint16_t header_len = static_cast( + 32 + 32 * src_fields.size() + 2); // Harbour DBFCDX header: 32 + 32*n + 2 + std::uint16_t rec_len = t->driver()->record_length(); + + std::vector file; + std::vector hdr(32, 0); + hdr[0] = 0x03; + stamp_dbf_header_today(hdr.data()); + // record count = 0 (bytes 4-7 stay zero) + hdr[8] = static_cast(header_len & 0xFFu); + hdr[9] = static_cast((header_len >> 8) & 0xFFu); + hdr[10] = static_cast(rec_len & 0xFFu); + hdr[11] = static_cast((rec_len >> 8) & 0xFFu); + file = hdr; + for (const auto& f : src_fields) { + std::vector fd(32, 0); + std::size_t n = std::min(f.name.size(), 10); + std::memcpy(fd.data(), f.name.data(), n); + fd[11] = static_cast(f.raw_type ? f.raw_type : 'C'); + fd[16] = static_cast(f.length); + fd[17] = f.decimals; + file.insert(file.end(), fd.begin(), fd.end()); + } + stamp_dbf_field_displacements(file.data() + 32, src_fields.size()); + file.push_back(0x0D); + file.push_back(0x00); // Harbour writes a 2-byte 0x0D 0x00 terminator + file.push_back(0x1A); // EOF, no records + + { + std::error_code ec; + fs::remove(dst, ec); + } + { + std::ofstream out(dst, std::ios::binary); + if (!out) return fail(openads::AE_INTERNAL_ERROR, + "AdsCopyTableStructure: open failed"); + out.write(reinterpret_cast(file.data()), + static_cast(file.size())); + if (!out) return fail(openads::AE_INTERNAL_ERROR, + "AdsCopyTableStructure: write failed"); + } + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsGetRecordCRC(ADSHANDLE hTable, UNSIGNED32* pulCRC, + UNSIGNED32 /*ulOption*/) { + arc2_trace("AdsGetRecordCRC"); + if (pulCRC == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *pulCRC = 0; + if (auto* rt = get_remote_table(hTable)) { + if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; + auto r = rt->conn->get_record_crc(rt->id); + if (!r) return fail(r.error()); + *pulCRC = r.value(); + return ok(); + } + Table* t = get_table(hTable); + if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); + if (!t->positioned()) + return fail(openads::AE_NO_CURRENT_RECORD, "no current record"); + *pulCRC = openads::engine::crc32_record(t->record_buffer()); + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsInitRawKey(ADSHANDLE) { + arc2_trace("AdsInitRawKey"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsMgDumpInternalTables(ADSHANDLE h) { + arc2_trace("AdsMgDumpInternalTables"); + return lookup_mg(h) ? openads::AE_SUCCESS + : openads::AE_INVALID_CONNECTION_HANDLE; +} +UNSIGNED32 ENTRYPOINT AdsClearSQLAbortFunc(void) { + arc2_trace("AdsClearSQLAbortFunc"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsClearSQLParams(ADSHANDLE) { + arc2_trace("AdsClearSQLParams"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsStmtClearTablePasswords(ADSHANDLE h) { + arc2_trace("AdsStmtClearTablePasswords"); + if (auto* st = stmt_lookup(h)) st->passwords.clear(); + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsStmtDisableEncryption(ADSHANDLE h) { + arc2_trace("AdsStmtDisableEncryption"); + if (auto* st = stmt_lookup(h)) st->disable_enc = true; + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsStmtSetTableCharType(ADSHANDLE h, UNSIGNED16 us) { + arc2_trace("AdsStmtSetTableCharType"); + if (auto* st = stmt_lookup(h)) st->char_type = us; + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsStmtSetTableCollation(ADSHANDLE h, UNSIGNED8* puc) { + arc2_trace("AdsStmtSetTableCollation"); + if (auto* st = stmt_lookup(h)) st->collation = openads::abi::to_internal(puc, 0); + return ok(); +} +UNSIGNED32 ENTRYPOINT AdsStmtSetTableRights(ADSHANDLE h, UNSIGNED16 us) { + arc2_trace("AdsStmtSetTableRights"); + if (auto* st = stmt_lookup(h)) st->check_rights = us; + return ok(); +} + +// --- field-identifier-aware setters/getters (name OR ordinal-as-pointer) --- +UNSIGNED32 ENTRYPOINT AdsSetField(ADSHANDLE hObj, UNSIGNED8* pId, UNSIGNED8* pucBuf, + UNSIGNED32 ulLen) { + arc2_trace("AdsSetField"); + UNSIGNED8 nm[64]; + return AdsSetString(hObj, as_field(resolve_field_id(hObj, pId, nm, sizeof(nm))), + pucBuf, ulLen); +} +UNSIGNED32 ENTRYPOINT AdsSetFieldW(ADSHANDLE hObj, UNSIGNED8* pId, + UNSIGNED16* pwcBuf, UNSIGNED32 ulLen) { + arc2_trace("AdsSetFieldW"); + UNSIGNED8 nm[64]; + return AdsSetStringW(hObj, as_field(resolve_field_id(hObj, pId, nm, sizeof(nm))), + pwcBuf, ulLen); +} +UNSIGNED32 ENTRYPOINT AdsSetEmpty(ADSHANDLE hObj, UNSIGNED8* pId) { + arc2_trace("AdsSetEmpty"); + UNSIGNED8 nm[64]; + UNSIGNED8 blank = 0; + return AdsSetString(hObj, as_field(resolve_field_id(hObj, pId, nm, sizeof(nm))), + &blank, 0); +} +// RCB 2026-07-03 -- AdsSetNull previously always aliased to AdsSetEmpty. +// For a prepared-statement parameter, AdsSetEmpty routes through +// AdsSetString, which stores the *quoted* literal '' (see +// set_stmt_param callers above) -- that substitutes an empty-string +// literal into the SQL text instead of the NULL keyword, breaking +// parameterized queries on typed (numeric/date) columns and changing +// IS NULL semantics on text columns. Try the statement-handle path +// first with the unquoted literal NULL (matching the raw-literal +// convention AdsSetLogical/AdsSetDouble already use for stmt params). +// For local table handles, route through Table::set_field_null so VFP +// nullable columns set _NullFlags and ADT columns receive their native +// NULL sentinel; CDX/NTX still blank through that same primitive. +UNSIGNED32 ENTRYPOINT AdsSetNull(ADSHANDLE hObj, UNSIGNED8* pId) { + arc2_trace("AdsSetNull"); + if (pId != nullptr && + set_stmt_param(hObj, reinterpret_cast(pId), "NULL")) { + return ok(); + } + if (auto* rt = get_remote_table(hObj)) { + if (pId == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + std::string fname(reinterpret_cast(pId)); + return remote_buffered_set(rt, fname, std::string()); + } + Table* t = get_table(hObj); + if (t != nullptr) { + UNSIGNED8 nm[64]; + std::uint16_t idx = 0; + if (!resolve_field_index(t, + as_field(resolve_field_id(hObj, pId, nm, sizeof(nm))), &idx)) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + auto r = t->set_field_null(idx); + if (!r) return fail(r.error()); + return ok(); + } + return AdsSetEmpty(hObj, pId); +} +UNSIGNED32 ENTRYPOINT AdsSetShort(ADSHANDLE hObj, UNSIGNED8* pId, SIGNED32 sValue) { + arc2_trace("AdsSetShort"); + UNSIGNED8 nm[64]; + return AdsSetDouble(hObj, as_field(resolve_field_id(hObj, pId, nm, sizeof(nm))), + static_cast(sValue)); +} +UNSIGNED32 ENTRYPOINT AdsSetMoney(ADSHANDLE hObj, UNSIGNED8* pId, SIGNED64 qValue) { + arc2_trace("AdsSetMoney"); + UNSIGNED8 nm[64]; + return AdsSetDouble(hObj, as_field(resolve_field_id(hObj, pId, nm, sizeof(nm))), + static_cast(qValue) / 10000.0); // ACE money scale +} +UNSIGNED32 ENTRYPOINT AdsSetTime(ADSHANDLE hObj, UNSIGNED8* pId, UNSIGNED8* pucValue, + UNSIGNED16 usLen) { + arc2_trace("AdsSetTime"); + UNSIGNED8 nm[64]; + return AdsSetString(hObj, as_field(resolve_field_id(hObj, pId, nm, sizeof(nm))), + pucValue, usLen); +} +UNSIGNED32 ENTRYPOINT AdsSetTimeStamp(ADSHANDLE hObj, UNSIGNED8* pId, UNSIGNED8* pucBuf, + UNSIGNED32 ulLen) { + arc2_trace("AdsSetTimeStamp"); + UNSIGNED8 nm[64]; + // Normalise the text to the compact "YYYYMMDDhhmmss" the engine's + // encoder expects. SAP parses " hh:mm:ss" + // (24-hour on input); forwarding the text verbatim let a formatted + // timestamp through to the raw encoder, which mis-sliced it and + // wrote garbage (probed: read back as a negative epoch). + std::string s(pucBuf ? reinterpret_cast(pucBuf) : "", + pucBuf ? static_cast(ulLen) : 0u); + std::string date8, time6 = "000000"; + std::string date_text = s, time_text; + const auto sp = s.find(' '); + if (sp != std::string::npos) { + date_text = s.substr(0, sp); + time_text = s.substr(sp + 1); + } + if (std::string byfmt = parse_date_by_format(date_text); !byfmt.empty()) { + date8 = std::move(byfmt); + } else if (date_text.size() == 10 && date_text[4] == '-' && + date_text[7] == '-') { + date8 = date_text.substr(0, 4) + date_text.substr(5, 2) + + date_text.substr(8, 2); + } else if (s.size() >= 14) { + bool digits = true; + for (std::size_t i = 0; i < 14; ++i) + digits = digits && std::isdigit(static_cast(s[i])); + if (digits) { // already compact "YYYYMMDDhhmmss" + date8 = s.substr(0, 8); + time6 = s.substr(8, 6); + time_text.clear(); + } + } + if (!date8.empty() && time_text.size() >= 8 && + time_text[2] == ':' && time_text[5] == ':') { + time6 = time_text.substr(0, 2) + time_text.substr(3, 2) + + time_text.substr(6, 2); + } + std::string compact = date8.empty() ? s : date8 + time6; + std::vector bytes(compact.begin(), compact.end()); + bytes.push_back(0); + return AdsSetString(hObj, as_field(resolve_field_id(hObj, pId, nm, sizeof(nm))), + bytes.data(), static_cast(compact.size())); +} +// Raw-bytes sibling of AdsSetTimeStamp, following the AdsSetFieldRaw / +// AdsGetFieldRaw convention already used elsewhere in this file: bypass +// string formatting and write pucBuf's bytes directly into the field. +UNSIGNED32 ENTRYPOINT AdsSetTimeStampRaw(ADSHANDLE hObj, UNSIGNED8* pId, UNSIGNED8* pucBuf, + UNSIGNED32 ulLen) { + arc2_trace("AdsSetTimeStampRaw"); + UNSIGNED8 nm[64]; + return AdsSetFieldRaw(hObj, as_field(resolve_field_id(hObj, pId, nm, sizeof(nm))), + pucBuf, ulLen); +} +UNSIGNED32 ENTRYPOINT AdsGetDate(ADSHANDLE hObj, UNSIGNED8* pId, UNSIGNED8* pucBuf, + UNSIGNED16* pusLen) { + arc2_trace("AdsGetDate"); + UNSIGNED8 nm[64]; + UNSIGNED32 cap = pusLen ? *pusLen : 0; + // rddads passes hOrdCurrent (a RemoteIndex handle) for Date fields. + // AdsGetField only checks get_remote_table(); resolve index -> parent. + ADSHANDLE real_hObj = hObj; + bool is_remote = (get_remote_index(hObj) != nullptr); + if (auto* ri = get_remote_index(hObj)) { + is_remote = true; + if (ri->parent) real_hObj = to_ads_handle(handle_for_remote_table(ri->parent)); + } + is_remote = is_remote || (get_remote_table(real_hObj) != nullptr); + // rddads date FieldGet expects the RAW "YYYYMMDD" storage text (it + // decodes it to a Julian day itself); formatting it per the display + // format would feed "01/15/2024" into that decode. Suppress the + // display formatting for remote reads here — the local/AdsGetField + // surface keeps SAP formatting. + if (is_remote) openads::abi::field_read_raw_begin(); + UNSIGNED32 rc = AdsGetField(real_hObj, + as_field(resolve_field_id(real_hObj, pId, nm, sizeof(nm))), + pucBuf, &cap, 0); + if (is_remote) openads::abi::field_read_raw_end(); + if (pusLen) *pusLen = static_cast(cap); + return rc; +} + +UNSIGNED32 ENTRYPOINT AdsSetSQLTimeout(ADSHANDLE hObj, UNSIGNED32 ulTimeout) { + arc2_trace("AdsSetSQLTimeout"); + if (auto* st = stmt_lookup(hObj)) { + st->sql_timeout = ulTimeout; + std::lock_guard lk(stmt_mu()); + sql_timeout_map()[hObj] = ulTimeout; + return ok(); + } + + auto& s = state(); + std::lock_guard lk(s.mu); + bool valid = s.registry.lookup(hObj, HandleKind::Connection) || + s.registry.lookup( + hObj, HandleKind::RemoteConnection); +#if defined(OPENADS_WITH_SQLITE) + valid = valid || s.registry.lookup( + hObj, HandleKind::SqliteConnection); +#endif +#if defined(OPENADS_WITH_MSSQL) + valid = valid || s.registry.lookup( + hObj, HandleKind::MssqlConnection); +#endif +#if defined(OPENADS_WITH_POSTGRESQL) + valid = valid || s.registry.lookup( + hObj, HandleKind::PostgresConnection); +#endif +#if defined(OPENADS_WITH_MARIADB) + valid = valid || s.registry.lookup( + hObj, HandleKind::MariaConnection); +#endif +#if defined(OPENADS_WITH_ODBC) + valid = valid || s.registry.lookup( + hObj, HandleKind::OdbcConnection); +#endif +#if defined(OPENADS_WITH_FIREBIRD) + valid = valid || s.registry.lookup( + hObj, HandleKind::FirebirdConnection); +#endif + if (!valid) { + return fail(openads::AE_INVALID_CONNECTION_HANDLE, + "unknown SQL timeout handle"); + } + + std::lock_guard stmt_lk(stmt_mu()); + sql_timeout_map()[hObj] = ulTimeout; + return ok(); +} + +// --------------------------------------------------------------------------- +// SAP ACE API name aliases -- binaries compiled against ace64.dll use these +// names. OpenADS uses Create/Drop internally; SAP ACE uses Add/Remove. +// --------------------------------------------------------------------------- + +UNSIGNED32 ENTRYPOINT AdsDDAddProcedure(ADSHANDLE hConn, UNSIGNED8* pucName, + UNSIGNED8* pucContainer, UNSIGNED8* pucProcName, + UNSIGNED32 ulInvokeOption, + UNSIGNED8* pucInParams, UNSIGNED8* pucOutParams, + UNSIGNED8* pucComments) { + arc2_trace("AdsDDAddProcedure"); + return AdsDDCreateProcedure(hConn, pucName, pucContainer, pucProcName, + ulInvokeOption, pucInParams, pucOutParams, + pucComments); +} +UNSIGNED32 ENTRYPOINT AdsDDRemoveProcedure(ADSHANDLE hConn, UNSIGNED8* pucName) { + arc2_trace("AdsDDRemoveProcedure"); + return AdsDDDropProcedure(hConn, pucName); +} +UNSIGNED32 ENTRYPOINT AdsDDGetProcedureProperty(ADSHANDLE hConn, UNSIGNED8* pucName, + UNSIGNED16 usProp, void* pBuf, + UNSIGNED16* pusLen) { + arc2_trace("AdsDDGetProcedureProperty"); + return AdsDDGetProcProperty(hConn, pucName, usProp, pBuf, pusLen); +} +UNSIGNED32 ENTRYPOINT AdsDDSetProcedureProperty(ADSHANDLE hConn, UNSIGNED8* pucName, + UNSIGNED16 usProp, void* pBuf, + UNSIGNED16 usLen) { + arc2_trace("AdsDDSetProcedureProperty"); + return AdsDDSetProcProperty(hConn, pucName, usProp, pBuf, usLen); +} +UNSIGNED32 ENTRYPOINT AdsDDRemoveTrigger(ADSHANDLE hConn, UNSIGNED8* pucName) { + arc2_trace("AdsDDRemoveTrigger"); + return AdsDDDropTrigger(hConn, pucName); +} + +// AdsDDFindFirstObject / FindNextObject / FindClose -- stubs +// OpenADS does not implement the find-handle enumeration pattern; callers +// that need object lists should query the system.* virtual tables instead. +UNSIGNED32 ENTRYPOINT AdsDDFindFirstObject(ADSHANDLE /*hObject*/, + UNSIGNED16 /*usFindObjectType*/, + UNSIGNED8* /*pucParentName*/, + UNSIGNED8* /*pucObjectName*/, + UNSIGNED16* pusObjectNameLen, + ADSHANDLE* phFindHandle) { + arc2_trace("AdsDDFindFirstObject"); + if (pusObjectNameLen) *pusObjectNameLen = 0; + if (phFindHandle) *phFindHandle = 0; + return fail(openads::AE_FUNCTION_NOT_AVAILABLE, "AdsDDFindFirstObject"); +} +UNSIGNED32 ENTRYPOINT AdsDDFindNextObject(ADSHANDLE /*hObject*/, + ADSHANDLE /*hFindHandle*/, + UNSIGNED8* /*pucObjectName*/, + UNSIGNED16* pusObjectNameLen) { + arc2_trace("AdsDDFindNextObject"); + if (pusObjectNameLen) *pusObjectNameLen = 0; + return fail(openads::AE_FUNCTION_NOT_AVAILABLE, "AdsDDFindNextObject"); +} +UNSIGNED32 ENTRYPOINT AdsDDFindClose(ADSHANDLE /*hObject*/, ADSHANDLE /*hFindHandle*/) { + arc2_trace("AdsDDFindClose"); + return ok(); +} + +} // extern "C" -- close stub block (opened at line 17925) +} // extern "C" -- close ACE API exports block (opened at line 12394) + +// ── Task 2: AdsFetchWhere result-set registry + exports ─────────────────────── +// +// Process-local registry that maps opaque result handles to the +// FetchWhereBatch returned by the wire call. Handles live in the +// high range (top bit set) to avoid collisions with the sequential +// handles that HandleRegistry issues (which start at 1 and count up). + +namespace { + +// Stores the batch received from fetch_where plus the column list that +// was requested, so AdsFetchWhereField can look up values by name. +struct FetchWhereResult { + openads::network::FetchWhereBatch batch; + std::vector cols; // same order as batch.rows[r] +}; + +std::mutex& fw_mu() { + static std::mutex m; + return m; +} + +std::unordered_map& fw_results() { + static std::unordered_map m; + return m; +} + +// Must be called while holding fw_mu(). +ADSHANDLE fw_next_handle() { + static std::uint64_t n = 0x8000000000000001ULL; + return static_cast(n++); +} + +bool agg_field_is_numeric(openads::drivers::DbfFieldType t) { + using T = openads::drivers::DbfFieldType; + switch (t) { + case T::Numeric: case T::Float: + case T::Integer: case T::Currency: + case T::Double: case T::ShortInt: + case T::AutoInc: case T::AdtMoney: + return true; + default: + return false; + } +} + +// In-process FetchWhere scan -- mirrors network/session.cpp Opcode::FetchWhere. +openads::network::FetchWhereBatch +local_run_fetch_where(Table& tbl, std::uint32_t maxrows, + const std::string& expr, + const std::vector& cols, + std::uint8_t flags) { + openads::network::FetchWhereBatch batch; + const bool want_recno = + (flags & openads::network::FetchWhereFlags::WANT_RECNO) != 0; + std::uint32_t nrows_out = 0; + while (!tbl.eof() && nrows_out < maxrows) { + if (openads::engine::evaluate_index_expr_truthy(tbl, expr)) { + if (want_recno) + batch.recnos.push_back(tbl.recno()); + std::vector row; + row.reserve(cols.size()); + for (const auto& cn : cols) { + std::int32_t fi = tbl.field_index(cn); + std::string val; + if (fi >= 0) { + auto v = tbl.read_field(static_cast(fi)); + if (v) val = v.value().as_string; + } + row.push_back(std::move(val)); + } + batch.rows.push_back(std::move(row)); + ++nrows_out; + } + if (!tbl.skip(1)) break; + } + batch.eof = tbl.eof(); + return batch; +} + +// In-process aggregate scan -- mirrors network/session.cpp Opcode::Aggregate. +// Returns false and sets err on an invalid spec (unknown field, empty field on +// non-COUNT, etc.). +bool local_run_aggregate(Table& tbl, const std::string& for_expr, + const std::vector& specs, + std::vector& out, + std::string& err) { + std::vector accs; + std::vector fidx; + accs.reserve(specs.size()); + fidx.reserve(specs.size()); + for (const auto& s : specs) { + if (s.field.empty()) { + if (s.fn != openads::engine::AggFn::Count) { + err = "Aggregate: empty field only valid for COUNT"; + return false; + } + fidx.push_back(-1); + accs.emplace_back(s.fn, false); + continue; + } + std::int32_t fi = tbl.field_index(s.field); + if (fi < 0) { + err = "Aggregate: unknown field " + s.field; + return false; + } + const auto& fd = tbl.field_descriptor(static_cast(fi)); + accs.emplace_back(s.fn, agg_field_is_numeric(fd.type)); + fidx.push_back(fi); + } + + const std::uint32_t saved = tbl.recno(); + const bool was_eof = tbl.eof(); + tbl.goto_top(); + while (!tbl.eof()) { + if (openads::engine::evaluate_index_expr_truthy(tbl, for_expr)) { + for (std::size_t i = 0; i < accs.size(); ++i) { + if (fidx[i] < 0) { + accs[i].feed(false, 0.0, ""); + } else { + auto v = tbl.read_field(static_cast(fidx[i])); + if (v) { + const auto& dv = v.value(); + accs[i].feed(dv.is_null, dv.as_double, dv.as_string); + } else { + accs[i].feed(true, 0.0, ""); + } + } + } + } + if (!tbl.skip(1)) break; + } + if (!was_eof && saved >= 1 && saved <= tbl.record_count()) + tbl.goto_record(saved); + else + tbl.goto_top(); + + out.clear(); + out.reserve(accs.size()); + for (auto& a : accs) + out.push_back(a.finalize()); + return true; +} + +// Parse a comma-separated column list (e.g. "NM,QTD") into a vector. +// Returns an empty vector when pszCols is null or an empty string, which +// tells the server to return no column data (count / locate mode). +std::vector fw_split_cols(const UNSIGNED8* pszCols) { + std::vector out; + if (pszCols == nullptr) return out; + const char* p = reinterpret_cast(pszCols); + if (*p == '\0') return out; + while (true) { + const char* comma = std::strchr(p, ','); + if (comma == nullptr) { + out.emplace_back(p); + break; + } + out.emplace_back(p, static_cast(comma - p)); + p = comma + 1; + } + return out; +} + +} // namespace + +extern "C" { // reopen for AdsFetchWhere* exports + +// ─ AdsFetchWhere ───────────────────────────────────────────────────────────── +// Filtered scan over a table; remote tables use the wire opcode, local DBF +// tables scan in-process. SQL-backed tables are not supported here. +UNSIGNED32 ENTRYPOINT AdsFetchWhere(ADSHANDLE hTbl, UNSIGNED8* pszExpr, UNSIGNED8* pszCols, + UNSIGNED32 ulMaxRows, UNSIGNED32 ulFlags, + ADSHANDLE* phResult) { + arc2_trace("AdsFetchWhere"); + if (phResult == nullptr) + return fail(openads::AE_INTERNAL_ERROR, "AdsFetchWhere: null phResult"); + *phResult = 0; + std::string expr; + if (pszExpr != nullptr) + expr = openads::abi::to_internal(pszExpr, 0); + auto cols = fw_split_cols(pszCols); + const std::uint8_t wire_flags = + (ulFlags & 0x01u) ? openads::network::FetchWhereFlags::WANT_RECNO : 0u; + + openads::network::FetchWhereBatch batch; + if (auto* rt = get_remote_table(hTbl)) { + if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; + auto r = rt->conn->fetch_where(rt->id, ulMaxRows, expr, cols, wire_flags); + if (!r) return fail(r.error()); + batch = std::move(r).value(); + } else if (Table* tbl = get_table(hTbl)) { + batch = local_run_fetch_where(*tbl, ulMaxRows, expr, cols, wire_flags); + } else { + return fail(openads::AE_FUNCTION_NOT_AVAILABLE, + "AdsFetchWhere: not applicable on this table"); + } + + std::lock_guard lk(fw_mu()); + ADSHANDLE h = fw_next_handle(); + fw_results().emplace(h, FetchWhereResult{std::move(batch), std::move(cols)}); + *phResult = h; + return ok(); +} + +// ─ AdsFetchWhereRows ───────────────────────────────────────────────────────── +UNSIGNED32 ENTRYPOINT AdsFetchWhereRows(ADSHANDLE hRes, UNSIGNED32* pulRows) { + arc2_trace("AdsFetchWhereRows"); + if (pulRows == nullptr) + return fail(openads::AE_INTERNAL_ERROR, "AdsFetchWhereRows: null"); + std::lock_guard lk(fw_mu()); + auto it = fw_results().find(hRes); + if (it == fw_results().end()) + return fail(openads::AE_INTERNAL_ERROR, "AdsFetchWhereRows: invalid handle"); + *pulRows = static_cast(it->second.batch.rows.size()); + return ok(); +} + +// ─ AdsFetchWhereRecno ──────────────────────────────────────────────────────── +// ulRow is 0-based. Recnos are populated only when WANT_RECNO (0x01) was +// set in ulFlags at AdsFetchWhere call time. +UNSIGNED32 ENTRYPOINT AdsFetchWhereRecno(ADSHANDLE hRes, UNSIGNED32 ulRow, + UNSIGNED32* pulRec) { + arc2_trace("AdsFetchWhereRecno"); + if (pulRec == nullptr) + return fail(openads::AE_INTERNAL_ERROR, "AdsFetchWhereRecno: null"); + std::lock_guard lk(fw_mu()); + auto it = fw_results().find(hRes); + if (it == fw_results().end()) + return fail(openads::AE_INTERNAL_ERROR, "AdsFetchWhereRecno: invalid handle"); + const auto& recnos = it->second.batch.recnos; + if (ulRow >= static_cast(recnos.size())) + return fail(openads::AE_INTERNAL_ERROR, "AdsFetchWhereRecno: row out of range"); + *pulRec = recnos[ulRow]; + return ok(); +} + +// ─ AdsFetchWhereField ──────────────────────────────────────────────────────── +// Copy the value of column `pszCol` at result row `ulRow` into `pucBuf`. +// Column lookup is case-insensitive. *pusLen is in/out (capacity in, +// written byte count out), following the same truncation idiom as AdsGetField. +// ulRow is 0-based. +UNSIGNED32 ENTRYPOINT AdsFetchWhereField(ADSHANDLE hRes, UNSIGNED32 ulRow, + UNSIGNED8* pszCol, + UNSIGNED8* pucBuf, UNSIGNED16* pusLen) { + arc2_trace("AdsFetchWhereField"); + if (pucBuf == nullptr || pusLen == nullptr) + return fail(openads::AE_INTERNAL_ERROR, "AdsFetchWhereField: null buf/len"); + std::lock_guard lk(fw_mu()); + auto it = fw_results().find(hRes); + if (it == fw_results().end()) + return fail(openads::AE_INTERNAL_ERROR, "AdsFetchWhereField: invalid handle"); + const auto& res = it->second; + const auto& rows = res.batch.rows; + if (ulRow >= static_cast(rows.size())) + return fail(openads::AE_INTERNAL_ERROR, "AdsFetchWhereField: row out of range"); + // Case-insensitive column name lookup in the stored column list. + std::size_t col_idx = std::numeric_limits::max(); + if (pszCol != nullptr) { + std::string want = openads::abi::to_internal(pszCol, 0); + for (auto& c : want) + c = static_cast(std::toupper(static_cast(c))); + for (std::size_t i = 0; i < res.cols.size(); ++i) { + std::string n = res.cols[i]; + for (auto& c : n) + c = static_cast(std::toupper(static_cast(c))); + if (n == want) { col_idx = i; break; } + } + } + if (col_idx == std::numeric_limits::max()) + return fail(openads::AE_COLUMN_NOT_FOUND, "AdsFetchWhereField: column not found"); + const auto& row = rows[ulRow]; + if (col_idx >= row.size()) + return fail(openads::AE_INTERNAL_ERROR, "AdsFetchWhereField: col idx out of range"); + openads::abi::copy_to_caller(pucBuf, pusLen, row[col_idx]); + return ok(); +} + +// ─ AdsFetchWhereEof ────────────────────────────────────────────────────────── +// *pbEof is set to 1 when the server exhausted the table during the scan +// (no more rows remain past the last matched record), 0 when ulMaxRows was +// hit before EOF. +UNSIGNED32 ENTRYPOINT AdsFetchWhereEof(ADSHANDLE hRes, UNSIGNED16* pbEof) { + arc2_trace("AdsFetchWhereEof"); + if (pbEof == nullptr) + return fail(openads::AE_INTERNAL_ERROR, "AdsFetchWhereEof: null"); + std::lock_guard lk(fw_mu()); + auto it = fw_results().find(hRes); + if (it == fw_results().end()) + return fail(openads::AE_INTERNAL_ERROR, "AdsFetchWhereEof: invalid handle"); + *pbEof = it->second.batch.eof ? 1u : 0u; + return ok(); +} + +// ─ AdsFetchWhereClose ──────────────────────────────────────────────────────── +// Release the result batch and invalidate the handle. Calling any other +// AdsFetchWhere* accessor with this handle after Close returns an error. +UNSIGNED32 ENTRYPOINT AdsFetchWhereClose(ADSHANDLE hRes) { + arc2_trace("AdsFetchWhereClose"); + std::lock_guard lk(fw_mu()); + fw_results().erase(hRes); + return ok(); +} + +// ─ AdsFetchWhereApplyRow ────────────────────────────────────────────────────── +// Load batch row `ulRow` (its recno + field values) into hTbl's RemoteTable +// row cache, so AdsGetField / AdsGetRecordNum / AdsIsRecordDeleted / AdsAtEOF +// serve that row with NO extra round-trip. This lets a forward filter scan +// (rddads V2) walk the matched rows entirely from a batched AdsFetchWhere +// result -- one round-trip per batch instead of an AdsGotoRecord per match. +// +// The batch must have been fetched with WANT_RECNO. Values are matched to the +// table's fields by column name (case-insensitive); columns the batch did not +// request read back empty. Not applicable on local tables. +UNSIGNED32 ENTRYPOINT AdsFetchWhereApplyRow(ADSHANDLE hRes, UNSIGNED32 ulRow, ADSHANDLE hTbl) { + arc2_trace("AdsFetchWhereApplyRow"); + auto* rt = get_remote_table(hTbl); + if (rt == nullptr) + return fail(openads::AE_FUNCTION_NOT_AVAILABLE, + "AdsFetchWhereApplyRow: not applicable on a local table"); + + // Ensure the field schema is cached so we can map columns → field indices. + // (One wire round-trip the first time only; rddads has it cached already.) + if (!rt->fields_cached) { + auto d = rt->conn->describe_table(rt->id); + if (!d) return fail(d.error()); + rt->fields = std::move(d).value(); + rt->fields_cached = true; + } + + auto upper = [](std::string s) { + for (auto& c : s) + c = static_cast(std::toupper(static_cast(c))); + return s; + }; + + std::lock_guard lk(fw_mu()); + auto it = fw_results().find(hRes); + if (it == fw_results().end()) + return fail(openads::AE_INTERNAL_ERROR, + "AdsFetchWhereApplyRow: invalid handle"); + const auto& res = it->second; + if (ulRow >= res.batch.rows.size()) + return fail(openads::AE_INTERNAL_ERROR, + "AdsFetchWhereApplyRow: row out of range"); + if (ulRow >= res.batch.recnos.size()) + return fail(openads::AE_INTERNAL_ERROR, + "AdsFetchWhereApplyRow: batch has no recnos (need WANT_RECNO)"); + + const auto& vals = res.batch.rows[ulRow]; + std::vector row(rt->fields.size()); + for (std::size_t j = 0; j < res.cols.size() && j < vals.size(); ++j) { + std::string want = upper(res.cols[j]); + for (std::size_t i = 0; i < rt->fields.size(); ++i) { + if (upper(rt->fields[i].name) == want) { row[i] = vals[j]; break; } + } + } + + rt->current_row = std::move(row); + rt->row_valid = true; + rt->current_recno = res.batch.recnos[ulRow]; + rt->current_deleted = false; // FetchWhere skip(1) honours SET DELETED + rt->found_cached = true; + rt->current_found = false; // a scan move clears Found() + rt->invalidate_prefetch(); // the cursor is driven by FetchWhere now + return ok(); +} + +} // extern "C" -- AdsFetchWhere* export block + +// ── Tier-3: AdsAggregate result-set registry + exports ──────────────────────── +// +// Mirrors the FetchWhere registry: opaque high-range handles map to the +// vector of scalars returned by RemoteConnection::aggregate. A distinct +// high range keeps Aggregate handles from colliding with FetchWhere ones. + +namespace { + +struct AggregateResult { + std::vector values; +}; + +std::mutex& agg_mu() { + static std::mutex m; + return m; +} + +std::unordered_map& agg_results() { + static std::unordered_map m; + return m; +} + +// Must be called while holding agg_mu(). +ADSHANDLE agg_next_handle() { + static std::uint64_t n = 0xC000000000000001ULL; + return static_cast(n++); +} + +// Parse "COUNT:;SUM:QTY;MIN:NM" into AggSpec list. Returns false on an +// unknown function token (so the caller can reject the whole request). +bool agg_parse_spec(const UNSIGNED8* pszAggSpec, + std::vector& out) { + if (pszAggSpec == nullptr) return false; + std::string s = reinterpret_cast(pszAggSpec); + std::size_t i = 0; + while (i < s.size()) { + std::size_t semi = s.find(';', i); + std::string item = (semi == std::string::npos) + ? s.substr(i) + : s.substr(i, semi - i); + i = (semi == std::string::npos) ? s.size() : semi + 1; + if (item.empty()) continue; + std::size_t colon = item.find(':'); + std::string fn = (colon == std::string::npos) + ? item : item.substr(0, colon); + std::string field = (colon == std::string::npos) + ? std::string() : item.substr(colon + 1); + for (auto& c : fn) + c = static_cast(std::toupper(static_cast(c))); + openads::network::AggSpec spec; + if (fn == "COUNT") spec.fn = openads::engine::AggFn::Count; + else if (fn == "SUM") spec.fn = openads::engine::AggFn::Sum; + else if (fn == "AVG") spec.fn = openads::engine::AggFn::Avg; + else if (fn == "MIN") spec.fn = openads::engine::AggFn::Min; + else if (fn == "MAX") spec.fn = openads::engine::AggFn::Max; + else return false; + spec.field = std::move(field); + out.push_back(std::move(spec)); + } + return true; +} + +} // namespace + +extern "C" { // reopen for AdsAggregate* exports + +// ─ AdsAggregate ────────────────────────────────────────────────────────────── +UNSIGNED32 ENTRYPOINT AdsAggregate(ADSHANDLE hTbl, UNSIGNED8* pszForCond, + UNSIGNED8* pszAggSpec, ADSHANDLE* phResult) { + arc2_trace("AdsAggregate"); + if (phResult == nullptr) + return fail(openads::AE_INTERNAL_ERROR, "AdsAggregate: null phResult"); + *phResult = 0; + std::string for_expr; + if (pszForCond != nullptr) + for_expr = openads::abi::to_internal(pszForCond, 0); + std::vector specs; + if (!agg_parse_spec(pszAggSpec, specs) || specs.empty()) + return fail(openads::AE_INTERNAL_ERROR, + "AdsAggregate: bad or empty aggregate spec"); + + // Remote (tcp://) table: the server scans + folds (opcode 0xA6). + if (auto* rt = get_remote_table(hTbl)) { + if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; + auto r = rt->conn->aggregate(rt->id, for_expr, specs); + if (!r) return fail(r.error()); + std::lock_guard lk(agg_mu()); + ADSHANDLE h = agg_next_handle(); + agg_results().emplace(h, AggregateResult{std::move(r).value().values}); + *phResult = h; + return ok(); + } + + // SQL-backed (SQLite/Postgres/...) table: push down a real + // `SELECT COUNT/SUM/AVG/MIN/MAX ... WHERE `. The FOR must + // translate to SQL via try_emit_sql_where; otherwise decline so the caller + // falls back (never half-apply a predicate). + if (const auto* ops = openads::abi::backend_table_ops_for(hTbl); + ops != nullptr && ops->aggregate != nullptr) { + std::string where_sql; + const char* where_arg = nullptr; + if (!for_expr.empty()) { + auto w = openads::engine::try_emit_sql_where(for_expr); + if (!w) + return fail(openads::AE_FUNCTION_NOT_AVAILABLE, + "AdsAggregate: FOR not translatable to SQL"); + where_sql = std::move(w).value(); + where_arg = where_sql.c_str(); + } + std::vector vals; + UNSIGNED32 rc = ops->aggregate(hTbl, where_arg, &specs, &vals); + if (rc != 0) return rc; + std::lock_guard lk(agg_mu()); + ADSHANDLE h = agg_next_handle(); + agg_results().emplace(h, AggregateResult{std::move(vals)}); + *phResult = h; + return ok(); + } + + // Local in-process DBF table. + if (Table* tbl = get_table(hTbl)) { + std::vector vals; + std::string err; + if (!local_run_aggregate(*tbl, for_expr, specs, vals, err)) { + if (err.find("unknown field") != std::string::npos) + return fail(openads::AE_COLUMN_NOT_FOUND, err.c_str()); + return fail(openads::AE_INTERNAL_ERROR, err.c_str()); + } + std::lock_guard lk(agg_mu()); + ADSHANDLE h = agg_next_handle(); + agg_results().emplace(h, AggregateResult{std::move(vals)}); + *phResult = h; + return ok(); + } + + return fail(openads::AE_FUNCTION_NOT_AVAILABLE, + "AdsAggregate: not applicable on this table"); +} + +// ─ AdsAggregateCount ───────────────────────────────────────────────────────── +UNSIGNED32 ENTRYPOINT AdsAggregateCount(ADSHANDLE hRes, UNSIGNED32* pulCount) { + arc2_trace("AdsAggregateCount"); + if (pulCount == nullptr) + return fail(openads::AE_INTERNAL_ERROR, "AdsAggregateCount: null"); + std::lock_guard lk(agg_mu()); + auto it = agg_results().find(hRes); + if (it == agg_results().end()) + return fail(openads::AE_INTERNAL_ERROR, + "AdsAggregateCount: invalid handle"); + *pulCount = static_cast(it->second.values.size()); + return ok(); +} + +// ─ AdsAggregateValue ───────────────────────────────────────────────────────── +// ulIndex is 0-based. *pusType receives the result discriminator +// (0=empty, 1=numeric, 2=string); *pusLen is in/out (capacity / written). +UNSIGNED32 ENTRYPOINT AdsAggregateValue(ADSHANDLE hRes, UNSIGNED32 ulIndex, + UNSIGNED16* pusType, UNSIGNED8* pucBuf, + UNSIGNED16* pusLen) { + arc2_trace("AdsAggregateValue"); + if (pusType == nullptr || pucBuf == nullptr || pusLen == nullptr) + return fail(openads::AE_INTERNAL_ERROR, "AdsAggregateValue: null arg"); + std::lock_guard lk(agg_mu()); + auto it = agg_results().find(hRes); + if (it == agg_results().end()) + return fail(openads::AE_INTERNAL_ERROR, + "AdsAggregateValue: invalid handle"); + const auto& vals = it->second.values; + if (ulIndex >= static_cast(vals.size())) + return fail(openads::AE_INTERNAL_ERROR, + "AdsAggregateValue: index out of range"); + const auto& v = vals[ulIndex]; + *pusType = static_cast(v.type); + openads::abi::copy_to_caller(pucBuf, pusLen, v.bytes); + return ok(); +} + +// ─ AdsAggregateClose ───────────────────────────────────────────────────────── +UNSIGNED32 ENTRYPOINT AdsAggregateClose(ADSHANDLE hRes) { + arc2_trace("AdsAggregateClose"); + std::lock_guard lk(agg_mu()); + agg_results().erase(hRes); + return ok(); +} + +} // extern "C" -- AdsAggregate* export block + + + +// --------------------------------------------------------------------------- +// ARC32 (SAP Advantage Data Architect) ACE API gap. +// These power ARC's table browser and metadata panes. Local tables get +// real implementations; remote tables return sane defaults; DD / FTS / +// replication functions that are out of reach return AE_SUCCESS with +// zeroed outputs so ARC never crashes. +// --------------------------------------------------------------------------- + +extern "C" { + +// -- AdsGetDataLength -------------------------------------------------------- +UNSIGNED32 ENTRYPOINT AdsGetDataLength(ADSHANDLE hTable, + UNSIGNED8* pucFldName, + UNSIGNED32 /*ulOptions*/, + UNSIGNED32* pulLength) { + arc2_trace("AdsGetDataLength"); + if (pulLength == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *pulLength = 0; + if (auto* rt = get_remote_table(hTable)) { + auto i = remote_field_index(rt, pucFldName); + if (i == std::numeric_limits::max()) + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + *pulLength = rt->fields[i].length; + return ok(); + } + if (auto* ops = openads::abi::backend_table_ops_for(hTable)) + if (ops->field_length) + return ops->field_length(hTable, pucFldName, pulLength); + Table* t = get_table(hTable); + if (!t) return fail(openads::AE_INTERNAL_ERROR, "no table"); + std::uint16_t idx = 0; + if (!resolve_field_index_h(hTable, t, pucFldName, &idx)) + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + const auto& fd = t->field_descriptor(idx); + using openads::drivers::DbfFieldType; + switch (fd.type) { + case DbfFieldType::Memo: + case DbfFieldType::Binary: { + UNSIGNED32 bufLen = 0; + UNSIGNED8 dummy = 0; + (void)AdsGetField(hTable, pucFldName, &dummy, &bufLen, 0); + *pulLength = bufLen; + break; + } + default: + *pulLength = fd.length; + break; + } + return ok(); +} + +// -- AdsGetBookmarkLength ---------------------------------------------------- +UNSIGNED32 ENTRYPOINT AdsGetBookmarkLength(ADSHANDLE /*hObj*/, + UNSIGNED32* pulLength) { + arc2_trace("AdsGetBookmarkLength"); + if (pulLength == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *pulLength = 4; + return ok(); +} + +// -- AdsCompareBookmarks ----------------------------------------------------- +UNSIGNED32 ENTRYPOINT AdsCompareBookmarks(UNSIGNED8* pucBookmark1, + UNSIGNED8* pucBookmark2, + SIGNED32* plResult) { + arc2_trace("AdsCompareBookmarks"); + if (plResult == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (pucBookmark1 == nullptr || pucBookmark2 == nullptr) + return fail(openads::AE_INTERNAL_ERROR, ""); + UNSIGNED32 r1 = static_cast(pucBookmark1[0]) + | (static_cast(pucBookmark1[1]) << 8) + | (static_cast(pucBookmark1[2]) << 16) + | (static_cast(pucBookmark1[3]) << 24); + UNSIGNED32 r2 = static_cast(pucBookmark2[0]) + | (static_cast(pucBookmark2[1]) << 8) + | (static_cast(pucBookmark2[2]) << 16) + | (static_cast(pucBookmark2[3]) << 24); + *plResult = (r1 < r2) ? -1 : (r1 > r2) ? 1 : 0; + return ok(); +} + +// -- AdsGotoBookmark (pre-60 ADSHANDLE version) ------------------------------ +UNSIGNED32 ENTRYPOINT AdsGotoBookmark(ADSHANDLE hTable, + ADSHANDLE hBookmark) { + arc2_trace("AdsGotoBookmark"); + return AdsGotoRecord(hTable, static_cast(hBookmark)); +} + +// -- AdsGetCollationLang ----------------------------------------------------- +UNSIGNED32 ENTRYPOINT AdsGetCollationLang(UNSIGNED8* pucLang, + UNSIGNED16* pusLen) { + arc2_trace("AdsGetCollationLang"); + if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + openads::abi::copy_to_caller(pucLang, pusLen, std::string("ENGLISH")); + return ok(); +} + +// -- AdsSetCollationLang ----------------------------------------------------- +UNSIGNED32 ENTRYPOINT AdsSetCollationLang(UNSIGNED8* /*pucLang*/) { + arc2_trace("AdsSetCollationLang"); + return ok(); +} + +// -- AdsGetCollation --------------------------------------------------------- +UNSIGNED32 ENTRYPOINT AdsGetCollation(ADSHANDLE /*hConnect*/, + UNSIGNED8* pucCollation, + UNSIGNED16* pusLen) { + arc2_trace("AdsGetCollation"); + if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + // SAP returns an empty string when no collation language was specified + // (ace_adsgettablecollation.htm). A bogus placeholder name ("ADS_COLLATION") + // breaks ARC's collation lookup and crashes its table grid. + openads::abi::copy_to_caller(pucCollation, pusLen, std::string()); + return ok(); +} + +// -- AdsGetTableCollation ---------------------------------------------------- +UNSIGNED32 ENTRYPOINT AdsGetTableCollation(ADSHANDLE /*hTbl*/, + UNSIGNED8* pucCollation, + UNSIGNED16* pusLen) { + arc2_trace("AdsGetTableCollation"); + if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + openads::abi::copy_to_caller(pucCollation, pusLen, std::string()); + arc2_log("COLLATION ret len=%u", pusLen ? (unsigned)*pusLen : 9999); + return ok(); +} + +// -- AdsGetIndexCollation ---------------------------------------------------- +UNSIGNED32 ENTRYPOINT AdsGetIndexCollation(ADSHANDLE /*hIndex*/, + UNSIGNED8* pucCollation, + UNSIGNED16* pusLen) { + arc2_trace("AdsGetIndexCollation"); + if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + openads::abi::copy_to_caller(pucCollation, pusLen, std::string()); + return ok(); +} + +// NOTE: AdsSetCollation is already defined at line 19123 of this file. +// Do NOT redefine it here. + +// -- AdsGetHandleLong / AdsSetHandleLong ------------------------------------- +static std::unordered_map& handle_long_map() { + static std::unordered_map m; + return m; +} + +UNSIGNED32 ENTRYPOINT AdsGetHandleLong(ADSHANDLE hObj, UNSIGNED32* pulVal) { + arc2_trace("AdsGetHandleLong"); + if (pulVal == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + auto& m = handle_long_map(); + auto it = m.find(hObj); + *pulVal = (it != m.end()) ? it->second : 0; + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsSetHandleLong(ADSHANDLE hObj, UNSIGNED32 ulVal) { + arc2_trace("AdsSetHandleLong"); + handle_long_map()[hObj] = ulVal; + return ok(); +} + +// -- AdsGetIntProperty ------------------------------------------------------- +UNSIGNED32 ENTRYPOINT AdsGetIntProperty(ADSHANDLE /*hObj*/, + UNSIGNED32 /*ulPropertyID*/, + UNSIGNED32* pulProperty) { + arc2_trace("AdsGetIntProperty"); + if (pulProperty == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *pulProperty = 0; + return ok(); +} + +// -- AdsGetConnectionPath ---------------------------------------------------- +UNSIGNED32 ENTRYPOINT AdsGetConnectionPath(ADSHANDLE hConnect, + UNSIGNED8* pucConnectionPath, + UNSIGNED16* pusLen) { + arc2_trace("AdsGetConnectionPath"); + if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (Connection* c = lookup_connection(hConnect)) { + openads::abi::copy_to_caller(pucConnectionPath, pusLen, + c->data_dir()); + return ok(); + } + openads::abi::copy_to_caller(pucConnectionPath, pusLen, std::string("")); + return ok(); +} + +// -- AdsGetConnectionProperty ------------------------------------------------ +UNSIGNED32 ENTRYPOINT AdsGetConnectionProperty(ADSHANDLE /*hConnect*/, + UNSIGNED16 /*usPropertyID*/, + void* pvProperty, + UNSIGNED32* pulPropertyLen) { + arc2_trace("AdsGetConnectionProperty"); + if (pulPropertyLen == nullptr) + return fail(openads::AE_INTERNAL_ERROR, ""); + if (pvProperty) memset(pvProperty, 0, *pulPropertyLen); + *pulPropertyLen = 0; + return ok(); +} + +// -- AdsGetTransactionCount -------------------------------------------------- +UNSIGNED32 ENTRYPOINT AdsGetTransactionCount( + ADSHANDLE /*hConnect*/, UNSIGNED32* pulTransactionCount) { + arc2_trace("AdsGetTransactionCount"); + if (pulTransactionCount == nullptr) + return fail(openads::AE_INTERNAL_ERROR, ""); + *pulTransactionCount = 0; + return ok(); +} + +// -- AdsGetSQLStatement ------------------------------------------------------ +UNSIGNED32 ENTRYPOINT AdsGetSQLStatement(ADSHANDLE /*hStmt*/, + UNSIGNED8* pucSQL, + UNSIGNED16* pusLen) { + arc2_trace("AdsGetSQLStatement"); + if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + openads::abi::copy_to_caller(pucSQL, pusLen, std::string("")); + return ok(); +} + +// -- AdsGetSQLStatementHandle ------------------------------------------------ +UNSIGNED32 ENTRYPOINT AdsGetSQLStatementHandle(ADSHANDLE /*hCursor*/, + ADSHANDLE* phStmt) { + arc2_trace("AdsGetSQLStatementHandle"); + if (phStmt == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *phStmt = 0; + return ok(); +} + +// -- AdsNullTerminateStrings ------------------------------------------------- +UNSIGNED32 ENTRYPOINT AdsNullTerminateStrings( + UNSIGNED16 /*bNullTerminate*/) { + arc2_trace("AdsNullTerminateStrings"); + return ok(); +} + +// -- AdsGetShort ------------------------------------------------------------- +UNSIGNED32 ENTRYPOINT AdsGetShort(ADSHANDLE hTable, UNSIGNED8* pucFldName, + int16_t* psValue) { + arc2_trace("AdsGetShort"); + if (psValue == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *psValue = 0; + SIGNED32 lVal = 0; + UNSIGNED32 rc = AdsGetLong(hTable, pucFldName, &lVal); + if (rc != ok()) return rc; + *psValue = static_cast(lVal); + return ok(); +} + +// -- AdsGetTime -------------------------------------------------------------- +UNSIGNED32 ENTRYPOINT AdsGetTime(ADSHANDLE hTable, UNSIGNED8* pucFldName, + UNSIGNED8* pucBuf, UNSIGNED16* pusLen) { + arc2_trace("AdsGetTime"); + UNSIGNED32 ulLen = (pusLen != nullptr) ? *pusLen : 0; + return AdsGetString(hTable, pucFldName, pucBuf, &ulLen, 0); +} + +// -- AdsGetMoney ------------------------------------------------------------- +UNSIGNED32 ENTRYPOINT AdsGetMoney(ADSHANDLE hTable, UNSIGNED8* pucFldName, + SIGNED64* pqValue) { + arc2_trace("AdsGetMoney"); + if (pqValue == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *pqValue = 0; + double dVal = 0; + UNSIGNED32 rc = AdsGetDouble(hTable, pucFldName, &dVal); + if (rc != ok()) return rc; + *pqValue = static_cast(dVal * 10000.0); + return ok(); +} + +// -- AdsIsFieldBinary -------------------------------------------------------- +UNSIGNED32 ENTRYPOINT AdsIsFieldBinary(ADSHANDLE hTable, + UNSIGNED8* pucFldName, + UNSIGNED16* pbBinary) { + arc2_trace("AdsIsFieldBinary"); + if (pbBinary == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *pbBinary = 0; + UNSIGNED16 usType = 0; + UNSIGNED32 rc = AdsGetFieldType(hTable, pucFldName, &usType); + if (rc != ok()) return rc; + if (usType == ADS_BINARY || usType == ADS_IMAGE || usType == ADS_MEMO) + *pbBinary = 1; + return ok(); +} + +// -- AdsGetTableMemoSize ----------------------------------------------------- +UNSIGNED32 ENTRYPOINT AdsGetTableMemoSize(ADSHANDLE /*hTable*/, + UNSIGNED16* pusMemoSize) { + arc2_trace("AdsGetTableMemoSize"); + if (pusMemoSize == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *pusMemoSize = 0; + return ok(); +} + +// -- AdsGetKeyColumn --------------------------------------------------------- +UNSIGNED32 ENTRYPOINT AdsGetKeyColumn(ADSHANDLE /*hCursor*/, + UNSIGNED16* pusKeyColumn, + UNSIGNED32 /*ulReserved*/) { + arc2_trace("AdsGetKeyColumn"); + if (pusKeyColumn == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *pusKeyColumn = 0; + return ok(); +} + +// -- AdsLocate --------------------------------------------------------------- +UNSIGNED32 ENTRYPOINT AdsLocate(ADSHANDLE hTable, UNSIGNED8* pucExpr, + UNSIGNED16 /*bForward*/, + UNSIGNED16* pbFound) { + arc2_trace("AdsLocate"); + if (pbFound == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *pbFound = 0; + UNSIGNED16 result = 0; + UNSIGNED32 rc = AdsEvalLogicalExpr(hTable, pucExpr, &result); + if (rc != ok()) return rc; + *pbFound = result; + return ok(); +} + +// -- AdsLookupKey ------------------------------------------------------------ +UNSIGNED32 ENTRYPOINT AdsLookupKey(ADSHANDLE hIndex, UNSIGNED8* pucKey, + UNSIGNED16 usKeyLen, UNSIGNED16 usDataType, + UNSIGNED16* pbFound) { + arc2_trace("AdsLookupKey"); + if (pbFound == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *pbFound = 0; + UNSIGNED16 usFound = 0; + UNSIGNED32 rc = AdsSeek(hIndex, pucKey, usKeyLen, usDataType, 0, &usFound); + if (rc != ok()) return rc; + *pbFound = usFound; + return ok(); +} + +// -- AdsBuildRawKey / AdsBuildRawKey100 -------------------------------------- +UNSIGNED32 ENTRYPOINT AdsBuildRawKey(ADSHANDLE /*hIndex*/, + UNSIGNED8* pucKey, + UNSIGNED16* pusKeyLen) { + arc2_trace("AdsBuildRawKey"); + if (pucKey == nullptr || pusKeyLen == nullptr) + return fail(openads::AE_INTERNAL_ERROR, ""); + memset(pucKey, 0, *pusKeyLen); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsBuildRawKey100(ADSHANDLE /*hIndex*/, + UNSIGNED8* pucKey, + UNSIGNED16* pusKeyLen, + UNSIGNED32 /*ulOptions*/) { + arc2_trace("AdsBuildRawKey100"); + return AdsBuildRawKey(0, pucKey, pusKeyLen); +} + +// -- AdsIsIndexCompound ------------------------------------------------------ +UNSIGNED32 ENTRYPOINT AdsIsIndexCompound(ADSHANDLE /*hIndex*/, + UNSIGNED16* pbCompound) { + arc2_trace("AdsIsIndexCompound"); + if (pbCompound == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *pbCompound = 0; + return ok(); +} + +// -- AdsIsIndexCandidate ----------------------------------------------------- +UNSIGNED32 ENTRYPOINT AdsIsIndexCandidate(ADSHANDLE /*hIndex*/, + UNSIGNED16* pbCandidate) { + arc2_trace("AdsIsIndexCandidate"); + if (pbCandidate == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *pbCandidate = 0; + return ok(); +} + +// -- AdsIsIndexPrimaryKey ---------------------------------------------------- +UNSIGNED32 ENTRYPOINT AdsIsIndexPrimaryKey(ADSHANDLE /*hIndex*/, + UNSIGNED16* pbPrimaryKey) { + arc2_trace("AdsIsIndexPrimaryKey"); + if (pbPrimaryKey == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *pbPrimaryKey = 0; + return ok(); +} + +// -- AdsIsIndexNullable ------------------------------------------------------ +UNSIGNED32 ENTRYPOINT AdsIsIndexNullable(ADSHANDLE /*hIndex*/, + UNSIGNED16* pbNullable) { + arc2_trace("AdsIsIndexNullable"); + if (pbNullable == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *pbNullable = 0; + return ok(); +} + +// -- AdsIsIndexUserDefined --------------------------------------------------- +UNSIGNED32 ENTRYPOINT AdsIsIndexUserDefined(ADSHANDLE /*hIndex*/, + UNSIGNED16* pbUserDefined) { + arc2_trace("AdsIsIndexUserDefined"); + if (pbUserDefined == nullptr) + return fail(openads::AE_INTERNAL_ERROR, ""); + *pbUserDefined = 0; + return ok(); +} + +// -- AdsGetNumFTSIndexes ----------------------------------------------------- +UNSIGNED32 ENTRYPOINT AdsGetNumFTSIndexes(ADSHANDLE /*hTable*/, + UNSIGNED16* pusNum) { + arc2_trace("AdsGetNumFTSIndexes"); + if (pusNum == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *pusNum = 0; + return ok(); +} + +// -- AdsGetIndexHandleByExpr ------------------------------------------------- +UNSIGNED32 ENTRYPOINT AdsGetIndexHandleByExpr(ADSHANDLE /*hTable*/, + UNSIGNED8* /*pucExpr*/, + UNSIGNED32 /*ulDescending*/, + ADSHANDLE* phIndex) { + arc2_trace("AdsGetIndexHandleByExpr"); + if (phIndex == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *phIndex = 0; + return fail(openads::AE_FUNCTION_NOT_AVAILABLE, ""); +} + +// -- AdsGetTableRights ------------------------------------------------------- +UNSIGNED32 ENTRYPOINT AdsGetTableRights(ADSHANDLE /*hTable*/, + UNSIGNED16* pusRights) { + arc2_trace("AdsGetTableRights"); + if (pusRights == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *pusRights = 0x000F; + return ok(); +} + +// -- AdsVerifyPassword ------------------------------------------------------- +UNSIGNED32 ENTRYPOINT AdsVerifyPassword(ADSHANDLE /*hTable*/, + UNSIGNED16* pusEnabled) { + arc2_trace("AdsVerifyPassword"); + if (pusEnabled == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *pusEnabled = 0; + return ok(); +} + +// -- AdsGetActiveLinkInfo ---------------------------------------------------- +UNSIGNED32 ENTRYPOINT AdsGetActiveLinkInfo(ADSHANDLE /*hDBConn*/, + UNSIGNED16 /*usLinkNum*/, + UNSIGNED8* pucLinkInfo, + UNSIGNED16* pusBufferLen) { + arc2_trace("AdsGetActiveLinkInfo"); + if (pusBufferLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + openads::abi::copy_to_caller(pucLinkInfo, pusBufferLen, std::string("")); + return ok(); +} + +// -- AdsRegisterUDF ---------------------------------------------------------- +static std::unordered_map& udf_map() { + static std::unordered_map m; + return m; +} + +UNSIGNED32 ENTRYPOINT AdsRegisterUDF(ADSHANDLE hObj, UNSIGNED16 /*usType*/, + void* lpfnUDF) { + arc2_trace("AdsRegisterUDF"); + udf_map()[hObj] = lpfnUDF; + return ok(); +} + +// -- AdsStmtConstrainUpdates ------------------------------------------------- +UNSIGNED32 ENTRYPOINT AdsStmtConstrainUpdates(ADSHANDLE /*hStatement*/, + UNSIGNED16 /*usConstrain*/) { + arc2_trace("AdsStmtConstrainUpdates"); + return ok(); +} + +// -- AdsStmtReadAllColumns --------------------------------------------------- +UNSIGNED32 ENTRYPOINT AdsStmtReadAllColumns(ADSHANDLE /*hStatement*/, + UNSIGNED16 /*usReadColumns*/) { + arc2_trace("AdsStmtReadAllColumns"); + return ok(); +} + +// -- AdsStmtEnableEncryption ------------------------------------------------- +UNSIGNED32 ENTRYPOINT AdsStmtEnableEncryption(ADSHANDLE /*hStatement*/, + UNSIGNED8* /*pucPassword*/) { + arc2_trace("AdsStmtEnableEncryption"); + return ok(); +} + +// -- AdsRegisterSQLAbortFunc ------------------------------------------------- +static void* g_sql_abort_func = nullptr; + +UNSIGNED32 ENTRYPOINT AdsRegisterSQLAbortFunc(void* lpfnCallback) { + arc2_trace("AdsRegisterSQLAbortFunc"); + g_sql_abort_func = lpfnCallback; + return ok(); +} + +// -- AdsReapUnusedConnections ------------------------------------------------ +UNSIGNED32 ENTRYPOINT AdsReapUnusedConnections(void) { + arc2_trace("AdsReapUnusedConnections"); + return ok(); +} + +// -- AdsFindServers ---------------------------------------------------------- +UNSIGNED32 ENTRYPOINT AdsFindServers(UNSIGNED32 /*ulOptions*/, + ADSHANDLE* phTable) { + arc2_trace("AdsFindServers"); + if (phTable == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *phTable = 0; + return fail(openads::AE_FUNCTION_NOT_AVAILABLE, ""); +} + +// -- AdsEvalLogicalExprW ----------------------------------------------------- +UNSIGNED32 ENTRYPOINT AdsEvalLogicalExprW(ADSHANDLE /*hTable*/, + void* /*pwcExpr*/, + UNSIGNED16* pbResult) { + arc2_trace("AdsEvalLogicalExprW"); + if (pbResult == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *pbResult = 0; + return ok(); +} + +// -- AdsCachePrepareSQL ------------------------------------------------------ +UNSIGNED32 ENTRYPOINT AdsCachePrepareSQL(ADSHANDLE hConnect, + UNSIGNED8* pucSQL, + ADSHANDLE* phStatement) { + arc2_trace("AdsCachePrepareSQL"); + if (phStatement == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *phStatement = 0; + UNSIGNED32 rc = AdsCreateSQLStatement(hConnect, phStatement); + if (rc != ok()) return rc; + return AdsPrepareSQL(*phStatement, pucSQL); +} + +// -- AdsCachePrepareSQLW ----------------------------------------------------- +UNSIGNED32 ENTRYPOINT AdsCachePrepareSQLW(ADSHANDLE hConnect, + void* pwcSQL, + ADSHANDLE* phStatement) { + arc2_trace("AdsCachePrepareSQLW"); + if (phStatement == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + *phStatement = 0; + UNSIGNED32 rc = AdsCreateSQLStatement(hConnect, phStatement); + if (rc != ok()) return rc; + return AdsPrepareSQLW(*phStatement, + reinterpret_cast(pwcSQL)); +} + +// -- AdsClearCachePool ------------------------------------------------------- +UNSIGNED32 ENTRYPOINT AdsClearCachePool(UNSIGNED8* /*pucConnectString*/) { + arc2_trace("AdsClearCachePool"); + return ok(); +} + +// -- AdsGetFTSIndexInfo ------------------------------------------------------ +UNSIGNED32 ENTRYPOINT AdsGetFTSIndexInfo(ADSHANDLE /*hIndex*/, + UNSIGNED8* pucOutput, + UNSIGNED32* pulBufLen, + UNSIGNED8** /*ppucField*/, + UNSIGNED32* /*pulMinWordLen*/, + UNSIGNED32* /*pulMaxWordLen*/, + UNSIGNED8** /*ppucDelimiters*/, + UNSIGNED8** /*ppucNoiseWords*/, + UNSIGNED8** /*ppucDropChars*/, + UNSIGNED8** /*ppucCondChars*/, + UNSIGNED8** /*ppucReserved1*/, + UNSIGNED8** /*ppucReserved2*/, + UNSIGNED32* /*pulOptions*/) { + arc2_trace("AdsGetFTSIndexInfo"); + if (pulBufLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (pucOutput) memset(pucOutput, 0, *pulBufLen); + *pulBufLen = 0; + return ok(); +} + +// -- AdsGetFTSIndexInfoW ----------------------------------------------------- +UNSIGNED32 ENTRYPOINT AdsGetFTSIndexInfoW(ADSHANDLE /*hIndex*/, + void* pwcOutput, + UNSIGNED32* pulBufLen, + void** /*ppwcField*/, + UNSIGNED32* /*pulMinWordLen*/, + UNSIGNED32* /*pulMaxWordLen*/, + void** /*ppwcDelimiters*/, + void** /*ppwcNoiseWords*/, + void** /*ppwcDropChars*/, + void** /*ppwcCondChars*/, + void** /*ppwcReserved1*/, + void** /*ppwcReserved2*/, + UNSIGNED32* /*pulOptions*/) { + arc2_trace("AdsGetFTSIndexInfoW"); + if (pulBufLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (pwcOutput) memset(pwcOutput, 0, *pulBufLen); + *pulBufLen = 0; + return ok(); +} + +// -- DD stubs ---------------------------------------------------------------- +// Data dictionary functions called by ARC when working with DD. +// We don't support DD in the free-table browsing path, so return +// AE_SUCCESS with zeroed outputs. + +UNSIGNED32 ENTRYPOINT AdsDDAddProcedure100(ADSHANDLE, UNSIGNED8*, UNSIGNED8*, + UNSIGNED8*, UNSIGNED16, UNSIGNED8*, UNSIGNED32, UNSIGNED32, + UNSIGNED32) { + arc2_trace("AdsDDAddProcedure100"); return ok(); } + +UNSIGNED32 ENTRYPOINT AdsDDAddView100(ADSHANDLE, UNSIGNED8*, UNSIGNED8*, + UNSIGNED32) { + arc2_trace("AdsDDAddView100"); return ok(); } + +UNSIGNED32 ENTRYPOINT AdsDDCreate101(ADSHANDLE*, UNSIGNED8*, UNSIGNED8*, + UNSIGNED8*, UNSIGNED16, UNSIGNED16, UNSIGNED16, + UNSIGNED8*) { + arc2_trace("AdsDDCreate101"); return ok(); } + +UNSIGNED32 ENTRYPOINT AdsDDCreateArticle(ADSHANDLE, UNSIGNED8*, UNSIGNED8*, + UNSIGNED8*, UNSIGNED8*) { + arc2_trace("AdsDDCreateArticle"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDCreateArticle100(ADSHANDLE, UNSIGNED8*, UNSIGNED8*, + UNSIGNED8*, UNSIGNED8*, UNSIGNED32) { + arc2_trace("AdsDDCreateArticle100"); return ok(); } + +UNSIGNED32 ENTRYPOINT AdsDDCreateTrigger100(ADSHANDLE, UNSIGNED8*, UNSIGNED8*, + UNSIGNED8*, UNSIGNED8*, UNSIGNED16, UNSIGNED32, UNSIGNED32, + UNSIGNED8*) { + arc2_trace("AdsDDCreateTrigger100"); return ok(); } + +UNSIGNED32 ENTRYPOINT AdsDDCreateUserGroup(ADSHANDLE, UNSIGNED8*, + UNSIGNED8*) { + arc2_trace("AdsDDCreateUserGroup"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDDeleteUserGroup(ADSHANDLE, + UNSIGNED8*) { + arc2_trace("AdsDDDeleteUserGroup"); return ok(); } + +UNSIGNED32 ENTRYPOINT AdsDDDeleteArticle(ADSHANDLE, UNSIGNED8*, + UNSIGNED8*) { + arc2_trace("AdsDDDeleteArticle"); return ok(); } + +UNSIGNED32 ENTRYPOINT AdsDDDeletePublication(ADSHANDLE, UNSIGNED8*) { + arc2_trace("AdsDDDeletePublication"); return ok(); } + +// -- Additional stubs for export gap + +UNSIGNED32 ENTRYPOINT AdsDDDeployDatabase(ADSHANDLE, UNSIGNED8*, + UNSIGNED8*) { + arc2_trace("AdsDDDeployDatabase"); return ok(); } + +UNSIGNED32 ENTRYPOINT AdsDDGetArticleProperty(ADSHANDLE, UNSIGNED8*, + UNSIGNED8*, UNSIGNED16, void*, UNSIGNED32*) { + arc2_trace("AdsDDGetArticleProperty"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDGetIndexFileProperty(ADSHANDLE, UNSIGNED8*, + UNSIGNED8*, UNSIGNED16, void*, UNSIGNED32*) { + arc2_trace("AdsDDGetIndexFileProperty"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDGetLinkProperty(ADSHANDLE, UNSIGNED8*, + UNSIGNED16, void*, UNSIGNED32*) { + arc2_trace("AdsDDGetLinkProperty"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDGetPublicationProperty(ADSHANDLE, UNSIGNED8*, + UNSIGNED16, void*, UNSIGNED32*) { + arc2_trace("AdsDDGetPublicationProperty"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDGetSubscriptionProperty(ADSHANDLE, UNSIGNED8*, + UNSIGNED16, void*, UNSIGNED32*) { + arc2_trace("AdsDDGetSubscriptionProperty"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDGetUserGroupProperty(ADSHANDLE, UNSIGNED8*, + UNSIGNED16, void*, UNSIGNED32*) { + arc2_trace("AdsDDGetUserGroupProperty"); return ok(); } + +UNSIGNED32 ENTRYPOINT AdsDDSetArticleProperty(ADSHANDLE, UNSIGNED8*, + UNSIGNED16, void*, UNSIGNED32, UNSIGNED32) { + arc2_trace("AdsDDSetArticleProperty"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDSetPublicationProperty(ADSHANDLE, UNSIGNED8*, + UNSIGNED16, void*, UNSIGNED32) { + arc2_trace("AdsDDSetPublicationProperty"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDSetSubscriptionProperty(ADSHANDLE, UNSIGNED8*, + UNSIGNED16, void*, UNSIGNED32) { + arc2_trace("AdsDDSetSubscriptionProperty"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDSetUserGroupProperty(ADSHANDLE, UNSIGNED8*, + UNSIGNED16, void*, UNSIGNED32) { + arc2_trace("AdsDDSetUserGroupProperty"); return ok(); } + +UNSIGNED32 ENTRYPOINT AdsDDDeleteSubscription(ADSHANDLE, UNSIGNED8*) { + arc2_trace("AdsDDDeleteSubscription"); return ok(); } + +UNSIGNED32 ENTRYPOINT AdsDDCreateSubscription(ADSHANDLE, UNSIGNED8*, UNSIGNED8*, + UNSIGNED8*, UNSIGNED8*, UNSIGNED8*, UNSIGNED8*, UNSIGNED8*, + UNSIGNED32, UNSIGNED32) { + arc2_trace("AdsDDCreateSubscription"); return ok(); } + + +// -- Additional stubs for export gap + + +UNSIGNED32 ENTRYPOINT AdsActivateAOF(ADSHANDLE, UNSIGNED8*, UNSIGNED32, UNSIGNED32) { + arc2_trace("AdsActivateAOF"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsAddToAOF(ADSHANDLE, UNSIGNED8*, UNSIGNED32, UNSIGNED32) { + arc2_trace("AdsAddToAOF"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsBuildKeyFromRecord(ADSHANDLE, UNSIGNED8*) { + arc2_trace("AdsBuildKeyFromRecord"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsClearCursorAOF(ADSHANDLE, UNSIGNED8*, UNSIGNED32) { + arc2_trace("AdsClearCursorAOF"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsClearLastError(void) { + arc2_trace("AdsClearLastError"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsClearRecordBuffer(ADSHANDLE, UNSIGNED8*, UNSIGNED32) { + arc2_trace("AdsClearRecordBuffer"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsConvertStringToMilliseconds(ADSHANDLE, UNSIGNED8*, UNSIGNED32, UNSIGNED32) { + arc2_trace("AdsConvertStringToMilliseconds"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsConvertUnicodeToCodePage(ADSHANDLE, UNSIGNED8*) { + arc2_trace("AdsConvertUnicodeToCodePage"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsCopyTableStructure81(ADSHANDLE, UNSIGNED8*, UNSIGNED32, UNSIGNED32, UNSIGNED32, UNSIGNED32, UNSIGNED32, UNSIGNED32) { + arc2_trace("AdsCopyTableStructure81"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsCopyTableTop(ADSHANDLE) { + arc2_trace("AdsCopyTableTop"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsGetColumnPermissions(ADSHANDLE, UNSIGNED8*, UNSIGNED32) { + arc2_trace("AdsGetColumnPermissions"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsGetLibraryVersion(ADSHANDLE) { + arc2_trace("AdsGetLibraryVersion"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsSetInternalError(ADSHANDLE, UNSIGNED8*, UNSIGNED32, UNSIGNED32) { + arc2_trace("AdsSetInternalError"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsSetLastError(ADSHANDLE, UNSIGNED8*, UNSIGNED32, UNSIGNED32) { + arc2_trace("AdsSetLastError"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsSetStringFromCodePage(ADSHANDLE, UNSIGNED8*) { + arc2_trace("AdsSetStringFromCodePage"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsSetupRI(ADSHANDLE, UNSIGNED8*, UNSIGNED32, UNSIGNED32, UNSIGNED32, UNSIGNED32, UNSIGNED32, UNSIGNED32, UNSIGNED32, UNSIGNED32, UNSIGNED32, UNSIGNED32) { + arc2_trace("AdsSetupRI"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsSqlPeekStatement(ADSHANDLE, UNSIGNED8*, UNSIGNED32) { + arc2_trace("AdsSqlPeekStatement"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsStepIndexKey(ADSHANDLE, UNSIGNED8*, UNSIGNED32, UNSIGNED32) { + arc2_trace("AdsStepIndexKey"); return ok(); } + +// -- Additional stubs for export gap (v1.8.71) +UNSIGNED32 ENTRYPOINT AdsAccessVfpSystemField() { + arc2_trace("AdsAccessVfpSystemField"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsAreTriggersEnabled() { + arc2_trace("AdsAreTriggersEnabled"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsCloseCachedTrigStatements() { + arc2_trace("AdsCloseCachedTrigStatements"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsCloseFile() { + arc2_trace("AdsCloseFile"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsConvertCodePageToUnicode() { + arc2_trace("AdsConvertCodePageToUnicode"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsConvertDateToJulian() { + arc2_trace("AdsConvertDateToJulian"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsConvertJulianToDate() { + arc2_trace("AdsConvertJulianToDate"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsConvertJulianToString() { + arc2_trace("AdsConvertJulianToString"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsConvertKeyToDouble() { + arc2_trace("AdsConvertKeyToDouble"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsConvertMillisecondsToString() { + arc2_trace("AdsConvertMillisecondsToString"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsConvertStringToJulian() { + arc2_trace("AdsConvertStringToJulian"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsCopyTableTop100() { + arc2_trace("AdsCopyTableTop100"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsCreateCriticalSection() { + arc2_trace("AdsCreateCriticalSection"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsCreateMemTable() { + arc2_trace("AdsCreateMemTable"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsCreateMemTable90() { + arc2_trace("AdsCreateMemTable90"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDBFDateToString() { + arc2_trace("AdsDBFDateToString"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDAutoCreateIndex() { + arc2_trace("AdsDDAutoCreateIndex"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDAutoCreateTable() { + arc2_trace("AdsDDAutoCreateTable"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDClose() { + arc2_trace("AdsDDClose"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDCreateASA() { + arc2_trace("AdsDDCreateASA"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDCreateASA101() { + arc2_trace("AdsDDCreateASA101"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDCreateFunction100() { + arc2_trace("AdsDDCreateFunction100"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDCreatePackage() { + arc2_trace("AdsDDCreatePackage"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDCreatePublication() { + arc2_trace("AdsDDCreatePublication"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDDeleteIndex() { + arc2_trace("AdsDDDeleteIndex"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDDisableTriggers() { + arc2_trace("AdsDDDisableTriggers"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDDropPackage() { + arc2_trace("AdsDDDropPackage"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDEnableTriggers() { + arc2_trace("AdsDDEnableTriggers"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDExecuteProcedure() { + arc2_trace("AdsDDExecuteProcedure"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDFindFirst() { + arc2_trace("AdsDDFindFirst"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDFreeTable() { + arc2_trace("AdsDDFreeTable"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDGetObjectProperty() { + arc2_trace("AdsDDGetObjectProperty"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDGetObjectProperty100() { + arc2_trace("AdsDDGetObjectProperty100"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDGetProcInterfaceVersion() { + arc2_trace("AdsDDGetProcInterfaceVersion"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDGetTableOpenOptions() { + arc2_trace("AdsDDGetTableOpenOptions"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDMoveObjectFile() { + arc2_trace("AdsDDMoveObjectFile"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDOpen() { + arc2_trace("AdsDDOpen"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDRenameObject() { + arc2_trace("AdsDDRenameObject"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDSetActiveDictionary() { + arc2_trace("AdsDDSetActiveDictionary"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDSetObjectAccessRights() { + arc2_trace("AdsDDSetObjectAccessRights"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDSetObjectProperty() { + arc2_trace("AdsDDSetObjectProperty"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDSetObjectProperty100() { + arc2_trace("AdsDDSetObjectProperty100"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDDVerifyUserRights() { + arc2_trace("AdsDDVerifyUserRights"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDeactivateAOF() { + arc2_trace("AdsDeactivateAOF"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsDeleteTable() { + arc2_trace("AdsDeleteTable"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsEcho() { + arc2_trace("AdsEcho"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsEvalExpr() { + arc2_trace("AdsEvalExpr"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsExpressionLongToShort() { + arc2_trace("AdsExpressionLongToShort"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsExpressionLongToShort100() { + arc2_trace("AdsExpressionLongToShort100"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsExpressionLongToShort90() { + arc2_trace("AdsExpressionLongToShort90"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsExpressionShortToLong() { + arc2_trace("AdsExpressionShortToLong"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsExpressionShortToLong90() { + arc2_trace("AdsExpressionShortToLong90"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsExtractPathPart() { + arc2_trace("AdsExtractPathPart"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsFreeExpr() { + arc2_trace("AdsFreeExpr"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsFreeTokenBuffer() { + arc2_trace("AdsFreeTokenBuffer"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsGetBaseFieldName() { + arc2_trace("AdsGetBaseFieldName"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsGetBaseFieldNum() { + arc2_trace("AdsGetBaseFieldNum"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsGetConnectionHandle() { + arc2_trace("AdsGetConnectionHandle"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsGetCursorAOF() { + arc2_trace("AdsGetCursorAOF"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsGetDeleteStatementW() { + arc2_trace("AdsGetDeleteStatementW"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsGetFieldCreationString() { + arc2_trace("AdsGetFieldCreationString"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsGetFilePosition() { + arc2_trace("AdsGetFilePosition"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsGetFTSScore() { + arc2_trace("AdsGetFTSScore"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsGetIndexFlags() { + arc2_trace("AdsGetIndexFlags"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsGetIndexInfo() { + arc2_trace("AdsGetIndexInfo"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsGetIndexPageSize() { + arc2_trace("AdsGetIndexPageSize"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsGetInsertStatementW() { + arc2_trace("AdsGetInsertStatementW"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsGetKeyFilter() { + arc2_trace("AdsGetKeyFilter"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsGetMergeStatementW() { + arc2_trace("AdsGetMergeStatementW"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsGetNullRecord() { + arc2_trace("AdsGetNullRecord"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsGetNumSegments() { + arc2_trace("AdsGetNumSegments"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsGetPreparedFields() { + arc2_trace("AdsGetPreparedFields"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsGetRecordPointer() { + arc2_trace("AdsGetRecordPointer"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsGetRILookupInfo() { + arc2_trace("AdsGetRILookupInfo"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsGetROWIDPrefix() { + arc2_trace("AdsGetROWIDPrefix"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsGetSegmentFieldname() { + arc2_trace("AdsGetSegmentFieldname"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsGetSegmentFieldNumbers() { + arc2_trace("AdsGetSegmentFieldNumbers"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsGetSegmentOffset() { + arc2_trace("AdsGetSegmentOffset"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsGetSelectStatementW() { + arc2_trace("AdsGetSelectStatementW"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsGetSQLStmtParams() { + arc2_trace("AdsGetSQLStmtParams"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsGetTableCreationString() { + arc2_trace("AdsGetTableCreationString"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsGetTableWAN() { + arc2_trace("AdsGetTableWAN"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsGetUpdateStatementW() { + arc2_trace("AdsGetUpdateStatementW"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsImageToClipboard() { + arc2_trace("AdsImageToClipboard"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsImageToHBitmap() { + arc2_trace("AdsImageToHBitmap"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsInitTokenBuffer() { + arc2_trace("AdsInitTokenBuffer"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsIsIndexExprValid() { + arc2_trace("AdsIsIndexExprValid"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsIsIndexFTS() { + arc2_trace("AdsIsIndexFTS"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsIsSegmentDescending() { + arc2_trace("AdsIsSegmentDescending"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsLockRecordImplicitly() { + arc2_trace("AdsLockRecordImplicitly"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsMakeNameUnique() { + arc2_trace("AdsMakeNameUnique"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsMemCompare() { + arc2_trace("AdsMemCompare"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsMemCompare90() { + arc2_trace("AdsMemCompare90"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsMemICompare() { + arc2_trace("AdsMemICompare"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsMemICompare90() { + arc2_trace("AdsMemICompare90"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsMemLwr() { + arc2_trace("AdsMemLwr"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsMemLwr90() { + arc2_trace("AdsMemLwr90"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsMergeAOF() { + arc2_trace("AdsMergeAOF"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsOpenFileRaw() { + arc2_trace("AdsOpenFileRaw"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsParseExpr() { + arc2_trace("AdsParseExpr"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsParseExpr100() { + arc2_trace("AdsParseExpr100"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsPerformRI() { + arc2_trace("AdsPerformRI"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsPrepareSQLNow() { + arc2_trace("AdsPrepareSQLNow"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsPrepareSQLNowW() { + arc2_trace("AdsPrepareSQLNowW"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsPullTrigger() { + arc2_trace("AdsPullTrigger"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsReadFile() { + arc2_trace("AdsReadFile"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsReadRecordNumbers() { + arc2_trace("AdsReadRecordNumbers"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsReadRecords() { + arc2_trace("AdsReadRecords"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsRefreshView() { + arc2_trace("AdsRefreshView"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsReindexFTS() { + arc2_trace("AdsReindexFTS"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsReleaseObject() { + arc2_trace("AdsReleaseObject"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsRemoveSQLComments() { + arc2_trace("AdsRemoveSQLComments"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsSeekInFile() { + arc2_trace("AdsSeekInFile"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsSetBaseTableAccess() { + arc2_trace("AdsSetBaseTableAccess"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsSetBOFFlag() { + arc2_trace("AdsSetBOFFlag"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsSetCollationSequence() { + arc2_trace("AdsSetCollationSequence"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsSetCursorAOF() { + arc2_trace("AdsSetCursorAOF"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsSetFlushFlag() { + arc2_trace("AdsSetFlushFlag"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsSetPacketSize() { + arc2_trace("AdsSetPacketSize"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsSetTableCharType() { + arc2_trace("AdsSetTableCharType"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsSleep() { + arc2_trace("AdsSleep"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsStmtGetCursorHandle() { + arc2_trace("AdsStmtGetCursorHandle"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsStmtGetNumParams() { + arc2_trace("AdsStmtGetNumParams"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsStmtSetOptimization() { + arc2_trace("AdsStmtSetOptimization"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsVerifyRI() { + arc2_trace("AdsVerifyRI"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsWaitForObject() { + arc2_trace("AdsWaitForObject"); return ok(); } +UNSIGNED32 ENTRYPOINT AdsWriteMiniDump() { + arc2_trace("AdsWriteMiniDump"); return ok(); } + +// M12.32 — Distributed mutex service (server-wide named mutexes). +// These functions only work over a remote connection (ADS_REMOTE_SERVER). +// For local connections they return AE_FUNCTION_NOT_AVAILABLE. + +UNSIGNED32 ENTRYPOINT AdsMutexCreate(ADSHANDLE hConnect, + UNSIGNED8* pucName) { + arc2_trace("AdsMutexCreate"); + auto& s = state(); + std::lock_guard lk(s.mu); + auto* rc = get_remote_connection(hConnect); + if (!rc) return fail(openads::AE_FUNCTION_NOT_AVAILABLE, "mutex requires remote connection"); + std::string name = pucName ? reinterpret_cast(pucName) : ""; + if (name.empty()) return fail(openads::AE_INVALID_CONNECTION_HANDLE, "mutex name is empty"); + auto r = rc->mutex_create(name); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsMutexLock(ADSHANDLE hConnect, + UNSIGNED8* pucName, + UNSIGNED32 ulTimeOut) { + arc2_trace("AdsMutexLock"); + auto& s = state(); + std::lock_guard lk(s.mu); + auto* rc = get_remote_connection(hConnect); + if (!rc) return fail(openads::AE_FUNCTION_NOT_AVAILABLE, "mutex requires remote connection"); + std::string name = pucName ? reinterpret_cast(pucName) : ""; + if (name.empty()) return fail(openads::AE_INVALID_CONNECTION_HANDLE, "mutex name is empty"); + auto r = rc->mutex_lock(name, ulTimeOut); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsMutexTryLock(ADSHANDLE hConnect, + UNSIGNED8* pucName, + UNSIGNED16* pbLocked) { + arc2_trace("AdsMutexTryLock"); + auto& s = state(); + std::lock_guard lk(s.mu); + auto* rc = get_remote_connection(hConnect); + if (!rc) return fail(openads::AE_FUNCTION_NOT_AVAILABLE, "mutex requires remote connection"); + std::string name = pucName ? reinterpret_cast(pucName) : ""; + if (name.empty()) return fail(openads::AE_INVALID_CONNECTION_HANDLE, "mutex name is empty"); + auto r = rc->mutex_try_lock(name); + if (!r) return fail(r.error()); + if (pbLocked) *pbLocked = r.value() ? 1 : 0; + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsMutexUnlock(ADSHANDLE hConnect, + UNSIGNED8* pucName) { + arc2_trace("AdsMutexUnlock"); + auto& s = state(); + std::lock_guard lk(s.mu); + auto* rc = get_remote_connection(hConnect); + if (!rc) return fail(openads::AE_FUNCTION_NOT_AVAILABLE, "mutex requires remote connection"); + std::string name = pucName ? reinterpret_cast(pucName) : ""; + if (name.empty()) return fail(openads::AE_INVALID_CONNECTION_HANDLE, "mutex name is empty"); + auto r = rc->mutex_unlock(name); + if (!r) return fail(r.error()); + return ok(); +} + +UNSIGNED32 ENTRYPOINT AdsMutexDestroy(ADSHANDLE hConnect, + UNSIGNED8* pucName) { + arc2_trace("AdsMutexDestroy"); + auto& s = state(); + std::lock_guard lk(s.mu); + auto* rc = get_remote_connection(hConnect); + if (!rc) return fail(openads::AE_FUNCTION_NOT_AVAILABLE, "mutex requires remote connection"); + std::string name = pucName ? reinterpret_cast(pucName) : ""; + if (name.empty()) return fail(openads::AE_INVALID_CONNECTION_HANDLE, "mutex name is empty"); + auto r = rc->mutex_destroy(name); + if (!r) return fail(r.error()); + return ok(); +} + +} // extern "C" -- ARC32 ACE API gap block + +} // extern "C++" From 513e6993f53686ba178079d66237961b6fd6e2db Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Fri, 25 Sep 2026 21:32:28 -0500 Subject: [PATCH 37/97] mtfix12: boundary-pair unit tests + nav expectation updates for fusion serves --- tests/unit/7-network_nav_batch_test.cpp | 808 ++++++++++++++++++++ tests/unit/8-network_use_budget_test.cpp | 121 +++ tests/unit/9-network_boundary_pair_test.cpp | 177 +++++ 3 files changed, 1106 insertions(+) create mode 100644 tests/unit/7-network_nav_batch_test.cpp create mode 100644 tests/unit/8-network_use_budget_test.cpp create mode 100644 tests/unit/9-network_boundary_pair_test.cpp diff --git a/tests/unit/7-network_nav_batch_test.cpp b/tests/unit/7-network_nav_batch_test.cpp new file mode 100644 index 00000000..de64a33a --- /dev/null +++ b/tests/unit/7-network_nav_batch_test.cpp @@ -0,0 +1,808 @@ +// tests/unit/network_nav_batch_test.cpp +// Nav-probe batching (Vouch startup: ~33 wire RTTs per USE on bare +// boundary probing). Three kills, all proven by server opcode counters: +// +// A. Consecutive-duplicate GotoTop/GotoBottom skip their frame when +// nothing hit the wire since (identical state, provably). +// B. A top/bottom that produced no row proves an empty cursor, so +// AtBOF/AtEOF answer locally until anything touches the wire. +// C. AtBOF/AtEOFAck carry the twin flag ([u8 bof][u8 eof] / +// [u8 eof][u8 bof]), so the wire half of rddads' pair caches the +// twin answer and the other half is served locally. +// +// Duplicate suppression is order-aware: a top in order A says nothing +// about order B, so the stamp carries the order context (RemoteIndex +// id, or the ack-confirmed server binding for table-handle nav). + +#include "doctest.h" +#include "mgmt/mg_stats.h" +#include "network/server.h" +#include "openads/ace.h" + +#include +#include +#include + +namespace fs = std::filesystem; + +namespace { + +constexpr std::uint8_t kOpGotoTop = 0x40; +constexpr std::uint8_t kOpAtEOF = 0x48; +constexpr std::uint8_t kOpAtBOF = 0x4C; +constexpr std::uint8_t kOpGetRecordNum = 0x4E; +constexpr std::uint8_t kOpGotoRecord = 0x58; +constexpr std::uint8_t kOpGotoBottom = 0x64; +constexpr std::uint8_t kOpSetOrder = 0x8C; +constexpr std::uint8_t kOpSeek = 0x90; +constexpr std::uint8_t kOpKeyCount = 0xB0; +constexpr std::uint8_t kOpCloseTable = 0x22; +constexpr std::uint8_t kOpGetRecordCount = 0x46; + +fs::path nb_tmp_dir() { + return fs::temp_directory_path() / "openads_navbatch_test"; +} + +void nb_wipe() { + std::error_code ec; + fs::remove_all(nb_tmp_dir(), ec); + fs::create_directories(nb_tmp_dir(), ec); +} + +void nb_seed(const fs::path& dir, const char* tname, int rows) { + UNSIGNED8 srv[512]{}; + std::memcpy(srv, dir.string().c_str(), dir.string().size()); + ADSHANDLE hConn = 0; + REQUIRE(AdsConnect60(srv, ADS_LOCAL_SERVER, nullptr, nullptr, 0, &hConn) + == AE_SUCCESS); + UNSIGNED8 def[] = "NM,C,10,0"; + UNSIGNED8 tn[64]{}; + std::memcpy(tn, tname, std::strlen(tname)); + ADSHANDLE hTable = 0; + REQUIRE(AdsCreateTable(hConn, tn, nullptr, ADS_CDX, ADS_ANSI, + 0, 0, 0, def, &hTable) == AE_SUCCESS); + UNSIGNED8 fld[] = "NM"; + for (int i = 0; i < rows; ++i) { + char v[16]{}; + std::snprintf(v, sizeof(v), "r%d", i); + REQUIRE(AdsAppendRecord(hTable) == AE_SUCCESS); + REQUIRE(AdsSetString(hTable, fld, + reinterpret_cast(v), + static_cast(std::strlen(v))) + == AE_SUCCESS); + REQUIRE(AdsWriteRecord(hTable) == AE_SUCCESS); + } + REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); +} + +std::uint64_t nb_op(std::uint8_t op) { + return openads::mgmt::process_mg_stats() + .op_timing[op].count.load(std::memory_order_relaxed); +} + +ADSHANDLE nb_connect_remote(const fs::path& dir, std::uint16_t port) { + char uri[512]{}; + std::snprintf(uri, sizeof(uri), "tcp://127.0.0.1:%u/%s", + static_cast(port), dir.string().c_str()); + UNSIGNED8 srvbuf[512]{}; + std::memcpy(srvbuf, uri, std::strlen(uri) + 1); + ADSHANDLE hConn = 0; + REQUIRE(AdsConnect60(srvbuf, ADS_REMOTE_SERVER, nullptr, nullptr, 0, + &hConn) == AE_SUCCESS); + return hConn; +} + +ADSHANDLE nb_open(ADSHANDLE hConn, const char* tname) { + UNSIGNED8 tn[64]{}; + std::memcpy(tn, tname, std::strlen(tname)); + ADSHANDLE hTable = 0; + REQUIRE(AdsOpenTable(hConn, tn, nullptr, ADS_CDX, ADS_ANSI, ADS_SHARED, + ADS_COMPATIBLE_LOCKING, ADS_DEFAULT, &hTable) + == AE_SUCCESS); + return hTable; +} + +UNSIGNED16 nb_bof(ADSHANDLE hTable) { + UNSIGNED16 v = 0; + REQUIRE(AdsAtBOF(hTable, &v) == AE_SUCCESS); + return v; +} + +UNSIGNED16 nb_eof(ADSHANDLE hTable) { + UNSIGNED16 v = 0; + REQUIRE(AdsAtEOF(hTable, &v) == AE_SUCCESS); + return v; +} + +UNSIGNED32 nb_recno(ADSHANDLE hTable) { + UNSIGNED32 v = 0; + REQUIRE(AdsGetRecordNum(hTable, 0, &v) == AE_SUCCESS); + return v; +} + +// Ordered fixture: physical IDs 30/10/20, tag BYID on ID. +// Natural top is rec 1, ordered top is rec 2. +void nb_seed_ord(const fs::path& dir) { + UNSIGNED8 srv[512]{}; + std::memcpy(srv, dir.string().c_str(), dir.string().size()); + ADSHANDLE hConn0 = 0; + REQUIRE(AdsConnect60(srv, ADS_LOCAL_SERVER, nullptr, nullptr, 0, &hConn0) + == AE_SUCCESS); + UNSIGNED8 def[] = "ID,N,8,0"; + UNSIGNED8 tname[] = "ord.dbf"; + ADSHANDLE hT = 0; + REQUIRE(AdsCreateTable(hConn0, tname, nullptr, ADS_CDX, 0, 0, 0, 0, def, + &hT) == AE_SUCCESS); + UNSIGNED8 fld[] = "ID"; + const double ids[] = {30.0, 10.0, 20.0}; + for (double id : ids) { + REQUIRE(AdsAppendRecord(hT) == AE_SUCCESS); + REQUIRE(AdsSetDouble(hT, fld, id) == AE_SUCCESS); + REQUIRE(AdsWriteRecord(hT) == AE_SUCCESS); + } + ADSHANDLE hI = 0; + UNSIGNED8 bag[] = "ord.cdx"; + UNSIGNED8 tag[] = "BYID"; + UNSIGNED8 exp[] = "ID"; + REQUIRE(AdsCreateIndex61(hT, bag, tag, exp, nullptr, nullptr, + ADS_COMPOUND, 512, &hI) == AE_SUCCESS); + REQUIRE(AdsCloseTable(hT) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hConn0) == AE_SUCCESS); +} + +} // namespace + +TEST_CASE("Nav batching: duplicate GotoTop/GotoBottom skip their frame") { + nb_wipe(); + auto dir = nb_tmp_dir(); + nb_seed(dir, "nb.dbf", 3); + + openads::network::Server srv; + REQUIRE(srv.start("127.0.0.1", 0).has_value()); + ADSHANDLE hConn = nb_connect_remote(dir, srv.port()); + ADSHANDLE hTable = nb_open(hConn, "nb.dbf"); + + const std::uint64_t top0 = nb_op(kOpGotoTop); + const std::uint64_t bot0 = nb_op(kOpGotoBottom); + + // Warm open already positioned at top: immediate GoTop probing + // (rddads' per-USE pattern) costs nothing. + REQUIRE(AdsGotoTop(hTable) == AE_SUCCESS); + REQUIRE(AdsGotoTop(hTable) == AE_SUCCESS); + CHECK(nb_op(kOpGotoTop) == top0); + + // Bottom twice: one frame, then suppressed. + REQUIRE(AdsGotoBottom(hTable) == AE_SUCCESS); + REQUIRE(AdsGotoBottom(hTable) == AE_SUCCESS); + CHECK(nb_op(kOpGotoBottom) == bot0 + 1); + + // mtfix12 R1: the wire GotoBottom certified the TOP in the same + // server visit, so this top answers from the pair certification — + // stronger than the old re-wire. A wire nav with no certification + // (GotoRecord) invalidates it: the next top goes out again. + REQUIRE(AdsGotoTop(hTable) == AE_SUCCESS); + CHECK(nb_op(kOpGotoTop) == top0); // pair-certified + REQUIRE(AdsGotoRecord(hTable, 2) == AE_SUCCESS); + REQUIRE(AdsGotoTop(hTable) == AE_SUCCESS); + CHECK(nb_op(kOpGotoTop) == top0 + 1); // invalidated: back on wire + + REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); + srv.stop(); +} + +TEST_CASE("Nav batching: empty table answers boundaries with zero frames") { + nb_wipe(); + auto dir = nb_tmp_dir(); + nb_seed(dir, "empty.dbf", 0); + + openads::network::Server srv; + REQUIRE(srv.start("127.0.0.1", 0).has_value()); + ADSHANDLE hConn = nb_connect_remote(dir, srv.port()); + ADSHANDLE hTable = nb_open(hConn, "empty.dbf"); + + const std::uint64_t top0 = nb_op(kOpGotoTop); + const std::uint64_t bof0 = nb_op(kOpAtBOF); + const std::uint64_t eof0 = nb_op(kOpAtEOF); + + // Warm open stamped the empty cursor: top + every BOF/EOF probe + // below is served locally. An empty cursor is both BOF and EOF. + REQUIRE(AdsGotoTop(hTable) == AE_SUCCESS); + for (int i = 0; i < 3; ++i) { + CHECK(nb_bof(hTable) == 1); + CHECK(nb_eof(hTable) == 1); + } + CHECK(nb_op(kOpGotoTop) == top0); + CHECK(nb_op(kOpAtBOF) == bof0); + CHECK(nb_op(kOpAtEOF) == eof0); + + // Break the stamp with a local filter reset: the next top really + // goes out (empty again), then boundaries go quiet again. + REQUIRE(AdsClearFilter(hTable) == AE_SUCCESS); + REQUIRE(AdsGotoTop(hTable) == AE_SUCCESS); + CHECK(nb_op(kOpGotoTop) == top0 + 1); + CHECK(nb_bof(hTable) == 1); + CHECK(nb_eof(hTable) == 1); + CHECK(nb_op(kOpAtBOF) == bof0); + CHECK(nb_op(kOpAtEOF) == eof0); + + REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); + srv.stop(); +} + +TEST_CASE("Nav batching: twin flag halves the BOF/EOF pair") { + nb_wipe(); + auto dir = nb_tmp_dir(); + nb_seed(dir, "two.dbf", 2); + + openads::network::Server srv; + REQUIRE(srv.start("127.0.0.1", 0).has_value()); + ADSHANDLE hConn = nb_connect_remote(dir, srv.port()); + ADSHANDLE hTable = nb_open(hConn, "two.dbf"); + + REQUIRE(AdsGotoTop(hTable) == AE_SUCCESS); + REQUIRE(AdsSkip(hTable, 1) == AE_SUCCESS); + REQUIRE(AdsSkip(hTable, 1) == AE_SUCCESS); // past the end -> EOF + + const std::uint64_t bof0 = nb_op(kOpAtBOF); + const std::uint64_t eof0 = nb_op(kOpAtEOF); + + // Arrived from rows, so not-BOF is proven: both answers local, no + // wire AtBOF at all (the twin would halve the pair if one went out). + CHECK(nb_bof(hTable) == 0); // arrived from rows, not BOF + CHECK(nb_op(kOpAtBOF) == bof0); + CHECK(nb_eof(hTable) == 1); + CHECK(nb_op(kOpAtEOF) == eof0); + + // Force a wire pair: a local filter change drops the proven-false + // flags without moving the server cursor (still past end). The + // wire AtBOF carries the EOF twin, so the follow-up AtEOF is local. + UNSIGNED8 flt[] = "ID > 0"; + REQUIRE(AdsSetFilter(hTable, flt) == AE_SUCCESS); + CHECK(nb_bof(hTable) == 0); + CHECK(nb_op(kOpAtBOF) == bof0 + 1); + CHECK(nb_eof(hTable) == 1); + CHECK(nb_op(kOpAtEOF) == eof0); + + REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); + srv.stop(); +} + +TEST_CASE("Nav batching: order context defeats duplicate suppression") { nb_wipe(); + auto dir = nb_tmp_dir(); + nb_seed_ord(dir); + + openads::network::Server s; + REQUIRE(s.start("127.0.0.1", 0).has_value()); + ADSHANDLE hConn = nb_connect_remote(dir, s.port()); + ADSHANDLE hTable = nb_open(hConn, "ord.dbf"); + + // Resolve the order handle (purely local) then navigate by it: the + // top MUST go out on the wire in BYID order (rec 2), not dedupe + // against the warm natural-order stamp (which would sit on rec 1). + ADSHANDLE hOrd = 0; + REQUIRE(AdsGetIndexHandleByOrder(hTable, 1, &hOrd) == AE_SUCCESS); + REQUIRE(hOrd != 0); + + const std::uint64_t top0 = nb_op(kOpGotoTop); + REQUIRE(AdsGotoTop(hOrd) == AE_SUCCESS); + CHECK(nb_op(kOpGotoTop) == top0 + 1); + UNSIGNED32 rec = 0; + REQUIRE(AdsGetRecordNum(hTable, 0, &rec) == AE_SUCCESS); + CHECK(rec == 2u); + + // Same order again: now the duplicate is real — suppressed. + REQUIRE(AdsGotoTop(hOrd) == AE_SUCCESS); + CHECK(nb_op(kOpGotoTop) == top0 + 1); + + // Table-handle top with the order still bound: same position, + // suppressed as well. + REQUIRE(AdsGotoTop(hTable) == AE_SUCCESS); + CHECK(nb_op(kOpGotoTop) == top0 + 1); + + // Back to natural order: reset frame + fresh top both go out. + REQUIRE(AdsSetIndexOrderByHandle(hTable, 0) == AE_SUCCESS); + REQUIRE(AdsGotoTop(hTable) == AE_SUCCESS); + CHECK(nb_op(kOpGotoTop) == top0 + 2); + REQUIRE(AdsGetRecordNum(hTable, 0, &rec) == AE_SUCCESS); + CHECK(rec == 1u); + + REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); + s.stop(); +} + +TEST_CASE("Nav batching: stamps survive read traffic") { + nb_wipe(); + auto dir = nb_tmp_dir(); + nb_seed(dir, "rdempty.dbf", 0); + + openads::network::Server s; + REQUIRE(s.start("127.0.0.1", 0).has_value()); + ADSHANDLE hConn = nb_connect_remote(dir, s.port()); + ADSHANDLE hTable = nb_open(hConn, "rdempty.dbf"); + + // rddads interleaves field/count reads between probes. Reads must + // not expire the empty-cursor stamp: top, a wire count read, then + // boundaries — still zero boundary frames. + REQUIRE(AdsGotoTop(hTable) == AE_SUCCESS); + UNSIGNED32 nrec = 0; + REQUIRE(AdsGetRecordCount(hTable, 0, &nrec) == AE_SUCCESS); + CHECK(nrec == 0u); + + const std::uint64_t bof0 = nb_op(kOpAtBOF); + const std::uint64_t eof0 = nb_op(kOpAtEOF); + CHECK(nb_bof(hTable) == 1); + CHECK(nb_eof(hTable) == 1); + CHECK(nb_bof(hTable) == 1); + CHECK(nb_eof(hTable) == 1); + CHECK(nb_op(kOpAtBOF) == bof0); + CHECK(nb_op(kOpAtEOF) == eof0); + + REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); + s.stop(); +} + +TEST_CASE("Nav batching: skip-established limits answer locally") { + nb_wipe(); + auto dir = nb_tmp_dir(); + nb_seed(dir, "lim.dbf", 2); + + openads::network::Server s; + REQUIRE(s.start("127.0.0.1", 0).has_value()); + ADSHANDLE hConn = nb_connect_remote(dir, s.port()); + ADSHANDLE hTable = nb_open(hConn, "lim.dbf"); + + REQUIRE(AdsGotoTop(hTable) == AE_SUCCESS); + REQUIRE(AdsSkip(hTable, 1) == AE_SUCCESS); + // Past the end: EOF is flagged, and arriving from rows proves + // not-BOF — both answer with zero further frames. + REQUIRE(AdsSkip(hTable, 1) == AE_SUCCESS); + const std::uint64_t bof0 = nb_op(kOpAtBOF); + const std::uint64_t eof0 = nb_op(kOpAtEOF); + CHECK(nb_eof(hTable) == 1); + CHECK(nb_bof(hTable) == 0); + CHECK(nb_eof(hTable) == 1); + CHECK(nb_bof(hTable) == 0); + CHECK(nb_op(kOpAtBOF) == bof0); + CHECK(nb_op(kOpAtEOF) == eof0); + + // Back to the top limit: BOF flags locally, and the Skip ack's + // bound piggyback certifies EOF too — zero frames either side. + REQUIRE(AdsSkip(hTable, -10) == AE_SUCCESS); + const std::uint64_t bof1 = nb_op(kOpAtBOF); + const std::uint64_t eof1 = nb_op(kOpAtEOF); + CHECK(nb_bof(hTable) == 1); + CHECK(nb_eof(hTable) == 0); + CHECK(nb_bof(hTable) == 1); + CHECK(nb_eof(hTable) == 0); + CHECK(nb_op(kOpAtBOF) == bof1); + CHECK(nb_op(kOpAtEOF) == eof1); + + REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); + s.stop(); +} + +TEST_CASE("Reposition truth: top/bottom/skip certify bounds and recno") { nb_wipe(); + auto dir = nb_tmp_dir(); + nb_seed(dir, "tbs.dbf", 3); + + openads::network::Server s; + REQUIRE(s.start("127.0.0.1", 0).has_value()); + ADSHANDLE hConn = nb_connect_remote(dir, s.port()); + ADSHANDLE hTable = nb_open(hConn, "tbs.dbf"); + + const std::uint64_t bof0 = nb_op(kOpAtBOF); + const std::uint64_t eof0 = nb_op(kOpAtEOF); + const std::uint64_t rn0 = nb_op(kOpGetRecordNum); + + // Top lands on row 1: positioned (not BOF — BOF means *before* + // first), not EOF, recno certified — the paint burst is local. + REQUIRE(AdsGotoTop(hTable) == AE_SUCCESS); + CHECK(nb_bof(hTable) == 0); + CHECK(nb_eof(hTable) == 0); + CHECK(nb_recno(hTable) == 1u); + CHECK(nb_op(kOpAtBOF) == bof0); + CHECK(nb_op(kOpAtEOF) == eof0); + CHECK(nb_op(kOpGetRecordNum) == rn0); + + // Bottom lands on the last row: same, mirrored. + REQUIRE(AdsGotoBottom(hTable) == AE_SUCCESS); + CHECK(nb_bof(hTable) == 0); + CHECK(nb_eof(hTable) == 0); + CHECK(nb_recno(hTable) == 3u); + CHECK(nb_op(kOpAtBOF) == bof0); + CHECK(nb_op(kOpAtEOF) == eof0); + CHECK(nb_op(kOpGetRecordNum) == rn0); + + // Skip past the end: EOF phantom with recno n+1, still certified. + REQUIRE(AdsSkip(hTable, 1) == AE_SUCCESS); + CHECK(nb_bof(hTable) == 0); + CHECK(nb_eof(hTable) == 1); + CHECK(nb_recno(hTable) == 4u); + CHECK(nb_op(kOpAtBOF) == bof0); + CHECK(nb_op(kOpAtEOF) == eof0); + CHECK(nb_op(kOpGetRecordNum) == rn0); + + REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); + s.stop(); +} + +TEST_CASE("Nav batching: deferred SetOrder fuses into GotoTop") { nb_wipe(); + auto dir = nb_tmp_dir(); + nb_seed_ord(dir); + + openads::network::Server s; + REQUIRE(s.start("127.0.0.1", 0).has_value()); + ADSHANDLE hConn = nb_connect_remote(dir, s.port()); + ADSHANDLE hTable = nb_open(hConn, "ord.dbf"); + + ADSHANDLE hOrd = 0; + REQUIRE(AdsGetIndexHandleByOrder(hTable, 1, &hOrd) == AE_SUCCESS); + REQUIRE(hOrd != 0); + + // SetOrder alone sends nothing; the following GotoTop absorbs it: + // one GotoTop frame, zero SetOrder frames, ordered position. + const std::uint64_t so0 = nb_op(kOpSetOrder); + const std::uint64_t top0 = nb_op(kOpGotoTop); + REQUIRE(AdsSetIndexOrderByHandle(hTable, hOrd) == AE_SUCCESS); + CHECK(nb_op(kOpSetOrder) == so0); + REQUIRE(AdsGotoTop(hOrd) == AE_SUCCESS); + CHECK(nb_op(kOpSetOrder) == so0); + CHECK(nb_op(kOpGotoTop) == top0 + 1); + UNSIGNED32 rec = 0; + REQUIRE(AdsGetRecordNum(hTable, 0, &rec) == AE_SUCCESS); + CHECK(rec == 2u); + + // Same pattern to the bottom: fused the same way. mtfix12 R1 + // notes: the fused GotoTop above certified the bottom in the same + // visit, so a plain ordered GotoBottom here would be pair-served + // and exercise nothing. A pending order blocks the pair serve (the + // certified order no longer provably matches), so defer a natural + // switch first — the bottom on the TABLE handle fuses it into one + // frame exactly like leg one. (ByHandle(hOrd) can't be the second + // switch: with the acked binding still hOrd it reads as a + // same-order no-op and would leave the natural switch pending.) + REQUIRE(AdsSetIndexOrderByHandle(hTable, 0) == AE_SUCCESS); + const std::uint64_t bot0 = nb_op(kOpGotoBottom); + CHECK(nb_op(kOpSetOrder) == so0); + REQUIRE(AdsGotoBottom(hTable) == AE_SUCCESS); + CHECK(nb_op(kOpSetOrder) == so0); + CHECK(nb_op(kOpGotoBottom) == bot0 + 1); + + REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); + s.stop(); +} + +TEST_CASE("Nav batching: fused nav certifies the new order key count") { + nb_wipe(); + auto dir = nb_tmp_dir(); + nb_seed_ord(dir); + + openads::network::Server s; + REQUIRE(s.start("127.0.0.1", 0).has_value()); + ADSHANDLE hConn = nb_connect_remote(dir, s.port()); + ADSHANDLE hTable = nb_open(hConn, "ord.dbf"); + + ADSHANDLE hOrd = 0; + REQUIRE(AdsGetIndexHandleByOrder(hTable, 1, &hOrd) == AE_SUCCESS); + REQUIRE(hOrd != 0); + + // The fused frame installs the order AND certifies its key + // count: the scrollbar setup that always follows costs nothing. + const std::uint64_t kc0 = nb_op(kOpKeyCount); + REQUIRE(AdsSetIndexOrderByHandle(hTable, hOrd) == AE_SUCCESS); + REQUIRE(AdsGotoTop(hOrd) == AE_SUCCESS); + UNSIGNED32 kc = 0; + REQUIRE(AdsGetKeyCount(hOrd, 0, &kc) == AE_SUCCESS); + CHECK(kc == 3u); + CHECK(nb_op(kOpKeyCount) == kc0); + + REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); + s.stop(); +} + +TEST_CASE("Nav batching: close absorbs a deferred switch") { + nb_wipe(); + auto dir = nb_tmp_dir(); + nb_seed_ord(dir); + + openads::network::Server s; + REQUIRE(s.start("127.0.0.1", 0).has_value()); + ADSHANDLE hConn = nb_connect_remote(dir, s.port()); + ADSHANDLE hTable = nb_open(hConn, "ord.dbf"); + + ADSHANDLE hOrd = 0; + REQUIRE(AdsGetIndexHandleByOrder(hTable, 1, &hOrd) == AE_SUCCESS); + REQUIRE(hOrd != 0); + + // Deferred switch that never reaches any wire op dies silently + // with the close (bindings die with the handle). + const std::uint64_t so0 = nb_op(kOpSetOrder); + const std::uint64_t cl0 = nb_op(kOpCloseTable); + REQUIRE(AdsSetIndexOrderByHandle(hTable, hOrd) == AE_SUCCESS); + CHECK(nb_op(kOpSetOrder) == so0); + REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); + CHECK(nb_op(kOpSetOrder) == so0); + CHECK(nb_op(kOpCloseTable) == cl0 + 1); + + REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); + s.stop(); +} + +TEST_CASE("Nav batching: non-nav op flushes a deferred switch plainly") { + nb_wipe(); + auto dir = nb_tmp_dir(); + nb_seed_ord(dir); + + openads::network::Server s; + REQUIRE(s.start("127.0.0.1", 0).has_value()); + ADSHANDLE hConn = nb_connect_remote(dir, s.port()); + ADSHANDLE hTable = nb_open(hConn, "ord.dbf"); + + ADSHANDLE hOrd = 0; + REQUIRE(AdsGetIndexHandleByOrder(hTable, 1, &hOrd) == AE_SUCCESS); + REQUIRE(hOrd != 0); + + // A key count needs the binding: the deferred switch goes out + // plainly first, then the count. + const std::uint64_t so0 = nb_op(kOpSetOrder); + const std::uint64_t kc0 = nb_op(kOpKeyCount); + REQUIRE(AdsSetIndexOrderByHandle(hTable, hOrd) == AE_SUCCESS); + CHECK(nb_op(kOpSetOrder) == so0); + UNSIGNED32 kc = 0; + REQUIRE(AdsGetKeyCount(hOrd, 0, &kc) == AE_SUCCESS); + CHECK(kc == 3u); + CHECK(nb_op(kOpSetOrder) == so0 + 1); + CHECK(nb_op(kOpKeyCount) == kc0 + 1); + // And again, cached this time. + REQUIRE(AdsGetKeyCount(hOrd, 0, &kc) == AE_SUCCESS); + CHECK(kc == 3u); + CHECK(nb_op(kOpKeyCount) == kc0 + 1); + + REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); + s.stop(); +} + +// Reposition-bound piggyback (Vouch paint loop: GotoRecord/Seek, then +// AtBOF/AtEOF/RecNo per row — 266 + 174 repositions/startup, 548 RecNo +// + 352 AtBOF wire frames). The server appends [u8 bof][u8 eof][u32 recno] +// after the row trailer of GotoRecordAck/SeekAck, so the whole +// post-reposition burst is served locally. Trailing section, +// length-gated: old servers send no tail (M12.24 convention, no caps bit). + +TEST_CASE("Reposition truth: GotoRecord certifies bounds and recno") { + nb_wipe(); + auto dir = nb_tmp_dir(); + nb_seed(dir, "gr.dbf", 3); + + openads::network::Server s; + REQUIRE(s.start("127.0.0.1", 0).has_value()); + ADSHANDLE hConn = nb_connect_remote(dir, s.port()); + ADSHANDLE hTable = nb_open(hConn, "gr.dbf"); + + const std::uint64_t gr0 = nb_op(kOpGotoRecord); + const std::uint64_t bof0 = nb_op(kOpAtBOF); + const std::uint64_t eof0 = nb_op(kOpAtEOF); + const std::uint64_t rn0 = nb_op(kOpGetRecordNum); + + // Mid-table restore: one frame, then the full paint burst is local. + REQUIRE(AdsGotoRecord(hTable, 2) == AE_SUCCESS); + CHECK(nb_op(kOpGotoRecord) == gr0 + 1); + for (int i = 0; i < 3; ++i) { + CHECK(nb_bof(hTable) == 0); + CHECK(nb_eof(hTable) == 0); + CHECK(nb_recno(hTable) == 2u); + } + CHECK(nb_op(kOpAtBOF) == bof0); + CHECK(nb_op(kOpAtEOF) == eof0); + CHECK(nb_op(kOpGetRecordNum) == rn0); + + // Past-the-end restore: Clipper-phantom Limbo (BOF+EOF, recno + // LastRec()+1) — certified in the ack, still zero frames. + REQUIRE(AdsGotoRecord(hTable, 4) == AE_SUCCESS); + CHECK(nb_op(kOpGotoRecord) == gr0 + 2); + CHECK(nb_bof(hTable) == 1); + CHECK(nb_eof(hTable) == 1); + CHECK(nb_recno(hTable) == 4u); + CHECK(nb_op(kOpAtBOF) == bof0); + CHECK(nb_op(kOpAtEOF) == eof0); + CHECK(nb_op(kOpGetRecordNum) == rn0); + + REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); + s.stop(); +} + +// Character-tag fixture for seeks: rows r0/r1/r2, tag BYNM on NM. +void nb_seed_chr(const fs::path& dir) { + UNSIGNED8 srv[512]{}; + std::memcpy(srv, dir.string().c_str(), dir.string().size()); + ADSHANDLE hConn0 = 0; + REQUIRE(AdsConnect60(srv, ADS_LOCAL_SERVER, nullptr, nullptr, 0, &hConn0) + == AE_SUCCESS); + UNSIGNED8 def[] = "NM,C,10,0"; + UNSIGNED8 tname[] = "chr.dbf"; + ADSHANDLE hT = 0; + REQUIRE(AdsCreateTable(hConn0, tname, nullptr, ADS_CDX, 0, 0, 0, 0, def, + &hT) == AE_SUCCESS); + UNSIGNED8 fld[] = "NM"; + for (int i = 0; i < 3; ++i) { + char v[16]{}; + std::snprintf(v, sizeof(v), "r%d", i); + REQUIRE(AdsAppendRecord(hT) == AE_SUCCESS); + REQUIRE(AdsSetString(hT, fld, reinterpret_cast(v), + static_cast(std::strlen(v))) + == AE_SUCCESS); + REQUIRE(AdsWriteRecord(hT) == AE_SUCCESS); + } + ADSHANDLE hI = 0; + UNSIGNED8 bag[] = "chr.cdx"; + UNSIGNED8 tag[] = "BYNM"; + UNSIGNED8 exp[] = "NM"; + REQUIRE(AdsCreateIndex61(hT, bag, tag, exp, nullptr, nullptr, + ADS_COMPOUND, 512, &hI) == AE_SUCCESS); + REQUIRE(AdsCloseTable(hT) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hConn0) == AE_SUCCESS); +} + +UNSIGNED16 nb_seek(ADSHANDLE hOrd, const char* key) { + UNSIGNED16 found = 0; + REQUIRE(AdsSeek(hOrd, reinterpret_cast( + const_cast(key)), + static_cast(std::strlen(key)), + ADS_STRINGKEY, 0, &found) == AE_SUCCESS); + return found; +} + +TEST_CASE("Reposition truth: Seek hit certifies bounds and recno") { + nb_wipe(); + auto dir = nb_tmp_dir(); + nb_seed_chr(dir); + + openads::network::Server s; + REQUIRE(s.start("127.0.0.1", 0).has_value()); + ADSHANDLE hConn = nb_connect_remote(dir, s.port()); + ADSHANDLE hTable = nb_open(hConn, "chr.dbf"); + ADSHANDLE hOrd = 0; + REQUIRE(AdsGetIndexHandleByOrder(hTable, 1, &hOrd) == AE_SUCCESS); + REQUIRE(hOrd != 0); + + const std::uint64_t sk0 = nb_op(kOpSeek); + const std::uint64_t bof0 = nb_op(kOpAtBOF); + const std::uint64_t eof0 = nb_op(kOpAtEOF); + const std::uint64_t rn0 = nb_op(kOpGetRecordNum); + + // Mid-key hit: one Seek frame, burst local. + CHECK(nb_seek(hOrd, "r1") == 1); + CHECK(nb_op(kOpSeek) == sk0 + 1); + CHECK(nb_bof(hTable) == 0); + CHECK(nb_eof(hTable) == 0); + CHECK(nb_recno(hTable) == 2u); + CHECK(nb_op(kOpAtBOF) == bof0); + CHECK(nb_op(kOpAtEOF) == eof0); + CHECK(nb_op(kOpGetRecordNum) == rn0); + + // First-key hit: positioned on a row, so BOF is false (ACE truth: + // BOF means positioned *before* first, not *on* first). The point + // stands: the piggyback — not a follow-up frame — certifies it. + CHECK(nb_seek(hOrd, "r0") == 1); + CHECK(nb_op(kOpSeek) == sk0 + 2); + CHECK(nb_bof(hTable) == 0); + CHECK(nb_eof(hTable) == 0); + CHECK(nb_recno(hTable) == 1u); + CHECK(nb_op(kOpAtBOF) == bof0); + CHECK(nb_op(kOpAtEOF) == eof0); + CHECK(nb_op(kOpGetRecordNum) == rn0); + + REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); + s.stop(); +} + +TEST_CASE("Reposition truth: Seek miss certifies EOF locally") { + nb_wipe(); + auto dir = nb_tmp_dir(); + nb_seed_chr(dir); + + openads::network::Server s; + REQUIRE(s.start("127.0.0.1", 0).has_value()); + ADSHANDLE hConn = nb_connect_remote(dir, s.port()); + ADSHANDLE hTable = nb_open(hConn, "chr.dbf"); + ADSHANDLE hOrd = 0; + REQUIRE(AdsGetIndexHandleByOrder(hTable, 1, &hOrd) == AE_SUCCESS); + REQUIRE(hOrd != 0); + + const std::uint64_t sk0 = nb_op(kOpSeek); + const std::uint64_t bof0 = nb_op(kOpAtBOF); + const std::uint64_t eof0 = nb_op(kOpAtEOF); + const std::uint64_t rn0 = nb_op(kOpGetRecordNum); + + // Hard miss past the end: the cursor is in Limbo (BOF+EOF, the + // Clipper-phantom convention) — certified in the ack, all local. + // Value-identical to the old wire poll (the server twin is the + // same ACE engine either way); only the frame is gone. + CHECK(nb_seek(hOrd, "zzz") == 0); + CHECK(nb_op(kOpSeek) == sk0 + 1); + CHECK(nb_eof(hTable) == 1); + CHECK(nb_bof(hTable) == 1); + // Certified recno, whatever the twin reports for a miss — served + // locally and stable across repeats. + CHECK(nb_recno(hTable) == nb_recno(hTable)); + CHECK(nb_op(kOpGetRecordNum) == rn0); + CHECK(nb_op(kOpAtBOF) == bof0); + CHECK(nb_op(kOpAtEOF) == eof0); + + REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); + s.stop(); +} + +UNSIGNED32 nb_reccount(ADSHANDLE hTable) { + UNSIGNED32 v = 0; + REQUIRE(AdsGetRecordCount(hTable, 0, &v) == AE_SUCCESS); + return v; +} + +TEST_CASE("Count truth: nav ack certifies the record count") { + nb_wipe(); + auto dir = nb_tmp_dir(); + nb_seed(dir, "cnt.dbf", 3); + + openads::network::Server s; + REQUIRE(s.start("127.0.0.1", 0).has_value()); + ADSHANDLE hConn = nb_connect_remote(dir, s.port()); + ADSHANDLE hTable = nb_open(hConn, "cnt.dbf"); + + // The USE flow (open, position, count) pays no count frame: the + // nav ack certified it. (Bottom, not top: the open's implicit + // GoTop dedupes a repeat top with no ack and no tail.) + const std::uint64_t rc0 = nb_op(kOpGetRecordCount); + REQUIRE(AdsGotoBottom(hTable) == AE_SUCCESS); + CHECK(nb_reccount(hTable) == 3u); + CHECK(nb_reccount(hTable) == 3u); + CHECK(nb_op(kOpGetRecordCount) == rc0); + + REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); + s.stop(); +} + +TEST_CASE("Phantom RecNo derives from flags plus cached count") { + nb_wipe(); + auto dir = nb_tmp_dir(); + nb_seed(dir, "ph1.dbf", 3); + nb_seed(dir, "ph2.dbf", 3); + + openads::network::Server s; + REQUIRE(s.start("127.0.0.1", 0).has_value()); + ADSHANDLE hConn = nb_connect_remote(dir, s.port()); + ADSHANDLE hA = nb_open(hConn, "ph1.dbf"); + ADSHANDLE hB = nb_open(hConn, "ph2.dbf"); + + // Park A at its EOF phantom (certified), cache its count, then + // navigate B: the cross-table frame evicts A's seq-gated caches, + // but the phantom recno re-derives (EOF + count) with no frame. + REQUIRE(AdsGotoBottom(hA) == AE_SUCCESS); + REQUIRE(AdsSkip(hA, 1) == AE_SUCCESS); + CHECK(nb_reccount(hA) == 3u); + const std::uint64_t rn0 = nb_op(kOpGetRecordNum); + REQUIRE(AdsGotoTop(hB) == AE_SUCCESS); + CHECK(nb_recno(hA) == 4u); + CHECK(nb_recno(hA) == 4u); + CHECK(nb_op(kOpGetRecordNum) == rn0); + + REQUIRE(AdsCloseTable(hA) == AE_SUCCESS); + REQUIRE(AdsCloseTable(hB) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); + s.stop(); +} diff --git a/tests/unit/8-network_use_budget_test.cpp b/tests/unit/8-network_use_budget_test.cpp new file mode 100644 index 00000000..3dd545ef --- /dev/null +++ b/tests/unit/8-network_use_budget_test.cpp @@ -0,0 +1,121 @@ +// tests/unit/network_use_budget_test.cpp +// Per-USE wire budget (Vouch WAN startup). One realistic rddads-style +// USE cycle — open + setorder + gotop×2 + bof/eof pairs + bottom×2 + +// keycount + close — must cost a bounded number of server frames, with +// zero boundary-probe frames (sticky/twin) and zero duplicate navs: +// OpenTable + CloseTable + GotoTop + GotoBottom + SetOrder + +// KeyCount×2 = 9 frames (session setup excluded: the Hello/Connect +// handshake and the session-pool lanes are established once per +// logical connection and amortised over every USE). +#include "doctest.h" +#include "mgmt/mg_stats.h" +#include "network/server.h" +#include "openads/ace.h" + +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace { + +std::uint64_t ub_op(std::uint8_t op) { + return openads::mgmt::process_mg_stats() + .op_timing[op] + .count.load(std::memory_order_relaxed); +} + +} // namespace + +TEST_CASE("Nav batching: full USE cycle stays within wire budget") { + auto dir = fs::temp_directory_path() / "openads_usebudget"; + std::error_code ec; + fs::remove_all(dir, ec); + fs::create_directories(dir); + + UNSIGNED8 srv[512]{}; + std::memcpy(srv, dir.string().c_str(), dir.string().size()); + ADSHANDLE hC0 = 0; + REQUIRE(AdsConnect60(srv, ADS_LOCAL_SERVER, nullptr, nullptr, 0, &hC0) + == AE_SUCCESS); + UNSIGNED8 def[] = "ID,N,8,0"; + UNSIGNED8 tn0[] = "UB.DBF"; + ADSHANDLE hT0 = 0; + REQUIRE(AdsCreateTable(hC0, tn0, nullptr, ADS_CDX, 0, 0, 0, 0, def, &hT0) + == AE_SUCCESS); + UNSIGNED8 f1[] = "ID"; + for (int i = 1; i <= 50; ++i) { + REQUIRE(AdsAppendRecord(hT0) == AE_SUCCESS); + REQUIRE(AdsSetDouble(hT0, f1, i * 10) == AE_SUCCESS); + REQUIRE(AdsWriteRecord(hT0) == AE_SUCCESS); + } + ADSHANDLE hI0 = 0; + UNSIGNED8 bag[] = "UB.CDX"; + UNSIGNED8 tag[] = "BYID"; + UNSIGNED8 exp[] = "ID"; + REQUIRE(AdsCreateIndex61(hT0, bag, tag, exp, nullptr, nullptr, + ADS_COMPOUND, 512, &hI0) == AE_SUCCESS); + REQUIRE(AdsCloseTable(hT0) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hC0) == AE_SUCCESS); + + openads::network::Server s; + REQUIRE(s.start("127.0.0.1", 0).has_value()); + + char uri[512]{}; + std::snprintf(uri, sizeof(uri), "tcp://127.0.0.1:%u/%s", + static_cast(s.port()), dir.string().c_str()); + UNSIGNED8 sb[512]{}; + std::memcpy(sb, uri, std::strlen(uri) + 1); + ADSHANDLE hC = 0; + REQUIRE(AdsConnect60(sb, ADS_REMOTE_SERVER, nullptr, nullptr, 0, &hC) + == AE_SUCCESS); + + // Snapshot AFTER connect: the session pool (one Connect per lane, + // OPENADS_POOL_SIZE) is established once per logical connection and + // amortised over every USE — the budget below guards the per-USE + // cycle (open + setorder + gotop x2 + bof/eof + bottom x2 + keycount + // + close), not session setup. + std::map before; + for (int o = 0; o < 256; ++o) + before[static_cast(o)] = ub_op((std::uint8_t)o); + UNSIGNED8 tn[] = "UB.DBF"; + ADSHANDLE hT = 0; + REQUIRE(AdsOpenTable(hC, tn, nullptr, ADS_CDX, 0, 0, 0, 0, &hT) == AE_SUCCESS); + ADSHANDLE hOrd = 0; + UNSIGNED8 want[] = "BYID"; + REQUIRE(AdsGetIndexHandle(hT, want, &hOrd) == AE_SUCCESS); + REQUIRE(AdsSetIndexOrderByHandle(hT, hOrd) == AE_SUCCESS); + REQUIRE(AdsGotoTop(hOrd) == AE_SUCCESS); + REQUIRE(AdsGotoTop(hOrd) == AE_SUCCESS); + UNSIGNED16 b = 0, e = 0; + REQUIRE(AdsAtBOF(hT, &b) == AE_SUCCESS); + REQUIRE(AdsAtEOF(hT, &e) == AE_SUCCESS); + REQUIRE(AdsAtBOF(hT, &b) == AE_SUCCESS); + REQUIRE(AdsAtEOF(hT, &e) == AE_SUCCESS); + REQUIRE(AdsGotoBottom(hOrd) == AE_SUCCESS); + REQUIRE(AdsGotoBottom(hOrd) == AE_SUCCESS); + UNSIGNED32 kc = 0; + REQUIRE(AdsGetKeyCount(hOrd, 0, &kc) == AE_SUCCESS); + CHECK(kc == 50u); + REQUIRE(AdsCloseTable(hT) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hC) == AE_SUCCESS); + + auto delta = [&](std::uint8_t op) { return ub_op(op) - before[op]; }; + std::uint64_t total = 0; + for (int o = 0; o < 256; ++o) + total += ub_op((std::uint8_t)o) - before[(std::uint8_t)o]; + + // The whole cycle, connect included, fits in a small fixed budget — + // boundary probes and duplicate navs contribute zero frames. + CHECK(total <= 14u); + CHECK(delta(0x40) == 1u); // GotoTop: second suppressed + CHECK(delta(0x64) == 0u); // GotoBottom: mtfix12 pair-certified by + // the GotoTop's ack, second suppressed + CHECK(delta(0x48) == 0u); // AtEOF: served locally + CHECK(delta(0x4C) == 0u); // AtBOF: served locally + + s.stop(); + fs::remove_all(dir, ec); +} diff --git a/tests/unit/9-network_boundary_pair_test.cpp b/tests/unit/9-network_boundary_pair_test.cpp new file mode 100644 index 00000000..7bb2cd75 --- /dev/null +++ b/tests/unit/9-network_boundary_pair_test.cpp @@ -0,0 +1,177 @@ +// tests/unit/network_boundary_pair_test.cpp +// mtfix12 R1/R2/R3 — boundary-pair certification, self-GotoRecord +// suppression, and GetRecordNum anchoring, end to end over a loopback +// server: per-opcode frame counts prove which calls hit the wire, and +// reads prove the locally-served state is what the wire would have +// returned. +#include "doctest.h" +#include "network/client.h" +#include "network/server.h" +#include "openads/ace.h" + +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace { + +std::atomic g_top{0}, g_bottom{0}, g_goto{0}, g_recnum{0}; + +void count_nav(const void*, std::uint8_t op, std::uint32_t, std::size_t, + std::uint8_t, std::size_t, long long) { + if (op == 0x40) g_top.fetch_add(1); // GotoTop + if (op == 0x64) g_bottom.fetch_add(1); // GotoBottom + if (op == 0x58) g_goto.fetch_add(1); // GotoRecord + if (op == 0x4E) g_recnum.fetch_add(1); // GetRecordNum +} + +void reset_counts() { + g_top = 0; g_bottom = 0; g_goto = 0; g_recnum = 0; +} + +std::uint32_t recno_of(ADSHANDLE h) { + UNSIGNED32 n = 0; + REQUIRE(AdsGetRecordNum(h, 0, &n) == AE_SUCCESS); + return n; +} + +std::string id_field(ADSHANDLE h) { + char buf[64] = {}; + UNSIGNED32 len = sizeof(buf) - 1; + UNSIGNED8 fld[] = "ID"; + REQUIRE(AdsGetField(h, fld, reinterpret_cast(buf), &len, + ADS_NONE) == AE_SUCCESS); + return std::string(buf, len); +} + +} // namespace + +TEST_CASE("boundary pair, self-goto, and recno anchor over loopback") { + auto dir = fs::temp_directory_path() / "openads_bpair"; + std::error_code ec; + fs::remove_all(dir, ec); + fs::create_directories(dir); + + // Seed two 5-row tables locally. + UNSIGNED8 srv[512]{}; + std::memcpy(srv, dir.string().c_str(), dir.string().size()); + ADSHANDLE hC0 = 0; + REQUIRE(AdsConnect60(srv, ADS_LOCAL_SERVER, nullptr, nullptr, 0, &hC0) + == AE_SUCCESS); + UNSIGNED8 def[] = "ID,N,8,0"; + for (const char* nm : {"BP1.DBF", "BP2.DBF"}) { + UNSIGNED8 tn[64]{}; + std::memcpy(tn, nm, std::strlen(nm) + 1); + ADSHANDLE h = 0; + REQUIRE(AdsCreateTable(hC0, tn, nullptr, ADS_CDX, 0, 0, 0, 0, def, + &h) == AE_SUCCESS); + for (int i = 1; i <= 5; ++i) { + REQUIRE(AdsAppendRecord(h) == AE_SUCCESS); + char v[16]; + std::snprintf(v, sizeof(v), "%d", i); + UNSIGNED8 fld[] = "ID"; + REQUIRE(AdsSetField(h, fld, + reinterpret_cast(v), + static_cast(std::strlen(v))) + == AE_SUCCESS); + } + REQUIRE(AdsCloseTable(h) == AE_SUCCESS); + } + REQUIRE(AdsDisconnect(hC0) == AE_SUCCESS); + + openads::network::Server s; + REQUIRE(s.start("127.0.0.1", 0).has_value()); + char uri[512]{}; + std::snprintf(uri, sizeof(uri), "tcp://127.0.0.1:%u/%s", + static_cast(s.port()), dir.string().c_str()); + UNSIGNED8 sb[512]{}; + std::memcpy(sb, uri, std::strlen(uri) + 1); + ADSHANDLE hC = 0; + REQUIRE(AdsConnect60(sb, ADS_REMOTE_SERVER, nullptr, nullptr, 0, &hC) + == AE_SUCCESS); + + UNSIGNED8 t1[] = "BP1.DBF"; + UNSIGNED8 t2[] = "BP2.DBF"; + ADSHANDLE hT = 0, hU = 0; + REQUIRE(AdsOpenTable(hC, t1, nullptr, ADS_CDX, 0, 0, 0, 0, &hT) + == AE_SUCCESS); + REQUIRE(AdsOpenTable(hC, t2, nullptr, ADS_CDX, 0, 0, 0, 0, &hU) + == AE_SUCCESS); + + openads::network::set_frame_trace_hook(&count_nav); + + // Settle: position somewhere known; whatever it costs is not counted. + REQUIRE(AdsGotoTop(hT) == AE_SUCCESS); + REQUIRE(AdsGotoTop(hU) == AE_SUCCESS); + reset_counts(); + + // R2 — self-GotoRecord: first GO wires and certifies the anchor, + // the repeat is served locally. + REQUIRE(AdsGotoRecord(hT, 3) == AE_SUCCESS); + CHECK(g_goto.load() == 1u); + CHECK(recno_of(hT) == 3u); + CHECK(g_recnum.load() == 0u); // R3: from the anchor + REQUIRE(AdsGotoRecord(hT, 3) == AE_SUCCESS); + CHECK(g_goto.load() == 1u); // no new frame + CHECK(recno_of(hT) == 3u); + + // R1 — the GotoTop ack certified the bottom in the same visit. + REQUIRE(AdsGotoTop(hT) == AE_SUCCESS); + CHECK(g_top.load() == 1u); + CHECK(recno_of(hT) == 1u); + REQUIRE(AdsGotoBottom(hT) == AE_SUCCESS); + CHECK(g_bottom.load() == 0u); // served from the pair blob + CHECK(recno_of(hT) == 5u); // and it IS the bottom row + CHECK(id_field(hT) == "5"); + UNSIGNED16 atEof = 1; + REQUIRE(AdsAtEOF(hT, &atEof) == AE_SUCCESS); + CHECK(atEof == 0); + + // The pair serve stamped bottom; a consecutive bottom is the old + // duplicate path, and the top that follows re-wires (no fresh + // certification for top was made by the LOCAL bottom serve... the + // certification from the earlier GotoBottom... none happened, so + // this top must wire and re-certify bottom). + REQUIRE(AdsGotoBottom(hT) == AE_SUCCESS); + CHECK(g_bottom.load() == 0u); // duplicate of the pair serve + REQUIRE(AdsGotoTop(hT) == AE_SUCCESS); + CHECK(g_top.load() == 2u); // wire: re-certifies bottom + REQUIRE(AdsGotoBottom(hT) == AE_SUCCESS); + CHECK(g_bottom.load() == 0u); // pair again + CHECK(recno_of(hT) == 5u); + + // A write on this table kills the certification (the blob's row + // bytes predate it) and the frame expires the conn-wide seq anyway. + REQUIRE(AdsGotoTop(hT) == AE_SUCCESS); + CHECK(g_top.load() == 3u); // wire: certify bottom again + REQUIRE(AdsLockRecord(hT, 0) == AE_SUCCESS); + UNSIGNED8 fld[] = "ID"; + UNSIGNED8 seven[] = "7"; + REQUIRE(AdsSetField(hT, fld, seven, 1) == AE_SUCCESS); + REQUIRE(AdsUnlockRecord(hT, 0) == AE_SUCCESS); + REQUIRE(AdsGotoBottom(hT) == AE_SUCCESS); + CHECK(g_bottom.load() == 1u); // back on the wire + CHECK(recno_of(hT) == 5u); + CHECK(id_field(hT) == "5"); // row 5 untouched by the write + + // The wire GotoBottom also certified the TOP: this GotoTop is + // itself pair-served (symmetric certification). + REQUIRE(AdsGotoTop(hT) == AE_SUCCESS); + CHECK(g_top.load() == 3u); // pair-served from the bottom + CHECK(recno_of(hT) == 1u); + // Conn-wide envelope: nav on ANOTHER table expires the pair. + REQUIRE(AdsGotoRecord(hU, 2) == AE_SUCCESS); // other table's nav + REQUIRE(AdsGotoBottom(hT) == AE_SUCCESS); + CHECK(g_bottom.load() == 2u); // wire: conn seq moved + + openads::network::set_frame_trace_hook(nullptr); + + REQUIRE(AdsCloseTable(hT) == AE_SUCCESS); + REQUIRE(AdsCloseTable(hU) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hC) == AE_SUCCESS); + s.stop(); + fs::remove_all(dir, ec); +} From 3fe8f3afafdb8647176b4c4c06168bc2db6f070f Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Fri, 25 Sep 2026 21:32:47 -0500 Subject: [PATCH 38/97] mtfix12: register network_boundary_pair_test --- tests/10-CMakeLists.txt | 568 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 568 insertions(+) create mode 100644 tests/10-CMakeLists.txt diff --git a/tests/10-CMakeLists.txt b/tests/10-CMakeLists.txt new file mode 100644 index 00000000..4d547798 --- /dev/null +++ b/tests/10-CMakeLists.txt @@ -0,0 +1,568 @@ +# Test fixtures synthesise binary DBF / index / DD blobs with integer +# literals and `buf[int_index]` accesses. Under clang/gcc -Werror those +# trip -Wconversion / -Wsign-conversion even though the narrowing is +# intentional (masking bytes). Relax just those two for test code — the +# library in src/ keeps the full strict warning set. MSVC gets /utf-8 +# to avoid C4566 on non-ASCII identifiers in test source files. +if(MSVC) + add_compile_options(/utf-8) +elseif(NOT MSVC) + add_compile_options(-Wno-conversion -Wno-sign-conversion) + # __COUNTER__ is a widely-supported GCC/Clang extension used by doctest. + # Clang 19+ pedantically classifies it as -Wc2y-extensions; suppress it. + if(CMAKE_CXX_COMPILER_ID STREQUAL "Clang" OR CMAKE_CXX_COMPILER_ID STREQUAL "AppleClang") + include(CheckCXXCompilerFlag) + check_cxx_compiler_flag(-Wno-c2y-extensions HAS_NO_C2Y) + if(HAS_NO_C2Y) + add_compile_options(-Wno-c2y-extensions) + endif() + endif() +endif() + +add_executable(openads_unit_tests + unit/doctest_main.cpp + unit/script_engine_test.cpp + unit/abi_script_proc_test.cpp + unit/util_result_test.cpp + unit/util_span_test.cpp + unit/util_log_test.cpp + unit/serverd_config_ini_test.cpp + ${CMAKE_SOURCE_DIR}/tools/serverd/config_ini.cpp + unit/platform_file_test.cpp + unit/platform_lock_test.cpp + unit/platform_mmap_test.cpp + unit/platform_path_test.cpp + unit/fs_sandbox_test.cpp + unit/server_fs_test.cpp + unit/abi_server_fs_test.cpp + unit/platform_time_test.cpp + unit/platform_thread_test.cpp + unit/dbf_header_test.cpp + unit/dbf_field_test.cpp + unit/dbf_record_test.cpp + unit/dbf_write_test.cpp + unit/ntx_driver_test.cpp + unit/lock_mgr_test.cpp + unit/engine_table_test.cpp + unit/engine_table_write_test.cpp + unit/engine_cursor_test.cpp + unit/session_handle_registry_test.cpp + unit/handle_registry_kind_of_test.cpp + unit/backend_registry_test.cpp + unit/session_connection_test.cpp + unit/abi_smoke_test.cpp + unit/abi_write_smoke_test.cpp + unit/abi_pritpal_lock_test.cpp + unit/ntx_index_test.cpp + unit/cdx_index_test.cpp + unit/cdx_dup_key_split_test.cpp + unit/abi_alternating_append_test.cpp + unit/abi_mt_contention_test.cpp + unit/abi_mt_create_vs_dbfcdx_test.cpp + unit/cdx_ins_seq_test.cpp + unit/abi_ins_order_test.cpp + unit/engine_order_test.cpp + unit/engine_scope_test.cpp + unit/abi_index_smoke_test.cpp + unit/abi_qa_repro_test.cpp + unit/aof_expr_test.cpp + unit/aggregate_test.cpp + unit/abi_aggregate_test.cpp + unit/aof_eval_test.cpp + unit/abi_aof_test.cpp + unit/abi_bitmap_filter_test.cpp + unit/abi_cdx_tag_order_test.cpp + unit/abi_cdx_expr_index_scale_test.cpp + unit/abi_multitag_order_nav_test.cpp + unit/aes_test.cpp + unit/dbt_memo_test.cpp + unit/fpt_memo_test.cpp + unit/engine_memo_test.cpp + unit/abi_m4_smoke_test.cpp + unit/abi_m5_smoke_test.cpp + unit/tx_log_test.cpp + unit/abi_m5x_recovery_test.cpp + unit/abi_savepoint_test.cpp + unit/abi_tx_rollback_append_test.cpp + unit/data_dict_test.cpp + unit/abi_dd_test.cpp + unit/sql_parser_test.cpp + unit/abi_sql_smoke_test.cpp + unit/abi_plus_sqlite_read_test.cpp + unit/abi_plus_sqlite_write_test.cpp + unit/abi_plus_sqlite_filter_test.cpp + unit/abi_sql_uri_smoke_test.cpp + unit/abi_aggregate_sqlite_test.cpp + unit/abi_plus_sqlite_seek_test.cpp + unit/abi_plus_sqlite_passthrough_test.cpp + unit/abi_plus_sqlcipher_read_test.cpp + unit/lsn_map_test.cpp + unit/index_expr_test.cpp + unit/index_expr_sql_test.cpp + unit/zip_arch_test.cpp + unit/zip_files_abi_test.cpp + unit/abi_m92_real_test.cpp + unit/abi_create_table_test.cpp + unit/abi_adt_dat_extension_test.cpp + unit/abi_create_outside_datadir_test.cpp + unit/abi_remote_create_table_test.cpp + unit/abi_remote_logical_write_test.cpp + unit/abi_create_concurrent_test.cpp + unit/abi_remote_create_stress_test.cpp + unit/abi_openindex_create_race_test.cpp + unit/abi_setorder_natural_remote_test.cpp + unit/abi_append_lock_contention_test.cpp + unit/abi_append_queue_timeout_test.cpp + unit/abi_refresh_extract_test.cpp + unit/abi_create_index61_test.cpp + unit/abi_index_expr_validation_test.cpp + unit/abi_zap_pack_test.cpp + unit/abi_pack_reindex_softseek_test.cpp + unit/abi_stale_index_walk_test.cpp + unit/abi_index_intensive_test.cpp + unit/abi_index_intensive2_test.cpp + unit/adi_erase_insert_repro_test.cpp + unit/abi_lock_unlock_full_test.cpp + unit/abi_ntx_pack_reindex_test.cpp + unit/abi_ntx_pack_reindex_multipage_test.cpp + unit/abi_ntx_multipage_dup_test.cpp + unit/abi_ntx_internal_split_test.cpp + unit/abi_record_count_filter_test.cpp + unit/abi_reindex_test.cpp + unit/abi_cdx_conditional_index_test.cpp + unit/abi_conditional_index_nav_test.cpp + unit/abi_conditional_index_refilter_test.cpp + unit/abi_cond_refilter_no_close_test.cpp + unit/ntx_multilevel_test.cpp + unit/cdx_multilevel_test.cpp + unit/cdx_multitag_2nd_test.cpp + unit/cdx_prefix_seek_test.cpp + unit/abi_scope_partial_key_test.cpp + unit/abi_seek_last_partial_test.cpp + unit/abi_aof_index_agreement_test.cpp + unit/abi_aof_empty_result_test.cpp + unit/abi_adi_prefix_seek_multilevel_test.cpp + unit/abi_prefix_seek_deleted_test.cpp + unit/cdx_reindex_char_test.cpp + unit/cdx_prev_empty_leaf_test.cpp + unit/cdx_empty_tree_test.cpp + unit/abi_ntx_numeric_edge_test.cpp + unit/abi_memo_large_test.cpp + unit/abi_ordsetfocus_test.cpp + unit/abi_seek_empty_test.cpp + unit/abi_open_concurrent_header_test.cpp + unit/cdx_peer_append_5000_test.cpp + unit/abi_seek_numeric_alias_test.cpp + unit/abi_open_index_order_test.cpp + unit/abi_ordlistadd_path_test.cpp + unit/abi_gobottom_filtered_test.cpp + unit/abi_navigation_test.cpp + unit/abi_rddtst_repro_test.cpp + unit/engine_navigation_empty_test.cpp + unit/abi_deleted_records_test.cpp + unit/abi_sql_show_deleted_test.cpp + unit/abi_scoped_deleted_keycount_test.cpp + unit/abi_keycount_deleted_scan_test.cpp + unit/abi_index_key_count_test.cpp + unit/abi_key_type_test.cpp + unit/abi_pl852_reopen_seek_test.cpp + unit/abi_oem_chartype_default_collation_test.cpp + unit/abi_copy_table_test.cpp + unit/abi_find_table_test.cpp + unit/abi_binary_memo_test.cpp + unit/abi_ntx_multitag_test.cpp + unit/abi_ntx_numeric_key_test.cpp + unit/abi_cdx_char_keylen_test.cpp + unit/abi_cdx_index_direction_test.cpp + unit/abi_cdx_empty_table_keylen_test.cpp + unit/abi_cdx_recreate_tag_diff_expr_test.cpp + unit/abi_cdx_multitag_create_test.cpp + unit/abi_cdx_estaelec_compound_test.cpp + unit/abi_cdx_str_val_compound_test.cpp + unit/abi_cdx_issue131_test.cpp + unit/abi_ordnumber_test.cpp + unit/abi_server_info_test.cpp + unit/abi_unicode_test.cpp + unit/abi_lock_retry_test.cpp + unit/abi_fts_test.cpp + unit/abi_custom_key_test.cpp + unit/abi_fts_search_test.cpp + unit/abi_sql_contains_test.cpp + unit/abi_sql_operators_test.cpp + unit/index_expr_utf8_test.cpp + unit/abi_misc_impls_test.cpp + unit/abi_mgmt_test.cpp + unit/abi_mgmt_locks_indexes_test.cpp + unit/error_log_test.cpp + unit/backup_test.cpp + unit/mg_collector_test.cpp + unit/mg_wire_test.cpp + unit/abi_mgmt_remote_test.cpp + unit/abi_dd_crud_test.cpp + unit/abi_restructure_test.cpp + unit/abi_versioned_overloads_test.cpp + unit/abi_remote_overloads_test.cpp + unit/abi_remote_prefetch_test.cpp + unit/abi_remote_ordered_prefetch_test.cpp + unit/abi_remote_close_hang_test.cpp + unit/abi_remote_index_reopen_test.cpp + unit/abi_remote_zombie_lock_test.cpp + unit/network_skip_depth_test.cpp + unit/abi_remote_index_nav_test.cpp + unit/abi_remote_date_index_scope_test.cpp + unit/abi_remote_prodcdx_test.cpp + unit/abi_append_autolock_test.cpp + unit/abi_remote_only_access_test.cpp + unit/util_client_config_test.cpp + unit/abi_lock_comprehensive_test.cpp + unit/abi_get_set_record_test.cpp + unit/abi_set_relation_test.cpp + unit/abi_record_crc_aof_test.cpp + unit/abi_settings_refreshaof_test.cpp + unit/abi_dd_persistence_test.cpp + unit/abi_remote_timscope_test.cpp + unit/abi_setorder_zero_test.cpp + unit/dbf_vfp_test.cpp + unit/abi_sql_or_test.cpp + unit/abi_index_direction_test.cpp + unit/abi_sql_insert_test.cpp + unit/abi_sql_named_param_test.cpp + unit/abi_null_semantics_test.cpp + unit/abi_sql_orderby_test.cpp + unit/abi_sql_orderby_types_test.cpp + unit/abi_sql_dml_test.cpp + unit/abi_date_format_test.cpp + unit/abi_sql_projection_test.cpp + unit/abi_sql_temp_names_test.cpp + unit/abi_sql_ddl_test.cpp + unit/abi_sql_agg_test.cpp + unit/dbf_vfp_autoinc_test.cpp + unit/dbf_vfp_0x32_combined_test.cpp + unit/abi_vfp_create_test.cpp + unit/abi_sql_join_test.cpp + unit/abi_sql_in_test.cpp + unit/abi_sql_exists_test.cpp + unit/abi_sql_scalar_test.cpp + unit/abi_sql_union_test.cpp + unit/abi_sql_distinct_limit_test.cpp + unit/abi_sql_case_test.cpp + unit/abi_aep_test.cpp + unit/abi_encryption_test.cpp + unit/pbkdf2_test.cpp + unit/abi_sql_scalar_fn_test.cpp + unit/abi_sql_insert_select_test.cpp + unit/abi_sql_derived_test.cpp + unit/abi_sql_null_window_test.cpp + unit/network_wire_test.cpp + unit/abi_collation_codepage_test.cpp + unit/oem_collation_test.cpp + unit/cdx_build_bulk_collation_test.cpp + unit/abi_ntxpl852_seek_test.cpp + unit/abi_ntxpl852_collation_abi_test.cpp + unit/abi_index_collation_persist_test.cpp + unit/network_socket_test.cpp + unit/network_server_test.cpp + unit/network_pool_test.cpp + unit/network_frame_reader_test.cpp + unit/abi_adt_smoke_test.cpp + unit/abi_adt_create_test.cpp + unit/abi_adt_wide_numeric_test.cpp + unit/abi_adi_create_test.cpp + unit/abi_adi_separate_bag_test.cpp + unit/abi_adi_types_test.cpp + unit/abi_adi_multilevel_build_test.cpp + unit/abi_adt_scope_validation_test.cpp + unit/abi_adi_smoke_test.cpp + unit/abi_adi_clear_multilevel_test.cpp + unit/abi_adi_dat_extension_path_test.cpp + unit/abi_adi_estaelec_compound_test.cpp + unit/abi_adi_frontcoding_size_test.cpp + unit/abi_adi_legacy_bulk_size_test.cpp + unit/abi_adi_keycount_test.cpp + unit/abi_adi_native_estaelec_test.cpp + unit/abi_adi_reindex_bench_test.cpp + unit/abi_keycount_cache_test.cpp + unit/abi_adi_tagdir_order_test.cpp + unit/abi_adi_tagdir_prepend_read_test.cpp + unit/abi_adi_tagdir_wide_page_test.cpp + unit/abi_cdx_estaelec_compound_test.cpp + unit/abi_adi_ordinal_stable_test.cpp + unit/abi_adi_v2_explicit_override_test.cpp + unit/abi_sql_temp_browse_nav_test.cpp + unit/abi_stale_index_walk_test.cpp + unit/abi_adt_sql_test.cpp + unit/abi_adt_dat_extension_sql_test.cpp + unit/openads_sql_c_test.cpp + unit/abi_dd_table_prop_test.cpp + unit/abi_dd_user_prop_test.cpp + unit/abi_enum_test.cpp + unit/abi_dd_ri_test.cpp + unit/abi_dd_auth_test.cpp + unit/abi_dd_perms_test.cpp + unit/abi_dd_field_prop_test.cpp + unit/abi_dd_trigger_test.cpp + unit/abi_dd_trigger_fire_test.cpp + unit/abi_dd_proc_view_test.cpp + unit/abi_dd_remote_test.cpp + unit/abi_dd_remote_phase2_test.cpp + unit/abi_sql_system_tables_test.cpp + unit/abi_sql_system_primarykeys_test.cpp + unit/abi_sql_dd_sql_test.cpp + unit/abi_no_current_record_test.cpp + unit/codepage_test.cpp + unit/adm_memo_test.cpp + unit/sqlite_uri_test.cpp + unit/abi_sql_stmt_concurrency_test.cpp + unit/sqlite_concurrency_test.cpp + unit/proc_test.cpp + unit/sql_common_test.cpp + unit/sql_system_catalog_test.cpp + unit/sql_oracle_dialect_test.cpp + unit/sql_acl_store_test.cpp + unit/enterprise_config_test.cpp + unit/legacy_crt_shims_test.cpp + unit/dd_native_codec_test.cpp + unit/dll_test.cpp + unit/network_client_test.cpp + unit/abi_dd_invoicing_test.cpp + unit/abi_fetch_where_test.cpp + unit/network_setfields_test.cpp + unit/network_open_warm_test.cpp + unit/network_version_test.cpp + unit/network_nav_batch_test.cpp + unit/network_use_budget_test.cpp + unit/network_local_answers_test.cpp + unit/network_empty_window_test.cpp + unit/network_remote_reindex_test.cpp + unit/network_frame_trace_test.cpp + unit/network_boundary_pair_test.cpp + unit/network_commit_slimming_test.cpp + unit/network_teardown_batch_test.cpp + unit/network_pool_mt_test.cpp + unit/network_mutex_release_test.cpp + unit/network_lock_exclusion_test.cpp + unit/network_login_gate_stress_test.cpp + unit/abi_remote_lock_introspection_test.cpp + unit/network_exclusive_open_test.cpp + unit/backend_tier1_test.cpp + unit/abi_oads_file_funcs_test.cpp + unit/abi_create_index_path_test.cpp + unit/abi_pritpal_empty_index_test.cpp + unit/abi_remote_pritpal_empty_index_test.cpp + unit/abi_multiuser_nav_visibility_test.cpp + unit/repl_queue_test.cpp + unit/repl_catalog_test.cpp + unit/abi_repl_capture_test.cpp + unit/repl_apply_test.cpp + unit/abi_seek_after_order_change_test.cpp + unit/abi_index_collation_persist_test.cpp + unit/abi_index_header_compat_test.cpp +) + +# OpenADS ODBC driver (provider) test — drives the driver's SQL* exports +# directly. Windows only: relies on the MSVC SDK ODBC headers/types. +if(WIN32) + target_sources(openads_unit_tests PRIVATE + unit/odbc_driver_test.cpp + unit/odbc_driver_pk_test.cpp + ${CMAKE_SOURCE_DIR}/bindings/odbc/openads_odbc.cpp) +endif() + +if(OPENADS_WITH_POSTGRESQL OR OPENADS_WITH_MARIADB OR OPENADS_WITH_MSSQL + OR OPENADS_WITH_FIREBIRD) + target_sources(openads_unit_tests PRIVATE + unit/abi_sql_live_pg_maria_test.cpp) +endif() + +if(OPENADS_WITH_POSTGRESQL) + target_sources(openads_unit_tests PRIVATE + unit/abi_plus_postgres_read_test.cpp + unit/abi_plus_postgres_write_test.cpp + unit/abi_plus_postgres_filter_test.cpp + unit/abi_aggregate_postgres_test.cpp + unit/abi_plus_postgres_seek_test.cpp + unit/postgres_uri_test.cpp + ) +endif() + +if(OPENADS_WITH_MARIADB) + target_sources(openads_unit_tests PRIVATE + unit/maria_uri_test.cpp + unit/abi_plus_mariadb_read_test.cpp + unit/abi_plus_mariadb_seek_test.cpp + unit/abi_plus_mariadb_write_test.cpp + ) +endif() + +if(OPENADS_WITH_FIREBIRD) + target_sources(openads_unit_tests PRIVATE + unit/firebird_uri_test.cpp + unit/firebird_connection_test.cpp + unit/abi_plus_firebird_read_test.cpp + unit/abi_plus_firebird_write_test.cpp + ) +endif() + +if(OPENADS_WITH_MSSQL) + target_sources(openads_unit_tests PRIVATE + unit/abi_plus_mssql_connect_test.cpp + unit/abi_plus_mssql_read_test.cpp + unit/abi_plus_mssql_write_test.cpp + unit/tds_protocol_test.cpp + unit/mssql_table_skip_test.cpp + unit/mssql_uri_test.cpp + ) +endif() + +if(OPENADS_WITH_ODBC) + target_sources(openads_unit_tests PRIVATE + unit/oracle_uri_test.cpp + unit/odbc_uri_test.cpp + unit/abi_plus_odbc_read_test.cpp + unit/abi_plus_odbc_seek_test.cpp + unit/abi_plus_odbc_write_test.cpp + ) +endif() + +# M11.4 — side DLL with stored procedures used by abi_aep_test. +add_library(openads_test_aep_proc SHARED + fixtures/test_aep_proc.cpp +) +set_target_properties(openads_test_aep_proc PROPERTIES + LIBRARY_OUTPUT_DIRECTORY $ + RUNTIME_OUTPUT_DIRECTORY $ +) +add_dependencies(openads_unit_tests openads_test_aep_proc) +target_compile_definitions(openads_unit_tests PRIVATE + OPENADS_TEST_AEP_DLL="$" +) + +target_include_directories(openads_unit_tests SYSTEM PRIVATE + ${CMAKE_SOURCE_DIR}/third_party/doctest +) +target_include_directories(openads_unit_tests PRIVATE + ${CMAKE_SOURCE_DIR} + ${CMAKE_SOURCE_DIR}/src + ${CMAKE_SOURCE_DIR}/tests +) + +target_link_libraries(openads_unit_tests PRIVATE openads_core) + +if(OPENADS_WITH_SQLITE) + # The sqlite read/seek tests seed a fixture DB through the SQLite C + # API directly, so they need the amalgamation header + symbols. + target_link_libraries(openads_unit_tests PRIVATE openads_sqlite3) +endif() + +if(OPENADS_WITH_HARBOUR_UDF) + # Harbour libs for the UDF backend under test (registered by the + # hrb_udf test itself — core stays Harbour-free, see src/CMakeLists). + target_link_libraries(openads_unit_tests PRIVATE ${HARBOUR_LINK_LIBRARIES}) + # The test .hrb module is compiled from PRG at build time so the + # source of truth stays readable. Needs the Harbour compiler. + find_program(HARBOUR_COMPILER NAMES harbour + HINTS $ENV{HB_ROOT}/bin C:/harbour-git/bin/win/mingw-550S C:/harbour/bin + PATH_SUFFIXES linux/gcc) + if(NOT HARBOUR_COMPILER) + message(FATAL_ERROR "OPENADS_WITH_HARBOUR_UDF=ON but no 'harbour' compiler found (set HARBOUR_COMPILER)") + endif() + file(MAKE_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/hrb) + add_custom_command( + OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/hrb/udf_test.hrb + COMMAND ${HARBOUR_COMPILER} ${CMAKE_CURRENT_SOURCE_DIR}/hrb/udf_test.prg -gh "-o${CMAKE_CURRENT_BINARY_DIR}/hrb/udf_test.hrb" + DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/hrb/udf_test.prg + COMMENT "Compiling HRB UDF test module") + add_custom_target(openads_udf_test_hrb ALL + DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/hrb/udf_test.hrb) + add_dependencies(openads_unit_tests openads_udf_test_hrb) + target_sources(openads_unit_tests PRIVATE unit/hrb_udf_test.cpp) + target_compile_definitions(openads_unit_tests PRIVATE + OPENADS_TEST_HRB_PATH="${CMAKE_CURRENT_BINARY_DIR}/hrb/udf_test.hrb") +endif() + +# Default CI/fast tier: skip stress tests tagged [slow], and timing- +# sensitive cases tagged [flaky] (proven load-flakes on shared CI +# runners: connection storms, lock races, teardown parks — see +# docs/known-issues.md). Both tiers still run everywhere else +# (local dev, explicit runs); the release gate depends on this tier. +# NOTE: the short flag MUST be -tce (test-case-exclude). Bare -e is +# silently ignored by doctest 2.4.11, and repeated -tce occurrences +# do NOT accumulate (parseOption takes one) — so slow+flaky share a +# single comma-separated -tce. The old -e=*[slow]* excluded nothing: +# every tier always ran the 240 s storm cases, which also explains +# past 30-minute Test timeouts on loaded runners. No embedded quotes +# around the filter value (they mangle args on Windows under ctest). +add_test(NAME openads_unit_tests COMMAND openads_unit_tests -tce=*[slow]*,*flaky*) +add_test(NAME openads_unit_tests_slow COMMAND openads_unit_tests -tc=*[slow]* -tce=*flaky*) + +# Session-pool MT stress: the pool test in 30 fresh processes (each run +# already repeats its 4-thread phase 25x). Catches rare races that one +# in-suite pass misses. Every patch must keep this green. +if(UNIX) + add_test(NAME network_pool_mt_repeat COMMAND sh -c "i=0; while [ $i -lt 30 ]; do \"$0\" -tc=\"Session pool: MT*\" >/dev/null 2>&1 || { echo \"pool MT test failed on run $i\"; \"$0\" -tc=\"Session pool: MT*\"; exit 1; }; i=$((i+1)); done; echo \"pool MT test: 30 clean runs\"" $) +endif() + +# SQL URI backend smoke (sqlite://): runs a focused doctest subset in CI. +if(OPENADS_WITH_POSTGRESQL) + add_test(NAME pg_live_smoke COMMAND openads_unit_tests + -tc="SQL live PG*") +endif() +if(OPENADS_WITH_MARIADB) + add_test(NAME maria_live_smoke COMMAND openads_unit_tests + -tc="SQL live Maria*") +endif() +if(OPENADS_WITH_MSSQL) + add_test(NAME mssql_live_smoke COMMAND openads_unit_tests + -tc="SQL live MSSQL*") +endif() +if(OPENADS_WITH_FIREBIRD) + add_test(NAME firebird_live_smoke COMMAND openads_unit_tests + -tc="SQL live Firebird*") +endif() + +if(OPENADS_WITH_SQLITE) + add_test(NAME sql_uri_smoke COMMAND openads_unit_tests + -tc="SQL URI smoke*") + add_test(NAME sqlite_filter_pushdown COMMAND openads_unit_tests + -tc="Tier-2 push-down: SET FILTER*") + add_test(NAME sqlite_seek_smoke COMMAND openads_unit_tests + -tc="ABI Plus: sqlite*") +endif() + +# Harbour smoke fixture builder (M8.6). Produces a CDX matching the +# M8.5 fixture DBF using OpenADS' own CdxIndex::create + insert path, +# so the Harbour smoke tests dbSeek end-to-end through OpenADS. +add_executable(make_cdx smoke/harbour/make_cdx.cpp) +target_include_directories(make_cdx PRIVATE ${CMAKE_SOURCE_DIR}/src) +target_link_libraries(make_cdx PRIVATE openads_core) + +# ADT/ADI fixture generator (M4 smoke tests — landlords, leases, properties). +add_executable(generate_adi_fixtures tools/generate_adi_fixtures.cpp) +target_include_directories(generate_adi_fixtures PRIVATE ${CMAKE_SOURCE_DIR}/include) +target_link_libraries(generate_adi_fixtures PRIVATE openads_ace) + +add_executable(probe_cdx smoke/harbour/probe_cdx.cpp) +target_include_directories(probe_cdx PRIVATE ${CMAKE_SOURCE_DIR}/src) +target_link_libraries(probe_cdx PRIVATE openads_core) + +add_executable(probe_cdx2 smoke/harbour/probe_cdx2.cpp) +target_include_directories(probe_cdx2 PRIVATE ${CMAKE_SOURCE_DIR}/src) +target_link_libraries(probe_cdx2 PRIVATE openads_core) + +# Benchmark: DBF 500K records + CDX index build +add_executable(dbf_cdx_bench bench/dbf_cdx_bench.cpp) +target_include_directories(dbf_cdx_bench PRIVATE ${CMAKE_SOURCE_DIR}/include) +target_link_libraries(dbf_cdx_bench PRIVATE openads_ace) + +# Remote benchmark: tcp:// to iMac server +add_executable(remote_bench bench/remote_bench.cpp) +target_include_directories(remote_bench PRIVATE ${CMAKE_SOURCE_DIR}/include) +target_link_libraries(remote_bench PRIVATE openads_ace) + +add_executable(remote_test bench/remote_test.cpp) +target_include_directories(remote_test PRIVATE ${CMAKE_SOURCE_DIR}/include) +target_link_libraries(remote_test PRIVATE openads_ace) + +add_executable(oads_imac_test bench/oads_imac_test.cpp) +target_include_directories(oads_imac_test PRIVATE ${CMAKE_SOURCE_DIR}/include) +target_link_libraries(oads_imac_test PRIVATE openads_ace) From 8e41fa3f02cf36ebf4fd6146ccbd2c44488d9ec7 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Fri, 25 Sep 2026 21:32:59 -0500 Subject: [PATCH 39/97] release notes v1.09.68-mtfix12 --- 11-release-notes-1.09.68-mtfix12.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 11-release-notes-1.09.68-mtfix12.md diff --git a/11-release-notes-1.09.68-mtfix12.md b/11-release-notes-1.09.68-mtfix12.md new file mode 100644 index 00000000..8be0449e --- /dev/null +++ b/11-release-notes-1.09.68-mtfix12.md @@ -0,0 +1,23 @@ +Built for Pritpal Bedi - bedipritpal/OpenADS, forked from FiveTechSoft/OpenADS. + +## v1.09.68-mtfix12 - navigation fusion: boundary pairs, self-goto suppression, record-number anchoring + +One voucher save replayed from the mtfix11 production trace: 893 wire round trips. This release takes 155 of them off the wire in the default configuration (-17%), up to 201 (-23%) with the opt-in below. At the WAN rate he measured (~40 ms/RTT) that is about 6 s off the 38.5 s run by default, about 8.5 s with the opt-in. Zero behavior change: every suppressed call answers from state the server already certified, under the same freshness envelope the shipped duplicate-suppression uses. + +### 1. Boundary-pair certification (protocol extension, caps-gated) +GotoTop and GotoBottom requests can now ask the server to certify BOTH boundaries in one visit (new capability bit kCapNavBoundaryPair; old clients and old servers interoperate unchanged - the byte is only sent when both sides advertise it). The app's dbGoTop(); dbGoBottom() ritual - 102 adjacent pairs in the trace - now costs one frame instead of two. Bonus: top and bottom read in the same server visit are more consistent with each other than two separate frames ever were. + +### 2. Self-GotoRecord suppression +Re-issuing GotoRecord for the record the server cursor already sits on (the xBrowse bookmark-restore pattern - 89 of 126 GotoRecords in the trace are self-gotos) now answers locally. The default envelope is connection-wide (53 served in the replay); setting OPENADS_NAV_SELF_GOTO=table widens freshness to per-table (96 served in the replay) for apps that want the extra savings. + +### 3. GetRecordNum from the certified cursor +AdsGetRecordNum answers from the server-certified cursor anchor when the freshness envelope holds, instead of asking the server where it already told us it is. + +### Validation +- Full unit suite: 1588/1591 green; the 3 failures are the known pre-existing flaky tests that fail identically on the clean tree (mt contention, openindex race, create storm). +- New boundary-pair unit tests (82 assertions): pair serve in both directions, write invalidation, cross-table expiry, self-goto conditions, record-number anchor. +- Analytical replay of the mtfix11 production trace with the shipped invalidation rules: -102 (boundary pairs), -53 default / -96 opt-in (self-goto), GetRecordNum serves on top. Predicted wire totals: 738 default, 692 with OPENADS_NAV_SELF_GOTO=table (from 893). +- The replay caught one real bug during validation: the self-goto serve initially kept the prefetch queue a wire GotoRecord would have cleared; fixed, covered by the ramp test. + +### A/B +Same harness as mtfix9-11: wire_trace=1 in openads.ini (or OPENADS_WIRE_TRACE=1), OPENADS_WIRE_TRACE_FILE picks the log path. Run the voucher save on the mtfix11 build, then on mtfix12, count ` wire ` lines. Expect ~17% fewer by default, ~23% with OPENADS_NAV_SELF_GOTO=table; zero errors either way. From d907e304421dd4a5aa681059737c998db73a2166 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Fri, 25 Sep 2026 21:57:02 -0500 Subject: [PATCH 40/97] glibc231 leg: fix lost version stamp (fetch tags + explicit OPENADS_VERSION_FORCE from tag) --- .github/workflows/1-release.yml | 594 ++++++++++++++++++++++++++++++++ 1 file changed, 594 insertions(+) create mode 100644 .github/workflows/1-release.yml diff --git a/.github/workflows/1-release.yml b/.github/workflows/1-release.yml new file mode 100644 index 00000000..7b1136de --- /dev/null +++ b/.github/workflows/1-release.yml @@ -0,0 +1,594 @@ +name: release + +on: + push: + tags: + - "v*" + workflow_dispatch: + inputs: + tag: + description: "tag to package (e.g. v1.0.0-rc1)" + required: true + +permissions: + contents: write + +jobs: + build: + name: ${{ matrix.os }} / ${{ matrix.arch }} + runs-on: ${{ matrix.os }} + # 90: a cold Harbour SDK build (Linux leg) plus the full tree can + # exceed the old 60-minute cap; cached runs finish far sooner. + timeout-minutes: 90 + # The macOS runners can stall in the test step (>60 min); + # don't let them block the release while that's investigated. + continue-on-error: ${{ startsWith(matrix.os, 'macos') }} + strategy: + fail-fast: false + matrix: + include: + - os: windows-2022 + arch: x64 + preset: msvc-x64 + artifact_ext: zip + cmake_extra: "" + - os: windows-2022 + arch: x86 + preset: msvc-x86 + artifact_ext: zip + cmake_extra: "" + - os: ubuntu-24.04 + arch: x64 + preset: ninja-clang + artifact_ext: tar.gz + cmake_extra: "" + # One macOS leg builds a universal (arm64 + x86_64) + # binary on the Apple-Silicon runner — GitHub's Intel + # macos-13 runners are scarce and stall the release in + # the queue. A universal archive runs on both arches. + - os: macos-14 + arch: universal + preset: default + artifact_ext: tar.gz + cmake_extra: '-DCMAKE_OSX_ARCHITECTURES="arm64;x86_64"' + + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ inputs.tag || github.ref }} + + - name: Resolve tag + id: tag + shell: bash + run: | + T="${{ inputs.tag }}" + if [ -z "$T" ]; then T="${GITHUB_REF#refs/tags/}"; fi + echo "tag=$T" >> "$GITHUB_OUTPUT" + echo "version=${T#v}" >> "$GITHUB_OUTPUT" + + - name: Install Ninja (Linux / macOS) + if: runner.os != 'Windows' + uses: seanmiddleditch/gha-setup-ninja@v6 + + # Server-side Harbour UDFs (.hrb): the Linux leg embeds hbvm, + # so it needs a Harbour SDK. Built from source once, then cached + # (mirrors tools/scripts/bootstrap_harbour_ci.ps1 on Windows). + - name: Cache Harbour SDK (Linux) + if: runner.os == 'Linux' + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/harbour-udf + key: harbour-udf-linux-4ec2e154ed92757418bc30af571ab90240ab3209-v1 + + - name: Provision Harbour SDK (Linux) + if: runner.os == 'Linux' + shell: bash + env: + HARBOUR_REF: 4ec2e154ed92757418bc30af571ab90240ab3209 + run: | + set -e + HB="$RUNNER_TEMP/harbour-udf" + echo "HB_ROOT=$HB/install" >> "$GITHUB_ENV" + if [ -f "$HB/install/include/hbvm.h" ] && \ + [ -n "$(find "$HB/install" -name 'libhbvmmt.a' 2>/dev/null | head -1)" ] && \ + [ -n "$(find "$HB/install" -name harbour -type f 2>/dev/null | head -1)" ]; then + echo "Harbour SDK cached at $HB/install" + else + sudo apt-get update + sudo apt-get install -y build-essential libncurses-dev libx11-dev + rm -rf "$HB" + mkdir -p "$HB" + git clone --depth 1 --branch master https://github.com/harbour/core.git "$HB/src" + git -C "$HB/src" fetch --depth 1 origin "$HARBOUR_REF" + git -C "$HB/src" checkout "$HARBOUR_REF" + cd "$HB/src" + make -j"$(nproc)" install HB_INSTALL_PREFIX="$HB/install" HB_BUILD_3RDEXT=no + fi + # Resolve exact SDK paths (no layout guessing in CMake): + # headers, MT VM archive, compiler binary. + HB_INC="$(dirname "$(find "$HB/install" -name hbvm.h | head -1)")" + HB_LIBDIR="$(dirname "$(find "$HB/install" -name 'libhbvmmt.a' | head -1)")" + HB_CC="$(find "$HB/install" -name harbour -type f -executable | head -1)" + echo "Harbour SDK layout:" + echo " include: ${HB_INC:-MISSING}" + echo " libdir: ${HB_LIBDIR:-MISSING}" + echo " compiler:${HB_CC:-MISSING}" + if [ -z "$HB_INC" ] || [ -z "$HB_LIBDIR" ] || [ -z "$HB_CC" ]; then + echo "::error::Harbour SDK layout unexpected — full listing:" + find "$HB/install" -maxdepth 4 | sort | head -80 + exit 1 + fi + { + echo "HARBOUR_INCLUDE_DIR=$HB_INC" + echo "HARBOUR_LIB_DIR=$HB_LIBDIR" + echo "HARBOUR_COMPILER=$HB_CC" + } >> "$GITHUB_ENV" + + - name: Configure + shell: bash + run: | + cmake --preset ${{ matrix.preset }} \ + -DOPENADS_WITH_TLS=ON \ + -DOPENADS_WITH_HTTP=ON \ + ${{ matrix.cmake_extra }} \ + ${{ runner.os == 'Linux' && format('-DOPENADS_WITH_HARBOUR_UDF=ON -DHARBOUR_INCLUDE_DIR={0} -DHARBOUR_LIB_DIR={1} -DHARBOUR_COMPILER={2}', env.HARBOUR_INCLUDE_DIR, env.HARBOUR_LIB_DIR, env.HARBOUR_COMPILER) || '' }} + + - name: Build + run: cmake --build build/${{ matrix.preset }} --config Release + + - name: Test + # The macOS runners can stall here (>60 min); cap them and + # let the leg pass anyway so it can't block the release. + timeout-minutes: ${{ startsWith(matrix.os, 'macos') && 20 || 30 }} + continue-on-error: ${{ startsWith(matrix.os, 'macos') }} + run: ctest --test-dir build/${{ matrix.preset }} + --output-on-failure -C Release + + # Static ACE library for Harbour + MinGW (libopenace32.a / + # libopenace64.a): linking it into an hbmk2 -comp=mingw[64] app + # embeds the whole ACE client+engine in the .exe — no ace32.dll / + # ace64.dll to deploy. Built with MSYS2's matching MinGW toolchain + # (the MSVC legs above can't produce a GNU archive). Lean config: + # no HTTP console, no TLS, no tests. (Requested by Pritpal Bedi.) + - name: Set up MinGW for the static ACE library + if: always() && runner.os == 'Windows' + uses: msys2/setup-msys2@v2 + with: + msystem: ${{ matrix.arch == 'x64' && 'MINGW64' || 'MINGW32' }} + # v1.09.62 postmortem: update:true + upstream base (June) died + # deterministically on both Windows legs at this step (MSYS2 + # mirror PGP/db-sync rot hits the full -Syuu upgrade path; + # our diff cannot influence this step). Use the runner image's + # preinstalled MSYS2 (days old, not months) and install only + # the toolchain — no system upgrade. If this still fails, the + # step-9 log lines (not just the exit code) are required + # before further workflow surgery. + release: false + update: false + install: >- + ${{ matrix.arch == 'x64' && 'mingw-w64-x86_64-toolchain mingw-w64-x86_64-cmake' || 'mingw-w64-i686-toolchain mingw-w64-i686-cmake' }} + + - name: Build static ACE library (MinGW) + if: always() && runner.os == 'Windows' + shell: msys2 {0} + run: | + cmake -S . -B build/mingw-${{ matrix.arch }} -G "MinGW Makefiles" \ + -DCMAKE_BUILD_TYPE=Release \ + -DOPENADS_BUILD_TESTS=OFF \ + -DOPENADS_WITH_HTTP=OFF \ + -DOPENADS_WITH_TLS=OFF \ + -DOPENADS_WARNINGS_AS_ERRORS=OFF + cmake --build build/mingw-${{ matrix.arch }} \ + --target openads_ace_static -j"$(nproc)" + ls -la build/mingw-${{ matrix.arch }}/src/libopenace*.a + + - name: Stage release files (POSIX) + if: always() && runner.os != 'Windows' + shell: bash + run: | + STAGE="openads-${{ steps.tag.outputs.version }}-${{ runner.os == 'Linux' && 'linux' || 'macos' }}-${{ matrix.arch }}" + mkdir -p "$STAGE" + BUILD="build/${{ matrix.preset }}" + # Engine shared lib is built as libopenace64.{so,dylib}. Ship it, + # plus a drop-in-named copy libace64.* for apps that load by the + # canonical ACE name. + for f in "$BUILD"/src/libopenace64.*; do + [ -e "$f" ] || continue + cp "$f" "$STAGE/" + cp "$f" "$STAGE/$(basename "$f" | sed 's/libopenace64/libace64/')" + done + # Server + bench CLIs + cp "$BUILD"/tools/serverd/openads_serverd "$STAGE/" + cp "$BUILD"/tools/bench/openads_bench "$STAGE/" + cp LICENSE NOTICE README.md openads.ini.sample "$STAGE/" + tar czvf "$STAGE.tar.gz" "$STAGE" + ls -la "$STAGE.tar.gz" + echo "ASSET=$STAGE.tar.gz" >> "$GITHUB_ENV" + + # Canonical Windows ZIP name is openads--windows-.zip + # (CPack and this workflow). Never emit openads--win-.zip — + # that short name was a hand-rolled slim kit uploaded beside the CI + # zip on v1.8.84–v1.8.86 and shipped a different ace64.dll. See + # packaging/windows/README.md ("Release ZIP names"). + - name: Stage release files (Windows) + if: always() && runner.os == 'Windows' + shell: pwsh + run: | + $stage = "openads-${{ steps.tag.outputs.version }}-windows-${{ matrix.arch }}" + New-Item -ItemType Directory -Path $stage | Out-Null + $build = "build/${{ matrix.preset }}" + $bits = if ("${{ matrix.arch }}" -eq "x64") { "64" } else { "32" } + $builtDll = "$build/src/Release/openace$bits.dll" + $builtLib = "$build/src/Release/openace$bits.lib" + if (-not (Test-Path $builtDll)) { + throw "Engine DLL missing: $builtDll" + } + if (-not (Test-Path $builtLib)) { + throw "Import library missing: $builtLib" + } + # Ship BOTH names at the archive root: + # openace64.dll / openace32.dll — OpenADS build product (PHP ext, + # side-by-side with SAP ACE) + # ace64.dll / ace32.dll — drop-in for Harbour rddads / FWH + Copy-Item $builtDll "$stage/openace$bits.dll" + Copy-Item $builtLib "$stage/openace$bits.lib" + Copy-Item "$build/src/Release/openace$bits.exp" "$stage/" -ErrorAction SilentlyContinue + Copy-Item $builtDll "$stage/ace$bits.dll" + Copy-Item $builtLib "$stage/ace$bits.lib" + foreach ($req in @( + "openace$bits.dll", "openace$bits.lib", + "ace$bits.dll", "ace$bits.lib")) { + if (-not (Test-Path "$stage/$req")) { + throw "Release staging incomplete: missing $req" + } + } + # Per-compiler import libraries (MSVC / MinGW-GCC / Borland) for + # ace.dll — prebuilt under dist/import-libs because the CI + # runners have no Borland toolchain (see dist/import-libs/gen.ps1). + New-Item -ItemType Directory "$stage/lib" | Out-Null + Copy-Item -Recurse "dist/import-libs/${{ matrix.arch }}/*" "$stage/lib/" + # Static ACE library (MinGW): link into an hbmk2 -comp=mingw[64] + # app and no ace.dll is needed at all. Built in the + # "Build static ACE library (MinGW)" step above. + $staticLib = "build/mingw-${{ matrix.arch }}/src/libopenace$bits.a" + if (-not (Test-Path $staticLib)) { + throw "Static ACE library missing: $staticLib" + } + Copy-Item $staticLib "$stage/libopenace$bits.a" + # Server + bench CLIs + Copy-Item "$build/tools/serverd/Release/openads_serverd.exe" "$stage/" + Copy-Item "$build/tools/bench/Release/openads_bench.exe" "$stage/" + Copy-Item LICENSE,NOTICE,README.md,openads.ini.sample $stage/ + Copy-Item QUICKSTART.md "$stage/" -ErrorAction SilentlyContinue + # Harbour HB_FUNC wrappers for the oads_* VFS API — compiled + # into the app, not shipped in the DLL. Arch-independent C + # source: both Windows zips carry it. (Pritpal Bedi.) + New-Item -ItemType Directory "$stage/contrib/oads_hb" -Force | Out-Null + Copy-Item contrib/oads_hb/oads_hb.c,contrib/oads_hb/README.md "$stage/contrib/oads_hb/" + if ("${{ matrix.arch }}" -eq "x86") { + Copy-Item src/openads_ace_x86_stdcall.def "$stage/" + # Ship the 32-bit MinGW import library at the archive root too: + # 32-bit Harbour (hbmk2 -comp=mingw, rddads) links it directly. + # (Reported by Pritpal Bedi.) + Copy-Item dist/import-libs/x86/mingw/libace32.a "$stage/" + } else { + Copy-Item src/openads_ace.def "$stage/" + } + Compress-Archive -Path "$stage/*" -DestinationPath "$stage.zip" -Force + Get-Item "$stage.zip" + "ASSET=$stage.zip" | Out-File -FilePath $env:GITHUB_ENV -Append + + - name: Upload artifact (workflow run) + if: always() && env.ASSET != '' + uses: actions/upload-artifact@v4 + with: + name: openads-${{ runner.os }}-${{ matrix.arch }} + path: ${{ env.ASSET }} + + # glibc 2.31 compatibility build for older Linux servers (Ubuntu 20.04): + # the ubuntu-24.04 leg's binaries require GLIBC_2.38+, so the same tree + # is built inside an ubuntu:20.04 container. Asset is additive: publish + # flattens it into the release but does not require the leg to be green. + # (Requested by Pritpal Bedi for his LAN test server.) + build-glibc231: + name: ubuntu 20.04 container / x64 (glibc 2.31) + runs-on: ubuntu-24.04 + container: ubuntu:20.04 + timeout-minutes: 90 + steps: + # Bare image: git/curl first so checkout + CMake FetchContent work. + - name: Install bootstrap tools + shell: bash + run: | + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y --no-install-recommends \ + git ca-certificates curl xz-utils + + - uses: actions/checkout@v5 + with: + ref: ${{ inputs.tag || github.ref }} + # Full history + tags: the version stamp falls back to + # `git describe --tags`, and a shallow tag checkout can leave + # the tag unresolvable to the container's older git. + fetch-depth: 0 + + - name: Resolve tag + id: tag + shell: bash + run: | + T="${{ inputs.tag }}" + if [ -z "$T" ]; then T="${GITHUB_REF#refs/tags/}"; fi + echo "tag=$T" >> "$GITHUB_OUTPUT" + echo "version=${T#v}" >> "$GITHUB_OUTPUT" + + # CMakePresets v4 needs CMake >= 3.23; 20.04's apt ships 3.16, so + # vendor the official binary (runs on any glibc >= 2.17). Compiler: + # focal's clang-10 rejects this tree's C++20 implicit-move returns + # (P1825, e.g. server_fs.cpp), so this leg builds with g++-10 (in + # the focal archive, implements P1825); warnings-as-errors stays + # off for the older toolchain. + - name: Install toolchain and CMake + shell: bash + run: | + export DEBIAN_FRONTEND=noninteractive + apt-get update + apt-get install -y --no-install-recommends \ + build-essential g++-10 ninja-build pkg-config \ + libncurses-dev libx11-dev bison flex python3 perl + curl -sSL -o /tmp/cmake.tar.gz \ + https://github.com/Kitware/CMake/releases/download/v3.28.6/cmake-3.28.6-linux-x86_64.tar.gz + mkdir -p /opt/cmake + tar xzf /tmp/cmake.tar.gz -C /opt/cmake --strip-components=1 + echo "/opt/cmake/bin" >> "$GITHUB_PATH" + /opt/cmake/bin/cmake --version + + # Same Harbour UDF SDK as the 24.04 leg, cached under its own key + # (binaries are glibc-specific). + - name: Cache Harbour SDK (glibc 2.31) + uses: actions/cache@v4 + with: + path: ${{ runner.temp }}/harbour-udf-glibc231 + key: harbour-udf-linux-glibc231-4ec2e154ed92757418bc30af571ab90240ab3209-v1 + + - name: Provision Harbour SDK (glibc 2.31) + shell: bash + env: + HARBOUR_REF: 4ec2e154ed92757418bc30af571ab90240ab3209 + run: | + set -e + HB="$RUNNER_TEMP/harbour-udf-glibc231" + echo "HB_ROOT=$HB/install" >> "$GITHUB_ENV" + if [ -f "$HB/install/include/hbvm.h" ] && \ + [ -n "$(find "$HB/install" -name 'libhbvmmt.a' 2>/dev/null | head -1)" ] && \ + [ -n "$(find "$HB/install" -name harbour -type f 2>/dev/null | head -1)" ]; then + echo "Harbour SDK cached at $HB/install" + else + rm -rf "$HB" + mkdir -p "$HB" + git clone --depth 1 --branch master https://github.com/harbour/core.git "$HB/src" + git -C "$HB/src" fetch --depth 1 origin "$HARBOUR_REF" + git -C "$HB/src" checkout "$HARBOUR_REF" + cd "$HB/src" + make -j"$(nproc)" install HB_INSTALL_PREFIX="$HB/install" HB_BUILD_3RDEXT=no + fi + HB_INC="$(dirname "$(find "$HB/install" -name hbvm.h | head -1)")" + HB_LIBDIR="$(dirname "$(find "$HB/install" -name 'libhbvmmt.a' | head -1)")" + HB_CC="$(find "$HB/install" -name harbour -type f -executable | head -1)" + echo "Harbour SDK layout:" + echo " include: ${HB_INC:-MISSING}" + echo " libdir: ${HB_LIBDIR:-MISSING}" + echo " compiler:${HB_CC:-MISSING}" + if [ -z "$HB_INC" ] || [ -z "$HB_LIBDIR" ] || [ -z "$HB_CC" ]; then + echo "::error::Harbour SDK layout unexpected - full listing:" + find "$HB/install" -maxdepth 4 | sort | head -80 + exit 1 + fi + { + echo "HARBOUR_INCLUDE_DIR=$HB_INC" + echo "HARBOUR_LIB_DIR=$HB_LIBDIR" + echo "HARBOUR_COMPILER=$HB_CC" + } >> "$GITHUB_ENV" + + - name: Configure + shell: bash + run: | + cmake --preset ninja-clang \ + -DOPENADS_VERSION_FORCE=${{ steps.tag.outputs.version }} \ + -DCMAKE_C_COMPILER=gcc-10 \ + -DCMAKE_CXX_COMPILER=g++-10 \ + -DOPENADS_WITH_TLS=ON \ + -DOPENADS_WITH_HTTP=ON \ + -DOPENADS_WARNINGS_AS_ERRORS=OFF \ + -DOPENADS_WITH_HARBOUR_UDF=ON \ + -DHARBOUR_INCLUDE_DIR="$HARBOUR_INCLUDE_DIR" \ + -DHARBOUR_LIB_DIR="$HARBOUR_LIB_DIR" \ + -DHARBOUR_COMPILER="$HARBOUR_COMPILER" + + - name: Build + run: cmake --build build/ninja-clang --config Release + + - name: Test + timeout-minutes: 30 + run: ctest --test-dir build/ninja-clang --output-on-failure -C Release + + - name: Stage release files + shell: bash + run: | + STAGE="openads-${{ steps.tag.outputs.version }}-linux-glibc231" + mkdir -p "$STAGE" + BUILD="build/ninja-clang" + for f in "$BUILD"/src/libopenace64.*; do + [ -e "$f" ] || continue + cp "$f" "$STAGE/" + cp "$f" "$STAGE/$(basename "$f" | sed 's/libopenace64/libace64/')" + done + cp "$BUILD"/tools/serverd/openads_serverd "$STAGE/" + cp "$BUILD"/tools/bench/openads_bench "$STAGE/" + cp LICENSE NOTICE README.md openads.ini.sample "$STAGE/" + tar czvf "$STAGE.tar.gz" "$STAGE" + ls -la "$STAGE.tar.gz" + echo "ASSET=$STAGE.tar.gz" >> "$GITHUB_ENV" + + # The whole point of the leg: prove the binaries stay within + # glibc 2.31 before they ship. + - name: Verify glibc ceiling (<= 2.31) + shell: bash + run: | + set -e + STAGE="openads-${{ steps.tag.outputs.version }}-linux-glibc231" + fail=0 + for b in "$STAGE"/openads_serverd "$STAGE"/openads_bench \ + "$STAGE"/libopenace64.so "$STAGE"/libace64.so; do + hit=$(objdump -T "$b" | grep -oE 'GLIBC_2\.(3[2-9]|[4-9][0-9])' | sort -uV || true) + if [ -n "$hit" ]; then + echo "::error::$b references > GLIBC_2.31: $hit" + fail=1 + fi + done + [ "$fail" -eq 0 ] && echo "All staged binaries within GLIBC_2.31." + + - name: Upload artifact (workflow run) + if: always() && env.ASSET != '' + uses: actions/upload-artifact@v4 + with: + name: openads-Linux-x64-glibc231 + path: ${{ env.ASSET }} + + + publish: + name: Publish GitHub Release + needs: build + # A cancelled/failed best-effort leg (macOS) must not skip publish; + # only a genuine workflow-run cancellation should. + if: ${{ always() && needs.build.result != 'skipped' }} + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v5 + + - name: Resolve tag + id: tag + shell: bash + run: | + T="${{ inputs.tag }}" + if [ -z "$T" ]; then T="${GITHUB_REF#refs/tags/}"; fi + echo "tag=$T" >> "$GITHUB_OUTPUT" + + - uses: actions/download-artifact@v4 + with: + path: dist + + - name: Flatten artifacts + run: | + mkdir -p out + find dist -type f \( -name "*.zip" -o -name "*.tar.gz" \) \ + -exec cp {} out/ \; + ls -la out + + - name: Resolve version + id: ver + shell: bash + run: | + T="${{ steps.tag.outputs.tag }}" + echo "version=${T#v}" >> "$GITHUB_OUTPUT" + + # Repo convention: every release commit ships release-notes-.md + # (see v1.09.22/v1.09.23). The published GitHub Release body is that + # file verbatim — never the auto-generated commit list — so the + # "What to test" and Packages sections reach users intact. + - name: Resolve release notes + id: notes + shell: bash + run: | + F="release-notes-${{ steps.ver.outputs.version }}.md" + if [ ! -f "$F" ]; then + echo "::error::Missing $F — every release tag needs its notes file." + ls release-notes-*.md || true + exit 1 + fi + echo "path=$F" >> "$GITHUB_OUTPUT" + + # All-or-nothing release: every leg must be green AND every + # canonical asset present, otherwise fail loudly instead of + # shipping a partial kit (v1.09.50 went out without the Linux + # tarball because publish ran on 3 of 4 legs). macOS keeps its + # stall-tolerant test step, but a real macOS build break still + # blocks here via the job conclusion. + - name: Require all legs green and all assets present + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + V="${{ steps.ver.outputs.version }}" + missing=0 + for asset in \ + "openads-${V}-windows-x64.zip" \ + "openads-${V}-windows-x86.zip" \ + "openads-${V}-linux-x64.tar.gz" \ + "openads-${V}-macos-universal.tar.gz"; do + if [ ! -f "out/${asset}" ]; then + echo "::error::Missing required release asset: ${asset}" + missing=1 + else + echo "Found: ${asset}" + fi + done + JOBS_URL="repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/jobs?per_page=20" + echo "Leg conclusions:" + gh api "$JOBS_URL" --jq '.jobs[] | "\(.name): \(.conclusion)"' + for leg in \ + "windows-2022 / x64" \ + "windows-2022 / x86" \ + "ubuntu-24.04 / x64" \ + "macos-14 / universal"; do + concl=$(gh api "$JOBS_URL" --jq \ + ".jobs[] | select(.name == \"$leg\") | .conclusion") + if [ "$concl" != "success" ]; then + echo "::error::Leg '$leg' conclusion is '${concl:-missing}' — blocking publish." + missing=1 + fi + done + if [ "$missing" -ne 0 ]; then + echo "::error::Refusing partial release." + exit 1 + fi + echo "All legs green, all assets present." + + # The short *-win-x64.zip / *-win-x86.zip names are forbidden. + # They were a manual FiveTechSoft kit that sat next to the CI + # *-windows-*.zip and confused users (different binary + layout). + - name: Reject leftover *-win-*.zip names + shell: bash + run: | + shopt -s nullglob + bad=(out/openads-*-win-*.zip) + if ((${#bad[@]})); then + echo "::error::Do not ship *-win-x64.zip / *-win-x86.zip." + echo "Canonical names are *-windows-x64.zip / *-windows-x86.zip." + printf '%s\n' "${bad[@]}" + exit 1 + fi + + - name: Publish release + uses: softprops/action-gh-release@v2 + with: + tag_name: ${{ steps.tag.outputs.tag }} + body_path: ${{ steps.notes.outputs.path }} + files: out/* + + # If the tag was published by hand with the old slim kit already + # attached, drop those assets so only the CI windows-* zips remain. + - name: Drop leftover manual *-win-*.zip assets + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + shell: bash + run: | + TAG="${{ steps.tag.outputs.tag }}" + mapfile -t names < <( + gh release view "$TAG" --json assets --jq '.assets[].name' \ + | grep -E '^openads-.+-win-(x64|x86)\.zip$' || true + ) + for name in "${names[@]}"; do + echo "Removing leftover duplicate asset: $name" + gh release delete-asset "$TAG" "$name" --yes + done From 41049f37e0d6f93c7d63bd6445fb51d6c4246d10 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Fri, 25 Sep 2026 21:57:13 -0500 Subject: [PATCH 41/97] version stamp: honor explicit OPENADS_VERSION_FORCE from the pipeline --- 2-CMakeLists.txt | 629 +++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 629 insertions(+) create mode 100644 2-CMakeLists.txt diff --git a/2-CMakeLists.txt b/2-CMakeLists.txt new file mode 100644 index 00000000..25f5f9bc --- /dev/null +++ b/2-CMakeLists.txt @@ -0,0 +1,629 @@ +cmake_minimum_required(VERSION 3.20) + +project(OpenADS + VERSION 1.09.68 + LANGUAGES C CXX + DESCRIPTION "Open-source ADS-compatible engine" +) + +# Resolve the public version string the binary reports through +# `--version`. Prefer `git describe --tags` so a build straight off +# tag v1.0.0-rc13 reports "1.0.0-rc13", a build off main (between +# tags) reports "1.0.0-rc13-7-g[-dirty]", and a tarball without +# a .git tree falls back to ${PROJECT_VERSION} from project() above. +# The previous hard-coded "1.0.0-rc1" string drifted six releases +# behind reality and surprised users running rc12. +# A pipeline that knows the release tag passes it explicitly: the tag +# is the source of truth for a release build's stamp, not the local +# clone's git state (shallow/container checkouts may not resolve +# `git describe` - the glibc231 container leg shipped a plain +# "1.09.68" banner for exactly that reason). +if(OPENADS_VERSION_FORCE) + set(OPENADS_VERSION_STR "${OPENADS_VERSION_FORCE}") +else() +set(OPENADS_VERSION_STR "${PROJECT_VERSION}") +find_package(Git QUIET) +if(GIT_FOUND AND EXISTS "${CMAKE_SOURCE_DIR}/.git") + execute_process( + COMMAND ${GIT_EXECUTABLE} describe --tags --always --dirty + WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} + OUTPUT_VARIABLE _git_describe + OUTPUT_STRIP_TRAILING_WHITESPACE + ERROR_QUIET + RESULT_VARIABLE _git_rc) + if(_git_rc EQUAL 0 AND NOT _git_describe STREQUAL "") + # Strip a leading "v" so tags v1.0.0-rc13 surface as 1.0.0-rc13. + string(REGEX REPLACE "^v" "" OPENADS_VERSION_STR "${_git_describe}") + string(REGEX REPLACE "-dirty$" "" OPENADS_VERSION_STR "${OPENADS_VERSION_STR}") + endif() +endif() +endif() +message(STATUS "OpenADS version: ${OPENADS_VERSION_STR}") +# Deliver the version through a generated header instead of a global +# compile definition: a new commit used to change the command line of +# EVERY translation unit (add_compile_definitions), forcing a full +# 340-TU rebuild on the next build. configure_file() is +# copy-if-different, so the header's timestamp only moves when the +# version string really changes and only its ~7 consumers recompile. +configure_file( + "${CMAKE_SOURCE_DIR}/cmake/openads_version.h.in" + "${CMAKE_BINARY_DIR}/generated/openads_version.h" + @ONLY) +include_directories("${CMAKE_BINARY_DIR}/generated") + +set(CMAKE_CXX_STANDARD 17) +set(CMAKE_CXX_STANDARD_REQUIRED ON) +set(CMAKE_CXX_EXTENSIONS OFF) + +# openads_core is a STATIC library that gets linked into both an +# executable (openads_serverd, tests) and a SHARED library +# (openads_ace -> ace64.dll / libace64.so). On Linux / macOS the +# shared-library link path requires every translation unit pulled +# in to be position-independent, otherwise we hit +# `R_X86_64_TPOFF32 ... can not be used when making a shared +# object; recompile with -fPIC`. Force PIC globally ÔÇö Windows +# silently ignores the flag, so this is a no-op there. +set(CMAKE_POSITION_INDEPENDENT_CODE ON) + +if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) + set(CMAKE_BUILD_TYPE Release CACHE STRING "" FORCE) +endif() + +option(OPENADS_BUILD_TESTS "Build OpenADS unit tests" ON) +option(OPENADS_WARNINGS_AS_ERRORS "Treat warnings as errors" ON) + +# M12.12 ÔÇö real TLS transport via vendored mbedtls (Apache-2.0). +# Off by default so existing builds don't pull a network dependency +# at configure time. When ON, FetchContent downloads mbedtls 3.6 LTS +# at configure time and links it into openads_core; AdsConnect60 +# with a `tls://` URI then opens a TlsTransport instead of returning +# AE_FUNCTION_NOT_AVAILABLE. +option(OPENADS_WITH_TLS "Enable TLS transport (vendors mbedtls 3.6 LTS via FetchContent)" OFF) + +# studio.web ÔÇö embedded HTTP web console inside openads_serverd. +# When ON, serverd gains a `--http-port N` flag that exposes a +# single-page admin UI + REST API on top of the running engine, +# so the database can be administered from any browser on the LAN +# without installing a desktop client. Vendors cpp-httplib (MIT) +# and nlohmann/json (MIT) at configure time via FetchContent. +option(OPENADS_WITH_HTTP "Enable embedded HTTP web console (Studio) in openads_serverd / ace64.dll" ON) + +# 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) +# Build the OpenADS ODBC *driver* (provider) DLL so external ODBC clients +# (pyodbc, PDO_ODBC, Power BI, Excel, ...) can connect TO OpenADS. Windows only. +option(OPENADS_BUILD_ODBC_DRIVER "Build the OpenADS ODBC driver DLL (Windows)" OFF) +# Native MS SQL Server / TDS 7.4 backend. Requires OPENADS_WITH_TLS=ON +# (uses mbedtls for the TLS layer that SQL Server mandates on port 1433 +# since MSSQL 2022 / Azure SQL). No external library is downloaded beyond +# mbedtls which OPENADS_WITH_TLS already fetches. OFF by default. +option(OPENADS_WITH_MSSQL "Enable native MS SQL Server / TDS backend" OFF) +# Opt-in: build the SQLite backend from the SQLite3 Multiple Ciphers +# amalgamation (SQLCipher-compatible encryption at rest, self-contained ÔÇö no +# external OpenSSL) instead of plain SQLite. Vendored under +# third_party/sqlcipher/ (not committed ÔÇö see .gitignore). +option(OPENADS_WITH_SQLCIPHER "Build the SQL backend with SQLCipher-compatible encryption (SQLite3 Multiple Ciphers)" OFF) +if(OPENADS_WITH_SQLCIPHER) + set(OPENADS_WITH_SQLITE ON CACHE BOOL "" FORCE) +endif() +# Optional native Firebird table driver (libfbclient; embedded .fdb in-process +# or TCP server). OFF by default. Requires the Firebird client SDK +# (fbclient + ibase.h) discoverable via FIREBIRD_ROOT. +option(OPENADS_WITH_FIREBIRD "Enable the native Firebird-backed table driver (libfbclient)" OFF) +# hbnetio Bridge - VFS adapter, distributed lock manager, RPC bridge +# Bridges harbour hbnetio virtual file system and RPC capabilities +# with OpenADS network transport layer. +option(OPENADS_WITH_HBNETIO_BRIDGE "Build the hbnetio VFS/RPC bridge for OpenADS" OFF) +# Server-side Harbour UDFs: embed hbvm and load a developer .hrb +# module whose functions become available to index expressions +# (LetoDB letoudf.hrb pattern — canonical upstream is Kresin's +# LetoDB, see docs/server-udf-hrb-design.md). OFF by default: +# needs a Harbour SDK (headers + hbvm/hbrtl libs). Core stays free +# of Harbour symbols by design (provider interface in +# engine/hrb_udf.*); the Harbour backend links into openads_serverd +# and unit tests only. +option(OPENADS_WITH_HARBOUR_UDF "Embed hbvm and load a developer .hrb module for server-side index-expression UDFs" OFF) +set(HARBOUR_INCLUDE_DIR "" CACHE PATH "Harbour include dir (hbvm.h) for HRB UDF support") +set(HARBOUR_LIB_DIR "" CACHE PATH "Harbour library dir for HRB UDF support") +set(OPENADS_HARBOUR_LIBS "hbvmmt;hbrtl;hbmacro;hbpcre;hblang;hbcpage;hbzlib;hbrdd;rddfpt;rddntx;hbsix;hbcommon" CACHE STRING "Harbour libraries to link (in order) for HRB UDF support") +if(OPENADS_WITH_HARBOUR_UDF) + if(NOT HARBOUR_INCLUDE_DIR) + find_path(HARBOUR_INCLUDE_DIR NAMES hbvm.h + HINTS $ENV{HB_ROOT}/include C:/harbour-git/include C:/harbour/include + PATH_SUFFIXES harbour) + endif() + if(NOT HARBOUR_INCLUDE_DIR) + message(FATAL_ERROR "OPENADS_WITH_HARBOUR_UDF=ON but hbvm.h not found (set HARBOUR_INCLUDE_DIR)") + endif() + set(HARBOUR_LIBRARIES "") + foreach(_hb ${OPENADS_HARBOUR_LIBS}) + # NOTE: the if() below must name the variable INDIRECTLY + # (if(NOT ${_hb_var})); testing the expanded path value + # instead is always true in CMake and would FATAL every time. + set(_hb_var "HARBOUR_LIB_${_hb}") + find_library(${_hb_var} NAMES ${_hb} + HINTS ${HARBOUR_LIB_DIR} $ENV{HB_ROOT}/lib + PATH_SUFFIXES linux/gcc) + if(NOT ${_hb_var}) + message(FATAL_ERROR "OPENADS_WITH_HARBOUR_UDF=ON but Harbour lib '${_hb}' not found (set HARBOUR_LIB_DIR)") + endif() + list(APPEND HARBOUR_LIBRARIES ${${_hb_var}}) + endforeach() + if(WIN32) + # hbrtl's GT system references the platform default GT driver + # (see Harbour rtl/gtsys.c HB_GT_REQUEST: WIN on Windows, TRM + # on Unix) — link exactly that one. + set(_hb_gt gtwin) + else() + set(_hb_gt gttrm) + endif() + find_library(HARBOUR_LIB_gt NAMES ${_hb_gt} + HINTS ${HARBOUR_LIB_DIR} $ENV{HB_ROOT}/lib + PATH_SUFFIXES linux/gcc) + if(NOT HARBOUR_LIB_gt) + message(FATAL_ERROR "OPENADS_WITH_HARBOUR_UDF=ON but Harbour GT driver '${_hb_gt}' not found (set HARBOUR_LIB_DIR)") + endif() + list(APPEND HARBOUR_LIBRARIES ${HARBOUR_LIB_gt}) + # Harbour archives reference each other in cycles (hbrtl<->hbvm, + # GT back-refs) while GNU ld resolves single-pass — group them so + # every static reference settles. (MSVC iterates archives itself; + # Apple ld accepts no groups.) Deliberately NOT whole-archive: + # runtime-resolved Harbour names are covered explicitly instead + # (our own init table publishes HB_HRB*, see hrb_harbour.cpp; + # UDF names self-register from the loaded .hrb), keeping the + # binary and its static-init surface minimal. + set(HARBOUR_LINK_LIBRARIES "") + if(NOT MSVC AND NOT APPLE) + list(APPEND HARBOUR_LINK_LIBRARIES "-Wl,--start-group") + endif() + list(APPEND HARBOUR_LINK_LIBRARIES ${HARBOUR_LIBRARIES}) + if(NOT MSVC AND NOT APPLE) + list(APPEND HARBOUR_LINK_LIBRARIES "-Wl,--end-group") + endif() + if(WIN32) + list(APPEND HARBOUR_LINK_LIBRARIES winmm iphlpapi) + else() + # gttrm rides on ncurses (libncurses pulls libtinfo). + list(APPEND HARBOUR_LINK_LIBRARIES ncurses) + endif() + message(STATUS "OpenADS: Harbour UDF backend (${HARBOUR_LIBRARIES})") +endif() + +# Static ACE library (libopenace32.a / libopenace64.a) for Harbour + +# MinGW: an hbmk2 -comp=mingw[64] app links the whole ACE client+engine +# into its .exe, so no ace32.dll / ace64.dll has to be deployed. +# MinGW-on-Windows only (the MSVC static-lib story is a separate one). +option(OPENADS_BUILD_ACE_STATIC "Build the static ACE library for Harbour/MinGW" ON) + +# Pull in mbedtls FIRST so our strict /WX -Werror flags don't bleed +# into the upstream sources (C4200 zero-sized arrays etc). Each +# OpenADS target adds the strict flags target-locally below via +# `apply_openads_warnings()`. +if(OPENADS_WITH_TLS) + include(FetchContent) + set(ENABLE_TESTING OFF CACHE BOOL "" FORCE) + set(ENABLE_PROGRAMS OFF CACHE BOOL "" FORCE) + set(MBEDTLS_FATAL_WARNINGS OFF CACHE BOOL "" FORCE) + # Force a STATIC mbedtls so the OpenADS DLL / server binary + # has no runtime dependency on a system OpenSSL. Reported by + # downstream users: a release ZIP without those system DLLs + # fails to launch on a clean Windows / macOS box. Static + # linking sidesteps that whole class of "missing libssl/ + # libcrypto" deployment errors. + set(USE_STATIC_MBEDTLS_LIBRARY ON CACHE BOOL "" FORCE) + set(USE_SHARED_MBEDTLS_LIBRARY OFF CACHE BOOL "" FORCE) + set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) + # Don't let the vendored, statically-linked mbedtls add its own install() + # rules / programs to our package (CPack would otherwise ship mbedtls + # headers + .cmake config). We only link it in. + set(ENABLE_PROGRAMS OFF CACHE BOOL "" FORCE) + set(ENABLE_TESTING OFF CACHE BOOL "" FORCE) + set(MBEDTLS_INSTALL OFF CACHE BOOL "" FORCE) + FetchContent_Declare( + mbedtls + GIT_REPOSITORY https://github.com/Mbed-TLS/mbedtls.git + GIT_TAG v3.6.2 + GIT_SHALLOW TRUE + GIT_SUBMODULES framework + GIT_SUBMODULES_RECURSE TRUE + ) + FetchContent_MakeAvailable(mbedtls) + message(STATUS "OpenADS: TLS enabled via vendored mbedtls 3.6.2 (statically linked)") +endif() + +if(OPENADS_WITH_HTTP) + include(FetchContent) + # cpp-httplib auto-detects OpenSSL / zlib / brotli on the host + # and pulls them in as link libraries on its INTERFACE target. + # That collides with the OpenADS no-runtime-DLL deployment + # promise (the shipped DLL must not depend on libssl / + # libcrypto). Force-disable the auto-link so Linux/macOS + # builds match the Windows side: plain HTTP only, no system + # OpenSSL pulled into ace64.dll / libace64.so. + set(HTTPLIB_USE_OPENSSL_IF_AVAILABLE OFF CACHE BOOL "" FORCE) + set(HTTPLIB_USE_ZLIB_IF_AVAILABLE OFF CACHE BOOL "" FORCE) + set(HTTPLIB_USE_BROTLI_IF_AVAILABLE OFF CACHE BOOL "" FORCE) + set(HTTPLIB_REQUIRE_OPENSSL OFF CACHE BOOL "" FORCE) + # Vendored statically into ace64.dll / serverd ÔÇö don't let httplib or + # nlohmann/json export their headers and *Config.cmake into our package. + set(HTTPLIB_INSTALL OFF CACHE BOOL "" FORCE) + set(JSON_Install OFF CACHE BOOL "" FORCE) + FetchContent_Declare( + cpp_httplib + URL https://github.com/yhirose/cpp-httplib/archive/refs/tags/v0.18.5.tar.gz + URL_HASH SHA256=731190e97acd63edce57cc3dacd496f57e7743bfc7933da7137cb3e93ec6c9a0 + ) + FetchContent_Declare( + nlohmann_json + URL https://github.com/nlohmann/json/archive/refs/tags/v3.11.3.tar.gz + URL_HASH SHA256=0d8ef5af7f9794e3263480193c491549b2ba6cc74bb018906202ada498a79406 + ) + set(JSON_BuildTests OFF CACHE INTERNAL "") + FetchContent_MakeAvailable(cpp_httplib nlohmann_json) + message(STATUS "OpenADS: HTTP web console enabled (cpp-httplib + nlohmann/json)") +endif() + +# Server-side ZIP/UNZIP (backup/restore archiving under --data): +# zlib + classic minizip (ZipCrypto-compatible, what xBase zip tools +# read). Unconditional — small, needed on every leg. Vendored under +# third_party/zlib/ when present (offline builds); else fetched. +# Upstream C, so warnings are off for these TUs (same treatment as +# the SQLite amalgamation below). +set(_openads_zlib_dir "${CMAKE_SOURCE_DIR}/third_party/zlib") +# zlib 1.3.1 registers example/example64 ctest tests (test/example.c). +# CI builds only --target dbf_cdx_bench openads_unit_tests, so those +# binaries are never built and ctest reports "Not Run" -> exit 8/1 on +# every leg (v1.09.60 CI red, release green because it builds all). +# We never ship the examples: keep them out of the tree entirely. +set(ZLIB_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) +if(NOT EXISTS "${_openads_zlib_dir}/zlib.h") + include(FetchContent) + FetchContent_Declare( + zlib_src + URL https://github.com/madler/zlib/releases/download/v1.3.1/zlib-1.3.1.tar.gz + URL_HASH SHA256=9a93b2b7dfdac77ceba5a558a580e74667dd6fede4585b91eefb60f03b72df23 + ) + set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) + set(SKIP_INSTALL_ALL ON CACHE BOOL "" FORCE) + FetchContent_MakeAvailable(zlib_src) + set(_openads_zlib_dir "${zlib_src_SOURCE_DIR}") + set(_openads_zlib_bin "${zlib_src_BINARY_DIR}") + set(_openads_zlib_target zlibstatic) +else() + # Offline layout: build the vendored tree into a fixed binary dir + # (zconf.h is generated there by zlib's own CMake). + set(_openads_zlib_bin "${CMAKE_BINARY_DIR}/_deps/zlib-build") + set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) + set(SKIP_INSTALL_ALL ON CACHE BOOL "" FORCE) + add_subdirectory("${_openads_zlib_dir}" "${_openads_zlib_bin}") + set(_openads_zlib_target zlibstatic) +endif() +add_library(openads_minizip STATIC + "${CMAKE_SOURCE_DIR}/third_party/minizip/zip.c" + "${CMAKE_SOURCE_DIR}/third_party/minizip/unzip.c" + "${CMAKE_SOURCE_DIR}/third_party/minizip/ioapi.c") +target_include_directories(openads_minizip SYSTEM PUBLIC + "${CMAKE_SOURCE_DIR}/third_party/minizip" + "${_openads_zlib_dir}" + "${_openads_zlib_bin}") +if(MSVC) + target_compile_options(openads_minizip PRIVATE /w) +else() + target_compile_options(openads_minizip PRIVATE -w) +endif() +target_link_libraries(openads_minizip PUBLIC ${_openads_zlib_target}) +message(STATUS "OpenADS: server-side ZIP support (zlib + minizip)") + +if(OPENADS_WITH_SQLITE) + if(OPENADS_WITH_SQLCIPHER) + # SQLite3 Multiple Ciphers amalgamation ÔÇö SQLCipher-compatible AES at + # rest, self-contained. Vendored under third_party/sqlcipher/. + set(_openads_sqlite_src "${CMAKE_SOURCE_DIR}/third_party/sqlcipher/sqlite3mc_amalgamation.c") + set(_openads_sqlite_inc "${CMAKE_SOURCE_DIR}/third_party/sqlcipher") + if(NOT EXISTS "${_openads_sqlite_src}") + message(FATAL_ERROR + "OPENADS_WITH_SQLCIPHER=ON needs the SQLite3 Multiple Ciphers " + "amalgamation at third_party/sqlcipher/sqlite3mc_amalgamation.c") + endif() + message(STATUS "OpenADS: SQL backend with SQLCipher-compatible encryption (SQLite3 Multiple Ciphers)") + else() + # Use a locally vendored amalgamation under third_party/sqlite/ when + # present (offline / air-gapped builds); otherwise fetch it. The + # amalgamation is not committed ÔÇö see .gitignore. + set(_openads_sqlite_src "${CMAKE_SOURCE_DIR}/third_party/sqlite/sqlite3.c") + set(_openads_sqlite_inc "${CMAKE_SOURCE_DIR}/third_party/sqlite") + if(NOT EXISTS "${_openads_sqlite_src}") + include(FetchContent) + FetchContent_Declare( + sqlite_amalgamation + URL https://www.sqlite.org/2024/sqlite-amalgamation-3460100.zip + URL_HASH SHA256=77823cb110929c2bcb0f5d48e4833b5c59a8a6e40cdea3936b99e199dbbe5784 + ) + FetchContent_MakeAvailable(sqlite_amalgamation) + set(_openads_sqlite_src "${sqlite_amalgamation_SOURCE_DIR}/sqlite3.c") + set(_openads_sqlite_inc "${sqlite_amalgamation_SOURCE_DIR}") + endif() + endif() + add_library(openads_sqlite3 STATIC "${_openads_sqlite_src}") + target_include_directories(openads_sqlite3 SYSTEM PUBLIC "${_openads_sqlite_inc}") + target_compile_definitions(openads_sqlite3 PRIVATE + SQLITE_THREADSAFE=1 + SQLITE_DEFAULT_MEMSTATUS=0) + if(MSVC) + target_compile_options(openads_sqlite3 PRIVATE /wd4267 /wd4244) + else() + target_compile_options(openads_sqlite3 PRIVATE -Wno-conversion) + endif() + 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(NOT _openads_maria_inc OR NOT _openads_maria_lib) + find_path(_openads_maria_inc mysql.h + PATH_SUFFIXES mariadb mysql) + find_library(_openads_maria_lib NAMES mariadb libmariadb mariadbclient) + 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) + # /MP: compile TUs of each project in parallel (one cl.exe per core). + # Without it a full rebuild of the ~340-TU tree runs serially. + add_compile_options(/W4 /permissive- /MP) + 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; + # harmless on Linux/macOS where this define has no effect. + add_compile_definitions(_CRT_SECURE_NO_WARNINGS) + if(OPENADS_WARNINGS_AS_ERRORS) + add_compile_options(-Werror) + endif() +endif() + +add_subdirectory(src) +if(OPENADS_BUILD_ODBC_DRIVER AND WIN32) + add_subdirectory(bindings/odbc) +endif() +add_subdirectory(tools/serverd) +add_subdirectory(tools/adsbackup) +add_subdirectory(tools/bench) +add_subdirectory(tools/stress) +add_subdirectory(tools/mgprobe) +add_subdirectory(tools/import_dd) +add_subdirectory(tools/adi_inspect) + +if(OPENADS_BUILD_TESTS) + enable_testing() + add_subdirectory(tests) +endif() + +# --------------------------------------------------------------------------- +# Install rules + CPack ÔÇö produce a structured, reproducible package with the +# same shape the release.yml staging assembles by hand (engine DLL + drop-in +# ACE-named copy, import libs, server/bench CLIs, headers, docs). Purely +# additive: a plain `cmake --build` is unaffected; `cmake --install` / +# `cpack` are opt-in. +# --------------------------------------------------------------------------- +include(GNUInstallDirs) + +# Windows packages are traditionally flat (drop ace64.dll next to the .exe), +# matching the existing release zip and QUICKSTART; POSIX follows the GNU +# layout so the systemd/DEB story lands binaries under bin/ and lib/. +if(WIN32) + set(OA_INSTALL_BIN ".") + set(OA_INSTALL_LIB "lib") + set(OA_INSTALL_DOC ".") +else() + set(OA_INSTALL_BIN "${CMAKE_INSTALL_BINDIR}") + set(OA_INSTALL_LIB "${CMAKE_INSTALL_LIBDIR}") + set(OA_INSTALL_DOC "${CMAKE_INSTALL_DOCDIR}") +endif() + +if(CMAKE_SIZEOF_VOID_P EQUAL 8) + set(OA_BITS "64") + set(OA_ARCH "x64") +else() + set(OA_BITS "32") + set(OA_ARCH "x86") +endif() + +# Engine shared library (openace64/openace32) + its import lib. +install(TARGETS openads_ace + RUNTIME DESTINATION "${OA_INSTALL_BIN}" + LIBRARY DESTINATION "${OA_INSTALL_BIN}" + ARCHIVE DESTINATION "${OA_INSTALL_LIB}") + +# Drop-in-named copy so apps that load OpenADS by the canonical ACE name +# (ace64.dll / libace64.so / libace64.dylib) work without renaming anything. +if(WIN32) + install(FILES "$" + DESTINATION "${OA_INSTALL_BIN}" RENAME "ace${OA_BITS}.dll") +elseif(APPLE) + install(FILES "$" + DESTINATION "${OA_INSTALL_BIN}" RENAME "libace${OA_BITS}.dylib") +else() + install(FILES "$" + DESTINATION "${OA_INSTALL_BIN}" RENAME "libace${OA_BITS}.so") +endif() + +# Server + bench + backup CLIs. +install(TARGETS openads_serverd openads_bench adsbackup + RUNTIME DESTINATION "${OA_INSTALL_BIN}") + +# Public ACE headers. +install(DIRECTORY "${CMAKE_SOURCE_DIR}/include/" + DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}") + +# License + docs. +install(FILES "${CMAKE_SOURCE_DIR}/LICENSE" + "${CMAKE_SOURCE_DIR}/NOTICE" + "${CMAKE_SOURCE_DIR}/README.md" + DESTINATION "${OA_INSTALL_DOC}") + +# Prebuilt per-compiler import libraries (MSVC / MinGW / Borland), Windows. +if(WIN32 AND EXISTS "${CMAKE_SOURCE_DIR}/dist/import-libs/${OA_ARCH}") + install(DIRECTORY "${CMAKE_SOURCE_DIR}/dist/import-libs/${OA_ARCH}/" + DESTINATION "${OA_INSTALL_LIB}") +endif() + +# Service unit templates (handy for unattended deploys). +install(FILES "${CMAKE_SOURCE_DIR}/tools/scripts/systemd/openads-serverd.service" + "${CMAKE_SOURCE_DIR}/tools/scripts/launchd/com.openads.serverd.plist" + DESTINATION "${OA_INSTALL_DOC}/service" OPTIONAL) + +# Onboarding files that land with sibling PRs (#88 config template, #90 kit). +# OPTIONAL keeps this PR self-contained against upstream/main ÔÇö they install +# automatically once those merge. +install(FILES "${CMAKE_SOURCE_DIR}/openads.ini.sample" + "${CMAKE_SOURCE_DIR}/QUICKSTART.md" + "${CMAKE_SOURCE_DIR}/openads-studio.sh" + "${CMAKE_SOURCE_DIR}/openads-studio.bat" + DESTINATION "${OA_INSTALL_BIN}" OPTIONAL) + +# --- CPack ------------------------------------------------------------------ +set(CPACK_PACKAGE_NAME "openads") +set(CPACK_PACKAGE_VENDOR "OpenADS") +set(CPACK_PACKAGE_VERSION "${OPENADS_VERSION_STR}") +set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "${PROJECT_DESCRIPTION}") +set(CPACK_PACKAGE_HOMEPAGE_URL "https://github.com/FiveTechSoft/OpenADS") +set(CPACK_RESOURCE_FILE_LICENSE "${CMAKE_SOURCE_DIR}/LICENSE") +set(CPACK_VERBATIM_VARIABLES ON) +if(WIN32) + set(_oa_os "windows") +elseif(APPLE) + set(_oa_os "macos") +else() + set(_oa_os "linux") +endif() +set(CPACK_PACKAGE_FILE_NAME "openads-${OPENADS_VERSION_STR}-${_oa_os}-${OA_ARCH}") +if(WIN32) + set(CPACK_GENERATOR "ZIP") +elseif(APPLE) + set(CPACK_GENERATOR "TGZ") +else() + set(CPACK_GENERATOR "TGZ;DEB") + set(CPACK_DEBIAN_PACKAGE_MAINTAINER "OpenADS") + set(CPACK_DEBIAN_PACKAGE_SECTION "database") +endif() +include(CPack) From 381f043bfca255fefe21b41d8502e30bccd37c26 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Fri, 25 Sep 2026 22:32:58 -0500 Subject: [PATCH 42/97] Create release-notes-1.09.68-mtfix12.md --- release-notes-1.09.68-mtfix12.md | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 release-notes-1.09.68-mtfix12.md diff --git a/release-notes-1.09.68-mtfix12.md b/release-notes-1.09.68-mtfix12.md new file mode 100644 index 00000000..8be0449e --- /dev/null +++ b/release-notes-1.09.68-mtfix12.md @@ -0,0 +1,23 @@ +Built for Pritpal Bedi - bedipritpal/OpenADS, forked from FiveTechSoft/OpenADS. + +## v1.09.68-mtfix12 - navigation fusion: boundary pairs, self-goto suppression, record-number anchoring + +One voucher save replayed from the mtfix11 production trace: 893 wire round trips. This release takes 155 of them off the wire in the default configuration (-17%), up to 201 (-23%) with the opt-in below. At the WAN rate he measured (~40 ms/RTT) that is about 6 s off the 38.5 s run by default, about 8.5 s with the opt-in. Zero behavior change: every suppressed call answers from state the server already certified, under the same freshness envelope the shipped duplicate-suppression uses. + +### 1. Boundary-pair certification (protocol extension, caps-gated) +GotoTop and GotoBottom requests can now ask the server to certify BOTH boundaries in one visit (new capability bit kCapNavBoundaryPair; old clients and old servers interoperate unchanged - the byte is only sent when both sides advertise it). The app's dbGoTop(); dbGoBottom() ritual - 102 adjacent pairs in the trace - now costs one frame instead of two. Bonus: top and bottom read in the same server visit are more consistent with each other than two separate frames ever were. + +### 2. Self-GotoRecord suppression +Re-issuing GotoRecord for the record the server cursor already sits on (the xBrowse bookmark-restore pattern - 89 of 126 GotoRecords in the trace are self-gotos) now answers locally. The default envelope is connection-wide (53 served in the replay); setting OPENADS_NAV_SELF_GOTO=table widens freshness to per-table (96 served in the replay) for apps that want the extra savings. + +### 3. GetRecordNum from the certified cursor +AdsGetRecordNum answers from the server-certified cursor anchor when the freshness envelope holds, instead of asking the server where it already told us it is. + +### Validation +- Full unit suite: 1588/1591 green; the 3 failures are the known pre-existing flaky tests that fail identically on the clean tree (mt contention, openindex race, create storm). +- New boundary-pair unit tests (82 assertions): pair serve in both directions, write invalidation, cross-table expiry, self-goto conditions, record-number anchor. +- Analytical replay of the mtfix11 production trace with the shipped invalidation rules: -102 (boundary pairs), -53 default / -96 opt-in (self-goto), GetRecordNum serves on top. Predicted wire totals: 738 default, 692 with OPENADS_NAV_SELF_GOTO=table (from 893). +- The replay caught one real bug during validation: the self-goto serve initially kept the prefetch queue a wire GotoRecord would have cleared; fixed, covered by the ramp test. + +### A/B +Same harness as mtfix9-11: wire_trace=1 in openads.ini (or OPENADS_WIRE_TRACE=1), OPENADS_WIRE_TRACE_FILE picks the log path. Run the voucher save on the mtfix11 build, then on mtfix12, count ` wire ` lines. Expect ~17% fewer by default, ~23% with OPENADS_NAV_SELF_GOTO=table; zero errors either way. From a120c7a68569084c71e6cf84c16f01d2c7d25c3a Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Fri, 25 Sep 2026 22:33:35 -0500 Subject: [PATCH 43/97] Delete 11-release-notes-1.09.68-mtfix12.md --- 11-release-notes-1.09.68-mtfix12.md | 23 ----------------------- 1 file changed, 23 deletions(-) delete mode 100644 11-release-notes-1.09.68-mtfix12.md diff --git a/11-release-notes-1.09.68-mtfix12.md b/11-release-notes-1.09.68-mtfix12.md deleted file mode 100644 index 8be0449e..00000000 --- a/11-release-notes-1.09.68-mtfix12.md +++ /dev/null @@ -1,23 +0,0 @@ -Built for Pritpal Bedi - bedipritpal/OpenADS, forked from FiveTechSoft/OpenADS. - -## v1.09.68-mtfix12 - navigation fusion: boundary pairs, self-goto suppression, record-number anchoring - -One voucher save replayed from the mtfix11 production trace: 893 wire round trips. This release takes 155 of them off the wire in the default configuration (-17%), up to 201 (-23%) with the opt-in below. At the WAN rate he measured (~40 ms/RTT) that is about 6 s off the 38.5 s run by default, about 8.5 s with the opt-in. Zero behavior change: every suppressed call answers from state the server already certified, under the same freshness envelope the shipped duplicate-suppression uses. - -### 1. Boundary-pair certification (protocol extension, caps-gated) -GotoTop and GotoBottom requests can now ask the server to certify BOTH boundaries in one visit (new capability bit kCapNavBoundaryPair; old clients and old servers interoperate unchanged - the byte is only sent when both sides advertise it). The app's dbGoTop(); dbGoBottom() ritual - 102 adjacent pairs in the trace - now costs one frame instead of two. Bonus: top and bottom read in the same server visit are more consistent with each other than two separate frames ever were. - -### 2. Self-GotoRecord suppression -Re-issuing GotoRecord for the record the server cursor already sits on (the xBrowse bookmark-restore pattern - 89 of 126 GotoRecords in the trace are self-gotos) now answers locally. The default envelope is connection-wide (53 served in the replay); setting OPENADS_NAV_SELF_GOTO=table widens freshness to per-table (96 served in the replay) for apps that want the extra savings. - -### 3. GetRecordNum from the certified cursor -AdsGetRecordNum answers from the server-certified cursor anchor when the freshness envelope holds, instead of asking the server where it already told us it is. - -### Validation -- Full unit suite: 1588/1591 green; the 3 failures are the known pre-existing flaky tests that fail identically on the clean tree (mt contention, openindex race, create storm). -- New boundary-pair unit tests (82 assertions): pair serve in both directions, write invalidation, cross-table expiry, self-goto conditions, record-number anchor. -- Analytical replay of the mtfix11 production trace with the shipped invalidation rules: -102 (boundary pairs), -53 default / -96 opt-in (self-goto), GetRecordNum serves on top. Predicted wire totals: 738 default, 692 with OPENADS_NAV_SELF_GOTO=table (from 893). -- The replay caught one real bug during validation: the self-goto serve initially kept the prefetch queue a wire GotoRecord would have cleared; fixed, covered by the ramp test. - -### A/B -Same harness as mtfix9-11: wire_trace=1 in openads.ini (or OPENADS_WIRE_TRACE=1), OPENADS_WIRE_TRACE_FILE picks the log path. Run the voucher save on the mtfix11 build, then on mtfix12, count ` wire ` lines. Expect ~17% fewer by default, ~23% with OPENADS_NAV_SELF_GOTO=table; zero errors either way. From c40f016c30c2f573ea60438915475eafd1db097e Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Fri, 25 Sep 2026 23:01:43 -0500 Subject: [PATCH 44/97] ci: make tag resolvable in glibc231 container (safe.directory + fetch tags) so the version stamp suffix survives --- .github/workflows/release.yml | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3f0ab5dc..ead21b12 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -308,6 +308,21 @@ jobs: - uses: actions/checkout@v5 with: ref: ${{ inputs.tag || github.ref }} + # Full history + tags: the version stamp falls back to + # `git describe --tags`, and a shallow tag checkout can leave + # the tag unresolvable to the container's older git. + fetch-depth: 0 + + # The container's git refuses the runner-owned workspace ("dubious + # ownership"), which silently broke `git describe --tags` and shipped + # a plain-version stamp on the 20.04 asset. Mark it safe and re-fetch + # the tag refs so describe resolves even for a tag checkout. + - name: Make the tag resolvable to git in the container + shell: bash + run: | + git config --global --add safe.directory "$GITHUB_WORKSPACE" + git fetch --tags --force origin + git describe --tags - name: Resolve tag id: tag @@ -390,6 +405,7 @@ jobs: shell: bash run: | cmake --preset ninja-clang \ + -DOPENADS_VERSION_FORCE=${{ steps.tag.outputs.version }} \ -DCMAKE_C_COMPILER=gcc-10 \ -DCMAKE_CXX_COMPILER=g++-10 \ -DOPENADS_WITH_TLS=ON \ From a6cf6391172dcaf0a327f2cd984ba45de0241583 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898283+github-actions[bot]@users.noreply.github.com> Date: Sat, 26 Sep 2026 05:36:43 +0000 Subject: [PATCH 45/97] mtfix12: nav fusion - boundary-pair certification, self-GotoRecord suppression, GetRecordNum anchor (wire layer) Applied by apply-patch workflow, run 36220961436, started by bedipritpal. --- src/network/client.cpp | 251 +++++++++++++++++++++++++++++++++++++--- src/network/client.h | 86 ++++++++++++++ src/network/session.cpp | 155 ++++++++++++++++++++++--- src/network/session.h | 11 ++ src/network/wire.h | 21 ++++ 5 files changed, 492 insertions(+), 32 deletions(-) diff --git a/src/network/client.cpp b/src/network/client.cpp index be096b84..2fbb7b8b 100644 --- a/src/network/client.cpp +++ b/src/network/client.cpp @@ -154,7 +154,7 @@ std::size_t parse_row_trailer_into(RemoteTable* rt, // Returns the offset where trailer parsing stopped, so callers // can read trailing sections (reposition-bound piggyback). if (rt == nullptr || pos >= pl.size()) { - if (rt) rt->row_valid = false; + if (rt) { rt->row_valid = false; rt->anchor_ok = false; } return pos; } rt->prefetch_queue.clear(); @@ -165,6 +165,9 @@ std::size_t parse_row_trailer_into(RemoteTable* rt, std::uint8_t has_row = pl[pos++]; if (has_row == 0) { rt->row_valid = false; + // Landed on no row: the server cursor is at a phantom, which + // certifies no recno anchor for the R2 self-goto check. + rt->anchor_ok = false; // Lookahead count is always 0 when has_row=0, but the 2 bytes // are still on the wire: consume them so the returned offset // points past the trailer (trailing sections key off it). @@ -174,10 +177,19 @@ std::size_t parse_row_trailer_into(RemoteTable* rt, auto end = parse_one_row(pl, pos, rt->current_recno, rt->current_deleted, rt->current_row); if (end == static_cast(-1)) { - rt->row_valid = false; return pos; + rt->row_valid = false; rt->anchor_ok = false; return pos; } pos = end; rt->row_valid = true; + // mtfix12 R2 — every server-shipped landing certifies the cursor + // anchor: the server cursor IS current_recno right now (the ack + // contract resets cursor_lag to 0 above). Serves self-GotoRecord + // and GetRecordNum while the freshness seq holds. + rt->anchor_recno = rt->current_recno; + rt->anchor_ok = true; + rt->anchor_seq = rt->conn != nullptr ? rt->conn->nav_seq() : 0; + rt->anchor_tbl_seq = + rt->conn != nullptr ? rt->conn->tbl_nav_seq(rt->id) : 0; // M12.21 lookahead block. [u16 count] then count rows. if (pos + 2 > pl.size()) return pos; // M12.18 server (no lookahead) — done. std::uint16_t la = read_u16_le(&pl[pos]); pos += 2; @@ -243,6 +255,28 @@ void set_frame_trace_hook(FrameTraceHook hook) noexcept { g_frame_trace_hook.store(hook, std::memory_order_relaxed); } +std::uint64_t RemoteConnection::tbl_nav_seq(std::uint32_t id) { + std::lock_guard lk(mu_); + const auto it = tbl_nav_seqs_.find(id); + return it != tbl_nav_seqs_.end() ? it->second : 0; +} + +std::uint64_t RemoteConnection::tbl_write_gen(std::uint32_t id) { + std::lock_guard lk(mu_); + const auto it = tbl_write_gens_.find(id); + return it != tbl_write_gens_.end() ? it->second : 0; +} + +void RemoteConnection::note_tbl_nav(std::uint32_t id) { + std::lock_guard lk(mu_); + ++tbl_nav_seqs_[id]; +} + +void RemoteConnection::note_all_tables_nav() { + std::lock_guard lk(mu_); + for (auto& [id, v] : tbl_nav_seqs_) ++v; +} + util::Result RemoteConnection::request(const Frame& f) { std::lock_guard lk(mu_); frame_seq_.fetch_add(1, std::memory_order_relaxed); @@ -256,6 +290,41 @@ util::Result RemoteConnection::request(const Frame& f) { default: break; } + // mtfix12 — per-table generations. Every frame funnels through + // here, so classifying by opcode is exhaustive by construction. + // nav: cursor/visibility-affecting (mirrors note_nav_frame per + // table; FlushTable/FlushFileBuffers intentionally excluded — + // they move no cursor, and dropping them is exactly the widening + // the per-table envelope opts into). write: content-affecting + // (row bytes may differ after it lands). Index-keyed ops + // (Seek/SeekLast/SkipUnique/SetScope/ClearScope) and the + // conn-wide ShowDeleted are bumped by their methods, which know + // the binding. + if (f.payload.size() >= 4) { + const std::uint32_t tid = read_u32_le(f.payload.data()); + switch (f.opcode) { + case Opcode::GotoTop: case Opcode::GotoBottom: + case Opcode::GotoRecord: case Opcode::Skip: + case Opcode::AppendBlank: case Opcode::PackTable: + case Opcode::ZapTable: case Opcode::Reindex: + case Opcode::SetAOF: case Opcode::ClearAOFRemote: + case Opcode::CustomizeAOF: case Opcode::SetOrder: + case Opcode::SetOrderByName: + ++tbl_nav_seqs_[tid]; + break; + default: break; + } + switch (f.opcode) { + case Opcode::AppendBlank: case Opcode::SetField: + case Opcode::SetFields: case Opcode::SetRecord: + case Opcode::DeleteRecord: case Opcode::RecallRecord: + case Opcode::PackTable: case Opcode::ZapTable: + case Opcode::Reindex: + ++tbl_write_gens_[tid]; + break; + default: break; + } + } const FrameTraceHook trace_hook = g_frame_trace_hook.load(std::memory_order_relaxed); const auto trace_t0 = trace_hook != nullptr @@ -349,7 +418,8 @@ void connect_pack_payload(std::vector& payload, // to a client that only understands forward ones (see kCapPrefetchBackward). std::uint32_t caps = kCapPrefetchConsume | kCapPrefetchBackward | kCapOpenTableMode | kCapSetFieldsBatch - | kCapFlushInCloseAll | kCapNavOrderFuse; + | kCapFlushInCloseAll | kCapNavOrderFuse + | kCapNavBoundaryPair; for (int i = 0; i < 4; ++i) payload.push_back(static_cast((caps >> (8 * i)) & 0xFFu)); } @@ -636,6 +706,7 @@ util::Result RemoteConnection::apply_open_row(RemoteTable* rt, return {}; } + util::Result RemoteConnection::close_table(std::uint32_t id) { Frame req; req.opcode = Opcode::CloseTable; @@ -698,6 +769,93 @@ static bool apply_bound_tail(RemoteTable* rt, return true; } +// mtfix12 R1 — pair-requested acks carry an explicit [u8 ack_flags] +// byte right after the row trailer so section offsets are deterministic +// (the plain length-inference in apply_bound_tail cannot tell a 6-byte +// bound followed by a pair section from a 10-byte bound). ack_flags +// bit 0x01: the main bound tail carries reccount (10 bytes). Applies +// the bound exactly like apply_bound_tail and returns the offset just +// past it. Missing/short tail: nothing applied, returns tend (the +// server declined pair entirely; no certification, wire fallback). +static std::size_t pair_ack_bound_end(RemoteTable* rt, + const std::vector& pl, + std::size_t tend) { + if (rt == nullptr || pl.size() < tend + 1) return tend; + const std::uint8_t af = pl[tend]; + const std::size_t blen = (af & 0x01) ? 10 : 6; + if (pl.size() < tend + 1 + blen) return tend; + apply_bound_trailer(rt, pl[tend + 1] != 0, pl[tend + 2] != 0, + read_u32_le(pl.data() + tend + 3), + (af & 0x01) ? read_u32_le(pl.data() + tend + 7) : 0u, + (af & 0x01) != 0); + return tend + 1 + blen; +} + +// mtfix12 R1 — boundary-pair tail (see kCapNavBoundaryPair in wire.h). +// Layout at [off, ...): +// [u8 flags][u32 trailer_len][trailer][bound 6|10][u32 keycount?] +// flags: 0x02 = bound carries reccount (10 bytes), 0x04 = keycount +// present (ordered tables). The trailer is row-trailer format +// (pack_row_trailer bytes at lookahead depth 0) describing the OPPOSITE +// boundary's landing, read by the server in the same atomic visit as +// the nav that carried it. Stored for the ABI layer to apply verbatim +// if the adjacent opposite-boundary call arrives while the conn-wide +// freshness envelope holds (same rule as remote_nav_duplicate) and no +// write touched the table since (write_gen). Absent/malformed tail = +// no certification: pair_valid stays false and the next +// opposite-boundary call goes to the wire exactly as before. +static bool apply_pair_tail(RemoteTable* rt, int opp_which, + std::uint32_t order, + const std::vector& pl, + std::size_t off) { + if (rt == nullptr || pl.size() < off + 5) return false; + const std::uint8_t flags = pl[off]; + const std::uint32_t tlen = read_u32_le(pl.data() + off + 1); + const std::size_t bound_len = (flags & 0x02) ? 10 : 6; + const std::size_t need = + 5 + static_cast(tlen) + bound_len + + ((flags & 0x04) ? 4 : 0); + if (pl.size() < off + need) return false; + std::size_t p = off + 5; + rt->pair_blob.assign(pl.begin() + static_cast(p), + pl.begin() + static_cast(p + tlen)); + p += tlen; + rt->pair_bof = pl[p] != 0; + rt->pair_eof = pl[p + 1] != 0; + rt->pair_recno = read_u32_le(pl.data() + p + 2); + rt->pair_has_count = (flags & 0x02) != 0; + rt->pair_reccount = rt->pair_has_count + ? read_u32_le(pl.data() + p + 6) : 0u; + p += bound_len; + rt->pair_has_keycount = (flags & 0x04) != 0; + rt->pair_keycount = rt->pair_has_keycount + ? read_u32_le(pl.data() + p) : 0u; + rt->pair_which = opp_which; + rt->pair_order = order; + rt->pair_seq = rt->conn != nullptr ? rt->conn->nav_seq() : 0; + rt->pair_write_gen = + rt->conn != nullptr ? rt->conn->tbl_write_gen(rt->id) : 0; + rt->pair_valid = true; + return true; +} + +util::Result RemoteConnection::apply_pair_blob(RemoteTable* rt) { + if (rt == nullptr || !rt->pair_valid) { + return util::Error{5000, 0, "apply_pair_blob: no certification", ""}; + } + // Same byte path a wire ack for the opposite boundary would take: + // the stored trailer parse re-anchors lag and resets prefetch, + // then the certified bound values land through the shared trailer + // application so every bound/recno/count stamp matches. + parse_row_trailer_into(rt, rt->pair_blob, 0); + apply_bound_trailer(rt, rt->pair_bof, rt->pair_eof, rt->pair_recno, + rt->pair_reccount, rt->pair_has_count); + if (rt->pair_has_keycount) { + rt->key_counts[rt->pair_order] = rt->pair_keycount; + } + return {}; +} + util::Result RemoteConnection::goto_top(std::uint32_t id) { note_nav_frame(); Frame req; @@ -735,6 +893,12 @@ util::Result RemoteConnection::goto_top(RemoteTable* rt) { static_cast(rt->cache_records_hint & 0xFFu)); req.payload.push_back( static_cast((rt->cache_records_hint >> 8) & 0xFFu)); + // mtfix12 R1: ask for the opposite boundary's certification (the + // back-to-back dbGoTop(); dbGoBottom() ritual). Caps-gated: old + // servers never see the byte (flag 0x02, no order section). + const bool want_pair = server_nav_boundary_pair(); + if (want_pair) req.payload.push_back(0x02); + rt->pair_valid = false; auto rep = request(req); if (!rep) return rep.error(); if (rep.value().opcode != Opcode::GotoTopAck) { @@ -744,7 +908,14 @@ util::Result RemoteConnection::goto_top(RemoteTable* rt) { parse_row_trailer_into(rt, rep.value().payload, 0); // Reposition-bound piggyback (see goto_record): trailing // [u8 bof][u8 eof][u32 recno], length-gated. - apply_bound_tail(rt, rep.value().payload, tend); + if (want_pair) { + const std::size_t off = + pair_ack_bound_end(rt, rep.value().payload, tend); + apply_pair_tail(rt, 2, rt->server_order_id, + rep.value().payload, off); + } else { + apply_bound_tail(rt, rep.value().payload, tend); + } return {}; } @@ -1125,6 +1296,10 @@ util::Result RemoteConnection::goto_bottom(RemoteTable* rt) { Frame req; req.opcode = Opcode::GotoBottom; write_u32_le(rt->id, req.payload); + // mtfix12 R1: opposite-boundary certification (see goto_top). + const bool want_pair = server_nav_boundary_pair(); + if (want_pair) req.payload.push_back(0x02); + rt->pair_valid = false; auto rep = request(req); if (!rep) return rep.error(); if (rep.value().opcode != Opcode::GotoBottomAck) { @@ -1134,7 +1309,14 @@ util::Result RemoteConnection::goto_bottom(RemoteTable* rt) { parse_row_trailer_into(rt, rep.value().payload, 0); // Reposition-bound piggyback (see goto_record): trailing // [u8 bof][u8 eof][u32 recno], length-gated. - apply_bound_tail(rt, rep.value().payload, tend); + if (want_pair) { + const std::size_t off = + pair_ack_bound_end(rt, rep.value().payload, tend); + apply_pair_tail(rt, 1, rt->server_order_id, + rep.value().payload, off); + } else { + apply_bound_tail(rt, rep.value().payload, tend); + } return {}; } @@ -1152,8 +1334,11 @@ util::Result RemoteConnection::goto_top_fused(RemoteTable* rt, static_cast(rt->cache_records_hint & 0xFFu)); req.payload.push_back( static_cast((rt->cache_records_hint >> 8) & 0xFFu)); - req.payload.push_back(0x01); + const bool want_pair = server_nav_boundary_pair(); + req.payload.push_back(static_cast( + 0x01 | (want_pair ? 0x02 : 0x00))); write_u32_le(order_id, req.payload); + rt->pair_valid = false; auto rep = request(req); if (!rep) return rep.error(); if (rep.value().opcode != Opcode::GotoTopAck) { @@ -1161,13 +1346,23 @@ util::Result RemoteConnection::goto_top_fused(RemoteTable* rt, } const std::size_t tend = parse_row_trailer_into(rt, rep.value().payload, 0); - // Reposition-bound piggyback (see goto_record): trailing - // [u8 bof][u8 eof][u32 recno](+[u32 reccount]), length-gated. - apply_bound_tail(rt, rep.value().payload, tend); - // Fused switch only: the server certified the new order's key - // count past the bound tail ([u32]). Rotation visits ask it - // next; serve from the per-order map instead of a frame. - { + if (want_pair) { + // Pair-requested fused ack: [rowtrailer][u8 ack_flags][bound] + // [u32 fused key count][pair section]. + const std::size_t off = + pair_ack_bound_end(rt, rep.value().payload, tend); + const auto& pl = rep.value().payload; + if (pl.size() >= off + 4) { + rt->key_counts[order_id] = read_u32_le(pl.data() + off); + } + apply_pair_tail(rt, 2, order_id, pl, off + 4); + } else { + // Reposition-bound piggyback (see goto_record): trailing + // [u8 bof][u8 eof][u32 recno](+[u32 reccount]), length-gated. + apply_bound_tail(rt, rep.value().payload, tend); + // Fused switch only: the server certified the new order's key + // count past the bound tail ([u32]). Rotation visits ask it + // next; serve from the per-order map instead of a frame. const auto& pl = rep.value().payload; if (pl.size() >= tend + 14) { rt->key_counts[order_id] = read_u32_le(pl.data() + tend + 10); @@ -1182,8 +1377,11 @@ util::Result RemoteConnection::goto_bottom_fused(RemoteTable* rt, Frame req; req.opcode = Opcode::GotoBottom; write_u32_le(rt->id, req.payload); - req.payload.push_back(0x01); + const bool want_pair = server_nav_boundary_pair(); + req.payload.push_back(static_cast( + 0x01 | (want_pair ? 0x02 : 0x00))); write_u32_le(order_id, req.payload); + rt->pair_valid = false; auto rep = request(req); if (!rep) return rep.error(); if (rep.value().opcode != Opcode::GotoBottomAck) { @@ -1191,11 +1389,19 @@ util::Result RemoteConnection::goto_bottom_fused(RemoteTable* rt, } const std::size_t tend = parse_row_trailer_into(rt, rep.value().payload, 0); - // Reposition-bound piggyback (see goto_record): trailing - // [u8 bof][u8 eof][u32 recno](+[u32 reccount]), length-gated. - apply_bound_tail(rt, rep.value().payload, tend); - // Fused switch only: certified key count past the bound tail. - { + if (want_pair) { + const std::size_t off = + pair_ack_bound_end(rt, rep.value().payload, tend); + const auto& pl = rep.value().payload; + if (pl.size() >= off + 4) { + rt->key_counts[order_id] = read_u32_le(pl.data() + off); + } + apply_pair_tail(rt, 1, order_id, pl, off + 4); + } else { + // Reposition-bound piggyback (see goto_record): trailing + // [u8 bof][u8 eof][u32 recno](+[u32 reccount]), length-gated. + apply_bound_tail(rt, rep.value().payload, tend); + // Fused switch only: certified key count past the bound tail. const auto& pl = rep.value().payload; if (pl.size() >= tend + 14) { rt->key_counts[order_id] = read_u32_le(pl.data() + tend + 10); @@ -2948,6 +3154,7 @@ util::Result RemoteConnection::skip_unique(std::uint32_t index_id, std::int32_t direction) { note_nav_frame(); + note_all_tables_nav(); // index-keyed, no parent resolved here Frame req; req.opcode = Opcode::SkipUnique; write_u32_le(index_id, req.payload); write_u32_le(static_cast(direction), req.payload); @@ -2965,6 +3172,7 @@ RemoteConnection::set_scope(std::uint32_t index_id, const std::string& key, std::uint16_t data_type) { note_nav_frame(); + note_all_tables_nav(); // index-keyed visibility change // Payload: u32 index_id | u16 which | u16 data_type | bytes key. // Key length is the trailing byte count (payload.size() - 8). Frame req; req.opcode = Opcode::SetScope; @@ -3022,6 +3230,7 @@ RemoteConnection::fetch_current_row(RemoteTable* rt) { void RemoteConnection::show_deleted(bool visible) noexcept { note_nav_frame(); + note_all_tables_nav(); // conn-wide visibility change if (!transport_ || !transport_->valid()) return; Frame req; req.opcode = Opcode::ShowDeleted; @@ -3035,6 +3244,7 @@ util::Result RemoteConnection::clear_scope(std::uint32_t index_id, std::uint16_t which) { note_nav_frame(); + note_all_tables_nav(); // index-keyed visibility change Frame req; req.opcode = Opcode::ClearScope; write_u32_le(index_id, req.payload); write_u16_le(which, req.payload); @@ -3053,6 +3263,9 @@ RemoteConnection::seek(std::uint32_t index_id, std::uint8_t last, RemoteTable* parent) { note_nav_frame(); + // Index-keyed op: request() cannot map it to a table, so the + // binding the caller resolved bumps the per-table generation here. + if (parent != nullptr) note_tbl_nav(parent->id); Frame req; req.opcode = last ? Opcode::SeekLast : Opcode::Seek; write_u32_le(index_id, req.payload); diff --git a/src/network/client.h b/src/network/client.h index 0ef004ae..ef4b1e93 100644 --- a/src/network/client.h +++ b/src/network/client.h @@ -126,6 +126,13 @@ class RemoteConnection { return (server_caps_ & kCapFlushTableDurable) != 0; } + // Server certifies the opposite boundary on GotoTop/GotoBottom + // (see kCapNavBoundaryPair): one frame proves both ends, so a + // back-to-back dbGoTop(); dbGoBottom() ritual costs one RTT. + bool server_nav_boundary_pair() const noexcept { + return (server_caps_ & kCapNavBoundaryPair) != 0; + } + // Current cursor-generation sequence (see nav_seq_). Relaxed load // is enough: it only orders the ABI layer's own duplicate // detection, never data. @@ -133,6 +140,24 @@ class RemoteConnection { return nav_seq_.load(std::memory_order_relaxed); } + // mtfix12 — per-table generations (R1 pair guard / R2 per-table + // envelope). Keyed by wire table id, bumped inside request() for + // every cursor/visibility-affecting frame (nav) and + // content-affecting frame (write) — central by construction, so no + // ABI call site can miss one. Index-keyed ops (Seek/SeekLast/ + // SkipUnique/SetScope/ClearScope) and the connection-wide + // ShowDeleted bump through note_tbl_nav/note_all_tables_nav from + // the methods that know the binding. Table-id reuse after a close + // only starts a new table's counter higher — conservative, never + // stale-accepting. + std::uint64_t tbl_nav_seq(std::uint32_t id); + std::uint64_t tbl_write_gen(std::uint32_t id); + // Index-keyed cursor op whose parent table the caller resolved. + void note_tbl_nav(std::uint32_t id); + // Connection-wide visibility change (ShowDeleted) or an + // index-keyed op with no resolved parent: expire every table. + void note_all_tables_nav(); + // Server version from the HelloAck handshake ("openads/1.09.27"; // pre-1.8.14 servers answer the literal "openads/0.3.2"). Empty // when the Hello probe failed — callers treat that as unknown, @@ -475,6 +500,13 @@ class RemoteConnection { // and skips the round-trip). util::Result apply_open_row(RemoteTable* rt, const std::vector& trailer); + // mtfix12 R1 — apply the certified opposite-boundary landing + // (pair_blob, row-trailer format) exactly as that boundary's wire + // ack would have: same row-trailer parse (row cache, prefetch + // reset, lag re-anchor), then the certified bound values and the + // scoped key count. Caller has already verified freshness + // (pair_seq/pair_write_gen) via remote_nav_pair. + util::Result apply_pair_blob(RemoteTable* rt); // M12.6 — remote write surface. util::Result append_blank(std::uint32_t id); util::Result set_field(std::uint32_t id, @@ -602,6 +634,10 @@ class RemoteConnection { std::unique_ptr transport_; std::mutex mu_; + // mtfix12 per-table generations (see tbl_nav_seq/tbl_write_gen). + // Guarded by mu_ (bumped inside request(), which already holds it). + std::unordered_map tbl_nav_seqs_; + std::unordered_map tbl_write_gens_; // Server caps echoed in ConnectAck (0 when the server predates caps). std::uint32_t server_caps_ = 0; // Monotonic cursor-generation counter. Bumped ONLY by frames that @@ -824,6 +860,56 @@ struct RemoteTable { // A top in order A says nothing about order B, so the duplicate // check requires the context to match, not just the op. std::uint32_t last_nav_order = 0; + + // mtfix12 R1 — boundary-pair certification. A GotoTop/GotoBottom ack + // on a kCapNavBoundaryPair server carries the OPPOSITE boundary's + // complete landing state, read by the server in the same atomic + // visit: the row blob in row-trailer format (pack_row_trailer + // bytes, lookahead depth 0), that landing's bound values, and the + // scoped key count. While the conn-wide nav_seq holds (identical + // envelope to remote_nav_duplicate) and no write touched this table + // since (write_gen), a back-to-back opposite-boundary call applies + // the blob exactly as the wire ack would have: same parser, same + // bound application, same keyno sync — byte-identical state, one + // RTT saved. pair_which is the boundary this certifies (1 = top, + // 2 = bottom), i.e. the OPPOSITE of the nav that carried it. + bool pair_valid = false; + int pair_which = 0; + std::uint32_t pair_order = 0; + std::uint64_t pair_seq = 0; + std::uint64_t pair_write_gen = 0; + std::vector pair_blob; + bool pair_bof = false; + bool pair_eof = false; + std::uint32_t pair_recno = 0; + bool pair_has_count = false; + std::uint32_t pair_reccount = 0; + bool pair_has_keycount = false; + std::uint32_t pair_keycount = 0; + // Write generation snapshot: RemoteConnection::tbl_write_gen(id) + // at certification time (the conn bumps the live counter inside + // request() for every content-affecting frame, so no call site can + // miss it). The pair blob was read before any such change, so it + // must not overwrite fresher row state — guard on equality at + // serve time. + // mtfix12 R2 — certified server-cursor anchor. Set whenever a wire + // nav ack (or the open warm row) lands this handle's cursor on a + // row: the server cursor IS anchor_recno at that instant (lag is + // 0 by ack contract). Conn-wide envelope: valid while + // anchor_seq == conn nav_seq. Per-table opt-in envelope + // (OPENADS_NAV_SELF_GOTO=table): valid while anchor_tbl_seq == + // tbl_change_seq — server cursors are per handle, so only THIS + // handle's frames can move it. Position-only certification; row + // bytes come from the existing row cache. + std::uint32_t anchor_recno = 0; + bool anchor_ok = false; + std::uint64_t anchor_seq = 0; + std::uint64_t anchor_tbl_seq = 0; + // anchor_tbl_seq snapshots RemoteConnection::tbl_nav_seq(id) — the + // per-table cursor/visibility generation bumped centrally in + // request(). Purely-local visibility mutations (filter/scope/order + // set+clear — the sites that reset last_nav) must also expire the + // anchor: they clear anchor_ok directly. // M12.19 — cached record count. Serves AdsGetRecordCount and // AdsGetRelKeyPos (scrollbar) without an extra RTT. Invalidated // on writes that may change the row count: AppendBlank / diff --git a/src/network/session.cpp b/src/network/session.cpp index 13522416..56803e18 100644 --- a/src/network/session.cpp +++ b/src/network/session.cpp @@ -1148,6 +1148,84 @@ void Session::pack_bound_trailer(Frame& reply, std::uint32_t id) { } } +// mtfix12 R1 (kCapNavBoundaryPair) — see session.h. The pair section: +// [u8 flags][u32 trailer_len][trailer][bound 6|10][u32 keycount?] +// flags bit 0x02 = pair bound carries reccount, bit 0x04 = keycount +// present (ordered tables; natural order derives count == reccount on +// the client). The trailer is pack_row_trailer output at lookahead +// depth 0 for the OPPOSITE boundary, read inside this same visit, so +// the client's adjacent opposite-boundary call applies it verbatim. +void Session::pack_boundary_pair(Frame& reply, std::uint32_t id, + int which, ADSHANDLE hord, + openads::engine::Table* tbl) { + const bool to_bottom = (which == 1); // certify the opposite end + std::uint8_t flags = 0; + Frame blob; + blob.opcode = reply.opcode; + Frame bnd; + bnd.opcode = reply.opcode; + UNSIGNED32 kc = 0; + bool granted = false; + if (hord != 0) { + // Ordered table: navigate the ABI twin (it carries the order + // and scope), restore the requested boundary exactly after. + UNSIGNED32 save_rec = 0; + UNSIGNED16 sb = 0, se = 0; + (void)AdsGetRecordNum(hord, 0, &save_rec); + (void)AdsAtBOF(hord, &sb); + (void)AdsAtEOF(hord, &se); + const bool empty_landing = (sb != 0 && se != 0); + const UNSIGNED32 grc = + to_bottom ? AdsGotoBottom(hord) : AdsGotoTop(hord); + if (grc == 0) { + pack_row_trailer(blob, id, 0); + pack_bound_trailer(bnd, id); + if (bnd.payload.size() >= 10) flags |= 0x02; + if (AdsGetKeyCount(hord, 0, &kc) == 0) flags |= 0x04; + granted = true; + } + if (empty_landing) { + (void)(which == 1 ? AdsGotoTop(hord) : AdsGotoBottom(hord)); + } else { + (void)AdsGotoRecord(hord, save_rec); + } + } else if (tbl != nullptr) { + const std::uint32_t save_rec = tbl->recno(); + const bool empty_landing = tbl->bof() && tbl->eof(); + auto gr = to_bottom ? tbl->goto_bottom() : tbl->goto_top(); + if (gr) { + pack_row_trailer(blob, id, 0); + pack_bound_trailer(bnd, id); + if (bnd.payload.size() >= 10) flags |= 0x02; + granted = true; + } + if (empty_landing) { + if (which == 1) (void)tbl->goto_top(); + else (void)tbl->goto_bottom(); + } else { + (void)tbl->goto_record(save_rec); + } + } + if (!granted) return; // client falls back to a plain second frame + reply.payload.push_back(flags); + const std::uint32_t tlen = + static_cast(blob.payload.size()); + reply.payload.push_back(static_cast( tlen & 0xFFu)); + reply.payload.push_back(static_cast((tlen >> 8) & 0xFFu)); + reply.payload.push_back(static_cast((tlen >> 16) & 0xFFu)); + reply.payload.push_back(static_cast((tlen >> 24) & 0xFFu)); + reply.payload.insert(reply.payload.end(), + blob.payload.begin(), blob.payload.end()); + reply.payload.insert(reply.payload.end(), + bnd.payload.begin(), bnd.payload.end()); + if ((flags & 0x04) != 0) { + reply.payload.push_back(static_cast( kc & 0xFFu)); + reply.payload.push_back(static_cast((kc >> 8) & 0xFFu)); + reply.payload.push_back(static_cast((kc >> 16) & 0xFFu)); + reply.payload.push_back(static_cast((kc >> 24) & 0xFFu)); + } +} + // M12.22/M12.23 — read-ahead depth for one forward Skip. // // `hint` is what the client asked for via AdsCacheRecords, or @@ -1727,7 +1805,8 @@ DispatchResult Session::dispatch(const Frame& f) { openads::network::kCapSetFieldsBatch | openads::network::kCapFlushInCloseAll | openads::network::kCapNavOrderFuse | - openads::network::kCapFlushTableDurable; + openads::network::kCapFlushTableDurable | + openads::network::kCapNavBoundaryPair; reply.payload.push_back( static_cast( scaps & 0xFFu)); reply.payload.push_back( @@ -2082,12 +2161,21 @@ DispatchResult Session::dispatch(const Frame& f) { // navigating, collapsing SetOrder+GotoTop into one frame. // Length-gated (old clients stop at byte 6); cursor tables // above ignore it (their orders are query-fixed). + // mtfix12 R1: byte 6 is a flags byte on new clients — + // bit 0x01 fused order section (kCapNavOrderFuse, same + // wire shape as before: old clients send exactly 0x01), + // bit 0x02 boundary-pair request (kCapNavBoundaryPair). bool fused_order = false; - if (f.payload.size() >= 11 && f.payload[6] == 0x01) { - std::uint32_t oiid = read_u32_le(f.payload.data() + 7); - UNSIGNED32 oorc = install_table_order(id, oiid); - if (oorc != 0) { reply = err("SetOrder", oorc); break; } - fused_order = true; + bool want_pair = false; + if (f.payload.size() >= 7) { + const std::uint8_t fl = f.payload[6]; + want_pair = (fl & 0x02) != 0; + if ((fl & 0x01) != 0 && f.payload.size() >= 11) { + std::uint32_t oiid = read_u32_le(f.payload.data() + 7); + UNSIGNED32 oorc = install_table_order(id, oiid); + if (oorc != 0) { reply = err("SetOrder", oorc); break; } + fused_order = true; + } } auto it = tbls_.find(id); if (it == tbls_.end() || !sess_conn_) { @@ -2135,7 +2223,21 @@ DispatchResult Session::dispatch(const Frame& f) { } pack_row_trailer(reply, id, next_lookahead(id, gt_hint)); // Reposition truth rides after the trailer (see GotoRecord). - pack_bound_trailer(reply, id); + // mtfix12 R1: pair-requested acks carry an explicit + // [u8 ack_flags] first (bit 0x01 = bound has reccount) so + // the client parses section offsets deterministically. + if (want_pair) { + Frame bnd; + bnd.opcode = reply.opcode; + pack_bound_trailer(bnd, id); + reply.payload.push_back( + bnd.payload.size() >= 10 ? 1 : 0); + reply.payload.insert(reply.payload.end(), + bnd.payload.begin(), + bnd.payload.end()); + } else { + pack_bound_trailer(reply, id); + } // Fused switch only: the app almost always asks this // order's key count next (scrollbar setup), so certify it // now ([u32] after the bound tail) instead of paying a @@ -2152,6 +2254,11 @@ DispatchResult Session::dispatch(const Frame& f) { reply.payload.push_back(static_cast((fkc >> 16) & 0xFFu)); reply.payload.push_back(static_cast((fkc >> 24) & 0xFFu)); } + // mtfix12 R1: the opposite boundary's landing state, + // read and packed inside this same visit. + if (want_pair) { + pack_boundary_pair(reply, id, 1, hord, tbl); + } // Sync AFTER packing — pack_row_trailer walks the ABI cursor // through the block and restores it, so the engine cursor has to be // anchored to where the ABI cursor finally lands (same reason as @@ -2617,12 +2724,20 @@ DispatchResult Session::dispatch(const Frame& f) { // Fused nav+order: trailing [u8 0x01][u32 order_id]. // (GotoBottom carries no depth hint, so the section starts // at byte 4.) + // mtfix12 R1: byte 4 is a flags byte on new clients (see + // GotoTop): bit 0x01 fused order section, bit 0x02 + // boundary-pair request. bool fused_order = false; - if (f.payload.size() >= 9 && f.payload[4] == 0x01) { - std::uint32_t oiid = read_u32_le(f.payload.data() + 5); - UNSIGNED32 oorc = install_table_order(id, oiid); - if (oorc != 0) { reply = err("SetOrder", oorc); break; } - fused_order = true; + bool want_pair = false; + if (f.payload.size() >= 5) { + const std::uint8_t fl = f.payload[4]; + want_pair = (fl & 0x02) != 0; + if ((fl & 0x01) != 0 && f.payload.size() >= 9) { + std::uint32_t oiid = read_u32_le(f.payload.data() + 5); + UNSIGNED32 oorc = install_table_order(id, oiid); + if (oorc != 0) { reply = err("SetOrder", oorc); break; } + fused_order = true; + } } auto it = tbls_.find(id); if (it == tbls_.end() || !sess_conn_) { @@ -2644,7 +2759,18 @@ DispatchResult Session::dispatch(const Frame& f) { } reply.opcode = Opcode::GotoBottomAck; pack_row_trailer(reply, id); - pack_bound_trailer(reply, id); + if (want_pair) { + Frame bnd; + bnd.opcode = reply.opcode; + pack_bound_trailer(bnd, id); + reply.payload.push_back( + bnd.payload.size() >= 10 ? 1 : 0); + reply.payload.insert(reply.payload.end(), + bnd.payload.begin(), + bnd.payload.end()); + } else { + pack_bound_trailer(reply, id); + } // Fused switch only: certify the new order's key count // (see GotoTop). if (fused_order) { @@ -2659,6 +2785,9 @@ DispatchResult Session::dispatch(const Frame& f) { reply.payload.push_back(static_cast((fkc >> 16) & 0xFFu)); reply.payload.push_back(static_cast((fkc >> 24) & 0xFFu)); } + if (want_pair) { + pack_boundary_pair(reply, id, 2, hord, tbl); + } break; } // M12.15 — info / lock / maintenance / AOF. diff --git a/src/network/session.h b/src/network/session.h index 5e5e253f..61271fc4 100644 --- a/src/network/session.h +++ b/src/network/session.h @@ -231,6 +231,17 @@ class Session { // what must stay a pure read. A freshly repositioned twin is not // in limbo; both-true genuinely means an empty cursor. void pack_bound_trailer(Frame& reply, std::uint32_t id); + // mtfix12 R1 (kCapNavBoundaryPair): append the OPPOSITE boundary's + // landing state (row blob at lookahead depth 0, bound values, + // scoped key count for ordered tables) after the requested + // boundary's own sections. `which` is the boundary just navigated + // (1 = top, 2 = bottom); the pair certifies the other end. The + // cursor is restored exactly before returning; on any failure + // nothing is appended (the client length-gates and falls back to + // a plain second frame). Read-only wrt caller-visible state. + void pack_boundary_pair(Frame& reply, std::uint32_t id, + int which, ADSHANDLE hord, + openads::engine::Table* tbl); // Warm OpenTableAck sections (USE latency): schema + first-row TLVs // appended after the bag field (see wire.h OpenTableAckSections). // The row section positions the engine cursor exactly like an diff --git a/src/network/wire.h b/src/network/wire.h index e134f072..b9935788 100644 --- a/src/network/wire.h +++ b/src/network/wire.h @@ -658,6 +658,27 @@ inline constexpr std::uint32_t kCapNavOrderFuse = 0x00000040u; // the client keeps sending both frames there. inline constexpr std::uint32_t kCapFlushTableDurable = 0x00000080u; +// Boundary-pair certification (mtfix12 R1): a GotoTop/GotoBottom request +// may carry one more trailing flag bit (see the flag bytes below) asking +// the server to read the OPPOSITE boundary in the same atomic visit and +// append its full landing state to the ack (row blob + bound values + +// scoped key count). The client stamps that certification and serves a +// back-to-back opposite-boundary call (the dbGoTop(); dbGoBottom() +// ritual) with zero frames between proof and serve -- the same +// conn-wide freshness envelope the shipped duplicate-suppression uses. +// Same two-way gating as kCapNavOrderFuse: the client only sets the +// flag when the ConnectAck echo carries this bit, so old servers never +// see it and old clients never emit it. +// +// Request layouts (new server parses bits, old layouts stay valid): +// GotoTop: [u32 id][u16 depth][u8 flags]([u32 order]) +// GotoBottom: [u32 id][u8 flags]([u32 order]) +// flags bit 0x01 = fused order section present (kCapNavOrderFuse) +// flags bit 0x02 = boundary-pair certification requested (this bit) +// Pre-mtfix12 clients send flags == 0x01 exactly when fusing, which the +// new server reads as "fused, no pair" -- bit-exact with the old parse. +inline constexpr std::uint32_t kCapNavBoundaryPair = 0x00000100u; + // Warm OpenTableAck sections (USE latency). After the fixed // `[u32 id][u16 bag_len][bag]` prefix, the ack carries // `[u8 section_count]` then that many TLVs: From 92d46282e8e6c678f68cbfef9fa4b1375f91cba1 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898283+github-actions[bot]@users.noreply.github.com> Date: Sat, 26 Sep 2026 06:24:01 +0000 Subject: [PATCH 46/97] mtfix12: nav fusion (ABI layer) + OPENADS_VERSION_FORCE stamp support + boundary-pair tests + nav expectation updates Applied by apply-patch workflow, run 36223303730, started by bedipritpal. --- CMakeLists.txt | 9 ++ src/abi/ace_exports.cpp | 137 ++++++++++++++++- tests/CMakeLists.txt | 1 + tests/unit/network_boundary_pair_test.cpp | 177 ++++++++++++++++++++++ tests/unit/network_nav_batch_test.cpp | 24 ++- tests/unit/network_use_budget_test.cpp | 3 +- 6 files changed, 344 insertions(+), 7 deletions(-) create mode 100644 tests/unit/network_boundary_pair_test.cpp diff --git a/CMakeLists.txt b/CMakeLists.txt index d7467a51..25f5f9bc 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -13,6 +13,14 @@ project(OpenADS # a .git tree falls back to ${PROJECT_VERSION} from project() above. # The previous hard-coded "1.0.0-rc1" string drifted six releases # behind reality and surprised users running rc12. +# A pipeline that knows the release tag passes it explicitly: the tag +# is the source of truth for a release build's stamp, not the local +# clone's git state (shallow/container checkouts may not resolve +# `git describe` - the glibc231 container leg shipped a plain +# "1.09.68" banner for exactly that reason). +if(OPENADS_VERSION_FORCE) + set(OPENADS_VERSION_STR "${OPENADS_VERSION_FORCE}") +else() set(OPENADS_VERSION_STR "${PROJECT_VERSION}") find_package(Git QUIET) if(GIT_FOUND AND EXISTS "${CMAKE_SOURCE_DIR}/.git") @@ -29,6 +37,7 @@ if(GIT_FOUND AND EXISTS "${CMAKE_SOURCE_DIR}/.git") string(REGEX REPLACE "-dirty$" "" OPENADS_VERSION_STR "${OPENADS_VERSION_STR}") endif() endif() +endif() message(STATUS "OpenADS version: ${OPENADS_VERSION_STR}") # Deliver the version through a generated header instead of a global # compile definition: a new commit used to change the command line of diff --git a/src/abi/ace_exports.cpp b/src/abi/ace_exports.cpp index 9d0cab97..92407c22 100644 --- a/src/abi/ace_exports.cpp +++ b/src/abi/ace_exports.cpp @@ -750,6 +750,8 @@ UNSIGNED32 remote_emit_close_all(openads::network::RemoteTable* rt) { rt->index_handles.clear(); rt->active_index_id = 0; rt->last_nav = 0; + rt->pair_valid = false; + rt->anchor_ok = false; return ok(); } // Parked-index truth enforcement (see the nav entries): order-dependent @@ -1883,6 +1885,22 @@ std::uint32_t remote_empty_ttl_ms() { return v; } +// mtfix12 R2 opt-in envelope (his explicit flag): conn-wide by +// default — any cursor-affecting frame on the connection expires the +// self-goto anchor. OPENADS_NAV_SELF_GOTO=table scopes freshness to +// the table handle: server cursors are per handle, so only this +// handle's frames can move its cursor, and the per-table generation +// (bumped centrally in request()) sees them all. For his deployment — +// writes coordinated by SEMA4s and physical locks — this is safe; the +// general default stays conn-wide. +bool remote_self_goto_per_table() { + static const bool v = [] { + const char* e = std::getenv("OPENADS_NAV_SELF_GOTO"); + return e != nullptr && std::strcmp(e, "table") == 0; + }(); + return v; +} + bool remote_empty_window(const openads::network::RemoteTable* rt) { if (rt == nullptr || rt->conn == nullptr) return false; const std::uint32_t ttl = remote_empty_ttl_ms(); @@ -1918,6 +1936,10 @@ void remote_empty_restamp(openads::network::RemoteTable* rt) { rt->recno_bound_seq = seq; rt->count_bound_seq = seq; rt->last_nav = 0; + // The serve lands on no row: no anchor, and any certified pair + // landing described a state this serve just replaced. + rt->pair_valid = false; + rt->anchor_ok = false; } // Memo key for AdsGetRecordLength re-opens: lowercased table name plus @@ -2003,6 +2025,22 @@ void remote_nav_stamp(openads::network::RemoteTable* rt, int which, rt->last_nav_seq = rt->conn->nav_seq(); } +// mtfix12 R1 — opposite-boundary certification serve check. True when +// the just-landed GotoTop/GotoBottom carried THIS boundary's full +// landing state (kCapNavBoundaryPair) and the same freshness envelope +// the duplicate check uses still holds — conn-wide nav_seq unchanged — +// plus no write touched this table since certification (the blob's row +// bytes predate any write; a stale blob must never overwrite fresher +// row state). Pending local mutations stay on the wire path. +bool remote_nav_pair(const openads::network::RemoteTable* rt, int which, + std::uint32_t order) { + return rt != nullptr && rt->conn != nullptr && rt->pair_valid && + rt->pair_which == which && rt->pair_order == order && + rt->pair_seq == rt->conn->nav_seq() && + rt->pair_write_gen == rt->conn->tbl_write_gen(rt->id) && + rt->pending_sets.empty() && !rt->pending_order; +} + // Empty-cursor sticky for AdsAtBOF/AdsAtEOF: true when the last wire // nav on this table established an empty cursor with no // cursor-affecting frame since. An empty cursor is simultaneously BOF @@ -8320,6 +8358,8 @@ UNSIGNED32 ENTRYPOINT AdsOpenTable(ADSHANDLE hConnect, adopted->found_cached = false; adopted->nav_at_bof = adopted->nav_at_eof = false; adopted->last_nav = 0; // re-stamped by the warm GotoTop below + adopted->pair_valid = false; + adopted->anchor_ok = false; adopted->parked_nav_which = 0; adopted->flush_file_pending = false; adopted->close_all_indexes_pending = false; @@ -10583,13 +10623,46 @@ UNSIGNED32 ENTRYPOINT AdsGotoRecord(ADSHANDLE hTable, UNSIGNED32 ulRecord) { rt->count_bound_seq == rt->conn->nav_seq(); const bool empty_goto = empty_goto_exact || remote_empty_window(rt); + // mtfix12 R2 — self-GotoRecord: the app re-issues GotoRecord + // for the recno the server cursor already sits on (FWH xBrowse + // bookmark restore). Serve locally when the certified anchor + // IS the wanted recno, the row cache holds that very row, no + // prefetch drain is outstanding (lag 0: client logical == + // server cursor), and the freshness envelope holds — + // conn-wide by default, per-table under the explicit opt-in. + // The serve is byte-identical to the wire ack: same row (cache), + // same position (anchor), lag stays 0, keyno preserved below + // (ulRecord == prev_recno), duplicate-nav stamp expired as a + // real frame would. + const bool self_goto = + ulRecord != 0u && rt->row_valid && + rt->current_recno == ulRecord && rt->cursor_lag == 0 && + rt->anchor_ok && rt->anchor_recno == ulRecord && + rt->pending_sets.empty() && + (remote_self_goto_per_table() + ? rt->anchor_tbl_seq == rt->conn->tbl_nav_seq(rt->id) + : rt->anchor_seq == rt->conn->nav_seq()); rt->goto_row_fresh = false; - if (empty_goto) { + if (self_goto) { + cli_trace_tbl(rt, "AdsGotoRecord", "served locally (self-goto)"); + // A wire GotoRecord ack carries the row but NO lookahead + // block, and its trailer parse clears the queue — mirror + // that exactly, or a following Skip drains rows a real + // reposition would have discarded (the ramp test caught + // this: the skip never reached the wire). + rt->invalidate_prefetch(); + rt->goto_row_fresh = true; + rt->goto_row_frame_seq = rt->conn->frame_seq(); + rt->goto_row_recno = rt->current_recno; + rt->last_nav = 0; // a real frame would have expired it + } else if (empty_goto) { if (empty_goto_exact) { cli_trace_tbl(rt, "AdsGotoRecord", "served locally (empty table)"); rt->invalidate_prefetch(); // A real frame would have expired the duplicate-nav stamp. rt->last_nav = 0; + rt->pair_valid = false; + rt->anchor_ok = false; // landed on no row } else { cli_trace_tbl(rt, "AdsGotoRecord", "empty window: served locally want=%u", @@ -11560,6 +11633,18 @@ UNSIGNED32 ENTRYPOINT AdsGotoTop(ADSHANDLE hTable) { apply_relations_for_handle(to_ads_handle(th)); return ok(); } + if (remote_nav_pair(ri->parent, 1, ri->id)) { + // mtfix12 R1: the preceding GotoBottom certified this + // landing in the same server visit; apply it verbatim. + auto pr = ri->parent->conn->apply_pair_blob(ri->parent); + if (!pr) return fail(pr.error()); + cli_trace_tbl(ri->parent, "AdsGotoTop(idx)", "pair certified"); + remote_sync_keyno_gototop(ri->parent); + remote_nav_stamp(ri->parent, 1, ri->id); + if (Handle th = handle_for_remote_table(ri->parent)) + apply_relations_for_handle(to_ads_handle(th)); + return ok(); + } auto r = openads::network::remote_index_goto_top(ri); if (!r) return fail(r.error()); remote_sync_keyno_gototop(ri->parent); @@ -11618,6 +11703,15 @@ UNSIGNED32 ENTRYPOINT AdsGotoTop(ADSHANDLE hTable) { apply_relations_for_handle(hTable); return ok(); } + if (remote_nav_pair(rt, 1, rt->server_order_id)) { + auto pr = rt->conn->apply_pair_blob(rt); + if (!pr) return fail(pr.error()); + cli_trace_tbl(rt, "AdsGotoTop", "pair certified"); + remote_sync_keyno_gototop(rt); + remote_nav_stamp(rt, 1, rt->server_order_id); + apply_relations_for_handle(hTable); + return ok(); + } auto r = rt->conn->goto_top(rt); if (!r) return fail(r.error()); remote_sync_keyno_gototop(rt); @@ -11686,6 +11780,17 @@ UNSIGNED32 ENTRYPOINT AdsGotoBottom(ADSHANDLE hTable) { apply_relations_for_handle(to_ads_handle(th)); return ok(); } + if (remote_nav_pair(ri->parent, 2, ri->id)) { + // mtfix12 R1: the preceding GotoTop certified this landing. + auto pr = ri->parent->conn->apply_pair_blob(ri->parent); + if (!pr) return fail(pr.error()); + cli_trace_tbl(ri->parent, "AdsGotoBottom(idx)", "pair certified"); + remote_sync_keyno_gotobottom(ri->parent); + remote_nav_stamp(ri->parent, 2, ri->id); + if (Handle th = handle_for_remote_table(ri->parent)) + apply_relations_for_handle(to_ads_handle(th)); + return ok(); + } auto r = openads::network::remote_index_goto_bottom(ri); if (!r) return fail(r.error()); remote_sync_keyno_gotobottom(ri->parent); @@ -11735,6 +11840,15 @@ UNSIGNED32 ENTRYPOINT AdsGotoBottom(ADSHANDLE hTable) { apply_relations_for_handle(hTable); return ok(); } + if (remote_nav_pair(rt, 2, rt->server_order_id)) { + auto pr = rt->conn->apply_pair_blob(rt); + if (!pr) return fail(pr.error()); + cli_trace_tbl(rt, "AdsGotoBottom", "pair certified"); + remote_sync_keyno_gotobottom(rt); + remote_nav_stamp(rt, 2, rt->server_order_id); + apply_relations_for_handle(hTable); + return ok(); + } auto r = rt->conn->goto_bottom(rt); if (!r) return fail(r.error()); remote_sync_keyno_gotobottom(rt); @@ -12630,6 +12744,17 @@ UNSIGNED32 ENTRYPOINT AdsGetRecordNum(ADSHANDLE hTable, UNSIGNED16 /*bFilterOpti *pulRecordNum = rt->recno_bound; return ok(); } + // mtfix12 R3 — the R2 anchor certifies the server cursor's + // recno even when the local row cache was dropped without a + // wire move. With no drain outstanding (lag 0), the wire answer + // would be exactly anchor_recno. + if (rt->anchor_ok && rt->cursor_lag == 0 && + (remote_self_goto_per_table() + ? rt->anchor_tbl_seq == rt->conn->tbl_nav_seq(rt->id) + : rt->anchor_seq == rt->conn->nav_seq())) { + *pulRecordNum = rt->anchor_recno; + return ok(); + } // Phantom derivation (no frame, no currency): at the EOF // phantom the recno is LastRec+1 by Clipper convention — a // pure function of the certified EOF flag plus the cached @@ -16080,6 +16205,8 @@ UNSIGNED32 ENTRYPOINT AdsCloseAllIndexes(ADSHANDLE hTable) { // Order belief changed with no frame: expire the nav stamp so a // subsequent GotoTop cannot dedupe against the old binding. rt->last_nav = 0; + rt->pair_valid = false; + rt->anchor_ok = false; rt->close_all_indexes_pending = true; return ok(); } @@ -21444,6 +21571,8 @@ UNSIGNED32 ENTRYPOINT AdsSetAOF(ADSHANDLE hTable, UNSIGNED8* pucCondition, // Replacement filter can narrow to empty or widen to rows: // expire the nav stamp, proven-false answers and cached answers. rt->last_nav = 0; + rt->pair_valid = false; + rt->anchor_ok = false; rt->nav_not_bof = false; rt->nav_not_eof = false; remote_nav_bound_clear(rt); @@ -21615,6 +21744,8 @@ UNSIGNED32 ENTRYPOINT AdsClearAOF(ADSHANDLE hTable) { // cached boundary answers (the wire frame already expired the // seq-gated state; this covers the seq-blind sticky). rt->last_nav = 0; + rt->pair_valid = false; + rt->anchor_ok = false; rt->nav_not_bof = false; rt->nav_not_eof = false; remote_nav_bound_clear(rt); @@ -21635,6 +21766,8 @@ UNSIGNED32 ENTRYPOINT AdsClearFilter(ADSHANDLE hTable) { if (auto* rt = get_remote_table(hTable)) { rt->filter_expr.clear(); rt->last_nav = 0; // local visibility change: expire nav stamp + rt->pair_valid = false; + rt->anchor_ok = false; rt->nav_not_bof = false; // widening-safe to keep; uniformity wins rt->nav_not_eof = false; remote_nav_bound_clear(rt); @@ -38520,6 +38653,8 @@ UNSIGNED32 ENTRYPOINT AdsSetFilter(ADSHANDLE hTable, UNSIGNED8* pucFilter) { // nav stamp, proven-false answers and cached answers — the // wire-seq rule cannot see any of them. rt->last_nav = 0; + rt->pair_valid = false; + rt->anchor_ok = false; rt->nav_not_bof = false; rt->nav_not_eof = false; remote_nav_bound_clear(rt); diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index ff84e990..4d547798 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -335,6 +335,7 @@ add_executable(openads_unit_tests unit/network_empty_window_test.cpp unit/network_remote_reindex_test.cpp unit/network_frame_trace_test.cpp + unit/network_boundary_pair_test.cpp unit/network_commit_slimming_test.cpp unit/network_teardown_batch_test.cpp unit/network_pool_mt_test.cpp diff --git a/tests/unit/network_boundary_pair_test.cpp b/tests/unit/network_boundary_pair_test.cpp new file mode 100644 index 00000000..7bb2cd75 --- /dev/null +++ b/tests/unit/network_boundary_pair_test.cpp @@ -0,0 +1,177 @@ +// tests/unit/network_boundary_pair_test.cpp +// mtfix12 R1/R2/R3 — boundary-pair certification, self-GotoRecord +// suppression, and GetRecordNum anchoring, end to end over a loopback +// server: per-opcode frame counts prove which calls hit the wire, and +// reads prove the locally-served state is what the wire would have +// returned. +#include "doctest.h" +#include "network/client.h" +#include "network/server.h" +#include "openads/ace.h" + +#include +#include +#include +#include + +namespace fs = std::filesystem; + +namespace { + +std::atomic g_top{0}, g_bottom{0}, g_goto{0}, g_recnum{0}; + +void count_nav(const void*, std::uint8_t op, std::uint32_t, std::size_t, + std::uint8_t, std::size_t, long long) { + if (op == 0x40) g_top.fetch_add(1); // GotoTop + if (op == 0x64) g_bottom.fetch_add(1); // GotoBottom + if (op == 0x58) g_goto.fetch_add(1); // GotoRecord + if (op == 0x4E) g_recnum.fetch_add(1); // GetRecordNum +} + +void reset_counts() { + g_top = 0; g_bottom = 0; g_goto = 0; g_recnum = 0; +} + +std::uint32_t recno_of(ADSHANDLE h) { + UNSIGNED32 n = 0; + REQUIRE(AdsGetRecordNum(h, 0, &n) == AE_SUCCESS); + return n; +} + +std::string id_field(ADSHANDLE h) { + char buf[64] = {}; + UNSIGNED32 len = sizeof(buf) - 1; + UNSIGNED8 fld[] = "ID"; + REQUIRE(AdsGetField(h, fld, reinterpret_cast(buf), &len, + ADS_NONE) == AE_SUCCESS); + return std::string(buf, len); +} + +} // namespace + +TEST_CASE("boundary pair, self-goto, and recno anchor over loopback") { + auto dir = fs::temp_directory_path() / "openads_bpair"; + std::error_code ec; + fs::remove_all(dir, ec); + fs::create_directories(dir); + + // Seed two 5-row tables locally. + UNSIGNED8 srv[512]{}; + std::memcpy(srv, dir.string().c_str(), dir.string().size()); + ADSHANDLE hC0 = 0; + REQUIRE(AdsConnect60(srv, ADS_LOCAL_SERVER, nullptr, nullptr, 0, &hC0) + == AE_SUCCESS); + UNSIGNED8 def[] = "ID,N,8,0"; + for (const char* nm : {"BP1.DBF", "BP2.DBF"}) { + UNSIGNED8 tn[64]{}; + std::memcpy(tn, nm, std::strlen(nm) + 1); + ADSHANDLE h = 0; + REQUIRE(AdsCreateTable(hC0, tn, nullptr, ADS_CDX, 0, 0, 0, 0, def, + &h) == AE_SUCCESS); + for (int i = 1; i <= 5; ++i) { + REQUIRE(AdsAppendRecord(h) == AE_SUCCESS); + char v[16]; + std::snprintf(v, sizeof(v), "%d", i); + UNSIGNED8 fld[] = "ID"; + REQUIRE(AdsSetField(h, fld, + reinterpret_cast(v), + static_cast(std::strlen(v))) + == AE_SUCCESS); + } + REQUIRE(AdsCloseTable(h) == AE_SUCCESS); + } + REQUIRE(AdsDisconnect(hC0) == AE_SUCCESS); + + openads::network::Server s; + REQUIRE(s.start("127.0.0.1", 0).has_value()); + char uri[512]{}; + std::snprintf(uri, sizeof(uri), "tcp://127.0.0.1:%u/%s", + static_cast(s.port()), dir.string().c_str()); + UNSIGNED8 sb[512]{}; + std::memcpy(sb, uri, std::strlen(uri) + 1); + ADSHANDLE hC = 0; + REQUIRE(AdsConnect60(sb, ADS_REMOTE_SERVER, nullptr, nullptr, 0, &hC) + == AE_SUCCESS); + + UNSIGNED8 t1[] = "BP1.DBF"; + UNSIGNED8 t2[] = "BP2.DBF"; + ADSHANDLE hT = 0, hU = 0; + REQUIRE(AdsOpenTable(hC, t1, nullptr, ADS_CDX, 0, 0, 0, 0, &hT) + == AE_SUCCESS); + REQUIRE(AdsOpenTable(hC, t2, nullptr, ADS_CDX, 0, 0, 0, 0, &hU) + == AE_SUCCESS); + + openads::network::set_frame_trace_hook(&count_nav); + + // Settle: position somewhere known; whatever it costs is not counted. + REQUIRE(AdsGotoTop(hT) == AE_SUCCESS); + REQUIRE(AdsGotoTop(hU) == AE_SUCCESS); + reset_counts(); + + // R2 — self-GotoRecord: first GO wires and certifies the anchor, + // the repeat is served locally. + REQUIRE(AdsGotoRecord(hT, 3) == AE_SUCCESS); + CHECK(g_goto.load() == 1u); + CHECK(recno_of(hT) == 3u); + CHECK(g_recnum.load() == 0u); // R3: from the anchor + REQUIRE(AdsGotoRecord(hT, 3) == AE_SUCCESS); + CHECK(g_goto.load() == 1u); // no new frame + CHECK(recno_of(hT) == 3u); + + // R1 — the GotoTop ack certified the bottom in the same visit. + REQUIRE(AdsGotoTop(hT) == AE_SUCCESS); + CHECK(g_top.load() == 1u); + CHECK(recno_of(hT) == 1u); + REQUIRE(AdsGotoBottom(hT) == AE_SUCCESS); + CHECK(g_bottom.load() == 0u); // served from the pair blob + CHECK(recno_of(hT) == 5u); // and it IS the bottom row + CHECK(id_field(hT) == "5"); + UNSIGNED16 atEof = 1; + REQUIRE(AdsAtEOF(hT, &atEof) == AE_SUCCESS); + CHECK(atEof == 0); + + // The pair serve stamped bottom; a consecutive bottom is the old + // duplicate path, and the top that follows re-wires (no fresh + // certification for top was made by the LOCAL bottom serve... the + // certification from the earlier GotoBottom... none happened, so + // this top must wire and re-certify bottom). + REQUIRE(AdsGotoBottom(hT) == AE_SUCCESS); + CHECK(g_bottom.load() == 0u); // duplicate of the pair serve + REQUIRE(AdsGotoTop(hT) == AE_SUCCESS); + CHECK(g_top.load() == 2u); // wire: re-certifies bottom + REQUIRE(AdsGotoBottom(hT) == AE_SUCCESS); + CHECK(g_bottom.load() == 0u); // pair again + CHECK(recno_of(hT) == 5u); + + // A write on this table kills the certification (the blob's row + // bytes predate it) and the frame expires the conn-wide seq anyway. + REQUIRE(AdsGotoTop(hT) == AE_SUCCESS); + CHECK(g_top.load() == 3u); // wire: certify bottom again + REQUIRE(AdsLockRecord(hT, 0) == AE_SUCCESS); + UNSIGNED8 fld[] = "ID"; + UNSIGNED8 seven[] = "7"; + REQUIRE(AdsSetField(hT, fld, seven, 1) == AE_SUCCESS); + REQUIRE(AdsUnlockRecord(hT, 0) == AE_SUCCESS); + REQUIRE(AdsGotoBottom(hT) == AE_SUCCESS); + CHECK(g_bottom.load() == 1u); // back on the wire + CHECK(recno_of(hT) == 5u); + CHECK(id_field(hT) == "5"); // row 5 untouched by the write + + // The wire GotoBottom also certified the TOP: this GotoTop is + // itself pair-served (symmetric certification). + REQUIRE(AdsGotoTop(hT) == AE_SUCCESS); + CHECK(g_top.load() == 3u); // pair-served from the bottom + CHECK(recno_of(hT) == 1u); + // Conn-wide envelope: nav on ANOTHER table expires the pair. + REQUIRE(AdsGotoRecord(hU, 2) == AE_SUCCESS); // other table's nav + REQUIRE(AdsGotoBottom(hT) == AE_SUCCESS); + CHECK(g_bottom.load() == 2u); // wire: conn seq moved + + openads::network::set_frame_trace_hook(nullptr); + + REQUIRE(AdsCloseTable(hT) == AE_SUCCESS); + REQUIRE(AdsCloseTable(hU) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hC) == AE_SUCCESS); + s.stop(); + fs::remove_all(dir, ec); +} diff --git a/tests/unit/network_nav_batch_test.cpp b/tests/unit/network_nav_batch_test.cpp index 12f3607b..de64a33a 100644 --- a/tests/unit/network_nav_batch_test.cpp +++ b/tests/unit/network_nav_batch_test.cpp @@ -177,9 +177,15 @@ TEST_CASE("Nav batching: duplicate GotoTop/GotoBottom skip their frame") { REQUIRE(AdsGotoBottom(hTable) == AE_SUCCESS); CHECK(nb_op(kOpGotoBottom) == bot0 + 1); - // A wire nav in between invalidates: top goes out again. + // mtfix12 R1: the wire GotoBottom certified the TOP in the same + // server visit, so this top answers from the pair certification — + // stronger than the old re-wire. A wire nav with no certification + // (GotoRecord) invalidates it: the next top goes out again. REQUIRE(AdsGotoTop(hTable) == AE_SUCCESS); - CHECK(nb_op(kOpGotoTop) == top0 + 1); + CHECK(nb_op(kOpGotoTop) == top0); // pair-certified + REQUIRE(AdsGotoRecord(hTable, 2) == AE_SUCCESS); + REQUIRE(AdsGotoTop(hTable) == AE_SUCCESS); + CHECK(nb_op(kOpGotoTop) == top0 + 1); // invalidated: back on wire REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); @@ -454,11 +460,19 @@ TEST_CASE("Nav batching: deferred SetOrder fuses into GotoTop") { nb_wipe(); REQUIRE(AdsGetRecordNum(hTable, 0, &rec) == AE_SUCCESS); CHECK(rec == 2u); - // Same pair to the bottom: fused the same way. + // Same pattern to the bottom: fused the same way. mtfix12 R1 + // notes: the fused GotoTop above certified the bottom in the same + // visit, so a plain ordered GotoBottom here would be pair-served + // and exercise nothing. A pending order blocks the pair serve (the + // certified order no longer provably matches), so defer a natural + // switch first — the bottom on the TABLE handle fuses it into one + // frame exactly like leg one. (ByHandle(hOrd) can't be the second + // switch: with the acked binding still hOrd it reads as a + // same-order no-op and would leave the natural switch pending.) + REQUIRE(AdsSetIndexOrderByHandle(hTable, 0) == AE_SUCCESS); const std::uint64_t bot0 = nb_op(kOpGotoBottom); - REQUIRE(AdsSetIndexOrderByHandle(hTable, hOrd) == AE_SUCCESS); CHECK(nb_op(kOpSetOrder) == so0); - REQUIRE(AdsGotoBottom(hOrd) == AE_SUCCESS); + REQUIRE(AdsGotoBottom(hTable) == AE_SUCCESS); CHECK(nb_op(kOpSetOrder) == so0); CHECK(nb_op(kOpGotoBottom) == bot0 + 1); diff --git a/tests/unit/network_use_budget_test.cpp b/tests/unit/network_use_budget_test.cpp index e11a1fb0..3dd545ef 100644 --- a/tests/unit/network_use_budget_test.cpp +++ b/tests/unit/network_use_budget_test.cpp @@ -111,7 +111,8 @@ TEST_CASE("Nav batching: full USE cycle stays within wire budget") { // boundary probes and duplicate navs contribute zero frames. CHECK(total <= 14u); CHECK(delta(0x40) == 1u); // GotoTop: second suppressed - CHECK(delta(0x64) == 1u); // GotoBottom: second suppressed + CHECK(delta(0x64) == 0u); // GotoBottom: mtfix12 pair-certified by + // the GotoTop's ack, second suppressed CHECK(delta(0x48) == 0u); // AtEOF: served locally CHECK(delta(0x4C) == 0u); // AtBOF: served locally From 3c8eee49ed76723702ac8691a2b526fe031e1f50 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Sat, 26 Sep 2026 01:27:54 -0500 Subject: [PATCH 47/97] Delete 2-CMakeLists.txt --- 2-CMakeLists.txt | 629 ----------------------------------------------- 1 file changed, 629 deletions(-) delete mode 100644 2-CMakeLists.txt diff --git a/2-CMakeLists.txt b/2-CMakeLists.txt deleted file mode 100644 index 25f5f9bc..00000000 --- a/2-CMakeLists.txt +++ /dev/null @@ -1,629 +0,0 @@ -cmake_minimum_required(VERSION 3.20) - -project(OpenADS - VERSION 1.09.68 - LANGUAGES C CXX - DESCRIPTION "Open-source ADS-compatible engine" -) - -# Resolve the public version string the binary reports through -# `--version`. Prefer `git describe --tags` so a build straight off -# tag v1.0.0-rc13 reports "1.0.0-rc13", a build off main (between -# tags) reports "1.0.0-rc13-7-g[-dirty]", and a tarball without -# a .git tree falls back to ${PROJECT_VERSION} from project() above. -# The previous hard-coded "1.0.0-rc1" string drifted six releases -# behind reality and surprised users running rc12. -# A pipeline that knows the release tag passes it explicitly: the tag -# is the source of truth for a release build's stamp, not the local -# clone's git state (shallow/container checkouts may not resolve -# `git describe` - the glibc231 container leg shipped a plain -# "1.09.68" banner for exactly that reason). -if(OPENADS_VERSION_FORCE) - set(OPENADS_VERSION_STR "${OPENADS_VERSION_FORCE}") -else() -set(OPENADS_VERSION_STR "${PROJECT_VERSION}") -find_package(Git QUIET) -if(GIT_FOUND AND EXISTS "${CMAKE_SOURCE_DIR}/.git") - execute_process( - COMMAND ${GIT_EXECUTABLE} describe --tags --always --dirty - WORKING_DIRECTORY ${CMAKE_SOURCE_DIR} - OUTPUT_VARIABLE _git_describe - OUTPUT_STRIP_TRAILING_WHITESPACE - ERROR_QUIET - RESULT_VARIABLE _git_rc) - if(_git_rc EQUAL 0 AND NOT _git_describe STREQUAL "") - # Strip a leading "v" so tags v1.0.0-rc13 surface as 1.0.0-rc13. - string(REGEX REPLACE "^v" "" OPENADS_VERSION_STR "${_git_describe}") - string(REGEX REPLACE "-dirty$" "" OPENADS_VERSION_STR "${OPENADS_VERSION_STR}") - endif() -endif() -endif() -message(STATUS "OpenADS version: ${OPENADS_VERSION_STR}") -# Deliver the version through a generated header instead of a global -# compile definition: a new commit used to change the command line of -# EVERY translation unit (add_compile_definitions), forcing a full -# 340-TU rebuild on the next build. configure_file() is -# copy-if-different, so the header's timestamp only moves when the -# version string really changes and only its ~7 consumers recompile. -configure_file( - "${CMAKE_SOURCE_DIR}/cmake/openads_version.h.in" - "${CMAKE_BINARY_DIR}/generated/openads_version.h" - @ONLY) -include_directories("${CMAKE_BINARY_DIR}/generated") - -set(CMAKE_CXX_STANDARD 17) -set(CMAKE_CXX_STANDARD_REQUIRED ON) -set(CMAKE_CXX_EXTENSIONS OFF) - -# openads_core is a STATIC library that gets linked into both an -# executable (openads_serverd, tests) and a SHARED library -# (openads_ace -> ace64.dll / libace64.so). On Linux / macOS the -# shared-library link path requires every translation unit pulled -# in to be position-independent, otherwise we hit -# `R_X86_64_TPOFF32 ... can not be used when making a shared -# object; recompile with -fPIC`. Force PIC globally ÔÇö Windows -# silently ignores the flag, so this is a no-op there. -set(CMAKE_POSITION_INDEPENDENT_CODE ON) - -if(NOT CMAKE_BUILD_TYPE AND NOT CMAKE_CONFIGURATION_TYPES) - set(CMAKE_BUILD_TYPE Release CACHE STRING "" FORCE) -endif() - -option(OPENADS_BUILD_TESTS "Build OpenADS unit tests" ON) -option(OPENADS_WARNINGS_AS_ERRORS "Treat warnings as errors" ON) - -# M12.12 ÔÇö real TLS transport via vendored mbedtls (Apache-2.0). -# Off by default so existing builds don't pull a network dependency -# at configure time. When ON, FetchContent downloads mbedtls 3.6 LTS -# at configure time and links it into openads_core; AdsConnect60 -# with a `tls://` URI then opens a TlsTransport instead of returning -# AE_FUNCTION_NOT_AVAILABLE. -option(OPENADS_WITH_TLS "Enable TLS transport (vendors mbedtls 3.6 LTS via FetchContent)" OFF) - -# studio.web ÔÇö embedded HTTP web console inside openads_serverd. -# When ON, serverd gains a `--http-port N` flag that exposes a -# single-page admin UI + REST API on top of the running engine, -# so the database can be administered from any browser on the LAN -# without installing a desktop client. Vendors cpp-httplib (MIT) -# and nlohmann/json (MIT) at configure time via FetchContent. -option(OPENADS_WITH_HTTP "Enable embedded HTTP web console (Studio) in openads_serverd / ace64.dll" ON) - -# 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) -# Build the OpenADS ODBC *driver* (provider) DLL so external ODBC clients -# (pyodbc, PDO_ODBC, Power BI, Excel, ...) can connect TO OpenADS. Windows only. -option(OPENADS_BUILD_ODBC_DRIVER "Build the OpenADS ODBC driver DLL (Windows)" OFF) -# Native MS SQL Server / TDS 7.4 backend. Requires OPENADS_WITH_TLS=ON -# (uses mbedtls for the TLS layer that SQL Server mandates on port 1433 -# since MSSQL 2022 / Azure SQL). No external library is downloaded beyond -# mbedtls which OPENADS_WITH_TLS already fetches. OFF by default. -option(OPENADS_WITH_MSSQL "Enable native MS SQL Server / TDS backend" OFF) -# Opt-in: build the SQLite backend from the SQLite3 Multiple Ciphers -# amalgamation (SQLCipher-compatible encryption at rest, self-contained ÔÇö no -# external OpenSSL) instead of plain SQLite. Vendored under -# third_party/sqlcipher/ (not committed ÔÇö see .gitignore). -option(OPENADS_WITH_SQLCIPHER "Build the SQL backend with SQLCipher-compatible encryption (SQLite3 Multiple Ciphers)" OFF) -if(OPENADS_WITH_SQLCIPHER) - set(OPENADS_WITH_SQLITE ON CACHE BOOL "" FORCE) -endif() -# Optional native Firebird table driver (libfbclient; embedded .fdb in-process -# or TCP server). OFF by default. Requires the Firebird client SDK -# (fbclient + ibase.h) discoverable via FIREBIRD_ROOT. -option(OPENADS_WITH_FIREBIRD "Enable the native Firebird-backed table driver (libfbclient)" OFF) -# hbnetio Bridge - VFS adapter, distributed lock manager, RPC bridge -# Bridges harbour hbnetio virtual file system and RPC capabilities -# with OpenADS network transport layer. -option(OPENADS_WITH_HBNETIO_BRIDGE "Build the hbnetio VFS/RPC bridge for OpenADS" OFF) -# Server-side Harbour UDFs: embed hbvm and load a developer .hrb -# module whose functions become available to index expressions -# (LetoDB letoudf.hrb pattern — canonical upstream is Kresin's -# LetoDB, see docs/server-udf-hrb-design.md). OFF by default: -# needs a Harbour SDK (headers + hbvm/hbrtl libs). Core stays free -# of Harbour symbols by design (provider interface in -# engine/hrb_udf.*); the Harbour backend links into openads_serverd -# and unit tests only. -option(OPENADS_WITH_HARBOUR_UDF "Embed hbvm and load a developer .hrb module for server-side index-expression UDFs" OFF) -set(HARBOUR_INCLUDE_DIR "" CACHE PATH "Harbour include dir (hbvm.h) for HRB UDF support") -set(HARBOUR_LIB_DIR "" CACHE PATH "Harbour library dir for HRB UDF support") -set(OPENADS_HARBOUR_LIBS "hbvmmt;hbrtl;hbmacro;hbpcre;hblang;hbcpage;hbzlib;hbrdd;rddfpt;rddntx;hbsix;hbcommon" CACHE STRING "Harbour libraries to link (in order) for HRB UDF support") -if(OPENADS_WITH_HARBOUR_UDF) - if(NOT HARBOUR_INCLUDE_DIR) - find_path(HARBOUR_INCLUDE_DIR NAMES hbvm.h - HINTS $ENV{HB_ROOT}/include C:/harbour-git/include C:/harbour/include - PATH_SUFFIXES harbour) - endif() - if(NOT HARBOUR_INCLUDE_DIR) - message(FATAL_ERROR "OPENADS_WITH_HARBOUR_UDF=ON but hbvm.h not found (set HARBOUR_INCLUDE_DIR)") - endif() - set(HARBOUR_LIBRARIES "") - foreach(_hb ${OPENADS_HARBOUR_LIBS}) - # NOTE: the if() below must name the variable INDIRECTLY - # (if(NOT ${_hb_var})); testing the expanded path value - # instead is always true in CMake and would FATAL every time. - set(_hb_var "HARBOUR_LIB_${_hb}") - find_library(${_hb_var} NAMES ${_hb} - HINTS ${HARBOUR_LIB_DIR} $ENV{HB_ROOT}/lib - PATH_SUFFIXES linux/gcc) - if(NOT ${_hb_var}) - message(FATAL_ERROR "OPENADS_WITH_HARBOUR_UDF=ON but Harbour lib '${_hb}' not found (set HARBOUR_LIB_DIR)") - endif() - list(APPEND HARBOUR_LIBRARIES ${${_hb_var}}) - endforeach() - if(WIN32) - # hbrtl's GT system references the platform default GT driver - # (see Harbour rtl/gtsys.c HB_GT_REQUEST: WIN on Windows, TRM - # on Unix) — link exactly that one. - set(_hb_gt gtwin) - else() - set(_hb_gt gttrm) - endif() - find_library(HARBOUR_LIB_gt NAMES ${_hb_gt} - HINTS ${HARBOUR_LIB_DIR} $ENV{HB_ROOT}/lib - PATH_SUFFIXES linux/gcc) - if(NOT HARBOUR_LIB_gt) - message(FATAL_ERROR "OPENADS_WITH_HARBOUR_UDF=ON but Harbour GT driver '${_hb_gt}' not found (set HARBOUR_LIB_DIR)") - endif() - list(APPEND HARBOUR_LIBRARIES ${HARBOUR_LIB_gt}) - # Harbour archives reference each other in cycles (hbrtl<->hbvm, - # GT back-refs) while GNU ld resolves single-pass — group them so - # every static reference settles. (MSVC iterates archives itself; - # Apple ld accepts no groups.) Deliberately NOT whole-archive: - # runtime-resolved Harbour names are covered explicitly instead - # (our own init table publishes HB_HRB*, see hrb_harbour.cpp; - # UDF names self-register from the loaded .hrb), keeping the - # binary and its static-init surface minimal. - set(HARBOUR_LINK_LIBRARIES "") - if(NOT MSVC AND NOT APPLE) - list(APPEND HARBOUR_LINK_LIBRARIES "-Wl,--start-group") - endif() - list(APPEND HARBOUR_LINK_LIBRARIES ${HARBOUR_LIBRARIES}) - if(NOT MSVC AND NOT APPLE) - list(APPEND HARBOUR_LINK_LIBRARIES "-Wl,--end-group") - endif() - if(WIN32) - list(APPEND HARBOUR_LINK_LIBRARIES winmm iphlpapi) - else() - # gttrm rides on ncurses (libncurses pulls libtinfo). - list(APPEND HARBOUR_LINK_LIBRARIES ncurses) - endif() - message(STATUS "OpenADS: Harbour UDF backend (${HARBOUR_LIBRARIES})") -endif() - -# Static ACE library (libopenace32.a / libopenace64.a) for Harbour + -# MinGW: an hbmk2 -comp=mingw[64] app links the whole ACE client+engine -# into its .exe, so no ace32.dll / ace64.dll has to be deployed. -# MinGW-on-Windows only (the MSVC static-lib story is a separate one). -option(OPENADS_BUILD_ACE_STATIC "Build the static ACE library for Harbour/MinGW" ON) - -# Pull in mbedtls FIRST so our strict /WX -Werror flags don't bleed -# into the upstream sources (C4200 zero-sized arrays etc). Each -# OpenADS target adds the strict flags target-locally below via -# `apply_openads_warnings()`. -if(OPENADS_WITH_TLS) - include(FetchContent) - set(ENABLE_TESTING OFF CACHE BOOL "" FORCE) - set(ENABLE_PROGRAMS OFF CACHE BOOL "" FORCE) - set(MBEDTLS_FATAL_WARNINGS OFF CACHE BOOL "" FORCE) - # Force a STATIC mbedtls so the OpenADS DLL / server binary - # has no runtime dependency on a system OpenSSL. Reported by - # downstream users: a release ZIP without those system DLLs - # fails to launch on a clean Windows / macOS box. Static - # linking sidesteps that whole class of "missing libssl/ - # libcrypto" deployment errors. - set(USE_STATIC_MBEDTLS_LIBRARY ON CACHE BOOL "" FORCE) - set(USE_SHARED_MBEDTLS_LIBRARY OFF CACHE BOOL "" FORCE) - set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) - # Don't let the vendored, statically-linked mbedtls add its own install() - # rules / programs to our package (CPack would otherwise ship mbedtls - # headers + .cmake config). We only link it in. - set(ENABLE_PROGRAMS OFF CACHE BOOL "" FORCE) - set(ENABLE_TESTING OFF CACHE BOOL "" FORCE) - set(MBEDTLS_INSTALL OFF CACHE BOOL "" FORCE) - FetchContent_Declare( - mbedtls - GIT_REPOSITORY https://github.com/Mbed-TLS/mbedtls.git - GIT_TAG v3.6.2 - GIT_SHALLOW TRUE - GIT_SUBMODULES framework - GIT_SUBMODULES_RECURSE TRUE - ) - FetchContent_MakeAvailable(mbedtls) - message(STATUS "OpenADS: TLS enabled via vendored mbedtls 3.6.2 (statically linked)") -endif() - -if(OPENADS_WITH_HTTP) - include(FetchContent) - # cpp-httplib auto-detects OpenSSL / zlib / brotli on the host - # and pulls them in as link libraries on its INTERFACE target. - # That collides with the OpenADS no-runtime-DLL deployment - # promise (the shipped DLL must not depend on libssl / - # libcrypto). Force-disable the auto-link so Linux/macOS - # builds match the Windows side: plain HTTP only, no system - # OpenSSL pulled into ace64.dll / libace64.so. - set(HTTPLIB_USE_OPENSSL_IF_AVAILABLE OFF CACHE BOOL "" FORCE) - set(HTTPLIB_USE_ZLIB_IF_AVAILABLE OFF CACHE BOOL "" FORCE) - set(HTTPLIB_USE_BROTLI_IF_AVAILABLE OFF CACHE BOOL "" FORCE) - set(HTTPLIB_REQUIRE_OPENSSL OFF CACHE BOOL "" FORCE) - # Vendored statically into ace64.dll / serverd ÔÇö don't let httplib or - # nlohmann/json export their headers and *Config.cmake into our package. - set(HTTPLIB_INSTALL OFF CACHE BOOL "" FORCE) - set(JSON_Install OFF CACHE BOOL "" FORCE) - FetchContent_Declare( - cpp_httplib - URL https://github.com/yhirose/cpp-httplib/archive/refs/tags/v0.18.5.tar.gz - URL_HASH SHA256=731190e97acd63edce57cc3dacd496f57e7743bfc7933da7137cb3e93ec6c9a0 - ) - FetchContent_Declare( - nlohmann_json - URL https://github.com/nlohmann/json/archive/refs/tags/v3.11.3.tar.gz - URL_HASH SHA256=0d8ef5af7f9794e3263480193c491549b2ba6cc74bb018906202ada498a79406 - ) - set(JSON_BuildTests OFF CACHE INTERNAL "") - FetchContent_MakeAvailable(cpp_httplib nlohmann_json) - message(STATUS "OpenADS: HTTP web console enabled (cpp-httplib + nlohmann/json)") -endif() - -# Server-side ZIP/UNZIP (backup/restore archiving under --data): -# zlib + classic minizip (ZipCrypto-compatible, what xBase zip tools -# read). Unconditional — small, needed on every leg. Vendored under -# third_party/zlib/ when present (offline builds); else fetched. -# Upstream C, so warnings are off for these TUs (same treatment as -# the SQLite amalgamation below). -set(_openads_zlib_dir "${CMAKE_SOURCE_DIR}/third_party/zlib") -# zlib 1.3.1 registers example/example64 ctest tests (test/example.c). -# CI builds only --target dbf_cdx_bench openads_unit_tests, so those -# binaries are never built and ctest reports "Not Run" -> exit 8/1 on -# every leg (v1.09.60 CI red, release green because it builds all). -# We never ship the examples: keep them out of the tree entirely. -set(ZLIB_BUILD_EXAMPLES OFF CACHE BOOL "" FORCE) -if(NOT EXISTS "${_openads_zlib_dir}/zlib.h") - include(FetchContent) - FetchContent_Declare( - zlib_src - URL https://github.com/madler/zlib/releases/download/v1.3.1/zlib-1.3.1.tar.gz - URL_HASH SHA256=9a93b2b7dfdac77ceba5a558a580e74667dd6fede4585b91eefb60f03b72df23 - ) - set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) - set(SKIP_INSTALL_ALL ON CACHE BOOL "" FORCE) - FetchContent_MakeAvailable(zlib_src) - set(_openads_zlib_dir "${zlib_src_SOURCE_DIR}") - set(_openads_zlib_bin "${zlib_src_BINARY_DIR}") - set(_openads_zlib_target zlibstatic) -else() - # Offline layout: build the vendored tree into a fixed binary dir - # (zconf.h is generated there by zlib's own CMake). - set(_openads_zlib_bin "${CMAKE_BINARY_DIR}/_deps/zlib-build") - set(BUILD_SHARED_LIBS OFF CACHE BOOL "" FORCE) - set(SKIP_INSTALL_ALL ON CACHE BOOL "" FORCE) - add_subdirectory("${_openads_zlib_dir}" "${_openads_zlib_bin}") - set(_openads_zlib_target zlibstatic) -endif() -add_library(openads_minizip STATIC - "${CMAKE_SOURCE_DIR}/third_party/minizip/zip.c" - "${CMAKE_SOURCE_DIR}/third_party/minizip/unzip.c" - "${CMAKE_SOURCE_DIR}/third_party/minizip/ioapi.c") -target_include_directories(openads_minizip SYSTEM PUBLIC - "${CMAKE_SOURCE_DIR}/third_party/minizip" - "${_openads_zlib_dir}" - "${_openads_zlib_bin}") -if(MSVC) - target_compile_options(openads_minizip PRIVATE /w) -else() - target_compile_options(openads_minizip PRIVATE -w) -endif() -target_link_libraries(openads_minizip PUBLIC ${_openads_zlib_target}) -message(STATUS "OpenADS: server-side ZIP support (zlib + minizip)") - -if(OPENADS_WITH_SQLITE) - if(OPENADS_WITH_SQLCIPHER) - # SQLite3 Multiple Ciphers amalgamation ÔÇö SQLCipher-compatible AES at - # rest, self-contained. Vendored under third_party/sqlcipher/. - set(_openads_sqlite_src "${CMAKE_SOURCE_DIR}/third_party/sqlcipher/sqlite3mc_amalgamation.c") - set(_openads_sqlite_inc "${CMAKE_SOURCE_DIR}/third_party/sqlcipher") - if(NOT EXISTS "${_openads_sqlite_src}") - message(FATAL_ERROR - "OPENADS_WITH_SQLCIPHER=ON needs the SQLite3 Multiple Ciphers " - "amalgamation at third_party/sqlcipher/sqlite3mc_amalgamation.c") - endif() - message(STATUS "OpenADS: SQL backend with SQLCipher-compatible encryption (SQLite3 Multiple Ciphers)") - else() - # Use a locally vendored amalgamation under third_party/sqlite/ when - # present (offline / air-gapped builds); otherwise fetch it. The - # amalgamation is not committed ÔÇö see .gitignore. - set(_openads_sqlite_src "${CMAKE_SOURCE_DIR}/third_party/sqlite/sqlite3.c") - set(_openads_sqlite_inc "${CMAKE_SOURCE_DIR}/third_party/sqlite") - if(NOT EXISTS "${_openads_sqlite_src}") - include(FetchContent) - FetchContent_Declare( - sqlite_amalgamation - URL https://www.sqlite.org/2024/sqlite-amalgamation-3460100.zip - URL_HASH SHA256=77823cb110929c2bcb0f5d48e4833b5c59a8a6e40cdea3936b99e199dbbe5784 - ) - FetchContent_MakeAvailable(sqlite_amalgamation) - set(_openads_sqlite_src "${sqlite_amalgamation_SOURCE_DIR}/sqlite3.c") - set(_openads_sqlite_inc "${sqlite_amalgamation_SOURCE_DIR}") - endif() - endif() - add_library(openads_sqlite3 STATIC "${_openads_sqlite_src}") - target_include_directories(openads_sqlite3 SYSTEM PUBLIC "${_openads_sqlite_inc}") - target_compile_definitions(openads_sqlite3 PRIVATE - SQLITE_THREADSAFE=1 - SQLITE_DEFAULT_MEMSTATUS=0) - if(MSVC) - target_compile_options(openads_sqlite3 PRIVATE /wd4267 /wd4244) - else() - target_compile_options(openads_sqlite3 PRIVATE -Wno-conversion) - endif() - 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(NOT _openads_maria_inc OR NOT _openads_maria_lib) - find_path(_openads_maria_inc mysql.h - PATH_SUFFIXES mariadb mysql) - find_library(_openads_maria_lib NAMES mariadb libmariadb mariadbclient) - 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) - # /MP: compile TUs of each project in parallel (one cl.exe per core). - # Without it a full rebuild of the ~340-TU tree runs serially. - add_compile_options(/W4 /permissive- /MP) - 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; - # harmless on Linux/macOS where this define has no effect. - add_compile_definitions(_CRT_SECURE_NO_WARNINGS) - if(OPENADS_WARNINGS_AS_ERRORS) - add_compile_options(-Werror) - endif() -endif() - -add_subdirectory(src) -if(OPENADS_BUILD_ODBC_DRIVER AND WIN32) - add_subdirectory(bindings/odbc) -endif() -add_subdirectory(tools/serverd) -add_subdirectory(tools/adsbackup) -add_subdirectory(tools/bench) -add_subdirectory(tools/stress) -add_subdirectory(tools/mgprobe) -add_subdirectory(tools/import_dd) -add_subdirectory(tools/adi_inspect) - -if(OPENADS_BUILD_TESTS) - enable_testing() - add_subdirectory(tests) -endif() - -# --------------------------------------------------------------------------- -# Install rules + CPack ÔÇö produce a structured, reproducible package with the -# same shape the release.yml staging assembles by hand (engine DLL + drop-in -# ACE-named copy, import libs, server/bench CLIs, headers, docs). Purely -# additive: a plain `cmake --build` is unaffected; `cmake --install` / -# `cpack` are opt-in. -# --------------------------------------------------------------------------- -include(GNUInstallDirs) - -# Windows packages are traditionally flat (drop ace64.dll next to the .exe), -# matching the existing release zip and QUICKSTART; POSIX follows the GNU -# layout so the systemd/DEB story lands binaries under bin/ and lib/. -if(WIN32) - set(OA_INSTALL_BIN ".") - set(OA_INSTALL_LIB "lib") - set(OA_INSTALL_DOC ".") -else() - set(OA_INSTALL_BIN "${CMAKE_INSTALL_BINDIR}") - set(OA_INSTALL_LIB "${CMAKE_INSTALL_LIBDIR}") - set(OA_INSTALL_DOC "${CMAKE_INSTALL_DOCDIR}") -endif() - -if(CMAKE_SIZEOF_VOID_P EQUAL 8) - set(OA_BITS "64") - set(OA_ARCH "x64") -else() - set(OA_BITS "32") - set(OA_ARCH "x86") -endif() - -# Engine shared library (openace64/openace32) + its import lib. -install(TARGETS openads_ace - RUNTIME DESTINATION "${OA_INSTALL_BIN}" - LIBRARY DESTINATION "${OA_INSTALL_BIN}" - ARCHIVE DESTINATION "${OA_INSTALL_LIB}") - -# Drop-in-named copy so apps that load OpenADS by the canonical ACE name -# (ace64.dll / libace64.so / libace64.dylib) work without renaming anything. -if(WIN32) - install(FILES "$" - DESTINATION "${OA_INSTALL_BIN}" RENAME "ace${OA_BITS}.dll") -elseif(APPLE) - install(FILES "$" - DESTINATION "${OA_INSTALL_BIN}" RENAME "libace${OA_BITS}.dylib") -else() - install(FILES "$" - DESTINATION "${OA_INSTALL_BIN}" RENAME "libace${OA_BITS}.so") -endif() - -# Server + bench + backup CLIs. -install(TARGETS openads_serverd openads_bench adsbackup - RUNTIME DESTINATION "${OA_INSTALL_BIN}") - -# Public ACE headers. -install(DIRECTORY "${CMAKE_SOURCE_DIR}/include/" - DESTINATION "${CMAKE_INSTALL_INCLUDEDIR}") - -# License + docs. -install(FILES "${CMAKE_SOURCE_DIR}/LICENSE" - "${CMAKE_SOURCE_DIR}/NOTICE" - "${CMAKE_SOURCE_DIR}/README.md" - DESTINATION "${OA_INSTALL_DOC}") - -# Prebuilt per-compiler import libraries (MSVC / MinGW / Borland), Windows. -if(WIN32 AND EXISTS "${CMAKE_SOURCE_DIR}/dist/import-libs/${OA_ARCH}") - install(DIRECTORY "${CMAKE_SOURCE_DIR}/dist/import-libs/${OA_ARCH}/" - DESTINATION "${OA_INSTALL_LIB}") -endif() - -# Service unit templates (handy for unattended deploys). -install(FILES "${CMAKE_SOURCE_DIR}/tools/scripts/systemd/openads-serverd.service" - "${CMAKE_SOURCE_DIR}/tools/scripts/launchd/com.openads.serverd.plist" - DESTINATION "${OA_INSTALL_DOC}/service" OPTIONAL) - -# Onboarding files that land with sibling PRs (#88 config template, #90 kit). -# OPTIONAL keeps this PR self-contained against upstream/main ÔÇö they install -# automatically once those merge. -install(FILES "${CMAKE_SOURCE_DIR}/openads.ini.sample" - "${CMAKE_SOURCE_DIR}/QUICKSTART.md" - "${CMAKE_SOURCE_DIR}/openads-studio.sh" - "${CMAKE_SOURCE_DIR}/openads-studio.bat" - DESTINATION "${OA_INSTALL_BIN}" OPTIONAL) - -# --- CPack ------------------------------------------------------------------ -set(CPACK_PACKAGE_NAME "openads") -set(CPACK_PACKAGE_VENDOR "OpenADS") -set(CPACK_PACKAGE_VERSION "${OPENADS_VERSION_STR}") -set(CPACK_PACKAGE_DESCRIPTION_SUMMARY "${PROJECT_DESCRIPTION}") -set(CPACK_PACKAGE_HOMEPAGE_URL "https://github.com/FiveTechSoft/OpenADS") -set(CPACK_RESOURCE_FILE_LICENSE "${CMAKE_SOURCE_DIR}/LICENSE") -set(CPACK_VERBATIM_VARIABLES ON) -if(WIN32) - set(_oa_os "windows") -elseif(APPLE) - set(_oa_os "macos") -else() - set(_oa_os "linux") -endif() -set(CPACK_PACKAGE_FILE_NAME "openads-${OPENADS_VERSION_STR}-${_oa_os}-${OA_ARCH}") -if(WIN32) - set(CPACK_GENERATOR "ZIP") -elseif(APPLE) - set(CPACK_GENERATOR "TGZ") -else() - set(CPACK_GENERATOR "TGZ;DEB") - set(CPACK_DEBIAN_PACKAGE_MAINTAINER "OpenADS") - set(CPACK_DEBIAN_PACKAGE_SECTION "database") -endif() -include(CPack) From 9aaac6505d7188e780260689d94f1748f0c5c859 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Sat, 26 Sep 2026 01:28:36 -0500 Subject: [PATCH 48/97] Delete src/network/1-wire.h --- src/network/1-wire.h | 748 ------------------------------------------- 1 file changed, 748 deletions(-) delete mode 100644 src/network/1-wire.h diff --git a/src/network/1-wire.h b/src/network/1-wire.h deleted file mode 100644 index b9935788..00000000 --- a/src/network/1-wire.h +++ /dev/null @@ -1,748 +0,0 @@ -#pragma once - -#include "util/result.h" - -#include -#include -#include - -namespace openads::network { - -// M12.1 — Phase 2 wire-protocol skeleton for the TCP server. -// -// Frame layout (big-endian length, fixed-size header): -// -// bytes 0..3 : payload length (uint32 BE, excludes header) -// byte 4 : opcode -// bytes 5..N : payload -// -// Total frame size = 5 + payload_length. The opcode space is -// reserved here even though the Phase 2 server is not yet wired -// up — apps and tests can already round-trip frames through -// encode_frame / decode_frame. - -enum class Opcode : std::uint8_t { - Hello = 0x01, - HelloAck = 0x02, - Connect = 0x10, - ConnectAck = 0x11, - Disconnect = 0x12, - OpenTable = 0x20, - OpenTableAck = 0x21, - CloseTable = 0x22, - CloseTableAck = 0x23, - ExecuteSQL = 0x30, - ExecuteSQLAck = 0x31, - Fetch = 0x32, - FetchAck = 0x33, - // M12.4 — remote table navigation + read. - GotoTop = 0x40, - GotoTopAck = 0x41, - Skip = 0x42, - SkipAck = 0x43, - GetField = 0x44, - GetFieldAck = 0x45, - GetRecordCount = 0x46, - GetRecordCountAck = 0x47, - // Nav-boundary twin flag: AtEOFAck carries [u8 eof][u8 bof] and - // AtBOFAck carries [u8 bof][u8 eof]. Old single-byte servers send - // only byte 0; old clients read only byte 0 — rddads' AtBOF+AtEOF - // pair therefore collapses to one round-trip when both ends are - // new, with zero fallback paths in any version mix. - AtEOF = 0x48, - AtEOFAck = 0x49, - // M12.14 — remote field metadata + extended cursor state. - // DescribeTable returns the full per-column schema in one - // round-trip so rddads' adsOpen path doesn't need 5 × num_fields - // hops to populate AdsGetFieldName/Type/Length/Decimals. - DescribeTable = 0x4A, - DescribeTableAck = 0x4B, - AtBOF = 0x4C, - AtBOFAck = 0x4D, // [u8 bof][u8 eof] — see AtEOFAck note - GetRecordNum = 0x4E, - GetRecordNumAck = 0x4F, - IsRecordDeleted = 0x62, - IsRecordDeletedAck = 0x63, - GotoBottom = 0x64, - GotoBottomAck = 0x65, - // M12.15 — remote info / lock / maintenance / AOF. - IsFound = 0x66, - IsFoundAck = 0x67, - RefreshRecord = 0x68, - RefreshRecordAck = 0x69, - GetTableType = 0x6A, - GetTableTypeAck = 0x6B, - GetRecordLength = 0x6C, - GetRecordLengthAck = 0x6D, - GetNumIndexes = 0x6E, - GetNumIndexesAck = 0x6F, - GetLastAutoinc = 0x70, - GetLastAutoincAck = 0x71, - LockRecord = 0x72, - LockRecordAck = 0x73, - UnlockRecord = 0x74, - UnlockRecordAck = 0x75, - LockTable = 0x76, - LockTableAck = 0x77, - UnlockTable = 0x78, - UnlockTableAck = 0x79, - PackTable = 0x7A, - PackTableAck = 0x7B, - ZapTable = 0x7C, - ZapTableAck = 0x7D, - FlushFileBuffers = 0x7E, - FlushFileBuffersAck= 0x7F, - CloseAllIndexes = 0x80, - CloseAllIndexesAck = 0x81, - SetAOF = 0x82, - SetAOFAck = 0x83, - ClearAOFRemote = 0x84, - ClearAOFRemoteAck = 0x85, - GetAOFOptLevel = 0x86, - GetAOFOptLevelAck = 0x87, - // M12.16 — remote index handle subsystem. - OpenIndex = 0x88, OpenIndexAck = 0x89, - CloseIndex = 0x8A, - CloseIndexAck = 0x8B, - SetOrder = 0x8C, - SetOrderAck = 0x8D, - SetOrderByName = 0x8E, - SetOrderByNameAck = 0x8F, - Seek = 0x90, - SeekAck = 0x91, - SeekLast = 0x92, - SeekLastAck = 0x93, - CreateIndex = 0x94, - CreateIndexAck = 0x95, - SkipUnique = 0x96, - SkipUniqueAck = 0x97, - SetScope = 0x98, - SetScopeAck = 0x99, - ClearScope = 0x9A, - ClearScopeAck = 0x9B, - // M12.17 — single-frame whole-record read. xbrowse-style - // viewers paint W cols × H rows = W*H FieldGet calls per - // repaint; without this op every cell costs one TCP RTT - // (~5-15 ms LAN), so a 20×12 grid stalls 1-4 s. With this op - // RemoteTable caches the full row server-side and one - // FetchCurrentRow RTT serves every subsequent FieldGet on - // the same record. - FetchCurrentRow = 0x9C, - FetchCurrentRowAck = 0x9D, - // M12.6 — remote write surface. - AppendBlank = 0x50, - AppendBlankAck = 0x51, - SetField = 0x52, - SetFieldAck = 0x53, - // Write coalescing for RDD-only apps (no app change possible): the - // client buffers consecutive AdsSet* calls and flushes them as ONE - // frame on the next visibility event (read/nav/lock/commit/close), - // collapsing N-field voucher entry from N RTTs to 1. Capability- - // gated via kCapSetFieldsBatch in ConnectAck (old servers never see - // 0x5E: the client only sends it when the ack advertised the bit). - // Request SetFields: [u32 tid][u16 n][per field: u16 nlen][name] - // [u32 vlen][value] (n==0 is a no-op success) - // Reply SetFieldsAck: (empty). First failing field stops the apply, - // exactly like a sequential SetField loop failing at the same field. - SetFields = 0x5E, - SetFieldsAck = 0x5F, - DeleteRecord = 0x54, - DeleteRecordAck = 0x55, - RecallRecord = 0x56, - RecallRecordAck = 0x57, - GotoRecord = 0x58, - GotoRecordAck = 0x59, - FlushTable = 0x5A, - FlushTableAck = 0x5B, - // M12.35 — AdsGetKeyType over the wire. Returns the key expression - // result type (ADS_STRING=4, ADS_DATE=3, ADS_NUMERIC=2, ADS_LOGICAL=1) - // for a remote index so the client encodes scope/seek values correctly. - // Request: [u32 index_id] - // Reply: [u16 key_type LE] - GetKeyType = 0x5C, - GetKeyTypeAck = 0x5D, - // M12.8 — remote index ops (CREATE INDEX is already covered by - // M12.7's ExecuteSQL `CREATE INDEX` DDL path; Reindex isn't in - // SQL grammar so it needs a dedicated opcode). - Reindex = 0x60, - ReindexAck = 0x61, - // M12.24 — remote AdsGetLastTableUpdate. Reply payload is the - // DBF header date packed big-endian-ish into 4 bytes: - // (year << 16) | (month << 8) | day. - GetLastTableUpdate = 0x9E, - GetLastTableUpdateAck = 0x9F, - - // M12.36 — record-lock introspection over the wire. Both report the - // per-connection view ("locks THIS session holds"), matching the - // local AdsIsRecordLocked/AdsGetAllLocks semantics that walk the - // Table's own held-lock list. Needed by Harbour dbRecordInfo( - // DBRI_LOCKED) and dbRLockList() under ADSCDX (Vouch IsLogged()). - // Request IsRecordLocked: [u32 tid][u32 recno] (0 = current record) - // Reply IsRecordLockedAck: [u16 locked LE] - IsRecordLocked = 0x13, - IsRecordLockedAck = 0x14, - // Request GetAllLocks: [u32 tid] - // Reply GetAllLocksAck: [u16 count][u32 recno LE]... - GetAllLocks = 0x15, - GetAllLocksAck = 0x16, - - // M9.25 — management telemetry channel. - MgConnect = 0xA0, - MgConnectAck = 0xA1, - MgRequest = 0xA2, - MgReplyAck = 0xA3, - - // Tier-2 server-side filtered scan. Like Fetch (0x32) but the - // server evaluates a Clipper-style FOR predicate per row and - // returns only matching rows + the requested columns, walking - // the table server-side until `max_rows` matches or EOF. Removes - // the per-record Skip/GetField round-trips for a `SET FILTER` / - // `COUNT FOR` / `LOCATE FOR` scan whose predicate is outside the - // index-optimisable AOF subset (where the client RDD would - // otherwise filter row-by-row over the wire). - FetchWhere = 0xA4, - FetchWhereAck = 0xA5, - - // Tier-3 server-side aggregation. The server scans the whole table - // once, evaluating an xBase-style FOR predicate per row, and folds - // each matching row into the requested COUNT / SUM / AVG / MIN / MAX - // accumulators — returning just the scalars instead of streaming - // every matching row back. Collapses `COUNT FOR` / `SUM .. FOR` / - // `AVERAGE` / totalling reports from O(matched rows over the wire) - // to a single round-trip. Request payload: - // [u32 tid][u16 forlen][for_expr][u8 n_aggs] - // per agg: [u8 fn_type][u8 nlen][field_name] (nlen=0 => COUNT(*)) - // Reply (AggregateAck): - // [u8 n_aggs] per agg: [u8 result_type][u16 vlen][val] - // fn_type: 0=COUNT 1=SUM 2=AVG 3=MIN 4=MAX (engine::AggFn). - // result_type: 0=empty/null 1=numeric(ASCII) 2=string (engine::AggType). - Aggregate = 0xA6, - AggregateAck = 0xA7, - - // M12.25 — raw record image read/write (AdsGetRecord / AdsSetRecord). - // Request GetRecord: [u32 tid] - // Reply GetRecordAck: [u16 len][record bytes] - // Request SetRecord: [u32 tid][u16 len][record bytes] - // Reply SetRecordAck: (empty) - GetRecord = 0xA8, - GetRecordAck = 0xA9, - SetRecord = 0xAA, - SetRecordAck = 0xAB, - - // M12.26 — AdsCustomizeAOF: flip individual records in/out of the - // active AOF bitmap. Payload: - // [u32 tid][u8 option][u16 nrecs][u32 recno]... - // option: 1=ADD (ADS_AOF_ADD_RECORD), 2=REMOVE (ADS_AOF_REMOVE_RECORD). - CustomizeAOF = 0xAC, - CustomizeAOFAck = 0xAD, - - // M12.27 — AdsGetRecordCRC over the wire. Request: [u32 tid] - // Reply GetRecordCRAck: [u32 crc LE] (IEEE CRC-32 over record_buffer). - GetRecordCRC = 0xAE, - GetRecordCRAck = 0xAF, - - // M12.28 -- remote AdsGetKeyCount: filtered key count from the - // active index order (conditional FOR tags index only matching - // rows, so this differs from GetRecordCount's physical row count). - // Request: [u32 table_id] - // Reply: [u32 key_count LE] - GetKeyCount = 0xB0, - GetKeyCountAck = 0xB1, - // M12.29 — remote AdsGetKeyNum: current key number in the active - // order's walk. Server computes via pos_of_recno_cached() → O(1). - // Request: [table_id u32 LE] - // Response: [key_num u32 LE] (0 = no order / not positioned) - GetKeyNum = 0x03, - GetKeyNumAck = 0x04, - - // M12.33 — remote AdsFindFirstTable / AdsFindNextTable / AdsFindClose. - // List table files matching a glob mask in the session data directory. - // Unlike the Directory opcode (0xEA), this is NOT gated by - // EnableFileFunc — listing tables is a core database operation that - // arc32 and other DBA tools need. - // Request FindTables: [u16 maskLen][mask] - // Reply FindTablesAck: [u32 nFiles][for each: u16 nameLen][name] - // The client caches the full list; FindNext/FindClose iterate locally. - FindTables = 0x05, - FindTablesAck = 0x06, - - // M12.34 — Transaction lifecycle over the wire. Request payloads are - // empty; ack payloads are empty. Success is indicated by the matching - // Ack opcode; failure by Opcode::Error. - BeginTransaction = 0x07, - BeginTransactionAck = 0x08, - CommitTransaction = 0x09, - CommitTransactionAck = 0x0A, - RollbackTransaction = 0x0B, - RollbackTransactionAck = 0x0C, - - // M12.34 — find a record by identity columns over the wire. - // Request: [u32 tid][u16 nident][for each: u16 nlen][name][u16 vlen][value] - // Reply: [u32 recno] (0 = not found) - FindRecord = 0x0D, - FindRecordAck = 0x0E, - - // Server-side backup archiving (OAds_Zip/OAds_UnZip). Connection- - // level ops (no table id); paths stay under the session data jail - // and archives land in /backup/_YYYYMMDD.zip. - // NOT gated by EnableFileFunc (database ops, like FindTables). - // File lists ride as one 0x1F-joined blob (0x1F cannot occur in - // a file name on any OS); u32 lengths where a file set can exceed - // 64 KiB of names. - // Request ZipArchive: - // [u16 dirLen][dir][u32 filesLen][0x1F files] - // [u16 zipNameLen][zipName][u16 level][u8 overwrite][u8 withPath] - // [u32 exclLen][0x1F excludes][u16 pwdLen][password] - // Reply ZipArchiveAck: - // [u32 files][u64 bytes][u64 archiveBytes][u16 arcLen][archiveRel] - ZipArchive = 0x17, - ZipArchiveAck = 0x18, - // Request UnzipArchive: - // [u16 dirLen][dir][u16 zipLen][zip][u16 pwdLen][password] - // [u8 overwrite][u8 withPath] - // Reply UnzipArchiveAck: - // [u32 files][u64 bytes][u64 archiveBytes] - UnzipArchive = 0x19, - UnzipArchiveAck = 0x1A, - // Central-directory listing (OAds_ZipFileCount/OAds_ZipFileList). - // Connection-level op (no table id); the archive spelling follows - // the unzip rule (bare names under backup/). NOT gated by - // EnableFileFunc. Needs no password (contents stay encrypted). - // Request ZipList: [u16 zipLen][zip] - // Reply ZipListAck: [u32 count][packed ZipEntry records, see - // engine/zip_arch.h pack_zip_entry; length-gated parse] - ZipList = 0x1B, - ZipListAck = 0x1C, - - // M12.29 — AdsDD* Data Dictionary property API, phase 1. Previously - // every AdsDD* getter/setter silently returned empty/no-op over a - // remote connection (dd_from_handle() only resolves a LOCAL Connection - // handle) instead of erroring or forwarding — see docs/wire-protocol.md - // §9. These opcodes cover the ~19 Get/Set*Property functions (all share - // the same name[,subName]+propertyId+value shape — see DDObjectKind - // below) plus the exact create/drop calls DA-Web exercises today. - // Everything else in the AdsDD* surface (user/group/link/RI CRUD, - // permissions bitmask, index-file add/remove) is deferred to a phase 2 - // — those already work remotely via SQL EXECUTE PROCEDURE in DA-Web. - // - // Request DDGetProperty: [u8 objKind][u16 nameLen][name] - // [u16 subNameLen][subName][u16 propId] - // Reply DDGetPropertyAck: [u32 valLen][value bytes] - DDGetProperty = 0xB2, - DDGetPropertyAck = 0xB3, - // Request DDSetProperty: [u8 objKind][u16 nameLen][name] - // [u16 subNameLen][subName][u16 propId] - // [u32 valLen][value bytes] - // Reply DDSetPropertyAck: (empty) - DDSetProperty = 0xB4, - DDSetPropertyAck = 0xB5, - // Request DDCreateProc: [u16 nameLen][name][u16 containerLen][container] - // [u16 procNameLen][procName][u16 inParamsLen][inParams] - // [u16 outParamsLen][outParams][u16 commentsLen][comments] - // Reply DDCreateProcAck: (empty) - DDCreateProc = 0xB6, - DDCreateProcAck = 0xB7, - // Request DDCreateFunction: [u16 nameLen][name][u16 containerLen][container] - // [u16 implLen][implementation][u16 retTypeLen][retType] - // [u16 inParamsLen][inParams][u16 commentLen][comment] - // Reply DDCreateFunctionAck: (empty) - DDCreateFunction = 0xB8, - DDCreateFunctionAck= 0xB9, - // Request DDCreateTrigger: [u16 nameLen][name][u16 tableLen][table] - // [u32 type][u16 containerLen][container][u16 procedureLen][procedure] - // [u32 priority] - // Reply DDCreateTriggerAck: (empty) - DDCreateTrigger = 0xBA, - DDCreateTriggerAck = 0xBB, - // Request DDDropTrigger: [u16 nameLen][name] - // Reply DDDropTriggerAck: (empty) - DDDropTrigger = 0xBC, - DDDropTriggerAck = 0xBD, - // Request DDDropView: [u16 nameLen][name] - // Reply DDDropViewAck: (empty) - DDDropView = 0xBE, - DDDropViewAck = 0xBF, - // Request DDDropLink: [u16 nameLen][name] - // Reply DDDropLinkAck: (empty) - DDDropLink = 0xC0, - DDDropLinkAck = 0xC1, - - // M12.30 — AdsDD* Data Dictionary property API, phase 2. Covers the - // remaining CRUD calls deferred by phase 1 (M12.29, see wire-protocol.md - // §5.24/§5.25): user/group management, links, referential integrity - // create, views, index-file registration, and permissions. Two more of - // the deferred functions (GetIndexProperty, GetUserTableRights/ - // SetUserTableRights) reuse the existing DDGetProperty/DDSetProperty - // opcodes via two new DDObjectKind values below — no new opcode needed - // for those. AdsDDRevokePermission is a pure local wrapper around - // AdsDDGrantPermission(..., 0) and needs no wire support of its own. - // - // Request DDCreateUser: [u16 groupLen][group][u16 userLen][user] - // [u16 pwdLen][pwd][u16 descLen][desc] - // Reply DDCreateUserAck: (empty) - DDCreateUser = 0xC2, - DDCreateUserAck = 0xC3, - // Generic drop-by-name, parallel to DDGetProperty's DDObjectKind tag. - // Covers AdsDDDeleteUser(User), AdsDDRemoveRefIntegrity(RefIntegrity), - // AdsDDDropProcedure(Proc), AdsDDDropFunction(Function) — the four - // remaining plain "drop by name" calls (Trigger/View/Link already have - // their own phase-1 opcodes). - // Request DDDropObject: [u8 objKind][u16 nameLen][name] - // Reply DDDropObjectAck: (empty) - DDDropObject = 0xC4, - DDDropObjectAck = 0xC5, - // Request DDAddUserToGroup: [u16 groupLen][group][u16 userLen][user] - // Reply DDAddUserToGroupAck: (empty) - DDAddUserToGroup = 0xC6, - DDAddUserToGroupAck = 0xC7, - // Request DDRemoveUserFromGroup: [u16 groupLen][group][u16 userLen][user] - // Reply DDRemoveUserFromGroupAck: (empty) - DDRemoveUserFromGroup = 0xC8, - DDRemoveUserFromGroupAck = 0xC9, - // Request DDCreateLink: [u16 aliasLen][alias][u16 pathLen][path] - // [u16 userLen][user][u16 pwdLen][pwd] - // Reply DDCreateLinkAck: (empty) - DDCreateLink = 0xCA, - DDCreateLinkAck = 0xCB, - // Request DDModifyLink: same payload as DDCreateLink. - // Reply DDModifyLinkAck: (empty) - DDModifyLink = 0xCC, - DDModifyLinkAck = 0xCD, - // Request DDCreateRefIntegrity: [u16 nameLen][name][u16 failLen][fail] - // [u16 parentLen][parent][u16 parentTagLen][parentTag] - // [u16 childLen][child][u16 childTagLen][childTag] - // [u16 updateRule][u16 deleteRule] - // Reply DDCreateRefIntegrityAck: (empty) - DDCreateRefIntegrity = 0xCE, - DDCreateRefIntegrityAck = 0xCF, - // Request DDCreateView: [u16 nameLen][name][u16 commentsLen][comments] - // [u32 sqlLen][sql] (u32: view SQL can be long, unlike short names) - // Reply DDCreateViewAck: (empty) - DDCreateView = 0xD0, - DDCreateViewAck = 0xD1, - // Request DDAddIndexFile: [u16 tableLen][table][u16 indexLen][index] - // [u16 commentLen][comment] - // Reply DDAddIndexFileAck: (empty) - DDAddIndexFile = 0xD2, - DDAddIndexFileAck = 0xD3, - // Request DDRemoveIndexFile: [u16 tableLen][table][u16 indexLen][index] - // Reply DDRemoveIndexFileAck: (empty) - DDRemoveIndexFile = 0xD4, - DDRemoveIndexFileAck = 0xD5, - // Request DDGetPermissions: [u16 granteeLen][grantee][u16 objType] - // [u16 objNameLen][objName][u8 getInherited] - // Reply DDGetPermissionsAck: [u32 permissions] - DDGetPermissions = 0xD6, - DDGetPermissionsAck = 0xD7, - // Request DDGrantPermission: [u16 objType][u16 objNameLen][objName] - // [u16 granteeLen][grantee][u32 permissions] - // Reply DDGrantPermissionAck: (empty). Revoke = grant with permissions=0 - // (matches AdsDDRevokePermission's local implementation). - DDGrantPermission = 0xD8, - DDGrantPermissionAck = 0xD9, - - // M12.31 — AdsShowDeleted (SET DELETED ON/OFF) over the wire. - // Without this, the client's SET DELETED flag never reaches - // openads_serverd: scoped/ordered GotoTop/Skip on the server - // walk index keys with show_deleted=true and return rows flagged - // deleted even though LOCAL mode hides them. - // Request: [u8 show] (0 = hide deleted, 1 = show deleted) - // Reply ShowDeletedAck: (empty) - ShowDeleted = 0xDA, - ShowDeletedAck = 0xDB, - - // Remote AdsCreateTable / AdsDropTable. Without these, a client that - // AdsConnect60'd to tcp://… fell through AdsCreateTable's local - // Connection lookup, wrote the .dbf next to the client app (cwd of - // the default local connection), then AdsOpenTable (which *does* - // route remote) failed with AE_TABLE_CORRUPTED (5103) because the - // file never landed under the server data directory. - // - // Request CreateTable: - // [u16 tableType][u16 charType][u16 memoBlockSize] - // [u16 nameLen][name][u16 fieldsLen][fields] - // Reply CreateTableAck: (empty) — client re-opens via OpenTable so - // production-bag auto-open / GotoTop reuse the existing path. - CreateTable = 0xDC, - CreateTableAck = 0xDD, - // Request DropTable: [u16 nameLen][name][u16 deleteFiles] - // Reply DropTableAck: (empty) - DropTable = 0xDE, - DropTableAck = 0xDF, - - // Server filesystem API (oads_* / Ads*) — gated by EnableFileFunc. - // Paths are relative to the session data directory (jail). - // FileExists: [u16 pathLen][path] → FileExistsAck: [u8 exists] - FileExists = 0xE0, - FileExistsAck = 0xE1, - // FileErase: [u16 pathLen][path] → empty ack - FileErase = 0xE2, - FileEraseAck = 0xE3, - // FileRename: [u16 oldLen][old][u16 newLen][new] → empty ack - FileRename = 0xE4, - FileRenameAck = 0xE5, - // FileSize: [u16 pathLen][path] → FileSizeAck: [u64 size LE] - FileSize = 0xE6, - FileSizeAck = 0xE7, - // FileMTime: [u16 pathLen][path] → [u16 y][u8 mon day hh mm ss] - FileMTime = 0xE8, - FileMTimeAck = 0xE9, - // Directory: [u16 maskLen][mask][u16 attr] → [u32 n][entries…] - Directory = 0xEA, - DirectoryAck = 0xEB, - DirExist = 0xEC, - DirExistAck = 0xED, - DirMake = 0xEE, - DirMakeAck = 0xEF, - DirRemove = 0xF0, - DirRemoveAck = 0xF1, - // FOpen: [u16 pathLen][path][u16 mode] → [u32 file_id] - FOpen = 0xF2, - FOpenAck = 0xF3, - // FCreate:[u16 pathLen][path][u16 attr] → [u32 file_id] - FCreate = 0xF4, - FCreateAck = 0xF5, - // FClose: [u32 file_id] - FClose = 0xF6, - FCloseAck = 0xF7, - // FRead: [u32 file_id][u32 nbytes] → [u32 nread][bytes] - FRead = 0xF8, - FReadAck = 0xF9, - // FWrite: [u32 file_id][u32 nbytes][bytes] → [u32 nwritten] - FWrite = 0xFA, - FWriteAck = 0xFB, - // FSeek: [u32 file_id][i32 offset][u8 origin] → [u32 position] - FSeek = 0xFC, - FSeekAck = 0xFD, - - // M12.32 — Distributed mutex service (sub-opcode multiplexed). - // Request Mutex: [u8 sub_op][u16 nameLen][name][optional fields] - // Reply Mutex: [u8 sub_op][u8 ok][optional fields] - // - // Sub-ops: - // 0x01 Create: [u8=0x01][u16 nameLen][name] - // Ack: [u8=0x01][u8 ok] - // 0x02 Lock: [u8=0x02][u16 nameLen][name][u32 timeout_ms] - // Ack: [u8=0x02][u8 ok] - // 0x03 TryLock: [u8=0x03][u16 nameLen][name] - // Ack: [u8=0x03][u8 ok] - // 0x04 Unlock: [u8=0x04][u16 nameLen][name] - // Ack: [u8=0x04][u8 ok] - // 0x05 Destroy: [u8=0x05][u16 nameLen][name] - // Ack: [u8=0x05][u8 ok] - Mutex = 0xFE, - - Error = 0xFF, -}; - -// M12.29 — object kind discriminator for DDGetProperty/DDSetProperty. -// Every Get/Set*Property function in the AdsDD* ABI shares the same -// name[,subName]+propertyId+value shape; this tags which local -// AdsDDGet*Property/AdsDDSet*Property function the server should call. -// subName is only meaningful for Field (the field name within the table -// named by `name`); every other kind sends an empty subName. -// -// M12.30 additions: Index (name=table, subName=index — GetIndexProperty -// only; SetIndexProperty is a permanent local stub regardless of -// connection type, so it isn't wired) and UserTableRights (name=table, -// subName=user, propId unused, value = 4-byte LE access level — the -// underlying local functions take/return a raw UNSIGNED32, not a -// property-id-keyed buffer, but the wire shape is identical so it reuses -// DDGetProperty/DDSetProperty instead of adding two more opcodes). -enum class DDObjectKind : std::uint8_t { - Database = 1, - User = 2, - Table = 3, - Field = 4, - Trigger = 5, - Proc = 6, - Function = 7, - View = 8, - RefIntegrity = 9, - Index = 10, - UserTableRights = 11, -}; - -// Request flags for FetchWhere (Opcode::FetchWhere = 0xA4). -// Set WANT_RECNO in the flags byte to make the server emit a u32 LE -// record number before each row's column data in the FetchWhereAck -// payload. A flags value of 0 produces a reply byte-identical to the -// v1.4.0 wire (no recno field) — full backward compatibility. -namespace FetchWhereFlags { - constexpr std::uint8_t WANT_RECNO = 0x01; -} - -// Inbound cap — symmetric with encode_frame's outbound check; prevents -// multi-gigabyte resize on a malicious 4-byte length prefix. -inline constexpr std::uint32_t kMaxFramePayload = 16u * 1024u * 1024u; - -// M12.32 — Mutex sub-opcodes (payload[0] of Opcode::Mutex frames). -enum class MutexOp : std::uint8_t { - Create = 0x01, - Lock = 0x02, - TryLock = 0x03, - Unlock = 0x04, - Destroy = 0x05, -}; - -// Upper bound on field count in a single wire row — guards against -// malicious/corrupt servers or clients allocating unbounded vectors. -inline constexpr std::uint16_t kMaxWireFields = 4096u; - -// M12.21 option C — client capability flags, advertised as a trailing -// [u32 LE] appended to the Connect payload (after the password field). -// A server only piggybacks the sequential-prefetch lookahead block onto -// forward-Skip acks for clients that set kCapPrefetchConsume, because -// draining that queue locally requires the consumed-counter cursor -// resync. Older clients omit the field (caps = 0) and keep the -// one-round-trip-per-Skip behavior, so the wire stays backward -// compatible in every version-mix direction. -inline constexpr std::uint32_t kCapPrefetchConsume = 0x00000001u; - -// Tier-3: the client understands the Aggregate / AggregateAck opcodes -// (0xA6/0xA7). A server only routes a `COUNT/SUM/.. FOR` to the -// server-side accumulator path for clients that advertise this; older -// servers never see a 0xA6 frame, so the wire stays backward compatible. -inline constexpr std::uint32_t kCapAggregate = 0x00000002u; - -// RCB 07/15/2026: M12.25 — the client can drain a BACKWARD lookahead block -// (PgUp). This MUST be its own capability, not a free extension of -// kCapPrefetchConsume, because it is a correctness gate rather than an -// optimization: the read-ahead block is a plain [count][rows] list with no -// direction marker on the wire, and a kCapPrefetchConsume-only client (which -// only knows how to drain a block forward) would pop a backward-walked row on -// its next Skip(+1) and serve the WRONG record. A server therefore attaches a -// backward block only to clients that set this bit; everyone else keeps paying -// one round-trip per backward step, exactly as before. Both mix directions stay -// safe: an old server ignores the bit, and a new server never sends a backward -// block to a client that did not advertise it. -inline constexpr std::uint32_t kCapPrefetchBackward = 0x00000004u; - -// M12.x — OpenTable mode pass-through. When the client sets this bit, it -// prepends a [u16 LE mode] to every OpenTable payload. The server reads -// that prefix and opens the table in the requested mode (Shared / Exclusive / -// ReadOnly). Old servers ignore the prefix because they treat the entire -// payload as the table name and the leading null bytes make the path invalid; -// the open_table fallback then fails with AE_TABLE_NOT_FOUND (5018). -inline constexpr std::uint32_t kCapOpenTableMode = 0x00000008u; - -// Write coalescing (SetFields 0x5E). Client→server: advertised in the -// Connect caps word like the other bits (old servers ignore unknown -// bits). Server→client: echoed in ConnectAck (see Session::dispatch -// Connect) — the client sends 0x5E only when the ack carried this bit, -// so an old server never receives the frame and needs no fallback path. -inline constexpr std::uint32_t kCapSetFieldsBatch = 0x00000010u; - -// Flush merged into CloseAllIndexes: the server flushes table data -// inside the CloseAllIndexes handler before dropping bindings, so a -// preceding FlushFileBuffers is redundant. Same two-way gating as -// kCapSetFieldsBatch — clients merge only when the ConnectAck echo -// carries this bit. -inline constexpr std::uint32_t kCapFlushInCloseAll = 0x00000020u; - -// Fused nav+order (SetOrder+GotoTop/GotoBottom in one frame): the -// GotoTop request carries an optional trailing [u8 0x01][u32 order_id] -// after the depth hint (GotoBottom: right after the table id), and the -// server installs the order before navigating. Same two-way gating; -// old servers ignore the trailer (prefix-only parse), old clients -// never emit it. -inline constexpr std::uint32_t kCapNavOrderFuse = 0x00000040u; - -// Durable FlushTable: the server's FlushTable handler already performs -// the full file-buffers flush (ABI twin via AdsFlushFileBuffers, then -// the engine table), i.e. exactly the work of a standalone -// FlushFileBuffers frame. A client that sees this bit clears its dirty -// flag when its own FlushTable lands and skips the trailing -// FlushFileBuffers -- one frame per commit instead of two. Same -// two-way gating as kCapFlushInCloseAll; old servers never echo it, so -// the client keeps sending both frames there. -inline constexpr std::uint32_t kCapFlushTableDurable = 0x00000080u; - -// Boundary-pair certification (mtfix12 R1): a GotoTop/GotoBottom request -// may carry one more trailing flag bit (see the flag bytes below) asking -// the server to read the OPPOSITE boundary in the same atomic visit and -// append its full landing state to the ack (row blob + bound values + -// scoped key count). The client stamps that certification and serves a -// back-to-back opposite-boundary call (the dbGoTop(); dbGoBottom() -// ritual) with zero frames between proof and serve -- the same -// conn-wide freshness envelope the shipped duplicate-suppression uses. -// Same two-way gating as kCapNavOrderFuse: the client only sets the -// flag when the ConnectAck echo carries this bit, so old servers never -// see it and old clients never emit it. -// -// Request layouts (new server parses bits, old layouts stay valid): -// GotoTop: [u32 id][u16 depth][u8 flags]([u32 order]) -// GotoBottom: [u32 id][u8 flags]([u32 order]) -// flags bit 0x01 = fused order section present (kCapNavOrderFuse) -// flags bit 0x02 = boundary-pair certification requested (this bit) -// Pre-mtfix12 clients send flags == 0x01 exactly when fusing, which the -// new server reads as "fused, no pair" -- bit-exact with the old parse. -inline constexpr std::uint32_t kCapNavBoundaryPair = 0x00000100u; - -// Warm OpenTableAck sections (USE latency). After the fixed -// `[u32 id][u16 bag_len][bag]` prefix, the ack carries -// `[u8 section_count]` then that many TLVs: -// `[u8 tag][u32 len LE][bytes]` -// Tag 1 (schema) reuses the DescribeTableAck body layout; tag 2 -// (first-row) reuses the nav-ack row-trailer layout (row + optional -// lookahead block, same bytes pack_row_trailer emits). Clients that -// predate sections stop after the bag field (which is always emitted, -// empty when absent) and fall back to DescribeTable + GotoTop; new -// clients skip both round-trips. Unknown tags are skipped by length, -// so the section list stays extensible in both directions. -namespace OpenTableAckSections { - constexpr std::uint8_t kSchema = 1; - constexpr std::uint8_t kFirstRow = 2; -} - -// RCB 07/14/2026: M12.23 — AdsCacheRecords support. Why a bare trailing field -// and not a new opcode or a capability bit: the Skip request grew an OPTIONAL -// trailing [u16 LE] carrying the caller's requested read-ahead depth: -// -// Skip: [u32 tid][i32 step] <- pre-M12.23, still valid -// Skip: [u32 tid][i32 step][u16 depth] <- M12.23 -// -// No capability bit is needed, because this is compatible in BOTH -// version-mix directions by construction: -// * new client -> old server: the old handler length-checks (`size() < 8`) -// and reads only the first 8 bytes, so the trailing field is ignored and -// the server keeps choosing the depth itself. -// * old client -> new server: the field is simply absent, which the new -// handler reads as kPrefetchDepthAuto (below). -// -// RCB 07/14/2026: this sentinel is 0xFFFF and deliberately NOT 0. SAP gives 0 -// a real meaning — "0 (or 1) effectively turns read-ahead record caching off" -// (ace_adscacherecords.htm). So "the caller said nothing" and "the caller said -// stop caching" are DIFFERENT instructions and the server has to tell them -// apart: the first ramps, the second sends no block at all. Had I let an -// absent field decode as 0, every pre-M12.23 client — which sends no field — -// would have silently lost read-ahead the day this shipped. Pinned by -// tests/unit/network_skip_depth_test.cpp, which hand-builds a legacy 8-byte -// Skip because the ABI client can no longer emit one. -inline constexpr std::uint16_t kPrefetchDepthAuto = 0xFFFFu; - -// RCB 07/14/2026: safety cap on a caller-requested depth. usRecords is a u16, -// so an app can ask for 65534 rows in one block. The byte budget would stop -// that from becoming an oversized frame, but NOT from walking 65534 records on -// the server thread first — the cost we actually care about. SAP's own -// guidance is that "read-ahead values greater than 100 records are not -// beneficial", so this bound sits far above anything useful and exists purely -// to keep one wire request from turning into an unbounded scan. -inline constexpr std::uint16_t kPrefetchDepthMax = 512u; - -struct Frame { - Opcode opcode = Opcode::Hello; - std::vector payload; -}; - -// Encode `f` to a flat byte buffer ready to send over TCP. -util::Result> encode_frame(const Frame& f); - -// Decode `f` from `buf` (must contain at least one full frame). -// Returns the decoded frame plus the number of bytes consumed via -// the optional `consumed` out-param. -util::Result decode_frame(const std::uint8_t* buf, - std::size_t size, - std::size_t* consumed = nullptr); - -} // namespace openads::network From 5f3dc7d1ec9d9018bb9557d15658b3de0f68c2b2 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Sat, 26 Sep 2026 01:28:48 -0500 Subject: [PATCH 49/97] Delete src/network/2-client.h --- src/network/2-client.h | 1149 ---------------------------------------- 1 file changed, 1149 deletions(-) delete mode 100644 src/network/2-client.h diff --git a/src/network/2-client.h b/src/network/2-client.h deleted file mode 100644 index ef4b1e93..00000000 --- a/src/network/2-client.h +++ /dev/null @@ -1,1149 +0,0 @@ -#pragma once - -#include "network/server.h" -#include "network/socket.h" -#include "network/transport.h" -#include "network/wire.h" -#include "engine/aggregate.h" -#include "engine/server_fs.h" -#include "engine/zip_arch.h" -#include "util/result.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace openads::network { - -// Result type for RemoteConnection::fetch_where. `rows` carries the -// matched column values (row-major); `recnos` is populated iff -// FetchWhereFlags::WANT_RECNO was set in the request flags (one entry -// per row, same order as rows). `eof` is true when the server walked -// to the end of the table during this call. -struct FetchWhereBatch { - std::vector> rows; - std::vector recnos; // 1 per row iff WANT_RECNO - bool eof = false; -}; - -// One requested aggregate (function + column; empty field = COUNT(*)). -// Defined in engine/aggregate.h so the SQL-backend push-down (abi layer) -// and the wire client share one type. -using AggSpec = engine::AggSpec; - -// Result of RemoteConnection::aggregate — one scalar per requested AggSpec, -// in the same order. -struct AggregateBatch { - std::vector values; -}; - -struct RemoteTable; - -// Diagnostics only: optional per-frame hook, called after every -// completed request/reply round trip (wire_trace). nullptr = off, which -// is the default; the request path then pays one relaxed atomic load. -// Arguments: connection, request opcode, first u32 of the request -// payload (the table id for table-scoped opcodes; meaningless for -// Hello/Connect/OpenTable), request payload bytes, reply opcode, reply -// payload bytes, microseconds spent in send+receive. -using FrameTraceHook = void (*)(const void* conn, std::uint8_t op, - std::uint32_t tid, std::size_t req_bytes, - std::uint8_t rep_op, std::size_t rep_bytes, - long long us); -void set_frame_trace_hook(FrameTraceHook hook) noexcept; - -// M12.5 — wire client used by ace64.dll's dual-mode dispatch. -// `RemoteConnection` opens a TCP socket to an OpenADS server, -// sends a Connect frame for the data_dir, and exposes a small -// surface mirroring the local Connection methods that the -// remote-routed Ads* functions need. - -class RemoteConnection { -public: - RemoteConnection() = default; - ~RemoteConnection() { disconnect(); } - RemoteConnection(const RemoteConnection&) = delete; - RemoteConnection& operator=(const RemoteConnection&) = delete; - - util::Result connect(const std::string& host, - std::uint16_t port, - const std::string& data_dir, - const std::string& user = "", - const std::string& password = ""); - - // M12.12 — connect using a pre-built transport (e.g. a - // TlsTransport built by `connect_tls`). The Connect frame is - // sent through the supplied transport; ownership passes to - // RemoteConnection. - util::Result - connect_with_transport(std::unique_ptr transport, - const std::string& data_dir, - const std::string& user = "", - const std::string& password = ""); - - void disconnect() noexcept; - - // M12.34 — remote transaction lifecycle. - util::Result begin_transaction(); - util::Result commit_transaction(); - util::Result rollback_transaction(); - - bool valid() const noexcept { - return transport_ && transport_->valid(); - } - - // Server capability word echoed in ConnectAck (see Session::dispatch). - // Bit kCapSetFieldsBatch => the server implements SetFields (0x5E); - // the write-coalescing buffer below only batches when this is set, - // otherwise every set goes out as a single SetField exactly as before. - bool server_setfields_batch() const noexcept { - return (server_caps_ & kCapSetFieldsBatch) != 0; - } - - // Server flushes inside CloseAllIndexes (see kCapFlushInCloseAll): - // a deferred FlushFileBuffers merges into that single frame. - bool server_flush_in_closeall() const noexcept { - return (server_caps_ & kCapFlushInCloseAll) != 0; - } - - // Server installs an order section on GotoTop/GotoBottom - // (see kCapNavOrderFuse): SetOrder+Goto can go out as one frame. - bool server_nav_order_fuse() const noexcept { - return (server_caps_ & kCapNavOrderFuse) != 0; - } - - // Server's FlushTable handler does the full file-buffers flush - // (see kCapFlushTableDurable): a commit needs no trailing - // FlushFileBuffers frame. - bool server_flush_table_durable() const noexcept { - return (server_caps_ & kCapFlushTableDurable) != 0; - } - - // Server certifies the opposite boundary on GotoTop/GotoBottom - // (see kCapNavBoundaryPair): one frame proves both ends, so a - // back-to-back dbGoTop(); dbGoBottom() ritual costs one RTT. - bool server_nav_boundary_pair() const noexcept { - return (server_caps_ & kCapNavBoundaryPair) != 0; - } - - // Current cursor-generation sequence (see nav_seq_). Relaxed load - // is enough: it only orders the ABI layer's own duplicate - // detection, never data. - std::uint64_t nav_seq() const noexcept { - return nav_seq_.load(std::memory_order_relaxed); - } - - // mtfix12 — per-table generations (R1 pair guard / R2 per-table - // envelope). Keyed by wire table id, bumped inside request() for - // every cursor/visibility-affecting frame (nav) and - // content-affecting frame (write) — central by construction, so no - // ABI call site can miss one. Index-keyed ops (Seek/SeekLast/ - // SkipUnique/SetScope/ClearScope) and the connection-wide - // ShowDeleted bump through note_tbl_nav/note_all_tables_nav from - // the methods that know the binding. Table-id reuse after a close - // only starts a new table's counter higher — conservative, never - // stale-accepting. - std::uint64_t tbl_nav_seq(std::uint32_t id); - std::uint64_t tbl_write_gen(std::uint32_t id); - // Index-keyed cursor op whose parent table the caller resolved. - void note_tbl_nav(std::uint32_t id); - // Connection-wide visibility change (ShowDeleted) or an - // index-keyed op with no resolved parent: expire every table. - void note_all_tables_nav(); - - // Server version from the HelloAck handshake ("openads/1.09.27"; - // pre-1.8.14 servers answer the literal "openads/0.3.2"). Empty - // when the Hello probe failed — callers treat that as unknown, - // never as an error (the Connect that follows decides success). - const std::string& server_version() const noexcept { - return server_version_; - } - - // M12.16 — remote index handle subsystem. - struct OpenIndexEntry { - std::uint32_t id = 0; - std::string tag; - std::string bag_path; // production CDX/NTX/ADI filename - std::string expression; - bool is_unique = false; - bool is_descending = false; - }; - // M12.14 — remote field metadata + extended cursor state. - struct FieldDesc { - std::string name; - std::uint16_t type = 0; // ADS_* code - std::uint32_t length = 0; - std::uint16_t decimals = 0; - }; - // M-AOF.6 — extended OpenTableAck carries production bag path. - struct OpenTableResult { - std::uint32_t id = 0; - std::string prod_bag_path; // production CDX/ADI filename (if found) - // Warm sections (USE latency): schema + first row piggybacked on - // the open ack (see wire.h OpenTableAckSections). Absent when the - // server predates sections — the caller falls back to - // DescribeTable + GotoTop exactly as before. - std::vector fields; - bool has_schema = false; - std::vector first_row; // nav-ack trailer layout - bool has_first_row = false; - }; - util::Result open_table(const std::string& rel, std::uint16_t mode = 0); - util::Result close_table(std::uint32_t id); - util::Result goto_top(std::uint32_t id); - util::Result goto_top(RemoteTable* rt); - // Fused nav+order (see kCapNavOrderFuse): installs order_id before - // navigating, collapsing SetOrder+Goto into one frame. Only call - // when server_nav_order_fuse() is true. - util::Result goto_top_fused(RemoteTable* rt, - std::uint32_t order_id); - util::Result goto_bottom_fused(RemoteTable* rt, - std::uint32_t order_id); - util::Result skip(std::uint32_t id, std::int32_t step); - util::Result skip(RemoteTable* rt, std::int32_t step); - util::Result get_field(std::uint32_t id, - const std::string& field_name); - util::Result record_count(std::uint32_t id); - util::Result key_count(std::uint32_t table_id); - // M12.29 — server-side key number: position of current record in the - // active order's walk. O(1) via pos_of_recno_cached() on the server. - util::Result key_num(std::uint32_t table_id); - util::Result at_eof(std::uint32_t id); - util::Result> - describe_table(std::uint32_t id); - util::Result at_bof(std::uint32_t id); - // Nav-boundary twin read: AtBOF/AtEOF acks carry the twin flag as a - // trailing byte ([u8 bof][u8 eof] / [u8 eof][u8 bof]); has_twin is - // false against old single-byte servers. Lets the ABI layer serve - // the twin answer locally instead of paying a second round-trip - // for rddads' inevitable AtBOF+AtEOF pair. - struct BofEof { - bool bof = false; - bool eof = false; - bool has_twin = false; - }; - util::Result bof_eof(std::uint32_t id); - util::Result eof_bof(std::uint32_t id); - util::Result get_record_num(std::uint32_t id); - util::Result is_record_deleted(std::uint32_t id); - util::Result goto_bottom(std::uint32_t id); - // M12.15 — remote info / lock / maintenance / AOF. - util::Result is_found(std::uint32_t id); - util::Result refresh_record(std::uint32_t id); - // Table-aware overload: parses the row trailer + bound tail the - // server appends, so the refreshed row, bounds and recno serve - // locally instead of costing follow-up frames. - util::Result refresh_record(RemoteTable* rt); - util::Result get_table_type(std::uint32_t id); - util::Result get_record_length(std::uint32_t id); - util::Result get_num_indexes(std::uint32_t id); - util::Result get_last_autoinc(std::uint32_t id); - // DBF header "last updated" date, packed (y<<16)|(m<<8)|d. - util::Result get_last_table_update(std::uint32_t id); - util::Result lock_record(std::uint32_t id, std::uint32_t recno); - util::Result unlock_record(std::uint32_t id, std::uint32_t recno); - // M12.36 — does THIS connection hold a lock on recno (0 = current)? - util::Result is_record_locked(std::uint32_t id, std::uint32_t recno); - // M12.36 — recnos this connection currently holds locks on. - util::Result> get_all_locks(std::uint32_t id); - util::Result lock_table(std::uint32_t id); - util::Result unlock_table(std::uint32_t id); - util::Result pack_table(std::uint32_t id); - util::Result zap_table(std::uint32_t id); - util::Result flush_file_buffers(std::uint32_t id); - util::Result close_all_indexes(std::uint32_t id); - util::Result set_aof(std::uint32_t id, const std::string& cond); - util::Result clear_aof(std::uint32_t id); - util::Result customize_aof(std::uint32_t id, - std::uint16_t option, - const std::vector& recnos); - util::Result get_aof_opt_level(std::uint32_t id); - util::Result> - open_index(std::uint32_t table_id, - const std::string& path); - util::Result close_index(std::uint32_t index_id); - - // M12.29 — AdsDD* Data Dictionary property API, phase 1. `subName` is - // only meaningful for DDObjectKind::Field (the field name within the - // table named by `name`); pass "" for every other kind. See - // docs/wire-protocol.md §9 and wire.h for the wire payload formats. - util::Result dd_get_property(DDObjectKind kind, - const std::string& name, - const std::string& subName, - std::uint16_t propId); - util::Result dd_set_property(DDObjectKind kind, - const std::string& name, - const std::string& subName, - std::uint16_t propId, - const std::string& value); - util::Result dd_create_proc(const std::string& name, - const std::string& container, - const std::string& procName, - const std::string& inParams, - const std::string& outParams, - const std::string& comments); - util::Result dd_create_function(const std::string& name, - const std::string& container, - const std::string& implementation, - const std::string& retType, - const std::string& inParams, - const std::string& comment); - util::Result dd_create_trigger(const std::string& name, - const std::string& table, - std::uint32_t type, - const std::string& container, - const std::string& procedure, - std::uint32_t priority); - util::Result dd_drop_trigger(const std::string& name); - util::Result dd_drop_view(const std::string& name); - util::Result dd_drop_link(const std::string& name); - - // M12.30 — AdsDD* Data Dictionary property API, phase 2 (deferred - // user/group/link/RI/view/index-file/permissions calls). See - // docs/wire-protocol.md §5.25 and wire.h for the wire payload formats. - util::Result dd_create_user(const std::string& group, - const std::string& user, - const std::string& pwd, - const std::string& desc); - // `kind` must be User, RefIntegrity, Proc, or Function — the four - // plain "drop by name" calls not already covered by a phase-1 opcode. - util::Result dd_drop_object(DDObjectKind kind, - const std::string& name); - util::Result dd_add_user_to_group(const std::string& group, - const std::string& user); - util::Result dd_remove_user_from_group(const std::string& group, - const std::string& user); - util::Result dd_create_link(const std::string& alias, - const std::string& path, - const std::string& user, - const std::string& pwd); - util::Result dd_modify_link(const std::string& alias, - const std::string& path, - const std::string& user, - const std::string& pwd); - util::Result dd_create_ref_integrity(const std::string& name, - const std::string& failTable, - const std::string& parent, - const std::string& parentTag, - const std::string& child, - const std::string& childTag, - std::uint16_t updateRule, - std::uint16_t deleteRule); - util::Result dd_create_view(const std::string& name, - const std::string& comments, - const std::string& sql); - util::Result dd_add_index_file(const std::string& table, - const std::string& index, - const std::string& comment); - util::Result dd_remove_index_file(const std::string& table, - const std::string& index); - util::Result dd_get_permissions(const std::string& grantee, - std::uint16_t objType, - const std::string& objName, - bool getInherited); - util::Result dd_grant_permission(std::uint16_t objType, - const std::string& objName, - const std::string& grantee, - std::uint32_t permissions); - - // SetOrder ack outcome: the server appends the just-installed - // order's key count (trailing [u32] after the empty ack body), - // length-gated. Lets the scrollbar setup that always follows a - // switch serve locally instead of paying a GetKeyCount frame. - // Absent on old servers (counted=false). - struct SetOrderOutcome { - bool counted = false; - std::uint32_t key_count = 0; - }; - util::Result set_order(std::uint32_t table_id, - std::uint32_t index_id); - util::Result set_order_by_name(std::uint32_t table_id, - const std::string& tag); - struct SeekOutcome { - std::uint8_t hit = 0; // 1 = exact, 0 = soft / not found - std::uint32_t recno = 0; - }; - // RCB 07/14/2026: `parent` is optional but you almost always want to pass - // it — an M12.24 server returns the row it landed on in the SeekAck, and - // that is the only way to receive it. Pass nullptr and the row cache stays - // invalid, which costs a FetchCurrentRow round-trip on the next field read. - util::Result seek(std::uint32_t index_id, - const std::string& key, - std::uint8_t soft, - std::uint8_t last, - RemoteTable* parent = nullptr); - // M12.35 — query the server for the key expression result type of a - // remote index (ADS_STRING, ADS_DATE, ADS_NUMERIC, ADS_LOGICAL). - util::Result get_key_type(std::uint32_t index_id); - util::Result create_index(std::uint32_t table_id, - const std::string& path, - const std::string& tag, - const std::string& expr, - const std::string& cond, - const std::string& key_filter, - std::uint32_t options, - std::uint16_t page_size); - // Create a free table on the remote data directory (AdsCreateTable). - // Does not leave the table open — caller follows up with open_table(). - util::Result create_table(const std::string& name, - const std::string& fields, - std::uint16_t table_type, - std::uint16_t char_type, - std::uint16_t memo_block_size); - util::Result drop_table(const std::string& name, - std::uint16_t delete_files); - - // Server filesystem (EnableFileFunc on server). - // Positive-only existence cache (rddads probes the same production - // bags every USE): a "true" answer is served locally until any - // file-mutating op on this connection (erase/rename/drop/create, - // via file_exists_invalidate) clears it. Negatives always go to - // the wire — caching "missing" would hide a concurrently created - // table, while a stale "present" degrades to a clean open error. - // src (optional out): 0 = positive cache, 1 = negative cache, - // 2 = wire probe. For probe-level trace logging. - util::Result file_exists(const std::string& path, - int* src = nullptr); - void file_exists_invalidate(); - util::Result file_erase(const std::string& path); - util::Result file_rename(const std::string& old_p, - const std::string& new_p); - util::Result file_size(const std::string& path); - util::Result file_mtime( - const std::string& path); - util::Result> - directory(const std::string& mask); - // M12.33 — remote table enumeration (not gated by EnableFileFunc). - util::Result> - find_tables(const std::string& mask); - // Server-side backup archiving (OAds_Zip/OAds_UnZip — not gated - // by EnableFileFunc; database ops). The file/exclude lists ride - // 0x1F-joined on the wire; stats + archive path come back. - struct ZipArchiveOutcome { - std::uint32_t files = 0; - std::uint64_t bytes = 0; - std::uint64_t archive_bytes = 0; - std::string archive; // relative to owning data root - }; - util::Result - zip_archive(const std::string& dir, - const std::vector& files, - const std::string& zip_name, - std::uint16_t level, - bool overwrite, - const std::string& password, - const std::vector& exclude, - bool with_path); - struct UnzipArchiveOutcome { - std::uint32_t files = 0; - std::uint64_t bytes = 0; - std::uint64_t archive_bytes = 0; - }; - util::Result - unzip_archive(const std::string& dir, - const std::string& zip, - const std::string& password, - bool overwrite, - bool with_path); - // Central-directory listing (OAds_ZipFileCount/OAds_ZipFileList — - // not gated by EnableFileFunc; database ops). The archive spelling - // follows the unzip rule (bare names under backup/). - struct ZipListOutcome { - std::vector entries; - }; - util::Result - zip_list(const std::string& zip); - util::Result dir_exist(const std::string& path); - util::Result dir_make(const std::string& path); - util::Result dir_remove(const std::string& path); - util::Result fopen(const std::string& path, - std::uint16_t mode); - util::Result fcreate(const std::string& path, - std::uint16_t attr); - util::Result fclose(std::uint32_t file_id); - util::Result> - fread(std::uint32_t file_id, - std::uint32_t nbytes); - util::Result fwrite(std::uint32_t file_id, - const std::uint8_t* data, - std::uint32_t n); - util::Result fseek(std::uint32_t file_id, - std::int32_t offset, - std::uint8_t origin); - util::Result skip_unique(std::uint32_t index_id, - std::int32_t direction); - util::Result set_scope(std::uint32_t index_id, - std::uint16_t which, - const std::string& key, - std::uint16_t data_type); - util::Result clear_scope(std::uint32_t index_id, - std::uint16_t which); - // M12.31 — propagate SET DELETED ON/OFF to the server session. - void show_deleted(bool visible) noexcept; - // M12.17 — single-frame whole-record read. - struct RowSnapshot { - bool has_row = false; - std::vector fields; // order matches FieldDesc cache - }; - util::Result fetch_current_row(std::uint32_t table_id); - util::Result fetch_current_row(RemoteTable* rt); - // Warm open: feed the OpenTableAck first-row section through the nav - // trailer parser so the table lands exactly as an explicit GotoTop - // would leave it (caller then mirrors GotoTop's local bookkeeping - // and skips the round-trip). - util::Result apply_open_row(RemoteTable* rt, - const std::vector& trailer); - // mtfix12 R1 — apply the certified opposite-boundary landing - // (pair_blob, row-trailer format) exactly as that boundary's wire - // ack would have: same row-trailer parse (row cache, prefetch - // reset, lag re-anchor), then the certified bound values and the - // scoped key count. Caller has already verified freshness - // (pair_seq/pair_write_gen) via remote_nav_pair. - util::Result apply_pair_blob(RemoteTable* rt); - // M12.6 — remote write surface. - util::Result append_blank(std::uint32_t id); - util::Result set_field(std::uint32_t id, - const std::string& field_name, - const std::string& value); - // Write coalescing: N field writes in ONE round-trip. Payload: - // [u32 tid][u16 n][per field: u16 nlen][name][u32 vlen][value]. - // Empty input is a no-op success (no frame sent). - util::Result set_fields_batch( - std::uint32_t id, - const std::vector>& fields); - // M12.25 — raw DBF record image at the current cursor position. - util::Result> get_record(std::uint32_t id); - util::Result get_record_crc(std::uint32_t id); - util::Result set_record(std::uint32_t id, - const std::uint8_t* bytes, - std::size_t len); - util::Result delete_record(std::uint32_t id); - util::Result recall_record(std::uint32_t id); - util::Result goto_record(std::uint32_t id, - std::uint32_t recno); - util::Result goto_record(RemoteTable* rt, - std::uint32_t recno); - util::Result goto_bottom(RemoteTable* rt); - util::Result flush_table(std::uint32_t id); - // M12.34 — find record by identity columns. Returns recno (0 = not found). - util::Result - find_record(std::uint32_t id, - const std::vector>& identity); - // M12.7 — remote SQL exec. Returns cursor table-id (0 = no cursor, - // i.e. INSERT / UPDATE / DELETE / DDL). - util::Result execute_sql(const std::string& sql); - // M12.8 — remote index ops. - util::Result reindex(std::uint32_t id); - // M12.11 — batch row read. Walks up to `max_rows` rows starting - // at the current cursor position, returning a row-major matrix of - // column values. Empty result set ⇒ empty outer vector. Reduces - // per-row Skip/GetField round-trips for WAN-latency callers. - util::Result>> - fetch_batch(std::uint32_t id, - std::uint32_t max_rows, - const std::vector& columns); - // Tier-2 server-side filtered scan. Like fetch_batch, but the - // server evaluates `where_expr` (a Clipper-style FOR predicate, - // e.g. "AGE > 40 .AND. CITY = 'RIO'") against each row and returns - // only the matching rows' requested columns, walking the table - // server-side until `max_rows` matches or EOF. Collapses a - // non-index-optimisable SET FILTER / COUNT FOR / LOCATE scan from - // one round-trip per record to ceil(matches / max_rows). Base - // tables only — a SQL cursor already filters via its WHERE clause. - // Callers continue the walk like fetch_batch: a batch returning - // fewer than `max_rows` rows has reached EOF. - // `flags`: FetchWhereFlags::WANT_RECNO (0x01) causes the server to - // include each matching row's recno in the reply; the recnos are - // stored in FetchWhereBatch::recnos (same order as rows). flags=0 - // (default) is byte-identical to the v1.4.0 request/reply. - util::Result - fetch_where(std::uint32_t id, - std::uint32_t max_rows, - const std::string& where_expr, - const std::vector& columns, - std::uint8_t flags = 0); - - // Tier-3 — server-side aggregation. The server scans the whole table - // once, evaluates `for_expr` per row, and folds each match into the - // requested accumulators, returning one scalar per spec (same order). - // Base tables only — a SQL cursor already aggregates via SQL. - util::Result - aggregate(std::uint32_t id, - const std::string& for_expr, - const std::vector& specs); - - // M12.32 — distributed mutex service (server-wide named mutexes). - util::Result mutex_create(const std::string& name); - util::Result mutex_lock(const std::string& name, - std::uint32_t timeout_ms = 0); - util::Result mutex_try_lock(const std::string& name); - util::Result mutex_unlock(const std::string& name); - util::Result mutex_destroy(const std::string& name); - - // Count of wire round trips issued on this connection (every - // request(), reads included). Unlike nav_seq() this also moves on - // locks, fetches and counts, so "no frame since X" means nothing at - // all reached the server in between (used by AdsRefreshRecord to - // serve the row a GotoRecord ack just delivered). - std::uint64_t frame_seq() const noexcept { - return frame_seq_.load(std::memory_order_relaxed); - } - - // Count of frames that can ADD rows or change row contents on the - // server (append, field writes, recall, SQL, pack/zap/reindex) sent on - // this connection. The empty-table window (see AdsSeek) closes as soon - // as this moves, so this station never misses its own new record. - std::uint64_t data_epoch() const noexcept { - return data_epoch_.load(std::memory_order_relaxed); - } - - // Per-connection record-length memo for re-opens of the same table. - // Keyed by lowercased table name + the full schema (names, types, - // widths, decimals) from the open ack, so any restructure changes - // the key and misses. Thread-safe. - bool recall_record_length(const std::string& key, - std::uint32_t& out) { - std::lock_guard lk(reclen_mu_); - auto it = reclen_memo_.find(key); - if (it == reclen_memo_.end()) return false; - out = it->second; - return true; - } - void remember_record_length(const std::string& key, std::uint32_t v) { - std::lock_guard lk(reclen_mu_); - if (reclen_memo_.size() < 4096) reclen_memo_[key] = v; - } - -private: - util::Result request(const Frame& f); - - // Record a cursor/visibility-affecting frame. Called at the top of - // every method that can move the server cursor or change what it - // shows (never by pure reads). Lock-free atomic: callable with or - // without mu_ held. - void note_nav_frame() noexcept { - nav_seq_.fetch_add(1, std::memory_order_relaxed); - } - - std::unique_ptr transport_; - std::mutex mu_; - // mtfix12 per-table generations (see tbl_nav_seq/tbl_write_gen). - // Guarded by mu_ (bumped inside request(), which already holds it). - std::unordered_map tbl_nav_seqs_; - std::unordered_map tbl_write_gens_; - // Server caps echoed in ConnectAck (0 when the server predates caps). - std::uint32_t server_caps_ = 0; - // Monotonic cursor-generation counter. Bumped ONLY by frames that - // can move the server cursor or change visibility (nav, seek, - // order/scope/AOF/show-deleted, writes, pack/zap/reindex) — never - // by reads (fetch, counts, describe, keynum, boundary probes). - // The ABI layer stamps nav operations with it, so a consecutive - // duplicate GotoTop/GotoBottom or a proven-empty cursor survives - // the read traffic rddads interleaves between probes (a blanket - // per-frame counter died on the first fetch). Cross-station - // staleness is impossible by construction: observing a change - // requires a cursor-affecting frame, which is exactly what bumps. - std::atomic nav_seq_{0}; - // See frame_seq(): bumped by every request(). - std::atomic frame_seq_{0}; - std::atomic data_epoch_{0}; - std::mutex reclen_mu_; - std::unordered_map reclen_memo_; - // Raw HelloAck payload (see above). Written once during - // connect_with_transport, read afterwards without mu_ (the - // connection is fully established before any other thread - // can hold its handle). - std::string server_version_; - // Positive-only file-existence cache (see file_exists). Guarded - // by its own mutex: consulted on the read path, cleared by - // file-mutating ops, never held across wire calls. - std::set file_exists_cache_; - std::mutex file_exists_mu_; - // Negative half of the existence cache: repeated "missing" probes - // (Vouch checks optional bags/configs on every USE) are served - // locally until any file-mutating op on this connection clears it. - // A stale "missing" degrades to the app re-checking after its own - // create attempt (which clears the cache), never to wrong data. - std::set file_exists_neg_cache_; - // Directory truth (EnableFileFunc): DirExist=true answers and - // successful DirMakes feed dir_known_; DirExist=false feeds - // dir_missing_. A known dir makes DirMake a no-op (the server - // create is idempotent) and DirExist answer locally. Our own - // DirRemove erases the path from both. Peer mkdir/rmdir is not - // tracked -- session-scoped, same trade as the file cache. - std::set dir_known_; - std::set dir_missing_; - std::mutex dir_cache_mu_; - -public: - // Deferred disconnect (MT shared connections). AdsDisconnect on a - // connection that still has tables open through it must not kill - // those tables out from under other threads using the same handle: - // it sets close_pending instead, and the last AdsCloseTable performs - // the real disconnect. Both fields are only touched under the ABI - // state mutex; disconnect() itself stays unconditional. - int deferred_open_tables = 0; - bool close_pending = false; - -public: - // Short-lived handle reuse across close/reopen (Vouch startup: the - // same lookup tables re-USE every few seconds). A parked table keeps - // its server handle, schema, tags and order bindings; a re-USE only - // pays a warm GotoTop instead of open+index+describe+goto (~4 RTTs). - // Eligibility is conservative (shared mode, natural order, never - // locked/scoped, decided by the ABI layer); the pool here is pure - // storage with cap + TTL eviction. Guarded by park_mu_ (never held - // across wire calls — callers extract under it, then go to the wire). - struct ParkedTable { - std::unique_ptr table; - std::string key; - std::chrono::steady_clock::time_point parked_at{}; - }; - static constexpr std::size_t kParkedMax = 16; - static constexpr std::uint64_t kParkedTtlSec = 30; - // Move a matching entry out (most-recent first). True on hit. - bool parked_reuse(const std::string& key, - std::unique_ptr& out); - // Store; evicted entries (beyond cap / expired) are returned for the - // caller to really close (wire + accounting live in the ABI layer). - void parked_store(std::string key, std::unique_ptr rt, - std::vector>& evicted); - // Drain everything (disconnect). Caller really-closes each entry. - void parked_flush(std::vector>& out); - // Diagnostics (wire_trace): is an unexpired entry parked under key? - bool parked_contains(const std::string& key) const; - -private: - std::vector parked_; - mutable std::mutex park_mu_; -}; - -// Per-handle wrapper for a remote table. Stores back-pointer to -// the connection plus the server-side table id. -struct RemoteTable { - RemoteConnection* conn = nullptr; - std::uint32_t id = 0; - // True when this table was counted in conn->deferred_open_tables at - // AdsOpenTable time; AdsCloseTable decrements only when set (remote - // SQL cursors are excluded — AdsDisconnect nulls their conn first). - bool close_counted = false; - // The table name as passed to open_table (with extension). Served - // by AdsGetTableFilename so the consuming RDD has something to show. - std::string name; - // M12.14 — schema cache populated lazily on first - // AdsGetNumFields / AdsGetFieldName / ... call so rddads' - // adsOpen field-iteration loop stays at one wire round-trip. - std::vector fields; - bool fields_cached = false; - // M12.17 — current-record buffer cache. Populated lazily on the - // first AdsGetField after a nav op; invalidated by AdsSkip / - // AdsGotoTop / AdsGotoBottom / AdsGotoRecord / AdsAppendRecord - // / AdsWriteRecord / AdsDeleteRecord / AdsRecallRecord. xbrowse - // re-paints therefore cost one extra RTT per row (the fetch), - // not one per cell. - std::vector current_row; - bool row_valid = false; - // Write coalescing (RDD voucher entry): consecutive AdsSet* calls - // buffer (field name, value) here instead of paying 1 RTT each; the - // buffer flushes as one SetFields batch on the next visibility event - // (read/nav/lock/unlock/commit/close — see remote_flush_pending in - // ace_exports.cpp). Empty = clean. Reads overlay these values onto - // current_row so same-handle read-after-write stays exact. - std::vector> pending_sets; - // Lazy-close pooling (USE latency): eligibility observed over the - // handle lifetime. Parked only when every line below still reads - // fresh-open equivalent at close time. - std::uint16_t open_mode_raw = 0; // usMode as passed to open_table - bool open_exclusive = false; // mapped mode (never pooled) - bool ever_locked = false; // AppendBlank/LockRecord/Table - bool scope_touched = false; // SetScope (server state unclear) - // Client-side lock ledger (WAN chattiness): mirrors the record and - // table locks this connection is known to hold on the server. Lets - // AdsUnlockTable skip the frame when nothing is held (rddads - // dbUnlock() calls it blindly before every lock/append), lets - // AdsGetAllLocks answer locally, and lets the close path park a - // table whose locks were all released (the blunt ever_locked flag - // alone used to force a real close + 7-frame reopen per save). - // locks_uncertain covers locks the ledger cannot name: the append - // auto-lock (AppendBlankAck carries no recno) and LockRecord(0) - // with no valid cursor. Only a wire UnlockTable (or close) clears - // it. Conservative throughout: doubt always goes to the wire. - std::set held_recs; - bool table_lock_held = false; - bool locks_uncertain = false; - // M12.18 — recno + deleted flag arrive together with the row - // bytes so AdsGetRecordNum / AdsIsRecordDeleted can serve from - // cache instead of a separate RTT each. - std::uint32_t current_recno = 0; - bool current_deleted = false; - // Logical 1-based key position within the active index order. - // Updated on remote nav when active_index_id != 0 so AdsGetKeyNum - // (FWH xBrowse scrollbar) returns a non-zero position over TCP. - // Invalidated on Append / Write / Delete / Recall (and on Goto to a - // different recno); re-seeded by GoTop/GoBottom/Skip when valid, or - // by server GetKeyNum (M12.29) on the next AdsGetKeyNum/GetRelKeyPos. - std::uint32_t current_keyno = 0; - bool keyno_valid = false; - // Set when a backward Skip cannot move (top of order). Cleared on - // forward nav / GoTop. Without this, AdsAtBOF always answers "not - // BOF" while row_valid and xBrowse GoUp rubber-bands at key #1. - bool nav_at_bof = false; - // Set when a forward Skip cannot move (bottom of order). - bool nav_at_eof = false; - // Proven-FALSE stickies (mirror of the above): set only when the - // cursor state entails the answer, cleared whenever position or - // visibility could have changed. Lets the twin half of a boundary - // pair answer locally even with no valid row — e.g. Skip forward - // to EOF proves EOF (nav_at_eof) AND not-BOF (arrived from rows), - // so the following AtBOF costs nothing. xBase truth table: - // on-a-row ⇒ neither limit; empty cursor ⇒ both limits. - bool nav_not_bof = false; - bool nav_not_eof = false; - // Last boundary answers with the connection seq at answer time. - // Repeated identical probes (rddads polls BOF/EOF per paint row) - // are served locally while no cursor-affecting frame lands in - // between — strictly fresher than the flags above because ANY - // wire cursor op expires them via the seq. Per-side validity: a - // lone wire answer certifies only its own side; the twin half is - // certified only by the piggybacked twin byte. Cleared alongside - // last_nav at purely-local visibility mutations (the seq rule - // can't see those). - bool bound_bof_ok = false; - bool bound_bof = false; - bool bound_eof_ok = false; - bool bound_eof = false; - std::uint64_t bound_seq = 0; - // Certified recno from the last reposition truth (GotoRecord/Seek - // bound piggyback) or GetRecordNum wire answer, with the seq at - // answer time. Serves AdsGetRecordNum while no cursor-affecting - // frame lands — the phantom-position case (row_valid false) that - // used to cost a round-trip per xBrowse paint row. Same currency - // rule as the bound cache above; cleared with it. - std::uint32_t recno_bound = 0; - bool recno_bound_ok = false; - std::uint64_t recno_bound_seq = 0; - // Certified record count from the last nav ack tail (optional - // [u32 reccount] after the bound bytes), with the seq at answer - // time. Same trust as rec_count_cached (in-memory server count, - // no disk refresh — the wire GetRecordCount keeps its refresh - // for callers that need multiuser-fresh). - std::uint32_t count_bound = 0; - bool count_bound_ok = false; - std::uint64_t count_bound_seq = 0; - // Wall time of the last bound tail (server certification) and the - // connection data_epoch() at that moment. Drive the short "still - // empty" window for physically empty tables (AdsSeek/AdsGotoRecord). - std::chrono::steady_clock::time_point bound_at{}; - std::uint64_t bound_data_epoch = 0; - // Last wire nav op on this table (0 = none/other, 1 = GotoTop, - // 2 = GotoBottom), whether it produced a row, and the connection - // nav_seq_ at the time. Serves two WAN-chattiness kills with one - // stamp: (a) a consecutive duplicate GotoTop/GotoBottom with an - // unchanged seq provably re-establishes identical state, so the - // frame is skipped; (b) a top/bottom that produced NO row proves - // an empty cursor, so AtBOF/AtEOF answer locally until a - // cursor-affecting frame lands. Purely-local visibility mutations - // (AdsSetFilter/ClearFilter) reset last_nav to 0 — every - // cursor-affecting wire op invalidates automatically via the seq. - int last_nav = 0; - bool last_nav_row = false; - std::uint64_t last_nav_seq = 0; - // Order context of last_nav: the RemoteIndex id for index-handle - // nav, the ack-confirmed server_order_id for table-handle nav. - // A top in order A says nothing about order B, so the duplicate - // check requires the context to match, not just the op. - std::uint32_t last_nav_order = 0; - - // mtfix12 R1 — boundary-pair certification. A GotoTop/GotoBottom ack - // on a kCapNavBoundaryPair server carries the OPPOSITE boundary's - // complete landing state, read by the server in the same atomic - // visit: the row blob in row-trailer format (pack_row_trailer - // bytes, lookahead depth 0), that landing's bound values, and the - // scoped key count. While the conn-wide nav_seq holds (identical - // envelope to remote_nav_duplicate) and no write touched this table - // since (write_gen), a back-to-back opposite-boundary call applies - // the blob exactly as the wire ack would have: same parser, same - // bound application, same keyno sync — byte-identical state, one - // RTT saved. pair_which is the boundary this certifies (1 = top, - // 2 = bottom), i.e. the OPPOSITE of the nav that carried it. - bool pair_valid = false; - int pair_which = 0; - std::uint32_t pair_order = 0; - std::uint64_t pair_seq = 0; - std::uint64_t pair_write_gen = 0; - std::vector pair_blob; - bool pair_bof = false; - bool pair_eof = false; - std::uint32_t pair_recno = 0; - bool pair_has_count = false; - std::uint32_t pair_reccount = 0; - bool pair_has_keycount = false; - std::uint32_t pair_keycount = 0; - // Write generation snapshot: RemoteConnection::tbl_write_gen(id) - // at certification time (the conn bumps the live counter inside - // request() for every content-affecting frame, so no call site can - // miss it). The pair blob was read before any such change, so it - // must not overwrite fresher row state — guard on equality at - // serve time. - // mtfix12 R2 — certified server-cursor anchor. Set whenever a wire - // nav ack (or the open warm row) lands this handle's cursor on a - // row: the server cursor IS anchor_recno at that instant (lag is - // 0 by ack contract). Conn-wide envelope: valid while - // anchor_seq == conn nav_seq. Per-table opt-in envelope - // (OPENADS_NAV_SELF_GOTO=table): valid while anchor_tbl_seq == - // tbl_change_seq — server cursors are per handle, so only THIS - // handle's frames can move it. Position-only certification; row - // bytes come from the existing row cache. - std::uint32_t anchor_recno = 0; - bool anchor_ok = false; - std::uint64_t anchor_seq = 0; - std::uint64_t anchor_tbl_seq = 0; - // anchor_tbl_seq snapshots RemoteConnection::tbl_nav_seq(id) — the - // per-table cursor/visibility generation bumped centrally in - // request(). Purely-local visibility mutations (filter/scope/order - // set+clear — the sites that reset last_nav) must also expire the - // anchor: they clear anchor_ok directly. - // M12.19 — cached record count. Serves AdsGetRecordCount and - // AdsGetRelKeyPos (scrollbar) without an extra RTT. Invalidated - // on writes that may change the row count: AppendBlank / - // DeleteRecord / RecallRecord / Pack / Zap. - std::uint32_t cached_rec_count = 0; - bool rec_count_cached = false; - // Scoped key count of the active order (scope + SET DELETED aware, - // computed server-side). What the keyno machinery must clamp to — - // NOT the physical record count, or a 1-row scope reports KeyNo=36 - // and xBrowse walks past the scope end (Tim Stone, 31/07/2026). - // Invalidated on scope set/clear, order change, show-deleted flip, - // and every write that invalidates rec_count_cached. - std::uint32_t cached_key_count = 0; - bool key_count_cached = false; - // Per-order key counts: an order switch does not change any - // order's count, so rotation revisits serve from here while the - // single slot above still bounces per switch. Second-chance - // cache only: every site that clears the slot for a WRITE (or - // adopt/refresh) clears this too; order-switch clears leave it - // intact. Counts are order-scoped server values, keyed by the - // wire index id (0 = natural order). - std::unordered_map key_counts; - // M12.21 — sequential prefetch queue. SkipAck (when step==1) - // returns the row at +1 plus up to N lookahead rows; the - // bridge pops one entry per Skip(1) call so a 20-row PgDn - // costs 1 RTT total, not 20. Cleared by any non-sequential - // nav or write so the queue can never serve a stale row. - struct PrefetchedRow { - std::uint32_t recno = 0; - bool deleted = false; - std::vector fields; - }; - std::deque prefetch_queue; - // RCB 07/15/2026: M12.25 — the direction the queued rows are walked in: - // +1 = forward (each row is the next in a Skip(+1) scan), -1 = backward - // (PgUp), 0 = empty/none. A drain only fires when the caller's step sign - // matches; a direction reversal can't serve from the queue and goes to the - // wire (which refills the queue in the new direction). Set on each nav ack - // from the sign of the wire step. - std::int8_t prefetch_dir = 0; - // RCB 07/15/2026: M12.25 — was `prefetch_consumed` (unsigned, forward-only). - // Now SIGNED: cursor_lag = client's logical position MINUS the server's - // cursor position. A forward local drain moves the client ahead of the - // server (lag += 1); a backward local drain moves it behind (lag -= 1). The - // next wire Skip sends (step + cursor_lag) so the server lands at - // client_logical + step regardless of sign. Reset to 0 on every nav ack - // (the ack re-anchors the server to the client's logical row). The forward - // path is byte-for-byte unchanged: lag was always >= 0 there, and - // step + lag == the old step + prefetch_consumed. - std::int32_t cursor_lag = 0; - // M12.21 option C — cached Found() state. xBase clears Found() on any - // non-seek move (Skip/Goto) and sets it from the seek outcome, so the - // ops that know the value set it here and AdsIsFound serves it with no - // round-trip (rddads polls IsFound after every DbSkip). found_cached - // gates the fast path: when false, AdsIsFound asks the server. - bool found_cached = false; - bool current_found = false; - // Filter / AOF expression strings (stored for AdsGetFilter / AdsGetAOF). - std::string filter_expr; - std::string aof_expr; - // Table alias (stored for AdsGetTableAlias). - std::string alias; - // Server-side wire id of the active controlling index (0 = natural order). - // Updated by AdsSetIndexOrder / AdsSetIndexOrderByHandle / AdsOpenIndex. - std::uint32_t active_index_id = 0; - // RCB 07/14/2026: the order the SERVER actually has installed, as last - // confirmed by a SetOrder ack. Why this exists as a SECOND field instead - // of just reusing active_index_id: active_index_id is only the client's - // *belief*, and it can be set with no round-trip at all (the production-bag - // auto-open in AdsOpenTable100, or AdsGetIndexHandle resolving a tag). That - // asymmetry is exactly why remote_activate_index used to re-send SetOrder - // before every single nav op — trusting active_index_id left - // ordered_tables_ empty on the server, so GotoTop/Skip walked the engine - // table in natural order and ignored any scope. Somebody hit that, and the - // fix was to give up and always send the frame. - // - // Keying the skip on a value that ONLY a real ack can write closes the hole - // properly, which is what makes it safe to send SetOrder once per order - // change instead of once per row. rddads navigates on hOrdCurrent whenever - // an order is set, so that was costing a whole round-trip on every single - // row of every ordered browse. - static constexpr std::uint32_t kOrderUnknown = 0xFFFFFFFFu; - std::uint32_t server_order_id = kOrderUnknown; - - // RCB 07/14/2026: M12.23 — AdsCacheRecords(hTable, N). The depth the CALLER - // asked for, sent on each Skip request. kPrefetchDepthAuto (the default) - // means the app never called AdsCacheRecords, so the server picks the depth - // itself by ramping on detected sequential access. - // - // Why honour an explicit value at all, when the server's ramp is usually - // smarter than a fixed number: because the app can know things the access - // pattern cannot reveal. "I am about to edit most of the records I visit" - // looks identical to a plain scan until the writes start landing — and by - // then we have already shipped blocks that each write throws away. SAP - // documents 0/1 as the remedy for exactly that, and it needs a way to reach - // us. - std::uint16_t cache_records_hint = kPrefetchDepthAuto; - - // RCB 07/14/2026: drop the read-ahead block AND the consumed-lag counter. - // Every op that moves the server cursor outside the sequential-skip path - // (Seek, order change, ...) must call this. Missing it is not a perf bug, - // it is a WRONG-DATA bug, and we shipped three of them: the queued rows - // were read at the *old* position/in the *old* order, so a following - // Skip(1) would pop a stale row with no wire traffic at all (nothing on the - // network to make it look suspicious), and the next real skip would then - // send a step inflated by a lag that no longer applies. - void invalidate_prefetch() { - prefetch_queue.clear(); - prefetch_dir = 0; - cursor_lag = 0; - } - // Tag name → server wire index id (populated at AdsOpenIndex). - std::vector> index_by_tag; - // Parallel to index_by_tag: the ABI ADSHANDLE (registry handle, 64-bit) - // wrapping each opened index. Lets AdsGetIndexHandle (by tag) and - // AdsGetIndexHandleByOrder (by ordinal) resolve to a usable remote - // index handle over the wire — the path rddads' DbSetOrder() takes. - std::vector index_handles; - // Production index bag path (e.g. "customers.cdx"). Populated from the - // OpenTableAck auto-open or AdsOpenIndex. Lets AdsGetIndexFilename - // (OrdBagName) return the bag name without a separate wire round-trip. - std::string prod_bag_path; - // Immutable-per-handle metadata: table type and record length never - // change for an open table (only a restructure alters them, and that - // closes/reopens). Cached on first hit; rddads asks both per USE. - bool table_type_cached = false; - std::uint16_t cached_table_type = 0; - bool record_length_cached = false; - std::uint32_t cached_record_length = 0; - // Set by a wire AdsGotoRecord that landed on a row: the connection - // frame_seq() right after the ack and the recno it delivered. While - // no other frame has gone out and the cursor still sits on that row, - // an AdsRefreshRecord would re-read the very row that ack carried - // (the server's GotoRecord already re-read it from disk), so the - // refresh is served from it with no round trip. - bool goto_row_fresh = false; - std::uint64_t goto_row_frame_seq = 0; - std::uint32_t goto_row_recno = 0; - // Deferred teardown (WAN chattiness: rddads issues FlushFileBuffers - // + CloseAllIndexes before every CloseTable — 2 wasted frames per - // USE, since the server close flushes data and purges index - // bindings anyway). Set instead of sending; remote_flush_pending - // emits them ahead of any other intervening wire op, and the close - // path absorbs them silently. Tables carrying either flag are - // never parked (a park performs no server close to absorb into). - bool flush_file_pending = false; - bool close_all_indexes_pending = false; - // True once this handle wrote (buffered sets, append, delete, - // recall) without a durability flush since. Gates AdsFlushFileBuffers: - // a flush on a clean table (the RDD calls it blindly around every - // rotation) sets no flag, so teardown stays fully deferred and the - // index park survives. Cleared when a flush frame goes out or a - // real close absorbs it. Over-flagging only sends flushes (safe); - // the flag never gates data visibility (buffered sets flush via - // pending_sets independently). - bool write_dirty = false; - // Parked index bindings (WAN chattiness: per-tag CloseAll→OpenIndex - // rotation — 166 + 125 frames/startup). CloseAll snapshots the live - // maps here instead of dropping them; the server bindings stay open - // (no frame sent), so a same-bag OpenIndex restores them with zero - // frames and any op that only needs live bindings adopts the park - // (pending flag cleared, nothing sent). A real CloseAll frame goes - // out only when server state must actually drop: different-bag open - // (else the server accumulates bags), structural ops like - // CreateIndex (snapshot invalidated explicitly). Table close - // absorbs silently (server close purges anyway) and disconnect - // drops everything. Session-scoped validity: our session's bindings - // die only via our own frames, all of which funnel through the - // adopt-or-emit decision, so a parked snapshot can never reference - // dead server ids. - bool indexes_parked = false; - // Nav stamp parked alongside the index snapshot: the CloseAll -> - // OpenIndex (unpark) cycle sends no frames, so a stamp taken just - // before the park still describes the live server cursor as long as - // nav_seq is unchanged when the same order is restored. Zeroed - // whenever the parked snapshot dies for real. - int parked_nav_which = 0; - std::uint32_t parked_nav_order = 0; - bool parked_nav_row = false; - std::uint64_t parked_nav_seq = 0; - std::vector> parked_by_tag; - std::vector parked_handles; - std::uint32_t parked_active = 0; - std::string parked_bag_stem; - // Server-canonical bag path of the last wire OpenIndex (empty - // until the first one; the production auto-open counts). Seeds - // parked_bag_stem at CloseAll so the reopen match compares the - // bag actually bound, not a reopen spelling. - std::string last_open_bag; - // Deferred order switch (WAN chattiness: SetOrder+GotoTop/Bottom - // per tag rotation). SetOrder never moves the cursor, so the frame - // waits here until a nav absorbs it (fused SetOrder+Goto, one - // frame) or any other binding-dependent op flushes it plainly. - // active_index_id tracks the pending belief immediately; - // server_order_id only on ack. Never parked (adopt would inherit - // a belief the server doesn't share). - bool pending_order = false; - std::uint32_t pending_order_id = 0; // wire index id (0 = natural) -}; - -// M12.16 — per-handle wrapper for a remote index. Each tag -// returned by AdsOpenIndex (the multi-tag CDX case) gets one of -// these so the ABI surface can hand the host a real ADSHANDLE. -struct RemoteIndex { - RemoteConnection* conn = nullptr; - std::uint32_t id = 0; // server-side index id - std::uint32_t tbl_id = 0; // server-side table id this binds to - std::string tag_name; // CDX/NTX tag (AdsGetIndexName / OrdName) - std::string bag_path; // production CDX/NTX/ADI filename (AdsGetIndexFilename / OrdBagName) - std::string expression; // key expression (AdsGetIndexExpr) - bool is_unique = false; // AdsIsIndexUnique - bool is_descending = false; // AdsIsIndexDescending (physical flag) - // M12.17 — back-pointer so AdsSeek / AdsSeekLast / AdsSkipUnique - // can invalidate the parent table's row cache after the cursor - // moves on the server side. - RemoteTable* parent = nullptr; -}; - -// Parse `tcp://host:port/` into its parts. Returns -// false when the input doesn't carry the tcp:// prefix. -bool parse_tcp_uri(const std::string& uri, - std::string& host, - std::uint16_t& port, - std::string& data_dir); - -// M12.12 — TLS URI scheme `tls://host:port/` is reserved -// but not yet implemented. parse_tls_uri only returns true when the -// input carries the tls:// prefix; AdsConnect60 surfaces -// AE_FUNCTION_NOT_AVAILABLE so apps get a clear failure instead of -// a silent plaintext fallback. Real TLS (vendored mbedtls / -// platform-native) lands in v0.4.0. -bool parse_tls_uri(const std::string& uri, - std::string& host, - std::uint16_t& port, - std::string& data_dir); - -} // namespace openads::network From c9bd2036680be91ffa84e9f4d7d6502dd44532a9 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Sat, 26 Sep 2026 01:29:06 -0500 Subject: [PATCH 50/97] Delete src/network/3-client.cpp --- src/network/3-client.cpp | 3398 -------------------------------------- 1 file changed, 3398 deletions(-) delete mode 100644 src/network/3-client.cpp diff --git a/src/network/3-client.cpp b/src/network/3-client.cpp deleted file mode 100644 index 373727d3..00000000 --- a/src/network/3-client.cpp +++ /dev/null @@ -1,3398 +0,0 @@ -#include "network/client.h" - -#include "abi/lock_retry_policy.h" -#include "engine/table.h" -#include "openads/error.h" - -#include -#include -#include -#include -#include -#include - -namespace openads::network { - -namespace { - -inline void write_u32_le(std::uint32_t v, - std::vector& out) { - out.push_back(static_cast( v & 0xFFu)); - out.push_back(static_cast((v >> 8) & 0xFFu)); - out.push_back(static_cast((v >> 16) & 0xFFu)); - out.push_back(static_cast((v >> 24) & 0xFFu)); -} - -inline std::uint32_t read_u32_le(const std::uint8_t* p) { - return static_cast(p[0]) | - (static_cast(p[1]) << 8) | - (static_cast(p[2]) << 16) | - (static_cast(p[3]) << 24); -} - -inline void write_u16_le(std::uint16_t v, - std::vector& out) { - out.push_back(static_cast( v & 0xFFu)); - out.push_back(static_cast((v >> 8) & 0xFFu)); -} - -inline std::uint16_t read_u16_le(const std::uint8_t* p) { - return static_cast( - static_cast(p[0]) | - (static_cast(p[1]) << 8)); -} - -// M12.29 — [u16 len][bytes] string helper shared by the DD RPC methods. -inline void write_lstr16(const std::string& s, std::vector& out) { - write_u16_le(static_cast(s.size()), out); - out.insert(out.end(), s.begin(), s.end()); -} - -// M12.33 — read a [u16 len][bytes] string from a payload buffer. -inline bool read_lstr16(const std::vector& pl, - std::size_t& off, std::string& out) { - if (off + 2 > pl.size()) return false; - std::uint16_t len = read_u16_le(pl.data() + off); - off += 2; - if (off + len > pl.size()) return false; - out.assign(reinterpret_cast(pl.data() + off), len); - off += len; - return true; -} - -// Schema body shared by DescribeTableAck and the warm OpenTableAck -// section (identical layout by construction — see Session:: -// append_open_warm_sections). Returns the fields or an error with the -// same messages DescribeTable has always produced. -inline util::Result> -parse_schema_body(const std::vector& pl, std::size_t base, - std::size_t len) { - std::vector out; - if (len < 2) { - return util::Error{5000, 0, - "DescribeTable: short payload", ""}; - } - std::size_t pos = base; - const std::size_t end = base + len; - std::uint16_t n = read_u16_le(&pl[pos]); pos += 2; - out.reserve(n); - for (std::uint16_t i = 0; i < n; ++i) { - if (pos >= end) { - return util::Error{5000, 0, - "DescribeTable: truncated field record", ""}; - } - std::uint8_t name_len = pl[pos++]; - if (pos + name_len + 8 > end) { - return util::Error{5000, 0, - "DescribeTable: truncated field record", ""}; - } - RemoteConnection::FieldDesc f; - f.name.assign(pl.begin() + static_cast(pos), - pl.begin() + static_cast(pos + name_len)); - pos += name_len; - f.type = read_u16_le(&pl[pos]); pos += 2; - f.length = read_u32_le(&pl[pos]); pos += 4; - f.decimals = read_u16_le(&pl[pos]); pos += 2; - out.push_back(std::move(f)); - } - return out; -} - -} // namespace - -// M12.18 — parse the per-row trailer the server appends to every -// nav-op ack and the FetchCurrentRow ack. Format: -// -// [u8 has_row] -// if has_row != 0: -// [u32 recno][u8 deleted][u16 nfields] -// per field: [u32 len][bytes] -// -// On success rt is updated in-place; on a "no row" trailer (has_row -// == 0) rt->row_valid is cleared and current_row left as is. -namespace { - -// Parse one record body starting at `pos`: [u32 recno][u8 deleted] -// [u16 nfields][per-field: u32 len, bytes]. Returns the new cursor -// position past the record, or std::size_t(-1) on truncation. -std::size_t parse_one_row(const std::vector& pl, - std::size_t pos, - std::uint32_t& recno, - bool& deleted, - std::vector& fields) { - constexpr std::size_t fail = static_cast(-1); - if (pos + 4 + 1 + 2 > pl.size()) return fail; - recno = read_u32_le(&pl[pos]); pos += 4; - deleted = (pl[pos++] != 0); - std::uint16_t n = read_u16_le(&pl[pos]); pos += 2; - if (n > kMaxWireFields) return fail; - fields.clear(); - fields.reserve(n); - for (std::uint16_t i = 0; i < n; ++i) { - if (pos + 4 > pl.size()) return fail; - std::uint32_t vlen = read_u32_le(&pl[pos]); pos += 4; - if (pos + vlen > pl.size()) return fail; - fields.emplace_back( - reinterpret_cast(pl.data() + pos), vlen); - pos += vlen; - } - return pos; -} - -// RCB 07/15/2026: `block_dir` is the direction the SERVER walked the lookahead -// block (+1 forward, -1 backward) — which is sign(eff), NOT sign(the caller's -// step. On a direction reversal those disagree for one step: after a forward -// run the resync eff can still be positive on the first backward Skip, so the -// server sends a FORWARD block; tagging it backward would make the next -// Skip(-1) pop a forward row and serve the wrong record. Callers that never -// request a block (GotoBottom / Seek / FetchCurrentRow) can leave the default; -// if no block comes back the queue ends up empty and prefetch_dir is set to 0. -std::size_t parse_row_trailer_into(RemoteTable* rt, - const std::vector& pl, - std::size_t pos = 0, - std::int8_t block_dir = 1) { - // Returns the offset where trailer parsing stopped, so callers - // can read trailing sections (reposition-bound piggyback). - if (rt == nullptr || pos >= pl.size()) { - if (rt) { rt->row_valid = false; rt->anchor_ok = false; } - return pos; - } - rt->prefetch_queue.clear(); - // M12.21 option C — every nav ack re-anchors the server cursor to the - // client's logical position, so the lag resets to zero. - rt->cursor_lag = 0; - rt->prefetch_dir = 0; - std::uint8_t has_row = pl[pos++]; - if (has_row == 0) { - rt->row_valid = false; - // Landed on no row: the server cursor is at a phantom, which - // certifies no recno anchor for the R2 self-goto check. - rt->anchor_ok = false; - // Lookahead count is always 0 when has_row=0, but the 2 bytes - // are still on the wire: consume them so the returned offset - // points past the trailer (trailing sections key off it). - if (pos + 2 <= pl.size()) pos += 2; - return pos; - } - auto end = parse_one_row(pl, pos, - rt->current_recno, rt->current_deleted, rt->current_row); - if (end == static_cast(-1)) { - rt->row_valid = false; rt->anchor_ok = false; return pos; - } - pos = end; - rt->row_valid = true; - // mtfix12 R2 — every server-shipped landing certifies the cursor - // anchor: the server cursor IS current_recno right now (the ack - // contract resets cursor_lag to 0 above). Serves self-GotoRecord - // and GetRecordNum while the freshness seq holds. - rt->anchor_recno = rt->current_recno; - rt->anchor_ok = true; - rt->anchor_seq = rt->conn != nullptr ? rt->conn->nav_seq() : 0; - rt->anchor_tbl_seq = - rt->conn != nullptr ? rt->conn->tbl_nav_seq(rt->id) : 0; - // M12.21 lookahead block. [u16 count] then count rows. - if (pos + 2 > pl.size()) return pos; // M12.18 server (no lookahead) — done. - std::uint16_t la = read_u16_le(&pl[pos]); pos += 2; - for (std::uint16_t i = 0; i < la; ++i) { - RemoteTable::PrefetchedRow pr; - end = parse_one_row(pl, pos, pr.recno, pr.deleted, pr.fields); - if (end == static_cast(-1)) break; - pos = end; - rt->prefetch_queue.push_back(std::move(pr)); - } - if (!rt->prefetch_queue.empty()) rt->prefetch_dir = block_dir; - return pos; -} - -} // namespace - -namespace { - -bool parse_scheme_uri(const std::string& uri, - const std::string& scheme, - std::string& host, - std::uint16_t& port, - std::string& data_dir) { - if (uri.size() < scheme.size() || - uri.compare(0, scheme.size(), scheme) != 0) { - return false; - } - std::size_t after = scheme.size(); - std::size_t slash = uri.find('/', after); - std::string hostport = uri.substr(after, - slash == std::string::npos ? std::string::npos : slash - after); - std::size_t colon = hostport.find(':'); - if (colon == std::string::npos) return false; - host = hostport.substr(0, colon); - port = static_cast( - std::strtoul(hostport.substr(colon + 1).c_str(), nullptr, 10)); - data_dir = (slash == std::string::npos) ? std::string() - : uri.substr(slash + 1); - return true; -} - -} // namespace - -bool parse_tcp_uri(const std::string& uri, - std::string& host, - std::uint16_t& port, - std::string& data_dir) { - return parse_scheme_uri(uri, "tcp://", host, port, data_dir); -} - -bool parse_tls_uri(const std::string& uri, - std::string& host, - std::uint16_t& port, - std::string& data_dir) { - return parse_scheme_uri(uri, "tls://", host, port, data_dir); -} - -namespace { -std::atomic g_frame_trace_hook{nullptr}; -} // namespace - -void set_frame_trace_hook(FrameTraceHook hook) noexcept { - g_frame_trace_hook.store(hook, std::memory_order_relaxed); -} - -std::uint64_t RemoteConnection::tbl_nav_seq(std::uint32_t id) { - std::lock_guard lk(mu_); - const auto it = tbl_nav_seqs_.find(id); - return it != tbl_nav_seqs_.end() ? it->second : 0; -} - -std::uint64_t RemoteConnection::tbl_write_gen(std::uint32_t id) { - std::lock_guard lk(mu_); - const auto it = tbl_write_gens_.find(id); - return it != tbl_write_gens_.end() ? it->second : 0; -} - -void RemoteConnection::note_tbl_nav(std::uint32_t id) { - std::lock_guard lk(mu_); - ++tbl_nav_seqs_[id]; -} - -void RemoteConnection::note_all_tables_nav() { - std::lock_guard lk(mu_); - for (auto& [id, v] : tbl_nav_seqs_) ++v; -} - -util::Result RemoteConnection::request(const Frame& f) { - std::lock_guard lk(mu_); - frame_seq_.fetch_add(1, std::memory_order_relaxed); - switch (f.opcode) { - case Opcode::AppendBlank: case Opcode::SetField: - case Opcode::SetFields: case Opcode::RecallRecord: - case Opcode::ExecuteSQL: case Opcode::Reindex: - case Opcode::PackTable: case Opcode::ZapTable: - data_epoch_.fetch_add(1, std::memory_order_relaxed); - break; - default: - break; - } - // mtfix12 — per-table generations. Every frame funnels through - // here, so classifying by opcode is exhaustive by construction. - // nav: cursor/visibility-affecting (mirrors note_nav_frame per - // table; FlushTable/FlushFileBuffers intentionally excluded — - // they move no cursor, and dropping them is exactly the widening - // the per-table envelope opts into). write: content-affecting - // (row bytes may differ after it lands). Index-keyed ops - // (Seek/SeekLast/SkipUnique/SetScope/ClearScope) and the - // conn-wide ShowDeleted are bumped by their methods, which know - // the binding. - if (f.payload.size() >= 4) { - const std::uint32_t tid = read_u32_le(f.payload.data()); - switch (f.opcode) { - case Opcode::GotoTop: case Opcode::GotoBottom: - case Opcode::GotoRecord: case Opcode::Skip: - case Opcode::AppendBlank: case Opcode::PackTable: - case Opcode::ZapTable: case Opcode::Reindex: - case Opcode::SetAOF: case Opcode::ClearAOFRemote: - case Opcode::CustomizeAOF: case Opcode::SetOrder: - case Opcode::SetOrderByName: - ++tbl_nav_seqs_[tid]; - break; - default: break; - } - switch (f.opcode) { - case Opcode::AppendBlank: case Opcode::SetField: - case Opcode::SetFields: case Opcode::SetRecord: - case Opcode::DeleteRecord: case Opcode::RecallRecord: - case Opcode::PackTable: case Opcode::ZapTable: - case Opcode::Reindex: - ++tbl_write_gens_[tid]; - break; - default: break; - } - } - const FrameTraceHook trace_hook = - g_frame_trace_hook.load(std::memory_order_relaxed); - const auto trace_t0 = trace_hook != nullptr - ? std::chrono::steady_clock::now() - : std::chrono::steady_clock::time_point{}; - // Fail fast on a disconnected handle. rddads hands us connection - // handles that AdsDisconnect already tore down (its "current - // connection" can point at a handle another thread just closed); - // dereferencing a null transport_ here was an access violation, - // and a blocking read on a dead socket hung the caller forever. - // SAP ADS returns an error immediately in this state. - if (!transport_ || !transport_->valid()) { - return util::Error{5036 /* AE_NO_CONNECTION */, 0, - "RemoteConnection: not connected", ""}; - } - if (auto r = write_frame(*transport_,f); !r) { - // Storm fix (run30): a failed send leaves the wire in an unknown - // state. Poison the connection so every later request() fails - // fast with AE_NO_CONNECTION instead of reading half-written or - // stale frames (opcode desync -> cascading "server error"). - transport_->close(); - return r.error(); - } - auto rep = read_frame(*transport_); - if (!rep) { - // Storm fix (run30): a failed/timeout recv is worse than a failed - // send — the request may still be in flight and its reply can - // arrive later. If we kept the socket, the next request would - // consume that late reply and every subsequent opcode check on - // the shared connection would mismatch (run30: ~370 cascading - // "SetField/AppendBlank: server error" after 30 s recv timeouts). - // Poison instead: fail fast with 5036 like a dropped connection. - transport_->close(); - return rep.error(); - } - if (trace_hook != nullptr) { - const long long us = static_cast( - std::chrono::duration_cast( - std::chrono::steady_clock::now() - trace_t0).count()); - const std::uint32_t tid = - f.payload.size() >= 4 ? read_u32_le(f.payload.data()) : 0u; - trace_hook(this, static_cast(f.opcode), tid, - f.payload.size(), - static_cast(rep.value().opcode), - rep.value().payload.size(), us); - } - // M12.10 — Error frame payload prefixed with [u32 LE ace_code]. - // Parse it back into the util::Error so callers see the real ACE - // code (5036, 7077, 5066, ...) instead of a generic 5000. - if (rep.value().opcode == Opcode::Error) { - std::uint32_t code = 5000; - std::string msg; - const auto& pl = rep.value().payload; - if (pl.size() >= 4) { - code = read_u32_le(pl.data()); - msg.assign(reinterpret_cast(pl.data() + 4), - pl.size() - 4); - } else { - msg.assign(reinterpret_cast(pl.data()), - pl.size()); - } - return util::Error{static_cast(code), 0, - std::move(msg), ""}; - } - return rep; -} - -namespace { - -void connect_pack_payload(std::vector& payload, - const std::string& data_dir, - const std::string& user, - const std::string& password) { - auto pushlen = [](std::vector& out, std::uint16_t n) { - out.push_back(static_cast( n & 0xFFu)); - out.push_back(static_cast((n >> 8) & 0xFFu)); - }; - auto pushstr = [&pushlen](std::vector& out, - const std::string& s) { - pushlen(out, static_cast(s.size())); - out.insert(out.end(), s.begin(), s.end()); - }; - pushstr(payload, data_dir); - pushstr(payload, user); - pushstr(payload, password); - // M12.21 option C — advertise the prefetch-consume capability so the - // server may piggyback lookahead rows on forward-Skip acks. Trailing - // and optional: pre-M12.21 servers ignore the extra 4 bytes. - // RCB 07/15/2026: M12.25 — also advertise backward (PgUp) prefetch. This is - // a distinct bit precisely because a server must NOT send a backward block - // to a client that only understands forward ones (see kCapPrefetchBackward). - std::uint32_t caps = kCapPrefetchConsume | kCapPrefetchBackward - | kCapOpenTableMode | kCapSetFieldsBatch - | kCapFlushInCloseAll | kCapNavOrderFuse - | kCapNavBoundaryPair; - for (int i = 0; i < 4; ++i) - payload.push_back(static_cast((caps >> (8 * i)) & 0xFFu)); -} - -} // namespace - -util::Result RemoteConnection::connect(const std::string& host, - std::uint16_t port, - const std::string& data_dir, - const std::string& user, - const std::string& password) { - auto s = connect_tcp(host, port); - if (!s) return s.error(); - return connect_with_transport(make_plain_transport(s.value()), - data_dir, user, password); -} - -util::Result -RemoteConnection::connect_with_transport(std::unique_ptr transport, - const std::string& data_dir, - const std::string& user, - const std::string& password) { - if (!transport || !transport->valid()) { - return util::Error{5000, 0, - "RemoteConnection: invalid transport", data_dir}; - } - transport_ = std::move(transport); - // Version probe: Hello predates every capability word and every - // server answers it (HelloAck payload = "openads/"), so a - // client can report WHICH serverd binary is answering without any - // new opcode. Best-effort: a failed probe leaves server_version_ - // empty (unknown) and the Connect below still decides success, so - // old or half-open peers behave exactly as before at the cost of - // one extra RTT per connect (connects are rare; USEs are not). - server_version_.clear(); - { - Frame hello; - hello.opcode = Opcode::Hello; - if (auto hrep = request(hello); - hrep && hrep.value().opcode == Opcode::HelloAck) { - const auto& pl = hrep.value().payload; - server_version_.assign(pl.begin(), pl.end()); - } - } - Frame req; - req.opcode = Opcode::Connect; - connect_pack_payload(req.payload, data_dir, user, password); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::ConnectAck) { - std::string msg(rep.value().payload.begin(), - rep.value().payload.end()); - return util::Error{5000, 0, "Connect: " + msg, data_dir}; - } - // Server caps echo: new servers append [u32 LE] after - // "connected:" (dir echoed verbatim). Old servers send exactly - // "connected:" — the size/echo check below rejects that shape - // so server_caps_ stays 0 and the client keeps legacy single ops. - server_caps_ = 0; - { - const auto& pl = rep.value().payload; - constexpr const char* kPrefix = "connected:"; - constexpr std::size_t kPreLen = 10; - if (pl.size() >= kPreLen + 4 && - std::equal(pl.begin(), - pl.begin() + static_cast(kPreLen), - kPrefix)) { - const std::size_t tail = pl.size() - kPreLen - 4; - if (tail == data_dir.size() && - std::equal(pl.begin() + static_cast(kPreLen), - pl.begin() + static_cast(kPreLen + tail), - data_dir.begin())) { - const std::uint8_t* c = pl.data() + pl.size() - 4; - server_caps_ = - static_cast(c[0]) | - (static_cast(c[1]) << 8) | - (static_cast(c[2]) << 16) | - (static_cast(c[3]) << 24); - } - } - } - // M12.32 — SET DELETED often runs before AdsConnect60 (rddads apps - // set it in Main, then connect), so the AdsShowDeleted broadcast - // found no remote connection to notify. Sync the state now; the - // server default is "show", so only the hidden state needs pushing. - if (!openads::engine::show_deleted()) show_deleted(false); - return {}; -} - -bool RemoteConnection::parked_reuse(const std::string& key, - std::unique_ptr& out) { - std::lock_guard lk(park_mu_); - const auto now = std::chrono::steady_clock::now(); - for (auto it = parked_.begin(); it != parked_.end(); ++it) { - const auto age = - std::chrono::duration_cast(now - it->parked_at); - if (age.count() > static_cast(kParkedTtlSec)) continue; - if (it->key == key) { - out = std::move(it->table); - parked_.erase(it); - return true; - } - } - return false; -} - -void RemoteConnection::parked_store(std::string key, - std::unique_ptr rt, - std::vector>& evicted) { - std::lock_guard lk(park_mu_); - const auto now = std::chrono::steady_clock::now(); - // Drop expired first (caller really-closes them like any eviction). - for (auto it = parked_.begin(); it != parked_.end();) { - const auto age = - std::chrono::duration_cast(now - it->parked_at); - if (age.count() > static_cast(kParkedTtlSec)) { - evicted.push_back(std::move(it->table)); - it = parked_.erase(it); - } else { - ++it; - } - } - ParkedTable e; - e.table = std::move(rt); - e.key = std::move(key); - e.parked_at = now; - parked_.push_back(std::move(e)); - // Cap: evict oldest (front) beyond the limit. - while (parked_.size() > kParkedMax) { - evicted.push_back(std::move(parked_.front().table)); - parked_.erase(parked_.begin()); - } -} - -bool RemoteConnection::parked_contains(const std::string& key) const { - std::lock_guard lk(park_mu_); - const auto now = std::chrono::steady_clock::now(); - for (const auto& e : parked_) { - const auto age = - std::chrono::duration_cast(now - e.parked_at); - if (age.count() > static_cast(kParkedTtlSec)) continue; - if (e.key == key && e.table) return true; - } - return false; -} - -void RemoteConnection::parked_flush( - std::vector>& out) { - std::lock_guard lk(park_mu_); - for (auto& e : parked_) { - if (e.table) out.push_back(std::move(e.table)); - } - parked_.clear(); -} - -void RemoteConnection::disconnect() noexcept { // Serialise with request(): resetting transport_ while another - // thread is mid-round-trip on this connection made that thread - // dereference a null transport_ (AV) or read a corrupted stream. - std::lock_guard lk(mu_); - if (!transport_ || !transport_->valid()) return; - Frame req; - req.opcode = Opcode::Disconnect; - (void)write_frame(*transport_, req); - transport_->close(); - transport_.reset(); -} - -util::Result RemoteConnection::begin_transaction() { - Frame req; - req.opcode = Opcode::BeginTransaction; - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::BeginTransactionAck) { - return util::Error{5000, 0, "BeginTransaction: server error", - std::string(rep.value().payload.begin(), - rep.value().payload.end())}; - } - return {}; -} - -util::Result RemoteConnection::commit_transaction() { - Frame req; - req.opcode = Opcode::CommitTransaction; - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::CommitTransactionAck) { - return util::Error{5000, 0, "CommitTransaction: server error", - std::string(rep.value().payload.begin(), - rep.value().payload.end())}; - } - return {}; -} - -util::Result RemoteConnection::rollback_transaction() { - Frame req; - req.opcode = Opcode::RollbackTransaction; - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::RollbackTransactionAck) { - return util::Error{5000, 0, "RollbackTransaction: server error", - std::string(rep.value().payload.begin(), - rep.value().payload.end())}; - } - return {}; -} - -util::Result -RemoteConnection::open_table(const std::string& rel, std::uint16_t mode) { - Frame req; - req.opcode = Opcode::OpenTable; - // Extended payload: [u16 mode][table_name_bytes] - // Old servers ignore the prefix and read the full payload as the table - // name; new servers strip the 2-byte prefix when mode is non-zero. - write_u16_le(mode, req.payload); - req.payload.insert(req.payload.end(), rel.begin(), rel.end()); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::OpenTableAck) { - return util::Error{5000, 0, "OpenTable: server error", - std::string(rep.value().payload.begin(), - rep.value().payload.end())}; - } - const auto& pl = rep.value().payload; - if (pl.size() < 4) { - return util::Error{5000, 0, - "OpenTable: ack payload too short", ""}; - } - OpenTableResult result; - result.id = read_u32_le(pl.data()); - // Fixed prefix: [u32 id][u16 bag_len][bag]. The bag field is always - // emitted (possibly empty) so the warm sections have a fixed anchor; - // servers predating sections send exactly the id, servers predating - // the always-emit rule send id+bag with no trailing sections. - size_t off = 4; - if (off + 2 <= pl.size()) { - std::uint16_t blen = read_u16_le(pl.data() + off); - off += 2; - if (off + blen <= pl.size()) { - if (blen > 0) { - result.prod_bag_path.assign( - reinterpret_cast(pl.data() + off), blen); - } - off += blen; - // Warm sections: [u8 count][tag u8][len u32 LE][bytes]... - // Unknown tags skip by length; truncation drops the sections - // (never the open — fall back to DescribeTable + GotoTop). - if (off < pl.size()) { - std::uint8_t nsec = pl[off++]; - for (std::uint8_t s = 0; s < nsec; ++s) { - if (off + 1 + 4 > pl.size()) break; - std::uint8_t tag = pl[off++]; - std::uint32_t slen = - static_cast(pl[off]) | - (static_cast(pl[off + 1]) << 8) | - (static_cast(pl[off + 2]) << 16) | - (static_cast(pl[off + 3]) << 24); - off += 4; - if (off + slen > pl.size()) break; - if (tag == OpenTableAckSections::kSchema) { - if (auto sr = parse_schema_body(pl, off, slen)) { - result.fields = std::move(sr).value(); - result.has_schema = true; - } - } else if (tag == OpenTableAckSections::kFirstRow) { - result.first_row.assign( - pl.begin() + static_cast(off), - pl.begin() + static_cast(off + slen)); - result.has_first_row = true; - } - off += slen; - } - } - } - } - return result; -} - -util::Result RemoteConnection::apply_open_row(RemoteTable* rt, - const std::vector& trailer) { - // Feed the warm row section through the same parser every nav ack - // uses, so the table lands exactly as an explicit GotoTop would - // leave it (row cache + prefetch block + lag reset). - parse_row_trailer_into(rt, trailer, 0); - return {}; -} - - -util::Result RemoteConnection::close_table(std::uint32_t id) { - Frame req; - req.opcode = Opcode::CloseTable; - write_u32_le(id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::CloseTableAck) { - return util::Error{5000, 0, "CloseTable: server error", ""}; - } - return {}; -} - -// Reposition-bound truth: the server appended [u8 bof][u8 eof][u32 recno] -// (+ optional [u32 reccount]) after the row trailer because it just -// positioned explicitly and knows them exactly. Applies them as certified -// state so the poll burst that always follows a reposition -// (AtBOF/AtEOF/RecNo/RecCount per paint row) is served locally. -// Length-gated by the caller: absent on old servers. -static void apply_bound_trailer(RemoteTable* rt, bool bof, bool eof, - std::uint32_t recno, std::uint32_t reccount, - bool has_count) { - if (rt == nullptr || rt->conn == nullptr) return; - rt->nav_at_bof = bof; - rt->nav_at_eof = eof; - // xBase truth table (mirrors the proven-false stickies): a defined - // position that is not a limit is provably not that limit; an - // empty cursor proves neither. - rt->nav_not_bof = !bof; - rt->nav_not_eof = !eof; - const std::uint64_t seq = rt->conn->nav_seq(); - rt->bound_bof_ok = true; - rt->bound_bof = bof; - rt->bound_eof_ok = true; - rt->bound_eof = eof; - rt->bound_seq = seq; - rt->recno_bound = recno; - rt->recno_bound_ok = true; - rt->recno_bound_seq = seq; - rt->bound_at = std::chrono::steady_clock::now(); - rt->bound_data_epoch = rt->conn->data_epoch(); - if (has_count) { - rt->count_bound = reccount; - rt->count_bound_ok = true; - rt->count_bound_seq = seq; - } -} - -// Read a bound tail at [tend, ...): 6 bytes (bof/eof/recno), plus an -// optional 4-byte reccount. Returns false when no tail is present -// (old server) — caller falls back to the wire polls as before. -static bool apply_bound_tail(RemoteTable* rt, - const std::vector& pl, - std::size_t tend) { - if (pl.size() < tend + 6) return false; - const bool has_count = pl.size() >= tend + 10; - apply_bound_trailer(rt, pl[tend] != 0, pl[tend + 1] != 0, - read_u32_le(pl.data() + tend + 2), - has_count ? read_u32_le(pl.data() + tend + 6) : 0u, - has_count); - return true; -} - -// mtfix12 R1 — pair-requested acks carry an explicit [u8 ack_flags] -// byte right after the row trailer so section offsets are deterministic -// (the plain length-inference in apply_bound_tail cannot tell a 6-byte -// bound followed by a pair section from a 10-byte bound). ack_flags -// bit 0x01: the main bound tail carries reccount (10 bytes). Applies -// the bound exactly like apply_bound_tail and returns the offset just -// past it. Missing/short tail: nothing applied, returns tend (the -// server declined pair entirely; no certification, wire fallback). -static std::size_t pair_ack_bound_end(RemoteTable* rt, - const std::vector& pl, - std::size_t tend) { - if (rt == nullptr || pl.size() < tend + 1) return tend; - const std::uint8_t af = pl[tend]; - const std::size_t blen = (af & 0x01) ? 10 : 6; - if (pl.size() < tend + 1 + blen) return tend; - apply_bound_trailer(rt, pl[tend + 1] != 0, pl[tend + 2] != 0, - read_u32_le(pl.data() + tend + 3), - (af & 0x01) ? read_u32_le(pl.data() + tend + 7) : 0u, - (af & 0x01) != 0); - return tend + 1 + blen; -} - -// mtfix12 R1 — boundary-pair tail (see kCapNavBoundaryPair in wire.h). -// Layout at [off, ...): -// [u8 flags][u32 trailer_len][trailer][bound 6|10][u32 keycount?] -// flags: 0x02 = bound carries reccount (10 bytes), 0x04 = keycount -// present (ordered tables). The trailer is row-trailer format -// (pack_row_trailer bytes at lookahead depth 0) describing the OPPOSITE -// boundary's landing, read by the server in the same atomic visit as -// the nav that carried it. Stored for the ABI layer to apply verbatim -// if the adjacent opposite-boundary call arrives while the conn-wide -// freshness envelope holds (same rule as remote_nav_duplicate) and no -// write touched the table since (write_gen). Absent/malformed tail = -// no certification: pair_valid stays false and the next -// opposite-boundary call goes to the wire exactly as before. -static bool apply_pair_tail(RemoteTable* rt, int opp_which, - std::uint32_t order, - const std::vector& pl, - std::size_t off) { - if (rt == nullptr || pl.size() < off + 5) return false; - const std::uint8_t flags = pl[off]; - const std::uint32_t tlen = read_u32_le(pl.data() + off + 1); - const std::size_t bound_len = (flags & 0x02) ? 10 : 6; - const std::size_t need = - 5 + static_cast(tlen) + bound_len + - ((flags & 0x04) ? 4 : 0); - if (pl.size() < off + need) return false; - std::size_t p = off + 5; - rt->pair_blob.assign(pl.begin() + p, pl.begin() + p + tlen); - p += tlen; - rt->pair_bof = pl[p] != 0; - rt->pair_eof = pl[p + 1] != 0; - rt->pair_recno = read_u32_le(pl.data() + p + 2); - rt->pair_has_count = (flags & 0x02) != 0; - rt->pair_reccount = rt->pair_has_count - ? read_u32_le(pl.data() + p + 6) : 0u; - p += bound_len; - rt->pair_has_keycount = (flags & 0x04) != 0; - rt->pair_keycount = rt->pair_has_keycount - ? read_u32_le(pl.data() + p) : 0u; - rt->pair_which = opp_which; - rt->pair_order = order; - rt->pair_seq = rt->conn != nullptr ? rt->conn->nav_seq() : 0; - rt->pair_write_gen = - rt->conn != nullptr ? rt->conn->tbl_write_gen(rt->id) : 0; - rt->pair_valid = true; - return true; -} - -util::Result RemoteConnection::apply_pair_blob(RemoteTable* rt) { - if (rt == nullptr || !rt->pair_valid) { - return util::Error{5000, 0, "apply_pair_blob: no certification", ""}; - } - // Same byte path a wire ack for the opposite boundary would take: - // the stored trailer parse re-anchors lag and resets prefetch, - // then the certified bound values land through the shared trailer - // application so every bound/recno/count stamp matches. - parse_row_trailer_into(rt, rt->pair_blob, 0); - apply_bound_trailer(rt, rt->pair_bof, rt->pair_eof, rt->pair_recno, - rt->pair_reccount, rt->pair_has_count); - if (rt->pair_has_keycount) { - rt->key_counts[rt->pair_order] = rt->pair_keycount; - } - return {}; -} - -util::Result RemoteConnection::goto_top(std::uint32_t id) { - note_nav_frame(); - Frame req; - req.opcode = Opcode::GotoTop; - write_u32_le(id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::GotoTopAck) { - return util::Error{5000, 0, "GotoTop: server error", ""}; - } - return {}; -} - -// M12.18 — RemoteTable-aware overload: same wire op, but parses -// the row trailer the server appends to populate the table's -// row cache in-place. xbrowse repaint becomes 1 RTT per Skip -// (the row arrives with the ack) instead of 2. -util::Result RemoteConnection::goto_top(RemoteTable* rt) { - note_nav_frame(); - // RCB 07/15/2026: rt is non-null by caller contract -- every caller resolves - // it from the handle registry (get_remote_table) or from a ri->parent that - // was already checked. Deliberately NOT null-checked here, matching every - // other rt/id method on this class; a lone guard (flagged in review) would - // be inconsistent noise, not added safety. - Frame req; - req.opcode = Opcode::GotoTop; - write_u32_le(rt->id, req.payload); - // RCB 07/14/2026: M12.24 — same OPTIONAL trailing [u16 depth] the Skip - // request carries. GotoTop now comes back warm (a lookahead block rides on - // the ack), and it has to respect AdsCacheRecords like everything else — - // otherwise an app that explicitly turned read-ahead OFF would still get a - // block dumped on it by every GoTop. Old servers length-check and ignore - // the two bytes; see kPrefetchDepthAuto in wire.h. - req.payload.push_back( - static_cast(rt->cache_records_hint & 0xFFu)); - req.payload.push_back( - static_cast((rt->cache_records_hint >> 8) & 0xFFu)); - // mtfix12 R1: ask for the opposite boundary's certification (the - // back-to-back dbGoTop(); dbGoBottom() ritual). Caps-gated: old - // servers never see the byte (flag 0x02, no order section). - const bool want_pair = server_nav_boundary_pair(); - if (want_pair) req.payload.push_back(0x02); - rt->pair_valid = false; - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::GotoTopAck) { - return util::Error{5000, 0, "GotoTop: server error", ""}; - } - const std::size_t tend = - parse_row_trailer_into(rt, rep.value().payload, 0); - // Reposition-bound piggyback (see goto_record): trailing - // [u8 bof][u8 eof][u32 recno], length-gated. - if (want_pair) { - const std::size_t off = - pair_ack_bound_end(rt, rep.value().payload, tend); - apply_pair_tail(rt, 2, rt->server_order_id, - rep.value().payload, off); - } else { - apply_bound_tail(rt, rep.value().payload, tend); - } - return {}; -} - -util::Result RemoteConnection::skip(std::uint32_t id, - std::int32_t step) { - note_nav_frame(); - Frame req; - req.opcode = Opcode::Skip; - write_u32_le(id, req.payload); - write_u32_le(static_cast(step), req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::SkipAck) { - return util::Error{5000, 0, "Skip: server error", ""}; - } - return {}; -} - -util::Result RemoteConnection::skip(RemoteTable* rt, - std::int32_t step) { - note_nav_frame(); - // RCB 07/15/2026: rt is non-null by caller contract (resolved from the - // handle registry, or a checked ri->parent). Not null-checked, for the same - // reason as goto_top(rt) above -- consistent with the rest of this class. - Frame req; - req.opcode = Opcode::Skip; - write_u32_le(rt->id, req.payload); - // M12.21 option C — the server cursor is offset from the client's logical - // position by cursor_lag (rows served locally from the queue without a - // round-trip). Fold that lag into the wire step so the server lands where - // the client logically is + step. RCB 07/15/2026: cursor_lag is signed now - // (negative for a backward run), so this one line serves both directions; - // parse_row_trailer_into on the ack zeroes it again. - std::int32_t eff = step + rt->cursor_lag; - write_u32_le(static_cast(eff), req.payload); - // RCB 07/14/2026: M12.23 — trailing optional [u16 depth] carrying the - // AdsCacheRecords value (kPrefetchDepthAuto when the app never called it). - // Safe to append unconditionally: an older server length-checks its Skip - // payload (`size() < 8`) and reads only the first 8 bytes, so it simply - // ignores these two. That is why this needed no capability bit — see the - // note on kPrefetchDepthAuto in wire.h for the other direction, which is - // the one that could actually have bitten us. - req.payload.push_back( - static_cast(rt->cache_records_hint & 0xFFu)); - req.payload.push_back( - static_cast((rt->cache_records_hint >> 8) & 0xFFu)); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::SkipAck) { - return util::Error{5000, 0, "Skip: server error", ""}; - } - // The block (if any) was walked by the server in the direction of eff, not - // of the user's step — see parse_row_trailer_into. eff == 0 sends no block. - const std::int8_t bdir = (eff > 0) ? 1 : (eff < 0) ? -1 : 1; - const std::size_t tend = - parse_row_trailer_into(rt, rep.value().payload, 0, bdir); - // Reposition-bound piggyback (see goto_record): trailing - // [u8 bof][u8 eof][u32 recno], length-gated. - apply_bound_tail(rt, rep.value().payload, tend); - return {}; -} - -util::Result -RemoteConnection::get_field(std::uint32_t id, - const std::string& field_name) { - Frame req; - req.opcode = Opcode::GetField; - write_u32_le(id, req.payload); - req.payload.insert(req.payload.end(), - field_name.begin(), field_name.end()); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::GetFieldAck) { - return util::Error{5000, 0, "GetField: server error", - field_name}; - } - return std::string(rep.value().payload.begin(), - rep.value().payload.end()); -} - -util::Result -RemoteConnection::record_count(std::uint32_t id) { - Frame req; - req.opcode = Opcode::GetRecordCount; - write_u32_le(id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::GetRecordCountAck || - rep.value().payload.size() < 4) { - return util::Error{5000, 0, - "GetRecordCount: server error", ""}; - } - return read_u32_le(rep.value().payload.data()); -} - -util::Result -RemoteConnection::key_count(std::uint32_t id) { - Frame req; - req.opcode = Opcode::GetKeyCount; - write_u32_le(id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::GetKeyCountAck || - rep.value().payload.size() < 4) { - return util::Error{5000, 0, - "GetKeyCount: server error", ""}; - } - return read_u32_le(rep.value().payload.data()); -} - -// M12.29 — server-side key number. The server computes the position of the -// current record in the active order's walk via pos_of_recno_cached() → O(1), -// eliminating the O(n) remote_measure_keyno client-side walk. -util::Result RemoteConnection::key_num(std::uint32_t id) { - Frame req; - req.opcode = Opcode::GetKeyNum; - write_u32_le(id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::GetKeyNumAck || - rep.value().payload.size() < 4) { - return util::Error{5000, 0, - "GetKeyNum: server error", ""}; - } - return read_u32_le(rep.value().payload.data()); -} - -util::Result -RemoteConnection::bof_eof(std::uint32_t id) { - Frame req; - req.opcode = Opcode::AtBOF; - write_u32_le(id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::AtBOFAck || - rep.value().payload.empty()) { - return util::Error{5000, 0, "AtBOF: server error", ""}; - } - BofEof out; - out.bof = rep.value().payload[0] != 0; - // Twin flag: new servers append the EOF answer ([u8 bof][u8 eof]). - // Old single-byte servers leave has_twin false and the caller - // behaves exactly as before. - if (rep.value().payload.size() >= 2) { - out.eof = rep.value().payload[1] != 0; - out.has_twin = true; - } - return out; -} - -util::Result -RemoteConnection::eof_bof(std::uint32_t id) { - Frame req; - req.opcode = Opcode::AtEOF; - write_u32_le(id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::AtEOFAck || - rep.value().payload.empty()) { - return util::Error{5000, 0, "AtEOF: server error", ""}; - } - BofEof out; - out.eof = rep.value().payload[0] != 0; - // Twin flag: new servers append the BOF answer ([u8 eof][u8 bof]). - if (rep.value().payload.size() >= 2) { - out.bof = rep.value().payload[1] != 0; - out.has_twin = true; - } - return out; -} - -util::Result RemoteConnection::at_eof(std::uint32_t id) { - auto r = eof_bof(id); - if (!r) return r.error(); - return r.value().eof; -} - -util::Result RemoteConnection::append_blank(std::uint32_t id) { - note_nav_frame(); - Frame req; - req.opcode = Opcode::AppendBlank; - write_u32_le(id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::AppendBlankAck) { - return util::Error{5000, 0, "AppendBlank: server error", ""}; - } - return {}; -} - -util::Result> -RemoteConnection::get_record(std::uint32_t id) { - Frame req; - req.opcode = Opcode::GetRecord; - write_u32_le(id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::GetRecordAck || - rep.value().payload.size() < 2) { - return util::Error{5000, 0, "GetRecord: server error", ""}; - } - const auto& pl = rep.value().payload; - std::uint16_t len = static_cast( - static_cast(pl[0]) | - (static_cast(pl[1]) << 8)); - if (pl.size() < 2u + len) { - return util::Error{5000, 0, "GetRecord: truncated payload", ""}; - } - return std::vector(pl.begin() + 2, pl.begin() + 2 + len); -} - -util::Result -RemoteConnection::get_record_crc(std::uint32_t id) { - Frame req; - req.opcode = Opcode::GetRecordCRC; - write_u32_le(id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::GetRecordCRAck || - rep.value().payload.size() < 4) { - return util::Error{5000, 0, "GetRecordCRC: server error", ""}; - } - const auto& pl = rep.value().payload; - return static_cast(pl[0]) | - (static_cast(pl[1]) << 8) | - (static_cast(pl[2]) << 16) | - (static_cast(pl[3]) << 24); -} - -util::Result RemoteConnection::set_record(std::uint32_t id, - const std::uint8_t* bytes, - std::size_t len) { - note_nav_frame(); - if (len > 0xFFFFu) { - return util::Error{5000, 0, "SetRecord: record too large", ""}; - } - Frame req; - req.opcode = Opcode::SetRecord; - write_u32_le(id, req.payload); - auto ulen = static_cast(len); - write_u16_le(ulen, req.payload); - if (bytes != nullptr && len > 0) { - req.payload.insert(req.payload.end(), bytes, bytes + len); - } - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::SetRecordAck) { - return util::Error{5000, 0, "SetRecord: server error", ""}; - } - return {}; -} - -util::Result RemoteConnection::set_field(std::uint32_t id, - const std::string& field_name, - const std::string& value) { - note_nav_frame(); - if (field_name.size() > 0xFFFFu) { - return util::Error{5000, 0, - "SetField: field name too long", field_name}; - } - Frame req; - req.opcode = Opcode::SetField; - write_u32_le(id, req.payload); - auto nlen = static_cast(field_name.size()); - req.payload.push_back(static_cast( nlen & 0xFFu)); - req.payload.push_back(static_cast((nlen >> 8) & 0xFFu)); - req.payload.insert(req.payload.end(), - field_name.begin(), field_name.end()); - req.payload.insert(req.payload.end(), value.begin(), value.end()); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::SetFieldAck) { - return util::Error{5000, 0, "SetField: server error", - field_name}; - } - return {}; -} - -util::Result RemoteConnection::set_fields_batch( - std::uint32_t id, - const std::vector>& fields) { - note_nav_frame(); - if (fields.empty()) return {}; - if (fields.size() > kMaxWireFields) { - return util::Error{5000, 0, - "SetFields: too many fields", ""}; - } - Frame req; - req.opcode = Opcode::SetFields; - write_u32_le(id, req.payload); - write_u16_le(static_cast(fields.size()), req.payload); - for (const auto& [name, value] : fields) { - if (name.size() > 0xFFFFu || value.size() > 0xFFFFFFFFu) { - return util::Error{5000, 0, - "SetFields: field too large", name}; - } - write_u16_le(static_cast(name.size()), req.payload); - req.payload.insert(req.payload.end(), name.begin(), name.end()); - std::uint32_t vlen = static_cast(value.size()); - req.payload.push_back(static_cast( vlen & 0xFFu)); - req.payload.push_back(static_cast((vlen >> 8) & 0xFFu)); - req.payload.push_back(static_cast((vlen >> 16) & 0xFFu)); - req.payload.push_back(static_cast((vlen >> 24) & 0xFFu)); - req.payload.insert(req.payload.end(), value.begin(), value.end()); - } - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::SetFieldsAck) { - return util::Error{5000, 0, "SetFields: server error", ""}; - } - return {}; -} - -util::Result RemoteConnection::delete_record(std::uint32_t id) { - note_nav_frame(); - Frame req; - req.opcode = Opcode::DeleteRecord; - write_u32_le(id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::DeleteRecordAck) { - return util::Error{5000, 0, "DeleteRecord: server error", ""}; - } - return {}; -} - -util::Result RemoteConnection::recall_record(std::uint32_t id) { - note_nav_frame(); - Frame req; - req.opcode = Opcode::RecallRecord; - write_u32_le(id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::RecallRecordAck) { - return util::Error{5000, 0, "RecallRecord: server error", ""}; - } - return {}; -} - -util::Result RemoteConnection::goto_record(std::uint32_t id, - std::uint32_t recno) { - note_nav_frame(); - Frame req; - req.opcode = Opcode::GotoRecord; - write_u32_le(id, req.payload); - write_u32_le(recno, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::GotoRecordAck) { - return util::Error{5000, 0, "GotoRecord: server error", ""}; - } - return {}; -} - -util::Result RemoteConnection::goto_record(RemoteTable* rt, - std::uint32_t recno) { - note_nav_frame(); - Frame req; - req.opcode = Opcode::GotoRecord; - write_u32_le(rt->id, req.payload); - write_u32_le(recno, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::GotoRecordAck) { - return util::Error{5000, 0, "GotoRecord: server error", ""}; - } - const std::size_t tend = - parse_row_trailer_into(rt, rep.value().payload, 0); - // Reposition-bound piggyback: trailing [u8 bof][u8 eof][u32 recno] - // (length-gated; old servers send no tail). Certifies the boundary - // + recno answers so the post-reposition poll burst costs nothing. - apply_bound_tail(rt, rep.value().payload, tend); - return {}; -} - -util::Result RemoteConnection::goto_bottom(RemoteTable* rt) { - note_nav_frame(); - Frame req; - req.opcode = Opcode::GotoBottom; - write_u32_le(rt->id, req.payload); - // mtfix12 R1: opposite-boundary certification (see goto_top). - const bool want_pair = server_nav_boundary_pair(); - if (want_pair) req.payload.push_back(0x02); - rt->pair_valid = false; - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::GotoBottomAck) { - return util::Error{5000, 0, "GotoBottom: server error", ""}; - } - const std::size_t tend = - parse_row_trailer_into(rt, rep.value().payload, 0); - // Reposition-bound piggyback (see goto_record): trailing - // [u8 bof][u8 eof][u32 recno], length-gated. - if (want_pair) { - const std::size_t off = - pair_ack_bound_end(rt, rep.value().payload, tend); - apply_pair_tail(rt, 1, rt->server_order_id, - rep.value().payload, off); - } else { - apply_bound_tail(rt, rep.value().payload, tend); - } - return {}; -} - -// Fused nav+order: trailing [u8 0x01][u32 order_id] installs the order -// before navigating (see kCapNavOrderFuse). Caller guarantees the -// server advertised the bit; old servers must never see this shape -// (their prefix-only parse would navigate the stale binding). -util::Result RemoteConnection::goto_top_fused(RemoteTable* rt, - std::uint32_t order_id) { - note_nav_frame(); - Frame req; - req.opcode = Opcode::GotoTop; - write_u32_le(rt->id, req.payload); - req.payload.push_back( - static_cast(rt->cache_records_hint & 0xFFu)); - req.payload.push_back( - static_cast((rt->cache_records_hint >> 8) & 0xFFu)); - const bool want_pair = server_nav_boundary_pair(); - req.payload.push_back(static_cast( - 0x01 | (want_pair ? 0x02 : 0x00))); - write_u32_le(order_id, req.payload); - rt->pair_valid = false; - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::GotoTopAck) { - return util::Error{5000, 0, "GotoTop: server error", ""}; - } - const std::size_t tend = - parse_row_trailer_into(rt, rep.value().payload, 0); - if (want_pair) { - // Pair-requested fused ack: [rowtrailer][u8 ack_flags][bound] - // [u32 fused key count][pair section]. - const std::size_t off = - pair_ack_bound_end(rt, rep.value().payload, tend); - const auto& pl = rep.value().payload; - if (pl.size() >= off + 4) { - rt->key_counts[order_id] = read_u32_le(pl.data() + off); - } - apply_pair_tail(rt, 2, order_id, pl, off + 4); - } else { - // Reposition-bound piggyback (see goto_record): trailing - // [u8 bof][u8 eof][u32 recno](+[u32 reccount]), length-gated. - apply_bound_tail(rt, rep.value().payload, tend); - // Fused switch only: the server certified the new order's key - // count past the bound tail ([u32]). Rotation visits ask it - // next; serve from the per-order map instead of a frame. - const auto& pl = rep.value().payload; - if (pl.size() >= tend + 14) { - rt->key_counts[order_id] = read_u32_le(pl.data() + tend + 10); - } - } - return {}; -} - -util::Result RemoteConnection::goto_bottom_fused(RemoteTable* rt, - std::uint32_t order_id) { - note_nav_frame(); - Frame req; - req.opcode = Opcode::GotoBottom; - write_u32_le(rt->id, req.payload); - const bool want_pair = server_nav_boundary_pair(); - req.payload.push_back(static_cast( - 0x01 | (want_pair ? 0x02 : 0x00))); - write_u32_le(order_id, req.payload); - rt->pair_valid = false; - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::GotoBottomAck) { - return util::Error{5000, 0, "GotoBottom: server error", ""}; - } - const std::size_t tend = - parse_row_trailer_into(rt, rep.value().payload, 0); - if (want_pair) { - const std::size_t off = - pair_ack_bound_end(rt, rep.value().payload, tend); - const auto& pl = rep.value().payload; - if (pl.size() >= off + 4) { - rt->key_counts[order_id] = read_u32_le(pl.data() + off); - } - apply_pair_tail(rt, 1, order_id, pl, off + 4); - } else { - // Reposition-bound piggyback (see goto_record): trailing - // [u8 bof][u8 eof][u32 recno](+[u32 reccount]), length-gated. - apply_bound_tail(rt, rep.value().payload, tend); - // Fused switch only: certified key count past the bound tail. - const auto& pl = rep.value().payload; - if (pl.size() >= tend + 14) { - rt->key_counts[order_id] = read_u32_le(pl.data() + tend + 10); - } - } - return {}; -} - -util::Result RemoteConnection::flush_table(std::uint32_t id) { - note_nav_frame(); - Frame req; - req.opcode = Opcode::FlushTable; - write_u32_le(id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::FlushTableAck) { - return util::Error{5000, 0, "FlushTable: server error", ""}; - } - return {}; -} - -util::Result -RemoteConnection::find_record( - std::uint32_t id, - const std::vector>& identity) { - Frame req; - req.opcode = Opcode::FindRecord; - write_u32_le(id, req.payload); - write_u16_le(static_cast(identity.size()), req.payload); - for (auto& [n, v] : identity) { - write_u16_le(static_cast(n.size()), req.payload); - req.payload.insert(req.payload.end(), n.begin(), n.end()); - write_u16_le(static_cast(v.size()), req.payload); - req.payload.insert(req.payload.end(), v.begin(), v.end()); - } - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::FindRecordAck) { - return util::Error{5000, 0, "FindRecord: server error", - std::string(rep.value().payload.begin(), - rep.value().payload.end())}; - } - if (rep.value().payload.size() < 4) { - return util::Error{5000, 0, "FindRecord: ack too short", ""}; - } - std::uint32_t recno = read_u32_le(rep.value().payload.data()); - return recno; -} - -util::Result RemoteConnection::reindex(std::uint32_t id) { - note_nav_frame(); - Frame req; - req.opcode = Opcode::Reindex; - write_u32_le(id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::ReindexAck) { - return util::Error{5000, 0, "Reindex: server error", ""}; - } - return {}; -} - -util::Result>> -RemoteConnection::fetch_batch(std::uint32_t id, - std::uint32_t max_rows, - const std::vector& columns) { - note_nav_frame(); - if (columns.size() > 0xFFu) { - return util::Error{5000, 0, - "Fetch: too many columns (max 255)", ""}; - } - Frame req; - req.opcode = Opcode::Fetch; - write_u32_le(id, req.payload); - write_u32_le(max_rows, req.payload); - req.payload.push_back(static_cast(columns.size())); - for (auto& c : columns) { - if (c.size() > 0xFFu) { - return util::Error{5000, 0, - "Fetch: column name too long (max 255)", c}; - } - req.payload.push_back(static_cast(c.size())); - req.payload.insert(req.payload.end(), c.begin(), c.end()); - } - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::FetchAck || - rep.value().payload.size() < 5) { - return util::Error{5000, 0, "Fetch: server error", ""}; - } - const auto& pl = rep.value().payload; - std::size_t p = 0; - std::uint32_t nrows = read_u32_le(pl.data() + p); p += 4; - std::uint8_t ncols = pl[p++]; - std::vector> rows; - rows.reserve(nrows); - for (std::uint32_t r = 0; r < nrows; ++r) { - std::vector row; - row.reserve(ncols); - for (std::uint8_t c = 0; c < ncols; ++c) { - if (p + 2 > pl.size()) { - return util::Error{5000, 0, - "Fetch: truncated payload (vlen)", ""}; - } - std::uint16_t vlen = static_cast( - static_cast(pl[p]) | - (static_cast(pl[p + 1]) << 8)); - p += 2; - if (p + vlen > pl.size()) { - return util::Error{5000, 0, - "Fetch: truncated payload (val)", ""}; - } - row.emplace_back(reinterpret_cast(pl.data() + p), - vlen); - p += vlen; - } - rows.push_back(std::move(row)); - } - return rows; -} - -util::Result -RemoteConnection::fetch_where(std::uint32_t id, - std::uint32_t max_rows, - const std::string& where_expr, - const std::vector& columns, - std::uint8_t flags) { - note_nav_frame(); - if (columns.size() > 0xFFu) { - return util::Error{5000, 0, - "FetchWhere: too many columns (max 255)", ""}; - } - if (where_expr.size() > 0xFFFFu) { - return util::Error{5000, 0, - "FetchWhere: predicate too long (max 65535)", ""}; - } - Frame req; - req.opcode = Opcode::FetchWhere; - write_u32_le(id, req.payload); - write_u32_le(max_rows, req.payload); - req.payload.push_back(flags); // new: flags byte at offset 8 - req.payload.push_back( - static_cast( where_expr.size() & 0xFFu)); - req.payload.push_back( - static_cast((where_expr.size() >> 8) & 0xFFu)); - req.payload.insert(req.payload.end(), - where_expr.begin(), where_expr.end()); - req.payload.push_back(static_cast(columns.size())); - for (auto& c : columns) { - if (c.size() > 0xFFu) { - return util::Error{5000, 0, - "FetchWhere: column name too long (max 255)", c}; - } - req.payload.push_back(static_cast(c.size())); - req.payload.insert(req.payload.end(), c.begin(), c.end()); - } - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::FetchWhereAck || - rep.value().payload.size() < 5) { - return util::Error{5000, 0, "FetchWhere: server error", ""}; - } - const auto& pl = rep.value().payload; - std::size_t p = 0; - std::uint32_t nrows = read_u32_le(pl.data() + p); p += 4; - std::uint8_t ncols = pl[p++]; - FetchWhereBatch batch; - batch.rows.reserve(nrows); - if (flags & FetchWhereFlags::WANT_RECNO) - batch.recnos.reserve(nrows); - for (std::uint32_t r = 0; r < nrows; ++r) { - // Per-row optional recno (emitted before column data). - if (flags & FetchWhereFlags::WANT_RECNO) { - if (p + 4 > pl.size()) { - return util::Error{5000, 0, - "FetchWhere: truncated payload (recno)", ""}; - } - std::uint32_t rn = read_u32_le(pl.data() + p); p += 4; - batch.recnos.push_back(rn); - } - std::vector row; - row.reserve(ncols); - for (std::uint8_t c = 0; c < ncols; ++c) { - if (p + 2 > pl.size()) { - return util::Error{5000, 0, - "FetchWhere: truncated payload (vlen)", ""}; - } - std::uint16_t vlen = static_cast( - static_cast(pl[p]) | - (static_cast(pl[p + 1]) << 8)); - p += 2; - if (p + vlen > pl.size()) { - return util::Error{5000, 0, - "FetchWhere: truncated payload (val)", ""}; - } - row.emplace_back(reinterpret_cast(pl.data() + p), - vlen); - p += vlen; - } - batch.rows.push_back(std::move(row)); - } - // Trailing [u8 eof] byte: 1 = the server walked to end of table. - if (p < pl.size()) { - batch.eof = (pl[p] != 0); - } - return batch; -} - -util::Result -RemoteConnection::aggregate(std::uint32_t id, - const std::string& for_expr, - const std::vector& specs) { - note_nav_frame(); - if (specs.size() > 0xFFu) - return util::Error{5000, 0, - "Aggregate: too many aggregates (max 255)", ""}; - if (for_expr.size() > 0xFFFFu) - return util::Error{5000, 0, - "Aggregate: predicate too long (max 65535)", ""}; - Frame req; - req.opcode = Opcode::Aggregate; - write_u32_le(id, req.payload); - req.payload.push_back( - static_cast( for_expr.size() & 0xFFu)); - req.payload.push_back( - static_cast((for_expr.size() >> 8) & 0xFFu)); - req.payload.insert(req.payload.end(), for_expr.begin(), for_expr.end()); - req.payload.push_back(static_cast(specs.size())); - for (const auto& s : specs) { - if (s.field.size() > 0xFFu) - return util::Error{5000, 0, - "Aggregate: field name too long (max 255)", s.field}; - req.payload.push_back(static_cast(s.fn)); - req.payload.push_back(static_cast(s.field.size())); - req.payload.insert(req.payload.end(), s.field.begin(), s.field.end()); - } - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::AggregateAck || - rep.value().payload.empty()) - return util::Error{5000, 0, "Aggregate: server error", ""}; - - const auto& pl = rep.value().payload; - std::size_t p = 0; - std::uint8_t n = pl[p++]; - AggregateBatch out; - out.values.reserve(n); - for (std::uint8_t i = 0; i < n; ++i) { - if (p + 3 > pl.size()) - return util::Error{5000, 0, - "Aggregate: truncated payload (header)", ""}; - std::uint8_t rt = pl[p++]; - std::uint16_t vlen = static_cast( - static_cast(pl[p]) | - (static_cast(pl[p + 1]) << 8)); - p += 2; - if (p + vlen > pl.size()) - return util::Error{5000, 0, - "Aggregate: truncated payload (value)", ""}; - engine::AggValue v; - v.type = static_cast(rt); - v.bytes.assign(reinterpret_cast(pl.data() + p), vlen); - p += vlen; - out.values.push_back(std::move(v)); - } - return out; -} - -util::Result -RemoteConnection::execute_sql(const std::string& sql) { - Frame req; - req.opcode = Opcode::ExecuteSQL; - req.payload.assign(sql.begin(), sql.end()); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::ExecuteSQLAck || - rep.value().payload.size() < 4) { - return util::Error{5000, 0, "ExecuteSQL: server error", - sql.substr(0, 200)}; - } - return read_u32_le(rep.value().payload.data()); -} - -// ===================================================================== -// M12.14 — remote field metadata + extended cursor state. -// ===================================================================== - -util::Result> -RemoteConnection::describe_table(std::uint32_t id) { - Frame req; - req.opcode = Opcode::DescribeTable; - write_u32_le(id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::DescribeTableAck) { - return util::Error{5000, 0, - "DescribeTable: server error", ""}; - } - const auto& pl = rep.value().payload; - if (pl.size() < 2) { - return util::Error{5000, 0, - "DescribeTable: short payload", ""}; - } - return parse_schema_body(pl, 0, pl.size()); -} - -util::Result RemoteConnection::at_bof(std::uint32_t id) { - auto r = bof_eof(id); - if (!r) return r.error(); - return r.value().bof; -} - -util::Result -RemoteConnection::get_record_num(std::uint32_t id) { - Frame req; - req.opcode = Opcode::GetRecordNum; - write_u32_le(id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::GetRecordNumAck || - rep.value().payload.size() < 4) { - return util::Error{5000, 0, - "GetRecordNum: server error", ""}; - } - return read_u32_le(rep.value().payload.data()); -} - -util::Result -RemoteConnection::is_record_deleted(std::uint32_t id) { - Frame req; - req.opcode = Opcode::IsRecordDeleted; - write_u32_le(id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::IsRecordDeletedAck || - rep.value().payload.empty()) { - return util::Error{5000, 0, - "IsRecordDeleted: server error", ""}; - } - return rep.value().payload[0] != 0; -} - -util::Result RemoteConnection::goto_bottom(std::uint32_t id) { - note_nav_frame(); - Frame req; - req.opcode = Opcode::GotoBottom; - write_u32_le(id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::GotoBottomAck) { - return util::Error{5000, 0, "GotoBottom: server error", ""}; - } - return {}; -} - -// ===================================================================== -// M12.15 — info / lock / maintenance / AOF. -// -// Pattern: every op carries a u32 server table id in the request -// payload; the ack carries the answer (bool / u16 / u32) or is -// empty for void. The server-side handlers in network/server.cpp -// match the same wire layout. -// ===================================================================== - -util::Result RemoteConnection::is_found(std::uint32_t id) { - Frame req; req.opcode = Opcode::IsFound; - write_u32_le(id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::IsFoundAck || - rep.value().payload.empty()) { - return util::Error{5000, 0, "IsFound: server error", ""}; - } - return rep.value().payload[0] != 0; -} - -util::Result RemoteConnection::refresh_record(std::uint32_t id) { - Frame req; req.opcode = Opcode::RefreshRecord; - write_u32_le(id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::RefreshRecordAck) { - return util::Error{5000, 0, "RefreshRecord: server error", ""}; - } - return {}; -} - -util::Result RemoteConnection::refresh_record(RemoteTable* rt) { - if (rt == nullptr) { - return util::Error{5000, 0, "RefreshRecord: null table", ""}; - } - // No note_nav_frame: a refresh re-reads the same position, it - // never moves the cursor (matches the id overload below). - Frame req; req.opcode = Opcode::RefreshRecord; - write_u32_le(rt->id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::RefreshRecordAck) { - return util::Error{5000, 0, "RefreshRecord: server error", ""}; - } - const std::size_t tend = - parse_row_trailer_into(rt, rep.value().payload, 0); - // Refreshed row + bounds + recno ride back (length-gated). - apply_bound_tail(rt, rep.value().payload, tend); - return {}; -} - -util::Result -RemoteConnection::get_table_type(std::uint32_t id) { - Frame req; req.opcode = Opcode::GetTableType; - write_u32_le(id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::GetTableTypeAck || - rep.value().payload.size() < 2) { - return util::Error{5000, 0, "GetTableType: server error", ""}; - } - return read_u16_le(rep.value().payload.data()); -} - -util::Result -RemoteConnection::get_record_length(std::uint32_t id) { - Frame req; req.opcode = Opcode::GetRecordLength; - write_u32_le(id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::GetRecordLengthAck || - rep.value().payload.size() < 4) { - return util::Error{5000, 0, "GetRecordLength: server error", ""}; - } - return read_u32_le(rep.value().payload.data()); -} - -util::Result -RemoteConnection::get_num_indexes(std::uint32_t id) { - Frame req; req.opcode = Opcode::GetNumIndexes; - write_u32_le(id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::GetNumIndexesAck || - rep.value().payload.size() < 2) { - return util::Error{5000, 0, "GetNumIndexes: server error", ""}; - } - return read_u16_le(rep.value().payload.data()); -} - -util::Result -RemoteConnection::get_last_autoinc(std::uint32_t id) { - Frame req; req.opcode = Opcode::GetLastAutoinc; - write_u32_le(id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::GetLastAutoincAck || - rep.value().payload.size() < 4) { - return util::Error{5000, 0, "GetLastAutoinc: server error", ""}; - } - return read_u32_le(rep.value().payload.data()); -} - -util::Result -RemoteConnection::get_last_table_update(std::uint32_t id) { - Frame req; req.opcode = Opcode::GetLastTableUpdate; - write_u32_le(id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::GetLastTableUpdateAck || - rep.value().payload.size() < 4) { - return util::Error{5000, 0, "GetLastTableUpdate: server error", ""}; - } - return read_u32_le(rep.value().payload.data()); -} - -util::Result RemoteConnection::lock_record(std::uint32_t id, - std::uint32_t recno) { - // The server answers a contended lock immediately (fail-fast). The - // retry lives HERE: one wire request per attempt, so the connection - // mutex is free between attempts and a peer thread's unlock/lock ops - // interleave instead of starving behind ours (Pritpal Bedi: - // "dbUnlock() in threads fail somehow"). - const auto policy = openads::abi::lock_retry_policy(); - // Budget = retry_count x cycle_ms (the plain ACE total-wait contract), - // but early attempts recheck after a few ms (adaptive backoff): a - // short contention — the common case — resolves in single-digit ms - // instead of one 100ms slice (700-instance B_BIG measured a full - // ~1.4s per contended RLock with the flat quantum). - const auto t0 = std::chrono::steady_clock::now(); - const auto deadline = - t0 + std::chrono::milliseconds(policy.budget_ms()); - for (std::uint32_t i = 0; ; ++i) { - Frame req; req.opcode = Opcode::LockRecord; - write_u32_le(id, req.payload); - write_u32_le(recno, req.payload); - auto rep = request(req); - if (rep && rep.value().opcode == Opcode::LockRecordAck) return {}; - // Only contention (AE_LOCKED) is retryable — the server fail-fast - // answers it via an Error frame, which request() maps to a failed - // Result. Anything else is a real error; return it at once. - const bool contended = - !rep && rep.error().code == - static_cast(openads::AE_LOCKED); - if (!contended) { - if (rep) { - return util::Error{5000, 0, "LockRecord: server error", ""}; - } - return rep.error(); - } - if (i >= policy.retry_count && - std::chrono::steady_clock::now() >= deadline) { - return rep.error(); - } - openads::abi::lock_retry_sleep(i); - } -} - -util::Result RemoteConnection::unlock_record(std::uint32_t id, - std::uint32_t recno) { - Frame req; req.opcode = Opcode::UnlockRecord; - write_u32_le(id, req.payload); - write_u32_le(recno, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::UnlockRecordAck) { - return util::Error{5000, 0, "UnlockRecord: server error", ""}; - } - return {}; -} - -util::Result RemoteConnection::is_record_locked( - std::uint32_t id, std::uint32_t recno) { - Frame req; req.opcode = Opcode::IsRecordLocked; - write_u32_le(id, req.payload); - write_u32_le(recno, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::IsRecordLockedAck || - rep.value().payload.size() < 2) { - return util::Error{5000, 0, "IsRecordLocked: server error", ""}; - } - return read_u16_le(rep.value().payload.data()); -} - -util::Result> RemoteConnection::get_all_locks( - std::uint32_t id) { - Frame req; req.opcode = Opcode::GetAllLocks; - write_u32_le(id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::GetAllLocksAck || - rep.value().payload.size() < 2) { - return util::Error{5000, 0, "GetAllLocks: server error", ""}; - } - const auto& pl = rep.value().payload; - std::uint16_t n = read_u16_le(pl.data()); - if (pl.size() < 2 + static_cast(n) * 4) { - return util::Error{5000, 0, "GetAllLocks: short payload", ""}; - } - std::vector recs; - recs.reserve(n); - for (std::uint16_t i = 0; i < n; ++i) { - recs.push_back(read_u32_le(pl.data() + 2 + i * 4)); - } - return recs; -} - -util::Result RemoteConnection::lock_table(std::uint32_t id) { - // Same client-side retry as lock_record (see there): budget = - // retry_count x cycle_ms with adaptive early backoff. - const auto policy = openads::abi::lock_retry_policy(); - const auto t0 = std::chrono::steady_clock::now(); - const auto deadline = - t0 + std::chrono::milliseconds(policy.budget_ms()); - for (std::uint32_t i = 0; ; ++i) { - Frame req; req.opcode = Opcode::LockTable; - write_u32_le(id, req.payload); - auto rep = request(req); - if (rep && rep.value().opcode == Opcode::LockTableAck) return {}; - const bool contended = - !rep && rep.error().code == - static_cast(openads::AE_LOCKED); - if (!contended) { - if (rep) { - return util::Error{5000, 0, "LockTable: server error", ""}; - } - return rep.error(); - } - if (i >= policy.retry_count && - std::chrono::steady_clock::now() >= deadline) { - return rep.error(); - } - openads::abi::lock_retry_sleep(i); - } -} - -util::Result RemoteConnection::unlock_table(std::uint32_t id) { - Frame req; req.opcode = Opcode::UnlockTable; - write_u32_le(id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::UnlockTableAck) { - return util::Error{5000, 0, "UnlockTable: server error", ""}; - } - return {}; -} - -util::Result RemoteConnection::pack_table(std::uint32_t id) { - note_nav_frame(); - Frame req; req.opcode = Opcode::PackTable; - write_u32_le(id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::PackTableAck) { - return util::Error{5000, 0, "PackTable: server error", ""}; - } - return {}; -} - -util::Result RemoteConnection::zap_table(std::uint32_t id) { - note_nav_frame(); - Frame req; req.opcode = Opcode::ZapTable; - write_u32_le(id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::ZapTableAck) { - return util::Error{5000, 0, "ZapTable: server error", ""}; - } - return {}; -} - -util::Result RemoteConnection::flush_file_buffers(std::uint32_t id) { - note_nav_frame(); - Frame req; req.opcode = Opcode::FlushFileBuffers; - write_u32_le(id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::FlushFileBuffersAck) { - return util::Error{5000, 0, "FlushFileBuffers: server error", ""}; - } - return {}; -} - -util::Result RemoteConnection::close_all_indexes(std::uint32_t id) { - Frame req; req.opcode = Opcode::CloseAllIndexes; - write_u32_le(id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::CloseAllIndexesAck) { - return util::Error{5000, 0, "CloseAllIndexes: server error", ""}; - } - return {}; -} - -util::Result RemoteConnection::set_aof(std::uint32_t id, - const std::string& cond) { - note_nav_frame(); - Frame req; req.opcode = Opcode::SetAOF; - write_u32_le(id, req.payload); - req.payload.insert(req.payload.end(), cond.begin(), cond.end()); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::SetAOFAck) { - return util::Error{5000, 0, "SetAOF: server error", - cond.substr(0, 200)}; - } - return {}; -} - -util::Result RemoteConnection::clear_aof(std::uint32_t id) { - note_nav_frame(); - Frame req; req.opcode = Opcode::ClearAOFRemote; - write_u32_le(id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::ClearAOFRemoteAck) { - return util::Error{5000, 0, "ClearAOF: server error", ""}; - } - return {}; -} - -util::Result RemoteConnection::customize_aof( - std::uint32_t id, std::uint16_t option, - const std::vector& recnos) { - note_nav_frame(); - if (recnos.size() > 0xFFFFu) { - return util::Error{5000, 0, "CustomizeAOF: too many records", ""}; - } - Frame req; - req.opcode = Opcode::CustomizeAOF; - write_u32_le(id, req.payload); - req.payload.push_back(static_cast(option & 0xFFu)); - auto n = static_cast(recnos.size()); - write_u16_le(n, req.payload); - for (auto r : recnos) - write_u32_le(r, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::CustomizeAOFAck) { - return util::Error{5000, 0, "CustomizeAOF: server error", ""}; - } - return {}; -} - -util::Result -RemoteConnection::get_aof_opt_level(std::uint32_t id) { - Frame req; req.opcode = Opcode::GetAOFOptLevel; - write_u32_le(id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::GetAOFOptLevelAck || - rep.value().payload.size() < 2) { - return util::Error{5000, 0, - "GetAOFOptLevel: server error", ""}; - } - return read_u16_le(rep.value().payload.data()); -} - -// ===================================================================== -// M12.16 — remote index handle subsystem. -// ===================================================================== - -util::Result> -RemoteConnection::open_index(std::uint32_t table_id, - const std::string& path) { - Frame req; req.opcode = Opcode::OpenIndex; - write_u32_le(table_id, req.payload); - req.payload.insert(req.payload.end(), path.begin(), path.end()); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::OpenIndexAck || - rep.value().payload.size() < 2) { - return util::Error{5000, 0, "OpenIndex: server error", path}; - } - const auto& pl = rep.value().payload; - std::uint16_t n = read_u16_le(pl.data()); - if (pl.size() < 2u + 4u * n) { - return util::Error{5000, 0, - "OpenIndex: short payload", path}; - } - std::vector out; - out.reserve(n); - const size_t legacy_end = 2u + 4u * n; - if (pl.size() == legacy_end) { - for (std::uint16_t i = 0; i < n; ++i) { - OpenIndexEntry e; - e.id = read_u32_le(pl.data() + 2 + 4u * i); - out.push_back(std::move(e)); - } - return out; - } - size_t off = 2; - // bag_path is appended after all tag entries by the server so - // AdsGetIndexFilename / OrdBagName can serve without a wire - // round-trip. Parse it after the tag loop. - std::string bag_path; - for (std::uint16_t i = 0; i < n; ++i) { - if (off + 6 > pl.size()) { - return util::Error{5000, 0, - "OpenIndex: short tag payload", path}; - } - OpenIndexEntry e; - e.id = read_u32_le(pl.data() + off); - off += 4; - std::uint16_t tlen = read_u16_le(pl.data() + off); - off += 2; - if (off + tlen > pl.size()) { - return util::Error{5000, 0, - "OpenIndex: truncated tag name", path}; - } - if (tlen > 0) { - e.tag.assign(reinterpret_cast(pl.data() + off), - tlen); - while (!e.tag.empty() && e.tag.back() == ' ') e.tag.pop_back(); - } - off += tlen; - out.push_back(std::move(e)); - } - // Parse trailing bag path (u16-prefixed) if present. Older servers - // don't emit it, so the payload may end here — that's fine. - if (off + 2 <= pl.size()) { - std::uint16_t blen = read_u16_le(pl.data() + off); - off += 2; - if (blen > 0 && off + blen <= pl.size()) { - bag_path.assign(reinterpret_cast(pl.data() + off), - blen); - } - // Advance past the bag_path bytes. Previously missing: harmless - // while bag_path was the last field, but it left `off` pointing - // mid-string for anything parsed afterward — e.g. the per-tag - // expression/unique/descending block below. - off += blen; - } - // Propagate the bag path to every entry so the caller can store it - // on each RemoteIndex without special-casing. - if (!bag_path.empty()) { - for (auto& e : out) e.bag_path = bag_path; - } - // Optional trailing per-tag metadata (expression, unique, descending), - // one triple per entry in the same order as the tag loop above. Added - // after bag_path so older servers that don't emit it still parse fine — - // the loop below simply finds no bytes left and leaves the defaults. - for (std::uint16_t i = 0; i < n; ++i) { - if (off + 2 > pl.size()) break; - std::uint16_t elen = read_u16_le(pl.data() + off); - off += 2; - if (off + elen > pl.size()) break; - if (elen > 0) { - out[i].expression.assign( - reinterpret_cast(pl.data() + off), elen); - } - off += elen; - if (off + 2 > pl.size()) break; - out[i].is_unique = pl[off] != 0; - out[i].is_descending = pl[off + 1] != 0; - off += 2; - } - return out; -} - -util::Result RemoteConnection::close_index(std::uint32_t index_id) { - Frame req; req.opcode = Opcode::CloseIndex; - write_u32_le(index_id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::CloseIndexAck) { - return util::Error{5000, 0, "CloseIndex: server error", ""}; - } - return {}; -} - -// ===================================================================== -// M12.29 — AdsDD* Data Dictionary property API, phase 1. -// ===================================================================== - -util::Result RemoteConnection::dd_get_property( - DDObjectKind kind, const std::string& name, const std::string& subName, - std::uint16_t propId) { - Frame req; req.opcode = Opcode::DDGetProperty; - req.payload.push_back(static_cast(kind)); - write_lstr16(name, req.payload); - write_lstr16(subName, req.payload); - write_u16_le(propId, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::DDGetPropertyAck || - rep.value().payload.size() < 4) { - return util::Error{5000, 0, "DDGetProperty: server error", name}; - } - const auto& pl = rep.value().payload; - std::uint32_t len = read_u32_le(pl.data()); - if (pl.size() < 4u + len) { - return util::Error{5000, 0, "DDGetProperty: short payload", name}; - } - return std::string(reinterpret_cast(pl.data() + 4), len); -} - -util::Result RemoteConnection::dd_set_property( - DDObjectKind kind, const std::string& name, const std::string& subName, - std::uint16_t propId, const std::string& value) { - Frame req; req.opcode = Opcode::DDSetProperty; - req.payload.push_back(static_cast(kind)); - write_lstr16(name, req.payload); - write_lstr16(subName, req.payload); - write_u16_le(propId, req.payload); - write_u32_le(static_cast(value.size()), req.payload); - req.payload.insert(req.payload.end(), value.begin(), value.end()); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::DDSetPropertyAck) { - return util::Error{5000, 0, "DDSetProperty: server error", name}; - } - return {}; -} - -util::Result RemoteConnection::dd_create_proc( - const std::string& name, const std::string& container, - const std::string& procName, const std::string& inParams, - const std::string& outParams, const std::string& comments) { - Frame req; req.opcode = Opcode::DDCreateProc; - write_lstr16(name, req.payload); - write_lstr16(container, req.payload); - write_lstr16(procName, req.payload); - write_lstr16(inParams, req.payload); - write_lstr16(outParams, req.payload); - write_lstr16(comments, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::DDCreateProcAck) { - return util::Error{5000, 0, "DDCreateProc: server error", name}; - } - return {}; -} - -util::Result RemoteConnection::dd_create_function( - const std::string& name, const std::string& container, - const std::string& implementation, const std::string& retType, - const std::string& inParams, const std::string& comment) { - Frame req; req.opcode = Opcode::DDCreateFunction; - write_lstr16(name, req.payload); - write_lstr16(container, req.payload); - write_lstr16(implementation, req.payload); - write_lstr16(retType, req.payload); - write_lstr16(inParams, req.payload); - write_lstr16(comment, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::DDCreateFunctionAck) { - return util::Error{5000, 0, "DDCreateFunction: server error", name}; - } - return {}; -} - -util::Result RemoteConnection::dd_create_trigger( - const std::string& name, const std::string& table, std::uint32_t type, - const std::string& container, const std::string& procedure, - std::uint32_t priority) { - Frame req; req.opcode = Opcode::DDCreateTrigger; - write_lstr16(name, req.payload); - write_lstr16(table, req.payload); - write_u32_le(type, req.payload); - write_lstr16(container, req.payload); - write_lstr16(procedure, req.payload); - write_u32_le(priority, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::DDCreateTriggerAck) { - return util::Error{5000, 0, "DDCreateTrigger: server error", name}; - } - return {}; -} - -util::Result RemoteConnection::dd_drop_trigger(const std::string& name) { - Frame req; req.opcode = Opcode::DDDropTrigger; - write_lstr16(name, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::DDDropTriggerAck) { - return util::Error{5000, 0, "DDDropTrigger: server error", name}; - } - return {}; -} - -util::Result RemoteConnection::dd_drop_view(const std::string& name) { - Frame req; req.opcode = Opcode::DDDropView; - write_lstr16(name, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::DDDropViewAck) { - return util::Error{5000, 0, "DDDropView: server error", name}; - } - return {}; -} - -util::Result RemoteConnection::dd_drop_link(const std::string& name) { - Frame req; req.opcode = Opcode::DDDropLink; - write_lstr16(name, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::DDDropLinkAck) { - return util::Error{5000, 0, "DDDropLink: server error", name}; - } - return {}; -} - -// ===================================================================== -// M12.30 — AdsDD* Data Dictionary property API, phase 2. -// ===================================================================== - -util::Result RemoteConnection::dd_create_user( - const std::string& group, const std::string& user, - const std::string& pwd, const std::string& desc) { - Frame req; req.opcode = Opcode::DDCreateUser; - write_lstr16(group, req.payload); - write_lstr16(user, req.payload); - write_lstr16(pwd, req.payload); - write_lstr16(desc, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::DDCreateUserAck) { - return util::Error{5000, 0, "DDCreateUser: server error", user}; - } - return {}; -} - -util::Result RemoteConnection::dd_drop_object( - DDObjectKind kind, const std::string& name) { - Frame req; req.opcode = Opcode::DDDropObject; - req.payload.push_back(static_cast(kind)); - write_lstr16(name, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::DDDropObjectAck) { - return util::Error{5000, 0, "DDDropObject: server error", name}; - } - return {}; -} - -util::Result RemoteConnection::dd_add_user_to_group( - const std::string& group, const std::string& user) { - Frame req; req.opcode = Opcode::DDAddUserToGroup; - write_lstr16(group, req.payload); - write_lstr16(user, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::DDAddUserToGroupAck) { - return util::Error{5000, 0, "DDAddUserToGroup: server error", user}; - } - return {}; -} - -util::Result RemoteConnection::dd_remove_user_from_group( - const std::string& group, const std::string& user) { - Frame req; req.opcode = Opcode::DDRemoveUserFromGroup; - write_lstr16(group, req.payload); - write_lstr16(user, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::DDRemoveUserFromGroupAck) { - return util::Error{5000, 0, "DDRemoveUserFromGroup: server error", user}; - } - return {}; -} - -util::Result RemoteConnection::dd_create_link( - const std::string& alias, const std::string& path, - const std::string& user, const std::string& pwd) { - Frame req; req.opcode = Opcode::DDCreateLink; - write_lstr16(alias, req.payload); - write_lstr16(path, req.payload); - write_lstr16(user, req.payload); - write_lstr16(pwd, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::DDCreateLinkAck) { - return util::Error{5000, 0, "DDCreateLink: server error", alias}; - } - return {}; -} - -util::Result RemoteConnection::dd_modify_link( - const std::string& alias, const std::string& path, - const std::string& user, const std::string& pwd) { - Frame req; req.opcode = Opcode::DDModifyLink; - write_lstr16(alias, req.payload); - write_lstr16(path, req.payload); - write_lstr16(user, req.payload); - write_lstr16(pwd, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::DDModifyLinkAck) { - return util::Error{5000, 0, "DDModifyLink: server error", alias}; - } - return {}; -} - -util::Result RemoteConnection::dd_create_ref_integrity( - const std::string& name, const std::string& failTable, - const std::string& parent, const std::string& parentTag, - const std::string& child, const std::string& childTag, - std::uint16_t updateRule, std::uint16_t deleteRule) { - Frame req; req.opcode = Opcode::DDCreateRefIntegrity; - write_lstr16(name, req.payload); - write_lstr16(failTable, req.payload); - write_lstr16(parent, req.payload); - write_lstr16(parentTag, req.payload); - write_lstr16(child, req.payload); - write_lstr16(childTag, req.payload); - write_u16_le(updateRule, req.payload); - write_u16_le(deleteRule, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::DDCreateRefIntegrityAck) { - return util::Error{5000, 0, "DDCreateRefIntegrity: server error", name}; - } - return {}; -} - -util::Result RemoteConnection::dd_create_view( - const std::string& name, const std::string& comments, - const std::string& sql) { - Frame req; req.opcode = Opcode::DDCreateView; - write_lstr16(name, req.payload); - write_lstr16(comments, req.payload); - write_u32_le(static_cast(sql.size()), req.payload); - req.payload.insert(req.payload.end(), sql.begin(), sql.end()); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::DDCreateViewAck) { - return util::Error{5000, 0, "DDCreateView: server error", name}; - } - return {}; -} - -util::Result RemoteConnection::dd_add_index_file( - const std::string& table, const std::string& index, - const std::string& comment) { - Frame req; req.opcode = Opcode::DDAddIndexFile; - write_lstr16(table, req.payload); - write_lstr16(index, req.payload); - write_lstr16(comment, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::DDAddIndexFileAck) { - return util::Error{5000, 0, "DDAddIndexFile: server error", table}; - } - return {}; -} - -util::Result RemoteConnection::dd_remove_index_file( - const std::string& table, const std::string& index) { - Frame req; req.opcode = Opcode::DDRemoveIndexFile; - write_lstr16(table, req.payload); - write_lstr16(index, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::DDRemoveIndexFileAck) { - return util::Error{5000, 0, "DDRemoveIndexFile: server error", table}; - } - return {}; -} - -util::Result RemoteConnection::dd_get_permissions( - const std::string& grantee, std::uint16_t objType, - const std::string& objName, bool getInherited) { - Frame req; req.opcode = Opcode::DDGetPermissions; - write_lstr16(grantee, req.payload); - write_u16_le(objType, req.payload); - write_lstr16(objName, req.payload); - req.payload.push_back(getInherited ? 1 : 0); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::DDGetPermissionsAck || - rep.value().payload.size() < 4) { - return util::Error{5000, 0, "DDGetPermissions: server error", objName}; - } - return read_u32_le(rep.value().payload.data()); -} - -util::Result RemoteConnection::dd_grant_permission( - std::uint16_t objType, const std::string& objName, - const std::string& grantee, std::uint32_t permissions) { - Frame req; req.opcode = Opcode::DDGrantPermission; - write_u16_le(objType, req.payload); - write_lstr16(objName, req.payload); - write_lstr16(grantee, req.payload); - write_u32_le(permissions, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::DDGrantPermissionAck) { - return util::Error{5000, 0, "DDGrantPermission: server error", objName}; - } - return {}; -} - -util::Result -RemoteConnection::set_order(std::uint32_t table_id, - std::uint32_t index_id) { - note_nav_frame(); - Frame req; req.opcode = Opcode::SetOrder; - write_u32_le(table_id, req.payload); - write_u32_le(index_id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::SetOrderAck) { - std::int32_t code = 5000; - if (rep.value().payload.size() >= 4) - code = static_cast( - read_u32_le(rep.value().payload.data())); - return util::Error{code, 0, "SetOrder: server error", ""}; - } - // Certified key count travels after the (empty) ack body, - // length-gated; old servers send nothing. - SetOrderOutcome o; - if (rep.value().payload.size() >= 4) { - o.counted = true; - o.key_count = read_u32_le(rep.value().payload.data()); - } - return o; -} - -util::Result -RemoteConnection::set_order_by_name(std::uint32_t table_id, - const std::string& tag) { - note_nav_frame(); - Frame req; req.opcode = Opcode::SetOrderByName; - write_u32_le(table_id, req.payload); - req.payload.insert(req.payload.end(), tag.begin(), tag.end()); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::SetOrderByNameAck) { - std::int32_t code = 5000; - if (rep.value().payload.size() >= 4) - code = static_cast( - read_u32_le(rep.value().payload.data())); - return util::Error{code, 0, "SetOrderByName: server error", tag}; - } - SetOrderOutcome o; - if (rep.value().payload.size() >= 4) { - o.counted = true; - o.key_count = read_u32_le(rep.value().payload.data()); - } - return o; -} - -namespace { - -// Push a u16-prefixed length + bytes string into a payload buffer. -inline void push_lp_str(std::vector& buf, - const std::string& s) { - auto n = static_cast(s.size()); - buf.push_back(static_cast( n & 0xFFu)); - buf.push_back(static_cast((n >> 8) & 0xFFu)); - buf.insert(buf.end(), s.begin(), s.end()); -} - -} // namespace - -util::Result -RemoteConnection::create_index(std::uint32_t table_id, - const std::string& path, - const std::string& tag, - const std::string& expr, - const std::string& cond, - const std::string& key_filter, - std::uint32_t options, - std::uint16_t page_size) { - Frame req; - req.opcode = Opcode::CreateIndex; - write_u32_le(table_id, req.payload); - write_u32_le(options, req.payload); - write_u16_le(page_size, req.payload); - push_lp_str(req.payload, path); - push_lp_str(req.payload, tag); - push_lp_str(req.payload, expr); - push_lp_str(req.payload, cond); - push_lp_str(req.payload, key_filter); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::CreateIndexAck || - rep.value().payload.size() < 4) { - return util::Error{5000, 0, "CreateIndex: server error", - tag.empty() ? path : tag}; - } - return read_u32_le(rep.value().payload.data()); -} - -util::Result -RemoteConnection::create_table(const std::string& name, - const std::string& fields, - std::uint16_t table_type, - std::uint16_t char_type, - std::uint16_t memo_block_size) { - Frame req; - req.opcode = Opcode::CreateTable; - write_u16_le(table_type, req.payload); - write_u16_le(char_type, req.payload); - write_u16_le(memo_block_size, req.payload); - push_lp_str(req.payload, name); - push_lp_str(req.payload, fields); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::CreateTableAck) { - return util::Error{5000, 0, "CreateTable: server error", - std::string(rep.value().payload.begin(), - rep.value().payload.end())}; - } - return {}; -} - -util::Result -RemoteConnection::drop_table(const std::string& name, - std::uint16_t delete_files) { - Frame req; - req.opcode = Opcode::DropTable; - push_lp_str(req.payload, name); - write_u16_le(delete_files, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::DropTableAck) { - return util::Error{5000, 0, "DropTable: server error", - std::string(rep.value().payload.begin(), - rep.value().payload.end())}; - } - return {}; -} - -namespace { - -util::Error fs_wire_err(const Frame& rep, const char* op) { - std::string msg(rep.payload.begin(), rep.payload.end()); - std::int32_t code = 5000; - if (rep.payload.size() >= 4) { - code = static_cast(read_u32_le(rep.payload.data())); - } - return util::Error{code, 0, std::string(op) + ": server error", msg}; -} - -std::uint64_t read_u64_le(const std::uint8_t* p) { - std::uint64_t v = 0; - for (int i = 0; i < 8; ++i) - v |= static_cast(p[i]) << (8 * i); - return v; -} - -// Existence-cache key: the server separator-normalizes client paths -// (fs_sandbox), so "/" and "\\" spellings of one file are the same -// file. Case is NOT folded: the server fs may be case-sensitive (Linux -// hosts), where "A.DBF" and "a.dbf" are legitimately different files. -std::string fs_cache_key(const std::string& path) { - std::string k = path; - for (auto& ch : k) { - if (ch == '/') ch = '\\'; - } - return k; -} - -} // namespace - -util::Result -RemoteConnection::file_exists(const std::string& path, int* src) { - if (src != nullptr) *src = 2; - const std::string key = fs_cache_key(path); - { - std::lock_guard lk(file_exists_mu_); - if (file_exists_cache_.count(key) != 0) { - if (src != nullptr) *src = 0; - return true; - } - if (file_exists_neg_cache_.count(key) != 0) { - if (src != nullptr) *src = 1; - return false; - } - } - Frame req; - req.opcode = Opcode::FileExists; - push_lp_str(req.payload, path); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::FileExistsAck || - rep.value().payload.empty()) - return fs_wire_err(rep.value(), "FileExists"); - bool exists = rep.value().payload[0] != 0; - { - std::lock_guard lk(file_exists_mu_); - if (exists) { - file_exists_cache_.insert(key); - file_exists_neg_cache_.erase(key); - } else { - file_exists_neg_cache_.insert(key); - } - } - return exists; -} - -void RemoteConnection::file_exists_invalidate() { - std::lock_guard lk(file_exists_mu_); - file_exists_cache_.clear(); - file_exists_neg_cache_.clear(); -} - -util::Result -RemoteConnection::file_erase(const std::string& path) { - Frame req; - req.opcode = Opcode::FileErase; - push_lp_str(req.payload, path); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::FileEraseAck) - return fs_wire_err(rep.value(), "FileErase"); - file_exists_invalidate(); - return {}; -} - -util::Result -RemoteConnection::file_rename(const std::string& old_p, - const std::string& new_p) { - Frame req; - req.opcode = Opcode::FileRename; - push_lp_str(req.payload, old_p); - push_lp_str(req.payload, new_p); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::FileRenameAck) - return fs_wire_err(rep.value(), "FileRename"); - file_exists_invalidate(); - return {}; -} - -util::Result -RemoteConnection::file_size(const std::string& path) { - Frame req; - req.opcode = Opcode::FileSize; - push_lp_str(req.payload, path); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::FileSizeAck || - rep.value().payload.size() < 8) - return fs_wire_err(rep.value(), "FileSize"); - return read_u64_le(rep.value().payload.data()); -} - -util::Result -RemoteConnection::file_mtime(const std::string& path) { - Frame req; - req.opcode = Opcode::FileMTime; - push_lp_str(req.payload, path); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::FileMTimeAck || - rep.value().payload.size() < 7) - return fs_wire_err(rep.value(), "FileMTime"); - openads::engine::DirEntry e; - const auto& pl = rep.value().payload; - e.year = static_cast(pl[0] | (pl[1] << 8)); - e.mon = pl[2]; e.day = pl[3]; - e.hh = pl[4]; e.mm = pl[5]; e.ss = pl[6]; - return e; -} - -util::Result> -RemoteConnection::directory(const std::string& mask) { - Frame req; - req.opcode = Opcode::Directory; - push_lp_str(req.payload, mask); - write_u16_le(0, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::DirectoryAck || - rep.value().payload.size() < 4) - return fs_wire_err(rep.value(), "Directory"); - std::size_t off = 0; - std::uint32_t n = read_u32_le(rep.value().payload.data()); - off = 4; - // Guard against corrupt/huge counts (avoids std::length_error). - if (n > 100000u) - return util::Error{5000, 0, "Directory: count too large", mask}; - std::vector out; - out.reserve(n); - for (std::uint32_t i = 0; i < n; ++i) { - openads::engine::DirEntry e; - if (!openads::engine::unpack_dir_entry(rep.value().payload, off, e)) - return util::Error{5000, 0, "Directory: bad entry", mask}; - out.push_back(std::move(e)); - } - return out; -} - -util::Result> -RemoteConnection::find_tables(const std::string& mask) { - Frame req; - req.opcode = Opcode::FindTables; - push_lp_str(req.payload, mask); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::FindTablesAck || - rep.value().payload.size() < 4) - return fs_wire_err(rep.value(), "FindTables"); - std::size_t off = 0; - std::uint32_t n = read_u32_le(rep.value().payload.data()); - off = 4; - if (n > 100000u) - return util::Error{5000, 0, "FindTables: count too large", mask}; - std::vector out; - out.reserve(n); - for (std::uint32_t i = 0; i < n; ++i) { - std::string name; - if (!read_lstr16(rep.value().payload, off, name)) - return util::Error{5000, 0, "FindTables: bad entry", mask}; - out.push_back(std::move(name)); - } - return out; -} - -namespace { - -// u64 LE + u32-length blob + 0x1F-joined lists for the archive ops. -inline void push_lstr32(const std::string& s, - std::vector& out) { - write_u32_le(static_cast(s.size()), out); - out.insert(out.end(), s.begin(), s.end()); -} - -inline std::string join_0x1f(const std::vector& v) { - std::string o; - for (std::size_t i = 0; i < v.size(); ++i) { - if (i != 0) o.push_back('\x1F'); - o += v[i]; - } - return o; -} - -inline std::uint64_t read_u64_at(const std::vector& pl, - std::size_t off) { - std::uint64_t v = 0; - for (int i = 0; i < 8; ++i) - v |= static_cast( - pl[off + static_cast(i)]) - << (8 * i); - return v; -} - -} // namespace - -util::Result -RemoteConnection::zip_archive(const std::string& dir, - const std::vector& files, - const std::string& zip_name, - std::uint16_t level, bool overwrite, - const std::string& password, - const std::vector& exclude, - bool with_path) { - Frame req; - req.opcode = Opcode::ZipArchive; - push_lp_str(req.payload, dir); - push_lstr32(join_0x1f(files), req.payload); - push_lp_str(req.payload, zip_name); - write_u16_le(level, req.payload); - req.payload.push_back(overwrite ? 1 : 0); - req.payload.push_back(with_path ? 1 : 0); - push_lstr32(join_0x1f(exclude), req.payload); - push_lp_str(req.payload, password); - auto rep = request(req); - if (!rep) return rep.error(); - // [u32 files][u64 bytes][u64 archiveBytes][u16 arcLen][archiveRel] - if (rep.value().opcode != Opcode::ZipArchiveAck || - rep.value().payload.size() < 22) - return fs_wire_err(rep.value(), "ZipArchive"); - const auto& pl = rep.value().payload; - ZipArchiveOutcome o; - o.files = read_u32_le(pl.data()); - o.bytes = read_u64_at(pl, 4); - o.archive_bytes = read_u64_at(pl, 12); - std::size_t off = 20; - if (!read_lstr16(pl, off, o.archive)) - return fs_wire_err(rep.value(), "ZipArchive"); - return o; -} - -util::Result -RemoteConnection::unzip_archive(const std::string& dir, - const std::string& zip, - const std::string& password, - bool overwrite, bool with_path) { - Frame req; - req.opcode = Opcode::UnzipArchive; - push_lp_str(req.payload, dir); - push_lp_str(req.payload, zip); - push_lp_str(req.payload, password); - req.payload.push_back(overwrite ? 1 : 0); - req.payload.push_back(with_path ? 1 : 0); - auto rep = request(req); - if (!rep) return rep.error(); - // [u32 files][u64 bytes][u64 archiveBytes] - if (rep.value().opcode != Opcode::UnzipArchiveAck || - rep.value().payload.size() < 20) - return fs_wire_err(rep.value(), "UnzipArchive"); - const auto& pl = rep.value().payload; - UnzipArchiveOutcome o; - o.files = read_u32_le(pl.data()); - o.bytes = read_u64_at(pl, 4); - o.archive_bytes = read_u64_at(pl, 12); - return o; -} - -util::Result -RemoteConnection::zip_list(const std::string& zip) { - Frame req; - req.opcode = Opcode::ZipList; - push_lp_str(req.payload, zip); - auto rep = request(req); - if (!rep) return rep.error(); - // [u32 count][packed ZipEntry records] - if (rep.value().opcode != Opcode::ZipListAck || - rep.value().payload.size() < 4) - return fs_wire_err(rep.value(), "ZipList"); - const auto& pl = rep.value().payload; - const std::uint32_t count = read_u32_le(pl.data()); - if (count > (1u << 20)) - return fs_wire_err(rep.value(), "ZipList"); - ZipListOutcome o; - o.entries.reserve(count); - std::size_t off = 4; - for (std::uint32_t i = 0; i < count; ++i) { - openads::engine::zip_arch::ZipEntry e; - if (!openads::engine::zip_arch::unpack_zip_entry(pl, off, e)) - return fs_wire_err(rep.value(), "ZipList"); - o.entries.push_back(std::move(e)); - } - if (off != pl.size()) - return fs_wire_err(rep.value(), "ZipList"); - return o; -} - -util::Result -RemoteConnection::dir_exist(const std::string& path) { - const std::string key = fs_cache_key(path); - { - std::lock_guard lk(dir_cache_mu_); - if (dir_known_.count(key) != 0) return true; - if (dir_missing_.count(key) != 0) return false; - } - Frame req; - req.opcode = Opcode::DirExist; - push_lp_str(req.payload, path); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::DirExistAck || - rep.value().payload.empty()) - return fs_wire_err(rep.value(), "DirExist"); - const bool exists = rep.value().payload[0] != 0; - { - std::lock_guard lk(dir_cache_mu_); - if (exists) { - dir_known_.insert(key); - dir_missing_.erase(key); - } else { - dir_missing_.insert(key); - } - } - return exists; -} - -util::Result -RemoteConnection::dir_make(const std::string& path) { - const std::string key = fs_cache_key(path); - { - std::lock_guard lk(dir_cache_mu_); - if (dir_known_.count(key) != 0) return {}; - } - Frame req; - req.opcode = Opcode::DirMake; - push_lp_str(req.payload, path); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::DirMakeAck) - return fs_wire_err(rep.value(), "DirMake"); - std::lock_guard lk(dir_cache_mu_); - dir_known_.insert(key); - dir_missing_.erase(key); - return {}; -} - -util::Result -RemoteConnection::dir_remove(const std::string& path) { - Frame req; - req.opcode = Opcode::DirRemove; - push_lp_str(req.payload, path); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::DirRemoveAck) - return fs_wire_err(rep.value(), "DirRemove"); - std::lock_guard lk(dir_cache_mu_); - const std::string rkey = fs_cache_key(path); - dir_known_.erase(rkey); - dir_missing_.insert(rkey); - return {}; -} - -util::Result -RemoteConnection::fopen(const std::string& path, std::uint16_t mode) { - Frame req; - req.opcode = Opcode::FOpen; - push_lp_str(req.payload, path); - write_u16_le(mode, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::FOpenAck || - rep.value().payload.size() < 4) - return fs_wire_err(rep.value(), "FOpen"); - return read_u32_le(rep.value().payload.data()); -} - -util::Result -RemoteConnection::fcreate(const std::string& path, std::uint16_t attr) { - Frame req; - req.opcode = Opcode::FCreate; - push_lp_str(req.payload, path); - write_u16_le(attr, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::FCreateAck || - rep.value().payload.size() < 4) - return fs_wire_err(rep.value(), "FCreate"); - return read_u32_le(rep.value().payload.data()); -} - -util::Result -RemoteConnection::fclose(std::uint32_t file_id) { - Frame req; - req.opcode = Opcode::FClose; - write_u32_le(file_id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::FCloseAck) - return fs_wire_err(rep.value(), "FClose"); - return {}; -} - -util::Result> -RemoteConnection::fread(std::uint32_t file_id, std::uint32_t nbytes) { - Frame req; - req.opcode = Opcode::FRead; - write_u32_le(file_id, req.payload); - write_u32_le(nbytes, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::FReadAck || - rep.value().payload.size() < 4) - return fs_wire_err(rep.value(), "FRead"); - std::uint32_t n = read_u32_le(rep.value().payload.data()); - if (4u + n > rep.value().payload.size()) - return util::Error{5000, 0, "FRead: short data", ""}; - return std::vector( - rep.value().payload.begin() + 4, - rep.value().payload.begin() + 4 + static_cast(n)); -} - -util::Result -RemoteConnection::fwrite(std::uint32_t file_id, const std::uint8_t* data, - std::uint32_t n) { - Frame req; - req.opcode = Opcode::FWrite; - write_u32_le(file_id, req.payload); - write_u32_le(n, req.payload); - if (data && n) - req.payload.insert(req.payload.end(), data, data + n); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::FWriteAck || - rep.value().payload.size() < 4) - return fs_wire_err(rep.value(), "FWrite"); - return read_u32_le(rep.value().payload.data()); -} - -util::Result -RemoteConnection::fseek(std::uint32_t file_id, std::int32_t offset, - std::uint8_t origin) { - Frame req; - req.opcode = Opcode::FSeek; - write_u32_le(file_id, req.payload); - write_u32_le(static_cast(offset), req.payload); - req.payload.push_back(origin); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::FSeekAck || - rep.value().payload.size() < 4) - return fs_wire_err(rep.value(), "FSeek"); - return read_u32_le(rep.value().payload.data()); -} - -util::Result -RemoteConnection::skip_unique(std::uint32_t index_id, - std::int32_t direction) { - note_nav_frame(); - note_all_tables_nav(); // index-keyed, no parent resolved here - Frame req; req.opcode = Opcode::SkipUnique; - write_u32_le(index_id, req.payload); - write_u32_le(static_cast(direction), req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::SkipUniqueAck) { - return util::Error{5000, 0, "SkipUnique: server error", ""}; - } - return {}; -} - -util::Result -RemoteConnection::set_scope(std::uint32_t index_id, - std::uint16_t which, - const std::string& key, - std::uint16_t data_type) { - note_nav_frame(); - note_all_tables_nav(); // index-keyed visibility change - // Payload: u32 index_id | u16 which | u16 data_type | bytes key. - // Key length is the trailing byte count (payload.size() - 8). - Frame req; req.opcode = Opcode::SetScope; - write_u32_le(index_id, req.payload); - write_u16_le(which, req.payload); - write_u16_le(data_type, req.payload); - req.payload.insert(req.payload.end(), key.begin(), key.end()); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::SetScopeAck) { - return util::Error{5000, 0, "SetScope: server error", key}; - } - return {}; -} - -util::Result -RemoteConnection::fetch_current_row(std::uint32_t table_id) { - // Compatibility wrapper for callers that don't carry a - // RemoteTable: fetch + extract just the visible-row vector. The - // M12.18 wire format is parsed via the same shared helper as - // the rt-aware overload below. - Frame req; req.opcode = Opcode::FetchCurrentRow; - write_u32_le(table_id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::FetchCurrentRowAck || - rep.value().payload.empty()) { - return util::Error{5000, 0, - "FetchCurrentRow: server error", ""}; - } - RemoteTable scratch; - scratch.conn = this; - scratch.id = table_id; - parse_row_trailer_into(&scratch, rep.value().payload, 0); - RowSnapshot snap; - snap.has_row = scratch.row_valid; - snap.fields = std::move(scratch.current_row); - return snap; -} - -util::Result -RemoteConnection::fetch_current_row(RemoteTable* rt) { - Frame req; req.opcode = Opcode::FetchCurrentRow; - write_u32_le(rt->id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::FetchCurrentRowAck || - rep.value().payload.empty()) { - return util::Error{5000, 0, - "FetchCurrentRow: server error", ""}; - } - parse_row_trailer_into(rt, rep.value().payload, 0); - return {}; -} - -void RemoteConnection::show_deleted(bool visible) noexcept { - note_nav_frame(); - note_all_tables_nav(); // conn-wide visibility change - if (!transport_ || !transport_->valid()) return; - Frame req; - req.opcode = Opcode::ShowDeleted; - req.payload.push_back(visible ? 1 : 0); - // Best-effort: pre-M12.31 servers lack this opcode; local SET - // DELETED must still succeed on the client either way. - (void)request(req); -} - -util::Result -RemoteConnection::clear_scope(std::uint32_t index_id, - std::uint16_t which) { - note_nav_frame(); - note_all_tables_nav(); // index-keyed visibility change - Frame req; req.opcode = Opcode::ClearScope; - write_u32_le(index_id, req.payload); - write_u16_le(which, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::ClearScopeAck) { - return util::Error{5000, 0, "ClearScope: server error", ""}; - } - return {}; -} - -util::Result -RemoteConnection::seek(std::uint32_t index_id, - const std::string& key, - std::uint8_t soft, - std::uint8_t last, - RemoteTable* parent) { - note_nav_frame(); - // Index-keyed op: request() cannot map it to a table, so the - // binding the caller resolved bumps the per-table generation here. - if (parent != nullptr) note_tbl_nav(parent->id); - Frame req; - req.opcode = last ? Opcode::SeekLast : Opcode::Seek; - write_u32_le(index_id, req.payload); - req.payload.push_back(soft); - req.payload.insert(req.payload.end(), key.begin(), key.end()); - auto rep = request(req); - if (!rep) return rep.error(); - Opcode want = last ? Opcode::SeekLastAck : Opcode::SeekAck; - if (rep.value().opcode != want || - rep.value().payload.size() < 5) { - return util::Error{5000, 0, "Seek: server error", key}; - } - SeekOutcome o; - o.hit = rep.value().payload[0]; - o.recno = read_u32_le(rep.value().payload.data() + 1); - // RCB 07/14/2026: M12.24 — a current-M12.24 server appends the row it - // landed on after the [u8 found][u32 recno] pair, so the caller does not - // have to spend a second round-trip fetching it. Strictly optional: an - // older server sends exactly 5 bytes, we parse no trailer, row_valid stays - // false, and the old FetchCurrentRow fallback takes over unchanged. - if (parent != nullptr && rep.value().payload.size() > 5) { - const std::size_t tend = parse_row_trailer_into( - parent, rep.value().payload, 5); - // Reposition-bound piggyback (see goto_record): trailing - // [u8 bof][u8 eof][u32 recno](+[u32 reccount]), length-gated. - apply_bound_tail(parent, rep.value().payload, tend); - } - return o; -} - -// M12.35 — query the server for the key expression result type of a -// remote index. Returns ADS_STRING (4), ADS_DATE (3), ADS_NUMERIC (2), -// or ADS_LOGICAL (1) — the same values as the local AdsGetKeyType. - -util::Result -RemoteConnection::get_key_type(std::uint32_t index_id) { - Frame req; - req.opcode = Opcode::GetKeyType; - write_u32_le(index_id, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::GetKeyTypeAck || - rep.value().payload.size() < 2) { - return util::Error{5000, 0, "GetKeyType: server error", ""}; - } - return read_u16_le(rep.value().payload.data()); -} - -// M12.32 — distributed mutex service. - -util::Result RemoteConnection::mutex_create(const std::string& name) { - Frame req; - req.opcode = Opcode::Mutex; - req.payload.push_back(static_cast(MutexOp::Create)); - write_lstr16(name, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::Mutex || - rep.value().payload.size() < 2 || - rep.value().payload[0] != static_cast(MutexOp::Create)) - return util::Error{5000, 0, "MutexCreate: server error", ""}; - if (!rep.value().payload[1]) - return util::Error{5000, 0, "MutexCreate: failed (exists?)", name}; - return util::Result{}; -} - -util::Result RemoteConnection::mutex_lock(const std::string& name, - std::uint32_t timeout_ms) { - Frame req; - req.opcode = Opcode::Mutex; - req.payload.push_back(static_cast(MutexOp::Lock)); - write_lstr16(name, req.payload); - write_u32_le(timeout_ms, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::Mutex || - rep.value().payload.size() < 2 || - rep.value().payload[0] != static_cast(MutexOp::Lock)) - return util::Error{5000, 0, "MutexLock: server error", ""}; - if (!rep.value().payload[1]) - return util::Error{5000, 0, "MutexLock: timeout or not found", name}; - return util::Result{}; -} - -util::Result RemoteConnection::mutex_try_lock(const std::string& name) { - Frame req; - req.opcode = Opcode::Mutex; - req.payload.push_back(static_cast(MutexOp::TryLock)); - write_lstr16(name, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::Mutex || - rep.value().payload.size() < 2 || - rep.value().payload[0] != static_cast(MutexOp::TryLock)) - return util::Error{5000, 0, "MutexTryLock: server error", ""}; - return rep.value().payload[1] != 0; -} - -util::Result RemoteConnection::mutex_unlock(const std::string& name) { - Frame req; - req.opcode = Opcode::Mutex; - req.payload.push_back(static_cast(MutexOp::Unlock)); - write_lstr16(name, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::Mutex || - rep.value().payload.size() < 2 || - rep.value().payload[0] != static_cast(MutexOp::Unlock)) - return util::Error{5000, 0, "MutexUnlock: server error", ""}; - if (!rep.value().payload[1]) - return util::Error{5000, 0, "MutexUnlock: not owner or not found", name}; - return util::Result{}; -} - -util::Result RemoteConnection::mutex_destroy(const std::string& name) { - Frame req; - req.opcode = Opcode::Mutex; - req.payload.push_back(static_cast(MutexOp::Destroy)); - write_lstr16(name, req.payload); - auto rep = request(req); - if (!rep) return rep.error(); - if (rep.value().opcode != Opcode::Mutex || - rep.value().payload.size() < 2 || - rep.value().payload[0] != static_cast(MutexOp::Destroy)) - return util::Error{5000, 0, "MutexDestroy: server error", ""}; - if (!rep.value().payload[1]) - return util::Error{5000, 0, "MutexDestroy: not owner or not found", name}; - return util::Result{}; -} - -} // namespace openads::network From bfa452563d67c40cec068fa01ff35cbd9d598f37 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Sat, 26 Sep 2026 01:29:18 -0500 Subject: [PATCH 51/97] Delete src/network/4-session.h --- src/network/4-session.h | 277 ---------------------------------------- 1 file changed, 277 deletions(-) delete mode 100644 src/network/4-session.h diff --git a/src/network/4-session.h b/src/network/4-session.h deleted file mode 100644 index 61271fc4..00000000 --- a/src/network/4-session.h +++ /dev/null @@ -1,277 +0,0 @@ -#pragma once - -#include "engine/server_fs.h" -#include "network/frame_reader.h" -#include "network/server.h" -#include "network/socket.h" -#include "network/wire.h" -#include "openads/ace.h" -#include "session/connection.h" - -#include -#include -#include -#include -#include -#include -#include -#include - -namespace openads::engine { class Table; } - -namespace openads::network { - -// Result of dispatching one wire frame. `reply == std::nullopt` means -// "send nothing back" (used by Disconnect); `close_session` asks the -// driving loop to stop reading and tear the connection down. -struct DispatchResult { - std::optional reply; // std::nullopt => send nothing - bool close_session = false; -}; - -// SLICE 3a — all per-connection state and the opcode dispatch switch -// extracted verbatim out of Server::session_loop. The existing -// thread-per-connection loop constructs a Session per accepted socket -// and drives it through dispatch(). Zero behavior change. -class Session { -public: - Session(Server& srv, Socket s, std::string default_data_dir, - std::uint16_t listener_port); // computes peer addr, register_session, install_session_socket - ~Session(); // cleanup(); srv_->erase_session_socket(sid_); srv_->unregister_session(sid_); - Session(const Session&) = delete; - Session& operator=(const Session&) = delete; - - DispatchResult dispatch(const Frame& f); - std::uint64_t id() const noexcept { return sid_; } - - // Session serial for mutex owner identification (M12.32). - std::string conn_serial() const { return std::to_string(sid_); } - - // Read one frame off this connection, dispatch it, write any reply, - // and fold in the per-frame telemetry. Returns false when the - // connection should be torn down (peer closed, write failed, or the - // opcode asked to close). Drives both the legacy thread-per-connection - // loop and the reactor WorkerPool, so the per-frame contract lives in - // exactly one place. - bool handle_readable(); - - // Accessor for the reactor: the connection socket this Session owns. - Socket socket() const noexcept { return s_; } - -private: - // Telemetry + dispatch + reply for one complete frame. Shared by the - // blocking thread-per-connection loop and the non-blocking reactor path. - // Returns false when the connection should be torn down. - bool process_frame(const Frame& f); - - Server* srv_; - Socket s_; - std::uint64_t sid_; - // Cached "ip:port" of the remote peer for the text error log - // (resolved once at accept; getpeername per frame would be a - // syscall on every request). - std::string peer_str_; - // Reassembles complete frames from partial non-blocking reads (reactor - // path). Harmless on the blocking path — each read yields a whole frame. - FrameReader reader_; - // Default data directory for this connection, determined by which TCP - // port the client connected to. Empty means use the server's global - // data_dir_ (primary listener). - std::string default_data_dir_; - - // M12.4 — per-session state. Connection is opened by the - // Connect frame; OpenTable allocates a session-scoped 32-bit - // table id keyed into engine handles. - std::unique_ptr sess_conn_; - std::unordered_map tbls_; - // Original OpenTable payload (DD alias or relative path). ensure_abi_handle - // must reopen the same physical file — basename-only breaks subdir tables. - std::unordered_map tbl_open_paths_; - // mtfix11 - Server::try_register_open bookkeeping per wire table id: - // (engine-resolved canonical path, exclusive flag) as registered at - // OpenTable; consumed at CloseTable / teardown for unregister_open. - std::unordered_map> - tbl_open_reg_; - std::unordered_map cursor_tbls_; - // M12.16 — lazy-promoted ABI handle parallel to tbls_. - std::unordered_map tbls_h_; - // M12.16 — index_h_[index_id] holds the ABI hIndex. - std::unordered_map index_h_; - // M12.16 — reverse map index_id -> table_id. - std::unordered_map index_table_; - // Wire index_id -> tag name. Lets the SetOrder handler re-resolve a - // stale id: server-side AdsCreateIndex61's silent overwrite and - // AdsCloseAllIndexes' non-structural purge erase ABI bindings and mint - // fresh handles, but the session's index_h_ keeps the dead one -- the - // next ordered nav then fails with 5000 "index not bound to table" - // (B_BIG storm 700). With the tag cached, the handler looks up the - // binding's CURRENT handle (AdsGetIndexHandle) and retries. - std::unordered_map index_tag_; - // Table ids with an active controlling order. Index ops set the order - // on the ABI handle (tbls_h_), not the engine table, so when one is - // active GotoTop / GotoBottom / Skip must navigate the ABI handle and - // sync the engine cursor — otherwise they walk natural order and the - // remote browse shows no index. - std::unordered_set ordered_tables_; - ADSHANDLE abi_conn_ = 0; - ADSHANDLE abi_stmt_ = 0; - std::uint32_t next_id_ = 1; - // RCB 06/30/2026: Remote sessions keep DD credentials so lazy server-side - // ABI handles used by SQL/index operations authenticate the same user. - std::string session_user_; - std::string session_password_; - // M12.21 option C — set from the Connect payload's capability word. - bool client_prefetch_ok_ = false; - // RCB 07/15/2026: M12.25 — client can also drain a BACKWARD lookahead block - // (kCapPrefetchBackward). Gated separately: a forward-only client that got a - // backward block would pop its rows the wrong way. See the Skip handler. - bool client_prefetch_back_ok_ = false; - // M12.x — client sends [u16 mode] prefix on OpenTable payloads. - bool client_open_table_mode_ok_ = false; - // M12.32 — last ShowDeleted state received; abi_conn_ is created - // lazily, so ensure_abi_conn must re-apply it or the ABI connection - // starts with the default (show) and ordered walks leak deleted rows. - bool show_deleted_ = true; - - // Server filesystem (oads_*) — open files for this session only. - std::unordered_map> files_; - std::uint32_t next_file_id_ = 1; - - // Resolve client path under session data dir (or server data roots). - std::optional resolve_fs_client_path( - const std::string& client_path) const; - - // Resolve a remote CreateIndex bag path server-side (see session.cpp). - std::string resolve_index_bag_path(const std::string& bag) const; - - // ---- M12.22 read-ahead ramp ------------------------------------------- - // RCB 07/14/2026: why sequential-access detection lives on the SERVER and - // not the client. First, it is where OS and DB read-ahead normally put it - // (Linux readahead, SQL Server read-ahead): the provider watches the access - // pattern instead of making the caller declare it, because callers are bad - // at declaring it. Second — and this is the part specific to us — it is the - // only place that CAN see it. The client serves most of a block locally - // without telling anyone, so the server sees roughly one Skip per block, - // and every such Skip is direct evidence that the client drained the last - // one. The signal is free and it is exactly the signal we want. - // - // Depth doubles per consecutive forward Skip on a table, floor -> ceiling. - // Any reposition or write on that table (see breaks_prefetch_run() in - // session.cpp) drops the entry, so the next run restarts at the floor. - // - // The ramp is not decoration. Before it, EVERY forward Skip dragged a flat - // 64 rows — including the lone Skip after a Seek, i.e. "find one customer - // and read him", which is a very common shape and paid for 63 rows it would - // never look at. - static constexpr std::uint16_t kPrefetchFloor = 8; - static constexpr std::uint16_t kPrefetchCeil = 64; - // RCB 07/14/2026: a row count alone is NOT a bound — 64 rows of a table - // with 4 KB records is a 256 KB frame, and rows are packed with every field - // (no projection on the nav path). So bound the block by bytes as well and - // let whichever limit hits first win. SAP states the same two-sided rule for - // its own client cache: "the lesser of 10 records or the number of records - // that can fit in a burst of packets ... about 22K when using IP" - // (ace_adscacherecords.htm). - static constexpr std::size_t kPrefetchMaxBytes = 32u * 1024u; - std::unordered_map prefetch_depth_; - // RCB 07/15/2026: M12.25 — the direction (+1/-1) the current ramp run for a - // table is going. A reversal restarts the ramp at the floor (see - // next_lookahead), so a PgUp right after a long PgDn doesn't inherit the - // forward run's ceiling depth. - std::unordered_map prefetch_run_dir_; - - // Depth to use for one Skip on `id` in direction `dir` (+1 fwd, -1 back). - // `hint` is the client's AdsCacheRecords value, or kPrefetchDepthAuto to let - // the ramp decide. Returns 0 when the client never advertised - // kCapPrefetchConsume. - std::uint16_t next_lookahead(std::uint32_t id, - std::uint16_t hint = kPrefetchDepthAuto, - std::int8_t dir = 1); - // Break the sequential run for `id` (reposition / write / order change). - void reset_lookahead(std::uint32_t id); - - // ---- ABI schema cache -------------------------------------------------- - // RCB 07/14/2026: pack_one_row_abi resolved every field's NAME and TYPE - // from the ABI on every single row (AdsGetFieldName + AdsGetFieldType + - // AdsGetField, per column). At one row per ack nobody would ever notice. - // But the whole point of this change is to make that function pack a 64-row - // block, which turns it into ~3 ABI calls x columns x 64 rows on every - // Skip — i.e. the read-ahead work would have partly eaten the round-trips - // it saves. The schema of an OPEN handle cannot change, so resolve it once - // per handle and hold it. Invalidated on CloseTable, because AdsCloseTable - // frees the ADSHANDLE and a later open can be handed the same value back. - struct AbiField { - std::vector name; // NUL-terminated, for AdsGetField - bool is_memo = false; - }; - std::unordered_map> abi_schema_; - const std::vector& abi_schema_for(ADSHANDLE h_abi); - - // Moved helpers (were [&] lambdas in session_loop) -> private - // methods; bodies unchanged except member renames. - void cleanup(); - bool ensure_abi_conn(); - ADSHANDLE ensure_abi_handle(std::uint32_t id); - bool pack_one_row_engine(std::vector& dst, - openads::engine::Table* tbl); - bool pack_one_row_abi(std::vector& dst, - ADSHANDLE h_abi); - void pack_row_trailer(Frame& reply, std::uint32_t id, - std::uint16_t lookahead_n = 0, - std::int8_t dir = 1); - // Reposition-bound piggyback: after an explicit reposition - // (GotoRecord/Seek) the server knows BOF/EOF/recno exactly, so it - // appends [u8 bof][u8 eof][u32 recno] AFTER the row trailer. The - // client serves the poll burst that always follows a reposition - // (AtBOF/AtEOF/RecNo per paint row) locally. Trailing section, - // length-gated: old clients ignore the tail (same convention as - // the M12.24 row trailer and the twin flag), so no caps bit. - // No Limbo rescue here: a rescue would MOVE the cursor during - // what must stay a pure read. A freshly repositioned twin is not - // in limbo; both-true genuinely means an empty cursor. - void pack_bound_trailer(Frame& reply, std::uint32_t id); - // mtfix12 R1 (kCapNavBoundaryPair): append the OPPOSITE boundary's - // landing state (row blob at lookahead depth 0, bound values, - // scoped key count for ordered tables) after the requested - // boundary's own sections. `which` is the boundary just navigated - // (1 = top, 2 = bottom); the pair certifies the other end. The - // cursor is restored exactly before returning; on any failure - // nothing is appended (the client length-gates and falls back to - // a plain second frame). Read-only wrt caller-visible state. - void pack_boundary_pair(Frame& reply, std::uint32_t id, - int which, ADSHANDLE hord, - openads::engine::Table* tbl); - // Warm OpenTableAck sections (USE latency): schema + first-row TLVs - // appended after the bag field (see wire.h OpenTableAckSections). - // The row section positions the engine cursor exactly like an - // explicit GotoTop would (same goto_top + pack_row_trailer + - // lookahead machinery), so a client that consumes it must skip its - // GotoTop round-trip. tbl may be nullptr (schema/row omitted). - void append_open_warm_sections(std::vector& out, - std::uint32_t id, - openads::engine::Table* tbl); - void sync_engine_cursor(std::uint32_t id); - // Install index iid as table tid's controlling order (iid 0 = - // natural), shared by the SetOrder handler and the fused nav+order - // path in GotoTop/GotoBottom. Returns 0 on success, else the ACE - // code; the caller formats the err() frame. - UNSIGNED32 install_table_order(std::uint32_t tid, std::uint32_t iid); - // Apply field writes to the current record; shared by the SetField - // (single) and SetFields (batch) handlers so both maintain the twin - // handle, bags and engine cursor identically. tbl is the already - // looked-up engine table for id.Twin cursor is synced once up front - // and the engine cursor once at the end (not per field). Returns 0 - // on success; on failure the ACE code, with column_missing set when - // a field name did not resolve (caller reports "column not found" - // instead of "write failed", matching the single-field handler). - struct FieldWriteResult { - UNSIGNED32 code = 0; - bool column_missing = false; - }; - FieldWriteResult write_fields(std::uint32_t id, - openads::engine::Table* tbl, - const std::vector>& pairs); -}; - -} // namespace openads::network From 64b510f2b20a82241df0cbafc604f2dd678ed362 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Sat, 26 Sep 2026 01:29:30 -0500 Subject: [PATCH 52/97] Delete src/network/5-session.cpp --- src/network/5-session.cpp | 5738 ------------------------------------- 1 file changed, 5738 deletions(-) delete mode 100644 src/network/5-session.cpp diff --git a/src/network/5-session.cpp b/src/network/5-session.cpp deleted file mode 100644 index 56803e18..00000000 --- a/src/network/5-session.cpp +++ /dev/null @@ -1,5738 +0,0 @@ -#include "network/session.h" - -#include "openads_version.h" // OPENADS_VERSION_STR (CMake-generated) - -#include "engine/aof_eval.h" -#include "engine/index_expr.h" -#include "engine/aof_expr.h" -#include "engine/aggregate.h" -#include "engine/record_crc.h" -#include "engine/table.h" -#include "mgmt/error_log.h" -#include "mgmt/mg_collector.h" -#include "mgmt/mg_stats.h" -#include "network/mg_wire.h" -#include "network/mutex_manager.h" -#include "platform/proc.h" -#include "openads/ace.h" -#include "openads/error.h" -#include "abi/lock_retry_policy.h" -#include "engine/server_fs.h" -#include "platform/fs_sandbox.h" -#include "platform/path.h" -#include "session/connection.h" -#include "sql_backend/enterprise_config.h" - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -namespace openads::abi { -void set_connection_show_deleted(ADSHANDLE hConnect, bool visible); -void set_connection_legacy_paths(ADSHANDLE hConnect, bool on); -void set_connection_remote_server(ADSHANDLE hConnect, bool on); -void share_connection_resolve_log(ADSHANDLE hConnect, - openads::session::Connection* src); -// Single-attempt record lock (see ace_exports.cpp): the wire server must -// fail a contended lock FAST — AdsLockRecord's retry loop would block the -// session thread ~1s, starving the peer op that releases the lock. -UNSIGNED32 try_lock_record_once(ADSHANDLE hTable, UNSIGNED32 ulRecord); -// Raw (unformatted) date/timestamp field reads for the wire: the server -// packs "YYYYMMDD"/"YYYYMMDDhhmmss" into row payloads regardless of the -// process-wide date format; clients format for display themselves. -void field_read_raw_begin(); -void field_read_raw_end(); -} - -namespace openads::network { - -static bool wire_trace_on() { - static const bool on = std::getenv("OPENADS_WIRE_TRACE") != nullptr; - return on; -} -#define WTRACE(...) do { if (wire_trace_on()) std::fprintf(stderr, __VA_ARGS__); } while (0) - -namespace { - -inline std::uint16_t read_u16_le(const std::uint8_t* p) { - return static_cast( - static_cast(p[0]) | - (static_cast(p[1]) << 8)); -} - -inline std::uint32_t read_u32_le(const std::uint8_t* p) { - return static_cast(p[0]) | - (static_cast(p[1]) << 8) | - (static_cast(p[2]) << 16) | - (static_cast(p[3]) << 24); -} - -inline void write_u16_le(std::uint16_t v, std::vector& out) { - out.push_back(static_cast( v & 0xFFu)); - out.push_back(static_cast((v >> 8) & 0xFFu)); -} - -inline void write_u32_le(std::uint32_t v, std::vector& out) { - out.push_back(static_cast( v & 0xFFu)); - out.push_back(static_cast((v >> 8) & 0xFFu)); - out.push_back(static_cast((v >> 16) & 0xFFu)); - out.push_back(static_cast((v >> 24) & 0xFFu)); -} - -// M12.29 — [u16 len][bytes] string helpers shared by the DD opcode handlers. -// Parses a [u16 len][bytes] field starting at `off`, advancing `off` past -// it. Returns false (payload too short) without touching `out` or `off` -// further than what was already consumed. -inline bool read_lstr16(const std::vector& pl, std::size_t& off, - std::string& out) { - if (off + 2 > pl.size()) return false; - std::uint16_t len = read_u16_le(pl.data() + off); - off += 2; - if (off + len > pl.size()) return false; - out.assign(reinterpret_cast(pl.data() + off), len); - off += len; - return true; -} - -inline void write_lstr16(const std::string& s, - std::vector& out) { - auto n = static_cast(s.size()); - out.push_back(static_cast(n & 0xFFu)); - out.push_back(static_cast((n >> 8) & 0xFFu)); - out.insert(out.end(), s.begin(), s.end()); -} - -// Mirror the ABI map_field_type() table so the wire reports ADS_* type -// codes (4 = STRING, 2 = NUMERIC, 11 = INTEGER, …) regardless of which -// server-side branch produced the schema. Shared by DescribeTable and -// the warm OpenTableAck schema section (which must agree byte-for-byte). -std::uint16_t ads_type_for_wire(openads::drivers::DbfFieldType t) { - using T = openads::drivers::DbfFieldType; - switch (t) { - case T::Character: return ADS_STRING; - case T::Numeric: - case T::Float: return ADS_NUMERIC; - case T::Logical: return ADS_LOGICAL; - case T::Date: - case T::AdtDate: return ADS_DATE; - case T::DateTime: - case T::AdtTimestamp: return ADS_TIMESTAMP; - case T::Memo: return ADS_MEMO; - case T::Integer: - case T::ShortInt: - case T::AutoInc: return ADS_INTEGER; - case T::Currency: - case T::AdtMoney: return ADS_MONEY; - case T::Double: return ADS_DOUBLE; - case T::Varchar: - case T::CiCharacter: return ADS_STRING; - case T::Varbinary: - case T::Binary: return ADS_RAW; - case T::Time: return ADS_TIME; - case T::Unknown: - default: return ADS_FIELD_TYPE_UNKNOWN; - } -} - -// Short opcode label for the text error log (OP column). Covers the -// hot paths developers actually grep for; everything else falls back -// to a stable hex tag so columns stay aligned. -const char* opcode_label(Opcode op) { - switch (op) { - case Opcode::Connect: return "Connect"; - case Opcode::OpenTable: return "OpenTable"; - case Opcode::CloseTable: return "CloseTable"; - case Opcode::ExecuteSQL: return "ExecuteSQL"; - case Opcode::Fetch: return "Fetch"; - case Opcode::FetchWhere: return "FetchWhere"; - case Opcode::Aggregate: return "Aggregate"; - case Opcode::GotoTop: return "GotoTop"; - case Opcode::GotoBottom: return "GotoBottom"; - case Opcode::GotoRecord: return "GotoRecord"; - case Opcode::Skip: return "Skip"; - case Opcode::GetField: return "GetField"; - case Opcode::SetField: return "SetField"; - case Opcode::AppendBlank: return "AppendBlank"; - case Opcode::DeleteRecord: return "DeleteRecord"; - case Opcode::RecallRecord: return "RecallRecord"; - case Opcode::FlushTable: return "FlushTable"; - case Opcode::OpenIndex: return "OpenIndex"; - case Opcode::CloseIndex: return "CloseIndex"; - case Opcode::SetOrder: return "SetOrder"; - case Opcode::SetOrderByName: return "SetOrderByNm"; - case Opcode::Seek: return "Seek"; - case Opcode::CreateIndex: return "CreateIndex"; - case Opcode::CreateTable: return "CreateTable"; - case Opcode::DropTable: return "DropTable"; - case Opcode::Reindex: return "Reindex"; - case Opcode::GetRecord: return "GetRecord"; - case Opcode::SetRecord: return "SetRecord"; - default: return nullptr; - } -} - -// Null-terminated UNSIGNED8 buffer for a std::string — the AdsDD* ABI takes -// mutable UNSIGNED8* name arguments (never written through) with no const -// C signature available. -inline std::vector to_cbuf(const std::string& s) { - std::vector b(s.size() + 1, 0); - if (!s.empty()) std::memcpy(b.data(), s.data(), s.size()); - return b; -} - -// M12.29 — dispatch a DDGetProperty/DDSetProperty request to the matching -// existing local AdsDDGet*Property/AdsDDSet*Property ABI function. `hConn` -// is the session's server-side abi_conn_ (a real local Connection with the -// DD attached via Session::ensure_abi_conn()) — these calls already work -// correctly locally; this only marshals which one to call. -UNSIGNED32 dd_get_property_dispatch(ADSHANDLE hConn, DDObjectKind kind, - const std::string& name, - const std::string& subName, - UNSIGNED16 propId, - void* pBuf, UNSIGNED16* pusLen) { - std::vector nameBuf(name.size() + 1, 0); - if (!name.empty()) std::memcpy(nameBuf.data(), name.data(), name.size()); - std::vector subBuf(subName.size() + 1, 0); - if (!subName.empty()) std::memcpy(subBuf.data(), subName.data(), subName.size()); - - switch (kind) { - case DDObjectKind::Database: - return AdsDDGetDatabaseProperty(hConn, propId, pBuf, pusLen); - case DDObjectKind::User: - return AdsDDGetUserProperty(hConn, nameBuf.data(), propId, pBuf, pusLen); - case DDObjectKind::Table: - return AdsDDGetTableProperty(hConn, nameBuf.data(), propId, pBuf, pusLen); - case DDObjectKind::Field: - return AdsDDGetFieldProperty(hConn, nameBuf.data(), subBuf.data(), - propId, pBuf, pusLen); - case DDObjectKind::Trigger: - return AdsDDGetTriggerProperty(hConn, nameBuf.data(), propId, pBuf, pusLen); - case DDObjectKind::Proc: - return AdsDDGetProcProperty(hConn, nameBuf.data(), propId, pBuf, pusLen); - case DDObjectKind::Function: - return AdsDDGetFunctionProperty(hConn, nameBuf.data(), propId, pBuf, pusLen); - case DDObjectKind::View: - return AdsDDGetViewProperty(hConn, nameBuf.data(), propId, pBuf, pusLen); - case DDObjectKind::RefIntegrity: - return AdsDDGetRefIntegrityProperty(hConn, nameBuf.data(), propId, pBuf, pusLen); - case DDObjectKind::Index: - return AdsDDGetIndexProperty(hConn, nameBuf.data(), subBuf.data(), - propId, pBuf, pusLen); - case DDObjectKind::UserTableRights: { - // Underlying local function returns a raw UNSIGNED32, not a - // property-id-keyed buffer — pack it into the same [value - // bytes] wire slot everything else uses (4-byte LE) so this - // reuses DDGetProperty instead of needing its own opcode. - UNSIGNED32 level = 0; - UNSIGNED32 rc = AdsDDGetUserTableRights(hConn, nameBuf.data(), - subBuf.data(), &level); - if (rc != 0) return rc; - if (pusLen != nullptr) { - UNSIGNED16 cap = *pusLen; - if (pBuf != nullptr && cap >= 4) { - auto* b = static_cast(pBuf); - b[0] = static_cast( level & 0xFFu); - b[1] = static_cast((level >> 8) & 0xFFu); - b[2] = static_cast((level >> 16) & 0xFFu); - b[3] = static_cast((level >> 24) & 0xFFu); - } - *pusLen = 4; - } - return openads::AE_SUCCESS; - } - } - return openads::AE_FUNCTION_NOT_AVAILABLE; -} - -UNSIGNED32 dd_set_property_dispatch(ADSHANDLE hConn, DDObjectKind kind, - const std::string& name, - const std::string& subName, - UNSIGNED16 propId, - void* pBuf, UNSIGNED16 usLen) { - std::vector nameBuf(name.size() + 1, 0); - if (!name.empty()) std::memcpy(nameBuf.data(), name.data(), name.size()); - std::vector subBuf(subName.size() + 1, 0); - if (!subName.empty()) std::memcpy(subBuf.data(), subName.data(), subName.size()); - - switch (kind) { - case DDObjectKind::Database: - return AdsDDSetDatabaseProperty(hConn, propId, pBuf, usLen); - case DDObjectKind::User: - return AdsDDSetUserProperty(hConn, nameBuf.data(), propId, pBuf, usLen); - case DDObjectKind::Table: - return AdsDDSetTableProperty(hConn, nameBuf.data(), propId, pBuf, usLen); - case DDObjectKind::Field: - return AdsDDSetFieldProperty(hConn, nameBuf.data(), subBuf.data(), - propId, pBuf, usLen); - case DDObjectKind::Trigger: - return AdsDDSetTriggerProperty(hConn, nameBuf.data(), propId, pBuf, usLen); - case DDObjectKind::Proc: - return AdsDDSetProcProperty(hConn, nameBuf.data(), propId, pBuf, usLen); - case DDObjectKind::Function: - return AdsDDSetFunctionProperty(hConn, nameBuf.data(), propId, pBuf, usLen); - case DDObjectKind::View: - return AdsDDSetViewProperty(hConn, nameBuf.data(), propId, pBuf, usLen); - case DDObjectKind::RefIntegrity: - return AdsDDSetRefIntegrityProperty(hConn, nameBuf.data(), propId, pBuf, usLen); - case DDObjectKind::Index: - // AdsDDSetIndexProperty is a permanent local stub regardless of - // connection type — nothing to forward. - return openads::AE_FUNCTION_NOT_AVAILABLE; - case DDObjectKind::UserTableRights: { - if (pBuf == nullptr || usLen < 4) { - return openads::AE_INTERNAL_ERROR; - } - auto* b = static_cast(pBuf); - UNSIGNED32 level = static_cast(b[0]) | - (static_cast(b[1]) << 8) | - (static_cast(b[2]) << 16) | - (static_cast(b[3]) << 24); - return AdsDDSetUserTableRights(hConn, nameBuf.data(), subBuf.data(), level); - } - } - return openads::AE_FUNCTION_NOT_AVAILABLE; -} - -// M12.10 — Error frame payload: -// [u32 LE ace_code][message bytes] -// Server-side handlers fold either a literal ACE code or a code -// pulled from a sub-result's util::Error into every Error frame -// they emit so the client can surface the right `Ads*` code. -Frame err(const std::string& msg, - UNSIGNED32 code = openads::AE_INTERNAL_ERROR) { - // Every error frame returned to a remote client also lands in the - // SAP-style ads_err error log (mirrors ADS, which records errors the - // server encounters serving client applications). - openads::mgmt::ErrorLog::instance().log( - static_cast(code), "NET", 0, msg); - Frame f; - f.opcode = Opcode::Error; - f.payload.resize(4); - f.payload[0] = static_cast( code & 0xFFu); - f.payload[1] = static_cast((code >> 8) & 0xFFu); - f.payload[2] = static_cast((code >> 16) & 0xFFu); - f.payload[3] = static_cast((code >> 24) & 0xFFu); - f.payload.insert(f.payload.end(), msg.begin(), msg.end()); - return f; -} - -} // namespace - -Session::Session(Server& srv, Socket s, std::string default_data_dir, - std::uint16_t listener_port) - : srv_(&srv), s_(s), sid_(0), default_data_dir_(std::move(default_data_dir)) { - // studio.web.0.4 — register an entry in the live sessions - // registry so the Studio "Sessions" tab can list this peer. - Server::SessionInfo init; - if (auto pa = socket_peer_addr(s_); pa) { - init.peer_ip = pa.value().ip; - init.peer_port = pa.value().port; - peer_str_ = pa.value().ip + ":" + std::to_string(pa.value().port); - } - init.listener_port = listener_port; - init.connected_at = std::chrono::system_clock::now(); - init.last_activity = init.connected_at; - sid_ = srv_->register_session(init); - srv_->install_session_socket(sid_, s_); -} - -Session::~Session() { - cleanup(); - srv_->erase_session_socket(sid_); - srv_->unregister_session(sid_); -} - -bool Session::process_frame(const Frame& f) { - srv_->touch_session(sid_, true, false); - srv_->set_session_opcode(sid_, static_cast(f.opcode)); - { - // M9.25 — inbound comm telemetry. +5 accounts for the 4-byte - // length prefix + 1-byte opcode of the wire framing. - auto& mgst = openads::mgmt::process_mg_stats(); - mgst.packets_in.fetch_add(1, std::memory_order_relaxed); - mgst.bytes_in.fetch_add(f.payload.size() + 5, - std::memory_order_relaxed); - } - // Track the actual SQL text for the Active Queries "current query" - // detail view — mirrors SAP ARC's sp_GetSQLStatements() (see - // mgtscrn.pas/.dfm in the Advantage Data Architect source). The - // ExecuteSQL frame's raw payload IS the SQL string. - if (f.opcode == Opcode::ExecuteSQL) { - // Iterator-based ctor: payload.data() may be nullptr when empty, - // and std::string(nullptr, 0) is UB. - std::string sql(f.payload.begin(), f.payload.end()); - srv_->set_session_sql(sid_, sql); - } - srv_->set_session_executing(sid_, true); - // Stamp the text error log context for this frame so any err() the - // handler emits carries SESSION/CLIENT/OP/TABLE columns. Cheap: - // label lookup + payload slice, no validation (handlers re-parse). - { - std::string op; - if (const char* l = opcode_label(f.opcode)) op = l; - else { - char hex[8]; - std::snprintf(hex, sizeof(hex), "0x%02X", - static_cast(f.opcode)); - op = hex; - } - std::string table; - if (f.opcode == Opcode::OpenTable && !f.payload.empty()) { - std::size_t off = (client_open_table_mode_ok_ && f.payload.size() >= 2) - ? 2 : 0; - if (off <= f.payload.size()) { - table.assign(reinterpret_cast(f.payload.data() + off), - f.payload.size() - off); - if (table.size() > 7 && - (table.rfind("tcp://", 0) == 0 || table.rfind("TCP://", 0) == 0)) { - auto sep = table.find_last_of("/\\"); - if (sep != std::string::npos) table = table.substr(sep + 1); - } - } - } else if (f.opcode == Opcode::OpenIndex && f.payload.size() > 4) { - table.assign(reinterpret_cast(f.payload.data() + 4), - f.payload.size() - 4); - } - // Keep the column readable: basename for paths. - if (!table.empty()) { - auto sep = table.find_last_of("/\\"); - if (sep != std::string::npos && sep + 1 < table.size()) - table = table.substr(sep + 1); - if (table.size() > 24) table = table.substr(table.size() - 24); - } - openads::mgmt::ErrorLog::instance().set_log_context( - sid_, peer_str_, op, table); - } - auto t0 = std::chrono::steady_clock::now(); - DispatchResult res; - try { - res = dispatch(f); - } catch (const std::exception& e) { - // A failing handler (e.g. std::bad_alloc once the address space is - // exhausted) must close this session, never kill the whole server. - res.reply = err(std::string("internal error: ") + e.what()); - res.close_session = true; - } catch (...) { - res.reply = err("internal error: unknown exception"); - res.close_session = true; - } - auto micros = std::chrono::duration_cast( - std::chrono::steady_clock::now() - t0).count(); - srv_->record_session_op_time(sid_, static_cast(micros)); - { - // Storm-diag — lock-free per-opcode latency accumulation. - auto& st = openads::mgmt::process_mg_stats(); - auto& t = st.op_timing[static_cast(f.opcode)]; - t.count.fetch_add(1, std::memory_order_relaxed); - t.total_us.fetch_add(static_cast(micros), - std::memory_order_relaxed); - auto prev = t.max_us.load(std::memory_order_relaxed); - while (static_cast(micros) > prev && - !t.max_us.compare_exchange_weak( - prev, static_cast(micros), - std::memory_order_relaxed)) { - } - } - srv_->set_session_executing(sid_, false); - if (res.reply) { - if (auto wr = write_frame(s_, *res.reply); !wr) return false; - auto& mgst = openads::mgmt::process_mg_stats(); - mgst.packets_out.fetch_add(1, std::memory_order_relaxed); - // RCB 07/14/2026: bytes_out was declared alongside packets_out but - // never actually fed, so AdsMgGetCommStats / sp_mgGetCommStats have - // always reported 0 bytes sent. Found it while trying to MEASURE the - // read-ahead block size and getting zeroes back. Same +5 framing - // overhead as bytes_in above. - mgst.bytes_out.fetch_add(res.reply->payload.size() + 5, - std::memory_order_relaxed); - srv_->touch_session(sid_, false, true); - } - return !res.close_session; -} - -bool Session::handle_readable() { - // Read whatever a single recv yields — a partial frame, one frame, or - // several — then reassemble and dispatch every complete frame. On a - // non-blocking socket (reactor pool) an idle or stalled peer returns - // would-block and we hand the worker straight back to its other - // connections, so one slow client can't cause head-of-line blocking. On a - // blocking socket (legacy thread-per-connection loop) recv just waits for - // the next bytes, preserving the previous one-frame-at-a-time behavior. - std::uint8_t buf[16384]; - auto r = sock_recv(s_, buf, sizeof(buf)); - if (!r) { - if (socket_recv_would_block(r.error())) return true; // nothing right now - return false; // peer reset / error - } - if (r.value() == 0) return false; // peer closed cleanly - auto frames = reader_.feed(buf, r.value()); - if (!frames) return false; // malformed framing - for (const auto& f : frames.value()) { - if (!process_frame(f)) return false; - } - return true; -} - -void Session::cleanup() { - // Login-gate audit: a session that held record locks is going away - // and every one of them evaporates HERE. If a duplicate login slips - // through right after this line, the holder's session died first - // (restart, WAN drop, app close) — not an exclusion failure. - WTRACE("[wire] session end user=%s conn=%u tables=%u\n", - session_user_.empty() ? "(anonymous)" : session_user_.c_str(), - (unsigned)(srv_ ? srv_->conn_no_for_session(sid_) : 0), - (unsigned)tbls_.size()); - // 0) Distributed mutexes first: free locks held by this session and - // reap names it created, so a vanished process leaves neither a - // stale lock (peers would block until server restart) nor a stale - // name (every later MutexCreate would fail "exists"). Runs before - // the table teardown below so woken peers proceed while we close. - // Covers Disconnect, peer-close and exception paths alike — all - // funnel through here (destructor included). - if (srv_ != nullptr) { - srv_->mutex_manager().release_session(conn_serial()); - } - - // Tear-down order matters for OS file handles on Windows (no - // FILE_SHARE_DELETE): indexes first, then shadow ABI tables, then - // engine tables, then low-level FsFile slots, then the ABI connection. - // A prior version only closed the ABI shadow (tbls_h_) and left - // engine tables + index_h_ + files_ to member destruction — that - // worked for most paths, but a client killed mid-CreateIndex (or - // after hundreds of open/close cycles) could leave the production - // .cdx open until openads_serverd itself exited (Pritpal Bedi). - - // 1) Explicit AdsCloseIndex for every wire index handle. AdsCloseTable - // purges bindings too, but closing indexes first drops each tag's - // File + CDX write-lock join before the table goes away. - for (auto& [iid, hidx] : index_h_) { - (void)iid; - if (hidx != 0) (void)AdsCloseIndex(hidx); - } - index_h_.clear(); - index_table_.clear(); - index_tag_.clear(); - ordered_tables_.clear(); - abi_schema_.clear(); - prefetch_depth_.clear(); - prefetch_run_dir_.clear(); - - // 2) SQL cursors (already ABI handles). - for (auto& [id, h] : cursor_tbls_) { - (void)id; - if (h != 0) (void)AdsCloseTable(h); - } - cursor_tbls_.clear(); - - // 3) Shadow ABI twins (production CDX auto-open + CreateIndex live here). - for (auto& [id, h] : tbls_h_) { - (void)id; - if (h != 0) (void)AdsCloseTable(h); - } - tbls_h_.clear(); - - // 4) Engine tables held by the session Connection. close_table erases - // the unique_ptr so the driver's File closes immediately — not after - // the Session member destructor runs (which is too late if anything - // else still references the path, and skips LockRegistry cleanup). - if (sess_conn_) { - for (auto& [id, h] : tbls_) { - if (auto rit = tbl_open_reg_.find(id); - rit != tbl_open_reg_.end()) { - srv_->unregister_open(sid_, rit->second.first, - rit->second.second); - } - if (auto* t = sess_conn_->lookup_table(h)) { - (void)t->flush(); - openads::mgmt::LockRegistry::instance().remove_all_for_table(t); - } - sess_conn_->close_table(h); - srv_->add_session_table(sid_, -1); - } - } - tbls_.clear(); - tbl_open_paths_.clear(); - tbl_open_reg_.clear(); - - // 5) oads_FOpen / AdsFOpen files for this session. - files_.clear(); - - // 6) ABI SQL statement + connection (AdsDisconnect closes any leftover - // tables that somehow escaped the loops above). - if (abi_stmt_ != 0) { - (void)AdsCloseSQLStatement(abi_stmt_); - abi_stmt_ = 0; - } - if (abi_conn_ != 0) { - (void)AdsDisconnect(abi_conn_); - abi_conn_ = 0; - } -} - -// M12.16 — lazy-init the per-session ABI connection (same one -// M12.7 SQL exec uses). -bool Session::ensure_abi_conn() { - if (abi_conn_ != 0) return true; - if (!sess_conn_) return false; - // Prefer the full .add path so the ABI connection inherits the DD. - const std::string& conn_path = sess_conn_->dd_path().empty() - ? sess_conn_->data_dir() : sess_conn_->dd_path(); - std::vector srvbuf(conn_path.size() + 1); - std::memcpy(srvbuf.data(), conn_path.c_str(), conn_path.size() + 1); - // RCB 06/30/2026: The server creates this ABI handle lazily for remote - // SQL/index operations. Reuse the original DD login so login-required - // dictionaries do not fail after the first remote Connect succeeds. - std::vector userbuf; - std::vector pwbuf; - auto make_arg = [](const std::string& s, std::vector& out) - -> UNSIGNED8* { - if (s.empty()) return nullptr; - out.resize(s.size() + 1); - std::memcpy(out.data(), s.c_str(), s.size() + 1); - return out.data(); - }; - UNSIGNED8* user = make_arg(session_user_, userbuf); - UNSIGNED8* pw = make_arg(session_password_, pwbuf); - UNSIGNED32 rc = AdsConnect60(srvbuf.data(), ADS_LOCAL_SERVER, - user, pw, 0, &abi_conn_); - if (rc == 0 && abi_conn_ != 0) { - // --legacy-paths: the ABI twin must fold client-absolute paths - // (CreateTable/Reindex/SQL go through it) the same way the - // session's engine Connection does. - openads::abi::set_connection_legacy_paths(abi_conn_, - srv_->legacy_paths()); - openads::abi::set_connection_remote_server(abi_conn_, true); - // One client session = one RESOLVED audit line per file: the - // twin re-opens tables for index/SQL work, so dedup against the - // engine connection's set, not its own (Pritpal Bedi, Aug 2026). - openads::abi::share_connection_resolve_log(abi_conn_, - sess_conn_.get()); - // M12.32 — a ShowDeleted opcode may have arrived before this - // lazy connection existed; new connections default to "show". - if (!show_deleted_) { - openads::abi::set_connection_show_deleted(abi_conn_, false); - } - } - return rc == 0; -} - -// M12.16 — open a parallel ABI handle alongside the engine -// handle in tbls_[id], stash in tbls_h_[id]. The engine handle -// stays open so subsequent navigation handlers (GotoTop/Skip/…) -// keep their existing path; only index operations route -// through the ABI handle. After an index op that moves the -// cursor (Seek / SeekLast) the helper sync_engine_cursor -// re-positions the engine cursor to the same recno so the two -// states never drift. SQL cursor handles (cursor_tbls_) are -// returned as-is since they are already ABI-side. -ADSHANDLE Session::ensure_abi_handle(std::uint32_t id) { - if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { - return cit->second; - } - if (auto hit = tbls_h_.find(id); hit != tbls_h_.end()) { - return hit->second; - } - auto eit = tbls_.find(id); - if (eit == tbls_.end() || !sess_conn_) return 0; - if (!ensure_abi_conn()) return 0; - auto* tbl = sess_conn_->lookup_table(eit->second); - if (!tbl) return 0; - // Reopen with the same name the client used (e.g. "orders/work.dbf"), - // not basename-only — otherwise production CDX auto-open binds against - // the wrong table when data files live in subdirectories. - std::string open_name; - if (auto pit = tbl_open_paths_.find(id); pit != tbl_open_paths_.end()) { - open_name = pit->second; - } - if (open_name.empty()) { - std::filesystem::path abs(tbl->path()); - std::filesystem::path base(sess_conn_->data_dir()); - std::error_code ec; - auto rel = std::filesystem::relative(abs, base, ec); - if (!ec && !rel.empty() && rel != ".") { - open_name = rel.generic_string(); - } else { - open_name = abs.filename().string(); - } - } - std::vector nb(open_name.size() + 1); - std::memcpy(nb.data(), open_name.data(), open_name.size()); - // Match the engine table's share mode so the twin does not upgrade a - // shared open to exclusive (or vice versa). map_open_mode(0) is Shared; - // Exclusive/Read map from the engine OpenMode. - UNSIGNED16 us_mode = ADS_SHARED; - switch (tbl->open_mode()) { - case openads::engine::OpenMode::Exclusive: - us_mode = ADS_EXCLUSIVE; break; - case openads::engine::OpenMode::Read: - us_mode = ADS_READONLY; break; - default: - us_mode = ADS_SHARED; break; - } - ADSHANDLE h = 0; - WTRACE("[wire] ensure_abi_handle id=%u open_name='%s' mode=%u eng_path='%s' eng_recs=%u\n", - id, open_name.c_str(), (unsigned)us_mode, - tbl->path().c_str(), (unsigned)tbl->record_count()); - auto& oi_st = openads::mgmt::process_mg_stats(); - auto oi_us = [](auto a, auto b) { - return static_cast( - std::chrono::duration_cast( - b - a).count()); - }; - auto oi_acc = [&oi_st, &oi_us](int ph, auto a, auto b) { - auto& t = oi_st.op_timing_oi[ph]; - auto us = oi_us(a, b); - t.count.fetch_add(1, std::memory_order_relaxed); - t.total_us.fetch_add(us, std::memory_order_relaxed); - auto pv = t.max_us.load(std::memory_order_relaxed); - while (us > pv && !t.max_us.compare_exchange_weak( - pv, us, std::memory_order_relaxed)) {} - }; - auto oi_t0 = std::chrono::steady_clock::now(); - UNSIGNED32 open_rrc = AdsOpenTable(abi_conn_, nb.data(), nullptr, - ADS_CDX, 0, 0, 0, us_mode, &h); - oi_acc(0, oi_t0, std::chrono::steady_clock::now()); - if (open_rrc != 0) { - WTRACE("[wire] ensure_abi_handle id=%u AdsOpenTable FAILED rrc=%u\n", - id, (unsigned)open_rrc); - return 0; - } - // Position the twin so pack_row_trailer sees a live cursor (not Limbo). - auto oi_t2 = std::chrono::steady_clock::now(); - UNSIGNED32 gt_rc = AdsGotoTop(h); - UNSIGNED16 _b = 9, _e = 9; - AdsAtBOF(h, &_b); AdsAtEOF(h, &_e); - UNSIGNED32 twin_recs = 0; - AdsGetRecordCount(h, 0, &twin_recs); - oi_acc(2, oi_t2, std::chrono::steady_clock::now()); - WTRACE("[wire] ensure_abi_handle id=%u CDX open: bof=%u eof=%u twin_recs=%u\n", - id, (unsigned)_b, (unsigned)_e, (unsigned)twin_recs); - // Fallback: if GotoTop left the twin in Limbo (both bof and eof true), - // the ACE CDX implementation may disagree with the engine's CDX on the - // same file. Close and reopen as a plain DBF (no CDX), then try to - // attach the index explicitly. If even that fails, we still have a - // working cursor in natural record order — better than Limbo. - // - // Skip when the table is genuinely EMPTY: Limbo is the correct state - // there, and reopening without the production bag only discards its - // binding (later twin appends then leave the bag stale). Since the - // AdsOpenIndex heal (v1.09.9) reindexes stale bags on open, a non-empty - // table should never reach this branch in Limbo anymore — it remains - // as a safety net for bags the heal cannot repair. - if (_b && _e && tbl->record_count() > 0) { - WTRACE("[wire] ensure_abi_handle id=%u Limbo after CDX open, retrying without CDX eng_recs=%u\n", - id, (unsigned)tbl->record_count()); - AdsCloseTable(h); - h = 0; - open_rrc = AdsOpenTable(abi_conn_, nb.data(), nullptr, - ADS_TABLE, 0, 0, 0, us_mode, &h); - if (open_rrc != 0) { - WTRACE("[wire] ensure_abi_handle id=%u AdsOpenTable(ADS_TABLE) FAILED rrc=%u\n", - id, (unsigned)open_rrc); - return 0; - } - // Try to attach the production index explicitly. - ADSHANDLE hIdx = 0; - UNSIGNED16 idxCount = 0; - // Build the CDX path from the DBF path. - std::string cdx_name; - { - std::filesystem::path p(open_name); - cdx_name = p.replace_extension(".cdx").generic_string(); - } - std::vector cdx_nb(cdx_name.size() + 1); - std::memcpy(cdx_nb.data(), cdx_name.data(), cdx_name.size()); - UNSIGNED32 idx_rc = AdsOpenIndex(h, cdx_nb.data(), &hIdx, &idxCount); - if (idx_rc == 0 && idxCount > 0) { - // Register the attached tag under a wire id so ordered nav can - // reach it (mirrors the OpenIndex handler's bookkeeping). - std::uint32_t iid = next_id_++; - index_h_[iid] = hIdx; - index_table_[iid] = id; - UNSIGNED8 tbuf[256] = {0}; - UNSIGNED16 tlen = static_cast(sizeof(tbuf) - 1); - if (AdsGetIndexName(hIdx, tbuf, &tlen) == 0) { - index_tag_[iid] = std::string( - reinterpret_cast(tbuf), tlen); - } - WTRACE("[wire] ensure_abi_handle id=%u CDX attached via ADS_TABLE fallback idx=%u iid=%u\n", - id, (unsigned)idxCount, (unsigned)iid); - } else { - WTRACE("[wire] ensure_abi_handle id=%u no CDX (rc=%u count=%u), natural order\n", - id, (unsigned)idx_rc, (unsigned)idxCount); - } - gt_rc = AdsGotoTop(h); - AdsAtBOF(h, &_b); AdsAtEOF(h, &_e); - AdsGetRecordCount(h, 0, &twin_recs); - WTRACE("[wire] ensure_abi_handle id=%u TABLE fallback: bof=%u eof=%u twin_recs=%u\n", - id, (unsigned)_b, (unsigned)_e, (unsigned)twin_recs); - } - WTRACE("[wire] ensure_abi_handle id=%u opened h=%llu gt_rc=%u bof=%u eof=%u twin_recs=%u\n", - id, (unsigned long long)h, (unsigned)gt_rc, (unsigned)_b, (unsigned)_e, (unsigned)twin_recs); - tbls_h_[id] = h; - return h; -} - -// M12.18 — pack the current record (recno + deleted + per-field -// value bytes) onto the tail of any nav-op ack so the client -// populates RemoteTable's row cache in the same RTT as the nav -// itself. -// Pack one record's bytes into `dst` at the current cursor. -// Returns false on EoF / unread error so the caller can stop -// walking lookahead. -bool Session::pack_one_row_engine(std::vector& dst, - openads::engine::Table* tbl) { - if (!tbl || tbl->eof() || tbl->bof() || tbl->recno() == 0) { - return false; - } - auto write_u32_p = [&](std::uint32_t v) { - dst.push_back(static_cast( v & 0xFFu)); - dst.push_back(static_cast((v >> 8) & 0xFFu)); - dst.push_back(static_cast((v >> 16) & 0xFFu)); - dst.push_back(static_cast((v >> 24) & 0xFFu)); - }; - auto write_u16_p = [&](std::uint16_t v) { - dst.push_back(static_cast( v & 0xFFu)); - dst.push_back(static_cast((v >> 8) & 0xFFu)); - }; - write_u32_p(tbl->recno()); - dst.push_back(tbl->is_deleted() ? 1 : 0); - auto nf = static_cast(tbl->field_count()); - write_u16_p(nf); - for (std::uint16_t i = 0; i < nf; ++i) { - auto v = tbl->read_field(i); - std::string vv = v ? v.value().as_string : std::string(); - write_u32_p(static_cast(vv.size())); - dst.insert(dst.end(), vv.begin(), vv.end()); - } - return true; -} - -// Field names + memo-ness for one open ABI handle, resolved once. The schema -// of an open table cannot change under us, so this is a pure win: it takes -// the two per-column ABI lookups (name, type) out of the per-row path, which -// a 64-row lookahead block walks 64 times. -const std::vector& -Session::abi_schema_for(ADSHANDLE h_abi) { - auto it = abi_schema_.find(h_abi); - if (it != abi_schema_.end()) return it->second; - - std::vector cols; - UNSIGNED16 nf = 0; - AdsGetNumFields(h_abi, &nf); - cols.reserve(nf); - // RCB 07/15/2026: on `cap` and the memcpy — this is NOT the unbounded - // "ACE writes back the required size" pattern that would overflow nm[64]. - // These AdsGetFieldName/AdsGetField calls run against a local server-side - // handle and land in copy_to_caller() (src/abi/charset.cpp), which writes - // back cap = min(name_len, buf-1) and never a value >= the buffer. So the - // memcpy is bounded by construction. One record per column, always the full - // count: do NOT rewrite this to skip a column on an (unreachable, i = 1..nf) - // failure — cols.size() is sent as the row's field count in - // pack_one_row_abi, so a short cols would desync the wire row. - for (UNSIGNED16 i = 1; i <= nf; ++i) { - AbiField fd; - UNSIGNED8 nm[64] = {0}; - UNSIGNED16 cap = sizeof(nm); - AdsGetFieldName(h_abi, i, nm, &cap); - if (cap > sizeof(nm)) cap = sizeof(nm); // belt-and-suspenders; see above - fd.name.assign(cap + 1, 0); - std::memcpy(fd.name.data(), nm, cap); - UNSIGNED16 ftype = 0; - AdsGetFieldType(h_abi, fd.name.data(), &ftype); - fd.is_memo = (ftype == ADS_MEMO || - ftype == ADS_BINARY || - ftype == ADS_IMAGE); - cols.push_back(std::move(fd)); - } - return abi_schema_.emplace(h_abi, std::move(cols)).first->second; -} - -bool Session::pack_one_row_abi(std::vector& dst, - ADSHANDLE h_abi) { - UNSIGNED16 atend = 0; - AdsAtEOF(h_abi, &atend); - if (atend) return false; - auto write_u32_p = [&](std::uint32_t v) { - dst.push_back(static_cast( v & 0xFFu)); - dst.push_back(static_cast((v >> 8) & 0xFFu)); - dst.push_back(static_cast((v >> 16) & 0xFFu)); - dst.push_back(static_cast((v >> 24) & 0xFFu)); - }; - auto write_u16_p = [&](std::uint16_t v) { - dst.push_back(static_cast( v & 0xFFu)); - dst.push_back(static_cast((v >> 8) & 0xFFu)); - }; - UNSIGNED32 rn = 0; - AdsGetRecordNum(h_abi, 0, &rn); - write_u32_p(rn); - UNSIGNED16 del = 0; - AdsIsRecordDeleted(h_abi, &del); - dst.push_back(del != 0 ? 1 : 0); - const auto& cols = abi_schema_for(h_abi); - write_u16_p(static_cast(cols.size())); - std::vector vbuf; - for (const auto& fd : cols) { - // Non-memo values fit the fixed DBF field width; 4096 covers every - // scalar type the ABI hands back. Memos are sized from the engine. - UNSIGNED32 vcap = 4096; - if (fd.is_memo) { - UNSIGNED32 mlen = 0; - if (AdsGetMemoLength(h_abi, - const_cast(fd.name.data()), &mlen) != 0) { - mlen = 0; - } - vcap = mlen + 1; - } - vbuf.assign(vcap, 0); - // Date/Timestamp cells ride the wire RAW ("YYYYMMDD" / - // "YYYYMMDDhhmmss") — the client formats for display; SAP parity - // (AdsGetField formatted) stays on the client-side ABI surface. - openads::abi::field_read_raw_begin(); - UNSIGNED32 grc = AdsGetField(h_abi, const_cast(fd.name.data()), - vbuf.data(), &vcap, 0); - openads::abi::field_read_raw_end(); - if (grc != 0) { - vcap = 0; - } - write_u32_p(vcap); - if (vcap > 0) { - dst.insert(dst.end(), vbuf.data(), vbuf.data() + vcap); - } - } - return true; -} - -void Session::pack_row_trailer(Frame& reply, std::uint32_t id, - std::uint16_t lookahead_n, - std::int8_t dir) { - auto write_u16_p = [&](std::uint16_t v, - std::vector& dst) { - dst.push_back(static_cast( v & 0xFFu)); - dst.push_back(static_cast((v >> 8) & 0xFFu)); - }; - // Resolve the table once; remember which path applies for - // both the current row and the lookahead block below. - openads::engine::Table* eng_tbl = nullptr; - ADSHANDLE h_abi = 0; - if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { - h_abi = cit->second; - } else if (ordered_tables_.count(id) != 0) { - h_abi = ensure_abi_handle(id); - // Also resolve the engine table — we need it for fallback reads - // when the ABI twin is in Limbo (ACE's CDX may disagree with the - // engine's CDX on index state, leaving the twin at bof=eof=1). - if (auto eit = tbls_.find(id); eit != tbls_.end() && sess_conn_) { - eng_tbl = sess_conn_->lookup_table(eit->second); - } - } else if (auto eit = tbls_.find(id); eit != tbls_.end() && sess_conn_) { - eng_tbl = sess_conn_->lookup_table(eit->second); - } else if (auto hit = tbls_h_.find(id); hit != tbls_h_.end()) { - h_abi = hit->second; - } - if (!eng_tbl && h_abi == 0) { - reply.payload.push_back(0); - return; - } - if (h_abi != 0) { - UNSIGNED16 _b = 9, _e = 9; AdsAtBOF(h_abi, &_b); AdsAtEOF(h_abi, &_e); - // Rescue: if the twin landed in Limbo (bof=1 eof=1) — e.g. from a - // cached handle opened when the table was empty — reposition it now. - if (_b && _e) { - AdsGotoTop(h_abi); - AdsAtBOF(h_abi, &_b); AdsAtEOF(h_abi, &_e); - if (_b && _e) AdsGotoBottom(h_abi); - AdsAtBOF(h_abi, &_b); AdsAtEOF(h_abi, &_e); - WTRACE("[wire] pack enter id=%u Limbo rescue bof=%u eof=%u\n", - id, (unsigned)_b, (unsigned)_e); - } - WTRACE("[wire] pack enter id=%u twin bof=%u eof=%u\n", id, (unsigned)_b, (unsigned)_e); - } - // f2436d8 regression: for ORDERED tables it made this pack read from the - // engine mirror. That mirror lags the twin one op (handlers sync AFTER - // packing) and its lookahead walks NATURAL order, so every ordered op - // shipped the previous position's row and ordered read-ahead walked - // recno order instead of the index. The twin is the authority for an - // ordered table: pack from it. The engine stays the source for - // natural-order tables and the fallback when the twin is genuinely in - // Limbo (empty order/table) — the case f2436d8 meant to cover. - bool twin_limbo = false; - if (h_abi != 0) { - UNSIGNED16 lb = 9, le = 9; - AdsAtBOF(h_abi, &lb); AdsAtEOF(h_abi, &le); - twin_limbo = (lb != 0 && le != 0); - } - const bool use_engine = (eng_tbl != nullptr) && (h_abi == 0 || twin_limbo); - // Pack the current row. - bool current_packed = false; - if (use_engine) { - // If the ABI twin is in Limbo (bof=eof=1) but we have the engine - // table, sync the engine to the ABI twin's recno and read from engine. - // ACE's CDX may disagree with the engine's CDX on index state. - if (h_abi != 0) { - UNSIGNED32 rn = 0; - AdsGetRecordNum(h_abi, 0, &rn); - if (rn > 0 && eng_tbl->record_count() >= rn) { - eng_tbl->goto_record(rn); - WTRACE("[wire] pack enter id=%u Limbo fallback engine recno=%u\n", - id, (unsigned)rn); - } else { - // Twin recno unavailable (Limbo); use engine's current pos. - WTRACE("[wire] pack enter id=%u Limbo, engine recno=%u\n", - id, (unsigned)eng_tbl->recno()); - } - } - if (eng_tbl->eof() || eng_tbl->bof() || eng_tbl->recno() == 0) { - reply.payload.push_back(0); - } else { - reply.payload.push_back(1); - std::vector tmp; - if (pack_one_row_engine(tmp, eng_tbl)) { - reply.payload.insert(reply.payload.end(), - tmp.begin(), tmp.end()); - current_packed = true; - } - } - } else { - // BOF must report has_row=0 just like EOF: with a row on the - // trailer the client treats the BOF landing as a valid position, - // and its boundary detection (which relies on a pristine - // row_valid_before) then reports Bof()=.F. on the first backward - // skip at the top — xBrowse counts one extra row above and paints - // a phantom duplicate (Tim Stone, 1-record scope). It also lets - // the lookahead walk run at BOF, whose restore step clears the - // twin's BOF flag. Field reads at BOF still work: they miss the - // cache and cost one round trip, exactly like EOF. - UNSIGNED16 atend = 0, atbeg = 0; - AdsAtEOF(h_abi, &atend); - AdsAtBOF(h_abi, &atbeg); - if (atend || atbeg) { - reply.payload.push_back(0); - } else { - reply.payload.push_back(1); - std::vector tmp; - if (pack_one_row_abi(tmp, h_abi)) { - reply.payload.insert(reply.payload.end(), - tmp.begin(), tmp.end()); - current_packed = true; - } - } - } - // M12.21 lookahead block. Walk Skip(dir) up to lookahead_n times capturing - // each row, then Skip(-dir * advance) back so the cursor lands at the same - // spot the lone Skip the caller actually issued would have produced. - // RCB 07/15/2026: M12.25 — `dir` is +1 for a forward block, -1 for a - // backward (PgUp) one. The walk and the restore are symmetric in it. - if (!current_packed || lookahead_n == 0 || dir == 0) { - // No lookahead either way — emit a count of 0 so the - // wire format always carries the field. Old clients - // (M12.18) ignore the extra bytes. - write_u16_p(0, reply.payload); - return; - } - const SIGNED32 walk = dir; // +1 or -1, one step per iteration - std::vector> rows; - rows.reserve(lookahead_n); - std::uint16_t taken = 0; - // Track cursor moves separately from rows packed: a Skip that lands on - // EoF/BoF still moves the cursor, even though no row gets packed. The - // restore step at the end has to undo every cursor advance, packed-row or - // not, otherwise the caller-visible cursor lands a row past where the lone - // Skip it issued would have produced. - int cursor_advance = 0; - // RCB 07/14/2026: the row count is only half the bound — see - // kPrefetchMaxBytes. Stop as soon as the block reaches the byte budget so a - // wide table cannot turn a 64-row lookahead into a multi-hundred-KB frame. - // Checked AFTER packing each row on purpose: that way we always send at - // least one lookahead row even when a single row is bigger than the whole - // budget, instead of silently degrading to no read-ahead on exactly the - // tables where a round-trip costs the most. - std::size_t block_bytes = 0; - for (std::uint16_t i = 0; i < lookahead_n; ++i) { - if (use_engine) { - auto sk = eng_tbl->skip(walk); - if (!sk) break; - ++cursor_advance; - std::vector row; - if (!pack_one_row_engine(row, eng_tbl)) break; - block_bytes += row.size(); - rows.push_back(std::move(row)); - ++taken; - } else { - if (AdsSkip(h_abi, walk) != 0) break; - ++cursor_advance; - // A backward walk stops at BoF, a forward one at EoF; pack_one_row_* - // already refuses to pack an off-record cursor, but check the - // relevant boundary first so we don't pack a phantom row. - UNSIGNED16 atend = 0; - if (dir > 0) AdsAtEOF(h_abi, &atend); else AdsAtBOF(h_abi, &atend); - if (atend) break; - std::vector row; - if (!pack_one_row_abi(row, h_abi)) break; - block_bytes += row.size(); - rows.push_back(std::move(row)); - ++taken; - } - if (block_bytes >= kPrefetchMaxBytes) break; - } - if (cursor_advance > 0) { - // Undo every advance: we stepped `walk` cursor_advance times, so step - // back by -walk * cursor_advance. - const SIGNED32 restore = - -walk * static_cast(cursor_advance); - if (use_engine) { - (void)eng_tbl->skip(restore); - } else { - (void)AdsSkip(h_abi, restore); - } - } - write_u16_p(taken, reply.payload); - for (auto& r : rows) { - reply.payload.insert(reply.payload.end(), r.begin(), r.end()); - } -} - -void Session::pack_bound_trailer(Frame& reply, std::uint32_t id) { - // Same source-of-truth rule as the AtBOF/AtEOF handlers: ordered - // tables answer from the ABI twin, natural tables from the engine - // cursor. The caller just repositioned explicitly, so both are - // freshly placed (no rescue moves here — this must stay a pure - // read; both-true genuinely means an empty cursor). - // Layout: [u8 bof][u8 eof][u32 recno] + optional [u32 reccount]. - // The count rides only when the engine table resolved (in-memory - // record_count, no disk refresh — same trust as the client's - // rec_count cache; the wire GetRecordCount keeps its refresh for - // callers that need multiuser-fresh). Length-gated on the client. - UNSIGNED16 b = 1, e = 1; - UNSIGNED32 rn = 0; - openads::engine::Table* eng = nullptr; - if (ordered_tables_.count(id) != 0) { - if (ADSHANDLE h = ensure_abi_handle(id); h != 0) { - AdsAtBOF(h, &b); - AdsAtEOF(h, &e); - AdsGetRecordNum(h, 0, &rn); - } - if (auto eit = tbls_.find(id); - eit != tbls_.end() && sess_conn_ != nullptr) { - eng = sess_conn_->lookup_table(eit->second); - } - } else if (auto eit = tbls_.find(id); - eit != tbls_.end() && sess_conn_ != nullptr) { - if (auto* tbl = sess_conn_->lookup_table(eit->second); - tbl != nullptr) { - eng = tbl; - b = tbl->bof() ? 1 : 0; - e = tbl->eof() ? 1 : 0; - rn = tbl->recno(); - } - } - reply.payload.push_back(b != 0 ? 1 : 0); - reply.payload.push_back(e != 0 ? 1 : 0); - reply.payload.push_back(static_cast( rn & 0xFFu)); - reply.payload.push_back(static_cast((rn >> 8) & 0xFFu)); - reply.payload.push_back(static_cast((rn >> 16) & 0xFFu)); - reply.payload.push_back(static_cast((rn >> 24) & 0xFFu)); - if (eng != nullptr) { - const std::uint32_t n = eng->record_count(); - reply.payload.push_back(static_cast( n & 0xFFu)); - reply.payload.push_back(static_cast((n >> 8) & 0xFFu)); - reply.payload.push_back(static_cast((n >> 16) & 0xFFu)); - reply.payload.push_back(static_cast((n >> 24) & 0xFFu)); - } -} - -// mtfix12 R1 (kCapNavBoundaryPair) — see session.h. The pair section: -// [u8 flags][u32 trailer_len][trailer][bound 6|10][u32 keycount?] -// flags bit 0x02 = pair bound carries reccount, bit 0x04 = keycount -// present (ordered tables; natural order derives count == reccount on -// the client). The trailer is pack_row_trailer output at lookahead -// depth 0 for the OPPOSITE boundary, read inside this same visit, so -// the client's adjacent opposite-boundary call applies it verbatim. -void Session::pack_boundary_pair(Frame& reply, std::uint32_t id, - int which, ADSHANDLE hord, - openads::engine::Table* tbl) { - const bool to_bottom = (which == 1); // certify the opposite end - std::uint8_t flags = 0; - Frame blob; - blob.opcode = reply.opcode; - Frame bnd; - bnd.opcode = reply.opcode; - UNSIGNED32 kc = 0; - bool granted = false; - if (hord != 0) { - // Ordered table: navigate the ABI twin (it carries the order - // and scope), restore the requested boundary exactly after. - UNSIGNED32 save_rec = 0; - UNSIGNED16 sb = 0, se = 0; - (void)AdsGetRecordNum(hord, 0, &save_rec); - (void)AdsAtBOF(hord, &sb); - (void)AdsAtEOF(hord, &se); - const bool empty_landing = (sb != 0 && se != 0); - const UNSIGNED32 grc = - to_bottom ? AdsGotoBottom(hord) : AdsGotoTop(hord); - if (grc == 0) { - pack_row_trailer(blob, id, 0); - pack_bound_trailer(bnd, id); - if (bnd.payload.size() >= 10) flags |= 0x02; - if (AdsGetKeyCount(hord, 0, &kc) == 0) flags |= 0x04; - granted = true; - } - if (empty_landing) { - (void)(which == 1 ? AdsGotoTop(hord) : AdsGotoBottom(hord)); - } else { - (void)AdsGotoRecord(hord, save_rec); - } - } else if (tbl != nullptr) { - const std::uint32_t save_rec = tbl->recno(); - const bool empty_landing = tbl->bof() && tbl->eof(); - auto gr = to_bottom ? tbl->goto_bottom() : tbl->goto_top(); - if (gr) { - pack_row_trailer(blob, id, 0); - pack_bound_trailer(bnd, id); - if (bnd.payload.size() >= 10) flags |= 0x02; - granted = true; - } - if (empty_landing) { - if (which == 1) (void)tbl->goto_top(); - else (void)tbl->goto_bottom(); - } else { - (void)tbl->goto_record(save_rec); - } - } - if (!granted) return; // client falls back to a plain second frame - reply.payload.push_back(flags); - const std::uint32_t tlen = - static_cast(blob.payload.size()); - reply.payload.push_back(static_cast( tlen & 0xFFu)); - reply.payload.push_back(static_cast((tlen >> 8) & 0xFFu)); - reply.payload.push_back(static_cast((tlen >> 16) & 0xFFu)); - reply.payload.push_back(static_cast((tlen >> 24) & 0xFFu)); - reply.payload.insert(reply.payload.end(), - blob.payload.begin(), blob.payload.end()); - reply.payload.insert(reply.payload.end(), - bnd.payload.begin(), bnd.payload.end()); - if ((flags & 0x04) != 0) { - reply.payload.push_back(static_cast( kc & 0xFFu)); - reply.payload.push_back(static_cast((kc >> 8) & 0xFFu)); - reply.payload.push_back(static_cast((kc >> 16) & 0xFFu)); - reply.payload.push_back(static_cast((kc >> 24) & 0xFFu)); - } -} - -// M12.22/M12.23 — read-ahead depth for one forward Skip. -// -// `hint` is what the client asked for via AdsCacheRecords, or -// kPrefetchDepthAuto when it never called it (and for any pre-M12.23 client, -// whose Skip frame carries no depth field at all). -// -// * explicit hint: honour it. The caller knows something we cannot infer -// from the access pattern — SAP's documented cases are "0 or 1 turns -// read-ahead off" (a batch loop that edits most records it visits, where -// every edit would dump the block anyway) and "aggressive = 100" (a -// one-directional sweep). Capped at kPrefetchDepthMax so one request -// can't become an unbounded server-side scan. -// * auto: ramp. One step per consecutive forward Skip on the table, -// 8 -> 16 -> 32 -> 64, then held. Anything that breaks the sequential run -// (reposition, write, order change) erases the entry, so the next run -// starts at the floor again — which is what keeps a one-off "seek a -// record and read it" from dragging a full block it will never look at. -std::uint16_t Session::next_lookahead(std::uint32_t id, std::uint16_t hint, - std::int8_t dir) { - if (!client_prefetch_ok_) return 0; - if (hint != kPrefetchDepthAuto) { - // SAP: "A usRecords value of 0 (or 1) effectively turns read-ahead - // record caching off." 1 means "just the current row", which is - // exactly a zero-length lookahead block. - if (hint <= 1) return 0; - return hint > kPrefetchDepthMax ? kPrefetchDepthMax : hint; - } - // RCB 07/15/2026: M12.25 — a direction reversal (PgDn then PgUp) is a fresh - // run, so restart the ramp at the floor. Otherwise the first backward block - // after a long forward scan would arrive at the ceiling and over-fetch the - // same way an unramped forward scan did. - if (auto dit = prefetch_run_dir_.find(id); - dit != prefetch_run_dir_.end() && dit->second != dir) { - prefetch_depth_.erase(id); - } - prefetch_run_dir_[id] = dir; - auto [it, fresh] = prefetch_depth_.try_emplace(id, kPrefetchFloor); - if (!fresh) { - std::uint32_t grown = static_cast(it->second) * 2u; - it->second = static_cast( - grown > kPrefetchCeil ? kPrefetchCeil : grown); - } - return it->second; -} - -void Session::reset_lookahead(std::uint32_t id) { - prefetch_depth_.erase(id); - prefetch_run_dir_.erase(id); -} - -namespace { - -// RCB 07/14/2026: opcodes that end a sequential read-ahead run on the table -// they name. All of them either move the cursor somewhere the block was not -// read from, or change what the rows mean (order / scope / filter), or write. -// -// Why a central list instead of a reset_lookahead() call inside each handler: -// dispatch() can break the run in ONE place rather than in twenty handlers, and -// twenty hand-placed calls is twenty chances to forget one when a new opcode -// lands. The leading u32 of the payload names the affected object — but see -// break_key_is_index_id() below: for the index-scoped ops that u32 is an INDEX -// id, not a table id, and has to be resolved before it means anything to -// prefetch_depth_ (which is keyed by table). -// -// Forgetting an opcode here (or mis-keying one) is cheap by construction, which -// is the property that makes the central approach safe: it is a TUNING bug, -// never a correctness bug. The lookahead rows are always walked fresh from the -// post-op cursor, so the worst case is a Skip that carries a deeper block than -// it should have. -bool breaks_prefetch_run(Opcode op) { - switch (op) { - case Opcode::GotoTop: - case Opcode::GotoBottom: - case Opcode::GotoRecord: - case Opcode::RefreshRecord: - case Opcode::Seek: - case Opcode::SeekLast: - case Opcode::SkipUnique: - case Opcode::SetOrder: - case Opcode::SetOrderByName: - case Opcode::SetScope: - case Opcode::ClearScope: - case Opcode::SetAOF: - case Opcode::CustomizeAOF: - case Opcode::SetField: - case Opcode::SetRecord: - case Opcode::AppendBlank: - case Opcode::DeleteRecord: - case Opcode::RecallRecord: - case Opcode::PackTable: - case Opcode::ZapTable: - case Opcode::CloseTable: - return true; - default: - return false; - } -} - -// RCB 07/15/2026: of the run-enders above, these carry an INDEX id as their -// leading u32, not a table id — their handlers resolve it through index_h_ / -// index_table_ (see the Seek / SkipUnique / SetScope / ClearScope cases). The -// ramp map prefetch_depth_ is keyed by TABLE, so dispatch() must translate -// index->table before resetting, or the reset silently misses: an index id is -// never a live table id, so reset_lookahead() would just erase an absent key -// and the real table's ramp would keep climbing across a seek — defeating the -// very "seek then read one, don't over-fetch" case the ramp exists for. -// SetOrder / SetOrderByName are absent on purpose: those DO lead with a table -// id (see their handlers). -bool break_key_is_index_id(Opcode op) { - switch (op) { - case Opcode::Seek: - case Opcode::SeekLast: - case Opcode::SkipUnique: - case Opcode::SetScope: - case Opcode::ClearScope: - return true; - default: - return false; - } -} - -} // namespace - -// M12.16 — re-position the engine cursor to match the ABI -// cursor after an index op that moves it (Seek / SeekLast). -// No-op when the table is a cursor or has no engine handle. -void Session::sync_engine_cursor(std::uint32_t id) { - WTRACE("[wire] sync_engine_cursor id=%u enter\n", id); - if (cursor_tbls_.count(id)) return; - auto eit = tbls_.find(id); - if (eit == tbls_.end() || !sess_conn_) return; - auto* tbl = sess_conn_->lookup_table(eit->second); - if (!tbl) return; - ADSHANDLE h = 0; - if (auto hit = tbls_h_.find(id); hit != tbls_h_.end()) { - h = hit->second; - } else { return; } - // Appends / writes often go through the parallel ABI handle (where - // CreateIndex / OpenIndex bound the bag). Re-read the header so the - // engine Table sees the new record_count before GotoRecord. - if (auto* drv = tbl->driver()) { - drv->refresh_record_count_from_disk(); - drv->invalidate_read_cache(); - } - UNSIGNED32 rn = 0; - AdsGetRecordNum(h, 0, &rn); - if (rn == 0) return; - // A twin in Limbo (stale/empty order) reports a phantom recno - // (record_count + 1). Dragging the engine cursor there poisons every - // later pack_row_trailer fallback ("engine recno=16" on a 15-record - // table). Skip the sync: the engine keeps its last known-good position. - // NOTE: recno == record_count+1 with eof-only is a LEGITIMATE past-end - // position (scope end) and must still sync — only Limbo (bof&&eof) is - // the poison case. - { - UNSIGNED16 lb = 0, le = 0; - AdsAtBOF(h, &lb); AdsAtEOF(h, &le); - if (lb && le) { - WTRACE("[wire] sync_engine_cursor id=%u twin in Limbo, skip\n", id); - return; - } - } - (void)tbl->goto_record(rn); -} - -// Fused nav+order path (SetOrder+GotoTop in one frame): install index -// iid as table tid's controlling order. Factored out of the SetOrder -// handler so GotoTop/GotoBottom can take an order section without -// duplicating the self-heal logic. Returns 0 on success, else the ACE -// code for the caller to format. -UNSIGNED32 Session::install_table_order(std::uint32_t tid, - std::uint32_t iid) { - ADSHANDLE ht = ensure_abi_handle(tid); - if (ht == 0) return openads::AE_NO_FILE_FOUND; - // iid 0 = natural order (rddads DbSetOrder(0)). A missing - // map entry used to err() with default 5000 and poison B_BIG. - if (iid == 0) { - UNSIGNED32 rrc = AdsSetIndexOrder(ht, nullptr); - if (rrc != 0) return rrc; - ordered_tables_.erase(tid); - sync_engine_cursor(tid); - return openads::AE_SUCCESS; - } - auto iit = index_h_.find(iid); - if (iit == index_h_.end()) { - return openads::AE_NO_FILE_FOUND; - } - UNSIGNED32 rrc = AdsSetIndexOrderByHandle(ht, iit->second); - if (rrc != 0) { - // Self-heal (B_BIG storm 700): a server-side silent-overwrite - // CREATE INDEX or AdsCloseAllIndexes re-creates the ABI binding - // under a fresh handle while index_h_[iid] still holds the dead - // one -> 5000 "index not bound to table". Re-resolve the tag's - // CURRENT binding and retry once; on success refresh index_h_. - std::string tag; - if (auto tit = index_tag_.find(iid); tit != index_tag_.end()) - tag = tit->second; - if (!tag.empty()) { - std::vector tb(tag.size() + 1); - std::memcpy(tb.data(), tag.data(), tag.size()); - ADSHANDLE h_fresh = 0; - if (AdsGetIndexHandle(ht, tb.data(), &h_fresh) == 0 && - h_fresh != 0 && h_fresh != iit->second) { - WTRACE("[wire] SetOrder iid=%u stale handle, healed via tag '%s'\n", - iid, tag.c_str()); - rrc = AdsSetIndexOrderByHandle(ht, h_fresh); - if (rrc == 0) iit->second = h_fresh; - } - } - } - if (rrc != 0) return rrc; - ordered_tables_.insert(tid); - sync_engine_cursor(tid); - return openads::AE_SUCCESS; -} - -Session::FieldWriteResult Session::write_fields(std::uint32_t id, - openads::engine::Table* tbl, - const std::vector>& pairs) { - FieldWriteResult out; - if (tbl == nullptr) { out.code = openads::AE_INTERNAL_ERROR; return out; } - // When indexes live on the parallel ABI handle, write through - // AdsSetString so the bag is maintained (see AppendBlank). - if (auto hit = tbls_h_.find(id); hit != tbls_h_.end()) { - // The engine cursor is authoritative: navigation opcodes move only - // the engine table, the twin syncs lazily. Land the twin on the - // same row ONCE up front (not per field) — otherwise the 5035 - // write guard checks the wrong row's lock. - UNSIGNED32 cur = 0; - AdsGetRecordNum(hit->second, 0, &cur); - UNSIGNED32 eng = tbl->recno(); - if (eng != 0 && cur != eng) { - (void)AdsGotoRecord(hit->second, eng); - } - for (const auto& [fname, val] : pairs) { - std::vector fn(fname.begin(), fname.end()); - fn.push_back(0); - std::vector vv(val.begin(), val.end()); - // AdsSetString takes length; do not require NUL in value. - UNSIGNED32 rrc = AdsSetString( - hit->second, fn.data(), - vv.empty() ? reinterpret_cast(const_cast("")) - : vv.data(), - static_cast(val.size())); - if (rrc != 0) { out.code = rrc; return out; } - } - // Storm fix — no per-field flush; the record is made durable by - // DbCommit→FlushTable at the end of the append. - sync_engine_cursor(id); - return out; - } - - for (const auto& [fname, val] : pairs) { - std::int32_t fi = tbl->field_index(fname); - if (fi < 0) { out.column_missing = true; return out; } - const auto& fdesc = - tbl->field_descriptor(static_cast(fi)); - util::Result r; - auto fi_u = static_cast(fi); - switch (fdesc.type) { - case drivers::DbfFieldType::Logical: { - bool lv = !val.empty() && - (val[0] == '1' || val[0] == 'T' || val[0] == 't' || - val[0] == 'Y' || val[0] == 'y'); - r = tbl->set_field(fi_u, lv); - break; - } - case drivers::DbfFieldType::Integer: - case drivers::DbfFieldType::AutoInc: - case drivers::DbfFieldType::Double: - case drivers::DbfFieldType::ShortInt: - case drivers::DbfFieldType::Currency: - case drivers::DbfFieldType::AdtMoney: - case drivers::DbfFieldType::Time: - case drivers::DbfFieldType::Numeric: - try { - r = tbl->set_field(fi_u, std::stod(val)); - } catch (...) { - r = tbl->set_field(fi_u, val); - } - break; - default: - r = tbl->set_field(fi_u, val); - break; - } - if (!r) { out.code = 5035; return out; } - } - return out; -} - -void Session::append_open_warm_sections(std::vector& out, - std::uint32_t id, - openads::engine::Table* tbl) { - namespace Sec = openads::network::OpenTableAckSections; - struct Tlv { std::uint8_t tag = 0; std::vector bytes; }; - std::vector secs; - if (tbl != nullptr) { - // 1. schema — byte-identical to the DescribeTableAck engine - // branch (same mapper, same u8 name_LEN + u16 type + u32 length - // + u16 decimals layout the client already parses). - Tlv sch; - sch.tag = Sec::kSchema; - auto nf = static_cast(tbl->field_count()); - sch.bytes.push_back(static_cast( nf & 0xFFu)); - sch.bytes.push_back(static_cast((nf >> 8) & 0xFFu)); - for (std::uint16_t i = 0; i < nf; ++i) { - const auto& fd = tbl->field_descriptor(i); - auto name_len = static_cast(fd.name.size() & 0xFFu); - sch.bytes.push_back(name_len); - sch.bytes.insert(sch.bytes.end(), - fd.name.begin(), - fd.name.begin() + name_len); - auto ftype = ads_type_for_wire(fd.type); - sch.bytes.push_back(static_cast( ftype & 0xFFu)); - sch.bytes.push_back(static_cast((ftype >> 8) & 0xFFu)); - std::uint32_t flen = fd.length; - std::uint16_t fdec = fd.decimals; - sch.bytes.push_back(static_cast( flen & 0xFFu)); - sch.bytes.push_back(static_cast((flen >> 8) & 0xFFu)); - sch.bytes.push_back(static_cast((flen >> 16) & 0xFFu)); - sch.bytes.push_back(static_cast((flen >> 24) & 0xFFu)); - sch.bytes.push_back(static_cast( fdec & 0xFFu)); - sch.bytes.push_back(static_cast((fdec >> 8) & 0xFFu)); - } - secs.push_back(std::move(sch)); - // 2. first row — the exact positioning + trailer + lookahead an - // explicit GotoTop would have produced (same goto_top call the - // GotoTop handler issues on the natural-order path, same pack - // machinery incl. the prefetch-cap gate inside next_lookahead). - // The return is ignored exactly like there: an empty table packs - // has_row=0, which the client reads as unpositioned. - (void)tbl->goto_top(); - Frame tmp; - pack_row_trailer(tmp, id, next_lookahead(id)); - Tlv row; - row.tag = Sec::kFirstRow; - row.bytes = std::move(tmp.payload); - secs.push_back(std::move(row)); - } - out.push_back(static_cast(secs.size())); - for (auto& s : secs) { - out.push_back(s.tag); - std::uint32_t n = static_cast(s.bytes.size()); - out.push_back(static_cast( n & 0xFFu)); - out.push_back(static_cast((n >> 8) & 0xFFu)); - out.push_back(static_cast((n >> 16) & 0xFFu)); - out.push_back(static_cast((n >> 24) & 0xFFu)); - out.insert(out.end(), s.bytes.begin(), s.bytes.end()); - } -} - -DispatchResult Session::dispatch(const Frame& f) { - Frame reply; - WTRACE("[wire] op=%u\n", (unsigned)f.opcode); - if (wire_trace_on() && f.payload.size() >= 4) { - std::uint32_t tid0 = read_u32_le(f.payload.data()); - if (ordered_tables_.count(tid0)) { - if (auto hit0 = tbls_h_.find(tid0); hit0 != tbls_h_.end()) { - UNSIGNED16 b0 = 9, e0 = 9; - AdsAtBOF(hit0->second, &b0); AdsAtEOF(hit0->second, &e0); - WTRACE("[wire] op=%u id=%u twin-in(bof=%u eof=%u)\n", - (unsigned)f.opcode, tid0, (unsigned)b0, (unsigned)e0); - } - } - } - // M12.22 — break the read-ahead run before the handler runs, in one place - // instead of a reset call in each of ~20 handlers. - // - // RCB 07/15/2026: the leading u32 is the affected object's id, but for the - // index-scoped ops (break_key_is_index_id) that is an INDEX id and has to be - // resolved to its table first — prefetch_depth_ is keyed by table. Without - // this, a Seek/SetScope/ClearScope on a table left its ramp climbing (an - // index id is never a live table id, so the reset hit an absent key and did - // nothing). If an index id can't be resolved, there is no table to reset. - if (breaks_prefetch_run(f.opcode) && f.payload.size() >= 4) { - std::uint32_t id = read_u32_le(f.payload.data()); - if (break_key_is_index_id(f.opcode)) { - auto it = index_table_.find(id); - id = (it != index_table_.end()) ? it->second : 0; - } - if (id != 0) reset_lookahead(id); - } - switch (f.opcode) { - case Opcode::Hello: { - reply.opcode = Opcode::HelloAck; - // Real build version, not a hardcoded protocol string: this is - // the only way a client (or a support engineer) can prove which - // serverd binary is actually answering — "the fix didn't help" - // reports keep turning out to be an old serverd still running. - // Any pre-1.8.14 server answers the literal "openads/0.3.2". -#ifdef OPENADS_VERSION_STR - std::string v = "openads/" OPENADS_VERSION_STR; -#else - std::string v = "openads/unknown"; -#endif - reply.payload.assign(v.begin(), v.end()); - break; - } - case Opcode::Connect: { - // M12.9 — Connect payload format: - // [u16 dlen][dir][u16 ulen][user][u16 plen][password] - // All three lengths are required; user/password may be - // empty when the server doesn't require auth. - const auto& pl = f.payload; - std::string dir, user, pw; - std::size_t p = 0; - auto readlen = [&](std::uint16_t& out)->bool { - if (p + 2 > pl.size()) return false; - out = static_cast( - static_cast(pl[p]) | - (static_cast(pl[p+1]) << 8)); - p += 2; return true; - }; - auto readstr = [&](std::string& out, std::uint16_t n)->bool { - if (p + n > pl.size()) return false; - out.assign(reinterpret_cast(pl.data() + p), n); - p += n; return true; - }; - std::uint16_t dl=0, ul=0, pwl=0; - if (!readlen(dl) || !readstr(dir, dl) || - !readlen(ul) || !readstr(user, ul) || - !readlen(pwl) || !readstr(pw, pwl)) { - reply = err("Connect: bad payload"); - break; - } - // M12.21 option C — optional trailing [u32 LE caps]. - // Absent for pre-M12.21 clients (p == pl.size()). - if (p + 4 <= pl.size()) { - std::uint32_t caps = - static_cast(pl[p]) | - (static_cast(pl[p + 1]) << 8) | - (static_cast(pl[p + 2]) << 16) | - (static_cast(pl[p + 3]) << 24); - client_prefetch_ok_ = - (caps & openads::network::kCapPrefetchConsume) != 0; - // RCB 07/15/2026: M12.25 — backward (PgUp) prefetch is a - // separate opt-in; a client that only set kCapPrefetchConsume - // cannot drain a backward block (see kCapPrefetchBackward). - client_prefetch_back_ok_ = - (caps & openads::network::kCapPrefetchBackward) != 0; - // M12.x — client sends [u16 mode] prefix on OpenTable. - client_open_table_mode_ok_ = - (caps & openads::network::kCapOpenTableMode) != 0; - } - if (srv_->require_auth()) { - std::lock_guard clk(srv_->creds_mu_); - auto cit = srv_->creds_.find(user); - if (cit == srv_->creds_.end() || cit->second != pw) { - reply = err("Connect: authentication failed", - openads::AE_LOGIN_FAILED); - break; - } - } - // Resolve client paths under one of the server's data roots and - // reject traversal attempts (e.g. "../../outside"). --data (or - // the ini `data=` key) may list several roots separated by ';' - // so one server can serve DDs living under different - // drives/shares; the client path just has to fall under any one - // of them. - // - // Multi-port: when the client connected on an extra port, use - // that port's data_dir instead of the global one. - // - // --legacy-paths: route through platform::resolve_client_path - // instead — case-insensitive, drive-letter-ignoring prefix - // strip ("C:/TEMP" maps onto root "c:\temp"), drive fold for - // foreign absolute paths ("E:\CLIENT" -> "/CLIENT"), and - // an empty/drive-root dir maps to the first root itself, so a - // legacy ERP connect string needs no server-side spelling. - const std::string& effective_data_dir = - default_data_dir_.empty() ? srv_->data_dir_ : default_data_dir_; - std::string resolved = dir; - if (!effective_data_dir.empty()) { - auto roots = openads::platform::split_data_roots(effective_data_dir); - std::optional jail; - if (srv_->legacy_paths()) { - jail = openads::platform::resolve_client_path(roots, dir); - } else { - jail = openads::platform::resolve_under_any_root(roots, dir); - } - if (!jail) { - reply = err("Connect: path outside data directory", - openads::AE_ACCESS_DENIED); - break; - } - resolved = *jail; - } - auto co = openads::session::Connection::open(resolved); - if (!co) { - reply = err("Connect: connection open failed", - static_cast(co.error().code)); - break; - } - // RCB 06/30/2026: Remote Connect opens the engine Connection - // directly, bypassing local AdsConnect60. Mirror DD login handling - // here so the session username, permissions, and later lazy ABI - // connection all represent the same authenticated DD user. - if (co.value().has_dd()) { - auto* dd = co.value().dd(); - // Logins-disabled: a stricter, all-connections-rejected gate - // than LOG_IN_REQUIRED below — even a valid user/password - // normally can't connect while this is set. Reads the STABLE - // storage key "prop_16" (SP_MODIFYDATABASE numbering); the - // SAP ABI id is ADS_DD_LOGINS_DISABLED (113), translated by - // ace_exports' db_prop_storage_key. - // - // Admin bypass: without this, setting the flag would be a - // one-way door — nobody, not even the admin trying to undo - // it, could ever reconnect to flip it back off. See - // mgmt::is_admin_bypass / mgmt::kAdminBypassUser, mirrored - // in ace_exports.cpp's AdsConnect for local connections. - std::string logins_disabled = dd->get_db_property("prop_16"); - bool disabled_raw_zero = (logins_disabled.size() >= 2 && - static_cast(logins_disabled[0]) == 0 && - static_cast(logins_disabled[1]) == 0); - bool logins_are_disabled = (!logins_disabled.empty() && - logins_disabled != "0" && logins_disabled != "False" && - !disabled_raw_zero); - if (logins_are_disabled && - !openads::mgmt::is_admin_bypass(dd, user, pw)) { - reply = err("Connect: logins are disabled for this dictionary", - openads::AE_LOGIN_FAILED); - break; - } - - std::string login_req = dd->get_db_property("prop_5"); - bool is_raw_zero = (login_req.size() >= 2 && - static_cast(login_req[0]) == 0 && - static_cast(login_req[1]) == 0); - bool require_login = (!login_req.empty() && - login_req != "0" && login_req != "False" && !is_raw_zero); - if (require_login) { - if (user.empty()) { - reply = err("Connect: login required but no username supplied", - openads::AE_LOGIN_FAILED); - break; - } - if (!dd->has_user(user)) { - reply = err("Connect: unknown user", - openads::AE_LOGIN_FAILED); - break; - } - std::string stored = dd->get_user_property(user, "prop_1101"); - if (stored != pw) { - reply = err("Connect: invalid password", - openads::AE_LOGIN_FAILED); - break; - } - } - if (!user.empty()) { - co.value().set_username(user); - if (dd->has_any_acl()) dd->build_perm_cache(user); - } - } - sess_conn_ = std::make_unique( - std::move(co).value()); - // M12.34 — propagate server identity for replication loop prevention. - sess_conn_->set_origin_id(srv_->server_id()); - // --legacy-paths: the session's table opens must resolve - // client-absolute paths the same way the Connect jail above - // accepted the connect dir. - sess_conn_->set_legacy_paths(srv_->legacy_paths()); - sess_conn_->set_remote_server(true); - session_user_ = user; - session_password_ = pw; - srv_->set_session_user(sid_, user, dir); - reply.opcode = Opcode::ConnectAck; - std::string ackmsg = "connected:" + dir; - reply.payload.assign(ackmsg.begin(), ackmsg.end()); - // Server caps echo (write coalescing): trailing [u32 LE]. - // Old clients ignore the ack payload entirely (opcode-only - // check), so this is backward compatible; new clients match - // the "connected:" echo against their requested dir - // before trusting the trailing word (see connect_with_transport). - { - const std::uint32_t scaps = - openads::network::kCapSetFieldsBatch | - openads::network::kCapFlushInCloseAll | - openads::network::kCapNavOrderFuse | - openads::network::kCapFlushTableDurable | - openads::network::kCapNavBoundaryPair; - reply.payload.push_back( - static_cast( scaps & 0xFFu)); - reply.payload.push_back( - static_cast((scaps >> 8) & 0xFFu)); - reply.payload.push_back( - static_cast((scaps >> 16) & 0xFFu)); - reply.payload.push_back( - static_cast((scaps >> 24) & 0xFFu)); - } - break; - } - case Opcode::Disconnect: { - cleanup(); - return { std::nullopt, true }; - } - case Opcode::BeginTransaction: { - if (!sess_conn_) { - reply = err("BeginTransaction: not connected", - openads::AE_NO_CONNECTION); - break; - } - auto r = sess_conn_->begin_tx(); - if (!r) { - reply = err("BeginTransaction: " + r.error().message, - static_cast(r.error().code)); - break; - } - reply.opcode = Opcode::BeginTransactionAck; - break; - } - case Opcode::CommitTransaction: { - if (!sess_conn_) { - reply = err("CommitTransaction: not connected", - openads::AE_NO_CONNECTION); - break; - } - auto r = sess_conn_->commit_tx(); - if (!r) { - reply = err("CommitTransaction: " + r.error().message, - static_cast(r.error().code)); - break; - } - reply.opcode = Opcode::CommitTransactionAck; - break; - } - case Opcode::RollbackTransaction: { - if (!sess_conn_) { - reply = err("RollbackTransaction: not connected", - openads::AE_NO_CONNECTION); - break; - } - auto r = sess_conn_->rollback_tx(); - if (!r) { - reply = err("RollbackTransaction: " + r.error().message, - static_cast(r.error().code)); - break; - } - reply.opcode = Opcode::RollbackTransactionAck; - break; - } - case Opcode::FindRecord: { - if (!sess_conn_) { - reply = err("FindRecord: not connected", - openads::AE_NO_CONNECTION); - break; - } - if (f.payload.size() < 6) { - reply = err("FindRecord: bad payload"); break; - } - { - std::uint32_t id = read_u32_le(f.payload.data()); - std::uint16_t nident = static_cast( - static_cast(f.payload[4]) | - (static_cast(f.payload[5]) << 8)); - auto it = tbls_.find(id); - if (it == tbls_.end()) { - reply = err("FindRecord: bad table id"); break; - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { - reply = err("FindRecord: lookup failed"); break; - } - std::size_t off = 6; - std::vector> ident; - for (std::uint16_t i = 0; i < nident; ++i) { - if (off + 4 > f.payload.size()) { - reply = err("FindRecord: truncated identity"); break; - } - std::uint16_t nlen = static_cast( - static_cast(f.payload[off]) | - (static_cast(f.payload[off + 1]) << 8)); - off += 2; - if (off + nlen > f.payload.size()) { - reply = err("FindRecord: truncated name"); break; - } - std::string name(f.payload.begin() + - static_cast(off), - f.payload.begin() + - static_cast(off + nlen)); - off += nlen; - if (off + 2 > f.payload.size()) { - reply = err("FindRecord: truncated vlen"); break; - } - std::uint16_t vlen = static_cast( - static_cast(f.payload[off]) | - (static_cast(f.payload[off + 1]) << 8)); - off += 2; - if (off + vlen > f.payload.size()) { - reply = err("FindRecord: truncated value"); break; - } - std::string val(f.payload.begin() + static_cast(off), - f.payload.begin() + static_cast(off + vlen)); - off += vlen; - ident.emplace_back(std::move(name), std::move(val)); - } - if (reply.opcode == Opcode::Error) break; - // Sequential scan matching local find_by_identity logic. - if (auto gr = tbl->goto_top(); !gr) { - reply.opcode = Opcode::FindRecordAck; - reply.payload.assign(4, 0); - break; - } - std::uint32_t found = 0; - while (!tbl->eof()) { - bool match = true; - for (auto& [n, v] : ident) { - auto idx = tbl->field_index(n); - if (idx < 0) { match = false; break; } - auto val = tbl->read_field(static_cast(idx)); - if (!val) { match = false; break; } - if (val.value().as_string != v) { match = false; break; } - } - if (match) { found = tbl->recno(); break; } - if (auto sr = tbl->skip(1); !sr) break; - } - reply.opcode = Opcode::FindRecordAck; - reply.payload.resize(4); - reply.payload[0] = static_cast( found & 0xFFu); - reply.payload[1] = static_cast((found >> 8) & 0xFFu); - reply.payload[2] = static_cast((found >> 16) & 0xFFu); - reply.payload[3] = static_cast((found >> 24) & 0xFFu); - } - break; - } - case Opcode::OpenTable: { - if (!sess_conn_) { - reply = err("OpenTable: not connected", - openads::AE_NO_CONNECTION); - break; - } - // Iterator-based ctor: payload.data() may be nullptr when empty, - // and std::string(nullptr, 0) is UB. - std::string rel; - auto open_mode = openads::engine::OpenMode::Shared; - if (client_open_table_mode_ok_ && f.payload.size() >= 2) { - // M12.x extended payload: [u16 mode][table_name_bytes] - std::uint16_t mode_u16 = static_cast( - static_cast(f.payload[0]) | - (static_cast(f.payload[1]) << 8)); - open_mode = static_cast(mode_u16); - rel.assign(f.payload.begin() + 2, f.payload.end()); - } else { - rel.assign(f.payload.begin(), f.payload.end()); - } - // M12.33 — strip a full tcp:// URI prefix that legacy Delphi - // TAdsTable components embed in the table name. arc32 sends - // "tcp://host:port/C:/data/dir\table.dbf" instead of bare - // "table.dbf"; the server must strip the URI to resolve - // relative to its data root. - if (rel.size() > 8 && - (rel.rfind("tcp://", 0) == 0 || rel.rfind("TCP://", 0) == 0)) { - auto last_sep = rel.find_last_of("/\\"); - if (last_sep != std::string::npos && last_sep > 6) { - std::string stripped = rel.substr(last_sep + 1); - if (!stripped.empty()) rel = std::move(stripped); - } - } - auto th = sess_conn_->open_table(rel, - openads::engine::TableType::Cdx, - open_mode); - if (!th) { - std::fprintf(stderr, "[srv] OpenTable FAILED rel='%s' code=%d msg='%s'\n", - rel.c_str(), th.error().code, - th.error().message.c_str()); - reply = err("OpenTable: open failed", - static_cast(th.error().code)); - break; - } - std::uint32_t id = next_id_++; - // mtfix11 - enforce ADS_EXCLUSIVE across wire sessions (SAP - // semantics; the drivers alone treat Exclusive as a plain - // open, so a reindex/pack exclusive hold never kept a second - // instance's opens out). Open-then-register keeps the - // registry key canonical (the engine's resolved path); a - // denied open is closed again and answered with 7040 - // AE_FILE_IN_USE - the same code this codebase maps Win32 - // sharing violations to. - if (auto* tbl = sess_conn_->lookup_table(th.value())) { - const std::string& canon = tbl->path(); - const bool excl = - (open_mode == openads::engine::OpenMode::Exclusive); - if (!canon.empty()) { - if (!srv_->try_register_open(sid_, canon, excl)) { - sess_conn_->close_table(th.value()); - reply = err("OpenTable: table in use exclusively", - 7040); - break; - } - tbl_open_reg_.emplace(id, std::make_pair(canon, excl)); - } - } - tbls_.emplace(id, th.value()); - tbl_open_paths_.emplace(id, rel); - srv_->add_session_table(sid_, +1, rel); - reply.opcode = Opcode::OpenTableAck; - write_u32_le(id, reply.payload); - // M-AOF.6 mirror: detect the production CDX/ADI on the server - // filesystem and include it in the ack so the client can skip - // the speculative AdsOpenIndex round-trip. We only STAT the - // file here — no ABI calls that could deadlock in the - // embedded-server case (the client holds s.mu while waiting - // for this ack). - { - auto* tbl = sess_conn_->lookup_table(th.value()); - std::string bag; - if (tbl) { - std::filesystem::path tp(tbl->path()); - std::string ext = tp.extension().string(); - for (auto& c : ext) - c = static_cast(std::tolower( - static_cast(c))); - std::vector bag_leaves; - if (ext == ".dbf") bag_leaves = {tp.stem().string() + ".cdx", - tp.stem().string() + ".z01"}; - else if (ext == ".adt") bag_leaves = {tp.stem().string() + ".adi"}; - for (const auto& bag_leaf : bag_leaves) { - std::error_code ec; - std::filesystem::path bagp = tp.parent_path() / bag_leaf; - if (!std::filesystem::exists(bagp, ec)) { - // Case-insensitive fallback (.CDX vs .cdx, .Z01 vs .z01). - std::string ci = openads::platform:: - resolve_case_insensitive(bagp.string()); - if (!ci.empty()) - bagp = std::filesystem::path(ci); - } - if (std::filesystem::exists(bagp, ec)) { - // Send the bag path relative to the connection - // root so subdirectory tables round-trip the - // correct production index (basename-only made - // AdsOpenIndex miss when table_dir != data root). - // NOTE: Vouch/ERP apps keep the CDX-format - // production bag as .z01 instead of - // .cdx — without this the client falls - // back to a speculative .cdx OpenIndex - // that always 5018s + writes ads_err.dbf. - bag = bag_leaf; - std::filesystem::path base(sess_conn_->data_dir()); - auto bag_rel = std::filesystem::relative(bagp, base, ec); - if (!ec && !bag_rel.empty() && bag_rel != ".") { - bag = bag_rel.generic_string(); - } - break; - } - } - } - // Always emit the bag field (empty when absent): old - // clients parse it the same way, and the sections below - // need a fixed anchor (see wire.h OpenTableAckSections). - { - auto bn = static_cast(bag.size()); - reply.payload.push_back( - static_cast( bn & 0xFFu)); - reply.payload.push_back( - static_cast((bn >> 8) & 0xFFu)); - reply.payload.insert(reply.payload.end(), - bag.begin(), bag.end()); - } - append_open_warm_sections(reply.payload, id, tbl); - } - break; - } - case Opcode::CloseTable: { - if (f.payload.size() < 4) { reply = err("CloseTable: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - auto cit = cursor_tbls_.find(id); - if (cit != cursor_tbls_.end()) { - // Drop the cached schema with the handle: AdsCloseTable frees - // the ADSHANDLE, and a later AdsOpenTable can hand the same - // value back for a different table. - abi_schema_.erase(cit->second); - (void)AdsCloseTable(cit->second); - cursor_tbls_.erase(cit); - srv_->add_session_table(sid_, -1); - reply.opcode = Opcode::CloseTableAck; - break; - } - auto it = tbls_.find(id); - if (it != tbls_.end()) { - sess_conn_->close_table(it->second); - tbls_.erase(it); - if (auto rit = tbl_open_reg_.find(id); - rit != tbl_open_reg_.end()) { - srv_->unregister_open(sid_, rit->second.first, - rit->second.second); - tbl_open_reg_.erase(rit); - } - std::string tname; - if (auto pit = tbl_open_paths_.find(id); pit != tbl_open_paths_.end()) - tname = pit->second; - srv_->add_session_table(sid_, -1, tname); - } - tbl_open_paths_.erase(id); - if (auto hit = tbls_h_.find(id); hit != tbls_h_.end()) { - abi_schema_.erase(hit->second); - // Close the shadow ABI handle, not just the map entry. - // Erasing alone leaks a full local open of the same - // .dbf/.cdx/.fpt until the session disconnects, so the - // server kept the files open after the client closed the - // table — any app that erases/renames/reopens-exclusive - // its work files right after closing them then blocks on - // files "in use" for as long as the connection lives. - (void)AdsCloseTable(hit->second); - tbls_h_.erase(hit); - } - // Index ids resolved through the shadow handle died with it - // (AdsCloseTable purges the table's index bindings); drop the - // session's entries so a stale id can't be re-used. - for (auto iit = index_table_.begin(); iit != index_table_.end(); ) { - if (iit->second == id) { - index_h_.erase(iit->first); - index_tag_.erase(iit->first); - iit = index_table_.erase(iit); - } else { - ++iit; - } - } - ordered_tables_.erase(id); - reply.opcode = Opcode::CloseTableAck; - break; - } - case Opcode::GotoTop: { - if (f.payload.size() < 4) { reply = err("GotoTop: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { - (void)AdsGotoTop(cit->second); - reply.opcode = Opcode::GotoTopAck; - pack_row_trailer(reply, id); - break; - } - // Fused nav+order: trailing [u8 0x01][u32 order_id] after - // the optional depth hint installs the order before - // navigating, collapsing SetOrder+GotoTop into one frame. - // Length-gated (old clients stop at byte 6); cursor tables - // above ignore it (their orders are query-fixed). - // mtfix12 R1: byte 6 is a flags byte on new clients — - // bit 0x01 fused order section (kCapNavOrderFuse, same - // wire shape as before: old clients send exactly 0x01), - // bit 0x02 boundary-pair request (kCapNavBoundaryPair). - bool fused_order = false; - bool want_pair = false; - if (f.payload.size() >= 7) { - const std::uint8_t fl = f.payload[6]; - want_pair = (fl & 0x02) != 0; - if ((fl & 0x01) != 0 && f.payload.size() >= 11) { - std::uint32_t oiid = read_u32_le(f.payload.data() + 7); - UNSIGNED32 oorc = install_table_order(id, oiid); - if (oorc != 0) { reply = err("SetOrder", oorc); break; } - fused_order = true; - } - } - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("GotoTop: bad table id"); break; - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("GotoTop: lookup failed"); break; } - // Ordered: navigate the ABI handle (it carries the order) and - // mirror the position onto the engine cursor pack_row_trailer - // reads from. Natural order: engine table directly. - ADSHANDLE hord = - ordered_tables_.count(id) ? ensure_abi_handle(id) : 0; - WTRACE("[wire] GotoTop id=%u hord=%llu\n", id, (unsigned long long)hord); - if (hord != 0) { - (void)AdsGotoTop(hord); - } else { - (void)tbl->goto_top(); - } - reply.opcode = Opcode::GotoTopAck; - // RCB 07/14/2026: M12.24 — warm the first page. A GotoTop is about - // as strong a "I am about to walk this table" signal as exists (a - // browse painting its first screen, or a scan loop starting), yet - // its ack used to carry the current row and nothing else, so the - // very first Skip after it was always cold and always cost a - // round-trip. Send the block with the row. - // - // Only GotoTop, deliberately. GotoBottom gets nothing: the block is - // a FORWARD walk and there is nothing after the last record — a - // warm GotoBottom needs BACKWARD lookahead, which is its own piece - // of work (P5). GotoRecord/Seek get nothing either, because - // relation navigation drives them once per parent row and would - // drag a child block over the wire every time for nothing. - // - // Depth comes from next_lookahead() like any other block, so it - // starts at the floor and honours AdsCacheRecords (an app that - // turned read-ahead off must not get a block dumped on it here). - // dispatch() has already reset the run for this table (GotoTop is - // in breaks_prefetch_run), so this is a fresh run at the floor and - // the following Skips ramp up from it. - std::uint16_t gt_hint = kPrefetchDepthAuto; - if (f.payload.size() >= 6) { - gt_hint = static_cast( - static_cast(f.payload[4]) | - (static_cast(f.payload[5]) << 8)); - } - pack_row_trailer(reply, id, next_lookahead(id, gt_hint)); - // Reposition truth rides after the trailer (see GotoRecord). - // mtfix12 R1: pair-requested acks carry an explicit - // [u8 ack_flags] first (bit 0x01 = bound has reccount) so - // the client parses section offsets deterministically. - if (want_pair) { - Frame bnd; - bnd.opcode = reply.opcode; - pack_bound_trailer(bnd, id); - reply.payload.push_back( - bnd.payload.size() >= 10 ? 1 : 0); - reply.payload.insert(reply.payload.end(), - bnd.payload.begin(), - bnd.payload.end()); - } else { - pack_bound_trailer(reply, id); - } - // Fused switch only: the app almost always asks this - // order's key count next (scrollbar setup), so certify it - // now ([u32] after the bound tail) instead of paying a - // frame for it. Order-scoped, like the wire GetKeyCount. - if (fused_order) { - UNSIGNED32 fkc = 0; - if (hord != 0) { - (void)AdsGetKeyCount(hord, 0, &fkc); - } else if (tbl != nullptr) { - fkc = tbl->record_count(); - } - reply.payload.push_back(static_cast( fkc & 0xFFu)); - reply.payload.push_back(static_cast((fkc >> 8) & 0xFFu)); - reply.payload.push_back(static_cast((fkc >> 16) & 0xFFu)); - reply.payload.push_back(static_cast((fkc >> 24) & 0xFFu)); - } - // mtfix12 R1: the opposite boundary's landing state, - // read and packed inside this same visit. - if (want_pair) { - pack_boundary_pair(reply, id, 1, hord, tbl); - } - // Sync AFTER packing — pack_row_trailer walks the ABI cursor - // through the block and restores it, so the engine cursor has to be - // anchored to where the ABI cursor finally lands (same reason as - // the ordered Skip path). - if (hord != 0) sync_engine_cursor(id); - break; - } - case Opcode::Skip: { - if (f.payload.size() < 8) { reply = err("Skip: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - std::int32_t step = static_cast( - read_u32_le(f.payload.data() + 4)); - // M12.21 option C — a forward Skip from a prefetch-capable client - // piggybacks a lookahead block; the client serves those rows - // locally and folds the consumed count back into the next wire - // step, so the server cursor never desyncs (the bug that shelved - // option B). Non-capable clients and non-forward steps get no - // lookahead, preserving the old behavior. - // - // M12.22 — the depth is no longer a flat 64. next_lookahead() - // ramps it per consecutive forward Skip on this table and any - // reposition/write resets the run (see breaks_prefetch_run), so a - // one-off Skip pays for a handful of rows instead of a full block. - // - // M12.23 — ...unless the client named a depth via AdsCacheRecords, - // which rides along as an OPTIONAL trailing [u16]. Absent (any - // pre-M12.23 client) reads as kPrefetchDepthAuto = "you decide". - std::uint16_t depth_hint = kPrefetchDepthAuto; - if (f.payload.size() >= 10) { - depth_hint = static_cast( - static_cast(f.payload[8]) | - (static_cast(f.payload[9]) << 8)); - } - // RCB 07/15/2026: M12.25 — direction from the step sign. Forward - // (>= 1) needs kCapPrefetchConsume; backward (<= -1) additionally - // needs kCapPrefetchBackward, because a forward-only client would - // mis-drain a backward block. step == 0 (a settle) gets no block. - const std::int8_t dir = (step >= 1) ? 1 : (step <= -1) ? -1 : 0; - WTRACE("[wire] Skip id=%u step=%d\n", id, (int)step); - const bool want_lookahead = - (dir == 1 && client_prefetch_ok_) || - (dir == -1 && client_prefetch_ok_ && client_prefetch_back_ok_); - const std::uint16_t lookahead = - want_lookahead ? next_lookahead(id, depth_hint, dir) : 0; - if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { - (void)AdsSkip(cit->second, step); - reply.opcode = Opcode::SkipAck; - pack_row_trailer(reply, id, lookahead, dir); - break; - } - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("Skip: bad table id"); break; - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("Skip: lookup failed"); break; } - // Ordered: skip on the ABI handle, which is where the order and - // any scope live. M12.22 — the lookahead block now rides along - // here too: pack_row_trailer resolves an ordered table to the same - // ABI handle, so it walks the INDEX, not natural record order. - // That was the one thing blocking read-ahead on the browse that - // actually matters (rddads skips on hOrdCurrent when an order is - // set), and it needed no wire change. - ADSHANDLE hord = - ordered_tables_.count(id) ? ensure_abi_handle(id) : 0; - if (hord != 0) { - (void)AdsSkip(hord, step); - { UNSIGNED16 _b = 9, _e = 9; AdsAtBOF(hord, &_b); AdsAtEOF(hord, &_e); - WTRACE("[wire] Skip twin hord bof=%u eof=%u\n", (unsigned)_b, (unsigned)_e); } - reply.opcode = Opcode::SkipAck; - pack_row_trailer(reply, id, lookahead, dir); - pack_bound_trailer(reply, id); - // RCB 07/14/2026: sync AFTER packing, not before (this call - // used to sit above the pack). pack_row_trailer walks the ABI - // cursor through the lookahead block and then restores it, so - // the engine cursor has to be re-anchored to where the ABI - // cursor FINALLY lands. Syncing first would anchor it to a - // position the pack is about to move away from. - sync_engine_cursor(id); - { UNSIGNED16 _b = 9, _e = 9; AdsAtBOF(hord, &_b); AdsAtEOF(hord, &_e); - WTRACE("[wire] Skip end id=%u twin bof=%u eof=%u\n", id, (unsigned)_b, (unsigned)_e); } - break; - } - (void)tbl->skip(step); - reply.opcode = Opcode::SkipAck; - pack_row_trailer(reply, id, lookahead, dir); - pack_bound_trailer(reply, id); - break; - } - case Opcode::GetField: { - if (f.payload.size() < 5) { reply = err("GetField: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - std::string fname(reinterpret_cast( - f.payload.data() + 4), - f.payload.size() - 4); - if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { - UNSIGNED8 fbuf[64] = {0}; - UNSIGNED8 out [4096] = {0}; - UNSIGNED32 cap = sizeof(out); - std::size_t n = std::min(fname.size(), - sizeof(fbuf) - 1); - std::memcpy(fbuf, fname.data(), n); - fbuf[n] = 0; - UNSIGNED32 rrc = AdsGetField(cit->second, fbuf, out, &cap, 0); - if (rrc != 0) { reply = err("GetField: cursor read failed"); break; } - reply.opcode = Opcode::GetFieldAck; - reply.payload.assign(out, out + cap); - break; - } - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("GetField: bad table id"); break; - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("GetField: lookup failed"); break; } - std::int32_t fi = tbl->field_index(fname); - if (fi < 0) { reply = err("GetField: column not found"); break; } - auto v = tbl->read_field(static_cast(fi)); - if (!v) { - if (v.error().code == - static_cast(openads::AE_NO_CURRENT_RECORD)) { - const auto& fd = - tbl->field_descriptor(static_cast(fi)); - std::string blank; - using FT = openads::drivers::DbfFieldType; - switch (fd.type) { - case FT::Logical: - blank = "F"; - break; - default: - blank.assign(fd.length > 0 ? fd.length : 1, ' '); - break; - } - reply.opcode = Opcode::GetFieldAck; - reply.payload.assign(blank.begin(), blank.end()); - break; - } - reply = err("GetField: read failed"); break; - } - reply.opcode = Opcode::GetFieldAck; - auto& sval = v.value().as_string; - reply.payload.assign(sval.begin(), sval.end()); - break; - } - case Opcode::GetRecordCount: { - if (f.payload.size() < 4) { reply = err("GetRecordCount: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { - UNSIGNED32 rc = 0; - AdsGetRecordCount(cit->second, 0, &rc); - reply.opcode = Opcode::GetRecordCountAck; - write_u32_le(rc, reply.payload); - break; - } - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("GetRecordCount: bad table id"); break; - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("GetRecordCount: lookup failed"); break; } - // Refresh the on-disk record count so concurrent appends by - // other connections are visible (multiuser coherence). - tbl->refresh_record_count_from_disk(); - std::uint32_t rc = tbl->record_count(); - reply.opcode = Opcode::GetRecordCountAck; - write_u32_le(rc, reply.payload); - break; - } - case Opcode::GetKeyCount: { - if (f.payload.size() < 4) { reply = err("GetKeyCount: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { - UNSIGNED32 kc = 0; - AdsGetRecordCount(cit->second, 0, &kc); - reply.opcode = Opcode::GetKeyCountAck; - write_u32_le(kc, reply.payload); - break; - } - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("GetKeyCount: bad table id"); break; } - ADSHANDLE ht = ensure_abi_handle(id); - if (ht != 0 && ordered_tables_.count(id)) { - UNSIGNED32 kc = 0; - UNSIGNED32 rrc = AdsGetKeyCount(ht, 0, &kc); - if (rrc == 0) { - reply.opcode = Opcode::GetKeyCountAck; - write_u32_le(kc, reply.payload); - break; - } - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("GetKeyCount: lookup failed"); break; } - std::uint32_t kc = tbl->record_count(); - reply.opcode = Opcode::GetKeyCountAck; - write_u32_le(kc, reply.payload); - break; - } - // M12.29 — server-side key number: position of current record in - // the active order's walk. Uses pos_of_recno_cached() on CDX → O(1), - // eliminating the O(n) remote_measure_keyno client-side walk that - // made TXBrowse:Refresh() take ~22 sec for 9500 records. - case Opcode::GetKeyNum: { - if (f.payload.size() < 4) { reply = err("GetKeyNum: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("GetKeyNum: bad table id"); break; } - ADSHANDLE ht = ensure_abi_handle(id); - if (ht != 0) { - UNSIGNED32 kn = 0; - UNSIGNED32 rrc = AdsGetKeyNum(ht, 0, &kn); - if (rrc == 0) { - reply.opcode = Opcode::GetKeyNumAck; - write_u32_le(kn, reply.payload); - break; - } - } - // Fallback: not positioned or no order → key number 0. - reply.opcode = Opcode::GetKeyNumAck; - write_u32_le(0u, reply.payload); - break; - } - case Opcode::AtEOF: { - if (f.payload.size() < 4) { reply = err("AtEOF: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { - UNSIGNED16 v = 0, vbof = 0; - AdsAtEOF(cit->second, &v); - AdsAtBOF(cit->second, &vbof); - WTRACE("[wire] AtEOF id=%u via twin -> %u\n", id, (unsigned)v); - reply.opcode = Opcode::AtEOFAck; - reply.payload.push_back(v != 0 ? 1 : 0); - // Twin flag: the BOF answer rides along ([u8 eof][u8 bof]) - // so the client skips rddads' inevitable follow-up AtBOF. - // Old clients read byte 0 and ignore the trailer. - reply.payload.push_back(vbof != 0 ? 1 : 0); - break; - } - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("AtEOF: bad table id"); break; - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("AtEOF: lookup failed"); break; } - // See AtBOF: ordered tables must answer from the ABI twin. - ADSHANDLE hord_eof = - ordered_tables_.count(id) ? ensure_abi_handle(id) : 0; - if (hord_eof != 0) { - UNSIGNED16 v = 0, v2 = 0; - AdsAtBOF(hord_eof, &v2); - AdsAtEOF(hord_eof, &v); - // Limbo rescue: if both bof and eof are true, reposition - // the twin to break the deadlock (same logic as pack_row_trailer). - if (v && v2) { - AdsGotoTop(hord_eof); - AdsAtBOF(hord_eof, &v2); AdsAtEOF(hord_eof, &v); - if (v && v2) AdsGotoBottom(hord_eof); - AdsAtEOF(hord_eof, &v); - // Twin must be re-read after the rescue reposition: - // v2 above is pre-rescue and would cache stale BOF. - AdsAtBOF(hord_eof, &v2); - WTRACE("[wire] AtEOF id=%u Limbo rescue -> %u\n", id, (unsigned)v); - } - WTRACE("[wire] AtEOF id=%u via ordered twin -> %u\n", id, (unsigned)v); - reply.opcode = Opcode::AtEOFAck; - reply.payload.push_back(v != 0 ? 1 : 0); - reply.payload.push_back(v2 != 0 ? 1 : 0); - break; - } - WTRACE("[wire] AtEOF id=%u engine eof=%d bof=%d\n", id, (int)tbl->eof(), (int)tbl->bof()); - reply.opcode = Opcode::AtEOFAck; - reply.payload.push_back(tbl->eof() ? 1 : 0); - reply.payload.push_back(tbl->bof() ? 1 : 0); - break; - } - // M12.14 — DescribeTable: serialize the schema in one - // round-trip so rddads' adsOpen field-iteration loop - // doesn't generate 5 × num_fields hops. - case Opcode::DescribeTable: { - if (f.payload.size() < 4) { - reply = err("DescribeTable: bad payload"); break; - } - std::uint32_t id = read_u32_le(f.payload.data()); - ADSHANDLE cur_h = 0; - openads::engine::Table* tbl = nullptr; - if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { - cur_h = cit->second; - } else { - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("DescribeTable: bad table id"); break; - } - tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { - reply = err("DescribeTable: lookup failed"); break; - } - } - reply.opcode = Opcode::DescribeTableAck; - if (cur_h != 0) { - UNSIGNED16 nf = 0; - AdsGetNumFields(cur_h, &nf); - reply.payload.push_back(static_cast(nf & 0xFFu)); - reply.payload.push_back(static_cast((nf >> 8) & 0xFFu)); - for (UNSIGNED16 i = 1; i <= nf; ++i) { - UNSIGNED8 nm[64] = {0}; - UNSIGNED16 cap = sizeof(nm); - AdsGetFieldName(cur_h, i, nm, &cap); - std::vector nbuf(cap + 1, 0); - std::memcpy(nbuf.data(), nm, cap); - UNSIGNED16 ftype = 0; - UNSIGNED32 flen = 0; - UNSIGNED16 fdec = 0; - AdsGetFieldType (cur_h, nbuf.data(), &ftype); - AdsGetFieldLength (cur_h, nbuf.data(), &flen); - AdsGetFieldDecimals(cur_h, nbuf.data(), &fdec); - reply.payload.push_back(static_cast(cap)); - reply.payload.insert(reply.payload.end(), - nm, nm + cap); - reply.payload.push_back(static_cast( ftype & 0xFFu)); - reply.payload.push_back(static_cast((ftype >> 8) & 0xFFu)); - write_u32_le(flen, reply.payload); - reply.payload.push_back(static_cast( fdec & 0xFFu)); - reply.payload.push_back(static_cast((fdec >> 8) & 0xFFu)); - } - } else { - // Engine branch: shared mapper (must agree byte-for-byte - // with the warm OpenTableAck schema section). - auto map_type = [](openads::drivers::DbfFieldType t) -> std::uint16_t { - return ads_type_for_wire(t); - }; - auto nf = static_cast(tbl->field_count()); - reply.payload.push_back(static_cast(nf & 0xFFu)); - reply.payload.push_back(static_cast((nf >> 8) & 0xFFu)); - for (std::uint16_t i = 0; i < nf; ++i) { - const auto& fd = tbl->field_descriptor(i); - std::uint8_t name_len = - static_cast(fd.name.size() & 0xFFu); - std::uint16_t ftype = map_type(fd.type); - std::uint32_t flen = fd.length; - std::uint16_t fdec = fd.decimals; - reply.payload.push_back(name_len); - reply.payload.insert(reply.payload.end(), - fd.name.begin(), - fd.name.begin() + name_len); - reply.payload.push_back(static_cast( ftype & 0xFFu)); - reply.payload.push_back(static_cast((ftype >> 8) & 0xFFu)); - write_u32_le(flen, reply.payload); - reply.payload.push_back(static_cast( fdec & 0xFFu)); - reply.payload.push_back(static_cast((fdec >> 8) & 0xFFu)); - } - } - break; - } - case Opcode::AtBOF: { - if (f.payload.size() < 4) { reply = err("AtBOF: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - WTRACE("[wire] AtBOF id=%u\n", id); - if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { - UNSIGNED16 v = 0, veof = 0; - AdsAtBOF(cit->second, &v); - AdsAtEOF(cit->second, &veof); - WTRACE("[wire] AtBOF id=%u via twin -> %u\n", id, (unsigned)v); - reply.opcode = Opcode::AtBOFAck; - reply.payload.push_back(v != 0 ? 1 : 0); - // Twin flag: [u8 bof][u8 eof] (see AtEOF). - reply.payload.push_back(veof != 0 ? 1 : 0); - break; - } - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("AtBOF: bad table id"); break; - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("AtBOF: lookup failed"); break; } - // Ordered tables navigate on the ABI twin (it carries the order - // and any scope); the engine cursor is only a mirrored recno and - // can sit with bof+eof both set after a scope-end sync — answer - // from the twin, like the GotoTop/Skip handlers do. Fixes the - // BOF+EOF-both-true answer that made rddads' DBOI_POSITION - // (OrdKeyGoto past the scoped key count) report Bof()=.T. and - // xBrowse paint a phantom duplicate row (Tim Stone, 1-record - // scope). - ADSHANDLE hord_bof = - ordered_tables_.count(id) ? ensure_abi_handle(id) : 0; - if (hord_bof != 0) { - UNSIGNED16 v = 0, v2 = 0; - AdsAtBOF(hord_bof, &v); - AdsAtEOF(hord_bof, &v2); - // Limbo rescue: if both bof and eof are true, reposition - // the twin to break the deadlock (same logic as pack_row_trailer). - if (v && v2) { - AdsGotoTop(hord_bof); - AdsAtBOF(hord_bof, &v); AdsAtEOF(hord_bof, &v2); - if (v && v2) AdsGotoBottom(hord_bof); - AdsAtBOF(hord_bof, &v); - // Twin must be re-read after the rescue reposition - // (see AtEOF). - AdsAtEOF(hord_bof, &v2); - WTRACE("[wire] AtBOF id=%u Limbo rescue -> %u\n", id, (unsigned)v); - } - WTRACE("[wire] AtBOF id=%u via ordered twin -> %u\n", id, (unsigned)v); - reply.opcode = Opcode::AtBOFAck; - reply.payload.push_back(v != 0 ? 1 : 0); - reply.payload.push_back(v2 != 0 ? 1 : 0); - break; - } - WTRACE("[wire] AtBOF id=%u engine bof=%d eof=%d\n", id, (int)tbl->bof(), (int)tbl->eof()); - reply.opcode = Opcode::AtBOFAck; - reply.payload.push_back(tbl->bof() ? 1 : 0); - reply.payload.push_back(tbl->eof() ? 1 : 0); - break; - } - case Opcode::GetRecordNum: { - if (f.payload.size() < 4) { reply = err("GetRecordNum: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { - UNSIGNED32 rn = 0; - AdsGetRecordNum(cit->second, 0, &rn); - reply.opcode = Opcode::GetRecordNumAck; - write_u32_le(rn, reply.payload); - break; - } - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("GetRecordNum: bad table id"); break; - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("GetRecordNum: lookup failed"); break; } - reply.opcode = Opcode::GetRecordNumAck; - write_u32_le(tbl->recno(), reply.payload); - break; - } - case Opcode::IsRecordDeleted: { - if (f.payload.size() < 4) { reply = err("IsRecordDeleted: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { - UNSIGNED16 v = 0; - AdsIsRecordDeleted(cit->second, &v); - reply.opcode = Opcode::IsRecordDeletedAck; - reply.payload.push_back(v != 0 ? 1 : 0); - break; - } - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("IsRecordDeleted: bad table id"); break; - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("IsRecordDeleted: lookup failed"); break; } - reply.opcode = Opcode::IsRecordDeletedAck; - reply.payload.push_back(tbl->is_deleted() ? 1 : 0); - break; - } - case Opcode::GotoBottom: { - if (f.payload.size() < 4) { reply = err("GotoBottom: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { - AdsGotoBottom(cit->second); - reply.opcode = Opcode::GotoBottomAck; - pack_row_trailer(reply, id); - break; - } - // Fused nav+order: trailing [u8 0x01][u32 order_id]. - // (GotoBottom carries no depth hint, so the section starts - // at byte 4.) - // mtfix12 R1: byte 4 is a flags byte on new clients (see - // GotoTop): bit 0x01 fused order section, bit 0x02 - // boundary-pair request. - bool fused_order = false; - bool want_pair = false; - if (f.payload.size() >= 5) { - const std::uint8_t fl = f.payload[4]; - want_pair = (fl & 0x02) != 0; - if ((fl & 0x01) != 0 && f.payload.size() >= 9) { - std::uint32_t oiid = read_u32_le(f.payload.data() + 5); - UNSIGNED32 oorc = install_table_order(id, oiid); - if (oorc != 0) { reply = err("SetOrder", oorc); break; } - fused_order = true; - } - } - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("GotoBottom: bad table id"); break; - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("GotoBottom: lookup failed"); break; } - ADSHANDLE hord = - ordered_tables_.count(id) ? ensure_abi_handle(id) : 0; - if (hord != 0) { - (void)AdsGotoBottom(hord); - sync_engine_cursor(id); - } else { - auto rb = tbl->goto_bottom(); - if (!rb) { - reply = err("GotoBottom: " + rb.error().message); - break; - } - } - reply.opcode = Opcode::GotoBottomAck; - pack_row_trailer(reply, id); - if (want_pair) { - Frame bnd; - bnd.opcode = reply.opcode; - pack_bound_trailer(bnd, id); - reply.payload.push_back( - bnd.payload.size() >= 10 ? 1 : 0); - reply.payload.insert(reply.payload.end(), - bnd.payload.begin(), - bnd.payload.end()); - } else { - pack_bound_trailer(reply, id); - } - // Fused switch only: certify the new order's key count - // (see GotoTop). - if (fused_order) { - UNSIGNED32 fkc = 0; - if (hord != 0) { - (void)AdsGetKeyCount(hord, 0, &fkc); - } else if (tbl != nullptr) { - fkc = tbl->record_count(); - } - reply.payload.push_back(static_cast( fkc & 0xFFu)); - reply.payload.push_back(static_cast((fkc >> 8) & 0xFFu)); - reply.payload.push_back(static_cast((fkc >> 16) & 0xFFu)); - reply.payload.push_back(static_cast((fkc >> 24) & 0xFFu)); - } - if (want_pair) { - pack_boundary_pair(reply, id, 2, hord, tbl); - } - break; - } - // M12.15 — info / lock / maintenance / AOF. - // - // All these handlers share the same shape: - // payload[0..3] = table id (client-side) - // reply payload = answer (or empty for void) - // For the local-table branch (sess_conn_-owned engine - // Tables) the call lands on Table::* methods directly. - // Cursor handles (from ExecuteSQL) route through the - // matching ABI entry point, preserving the wire/ABI - // symmetry. - case Opcode::IsFound: { - if (f.payload.size() < 4) { reply = err("IsFound: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { - UNSIGNED16 v = 0; - AdsIsFound(cit->second, &v); - reply.opcode = Opcode::IsFoundAck; - reply.payload.push_back(v != 0 ? 1 : 0); - break; - } - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("IsFound: bad table id"); break; - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("IsFound: lookup failed"); break; } - reply.opcode = Opcode::IsFoundAck; - reply.payload.push_back(tbl->last_seek_found() ? 1 : 0); - break; - } - case Opcode::RefreshRecord: { - if (f.payload.size() < 4) { reply = err("RefreshRecord: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { - AdsRefreshRecord(cit->second); - reply.opcode = Opcode::RefreshRecordAck; - break; - } - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("RefreshRecord: bad table id"); break; - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("RefreshRecord: lookup failed"); break; } - // Force a re-load from disk by re-positioning to the - // current recno. - auto rb = tbl->goto_record(tbl->recno()); - if (!rb) { reply = err("RefreshRecord: " + rb.error().message); break; } - reply.opcode = Opcode::RefreshRecordAck; - // The re-read row rides back with the ack (row trailer), - // plus position truth (bound tail), so the caller's - // follow-up reads serve locally. Length-gated as usual. - pack_row_trailer(reply, id); - pack_bound_trailer(reply, id); - break; - } - case Opcode::GetTableType: { - if (f.payload.size() < 4) { reply = err("GetTableType: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { - UNSIGNED16 v = 0; - AdsGetTableType(cit->second, &v); - reply.opcode = Opcode::GetTableTypeAck; - reply.payload.push_back(static_cast( v & 0xFFu)); - reply.payload.push_back(static_cast((v >> 8) & 0xFFu)); - break; - } - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("GetTableType: bad table id"); break; - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("GetTableType: lookup failed"); break; } - std::uint16_t v = ADS_CDX; - std::filesystem::path p(tbl->path()); - std::string ext = p.extension().string(); - for (auto& c : ext) c = static_cast( - std::tolower(static_cast(c))); - if (ext == ".adt") v = ADS_ADT; - reply.opcode = Opcode::GetTableTypeAck; - reply.payload.push_back(static_cast( v & 0xFFu)); - reply.payload.push_back(static_cast((v >> 8) & 0xFFu)); - break; - } - case Opcode::GetRecordLength: { - if (f.payload.size() < 4) { reply = err("GetRecordLength: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { - UNSIGNED32 v = 0; - AdsGetRecordLength(cit->second, &v); - reply.opcode = Opcode::GetRecordLengthAck; - write_u32_le(v, reply.payload); - break; - } - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("GetRecordLength: bad table id"); break; - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("GetRecordLength: lookup failed"); break; } - std::uint32_t rl = tbl->driver() - ? tbl->driver()->record_length() : 0; - reply.opcode = Opcode::GetRecordLengthAck; - write_u32_le(rl, reply.payload); - break; - } - case Opcode::GetNumIndexes: { - if (f.payload.size() < 4) { reply = err("GetNumIndexes: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { - UNSIGNED16 v = 0; - AdsGetNumIndexes(cit->second, &v); - reply.opcode = Opcode::GetNumIndexesAck; - reply.payload.push_back(static_cast( v & 0xFFu)); - reply.payload.push_back(static_cast((v >> 8) & 0xFFu)); - break; - } - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("GetNumIndexes: bad table id"); break; - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("GetNumIndexes: lookup failed"); break; } - // Indexes are opened on the ABI handle (incl. the production - // CDX auto-opened there), not the engine table — count via the - // ABI handle so OrdCount() / DBOI_ORDERCOUNT is non-zero - // remotely. Falls back to the engine table's view if no ABI - // handle has been promoted yet. - std::uint16_t n = 0; - if (ADSHANDLE ht = ensure_abi_handle(id); ht != 0) { - (void)AdsGetNumIndexes(ht, &n); - } else { - n = static_cast(tbl->all_indexes().size()); - } - reply.opcode = Opcode::GetNumIndexesAck; - reply.payload.push_back(static_cast( n & 0xFFu)); - reply.payload.push_back(static_cast((n >> 8) & 0xFFu)); - break; - } - case Opcode::GetLastAutoinc: { - if (f.payload.size() < 4) { reply = err("GetLastAutoinc: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - std::uint32_t v = 0; - if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { - AdsGetLastAutoinc(cit->second, &v); - } - // Local-table path: not exposed at engine level; - // return 0. Same as the local AdsGetLastAutoinc - // fallback when the column isn't autoinc. - reply.opcode = Opcode::GetLastAutoincAck; - write_u32_le(v, reply.payload); - break; - } - case Opcode::GetLastTableUpdate: { - if (f.payload.size() < 4) { reply = err("GetLastTableUpdate: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - std::uint32_t packed = 0; - if (cursor_tbls_.find(id) == cursor_tbls_.end()) { - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("GetLastTableUpdate: bad table id"); break; - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("GetLastTableUpdate: lookup failed"); break; } - if (tbl->driver()) { - std::uint8_t b[3] = {0, 0, 0}; - auto got = tbl->driver()->file().read_at(1, b, sizeof(b)); - if (got && got.value() >= sizeof(b)) { - packed = (static_cast(1900 + b[0]) << 16) | - (static_cast(b[1]) << 8) | - static_cast(b[2]); - } - } - } - reply.opcode = Opcode::GetLastTableUpdateAck; - write_u32_le(packed, reply.payload); - break; - } - // Record locks route through the parallel ABI handle when one - // exists (M12.16 dual-handle) so they land on the same Table - // instance that appends/writes go through; otherwise they use - // the engine table from tbls_ with a non-blocking retry loop. - case Opcode::LockRecord: - case Opcode::UnlockRecord: { - if (f.payload.size() < 8) { reply = err("Lock: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - std::uint32_t rn = read_u32_le(f.payload.data() + 4); - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("Lock: bad table id"); break; - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("Lock: lookup failed"); break; } - // ACE convention: recno 0 = the current record. The engine - // cursor is the authoritative position (write opcodes sync the - // ABI twin to it before guarding), so translate here or the - // lock lands on nonexistent record 0 and the write guard - // correctly rejects the later write with 5035. - if (rn == 0) rn = tbl->recno(); - openads::mgmt::set_current_lock_owner( - session_user_.empty() ? "(anonymous)" : session_user_, - srv_->conn_no_for_session(sid_)); - // M12.16 dual-handle: appends go through the parallel ABI - // handle (see AppendBlank), and AdsAppendRecord auto-locks the - // new record on THAT Table instance. Routing the client's - // RLock() to the engine Table made every lock after APPEND - // BLANK burn the full lock-retry budget (~1 s per record — - // Pritpal's "9 records in 10 seconds", 31/07/2026) and the - // matching UNLOCK never released the ABI-side auto-lock, - // leaking it until session cleanup and stalling other - // stations. Route lock/unlock through the same ABI handle - // whenever it exists. - if (auto hit = tbls_h_.find(id); hit != tbls_h_.end()) { - UNSIGNED32 rrc = (f.opcode == Opcode::LockRecord) - ? openads::abi::try_lock_record_once(hit->second, rn) - : AdsUnlockRecord(hit->second, rn); - // Also release any stale engine-side lock taken before - // the ABI handle existed (best-effort; not holding the - // lock is fine). - if (f.opcode == Opcode::UnlockRecord) - (void)tbl->unlock_record(rn); - if (rrc != 0) { - // Trace the holder so a field log shows WHO is blocking - // a record lock (Pritpal's GN_COUNT retry loop). - if (wire_trace_on()) { - for (const auto& lk : openads::mgmt::LockRegistry - ::instance().snapshot()) { - if (lk.recno == rn) { - WTRACE("[wire] %s id=%u recno=%u FAILED rrc=%u; held by user=%s conn=%u table=%s\n", - f.opcode == Opcode::LockRecord ? "LockRecord" : "UnlockRecord", - id, (unsigned)rn, (unsigned)rrc, - lk.user.c_str(), (unsigned)lk.conn_no, - lk.table.c_str()); - } - } - WTRACE("[wire] %s id=%u recno=%u FAILED rrc=%u (no registered holder)\n", - f.opcode == Opcode::LockRecord ? "LockRecord" : "UnlockRecord", - id, (unsigned)rn, (unsigned)rrc); - } - reply = err("Lock: failed", rrc); break; - } - WTRACE("[wire] %s id=%u recno=%u ok user=%s conn=%u table=%s\n", - f.opcode == Opcode::LockRecord ? "LockRecord" : "UnlockRecord", - id, (unsigned)rn, - session_user_.empty() ? "(anonymous)" : session_user_.c_str(), - (unsigned)srv_->conn_no_for_session(sid_), - tbl_open_paths_.count(id) ? tbl_open_paths_[id].c_str() : "?"); - } else if (f.opcode == Opcode::LockRecord) { - // Fail FAST on contention — a single attempt. Retrying - // here blocked the session thread for the whole lock - // budget (~1s), starving the peer op that releases the - // record; the client retries instead, one wire request - // per attempt, keeping the connection free between them. - auto r = tbl->try_lock_record_excl(rn); - if (!r) { - WTRACE("[wire] LockRecord id=%u recno=%u FAILED (engine)\n", - id, (unsigned)rn); - reply = err("LockRecord: failed", - openads::AE_LOCKED); - break; - } - WTRACE("[wire] LockRecord id=%u recno=%u ok (engine) user=%s conn=%u table=%s\n", - id, (unsigned)rn, - session_user_.empty() ? "(anonymous)" : session_user_.c_str(), - (unsigned)srv_->conn_no_for_session(sid_), - tbl_open_paths_.count(id) ? tbl_open_paths_[id].c_str() : "?"); - } else { - auto r = tbl->unlock_record(rn); - if (!r) { reply = err("UnlockRecord: failed", - static_cast(r.error().code)); break; } - WTRACE("[wire] UnlockRecord id=%u recno=%u ok (engine) user=%s conn=%u table=%s\n", - id, (unsigned)rn, - session_user_.empty() ? "(anonymous)" : session_user_.c_str(), - (unsigned)srv_->conn_no_for_session(sid_), - tbl_open_paths_.count(id) ? tbl_open_paths_[id].c_str() : "?"); - } - reply.opcode = (f.opcode == Opcode::LockRecord) - ? Opcode::LockRecordAck - : Opcode::UnlockRecordAck; - break; - } - // M12.36 — lock introspection. Same dual-handle routing as - // LockRecord above: when the parallel ABI handle exists it owns - // the locks (appends auto-lock there), so query it through the - // local ACE calls; otherwise walk the engine Table's held list. - case Opcode::IsRecordLocked: { - if (f.payload.size() < 8) { reply = err("IsRecordLocked: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - std::uint32_t rn = read_u32_le(f.payload.data() + 4); - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("IsRecordLocked: bad table id"); break; - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("IsRecordLocked: lookup failed"); break; } - // ACE convention: recno 0 = the current record. - if (rn == 0) rn = tbl->recno(); - // mtfix11: SAP AdsIsRecordLocked answers across connections; - // the own-session lock list alone is blind to other sessions - // (login-semaphore guards read this op and silently admitted - // every newcomer). Own registrations plus a non-destructive - // OS lock-byte probe - which also covers locks held through - // this session's ABI twin handle, whose Table object is not - // `tbl`. - std::uint16_t locked = 0; - if (auto any = tbl->is_record_locked_any(rn); any) { - locked = any.value() ? 1 : 0; - } else { - reply = err("IsRecordLocked: probe failed"); break; - } - reply.opcode = Opcode::IsRecordLockedAck; - write_u16_le(locked, reply.payload); - break; - } - case Opcode::GetAllLocks: { - if (f.payload.size() < 4) { reply = err("GetAllLocks: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("GetAllLocks: bad table id"); break; - } - std::vector recs; - if (auto hit = tbls_h_.find(id); hit != tbls_h_.end()) { - UNSIGNED16 count = 0; - if (AdsGetAllLocks(hit->second, nullptr, &count) != 0) { - reply = err("GetAllLocks: failed"); break; - } - recs.resize(count); - if (count > 0 && - AdsGetAllLocks(hit->second, recs.data(), &count) != 0) { - reply = err("GetAllLocks: failed"); break; - } - recs.resize(count); - } else { - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("GetAllLocks: lookup failed"); break; } - recs = tbl->held_record_locks(); - } - reply.opcode = Opcode::GetAllLocksAck; - write_u16_le(static_cast(recs.size()), - reply.payload); - for (std::uint32_t rn : recs) write_u32_le(rn, reply.payload); - break; - } - case Opcode::LockTable: - case Opcode::UnlockTable: { - if (f.payload.size() < 4) { reply = err("Lock: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("LockTable: bad table id"); break; - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("LockTable: lookup failed"); break; } - openads::mgmt::set_current_lock_owner( - session_user_.empty() ? "(anonymous)" : session_user_, - srv_->conn_no_for_session(sid_)); - if (f.opcode == Opcode::LockTable) { - // Fail fast — a single attempt (see LockRecord above: the - // client owns the retry loop so the connection stays free - // between attempts). - auto r = tbl->try_lock_table_excl(); - if (!r) { - reply = err("LockTable: failed", - openads::AE_LOCKED); - break; - } - } else { - // M12.16 dual-handle: route through the ABI shadow handle - // when it exists (same as LockRecord/UnlockRecord) so the - // ABI-side Table's LockMgr releases its own locks. - WTRACE("[wire] UnlockTable id=%u\n", id); - if (auto hit = tbls_h_.find(id); hit != tbls_h_.end()) { - UNSIGNED32 rrc = AdsUnlockTable(hit->second); - // Also release engine-side locks (best-effort cleanup). - (void)tbl->unlock_table(); - if (rrc != 0) { - reply = err("UnlockTable: failed", rrc); break; - } - } else { - auto r = tbl->unlock_table(); - if (!r) { reply = err("UnlockTable: failed", - static_cast(r.error().code)); break; } - } - } - reply.opcode = (f.opcode == Opcode::LockTable) - ? Opcode::LockTableAck - : Opcode::UnlockTableAck; - break; - } - case Opcode::PackTable: - case Opcode::ZapTable: - case Opcode::FlushFileBuffers: - case Opcode::CloseAllIndexes: { - if (f.payload.size() < 4) { reply = err("Maintenance: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - Opcode ack_op = - (f.opcode == Opcode::PackTable) ? Opcode::PackTableAck - : (f.opcode == Opcode::ZapTable) ? Opcode::ZapTableAck - : (f.opcode == Opcode::FlushFileBuffers) ? Opcode::FlushFileBuffersAck - : Opcode::CloseAllIndexesAck; - if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { - if (f.opcode == Opcode::PackTable) AdsPackTable(cit->second); - else if (f.opcode == Opcode::ZapTable) AdsZapTable(cit->second); - else if (f.opcode == Opcode::FlushFileBuffers) AdsFlushFileBuffers(cit->second); - else { - // Best-effort flush first (see engine branch below): - // clients may have merged a FlushFileBuffers here. - (void)AdsFlushFileBuffers(cit->second); - AdsCloseAllIndexes(cit->second); - } - reply.opcode = ack_op; - break; - } - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("Maintenance: bad table id"); break; - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("Maintenance: lookup failed"); break; } - util::Result rb = util::Result{}; - if (f.opcode == Opcode::PackTable) rb = tbl->pack(); - else if (f.opcode == Opcode::ZapTable) rb = tbl->zap(); - else if (f.opcode == Opcode::FlushFileBuffers) rb = tbl->flush(); - else { - // CloseAllIndexes: drop both active order + - // every parked extra view in lockstep. Flush first: - // clients merge a preceding FlushFileBuffers into this - // frame (one RTT instead of two), so durability must - // not depend on a separate flush arriving. - rb = tbl->flush(); - if (!rb) { reply = err("Maintenance: " + rb.error().message); break; } - tbl->clear_order(); - tbl->clear_extra_index_views(); - } - if (!rb) { reply = err("Maintenance: " + rb.error().message); break; } - // Engine-side pack/zap rewrites recnos (pack) or empties the file - // (zap); bags bound on the ABI twin now point at phantom recnos. - // Rebuild them so the next ordered nav doesn't walk ghosts. - // AdsGotoTop first: it refreshes the twin's cached record_count - // from disk, which the reindex scan relies on. - if (f.opcode == Opcode::PackTable || f.opcode == Opcode::ZapTable) { - if (auto hit = tbls_h_.find(id); hit != tbls_h_.end()) { - (void)AdsGotoTop(hit->second); - (void)AdsReindex(hit->second); - } - } - reply.opcode = ack_op; - break; - } - case Opcode::SetAOF: { - if (f.payload.size() < 4) { reply = err("SetAOF: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - std::string cond(reinterpret_cast( - f.payload.data() + 4), - f.payload.size() - 4); - if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { - std::vector b(cond.size() + 1); - std::memcpy(b.data(), cond.data(), cond.size()); - UNSIGNED32 rrc = AdsSetAOF(cit->second, b.data(), 0); - if (rrc != 0) { reply = err("SetAOF: parse failed", rrc); break; } - reply.opcode = Opcode::SetAOFAck; - break; - } - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("SetAOF: bad table id"); break; - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("SetAOF: lookup failed"); break; } - auto ast = openads::engine::aof::parse(cond); - if (!ast) { - // Expression outside the optimisable AOF subset - // (e.g. Empty(NAME)) — not an error. Drop any - // prior AOF and ack; the client RDD filters. - tbl->clear_filter(); - reply.opcode = Opcode::SetAOFAck; - break; - } - auto rep = openads::engine::aof::evaluate_optimised(*ast.value(), *tbl); - if (!rep) { reply = err("SetAOF: " + rep.error().message); break; } - tbl->install_aof_bitmap(std::move(rep.value().bm)); - tbl->set_aof_expr(cond); - int lvl = ADS_OPTIMIZED_NONE; - switch (rep.value().level) { - case openads::engine::aof::OptLevel::None: lvl = ADS_OPTIMIZED_NONE; break; - case openads::engine::aof::OptLevel::Part: lvl = ADS_OPTIMIZED_PART; break; - case openads::engine::aof::OptLevel::Full: lvl = ADS_OPTIMIZED_FULL; break; - } - tbl->set_aof_opt_level(lvl); - reply.opcode = Opcode::SetAOFAck; - break; - } - case Opcode::ClearAOFRemote: { - if (f.payload.size() < 4) { reply = err("ClearAOF: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { - AdsClearAOF(cit->second); - reply.opcode = Opcode::ClearAOFRemoteAck; - break; - } - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("ClearAOF: bad table id"); break; - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("ClearAOF: lookup failed"); break; } - tbl->clear_filter(); - reply.opcode = Opcode::ClearAOFRemoteAck; - break; - } - case Opcode::GetAOFOptLevel: { - if (f.payload.size() < 4) { reply = err("GetAOFOptLevel: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - std::uint16_t v = ADS_OPTIMIZED_NONE; - if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { - UNSIGNED16 lvl = 0; UNSIGNED16 buflen = 0; - AdsGetAOFOptLevel(cit->second, &lvl, nullptr, &buflen); - v = lvl; - } else if (auto it = tbls_.find(id); it != tbls_.end() && sess_conn_) { - if (auto* tbl = sess_conn_->lookup_table(it->second)) { - if (tbl->aof_active()) { - v = static_cast(tbl->aof_opt_level()); - } - } - } - reply.opcode = Opcode::GetAOFOptLevelAck; - reply.payload.push_back(static_cast( v & 0xFFu)); - reply.payload.push_back(static_cast((v >> 8) & 0xFFu)); - break; - } - // M12.16 — remote index handle subsystem. All ops route - // through the ABI handle on tbls_h_ (lazy-promoted via - // ensure_abi_handle) so AdsOpenIndex / AdsSetOrder / - // AdsSeek work end-to-end over the wire. - case Opcode::OpenIndex: { - if (f.payload.size() < 4) { reply = err("OpenIndex: bad payload"); break; } - std::uint32_t tid = read_u32_le(f.payload.data()); - std::string path(reinterpret_cast( - f.payload.data() + 4), - f.payload.size() - 4); - auto& oi_st = openads::mgmt::process_mg_stats(); - auto oi_us = [](auto a, auto b) { - return static_cast( - std::chrono::duration_cast( - b - a).count()); - }; - auto oi_t0 = std::chrono::steady_clock::now(); - // Remember whether the twin pre-existed: ensure_abi_handle - // creates one as a side effect, and a FAILED open below must - // not leave it behind. An orphan twin hijacks every later - // write (AppendBlank/SetField/SetFields prefer tbls_h_) while - // fetch packs the engine table — same-handle read-after-write - // then serves stale blanks until a nav reloads from disk. - const bool had_twin = tbls_h_.find(tid) != tbls_h_.end(); - ADSHANDLE ht = ensure_abi_handle(tid); - { - auto& t = oi_st.op_timing_oi[0]; - auto us = oi_us(oi_t0, std::chrono::steady_clock::now()); - t.count.fetch_add(1, std::memory_order_relaxed); - t.total_us.fetch_add(us, std::memory_order_relaxed); - auto pv = t.max_us.load(std::memory_order_relaxed); - while (us > pv && !t.max_us.compare_exchange_weak( - pv, us, std::memory_order_relaxed)) {} - } - if (ht == 0) { reply = err("OpenIndex: bad table id"); break; } - std::vector pb(path.size() + 1); - std::memcpy(pb.data(), path.data(), path.size()); - ADSHANDLE arr[64] = {0}; - UNSIGNED16 alen = 64; - auto oi_t1 = std::chrono::steady_clock::now(); - UNSIGNED32 rrc = AdsOpenIndex(ht, pb.data(), arr, &alen); - { - auto& t = oi_st.op_timing_oi[1]; - auto us = oi_us(oi_t1, std::chrono::steady_clock::now()); - t.count.fetch_add(1, std::memory_order_relaxed); - t.total_us.fetch_add(us, std::memory_order_relaxed); - auto pv = t.max_us.load(std::memory_order_relaxed); - while (us > pv && !t.max_us.compare_exchange_weak( - pv, us, std::memory_order_relaxed)) {} - } - if (rrc != 0) { - // Drop an orphan twin created above: it owns no tags, so - // keeping it would only split engine/twin state (and leak - // the ABI handle). A pre-existing twin stays untouched, as - // does a cursor_tbls_ handle (never in tbls_h_). - if (!had_twin) { - if (auto hit = tbls_h_.find(tid); - hit != tbls_h_.end() && hit->second == ht) { - abi_schema_.erase(ht); - (void)AdsCloseTable(ht); - tbls_h_.erase(hit); - } - } - reply = err("OpenIndex: " + path, rrc); break; - } - reply.opcode = Opcode::OpenIndexAck; - reply.payload.push_back(static_cast( alen & 0xFFu)); - reply.payload.push_back(static_cast((alen >> 8) & 0xFFu)); - auto oi_t3 = std::chrono::steady_clock::now(); - for (std::uint16_t i = 0; i < alen; ++i) { - std::uint32_t iid = next_id_++; - index_h_[iid] = arr[i]; - index_table_[iid] = tid; - write_u32_le(iid, reply.payload); - std::string tag; - UNSIGNED8 tbuf[256] = {0}; - UNSIGNED16 tlen = static_cast(sizeof(tbuf) - 1); - if (AdsGetIndexName(arr[i], tbuf, &tlen) == 0) { - tag.assign(reinterpret_cast(tbuf), tlen); - while (!tag.empty() && tag.back() == ' ') tag.pop_back(); - } - index_tag_[iid] = tag; - auto tn = static_cast(tag.size()); - reply.payload.push_back(static_cast( tn & 0xFFu)); - reply.payload.push_back(static_cast((tn >> 8) & 0xFFu)); - reply.payload.insert(reply.payload.end(), - tag.begin(), tag.end()); - } - // Append the production bag path so the client can serve - // AdsGetIndexFilename / OrdBagName without a wire round-trip. - // All tags in this OpenIndex response come from the same bag - // (the first tag handle is enough to query the bag path). - { - UNSIGNED8 bbuf[512] = {0}; - UNSIGNED16 blen = static_cast(sizeof(bbuf) - 1); - std::string bag; - if (alen > 0 && - AdsGetIndexFilename(arr[0], 0, bbuf, &blen) == 0 && - blen > 0) { - bag.assign(reinterpret_cast(bbuf), blen); - } - auto bn = static_cast(bag.size()); - reply.payload.push_back(static_cast( bn & 0xFFu)); - reply.payload.push_back(static_cast((bn >> 8) & 0xFFu)); - reply.payload.insert(reply.payload.end(), - bag.begin(), bag.end()); - } - // Extended per-tag metadata (expression, unique, descending), - // one triple per tag in the same order as the tag loop above. - // Lets remote AdsGetIndexExpr / AdsIsIndexUnique / - // AdsIsIndexDescending answer from the cached RemoteIndex - // instead of a lookup that only ever resolves local handles. - for (std::uint16_t i = 0; i < alen; ++i) { - UNSIGNED8 ebuf[1024] = {0}; - UNSIGNED16 elen = static_cast(sizeof(ebuf) - 1); - std::string expr; - if (AdsGetIndexExpr(arr[i], ebuf, &elen) == 0 && elen > 0) { - expr.assign(reinterpret_cast(ebuf), elen); - } - auto en = static_cast(expr.size()); - reply.payload.push_back(static_cast( en & 0xFFu)); - reply.payload.push_back(static_cast((en >> 8) & 0xFFu)); - reply.payload.insert(reply.payload.end(), - expr.begin(), expr.end()); - - UNSIGNED16 uniq = 0, desc = 0; - AdsIsIndexUnique(arr[i], &uniq); - AdsIsIndexDescending(arr[i], &desc); - reply.payload.push_back(uniq != 0 ? 1 : 0); - reply.payload.push_back(desc != 0 ? 1 : 0); - } - { - auto& t = oi_st.op_timing_oi[3]; - auto us = oi_us(oi_t3, std::chrono::steady_clock::now()); - t.count.fetch_add(1, std::memory_order_relaxed); - t.total_us.fetch_add(us, std::memory_order_relaxed); - auto pv = t.max_us.load(std::memory_order_relaxed); - while (us > pv && !t.max_us.compare_exchange_weak( - pv, us, std::memory_order_relaxed)) {} - } - break; - } - case Opcode::CloseIndex: { - if (f.payload.size() < 4) { reply = err("CloseIndex: bad payload"); break; } - std::uint32_t iid = read_u32_le(f.payload.data()); - auto iit = index_h_.find(iid); - if (iit != index_h_.end()) { - AdsCloseIndex(iit->second); - index_h_.erase(iit); - } - index_table_.erase(iid); - index_tag_.erase(iid); - reply.opcode = Opcode::CloseIndexAck; - break; - } - case Opcode::SetOrder: { - if (f.payload.size() < 8) { reply = err("SetOrder: bad payload"); break; } - std::uint32_t tid = read_u32_le(f.payload.data()); - std::uint32_t iid = read_u32_le(f.payload.data() + 4); - UNSIGNED32 orc = install_table_order(tid, iid); - if (orc != 0) { - // Distinguish unknown-index (5018, historical) from a - // bad table id: install returns NO_FILE_FOUND for both, - // and the table-id case previously read "bad table id". - auto it = tbls_.find(tid); - if (it == tbls_.end()) - reply = err("SetOrder: bad table id"); - else - reply = err("SetOrder", orc); - break; - } - reply.opcode = Opcode::SetOrderAck; - // NOTE: no key-count piggyback here (v1.09.46 tried it and - // reverted in v1.09.47): certifying the count needs - // commit_dirty_record + a count walk on the twin, which - // convoys under append storms (10-thread bulk append went - // 0.283 s -> 8.2 s on loopback). The fused navs keep their - // piggyback (browse-shaped traffic only). Client parsing - // below stays length-gated for forward compatibility. - break; - } - case Opcode::SetOrderByName: { - if (f.payload.size() < 4) { reply = err("SetOrderByName: bad payload"); break; } - std::uint32_t tid = read_u32_le(f.payload.data()); - std::string tag(reinterpret_cast( - f.payload.data() + 4), - f.payload.size() - 4); - ADSHANDLE ht = ensure_abi_handle(tid); - if (ht == 0) { reply = err("SetOrderByName: bad table id"); break; } - std::vector tb(tag.size() + 1); - std::memcpy(tb.data(), tag.data(), tag.size()); - UNSIGNED32 rrc = AdsSetIndexOrder(ht, - tag.empty() ? nullptr : tb.data()); - if (rrc != 0) { reply = err("SetOrderByName: " + tag, rrc); break; } - if (tag.empty()) ordered_tables_.erase(tid); - else ordered_tables_.insert(tid); - sync_engine_cursor(tid); - reply.opcode = Opcode::SetOrderByNameAck; - // No key-count piggyback (see SetOrder: reverted — append - // storms convoys). Fused navs keep theirs. - break; - } - case Opcode::Seek: - case Opcode::SeekLast: { - // payload: u32 index_id, u8 soft, key bytes. - if (f.payload.size() < 5) { reply = err("Seek: bad payload"); break; } - std::uint32_t iid = read_u32_le(f.payload.data()); - std::uint8_t soft = f.payload[4]; - std::string key(reinterpret_cast( - f.payload.data() + 5), - f.payload.size() - 5); - auto iit = index_h_.find(iid); - if (iit == index_h_.end()) { reply = err("Seek: bad index id"); break; } - std::vector kb(key.size() + 1); - std::memcpy(kb.data(), key.data(), key.size()); - UNSIGNED16 found = 0; - UNSIGNED32 rrc = (f.opcode == Opcode::SeekLast) - ? AdsSeekLast(iit->second, kb.data(), - static_cast(key.size()), - ADS_STRINGKEY, - &found) - : AdsSeek (iit->second, kb.data(), - static_cast(key.size()), - ADS_STRINGKEY, - static_cast(soft), - &found); - if (rrc != 0) { reply = err("Seek", rrc); break; } - // Read recno via the parent table's ABI handle; that - // also lets sync_engine_cursor reflect the new - // position on the engine cursor we keep alive - // alongside. - UNSIGNED32 rn = 0; - std::uint32_t parent_tid = 0; - if (auto tit = index_table_.find(iid); tit != index_table_.end()) { - parent_tid = tit->second; - if (ADSHANDLE ht = ensure_abi_handle(parent_tid); ht != 0) { - AdsGetRecordNum(ht, 0, &rn); - } - } - if (parent_tid != 0) sync_engine_cursor(parent_tid); - reply.opcode = (f.opcode == Opcode::SeekLast) - ? Opcode::SeekLastAck : Opcode::SeekAck; - reply.payload.push_back(static_cast(found != 0 ? 1 : 0)); - write_u32_le(rn, reply.payload); - // RCB 07/14/2026: M12.24 — append the row the seek landed on. - // - // The ack used to be just [u8 found][u32 recno], which meant the - // client knew WHERE it was but not WHAT was there: row_valid stayed - // false, so the first AdsGetField after a seek paid a whole extra - // FetchCurrentRow round-trip. Seek-then-read is the single most - // common thing a business app does, and it was costing 2 RTTs. - // Worse, the relation code (seek_remote_child_relation) papered - // over it by firing a GotoRecord straight after every seek purely - // to pull the row down — so a parent browse with a child relation - // paid 2 RTTs per parent ROW. The trailer kills both. - // - // Deliberately NO lookahead block here (depth 0). A relation - // re-seeks the child on every single parent row, so a block would - // drag N child rows over the wire per parent row and almost never - // be read. SAP draws the same line: read-ahead triggers on "a skip - // operation after ... any other movement operation", i.e. the skip - // earns the block, not the seek. - // - // Wire-safe both ways, no capability bit: an old client requires - // size() >= 5 and ignores trailing bytes; a new client against an - // old server sees a 5-byte ack, parses no trailer, and falls back - // to the FetchCurrentRow path exactly as before. - if (parent_tid != 0) pack_row_trailer(reply, parent_tid, 0); - // Reposition truth rides after the trailer (length-gated on - // the client; old peers ignore the tail). parent_tid != 0 - // whenever a twin exists to read it from. - if (parent_tid != 0) pack_bound_trailer(reply, parent_tid); - break; - } - // CreateIndex / SkipUnique / SetScope / ClearScope — - // remote bridges for the remaining index ops. CreateIndex - // takes the full AdsCreateIndex61 input and returns a - // single index id (multi-tag CDX additions are supported - // via repeated calls). SkipUnique walks distinct keys via - // the active order; SetScope / ClearScope manage top / - // bottom range bounds on an existing hIndex. - case Opcode::CreateIndex: { - if (f.payload.size() < 4 + 4 + 2) { - reply = err("CreateIndex: bad payload"); break; - } - std::size_t pos = 0; - std::uint32_t tid = read_u32_le(f.payload.data() + pos); pos += 4; - std::uint32_t options = read_u32_le(f.payload.data() + pos); pos += 4; - std::uint16_t pgsize = read_u16_le(f.payload.data() + pos); pos += 2; - auto pop_str = [&](std::string& out) -> bool { - if (pos + 2 > f.payload.size()) return false; - std::uint16_t n = read_u16_le(f.payload.data() + pos); - pos += 2; - if (pos + n > f.payload.size()) return false; - out.assign(reinterpret_cast( - f.payload.data() + pos), n); - pos += n; - return true; - }; - std::string path, tag, expr, cond, key_filter; - if (!pop_str(path) || !pop_str(tag) || !pop_str(expr) || - !pop_str(cond) || !pop_str(key_filter)) { - reply = err("CreateIndex: short payload"); break; - } - // Honor a directory-qualified bag name ("folder/Indexes/x.Z01") - // server-side; a bare filename keeps the table-folder fallback. - path = resolve_index_bag_path(path); - ADSHANDLE ht = ensure_abi_handle(tid); - if (ht == 0) { reply = err("CreateIndex: bad table id"); break; } - std::vector pb (path .size() + 1); std::memcpy(pb .data(), path .data(), path .size()); - std::vector tb (tag .size() + 1); std::memcpy(tb .data(), tag .data(), tag .size()); - std::vector eb (expr .size() + 1); std::memcpy(eb .data(), expr .data(), expr .size()); - std::vector cb (cond .size() + 1); std::memcpy(cb .data(), cond .data(), cond .size()); - std::vector kfb(key_filter.size() + 1); - std::memcpy(kfb.data(), key_filter.data(), key_filter.size()); - ADSHANDLE hidx = 0; - UNSIGNED32 rrc = AdsCreateIndex61( - ht, pb.data(), tb.data(), eb.data(), - cond.empty() ? nullptr : cb.data(), - key_filter.empty() ? nullptr : kfb.data(), - options, pgsize, &hidx); - if (rrc != 0) { reply = err("CreateIndex", rrc); break; } - std::uint32_t iid = next_id_++; - index_h_[iid] = hidx; - index_table_[iid] = tid; - index_tag_[iid] = tag; - reply.opcode = Opcode::CreateIndexAck; - write_u32_le(iid, reply.payload); - break; - } - // Remote AdsCreateTable: write the free table under the session - // data directory via the lazy ABI connection, then close it so - // the client can re-open through OpenTable (prod bag auto-open). - case Opcode::CreateTable: { - if (!sess_conn_) { - reply = err("CreateTable: not connected", - openads::AE_NO_CONNECTION); - break; - } - if (f.payload.size() < 6) { - reply = err("CreateTable: bad payload"); break; - } - std::size_t pos = 0; - std::uint16_t table_type = read_u16_le(f.payload.data() + pos); - pos += 2; - std::uint16_t char_type = read_u16_le(f.payload.data() + pos); - pos += 2; - std::uint16_t memo_bs = read_u16_le(f.payload.data() + pos); - pos += 2; - std::string name, fields; - if (!read_lstr16(f.payload, pos, name) || - !read_lstr16(f.payload, pos, fields)) { - reply = err("CreateTable: short payload"); break; - } - if (!ensure_abi_conn()) { - reply = err("CreateTable: no ABI connection"); break; - } - auto nb = to_cbuf(name); - auto fb = to_cbuf(fields); - ADSHANDLE hTable = 0; - UNSIGNED32 rrc = AdsCreateTable( - abi_conn_, nb.data(), nullptr, - table_type, char_type, 0, 0, memo_bs, - fb.data(), &hTable); - if (rrc != 0) { - reply = err("CreateTable", rrc); break; - } - // Files are on disk under the data dir; release the local - // handle so the client's subsequent OpenTable can take Shared. - if (hTable != 0) (void)AdsCloseTable(hTable); - reply.opcode = Opcode::CreateTableAck; - break; - } - case Opcode::DropTable: { - if (!sess_conn_) { - reply = err("DropTable: not connected", - openads::AE_NO_CONNECTION); - break; - } - std::size_t pos = 0; - std::string name; - if (!read_lstr16(f.payload, pos, name)) { - reply = err("DropTable: short payload"); break; - } - std::uint16_t delete_files = 0; - if (pos + 2 <= f.payload.size()) { - delete_files = read_u16_le(f.payload.data() + pos); - } - if (!ensure_abi_conn()) { - reply = err("DropTable: no ABI connection"); break; - } - auto nb = to_cbuf(name); - UNSIGNED32 rrc = AdsDropTable(abi_conn_, nb.data(), delete_files); - if (rrc != 0) { - reply = err("DropTable", rrc); break; - } - reply.opcode = Opcode::DropTableAck; - break; - } - // ── Server filesystem (EnableFileFunc) ────────────────────────── - // M12.33 — remote table enumeration. NOT gated by EnableFileFunc - // because listing tables is a core database operation. - case Opcode::FindTables: { - if (!sess_conn_) { - reply = err("FindTables: not connected", - openads::AE_NO_CONNECTION); - break; - } - std::size_t pos = 0; - std::string mask; - if (!read_lstr16(f.payload, pos, mask)) { - reply = err("FindTables: short payload"); break; - } - if (mask.empty()) mask = "*.dbf"; - auto r = sess_conn_->find_tables(mask); - if (!r) { - reply = err("FindTables", - static_cast(r.error().code)); - break; - } - auto& matches = r.value(); - reply.opcode = Opcode::FindTablesAck; - write_u32_le(static_cast(matches.size()), - reply.payload); - for (const auto& name : matches) { - write_lstr16(name, reply.payload); - } - break; - } - - case Opcode::FileExists: - case Opcode::FileErase: - case Opcode::FileRename: - case Opcode::FileSize: - case Opcode::FileMTime: - case Opcode::Directory: - case Opcode::DirExist: - case Opcode::DirMake: - case Opcode::DirRemove: - case Opcode::FOpen: - case Opcode::FCreate: - case Opcode::FClose: - case Opcode::FRead: - case Opcode::FWrite: - case Opcode::FSeek: { - if (!srv_->enable_file_func()) { - reply = err("file functions disabled", - openads::AE_ACCESS_DENIED); - break; - } - if (!sess_conn_) { - reply = err("fs: not connected", openads::AE_NO_CONNECTION); - break; - } - auto deny_path = [&](const std::string&) { - reply = err("path outside data directory", - openads::AE_ACCESS_DENIED); - }; - if (f.opcode == Opcode::FileExists) { - std::size_t pos = 0; - std::string path; - if (!read_lstr16(f.payload, pos, path)) { - reply = err("FileExists: short payload"); break; - } - auto abs = resolve_fs_client_path(path); - if (!abs) { deny_path(path); break; } - auto ex = openads::engine::fs_exists(*abs); - if (!ex) { - reply = err("FileExists", - static_cast(ex.error().code)); - break; - } - reply.opcode = Opcode::FileExistsAck; - reply.payload.push_back(ex.value() ? 1 : 0); - } else if (f.opcode == Opcode::FileErase) { - std::size_t pos = 0; - std::string path; - if (!read_lstr16(f.payload, pos, path)) { - reply = err("FileErase: short payload"); break; - } - auto abs = resolve_fs_client_path(path); - if (!abs) { deny_path(path); break; } - auto r = openads::engine::fs_erase(*abs); - if (!r) { - reply = err("FileErase", - static_cast(r.error().code)); - break; - } - reply.opcode = Opcode::FileEraseAck; - } else if (f.opcode == Opcode::FileRename) { - std::size_t pos = 0; - std::string old_p, new_p; - if (!read_lstr16(f.payload, pos, old_p) || - !read_lstr16(f.payload, pos, new_p)) { - reply = err("FileRename: short payload"); break; - } - auto a = resolve_fs_client_path(old_p); - auto b = resolve_fs_client_path(new_p); - if (!a || !b) { deny_path(old_p); break; } - auto r = openads::engine::fs_rename(*a, *b); - if (!r) { - reply = err("FileRename", - static_cast(r.error().code)); - break; - } - reply.opcode = Opcode::FileRenameAck; - } else if (f.opcode == Opcode::FileSize) { - std::size_t pos = 0; - std::string path; - if (!read_lstr16(f.payload, pos, path)) { - reply = err("FileSize: short payload"); break; - } - auto abs = resolve_fs_client_path(path); - if (!abs) { deny_path(path); break; } - auto sz = openads::engine::fs_size(*abs); - if (!sz) { - reply = err("FileSize", - static_cast(sz.error().code)); - break; - } - reply.opcode = Opcode::FileSizeAck; - std::uint64_t v = sz.value(); - for (int i = 0; i < 8; ++i) - reply.payload.push_back( - static_cast((v >> (8 * i)) & 0xFF)); - } else if (f.opcode == Opcode::FileMTime) { - std::size_t pos = 0; - std::string path; - if (!read_lstr16(f.payload, pos, path)) { - reply = err("FileMTime: short payload"); break; - } - auto abs = resolve_fs_client_path(path); - if (!abs) { deny_path(path); break; } - auto st = openads::engine::fs_stat_entry(*abs); - if (!st) { - reply = err("FileMTime", - static_cast(st.error().code)); - break; - } - const auto& se = st.value(); - reply.opcode = Opcode::FileMTimeAck; - reply.payload.push_back( - static_cast(se.year & 0xFF)); - reply.payload.push_back( - static_cast((se.year >> 8) & 0xFF)); - reply.payload.push_back(se.mon); - reply.payload.push_back(se.day); - reply.payload.push_back(se.hh); - reply.payload.push_back(se.mm); - reply.payload.push_back(se.ss); - } else if (f.opcode == Opcode::Directory) { - std::size_t pos = 0; - std::string mask; - if (!read_lstr16(f.payload, pos, mask)) { - reply = err("Directory: short payload"); break; - } - // Split "subdir/*.dbf" and jail-resolve the directory part. - std::string dir_part = "."; - std::string pat = mask.empty() ? "*" : mask; - auto slash = pat.find_last_of("/\\"); - if (slash != std::string::npos) { - dir_part = pat.substr(0, slash); - if (dir_part.empty()) dir_part = "."; - pat = pat.substr(slash + 1); - if (pat.empty()) pat = "*"; - } - auto abs_dir = resolve_fs_client_path(dir_part); - if (!abs_dir) { deny_path(dir_part); break; } - auto entries = - openads::engine::fs_directory(*abs_dir, pat); - if (!entries) { - reply = err("Directory", - static_cast(entries.error().code)); - break; - } - const auto& elist = entries.value(); - reply.opcode = Opcode::DirectoryAck; - write_u32_le(static_cast(elist.size()), - reply.payload); - for (const auto& e : elist) - openads::engine::pack_dir_entry(e, reply.payload); - } else if (f.opcode == Opcode::DirExist) { - std::size_t pos = 0; - std::string path; - if (!read_lstr16(f.payload, pos, path)) { - reply = err("DirExist: short payload"); break; - } - auto abs = resolve_fs_client_path(path); - if (!abs) { deny_path(path); break; } - auto ex = openads::engine::fs_dir_exist(*abs); - if (!ex) { - reply = err("DirExist", - static_cast(ex.error().code)); - break; - } - reply.opcode = Opcode::DirExistAck; - reply.payload.push_back(ex.value() ? 1 : 0); - } else if (f.opcode == Opcode::DirMake) { - std::size_t pos = 0; - std::string path; - if (!read_lstr16(f.payload, pos, path)) { - reply = err("DirMake: short payload"); break; - } - auto abs = resolve_fs_client_path(path); - if (!abs) { deny_path(path); break; } - auto r = openads::engine::fs_dir_make(*abs); - if (!r) { - reply = err("DirMake", - static_cast(r.error().code)); - break; - } - reply.opcode = Opcode::DirMakeAck; - } else if (f.opcode == Opcode::DirRemove) { - std::size_t pos = 0; - std::string path; - if (!read_lstr16(f.payload, pos, path)) { - reply = err("DirRemove: short payload"); break; - } - auto abs = resolve_fs_client_path(path); - if (!abs) { deny_path(path); break; } - auto r = openads::engine::fs_dir_remove(*abs); - if (!r) { - reply = err("DirRemove", - static_cast(r.error().code)); - break; - } - reply.opcode = Opcode::DirRemoveAck; - } else if (f.opcode == Opcode::FOpen || - f.opcode == Opcode::FCreate) { - std::size_t pos = 0; - std::string path; - if (!read_lstr16(f.payload, pos, path)) { - reply = err("FOpen: short payload"); break; - } - std::uint16_t mode = 0; - if (pos + 2 <= f.payload.size()) { - mode = read_u16_le(f.payload.data() + pos); - } - if (files_.size() >= openads::engine::kMaxSessionFiles) { - reply = err("too many open files", - openads::AE_INTERNAL_ERROR); - break; - } - auto abs = resolve_fs_client_path(path); - if (!abs) { deny_path(path); break; } - bool create = (f.opcode == Opcode::FCreate); - auto fo = openads::engine::fs_open( - *abs, mode, create); - if (!fo) { - reply = err(create ? "FCreate" : "FOpen", - static_cast(fo.error().code)); - break; - } - std::uint32_t id = next_file_id_++; - files_[id] = std::move(fo.value()); - reply.opcode = create ? Opcode::FCreateAck : Opcode::FOpenAck; - write_u32_le(id, reply.payload); - } else if (f.opcode == Opcode::FClose) { - if (f.payload.size() < 4) { - reply = err("FClose: short payload"); break; - } - std::uint32_t id = read_u32_le(f.payload.data()); - if (files_.erase(id) == 0) { - reply = err("FClose: bad handle", - openads::AE_INTERNAL_ERROR); - break; - } - reply.opcode = Opcode::FCloseAck; - } else if (f.opcode == Opcode::FRead) { - if (f.payload.size() < 8) { - reply = err("FRead: short payload"); break; - } - std::uint32_t id = read_u32_le(f.payload.data()); - std::uint32_t n = read_u32_le(f.payload.data() + 4); - if (n > openads::engine::kMaxFsIoChunk) - n = openads::engine::kMaxFsIoChunk; - auto it = files_.find(id); - if (it == files_.end()) { - reply = err("FRead: bad handle", - openads::AE_INTERNAL_ERROR); - break; - } - std::vector buf(n); - it->second->stream.read(buf.data(), - static_cast(n)); - auto got = static_cast( - it->second->stream.gcount()); - reply.opcode = Opcode::FReadAck; - write_u32_le(got, reply.payload); - reply.payload.insert( - reply.payload.end(), buf.begin(), - buf.begin() + static_cast(got)); - } else if (f.opcode == Opcode::FWrite) { - if (f.payload.size() < 8) { - reply = err("FWrite: short payload"); break; - } - std::uint32_t id = read_u32_le(f.payload.data()); - std::uint32_t n = read_u32_le(f.payload.data() + 4); - if (n > openads::engine::kMaxFsIoChunk) - n = openads::engine::kMaxFsIoChunk; - if (8u + n > f.payload.size()) { - reply = err("FWrite: short data"); break; - } - auto it = files_.find(id); - if (it == files_.end()) { - reply = err("FWrite: bad handle", - openads::AE_INTERNAL_ERROR); - break; - } - it->second->stream.write( - reinterpret_cast(f.payload.data() + 8), - static_cast(n)); - it->second->stream.flush(); - if (!it->second->stream) { - reply = err("FWrite: io error", - openads::AE_INTERNAL_ERROR); - break; - } - reply.opcode = Opcode::FWriteAck; - write_u32_le(n, reply.payload); - } else if (f.opcode == Opcode::FSeek) { - if (f.payload.size() < 9) { - reply = err("FSeek: short payload"); break; - } - std::uint32_t id = read_u32_le(f.payload.data()); - std::int32_t off = static_cast( - read_u32_le(f.payload.data() + 4)); - std::uint8_t origin = f.payload[8]; - auto it = files_.find(id); - if (it == files_.end()) { - reply = err("FSeek: bad handle", - openads::AE_INTERNAL_ERROR); - break; - } - std::ios::seekdir way = std::ios::beg; - if (origin == 1) way = std::ios::cur; - else if (origin == 2) way = std::ios::end; - it->second->stream.clear(); - // ONE shared fstream position — a second seekp with - // ios::cur would apply a relative offset twice. - it->second->stream.seekg(off, way); - auto pos = static_cast( - it->second->stream.tellg()); - reply.opcode = Opcode::FSeekAck; - write_u32_le(pos, reply.payload); - } - break; - } - case Opcode::ZipArchive: - case Opcode::UnzipArchive: { - // Server-side backup archiving (OAds_Zip/OAds_UnZip). - // Database ops — NOT gated by EnableFileFunc. Paths stay - // under the session data jail inside Connection. - if (!sess_conn_) { - reply = err("zip: not connected", - openads::AE_NO_CONNECTION); - break; - } - auto read_blob32 = [&](std::size_t& pos, - std::string& out) -> bool { - if (pos + 4 > f.payload.size()) return false; - std::uint32_t n = read_u32_le(f.payload.data() + pos); - pos += 4; - if (pos + n > f.payload.size()) return false; - out.assign(reinterpret_cast( - f.payload.data() + pos), - n); - pos += n; - return true; - }; - auto split_1f = [](const std::string& blob) { - std::vector v; - std::string cur; - for (char c : blob) { - if (c == '\x1F') { - if (!cur.empty()) v.push_back(std::move(cur)); - cur.clear(); - } else { - cur.push_back(c); - } - } - if (!cur.empty()) v.push_back(std::move(cur)); - return v; - }; - auto write_u64 = [](std::uint64_t v, - std::vector& o) { - for (int i = 0; i < 8; ++i) - o.push_back(static_cast((v >> (8 * i)) & - 0xFF)); - }; - if (f.opcode == Opcode::ZipArchive) { - // Layout: [u16 dir][u32 files][u16 zipName][u16 level] - // [u8 ov][u8 wp][u32 excl][u16 pwd]. - std::size_t pos = 0; - std::string d2, fb2, zn2, eb2, pw2; - std::uint16_t lv = 0; - std::uint8_t ov = 0, wp = 0; - bool ok = read_lstr16(f.payload, pos, d2) && - read_blob32(pos, fb2) && - read_lstr16(f.payload, pos, zn2) && - pos + 4 <= f.payload.size(); - if (ok) { - lv = read_u16_le(f.payload.data() + pos); - pos += 2; - ov = f.payload[pos++]; - wp = f.payload[pos++]; - ok = read_blob32(pos, eb2) && - read_lstr16(f.payload, pos, pw2) && - pos == f.payload.size(); - } - if (!ok) { - reply = err("ZipArchive: bad payload"); - break; - } - auto r = sess_conn_->zip_archive( - d2, split_1f(fb2), zn2, static_cast(lv), - ov != 0, pw2, split_1f(eb2), wp != 0); - if (!r) { - reply = err("ZipArchive", - static_cast( - r.error().code)); - break; - } - reply.opcode = Opcode::ZipArchiveAck; - write_u32_le(r.value().stats.files, reply.payload); - write_u64(r.value().stats.bytes, reply.payload); - write_u64(r.value().stats.archive_bytes, reply.payload); - write_lstr16(r.value().archive_rel, reply.payload); - } else { - std::size_t pos = 0; - std::string dir, zip, pwd; - if (!read_lstr16(f.payload, pos, dir) || - !read_lstr16(f.payload, pos, zip) || - !read_lstr16(f.payload, pos, pwd) || - pos + 2 > f.payload.size()) { - reply = err("UnzipArchive: bad payload"); - break; - } - const bool ov = f.payload[pos++] != 0; - const bool wp = f.payload[pos++] != 0; - if (pos != f.payload.size()) { - reply = err("UnzipArchive: bad payload"); - break; - } - auto r = sess_conn_->unzip_archive(dir, zip, pwd, ov, - wp); - if (!r) { - reply = err("UnzipArchive", - static_cast( - r.error().code)); - break; - } - reply.opcode = Opcode::UnzipArchiveAck; - write_u32_le(r.value().files, reply.payload); - write_u64(r.value().bytes, reply.payload); - write_u64(r.value().archive_bytes, reply.payload); - } - break; - } - case Opcode::ZipList: { - // Central-directory listing (OAds_ZipFileCount/List). - // Database op — NOT gated by EnableFileFunc. - if (!sess_conn_) { - reply = err("zip: not connected", - openads::AE_NO_CONNECTION); - break; - } - std::size_t pos = 0; - std::string zip; - if (!read_lstr16(f.payload, pos, zip) || - pos != f.payload.size()) { - reply = err("ZipList: bad payload"); - break; - } - auto r = sess_conn_->zip_list(zip); - if (!r) { - reply = err("ZipList", - static_cast(r.error().code)); - break; - } - std::vector packed; - for (const auto& e : r.value()) - openads::engine::zip_arch::pack_zip_entry(e, packed); - if (packed.size() > 16u * 1024u * 1024u) { - reply = err("ZipList", - static_cast( - openads::AE_INTERNAL_ERROR)); - break; - } - reply.opcode = Opcode::ZipListAck; - write_u32_le( - static_cast(r.value().size()), - reply.payload); - reply.payload.insert(reply.payload.end(), packed.begin(), - packed.end()); - break; - } - case Opcode::SkipUnique: { - if (f.payload.size() < 8) { reply = err("SkipUnique: bad payload"); break; } - std::uint32_t iid = read_u32_le(f.payload.data()); - std::int32_t dir = static_cast( - read_u32_le(f.payload.data() + 4)); - auto iit = index_h_.find(iid); - if (iit == index_h_.end()) { reply = err("SkipUnique: bad index id"); break; } - UNSIGNED32 rrc = AdsSkipUnique(iit->second, dir); - if (rrc != 0) { reply = err("SkipUnique", rrc); break; } - if (auto tit = index_table_.find(iid); tit != index_table_.end()) { - sync_engine_cursor(tit->second); - } - reply.opcode = Opcode::SkipUniqueAck; - break; - } - case Opcode::SetScope: { - // Payload: u32 index_id | u16 which | u16 data_type | bytes key. - if (f.payload.size() < 8) { reply = err("SetScope: bad payload"); break; } - std::uint32_t iid = read_u32_le(f.payload.data()); - std::uint16_t which = read_u16_le(f.payload.data() + 4); - std::uint16_t dtype = read_u16_le(f.payload.data() + 6); - std::size_t klen = f.payload.size() - 8; - auto iit = index_h_.find(iid); - if (iit == index_h_.end()) { reply = err("SetScope: bad index id"); break; } - std::vector kb(klen + 1); - if (klen > 0) std::memcpy(kb.data(), f.payload.data() + 8, klen); - UNSIGNED32 rrc = AdsSetScope( - iit->second, which, kb.data(), - static_cast(klen), dtype); - if (rrc != 0) { reply = err("SetScope", rrc); break; } - // Scope is stored on the ABI table's active order (via - // table_for_index inside AdsSetScope). GotoTop/Skip only - // honour index scope when they route through that ABI - // handle (ordered_tables_), not the parallel engine Table - // in tbls_. OrdScope / scoped-relation flows often set - // scope without a preceding SetOrder wire op — the client - // may already track active_index_id from auto-opened - // production CDX tags while ordered_tables_ is still - // empty, which made remote navigation ignore scope. - if (auto tit = index_table_.find(iid); tit != index_table_.end()) { - ordered_tables_.insert(tit->second); - if (ADSHANDLE ht = ensure_abi_handle(tit->second); ht != 0) { - (void)AdsSetIndexOrderByHandle(ht, iit->second); - sync_engine_cursor(tit->second); - } - } - reply.opcode = Opcode::SetScopeAck; - break; - } - case Opcode::GetKeyType: { - // M12.35 — return the key expression result type for a remote - // index. Payload: [u32 index_id]. Reply: [u16 key_type LE]. - if (f.payload.size() < 4) { - reply = err("GetKeyType: bad payload"); break; - } - std::uint32_t iid = read_u32_le(f.payload.data()); - auto iit = index_h_.find(iid); - if (iit == index_h_.end()) { - reply = err("GetKeyType: bad index id"); break; - } - UNSIGNED16 kt = 0; - UNSIGNED32 rrc = AdsGetKeyType(iit->second, &kt); - if (rrc != 0) { reply = err("GetKeyType", rrc); break; } - reply.opcode = Opcode::GetKeyTypeAck; - reply.payload.push_back(static_cast(kt & 0xFF)); - reply.payload.push_back(static_cast((kt >> 8) & 0xFF)); - break; - } - case Opcode::ShowDeleted: { - if (f.payload.size() < 1) { - reply = err("ShowDeleted: bad payload"); break; - } - const bool visible = f.payload[0] != 0; - show_deleted_ = visible; - openads::engine::set_show_deleted(visible); - if (sess_conn_) sess_conn_->set_show_deleted(visible); - if (abi_conn_ != 0) { - openads::abi::set_connection_show_deleted(abi_conn_, visible); - } - // RCB 07/14/2026: row visibility just changed for EVERY table on - // this session, so end the read-ahead run on all of them and let - // the depth ramp back up from the floor. - // - // This cannot go through breaks_prefetch_run() like the other - // run-enders: that mechanism reads the table id from the leading - // u32 of the payload, and ShowDeleted's payload is a single byte - // with no table id in it at all. Hence the explicit clear here. - prefetch_depth_.clear(); - reply.opcode = Opcode::ShowDeletedAck; - break; - } - case Opcode::ClearScope: { - if (f.payload.size() < 6) { reply = err("ClearScope: bad payload"); break; } - std::uint32_t iid = read_u32_le(f.payload.data()); - std::uint16_t which = read_u16_le(f.payload.data() + 4); - auto iit = index_h_.find(iid); - if (iit == index_h_.end()) { reply = err("ClearScope: bad index id"); break; } - UNSIGNED32 rrc = AdsClearScope(iit->second, which); - if (rrc != 0) { reply = err("ClearScope", rrc); break; } - reply.opcode = Opcode::ClearScopeAck; - break; - } - // M12.17 — single-frame whole-record read. The server - // walks every column once with AdsGetField against a - // suitably-sized buffer (memo lengths queried up-front) - // and packs each value into the ack so the client can - // serve subsequent FieldGet calls from cache without - // another RTT per cell. - case Opcode::FetchCurrentRow: { - if (f.payload.size() < 4) { reply = err("FetchCurrentRow: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - reply.opcode = Opcode::FetchCurrentRowAck; - pack_row_trailer(reply, id); - break; - } - // M12.7 — remote SQL exec. Lazy-creates a parallel ABI - // connection on the server side; cursor handles returned - // by AdsExecuteSQLDirect get wrapped in cursor_tbls_ so the - // existing read-side ops can serve them through the same - // wire opcodes. - case Opcode::ExecuteSQL: { - if (!sess_conn_) { reply = err("ExecuteSQL: not connected"); break; } - if (abi_conn_ == 0) { - if (!ensure_abi_conn()) { - reply = err("ExecuteSQL: AdsConnect60 failed"); - break; - } - if (AdsCreateSQLStatement(abi_conn_, &abi_stmt_) != 0) { - reply = err("ExecuteSQL: AdsCreateSQLStatement failed"); - break; - } - } - std::vector sqlbuf(f.payload.size() + 1); - if (!f.payload.empty()) { - std::memcpy(sqlbuf.data(), f.payload.data(), - f.payload.size()); - } - sqlbuf[f.payload.size()] = 0; - ADSHANDLE hCur = 0; - UNSIGNED32 rrc = AdsExecuteSQLDirect(abi_stmt_, - sqlbuf.data(), &hCur); - if (rrc != 0) { - reply = err("ExecuteSQL: server-side exec failed", rrc); - break; - } - std::uint32_t id = 0; - if (hCur != 0) { - id = next_id_++; - cursor_tbls_.emplace(id, hCur); - } - reply.opcode = Opcode::ExecuteSQLAck; - write_u32_le(id, reply.payload); - break; - } - case Opcode::AppendBlank: { - if (f.payload.size() < 4) { reply = err("AppendBlank: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("AppendBlank: bad table id"); break; - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("AppendBlank: lookup failed"); break; } - // M12.16 dual-handle: CreateIndex/OpenIndex bind bags on the - // parallel ABI Table (tbls_h_), not on the engine Table used by - // the historical write path. Writing only through the engine - // left production CDX bags empty after remote APPEND (Pritpal - // TestIndex: 36 rows / 0 keys). Prefer the ABI handle when it - // exists so sync_all_indexes_ updates every bound tag. - if (auto hit = tbls_h_.find(id); hit != tbls_h_.end()) { - UNSIGNED32 rrc = AdsAppendRecord(hit->second); - if (rrc != 0) { reply = err("AppendBlank", rrc); break; } - // Storm fix — the client commits with DbCommit→FlushTable - // (which flushes this same handle); a per-append flush here - // multiplied CDX page writes ~9x under the 700-storm and - // serialised every ABI op behind the batch. Durability is - // still delivered at commit time. - sync_engine_cursor(id); - tbl->set_pending_append(true); - } else { - auto r = tbl->append_record(); - if (!r) { reply = err("AppendBlank: append_record failed"); break; } - tbl->set_pending_append(true); - } - reply.opcode = Opcode::AppendBlankAck; - break; - } - case Opcode::SetField: { - // payload: [u32 tid][u16 name_len][name bytes][value bytes...] - if (f.payload.size() < 6) { reply = err("SetField: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - std::uint16_t nlen = static_cast( - static_cast(f.payload[4]) | - (static_cast(f.payload[5]) << 8)); - if (f.payload.size() < 6u + nlen) { - reply = err("SetField: truncated"); break; - } - std::string fname(reinterpret_cast( - f.payload.data() + 6), - nlen); - std::string val(reinterpret_cast( - f.payload.data() + 6 + nlen), - f.payload.size() - 6 - nlen); - - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("SetField: bad table id"); break; - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("SetField: lookup failed"); break; } - - auto wr = write_fields(id, tbl, {{fname, val}}); - if (wr.code != 0) { - reply = wr.column_missing - ? err("SetField: column not found") - : err("SetField: write failed", wr.code); - break; - } - reply.opcode = Opcode::SetFieldAck; - break; - } - case Opcode::SetFields: { - // payload: [u32 tid][u16 n][per field: u16 nlen][name] - // [u32 vlen][value]. Write-coalescing batch: the - // client buffers consecutive AdsSet* calls and flushes - // them here in ONE round-trip. First failing field - // stops the apply, exactly like a sequential SetField - // loop failing at the same field. - if (f.payload.size() < 6) { reply = err("SetFields: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - std::size_t pos = 4; - std::uint16_t n = static_cast( - static_cast(f.payload[pos]) | - (static_cast(f.payload[pos + 1]) << 8)); - pos += 2; - std::vector> pairs; - pairs.reserve(n); - bool truncated = false; - for (std::uint16_t k = 0; k < n; ++k) { - if (pos + 2 > f.payload.size()) { truncated = true; break; } - std::uint16_t nlen = static_cast( - static_cast(f.payload[pos]) | - (static_cast(f.payload[pos + 1]) << 8)); - pos += 2; - if (pos + nlen + 4 > f.payload.size()) { truncated = true; break; } - std::string fname(reinterpret_cast( - f.payload.data() + pos), nlen); - pos += nlen; - std::uint32_t vlen = - static_cast(f.payload[pos]) | - (static_cast(f.payload[pos + 1]) << 8) | - (static_cast(f.payload[pos + 2]) << 16) | - (static_cast(f.payload[pos + 3]) << 24); - pos += 4; - if (pos + vlen > f.payload.size()) { truncated = true; break; } - std::string val(reinterpret_cast( - f.payload.data() + pos), vlen); - pos += vlen; - pairs.emplace_back(std::move(fname), std::move(val)); - } - if (truncated) { reply = err("SetFields: truncated"); break; } - if (pairs.size() > openads::network::kMaxWireFields) { - reply = err("SetFields: too many fields"); break; - } - - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("SetFields: bad table id"); break; - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("SetFields: lookup failed"); break; } - - auto wr = write_fields(id, tbl, pairs); - if (wr.code != 0) { - reply = wr.column_missing - ? err("SetFields: column not found") - : err("SetFields: write failed", wr.code); - break; - } - reply.opcode = Opcode::SetFieldsAck; - break; - } - case Opcode::DeleteRecord: { - if (f.payload.size() < 4) { reply = err("DeleteRecord: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("DeleteRecord: bad table id"); break; - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("DeleteRecord: lookup failed"); break; } - if (auto hit = tbls_h_.find(id); hit != tbls_h_.end()) { - // Same twin-cursor sync as SetField: the delete must hit the - // row the engine cursor sits on, and the write guard must - // evaluate that row's lock. - UNSIGNED32 cur = 0; - AdsGetRecordNum(hit->second, 0, &cur); - UNSIGNED32 eng = tbl->recno(); - if (eng != 0 && cur != eng) { - (void)AdsGotoRecord(hit->second, eng); - } - UNSIGNED32 rrc = AdsDeleteRecord(hit->second); - if (rrc != 0) { reply = err("DeleteRecord", rrc); break; } - sync_engine_cursor(id); - } else { - auto r = tbl->mark_deleted(); - if (!r) { - reply = err("DeleteRecord: mark_deleted failed", - static_cast(r.error().code)); - break; - } - } - reply.opcode = Opcode::DeleteRecordAck; - break; - } - case Opcode::RecallRecord: { - if (f.payload.size() < 4) { reply = err("RecallRecord: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("RecallRecord: bad table id"); break; - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("RecallRecord: lookup failed"); break; } - if (auto hit = tbls_h_.find(id); hit != tbls_h_.end()) { - // Mirror DeleteRecord: locks taken via LockRecord land on - // the ABI twin, so recall must go through it too (with the - // same cursor sync) or the engine-side guard sees no lock. - UNSIGNED32 cur = 0; - AdsGetRecordNum(hit->second, 0, &cur); - UNSIGNED32 eng = tbl->recno(); - if (eng != 0 && cur != eng) { - (void)AdsGotoRecord(hit->second, eng); - } - UNSIGNED32 rrc = AdsRecallRecord(hit->second); - if (rrc != 0) { reply = err("RecallRecord", rrc); break; } - sync_engine_cursor(id); - } else { - auto r = tbl->recall_deleted(); - if (!r) { - reply = err("RecallRecord: recall_deleted failed", - static_cast(r.error().code)); - break; - } - } - reply.opcode = Opcode::RecallRecordAck; - break; - } - case Opcode::GotoRecord: { - if (f.payload.size() < 8) { reply = err("GotoRecord: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - std::uint32_t recno = read_u32_le(f.payload.data() + 4); - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("GotoRecord: bad table id"); break; - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("GotoRecord: lookup failed"); break; } - auto r = tbl->goto_record(recno); - if (!r) { reply = err("GotoRecord: failed"); break; } - // Physical GOTO moves only the engine cursor. When an order - // is active the parallel ABI handle (used by ordered Skip) must - // land on the same recno or xBrowse's bookmark DbGoto restore - // leaves the next index Skip walking from a stale key. - if (ordered_tables_.count(id)) { - if (ADSHANDLE hord = ensure_abi_handle(id)) { - (void)AdsGotoRecord(hord, recno); - } - } - reply.opcode = Opcode::GotoRecordAck; - pack_row_trailer(reply, id); - pack_bound_trailer(reply, id); - break; - } - case Opcode::FlushTable: { - if (f.payload.size() < 4) { reply = err("FlushTable: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("FlushTable: bad table id"); break; - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("FlushTable: lookup failed"); break; } - // Flush the ABI twin first (holds open index bags), then engine. - if (auto hit = tbls_h_.find(id); hit != tbls_h_.end()) { - (void)AdsFlushFileBuffers(hit->second); - } - // Remote AdsWriteRecord lands here: the append is now committed, - // so drop the pending-append marker (mirrors the local - // AdsWriteRecord which clears it). Without this the flag stayed - // set for the life of the server-side table. - tbl->set_pending_append(false); - auto r = tbl->flush(); - if (!r) { reply = err("FlushTable: flush failed"); break; } - reply.opcode = Opcode::FlushTableAck; - break; - } - case Opcode::Fetch: { - // M12.11 — payload: - // [u32 tid][u32 max_rows][u8 ncols][u8 nlen][name]... - // Reply: - // [u32 nrows][u8 ncols][per row, per col: u16 vlen][val] - if (f.payload.size() < 9) { reply = err("Fetch: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - std::uint32_t maxrows = read_u32_le(f.payload.data() + 4); - std::uint8_t ncols = f.payload[8]; - std::vector cols; - cols.reserve(ncols); - std::size_t p = 9; - bool parse_ok = true; - for (std::uint8_t c = 0; c < ncols && parse_ok; ++c) { - if (p + 1 > f.payload.size()) { - reply = err("Fetch: truncated col header"); - parse_ok = false; break; - } - std::uint8_t nlen = f.payload[p++]; - if (p + nlen > f.payload.size()) { - reply = err("Fetch: truncated col name"); - parse_ok = false; break; - } - cols.emplace_back( - reinterpret_cast(f.payload.data() + p), - nlen); - p += nlen; - } - if (!parse_ok) break; - - auto write_u16_le = [](std::vector& out, - std::uint16_t v) { - out.push_back(static_cast( v & 0xFFu)); - out.push_back(static_cast((v >> 8) & 0xFFu)); - }; - - reply.opcode = Opcode::FetchAck; - std::vector rowbuf; - std::uint32_t nrows_out = 0; - - if (auto cit = cursor_tbls_.find(id); cit != cursor_tbls_.end()) { - ADSHANDLE hCur = cit->second; - UNSIGNED16 atend = 0; - AdsAtEOF(hCur, &atend); - while (atend == 0 && nrows_out < maxrows) { - for (auto& cn : cols) { - UNSIGNED8 fbuf[64] = {0}; - UNSIGNED8 out [4096] = {0}; - UNSIGNED32 cap = sizeof(out); - std::size_t n = std::min( - cn.size(), sizeof(fbuf) - 1); - std::memcpy(fbuf, cn.data(), n); - fbuf[n] = 0; - UNSIGNED32 rrc = AdsGetField(hCur, fbuf, - out, &cap, 0); - if (rrc != 0) cap = 0; - write_u16_le(rowbuf, - static_cast(cap)); - rowbuf.insert(rowbuf.end(), out, out + cap); - } - ++nrows_out; - if (AdsSkip(hCur, 1) != 0) break; - AdsAtEOF(hCur, &atend); - } - } else if (auto it = tbls_.find(id); - it != tbls_.end() && sess_conn_) { - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("Fetch: lookup failed"); break; } - while (!tbl->eof() && nrows_out < maxrows) { - for (auto& cn : cols) { - std::int32_t fi = tbl->field_index(cn); - std::string val; - if (fi >= 0) { - auto v = tbl->read_field( - static_cast(fi)); - if (v) val = v.value().as_string; - } - write_u16_le(rowbuf, - static_cast(val.size())); - rowbuf.insert(rowbuf.end(), - val.begin(), val.end()); - } - ++nrows_out; - auto sk = tbl->skip(1); - if (!sk) break; - } - } else { - reply = err("Fetch: bad table id"); break; - } - - write_u32_le(nrows_out, reply.payload); - reply.payload.push_back(ncols); - reply.payload.insert(reply.payload.end(), - rowbuf.begin(), rowbuf.end()); - break; - } - case Opcode::FetchWhere: { - // Tier-2 server-side filtered scan. Payload: - // [u32 tid][u32 max_rows][u8 flags][u16 exprlen][expr] - // [u8 ncols][u8 nlen][name]... - // Reply (FetchWhereAck): - // [u32 nrows][u8 ncols] - // [per row: (u32 recno IF WANT_RECNO)(per col: u16 vlen,val)] - // [u8 eof] - // flags=0 reply is byte-identical to v1.4.0 (backward compat). - // The server walks the table from the cursor's current - // position, evaluating `expr` (Clipper-style FOR - // predicate) per row, and emits only the matching rows' - // requested columns until `max_rows` matches or EOF. - // The cursor is left positioned past the last examined - // row so a follow-up FetchWhere resumes the scan. - if (f.payload.size() < 12) { - reply = err("FetchWhere: bad payload"); break; - } - std::uint32_t id = read_u32_le(f.payload.data()); - std::uint32_t maxrows = read_u32_le(f.payload.data() + 4); - std::uint8_t flags = f.payload[8]; - std::uint16_t elen = - static_cast( - static_cast(f.payload[9]) | - (static_cast(f.payload[10]) << 8)); - std::size_t p = 11; - if (p + elen > f.payload.size()) { - reply = err("FetchWhere: truncated expr"); break; - } - std::string expr( - reinterpret_cast(f.payload.data() + p), - elen); - p += elen; - if (p + 1 > f.payload.size()) { - reply = err("FetchWhere: missing ncols"); break; - } - std::uint8_t ncols = f.payload[p++]; - std::vector cols; - cols.reserve(ncols); - bool parse_ok = true; - for (std::uint8_t c = 0; c < ncols && parse_ok; ++c) { - if (p + 1 > f.payload.size()) { - reply = err("FetchWhere: truncated col header"); - parse_ok = false; break; - } - std::uint8_t nlen = f.payload[p++]; - if (p + nlen > f.payload.size()) { - reply = err("FetchWhere: truncated col name"); - parse_ok = false; break; - } - cols.emplace_back( - reinterpret_cast(f.payload.data() + p), - nlen); - p += nlen; - } - if (!parse_ok) break; - - auto write_u16_le = [](std::vector& out, - std::uint16_t v) { - out.push_back(static_cast( v & 0xFFu)); - out.push_back(static_cast((v >> 8) & 0xFFu)); - }; - - // Slice 1 is base-table only: a SQL cursor already - // filters server-side via its WHERE clause, and the - // FOR-predicate evaluator needs an engine Table to read - // the current record. Reject cursor ids with a clear - // error rather than silently mis-filtering. - if (cursor_tbls_.find(id) != cursor_tbls_.end()) { - reply = err("FetchWhere: not supported on SQL " - "cursors (use SQL WHERE)"); - break; - } - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("FetchWhere: bad table id"); break; - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("FetchWhere: lookup failed"); break; } - - reply.opcode = Opcode::FetchWhereAck; - std::vector rowbuf; - std::uint32_t nrows_out = 0; - while (!tbl->eof() && nrows_out < maxrows) { - if (openads::engine::evaluate_index_expr_truthy( - *tbl, expr)) { - if (flags & FetchWhereFlags::WANT_RECNO) { - write_u32_le(tbl->recno(), rowbuf); - } - for (auto& cn : cols) { - std::int32_t fi = tbl->field_index(cn); - std::string val; - if (fi >= 0) { - auto v = tbl->read_field( - static_cast(fi)); - if (v) val = v.value().as_string; - } - write_u16_le(rowbuf, - static_cast(val.size())); - rowbuf.insert(rowbuf.end(), - val.begin(), val.end()); - } - ++nrows_out; - } - auto sk = tbl->skip(1); - if (!sk) break; - } - - write_u32_le(nrows_out, reply.payload); - reply.payload.push_back(ncols); - reply.payload.insert(reply.payload.end(), - rowbuf.begin(), rowbuf.end()); - reply.payload.push_back( - static_cast(tbl->eof() ? 1 : 0)); - break; - } - case Opcode::CustomizeAOF: { - if (f.payload.size() < 7) { - reply = err("CustomizeAOF: bad payload"); break; - } - std::uint32_t id = read_u32_le(f.payload.data()); - std::uint8_t opt = f.payload[4]; - std::uint16_t nrecs = static_cast( - static_cast(f.payload[5]) | - (static_cast(f.payload[6]) << 8)); - std::size_t p = 7; - if (f.payload.size() < p + static_cast(nrecs) * 4u) { - reply = err("CustomizeAOF: truncated recnos"); break; - } - if (cursor_tbls_.find(id) != cursor_tbls_.end()) { - reply = err("CustomizeAOF: not supported on SQL cursors"); - break; - } - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("CustomizeAOF: bad table id"); break; - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("CustomizeAOF: lookup failed"); break; } - if (!tbl->aof_active()) { - reply = err("CustomizeAOF: no active AOF"); break; - } - bool include = false; - if (opt == 1) include = true; - else if (opt == 2) include = false; - else { reply = err("CustomizeAOF: invalid option"); break; } - for (std::uint16_t i = 0; i < nrecs; ++i) { - std::uint32_t recno = read_u32_le(f.payload.data() + p); - p += 4; - (void)tbl->customize_aof_record(recno, include); - } - reply.opcode = Opcode::CustomizeAOFAck; - break; - } - case Opcode::GetRecord: { - if (f.payload.size() < 4) { - reply = err("GetRecord: bad payload"); break; - } - std::uint32_t id = read_u32_le(f.payload.data()); - if (cursor_tbls_.find(id) != cursor_tbls_.end()) { - reply = err("GetRecord: not supported on SQL cursors"); - break; - } - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("GetRecord: bad table id"); break; - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("GetRecord: lookup failed"); break; } - if (!tbl->positioned() || tbl->eof() || tbl->bof() || - tbl->recno() == 0) { - reply = err("GetRecord: no current record"); break; - } - const auto& buf = tbl->record_buffer(); - if (buf.size() > 0xFFFFu) { - reply = err("GetRecord: record too large"); break; - } - reply.opcode = Opcode::GetRecordAck; - auto write_u16 = [](std::vector& out, - std::uint16_t v) { - out.push_back(static_cast( v & 0xFFu)); - out.push_back(static_cast((v >> 8) & 0xFFu)); - }; - write_u16(reply.payload, - static_cast(buf.size())); - reply.payload.insert(reply.payload.end(), - buf.begin(), buf.end()); - break; - } - case Opcode::GetRecordCRC: { - if (f.payload.size() < 4) { - reply = err("GetRecordCRC: bad payload"); break; - } - std::uint32_t id = read_u32_le(f.payload.data()); - if (cursor_tbls_.find(id) != cursor_tbls_.end()) { - reply = err("GetRecordCRC: not supported on SQL cursors"); - break; - } - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("GetRecordCRC: bad table id"); break; - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("GetRecordCRC: lookup failed"); break; } - if (!tbl->positioned() || tbl->eof() || tbl->bof() || - tbl->recno() == 0) { - reply = err("GetRecordCRC: no current record"); break; - } - const std::uint32_t crc = - openads::engine::crc32_record(tbl->record_buffer()); - reply.opcode = Opcode::GetRecordCRAck; - reply.payload.push_back(static_cast( crc & 0xFFu)); - reply.payload.push_back(static_cast((crc >> 8) & 0xFFu)); - reply.payload.push_back(static_cast((crc >> 16) & 0xFFu)); - reply.payload.push_back(static_cast((crc >> 24) & 0xFFu)); - break; - } - case Opcode::SetRecord: { - if (f.payload.size() < 6) { - reply = err("SetRecord: bad payload"); break; - } - std::uint32_t id = read_u32_le(f.payload.data()); - std::uint16_t rlen = static_cast( - static_cast(f.payload[4]) | - (static_cast(f.payload[5]) << 8)); - if (f.payload.size() < 6u + rlen) { - reply = err("SetRecord: truncated record"); break; - } - if (cursor_tbls_.find(id) != cursor_tbls_.end()) { - reply = err("SetRecord: not supported on SQL cursors"); - break; - } - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("SetRecord: bad table id"); break; - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("SetRecord: lookup failed"); break; } - auto r = tbl->set_record_raw(f.payload.data() + 6, - static_cast(rlen)); - if (!r) { reply = err("SetRecord: write failed"); break; } - reply.opcode = Opcode::SetRecordAck; - break; - } - case Opcode::Aggregate: { - // Tier-3 server-side aggregation. Payload: - // [u32 tid][u16 forlen][for_expr][u8 n_aggs] - // per agg: [u8 fn_type][u8 nlen][field_name] - // Reply (AggregateAck): - // [u8 n_aggs] per agg: [u8 result_type][u16 vlen][val] - // The server scans the whole table once (independent of the - // cursor position), folds each matching row into the - // accumulators, and returns one scalar per requested agg. - if (f.payload.size() < 7) { - reply = err("Aggregate: bad payload"); break; - } - std::uint32_t id = read_u32_le(f.payload.data()); - std::uint16_t flen = - static_cast( - static_cast(f.payload[4]) | - (static_cast(f.payload[5]) << 8)); - std::size_t p = 6; - if (p + flen > f.payload.size()) { - reply = err("Aggregate: truncated expr"); break; - } - std::string for_expr( - reinterpret_cast(f.payload.data() + p), flen); - p += flen; - if (p + 1 > f.payload.size()) { - reply = err("Aggregate: missing n_aggs"); break; - } - std::uint8_t naggs = f.payload[p++]; - struct AggReq { std::uint8_t fn; std::string field; }; - std::vector specs; - specs.reserve(naggs); - bool parse_ok = true; - for (std::uint8_t i = 0; i < naggs && parse_ok; ++i) { - if (p + 2 > f.payload.size()) { - reply = err("Aggregate: truncated agg header"); - parse_ok = false; break; - } - AggReq s; - s.fn = f.payload[p++]; - std::uint8_t nlen = f.payload[p++]; - if (p + nlen > f.payload.size()) { - reply = err("Aggregate: truncated field name"); - parse_ok = false; break; - } - s.field.assign( - reinterpret_cast(f.payload.data() + p), - nlen); - p += nlen; - specs.push_back(std::move(s)); - } - if (!parse_ok) break; - - // Base tables only — a SQL cursor aggregates via SQL. - if (cursor_tbls_.find(id) != cursor_tbls_.end()) { - reply = err("Aggregate: not supported on SQL cursors " - "(use SQL aggregates)"); - break; - } - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("Aggregate: bad table id"); break; - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("Aggregate: lookup failed"); break; } - - auto field_is_numeric = - [](openads::drivers::DbfFieldType t) { - using T = openads::drivers::DbfFieldType; - switch (t) { - case T::Numeric: case T::Float: - case T::Integer: case T::Currency: - case T::Double: case T::ShortInt: - case T::AutoInc: case T::AdtMoney: - return true; - default: - return false; - } - }; - - // One accumulator per spec; resolve field index + numeric-ness - // (the latter drives numeric vs lexical MIN/MAX). - std::vector accs; - std::vector fidx; - accs.reserve(specs.size()); - fidx.reserve(specs.size()); - // Validate every spec before touching the table: an unknown - // field name (field_index -> -1) must be rejected, never folded - // as COUNT(*) -- that would silently return the row count for a - // typo'd or injected field. Only COUNT may omit the field. - bool specs_ok = true; - for (const auto& s : specs) { - const auto fn = static_cast(s.fn); - if (s.field.empty()) { - if (fn != openads::engine::AggFn::Count) { - reply = err("Aggregate: empty field only valid for " - "COUNT"); - specs_ok = false; - break; - } - fidx.push_back(-1); - accs.emplace_back(fn, false); - continue; - } - std::int32_t fi = tbl->field_index(s.field); - if (fi < 0) { - reply = err("Aggregate: unknown field " + s.field); - specs_ok = false; - break; - } - const auto& fd = - tbl->field_descriptor(static_cast(fi)); - accs.emplace_back(fn, field_is_numeric(fd.type)); - fidx.push_back(fi); - } - if (!specs_ok) break; - - // Scan the whole table once, restoring the cursor afterwards. - std::uint32_t saved = tbl->recno(); - bool was_eof = tbl->eof(); - tbl->goto_top(); - while (!tbl->eof()) { - if (openads::engine::evaluate_index_expr_truthy( - *tbl, for_expr)) { - for (std::size_t i = 0; i < accs.size(); ++i) { - if (fidx[i] < 0) { - accs[i].feed(false, 0.0, ""); // COUNT(*) - } else { - auto v = tbl->read_field( - static_cast(fidx[i])); - if (v) { - const auto& dv = v.value(); - accs[i].feed(dv.is_null, dv.as_double, - dv.as_string); - } else { - accs[i].feed(true, 0.0, ""); - } - } - } - } - if (!tbl->skip(1)) break; - } - if (!was_eof && saved >= 1 && saved <= tbl->record_count()) - tbl->goto_record(saved); - else - tbl->goto_top(); - - reply.opcode = Opcode::AggregateAck; - auto write_u16 = [](std::vector& out, - std::uint16_t v) { - out.push_back(static_cast( v & 0xFFu)); - out.push_back(static_cast((v >> 8) & 0xFFu)); - }; - reply.payload.push_back( - static_cast(accs.size())); - for (auto& a : accs) { - openads::engine::AggValue val = a.finalize(); - reply.payload.push_back( - static_cast(val.type)); - write_u16(reply.payload, - static_cast(val.bytes.size())); - reply.payload.insert(reply.payload.end(), - val.bytes.begin(), val.bytes.end()); - } - break; - } - case Opcode::Reindex: { - if (f.payload.size() < 4) { reply = err("Reindex: bad payload"); break; } - std::uint32_t id = read_u32_le(f.payload.data()); - auto it = tbls_.find(id); - if (it == tbls_.end() || !sess_conn_) { - reply = err("Reindex: bad table id"); break; - } - auto* tbl = sess_conn_->lookup_table(it->second); - if (!tbl) { reply = err("Reindex: lookup failed"); break; } - auto r = tbl->reindex(); - if (!r) { reply = err("Reindex: reindex failed"); break; } - // The table's bags (production .cdx/.z01 and any OpenIndex - // bag) are bound on the ABI twin (tbls_h_), not on the engine - // table, so the engine reindex above finds no index and is a - // no-op. Rebuild the twin's bags too, as Pack/Zap already do. - // Flush first so the rebuild reads every committed row; - // AdsGotoTop refreshes the twin's cached record count. - if (auto hit = tbls_h_.find(id); hit != tbls_h_.end()) { - if (auto fl = tbl->flush(); !fl) { - reply = err("Reindex: flush failed"); break; - } - (void)AdsGotoTop(hit->second); - UNSIGNED32 rrc = AdsReindex(hit->second); - if (rrc != 0) { reply = err("Reindex", rrc); break; } - } - reply.opcode = Opcode::ReindexAck; - break; - } - case Opcode::MgConnect: { - // Management handshake. Optional [u16 ulen][user] payload — - // when a caller (e.g. DA-Web's Server Info tab) knows which DD - // user it's asking on behalf of, register this mgmt session - // under that name so it shows up as (say) "adssys" instead of - // "(anonymous)" in AdsMgGetUserNames / the Connected Users grid. - // Every accepted socket already gets a session (register_session - // in Session::run(), before any opcode is dispatched), so this - // mgmt-only connection is already tracked — it just never had a - // user name attached to it until now. - if (f.payload.size() >= 2) { - std::uint16_t ulen = static_cast( - static_cast(f.payload[0]) | - (static_cast(f.payload[1]) << 8)); - if (f.payload.size() >= static_cast(2 + ulen) && ulen > 0) { - std::string mgUser(reinterpret_cast(f.payload.data() + 2), ulen); - srv_->set_session_user(sid_, mgUser, ""); - } - } - reply.opcode = Opcode::MgConnectAck; - std::string ok = "mg-ok"; - reply.payload.assign(ok.begin(), ok.end()); - break; - } - case Opcode::MgRequest: { - // Iterator-based ctor: payload.data() may be nullptr when empty, - // and std::string(nullptr, 0) is UB. - std::string reqbuf(f.payload.begin(), f.payload.end()); - auto req = decode_mg_request(reqbuf); - if (!req) { - reply = err("bad mg request"); - break; - } - switch (req.value().kind) { - case MgRequestKind::Snapshot: { - reply.opcode = Opcode::MgReplyAck; - std::string snap = - encode_mg_snapshot(srv_->build_mg_snapshot()); - reply.payload.assign(snap.begin(), snap.end()); - break; - } - case MgRequestKind::KillUser: { - // arg is the 1-based connection number; map it - // to the matching session id and kill it. - srv_->kill_session_by_conn_no(req.value().arg); - reply.opcode = Opcode::MgReplyAck; - break; - } - case MgRequestKind::ResetCommStats: { - openads::mgmt::process_mg_stats().reset_comm(); - reply.opcode = Opcode::MgReplyAck; - break; - } - case MgRequestKind::DumpTables: { - reply.opcode = Opcode::MgReplyAck; - break; - } - default: - reply = err("unknown mg request kind"); - break; - } - break; - } - // M12.29 — AdsDD* Data Dictionary property API, phase 1. See - // docs/wire-protocol.md §9 / wire.h for the payload formats. - case Opcode::DDGetProperty: { - if (!ensure_abi_conn()) { reply = err("DDGetProperty: connect failed"); break; } - if (f.payload.empty()) { reply = err("DDGetProperty: bad payload"); break; } - auto kind = static_cast(f.payload[0]); - std::size_t off = 1; - std::string name, subName; - if (!read_lstr16(f.payload, off, name) || - !read_lstr16(f.payload, off, subName) || - off + 2 > f.payload.size()) { - reply = err("DDGetProperty: bad payload"); break; - } - UNSIGNED16 propId = read_u16_le(f.payload.data() + off); - UNSIGNED16 len = 0; - UNSIGNED32 rc = dd_get_property_dispatch(abi_conn_, kind, name, subName, - propId, nullptr, &len); - if (rc != 0) { reply = err("DDGetProperty", rc); break; } - std::vector buf(len > 0 ? len : 1); - UNSIGNED16 cap = len; - rc = dd_get_property_dispatch(abi_conn_, kind, name, subName, propId, - buf.data(), &cap); - if (rc != 0) { reply = err("DDGetProperty", rc); break; } - reply.opcode = Opcode::DDGetPropertyAck; - write_u32_le(cap, reply.payload); - reply.payload.insert(reply.payload.end(), buf.begin(), buf.begin() + cap); - break; - } - case Opcode::DDSetProperty: { - if (!ensure_abi_conn()) { reply = err("DDSetProperty: connect failed"); break; } - if (f.payload.empty()) { reply = err("DDSetProperty: bad payload"); break; } - auto kind = static_cast(f.payload[0]); - std::size_t off = 1; - std::string name, subName; - if (!read_lstr16(f.payload, off, name) || - !read_lstr16(f.payload, off, subName) || - off + 2 > f.payload.size()) { - reply = err("DDSetProperty: bad payload"); break; - } - UNSIGNED16 propId = read_u16_le(f.payload.data() + off); - off += 2; - if (off + 4 > f.payload.size()) { reply = err("DDSetProperty: bad payload"); break; } - UNSIGNED32 valLen = read_u32_le(f.payload.data() + off); - off += 4; - if (off + valLen > f.payload.size()) { reply = err("DDSetProperty: bad payload"); break; } - // The underlying local AdsDDSet*Property signatures take a - // 16-bit length (the same cap ads_misc.c's PHP extension - // already applies for local calls) — not a wire limitation. - if (valLen > 0xFFFFu) { reply = err("DDSetProperty: value too large"); break; } - void* valPtr = valLen > 0 - ? const_cast(f.payload.data() + off) : nullptr; - UNSIGNED32 rc = dd_set_property_dispatch(abi_conn_, kind, name, subName, - propId, valPtr, static_cast(valLen)); - if (rc != 0) { reply = err("DDSetProperty", rc); break; } - reply.opcode = Opcode::DDSetPropertyAck; - break; - } - case Opcode::DDCreateProc: { - if (!ensure_abi_conn()) { reply = err("DDCreateProc: connect failed"); break; } - std::size_t off = 0; - std::string name, container, procName, inParams, outParams, comments; - if (!read_lstr16(f.payload, off, name) || - !read_lstr16(f.payload, off, container) || - !read_lstr16(f.payload, off, procName) || - !read_lstr16(f.payload, off, inParams) || - !read_lstr16(f.payload, off, outParams) || - !read_lstr16(f.payload, off, comments)) { - reply = err("DDCreateProc: bad payload"); break; - } - auto nameBuf = to_cbuf(name), contBuf = to_cbuf(container), - procBuf = to_cbuf(procName), inBuf = to_cbuf(inParams), - outBuf = to_cbuf(outParams), cmtBuf = to_cbuf(comments); - UNSIGNED32 rc = AdsDDCreateProcedure(abi_conn_, nameBuf.data(), - contBuf.data(), procBuf.data(), 0, inBuf.data(), outBuf.data(), - cmtBuf.data()); - if (rc != 0) { reply = err("DDCreateProc", rc); break; } - reply.opcode = Opcode::DDCreateProcAck; - break; - } - case Opcode::DDCreateFunction: { - if (!ensure_abi_conn()) { reply = err("DDCreateFunction: connect failed"); break; } - std::size_t off = 0; - std::string name, container, impl, retType, inParams, comment; - if (!read_lstr16(f.payload, off, name) || - !read_lstr16(f.payload, off, container) || - !read_lstr16(f.payload, off, impl) || - !read_lstr16(f.payload, off, retType) || - !read_lstr16(f.payload, off, inParams) || - !read_lstr16(f.payload, off, comment)) { - reply = err("DDCreateFunction: bad payload"); break; - } - auto nameBuf = to_cbuf(name), contBuf = to_cbuf(container), - implBuf = to_cbuf(impl), retBuf = to_cbuf(retType), - inBuf = to_cbuf(inParams), cmtBuf = to_cbuf(comment); - UNSIGNED32 rc = AdsDDCreateFunction(abi_conn_, nameBuf.data(), - contBuf.data(), implBuf.data(), retBuf.data(), inBuf.data(), - cmtBuf.data()); - if (rc != 0) { reply = err("DDCreateFunction", rc); break; } - reply.opcode = Opcode::DDCreateFunctionAck; - break; - } - case Opcode::DDCreateTrigger: { - if (!ensure_abi_conn()) { reply = err("DDCreateTrigger: connect failed"); break; } - std::size_t off = 0; - std::string name, table, container, procedure; - if (!read_lstr16(f.payload, off, name) || - !read_lstr16(f.payload, off, table) || - off + 4 > f.payload.size()) { - reply = err("DDCreateTrigger: bad payload"); break; - } - UNSIGNED32 type = read_u32_le(f.payload.data() + off); - off += 4; - if (!read_lstr16(f.payload, off, container) || - !read_lstr16(f.payload, off, procedure) || - off + 4 > f.payload.size()) { - reply = err("DDCreateTrigger: bad payload"); break; - } - UNSIGNED32 priority = read_u32_le(f.payload.data() + off); - auto nameBuf = to_cbuf(name), tblBuf = to_cbuf(table), - contBuf = to_cbuf(container), procBuf = to_cbuf(procedure); - UNSIGNED32 rc = AdsDDCreateTrigger(abi_conn_, nameBuf.data(), - tblBuf.data(), type, 0, contBuf.data(), procBuf.data(), priority); - if (rc != 0) { reply = err("DDCreateTrigger", rc); break; } - reply.opcode = Opcode::DDCreateTriggerAck; - break; - } - case Opcode::DDDropTrigger: { - if (!ensure_abi_conn()) { reply = err("DDDropTrigger: connect failed"); break; } - std::size_t off = 0; - std::string name; - if (!read_lstr16(f.payload, off, name)) { - reply = err("DDDropTrigger: bad payload"); break; - } - auto nameBuf = to_cbuf(name); - UNSIGNED32 rc = AdsDDDropTrigger(abi_conn_, nameBuf.data()); - if (rc != 0) { reply = err("DDDropTrigger", rc); break; } - reply.opcode = Opcode::DDDropTriggerAck; - break; - } - case Opcode::DDDropView: { - if (!ensure_abi_conn()) { reply = err("DDDropView: connect failed"); break; } - std::size_t off = 0; - std::string name; - if (!read_lstr16(f.payload, off, name)) { - reply = err("DDDropView: bad payload"); break; - } - auto nameBuf = to_cbuf(name); - UNSIGNED32 rc = AdsDDDropView(abi_conn_, nameBuf.data()); - if (rc != 0) { reply = err("DDDropView", rc); break; } - reply.opcode = Opcode::DDDropViewAck; - break; - } - case Opcode::DDDropLink: { - if (!ensure_abi_conn()) { reply = err("DDDropLink: connect failed"); break; } - std::size_t off = 0; - std::string name; - if (!read_lstr16(f.payload, off, name)) { - reply = err("DDDropLink: bad payload"); break; - } - auto nameBuf = to_cbuf(name); - UNSIGNED32 rc = AdsDDDropLink(abi_conn_, nameBuf.data(), 0); - if (rc != 0) { reply = err("DDDropLink", rc); break; } - reply.opcode = Opcode::DDDropLinkAck; - break; - } - // M12.30 — AdsDD* Data Dictionary property API, phase 2. See - // docs/wire-protocol.md §5.25 / wire.h for the payload formats. - case Opcode::DDCreateUser: { - if (!ensure_abi_conn()) { reply = err("DDCreateUser: connect failed"); break; } - std::size_t off = 0; - std::string group, user, pwd, desc; - if (!read_lstr16(f.payload, off, group) || - !read_lstr16(f.payload, off, user) || - !read_lstr16(f.payload, off, pwd) || - !read_lstr16(f.payload, off, desc)) { - reply = err("DDCreateUser: bad payload"); break; - } - auto groupBuf = to_cbuf(group), userBuf = to_cbuf(user), - pwdBuf = to_cbuf(pwd), descBuf = to_cbuf(desc); - UNSIGNED32 rc = AdsDDCreateUser(abi_conn_, groupBuf.data(), - userBuf.data(), pwdBuf.data(), descBuf.data()); - if (rc != 0) { reply = err("DDCreateUser", rc); break; } - reply.opcode = Opcode::DDCreateUserAck; - break; - } - case Opcode::DDDropObject: { - if (!ensure_abi_conn()) { reply = err("DDDropObject: connect failed"); break; } - if (f.payload.empty()) { reply = err("DDDropObject: bad payload"); break; } - auto kind = static_cast(f.payload[0]); - std::size_t off = 1; - std::string name; - if (!read_lstr16(f.payload, off, name)) { - reply = err("DDDropObject: bad payload"); break; - } - auto nameBuf = to_cbuf(name); - UNSIGNED32 rc = openads::AE_FUNCTION_NOT_AVAILABLE; - switch (kind) { - case DDObjectKind::User: - rc = AdsDDDeleteUser(abi_conn_, nameBuf.data()); break; - case DDObjectKind::RefIntegrity: - rc = AdsDDRemoveRefIntegrity(abi_conn_, nameBuf.data()); break; - case DDObjectKind::Proc: - rc = AdsDDDropProcedure(abi_conn_, nameBuf.data()); break; - case DDObjectKind::Function: - rc = AdsDDDropFunction(abi_conn_, nameBuf.data()); break; - default: break; - } - if (rc != 0) { reply = err("DDDropObject", rc); break; } - reply.opcode = Opcode::DDDropObjectAck; - break; - } - case Opcode::DDAddUserToGroup: { - if (!ensure_abi_conn()) { reply = err("DDAddUserToGroup: connect failed"); break; } - std::size_t off = 0; - std::string group, user; - if (!read_lstr16(f.payload, off, group) || - !read_lstr16(f.payload, off, user)) { - reply = err("DDAddUserToGroup: bad payload"); break; - } - auto groupBuf = to_cbuf(group), userBuf = to_cbuf(user); - UNSIGNED32 rc = AdsDDAddUserToGroup(abi_conn_, groupBuf.data(), userBuf.data()); - if (rc != 0) { reply = err("DDAddUserToGroup", rc); break; } - reply.opcode = Opcode::DDAddUserToGroupAck; - break; - } - case Opcode::DDRemoveUserFromGroup: { - if (!ensure_abi_conn()) { reply = err("DDRemoveUserFromGroup: connect failed"); break; } - std::size_t off = 0; - std::string group, user; - if (!read_lstr16(f.payload, off, group) || - !read_lstr16(f.payload, off, user)) { - reply = err("DDRemoveUserFromGroup: bad payload"); break; - } - auto groupBuf = to_cbuf(group), userBuf = to_cbuf(user); - UNSIGNED32 rc = AdsDDRemoveUserFromGroup(abi_conn_, groupBuf.data(), userBuf.data()); - if (rc != 0) { reply = err("DDRemoveUserFromGroup", rc); break; } - reply.opcode = Opcode::DDRemoveUserFromGroupAck; - break; - } - case Opcode::DDCreateLink: { - if (!ensure_abi_conn()) { reply = err("DDCreateLink: connect failed"); break; } - std::size_t off = 0; - std::string alias, path, user, pwd; - if (!read_lstr16(f.payload, off, alias) || - !read_lstr16(f.payload, off, path) || - !read_lstr16(f.payload, off, user) || - !read_lstr16(f.payload, off, pwd)) { - reply = err("DDCreateLink: bad payload"); break; - } - auto aliasBuf = to_cbuf(alias), pathBuf = to_cbuf(path), - userBuf = to_cbuf(user), pwdBuf = to_cbuf(pwd); - UNSIGNED32 rc = AdsDDCreateLink(abi_conn_, aliasBuf.data(), pathBuf.data(), - userBuf.data(), pwdBuf.data(), 0); - if (rc != 0) { reply = err("DDCreateLink", rc); break; } - reply.opcode = Opcode::DDCreateLinkAck; - break; - } - case Opcode::DDModifyLink: { - if (!ensure_abi_conn()) { reply = err("DDModifyLink: connect failed"); break; } - std::size_t off = 0; - std::string alias, path, user, pwd; - if (!read_lstr16(f.payload, off, alias) || - !read_lstr16(f.payload, off, path) || - !read_lstr16(f.payload, off, user) || - !read_lstr16(f.payload, off, pwd)) { - reply = err("DDModifyLink: bad payload"); break; - } - auto aliasBuf = to_cbuf(alias), pathBuf = to_cbuf(path), - userBuf = to_cbuf(user), pwdBuf = to_cbuf(pwd); - UNSIGNED32 rc = AdsDDModifyLink(abi_conn_, aliasBuf.data(), pathBuf.data(), - userBuf.data(), pwdBuf.data(), 0); - if (rc != 0) { reply = err("DDModifyLink", rc); break; } - reply.opcode = Opcode::DDModifyLinkAck; - break; - } - case Opcode::DDCreateRefIntegrity: { - if (!ensure_abi_conn()) { reply = err("DDCreateRefIntegrity: connect failed"); break; } - std::size_t off = 0; - std::string name, failTbl, parent, parentTag, child, childTag; - if (!read_lstr16(f.payload, off, name) || - !read_lstr16(f.payload, off, failTbl) || - !read_lstr16(f.payload, off, parent) || - !read_lstr16(f.payload, off, parentTag) || - !read_lstr16(f.payload, off, child) || - !read_lstr16(f.payload, off, childTag) || - off + 4 > f.payload.size()) { - reply = err("DDCreateRefIntegrity: bad payload"); break; - } - UNSIGNED16 updateRule = read_u16_le(f.payload.data() + off); off += 2; - UNSIGNED16 deleteRule = read_u16_le(f.payload.data() + off); off += 2; - auto nameBuf = to_cbuf(name), failBuf = to_cbuf(failTbl), - parentBuf = to_cbuf(parent), parentTagBuf = to_cbuf(parentTag), - childBuf = to_cbuf(child), childTagBuf = to_cbuf(childTag); - UNSIGNED32 rc = AdsDDCreateRefIntegrity(abi_conn_, nameBuf.data(), - failBuf.data(), parentBuf.data(), parentTagBuf.data(), - childBuf.data(), childTagBuf.data(), updateRule, deleteRule); - if (rc != 0) { reply = err("DDCreateRefIntegrity", rc); break; } - reply.opcode = Opcode::DDCreateRefIntegrityAck; - break; - } - case Opcode::DDCreateView: { - if (!ensure_abi_conn()) { reply = err("DDCreateView: connect failed"); break; } - std::size_t off = 0; - std::string name, comments; - if (!read_lstr16(f.payload, off, name) || - !read_lstr16(f.payload, off, comments) || - off + 4 > f.payload.size()) { - reply = err("DDCreateView: bad payload"); break; - } - std::uint32_t sqlLen = read_u32_le(f.payload.data() + off); - off += 4; - if (off + sqlLen > f.payload.size()) { - reply = err("DDCreateView: bad payload"); break; - } - std::string sql(reinterpret_cast(f.payload.data() + off), sqlLen); - off += sqlLen; - auto nameBuf = to_cbuf(name), commentsBuf = to_cbuf(comments), - sqlBuf = to_cbuf(sql); - UNSIGNED32 rc = AdsDDCreateView(abi_conn_, nameBuf.data(), - commentsBuf.data(), sqlBuf.data()); - if (rc != 0) { reply = err("DDCreateView", rc); break; } - reply.opcode = Opcode::DDCreateViewAck; - break; - } - case Opcode::DDAddIndexFile: { - if (!ensure_abi_conn()) { reply = err("DDAddIndexFile: connect failed"); break; } - std::size_t off = 0; - std::string table, index, comment; - if (!read_lstr16(f.payload, off, table) || - !read_lstr16(f.payload, off, index) || - !read_lstr16(f.payload, off, comment)) { - reply = err("DDAddIndexFile: bad payload"); break; - } - auto tableBuf = to_cbuf(table), indexBuf = to_cbuf(index), - commentBuf = to_cbuf(comment); - UNSIGNED32 rc = AdsDDAddIndexFile(abi_conn_, tableBuf.data(), - indexBuf.data(), commentBuf.data()); - if (rc != 0) { reply = err("DDAddIndexFile", rc); break; } - reply.opcode = Opcode::DDAddIndexFileAck; - break; - } - case Opcode::DDRemoveIndexFile: { - if (!ensure_abi_conn()) { reply = err("DDRemoveIndexFile: connect failed"); break; } - std::size_t off = 0; - std::string table, index; - if (!read_lstr16(f.payload, off, table) || - !read_lstr16(f.payload, off, index)) { - reply = err("DDRemoveIndexFile: bad payload"); break; - } - auto tableBuf = to_cbuf(table), indexBuf = to_cbuf(index); - UNSIGNED32 rc = AdsDDRemoveIndexFile(abi_conn_, tableBuf.data(), - indexBuf.data(), 0); - if (rc != 0) { reply = err("DDRemoveIndexFile", rc); break; } - reply.opcode = Opcode::DDRemoveIndexFileAck; - break; - } - case Opcode::DDGetPermissions: { - if (!ensure_abi_conn()) { reply = err("DDGetPermissions: connect failed"); break; } - std::size_t off = 0; - std::string grantee; - if (!read_lstr16(f.payload, off, grantee) || - off + 2 > f.payload.size()) { - reply = err("DDGetPermissions: bad payload"); break; - } - UNSIGNED16 objType = read_u16_le(f.payload.data() + off); off += 2; - std::string objName; - if (!read_lstr16(f.payload, off, objName) || - off + 1 > f.payload.size()) { - reply = err("DDGetPermissions: bad payload"); break; - } - UNSIGNED16 getInherited = f.payload[off]; off += 1; - auto granteeBuf = to_cbuf(grantee), objNameBuf = to_cbuf(objName); - UNSIGNED32 permissions = 0; - UNSIGNED32 rc = AdsDDGetPermissions(abi_conn_, granteeBuf.data(), - objType, objNameBuf.data(), nullptr, getInherited, &permissions); - if (rc != 0) { reply = err("DDGetPermissions", rc); break; } - reply.opcode = Opcode::DDGetPermissionsAck; - write_u32_le(permissions, reply.payload); - break; - } - case Opcode::DDGrantPermission: { - if (!ensure_abi_conn()) { reply = err("DDGrantPermission: connect failed"); break; } - std::size_t off = 0; - if (off + 2 > f.payload.size()) { - reply = err("DDGrantPermission: bad payload"); break; - } - UNSIGNED16 objType = read_u16_le(f.payload.data() + off); off += 2; - std::string objName, grantee; - if (!read_lstr16(f.payload, off, objName) || - !read_lstr16(f.payload, off, grantee) || - off + 4 > f.payload.size()) { - reply = err("DDGrantPermission: bad payload"); break; - } - std::uint32_t permissions = read_u32_le(f.payload.data() + off); - auto objNameBuf = to_cbuf(objName), granteeBuf = to_cbuf(grantee); - UNSIGNED32 rc = AdsDDGrantPermission(abi_conn_, objType, objNameBuf.data(), - nullptr, granteeBuf.data(), permissions); - if (rc != 0) { reply = err("DDGrantPermission", rc); break; } - reply.opcode = Opcode::DDGrantPermissionAck; - break; - } - case Opcode::Mutex: { - if (f.payload.empty()) { - reply = err("Mutex: empty payload"); break; - } - std::uint8_t sub_op = f.payload[0]; - std::size_t pos = 1; - std::string name; - if (!read_lstr16(f.payload, pos, name)) { - reply = err("Mutex: short payload"); break; - } - // Session identifier: use connection serial as owner - std::string owner = conn_serial(); - auto& mm = srv_->mutex_manager(); - switch (static_cast(sub_op)) { - case MutexOp::Create: { - // Record the creating session so its death reaps the - // name (release_session in cleanup) instead of wedging - // every later MutexCreate until server restart. - bool ok = mm.create(name, owner); - reply.opcode = Opcode::Mutex; - reply.payload = { sub_op, static_cast(ok ? 1 : 0) }; - break; - } - case MutexOp::Lock: { - std::uint32_t timeout_ms = 0; - if (pos + 4 <= f.payload.size()) { - timeout_ms = read_u32_le(f.payload.data() + pos); - } - bool ok = mm.lock(name, timeout_ms, owner); - reply.opcode = Opcode::Mutex; - reply.payload = { sub_op, static_cast(ok ? 1 : 0) }; - break; - } - case MutexOp::TryLock: { - bool ok = mm.try_lock(name, owner); - reply.opcode = Opcode::Mutex; - reply.payload = { sub_op, static_cast(ok ? 1 : 0) }; - break; - } - case MutexOp::Unlock: { - bool ok = mm.unlock(name, owner); - reply.opcode = Opcode::Mutex; - reply.payload = { sub_op, static_cast(ok ? 1 : 0) }; - break; - } - case MutexOp::Destroy: { - bool ok = mm.destroy(name, owner); - reply.opcode = Opcode::Mutex; - reply.payload = { sub_op, static_cast(ok ? 1 : 0) }; - break; - } - default: - reply = err("Mutex: unknown sub-opcode"); - break; - } - break; - } - default: { - reply = err("unsupported opcode"); - break; - } - } - return { std::move(reply), false }; -} - -// Pritpal Bedi 29/07/2026 — a remote OrdCreate / AdsCreateIndex61 ignored -// the folder in the index bag name: "cFolder/Indexes/foo.Z01" always landed -// next to the .DBF, because the client used to strip every bag name to its -// basename before sending. The client now sends the path verbatim and the -// SERVER decides where the index goes, mirroring the rules -// Connection::resolve_table_file applies to the .DBF itself: -// - bare filename -> returned unchanged; AdsCreateIndex61 -// falls back to the table's own folder -// - relative with directories -> anchored at the connection data root -// - absolute inside data root -> honored verbatim -// - absolute outside data root -> drive/root folded, remainder joined -// under the data root -// A jail escape (".." outside the root) degrades to the bare filename. -std::string Session::resolve_index_bag_path(const std::string& bag) const { - namespace fs = std::filesystem; - if (!sess_conn_ || sess_conn_->data_dir().empty()) return bag; - std::string s = bag; - for (char& ch : s) if (ch == '\\') ch = '/'; - if (s.find('/') == std::string::npos) return bag; // bare filename - // Same remount as the .DBF: --legacy-paths always folds a client- - // absolute bag under --data (C:/Creative.RAM/T.Z01 → - // /Creative.RAM/T.Z01). Honouring a host-absolute path that - // merely existed next to the app is what put .z01 on the local - // tree while the table updated in the jail (Pritpal Bedi, 12/08/2026). - auto type = openads::engine::TableType::Cdx; - std::string resolved = - sess_conn_->resolve_table_file(s, type, /*for_create=*/true); - std::error_code ec; - fs::create_directories(fs::path(resolved).parent_path(), ec); - return resolved; -} - -std::optional Session::resolve_fs_client_path( - const std::string& client_path) const { - if (!sess_conn_) return std::nullopt; - // Prefer the session connection data directory (already jailed at - // Connect time). Fall back to server multi-root list. - // --legacy-paths: use the prefix-stripping resolver so absolute - // client paths ("C:/TEMP/...") map onto the root instead of - // folding to "/TEMP/...". - if (!sess_conn_->data_dir().empty()) { - if (srv_ && srv_->legacy_paths()) { - return openads::platform::resolve_client_path( - {sess_conn_->data_dir()}, client_path); - } - return openads::platform::resolve_fs_path(sess_conn_->data_dir(), - client_path); - } - if (srv_ && !srv_->data_dir_.empty()) { - auto roots = openads::platform::split_data_roots(srv_->data_dir_); - if (srv_->legacy_paths()) { - return openads::platform::resolve_client_path(roots, client_path); - } - return openads::platform::resolve_fs_path(roots, client_path); - } - return std::nullopt; -} - -} // namespace openads::network From fb5f47f4e1fd140f374b9d25f1e59662b1e242b2 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Sat, 26 Sep 2026 01:29:43 -0500 Subject: [PATCH 53/97] Delete src/abi/6-ace_exports.cpp --- src/abi/6-ace_exports.cpp | 42184 ------------------------------------ 1 file changed, 42184 deletions(-) delete mode 100644 src/abi/6-ace_exports.cpp diff --git a/src/abi/6-ace_exports.cpp b/src/abi/6-ace_exports.cpp deleted file mode 100644 index 92407c22..00000000 --- a/src/abi/6-ace_exports.cpp +++ /dev/null @@ -1,42184 +0,0 @@ -#include -#include "openads/ace.h" -#include "openads/error.h" -#include "openads_version.h" // OPENADS_VERSION_STR (CMake-generated) -#include "abi/lock_retry_policy.h" - -// Expose lock_retry_policy() at file scope so ADS_SETLOCKCYCLE etc. can use it. -using openads::abi::lock_retry_policy; - -#include -#include -#include -#include -#include -#include - -#include "abi/backend_table_ops.h" -#include "abi/backend_registry.h" -#include "abi/charset.h" -#include "abi/last_error.h" -#include "abi/runtime.h" - -#include "engine/aof_eval.h" -#include "engine/aof_expr.h" -#include "engine/codepage.h" -#include "engine/fts.h" -#include "engine/aggregate.h" -#include "sql_backend/backend_tx_manager.h" -#include "sql_backend/sql_acl_store.h" -#include "sql_backend/sql_ddl.h" -#include "sql_backend/sql_system_catalog.h" -#include "engine/backup.h" -#include "engine/index_expr.h" -#include "engine/oem_collation.h" -#include "engine/record_crc.h" -#include "engine/table.h" -#include "engine/server_fs.h" -#include "platform/file.h" -#include "platform/fs_sandbox.h" - -#include "network/client.h" -#include "network/remote_index_nav.h" -#if defined(OPENADS_WITH_TLS) -#include "network/tls_transport.h" -#endif -#include "network/mg_wire.h" -#include "network/server.h" -#include "network/socket.h" -#include "mgmt/error_log.h" -#include "mgmt/mg_collector.h" -#include "mgmt/mg_stats.h" -#include "session/connection.h" -#include "session/handle_registry.h" -#include "util/log.h" -#include "util/client_config.h" -#if defined(OPENADS_WITH_SQLITE) -#include "sql_backend/sqlite_connection.h" -#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" -#include "sql_backend/oracle_uri.h" -#endif -#if defined(OPENADS_WITH_MSSQL) -#include "sql_backend/mssql_connection.h" -#include "sql_backend/mssql_index.h" -#include "sql_backend/mssql_table.h" -#include "sql_backend/mssql_uri.h" -#endif -#if defined(OPENADS_WITH_FIREBIRD) -#include "sql_backend/firebird_connection.h" -#include "sql_backend/firebird_index.h" -#include "sql_backend/firebird_uri.h" -#endif -#include "drivers/dbf_common.h" -#include "drivers/index_trait.h" -#include "drivers/ntx/ntx_index.h" -#include "drivers/adt/adt_driver.h" -#include "drivers/cdx/cdx_driver.h" -#include "drivers/cdx/cdx_index.h" -#include "drivers/adi/adi_index.h" -#include "drivers/adm/adm_memo.h" -#include "drivers/fpt/fpt_memo.h" -#include "drivers/memory/memory_driver.h" -#include "engine/script/exec.h" -#include "engine/script/lexer.h" -#include "engine/script/parser.h" -#include "platform/path.h" -#include "platform/proc.h" -#include "platform/time.h" -#include "sql/parser.h" - -#include -#include -#include -#include -#include -#include - -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include -#include - -extern "C++" { -namespace { - -using openads::engine::Table; -using openads::session::Connection; -using openads::session::Handle; -using openads::session::HandleKind; - -// S5 -- SAP display formatting for Date / Timestamp fields. Defined with the -// date-format machinery near AdsSetDateFormat (bottom of file); declared -// here because AdsGetField sits much earlier in this TU. -// -// SAP's rule, probed against ace64.dll (see docs/date-display-format.md): -// AdsGetFIELD returns the value FORMATTED per the connection date format -// ("01/15/2024", blank " / / "); AdsGetSTRING returns the raw -// storage text ("20240115") regardless of format. The two entry points -// intentionally differ -- do not "unify" them. -std::string format_date_display(const std::string& raw); -std::string format_timestamp_display(const std::string& raw); -// Inverse direction: parse text laid out per the CURRENT date format into -// compact "YYYYMMDD" ("03/07/2025" -> "20250307" under MM/DD/CCYY); empty -// result = text does not match the format. AdsSetDate / AdsSetTimeStamp -// route through this, mirroring SAP's format-driven parses. -std::string parse_date_by_format(const std::string& text); -// AdsGetString delegates remote/backend handles through AdsGetField; this -// flag keeps that delegated read RAW so GetString semantics hold there too. -extern thread_local bool g_field_read_raw; - -// SAP ACE defines ADSHANDLE as 32-bit on all platforms while the internal -// registry Handle is 64-bit; centralise the (value-preserving) narrowing. -ADSHANDLE to_ads_handle(Handle h) { return static_cast(h); } - -using openads::abi::detail::ProcessState; -using openads::abi::detail::state; - -// Serialise the file-creating entry points (AdsCreateTable local paths, -// AdsCreateIndex61 native branch) PER TARGET PATH. All serverd sessions -// run in one process, so this closes the TOCTOU window where N racing -// creators each saw "file missing" and all truncated the same new -// DBF/CDX (field report: N=200 remote processes corrupting one table). -// Leaf lock: never take state().mu or any other lock while holding it. -std::mutex& create_path_mu_for(std::string key) { - // Path case varies by caller on case-insensitive filesystems. - for (auto& c : key) - c = static_cast( - std::tolower(static_cast(c))); - static std::mutex g_mu; - static std::unordered_map> - g_map; - std::lock_guard lk(g_mu); - auto& slot = g_map[key]; // never erased: create targets are few - if (!slot) slot = std::make_unique(); - return *slot; -} - -UNSIGNED32 ok() { - openads::abi::clear_last_error(); - return openads::AE_SUCCESS; -} - -UNSIGNED32 fail(const openads::util::Error& e) { - openads::abi::set_last_error(e); - return static_cast(e.code); -} - -UNSIGNED32 fail(int code, const char* msg) { - return fail(openads::util::Error{code, 0, msg ? msg : "", ""}); -} - -// Write a brand-new table file atomically w.r.t. other readers/writers: -// CreateExclusive denies every other open for the duration, so a -// concurrent AdsOpenTable can never read a partially-written header -// (5103 storm) and a create over a file another connection holds OPEN -// fails with AE_FILE_IN_USE (7040) instead of truncating the live table -// underneath its appenders. Overwriting a CLOSED existing file succeeds, -// matching SAP ADS. Returns 7040 when the create failed because the file -// exists (open elsewhere or lost the create race), 5000 otherwise. -UNSIGNED32 write_new_table_file(const std::string& full, - const std::vector& bytes, - const char* op) { - namespace fs = std::filesystem; - auto fres = openads::platform::File::open( - full, openads::platform::OpenMode::CreateExclusive); - if (!fres) { - std::error_code ec; - if (fs::exists(full, ec)) { - return fail(openads::util::Error{ - static_cast(openads::AE_FILE_IN_USE), 0, - std::string(op) + ": file is in use", ""}); - } - return fail(openads::util::Error{ - static_cast(openads::AE_INTERNAL_ERROR), 0, - std::string(op) + ": " + fres.error().message, ""}); - } - auto file = std::move(fres).value(); - auto wrote = file.write_at(0, bytes.data(), bytes.size()); - if (!wrote || wrote.value() != bytes.size()) { - return fail(openads::util::Error{ - static_cast(openads::AE_INTERNAL_ERROR), 0, - std::string(op) + ": write failed", ""}); - } - return openads::AE_SUCCESS; // close (releases exclusivity) via RAII -} - -// ── Tier-1 auto-commit hook ─────────────────────────────────────── -// After a successful DML operation on a SQL backend table, mark the -// transaction dirty and fire auto-commit if the threshold is reached. -// Returns the auto-commit result (ok or error from commit). -template -UNSIGNED32 hook_auto_commit(TableT* tbl) { - if (tbl == nullptr || tbl->conn == nullptr) return ok(); - auto& tx = tbl->conn->tx_manager(); - if (!tx.in_transaction()) return ok(); - tx.dirty = true; - tx.note_dml(); - return ok(); -} - -#if defined(OPENADS_WITH_SQLITE) -void ensure_sqlite_tx_wired(openads::sql_backend::SqliteConnection* c) { - if (!c) return; - openads::sql_backend::wire_standard_savepoint_tx( - c->tx_manager(), [c](const std::string& sql) { (void)c->exec_sql(sql); }); -} -#endif -#if defined(OPENADS_WITH_POSTGRESQL) -void ensure_postgres_tx_wired(openads::sql_backend::PostgresConnection* c) { - if (!c) return; - auto& tx = c->tx_manager(); - if (tx.on_begin) return; - openads::sql_backend::wire_standard_savepoint_tx( - tx, [c](const std::string& sql) { (void)c->exec_sql(sql); }); -} -#endif -#if defined(OPENADS_WITH_MARIADB) -void ensure_maria_tx_wired(openads::sql_backend::MariaConnection* c) { - if (!c) return; - openads::sql_backend::wire_standard_savepoint_tx( - c->tx_manager(), [c](const std::string& sql) { (void)c->exec_sql(sql); }); -} -#endif -#if defined(OPENADS_WITH_ODBC) -void ensure_odbc_tx_wired(openads::sql_backend::OdbcConnection* c) { - if (!c) return; - openads::sql_backend::wire_standard_savepoint_tx( - c->tx_manager(), [c](const std::string& sql) { (void)c->exec_sql(sql); }); -} -#endif -#if defined(OPENADS_WITH_FIREBIRD) -void ensure_firebird_tx_wired(openads::sql_backend::FirebirdConnection* c) { - if (!c) return; - openads::sql_backend::wire_standard_savepoint_tx( - c->tx_manager(), [c](const std::string& sql) { (void)c->exec_sql(sql); }); -} -#endif -#if defined(OPENADS_WITH_MSSQL) -void ensure_mssql_tx_wired(openads::sql_backend::MssqlConnection* c) { - if (!c) return; - openads::sql_backend::wire_mssql_savepoint_tx( - c->tx_manager(), [c](const std::string& sql) { (void)c->exec_sql(sql); }); -} -#endif - -openads::engine::TableType map_type(UNSIGNED16 t) { - switch (t) { - case ADS_NTX: return openads::engine::TableType::Ntx; - case ADS_CDX: return openads::engine::TableType::Cdx; - case ADS_ADT: return openads::engine::TableType::Adt; - case ADS_VFP: return openads::engine::TableType::Vfp; - default: return openads::engine::TableType::Cdx; - } -} - -openads::engine::OpenMode map_open_mode(UNSIGNED16 mode) { - if (mode == ADS_READONLY) return openads::engine::OpenMode::Read; - if (mode == ADS_EXCLUSIVE) return openads::engine::OpenMode::Exclusive; - return openads::engine::OpenMode::Shared; -} - -openads::engine::LockingMode map_locking_mode(UNSIGNED16 lock_type) { - return (lock_type == ADS_PROPRIETARY_LOCKING) - ? openads::engine::LockingMode::Proprietary - : openads::engine::LockingMode::Compatible; -} - -// Stamp DBF header bytes [1..3] (YY MM DD, year as offset from 1900) -// with today's UTC date -- what a real ADS server records when it -// creates or modifies a table. Without this a freshly-created table -// reports a "1900-00-00" last-update stamp until its first record -// write triggers CdxDriver::rewrite_header_(). UTC keeps the two paths -// consistent. `hdr` must point at the start of the 32-byte header. -void stamp_dbf_header_today(std::uint8_t* hdr) { - std::time_t secs = static_cast( - openads::platform::utc_unix_micros() / 1'000'000); - std::tm tm_utc{}; -#ifdef _WIN32 - gmtime_s(&tm_utc, &secs); -#else - gmtime_r(&secs, &tm_utc); -#endif - hdr[1] = static_cast(tm_utc.tm_year); - hdr[2] = static_cast(tm_utc.tm_mon + 1); - hdr[3] = static_cast(tm_utc.tm_mday); -} - -// dBASE/FoxPro field-descriptor bytes 12-15 hold the field's little-endian -// displacement within the record (record byte 0 is the deletion flag, so -// the first field is at 1). OpenADS left them zero: its own reader and -// tolerant ones (LibreOffice) compute offsets from the field lengths and -// never notice, but stricter readers reject the header as corrupt and it -// is simply not what a spec-conforming writer emits (Pritpal Bedi, -// 29/07/2026 -- DBFCDX "corruption detected" report). Stamp the running -// displacement over a just-built descriptor block: `descriptors` points at -// the first 32-byte descriptor, lengths come from each fd[16]. -// `first_offset` is 1 for plain DBF, 5 when a 4-byte VFP _NullFlags -// bitmap precedes the user fields. -void stamp_dbf_field_displacements(std::uint8_t* descriptors, - std::size_t field_count, - std::uint32_t first_offset = 1) { - std::uint32_t off = first_offset; - for (std::size_t i = 0; i < field_count; ++i) { - std::uint8_t* fd = descriptors + i * 32; - fd[12] = static_cast( off & 0xFFu); - fd[13] = static_cast((off >> 8) & 0xFFu); - fd[14] = static_cast((off >> 16) & 0xFFu); - fd[15] = static_cast((off >> 24) & 0xFFu); - off += fd[16]; - } -} - -// Harbour DBFCDX production-index flag (DBF header byte 28, bit 0x01): -// a CDX bag whose basename matches the table's is the structural index -// and Harbour auto-opens it on USE when the bit is set. Stamp it after -// bag creation when the names match. Skipped for OpenADS-encrypted -// tables (header byte 0 = 0xC3/0xC4), whose bytes 28-31 hold the -// encryption-bitmap offset. -void stamp_production_index_flag(openads::engine::Table* t, - const std::string& bag_path) { - if (t == nullptr) return; - namespace fs = std::filesystem; - auto stem_ci = [](const std::string& p) { - std::string s = fs::path(p).stem().string(); - for (auto& c : s) { - c = static_cast( - std::tolower(static_cast(c))); - } - return s; - }; - if (stem_ci(bag_path) != stem_ci(t->path())) return; - auto* drv = t->driver(); - if (drv == nullptr) return; - std::uint8_t b0 = 0, b28 = 0; - auto r0 = drv->file().read_at(0, &b0, 1); - if (!r0 || r0.value() < 1) return; - if (b0 == 0xC3 || b0 == 0xC4) return; - auto r28 = drv->file().read_at(28, &b28, 1); - if (!r28 || r28.value() < 1) return; - if ((b28 & 0x01u) != 0) return; - b28 = static_cast(b28 | 0x01u); - (void)drv->file().write_at(28, &b28, 1); -} - -UNSIGNED16 map_field_type(openads::drivers::DbfFieldType t) { - using openads::drivers::DbfFieldType; - // Constants verified empirically (M8.4) against - // c:\harbour\lib\win\msvc64\rddads.lib -- see include/openads/ace.h - // for the full sweep table. - switch (t) { - case DbfFieldType::Character: return ADS_STRING; // 4 - case DbfFieldType::Numeric: - case DbfFieldType::Float: return ADS_NUMERIC; // 2 - case DbfFieldType::Logical: return ADS_LOGICAL; // 1 - case DbfFieldType::Date: return ADS_DATE; // 3 - case DbfFieldType::DateTime: return ADS_TIMESTAMP; // 14 - case DbfFieldType::Memo: return ADS_MEMO; // 5 - case DbfFieldType::Integer: return ADS_INTEGER; // 11 - case DbfFieldType::Currency: return ADS_MONEY; // 18 - case DbfFieldType::Double: return ADS_DOUBLE; // 10 - case DbfFieldType::Varchar: return ADS_STRING; // M11.1 - case DbfFieldType::Varbinary: return ADS_RAW; // M11.1 - // ADT-native types (M4) - case DbfFieldType::ShortInt: return ADS_SHORTINT; // 12 - case DbfFieldType::Binary: return ADS_BINARY; // 6 - case DbfFieldType::CiCharacter: return ADS_CISTRING; // 25 - case DbfFieldType::AutoInc: return ADS_AUTOINC; // 15 - case DbfFieldType::Time: return ADS_TIME; // 13 - case DbfFieldType::AdtDate: return ADS_DATE; // 3 - case DbfFieldType::AdtTimestamp: return ADS_TIMESTAMP; // 14 - case DbfFieldType::AdtMoney: return ADS_MONEY; // 18 - case DbfFieldType::RowVersion: return ADS_ROWVERSION; // 21 - case DbfFieldType::ModTime: return ADS_MODTIME; // 22 - case DbfFieldType::Unknown: return ADS_FIELD_TYPE_UNKNOWN; - } - return ADS_FIELD_TYPE_UNKNOWN; -} - -[[maybe_unused]] const openads::drivers::DbfField* -find_field(Table* tbl, const std::string& name) { - for (std::uint16_t i = 0; i < tbl->field_count(); ++i) { - const auto& f = tbl->field_descriptor(i); - if (f.name == name) return &f; - } - return nullptr; -} - -// Cursor projections (M10.8). When a SELECT carries a projection -// list, the cursor handle's entry in this map holds the source-field -// indices (in projection order) so AdsGetNumFields / AdsGetFieldName -// / AdsGetField with ADSFIELD(n) report the projected schema instead -// of the underlying table's full layout. -std::unordered_map>& -cursor_projections() { - static std::unordered_map> m; - return m; -} - -// Remote SQL cursors map -- moved out of AdsExecuteSQLDirect so that -// AdsDisconnect can reach it to null out rt->conn before the -// RemoteConnection is freed, preventing use-after-free in AdsCloseTable. -std::unordered_map>& -remote_sql_cursors_map() { - static std::unordered_map> m; - return m; -} - -const std::vector* -projection_for(ADSHANDLE h) { - auto& m = cursor_projections(); - auto it = m.find(h); - if (it == m.end() || it->second.empty()) return nullptr; - return &it->second; -} - -// S4 -- user-visible column names for a projected cursor, parallel to -// cursor_projections(). SAP names a result column after the SELECT list, -// not after the table's declared spelling, so the two must be tracked -// separately; see build_projection_aliases() for the rule. -std::unordered_map>& -cursor_aliases() { - static std::unordered_map> m; - return m; -} - -const std::vector* -aliases_for(ADSHANDLE h) { - auto& m = cursor_aliases(); - auto it = m.find(h); - if (it == m.end() || it->second.empty()) return nullptr; - return &it->second; -} - -// Forget both maps together -- an alias vector outliving its projection -// would misname the next cursor to reuse the handle. -void forget_cursor_projection(ADSHANDLE h) { - cursor_projections().erase(h); - cursor_aliases().erase(h); -} - -// SAP's result-column naming rule, established by probing ace64.dll: -// -// 1. An explicit `AS alias` wins outright. -// 2. Otherwise the column is named with the SPELLING USED IN THE SELECT -// LIST, not the table's declared spelling - `SELECT CLAIMKEY` over a -// column declared `claimkey` reports CLAIMKEY. The table qualifier is -// dropped, so `s.CLAIMKEY` still reports CLAIMKEY. -// 3. Repeats are suffixed `_1`, `_2`, ... in select-list order; the first -// occurrence keeps the bare name. Collisions are detected -// case-insensitively but each item keeps ITS OWN case, so -// `s.ADM_NUM, l.ADM_NUM, s.adm_num` reports ADM_NUM, ADM_NUM_1 and -// adm_num_2 - note the third is lower-cased like the way it was -// written, not like the first. -// -// Returns an empty vector when there is nothing to override. -std::vector -build_projection_aliases(const openads::sql::SelectStmt& st, - std::size_t n) { - if (n == 0) return {}; - std::vector names; - names.reserve(n); - for (std::size_t i = 0; i < n; ++i) { - const std::string* as_alias = nullptr; - for (const auto& pa : st.projection_aliases) - if (pa.first == i) { as_alias = &pa.second; break; } - if (as_alias != nullptr) { - names.push_back(*as_alias); - } else if (i < st.projection.size()) { - names.push_back(st.projection[i]); - } else { - names.emplace_back(); - } - } - auto lower = [](std::string v) { - for (auto& ch : v) - ch = static_cast( - std::tolower(static_cast(ch))); - return v; - }; - std::unordered_map seen; - for (auto& nm : names) { - if (nm.empty()) continue; - const int k = seen[lower(nm)]++; - if (k > 0) nm += "_" + std::to_string(k); - } - return names; -} - -// Projection-aware variant. Called by Get* entry points that take -// hTable + pucField; routes ADSFIELD(n) numeric handles through the -// projection map (n = position within projection, translated to the -// underlying field index). Bare-name lookups stay direct -- rddads -// only ever asks for projected names so the underlying schema's -// extra columns aren't reachable through the cursor anyway. -bool resolve_field_index(Table* tbl, UNSIGNED8* pucField, std::uint16_t* out); -bool resolve_field_index_h(ADSHANDLE h, Table* tbl, - UNSIGNED8* pucField, std::uint16_t* out) { - auto p = reinterpret_cast(pucField); - const auto* proj = projection_for(h); - if (proj != nullptr && p != 0 && p < 0x10000u) { - std::uint16_t one_based = static_cast(p); - if (one_based >= 1 && one_based <= proj->size()) { - *out = (*proj)[one_based - 1]; - return true; - } - return false; - } - // S4 -- a projected column may be visible under a name the underlying - // table does not carry (`AS alias`, or a duplicate suffixed `_1`), so - // match the cursor's own names before the table's. - if (proj != nullptr && p >= 0x10000u && pucField != nullptr) { - if (const auto* al = aliases_for(h); al != nullptr) { - const std::string want(reinterpret_cast(pucField)); - auto same = [](const std::string& a, const std::string& b) { - if (a.size() != b.size()) return false; - for (std::size_t k = 0; k < a.size(); ++k) - if (std::tolower(static_cast(a[k])) != - std::tolower(static_cast(b[k]))) - return false; - return true; - }; - for (std::size_t i = 0; i < al->size() && i < proj->size(); ++i) { - if (!(*al)[i].empty() && same((*al)[i], want)) { - *out = (*proj)[i]; - return true; - } - } - } - } - return resolve_field_index(tbl, pucField, out); -} - -// Resolve a pucField argument to a 0-based field index. Real ACE.h -// defines `ADSFIELD(n)` as `((UNSIGNED8*)(UNSIGNED_PTR)(n))`, so -// callers compiled against that header pass small integers cast to -// pointers. Anything below 0x10000 cannot be a valid string address in -// any real process layout, so we treat it as a 1-based field index. -// Otherwise pucField is a NUL-terminated field name. -bool resolve_field_index(Table* tbl, UNSIGNED8* pucField, std::uint16_t* out) { - if (tbl == nullptr || out == nullptr) return false; - auto p = reinterpret_cast(pucField); - if (p != 0 && p < 0x10000u) { - std::uint16_t one_based = static_cast(p); - if (one_based >= 1 && one_based <= tbl->field_count()) { - *out = static_cast(one_based - 1); - return true; - } - return false; - } - if (pucField == nullptr) return false; - auto name = openads::abi::to_internal(pucField, 0); - // Delegate to Table::field_index -- case-insensitive (matches native - // ACE semantics) and cached. Field names in DBF/ADT storage are - // upper-cased, but callers (and CDX/NTX index expressions) may use - // any case; an exact-case compare here spuriously missed them. - std::int32_t idx = tbl->field_index(name); - if (idx < 0) return false; - *out = static_cast(idx); - return true; -} - -// lookup_table_by_index -- defined further down once IndexBinding is -// known. Returns the Table bound to the given index handle, or null. -Table* lookup_table_by_index(ADSHANDLE h); -openads::drivers::IIndex* iindex_for_handle(ADSHANDLE h); -openads::util::Result activate_binding(ADSHANDLE h); -void purge_bindings_for_table(Table* t); - -// M12.5 -- remote-table lookup helper. Returns nullptr when the -// handle isn't a TCP-routed table. -openads::network::RemoteTable* get_remote_table(ADSHANDLE h) { - auto& s = state(); - return s.registry.lookup( - h, HandleKind::RemoteTable); -} - -// M12.21 option C -- settle the sequential-prefetch lag before any op -// that reads or mutates the server's CURRENT record. Rows served locally -// from the lookahead queue left the server cursor away from the client's -// logical row by cursor_lag (behind it for a forward run, ahead of it for a -// backward one); a Skip(0) -- which the client sends as Skip(cursor_lag) -- -// walks the server cursor to the logical row and the ack zeroes the lag. A -// no-op when nothing was prefetched (the common cold-cache write path pays -// nothing). RCB 07/15/2026: condition is now `!= 0`, since a backward lag is -// negative. -void remote_settle_cursor(openads::network::RemoteTable* rt) { - if (rt != nullptr && rt->conn != nullptr && rt->cursor_lag != 0) { - (void)rt->conn->skip(rt, 0); - } -} - -// Write coalescing for RDD-only apps over WAN (Vouch/ERP — the app cannot -// be changed to batch). After the first set, consecutive AdsSet* calls -// buffer (field, value) locally instead of paying 1 RTT each; the buffer -// flushes as one SetFields batch on the next visibility event (see -// remote_flush_pending call sites). -// -// The FIRST set of a run goes out immediately (probe): write-guard errors -// (record locked by another station — alternating multi-user appends — -// or X#'s GoHot lock-required check) surface on the writing call itself, -// exactly as before. Only runs of 2+ consecutive sets batch, which is -// precisely the voucher-entry shape this exists for. A failed probe -// buffers nothing, so error timing matches the legacy loop field-for-field. -UNSIGNED32 remote_buffered_set(openads::network::RemoteTable* rt, - const std::string& fname, - const std::string& val) { - if (rt == nullptr || rt->conn == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, ""); - } - rt->write_dirty = true; - remote_settle_cursor(rt); - rt->row_valid = false; // M12.17 cache invalidation - if (rt->pending_sets.empty()) { - // Probe: the first set of a run goes out immediately so - // write-guard errors surface on the writing call itself (see - // above). The value is ALSO buffered: after a twin-path append - // the engine cursor has no fetchable row yet, so same-handle - // reads would otherwise miss even the just-probed value. The - // flush re-applies it idempotently (same value, same record). - if (auto r = rt->conn->set_field(rt->id, fname, val); !r) { - return fail(r.error()); - } - // The probe applies server-side at once: a conditional-order - // row may just have entered/left the key set. - rt->key_count_cached = false; - rt->key_counts.clear(); - rt->key_counts.clear(); - } - rt->pending_sets.emplace_back(fname, val); - return ok(); -} - -// Overlay coalesced writes onto a cached row value (latest buffered set -// wins). Lets same-handle read-after-write stay exact without flushing, -// so interleaved validation reads don't break the batching. -bool remote_pending_overlay(openads::network::RemoteTable* rt, - std::size_t i, std::string& vstr) { - if (rt == nullptr || rt->pending_sets.empty() || - i >= rt->fields.size()) { - return false; - } - const std::string& want = rt->fields[i].name; - for (auto it = rt->pending_sets.rbegin(); - it != rt->pending_sets.rend(); ++it) { - if (it->first == want) { vstr = it->second; return true; } - } - return false; -} - -// Flush buffered sets: one SetFields batch when the server advertised -// kCapSetFieldsBatch in ConnectAck, else the legacy per-field loop -// (old servers behave exactly as before). No-op when clean, so hooking -// every visibility event is free for read-mostly flows. Returns an ACE -// code (0 = ok); on error the buffer is dropped with the server-side -// prefix applied — identical to a sequential loop failing mid-way. -// Flush a deferred order switch plainly (see pending_order): used by -// every binding-dependent wire op except GotoTop/GotoBottom, which -// absorb it into their fused frame instead. Returns an ACE code. -UNSIGNED32 remote_flush_order(openads::network::RemoteTable* rt) { - if (rt == nullptr || rt->conn == nullptr || !rt->pending_order) { - return ok(); - } - rt->pending_order = false; - const std::uint32_t oid = rt->pending_order_id; - auto r = oid == 0 - ? rt->conn->set_order_by_name(rt->id, "") - : rt->conn->set_order(rt->id, oid); - if (!r) return fail(r.error()); - // Ack-confirmed: the server binding now matches the belief held - // since pend time. The switch may have repositioned server-side - // state tracking (never the cursor itself — SetOrder moves - // nothing), so row/queue caches established under the old binding - // go, exactly like the immediate path. - rt->server_order_id = oid; - rt->row_valid = false; - rt->invalidate_prefetch(); - // The ack certifies the new order's count: seed slot + map. - if (r.value().counted) { - rt->cached_key_count = r.value().key_count; - rt->key_count_cached = true; - rt->key_counts[oid] = r.value().key_count; - } - return ok(); -} - -UNSIGNED32 remote_flush_teardown(openads::network::RemoteTable* rt); -UNSIGNED32 remote_flush_sets(openads::network::RemoteTable* rt); -// Send a real CloseAllIndexes frame and forget every binding (live -// maps + parked snapshot): the server dropped them all, so any -// client reference would be a dead id (SetOrder 5000 on next use). -UNSIGNED32 remote_emit_close_all(openads::network::RemoteTable* rt) { - if (rt == nullptr || rt->conn == nullptr) return ok(); - if (auto r = rt->conn->close_all_indexes(rt->id); !r) { - return fail(r.error()); - } - rt->close_all_indexes_pending = false; - rt->indexes_parked = false; - rt->parked_nav_which = 0; - rt->parked_by_tag.clear(); - rt->parked_handles.clear(); - rt->index_by_tag.clear(); - rt->index_handles.clear(); - rt->active_index_id = 0; - rt->last_nav = 0; - rt->pair_valid = false; - rt->anchor_ok = false; - return ok(); -} -// Parked-index truth enforcement (see the nav entries): order-dependent -// table-handle navs must resolve a parked snapshot with a real close -// first, or they would walk the stale server order while the client -// accounts natural rows. -UNSIGNED32 remote_close_parked_indexes(openads::network::RemoteTable* rt) { - if (rt == nullptr || rt->conn == nullptr || !rt->indexes_parked) { - return ok(); - } - // A nav carrying its own order context converges the server - // explicitly (fused install / pending switch / active belief - // already acked), so adoption is safe. A truly natural nav is - // safe too when the server never left natural (no order ever - // acked): there is no stale order to walk. Only a natural nav - // against a stale server order needs the real close. - if (rt->pending_order || rt->active_index_id != 0) return ok(); - if (rt->server_order_id == 0 || - rt->server_order_id == - openads::network::RemoteTable::kOrderUnknown) { - return ok(); - } - return remote_emit_close_all(rt); -} -UNSIGNED32 remote_flush_pending(openads::network::RemoteTable* rt) { - if (UNSIGNED32 orc = remote_flush_order(rt); orc != 0) return orc; - if (UNSIGNED32 rc = remote_flush_sets(rt); rc != 0) return rc; - return remote_flush_teardown(rt); -} - -// Buffered-sets half of remote_flush_pending (no teardown): the close -// path uses this so deferred teardown survives to the absorb-or-park -// decision below instead of being emitted first. -UNSIGNED32 remote_flush_sets(openads::network::RemoteTable* rt) { - if (rt == nullptr || rt->conn == nullptr || rt->pending_sets.empty()) { - return ok(); - } - // Land the server on our logical row first: with prefetch drained - // locally the server cursor lags behind, and the batch must apply to - // the row the buffered sets targeted — not where the server sits. - remote_settle_cursor(rt); - // pending[0] is always the probe (already applied server-side at - // buffer time), so the flush covers pending[1:]. A lone probe needs - // no frame at all — single-field edits cost exactly what they did - // before coalescing existed. - if (rt->pending_sets.size() == 1) { - rt->pending_sets.clear(); - rt->row_valid = false; - return ok(); - } - const std::vector> rest( - rt->pending_sets.begin() + 1, rt->pending_sets.end()); - if (rt->conn->server_setfields_batch()) { - if (auto r = rt->conn->set_fields_batch(rt->id, rest); - !r) { - rt->pending_sets.clear(); - rt->row_valid = false; - return fail(r.error()); - } - } else { - for (auto& [fname, val] : rest) { - if (auto r = rt->conn->set_field(rt->id, fname, val); !r) { - rt->pending_sets.clear(); - rt->row_valid = false; - return fail(r.error()); - } - } - } - rt->pending_sets.clear(); - rt->row_valid = false; - return ok(); -} - -// Deferred teardown (WAN chattiness): AdsFlushFileBuffers and -// AdsCloseAllIndexes set flags instead of sending when a CloseTable -// is expected to absorb them. If any OTHER wire op intervenes first, -// emit the deferred frames here, ahead of it, in the app's original -// relative order (flush, then close-all). The close path itself never -// reaches this function with flags set — it absorbs them silently. -UNSIGNED32 remote_flush_teardown(openads::network::RemoteTable* rt) { - if (rt == nullptr || rt->conn == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, ""); - } - // Merged flush: when both are owed, the CloseAllIndexes frame - // alone suffices — the server flushes table data inside that - // handler before dropping bindings. One RTT instead of two for - // rddads' flush→closeall teardown pair. Only against servers that - // advertise kCapFlushInCloseAll; old servers keep both frames. - // Parked exception (below): the park keeps server bindings open, - // so a real CloseAll would destroy what the park preserves — the - // close is adopted unsent, and a flush owed alongside travels - // alone (the merge below only applies when a real close goes out). - if (rt->close_all_indexes_pending && rt->indexes_parked) { - if (rt->flush_file_pending) { - if (auto r = rt->conn->flush_file_buffers(rt->id); !r) { - return fail(r.error()); - } - rt->flush_file_pending = false; - rt->write_dirty = false; - } - rt->close_all_indexes_pending = false; - return ok(); - } - if (rt->flush_file_pending && rt->close_all_indexes_pending && - rt->conn->server_flush_in_closeall()) { - if (auto r = rt->conn->close_all_indexes(rt->id); !r) { - return fail(r.error()); - } - rt->flush_file_pending = false; - rt->close_all_indexes_pending = false; - rt->write_dirty = false; - return ok(); - } - if (rt->flush_file_pending) { - if (auto r = rt->conn->flush_file_buffers(rt->id); !r) { - return fail(r.error()); - } - rt->flush_file_pending = false; - rt->write_dirty = false; - } - if (rt->close_all_indexes_pending) { - // Parked closes were adopted above; what reaches here is - // unparked and needs the real frame. - if (UNSIGNED32 frc = remote_emit_close_all(rt); frc != 0) { - return frc; - } - } - return ok(); -} - -#if defined(OPENADS_WITH_SQLITE) -std::unordered_map>& -sqlite_conns_map() { - static std::unordered_map> m; - return m; -} - -std::unordered_map>& -sqlite_tables_map() { - static std::unordered_map> m; - return m; -} - -void invalidate_sqlite_nav_after_dml( - openads::sql_backend::SqliteConnection* conn) { - if (!conn) return; - for (auto& kv : sqlite_tables_map()) { - if (!kv.second || kv.second->conn != conn || kv.second->is_result) { - continue; - } - kv.second->rec_count_cached = false; - kv.second->row_valid = false; - kv.second->positioned = false; - } -} - -openads::sql_backend::SqliteTable* get_sqlite_table(ADSHANDLE h) { - auto& s = state(); - return s.registry.lookup( - h, HandleKind::SqliteTable); -} - -// ── SQL backend connection lookups ───────────────────────────────── -// Returns the connection object for a SQL backend handle, or nullptr. -openads::sql_backend::SqliteConnection* get_sqlite_conn(ADSHANDLE h) { - auto& s = state(); - return s.registry.lookup( - h, HandleKind::SqliteConnection); -} - -std::unordered_map>& -sqlite_indexes_map() { - static std::unordered_map> m; - return m; -} - -openads::sql_backend::SqliteIndex* get_sqlite_index(ADSHANDLE h) { - auto& s = state(); - return s.registry.lookup( - h, HandleKind::SqliteIndex); -} - -std::size_t sqlite_field_index(openads::sql_backend::SqliteTable* st, - UNSIGNED8* pucField) { - if (!st->fields_cached) { - // st->conn is nulled by AdsDisconnect on still-open tables to - // avoid use-after-free; guard before dereferencing it. - 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_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; -} - -void invalidate_odbc_nav_after_dml( - openads::sql_backend::OdbcConnection* conn) { - if (!conn) return; - for (auto& kv : odbc_tables_map()) { - if (!kv.second || kv.second->conn != conn) continue; - kv.second->rec_count_cached = false; - kv.second->row_valid = false; - kv.second->positioned = false; - } -} - -openads::sql_backend::OdbcTable* get_odbc_table(ADSHANDLE h) { - auto& s = state(); - return s.registry.lookup( - h, HandleKind::OdbcTable); -} - -openads::sql_backend::OdbcConnection* get_odbc_conn(ADSHANDLE h) { - auto& s = state(); - return s.registry.lookup( - h, HandleKind::OdbcConnection); -} - -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_MSSQL) -std::unordered_map>& -mssql_conns_map() { - static std::unordered_map> m; - return m; -} - -std::unordered_map>& -mssql_tables_map() { - static std::unordered_map> m; - return m; -} - -openads::sql_backend::MssqlTable* get_mssql_table(ADSHANDLE h) { - auto& s = state(); - return s.registry.lookup( - h, HandleKind::MssqlTable); -} - -openads::sql_backend::MssqlConnection* get_mssql_conn(ADSHANDLE h) { - auto& s = state(); - return s.registry.lookup( - h, HandleKind::MssqlConnection); -} - -std::unordered_map>& -mssql_indexes_map() { - static std::unordered_map> m; - return m; -} - -openads::sql_backend::MssqlIndex* get_mssql_index(ADSHANDLE h) { - auto& s = state(); - return s.registry.lookup( - h, HandleKind::MssqlIndex); -} - -std::size_t mssql_field_index(openads::sql_backend::MssqlTable* st, - UNSIGNED8* pucField) { - if (pucField == nullptr) return std::numeric_limits::max(); - auto p = reinterpret_cast(pucField); - if (p != 0 && p < 0x10000u) { - auto idx = static_cast(p) - 1; - if (idx < st->field_count()) return idx; - return std::numeric_limits::max(); - } - std::string fname(reinterpret_cast(pucField)); - for (std::size_t i = 0; i < st->field_count(); ++i) { - if (st->field_name(i) == fname) return i; - } - return std::numeric_limits::max(); -} -#endif // OPENADS_WITH_MSSQL - -#if defined(OPENADS_WITH_FIREBIRD) -std::unordered_map>& -firebird_conns_map() { - static std::unordered_map> m; - return m; -} - -std::unordered_map>& -firebird_tables_map() { - static std::unordered_map> m; - return m; -} - -void invalidate_firebird_nav_after_dml( - openads::sql_backend::FirebirdConnection* conn) { - if (!conn) return; - for (auto& kv : firebird_tables_map()) { - if (!kv.second || kv.second->conn != conn || kv.second->is_result) { - continue; - } - kv.second->rec_count_cached = false; - kv.second->row_valid = false; - kv.second->positioned = false; - } -} - -openads::sql_backend::FirebirdTable* get_firebird_table(ADSHANDLE h) { - auto& s = state(); - return s.registry.lookup( - h, HandleKind::FirebirdTable); -} - -openads::sql_backend::FirebirdConnection* get_firebird_conn(ADSHANDLE h) { - auto& s = state(); - return s.registry.lookup( - h, HandleKind::FirebirdConnection); -} - -std::unordered_map>& -firebird_indexes_map() { - static std::unordered_map> m; - return m; -} - -openads::sql_backend::FirebirdIndex* get_firebird_index(ADSHANDLE h) { - auto& s = state(); - return s.registry.lookup( - h, HandleKind::FirebirdIndex); -} - -std::size_t firebird_field_index(openads::sql_backend::FirebirdTable* 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_FIREBIRD - -#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; -} - -void invalidate_maria_nav_after_dml( - openads::sql_backend::MariaConnection* conn) { - if (!conn) return; - for (auto& kv : maria_tables_map()) { - if (!kv.second || kv.second->conn != conn || kv.second->is_result) { - continue; - } - kv.second->rec_count_cached = false; - kv.second->row_valid = false; - kv.second->positioned = false; - } -} - -openads::sql_backend::MariaTable* get_maria_table(ADSHANDLE h) { - auto& s = state(); - return s.registry.lookup( - h, HandleKind::MariaTable); -} - -openads::sql_backend::MariaConnection* get_maria_conn(ADSHANDLE h) { - auto& s = state(); - return s.registry.lookup( - h, HandleKind::MariaConnection); -} - -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; -} - -openads::sql_backend::PostgresConnection* get_postgres_conn(ADSHANDLE h) { - auto& s = state(); - return s.registry.lookup( - h, HandleKind::PostgresConnection); -} - -std::unordered_map>& -postgres_tables_map() { - static std::unordered_map> m; - return m; -} - -void invalidate_postgres_nav_after_dml( - openads::sql_backend::PostgresConnection* conn) { - if (!conn) return; - for (auto& kv : postgres_tables_map()) { - if (!kv.second || kv.second->conn != conn || kv.second->is_result) { - continue; - } - kv.second->rec_count_cached = false; - kv.second->row_valid = false; - kv.second->positioned = false; - } -} - -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; - } - 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); -} - -// M12.29 -- same dispatch helper for remote-connection handles, used by the -// AdsDD* Data Dictionary property functions. dd_from_handle() only ever -// resolves a LOCAL Connection (see its definition below), so every AdsDD* -// entrypoint checks this FIRST and routes through the wire protocol instead -// of silently falling through to "no DD attached" behavior. -openads::network::RemoteConnection* get_remote_connection(ADSHANDLE h) { - auto& s = state(); - return s.registry.lookup( - h, HandleKind::RemoteConnection); -} - -// Forward decl: defined further down with the connection-resolution -// helpers (thread-local rddads default connection). -ADSHANDLE& rddads_default_connection() noexcept; - -// Resolve a caller connection handle -- 0 (rddads' "current connection" -// cleared by a disconnect), a thread-local default that is already -// disconnected, or a stale handle read from rddads' process-wide slot -// before another thread's disconnect cleared it -- to a live -// RemoteConnection handle. A valid handle passes through untouched; -// otherwise the thread-local default (last AdsConnect60 on this thread) -// wins if live, and failing that any surviving live RemoteConnection is -// adopted -- mirroring SAP ACE's internal default-connection behaviour, -// where disconnecting one connection must not render the surviving ones -// unusable. Returns 0 when no live remote connection exists (callers -// then fail fast or take their local fallback, as appropriate). -ADSHANDLE resolve_remote_conn_handle(ADSHANDLE h) { - auto& s = state(); - auto live_handle = [&](ADSHANDLE hh) -> ADSHANDLE { - if (hh == 0) return 0; - if (auto* rc = s.registry.lookup( - hh, HandleKind::RemoteConnection)) { - if (rc->valid()) return hh; - } - return 0; - }; - if (ADSHANDLE r = live_handle(h)) return r; - if (ADSHANDLE r = live_handle(rddads_default_connection())) return r; - ADSHANDLE found = 0; - s.registry.for_each_handle([&](Handle hh, HandleKind k, void* p) { - if (found != 0 || k != HandleKind::RemoteConnection) return; - auto* rc = static_cast(p); - if (rc != nullptr && rc->valid()) found = to_ads_handle(hh); - }); - return found; -} - -namespace { - -Handle handle_for_remote_table(openads::network::RemoteTable* rt) { - if (rt == nullptr) return 0; - return state().registry.find_handle(HandleKind::RemoteTable, rt); -} - -bool remote_table_has_index(const openads::network::RemoteTable* rt) { - return rt != nullptr && - (rt->active_index_id != 0 || !rt->index_by_tag.empty()); -} - -// Diagnostic WAN trace. Off unless wire_trace=1; no file opens or timestamps -// on the disabled request path. Payload and seek keys are never logged. -static bool cli_trace_on() { - static const bool on = openads::util::client_setting_truthy( - "OPENADS_WIRE_TRACE", "wire_trace"); - return on; -} - -static const std::string& cli_trace_path() { - static const std::string path = [] { - auto p = openads::util::client_setting("OPENADS_WIRE_TRACE_FILE", - "wire_trace_file"); - return p.empty() ? std::string("C:/tmp/cli_trace.log") : p; - }(); - return path; -} - -struct CliTraceState { - std::mutex mu; - std::map, std::string> tables; - std::map, std::string> indexes; -}; -static CliTraceState& cli_trace_state() { - static CliTraceState trace; - return trace; -} - -// Called after a successful open; table ID belongs to its connection, -// not globally. Do not hold this mutex while performing any wire request. -static void cli_trace_table_open(const openads::network::RemoteTable* rt) { - if (!cli_trace_on() || !rt || !rt->conn) return; - auto& trace = cli_trace_state(); - std::lock_guard lk(trace.mu); - trace.tables[{rt->conn, rt->id}] = - rt->alias.empty() ? rt->name : rt->alias; -} -static void cli_trace_table_close(const openads::network::RemoteTable* rt) { - if (!cli_trace_on() || !rt || !rt->conn) return; - auto& trace = cli_trace_state(); - std::lock_guard lk(trace.mu); - trace.tables.erase({rt->conn, rt->id}); - for (const auto& entry : rt->index_by_tag) - trace.indexes.erase({rt->conn, entry.second}); -} - -static void cli_trace_write_locked(FILE* hf, long long ms, - const char* who, const char* op, - const char* detail) { - const auto now = std::chrono::system_clock::now(); - const auto epoch_ms = std::chrono::duration_cast( - now.time_since_epoch()).count(); - const auto secs = std::chrono::system_clock::to_time_t(now); - std::tm local{}; -#if defined(_WIN32) - localtime_s(&local, &secs); -#else - localtime_r(&secs, &local); -#endif - char stamp[32]; - std::strftime(stamp, sizeof(stamp), "%Y-%m-%d %H:%M:%S", &local); - std::fprintf(hf, "%s.%03lld [+%9lldms] [%-20.20s] [%-20.20s] %s\n", - stamp, static_cast(epoch_ms % 1000), ms, - who ? who : "-", op ? op : "-", detail ? detail : ""); -} - -static void cli_trace_line(long long ms, const char* who, const char* op, - const char* detail) { - if (!cli_trace_on()) return; - auto& trace = cli_trace_state(); - std::lock_guard lk(trace.mu); - FILE* hf = std::fopen(cli_trace_path().c_str(), "a"); - if (!hf) return; - cli_trace_write_locked(hf, ms, who, op, detail); - std::fclose(hf); -} - -static long long cli_trace_ms() { - static const auto t0 = std::chrono::steady_clock::now(); - return static_cast( - std::chrono::duration_cast( - std::chrono::steady_clock::now() - t0).count()); -} - -static void cli_trace(const char* op, const char* fmt, ...) { - char buf[512]; - va_list ap; - va_start(ap, fmt); - vsnprintf(buf, sizeof(buf) - 1, fmt, ap); - va_end(ap); - buf[sizeof(buf) - 1] = 0; - cli_trace_line(cli_trace_ms(), "-", op, buf); -} -static void cli_trace_tbl(const openads::network::RemoteTable* rt, - const char* op, const char* fmt, ...) { - if (!cli_trace_on()) return; - const std::string who = rt == nullptr ? "-" : - (!rt->alias.empty() ? rt->alias : rt->name); - char buf[512]; - va_list ap; - va_start(ap, fmt); - vsnprintf(buf, sizeof(buf) - 1, fmt, ap); - va_end(ap); - buf[sizeof(buf) - 1] = 0; - cli_trace_line(cli_trace_ms(), who.c_str(), op, buf); -} - -static const char* cli_trace_opcode(std::uint8_t op) { - switch (op) { - case 0x01: return "Hello"; - case 0x10: return "Connect"; - case 0x12: return "Disconnect"; - case 0x20: return "OpenTable"; - case 0x22: return "CloseTable"; - case 0x30: return "ExecuteSQL"; - case 0x32: return "Fetch"; - case 0x40: return "GotoTop"; - case 0x42: return "Skip"; - case 0x44: return "GetField"; - case 0x46: return "GetRecordCount"; - case 0x48: return "AtEOF"; - case 0x4A: return "DescribeTable"; - case 0x4C: return "AtBOF"; - case 0x4E: return "GetRecordNum"; - case 0x62: return "IsRecordDeleted"; - case 0x64: return "GotoBottom"; - case 0x66: return "IsFound"; - case 0x68: return "RefreshRecord"; - case 0x6A: return "GetTableType"; - case 0x6C: return "GetRecordLength"; - case 0x6E: return "GetNumIndexes"; - case 0x70: return "GetLastAutoinc"; - case 0x72: return "LockRecord"; - case 0x74: return "UnlockRecord"; - case 0x76: return "LockTable"; - case 0x78: return "UnlockTable"; - case 0x7A: return "PackTable"; - case 0x7C: return "ZapTable"; - case 0x7E: return "FlushFileBuffers"; - case 0x80: return "CloseAllIndexes"; - case 0x82: return "SetAOF"; - case 0x84: return "ClearAOFRemote"; - case 0x86: return "GetAOFOptLevel"; - case 0x88: return "OpenIndex"; - case 0x8A: return "CloseIndex"; - case 0x8C: return "SetOrder"; - case 0x8E: return "SetOrderByName"; - case 0x90: return "Seek"; - case 0x92: return "SeekLast"; - case 0x94: return "CreateIndex"; - case 0x96: return "SkipUnique"; - case 0x98: return "SetScope"; - case 0x9A: return "ClearScope"; - case 0x9C: return "FetchCurrentRow"; - case 0x50: return "AppendBlank"; - case 0x52: return "SetField"; - case 0x5E: return "SetFields"; - case 0x54: return "DeleteRecord"; - case 0x56: return "RecallRecord"; - case 0x58: return "GotoRecord"; - case 0x5A: return "FlushTable"; - case 0x5C: return "GetKeyType"; - case 0x60: return "Reindex"; - case 0x9E: return "GetLastTableUpdate"; - case 0x13: return "IsRecordLocked"; - case 0x15: return "GetAllLocks"; - case 0xA0: return "MgConnect"; - case 0xA2: return "MgRequest"; - case 0xA4: return "FetchWhere"; - case 0xA6: return "Aggregate"; - case 0xA8: return "GetRecord"; - case 0xAA: return "SetRecord"; - case 0xAC: return "CustomizeAOF"; - case 0xAE: return "GetRecordCRC"; - case 0xB0: return "GetKeyCount"; - case 0x03: return "GetKeyNum"; - case 0x05: return "FindTables"; - case 0x07: return "BeginTransaction"; - case 0x09: return "CommitTransaction"; - case 0x0B: return "RollbackTransaction"; - case 0x0D: return "FindRecord"; - case 0x17: return "ZipArchive"; - case 0x19: return "UnzipArchive"; - case 0x1B: return "ZipList"; - case 0xB2: return "DDGetProperty"; - case 0xB4: return "DDSetProperty"; - case 0xB6: return "DDCreateProc"; - case 0xB8: return "DDCreateFunction"; - case 0xBA: return "DDCreateTrigger"; - case 0xBC: return "DDDropTrigger"; - case 0xBE: return "DDDropView"; - case 0xC0: return "DDDropLink"; - case 0xC2: return "DDCreateUser"; - case 0xC4: return "DDDropObject"; - case 0xC6: return "DDAddUserToGroup"; - case 0xC8: return "DDRemoveUserFromGroup"; - case 0xCA: return "DDCreateLink"; - case 0xCC: return "DDModifyLink"; - case 0xCE: return "DDCreateRefIntegrity"; - case 0xD0: return "DDCreateView"; - case 0xD2: return "DDAddIndexFile"; - case 0xD4: return "DDRemoveIndexFile"; - case 0xD6: return "DDGetPermissions"; - case 0xD8: return "DDGrantPermission"; - case 0xDA: return "ShowDeleted"; - case 0xDC: return "CreateTable"; - case 0xDE: return "DropTable"; - case 0xE0: return "FileExists"; - case 0xE2: return "FileErase"; - case 0xE4: return "FileRename"; - case 0xE6: return "FileSize"; - case 0xE8: return "FileMTime"; - case 0xEA: return "Directory"; - case 0xEC: return "DirExist"; - case 0xEE: return "DirMake"; - case 0xF0: return "DirRemove"; - case 0xF2: return "FOpen"; - case 0xF4: return "FCreate"; - case 0xF6: return "FClose"; - case 0xF8: return "FRead"; - case 0xFA: return "FWrite"; - case 0xFC: return "FSeek"; - case 0xFE: return "Mutex"; - default: return "Other"; - } -} - -// One line per completed request/reply. Connection and table ID identify -// overlapping aliases, duration includes send+receive; no payload bytes. -static void cli_trace_frame_hook(const void* conn, std::uint8_t op, - std::uint32_t tid, std::size_t req_bytes, - std::uint8_t rep_op, std::size_t rep_bytes, - long long us) { - auto& trace = cli_trace_state(); - std::lock_guard lk(trace.mu); - std::string table = "-"; - const bool index_op = op == 0x90 || op == 0x92 || op == 0x8A || - op == 0x8C || op == 0x8E || op == 0x5C; - if (index_op) { - const auto ix = trace.indexes.find({conn, tid}); - if (ix != trace.indexes.end()) table = ix->second; - } else if (op != 0x01 && op != 0x10 && op != 0x20 && - op != 0x30 && op != 0x32 && op != 0x05 && op != 0x07 && - op != 0x09 && op != 0x0B) { - const auto it = trace.tables.find({conn, tid}); - if (it != trace.tables.end()) table = it->second; - } - char buf[200]; - std::snprintf(buf, sizeof(buf), - "wire op=0x%02X tid=%u req=%lu ack=0x%02X ackb=%lu dur_us=%lld conn=%04X", - static_cast(op), static_cast(tid), - static_cast(req_bytes), - static_cast(rep_op), - static_cast(rep_bytes), us, - static_cast(reinterpret_cast(conn) & 0xFFFFu)); - FILE* hf = std::fopen(cli_trace_path().c_str(), "a"); - if (!hf) return; - cli_trace_write_locked(hf, cli_trace_ms(), table.c_str(), - cli_trace_opcode(op), buf); - std::fclose(hf); -} - -void remote_clear_nav_boundaries(openads::network::RemoteTable* rt) { - if (rt == nullptr) return; - rt->nav_at_bof = false; - rt->nav_at_eof = false; - rt->nav_not_bof = false; - rt->nav_not_eof = false; -} - -void remote_ensure_rec_count(openads::network::RemoteTable* rt) { - if (rt == nullptr || rt->rec_count_cached) return; - if (auto r = rt->conn->record_count(rt->id)) { - rt->cached_rec_count = r.value(); - rt->rec_count_cached = true; - } -} - -// Scoped key count of the active order -- the server computes it scope + -// SET DELETED aware, so it matches what OrdKeyCount reports. Falls back -// to the physical record count in natural order. The remote keyno -// machinery (GoBottom, KeyGoto, RelKeyPos, skip clamp) must use THIS, -// not cached_rec_count: with a scope of 1 live row in a 36-row table, -// clamping to 36 made GoBottom report KeyNo=36 and sent KeyGoto/rel-pos -// walks 35 rows past the scope end -- the phantom/duplicate rows in Tim -// Stone's scoped remote xBrowse (31/07/2026). -std::uint32_t remote_ensure_key_count(openads::network::RemoteTable* rt) { - if (rt == nullptr) return 0; - if (rt->active_index_id == 0) { - // Natural record order (or no order installed yet): key position - // tracks recno, so the count is the physical record count. - remote_ensure_rec_count(rt); - return rt->rec_count_cached ? rt->cached_rec_count : 0u; - } - if (!rt->key_count_cached) { - // Second-chance: rotation revisits orders whose counts were - // fetched before (order switches don't change counts). - auto hit = rt->key_counts.find(rt->active_index_id); - if (hit != rt->key_counts.end()) { - rt->cached_key_count = hit->second; - rt->key_count_cached = true; - } else if (auto r = rt->conn->key_count(rt->id)) { - rt->cached_key_count = r.value(); - rt->key_count_cached = true; - rt->key_counts[rt->active_index_id] = r.value(); - } - } - return rt->key_count_cached ? rt->cached_key_count : 0u; -} - -void remote_update_nav_boundaries(openads::network::RemoteTable* rt, - std::int32_t step, - std::uint32_t rec_before, - bool row_valid_before) { - if (rt == nullptr || step == 0) return; - // Proven-false stickies derive ONLY from row_valid_before and - // landed-row facts — never from the at_* flags, which can predate - // scope/filter edits elsewhere. A stale TRUE here would answer a - // live boundary call wrongly; a stale FALSE only costs a frame. - if (step < 0) { - rt->nav_at_eof = false; - rt->nav_not_eof = row_valid_before; - if (!rt->row_valid) { - rt->nav_at_bof = true; - rt->nav_not_bof = false; - return; - } - rt->nav_at_bof = - row_valid_before && rt->current_recno == rec_before; - // A backward step that lands on a row is on neither limit: - // from a row it moved back, from the EOF limit it re-entered. - rt->nav_not_bof = true; - rt->nav_not_eof = true; - return; - } - rt->nav_at_bof = false; - rt->nav_not_bof = row_valid_before; - if (!rt->row_valid) { - rt->nav_at_eof = true; - rt->nav_not_eof = false; - return; - } - rt->nav_at_eof = - row_valid_before && rt->current_recno == rec_before; - // A forward step that lands on a row is on neither limit either. - rt->nav_not_bof = true; - rt->nav_not_eof = true; - return; -} - -void remote_sync_keyno_gototop(openads::network::RemoteTable* rt) { - if (rt == nullptr) return; - remote_clear_nav_boundaries(rt); - if (remote_table_has_index(rt)) { - rt->current_keyno = 1; - rt->keyno_valid = true; - } else if (rt->row_valid) { - rt->current_keyno = rt->current_recno; - rt->keyno_valid = true; - } else { - rt->keyno_valid = false; - } - // Boundary truth: on a row ⇒ not-BOF; no row ⇒ empty ⇒ both limits. - if (rt->row_valid) { - rt->nav_not_bof = true; - } else { - rt->nav_at_bof = true; - rt->nav_at_eof = true; - } -} - -// True when the server itself certified, on the latest cursor-affecting -// frame, that this table's cursor sits on no row with BOTH limits set -// (the xBase empty-cursor state), and nothing client-side could have -// changed what that means since. Filters are excluded on purpose: the -// server key/record counts do not apply them, so an empty filtered -// cursor says nothing about the counts. -bool remote_cursor_proven_empty(const openads::network::RemoteTable* rt) { - return rt != nullptr && rt->conn != nullptr && - !rt->row_valid && - rt->bound_bof_ok && rt->bound_bof && - rt->bound_eof_ok && rt->bound_eof && - rt->bound_seq == rt->conn->nav_seq() && - !rt->pending_order && - rt->pending_sets.empty() && - rt->filter_expr.empty() && rt->aof_expr.empty(); -} - -// "Still empty" window (user-approved trade-off, 23/09/2026): after the -// server certifies a table physically EMPTY (record count 0) with the -// cursor on no row (BOF+EOF), Seek / GO n on it are answered "not found" -// locally for a short time instead of paying a round trip each. Vouch -// probes its empty per-user settings tables ~280 times per startup. -// Risk accepted: a record another STATION adds inside the window is seen -// only when the window ends. This station's own writes (any append / -// field write / SQL on this connection) close the window at once. -// OPENADS_EMPTY_TTL_MS: window length, default 1500, 0 = off, max 2000. -std::uint32_t remote_empty_ttl_ms() { - static const std::uint32_t v = [] { - const char* e = std::getenv("OPENADS_EMPTY_TTL_MS"); - if (e == nullptr || *e == 0) return 1500u; - long x = std::strtol(e, nullptr, 10); - if (x < 0) x = 0; - if (x > 2000) x = 2000; - return static_cast(x); - }(); - return v; -} - -// mtfix12 R2 opt-in envelope (his explicit flag): conn-wide by -// default — any cursor-affecting frame on the connection expires the -// self-goto anchor. OPENADS_NAV_SELF_GOTO=table scopes freshness to -// the table handle: server cursors are per handle, so only this -// handle's frames can move its cursor, and the per-table generation -// (bumped centrally in request()) sees them all. For his deployment — -// writes coordinated by SEMA4s and physical locks — this is safe; the -// general default stays conn-wide. -bool remote_self_goto_per_table() { - static const bool v = [] { - const char* e = std::getenv("OPENADS_NAV_SELF_GOTO"); - return e != nullptr && std::strcmp(e, "table") == 0; - }(); - return v; -} - -bool remote_empty_window(const openads::network::RemoteTable* rt) { - if (rt == nullptr || rt->conn == nullptr) return false; - const std::uint32_t ttl = remote_empty_ttl_ms(); - if (ttl == 0) return false; - if (rt->row_valid || !rt->pending_sets.empty() || rt->write_dirty) - return false; - if (!(rt->bound_bof_ok && rt->bound_bof && - rt->bound_eof_ok && rt->bound_eof)) - return false; - // The count must come from the same certification as the bounds. - if (!(rt->count_bound_ok && rt->count_bound == 0 && - rt->count_bound_seq == rt->bound_seq)) - return false; - if (rt->bound_data_epoch != rt->conn->data_epoch()) return false; - const auto age = std::chrono::steady_clock::now() - rt->bound_at; - return age >= std::chrono::steady_clock::duration::zero() && - age <= std::chrono::milliseconds(ttl); -} - -// Leave the table exactly as a wire answer on an empty table would: -// no row, both limits, recno/count unchanged, certified at the current -// seq (bound_at is NOT refreshed: the window runs from the last real -// server answer, never extended by local answers). -void remote_empty_restamp(openads::network::RemoteTable* rt) { - const std::uint64_t seq = rt->conn->nav_seq(); - rt->row_valid = false; - rt->invalidate_prefetch(); - rt->nav_at_bof = true; - rt->nav_at_eof = true; - rt->nav_not_bof = false; - rt->nav_not_eof = false; - rt->bound_seq = seq; - rt->recno_bound_seq = seq; - rt->count_bound_seq = seq; - rt->last_nav = 0; - // The serve lands on no row: no anchor, and any certified pair - // landing described a state this serve just replaced. - rt->pair_valid = false; - rt->anchor_ok = false; -} - -// Memo key for AdsGetRecordLength re-opens: lowercased table name plus -// the whole schema. Empty (= no memo) when the schema is not known. -std::string remote_record_length_key(const openads::network::RemoteTable* rt) { - if (rt == nullptr || !rt->fields_cached || rt->fields.empty() || - rt->name.empty()) { - return {}; - } - std::string k = rt->name; - for (auto& c : k) - c = static_cast(std::tolower(static_cast(c))); - for (const auto& fd : rt->fields) { - k.push_back('\x1f'); - k += fd.name; - k.push_back('\x1e'); - k += std::to_string(fd.type) + "," + std::to_string(fd.length) + - "," + std::to_string(fd.decimals); - } - return k; -} - -void remote_sync_keyno_gotobottom(openads::network::RemoteTable* rt) { - if (rt == nullptr) return; - remote_clear_nav_boundaries(rt); - if (remote_table_has_index(rt)) { - // An ordered GotoBottom that the server certified empty (no row, - // BOF+EOF) proves the order holds no visible keys in its scope: - // the server key count (scope + SET DELETED aware, filter-blind) - // is 0. Seed it instead of asking — the empty tables of a USE - // otherwise pay one GetKeyCount frame each here. - if (rt->active_index_id != 0 && !rt->key_count_cached && - remote_cursor_proven_empty(rt)) { - rt->cached_key_count = 0; - rt->key_count_cached = true; - rt->key_counts[rt->active_index_id] = 0; - } - // Bottom of the ORDER, not of the file: with a scope active the - // last key is key #scoped_key_count, not #physical_rec_count. - const std::uint32_t kc = remote_ensure_key_count(rt); - rt->current_keyno = kc > 0 ? kc : 1u; - rt->keyno_valid = true; - } else if (rt->row_valid) { - rt->current_keyno = rt->current_recno; - rt->keyno_valid = true; - } else { - rt->keyno_valid = false; - } - // Boundary truth: on a row ⇒ not-EOF; no row ⇒ empty ⇒ both limits. - if (rt->row_valid) { - rt->nav_not_eof = true; - } else { - rt->nav_at_bof = true; - rt->nav_at_eof = true; - } -} - -// Consecutive-duplicate nav detection (WAN chattiness: rddads issues -// back-to-back GotoTop/GotoBottom pairs per USE). True when the table's -// last wire op was `which` (1 = top, 2 = bottom) in the same order -// context with no cursor-affecting frame on the connection since — the -// skipped frame would re-establish byte-identical state, so the only -// observable difference is one less round-trip. -// The dedupe path still re-runs the keyno sync + relation apply (both -// local once caches are warm) so every side effect but the frame stays. -bool remote_nav_duplicate(openads::network::RemoteTable* rt, int which, - std::uint32_t order) { - return rt != nullptr && rt->conn != nullptr && rt->last_nav == which && - rt->last_nav_order == order && - rt->last_nav_seq == rt->conn->nav_seq(); -} - -// Stamp after a successful wire GotoTop/GotoBottom. Also feeds the -// empty-cursor sticky: a top/bottom that produced no row proves an -// empty cursor, so AtBOF/AtEOF answer locally until a cursor-affecting -// frame lands (any such frame bumps the seq and expires the stamp). -void remote_nav_stamp(openads::network::RemoteTable* rt, int which, - std::uint32_t order) { - if (rt == nullptr || rt->conn == nullptr) return; - rt->last_nav = which; - rt->last_nav_order = order; - rt->last_nav_row = rt->row_valid; - rt->last_nav_seq = rt->conn->nav_seq(); -} - -// mtfix12 R1 — opposite-boundary certification serve check. True when -// the just-landed GotoTop/GotoBottom carried THIS boundary's full -// landing state (kCapNavBoundaryPair) and the same freshness envelope -// the duplicate check uses still holds — conn-wide nav_seq unchanged — -// plus no write touched this table since certification (the blob's row -// bytes predate any write; a stale blob must never overwrite fresher -// row state). Pending local mutations stay on the wire path. -bool remote_nav_pair(const openads::network::RemoteTable* rt, int which, - std::uint32_t order) { - return rt != nullptr && rt->conn != nullptr && rt->pair_valid && - rt->pair_which == which && rt->pair_order == order && - rt->pair_seq == rt->conn->nav_seq() && - rt->pair_write_gen == rt->conn->tbl_write_gen(rt->id) && - rt->pending_sets.empty() && !rt->pending_order; -} - -// Empty-cursor sticky for AdsAtBOF/AdsAtEOF: true when the last wire -// nav on this table established an empty cursor with no -// cursor-affecting frame since. An empty cursor is simultaneously BOF -// and EOF (xBase). -bool remote_nav_empty_sticky(openads::network::RemoteTable* rt) { - return rt != nullptr && rt->conn != nullptr && rt->last_nav != 0 && - !rt->last_nav_row && - rt->last_nav_seq == rt->conn->nav_seq(); -} - -// Seq-gated boundary-answer cache. A lone wire answer certifies only -// its own side (bound_*_ok); the twin half is certified only by the -// piggybacked twin byte. Any cursor-affecting wire frame expires via -// the seq; purely-local visibility mutations clear explicitly (same -// sites as last_nav). -bool remote_nav_bound_get(openads::network::RemoteTable* rt, bool want_eof, - bool* out) { - if (rt == nullptr || rt->conn == nullptr || out == nullptr) - return false; - if (rt->bound_seq != rt->conn->nav_seq()) return false; - if (want_eof && rt->bound_eof_ok) { *out = rt->bound_eof; return true; } - if (!want_eof && rt->bound_bof_ok) { *out = rt->bound_bof; return true; } - return false; -} -void remote_nav_bound_store(openads::network::RemoteTable* rt, bool eof, - bool eof_valid, bool bof, bool bof_valid) { - if (rt == nullptr || rt->conn == nullptr) return; - rt->bound_eof_ok = eof_valid; - rt->bound_eof = eof; - rt->bound_bof_ok = bof_valid; - rt->bound_bof = bof; - rt->bound_seq = rt->conn->nav_seq(); -} -void remote_nav_bound_clear(openads::network::RemoteTable* rt) { - if (rt == nullptr) return; - rt->bound_bof_ok = false; - rt->bound_eof_ok = false; - rt->recno_bound_ok = false; -} - -void remote_sync_keyno_skip(openads::network::RemoteTable* rt, - std::int32_t step) { - if (rt == nullptr) return; - if (remote_table_has_index(rt)) { - // FWH xBrowse Paint() saves RecNo(), skips through visible rows, - // then DbGoto(bookmark). AdsGotoRecord invalidates keyno; do not - // invent a position here or KeyNo/CalcRowSelPos drift. - if (!rt->keyno_valid) return; - if (!rt->row_valid) { - // Landed at EOF. The phantom key number is key_count + 1, so - // a Skip(-1) back lands exactly on the last scoped key. Fetch - // the scoped count ONLY here (it is then cached): fetching on - // the per-skip hot path would add a wire RTT to every browse - // step (broke the zero-packet prefetch guarantee, M12.24). - if (step > 0) { - const std::uint32_t kc = remote_ensure_key_count(rt); - if (kc > 0) rt->current_keyno = kc + 1; - } - return; - } - // On a valid row the counter is exact by construction (seeded by - // GoTop/GoBottom/measure); no clamp, no wire traffic. - std::int64_t k = static_cast(rt->current_keyno) + step; - if (k < 1) k = 1; - rt->current_keyno = static_cast(k); - return; - } - if (rt->row_valid) { - rt->current_keyno = rt->current_recno; - rt->keyno_valid = true; - } -} - -// Walk from top (server honours ordered_tables_ even when the client's -// active_index_id was never set) until current_recno matches. -UNSIGNED32 remote_measure_keyno(openads::network::RemoteTable* rt) { - if (rt == nullptr) return 0; - if (!rt->row_valid) { - remote_sync_keyno_gototop(rt); - return rt->current_keyno; - } - const std::uint32_t target = rt->current_recno; - remote_ensure_rec_count(rt); - const std::uint32_t limit = - rt->rec_count_cached ? rt->cached_rec_count : 1000000u; - - rt->found_cached = true; - rt->current_found = false; - rt->invalidate_prefetch(); - if (auto r = rt->conn->goto_top(rt); !r) return 0; - remote_sync_keyno_gototop(rt); - if (rt->row_valid && rt->current_recno == target) { - return 1u; - } - for (std::uint32_t kn = 1; kn < limit; ++kn) { - if (rt->row_valid && rt->current_recno == target) { - rt->current_keyno = kn; - rt->keyno_valid = true; - return kn; - } - auto eo = rt->conn->at_eof(rt->id); - if (eo && eo.value()) break; - if (auto sk = rt->conn->skip(rt, 1); !sk) break; - remote_sync_keyno_skip(rt, 1); - } - rt->current_keyno = rt->keyno_valid ? rt->current_keyno : 1u; - rt->keyno_valid = true; - return rt->current_keyno; -} - -UNSIGNED32 remote_query_key_num(openads::network::RemoteTable* rt, - openads::network::RemoteIndex* ri, - UNSIGNED32* pulKeyNum) { - if (rt == nullptr || pulKeyNum == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, ""); - } - // Key position is computed server-side from committed values; a - // buffered key-field write would misplace us. Flush first (no-op - // when clean). - if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; - if (ri != nullptr) { - auto act = openads::network::remote_activate_index(ri); - if (!act) return fail(act.error()); - } - // At the EOF phantom there is no key under the cursor: the local - // engine reports 0 here (recno past end is not in the index walk), - // and xBrowse depends on that to stop painting rows. - if (rt->nav_at_eof && !rt->row_valid) { - *pulKeyNum = 0; - return ok(); - } - const bool has_index = rt->active_index_id != 0 || - !rt->index_by_tag.empty(); - if (!has_index) { - if (rt->row_valid) { - *pulKeyNum = rt->current_recno; - return ok(); - } - auto r = rt->conn->get_record_num(rt->id); - if (!r) return fail(r.error()); - *pulKeyNum = r.value(); - return ok(); - } - if (rt->keyno_valid) { - *pulKeyNum = rt->current_keyno; - return ok(); - } - // M12.29 -- prefer server-side key number (O(1)) over the legacy - // O(n) client-side walk. The server computes pos_of_recno_cached() - // on the CDX index, eliminating the ~22 sec remote_measure_keyno - // walk that TXBrowse:Refresh() triggered on every repaint. - if (auto knr = rt->conn->key_num(rt->id)) { - rt->current_keyno = knr.value(); - rt->keyno_valid = true; - *pulKeyNum = rt->current_keyno; - return ok(); - } - // Fallback to legacy O(n) walk if server doesn't support GetKeyNum. - const UNSIGNED32 kn = remote_measure_keyno(rt); - rt->current_keyno = kn; - rt->keyno_valid = true; - *pulKeyNum = kn; - return ok(); -} - -UNSIGNED32 remote_goto_key_num(openads::network::RemoteTable* rt, - openads::network::RemoteIndex* ri, - std::uint32_t keyno) { - if (rt == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - // Scrollbar jump moves the cursor: buffered sets belong to the row - // under the old position. Land them first (no-op when clean). - if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; - if (keyno < 1u) keyno = 1u; - if (ri != nullptr) { - auto act = openads::network::remote_activate_index(ri); - if (!act) return fail(act.error()); - } - remote_ensure_rec_count(rt); - // Clamp to the scoped key count when an order is active -- a KeyGoto - // past the scope end must land on the last scoped key, not on a - // physical record outside the scope. - const std::uint32_t kmax = remote_table_has_index(rt) - ? remote_ensure_key_count(rt) - : (rt->rec_count_cached ? rt->cached_rec_count : 0u); - cli_trace("goto_key_num", "want=%u kmax=%u", keyno, kmax); - if (kmax > 0 && keyno > kmax) { - keyno = kmax; - } - if (!remote_table_has_index(rt)) { - rt->found_cached = true; - rt->current_found = false; - rt->invalidate_prefetch(); - if (auto r = rt->conn->goto_record(rt, keyno); !r) { - return fail(r.error()); - } - rt->current_keyno = keyno; - rt->keyno_valid = true; - return ok(); - } - rt->found_cached = true; - rt->current_found = false; - rt->invalidate_prefetch(); - if (auto r = rt->conn->goto_top(rt); !r) return fail(r.error()); - remote_sync_keyno_gototop(rt); - if (keyno > 1u) { - const auto step = static_cast(keyno - 1u); - if (auto sk = rt->conn->skip(rt, step); !sk) return fail(sk.error()); - remote_sync_keyno_skip(rt, step); - } - rt->current_keyno = keyno; - rt->keyno_valid = true; - return ok(); -} - -UNSIGNED32 remote_query_rel_key_pos(openads::network::RemoteTable* rt, - openads::network::RemoteIndex* ri, - double* p) { - if (rt == nullptr || p == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (ri != nullptr) { - auto act = openads::network::remote_activate_index(ri); - if (!act) return fail(act.error()); - } - remote_ensure_rec_count(rt); - // Relative position is within the active order's key walk, so the - // denominator is the scoped key count when an order is active -- - // not the physical record count. - const std::uint32_t rc = remote_table_has_index(rt) - ? remote_ensure_key_count(rt) - : (rt->rec_count_cached ? rt->cached_rec_count : 0u); - if (rc <= 1u) { - *p = 0.0; - return ok(); - } - if (rt->active_index_id != 0 || !rt->index_by_tag.empty()) { - UNSIGNED32 kn = 0; - if (UNSIGNED32 rc2 = remote_query_key_num(rt, nullptr, &kn); - rc2 != openads::AE_SUCCESS) { - return rc2; - } - if (kn < 1u) kn = 1u; - if (kn > rc) kn = rc; - *p = static_cast(kn - 1u) / - static_cast(rc - 1u); - return ok(); - } - std::uint32_t rn = 0; - if (rt->row_valid) { - rn = rt->current_recno; - } else { - auto rnr = rt->conn->get_record_num(rt->id); - if (!rnr) return fail(rnr.error()); - rn = rnr.value(); - } - if (rn == 0u) { - *p = 0.0; - return ok(); - } - if (rn > rc) rn = rc; - *p = static_cast(rn - 1u) / static_cast(rc - 1u); - return ok(); -} - -} // namespace - -// Latch set by AdsSeekLast, cleared by AdsSkip. It marks "we are inside -// an AdsSeekLast cycle" for rddads' retry chain (soft AdsSeek + -// AdsSkip(-1) after a hard miss). Nothing reads it today: the fLast walk -// itself travels as a parameter to ads_seek_local, and the empty-key -// quirk decides on the order direction instead. Kept because the retry -// chain is the one place a future consumer would need 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 = to_ads_handle(h); - return to_ads_handle(h); -} - -// Harbour rddads: AdsConnect stores the handle globally; BEGIN/COMMIT/ -// ROLLBACK call AdsBeginTransaction(0). Resolve 0 to the last AdsConnect -// handle before falling back to cwd auto-connect. -ADSHANDLE& rddads_default_connection() noexcept { - thread_local ADSHANDLE h = 0; - return h; -} - -ADSHANDLE resolve_connection_handle(ADSHANDLE hConnect) { - if (hConnect != 0) return hConnect; - ADSHANDLE h = rddads_default_connection(); - if (h != 0) { - auto& s = state(); - if (s.registry.lookup(h, HandleKind::Connection)) - return h; - rddads_default_connection() = 0; - } - return get_or_create_default_connection(); -} - -Connection* lookup_connection(ADSHANDLE hConnect) { - auto& s = state(); - return s.registry.lookup( - resolve_connection_handle(hConnect), HandleKind::Connection); -} - -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; -} - -Connection* find_owning_connection(Table* t) { - if (t == nullptr) return nullptr; - auto& s = state(); - Connection* owning = nullptr; - s.registry.for_each_handle([&](Handle, HandleKind k, void* p) { - if (k != HandleKind::Connection || owning) return; - auto* cc = static_cast(p); - if (cc->owns_table_ptr(t)) owning = cc; - }); - return owning; -} - -// 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; -} - -// Blank ABI value for BOF/EOF / append-row reads. Harbour FWH -// TDataBase:td_blankrow does DBGOBOTTOM+DBSKIP then FieldGet; rddads -// must not see AE_INTERNAL_ERROR (5000) here. -static bool is_no_current_record(const openads::util::Error& e) { - return e.code == static_cast(openads::AE_NO_CURRENT_RECORD); -} - -static std::string blank_abi_field_from_dbf( - const openads::drivers::DbfField& fd) { - using FT = openads::drivers::DbfFieldType; - switch (fd.type) { - case FT::Character: - case FT::Varchar: - case FT::Numeric: - case FT::Float: - case FT::Double: - case FT::Currency: - case FT::Integer: - case FT::Date: - case FT::DateTime: - return std::string(fd.length > 0 ? fd.length : 1, ' '); - case FT::Logical: - return std::string(1, 'F'); - default: - return {}; - } -} - -static std::string blank_abi_field_from_ads(std::uint16_t ads_type, - std::uint32_t length) { - switch (ads_type) { - case ADS_STRING: - return std::string(length > 0 ? length : 1, ' '); - case ADS_LOGICAL: - return "F"; - case ADS_NUMERIC: - case ADS_INTEGER: - case ADS_DATE: - default: - return std::string(length > 0 ? length : 1, ' '); - } -} - -static UNSIGNED32 ads_get_field_blank_out(UNSIGNED8* pucBuf, - UNSIGNED32* pulLen, - std::string val, - std::uint16_t ads_type, - std::uint32_t width) { - if (ads_type == ADS_STRING) - val = pad_char_field(std::move(val), width); - openads::abi::copy_to_caller(pucBuf, pulLen, val); - return ok(); -} - -// --------------------------------------------------------------------------- -// 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_set_filter(ADSHANDLE hTable, UNSIGNED8* pucWhere) { - auto* st = get_sqlite_table(hTable); - if (st == nullptr || st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - std::string where = - pucWhere ? openads::abi::to_internal(pucWhere, 0) : std::string(); - auto r = st->conn->set_filter(st, where); - if (!r) return fail(r.error()); - st->aof_opt_level = where.empty() - ? static_cast(ADS_OPTIMIZED_NONE) - : static_cast(ADS_OPTIMIZED_FULL); - return ok(); -} - -// Tier-3 push-down: compute the aggregates with a single SQL statement in the -// backend (`SELECT COUNT/TOTAL/AVG/MIN/MAX ... WHERE`). TOTAL() (not SUM()) -// gives 0 over zero rows, matching xBase SUM semantics; AVG/MIN/MAX over zero -// rows come back SQL NULL -> AggType::Empty. Numeric results are re-formatted -// through format_agg_double so they match the wire path byte-for-byte. -UNSIGNED32 sqlite_aggregate( - ADSHANDLE hTable, const char* where_sql, - const std::vector* specs, - std::vector* out) { - auto* st = get_sqlite_table(hTable); - if (st == nullptr || st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - if (specs == nullptr || out == nullptr) - return fail(openads::AE_INTERNAL_ERROR, "sqlite_aggregate: null arg"); - - if (!st->fields_cached) { - auto d = st->conn->describe_table(st); - if (!d) return fail(d.error()); - st->fields = std::move(d).value(); - st->fields_cached = true; - } - auto iequal = [](const std::string& a, const std::string& b) { - if (a.size() != b.size()) return false; - for (std::size_t i = 0; i < a.size(); ++i) - if (std::toupper(static_cast(a[i])) != - std::toupper(static_cast(b[i]))) return false; - return true; - }; - auto field_is_numeric = [&](const std::string& name) { - for (const auto& f : st->fields) { - if (!iequal(f.name, name)) continue; - switch (f.type) { - case ADS_NUMERIC: case ADS_DOUBLE: case ADS_INTEGER: - case ADS_SHORTINT: case ADS_AUTOINC: case ADS_CURDOUBLE: - case ADS_MONEY: - return true; - default: - return false; - } - } - return false; - }; - - // Resolve a spec's field to its canonical, schema-validated column name. - // An unknown name must be rejected, never concatenated into the SQL: a - // double-quoted token that is not a real column is silently treated as a - // string literal by SQLite (TOTAL("NOPE") -> 0), and a quote in the name - // would break out of the identifier. Empty field is valid only for COUNT. - auto resolve_col = [&](const openads::engine::AggSpec& s, - std::string& col) -> bool { - if (s.field.empty()) - return s.fn == openads::engine::AggFn::Count; - for (const auto& f : st->fields) { - if (iequal(f.name, s.field)) { col = "\"" + f.name + "\""; return true; } - } - return false; - }; - - std::string sql = "SELECT "; - for (std::size_t i = 0; i < specs->size(); ++i) { - if (i) sql += ", "; - const auto& s = (*specs)[i]; - std::string col; - if (!resolve_col(s, col)) - return fail(openads::AE_INTERNAL_ERROR, - ("sqlite_aggregate: invalid field " + s.field).c_str()); - switch (s.fn) { - case openads::engine::AggFn::Count: - sql += s.field.empty() ? "COUNT(*)" : ("COUNT(" + col + ")"); - break; - case openads::engine::AggFn::Sum: sql += "TOTAL(" + col + ")"; break; - case openads::engine::AggFn::Avg: sql += "AVG(" + col + ")"; break; - case openads::engine::AggFn::Min: sql += "MIN(" + col + ")"; break; - case openads::engine::AggFn::Max: sql += "MAX(" + col + ")"; break; - } - sql += " AS a" + std::to_string(i); - } - sql += " FROM \"" + st->name + "\""; - if (where_sql != nullptr && *where_sql != '\0') - sql += " WHERE (" + std::string(where_sql) + ")"; - - auto cur = st->conn->run_sql(sql); - if (!cur) return fail(cur.error()); - const auto& res = *cur.value(); - - out->clear(); - out->reserve(specs->size()); - for (std::size_t i = 0; i < specs->size(); ++i) { - const auto& s = (*specs)[i]; - bool is_null = true; - std::string val; - if (!res.result_rows.empty() && i < res.result_rows[0].size()) { - val = res.result_rows[0][i]; - is_null = !res.result_nulls.empty() && - i < res.result_nulls[0].size() && res.result_nulls[0][i]; - } - const bool numeric_result = - (s.fn == openads::engine::AggFn::Count) || - (s.fn == openads::engine::AggFn::Sum) || - (s.fn == openads::engine::AggFn::Avg) || - field_is_numeric(s.field); - openads::engine::AggValue av; - if (is_null) { - av.type = openads::engine::AggType::Empty; // AVG/MIN/MAX, 0 rows - } else if (numeric_result) { - av.type = openads::engine::AggType::Numeric; - av.bytes = openads::engine::format_agg_double( - std::strtod(val.c_str(), nullptr)); - } else { - av.type = openads::engine::AggType::String; // MIN/MAX of text - av.bytes = val; - } - out->push_back(std::move(av)); - } - 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()); - } - if (!openads::abi::assign_u16(pusCnt, st->fields.size())) { - return fail(openads::AE_INTERNAL_ERROR, "field count exceeds 65535"); - } - 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, ""); - // rddads reads by ORDINAL: pucField is ADSFIELD(n) (a 1-based field - // number cast to a pointer), not a NUL-terminated name. Resolve it with - // the ordinal-aware index helper BEFORE reading -- to_internal()/strlen() - // on the tiny pointer dereferences invalid memory and crashes. - auto fi = sqlite_field_index(st, pucField); - if (fi == std::numeric_limits::max()) - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - bool is_null = false; - std::string val; - auto r = st->conn->read_field(st, st->fields[fi].name, val, is_null); - if (!r) return fail(r.error()); - if (is_null) val.clear(); - if (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] = to_ads_handle(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(); -} - -// ── Tier 1: Transaction management adapters (SQLRDD pattern) ────── - -UNSIGNED32 sqlite_begin_tx(ADSHANDLE hTable) { - auto* st = get_sqlite_table(hTable); - if (st == nullptr || st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto& tx = st->conn->tx_manager(); - // Wire up the SQLite backend callbacks if not already done - if (!tx.on_begin) { - auto* conn = st->conn; - tx.on_begin = [conn](bool is_nested) { - if (!is_nested) { - conn->exec_sql("BEGIN"); - } else { - // Nested: use SAVEPOINT - conn->exec_sql("SAVEPOINT sp_" + std::to_string(conn->tx_manager().nesting)); - } - }; - tx.on_commit = [conn](bool is_nested) { - (void)is_nested; - conn->exec_sql("COMMIT"); - }; - tx.on_rollback = [conn]() { - conn->exec_sql("ROLLBACK"); - }; - tx.on_savepoint = [conn](const std::string& name) { - conn->exec_sql("SAVEPOINT " + name); - }; - tx.on_release_savepoint = [conn](const std::string& name) { - conn->exec_sql("RELEASE SAVEPOINT " + name); - }; - tx.on_rollback_savepoint = [conn](const std::string& name) { - conn->exec_sql("ROLLBACK TO SAVEPOINT " + name); - }; - } - tx.begin(); - return ok(); -} - -UNSIGNED32 sqlite_commit_tx(ADSHANDLE hTable) { - auto* st = get_sqlite_table(hTable); - if (st == nullptr || st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - st->conn->tx_manager().commit(); - return ok(); -} - -UNSIGNED32 sqlite_rollback_tx(ADSHANDLE hTable) { - auto* st = get_sqlite_table(hTable); - if (st == nullptr || st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - st->conn->tx_manager().rollback(); - return ok(); -} - -UNSIGNED32 sqlite_set_auto_commit(ADSHANDLE hTable, SIGNED32 threshold) { - auto* st = get_sqlite_table(hTable); - if (st == nullptr || st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - st->conn->tx_manager().auto_commit_threshold = threshold; - 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; - o.set_filter = &sqlite_set_filter; - o.aggregate = &sqlite_aggregate; - o.begin_tx = &sqlite_begin_tx; - o.commit_tx = &sqlite_commit_tx; - o.rollback_tx = &sqlite_rollback_tx; - o.set_auto_commit = &sqlite_set_auto_commit; - 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()); - } - if (!openads::abi::assign_u16(pusCnt, st->fields.size())) { - return fail(openads::AE_INTERNAL_ERROR, "field count exceeds 65535"); - } - 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, ""); - // rddads reads by ORDINAL: pucField is ADSFIELD(n) (a 1-based field - // number cast to a pointer), not a NUL-terminated name. Resolve it with - // the ordinal-aware index helper BEFORE reading -- to_internal()/strlen() - // on the tiny pointer dereferences invalid memory and crashes. - auto fi = odbc_field_index(st, pucField); - if (fi == std::numeric_limits::max()) - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - bool is_null = false; - std::string val; - auto r = st->conn->read_field(st, st->fields[fi].name, val, is_null); - if (!r) return fail(r.error()); - if (is_null) val.clear(); - if (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] = to_ads_handle(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(); -} - -UNSIGNED32 odbc_set_filter(ADSHANDLE hTable, UNSIGNED8* pucWhere) { - auto* st = get_odbc_table(hTable); - if (st == nullptr || st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - std::string where = - pucWhere ? openads::abi::to_internal(pucWhere, 0) : std::string(); - auto r = st->conn->set_filter(st, where); - if (!r) return fail(r.error()); - st->aof_opt_level = where.empty() - ? static_cast(ADS_OPTIMIZED_NONE) - : static_cast(ADS_OPTIMIZED_FULL); - return ok(); -} - -UNSIGNED32 odbc_aggregate( - ADSHANDLE hTable, const char* where_sql, - const std::vector* specs, - std::vector* out) { - auto* st = get_odbc_table(hTable); - if (st == nullptr || st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - if (specs == nullptr || out == nullptr) - return fail(openads::AE_INTERNAL_ERROR, "odbc_aggregate: null arg"); - auto r = st->conn->aggregate( - st, where_sql ? std::string(where_sql) : std::string(), *specs); - if (!r) return fail(r.error()); - *out = std::move(r).value(); - return ok(); -} - -UNSIGNED32 odbc_begin_tx(ADSHANDLE hTable) { - auto* st = get_odbc_table(hTable); - if (st == nullptr || st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto* conn = st->conn; - auto& tx = conn->tx_manager(); - openads::sql_backend::wire_standard_savepoint_tx( - tx, [conn](const std::string& sql) { (void)conn->exec_sql(sql); }); - tx.begin(); - return ok(); -} - -UNSIGNED32 odbc_commit_tx(ADSHANDLE hTable) { - auto* st = get_odbc_table(hTable); - if (st == nullptr || st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - st->conn->tx_manager().commit(); - return ok(); -} - -UNSIGNED32 odbc_rollback_tx(ADSHANDLE hTable) { - auto* st = get_odbc_table(hTable); - if (st == nullptr || st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - st->conn->tx_manager().rollback(); - return ok(); -} - -UNSIGNED32 odbc_set_auto_commit(ADSHANDLE hTable, SIGNED32 threshold) { - auto* st = get_odbc_table(hTable); - if (st == nullptr || st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - st->conn->tx_manager().auto_commit_threshold = threshold; - 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; - o.set_filter = &odbc_set_filter; - o.aggregate = &odbc_aggregate; - o.begin_tx = &odbc_begin_tx; - o.commit_tx = &odbc_commit_tx; - o.rollback_tx = &odbc_rollback_tx; - o.set_auto_commit = &odbc_set_auto_commit; - return o; - }(); - return &ops; -} - -#endif // OPENADS_WITH_ODBC (lifted ops) - -// --------------------------------------------------------------------------- -// Lifted MSSQL table ops + accessor -// --------------------------------------------------------------------------- -#if defined(OPENADS_WITH_MSSQL) - -UNSIGNED32 mssql_close_table(ADSHANDLE hTable) { - auto* st = get_mssql_table(hTable); - (void)st; - auto& s2 = state(); - std::lock_guard lk2(s2.mu); - mssql_tables_map().erase(hTable); - s2.registry.release(hTable); - return ok(); -} - -UNSIGNED32 mssql_goto_top(ADSHANDLE hTable) { - auto* st = get_mssql_table(hTable); - if (!st) return fail(openads::AE_INTERNAL_ERROR, ""); - st->go_top(); - return ok(); -} - -UNSIGNED32 mssql_goto_bottom(ADSHANDLE hTable) { - auto* st = get_mssql_table(hTable); - if (!st) return fail(openads::AE_INTERNAL_ERROR, ""); - st->go_bottom(); - return ok(); -} - -UNSIGNED32 mssql_skip(ADSHANDLE hTable, SIGNED32 lRows) { - auto* st = get_mssql_table(hTable); - if (!st) return fail(openads::AE_INTERNAL_ERROR, ""); - st->skip(static_cast(lRows)); - return ok(); -} - -UNSIGNED32 mssql_at_eof(ADSHANDLE hTable, UNSIGNED16* pbAtEnd) { - auto* st = get_mssql_table(hTable); - if (!st) return fail(openads::AE_INTERNAL_ERROR, ""); - if (pbAtEnd == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *pbAtEnd = st->at_eof() ? 1 : 0; - return ok(); -} - -UNSIGNED32 mssql_at_bof(ADSHANDLE hTable, UNSIGNED16* pbAtBegin) { - auto* st = get_mssql_table(hTable); - if (!st) return fail(openads::AE_INTERNAL_ERROR, ""); - *pbAtBegin = st->at_bof() ? 1 : 0; - return ok(); -} - -UNSIGNED32 mssql_num_fields(ADSHANDLE hTable, UNSIGNED16* pusCnt) { - auto* st = get_mssql_table(hTable); - if (!st) return fail(openads::AE_INTERNAL_ERROR, ""); - if (pusCnt == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (!openads::abi::assign_u16(pusCnt, st->field_count())) { - return fail(openads::AE_INTERNAL_ERROR, "field count exceeds 65535"); - } - return ok(); -} - -UNSIGNED32 mssql_field_name(ADSHANDLE hTable, UNSIGNED16 n, - UNSIGNED8* pucBuf, UNSIGNED16* pusLen) { - auto* st = get_mssql_table(hTable); - if (!st) return fail(openads::AE_INTERNAL_ERROR, ""); - if (n == 0 || n > st->field_count()) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - auto name = st->field_name(n - 1); - openads::abi::copy_to_caller(pucBuf, pusLen, name); - return ok(); -} - -UNSIGNED32 mssql_field_type(ADSHANDLE hTable, UNSIGNED8* pucField, - UNSIGNED16* pusType) { - auto* st = get_mssql_table(hTable); - if (!st) return fail(openads::AE_INTERNAL_ERROR, ""); - auto i = mssql_field_index(st, pucField); - if (i == std::numeric_limits::max()) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - if (pusType) *pusType = st->field_type(i); - return ok(); -} - -UNSIGNED32 mssql_field_length(ADSHANDLE hTable, UNSIGNED8* pucField, - UNSIGNED32* pulLen) { - auto* st = get_mssql_table(hTable); - if (!st) return fail(openads::AE_INTERNAL_ERROR, ""); - auto i = mssql_field_index(st, pucField); - if (i == std::numeric_limits::max()) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - if (pulLen) *pulLen = st->field_length(i); - return ok(); -} - -UNSIGNED32 mssql_field_decimals(ADSHANDLE hTable, UNSIGNED8* pucField, - UNSIGNED16* pusDec) { - auto* st = get_mssql_table(hTable); - if (!st) return fail(openads::AE_INTERNAL_ERROR, ""); - auto i = mssql_field_index(st, pucField); - if (i == std::numeric_limits::max()) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - if (pusDec) *pusDec = st->field_decimals(i); - return ok(); -} - -UNSIGNED32 mssql_record_num(ADSHANDLE hTable, UNSIGNED32* pulRec) { - auto* st = get_mssql_table(hTable); - if (!st) return fail(openads::AE_INTERNAL_ERROR, ""); - if (pulRec == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *pulRec = st->record_num(); - return ok(); -} - -UNSIGNED32 mssql_record_count(ADSHANDLE hTable, UNSIGNED32* pulCount, - UNSIGNED16 /*usFilterOption*/) { - auto* st = get_mssql_table(hTable); - if (!st) return fail(openads::AE_INTERNAL_ERROR, ""); - if (pulCount == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *pulCount = st->record_count(); - return ok(); -} - -UNSIGNED32 mssql_get_field(ADSHANDLE hTable, UNSIGNED8* pucField, - UNSIGNED8* pucBuf, UNSIGNED32* pulLen, - UNSIGNED16 /*usOption*/) { - auto* st = get_mssql_table(hTable); - if (!st) return fail(openads::AE_INTERNAL_ERROR, ""); - if (pulLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - auto fi = mssql_field_index(st, pucField); - if (fi == std::numeric_limits::max()) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - bool is_null = false; - std::string val; - if (!st->get_field(fi, val, is_null)) { - return fail(openads::AE_INTERNAL_ERROR, ""); - } - if (is_null) val.clear(); - if (st->field_type(fi) == ADS_STRING) { - val = pad_char_field(std::move(val), st->field_length(fi)); - } - openads::abi::copy_to_caller(pucBuf, pulLen, val); - return ok(); -} - -UNSIGNED32 mssql_is_record_deleted(ADSHANDLE hTable, UNSIGNED16* pbDeleted) { - auto* st = get_mssql_table(hTable); - if (!st) return fail(openads::AE_INTERNAL_ERROR, ""); - if (pbDeleted) *pbDeleted = 0; - return ok(); -} - -UNSIGNED32 mssql_open_index(ADSHANDLE hTable, UNSIGNED8* pucName, - ADSHANDLE* ahIndex, UNSIGNED16* pu16ArrayLen) { - auto* st = get_mssql_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::MssqlIndex, si.get()); - ahIndex[0] = to_ads_handle(gh); - if (pu16ArrayLen != nullptr) *pu16ArrayLen = 1; - mssql_indexes_map().emplace(gh, std::move(si)); - return ok(); -} - -UNSIGNED32 mssql_is_found(ADSHANDLE hTable, UNSIGNED16* pbFound) { - auto* st = get_mssql_table(hTable); - *pbFound = st->last_seek_found ? 1 : 0; - return ok(); -} - -UNSIGNED32 mssql_set_filter(ADSHANDLE hTable, UNSIGNED8* pucWhere) { - auto* st = get_mssql_table(hTable); - if (st == nullptr || st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - std::string where = - pucWhere ? openads::abi::to_internal(pucWhere, 0) : std::string(); - auto r = st->conn->set_filter(st, where); - if (!r) return fail(r.error()); - st->aof_opt_level = where.empty() - ? static_cast(ADS_OPTIMIZED_NONE) - : static_cast(ADS_OPTIMIZED_FULL); - return ok(); -} - -UNSIGNED32 mssql_aggregate( - ADSHANDLE hTable, const char* where_sql, - const std::vector* specs, - std::vector* out) { - auto* st = get_mssql_table(hTable); - if (st == nullptr || st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - if (specs == nullptr || out == nullptr) - return fail(openads::AE_INTERNAL_ERROR, "mssql_aggregate: null arg"); - auto r = st->conn->aggregate( - st, where_sql ? std::string(where_sql) : std::string(), *specs); - if (!r) return fail(r.error()); - *out = std::move(r).value(); - return ok(); -} - -UNSIGNED32 mssql_begin_tx(ADSHANDLE hTable) { - auto* st = get_mssql_table(hTable); - if (st == nullptr || st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto* conn = st->conn; - auto& tx = conn->tx_manager(); - openads::sql_backend::wire_mssql_savepoint_tx( - tx, [conn](const std::string& sql) { (void)conn->exec_sql(sql); }); - tx.begin(); - return ok(); -} - -UNSIGNED32 mssql_commit_tx(ADSHANDLE hTable) { - auto* st = get_mssql_table(hTable); - if (st == nullptr || st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - st->conn->tx_manager().commit(); - return ok(); -} - -UNSIGNED32 mssql_rollback_tx(ADSHANDLE hTable) { - auto* st = get_mssql_table(hTable); - if (st == nullptr || st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - st->conn->tx_manager().rollback(); - return ok(); -} - -UNSIGNED32 mssql_set_auto_commit(ADSHANDLE hTable, SIGNED32 threshold) { - auto* st = get_mssql_table(hTable); - if (st == nullptr || st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - st->conn->tx_manager().auto_commit_threshold = threshold; - return ok(); -} - -const openads::abi::BackendTableOps* mssql_table_ops() { - static const openads::abi::BackendTableOps ops = [] { - openads::abi::BackendTableOps o{}; - o.close_table = &mssql_close_table; - o.goto_top = &mssql_goto_top; - o.goto_bottom = &mssql_goto_bottom; - o.skip = &mssql_skip; - o.at_eof = &mssql_at_eof; - o.at_bof = &mssql_at_bof; - o.num_fields = &mssql_num_fields; - o.field_name = &mssql_field_name; - o.field_type = &mssql_field_type; - o.field_length = &mssql_field_length; - o.field_decimals = &mssql_field_decimals; - o.record_num = &mssql_record_num; - o.record_count = &mssql_record_count; - o.get_field = &mssql_get_field; - o.is_record_deleted = &mssql_is_record_deleted; - o.open_index = &mssql_open_index; - o.is_found = &mssql_is_found; - o.set_filter = &mssql_set_filter; - o.aggregate = &mssql_aggregate; - o.begin_tx = &mssql_begin_tx; - o.commit_tx = &mssql_commit_tx; - o.rollback_tx = &mssql_rollback_tx; - o.set_auto_commit = &mssql_set_auto_commit; - return o; - }(); - return &ops; -} -#endif // OPENADS_WITH_MSSQL (lifted ops) - -// --------------------------------------------------------------------------- -// Lifted Firebird table ops + accessor (mirrors the ODBC lift exactly; the -// native Firebird backend exposes the same read-navigation surface -- its -// extra write / run_sql methods are not part of BackendTableOps and are not -// wired at the ABI border in this slice, matching the ODBC read-only border). -// --------------------------------------------------------------------------- -#if defined(OPENADS_WITH_FIREBIRD) - -UNSIGNED32 firebird_close_table(ADSHANDLE hTable) { - auto* st = get_firebird_table(hTable); - (void)st; - auto& s2 = state(); - std::lock_guard lk2(s2.mu); - firebird_tables_map().erase(hTable); - s2.registry.release(hTable); - return ok(); -} - -UNSIGNED32 firebird_goto_top(ADSHANDLE hTable) { - auto* st = get_firebird_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 firebird_goto_bottom(ADSHANDLE hTable) { - auto* st = get_firebird_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 firebird_skip(ADSHANDLE hTable, SIGNED32 lRows) { - auto* st = get_firebird_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 firebird_at_eof(ADSHANDLE hTable, UNSIGNED16* pbAtEnd) { - auto* st = get_firebird_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 firebird_at_bof(ADSHANDLE hTable, UNSIGNED16* pbAtBof) { - auto* st = get_firebird_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 firebird_num_fields(ADSHANDLE hTable, UNSIGNED16* pusCnt) { - auto* st = get_firebird_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 (!openads::abi::assign_u16(pusCnt, st->fields.size())) { - return fail(openads::AE_INTERNAL_ERROR, "field count exceeds 65535"); - } - return ok(); -} - -UNSIGNED32 firebird_field_name(ADSHANDLE hTable, UNSIGNED16 n, - UNSIGNED8* pucBuf, UNSIGNED16* pusLen) { - auto* st = get_firebird_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 firebird_field_type(ADSHANDLE hTable, UNSIGNED8* pucField, - UNSIGNED16* pusType) { - auto* st = get_firebird_table(hTable); - auto i = firebird_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 firebird_field_length(ADSHANDLE hTable, UNSIGNED8* pucField, - UNSIGNED32* pulLen) { - auto* st = get_firebird_table(hTable); - auto i = firebird_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 firebird_field_decimals(ADSHANDLE hTable, UNSIGNED8* pucField, - UNSIGNED16* pusDec) { - auto* st = get_firebird_table(hTable); - auto i = firebird_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 firebird_record_num(ADSHANDLE hTable, UNSIGNED32* pulRec) { - auto* st = get_firebird_table(hTable); - if (!st->positioned || !st->row_valid) { - return fail(5026, "no current record"); - } - *pulRec = static_cast(st->current_recno); - return ok(); -} - -UNSIGNED32 firebird_record_count(ADSHANDLE hTable, UNSIGNED32* pulCount, - UNSIGNED16 /*usFilterOption*/) { - auto* st = get_firebird_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 firebird_get_field(ADSHANDLE hTable, UNSIGNED8* pucField, - UNSIGNED8* pucBuf, UNSIGNED32* pulLen, - UNSIGNED16 /*usOption*/) { - auto* st = get_firebird_table(hTable); - if (pulLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (st->conn == nullptr) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - // rddads reads by ORDINAL: pucField is ADSFIELD(n) (a 1-based field - // number cast to a pointer), not a NUL-terminated name. Resolve it with - // the ordinal-aware index helper BEFORE reading -- to_internal()/strlen() - // on the tiny pointer dereferences invalid memory and crashes. - auto fi = firebird_field_index(st, pucField); - if (fi == std::numeric_limits::max()) - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - bool is_null = false; - std::string val; - auto r = st->conn->read_field(st, st->fields[fi].name, val, is_null); - if (!r) return fail(r.error()); - if (is_null) val.clear(); - if (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 firebird_is_record_deleted(ADSHANDLE hTable, UNSIGNED16* pbDeleted) { - auto* st = get_firebird_table(hTable); - *pbDeleted = st->current_deleted ? 1 : 0; - return ok(); -} - -UNSIGNED32 firebird_open_index(ADSHANDLE hTable, UNSIGNED8* pucName, - ADSHANDLE* ahIndex, UNSIGNED16* pu16ArrayLen) { - auto* st = get_firebird_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::FirebirdIndex, si.get()); - ahIndex[0] = to_ads_handle(gh); - if (pu16ArrayLen != nullptr) { - *pu16ArrayLen = 1; - } - firebird_indexes_map().emplace(gh, std::move(si)); - return ok(); -} - -UNSIGNED32 firebird_is_found(ADSHANDLE hTable, UNSIGNED16* pbFound) { - auto* st = get_firebird_table(hTable); - *pbFound = st->last_seek_found ? 1 : 0; - return ok(); -} - -UNSIGNED32 firebird_set_filter(ADSHANDLE hTable, UNSIGNED8* pucWhere) { - auto* st = get_firebird_table(hTable); - if (st == nullptr || st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - std::string where = - pucWhere ? openads::abi::to_internal(pucWhere, 0) : std::string(); - auto r = st->conn->set_filter(st, where); - if (!r) return fail(r.error()); - st->aof_opt_level = where.empty() - ? static_cast(ADS_OPTIMIZED_NONE) - : static_cast(ADS_OPTIMIZED_FULL); - return ok(); -} - -UNSIGNED32 firebird_aggregate( - ADSHANDLE hTable, const char* where_sql, - const std::vector* specs, - std::vector* out) { - auto* st = get_firebird_table(hTable); - if (st == nullptr || st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - if (specs == nullptr || out == nullptr) - return fail(openads::AE_INTERNAL_ERROR, "firebird_aggregate: null arg"); - auto r = st->conn->aggregate( - st, where_sql ? std::string(where_sql) : std::string(), *specs); - if (!r) return fail(r.error()); - *out = std::move(r).value(); - return ok(); -} - -UNSIGNED32 firebird_begin_tx(ADSHANDLE hTable) { - auto* st = get_firebird_table(hTable); - if (st == nullptr || st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto* conn = st->conn; - auto& tx = conn->tx_manager(); - openads::sql_backend::wire_standard_savepoint_tx( - tx, [conn](const std::string& sql) { (void)conn->exec_sql(sql); }); - tx.begin(); - return ok(); -} - -UNSIGNED32 firebird_commit_tx(ADSHANDLE hTable) { - auto* st = get_firebird_table(hTable); - if (st == nullptr || st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - st->conn->tx_manager().commit(); - return ok(); -} - -UNSIGNED32 firebird_rollback_tx(ADSHANDLE hTable) { - auto* st = get_firebird_table(hTable); - if (st == nullptr || st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - st->conn->tx_manager().rollback(); - return ok(); -} - -UNSIGNED32 firebird_set_auto_commit(ADSHANDLE hTable, SIGNED32 threshold) { - auto* st = get_firebird_table(hTable); - if (st == nullptr || st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - st->conn->tx_manager().auto_commit_threshold = threshold; - return ok(); -} - -const openads::abi::BackendTableOps* firebird_table_ops() { - static const openads::abi::BackendTableOps ops = [] { - openads::abi::BackendTableOps o{}; - o.close_table = &firebird_close_table; - o.goto_top = &firebird_goto_top; - o.goto_bottom = &firebird_goto_bottom; - o.skip = &firebird_skip; - o.at_eof = &firebird_at_eof; - o.at_bof = &firebird_at_bof; - o.num_fields = &firebird_num_fields; - o.field_name = &firebird_field_name; - o.field_type = &firebird_field_type; - o.field_length = &firebird_field_length; - o.field_decimals = &firebird_field_decimals; - o.record_num = &firebird_record_num; - o.record_count = &firebird_record_count; - o.get_field = &firebird_get_field; - o.is_record_deleted = &firebird_is_record_deleted; - o.open_index = &firebird_open_index; - o.is_found = &firebird_is_found; - o.set_filter = &firebird_set_filter; - o.aggregate = &firebird_aggregate; - o.begin_tx = &firebird_begin_tx; - o.commit_tx = &firebird_commit_tx; - o.rollback_tx = &firebird_rollback_tx; - o.set_auto_commit = &firebird_set_auto_commit; - return o; - }(); - return &ops; -} - -#endif // OPENADS_WITH_FIREBIRD (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()); - } - if (!openads::abi::assign_u16(pusCnt, st->fields.size())) { - return fail(openads::AE_INTERNAL_ERROR, "field count exceeds 65535"); - } - 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, ""); - // rddads reads by ORDINAL: pucField is ADSFIELD(n) (a 1-based field - // number cast to a pointer), not a NUL-terminated name. Resolve it with - // the ordinal-aware index helper BEFORE reading -- to_internal()/strlen() - // on the tiny pointer dereferences invalid memory and crashes. - auto fi = maria_field_index(st, pucField); - if (fi == std::numeric_limits::max()) - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - bool is_null = false; - std::string val; - auto r = st->conn->read_field(st, st->fields[fi].name, val, is_null); - if (!r) return fail(r.error()); - if (is_null) val.clear(); - if (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] = to_ads_handle(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(); -} - -UNSIGNED32 maria_set_filter(ADSHANDLE hTable, UNSIGNED8* pucWhere) { - auto* st = get_maria_table(hTable); - if (st == nullptr || st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - std::string where = - pucWhere ? openads::abi::to_internal(pucWhere, 0) : std::string(); - auto r = st->conn->set_filter(st, where); - if (!r) return fail(r.error()); - st->aof_opt_level = where.empty() - ? static_cast(ADS_OPTIMIZED_NONE) - : static_cast(ADS_OPTIMIZED_FULL); - return ok(); -} - -UNSIGNED32 maria_aggregate( - ADSHANDLE hTable, const char* where_sql, - const std::vector* specs, - std::vector* out) { - auto* st = get_maria_table(hTable); - if (st == nullptr || st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - if (specs == nullptr || out == nullptr) - return fail(openads::AE_INTERNAL_ERROR, "maria_aggregate: null arg"); - auto r = st->conn->aggregate( - st, where_sql ? std::string(where_sql) : std::string(), *specs); - if (!r) return fail(r.error()); - *out = std::move(r).value(); - return ok(); -} - -UNSIGNED32 maria_begin_tx(ADSHANDLE hTable) { - auto* st = get_maria_table(hTable); - if (st == nullptr || st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto* conn = st->conn; - auto& tx = conn->tx_manager(); - openads::sql_backend::wire_standard_savepoint_tx( - tx, [conn](const std::string& sql) { (void)conn->exec_sql(sql); }); - tx.begin(); - return ok(); -} - -UNSIGNED32 maria_commit_tx(ADSHANDLE hTable) { - auto* st = get_maria_table(hTable); - if (st == nullptr || st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - st->conn->tx_manager().commit(); - return ok(); -} - -UNSIGNED32 maria_rollback_tx(ADSHANDLE hTable) { - auto* st = get_maria_table(hTable); - if (st == nullptr || st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - st->conn->tx_manager().rollback(); - return ok(); -} - -UNSIGNED32 maria_set_auto_commit(ADSHANDLE hTable, SIGNED32 threshold) { - auto* st = get_maria_table(hTable); - if (st == nullptr || st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - st->conn->tx_manager().auto_commit_threshold = threshold; - 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; - o.set_filter = &maria_set_filter; - o.aggregate = &maria_aggregate; - o.begin_tx = &maria_begin_tx; - o.commit_tx = &maria_commit_tx; - o.rollback_tx = &maria_rollback_tx; - o.set_auto_commit = &maria_set_auto_commit; - 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_set_filter(ADSHANDLE hTable, UNSIGNED8* pucWhere) { - auto* st = get_postgres_table(hTable); - if (st == nullptr || st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - std::string where = - pucWhere ? openads::abi::to_internal(pucWhere, 0) : std::string(); - auto r = st->conn->set_filter(st, where); - if (!r) return fail(r.error()); - st->aof_opt_level = where.empty() - ? static_cast(ADS_OPTIMIZED_NONE) - : static_cast(ADS_OPTIMIZED_FULL); - return ok(); -} - -// Tier-3 push-down: delegate to PostgresConnection::aggregate (one SELECT -// COUNT/SUM/AVG/MIN/MAX ... WHERE) -- same contract as sqlite_aggregate. -UNSIGNED32 postgres_aggregate( - ADSHANDLE hTable, const char* where_sql, - const std::vector* specs, - std::vector* out) { - auto* st = get_postgres_table(hTable); - if (st == nullptr || st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - if (specs == nullptr || out == nullptr) - return fail(openads::AE_INTERNAL_ERROR, "postgres_aggregate: null arg"); - auto r = st->conn->aggregate( - st, where_sql ? std::string(where_sql) : std::string(), *specs); - if (!r) return fail(r.error()); - *out = std::move(r).value(); - 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()); - } - if (!openads::abi::assign_u16(pusCnt, st->fields.size())) { - return fail(openads::AE_INTERNAL_ERROR, "field count exceeds 65535"); - } - 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, ""); - } - *pusDec = st->fields[i].decimals; - return ok(); -} - -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(); -} - -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(); -} - -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, ""); - // rddads reads by ORDINAL: pucField is ADSFIELD(n) (a 1-based field - // number cast to a pointer), not a NUL-terminated name. Resolve it with - // the ordinal-aware index helper BEFORE reading -- to_internal()/strlen() - // on the tiny pointer dereferences invalid memory and crashes. - auto fi = postgres_field_index(st, pucField); - if (fi == std::numeric_limits::max()) - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - bool is_null = false; - std::string val; - auto r = st->conn->read_field(st, st->fields[fi].name, val, is_null); - if (!r) return fail(r.error()); - if (is_null) val.clear(); - if (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 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(); - std::lock_guard lk(s.mu); - Handle gh = s.registry.register_object( - HandleKind::PostgresIndex, si.get()); - ahIndex[0] = to_ads_handle(gh); - if (pu16ArrayLen != nullptr) { - *pu16ArrayLen = 1; - } - postgres_indexes_map().emplace(gh, std::move(si)); - return ok(); -} - -UNSIGNED32 postgres_is_found(ADSHANDLE hTable, UNSIGNED16* pbFound) { - auto* st = get_postgres_table(hTable); - *pbFound = st->last_seek_found ? 1 : 0; - return ok(); -} - -// ── Tier 1: Transaction management adapters (SQLRDD pattern) ────── - -UNSIGNED32 postgres_begin_tx(ADSHANDLE hTable) { - auto* st = get_postgres_table(hTable); - if (st == nullptr || st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto& tx = st->conn->tx_manager(); - if (!tx.on_begin) { - auto* conn = st->conn; - tx.on_begin = [conn](bool is_nested) { - if (!is_nested) { - conn->exec_sql("BEGIN"); - } else { - conn->exec_sql("SAVEPOINT sp_" + std::to_string(conn->tx_manager().nesting)); - } - }; - tx.on_commit = [conn](bool is_nested) { - (void)is_nested; - // PostgreSQL needs explicit BEGIN after COMMIT - conn->exec_sql("COMMIT"); - }; - tx.on_rollback = [conn]() { - conn->exec_sql("ROLLBACK"); - }; - tx.on_savepoint = [conn](const std::string& name) { - conn->exec_sql("SAVEPOINT " + name); - }; - tx.on_release_savepoint = [conn](const std::string& name) { - conn->exec_sql("RELEASE SAVEPOINT " + name); - }; - tx.on_rollback_savepoint = [conn](const std::string& name) { - conn->exec_sql("ROLLBACK TO SAVEPOINT " + name); - }; - } - tx.begin(); - return ok(); -} - -UNSIGNED32 postgres_commit_tx(ADSHANDLE hTable) { - auto* st = get_postgres_table(hTable); - if (st == nullptr || st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - st->conn->tx_manager().commit(); - return ok(); -} - -UNSIGNED32 postgres_rollback_tx(ADSHANDLE hTable) { - auto* st = get_postgres_table(hTable); - if (st == nullptr || st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - st->conn->tx_manager().rollback(); - return ok(); -} - -UNSIGNED32 postgres_set_auto_commit(ADSHANDLE hTable, SIGNED32 threshold) { - auto* st = get_postgres_table(hTable); - if (st == nullptr || st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - st->conn->tx_manager().auto_commit_threshold = threshold; - 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; - o.set_filter = &postgres_set_filter; - o.aggregate = &postgres_aggregate; - o.begin_tx = &postgres_begin_tx; - o.commit_tx = &postgres_commit_tx; - o.rollback_tx = &postgres_rollback_tx; - o.set_auto_commit = &postgres_set_auto_commit; - return o; - }(); - return &ops; -} - -#endif // OPENADS_WITH_POSTGRESQL (lifted ops) - -// --------------------------------------------------------------------------- -// Referential Integrity enforcement -// --------------------------------------------------------------------------- - -// "AdsAppendRecord called but AdsWriteRecord hasn't fired yet" is tracked -// per-Table via Table::pending_append() -- see table.h. It used to be a -// global std::unordered_set, but a freed table's heap address -// could be reused by a different table that still carried the stale -// "pending append" flag, making a plain UPDATE take the INSERT path and -// silently skip RI cascade/restrict enforcement (intermittent, heap- -// layout dependent). Storing the flag on the Table removes that aliasing. - -// Recursion guard: prevents RI cascade actions from triggering a second -// round of RI checks on the child table. -bool& in_ri_check() { - static thread_local bool flag = false; - return flag; -} - -// Find the Connection that owns Table* t. -Connection* conn_for_table(Table* t) { - auto& s = state(); - // Each Connection's table map is changed under s.mu (open/close), so - // the scan must hold it too. Without it a navigation on one session - // (snapshot_ri_pks) read a map another session was inserting into - // (found by ThreadSanitizer under the session-pool test). Lock order - // s.mu -> registry matches register_object/release. - std::lock_guard lk(s.mu); - Connection* found = nullptr; - s.registry.for_each_handle([&](Handle, HandleKind k, void* p) { - if (k != HandleKind::Connection || found) return; - auto* c = static_cast(p); - if (c->owns_table_ptr(t)) found = c; - }); - return found; -} - -// Find the DD alias for a table given its resolved absolute path. -// -// PERF: fs::weakly_canonical is a filesystem syscall. Doing it once per DD -// table on every call made this ~7 ms on a 95-table DD -- and callers run -// per NAVIGATION (snapshot_ri_pks) and per WRITE (RI enforcement), which -// turned large cursor walks into apparent hangs. Two defences: -// - a case-insensitive BASENAME pre-filter, so entries that cannot match -// (every SQL temp, most tables) are rejected with pure string compares -// and only basename collisions pay the canonicalisation; -// - hot callers additionally cache the result on the Table -// (ri_alias_cached / ri_alias_cache) -- the path never changes after -// open, so the alias can't either. -std::string ri_alias_for_path(Connection* conn, const std::string& abs_path) { - namespace fs = std::filesystem; - auto* dd = conn->dd(); - if (!dd) return {}; - std::error_code ec; - auto lower = [](std::string v) { - for (auto& ch : v) - ch = static_cast(std::tolower( - static_cast(ch))); - return v; - }; - const std::string want_base = - lower(fs::path(abs_path).filename().string()); - std::string cb; // canonical abs_path, computed on first basename hit - for (auto& [alias, rel] : dd->tables()) { - if (lower(fs::path(rel).filename().string()) != want_base) continue; - if (cb.empty()) - cb = lower(fs::weakly_canonical(fs::path(abs_path), ec).string()); - fs::path full = fs::path(conn->data_dir()) / rel; - auto ca = lower(fs::weakly_canonical(full, ec).string()); - if (ca == cb) return alias; - } - return {}; -} - -// Right-trim spaces from a DBF field value. -std::string ri_trim(const std::string& s) { - auto i = s.find_last_not_of(' '); - return (i == std::string::npos) ? std::string{} : s.substr(0, i + 1); -} - -// Read a named field from the current record buffer. -std::string ri_read_field(Table& tbl, const std::string& name) { - auto idx = tbl.field_index(name); - if (idx < 0) return {}; - auto v = tbl.read_field(static_cast(idx)); - return v ? v.value().as_string : std::string{}; -} - -// Scan `tbl` for any live row where field `fname` equals `key` (trimmed). -// Returns true if at least one match is found; if `recnos` is non-null -// it collects ALL matching record numbers. -bool ri_scan(Table& tbl, const std::string& fname, const std::string& key, - std::vector* recnos) { - bool any = false; - if (auto r = tbl.goto_top(); !r) return false; - while (!tbl.eof()) { - if (!tbl.is_deleted()) { - if (ri_trim(ri_read_field(tbl, fname)) == key) { - any = true; - if (recnos) recnos->push_back(tbl.recno()); - else return true; // RESTRICT: one match is enough - } - } - (void)tbl.skip(1); - } - return any; -} - -// Called from AdsWriteRecord when the record was freshly appended. -// Validates that every FK field exists in the referenced parent table. -openads::util::Result ri_check_insert(Connection* conn, Table& child) { - if (in_ri_check()) return {}; - auto* dd = conn->dd(); - if (!dd || dd->ri().empty()) return {}; - std::string child_alias = ri_alias_for_path(conn, child.path()); - if (child_alias.empty()) return {}; - - for (const auto* rulep : dd->ri_by_child_table(child_alias)) { - const auto& rule = *rulep; - const std::string& rname = rule.name; - // rule.parent_tag is the parent index tag name; by convention (single-field - // PK/FK) the same name identifies the FK field in the child. - std::string fk_val = ri_trim(ri_read_field(child, rule.parent_tag)); - if (fk_val.empty()) continue; // NULL / blank FK → skip - - auto ph = conn->open_table(rule.parent, - openads::engine::TableType::Cdx, - openads::engine::OpenMode::Read); - if (!ph) { - return openads::util::Error{ - openads::AE_RI_VIOLATION, 0, - "RI: cannot open parent table '" + rule.parent + "'", rname}; - } - Table* parent = conn->lookup_table(ph.value()); - bool found = parent && ri_scan(*parent, rule.parent_tag, fk_val, nullptr); - conn->close_table(ph.value()); - if (!found) { - return openads::util::Error{ - openads::AE_RI_VIOLATION, 0, - "RI violation: FK value '" + fk_val + - "' not found in parent '" + rule.parent + "'", - rname}; - } - } - return {}; -} - -// Called from AdsDeleteRecord before marking the row deleted. -// Enforces delete_opt rules for every RI rule where this table is the parent. -openads::util::Result ri_enforce_delete(Connection* conn, Table& parent) { - if (in_ri_check()) return {}; - auto* dd = conn->dd(); - if (!dd || dd->ri().empty()) return {}; - std::string parent_alias = ri_alias_for_path(conn, parent.path()); - if (parent_alias.empty()) return {}; - - for (const auto* rulep : dd->ri_by_parent_table(parent_alias)) { - const auto& rule = *rulep; - const std::string& rname = rule.name; - std::string pk_val = ri_trim(ri_read_field(parent, rule.parent_tag)); - if (pk_val.empty()) continue; - - unsigned del_opt = ADS_DD_RI_RESTRICT; - if (!rule.delete_opt.empty()) { - try { del_opt = static_cast( - std::stoul(rule.delete_opt)); } catch (...) {} - } - - bool need_write = (del_opt == ADS_DD_RI_CASCADE || - del_opt == ADS_DD_RI_SETNULL || - del_opt == ADS_DD_RI_SETDEFAULT); - // Reuse the application's already-open child instance when present - // (see ri_enforce_update for why a second open races and drops the - // action). Open a fresh one only as a fallback. - Table* child = conn->find_open_table(rule.child); - bool opened_here = false; - Handle ch_handle = 0; - if (!child) { - auto ch = conn->open_table(rule.child, - openads::engine::TableType::Cdx, - need_write ? openads::engine::OpenMode::Shared - : openads::engine::OpenMode::Read); - if (!ch) continue; // can't open child → skip rule - child = conn->lookup_table(ch.value()); - if (!child) { conn->close_table(ch.value()); continue; } - opened_here = true; - ch_handle = ch.value(); - } - - if (del_opt == ADS_DD_RI_RESTRICT) { - bool any = ri_scan(*child, rule.parent_tag, pk_val, nullptr); - if (opened_here) conn->close_table(ch_handle); - if (any) { - return openads::util::Error{ - openads::AE_RI_VIOLATION, 0, - "RI violation: child rows exist in '" + rule.child + "'", - rname}; - } - } else { - // Collect matching recnos first, then apply action. - std::vector matches; - ri_scan(*child, rule.parent_tag, pk_val, &matches); - in_ri_check() = true; - // Lock the child table so writeback_record_() (the GoHot gate) - // permits the cascade/SETNULL writes. - if (need_write) { - (void)child->lock_table_excl(); - } - for (std::uint32_t rec : matches) { - if (auto gr = child->goto_record(rec); !gr) continue; - if (del_opt == ADS_DD_RI_CASCADE) { - (void)child->mark_deleted(); - } else { - // SETNULL / SETDEFAULT: blank the FK field. - auto fi = child->field_index(rule.parent_tag); - if (fi >= 0) { - auto fi16 = static_cast(fi); - std::uint32_t flen = - child->field_descriptor(fi16).length; - (void)child->set_field(fi16, - std::string(flen, ' ')); - } - } - } - in_ri_check() = false; - if (!matches.empty()) (void)child->flush(); - if (opened_here) conn->close_table(ch_handle); - } - } - return {}; -} - -// Called after every successful local-table navigation. -// If the table is a parent in any RI rule, snapshot its PK fields onto -// the Table itself (Table::ri_snapshot()). Storing the snapshot on the -// Table -- rather than in a global Table*-keyed map -- means it lives and -// dies with the table, so a freed-then-reallocated table can never -// inherit a previous table's stale snapshot (the cause of intermittent -// missed cascades/restrictions seen only in the full-suite run). -void snapshot_ri_pks(Table* t) { - if (!t || t->eof()) { - if (t) t->ri_snapshot().clear(); - return; - } - // Runs on EVERY navigation (Skip/GoTop/GoBottom/GoTo) -- resolve the - // DD alias once per Table, not once per step. Without the cache a - // 382K-row cursor walk on a DD connection took ~45 minutes (7 ms of - // fs::weakly_canonical calls per Skip); see ri_alias_for_path. - if (!t->ri_alias_cached()) { - Connection* cfa = conn_for_table(t); - t->ri_alias_cache() = - cfa ? ri_alias_for_path(cfa, t->path()) : std::string(); - t->ri_alias_cached() = true; - } - const std::string& alias = t->ri_alias_cache(); - if (alias.empty()) return; - Connection* conn = conn_for_table(t); - if (!conn) return; - auto* dd = conn->dd(); - if (!dd || dd->ri().empty()) return; - const auto& parent_rules = dd->ri_by_parent_table(alias); - if (parent_rules.empty()) return; - auto& snap = t->ri_snapshot(); - snap.clear(); - for (const auto* rulep : parent_rules) { - const auto& rule = *rulep; - snap[rule.parent_tag] = ri_trim(ri_read_field(*t, rule.parent_tag)); - } -} - -// ── Parent→child work-area relations (AdsSetRelation) ──────────────── -// When a parent's cursor moves, each related child is re-positioned: its -// controlling order is seeked to the key produced by evaluating `expr` -// against the parent's current record. A child with no controlling order -// is moved to the record number the expression yields. A miss (or a -// parent sitting off a record) leaves the child at EOF, matching ACE / -// Clipper dbSetRelation. A `scoped` relation (AdsSetScopedRelation) also -// constrains the child to the group of records whose key matches, by -// setting the child order's top/bottom scope to the relation key. -// Parent work-area → child relations (local Tables and tcp:// RemoteTables). -struct AdsRelation { ADSHANDLE child; std::string expr; bool scoped; }; -std::unordered_map>& relation_map() { - static std::unordered_map> m; - return m; -} - -// Active controlling index per SQL table handle (AdsSetIndexOrder). -std::unordered_map& sql_active_index_handle() { - static std::unordered_map m; - return m; -} - -bool is_sql_table_handle(ADSHANDLE h) { -#if defined(OPENADS_WITH_SQLITE) - if (get_sqlite_table(h)) return true; -#endif -#if defined(OPENADS_WITH_POSTGRESQL) - if (get_postgres_table(h)) return true; -#endif -#if defined(OPENADS_WITH_MARIADB) - if (get_maria_table(h)) return true; -#endif -#if defined(OPENADS_WITH_ODBC) - if (get_odbc_table(h)) return true; -#endif -#if defined(OPENADS_WITH_FIREBIRD) - if (get_firebird_table(h)) return true; -#endif -#if defined(OPENADS_WITH_MSSQL) - if (get_mssql_table(h)) return true; -#endif - (void)h; - return false; -} - -namespace { - -void remote_child_to_eof(openads::network::RemoteTable* child) { - if (child == nullptr || child->conn == nullptr) return; - (void)child->conn->goto_bottom(child); - (void)child->conn->skip(child, 1); - child->row_valid = false; -} - -bool remote_parent_positioned(openads::network::RemoteTable* parent) { - if (parent == nullptr || parent->conn == nullptr) return false; - if (parent->row_valid) return true; - (void)parent->conn->fetch_current_row(parent); - if (parent->row_valid) return true; - auto eof = parent->conn->at_eof(parent->id); - return eof.has_value() && !eof.value(); -} - -std::string remote_parent_field(openads::network::RemoteTable* parent, - const std::string& expr) { - // Defensive: the seek key must derive from committed parent values. - // Normally clean here (every parent nav flushes), so this is free. - (void)remote_flush_pending(parent); - std::string field = openads::engine::strip_alias_qualifiers(expr); - if (!parent->row_valid) { - (void)parent->conn->fetch_current_row(parent); - } - // RCB 07/14/2026: BUG FIX (pre-existing, exposed by the M12.24 warm - // GotoTop). This used to fetch the parent's row into the cache above and - // then ignore it, reading the field with conn->get_field() -- which reads - // the SERVER's cursor. That cursor is offset from the client's logical - // position by cursor_lag whenever rows have been served out of the lookahead - // block, so the relation followed a STALE PARENT ROW and pointed the child - // at the wrong record. - // - // It stayed hidden because the only coverage skipped the parent exactly - // once, and before the warm GotoTop that first Skip always went to the wire - // (the queue was still empty), leaving the cursors in sync. The SECOND skip - // of any scan would have drained the queue and broken -- i.e. this has been - // wrong for parent/child grids since sequential prefetch landed. - // - // Read the cached row, which is the client's logical current record by - // construction. Also removes a wire round-trip per parent row per relation. - // - // DO NOT "simplify" this back to a single conn->get_field() call. It reads - // like the obvious, direct thing to do -- that is exactly how the bug got - // written -- but asking the server for "the current record" is asking the - // WRONG QUESTION whenever the client is ahead of it. Guarded by the - // full-parent walk in the AdsSetRelation remote test; a test that skips the - // parent only once will NOT catch a regression here. - if (parent->row_valid) { - if (!parent->fields_cached) { - if (auto d = parent->conn->describe_table(parent->id)) { - parent->fields = std::move(d).value(); - parent->fields_cached = true; - } - } - auto upper = [](std::string s) { - for (auto& c : s) { - c = static_cast( - std::toupper(static_cast(c))); - } - return s; - }; - const std::string want = upper(field); - const std::size_t n = std::min(parent->fields.size(), - parent->current_row.size()); - for (std::size_t i = 0; i < n; ++i) { - if (upper(parent->fields[i].name) == want) { - std::string vstr = parent->current_row[i]; - remote_pending_overlay(parent, i, vstr); - return vstr; - } - } - } - // Fallback: no cached row (e.g. an older server that sent no trailer). - // The server cursor is authoritative in that case because nothing was - // drained locally. - auto v = parent->conn->get_field(parent->id, field); - return v.has_value() ? v.value() : std::string(); -} - -std::string remote_relation_seek_key(openads::network::RemoteTable* child, - const std::string& raw) { - if (!child->fields_cached) { - auto r = child->conn->describe_table(child->id); - if (r) { - child->fields = std::move(r).value(); - child->fields_cached = true; - } - } - std::uint32_t klen = 10; - bool numeric = false; - std::uint16_t width = 0; - std::uint16_t dec = 0; - if (child->active_index_id != 0) { - for (auto& [tag, wid] : child->index_by_tag) { - if (wid == child->active_index_id) { - (void)tag; - break; - } - } - } - for (auto& fd : child->fields) { - if (fd.type == ADS_NUMERIC || fd.type == ADS_INTEGER || - fd.type == ADS_DOUBLE) { - numeric = true; - width = static_cast(fd.length); - dec = fd.decimals; - klen = fd.length > 0 ? fd.length : 8u; - } - } - if (numeric) { - char buf[264]; - double dv = 0; - try { dv = std::stod(raw); } catch (...) { dv = 0; } - int n = (dec > 0) - ? std::snprintf(buf, sizeof(buf), "%*.*f", - static_cast(width), static_cast(dec), dv) - : std::snprintf(buf, sizeof(buf), "%*.0f", - static_cast(width), dv); - std::size_t take = (n < 0) - ? 0u - : std::min(static_cast(n), - sizeof(buf) - 1); - std::string key(buf, take); - if (key.size() < klen) key.append(klen - key.size(), ' '); - return key; - } - std::string key = raw; - if (key.size() < klen) key.append(klen - key.size(), ' '); - else if (key.size() > klen) key.resize(klen); - return key; -} - -void seek_remote_child_relation(openads::network::RemoteTable* parent, - openads::network::RemoteTable* child, - const std::string& expr, - bool scoped) { - if (parent == nullptr || child == nullptr || child->conn == nullptr) return; - // A detail line edited but not yet flushed belongs to the child row - // under the previous parent; the seek below moves the child cursor, - // so land the buffered writes first (no-op when clean). - (void)remote_flush_pending(child); - if (!remote_parent_positioned(parent)) { - if (scoped && child->active_index_id != 0) { - (void)child->conn->clear_scope(child->active_index_id, ADS_TOP); - (void)child->conn->clear_scope(child->active_index_id, ADS_BOTTOM); - } - remote_child_to_eof(child); - return; - } - std::string raw = remote_parent_field(parent, expr); - if (child->active_index_id == 0) { - double dv = 0; - try { dv = std::stod(raw); } catch (...) { dv = 0; } - auto rc = child->conn->record_count(child->id); - std::uint32_t max_rec = rc.has_value() ? rc.value() : 0; - auto rn = static_cast(dv); - if (rn >= 1 && rn <= max_rec) { - (void)child->conn->goto_record(child, rn); - } else { - remote_child_to_eof(child); - } - return; - } - std::string key = remote_relation_seek_key(child, raw); - if (scoped) { - (void)child->conn->clear_scope(child->active_index_id, ADS_TOP); - (void)child->conn->clear_scope(child->active_index_id, ADS_BOTTOM); - (void)child->conn->set_scope(child->active_index_id, ADS_TOP, - key, ADS_STRINGKEY); - (void)child->conn->set_scope(child->active_index_id, ADS_BOTTOM, - key, ADS_STRINGKEY); - (void)child->conn->goto_top(child); - child->found_cached = true; - child->current_found = true; - return; - } - // RCB 07/14/2026: M12.24 -- pass `child` so the SeekAck's row trailer lands - // straight in the child's row cache. - // - // Clearing row_valid FIRST is load-bearing, not tidiness: it is what turns - // the `!child->row_valid` test below into an honest "did the server actually - // send me a row?" probe. Leave a stale row_valid=true standing here and that - // test answers yes for the PREVIOUS parent's child row, we skip the fallback, - // and the grid quietly shows the wrong child. - child->row_valid = false; - child->invalidate_prefetch(); - auto so = child->conn->seek(child->active_index_id, key, - /*soft=*/1, /*last=*/0, child); - if (!so || so.value().hit == 0) { - remote_child_to_eof(child); - return; - } - // RCB 07/14/2026: this GotoRecord used to fire UNCONDITIONALLY, and it was - // pure overhead -- the seek had ALREADY positioned the server cursor on this - // record. The only reason for the second frame was to drag the row down, - // because SeekAck carried no row. It runs once per PARENT row in a relation - // browse, so it was doubling the wire cost of every child lookup in the grid. - // - // An M12.24 server hands us the row with the seek, so this is now only sent - // when talking to an OLDER server that didn't. It is NOT dead code -- delete - // the branch and every remote relation against a pre-M12.24 server silently - // stops resolving its child row. (See the row_valid note above for why the - // condition is trustworthy.) - if (!child->row_valid) { - (void)child->conn->goto_record(child, so.value().recno); - } - child->found_cached = true; - child->current_found = true; -} - -void seek_remote_child_relation(Table* parent, - openads::network::RemoteTable* child, - const std::string& expr, - bool scoped) { - if (parent == nullptr || child == nullptr || child->conn == nullptr) return; - // Same child-cursor flush as the RemoteTable-parent overload above. - (void)remote_flush_pending(child); - if (!parent->positioned() || parent->eof()) { - if (scoped && child->active_index_id != 0) { - (void)child->conn->clear_scope(child->active_index_id, ADS_TOP); - (void)child->conn->clear_scope(child->active_index_id, ADS_BOTTOM); - } - remote_child_to_eof(child); - return; - } - const std::string field = openads::engine::strip_alias_qualifiers(expr); - std::string raw = ri_read_field(*parent, field); - while (!raw.empty() && raw.back() == ' ') raw.pop_back(); - if (child->active_index_id == 0) { - double dv = 0; - try { dv = std::stod(raw); } catch (...) { dv = 0; } - auto rc = child->conn->record_count(child->id); - std::uint32_t max_rec = rc.has_value() ? rc.value() : 0; - auto rn = static_cast(dv); - if (rn >= 1 && rn <= max_rec) { - (void)child->conn->goto_record(child, rn); - } else { - remote_child_to_eof(child); - } - return; - } - std::string key = remote_relation_seek_key(child, raw); - if (scoped) { - (void)child->conn->clear_scope(child->active_index_id, ADS_TOP); - (void)child->conn->clear_scope(child->active_index_id, ADS_BOTTOM); - (void)child->conn->set_scope(child->active_index_id, ADS_TOP, - key, ADS_STRINGKEY); - (void)child->conn->set_scope(child->active_index_id, ADS_BOTTOM, - key, ADS_STRINGKEY); - (void)child->conn->goto_top(child); - child->found_cached = true; - child->current_found = true; - return; - } - // RCB 07/14/2026: M12.24 -- pass `child` so the SeekAck's row trailer lands - // straight in the child's row cache. - // - // Clearing row_valid FIRST is load-bearing, not tidiness: it is what turns - // the `!child->row_valid` test below into an honest "did the server actually - // send me a row?" probe. Leave a stale row_valid=true standing here and that - // test answers yes for the PREVIOUS parent's child row, we skip the fallback, - // and the grid quietly shows the wrong child. - child->row_valid = false; - child->invalidate_prefetch(); - auto so = child->conn->seek(child->active_index_id, key, - /*soft=*/1, /*last=*/0, child); - if (!so || so.value().hit == 0) { - remote_child_to_eof(child); - return; - } - // RCB 07/14/2026: this GotoRecord used to fire UNCONDITIONALLY, and it was - // pure overhead -- the seek had ALREADY positioned the server cursor on this - // record. The only reason for the second frame was to drag the row down, - // because SeekAck carried no row. It runs once per PARENT row in a relation - // browse, so it was doubling the wire cost of every child lookup in the grid. - // - // An M12.24 server hands us the row with the seek, so this is now only sent - // when talking to an OLDER server that didn't. It is NOT dead code -- delete - // the branch and every remote relation against a pre-M12.24 server silently - // stops resolving its child row. (See the row_valid note above for why the - // condition is trustworthy.) - if (!child->row_valid) { - (void)child->conn->goto_record(child, so.value().recno); - } - child->found_cached = true; - child->current_found = true; -} - -#if defined(OPENADS_WITH_SQLITE) -void sql_child_to_eof_sqlite(openads::sql_backend::SqliteTable* child) { - if (child == nullptr) return; - child->positioned = false; - child->row_valid = false; - child->pos = child->rowids.size(); -} -#endif - -#if defined(OPENADS_WITH_MARIADB) -void sql_child_to_eof_maria(openads::sql_backend::MariaTable* child) { - if (child == nullptr) return; - child->positioned = false; - child->row_valid = false; - child->pos = child->pk_snapshot.size(); -} -#endif - -#if defined(OPENADS_WITH_ODBC) -void sql_child_to_eof_odbc(openads::sql_backend::OdbcTable* child) { - if (child == nullptr) return; - child->positioned = false; - child->row_valid = false; - child->pos = child->pk_snapshot.size(); -} -#endif - -#if defined(OPENADS_WITH_FIREBIRD) -void sql_child_to_eof_firebird(openads::sql_backend::FirebirdTable* child) { - if (child == nullptr) return; - child->positioned = false; - child->row_valid = false; - child->pos = child->pk_snapshot.size(); -} -#endif - -#if defined(OPENADS_WITH_POSTGRESQL) -void sql_child_to_eof_postgres(openads::sql_backend::PostgresTable* child) { - if (child == nullptr) return; - child->positioned = false; - child->row_valid = false; - child->pos = child->pk_snapshot.size(); -} -#endif - -#if defined(OPENADS_WITH_MSSQL) -void sql_child_to_eof_mssql(openads::sql_backend::MssqlTable* child) { - if (child == nullptr) return; - child->pos = child->data.rows.size(); - child->bof = false; - child->eof = true; -} -#endif - -std::string sql_parent_field_raw(const std::string& expr) { - return openads::engine::strip_alias_qualifiers(expr); -} - -std::string sql_escape_literal_value(const std::string& s) { - std::string o; - o.reserve(s.size()); - for (char c : s) { - if (c == '\'') o += "''"; - else o += c; - } - return o; -} - -bool sql_raw_key_looks_numeric(const std::string& raw) { - if (raw.empty()) return false; - bool saw_digit = false; - for (char c : raw) { - if (std::isdigit(static_cast(c))) { - saw_digit = true; - continue; - } - if (c == '.' || c == '-' || c == '+') continue; - return false; - } - return saw_digit; -} - -std::string sql_scope_equality(const std::string& col, - const std::string& raw_key, - const char* ql, - const char* qr) { - const std::string qcol = std::string(ql) + col + qr; - if (sql_raw_key_looks_numeric(raw_key)) { - return qcol + " = " + raw_key; - } - return qcol + " = '" + sql_escape_literal_value(raw_key) + "'"; -} - -void sql_clear_relation_scope(ADSHANDLE hChild) { -#if defined(OPENADS_WITH_SQLITE) - if (auto* ct = get_sqlite_table(hChild)) { - if (ct->conn == nullptr) return; - ct->where_builder.scope_lower.clear(); - ct->where_builder.scope_upper.clear(); - (void)ct->conn->refresh_where_filter(ct); - return; - } -#endif -#if defined(OPENADS_WITH_MARIADB) - if (auto* ct = get_maria_table(hChild)) { - if (ct->conn == nullptr) return; - ct->where_builder.scope_lower.clear(); - ct->where_builder.scope_upper.clear(); - (void)ct->conn->refresh_where_filter(ct); - return; - } -#endif -#if defined(OPENADS_WITH_POSTGRESQL) - if (auto* ct = get_postgres_table(hChild)) { - if (ct->conn == nullptr) return; - ct->where_builder.scope_lower.clear(); - ct->where_builder.scope_upper.clear(); - (void)ct->conn->refresh_where_filter(ct); - return; - } -#endif -#if defined(OPENADS_WITH_ODBC) - if (auto* ct = get_odbc_table(hChild)) { - if (ct->conn == nullptr) return; - ct->where_builder.scope_lower.clear(); - ct->where_builder.scope_upper.clear(); - (void)ct->conn->refresh_where_filter(ct); - return; - } -#endif -#if defined(OPENADS_WITH_FIREBIRD) - if (auto* ct = get_firebird_table(hChild)) { - if (ct->conn == nullptr) return; - ct->where_builder.scope_lower.clear(); - ct->where_builder.scope_upper.clear(); - (void)ct->conn->refresh_where_filter(ct); - return; - } -#endif -#if defined(OPENADS_WITH_MSSQL) - if (auto* ct = get_mssql_table(hChild)) { - if (ct->conn == nullptr) return; - ct->where_builder.scope_lower.clear(); - ct->where_builder.scope_upper.clear(); - (void)ct->conn->refresh_where_filter(ct); - return; - } -#endif - (void)hChild; -} - -bool sql_apply_relation_scope(ADSHANDLE hChild, ADSHANDLE idx_h, - const std::string& raw_key) { - if (raw_key.empty()) { - sql_clear_relation_scope(hChild); - return true; - } -#if defined(OPENADS_WITH_SQLITE) - if (auto* ct = get_sqlite_table(hChild)) { - if (ct->conn == nullptr) return false; - if (auto* si = get_sqlite_index(idx_h); si != nullptr) { - const std::string pred = - sql_scope_equality(si->column, raw_key, "\"", "\""); - ct->where_builder.scope_lower = pred; - ct->where_builder.scope_upper = pred; - (void)ct->conn->refresh_where_filter(ct); - (void)ct->conn->goto_top(ct); - return true; - } - return false; - } -#endif -#if defined(OPENADS_WITH_MARIADB) - if (auto* ct = get_maria_table(hChild)) { - if (ct->conn == nullptr) return false; - if (auto* si = get_maria_index(idx_h); si != nullptr) { - const std::string pred = - sql_scope_equality(si->column, raw_key, "`", "`"); - ct->where_builder.scope_lower = pred; - ct->where_builder.scope_upper = pred; - (void)ct->conn->refresh_where_filter(ct); - (void)ct->conn->goto_top(ct); - return true; - } - return false; - } -#endif -#if defined(OPENADS_WITH_POSTGRESQL) - if (auto* ct = get_postgres_table(hChild)) { - if (ct->conn == nullptr) return false; - if (auto* si = get_postgres_index(idx_h); si != nullptr) { - const std::string pred = - sql_scope_equality(si->column, raw_key, "\"", "\""); - ct->where_builder.scope_lower = pred; - ct->where_builder.scope_upper = pred; - (void)ct->conn->refresh_where_filter(ct); - (void)ct->conn->goto_top(ct); - return true; - } - return false; - } -#endif -#if defined(OPENADS_WITH_ODBC) - if (auto* ct = get_odbc_table(hChild)) { - if (ct->conn == nullptr) return false; - if (auto* si = get_odbc_index(idx_h); si != nullptr) { - const std::string pred = - sql_scope_equality(si->column, raw_key, "\"", "\""); - ct->where_builder.scope_lower = pred; - ct->where_builder.scope_upper = pred; - (void)ct->conn->refresh_where_filter(ct); - (void)ct->conn->goto_top(ct); - return true; - } - return false; - } -#endif -#if defined(OPENADS_WITH_FIREBIRD) - if (auto* ct = get_firebird_table(hChild)) { - if (ct->conn == nullptr) return false; - if (auto* si = get_firebird_index(idx_h); si != nullptr) { - const std::string pred = - sql_scope_equality(si->column, raw_key, "\"", "\""); - ct->where_builder.scope_lower = pred; - ct->where_builder.scope_upper = pred; - (void)ct->conn->refresh_where_filter(ct); - (void)ct->conn->goto_top(ct); - return true; - } - return false; - } -#endif -#if defined(OPENADS_WITH_MSSQL) - if (auto* ct = get_mssql_table(hChild)) { - if (ct->conn == nullptr) return false; - if (auto* si = get_mssql_index(idx_h); si != nullptr) { - const std::string pred = - sql_scope_equality(si->column, raw_key, "[", "]"); - ct->where_builder.scope_lower = pred; - ct->where_builder.scope_upper = pred; - (void)ct->conn->refresh_where_filter(ct); - return true; - } - return false; - } -#endif - (void)hChild; (void)idx_h; - return false; -} - -template -bool sql_read_parent_string(TableT* parent, ConnT* conn, - const std::string& expr, std::string& out) { - if (parent == nullptr || conn == nullptr) return false; - const std::string fname = sql_parent_field_raw(expr); - bool is_null = false; - auto r = conn->read_field(parent, fname, out, is_null); - if (!r) return false; - if (is_null) out.clear(); - return true; -} - -void seek_sql_child(ADSHANDLE hChild, const std::string& raw_key, bool scoped) { - ADSHANDLE idx_h = 0; - if (auto it = sql_active_index_handle().find(hChild); - it != sql_active_index_handle().end()) { - idx_h = it->second; - } - if (scoped && idx_h != 0) { - if (raw_key.empty()) { - sql_clear_relation_scope(hChild); -#if defined(OPENADS_WITH_SQLITE) - if (auto* ct = get_sqlite_table(hChild)) sql_child_to_eof_sqlite(ct); -#endif -#if defined(OPENADS_WITH_MARIADB) - if (auto* ct = get_maria_table(hChild)) sql_child_to_eof_maria(ct); -#endif -#if defined(OPENADS_WITH_POSTGRESQL) - if (auto* ct = get_postgres_table(hChild)) sql_child_to_eof_postgres(ct); -#endif -#if defined(OPENADS_WITH_ODBC) - if (auto* ct = get_odbc_table(hChild)) sql_child_to_eof_odbc(ct); -#endif -#if defined(OPENADS_WITH_FIREBIRD) - if (auto* ct = get_firebird_table(hChild)) sql_child_to_eof_firebird(ct); -#endif -#if defined(OPENADS_WITH_MSSQL) - if (auto* ct = get_mssql_table(hChild)) sql_child_to_eof_mssql(ct); -#endif - return; - } - if (sql_apply_relation_scope(hChild, idx_h, raw_key)) return; - } -#if defined(OPENADS_WITH_SQLITE) - if (auto* ct = get_sqlite_table(hChild)) { - if (ct->conn == nullptr) return; - if (raw_key.empty()) { sql_child_to_eof_sqlite(ct); return; } - if (idx_h == 0) { - double dv = 0; - try { dv = std::stod(raw_key); } catch (...) { dv = 0; } - const auto rn = static_cast(dv); - if (rn >= 1 && rn <= ct->rowids.size()) { - (void)ct->conn->goto_top(ct); - if (rn > 1) { - (void)ct->conn->skip(ct, static_cast(rn - 1)); - } - } else { - sql_child_to_eof_sqlite(ct); - } - return; - } - if (auto* si = get_sqlite_index(idx_h)) { - (void)si->parent->conn->seek_index( - si->parent, si->column, raw_key, true, false); - return; - } - } -#endif -#if defined(OPENADS_WITH_MARIADB) - if (auto* ct = get_maria_table(hChild)) { - if (ct->conn == nullptr) return; - if (idx_h == 0) { - double dv = 0; - try { dv = std::stod(raw_key); } catch (...) { dv = 0; } - if (dv >= 1 && - dv <= static_cast(ct->pk_snapshot.size())) { - ct->pos = static_cast(dv) - 1; - (void)ct->conn->goto_top(ct); - (void)ct->conn->skip(ct, static_cast(ct->pos)); - } else { - sql_child_to_eof_maria(ct); - } - return; - } - if (auto* si = get_maria_index(idx_h)) { - (void)si->parent->conn->seek_index( - si->parent, si->column, raw_key, true, false); - return; - } - } -#endif -#if defined(OPENADS_WITH_POSTGRESQL) - if (auto* ct = get_postgres_table(hChild)) { - if (ct->conn == nullptr) return; - if (idx_h != 0) { - if (auto* si = get_postgres_index(idx_h)) { - (void)si->parent->conn->seek_index( - si->parent, si->column, raw_key, true, false); - } - } else { - sql_child_to_eof_postgres(ct); - } - return; - } -#endif -#if defined(OPENADS_WITH_ODBC) - if (auto* ct = get_odbc_table(hChild)) { - if (ct->conn == nullptr) return; - if (idx_h != 0) { - if (auto* si = get_odbc_index(idx_h)) { - (void)si->parent->conn->seek_index( - si->parent, si->column, si->expr_kind, raw_key, true, false); - } - } else { - sql_child_to_eof_odbc(ct); - } - return; - } -#endif -#if defined(OPENADS_WITH_FIREBIRD) - if (auto* ct = get_firebird_table(hChild)) { - if (ct->conn == nullptr) return; - if (idx_h != 0) { - if (auto* si = get_firebird_index(idx_h)) { - (void)si->parent->conn->seek_index( - si->parent, si->column, si->expr_kind, raw_key, true, false); - } - } else { - sql_child_to_eof_firebird(ct); - } - return; - } -#endif -#if defined(OPENADS_WITH_MSSQL) - if (auto* ct = get_mssql_table(hChild)) { - if (ct->conn == nullptr) return; - if (idx_h != 0) { - if (auto* si = get_mssql_index(idx_h)) { - (void)ct->conn->seek_index(ct, si->column, raw_key, true, false); - } - } else { - sql_child_to_eof_mssql(ct); - } - return; - } -#endif - (void)hChild; (void)raw_key; -} - -bool sql_parent_key(ADSHANDLE hParent, const std::string& expr, std::string& key) { -#if defined(OPENADS_WITH_SQLITE) - if (auto* pt = get_sqlite_table(hParent)) { - if (!pt->positioned || !pt->row_valid || pt->is_result || !pt->conn) { - return false; - } - return sql_read_parent_string(pt, pt->conn, expr, key); - } -#endif -#if defined(OPENADS_WITH_MARIADB) - if (auto* pt = get_maria_table(hParent)) { - if (!pt->positioned || !pt->row_valid || !pt->conn) return false; - return sql_read_parent_string(pt, pt->conn, expr, key); - } -#endif -#if defined(OPENADS_WITH_POSTGRESQL) - if (auto* pt = get_postgres_table(hParent)) { - if (!pt->positioned || !pt->row_valid || !pt->conn) return false; - return sql_read_parent_string(pt, pt->conn, expr, key); - } -#endif -#if defined(OPENADS_WITH_ODBC) - if (auto* pt = get_odbc_table(hParent)) { - if (!pt->positioned || !pt->row_valid || !pt->conn) return false; - return sql_read_parent_string(pt, pt->conn, expr, key); - } -#endif -#if defined(OPENADS_WITH_FIREBIRD) - if (auto* pt = get_firebird_table(hParent)) { - if (!pt->positioned || !pt->row_valid || !pt->conn) return false; - return sql_read_parent_string(pt, pt->conn, expr, key); - } -#endif -#if defined(OPENADS_WITH_MSSQL) - if (auto* pt = get_mssql_table(hParent)) { - if (pt->bof || pt->eof) return false; - const std::string fname = sql_parent_field_raw(expr); - for (std::size_t i = 0; i < pt->field_count(); ++i) { - if (pt->field_name(i) == fname) { - bool is_null = false; - (void)pt->get_field(i, key, is_null); - return true; - } - } - return false; - } -#endif - (void)hParent; (void)expr; (void)key; - return false; -} - -} // namespace - -// Drive `child` to EOF -- used when the parent has no current record or -// the relation key finds no match. -void relation_child_to_eof(Table* child) { - (void)child->goto_bottom(); - (void)child->skip(1); -} - -// Build the seek key for a relation, in the child index's encoding, -// mirroring AdsSeek's numeric handling so a numeric child index is matched -// the same way an explicit dbSeek would be. `dk` must be non-null. -std::string relation_child_key(Table* parent, Table* child, - const std::string& expr, - openads::drivers::IIndex* dk) { - std::int32_t fidx = child->field_index( - openads::engine::strip_alias_qualifiers(dk->expression())); - bool numeric = false; - if (fidx >= 0) { - auto ft = child->field_descriptor( - static_cast(fidx)).type; - numeric = (ft == openads::drivers::DbfFieldType::Numeric || - ft == openads::drivers::DbfFieldType::Float); - } - const auto enc = dk->key_encoding(); - double dv = 0; - const bool want_numeric = - enc == openads::drivers::KeyEncoding::FoxNumeric || - enc == openads::drivers::KeyEncoding::NtxNumeric || numeric; - if (want_numeric && - openads::engine::evaluate_index_expr_number(*parent, expr, dv)) { - if (enc == openads::drivers::KeyEncoding::FoxNumeric) - return openads::engine::fox_numeric_key(dv); - if (enc == openads::drivers::KeyEncoding::NtxNumeric) - return openads::engine::ntx_numeric_key( - dv, dk->key_length(), dk->key_decimals()); - // ASCII-stored numeric key (DBF text): right-align to the field - // width, pad to the index key length with spaces. - std::uint16_t klen = dk->key_length(); - std::uint16_t w = klen, dec = 0; - if (fidx >= 0) { - const auto& fd = child->field_descriptor( - static_cast(fidx)); - if (fd.length > 0) w = static_cast(fd.length); - dec = static_cast(fd.decimals); - } - char buf[264]; - int n = (dec > 0) - ? std::snprintf(buf, sizeof(buf), "%*.*f", - static_cast(w), static_cast(dec), dv) - : std::snprintf(buf, sizeof(buf), "%*.0f", - static_cast(w), dv); - std::size_t take = (n < 0) - ? 0u - : std::min(static_cast(n), - sizeof(buf) - 1); - std::string key(buf, take); - if (key.size() < klen) key.append(klen - key.size(), ' '); - return key; - } - // Character key (or a non-numeric parent expression): evaluate the - // expression against the parent and pad to the child key length. - auto k = openads::engine::evaluate_index_expr( - *parent, expr, dk->key_length()); - return k ? k.value() : std::string(); -} - -void seek_child_relation(Table* parent, Table* child, const std::string& expr, - bool scoped) { - if (parent == nullptr || child == nullptr) return; - if (parent->eof() || !parent->positioned()) { - if (scoped) (void)child->clear_scopes(); - relation_child_to_eof(child); - return; - } - openads::engine::Order* ord = child->order(); - openads::drivers::IIndex* dk = (ord != nullptr) ? ord->index() : nullptr; - if (dk == nullptr) { - // No controlling order: the expression yields a record number. - // A scope needs an order, so a scoped relation degrades to a plain - // record-number move here. - double dv = 0; - if (openads::engine::evaluate_index_expr_number(*parent, expr, dv)) { - auto rn = static_cast(dv); - if (rn >= 1 && rn <= child->record_count()) - (void)child->goto_record(rn); - else - relation_child_to_eof(child); - } - return; - } - - std::string key = relation_child_key(parent, child, expr, dk); - - if (scoped) { - // Constrain the child to the matching key group: top == bottom == - // key, then land on the first record in scope. - (void)child->clear_scopes(); - (void)child->set_scope(true, key); - (void)child->set_scope(false, key); - auto gt = child->goto_top(); - child->set_last_seek_found(static_cast(gt) && !child->eof()); - return; - } - - auto r = child->seek_key(key, /*soft=*/true); - if (r) { - child->set_last_seek_found(r.value()); - if (!r.value()) relation_child_to_eof(child); - } -} - -void apply_relations_for_handle(ADSHANDLE hParent); - -void apply_sql_parent_relations(ADSHANDLE hParent) { - auto& tbl = relation_map(); - auto it = tbl.find(hParent); - if (it == tbl.end()) return; - for (auto& rel : it->second) { - std::string key; - if (!sql_parent_key(hParent, rel.expr, key)) { - seek_sql_child(rel.child, "", rel.scoped); - } else { - seek_sql_child(rel.child, key, rel.scoped); - } - apply_relations_for_handle(rel.child); - } -} - -// Re-seek every child related to `hParent`, then cascade into each child's -// own relations so a multi-level chain (A→B→C) refreshes end to end. -void apply_relations_for_handle(ADSHANDLE hParent) { - auto& tbl = relation_map(); - auto it = tbl.find(hParent); - if (it == tbl.end()) return; - static thread_local std::unordered_set active; - if (!active.insert(hParent).second) return; - if (is_sql_table_handle(hParent)) { - apply_sql_parent_relations(hParent); - active.erase(hParent); - return; - } - if (auto* rtp = get_remote_table(hParent)) { - for (auto& rel : it->second) { - if (auto* rtc = get_remote_table(rel.child)) { - seek_remote_child_relation(rtp, rtc, rel.expr, rel.scoped); - apply_relations_for_handle(rel.child); - } - } - active.erase(hParent); - return; - } - Table* parent = get_table(hParent); - if (parent == nullptr) { - active.erase(hParent); - return; - } - for (auto& rel : it->second) { - if (Table* child = get_table(rel.child)) { - seek_child_relation(parent, child, rel.expr, rel.scoped); - apply_relations_for_handle(rel.child); - } else if (auto* rtc = get_remote_table(rel.child)) { - seek_remote_child_relation(parent, rtc, rel.expr, rel.scoped); - apply_relations_for_handle(rel.child); - } else if (is_sql_table_handle(rel.child)) { - std::string key; - if (sql_parent_key(hParent, rel.expr, key)) { - seek_sql_child(rel.child, key, rel.scoped); - } else { - seek_sql_child(rel.child, "", rel.scoped); - } - apply_relations_for_handle(rel.child); - } - } - active.erase(hParent); -} - -void apply_relations_for_table(Table* parent) { - if (parent == nullptr) return; - auto& s = state(); - std::lock_guard lk(s.mu); - ADSHANDLE h = 0; - s.registry.for_each_handle([&](Handle handle, HandleKind k, void* p) { - if (!h && k == HandleKind::Table && - static_cast(p) == parent) { - h = to_ads_handle(handle); - } - }); - if (h) apply_relations_for_handle(h); -} - -// Drop any relation state touching a closing table handle. -void forget_relations(ADSHANDLE h) { - sql_active_index_handle().erase(h); - auto& tbl = relation_map(); - if (auto it = tbl.find(h); it != tbl.end()) { - for (auto& rel : it->second) { - if (!rel.scoped) continue; - if (Table* child = get_table(rel.child)) { - (void)child->clear_scopes(); - } else if (auto* rtc = get_remote_table(rel.child); - rtc != nullptr && rtc->active_index_id != 0 && - rtc->conn != nullptr) { - (void)rtc->conn->clear_scope(rtc->active_index_id, ADS_TOP); - (void)rtc->conn->clear_scope(rtc->active_index_id, ADS_BOTTOM); - } else if (is_sql_table_handle(rel.child)) { - sql_clear_relation_scope(rel.child); - } - } - tbl.erase(it); - } - for (auto& [parent, kids] : tbl) { - for (auto kid = kids.begin(); kid != kids.end();) { - kid = (kid->child == h) ? kids.erase(kid) : kid + 1; - } - } -} - -// Called from AdsWriteRecord for non-append writes (i.e. plain UPDATE). -// For every RI rule where this table is the parent, compares the new PK -// value (in the dirty buffer) against the old PK value snapshotted at -// navigation time. If they differ, enforces update_opt on the child table. -openads::util::Result ri_enforce_update(Connection* conn, Table& parent) { - if (in_ri_check()) return {}; - auto* dd = conn->dd(); - if (!dd || dd->ri().empty()) return {}; - std::string parent_alias = ri_alias_for_path(conn, parent.path()); - if (parent_alias.empty()) return {}; - - for (const auto* rulep : dd->ri_by_parent_table(parent_alias)) { - const auto& rule = *rulep; - const std::string& rname = rule.name; - - unsigned upd_opt = ADS_DD_RI_RESTRICT; - if (!rule.update_opt.empty()) { - try { upd_opt = static_cast( - std::stoul(rule.update_opt)); } catch (...) {} - } - if (upd_opt == 0) continue; - - // New PK value is in the dirty buffer. - std::string new_pk = ri_trim(ri_read_field(parent, rule.parent_tag)); - - // Old PK value was snapshotted onto the parent Table at nav time. - auto& snap = parent.ri_snapshot(); - auto fit = snap.find(rule.parent_tag); - if (fit == snap.end()) continue; - std::string old_pk = fit->second; - - if (old_pk == new_pk) continue; // no PK change for this rule - if (old_pk.empty()) continue; // was blank (NULL) -- skip - - bool need_write = (upd_opt == ADS_DD_RI_CASCADE || - upd_opt == ADS_DD_RI_SETNULL || - upd_opt == ADS_DD_RI_SETDEFAULT); - // Prefer the child instance the application already has open on - // this connection -- cascading into a *second* open of the same - // file races the OS file cache and share-mode locks, which - // intermittently dropped the cascade/restrict. Only open (and - // later close) a fresh instance when the child isn't already open. - Table* child = conn->find_open_table(rule.child); - bool opened_here = false; - Handle ch_handle = 0; - if (!child) { - auto ch = conn->open_table(rule.child, - openads::engine::TableType::Cdx, - need_write ? openads::engine::OpenMode::Shared - : openads::engine::OpenMode::Read); - if (!ch) continue; - child = conn->lookup_table(ch.value()); - if (!child) { conn->close_table(ch.value()); continue; } - opened_here = true; - ch_handle = ch.value(); - } - - if (upd_opt == ADS_DD_RI_RESTRICT) { - bool any = ri_scan(*child, rule.parent_tag, old_pk, nullptr); - if (opened_here) conn->close_table(ch_handle); - if (any) { - // Restore parent's old PK in the dirty buffer (and any prior - // immediate write). set_field encodes into the buffer; the - // failed WriteRecord path never commits, so disk stays old. - auto rfi = parent.field_index(rule.parent_tag); - if (rfi >= 0) { - auto rfi16 = static_cast(rfi); - std::uint32_t rflen = parent.field_descriptor(rfi16).length; - std::string padded = old_pk; - padded.resize(rflen, ' '); - (void)parent.set_field(rfi16, padded); - } - return openads::util::Error{ - openads::AE_RI_VIOLATION, 0, - "RI violation: child rows reference old PK '" + old_pk + - "' in '" + rule.child + "'", - rname}; - } - } else { - std::vector matches; - ri_scan(*child, rule.parent_tag, old_pk, &matches); - in_ri_check() = true; - // Lock the child table so writeback_record_() (the GoHot gate) - // permits the cascade/SETNULL writes. Exclusive is safe here - // because the RI cascade already serialises through the parent's - // write path; no other writer can be concurrently accessing the - // same child table. - if (need_write) { - (void)child->lock_table_excl(); - } - auto fi = child->field_index(rule.parent_tag); - if (fi >= 0) { - auto fi16 = static_cast(fi); - std::uint32_t flen = child->field_descriptor(fi16).length; - for (std::uint32_t rec : matches) { - if (auto gr = child->goto_record(rec); !gr) continue; - if (upd_opt == ADS_DD_RI_CASCADE) { - std::string padded = new_pk; - padded.resize(flen, ' '); - (void)child->set_field(fi16, padded); - } else { - // SETNULL / SETDEFAULT: blank the FK field. - (void)child->set_field(fi16, std::string(flen, ' ')); - } - } - } - in_ri_check() = false; - if (!matches.empty()) (void)child->flush(); - if (opened_here) conn->close_table(ch_handle); - } - } - return {}; -} - -// Returns effective permission level (0-4) for the authenticated user on a -// DD table alias. Returns 4 (full) when no ACL or no DD is present. -// Legacy -- kept for callers that only need a coarse level. -[[maybe_unused]] int table_perm_level(Connection* conn, const std::string& alias) { - if (!conn || !conn->has_dd()) return 4; - auto* dd = conn->dd(); - if (!dd->has_table_acl(alias)) return 4; - return dd->get_effective_permission(conn->username(), alias); -} - -// Returns per-operation effective permissions for the connected user on object. -// If no DD / no ACL defined → all ops open. -openads::engine::DataDict::EffectiveOps -eff_ops(Connection* conn, const std::string& object_name) { - openads::engine::DataDict::EffectiveOps full; - full.open = full.select_ = full.update_ = - full.insert_ = full.delete_ = full.execute_ = true; - if (!conn || !conn->has_dd()) return full; - auto* dd = conn->dd(); - if (!dd->has_any_acl()) return full; - return dd->get_effective_ops(conn->username(), object_name); -} - -bool dd_can_view_object_metadata(Connection* conn, const std::string& object_name) { - if (!conn || !conn->has_dd() || conn->username().empty()) return true; - auto ops = eff_ops(conn, object_name); - // RCB 2026-06-28: DD metadata is not table data, but exposing names, - // columns, indexes, triggers, or properties still reveals protected objects. - // A user with zero effective bits on a protected object cannot view that - // object's metadata; any inherited/direct operation keeps legacy visibility. - return ops.open || ops.select_ || ops.update_ || - ops.insert_ || ops.delete_ || ops.execute_; -} - -// Resolve an AdsOpenTable name (alias, bare filename, or path) to a DD alias. -std::string name_to_alias(const openads::engine::DataDict* dd, - const std::string& name) { - if (!dd) return {}; - if (dd->has_alias(name)) return name; - namespace fs = std::filesystem; - std::string stem = fs::path(name).stem().string(); - if (dd->has_alias(stem)) return stem; - return {}; -} - -} // namespace - -// RCB 2026-05-22 17:03 -- set_stmt_param is defined later in the file alongside -// SqlStatement and stmt_map. AdsSetString / AdsSetDouble / AdsSetLogical all -// call it before that definition is reached, so a forward declaration is needed -// here to satisfy the compiler. It must be inside a namespace{} block to match -// the anonymous-namespace linkage of the definition; a file-scope static and an -// anonymous-namespace function are distinct symbols as far as MSVC is concerned. -namespace { -bool set_stmt_param(ADSHANDLE h, const char* pname, std::string literal); -} // namespace - -// M9.16: chunked AdsSetBinary keeps a per-(table, field) accumulator; -// table teardown drains it. Forward-declared here so the close / -// 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 set_connection_show_deleted(ADSHANDLE hConnect, bool visible) { - if (openads::session::Connection* c = lookup_connection(hConnect)) { - c->set_show_deleted(visible); - } -} - -// Server --legacy-paths: the network Session's lazy ABI connection must -// resolve client-absolute paths (e.g. remote AdsCreateTable of -// "E:\CLIENT\F.DBF") exactly like the session's engine Connection -- -// otherwise a POSIX server creates a literal "E:\..." file under the -// data root and the follow-up open fails (found by live verification -// against a Linux server, Pritpal Bedi's ERP scenario). -void set_connection_legacy_paths(ADSHANDLE hConnect, bool on) { - if (openads::session::Connection* c = lookup_connection(hConnect)) { - c->set_legacy_paths(on); - } -} - -void set_connection_remote_server(ADSHANDLE hConnect, bool on) { - if (openads::session::Connection* c = lookup_connection(hConnect)) { - c->set_remote_server(on); - } -} - -// Single-attempt record lock for the wire server. AdsLockRecord retries -// via the process-global policy (lock_with_retry), and doing that inside -// a session handler blocks the connection for ~1s under contention — -// starving the peer op that would release the record (client threads on -// a shared connection; Pritpal Bedi: "dbUnlock() in threads fail -// somehow"). The client retries instead, one wire request per attempt. -UNSIGNED32 try_lock_record_once(ADSHANDLE hTable, UNSIGNED32 ulRecord) { - Table* t = get_table(hTable); - if (!t) return openads::AE_INTERNAL_ERROR; - // ulRecord == 0 → the current record (ACE convention; see - // AdsLockRecord for the FILE_BASE byte-range rationale). - std::uint32_t rec = (ulRecord == 0) ? t->recno() : ulRecord; - auto r = t->try_lock_record_excl(rec); - if (!r) return static_cast(r.error().code); - openads::mgmt::LockRegistry::instance().add_record_lock(t, t->path(), rec); - return 0; -} - -// RAII-style toggle for the raw field-read flag (g_field_read_raw, anon -// namespace below). The wire session's row packer uses this so date / -// timestamp cells travel as raw storage text ("YYYYMMDD") independent of -// the process-wide date display format — the client does its own display -// formatting, and server-side AdsGetDateFormat must keep returning the -// connection's format (not the old "YYYYMMDD" polluter, removed 2026-09). -void field_read_raw_begin() { g_field_read_raw = true; } -void field_read_raw_end() { g_field_read_raw = false; } - -// Server: link the per-session ABI twin's RESOLVED-audit dedup set to -// the engine connection's, so a single client open logs one RESOLVED -// line per physical file even though the twin re-opens the table for -// index/SQL work (Pritpal Bedi, Aug 2026: .dbf logged twice per open). -void share_connection_resolve_log(ADSHANDLE hConnect, - openads::session::Connection* src) { - if (openads::session::Connection* c = lookup_connection(hConnect)) { - if (src) c->share_logged_resolves_from(*src); - } -} - -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_MSSQL) - register_backend_table_ops(openads::session::HandleKind::MssqlTable, - mssql_table_ops()); -#endif -#if defined(OPENADS_WITH_FIREBIRD) - register_backend_table_ops(openads::session::HandleKind::FirebirdTable, - firebird_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 - -namespace { - -std::unordered_map& sql_uri_usernames() { - static std::unordered_map m; - return m; -} - -const std::string& sql_uri_username(ADSHANDLE hConnect) { - static const std::string kEmpty; - auto it = sql_uri_usernames().find(hConnect); - return it == sql_uri_usernames().end() ? kEmpty : it->second; -} - -void sql_uri_remember_user(ADSHANDLE hConnect, UNSIGNED8* pucUser) { - if (pucUser == nullptr) return; - const std::string user = openads::abi::to_internal(pucUser, 0); - if (!user.empty()) { - sql_uri_usernames()[hConnect] = user; - } -} - -void sql_uri_forget_user(ADSHANDLE hConnect) { - sql_uri_usernames().erase(hConnect); -} - -#if defined(OPENADS_WITH_ODBC) -std::unordered_map& -odbc_conn_dialects() { - static std::unordered_map m; - return m; -} - -void sql_uri_remember_odbc_dialect( - ADSHANDLE hConnect, - openads::sql_backend::SqlDdlDialect dialect) { - odbc_conn_dialects()[hConnect] = dialect; -} - -void sql_uri_forget_odbc_dialect(ADSHANDLE hConnect) { - odbc_conn_dialects().erase(hConnect); -} - -openads::sql_backend::SqlDdlDialect odbc_dialect_for(ADSHANDLE hConnect) { - auto it = odbc_conn_dialects().find(hConnect); - if (it != odbc_conn_dialects().end()) return it->second; - return openads::sql_backend::SqlDdlDialect::Postgres; -} -#endif - -template -void sql_uri_connect_register_user(ADSHANDLE hConnect, UNSIGNED8* pucUser, - ConnT* conn, - openads::sql_backend::SqlDdlDialect dialect) { - sql_uri_remember_user(hConnect, pucUser); - if (pucUser == nullptr || conn == nullptr) return; - const std::string user = openads::abi::to_internal(pucUser, 0); - if (user.empty()) return; - openads::sql_backend::SqlExecFn exec = - [conn](const std::string& sql) { return conn->exec_sql(sql); }; - openads::sql_backend::sql_acl_register_connect_user(dialect, exec, user); -} - -#if defined(OPENADS_WITH_SQLITE) -openads::util::Result> sqlite_acl_query( - openads::sql_backend::SqliteConnection* sc, const std::string& sql) { - auto r = sc->run_sql(sql); - if (!r) return r.error(); - if (!r.value() || r.value()->result_rows.empty()) { - return std::optional{}; - } - return std::optional{r.value()->result_rows[0][0]}; -} -#endif - -#if defined(OPENADS_WITH_POSTGRESQL) -openads::util::Result> postgres_acl_query( - openads::sql_backend::PostgresConnection* pc, const std::string& sql) { - auto r = pc->run_sql(sql); - if (!r) return r.error(); - if (!r.value() || r.value()->result_rows.empty()) { - return std::optional{}; - } - return std::optional{r.value()->result_rows[0][0]}; -} -#endif - -#if defined(OPENADS_WITH_MARIADB) -openads::util::Result> maria_acl_query( - openads::sql_backend::MariaConnection* mc, const std::string& sql) { - auto r = mc->run_sql(sql); - if (!r) return r.error(); - if (!r.value() || r.value()->result_rows.empty()) { - return std::optional{}; - } - return std::optional{r.value()->result_rows[0][0]}; -} -#endif - -#if defined(OPENADS_WITH_MSSQL) -openads::util::Result> mssql_acl_query( - openads::sql_backend::MssqlConnection* mc, const std::string& sql) { - auto qr = mc->query(sql); - if (!qr) return qr.error(); - const auto& res = qr.value(); - if (!res.ok || res.rows.empty() || res.rows[0].empty() || - res.rows[0][0].is_null) { - return std::optional{}; - } - return std::optional{res.rows[0][0].value}; -} -#endif - -#if defined(OPENADS_WITH_FIREBIRD) -openads::util::Result> firebird_acl_query( - openads::sql_backend::FirebirdConnection* fc, const std::string& sql) { - auto r = fc->run_sql(sql); - if (!r) return r.error(); - if (!r.value() || r.value()->result_rows.empty()) { - return std::optional{}; - } - return std::optional{r.value()->result_rows[0][0]}; -} -#endif - -#if defined(OPENADS_WITH_ODBC) -openads::util::Result> odbc_acl_query( - openads::sql_backend::OdbcConnection* oc, const std::string& sql) { - return oc->query_scalar(sql); -} -#endif - -bool sql_uri_table_denied( - ADSHANDLE hConnect, - UNSIGNED16 usCheckRights, - UNSIGNED16 usMode, - const std::string& object_name, - openads::sql_backend::SqlDdlDialect dialect, - const openads::sql_backend::SqlQueryFn& query) { - if (usCheckRights == 0) return false; - const auto ops = openads::sql_backend::sql_acl_effective_ops( - dialect, query, sql_uri_username(hConnect), object_name); - if (usMode == ADS_READONLY) return !ops.select_; - return !ops.update_ && !ops.insert_; -} - -UNSIGNED32 sql_uri_check_sql_rights( - const std::string& sql, - UNSIGNED16 check_rights, - const std::string& username, - openads::sql_backend::SqlDdlDialect dialect, - const openads::sql_backend::SqlQueryFn& query) { - if (check_rights == 0) return ok(); - std::string obj_name; - enum class SqlOp { None, Select, Insert, Update, Delete } op = SqlOp::None; - if (openads::sql::sql_is_insert(sql)) { - if (auto p = openads::sql::parse_insert(sql)) { - obj_name = p.value().table; - op = SqlOp::Insert; - } - } else if (openads::sql::sql_is_update(sql)) { - if (auto p = openads::sql::parse_update(sql)) { - obj_name = p.value().table; - op = SqlOp::Update; - } - } else if (openads::sql::sql_is_delete(sql)) { - if (auto p = openads::sql::parse_delete(sql)) { - obj_name = p.value().table; - op = SqlOp::Delete; - } - } else if (openads::sql::sql_is_merge(sql)) { - // MERGE mutates like UPDATE (and may INSERT); gate on update - // rights. - if (auto p = openads::sql::parse_merge(sql)) { - obj_name = p.value().table; - op = SqlOp::Update; - } - } else if (openads::sql::sql_is_select(sql)) { - if (auto p = openads::sql::parse_select(sql)) { - obj_name = p.value().table; - op = SqlOp::Select; - } - } - if (obj_name.empty() || op == SqlOp::None) return ok(); - std::string px = obj_name.size() >= 7 ? obj_name.substr(0, 7) : obj_name; - for (auto& ch : px) { - ch = static_cast(std::tolower(static_cast(ch))); - } - if (px == "system.") return ok(); - const auto ops = openads::sql_backend::sql_acl_effective_ops( - dialect, query, username, obj_name); - bool denied = false; - switch (op) { - case SqlOp::Select: denied = !ops.select_; break; - case SqlOp::Insert: denied = !ops.insert_; break; - case SqlOp::Update: denied = !ops.update_; break; - case SqlOp::Delete: denied = !ops.delete_; break; - default: break; - } - if (denied) return fail(openads::AE_ACCESS_DENIED, obj_name.c_str()); - return ok(); -} - -enum class SqlUriDmlOp { Insert, Update, Delete }; - -UNSIGNED32 sql_uri_dml_rights( - std::uint32_t connect_handle, - std::uint16_t check_rights, - const std::string& object_name, - SqlUriDmlOp op, - openads::sql_backend::SqlDdlDialect dialect, - const openads::sql_backend::SqlQueryFn& query) { - if (check_rights == 0) return ok(); - const auto ops = openads::sql_backend::sql_acl_effective_ops( - dialect, query, - sql_uri_username(static_cast(connect_handle)), - object_name); - bool denied = false; - switch (op) { - case SqlUriDmlOp::Insert: denied = !ops.insert_; break; - case SqlUriDmlOp::Update: denied = !ops.update_; break; - case SqlUriDmlOp::Delete: denied = !ops.delete_; break; - } - if (denied) return fail(openads::AE_ACCESS_DENIED, object_name.c_str()); - return ok(); -} - -template -UNSIGNED32 sql_uri_check_dml( - std::uint32_t connect_handle, - std::uint16_t check_rights, - const std::string& table_name, - SqlUriDmlOp op, - openads::sql_backend::SqlDdlDialect dialect, - ConnPtr* conn, - QueryFn query_fn) { - if (check_rights == 0 || conn == nullptr) return ok(); - const openads::sql_backend::SqlQueryFn qfn = - [conn, query_fn](const std::string& sql) { - return query_fn(conn, sql); - }; - return sql_uri_dml_rights( - connect_handle, check_rights, table_name, op, dialect, qfn); -} - -template -void sql_uri_bind_table_acl(TableT* tbl, ADSHANDLE hConnect, - UNSIGNED16 usCheckRights) { - tbl->connect_handle = static_cast(hConnect); - tbl->check_rights = usCheckRights; -} - -template -void sql_uri_mark_index_full(TableT* tbl) { - if (tbl != nullptr) { - tbl->aof_opt_level = static_cast(ADS_OPTIMIZED_FULL); - } -} - -bool sql_uri_system_suffix(const std::string& name, std::string& suffix) { - if (name.size() <= 7) return false; - std::string px = name.substr(0, 7); - for (auto& ch : px) { - ch = static_cast(std::tolower(static_cast(ch))); - } - if (px != "system.") return false; - suffix = name.substr(7); - for (auto& ch : suffix) { - ch = static_cast(std::tolower(static_cast(ch))); - } - return true; -} - -std::string trim_ascii(std::string s) { - auto is_ws = [](unsigned char ch) { - return std::isspace(ch) != 0; - }; - while (!s.empty() && is_ws(static_cast(s.front()))) { - s.erase(s.begin()); - } - while (!s.empty() && is_ws(static_cast(s.back()))) { - s.pop_back(); - } - return s; -} - -std::string connect101_key(std::string s) { - std::string out; - for (char raw_ch : s) { - auto ch = static_cast(raw_ch); - if (!std::isspace(ch)) { - out.push_back(static_cast(std::tolower(ch))); - } - } - return out; -} - -std::unordered_map -parse_connect101_options(const std::string& text) { - std::unordered_map opts; - std::size_t start = 0; - char quote = 0; - auto add_pair = [&](std::size_t end) { - std::string part = text.substr(start, end - start); - std::size_t eq = std::string::npos; - char q = 0; - for (std::size_t i = 0; i < part.size(); ++i) { - char ch = part[i]; - if ((ch == '\'' || ch == '"') && (q == 0 || q == ch)) { - q = q == 0 ? ch : 0; - } else if (ch == '=' && q == 0) { - eq = i; - break; - } - } - if (eq == std::string::npos) return; - std::string key = connect101_key(part.substr(0, eq)); - std::string val = trim_ascii(part.substr(eq + 1)); - if (val.size() >= 2) { - char first = val.front(); - if ((first == '\'' || first == '"') && val.back() == first) { - val = val.substr(1, val.size() - 2); - } - } - if (!key.empty()) opts[key] = val; - }; - - for (std::size_t i = 0; i < text.size(); ++i) { - char ch = text[i]; - if ((ch == '\'' || ch == '"') && (quote == 0 || quote == ch)) { - quote = quote == 0 ? ch : 0; - } else if (ch == ';' && quote == 0) { - add_pair(i); - start = i + 1; - } - } - add_pair(text.size()); - return opts; -} - -bool connect101_truthy(const std::string& value) { - std::string v = connect101_key(value); - return v == "true" || v == "yes" || v == "1" || v == "on"; -} - -std::uint16_t connect101_server_type(const std::string& value) { - std::string v = connect101_key(value); - if (v.empty()) return 0; - char* end = nullptr; - long n = std::strtol(v.c_str(), &end, 10); - if (end != nullptr && *end == '\0') { - if ((n & ADS_REMOTE_SERVER) != 0 || (n & 4) != 0) { - return ADS_REMOTE_SERVER; - } - if ((n & ADS_LOCAL_SERVER) != 0) return ADS_LOCAL_SERVER; - } - if (v.find("remote") != std::string::npos || - v.find("internet") != std::string::npos || - v.find("ais") != std::string::npos) { - return ADS_REMOTE_SERVER; - } - if (v.find("local") != std::string::npos) return ADS_LOCAL_SERVER; - return 0; -} - -struct Connect101OpenOptions { - UNSIGNED16 table_type = ADS_DEFAULT; - UNSIGNED16 char_type = ADS_DEFAULT; - UNSIGNED16 lock_type = ADS_DEFAULT; - UNSIGNED16 check_rights = ADS_DEFAULT; - UNSIGNED16 mode = ADS_DEFAULT; -}; - -std::unordered_map& -connect101_open_options() { - static std::unordered_map opts; - return opts; -} - -std::unordered_map>& -connect101_option_tables() { - static std::unordered_map> tables; - return tables; -} - -// Cursor handle -> on-disk stem (no extension) of the temp table a -// materialised SELECT built for it. AdsCloseTable deletes the stem's files, -// so a data directory doesn't accumulate one temp per query. -std::unordered_map& -materialised_cursor_temps() { - static std::unordered_map temps; - return temps; -} - -// Delete the files of a materialised cursor's temp table: the table itself plus -// any memo / index companion an application created on it (the ERP runs -// INDEX ON over the result, producing .cdx or .adi). -// -// The materialising path builds ADT temps (see the ADS_ADT call in -// exec_sql_direct_impl -- DBF would truncate column names to 10 chars), so .adt -// and its .adm/.adi companions must be listed here. The DBF-era extensions stay -// so temps written by an older build are still cleaned up. If a new -// materialising path introduces another on-disk format, add its extensions here -// too -- anything missing leaks one file per query into the data directory. -void remove_temp_table_files(const std::string& stem) { - std::error_code ec; - for (const char* ext : {".adt", ".adm", ".adi", - ".dbf", ".cdx", ".fpt", ".dbt", ".ntx"}) { - std::filesystem::remove(std::filesystem::path(stem + ext), ec); - } -} - -void drop_materialised_cursor_temp(ADSHANDLE h) { - auto& m = materialised_cursor_temps(); - auto it = m.find(h); - if (it == m.end()) return; - const std::string stem = it->second; - m.erase(it); - remove_temp_table_files(stem); -} - -// ─── Shared materialiser for join / union / aggregate temp cursors ───────── -// -// >>> Before changing ANY of this, read docs/materialised-cursor-temps.md. <<< -// >>> Do NOT switch these temps back to DBF, and do not "simplify" the <<< -// >>> AsciiNumeric mapping to plain Numeric. OpenADS is not a DBF-only <<< -// >>> engine: result-column names longer than 10 characters (SAP's own <<< -// >>> catalogs use them), AS aliases, and merged R_ spellings all <<< -// >>> require the ADT container, and N(x,y) scale survives ONLY through <<< -// >>> ADT type 2. Both halves have been regressed before (#136, #146); <<< -// >>> the doc records the failures each regression caused. <<< -// -// Every SELECT that cannot be answered by a live cursor materialises its -// result into a temp table. Seven paths (2-table join, N-way join, UNION, -// scalar / grouped / join / join+GROUP-BY aggregates) used to hand-assemble -// a raw DBF here, which silently truncated every result-column name -// (constraint 2 in the doc) -- including `AS` aliases and the merged `R_` -// spellings. They now share this one materialiser, which builds an ADT temp: -// -// - The SKELETON (400-byte header + 200-byte descriptors) comes from -// AdsCreateTable's own ADS_ADT path, so there is exactly one place in the -// tree that knows the ADT container layout. This helper only appends -// records and patches the record count, reading hdr_len / rec_len back -// from the file it was just handed. -// - Cells arrive PRE-FORMATTED from the callers (fixed-width text, the same -// bytes the DBF temps stored), and are written verbatim: Character and -// AsciiNumeric cells are raw ASCII in both containers, so read-back is -// byte-identical to the DBF-era temps. Only Date cells change shape -- -// 8-char "YYYYMMDD" text is encoded to the 4-byte JDN through the same -// encode_field_string the write path uses everywhere else (blank-safe: -// 8 blanks store JDN 0), and decodes back to "YYYYMMDD". -// - Numeric columns are declared AsciiNumeric (ADT type 2), NEVER the -// letter 'N': the letter maps to binary INTEGER / DOUBLE via -// adt_spec_for, which re-renders values and drops the declared scale -// (constraint 3 -- the #146 trap). -// - Records are streamed in ONE file append and the count patched once, -// instead of per-record append_record_raw (lock + header rewrite per -// row), because a join over a 600K-row table lands here. -// -// The temp is registered in materialised_cursor_temps(), so closing the -// cursor deletes it -- the DBF-era paths never registered theirs and leaked -// one file per query into the data directory. -struct TempColSpec { - std::string name; // FULL-length result-column name (the point of ADT) - char type; // DBF letter OR raw ADT type code from the source - std::uint16_t len; // width of the caller's fixed-width text cell - std::uint8_t dec; -}; - -enum class TempCellKind : std::uint8_t { Text, Num, Date, Logic }; - -// Classify a source type for the temp's declared column type. Letters and -// raw ADT codes are disjoint (letters are all >= 0x41, ADT codes top out at -// 22), same reasoning as type_name() in the _srt_ path. Anything unknown -// degrades to Character, which stores the caller's text cell verbatim -- -// the same net behaviour the DBF temps had for exotic types. -TempCellKind temp_cell_kind(char t) { - switch (t) { - case 'N': case 'F': case 'I': case 'B': case 'Y': - case 'S': case 'A': case '$': case '#': - return TempCellKind::Num; - case 'D': return TempCellKind::Date; - case 'L': return TempCellKind::Logic; - case 'C': case 'W': case 'V': case 'M': case 'Q': - case 'T': case 'Z': case 'P': - return TempCellKind::Text; - default: break; - } - switch (static_cast(t)) { - case 1: return TempCellKind::Logic; - case 2: case 10: case 11: case 12: case 15: case 18: - return TempCellKind::Num; - case 3: return TempCellKind::Date; - default: break; - } - return TempCellKind::Text; -} - -// An ADT temp materialised and OPENED on `c`, but not yet registered as a -// user-visible cursor. The join paths need this split: the merged temp is -// an intermediate the outer WHERE / ORDER BY / aggregate stages keep -// working on, and only the final survivor becomes the caller's cursor. -struct MaterialisedTemp { - Handle th = 0; // close with c->close_table - openads::engine::Table* tbl = nullptr; - std::string stem; // for remove_temp_table_files -}; - -// `rows` is the concatenation of fixed-width row images (NO deletion-flag -// byte -- pure cells, `row_stride` bytes each, in `cols` order). Creates, -// fills and opens the temp; does NOT register a cursor handle. -UNSIGNED32 materialise_temp_adt_open(Connection* c, - const char* prefix, - const std::vector& cols, - const std::vector& rows, - std::size_t row_stride, - MaterialisedTemp* out) { - namespace fs = std::filesystem; - if (cols.empty() || row_stride == 0 || rows.size() % row_stride != 0) { - return fail(openads::AE_INTERNAL_ERROR, "temp materialise: bad shape"); - } - // The skeleton goes through AdsCreateTable, which takes a connection - // HANDLE; the callers hold the Connection*. Recover the handle so the - // create resolves against the same data directory the reopen uses. - ADSHANDLE conn_h = 0; - state().registry.for_each_handle([&](Handle h, HandleKind k, void* p) { - if (k != HandleKind::Connection) return; - if (static_cast(p) == c) - conn_h = to_ads_handle(h); - }); - if (conn_h == 0) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - // Column plan: defs string for the skeleton + src/dst cell geometry. - std::string defs; - std::size_t src_sum = 0; - std::vector kinds; - std::vector dst_lens; - kinds.reserve(cols.size()); - dst_lens.reserve(cols.size()); - for (const auto& col : cols) { - const TempCellKind k = temp_cell_kind(col.type); - const std::uint16_t sl = col.len ? col.len : 1; - std::uint16_t dl = sl; - if (!defs.empty()) defs.push_back(';'); - defs += col.name; - switch (k) { - case TempCellKind::Num: - defs += ",AsciiNumeric," + std::to_string(sl); - if (col.dec > 0) defs += "," + std::to_string(col.dec); - break; - case TempCellKind::Date: - defs += ",Date"; - dl = 4; - break; - case TempCellKind::Logic: - defs += ",Logical"; - dl = 1; - break; - case TempCellKind::Text: - defs += ",Character," + std::to_string(sl); - break; - } - kinds.push_back(k); - dst_lens.push_back(dl); - src_sum += sl; - } - if (src_sum != row_stride) { - return fail(openads::AE_INTERNAL_ERROR, - "temp materialise: cols do not tile the row stride"); - } - - char nb[64]; - std::snprintf(nb, sizeof(nb), "%s%llx", prefix, - static_cast( - openads::platform::monotonic_nanos())); - const std::string tmp_name = nb; - - std::vector name_buf(tmp_name.size() + 1, 0); - std::memcpy(name_buf.data(), tmp_name.data(), tmp_name.size()); - std::vector def_buf(defs.size() + 1, 0); - std::memcpy(def_buf.data(), defs.data(), defs.size()); - ADSHANDLE hNew = 0; - UNSIGNED32 crc = AdsCreateTable(conn_h, name_buf.data(), nullptr, - ADS_ADT, 0, 0, 0, 0, - def_buf.data(), &hNew); - if (crc != openads::AE_SUCCESS) return crc; - AdsCloseTable(hNew); - - // Locate the file the create path actually wrote. This must mirror - // AdsCreateTable's own resolution EXACTLY: the connection resolver may - // hand back a default extension that is not ".adt" (an extensionless - // temp name resolves to ".dbf" -- the ADT container still goes - // there; only the file NAME is legacy). Guessing ".adt" here left - // the skeleton orphaned and the reopen failing. - fs::path adt; - { - auto rt = openads::engine::TableType::Adt; - adt = fs::path(c->resolve_table_file(tmp_name, rt, - /*for_create=*/true)); - if (!adt.has_extension()) adt.replace_extension(".adt"); - } - fs::path stem = adt; - stem.replace_extension(); - { - std::fstream f(adt, std::ios::in | std::ios::out | std::ios::binary); - if (!f) return fail(openads::AE_INTERNAL_ERROR, - "temp materialise: reopen of created ADT failed"); - auto rd32 = [&](std::streamoff off) -> std::uint32_t { - std::uint8_t b[4] = {0, 0, 0, 0}; - f.seekg(off); - f.read(reinterpret_cast(b), 4); - return static_cast(b[0]) | - (static_cast(b[1]) << 8) | - (static_cast(b[2]) << 16) | - (static_cast(b[3]) << 24); - }; - const std::uint32_t hdr_len = rd32(32); - const std::uint32_t rec_len = rd32(36); - // Guard against the mapping here drifting from adt_spec_for: the - // record length the skeleton declares must equal 5-byte prefix + - // the cells this helper is about to write. - std::uint32_t expect = 5; - for (auto dl : dst_lens) expect += dl; - if (rec_len != expect) { - f.close(); - std::error_code ec; - fs::remove(adt, ec); - return fail(openads::AE_INTERNAL_ERROR, - "temp materialise: rec_len mismatch vs skeleton"); - } - - const std::size_t nrows = rows.size() / row_stride; - std::vector obuf; - obuf.reserve(nrows * rec_len); - std::vector rec(rec_len); - for (std::size_t r = 0; r < nrows; ++r) { - std::fill(rec.begin(), rec.end(), 0); - rec[0] = 0x04; // ADT active-record flag (0x05 = deleted) - const std::uint8_t* src = rows.data() + r * row_stride; - std::size_t so = 0; - std::uint16_t dst_off = 5; - for (std::size_t i = 0; i < cols.size(); ++i) { - const std::uint16_t sl = cols[i].len ? cols[i].len : 1; - if (kinds[i] == TempCellKind::Date) { - // Through the production encoder -- same blank-safety - // and JDN math as every other ADT date write. A cell - // that is not 8 digits (blank group, the "0" sentinel - // an empty aggregate renders) stays the zero JDN the - // record was initialised with -- a blank date. - std::string cell( - reinterpret_cast(src + so), sl); - bool date8 = cell.size() >= 8; - for (std::size_t k = 0; date8 && k < 8; ++k) - date8 = std::isdigit( - static_cast(cell[k])) != 0; - if (date8) { - openads::drivers::DbfField df; - df.type = - openads::drivers::DbfFieldType::AdtDate; - df.record_offset = dst_off; - df.length = 4; - (void)openads::drivers::encode_field_string( - df, rec.data(), rec.size(), cell); - } - } else { - std::memcpy(rec.data() + dst_off, src + so, dst_lens[i]); - } - so += sl; - dst_off = static_cast(dst_off + dst_lens[i]); - } - obuf.insert(obuf.end(), rec.begin(), rec.end()); - } - f.seekp(static_cast(hdr_len)); - f.write(reinterpret_cast(obuf.data()), - static_cast(obuf.size())); - // Patch the record count (header bytes 24-27, LE u32). - std::uint8_t cnt[4] = { - static_cast( nrows & 0xFFu), - static_cast((nrows >> 8) & 0xFFu), - static_cast((nrows >> 16) & 0xFFu), - static_cast((nrows >> 24) & 0xFFu)}; - f.seekp(24); - f.write(reinterpret_cast(cnt), 4); - if (!f) return fail(openads::AE_INTERNAL_ERROR, - "temp materialise: short write"); - } - - auto cth = c->open_table(tmp_name, openads::engine::TableType::Adt, - openads::engine::OpenMode::Read); - if (!cth) return fail(cth.error()); - openads::engine::Table* ctbl = c->lookup_table(cth.value()); - if (!ctbl) return fail(openads::AE_INTERNAL_ERROR, - "temp materialise: post-open"); - out->th = cth.value(); - out->tbl = ctbl; - out->stem = stem.string(); - return ok(); -} - -// Convenience wrapper for the paths whose temp IS the final result: -// materialise, open, register the cursor handle, and tie the temp's on-disk -// lifetime to it (cleanup happens in drop_materialised_cursor_temp). -UNSIGNED32 materialise_temp_adt(Connection* c, - const char* prefix, - const std::vector& cols, - const std::vector& rows, - std::size_t row_stride, - ADSHANDLE* phCursor) { - MaterialisedTemp mt; - UNSIGNED32 rc = materialise_temp_adt_open(c, prefix, cols, rows, - row_stride, &mt); - if (rc != openads::AE_SUCCESS) return rc; - ADSHANDLE gh = to_ads_handle( - state().registry.register_object(HandleKind::Table, mt.tbl)); - materialised_cursor_temps()[gh] = mt.stem; - *phCursor = gh; - return ok(); -} - -openads::util::Result
build_connect101_options_table( - const std::unordered_map& options) { - struct Col { - const char* colname; - std::uint16_t length; - }; - const std::vectorcols = { - {"Name", 64}, - {"Value", 1024}, - }; - - std::vector> sorted; - sorted.reserve(options.size()); - for (const auto& kv : options) sorted.emplace_back(kv.first, kv.second); - std::sort(sorted.begin(), sorted.end(), - [](const auto& a, const auto& b) { return a.first < b.first; }); - - std::vector fields; - fields.reserve(cols.size()); - std::uint32_t rlen = 1; - for (const auto& col : cols) { - openads::drivers::DbfField f; - f.name = col.colname; - f.raw_type = 20; - f.type = openads::drivers::DbfFieldType::CiCharacter; - f.length = col.length; - f.decimals = 0; - f.record_offset = static_cast(rlen); - rlen += f.length; - fields.push_back(std::move(f)); - } - - std::vector> records; - records.reserve(sorted.size()); - for (const auto& kv : sorted) { - std::vector rec(rlen, 0x20u); - rec[0] = ' '; - const std::string values[] = {kv.first, kv.second}; - for (std::size_t i = 0; i < cols.size(); ++i) { - const auto& f = fields[i]; - const std::string& val = values[i]; - std::size_t n = std::min(val.size(), f.length); - std::memcpy(rec.data() + f.record_offset, val.data(), n); - } - records.push_back(std::move(rec)); - } - - char name[80]; - std::snprintf(name, sizeof(name), "memory://adsconnect101/%llx", - static_cast( - openads::platform::monotonic_nanos())); - auto drv = std::make_unique( - name, std::move(fields), std::move(records), - static_cast(rlen)); - return Table::from_driver(std::move(drv), name, - openads::engine::TableType::Adt, - openads::engine::OpenMode::Read, - openads::engine::LockingMode::Compatible); -} - -UNSIGNED16 connect101_table_type(const std::string& value) { - std::string v = connect101_key(value); - if (v.rfind("ads_", 0) == 0) v.erase(0, 4); - if (v == "ntx") return ADS_NTX; - if (v == "cdx") return ADS_CDX; - if (v == "adt") return ADS_ADT; - if (v == "vfp") return ADS_VFP; - if (v == "default") return ADS_DEFAULT; - return static_cast(std::strtoul(v.c_str(), nullptr, 10)); -} - -UNSIGNED16 connect101_char_type(const std::string& value) { - std::string v = connect101_key(value); - if (v.rfind("ads_", 0) == 0) v.erase(0, 4); - if (v == "ansi") return ADS_ANSI; - if (v == "oem") return ADS_OEM; - return static_cast(std::strtoul(v.c_str(), nullptr, 10)); -} - -UNSIGNED16 connect101_lock_type(const std::string& value) { - std::string v = connect101_key(value); - if (v.find("proprietary") != std::string::npos) { - return ADS_PROPRIETARY_LOCKING; - } - if (v.find("compatible") != std::string::npos) { - return ADS_COMPATIBLE_LOCKING; - } - return static_cast(std::strtoul(v.c_str(), nullptr, 10)); -} - -UNSIGNED16 connect101_security_mode(const std::string& value) { - std::string v = connect101_key(value); - if (v.find("check") != std::string::npos) return ADS_CHECKRIGHTS; - if (v.find("ignore") != std::string::npos) return ADS_IGNORERIGHTS; - return static_cast(std::strtoul(v.c_str(), nullptr, 10)); -} - -UNSIGNED16 connect101_open_mode( - const std::unordered_map& options) { - auto readonly = options.find("readonly"); - if (readonly != options.end() && connect101_truthy(readonly->second)) { - return ADS_READONLY; - } - auto shared = options.find("shared"); - if (shared != options.end()) { - return connect101_truthy(shared->second) ? ADS_SHARED : ADS_EXCLUSIVE; - } - return ADS_DEFAULT; -} - -UNSIGNED16 infer_table_type_from_name(UNSIGNED8* pucName) { - if (pucName == nullptr) return ADS_CDX; - std::filesystem::path p(openads::abi::to_internal(pucName, 0)); - std::string ext = p.extension().string(); - for (auto& ch : ext) { - ch = static_cast(std::tolower(static_cast(ch))); - } - if (ext == ".adt") return ADS_ADT; - if (ext == ".dbf") return ADS_CDX; - return ADS_CDX; -} - -} // namespace - -extern "C++" { -std::uint16_t& server_type_mask(); -} // extern "C++" - -// --- Remote session pool (WAN thread-lanes) ------------------------------- -// One RemoteConnection serialises all its frames on mu_ (see -// RemoteConnection::request): every thread of an MT app queues behind a -// single TCP session. The pool opens N sessions per logical connect -// (same host/port/dir/creds) and pins each calling thread to a lane, so -// threads proceed in parallel while each table stays on the session that -// opened it (rt->conn never migrates — cursor/order bindings are -// per-session server-side). Single-threaded callers always land on lane -// 0 (the primary): behaviour identical to before. -// Size: OPENADS_POOL_SIZE env / pool_size ini key, default 4, clamp 1..16. -// Lifetime matches the existing remote_conns policy: lane objects are -// never unregistered (disconnect() only closes the socket). -// Lives here (C++ linkage, ahead of first use) because the file's later -// anonymous namespaces nest — a declaration outside and a definition -// inside would be distinct entities. -struct RemoteLanePool { - std::mutex mu; // guards affinity/next only; lanes fixed after grow - std::vector lanes; // [0] = primary - std::map affinity; - std::size_t next = 0; -}; -static std::vector>& remote_lane_pool_store() { - static std::vector> v; - return v; -} -// Raw connection -> pool (entries never removed; written once at connect). -static std::unordered_map& remote_lane_pool_of() { - static std::unordered_map m; - return m; -} -// Internal connection handle -> pool (written once at connect, under s.mu). -static std::unordered_map& -remote_lane_pool_by_handle() { - static std::unordered_map m; - return m; -} -static unsigned remote_pool_size_cfg() { - unsigned n = 4; - try { - std::string v = openads::util::client_setting( - "OPENADS_POOL_SIZE", "pool_size"); - if (!v.empty()) n = static_cast(std::stoul(v)); - } catch (...) {} - if (n < 1) n = 1; - if (n > 16) n = 16; - return n; -} -// Must be called with s.mu held, right after the primary is registered. -static RemoteLanePool* remote_lane_pool_create( - openads::network::RemoteConnection* primary, Handle primary_h) { - auto p = std::make_unique(); - p->lanes.push_back(primary); - RemoteLanePool* raw = p.get(); - remote_lane_pool_store().push_back(std::move(p)); - remote_lane_pool_of()[primary] = raw; - remote_lane_pool_by_handle()[primary_h] = raw; - return raw; -} -static void remote_lane_pool_add_lane(RemoteLanePool* pool, - openads::network::RemoteConnection* lane, Handle lane_h) { - if (pool == nullptr || lane == nullptr) return; - pool->lanes.push_back(lane); - remote_lane_pool_of()[lane] = pool; - remote_lane_pool_by_handle()[lane_h] = pool; -} -// Thread-affine lane for the pool owning h (primary or lane handle). -// First-touch threads deal round-robin; a thread sticks to its lane -// afterwards (park hits, order bindings, no cross-thread cursor sharing). -// Dead lanes are skipped (failover); unpooled handles return directly. -// s.mu must be held. -static openads::network::RemoteConnection* remote_pool_lane_conn(Handle h) { - auto& s = state(); - RemoteLanePool* pool = nullptr; - auto ith = remote_lane_pool_by_handle().find(h); - if (ith != remote_lane_pool_by_handle().end()) pool = ith->second; - auto* direct = s.registry.lookup( - h, HandleKind::RemoteConnection); - if (pool == nullptr) return direct; - std::size_t idx = 0; - { - std::lock_guard lk(pool->mu); - auto tid = std::this_thread::get_id(); - auto ita = pool->affinity.find(tid); - if (ita != pool->affinity.end()) - idx = ita->second % pool->lanes.size(); - else { - idx = pool->next++ % pool->lanes.size(); - pool->affinity[tid] = idx; - } - } - if (idx < pool->lanes.size()) { - if (auto* rc = pool->lanes[idx]; rc != nullptr && rc->valid()) - return rc; - } - for (auto* rc : pool->lanes) { - if (rc != nullptr && rc->valid()) return rc; - } - return direct; -} -// All lanes of rc's pool (or just rc when unpooled). The lanes vector is -// fixed after grow; entries are never removed, so lock-free reads here -// match the existing registry posture (cf. resolve_remote_conn_handle). -static std::vector -remote_pool_lanes_of(openads::network::RemoteConnection* rc) { - if (rc == nullptr) return {}; - auto it = remote_lane_pool_of().find(rc); - if (it == remote_lane_pool_of().end()) return {rc}; - RemoteLanePool* pool = it->second; - if (pool == nullptr) return {rc}; - std::lock_guard lk(pool->mu); - return pool->lanes; -} - -// Lane pinning by (logical connection, path, alias). -// -// The thread-affine lane pool spreads a process's tables over several -// server sessions for parallel wire throughput, but record locks are -// owned per server SESSION. Harbour's zero-space pattern -// (hb_dbRequest/hb_dbDetach - one workarea shared process-wide across -// threads for login semaphores) needs the opposite: a workarea's lock -// identity must survive being driven from any thread, and must survive a -// close + fresh USE of the same alias (Vouch's LogUse). DBFCDX gets this -// for free because the lock list lives in the workarea struct itself. -// -// Pinning gives each (connection, normalized path, alias) one stable -// lane for the connection's lifetime: every USE of that alias+path from -// any thread - including the first USE after a close - binds to the same -// server session, so locks taken through it stay visible and unlockable -// no matter which thread is holding the workarea. Different aliases of -// the same file keep different owners (UsrConsole-style cross-owner lock -// probes still conflict, either on another lane or as another server-side -// Table object on the same lane). Single-threaded callers are unaffected -// (thread affinity already kept them on lane 0). -// -// Pins persist across AdsCloseTable on purpose - a close releases held -// locks (same as DBFCDX closing a workarea); what pinning preserves is -// IDENTITY for the next open, not the locks themselves. Pins are dropped -// at AdsDisconnect. -// -// Default ON; kill switch: openads.ini lane_pin_alias = 0 (or -// OPENADS_LANE_PIN_ALIAS=0) restores pure thread-affinity. -static std::unordered_map& -remote_lane_pins() { - static std::unordered_map m; - return m; -} -static bool remote_lane_pin_enabled() { - static const bool on = [] { - const std::string v = openads::util::client_setting( - "OPENADS_LANE_PIN_ALIAS", "lane_pin_alias"); - if (v.empty()) return true; - std::string l = v; - for (auto& c : l) c = static_cast(::tolower((unsigned char)c)); - return !(l == "0" || l == "false" || l == "off" || l == "no"); - }(); - return on; -} -static std::string remote_lane_pin_key(ADSHANDLE rem_h, - const std::string& name, - const std::string& alias) { - std::string n = name; - for (auto& c : n) { if (c == '\\') c = '/'; } - std::string a = alias; - for (auto& c : a) c = static_cast(::toupper((unsigned char)c)); - return std::to_string(static_cast(rem_h)) + - '\x01' + n + '\x01' + a; -} -// Resolve the lane for a (connection, path, alias) open: pinned lane when -// one is recorded and alive, otherwise the thread-affine pick, which then -// becomes the pin. s.mu must be held. -static openads::network::RemoteConnection* -remote_pool_lane_for_open(ADSHANDLE rem_h, const std::string& name, - const std::string& alias) { - namespace net = openads::network; - if (!remote_lane_pin_enabled()) - return remote_pool_lane_conn(static_cast(rem_h)); - const std::string key = remote_lane_pin_key(rem_h, name, alias); - auto& pins = remote_lane_pins(); - auto it = pins.find(key); - if (it != pins.end()) { - if (it->second != nullptr && it->second->valid()) return it->second; - pins.erase(it); // dead lane: fall through and re-pin - } - net::RemoteConnection* rc = remote_pool_lane_conn(static_cast(rem_h)); - if (rc != nullptr) pins[key] = rc; - return rc; -} -// Drop every pin of a logical connection (AdsDisconnect). s.mu held. -static void remote_lane_pins_clear(ADSHANDLE rem_h) { - const std::string prefix = - std::to_string(static_cast(rem_h)) + '\x01'; - for (auto it = remote_lane_pins().begin(); - it != remote_lane_pins().end();) { - if (it->first.compare(0, prefix.size(), prefix) == 0) - it = remote_lane_pins().erase(it); - else - ++it; - } -} - -extern "C" { - - -/* ARC32 bring-up trace (temporary): one line per key ABI call. */ -#include -static bool arc2_on() { - static const bool on = openads::util::client_setting_truthy( - "OPENADS_ARC_TRACE", "arc_trace"); - return on && openads::util::logging_enabled(); -} -static void arc2_stamp(FILE* f) { - std::time_t t = std::time(nullptr); - struct tm tmv; -#if defined(_WIN32) - localtime_s(&tmv, &t); -#else - localtime_r(&t, &tmv); -#endif - fprintf(f, "%02d:%02d:%02d ", tmv.tm_hour, tmv.tm_min, tmv.tm_sec); -} -static void arc2_trace(const char* name) { - if (!arc2_on()) return; - const char* path = -#if defined(_MSC_VER) - "C:/OpenADS/_arc32/ace_calls.log"; -#else - "/tmp/ace_calls.log"; -#endif - if (auto* f = fopen(path, "a")) { - arc2_stamp(f); - fprintf(f, "%s\n", name); - fclose(f); - } -} -static void arc2_log(const char* fmt, ...) { - if (!arc2_on()) return; - const char* path = -#if defined(_MSC_VER) - "C:/OpenADS/_arc32/ace_calls.log"; -#else - "/tmp/ace_calls.log"; -#endif - if (auto* f = fopen(path, "a")) { - arc2_stamp(f); - va_list ap; va_start(ap, fmt); - vfprintf(f, fmt, ap); - va_end(ap); - fputc('\n', f); - fclose(f); - } -} - -UNSIGNED32 ENTRYPOINT AdsConnect60(UNSIGNED8* pucServer, UNSIGNED16 usServerType, - UNSIGNED8* pucUser, UNSIGNED8* pucPwd, - UNSIGNED32 /*ulOptions*/, ADSHANDLE* phConnect) { - arc2_trace("AdsConnect60"); - if (cli_trace_on()) { - openads::network::set_frame_trace_hook(&cli_trace_frame_hook); - } - if (phConnect == nullptr) return fail(openads::AE_INTERNAL_ERROR, - "phConnect is null"); - auto path = openads::abi::to_internal(pucServer, 0); - auto has_remote_scheme = [](const std::string& s) { - return s.rfind("tcp://", 0) == 0 || s.rfind("tls://", 0) == 0; - }; - if (usServerType == ADS_REMOTE_SERVER && !has_remote_scheme(path)) { - path = "tcp://127.0.0.1:6262/" + path; - } - // M12.5 -- `tcp://host:port/` routes the connection - // through the wire client; every Ads* function that recognises - // the connection handle's RemoteConnection kind dispatches to - // the server instead of touching a local Connection. - // M12.9 -- pucUser / pucPwd are forwarded into the Connect frame; - // the server validates them when it has credentials registered. - { - // M12.12 -- `tls://host:port/` URI. When the engine was - // built with -DOPENADS_WITH_TLS=ON we open a real TLS client - // through vendored mbedtls; otherwise we surface a clear - // AE_FUNCTION_NOT_AVAILABLE so apps don't silently downgrade - // to plaintext. - std::string thost, tdir; - std::uint16_t tport = 0; - if (openads::network::parse_tls_uri(path, thost, tport, tdir)) { -#if defined(OPENADS_WITH_TLS) - std::string user = pucUser ? openads::abi::to_internal(pucUser, 0) - : std::string(); - std::string pw = pucPwd ? openads::abi::to_internal(pucPwd, 0) - : std::string(); - openads::network::TlsConfig cfg; - // Verify peer certificates by default. Set OPENADS_TLS_INSECURE=1 - // (or tls_insecure = 1 in openads.ini) for dev/self-signed - // endpoints until AdsSetTlsCa ships. - cfg.insecure_skip_verify = openads::util::client_setting_truthy( - "OPENADS_TLS_INSECURE", "tls_insecure"); - cfg.sni_hostname = thost; - auto tt = openads::network::connect_tls(thost, tport, cfg); - if (!tt) return fail(tt.error()); - auto rc = std::make_unique(); - if (auto r = rc->connect_with_transport( - std::move(tt).value(), tdir, user, pw); !r) { - return fail(r.error()); - } - auto& s = state(); - std::lock_guard lk(s.mu); - Handle h = s.registry.register_object( - HandleKind::RemoteConnection, rc.get()); - static std::unordered_map> - remote_tls_conns; - remote_tls_conns.emplace(h, std::move(rc)); - // Session pool (WAN thread-lanes): extra sessions for the same - // logical connection. Best-effort: failures degrade lane count. - { - RemoteLanePool* pool = remote_lane_pool_create( - remote_tls_conns[h].get(), h); - static std::unordered_map> - remote_tls_pool_conns; - const unsigned want = remote_pool_size_cfg(); - for (unsigned i = 1; i < want; ++i) { - auto tt2 = openads::network::connect_tls(thost, tport, cfg); - if (!tt2) break; - auto lane = std::make_unique< - openads::network::RemoteConnection>(); - if (auto r = lane->connect_with_transport( - std::move(tt2).value(), tdir, user, pw); !r) - break; - Handle lh = s.registry.register_object( - HandleKind::RemoteConnection, lane.get()); - remote_lane_pool_add_lane(pool, lane.get(), lh); - remote_tls_pool_conns.emplace(lh, std::move(lane)); - } - } - *phConnect = to_ads_handle(h); - // Harbour rddads stores the last AdsConnect handle for - // AdsCreateTable / AdsBeginTransaction(0) etc. - rddads_default_connection() = to_ads_handle(h); - openads::util::write_connected_audit(tdir, true); - return ok(); -#else - return fail(openads::AE_FUNCTION_NOT_AVAILABLE, - "tls:// URI requires building OpenADS with " - "-DOPENADS_WITH_TLS=ON (vendors mbedtls 3.6 LTS)"); -#endif - } - } - { - std::string host, dir; - std::uint16_t port = 0; - if (openads::network::parse_tcp_uri(path, host, port, dir)) { - std::string user = pucUser ? openads::abi::to_internal(pucUser, 0) - : std::string(); - std::string pw = pucPwd ? openads::abi::to_internal(pucPwd, 0) - : std::string(); - auto rc = std::make_unique(); - if (auto r = rc->connect(host, port, dir, user, pw); !r) { - return fail(r.error()); - } - auto& s = state(); - std::lock_guard lk(s.mu); - Handle h = s.registry.register_object( - HandleKind::RemoteConnection, rc.get()); - // Keep RemoteConnection alive in a side container. - static std::unordered_map> - remote_conns; - remote_conns.emplace(h, std::move(rc)); - // Session pool (WAN thread-lanes): extra sessions for the same - // logical connection so MT callers proceed in parallel. - // Best-effort: a failed lane connect degrades to fewer lanes. - { - RemoteLanePool* pool = remote_lane_pool_create( - remote_conns[h].get(), h); - static std::unordered_map> - remote_pool_conns; - const unsigned want = remote_pool_size_cfg(); - for (unsigned i = 1; i < want; ++i) { - auto lane = std::make_unique< - openads::network::RemoteConnection>(); - if (auto r = lane->connect(host, port, dir, user, pw); !r) - break; - Handle lh = s.registry.register_object( - HandleKind::RemoteConnection, lane.get()); - remote_lane_pool_add_lane(pool, lane.get(), lh); - remote_pool_conns.emplace(lh, std::move(lane)); - } - } - *phConnect = to_ads_handle(h); - // Harbour rddads stores the last AdsConnect handle for - // AdsCreateTable / AdsBeginTransaction(0) etc. - rddads_default_connection() = to_ads_handle(h); - openads::util::write_connected_audit(dir, true); - return ok(); - } - } -#if defined(OPENADS_WITH_SQLITE) - { - openads::sql_backend::SqliteUri suri; - if (openads::sql_backend::parse_sqlite_uri(path, suri)) { - auto opened = openads::sql_backend::SqliteConnection::open(suri); - if (!opened) return fail(opened.error()); - auto holder = std::make_unique( - std::move(opened).value()); - openads::sql_backend::SqliteConnection* raw = holder.get(); - auto& s = state(); - std::lock_guard lk(s.mu); - Handle h = s.registry.register_object( - HandleKind::SqliteConnection, raw); - sqlite_conns_map().emplace(h, std::move(holder)); - sql_uri_connect_register_user(to_ads_handle(h), pucUser, raw, - openads::sql_backend::SqlDdlDialect::Sqlite); - *phConnect = to_ads_handle(h); - return ok(); - } - } -#endif -#if defined(OPENADS_WITH_ODBC) - { - openads::sql_backend::OracleUri oruri; - if (openads::sql_backend::parse_oracle_uri(path, oruri)) { - openads::sql_backend::OdbcUri ouri; - ouri.connstr = openads::sql_backend::oracle_to_odbc_connstr(oruri); - ouri.password = oruri.password; - 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)); - sql_uri_remember_odbc_dialect( - h, openads::sql_backend::SqlDdlDialect::Oracle); - sql_uri_connect_register_user(to_ads_handle(h), pucUser, raw, - openads::sql_backend::SqlDdlDialect::Oracle); - *phConnect = to_ads_handle(h); - return ok(); - } - } - { - 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)); - sql_uri_remember_odbc_dialect( - h, openads::sql_backend::SqlDdlDialect::Postgres); - sql_uri_connect_register_user(to_ads_handle(h), pucUser, raw, - openads::sql_backend::SqlDdlDialect::Postgres); - *phConnect = to_ads_handle(h); - return ok(); - } - } -#else - { - static constexpr const char* kOdbcPrefixes[] = { - "odbc://", "odbc:", "oracle://", - }; - 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/oracle URI requires OPENADS_WITH_ODBC=ON"); - } - } - } -#endif -#if defined(OPENADS_WITH_MSSQL) - { - openads::sql_backend::MssqlUri muri; - if (openads::sql_backend::parse_mssql_uri(path, muri)) { - auto opened = openads::sql_backend::MssqlConnection::open(muri); - if (!opened) return fail(opened.error()); - auto holder = - std::make_unique( - std::move(opened).value()); - openads::sql_backend::MssqlConnection* raw = holder.get(); - auto& s = state(); - std::lock_guard lk(s.mu); - Handle h = s.registry.register_object( - HandleKind::MssqlConnection, raw); - mssql_conns_map().emplace(h, std::move(holder)); - sql_uri_connect_register_user(to_ads_handle(h), pucUser, raw, - openads::sql_backend::SqlDdlDialect::Mssql); - *phConnect = to_ads_handle(h); - return ok(); - } - } -#else - { - static constexpr const char* kMssqlPrefixes[] = { - "mssql://", "tds://", - }; - for (const char* prefix : kMssqlPrefixes) { - 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, - "mssql/tds URI requires OPENADS_WITH_MSSQL=ON"); - } - } - } -#endif -#if defined(OPENADS_WITH_FIREBIRD) - // Native Firebird driver (firebird:// / fb://) -- embedded `.fdb` - // in-process via libfbclient, or a TCP server. Parsed into a - // FirebirdUri (structured user/password/charset/role) and torn down - // in AdsDisconnect. - { - openads::sql_backend::FirebirdUri furi; - if (openads::sql_backend::parse_firebird_uri(path, furi)) { - auto opened = openads::sql_backend::FirebirdConnection::open(furi); - if (!opened) return fail(opened.error()); - auto holder = - std::make_unique( - std::move(opened).value()); - openads::sql_backend::FirebirdConnection* raw = holder.get(); - auto& s = state(); - std::lock_guard lk(s.mu); - Handle h = s.registry.register_object( - HandleKind::FirebirdConnection, raw); - firebird_conns_map().emplace(h, std::move(holder)); - sql_uri_connect_register_user(to_ads_handle(h), pucUser, raw, - openads::sql_backend::SqlDdlDialect::Firebird); - *phConnect = to_ads_handle(h); - return ok(); - } - } -#else - { - static constexpr const char* kFirebirdPrefixes[] = { - "firebird://", "firebird:", "fb://", - }; - for (const char* prefix : kFirebirdPrefixes) { - 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, - "firebird URI requires OPENADS_WITH_FIREBIRD=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)); - sql_uri_connect_register_user(to_ads_handle(h), pucUser, raw, - openads::sql_backend::SqlDdlDialect::Maria); - *phConnect = to_ads_handle(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)); - sql_uri_connect_register_user(to_ads_handle(h), pucUser, raw, - openads::sql_backend::SqlDdlDialect::Postgres); - *phConnect = to_ads_handle(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()); - auto holder = std::make_unique(std::move(opened).value()); - Connection* raw = holder.get(); - // DD authentication: if the DD has LOG_IN_REQUIRED set, validate - // the supplied credentials before registering the connection. - if (raw->has_dd()) { - auto* dd = raw->dd(); - std::string user = pucUser ? openads::abi::to_internal(pucUser, 0) - : std::string(); - std::string pwd = pucPwd ? openads::abi::to_internal(pucPwd, 0) - : std::string(); - - // Logins-disabled: a stricter, all-connections-rejected gate than - // LOG_IN_REQUIRED below -- even a valid user/password normally can't - // connect while this is set. Reads the STABLE storage key "prop_16" - // (SP_MODIFYDATABASE numbering) -- the SAP ABI id for it is - // ADS_DD_LOGINS_DISABLED (113), translated by db_prop_storage_key. - // Mirrored in network/session.cpp's Connect handler for remote - // connections. - // - // Admin bypass: without this, setting the flag would be a one-way - // door -- nobody, not even the admin trying to undo it, could ever - // reconnect to flip it back off. openads::mgmt::kAdminBypassUser - // ("adssys", ADS's own conventional admin username) with a correct - // per-user password (prop_1101) is exempted. - std::string logins_disabled = dd->get_db_property("prop_16"); - bool disabled_raw_zero = (logins_disabled.size() >= 2 && - static_cast(logins_disabled[0]) == 0 && - static_cast(logins_disabled[1]) == 0); - bool logins_are_disabled = (!logins_disabled.empty() && - logins_disabled != "0" && logins_disabled != "False" && - !disabled_raw_zero); - if (logins_are_disabled && - !openads::mgmt::is_admin_bypass(dd, user, pwd)) { - return fail(openads::AE_LOGIN_FAILED, - "logins are disabled for this dictionary"); - } - - std::string login_req = dd->get_db_property("prop_5"); - // Stored as decimal string by import tool and UI: "0" = not required, - // "1" = required. Keep raw-byte fallback for any old imports. - bool is_raw_zero = (login_req.size() >= 2 && - static_cast(login_req[0]) == 0 && - static_cast(login_req[1]) == 0); - bool require_login = (!login_req.empty() && login_req != "0" && - login_req != "False" && !is_raw_zero); - if (require_login) { - if (user.empty()) - return fail(openads::AE_LOGIN_FAILED, - "login required but no username supplied"); - if (!dd->has_user(user)) - return fail(openads::AE_LOGIN_FAILED, "unknown user"); - std::string stored = dd->get_user_property(user, "prop_1101"); - if (stored != pwd) - return fail(openads::AE_LOGIN_FAILED, "invalid password"); - } - if (!user.empty()) { - raw->set_username(user); - // Pre-build effective-permission cache for this user so that - // subsequent AdsOpenTable / AdsExecuteSQLDirect checks are O(1). - if (dd->has_any_acl()) - dd->build_perm_cache(user); - } - } - auto& s = state(); - std::lock_guard lk(s.mu); - Handle h = s.registry.register_object(HandleKind::Connection, raw); - s.conns.emplace(h, std::move(holder)); - *phConnect = to_ads_handle(h); - rddads_default_connection() = to_ads_handle(h); - // Reject connections to SAP proprietary binary .add files. OpenADS - // can read them (load_add_binary_) but cannot safely write them back - // (format is closed and permission fields are encrypted). Direct the - // caller to run the import_dd tool to produce an OpenADS-format DD. - if (raw->has_dd() && raw->dd()->has_sap_permissions()) { - s.conns.erase(h); // destroys the Connection object - s.registry.release(h); - *phConnect = 0; - return fail(openads::AE_SAP_PERMS_NEED_IMPORT, - "This is a SAP Advantage Data Dictionary in proprietary binary format. " - "OpenADS cannot open it directly. " - "Run: import_dd " - "to convert it to OpenADS format, then connect to the converted file."); - } - openads::util::write_connected_audit(path, false); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsConnect101(UNSIGNED8* pucConnectString, - ADSHANDLE* phConnectOptions, - ADSHANDLE* phConnect) { - arc2_trace("AdsConnect101"); - arc2_trace("AdsConnect101"); - arc2_log("CONNECT101 connstr=[%s]", - pucConnectString ? (const char*)pucConnectString : "(null)"); - if (phConnect == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, "phConnect is null"); - } - *phConnect = 0; - if (phConnectOptions != nullptr) *phConnectOptions = 0; - if (pucConnectString == nullptr || pucConnectString[0] == 0) { - return fail(openads::AE_INTERNAL_ERROR, - "AdsConnect101: empty connection string"); - } - - auto options = parse_connect101_options( - openads::abi::to_internal(pucConnectString, 0)); - auto get_opt = [&](const char* key) -> std::string { - auto it = options.find(key); - return it == options.end() ? std::string() : it->second; - }; - - std::string data_source = get_opt("datasource"); - if (data_source.empty()) { - return fail(openads::AE_INTERNAL_ERROR, - "AdsConnect101: Data Source is required"); - } - - if (auto v = get_opt("dateformat"); !v.empty()) { - AdsSetDateFormat(reinterpret_cast(v.data())); - } - if (auto v = get_opt("decimals"); !v.empty()) { - AdsSetDecimals(static_cast( - std::strtoul(v.c_str(), nullptr, 10))); - } - if (auto v = get_opt("epoch"); !v.empty()) { - AdsSetEpoch(static_cast( - std::strtoul(v.c_str(), nullptr, 10))); - } - if (auto v = get_opt("exact"); !v.empty()) { - AdsSetExact(connect101_truthy(v) ? 1 : 0); - } - if (auto v = get_opt("showdeleted"); !v.empty()) { - AdsShowDeleted(connect101_truthy(v) ? 1 : 0); - } - - std::uint16_t server_type = 0; - if (auto v = get_opt("servertype"); !v.empty()) { - server_type = connect101_server_type(v); - } - if (server_type == 0) { - std::uint16_t mask = server_type_mask(); - server_type = (mask & ADS_LOCAL_SERVER) != 0 - ? ADS_LOCAL_SERVER : ADS_REMOTE_SERVER; - } - - std::string user = get_opt("userid"); - std::string password = get_opt("password"); - UNSIGNED32 rc = AdsConnect60( - reinterpret_cast(data_source.data()), - server_type, - user.empty() ? nullptr : reinterpret_cast(user.data()), - password.empty() ? nullptr : reinterpret_cast(password.data()), - 0, - phConnect); - if (rc != openads::AE_SUCCESS) return rc; - - if (auto v = get_opt("sqltimeout"); !v.empty()) { - rc = AdsSetSQLTimeout(*phConnect, - static_cast( - std::strtoul(v.c_str(), nullptr, 10))); - if (rc != openads::AE_SUCCESS) { - (void)AdsDisconnect(*phConnect); - *phConnect = 0; - return rc; - } - } - Connect101OpenOptions open_opts; - if (auto v = get_opt("tabletype"); !v.empty()) { - open_opts.table_type = connect101_table_type(v); - } - if (auto v = get_opt("chartype"); !v.empty()) { - open_opts.char_type = connect101_char_type(v); - } - if (auto v = get_opt("lockmode"); !v.empty()) { - open_opts.lock_type = connect101_lock_type(v); - } - if (auto v = get_opt("securitymode"); !v.empty()) { - open_opts.check_rights = connect101_security_mode(v); - } - open_opts.mode = connect101_open_mode(options); - { - auto& s = state(); - std::lock_guard lk(s.mu); - connect101_open_options()[*phConnect] = open_opts; - } - if (phConnectOptions != nullptr) { - auto opt_table = build_connect101_options_table(options); - if (!opt_table) { - (void)AdsDisconnect(*phConnect); - *phConnect = 0; - *phConnectOptions = 0; - return fail(opt_table.error()); - } - auto holder = std::make_unique
(std::move(opt_table).value()); - Table* raw = holder.get(); - auto& s = state(); - std::lock_guard lk(s.mu); - Handle h = s.registry.register_object(HandleKind::Table, raw); - connect101_option_tables().emplace(to_ads_handle(h), std::move(holder)); - *phConnectOptions = to_ads_handle(h); - } - return ok(); -} - -// Forward decl: sp_CreateEvent registry cleanup, defined with the sp_* -// system-procedure machinery further down (namespace spproc). -extern "C++" { -namespace spproc { void drop_all_events_for(const void* conn); } -// S4 -- session #temp / trigger-image cleanup (defined by the sess -// namespace further down, near the trigger machinery). -extern "C++" void sess_forget_connection(Connection* c); -// park_active_order lives with the index-binding helpers further down -// (anonymous namespace). The production-index auto-open in AdsOpenTable -// calls it to undo AdsOpenIndex's optimistic first-tag activation -- -// ADS semantics leave the controlling order natural (0) after an -// auto-open until DbSetOrder picks a tag. -namespace { void park_active_order(Table* t); } -} - -// --- Lazy-close table pool (USE latency) ---------------------------------- -// -// NOTE: this block must stay inside extern "C++": the file-scope -// extern "C" gives every function here C linkage, and MSVC errors -// (C4190) on C++ return types like unique_ptr/string under /WX. - -extern "C++" { -// Vouch-style apps re-USE the same lookup tables every few seconds; each -// USE costs open+index+describe+goto round-trips. Parking an eligible -// table at close keeps its server handle, schema, tags and order -// bindings alive, so the next USE of the same path+alias+mode pays a -// single warm GotoTop. Eligibility is conservative (shared mode, natural -// order, never locked/scoped, no AOF/filter, no relations); anything -// else takes the legacy real close, exactly as before. -// -// Ownership: live tables live in remote_table_store() keyed by object -// pointer (moved out of AdsOpenTable, where the map leaked: nothing -// ever erased it). Parked tables live in the connection pool; the -// registry slot is released at park time so use-after-close still -// errors SAP-correct. - -// Owns every live RemoteTable. Keyed by object pointer (stable across -// handle re-registration on pooled reuse). -std::unordered_map>& -remote_table_store() { - static std::unordered_map> m; - return m; -} - -// Drop ownership without closing (park path hands it to the pool). -std::unique_ptr -remote_table_take(openads::network::RemoteTable* rt) { - if (rt == nullptr) return nullptr; - // remote_table_store() is shared by every thread (all lanes of a - // session pool): every access must hold s.mu. The park path in - // AdsCloseTable used to call this unlocked while other threads - // inserted/erased under s.mu -- a data race on the map that corrupted - // the heap under the 4-thread pool test (~1 run in 30 on Linux). - std::lock_guard lk(state().mu); - auto& m = remote_table_store(); - auto it = m.find(rt); - if (it == m.end()) return nullptr; - auto owned = std::move(it->second); - m.erase(it); - return owned; -} - -void remote_table_forget(openads::network::RemoteTable* rt) { - if (rt == nullptr) return; - std::lock_guard lk(state().mu); // see take() - remote_table_store().erase(rt); -} - -// Drop parked index snapshots on every live table of rc (file -// erase/rename can pull a bag out from under parked bindings). -// Drops the snapshot but keeps the pending flag, so the next flush -// sends a real close — files change rarely, correctness first. -// Pooled tables are real-closed by remote_flush_pools alongside. -void remote_invalidate_index_parks(openads::network::RemoteConnection* rc) { - if (rc == nullptr) return; - // Pool-wide: a bag pulled on one lane invalidates parked bindings on - // every lane of the same logical connection. - auto lanes = remote_pool_lanes_of(rc); - auto lane_match = [&](openads::network::RemoteConnection* c) { - for (auto* l : lanes) - if (c == l) return true; - return false; - }; - std::lock_guard lk(state().mu); // shared store - for (auto& kv : remote_table_store()) { - auto* rt = kv.first; - if (rt == nullptr || !lane_match(rt->conn)) continue; - if (!rt->indexes_parked) continue; - rt->indexes_parked = false; - rt->parked_by_tag.clear(); - rt->parked_handles.clear(); - } -} - -// Pool key: connection-implied (pools are per-connection), normalized -// name + alias + raw open mode. Slashes folded; case preserved (Linux -// filesystems are case-sensitive -- a miss is always safe). -std::string remote_pool_key(const std::string& name, - const std::string& alias, - std::uint16_t mode) { - std::string n = name; - for (auto& c : n) { if (c == '\\') c = '/'; } - return n + '\x01' + alias + '\x01' + std::to_string(mode); -} - -// True when the table participates in any relation (as parent or child). -// Parked tables keep no registry handle, so relations addressed by -// handle could neither follow nor clean up -- real-close those. -bool remote_table_has_relations(ADSHANDLE hTable) { - std::lock_guard lk(state().mu); - auto& tbl = relation_map(); - if (tbl.find(hTable) != tbl.end()) return true; - for (auto& [parent, kids] : tbl) { - (void)parent; - for (auto& k : kids) { - if (k.child == hTable) return true; - } - } - return false; -} - -// Eligibility snapshot at close time: every line must still read -// fresh-open equivalent. Order state is EXEMPT: no close reaches the -// server while parked, and server order/index bindings are per-session, -// so no peer can invalidate them behind our back — an active order -// resumes exactly (Vouch keeps IndexOrd()=1 across USEs; excluding it -// would empty the pool). -// wire_trace only: first rule (in remote_table_poolable order, then -// relations) that keeps a closing table out of the park. Mirrors the -// checks below; it never decides anything itself. -const char* remote_table_unpoolable_reason( - openads::network::RemoteTable* rt, ADSHANDLE hTable) { - if (rt == nullptr || rt->conn == nullptr) return "no_connection"; - if (!rt->close_counted) return "sql_cursor"; - if (rt->open_exclusive) return "exclusive"; - if (!rt->pending_sets.empty()) return "pending_sets"; - if (!rt->held_recs.empty() || rt->table_lock_held || - rt->locks_uncertain) return "locks_held"; - if (rt->scope_touched) return "scope"; - if (!rt->aof_expr.empty()) return "aof"; - if (!rt->filter_expr.empty()) return "filter"; - if (rt->flush_file_pending || rt->close_all_indexes_pending) - return "teardown_pending"; - if (rt->pending_order) return "pending_order"; - if (remote_table_has_relations(hTable)) return "relations"; - return "none"; -} - -bool remote_table_poolable(openads::network::RemoteTable* rt) { - if (rt == nullptr || rt->conn == nullptr) return false; - if (!rt->close_counted) return false; // SQL cursors etc. - if (rt->open_exclusive) return false; // parked exclusive blocks peers - if (!rt->pending_sets.empty()) return false; // flushed before close - // Locks once held no longer bar the park by themselves: the ledger - // proves whether any lock is STILL held (a parked table would - // otherwise pin it against every peer forever). ever_locked stays - // recorded for the trace but stops forcing a real close -- that - // policy cost GN_COUNT/FA_ACC01/USASELOG a full 7-frame reopen per - // voucher save. - if (!rt->held_recs.empty() || rt->table_lock_held || - rt->locks_uncertain) return false; - if (rt->scope_touched) return false; - if (!rt->aof_expr.empty() || !rt->filter_expr.empty()) return false; - // Deferred teardown (FlushFileBuffers/CloseAllIndexes) is absorbed - // by a real close, never by a park (no server close happens) — the - // close path drops the flags before deciding, so this line is an - // invariant guard for future park paths, not a live branch. - // A deferred order switch is likewise never parked: adopt would - // inherit a belief the server doesn't share. - if (rt->flush_file_pending || rt->close_all_indexes_pending) - return false; - if (rt->pending_order) - return false; - return true; -} - -// The actual server close + accounting for a live remote table. -// hTable==0 for already-unregistered (parked eviction/disconnect flush): -// skips registry/relations/cursor-map steps. Never holds s.mu across -// the wire close (in-process server re-enters it) -- callers must not -// hold it either; the mutex below covers accounting only. -void remote_close_table_live(ADSHANDLE hTable, - openads::network::RemoteTable* rt) { - if (rt == nullptr) return; - // A deferred order switch dies with the bindings: never emit it - // ahead of the close that destroys what it would install. - rt->pending_order = false; - (void)remote_flush_pending(rt); - auto* rc = rt->conn; - const bool counted = rt->close_counted; - if (rc != nullptr) (void)rc->close_table(rt->id); - cli_trace_table_close(rt); - if (hTable != 0) forget_relations(hTable); - auto& s2 = state(); - openads::network::RemoteConnection* fire = nullptr; - { - std::lock_guard lk2(s2.mu); - if (counted && rc != nullptr) { - if (--rc->deferred_open_tables == 0 && rc->close_pending) { - rc->close_pending = false; - fire = rc; - } - } - if (hTable != 0) { - s2.registry.release(hTable); - remote_sql_cursors_map().erase(hTable); - } - remote_table_forget(rt); - } - if (fire != nullptr) fire->disconnect(); -} - -// (remote_emit_close_all / remote_close_parked_indexes / -// remote_invalidate_index_parks live with the flush funnel above, -// ahead of first use.) - -// Parked-index truth enforcement: while indexes_parked holds, the -// server still runs the pre-CloseAll order but the client belief is -// natural (active_index_id 0). Order-dependent table-handle navs -// (top/bottom/skip/goto-record walks the server order) must resolve -// the divergence with a real close first — adoption would walk the -// stale order while the client accounts natural rows. Fires only -// when a nav lands inside a CloseAll→OpenIndex window (the rotation -// itself never does: Clear→Add is adjacent), so the normal flow pays -// nothing. Index-handle navs and seeks carry their own order context -// and adopt safely without this. -// (remote_emit_close_all / remote_close_parked_indexes / -// remote_invalidate_index_parks live with the flush funnel above, -// ahead of first use.) - -// Real-close every parked table on rc (drop/create/erase/rename and -// exclusive opens change what paths mean on disk; a parked server -// handle would pin or shadow them). Drops are rare; flushing the whole -// small pool is cheaper than path-matching subtleties. Wire closes run -// without s.mu held (in-process server re-enters it) — callers must -// arrange that (disconnect/open paths unlock first). -static void remote_flush_pools_one(openads::network::RemoteConnection* rc) { - if (rc == nullptr) return; - // File lifecycle changed meaning on disk: drop the existence - // cache alongside the parked handles. - rc->file_exists_invalidate(); - std::vector> parked; - rc->parked_flush(parked); - for (auto& e : parked) { - // A drop/create/erase/rename absorbs deferred teardown the - // same way a close does — never emit it first. - e->flush_file_pending = false; - e->close_all_indexes_pending = false; - e->write_dirty = false; - remote_close_table_live(0, e.get()); - } -} - -void remote_flush_pools(openads::network::RemoteConnection* rc) { - if (rc == nullptr) return; - // Pool-wide fan-out: a drop/create/erase/rename on one lane changes - // what paths mean for every lane (parks pin server handles by path, - // existence caches are per-lane). Drops are rare; flushing all small - // lane pools is cheaper than path-matching subtleties. - for (auto* lane : remote_pool_lanes_of(rc)) { - if (lane == nullptr) continue; - remote_flush_pools_one(lane); - } -} - -} // extern "C++" (lazy-close pool helpers end; ABI exports resume in C) - -UNSIGNED32 ENTRYPOINT AdsDisconnect(ADSHANDLE hConnect) { - arc2_trace("AdsDisconnect"); - arc2_trace("AdsDisconnect"); - std::vector pending_remote_disconnects; - bool deferred_close = false; - { - auto& s_local = state(); - std::unique_lock lk_local(s_local.mu); - connect101_open_options().erase(hConnect); -#if defined(OPENADS_WITH_SQLITE) - if (auto* sc = s_local.registry.lookup( - hConnect, HandleKind::SqliteConnection)) { - for (auto& kv : sqlite_tables_map()) { - if (kv.second && kv.second->conn == sc) { - kv.second->conn = nullptr; - } - } - sc->disconnect(); - sqlite_conns_map().erase(hConnect); - sql_uri_forget_user(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); - sql_uri_forget_user(hConnect); - sql_uri_forget_odbc_dialect(hConnect); - s_local.registry.release(hConnect); - return ok(); - } -#endif -#if defined(OPENADS_WITH_MSSQL) - if (auto* mc = s_local.registry.lookup< - openads::sql_backend::MssqlConnection>( - hConnect, HandleKind::MssqlConnection)) { - mc->disconnect(); - mssql_conns_map().erase(hConnect); - sql_uri_forget_user(hConnect); - s_local.registry.release(hConnect); - return ok(); - } -#endif -#if defined(OPENADS_WITH_FIREBIRD) - if (auto* sc = s_local.registry.lookup< - openads::sql_backend::FirebirdConnection>( - hConnect, HandleKind::FirebirdConnection)) { - for (auto& kv : firebird_tables_map()) { - if (kv.second && kv.second->conn == sc) { - kv.second->conn = nullptr; - } - } - sc->disconnect(); - firebird_conns_map().erase(hConnect); - sql_uri_forget_user(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); - sql_uri_forget_user(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); - sql_uri_forget_user(hConnect); - s_local.registry.release(hConnect); - return ok(); - } -#endif - if (auto* rc0 = s_local.registry.lookup( - hConnect, HandleKind::RemoteConnection)) { - // Pool-wide teardown: every lane of the logical connection. - // Deferred accounting stays per-lane (each lane's last close - // disconnects that lane); the logical disconnect defers while - // ANY lane still has open tables. - auto lanes = remote_pool_lanes_of(rc0); - if (lanes.empty()) lanes.push_back(rc0); - // Lane pins name lanes of this connection; drop them all. - remote_lane_pins_clear(hConnect); - // Null out rt->conn on any open SQL cursors that reference this - // connection so AdsCloseTable can detect the dangling case and - // skip the wire op rather than crashing with a use-after-free. - for (auto* rc : lanes) { - if (rc == nullptr) continue; - for (auto& kv : remote_sql_cursors_map()) { - if (kv.second && kv.second->conn == rc) - kv.second->conn = nullptr; - } - } - // Drain parked tables first: they count as open - // (deferred_open_tables) and would otherwise pin a deferred - // disconnect forever — nothing will ever close them. Wire - // closes run with s.mu released (in-process server - // re-enters it); accounting re-locks inside. - { - std::vector> parked; - for (auto* rc : lanes) { - if (rc == nullptr) continue; - rc->parked_flush(parked); - } - lk_local.unlock(); - for (auto& e : parked) remote_close_table_live(0, e.get()); - lk_local.lock(); - } - for (auto* rc : lanes) { - if (rc == nullptr) continue; - if (rc->deferred_open_tables > 0 && rc->valid()) { - // Tables opened through this connection are still in use - // (MT apps sharing one connection across threads): killing - // the socket now would turn their next op into an error. - // The last AdsCloseTable performs the real disconnect. - rc->close_pending = true; - deferred_close = true; - } else { - // Disconnect OUTSIDE s.mu: RemoteConnection::disconnect() - // serialises with in-flight requests on the connection - // mutex, and a request's server handler (in-process - // server) re-enters the ABI and takes s.mu -- holding both - // here could deadlock. - pending_remote_disconnects.push_back(rc); - } - } - } - } - if (!pending_remote_disconnects.empty()) { - for (auto* rc : pending_remote_disconnects) { - if (rc != nullptr) rc->disconnect(); - } - if (hConnect == rddads_default_connection()) - rddads_default_connection() = 0; - return ok(); - } - if (deferred_close) { - if (hConnect == rddads_default_connection()) - rddads_default_connection() = 0; - return ok(); - } - auto& s = state(); - std::lock_guard lk(s.mu); - // Purge any index bindings whose Table* belongs to a table owned - // by this connection -- otherwise the bindings outlive the conns - // entry that owned the Table and leave dangling pointers behind. - Connection* c = s.registry.lookup(hConnect, HandleKind::Connection); - if (c != nullptr) { - // Drop this connection's sp_CreateEvent registrations so the - // global event map can't hold a dangling Connection key. - spproc::drop_all_events_for(c); - // S4 -- drop session #temp tables / trigger-image registrations so - // the maps can't hold a dangling Connection key (defined near the - // sess namespace below). - sess_forget_connection(c); - // If this connection activated the process-wide OEM upper table - // (AdsSetCollation NTXPL852/PL852), deactivate it so UPPER() - // reverts to UTF-8 promotion for the rest of the process. - if (c->oem_upper_table() != nullptr && - c->oem_upper_table() == - openads::engine::active_oem_upper_table()) { - openads::engine::set_active_oem_upper_table(nullptr); - } - // Collect this connection's still-open Table handles. `s.conns.erase` - // below frees the Connection -- and with it every Table it owns -- so - // any registry slot still pointing at one of those Tables would dangle. - // A later allocation reusing that heap address then aliases the stale - // slot, which surfaces as AdsGetAllTables over-counting and, worse, a - // use-after-free on any Ads* call that looks the stale handle up. - // owns_table_ptr() identifies the owned tables precisely (the old - // heuristic could not, so it purged every registered Table*). - std::vector owned_handles; - std::vector to_purge; - s.registry.for_each_handle([&](Handle h, HandleKind k, void* p) { - if (k != HandleKind::Table) return; - Table* tp = static_cast(p); - if (tp == nullptr || !c->owns_table_ptr(tp)) return; - owned_handles.push_back(h); - to_purge.push_back(tp); - }); - for (Table* tp : to_purge) { - purge_bindings_for_table(tp); - purge_pending_binaries_for_table(tp); - } - for (Handle h : owned_handles) { - forget_cursor_projection(to_ads_handle(h)); - s.registry.release(h); - } - } - if (hConnect == rddads_default_connection()) - rddads_default_connection() = 0; - s.registry.release(hConnect); - s.conns.erase(hConnect); - return ok(); -} - -// OPENADS_REMOTE_ONLY_ACCESS -- legacy local paths must never be -// accessed through this DLL in a remote-only deployment. Modes: -// 1/on/true/yes deny: local open/create fails with AE_ACCESS_DENIED -// (the RDD raises a runtime error) -// 2/log/warn audit: a LOCALACCESS line goes to the audit -// console/file and the access proceeds -- inventory -// mode for finding every local open without breaking -// the app. Use this first: in deny mode an app whose -// error handler itself opens tables via ADSCDX dies -// from Harbour error-handler recursion. (Pritpal Bedi.) -// unset/0/off disabled -// Env var or openads.ini key `remote_only_access`; env wins. Read per -// call (no static cache) so tests and long-lived hosts can toggle it. -static int remote_only_access_mode() { - std::string v = openads::util::client_setting( - "OPENADS_REMOTE_ONLY_ACCESS", "remote_only_access"); - for (auto& c : v) - c = static_cast(std::tolower(static_cast(c))); - if (v == "2" || v == "log" || v == "warn") return 2; - if (v.empty() || v == "0" || v == "false" || v == "off" || v == "no") - return 0; - return 1; -} - -UNSIGNED32 ENTRYPOINT AdsOpenTable(ADSHANDLE hConnect, - UNSIGNED8* pucName, - UNSIGNED8* pucAlias, - UNSIGNED16 usTableType, - UNSIGNED16 usCharType, - UNSIGNED16 usLockType, - UNSIGNED16 usCheckRights, - UNSIGNED16 usMode, - ADSHANDLE* phTable) { - arc2_trace("AdsOpenTable"); - arc2_trace("AdsOpenTable"); - arc2_log("OPEN name=[%s] conn=%llu type=%u mode=%u", - pucName ? (const char*)pucName : "(null)", - (unsigned long long)hConnect, (unsigned)usTableType, - (unsigned)usMode); - if (phTable == nullptr) return fail(openads::AE_INTERNAL_ERROR, - "phTable is null"); - auto& s = state(); - std::unique_lock lk(s.mu); - // M12.5 -- remote connection handle: route through wire client. - // An explicit local Connection handle must stay local: falling - // through to "any live RemoteConnection" hijacks the server ABI - // twin whenever an in-process client also holds a tcp:// handle - // (embedded-server tests + mixed local/remote apps). - // hConnect == 0 / stale still adopts a surviving remote (rddads - // "current connection" after disconnect). - ADSHANDLE rem_h = 0; - if (s.registry.lookup( - hConnect, HandleKind::RemoteConnection) != nullptr) { - rem_h = hConnect; - } else if (s.registry.lookup( - hConnect, HandleKind::Connection) == nullptr) { - rem_h = resolve_remote_conn_handle(hConnect); - } - if (auto* rc0 = s.registry.lookup( - rem_h, HandleKind::RemoteConnection)) { - auto name = openads::abi::to_internal(pucName, 0); - // M12.33 — strip tcp:// URI prefix that legacy Delphi TAdsTable - // components embed in the table name (e.g. - // "tcp://host:port/C:/data\table.dbf" → "table.dbf"). - if (name.size() > 8 && - (name.rfind("tcp://", 0) == 0 || name.rfind("TCP://", 0) == 0)) { - auto last_sep = name.find_last_of("/\\"); - if (last_sep != std::string::npos && last_sep > 6) { - std::string stripped = name.substr(last_sep + 1); - if (!stripped.empty()) name = std::move(stripped); - } - } - // Callers (incl. X#'s ADSRDD) commonly pass the bare table name - // without an extension. Send it through unchanged -- the server's - // Connection::resolve_table_file() already falls back from .dbf to - // .adt when the bare name has no matching .dbf on disk, exactly - // mirroring the LOCAL AdsOpenTable path a few hundred lines down - // (conn->open_table(name, ...)). Force-appending ".dbf" here (as a - // previous version of this code did) skipped that fallback on the - // server -- resolve_table_file() only auto-detects when the name it - // receives has NO extension -- so every ADT-format table (e.g. a DD - // migrated from SAP with Table_Type=ADT) failed to open remotely - // with AE_TABLE_CORRUPTED (5103), even though the identical bare - // name opens fine locally. - // Honour the caller's pucAlias (Harbour USE ... ALIAS xxx) for - // the log entry and the RemoteTable metadata; fall back to the - // filename stem when the caller omits ALIAS or passes "". - std::string alias; - if (pucAlias && pucAlias[0] != '\0') { - alias = openads::abi::to_internal(pucAlias, 0); - } - if (alias.empty()) { - alias = std::filesystem::path(name).stem().string(); - } - // MT lane: this table's session for the logical connection. - // Lane pinning (default ON): same alias+path binds to the same - // lane from any thread and across close/reopen, so record-lock - // identity follows the workarea (zero-space detach/request). - // Without pinning: this thread's affine lane, and single-threaded - // callers always get the primary (lane 0) — behaviour unchanged. - openads::network::RemoteConnection* rc = - remote_pool_lane_for_open(rem_h, name, alias); - if (rc == nullptr) rc = rc0; - openads::util::write_remote_open_audit(name, alias); - // Pooled re-USE (USE latency): same connection/path/alias/mode - // within TTL reuses the parked server handle — no OpenTable wire - // op. Caches reset below; one warm GotoTop repositions (after - // lk is released — wire must never run under s.mu). - std::unique_ptr adopted; - { - std::unique_ptr hit; - if (rc->parked_reuse(remote_pool_key(name, alias, usMode), hit) && - hit && hit->conn == rc) { - adopted = std::move(hit); - } - } - if (cli_trace_on()) { - // Park diagnostics: hit/miss for this lane, plus whether - // another lane of the same session pool holds it parked. - char pbuf[320]; - if (adopted) { - std::snprintf(pbuf, sizeof(pbuf), "park hit id=%u %.200s", - static_cast(adopted->id), - name.c_str()); - } else { - const std::string pk = remote_pool_key(name, alias, usMode); - bool other_lane = false; - auto pit = remote_lane_pool_of().find(rc); - if (pit != remote_lane_pool_of().end() && - pit->second != nullptr) { - for (auto* ln : pit->second->lanes) { - if (ln != nullptr && ln != rc && - ln->parked_contains(pk)) { - other_lane = true; - break; - } - } - } - std::snprintf(pbuf, sizeof(pbuf), - "park miss%s conn=%04X %.200s", - other_lane ? " (parked on other lane)" : "", - static_cast( - reinterpret_cast(rc) & - 0xFFFFu), - name.c_str()); - } - cli_trace_line(cli_trace_ms(), alias.c_str(), "AdsOpenTable", - pbuf); - } - if (adopted) { - adopted->row_valid = false; - adopted->rec_count_cached = false; - adopted->key_count_cached = false; - adopted->key_counts.clear(); - adopted->key_counts.clear(); - adopted->keyno_valid = false; - adopted->found_cached = false; - adopted->nav_at_bof = adopted->nav_at_eof = false; - adopted->last_nav = 0; // re-stamped by the warm GotoTop below - adopted->pair_valid = false; - adopted->anchor_ok = false; - adopted->parked_nav_which = 0; - adopted->flush_file_pending = false; - adopted->close_all_indexes_pending = false; - adopted->pending_order = false; - remote_nav_bound_clear(adopted.get()); - adopted->invalidate_prefetch(); - // close_counted stays true (never decremented at park time), - // so no re-increment here. - lk.unlock(); - // A parked index snapshot does not survive adoption: the - // warm GotoTop below must walk the natural order, not the - // pre-park one (same divergence as a nav inside the - // CloseAll→OpenIndex window). Rare (pool + park combined); - // one frame when it fires. - if (UNSIGNED32 frc = remote_close_parked_indexes(adopted.get()); - frc != 0) { - return frc; - } - auto r = adopted->conn->goto_top(adopted.get()); - if (!r) { - // Server lost it (only when the session itself is gone — - // a live session never drops a parked handle). The count - // it still holds would pin a deferred disconnect, so - // release it before reporting the failure. - lk.lock(); - if (adopted->close_counted && adopted->conn != nullptr) { - adopted->conn->deferred_open_tables -= 1; - } - return fail(r.error()); - } - lk.lock(); - Handle gh2 = s.registry.register_object( - HandleKind::RemoteTable, adopted.get()); - auto* raw = adopted.get(); - remote_table_store()[raw] = std::move(adopted); - *phTable = to_ads_handle(gh2); - if (auto* rtp = get_remote_table(to_ads_handle(gh2))) { - rtp->found_cached = true; rtp->current_found = false; - // The warm GotoTop above is a real wire nav: stamp it so - // the app's immediate GoTop probing dedupes + an empty - // re-USE answers boundaries locally. - remote_nav_stamp(rtp, 1, rtp->server_order_id); - remote_sync_keyno_gototop(rtp); - } - apply_relations_for_handle(to_ads_handle(gh2)); - return ok(); - } - // Exclusive opens cannot share the file with a parked shared - // handle: evict the pool first (wire closes run unlocked). - if (map_open_mode(usMode) == openads::engine::OpenMode::Exclusive) { - lk.unlock(); - remote_flush_pools(rc); - lk.lock(); - } - // Release s.mu across the OpenTable round-trip (same rule as the - // pool flush above and the production auto-open below). Holding - // it here blocks every other ABI call in the process for a full - // RTT, and with an in-process server (local serverd / tests) it - // deadlocks: this thread waits on rc's request lock, the lane - // thread that owns it waits on a reply, and the server handler - // for that reply waits on s.mu. Nothing below touches shared - // state until the lock is re-taken. - lk.unlock(); - auto otr = rc->open_table(name, - static_cast(map_open_mode(usMode))); - lk.lock(); - if (!otr) return fail(otr.error()); - auto& ot = otr.value(); - auto rt = std::make_unique(); - rt->conn = rc; - rt->id = ot.id; - rt->name = name; - rt->alias = std::move(alias); - rt->close_counted = true; - rt->open_mode_raw = usMode; - rt->open_exclusive = - (map_open_mode(usMode) == openads::engine::OpenMode::Exclusive); - // Table type is decided by the server from the opened file's - // extension alone (.adt -> ADS_ADT, anything else -> ADS_CDX; - // see the GetTableType handler). The name we just opened carries - // that extension, so answer it locally. No extension -> leave it - // to the wire (the server may have resolved one). - { - std::string ext = std::filesystem::path(name).extension().string(); - for (auto& c : ext) - c = static_cast(std::tolower( - static_cast(c))); - if (!ext.empty()) { - rt->cached_table_type = (ext == ".adt") ? ADS_ADT : ADS_CDX; - rt->table_type_cached = true; - } - } - cli_trace_table_open(rt.get()); - cli_trace_tbl(rt.get(), "AdsOpenTable", "opened id=%u mode=%u", - static_cast(rt->id), - static_cast(usMode)); - rc->deferred_open_tables += 1; - Handle gh = s.registry.register_object( - HandleKind::RemoteTable, rt.get()); - remote_table_store()[rt.get()] = std::move(rt); - *phTable = to_ads_handle(gh); - // Warm open: schema + first row rode along in the ack. Install - // both so DescribeTable and the implicit GotoTop below cost zero - // RTTs. Absent on old servers — the legacy path runs unchanged. - bool open_warm = false; - if (auto* rtp = get_remote_table(to_ads_handle(gh))) { - if (ot.has_schema) { - rtp->fields = std::move(ot.fields); - rtp->fields_cached = true; - } - if (ot.has_first_row) { - (void)rc->apply_open_row(rtp, ot.first_row); - open_warm = true; - } - } - // Mirror the local M-AOF.6 production-index auto-open over the - // wire: opening .dbf binds .cdx (or .adi for - // ADT) when the server has it, so rddads / X# see the production - // tags as navigable orders -- and DbSetOrder(n) resolves them -- - // without an explicit AdsOpenIndex. - // - // The server's OpenTableAck may include the production bag path - // (stat-only, no ABI). Use it when available; otherwise derive - // it from the table name as a fallback. - // - // Release s.mu first: AdsOpenIndex issues a wire round-trip whose - // server handler (in-process for local serverd / tests) re-enters - // the ABI and takes s.mu on another thread. Holding it across the - // blocking call would deadlock. The client AdsOpenIndex re-locks - // for its own bookkeeping after the wire reply, which is fine. - lk.unlock(); - { - std::vector bags; - // Prefer the bag path the server confirmed exists. - if (!ot.prod_bag_path.empty()) { - bags = {ot.prod_bag_path}; - } else { - // Fallback: derive from the table name. Vouch/ERP apps - // keep the CDX-format production bag as .z01, so - // try .cdx first then .z01 (old servers never send - // prod_bag_path; new servers already confirmed above). - std::filesystem::path tp(name); - std::string ext = tp.extension().string(); - for (auto& c : ext) - c = static_cast(std::tolower( - static_cast(c))); - if (ext == ".dbf") bags = {tp.stem().string() + ".cdx", - tp.stem().string() + ".z01"}; - else if (ext == ".adt") bags = {tp.stem().string() + ".adi"}; - } - for (const auto& bag : bags) { - std::vector b(bag.size() + 1); - std::memcpy(b.data(), bag.data(), bag.size()); - ADSHANDLE arr[64] = {0}; - UNSIGNED16 alen = 64; - UNSIGNED32 orc = AdsOpenIndex(to_ads_handle(gh), b.data(), arr, &alen); - if (orc == 0) break; // .z01 hit after .cdx 5018, or first hit - if (!ot.prod_bag_path.empty()) break; // confirmed path: don't retry - } - // ADS semantics: auto-opening the production index leaves - // the controlling order natural (0) until DbSetOrder picks - // one. AdsOpenIndex optimistically marks the first tag - // active; undo that so the first nav-by-index actually - // sends SetOrder to the server. - if (auto* rtp = get_remote_table(to_ads_handle(gh))) { - rtp->active_index_id = 0; - // Store the confirmed production bag path so - // AdsGetIndexFilename / OrdBagName works locally. - if (!ot.prod_bag_path.empty()) - rtp->prod_bag_path = ot.prod_bag_path; - } - } - // Implicit GoTop in REMOTE mode: after the production CDX - // auto-open the client has no record buffer yet. LOCAL mode - // leaves the cursor at BOF with a valid buffer; REMOTE leaves - // the buffer empty, so AdsGetField / AdsGetRecordCount etc. - // would read uninitialized memory. One extra round-trip on - // table open positions the cursor on record 1 and populates - // the record cache, matching LOCAL semantics. - // - // Warm opens skip the round-trip: the ack already positioned - // the cursor and populated the cache identically. Mirror - // AdsGotoTop's local tail (found flags, keyno seed, relations). - if (open_warm) { - if (auto* rtp = get_remote_table(to_ads_handle(gh))) { - rtp->found_cached = true; rtp->current_found = false; - remote_sync_keyno_gototop(rtp); - // Warm sections positioned the cursor at top with no - // frame: stamp it so the app's immediate GoTop probing - // dedupes like a real wire nav. - remote_nav_stamp(rtp, 1, rtp->server_order_id); - } - apply_relations_for_handle(to_ads_handle(gh)); - } else if (get_remote_table(to_ads_handle(gh)) != nullptr) { - (void)AdsGotoTop(to_ads_handle(gh)); - } - return ok(); - } - // Explicit handle to a disconnected remote connection, and no live - // remote connection to fall back to: fail fast (SAP ADS semantics -- - // a disconnected handle returns an error immediately) instead of - // silently opening a LOCAL file through the cwd fallback below. - if (hConnect != 0 && rem_h == 0 && - s.registry.kind_of(hConnect) == HandleKind::RemoteConnection) { - return fail(openads::AE_NO_CONNECTION, "connection is closed"); - } -#if defined(OPENADS_WITH_SQLITE) - if (auto* sc = s.registry.lookup( - hConnect, HandleKind::SqliteConnection)) { - auto name = openads::abi::to_internal(pucName, 0); - std::string sys_suffix; - const bool is_sys = sql_uri_system_suffix(name, sys_suffix); - if (!is_sys && usCheckRights != 0) { - const openads::sql_backend::SqlQueryFn qfn = - [sc](const std::string& sql) { - return sqlite_acl_query(sc, sql); - }; - if (sql_uri_table_denied(hConnect, usCheckRights, usMode, name, - openads::sql_backend::SqlDdlDialect::Sqlite, qfn)) { - return fail(openads::AE_ACCESS_DENIED, name.c_str()); - } - } - if (is_sys) { - auto catalog = openads::sql_backend::build_system_catalog_sql( - openads::sql_backend::SqlDdlDialect::Sqlite, sys_suffix); - if (catalog) { - auto cur = sc->run_sql(*catalog); - if (!cur) return fail(cur.error()); - auto st = std::move(cur).value(); - if (!st) { - return fail(openads::AE_NO_FILE_FOUND, name.c_str()); - } - st->conn = sc; - Handle gh = s.registry.register_object( - HandleKind::SqliteTable, st.get()); - sqlite_tables_map().emplace(gh, std::move(st)); - *phTable = to_ads_handle(gh); - return ok(); - } - } - if (is_sys) { - return fail(openads::AE_NO_FILE_FOUND, name.c_str()); - } - auto tbl = sc->open_table(name); - if (!tbl) return fail(tbl.error()); - auto st = std::move(tbl).value(); - st->conn = sc; - sql_uri_bind_table_acl(st.get(), hConnect, usCheckRights); - Handle gh = s.registry.register_object( - HandleKind::SqliteTable, st.get()); - sqlite_tables_map().emplace(gh, std::move(st)); - *phTable = to_ads_handle(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); - if (usCheckRights != 0) { - const openads::sql_backend::SqlQueryFn qfn = - [sc](const std::string& sql) { - return odbc_acl_query(sc, sql); - }; - if (sql_uri_table_denied(hConnect, usCheckRights, usMode, name, - odbc_dialect_for(hConnect), qfn)) { - return fail(openads::AE_ACCESS_DENIED, name.c_str()); - } - } - auto tbl = sc->open_table(name); - if (!tbl) return fail(tbl.error()); - auto st = std::move(tbl).value(); - st->conn = sc; - sql_uri_bind_table_acl(st.get(), hConnect, usCheckRights); - Handle gh = s.registry.register_object( - HandleKind::OdbcTable, st.get()); - odbc_tables_map().emplace(gh, std::move(st)); - *phTable = to_ads_handle(gh); - return ok(); - } -#endif -#if defined(OPENADS_WITH_MSSQL) - if (auto* mc = s.registry.lookup( - hConnect, HandleKind::MssqlConnection)) { - auto name = openads::abi::to_internal(pucName, 0); - std::string sys_suffix; - const bool is_sys = sql_uri_system_suffix(name, sys_suffix); - if (!is_sys && usCheckRights != 0) { - const openads::sql_backend::SqlQueryFn qfn = - [mc](const std::string& sql) { - return mssql_acl_query(mc, sql); - }; - if (sql_uri_table_denied(hConnect, usCheckRights, usMode, name, - openads::sql_backend::SqlDdlDialect::Mssql, qfn)) { - return fail(openads::AE_ACCESS_DENIED, name.c_str()); - } - } - if (is_sys) { - auto catalog = openads::sql_backend::build_system_catalog_sql( - openads::sql_backend::SqlDdlDialect::Mssql, sys_suffix); - if (catalog) { - auto qr = mc->query(*catalog); - if (!qr) return fail(qr.error()); - openads::sql_backend::tds::QueryResult result = - std::move(qr).value(); - if (!result.ok) { - return fail(static_cast(result.error_number), - result.message.c_str()); - } - auto st = openads::sql_backend::MssqlTable::from_result( - std::move(result)); - Handle gh = s.registry.register_object( - HandleKind::MssqlTable, st.get()); - mssql_tables_map().emplace(gh, std::move(st)); - *phTable = to_ads_handle(gh); - return ok(); - } - return fail(openads::AE_NO_FILE_FOUND, name.c_str()); - } - auto tbl = openads::sql_backend::MssqlTable::open(*mc, name); - if (!tbl) return fail(tbl.error()); - auto st = std::move(tbl).value(); - st->conn = mc; - st->name = name; - st->sql_table = name; - sql_uri_bind_table_acl(st.get(), hConnect, usCheckRights); - if (auto pk = mc->discover_pk(name); pk) { - for (const std::string& col : pk.value()) { - for (std::size_t i = 0; i < st->field_count(); ++i) { - const std::string& fn = st->field_name(i); - if (fn.size() == col.size()) { - bool match = true; - for (std::size_t j = 0; j < fn.size(); ++j) { - if (std::tolower(static_cast(fn[j])) != - std::tolower(static_cast(col[j]))) { - match = false; - break; - } - } - if (match) { - st->pk_cols.push_back(i); - break; - } - } - } - } - } - Handle gh = s.registry.register_object( - HandleKind::MssqlTable, st.get()); - mssql_tables_map().emplace(gh, std::move(st)); - *phTable = to_ads_handle(gh); - return ok(); - } -#endif -#if defined(OPENADS_WITH_FIREBIRD) - if (auto* sc = s.registry.lookup( - hConnect, HandleKind::FirebirdConnection)) { - auto name = openads::abi::to_internal(pucName, 0); - std::string sys_suffix; - const bool is_sys = sql_uri_system_suffix(name, sys_suffix); - if (!is_sys && usCheckRights != 0) { - const openads::sql_backend::SqlQueryFn qfn = - [sc](const std::string& sql) { - return firebird_acl_query(sc, sql); - }; - if (sql_uri_table_denied(hConnect, usCheckRights, usMode, name, - openads::sql_backend::SqlDdlDialect::Firebird, qfn)) { - return fail(openads::AE_ACCESS_DENIED, name.c_str()); - } - } - if (is_sys) { - auto catalog = openads::sql_backend::build_system_catalog_sql( - openads::sql_backend::SqlDdlDialect::Firebird, sys_suffix); - if (catalog) { - auto cur = sc->run_sql(*catalog); - if (!cur) return fail(cur.error()); - auto st = std::move(cur).value(); - if (!st) { - return fail(openads::AE_NO_FILE_FOUND, name.c_str()); - } - st->conn = sc; - Handle gh = s.registry.register_object( - HandleKind::FirebirdTable, st.get()); - firebird_tables_map().emplace(gh, std::move(st)); - *phTable = to_ads_handle(gh); - return ok(); - } - return fail(openads::AE_NO_FILE_FOUND, name.c_str()); - } - auto tbl = sc->open_table(name); - if (!tbl) return fail(tbl.error()); - auto st = std::move(tbl).value(); - st->conn = sc; - sql_uri_bind_table_acl(st.get(), hConnect, usCheckRights); - Handle gh = s.registry.register_object( - HandleKind::FirebirdTable, st.get()); - firebird_tables_map().emplace(gh, std::move(st)); - *phTable = to_ads_handle(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); - std::string sys_suffix; - const bool is_sys = sql_uri_system_suffix(name, sys_suffix); - if (!is_sys && usCheckRights != 0) { - const openads::sql_backend::SqlQueryFn qfn = - [sc](const std::string& sql) { - return maria_acl_query(sc, sql); - }; - if (sql_uri_table_denied(hConnect, usCheckRights, usMode, name, - openads::sql_backend::SqlDdlDialect::Maria, qfn)) { - return fail(openads::AE_ACCESS_DENIED, name.c_str()); - } - } - if (is_sys) { - auto catalog = openads::sql_backend::build_system_catalog_sql( - openads::sql_backend::SqlDdlDialect::Maria, sys_suffix); - if (catalog) { - auto cur = sc->run_sql(*catalog); - if (!cur) return fail(cur.error()); - auto st = std::move(cur).value(); - if (!st) { - return fail(openads::AE_NO_FILE_FOUND, name.c_str()); - } - st->conn = sc; - Handle gh = s.registry.register_object( - HandleKind::MariaTable, st.get()); - maria_tables_map().emplace(gh, std::move(st)); - *phTable = to_ads_handle(gh); - return ok(); - } - return fail(openads::AE_NO_FILE_FOUND, name.c_str()); - } - auto tbl = sc->open_table(name); - if (!tbl) return fail(tbl.error()); - auto st = std::move(tbl).value(); - st->conn = sc; - sql_uri_bind_table_acl(st.get(), hConnect, usCheckRights); - Handle gh = s.registry.register_object( - HandleKind::MariaTable, st.get()); - maria_tables_map().emplace(gh, std::move(st)); - *phTable = to_ads_handle(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); - std::string sys_suffix; - const bool is_sys = sql_uri_system_suffix(name, sys_suffix); - if (!is_sys && usCheckRights != 0) { - const openads::sql_backend::SqlQueryFn qfn = - [sc](const std::string& sql) { - return postgres_acl_query(sc, sql); - }; - if (sql_uri_table_denied(hConnect, usCheckRights, usMode, name, - openads::sql_backend::SqlDdlDialect::Postgres, qfn)) { - return fail(openads::AE_ACCESS_DENIED, name.c_str()); - } - } - if (is_sys) { - auto catalog = openads::sql_backend::build_system_catalog_sql( - openads::sql_backend::SqlDdlDialect::Postgres, sys_suffix); - if (catalog) { - auto cur = sc->run_sql(*catalog); - if (!cur) return fail(cur.error()); - auto st = std::move(cur).value(); - if (!st) { - return fail(openads::AE_NO_FILE_FOUND, name.c_str()); - } - st->conn = sc; - Handle gh = s.registry.register_object( - HandleKind::PostgresTable, st.get()); - postgres_tables_map().emplace(gh, std::move(st)); - *phTable = to_ads_handle(gh); - return ok(); - } - return fail(openads::AE_NO_FILE_FOUND, name.c_str()); - } - auto tbl = sc->open_table(name); - if (!tbl) return fail(tbl.error()); - auto st = std::move(tbl).value(); - st->conn = sc; - sql_uri_bind_table_acl(st.get(), hConnect, usCheckRights); - Handle gh = s.registry.register_object( - HandleKind::PostgresTable, st.get()); - postgres_tables_map().emplace(gh, std::move(st)); - *phTable = to_ads_handle(gh); - return ok(); - } -#endif - auto* conn = s.registry.lookup(hConnect, HandleKind::Connection); - if (conn == nullptr) { - ADSHANDLE def = get_or_create_default_connection(); - conn = s.registry.lookup(def, HandleKind::Connection); - if (conn == nullptr) { - return fail(openads::AE_INVALID_CONNECTION_HANDLE, - "unknown connection"); - } - } - // Remote-only deployments: a LOCAL open here means the app bypassed - // the server with a legacy local path -- deny (RTE) or audit it, - // never touch disk silently. - const int roa_mode = remote_only_access_mode(); - if (roa_mode == 1) { - return fail(openads::AE_ACCESS_DENIED, - "local table open blocked by OPENADS_REMOTE_ONLY_ACCESS"); - } - if (roa_mode == 2) { - auto nm = openads::abi::to_internal(pucName, 0); - openads::util::write_local_access_audit("OPEN", nm); - } - auto name = openads::abi::to_internal(pucName, 0); - // View alias expansion: if the requested name matches a DD view, execute - // the view's SQL and return the resulting cursor as the table handle. - if (conn->has_dd()) { - std::string uname = name; - for (auto& ch : uname) ch = static_cast( - std::toupper(static_cast(ch))); - for (const auto& kv : conn->dd()->views()) { - std::string vn = kv.first; - for (auto& ch : vn) ch = static_cast( - std::toupper(static_cast(ch))); - if (vn == uname) { - ADSHANDLE stmt_h = 0; - UNSIGNED32 rc = AdsCreateSQLStatement(hConnect, &stmt_h); - if (rc != 0) return rc; - const std::string& vsql = kv.second.sql; - std::vector sqlbuf(vsql.size() + 1); - std::memcpy(sqlbuf.data(), vsql.data(), vsql.size()); - rc = AdsExecuteSQLDirect(stmt_h, sqlbuf.data(), phTable); - AdsCloseSQLStatement(stmt_h); - return rc; - } - } - } - // Per-table ACL check: when usCheckRights is non-zero and the connection - // has an authenticated user with a DD ACL for this table, verify the - // Check per-operation permissions for the open mode requested. - if (usCheckRights != 0 && conn->has_dd() && !conn->username().empty()) { - std::string alias = name_to_alias(conn->dd(), name); - if (!alias.empty()) { - auto ops = eff_ops(conn, alias); - bool denied = (usMode == ADS_READONLY) ? !ops.select_ - : !ops.update_ && !ops.insert_; - if (denied) - return fail(openads::AE_ACCESS_DENIED, alias.c_str()); - } - } - auto th = conn->open_table(name, map_type(usTableType), - map_open_mode(usMode), - map_locking_mode(usLockType)); - if (!th) return fail(th.error()); - Table* tbl = conn->lookup_table(th.value()); - Handle gh = s.registry.register_object(HandleKind::Table, tbl); - *phTable = to_ads_handle(gh); - - // Set the table alias: honour the caller's pucAlias when provided - // (Harbour USE ... ALIAS xxx), otherwise derive from the filename stem. - { - namespace fs = std::filesystem; - std::string alias; - if (pucAlias && pucAlias[0] != '\0') { - alias = openads::abi::to_internal(pucAlias, 0); - } - if (alias.empty()) { - alias = fs::path(name).stem().string(); - } - tbl->set_alias(std::move(alias)); - } - - // RCB 2026-07-10 -- record the caller's usCharType on the Table, and - // do it BEFORE the production-index auto-open below: AdsOpenIndex → - // apply_cdx_oem_collation reads it to stamp the effective OEM sort - // table onto every tag. usCharType=ADS_OEM is the ONLY signal a - // Harbour rddads app gives that its data is OEM (AdsSetCharType() - // just feeds this parameter; rddads never calls AdsSetCollation), so - // ignoring it -- as this function did before v1.8.9 -- makes PL852 - // support unreachable for rddads and reintroduces the v1.8.6/v1.8.7 - // not-found seeks on Polish keys (#130 follow-up). Do not remove and - // do not move below the AdsOpenIndex calls. ADS_DEFAULT (0) keeps - // the ANSI default. - if (usCharType == ADS_ANSI || usCharType == ADS_OEM) - tbl->set_char_type(usCharType); - - // Release s.mu before production-index auto-open (AdsOpenIndex does - // file I/O + may take index_bindings_mu). Holding the registry mutex - // across it serialises every USE behind INDEX ON and can deadlock. - lk.unlock(); - - // M-AOF.6 -- production-CDX auto-open. ADS / rddads convention: - // opening `.dbf` auto-binds `.cdx` if it exists, so - // every tag inside it becomes navigable on this Table without - // an explicit AdsOpenIndex60 call. Without this, the AOF - // matcher in evaluate_optimised() never finds the index and - // every leaf falls back to the per-record evaluation -- - // AdsGetAOFOptLevel reports NONE forever even after a - // CREATE INDEX SQL ran in a prior session. - namespace fs = std::filesystem; - fs::path tp(tbl->path()); - if (tp.extension() == ".dbf" || tp.extension() == ".DBF") { - // Vouch/ERP: production bag may be .z01 (CDX format). - for (const char* ext : {".cdx", ".z01"}) { - fs::path bag = tp; bag.replace_extension(ext); - std::error_code ec; - std::string bag_path = - openads::platform::resolve_case_insensitive(bag.string()); - if (fs::exists(bag_path, ec)) { - std::vector b(bag_path.size() + 1); - std::memcpy(b.data(), bag_path.data(), bag_path.size()); - // Up to 64 tag handles is plenty for a production bag. - ADSHANDLE arr[64] = {0}; - UNSIGNED16 alen = 64; - if (AdsOpenIndex(to_ads_handle(gh), b.data(), arr, &alen) == 0) break; - } - } - } - // ADI auto-open: same convention for ADT tables -- opening `.adt` - // (or `.dat`, the Russoft ERP convention of keeping ADT data in - // .DAT + .ADI per ARC-CAJA) auto-binds `.adi` if it exists, so every - // tag inside it becomes navigable without an explicit AdsOpenIndex call. - std::string tp_extl = tp.extension().string(); - for (auto& ch : tp_extl) - ch = static_cast(std::tolower(static_cast(ch))); - if (tp_extl == ".adt" || tp_extl == ".dat") { - fs::path adi = tp; adi.replace_extension(".adi"); - std::error_code ec; - std::string adi_path = - openads::platform::resolve_case_insensitive(adi.string()); - if (fs::exists(adi_path, ec)) { - std::string adis = adi_path; - std::vector b(adis.size() + 1); - std::memcpy(b.data(), adis.data(), adis.size()); - ADSHANDLE arr[64] = {0}; - UNSIGNED16 alen = 64; - (void)AdsOpenIndex(to_ads_handle(gh), b.data(), arr, &alen); - } - } - // ADS semantics: auto-opening the production index leaves the - // controlling order natural (0) until DbSetOrder picks one. - // AdsOpenIndex optimistically marks the first tag active; park it - // back so dbGoBottom()/dbSkip() walk natural record order until the - // app selects a tag. Mirrors the remote path, which resets - // RemoteTable::active_index_id to 0 after the same auto-open. - // (Pritpal Bedi: dbGoBottom() landed on the last index key instead - // of LastRec() after a plain USE via ADSCDX.) - park_active_order(tbl); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsOpenTable101(ADSHANDLE hConnect, UNSIGNED8* pucName, - ADSHANDLE* phTable) { - arc2_trace("AdsOpenTable101"); - Connect101OpenOptions opts; - { - auto& s = state(); - std::lock_guard lk(s.mu); - auto it = connect101_open_options().find(hConnect); - if (it != connect101_open_options().end()) opts = it->second; - } - if (opts.table_type == ADS_DEFAULT) { - opts.table_type = infer_table_type_from_name(pucName); - } - return AdsOpenTable(hConnect, pucName, nullptr, opts.table_type, - opts.char_type, opts.lock_type, opts.check_rights, - opts.mode, phTable); -} - -UNSIGNED32 ENTRYPOINT AdsGetTableType(ADSHANDLE hTable, UNSIGNED16* pusType) { - arc2_trace("AdsGetTableType"); - if (pusType == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (auto* rt = get_remote_table(hTable)) { - // Immutable for an open handle: cache after the first hit - // (rddads asks per USE). - if (rt->table_type_cached) { - *pusType = rt->cached_table_type; - return ok(); - } - auto r = rt->conn->get_table_type(rt->id); - if (!r) return fail(r.error()); - *pusType = r.value(); - rt->cached_table_type = r.value(); - rt->table_type_cached = true; - arc2_log("TABLETYPE ret %u", (unsigned)*pusType); - return ok(); - } - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); - // Map our internal TableType back to ACE constants. We only own - // CDX and NTX today; the rest are out of scope for phase 1. - namespace fs = std::filesystem; - fs::path p(t->path()); - auto ext = p.extension().string(); - for (auto& c : ext) c = static_cast(std::tolower( - static_cast(c))); - if (ext == ".dbf") { - // Distinguishing CDX vs NTX vs VFP from a bare DBF requires - // probing the matching index file alongside it, which we - // don't do yet. Return CDX (the most common case). - *pusType = ADS_CDX; - } else if (ext == ".adt") { - *pusType = ADS_ADT; - } else { - *pusType = ADS_CDX; - } - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsGetTableLockType(ADSHANDLE hTable, - UNSIGNED16* pusLockType) { - arc2_trace("AdsGetTableLockType"); - if (pusLockType == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (get_remote_table(hTable) != nullptr) { - *pusLockType = ADS_COMPATIBLE_LOCKING; - return ok(); - } - if (openads::abi::backend_table_ops_for(hTable) != nullptr) { - *pusLockType = ADS_COMPATIBLE_LOCKING; - return ok(); - } - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); - *pusLockType = - (t->locking_mode() == openads::engine::LockingMode::Proprietary) - ? ADS_PROPRIETARY_LOCKING - : ADS_COMPATIBLE_LOCKING; - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsGetTableFilename(ADSHANDLE hTable, UNSIGNED16 /*usOption*/, - UNSIGNED8* pucBuf, UNSIGNED16* pusLen) { - arc2_trace("AdsGetTableFilename"); - if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (auto* rt = get_remote_table(hTable)) { - openads::abi::copy_to_caller(pucBuf, pusLen, rt->name); - return ok(); - } - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); - openads::abi::copy_to_caller(pucBuf, pusLen, t->path()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsGetRecordLength(ADSHANDLE hTable, UNSIGNED32* pulLen) { - arc2_trace("AdsGetRecordLength"); - if (pulLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (auto* rt = get_remote_table(hTable)) { - // Immutable for an open handle (only a restructure alters it, - // and that closes/reopens): cache after the first hit. - if (rt->record_length_cached) { - *pulLen = rt->cached_record_length; - return ok(); - } - // Re-open of a table already measured on this connection with - // the identical schema: same file layout, same length. - const std::string memo_key = remote_record_length_key(rt); - if (!memo_key.empty() && - rt->conn->recall_record_length(memo_key, - rt->cached_record_length)) { - rt->record_length_cached = true; - *pulLen = rt->cached_record_length; - return ok(); - } - auto r = rt->conn->get_record_length(rt->id); - if (!r) return fail(r.error()); - *pulLen = r.value(); - rt->cached_record_length = r.value(); - rt->record_length_cached = true; - if (!memo_key.empty()) - rt->conn->remember_record_length(memo_key, r.value()); - return ok(); - } - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); - if (t->driver() == nullptr) { *pulLen = 0; return ok(); } - *pulLen = t->driver()->record_length(); - return ok(); -} - -extern "C++" { -namespace { - -// M10.33 -- standard SQL LIKE pattern. `%` matches any sequence -// (including empty), `_` matches a single character. Greedy match -// with backtracking -- adequate for short DBF cells. -static inline bool sql_like_match(const std::string& s, - const std::string& pat) { - std::size_t si = 0, pi = 0; - std::size_t star = std::string::npos, ss = 0; - while (si < s.size()) { - if (pi < pat.size() && - (pat[pi] == '_' || pat[pi] == s[si])) { - ++si; ++pi; - } else if (pi < pat.size() && pat[pi] == '%') { - star = pi++; - ss = si; - } else if (star != std::string::npos) { - pi = star + 1; - si = ++ss; - } else { - return false; - } - } - while (pi < pat.size() && pat[pi] == '%') ++pi; - return pi == pat.size(); -} - -// LIKE with optional case folding -- CICHAR columns match LIKE -// case-insensitively (oracle-verified), plain CHAR byte-wise. -static inline bool sql_like_match_t(const std::string& s, - const std::string& pat, bool ci) { - if (!ci) return sql_like_match(s, pat); - std::string s2 = s, p2 = pat; - for (char& ch : s2) - ch = static_cast(std::toupper(static_cast(ch))); - for (char& ch : p2) - ch = static_cast(std::toupper(static_cast(ch))); - return sql_like_match(s2, p2); -} - -// S4 -- case sensitivity is a FIELD-TYPE property (oracle-verified on -// pmsys): CICHAR (ADT type 20) columns compare case-insensitively in -// `=`, LIKE, BETWEEN and IN; plain CHAR compares byte-wise. SAP's -// system.* catalog columns are CICHAR, which is why -// `WHERE Parent = 'PROPERTIES'` matches 'properties' there. -// PAD SPACE semantics (oracle-verified: `Parent = 'PROPERTIES '` and -// `address = '533 Hickory Blvd '` both match on SAP): trailing blanks -// are insignificant on BOTH sides of a string comparison, CHAR and -// CICHAR alike. -static inline int where_str_cmp(const std::string& a, const std::string& b, - bool ci) { - std::size_t alen = a.size(), blen = b.size(); - while (alen > 0 && a[alen - 1] == ' ') --alen; - while (blen > 0 && b[blen - 1] == ' ') --blen; - std::size_t n = std::min(alen, blen); - for (std::size_t i = 0; i < n; ++i) { - int ca = static_cast(a[i]); - int cb = static_cast(b[i]); - if (ci) { ca = std::toupper(ca); cb = std::toupper(cb); } - if (ca != cb) return ca < cb ? -1 : 1; - } - if (alen == blen) return 0; - return alen < blen ? -1 : 1; -} - -static inline bool field_is_cichar(const openads::engine::Table& t, - std::uint16_t fi) { - return t.field_descriptor(fi).type == - openads::drivers::DbfFieldType::CiCharacter; -} - -// ADS dialect -- apply the optional UPPER()/LOWER() case-fold from a -// WHERE left-hand side to a cell value before it is compared. A no-op -// for WhereFn::None, so callers can apply it unconditionally. -static inline std::string apply_where_fn(std::string s, - openads::sql::WhereFn fn) { - if (fn == openads::sql::WhereFn::Upper) { - for (char& ch : s) - ch = static_cast(std::toupper(static_cast(ch))); - } else if (fn == openads::sql::WhereFn::Lower) { - for (char& ch : s) - ch = static_cast(std::tolower(static_cast(ch))); - } - return s; -} - -// Map an rddads type name (Character, Numeric, Date, ...) to the -// DBF field-descriptor type byte plus a default length / decimals -// when those aren't explicit in the field-def string. -struct DbfTypeSpec { - char type = 'C'; - std::uint8_t length = 0; - std::uint8_t dec = 0; - bool needs_memo = false; -}; - -DbfTypeSpec dbf_type_for(const std::string& name) { - auto eq = [&](const char* k) { - if (name.size() != std::strlen(k)) return false; - for (std::size_t i = 0; i < name.size(); ++i) { - char a = static_cast(std::tolower( - static_cast(name[i]))); - char b = static_cast(std::tolower( - static_cast(k[i]))); - if (a != b) return false; - } - return true; - }; - // Single-letter DBF type codes (the dBASE convention every - // raw-DBF tool uses). Both the rddads verbose names AND the - // one-letter codes flow through here so callers can pick - // either style. - if (name.size() == 1) { - char c = static_cast(std::toupper( - static_cast(name[0]))); - switch (c) { - case 'C': return {'C', 0, 0, false}; - case 'N': return {'N', 0, 0, false}; - case 'L': return {'L', 1, 0, false}; - case 'D': return {'D', 8, 0, false}; - case 'M': return {'M', 10, 0, true }; - case 'I': return {'N', 0, 0, false}; // integer-as-text - case 'Y': return {'N', 0, 2, false}; // currency-as-text - case 'B': return {'N', 0, 0, false}; // double-as-text - case 'V': return {'V', 0, 0, false}; // varchar (M11.1) - case 'Q': return {'Q', 0, 0, false}; // varbinary (M11.1) - case 'T': return {'C',23, 0, false}; // ISO-8601 fallback - default: break; - } - } - if (eq("Character") || eq("Char")) - return {'C', 0, 0, false}; - if (eq("Numeric") || eq("Long") || eq("Number")) - return {'N', 0, 0, false}; - if (eq("Logical") || eq("Bool")) - return {'L', 1, 0, false}; - if (eq("Date") || eq("ShortDate")) - return {'D', 8, 0, false}; - if (eq("Memo") || eq("NMemo")) - return {'M', 10, 0, true}; - if (eq("Binary")) - return {'Q', 9, 0, true}; - if (eq("Image")) - return {'I', 9, 0, true}; - if (eq("Integer") || eq("LongLong")) - return {'N', 0, 0, false}; - if (eq("Double") || eq("CurDouble")) - return {'N', 0, 0, false}; - if (eq("ModTime")) - return {'C', 23, 0, false}; // store as ISO-8601 string for now - // ── ADT-specific type names: use sentinel chars handled by adt_spec_for ── - if (eq("CICHARACTER") || eq("CiCharacter") || eq("CICHAR")) - return {'W', 0, 0, false}; // ADT type 20: case-insensitive char - if (eq("ShortInt") || eq("SmallInt") || eq("SMALLINT")) - return {'S', 2, 0, false}; // ADT type 12: 2-byte int16 - if (eq("Money") || eq("Currency")) - return {'$', 8, 0, false}; // ADT type 18: 8-byte int64 * 10000 - if (eq("Timestamp")) - return {'P', 8, 0, false}; // ADT type 14: 8-byte JDN+ms - if (eq("AutoInc")) - return {'A', 4, 0, false}; // ADT type 15: 4-byte uint32 auto-increment - if (eq("Time")) - return {'Z', 4, 0, false}; // ADT type 13: 4-byte ms since midnight - // ADT type 2 -- numeric stored as ASCII digits, so the declared scale is - // part of the value ("10.50" stays "10.50"). Plain "Numeric" with decimals - // maps to ADT DOUBLE (type 10) instead, and a conforming ADT DOUBLE - // descriptor carries NO decimal count (see the fd[137] comment in the ADT - // writer -- stamping one makes the real ADS engine report the table corrupt, - // 7016), so a DOUBLE round-trips the value but not its formatting. Used by - // the SELECT materialisation path, which needs full-length column names - // (ADT) *and* the source's numeric scale. - if (eq("AsciiNumeric")) - return {'#', 0, 0, false}; - return {'C', 0, 0, false}; // unknown -> Character -} - -// Map a parsed field-type char to the byte a DBF field descriptor can carry. -// The ADT sentinels above ('W', 'S', '$', 'P', 'A', 'Z', '#') are internal -// markers, not DBF type codes -- writing one into fd[11] would produce a table -// no reader can classify. Collapse each to its closest DBF equivalent. -char dbf_descriptor_type_char(char t) { - switch (t) { - case 'W': return 'C'; // CICHARACTER -> Character - case 'S': return 'N'; // SHORTINT -> Numeric - case '$': return 'Y'; // MONEY -> Currency - case 'P': return 'T'; // TIMESTAMP -> DateTime - case 'A': return 'N'; // AUTOINC -> Numeric - case 'Z': return 'C'; // TIME -> Character - case '#': return 'N'; // ASCII numeric is exactly DBF's 'N' - default: return t; - } -} - -// Trim leading/trailing whitespace. -std::string trim(std::string s) { - while (!s.empty() && std::isspace( - static_cast(s.front()))) s.erase(s.begin()); - while (!s.empty() && std::isspace( - static_cast(s.back()))) s.pop_back(); - return s; -} - -// rddads `NAME,Type,Len,Dec;…` parser. Empty `defs` returns an empty -// vector. Used by AdsCreateTable (M9.5) and AdsRestructureTable (M9.26). -struct FieldOut { - std::string name; - char type = 'C'; - std::uint8_t length = 0; - std::uint8_t dec = 0; - bool nullable = false; - bool autoinc = false; -}; - -DbfTypeSpec vfp_type_for(const std::string& name) { - auto eq = [&](const char* k) { - if (name.size() != std::strlen(k)) return false; - for (std::size_t i = 0; i < name.size(); ++i) { - char a = static_cast(std::tolower( - static_cast(name[i]))); - char b = static_cast(std::tolower( - static_cast(k[i]))); - if (a != b) return false; - } - return true; - }; - if (name.size() == 1) { - char c = static_cast(std::toupper( - static_cast(name[0]))); - switch (c) { - case 'I': return {'I', 4, 0, false}; - case 'Y': return {'Y', 8, 0, false}; - case 'B': return {'B', 8, 0, false}; - case 'V': return {'V', 10, 0, false}; - case 'Q': return {'Q', 10, 0, false}; - case 'M': return {'M', 4, 0, true}; - default: break; - } - } - if (eq("Integer") || eq("LongLong")) - return {'I', 4, 0, false}; - if (eq("Currency") || eq("Money")) - return {'Y', 8, 0, false}; - if (eq("Double") || eq("CurDouble")) - return {'B', 8, 0, false}; - if (eq("Varchar")) - return {'V', 10, 0, false}; - if (eq("Varbinary")) - return {'Q', 10, 0, false}; - if (eq("AutoInc")) - return {'I', 4, 0, false}; - return dbf_type_for(name); -} - -std::vector parse_rddads_field_defs(const std::string& defs, - bool vfp = false) { - std::vector fields; - std::string buf; - auto flush = [&] { - if (buf.empty()) return; - std::vector parts; - std::string p; - for (char c2 : buf) { - if (c2 == ',') { parts.push_back(trim(p)); p.clear(); } - else p.push_back(c2); - } - parts.push_back(trim(p)); - if (parts.size() >= 2) { - DbfTypeSpec ts = vfp ? vfp_type_for(parts[1]) - : dbf_type_for(parts[1]); - FieldOut f; - f.name = parts[0]; - // Each write path enforces its own limit: - // DBF: std::min(name.size(), 10) at the header-write site - // ADT: std::min(name.size(), 127u) at the field-descriptor site - if (f.name.size() > 128) f.name.resize(128); - f.type = ts.type; - f.length = ts.length; - f.dec = ts.dec; - if (vfp && (parts[1] == "AutoInc" || parts[1] == "autoinc")) - f.autoinc = true; - if (parts.size() >= 3) { - int n = std::atoi(parts[2].c_str()); - if (n > 0 && n < 256) f.length = static_cast(n); - } - if (parts.size() >= 4) { - int d = std::atoi(parts[3].c_str()); - if (d >= 0 && d < 256) f.dec = static_cast(d); - } - if (parts.size() >= 5) { - std::string fl = parts[4]; - for (char& ch : fl) { - ch = static_cast(std::toupper( - static_cast(ch))); - } - if (fl == "NULL" || fl == "NULLABLE") f.nullable = true; - else if (fl == "AUTOINC" || fl == "AUTO") f.autoinc = true; - } - if (f.length == 0) f.length = 10; - fields.push_back(std::move(f)); - } - buf.clear(); - }; - for (std::size_t i = 0; i <= defs.size(); ++i) { - char ch = (i < defs.size()) ? defs[i] : ';'; - if (ch == ';') flush(); - else buf.push_back(ch); - } - return fields; -} - -// ADT field spec: ADT type code + fixed storage length for the creation path. -struct AdtFieldSpec { - std::uint16_t adt_type; - std::uint16_t adt_length; - std::uint8_t adt_dec; - bool needs_memo; -}; - -AdtFieldSpec adt_spec_for(const FieldOut& f) { - switch (f.type) { - case 'L': return { 1, 1, 0, false}; // LOGICAL - case 'D': return { 3, 4, 0, false}; // DATE (JDN uint32) - case 'M': return { 5, 9, 0, true }; // MEMO (9-byte ref) - case 'Q': return { 6, 9, 0, true }; // BINARY (9-byte ref) - case 'I': return { 7, 9, 0, true }; // IMAGE (9-byte ref) - case 'N': - if (f.dec > 0) return {10, 8, f.dec, false}; // DOUBLE - // INTEGER is a 4-byte int32, so it only holds up to 2,147,483,647. - // Mapping every decimal-less numeric to it silently destroyed any - // wider value: a DBF N(19,0) holding 5,000,000,000 read back as 0 - // -- not rounded, zero, with nothing to signal it. Only take that - // path when the declared width cannot overflow (9 digits max out at - // 999,999,999); anything wider goes to DOUBLE, which is exact for - // integers up to 2^53 (~9.0e15) and covers every realistic value in - // such a field. - if (f.length <= 9) return {11, 4, 0, false}; // INTEGER - return {10, 8, 0, false}; // DOUBLE - // ADT-specific sentinels from dbf_type_for: - case 'W': return {20, static_cast(f.length ? f.length : 10u), - 0, false}; // CICHARACTER - case 'S': return {12, 2, 0, false}; // SHORTINT - case '$': return {18, 8, 0, false}; // MONEY (int64 * 10000) - case 'P': return {14, 8, 0, false}; // TIMESTAMP (JDN+ms) - case 'A': return {15, 4, 0, false}; // AUTOINC - case 'Z': return {13, 4, 0, false}; // TIME (ms since midnight) - case '#': return { 2, static_cast(f.length ? f.length : 10u), - f.dec, false}; // NUMERIC as ASCII digits - case 'C': - default: - return { 4, static_cast(f.length ? f.length : 10u), - 0, false}; // CHAR - } -} - -} // namespace -} // extern "C++" - -// RCB 2026-07-10 -- usCharType is now honoured: it is forwarded to the -// AdsOpenTable call that hands the created table back, so an ADS_OEM -// creation gets its tags built under the configured OEM collation -// (#130 follow-up). Don't re-comment the parameter out. -UNSIGNED32 ENTRYPOINT AdsCreateTable(ADSHANDLE hConn, - UNSIGNED8* pucName, - UNSIGNED8* pucAlias, - UNSIGNED16 usTableType, - UNSIGNED16 usCharType, - UNSIGNED16 /*usLockType*/, - UNSIGNED16 /*usCheckRights*/, - UNSIGNED16 usMemoBlockSize, - UNSIGNED8* pucFields, - ADSHANDLE* phTable) { - arc2_trace("AdsCreateTable"); - if (pucName == nullptr || pucFields == nullptr || phTable == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, "null arg"); - } - const auto rel = openads::abi::to_internal(pucName, 0); - const auto defs = openads::abi::to_internal(pucFields, 0); - const bool is_vfp = (usTableType == ADS_VFP); - auto fields = parse_rddads_field_defs(defs, is_vfp); - if (fields.empty()) { - return fail(openads::AE_INTERNAL_ERROR, "no fields"); - } - - std::vector ddl_cols; - ddl_cols.reserve(fields.size()); - for (const auto& f : fields) { - ddl_cols.push_back({f.name, f.type, f.length, f.dec}); - } - auto sql_open_created = [&](ADSHANDLE conn_h) -> UNSIGNED32 { - std::vector namebuf(rel.size() + 1, 0); - std::memcpy(namebuf.data(), rel.data(), rel.size()); - return AdsOpenTable(conn_h, namebuf.data(), pucAlias, - ADS_CDX, usCharType, 0, 0, 1, phTable); - }; - auto run_sql_ddl = [&](auto* conn, - openads::sql_backend::SqlDdlDialect dialect) - -> UNSIGNED32 { - auto ddl = openads::sql_backend::build_create_table_ddl( - dialect, rel, ddl_cols); - if (!ddl) return fail(ddl.error()); - auto ex = conn->exec_sql(ddl.value()); - if (!ex) return fail(ex.error()); - return sql_open_created(hConn); - }; -#if defined(OPENADS_WITH_SQLITE) - if (auto* sc = get_sqlite_conn(hConn)) { - return run_sql_ddl(sc, openads::sql_backend::SqlDdlDialect::Sqlite); - } -#endif -#if defined(OPENADS_WITH_POSTGRESQL) - if (auto* pc = get_postgres_conn(hConn)) { - return run_sql_ddl(pc, openads::sql_backend::SqlDdlDialect::Postgres); - } -#endif -#if defined(OPENADS_WITH_MARIADB) - if (auto* mc = get_maria_conn(hConn)) { - return run_sql_ddl(mc, openads::sql_backend::SqlDdlDialect::Maria); - } -#endif -#if defined(OPENADS_WITH_ODBC) - if (auto* oc = get_odbc_conn(hConn)) { - return run_sql_ddl(oc, odbc_dialect_for(hConn)); - } -#endif -#if defined(OPENADS_WITH_FIREBIRD) - if (auto* fc = get_firebird_conn(hConn)) { - return run_sql_ddl(fc, openads::sql_backend::SqlDdlDialect::Firebird); - } -#endif -#if defined(OPENADS_WITH_MSSQL) - if (auto* msc = get_mssql_conn(hConn)) { - return run_sql_ddl(msc, openads::sql_backend::SqlDdlDialect::Mssql); - } -#endif - - // Remote connection: create on the server data directory over the wire. - // Without this branch AdsCreateTable falls through to a local cwd - // Connection (get_or_create_default_connection), so DbCreate / rddads - // writes the .dbf next to the client app while the subsequent open - // (remote AdsOpenTable) fails with AE_TABLE_CORRUPTED (5103). - // Same local-handle guard as AdsOpenTable: a valid local Connection - // (server ABI twin) must not be hijacked by an in-process tcp:// - // client connection. - { - ADSHANDLE rc_h = 0; - if (get_remote_connection(hConn) != nullptr) { - rc_h = hConn; - } else if (state().registry.lookup( - hConn, HandleKind::Connection) == nullptr) { - rc_h = resolve_remote_conn_handle(hConn); - } - if (auto* rc = get_remote_connection(rc_h)) { - // Create-over-existing must see closed files (SAP: overwrite - // when closed, 7040 when open) — a parked handle reads as open. - remote_flush_pools(rc); - auto cr = rc->create_table(rel, defs, - static_cast(usTableType), - static_cast(usCharType), - static_cast(usMemoBlockSize)); - if (!cr) return fail(cr.error()); - // Re-open via the normal remote path so production-bag auto-open - // and the implicit GotoTop match AdsOpenTable semantics. - std::vector namebuf(rel.size() + 1, 0); - if (!rel.empty()) - std::memcpy(namebuf.data(), rel.data(), rel.size()); - const UNSIGNED16 open_type = - (usTableType == ADS_ADT) ? ADS_ADT - : (usTableType == ADS_VFP) ? ADS_VFP - : ADS_CDX; - return AdsOpenTable(rc_h, namebuf.data(), pucAlias, - open_type, usCharType, 0, 0, 1, phTable); - } - // Explicit handle to a disconnected remote connection with no - // live remote to fall back to: fail fast rather than writing a - // LOCAL file next to the client app. - if (hConn != 0 && rc_h == 0) { - auto& s_dead = state(); - if (s_dead.registry.kind_of(hConn) == - HandleKind::RemoteConnection) { - return fail(openads::AE_NO_CONNECTION, - "connection is closed"); - } - } - } - - auto& s = state(); - Connection* c = s.registry.lookup(hConn, - HandleKind::Connection); - if (c == nullptr) { - ADSHANDLE def = get_or_create_default_connection(); - c = s.registry.lookup(def, HandleKind::Connection); - if (c == nullptr) { - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - } - } - // Same guard as AdsOpenTable: never CREATE a local file silently in - // a remote-only deployment (OPENADS_REMOTE_ONLY_ACCESS). - const int roa_mode = remote_only_access_mode(); - if (roa_mode == 1) { - return fail(openads::AE_ACCESS_DENIED, - "local table create blocked by OPENADS_REMOTE_ONLY_ACCESS"); - } - if (roa_mode == 2) { - openads::util::write_local_access_audit("CREATE", rel); - } - - namespace fs = std::filesystem; - // Resolve the create target through the connection's table resolver so - // the freshly-written file lands in the same place a later - // AdsOpenTable(name) will look for it (the ADS data directory), even - // when the caller passes an absolute / drive-rooted path. - openads::engine::TableType create_type = - (usTableType == ADS_ADT) ? openads::engine::TableType::Adt - : (usTableType == ADS_VFP) ? openads::engine::TableType::Vfp - : openads::engine::TableType::Cdx; - fs::path full(c->resolve_table_file(rel, create_type, - /*for_create=*/true)); - const bool is_adt = (usTableType == ADS_ADT); - if (!full.has_extension()) full.replace_extension(is_adt ? ".adt" : ".dbf"); - - if (is_adt) { - // ── ADT creation path ─────────────────────────────────────────────── - // Respect the file name the caller asked for -- real ACE creates - // exactly that file. Forcing ".adt" breaks any application that keeps - // ADT data under another extension (ExtFile='.DAT' is a common ERP - // convention): COPY TO "CONSEINV.DAT" VIA "ADS" silently produced - // CONSEINV.adt and the application could not find the table it had - // just written. An extension-less name still defaults above. - - std::vector specs; - specs.reserve(fields.size()); - // Header offset 88 holds the count of DISTINCT companion-stream types - // present in the table (memo / binary / image), each stored in the side - // companion file. Stamping a flat 1 whenever any memo field existed made - // a conforming reader reject tables that mix several companion types (it - // expects N distinct streams but the header claims one) -- the table was - // reported as corrupt. Count the distinct types actually present. - bool has_memo = false, has_binary = false, has_image = false; - for (auto& f : fields) { - AdtFieldSpec sp = adt_spec_for(f); - switch (sp.adt_type) { - case ADS_MEMO: has_memo = true; break; // .adm companion - case ADS_BINARY: has_binary = true; break; // .adm companion - case ADS_IMAGE: has_image = true; break; // .adm companion - default: break; - } - specs.push_back(sp); - } - const bool has_companion = has_memo || has_binary || has_image; - - // Record: 5-byte prefix (delete-flag byte + 4-byte null bitmap) + fields - std::uint32_t rec_len = 5; - for (auto& sp : specs) rec_len += sp.adt_length; - if (rec_len > 0xFFFFu) - return fail(openads::AE_INTERNAL_ERROR, "ADT record too long"); - - std::uint32_t hdr_len = 400u + - static_cast(fields.size()) * 200u; - - // 400-byte file header - std::vector adt_hdr(400, 0); - std::memcpy(adt_hdr.data(), "Advantage Table", 15); - auto w32 = [&](std::size_t off, std::uint32_t v) { - adt_hdr[off+0] = static_cast(v); - adt_hdr[off+1] = static_cast(v >> 8); - adt_hdr[off+2] = static_cast(v >> 16); - adt_hdr[off+3] = static_cast(v >> 24); - }; - w32(24, 0); // rec_count = 0 - w32(32, hdr_len); // hdr_len - w32(36, rec_len); // rec_len - // Proprietary header tail observed in reference fixtures. - adt_hdr[20] = 1; - adt_hdr[356] = 4; - adt_hdr[358] = static_cast(fields.size() & 0xFFu); - adt_hdr[359] = static_cast((fields.size() >> 8) & 0xFFu); - adt_hdr[88] = static_cast( - has_memo + has_binary + has_image); - - // 200-byte field descriptors - std::vector fds(fields.size() * 200, 0); - std::uint16_t fld_off = 5; // first field starts after the 5-byte prefix - for (std::size_t i = 0; i < fields.size(); ++i) { - const auto& f = fields[i]; - const auto& sp = specs[i]; - std::uint8_t* fd = fds.data() + i * 200; - // name: null-terminated, bytes 0-127 - std::size_t n = std::min(f.name.size(), 127u); - std::memcpy(fd, f.name.data(), n); - // fd[128] = flags (0 = not nullable) - fd[129] = static_cast(sp.adt_type & 0xFFu); - fd[130] = static_cast((sp.adt_type >> 8) & 0xFFu); - fd[131] = static_cast(fld_off & 0xFFu); - fd[132] = static_cast((fld_off >> 8) & 0xFFu); - fd[135] = static_cast(sp.adt_length & 0xFFu); - fd[136] = static_cast((sp.adt_length >> 8) & 0xFFu); - // DOUBLE (type 10) is an IEEE 8-byte binary value: the real ADS - // engine stores NO decimal count in its field descriptor (fd[137] - // and fd[139] stay 0). Stamping the Harbour decimals there made the - // engine reject the whole table as corrupt (error 7016). The value - // is full-precision binary, so dropping the descriptor "decimals" - // is loss-free and matches the ADT on-disk layout a conforming - // reader expects. - // - // For NUMERIC (type 2) the scale goes at byte 139, not 137 -- that - // is where the real engine puts it. Verified byte-for-byte against - // SAP-written tables (mp corpus, service.adt): an N(10,2) column - // reads …135:10 136:0 137:0 138:0 139:2 140:0. We keep writing 137 - // as well so a table written here stays readable by older OpenADS - // builds, which only looked at 137; SAP ignores it. - // - // 139 is also the AUTOINC counter slot, but only for type 15, and - // an autoinc field has no scale -- so the two uses never collide. - // The reader in adt_driver.cpp mirrors this and only consults 139 - // for type 2, precisely so a populated counter is never mistaken - // for a decimal count. - if (sp.adt_type != 10u) - fd[137] = sp.adt_dec; - if (sp.adt_type == 2u) - fd[139] = sp.adt_dec; - // AUTOINC tail (bytes 139-143) stays zero on disk for type 15; - // the counter is seeded from existing data when the table opens. - fld_off = static_cast(fld_off + sp.adt_length); - } - - // Write the .adt file under the per-path create lock with an - // exclusive (share=0) handle: a concurrent opener can never see a - // partial header, and a create over an OPEN table fails 7040 - // instead of truncating it (SAP semantics, verified 2026-08-30). - std::vector adt_file; - adt_file.reserve(adt_hdr.size() + fds.size()); - adt_file.insert(adt_file.end(), adt_hdr.begin(), adt_hdr.end()); - adt_file.insert(adt_file.end(), fds.begin(), fds.end()); - { - std::lock_guard clk( - create_path_mu_for(full.string())); - if (const UNSIGNED32 wrc = write_new_table_file( - full.string(), adt_file, "AdsCreateTable: ADT"); - wrc != openads::AE_SUCCESS) { - return wrc; - } - } - - // Create a companion .adm for MEMO/BINARY/IMAGE fields - if (has_companion) { - fs::path adm = full; - adm.replace_extension(".adm"); - { std::error_code ec; fs::remove(adm, ec); } - auto mr = openads::drivers::adm::AdmMemo::create(adm.string()); - if (!mr) return fail(mr.error()); - } - - // Open via the standard path so the caller gets a usable handle - // Reopen the file that was actually written, which is not - // necessarily .adt (see the ExtFile note above). - std::string rel_adt = full.string(); - UNSIGNED8 adt_namebuf[260] = {0}; - std::size_t adt_nb = std::min(rel_adt.size(), - sizeof(adt_namebuf) - 1); - std::memcpy(adt_namebuf, rel_adt.data(), adt_nb); - return AdsOpenTable(hConn, adt_namebuf, adt_namebuf, - ADS_ADT, usCharType, 0, 0, 1, phTable); - } - - if (is_vfp) { - // ── VFP DBF creation (0x30 / 0x31 / 0x32) ─────────────────────────── - bool has_memo = false; - bool has_autoinc = false; - std::uint16_t nullable_count = 0; - for (const auto& f : fields) { - if (f.type == 'M' || f.type == 'm') has_memo = true; - if (f.autoinc) has_autoinc = true; - if (f.nullable) ++nullable_count; - } - const bool has_null = nullable_count > 0; - const std::uint8_t ver = - has_autoinc ? 0x32 : (has_memo ? 0x31 : 0x30); - - std::uint16_t header_len = static_cast( - 32 + 32 * fields.size() + 1); - std::uint32_t rec_len = 1; - if (has_null) rec_len += 4; - for (const auto& f : fields) rec_len += f.length; - if (rec_len > 0xFFFF) { - return fail(openads::AE_INTERNAL_ERROR, "record too long"); - } - - std::vector hdr(32, 0); - hdr[0] = ver; - stamp_dbf_header_today(hdr.data()); - hdr[8] = static_cast(header_len & 0xFFu); - hdr[9] = static_cast((header_len >> 8) & 0xFFu); - hdr[10] = static_cast(rec_len & 0xFFu); - hdr[11] = static_cast((rec_len >> 8) & 0xFFu); - - std::vector file = hdr; - for (const auto& f : fields) { - std::vector fd(32, 0); - std::size_t n = std::min(f.name.size(), 10); - std::memcpy(fd.data(), f.name.data(), n); - fd[11] = static_cast( - dbf_descriptor_type_char(f.type)); - fd[16] = f.length; - fd[17] = f.dec; - std::uint8_t flags = 0; - if (f.nullable) flags |= 0x02u; - if (f.autoinc) { - flags |= 0x0Cu; - fd[19] = 1; // next value to issue - fd[23] = 1; // step - } - fd[18] = flags; - file.insert(file.end(), fd.begin(), fd.end()); - } - stamp_dbf_field_displacements(file.data() + 32, fields.size(), - has_null ? 5u : 1u); - file.push_back(0x0D); - file.push_back(0x1A); - - { - // See the ADT branch: per-path lock + exclusive create. - std::lock_guard clk( - create_path_mu_for(full.string())); - if (const UNSIGNED32 wrc = write_new_table_file( - full.string(), file, "AdsCreateTable: VFP"); - wrc != openads::AE_SUCCESS) { - return wrc; - } - } - - if (has_memo) { - fs::path fpt = full; - fpt.replace_extension(".fpt"); - { std::error_code ec; fs::remove(fpt, ec); } - std::uint16_t bs = usMemoBlockSize != 0 ? usMemoBlockSize : 512; - auto mr = openads::drivers::fpt::FptMemo::create(fpt.string(), bs); - if (!mr) return fail(mr.error()); - } - - UNSIGNED8 namebuf[260] = {0}; - std::size_t nb = std::min(rel.size(), sizeof(namebuf) - 1); - std::memcpy(namebuf, rel.data(), nb); - return AdsOpenTable(hConn, namebuf, namebuf, - ADS_VFP, usCharType, 0, 0, 1, phTable); - } - - // ── DBF creation path (existing) ──────────────────────────────────────── - // Compute header + record sizes. Harbour DBFCDX uses - // 32 + 32*n + 2 (the field list ends with a 2-byte 0x0D 0x00 - // terminator); matching it keeps headers byte-identical with a - // Harbour-written DBF of the same schema. - std::uint16_t header_len = static_cast( - 32 + 32 * fields.size() + 2); - std::uint32_t rec_len = 1; // delete-flag byte - for (auto& f : fields) rec_len += f.length; - if (rec_len > 0xFFFF) { - return fail(openads::AE_INTERNAL_ERROR, "record too long"); - } - - // Detect a memo (M) field up front: it drives both the version byte and - // the companion memo file written below. OpenADS writes a FoxPro .fpt - // memo, so per the dBASE/FoxPro format spec a table WITH a memo must - // declare FoxPro 2.x (0xF5) in the version byte. Writing 0x03 (dBASE III - // "no memo") made conforming readers (DBFCDX/DBFNTX) look for a .dbt that - // does not exist and fail to open with subcode 1056; no-memo tables stay - // 0x03. - bool has_memo = false; - for (auto& f : fields) { - if (f.type == 'M' || f.type == 'm') { has_memo = true; break; } - } - - std::vector hdr(32, 0); - hdr[0] = has_memo ? 0xF5 : 0x03; // FoxPro 2.x (FPT) / dBASE III - stamp_dbf_header_today(hdr.data()); // last-update - hdr[4] = 0; hdr[5] = 0; hdr[6] = 0; hdr[7] = 0; // 0 records - hdr[8] = static_cast(header_len & 0xFFu); - hdr[9] = static_cast((header_len >> 8) & 0xFFu); - hdr[10] = static_cast(rec_len & 0xFFu); - hdr[11] = static_cast((rec_len >> 8) & 0xFFu); - - std::vector file = hdr; - for (auto& f : fields) { - std::vector fd(32, 0); - std::size_t n = std::min(f.name.size(), 10); - std::memcpy(fd.data(), f.name.data(), n); - fd[11] = static_cast( - dbf_descriptor_type_char(f.type)); - fd[16] = f.length; - fd[17] = f.dec; - file.insert(file.end(), fd.begin(), fd.end()); - } - stamp_dbf_field_displacements(file.data() + 32, fields.size()); - file.push_back(0x0D); - file.push_back(0x00); - file.push_back(0x1A); - - // Atomic create: per-path lock + exclusive (share=0) handle for the - // whole write. The old fs::remove + ofstream truncate-create let a - // racing creator truncate the DBF underneath open appenders - // (permanent corruption: header count reset while peers kept writing - // records at stale offsets) and let a concurrent AdsOpenTable read a - // partially-written header (5103 "DBF header truncated" storms). - // SAP semantics (verified against SAP ADS 10.10 ace32.dll): create - // over a CLOSED existing table overwrites; create over an OPEN one - // fails 7040 (AE_FILE_IN_USE) leaving the data intact. - { - std::lock_guard clk(create_path_mu_for(full.string())); - if (const UNSIGNED32 wrc = write_new_table_file( - full.string(), file, "AdsCreateTable"); - wrc != openads::AE_SUCCESS) { - return wrc; - } - } - - // If the field list declares any memo (M) field, stage an empty - // .fpt next to the .dbf -- Connection::open_table auto-attaches it, - // and without it any write to the M field fails "memo store not - // attached" (e.g. X#'s ADSRDD on FieldPut to a memo column). - { - if (has_memo) { - fs::path fpt = full; - fpt.replace_extension(".fpt"); - { std::error_code ec; fs::remove(fpt, ec); } - // Default FPT block size 512 (the FoxPro 2.x default). The old - // 64-byte default is a Visual FoxPro value that stricter readers - // reject on open; a conforming reader honours the size from the - // header either way. The caller can still override via - // usMemoBlockSize. - std::uint16_t bs = usMemoBlockSize != 0 ? usMemoBlockSize : 512; - auto mr = openads::drivers::fpt::FptMemo::create(fpt.string(), bs); - if (!mr) return fail(mr.error()); - } - } - - // Open the freshly-created table through the regular path so the - // caller gets a usable handle. - UNSIGNED8 namebuf[260] = {0}; - std::size_t nb = std::min(rel.size(), sizeof(namebuf) - 1); - std::memcpy(namebuf, rel.data(), nb); - return AdsOpenTable(hConn, namebuf, namebuf, - ADS_CDX, // table type - usCharType, 0, 0, 1, // char/lock/checkrights/mode - phTable); -} - -UNSIGNED32 ENTRYPOINT AdsDropTable(ADSHANDLE hConnect, - UNSIGNED8* pucName, - UNSIGNED16 /*usDeleteFiles*/) { - arc2_trace("AdsDropTable"); - if (pucName == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, "null table name"); - } - const auto rel = openads::abi::to_internal(pucName, 0); - auto run_sql_drop = [&](auto* conn, - openads::sql_backend::SqlDdlDialect dialect) - -> UNSIGNED32 { - auto ddl = openads::sql_backend::build_drop_table_ddl( - dialect, rel, false); - if (!ddl) return fail(ddl.error()); - auto ex = conn->exec_sql(ddl.value()); - if (!ex) return fail(ex.error()); - return ok(); - }; -#if defined(OPENADS_WITH_SQLITE) - if (auto* sc = get_sqlite_conn(hConnect)) { - return run_sql_drop(sc, openads::sql_backend::SqlDdlDialect::Sqlite); - } -#endif -#if defined(OPENADS_WITH_POSTGRESQL) - if (auto* pc = get_postgres_conn(hConnect)) { - return run_sql_drop(pc, openads::sql_backend::SqlDdlDialect::Postgres); - } -#endif -#if defined(OPENADS_WITH_MARIADB) - if (auto* mc = get_maria_conn(hConnect)) { - return run_sql_drop(mc, openads::sql_backend::SqlDdlDialect::Maria); - } -#endif -#if defined(OPENADS_WITH_ODBC) - if (auto* oc = get_odbc_conn(hConnect)) { - return run_sql_drop(oc, odbc_dialect_for(hConnect)); - } -#endif -#if defined(OPENADS_WITH_FIREBIRD) - if (auto* fc = get_firebird_conn(hConnect)) { - return run_sql_drop(fc, openads::sql_backend::SqlDdlDialect::Firebird); - } -#endif -#if defined(OPENADS_WITH_MSSQL) - if (auto* msc = get_mssql_conn(hConnect)) { - return run_sql_drop(msc, openads::sql_backend::SqlDdlDialect::Mssql); - } -#endif - - { - // Same local-handle guard as AdsCreateTable/AdsOpenTable: a - // valid local Connection (the in-process server's ABI twin) must - // not be hijacked by an in-process tcp:// client connection via - // the any-remote fallback -- that desynced the wire protocol - // (server thread writing a request into the client's own - // socket), so a remote AdsDropTable died with recv() failed - // (abi_remote_create_table_test). - ADSHANDLE rc_h = 0; - if (get_remote_connection(hConnect) != nullptr) { - rc_h = hConnect; - } else if (state().registry.lookup( - hConnect, HandleKind::Connection) == nullptr) { - rc_h = resolve_remote_conn_handle(hConnect); - } - if (auto* rc = get_remote_connection(rc_h)) { - // Parked handles pin files server-side; drop must really drop. - remote_flush_pools(rc); - auto dr = rc->drop_table(rel, /*delete_files=*/1); - if (!dr) return fail(dr.error()); - return ok(); - } - if (hConnect != 0 && rc_h == 0) { - auto& s_dead = state(); - if (s_dead.registry.kind_of(hConnect) == - HandleKind::RemoteConnection) { - return fail(openads::AE_NO_CONNECTION, - "connection is closed"); - } - } - } - - auto& s = state(); - Connection* c = s.registry.lookup(hConnect, - HandleKind::Connection); - if (c == nullptr) { - ADSHANDLE def = get_or_create_default_connection(); - c = s.registry.lookup(def, HandleKind::Connection); - if (c == nullptr) { - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - } - } - namespace fs = std::filesystem; - fs::path full = fs::path(c->data_dir()) / rel; - if (!full.has_extension()) full.replace_extension(".dbf"); - if (!fs::exists(full)) { - return fail(openads::AE_NO_FILE_FOUND, rel.c_str()); - } - std::error_code ec; - fs::remove(full, ec); - auto cdx = full; cdx.replace_extension(".cdx"); - fs::remove(cdx, ec); - auto fpt = full; fpt.replace_extension(".fpt"); - fs::remove(fpt, ec); - auto dbt = full; dbt.replace_extension(".dbt"); - fs::remove(dbt, ec); - return ok(); -} - -// --- M9.26 AdsRestructureTable (ADD-only) ---------------------------------- -// -// Real ACE rebuilds the DBF with three field-def strings -- add, -// delete, and change. The most common rddads call site only feeds -// the "add" list (`pucDeleteFields` / `pucChangeFields` empty), which -// is what 0.2.x supports. Non-empty delete / change lists return -// AE_FUNCTION_NOT_AVAILABLE until the 0.3.x VFP / ADT structural -// extensions land. -// -// Indexes are NOT auto-rebuilt (real ACE handles that internally). -// Apps that depend on a bound index after a restructure should -// follow up with AdsReindex; the on-disk record format changed, so -// stale entries point at the wrong recnos. - -UNSIGNED32 ENTRYPOINT AdsRestructureTable(ADSHANDLE hConnect, - UNSIGNED8* pucTableName, - UNSIGNED8* /*pucAlias*/, - UNSIGNED16 /*usFileType*/, - UNSIGNED16 /*usCharType*/, - UNSIGNED16 /*usLockType*/, - UNSIGNED16 /*usCheckRights*/, - UNSIGNED8* pucAddFields, - UNSIGNED8* pucDeleteFields, - UNSIGNED8* pucChangeFields) { - arc2_trace("AdsRestructureTable"); - if (pucTableName == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, - "AdsRestructureTable: null table name"); - } - auto del = pucDeleteFields ? openads::abi::to_internal(pucDeleteFields, 0) - : std::string(); - auto chg = pucChangeFields ? openads::abi::to_internal(pucChangeFields, 0) - : std::string(); - - auto add = pucAddFields ? openads::abi::to_internal(pucAddFields, 0) - : std::string(); - auto add_fields = parse_rddads_field_defs(add); - - // CHANGE list (M10.12): same shape as ADD (NAME,Type,Len,Dec;…). - // Each entry replaces the same-named existing field's length / - // decimals. The Type must match the existing field -- type - // conversion (rename / retype) needs a clean-room ADS spec and - // stays deferred. Apps that need it can issue DELETE + ADD. - auto change_fields = parse_rddads_field_defs(chg); - std::unordered_map change_map; - for (auto& cf : change_fields) { - change_map[cf.name] = cf; - } - - // DELETE list is a `;`-separated list of bare field names -- - // unlike pucAddFields the entries carry no type / len info. - std::unordered_set del_set; - { - std::string buf; - auto flush = [&] { - std::string trimmed = buf; - while (!trimmed.empty() && - std::isspace(static_cast(trimmed.front()))) { - trimmed.erase(trimmed.begin()); - } - while (!trimmed.empty() && - std::isspace(static_cast(trimmed.back()))) { - trimmed.pop_back(); - } - if (!trimmed.empty()) del_set.insert(trimmed); - buf.clear(); - }; - for (std::size_t i = 0; i <= del.size(); ++i) { - char ch = (i < del.size()) ? del[i] : ';'; - if (ch == ';' || ch == ',') flush(); - else buf.push_back(ch); - } - } - - if (add_fields.empty() && del_set.empty() && change_fields.empty()) { - return ok(); // nothing to do - } - - auto rel = openads::abi::to_internal(pucTableName, 0); - - { - std::vector add_cols; - add_cols.reserve(add_fields.size()); - for (const auto& f : add_fields) { - add_cols.push_back({f.name, f.type, f.length, f.dec}); - } - std::vector chg_cols; - chg_cols.reserve(change_fields.size()); - for (const auto& f : change_fields) { - chg_cols.push_back({f.name, f.type, f.length, f.dec}); - } - std::vector drop_cols(del_set.begin(), del_set.end()); - - auto run_sql_restructure = - [&](auto* conn, openads::sql_backend::SqlDdlDialect dialect, - const std::vector& chg) - -> UNSIGNED32 { - auto exec_vec = [&](const std::vector& stmts) - -> UNSIGNED32 { - for (const auto& sql : stmts) { - auto ex = conn->exec_sql(sql); - if (!ex) return fail(ex.error()); - } - return ok(); - }; - if (!add_cols.empty()) { - auto ddl = openads::sql_backend::build_alter_table_add_ddl( - dialect, rel, add_cols); - if (!ddl) return fail(ddl.error()); - auto rc = exec_vec(ddl.value()); - if (rc != openads::AE_SUCCESS) return rc; - } - if (!chg.empty()) { - auto ddl = openads::sql_backend::build_alter_table_change_ddl( - dialect, rel, chg); - if (!ddl) return fail(ddl.error()); - auto rc = exec_vec(ddl.value()); - if (rc != openads::AE_SUCCESS) return rc; - } - if (!drop_cols.empty()) { - auto ddl = openads::sql_backend::build_alter_table_drop_ddl( - dialect, rel, drop_cols); - if (!ddl) return fail(ddl.error()); - auto rc = exec_vec(ddl.value()); - if (rc != openads::AE_SUCCESS) return rc; - } - return ok(); - }; -#if defined(OPENADS_WITH_SQLITE) - if (auto* sc = get_sqlite_conn(hConnect)) { - if (!chg_cols.empty()) { - if (auto r = sc->restructure_change(rel, chg_cols); !r) { - return fail(r.error()); - } - } - return run_sql_restructure(sc, - openads::sql_backend::SqlDdlDialect::Sqlite, {}); - } -#endif -#if defined(OPENADS_WITH_POSTGRESQL) - if (auto* pc = get_postgres_conn(hConnect)) { - return run_sql_restructure(pc, - openads::sql_backend::SqlDdlDialect::Postgres, chg_cols); - } -#endif -#if defined(OPENADS_WITH_MARIADB) - if (auto* mc = get_maria_conn(hConnect)) { - return run_sql_restructure(mc, - openads::sql_backend::SqlDdlDialect::Maria, chg_cols); - } -#endif -#if defined(OPENADS_WITH_ODBC) - if (auto* oc = get_odbc_conn(hConnect)) { - return run_sql_restructure(oc, odbc_dialect_for(hConnect), chg_cols); - } -#endif -#if defined(OPENADS_WITH_FIREBIRD) - if (auto* fc = get_firebird_conn(hConnect)) { - return run_sql_restructure(fc, - openads::sql_backend::SqlDdlDialect::Firebird, chg_cols); - } -#endif -#if defined(OPENADS_WITH_MSSQL) - if (auto* msc = get_mssql_conn(hConnect)) { - return run_sql_restructure(msc, - openads::sql_backend::SqlDdlDialect::Mssql, chg_cols); - } -#endif - } - - auto& s = state(); - Connection* c = s.registry.lookup(hConnect, HandleKind::Connection); - if (c == nullptr) { - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - } - - namespace fs = std::filesystem; - fs::path full = fs::path(c->data_dir()) / rel; - if (!full.has_extension()) full.replace_extension(".dbf"); - - fs::path tmp = full; - tmp += ".restructure.tmp"; - { - std::error_code ec; - fs::remove(tmp, ec); - } - - // Read the source schema + record bytes inside an inner scope so - // the engine's File handle on `full` is closed before the rename. - { - auto opened = openads::engine::Table::open( - full.string(), openads::engine::TableType::Cdx, - openads::engine::OpenMode::Read); - if (!opened) return fail(opened.error()); - auto& t = opened.value(); - - // Per-field copy plan: keep the source order, drop fields that - // appear in the DELETE list, apply the CHANGE list's - // length/decimals overrides for matching surviving fields, - // then append the ADD list. Each surviving field tracks where - // to copy from in the old record (or no source for newly-added - // columns). - struct PerField { - FieldOut descriptor; - bool from_old = false; - std::uint16_t old_offset = 0; - std::uint8_t old_length = 0; - char old_type = '\0'; // non-'\0' → type conversion - char new_type = '\0'; // target raw type - }; - std::vector plan; - for (std::uint16_t i = 0; i < t.field_count(); ++i) { - const auto& src = t.field_descriptor(i); - if (del_set.find(src.name) != del_set.end()) continue; - PerField p; - p.descriptor.name = src.name; - p.descriptor.type = src.raw_type; - p.descriptor.length = static_cast(src.length); - p.descriptor.dec = src.decimals; - p.from_old = true; - p.old_offset = src.record_offset; - p.old_length = static_cast(src.length); - - auto cit = change_map.find(src.name); - if (cit != change_map.end()) { - if (cit->second.type != src.raw_type) { - p.old_type = src.raw_type; - p.new_type = cit->second.type; - p.descriptor.type = cit->second.type; - } - p.descriptor.length = cit->second.length; - p.descriptor.dec = cit->second.dec; - } - plan.push_back(std::move(p)); - } - for (auto& nf : add_fields) { - for (auto& existing : plan) { - if (existing.descriptor.name == nf.name) { - return fail(openads::AE_INTERNAL_ERROR, - "AdsRestructureTable: duplicate field name"); - } - } - PerField p; - p.descriptor = nf; - p.from_old = false; - plan.push_back(std::move(p)); - } - if (plan.empty()) { - return fail(openads::AE_INTERNAL_ERROR, - "AdsRestructureTable: every field deleted " - "without an ADD -- would leave the table empty"); - } - std::vector merged; - merged.reserve(plan.size()); - for (auto& p : plan) merged.push_back(p.descriptor); - - std::uint16_t header_len = static_cast( - 32 + 32 * merged.size() + 2); - std::uint32_t rec_len = 1; - for (auto& f : merged) rec_len += f.length; - if (rec_len > 0xFFFF) { - return fail(openads::AE_INTERNAL_ERROR, - "AdsRestructureTable: record exceeds 64 KiB"); - } - - std::vector hdr(32, 0); - hdr[0] = 0x03; - stamp_dbf_header_today(hdr.data()); - std::uint32_t rcount = t.record_count(); - hdr[4] = static_cast( rcount & 0xFFu); - hdr[5] = static_cast((rcount >> 8) & 0xFFu); - hdr[6] = static_cast((rcount >> 16) & 0xFFu); - hdr[7] = static_cast((rcount >> 24) & 0xFFu); - hdr[8] = static_cast(header_len & 0xFFu); - hdr[9] = static_cast((header_len >> 8) & 0xFFu); - hdr[10] = static_cast(rec_len & 0xFFu); - hdr[11] = static_cast((rec_len >> 8) & 0xFFu); - - std::vector file_bytes = hdr; - for (auto& f : merged) { - std::vector fd(32, 0); - std::size_t n = std::min(f.name.size(), 10); - std::memcpy(fd.data(), f.name.data(), n); - fd[11] = static_cast( - dbf_descriptor_type_char(f.type)); - fd[16] = f.length; - fd[17] = f.dec; - file_bytes.insert(file_bytes.end(), fd.begin(), fd.end()); - } - stamp_dbf_field_displacements(file_bytes.data() + 32, merged.size()); - file_bytes.push_back(0x0D); - file_bytes.push_back(0x00); - - std::uint16_t old_rec_len = t.driver()->record_length(); - for (std::uint32_t r = 1; r <= rcount; ++r) { - auto rec = t.driver()->read_record_raw(r); - if (!rec) return fail(rec.error()); - std::vector old_buf = std::move(rec).value(); - - std::vector new_buf(rec_len, ' '); - new_buf[0] = old_buf.empty() ? ' ' : old_buf[0]; - std::uint16_t out_off = 1; - for (auto& p : plan) { - if (p.from_old && p.new_type != '\0') { - // Type conversion: read old bytes, convert, write new. - std::string old_data(p.old_length, ' '); - if (old_buf.size() >= static_cast(p.old_offset) - + p.old_length) { - std::memcpy(old_data.data(), - old_buf.data() + p.old_offset, p.old_length); - } - const std::uint8_t nlen = p.descriptor.length; - const std::uint8_t ndec = p.descriptor.dec; - if (p.old_type == 'C' && p.new_type == 'N') { - auto first = old_data.find_first_not_of(' '); - const char* src_ptr = (first == std::string::npos) - ? "" : old_data.c_str() + first; - double val = *src_ptr ? std::strtod(src_ptr, nullptr) : 0.0; - char fmt[32]; - std::snprintf(fmt, sizeof(fmt), "%%%u.%uf", - static_cast(nlen), - static_cast(ndec)); - char nb[64] = {}; - std::snprintf(nb, sizeof(nb), fmt, val); - std::size_t slen = std::strlen(nb); - std::size_t copy = std::min(slen, nlen); - std::size_t soff = slen > nlen ? slen - nlen : 0u; - std::memcpy(new_buf.data() + out_off, nb + soff, copy); - } else if (p.old_type == 'N' && p.new_type == 'C') { - auto first = old_data.find_first_not_of(' '); - if (first != std::string::npos) { - std::size_t copy = std::min( - old_data.size() - first, - static_cast(nlen)); - std::memcpy(new_buf.data() + out_off, - old_data.data() + first, copy); - } - } else if (p.new_type == 'L') { - char lval = 'F'; - if (p.old_type == 'C') { - char ch = old_data.empty() ? ' ' : old_data[0]; - if (ch=='T'||ch=='t'||ch=='Y'||ch=='y'||ch=='1') lval='T'; - } else if (p.old_type == 'N') { - auto f2 = old_data.find_first_not_of(' '); - if (f2 != std::string::npos && - std::strtod(old_data.c_str() + f2, nullptr) != 0.0) - lval = 'T'; - } - new_buf[out_off] = static_cast(lval); - } else if (p.old_type == 'L') { - char lc = old_data.empty() ? 'F' : old_data[0]; - bool is_true = (lc == 'T' || lc == 't'); - if (p.new_type == 'C') { - new_buf[out_off] = - static_cast(is_true ? 'T' : 'F'); - } else if (p.new_type == 'N') { - char fmt[32]; - std::snprintf(fmt, sizeof(fmt), "%%%u.%uf", - static_cast(nlen), - static_cast(ndec)); - char nb[64] = {}; - std::snprintf(nb, sizeof(nb), fmt, is_true ? 1.0 : 0.0); - std::size_t copy = - std::min(std::strlen(nb), nlen); - std::memcpy(new_buf.data() + out_off, nb, copy); - } - } else { - // D↔C and other pairs: raw copy up to min length. - std::uint8_t copy_len = - std::min(p.old_length, nlen); - std::memcpy(new_buf.data() + out_off, - old_data.data(), copy_len); - } - } else if (p.from_old) { - std::uint8_t copy_len = - std::min(p.old_length, - p.descriptor.length); - if (old_buf.size() >= - static_cast(p.old_offset) + - static_cast(copy_len)) { - std::memcpy(new_buf.data() + out_off, - old_buf.data() + p.old_offset, - copy_len); - } - // Tail bytes (when new length > old length) stay - // as the blank-pad already in new_buf. - } - out_off = static_cast( - out_off + p.descriptor.length); - } - (void)old_rec_len; - file_bytes.insert(file_bytes.end(), - new_buf.begin(), new_buf.end()); - } - file_bytes.push_back(0x1A); - - std::ofstream out(tmp, std::ios::binary); - if (!out) return fail(openads::AE_INTERNAL_ERROR, - "AdsRestructureTable: tmp open failed"); - out.write(reinterpret_cast(file_bytes.data()), - static_cast(file_bytes.size())); - if (!out) return fail(openads::AE_INTERNAL_ERROR, - "AdsRestructureTable: tmp write failed"); - } // engine handle on `full` closes here - - { - std::error_code ec; - fs::remove(full, ec); - fs::rename(tmp, full, ec); - if (ec) { - return fail(openads::AE_INTERNAL_ERROR, - "AdsRestructureTable: rename failed"); - } - } - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsRefreshRecord(ADSHANDLE hTable) { - arc2_trace("AdsRefreshRecord"); - if (auto* rt = get_remote_table(hTable)) { - if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; - // Refresh right after a GotoRecord on this handle, with nothing - // at all sent to the server in between (no lock, write, nav or - // read frame) and the cursor still on the row that ack carried: - // the server's GotoRecord had just re-read that row from disk, - // which is all a refresh does. Serve it from that ack. - if (rt->goto_row_fresh && rt->row_valid && rt->conn != nullptr && - rt->goto_row_frame_seq == rt->conn->frame_seq() && - rt->current_recno == rt->goto_row_recno && - rt->cursor_lag == 0 && rt->pending_sets.empty()) { - rt->goto_row_fresh = false; - cli_trace_tbl(rt, "AdsRefreshRecord", "served from GotoRecord ack"); - rt->invalidate_prefetch(); - rt->rec_count_cached = false; - rt->key_count_cached = false; - rt->key_counts.clear(); - return ok(); - } - rt->goto_row_fresh = false; - remote_settle_cursor(rt); // M12.21 option C - rt->row_valid = false; // M12.17 cache invalidation - rt->rec_count_cached = false; - rt->key_count_cached = false; // force fresh record count from server - rt->key_counts.clear(); - // Table-aware overload: the re-read row + bounds + recno ride - // back in the ack and repopulate the caches below (row_valid - // restored by the trailer on new servers; the pre-clear above - // is the old-server fallback). - auto r = rt->conn->refresh_record(rt); - if (!r) return fail(r.error()); - return ok(); - } - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - if (t->eof() || t->bof() || t->recno() == 0) return ok(); - auto r = t->goto_record(t->recno()); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsExtractKey(ADSHANDLE hIndex, UNSIGNED8* pucBuf, - UNSIGNED16* pusLen) { - arc2_trace("AdsExtractKey"); - if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, "null len"); - Table* t = lookup_table_by_index(hIndex); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown index"); - openads::drivers::IIndex* idx = iindex_for_handle(hIndex); - if (!idx) return fail(openads::AE_INTERNAL_ERROR, "index not loaded"); - // Return the key in its STORED encoding -- rddads' OrdKeyVal decodes - // the buffer by key type (HB_ORD2DBL for ADS_NUMERIC), so a numeric - // (FoxNumeric) tag must yield the 8-byte order-preserving binary key, - // not the ASCII expression text (which OrdKeyVal printed as "*****"). - // Mirrors Table::compute_index_key_. - std::string key; - if (idx->key_encoding() == openads::drivers::KeyEncoding::FoxNumeric) { - double d = 0.0; - openads::engine::evaluate_index_expr_number(*t, idx->expression(), d); - key = openads::engine::fox_numeric_key(d); - } else if (idx->key_encoding() == openads::drivers::KeyEncoding::NtxNumeric) { - double d = 0.0; - openads::engine::evaluate_index_expr_number(*t, idx->expression(), d); - key = openads::engine::ntx_numeric_key(d, idx->key_length(), - idx->key_decimals()); - } else { - auto k = openads::engine::evaluate_index_expr(*t, idx->expression(), - idx->key_length()); - if (!k) return fail(k.error()); - key = std::move(k).value(); - } - openads::abi::copy_to_caller(pucBuf, pusLen, key); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsGotoRecord(ADSHANDLE hTable, UNSIGNED32 ulRecord) { - arc2_trace("AdsGotoRecord"); - if (auto* rt = get_remote_table(hTable)) { - cli_trace_tbl(rt, "AdsGotoRecord", "want=%u row_valid=%d", - ulRecord, (int)rt->row_valid); - if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; - if (UNSIGNED32 frc = remote_close_parked_indexes(rt); frc != 0) { - return frc; - } - rt->found_cached = true; rt->current_found = false; // M12.21: GoTo clears Found() - remote_clear_nav_boundaries(rt); - // M12.29 -- preserve keyno_valid when navigating to the same record. - // FWH xbrowse paint saves RecNo(), skips through visible rows, then - // GotoRecord(bookmark). The restore-same-record case was destroying - // keyno_valid unconditionally, triggering an O(n) remote_measure_keyno - // walk on the very next AdsGetRelKeyPos call (~22 sec for 9500 records). - const std::uint32_t prev_recno = - rt->row_valid ? rt->current_recno : 0u; - // GO 0 on a table the server certified physically EMPTY with the - // cursor already in the empty (BOF+EOF, no row) state: the server - // keeps that state (Table::goto_record preserves the phantom - // position on GO 0 even if another station appended meanwhile) - // and would send back exactly the bounds/recno we already hold. - // Answer it locally. GO n>0 always goes to the wire (a peer may - // have appended record n). Only for record count 0: on a - // non-empty table GO 0 moves the server's engine cursor. - const bool empty_goto_exact = - ulRecord == 0u && - remote_cursor_proven_empty(rt) && - rt->count_bound_ok && rt->count_bound == 0 && - rt->count_bound_seq == rt->conn->nav_seq(); - const bool empty_goto = - empty_goto_exact || remote_empty_window(rt); - // mtfix12 R2 — self-GotoRecord: the app re-issues GotoRecord - // for the recno the server cursor already sits on (FWH xBrowse - // bookmark restore). Serve locally when the certified anchor - // IS the wanted recno, the row cache holds that very row, no - // prefetch drain is outstanding (lag 0: client logical == - // server cursor), and the freshness envelope holds — - // conn-wide by default, per-table under the explicit opt-in. - // The serve is byte-identical to the wire ack: same row (cache), - // same position (anchor), lag stays 0, keyno preserved below - // (ulRecord == prev_recno), duplicate-nav stamp expired as a - // real frame would. - const bool self_goto = - ulRecord != 0u && rt->row_valid && - rt->current_recno == ulRecord && rt->cursor_lag == 0 && - rt->anchor_ok && rt->anchor_recno == ulRecord && - rt->pending_sets.empty() && - (remote_self_goto_per_table() - ? rt->anchor_tbl_seq == rt->conn->tbl_nav_seq(rt->id) - : rt->anchor_seq == rt->conn->nav_seq()); - rt->goto_row_fresh = false; - if (self_goto) { - cli_trace_tbl(rt, "AdsGotoRecord", "served locally (self-goto)"); - // A wire GotoRecord ack carries the row but NO lookahead - // block, and its trailer parse clears the queue — mirror - // that exactly, or a following Skip drains rows a real - // reposition would have discarded (the ramp test caught - // this: the skip never reached the wire). - rt->invalidate_prefetch(); - rt->goto_row_fresh = true; - rt->goto_row_frame_seq = rt->conn->frame_seq(); - rt->goto_row_recno = rt->current_recno; - rt->last_nav = 0; // a real frame would have expired it - } else if (empty_goto) { - if (empty_goto_exact) { - cli_trace_tbl(rt, "AdsGotoRecord", "served locally (empty table)"); - rt->invalidate_prefetch(); - // A real frame would have expired the duplicate-nav stamp. - rt->last_nav = 0; - rt->pair_valid = false; - rt->anchor_ok = false; // landed on no row - } else { - cli_trace_tbl(rt, "AdsGotoRecord", - "empty window: served locally want=%u", - ulRecord); - remote_empty_restamp(rt); - } - } else { - auto r = rt->conn->goto_record(rt, ulRecord); - if (!r) return fail(r.error()); - if (rt->row_valid) { - rt->goto_row_fresh = true; - rt->goto_row_frame_seq = rt->conn->frame_seq(); - rt->goto_row_recno = rt->current_recno; - } - } - if (remote_table_has_index(rt)) { - // Only invalidate when the cursor actually moved: same-record - // restores (the xbrowse common case) keep the cached key number. - if (ulRecord != prev_recno) { - rt->keyno_valid = false; - } - } else if (ulRecord > 0u) { - rt->current_keyno = ulRecord; - rt->keyno_valid = true; - } else { - rt->keyno_valid = false; - } - apply_relations_for_handle(hTable); - return ok(); - } - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - auto r = t->goto_record(ulRecord); - if (!r) return fail(r.error()); - snapshot_ri_pks(t); - apply_relations_for_handle(hTable); - return ok(); -} - -// These filesystem helpers live inside the file-scope `extern "C"` block -// (opened far above for the ACE ABI exports). An anonymous namespace does -// NOT reset language linkage, so without this `extern "C++"` their -// functions would inherit C linkage and clang -Werror,-Wreturn-type-c-linkage -// rejects the ones returning C++ types (FsConnCtx / std::optional / map&). -extern "C++" { -namespace { - -// Resolve hConn (0 = rddads default) to remote connection or local -// Connection and jail-resolve a client path under that data directory. -struct FsConnCtx { - openads::network::RemoteConnection* remote = nullptr; - Connection* local = nullptr; - std::string data_dir; -}; - -FsConnCtx resolve_fs_conn(ADSHANDLE hConn) { - FsConnCtx c; - ADSHANDLE h = hConn; - if (h == 0) h = rddads_default_connection(); - if (h == 0) h = get_or_create_default_connection(); - c.remote = get_remote_connection(h); - if (c.remote) return c; - c.local = lookup_connection(h); - if (c.local) c.data_dir = c.local->data_dir(); - return c; -} - -std::optional jail_local(const FsConnCtx& c, - const std::string& name) { - if (c.data_dir.empty()) return std::nullopt; - return openads::platform::resolve_fs_path(c.data_dir, name); -} - -struct RemoteFileRec { - openads::network::RemoteConnection* conn = nullptr; - std::uint32_t file_id = 0; -}; - -std::unordered_map>& -remote_files_map() { - static std::unordered_map> m; - return m; -} - -std::unordered_map>& -local_files_map() { - static std::unordered_map> m; - return m; -} - -} // namespace -} // extern "C++" -- fs helpers regain C++ linkage inside the ABI block - -UNSIGNED32 ENTRYPOINT AdsCheckExistence(ADSHANDLE hConn, UNSIGNED8* pucName, - UNSIGNED16* pbExists) { - arc2_trace("AdsCheckExistence"); - if (pbExists == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, "null out"); - } - *pbExists = 0; - if (pucName == nullptr) return ok(); - auto name = openads::abi::to_internal(pucName, 0); - auto ctx = resolve_fs_conn(hConn); - if (ctx.remote) { - // Open-bag short-circuit (per-USE VouExistIndex probes): a bag - // bound by any live table on this connection trivially exists - // — the app is reading through it. Matched by lowercased stem - // (no directory, no extension; .cdx/.z01 are equivalent - // production spellings). False positives are safe (a real - // open follows and errors properly); false negatives never - // happen here. - auto stem_of = [](const std::string& p) { - std::string b = p; - auto sep = b.find_last_of("/\\"); - if (sep != std::string::npos) b = b.substr(sep + 1); - auto dot = b.find_last_of('.'); - if (dot != std::string::npos) b = b.substr(0, dot); - for (auto& c : b) - c = static_cast(std::tolower( - static_cast(c))); - return b; - }; - const std::string want = stem_of(name); - if (!want.empty()) { - // Shared store: hold s.mu for the scan (memory only, no wire). - std::lock_guard lk_store(state().mu); - for (auto& kv : remote_table_store()) { - auto* rt = kv.first; - if (rt == nullptr || rt->conn != ctx.remote) continue; - if (stem_of(rt->prod_bag_path) == want || - (!rt->last_open_bag.empty() && - stem_of(rt->last_open_bag) == want) || - stem_of(rt->name) == want) { - // rt->name is the table's own path as opened: a - // live (or parked) table proves its dbf exists. - *pbExists = 1; - return ok(); - } - } - } - int fe_src = 2; - auto r = ctx.remote->file_exists(name, &fe_src); - if (!r) return fail(r.error()); - // Probe-level visibility: the frame hook only logs wire misses, - // so cache hits used to be invisible in the trace. One line per - // probe with the REAL path (the frame hook's tid is a truncated - // hash that cannot distinguish same-length paths). - cli_trace("FileExists", "probe %s -> %s (%s)", name.c_str(), - r.value() ? "yes" : "no", - fe_src == 0 ? "pos-cache" : - fe_src == 1 ? "neg-cache" : "wire"); - *pbExists = r.value() ? 1 : 0; - return ok(); - } - if (!ctx.local) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto abs = jail_local(ctx, name); - if (!abs) return fail(openads::AE_ACCESS_DENIED, "path"); - auto ex = openads::engine::fs_exists(*abs); - if (!ex) return fail(ex.error()); - *pbExists = ex.value() ? 1 : 0; - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsDeleteFile(ADSHANDLE hConn, UNSIGNED8* pucName) { - arc2_trace("AdsDeleteFile"); - if (pucName == nullptr) return fail(openads::AE_INTERNAL_ERROR, "null name"); - auto name = openads::abi::to_internal(pucName, 0); - auto ctx = resolve_fs_conn(hConn); - if (ctx.remote) { - remote_flush_pools(ctx.remote); - remote_invalidate_index_parks(ctx.remote); - auto r = ctx.remote->file_erase(name); - if (!r) return fail(r.error()); - return ok(); - } - if (!ctx.local) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto abs = jail_local(ctx, name); - if (!abs) return fail(openads::AE_ACCESS_DENIED, "path"); - auto r = openads::engine::fs_erase(*abs); - if (!r) return fail(r.error()); - return ok(); -} - -// ---- Server-side backup archiving (OAds_Zip/OAds_UnZip) ------------- -// 0x1F-separated lists in, stats + archive path out. Local and remote -// share Connection::zip_archive/unzip_archive; the wire only carries -// names and numbers (archives never cross it). -// -// NOTE: this file is inside a file-scope extern "C" block (ACE ABI); -// the helper below needs C++ linkage like the FsConnCtx block above. -extern "C++" { -namespace { - -std::vector split_1f_list(const std::string& blob) { - std::vector v; - std::string cur; - for (char c : blob) { - if (c == '\x1F') { - if (!cur.empty()) v.push_back(std::move(cur)); - cur.clear(); - } else { - cur.push_back(c); - } - } - if (!cur.empty()) v.push_back(std::move(cur)); - return v; -} - -} // namespace -} // extern "C++" (helper ends; ABI exports resume in C) - -UNSIGNED32 ENTRYPOINT AdsZipFiles(ADSHANDLE hConnect, - UNSIGNED8* pucDir, UNSIGNED8* pucFiles, - UNSIGNED8* pucZipName, UNSIGNED16 usLevel, - UNSIGNED16 usOverwrite, UNSIGNED8* pucPassword, - UNSIGNED8* pucExclude, UNSIGNED16 usWithPath, - UNSIGNED8* pucArchive, UNSIGNED16* pusArchiveLen, - UNSIGNED32* pulFiles, UNSIGNED64* pullBytes, - UNSIGNED64* pullArchiveBytes) { - arc2_trace("AdsZipFiles"); - if (!pucDir || !pucFiles || !pucZipName || !pusArchiveLen || - !pulFiles || !pullBytes || !pullArchiveBytes) - return fail(openads::AE_INTERNAL_ERROR, "null arg"); - if (usLevel > 9) - return fail(openads::AE_INTERNAL_ERROR, "level must be 0..9"); - const std::string dir = openads::abi::to_internal(pucDir, 0); - const std::vector files = - split_1f_list(openads::abi::to_internal(pucFiles, 0)); - const std::string zip_name = openads::abi::to_internal(pucZipName, 0); - const std::string password = - pucPassword ? openads::abi::to_internal(pucPassword, 0) - : std::string(); - const std::vector exclude = - pucExclude ? split_1f_list(openads::abi::to_internal(pucExclude, 0)) - : std::vector{}; - auto ctx = resolve_fs_conn(hConnect); - std::uint32_t n = 0; - std::uint64_t nb = 0, ab = 0; - std::string archive; - if (ctx.remote) { - auto r = ctx.remote->zip_archive( - dir, files, zip_name, usLevel, usOverwrite != 0, password, - exclude, usWithPath != 0); - if (!r) return fail(r.error()); - n = r.value().files; - nb = r.value().bytes; - ab = r.value().archive_bytes; - archive = std::move(r.value().archive); - } else { - if (!ctx.local) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto r = ctx.local->zip_archive( - dir, files, zip_name, static_cast(usLevel), - usOverwrite != 0, password, exclude, usWithPath != 0); - if (!r) return fail(r.error()); - n = r.value().stats.files; - nb = r.value().stats.bytes; - ab = r.value().stats.archive_bytes; - archive = std::move(r.value().archive_rel); - } - const UNSIGNED16 cap = *pusArchiveLen; - if (pucArchive != nullptr && cap > 0) { - const std::size_t need = - archive.size() < cap ? archive.size() : cap; - if (need > 0) - std::memcpy(pucArchive, archive.data(), need); - if (need < static_cast(cap)) pucArchive[need] = '\0'; - } - *pusArchiveLen = static_cast(archive.size()); - *pulFiles = n; - *pullBytes = nb; - *pullArchiveBytes = ab; - if (archive.size() > cap) - return fail(openads::AE_INSUFFICIENT_BUFFER, "archive path"); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsUnzipFiles(ADSHANDLE hConnect, - UNSIGNED8* pucDir, UNSIGNED8* pucZip, - UNSIGNED8* pucPassword, UNSIGNED16 usOverwrite, - UNSIGNED16 usWithPath, - UNSIGNED32* pulFiles, UNSIGNED64* pullBytes, - UNSIGNED64* pullArchiveBytes) { - arc2_trace("AdsUnzipFiles"); - if (!pucDir || !pucZip || !pulFiles || !pullBytes || !pullArchiveBytes) - return fail(openads::AE_INTERNAL_ERROR, "null arg"); - const std::string dir = openads::abi::to_internal(pucDir, 0); - const std::string zip = openads::abi::to_internal(pucZip, 0); - const std::string password = - pucPassword ? openads::abi::to_internal(pucPassword, 0) - : std::string(); - auto ctx = resolve_fs_conn(hConnect); - std::uint32_t n = 0; - std::uint64_t nb = 0, ab = 0; - if (ctx.remote) { - auto r = ctx.remote->unzip_archive(dir, zip, password, - usOverwrite != 0, - usWithPath != 0); - if (!r) return fail(r.error()); - n = r.value().files; - nb = r.value().bytes; - ab = r.value().archive_bytes; - } else { - if (!ctx.local) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto r = ctx.local->unzip_archive(dir, zip, password, - usOverwrite != 0, - usWithPath != 0); - if (!r) return fail(r.error()); - n = r.value().files; - nb = r.value().bytes; - ab = r.value().archive_bytes; - } - *pulFiles = n; - *pullBytes = nb; - *pullArchiveBytes = ab; - return ok(); -} - -// Central-directory listing for OAds_ZipFileCount/OAds_ZipFileList. -// Packed entry layout: see engine::pack_zip_entry. Two-pass buffer -// protocol like AdsDirectory (null/short buffer -> INSUFFICIENT and -// the required size); pulCount is optional and always filled with -// the entry count (0 on error paths that get that far). -UNSIGNED32 ENTRYPOINT AdsZipListFiles(ADSHANDLE hConnect, - UNSIGNED8* pucZip, UNSIGNED8* pucBuffer, - UNSIGNED32* pulBufLen, UNSIGNED32* pulCount) { - arc2_trace("AdsZipListFiles"); - if (!pucZip || !pulBufLen) - return fail(openads::AE_INTERNAL_ERROR, "null arg"); - const std::string zip = openads::abi::to_internal(pucZip, 0); - auto ctx = resolve_fs_conn(hConnect); - std::vector entries; - if (ctx.remote) { - auto r = ctx.remote->zip_list(zip); - if (!r) return fail(r.error()); - entries = std::move(r.value().entries); - } else { - if (!ctx.local) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto r = ctx.local->zip_list(zip); - if (!r) return fail(r.error()); - entries = std::move(r.value()); - } - if (entries.size() > 0xFFFFFFu) - return fail(openads::AE_INTERNAL_ERROR, "AdsZipListFiles: too many"); - if (pulCount != nullptr) - *pulCount = static_cast(entries.size()); - std::vector packed; - for (const auto& e : entries) - openads::engine::zip_arch::pack_zip_entry(e, packed); - if (packed.size() > 16u * 1024u * 1024u) - return fail(openads::AE_INTERNAL_ERROR, "AdsZipListFiles: too large"); - UNSIGNED32 need = static_cast(packed.size()); - if (!pucBuffer || *pulBufLen < need) { - *pulBufLen = need; - return fail(openads::AE_INSUFFICIENT_BUFFER, "AdsZipListFiles"); - } - if (need) std::memcpy(pucBuffer, packed.data(), need); - *pulBufLen = need; - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsRenameFile(ADSHANDLE hConn, UNSIGNED8* pucOld, - UNSIGNED8* pucNew) { - arc2_trace("AdsRenameFile"); - if (!pucOld || !pucNew) - return fail(openads::AE_INTERNAL_ERROR, "null name"); - auto o = openads::abi::to_internal(pucOld, 0); - auto n = openads::abi::to_internal(pucNew, 0); - auto ctx = resolve_fs_conn(hConn); - if (ctx.remote) { - remote_flush_pools(ctx.remote); - remote_invalidate_index_parks(ctx.remote); - auto r = ctx.remote->file_rename(o, n); - if (!r) return fail(r.error()); - return ok(); - } - if (!ctx.local) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto a = jail_local(ctx, o); - auto b = jail_local(ctx, n); - if (!a || !b) return fail(openads::AE_ACCESS_DENIED, "path"); - auto r = openads::engine::fs_rename(*a, *b); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsGetFileSize(ADSHANDLE hConn, UNSIGNED8* pucName, - UNSIGNED32* pulSize) { - arc2_trace("AdsGetFileSize"); - if (!pulSize) return fail(openads::AE_INTERNAL_ERROR, "null out"); - *pulSize = 0; - if (!pucName) return fail(openads::AE_INTERNAL_ERROR, "null name"); - auto name = openads::abi::to_internal(pucName, 0); - auto ctx = resolve_fs_conn(hConn); - std::uint64_t sz = 0; - if (ctx.remote) { - auto r = ctx.remote->file_size(name); - if (!r) return fail(r.error()); - sz = r.value(); - } else { - if (!ctx.local) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto abs = jail_local(ctx, name); - if (!abs) return fail(openads::AE_ACCESS_DENIED, "path"); - auto r = openads::engine::fs_size(*abs); - if (!r) return fail(r.error()); - sz = r.value(); - } - if (sz > 0xFFFFFFFFull) - return fail(openads::AE_INTERNAL_ERROR, "file too large for UNSIGNED32"); - *pulSize = static_cast(sz); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsGetFileTime(ADSHANDLE hConn, UNSIGNED8* pucName, - UNSIGNED8* pucTime, UNSIGNED16* pusLen) { - arc2_trace("AdsGetFileTime"); - if (!pucName || !pusLen) - return fail(openads::AE_INTERNAL_ERROR, "null arg"); - auto name = openads::abi::to_internal(pucName, 0); - auto ctx = resolve_fs_conn(hConn); - openads::engine::DirEntry e; - if (ctx.remote) { - auto r = ctx.remote->file_mtime(name); - if (!r) return fail(r.error()); - e = r.value(); - } else { - if (!ctx.local) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto abs = jail_local(ctx, name); - if (!abs) return fail(openads::AE_ACCESS_DENIED, "path"); - auto r = openads::engine::fs_stat_entry(*abs); - if (!r) return fail(r.error()); - e = r.value(); - } - char buf[16]; - std::snprintf(buf, sizeof(buf), "%02u:%02u:%02u", - static_cast(e.hh), - static_cast(e.mm), - static_cast(e.ss)); - UNSIGNED16 need = 9; // "hh:mm:ss\0" - if (!pucTime || *pusLen < need) { - *pusLen = need; - return fail(openads::AE_INSUFFICIENT_BUFFER, "AdsGetFileTime"); - } - std::memcpy(pucTime, buf, need); - *pusLen = need; - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsGetFileDate(ADSHANDLE hConn, UNSIGNED8* pucName, - UNSIGNED8* pucDate, UNSIGNED16* pusLen) { - arc2_trace("AdsGetFileDate"); - if (!pucName || !pusLen) - return fail(openads::AE_INTERNAL_ERROR, "null arg"); - auto name = openads::abi::to_internal(pucName, 0); - auto ctx = resolve_fs_conn(hConn); - openads::engine::DirEntry e; - if (ctx.remote) { - auto r = ctx.remote->file_mtime(name); - if (!r) return fail(r.error()); - e = r.value(); - } else { - if (!ctx.local) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto abs = jail_local(ctx, name); - if (!abs) return fail(openads::AE_ACCESS_DENIED, "path"); - auto r = openads::engine::fs_stat_entry(*abs); - if (!r) return fail(r.error()); - e = r.value(); - } - char buf[16]; - std::snprintf(buf, sizeof(buf), "%04u%02u%02u", - static_cast(e.year), - static_cast(e.mon), - static_cast(e.day)); - UNSIGNED16 need = 9; - if (!pucDate || *pusLen < need) { - *pusLen = need; - return fail(openads::AE_INSUFFICIENT_BUFFER, "AdsGetFileDate"); - } - std::memcpy(pucDate, buf, need); - *pusLen = need; - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsDirectory(ADSHANDLE hConn, UNSIGNED8* pucMask, - UNSIGNED16 /*usAttr*/, UNSIGNED8* pucBuffer, - UNSIGNED32* pulBufLen) { - arc2_trace("AdsDirectory"); - if (!pulBufLen) return fail(openads::AE_INTERNAL_ERROR, "null out"); - auto mask = pucMask ? openads::abi::to_internal(pucMask, 0) : std::string("*"); - auto ctx = resolve_fs_conn(hConn); - std::vector entries; - if (ctx.remote) { - auto r = ctx.remote->directory(mask); - if (!r) return fail(r.error()); - entries = std::move(r.value()); - } else { - if (!ctx.local) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - std::string dir_part = "."; - std::string pat = mask.empty() ? "*" : mask; - auto slash = pat.find_last_of("/\\"); - if (slash != std::string::npos) { - dir_part = pat.substr(0, slash); - if (dir_part.empty()) dir_part = "."; - pat = pat.substr(slash + 1); - if (pat.empty()) pat = "*"; - } - auto abs = jail_local(ctx, dir_part); - if (!abs) return fail(openads::AE_ACCESS_DENIED, "path"); - auto r = openads::engine::fs_directory(*abs, pat); - if (!r) return fail(r.error()); - entries = std::move(r.value()); - } - std::vector packed; - for (const auto& e : entries) - openads::engine::pack_dir_entry(e, packed); - if (packed.size() > 16u * 1024u * 1024u) - return fail(openads::AE_INTERNAL_ERROR, "AdsDirectory: too large"); - UNSIGNED32 need = static_cast(packed.size()); - if (!pucBuffer || *pulBufLen < need) { - *pulBufLen = need; - return fail(openads::AE_INSUFFICIENT_BUFFER, "AdsDirectory"); - } - if (need) std::memcpy(pucBuffer, packed.data(), need); - *pulBufLen = need; - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsDirExist(ADSHANDLE hConn, UNSIGNED8* pucPath, - UNSIGNED16* pbExists) { - arc2_trace("AdsDirExist"); - if (!pbExists) return fail(openads::AE_INTERNAL_ERROR, "null out"); - *pbExists = 0; - if (!pucPath) return ok(); - auto name = openads::abi::to_internal(pucPath, 0); - auto ctx = resolve_fs_conn(hConn); - if (ctx.remote) { - auto r = ctx.remote->dir_exist(name); - if (!r) return fail(r.error()); - *pbExists = r.value() ? 1 : 0; - return ok(); - } - if (!ctx.local) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto abs = jail_local(ctx, name); - if (!abs) return fail(openads::AE_ACCESS_DENIED, "path"); - auto r = openads::engine::fs_dir_exist(*abs); - if (!r) return fail(r.error()); - *pbExists = r.value() ? 1 : 0; - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsDirMake(ADSHANDLE hConn, UNSIGNED8* pucPath) { - arc2_trace("AdsDirMake"); - if (!pucPath) return fail(openads::AE_INTERNAL_ERROR, "null path"); - auto name = openads::abi::to_internal(pucPath, 0); - auto ctx = resolve_fs_conn(hConn); - if (ctx.remote) { - auto r = ctx.remote->dir_make(name); - if (!r) return fail(r.error()); - return ok(); - } - if (!ctx.local) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto abs = jail_local(ctx, name); - if (!abs) return fail(openads::AE_ACCESS_DENIED, "path"); - auto r = openads::engine::fs_dir_make(*abs); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsDirRemove(ADSHANDLE hConn, UNSIGNED8* pucPath) { - arc2_trace("AdsDirRemove"); - if (!pucPath) return fail(openads::AE_INTERNAL_ERROR, "null path"); - auto name = openads::abi::to_internal(pucPath, 0); - auto ctx = resolve_fs_conn(hConn); - if (ctx.remote) { - auto r = ctx.remote->dir_remove(name); - if (!r) return fail(r.error()); - return ok(); - } - if (!ctx.local) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto abs = jail_local(ctx, name); - if (!abs) return fail(openads::AE_ACCESS_DENIED, "path"); - auto r = openads::engine::fs_dir_remove(*abs); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsFOpen(ADSHANDLE hConn, UNSIGNED8* pucName, - UNSIGNED16 usMode, ADSHANDLE* phFile) { - arc2_trace("AdsFOpen"); - if (!pucName || !phFile) - return fail(openads::AE_INTERNAL_ERROR, "null arg"); - *phFile = 0; - auto name = openads::abi::to_internal(pucName, 0); - auto ctx = resolve_fs_conn(hConn); - auto& s = state(); - std::lock_guard lk(s.mu); - if (ctx.remote) { - auto r = ctx.remote->fopen(name, usMode); - if (!r) return fail(r.error()); - auto rec = std::make_unique(); - rec->conn = ctx.remote; - rec->file_id = r.value(); - auto* raw = rec.get(); - Handle h = s.registry.register_object( - openads::session::HandleKind::RemoteFile, raw); - remote_files_map().emplace(to_ads_handle(h), std::move(rec)); - *phFile = to_ads_handle(h); - return ok(); - } - if (!ctx.local) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto abs = jail_local(ctx, name); - if (!abs) return fail(openads::AE_ACCESS_DENIED, "path"); - auto fo = openads::engine::fs_open(*abs, usMode, false); - if (!fo) return fail(fo.error()); - auto* raw = fo.value().get(); - Handle h = s.registry.register_object( - openads::session::HandleKind::LocalFile, raw); - local_files_map().emplace(to_ads_handle(h), std::move(fo.value())); - *phFile = to_ads_handle(h); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsFCreate(ADSHANDLE hConn, UNSIGNED8* pucName, - UNSIGNED16 usAttribute, ADSHANDLE* phFile) { - arc2_trace("AdsFCreate"); - if (!pucName || !phFile) - return fail(openads::AE_INTERNAL_ERROR, "null arg"); - *phFile = 0; - auto name = openads::abi::to_internal(pucName, 0); - auto ctx = resolve_fs_conn(hConn); - auto& s = state(); - std::lock_guard lk(s.mu); - if (ctx.remote) { - auto r = ctx.remote->fcreate(name, usAttribute); - if (!r) return fail(r.error()); - auto rec = std::make_unique(); - rec->conn = ctx.remote; - rec->file_id = r.value(); - auto* raw = rec.get(); - Handle h = s.registry.register_object( - openads::session::HandleKind::RemoteFile, raw); - remote_files_map().emplace(to_ads_handle(h), std::move(rec)); - *phFile = to_ads_handle(h); - return ok(); - } - if (!ctx.local) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto abs = jail_local(ctx, name); - if (!abs) return fail(openads::AE_ACCESS_DENIED, "path"); - auto fo = openads::engine::fs_open( - *abs, openads::engine::ADS_FO_READWRITE, true); - if (!fo) return fail(fo.error()); - auto* raw = fo.value().get(); - Handle h = s.registry.register_object( - openads::session::HandleKind::LocalFile, raw); - local_files_map().emplace(to_ads_handle(h), std::move(fo.value())); - *phFile = to_ads_handle(h); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsFClose(ADSHANDLE hFile) { - arc2_trace("AdsFClose"); - auto& s = state(); - std::lock_guard lk(s.mu); - if (auto* rf = s.registry.lookup( - hFile, openads::session::HandleKind::RemoteFile)) { - auto r = rf->conn->fclose(rf->file_id); - remote_files_map().erase(hFile); - s.registry.release(hFile); - if (!r) return fail(r.error()); - return ok(); - } - if (s.registry.lookup( - hFile, openads::session::HandleKind::LocalFile)) { - local_files_map().erase(hFile); - s.registry.release(hFile); - return ok(); - } - return fail(openads::AE_INTERNAL_ERROR, "bad file handle"); -} - -UNSIGNED32 ENTRYPOINT AdsFRead(ADSHANDLE hFile, void* pBuf, UNSIGNED32 ulLen, - UNSIGNED32* pulRead) { - arc2_trace("AdsFRead"); - if (!pulRead) return fail(openads::AE_INTERNAL_ERROR, "null out"); - *pulRead = 0; - auto& s = state(); - std::lock_guard lk(s.mu); - if (auto* rf = s.registry.lookup( - hFile, openads::session::HandleKind::RemoteFile)) { - auto r = rf->conn->fread(rf->file_id, ulLen); - if (!r) return fail(r.error()); - const auto& bytes = r.value(); - if (pBuf && !bytes.empty()) - std::memcpy(pBuf, bytes.data(), bytes.size()); - *pulRead = static_cast(bytes.size()); - return ok(); - } - if (auto* lf = s.registry.lookup( - hFile, openads::session::HandleKind::LocalFile)) { - if (!pBuf && ulLen) return fail(openads::AE_INTERNAL_ERROR, "null buf"); - lf->stream.read(static_cast(pBuf), - static_cast(ulLen)); - *pulRead = static_cast(lf->stream.gcount()); - return ok(); - } - return fail(openads::AE_INTERNAL_ERROR, "bad file handle"); -} - -UNSIGNED32 ENTRYPOINT AdsFWrite(ADSHANDLE hFile, const void* pBuf, - UNSIGNED32 ulLen, UNSIGNED32* pulWritten) { - arc2_trace("AdsFWrite"); - if (!pulWritten) return fail(openads::AE_INTERNAL_ERROR, "null out"); - *pulWritten = 0; - auto& s = state(); - std::lock_guard lk(s.mu); - if (auto* rf = s.registry.lookup( - hFile, openads::session::HandleKind::RemoteFile)) { - auto r = rf->conn->fwrite( - rf->file_id, static_cast(pBuf), ulLen); - if (!r) return fail(r.error()); - *pulWritten = r.value(); - return ok(); - } - if (auto* lf = s.registry.lookup( - hFile, openads::session::HandleKind::LocalFile)) { - if (!pBuf && ulLen) return fail(openads::AE_INTERNAL_ERROR, "null buf"); - lf->stream.write(static_cast(pBuf), - static_cast(ulLen)); - lf->stream.flush(); - if (!lf->stream) - return fail(openads::AE_INTERNAL_ERROR, "write failed"); - *pulWritten = ulLen; - return ok(); - } - return fail(openads::AE_INTERNAL_ERROR, "bad file handle"); -} - -UNSIGNED32 ENTRYPOINT AdsFSeek(ADSHANDLE hFile, SIGNED32 lOffset, - UNSIGNED16 usOrigin, UNSIGNED32* pulPos) { - arc2_trace("AdsFSeek"); - if (!pulPos) return fail(openads::AE_INTERNAL_ERROR, "null out"); - *pulPos = 0; - auto& s = state(); - std::lock_guard lk(s.mu); - if (auto* rf = s.registry.lookup( - hFile, openads::session::HandleKind::RemoteFile)) { - auto r = rf->conn->fseek(rf->file_id, lOffset, - static_cast(usOrigin)); - if (!r) return fail(r.error()); - *pulPos = r.value(); - return ok(); - } - if (auto* lf = s.registry.lookup( - hFile, openads::session::HandleKind::LocalFile)) { - std::ios::seekdir way = std::ios::beg; - if (usOrigin == 1) way = std::ios::cur; - else if (usOrigin == 2) way = std::ios::end; - lf->stream.clear(); - // std::fstream keeps ONE shared file position for get and put; - // a second seekp with ios::cur would apply the offset AGAIN - // (SEEK_CUR +3 from 2 landed at 8 instead of 5). - lf->stream.seekg(lOffset, way); - *pulPos = static_cast(lf->stream.tellg()); - return ok(); - } - return fail(openads::AE_INTERNAL_ERROR, "bad file handle"); -} - -// oads_*() filesystem aliases moved to abi/adsfunc.c (pure C, standalone). - -// SAP's ace.h declares `AdsCloseAllTables(void)`: close every table -// the calling process has opened. We accept the same 0-arg form; -// per-connection close still works through AdsCloseTable(). -UNSIGNED32 ENTRYPOINT AdsCloseAllTables(void) { - arc2_trace("AdsCloseAllTables"); - auto& s = state(); - std::lock_guard lk(s.mu); - (void)0; - // Walk every Table handle that points to a Table belonging to - // this connection and release it. Connection's tables_ map owns - // the unique_ptrs; the handle registry just borrows pointers. - std::vector to_release; - s.registry.for_each_handle([&](Handle h, HandleKind k, void* p) { - if (k != HandleKind::Table) return; - Table* tp = static_cast(p); - if (tp == nullptr) return; - // Table belongs to this connection if c->lookup_table on its - // handle returns the same pointer. We don't track the - // back-edge directly, so iterate all Connection's table - // handles instead. - (void)tp; - to_release.push_back(h); - }); - for (Handle h : to_release) { - Table* t = s.registry.lookup
(h, HandleKind::Table); - if (t) { - Connection* owning = nullptr; - s.registry.for_each_handle([&](Handle, HandleKind k, void* p) { - if (k != HandleKind::Connection || owning) return; - auto* cc = static_cast(p); - if (cc->owns_table_ptr(t)) owning = cc; - }); - (void)t->flush(); - purge_bindings_for_table(t); - purge_pending_binaries_for_table(t); - connect101_option_tables().erase(to_ads_handle(h)); - if (owning) owning->close_table_ptr(t); - } - s.registry.release(h); - } - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsCloseTable(ADSHANDLE hTable) { - arc2_trace("AdsCloseTable"); - arc2_trace("AdsCloseTable"); - { - if (auto* rt = get_remote_table(hTable)) { - // Buffered sets flush before anything else (data must land). - // Deferred teardown does NOT flush here: a real close absorbs - // it below (server close flushes + purges), while a park keeps - // the server handle untouched — so tables carrying either flag - // are never parked (see remote_table_poolable). - if (UNSIGNED32 frc = remote_flush_sets(rt); frc != 0) return frc; - auto* rc = rt->conn; - // Pooled re-USE: park eligible tables instead of closing. The - // registry slot dies (use-after-close still errors SAP-correct) - // but the server handle, schema, tags and order bindings survive - // for the next open of the same path+alias+mode. - if (rc != nullptr && remote_table_poolable(rt) && - !remote_table_has_relations(hTable)) { - std::unique_ptr owned = - remote_table_take(rt); - if (owned) { - auto& s2 = state(); - { - std::lock_guard lk2(s2.mu); - s2.registry.release(hTable); - remote_sql_cursors_map().erase(hTable); - } - std::vector> evicted; - // NOTE: key first, move second — argument evaluation - // order is indeterminate, and moving `owned` before - // reading its members is use-after-move (it crashed). - std::string pkey = remote_pool_key(owned->name, owned->alias, - owned->open_mode_raw); - openads::network::RemoteTable* traced = owned.get(); - if (cli_trace_on()) { - cli_trace_tbl(traced, "AdsCloseTable", "parked id=%u", - static_cast(traced->id)); - } - rc->parked_store(std::move(pkey), std::move(owned), evicted); - if (cli_trace_on()) { - for (auto& e : evicted) { - cli_trace_tbl(e.get(), "AdsCloseTable", - "evicted from park id=%u", - e ? static_cast(e->id) : 0u); - } - } - for (auto& e : evicted) remote_close_table_live(0, e.get()); - return ok(); - } - } - // Real close: absorb deferred teardown (never emitted). The - // server close flushes data via its shadow handle and purges - // the table's index bindings, so these frames would be waste. - // A parked index snapshot dies with the handle (same purge). - if (cli_trace_on()) { - cli_trace_tbl(rt, "AdsCloseTable", "real close id=%u reason=%s", - static_cast(rt->id), - remote_table_unpoolable_reason(rt, hTable)); - } - rt->flush_file_pending = false; - rt->close_all_indexes_pending = false; - rt->indexes_parked = false; - rt->write_dirty = false; - remote_close_table_live(hTable, rt); - 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); - // Flush the table (driver + active order + extra index views) - // before releasing the handle. Without an explicit transaction, - // this is the only point at which mutations made since open - // reach disk; with one, commit_tx already flushed and this is - // a no-op. Also drop any index bindings tied to this Table so - // a future Table allocation at the same heap address doesn't - // inherit stale entries. - Table* t = s.registry.lookup
(hTable, HandleKind::Table); - if (t != nullptr) { - Connection* owning = nullptr; - s.registry.for_each_handle([&](Handle, HandleKind k, void* p) { - if (k != HandleKind::Connection || owning) return; - auto* cc = static_cast(p); - if (cc->owns_table_ptr(t)) owning = cc; - }); - (void)t->flush(); - purge_bindings_for_table(t); - purge_pending_binaries_for_table(t); - forget_relations(hTable); - t->ri_snapshot().clear(); - connect101_option_tables().erase(hTable); - if (owning) owning->close_table_ptr(t); - // Safety net: a client that disconnects without an explicit - // unlock would otherwise leak its entry in the global lock - // registry forever (or until another Table happens to reuse - // this same heap address). - openads::mgmt::LockRegistry::instance().remove_all_for_table(t); - } - forget_cursor_projection(hTable); - s.registry.release(hTable); - // The table is closed and its handle released, so the temp table a - // materialised SELECT built for this cursor can go with it. - drop_materialised_cursor_temp(hTable); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsGotoTop(ADSHANDLE hTable) { - arc2_trace("AdsGotoTop"); - if (auto* ri = get_remote_index(hTable)) { - // Sets + teardown flush here, but NOT a deferred order switch: - // that absorbs into the nav below (fused frame) when it targets - // this order, or flushes plainly inside remote_index_goto_top's - // activate path otherwise. - if (UNSIGNED32 frc = remote_flush_sets(ri->parent); frc != 0) return frc; - if (UNSIGNED32 frc = remote_flush_teardown(ri->parent); frc != 0) return frc; - // Deferred switch absorbs into the nav below (fused frame) when - // it targets this order; otherwise (or on old servers) it goes - // out plainly first and the normal path binds cheap. - if (ri->parent != nullptr && ri->parent->pending_order) { - if (ri->parent->pending_order_id == ri->id && - ri->parent->conn != nullptr && - ri->parent->conn->server_nav_order_fuse()) { - ri->parent->pending_order = false; - ri->parent->found_cached = true; - ri->parent->current_found = false; - ri->parent->invalidate_prefetch(); - auto fr = ri->conn->goto_top_fused(ri->parent, ri->id); - if (!fr) return fail(fr.error()); - ri->parent->server_order_id = ri->id; - ri->parent->active_index_id = ri->id; - ri->parent->key_count_cached = false; - remote_sync_keyno_gototop(ri->parent); - remote_nav_stamp(ri->parent, 1, ri->id); - cli_trace_tbl(ri->parent, "AdsGotoTop(idx)", "fused: row_valid=%d recno=%u keyno=%u eof=%d bof=%d", - (int)ri->parent->row_valid, - ri->parent->current_recno, - ri->parent->current_keyno, - (int)ri->parent->nav_at_eof, - (int)ri->parent->nav_at_bof); - if (Handle th = handle_for_remote_table(ri->parent)) - apply_relations_for_handle(to_ads_handle(th)); - return ok(); - } - if (UNSIGNED32 frc = remote_flush_order(ri->parent); - frc != 0) { - return frc; - } - } - if (remote_nav_duplicate(ri->parent, 1, ri->id)) { - cli_trace_tbl(ri->parent, "AdsGotoTop(idx)", "duplicate suppressed"); - remote_sync_keyno_gototop(ri->parent); - if (Handle th = handle_for_remote_table(ri->parent)) - apply_relations_for_handle(to_ads_handle(th)); - return ok(); - } - if (remote_nav_pair(ri->parent, 1, ri->id)) { - // mtfix12 R1: the preceding GotoBottom certified this - // landing in the same server visit; apply it verbatim. - auto pr = ri->parent->conn->apply_pair_blob(ri->parent); - if (!pr) return fail(pr.error()); - cli_trace_tbl(ri->parent, "AdsGotoTop(idx)", "pair certified"); - remote_sync_keyno_gototop(ri->parent); - remote_nav_stamp(ri->parent, 1, ri->id); - if (Handle th = handle_for_remote_table(ri->parent)) - apply_relations_for_handle(to_ads_handle(th)); - return ok(); - } - auto r = openads::network::remote_index_goto_top(ri); - if (!r) return fail(r.error()); - remote_sync_keyno_gototop(ri->parent); - remote_nav_stamp(ri->parent, 1, ri->id); - cli_trace_tbl(ri->parent, "AdsGotoTop(idx)", "row_valid=%d recno=%u keyno=%u eof=%d bof=%d", - (int)ri->parent->row_valid, ri->parent->current_recno, - ri->parent->current_keyno, (int)ri->parent->nav_at_eof, - (int)ri->parent->nav_at_bof); - if (Handle th = handle_for_remote_table(ri->parent)) - apply_relations_for_handle(to_ads_handle(th)); - return ok(); - } - if (auto* rt = get_remote_table(hTable)) { - // Sets + teardown flush here; a deferred order switch absorbs - // into the nav below (fused frame) instead of going out alone. - if (UNSIGNED32 frc = remote_flush_sets(rt); frc != 0) return frc; - if (UNSIGNED32 frc = remote_flush_teardown(rt); frc != 0) return frc; - // A parked index snapshot leaves the server on the pre-CloseAll - // order while this handle believes natural: resolve before the - // nav walks the stale order (see remote_close_parked_indexes). - if (UNSIGNED32 frc = remote_close_parked_indexes(rt); frc != 0) { - return frc; - } - // M12.18 -- rt-aware overload parses the row trailer in the - // same RTT, so AdsGetField immediately after GoTop hits - // the cache. - rt->found_cached = true; rt->current_found = false; // M12.21: GoTop clears Found() - // Deferred order switch absorbs into this nav (fused frame) on - // new servers; on old ones it goes out plainly first and the - // normal path below binds cheap. - if (rt->pending_order) { - if (rt->conn != nullptr && - rt->conn->server_nav_order_fuse()) { - const std::uint32_t oid = rt->pending_order_id; - rt->pending_order = false; - rt->invalidate_prefetch(); - auto fr = rt->conn->goto_top_fused(rt, oid); - if (!fr) return fail(fr.error()); - rt->server_order_id = oid; - rt->active_index_id = oid; - rt->key_count_cached = false; - remote_sync_keyno_gototop(rt); - remote_nav_stamp(rt, 1, oid); - cli_trace_tbl(rt, "AdsGotoTop", "fused: row_valid=%d recno=%u keyno=%u eof=%d bof=%d", - (int)rt->row_valid, rt->current_recno, - rt->current_keyno, (int)rt->nav_at_eof, - (int)rt->nav_at_bof); - apply_relations_for_handle(hTable); - return ok(); - } - if (UNSIGNED32 frc = remote_flush_order(rt); frc != 0) return frc; - } - if (remote_nav_duplicate(rt, 1, rt->server_order_id)) { - cli_trace_tbl(rt, "AdsGotoTop", "duplicate suppressed"); - remote_sync_keyno_gototop(rt); - apply_relations_for_handle(hTable); - return ok(); - } - if (remote_nav_pair(rt, 1, rt->server_order_id)) { - auto pr = rt->conn->apply_pair_blob(rt); - if (!pr) return fail(pr.error()); - cli_trace_tbl(rt, "AdsGotoTop", "pair certified"); - remote_sync_keyno_gototop(rt); - remote_nav_stamp(rt, 1, rt->server_order_id); - apply_relations_for_handle(hTable); - return ok(); - } - auto r = rt->conn->goto_top(rt); - if (!r) return fail(r.error()); - remote_sync_keyno_gototop(rt); - remote_nav_stamp(rt, 1, rt->server_order_id); - apply_relations_for_handle(hTable); - return ok(); - } - if (auto* ops = openads::abi::backend_table_ops_for(hTable)) { - if (ops->goto_top) { - UNSIGNED32 rc = ops->goto_top(hTable); - if (rc == openads::AE_SUCCESS) apply_relations_for_handle(hTable); - return rc; - } - } - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - auto r = t->goto_top(); - if (!r) return fail(r.error()); - snapshot_ri_pks(t); - apply_relations_for_handle(hTable); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsGotoBottom(ADSHANDLE hTable) { - arc2_trace("AdsGotoBottom"); - if (auto* ri = get_remote_index(hTable)) { - // Sets + teardown flush here, but NOT a deferred order switch: - // that absorbs into the nav below (fused frame) when it targets - // this order, or flushes plainly otherwise. - if (UNSIGNED32 frc = remote_flush_sets(ri->parent); frc != 0) return frc; - if (UNSIGNED32 frc = remote_flush_teardown(ri->parent); frc != 0) return frc; - if (ri->parent != nullptr && ri->parent->pending_order) { - if (ri->parent->pending_order_id == ri->id && - ri->parent->conn != nullptr && - ri->parent->conn->server_nav_order_fuse()) { - ri->parent->pending_order = false; - ri->parent->found_cached = true; - ri->parent->current_found = false; - ri->parent->invalidate_prefetch(); - auto fr = ri->conn->goto_bottom_fused(ri->parent, ri->id); - if (!fr) return fail(fr.error()); - ri->parent->server_order_id = ri->id; - ri->parent->active_index_id = ri->id; - ri->parent->key_count_cached = false; - remote_sync_keyno_gotobottom(ri->parent); - remote_nav_stamp(ri->parent, 2, ri->id); - cli_trace_tbl(ri->parent, "AdsGotoBottom(idx)", "fused: row_valid=%d recno=%u keyno=%u eof=%d bof=%d", - (int)ri->parent->row_valid, - ri->parent->current_recno, - ri->parent->current_keyno, - (int)ri->parent->nav_at_eof, - (int)ri->parent->nav_at_bof); - if (Handle th = handle_for_remote_table(ri->parent)) - apply_relations_for_handle(to_ads_handle(th)); - return ok(); - } - if (UNSIGNED32 frc = remote_flush_order(ri->parent); - frc != 0) { - return frc; - } - } - if (remote_nav_duplicate(ri->parent, 2, ri->id)) { - cli_trace_tbl(ri->parent, "AdsGotoBottom(idx)", "duplicate suppressed"); - remote_sync_keyno_gotobottom(ri->parent); - if (Handle th = handle_for_remote_table(ri->parent)) - apply_relations_for_handle(to_ads_handle(th)); - return ok(); - } - if (remote_nav_pair(ri->parent, 2, ri->id)) { - // mtfix12 R1: the preceding GotoTop certified this landing. - auto pr = ri->parent->conn->apply_pair_blob(ri->parent); - if (!pr) return fail(pr.error()); - cli_trace_tbl(ri->parent, "AdsGotoBottom(idx)", "pair certified"); - remote_sync_keyno_gotobottom(ri->parent); - remote_nav_stamp(ri->parent, 2, ri->id); - if (Handle th = handle_for_remote_table(ri->parent)) - apply_relations_for_handle(to_ads_handle(th)); - return ok(); - } - auto r = openads::network::remote_index_goto_bottom(ri); - if (!r) return fail(r.error()); - remote_sync_keyno_gotobottom(ri->parent); - remote_nav_stamp(ri->parent, 2, ri->id); - cli_trace_tbl(ri->parent, "AdsGotoBottom(idx)", "row_valid=%d recno=%u keyno=%u eof=%d bof=%d", - (int)ri->parent->row_valid, ri->parent->current_recno, - ri->parent->current_keyno, (int)ri->parent->nav_at_eof, - (int)ri->parent->nav_at_bof); - if (Handle th = handle_for_remote_table(ri->parent)) - apply_relations_for_handle(to_ads_handle(th)); - return ok(); - } - if (auto* rt = get_remote_table(hTable)) { - // Sets + teardown flush here; a deferred order switch absorbs - // into the nav below (fused frame) instead of going out alone. - if (UNSIGNED32 frc = remote_flush_sets(rt); frc != 0) return frc; - if (UNSIGNED32 frc = remote_flush_teardown(rt); frc != 0) return frc; - if (UNSIGNED32 frc = remote_close_parked_indexes(rt); frc != 0) { - return frc; - } - rt->found_cached = true; rt->current_found = false; // M12.21: GoBottom clears Found() - if (rt->pending_order) { - if (rt->conn != nullptr && - rt->conn->server_nav_order_fuse()) { - const std::uint32_t oid = rt->pending_order_id; - rt->pending_order = false; - rt->invalidate_prefetch(); - auto fr = rt->conn->goto_bottom_fused(rt, oid); - if (!fr) return fail(fr.error()); - rt->server_order_id = oid; - rt->active_index_id = oid; - rt->key_count_cached = false; - remote_sync_keyno_gotobottom(rt); - remote_nav_stamp(rt, 2, oid); - cli_trace_tbl(rt, "AdsGotoBottom", "fused: row_valid=%d recno=%u keyno=%u eof=%d bof=%d", - (int)rt->row_valid, rt->current_recno, - rt->current_keyno, (int)rt->nav_at_eof, - (int)rt->nav_at_bof); - apply_relations_for_handle(hTable); - return ok(); - } - if (UNSIGNED32 frc = remote_flush_order(rt); frc != 0) return frc; - } - if (remote_nav_duplicate(rt, 2, rt->server_order_id)) { - cli_trace_tbl(rt, "AdsGotoBottom", "duplicate suppressed"); - remote_sync_keyno_gotobottom(rt); - apply_relations_for_handle(hTable); - return ok(); - } - if (remote_nav_pair(rt, 2, rt->server_order_id)) { - auto pr = rt->conn->apply_pair_blob(rt); - if (!pr) return fail(pr.error()); - cli_trace_tbl(rt, "AdsGotoBottom", "pair certified"); - remote_sync_keyno_gotobottom(rt); - remote_nav_stamp(rt, 2, rt->server_order_id); - apply_relations_for_handle(hTable); - return ok(); - } - auto r = rt->conn->goto_bottom(rt); - if (!r) return fail(r.error()); - remote_sync_keyno_gotobottom(rt); - remote_nav_stamp(rt, 2, rt->server_order_id); - apply_relations_for_handle(hTable); - return ok(); - } - if (auto* ops = openads::abi::backend_table_ops_for(hTable)) { - if (ops->goto_bottom) { - UNSIGNED32 rc = ops->goto_bottom(hTable); - if (rc == openads::AE_SUCCESS) apply_relations_for_handle(hTable); - return rc; - } - } - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - auto r = t->goto_bottom(); - if (!r) return fail(r.error()); - snapshot_ri_pks(t); - apply_relations_for_handle(hTable); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsSkip(ADSHANDLE hTable, SIGNED32 lRows) { - arc2_trace("AdsSkip"); - seek_last_retry_latch() = false; - if (auto* ri = get_remote_index(hTable)) { - cli_trace_tbl(ri->parent, "AdsSkip(idx)", "rows=%d", (int)lRows); - openads::network::RemoteTable* rt = ri->parent; - if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; - const std::uint32_t rec_before = - (rt != nullptr && rt->row_valid) ? rt->current_recno : 0u; - const bool row_valid_before = rt != nullptr && rt->row_valid; - auto r = openads::network::remote_index_skip(ri, lRows); - if (!r) return fail(r.error()); - remote_sync_keyno_skip(rt, lRows); - remote_update_nav_boundaries(rt, lRows, rec_before, row_valid_before); - cli_trace_tbl(rt, "AdsSkip(idx)", "done: row_valid=%d recno=%u keyno=%u eof=%d bof=%d", - (int)rt->row_valid, rt->current_recno, rt->current_keyno, - (int)rt->nav_at_eof, (int)rt->nav_at_bof); - if (Handle th = handle_for_remote_table(rt)) - apply_relations_for_handle(to_ads_handle(th)); - return ok(); - } - if (auto* rt = get_remote_table(hTable)) { - if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; - if (UNSIGNED32 frc = remote_close_parked_indexes(rt); frc != 0) { - return frc; - } - rt->found_cached = true; rt->current_found = false; // M12.21: Skip clears Found() - const std::uint32_t rec_before = - rt->row_valid ? rt->current_recno : 0u; - const bool row_valid_before = rt->row_valid; - // M12.21 -- sequential prefetch: Skip(1) drains the queue - // populated by the previous Skip's lookahead block. Zero - // RTT for every cached step. - // - // RCB 07/14/2026: the drain itself now lives in remote_drain_prefetch() - // because the index-handle Skip path (remote_index_skip -- what rddads - // ACTUALLY calls once an order is set, via hOrdCurrent) needs exactly - // the same logic. It used to have none at all: it threw the queue away - // on every skip. Duplicating the drain there would have meant two copies - // of the consumed-counter bookkeeping that keeps the lagging server - // cursor correct, and that counter is the whole reason "option B" of - // this feature had to be shelved once already. One copy, two callers. - if (lRows == 1 && - openads::network::remote_drain_prefetch(rt, +1)) { - remote_sync_keyno_skip(rt, lRows); - remote_update_nav_boundaries(rt, lRows, rec_before, row_valid_before); - apply_relations_for_handle(hTable); - return ok(); - } - // RCB 07/15/2026: M12.25 -- PgUp. Symmetric to the forward drain above. - if (lRows == -1 && - openads::network::remote_drain_prefetch(rt, -1)) { - remote_sync_keyno_skip(rt, lRows); - remote_update_nav_boundaries(rt, lRows, rec_before, row_valid_before); - apply_relations_for_handle(hTable); - return ok(); - } - // Any non-sequential nav drops the queue (handled inside - // parse_row_trailer_into when the new ack arrives). - auto r = rt->conn->skip(rt, lRows); - if (!r) return fail(r.error()); - remote_sync_keyno_skip(rt, lRows); - remote_update_nav_boundaries(rt, lRows, rec_before, row_valid_before); - apply_relations_for_handle(hTable); - return ok(); - } - if (auto* ops = openads::abi::backend_table_ops_for(hTable)) { - if (ops->skip) { - UNSIGNED32 rc = ops->skip(hTable, lRows); - if (rc == openads::AE_SUCCESS) apply_relations_for_handle(hTable); - return rc; - } - } - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - auto r = t->skip(lRows); - if (!r) return fail(r.error()); - snapshot_ri_pks(t); - apply_relations_for_handle(hTable); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsAtEOF(ADSHANDLE hTable, UNSIGNED16* pbAtEnd) { - arc2_trace("AdsAtEOF"); - if (auto* rt = get_remote_table(hTable)) { - if (pbAtEnd == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - cli_trace_tbl(rt, "AdsAtEOF", "nav_eof=%d row_valid=%d", - (int)rt->nav_at_eof, (int)rt->row_valid); - if (rt->nav_at_eof) { *pbAtEnd = 1; return ok(); } - // M12.21 option C -- a valid cached current row (including one - // served locally from the prefetch queue) means the cursor is - // on a record, so it cannot be at EOF: answer with no round - // trip. This is what lets a prefetched scan loop, which polls - // Eof() every iteration, actually shed its per-step round trips. - if (rt->row_valid) { *pbAtEnd = 0; return ok(); } - // Proven not-EOF (skip landed on a row, bottom positioned): - // answer locally even with no valid row right now. - if (rt->nav_not_eof) { *pbAtEnd = 0; return ok(); } - // Empty-cursor sticky (see remote_nav_empty_sticky): the last - // wire nav proved the cursor empty with nothing on the wire - // since — an empty cursor is EOF without asking the server. - if (remote_nav_empty_sticky(rt)) { *pbAtEnd = 1; return ok(); } - // Seq-gated repeat: same answer as moments ago, still current. - { - bool be = false; - if (remote_nav_bound_get(rt, true, &be)) { - *pbAtEnd = be ? 1 : 0; - return ok(); - } - } - auto r = rt->conn->eof_bof(rt->id); - if (!r) return fail(r.error()); - // Twin flag: the ack carried the BOF answer too — cache it so - // the twin half of rddads' pair is served locally. - if (r.value().has_twin) - rt->nav_at_bof = r.value().bof; - *pbAtEnd = r.value().eof ? 1 : 0; - remote_nav_bound_store(rt, r.value().eof, true, r.value().bof, - r.value().has_twin); - return ok(); - } - 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; - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsAtBOF(ADSHANDLE hTable, UNSIGNED16* pbAtBegin) { - arc2_trace("AdsAtBOF"); - if (pbAtBegin == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (auto* rt = get_remote_table(hTable)) { - cli_trace_tbl(rt, "AdsAtBOF", "nav_bof=%d row_valid=%d", - (int)rt->nav_at_bof, (int)rt->row_valid); - if (rt->nav_at_bof) { *pbAtBegin = 1; return ok(); } - // M12.21 option C -- a valid cached current row means the cursor - // is on a record, so it cannot be at BOF: answer with no round - // trip (see AdsAtEOF). - if (rt->row_valid) { *pbAtBegin = 0; return ok(); } - // Proven not-BOF: answer locally even with no valid row. - if (rt->nav_not_bof) { *pbAtBegin = 0; return ok(); } - // Empty-cursor sticky: an empty cursor is BOF without asking. - if (remote_nav_empty_sticky(rt)) { *pbAtBegin = 1; return ok(); } - // Seq-gated repeat: same answer as moments ago, still current. - { - bool bb = false; - if (remote_nav_bound_get(rt, false, &bb)) { - *pbAtBegin = bb ? 1 : 0; - return ok(); - } - } - auto r = rt->conn->bof_eof(rt->id); - if (!r) return fail(r.error()); - // Twin flag: cache the EOF answer for the twin half of the pair. - if (r.value().has_twin) - rt->nav_at_eof = r.value().eof; - *pbAtBegin = r.value().bof ? 1 : 0; - remote_nav_bound_store(rt, r.value().eof, r.value().has_twin, - r.value().bof, true); - return ok(); - } - 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; - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsGetNumFields(ADSHANDLE hTable, UNSIGNED16* pusFields) { - arc2_trace("AdsGetNumFields"); - if (pusFields == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (auto* rt = get_remote_table(hTable)) { - if (!rt->fields_cached) { - auto r = rt->conn->describe_table(rt->id); - if (!r) return fail(r.error()); - rt->fields = std::move(r).value(); - rt->fields_cached = true; - } - if (!openads::abi::assign_u16(pusFields, rt->fields.size())) { - return fail(openads::AE_INTERNAL_ERROR, "field count exceeds 65535"); - } - return ok(); - } - 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) { - if (!openads::abi::assign_u16(pusFields, p->size())) { - return fail(openads::AE_INTERNAL_ERROR, "field count exceeds 65535"); - } - } else { - *pusFields = t->field_count(); - } - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsGetFieldName(ADSHANDLE hTable, UNSIGNED16 usFieldNum, - UNSIGNED8* pucBuf, UNSIGNED16* pusLen) { - arc2_trace("AdsGetFieldName"); - if (auto* rt = get_remote_table(hTable)) { - if (!rt->fields_cached) { - auto r = rt->conn->describe_table(rt->id); - if (!r) return fail(r.error()); - rt->fields = std::move(r).value(); - rt->fields_cached = true; - } - if (usFieldNum == 0 || usFieldNum > rt->fields.size()) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - openads::abi::copy_to_caller(pucBuf, pusLen, - rt->fields[usFieldNum - 1].name); - return ok(); - } - 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); - std::uint16_t src_idx = 0; - if (p != nullptr) { - if (usFieldNum == 0 || usFieldNum > p->size()) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - // S4 -- a projected column is named after the SELECT list, not - // after the table's declared spelling. See - // build_projection_aliases() for SAP's exact rule. - if (const auto* al = aliases_for(hTable); - al != nullptr && usFieldNum <= al->size() && - !(*al)[usFieldNum - 1].empty()) { - openads::abi::copy_to_caller(pucBuf, pusLen, - (*al)[usFieldNum - 1]); - return ok(); - } - src_idx = (*p)[usFieldNum - 1]; - } else { - if (usFieldNum == 0 || usFieldNum > t->field_count()) { - return fail(openads::AE_COLUMN_NOT_FOUND, "field index out of range"); - } - src_idx = static_cast(usFieldNum - 1); - } - const auto& f = t->field_descriptor(src_idx); - openads::abi::copy_to_caller(pucBuf, pusLen, f.name); - return ok(); -} - -// Cache the remote schema on `rt` if not already; return index of -// the field whose name (case-insensitive) matches `pucField`, or -// SIZE_MAX when not found. Used by the three remote field-by-name -// metadata bridges below. -namespace { - -static void uppercase_ascii(std::string& s) { - for (auto& c : s) { - c = static_cast( - std::toupper(static_cast(c))); - } -} - -static bool field_arg_ordinal(UNSIGNED8* pucField, std::size_t limit, - std::size_t* zero_based) { - auto p = reinterpret_cast(pucField); - if (p == 0 || p >= 0x10000u) return false; - std::size_t one_based = static_cast(p); - if (one_based < 1 || one_based > limit) return false; - if (zero_based != nullptr) *zero_based = one_based - 1; - return true; -} - -static bool field_name_matches(UNSIGNED8* pucField, const std::string& have) { - if (pucField == nullptr) return false; - auto p = reinterpret_cast(pucField); - if (p != 0 && p < 0x10000u) return false; - std::string want = openads::abi::to_internal(pucField, 0); - std::string normalized_have = have; - uppercase_ascii(want); - uppercase_ascii(normalized_have); - return want == normalized_have; -} - -static bool projection_field_position(ADSHANDLE h, Table* t, - UNSIGNED8* pucField, - std::uint16_t* ordinal, - std::uint16_t* source_idx) { - const auto* proj = projection_for(h); - if (proj == nullptr || t == nullptr) return false; - std::size_t pos = 0; - if (field_arg_ordinal(pucField, proj->size(), &pos)) { - *ordinal = static_cast(pos); - *source_idx = (*proj)[pos]; - return true; - } - if (pucField == nullptr) return false; - auto p = reinterpret_cast(pucField); - if (p != 0 && p < 0x10000u) return false; - std::string want = openads::abi::to_internal(pucField, 0); - uppercase_ascii(want); - for (std::size_t i = 0; i < proj->size(); ++i) { - const auto src = (*proj)[i]; - if (src >= t->field_count()) continue; - std::string have = t->field_descriptor(src).name; - uppercase_ascii(have); - if (have == want) { - *ordinal = static_cast(i); - *source_idx = src; - return true; - } - } - return false; -} - -static UNSIGNED32 backend_field_num(ADSHANDLE hTable, - const openads::abi::BackendTableOps* ops, - UNSIGNED8* pucField, - UNSIGNED16* pusNum) { - if (ops == nullptr || ops->num_fields == nullptr || - ops->field_name == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, ""); - } - UNSIGNED16 count = 0; - UNSIGNED32 rc = ops->num_fields(hTable, &count); - if (rc != openads::AE_SUCCESS) return rc; - std::size_t pos = 0; - if (field_arg_ordinal(pucField, count, &pos)) { - *pusNum = static_cast(pos + 1); - return ok(); - } - for (UNSIGNED16 i = 1; i <= count; ++i) { - UNSIGNED8 name[512] = {0}; - UNSIGNED16 len = sizeof(name); - rc = ops->field_name(hTable, i, name, &len); - if (rc != openads::AE_SUCCESS) return rc; - if (field_name_matches(pucField, - std::string(reinterpret_cast(name)))) { - *pusNum = i; - return ok(); - } - } - return fail(openads::AE_COLUMN_NOT_FOUND, ""); -} - -static UNSIGNED32 backend_field_offset( - ADSHANDLE hTable, const openads::abi::BackendTableOps* ops, - UNSIGNED8* pucField, UNSIGNED32* pulOffset) { - if (ops == nullptr || ops->num_fields == nullptr || - ops->field_name == nullptr || ops->field_length == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, ""); - } - UNSIGNED16 count = 0; - UNSIGNED32 rc = ops->num_fields(hTable, &count); - if (rc != openads::AE_SUCCESS) return rc; - std::size_t want_pos = std::numeric_limits::max(); - (void)field_arg_ordinal(pucField, count, &want_pos); - UNSIGNED32 offset = 1; - for (UNSIGNED16 i = 1; i <= count; ++i) { - UNSIGNED8 name[512] = {0}; - UNSIGNED16 len = sizeof(name); - rc = ops->field_name(hTable, i, name, &len); - if (rc != openads::AE_SUCCESS) return rc; - if (want_pos == static_cast(i - 1) || - field_name_matches(pucField, - std::string(reinterpret_cast(name)))) { - *pulOffset = offset; - return ok(); - } - UNSIGNED32 field_len = 0; - rc = ops->field_length(hTable, name, &field_len); - if (rc != openads::AE_SUCCESS) return rc; - offset += field_len; - } - return fail(openads::AE_COLUMN_NOT_FOUND, ""); -} - -std::size_t remote_field_index(openads::network::RemoteTable* rt, - UNSIGNED8* pucField) { - if (!rt->fields_cached) { - auto r = rt->conn->describe_table(rt->id); - if (!r) return std::numeric_limits::max(); - rt->fields = std::move(r).value(); - rt->fields_cached = true; - } - // ACE "field name OR 1-based ordinal cast to a pointer" idiom -- X#'s - // ADSRDD calls AdsGetFieldType/Length/Decimals (and the value - // getters) by ordinal. A tiny pointer value is the ordinal; reading - // it as a string address would fault. - { - auto p = reinterpret_cast(pucField); - if (p != 0 && p < 0x10000u) { - std::size_t one_based = static_cast(p); - if (one_based >= 1 && one_based <= rt->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 < rt->fields.size(); ++i) { - std::string have = rt->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(); -} - -} // namespace - -UNSIGNED32 ENTRYPOINT AdsGetFieldNum(ADSHANDLE hTable, UNSIGNED8* pucFldName, - UNSIGNED16* pusNum) { - arc2_trace("AdsGetFieldNum"); - if (pusNum == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (auto* rt = get_remote_table(hTable)) { - auto i = remote_field_index(rt, pucFldName); - if (i == std::numeric_limits::max()) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - if (!openads::abi::assign_u16(pusNum, i + 1)) { - return fail(openads::AE_INTERNAL_ERROR, "field number exceeds 65535"); - } - return ok(); - } - if (auto* ops = openads::abi::backend_table_ops_for(hTable)) { - return backend_field_num(hTable, ops, pucFldName, pusNum); - } - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); - std::uint16_t ordinal = 0; - std::uint16_t src_idx = 0; - if (projection_field_position(hTable, t, pucFldName, &ordinal, &src_idx)) { - *pusNum = static_cast(ordinal + 1); - return ok(); - } - if (!resolve_field_index(t, pucFldName, &src_idx)) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - *pusNum = static_cast(src_idx + 1); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsGetFieldOffset(ADSHANDLE hTable, UNSIGNED8* pucFldName, - UNSIGNED32* pulOffset) { - arc2_trace("AdsGetFieldOffset"); - if (pulOffset == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (auto* rt = get_remote_table(hTable)) { - auto i = remote_field_index(rt, pucFldName); - if (i == std::numeric_limits::max()) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - UNSIGNED32 offset = 1; - for (std::size_t n = 0; n < i; ++n) { - offset += rt->fields[n].length; - } - *pulOffset = offset; - return ok(); - } - if (auto* ops = openads::abi::backend_table_ops_for(hTable)) { - return backend_field_offset(hTable, ops, pucFldName, pulOffset); - } - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); - std::uint16_t ordinal = 0; - std::uint16_t src_idx = 0; - if (projection_field_position(hTable, t, pucFldName, &ordinal, &src_idx)) { - UNSIGNED32 offset = 1; - const auto* proj = projection_for(hTable); - for (std::size_t i = 0; proj != nullptr && i < ordinal; ++i) { - const auto prev = (*proj)[i]; - if (prev < t->field_count()) { - offset += t->field_descriptor(prev).length; - } - } - *pulOffset = offset; - return ok(); - } - if (!resolve_field_index(t, pucFldName, &src_idx)) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - *pulOffset = t->field_descriptor(src_idx).record_offset; - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsGetFieldType(ADSHANDLE hTable, UNSIGNED8* pucField, - UNSIGNED16* pusType) { - arc2_trace("AdsGetFieldType"); - if (pusType == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (auto* rt = get_remote_table(hTable)) { - auto i = remote_field_index(rt, pucField); - if (i == std::numeric_limits::max()) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - *pusType = rt->fields[i].type; - return ok(); - } - 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; - if (!resolve_field_index_h(hTable, t, pucField, &idx)) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - *pusType = map_field_type(t->field_descriptor(idx).type); - // ADT IMAGE (raw type 7) vs BINARY (raw type 6): both map to - // DbfFieldType::Binary internally, but the ABI type differs. - const auto& fd = t->field_descriptor(idx); - if (fd.type == openads::drivers::DbfFieldType::Binary) { - auto raw = static_cast(fd.raw_type); - if (raw == 7u) *pusType = static_cast(ADS_IMAGE); - else if (raw == 6u) *pusType = static_cast(ADS_BINARY); - } - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsGetFieldLength(ADSHANDLE hTable, UNSIGNED8* pucField, - UNSIGNED32* pulLen) { - arc2_trace("AdsGetFieldLength"); - if (pulLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (auto* rt = get_remote_table(hTable)) { - auto i = remote_field_index(rt, pucField); - if (i == std::numeric_limits::max()) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - *pulLen = rt->fields[i].length; - return ok(); - } - 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; - if (!resolve_field_index_h(hTable, t, pucField, &idx)) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - const auto& fd = t->field_descriptor(idx); - // Return the string representation length, not the raw field length, for - // types where decode_field() produces a longer formatted string. This lets - // callers (e.g. the PHP extension) allocate the right buffer size before - // calling AdsGetString. - using openads::drivers::DbfFieldType; - switch (fd.type) { - case DbfFieldType::DateTime: - case DbfFieldType::AdtTimestamp: - case DbfFieldType::ModTime: - *pulLen = 14; // "YYYYMMDDHHMMSS" - break; - case DbfFieldType::AdtDate: - *pulLen = 8; // "YYYYMMDD" - break; - case DbfFieldType::RowVersion: - *pulLen = 20; // up to 20 digits for uint64_max - break; - default: - *pulLen = fd.length; - break; - } - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsGetFieldLength100(ADSHANDLE hTable, - UNSIGNED8* pucField, - UNSIGNED32 /*ulOptions*/, - UNSIGNED32* pulLen) { - arc2_trace("AdsGetFieldLength100"); - return AdsGetFieldLength(hTable, pucField, pulLen); -} - -UNSIGNED32 ENTRYPOINT AdsGetFieldDecimals(ADSHANDLE hTable, UNSIGNED8* pucField, - UNSIGNED16* pusDec) { - arc2_trace("AdsGetFieldDecimals"); - if (pusDec == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (auto* rt = get_remote_table(hTable)) { - auto i = remote_field_index(rt, pucField); - if (i == std::numeric_limits::max()) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - *pusDec = rt->fields[i].decimals; - return ok(); - } - 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; - if (!resolve_field_index_h(hTable, t, pucField, &idx)) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - *pusDec = t->field_descriptor(idx).decimals; - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsGetLong(ADSHANDLE hTable, UNSIGNED8* pucField, SIGNED32* plVal) { - arc2_trace("AdsGetLong"); - if (plVal == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (auto* rt = get_remote_table(hTable)) { - auto i = remote_field_index(rt, pucField); - if (i == std::numeric_limits::max()) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - if (!rt->row_valid) { - (void)rt->conn->fetch_current_row(rt); - } - std::string vstr; - const bool overlaid = remote_pending_overlay(rt, i, vstr); - if (!overlaid && rt->row_valid && i < rt->current_row.size()) { - vstr = rt->current_row[i]; - } else if (!overlaid && i < rt->fields.size()) { - auto v = rt->conn->get_field(rt->id, rt->fields[i].name); - if (!v) return fail(v.error()); - vstr = std::move(v).value(); - } - try { *plVal = static_cast(std::stol(vstr)); } - catch (...) { *plVal = 0; } - return ok(); - } - // SQL backend (e.g. postgresql): read text via the per-backend ops - // vtable then parse. Mirrors the AdsGetDouble fix -- without it a PG - // handle fell through to the native get_table() path and errored. - if (auto* ops = openads::abi::backend_table_ops_for(hTable)) { - if (ops->get_field) { - UNSIGNED8 buf[64] = {0}; - UNSIGNED32 cap = sizeof(buf); - UNSIGNED32 rc = ops->get_field(hTable, pucField, buf, &cap, 0); - if (rc != 0) return rc; - std::string vstr(reinterpret_cast(buf), - std::min(cap, sizeof(buf))); - try { *plVal = static_cast(std::stol(vstr)); } - catch (...) { *plVal = 0; } - return ok(); - } - } - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); - std::uint16_t idx = 0; - if (!resolve_field_index(t, pucField, &idx)) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - auto v = t->read_field(idx); - if (!v) return fail(v.error()); - *plVal = static_cast(v.value().as_double); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsGetDouble(ADSHANDLE hTable, UNSIGNED8* pucField, double* pdVal) { - arc2_trace("AdsGetDouble"); - if (pdVal == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (auto* rt = get_remote_table(hTable)) { - auto i = remote_field_index(rt, pucField); - if (i == std::numeric_limits::max()) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - if (!rt->row_valid) { - (void)rt->conn->fetch_current_row(rt); - } - std::string vstr; - const bool overlaid = remote_pending_overlay(rt, i, vstr); - if (!overlaid && rt->row_valid && i < rt->current_row.size()) { - vstr = rt->current_row[i]; - } else if (!overlaid && i < rt->fields.size()) { - auto v = rt->conn->get_field(rt->id, rt->fields[i].name); - if (!v) return fail(v.error()); - vstr = std::move(v).value(); - } - try { *pdVal = std::stod(vstr); } - catch (...) { *pdVal = 0.0; } - return ok(); - } - // SQL backend (e.g. postgresql): read the field as text through the - // per-backend ops vtable, then parse the numeric. Without this branch a - // PG handle fell through to the native get_table() path below, which - // returns null for a non-native table -> AdsGetDouble yielded an error. - if (auto* ops = openads::abi::backend_table_ops_for(hTable)) { - if (ops->get_field) { - UNSIGNED8 buf[64] = {0}; - UNSIGNED32 cap = sizeof(buf); - UNSIGNED32 rc = ops->get_field(hTable, pucField, buf, &cap, 0); - if (rc != 0) return rc; - std::string vstr(reinterpret_cast(buf), - std::min(cap, sizeof(buf))); - try { *pdVal = std::stod(vstr); } - catch (...) { *pdVal = 0.0; } - return ok(); - } - } - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); - std::uint16_t idx = 0; - if (!resolve_field_index(t, pucField, &idx)) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - auto v = t->read_field(idx); - if (!v) return fail(v.error()); - *pdVal = v.value().as_double; - return ok(); -} - -namespace { - -// Gregorian -> Clipper/Harbour Julian Day Number. Same formula as -// hb_dateEncode in Harbour core. -SIGNED32 to_julian(int y, int m, int d) { - long y32 = y; - long m32 = m; - long d32 = d; - return static_cast( - (1461 * (y32 + 4800 + (m32 - 14) / 12)) / 4 - + (367 * (m32 - 2 - 12 * ((m32 - 14) / 12))) / 12 - - (3 * ((y32 + 4900 + (m32 - 14) / 12) / 100)) / 4 - + d32 - 32075); -} - -// Count the live (non-deleted) recnos of an index walk. -// -// Reads in RECNO order, not key order. The walk arrives ordered by key, and on -// an index whose key does not correlate with recno that makes consecutive reads -// land in different read-ahead blocks -- every record then costs a fresh 64 KB -// block fetch and the driver's read-ahead buys nothing. Measured on two copies -// of the same 34,595-row table: 14 ms where key order happened to follow recno, -// 492 ms where it did not. The count does not depend on the order, so read the -// records in the order the file stores them. -// Opt-in switch for the ADI v2 tag layout (compound / computed / FOR tags, -// dense leaf with front coding). OFF by default, so a deployment that does not -// ask for it keeps the legacy single-field bag byte for byte. Read on every -// call -- index creation is not a hot path -- so a test or an application can turn -// it on for one process without a restart. -// -// g_adi_v2_override lets a caller flip this from AdsSetAdiV2() instead of the -// environment. Windows does not share a CRT's cached _environ across modules -// linked against separate ucrtbase instances (confirmed: an env var set from -// a Harbour host process via its own putenv/SetEnvironmentVariable is not -// visible to this DLL's std::getenv() without a fresh process) -- an in-DLL -// flag sidesteps that entirely. -1 = unset (fall back to the env var), 0/1 = -// explicit override. -std::atomic g_adi_v2_override{-1}; - -bool adi_v2_enabled() { - const int ov = g_adi_v2_override.load(std::memory_order_relaxed); - if (ov >= 0) return ov != 0; - return openads::util::client_setting_truthy("OPENADS_ADI_V2", "adi_v2"); -} - -// Counting the live entries of an index costs one deleted_at() per entry, and -// deleted_at() reads the whole record. On a 34.595-row table that is ~15 ms, -// and AdsGetKeyCount is what a TXBrowse asks on EVERY paint to size its -// scrollbar -- so the browse paid it again and again for an answer that had -// not changed. Memoise per index (`owner`), keyed by the table's live -// generation, which moves on delete / recall / append / zap / pack and on the -// refresh that makes another station's work visible. -std::uint32_t count_live_recnos(openads::engine::Table* t, - const std::vector& walk, - const void* owner) { - struct Cached { - const openads::engine::Table* table = nullptr; - std::uint64_t gen = 0; - std::size_t size = 0; - std::uint32_t count = 0; - }; - static std::unordered_map cache; - - const std::uint64_t gen = t->live_gen(); - if (owner != nullptr) { - auto it = cache.find(owner); - if (it != cache.end() && it->second.table == t && - it->second.gen == gen && it->second.size == walk.size()) { - return it->second.count; - } - } - - std::vector by_recno(walk); - std::sort(by_recno.begin(), by_recno.end()); - std::uint32_t n = 0; - for (std::uint32_t rn : by_recno) { - auto del = t->deleted_at(rn); - if (del && !del.value()) ++n; - } - if (owner != nullptr) cache[owner] = Cached{t, gen, walk.size(), n}; - return n; -} - -} // namespace - -UNSIGNED32 ENTRYPOINT AdsGetJulian(ADSHANDLE hTable, UNSIGNED8* pucField, SIGNED32* plDate) { - arc2_trace("AdsGetJulian"); - if (plDate == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - auto decode_date = [](const std::string& s) -> SIGNED32 { - if (s.size() < 8) return 0; - int y = (s[0] - '0') * 1000 + (s[1] - '0') * 100 - + (s[2] - '0') * 10 + (s[3] - '0'); - int m = (s[4] - '0') * 10 + (s[5] - '0'); - int d = (s[6] - '0') * 10 + (s[7] - '0'); - if (y > 0 && m >= 1 && m <= 12 && d >= 1 && d <= 31) { - return to_julian(y, m, d); - } - return 0; - }; - if (auto* rt = get_remote_table(hTable)) { - // Use ordinal-safe index + row cache (or fallback get_field by - // resolved name) so that callers passing field ordinals (X#, FWH - // TDataBase) don't AV in to_internal, and Date fields work. - auto i = remote_field_index(rt, pucField); - if (i == std::numeric_limits::max()) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - if (!rt->row_valid) { - (void)rt->conn->fetch_current_row(rt); - } - std::string vstr; - const bool overlaid = remote_pending_overlay(rt, i, vstr); - if (!overlaid && rt->row_valid && i < rt->current_row.size()) { - vstr = rt->current_row[i]; - } else if (!overlaid && i < rt->fields.size()) { - auto v = rt->conn->get_field(rt->id, rt->fields[i].name); - if (!v) return fail(v.error()); - vstr = std::move(v).value(); - } - *plDate = decode_date(vstr); - return ok(); - } - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); - std::uint16_t idx = 0; - if (!resolve_field_index(t, pucField, &idx)) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - auto v = t->read_field(idx); - if (!v) return fail(v.error()); - const std::string& s = v.value().as_string; - *plDate = 0; - if (s.size() >= 8) { - int y = (s[0] - '0') * 1000 + (s[1] - '0') * 100 - + (s[2] - '0') * 10 + (s[3] - '0'); - int m = (s[4] - '0') * 10 + (s[5] - '0'); - int d = (s[6] - '0') * 10 + (s[7] - '0'); - if (y > 0 && m >= 1 && m <= 12 && d >= 1 && d <= 31) { - *plDate = to_julian(y, m, d); - } - } - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsGetRecordNum(ADSHANDLE hTable, UNSIGNED16 /*bFilterOption*/, - UNSIGNED32* pulRecordNum) { - arc2_trace("AdsGetRecordNum"); - if (pulRecordNum == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (auto* rt = get_remote_table(hTable)) { - // M12.18 -- recno is part of the row trailer that arrives - // with every nav ack, so the cache hit avoids a separate - // GetRecordNum RTT after a nav. - if (rt->row_valid) { - *pulRecordNum = rt->current_recno; - return ok(); - } - // Reposition-bound recno: the last GotoRecord/Seek certified - // the phantom recno in its ack tail (or a wire answer just - // did). Serves xBrowse's per-row RecNo() while no - // cursor-affecting frame lands — same currency as the bound - // cache, cleared with it. - if (rt->recno_bound_ok && - rt->recno_bound_seq == rt->conn->nav_seq()) { - *pulRecordNum = rt->recno_bound; - return ok(); - } - // mtfix12 R3 — the R2 anchor certifies the server cursor's - // recno even when the local row cache was dropped without a - // wire move. With no drain outstanding (lag 0), the wire answer - // would be exactly anchor_recno. - if (rt->anchor_ok && rt->cursor_lag == 0 && - (remote_self_goto_per_table() - ? rt->anchor_tbl_seq == rt->conn->tbl_nav_seq(rt->id) - : rt->anchor_seq == rt->conn->nav_seq())) { - *pulRecordNum = rt->anchor_recno; - return ok(); - } - // Phantom derivation (no frame, no currency): at the EOF - // phantom the recno is LastRec+1 by Clipper convention — a - // pure function of the certified EOF flag plus the cached - // count. Applies in natural AND ordered walks (the twin - // reports physical n+1 past the last key, same engine rule). - // Restricted to certain semantics: no filter/AOF/scope - // (scoped/conditional walks may end elsewhere — those keep - // the wire path), and a cached count to derive from. - // Immune to cross-table eviction by construction (nothing - // is stored). - if (!rt->row_valid && rt->nav_at_eof && - rt->filter_expr.empty() && rt->aof_expr.empty() && - !rt->scope_touched && rt->rec_count_cached) { - *pulRecordNum = rt->cached_rec_count + 1; - return ok(); - } - auto r = rt->conn->get_record_num(rt->id); - if (!r) return fail(r.error()); - *pulRecordNum = r.value(); - rt->recno_bound = r.value(); - rt->recno_bound_ok = true; - rt->recno_bound_seq = rt->conn->nav_seq(); - return ok(); - } - 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(); - return ok(); -} - -extern "C++" { -namespace { -struct SqlStatement; -SqlStatement* stmt_lookup(ADSHANDLE h); -} -} - -UNSIGNED32 ENTRYPOINT AdsGetRecordCount(ADSHANDLE hTable, UNSIGNED16 bFilterOption, - UNSIGNED32* pulRecordCount) { - arc2_trace("AdsGetRecordCount"); - arc2_trace("AdsGetRecordCount"); - // SAP ACE also accepts a SQL *statement* handle here (rows affected / - // returned by the last execute). ARC relies on this immediately after - // AdsExecuteSQLDirectW(stmt, sql, NULL) — e.g. sp_SetApplicationID — - // and treats an error as a failed connection setup. - if (stmt_lookup(hTable) != nullptr) { - if (pulRecordCount == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, ""); - } - *pulRecordCount = 0; - return ok(); - } - // rddads OrdKeyCount (DBOI_KEYCOUNT) passes hOrdCurrent here -- a - // RemoteIndex handle. Must return the *index* key count (scope + - // SET DELETED aware on the server), NOT the parent table's physical - // record count. Routing to the table made remote xBrowse allocate - // ghost rows when deleted keys sat inside the scope (Tim's report). - if (auto* ri = get_remote_index(hTable)) { - if (pulRecordCount == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, "remote index"); - } - auto r = openads::network::remote_index_key_count(ri); - if (!r) return fail(r.error()); - *pulRecordCount = r.value(); - return ok(); - } - if (auto* rt = get_remote_table(hTable)) { - if (pulRecordCount == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - // M12.19 -- record count is invariant outside of explicit - // writes (AppendBlank / DeleteRecord / RecallRecord / Pack - // / Zap), so cache the value on first hit and serve every - // subsequent AdsGetRecordCount + AdsGetRelKeyPos (scrollbar) - // call from cache. Each cache hit saves one wire RTT. - if (rt->rec_count_cached) { - *pulRecordCount = rt->cached_rec_count; - return ok(); - } - // Certified count from the last nav ack tail (same trust as - // the cache above — in-memory server count). Covers the - // open→goto→count USE flow with zero extra frames. - if (rt->count_bound_ok && - rt->count_bound_seq == rt->conn->nav_seq()) { - *pulRecordCount = rt->count_bound; - rt->cached_rec_count = rt->count_bound; - rt->rec_count_cached = true; - return ok(); - } - auto r = rt->conn->record_count(rt->id); - if (!r) return fail(r.error()); - rt->cached_rec_count = static_cast(r.value()); - rt->rec_count_cached = true; - *pulRecordCount = rt->cached_rec_count; - return ok(); - } - 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, ""); - // rddads' OrdKeyCount (DBOI_KEYCOUNT) does NOT call AdsGetKeyCount; it - // calls AdsGetRecordCount with the ORDER handle (pArea->hOrdCurrent) to - // count the records reachable THROUGH that order. For a conditional/FOR - // order the index holds only the matching rows, so the count must be the - // index key count (e.g. 4), not the table's physical record_count() (5) -- - // native DBFCDX reports 4 here. AdsGetKeyCount already special-cases this; - // mirror it, but ONLY when an INDEX handle was passed: a TABLE handle must - // still report the full physical count (RecCount() semantics). - if (auto* idx = iindex_for_handle(hTable)) { - if (auto* cdx = - dynamic_cast(idx)) { - // M10.31 -- scope-aware: when scope is active, count only keys - // within the scoped range rather than the full conditional index. - // t was already resolved from hTable via get_table() above, which - // handles index handles via lookup_table_by_index + activate. - if (t && t->order()) { - auto& sc = t->order()->scope(); - // rddads OrdKeyCount calls AdsGetRecordCount on the order - // handle -- must honour SET DELETED ON (exclude deleted - // keys) or remote xBrowse allocates ghost rows / hangs. - const bool hide_del = !t->show_deleted_records(); - std::function live; - const std::function* live_p = nullptr; - if (hide_del) { - // deleted_at() keeps the driver's read-ahead warm and does - // not move the cursor, so there is nothing to restore. - live = [t](std::uint32_t rn) { - auto del = t->deleted_at(rn); - return del && !del.value(); - }; - live_p = &live; - } - if (sc.top.has_value() || sc.bottom.has_value()) { - *pulRecordCount = cdx->count_scoped_keys( - sc.top.value_or(""), - sc.bottom.value_or(""), - live_p); - } else if (hide_del) { - *pulRecordCount = count_live_recnos( - t, cdx->ordered_recnos_cached(), cdx); - } else { - *pulRecordCount = static_cast( - cdx->ordered_recnos_cached().size()); - } - return ok(); - } - *pulRecordCount = static_cast( - cdx->ordered_recnos_cached().size()); - return ok(); - } - if (auto* adi = - dynamic_cast(idx)) { - // Native ADI tag: same rule as CDX -- count the index walk, not - // the table, so a FOR-clause tag reports its matching subset. - // - // Y se cuenta con count_live_recnos, igual que CDX: mirar el - // borrado con goto_record() invalida la cache de lectura en cada - // vuelta, o sea un bloque leido por registro y sin reuso entre - // llamadas. rddads pide OrdKeyCount por esta puerta y un TXBrowse - // la golpea cientos de veces al abrir: con goto_record eran ~9 s - // de pantalla sobre 34.595 filas. - const bool hide_del = t && !t->show_deleted_records(); - *pulRecordCount = hide_del - ? count_live_recnos(t, adi->ordered_recnos_cached(), adi) - : static_cast(adi->ordered_recnos_cached().size()); - return ok(); - } - } - // M10.31 / M10.32 -- when SQL has materialised a traversal sequence - // (DISTINCT / LIMIT / OFFSET / ORDER BY), report that sequence's - // length so apps that drive walking by record-count get the - // post-clause row count. - if (t->has_recno_sequence()) { - *pulRecordCount = static_cast(t->recno_sequence().size()); - } else if (t->has_filter()) { - // M10.33 -- WHERE-filtered cursor without an installed - // sequence (no ORDER BY / DISTINCT / LIMIT). Count - // matching live rows on demand so BETWEEN / LIKE / regular - // predicates surface their cardinality through GetRecordCount. - std::uint32_t rc = t->record_count(); - std::uint32_t pass = 0; - for (std::uint32_t r = 1; r <= rc; ++r) { - if (auto g = t->goto_record(r); !g) continue; - if (!t->show_deleted_records() && - t->is_deleted()) continue; - if (!t->passes_filter()) continue; - ++pass; - } - *pulRecordCount = pass; - } else if (bFilterOption == ADS_RESPECTFILTERS && - !t->show_deleted_records()) { - // ADS_RESPECTFILTERS: count live records only when deleted records - // are hidden (SET DELETED ON). Walk the raw record range, skipping - // deleted rows, then restore the cursor to its original position. - const std::uint32_t saved = t->recno(); - std::uint32_t rc = t->record_count(); - std::uint32_t live = 0; - for (std::uint32_t r = 1; r <= rc; ++r) { - if (auto g = t->goto_record(r); !g) continue; - if (!t->is_deleted()) ++live; - } - t->goto_record(saved); - *pulRecordCount = live; - } else { - // Multiuser: peer appends bump the on-disk header; re-read so - // LastRec() / RecCount() match the other stations (and so the - // browser's EOF fence is not stuck at open-time count). - t->refresh_record_count_from_disk(); - *pulRecordCount = t->record_count(); - } - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsGetField(ADSHANDLE hTable, UNSIGNED8* pucField, - UNSIGNED8* pucBuf, UNSIGNED32* pulLen, - UNSIGNED16 /*usOption*/) { - arc2_trace("AdsGetField"); - if (auto* rt = get_remote_table(hTable)) { - if (pulLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - // M12.17/18 -- serve from row cache. Cache populated either - // by piggyback on the prior nav-op ack (M12.18) or by a - // standalone FetchCurrentRow call here on first access. - // xbrowse-style W cols × H rows repaint: 1 RTT per row, - // zero RTT per cell. - if (!rt->row_valid) { - (void)rt->conn->fetch_current_row(rt); - } - auto fi = remote_field_index(rt, pucField); - if (fi == std::numeric_limits::max()) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - // Buffered sets win over the cached row (fresh appends have no - // fetchable row yet); otherwise serve the cache, else fall - // through to the BOF/EOF blank template below. - { - std::string val; - const bool overlaid = remote_pending_overlay(rt, fi, val); - if (!overlaid && rt->row_valid && fi < rt->current_row.size()) { - val = rt->current_row[fi]; - } - if (!overlaid && !rt->row_valid) { - // fall through to the BOF/EOF blank template below - } else { - if (rt->fields[fi].type == ADS_STRING) - val = pad_char_field(std::move(val), rt->fields[fi].length); - else if (!g_field_read_raw && rt->fields[fi].type == ADS_DATE) - val = format_date_display(val); - else if (!g_field_read_raw && rt->fields[fi].type == ADS_TIMESTAMP) - val = format_timestamp_display(val); - openads::abi::copy_to_caller(pucBuf, pulLen, val); - return ok(); - } - } - // BOF/EOF -- blank template (FWH td_blankrow, TBrowse append row). - return ads_get_field_blank_out( - pucBuf, pulLen, - blank_abi_field_from_ads(rt->fields[fi].type, rt->fields[fi].length), - rt->fields[fi].type, rt->fields[fi].length); - } - 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; - if (!resolve_field_index_h(hTable, t, pucField, &idx)) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - auto v = t->read_field(idx); - if (!v) { - if (is_no_current_record(v.error())) { - const auto& fd = t->field_descriptor(idx); - std::uint16_t ads_type = ADS_STRING; - if (fd.type == openads::drivers::DbfFieldType::Logical) - ads_type = ADS_LOGICAL; - else if (fd.type == openads::drivers::DbfFieldType::Numeric || - fd.type == openads::drivers::DbfFieldType::Float || - fd.type == openads::drivers::DbfFieldType::Integer) - ads_type = ADS_NUMERIC; - return ads_get_field_blank_out( - pucBuf, pulLen, blank_abi_field_from_dbf(fd), - ads_type, fd.length); - } - return fail(v.error()); - } - // Re-pad CHARACTER fields to the declared field width on the way out. - // The internal decode (make_string) trims for the SQL/index engine; - // ABI callers expect the full fixed-width value. - std::string val = v.value().as_string; - const auto& fd = t->field_descriptor(idx); - if (fd.type == openads::drivers::DbfFieldType::Character) { - val = pad_char_field(std::move(val), fd.length); - } else if (!g_field_read_raw && - (fd.type == openads::drivers::DbfFieldType::Date || - fd.type == openads::drivers::DbfFieldType::AdtDate)) { - // SAP: AdsGetField formats dates per the connection date format - // ("01/15/2024"; blank " / / "); AdsGetString stays raw - // "YYYYMMDD". php_ads and the engine internals read via - // GetString / as_string, so this formatting is display-only. - val = format_date_display(v.value().is_null ? std::string() : val); - } else if (!g_field_read_raw && - fd.type == openads::drivers::DbfFieldType::AdtTimestamp) { - // SAP: "MM/DD/CCYY hh:mm:ss AM" (12-hour, 2-digit hour, len 22). - val = format_timestamp_display( - v.value().is_null ? std::string() : val); - } - openads::abi::copy_to_caller(pucBuf, pulLen, val); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsGetLastError(UNSIGNED32* pulCode, UNSIGNED8* pucBuf, - UNSIGNED16* pusBufLen) { - arc2_trace("AdsGetLastError"); - arc2_trace("AdsGetLastError"); - arc2_log("LASTERR code=%d msg=%.120s", - (int)openads::abi::last_error_code(), - openads::abi::last_error_message().c_str()); - if (pulCode != nullptr) *pulCode = static_cast( - openads::abi::last_error_code()); - if (pucBuf != nullptr && pusBufLen != nullptr) { - openads::abi::copy_to_caller(pucBuf, pusBufLen, - openads::abi::last_error_message()); - } - return openads::AE_SUCCESS; -} - -// SAP / rddads signature: 5 args. -// AdsGetVersion(&ulMajor, &ulMinor, &ucLetter, ucDesc, &usDescLen) -// -// pucLetter : single ASCII letter (NOT a UNSIGNED32 codepoint) -- -// writing 4 bytes into a 1-byte slot was undefined. -// pucDesc / pusDescLen : caller-allocated description buffer + -// in/out length. We write the OpenADS version string -// and truncate to the caller's capacity. -// -// Major/minor come from OPENADS_VERSION_STR (CMake project version, e.g. -// "1.8.43" or "1.8.43-5-gdeadbee") so a client can display the server -// build programmatically; the description carries the full string. -UNSIGNED32 ENTRYPOINT AdsGetVersion(UNSIGNED32* pulMajor, UNSIGNED32* pulMinor, - UNSIGNED8* pucLetter, UNSIGNED8* pucDesc, - UNSIGNED16* pusDescLen) { - arc2_trace("AdsGetVersion"); -#ifndef OPENADS_VERSION_STR -# define OPENADS_VERSION_STR "0.0" -#endif - UNSIGNED32 maj = 0, min = 0; - { - const char* v = OPENADS_VERSION_STR; - char* end = nullptr; - unsigned long a = std::strtoul(v, &end, 10); - unsigned long b = 0; - if (end != nullptr && *end == '.') { - b = std::strtoul(end + 1, nullptr, 10); - } - maj = static_cast(a); - min = static_cast(b); - } - if (pulMajor != nullptr) *pulMajor = maj; - if (pulMinor != nullptr) *pulMinor = min; - if (pucLetter != nullptr) *pucLetter = 'a'; - static const char kDesc[] = "OpenADS " OPENADS_VERSION_STR - " ACE-compatible engine"; - if (pucDesc != nullptr && pusDescLen != nullptr) { - UNSIGNED16 cap = *pusDescLen; - UNSIGNED16 n = static_cast( - sizeof(kDesc) - 1 < cap ? sizeof(kDesc) - 1 : cap); - if (n > 0) std::memcpy(pucDesc, kDesc, n); - if (cap > n) pucDesc[n] = '\0'; - *pusDescLen = n; - } else if (pusDescLen != nullptr) { - *pusDescLen = sizeof(kDesc) - 1; - } - return openads::AE_SUCCESS; -} - -// --- M9.15 server info ----------------------------------------------------- -// -// Local-mode connections now report the host name + the local wall clock -// instead of empty strings / 0. AdsGetServerTime returns a six-arg shape -// matching the ACE 6.x signature rddads' ADSGETSERVERTIME function expects -// (date string, time string, milliseconds since midnight) -- the previous -// 2-arg stub left rddads' on-stack pucDateBuf / pucTimeBuf uninitialised. - -namespace { - -UNSIGNED32 emit_text_with_u16len(UNSIGNED8* pucBuf, UNSIGNED16* pusLen, - const std::string& s) { - if (pusLen == nullptr) return openads::AE_INTERNAL_ERROR; - UNSIGNED16 cap = *pusLen; - UNSIGNED16 n = static_cast(s.size() < cap ? s.size() : cap); - if (pucBuf != nullptr && cap > 0) { - if (n > 0) std::memcpy(pucBuf, s.data(), n); - if (n < cap) pucBuf[n] = '\0'; - } - *pusLen = static_cast(s.size()); - return openads::AE_SUCCESS; -} - -} // namespace - -UNSIGNED32 ENTRYPOINT AdsGetServerName(ADSHANDLE /*hConnect*/, - UNSIGNED8* pucBuf, UNSIGNED16* pusLen) { - arc2_trace("AdsGetServerName"); - return emit_text_with_u16len(pucBuf, pusLen, - openads::platform::host_name()); -} - -UNSIGNED32 ENTRYPOINT AdsGetServerTime(ADSHANDLE /*hConnect*/, - UNSIGNED8* pucDateBuf, UNSIGNED16* pusDateLen, - SIGNED32* plTime, - UNSIGNED8* pucTimeBuf, UNSIGNED16* pusTimeLen) { - arc2_trace("AdsGetServerTime"); - auto wc = openads::platform::now_local(); - auto rc = emit_text_with_u16len(pucDateBuf, pusDateLen, wc.date); - if (rc != openads::AE_SUCCESS) return rc; - rc = emit_text_with_u16len(pucTimeBuf, pusTimeLen, wc.time); - if (rc != openads::AE_SUCCESS) return rc; - if (plTime != nullptr) *plTime = wc.ms_of_day; - return openads::AE_SUCCESS; -} - -// OpenADS extension (no SAP equivalent): dotted version of the server -// behind hConnect ("1.09.27"), so a Harbour app can prove WHICH -// serverd binary it is talking to — "the fix didn't help" reports -// keep turning out to be an old serverd still running. -// -// Remote: the HelloAck handshake version captured at connect time -// ("openads/1.09.27" on current servers, the literal "openads/0.3.2" -// on pre-1.8.14 ones), prefix stripped. Empty when the probe failed — -// still AE_SUCCESS; the caller treats empty as unknown. -// Local (or unresolvable handle): the DLL's own build version, which -// IS the server in-process. -UNSIGNED32 ENTRYPOINT AdsGetServerVersion(ADSHANDLE hConnect, - UNSIGNED8* pucBuf, UNSIGNED16* pusLen) { - arc2_trace("AdsGetServerVersion"); - // Remote only: a valid LOCAL Connection handle must never adopt an - // unrelated live remote connection through resolve_remote_conn_handle's - // thread-default/any-live fallback — same guard as AdsCreateTable. - openads::network::RemoteConnection* rc = get_remote_connection(hConnect); - if (rc == nullptr && - state().registry.lookup( - hConnect, HandleKind::Connection) == nullptr) { - ADSHANDLE rh = resolve_remote_conn_handle(hConnect); - if (rh != 0) rc = get_remote_connection(rh); - } - std::string v; - if (rc != nullptr) { - v = rc->server_version(); - auto slash = v.find('/'); - if (slash != std::string::npos) v.erase(0, slash + 1); - // Empty probe stays empty: unknown, still AE_SUCCESS. - } else { -#ifdef OPENADS_VERSION_STR - v = OPENADS_VERSION_STR; -#else - v = "0.0"; -#endif - } - return emit_text_with_u16len(pucBuf, pusLen, v); -} - -// ── Trigger execution ────────────────────────────────────────────────────────── -// Fires enabled triggers on `table_alias` matching `event_mask` (1=INSERT -// 2=UPDATE 3=DELETE) and `timing` (1=BEFORE 2=INSTEAD_OF 4=AFTER). -// Triggers are sorted by priority (ascending) before firing. -// Nesting is tracked with a thread-local depth counter; execution aborts -// when depth exceeds 64 to prevent infinite recursion. -// Returns true if at least one INSTEAD OF trigger was fired (caller should -// skip the actual DML in that case). - -// One collected trigger row-image field: display text + a canonical type -// char ('C','N','I','L','D','T'), so the script layer can hand cursors and -// expressions a properly TYPED value (S3 -- `n.recurring > 0` must see a -// number, not text). -struct TrigField_ { - std::string text; - char type = 'C'; -}; - -// Canonical type char for a driver field type. Time/RowVersion/blob-ish -// types stay 'C' (raw text) -- the script engine has no representation for -// them and text round-trips safely. -inline char trig_type_char_(openads::drivers::DbfFieldType t) { - using FT = openads::drivers::DbfFieldType; - switch (t) { - case FT::Numeric: case FT::Float: case FT::Currency: - case FT::Double: case FT::AdtMoney: - return 'N'; - case FT::Integer: case FT::ShortInt: case FT::AutoInc: - return 'I'; - case FT::Logical: - return 'L'; - case FT::Date: case FT::AdtDate: - return 'D'; - case FT::DateTime: case FT::AdtTimestamp: - return 'T'; - default: - return 'C'; - } -} - -// RCB 07/17/2026 (S2): trigger bodies run through the script engine. The -// runner is defined after the scriptbridge machinery (much later in this -// file, at global scope); declared here so fire_triggers_ can call it. -extern "C++" openads::util::Result script_run_trigger_body( - Connection* conn, ADSHANDLE hConn, const std::string& body, - const std::map& new_f, - const std::map& old_f, - bool is_instead_of); - -namespace { -thread_local int tl_trigger_depth = 0; -static constexpr int kTrigMaxDepth = 64; - -// Find the connection handle for a given Connection pointer. -Handle handle_for_conn(Connection* c) { - auto& s = state(); - Handle found = 0; - s.registry.for_each_handle([&](Handle h, HandleKind k, void* p) { - if (found) return; - if (k == HandleKind::Connection && static_cast(p) == c) - found = h; - }); - return found; -} - -// Return the SQL body to execute for a trigger -- prefer container unless it -// looks like a short type code (e.g. "1"), in which case fall back to procedure. -static const std::string& trigger_sql_body(const openads::engine::DataDict::TriggerEntry& e) { - if (e.container.size() > 4) return e.container; - if (!e.procedure.empty()) return e.procedure; - return e.container; -} - -// ── Procedural trigger body executor ──────────────────────────────────────── -// Implements a minimal interpreter for SAP ADS trigger body SQL: -// DECLARE @var TYPE -- declares a local variable (ignored, just tracked) -// SET @var = expr -- assigns a value; expr may be __new.field, __old.field, -// a string literal, a numeric literal, or a SQL expression -// __new.fieldname -- value of the new record's field (INSERT/UPDATE) -// __old.fieldname -- value of the old record's field (UPDATE/DELETE) -// All other statements are executed via AdsExecuteSQLDirect after substitution. - -// Type alias to avoid MSVC C2562 when returning std::map<> from a function -// inside an extern "C" / anonymous-namespace block. -// -// The trig_* helpers below return C++ types (std::string / std::vector / -// TrigError_). Give them C++ linkage so MSVC does not raise C4190 (C-linkage -// function returning a C++-incompatible type) under /W4 /WX. -extern "C++" { - -using TrigFieldMap_ = std::map; - -// SQL-quote a raw string value (escape embedded quotes, wrap in single quotes). -// Collect all field values from a Table into a lowercase-keyed map of -// (display text, field type char). Char fields are space-trimmed. -static void trig_collect_row_(Table* t, TrigFieldMap_& m) { - if (!t) return; - std::uint16_t nf = t->field_count(); - for (std::uint16_t i = 0; i < nf; ++i) { - const auto& fd = t->field_descriptor(i); - std::string name = fd.name; - for (auto& ch : name) - ch = static_cast(std::tolower(static_cast(ch))); - char tc = trig_type_char_(fd.type); - auto v = t->read_field(i); - if (!v) { m[name] = TrigField_{"", tc}; continue; } - std::string sv = v.value().as_string; - while (!sv.empty() && sv.back() == ' ') sv.pop_back(); - m[name] = TrigField_{std::move(sv), tc}; - } -} - -// Error info returned from a trigger body (via INSERT INTO __error). -struct TrigError_ { - bool has_error = false; - std::uint32_t errno_val = 0; - std::string message; -}; - -} // extern "C++" -- trig_ helpers regain C++ linkage (silences C4190) - -// fire_triggers_ -- fire all enabled, matching triggers for a given event + timing. -// timing: 1=BEFORE 2=INSTEAD_OF 4=AFTER -// Returns true if an INSTEAD OF trigger was fired (caller should skip the actual DML). -bool fire_triggers_(Handle hConn, Connection* conn, - const std::string& table_alias, std::uint32_t event_mask, - std::uint32_t timing, - Table* new_tbl = nullptr, Table* old_tbl = nullptr, - TrigError_* out_err = nullptr, - // S4 -- pre-built row images for firings where no table - // row exists yet (INSERT BEFORE / INSTEAD OF: the image - // comes from the statement's VALUES). - const TrigFieldMap_* new_override = nullptr, - const TrigFieldMap_* old_override = nullptr) { - if (tl_trigger_depth >= kTrigMaxDepth) return false; // depth limit - if (conn->triggers_disabled()) return false; // connection-level disable - auto* dd = conn->dd(); - if (!dd) return false; - - // RCB 06/30/2026: Trigger firing is on every DML path, so ask the DD for - // enabled triggers already scoped by table/event/timing and sorted by - // priority instead of scanning the full trigger catalogue for each row. - const auto& matched = dd->triggers_for_event(table_alias, event_mask, timing); - if (matched.empty()) return false; - - // Collect __new / __old field values if WANT_VALUES option is set (bit 0x01). - // If any trigger in the set requires values, collect them for all. - bool want_values = false; - bool want_memos = false; - for (const auto* e : matched) { - if (e->options & 0x01u) { want_values = true; } - if (e->options & 0x02u) { want_memos = true; } - } - TrigFieldMap_ new_fields, old_fields; - // S4 -- statement-supplied images take precedence (INSERT BEFORE / - // INSTEAD OF fire before any row exists to collect from). - if (new_override != nullptr) new_fields = *new_override; - if (old_override != nullptr) old_fields = *old_override; - if (want_values && new_override == nullptr && old_override == nullptr) { - if (want_memos) { - trig_collect_row_(new_tbl, new_fields); - trig_collect_row_(old_tbl, old_fields); - } else { - // NO MEMOS/BLOBS option: skip memo and binary fields - auto collect_no_memo = [](Table* t, TrigFieldMap_& m) { - if (!t) return; - std::uint16_t nf = t->field_count(); - for (std::uint16_t i = 0; i < nf; ++i) { - const auto& fd = t->field_descriptor(i); - // Skip memo and blob field types (NO MEMOS option). - // RCB 07/18/2026: was a char-vs-enum comparison that - // never matched -- fixed to compare the enum. - using FT = openads::drivers::DbfFieldType; - if (fd.type == FT::Memo || fd.type == FT::Binary || - fd.type == FT::Varbinary) - continue; - std::string name = fd.name; - for (auto& ch : name) - ch = static_cast(std::tolower(static_cast(ch))); - char tc = trig_type_char_(fd.type); - auto v = t->read_field(i); - if (!v) { m[name] = TrigField_{"", tc}; continue; } - std::string sv = v.value().as_string; - while (!sv.empty() && sv.back() == ' ') sv.pop_back(); - m[name] = TrigField_{std::move(sv), tc}; - } - }; - collect_no_memo(new_tbl, new_fields); - collect_no_memo(old_tbl, old_fields); - } - } - - // S4 -- re-entrancy guard: a write performed INSIDE a trigger body to - // the SAME table/event does not re-fire that trigger (SAP semantics -- - // the INSTEAD OF idiom `insert into t select * from __new` would - // otherwise recurse). - static thread_local std::vector tl_active_fires; - std::string fire_key = table_alias + "|" + - std::to_string(event_mask) + "|" + - std::to_string(timing); - if (std::find(tl_active_fires.begin(), tl_active_fires.end(), - fire_key) != tl_active_fires.end()) - return false; - struct FireGuard { - std::vector& v; - ~FireGuard() { v.pop_back(); } - }; - tl_active_fires.push_back(fire_key); - FireGuard fire_guard{tl_active_fires}; - - bool instead_of_fired = false; - ++tl_trigger_depth; - for (const auto* e : matched) { - const auto& sql_body = trigger_sql_body(*e); - if (sql_body.empty()) continue; - - // Strip trailing non-printable garbage (binary .am file padding) - std::string body_copy = sql_body; - while (!body_copy.empty()) { - unsigned char last = static_cast(body_copy.back()); - if (last >= 0x20u || last == '\t' || last == '\n' || last == '\r') break; - body_copy.pop_back(); - } - - // RCB 07/17/2026 (S2): the body runs through the typed script - // engine (full IF/WHILE/TRY, EXECUTE PROCEDURE into DD script - // procs, RAISE). A failure is REPORTED to the caller -- the old - // fragment silently swallowed it, so pmsys audit triggers "fired" - // while writing nothing and the DML still reported success. - auto r = script_run_trigger_body(conn, to_ads_handle(hConn), body_copy, - new_fields, old_fields, - timing == 2u /*is_instead_of*/); - if (timing == 2u) instead_of_fired = true; - if (!r && out_err != nullptr && !out_err->has_error) { - // Per SAP semantics remaining triggers still fire; the first - // error is what the client sees after all of them complete. - out_err->has_error = true; - out_err->errno_val = - static_cast(r.error().code); - out_err->message = r.error().message; - } - } - --tl_trigger_depth; - return instead_of_fired; -} -} // anonymous namespace - -UNSIGNED32 ENTRYPOINT AdsAppendRecord(ADSHANDLE hTable) { - arc2_trace("AdsAppendRecord"); - if (auto* rt = get_remote_table(hTable)) { - if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; - remote_settle_cursor(rt); // M12.21 option C - rt->row_valid = false; // M12.17 - rt->rec_count_cached = false; - rt->key_count_cached = false; // M12.19 - rt->key_counts.clear(); - rt->key_counts.clear(); - // Cursor moves onto a new blank; any prior keyno is for a different - // row. Leaving keyno_valid set made AdsGetKeyNum/GetRelKeyPos report - // the pre-append position until the next nav invalidation -- wrong - // scrollbar math after TXBrowse:Refresh() following DBAPPEND. - rt->keyno_valid = false; - remote_clear_nav_boundaries(rt); - rt->invalidate_prefetch(); - auto r = rt->conn->append_blank(rt->id); - if (!r) return fail(r.error()); - // Fresh appends auto-lock (non-exclusive tables): pooled reuse - // must not resurrect a locked handle. The ack carries no - // recno, so the ledger cannot name the auto-lock -- mark the - // lock state uncertain until a full UnlockTable proves clear. - rt->ever_locked = true; - rt->locks_uncertain = true; - rt->write_dirty = true; - return ok(); - } -#if defined(OPENADS_WITH_FIREBIRD) - if (auto* ft = get_firebird_table(hTable)) { - if (ft->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - if (UNSIGNED32 rc = sql_uri_check_dml( - ft->connect_handle, ft->check_rights, ft->name, - SqlUriDmlOp::Insert, - openads::sql_backend::SqlDdlDialect::Firebird, ft->conn, - firebird_acl_query); - rc != openads::AE_SUCCESS) { - return rc; - } - auto r = ft->conn->append_blank(ft); - if (!r) return fail(r.error()); - return hook_auto_commit(ft); - } -#endif -#if defined(OPENADS_WITH_POSTGRESQL) - if (auto* pt = get_postgres_table(hTable)) { - if (pt->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - if (UNSIGNED32 rc = sql_uri_check_dml( - pt->connect_handle, pt->check_rights, pt->name, - SqlUriDmlOp::Insert, - openads::sql_backend::SqlDdlDialect::Postgres, pt->conn, - postgres_acl_query); - rc != openads::AE_SUCCESS) { - return rc; - } - auto r = pt->conn->append_blank(pt); - if (!r) return fail(r.error()); - return hook_auto_commit(pt); - } -#endif -#if defined(OPENADS_WITH_MARIADB) - if (auto* mt = get_maria_table(hTable)) { - if (mt->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - if (UNSIGNED32 rc = sql_uri_check_dml( - mt->connect_handle, mt->check_rights, mt->name, - SqlUriDmlOp::Insert, - openads::sql_backend::SqlDdlDialect::Maria, mt->conn, - maria_acl_query); - rc != openads::AE_SUCCESS) { - return rc; - } - auto r = mt->conn->append_blank(mt); - if (!r) return fail(r.error()); - return hook_auto_commit(mt); - } -#endif -#if defined(OPENADS_WITH_ODBC) - if (auto* ot = get_odbc_table(hTable)) { - if (ot->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - if (UNSIGNED32 rc = sql_uri_check_dml( - ot->connect_handle, ot->check_rights, ot->name, - SqlUriDmlOp::Insert, - odbc_dialect_for(ot->connect_handle), ot->conn, - odbc_acl_query); - rc != openads::AE_SUCCESS) { - return rc; - } - auto r = ot->conn->append_blank(ot); - if (!r) return fail(r.error()); - return hook_auto_commit(ot); - } -#endif -#if defined(OPENADS_WITH_SQLITE) - if (auto* st = get_sqlite_table(hTable)) { - if (st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - if (UNSIGNED32 rc = sql_uri_check_dml( - st->connect_handle, st->check_rights, st->name, - SqlUriDmlOp::Insert, - openads::sql_backend::SqlDdlDialect::Sqlite, st->conn, - sqlite_acl_query); - rc != openads::AE_SUCCESS) { - return rc; - } - auto r = st->conn->append_blank(st); - if (!r) return fail(r.error()); - return hook_auto_commit(st); - } -#endif -#if defined(OPENADS_WITH_MSSQL) - if (auto* mt = get_mssql_table(hTable)) { - if (mt->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - if (UNSIGNED32 rc = sql_uri_check_dml( - mt->connect_handle, mt->check_rights, mt->name, - SqlUriDmlOp::Insert, - openads::sql_backend::SqlDdlDialect::Mssql, mt->conn, - mssql_acl_query); - rc != openads::AE_SUCCESS) { - return rc; - } - auto r = mt->conn->append_blank(mt); - if (!r) return fail(r.error()); - return hook_auto_commit(mt); - } -#endif - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - auto r = t->append_record(); - if (!r) return fail(r.error()); - // ACE semantics: a freshly-appended record in a non-exclusive table is - // automatically locked. Done inside engine append_record() so engine- - // level callers (SQL INSERT, server AppendBlank) get it too. X#'s - // ADSRDD relies on this -- its GoHot refuses to write a record it sees - // as unlocked. - t->set_pending_append(true); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsWriteRecord(ADSHANDLE hTable) { - arc2_trace("AdsWriteRecord"); - if (auto* rt = get_remote_table(hTable)) { - // Snapshot dirtiness before remote_flush_pending drains the - // buffered sets: a commit with nothing written since the last - // flush (rddads DBCOMMITALL touches every open workarea) owes - // the server no frame at all -- it would only fsync unchanged - // pages, 1 RTT each, 16 of them after a Vouch voucher save. - const bool had_writes = - !rt->pending_sets.empty() || rt->write_dirty; - if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; - rt->row_valid = false; // M12.17 cache invalidation - // A write can move the row in/out of a conditional order or - // scope: the cached key count may have changed with it. - rt->key_count_cached = false; - rt->key_counts.clear(); - rt->key_counts.clear(); - // Key fields may have moved the row in the active order (or this is - // the flush of a fresh append). Drop the key position so the next - // AdsGetKeyNum / AdsGetRelKeyPos re-seeds via server GetKeyNum (O(1)) - // instead of serving a stale current_keyno. - rt->keyno_valid = false; - rt->invalidate_prefetch(); - if (!had_writes) { - cli_trace_tbl(rt, "AdsWriteRecord", "clean: flush skipped"); - return ok(); - } - auto r = rt->conn->flush_table(rt->id); - if (!r) return fail(r.error()); - // kCapFlushTableDurable servers run the full file-buffers - // flush inside the FlushTable handler, so the commit is - // durable right here and a trailing AdsFlushFileBuffers owes - // nothing (1 RTT saved per commit). Old servers keep the - // classic two-frame pair. - rt->write_dirty = !rt->conn->server_flush_table_durable(); - return ok(); - } -#if defined(OPENADS_WITH_FIREBIRD) - if (auto* ft = get_firebird_table(hTable)) { - if (ft->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - if (UNSIGNED32 rc = sql_uri_check_dml( - ft->connect_handle, ft->check_rights, ft->name, - ft->pending_append ? SqlUriDmlOp::Insert : SqlUriDmlOp::Update, - openads::sql_backend::SqlDdlDialect::Firebird, ft->conn, - firebird_acl_query); - rc != openads::AE_SUCCESS) { - return rc; - } - auto r = ft->conn->flush_record(ft); - if (!r) return fail(r.error()); - return hook_auto_commit(ft); - } -#endif -#if defined(OPENADS_WITH_POSTGRESQL) - if (auto* pt = get_postgres_table(hTable)) { - if (pt->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - if (UNSIGNED32 rc = sql_uri_check_dml( - pt->connect_handle, pt->check_rights, pt->name, - pt->pending_append ? SqlUriDmlOp::Insert : SqlUriDmlOp::Update, - openads::sql_backend::SqlDdlDialect::Postgres, pt->conn, - postgres_acl_query); - rc != openads::AE_SUCCESS) { - return rc; - } - auto r = pt->conn->flush_record(pt); - if (!r) return fail(r.error()); - return hook_auto_commit(pt); - } -#endif -#if defined(OPENADS_WITH_MARIADB) - if (auto* mt = get_maria_table(hTable)) { - if (mt->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - if (UNSIGNED32 rc = sql_uri_check_dml( - mt->connect_handle, mt->check_rights, mt->name, - mt->pending_append ? SqlUriDmlOp::Insert : SqlUriDmlOp::Update, - openads::sql_backend::SqlDdlDialect::Maria, mt->conn, - maria_acl_query); - rc != openads::AE_SUCCESS) { - return rc; - } - auto r = mt->conn->flush_record(mt); - if (!r) return fail(r.error()); - return hook_auto_commit(mt); - } -#endif -#if defined(OPENADS_WITH_ODBC) - if (auto* ot = get_odbc_table(hTable)) { - if (ot->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - if (UNSIGNED32 rc = sql_uri_check_dml( - ot->connect_handle, ot->check_rights, ot->name, - ot->appending ? SqlUriDmlOp::Insert : SqlUriDmlOp::Update, - odbc_dialect_for(ot->connect_handle), ot->conn, - odbc_acl_query); - rc != openads::AE_SUCCESS) { - return rc; - } - auto r = ot->conn->flush_table(ot); - if (!r) return fail(r.error()); - return hook_auto_commit(ot); - } -#endif -#if defined(OPENADS_WITH_SQLITE) - if (auto* st = get_sqlite_table(hTable)) { - if (st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - if (UNSIGNED32 rc = sql_uri_check_dml( - st->connect_handle, st->check_rights, st->name, - st->pending_append ? SqlUriDmlOp::Insert : SqlUriDmlOp::Update, - openads::sql_backend::SqlDdlDialect::Sqlite, st->conn, - sqlite_acl_query); - rc != openads::AE_SUCCESS) { - return rc; - } - auto r = st->conn->flush_record(st); - if (!r) return fail(r.error()); - return hook_auto_commit(st); - } -#endif -#if defined(OPENADS_WITH_MSSQL) - if (auto* mt = get_mssql_table(hTable)) { - if (mt->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - if (UNSIGNED32 rc = sql_uri_check_dml( - mt->connect_handle, mt->check_rights, mt->name, - mt->pending_append ? SqlUriDmlOp::Insert : SqlUriDmlOp::Update, - openads::sql_backend::SqlDdlDialect::Mssql, mt->conn, - mssql_acl_query); - rc != openads::AE_SUCCESS) { - return rc; - } - auto r = mt->conn->flush_record(mt); - if (!r) return fail(r.error()); - return hook_auto_commit(mt); - } -#endif - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - bool is_insert = t->pending_append(); - std::uint32_t event_mask = is_insert ? 1u : 2u; - - if (is_insert) { - if (Connection* conn = conn_for_table(t)) { - if (auto ri = ri_check_insert(conn, *t); !ri) - return fail(ri.error()); - } - } else { - if (Connection* conn = conn_for_table(t)) { - if (auto ri = ri_enforce_update(conn, *t); !ri) - return fail(ri.error()); - } - } - - // Trigger firing order: INSTEAD OF → (skip flush) OR BEFORE → flush → AFTER - // RCB 07/17/2026 (S2): trigger failures propagate to the client (SAP - // oracle probe Q6): BEFORE/INSTEAD OF failure blocks the write; AFTER - // failure returns the error but the write persists. - TrigError_ terr; - if (Connection* conn = conn_for_table(t)) { - std::string alias = ri_alias_for_path(conn, t->path()); - if (!alias.empty()) { - Handle hConn = handle_for_conn(conn); - if (hConn) { - // INSTEAD OF trigger: fire and skip the actual write - if (fire_triggers_(hConn, conn, alias, event_mask, - 2u /*INSTEAD_OF*/, t, nullptr, &terr)) { - if (terr.has_error) - return fail(static_cast(terr.errno_val), terr.message.c_str()); - return ok(); - } - // BEFORE trigger: fire before the write - fire_triggers_(hConn, conn, alias, event_mask, - 1u /*BEFORE*/, t, nullptr, &terr); - if (terr.has_error) - return fail(static_cast(terr.errno_val), terr.message.c_str()); - } - } - } - - // Always settle the dirty record buffer (field replaces coalesce until - // here). deferred_flush only skips OS FlushFileBuffers / index fsync. - // A bare append (AdsAppendRecord + WriteRecord with NO field writes) - // never marked the row dirty, so commit_dirty_record would skip it and - // the record would stay unkeyed (the blank-key test pins this). Key it - // here exactly once: append_record deliberately does not key eagerly - // (the eager per-append insert measured a ~40% remote append - // regression — see the NOTE in Table::append_record()). - if (is_insert && !t->record_dirty()) { - auto r = t->commit_bare_append(); - if (!r) return fail(r.error()); - if (!t->deferred_flush()) { - auto r2 = t->flush(); - if (!r2) return fail(r2.error()); - } - } else if (t->deferred_flush()) { - auto r = t->commit_dirty_record(); - if (!r) return fail(r.error()); - } else { - auto r = t->flush(); - if (!r) return fail(r.error()); - } - - if (Connection* conn = conn_for_table(t)) { - std::string alias = ri_alias_for_path(conn, t->path()); - if (!alias.empty()) { - Handle hConn = handle_for_conn(conn); - // AFTER trigger: __new = current record (new values for UPDATE, inserted for INSERT) - if (hConn) { - fire_triggers_(hConn, conn, alias, event_mask, - 4u /*AFTER*/, t, nullptr, &terr); - if (terr.has_error) - return fail(static_cast(terr.errno_val), terr.message.c_str()); - } - } - } - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsDeleteRecord(ADSHANDLE hTable) { - arc2_trace("AdsDeleteRecord"); - if (auto* rt = get_remote_table(hTable)) { - if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; - remote_settle_cursor(rt); // M12.21 option C - rt->row_valid = false; // M12.17 - rt->rec_count_cached = false; - rt->key_count_cached = false; // M12.19 (Pack drops the row) - rt->key_counts.clear(); - rt->key_counts.clear(); - rt->keyno_valid = false; // order position may shift - remote_clear_nav_boundaries(rt); - rt->invalidate_prefetch(); - auto r = rt->conn->delete_record(rt->id); - if (!r) return fail(r.error()); - rt->write_dirty = true; - return ok(); - } -#if defined(OPENADS_WITH_FIREBIRD) - if (auto* ft = get_firebird_table(hTable)) { - if (ft->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - if (UNSIGNED32 rc = sql_uri_check_dml( - ft->connect_handle, ft->check_rights, ft->name, - SqlUriDmlOp::Delete, - openads::sql_backend::SqlDdlDialect::Firebird, ft->conn, - firebird_acl_query); - rc != openads::AE_SUCCESS) { - return rc; - } - auto r = ft->conn->delete_record(ft); - if (!r) return fail(r.error()); - return hook_auto_commit(ft); - } -#endif -#if defined(OPENADS_WITH_POSTGRESQL) - if (auto* pt = get_postgres_table(hTable)) { - if (pt->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - if (UNSIGNED32 rc = sql_uri_check_dml( - pt->connect_handle, pt->check_rights, pt->name, - SqlUriDmlOp::Delete, - openads::sql_backend::SqlDdlDialect::Postgres, pt->conn, - postgres_acl_query); - rc != openads::AE_SUCCESS) { - return rc; - } - auto r = pt->conn->delete_record(pt); - if (!r) return fail(r.error()); - return hook_auto_commit(pt); - } -#endif -#if defined(OPENADS_WITH_MARIADB) - if (auto* mt = get_maria_table(hTable)) { - if (mt->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - if (UNSIGNED32 rc = sql_uri_check_dml( - mt->connect_handle, mt->check_rights, mt->name, - SqlUriDmlOp::Delete, - openads::sql_backend::SqlDdlDialect::Maria, mt->conn, - maria_acl_query); - rc != openads::AE_SUCCESS) { - return rc; - } - auto r = mt->conn->delete_record(mt); - if (!r) return fail(r.error()); - return hook_auto_commit(mt); - } -#endif -#if defined(OPENADS_WITH_ODBC) - if (auto* ot = get_odbc_table(hTable)) { - if (ot->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - if (UNSIGNED32 rc = sql_uri_check_dml( - ot->connect_handle, ot->check_rights, ot->name, - SqlUriDmlOp::Delete, - odbc_dialect_for(ot->connect_handle), ot->conn, - odbc_acl_query); - rc != openads::AE_SUCCESS) { - return rc; - } - auto r = ot->conn->delete_record(ot); - if (!r) return fail(r.error()); - return hook_auto_commit(ot); - } -#endif -#if defined(OPENADS_WITH_SQLITE) - if (auto* st = get_sqlite_table(hTable)) { - if (st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - if (UNSIGNED32 rc = sql_uri_check_dml( - st->connect_handle, st->check_rights, st->name, - SqlUriDmlOp::Delete, - openads::sql_backend::SqlDdlDialect::Sqlite, st->conn, - sqlite_acl_query); - rc != openads::AE_SUCCESS) { - return rc; - } - auto r = st->conn->delete_record(st); - if (!r) return fail(r.error()); - return hook_auto_commit(st); - } -#endif -#if defined(OPENADS_WITH_MSSQL) - if (auto* mt = get_mssql_table(hTable)) { - if (mt->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - if (UNSIGNED32 rc = sql_uri_check_dml( - mt->connect_handle, mt->check_rights, mt->name, - SqlUriDmlOp::Delete, - openads::sql_backend::SqlDdlDialect::Mssql, mt->conn, - mssql_acl_query); - rc != openads::AE_SUCCESS) { - return rc; - } - auto r = mt->conn->delete_record(mt); - if (!r) return fail(r.error()); - return hook_auto_commit(mt); - } -#endif - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - t->set_pending_append(false); // abandon any in-flight append - if (Connection* conn = conn_for_table(t)) { - if (auto ri = ri_enforce_delete(conn, *t); !ri) - return fail(ri.error()); - } - - // Trigger firing order for DELETE: INSTEAD OF → (skip delete) OR BEFORE → delete → AFTER - // RCB 07/17/2026 (S2): failures propagate (probe Q6 semantics). - TrigError_ terr; - if (Connection* conn = conn_for_table(t)) { - std::string alias = ri_alias_for_path(conn, t->path()); - if (!alias.empty()) { - Handle hConn = handle_for_conn(conn); - if (hConn) { - // INSTEAD OF DELETE: __old = current record - if (fire_triggers_(hConn, conn, alias, 3u, 2u /*INSTEAD_OF*/, - nullptr, t, &terr)) { - if (terr.has_error) - return fail(static_cast(terr.errno_val), terr.message.c_str()); - return ok(); - } - // BEFORE DELETE: __old = current record (about to be deleted) - fire_triggers_(hConn, conn, alias, 3u, 1u /*BEFORE*/, - nullptr, t, &terr); - if (terr.has_error) - return fail(static_cast(terr.errno_val), terr.message.c_str()); - } - } - } - - auto r = t->mark_deleted(); - if (!r) return fail(r.error()); - - if (Connection* conn = conn_for_table(t)) { - std::string alias = ri_alias_for_path(conn, t->path()); - if (!alias.empty()) { - Handle hConn = handle_for_conn(conn); - // AFTER DELETE: __old = the deleted record - if (hConn) { - fire_triggers_(hConn, conn, alias, 3u, 4u /*AFTER*/, - nullptr, t, &terr); - if (terr.has_error) - return fail(static_cast(terr.errno_val), terr.message.c_str()); - } - } - } - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsRecallRecord(ADSHANDLE hTable) { - arc2_trace("AdsRecallRecord"); - if (auto* rt = get_remote_table(hTable)) { - if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; - remote_settle_cursor(rt); // M12.21 option C - rt->row_valid = false; // M12.17 - rt->rec_count_cached = false; - rt->key_count_cached = false; // M12.19 - rt->key_counts.clear(); - rt->key_counts.clear(); - rt->keyno_valid = false; // order position may shift - remote_clear_nav_boundaries(rt); - rt->invalidate_prefetch(); - auto r = rt->conn->recall_record(rt->id); - if (!r) return fail(r.error()); - rt->write_dirty = true; - return ok(); - } - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - auto r = t->recall_deleted(); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsIsRecordDeleted(ADSHANDLE hTable, UNSIGNED16* pbDeleted) { - arc2_trace("AdsIsRecordDeleted"); - if (pbDeleted == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (auto* rt = get_remote_table(hTable)) { - // M12.18 -- deleted flag rides with the row trailer. - if (rt->row_valid) { - *pbDeleted = rt->current_deleted ? 1 : 0; - return ok(); - } - auto r = rt->conn->is_record_deleted(rt->id); - if (!r) return fail(r.error()); - *pbDeleted = r.value() ? 1 : 0; - return ok(); - } - 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; - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsSetString(ADSHANDLE hTable, UNSIGNED8* pucField, - UNSIGNED8* pucValue, UNSIGNED32 ulLen) { - arc2_trace("AdsSetString"); - // RCB 2026-05-22 17:03 -- AdsSet* previously had no awareness of statement - // handles. get_table() only queries the HandleRegistry for HandleKind::Table - // and returns nullptr for anything else, so calls against a prepared statement - // handle always failed with [5000] unknown table. We check set_stmt_param - // first; if the handle is in stmt_map the value is stored as a quoted SQL - // string literal and we return immediately without touching the table path. - // Single quotes inside the value are doubled to produce a valid SQL literal. - if (pucField != nullptr) { - std::string val(pucValue ? reinterpret_cast(pucValue) : "", - pucValue ? static_cast(ulLen) : 0u); - std::string escaped; - escaped.reserve(val.size() + 2); - for (char c : val) { escaped += c; if (c == '\'') escaped += c; } - if (set_stmt_param(hTable, - reinterpret_cast(pucField), - "'" + escaped + "'")) - return ok(); - } - if (auto* rt = get_remote_table(hTable)) { - if (pucField == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - auto i = remote_field_index(rt, pucField); - if (i == std::numeric_limits::max() || i >= rt->fields.size()) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - std::string fname = rt->fields[i].name; - std::string val; - if (pucValue != nullptr && ulLen > 0) { - val.assign(reinterpret_cast(pucValue), ulLen); - } - return remote_buffered_set(rt, fname, val); - } -#if defined(OPENADS_WITH_FIREBIRD) - if (auto* ft = get_firebird_table(hTable)) { - if (pucField == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (ft->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - std::string fname(reinterpret_cast(pucField)); - std::string val; - if (pucValue != nullptr && ulLen > 0) - val.assign(reinterpret_cast(pucValue), ulLen); - auto r = ft->conn->set_field(ft, fname, val); - if (!r) return fail(r.error()); - return ok(); - } -#endif -#if defined(OPENADS_WITH_POSTGRESQL) - if (auto* pt = get_postgres_table(hTable)) { - if (pucField == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (pt->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - std::string fname(reinterpret_cast(pucField)); - std::string val; - if (pucValue != nullptr && ulLen > 0) - val.assign(reinterpret_cast(pucValue), ulLen); - auto r = pt->conn->set_field(pt, fname, val); - if (!r) return fail(r.error()); - return ok(); - } -#endif -#if defined(OPENADS_WITH_MARIADB) - if (auto* mt = get_maria_table(hTable)) { - if (pucField == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (mt->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - std::string fname(reinterpret_cast(pucField)); - std::string val; - if (pucValue != nullptr && ulLen > 0) - val.assign(reinterpret_cast(pucValue), ulLen); - auto r = mt->conn->set_field(mt, fname, val); - if (!r) return fail(r.error()); - return ok(); - } -#endif -#if defined(OPENADS_WITH_ODBC) - if (auto* ot = get_odbc_table(hTable)) { - if (pucField == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (ot->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - std::string fname(reinterpret_cast(pucField)); - std::string val; - if (pucValue != nullptr && ulLen > 0) - val.assign(reinterpret_cast(pucValue), ulLen); - auto r = ot->conn->set_field(ot, fname, val); - if (!r) return fail(r.error()); - return ok(); - } -#endif -#if defined(OPENADS_WITH_SQLITE) - if (auto* st = get_sqlite_table(hTable)) { - if (pucField == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (st->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - std::string fname(reinterpret_cast(pucField)); - std::string val; - if (pucValue != nullptr && ulLen > 0) - val.assign(reinterpret_cast(pucValue), ulLen); - auto r = st->conn->set_field(st, fname, val); - if (!r) return fail(r.error()); - return ok(); - } -#endif -#if defined(OPENADS_WITH_MSSQL) - if (auto* mt = get_mssql_table(hTable)) { - if (pucField == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (mt->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - std::string fname(reinterpret_cast(pucField)); - std::string val; - if (pucValue != nullptr && ulLen > 0) - val.assign(reinterpret_cast(pucValue), ulLen); - auto r = mt->conn->set_field(mt, fname, val); - if (!r) return fail(r.error()); - return ok(); - } -#endif - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - std::uint16_t idx = 0; - if (!resolve_field_index(t, pucField, &idx)) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - std::string val(reinterpret_cast(pucValue), ulLen); - auto r = t->set_field(idx, val); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsSetLogical(ADSHANDLE hTable, UNSIGNED8* pucField, - UNSIGNED16 bValue) { - arc2_trace("AdsSetLogical"); - // RCB 2026-05-22 17:03 -- same statement-handle gap as AdsSetString. - // Logical fields in DBF are stored as 'T'/'F' but the SQL parser accepts - // 1 and 0 in INSERT/UPDATE VALUES, so we emit those as the literal. - if (pucField != nullptr) - if (set_stmt_param(hTable, - reinterpret_cast(pucField), - bValue ? "1" : "0")) - return ok(); - if (auto* rt = get_remote_table(hTable)) { - if (pucField == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - auto i = remote_field_index(rt, pucField); - if (i == std::numeric_limits::max() || i >= rt->fields.size()) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - std::string fname = rt->fields[i].name; - // Send the DBF logical byte itself ('T'/'F'), not "1"/"0": servers - // that write strings through AdsSetString (twin handle) stored the - // '1' raw, which index FOR evaluation and DBFCDX read as .F. - return remote_buffered_set(rt, fname, bValue ? "T" : "F"); - } - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - std::uint16_t idx = 0; - if (!resolve_field_index(t, pucField, &idx)) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - auto r = t->set_field(idx, bValue != 0); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsSetDouble(ADSHANDLE hTable, UNSIGNED8* pucField, - double dValue) { - arc2_trace("AdsSetDouble"); - // RCB 2026-05-22 17:03 -- same statement-handle gap as AdsSetString. - // AdsSetLongLong also routes through here (it casts to double before calling - // us), so fixing this one covers both numeric bind types. We use a char - // buffer with snprintf rather than std::to_string to avoid locale-dependent - // decimal separators that would break the SQL parser. - if (pucField != nullptr) { - char nbuf[64]; - std::snprintf(nbuf, sizeof(nbuf), "%.17g", dValue); - if (set_stmt_param(hTable, - reinterpret_cast(pucField), - std::string(nbuf))) - return ok(); - } - if (auto* rt = get_remote_table(hTable)) { - if (pucField == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - auto i = remote_field_index(rt, pucField); - if (i == std::numeric_limits::max() || i >= rt->fields.size()) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - std::string fname = rt->fields[i].name; - char nbuf[64]; - std::snprintf(nbuf, sizeof(nbuf), "%.17g", dValue); - return remote_buffered_set(rt, fname, std::string(nbuf)); - } - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - std::uint16_t idx = 0; - if (!resolve_field_index(t, pucField, &idx)) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - auto r = t->set_field(idx, dValue); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsSetLong(ADSHANDLE hTable, UNSIGNED8* pucField, - SIGNED32 lValue) { - arc2_trace("AdsSetLong"); - return AdsSetDouble(hTable, pucField, static_cast(lValue)); -} - -UNSIGNED32 ENTRYPOINT AdsSetLongLong(ADSHANDLE hTable, UNSIGNED8* pucField, - std::int64_t llValue) { - arc2_trace("AdsSetLongLong"); - return AdsSetDouble(hTable, pucField, static_cast(llValue)); -} - -namespace { - -// Inverse of to_julian -- convert a Clipper Julian Day Number back to -// a Gregorian (Y, M, D) triple. -void julian_to_ymd(SIGNED32 jd, int& y, int& m, int& d) { - long L = static_cast(jd) + 68569; - long N = (4 * L) / 146097; - L = L - (146097 * N + 3) / 4; - long I = (4000 * (L + 1)) / 1461001; - L = L - (1461 * I) / 4 + 31; - long J = (80 * L) / 2447; - d = static_cast(L - (2447 * J) / 80); - L = J / 11; - m = static_cast(J + 2 - 12 * L); - y = static_cast(100 * (N - 49) + I + L); -} - -void compact_ace_date_value(const UNSIGNED8* value, UNSIGNED16 len, - std::string* out) { - if (out == nullptr) return; - std::string s(value ? reinterpret_cast(value) : "", - value ? static_cast(len) : 0u); - // SAP's AdsSetDate parses the text against the CURRENT date format - // ("03/07/2025" under MM/DD/CCYY -> 2025-03-07); try that first. - // Everything below is the permissive OpenADS superset kept for - // existing callers: ISO "yyyy-mm-dd" and raw "YYYYMMDD" still land - // (SAP rejects raw with 5080 -- documented deviation, php and the - // remote twin write raw). - if (std::string byfmt = parse_date_by_format(s); !byfmt.empty()) { - *out = std::move(byfmt); - return; - } - if (s.size() == 10 && s[4] == '-' && s[7] == '-') { - *out = s.substr(0, 4) + s.substr(5, 2) + s.substr(8, 2); - return; - } - if (s.size() == 8) { - bool all_digits = true; - for (char c : s) { - all_digits = all_digits && - std::isdigit(static_cast(c)) != 0; - } - if (all_digits) { - *out = s; - return; - } - } - *out = s; -} - -} // namespace - -// Memo readers: rddads' adsGetValue routes HB_FT_MEMO fields through -// AdsGetMemoDataType + AdsGetMemoLength + AdsGetString. The first -// reports the memo's content type (text vs binary), the second the -// payload length, and the third copies the bytes into the caller's -// buffer. We resolve the field as before, fetch the memo block via -// the attached IMemoStore, and answer in kind. -UNSIGNED32 ENTRYPOINT AdsGetMemoLength(ADSHANDLE hTable, UNSIGNED8* pucField, - UNSIGNED32* pulLen) { - arc2_trace("AdsGetMemoLength"); - if (pulLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (auto* rt = get_remote_table(hTable)) { - // Reuse the existing GetField wire op -- the returned string - // is the full memo content; size() is the memo length. - // Flush first: a buffered memo write must be visible to this read. - if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; - auto i = remote_field_index(rt, pucField); - if (i == std::numeric_limits::max() || i >= rt->fields.size()) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - auto v = rt->conn->get_field(rt->id, rt->fields[i].name); - if (!v) return fail(v.error()); - *pulLen = static_cast(v.value().size()); - return ok(); - } - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); - std::uint16_t idx = 0; - if (!resolve_field_index(t, pucField, &idx)) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - auto v = t->read_field(idx); - if (!v) return fail(v.error()); - *pulLen = static_cast(v.value().as_string.size()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsGetMemoDataType(ADSHANDLE hTable, UNSIGNED8* pucField, - UNSIGNED16* pusType) { - arc2_trace("AdsGetMemoDataType"); - if (pusType == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (auto* rt = get_remote_table(hTable)) { - auto i = remote_field_index(rt, pucField); - if (i == std::numeric_limits::max()) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - switch (rt->fields[i].type) { - case ADS_IMAGE: - *pusType = static_cast(ADS_IMAGE); - break; - case ADS_BINARY: - *pusType = static_cast(ADS_BINARY); - break; - default: - *pusType = static_cast(ADS_STRING); - break; - } - return ok(); - } - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); - std::uint16_t idx = 0; - if (!resolve_field_index(t, pucField, &idx)) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - auto r = t->field_memo_type(idx); - if (!r) return fail(r.error()); - switch (r.value()) { - case openads::drivers::MemoBlockType::Text: - *pusType = static_cast(ADS_STRING); - break; - case openads::drivers::MemoBlockType::Picture: - *pusType = static_cast(ADS_IMAGE); - break; - case openads::drivers::MemoBlockType::Object: - *pusType = static_cast(ADS_BINARY); - break; - } - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsGetString(ADSHANDLE hTable, UNSIGNED8* pucField, - UNSIGNED8* pucBuf, UNSIGNED32* pulLen, - UNSIGNED16 usOption) { - arc2_trace("AdsGetString"); - // Remote cursor OR a SQL backend (e.g. postgresql) that exposes a - // get_field op: delegate through AdsGetField (which already routes the - // remote row cache and the per-backend ops vtable) then apply the - // ADS_TRIM trailing-space behaviour AdsGetString promises. Without this - // a backend handle fell through to the native get_table path below and - // AdsGetString returned an error / empty string for SQL backends. - bool delegate = (get_remote_table(hTable) != nullptr); - if (!delegate) { - if (auto* ops = openads::abi::backend_table_ops_for(hTable)) - delegate = (ops->get_field != nullptr); - } - if (delegate) { - UNSIGNED32 raw_len = (pulLen && *pulLen > 0) ? *pulLen : 65536; - std::vector tmp(raw_len + 1, 0); - // AdsGetString promises the RAW storage text (dates "YYYYMMDD"), - // while AdsGetField formats per the date format -- suppress the - // formatting for the length of this delegated read. - g_field_read_raw = true; - UNSIGNED32 frc = AdsGetField(hTable, pucField, tmp.data(), - &raw_len, usOption); - g_field_read_raw = false; - if (frc != 0) - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - // Trim trailing spaces (ADS_TRIM behaviour) then copy to caller. - std::string s(reinterpret_cast(tmp.data()), raw_len); - auto last = s.find_last_not_of(' '); - if (last != std::string::npos) s.erase(last + 1); - else s.clear(); - UNSIGNED32 cap = pulLen ? *pulLen : 0; - UNSIGNED32 n = cap > 0 ? std::min(cap - 1, - static_cast(s.size())) : 0; - if (pucBuf != nullptr && cap > 0) { - if (n > 0) std::memcpy(pucBuf, s.data(), n); - pucBuf[n] = '\0'; - } - if (pulLen) *pulLen = static_cast(s.size()); - return ok(); - } - Table* t = get_table(hTable); - if (!t || pulLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - std::uint16_t idx = 0; - if (!resolve_field_index(t, pucField, &idx)) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - auto v = t->read_field(idx); - if (!v) return fail(v.error()); - std::string s = v.value().as_string; - // SAP returns the raw-width blank (" ", len 8) for an empty - // DATE read through GetString, not an empty string. Timestamps stay - // as decoded -- SAP raises 5066 for GetString on those, but php_ads - // depends on the raw "YYYYMMDDhhmmss" (documented deviation). - { - const auto& fdd = t->field_descriptor(idx); - if (s.empty() && - (fdd.type == openads::drivers::DbfFieldType::Date || - fdd.type == openads::drivers::DbfFieldType::AdtDate)) { - s.assign(8, ' '); - } - } - UNSIGNED32 cap = *pulLen; - UNSIGNED32 n = cap > 0 ? std::min(cap - 1, - static_cast(s.size())) - : 0; - if (pucBuf != nullptr && cap > 0) { - if (n > 0) std::memcpy(pucBuf, s.data(), n); - pucBuf[n] = '\0'; - } - *pulLen = static_cast(s.size()); - return ok(); -} - -// --- M9.17 Unicode (*W) variants ------------------------------------------ -// -// rddads' ADS_LIB_VERSION >= 1000 path routes UNICODE-flagged columns -// through AdsSetStringW / AdsGetStringW / AdsGetFieldW with WCHAR* -// buffers (UTF-16LE on Windows). The engine stores byte sequences -// without a fixed codepage assumption, so the W variants transcode -// at the boundary: UTF-16LE -> UTF-8 on the way in, UTF-8 -> UTF-16LE -// on the way out. Field names are 7-bit ASCII inside the DBF, so the -// pucFieldW name is dropped to ASCII via the same converter. - -namespace { - -// Resolve an ASCII / numeric `pucField` to a 0-based field index -// for the W-variant entry points. SAP keeps field names ASCII -// (UNSIGNED8*) even on the W variants; only the value buffer is -// wide. Small pointer values are interpreted as a 1-based field -// number (rddads' ADSFIELD macro), otherwise the value is a NUL- -// terminated ASCII field name. -bool resolve_field_index_w(Table* tbl, UNSIGNED8* pucField, - std::uint16_t* out) { - if (tbl == nullptr || out == nullptr) return false; - auto p = reinterpret_cast(pucField); - if (p != 0 && p < 0x10000u) { - std::uint16_t one_based = static_cast(p); - if (one_based >= 1 && one_based <= tbl->field_count()) { - *out = static_cast(one_based - 1); - return true; - } - return false; - } - if (pucField == nullptr) return false; - auto name = openads::abi::to_internal(pucField, 0); - // Delegate to Table::field_index -- case-insensitive (matches native - // ACE semantics) and cached. Field names in DBF/ADT storage are - // upper-cased, but callers (and CDX/NTX index expressions) may use - // any case; an exact-case compare here spuriously missed them. - std::int32_t idx = tbl->field_index(name); - if (idx < 0) return false; - *out = static_cast(idx); - return true; -} - -UNSIGNED32 emit_utf16(UNSIGNED16* pucBufW, UNSIGNED32* pulLenW, - const std::string& utf8) { - if (pulLenW == nullptr) return openads::AE_INTERNAL_ERROR; - auto units = openads::abi::utf8_to_utf16le(utf8); - UNSIGNED32 cap = *pulLenW; - UNSIGNED32 n = cap > 0 - ? std::min(cap - 1, - static_cast(units.size())) - : 0; - if (pucBufW != nullptr && cap > 0) { - if (n > 0) { - std::memcpy(pucBufW, units.data(), - n * sizeof(std::uint16_t)); - } - pucBufW[n] = 0; - } - *pulLenW = static_cast(units.size()); - return openads::AE_SUCCESS; -} - -void utf16z_to_utf8(const UNSIGNED16* text, std::string* out) { - if (out == nullptr) return; - out->clear(); - if (text == nullptr) return; - std::size_t units = 0; - while (text[units] != 0) ++units; - if (units == 0) return; - - if (text[0] == 0xFEFFu) { - *out = openads::abi::utf16le_to_utf8(text + 1, units - 1); - return; - } - if (text[0] == 0xFFFEu) { - std::vector swapped; - swapped.reserve(units - 1); - for (std::size_t i = 1; i < units; ++i) { - std::uint16_t v = text[i]; - swapped.push_back(static_cast( - static_cast(v << 8) | - static_cast(v >> 8))); - } - *out = openads::abi::utf16le_to_utf8(swapped.data(), swapped.size()); - return; - } - *out = openads::abi::utf16le_to_utf8(text, units); -} - -} // namespace - -UNSIGNED32 ENTRYPOINT AdsSetStringW(ADSHANDLE hTable, UNSIGNED8* pucField, - UNSIGNED16* pucValueW, UNSIGNED32 ulLen) { - arc2_trace("AdsSetStringW"); - constexpr std::size_t kMaxUtf16Units = 1u << 20; - std::size_t units = ulLen; - if (units == 0 && pucValueW != nullptr) { - while (units < kMaxUtf16Units && pucValueW[units] != 0) ++units; - if (units >= kMaxUtf16Units) { - return fail(openads::AE_INSUFFICIENT_BUFFER, "unicode string too long"); - } - } - std::string utf8 = openads::abi::utf16le_to_utf8( - reinterpret_cast(pucValueW), units); - - if (auto* rt = get_remote_table(hTable)) { - if (pucField == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - auto i = remote_field_index(rt, pucField); - if (i == std::numeric_limits::max() || i >= rt->fields.size()) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - std::string fname = rt->fields[i].name; - return remote_buffered_set(rt, fname, utf8); - } - Table* t = get_table(hTable); - if (!t) { - return AdsSetString(hTable, pucField, - reinterpret_cast(utf8.data()), - static_cast(utf8.size())); - } - std::uint16_t idx = 0; - if (!resolve_field_index_w(t, pucField, &idx)) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - auto r = t->set_field(idx, utf8); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsGetStringW(ADSHANDLE hTable, UNSIGNED8* pucField, - UNSIGNED16* pucBufW, UNSIGNED32* pulLenW, - UNSIGNED16 /*usOption*/) { - arc2_trace("AdsGetStringW"); - if (pulLenW == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (auto* _rt = get_remote_table(hTable); _rt != nullptr) { - (void)_rt; - UNSIGNED8 tmp[4096] = {0}; - UNSIGNED32 cap = sizeof(tmp); - auto rc = AdsGetField(hTable, pucField, tmp, &cap, 0); - if (rc != 0) return rc; - return emit_utf16(pucBufW, pulLenW, - std::string(reinterpret_cast(tmp), cap)); - } - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); - std::uint16_t idx = 0; - if (!resolve_field_index_w(t, pucField, &idx)) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - auto v = t->read_field(idx); - if (!v) return fail(v.error()); - return emit_utf16(pucBufW, pulLenW, v.value().as_string); -} - -UNSIGNED32 ENTRYPOINT AdsGetFieldW(ADSHANDLE hTable, UNSIGNED8* pucField, - UNSIGNED16* pucBufW, UNSIGNED32* pulLenW, - UNSIGNED16 /*usOption*/) { - arc2_trace("AdsGetFieldW"); - return AdsGetStringW(hTable, pucField, pucBufW, pulLenW, 0); -} - -UNSIGNED32 ENTRYPOINT AdsSetJulian(ADSHANDLE hTable, UNSIGNED8* pucField, - SIGNED32 lDate) { - arc2_trace("AdsSetJulian"); - char buf[9]; - if (lDate <= 0) { - std::memset(buf, ' ', 8); buf[8] = '\0'; - } else { - int y = 0, m = 0, d = 0; - julian_to_ymd(lDate, y, m, d); - if (y < 0) { y = 0; } - if (y > 9999) { y = 9999; } - m = ((m % 100) + 100) % 100; - d = ((d % 100) + 100) % 100; - std::snprintf(buf, sizeof(buf), "%04d%02d%02d", y, m, d); - } - std::string val(buf, 8); - - if (auto* rt = get_remote_table(hTable)) { - if (pucField == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - auto i = remote_field_index(rt, pucField); - if (i == std::numeric_limits::max() || i >= rt->fields.size()) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - std::string fname = rt->fields[i].name; - return remote_buffered_set(rt, fname, val); - } - Table* t = get_table(hTable); - if (!t) { - return AdsSetString(hTable, pucField, - reinterpret_cast(val.data()), 8); - } - std::uint16_t idx = 0; - if (!resolve_field_index(t, pucField, &idx)) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - auto r = t->set_field(idx, val); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsSetDate(ADSHANDLE hTable, UNSIGNED8* pucField, - UNSIGNED8* pucValue, UNSIGNED16 usLen) { - arc2_trace("AdsSetDate"); - if (pucField != nullptr && - reinterpret_cast(pucField) >= 0x10000u) { - std::string val(pucValue ? reinterpret_cast(pucValue) : "", - pucValue ? static_cast(usLen) : 0u); - std::string escaped; - escaped.reserve(val.size() + 2); - for (char c : val) { escaped += c; if (c == '\'') escaped += c; } - if (set_stmt_param(hTable, reinterpret_cast(pucField), - "'" + escaped + "'")) { - return ok(); - } - } - std::string compact; - compact_ace_date_value(pucValue, usLen, &compact); - std::vector bytes(compact.begin(), compact.end()); - bytes.push_back(0); - return AdsSetString(hTable, pucField, bytes.data(), - static_cast(compact.size())); -} - -// --- M9.18 lock retry policy ---------------------------------------------- -// -// Real ACE exposes a per-connection (cycle_ms, retry_count) tuple that -// callers tune via AdsSetLockCycle / AdsSetLockRetryCount; AdsLockTable -// and AdsLockRecord then re-attempt a contended lock up to that limit -// before reporting AE_LOCK_FAILED. OpenADS keeps a process-global -// policy (the hConnect arg is accepted for ABI compat but the value is -// shared across connections in this build); the retry loop sleeps -// `cycle_ms` between attempts and gives up after `retry_count` cycles. -// LockPolicy and lock_retry_policy() are defined in abi/lock_retry_policy.h. - -namespace { - -using openads::abi::lock_retry_policy; - -UNSIGNED32 lock_with_retry(std::function()> fn) { - using openads::abi::lock_retry_policy; - const auto p = lock_retry_policy(); - // Total wait budget = retry_count x cycle_ms (the plain ACE contract), - // but early attempts recheck after a few ms (adaptive backoff) so - // short contentions — the common case — resolve in single-digit ms - // instead of one 100ms slice. Attempts continue past retry_count - // while the time budget is not exhausted. - const auto t0 = std::chrono::steady_clock::now(); - const auto deadline = t0 + std::chrono::milliseconds(p.budget_ms()); - for (std::uint32_t i = 0; ; ++i) { - auto r = fn(); - if (r) return openads::AE_SUCCESS; - if (i >= p.retry_count && std::chrono::steady_clock::now() >= deadline) - return fail(r.error()); - openads::abi::lock_retry_sleep(i); - } -} - -} // namespace - -UNSIGNED32 ENTRYPOINT AdsSetLockCycle(ADSHANDLE /*hConnect*/, UNSIGNED32 ulCycle) { - arc2_trace("AdsSetLockCycle"); - auto& s = state(); - std::lock_guard lk(s.mu); - lock_retry_policy().cycle_ms = ulCycle; - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsGetLockCycle(ADSHANDLE /*hConnect*/, UNSIGNED32* pulCycle) { - arc2_trace("AdsGetLockCycle"); - if (pulCycle == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - auto& s = state(); - std::lock_guard lk(s.mu); - *pulCycle = lock_retry_policy().cycle_ms; - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsSetLockRetryCount(ADSHANDLE /*hConnect*/, UNSIGNED16 usRetryCount) { - arc2_trace("AdsSetLockRetryCount"); - auto& s = state(); - std::lock_guard lk(s.mu); - lock_retry_policy().retry_count = usRetryCount; - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsGetLockRetryCount(ADSHANDLE /*hConnect*/, UNSIGNED16* pusRetryCount) { - arc2_trace("AdsGetLockRetryCount"); - if (pusRetryCount == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - auto& s = state(); - std::lock_guard lk(s.mu); - *pusRetryCount = lock_retry_policy().retry_count; - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsLockRecord(ADSHANDLE hTable, UNSIGNED32 ulRecord) { - arc2_trace("AdsLockRecord"); - if (auto* rt = get_remote_table(hTable)) { - if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; - auto r = rt->conn->lock_record(rt->id, ulRecord); - if (!r) return fail(r.error()); - rt->ever_locked = true; - // ulRecord == 0 -> the current record (ACE convention). With - // no valid cursor the recno is unknowable client-side, so the - // ledger marks itself uncertain rather than guessing. - const std::uint32_t lrec = (ulRecord == 0) - ? (rt->row_valid ? rt->current_recno : 0) : ulRecord; - if (lrec == 0) rt->locks_uncertain = true; - else rt->held_recs.insert(lrec); - return ok(); - } -#if defined(OPENADS_WITH_FIREBIRD) - if (auto* ft = get_firebird_table(hTable)) { - if (ft->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto r = ft->conn->lock_record(ft, ulRecord); - if (!r) return fail(r.error()); - return ok(); - } -#endif -#if defined(OPENADS_WITH_POSTGRESQL) - if (auto* pt = get_postgres_table(hTable)) { - if (pt->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto r = pt->conn->lock_record(pt, ulRecord); - if (!r) return fail(r.error()); - return ok(); - } -#endif -#if defined(OPENADS_WITH_MARIADB) - if (auto* mt = get_maria_table(hTable)) { - if (mt->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto r = mt->conn->lock_record(mt, ulRecord); - if (!r) return fail(r.error()); - return ok(); - } -#endif -#if defined(OPENADS_WITH_ODBC) - if (auto* ot = get_odbc_table(hTable)) { - if (ot->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto r = ot->conn->lock_record(ot, ulRecord); - if (!r) return fail(r.error()); - return ok(); - } -#endif -#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->lock_record(st, ulRecord); - if (!r) return fail(r.error()); - return ok(); - } -#endif -#if defined(OPENADS_WITH_MSSQL) - if (auto* mst = get_mssql_table(hTable)) { - if (mst->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto r = mst->conn->lock_record(mst, ulRecord); - if (!r) return fail(r.error()); - return ok(); - } -#endif - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - // ulRecord == 0 → the current record (ACE convention). Resolving it - // also keeps the CDX record-lock byte (FILE_BASE - recno) clear of - // the file/table lock byte (FILE_BASE) when recno would be 0. - std::uint32_t rec = (ulRecord == 0) ? t->recno() : ulRecord; - return lock_with_retry([t, rec]() { - auto r = t->try_lock_record_excl(rec); - if (r) openads::mgmt::LockRegistry::instance().add_record_lock( - t, t->path(), rec); - return r; - }); -} - -UNSIGNED32 ENTRYPOINT AdsUnlockRecord(ADSHANDLE hTable, UNSIGNED32 ulRecord) { - arc2_trace("AdsUnlockRecord"); - if (auto* rt = get_remote_table(hTable)) { - if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; - auto r = rt->conn->unlock_record(rt->id, ulRecord); - if (!r) return fail(r.error()); - const std::uint32_t urec = (ulRecord == 0) - ? (rt->row_valid ? rt->current_recno : 0) : ulRecord; - if (urec != 0) rt->held_recs.erase(urec); - // locks_uncertain survives: only a full UnlockTable (or the - // close) proves the append auto-lock is gone. - return ok(); - } -#if defined(OPENADS_WITH_FIREBIRD) - if (auto* ft = get_firebird_table(hTable)) { - if (ft->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto r = ft->conn->unlock_record(ft, ulRecord); - if (!r) return fail(r.error()); - return ok(); - } -#endif -#if defined(OPENADS_WITH_POSTGRESQL) - if (auto* pt = get_postgres_table(hTable)) { - if (pt->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto r = pt->conn->unlock_record(pt, ulRecord); - if (!r) return fail(r.error()); - return ok(); - } -#endif -#if defined(OPENADS_WITH_MARIADB) - if (auto* mt = get_maria_table(hTable)) { - if (mt->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto r = mt->conn->unlock_record(mt, ulRecord); - if (!r) return fail(r.error()); - return ok(); - } -#endif -#if defined(OPENADS_WITH_ODBC) - if (auto* ot = get_odbc_table(hTable)) { - if (ot->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto r = ot->conn->unlock_record(ot, ulRecord); - if (!r) return fail(r.error()); - return ok(); - } -#endif -#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->unlock_record(st, ulRecord); - if (!r) return fail(r.error()); - return ok(); - } -#endif -#if defined(OPENADS_WITH_MSSQL) - if (auto* mst = get_mssql_table(hTable)) { - if (mst->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto r = mst->conn->unlock_record(mst, ulRecord); - if (!r) return fail(r.error()); - return ok(); - } -#endif - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - std::uint32_t rec = (ulRecord == 0) ? t->recno() : ulRecord; - auto r = t->unlock_record(rec); - if (!r) return fail(r.error()); - openads::mgmt::LockRegistry::instance().remove_record_lock(t, rec); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsLockTable(ADSHANDLE hTable) { - arc2_trace("AdsLockTable"); - if (auto* rt = get_remote_table(hTable)) { - if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; - auto r = rt->conn->lock_table(rt->id); - if (!r) return fail(r.error()); - rt->ever_locked = true; - rt->table_lock_held = true; - return ok(); - } -#if defined(OPENADS_WITH_FIREBIRD) - if (auto* ft = get_firebird_table(hTable)) { - if (ft->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto r = ft->conn->lock_table(ft); - if (!r) return fail(r.error()); - return ok(); - } -#endif -#if defined(OPENADS_WITH_POSTGRESQL) - if (auto* pt = get_postgres_table(hTable)) { - if (pt->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto r = pt->conn->lock_table(pt); - if (!r) return fail(r.error()); - return ok(); - } -#endif -#if defined(OPENADS_WITH_MARIADB) - if (auto* mt = get_maria_table(hTable)) { - if (mt->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto r = mt->conn->lock_table(mt); - if (!r) return fail(r.error()); - return ok(); - } -#endif -#if defined(OPENADS_WITH_ODBC) - if (auto* ot = get_odbc_table(hTable)) { - if (ot->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto r = ot->conn->lock_table(ot); - if (!r) return fail(r.error()); - return ok(); - } -#endif -#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->lock_table(st); - if (!r) return fail(r.error()); - return ok(); - } -#endif -#if defined(OPENADS_WITH_MSSQL) - if (auto* mst = get_mssql_table(hTable)) { - if (mst->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto r = mst->conn->lock_table(mst); - if (!r) return fail(r.error()); - return ok(); - } -#endif - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - return lock_with_retry([t]() { - auto r = t->try_lock_table_excl(); - if (r) openads::mgmt::LockRegistry::instance().add_table_lock( - t, t->path()); - return r; - }); -} - -UNSIGNED32 ENTRYPOINT AdsUnlockTable(ADSHANDLE hTable) { - arc2_trace("AdsUnlockTable"); - if (auto* rt = get_remote_table(hTable)) { - if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; - // Lock ledger: with nothing held on the server, the frame - // would release zero locks. Harbour rddads dbUnlock() has no - // client-side lock list and calls this blindly before every - // lock/append -- that blind call is most of the UnlockTable - // traffic in a Vouch save. - if (rt->held_recs.empty() && !rt->table_lock_held && - !rt->locks_uncertain) { - cli_trace_tbl(rt, "AdsUnlockTable", "skipped: no locks held"); - return ok(); - } - auto r = rt->conn->unlock_table(rt->id); - if (!r) return fail(r.error()); - // SAP ACE semantics: AdsUnlockTable releases ALL locks (table - // AND record, including the append auto-lock), so the ledger - // is provably empty again. - rt->held_recs.clear(); - rt->table_lock_held = false; - rt->locks_uncertain = false; - return ok(); - } -#if defined(OPENADS_WITH_FIREBIRD) - if (auto* ft = get_firebird_table(hTable)) { - if (ft->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto r = ft->conn->unlock_table(ft); - if (!r) return fail(r.error()); - return ok(); - } -#endif -#if defined(OPENADS_WITH_POSTGRESQL) - if (auto* pt = get_postgres_table(hTable)) { - if (pt->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto r = pt->conn->unlock_table(pt); - if (!r) return fail(r.error()); - return ok(); - } -#endif -#if defined(OPENADS_WITH_MARIADB) - if (auto* mt = get_maria_table(hTable)) { - if (mt->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto r = mt->conn->unlock_table(mt); - if (!r) return fail(r.error()); - return ok(); - } -#endif -#if defined(OPENADS_WITH_ODBC) - if (auto* ot = get_odbc_table(hTable)) { - if (ot->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto r = ot->conn->unlock_table(ot); - if (!r) return fail(r.error()); - return ok(); - } -#endif -#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->unlock_table(st); - if (!r) return fail(r.error()); - return ok(); - } -#endif -#if defined(OPENADS_WITH_MSSQL) - if (auto* mst = get_mssql_table(hTable)) { - if (mst->conn == nullptr) - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - auto r = mst->conn->unlock_table(mst); - if (!r) return fail(r.error()); - return ok(); - } -#endif - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - auto r = t->unlock_table(); - if (!r) return fail(r.error()); - // SAP ACE semantics (verified against ace32/ace64, commit 1fb224b): - // AdsUnlockTable releases ALL locks — table AND record. Harbour rddads - // trusts this (dbUnlock() has no client-side lock list; a leaked RLock - // blocks the next dbRLock forever). Drop every lock of the table from - // the management registry too. - openads::mgmt::LockRegistry::instance().remove_all_for_table(t); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsFlushFileBuffers(ADSHANDLE hTable) { - arc2_trace("AdsFlushFileBuffers"); - if (auto* rt = get_remote_table(hTable)) { - if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; - // Deferred: a CloseTable is expected to absorb this (the server - // close flushes via its shadow handle). If any other wire op - // intervenes, remote_flush_pending emits it ahead of that op. - // Clean tables (the RDD calls this blindly around every - // rotation) owe nothing: no flag, so teardown stays fully - // deferred and the index park survives. Buffered sets always - // mark dirty at buffer time, so this never skips real data. - if (rt->pending_sets.empty() && !rt->write_dirty) return ok(); - rt->flush_file_pending = true; - return ok(); - } - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - auto r = t->flush(); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsSetDeferredFlush(ADSHANDLE hTable, UNSIGNED16 usDeferred) { - arc2_trace("AdsSetDeferredFlush"); - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - t->set_deferred_flush(usDeferred != 0); - return ok(); -} - -// --- M3 index / scope / seek ----------------------------------------------- - -extern "C++" { - -namespace { - -// Index handles: a single "logical" handle wraps the table-bound order -// since openads::engine::Table owns the active IIndex via Order. We keep a -// per-process map so the L1 thunks can resolve the table from the index -// handle. -// Binding for one open tag. Multi-tag CDX files create one binding -// per tag. At most ONE binding per table is "live" -- its `idx` has -// been moved into Table::order_; the rest park their IIndex here so -// OrdSetFocus / AdsGetIndexHandle can swap them in on demand. -struct IndexBinding { - Table* table = nullptr; - std::string tag_name; - std::unique_ptr parked; // nullptr when this is the active binding - std::string path; // resolved index file path (M9.14) -}; - -std::unordered_map& index_bindings() { - static std::unordered_map m; - return m; -} - -// Protects index_bindings() / active_binding_for() only. MUST NOT be held -// across bulk CDX I/O (see AdsCreateIndex61). Recursive so helpers re-enter. -std::recursive_mutex& index_bindings_mu() { - static std::recursive_mutex m; - return m; -} - -// Records, per table, which binding handle currently owns the active -// IIndex (i.e. the one moved into Table::order_). -std::unordered_map& active_binding_for() { - static std::unordered_map m; - return m; -} - -// Park the table's active order back into its binding slot, leaving the -// table in natural order (AdsSetIndexOrder(h, "") semantics). -void park_active_order(Table* t) { - std::lock_guard lk(index_bindings_mu()); - auto& act = active_binding_for(); - auto act_it = act.find(t); - if (act_it == act.end()) return; - auto& m = index_bindings(); - auto bit = m.find(act_it->second); - if (bit != m.end()) { - auto taken = t->take_order(); - openads::drivers::IIndex* raw = taken.get(); - bit->second.parked = std::move(taken); - if (raw) t->register_extra_index_view(raw); - } - act.erase(act_it); -} - -// Drop every binding tied to `t`. Called from AdsCloseTable / AdsCloseAllTables -// / AdsDisconnect -- without this, a Connection teardown leaves the bindings -// behind, so a later test (or app reconnect) that allocates a Table at the -// same heap slot inherits the stale entries and table_has_active misfires. -void purge_bindings_for_table(Table* t) { - std::lock_guard lk(index_bindings_mu()); - auto& m = index_bindings(); - auto& act = active_binding_for(); - for (auto it = m.begin(); it != m.end(); ) { - if (it->second.table == t) it = m.erase(it); - else ++it; - } - act.erase(t); -} - -Table* lookup_table_by_index(ADSHANDLE h) { - std::lock_guard lk(index_bindings_mu()); - auto& m = index_bindings(); - auto it = m.find(h); - if (it == m.end()) return nullptr; - return it->second.table; -} - -openads::drivers::IIndex* iindex_for_handle(ADSHANDLE h) { - std::lock_guard lk(index_bindings_mu()); - auto& m = index_bindings(); - auto it = m.find(h); - if (it == m.end()) return nullptr; - if (it->second.parked) return it->second.parked.get(); - Table* t = it->second.table; - if (t && t->order()) return t->order()->index(); - return nullptr; -} - -// Make `h` the active order for its table. If another binding is -// currently active, park its IIndex back into that binding before -// stealing the requested one. No-op when `h` is already active. -openads::util::Result activate_binding(ADSHANDLE h) { - std::lock_guard lk(index_bindings_mu()); - auto& m = index_bindings(); - auto it = m.find(h); - if (it == m.end()) { - return openads::util::Error{ - openads::AE_INTERNAL_ERROR, 0, "unknown index", ""}; - } - Table* t = it->second.table; - auto& act = active_binding_for(); - auto act_it = act.find(t); - if (act_it != act.end() && act_it->second == h) return {}; // already live - - // Park the currently-active binding's index back into its slot - // and register it as an extra view (so multi-index sync still - // touches it after the swap). When act_it points to a handle - // that's no longer in the binding map (stale entry left by a - // previous AdsCloseAllIndexes / test cleanup that didn't tidy - // act_), drop the act entry but leave Table::order_ alone -- the - // current code may have set it via the legacy AdsCreateIndex path - // that doesn't populate `act_`. - if (act_it != act.end()) { - auto prev = m.find(act_it->second); - if (prev != m.end()) { - auto taken = t->take_order(); - openads::drivers::IIndex* raw = taken.get(); - prev->second.parked = std::move(taken); - if (raw) t->register_extra_index_view(raw); - } - // else: stale act entry; leave Table::order_ untouched. - } - - // Move the parked IIndex from this binding into the table; drop - // its extra-view entry since the active order's IIndex is already - // walked by the sync loop. - if (it->second.parked) { - openads::drivers::IIndex* raw = it->second.parked.get(); - t->unregister_extra_index_view(raw); - t->set_order(std::move(it->second.parked)); - } - act[t] = h; - return {}; -} - -ADSHANDLE next_index_handle() { - static std::atomic n{0x40000000ULL}; - return static_cast(++n); -} - -Table* table_for_index(ADSHANDLE hIndex) { - // Storm fix: reader must hold index_bindings_mu (binds are unlocked now). - std::lock_guard _tf_lk(index_bindings_mu()); - auto it = index_bindings().find(hIndex); - if (it == index_bindings().end()) return nullptr; - // Activate this binding so the Table's order_ reflects the - // requested index -- AdsSeek / AdsGotoTop / etc. always operate - // through the Table's active order, and rddads passes the index - // handle (pArea->hOrdCurrent) as the operand. - (void)activate_binding(hIndex); - return it->second.table; -} - -// Mark a freshly opened CDX index FoxNumeric when its key is a bare -// numeric field, so seek/append on a reopened numeric index builds the -// same 8-byte order-preserving key the file was written with. No-op for -// character keys / non-CDX drivers. -void mark_cdx_key_encoding(Table* t, openads::drivers::IIndex* idx) { - if (t == nullptr || idx == nullptr) return; - // Decide by the key EXPRESSION, never by key length alone. A blanket - // "key_length()==8 => FoxNumeric" mis-marked plain C(8) character - // tags on reopen; once compare_keys_ started honouring the encoding - // (v1.8.6 memcmp fix), every reopened C(8) tag under an OEM - // collation seeked with raw byte order against a tree built in - // collation order and missed existing keys (#130 follow-up). - const std::string bare = - openads::engine::strip_alias_qualifiers(idx->expression()); - std::int32_t fi = t->field_index(bare); - if (fi >= 0) { - // Bare field: the schema decides. Character/date/logical keys - // stay Text regardless of their width. - using FT = openads::drivers::DbfFieldType; - FT ft = t->field_descriptor(static_cast(fi)).type; - if (ft == FT::Numeric || ft == FT::Float || ft == FT::Integer || - ft == FT::Double || ft == FT::Currency || ft == FT::AdtMoney) { - idx->set_key_encoding(openads::drivers::KeyEncoding::FoxNumeric); - } - return; - } - // Computed expression: FoxNumeric only when the key has the 8-byte - // binary width AND the expression provably evaluates numeric -- the - // same rules the AdsCreateIndex61 path applies (Val() heuristic + - // record-1 probe). Fixes numeric Val() DbSeek on reopened CDX (#130). - if (idx->key_length() != 8) return; - std::string u = idx->expression(); - for (char& c : u) - c = static_cast(toupper(static_cast(c))); - if (u.find("VAL(") != std::string::npos) { - idx->set_key_encoding(openads::drivers::KeyEncoding::FoxNumeric); - return; - } - if (t->record_count() > 0) { - // Bulk-scan load so the caller's cursor state is untouched - // (mirrors the create-path probe). - if (auto raw = t->driver()->read_record_raw(1); raw) { - t->load_record_for_bulk_scan(std::move(raw.value()), 1); - double dv = 0.0; - if (openads::engine::evaluate_index_expr_number( - *t, idx->expression(), dv)) { - idx->set_key_encoding( - openads::drivers::KeyEncoding::FoxNumeric); - } - } - } -} - -// Stamp OEM national collation onto a CDX index for its table. -// RCB 2026-07-10 -- uses the table's EFFECTIVE collation -// (Table::oem_sort_table(): connection AdsSetCollation override, else -// ADS_OEM char type + configured default OEM collation), not just the -// connection's. Reading only conn->oem_sort_table() here left every -// rddads-opened tag on binary compare -- rddads can't call -// AdsSetCollation -- so PL852 seeks missed existing keys (#130 -// follow-up). Keep routing through Table::oem_sort_table(). -void apply_cdx_oem_collation(Table* t, openads::drivers::IIndex* idx) { - if (t == nullptr || idx == nullptr) return; - auto* cdx = dynamic_cast(idx); - if (cdx == nullptr) return; - cdx->set_oem_sort_table(t->oem_sort_table()); -} - -// Re-mark a reopened NTX index NtxNumeric so a later append writes the native -// zero-padded numeric key. The create path marks it via set_numeric_format; a -// reopen through AdsOpenIndex must restore the flag (open() already reads the -// width + decimal count back from the NTX header). No-op for character keys. -void mark_ntx_key_encoding(Table* t, openads::drivers::IIndex* idx) { - if (t == nullptr || idx == nullptr) return; - const std::string bare = - openads::engine::strip_alias_qualifiers(idx->expression()); - std::int32_t fi = t->field_index(bare); - if (fi < 0) return; - using FT = openads::drivers::DbfFieldType; - FT ft = t->field_descriptor(static_cast(fi)).type; - if (ft == FT::Numeric || ft == FT::Float) { - idx->set_key_encoding(openads::drivers::KeyEncoding::NtxNumeric); - } -} - -bool path_ends_with_ci(const std::string& s, const char* suffix) { - auto n = std::strlen(suffix); - if (s.size() < n) return false; - for (std::size_t i = 0; i < n; ++i) { - char a = static_cast(std::tolower( - static_cast(s[s.size() - n + i]))); - char b = static_cast(std::tolower( - static_cast(suffix[i]))); - if (a != b) return false; - } - return true; -} - -// Content sniff for bags whose extension carries no format hint (custom -// suffixes like ".Z01"): a CDX bag has the Harbour "RCHB" signature at -// offset 20 of the file header. Returns false on unreadable / short files -// (caller then keeps the extension-default driver). -bool file_has_cdx_signature(const std::string& path) { - std::ifstream f(path, std::ios::binary); - if (!f) return false; - char hdr[24] = {0}; - f.read(hdr, sizeof(hdr)); - if (f.gcount() < static_cast(sizeof(hdr))) return false; - return hdr[20] == 'R' && hdr[21] == 'C' && hdr[22] == 'H' && - hdr[23] == 'B'; -} - -// Harbour/FiveWin may pass a client-native fully qualified path (for example -// C:\\work\\table.cdx) or a relative path with subdirectories (for example -// Indexes\\table.cdx) even when the table is remote. Normalize separators -// everywhere but KEEP any directory component: for remote callers the server -// resolves the location itself (bare filename -> the table's own folder; -// directory-qualified -> under the connection data root, the same rule the -// .DBF gets), so stripping to basename here would silently discard the -// folder the caller asked for (Pritpal Bedi, 29/07/2026). -std::string normalize_index_path(std::string path) { - std::replace(path.begin(), path.end(), '\\', '/'); - return path; -} - -// Lowercased file stem (no directory, no extension): "C:\d\VO_ATDFN.z01" -// and "vo_atdfn.cdx" name the same production bag for rotation matching. -std::string bag_stem_ci(const std::string& p) { - std::string b = p; - auto sep = b.find_last_of("/\\"); - if (sep != std::string::npos) b = b.substr(sep + 1); - auto dot = b.find_last_of('.'); - if (dot != std::string::npos) b = b.substr(0, dot); - for (auto& c : b) - c = static_cast(std::tolower( - static_cast(c))); - return b; -} - -// Harbour INDEX ON TO cIdxFile passes a client-absolute bag -// ("C:/Creative.RAM/T.Z01"). Table opens remount that spelling under -// --data when --legacy-paths is on; the bag must follow or the .z01 -// lands in the host tree while the .dbf updates in the jail -// (Pritpal Bedi, 12/08/2026). -std::string resolve_index_bag_for_table(Table* t, std::string bag, - const char* default_ext) { - bag = normalize_index_path(std::move(bag)); - namespace fs = std::filesystem; - if (t != nullptr) { - if (auto* owner = t->owner()) { - if (owner->legacy_paths() && !owner->data_dir().empty()) { - if (bag.empty()) { - return fs::path(t->path()) - .replace_extension(default_ext) - .string(); - } - auto type = openads::engine::TableType::Cdx; - std::string resolved = - owner->resolve_table_file(bag, type, /*for_create=*/true); - fs::path p(resolved); - if (!p.has_extension()) p.replace_extension(default_ext); - std::error_code ec; - fs::create_directories(p.parent_path(), ec); - return p.string(); - } - } - } - fs::path p; - if (bag.empty()) { - p = fs::path(t->path()).replace_extension(default_ext); - } else { - p = fs::path(bag); - if (!p.is_absolute() && t != nullptr) { - p = fs::path(t->path()).parent_path() / p; - } - if (!p.has_extension()) p.replace_extension(default_ext); - } - return p.string(); -} - -std::unique_ptr -make_index_for(const std::string& path) { - if (path_ends_with_ci(path, ".cdx")) { - return std::make_unique(); - } - if (path_ends_with_ci(path, ".adi")) { - return std::make_unique(); - } - return std::make_unique(); -} - -} // namespace - -} // extern "C++" - -namespace { -// Route an ADT table's .adi bag through the CdxIndex engine (full evaluated -// key + 32-bit recno), so compound / computed / FOR tags work over ADT the -// same way they do over DBF. The file is then CDX-format even though it is -// named .adi -- only enable when those .ADI files are NOT interchanged with -// real Advantage. Gate: env OPENADS_ADT_CDX_INDEX=1 (or -// adt_cdx_index = 1 in openads.ini). -bool adt_cdx_index_enabled() { - return openads::util::client_setting_truthy( - "OPENADS_ADT_CDX_INDEX", "adt_cdx_index"); -} - -// A .adi bag written by the CdxIndex reroute carries the Harbour CDX structure -// signature "RCHB" at byte offset 20 (see cdx_index.cpp); a native AdiIndex bag -// does not. Detecting it lets the OPEN path pick the right engine REGARDLESS of -// OPENADS_ADT_CDX_INDEX. Without this, a .adi built CDX-format (reroute on at -// reindex) but opened with the flag absent routes to the native AdiIndex -// reader, which cannot parse it -> 5004 -> the table opens with 0 indexes and -// the first DBSETORDER/SEEK fails. -bool adi_bag_is_cdx_format(const std::string& path) { - std::ifstream f(path, std::ios::binary); - if (!f) return false; - char hdr[24] = {0}; - f.read(hdr, sizeof(hdr)); - if (f.gcount() < 24) return false; - return hdr[20] == 'R' && hdr[21] == 'C' && hdr[22] == 'H' && hdr[23] == 'B'; -} -} // namespace - -// Compare two filesystem paths for the "is this the same on-disk -// file?" question. Falls back to a case-insensitive lexical compare -// when canonical resolution fails (e.g. file doesn't exist yet). -namespace { -bool same_index_path(const std::string& a, const std::string& b) { - namespace fs = std::filesystem; - std::error_code ec; - auto ca = fs::weakly_canonical(fs::path(a), ec); - auto cb = fs::weakly_canonical(fs::path(b), ec); - auto sa = ec ? a : ca.string(); - auto sb = ec ? b : cb.string(); - if (sa.size() != sb.size()) { - if (a.size() != b.size()) return false; - for (std::size_t i = 0; i < a.size(); ++i) { - char ca2 = static_cast(std::tolower( - static_cast(a[i]))); - char cb2 = static_cast(std::tolower( - static_cast(b[i]))); - if (ca2 != cb2) return false; - } - return true; - } - for (std::size_t i = 0; i < sa.size(); ++i) { - char ca2 = static_cast(std::tolower( - static_cast(sa[i]))); - char cb2 = static_cast(std::tolower( - static_cast(sb[i]))); - if (ca2 != cb2) return false; - } - return true; -} - -// Stale-bag heal (Pritpal Bedi's .z01 work bags, Aug 2026): a compound tag -// whose tree was never built — or whose keys were written by a handle that -// never reached disk — has no root page, so every GotoTop on it lands in -// Limbo (bof=eof=1) over a NON-empty table and no navigation rescue -// escapes. A PARTIALLY-maintained bag is just as broken for the app: -// dbGoBottom lands on the last STALE key (voucher 16 of 21) instead of the -// last record. An unconditional non-unique tag indexes every record -// (deleted ones included), so key_count != record_count means some writes -// bypassed the bag (SQL DML, index closed at write time, killed before -// flush). Rebuild the bag once here instead of handing the caller a ghost -// order. Conditional (FOR) tags are skipped: zero/fewer matching rows is -// legitimate for them. Unique tags are skipped too: duplicates collapse, -// so a smaller key count proves nothing. Read-only tables are skipped. -// The partial check walks the tag once per open — threshold-gated. -void heal_stale_bag_on_open(Table* t, - const std::vector& views) { - if (t == nullptr || t->record_count() == 0) return; - if (t->open_mode() == openads::engine::OpenMode::Read) return; - for (auto* v : views) { - if (v == nullptr || !v->condition().empty()) continue; - // empty() refreshes the sub-tag header and checks root_page_==0 — - // the never-built / never-flushed Limbo bag this heal exists for. - // The old key_count() != record_count() trigger walked the whole - // tag (O(keys)) per open AND is inherently racy: under a - // concurrent-writer storm keys insert at commit, so every - // simultaneous OpenIndex saw key_count < record_count, concluded - // "stale" and each launched a full t->reindex() — measured as the - // 700-session storm convoy (OPDUMP: 0x88 OpenIndex = 92.6% of - // server time, 6.8 s/call). A partially-maintained bag cannot be - // detected reliably under concurrency and stays the app's - // reindex responsibility. - bool stale = v->empty(); - if (!stale) continue; - arc2_log("IDXHEAL stale tag '%s' on %s (recs=%u) -> reindex", - v->name().c_str(), t->path().c_str(), - (unsigned)t->record_count()); - (void)t->reindex(); - return; - } -} -} // namespace - -// Real-ACE 4-arg signature: opens an index FILE, registers one handle -// per tag, and writes the handles into ahIndex[] / *pu16ArrayLen. -// -// M9.14 made this additive: a second AdsOpenIndex against a different -// file path no longer wipes the prior bindings. Instead, the new -// indices land as parked extra views (their writes still sync) and -// the first one only steals Table::order_ when no active order is -// currently bound. Repeated calls with the SAME path drop the prior -// bindings for that path (refresh semantics) so reopening the same -// .ntx / .cdx leaves at most one binding per tag. -UNSIGNED32 ENTRYPOINT AdsOpenIndex(ADSHANDLE hTable, UNSIGNED8* pucName, - ADSHANDLE* ahIndex, UNSIGNED16* pu16ArrayLen) { - arc2_trace("AdsOpenIndex"); - if (ahIndex == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, "null out"); - } - if (auto* rt = get_remote_table(hTable)) { - std::string path = openads::abi::to_internal(pucName, 0); - // Distributed flush (WAN chattiness): a deferred order switch - // or teardown absorbed here must not interleave with the parked - // restore below — flush first, then decide. - if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; - // Parked-index reuse (per-tag rotation): CloseAll parked the - // live maps with the server bindings still open. A same-bag - // reopen restores them and serves the handles with zero - // frames. A different (or unknown) bag needs a real close - // first so the server does not accumulate bags behind the - // client's back. - if (rt->indexes_parked) { - if (!rt->parked_by_tag.empty() && !bag_stem_ci(path).empty() && - bag_stem_ci(path) == rt->parked_bag_stem && - rt->parked_by_tag.size() == rt->parked_handles.size()) { - rt->index_by_tag = std::move(rt->parked_by_tag); - rt->index_handles = std::move(rt->parked_handles); - rt->active_index_id = rt->parked_active; - rt->indexes_parked = false; - rt->close_all_indexes_pending = false; - // Restore the nav stamp parked at CloseAll when it still - // proves the server cursor: same order coming back, no - // cursor-affecting frame since the park (seq gate). The - // post-unpark GotoTop(idx) then dedupes locally instead - // of paying a refresh RTT for a position the server - // never lost. - if (rt->parked_nav_which != 0 && - rt->parked_nav_order == rt->parked_active && - rt->parked_nav_seq == rt->conn->nav_seq()) { - rt->last_nav = rt->parked_nav_which; - rt->last_nav_order = rt->parked_nav_order; - rt->last_nav_row = rt->parked_nav_row; - rt->last_nav_seq = rt->parked_nav_seq; - } - rt->parked_nav_which = 0; - cli_trace_tbl(rt, "AdsOpenIndex", "unpark hit %.32s", - rt->parked_bag_stem.c_str()); - auto& s = state(); - std::lock_guard lk(s.mu); - const std::uint16_t cap = - (pu16ArrayLen != nullptr) ? *pu16ArrayLen : 0; - std::uint16_t count = 0; - for (std::size_t i = 0; i < rt->index_by_tag.size(); ++i) { - if (count < cap) { - ahIndex[count] = to_ads_handle(static_cast( - rt->index_handles[i])); - } - ++count; - } - if (rt->active_index_id == 0) - rt->active_index_id = rt->index_by_tag.front().second; - if (pu16ArrayLen != nullptr) *pu16ArrayLen = count; - return ok(); - } - if (auto r = remote_emit_close_all(rt); r != 0) { - return r; - } - cli_trace_tbl(rt, "AdsOpenIndex", "unpark miss %.32s", - path.c_str()); - } - // Zero-RTT dedup (RDD-only apps, no app change possible): the - // production bag is already bound on this handle by the OpenTable - // auto-open, but rddads issues an explicit AdsOpenIndex for the - // same bag on every USE. A second wire open buys identical - // bindings for a full RTT + server reopen, so serve it from the - // cached tag/handle lists when the request names the production - // bag. Compared by stem (case-insensitive): .cdx/.z01 are - // equivalent production spellings. Temp bags (different stem) - // always go to the wire — another session may have added tags. - { - auto& s = state(); - std::lock_guard lk(s.mu); - if (!rt->index_by_tag.empty() && !rt->prod_bag_path.empty() && - rt->index_by_tag.size() == rt->index_handles.size() && - !bag_stem_ci(path).empty() && - bag_stem_ci(path) == bag_stem_ci(rt->prod_bag_path)) { - const std::uint16_t cap = - (pu16ArrayLen != nullptr) ? *pu16ArrayLen : 0; - std::uint16_t count = 0; - for (std::size_t i = 0; i < rt->index_by_tag.size(); ++i) { - if (count < cap) { - ahIndex[count] = to_ads_handle(static_cast( - rt->index_handles[i])); - } - ++count; - } - if (rt->active_index_id == 0) - rt->active_index_id = rt->index_by_tag.front().second; - if (pu16ArrayLen != nullptr) *pu16ArrayLen = count; - return ok(); - } - } - auto r = rt->conn->open_index(rt->id, path); - if (!r) return fail(r.error()); - auto& s = state(); - std::lock_guard lk(s.mu); - // Persist RemoteIndex objects in a side container keyed by - // their ADSHANDLE; the registry only stores raw pointers. - static std::unordered_map> remote_indexes; - const auto& entries = r.value(); - // Register every tag the server opened, but only WRITE handles up - // to the caller's array capacity. The previous code wrote all - // entries whenever pu16ArrayLen was NULL -- a stack overflow past a - // single-handle out-param that clobbered adjacent locals (found on - // x86: the table handle got overwritten with an index handle and - // the next call failed 5000 "unknown table"). - const std::uint16_t cap = - (pu16ArrayLen != nullptr) ? *pu16ArrayLen : 0; - std::uint16_t count = 0; - for (const auto& ent : entries) { - auto ri = std::make_unique(); - ri->conn = rt->conn; - ri->id = ent.id; - ri->tbl_id = rt->id; - ri->parent = rt; - ri->tag_name = ent.tag; - ri->bag_path = ent.bag_path; - ri->expression = ent.expression; - ri->is_unique = ent.is_unique; - ri->is_descending = ent.is_descending; - Handle gh = s.registry.register_object( - HandleKind::RemoteIndex, ri.get()); - if (count < cap) { - ahIndex[count] = to_ads_handle(gh); - } - ++count; - remote_indexes.emplace(gh, std::move(ri)); - if (cli_trace_on()) { - auto& trace = cli_trace_state(); - std::lock_guard trace_lk(trace.mu); - trace.indexes[{rt->conn, ent.id}] = rt->alias.empty() ? rt->name : rt->alias; - } - // Dedup by tag: production-CDX auto-open and a later explicit - // AdsOpenIndex on the same bag must not register the order - // twice, or AdsGetNumIndexes / by-order resolution skew. - bool known = false; - for (auto& kv : rt->index_by_tag) { - if (kv.first == ent.tag) { known = true; break; } - } - if (!known) { - rt->index_by_tag.emplace_back(ent.tag, ent.id); - rt->index_handles.push_back(static_cast(gh)); - } - if (rt->active_index_id == 0) rt->active_index_id = ent.id; - } - // Store the production bag path on the parent table so - // AdsGetIndexFilename / OrdBagName can serve without a wire - // round-trip even before any explicit AdsOpenIndex call. - if (!entries.empty() && !entries[0].bag_path.empty() && - rt->prod_bag_path.empty()) { - rt->prod_bag_path = entries[0].bag_path; - } - // Server-canonical bag of this open (seeds the CloseAll park - // match — the bag actually bound, not a reopen spelling). - if (!entries.empty() && !entries[0].bag_path.empty()) { - rt->last_open_bag = entries[0].bag_path; - } - if (pu16ArrayLen != nullptr) *pu16ArrayLen = count; - 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"); - } - // remote_only_access guard, index side: OrdListAdd with a legacy - // local .z01/.cdx path on a local-connection table lands here. - const int roa_mode = remote_only_access_mode(); - if (roa_mode == 1) { - return fail(openads::AE_ACCESS_DENIED, - "local index open blocked by OPENADS_REMOTE_ONLY_ACCESS"); - } - if (roa_mode == 2) { - openads::util::write_local_access_audit( - "OPENIDX", openads::abi::to_internal(pucName, 0)); - } - auto bag_name = normalize_index_path( - openads::abi::to_internal(pucName, 0)); - if (auto* owner = t->owner()) { - if (owner->legacy_paths() && !owner->data_dir().empty() && - openads::platform::is_client_absolute(bag_name)) { - auto type = openads::engine::TableType::Cdx; - bag_name = owner->resolve_table_file( - bag_name, type, /*for_create=*/false); - } - } - namespace fs = std::filesystem; - fs::path p(bag_name); - if (!p.is_absolute()) { - fs::path table_dir = fs::path(t->path()).parent_path(); - // ADS places index files next to the table; when the caller uses a - // subdirectory-qualified path (e.g. "sub/table.adx") and the table's - // parent is already "sub/", avoid double-prefix by falling back to - // basename. Example: table opened as "sub/t.adt" makes table_dir = - // ".../sub"; if the caller also passes "sub/t.adx" the naive join - // ".../sub/sub/t.adx" does not exist, so retry with just the - // filename ".../sub/t.adx". A single-component relative path - // (p.filename() == p) takes the same first branch and is unaffected. - fs::path candidate = table_dir / p; - fs::path by_name = table_dir / p.filename(); - // When the caller keeps indexes in a *different* folder than the - // table (e.g. table in Data/, index in Indexes/), the path is - // relative to the connection data directory, not the table dir. - // Also try resolving against the connection root. - fs::path conn_candidate; - if (auto* conn = t->owner()) { - conn_candidate = fs::path(conn->data_dir()) / p; - } - auto resolve_ci = [](const fs::path& cand) -> fs::path { - if (fs::exists(cand)) return cand; - std::string ci = openads::platform::resolve_case_insensitive( - cand.string()); - if (ci != cand.string() && fs::exists(ci)) return fs::path(ci); - return cand; - }; - if (fs::exists(candidate)) { - p = candidate; - } else if (fs::exists(by_name)) { - p = by_name; - } else if (!conn_candidate.empty() && fs::exists(conn_candidate)) { - // Index lives in a different folder; resolve against connection - // data directory so "MyFolder/MyTable.Z01" finds - // /MyFolder/MyTable.Z01 instead of - // /MyFolder/MyTable.Z01. - p = conn_candidate; - } else { - fs::path ci = resolve_ci(by_name); - if (fs::exists(ci)) { - p = ci; - } else if (!conn_candidate.empty() && - (ci = resolve_ci(conn_candidate), - fs::exists(ci))) { - p = ci; - } else { - ci = resolve_ci(candidate); - p = fs::exists(ci) ? ci : candidate; - } - } - } - if (!p.has_extension()) { - p.replace_extension(".cdx"); - } - if (!fs::exists(p)) { - std::string ci = openads::platform::resolve_case_insensitive( - p.string()); - if (ci != p.string() && fs::exists(ci)) p = fs::path(ci); - } - auto path = p.string(); - - // Same per-bag mutex AdsCreateIndex61 holds around create-or-attach. - // Taken BEFORE index_bindings_mu (leaf create lock first) so OpenIndex - // waits out a racing INDEX ON instead of reading a half-written header - // (5103/6106) or a sharing-violation 5000. B_BIG N=700 logged - // OpenIndex: TestIndex.cdx / 5103 during concurrent tag creates. - // Hold the create mutex ONLY for the exists check. Opening every tag - // under it serialised 7000 B_BIG OpenIndex calls and starved INDEX ON. - { - std::lock_guard bag_lk(create_path_mu_for(path)); - std::error_code ec; - if (!fs::exists(p, ec)) { - return fail(openads::AE_NO_FILE_FOUND, path.c_str()); - } - } - // Storm fix (700-session B_BIG, OPI run22): the old code held - // index_bindings_mu across list_tags + every open_named + heal -- - // 0.1-1.5 s of file I/O per call with 700 concurrent OpenIndex - // calls. Every metadata reader (AdsGetIndexName/Expr/Filename, - // AdsIsIndexUnique/Descending -> iindex_for_handle) queues behind - // ALL of them: measured 12.3 ms avg for a pure in-memory lookup - // loop (ph3 = 15.9 s of the server's time). The mutex protects the - // two process-global maps and nothing else; take it only around - // the map snapshot / erase / insert phases below. - std::unordered_map old_handles; - ADSHANDLE active_h = 0; - bool had_active = false; - { - std::lock_guard _oi_lk(index_bindings_mu()); - auto& m = index_bindings(); - auto& act = active_binding_for(); - - // Refresh: drop any prior bindings for this Table that came from - // the same file path. If the active binding was among them, also - // surrender Table::order_; the caller's reopen will repopulate it. - // RCB 01/08/2026: keep the old tag→handle mapping and REUSE those - // handles below -- remote clients (openads_serverd's wire index_h_) - // hold the numeric handles across a bag reopen, and erasing them - // made the next SetOrder fail with 5000 (stale binding). - bool active_dropped = false; - auto act_it = act.find(t); - if (act_it != act.end()) active_h = act_it->second; - for (auto it = m.begin(); it != m.end(); ) { - if (it->second.table == t && same_index_path(it->second.path, path)) { - old_handles[it->second.tag_name] = it->first; - if (it->first == active_h) { - active_dropped = true; - } else if (it->second.parked) { - t->unregister_extra_index_view(it->second.parked.get()); - } - it = m.erase(it); - } else { - ++it; - } - } - if (active_dropped) { - act.erase(t); - t->clear_order(); - } - had_active = act.find(t) != act.end(); - } - bool table_has_active = had_active; - - // Enumerate tags. CDX/ADI expose list_tags; NTX has only its single - // tag, which open() reports via name(). - std::vector tags; - bool is_adi = path_ends_with_ci(path, ".adi"); - bool is_cdx = path_ends_with_ci(path, ".cdx"); - if (!is_cdx && !is_adi && - !path_ends_with_ci(path, ".ntx")) { - // Custom extension (".Z01"): the suffix says nothing about the - // on-disk format -- a compound create writes a CDX bag under any - // name, and DBFCDX reads it back by content. Sniff the header so - // reopening takes the CDX path instead of misreading it as NTX. - is_cdx = file_has_cdx_signature(path); - } - // Open an ADT table's .adi through the CdxIndex engine when the env opt-in - // is set OR the bag on disk is already CDX-format (reroute-written). The - // format check makes OPEN robust to a missing env flag; it mirrors the - // create-side routing in AdsCreateIndex61. - const bool is_adt_tbl = - (dynamic_cast(t->driver()) != nullptr); - const bool adt_to_cdx = is_adi && is_adt_tbl && - (adt_cdx_index_enabled() || adi_bag_is_cdx_format(path)); - if (is_cdx || adt_to_cdx) { - auto r = openads::drivers::cdx::CdxIndex::list_tags(path); - if (!r) return fail(r.error()); - tags = std::move(r).value(); - } else if (is_adi) { - // AdiIndex::list_tags sorts by per-tag header page (creation order), - // not raw directory-slot order, so it is correct whether this bag's - // directory is laid out append or prepend. No reversal needed here: - // ordinal 1 is already the first tag created, which is what - // DBSETORDER(n) / OrdSetFocus(n) callers expect. - auto r = openads::drivers::adi::AdiIndex::list_tags(path, t->path()); - if (!r) return fail(r.error()); - tags = std::move(r).value(); - } - if (tags.empty()) { - // NTX or empty CDX: open once via the legacy path. M9.14 lets - // multiple NTX files coexist on the same Table -- when the - // table already has an active order, the new NTX parks as an - // extra view instead of replacing it. - auto idx = make_index_for(path); - if (auto r = idx->open(path, openads::drivers::IndexOpenMode::Shared); !r) { - return fail(r.error()); - } - std::string tag_name = idx->name(); - if (path_ends_with_ci(path, ".ntx")) - mark_ntx_key_encoding(t, idx.get()); - ADSHANDLE h = old_handles.count(tag_name) - ? old_handles[tag_name] : next_index_handle(); - // Map mutations only (storm fix -- see the refresh snapshot). - { - std::lock_guard _oi_lk(index_bindings_mu()); - auto& m = index_bindings(); - auto& act = active_binding_for(); - if (!table_has_active) { - t->set_order(std::move(idx)); - m[h] = IndexBinding{t, tag_name, nullptr, path}; - act[t] = h; - } else { - openads::drivers::IIndex* raw = idx.get(); - m[h] = IndexBinding{t, tag_name, std::move(idx), path}; - t->register_extra_index_view(raw); - } - } - ahIndex[0] = h; - if (pu16ArrayLen != nullptr) *pu16ArrayLen = 1; - return ok(); - } - - // CDX / ADI with one or more tags: open each by name. The first tag's - // IIndex moves into Table::order_ (becomes default order) only - // when the table doesn't already have an active order; the rest - // (and the first tag in the additive case) park as extra views. - // - // ACE semantics: opening a bag opens EVERY tag -- the handle array - // only limits how many tag handles are RETURNED to the caller, never - // how many tags are bound and maintained. Binding just the first tag - // when the caller passes a NULL/short array left the remaining tags - // stale on every write; a Harbour DBFCDX peer opening the same bag - // then hit corrupt trees ("Corruption detected" / GPF on append -- - // Pritpal Bedi's record-151 crash). - UNSIGNED16 cap = (pu16ArrayLen != nullptr) ? *pu16ArrayLen : 0; - UNSIGNED16 count = 0; - std::vector opened_views; - struct PendingBind { - ADSHANDLE h; - std::string name; - openads::drivers::IIndex* raw; - }; - std::vector to_bind; - for (const auto& name : tags) { - std::unique_ptr sub; - // A rerouted bag was listed through CdxIndex above, so it must be - // opened through CdxIndex too -- handing a CDX-format .adi to the - // native AdiIndex reader fails and drops every tag on the floor. - if (is_adi && !adt_to_cdx) { - auto idx = std::make_unique(); - if (auto r = idx->open_named(path, - openads::drivers::IndexOpenMode::Shared, - name, t->path()); !r) { - return fail(r.error()); - } - sub = std::move(idx); - } else { - auto idx = std::make_unique(); - if (auto r = idx->open_named(path, - openads::drivers::IndexOpenMode::Shared, - name); !r) { - return fail(r.error()); - } - mark_cdx_key_encoding(t, idx.get()); - apply_cdx_oem_collation(t, idx.get()); - sub = std::move(idx); - } - ADSHANDLE h = old_handles.count(name) - ? old_handles[name] : next_index_handle(); - to_bind.push_back({h, name, sub.release()}); - } - // Map mutations only -- opens above ran unlocked (storm fix, see the - // comment on the refresh snapshot). Table mutation (set_order / - // register_extra_index_view) is per-connection state, safe here too: - // this Table belongs to this session alone. - { - std::lock_guard _oi_lk(index_bindings_mu()); - auto& m = index_bindings(); - auto& act = active_binding_for(); - for (auto& pb : to_bind) { - std::unique_ptr sub(pb.raw); - if (!table_has_active) { - t->set_order(std::move(sub)); - m[pb.h] = IndexBinding{t, pb.name, nullptr, path}; - act[t] = pb.h; - table_has_active = true; - } else { - m[pb.h] = IndexBinding{t, pb.name, std::move(sub), path}; - t->register_extra_index_view(pb.raw); - } - opened_views.push_back(pb.raw); - if (count < cap) ahIndex[count] = pb.h; - ++count; - } - } - if (pu16ArrayLen != nullptr) *pu16ArrayLen = count; - heal_stale_bag_on_open(t, opened_views); - return ok(); -} - -namespace { -// AdsDeleteIndex (OrdDestroy) rewrites the bag and must detach the tag -// even when it belongs to the STRUCTURAL bag -- AdsCloseIndex refuses -// to close structural tags on purpose (xBase production-index -// convention). This scoped guard lets the delete path reuse -// AdsCloseIndex without that refusal. -struct ForceIndexClose { - ForceIndexClose() { flag() = true; } - ~ForceIndexClose() { flag() = false; } - static bool& flag() { static thread_local bool f = false; return f; } -}; -} // namespace - -UNSIGNED32 ENTRYPOINT AdsCloseIndex(ADSHANDLE hIndex) { - arc2_trace("AdsCloseIndex"); -#if defined(OPENADS_WITH_SQLITE) - if (auto* si = get_sqlite_index(hIndex)) { - (void)si; - auto& s = state(); - std::lock_guard lk(s.mu); - sqlite_indexes_map().erase(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_FIREBIRD) - if (auto* si = get_firebird_index(hIndex)) { - (void)si; - auto& s = state(); - std::lock_guard lk(s.mu); - firebird_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); - if (!r) return fail(r.error()); - return ok(); - } - auto& m = index_bindings(); - auto& act = active_binding_for(); - // Storm fix: mutator must hold index_bindings_mu (binds are unlocked now). - std::lock_guard _cl_lk(index_bindings_mu()); - auto it = m.find(hIndex); - if (it == m.end()) return fail(openads::AE_INTERNAL_ERROR, "unknown index"); - // xBase production-index convention: a tag of the STRUCTURAL bag - // (the one named after the table) cannot be merely closed while the - // table is open -- DBFCDX leaves it attached on OrdBagClear - // (dballcmp: DBFCDX OrdCount() unchanged). OrdDestroy is the only - // way to drop a structural tag, and that goes through - // AdsDeleteIndex which rewrites the bag first. - if (it->second.table != nullptr && !ForceIndexClose::flag()) { - namespace fs = std::filesystem; - auto stem_ci = [](const std::string& p) { - std::string s = fs::path(p).stem().string(); - for (auto& c : s) - c = static_cast(std::tolower( - static_cast(c))); - return s; - }; - if (stem_ci(it->second.path) == - stem_ci(it->second.table->path())) { - if (it->second.parked) (void)it->second.parked->flush(); - return ok(); - } - } - Table* t = it->second.table; - if (t != nullptr) { - auto act_it = act.find(t); - if (act_it != act.end() && act_it->second == hIndex) { - t->clear_order(); - act.erase(act_it); - } else if (it->second.parked) { - t->unregister_extra_index_view(it->second.parked.get()); - (void)it->second.parked->flush(); - } - } - m.erase(it); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsCloseAllIndexes(ADSHANDLE hTable) { - arc2_trace("AdsCloseAllIndexes"); - if (auto* rt = get_remote_table(hTable)) { - // Deferred: a CloseTable is expected to absorb this (the server - // close purges the table's index bindings). If any other - // index-observing wire op intervenes, remote_flush_pending - // emits it ahead of that op. The client cache drops NOW so - // reads in the window answer post-close semantics. - // Parked, not dropped: the live tag->id maps move to the - // parked snapshot (server bindings stay open — no frame), so - // a same-bag OpenIndex restores them with zero frames and any - // op that only needs live bindings adopts the park. Getters in - // the window still see empty maps (post-close answers), exactly - // as when the maps were cleared outright. Repeat clears (the - // RDD issues OrdListClear twice per rotation) must NOT - // overwrite a good snapshot with empty maps — snapshot only - // when something is live. - if (!rt->index_by_tag.empty()) { - const unsigned ntags = - static_cast(rt->index_by_tag.size()); - rt->parked_by_tag = std::move(rt->index_by_tag); - rt->parked_handles = std::move(rt->index_handles); - rt->parked_active = rt->active_index_id; - rt->parked_bag_stem = bag_stem_ci(rt->last_open_bag.empty() ? - rt->prod_bag_path : - rt->last_open_bag); - rt->index_by_tag.clear(); - rt->index_handles.clear(); - rt->active_index_id = 0; - rt->indexes_parked = true; - // Park the nav stamp with the bindings: if the same order - // comes back via an unpark with no intervening wire nav - // (seq-gated), the post-unpark GotoTop dedupes instead of - // paying a refresh frame for state the server never lost. - rt->parked_nav_which = rt->last_nav; - rt->parked_nav_order = rt->last_nav_order; - rt->parked_nav_row = rt->last_nav_row; - rt->parked_nav_seq = rt->last_nav_seq; - cli_trace_tbl(rt, "AdsCloseAllIndexes", "parked %u tags", ntags); - } else if (rt->indexes_parked) { - cli_trace_tbl(rt, "AdsCloseAllIndexes", "repeat clear, park kept"); - } else { - cli_trace_tbl(rt, "AdsCloseAllIndexes", "nothing open"); - } - // Order belief changed with no frame: expire the nav stamp so a - // subsequent GotoTop cannot dedupe against the old binding. - rt->last_nav = 0; - rt->pair_valid = false; - rt->anchor_ok = false; - rt->close_all_indexes_pending = true; - return ok(); - } - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - auto& m = index_bindings(); - auto& act = active_binding_for(); - // Storm fix: mutator must hold index_bindings_mu (binds are unlocked now). - std::lock_guard _cal_lk(index_bindings_mu()); - // xBase production-index convention: tags of the STRUCTURAL bag - // (named after the table) stay attached through OrdListClear -- - // DBFCDX still reports OrdCount()=1 after it (dballcmp conformance - // harness). Only non-structural bindings are dropped; the order - // falls back to natural. - namespace fs = std::filesystem; - auto stem_ci = [](const std::string& p) { - std::string s = fs::path(p).stem().string(); - for (auto& c : s) - c = static_cast(std::tolower( - static_cast(c))); - return s; - }; - const std::string tbl_stem = stem_ci(t->path()); - for (auto it = m.begin(); it != m.end(); ) { - if (it->second.table == t && - stem_ci(it->second.path) != tbl_stem) { - if (it->second.parked) { - t->unregister_extra_index_view(it->second.parked.get()); - (void)it->second.parked->flush(); - } - it = m.erase(it); - } else { - ++it; - } - } - // Natural order after the clear: park a kept (structural) active - // binding into its slot; a dropped one just loses the order object. - auto act_it = act.find(t); - if (act_it != act.end()) { - if (m.find(act_it->second) != m.end()) { - park_active_order(t); - } else { - t->clear_order(); - act.erase(act_it); - } - } - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsCreateIndex61(ADSHANDLE hTable, - UNSIGNED8* pucFileName, - UNSIGNED8* pucIndexName, - UNSIGNED8* pucExpr, - UNSIGNED8* pucCondition, - UNSIGNED8* pucKeyFilter, - UNSIGNED32 ulOptions, - UNSIGNED16 usPageSize, - ADSHANDLE* phIndex) { - arc2_trace("AdsCreateIndex61"); - if (phIndex == nullptr || pucFileName == nullptr || - pucIndexName == nullptr || pucExpr == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, "null arg"); - } -#if defined(OPENADS_WITH_SQLITE) - if (auto* st = get_sqlite_table(hTable)) { - auto tag = openads::abi::to_internal(pucIndexName, 0); - 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 ddl = openads::sql_backend::build_create_index_ddl( - openads::sql_backend::SqlDdlDialect::Sqlite, - st->name, tag, px.kind, px.column); - if (!ddl) return fail(ddl.error()); - if (auto ex = st->conn->exec_sql(ddl.value()); !ex) return fail(ex.error()); - auto si = std::make_unique(); - si->parent = st; - si->column = px.column; - auto& s = state(); - std::lock_guard lk(s.mu); - Handle gh = s.registry.register_object(HandleKind::SqliteIndex, si.get()); - *phIndex = to_ads_handle(gh); - sqlite_indexes_map().emplace(gh, std::move(si)); - (void)pucFileName; - (void)pucCondition; - (void)pucKeyFilter; - (void)ulOptions; - (void)usPageSize; - return ok(); - } -#endif -#if defined(OPENADS_WITH_POSTGRESQL) - if (auto* st = get_postgres_table(hTable)) { - auto tag = openads::abi::to_internal(pucIndexName, 0); - 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 ddl = openads::sql_backend::build_create_index_ddl( - openads::sql_backend::SqlDdlDialect::Postgres, - st->name, tag, px.kind, px.column); - if (!ddl) return fail(ddl.error()); - if (auto ex = st->conn->exec_sql(ddl.value()); !ex) return fail(ex.error()); - auto si = std::make_unique(); - si->parent = st; - si->column = px.column; - auto& s = state(); - std::lock_guard lk(s.mu); - Handle gh = s.registry.register_object(HandleKind::PostgresIndex, si.get()); - *phIndex = to_ads_handle(gh); - postgres_indexes_map().emplace(gh, std::move(si)); - (void)pucFileName; - (void)pucCondition; - (void)pucKeyFilter; - (void)ulOptions; - (void)usPageSize; - return ok(); - } -#endif -#if defined(OPENADS_WITH_MARIADB) - if (auto* st = get_maria_table(hTable)) { - auto tag = openads::abi::to_internal(pucIndexName, 0); - 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 ddl = openads::sql_backend::build_create_index_ddl( - openads::sql_backend::SqlDdlDialect::Maria, - st->name, tag, px.kind, px.column); - if (!ddl) return fail(ddl.error()); - if (auto ex = st->conn->exec_sql(ddl.value()); !ex) return fail(ex.error()); - auto si = std::make_unique(); - si->parent = st; - si->column = px.column; - auto& s = state(); - std::lock_guard lk(s.mu); - Handle gh = s.registry.register_object(HandleKind::MariaIndex, si.get()); - *phIndex = to_ads_handle(gh); - maria_indexes_map().emplace(gh, std::move(si)); - (void)pucFileName; - (void)pucCondition; - (void)pucKeyFilter; - (void)ulOptions; - (void)usPageSize; - return ok(); - } -#endif -#if defined(OPENADS_WITH_MSSQL) - if (auto* st = get_mssql_table(hTable)) { - auto tag = openads::abi::to_internal(pucIndexName, 0); - 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 ddl = openads::sql_backend::build_create_index_ddl( - openads::sql_backend::SqlDdlDialect::Mssql, - st->name, tag, px.kind, px.column); - if (!ddl) return fail(ddl.error()); - if (auto ex = st->conn->exec_sql(ddl.value()); !ex) return fail(ex.error()); - auto si = std::make_unique(); - si->parent = st; - si->column = px.column; - auto& s = state(); - std::lock_guard lk(s.mu); - Handle gh = s.registry.register_object(HandleKind::MssqlIndex, si.get()); - *phIndex = to_ads_handle(gh); - mssql_indexes_map().emplace(gh, std::move(si)); - (void)pucFileName; - (void)pucCondition; - (void)pucKeyFilter; - (void)ulOptions; - (void)usPageSize; - return ok(); - } -#endif -#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 = to_ads_handle(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 defined(OPENADS_WITH_FIREBIRD) - if (auto* st = get_firebird_table(hTable)) { - auto tag = openads::abi::to_internal(pucIndexName, 0); - 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 ddl = openads::sql_backend::build_create_index_ddl( - openads::sql_backend::SqlDdlDialect::Firebird, - st->name, tag, px.kind, px.column); - if (!ddl) return fail(ddl.error()); - if (auto ex = st->conn->exec_sql(ddl.value()); !ex) return fail(ex.error()); - 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::FirebirdIndex, si.get()); - *phIndex = to_ads_handle(gh); - firebird_indexes_map().emplace(gh, std::move(si)); - (void)pucFileName; - (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); - path = normalize_index_path(std::move(path)); - std::string tag = openads::abi::to_internal(pucIndexName, 0); - std::string expr = openads::abi::to_internal(pucExpr, 0); - std::string cond = pucCondition - ? openads::abi::to_internal(pucCondition, 0) : std::string(); - std::string kf = pucKeyFilter - ? openads::abi::to_internal(pucKeyFilter, 0) : std::string(); - auto r = rt->conn->create_index(rt->id, path, tag, expr, - cond, kf, - ulOptions, usPageSize); - if (!r) return fail(r.error()); - // Creating a tag opens EVERY tag in the bag (ADS semantics -- the - // server-side create binds siblings as parked views), so refresh - // the client registry from the server: OrdCount() (AdsGetNumIndexes) - // and tag lookup (AdsGetIndexHandle) must reflect the whole bag - // immediately, not only after close+reopen (Pritpal Bedi). Wire - // round-trip BEFORE taking s.mu (see the deadlock note at the - // production auto-open in AdsOpenTable). - auto refr = rt->conn->open_index(rt->id, path); - auto& s = state(); - std::lock_guard lk(s.mu); - static std::unordered_map> remote_indexes; - auto ri = std::make_unique(); - ri->conn = rt->conn; - ri->id = r.value(); - ri->tbl_id = rt->id; - ri->parent = rt; - ri->tag_name = tag; - Handle gh = s.registry.register_object( - HandleKind::RemoteIndex, ri.get()); - *phIndex = to_ads_handle(gh); - remote_indexes.emplace(gh, std::move(ri)); - rt->index_by_tag.emplace_back(tag, r.value()); - rt->index_handles.push_back(static_cast(gh)); - if (refr) { - const auto& entries = refr.value(); - auto in_bag = [&](const std::string& tg) { - for (const auto& e : entries) - if (e.tag == tg) return true; - return false; - }; - // Keep other bags' entries, then re-add this bag in bag order, - // reusing the gh of tags the client already knows. - std::vector> keep_tags; - std::vector keep_handles; - for (std::size_t i = 0; i < rt->index_by_tag.size(); ++i) { - if (in_bag(rt->index_by_tag[i].first)) continue; - keep_tags.push_back(rt->index_by_tag[i]); - if (i < rt->index_handles.size()) - keep_handles.push_back(rt->index_handles[i]); - } - for (const auto& ent : entries) { - std::uint64_t egh = 0; - for (std::size_t i = 0; i < rt->index_by_tag.size(); ++i) { - if (rt->index_by_tag[i].first == ent.tag && - i < rt->index_handles.size()) { - egh = rt->index_handles[i]; - break; - } - } - if (egh == 0) { - auto nri = std::make_unique(); - nri->conn = rt->conn; - nri->id = ent.id; - nri->tbl_id = rt->id; - nri->parent = rt; - nri->tag_name = ent.tag; - nri->bag_path = ent.bag_path; - nri->expression = ent.expression; - nri->is_unique = ent.is_unique; - nri->is_descending = ent.is_descending; - Handle nh = s.registry.register_object( - HandleKind::RemoteIndex, nri.get()); - remote_indexes.emplace(nh, std::move(nri)); - egh = static_cast(to_ads_handle(nh)); - } - keep_tags.emplace_back(ent.tag, ent.id); - keep_handles.push_back(egh); - } - rt->index_by_tag = std::move(keep_tags); - rt->index_handles = std::move(keep_handles); - } - if (rt->active_index_id == 0) rt->active_index_id = r.value(); - // Structural change: the bag gained a tag, so a parked snapshot - // predates it — drop the park (the maps above were rebuilt fresh - // from the server). The pending flag stays: the next flush sends - // a real close, since parked validity no longer holds. - rt->indexes_parked = false; - rt->parked_by_tag.clear(); - rt->parked_handles.clear(); - return ok(); - } - Table* t = get_table(hTable); - if (!t) { - return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - } - // Settle any coalesced dirty record first: the build loop below reads - // rows straight from disk, so a pending buffer edit would be indexed - // from its stale on-disk image (and silently dropped by - // load_record_for_bulk_scan's discard). - if (auto cr = t->commit_dirty_record(); !cr) return fail(cr.error()); - // The native (DBF/CDX/NTX/ADI) create path mutates the process-global - // index_bindings() / active_binding_for() maps (and the per-table order - // list) with no synchronization, so two connections building indexes - // concurrently corrupt the unordered_map (heap corruption / AV). Serialize - // the whole native create under the registry mutex -- the SQL backends above - // already take it. (state().mu is recursive, so nested handle lookups are - // safe.) - // (no whole-path state().mu — see index_bindings_mu for map ops) - (void)pucKeyFilter; (void)usPageSize; - auto bag = normalize_index_path( - openads::abi::to_internal(pucFileName, 0)); - auto tag = openads::abi::to_internal(pucIndexName, 0); - auto expr = openads::abi::to_internal(pucExpr, 0); - // Never persist the FIELD->/ALIAS-> qualifier in the stored key - // expression: native DbfCdx strips it ("INDEX ON FIELD->name" saves - // key "name"), and Harbour errors on a bare field reference when the - // qualifier leaks into the bag header. The evaluator is qualifier- - // agnostic (strip_alias_qualifiers), so building/seeking is unchanged. - expr = openads::engine::strip_alias_qualifiers(expr); - std::string for_expr = pucCondition != nullptr - ? openads::abi::to_internal(pucCondition, 0) - : std::string{}; - - namespace fs = std::filesystem; - // An ADT table is not always named .adt (ExtFile='.DAT'), so ask the - // driver in use as well -- an extension-only test routes those tables to - // .cdx and their companion .adi is never created. - bool is_adt_table = path_ends_with_ci(t->path(), ".adt"); - if (!is_adt_table && - dynamic_cast(t->driver()) != nullptr) { - is_adt_table = true; - } - const char* default_ext = is_adt_table ? ".adi" : ".cdx"; - fs::path p(resolve_index_bag_for_table(t, bag, default_ext)); - // ACE AdsCreateIndex* option bits. include/openads/ace.h carries the - // SDK-standard values (ADS_UNIQUE 0x01, ADS_COMPOUND 0x02, ADS_CUSTOM - // 0x04, ADS_DESCENDING 0x08) -- but the two RDD clients we interop with - // put the "compound" and "descending" flags on SWAPPED bits, measured - // by instrumenting this function: - // - // client ascending tag descending tag - // X# ADSRDD 0x02 0x0A (compound 0x02 | descending 0x08) - // Harbour rddads 0x08 0x0A (compound 0x08 | descending 0x02) - // - // Each client always sets ITS compound bit on EVERY tag (cdx and ntx), - // and adds the OTHER bit of the {0x02,0x08} pair to mean descending. So a - // lone 0x02 OR a lone 0x08 is ascending (it is just that client's - // "compound" marker), and "descending" is the one case where BOTH bits - // are set (0x0A). Reading a lone 0x08 (or 0x02) as descending built every - // Harbour (resp. X#) order reversed -- AdsGotoTop landing on the last key, - // SKIP walking backward. The internal SQL CREATE INDEX path (below) emits - // 0x0A for a descending tag so it round-trips through this same decode. - const bool opt_compound_bit = (ulOptions & ADS_COMPOUND) != 0; // 0x02 - const bool opt_descending_bit = (ulOptions & ADS_DESCENDING) != 0; // 0x08 - bool unique = (ulOptions & ADS_UNIQUE) != 0; - bool descend = opt_compound_bit && opt_descending_bit; - - bool is_cdx = path_ends_with_ci(p.string(), ".cdx"); - bool is_adi = path_ends_with_ci(p.string(), ".adi"); - if (!is_cdx && !is_adi && - !path_ends_with_ci(p.string(), ".ntx") && - (opt_compound_bit || opt_descending_bit)) { - // Custom extension (Pritpal's ".Z01", 29/07/2026): the on-disk - // FORMAT follows the index kind, not the suffix. Both RDD clients - // set their compound marker on every tag, so a compound create - // writes a CDX-format bag regardless of the extension -- exactly - // what real ADS does and the only form Harbour's DBFCDX can read - // back (it parses every bag as CDX and reported our NTX-written - // ".Z01" as corrupted). An explicit ".ntx" suffix, or a create - // with no compound marker at all, still produces an NTX. - is_cdx = true; - } - - // Validate the key expression: a bare identifier that is not a column - // is a bug in the caller's PRG (typo / renamed field). Native - // Harbour/Clipper raises "Variable does not exist" at INDEX time; - // silently accepting it builds an all-blank, useless index and hides - // the mistake. Mirror evaluate_index_expr()'s bare-field detection - // (single alnum/underscore token) so computed expressions still pass. - { - std::string ident = expr; - while (!ident.empty() && - std::isspace(static_cast(ident.front()))) - ident.erase(ident.begin()); - while (!ident.empty() && - std::isspace(static_cast(ident.back()))) - ident.pop_back(); - const bool bare_ident = !ident.empty() && - std::all_of(ident.begin(), ident.end(), [](char c) { - return std::isalnum(static_cast(c)) || c == '_'; - }) && - !std::isdigit(static_cast(ident.front())); - if (bare_ident && t->field_index(ident) < 0) { - return fail(openads::util::Error{ - openads::AE_COLUMN_NOT_FOUND, 0, - "index expression references unknown column", ident}); - } - } - - // CDX numeric keys: FoxPro/Harbour store a bare numeric field as an - // 8-byte order-preserving binary key (keySize 8), not text. Detect a - // bare numeric field so the index is created with keySize=8 and marked - // FoxNumeric; the engine then emits the binary key at write time. - // STR()/character expressions stay text. (Date deferred -- DTOS-style - // text indexes already interop.) - bool cdx_numeric_key = false; - if (is_cdx) { - const std::string bare = openads::engine::strip_alias_qualifiers(expr); - std::int32_t fi = t->field_index(bare); - if (fi >= 0) { - using FT = openads::drivers::DbfFieldType; - FT ft = t->field_descriptor(static_cast(fi)).type; - cdx_numeric_key = - ft == FT::Numeric || ft == FT::Float || ft == FT::Integer || - ft == FT::Double || ft == FT::Currency || ft == FT::AdtMoney; - } - } - - // Support computed numeric expressions such as `Val(charfield)` (common - // in rddads apps that store "numbers" in char columns for legacy reasons). - // If the expression produces a number on a sample record, treat it as a - // CDX numeric key (8-byte Fox binary) so the index is built and searched - // with the correct encoding. Fixes #130. - if (is_cdx && !cdx_numeric_key && t->record_count() > 0) { - // Use a direct record read + bulk load so we don't disturb any - // cursor state the caller may have. - if (auto raw = t->driver()->read_record_raw(1); raw) { - t->load_record_for_bulk_scan(std::move(raw.value()), 1); - double dv = 0.0; - bool isnum = openads::engine::evaluate_index_expr_number(*t, expr, dv); - if (isnum) { - cdx_numeric_key = true; - } - } - } - - // Targeted for the common rddads pattern INDEX ON Val(charfield) (#130). - // The record-1 probe above cannot run on an empty table, so fall back to a - // syntactic test -- but only when the WHOLE expression is that call. - // - // Testing for "VAL(" anywhere misfires on a CHARACTER key that merely - // contains one: `cDocumeTra+STR(VAL(cConIntTra),3,0)` is a concatenation - // producing text, and marking it numeric makes the build loop demand a - // number from it and fail with ADSCDX/5000 "failed to evaluate numeric - // index expression" partway through the table. - if (is_cdx && !cdx_numeric_key) { - std::string u; - u.reserve(expr.size()); - for (char c : expr) - u.push_back(static_cast(std::toupper( - static_cast(c)))); - while (!u.empty() && std::isspace(static_cast(u.back()))) - u.pop_back(); - std::size_t b = 0; - while (b < u.size() && std::isspace(static_cast(u[b]))) - ++b; - u.erase(0, b); - if (u.rfind("VAL(", 0) == 0 && !u.empty() && u.back() == ')') { - // The ')' closing the leading VAL( must be the last character; - // otherwise the call is just one component of a bigger expression. - int depth = 0; - std::size_t close = std::string::npos; - for (std::size_t i = 3; i < u.size(); ++i) { // u[3] == '(' - if (u[i] == '(') { - ++depth; - } else if (u[i] == ')') { - if (--depth == 0) { close = i; break; } - } - } - if (close == u.size() - 1) cdx_numeric_key = true; - } - } - - // NTX numeric keys: a native xBase NTX stores a numeric field's key as - // fixed-width, right-justified ASCII = STR(value, fieldLen, fieldDec), - // and records the decimal count in the index header. The key stays - // TEXT (unlike the compound-index 8-byte binary form), but the width - // and decimals MUST come from the field descriptor, not from a probed - // key length (which is wrong/empty on an empty table). Detect a bare - // ASCII-stored numeric field (N / F) so the index width is pinned to - // the field length and the decimals land in the header. VFP binary - // numerics (I/B/Y) are not ASCII on disk -- left for a follow-up. - bool ntx_numeric_key = false; - std::uint16_t ntx_num_width = 0; - std::uint16_t ntx_num_dec = 0; - if (!is_cdx && !is_adi) { - const std::string bare = openads::engine::strip_alias_qualifiers(expr); - std::int32_t fi = t->field_index(bare); - if (fi >= 0) { - using FT = openads::drivers::DbfFieldType; - const auto& fd = - t->field_descriptor(static_cast(fi)); - if ((fd.type == FT::Numeric || fd.type == FT::Float) && - fd.length > 0) { - ntx_numeric_key = true; - ntx_num_width = fd.length; - ntx_num_dec = static_cast(fd.decimals); - } - } - } - - // Determine the on-disk key length. Numeric CDX keys use the 8-byte - // binary width; numeric NTX keys use the field's own width so the key - // matches the native reader. - std::uint16_t klen = cdx_numeric_key ? 8 - : ntx_numeric_key ? ntx_num_width - : 0; - if (!cdx_numeric_key && !ntx_numeric_key) { - // A character key is fixed-width on disk. For a BARE character field - // the width is the declared field length: deriving it from the - // *trimmed* value of the first record truncates every later key that - // shares a prefix beyond that width (a short first row collapses - // longer rows onto the same key), corrupting ordering and seeks in - // both the index itself and native FoxPro/Clipper readers. - const std::string bare = openads::engine::strip_alias_qualifiers(expr); - std::int32_t fi = t->field_index(bare); - if (fi >= 0) { - klen = t->field_descriptor(static_cast(fi)).length; - } else if (t->record_count() > 0) { - // Composite expression: probe the first record's key. - // key_len=0 makes evaluate_index_expr return the un-padded - // result, reading fields at their declared width. Do NOT - // rtrim: a fixed-width field's trailing blanks are part of - // the key. The old code rtrimmed here, pinning key_size to - // the first record's *content* length, so any longer key - // (e.g. a longer UPPER(name)) got truncated, its - // distinguishing tail dropped; the bulk-build sort then - // scrambled recnos and the browse showed rows out of order. - if (auto g = t->goto_record(1); g) { - if (auto k = openads::engine::evaluate_index_expr(*t, expr, 0)) { - std::size_t natlen = k.value().size(); - if (natlen > 0) - klen = static_cast( - std::min(natlen, 254)); - } - } - } - if (klen == 0) klen = 32; // empty table, composite expression - } - - std::unique_ptr idx_owner; - bool exists = false; - // Serialise the create-or-attach decision per bag path: two sessions - // racing INDEX ON ... TAG x TO both saw !exists and - // both ran CdxIndex::create (CREATE_ALWAYS), each truncating the bag - // the other had just written -- torn struct-tag leaf, dangling - // sub-tag pages (field stress: key count 0 on a 1200-row table). - // Under the lock the loser re-checks and takes the add_tag / - // overwrite path instead. Released before the build loop below. - std::unique_lock bag_create_lk( - create_path_mu_for(p.string())); - { - std::error_code ec; - exists = (is_cdx || is_adi) && fs::exists(p, ec); - } - - // ADT table + .adi bag -> route to the CdxIndex engine when the env opt-in - // is set OR the bag already exists in CDX format (so adding a tag to a - // reroute-built bag stays CDX even with the flag absent -- never mix formats - // in one bag). A fresh bag (the ERP erases the .adi before reindex) has no - // file, so the env flag decides the format to write. - const bool adt_to_cdx = is_adi && is_adt_table && - (adt_cdx_index_enabled() || - (std::filesystem::exists(p) && adi_bag_is_cdx_format(p.string()))); - - if (is_adi && is_adt_table && !adt_to_cdx) { - // ADT tables use a single .adi bag. Prefer bare field, but allow - // compound expressions (Russoft INDEX ON fld1+fld2 for .ADI) by - // falling back to first field for tag metadata. Keys are built from - // the full evaluated expr at population time. - const std::string bare = openads::engine::strip_alias_qualifiers(expr); - std::int32_t fidx = t->field_index(bare); - const bool is_compound = (fidx < 0); // computed / multi-field expression - if (fidx < 0) { - fidx = 0; // fallback for compound expr - } - if (fidx + 1 > 255) { - return fail(openads::AE_INTERNAL_ERROR, - "ADI index does not support field numbers greater than 255"); - } - const auto& fd = t->field_descriptor(static_cast(fidx)); - const std::uint16_t adt_t = static_cast( - static_cast(fd.raw_type)); - const bool is_char_field = - adt_t == openads::drivers::adi::ADT_TYPE_CHAR || - adt_t == openads::drivers::adi::ADT_TYPE_CICHAR; - // The v2 opaque-key leaf (full key stored, memcmp-ordered) is correct for - // char fields and ANY computed/compound expression -- exactly what the ERP - // uses (STR()/DTOS()/concat → character keys). A BARE numeric/date field - // keeps the legacy numeric ADI leaf (sign-flipped float keys) so the - // existing packed-key seeks keep working. - // v2 is OPT-IN. With the switch off this call behaves exactly as it did - // before: a bare field tag takes the legacy layout, and an expression - // the legacy format cannot represent is rejected the way it was - // rejected before (there is nowhere in a legacy tag header to keep the - // expression, so accepting it silently would produce a bag whose tag - // says one thing and whose keys say another). - const bool v2_on = adi_v2_enabled(); - if (!v2_on && is_compound) { - return fail(openads::AE_COLUMN_NOT_FOUND, - "ADI: a compound / computed index expression needs the " - "v2 tag layout (set OPENADS_ADI_V2=1)"); - } - const bool use_v2 = v2_on && (is_char_field || is_compound); - openads::drivers::adi::AdiIndex::CreateParams cp{}; - cp.field_num = static_cast(fidx + 1); - cp.field_name = fd.name; - cp.adt_type = adt_t; - cp.fld_length = fd.length; - cp.record_offset = fd.record_offset; - cp.adt_hdr_len = t->driver()->header_length(); - cp.adt_rec_len = t->driver()->record_length(); - cp.unique = unique; - cp.adt_path = t->path(); // important for .DAT + ADS_ADT case (not .adt) - if (use_v2) { - // identity by NAME + persisted expression/FOR + full opaque key - // (klen stays the full evaluated-expression length; the build loop - // below evaluates the whole expression). - cp.tag_name = tag; - cp.key_expr = expr; - cp.for_expr = for_expr; - cp.key_len = static_cast(klen); - cp.descending = descend; - } else if (!is_char_field) { - // Bare numeric/date → legacy numeric ADI leaf (8-byte packed keys). - // Bare character keeps klen = field length for the legacy char - // leaf -- do NOT force 8 here or every char key is truncated and - // partial seeks miss (OPENADS_ADI_V2 off is the default). - klen = 8; - } - - if (exists) { - // v2 identity is the TAG name (list_tags returns tag names now); - // legacy callers without a tag_name fall back to the field name. - const std::string ident = - cp.tag_name.empty() ? fd.name : cp.tag_name; - auto tags = openads::drivers::adi::AdiIndex::list_tags( - p.string(), t->path()); - bool have_tag = false; - if (tags) { - for (const auto& tn : tags.value()) { - if (tn.size() == ident.size()) { - bool eq = true; - for (std::size_t i = 0; i < tn.size(); ++i) { - if (std::tolower(static_cast(tn[i])) != - std::tolower(static_cast( - ident[i]))) { - eq = false; - break; - } - } - if (eq) { have_tag = true; break; } - } - } - } - if (have_tag) { - openads::drivers::adi::AdiIndex existing; - auto reopen = existing.open_named( - p.string(), openads::drivers::IndexOpenMode::Shared, - ident, t->path()); - if (!reopen) return fail(reopen.error()); - if (auto cl = existing.clear_data(); !cl) return fail(cl.error()); - idx_owner = std::make_unique< - openads::drivers::adi::AdiIndex>(std::move(existing)); - } else { - auto added = openads::drivers::adi::AdiIndex::add_tag( - p.string(), cp); - if (!added) return fail(added.error()); - idx_owner = std::make_unique< - openads::drivers::adi::AdiIndex>(std::move(added).value()); - } - } else { - auto created = openads::drivers::adi::AdiIndex::create( - p.string(), cp); - if (!created) return fail(created.error()); - idx_owner = std::make_unique( - std::move(created).value()); - } - } else if ((is_cdx || adt_to_cdx) && exists) { - // Harbour rddads / Clipper semantics: re-creating an - // existing tag is a silent overwrite, not an error. If the - // tag already exists, open it and clear its B+tree so the - // caller's per-record insert loop rebuilds it fresh. - auto added = openads::drivers::cdx::CdxIndex::add_tag( - p.string(), tag, expr, klen, unique, descend, for_expr); - if (!added && added.error().code == 5044) { - openads::drivers::cdx::CdxIndex existing; - auto reopen = existing.open_named(p.string(), - openads::drivers::IndexOpenMode::Shared, tag); - if (!reopen) return fail(reopen.error()); - // Wipe the old B+tree before the per-record insert - // loop rebuilds it; otherwise duplicates from the - // prior CREATE INDEX accumulate and break SKIP walks. - if (auto cl = existing.clear_data(); !cl) - return fail(cl.error()); - // CREATE INDEX overwrite must also pick up the new - // UNIQUE / DESCEND options + the freshly-probed key - // size; the sub-header was loaded from disk with the - // prior options. - if (auto so = existing.set_options(unique, descend, klen); !so) - return fail(so.error()); - // Re-creating an existing tag overwrites its stored KEY - // expression and FOR clause too -- the whole point of - // "INDEX ON TAG ORDERX" without first deleting - // the bag. Without this the header kept the old column, so - // AdsGetIndexExpr lied and the next dbAppend synced the wrong - // column into the tag (silent disorder). Native DBFCDX deletes - // and recreates the tag; rewriting the pool here matches that. - if (auto se = existing.set_expression(expr, for_expr); !se) - return fail(se.error()); - idx_owner = std::make_unique( - std::move(existing)); - } else if (!added) { - return fail(added.error()); - } else { - idx_owner = std::make_unique( - std::move(added).value()); - } - } else if (is_cdx || adt_to_cdx) { - auto created = openads::drivers::cdx::CdxIndex::create( - p.string(), tag, expr, klen, unique, descend, for_expr); - if (!created) return fail(created.error()); - idx_owner = std::make_unique( - std::move(created).value()); - } else if (is_adi) { - return fail(openads::AE_INTERNAL_ERROR, - "ADI index bag requires an ADT table"); - } else { - auto created = openads::drivers::ntx::NtxIndex::create( - p.string(), tag, expr, klen, unique, descend); - if (!created) return fail(created.error()); - auto ntx_owner = std::make_unique( - std::move(created).value()); - // Pin the key geometry to the numeric field descriptor so the - // on-disk key is the native fixed-width STR(value,width,dec) form - // (and the header carries the decimal count) -- independent of any - // probed key length, which is absent on an empty table. - if (ntx_numeric_key) { - if (auto sf = ntx_owner->set_numeric_format( - ntx_num_width, ntx_num_dec); !sf) { - return fail(sf.error()); - } - } - idx_owner = std::move(ntx_owner); - } - // Create/attach decision is made; the bulk build below only reads the - // table and writes through the (write-locked) index instance. - bag_create_lk.unlock(); - // Mark a numeric CDX index FoxNumeric so every key-build path (this - // create loop, the engine's sync_all_indexes_, seek) emits the 8-byte - // binary key a native reader expects. - if (cdx_numeric_key && idx_owner) { - idx_owner->set_key_encoding(openads::drivers::KeyEncoding::FoxNumeric); - } - if (idx_owner) apply_cdx_oem_collation(t, idx_owner.get()); - auto rec_count = t->record_count(); - // Fast path: for CDX, collect the whole key stream and bulk-load the - // B+tree in one bottom-up pass (sort -> pack leaves -> branch levels) - // instead of record-by-record top-down insertion. Each page is encoded - // once rather than decoded+re-encoded on every key, ~10x faster on a - // full CREATE INDEX / REINDEX. NTX keeps the incremental path. - // CDX and the v2 ADI dense leaf both override IIndex::build_bulk, so the - // call dispatches to the right engine; NTX falls back to the per-record - // default and is left on the incremental path here. - const bool use_bulk = is_cdx || is_adi; - std::vector> bulk_keys; - if (use_bulk) bulk_keys.reserve(rec_count); - for (std::uint32_t r = 1; r <= rec_count; ++r) { - // Use direct driver read + bulk buffer load so the driver's - // read-ahead cache is effective for this sequential scan. - // goto_record(r) would invalidate on every iteration. - auto raw = t->driver()->read_record_raw(r); - if (!raw) return fail(raw.error()); - t->load_record_for_bulk_scan(std::move(raw.value()), r); - - // DBFCDX inserts deleted rows too -- the index is a logical - // mirror of the table, not a "live-only" view. SET DELETED - // hides them at navigation time. Only the FOR clause filters - // entries out at build time. - if (!for_expr.empty()) { - if (!openads::engine::evaluate_index_expr_truthy(*t, for_expr)) - continue; - } - std::string kbytes; - if (cdx_numeric_key) { - double dv = 0.0; - if (!openads::engine::evaluate_index_expr_number(*t, expr, dv)) - return fail(openads::AE_INTERNAL_ERROR, - "failed to evaluate numeric index expression"); - kbytes = openads::engine::fox_numeric_key(dv); - } else if (ntx_numeric_key) { - double dv = 0.0; - if (!openads::engine::evaluate_index_expr_number(*t, expr, dv)) - return fail(openads::AE_INTERNAL_ERROR, - "failed to evaluate numeric index expression"); - kbytes = openads::engine::ntx_numeric_key(dv, ntx_num_width, - ntx_num_dec); - } else { - auto k = openads::engine::evaluate_index_expr(*t, expr, klen); - if (!k) return fail(k.error()); - kbytes = std::move(k).value(); - } - if (use_bulk) { - bulk_keys.emplace_back(std::move(kbytes), r); - } else if (auto ins = idx_owner->insert(r, kbytes); !ins) { - return fail(ins.error()); - } - } - if (use_bulk) { - if (auto b = idx_owner->build_bulk(std::move(bulk_keys)); !b) - return fail(b.error()); - } - if (auto fl = idx_owner->flush(); !fl) return fail(fl.error()); - // A bag named after the table is the Harbour DBFCDX production - // index -- set DBF header byte 28 so Harbour auto-opens it on USE. - if (is_cdx) { - stamp_production_index_flag(t, p.string()); - } - - // Map mutations (sibling park + tag registration) under index_bindings_mu. - // Bulk build above intentionally ran unlocked for multi-thread INDEX ON. - std::lock_guard _bind_lk(index_bindings_mu()); - auto& m = index_bindings(); - auto& act = active_binding_for(); - // Sibling tag refresh: a fresh CREATE INDEX implicitly resyncs - // every tag in the bag (rddads' ORDLSTCLEAR + INDEX cycle drops - // every other tag's binding so sync_all_indexes_ skipped them - // on subsequent dbappend / replace, leaving stale or empty - // B+trees on disk). Re-build each sibling tag's B+tree from the - // current DBF, then register it as a parked extra binding so - // later dbappend / dbreplace cycles update it via - // sync_all_indexes_ instead of leaving it stale again. - auto& m_pre = index_bindings(); - auto& act_pre = active_binding_for(); - if (is_cdx) { - auto sibs = openads::drivers::cdx::CdxIndex::list_tags(p.string()); - if (sibs) { - // Collect all siblings that need a full DBF rebuild (lost binding + empty tree). - // Batch their key collection with ONE scan using the multi-expression prototype. - struct Pending { - std::string name; - std::string expr; - std::string for_expr; - std::uint16_t klen; - bool fox; - std::unique_ptr sub; - }; - std::vector pending; - for (const auto& sib : sibs.value()) { - if (sib == tag) continue; - bool already_bound = false; - for (auto& [h0, b0] : m_pre) { - if (b0.table == t && b0.tag_name == sib) { already_bound = true; break; } - } - if (already_bound) continue; - - auto sub = std::make_unique(); - if (auto r0 = sub->open_named(p.string(), openads::drivers::IndexOpenMode::Shared, sib); !r0) continue; - mark_cdx_key_encoding(t, sub.get()); - apply_cdx_oem_collation(t, sub.get()); - - bool has_data = false; - if (auto sf = sub->seek_first(); sf) has_data = sf.value().positioned; - sub->invalidate_cursor(); - - if (!has_data) { - if (auto cl = sub->clear_data(); !cl) continue; - Pending pend; - pend.name = sib; - pend.expr = sub->expression(); - pend.for_expr = sub->condition(); - pend.klen = sub->key_length(); - pend.fox = (sub->key_encoding() == openads::drivers::KeyEncoding::FoxNumeric); - pend.sub = std::move(sub); - pending.push_back(std::move(pend)); - } else { - // Park existing - ADSHANDLE sh = next_index_handle(); - m_pre[sh] = IndexBinding{t, sib, std::move(sub), p.string()}; - t->register_extra_index_view(m_pre[sh].parked.get()); - (void)act_pre; - } - } - - if (!pending.empty()) { - // Batch non-fox string expressions with single scan - std::vector bexprs, bfors; - std::vector bklens; - std::vector batch_idx; - for (size_t i=0; i>> multi_res; - if (!bexprs.empty()) { - if (auto mr = t->collect_keys_for_multiple_expressions(bexprs, bfors, bklens); mr) { - multi_res = std::move(mr.value()); - } - } - - for (size_t j=0; j> keys = (j < multi_res.size()) ? std::move(multi_res[j]) : std::vector>(); - (void)ps.sub->build_bulk(std::move(keys)); - (void)ps.sub->flush(); - - ADSHANDLE sh = next_index_handle(); - openads::drivers::IIndex* rawp = ps.sub.get(); - m_pre[sh] = IndexBinding{t, ps.name, std::move(ps.sub), p.string()}; - t->register_extra_index_view(rawp); - (void)act_pre; - } - - // Fox numeric fallbacks (do individually) - for (auto& ps : pending) { - if (ps.fox && ps.sub) { - std::vector> keys; - for (uint32_t r=1; r<=rec_count; ++r) { - auto raw = t->driver()->read_record_raw(r); - if (!raw) continue; - t->load_record_for_bulk_scan(std::move(raw.value()), r); - if (!ps.for_expr.empty() && !openads::engine::evaluate_index_expr_truthy(*t, ps.for_expr)) continue; - double dv=0; - if (openads::engine::evaluate_index_expr_number(*t, ps.expr, dv)) { - keys.emplace_back(openads::engine::fox_numeric_key(dv), r); - } - } - (void)ps.sub->build_bulk(std::move(keys)); - (void)ps.sub->flush(); - ADSHANDLE sh = next_index_handle(); - m_pre[sh] = IndexBinding{t, ps.name, std::move(ps.sub), p.string()}; - t->register_extra_index_view(m_pre[sh].parked.get()); - } - } - } - } - } - - // Drop ANY existing binding whose tag matches the one we're - // re-creating: a CREATE INDEX command on an existing tag is a - // silent overwrite (clear_data already wiped the on-disk - // B+tree) so the stale binding must vanish too -- otherwise - // ordinal lookups iterate over both the old and new bindings. - // RCB 09/02/2026 (B_BIG storm 700): remember the dropped handle and - // REUSE it for the new binding -- mirroring AdsOpenIndex's reopen - // refresh (which keeps old tag->handle mappings for exactly this - // reason). Minting a fresh handle left remote sessions' index_h_ - // pointing at the erased one; their next SetOrder failed with - // 5000 "index not bound to table" and poisoned every ordered nav. - ADSHANDLE reuse_h = 0; - for (auto it = m.begin(); it != m.end(); ) { - if (it->second.table == t && it->second.tag_name == tag) { - // If the stale entry was the active one, take the - // active IIndex back from the Table so the next - // set_order doesn't double-free. - if (act.count(t) && act[t] == it->first) { - (void)t->take_order(); - act.erase(t); - } else if (it->second.parked) { - t->unregister_extra_index_view( - it->second.parked.get()); - } - reuse_h = it->first; - it = m.erase(it); - } else { - ++it; - } - } - ADSHANDLE h = reuse_h != 0 ? reuse_h : next_index_handle(); - // Each new CREATE INDEX makes itself the active order - // (Clipper / Harbour convention). Park the previous active - // (if any) so it stays openable + survives ORDSETFOCUS(N). - auto prev = act.find(t); - if (prev != act.end()) { - auto pit = m.find(prev->second); - if (pit != m.end()) { - auto displaced = t->take_order(); - pit->second.parked = std::move(displaced); - t->register_extra_index_view(pit->second.parked.get()); - } - } - t->set_order(std::move(idx_owner)); - m[h] = IndexBinding{t, tag, nullptr, p.string()}; - act[t] = h; - // Clipper / Harbour: a fresh CREATE INDEX leaves the cursor - // positioned at the new order's TOP key. - (void)t->goto_top(); - *phIndex = h; - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsCreateIndex(ADSHANDLE hTable, UNSIGNED8* pucFile, - UNSIGNED8* pucTag, UNSIGNED8* pucExpr, - UNSIGNED8* pucCondition, UNSIGNED32 ulOptions, - UNSIGNED16 usKeyType, ADSHANDLE* phIndex) { - arc2_trace("AdsCreateIndex"); - if (phIndex == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, "null index out-param"); - } - // Legacy API: remote tables route through AdsCreateIndex61 (M12.16). - if (get_remote_table(hTable) != nullptr) { - return AdsCreateIndex61(hTable, pucFile, pucTag, pucExpr, pucCondition, - nullptr, ulOptions, - usKeyType ? usKeyType - : static_cast(ADS_DEFAULT), - phIndex); - } - Table* t = get_table(hTable); - if (!t) { - return fail(openads::AE_INTERNAL_ERROR, "unknown table or null out"); - } - // Settle any coalesced dirty record first (see AdsCreateIndex61). - if (auto cr = t->commit_dirty_record(); !cr) return fail(cr.error()); - auto file = normalize_index_path( - openads::abi::to_internal(pucFile, 0)); - auto tag = openads::abi::to_internal(pucTag, 0); - auto expr = openads::abi::to_internal(pucExpr, 0); - // Never persist the FIELD->/ALIAS-> qualifier in the stored key - // expression (mirrors AdsCreateIndex61 and native DbfCdx). - expr = openads::engine::strip_alias_qualifiers(expr); - // A FOR condition makes this a conditional index: only matching rows get - // a key entry (mirrors AdsCreateIndex61's build loop). Ignoring pucCondition - // built a full index over every row, so AdsGetKeyCount, OrdKeyNo, SKIP and - // EOF all reflected ALL records instead of the matching subset. - std::string for_expr = pucCondition != nullptr - ? openads::abi::to_internal(pucCondition, 0) - : std::string{}; - - // Resolve the field referenced by the expression to determine key length. - std::int32_t fidx = t->field_index(expr); - if (fidx < 0) { - return fail(openads::AE_COLUMN_NOT_FOUND, - "AdsCreateIndex: expression must be a bare field name"); - } - std::uint16_t klen = t->field_descriptor(static_cast(fidx)).length; - - // Path resolution: mirror AdsCreateIndex61 so the legacy wrapper handles - // empty bag names, relative paths, and missing extensions the same way. - file = resolve_index_bag_for_table(t, file, ".cdx"); - - std::unique_ptr idx; - if (path_ends_with_ci(file, ".cdx")) { - namespace fs2 = std::filesystem; - // Same per-path serialization as AdsCreateIndex61: the - // create-vs-add decision must be atomic per bag. - std::unique_lock bag_create_lk( - create_path_mu_for(file)); - const bool bag_exists = fs2::exists(fs2::path(file)); - if (!bag_exists) { - auto created = openads::drivers::cdx::CdxIndex::create( - file, tag, expr, klen, false, false, for_expr); - if (!created) return fail(created.error()); - idx = std::make_unique( - std::move(created).value()); - } else { - // The bag exists: ADD the tag (SAP ACE semantics) instead of - // truncating the bag -- the legacy entry point used to recreate - // the file unconditionally, silently dropping every previously - // created tag (only the last one survived). - auto added = openads::drivers::cdx::CdxIndex::add_tag( - file, tag, expr, klen, false, false, for_expr); - if (!added && added.error().code == 5044) { - // Tag already present: silent overwrite (Harbour rddads / - // Clipper convention) -- reopen, wipe, rebuild below. - openads::drivers::cdx::CdxIndex existing; - auto reopen = existing.open_named( - file, openads::drivers::IndexOpenMode::Shared, tag); - if (!reopen) return fail(reopen.error()); - if (auto cl = existing.clear_data(); !cl) - return fail(cl.error()); - if (auto so = existing.set_options(false, false, klen); !so) - return fail(so.error()); - if (auto se = existing.set_expression(expr, for_expr); !se) - return fail(se.error()); - idx = std::make_unique( - std::move(existing)); - } else { - if (!added) return fail(added.error()); - idx = std::make_unique( - std::move(added).value()); - } - } - } else { - auto created = openads::drivers::ntx::NtxIndex::create( - file, tag, expr, klen, false, false); - if (!created) return fail(created.error()); - idx = std::make_unique( - std::move(created).value()); - } - - // Populate from existing live records in primary order. Deleted - // records are skipped so AdsSeek over the new index never returns - // phantom recnos. For CDX, collect the key stream and bulk-load the - // B+tree bottom-up (one encode per page) instead of record-by-record - // insertion -- ~10x faster on a full build. - auto rec_count = t->record_count(); - openads::drivers::cdx::CdxIndex* cdx_bulk = - path_ends_with_ci(file, ".cdx") - ? static_cast(idx.get()) - : nullptr; - std::vector> bulk_keys; - if (cdx_bulk) bulk_keys.reserve(rec_count); - for (std::uint32_t r = 1; r <= rec_count; ++r) { - // Direct driver read (read-ahead friendly) -- legacy AdsCreateIndex path. - auto raw = t->driver()->read_record_raw(r); - if (!raw) return fail(raw.error()); - t->load_record_for_bulk_scan(std::move(raw.value()), r); - - if (t->is_deleted()) continue; - // FOR clause filters entries out at build time (DBFCDX semantics). - if (!for_expr.empty() && - !openads::engine::evaluate_index_expr_truthy(*t, for_expr)) - continue; - auto v = t->read_field(static_cast(fidx)); - if (!v) return fail(v.error()); - std::string padded = v.value().as_string; - if (padded.size() < klen) padded.append(klen - padded.size(), ' '); - if (padded.size() > klen) padded.resize(klen); - if (cdx_bulk) { - bulk_keys.emplace_back(std::move(padded), r); - } else if (auto ins = idx->insert(r, padded); !ins) { - return fail(ins.error()); - } - } - if (cdx_bulk) { - if (auto b = cdx_bulk->build_bulk(std::move(bulk_keys)); !b) - return fail(b.error()); - } - if (auto fl = idx->flush(); !fl) return fail(fl.error()); - // A bag named after the table is the Harbour DBFCDX production - // index -- set header byte 28 so Harbour auto-opens it on USE. - if (path_ends_with_ci(file, ".cdx")) { - stamp_production_index_flag(t, file); - } - - // Map mutations only (storm fix): the bulk build above ran unlocked; - // wrap the binding registration. - std::lock_guard _ci_lk(index_bindings_mu()); - auto& m = index_bindings(); - auto& act = active_binding_for(); - bool table_has_active = act.find(t) != act.end(); - ADSHANDLE h = next_index_handle(); - if (!table_has_active) { - t->set_order(std::move(idx)); - m[h] = IndexBinding{t, tag, nullptr, file}; - act[t] = h; - } else { - openads::drivers::IIndex* raw = idx.get(); - m[h] = IndexBinding{t, tag, std::move(idx), file}; - t->register_extra_index_view(raw); - } - *phIndex = h; - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsDeleteIndex(ADSHANDLE hIndex) { - arc2_trace("AdsDeleteIndex"); - // OrdDestroy semantics (rddads DBOI_DESTROY): drop the tag from the - // CDX bag on disk, not just detach the handle -- the old close-only - // behaviour left the tag in the bag, so it reappeared on the next - // OrdListAdd / USE (dballcmp conformance harness). - { - std::lock_guard lk(index_bindings_mu()); - auto& m = index_bindings(); - auto it = m.find(hIndex); - if (it != m.end() && - path_ends_with_ci(it->second.path, ".cdx")) { - // Flush the in-memory tree before rewriting the struct leaf. - if (it->second.parked) (void)it->second.parked->flush(); - else if (it->second.table && it->second.table->order() && - it->second.table->order()->index()) - (void)it->second.table->order()->index()->flush(); - auto r = openads::drivers::cdx::CdxIndex::delete_tag( - it->second.path, it->second.tag_name); - if (!r) return fail(r.error()); - } - } - ForceIndexClose force_close; - return AdsCloseIndex(hIndex); -} - -// --- M9.20 custom-key indexes --------------------------------------------- -// -// rddads' DBOI_KEYADD / DBOI_KEYDELETE branches call AdsAddCustomKey -// / AdsDeleteCustomKey with just an index handle and expect the call -// to operate on the **current record**. Real ACE evaluates the -// index's expression against the positioned row and inserts (or -// erases) the resulting (key, recno) entry -- the "custom" wording -// comes from the surrounding `ADS_CUSTOM` flag on the index, which -// disables the engine's auto-sync so apps drive the index manually -// through these two entry points. -// -// OpenADS today doesn't separately track the `ADS_CUSTOM` flag, so -// these calls always evaluate + insert/erase. Apps that opt into -// custom mode get correct behaviour because they're the ones -// explicitly invoking these functions; expression-driven apps stay -// out of the call site. - -namespace { - -openads::drivers::IIndex* iindex_for_binding(IndexBinding& b) { - if (b.parked) return b.parked.get(); - if (auto* o = b.table ? b.table->order() : nullptr; o) { - return const_cast(o)->index(); - } - return nullptr; -} - -} // namespace - -UNSIGNED32 ENTRYPOINT AdsAddCustomKey(ADSHANDLE hIndex) { - arc2_trace("AdsAddCustomKey"); - auto& s = state(); - std::lock_guard lk(s.mu); - // Storm fix: reader must hold index_bindings_mu (binds are unlocked now). - std::lock_guard _ack_lk(index_bindings_mu()); - auto& m = index_bindings(); - auto it = m.find(hIndex); - if (it == m.end()) { - return fail(openads::AE_INTERNAL_ERROR, "unknown index handle"); - } - Table* t = it->second.table; - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - auto* idx = iindex_for_binding(it->second); - if (!idx) return fail(openads::AE_INTERNAL_ERROR, "no IIndex for binding"); - - std::uint16_t klen = idx->key_length(); - if (klen == 0) klen = 32; - std::string kb; - if (idx->key_encoding() == openads::drivers::KeyEncoding::FoxNumeric) { - double dv = 0.0; - if (!openads::engine::evaluate_index_expr_number( - *t, idx->expression(), dv)) - return fail(openads::AE_INTERNAL_ERROR, - "failed to evaluate numeric index expression"); - kb = openads::engine::fox_numeric_key(dv); - } else if (idx->key_encoding() == - openads::drivers::KeyEncoding::NtxNumeric) { - double dv = 0.0; - if (!openads::engine::evaluate_index_expr_number( - *t, idx->expression(), dv)) - return fail(openads::AE_INTERNAL_ERROR, - "failed to evaluate numeric index expression"); - kb = openads::engine::ntx_numeric_key(dv, idx->key_length(), - idx->key_decimals()); - } else { - auto k = openads::engine::evaluate_index_expr(*t, idx->expression(), klen); - if (!k) return fail(k.error()); - kb = std::move(k).value(); - } - auto r = idx->insert(t->recno(), kb); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsDeleteCustomKey(ADSHANDLE hIndex) { - arc2_trace("AdsDeleteCustomKey"); - auto& s = state(); - std::lock_guard lk(s.mu); - // Storm fix: reader must hold index_bindings_mu (binds are unlocked now). - std::lock_guard _dck_lk(index_bindings_mu()); - auto& m = index_bindings(); - auto it = m.find(hIndex); - if (it == m.end()) { - return fail(openads::AE_INTERNAL_ERROR, "unknown index handle"); - } - Table* t = it->second.table; - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - auto* idx = iindex_for_binding(it->second); - if (!idx) return fail(openads::AE_INTERNAL_ERROR, "no IIndex for binding"); - - std::uint16_t klen = idx->key_length(); - if (klen == 0) klen = 32; - std::string kb; - if (idx->key_encoding() == openads::drivers::KeyEncoding::FoxNumeric) { - double dv = 0.0; - if (!openads::engine::evaluate_index_expr_number( - *t, idx->expression(), dv)) - return fail(openads::AE_INTERNAL_ERROR, - "failed to evaluate numeric index expression"); - kb = openads::engine::fox_numeric_key(dv); - } else if (idx->key_encoding() == - openads::drivers::KeyEncoding::NtxNumeric) { - double dv = 0.0; - if (!openads::engine::evaluate_index_expr_number( - *t, idx->expression(), dv)) - return fail(openads::AE_INTERNAL_ERROR, - "failed to evaluate numeric index expression"); - kb = openads::engine::ntx_numeric_key(dv, idx->key_length(), - idx->key_decimals()); - } else { - auto k = openads::engine::evaluate_index_expr(*t, idx->expression(), klen); - if (!k) return fail(k.error()); - kb = std::move(k).value(); - } - auto r = idx->erase(t->recno(), kb); - if (!r) return fail(r.error()); - return ok(); -} - -// --- M9.19 Full-text search ------------------------------------------------ -// -// Creates an OpenADS-native `.fts` inverted-index file alongside the -// table. The format is plain UTF-8 text -- clean-room, NOT derived -// from any proprietary ADS FTS layout. Search support (token lookup -// at query time) is a follow-up milestone; today the create path -// gives apps a stable artefact to commit and visit. -// -// Most of the optional configuration knobs are honoured: min/max -// word length, custom delimiter / noise-word arrays. The page-size, -// drop-char, conditional-char, and reserved arguments are accepted -// (so the ABI shape matches rddads' ADSCREATEFTSINDEX call) and -// don't affect today's text emitter. - -} // extern "C" - -namespace { - -openads::engine::FtsOptions -build_fts_options(UNSIGNED32 ulMinWordLen, UNSIGNED32 ulMaxWordLen, - UNSIGNED16 usUseDefaultDelim, - const UNSIGNED8* pucDelimiters, - UNSIGNED16 usUseDefaultNoise, - const UNSIGNED8* pucNoiseWords) { - openads::engine::FtsOptions opts; - if (ulMinWordLen > 0) opts.min_word_len = ulMinWordLen; - if (ulMaxWordLen > 0) opts.max_word_len = ulMaxWordLen; - - if (!usUseDefaultDelim && pucDelimiters != nullptr) { - opts.extra_delims = openads::abi::to_internal( - const_cast(pucDelimiters), 0); - } - - if (usUseDefaultNoise) { - // Standard English stop-word seed; apps can override. - for (auto* w : {"a", "an", "the", "and", "or", "but", "if", - "of", "in", "on", "at", "to", "for", "is", - "are", "was", "were", "be", "by", "with", - "as", "from", "this", "that", "these", - "those", "it", "its", "not"}) { - opts.noise_words.insert(w); - } - } else if (pucNoiseWords != nullptr) { - auto raw = openads::abi::to_internal( - const_cast(pucNoiseWords), 0); - std::string cur; - for (char c : raw) { - if (c == ' ' || c == '\t' || c == ',' || c == ';' || c == '\n') { - if (!cur.empty()) { opts.noise_words.insert(cur); cur.clear(); } - } else { - cur.push_back(static_cast(std::tolower( - static_cast(c)))); - } - } - if (!cur.empty()) opts.noise_words.insert(cur); - } - return opts; -} - -} // namespace - -extern "C" { - -// --- M9.23 fill in remaining MISS exports --------------------------------- - -UNSIGNED32 ENTRYPOINT AdsGetLongLong(ADSHANDLE hTable, UNSIGNED8* pucField, - std::int64_t* pllValue) { - arc2_trace("AdsGetLongLong"); - if (pllValue == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - // SQL backend (e.g. postgresql): read text via the per-backend ops - // vtable then parse, instead of falling through to the native path. - if (auto* ops = openads::abi::backend_table_ops_for(hTable)) { - if (ops->get_field) { - UNSIGNED8 buf[64] = {0}; - UNSIGNED32 cap = sizeof(buf); - UNSIGNED32 rc = ops->get_field(hTable, pucField, buf, &cap, 0); - if (rc != 0) return rc; - std::string s(reinterpret_cast(buf), - std::min(cap, sizeof(buf))); - std::size_t j = 0; - while (j < s.size() && - std::isspace(static_cast(s[j]))) ++j; - *pllValue = static_cast( - std::strtoll(s.c_str() + j, nullptr, 10)); - return ok(); - } - } - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); - std::uint16_t idx = 0; - if (!resolve_field_index(t, pucField, &idx)) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - auto v = t->read_field(idx); - if (!v) return fail(v.error()); - auto& s = v.value().as_string; - // Strip leading whitespace; strtoll handles signed prefix and digits. - std::size_t i = 0; - while (i < s.size() && - std::isspace(static_cast(s[i]))) ++i; - *pllValue = static_cast( - std::strtoll(s.c_str() + i, nullptr, 10)); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsSetFieldRaw(ADSHANDLE hTable, UNSIGNED8* pucField, - UNSIGNED8* pucBuf, UNSIGNED32 ulLen) { - arc2_trace("AdsSetFieldRaw"); - std::string raw; - if (pucBuf != nullptr && ulLen > 0) { - raw.assign(reinterpret_cast(pucBuf), ulLen); - } - if (auto* rt = get_remote_table(hTable)) { - if (pucField == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - auto i = remote_field_index(rt, pucField); - if (i == std::numeric_limits::max() || i >= rt->fields.size()) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - std::string fname = rt->fields[i].name; - return remote_buffered_set(rt, fname, raw); - } - Table* t = get_table(hTable); - if (!t) { - return AdsSetString(hTable, pucField, pucBuf, ulLen); - } - std::uint16_t idx = 0; - if (!resolve_field_index(t, pucField, &idx)) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - auto r = t->set_field(idx, raw); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsVerifySQL(ADSHANDLE /*hStatement*/, UNSIGNED8* pucSQL) { - arc2_trace("AdsVerifySQL"); - if (pucSQL == nullptr) return fail(openads::AE_PARSE_ERROR, "null SQL"); - auto sql = openads::abi::to_internal(pucSQL, 0); - auto r = openads::sql::parse_select(sql); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsVerifySQLW(ADSHANDLE hStatement, UNSIGNED16* pwcSQL) { - arc2_trace("AdsVerifySQLW"); - if (pwcSQL == nullptr) return fail(openads::AE_PARSE_ERROR, "null SQL"); - std::string sql; - utf16z_to_utf8(pwcSQL, &sql); - std::vector bytes(sql.begin(), sql.end()); - bytes.push_back(0); - return AdsVerifySQL(hStatement, bytes.data()); -} - -UNSIGNED32 ENTRYPOINT AdsFailedTransactionRecovery(UNSIGNED8* pucServer) { - arc2_trace("AdsFailedTransactionRecovery"); - if (pucServer == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, "null server path"); - } - auto path = openads::abi::to_internal(pucServer, 0); - // Recovery happens automatically on Connection::open -- the open - // path scans openads.txlog, replays orphan transactions' before- - // images, and truncates the log. Open + close gives the caller a - // single explicit recovery pass. - auto opened = Connection::open(path); - if (!opened) return fail(opened.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsGetAllLocks(ADSHANDLE hTable, UNSIGNED32* paRecnos, - UNSIGNED16* pusCount) { - arc2_trace("AdsGetAllLocks"); - if (auto* rt = get_remote_table(hTable)) { - // The client lock ledger is complete unless an append - // auto-lock or an unresolvable current-record lock made it - // uncertain (and a table lock shadows the record list, so - // that case stays on the wire too). rddads polls this after - // every commit -- answering locally saves 1 RTT each time. - if (pusCount != nullptr && !rt->locks_uncertain && - !rt->table_lock_held) { - cli_trace_tbl(rt, "AdsGetAllLocks", - "served from client lock ledger"); - const UNSIGNED16 lcap = *pusCount; - UNSIGNED16 li = 0; - if (paRecnos != nullptr) { - for (std::uint32_t lrn : rt->held_recs) { - if (li >= lcap) break; - paRecnos[li++] = lrn; - } - } - *pusCount = static_cast(rt->held_recs.size()); - return ok(); - } - // M12.36 — remote record locks are server-managed; the - // GetAllLocks wire opcode returns this connection's held list. - if (pusCount == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - auto r = rt->conn->get_all_locks(rt->id); - if (!r) return fail(r.error()); - const auto& held = r.value(); - UNSIGNED16 cap = *pusCount; - UNSIGNED16 n = static_cast( - std::min(held.size(), cap)); - if (paRecnos != nullptr && n > 0) { - for (UNSIGNED16 i = 0; i < n; ++i) { - paRecnos[i] = held[i]; - } - } - *pusCount = static_cast(held.size()); - return ok(); - } - Table* t = get_table(hTable); - if (!t || pusCount == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - auto held = t->held_record_locks(); - UNSIGNED16 cap = *pusCount; - UNSIGNED16 n = static_cast( - std::min(held.size(), cap)); - if (paRecnos != nullptr && n > 0) { - for (UNSIGNED16 i = 0; i < n; ++i) { - paRecnos[i] = static_cast(held[i]); - } - } - *pusCount = static_cast(held.size()); - return ok(); -} - -// M12.16c -- switch the active order on `hTable` to the binding -// whose tag matches `pucName` (case-insensitive). Mirrors the -// rddads adsOrdSetActive(cTagName) flow. Empty / NULL name flips -// the table back to natural-record-order (clear active binding). -UNSIGNED32 ENTRYPOINT AdsSetIndexOrder(ADSHANDLE hTable, UNSIGNED8* pucName) { - arc2_trace("AdsSetIndexOrder"); - if (auto* rt = get_remote_table(hTable)) { - // Sets + teardown flush here, but NOT a deferred order switch: - // a new switch overwrites the pending one (unsent requests have - // no observable effect), so flushing first would just add a frame - // for a binding nobody will ever navigate in. - if (UNSIGNED32 frc = remote_flush_sets(rt); frc != 0) return frc; - if (UNSIGNED32 frc = remote_flush_teardown(rt); frc != 0) return frc; - std::string name = pucName - ? openads::abi::to_internal(pucName, 0) : std::string(); - // Resolve the target locally (case-insensitive, like the mirror - // block below). Unmapped names stay kOrderUnknown and always go - // out on the wire: the server resolves those, and a wrong local - // guess is the "remote browse shows no index" bug. - auto resolve_want = [&]() { - if (name.empty()) return std::uint32_t{0}; - auto match_in = [&](const std::vector< - std::pair>& m) { - for (auto& [tag, wid] : m) { - if (tag.size() != name.size()) continue; - bool eq = true; - for (std::size_t i = 0; i < tag.size(); ++i) { - if (std::toupper( - static_cast(tag[i])) != - std::toupper( - static_cast(name[i]))) { - eq = false; - break; - } - } - if (eq) return wid; - } - return openads::network::RemoteTable::kOrderUnknown; - }; - const std::uint32_t live = match_in(rt->index_by_tag); - if (live != openads::network::RemoteTable::kOrderUnknown) { - return live; - } - // Parked fallback: the snapshot's server ids are live - // (the park kept the bindings open), so a tag switch - // while parked defers normally instead of paying a wire - // by-name frame for a binding we already hold. - if (rt->indexes_parked) return match_in(rt->parked_by_tag); - return openads::network::RemoteTable::kOrderUnknown; - }; - // Skip a redundant switch: SetOrder never moves the cursor, so - // when the ack-confirmed server binding already equals the - // target the frame would change nothing observable. (Vouch - // re-sets the same order on every USE; pooled re-USEs keep the - // binding server-side.) - { - const std::uint32_t want = resolve_want(); - if (want != openads::network::RemoteTable::kOrderUnknown && - rt->server_order_id == want) { - rt->active_index_id = want; - return ok(); - } - // Defer: a following GotoTop/Bottom absorbs this into its - // fused frame; anything else flushes it plainly first. - // Overwrites any earlier pending switch (unsent requests - // have no observable effect). Belief updates now; position - // is retained but order-relative caches must go. - if (want != openads::network::RemoteTable::kOrderUnknown) { - rt->active_index_id = want; - rt->pending_order = true; - rt->pending_order_id = want; - rt->keyno_valid = false; - rt->key_count_cached = false; - rt->invalidate_prefetch(); - return ok(); - } - } - // Unmapped: legacy immediate wire path (errors surface now). - // Any pending switch is superseded by this explicit request. - rt->pending_order = false; - auto r = rt->conn->set_order_by_name(rt->id, name); - if (!r) return fail(r.error()); - // RCB 07/14/2026: BUG FIX -- the controlling order just changed, so the - // cached row and every queued lookahead row were read in the OLD order - // and cannot be served against the new one. Same family as the AdsSeek - // stale-queue bug: without this, the first skip after an OrdSetFocus - // serves a row from the previous ordering, locally, with no wire - // traffic to make it look suspicious. - rt->row_valid = false; - rt->invalidate_prefetch(); - rt->key_count_cached = false; - // RCB 07/14/2026: the server now orders by whatever `name` resolved to - // on ITS side. Mirror that into server_order_id when we can map the tag - // locally; when we can't, deliberately leave it "unknown" so the next - // remote_activate_index re-sends SetOrder rather than trusting a guess. - // A wrong guess here is the "remote browse shows no index" bug. - rt->server_order_id = openads::network::RemoteTable::kOrderUnknown; - if (name.empty()) { - rt->active_index_id = 0; - rt->server_order_id = 0; // natural record order - rt->keyno_valid = false; - } else { - for (auto& [tag, wid] : rt->index_by_tag) { - if (tag.size() == name.size()) { - bool eq = true; - for (std::size_t i = 0; i < tag.size(); ++i) { - if (std::toupper(static_cast(tag[i])) != - std::toupper(static_cast(name[i]))) { - eq = false; - break; - } - } - if (eq) { - rt->active_index_id = wid; - rt->server_order_id = wid; - rt->keyno_valid = false; - break; - } - } - } - } - return ok(); - } - if (is_sql_table_handle(hTable)) { - std::string name = pucName - ? openads::abi::to_internal(pucName, 0) : std::string(); - if (name.empty()) { - sql_active_index_handle().erase(hTable); - return ok(); - } - auto upper_eq = [](const std::string& a, const std::string& b) { - if (a.size() != b.size()) return false; - for (std::size_t i = 0; i < a.size(); ++i) { - if (std::toupper(static_cast(a[i])) != - std::toupper(static_cast(b[i]))) { - return false; - } - } - return true; - }; -#if defined(OPENADS_WITH_SQLITE) - for (auto& [ih, si] : sqlite_indexes_map()) { - if (si->parent == get_sqlite_table(hTable) && - upper_eq(si->column, name)) { - sql_active_index_handle()[hTable] = static_cast(ih); - return ok(); - } - } -#endif -#if defined(OPENADS_WITH_MARIADB) - for (auto& [ih, si] : maria_indexes_map()) { - if (si->parent == get_maria_table(hTable) && - upper_eq(si->column, name)) { - sql_active_index_handle()[hTable] = static_cast(ih); - return ok(); - } - } -#endif -#if defined(OPENADS_WITH_POSTGRESQL) - for (auto& [ih, si] : postgres_indexes_map()) { - if (si->parent == get_postgres_table(hTable) && - upper_eq(si->column, name)) { - sql_active_index_handle()[hTable] = static_cast(ih); - return ok(); - } - } -#endif -#if defined(OPENADS_WITH_ODBC) - for (auto& [ih, si] : odbc_indexes_map()) { - if (si->parent == get_odbc_table(hTable) && - upper_eq(si->column, name)) { - sql_active_index_handle()[hTable] = static_cast(ih); - return ok(); - } - } -#endif -#if defined(OPENADS_WITH_FIREBIRD) - for (auto& [ih, si] : firebird_indexes_map()) { - if (si->parent == get_firebird_table(hTable) && - upper_eq(si->column, name)) { - sql_active_index_handle()[hTable] = static_cast(ih); - return ok(); - } - } -#endif -#if defined(OPENADS_WITH_MSSQL) - for (auto& [ih, si] : mssql_indexes_map()) { - if (si->parent == get_mssql_table(hTable) && - upper_eq(si->column, name)) { - sql_active_index_handle()[hTable] = static_cast(ih); - return ok(); - } - } -#endif - return fail(openads::AE_INTERNAL_ERROR, - ("AdsSetIndexOrder: no tag '" + name + "' on table").c_str()); - } - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - std::string name = pucName - ? openads::abi::to_internal(pucName, 0) : std::string(); - if (name.empty()) { - // Empty tag = natural order. Park the active binding back - // into its slot so a subsequent AdsSetIndexOrder picks the - // current Table::order_ up cleanly. - park_active_order(t); - return ok(); - } - auto upper_eq = [](const std::string& a, const std::string& b) { - if (a.size() != b.size()) return false; - for (std::size_t i = 0; i < a.size(); ++i) { - char ca = static_cast(std::toupper( - static_cast(a[i]))); - char cb = static_cast(std::toupper( - static_cast(b[i]))); - if (ca != cb) return false; - } - return true; - }; - auto& m = index_bindings(); - // Storm fix: reader must hold index_bindings_mu (binds are unlocked now). - std::lock_guard _so_lk(index_bindings_mu()); - for (auto& [h, b] : m) { - if (b.table == t && upper_eq(b.tag_name, name)) { - auto r = activate_binding(h); - if (!r) return fail(r.error()); - return ok(); - } - } - return fail(openads::AE_INTERNAL_ERROR, - ("AdsSetIndexOrder: no tag '" + name + "' on table").c_str()); -} - -UNSIGNED32 ENTRYPOINT AdsSetIndexOrderByHandle(ADSHANDLE hTable, ADSHANDLE hIndex) { - arc2_trace("AdsSetIndexOrderByHandle"); - if (auto* rt = get_remote_table(hTable)) { - // Sets + teardown flush here, but NOT a deferred order switch - // (overwritten below — see ByName). - if (UNSIGNED32 frc = remote_flush_sets(rt); frc != 0) return frc; - if (UNSIGNED32 frc = remote_flush_teardown(rt); frc != 0) return frc; - if (hIndex == 0) { - // "Back to natural order" via the explicit API: send the reset - // frame so the server drops its ordered_tables_ entry (it used - // to send NO frame, and table-handle Skips kept walking the - // old index order). Skip only when the server is ack-confirmed - // natural already — the frame would change nothing (SetOrder - // never moves the cursor, so position is untouched either way). - if (rt->server_order_id == 0) { - rt->active_index_id = 0; - return ok(); - } - // Defer: a following GotoTop/Bottom absorbs this into its - // fused frame; anything else flushes it plainly first. - // Belief updates now (active_index_id); the ack-confirmed - // server_order_id only when a frame actually goes out. - // Position is retained, but order-relative caches must go: - // the keyno/count describe the OLD order from here on. - rt->active_index_id = 0; - rt->pending_order = true; - rt->pending_order_id = 0; - rt->keyno_valid = false; - rt->key_count_cached = false; - rt->invalidate_prefetch(); - return ok(); - } - if (auto* ri = get_remote_index(hIndex)) { - // Same-order repeat: the server binding is already correct - // and SetOrder moves no cursor — skip the frame, sync the - // client belief only. Caches stay: nothing observable changed. - if (rt->server_order_id == ri->id) { - rt->active_index_id = ri->id; - return ok(); - } - // Defer (see above). - rt->active_index_id = ri->id; - rt->pending_order = true; - rt->pending_order_id = ri->id; - rt->keyno_valid = false; - rt->key_count_cached = false; - rt->invalidate_prefetch(); - return ok(); - } - return fail(openads::AE_INTERNAL_ERROR, - "AdsSetIndexOrderByHandle: hIndex is not a remote index"); - } - if (is_sql_table_handle(hTable)) { - bool ok_index = false; -#if defined(OPENADS_WITH_SQLITE) - ok_index = ok_index || get_sqlite_index(hIndex) != nullptr; -#endif -#if defined(OPENADS_WITH_MARIADB) - ok_index = ok_index || get_maria_index(hIndex) != nullptr; -#endif -#if defined(OPENADS_WITH_POSTGRESQL) - ok_index = ok_index || get_postgres_index(hIndex) != nullptr; -#endif -#if defined(OPENADS_WITH_ODBC) - ok_index = ok_index || get_odbc_index(hIndex) != nullptr; -#endif -#if defined(OPENADS_WITH_FIREBIRD) - ok_index = ok_index || get_firebird_index(hIndex) != nullptr; -#endif -#if defined(OPENADS_WITH_MSSQL) - ok_index = ok_index || get_mssql_index(hIndex) != nullptr; -#endif - if (ok_index) { - sql_active_index_handle()[hTable] = hIndex; - return ok(); - } - return fail(openads::AE_INTERNAL_ERROR, - "AdsSetIndexOrderByHandle: index not bound to table"); - } - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - if (hIndex == 0) { - // Natural order (rddads' patched DbSetOrder(0) calls this): park - // the active order back into its binding slot. - park_active_order(t); - return ok(); - } - auto& m = index_bindings(); - // Storm fix: reader must hold index_bindings_mu (binds are unlocked now). - std::lock_guard _sob_lk(index_bindings_mu()); - auto it = m.find(hIndex); - if (it == m.end() || it->second.table != t) { - return fail(openads::AE_INTERNAL_ERROR, - "AdsSetIndexOrderByHandle: index not bound to table"); - } - auto r = activate_binding(hIndex); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsSkipUnique(ADSHANDLE hIndex, SIGNED32 lDirection) { - arc2_trace("AdsSkipUnique"); - if (auto* ri = get_remote_index(hIndex)) { - if (ri->parent) { - if (UNSIGNED32 frc = remote_flush_pending(ri->parent); frc != 0) return frc; - ri->parent->row_valid = false; // M12.17 - } - auto r = ri->conn->skip_unique(ri->id, lDirection); - if (!r) return fail(r.error()); - return ok(); - } - auto& s = state(); - std::lock_guard lk(s.mu); - auto* idx = iindex_for_handle(hIndex); - Table* t = lookup_table_by_index(hIndex); - if (!idx || !t) return fail(openads::AE_INTERNAL_ERROR, "unknown index"); - (void)activate_binding(hIndex); - - std::string start_key = idx->current_key(); - auto step = [&]() { - return lDirection < 0 ? idx->prev() : idx->next(); - }; - - for (;;) { - auto r = step(); - if (!r) return fail(r.error()); - if (!r.value().positioned) { - return fail(openads::AE_INTERNAL_ERROR, "no more unique keys"); - } - if (idx->current_key() != start_key) { - // Position the table on the new recno. - (void)t->goto_record(r.value().recno); - return ok(); - } - } -} - -// AdsFTSSearch is an OpenADS extension. The original ACE SDK doesn't -// export an entry point with this exact shape that rddads' Harbour -// surface ever reached (rddads is silent on FTS query semantics), so -// OpenADS publishes a small clean-room API: load the .fts file at -// `pucFile`, tokenise the query with the standard rules, intersect -// the per-token recno lists, and write up to `*pulCount` recnos into -// `paRecnos`. `*pulCount` is treated as in/out -- the caller passes -// the array capacity and reads back the total number of matches -// (which may be larger than the buffer). -UNSIGNED32 ENTRYPOINT AdsFTSSearch(ADSHANDLE /*hConnect*/, - UNSIGNED8* pucFile, - UNSIGNED8* pucQuery, - UNSIGNED32* paRecnos, - UNSIGNED32* pulCount) { - arc2_trace("AdsFTSSearch"); - if (pucFile == nullptr || pucQuery == nullptr || pulCount == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, "AdsFTSSearch: null arg"); - } - auto path = openads::abi::to_internal(pucFile, 0); - auto query = openads::abi::to_internal(pucQuery, 0); - - auto loaded = openads::engine::Fts::load(path); - if (!loaded) return fail(loaded.error()); - - openads::engine::FtsOptions opts; - auto hits = openads::engine::Fts::search(loaded.value(), query, opts); - - UNSIGNED32 cap = *pulCount; - UNSIGNED32 n = static_cast( - std::min(hits.size(), cap)); - if (paRecnos != nullptr && n > 0) { - std::memcpy(paRecnos, hits.data(), n * sizeof(UNSIGNED32)); - } - *pulCount = static_cast(hits.size()); - return ok(); -} - -// --- M10.1 Data-Dictionary CRUD -------------------------------------------- -// -// Real persistence in OpenADS' clean-room DD text format. When the -// caller's connection has no DD attached (i.e. the connection was -// opened against a plain data directory, not a `.add` file), the -// CRUD calls report AE_SUCCESS and no-op -- matching the "everything -// quiescent" contract used for AdsMg* in M9.24. Apps that opened -// the DD via `Connection::open(<.add>)` (M6) get round-trip -// persistence. - -namespace { - -Connection* conn_from_handle(ADSHANDLE hConn) { - auto& s = state(); - return s.registry.lookup(hConn, HandleKind::Connection); -} - -openads::engine::DataDict* dd_from_handle(ADSHANDLE hConn) { - Connection* c = conn_from_handle(hConn); - if (c == nullptr || !c->has_dd()) return nullptr; - return c->dd(); -} - -} // namespace - -UNSIGNED32 ENTRYPOINT AdsDDAddIndexFile(ADSHANDLE hConn, - UNSIGNED8* pucTable, UNSIGNED8* pucIndex, - UNSIGNED8* pucComment) { - arc2_trace("AdsDDAddIndexFile"); - if (auto* rc = get_remote_connection(hConn)) { - auto r = rc->dd_add_index_file( - openads::abi::to_internal(pucTable, 0), - openads::abi::to_internal(pucIndex, 0), - pucComment ? openads::abi::to_internal(pucComment, 0) : ""); - if (!r) return fail(r.error()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - if (dd == nullptr) return ok(); - auto tbl = openads::abi::to_internal(pucTable, 0); - auto idx = openads::abi::to_internal(pucIndex, 0); - auto cmt = pucComment ? openads::abi::to_internal(pucComment, 0) - : std::string(); - auto r = dd->add_index_file(tbl, idx, cmt); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsDDRemoveIndexFile(ADSHANDLE hConn, - UNSIGNED8* pucTable, UNSIGNED8* pucIndex, - UNSIGNED16 /*opt*/) { - arc2_trace("AdsDDRemoveIndexFile"); - if (auto* rc = get_remote_connection(hConn)) { - auto r = rc->dd_remove_index_file( - openads::abi::to_internal(pucTable, 0), - openads::abi::to_internal(pucIndex, 0)); - if (!r) return fail(r.error()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - if (dd == nullptr) return ok(); - auto tbl = openads::abi::to_internal(pucTable, 0); - auto idx = openads::abi::to_internal(pucIndex, 0); - auto r = dd->remove_index_file(tbl, idx); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsDDCreateUser(ADSHANDLE hConn, UNSIGNED8* pucGroup, - UNSIGNED8* pucUser, UNSIGNED8* pucPwd, - UNSIGNED8* pucDesc) { - arc2_trace("AdsDDCreateUser"); - if (auto* rc = get_remote_connection(hConn)) { - auto r = rc->dd_create_user( - pucGroup ? openads::abi::to_internal(pucGroup, 0) : "", - openads::abi::to_internal(pucUser, 0), - pucPwd ? openads::abi::to_internal(pucPwd, 0) : "", - pucDesc ? openads::abi::to_internal(pucDesc, 0) : ""); - if (!r) return fail(r.error()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - if (dd == nullptr) return ok(); - auto user = openads::abi::to_internal(pucUser, 0); - auto r = dd->create_user(user); - if (!r) return fail(r.error()); - if (pucPwd && pucPwd[0] != '\0') { - auto pwd = openads::abi::to_internal(pucPwd, 0); - dd->set_user_property(user, "prop_1101", pwd); - } - if (pucDesc && pucDesc[0] != '\0') { - auto desc = openads::abi::to_internal(pucDesc, 0); - dd->set_user_property(user, "prop_1", desc); - } - if (pucGroup && pucGroup[0] != '\0') { - auto grp = openads::abi::to_internal(pucGroup, 0); - dd->add_user_to_group(user, grp); - } - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsDDDeleteUser(ADSHANDLE hConn, UNSIGNED8* pucUser) { - arc2_trace("AdsDDDeleteUser"); - if (auto* rc = get_remote_connection(hConn)) { - auto r = rc->dd_drop_object(openads::network::DDObjectKind::User, - openads::abi::to_internal(pucUser, 0)); - if (!r) return fail(r.error()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - if (dd == nullptr) return ok(); - auto user = openads::abi::to_internal(pucUser, 0); - auto r = dd->delete_user(user); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsDDAddUserToGroup(ADSHANDLE hConn, - UNSIGNED8* pucGroup, UNSIGNED8* pucUser) { - arc2_trace("AdsDDAddUserToGroup"); - if (auto* rc = get_remote_connection(hConn)) { - auto r = rc->dd_add_user_to_group( - openads::abi::to_internal(pucGroup, 0), - openads::abi::to_internal(pucUser, 0)); - if (!r) return fail(r.error()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - if (dd == nullptr) return ok(); - auto group = openads::abi::to_internal(pucGroup, 0); - auto user = openads::abi::to_internal(pucUser, 0); - auto r = dd->add_user_to_group(user, group); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsDDRemoveUserFromGroup(ADSHANDLE hConn, - UNSIGNED8* pucGroup, UNSIGNED8* pucUser) { - arc2_trace("AdsDDRemoveUserFromGroup"); - if (auto* rc = get_remote_connection(hConn)) { - auto r = rc->dd_remove_user_from_group( - openads::abi::to_internal(pucGroup, 0), - openads::abi::to_internal(pucUser, 0)); - if (!r) return fail(r.error()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - if (dd == nullptr) return ok(); - auto group = openads::abi::to_internal(pucGroup, 0); - auto user = openads::abi::to_internal(pucUser, 0); - auto r = dd->remove_user_from_group(user, group); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsDDCreateLink(ADSHANDLE hConn, UNSIGNED8* pucAlias, - UNSIGNED8* pucPath, UNSIGNED8* pucUser, - UNSIGNED8* pucPwd, UNSIGNED16 /*opt*/) { - arc2_trace("AdsDDCreateLink"); - if (auto* rc = get_remote_connection(hConn)) { - auto r = rc->dd_create_link( - openads::abi::to_internal(pucAlias, 0), - openads::abi::to_internal(pucPath, 0), - pucUser ? openads::abi::to_internal(pucUser, 0) : "", - pucPwd ? openads::abi::to_internal(pucPwd, 0) : ""); - if (!r) return fail(r.error()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - if (dd == nullptr) return ok(); - auto alias = openads::abi::to_internal(pucAlias, 0); - auto path = openads::abi::to_internal(pucPath, 0); - auto user = pucUser ? openads::abi::to_internal(pucUser, 0) : std::string(); - auto pwd = pucPwd ? openads::abi::to_internal(pucPwd, 0) : std::string(); - auto r = dd->create_link(alias, path, user, pwd); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsDDDropLink(ADSHANDLE hConn, UNSIGNED8* pucAlias, - UNSIGNED16 /*opt*/) { - arc2_trace("AdsDDDropLink"); - if (auto* rc = get_remote_connection(hConn)) { - auto r = rc->dd_drop_link(openads::abi::to_internal(pucAlias, 0)); - if (!r) return fail(r.error()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - if (dd == nullptr) return ok(); - auto alias = openads::abi::to_internal(pucAlias, 0); - auto r = dd->drop_link(alias); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsDDModifyLink(ADSHANDLE hConn, UNSIGNED8* pucAlias, - UNSIGNED8* pucPath, UNSIGNED8* pucUser, - UNSIGNED8* pucPwd, UNSIGNED16 /*opt*/) { - arc2_trace("AdsDDModifyLink"); - if (auto* rc = get_remote_connection(hConn)) { - auto r = rc->dd_modify_link( - openads::abi::to_internal(pucAlias, 0), - pucPath ? openads::abi::to_internal(pucPath, 0) : "", - pucUser ? openads::abi::to_internal(pucUser, 0) : "", - pucPwd ? openads::abi::to_internal(pucPwd, 0) : ""); - if (!r) return fail(r.error()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - if (dd == nullptr) return ok(); - auto alias = openads::abi::to_internal(pucAlias, 0); - auto path = pucPath ? openads::abi::to_internal(pucPath, 0) : std::string(); - auto user = pucUser ? openads::abi::to_internal(pucUser, 0) : std::string(); - auto pwd = pucPwd ? openads::abi::to_internal(pucPwd, 0) : std::string(); - auto r = dd->modify_link(alias, path, user, pwd); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsDDCreateRefIntegrity(ADSHANDLE hConn, - UNSIGNED8* pucName, UNSIGNED8* pucFail, - UNSIGNED8* pucParent, UNSIGNED8* pucParentTag, - UNSIGNED8* pucChild, UNSIGNED8* pucChildTag, - UNSIGNED16 usUpdate, UNSIGNED16 usDelete) { - arc2_trace("AdsDDCreateRefIntegrity"); - if (auto* rc = get_remote_connection(hConn)) { - auto r = rc->dd_create_ref_integrity( - openads::abi::to_internal(pucName, 0), - pucFail ? openads::abi::to_internal(pucFail, 0) : "", - pucParent ? openads::abi::to_internal(pucParent, 0) : "", - pucParentTag ? openads::abi::to_internal(pucParentTag, 0) : "", - pucChild ? openads::abi::to_internal(pucChild, 0) : "", - pucChildTag ? openads::abi::to_internal(pucChildTag, 0) : "", - usUpdate, usDelete); - if (!r) return fail(r.error()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - if (dd == nullptr) return ok(); - openads::engine::DataDict::RiEntry e; - e.name = openads::abi::to_internal(pucName, 0); - e.parent = pucParent ? openads::abi::to_internal(pucParent, 0) : std::string(); - e.child = pucChild ? openads::abi::to_internal(pucChild, 0) : std::string(); - e.parent_tag = pucParentTag ? openads::abi::to_internal(pucParentTag, 0) : std::string(); - e.child_tag = pucChildTag ? openads::abi::to_internal(pucChildTag, 0) : std::string(); - e.update_opt = std::to_string(static_cast(usUpdate)); - e.delete_opt = std::to_string(static_cast(usDelete)); - e.fail_table = pucFail ? openads::abi::to_internal(pucFail, 0) : std::string(); - auto r = dd->create_ri(e); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsDDRemoveRefIntegrity(ADSHANDLE hConn, UNSIGNED8* pucName) { - arc2_trace("AdsDDRemoveRefIntegrity"); - if (auto* rc = get_remote_connection(hConn)) { - auto r = rc->dd_drop_object(openads::network::DDObjectKind::RefIntegrity, - openads::abi::to_internal(pucName, 0)); - if (!r) return fail(r.error()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - if (dd == nullptr) return ok(); - auto name = openads::abi::to_internal(pucName, 0); - auto r = dd->remove_ri(name); - if (!r) return fail(r.error()); - return ok(); -} - -// SAP database-property id (ABI) → the legacy "prop_N" storage key that -// existing OpenADS DDs persist (SP_MODIFYDATABASE numbering; the -// AdsConnect60 login checks read prop_5 / prop_16, DA-Web db_props.php -// reads the same keys). The storage keys are deliberately STABLE -- only -// the public ABI numbering moved to SAP's. Ids without a mapping fall -// back to a raw prop_ passthrough, which also keeps legacy callers -// that passed the storage numbers directly working (their old ids and -// keys coincide). -static const char* db_prop_storage_key(UNSIGNED16 usProp) { - switch (usProp) { - case ADS_DD_COMMENT: return "prop_1"; - case ADS_DD_USER_DEFINED_PROP: return "prop_26"; - case ADS_DD_DEFAULT_TABLE_PATH: return "prop_3"; - case ADS_DD_TEMP_TABLE_PATH: return "prop_12"; - case ADS_DD_LOG_IN_REQUIRED: return "prop_5"; - case ADS_DD_VERIFY_ACCESS_RIGHTS: return "prop_8"; - case ADS_DD_ENCRYPT_TABLE_PASSWORD: return "prop_13"; - case ADS_DD_ENCRYPT_NEW_TABLE: return "prop_10"; - case ADS_DD_ENABLE_INTERNET: return "prop_6"; - case ADS_DD_INTERNET_SECURITY_LEVEL: return "prop_7"; - case ADS_DD_MAX_FAILED_ATTEMPTS: return "prop_11"; - case ADS_DD_ALLOW_ADSSYS_NET_ACCESS: return "prop_24"; - case ADS_DD_VERSION_MAJOR: return "prop_14"; - case ADS_DD_VERSION_MINOR: return "prop_15"; - case ADS_DD_LOGINS_DISABLED: return "prop_16"; - case ADS_DD_LOGINS_DISABLED_ERRSTR: return "prop_17"; - case ADS_DD_FTS_DELIMITERS: return "prop_18"; - case ADS_DD_FTS_NOISE: return "prop_19"; - case ADS_DD_FTS_DROP_CHARS: return "prop_20"; - case ADS_DD_FTS_CONDITIONAL_CHARS: return "prop_21"; - case ADS_DD_ENCRYPTED: return "prop_22"; - case ADS_DD_ENCRYPT_INDEXES: return "prop_23"; - case ADS_DD_ENCRYPT_COMMUNICATION: return "prop_25"; - default: return nullptr; - } -} - -UNSIGNED32 ENTRYPOINT AdsDDSetDatabaseProperty(ADSHANDLE hConn, UNSIGNED16 usProp, - void* pBuf, UNSIGNED16 usLen) { - arc2_trace("AdsDDSetDatabaseProperty"); - if (auto* rc = get_remote_connection(hConn)) { - std::string val = (pBuf != nullptr && usLen > 0) - ? std::string(reinterpret_cast(pBuf), usLen) : std::string(); - auto r = rc->dd_set_property(openads::network::DDObjectKind::Database, - "", "", usProp, val); - if (!r) return fail(r.error()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - if (dd == nullptr) return ok(); - std::string val; - if (pBuf != nullptr && usLen > 0) { - val.assign(reinterpret_cast(pBuf), usLen); - } - // ADS_DD_ADMIN_PASSWORD sets the adssys account password (same as - // sp_ModifyDatabase ADMIN_PASSWORD); ADS_DD_VERSION is the DD format - // version and is not writable. - if (usProp == ADS_DD_ADMIN_PASSWORD) { - auto r = dd->set_user_property("adssys", "prop_1101", val); - if (!r) return fail(r.error()); - return ok(); - } - if (usProp == ADS_DD_VERSION) - return fail(openads::AE_FUNCTION_NOT_AVAILABLE, "read-only DD prop"); - const char* mapped = db_prop_storage_key(usProp); - std::string key = mapped != nullptr - ? std::string(mapped) - : "prop_" + std::to_string(static_cast(usProp)); - auto r = dd->set_db_property(key, val); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsDDGetDatabaseProperty(ADSHANDLE hConn, UNSIGNED16 usProp, - void* pBuf, UNSIGNED16* pusLen) { - arc2_trace("AdsDDGetDatabaseProperty"); - if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (auto* rc = get_remote_connection(hConn)) { - auto r = rc->dd_get_property(openads::network::DDObjectKind::Database, - "", "", usProp); - UNSIGNED16 cap = *pusLen; - if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); - if (!r) { *pusLen = 0; return fail(r.error()); } - UNSIGNED16 n = static_cast(std::min(r.value().size(), cap)); - if (pBuf != nullptr && n > 0) std::memcpy(pBuf, r.value().data(), n); - *pusLen = static_cast(r.value().size()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - UNSIGNED16 cap = *pusLen; - if (pBuf != nullptr && cap > 0) { - std::memset(pBuf, 0, cap); - } - if (dd == nullptr) { *pusLen = 0; return ok(); } - // ADMIN_PASSWORD is write-only in the SAP ABI; VERSION is the DD - // format version, which OpenADS does not track per-DD. - if (usProp == ADS_DD_ADMIN_PASSWORD) { - *pusLen = 0; - return fail(openads::AE_INVALID_PROPERTY_ID, "write-only DD prop"); - } - if (usProp == ADS_DD_VERSION) { - *pusLen = 0; - return fail(openads::AE_PROPERTY_NOT_SET, ""); - } - const char* mapped = db_prop_storage_key(usProp); - std::string key = mapped != nullptr - ? std::string(mapped) - : "prop_" + std::to_string(static_cast(usProp)); - auto val = dd->get_db_property(key); - UNSIGNED16 n = static_cast( - std::min(val.size(), cap)); - if (pBuf != nullptr && n > 0) std::memcpy(pBuf, val.data(), n); - *pusLen = static_cast(val.size()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsDDGetUserProperty(ADSHANDLE hConn, UNSIGNED8* pucUser, - UNSIGNED16 usProp, void* pBuf, - UNSIGNED16* pusLen) { - arc2_trace("AdsDDGetUserProperty"); - if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (auto* rc = get_remote_connection(hConn)) { - auto uname = openads::abi::to_internal(pucUser, 0); - auto r = rc->dd_get_property(openads::network::DDObjectKind::User, - uname, "", usProp); - UNSIGNED16 cap = *pusLen; - if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); - if (!r) { *pusLen = 0; return fail(r.error()); } - UNSIGNED16 n = static_cast(std::min(r.value().size(), cap)); - if (pBuf != nullptr && n > 0) std::memcpy(pBuf, r.value().data(), n); - *pusLen = static_cast(r.value().size()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - UNSIGNED16 cap = *pusLen; - if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); - if (dd == nullptr) { *pusLen = 0; return ok(); } - auto user = openads::abi::to_internal(pucUser, 0); - - // ADS_DD_USER_BAD_LOGINS (1103) -- always 0, returned as uint16. - if (usProp == 1103) { - UNSIGNED16 zero = 0; - UNSIGNED16 n = std::min(cap, sizeof(UNSIGNED16)); - if (pBuf && n > 0) std::memcpy(pBuf, &zero, n); - *pusLen = sizeof(UNSIGNED16); - return ok(); - } - // ADS_DD_USER_GROUP_MEMBERSHIP (1102) -- comma-separated group list. - if (usProp == 1102) { - std::string groups; - for (const auto& g : dd->groups_of(user)) { - if (!groups.empty()) groups += ','; - groups += g; - } - UNSIGNED16 n = static_cast( - std::min(groups.size(), cap)); - if (pBuf && n > 0) std::memcpy(pBuf, groups.data(), n); - *pusLen = static_cast(groups.size()); - return ok(); - } - - std::string key = "prop_" + std::to_string(static_cast(usProp)); - auto val = dd->get_user_property(user, key); - UNSIGNED16 n = static_cast( - std::min(val.size(), cap)); - if (pBuf != nullptr && n > 0) std::memcpy(pBuf, val.data(), n); - *pusLen = static_cast(val.size()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsDDSetUserProperty(ADSHANDLE hConn, UNSIGNED8* pucUser, - UNSIGNED16 usProp, void* pvBuf, - UNSIGNED16 usLen) { - arc2_trace("AdsDDSetUserProperty"); - if (auto* rc = get_remote_connection(hConn)) { - auto uname = openads::abi::to_internal(pucUser, 0); - std::string val = (pvBuf != nullptr && usLen > 0) - ? std::string(reinterpret_cast(pvBuf), usLen) : std::string(); - auto r = rc->dd_set_property(openads::network::DDObjectKind::User, - uname, "", usProp, val); - if (!r) return fail(r.error()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - if (dd == nullptr) return ok(); - auto user = openads::abi::to_internal(pucUser, 0); - if (!dd->has_user(user)) - return fail(static_cast(openads::AE_TABLE_NOT_FOUND), user.c_str()); - - // ADS_DD_USER_BAD_LOGINS (1103) -- read-only counter, silently ignore sets. - if (usProp == 1103) return ok(); - - // ADS_DD_USER_GROUP_MEMBERSHIP (1102) -- add user to the named group. - if (usProp == 1102) { - if (pvBuf == nullptr || usLen == 0) return ok(); - std::string grp(reinterpret_cast(pvBuf), usLen); - if (!grp.empty() && grp.back() == '\0') grp.pop_back(); - if (grp.empty()) return ok(); - auto r = dd->add_user_to_group(user, grp); - if (!r) return fail(r.error()); - return ok(); - } - - // All other codes (including 1 for comment, 1101 for password): store as - // string property keyed by "prop_N" so Get/Set round-trip symmetrically. - std::string val; - if (pvBuf != nullptr && usLen > 0) - val.assign(reinterpret_cast(pvBuf), usLen); - std::string key = "prop_" + std::to_string(static_cast(usProp)); - auto r = dd->set_user_property(user, key, val); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsDDGetTableProperty(ADSHANDLE hConn, UNSIGNED8* pucTable, - UNSIGNED16 usProp, void* pBuf, - UNSIGNED16* pusLen) { - arc2_trace("AdsDDGetTableProperty"); - namespace fs = std::filesystem; - if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (auto* rc = get_remote_connection(hConn)) { - auto tname = openads::abi::to_internal(pucTable, 0); - auto r = rc->dd_get_property(openads::network::DDObjectKind::Table, - tname, "", usProp); - UNSIGNED16 cap = *pusLen; - if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); - if (!r) { *pusLen = 0; return fail(r.error()); } - UNSIGNED16 n = static_cast(std::min(r.value().size(), cap)); - if (pBuf != nullptr && n > 0) std::memcpy(pBuf, r.value().data(), n); - *pusLen = static_cast(r.value().size()); - return ok(); - } - Connection* c = conn_from_handle(hConn); - UNSIGNED16 cap = *pusLen; - if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); - - auto* dd = (c != nullptr && c->has_dd()) ? c->dd() : nullptr; - if (dd == nullptr) { *pusLen = 0; return ok(); } - - auto alias = openads::abi::to_internal(pucTable, 0); - if (!dd->has_alias(alias)) { - *pusLen = 0; - return fail(static_cast(openads::AE_TABLE_NOT_FOUND), - alias.c_str()); - } - if (!dd_can_view_object_metadata(c, alias)) { - *pusLen = 0; - return fail(openads::AE_ACCESS_DENIED, alias.c_str()); - } - - std::string rel = dd->resolve(alias); - - auto put_str = [&](const std::string& s) -> UNSIGNED32 { - UNSIGNED16 n = static_cast( - std::min(s.size(), cap)); - if (pBuf != nullptr && n > 0) std::memcpy(pBuf, s.data(), n); - *pusLen = static_cast(s.size()); - return ok(); - }; - auto put_u16 = [&](std::uint16_t v) -> UNSIGNED32 { - const UNSIGNED16 need = 2; - if (pBuf != nullptr && cap >= need) { - auto* b = static_cast(pBuf); - b[0] = static_cast(v & 0xFFu); - b[1] = static_cast(v >> 8); - } - *pusLen = need; - return ok(); - }; - auto put_u32 = [&](std::uint32_t v) -> UNSIGNED32 { - const UNSIGNED16 need = 4; - if (pBuf != nullptr && cap >= need) { - auto* b = static_cast(pBuf); - b[0] = static_cast( v & 0xFFu); - b[1] = static_cast((v >> 8) & 0xFFu); - b[2] = static_cast((v >> 16) & 0xFFu); - b[3] = static_cast((v >> 24) & 0xFFu); - } - *pusLen = need; - return ok(); - }; - auto prop_u16 = [&](UNSIGNED16 prop, std::uint16_t fallback = 0) -> std::uint16_t { - // RCB 06/27/2026: DD table options are stored as decimal strings so - // PHP/JS tools can set them without having to marshal raw ACE bytes. - std::string raw = dd->get_table_property(alias, static_cast(prop)); - if (raw.empty()) return fallback; - try { - auto v = std::stoul(raw); - return static_cast(std::min(v, 65535ul)); - } catch (...) { - if (raw.size() >= 2) { - const auto* b = reinterpret_cast(raw.data()); - return static_cast(b[0] | (static_cast(b[1]) << 8)); - } - return fallback; - } - }; - - switch (usProp) { - case ADS_DD_TABLE_RELATIVE_PATH: // 211 - return put_str(rel); - - case ADS_DD_TABLE_PATH: { // 205 -- absolute path - fs::path abs = fs::path(c->data_dir()) / rel; - return put_str(abs.string()); - } - - case ADS_DD_TABLE_TYPE: { // 204 -- infer from extension - fs::path p(rel); - std::string ext = p.extension().string(); - for (auto& ch : ext) - ch = static_cast( - std::tolower(static_cast(ch))); - UNSIGNED16 ttype = ADS_CDX; - if (ext == ".adt") ttype = ADS_ADT; - return put_u16(ttype); - } - - case ADS_DD_TABLE_CHAR_TYPE: // 212 - return put_u16(ADS_ANSI); - - case ADS_DD_TABLE_OBJ_ID: // 208 - return put_u32(0); - - case ADS_DD_TABLE_FIELD_COUNT: // 206 -- requires opening table - return put_u32(0); - - case ADS_DD_TABLE_AUTO_CREATE: // 203 - return put_u16(prop_u16(usProp)); - - case ADS_DD_TABLE_MEMO_BLOCK_SIZE: // 215 - return put_u16(prop_u16(usProp)); - - case ADS_DD_TABLE_CACHING: // 217 - return put_u16(prop_u16(usProp)); - - case ADS_DD_TABLE_ENCRYPTION: // 214 - return put_u16(prop_u16(usProp)); - - case ADS_DD_TABLE_TXN_FREE: // 218 - return put_u16(prop_u16(usProp)); - - case ADS_DD_TABLE_IS_RI_PARENT: // 210 - return put_u16(0); - - case ADS_DD_TABLE_PERMISSION_LEVEL: { // 216 - int lvl = prop_u16(usProp, 0); - if (lvl == 0) { - lvl = (c && !c->username().empty()) - ? dd->get_effective_permission(c->username(), alias) - : 4; - } - return put_u16(static_cast(lvl)); - } - - case ADS_DD_TABLE_VALIDATION_EXPR: // 200 - case ADS_DD_TABLE_VALIDATION_MSG: // 201 - return put_str(dd->get_table_property(alias, usProp)); - - case ADS_DD_TABLE_PRIMARY_KEY: // 202 - return put_str(dd->get_table_property(alias, 202)); - - case ADS_DD_TABLE_DEFAULT_INDEX: // 213 - return put_str(dd->get_table_property(alias, 213)); - - case ADS_DD_COMMENT: // 1 (generic object prop) - case ADS_DD_USER_DEFINED_PROP: // 3 (generic object prop) - return put_str(dd->get_table_property(alias, usProp == ADS_DD_COMMENT ? 1 : 3)); - - default: - *pusLen = 0; - return fail(openads::AE_FUNCTION_NOT_AVAILABLE, ""); - } -} - -UNSIGNED32 ENTRYPOINT AdsDDSetTableProperty(ADSHANDLE hConn, UNSIGNED8* pucTable, - UNSIGNED16 usProp, void* pBuf, - UNSIGNED16 usLen) { - arc2_trace("AdsDDSetTableProperty"); - if (auto* rc = get_remote_connection(hConn)) { - auto tname = openads::abi::to_internal(pucTable, 0); - std::string val = (pBuf != nullptr && usLen > 0) - ? std::string(reinterpret_cast(pBuf), usLen) : std::string(); - auto r = rc->dd_set_property(openads::network::DDObjectKind::Table, - tname, "", usProp, val); - if (!r) return fail(r.error()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - if (dd == nullptr) return ok(); - auto alias = openads::abi::to_internal(pucTable, 0); - if (!dd->has_alias(alias)) - return fail(static_cast(openads::AE_TABLE_NOT_FOUND), - alias.c_str()); - auto parse_u16_prop = [&](std::uint16_t& out) -> bool { - if (pBuf == nullptr || usLen == 0) { - out = 0; - return true; - } - std::string s(static_cast(pBuf), usLen); - while (!s.empty() && (s.back() == '\0' || - std::isspace(static_cast(s.back())))) { - s.pop_back(); - } - if (!s.empty() && std::all_of(s.begin(), s.end(), [](char ch) { - return std::isdigit(static_cast(ch)) != 0; - })) { - try { - auto v = std::stoul(s); - if (v > 65535ul) return false; - out = static_cast(v); - return true; - } catch (...) { - return false; - } - } - if (usLen == 2) { - const auto* b = static_cast(pBuf); - out = static_cast(b[0] | (static_cast(b[1]) << 8)); - return true; - } - return false; - }; - - // RCB 06/27/2026: Support SAP-style DD table properties for any client - // using the ACE-compatible table-property API. - if (usProp == ADS_DD_COMMENT || - usProp == ADS_DD_USER_DEFINED_PROP || - usProp == ADS_DD_TABLE_VALIDATION_EXPR || - usProp == ADS_DD_TABLE_VALIDATION_MSG || - usProp == ADS_DD_TABLE_PRIMARY_KEY || - usProp == ADS_DD_TABLE_DEFAULT_INDEX) { - std::string val; - if (pBuf != nullptr && usLen > 0) - val.assign(static_cast(pBuf), usLen); - while (!val.empty() && val.back() == '\0') val.pop_back(); - int stored_prop = static_cast(usProp); // generic ids 1/3 - // store as-is now - dd->set_table_property(alias, stored_prop, val); - if (auto r = dd->save(); !r) return fail(r.error()); - return ok(); - } - if (usProp == ADS_DD_TABLE_AUTO_CREATE || - usProp == ADS_DD_TABLE_ENCRYPTION || - usProp == ADS_DD_TABLE_MEMO_BLOCK_SIZE || - usProp == ADS_DD_TABLE_PERMISSION_LEVEL || - usProp == ADS_DD_TABLE_CACHING || - usProp == ADS_DD_TABLE_TXN_FREE) { - std::uint16_t v = 0; - if (!parse_u16_prop(v)) - return fail(static_cast(AE_VALUE_OVERFLOW), - "AdsDDSetTableProperty"); - if (usProp == ADS_DD_TABLE_AUTO_CREATE || - usProp == ADS_DD_TABLE_ENCRYPTION || - usProp == ADS_DD_TABLE_TXN_FREE) { - v = (v == 0) ? 0 : 1; - } else if (usProp == ADS_DD_TABLE_CACHING && v > ADS_TABLE_CACHE_WRITES) { - return fail(static_cast(AE_VALUE_OVERFLOW), - "AdsDDSetTableProperty"); - } else if (usProp == ADS_DD_TABLE_PERMISSION_LEVEL && v > 3) { - return fail(static_cast(AE_VALUE_OVERFLOW), - "AdsDDSetTableProperty"); - } - dd->set_table_property(alias, static_cast(usProp), std::to_string(v)); - if (auto r = dd->save(); !r) return fail(r.error()); - return ok(); - } - return fail(static_cast(openads::AE_FUNCTION_NOT_AVAILABLE), - "AdsDDSetTableProperty"); -} - -UNSIGNED32 ENTRYPOINT AdsDDSetUserTableRights(ADSHANDLE hConn, UNSIGNED8* pucTable, - UNSIGNED8* pucUser, UNSIGNED32 ulLevel) { - arc2_trace("AdsDDSetUserTableRights"); - if (auto* rc = get_remote_connection(hConn)) { - auto tname = openads::abi::to_internal(pucTable, 0); - auto uname = pucUser ? openads::abi::to_internal(pucUser, 0) : ""; - std::string val(4, '\0'); - val[0] = static_cast( ulLevel & 0xFFu); - val[1] = static_cast((ulLevel >> 8) & 0xFFu); - val[2] = static_cast((ulLevel >> 16) & 0xFFu); - val[3] = static_cast((ulLevel >> 24) & 0xFFu); - auto r = rc->dd_set_property(openads::network::DDObjectKind::UserTableRights, - tname, uname, 0, val); - if (!r) return fail(r.error()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - if (dd == nullptr) return ok(); - auto tbl = openads::abi::to_internal(pucTable, 0); - auto user = pucUser ? openads::abi::to_internal(pucUser, 0) : std::string{}; - if (tbl.empty() || user.empty()) - return fail(openads::AE_INTERNAL_ERROR, "table or user is empty"); - auto r = dd->set_table_permission(tbl, user, static_cast(ulLevel)); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsDDGetUserTableRights(ADSHANDLE hConn, UNSIGNED8* pucTable, - UNSIGNED8* pucUser, UNSIGNED32* pulLevel) { - arc2_trace("AdsDDGetUserTableRights"); - if (pulLevel == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (auto* rc = get_remote_connection(hConn)) { - auto tname = openads::abi::to_internal(pucTable, 0); - auto uname = pucUser ? openads::abi::to_internal(pucUser, 0) : ""; - auto r = rc->dd_get_property(openads::network::DDObjectKind::UserTableRights, - tname, uname, 0); - if (!r || r.value().size() < 4) { *pulLevel = 4; return ok(); } - const auto& v = r.value(); - *pulLevel = static_cast(static_cast(v[0])) | - (static_cast(static_cast(v[1])) << 8) | - (static_cast(static_cast(v[2])) << 16) | - (static_cast(static_cast(v[3])) << 24); - return ok(); - } - Connection* c = conn_from_handle(hConn); - if (c == nullptr || !c->has_dd()) { *pulLevel = 4; return ok(); } - auto tbl = openads::abi::to_internal(pucTable, 0); - auto user = pucUser ? openads::abi::to_internal(pucUser, 0) : std::string{}; - *pulLevel = static_cast( - c->dd()->get_effective_permission(user, tbl)); - return ok(); -} - -// --------------------------------------------------------------------------- -// AdsDDGetFieldProperty / AdsDDSetFieldProperty -// --------------------------------------------------------------------------- - -UNSIGNED32 ENTRYPOINT AdsDDGetFieldProperty(ADSHANDLE hConn, UNSIGNED8* pucTable, - UNSIGNED8* pucField, UNSIGNED16 usProp, - void* pBuf, UNSIGNED16* pusLen) { - arc2_trace("AdsDDGetFieldProperty"); - if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (auto* rc = get_remote_connection(hConn)) { - auto tname = openads::abi::to_internal(pucTable, 0); - auto fname = openads::abi::to_internal(pucField, 0); - auto r = rc->dd_get_property(openads::network::DDObjectKind::Field, - tname, fname, usProp); - UNSIGNED16 cap = *pusLen; - if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); - if (!r) { *pusLen = 0; return fail(r.error()); } - UNSIGNED16 n = static_cast(std::min(r.value().size(), cap)); - if (pBuf != nullptr && n > 0) std::memcpy(pBuf, r.value().data(), n); - *pusLen = static_cast(r.value().size()); - return ok(); - } - Connection* c = conn_from_handle(hConn); - UNSIGNED16 cap = *pusLen; - if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); - - auto put_str = [&](const std::string& s) -> UNSIGNED32 { - UNSIGNED16 n = static_cast( - std::min(s.size(), cap)); - if (pBuf != nullptr && n > 0) std::memcpy(pBuf, s.data(), n); - *pusLen = static_cast(s.size()); - return ok(); - }; - - auto* dd = (c != nullptr && c->has_dd()) ? c->dd() : nullptr; - if (dd == nullptr) { -#if defined(OPENADS_WITH_POSTGRESQL) - // No Advantage Data Dictionary on this connection: a SQL backend with a - // live catalog (PostgreSQL information_schema) can still answer - // CAN_NULL (UNSIGNED16, SAP shape) and DEFAULT_VALUE per field. - if (usProp == ADS_DD_FIELD_CAN_NULL || - usProp == ADS_DD_FIELD_DEFAULT_VALUE) { - if (auto* pc = state().registry - .lookup( - hConn, HandleKind::PostgresConnection)) { - openads::sql_backend::PostgresTable probe; - probe.name = openads::abi::to_internal(pucTable, 0); - auto fr = pc->describe_table(&probe); - if (fr) { - const auto want = openads::abi::to_internal(pucField, 0); - for (const auto& fd2 : fr.value()) { - if (fd2.name.size() == want.size() && - std::equal(fd2.name.begin(), fd2.name.end(), - want.begin(), [](char a, char b) { - return std::toupper((unsigned char) a) == - std::toupper((unsigned char) b); - })) { - if (usProp == ADS_DD_FIELD_CAN_NULL) { - if (pBuf != nullptr && cap >= 2) { - auto* b = static_cast(pBuf); - b[0] = fd2.nullable ? 1 : 0; - b[1] = 0; - } - *pusLen = 2; - return ok(); - } - if (fd2.default_value.empty()) { - *pusLen = 0; - return fail(openads::AE_PROPERTY_NOT_SET, - want.c_str()); - } - return put_str(fd2.default_value); - } - } - } - } - } -#endif - *pusLen = 0; - return ok(); - } - - auto alias = openads::abi::to_internal(pucTable, 0); - auto field = openads::abi::to_internal(pucField, 0); - if (!dd->has_alias(alias)) { - *pusLen = 0; - return fail(static_cast(openads::AE_TABLE_NOT_FOUND), alias.c_str()); - } - if (!dd_can_view_object_metadata(c, alias)) { - *pusLen = 0; - return fail(openads::AE_ACCESS_DENIED, alias.c_str()); - } - - auto put_u16 = [&](std::uint16_t v) -> UNSIGNED32 { - if (pBuf != nullptr && cap >= 2) { - auto* b = static_cast(pBuf); - b[0] = static_cast(v & 0xFFu); - b[1] = static_cast(v >> 8); - } - *pusLen = 2; - return ok(); - }; - - // Structural properties: open the table briefly, read the field - // descriptor. SAP ABI (ace_adsddgetfieldproperty.htm): TYPE / LENGTH / - // DECIMAL are UNSIGNED16; DEFINITION is the field-definition text. - if (usProp == ADS_DD_FIELD_TYPE || usProp == ADS_DD_FIELD_LENGTH || - usProp == ADS_DD_FIELD_DECIMAL || usProp == ADS_DD_FIELD_DEFINITION) { - - std::string rel = dd->resolve(alias); - auto th = c->open_table(rel, openads::engine::TableType::Cdx, - openads::engine::OpenMode::Read); - if (!th) { *pusLen = 0; return fail(th.error()); } - - auto* tbl = c->lookup_table(th.value()); - UNSIGNED32 ret = ok(); - if (tbl != nullptr) { - std::int32_t fi = tbl->field_index(field); - if (fi < 0) { - ret = fail(static_cast(openads::AE_NO_FILE_FOUND), field.c_str()); - } else { - const auto& fd = tbl->field_descriptor(static_cast(fi)); - if (usProp == ADS_DD_FIELD_TYPE) { - ret = put_u16(map_field_type(fd.type)); - } else if (usProp == ADS_DD_FIELD_LENGTH) { - ret = put_u16(static_cast(fd.length)); - } else if (usProp == ADS_DD_FIELD_DECIMAL) { - ret = put_u16(static_cast(fd.decimals)); - } else { // ADS_DD_FIELD_DEFINITION -- "name, type, len, dec" - ret = put_str(fd.name + "," + - std::to_string(map_field_type(fd.type)) + - "," + std::to_string(fd.length) + "," + - std::to_string(fd.decimals)); - } - } - } - c->close_table(th.value()); - return ret; - } - - // CAN_NULL: UNSIGNED16, non-zero = a NULL value is allowed (SAP shape). - // The stored "required" flag is the inverse. - if (usProp == ADS_DD_FIELD_CAN_NULL) { - bool required = dd->get_field_property(alias, field, "required") == "T"; - return put_u16(required ? 0 : 1); - } - - // Stored string properties from field_props_. DEFAULT_VALUE / MIN / - // MAX return AE_PROPERTY_NOT_SET when absent (SAP behavior). - std::string key; - bool not_set_when_empty = false; - switch (usProp) { - case ADS_DD_FIELD_DEFAULT_VALUE: - key = "default"; not_set_when_empty = true; break; - case ADS_DD_FIELD_MIN_VALUE: - key = "min"; not_set_when_empty = true; break; - case ADS_DD_FIELD_MAX_VALUE: - key = "max"; not_set_when_empty = true; break; - case ADS_DD_FIELD_VALIDATION_MSG: key = "msg"; break; - case ADS_DD_OA_FIELD_VALIDATION_RULE: key = "rule"; break; - case ADS_DD_COMMENT: key = "comment"; break; - default: - *pusLen = 0; - return fail(openads::AE_INVALID_PROPERTY_ID, ""); - } - std::string val = dd->get_field_property(alias, field, key); - if (val.empty() && not_set_when_empty) { - *pusLen = 0; - return fail(openads::AE_PROPERTY_NOT_SET, field.c_str()); - } - return put_str(val); -} - -UNSIGNED32 ENTRYPOINT AdsDDSetFieldProperty(ADSHANDLE hConn, UNSIGNED8* pucTable, - UNSIGNED8* pucField, UNSIGNED16 usProp, - void* pBuf, UNSIGNED16 usLen) { - arc2_trace("AdsDDSetFieldProperty"); - if (auto* rc = get_remote_connection(hConn)) { - auto tname = openads::abi::to_internal(pucTable, 0); - auto fname = openads::abi::to_internal(pucField, 0); - std::string val = (pBuf != nullptr && usLen > 0) - ? std::string(reinterpret_cast(pBuf), usLen) : std::string(); - auto r = rc->dd_set_property(openads::network::DDObjectKind::Field, - tname, fname, usProp, val); - if (!r) return fail(r.error()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - if (dd == nullptr) return ok(); - auto alias = openads::abi::to_internal(pucTable, 0); - auto field = openads::abi::to_internal(pucField, 0); - if (!dd->has_alias(alias)) - return fail(static_cast(openads::AE_TABLE_NOT_FOUND), alias.c_str()); - - // Structural props (TYPE / LENGTH / DECIMAL / DEFINITION) are read-only. - if (usProp == ADS_DD_FIELD_TYPE || usProp == ADS_DD_FIELD_LENGTH || - usProp == ADS_DD_FIELD_DECIMAL || usProp == ADS_DD_FIELD_DEFINITION) - return fail(openads::AE_FUNCTION_NOT_AVAILABLE, "read-only field prop"); - - // CAN_NULL: SAP passes an UNSIGNED16 (non-zero = nullable). Text - // spellings (T/F, True/False) are tolerated for OA's own callers. - if (usProp == ADS_DD_FIELD_CAN_NULL) { - bool can_null = true; - if (pBuf != nullptr && usLen == 2) { - const auto* b = static_cast(pBuf); - can_null = (static_cast(b[0]) | - (static_cast(b[1]) << 8)) != 0; - } else if (pBuf != nullptr && usLen > 0) { - char c0 = static_cast(pBuf)[0]; - can_null = !(c0 == 'F' || c0 == 'f' || c0 == '0'); - } - auto r = dd->set_field_property(alias, field, "required", - can_null ? "" : "T"); - if (!r) return fail(r.error()); - return ok(); - } - - std::string key; - switch (usProp) { - case ADS_DD_FIELD_DEFAULT_VALUE: key = "default"; break; - case ADS_DD_FIELD_MIN_VALUE: key = "min"; break; - case ADS_DD_FIELD_MAX_VALUE: key = "max"; break; - case ADS_DD_FIELD_VALIDATION_MSG: key = "msg"; break; - case ADS_DD_OA_FIELD_VALIDATION_RULE: key = "rule"; break; - case ADS_DD_COMMENT: key = "comment"; break; - default: - return fail(openads::AE_INVALID_PROPERTY_ID, ""); - } - std::string val = pBuf && usLen > 0 - ? std::string(static_cast(pBuf), usLen) : std::string{}; - auto r = dd->set_field_property(alias, field, key, val); - if (!r) return fail(r.error()); - return ok(); -} - -// --------------------------------------------------------------------------- -// AdsDDGetIndexProperty / AdsDDSetIndexProperty -// --------------------------------------------------------------------------- - -UNSIGNED32 ENTRYPOINT AdsDDGetIndexProperty(ADSHANDLE hConn, UNSIGNED8* pucTable, - UNSIGNED8* pucIndex, UNSIGNED16 usProp, - void* pBuf, UNSIGNED16* pusLen) { - arc2_trace("AdsDDGetIndexProperty"); - if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (auto* rc = get_remote_connection(hConn)) { - auto tname = openads::abi::to_internal(pucTable, 0); - auto iname = openads::abi::to_internal(pucIndex, 0); - auto r = rc->dd_get_property(openads::network::DDObjectKind::Index, - tname, iname, usProp); - UNSIGNED16 cap = *pusLen; - if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); - if (!r) { *pusLen = 0; return fail(r.error()); } - UNSIGNED16 n = static_cast(std::min(r.value().size(), cap)); - if (pBuf != nullptr && n > 0) std::memcpy(pBuf, r.value().data(), n); - *pusLen = static_cast(r.value().size()); - return ok(); - } - Connection* c = conn_from_handle(hConn); - UNSIGNED16 cap = *pusLen; - if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); - - auto put_str = [&](const std::string& s) -> UNSIGNED32 { - UNSIGNED16 n = static_cast(std::min(s.size(), cap)); - if (pBuf != nullptr && n > 0) std::memcpy(pBuf, s.data(), n); - *pusLen = static_cast(s.size()); - return ok(); - }; - auto put_u16 = [&](std::uint16_t v) -> UNSIGNED32 { - if (pBuf != nullptr && cap >= 2) { - auto* b = static_cast(pBuf); - b[0] = static_cast(v & 0xFFu); - b[1] = static_cast(v >> 8); - } - *pusLen = 2; return ok(); - }; - - // Look in index_bindings() for a binding with this tag name that - // belongs to a table matching pucTable (alias or open table). - auto idx_name = openads::abi::to_internal(pucIndex, 0); - auto tbl_name = pucTable ? openads::abi::to_internal(pucTable, 0) : std::string{}; - - ADSHANDLE idx_h = 0; - openads::drivers::IIndex* found_idx = nullptr; - std::string found_path; - - // Storm fix: reader must hold index_bindings_mu (binds are unlocked now). - std::lock_guard _dd_lk(index_bindings_mu()); - for (auto& [h, b] : index_bindings()) { - if (b.tag_name != idx_name) continue; - // If a table name is given, check that the binding's table is that table. - if (!tbl_name.empty() && c != nullptr) { - if (!c->owns_table_ptr(b.table)) continue; - } - idx_h = h; - found_path = b.path; - found_idx = iindex_for_handle(h); - break; - } - - if (idx_h == 0) { - *pusLen = 0; - return fail(openads::AE_NO_FILE_FOUND, idx_name.c_str()); - } - if (found_idx == nullptr) { *pusLen = 0; return ok(); } - - switch (usProp) { - case ADS_DD_INDEX_FILE_NAME: return put_str(found_path); - case ADS_DD_INDEX_EXPRESSION: return put_str(found_idx->expression()); - case ADS_DD_INDEX_CONDITION: return put_str({}); // not exposed by IIndex - case ADS_DD_INDEX_OPTIONS: { - // SAP shape: UNSIGNED32 bitmask of ADS_UNIQUE / ADS_COMPOUND / - // ADS_CUSTOM / ADS_DESCENDING. - UNSIGNED32 opts = 0; - if (found_idx->unique()) opts |= ADS_UNIQUE; - if (found_idx->descending()) opts |= ADS_DESCENDING; - if (pBuf != nullptr && cap >= 4) { - auto* b = static_cast(pBuf); - b[0] = static_cast( opts & 0xFFu); - b[1] = static_cast((opts >> 8) & 0xFFu); - b[2] = static_cast((opts >> 16) & 0xFFu); - b[3] = static_cast((opts >> 24) & 0xFFu); - } - *pusLen = 4; - return ok(); - } - case ADS_DD_INDEX_KEY_LENGTH: return put_u16(found_idx->key_length()); - case ADS_DD_INDEX_KEY_TYPE: { - // SAP shape: UNSIGNED16 ADS data type of the key (see - // AdsGetKeyType) -- numeric-encoded keys report ADS_NUMERIC, - // everything else ADS_STRING. - using KE = openads::drivers::KeyEncoding; - bool numeric = - found_idx->key_encoding() == KE::FoxNumeric || - found_idx->key_encoding() == KE::NtxNumeric; - return put_u16(numeric ? ADS_NUMERIC : ADS_STRING); - } - default: - *pusLen = 0; - return fail(openads::AE_INVALID_PROPERTY_ID, ""); - } -} - -UNSIGNED32 ENTRYPOINT AdsDDSetIndexProperty(ADSHANDLE /*hConn*/, UNSIGNED8* /*pucTable*/, - UNSIGNED8* /*pucIndex*/, UNSIGNED16 /*usProp*/, - void* /*pBuf*/, UNSIGNED16 /*usLen*/) { - arc2_trace("AdsDDSetIndexProperty"); - return fail(openads::AE_FUNCTION_NOT_AVAILABLE, "AdsDDSetIndexProperty"); -} - -// --------------------------------------------------------------------------- -// AdsDDCreateTrigger / AdsDDDropTrigger / AdsDDGet/SetTriggerProperty -// --------------------------------------------------------------------------- - -UNSIGNED32 ENTRYPOINT AdsDDCreateTrigger(ADSHANDLE hConn, UNSIGNED8* pucName, - UNSIGNED8* pucTable, UNSIGNED32 ulType, - UNSIGNED32 /*ulOptions*/, UNSIGNED8* pucContainer, - UNSIGNED8* pucProcedure, UNSIGNED32 ulPriority) { - arc2_trace("AdsDDCreateTrigger"); - if (auto* rc = get_remote_connection(hConn)) { - auto r = rc->dd_create_trigger( - openads::abi::to_internal(pucName, 0), - openads::abi::to_internal(pucTable, 0), ulType, - pucContainer ? openads::abi::to_internal(pucContainer, 0) : "", - pucProcedure ? openads::abi::to_internal(pucProcedure, 0) : "", - ulPriority); - if (!r) return fail(r.error()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - if (dd == nullptr) return ok(); - openads::engine::DataDict::TriggerEntry e; - e.name = openads::abi::to_internal(pucName, 0); - e.table_alias = openads::abi::to_internal(pucTable, 0); - // Decode combined ADS trigger type constant into internal (event_mask, timing). - // ADS_BEFORE_INSERT=0x0001 ADS_AFTER_INSERT=0x0002 ADS_BEFORE_UPDATE=0x0004 - // ADS_AFTER_UPDATE=0x0008 ADS_BEFORE_DELETE=0x0010 ADS_AFTER_DELETE=0x0020 - // ADS_INSTEAD_OF_INSERT=0x0040 ADS_INSTEAD_OF_UPDATE=0x0080 - // ADS_INSTEAD_OF_DELETE=0x0100 - switch (ulType) { - case 0x0001: e.event_mask = 1; e.timing = 1; break; // BEFORE INSERT - case 0x0002: e.event_mask = 1; e.timing = 4; break; // AFTER INSERT - case 0x0040: e.event_mask = 1; e.timing = 2; break; // INSTEAD OF INSERT - case 0x0004: e.event_mask = 2; e.timing = 1; break; // BEFORE UPDATE - case 0x0008: e.event_mask = 2; e.timing = 4; break; // AFTER UPDATE - case 0x0080: e.event_mask = 2; e.timing = 2; break; // INSTEAD OF UPDATE - case 0x0010: e.event_mask = 3; e.timing = 1; break; // BEFORE DELETE - case 0x0020: e.event_mask = 3; e.timing = 4; break; // AFTER DELETE - case 0x0100: e.event_mask = 3; e.timing = 2; break; // INSTEAD OF DELETE - default: - return fail(openads::AE_INTERNAL_ERROR, "unknown trigger type"); - } - e.container = pucContainer ? openads::abi::to_internal(pucContainer, 0) : ""; - e.procedure = pucProcedure ? openads::abi::to_internal(pucProcedure, 0) : ""; - e.priority = ulPriority; - e.enabled = true; - if (e.name.empty() || e.table_alias.empty()) - return fail(openads::AE_INTERNAL_ERROR, "trigger name/table empty"); - auto r = dd->create_trigger(e); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsDDDropTrigger(ADSHANDLE hConn, UNSIGNED8* pucName) { - arc2_trace("AdsDDDropTrigger"); - if (auto* rc = get_remote_connection(hConn)) { - auto r = rc->dd_drop_trigger(openads::abi::to_internal(pucName, 0)); - if (!r) return fail(r.error()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - if (dd == nullptr) return ok(); - auto name = openads::abi::to_internal(pucName, 0); - if (!dd->has_trigger(name)) - return fail(static_cast(openads::AE_NO_FILE_FOUND), name.c_str()); - auto r = dd->drop_trigger(name); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsDDGetTriggerProperty(ADSHANDLE hConn, UNSIGNED8* pucName, - UNSIGNED16 usProp, void* pBuf, - UNSIGNED16* pusLen) { - arc2_trace("AdsDDGetTriggerProperty"); - if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (auto* rc = get_remote_connection(hConn)) { - auto tname = openads::abi::to_internal(pucName, 0); - auto r = rc->dd_get_property(openads::network::DDObjectKind::Trigger, - tname, "", usProp); - UNSIGNED16 cap = *pusLen; - if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); - if (!r) { *pusLen = 0; return fail(r.error()); } - UNSIGNED16 n = static_cast(std::min(r.value().size(), cap)); - if (pBuf != nullptr && n > 0) std::memcpy(pBuf, r.value().data(), n); - *pusLen = static_cast(r.value().size()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - UNSIGNED16 cap = *pusLen; - if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); - if (dd == nullptr) { *pusLen = 0; return ok(); } - auto name = openads::abi::to_internal(pucName, 0); - const auto* ep = dd->find_trigger(name); - if (ep == nullptr) { - *pusLen = 0; - return fail(static_cast(openads::AE_NO_FILE_FOUND), name.c_str()); - } - const auto& e = *ep; - - auto put_str = [&](const std::string& s) -> UNSIGNED32 { - UNSIGNED16 n = static_cast(std::min(s.size(), cap)); - if (pBuf != nullptr && n > 0) std::memcpy(pBuf, s.data(), n); - *pusLen = static_cast(s.size()); - return ok(); - }; - auto put_u32 = [&](std::uint32_t v) -> UNSIGNED32 { - if (pBuf != nullptr && cap >= 4) { - auto* b = static_cast(pBuf); - b[0]=static_cast(v&0xFF); b[1]=static_cast((v>>8)&0xFF); b[2]=static_cast((v>>16)&0xFF); b[3]=static_cast((v>>24)&0xFF); - } - *pusLen = 4; return ok(); - }; - - switch (usProp) { - case ADS_DD_TRIGGER_TABLE: - case 1408: /* ADS_DD_TRIG_TABLENAME (SAP ACE) */ - return put_str(e.table_alias); - case ADS_DD_TRIGGER_EVENT: - { - // ABI callers (AdsDDCreateTrigger/SetTriggerProperty) use combined ADS type constants. - std::uint32_t combined = 0; - if (e.event_mask==1 && e.timing==1) combined = 0x0001; - else if (e.event_mask==1 && e.timing==4) combined = 0x0002; - else if (e.event_mask==1 && e.timing==2) combined = 0x0040; - else if (e.event_mask==2 && e.timing==1) combined = 0x0004; - else if (e.event_mask==2 && e.timing==4) combined = 0x0008; - else if (e.event_mask==2 && e.timing==2) combined = 0x0080; - else if (e.event_mask==3 && e.timing==1) combined = 0x0010; - else if (e.event_mask==3 && e.timing==4) combined = 0x0020; - else if (e.event_mask==3 && e.timing==2) combined = 0x0100; - return put_u32(combined); - } - case 1401: /* ADS_DD_TRIG_EVENT_TYPE (SAP ACE) -- 1=INSERT 2=UPDATE 3=DELETE */ - return put_u32(static_cast(e.event_mask)); - case 1402: /* ADS_DD_TRIG_TIMING (SAP ACE extension) */ - return put_u32(e.timing); - case ADS_DD_TRIGGER_CONTAINER: - case 1404: /* ADS_DD_TRIG_CONTAINER (SAP ACE) */ - return put_str(e.container); - case ADS_DD_TRIGGER_PROC_NAME: - case 1405: /* ADS_DD_TRIG_FUNCTION_NAME (SAP ACE) */ - return put_str(e.procedure); - case ADS_DD_TRIGGER_ENABLED: return put_u32(e.enabled ? 1u : 0u); - case ADS_DD_TRIGGER_PRIORITY: - case 1406: /* ADS_DD_TRIG_PRIORITY (SAP ACE) */ - return put_u32(e.priority); - case ADS_DD_TRIGGER_COMMENT: return put_str(e.comment); - case 1407: /* ADS_DD_TRIG_OPTIONS (SAP ACE): 0x01=WANT_VALUES 0x02=WANT_MEMOS 0x04=NO_TRANSACTION */ - return put_u32(e.options); - default: *pusLen = 0; return fail(openads::AE_FUNCTION_NOT_AVAILABLE, ""); - } -} - -UNSIGNED32 ENTRYPOINT AdsDDSetTriggerProperty(ADSHANDLE hConn, UNSIGNED8* pucName, - UNSIGNED16 usProp, void* pBuf, - UNSIGNED16 usLen) { - arc2_trace("AdsDDSetTriggerProperty"); - if (auto* rc = get_remote_connection(hConn)) { - auto tname = openads::abi::to_internal(pucName, 0); - std::string val = (pBuf != nullptr && usLen > 0) - ? std::string(reinterpret_cast(pBuf), usLen) : std::string(); - auto r = rc->dd_set_property(openads::network::DDObjectKind::Trigger, - tname, "", usProp, val); - if (!r) return fail(r.error()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - if (dd == nullptr) return ok(); - auto name = openads::abi::to_internal(pucName, 0); - auto* ep = dd->find_trigger(name); - if (ep == nullptr) - return fail(static_cast(openads::AE_NO_FILE_FOUND), name.c_str()); - auto& e = *ep; - std::string val = pBuf && usLen > 0 - ? std::string(static_cast(pBuf), usLen) : std::string{}; - // Helper: parse val as uint32 (numeric string or 4-byte LE binary). - auto parse_u32 = [&](std::uint32_t& out) { - if (usLen >= 4 && (val[0] < '0' || val[0] > '9')) { - // Binary 4-byte LE - auto* b = static_cast(pBuf); - out = static_cast(b[0]) | (static_cast(b[1]) << 8) | (static_cast(b[2]) << 16) | (static_cast(b[3]) << 24); - } else if (!val.empty()) { - try { out = static_cast(std::stoul(val)); } catch (...) {} - } - }; - switch (usProp) { - case ADS_DD_TRIGGER_TABLE: - case 1408: /* ADS_DD_TRIG_TABLENAME (SAP ACE) */ - e.table_alias = val; break; - case ADS_DD_TRIGGER_EVENT: - case 1401: /* ADS_DD_TRIG_EVENT_TYPE (SAP ACE) -- decode combined ADS constant */ - { - std::uint32_t combined = 0; - parse_u32(combined); - switch (combined) { - case 0x0001: e.event_mask=1; e.timing=1; break; - case 0x0002: e.event_mask=1; e.timing=4; break; - case 0x0040: e.event_mask=1; e.timing=2; break; - case 0x0004: e.event_mask=2; e.timing=1; break; - case 0x0008: e.event_mask=2; e.timing=4; break; - case 0x0080: e.event_mask=2; e.timing=2; break; - case 0x0010: e.event_mask=3; e.timing=1; break; - case 0x0020: e.event_mask=3; e.timing=4; break; - case 0x0100: e.event_mask=3; e.timing=2; break; - default: break; - } - break; - } - case 1402: /* timing: 1=BEFORE 2=INSTEAD_OF 4=AFTER */ - parse_u32(e.timing); break; - case ADS_DD_TRIGGER_CONTAINER: - case 1404: /* ADS_DD_TRIG_CONTAINER (SAP ACE) */ - e.container = val; break; - case ADS_DD_TRIGGER_PROC_NAME: - case 1405: /* ADS_DD_TRIG_FUNCTION_NAME (SAP ACE) */ - e.procedure = val; break; - case ADS_DD_TRIGGER_COMMENT: e.comment = val; break; - case ADS_DD_TRIGGER_ENABLED: { - std::uint32_t v = 0; - parse_u32(v); - // Also accept "T"/"F", "True"/"False", "1"/"0" as strings - if (val == "T" || val == "True" || val == "true" || val == "yes" || val == "Yes") v = 1; - if (val == "F" || val == "False" || val == "false" || val == "no" || val == "No") v = 0; - e.enabled = (v != 0); - break; - } - case ADS_DD_TRIGGER_PRIORITY: - case 1406: /* ADS_DD_TRIG_PRIORITY (SAP ACE) */ - parse_u32(e.priority); break; - case 1407: /* ADS_DD_TRIG_OPTIONS (SAP ACE): 0x01=WANT_VALUES 0x02=WANT_MEMOS 0x04=NO_TRANSACTION */ - parse_u32(e.options); break; - default: - return fail(openads::AE_FUNCTION_NOT_AVAILABLE, ""); - } - auto r = dd->save(); - if (!r) return fail(r.error()); - return ok(); -} - -// --------------------------------------------------------------------------- -// AdsDDCreateProcedure / AdsDDDropProcedure / AdsDDGet/SetProcProperty -// --------------------------------------------------------------------------- - -UNSIGNED32 ENTRYPOINT AdsDDCreateProcedure(ADSHANDLE hConn, UNSIGNED8* pucName, - UNSIGNED8* pucContainer, - UNSIGNED8* pucProcName, - UNSIGNED32 /*ulInvokeOption*/, - UNSIGNED8* pucInParams, - UNSIGNED8* pucOutParams, - UNSIGNED8* pucComments) { - arc2_trace("AdsDDCreateProcedure"); - if (auto* rc = get_remote_connection(hConn)) { - auto r = rc->dd_create_proc( - openads::abi::to_internal(pucName, 0), - pucContainer ? openads::abi::to_internal(pucContainer, 0) : "", - pucProcName ? openads::abi::to_internal(pucProcName, 0) : "", - pucInParams ? openads::abi::to_internal(pucInParams, 0) : "", - pucOutParams ? openads::abi::to_internal(pucOutParams, 0) : "", - pucComments ? openads::abi::to_internal(pucComments, 0) : ""); - if (!r) return fail(r.error()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - if (dd == nullptr) return ok(); - openads::engine::DataDict::ProcEntry e; - e.name = openads::abi::to_internal(pucName, 0); - e.container = pucContainer ? openads::abi::to_internal(pucContainer, 0) : ""; - e.procedure = pucProcName ? openads::abi::to_internal(pucProcName, 0) : ""; - e.input_params = pucInParams ? openads::abi::to_internal(pucInParams, 0) : ""; - e.output_params = pucOutParams ? openads::abi::to_internal(pucOutParams, 0) : ""; - e.comment = pucComments ? openads::abi::to_internal(pucComments, 0) : ""; - if (e.name.empty()) - return fail(openads::AE_INTERNAL_ERROR, "proc name empty"); - auto r = dd->create_proc(e); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsDDDropProcedure(ADSHANDLE hConn, UNSIGNED8* pucName) { - arc2_trace("AdsDDDropProcedure"); - if (auto* rc = get_remote_connection(hConn)) { - auto r = rc->dd_drop_object(openads::network::DDObjectKind::Proc, - openads::abi::to_internal(pucName, 0)); - if (!r) return fail(r.error()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - if (dd == nullptr) return ok(); - auto name = openads::abi::to_internal(pucName, 0); - if (!dd->has_proc(name)) - return fail(static_cast(openads::AE_NO_FILE_FOUND), name.c_str()); - auto r = dd->drop_proc(name); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsDDGetProcProperty(ADSHANDLE hConn, UNSIGNED8* pucName, - UNSIGNED16 usProp, void* pBuf, - UNSIGNED16* pusLen) { - arc2_trace("AdsDDGetProcProperty"); - if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (auto* rc = get_remote_connection(hConn)) { - auto pname = openads::abi::to_internal(pucName, 0); - auto r = rc->dd_get_property(openads::network::DDObjectKind::Proc, - pname, "", usProp); - UNSIGNED16 cap = *pusLen; - if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); - if (!r) { *pusLen = 0; return fail(r.error()); } - UNSIGNED16 n = static_cast(std::min(r.value().size(), cap)); - if (pBuf != nullptr && n > 0) std::memcpy(pBuf, r.value().data(), n); - *pusLen = static_cast(r.value().size()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - UNSIGNED16 cap = *pusLen; - if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); - if (dd == nullptr) { *pusLen = 0; return ok(); } - auto name = openads::abi::to_internal(pucName, 0); - if (!dd->has_proc(name)) { - *pusLen = 0; - return fail(static_cast(openads::AE_NO_FILE_FOUND), name.c_str()); - } - const auto& e = dd->procs().at(name); - auto put_str = [&](const std::string& s) -> UNSIGNED32 { - UNSIGNED16 n = static_cast(std::min(s.size(), cap)); - if (pBuf != nullptr && n > 0) std::memcpy(pBuf, s.data(), n); - *pusLen = static_cast(s.size()); - return ok(); - }; - switch (usProp) { - case ADS_DD_PROC_INPUT: - case 800: /* ADS_DD_PROC_INPUT (SAP ACE) */ - return put_str(e.input_params); - case ADS_DD_PROC_OUTPUT: - case 801: /* ADS_DD_PROC_OUTPUT (SAP ACE) */ - return put_str(e.output_params); - case ADS_DD_PROC_CONTAINER: - case 802: /* ADS_DD_PROC_DLL_NAME (SAP ACE) */ - return put_str(e.container); - case ADS_DD_PROC_PROC_NAME: - case 803: /* ADS_DD_PROC_DLL_FUNCTION_NAME (SAP ACE) */ - return put_str(e.procedure); - case ADS_DD_PROC_COMMENT: - case 805: /* ADS_DD_PROC_SCRIPT (SAP ACE) */ - return put_str(e.comment); - default: *pusLen = 0; return fail(openads::AE_FUNCTION_NOT_AVAILABLE, ""); - } -} - -UNSIGNED32 ENTRYPOINT AdsDDSetProcProperty(ADSHANDLE hConn, UNSIGNED8* pucName, - UNSIGNED16 usProp, void* pBuf, - UNSIGNED16 usLen) { - arc2_trace("AdsDDSetProcProperty"); - if (auto* rc = get_remote_connection(hConn)) { - auto pname = openads::abi::to_internal(pucName, 0); - std::string val = (pBuf != nullptr && usLen > 0) - ? std::string(reinterpret_cast(pBuf), usLen) : std::string(); - auto r = rc->dd_set_property(openads::network::DDObjectKind::Proc, - pname, "", usProp, val); - if (!r) return fail(r.error()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - if (dd == nullptr) return ok(); - auto name = openads::abi::to_internal(pucName, 0); - if (!dd->has_proc(name)) - return fail(static_cast(openads::AE_NO_FILE_FOUND), name.c_str()); - auto& e = dd->procs().at(name); - std::string val = pBuf && usLen > 0 - ? std::string(static_cast(pBuf), usLen) : std::string{}; - switch (usProp) { - case ADS_DD_PROC_INPUT: - case 800: /* ADS_DD_PROC_INPUT (SAP ACE) */ - e.input_params = val; break; - case ADS_DD_PROC_OUTPUT: - case 801: /* ADS_DD_PROC_OUTPUT (SAP ACE) */ - e.output_params = val; break; - case ADS_DD_PROC_CONTAINER: - case 802: /* ADS_DD_PROC_DLL_NAME (SAP ACE) */ - e.container = val; break; - case ADS_DD_PROC_PROC_NAME: - case 803: /* ADS_DD_PROC_DLL_FUNCTION_NAME (SAP ACE) */ - e.procedure = val; break; - case ADS_DD_PROC_COMMENT: - case 805: /* ADS_DD_PROC_SCRIPT (SAP ACE) */ - e.comment = val; break; - default: return fail(openads::AE_FUNCTION_NOT_AVAILABLE, ""); - } - auto r = dd->save(); - if (!r) return fail(r.error()); - return ok(); -} - -// --------------------------------------------------------------------------- -// AdsDDCreateFunction / AdsDDDropFunction / AdsDDGet/SetFunctionProperty -// Property codes: 700=body(implementation), 701=input_params, 702=return_type, -// 703=container, 704=comment -// --------------------------------------------------------------------------- - -UNSIGNED32 ENTRYPOINT AdsDDCreateFunction(ADSHANDLE hConn, UNSIGNED8* pucName, - UNSIGNED8* pucContainer, - UNSIGNED8* pucImplementation, - UNSIGNED8* pucRetType, - UNSIGNED8* pucInParams, - UNSIGNED8* pucComment) { - arc2_trace("AdsDDCreateFunction"); - if (auto* rc = get_remote_connection(hConn)) { - auto r = rc->dd_create_function( - openads::abi::to_internal(pucName, 0), - pucContainer ? openads::abi::to_internal(pucContainer, 0) : "", - pucImplementation ? openads::abi::to_internal(pucImplementation, 0) : "", - pucRetType ? openads::abi::to_internal(pucRetType, 0) : "", - pucInParams ? openads::abi::to_internal(pucInParams, 0) : "", - pucComment ? openads::abi::to_internal(pucComment, 0) : ""); - if (!r) return fail(r.error()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - if (dd == nullptr) return ok(); - openads::engine::DataDict::FunctionEntry e; - e.name = openads::abi::to_internal(pucName, 0); - e.container = pucContainer ? openads::abi::to_internal(pucContainer, 0) : ""; - e.implementation = pucImplementation ? openads::abi::to_internal(pucImplementation, 0) : ""; - e.return_type = pucRetType ? openads::abi::to_internal(pucRetType, 0) : ""; - e.input_params = pucInParams ? openads::abi::to_internal(pucInParams, 0) : ""; - e.comment = pucComment ? openads::abi::to_internal(pucComment, 0) : ""; - if (e.name.empty()) - return fail(openads::AE_INTERNAL_ERROR, "function name empty"); - auto r = dd->create_function(e); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsDDDropFunction(ADSHANDLE hConn, UNSIGNED8* pucName) { - arc2_trace("AdsDDDropFunction"); - if (auto* rc = get_remote_connection(hConn)) { - auto r = rc->dd_drop_object(openads::network::DDObjectKind::Function, - openads::abi::to_internal(pucName, 0)); - if (!r) return fail(r.error()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - if (dd == nullptr) return ok(); - auto name = openads::abi::to_internal(pucName, 0); - if (!dd->has_function(name)) - return fail(static_cast(openads::AE_NO_FILE_FOUND), name.c_str()); - auto r = dd->drop_function(name); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsDDGetFunctionProperty(ADSHANDLE hConn, UNSIGNED8* pucName, - UNSIGNED16 usProp, void* pBuf, - UNSIGNED16* pusLen) { - arc2_trace("AdsDDGetFunctionProperty"); - if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (auto* rc = get_remote_connection(hConn)) { - auto fname = openads::abi::to_internal(pucName, 0); - auto r = rc->dd_get_property(openads::network::DDObjectKind::Function, - fname, "", usProp); - UNSIGNED16 cap = *pusLen; - if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); - if (!r) { *pusLen = 0; return fail(r.error()); } - UNSIGNED16 n = static_cast(std::min(r.value().size(), cap)); - if (pBuf != nullptr && n > 0) std::memcpy(pBuf, r.value().data(), n); - *pusLen = static_cast(r.value().size()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - UNSIGNED16 cap = *pusLen; - if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); - if (dd == nullptr) { *pusLen = 0; return ok(); } - auto name = openads::abi::to_internal(pucName, 0); - if (!dd->has_function(name)) { - *pusLen = 0; - return fail(static_cast(openads::AE_NO_FILE_FOUND), name.c_str()); - } - const auto& e = dd->functions().at(name); - auto put_str = [&](const std::string& s) -> UNSIGNED32 { - UNSIGNED16 n = static_cast(std::min(s.size(), cap)); - if (pBuf != nullptr && n > 0) std::memcpy(pBuf, s.data(), n); - *pusLen = static_cast(s.size()); - return ok(); - }; - switch (usProp) { - case 700: return put_str(e.implementation); - case 701: return put_str(e.input_params); - case 702: return put_str(e.return_type); - case 703: return put_str(e.container); - case 704: return put_str(e.comment); - default: *pusLen = 0; return fail(openads::AE_FUNCTION_NOT_AVAILABLE, ""); - } -} - -UNSIGNED32 ENTRYPOINT AdsDDSetFunctionProperty(ADSHANDLE hConn, UNSIGNED8* pucName, - UNSIGNED16 usProp, void* pBuf, - UNSIGNED16 usLen) { - arc2_trace("AdsDDSetFunctionProperty"); - if (auto* rc = get_remote_connection(hConn)) { - auto fname = openads::abi::to_internal(pucName, 0); - std::string val = (pBuf != nullptr && usLen > 0) - ? std::string(reinterpret_cast(pBuf), usLen) : std::string(); - auto r = rc->dd_set_property(openads::network::DDObjectKind::Function, - fname, "", usProp, val); - if (!r) return fail(r.error()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - if (dd == nullptr) return ok(); - auto name = openads::abi::to_internal(pucName, 0); - if (!dd->has_function(name)) - return fail(static_cast(openads::AE_NO_FILE_FOUND), name.c_str()); - auto& e = dd->functions().at(name); - std::string val = pBuf && usLen > 0 - ? std::string(static_cast(pBuf), usLen) : std::string{}; - switch (usProp) { - case 700: e.implementation = val; break; - case 701: e.input_params = val; break; - case 702: e.return_type = val; break; - case 703: e.container = val; break; - case 704: e.comment = val; break; - default: return fail(openads::AE_FUNCTION_NOT_AVAILABLE, ""); - } - auto r = dd->save(); - if (!r) return fail(r.error()); - return ok(); -} - -// --------------------------------------------------------------------------- -// AdsDDCreateView / AdsDDDropView / AdsDDGet/SetViewProperty -// --------------------------------------------------------------------------- - -UNSIGNED32 ENTRYPOINT AdsDDCreateView(ADSHANDLE hConn, UNSIGNED8* pucName, - UNSIGNED8* pucComments, UNSIGNED8* pucSQL) { - arc2_trace("AdsDDCreateView"); - if (auto* rc = get_remote_connection(hConn)) { - auto r = rc->dd_create_view( - openads::abi::to_internal(pucName, 0), - pucComments ? openads::abi::to_internal(pucComments, 0) : "", - pucSQL ? openads::abi::to_internal(pucSQL, 0) : ""); - if (!r) return fail(r.error()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - if (dd == nullptr) return ok(); - openads::engine::DataDict::ViewEntry e; - e.name = openads::abi::to_internal(pucName, 0); - e.comment = pucComments ? openads::abi::to_internal(pucComments, 0) : ""; - e.sql = pucSQL ? openads::abi::to_internal(pucSQL, 0) : ""; - if (e.name.empty()) - return fail(openads::AE_INTERNAL_ERROR, "view name empty"); - auto r = dd->create_view(e); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsDDDropView(ADSHANDLE hConn, UNSIGNED8* pucName) { - arc2_trace("AdsDDDropView"); - if (auto* rc = get_remote_connection(hConn)) { - auto r = rc->dd_drop_view(openads::abi::to_internal(pucName, 0)); - if (!r) return fail(r.error()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - if (dd == nullptr) return ok(); - auto name = openads::abi::to_internal(pucName, 0); - if (!dd->has_view(name)) - return fail(static_cast(openads::AE_NO_FILE_FOUND), name.c_str()); - auto r = dd->drop_view(name); - if (!r) return fail(r.error()); - return ok(); -} - -// --------------------------------------------------------------------------- -// SAP ACE aliases not yet covered above -// AdsDDAddView / AdsDDRemoveView -- thin aliases for Create/Drop. -// AdsDDGetPermissions / AdsDDGrantPermission / AdsDDRevokePermission -- -// fine-grained object ACL helpers used by the php_advantage extension. -// --------------------------------------------------------------------------- - -static const char* dd_type_name_from_code(UNSIGNED16 code) { - switch (code) { - case 1: return "Table"; - case 4: return "Field"; - case 6: return "View"; - case 8: return "User"; - case 9: return "Group"; - case 10: return "StoredProc"; - case 11: return "Database"; - case 12: return "Link"; - case 18: return "Function"; - default: return "Table"; - } -} - -UNSIGNED32 ENTRYPOINT AdsDDAddView(ADSHANDLE hConn, UNSIGNED8* pucName, - UNSIGNED8* pucComments, UNSIGNED8* pucSQL) { - arc2_trace("AdsDDAddView"); - return AdsDDCreateView(hConn, pucName, pucComments, pucSQL); -} - -UNSIGNED32 ENTRYPOINT AdsDDRemoveView(ADSHANDLE hConn, UNSIGNED8* pucName) { - arc2_trace("AdsDDRemoveView"); - return AdsDDDropView(hConn, pucName); -} - -UNSIGNED32 ENTRYPOINT AdsDDGetPermissions(ADSHANDLE hConn, - UNSIGNED8* pucGrantee, - UNSIGNED16 usObjectType, - UNSIGNED8* pucObjectName, - UNSIGNED8* /*pucParentName*/, - UNSIGNED16 usGetInherited, - UNSIGNED32* pulPermissions) { - arc2_trace("AdsDDGetPermissions"); - if (pulPermissions == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *pulPermissions = 0; - if (auto* rc = get_remote_connection(hConn)) { - auto r = rc->dd_get_permissions( - openads::abi::to_internal(pucGrantee, 0), usObjectType, - openads::abi::to_internal(pucObjectName, 0), usGetInherited != 0); - if (!r) return fail(r.error()); - *pulPermissions = r.value(); - return ok(); - } - auto* dd = dd_from_handle(hConn); - if (dd == nullptr) return ok(); - auto grantee = openads::abi::to_internal(pucGrantee, 0); - auto objname = openads::abi::to_internal(pucObjectName, 0); - *pulPermissions = dd->get_permission_mask( - grantee, objname, static_cast(usObjectType), usGetInherited != 0); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsDDGrantPermission(ADSHANDLE hConn, - UNSIGNED16 usObjectType, - UNSIGNED8* pucObjectName, - UNSIGNED8* /*pucParentName*/, - UNSIGNED8* pucGrantee, - UNSIGNED32 ulPermissions) { - arc2_trace("AdsDDGrantPermission"); - if (auto* rc = get_remote_connection(hConn)) { - auto r = rc->dd_grant_permission(usObjectType, - openads::abi::to_internal(pucObjectName, 0), - openads::abi::to_internal(pucGrantee, 0), ulPermissions); - if (!r) return fail(r.error()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - if (dd == nullptr) return ok(); - auto objname = openads::abi::to_internal(pucObjectName, 0); - auto grantee = openads::abi::to_internal(pucGrantee, 0); - auto objtype = dd_type_name_from_code(usObjectType); - auto r = dd->grant_permission(objtype, objname, grantee, ulPermissions); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsDDRevokePermission(ADSHANDLE hConn, - UNSIGNED16 usObjectType, - UNSIGNED8* pucObjectName, - UNSIGNED8* pucParentName, - UNSIGNED8* pucGrantee, - UNSIGNED32 /*ulPermissions*/) { - arc2_trace("AdsDDRevokePermission"); - // Revoke by granting a zero bitmask (deactivates existing record). - // The caller typically follows with a fresh AdsDDGrantPermission call. - return AdsDDGrantPermission(hConn, usObjectType, pucObjectName, - pucParentName, pucGrantee, 0); -} - -UNSIGNED32 ENTRYPOINT AdsDDGetViewProperty(ADSHANDLE hConn, UNSIGNED8* pucName, - UNSIGNED16 usProp, void* pBuf, - UNSIGNED16* pusLen) { - arc2_trace("AdsDDGetViewProperty"); - if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (auto* rc = get_remote_connection(hConn)) { - auto vname = openads::abi::to_internal(pucName, 0); - auto r = rc->dd_get_property(openads::network::DDObjectKind::View, - vname, "", usProp); - UNSIGNED16 cap = *pusLen; - if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); - if (!r) { *pusLen = 0; return fail(r.error()); } - UNSIGNED16 n = static_cast(std::min(r.value().size(), cap)); - if (pBuf != nullptr && n > 0) std::memcpy(pBuf, r.value().data(), n); - *pusLen = static_cast(r.value().size()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - UNSIGNED16 cap = *pusLen; - if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); - if (dd == nullptr) { *pusLen = 0; return ok(); } - auto name = openads::abi::to_internal(pucName, 0); - if (!dd->has_view(name)) { - *pusLen = 0; - return fail(static_cast(openads::AE_NO_FILE_FOUND), name.c_str()); - } - const auto& e = dd->views().at(name); - auto put_str = [&](const std::string& s) -> UNSIGNED32 { - UNSIGNED16 n = static_cast(std::min(s.size(), cap)); - if (pBuf != nullptr && n > 0) std::memcpy(pBuf, s.data(), n); - *pusLen = static_cast(s.size()); - return ok(); - }; - switch (usProp) { - case ADS_DD_VIEW_STMT: return put_str(e.sql); - case ADS_DD_VIEW_COMMENT: return put_str(e.comment); - default: *pusLen = 0; return fail(openads::AE_FUNCTION_NOT_AVAILABLE, ""); - } -} - -UNSIGNED32 ENTRYPOINT AdsDDSetViewProperty(ADSHANDLE hConn, UNSIGNED8* pucName, - UNSIGNED16 usProp, void* pBuf, - UNSIGNED16 usLen) { - arc2_trace("AdsDDSetViewProperty"); - if (auto* rc = get_remote_connection(hConn)) { - auto vname = openads::abi::to_internal(pucName, 0); - std::string val = (pBuf != nullptr && usLen > 0) - ? std::string(reinterpret_cast(pBuf), usLen) : std::string(); - auto r = rc->dd_set_property(openads::network::DDObjectKind::View, - vname, "", usProp, val); - if (!r) return fail(r.error()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - if (dd == nullptr) return ok(); - auto name = openads::abi::to_internal(pucName, 0); - if (!dd->has_view(name)) - return fail(static_cast(openads::AE_NO_FILE_FOUND), name.c_str()); - auto& e = dd->views().at(name); - std::string val = pBuf && usLen > 0 - ? std::string(static_cast(pBuf), usLen) : std::string{}; - switch (usProp) { - case ADS_DD_VIEW_STMT: e.sql = val; break; - case ADS_DD_VIEW_COMMENT: e.comment = val; break; - default: return fail(openads::AE_FUNCTION_NOT_AVAILABLE, ""); - } - auto r = dd->save(); - if (!r) return fail(r.error()); - return ok(); -} - -// --------------------------------------------------------------------------- -// AdsDDGetRefIntegrityProperty / AdsDDSetRefIntegrityProperty -// --------------------------------------------------------------------------- - -UNSIGNED32 ENTRYPOINT AdsDDGetRefIntegrityProperty(ADSHANDLE hConn, UNSIGNED8* pucName, - UNSIGNED16 usProp, void* pBuf, - UNSIGNED16* pusLen) { - arc2_trace("AdsDDGetRefIntegrityProperty"); - if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (auto* rc = get_remote_connection(hConn)) { - auto riname = openads::abi::to_internal(pucName, 0); - auto r = rc->dd_get_property(openads::network::DDObjectKind::RefIntegrity, - riname, "", usProp); - UNSIGNED16 cap = *pusLen; - if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); - if (!r) { *pusLen = 0; return fail(r.error()); } - UNSIGNED16 n = static_cast(std::min(r.value().size(), cap)); - if (pBuf != nullptr && n > 0) std::memcpy(pBuf, r.value().data(), n); - *pusLen = static_cast(r.value().size()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - UNSIGNED16 cap = *pusLen; - if (pBuf != nullptr && cap > 0) std::memset(pBuf, 0, cap); - if (dd == nullptr) { *pusLen = 0; return ok(); } - auto name = openads::abi::to_internal(pucName, 0); - const auto& ri = dd->ri(); - auto it = ri.find(name); - if (it == ri.end()) { - *pusLen = 0; - return fail(static_cast(openads::AE_NO_FILE_FOUND), name.c_str()); - } - const auto& e = it->second; - auto put_str = [&](const std::string& s) -> UNSIGNED32 { - UNSIGNED16 n = static_cast(std::min(s.size(), cap)); - if (pBuf != nullptr && n > 0) std::memcpy(pBuf, s.data(), n); - *pusLen = static_cast(s.size()); - return ok(); - }; - auto put_u32 = [&](std::uint32_t v) -> UNSIGNED32 { - if (pBuf != nullptr && cap >= 4) { - auto* b = static_cast(pBuf); - b[0]=static_cast(v&0xFF); b[1]=static_cast((v>>8)&0xFF); b[2]=static_cast((v>>16)&0xFF); b[3]=static_cast((v>>24)&0xFF); - } - *pusLen = 4; return ok(); - }; - - switch (usProp) { - case ADS_DD_RI_PARENT: return put_str(e.parent); - case ADS_DD_RI_CHILD: return put_str(e.child); - case ADS_DD_RI_PARENT_TAG: return put_str(e.parent_tag); - case ADS_DD_RI_CHILD_TAG: return put_str(e.child_tag); - case ADS_DD_RI_UPDATE_RULE: { - try { return put_u32(static_cast(std::stoul(e.update_opt))); } - catch (...) { return put_u32(0); } - } - case ADS_DD_RI_DELETE_RULE: { - try { return put_u32(static_cast(std::stoul(e.delete_opt))); } - catch (...) { return put_u32(0); } - } - case ADS_DD_RI_FAIL_TABLE: return put_str(e.fail_table); - default: *pusLen = 0; return fail(openads::AE_FUNCTION_NOT_AVAILABLE, ""); - } -} - -UNSIGNED32 ENTRYPOINT AdsDDSetRefIntegrityProperty(ADSHANDLE hConn, UNSIGNED8* pucName, - UNSIGNED16 usProp, void* pBuf, - UNSIGNED16 usLen) { - arc2_trace("AdsDDSetRefIntegrityProperty"); - if (auto* rc = get_remote_connection(hConn)) { - auto riname = openads::abi::to_internal(pucName, 0); - std::string val = (pBuf != nullptr && usLen > 0) - ? std::string(reinterpret_cast(pBuf), usLen) : std::string(); - auto r = rc->dd_set_property(openads::network::DDObjectKind::RefIntegrity, - riname, "", usProp, val); - if (!r) return fail(r.error()); - return ok(); - } - auto* dd = dd_from_handle(hConn); - if (dd == nullptr) return ok(); - auto name = openads::abi::to_internal(pucName, 0); - auto& ri = dd->ri(); - auto it = ri.find(name); - if (it == ri.end()) - return fail(static_cast(openads::AE_NO_FILE_FOUND), name.c_str()); - auto& e = it->second; - std::string val = pBuf && usLen > 0 - ? std::string(static_cast(pBuf), usLen) : std::string{}; - switch (usProp) { - case ADS_DD_RI_PARENT: e.parent = val; break; - case ADS_DD_RI_CHILD: e.child = val; break; - case ADS_DD_RI_PARENT_TAG: e.parent_tag = val; break; - case ADS_DD_RI_CHILD_TAG: e.child_tag = val; break; - case ADS_DD_RI_UPDATE_RULE: - if (pBuf && usLen >= 4) { - auto* b = static_cast(pBuf); - std::uint32_t v = static_cast(b[0]) | (static_cast(b[1]) << 8) | (static_cast(b[2]) << 16) | (static_cast(b[3]) << 24); - e.update_opt = std::to_string(v); - } - break; - case ADS_DD_RI_DELETE_RULE: - if (pBuf && usLen >= 4) { - auto* b = static_cast(pBuf); - std::uint32_t v = static_cast(b[0]) | (static_cast(b[1]) << 8) | (static_cast(b[2]) << 16) | (static_cast(b[3]) << 24); - e.delete_opt = std::to_string(v); - } - break; - case ADS_DD_RI_FAIL_TABLE: e.fail_table = val; break; - default: return fail(openads::AE_FUNCTION_NOT_AVAILABLE, ""); - } - auto r = dd->save(); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsCreateFTSIndex(ADSHANDLE hTable, - UNSIGNED8* pucFileName, - UNSIGNED8* pucTag, - UNSIGNED8* pucField, - UNSIGNED32 /*ulPageSize*/, - UNSIGNED32 ulMinWordLen, - UNSIGNED32 ulMaxWordLen, - UNSIGNED16 usUseDefaultDelim, - UNSIGNED8* pucDelimiters, - UNSIGNED16 usUseDefaultNoise, - UNSIGNED8* pucNoiseWords, - UNSIGNED16 /*usUseDefaultDrop*/, - UNSIGNED8* /*pucDropChars*/, - UNSIGNED16 /*usUseDefaultConditionals*/, - UNSIGNED8* /*pucConditionalChars*/, - UNSIGNED8* /*pucReserved1*/, - UNSIGNED8* /*pucReserved2*/, - UNSIGNED32 /*ulOptions*/) { - arc2_trace("AdsCreateFTSIndex"); - Table* t = get_table(hTable); - if (!t || pucTag == nullptr || pucField == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, "AdsCreateFTSIndex: null arg"); - } - auto tag = openads::abi::to_internal(pucTag, 0); - auto field = openads::abi::to_internal(pucField, 0); - - namespace fs = std::filesystem; - fs::path p; - if (pucFileName != nullptr && pucFileName[0] != '\0') { - p = openads::abi::to_internal(pucFileName, 0); - } else { - // Compound auto-open form: file lives next to the table with - // the table's stem and a `.fts` extension. - p = fs::path(t->path()).replace_extension(".fts"); - } - if (!p.is_absolute()) { - fs::path tdir = fs::path(t->path()).parent_path(); - p = tdir / p; - } - if (!p.has_extension()) p.replace_extension(".fts"); - - auto opts = build_fts_options(ulMinWordLen, ulMaxWordLen, - usUseDefaultDelim, pucDelimiters, - usUseDefaultNoise, pucNoiseWords); - auto r = openads::engine::Fts::create(*t, p.string(), tag, field, opts); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsGetNumIndexes(ADSHANDLE hTable, UNSIGNED16* pusCount) { - arc2_trace("AdsGetNumIndexes"); - if (pusCount == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (auto* rt = get_remote_table(hTable)) { - // Count indexes opened locally on the RemoteTable (production - // CDX auto-open + explicit AdsOpenIndex). Going to the server - // via get_num_indexes() would return 0 because the server's - // engine handle hasn't opened the production index yet. - *pusCount = static_cast( - std::min(rt->index_handles.size(), - static_cast(0xFFFF))); - return ok(); - } - Table* t = get_table(hTable); - if (!t || pusCount == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, ""); - } - UNSIGNED16 n = 0; - // Storm fix: reader must hold index_bindings_mu (binds are unlocked now). - std::lock_guard _rn2_lk(index_bindings_mu()); - for (auto& [_, b] : index_bindings()) { - if (b.table == t) ++n; - } - *pusCount = n; - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsGetIndexHandle(ADSHANDLE hTable, UNSIGNED8* pucName, - ADSHANDLE* phIndex) { - arc2_trace("AdsGetIndexHandle"); - if (phIndex == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, ""); - } - auto name = openads::abi::to_internal(pucName, 0); - // Strip trailing whitespace + nulls (rddads space-pads tag names - // up to ADS_MAX_TAG_NAME before passing them to us). - while (!name.empty() && (name.back() == ' ' || name.back() == '\0')) { - name.pop_back(); - } - // Remote table: resolve the tag to its wire index handle (parallel to - // index_by_tag). rddads' DbSetOrder("") and FWH xbrowse header - // sort both go through here. - if (auto* rt = get_remote_table(hTable)) { - const std::size_t n = std::min(rt->index_by_tag.size(), - rt->index_handles.size()); - for (std::size_t i = 0; i < n; ++i) { - const std::string& tag = rt->index_by_tag[i].first; - if (tag.size() != name.size()) continue; - bool eq = true; - for (std::size_t k = 0; k < tag.size(); ++k) { - if (std::toupper(static_cast(tag[k])) != - std::toupper(static_cast(name[k]))) { - eq = false; break; - } - } - if (eq) { - *phIndex = static_cast(rt->index_handles[i]); - return ok(); - } - } - // Parked fallback (per-tag rotation resolves orders between - // CloseAll and reopen): parked ids are server-live, so serve - // them exactly like live ones instead of failing. - if (rt->indexes_parked) { - const std::size_t pn = std::min(rt->parked_by_tag.size(), - rt->parked_handles.size()); - for (std::size_t i = 0; i < pn; ++i) { - const std::string& tag = rt->parked_by_tag[i].first; - if (tag.size() != name.size()) continue; - bool eq = true; - for (std::size_t k = 0; k < tag.size(); ++k) { - if (std::toupper(static_cast(tag[k])) != - std::toupper(static_cast(name[k]))) { - eq = false; break; - } - } - if (eq) { - *phIndex = - static_cast(rt->parked_handles[i]); - return ok(); - } - } - } - return fail(openads::AE_INTERNAL_ERROR, "remote index name not found"); - } -#if defined(OPENADS_WITH_POSTGRESQL) - // SQL backend (postgresql): resolve an already-open PG index by its - // tag/column name. AdsOpenIndex creates the PostgresIndex handle; this - // is the by-name lookup path the ORM uses after opening. A PG handle has - // no native Table*, so without this branch the function fell through to - // get_table() below and errored for every PG table. Match the tag the - // way postgres_open_index derives it (strip path + extension). - if (auto* st = get_postgres_table(hTable)) { - std::string tag = name; - 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); - for (auto& [h, si] : postgres_indexes_map()) { - if (si && si->parent == st && si->column == tag) { - *phIndex = static_cast(h); - return ok(); - } - } - return fail(openads::AE_INTERNAL_ERROR, "index name not found"); - } -#endif - Table* t = get_table(hTable); - if (!t) { - return fail(openads::AE_INTERNAL_ERROR, ""); - } - // Storm fix: reader must hold index_bindings_mu (binds are unlocked now). - std::lock_guard _rh_lk(index_bindings_mu()); - for (auto& [h, b] : index_bindings()) { - if (b.table == t && b.tag_name == name) { *phIndex = h; return ok(); } - } - return fail(openads::AE_INTERNAL_ERROR, "index name not found"); -} - - -// Authoritative ordinal sequence of index-binding handles for table `t`. -// For a CDX bag this is the file's struct-tag (creation) order -- what -// ADS / rddads expose through ORDSETFOCUS(N) and OrdNumber(); for NTX it -// is handle-id order. AdsGetIndexHandleByOrder and -// AdsGetIndexOrderByHandle MUST share this so they stay exact inverses. -// -// Harbour rddads' INDEX command (default fAll && !fAdditive) calls -// ORDLSTCLEAR before each AdsCreateIndex61, wiping every binding we held -// for the table even though the on-disk CDX bag still lists all prior -// tags. So any tag in the active CDX that lost its binding is lazily -// re-bound here. -// -// extern "C++": this file's ACE exports live in an extern "C" block, but -// this internal helper returns a C++ UDT (std::vector) -- give it C++ -// linkage so MSVC doesn't warn C4190. -extern "C++" std::vector ordered_index_handles_for(Table* t) { - // Caller may already hold index_bindings_mu (recursive; storm fix -- - // this helper reads the map and can bind extra tags). - std::lock_guard _oh_lk(index_bindings_mu()); - auto& m = index_bindings(); - auto& act = active_binding_for(); - std::string bag_path; - auto act_it = act.find(t); - if (act_it != act.end()) { - auto bit = m.find(act_it->second); - if (bit != m.end()) bag_path = bit->second.path; - } - // No active binding (e.g. every tag parked after an ORDLSTCLEAR): fall - // back to any CDX binding's bag so we can still recover file order. - if (bag_path.empty()) { - for (auto& [h, b] : m) { - if (b.table == t && path_ends_with_ci(b.path, ".cdx")) { - bag_path = b.path; - break; - } - } - } - std::vector ordered; - if (!bag_path.empty() - && path_ends_with_ci(bag_path, ".cdx")) { - auto tags = openads::drivers::cdx::CdxIndex::list_tags(bag_path); - if (tags) { - for (const auto& tag : tags.value()) { - ADSHANDLE found = 0; - for (auto& [h, b] : m) { - if (b.table == t && b.tag_name == tag) { - found = h; break; - } - } - if (!found) { - auto sub = std::make_unique(); - if (auto r = sub->open_named(bag_path, - openads::drivers::IndexOpenMode::Shared, - tag); !r) continue; - mark_cdx_key_encoding(t, sub.get()); - apply_cdx_oem_collation(t, sub.get()); - ADSHANDLE nh = next_index_handle(); - openads::drivers::IIndex* raw = sub.get(); - m[nh] = IndexBinding{t, tag, std::move(sub), bag_path}; - t->register_extra_index_view(raw); - found = nh; - } - ordered.push_back(found); - } - } - } - if (ordered.empty()) { - // Fall back to handle-id ordering for non-CDX (NTX) cases. - for (auto& [h, b] : index_bindings()) { - if (b.table == t) ordered.push_back(h); - } - std::sort(ordered.begin(), ordered.end()); - } - return ordered; -} - -UNSIGNED32 ENTRYPOINT AdsGetIndexHandleByOrder(ADSHANDLE hTable, UNSIGNED16 usOrder, - ADSHANDLE* phIndex) { - arc2_trace("AdsGetIndexHandleByOrder"); - if (phIndex == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - // Remote table: resolve the ordinal to one of the wire index handles - // registered at open (production auto-open) / AdsOpenIndex. This is the - // path rddads' DbSetOrder() takes -- without it, CDX falls back - // to natural order and remote browses show no index. - if (auto* rt = get_remote_table(hTable)) { - const std::vector& handles = - rt->index_handles.empty() && rt->indexes_parked - ? rt->parked_handles - : rt->index_handles; - if (handles.empty()) { - return fail(openads::AE_INTERNAL_ERROR, "no remote index"); - } - std::size_t idx = (usOrder == 0 || usOrder > handles.size()) - ? 0 : static_cast(usOrder - 1); - *phIndex = static_cast(handles[idx]); - return ok(); - } - Table* t = get_table(hTable); - if (!t) { - return fail(openads::AE_INTERNAL_ERROR, ""); - } - std::vector ordered = ordered_index_handles_for(t); - if (ordered.empty()) { - return fail(openads::AE_INTERNAL_ERROR, "no active index"); - } - if (usOrder == 0 || usOrder > ordered.size()) { - // Fall back to first entry on out-of-range ordinal so legacy - // callers that pass 0 or 1 still resolve to *some* index. - *phIndex = ordered.front(); - } else { - *phIndex = ordered[usOrder - 1]; - } - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsGetIndexExpr(ADSHANDLE hIndex, UNSIGNED8* pucBuf, - UNSIGNED16* pusBufLen) { - arc2_trace("AdsGetIndexExpr"); - if (auto* ri = get_remote_index(hIndex)) { - openads::abi::copy_to_caller(pucBuf, pusBufLen, ri->expression); - return ok(); - } - auto& m = index_bindings(); - // Storm fix: readers must hold index_bindings_mu now that OpenIndex - // binds outside the lock (map erase concurrent with .find = UAF). - std::lock_guard _rb_lk(index_bindings_mu()); - auto it = m.find(hIndex); - if (it == m.end()) { - return fail(openads::AE_INTERNAL_ERROR, "unknown index"); - } - // Pull the expression from whichever IIndex carries it: parked - // binding has its own; the active one's IIndex sits on the Table. - std::string expr; - if (it->second.parked) { - expr = it->second.parked->expression(); - } else if (it->second.table && it->second.table->order() - && it->second.table->order()->index()) { - expr = it->second.table->order()->index()->expression(); - } - openads::abi::copy_to_caller(pucBuf, pusBufLen, expr); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsGetIndexName(ADSHANDLE hIndex, UNSIGNED8* pucBuf, - UNSIGNED16* pusBufLen) { - arc2_trace("AdsGetIndexName"); - if (auto* ri = get_remote_index(hIndex)) { - openads::abi::copy_to_caller(pucBuf, pusBufLen, ri->tag_name); - return ok(); - } - auto& m = index_bindings(); - // Storm fix: reader must hold index_bindings_mu (binds are unlocked now). - std::lock_guard _rn_lk(index_bindings_mu()); - auto it = m.find(hIndex); - if (it == m.end()) { - return fail(openads::AE_INTERNAL_ERROR, "unknown index"); - } - openads::abi::copy_to_caller(pucBuf, pusBufLen, it->second.tag_name); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsSetIndexDirection(ADSHANDLE hIndex, UNSIGNED16 usDir) { - arc2_trace("AdsSetIndexDirection"); - auto& s = state(); - std::lock_guard lk(s.mu); - // Storm fix: reader must hold index_bindings_mu (binds are unlocked now). - std::lock_guard _sd_lk(index_bindings_mu()); - auto it = index_bindings().find(hIndex); - if (it == index_bindings().end()) { - return fail(openads::AE_INTERNAL_ERROR, "unknown index"); - } - Table* t = it->second.table; - if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); - (void)activate_binding(hIndex); - auto* o = t->order(); - if (o == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, "no active order"); - } - // Real ADS semantics: AdsSetIndexDirection REVERSES the current - // traversal direction -- it is not an absolute set. rddads' / X#'s - // OrdDescend() proves this: the RDD reads the current direction - // (AdsIsIndexDescending) and, when it differs from the requested one, - // calls AdsSetIndexDirection(hIndex, TRUE) -- always TRUE, in both the - // asc→desc and desc→asc cases. So TRUE means "flip", not "descend". - // Treating usDir as an absolute 0/1 wedged FWH xbrowse header sorting: - // the first double-click flipped to descending and every later click - // re-requested TRUE, which an absolute set left descending forever. - // (void)usDir -- the value is a legacy flag; the operation is a toggle. - (void)usDir; - auto* mo = const_cast(o); - mo->set_descending_traverse(!mo->descending_traverse()); - return ok(); -} - -// ACE / rddads signature: 6 args. -// AdsSeek(hIndex, pucKey, u16KeyLen, u16KeyType, u16SeekType, &u16Found) -// -// u16KeyType : ADS_STRINGKEY / ADS_NUMERICKEY / ... -- describes -// pucKey's encoding. We accept whatever the caller sends -// and pass the bytes through as-is; the engine compares -// on raw bytes after padding to the index's key length. -// u16SeekType : 0 = exact (hard), 1 = soft. Bit 1 = AfterKey. -// rddads' hb_adsUpdateAreaFlags asks AdsIsFound after every seek to -// decide whether Found() should report .T. -- return the flag the -// engine set inside seek_key. -UNSIGNED32 ENTRYPOINT AdsIsFound(ADSHANDLE hTable, UNSIGNED16* pbFound) { - arc2_trace("AdsIsFound"); - if (pbFound == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (auto* rt = get_remote_table(hTable)) { - // M12.21 option C -- serve Found() locally when a nav/seek op set - // it (the common scan case: Skip clears it), saving a round-trip - // on every step. Fall back to the server only when uncached. - if (rt->found_cached) { *pbFound = rt->current_found ? 1 : 0; return ok(); } - 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; - return ok(); -} - -// Local (DBF / ADT) tail shared by AdsSeek and AdsSeekLast. Everything up -// to the actual positioning -- the ADS_DOUBLEKEY / date / logical / Fox- -// numeric key conversions -- is identical for both, and only the final -// Table::seek_key differs: fLast walks to the END of the equal-key run. -// AdsSeekLast used to just call AdsSeek, which always seeks with -// last = false, so it returned the FIRST record of the group. With a -// partial key that is the first line of the document where the caller -// asked for the last one -- a silently wrong row, not an error. -static UNSIGNED32 ads_seek_local(ADSHANDLE hIndex, - UNSIGNED8* pucKey, - UNSIGNED16 u16KeyLen, - UNSIGNED16 u16KeyType, - UNSIGNED16 u16SeekType, - UNSIGNED16* pbFound, - bool find_last) { - Table* t = table_for_index(hIndex); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown index"); - std::string key; - (void)u16KeyType; - // Numeric seek keys arrive as sizeof(double) raw IEEE bytes. The ADS SDK - // names this ADS_DOUBLEKEY (2), but Harbour's rddads tags a numeric dbSeek - // with the field DATA type (ADS_STRING==4 in this ABI) -- so gating on - // u16KeyType==ADS_DOUBLEKEY missed EVERY rddads numeric seek (the key fell - // through as 8 raw double bytes and never matched the stored ASCII key). - // Detect a double key by length + a numeric (ASCII-stored) active index - // field instead, and convert to the same right-aligned ASCII the index - // holds. Character / non-numeric indexes keep the raw-bytes path. - auto* dk_idx = (t->order() != nullptr) ? t->order()->index() : nullptr; - // Strip the `ALIAS->` qualifier the way the write side does - // (evaluate_index_expr -> strip_alias_qualifiers) so a tag built from - // `FIELD->ID` still resolves the field. - std::int32_t dk_fidx = (dk_idx != nullptr) - ? t->field_index( - openads::engine::strip_alias_qualifiers(dk_idx->expression())) - : -1; - bool dk_numeric = false; - bool dk_date = false; - bool dk_logical = false; - if (dk_fidx >= 0) { - auto dkt = t->field_descriptor( - static_cast(dk_fidx)).type; - dk_numeric = (dkt == openads::drivers::DbfFieldType::Numeric || - dkt == openads::drivers::DbfFieldType::Float); - // DBF Date only: CDX/NTX date keys are raw YYYYMMDD text. ADT - // AdtDate keys are ADI packed binary -- the ADI driver converts - // a raw-double seek key itself, so leave those on the raw path. - dk_date = (dkt == openads::drivers::DbfFieldType::Date); - dk_logical = (dkt == openads::drivers::DbfFieldType::Logical); - } - const bool dk_foxnum = - dk_idx != nullptr && - dk_idx->key_encoding() == openads::drivers::KeyEncoding::FoxNumeric && - u16KeyLen == sizeof(double); - const bool dk_ntxnum = - dk_idx != nullptr && - dk_idx->key_encoding() == openads::drivers::KeyEncoding::NtxNumeric && - u16KeyLen == sizeof(double); - if (dk_foxnum) { - // Numeric CDX key: encode the seek value the same 8-byte - // order-preserving way the stored keys were written. - double dv = 0; - std::memcpy(&dv, pucKey, sizeof(double)); - key = openads::engine::fox_numeric_key(dv); - } else if (dk_ntxnum) { - // Numeric NTX key: encode the seek value the same native zero-padded - // (negatives byte-complemented) way the stored keys were written. - double dv = 0; - std::memcpy(&dv, pucKey, sizeof(double)); - key = openads::engine::ntx_numeric_key(dv, dk_idx->key_length(), - dk_idx->key_decimals()); - } else if (dk_idx != nullptr && dk_date && u16KeyLen == sizeof(double)) { - // Date seek: rddads sends DbSeek(date) as a julian day number - // double (hb_itemGetTD). Our date keys are raw YYYYMMDD text, - // so convert the julian day to that key form (mirrors the - // AdsSetScope date path). - double dv = 0; - std::memcpy(&dv, pucKey, sizeof(double)); - int y = 0, mo = 0, dy = 0; - julian_to_ymd(static_cast(dv), y, mo, dy); - char dbuf[16]; - std::snprintf(dbuf, sizeof(dbuf), "%04d%02d%02d", y, mo, dy); - key.assign(dbuf, 8); - std::uint16_t dklen = dk_idx->key_length(); - if (key.size() < dklen) key.append(dklen - key.size(), ' '); - } else if (dk_idx != nullptr && dk_numeric && u16KeyLen == sizeof(double)) { - double dv = 0; - std::memcpy(&dv, pucKey, sizeof(double)); - std::uint16_t klen = dk_idx->key_length(); - // Format width matches the FIELD width (eg N,10,0 -> 10), not a stale - // index key_length (eg INDEX-on-empty-table), so the right-aligned - // padding equals what evaluate_index_expr wrote at build/sync time. - const auto& fd = t->field_descriptor( - static_cast(dk_fidx)); - std::uint16_t dec = static_cast(fd.decimals); - std::uint16_t fmt_w = (fd.length > 0) - ? static_cast(fd.length) : klen; - // Buffer holds the widest valid xBase field width (255) plus - // sign, decimal point and NUL. snprintf is length-bounded and we - // assign only what it actually produced (clamped to the buffer), - // so an out-of-range fmt_w can never overread the stack buffer. - char buf[264]; - int n = (dec > 0) - ? std::snprintf(buf, sizeof(buf), "%*.*f", - static_cast(fmt_w), - static_cast(dec), dv) - : std::snprintf(buf, sizeof(buf), "%*.0f", - static_cast(fmt_w), dv); - std::size_t take = (n < 0) - ? 0u - : std::min(static_cast(n), - sizeof(buf) - 1); - // Pad to klen with trailing spaces (matches how - // evaluate_index_expr right-pads the field's raw bytes). - key.assign(buf, take); - if (key.size() < klen) key.append(klen - key.size(), ' '); - } else { - key.assign(reinterpret_cast(pucKey), - static_cast(u16KeyLen)); - } - - // rddads logical seek: Harbour's adsSeek sends DbSeek(.T.)/(.F.) as - // the 1-byte strings "1"/"0" (ads1.c HB_IS_LOGICAL branch), while a - // DBF logical index stores the raw field byte 'T'/'F'. SAP ACE maps - // the seek value onto the stored form; do the same or EVERY seek on a - // logical-key tag misses (GitHub #131 defect B). DBF-family only: - // ADT logical keys are ADI packed doubles and AdiIndex::seek_key - // already parses the "1"/"0" ASCII form itself. - if (dk_logical && u16KeyLen == 1 && - (pucKey[0] == '1' || pucKey[0] == '0') && - !path_ends_with_ci(t->path(), ".adt")) { - key = (pucKey[0] == '1') ? "T" : "F"; - } - - // Robustness for rddads/Val() case: if the index is FoxNumeric but the key arrived as string - // (e.g. rddads sent the digit string for a Val() tag), convert it to the 8-byte fox key. - // This helps when the key type detection in rddads sends string bytes for numeric expr tags. - if (dk_idx && dk_idx->key_encoding() == openads::drivers::KeyEncoding::FoxNumeric && key.size() != sizeof(double)) { - // try parse the received bytes as number string - std::string sk((const char*)pucKey, u16KeyLen); - // trim spaces - size_t start = sk.find_first_not_of(" \t"); - if (start != std::string::npos) { - size_t end = sk.find_last_not_of(" \t"); - sk = sk.substr(start, end - start + 1); - } - char* e = nullptr; - double dv = strtod(sk.c_str(), &e); - if (e != sk.c_str()) { - key = openads::engine::fox_numeric_key(dv); - } - } - bool soft = (u16SeekType & 0x01) != 0; - bool zero_length_key = (u16KeyLen == 0); - // Clipper / DBFCDX quirk: a zero-length seek key (`DBSEEK( "" )`) - // matches every record. Skip the underlying B+tree compare and - // walk straight to the first / last record (depending on the - // SeekLast retry latch). The seek_last_retry_latch is set only - // by AdsSeekLast -- meaning the caller is bFindLast=TRUE, in - // which case we want the LAST entry in ASC traversal direction - // (DBFCDX hb_cdxSeek with fLast = TRUE returns the same record - // as soft + skip-to-end-of-key-group; the empty key has only - // one "group" so first/last collapse to first under our walk). - if (zero_length_key && t->record_count() > 0) { - // DBFCDX: empty key always matches; soft-or-hard, asc-only. - // For DESCEND orders the empty key falls through to the - // regular seek path so DBFCDX's CDX_MAX_REC_NUM / fLast - // inversion takes effect -- `DBSEEK("",T,T)` on a DESCEND - // tag is expected to miss (Eof), not land on the bottom. - bool desc = (t->order() != nullptr && - t->order()->descending_traverse()); - if (!desc) { - auto gt = t->goto_top(); - if (!gt) return fail(gt.error()); - if (pbFound != nullptr) *pbFound = 1; - t->set_last_seek_found(true); - snapshot_ri_pks(t); - apply_relations_for_table(t); - return ok(); - } - } - auto r = t->seek_key(key, soft, find_last); - if (!r) return fail(r.error()); - bool found = r.value(); - if (pbFound != nullptr) *pbFound = found ? 1 : 0; - snapshot_ri_pks(t); - apply_relations_for_table(t); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsSeek(ADSHANDLE hIndex, - UNSIGNED8* pucKey, - UNSIGNED16 u16KeyLen, - UNSIGNED16 u16KeyType, - UNSIGNED16 u16SeekType, - UNSIGNED16* pbFound) { - arc2_trace("AdsSeek"); -#if defined(OPENADS_WITH_SQLITE) - if (auto* si = get_sqlite_index(hIndex)) { - if (si->parent == nullptr || si->parent->conn == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, "sqlite 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(); - sql_uri_mark_index_full(si->parent); - si->last_seek_found = found; - if (pbFound) *pbFound = found ? 1 : 0; - (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(); - sql_uri_mark_index_full(si->parent); - si->last_seek_found = found; - if (pbFound) *pbFound = found ? 1 : 0; - (void)u16KeyType; - return ok(); - } -#endif -#if defined(OPENADS_WITH_FIREBIRD) - if (auto* si = get_firebird_index(hIndex)) { - if (si->parent == nullptr || si->parent->conn == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, "firebird 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(); - sql_uri_mark_index_full(si->parent); - 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(); - sql_uri_mark_index_full(si->parent); - si->last_seek_found = found; - if (pbFound) *pbFound = found ? 1 : 0; - (void)u16KeyType; - return ok(); - } -#endif -#if defined(OPENADS_WITH_MSSQL) - if (auto* si = get_mssql_index(hIndex)) { - if (si->parent == nullptr || si->parent->conn == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, "mssql index orphan"); - } - std::string key(reinterpret_cast(pucKey), u16KeyLen); - const bool soft = (u16SeekType & 1u) != 0; - 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(); - sql_uri_mark_index_full(si->parent); - si->last_seek_found = found; - si->parent->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(); - sql_uri_mark_index_full(si->parent); - si->last_seek_found = found; - if (pbFound) *pbFound = found ? 1 : 0; - (void)u16KeyType; - return ok(); - } -#endif - if (auto* ri = get_remote_index(hIndex)) { - std::string key(reinterpret_cast(pucKey), - u16KeyLen); - if (ri->parent) { - if (UNSIGNED32 frc = remote_flush_pending(ri->parent); frc != 0) return frc; - ri->parent->row_valid = false; // M12.17 - // RCB 07/14/2026: BUG FIX -- this path invalidated the cached row - // but not the lookahead queue. A seek repositions the server cursor - // ABSOLUTELY, so every queued row belongs to the pre-seek position - // and the consumed-lag counter no longer describes anything real. - // Left as it was, the next AdsSkip(1) popped a stale PRE-SEEK row - // and returned it as the current record with no wire traffic at all - // -- nothing on the network to make it look wrong -- and the wire skip - // after that sent (step + a lag that no longer applied). - ri->parent->invalidate_prefetch(); - cli_trace_tbl(ri->parent, "AdsSeek", "key_len=%u", - static_cast(u16KeyLen)); - } - if (ri->parent != nullptr && remote_empty_window(ri->parent)) { - cli_trace_tbl(ri->parent, "AdsSeek", - "empty window: not found (local)"); - remote_empty_restamp(ri->parent); - ri->parent->found_cached = true; - ri->parent->current_found = false; - if (pbFound) *pbFound = 0; - (void)u16KeyType; - return ok(); - } - // RCB 07/14/2026: M12.24 -- pass the parent so the SeekAck's row trailer - // lands straight in the row cache. Without it row_valid stays false and - // the caller's next AdsGetField pays a FetchCurrentRow round-trip, i.e. - // seek-then-read costs 2 RTTs instead of 1. - auto r = ri->conn->seek(ri->id, key, - static_cast(u16SeekType), - /*last=*/0, ri->parent); - if (!r) return fail(r.error()); - if (pbFound) *pbFound = r.value().hit; - if (ri->parent) { // M12.21 option C - ri->parent->found_cached = true; - ri->parent->current_found = (r.value().hit != 0); - // FIX(seek-nav): a seek repositions the cursor ABSOLUTELY, so the - // client-side nav_at_bof / nav_at_eof flags inherited from the - // pre-seek position are stale. Left as they were, a browse walk - // that ended at EOF left nav_at_eof set; the next FOUND seek then - // made AdsAtEOF report true on a live record, rddads' - // hb_adsUpdateAreaFlags cleared fPositioned, and adsGetValue - // served blank strings for CHARACTER fields without calling ADS - // at all (Vouch: "SEEK succeeds but where are the field - // values?"). Numeric/logical/date/memo reads bypass that - // fPositioned guard, which is why only strings went blank. - // A miss with recno==0 genuinely lands at EOF (hard miss, or - // soft miss with no higher key); anything else is a live row. - // Piggyback guard: a current server already certified all of - // the above (plus the bound cache and recno) in the SeekAck - // tail — the manual reset below is the old-server fallback - // only. Presence check: the piggyback stamps bound_seq to the - // current seq, and seek() bumped it on entry, so equality - // holds iff the tail arrived. - if (ri->parent->bound_seq != ri->parent->conn->nav_seq()) { - if (r.value().hit != 0 || r.value().recno != 0) { - ri->parent->nav_at_bof = false; - ri->parent->nav_at_eof = false; - } else { - ri->parent->nav_at_bof = false; - ri->parent->nav_at_eof = true; - } - } - } - (void)u16KeyType; - return ok(); - } - return ads_seek_local(hIndex, pucKey, u16KeyLen, u16KeyType, - u16SeekType, pbFound, /*find_last=*/false); -} - -UNSIGNED32 ENTRYPOINT AdsSeekLast(ADSHANDLE hIndex, - UNSIGNED8* pucKey, - UNSIGNED16 u16KeyLen, - UNSIGNED16 u16KeyType, - UNSIGNED16* pbFound) { - arc2_trace("AdsSeekLast"); -#if defined(OPENADS_WITH_SQLITE) - if (auto* si = get_sqlite_index(hIndex)) { - if (si->parent == nullptr || si->parent->conn == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, "sqlite 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(); - sql_uri_mark_index_full(si->parent); - si->last_seek_found = found; - if (pbFound) *pbFound = found ? 1 : 0; - (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(); - sql_uri_mark_index_full(si->parent); - si->last_seek_found = found; - if (pbFound) *pbFound = found ? 1 : 0; - (void)u16KeyType; - return ok(); - } -#endif -#if defined(OPENADS_WITH_FIREBIRD) - if (auto* si = get_firebird_index(hIndex)) { - if (si->parent == nullptr || si->parent->conn == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, "firebird 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(); - sql_uri_mark_index_full(si->parent); - 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(); - sql_uri_mark_index_full(si->parent); - si->last_seek_found = found; - if (pbFound) *pbFound = found ? 1 : 0; - (void)u16KeyType; - return ok(); - } -#endif -#if defined(OPENADS_WITH_MSSQL) - if (auto* si = get_mssql_index(hIndex)) { - if (si->parent == nullptr || si->parent->conn == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, "mssql index orphan"); - } - std::string key(reinterpret_cast(pucKey), u16KeyLen); - 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(); - sql_uri_mark_index_full(si->parent); - si->last_seek_found = found; - si->parent->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(); - sql_uri_mark_index_full(si->parent); - si->last_seek_found = found; - if (pbFound) *pbFound = found ? 1 : 0; - (void)u16KeyType; - return ok(); - } -#endif - if (auto* ri = get_remote_index(hIndex)) { - std::string key(reinterpret_cast(pucKey), - u16KeyLen); - if (ri->parent) { - if (UNSIGNED32 frc = remote_flush_pending(ri->parent); frc != 0) return frc; - ri->parent->row_valid = false; // M12.17 - // RCB 07/14/2026: same stale-queue bug as AdsSeek -- see the note - // there for why dropping the block is mandatory after a seek. - ri->parent->invalidate_prefetch(); - cli_trace_tbl(ri->parent, "AdsSeekLast", "key_len=%u", - static_cast(u16KeyLen)); - } - auto r = ri->conn->seek(ri->id, key, - /*soft=*/0, - /*last=*/1, ri->parent); // M12.24 -- see AdsSeek - if (!r) return fail(r.error()); - if (pbFound) *pbFound = r.value().hit; - if (ri->parent) { // M12.21 option C - ri->parent->found_cached = true; - ri->parent->current_found = (r.value().hit != 0); - // FIX(seek-nav): same stale nav_at_bof / nav_at_eof reset as - // AdsSeek -- see the full note there. Same piggyback guard: - // a certified SeekLastAck tail already set everything. - if (ri->parent->bound_seq != ri->parent->conn->nav_seq()) { - if (r.value().hit != 0 || r.value().recno != 0) { - ri->parent->nav_at_bof = false; - ri->parent->nav_at_eof = false; - } else { - ri->parent->nav_at_bof = false; - ri->parent->nav_at_eof = true; - } - } - } - (void)u16KeyType; - return ok(); - } - // Mark the AdsSeekLast cycle for rddads' retry chain (see the latch - // definition). The fLast walk itself does NOT ride on the latch -- - // it is passed explicitly, because a latch left set by an early - // return would turn the next plain AdsSeek into a seek-last. - seek_last_retry_latch() = true; - return ads_seek_local(hIndex, pucKey, u16KeyLen, u16KeyType, - /*soft*/ 0, pbFound, /*find_last=*/true); -} - -// SAP / rddads signature: 5 args. -// AdsSetScope(hIndex, usScope, pucScope, usLen, usDataType) -// -// usLen : explicit scope-key length. Required because typed -// keys (ADS_DOUBLEKEY, ADS_RAWKEY) legally contain -// embedded NULs that strlen() would truncate. -// usDataType : ADS_STRINGKEY / ADS_RAWKEY / ADS_DOUBLEKEY / ... -- -// matches AdsSeek's u16KeyType. We mirror AdsSeek's -// ADS_DOUBLEKEY -> ASCII-padded conversion so a scope -// set with a double compares apples-to-apples against -// the index's stored key bytes. -// A scope bound longer than the index key is meaningless -- trim it. Bounds -// SHORTER than the key are NOT padded: they bound by prefix, and the engine -// compares only the bytes the caller supplied (Table::key_in_*_scope_). -// Padding them to key_length used to be done here to make an unpadded bound -// on a single-field tag work, but it broke the partial-key case it shares the -// path with: a 10-byte CON+DOC bound on a CON+DOC+STR(SEQ,6,0) tag became -// "CON+DOC" + 6 spaces, which every real key sorts after, emptying the scope. -static void trim_scope_key_to_index(Table* t, std::string& key) { - if (t == nullptr || key.empty()) return; - if (t->order() == nullptr || t->order()->index() == nullptr) return; - const std::uint16_t klen = t->order()->index()->key_length(); - if (key.size() > klen) key.resize(klen); -} - -UNSIGNED32 ENTRYPOINT AdsSetScope(ADSHANDLE hIndex, UNSIGNED16 usScope, - UNSIGNED8* pucScope, UNSIGNED16 usLen, - UNSIGNED16 usDataType) { - arc2_trace("AdsSetScope"); - if (auto* ri = get_remote_index(hIndex)) { - if (ri->parent) { - if (UNSIGNED32 frc = remote_flush_pending(ri->parent); frc != 0) return frc; - } - std::string key = pucScope - ? openads::abi::to_internal(pucScope, usLen) : std::string(); - auto r = ri->conn->set_scope(ri->id, usScope, key, - usDataType); - if (!r) return fail(r.error()); - // The scoped key count and every keyno/rel-pos value derived - // from it just changed; recompute lazily on next use. Also drop - // read-ahead rows -- they were read under the old scope. - // A scope once set disqualifies the table from lazy-close - // pooling (server index state is no longer fresh-open shaped). - if (ri->parent != nullptr) { - ri->parent->key_count_cached = false; - ri->parent->key_counts.clear(); - ri->parent->keyno_valid = false; - ri->parent->invalidate_prefetch(); - ri->parent->scope_touched = true; - // Visibility may have narrowed to empty (or widened): drop - // proven-false boundary answers and the empty sticky. - ri->parent->nav_not_bof = false; - ri->parent->nav_not_eof = false; - ri->parent->last_nav = 0; - remote_nav_bound_clear(ri->parent); - } - return ok(); - } - Table* t = table_for_index(hIndex); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown index"); - std::string key; - if (usDataType == ADS_DOUBLEKEY && usLen == sizeof(double) && - pucScope != nullptr && - t->order() != nullptr && t->order()->index() != nullptr) { - double dv = 0; - std::memcpy(&dv, pucScope, sizeof(double)); - auto* idx = t->order()->index(); - std::uint16_t klen = idx->key_length(); - std::uint16_t fmt_w = klen; - std::uint16_t dec = 0; - if (idx->key_encoding() == - openads::drivers::KeyEncoding::FoxNumeric) { - // Numeric CDX scope key: same 8-byte order-preserving encoding - // as the stored keys, not ASCII. - key = openads::engine::fox_numeric_key(dv); - auto rsc = t->set_scope(usScope == ADS_TOP, key); - if (!rsc) return fail(rsc.error()); - return ok(); - } - // Strip the `ALIAS->` qualifier the way the write side does - // (evaluate_index_expr -> strip_alias_qualifiers); otherwise a - // tag built from `FIELD->ID` never resolves the field, fmt_w - // stays at the stale key_length, and the numeric seek key is - // padded to a different width than the stored key. - std::int32_t fidx = t->field_index( - openads::engine::strip_alias_qualifiers(idx->expression())); - if (fidx >= 0) { - const auto& fd = t->field_descriptor( - static_cast(fidx)); - // DBF Date key: rddads sends OrdScope() date values as a - // julian day number double (hb_itemGetDL) once AdsGetKeyType - // reports ADS_DATE. CDX/NTX date keys are raw YYYYMMDD text, - // so convert the julian day to that key form instead of - // ASCII-formatting the raw number. (ADT AdtDate keys are ADI - // packed binary and stay on the generic path.) - if (fd.type == openads::drivers::DbfFieldType::Date) { - int y = 0, mo = 0, dy = 0; - julian_to_ymd(static_cast(dv), y, mo, dy); - char dbuf[16]; - std::snprintf(dbuf, sizeof(dbuf), "%04d%02d%02d", - y, mo, dy); - key.assign(dbuf, 8); - if (key.size() < klen) key.append(klen - key.size(), ' '); - auto rsc = t->set_scope(usScope == ADS_TOP, key); - if (!rsc) return fail(rsc.error()); - return ok(); - } - dec = static_cast(fd.decimals); - if (fd.length > 0) - fmt_w = static_cast(fd.length); - } - // Length-bounded format + clamped assign: an out-of-range fmt_w - // can never overread the buffer (buf sized for the widest valid - // xBase field width plus sign, separator and NUL). - char buf[264]; - int n = (dec > 0) - ? std::snprintf(buf, sizeof(buf), "%*.*f", - static_cast(fmt_w), - static_cast(dec), dv) - : std::snprintf(buf, sizeof(buf), "%*.0f", - static_cast(fmt_w), dv); - std::size_t take = (n < 0) - ? 0u - : std::min(static_cast(n), - sizeof(buf) - 1); - key.assign(buf, take); - if (key.size() < klen) key.append(klen - key.size(), ' '); - } else { - key = pucScope - ? openads::abi::to_internal(pucScope, usLen) : std::string(); - // rddads passes hb_itemGetCLen() -- the trimmed string length. That - // shorter-than-the-key bound is handled by prefix comparison in the - // engine, so it is stored as sent; only an over-long bound is trimmed. - trim_scope_key_to_index(t, key); - } - auto r = t->set_scope(usScope == ADS_TOP, key); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsClearScope(ADSHANDLE hIndex, UNSIGNED16 usScope) { - arc2_trace("AdsClearScope"); - if (auto* ri = get_remote_index(hIndex)) { - if (ri->parent) { - if (UNSIGNED32 frc = remote_flush_pending(ri->parent); frc != 0) return frc; - } - auto r = ri->conn->clear_scope(ri->id, usScope); - if (!r) return fail(r.error()); - if (ri->parent != nullptr) { - ri->parent->key_count_cached = false; - ri->parent->key_counts.clear(); - ri->parent->keyno_valid = false; - ri->parent->invalidate_prefetch(); - // Widening can un-empty the cursor: expire the nav stamp - // and cached boundary answers (proven-false answers survive - // widening, but uniformity beats auditing every path). - ri->parent->last_nav = 0; - ri->parent->nav_not_bof = false; - ri->parent->nav_not_eof = false; - remote_nav_bound_clear(ri->parent); - } - return ok(); - } - Table* t = table_for_index(hIndex); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown index"); - auto r = t->clear_scope(usScope == ADS_TOP); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsGetScope(ADSHANDLE hIndex, UNSIGNED16 usScope, - UNSIGNED8* pucBuf, UNSIGNED16* pusLen) { - arc2_trace("AdsGetScope"); - if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - // rddads' ADSKEYCOUNT (filter option != ADS_IGNOREFILTERS) seeds - // *pusLen with sizeof(buffer) then calls AdsGetScope. A failure that - // leaves *pusLen untouched makes the caller think a scope is set and - // enter the key-walk branch -- which returns KeyCount=0 and blanks - // FWH xBrowse over remote tables/indexes. Remote handles carry no - // client-side scope; report empty scope explicitly. - if (get_remote_index(hIndex) != nullptr || get_remote_table(hIndex)) { - *pusLen = 0; - return ok(); - } - Table* t = table_for_index(hIndex); - if (!t) { - *pusLen = 0; - return fail(openads::AE_INTERNAL_ERROR, "unknown index"); - } - auto s = t->get_scope(usScope == ADS_TOP); - openads::abi::copy_to_caller(pucBuf, pusLen, s.value_or("")); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsPackTable(ADSHANDLE hTable) { - arc2_trace("AdsPackTable"); - if (auto* rt = get_remote_table(hTable)) { - if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; - rt->row_valid = false; // M12.17/19 - rt->rec_count_cached = false; - rt->key_count_cached = false; - rt->key_counts.clear(); - rt->key_counts.clear(); - rt->nav_not_bof = false; // row removal can empty the cursor - rt->nav_not_eof = false; - auto r = rt->conn->pack_table(rt->id); - if (!r) return fail(r.error()); - return ok(); - } - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - auto r = t->pack(); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsPackTable120(ADSHANDLE hTable, - UNSIGNED32 /*ulMemoBlockSize*/, - UNSIGNED32 ulOptions) { - arc2_trace("AdsPackTable120"); - if (ulOptions != 0) { - return fail(openads::AE_INTERNAL_ERROR, "reserved options must be zero"); - } - return AdsPackTable(hTable); -} - -UNSIGNED32 ENTRYPOINT AdsZapTable(ADSHANDLE hTable) { - arc2_trace("AdsZapTable"); - if (auto* rt = get_remote_table(hTable)) { - if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; - rt->row_valid = false; // M12.17/19 - rt->rec_count_cached = false; - rt->key_count_cached = false; - rt->key_counts.clear(); - rt->key_counts.clear(); - rt->nav_not_bof = false; // row removal can empty the cursor - rt->nav_not_eof = false; - auto r = rt->conn->zap_table(rt->id); - if (!r) return fail(r.error()); - return ok(); - } - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - auto r = t->zap(); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsCopyTable(ADSHANDLE hHandle, - UNSIGNED16 /*usFilterOption*/, - UNSIGNED8* pucFile) { - arc2_trace("AdsCopyTable"); - if (pucFile == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, "null target"); - } - Table* t = get_table(hHandle); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - if (!t->driver()) return fail(openads::AE_INTERNAL_ERROR, "no driver"); - - namespace fs = std::filesystem; - auto raw = openads::abi::to_internal(pucFile, 0); - fs::path dst(raw); - if (!dst.is_absolute()) { - fs::path src_dir = fs::path(t->path()).parent_path(); - dst = src_dir / dst; - } - if (!dst.has_extension()) dst.replace_extension(".dbf"); - - // Build a new DBF that mirrors the source schema. Copy live - // records (deleted rows skipped -- filter options beyond - // ADS_RESPECTFILTERS land later). - const auto& src_fields = t->driver()->fields(); - if (src_fields.empty()) { - return fail(openads::AE_INTERNAL_ERROR, "source has no fields"); - } - std::uint16_t header_len = static_cast( - 32 + 32 * src_fields.size() + 2); - std::uint16_t rec_len = t->driver()->record_length(); - - std::vector file; - std::vector hdr(32, 0); - hdr[0] = 0x03; - stamp_dbf_header_today(hdr.data()); - hdr[8] = static_cast(header_len & 0xFFu); - hdr[9] = static_cast((header_len >> 8) & 0xFFu); - hdr[10] = static_cast(rec_len & 0xFFu); - hdr[11] = static_cast((rec_len >> 8) & 0xFFu); - file = hdr; - for (const auto& f : src_fields) { - std::vector fd(32, 0); - std::size_t n = std::min(f.name.size(), 10); - std::memcpy(fd.data(), f.name.data(), n); - fd[11] = static_cast(f.raw_type ? f.raw_type : 'C'); - fd[16] = static_cast(f.length); - fd[17] = f.decimals; - file.insert(file.end(), fd.begin(), fd.end()); - } - stamp_dbf_field_displacements(file.data() + 32, src_fields.size()); - file.push_back(0x0D); - file.push_back(0x00); - - // Walk source records, append live ones to the buffered file. - auto src_count = t->driver()->record_count(); - std::uint32_t live = 0; - for (std::uint32_t r = 1; r <= src_count; ++r) { - auto rec = t->driver()->read_record_raw(r); - if (!rec) return fail(rec.error()); - const auto& buf = rec.value(); - if (!buf.empty() && buf[0] == '*') continue; // deleted - ++live; - file.insert(file.end(), buf.begin(), buf.end()); - } - file.push_back(0x1A); - - // Patch the record count. - file[4] = static_cast( live & 0xFFu); - file[5] = static_cast((live >> 8) & 0xFFu); - file[6] = static_cast((live >> 16) & 0xFFu); - file[7] = static_cast((live >> 24) & 0xFFu); - - { - std::error_code ec; - fs::remove(dst, ec); - } - { - std::ofstream out(dst, std::ios::binary); - if (!out) return fail(openads::AE_INTERNAL_ERROR, - "AdsCopyTable: open for write failed"); - out.write(reinterpret_cast(file.data()), - static_cast(file.size())); - if (!out) return fail(openads::AE_INTERNAL_ERROR, - "AdsCopyTable: write failed"); - } - return ok(); -} - -// SAP / rddads signature: 3 args. -// AdsCopyTableContents(hSrc, hDst, usFilterOption) -// -// usFilterOption : ADS_IGNOREFILTERS (0) / ADS_RESPECTFILTERS (1). -// We iterate raw records and skip the DBF tombstone byte -- that -// matches IGNOREFILTERS (the default Harbour passes). RESPECT -// will land alongside AOF-aware copy in a follow-up; until then -// the param is accepted for signature parity and noted. -UNSIGNED32 ENTRYPOINT AdsCopyTableContents(ADSHANDLE hSrc, ADSHANDLE hDst, - UNSIGNED16 usFilterOption) { - arc2_trace("AdsCopyTableContents"); - (void)usFilterOption; - Table* src = get_table(hSrc); - Table* dst = get_table(hDst); - if (!src || !dst) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - if (!src->driver() || !dst->driver()) { - return fail(openads::AE_INTERNAL_ERROR, "no driver"); - } - if (src->driver()->record_length() != dst->driver()->record_length()) { - return fail(openads::AE_INTERNAL_ERROR, - "record length mismatch between src and dst"); - } - auto src_count = src->driver()->record_count(); - for (std::uint32_t r = 1; r <= src_count; ++r) { - auto rec = src->driver()->read_record_raw(r); - if (!rec) return fail(rec.error()); - const auto& buf = rec.value(); - if (!buf.empty() && buf[0] == '*') continue; - auto a = dst->driver()->append_record_raw(buf.data(), buf.size()); - if (!a) return fail(a.error()); - } - if (auto fl = dst->flush(); !fl) return fail(fl.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsConvertTable(ADSHANDLE hHandle, - UNSIGNED16 usFilterOption, - UNSIGNED8* pucFile, - UNSIGNED16 /*usTargetType*/) { - arc2_trace("AdsConvertTable"); - // Single-format engine for now (CDX-flavoured DBF). Convert is a - // copy that mirrors the source format; once ADT / VFP land the - // target type will pick a different writer. - return AdsCopyTable(hHandle, usFilterOption, pucFile); -} - -UNSIGNED32 ENTRYPOINT AdsReindex(ADSHANDLE hTable) { - arc2_trace("AdsReindex"); - if (auto* rt = get_remote_table(hTable)) { - if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; - auto r = rt->conn->reindex(rt->id); - if (!r) return fail(r.error()); - return ok(); - } - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - auto r = t->reindex(); - if (!r) return fail(r.error()); - return ok(); -} - -// Tier-2 SQL push-down: for a SQL-backend table, translate a Clipper filter -// predicate into a SQL WHERE and install it on the backend so it filters -// server-side. Returns true when handled here (backend table); `rc` then holds -// the ABI return: ok() on a pushed filter, or a "not available" failure when -// the predicate can't be translated -- so the caller never assumes a filter we -// did not actually apply and filters client-side instead. -static bool backend_try_push_filter(ADSHANDLE hTable, - const std::string& clipper_pred, - UNSIGNED32& rc) { - auto* ops = openads::abi::backend_table_ops_for(hTable); - if (ops == nullptr || ops->set_filter == nullptr) return false; - openads::engine::SqlDialect dialect; // defaults suit SQLite / ANSI - auto where = openads::engine::try_emit_sql_where(clipper_pred, dialect); - if (where) { - rc = ops->set_filter( - hTable, reinterpret_cast(const_cast(where->c_str()))); - } else { - ops->set_filter(hTable, nullptr); // drop any stale backend filter - rc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, - "filter not pushable to SQL backend; caller filters client-side"); - } - return true; -} - -// M-AOF.3 -- wire AdsSetAOF / AdsClearAOF to the -// engine::aof::evaluate full-scan bitmap evaluator and install the -// resulting per-record bitmap as the table-level filter predicate. -// Skip / GoTop / GoBottom already honour the predicate, so the -// ABI surface gets correct AOF semantics today; M-AOF.4 will swap -// individual leaves to index range scans without changing this -// entry-point contract. -// -// AdsGetAOFOptLevel still reports ADS_OPTIMIZED_NONE because the -// V1 bitmap is built by a full table scan -- no indexes are -// consulted yet. M-AOF.4 will start reporting PART / FULL based -// on per-leaf coverage. The "is an AOF currently installed at -// all?" signal is exposed separately so the ABI layer can keep -// AdsSetFilter and AdsSetAOF distinct. -UNSIGNED32 ENTRYPOINT AdsSetAOF(ADSHANDLE hTable, UNSIGNED8* pucCondition, - UNSIGNED16 /*usResolve*/) { - arc2_trace("AdsSetAOF"); - if (pucCondition == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, "AdsSetAOF: NULL condition"); - } - if (auto* rt = get_remote_table(hTable)) { - if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; - std::string cond = openads::abi::to_internal(pucCondition, 0); - auto r = rt->conn->set_aof(rt->id, cond); - if (!r) return fail(r.error()); - rt->aof_expr = cond; - rt->row_valid = false; - rt->prefetch_queue.clear(); - // Replacement filter can narrow to empty or widen to rows: - // expire the nav stamp, proven-false answers and cached answers. - rt->last_nav = 0; - rt->pair_valid = false; - rt->anchor_ok = false; - rt->nav_not_bof = false; - rt->nav_not_eof = false; - remote_nav_bound_clear(rt); - return ok(); - } - if (UNSIGNED32 rc = 0; - backend_try_push_filter(hTable, openads::abi::to_internal(pucCondition, 0), rc)) { - return rc; - } - Table* t = get_table(hTable); - if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (pucCondition == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, "AdsSetAOF: NULL condition"); - } - auto cond = openads::abi::to_internal(pucCondition, 0); - auto ast = openads::engine::aof::parse(cond); - if (!ast) { - // An expression outside the optimisable AOF subset (e.g. - // `Empty(NAME)`, `UPPER(NAME) = 'A'`) cannot be turned into a - // server-side AOF. Return a non-success code so the client RDD - // (rddads) falls back to its own client-side row filter. This - // mirrors real ADS, which errors when an AOF cannot be built. - // Returning "success + OPTIMIZED_NONE" reads correct in - // isolation, but stock rddads decides whether to run its own - // client-side row filter purely from AdsSetAOF's return value -- - // it does not call AdsGetAOFOptLevel -- so on AE_SUCCESS it - // assumes the server optimised the filter and skips the - // client-side pass, turning SET FILTER into a no-op. - t->clear_filter(); - return fail(openads::AE_INVALID_EXPRESSION, - "AOF expression not optimisable; client filters"); - } - // Route through the M-AOF.4 index-accelerated evaluator: every - // leaf that hits an open CDX/NTX index whose key expression is - // the field name turns into a range scan; the rest fall back - // to a per-record AST evaluation. The cached OptLevel feeds - // AdsGetAOFOptLevel so callers see PART/FULL when their leaves - // were served by an index. - auto rep = openads::engine::aof::evaluate_optimised(*ast.value(), *t); - if (!rep) return fail(rep.error()); - t->install_aof_bitmap(std::move(rep.value().bm)); - t->set_aof_expr(cond); - int lvl = ADS_OPTIMIZED_NONE; - switch (rep.value().level) { - case openads::engine::aof::OptLevel::None: lvl = ADS_OPTIMIZED_NONE; break; - case openads::engine::aof::OptLevel::Part: lvl = ADS_OPTIMIZED_PART; break; - case openads::engine::aof::OptLevel::Full: lvl = ADS_OPTIMIZED_FULL; break; - } - t->set_aof_opt_level(lvl); - return ok(); -} - -// AdsSetAOF100 -- wide/dual-encoding sibling of AdsSetAOF. Per the SAP -// docs, ulOptions can additionally carry ADS_ENCODE_UTF8 / ADS_ENCODE_UTF16 -// to select the encoding of pvFilter; the numeric values of those flags -// are not published anywhere available to this build, so only the -// default (neither flag set) ANSI/OEM path is handled here -- pvFilter -// is read the same as AdsSetAOF's pucFilter. usResolve/ulOptions carry -// no other effect since AdsSetAOF itself does not consult them yet. -UNSIGNED32 ENTRYPOINT AdsSetAOF100(ADSHANDLE hTable, void* pvFilter, - UNSIGNED32 /*ulOptions*/) { - arc2_trace("AdsSetAOF100"); - return AdsSetAOF(hTable, reinterpret_cast(pvFilter), 0); -} - -UNSIGNED32 ENTRYPOINT AdsEvalAOF100(ADSHANDLE hTable, void* pvExpr, - UNSIGNED32 /*ulOptions*/, UNSIGNED16* pusOptLevel) { - arc2_trace("AdsEvalAOF100"); - return AdsEvalAOF(hTable, reinterpret_cast(pvExpr), - pusOptLevel); -} - -UNSIGNED32 ENTRYPOINT AdsGetAOFOptLevel(ADSHANDLE hTable, UNSIGNED16* pusLevel, - UNSIGNED8* /*pucBuf*/, UNSIGNED16* /*pusLen*/) { - arc2_trace("AdsGetAOFOptLevel"); - if (auto* rt = get_remote_table(hTable)) { - auto r = rt->conn->get_aof_opt_level(rt->id); - if (!r) return fail(r.error()); - if (pusLevel != nullptr) *pusLevel = r.value(); - return ok(); - } -#if defined(OPENADS_WITH_SQLITE) - if (auto* st = get_sqlite_table(hTable)) { - if (pusLevel != nullptr) { - *pusLevel = st->aof_opt_level != 0 - ? st->aof_opt_level - : static_cast(ADS_OPTIMIZED_NONE); - } - return ok(); - } -#endif -#if defined(OPENADS_WITH_POSTGRESQL) - if (auto* st = get_postgres_table(hTable)) { - if (pusLevel != nullptr) { - *pusLevel = st->aof_opt_level != 0 - ? st->aof_opt_level - : static_cast(ADS_OPTIMIZED_NONE); - } - return ok(); - } -#endif -#if defined(OPENADS_WITH_MARIADB) - if (auto* st = get_maria_table(hTable)) { - if (pusLevel != nullptr) { - *pusLevel = st->aof_opt_level != 0 - ? st->aof_opt_level - : static_cast(ADS_OPTIMIZED_NONE); - } - return ok(); - } -#endif -#if defined(OPENADS_WITH_MSSQL) - if (auto* st = get_mssql_table(hTable)) { - if (pusLevel != nullptr) { - *pusLevel = st->aof_opt_level != 0 - ? st->aof_opt_level - : static_cast(ADS_OPTIMIZED_NONE); - } - return ok(); - } -#endif -#if defined(OPENADS_WITH_FIREBIRD) - if (auto* st = get_firebird_table(hTable)) { - if (pusLevel != nullptr) { - *pusLevel = st->aof_opt_level != 0 - ? st->aof_opt_level - : static_cast(ADS_OPTIMIZED_NONE); - } - return ok(); - } -#endif -#if defined(OPENADS_WITH_ODBC) - if (auto* st = get_odbc_table(hTable)) { - if (pusLevel != nullptr) { - *pusLevel = st->aof_opt_level != 0 - ? st->aof_opt_level - : static_cast(ADS_OPTIMIZED_NONE); - } - return ok(); - } -#endif - Table* t = get_table(hTable); - int lvl = ADS_OPTIMIZED_NONE; - if (t != nullptr && t->aof_active()) { - lvl = t->aof_opt_level(); - if (lvl == 0) lvl = ADS_OPTIMIZED_NONE; - } - if (pusLevel != nullptr) { - *pusLevel = static_cast(lvl); - } - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsGetAOFOptLevel100(ADSHANDLE hTable, - UNSIGNED16* pusLevel, void* pvBuf, - UNSIGNED16* pusLen, UNSIGNED32 /*ulOptions*/) { - arc2_trace("AdsGetAOFOptLevel100"); - return AdsGetAOFOptLevel(hTable, pusLevel, - reinterpret_cast(pvBuf), pusLen); -} - -UNSIGNED32 ENTRYPOINT AdsClearAOF(ADSHANDLE hTable) { - arc2_trace("AdsClearAOF"); - if (auto* rt = get_remote_table(hTable)) { - if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; - auto r = rt->conn->clear_aof(rt->id); - if (!r) return fail(r.error()); - // Widening can un-empty the cursor: expire the nav stamp and - // cached boundary answers (the wire frame already expired the - // seq-gated state; this covers the seq-blind sticky). - rt->last_nav = 0; - rt->pair_valid = false; - rt->anchor_ok = false; - rt->nav_not_bof = false; - rt->nav_not_eof = false; - remote_nav_bound_clear(rt); - return ok(); - } - if (auto* ops = openads::abi::backend_table_ops_for(hTable); - ops && ops->set_filter) { - return ops->set_filter(hTable, nullptr); // clear backend filter - } - Table* t = get_table(hTable); - if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - t->clear_filter(); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsClearFilter(ADSHANDLE hTable) { - arc2_trace("AdsClearFilter"); - if (auto* rt = get_remote_table(hTable)) { - rt->filter_expr.clear(); - rt->last_nav = 0; // local visibility change: expire nav stamp - rt->pair_valid = false; - rt->anchor_ok = false; - rt->nav_not_bof = false; // widening-safe to keep; uniformity wins - rt->nav_not_eof = false; - remote_nav_bound_clear(rt); - return ok(); - } - if (auto* ops = openads::abi::backend_table_ops_for(hTable); - ops && ops->set_filter) { - return ops->set_filter(hTable, nullptr); - } - Table* t = get_table(hTable); - if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - t->set_filter(nullptr); - t->set_filter_expr(""); - return ok(); -} - -// --- M4 memo + autoinc + encryption ---------------------------------------- - -UNSIGNED32 ENTRYPOINT AdsBinaryToFile(ADSHANDLE hTable, UNSIGNED8* pucField, - UNSIGNED8* pucPath) { - arc2_trace("AdsBinaryToFile"); - if (pucPath == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, ""); - } - auto write_path = [&](const std::string& payload) -> UNSIGNED32 { - auto path = openads::abi::to_internal(pucPath, 0); - auto fres = openads::platform::File::open( - path, openads::platform::OpenMode::CreateRW); - if (!fres) return fail(fres.error()); - auto file = std::move(fres).value(); - auto wrote = file.write_at(0, payload.data(), payload.size()); - if (!wrote) return fail(wrote.error()); - return ok(); - }; - if (auto* rt = get_remote_table(hTable)) { - if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; - auto i = remote_field_index(rt, pucField); - if (i == std::numeric_limits::max() || i >= rt->fields.size()) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - auto v = rt->conn->get_field(rt->id, rt->fields[i].name); - if (!v) return fail(v.error()); - return write_path(v.value()); - } - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); - auto name = openads::abi::to_internal(pucField, 0); - std::int32_t idx = t->field_index(name); - if (idx < 0) return fail(openads::AE_COLUMN_NOT_FOUND, name.c_str()); - auto v = t->read_field(static_cast(idx)); - if (!v) return fail(v.error()); - return write_path(v.value().as_string); -} - -UNSIGNED32 ENTRYPOINT AdsBinaryToFileW(ADSHANDLE hTable, UNSIGNED8* pucField, - UNSIGNED16* pwcPath) { - arc2_trace("AdsBinaryToFileW"); - if (pwcPath == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - std::string path; - utf16z_to_utf8(pwcPath, &path); - std::vector bytes(path.begin(), path.end()); - bytes.push_back(0); - return AdsBinaryToFile(hTable, pucField, bytes.data()); -} - -UNSIGNED32 ENTRYPOINT AdsFileToBinary(ADSHANDLE hTable, UNSIGNED8* pucField, - UNSIGNED16 /*usType*/, UNSIGNED8* pucPath) { - arc2_trace("AdsFileToBinary"); - if (pucPath == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - auto path = openads::abi::to_internal(pucPath, 0); - auto fres = openads::platform::File::open( - path, openads::platform::OpenMode::ReadOnly); - if (!fres) return fail(fres.error()); - auto file = std::move(fres).value(); - auto sz = file.size(); - if (!sz) return fail(sz.error()); - std::string payload; - payload.resize(static_cast(sz.value())); - if (!payload.empty()) { - auto rd = file.read_at(0, payload.data(), payload.size()); - if (!rd) return fail(rd.error()); - } - if (auto* rt = get_remote_table(hTable)) { - remote_settle_cursor(rt); // M12.21 option C - auto i = remote_field_index(rt, pucField); - if (i == std::numeric_limits::max() || i >= rt->fields.size()) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - std::string fname = rt->fields[i].name; - return remote_buffered_set(rt, fname, payload); - } - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); - auto name = openads::abi::to_internal(pucField, 0); - std::int32_t idx = t->field_index(name); - if (idx < 0) return fail(openads::AE_COLUMN_NOT_FOUND, name.c_str()); - auto r = t->set_field(static_cast(idx), payload); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsFileToBinaryW(ADSHANDLE hTable, UNSIGNED8* pucField, - UNSIGNED16 usType, UNSIGNED16* pwcPath) { - arc2_trace("AdsFileToBinaryW"); - if (pwcPath == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - std::string path; - utf16z_to_utf8(pwcPath, &path); - std::vector bytes(path.begin(), path.end()); - bytes.push_back(0); - return AdsFileToBinary(hTable, pucField, usType, bytes.data()); -} - -// --- M9.13 binary memo (ADS_BINARY / ADS_IMAGE) ---------------------------- -// -// rddads' adsGetValue / adsPutValue branch for ADS_BINARY+ADS_IMAGE -// fields call this trio instead of AdsGetString/AdsSetString so the -// payload is treated as raw bytes (length-prefixed, no NUL trimming, -// embedded zeros preserved). The engine stores the bytes through the -// existing memo store with an explicit FPT block-type tag, and reads -// back the field as bytes plus an offset window so the caller can do -// chunked reads through a small fixed-size buffer. - -UNSIGNED32 ENTRYPOINT AdsGetBinaryLength(ADSHANDLE hTable, UNSIGNED8* pucField, - UNSIGNED32* pulLength) { - arc2_trace("AdsGetBinaryLength"); - Table* t = get_table(hTable); - if (!t || pulLength == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, ""); - } - std::uint16_t idx = 0; - if (!resolve_field_index(t, pucField, &idx)) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - auto v = t->read_field(idx); - if (!v) return fail(v.error()); - *pulLength = static_cast(v.value().as_string.size()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsGetBinary(ADSHANDLE hTable, UNSIGNED8* pucField, - UNSIGNED32 ulOffset, UNSIGNED8* pucBuf, - UNSIGNED32* pulLen) { - arc2_trace("AdsGetBinary"); - Table* t = get_table(hTable); - if (!t || pulLen == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, ""); - } - std::uint16_t idx = 0; - if (!resolve_field_index(t, pucField, &idx)) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - auto v = t->read_field(idx); - if (!v) return fail(v.error()); - const std::string& s = v.value().as_string; - UNSIGNED32 cap = *pulLen; - UNSIGNED32 n = 0; - if (ulOffset < s.size()) { - UNSIGNED32 remaining = static_cast(s.size() - ulOffset); - n = cap < remaining ? cap : remaining; - if (pucBuf != nullptr && n > 0) { - std::memcpy(pucBuf, s.data() + ulOffset, n); - } - } - *pulLen = n; - return ok(); -} - -// M9.16: chunked AdsSetBinary writes. The accumulator below holds -// pending bytes per (Table*, field_idx) pair until the caller has -// delivered every byte of `ulTotalBytes`; only then does the payload -// land in the memo store via set_field_binary. Stale accumulators are -// scrubbed when the table closes (purge_pending_binaries_for_table). - -namespace { - -struct PendingBinaryKey { - Table* table; - std::uint16_t field; - bool operator==(const PendingBinaryKey& o) const noexcept { - return table == o.table && field == o.field; - } -}; -struct PendingBinaryHash { - std::size_t operator()(const PendingBinaryKey& k) const noexcept { - return std::hash{}(k.table) ^ - (static_cast(k.field) << 1); - } -}; -struct PendingBinary { - std::string payload; - std::uint32_t total = 0; - openads::drivers::MemoBlockType type = - openads::drivers::MemoBlockType::Object; -}; - -extern "C++" -std::unordered_map& -pending_binaries() { - static std::unordered_map m; - return m; -} - -openads::drivers::MemoBlockType -map_binary_type(UNSIGNED16 usBinaryType) { - if (usBinaryType == ADS_IMAGE) { - return openads::drivers::MemoBlockType::Picture; - } - if (usBinaryType == ADS_STRING || usBinaryType == ADS_MEMO) { - return openads::drivers::MemoBlockType::Text; - } - return openads::drivers::MemoBlockType::Object; -} - -} // namespace - -} // extern "C" - -void purge_pending_binaries_for_table(openads::engine::Table* t) { - using openads::engine::Table; - auto& m = pending_binaries(); - for (auto it = m.begin(); it != m.end(); ) { - if (it->first.table == t) it = m.erase(it); - else ++it; - } -} - -extern "C" { - -UNSIGNED32 ENTRYPOINT AdsSetBinary(ADSHANDLE hTable, UNSIGNED8* pucField, - UNSIGNED16 usBinaryType, - UNSIGNED32 ulTotalBytes, UNSIGNED32 ulOffset, - UNSIGNED8* pucBuf, UNSIGNED32 ulBytes) { - arc2_trace("AdsSetBinary"); - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); - std::uint16_t idx = 0; - if (!resolve_field_index(t, pucField, &idx)) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - auto type = map_binary_type(usBinaryType); - - // Single-shot fast path. No accumulator state is created when the - // caller delivers the whole payload in one go. - if (ulOffset == 0 && ulBytes == ulTotalBytes) { - // Drop any stale accumulator from a prior aborted chunked write. - pending_binaries().erase(PendingBinaryKey{t, idx}); - std::string payload; - if (pucBuf != nullptr && ulBytes > 0) { - payload.assign(reinterpret_cast(pucBuf), ulBytes); - } - auto r = t->set_field_binary(idx, payload, type); - if (!r) return fail(r.error()); - return ok(); - } - - // Chunked path. Accumulate at the caller's offset; flush when the - // payload reaches the announced total. - auto& m = pending_binaries(); - PendingBinaryKey key{t, idx}; - auto it = m.find(key); - if (ulOffset == 0) { - // First chunk -- reset (or create) the accumulator and lock in - // the announced total + binary type. - if (it != m.end()) it->second = PendingBinary{}; - else it = m.emplace(key, PendingBinary{}).first; - it->second.total = ulTotalBytes; - it->second.type = type; - it->second.payload.assign(static_cast(ulTotalBytes), - '\0'); - } else { - if (it == m.end()) { - return fail(openads::AE_INTERNAL_ERROR, - "chunked AdsSetBinary: no pending payload"); - } - if (ulTotalBytes != it->second.total) { - return fail(openads::AE_INTERNAL_ERROR, - "chunked AdsSetBinary: total bytes changed mid-write"); - } - } - if (static_cast(ulOffset) + - static_cast(ulBytes) > - static_cast(it->second.total)) { - m.erase(it); - return fail(openads::AE_INTERNAL_ERROR, - "chunked AdsSetBinary: chunk runs past total"); - } - if (pucBuf != nullptr && ulBytes > 0) { - std::memcpy(it->second.payload.data() + ulOffset, pucBuf, ulBytes); - } - - if (ulOffset + ulBytes == it->second.total) { - std::string payload = std::move(it->second.payload); - auto pending_type = it->second.type; - m.erase(it); - auto r = t->set_field_binary(idx, payload, pending_type); - if (!r) return fail(r.error()); - } - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsGetLastAutoinc(ADSHANDLE hTable, UNSIGNED32* pulValue) { - arc2_trace("AdsGetLastAutoinc"); - if (pulValue == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (auto* rt = get_remote_table(hTable)) { - auto r = rt->conn->get_last_autoinc(rt->id); - if (!r) return fail(r.error()); - *pulValue = r.value(); - return ok(); - } - Table* t = get_table(hTable); - if (!t || pulValue == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, ""); - } - // ADT/VFP autoinc tracking lands when those drivers gain extended - // type support. For now report 0 -- the field still reads as part - // of the record buffer for non-autoinc types. - *pulValue = 0; - return ok(); -} - -// Encryption thunks. The AES primitive is real (engine::Aes, validated -// against FIPS-197 / NIST SP 800-38A); the record-level boundary that -// marks a table encrypted on disk and re-keys per-record is part of a -// later milestone alongside ADT, since ADS-original encryption mode -// is not yet documented byte-for-byte. The thunks below behave as -// no-ops or fail with AE_FUNCTION_NOT_AVAILABLE. - -UNSIGNED32 ENTRYPOINT AdsEnableEncryption(ADSHANDLE /*hConnect*/, UNSIGNED8* /*pucPassword*/) { - arc2_trace("AdsEnableEncryption"); - return fail(openads::AE_FUNCTION_NOT_AVAILABLE, - "AdsEnableEncryption pending ADS encryption-mode RE"); -} - -UNSIGNED32 ENTRYPOINT AdsDisableEncryption(ADSHANDLE /*hConnect*/) { - arc2_trace("AdsDisableEncryption"); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsIsEncryptionEnabled(ADSHANDLE /*hConnect*/, UNSIGNED16* pbEnabled) { - arc2_trace("AdsIsEncryptionEnabled"); - if (pbEnabled != nullptr) *pbEnabled = 0; - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsIsTableEncrypted(ADSHANDLE hTable, UNSIGNED16* pbEncrypted) { - arc2_trace("AdsIsTableEncrypted"); - if (pbEncrypted == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *pbEncrypted = 0; - Table* t = get_table(hTable); - if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "invalid table"); - auto* cdx = dynamic_cast(t->driver()); - if (!cdx) return ok(); - if (cdx->encrypted() || cdx->partial_encrypted()) *pbEncrypted = 1; - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsIsRecordEncrypted(ADSHANDLE hTable, UNSIGNED16* pbEncrypted) { - arc2_trace("AdsIsRecordEncrypted"); - if (pbEncrypted == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *pbEncrypted = 0; - Table* t = get_table(hTable); - if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "invalid table"); - if (!t->positioned()) - return fail(openads::AE_NO_CURRENT_RECORD, "no current record"); - auto* cdx = dynamic_cast(t->driver()); - if (!cdx) return ok(); - if (cdx->encrypted() || cdx->record_encrypted(t->recno())) *pbEncrypted = 1; - return ok(); -} - -// M11.2 -- convert a plain CDX table to OpenADS-encrypted in place. -// Requires AdsSetEncryptionPassword to have been called on the -// owning connection (located by walking the registry for the -// connection whose tables include this Table*). -UNSIGNED32 ENTRYPOINT AdsEncryptTable(ADSHANDLE hTable) { - arc2_trace("AdsEncryptTable"); - auto& s = state(); - std::lock_guard lk(s.mu); - Table* t = s.registry.lookup
(hTable, HandleKind::Table); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "invalid table handle"); - Connection* owning = find_owning_connection(t); - if (!owning) return fail(openads::AE_INVALID_CONNECTION_HANDLE, - "table not owned by any connection"); - if (!owning->has_encryption_key()) { - return fail(openads::AE_FUNCTION_NOT_AVAILABLE, - "AdsSetEncryptionPassword required first"); - } - auto* cdx = dynamic_cast( - t->driver() ? t->driver()->unwrap() : nullptr); - if (!cdx) { - return fail(openads::AE_FUNCTION_NOT_AVAILABLE, - "encryption supported on CdxDriver tables only"); - } - auto r = cdx->encrypt_in_place(owning->encryption_key()); - if (!r) return fail(r.error()); - if (auto fl = t->flush(); !fl) return fail(fl.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsDecryptTable(ADSHANDLE /*hTable*/) { - arc2_trace("AdsDecryptTable"); - return fail(openads::AE_FUNCTION_NOT_AVAILABLE, - "AdsDecryptTable pending ADS encryption-mode RE"); -} - -UNSIGNED32 ENTRYPOINT AdsEncryptRecord(ADSHANDLE hTable) { - arc2_trace("AdsEncryptRecord"); - auto& s = state(); - std::lock_guard lk(s.mu); - Table* t = s.registry.lookup
(hTable, HandleKind::Table); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "invalid table handle"); - if (!t->positioned()) - return fail(openads::AE_NO_CURRENT_RECORD, "no current record"); - Connection* owning = find_owning_connection(t); - if (!owning) return fail(openads::AE_INVALID_CONNECTION_HANDLE, - "table not owned by any connection"); - if (!owning->has_encryption_key()) { - return fail(openads::AE_FUNCTION_NOT_AVAILABLE, - "AdsSetEncryptionPassword required first"); - } - auto* cdx = dynamic_cast(t->driver()); - if (!cdx) { - return fail(openads::AE_FUNCTION_NOT_AVAILABLE, - "encryption supported on CdxDriver tables only"); - } - if (cdx->encrypted()) return ok(); - auto r = cdx->encrypt_record_at(t->recno()); - if (!r) return fail(r.error()); - if (auto fl = t->flush(); !fl) return fail(fl.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsDecryptRecord(ADSHANDLE hTable) { - arc2_trace("AdsDecryptRecord"); - auto& s = state(); - std::lock_guard lk(s.mu); - Table* t = s.registry.lookup
(hTable, HandleKind::Table); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "invalid table handle"); - if (!t->positioned()) - return fail(openads::AE_NO_CURRENT_RECORD, "no current record"); - Connection* owning = find_owning_connection(t); - if (!owning) return fail(openads::AE_INVALID_CONNECTION_HANDLE, - "table not owned by any connection"); - if (!owning->has_encryption_key()) { - return fail(openads::AE_FUNCTION_NOT_AVAILABLE, - "AdsSetEncryptionPassword required first"); - } - auto* cdx = dynamic_cast(t->driver()); - if (!cdx) { - return fail(openads::AE_FUNCTION_NOT_AVAILABLE, - "encryption supported on CdxDriver tables only"); - } - auto r = cdx->decrypt_record_at(t->recno()); - if (!r) return fail(r.error()); - if (auto rf = t->refresh_record_buffer(); !rf) return fail(rf.error()); - if (auto fl = t->flush(); !fl) return fail(fl.error()); - return ok(); -} - -// --- M5 transaction surface ------------------------------------------------- - -UNSIGNED32 ENTRYPOINT AdsBeginTransaction(ADSHANDLE hConnect) { - arc2_trace("AdsBeginTransaction"); - auto& s = state(); - std::lock_guard lk(s.mu); - // Native connection path - Connection* c = lookup_connection(hConnect); - if (c) { - auto r = c->begin_tx(); - if (!r) return fail(r.error()); - return ok(); - } - // SQL backend path: dispatch BEGIN to the connection's TxManager -#if defined(OPENADS_WITH_SQLITE) - if (auto* sqc = get_sqlite_conn(hConnect)) { - ensure_sqlite_tx_wired(sqc); - sqc->tx_manager().begin(); - return ok(); - } -#endif -#if defined(OPENADS_WITH_POSTGRESQL) - if (auto* pgc = get_postgres_conn(hConnect)) { - ensure_postgres_tx_wired(pgc); - pgc->tx_manager().begin(); - return ok(); - } -#endif -#if defined(OPENADS_WITH_MARIADB) - if (auto* mc = get_maria_conn(hConnect)) { - ensure_maria_tx_wired(mc); - mc->tx_manager().begin(); - return ok(); - } -#endif -#if defined(OPENADS_WITH_ODBC) - if (auto* oc = get_odbc_conn(hConnect)) { - ensure_odbc_tx_wired(oc); - oc->tx_manager().begin(); - return ok(); - } -#endif -#if defined(OPENADS_WITH_FIREBIRD) - if (auto* fc = get_firebird_conn(hConnect)) { - ensure_firebird_tx_wired(fc); - fc->tx_manager().begin(); - return ok(); - } -#endif -#if defined(OPENADS_WITH_MSSQL) - if (auto* msc = get_mssql_conn(hConnect)) { - ensure_mssql_tx_wired(msc); - msc->tx_manager().begin(); - return ok(); - } -#endif - // Table handles -- route through the table's connection TxManager -#if defined(OPENADS_WITH_SQLITE) - if (auto* st = get_sqlite_table(hConnect)) { - if (st->conn) { - ensure_sqlite_tx_wired(st->conn); - st->conn->tx_manager().begin(); - return ok(); - } - } -#endif -#if defined(OPENADS_WITH_POSTGRESQL) - if (auto* pt = get_postgres_table(hConnect)) { - if (pt->conn) { - ensure_postgres_tx_wired(pt->conn); - pt->conn->tx_manager().begin(); - return ok(); - } - } -#endif -#if defined(OPENADS_WITH_MARIADB) - if (auto* mt = get_maria_table(hConnect)) { - if (mt->conn) { - ensure_maria_tx_wired(mt->conn); - mt->conn->tx_manager().begin(); - return ok(); - } - } -#endif -#if defined(OPENADS_WITH_ODBC) - if (auto* ot = get_odbc_table(hConnect)) { - if (ot->conn) { - ensure_odbc_tx_wired(ot->conn); - ot->conn->tx_manager().begin(); - return ok(); - } - } -#endif -#if defined(OPENADS_WITH_FIREBIRD) - if (auto* ft = get_firebird_table(hConnect)) { - if (ft->conn) { - ensure_firebird_tx_wired(ft->conn); - ft->conn->tx_manager().begin(); - return ok(); - } - } -#endif -#if defined(OPENADS_WITH_MSSQL) - if (auto* mst = get_mssql_table(hConnect)) { - if (mst->conn) { - ensure_mssql_tx_wired(mst->conn); - mst->conn->tx_manager().begin(); - return ok(); - } - } -#endif - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); -} - -UNSIGNED32 ENTRYPOINT AdsCommitTransaction(ADSHANDLE hConnect) { - arc2_trace("AdsCommitTransaction"); - auto& s = state(); - std::lock_guard lk(s.mu); - // Native connection path - Connection* c = lookup_connection(hConnect); - if (c) { - auto r = c->commit_tx(); - if (!r) return fail(r.error()); - return ok(); - } - // SQL backend path -#if defined(OPENADS_WITH_SQLITE) - if (auto* sqc = get_sqlite_conn(hConnect)) { - sqc->tx_manager().commit(); return ok(); - } -#endif -#if defined(OPENADS_WITH_POSTGRESQL) - if (auto* pgc = get_postgres_conn(hConnect)) { - pgc->tx_manager().commit(); return ok(); - } -#endif -#if defined(OPENADS_WITH_MARIADB) - if (auto* mc = get_maria_conn(hConnect)) { - mc->tx_manager().commit(); return ok(); - } -#endif -#if defined(OPENADS_WITH_ODBC) - if (auto* oc = get_odbc_conn(hConnect)) { - oc->tx_manager().commit(); return ok(); - } -#endif -#if defined(OPENADS_WITH_FIREBIRD) - if (auto* fc = get_firebird_conn(hConnect)) { - fc->tx_manager().commit(); return ok(); - } -#endif -#if defined(OPENADS_WITH_MSSQL) - if (auto* msc = get_mssql_conn(hConnect)) { - msc->tx_manager().commit(); return ok(); - } -#endif -#if defined(OPENADS_WITH_SQLITE) - if (auto* st = get_sqlite_table(hConnect)) { - if (st->conn) { st->conn->tx_manager().commit(); return ok(); } - } -#endif -#if defined(OPENADS_WITH_POSTGRESQL) - if (auto* pt = get_postgres_table(hConnect)) { - if (pt->conn) { pt->conn->tx_manager().commit(); return ok(); } - } -#endif -#if defined(OPENADS_WITH_MARIADB) - if (auto* mt = get_maria_table(hConnect)) { - if (mt->conn) { mt->conn->tx_manager().commit(); return ok(); } - } -#endif -#if defined(OPENADS_WITH_ODBC) - if (auto* ot = get_odbc_table(hConnect)) { - if (ot->conn) { ot->conn->tx_manager().commit(); return ok(); } - } -#endif -#if defined(OPENADS_WITH_FIREBIRD) - if (auto* ft = get_firebird_table(hConnect)) { - if (ft->conn) { ft->conn->tx_manager().commit(); return ok(); } - } -#endif -#if defined(OPENADS_WITH_MSSQL) - if (auto* mst = get_mssql_table(hConnect)) { - if (mst->conn) { mst->conn->tx_manager().commit(); return ok(); } - } -#endif - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); -} - -UNSIGNED32 ENTRYPOINT AdsRollbackTransaction(ADSHANDLE hConnect) { - arc2_trace("AdsRollbackTransaction"); - auto& s = state(); - std::lock_guard lk(s.mu); - // Native connection path - Connection* c = lookup_connection(hConnect); - if (c) { - auto r = c->rollback_tx(); - if (!r) return fail(r.error()); - return ok(); - } - // SQL backend path -#if defined(OPENADS_WITH_SQLITE) - if (auto* sqc = get_sqlite_conn(hConnect)) { - sqc->tx_manager().rollback(); return ok(); - } -#endif -#if defined(OPENADS_WITH_POSTGRESQL) - if (auto* pgc = get_postgres_conn(hConnect)) { - pgc->tx_manager().rollback(); return ok(); - } -#endif -#if defined(OPENADS_WITH_MARIADB) - if (auto* mc = get_maria_conn(hConnect)) { - mc->tx_manager().rollback(); return ok(); - } -#endif -#if defined(OPENADS_WITH_ODBC) - if (auto* oc = get_odbc_conn(hConnect)) { - oc->tx_manager().rollback(); return ok(); - } -#endif -#if defined(OPENADS_WITH_FIREBIRD) - if (auto* fc = get_firebird_conn(hConnect)) { - fc->tx_manager().rollback(); return ok(); - } -#endif -#if defined(OPENADS_WITH_MSSQL) - if (auto* msc = get_mssql_conn(hConnect)) { - msc->tx_manager().rollback(); return ok(); - } -#endif -#if defined(OPENADS_WITH_SQLITE) - if (auto* st = get_sqlite_table(hConnect)) { - if (st->conn) { st->conn->tx_manager().rollback(); return ok(); } - } -#endif -#if defined(OPENADS_WITH_POSTGRESQL) - if (auto* pt = get_postgres_table(hConnect)) { - if (pt->conn) { pt->conn->tx_manager().rollback(); return ok(); } - } -#endif -#if defined(OPENADS_WITH_MARIADB) - if (auto* mt = get_maria_table(hConnect)) { - if (mt->conn) { mt->conn->tx_manager().rollback(); return ok(); } - } -#endif -#if defined(OPENADS_WITH_ODBC) - if (auto* ot = get_odbc_table(hConnect)) { - if (ot->conn) { ot->conn->tx_manager().rollback(); return ok(); } - } -#endif -#if defined(OPENADS_WITH_FIREBIRD) - if (auto* ft = get_firebird_table(hConnect)) { - if (ft->conn) { ft->conn->tx_manager().rollback(); return ok(); } - } -#endif -#if defined(OPENADS_WITH_MSSQL) - if (auto* mst = get_mssql_table(hConnect)) { - if (mst->conn) { mst->conn->tx_manager().rollback(); return ok(); } - } -#endif - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); -} - -UNSIGNED32 ENTRYPOINT AdsInTransaction(ADSHANDLE hConnect, UNSIGNED16* pbInTx) { - arc2_trace("AdsInTransaction"); - auto& s = state(); - std::lock_guard lk(s.mu); - if (pbInTx == nullptr) { - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - } - // Native connection path - Connection* c = lookup_connection(hConnect); - if (c) { - *pbInTx = c->in_tx() ? 1 : 0; - return ok(); - } - // SQL backend path -#if defined(OPENADS_WITH_SQLITE) - if (auto* sqc = get_sqlite_conn(hConnect)) { - *pbInTx = sqc->tx_manager().in_transaction() ? 1 : 0; return ok(); - } -#endif -#if defined(OPENADS_WITH_POSTGRESQL) - if (auto* pgc = get_postgres_conn(hConnect)) { - *pbInTx = pgc->tx_manager().in_transaction() ? 1 : 0; return ok(); - } -#endif -#if defined(OPENADS_WITH_MARIADB) - if (auto* mc = get_maria_conn(hConnect)) { - *pbInTx = mc->tx_manager().in_transaction() ? 1 : 0; return ok(); - } -#endif -#if defined(OPENADS_WITH_ODBC) - if (auto* oc = get_odbc_conn(hConnect)) { - *pbInTx = oc->tx_manager().in_transaction() ? 1 : 0; return ok(); - } -#endif -#if defined(OPENADS_WITH_FIREBIRD) - if (auto* fc = get_firebird_conn(hConnect)) { - *pbInTx = fc->tx_manager().in_transaction() ? 1 : 0; return ok(); - } -#endif -#if defined(OPENADS_WITH_MSSQL) - if (auto* msc = get_mssql_conn(hConnect)) { - *pbInTx = msc->tx_manager().in_transaction() ? 1 : 0; return ok(); - } -#endif -#if defined(OPENADS_WITH_SQLITE) - if (auto* st = get_sqlite_table(hConnect)) { - if (st->conn) { - *pbInTx = st->conn->tx_manager().in_transaction() ? 1 : 0; - return ok(); - } - } -#endif -#if defined(OPENADS_WITH_POSTGRESQL) - if (auto* pt = get_postgres_table(hConnect)) { - if (pt->conn) { - *pbInTx = pt->conn->tx_manager().in_transaction() ? 1 : 0; - return ok(); - } - } -#endif -#if defined(OPENADS_WITH_MARIADB) - if (auto* mt = get_maria_table(hConnect)) { - if (mt->conn) { - *pbInTx = mt->conn->tx_manager().in_transaction() ? 1 : 0; - return ok(); - } - } -#endif -#if defined(OPENADS_WITH_ODBC) - if (auto* ot = get_odbc_table(hConnect)) { - if (ot->conn) { - *pbInTx = ot->conn->tx_manager().in_transaction() ? 1 : 0; - return ok(); - } - } -#endif -#if defined(OPENADS_WITH_FIREBIRD) - if (auto* ft = get_firebird_table(hConnect)) { - if (ft->conn) { - *pbInTx = ft->conn->tx_manager().in_transaction() ? 1 : 0; - return ok(); - } - } -#endif -#if defined(OPENADS_WITH_MSSQL) - if (auto* mst = get_mssql_table(hConnect)) { - if (mst->conn) { - *pbInTx = mst->conn->tx_manager().in_transaction() ? 1 : 0; - return ok(); - } - } -#endif - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); -} - -// M12.1 -- set the auto-commit threshold on a SQL backend connection. -// When threshold > 0, after threshold DML statements the connection -// auto-commits. When threshold = 0, auto-commit is disabled. -UNSIGNED32 ENTRYPOINT AdsSetAutoCommit(ADSHANDLE hConnect, - SIGNED32 nThreshold) { - arc2_trace("AdsSetAutoCommit"); - auto& s = state(); - std::lock_guard lk(s.mu); -#if !defined(OPENADS_WITH_SQLITE) && !defined(OPENADS_WITH_POSTGRESQL) && \ - !defined(OPENADS_WITH_MARIADB) && !defined(OPENADS_WITH_ODBC) && \ - !defined(OPENADS_WITH_FIREBIRD) && !defined(OPENADS_WITH_MSSQL) - (void)hConnect; - (void)nThreshold; -#endif - // SQL backend path -#if defined(OPENADS_WITH_SQLITE) - if (auto* sqc = get_sqlite_conn(hConnect)) { - sqc->tx_manager().auto_commit_threshold = nThreshold; return ok(); - } -#endif -#if defined(OPENADS_WITH_POSTGRESQL) - if (auto* pgc = get_postgres_conn(hConnect)) { - pgc->tx_manager().auto_commit_threshold = nThreshold; return ok(); - } -#endif -#if defined(OPENADS_WITH_MARIADB) - if (auto* mc = get_maria_conn(hConnect)) { - mc->tx_manager().auto_commit_threshold = nThreshold; return ok(); - } -#endif -#if defined(OPENADS_WITH_ODBC) - if (auto* oc = get_odbc_conn(hConnect)) { - oc->tx_manager().auto_commit_threshold = nThreshold; return ok(); - } -#endif -#if defined(OPENADS_WITH_FIREBIRD) - if (auto* fc = get_firebird_conn(hConnect)) { - fc->tx_manager().auto_commit_threshold = nThreshold; return ok(); - } -#endif -#if defined(OPENADS_WITH_MSSQL) - if (auto* msc = get_mssql_conn(hConnect)) { - msc->tx_manager().auto_commit_threshold = nThreshold; return ok(); - } -#endif -#if defined(OPENADS_WITH_SQLITE) - if (auto* st = get_sqlite_table(hConnect)) { - if (st->conn) { - st->conn->tx_manager().auto_commit_threshold = nThreshold; - return ok(); - } - } -#endif -#if defined(OPENADS_WITH_POSTGRESQL) - if (auto* pt = get_postgres_table(hConnect)) { - if (pt->conn) { - pt->conn->tx_manager().auto_commit_threshold = nThreshold; - return ok(); - } - } -#endif -#if defined(OPENADS_WITH_MARIADB) - if (auto* mt = get_maria_table(hConnect)) { - if (mt->conn) { - mt->conn->tx_manager().auto_commit_threshold = nThreshold; - return ok(); - } - } -#endif -#if defined(OPENADS_WITH_ODBC) - if (auto* ot = get_odbc_table(hConnect)) { - if (ot->conn) { - ot->conn->tx_manager().auto_commit_threshold = nThreshold; - return ok(); - } - } -#endif -#if defined(OPENADS_WITH_FIREBIRD) - if (auto* ft = get_firebird_table(hConnect)) { - if (ft->conn) { - ft->conn->tx_manager().auto_commit_threshold = nThreshold; - return ok(); - } - } -#endif -#if defined(OPENADS_WITH_MSSQL) - if (auto* mst = get_mssql_table(hConnect)) { - if (mst->conn) { - mst->conn->tx_manager().auto_commit_threshold = nThreshold; - return ok(); - } - } -#endif - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); -} - -// M11.2 -- set the encryption password on a connection. Affects -// every subsequent table open: encrypted tables (header byte 0xC3) -// transparently decrypt on read / encrypt on write using AES-256-CTR -// keyed off the (zero-padded) password bytes. OpenADS-only format -- -// not byte-compatible with SAP ADS encrypted .adt files. -// M11.8 -- OEM (CP437) ↔ ANSI (UTF-8 in this build) conversion -// helpers. `pucBuf` is read until a NUL byte. Output is written -// in place into the same buffer (caller must size for worst case -// -- UTF-8 may grow up to 3x); `pulLen` carries the input length -// in and the output length out. -UNSIGNED32 ENTRYPOINT AdsConvertOemToAnsi(UNSIGNED8* pucBuf, UNSIGNED32* pulLen) { - arc2_trace("AdsConvertOemToAnsi"); - if (pucBuf == nullptr || pulLen == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, ""); - } - const std::size_t in_len = static_cast(*pulLen); - // In-place callers must allocate at least 3x the OEM byte length - // (worst-case UTF-8 expansion). pulLen carries OEM length in. - const std::size_t buf_cap = in_len * 3u; - auto utf8 = openads::engine::cp437_to_utf8(pucBuf, in_len); - const std::size_t out_len = utf8.size(); - if (out_len > buf_cap) { - *pulLen = static_cast(out_len); - return fail(openads::AE_INSUFFICIENT_BUFFER, - "output buffer too small for UTF-8 expansion"); - } - std::memcpy(pucBuf, utf8.data(), out_len); - if (out_len < in_len) pucBuf[out_len] = '\0'; - *pulLen = static_cast(out_len); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsConvertAnsiToOem(UNSIGNED8* pucBuf, UNSIGNED32* pulLen) { - arc2_trace("AdsConvertAnsiToOem"); - if (pucBuf == nullptr || pulLen == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, ""); - } - const std::size_t in_len = static_cast(*pulLen); - auto cp = openads::engine::utf8_to_cp437( - reinterpret_cast(pucBuf), in_len); - const std::size_t out_len = cp.size(); - if (out_len > in_len) { - *pulLen = static_cast(out_len); - return fail(openads::AE_INSUFFICIENT_BUFFER, - "output buffer too small for OEM conversion"); - } - std::memcpy(pucBuf, cp.data(), out_len); - if (out_len < in_len) pucBuf[out_len] = '\0'; - *pulLen = static_cast(out_len); - return ok(); -} - -// M11.7 -- set the connection's string-compare collation. Names: -// `binary` (default) or `nocase`. Affects equality / range -// comparisons for Character columns in SQL WHERE. -UNSIGNED32 ENTRYPOINT AdsSetCollation(ADSHANDLE hConnect, UNSIGNED8* pucName) { - arc2_trace("AdsSetCollation"); - auto& s = state(); - std::lock_guard lk(s.mu); - Connection* c = s.registry.lookup( - hConnect, HandleKind::Connection); - if (!c || pucName == nullptr) { - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - } - auto name = openads::abi::to_internal(pucName, 0); - std::string upper; - upper.reserve(name.size()); - for (char ch : name) upper.push_back(static_cast( - std::toupper(static_cast(ch)))); - if (upper == "BINARY") { - c->set_collation(Connection::Collation::Binary); - c->set_oem_sort_table(nullptr); - c->set_oem_upper_table(nullptr); - openads::engine::set_active_oem_upper_table(nullptr); - } else if (upper == "NOCASE") { - c->set_collation(Connection::Collation::NoCase); - c->set_oem_sort_table(nullptr); - c->set_oem_upper_table(nullptr); - openads::engine::set_active_oem_upper_table(nullptr); - } else { - const auto* oem = openads::engine::lookup_oem_collation(name.c_str()); - if (oem == nullptr) { - return fail(openads::AE_FUNCTION_NOT_AVAILABLE, - "unknown collation name (expected BINARY / NOCASE / " - "NTXPL852 / PL852)"); - } - c->set_collation(Connection::Collation::Binary); - c->set_oem_sort_table(oem->sort); - const std::uint8_t* up = - openads::engine::lookup_oem_upper_table(name.c_str()); - c->set_oem_upper_table(up); - // Publish for expression evaluation (UPPER() / upper_bare in - // index_expr have no Connection context) -- see - // active_oem_upper_table in oem_collation.h. - openads::engine::set_active_oem_upper_table(up); - } - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsSetEncryptionPassword(ADSHANDLE hConnect, - UNSIGNED8* pucPassword) { - arc2_trace("AdsSetEncryptionPassword"); - auto& s = state(); - std::lock_guard lk(s.mu); - Connection* c = s.registry.lookup( - hConnect, HandleKind::Connection); - if (!c || pucPassword == nullptr) { - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - } - auto pw = openads::abi::to_internal(pucPassword, 0); - c->set_encryption_password(pw); - return ok(); -} - -// SAP / rddads signature: 3 args. ulOptions is reserved on the -// real ACE (must be ADS_DEFAULT); accept and ignore. -UNSIGNED32 ENTRYPOINT AdsCreateSavepoint(ADSHANDLE hConnect, UNSIGNED8* pucName, - UNSIGNED32 ulOptions) { - arc2_trace("AdsCreateSavepoint"); - (void)ulOptions; - auto& s = state(); - std::lock_guard lk(s.mu); - Connection* c = lookup_connection(hConnect); - if (!c || pucName == nullptr) { - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - } - auto name = openads::abi::to_internal(pucName, 0); - auto r = c->create_savepoint(name); - if (!r) return fail(r.error()); - return ok(); -} - -// M11.3 -- release a savepoint without rolling back. The work done -// since CreateSavepoint stays part of the enclosing transaction. -UNSIGNED32 ENTRYPOINT AdsReleaseSavepoint(ADSHANDLE hConnect, UNSIGNED8* pucName) { - arc2_trace("AdsReleaseSavepoint"); - auto& s = state(); - std::lock_guard lk(s.mu); - Connection* c = lookup_connection(hConnect); - if (!c || pucName == nullptr) { - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - } - auto name = openads::abi::to_internal(pucName, 0); - auto r = c->release_savepoint(name); - if (!r) return fail(r.error()); - return ok(); -} - -// SAP / rddads signature: 3 args. ulOptions is reserved on real -// ACE; accept and ignore. Null pucSavepoint => full rollback. -UNSIGNED32 ENTRYPOINT AdsRollbackTransaction80(ADSHANDLE hConnect, UNSIGNED8* pucSavepoint, - UNSIGNED32 ulOptions) { - arc2_trace("AdsRollbackTransaction80"); - (void)ulOptions; - auto& s = state(); - std::lock_guard lk(s.mu); - Connection* c = lookup_connection(hConnect); - if (!c) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - if (pucSavepoint == nullptr) { - // Full rollback if no savepoint name supplied (matches ACE legacy). - auto r = c->rollback_tx(); - if (!r) return fail(r.error()); - return ok(); - } - auto name = openads::abi::to_internal(pucSavepoint, 0); - auto r = c->rollback_to_savepoint(name); - if (!r) return fail(r.error()); - return ok(); -} - -// --- M9.12 Table directory iteration --------------------------------------- -// -// AdsFindFirstTable / AdsFindNextTable / AdsFindClose walk the -// connection's data directory and emit each entry whose name matches -// `pucMask` (FoxPro-style glob with `*` and `?`, case insensitive). -// Matches AE_SUCCESS while names remain; once exhausted, returns -// AE_NO_FILE_FOUND so the caller breaks out of its loop. The find -// handle is registered in the global registry under HandleKind::Find -// so it round-trips through ADSHANDLE without aliasing tables/cursors. - -namespace { - -// Truncate the matched filename into `pucBuf`, NUL-terminate when -// there's room, and report the on-wire length back through `pusLen` -// (matching the ACE convention rddads' AdsFindFirstTable/NextTable -// callers depend on). -UNSIGNED32 emit_name(UNSIGNED8* pucBuf, UNSIGNED16* pusLen, - const std::string& name) { - if (pusLen == nullptr) return openads::AE_INTERNAL_ERROR; - UNSIGNED16 cap = *pusLen; - UNSIGNED16 n = static_cast( - name.size() < cap ? name.size() : cap); - if (pucBuf != nullptr && cap > 0) { - std::memcpy(pucBuf, name.data(), n); - if (n < cap) pucBuf[n] = '\0'; - } - *pusLen = static_cast(name.size()); - arc2_log("EMIT name=[%s] len=%u cap=%u", - name.c_str(), (unsigned)name.size(), (unsigned)cap); - return openads::AE_SUCCESS; -} - -} // namespace - -UNSIGNED32 ENTRYPOINT AdsFindFirstTable(ADSHANDLE hConnect, - UNSIGNED8* pucMask, - UNSIGNED8* pucFileName, - UNSIGNED16* pusFileNameLen, - ADSHANDLE* phFind) { - arc2_trace("AdsFindFirstTable"); - arc2_trace("AdsFindFirstTable"); - auto& s = state(); - std::lock_guard lk(s.mu); - if (phFind == nullptr || pusFileNameLen == nullptr) { - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - } - std::string mask = pucMask - ? openads::abi::to_internal(pucMask, 0) - : std::string("*.dbf"); - if (mask.empty()) mask = "*.dbf"; - // ARC builds the find mask as "\", so with a - // tcp:// (or absolute local path) connection the whole URI/path arrives - // here. Matching is always done against the connection's data dir - // (locally or server-side), so reduce those to the bare file mask. - if (mask.find("://") != std::string::npos || - (mask.size() > 2 && mask[1] == ':') || - mask.rfind("\\\\", 0) == 0) { - if (auto sep = mask.find_last_of("/\\"); sep != std::string::npos) { - arc2_log("FIND mask [%s] -> [%s]", mask.c_str(), - mask.substr(sep + 1).c_str()); - mask = mask.substr(sep + 1); - } - } - - // M12.33 — remote path: send FindTables opcode, cache results locally. - ADSHANDLE rc_h = resolve_remote_conn_handle(hConnect); - if (auto* rc = get_remote_connection(rc_h)) { - auto r = rc->find_tables(mask); - if (!r) return fail(r.error()); - auto matches = std::move(r).value(); - if (matches.empty()) - return fail(openads::AE_NO_FILE_FOUND, mask.c_str()); - auto find = std::make_unique(); - find->matches = std::move(matches); - find->cursor = 1; - Connection::TableFind* raw = find.get(); - s.remote_finds.emplace_back(std::move(find)); - Handle gh = s.registry.register_object(HandleKind::Find, raw); - *phFind = to_ads_handle(gh); - return emit_name(pucFileName, pusFileNameLen, raw->matches.front()); - } - - Connection* c = s.registry.lookup(hConnect, HandleKind::Connection); - if (c == nullptr) { - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - } - - auto r = c->find_first_table(mask); - if (!r) return fail(r.error()); - - auto [find_ptr, name] = std::move(r).value(); - Handle gh = s.registry.register_object(HandleKind::Find, find_ptr); - *phFind = to_ads_handle(gh); - return emit_name(pucFileName, pusFileNameLen, name); -} - -UNSIGNED32 ENTRYPOINT AdsFindNextTable(ADSHANDLE hConnect, - ADSHANDLE hFind, - UNSIGNED8* pucFileName, - UNSIGNED16* pusFileNameLen) { - arc2_trace("AdsFindNextTable"); - arc2_trace("AdsFindNextTable"); - auto& s = state(); - std::lock_guard lk(s.mu); - (void)hConnect; // find state keyed by hFind only; silence C4100 (C2220 in CI) - if (pusFileNameLen == nullptr) { - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - } - auto* find = s.registry.lookup(hFind, HandleKind::Find); - if (find == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, "invalid find handle"); - } - if (find->cursor >= find->matches.size()) { - return fail(openads::AE_NO_FILE_FOUND, "no more files"); - } - return emit_name(pucFileName, pusFileNameLen, find->matches[find->cursor++]); -} - -UNSIGNED32 ENTRYPOINT AdsFindClose(ADSHANDLE hConnect, ADSHANDLE hFind) { - arc2_trace("AdsFindClose"); - arc2_trace("AdsFindClose"); - auto& s = state(); - std::lock_guard lk(s.mu); - (void)hConnect; // find state keyed by hFind only; silence C4100 (C2220 in CI) - auto* find = s.registry.lookup(hFind, HandleKind::Find); - if (find == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, "invalid find handle"); - } - s.registry.release(hFind); - return ok(); -} - -// --- M6 Data Dictionary ---------------------------------------------------- - -UNSIGNED32 ENTRYPOINT AdsDDCreate(UNSIGNED8* pucDictionary, UNSIGNED16 /*bEncrypt*/, - UNSIGNED8* /*pucAdminPassword*/, - ADSHANDLE* phConnect) { - arc2_trace("AdsDDCreate"); - if (pucDictionary == nullptr || phConnect == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, "null DD args"); - } - auto path = openads::abi::to_internal(pucDictionary, 0); - // Materialise an empty DD on disk, then open a Connection rooted at it. - auto created = openads::engine::DataDict::create(path); - if (!created) return fail(created.error()); - - auto opened = Connection::open(path); - if (!opened) return fail(opened.error()); - - auto holder = std::make_unique(std::move(opened).value()); - Connection* raw = holder.get(); - auto& s = state(); - std::lock_guard lk(s.mu); - Handle h = s.registry.register_object(HandleKind::Connection, raw); - s.conns.emplace(h, std::move(holder)); - *phConnect = to_ads_handle(h); - return ok(); -} - -// SAP signature (rddads): (hConn, name, file, fileType, charType, -// indexFile, comment). Matches Harbour's HB_FUNC(ADSDDADDTABLE). -UNSIGNED32 ENTRYPOINT AdsDDAddTable(ADSHANDLE hConnect, UNSIGNED8* pucAlias, - UNSIGNED8* pucTablePath, - UNSIGNED16 /*usFileType*/, - UNSIGNED16 /*usCharType*/, - UNSIGNED8* /*pucIndexPath*/, - UNSIGNED8* /*pucComment*/) { - arc2_trace("AdsDDAddTable"); - auto& s = state(); - std::lock_guard lk(s.mu); - Connection* c = s.registry.lookup(hConnect, HandleKind::Connection); - if (!c) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - if (!c->has_dd()) { - return fail(openads::AE_FUNCTION_NOT_AVAILABLE, - "connection has no data dictionary"); - } - if (pucAlias == nullptr || pucTablePath == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, "null DD-AddTable args"); - } - auto alias = openads::abi::to_internal(pucAlias, 0); - auto path = openads::abi::to_internal(pucTablePath, 0); - auto r = c->dd()->add_table(alias, path); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsDDRemoveTable(ADSHANDLE hConnect, UNSIGNED8* pucAlias, - UNSIGNED16 /*usDeleteFiles*/) { - arc2_trace("AdsDDRemoveTable"); - auto& s = state(); - std::lock_guard lk(s.mu); - Connection* c = s.registry.lookup(hConnect, HandleKind::Connection); - if (!c) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - if (!c->has_dd()) { - return fail(openads::AE_FUNCTION_NOT_AVAILABLE, - "connection has no data dictionary"); - } - if (pucAlias == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - auto alias = openads::abi::to_internal(pucAlias, 0); - auto r = c->dd()->remove_table(alias); - if (!r) return fail(r.error()); - return ok(); -} - -// --- M7.1 SQL surface ------------------------------------------------------- - -extern "C++" { - -namespace { - -struct SqlStatement { - Connection* conn = nullptr; - openads::network::RemoteConnection* remote = nullptr; -#if defined(OPENADS_WITH_SQLITE) - openads::sql_backend::SqliteConnection* sqlite = nullptr; -#endif -#if defined(OPENADS_WITH_MSSQL) - openads::sql_backend::MssqlConnection* mssql_conn = nullptr; -#endif -#if defined(OPENADS_WITH_POSTGRESQL) - openads::sql_backend::PostgresConnection* postgres = nullptr; -#endif -#if defined(OPENADS_WITH_MARIADB) - openads::sql_backend::MariaConnection* maria = nullptr; -#endif -#if defined(OPENADS_WITH_ODBC) - openads::sql_backend::OdbcConnection* odbc = nullptr; - openads::sql_backend::SqlDdlDialect odbc_dialect = - openads::sql_backend::SqlDdlDialect::Postgres; -#endif -#if defined(OPENADS_WITH_FIREBIRD) - openads::sql_backend::FirebirdConnection* firebird = nullptr; -#endif - std::string sql; - std::string sql_username; - // 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 - // no parameter map existed here. AdsPrepareSQL and AdsExecuteSQL had no - // substitution step, so calling bindXxx() on a prepared statement always - // fell through to get_table(), which knows nothing about statement handles, - // and returned [5000] unknown table. Adding params here gives AdsSet* a - // place to store :name -> SQL-literal pairs that AdsExecuteSQL can substitute - // before handing the final SQL to the parser. - std::unordered_map params; - UNSIGNED32 sql_timeout = 0; - // Per-statement table-open overrides set by AdsStmt* helpers. - // 0 = ADS_DEFAULT → CDX. NOTE (2026-07-28): this default is weaker - // than it looks, and callers cannot always correct it. Harbour's - // contrib/rddads calls AdsStmtSetTableType only when the requested - // type is ADS_CDX or ADS_VFP -- an ADS_ADT statement never reaches - // us, because on the real Advantage engine a statement already - // defaults to ADT. So an ADT table queried through Harbour arrives - // here as CDX and nothing in the SQL text can say otherwise when the - // file name carries no type (e.g. the RusSoft ERP names every table - // .DAT). Connection::resolve_table_file therefore sniffs the file - // header and overrides this on open; keep that in place before - // trusting table_type for anything that touches an existing file. - UNSIGNED16 table_type = 0; - UNSIGNED16 lock_type = 0; // 0 = default → compatible locking - UNSIGNED16 char_type = 0; - UNSIGNED16 read_only = 0; // non-zero → open read-only - UNSIGNED16 check_rights = 0; - bool disable_enc = false; - std::string collation; - std::vector> passwords; -}; - -std::unordered_map>& stmt_map() { - static std::unordered_map> m; - return m; -} - -std::unordered_map& sql_timeout_map() { - static std::unordered_map m; - return m; -} - -// stmt_map() is a process-wide table shared by every connection. Its -// structural operations (insert / find / erase) must all hold stmt_mu(); -// without it, an insert that rehashes the map while another thread walks a -// bucket corrupts the structure (the load-tested crash/hang at >= 8 threads). -// A dedicated mutex (not the global state lock) keeps query execution off the -// lock and avoids any cross-ordering with it. -std::mutex& stmt_mu() { - static std::mutex m; - return m; -} - -ADSHANDLE next_stmt_handle() { - // Atomic so concurrent AdsCreateSQLStatement calls never hand out a - // duplicate handle (the old non-atomic ++ could race two callers onto the - // same value). - static std::atomic n{0x60000000ULL}; - return static_cast(++n); -} - -// Look up a statement and return the raw pointer under the lock, then release: -// a statement is only ever executed by the thread that owns its handle, so the -// pointer stays valid for that thread while long query execution runs free of -// the lock. Returns nullptr if the handle is unknown. -SqlStatement* stmt_lookup(ADSHANDLE h) { - std::lock_guard lk(stmt_mu()); - auto& m = stmt_map(); - auto it = m.find(h); - return it == m.end() ? nullptr : it->second.get(); -} - -ADSHANDLE stmt_register(std::unique_ptr stmt) { - ADSHANDLE h = next_stmt_handle(); - std::lock_guard lk(stmt_mu()); - stmt_map()[h] = std::move(stmt); - return h; -} - -void stmt_unregister(ADSHANDLE h) { - std::lock_guard lk(stmt_mu()); - stmt_map().erase(h); - sql_timeout_map().erase(h); -} - -// RCB 2026-05-22 17:03 -- Statement handles live in stmt_map() which is a plain -// unordered_map keyed on the handle value (starting at 0x60000000). They are -// completely invisible to get_table(), which queries the separate HandleRegistry -// for HandleKind::Table. This helper is the single check point: if h is in -// stmt_map we store the value as a SQL literal string against the parameter name -// and return true so the caller skips the table path entirely. The parameter -// name may arrive with or without a leading colon depending on the caller. -bool set_stmt_param(ADSHANDLE h, const char* pname, std::string literal) { - std::lock_guard lk(stmt_mu()); - auto& m = stmt_map(); - auto it = m.find(h); - if (it == m.end()) return false; - std::string key(pname ? pname : ""); - if (!key.empty() && key[0] == ':') key.erase(0, 1); - it->second->params[key] = std::move(literal); - return true; -} - -UNSIGNED32 inherited_sql_timeout(ADSHANDLE hConnect) { - auto& m = sql_timeout_map(); - auto it = m.find(hConnect); - return it == m.end() ? 0 : it->second; -} - -openads::engine::TableType stmt_table_type(const SqlStatement& s) { - return map_type(s.table_type); -} - -openads::engine::OpenMode stmt_open_mode(const SqlStatement& s, bool for_write) { - if (s.read_only != 0) return openads::engine::OpenMode::Read; - return for_write ? openads::engine::OpenMode::Shared - : openads::engine::OpenMode::Read; -} - -openads::engine::LockingMode stmt_locking_mode(const SqlStatement& s) { - return (s.lock_type == ADS_PROPRIETARY_LOCKING) - ? openads::engine::LockingMode::Proprietary - : openads::engine::LockingMode::Compatible; -} - -// SQL DML (UPDATE/DELETE/INSERT/MERGE) opens tables Shared so multiuser -// readers can coexist, but writeback_record_() enforces a GoHot lock guard -// in Shared mode. Without an engine-held lock every SET/DELETE hits 5035 -// "record not locked" -- which broke AFTER-trigger bodies (UPDATE log SET…), -// plain SQL DML, NewSeqKey, and the unit suite after the write-guard landed -// (issue #138). Hold a table-exclusive lock for the life of the statement, -// matching the RI cascade path. Navigational multiuser still requires an -// explicit RLock/FLock; that contract is covered by engine_table_write_test. -inline void sql_dml_hold_write_lock(openads::engine::Table* tbl) { - if (tbl == nullptr || tbl->is_table_locked()) return; - (void)tbl->try_lock_table_excl(); -} - -// M-DML.IDX — production-index maintenance for SQL DML (Pritpal Bedi's -// .z01 work bags, Aug 2026). INSERT/UPDATE/DELETE/MERGE open the table -// through the ENGINE connection (Connection::open_table), which never -// binds the structural bag — so every SQL write left a production CDX -// stale, and the next handle to open it navigated a ghost order -// (bof=eof=1 Limbo over a non-empty table). SAP maintains the structural -// index on server-side DML. Bind .cdx (.adi for ADT) as parked -// extra views for the life of the statement so commit_dirty_record keeps -// it current. The guard flushes + frees the views at scope end; the -// unregister is skipped when the DML already closed the table (most paths) -// because the registry lookup then fails — touching the dead Table* would -// be UB. -struct DmlProductionIndexGuard { - openads::session::Connection* conn = nullptr; - openads::session::Handle th = 0; - std::vector> owned; - ~DmlProductionIndexGuard() { - if (owned.empty()) return; - if (conn != nullptr) { - if (auto* t = conn->lookup_table(th); t != nullptr) { - for (auto& v : owned) t->unregister_extra_index_view(v.get()); - } - } - for (auto& v : owned) (void)v->flush(); - } -}; - -void dml_bind_production_index(openads::session::Connection* c, - openads::session::Handle th, - DmlProductionIndexGuard& g) { - namespace fs = std::filesystem; - auto* tbl = c->lookup_table(th); - if (tbl == nullptr) return; - fs::path tp(tbl->path()); - std::string ext = tp.extension().string(); - for (auto& ch : ext) - ch = static_cast(std::tolower(static_cast(ch))); - fs::path bag; - bool use_cdx = false; - if (ext == ".dbf") { - // Structural candidates for a DBF: .cdx, else .z01 — - // Pritpal's ERP keeps its compound bags under a custom extension - // (CDX format by content). SQL DML must maintain whichever exists - // or every INSERT leaves the bag partially stale. - bag = tp; bag.replace_extension(".cdx"); use_cdx = true; - std::error_code ec1; - if (!fs::exists(openads::platform::resolve_case_insensitive( - bag.string()), ec1)) { - bag = tp; bag.replace_extension(".z01"); - } - } else if (ext == ".adt" || ext == ".dat") { - bag = tp; bag.replace_extension(".adi"); - } else { - return; - } - std::error_code ec; - const std::string bag_path = - openads::platform::resolve_case_insensitive(bag.string()); - if (!fs::exists(bag_path, ec)) return; - std::vector tags; - if (use_cdx) { - auto r = openads::drivers::cdx::CdxIndex::list_tags(bag_path); - if (!r) return; - tags = std::move(r).value(); - } else { - auto r = openads::drivers::adi::AdiIndex::list_tags(bag_path, - tbl->path()); - if (!r) return; - tags = std::move(r).value(); - } - for (const auto& name : tags) { - std::unique_ptr sub; - if (use_cdx) { - auto idx = std::make_unique(); - if (auto r = idx->open_named(bag_path, - openads::drivers::IndexOpenMode::Shared, name); !r) { - continue; - } - mark_cdx_key_encoding(tbl, idx.get()); - apply_cdx_oem_collation(tbl, idx.get()); - sub = std::move(idx); - } else { - auto idx = std::make_unique(); - if (auto r = idx->open_named(bag_path, - openads::drivers::IndexOpenMode::Shared, name, - tbl->path()); !r) { - continue; - } - sub = std::move(idx); - } - tbl->register_extra_index_view(sub.get()); - g.owned.push_back(std::move(sub)); - } - if (!g.owned.empty()) { g.conn = c; g.th = th; } -} - -} // namespace - -} // extern "C++" - -UNSIGNED32 ENTRYPOINT AdsCreateSQLStatement(ADSHANDLE hConnect, ADSHANDLE* phStatement) { - arc2_trace("AdsCreateSQLStatement"); - arc2_trace("AdsCreateSQLStatement"); - if (phStatement == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - auto& s = state(); - std::lock_guard lk(s.mu); - if (auto* rc = s.registry.lookup( - hConnect, HandleKind::RemoteConnection)) { - auto stmt = std::make_unique(); - stmt->remote = rc; - stmt->sql_timeout = inherited_sql_timeout(hConnect); - *phStatement = stmt_register(std::move(stmt)); - return ok(); - } -#if defined(OPENADS_WITH_SQLITE) - if (auto* sc = s.registry.lookup( - hConnect, HandleKind::SqliteConnection)) { - auto stmt = std::make_unique(); - stmt->sqlite = sc; - stmt->sql_timeout = inherited_sql_timeout(hConnect); - stmt->sql_username = sql_uri_username(hConnect); - *phStatement = stmt_register(std::move(stmt)); - return ok(); - } -#endif -#if defined(OPENADS_WITH_MSSQL) - if (auto* mc = s.registry.lookup( - hConnect, HandleKind::MssqlConnection)) { - auto stmt = std::make_unique(); - stmt->mssql_conn = mc; - stmt->sql_timeout = inherited_sql_timeout(hConnect); - stmt->sql_username = sql_uri_username(hConnect); - *phStatement = stmt_register(std::move(stmt)); - return ok(); - } -#endif -#if defined(OPENADS_WITH_POSTGRESQL) - if (auto* pc = get_postgres_conn(hConnect)) { - auto stmt = std::make_unique(); - stmt->postgres = pc; - stmt->sql_timeout = inherited_sql_timeout(hConnect); - stmt->sql_username = sql_uri_username(hConnect); - *phStatement = stmt_register(std::move(stmt)); - return ok(); - } -#endif -#if defined(OPENADS_WITH_MARIADB) - if (auto* mc = s.registry.lookup( - hConnect, HandleKind::MariaConnection)) { - auto stmt = std::make_unique(); - stmt->maria = mc; - stmt->sql_timeout = inherited_sql_timeout(hConnect); - stmt->sql_username = sql_uri_username(hConnect); - *phStatement = stmt_register(std::move(stmt)); - return ok(); - } -#endif -#if defined(OPENADS_WITH_ODBC) - if (auto* oc = s.registry.lookup( - hConnect, HandleKind::OdbcConnection)) { - auto stmt = std::make_unique(); - stmt->odbc = oc; - stmt->sql_timeout = inherited_sql_timeout(hConnect); - stmt->odbc_dialect = odbc_dialect_for(hConnect); - stmt->sql_username = sql_uri_username(hConnect); - *phStatement = stmt_register(std::move(stmt)); - return ok(); - } -#endif -#if defined(OPENADS_WITH_FIREBIRD) - if (auto* fc = s.registry.lookup( - hConnect, HandleKind::FirebirdConnection)) { - auto stmt = std::make_unique(); - stmt->firebird = fc; - stmt->sql_timeout = inherited_sql_timeout(hConnect); - stmt->sql_username = sql_uri_username(hConnect); - *phStatement = stmt_register(std::move(stmt)); - 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(); - stmt->conn = c; - stmt->sql_timeout = inherited_sql_timeout(hConnect); - *phStatement = stmt_register(std::move(stmt)); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsCloseSQLStatement(ADSHANDLE hStatement) { - arc2_trace("AdsCloseSQLStatement"); - arc2_trace("AdsCloseSQLStatement"); - stmt_unregister(hStatement); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsPrepareSQL(ADSHANDLE hStatement, UNSIGNED8* pucSQL) { - arc2_trace("AdsPrepareSQL"); - arc2_trace("AdsPrepareSQL"); - SqlStatement* st = stmt_lookup(hStatement); - if (st == nullptr) return fail(openads::AE_INTERNAL_ERROR, "unknown stmt"); - st->sql = openads::abi::to_internal(pucSQL, 0); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsPrepareSQLW(ADSHANDLE hStatement, UNSIGNED16* pwcSQL) { - arc2_trace("AdsPrepareSQLW"); - arc2_trace("AdsPrepareSQLW"); - std::string sql; - utf16z_to_utf8(pwcSQL, &sql); - std::vector bytes(sql.begin(), sql.end()); - bytes.push_back(0); - return AdsPrepareSQL(hStatement, bytes.data()); -} - -UNSIGNED32 ENTRYPOINT AdsGetNumParams(ADSHANDLE hStatement, UNSIGNED16* pusNumParams) { - arc2_trace("AdsGetNumParams"); - if (!pusNumParams) return fail(openads::AE_INTERNAL_ERROR, ""); - SqlStatement* st = stmt_lookup(hStatement); - if (st == nullptr) return fail(openads::AE_INTERNAL_ERROR, "unknown stmt"); - const std::string& sql = st->sql; - std::unordered_set names; - for (std::size_t i = 0; i < sql.size(); ) { - if (sql[i] == ':' && i + 1 < sql.size() && - (std::isalpha((unsigned char)sql[i + 1]) || sql[i + 1] == '_')) { - std::size_t j = i + 1; - while (j < sql.size() && - (std::isalnum((unsigned char)sql[j]) || sql[j] == '_')) - ++j; - names.insert(sql.substr(i + 1, j - (i + 1))); - i = j; - } else { - ++i; - } - } - *pusNumParams = static_cast(names.size()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsExecuteSQL(ADSHANDLE hStatement, ADSHANDLE* phCursor) { - arc2_trace("AdsExecuteSQL"); - SqlStatement* st_ptr = stmt_lookup(hStatement); - if (st_ptr == nullptr) return fail(openads::AE_INTERNAL_ERROR, "unknown stmt"); - // Alias so the body below keeps its `it->second->...` accesses unchanged; - // the lookup is now serialised and the pointer is used off the lock. - std::pair it_kv{hStatement, st_ptr}; - auto* it = &it_kv; - if (it->second->sql.empty()) { - return fail(openads::AE_PARSE_ERROR, "no prepared SQL"); - } - // AdsSet* stores literal values in SqlStatement::params keyed by the bare - // name (no colon); here we substitute every :name placeholder with its - // stored literal before the SQL reaches the parser. - // - // The substitution is a single left-to-right pass that, at each ':', - // consumes the WHOLE identifier (same boundary rule as AdsGetNumParams) - // and replaces it by exact-name lookup. A naive per-key find/replace was - // wrong on two counts: (1) ":p1" matched as a prefix of ":p10"/":p11"/..., - // so with >= 10 named params the double-digit placeholders were corrupted; - // and (2) a substituted literal could itself contain ":name" text and get - // re-scanned by a later key. A single pass that never re-scans emitted - // text and matches whole identifiers fixes both. The output is built in a - // std::string (no fixed size cap) and handed to AdsExecuteSQLDirect via a - // buffer sized to the result, so large multi-row INSERTs are not truncated. - const std::string& src = it->second->sql; - const auto& params = it->second->params; - std::string sql; - sql.reserve(src.size() + 64); - for (std::size_t i = 0; i < src.size(); ) { - if (src[i] == ':' && i + 1 < src.size() && - (std::isalpha((unsigned char)src[i + 1]) || src[i + 1] == '_')) { - std::size_t j = i + 1; - while (j < src.size() && - (std::isalnum((unsigned char)src[j]) || src[j] == '_')) - ++j; - std::string name = src.substr(i + 1, j - (i + 1)); - auto pit = params.find(name); - if (pit != params.end()) { - sql += pit->second; // bound literal - } else { - sql.append(src, i, j - i); // unknown :name -- leave verbatim - } - i = j; - } else { - sql += src[i]; - ++i; - } - } - std::vector buf(sql.size() + 1); - std::memcpy(buf.data(), sql.data(), sql.size()); - buf[sql.size()] = '\0'; - return AdsExecuteSQLDirect(hStatement, buf.data(), phCursor); -} - -// RCB 06/28/2026: system.* metadata is exposed through the existing Table -// cursor path, but backed by a read-only memory driver instead of writing -// _sys_*.adt scratch files to disk. This stops data-directory leaks, removes -// the pointless DD-memory -> disk -> cursor round trip, and keeps client -// behavior stable because the SQL executor still receives a normal Table. -extern "C++" { - -namespace { - -struct SystemTableFilter { - std::optional grantee; - std::optional object_name; - std::optional table_name; - std::optional group_name; - std::optional user_name; -}; - -extern "C++" std::optional -extract_system_table_filter_(const openads::sql::SelectStmt& st) { - if (!st.where) return std::nullopt; - SystemTableFilter filter; - bool found = false; - - auto visit = [&](const openads::sql::WhereExpr& node, - auto&& visit_ref) -> bool { - using Kind = openads::sql::WhereExpr::Kind; - if (node.kind == Kind::And) { - bool ok = true; - for (const auto& child : node.children) { - if (child) ok = visit_ref(*child, visit_ref) && ok; - } - return ok; - } - if (node.kind != Kind::Cmp) return false; - const auto& cmp = node.cmp; - if (cmp.op != openads::sql::WhereOp::Eq || - cmp.lhs_fn != openads::sql::WhereFn::None || - !cmp.column_alias.empty() || cmp.is_numeric || - cmp.subquery || cmp.is_outer_ref) { - return true; - } - std::string col = cmp.column; - for (auto& ch : col) - ch = static_cast(std::tolower(static_cast(ch))); - if (col == "grantee") { - filter.grantee = cmp.literal; - found = true; - } else if (col == "obj_name" || col == "name") { - // "name" is the SAP column name for the object in - // system.permissions (OpenADS renamed OBJ_NAME → Name). - filter.object_name = cmp.literal; - found = true; - } else if (col == "table_name") { - filter.table_name = cmp.literal; - found = true; - } else if (col == "group_name") { - filter.group_name = cmp.literal; - found = true; - } else if (col == "user_name") { - filter.user_name = cmp.literal; - found = true; - } - return true; - }; - - if (!visit(*st.where, visit) || !found) return std::nullopt; - return filter; -} - -} // namespace - -extern "C++" { - -// Column spec shared by the system.* virtual tables and the sp_* system -// procedure result sets. -struct SpCol { - const char* colname; - char type; // 'C' (CiCharacter), 'N' (int32), 'L' (logical) - std::uint16_t length; - std::uint8_t decimals; -}; - -// Builds a read-only memory table with ADT-compatible full-length field -// names. `tag` names the memory:// path for diagnostics only. Shared by -// build_system_table (system.* SELECT targets) and dispatch_sp_builtin_cursor -// (EXECUTE PROCEDURE sp_* result sets). -openads::util::Result
-build_memory_result(const std::string& tag, - const std::vector& cols, - const std::vector>& rows) { - std::vector fields; - fields.reserve(cols.size()); - std::uint32_t rlen = 1; // 1 byte delete flag - for (const auto& col : cols) { - openads::drivers::DbfField f; - f.name = col.colname; - f.decimals = col.decimals; - f.record_offset = static_cast(rlen); - if (col.type == 'N') { - f.raw_type = 11; - f.type = openads::drivers::DbfFieldType::Integer; - f.length = 4; - } else if (col.type == 'L') { - f.raw_type = 1; - f.type = openads::drivers::DbfFieldType::Logical; - f.length = 1; - } else { - f.raw_type = 20; - f.type = openads::drivers::DbfFieldType::CiCharacter; - f.length = col.length; - } - rlen += f.length; - if (rlen > 0xFFFFu) { - return openads::util::Error{openads::AE_INTERNAL_ERROR, 0, - "result table record too large", tag}; - } - fields.push_back(std::move(f)); - } - - std::vector> records; - records.reserve(rows.size()); - for (const auto& row : rows) { - std::vector rec(rlen, 0x20u); // space-fill - rec[0] = ' '; - for (std::size_t ci = 0; ci < cols.size(); ++ci) { - const std::string& val = ci < row.size() ? row[ci] : ""; - const auto& f = fields[ci]; - std::uint8_t* dst = rec.data() + f.record_offset; - if (f.type == openads::drivers::DbfFieldType::Integer) { - // Guard non-numeric text (e.g. an RI rule stored as a word) - // -- std::stol would throw across the ABI boundary and crash. - std::int32_t iv = 0; - if (!val.empty()) { - try { iv = static_cast(std::stol(val)); } - catch (...) { iv = 0; } - } - auto uiv = static_cast(iv); - dst[0] = static_cast( uiv & 0xFFu); - dst[1] = static_cast((uiv >> 8) & 0xFFu); - dst[2] = static_cast((uiv >> 16) & 0xFFu); - dst[3] = static_cast((uiv >> 24) & 0xFFu); - } else if (f.type == openads::drivers::DbfFieldType::Logical) { - dst[0] = (val == "1" || val == "T" || val == "t") ? 'T' : 'F'; - } else { // CICHAR: space-padded - std::size_t n2 = std::min(val.size(), f.length); - std::memcpy(dst, val.data(), n2); - } - } - records.push_back(std::move(rec)); - } - - char nb[96]; - std::snprintf(nb, sizeof(nb), "memory://%s/%llx", - tag.c_str(), - static_cast( - openads::platform::monotonic_nanos())); - auto drv = std::make_unique( - nb, std::move(fields), std::move(records), - static_cast(rlen)); - return Table::from_driver(std::move(drv), nb, - openads::engine::TableType::Adt, - openads::engine::OpenMode::Read, - openads::engine::LockingMode::Compatible); -} - -} // extern "C++" - -// Build a read-only memory table that materialises one of the system.* -// virtual tables from the connection's DataDict state. `sys_name` is the part -// after "system." (already lower-cased by the caller). -extern "C++" openads::util::Result
-build_system_table(Connection* c, std::string sys_name, - const SystemTableFilter* filter = nullptr) { - for (auto& ch : sys_name) ch = static_cast( - std::tolower(static_cast(ch))); - - auto* dd = c->dd(); - if (!dd) { - return openads::util::Error{openads::AE_NO_FILE_FOUND, 0, sys_name, ""}; - } - - namespace fs = std::filesystem; - - using Col = SpCol; - - auto build = [&](const std::vector& cols, - const std::vector>& rows) - -> openads::util::Result
{ - return build_memory_result("system." + sys_name, cols, rows); - }; - - // RCB 07/16/2026: size a text column to its longest actual value instead - // of a fixed CHAR width. system.{storedprocedures,functions} declared - // their body columns as C(255), so multi-KB SQL bodies (pmsys: - // sp_SaveIntoAuditLog is 5359 bytes) came back as a 255-byte prefix -- - // silent data loss in the catalog even though the DD and the execution - // path hold the full text. Memory-table records cap at 64 KB total, so - // clamp each column defensively below that. - auto fit_width = [](const std::vector>& rows, - std::size_t col_idx, std::size_t min_w) - -> std::uint16_t { - std::size_t w = min_w; - for (const auto& r : rows) - if (col_idx < r.size() && r[col_idx].size() > w) - w = r[col_idx].size(); - return static_cast(std::min(w, 60000)); - }; - - if (sys_name == "tables") { - // Column set matches SAP ADS system.tables for compatibility. - const std::vectorcols = { - {"Name", 'C', 200, 0}, - {"Table_Relative_Path", 'C', 250, 0}, - {"Table_Type", 'N', 5, 0}, - {"Table_Auto_Create", 'C', 6, 0}, - {"Table_Primary_Key", 'C', 200, 0}, - {"Table_Default_Index", 'C', 200, 0}, - {"Table_Encryption", 'C', 6, 0}, - {"Table_Permission_Level", 'N', 5, 0}, - {"Table_Memo_Block_Size", 'N', 5, 0}, - {"Table_Validation_Expr", 'C', 250, 0}, - {"Table_Validation_Msg", 'C', 250, 0}, - {"Comment", 'C', 250, 0}, - {"Triggers_Disabled", 'C', 6, 0}, - {"Table_Caching", 'N', 5, 0}, - {"Table_Trans_Free", 'C', 6, 0}, - {"Table_WEB_delta", 'C', 6, 0}, - {"Table_Concurrency_Enabled", 'C', 6, 0}, - }; - namespace fs = std::filesystem; - std::vector> rows; - for (const auto& kv : dd->tables()) { - const std::string& alias = kv.first; - const std::string& rel = kv.second; - if (!dd_can_view_object_metadata(c, alias)) continue; - // Infer table type from extension (3=ADT, 2=CDX/NTX) - std::string ext = fs::path(rel).extension().string(); - for (auto& ch : ext) ch = static_cast(std::tolower(static_cast(ch))); - int ttype = (ext == ".adt") ? 3 : 2; - std::string pk = dd->get_table_property(alias, 202); - std::string didx = dd->get_table_property(alias, 213); - std::string auto_create = dd->get_table_property(alias, 203); - std::string encryption = dd->get_table_property(alias, 214); - std::string memo_block = dd->get_table_property(alias, 215); - std::string perm_level = dd->get_table_property(alias, 216); - std::string caching = dd->get_table_property(alias, ADS_DD_TABLE_CACHING); - std::string validation_expr = dd->get_table_property(alias, 200); - std::string validation_msg = dd->get_table_property(alias, 201); - std::string comment = dd->get_table_property(alias, ADS_DD_COMMENT); - std::string txn_free = dd->get_table_property(alias, ADS_DD_TABLE_TXN_FREE); - rows.push_back({alias, rel, std::to_string(ttype), - (auto_create == "1" ? "True" : "False"), pk, didx, - (encryption == "1" ? "True" : "False"), - perm_level.empty() ? "0" : perm_level, - memo_block.empty() ? "0" : memo_block, - validation_expr, validation_msg, comment, - "False", caching.empty() ? "0" : caching, - (txn_free == "1" ? "True" : "False"), - "False", "False"}); - } - return build(cols, rows); - } - if (sys_name == "indexes") { - // RCB 07/16/2026: SAP ADS system.indexes is one row per index TAG with - // full metadata (Name/Expression/Condition/Options/Key_Length/...), not - // one row per file. The DD IndexEntry now carries the per-tag fields - // (populated by import_dd from SAP, or by CREATE INDEX); this projects - // them into SAP's exact column shape. Column names + order match SAP so - // a client that reads by name gets the same result on both engines. - const std::vectorcols = { - {"Name", 'C', 200, 0}, - {"Parent", 'C', 200, 0}, - {"Index_File_Name", 'C', 250, 0}, - {"Index_Expression", 'C', 250, 0}, - {"Index_Condition", 'C', 250, 0}, - {"Index_Options", 'C', 20, 0}, - {"Index_Key_Length", 'C', 20, 0}, - {"Index_FTS_Min_Length", 'C', 20, 0}, - {"Index_FTS_Delimiters", 'C', 100, 0}, - {"Index_FTS_Noise", 'C', 100, 0}, - {"Index_FTS_Drop_Chars", 'C', 100, 0}, - {"Index_FTS_Conditional_Chars", 'C', 100, 0}, - {"Comment", 'C', 200, 0}, - {"Index_Collation", 'C', 100, 0}, - }; - std::vector> rows; - auto add_index_row = [&](const auto& e) { - if (!dd_can_view_object_metadata(c, e.table_alias)) return; - // SAP reports the bare file name ("landlords.adi"), not the DD's - // stored relative path (".\\landlords.adi"). - std::string file = - std::filesystem::path(e.index_path).filename().string(); - rows.push_back({ - e.tag_name, e.table_alias, file, e.expression, e.condition, - e.options, e.key_length, - "0", "", "", "", "", // FTS fields -- empty on non-FTS indexes - e.comment, e.collation}); - }; - if (filter && filter->table_name) { - for (const auto* e : dd->indexes_for_table(*filter->table_name)) - add_index_row(*e); - } else { - for (const auto& e : dd->indexes()) - add_index_row(e); - } - return build(cols, rows); - } - if (sys_name == "primarykeys") { - // SAP ADS-style system.primarykeys: one row per primary-key column. - // The DD records only the PK tag NAME (property 202); the column list - // lives in the tag's key expression, which we read from the table's - // CDX bag(s) without activating any order. A composite key made of a - // simple field list ("F1+F2") expands to one row per field in order; - // a calculated expression (a function, or any operator other than - // '+') degrades to zero rows rather than report a guessed column. - const std::vectorcols = { - {"TABLE_NAME", 'C', 200, 0}, - {"COLUMN_NAME", 'C', 200, 0}, - {"KEY_SEQ", 'N', 5, 0}, - {"PK_NAME", 'C', 200, 0}, - }; - - // Split a key expression into an ordered list of plain field names. - // Returns empty if any term is not a bare identifier (degrade safely). - auto parse_simple_fields = - [](const std::string& expr) -> std::vector { - std::vector out; - std::size_t start = 0; - while (true) { - std::size_t plus = expr.find('+', start); - std::string term = expr.substr( - start, plus == std::string::npos ? std::string::npos - : plus - start); - std::size_t b = term.find_first_not_of(' '); - std::size_t e = term.find_last_not_of(' '); - if (b == std::string::npos) return {}; // empty term → bail - term = term.substr(b, e - b + 1); - bool okid = std::isalpha(static_cast(term[0])) || - term[0] == '_'; - for (std::size_t i = 1; okid && i < term.size(); ++i) { - const char ch = term[i]; - if (!(std::isalnum(static_cast(ch)) || - ch == '_')) - okid = false; - } - if (!okid) return {}; - out.push_back(term); - if (plus == std::string::npos) break; - start = plus + 1; - } - return out; - }; - - // Resolve a tag name to its key expression by reading a CDX bag, - // without activating the order. Tries DD-registered bags first, then - // the structural CDX next to the table. Returns empty if no CDX bag - // carries the tag. - auto expr_for_tag = [&](const std::string& table_rel, - const std::string& alias, - const std::string& tag) -> std::string { - std::vector bags; - for (const auto* ie : dd->indexes_for_table(alias)) - bags.push_back(ie->index_path); - { - fs::path tp(table_rel); - std::string ext = tp.extension().string(); - for (auto& ch : ext) - ch = static_cast( - std::tolower(static_cast(ch))); - if (ext != ".adt") // ADT keys live in .adi -- not read here - bags.push_back(tp.replace_extension(".cdx").string()); - } - for (const auto& bag : bags) { - fs::path full(bag); - if (!full.is_absolute()) - full = fs::path(c->data_dir()) / bag; - std::string fe = full.extension().string(); - for (auto& ch : fe) - ch = static_cast( - std::tolower(static_cast(ch))); - if (fe != ".cdx") continue; // only CDX understood here - openads::drivers::cdx::CdxIndex idx; - if (idx.open_named(full.string(), - openads::drivers::IndexOpenMode::ReadOnly, - tag)) - return idx.expression(); - } - return {}; - }; - - std::vector> rows; - for (const auto& kv : dd->tables()) { - const std::string& alias = kv.first; - const std::string& rel = kv.second; - if (filter && filter->table_name && - openads::engine::DataDict::ci_name(alias) != - openads::engine::DataDict::ci_name(*filter->table_name)) - continue; - if (!dd_can_view_object_metadata(c, alias)) continue; - std::string tag = dd->get_table_property(alias, 202); - if (tag.empty()) continue; - std::string expr = expr_for_tag(rel, alias, tag); - if (expr.empty()) continue; - std::vector fields = parse_simple_fields(expr); - if (fields.empty()) continue; // calculated/complex → no rows - int seq = 1; - for (const auto& f : fields) { - rows.push_back({alias, f, std::to_string(seq), tag}); - ++seq; - } - } - return build(cols, rows); - } - if (sys_name == "users") { - // SAP system.users column set + order (2026-07-29). Task #4 converted - // five catalogs to SAP's exact names but missed this one, so a client - // filtering `WHERE Name = ...` got "Column not found" from OpenADS. - // Logins_Disabled and Require_Old_Password are per-user in SAP but - // OpenADS only tracks logins-disabled at the DATABASE level - // (ADS_DD_LOGINS_DISABLED / prop_16), so they render as SAP's defaults - // rather than being invented per user. - const std::vectorcols = { - {"Name", 'C', 200, 0}, - {"Enable_Internet", 'L', 1, 0}, - {"Logins_Disabled", 'L', 1, 0}, - {"Comment", 'C', 200, 0}, - {"User_Defined_Prop", 'C', 4096, 0}, - {"Require_Old_Password", 'L', 1, 0}, - }; - // DD boolean props arrive in several encodings depending on who wrote - // them (SAP export, sp_ModifyUserProperty, a raw 2-byte UNSIGNED16). - auto prop_is_true = [](const std::string& v) { - if (v.empty()) return false; - if (v.size() == 2 && v[1] == '\0') // raw UNSIGNED16 - return v[0] != '\0'; - const char c = static_cast( - std::toupper(static_cast(v[0]))); - return c == 'T' || c == 'Y' || c == '1'; - }; - std::vector> rows; - // users_ holds folded lookup keys; show the declared spelling. - for (const auto& u : dd->users()) { - const auto internet = dd->get_user_property(u, "prop_1104"); - rows.push_back({ - dd->display_user(u), - prop_is_true(internet) ? "T" : "F", - "F", // not tracked per user - dd->get_user_property(u, "prop_1"), // COMMENT - dd->get_user_property(u, "prop_3"), // ADS_DD_USER_DEFINED_PROP - "T", // SAP default - }); - } - return build(cols, rows); - } - if (sys_name == "groups") { - const std::vectorcols = {{"GROUP_NAME", 'C', 200, 0}}; - std::vector> rows; - for (const auto& g : dd->groups()) - rows.push_back({g}); - return build(cols, rows); - } - if (sys_name == "usergroups") { - // Lists all defined groups (SAP: system.usergroups = group catalogue). - // Include both explicit group records AND groups referenced in memberships, - // so text-format DDs that only have MEMBER entries still list their groups. - // SAP system.usergroups column set + order (2026-07-29): Name, Comment, - // User_Defined_Prop. Group props share the user_props_ map (see - // sp_ModifyGroupProperty), so prop_1 / prop_3 apply here too. - const std::vectorcols = { - {"Name", 'C', 200, 0}, - {"Comment", 'C', 200, 0}, - {"User_Defined_Prop", 'C', 4096, 0}, - }; - std::unordered_set seen; - std::vector> rows; - auto add_group_row = [&](const std::string& g) { - if (filter && filter->group_name && - openads::engine::DataDict::ci_name(g) != - openads::engine::DataDict::ci_name(*filter->group_name)) - return; - if (seen.insert(g).second) - rows.push_back({g, - dd->get_user_property(g, "prop_1"), - dd->get_user_property(g, "prop_3")}); - }; - for (const auto& g : dd->groups()) - add_group_row(g); - for (const auto& kv : dd->memberships()) - for (const auto& g : kv.second) - add_group_row(g); - return build(cols, rows); - } - if (sys_name == "usergroupmembers") { - // Lists which users belong to which group. - // SAP column set + order (2026-07-29) is User_Name THEN Group_Name -- - // the reverse of the GROUP_NAME/USER_NAME pair OpenADS used to emit, so - // positional readers saw the two values swapped as well as misnamed. - // User_Name carries the declared spelling, matching system.users.Name. - const std::vectorcols = { - {"User_Name", 'C', 200, 0}, - {"Group_Name", 'C', 200, 0}, - }; - std::vector> rows; - if (filter && filter->group_name) { - for (const auto& user : dd->users_in_group(*filter->group_name)) - if (!filter->user_name || - openads::engine::DataDict::ci_name(user) == - openads::engine::DataDict::ci_name(*filter->user_name)) - rows.push_back({dd->display_user(user), *filter->group_name}); - } else if (filter && filter->user_name) { - for (const auto& grp : dd->groups_of(*filter->user_name)) - rows.push_back({dd->display_user(*filter->user_name), grp}); - } else { - for (const auto& kv : dd->memberships()) - for (const auto& grp : kv.second) - rows.push_back({dd->display_user(kv.first), grp}); - } - return build(cols, rows); - } - if (sys_name == "permission_grants") { - // RCB 06/30/2026: Expose only persisted DD permission grants for - // health checks and administration clients. system.permissions keeps - // SAP-compatible zero rows, but that compatibility surface is too - // expensive for clients that only need actual grant records. - const std::vectorcols = { - {"OBJ_NAME", 'C', 200, 0}, - {"OBJ_TYPE", 'N', 3, 0}, - {"OBJ_TYPE_NAME", 'C', 40, 0}, - {"GRANTEE", 'C', 200, 0}, - {"GRANTEE_TYPE", 'C', 10, 0}, - {"BITMASK", 'C', 20, 0}, - }; - - std::vector perm_src; - if (filter && filter->grantee && filter->object_name) { - auto by_grantee = dd->permissions_by_grantee(*filter->grantee); - perm_src.reserve(by_grantee.size()); - const auto obj_ci = openads::engine::DataDict::ci_name(*filter->object_name); - for (const auto* pe : by_grantee) { - if (openads::engine::DataDict::ci_name(pe->object_name) == obj_ci) - perm_src.push_back(pe); - } - } else if (filter && filter->grantee) { - auto by_grantee = dd->permissions_by_grantee(*filter->grantee); - perm_src.assign(by_grantee.begin(), by_grantee.end()); - } else if (filter && filter->object_name) { - auto by_object = dd->permissions_by_object(*filter->object_name); - perm_src.assign(by_object.begin(), by_object.end()); - } else { - perm_src.reserve(dd->permissions().size()); - for (const auto& pe : dd->permissions()) - perm_src.push_back(&pe); - } - - std::vector> rows; - rows.reserve(perm_src.size()); - for (const auto* pe : perm_src) { - rows.push_back({ - pe->object_name, - std::to_string(pe->object_type_code), - pe->object_type, - pe->grantee, - pe->grantee_is_group ? "GROUP" : "USER", - std::to_string(pe->bitmask), - }); - } - return build(cols, rows); - } - if (sys_name == "permission_issues") { - // RCB 06/30/2026: Run permission-grant health checks in core so - // remote administration clients receive only findings instead of - // pulling every grant row across the client/server boundary. - const std::vectorcols = { - {"SEVERITY", 'C', 20, 0}, - {"AREA", 'C', 40, 0}, - {"OBJECT", 'C', 200, 0}, - {"MESSAGE", 'C', 250, 0}, - {"DETAIL", 'C', 250, 0}, - }; - - std::unordered_set users_ci; - std::unordered_set groups_ci; - std::unordered_set objects_ci; - users_ci.reserve(dd->users().size()); - groups_ci.reserve(dd->groups().size() + 4); - objects_ci.reserve(dd->tables().size() + dd->views().size() + - dd->procs().size() + dd->functions().size() + - dd->links().size() + dd->users().size() + - dd->groups().size() + 8); - - for (const auto& u : dd->users()) { - users_ci.insert(openads::engine::DataDict::ci_name(u)); - objects_ci.insert(openads::engine::DataDict::ci_name(u)); - } - for (const auto& g : dd->groups()) { - groups_ci.insert(openads::engine::DataDict::ci_name(g)); - objects_ci.insert(openads::engine::DataDict::ci_name(g)); - } - for (const auto& g : {"DB:Admin", "DB:Backup", "DB:Debug", "DB:Public"}) - groups_ci.insert(openads::engine::DataDict::ci_name(g)); - for (const auto& [name, _] : dd->tables()) - objects_ci.insert(openads::engine::DataDict::ci_name(name)); - for (const auto& [name, _] : dd->views()) - objects_ci.insert(openads::engine::DataDict::ci_name(name)); - for (const auto& [name, _] : dd->procs()) - objects_ci.insert(openads::engine::DataDict::ci_name(name)); - for (const auto& [name, _] : dd->functions()) - objects_ci.insert(openads::engine::DataDict::ci_name(name)); - for (const auto& [name, _] : dd->links()) - objects_ci.insert(openads::engine::DataDict::ci_name(name)); - objects_ci.insert("database"); - - std::vector> rows; - for (const auto& pe : dd->permissions()) { - const auto grantee_ci = - openads::engine::DataDict::ci_name(pe.grantee); - if (!users_ci.count(grantee_ci) && !groups_ci.count(grantee_ci)) { - rows.push_back({ - "warning", - "Permissions", - pe.grantee, - "Permission grant references a missing user or group.", - pe.object_name, - }); - } - - bool object_known = false; - const auto obj_ci = - openads::engine::DataDict::ci_name(pe.object_name); - if (pe.object_type == "User") { - object_known = users_ci.count(obj_ci) != 0; - } else if (pe.object_type == "Group") { - object_known = groups_ci.count(obj_ci) != 0; - } else if (pe.object_type == "Database") { - object_known = true; - } else { - object_known = objects_ci.count(obj_ci) != 0; - } - if (!pe.object_name.empty() && !object_known) { - rows.push_back({ - "warning", - "Permissions", - pe.object_name, - "Permission grant references a missing DD object.", - pe.grantee, - }); - } - } - return build(cols, rows); - } - if (sys_name == "permissions") { - // Columns match SAP system.permissions: - // OBJ_NAME, OBJ_TYPE (numeric), PARENT, GRANTEE - // then 10 permission flag columns (0=denied, 1=granted, ""=N/A for type) - // PARENT is empty for top-level objects; table name for field rows (OBJ_TYPE=4). - // - // ADS_PERMISSION_* bitmask constants (ace.h): - // 0x001=READ/SELECT 0x002=UPDATE 0x004=EXECUTE 0x008=INHERIT(meta) - // 0x010=INSERT 0x020=DELETE 0x040=LINK_ACCESS - // 0x080=CREATE 0x100=ALTER 0x200=DROP - // 0x80000000=WITH_GRANT / SAP sentinel - // - // Column display values: - // "" = not applicable for this object/grantee type - // "0" = permission not granted - // "1" = permission granted to a GROUP grantee - // "2" = permission granted directly to a USER grantee - // - // SAP binary .add files store 0x80000000 as a constant info2 sentinel - // for ALL Permission records; per-bit rights are in encrypted blobs - // OpenADS cannot decode. We therefore treat the SAP sentinel as - // granting full DML for group records. - const std::vectorcols = { - {"Name", 'C', 200, 0}, - {"Object_Type", 'N', 3, 0}, - {"Parent", 'C', 200, 0}, - {"Grantee", 'C', 200, 0}, - {"Select", 'C', 1, 0}, - {"Update", 'C', 1, 0}, - {"Insert", 'C', 1, 0}, - {"Delete", 'C', 1, 0}, - {"Execute", 'C', 1, 0}, - {"Access", 'C', 1, 0}, - {"Inherit", 'C', 1, 0}, - {"Create", 'C', 1, 0}, - {"Alter", 'C', 1, 0}, - {"Drop", 'C', 1, 0}, - }; - - const uint32_t SAP_SENTINEL = 0x80000000u; - - // Return "1" or "2" depending on grantee type, or "0" if not set. - // Groups use "1"; users use "2". - auto perm_val = [](bool set, bool is_grp) -> std::string { - if (!set) return "0"; - return is_grp ? "1" : "2"; - }; - - // Decode a DML bitmask for a single logical column. - // sap_bit: actual bit position in the ADS_PERMISSION mask. - auto dml_col = [&perm_val]( - uint32_t mask, bool is_grp, int sap_bit) -> std::string { - if (mask & SAP_SENTINEL) { - // SAP sentinel: cannot decode actual level from encrypted blobs. - // For groups: approximate as full DML (SELECT+UPDATE+INSERT+DELETE). - // For users: SAP sentinel alone = INHERIT-only, no direct DML. - return perm_val(is_grp, is_grp); - } - return perm_val((mask >> sap_bit) & 1u, is_grp); - }; - - // Execute column (ADS_PERMISSION_EXECUTE = bit 2 = 0x004). - auto exe_col = [&perm_val](uint32_t mask, bool is_grp) -> std::string { - if (mask & SAP_SENTINEL) return perm_val(is_grp, is_grp); - return perm_val((mask >> 2) & 1u, is_grp); - }; - - std::vector> rows; - - // --- Canonical SAP permission matrix ------------------------------- - // SAP renders system.permissions as a full grantee x object cross - // product (uniform: every grantee lists every object). Grantees = - // real users + real groups + the two server pseudo-groups - // (SERVER:Admin / SERVER:Monitor); the adssys admin is omitted, as SAP - // does. Objects = every table and every one of its columns, plus - // users, groups, stored procedures, functions and links, plus a - // per-category "root" node SAP always lists (TABLE / VIEW / USER / - // USER GROUP / PROCEDURE / LINK / PUBLICATION / SUBSCRIPTION / PACKAGE - // and the Database singleton). Every cell renders "0"/"1"/"2", never - // blank, matching SAP. - auto is_adssys = [](const std::string& n) { - return openads::engine::DataDict::ci_name(n) == "adssys"; - }; - - struct MtxGrantee { std::string name; bool is_group; }; - std::vector grantees; - std::unordered_set gseen; - auto add_grantee = [&](const std::string& n, bool grp) { - if (is_adssys(n)) return; - if (filter && filter->grantee && - openads::engine::DataDict::ci_name(n) != - openads::engine::DataDict::ci_name(*filter->grantee)) - return; - if (gseen.insert(openads::engine::DataDict::ci_name(n)).second) - grantees.push_back({n, grp}); - }; - // SAP reports Grantee with the declared spelling ("RCB", "AutoTasks"). - // add_grantee() already dedupes/filters case-insensitively. - for (const auto& u : dd->users()) add_grantee(dd->display_user(u), false); - for (const auto& g : dd->groups()) add_grantee(g, true); - add_grantee("SERVER:Admin", true); - add_grantee("SERVER:Monitor", true); - - struct MtxObj { - std::string name; - std::string parent; // table name for fields; "" otherwise - int type; // SAP Object_Type code - }; - std::vector objects; - auto add_obj = [&](const std::string& n, const std::string& parent, - int type) { - if (filter && filter->object_name && - openads::engine::DataDict::ci_name(n) != - openads::engine::DataDict::ci_name(*filter->object_name)) - return; - objects.push_back({n, parent, type}); - }; - - // Tables and every column of every table (columns read live from the - // physical descriptors, exactly like system.columns). - for (const auto& kv : dd->tables()) { - add_obj(kv.first, "", 1); - auto th = c->open_table(kv.second, - openads::engine::TableType::Cdx, - openads::engine::OpenMode::Read); - if (th) { - openads::engine::Table* tbl = c->lookup_table(th.value()); - if (tbl) { - std::uint16_t nf = tbl->field_count(); - for (std::uint16_t i = 0; i < nf; ++i) - add_obj(tbl->field_descriptor(i).name, kv.first, 4); - } - c->close_table(th.value()); - } - } - add_obj("TABLE", "", 1); // t1 category root - for (const auto& kv : dd->views()) add_obj(kv.first, "", 6); - add_obj("VIEW", "", 6); // t6 category root - for (const auto& u : dd->users()) - if (!is_adssys(u)) add_obj(dd->display_user(u), "", 8); - add_obj("USER", "", 8); // t8 category root - for (const auto& g : dd->groups()) add_obj(g, "", 9); - add_obj("SERVER:Admin", "", 9); - add_obj("SERVER:Monitor", "", 9); - add_obj("USER GROUP", "", 9); // t9 category root - for (const auto& kv : dd->procs()) add_obj(kv.first, "", 10); - add_obj("PROCEDURE", "", 10); // t10 category root - add_obj("Database", "", 11); // t11 singleton - for (const auto& kv : dd->links()) add_obj(kv.first, "", 12); - add_obj("LINK", "", 12); // t12 category root - add_obj("PUBLICATION", "", 15); // t15 category root - add_obj("SUBSCRIPTION", "", 17); // t17 category root - for (const auto& kv : dd->functions()) add_obj(kv.first, "", 18); - add_obj("FUNCTION", "", 18); // t18 category root - add_obj("PACKAGE", "", 19); // t19 category root - - // Decode one (grantee, object) pair into the 14 output columns. - // Fields inherit their parent table's grant; every other object is - // looked up on its own name/type. Rights OpenADS cannot decode from - // SAP's encrypted permission blobs render "0". - auto emit_cell = [&](const MtxObj& obj, const MtxGrantee& gr) { - uint32_t m = 0; - const auto* pe = (obj.type == 4) - ? dd->find_permission(gr.name, obj.parent, 1) - : dd->find_permission(gr.name, obj.name, obj.type); - if (pe) m = pe->bitmask; - bool g = gr.is_group; - std::string S = "0", U = "0", I = "0", D = "0", E = "0", - Ac = "0", In = "0", Cr = "0", Al = "0", Dr = "0"; - switch (obj.type) { - case 1: // Table - S = dml_col(m, g, 0); U = dml_col(m, g, 1); - I = dml_col(m, g, 4); D = dml_col(m, g, 5); - break; - case 4: // Field (inherits parent table SELECT/UPDATE/INSERT) - S = dml_col(m, g, 0); U = dml_col(m, g, 1); - I = dml_col(m, g, 4); - break; - case 10: // StoredProc - case 18: // Function - E = exe_col(m, g); - break; - case 11: // Database (all DML + execute meaningful) - S = dml_col(m, g, 0); U = dml_col(m, g, 1); - I = dml_col(m, g, 4); D = dml_col(m, g, 5); - E = exe_col(m, g); - break; - default: // Views / users / groups / links / category roots - break; - } - rows.push_back({obj.name, std::to_string(obj.type), obj.parent, - gr.name, S, U, I, D, E, Ac, In, Cr, Al, Dr}); - }; - - rows.reserve(grantees.size() * objects.size()); - for (const auto& gr : grantees) - for (const auto& obj : objects) - emit_cell(obj, gr); - - return build(cols, rows); - } - if (sys_name == "effectivepermissions") { - // Effective permissions for the connected user: direct grants OR-ed with - // every group the user belongs to. Same columns as system.permissions. - // Only objects where an ACL entry exists are listed; objects without any - // ACL entry are open-access and are omitted (caller may infer full access). - const std::vectorcols = { - {"OBJ_NAME", 'C', 200, 0}, - {"OBJ_TYPE", 'N', 3, 0}, - {"GRANTEE", 'C', 200, 0}, - {"SELECT", 'C', 1, 0}, - {"UPDATE", 'C', 1, 0}, - {"INSERT", 'C', 1, 0}, - {"DELETE", 'C', 1, 0}, - {"EXECUTE", 'C', 1, 0}, - {"ACCESS", 'C', 1, 0}, - {"INHERIT", 'C', 1, 0}, - {"CREATE", 'C', 1, 0}, - {"ALTER", 'C', 1, 0}, - {"DROP", 'C', 1, 0}, - }; - const std::string& user = c->username(); - if (user.empty()) { - // No logged-in user -- return empty (caller treats as open access). - return build(cols, {}); - } - auto entries = dd->get_all_effective_perms(user); - std::vector> rows; - rows.reserve(entries.size()); - auto b1 = [](bool v) -> std::string { return v ? "1" : "0"; }; - for (const auto& e : entries) { - const auto& t = e.object_type; - bool is_table = (t == "Table"); - bool is_exec = (t == "StoredProc" || t == "Function"); - bool is_db = (t == "Database"); - const auto& o = e.ops; - rows.push_back({ - e.object_name, - std::to_string(e.object_type_code), - e.grantee, - (is_table || is_db) ? b1(o.select_) : "", // SELECT - (is_table || is_db) ? b1(o.update_) : "", // UPDATE - (is_table || is_db) ? b1(o.insert_) : "", // INSERT - (is_table || is_db) ? b1(o.delete_) : "", // DELETE - (is_exec || is_db) ? b1(o.execute_) : "", // EXECUTE - "", // ACCESS - "", // INHERIT (N/A for effective) - "", // CREATE - "", // ALTER - "", // DROP - }); - } - return build(cols, rows); - } - if (sys_name == "relations") { - // SAP system.relations column set + order (2026-07-26): primary - // (parent) table/index, then foreign (child) table/index, then the - // numeric update/delete rules. RI_No_PKey_Error / RI_Cascade_Error - // are empty (OpenADS does not track them; SAP shows them empty on - // pmsys). SAP has no fail-table column. - const std::vectorcols = { - {"Name", 'C', 200, 0}, - {"RI_Primary_Table", 'C', 200, 0}, - {"RI_Primary_Index", 'C', 200, 0}, - {"RI_Foreign_Table", 'C', 200, 0}, - {"RI_Foreign_Index", 'C', 200, 0}, - {"RI_UpdateRule", 'N', 10, 0}, - {"RI_DeleteRule", 'N', 10, 0}, - {"RI_No_PKey_Error", 'C', 1, 0}, - {"RI_Cascade_Error", 'C', 1, 0}, - }; - // OpenADS stores the RI rule as a word; SAP exposes the raw - // numeric code, which is asymmetric between update and delete - // (update Cascade=1, delete Cascade=2; SetNull=3 both). Restrict's - // code is a documented default (0) -- pmsys has no Restrict RI to - // oracle it against. - auto ri_rule_code = [](const std::string& w, bool is_del) - -> std::string { - if (w == "Cascade") return is_del ? "2" : "1"; - if (w == "SetNull") return "3"; - return "0"; // Restrict / unknown - }; - std::vector> rows; - for (const auto& kv : dd->ri()) { - const auto& e = kv.second; - if (!dd_can_view_object_metadata(c, e.parent) || - !dd_can_view_object_metadata(c, e.child)) { - continue; - } - rows.push_back({e.name, e.parent, e.parent_tag, - e.child, e.child_tag, - ri_rule_code(e.update_opt, false), - ri_rule_code(e.delete_opt, true), "", ""}); - } - return build(cols, rows); - } - if (sys_name == "referentialintegrity") { - // SAP-compatible alias for system.relations. - const std::vectorcols = { - {"RI_NAME", 'C', 200, 0}, - {"PARENT_TABLE", 'C', 200, 0}, - {"CHILD_TABLE", 'C', 200, 0}, - {"PARENT_TAG", 'C', 200, 0}, - {"CHILD_TAG", 'C', 200, 0}, - {"UPDATE_RULE", 'N', 10, 0}, - {"DELETE_RULE", 'N', 10, 0}, - {"FAIL_TABLE", 'C', 200, 0}, - }; - std::vector> rows; - for (const auto& kv : dd->ri()) { - const auto& e = kv.second; - if (!dd_can_view_object_metadata(c, e.parent) || - !dd_can_view_object_metadata(c, e.child)) { - continue; - } - rows.push_back({e.name, e.parent, e.child, - e.parent_tag, e.child_tag, - e.update_opt, e.delete_opt, e.fail_table}); - } - return build(cols, rows); - } - if (sys_name == "links") { - const std::vectorcols = { - {"LINK_NAME", 'C', 200, 0}, - {"LINK_PATH", 'C', 250, 0}, - {"LINK_USER", 'C', 200, 0}, - }; - std::vector> rows; - for (const auto& kv : dd->links()) - rows.push_back({kv.second.alias, kv.second.path, kv.second.user}); - return build(cols, rows); - } - if (sys_name == "triggers") { - // SAP system.triggers column set (2026-07-26). Trig_Event_Type: - // 1=INSERT 2=UPDATE 3=DELETE. Trig_Trigger_Type (timing): 1=BEFORE - // 2=INSTEAD OF 4=AFTER. Trig_Container_Type is 3 for a SQL-script - // trigger (SAP constant, oracle-verified). Trig_Function_Name is - // empty for script triggers. Triggers_Disabled is the inverse of - // OpenADS's `enabled`. - std::vector> rows; - auto add_trigger_row = [&](const auto& e) { - if (!dd_can_view_object_metadata(c, e.table_alias)) return; - rows.push_back({e.name, e.table_alias, - std::to_string(e.event_mask), - std::to_string(e.timing), - "3", - e.container, "", - std::to_string(e.priority), - std::to_string(e.options), - e.comment, - e.enabled ? "F" : "T"}); - }; - if (filter && filter->table_name) { - for (const auto* e : dd->triggers_for_table(*filter->table_name)) - add_trigger_row(*e); - } else { - for (const auto& kv : dd->triggers()) - add_trigger_row(kv.second); - } - const std::vectorcols = { - {"Name", 'C', 200, 0}, - {"Trig_TableName", 'C', 200, 0}, - {"Trig_Event_Type", 'N', 10, 0}, - {"Trig_Trigger_Type", 'N', 10, 0}, - {"Trig_Container_Type", 'N', 10, 0}, - {"Trig_Container", 'C', fit_width(rows, 5, 4096), 0}, - {"Trig_Function_Name", 'C', 200, 0}, - {"Trig_Priority", 'N', 10, 0}, - {"Trig_Options", 'N', 10, 0}, - {"Comment", 'C', 200, 0}, - {"Triggers_Disabled", 'L', 1, 0}, - }; - return build(cols, rows); - } - if (sys_name == "storedprocedures") { - // SAP system.storedprocedures column set (2026-07-26). OpenADS - // has only script procs, so Proc_DLL_* are empty and - // Proc_Invoke_Option is 4 (SAP's constant for a script proc, - // oracle-verified across all pmsys procs). - std::vector> rows; - for (const auto& kv : dd->procs()) { - const auto& e = kv.second; - rows.push_back({e.name, e.input_params, e.output_params, - "", "", e.comment, "4", e.procedure}); - } - const std::vectorcols = { - {"Name", 'C', 200, 0}, - {"Proc_Input", 'C', fit_width(rows, 1, 250), 0}, - {"Proc_Output", 'C', fit_width(rows, 2, 250), 0}, - {"Proc_DLL_Name", 'C', 128, 0}, - {"Proc_DLL_Function_Name", 'C', 128, 0}, - {"Comment", 'C', fit_width(rows, 5, 200), 0}, - {"Proc_Invoke_Option", 'N', 10, 0}, - {"SQL_Script", 'C', fit_width(rows, 7, 255), 0}, - }; - return build(cols, rows); - } - if (sys_name == "functions") { - // SAP system.functions column set (2026-07-26). Package and - // User_Defined_Prop are empty (OpenADS does not track them). - std::vector> rows; - for (const auto& kv : dd->functions()) { - const auto& e = kv.second; - rows.push_back({e.name, "", e.return_type, e.input_params, - e.implementation, e.comment, ""}); - } - const std::vectorcols = { - {"Name", 'C', 200, 0}, - {"Package", 'C', 64, 0}, - {"Return Type", 'C', 64, 0}, - {"Input Parameters", 'C', fit_width(rows, 3, 200), 0}, - {"Implementation", 'C', fit_width(rows, 4, 255), 0}, - {"Comment", 'C', 200, 0}, - {"User_Defined_Prop", 'C', 64, 0}, - }; - return build(cols, rows); - } - if (sys_name == "views") { - // SAP system.views column set (2026-07-26): Name, View_Stmt_Len - // (byte length of the statement), View_Stmt, Comment, - // Triggers_Disabled. - std::vector> rows; - for (const auto& kv : dd->views()) { - const auto& e = kv.second; - // SAP's View_Stmt_Len includes the terminating NUL (probed: - // a 41-char statement reports 42). - rows.push_back({e.name, std::to_string(e.sql.size() + 1), - e.sql, e.comment, "F"}); - } - const std::vectorcols = { - {"Name", 'C', 200, 0}, - {"View_Stmt_Len", 'N', 10, 0}, - {"View_Stmt", 'C', fit_width(rows, 2, 250), 0}, - {"Comment", 'C', 200, 0}, - {"Triggers_Disabled", 'L', 1, 0}, - }; - return build(cols, rows); - } - if (sys_name == "dictionary") { - const std::vectorcols = { - {"PROP_NAME", 'C', 200, 0}, - {"PROP_VALUE", 'C', 250, 0}, - }; - std::vector> rows; - for (const auto& kv : dd->db_props()) - rows.push_back({kv.first, kv.second}); - return build(cols, rows); - } - if (sys_name == "iota") { - const std::vectorcols = {{"IOTA", 'C', 1, 0}}; - return build(cols, {{std::vector{" "}}}); - } - if (sys_name == "columns") { - // SAP schema (oracle-verified on pmsys.add): Name / Parent / - // Field_Num / Field_Type (numeric ADT code; CICHAR reports 20) / - // Field_Length / Field_Decimal / Field_Min_Value / - // Field_Max_Value / Field_Can_Be_Null (T|F) / - // Field_Default_Value / Field_Validation_Msg / Comment / - // User_Defined_Prop / Field_Options. Nullability and defaults - // come from the DD's Field records (field_props "required" / - // "default"); the rest from the physical descriptors. - const std::vectorcols = { - {"Name", 'C', 200, 0}, - {"Parent", 'C', 200, 0}, - {"Field_Num", 'N', 6, 0}, - {"Field_Type", 'N', 6, 0}, - {"Field_Length", 'N', 8, 0}, - {"Field_Decimal", 'N', 4, 0}, - {"Field_Min_Value", 'C', 32, 0}, - {"Field_Max_Value", 'C', 32, 0}, - {"Field_Can_Be_Null", 'L', 1, 0}, - {"Field_Default_Value", 'C', 100, 0}, - {"Field_Validation_Msg",'C', 100, 0}, - {"Comment", 'C', 100, 0}, - {"User_Defined_Prop", 'C', 100, 0}, - {"Field_Options", 'N', 6, 0}, - }; - auto ci_eq2 = [](const std::string& x, const std::string& y) { - if (x.size() != y.size()) return false; - for (std::size_t z = 0; z < x.size(); ++z) - if (std::toupper(static_cast(x[z])) != - std::toupper(static_cast(y[z]))) - return false; - return true; - }; - std::vector> rows; - for (const auto& kv : dd->tables()) { - if (!dd_can_view_object_metadata(c, kv.first)) continue; - std::string rel = kv.second; - auto th = c->open_table(rel, openads::engine::TableType::Cdx, - openads::engine::OpenMode::Read); - if (!th) continue; - openads::engine::Table* tbl = c->lookup_table(th.value()); - if (tbl) { - // DD field props for this table (nullable/default), if any. - const std::unordered_map>* tfp = - nullptr; - for (const auto& fpkv : dd->field_props()) { - if (ci_eq2(fpkv.first, kv.first)) { - tfp = &fpkv.second; - break; - } - } - std::uint16_t nf = tbl->field_count(); - for (std::uint16_t i = 0; i < nf; ++i) { - const auto& fd = tbl->field_descriptor(i); - bool required = false; - std::string defv; - if (tfp) { - for (const auto& fkv : *tfp) { - if (!ci_eq2(fkv.first, fd.name)) continue; - auto rit = fkv.second.find("required"); - required = rit != fkv.second.end() && - rit->second == "T"; - auto dit = fkv.second.find("default"); - if (dit != fkv.second.end()) defv = dit->second; - break; - } - } - // SAP reports the DD/ADT type code -- CICHAR is 20 - // there, not the ACE sweep constant 25. - unsigned tcode = fd.type == - openads::drivers::DbfFieldType::CiCharacter - ? 20u : map_field_type(fd.type); - rows.push_back({ - fd.name, - kv.first, - std::to_string(i + 1), - std::to_string(tcode), - std::to_string(fd.length), - std::to_string(fd.decimals), - "", // Field_Min_Value - "", // Field_Max_Value - required ? "F" : "T", // Field_Can_Be_Null - defv, - "", // Field_Validation_Msg - "", // Comment - "", // User_Defined_Prop - "0", // Field_Options - }); - } - } - c->close_table(th.value()); - } - return build(cols, rows); - } - return openads::util::Error{openads::AE_NO_FILE_FOUND, 0, sys_name, ""}; -} - -} // extern "C++" - -// --------------------------------------------------------------------------- -// sp_* system-procedure support machinery (M14): named events, LIKE-style -// pattern matching, and process snapshot access shared by the non-cursor -// (dispatch_sp_builtin) and cursor (dispatch_sp_builtin_cursor) dispatchers. -// --------------------------------------------------------------------------- -extern "C++" { -namespace spproc { - -// -- Named events (sp_CreateEvent / sp_SignalEvent / sp_WaitForEvent...) ---- -// Registrations are per (connection, event-name): every connection that -// created an event gets its own pending-signal counter and data queue, so -// one waiter consuming a signal doesn't starve another -- mirroring SAP's -// per-connection event registration model. Signals from any connection in -// this process reach all registrations of that name (remote clients' SQL -// executes inside the server process, so cross-client signaling works). -struct EventReg { - std::uint32_t pending = 0; // signals not yet consumed - std::uint64_t consumed = 0; // signal sequence number - bool with_data = false; - std::deque data; -}; - -inline std::mutex& ev_mu() { static std::mutex m; return m; } -inline std::condition_variable& ev_cv() { - static std::condition_variable cv; return cv; -} -inline std::map, EventReg>& ev_map() { - static std::map, EventReg> m; - return m; -} - -inline std::string lower(std::string s) { - for (auto& ch : s) ch = static_cast( - std::tolower(static_cast(ch))); - return s; -} - -// Case-insensitive SQL LIKE match with % and _ wildcards. An empty -// pattern matches everything (SAP treats empty/NULL pattern as "all"). -inline bool like_match(const std::string& pattern, const std::string& text) { - if (pattern.empty()) return true; - std::string p = lower(pattern), t = lower(text); - std::size_t pi = 0, ti = 0, star = std::string::npos, mark = 0; - while (ti < t.size()) { - if (pi < p.size() && (p[pi] == '_' || p[pi] == t[ti])) { - ++pi; ++ti; - } else if (pi < p.size() && p[pi] == '%') { - star = pi++; mark = ti; - } else if (star != std::string::npos) { - pi = star + 1; ti = ++mark; - } else { - return false; - } - } - while (pi < p.size() && p[pi] == '%') ++pi; - return pi == p.size(); -} - -// Treat a textual NULL argument the same as an omitted one. -inline bool is_null_arg(const std::string& s) { - return s.empty() || lower(s) == "null"; -} - -void drop_all_events_for(const void* conn) { - std::lock_guard lk(ev_mu()); - auto& m = ev_map(); - for (auto it = m.begin(); it != m.end(); ) { - if (it->first.first == conn) it = m.erase(it); - else ++it; - } -} - -} // namespace spproc -} // extern "C++" - -// Dispatch for ADS built-in sp_* stored procedures. Returns true and sets *prc -// if the name was recognized; caller falls through to the DLL path otherwise. -extern "C++" bool dispatch_sp_builtin( - Connection* c, - const std::string& uname, - const std::vector& args, - UNSIGNED32* prc) { - auto* dd = c->has_dd() ? c->dd() : nullptr; - auto arg = [&](std::size_t i) -> const std::string& { - static const std::string empty; - return (i < args.size()) ? args[i].text : empty; - }; - auto ri_int = [&](std::size_t i) -> std::int32_t { - return (i < args.size() && args[i].is_numeric) - ? static_cast(args[i].number) : 0; - }; - - if (uname == "SP_CREATEUSER") { - if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } - if (auto r = dd->create_user(arg(0)); !r) { *prc = fail(r.error()); return true; } - if (!arg(1).empty()) dd->set_user_property(arg(0), "prop_1101", arg(1)); - if (!arg(2).empty()) dd->set_user_property(arg(0), "prop_1", arg(2)); - *prc = ok(); return true; - } - if (uname == "SP_DROPUSER") { - if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } - if (auto r = dd->delete_user(arg(0)); !r) { *prc = fail(r.error()); return true; } - *prc = ok(); return true; - } - if (uname == "SP_CREATEGROUP") { - if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } - if (auto r = dd->create_group(arg(0)); !r) { *prc = fail(r.error()); return true; } - if (!arg(1).empty()) dd->set_user_property(arg(0), "prop_1", arg(1)); - *prc = ok(); return true; - } - if (uname == "SP_DROPGROUP") { - if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } - if (auto r = dd->delete_group(arg(0)); !r) { *prc = fail(r.error()); return true; } - *prc = ok(); return true; - } - if (uname == "SP_ADDUSERTOGROUP") { - if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } - if (auto r = dd->add_user_to_group(arg(0), arg(1)); !r) { *prc = fail(r.error()); return true; } - *prc = ok(); return true; - } - if (uname == "SP_REMOVEUSERFROMGROUP") { - if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } - if (auto r = dd->remove_user_from_group(arg(0), arg(1)); !r) { *prc = fail(r.error()); return true; } - *prc = ok(); return true; - } - if (uname == "SP_MODIFYUSERPROPERTY") { - if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } - std::string upr = arg(1); - for (auto& ch : upr) ch = static_cast( - std::toupper(static_cast(ch))); - std::string key; - if (upr == "USER_PASSWORD" || upr == "PASSWORD") key = "prop_1101"; - else if (upr == "COMMENT") key = "prop_1"; - else if (upr == "ENABLE_INTERNET") key = "prop_1104"; - else if (upr == "BAD_LOGINS") { *prc = ok(); return true; } // read-only - else key = "prop_" + arg(1); - if (auto r = dd->set_user_property(arg(0), key, arg(2)); !r) { *prc = fail(r.error()); return true; } - *prc = ok(); return true; - } - if (uname == "SP_MODIFYGROUPPROPERTY") { - if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } - std::string upr = arg(1); - for (auto& ch : upr) ch = static_cast( - std::toupper(static_cast(ch))); - std::string key = (upr == "COMMENT") ? "prop_1" : ("prop_" + arg(1)); - if (auto r = dd->set_user_property(arg(0), key, arg(2)); !r) { *prc = fail(r.error()); return true; } - *prc = ok(); return true; - } - if (uname == "SP_ADDTABLETODATABASE") { - if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } - if (auto r = dd->add_table(arg(0), arg(1)); !r) { *prc = fail(r.error()); return true; } - // arg(4) may contain semicolon-separated index file paths - std::string idxlist = arg(4); - if (!idxlist.empty()) { - std::string cur; - idxlist.push_back(';'); - for (char ch : idxlist) { - if (ch == ';') { - if (!cur.empty()) { dd->add_index_file(arg(0), cur, ""); cur.clear(); } - } else cur.push_back(ch); - } - } - *prc = ok(); return true; - } - if (uname == "SP_ADDINDEXFILETODATABASE") { - if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } - if (auto r = dd->add_index_file(arg(0), arg(1), arg(2)); !r) { *prc = fail(r.error()); return true; } - *prc = ok(); return true; - } - if (uname == "SP_MODIFYTABLEPROPERTY") { - if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } - dd->set_db_property("TABLEPROP." + arg(0) + "." + arg(1), arg(2)); - *prc = ok(); return true; - } - if (uname == "SP_MODIFYFIELDPROPERTY") { - if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } - std::string upr = arg(2); - for (auto& ch : upr) ch = static_cast( - std::toupper(static_cast(ch))); - std::string key; - if (upr == "FIELD_CAN_BE_NULL" || upr == "REQUIRED") key = "required"; - else if (upr == "FIELD_DEFAULT_VALUE" || upr == "DEFAULT") key = "default"; - else if (upr == "FIELD_VALIDATION_RULE" || upr == "VALIDATION_RULE") key = "rule"; - else if (upr == "FIELD_VALIDATION_MSG" || upr == "VALIDATION_MSG") key = "msg"; - else if (upr == "FIELD_MAX_VALUE") key = "max"; - else if (upr == "FIELD_MIN_VALUE") key = "min"; - else if (upr == "COMMENT") key = "comment"; - else key = arg(2); - if (auto r = dd->set_field_property(arg(0), arg(1), key, arg(3)); !r) { *prc = fail(r.error()); return true; } - *prc = ok(); return true; - } - if (uname == "SP_CREATEREFERENTIALINTEGRITY") { - if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } - openads::engine::DataDict::RiEntry e; - e.name = arg(0); - e.parent = arg(1); - e.child = arg(2); - e.parent_tag = arg(3); - e.update_opt = std::to_string(ri_int(4)); - e.delete_opt = std::to_string(ri_int(5)); - e.fail_table = arg(6); - if (auto r = dd->create_ri(e); !r) { *prc = fail(r.error()); return true; } - *prc = ok(); return true; - } - if (uname == "SP_DROPREFERENTIALINTEGRITY") { - if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } - if (auto r = dd->remove_ri(arg(0)); !r) { *prc = fail(r.error()); return true; } - *prc = ok(); return true; - } - if (uname == "SP_CREATELINK") { - if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } - // sp_CreateLink(Name, Dictionary, Global, StaticPath, AuthenticateActiveUser, UserName, Password) - if (auto r = dd->create_link(arg(0), arg(1), arg(5), arg(6)); !r) { *prc = fail(r.error()); return true; } - *prc = ok(); return true; - } - if (uname == "SP_DROPLINK") { - if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } - if (auto r = dd->drop_link(arg(0)); !r) { *prc = fail(r.error()); return true; } - *prc = ok(); return true; - } - - // sp_DisableTriggers([scope[, object]]) - // sp_EnableTriggers([scope[, object]]) - // scope: "CURRENT USER" | "ALL" | table_name | trigger_name (auto-detected) - // When scope is omitted or "CURRENT USER": disable/enable for this connection only. - // When scope is a table name: disable/enable all triggers on that table (persisted in DD). - // When scope is a trigger name: disable/enable that single trigger (persisted in DD). - // "ALL" disables/enables all triggers for all users (persisted in DD). - if (uname == "SP_DISABLETRIGGERS" || uname == "SP_ENABLETRIGGERS") { - bool enable = (uname == "SP_ENABLETRIGGERS"); - std::string scope_raw = arg(0); - std::string scope_u = scope_raw; - for (auto& ch : scope_u) ch = static_cast(std::toupper((unsigned char)ch)); - - if (scope_raw.empty() || scope_u == "CURRENT USER") { - // Connection-level disable (non-persistent, this connection only) - c->set_triggers_disabled(!enable); - *prc = ok(); return true; - } - if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } - if (scope_u == "ALL") { - // All triggers in the DD -- persist - for (auto& [key, trig] : dd->triggers()) - trig.enabled = enable; - *prc = ok(); return true; - } - // Check if scope_raw matches a table alias → disable all triggers for that table - bool found_table = false; - for (auto& [key, trig] : dd->triggers()) { - std::string ta = trig.table_alias; - std::string sr = scope_raw; - for (auto& ch : ta) ch = static_cast(std::tolower((unsigned char)ch)); - for (auto& ch : sr) ch = static_cast(std::tolower((unsigned char)ch)); - if (ta == sr) { trig.enabled = enable; found_table = true; } - } - if (found_table) { *prc = ok(); return true; } - // Check if scope_raw matches a trigger name → disable that single trigger - for (auto& [key, trig] : dd->triggers()) { - std::string tn = trig.name; - std::string sr = scope_raw; - for (auto& ch : tn) ch = static_cast(std::tolower((unsigned char)ch)); - for (auto& ch : sr) ch = static_cast(std::tolower((unsigned char)ch)); - if (tn == sr) { trig.enabled = enable; *prc = ok(); return true; } - } - *prc = fail(openads::AE_INTERNAL_ERROR, "trigger or table not found"); - return true; - } - if (uname == "SP_MODIFYDATABASE") { - if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } - std::string upr = arg(0); - for (auto& ch : upr) ch = static_cast( - std::toupper(static_cast(ch))); - std::string key; - if (upr == "ADMIN_PASSWORD") key = "prop_1101"; - else if (upr == "COMMENT") key = "prop_1"; - else if (upr == "DEFAULT_TABLE_PATH") key = "prop_3"; - else if (upr == "LOG_IN_REQUIRED") key = "prop_5"; - else if (upr == "ENABLE_INTERNET") key = "prop_6"; - else if (upr == "INTERNET_SECURITY_LEVEL")key = "prop_7"; - else if (upr == "VERIFY_ACCESS_RIGHTS") key = "prop_8"; - else if (upr == "ENCRYPT_NEW_TABLE") key = "prop_10"; - else if (upr == "MAX_FAILED_ATTEMPTS") key = "prop_11"; - else if (upr == "TEMP_TABLE_PATH") key = "prop_12"; - else if (upr == "ENCRYPT_TABLE_PASSWORD") key = "prop_13"; - else if (upr == "VERSION_MAJOR") key = "prop_14"; - else if (upr == "VERSION_MINOR") key = "prop_15"; - else key = arg(0); - if (auto r = dd->set_db_property(key, arg(1)); !r) { *prc = fail(r.error()); return true; } - *prc = ok(); return true; - } - - // ---- M14: remaining SAP system procedures (non-cursor group) ----------- - - // Truthy logical argument ('T', 'TRUE', '1', numeric non-zero). - auto arg_bool = [&](std::size_t i) -> bool { - if (i < args.size() && args[i].is_numeric) return args[i].number != 0.0; - std::string v = spproc::lower(arg(i)); - return v == "t" || v == "true" || v == "1" || v == ".t."; - }; - - // Open a table by DD alias or path, run `fn`, close it again. - auto with_table = [&](const std::string& tname, - openads::engine::OpenMode mode, - const std::function& fn) -> UNSIGNED32 { - auto th = c->open_table(tname, openads::engine::TableType::Cdx, mode); - if (!th) return fail(th.error()); - openads::engine::Table* t = c->lookup_table(th.value()); - if (!t) { - c->close_table(th.value()); - return fail(openads::AE_INTERNAL_ERROR, "post-open"); - } - UNSIGNED32 rc = fn(*t); - c->close_table(th.value()); - return rc; - }; - - auto unsupported = [&](const char* what) { - *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, what); - return true; - }; - - // -- Table maintenance --------------------------------------------------- - if (uname == "SP_PACKTABLE" || uname == "SP_PACKTABLEONLINE") { - // MemoBlockSize / Options arguments are accepted and ignored. - *prc = with_table(arg(0), openads::engine::OpenMode::Exclusive, - [&](openads::engine::Table& t) -> UNSIGNED32 { - auto r = t.pack(); - return r ? ok() : fail(r.error()); - }); - return true; - } - if (uname == "SP_PACKALLTABLESONLINE") { - if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } - for (const auto& kv : dd->tables()) { - UNSIGNED32 rc = with_table(kv.second, - openads::engine::OpenMode::Exclusive, - [&](openads::engine::Table& t) -> UNSIGNED32 { - auto r = t.pack(); - return r ? ok() : fail(r.error()); - }); - if (rc != openads::AE_SUCCESS) { *prc = rc; return true; } - } - *prc = ok(); return true; - } - if (uname == "SP_REINDEX" || uname == "SP_REINDEXONLINE") { - // PageSize / Options arguments are accepted and ignored. - *prc = with_table(arg(0), openads::engine::OpenMode::Exclusive, - [&](openads::engine::Table& t) -> UNSIGNED32 { - auto r = t.reindex(); - return r ? ok() : fail(r.error()); - }); - return true; - } - if (uname == "SP_ZAPTABLE") { - *prc = with_table(arg(0), openads::engine::OpenMode::Exclusive, - [&](openads::engine::Table& t) -> UNSIGNED32 { - auto r = t.zap(); - return r ? ok() : fail(r.error()); - }); - return true; - } - if (uname == "SP_CREATEINDEX" || uname == "SP_CREATEINDEX90") { - // (TableName, FileName, TagName, Expression, Condition, Options, - // PageSize[, Collation]) -- mirrors the CREATE INDEX SQL path: - // resolve this Connection's ADSHANDLE, open the table, and reuse - // AdsCreateIndex61. The optional sp_CreateIndex90 collation - // argument is accepted and ignored (index collation follows the - // connection's collation). - auto& s = state(); - ADSHANDLE conn_h = 0; - s.registry.for_each_handle([&](Handle h, HandleKind k, void* p) { - if (k != HandleKind::Connection) return; - if (static_cast(p) == c) conn_h = to_ads_handle(h); - }); - if (conn_h == 0) { - *prc = fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - return true; - } - std::string tname = arg(0); - std::vector name_buf(tname.size() + 1, 0); - std::memcpy(name_buf.data(), tname.data(), tname.size()); - ADSHANDLE hTable = 0; - if (auto rc = AdsOpenTable(conn_h, name_buf.data(), name_buf.data(), - ADS_CDX, 1, 1, 0, 1, &hTable); - rc != openads::AE_SUCCESS) { *prc = rc; return true; } - - namespace fs = std::filesystem; - std::string file = spproc::is_null_arg(arg(1)) ? "" : arg(1); - if (file.empty()) { - fs::path tbl_path(c->data_dir()); - tbl_path /= tname; - if (!tbl_path.has_extension()) tbl_path.replace_extension(".dbf"); - fs::path bag = tbl_path; - bag.replace_extension( - spproc::lower(tbl_path.extension().string()) == ".adt" - ? ".adi" : ".cdx"); - file = bag.string(); - } - std::string tag = spproc::is_null_arg(arg(2)) ? "" : arg(2); - std::string expr = arg(3); - std::string cond = spproc::is_null_arg(arg(4)) ? "" : arg(4); - auto opts = static_cast(ri_int(5)); - auto pagesize = static_cast(ri_int(6)); - if (pagesize == 0) pagesize = 512; - - std::vector file_buf(file.size() + 1, 0); - std::memcpy(file_buf.data(), file.data(), file.size()); - std::vector tag_buf(tag.size() + 1, 0); - std::memcpy(tag_buf.data(), tag.data(), tag.size()); - std::vector expr_buf(expr.size() + 1, 0); - std::memcpy(expr_buf.data(), expr.data(), expr.size()); - std::vector cond_buf(cond.size() + 1, 0); - std::memcpy(cond_buf.data(), cond.data(), cond.size()); - - ADSHANDLE hIdx = 0; - UNSIGNED32 rc = AdsCreateIndex61( - hTable, file_buf.data(), - tag.empty() ? nullptr : tag_buf.data(), - expr_buf.data(), - cond.empty() ? nullptr : cond_buf.data(), - nullptr, opts, static_cast(pagesize), &hIdx); - AdsCloseTable(hTable); - *prc = rc; - return true; - } - if (uname == "SP_REMOVEINDEXFILE") { - if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } - if (auto r = dd->remove_index_file(arg(0), arg(1)); !r) { - *prc = fail(r.error()); return true; - } - if (arg_bool(2)) { - namespace fs = std::filesystem; - std::error_code ec; - fs::path p(arg(1)); - if (p.is_relative()) p = fs::path(c->data_dir()) / p; - fs::remove(p, ec); - } - *prc = ok(); return true; - } - if (uname == "SP_MODIFYINDEXPROPERTY") { - // Same property-bag persistence pattern as SP_MODIFYTABLEPROPERTY. - if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } - dd->set_db_property( - "INDEXPROP." + arg(0) + "." + arg(1) + "." + arg(2), arg(3)); - *prc = ok(); return true; - } - - // -- Encryption ------------------------------------------------------------ - if (uname == "SP_ENCRYPTTABLE") { - if (!spproc::is_null_arg(arg(1))) c->set_encryption_password(arg(1)); - if (!c->has_encryption_key()) { - return unsupported("sp_EncryptTable: no encryption password " - "(pass one, or AdsSetEncryptionPassword first)"); - } - *prc = with_table(arg(0), openads::engine::OpenMode::Exclusive, - [&](openads::engine::Table& t) -> UNSIGNED32 { - auto* cdx = dynamic_cast( - t.driver() ? t.driver()->unwrap() : nullptr); - if (!cdx) { - return fail(openads::AE_FUNCTION_NOT_AVAILABLE, - "encryption supported on CdxDriver tables only"); - } - auto r = cdx->encrypt_in_place(c->encryption_key()); - if (!r) return fail(r.error()); - if (auto fl = t.flush(); !fl) return fail(fl.error()); - return ok(); - }); - return true; - } - if (uname == "SP_DECRYPTTABLE") { - // Matches AdsDecryptTable: pending ADS encryption-mode RE. - return unsupported("sp_DecryptTable pending ADS encryption-mode RE"); - } - if (uname == "SP_SETDDENCRYPTIONTYPE") { - return unsupported("sp_SetDDEncryptionType: dictionary encryption " - "is not supported by OpenADS"); - } - - // -- Connection/session tuning -------------------------------------------- - if (uname == "SP_SETAPPLICATIONID") { - c->set_application_id(arg(0)); - *prc = ok(); return true; - } - if (uname == "SP_IGNORETRANSACTIONS" || - uname == "SP_IGNORETABLETRANSACTIONS") { - // Accepted no-op: OpenADS keeps every table transactional. The - // procedure is a performance hint, so honoring it lazily is safe. - *prc = ok(); return true; - } - if (uname == "SP_SETREQUESTPRIORITY") { - std::string p = spproc::lower(arg(0)); - if (p != "low" && p != "normal" && p != "high" && p != "critical") { - *prc = fail(openads::AE_INTERNAL_ERROR, - "priority must be low|normal|high|critical"); - return true; - } - // Accepted no-op: OpenADS has no request priority queue yet. - *prc = ok(); return true; - } - if (uname == "SP_SETSTATEMENTLIMIT") { - // Accepted no-op: OpenADS does not cap open statements. - *prc = ok(); return true; - } - if (uname == "SP_CANCELQUERY") { - return unsupported("sp_CancelQuery is not supported by OpenADS"); - } - if (uname == "SP_CHANGECURRENTUSERPASSWORD") { - if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } - const std::string& user = c->username(); - if (user.empty()) { - *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, - "no authenticated dictionary user"); - return true; - } - std::string current = dd->get_user_property(user, "prop_1101"); - if (!current.empty() && current != arg(0)) { - *prc = fail(openads::AE_ACCESS_DENIED, "old password mismatch"); - return true; - } - if (auto r = dd->set_user_property(user, "prop_1101", arg(1)); !r) { - *prc = fail(r.error()); return true; - } - *prc = ok(); return true; - } - - // -- DD object management --------------------------------------------------- - if (uname == "SP_RENAMEDDOBJECT") { - if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } - const std::string& oldn = arg(0); - const std::string& newn = arg(1); - std::int32_t objtype = ri_int(2); - const bool keep_file = ri_int(3) == 1; // ADS_KEEP_TABLE_FILE_NAME - switch (objtype) { - case 1: { // table - std::string rel; - for (const auto& kv : dd->tables()) { - if (spproc::lower(kv.first) == spproc::lower(oldn)) { - rel = kv.second; break; - } - } - if (rel.empty()) { - *prc = fail(openads::AE_NO_FILE_FOUND, oldn.c_str()); - return true; - } - namespace fs = std::filesystem; - std::string new_rel = rel; - if (!keep_file) { - fs::path oldp(rel); - if (oldp.is_relative()) - oldp = fs::path(c->data_dir()) / oldp; - fs::path newp = oldp; - newp.replace_filename(newn + oldp.extension().string()); - std::error_code ec; - fs::rename(oldp, newp, ec); - if (ec) { - *prc = fail(openads::AE_INTERNAL_ERROR, - "table file rename failed"); - return true; - } - // Sidecars follow the stem. - for (const char* ext : {".cdx", ".adi", ".fpt", - ".dbt", ".adm", ".ntx"}) { - fs::path so = oldp; so.replace_extension(ext); - fs::path sn = newp; sn.replace_extension(ext); - if (fs::exists(so, ec)) fs::rename(so, sn, ec); - } - fs::path nr(rel); - nr.replace_filename(newn + fs::path(rel).extension().string()); - new_rel = nr.string(); - } - if (auto r = dd->add_table(newn, new_rel); !r) { - *prc = fail(r.error()); return true; - } - if (auto r = dd->remove_table(oldn); !r) { - *prc = fail(r.error()); return true; - } - *prc = ok(); return true; - } - case 6: { // view - auto it = dd->views().find(oldn); - if (it == dd->views().end()) { - *prc = fail(openads::AE_NO_FILE_FOUND, oldn.c_str()); - return true; - } - auto e = it->second; - e.name = newn; - if (auto r = dd->create_view(e); !r) { *prc = fail(r.error()); return true; } - if (auto r = dd->drop_view(oldn); !r) { *prc = fail(r.error()); return true; } - *prc = ok(); return true; - } - case 10: { // stored procedure definition - auto it = dd->procs().find(oldn); - if (it == dd->procs().end()) { - *prc = fail(openads::AE_NO_FILE_FOUND, oldn.c_str()); - return true; - } - auto e = it->second; - e.name = newn; - if (auto r = dd->create_proc(e); !r) { *prc = fail(r.error()); return true; } - if (auto r = dd->drop_proc(oldn); !r) { *prc = fail(r.error()); return true; } - *prc = ok(); return true; - } - default: - return unsupported("sp_RenameDDObject: only TABLE (1), " - "VIEW (6) and PROCEDURE (10) objects " - "can be renamed"); - } - } - if (uname == "SP_MOVEDDOBJECTFILE") { - return unsupported("sp_MoveDDObjectFile is not supported by OpenADS"); - } - if (uname == "SP_MODIFYPROCEDUREPROPERTY") { - if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } - auto it = dd->procs().find(arg(0)); - if (it == dd->procs().end()) { - *prc = fail(openads::AE_NO_FILE_FOUND, arg(0).c_str()); - return true; - } - auto e = it->second; - std::string p = spproc::lower(arg(1)); - if (p == "comment") e.comment = arg(2); - else if (p == "input_parameters" || - p == "procedure_input") e.input_params = arg(2); - else if (p == "output_parameters" || - p == "procedure_output") e.output_params = arg(2); - else if (p == "container" || - p == "procedure_container") e.container = arg(2); - else if (p == "procedure_name" || p == "script") - e.procedure = arg(2); - else { - *prc = fail(openads::AE_INTERNAL_ERROR, "unknown procedure property"); - return true; - } - if (auto r = dd->drop_proc(e.name); !r) { *prc = fail(r.error()); return true; } - if (auto r = dd->create_proc(e); !r) { *prc = fail(r.error()); return true; } - *prc = ok(); return true; - } - if (uname == "SP_MODIFYVIEWPROPERTY") { - if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } - auto it = dd->views().find(arg(0)); - if (it == dd->views().end()) { - *prc = fail(openads::AE_NO_FILE_FOUND, arg(0).c_str()); - return true; - } - auto e = it->second; - std::string p = spproc::lower(arg(1)); - if (p == "comment") e.comment = arg(2); - else if (p == "statement" || p == "view_stmt" || - p == "sql") e.sql = arg(2); - else { - *prc = fail(openads::AE_INTERNAL_ERROR, "unknown view property"); - return true; - } - if (auto r = dd->drop_view(e.name); !r) { *prc = fail(r.error()); return true; } - if (auto r = dd->create_view(e); !r) { *prc = fail(r.error()); return true; } - *prc = ok(); return true; - } - if (uname == "SP_MODIFYLINK") { - // (Name, Dictionary, StaticPath, AuthenticateActiveUser, UserName, - // Password) -- recreate the link with the new settings. - if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } - if (auto r = dd->drop_link(arg(0)); !r) { *prc = fail(r.error()); return true; } - if (auto r = dd->create_link(arg(0), arg(1), arg(4), arg(5)); !r) { - *prc = fail(r.error()); return true; - } - *prc = ok(); return true; - } - - // -- Named events ----------------------------------------------------------- - if (uname == "SP_CREATEEVENT") { - std::lock_guard lk(spproc::ev_mu()); - auto& reg = spproc::ev_map()[{c, spproc::lower(arg(0))}]; - reg.with_data = (ri_int(1) & 2) != 0; // ADS_EVENT_WITH_DATA - *prc = ok(); return true; - } - if (uname == "SP_DROPEVENT") { - std::lock_guard lk(spproc::ev_mu()); - spproc::ev_map().erase({c, spproc::lower(arg(0))}); - *prc = ok(); return true; - } - if (uname == "SP_DROPALLEVENTS") { - spproc::drop_all_events_for(c); - *prc = ok(); return true; - } - if (uname == "SP_SIGNALEVENT") { - // (EventName, WaitForCommit, Options[, EventData]). WaitForCommit - // is accepted but signals immediately -- commit-deferred signaling - // needs transaction hooks that don't exist yet. - std::string name = spproc::lower(arg(0)); - { - std::lock_guard lk(spproc::ev_mu()); - for (auto& [key, reg] : spproc::ev_map()) { - if (key.second != name) continue; - ++reg.pending; - if (reg.with_data) { - if (reg.data.size() < 1000) reg.data.push_back(arg(3)); - } - } - } - spproc::ev_cv().notify_all(); - *prc = ok(); return true; - } - - // -- Server management (no result set) --------------------------------------- - if (uname == "SP_MGKILLUSER") { - if (auto kill = openads::mgmt::process_kill_user()) { - kill(arg(0), 0); - } - // Local mode: documented engine no-op, same as AdsMgKillUser. - *prc = ok(); return true; - } - if (uname == "SP_MGRESETCOMMSTATS") { - openads::mgmt::process_mg_stats().reset_comm(); - *prc = ok(); return true; - } - - // -- Recognized but unsupported feature groups. Returning a specific - // error beats "procedure not found": clients learn the call reached - // a real engine that simply doesn't ship the feature. - if (uname == "SP_CREATEARTICLE" || uname == "SP_DROPARTICLE" || - uname == "SP_CREATEPUBLICATION" || uname == "SP_DROPPUBLICATION" || - uname == "SP_CREATESUBSCRIPTION" || uname == "SP_DROPSUBSCRIPTION" || - uname == "SP_MODIFYARTICLEPROPERTY" || - uname == "SP_MODIFYPUBLICATIONPROPERTY" || - uname == "SP_MODIFYSUBSCRIPTIONPROPERTY" || - uname == "SP_DELETEREPLICATIONENTRY" || - uname == "SP_GETREPLICATIONENTRYDETAILS" || - uname == "SP_PROCESSREPLICATIONQUEUES" || - uname == "SP_TESTREPLICATIONCONNECTION") { - return unsupported("replication is not supported by OpenADS"); - } - if (uname == "SP_ENABLEQUERYLOGGING" || uname == "SP_DISABLEQUERYLOGGING" || - uname == "SP_GETQUERYLOGGINGRESULTS" || uname == "SP_VIEWQUERYLOGGING") { - return unsupported("query logging is not supported by OpenADS"); - } - - return false; -} - -// --------------------------------------------------------------------------- -// M14: sp_* system procedures that return a result set. -// --------------------------------------------------------------------------- -extern "C++" { - -// Enumerate every index attached to an open Table handle in this process. -// Walks the ABI registry at snapshot time -- no open/close bookkeeping to -// drift out of sync. Remote sessions open their tables through this same -// registry (session.cpp calls the ABI in-process), so the network Server's -// build_mg_snapshot uses this too; attribution to a user/conn is the -// caller's job (entries carry only the owning table's path). -namespace openads::abi { -std::vector open_index_snapshot() { - std::vector out; - state().registry.for_each_handle([&](Handle, HandleKind k, void* p) { - if (k != HandleKind::Table) return; - auto* tp = static_cast(p); - if (tp == nullptr) return; - for (auto* ix : tp->all_indexes()) { - if (ix == nullptr) continue; - openads::mgmt::MgIndex mi; - mi.name = ix->file_path().empty() ? ix->name() - : ix->file_path(); - mi.tag = ix->name(); - mi.expression = ix->expression(); - mi.table = tp->path(); - bool dup = false; - for (const auto& e : out) { - if (e.name == mi.name && e.tag == mi.tag && - e.table == mi.table) { dup = true; break; } - } - if (!dup) out.push_back(std::move(mi)); - } - }); - return out; -} -} // namespace openads::abi - -// In-process telemetry for the local (embedded DLL) case: enumerate the ABI -// handle registry for real connection/table counts and fold in the process -// MgStats. Shared with AdsMg*'s local backend (fetch_mg_snapshot). -openads::mgmt::MgSnapshot collect_local_abi_snapshot() { - openads::mgmt::MgSnapshot snap; - snap.server_type = 0; // 0 = local - std::uint32_t conns = 0, tbls = 0; - state().registry.for_each_handle( - [&](Handle, HandleKind k, void* p) { - if (k == HandleKind::Connection || - k == HandleKind::RemoteConnection) { - ++conns; - } else if (k == HandleKind::Table || - k == HandleKind::RemoteTable) { - ++tbls; - if (k == HandleKind::Table) { - auto* tp = static_cast(p); - if (tp != nullptr) { - openads::mgmt::MgTable t; - t.name = tp->path(); - t.user = "(local)"; - t.conn_no = 1; - snap.table_list.push_back(std::move(t)); - } - } - } - }); - snap.connections = conns; - snap.tables = tbls; - snap.workareas = tbls; - snap.users = 1; - openads::mgmt::MgUser u; - u.name = "(local)"; - u.conn_no = 1; - snap.user_list.push_back(u); - snap.rss_bytes = openads::platform::process_rss_bytes(); - - snap.locks = openads::mgmt::LockRegistry::instance().count(); - snap.lock_list = openads::mgmt::LockRegistry::instance().snapshot(); - for (auto& l : snap.lock_list) { - if (l.user.empty()) l.user = "(local)"; - if (l.conn_no == 0) l.conn_no = 1; - } - - snap.index_list = openads::abi::open_index_snapshot(); - for (auto& ix : snap.index_list) { - ix.user = "(local)"; - ix.conn_no = 1; - } - snap.indexes = static_cast(snap.index_list.size()); - - snap.error_log_path = openads::mgmt::ErrorLog::instance().directory(); - snap.error_log_max_kb = openads::mgmt::ErrorLog::instance().max_kbytes(); - - openads::mgmt::capture_mg_stats( - snap, openads::mgmt::process_mg_stats()); - return snap; -} - -namespace spproc { - -// The snapshot the sp_mg* procedures report: the network Server's when this -// SQL runs inside the daemon (provider hook), else this process's own. -inline openads::mgmt::MgSnapshot current_snapshot() { - if (auto prov = openads::mgmt::process_snapshot_provider()) return prov(); - return collect_local_abi_snapshot(); -} - -// NUL-terminated fixed char array -> std::string. -inline std::string mgstr(const UNSIGNED8* a, std::size_t n) { - std::size_t len = 0; - while (len < n && a[len] != 0) ++len; - return std::string(reinterpret_cast(a), len); -} - -inline std::string i2s(std::uint64_t v) { - if (v > 0x7FFFFFFFull) v = 0x7FFFFFFFull; // 'N' columns are int32 - return std::to_string(v); -} - -// "computer" or "computer\oslogin" (trailing '\' ignored) against a MgUser. -inline bool user_matches(const openads::mgmt::MgUser& u, std::string want) { - while (!want.empty() && want.back() == '\\') want.pop_back(); - if (want.empty()) return false; - std::string w = lower(want); - if (w == lower(u.name)) return true; - return w == lower(u.name + "\\" + u.os_login); -} - -// Table-path match: full path or basename, case-insensitive. -inline bool table_matches(const std::string& open_name, - const std::string& want) { - if (want.empty()) return true; - if (lower(open_name) == lower(want)) return true; - namespace fs = std::filesystem; - return lower(fs::path(open_name).filename().string()) == - lower(fs::path(want).filename().string()); -} - -inline std::string fmt_time(std::chrono::system_clock::time_point tp) { - if (tp.time_since_epoch().count() == 0) return ""; - std::time_t tt = std::chrono::system_clock::to_time_t(tp); - std::tm tmv{}; -#ifdef _WIN32 - localtime_s(&tmv, &tt); -#else - localtime_r(&tt, &tmv); -#endif - char buf[32]; - std::snprintf(buf, sizeof(buf), "%04d-%02d-%02d %02d:%02d:%02d", - tmv.tm_year + 1900, tmv.tm_mon + 1, tmv.tm_mday, - tmv.tm_hour, tmv.tm_min, tmv.tm_sec); - return buf; -} - -} // namespace spproc - -// Dispatch for built-in sp_* procedures that produce a cursor. Returns true -// and fills *out (a table or an error) when the name is recognized; the -// caller adopts the table and registers the cursor handle. Runs WITHOUT the -// global ABI mutex held -- sp_WaitForEvent blocks, and the signaling -// connection needs that mutex to get its EXECUTE PROCEDURE through. -bool dispatch_sp_builtin_cursor( - Connection* c, - const std::string& uname, - const std::vector& args, - std::optional>* out) { - using openads::util::Error; - namespace fs = std::filesystem; - - // Everything except the blocking event waits touches shared engine - // state (connection tables, DD, handle registry) -- serialize on the - // global ABI mutex like every other SQL statement branch. The waits - // stay lock-free: they only use `c` as an opaque registry key, and - // the signaling connection needs this mutex to make progress. - std::unique_lock abi_lk; - if (uname != "SP_WAITFOREVENT" && uname != "SP_WAITFORANYEVENT") - abi_lk = std::unique_lock(state().mu); - - auto arg = [&](std::size_t i) -> const std::string& { - static const std::string empty; - return (i < args.size()) ? args[i].text : empty; - }; - auto arg_int = [&](std::size_t i) -> std::int64_t { - if (i >= args.size()) return 0; - if (args[i].is_numeric) - return static_cast(args[i].number); - return std::atoll(args[i].text.c_str()); - }; - auto emit = [&](const char* tag, const std::vector& cols, - const std::vector>& rows) { - *out = build_memory_result(tag, cols, rows); - return true; - }; - auto err = [&](std::int32_t code, const std::string& msg) { - *out = openads::util::Result
(Error{code, 0, msg, ""}); - return true; - }; - - // -- sp_mg* management result sets --------------------------------------- - static const std::vector kUserCols = { - {"UserName", 'C', 64, 0}, - {"ConnNumber", 'N', 5, 0}, - {"DictionaryUser", 'C', 64, 0}, - {"Address", 'C', 64, 0}, - {"OSUserLoginName", 'C', 64, 0}, - {"TSAddress", 'C', 64, 0}, - }; - auto user_row = [](const openads::mgmt::MgUser& u) { - return std::vector{ - u.name, std::to_string(u.conn_no), u.name, u.address, - u.os_login, ""}; - }; - - if (uname == "SP_MGGETACTIVITYINFO") { - auto snap = spproc::current_snapshot(); - openads::mgmt::MgCollector col(snap); - auto ai = col.activity_info(); - char up[40]; - std::snprintf(up, sizeof(up), "%u days %02u:%02u:%02u", - ai.stUpTime.usDays, ai.stUpTime.usHours, - ai.stUpTime.usMinutes, ai.stUpTime.usSeconds); - return emit("sp_mgGetActivityInfo", - {{"Operations", 'N', 10, 0}, - {"LoggedErrors", 'N', 10, 0}, - {"UpTime", 'C', 40, 0}, - {"EQThreshold", 'N', 10, 0}, - {"EQActiveThreads", 'N', 10, 0}, - {"EQOperations", 'N', 10, 0}}, - {{spproc::i2s(ai.ulOperations), spproc::i2s(ai.ulLoggedErrors), - up, "0", "0", "0"}}); - } - if (uname == "SP_MGGETUSAGEINFO") { - auto snap = spproc::current_snapshot(); - openads::mgmt::MgCollector col(snap); - auto ai = col.activity_info(); - auto row = [](const char* item, const ADS_MGMT_USAGE_STRUCT& u) { - return std::vector{ - item, spproc::i2s(u.ulInUse), spproc::i2s(u.ulMaxUsed), - "0", spproc::i2s(u.ulRejected)}; - }; - return emit("sp_mgGetUsageInfo", - {{"Item", 'C', 30, 0}, - {"InUse", 'N', 10, 0}, - {"MaxUsed", 'N', 10, 0}, - {"Configured", 'N', 10, 0}, - {"Rejected", 'N', 10, 0}}, - {row("Users", ai.stUsers), - row("Connections", ai.stConnections), - row("Work Areas", ai.stWorkAreas), - row("Tables", ai.stTables), - row("Indexes", ai.stIndexes), - row("Locks", ai.stLocks), - row("TPS Header Elems", ai.stTpsHeaderElems), - row("TPS Visibility Elems", ai.stTpsVisElems), - row("TPS Memo Elems", ai.stTpsMemoElems), - row("Worker Threads", ai.stWorkerThreads)}); - } - if (uname == "SP_MGGETCONNECTEDUSERS") { - auto snap = spproc::current_snapshot(); - std::vector> rows; - for (const auto& u : snap.user_list) { - rows.push_back({u.name, std::to_string(u.conn_no), u.name, - u.address, u.os_login, "", "", - spproc::i2s(u.avg_cost_micros)}); - } - return emit("sp_mgGetConnectedUsers", - {{"UserName", 'C', 64, 0}, - {"ConnNumber", 'N', 5, 0}, - {"DictionaryUser", 'C', 64, 0}, - {"Address", 'C', 64, 0}, - {"OSUserLoginName", 'C', 64, 0}, - {"TSAddress", 'C', 64, 0}, - {"ApplicationID", 'C', 70, 0}, - {"AverageCost", 'N', 10, 0}}, - rows); - } - if (uname == "SP_MGGETTABLEUSERS") { - auto snap = spproc::current_snapshot(); - std::vector> rows; - for (const auto& t : snap.table_list) { - if (!spproc::table_matches(t.name, arg(0))) continue; - bool found = false; - for (const auto& u : snap.user_list) { - if (u.conn_no == t.conn_no) { - rows.push_back(user_row(u)); - found = true; - break; - } - } - if (!found) - rows.push_back({t.user, std::to_string(t.conn_no), - t.user, "", "", ""}); - } - return emit("sp_mgGetTableUsers", kUserCols, rows); - } - if (uname == "SP_MGGETINDEXUSERS") { - auto snap = spproc::current_snapshot(); - std::vector> rows; - for (const auto& x : snap.index_list) { - if (!spproc::table_matches(x.name, arg(0))) continue; - bool found = false; - for (const auto& u : snap.user_list) { - if (u.conn_no == x.conn_no) { - rows.push_back(user_row(u)); - found = true; - break; - } - } - if (!found && !x.user.empty()) - rows.push_back({x.user, std::to_string(x.conn_no), - x.user, "", "", ""}); - } - return emit("sp_mgGetIndexUsers", kUserCols, rows); - } - if (uname == "SP_MGGETUSERTABLES" || uname == "SP_MGGETALLTABLES") { - bool by_user = (uname == "SP_MGGETUSERTABLES"); - auto snap = spproc::current_snapshot(); - std::vector> rows; - for (const auto& t : snap.table_list) { - if (by_user) { - bool match = false; - for (const auto& u : snap.user_list) { - if (u.conn_no == t.conn_no && - spproc::user_matches(u, arg(0))) { - match = true; - break; - } - } - if (!match) continue; - } - // LockType per SAP: ADS(1) proprietary, CDX(2), NTX(3), ADT(4). - std::string ext = spproc::lower( - fs::path(t.name).extension().string()); - std::uint16_t ltv = (ext == ".adt") ? 4u - : (ext == ".ntx") ? 3u : 2u; - static const char* kLt[] = {"", "ADS", "CDX", "NTX", "ADT"}; - rows.push_back({t.name, kLt[ltv], std::to_string(ltv)}); - } - return emit(by_user ? "sp_mgGetUserTables" : "sp_mgGetAllTables", - {{"TableName", 'C', 260, 0}, - {"LockType", 'C', 3, 0}, - {"LockTypeValue", 'N', 5, 0}}, - rows); - } - if (uname == "SP_MGGETALLINDEXES" || uname == "SP_MGGETTABLEINDEXES" || - uname == "SP_MGGETUSERINDEXES") { - auto snap = spproc::current_snapshot(); - std::vector> rows; - for (const auto& x : snap.index_list) { - if (uname == "SP_MGGETTABLEINDEXES" && - !spproc::table_matches(x.table, arg(0))) continue; - if (uname == "SP_MGGETUSERINDEXES") { - bool match = spproc::lower(x.user) == spproc::lower(arg(0)); - if (!match) { - for (const auto& u : snap.user_list) { - if (u.conn_no == x.conn_no && - spproc::user_matches(u, arg(0))) { - match = true; - break; - } - } - } - if (!match) continue; - } - // One row per index FILE, like SAP -- a multi-tag CDX/ADI - // shows once, not once per tag. - bool dup = false; - for (const auto& r0 : rows) - if (r0[0] == x.name) { dup = true; break; } - if (!dup) rows.push_back({x.name}); - } - return emit("sp_mgGetIndexes", {{"IndexName", 'C', 260, 0}}, rows); - } - if (uname == "SP_MGGETALLLOCKS" || uname == "SP_MGGETUSERLOCKS") { - bool by_user = (uname == "SP_MGGETUSERLOCKS"); - auto snap = spproc::current_snapshot(); - std::vector> rows; - for (const auto& l : snap.lock_list) { - if (!spproc::table_matches(l.table, arg(0))) continue; - if (by_user) { - bool match = spproc::lower(l.user) == spproc::lower(arg(1)); - if (!match) { - for (const auto& u : snap.user_list) { - if (u.conn_no == l.conn_no && - spproc::user_matches(u, arg(1))) { - match = true; - break; - } - } - } - if (!match) continue; - } - rows.push_back({std::to_string(l.recno)}); - } - return emit("sp_mgGetLocks", {{"LockedRecNo", 'N', 10, 0}}, rows); - } - if (uname == "SP_MGGETLOCKOWNER") { - auto snap = spproc::current_snapshot(); - auto want = static_cast(arg_int(1)); - std::vector> rows; - for (const auto& l : snap.lock_list) { - if (!spproc::table_matches(l.table, arg(0))) continue; - if (l.recno != want) continue; - std::string addr, oslogin; - for (const auto& u : snap.user_list) { - if (u.conn_no == l.conn_no) { - addr = u.address; - oslogin = u.os_login; - break; - } - } - rows.push_back({l.user, std::to_string(l.conn_no), l.user, addr, - want == 0 ? "File Lock" : "Record Lock", - oslogin, ""}); - break; - } - if (rows.empty()) - rows.push_back({"", "0", "", "", "No Lock", "", ""}); - return emit("sp_mgGetLockOwner", - {{"UserName", 'C', 64, 0}, - {"ConnNumber", 'N', 5, 0}, - {"DictionaryUser", 'C', 64, 0}, - {"Address", 'C', 64, 0}, - {"LockType", 'C', 12, 0}, - {"OSUserLoginName", 'C', 64, 0}, - {"TSAddress", 'C', 64, 0}}, - rows); - } - if (uname == "SP_MGGETWORKERTHREADACTIVITY") { - auto snap = spproc::current_snapshot(); - std::vector> rows; - for (const auto& t : snap.thread_list) { - rows.push_back({spproc::i2s(t.thread_no), - std::to_string(t.opcode), t.user, - std::to_string(t.conn_no), t.os_login}); - } - return emit("sp_mgGetWorkerThreadActivity", - {{"ThreadNumber", 'N', 10, 0}, - {"OpCode", 'N', 5, 0}, - {"UserName", 'C', 64, 0}, - {"ConnNumber", 'N', 5, 0}, - {"OSUserLoginName", 'C', 64, 0}}, - rows); - } - if (uname == "SP_MGGETCOMMSTATS") { - auto snap = spproc::current_snapshot(); - openads::mgmt::MgCollector col(snap); - auto cs = col.comm_stats(); - char pct[24]; - std::snprintf(pct, sizeof(pct), "%.2f", cs.dPercentCheckSums); - return emit("sp_mgGetCommStats", - {{"PercentCheckSums", 'C', 12, 0}, - {"TotalPackets", 'N', 10, 0}, - {"RcvPktOutOfSeq", 'N', 10, 0}, - {"NotLoggedIn", 'N', 10, 0}, - {"RcvReqOutOfSeq", 'N', 10, 0}, - {"CheckSumFailures", 'N', 10, 0}, - {"DisconnectedUsers", 'N', 10, 0}, - {"PartialConnects", 'N', 10, 0}, - {"InvalidPackets", 'N', 10, 0}, - {"RcvFromErrors", 'N', 10, 0}, - {"SendToErrors", 'N', 10, 0}}, - {{pct, spproc::i2s(cs.ulTotalPackets), - spproc::i2s(cs.ulRcvPktOutOfSeq), spproc::i2s(cs.ulNotLoggedIn), - spproc::i2s(cs.ulRcvReqOutOfSeq), - spproc::i2s(cs.ulCheckSumFailures), - spproc::i2s(cs.ulDisconnectedUsers), - spproc::i2s(cs.ulPartialConnects), - spproc::i2s(cs.ulInvalidPackets), - spproc::i2s(cs.ulRecvFromErrors), - spproc::i2s(cs.ulSendToErrors)}}); - } - if (uname == "SP_MGGETCONFIGINFO") { - auto snap = spproc::current_snapshot(); - openads::mgmt::MgCollector col(snap); - auto cp = col.config_params(); - std::vector> rows = { - {"Stat Dump Interval", spproc::i2s(cp.ulStatDumpInterval)}, - {"Error Log Max", spproc::i2s(cp.ulErrorLogMax)}, - {"Error Log Path", spproc::mgstr(cp.aucErrorLog, 256)}, - {"Burst Packets", std::to_string(cp.usNumBurstPackets)}, - {"Sort Buffer", spproc::i2s(cp.ulSortBuffSize)}, - {"Sent IP Port", std::to_string(cp.usSendIPPort)}, - {"Receive IP Port", std::to_string(cp.usReceiveIPPort)}, - {"Semaphore File Path", spproc::mgstr(cp.aucSemaphore, 256)}, - {"Transaction Log Path", spproc::mgstr(cp.aucTransaction, 256)}, - {"SQL Statement Limit", "0"}, - {"User SQL Statement Limit", "0"}, - }; - return emit("sp_mgGetConfigInfo", - {{"Item", 'C', 30, 0}, {"Value", 'C', 260, 0}}, rows); - } - if (uname == "SP_MGGETCONFIGMEMORY") { - auto snap = spproc::current_snapshot(); - openads::mgmt::MgCollector col(snap); - auto cp = col.config_params(); - auto cm = col.config_memory(); - std::vector> rows = { - {"Connections", spproc::i2s(cp.ulNumConnections), - spproc::i2s(cm.ulConnectionMem)}, - {"Work Areas", spproc::i2s(cp.ulNumWorkAreas), - spproc::i2s(cm.ulWorkAreaMem)}, - {"Tables", spproc::i2s(cp.ulNumTables), - spproc::i2s(cm.ulTableMem)}, - {"Indexes", spproc::i2s(cp.ulNumIndexes), - spproc::i2s(cm.ulIndexMem)}, - {"Locks", spproc::i2s(cp.ulNumLocks), - spproc::i2s(cm.ulLockMem)}, - {"User Buffer Size", spproc::i2s(cp.ulUserBufferSize), - spproc::i2s(cm.ulUserBufferMem)}, - {"Worker Threads", std::to_string(cp.usNumWorkerThreads), - spproc::i2s(cm.ulWorkerThreadMem)}, - {"Total Memory", "", - spproc::i2s(static_cast( - cm.ulTotalConfigMem))}, - }; - return emit("sp_mgGetConfigMemory", - {{"Item", 'C', 30, 0}, - {"Value", 'C', 20, 0}, - {"Memory", 'N', 10, 0}}, - rows); - } - if (uname == "SP_MGGETINSTALLINFO") { - auto snap = spproc::current_snapshot(); - openads::mgmt::MgCollector col(snap); - auto ii = col.install_info(); - return emit("sp_mgGetInstallInfo", - {{"Owner", 'C', 128, 0}, - {"SerialNumber", 'C', 32, 0}, - {"UserOption", 'N', 10, 0}, - {"Version", 'C', 16, 0}, - {"InstallDate", 'C', 32, 0}, - {"EvalExpireDate", 'C', 32, 0}, - {"ANSI", 'C', 32, 0}, - {"OEM", 'C', 32, 0}, - {"ReplicationEnabled", 'L', 1, 0}, - {"MaxStatefulUsers", 'N', 10, 0}, - {"MaxStatelessUsers", 'N', 10, 0}}, - {{spproc::mgstr(ii.aucRegisteredOwner, 128), - spproc::mgstr(ii.aucSerialNumber, 32), - spproc::i2s(ii.ulUserOption), - spproc::mgstr(ii.aucVersionStr, 16), - spproc::mgstr(ii.aucInstallDate, 32), - spproc::mgstr(ii.aucEvalExpireDate, 32), - spproc::mgstr(ii.aucAnsiCharName, 32), - spproc::mgstr(ii.aucOemCharName, 32), - "F", "0", "0"}}); - } - if (uname == "SP_MGGETSERVERTYPE") { - auto snap = spproc::current_snapshot(); - std::uint16_t stv; - const char* stn; - if (snap.server_type == 0) { - stv = 3; stn = "Local Server"; - } else { -#ifdef _WIN32 - stv = 7; stn = "Windows 64-bit"; -#else - stv = 8; stn = "Linux 64-bit"; -#endif - } - return emit("sp_mgGetServerType", - {{"ServerType", 'C', 20, 0}, - {"ServerTypeValue", 'N', 5, 0}, - {"OSMajor", 'N', 5, 0}, - {"OSMinor", 'N', 5, 0}}, - {{stn, std::to_string(stv), "0", "0"}}); - } - if (uname == "SP_MGGETCRASHDUMPINFO") { - return emit("sp_mgGetCrashDumpInfo", - {{"CrashDumpName", 'C', 260, 0}, - {"CreationTime", 'C', 25, 0}, - {"FileSize", 'N', 10, 0}}, - {}); - } - if (uname == "SP_MGGETERRORLOG") { - // Newest entries from the ads_err.dbf error log (mgmt::ErrorLog). - // No-arg default is 25, single arg sets the count, and the - // two-arg SAP form's second number is the DBF-log count -- the - // only log OpenADS keeps. Error_Number is 0: per the SAP docs the - // DBF-log entries have no Error_Number (that column only exists - // in the ADT variant). - std::size_t want = 25; - if (args.size() >= 2) want = static_cast( - arg_int(1) > 0 ? arg_int(1) : 0); - else if (args.size() == 1) want = static_cast( - arg_int(0) > 0 ? arg_int(0) : 0); - std::vector> rows; - for (const auto& e : - openads::mgmt::ErrorLog::instance().read_last(want)) { - rows.push_back({"0", e.datetime, std::to_string(e.code), - e.source, std::to_string(e.src_line), - e.detail}); - } - return emit("sp_mgGetErrorLog", - {{"Error_Number", 'N', 10, 0}, - {"DateTime", 'C', 25, 0}, - {"Error_Code", 'N', 10, 0}, - {"Ads_Source", 'C', 100, 0}, - {"src_Line", 'N', 10, 0}, - {"FileName", 'C', 260, 0}}, - rows); - } - if (uname == "SP_MGSETCONFIGVALUE") { - auto& elog = openads::mgmt::ErrorLog::instance(); - std::string setting = spproc::lower(arg(0)); - auto respond = [&](const std::string& old, int result, - const char* text) { - return emit("sp_mgSetConfigValue", - {{"OldValue", 'C', 256, 0}, - {"Result", 'N', 5, 0}, - {"ResultText", 'C', 100, 0}}, - {{old, std::to_string(result), text}}); - }; - if (setting == "error_log_max") { - std::string old = std::to_string(elog.max_kbytes()); - long v = std::atol(arg(1).c_str()); - if (v <= 0) { - return respond(old, 3, "ERROR_LOG_MAX must be a positive " - "number of kilobytes."); - } - elog.set_max_kbytes(static_cast(v)); - return respond(old, 0, "Success. The change has been applied."); - } - if (setting == "error_assert_logs") { - std::string old = elog.directory(); - elog.set_directory(arg(1)); - return respond(old, 0, "Success. The change has been applied."); - } - return respond("", 3, "Modifying this setting via this stored " - "procedure is not supported."); - } - if (uname == "SP_GETSQLSTATEMENTS") { - auto snap = spproc::current_snapshot(); - std::vector> rows; - for (const auto& t : snap.thread_list) { - if (t.sql.empty() && !t.active) continue; - rows.push_back({spproc::i2s(t.thread_no), - t.active ? "T" : "F", - t.active ? "0" : "100", - t.user, t.user, "", - t.sql.substr(0, 1024), - "F", "", - spproc::fmt_time(t.sql_at), - "0", ""}); - } - return emit("sp_GetSQLStatements", - {{"Query Number", 'N', 10, 0}, - {"Active", 'L', 1, 0}, - {"Percent Complete", 'N', 10, 0}, - {"Connection Name", 'C', 100, 0}, - {"Database User", 'C', 100, 0}, - {"Database", 'C', 255, 0}, - {"Query", 'C', 1024, 0}, - {"Is Script", 'L', 1, 0}, - {"Full Script", 'C', 100, 0}, - {"Start Time", 'C', 25, 0}, - {"Seconds Until Finished", 'N', 10, 0}, - {"ApplicationID", 'C', 70, 0}}, - rows); - } - - // -- Metadata / catalog ---------------------------------------------------- - if (uname == "SP_GETTABLES") { - // (catalog, schemaPattern, tableNamePattern, Types) - const std::string& pat = spproc::is_null_arg(arg(2)) ? "" : arg(2); - std::string types = spproc::lower(arg(3)); - auto type_wanted = [&](const char* t) { - return types.empty() || spproc::is_null_arg(types) || - types.find(spproc::lower(t)) != std::string::npos; - }; - std::vector> rows; - auto* dd = c->has_dd() ? c->dd() : nullptr; - if (dd) { - if (type_wanted("TABLE")) { - for (const auto& kv : dd->tables()) { - if (!dd_can_view_object_metadata(c, kv.first)) continue; - if (!spproc::like_match(pat, kv.first)) continue; - rows.push_back({"", "", kv.first, "TABLE", ""}); - } - } - if (type_wanted("VIEW")) { - for (const auto& kv : dd->views()) { - if (!spproc::like_match(pat, kv.first)) continue; - rows.push_back({"", "", kv.first, "VIEW", - kv.second.comment}); - } - } - } else if (type_wanted("TABLE")) { - // Free connection: enumerate table files in the data directory. - std::error_code ec; - for (const auto& de : - fs::directory_iterator(c->data_dir(), ec)) { - if (!de.is_regular_file(ec)) continue; - std::string ext = spproc::lower( - de.path().extension().string()); - if (ext != ".dbf" && ext != ".adt") continue; - std::string stem = de.path().stem().string(); - if (!spproc::like_match(pat, stem)) continue; - rows.push_back({"", "", stem, "TABLE", ""}); - } - } - return emit("sp_GetTables", - {{"TABLE_CAT", 'C', 200, 0}, - {"TABLE_SCHEM", 'C', 200, 0}, - {"TABLE_NAME", 'C', 255, 0}, - {"TABLE_TYPE", 'C', 17, 0}, - {"REMARKS", 'C', 200, 0}}, - rows); - } - if (uname == "SP_GETCOLUMNS") { - // (catalog, schemaPattern, tableNamePattern, columnNamePattern) - const std::string& tpat = spproc::is_null_arg(arg(2)) ? "" : arg(2); - const std::string& cpat = spproc::is_null_arg(arg(3)) ? "" : arg(3); - - // DbfFieldType -> (ODBC DATA_TYPE, TYPE_NAME, default display size) - auto odbc_of = [](openads::drivers::DbfFieldType t, - const openads::drivers::DbfField& fd) - -> std::tuple { - using FT = openads::drivers::DbfFieldType; - switch (t) { - case FT::Character: return {1, "CHAR", fd.length}; - case FT::CiCharacter: return {1, "CICHAR", fd.length}; - case FT::Varchar: return {12, "VARCHAR", fd.length}; - case FT::Numeric: return {2, "NUMERIC", fd.length}; - case FT::Float: return {8, "DOUBLE", 15}; - case FT::Double: return {8, "DOUBLE", 15}; - case FT::Integer: return {4, "INTEGER", 10}; - case FT::ShortInt: return {5, "SHORT", 5}; - case FT::AutoInc: return {4, "AUTOINC", 10}; - case FT::Logical: return {-7, "LOGICAL", 1}; - case FT::Date: return {91, "DATE", 10}; - case FT::AdtDate: return {91, "DATE", 10}; - case FT::Time: return {92, "TIME", 8}; - case FT::DateTime: return {93, "TIMESTAMP", 22}; - case FT::AdtTimestamp: return {93, "TIMESTAMP", 22}; - case FT::ModTime: return {93, "MODTIME", 22}; - case FT::Memo: return {-1, "MEMO", 0}; - case FT::Binary: return {-4, "BLOB", 0}; - case FT::Varbinary: return {-4, "VARBINARY", fd.length}; - case FT::Currency: return {2, "MONEY", 18}; - case FT::AdtMoney: return {2, "MONEY", 18}; - case FT::RowVersion: return {-5, "ROWVERSION", 8}; - default: return {1, "CHAR", fd.length}; - } - }; - - std::vector> targets; - auto* dd = c->has_dd() ? c->dd() : nullptr; - if (dd) { - for (const auto& kv : dd->tables()) { - if (!dd_can_view_object_metadata(c, kv.first)) continue; - if (!spproc::like_match(tpat, kv.first)) continue; - targets.emplace_back(kv.first, kv.second); - } - } else { - std::error_code ec; - for (const auto& de : - fs::directory_iterator(c->data_dir(), ec)) { - if (!de.is_regular_file(ec)) continue; - std::string ext = spproc::lower( - de.path().extension().string()); - if (ext != ".dbf" && ext != ".adt") continue; - std::string stem = de.path().stem().string(); - if (!spproc::like_match(tpat, stem)) continue; - targets.emplace_back(stem, de.path().filename().string()); - } - } - - std::vector> rows; - for (const auto& [alias, rel] : targets) { - auto th = c->open_table(rel, openads::engine::TableType::Cdx, - openads::engine::OpenMode::Read); - if (!th) continue; - openads::engine::Table* tbl = c->lookup_table(th.value()); - if (tbl) { - std::uint16_t nf = tbl->field_count(); - for (std::uint16_t i = 0; i < nf; ++i) { - const auto& fd = tbl->field_descriptor(i); - if (!spproc::like_match(cpat, fd.name)) continue; - auto [dtype, tname, size] = odbc_of(fd.type, fd); - rows.push_back({ - "", "", alias, fd.name, - std::to_string(dtype), tname, - std::to_string(size), - std::to_string(fd.length), - std::to_string(fd.decimals), - "10", "1", "", "", - std::to_string(dtype), "0", - std::to_string(fd.length), - std::to_string(i + 1), "YES", "0"}); - } - } - c->close_table(th.value()); - } - return emit("sp_GetColumns", - {{"TABLE_CAT", 'C', 200, 0}, - {"TABLE_SCHEM", 'C', 200, 0}, - {"TABLE_NAME", 'C', 255, 0}, - {"COLUMN_NAME", 'C', 200, 0}, - {"DATA_TYPE", 'N', 5, 0}, - {"TYPE_NAME", 'C', 20, 0}, - {"COLUMN_SIZE", 'N', 10, 0}, - {"BUFFER_LENGTH", 'N', 10, 0}, - {"DECIMAL_DIGITS", 'N', 5, 0}, - {"NUM_PREC_RADIX", 'N', 5, 0}, - {"NULLABLE", 'N', 5, 0}, - {"REMARKS", 'C', 100, 0}, - {"COLUMN_DEF", 'C', 100, 0}, - {"SQL_DATA_TYPE", 'N', 10, 0}, - {"SQL_DATETIME_SUB", 'N', 10, 0}, - {"CHAR_OCTET_LENGTH", 'N', 10, 0}, - {"ORDINAL_POSITION", 'N', 10, 0}, - {"IS_NULLABLE", 'C', 4, 0}, - {"NOCPTRANS", 'N', 5, 0}}, - rows); - } - if (uname == "SP_GETSQLKEYWORDS") { - static const char* kKeywords[] = { - "ADD", "ALL", "ALTER", "AND", "ANY", "AS", "ASC", "AVG", - "BEGIN", "BETWEEN", "BOOLEAN", "BOTH", "BY", "CASE", "CAST", - "CHAR", "CHECK", "COLUMN", "COMMIT", "CONSTRAINT", "COUNT", - "CREATE", "CURRENT_DATE", "CURRENT_TIME", "CURRENT_TIMESTAMP", - "CURSOR", "DATABASE", "DATE", "DECLARE", "DEFAULT", "DELETE", - "DESC", "DISTINCT", "DOUBLE", "DROP", "ELSE", "END", "ESCAPE", - "EXECUTE", "EXISTS", "FETCH", "FOR", "FOREIGN", "FROM", - "FUNCTION", "GRANT", "GROUP", "HAVING", "IF", "IIF", "IN", - "INDEX", "INNER", "INSERT", "INTEGER", "INTO", "IS", "JOIN", - "KEY", "LEADING", "LEFT", "LIKE", "LIMIT", "LOGICAL", "MAX", - "MEMO", "MIN", "MONEY", "NOT", "NULL", "NUMERIC", "ON", "OR", - "ORDER", "OUTER", "OUTPUT", "PRIMARY", "PROCEDURE", "REFERENCES", - "RESTRICT", "REVOKE", "RIGHT", "ROLLBACK", "ROWID", "SELECT", - "SET", "SHORT", "SUM", "TABLE", "THEN", "TIME", "TIMESTAMP", - "TO", "TOP", "TRAILING", "TRANSACTION", "TRIGGER", "UNION", - "UNIQUE", "UPDATE", "VALUES", "VARCHAR", "VIEW", "WHEN", - "WHERE", "WHILE", "WITH", - }; - std::vector> rows; - for (const char* k : kKeywords) rows.push_back({k}); - return emit("sp_GetSQLKeywords", {{"Keyword", 'C', 30, 0}}, rows); - } - if (uname == "SP_GETLINKS") { - auto* dd = c->has_dd() ? c->dd() : nullptr; - if (!dd) return err(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); - std::vector> rows; - for (const auto& kv : dd->links()) - rows.push_back({kv.second.alias, kv.second.path}); - return emit("sp_GetLinks", - {{"LinkAlias", 'C', 200, 0}, {"Path", 'C', 260, 0}}, rows); - } - if (uname == "SP_GETAPPLICATIONID") { - return emit("sp_GetApplicationID", - {{"ApplicationID", 'C', 255, 0}}, - {{c->application_id()}}); - } - if (uname == "SP_GETSECURITYINFO") { - bool server_side = - static_cast(openads::mgmt::process_snapshot_provider()); - return emit("sp_GetSecurityInfo", - {{"FIPSMode", 'L', 1, 0}, - {"EncryptionType", 'C', 10, 0}, - {"DictionaryEncrypted", 'L', 1, 0}, - {"CommType", 'C', 10, 0}, - {"TLSCipher", 'C', 20, 0}, - {"TLSVersion", 'C', 20, 0}}, - {{"F", "RC4", "F", server_side ? "TCP_IP" : "LOCAL", "", ""}}); - } - if (uname == "SP_GETTABLEENCRYPTIONTYPE") { - auto th = c->open_table(arg(0), openads::engine::TableType::Cdx, - openads::engine::OpenMode::Read); - if (!th) return err(openads::AE_NO_FILE_FOUND, arg(0)); - std::string enc; - if (openads::engine::Table* t = c->lookup_table(th.value())) { - auto* cdx = dynamic_cast( - t->driver() ? t->driver()->unwrap() : nullptr); - if (cdx && (cdx->encrypted() || cdx->partial_encrypted())) - enc = "AES256"; - } - c->close_table(th.value()); - return emit("sp_GetTableEncryptionType", - {{"EncryptionType", 'C', 10, 0}}, {{enc}}); - } - if (uname == "SP_GETTABLESIZEINFO") { - auto th = c->open_table(arg(0), openads::engine::TableType::Cdx, - openads::engine::OpenMode::Read); - if (!th) return err(openads::AE_NO_FILE_FOUND, arg(0)); - std::vector> rows; - if (openads::engine::Table* t = c->lookup_table(th.value())) { - std::uint32_t rlen = static_cast( - t->record_buffer().size()); - std::uint32_t nf = t->field_count(); - std::string ext = spproc::lower( - fs::path(t->path()).extension().string()); - std::uint32_t header = (ext == ".adt") - ? 400u + 200u * nf - : 32u + 32u * nf + 1u; - rows.push_back({spproc::i2s(header), spproc::i2s(rlen), - spproc::i2s(t->record_count()), - "0", "0", "0", "0"}); - } - c->close_table(th.value()); - return emit("sp_GetTableSizeInfo", - {{"TableHeaderSize", 'N', 10, 0}, - {"RecordLength", 'N', 10, 0}, - {"RawRecordCount", 'N', 10, 0}, - {"DeletedRecordCount", 'N', 10, 0}, - {"MemoHeaderSize", 'N', 10, 0}, - {"MemoBlockSize", 'N', 10, 0}, - {"MemoBlockCount", 'N', 10, 0}}, - rows); - } - if (uname == "SP_GETRECORDCRC") { - auto th = c->open_table(arg(0), openads::engine::TableType::Cdx, - openads::engine::OpenMode::Read); - if (!th) return err(openads::AE_NO_FILE_FOUND, arg(0)); - std::optional crc; - if (openads::engine::Table* t = c->lookup_table(th.value())) { - auto recno = static_cast(arg_int(1)); - if (auto g = t->goto_record(recno); g) { - crc = openads::engine::crc32_record(t->record_buffer()); - } - } - c->close_table(th.value()); - if (!crc) return err(openads::AE_INTERNAL_ERROR, "record not found"); - return emit("sp_GetRecordCRC", - {{"CRCInteger", 'N', 10, 0}, {"CRCString", 'C', 12, 0}}, - {{std::to_string(static_cast(*crc)), - std::to_string(*crc)}}); - } - if (uname == "SP_GETCOLLATIONS") { - std::vector> rows; - for (const char* name : {"PL852", "NTXPL852"}) { - if (!spproc::is_null_arg(arg(0)) && - !spproc::like_match(arg(0), name)) continue; - rows.push_back({name, "PL852", "Polish CP-852 OEM collation", - "1", "852", "F", "", "F"}); - } - return emit("sp_GetCollations", - {{"Name", 'C', 100, 0}, - {"ShortName", 'C', 8, 0}, - {"Description", 'C', 100, 0}, - {"Version", 'N', 5, 0}, - {"CodePage", 'N', 5, 0}, - {"FoxCompat", 'L', 1, 0}, - {"UnicodeLocale", 'C', 16, 0}, - {"AllowMultiple", 'L', 1, 0}}, - rows); - } - if (uname == "SP_GETCOLLATIONTABLE") { - std::string want = spproc::is_null_arg(arg(0)) ? arg(1) : arg(0); - std::vector> rows; - if (openads::engine::lookup_oem_collation(want.c_str()) != nullptr) { - rows.push_back({"1", spproc::lower(want) == "ntxpl852" - ? "NTXPL852" : "PL852", - "PL852", "1", "852", "", "", "", "0", "", "F"}); - } - return emit("sp_GetCollationTable", - {{"CollationID", 'N', 5, 0}, - {"Name", 'C', 100, 0}, - {"ShortName", 'C', 8, 0}, - {"Version", 'N', 5, 0}, - {"CodePage", 'N', 5, 0}, - {"CE", 'C', 1, 0}, - {"Upper", 'C', 1, 0}, - {"Lower", 'C', 1, 0}, - {"NumContractions", 'N', 5, 0}, - {"Contractions", 'C', 1, 0}, - {"AllowMultiple", 'L', 1, 0}}, - rows); - } - if (uname == "SP_ALLOWMULTIPLECOLLATIONS") { - // Accepted no-op: OpenADS opens one collation per index today. - return emit("sp_AllowMultipleCollations", - {{"Result", 'N', 5, 0}}, {{"0"}}); - } - - // -- Backup / restore -------------------------------------------------------- - // SAP-shaped result set: an EMPTY set means complete success; rows are - // warnings/errors. Shares openads::engine::backup with the adsbackup - // command-line tool so the two entry points cannot drift. - if (uname == "SP_BACKUPDATABASE" || uname == "SP_BACKUPFREETABLES" || - uname == "SP_RESTOREDATABASE" || uname == "SP_RESTOREFREETABLES") { - namespace bk = openads::engine::backup; - openads::util::Result res = - openads::util::Error{openads::AE_INTERNAL_ERROR, 0, "", ""}; - - if (uname == "SP_BACKUPDATABASE") { - // (DestinationPath, Options) - if (!c->has_dd() || c->dd_path().empty()) { - return err(openads::AE_FUNCTION_NOT_AVAILABLE, - "sp_BackupDatabase requires a data dictionary " - "connection (use sp_BackupFreeTables for free " - "tables)"); - } - // Flush every open table first so the file-level image is - // consistent with what the engine has in memory. - state().registry.for_each_handle( - [&](Handle, HandleKind k, void* p) { - if (k != HandleKind::Table) return; - if (auto* tp = static_cast(p)) (void)tp->flush(); - }); - res = bk::backup_database(c->dd_path(), arg(0), - bk::parse_options(arg(1))); - } else if (uname == "SP_BACKUPFREETABLES") { - // (SourcePath, SourceMask, DestinationPath, Options, - // FreeTablePasswords) - auto opt = bk::parse_options(arg(3)); - if (!spproc::is_null_arg(arg(4))) { - opt.warnings.push_back( - "FreeTablePasswords ignored: OpenADS copies encrypted " - "tables byte-for-byte, no password needed"); - } - state().registry.for_each_handle( - [&](Handle, HandleKind k, void* p) { - if (k != HandleKind::Table) return; - if (auto* tp = static_cast(p)) (void)tp->flush(); - }); - std::string src = spproc::is_null_arg(arg(0)) - ? c->data_dir() : arg(0); - res = bk::backup_free_tables(src, arg(1), arg(2), opt); - } else if (uname == "SP_RESTOREDATABASE") { - // (SourcePath[.add], SourcePassword, DestinationPath[.add], - // Options) - res = bk::restore_database(arg(0), arg(2), - bk::parse_options(arg(3))); - } else { // SP_RESTOREFREETABLES - // (SourcePath, DestinationPath, Options, FreeTablePasswords) - res = bk::restore_free_tables(arg(0), arg(1), - bk::parse_options(arg(2))); - } - - if (!res.has_value()) { - return err(static_cast(res.error().code), - res.error().message); - } - std::vector> rows; - for (const auto& r0 : res.value().rows) { - rows.push_back({std::to_string(r0.severity), - std::to_string(r0.error_code), - r0.message, r0.table, r0.extra, - "backup.cpp", "0"}); - } - return emit("sp_Backup", - {{"Severity", 'N', 5, 0}, - {"Error Code", 'N', 10, 0}, - {"Error Message", 'C', 200, 0}, - {"Table Name", 'C', 200, 0}, - {"Additional Info", 'C', 260, 0}, - {"Source File", 'C', 32, 0}, - {"Source Line", 'N', 10, 0}}, - rows); - } - - // -- Named-event waits ------------------------------------------------------- - if (uname == "SP_WAITFOREVENT" || uname == "SP_WAITFORANYEVENT") { - const bool any = (uname == "SP_WAITFORANYEVENT"); - std::string name = any ? "" : spproc::lower(arg(0)); - std::int64_t timeout_ms = arg_int(any ? 0 : 1); - - std::unique_lock lk(spproc::ev_mu()); - auto& m = spproc::ev_map(); - if (!any && m.find({c, name}) == m.end()) { - lk.unlock(); - return err(openads::AE_NO_FILE_FOUND, - "event not registered on this connection: " + arg(0)); - } - auto find_signaled = - [&]() -> std::pair { - for (auto& [key, reg] : m) { - if (key.first != c) continue; - if (!any && key.second != name) continue; - if (reg.pending > 0) return {&key.second, ®}; - } - return {nullptr, nullptr}; - }; - - auto pred = [&] { return find_signaled().second != nullptr; }; - if (!pred()) { - if (timeout_ms < 0) { - spproc::ev_cv().wait(lk, pred); - } else if (timeout_ms > 0) { - spproc::ev_cv().wait_for( - lk, std::chrono::milliseconds(timeout_ms), pred); - } - } - - auto [signaled_name, reg] = find_signaled(); - std::string out_name, out_data; - std::uint64_t out_count = 0; - if (reg != nullptr) { - out_name = *signaled_name; - if (reg->with_data) { - --reg->pending; - ++reg->consumed; - out_count = reg->consumed; - if (!reg->data.empty()) { - out_data = reg->data.front(); - reg->data.pop_front(); - } - } else { - out_count = reg->pending; - reg->pending = 0; - } - } else if (!any) { - out_name = name; // timed out: EventCount 0 per SAP docs - } - lk.unlock(); - return emit("sp_WaitForEvent", - {{"EventName", 'C', 200, 0}, - {"EventCount", 'N', 10, 0}, - {"StringData", 'C', 1024, 0}}, - {{out_name, spproc::i2s(out_count), out_data}}); - } - - return false; -} - -} // extern "C++" - -} // extern "C" -- temporarily closed so proc:: helpers get C++ linkage - -// ============================================================ -// Script-engine bridge (docs/script-engine.md par 4.2 / 4.5). -// -// RCB 07/17/2026: this replaces the old string-based "proc" mini- -// interpreter. DD stored functions now run through the typed script -// engine (src/engine/script); this bridge is the SqlBridge the engine -// uses to delegate embedded SQL and subqueries back into the ONE SQL -// executor (AdsExecuteSQLDirect on the caller's statement), and to -// resolve UDF-to-UDF calls by direct recursion. -// ============================================================ -namespace scriptbridge { - -using openads::script::Type; -using openads::script::Value; - -// Parse-once program cache, keyed by the body text itself (bodies are -// immutable strings; identical text -> identical program). -inline openads::util::Result> -compiled(const std::string& body) { - static std::mutex mu; - static std::unordered_map> cache; - { - std::lock_guard lk(mu); - auto it = cache.find(body); - if (it != cache.end()) return it->second; - } - auto prog = openads::script::compile(body); - if (!prog) return prog.error(); - std::lock_guard lk(mu); - cache[body] = prog.value(); - return std::move(prog).value(); -} - -// Convert a display-format string into a typed Value for a declared slot. -// Dates accept both MM/DD/YYYY (the display format) and YYYY-MM-DD. -inline Value value_from_text(const std::string& text, Type t) { - auto trimmed = text; - while (!trimmed.empty() && trimmed.back() == ' ') trimmed.pop_back(); - if (trimmed.empty() && t != Type::Char) return Value::typed_null(t); - switch (t) { - case Type::Integer: - return Value::integer(std::strtoll(trimmed.c_str(), nullptr, 10)); - case Type::Double: - return Value::real(std::strtod(trimmed.c_str(), nullptr)); - case Type::Logical: - return Value::logical(!trimmed.empty() && - (trimmed[0] == 'T' || trimmed[0] == 't' || - trimmed[0] == '1' || trimmed[0] == 'Y')); - case Type::Date: { - int y = 0, m = 0, d = 0; - auto all_digits = [](const std::string& s2, std::size_t a2, - std::size_t b2) { - for (std::size_t k2 = a2; k2 < b2; ++k2) - if (!std::isdigit(static_cast(s2[k2]))) - return false; - return true; - }; - if (trimmed.size() >= 10 && trimmed[2] == '/' && trimmed[5] == '/') { - m = std::atoi(trimmed.substr(0, 2).c_str()); - d = std::atoi(trimmed.substr(3, 2).c_str()); - y = std::atoi(trimmed.substr(6, 4).c_str()); - } else if (trimmed.size() >= 10 && trimmed[4] == '-' && - trimmed[7] == '-') { - y = std::atoi(trimmed.substr(0, 4).c_str()); - m = std::atoi(trimmed.substr(5, 2).c_str()); - d = std::atoi(trimmed.substr(8, 2).c_str()); - } else if (trimmed.size() >= 8 && all_digits(trimmed, 0, 8)) { - // Raw engine format "YYYYMMDD" (DBF date cells and the ADT - // date reader's as_string both use it). - y = std::atoi(trimmed.substr(0, 4).c_str()); - m = std::atoi(trimmed.substr(4, 2).c_str()); - d = std::atoi(trimmed.substr(6, 2).c_str()); - } - if (y == 0) return Value::typed_null(Type::Date); - return Value::date(openads::script::jdn_from_ymd(y, m, d)); - } - case Type::Timestamp: { - // Raw engine format "YYYYMMDDHHMMSS[…]" (AdtTimestamp / DBF T - // reader output) -- digit run with no separators. - bool raw14 = trimmed.size() >= 8; - for (std::size_t k2 = 0; raw14 && k2 < 8; ++k2) - if (!std::isdigit(static_cast(trimmed[k2]))) - raw14 = false; - if (raw14 && (trimmed.size() == 8 || - (trimmed.size() >= 9 && - std::isdigit(static_cast( - trimmed[8]))))) { - Value dv = value_from_text(trimmed.substr(0, 8), Type::Date); - if (dv.is_null) return Value::typed_null(Type::Timestamp); - std::int64_t ms = 0; - if (trimmed.size() >= 14) { - int hh = std::atoi(trimmed.substr(8, 2).c_str()); - int mi = std::atoi(trimmed.substr(10, 2).c_str()); - int ss = std::atoi(trimmed.substr(12, 2).c_str()); - ms = (static_cast(hh) * 3600 + mi * 60 + - ss) * 1000; - } - return Value::timestamp(dv.i * 86400000 + ms); - } - Value dv = value_from_text(trimmed.substr(0, 10), Type::Date); - if (dv.is_null) return Value::typed_null(Type::Timestamp); - std::int64_t ms = 0; - if (trimmed.size() >= 19) { - int hh = std::atoi(trimmed.substr(11, 2).c_str()); - int mi = std::atoi(trimmed.substr(14, 2).c_str()); - int ss = std::atoi(trimmed.substr(17, 2).c_str()); - if (trimmed.find("PM") != std::string::npos && hh < 12) - hh += 12; - ms = (static_cast(hh) * 3600 + mi * 60 + ss) * - 1000; - } - return Value::timestamp(dv.i * 86400000 + ms); - } - case Type::Char: - case Type::Null: - return Value::character(text); - } - return Value::character(text); -} - -// Static result-type inference for a script expression. Drives the temp -// result-column type for ScriptCall projections so subquery consumers see -// typed values (SAP's cursors type expression columns; ours must too). -// Null = unknown -> C(50) fallback. -inline Type infer_expr_type(const openads::script::Expr& e) { - using E = openads::script::ExprKind; - switch (e.kind) { - case E::Literal: return e.lit.type; - case E::FnCall: { - const std::string& f = e.upper; - if (f == "YEAR" || f == "MONTH" || f == "DAY" || - f == "LENGTH" || f == "POSITION" || f == "TIMESTAMPDIFF" || - f == "DAYOFMONTH" || f == "DAYOFWEEK" || f == "DAYOFYEAR" || - f == "HOUR" || f == "MINUTE" || f == "SECOND") - return Type::Integer; - if (f == "CURDATE" || f == "TODAY") return Type::Date; - if (f == "NOW" || f == "CURTIMESTAMP" || f == "TIMESTAMPADD") - return Type::Timestamp; - if (f == "IIF" && e.args.size() == 3) { - Type t = infer_expr_type(*e.args[1]); - if (t != Type::Null) return t; - return infer_expr_type(*e.args[2]); - } - if (f == "CONVERT" || f == "CAST") { - const std::string& tn = e.type_name; - if (tn == "SQL_DATE" || tn == "DATE") return Type::Date; - if (tn == "SQL_TIMESTAMP" || tn == "TIMESTAMP") - return Type::Timestamp; - if (tn == "SQL_INTEGER" || tn == "INTEGER" || - tn == "SQL_SMALLINT") - return Type::Integer; - if (tn == "SQL_DOUBLE" || tn == "SQL_NUMERIC" || - tn == "SQL_FLOAT" || tn == "MONEY") - return Type::Double; - return Type::Null; // SQL_CHAR etc. -> C fallback - } - return Type::Null; - } - case E::Case: { - for (const auto& w : e.whens) { - Type t = infer_expr_type(*w.second); - if (t != Type::Null) return t; - } - if (e.else_expr) return infer_expr_type(*e.else_expr); - return Type::Null; - } - default: return Type::Null; - } -} - -// Bind every column of tbl's CURRENT row as a typed script parameter, -// under both bare and [bracketed] spellings (the script lexer keeps -// brackets in the identifier token). Used by ScriptCall projections and -// MERGE SET evaluation. -inline void bind_row_params(openads::script::Executor& ex, - openads::engine::Table& tbl) { - using FT = openads::drivers::DbfFieldType; - std::uint16_t nfld = tbl.field_count(); - for (std::uint16_t k2 = 0; k2 < nfld; ++k2) { - auto v2 = tbl.read_field(k2); - if (!v2) continue; - const auto& fd2 = tbl.field_descriptor(k2); - Value sv; - if (v2.value().is_null) { - sv = Value::null(); - } else switch (fd2.type) { - case FT::Numeric: case FT::Float: case FT::Double: - case FT::Currency: case FT::AdtMoney: - sv = Value::real(v2.value().as_double); - break; - case FT::Integer: case FT::ShortInt: case FT::AutoInc: - sv = Value::integer( - static_cast(v2.value().as_double)); - break; - case FT::Date: case FT::AdtDate: - sv = value_from_text(v2.value().as_string, Type::Date); - break; - case FT::DateTime: case FT::AdtTimestamp: case FT::ModTime: - sv = value_from_text(v2.value().as_string, Type::Timestamp); - break; - case FT::Logical: - sv = value_from_text(v2.value().as_string, Type::Logical); - break; - default: { - std::string s2 = v2.value().as_string; - while (!s2.empty() && s2.back() == ' ') s2.pop_back(); - sv = Value::character(std::move(s2)); - } - } - ex.set_param("[" + fd2.name + "]", sv); - ex.set_param(fd2.name, std::move(sv)); - } -} - -// Write a typed script Value into a table field: numerics through the -// double path, dates/timestamps as the engine's raw YYYYMMDD[HHMMSS] -// text, logicals as T/F. Shared by MERGE SET and INSERT default-value -// application. -inline openads::util::Result write_script_value( - openads::engine::Table& tbl, std::uint16_t fi, const Value& rv) { - if (rv.is_null) return tbl.set_field_null(fi); - switch (rv.type) { - case Type::Integer: - return tbl.set_field(fi, static_cast(rv.i)); - case Type::Double: - return tbl.set_field(fi, rv.d); - case Type::Logical: - return tbl.set_field(fi, std::string(rv.i ? "T" : "F")); - case Type::Date: { - int y2, m2, d2; - openads::script::ymd_from_jdn(rv.i, y2, m2, d2); - char db[16]; - std::snprintf(db, sizeof(db), "%04d%02d%02d", y2, m2, d2); - return tbl.set_field(fi, std::string(db)); - } - case Type::Timestamp: { - std::int64_t jdn = rv.i / 86400000; - std::int64_t ms2 = rv.i % 86400000; - int y2, m2, d2; - openads::script::ymd_from_jdn(jdn, y2, m2, d2); - char db[24]; - std::snprintf(db, sizeof(db), "%04d%02d%02d%02d%02d%02d", - y2, m2, d2, - static_cast(ms2 / 3600000), - static_cast((ms2 / 60000) % 60), - static_cast((ms2 / 1000) % 60)); - return tbl.set_field(fi, std::string(db)); - } - default: - return tbl.set_field(fi, rv.s); - } -} - -// One-value cursor: answers single-value fast paths (literal iota -// selects, trigger row-image reads) without a SQL round-trip. -struct OneValueCursor final : openads::script::SqlCursor { - Value v; - bool done = false; - explicit OneValueCursor(Value val) : v(std::move(val)) {} - bool next() override { - if (done) return false; - done = true; - return true; - } - std::size_t field_count() const override { return 1; } - Value field(std::size_t) override { return v; } -}; - -// If `sql` is exactly "SELECT FROM system.iota", return the -// literal's value; otherwise empty. Recognizes numbers, 'strings', NULL, -// TRUE/FALSE and {d '...'}/{ts '...'} -- precisely what the executor's -// variable substitution can produce. -inline std::optional literal_iota_select(const std::string& sql) { - auto trim = [](std::string s) { - auto b = s.find_first_not_of(" \t\r\n"); - auto e = s.find_last_not_of(" \t\r\n;"); - if (b == std::string::npos) return std::string(); - return s.substr(b, e - b + 1); - }; - std::string s = trim(sql); - if (s.size() < 12) return std::nullopt; - auto up = s; - for (auto& ch : up) - ch = static_cast(std::toupper((unsigned char)ch)); - if (up.compare(0, 7, "SELECT ") != 0) return std::nullopt; - auto fp = up.rfind(" FROM "); - if (fp == std::string::npos) return std::nullopt; - std::string src = trim(s.substr(fp + 6)); - for (auto& ch : src) - ch = static_cast(std::tolower((unsigned char)ch)); - if (src != "system.iota") return std::nullopt; - std::string lit = trim(s.substr(7, fp - 7)); - if (lit.empty()) return std::nullopt; - std::string lup = lit; - for (auto& ch : lup) - ch = static_cast(std::toupper((unsigned char)ch)); - if (lup == "NULL") return Value::null(); - if (lup == "TRUE") return Value::logical(true); - if (lup == "FALSE") return Value::logical(false); - if (lit.size() >= 2 && lit.front() == '\'' && lit.back() == '\'') { - std::string v2; - for (std::size_t i = 1; i + 1 < lit.size(); ++i) { - if (lit[i] == '\'' && i + 2 < lit.size() && lit[i + 1] == '\'') - { v2 += '\''; ++i; } - else if (lit[i] == '\'') return std::nullopt; // 'a','b' etc. - else v2 += lit[i]; - } - return Value::character(v2); - } - if (lit.front() == '{') { - // {d 'YYYY-MM-DD'} / {ts '...'} -- reuse the text parser. - auto q1 = lit.find('\''); - auto q2 = lit.rfind('\''); - if (q1 == std::string::npos || q2 <= q1) return std::nullopt; - std::string inner = lit.substr(q1 + 1, q2 - q1 - 1); - bool is_ts = lup.compare(0, 3, "{TS") == 0; - Value v2 = value_from_text(inner, - is_ts ? Type::Timestamp : Type::Date); - if (v2.is_null) return std::nullopt; - return v2; - } - char* end = nullptr; - double d = std::strtod(lit.c_str(), &end); - if (end == lit.c_str() + lit.size()) { - if (lit.find('.') == std::string::npos && - d == static_cast(static_cast(d))) - return Value::integer(static_cast(d)); - return Value::real(d); - } - return std::nullopt; -} - -// Typed row access over an AdsExecuteSQLDirect cursor. -struct AbiSqlCursor final : openads::script::SqlCursor { - ADSHANDLE h = 0; - bool first = true; - explicit AbiSqlCursor(ADSHANDLE hc) : h(hc) {} - ~AbiSqlCursor() override { if (h) AdsCloseTable(h); } - // Hand the underlying cursor handle to the caller (top-level script - // result -- S3 §10 mechanism); the wrapper stops owning it. - ADSHANDLE release_handle() { ADSHANDLE x = h; h = 0; return x; } - - bool next() override { - if (!first) AdsSkip(h, 1); - first = false; - UNSIGNED16 eof = 1; - AdsAtEOF(h, &eof); - return eof == 0; - } - std::size_t field_count() const override { - UNSIGNED16 n = 0; - AdsGetNumFields(h, &n); - return n; - } - std::string field_name(std::size_t idx) const override { - // Script cursor field access (c.name -- S3 §11). - UNSIGNED8 nm[128] = {0}; - UNSIGNED16 cap = sizeof(nm) - 1; - if (AdsGetFieldName(h, static_cast(idx + 1), nm, &cap) - != 0) - return ""; - std::string s(reinterpret_cast(nm), cap); - while (!s.empty() && s.back() == ' ') s.pop_back(); - return s; - } - Value field(std::size_t idx) override { - // RCB 07/18/2026 (S3 probe C22): prefer BY-NAME access -- on a - // projected SELECT the engine's ordinal path can reach the BASE - // table's ordinal (returning the wrong column), while name lookup - // respects the projection. Ordinal stays as the fallback for - // aggregate/expression columns whose names don't resolve - // ("COUNT(*)"). - UNSIGNED8* ord = reinterpret_cast( - static_cast(idx + 1)); - UNSIGNED8 nmbuf[128] = {0}; - UNSIGNED16 nmlen = sizeof(nmbuf) - 1; - bool have_name = - AdsGetFieldName(h, static_cast(idx + 1), nmbuf, - &nmlen) == 0 && nmlen > 0; - if (have_name) nmbuf[nmlen] = 0; - UNSIGNED16 ftype = 0; - std::vector buf(65536, 0); - UNSIGNED32 len = static_cast(buf.size() - 1); - bool got = false; - if (have_name && - AdsGetFieldType(h, nmbuf, &ftype) == 0 && - AdsGetString(h, nmbuf, reinterpret_cast(buf.data()), - &len, 0) == 0) - got = true; - if (!got) { - len = static_cast(buf.size() - 1); - AdsGetFieldType(h, ord, &ftype); - if (AdsGetString(h, ord, - reinterpret_cast(buf.data()), - &len, 0) != 0) - return Value::null(); - } - std::string text(buf.data(), len); - switch (ftype) { - case ADS_NUMERIC: case ADS_DOUBLE: case ADS_CURDOUBLE: - case ADS_MONEY: - return value_from_text(text, Type::Double); - case ADS_INTEGER: case ADS_SHORTINT: case ADS_AUTOINC: - return value_from_text(text, Type::Integer); - case ADS_DATE: - return value_from_text(text, Type::Date); - case ADS_TIMESTAMP: - return value_from_text(text, Type::Timestamp); - case ADS_LOGICAL: - return value_from_text(text, Type::Logical); - default: - return Value::character(text); - } - } -}; - -openads::util::Result run_dd_function( - Connection* c, ADSHANDLE hStmt, const std::string& name, - const std::vector& args); - -struct AbiBridge final : openads::script::SqlBridge { - Connection* c = nullptr; - ADSHANDLE hStmt = 0; - AbiBridge(Connection* conn, ADSHANDLE hs) : c(conn), hStmt(hs) {} - - openads::util::Result> - exec(const std::string& sql) override { - // Fast path: "SELECT FROM system.iota" -- the shape that - // variable substitution leaves behind for "(SELECT param FROM - // __input)". Answered directly; also sidesteps the SQL engine's - // current lack of literal-only projections (S2-4 gap list). - { - auto lit = literal_iota_select(sql); - if (lit.has_value()) { - return std::unique_ptr( - new OneValueCursor(std::move(*lit))); - } - } - // Expression fast path: "SELECT FROM system.iota" with a - // script-evaluable projection ("SELECT LENGTH(@t) …" after variable - // substitution). The SQL engine has no expression projections yet; - // the script evaluator's builtin/operator set covers the idiom - // (probes N5, C-series result selects). - { - auto v = expr_iota_value(sql); - if (v.has_value()) { - return std::unique_ptr( - new OneValueCursor(std::move(*v))); - } - } - std::vector sbuf(sql.size() + 1); - std::memcpy(sbuf.data(), sql.c_str(), sql.size() + 1); - ADSHANDLE hc = 0; - UNSIGNED32 rc = AdsExecuteSQLDirect(hStmt, sbuf.data(), &hc); - if (rc != 0) { - // Surface the INNER error text -- the statement prefix alone - // hides which piece of a multi-statement script failed. - std::string inner = openads::abi::last_error_message(); - return openads::util::Error{static_cast(rc), 0, - "embedded SQL failed" + - (inner.empty() ? std::string() : (" [" + inner + "]")) + - ": " + sql.substr(0, 80), ""}; - } - if (hc == 0) - return std::unique_ptr{}; - return std::unique_ptr( - new AbiSqlCursor(hc)); - } - - // Evaluate the projection of a "SELECT FROM system.iota" - // through the script expression evaluator. nullopt = not that shape or - // not script-evaluable (caller falls through to the SQL engine). - std::optional expr_iota_value(const std::string& sql) { - auto trim = [](std::string s) { - auto b = s.find_first_not_of(" \t\r\n"); - auto e = s.find_last_not_of(" \t\r\n;"); - if (b == std::string::npos) return std::string(); - return s.substr(b, e - b + 1); - }; - std::string s = trim(sql); - std::string up = s; - for (auto& ch : up) - ch = static_cast(std::toupper((unsigned char)ch)); - if (up.compare(0, 7, "SELECT ") != 0) return std::nullopt; - auto fp = up.rfind(" FROM "); - if (fp == std::string::npos) return std::nullopt; - std::string src = trim(s.substr(fp + 6)); - for (auto& ch : src) - ch = static_cast(std::tolower((unsigned char)ch)); - if (src != "system.iota") return std::nullopt; - std::string proj = trim(s.substr(7, fp - 7)); - if (proj.empty()) return std::nullopt; - // Single projection only -- but only a TOP-LEVEL comma splits - // projections; commas inside parens/braces/quotes are argument - // separators (TIMESTAMPDIFF( SQL_TSI_MONTH, {d '…'}, {d '…'} )). - { - int depth = 0; - bool in_str = false; - for (char ch : proj) { - if (in_str) { if (ch == '\'') in_str = false; continue; } - if (ch == '\'') in_str = true; - else if (ch == '(' || ch == '{') ++depth; - else if (ch == ')' || ch == '}') --depth; - else if (ch == ',' && depth == 0) return std::nullopt; - } - } - auto prog = openads::script::compile("RETURN " + proj + ";"); - if (!prog) return std::nullopt; - openads::script::Executor ex(this); // subqueries recurse here - auto r = ex.run(*prog.value()); - if (!r || !r.value().returned) return std::nullopt; - return std::move(r.value().return_value); - } - - const openads::engine::DataDict::FunctionEntry* - find_udf(const std::string& name) const { - if (!c->has_dd()) return nullptr; - auto* dd = c->dd(); - if (!dd) return nullptr; - std::string up = name; - for (auto& ch : up) - ch = static_cast(std::toupper((unsigned char)ch)); - for (const auto& kv : dd->functions()) { - std::string k = kv.first; - for (auto& ch : k) - ch = static_cast(std::toupper((unsigned char)ch)); - if (k == up) return &kv.second; - } - return nullptr; - } - - bool has_udf(const std::string& name) override { - return find_udf(name) != nullptr; - } - - openads::util::Result - call_udf(const std::string& name, - const std::vector& args) override { - return run_dd_function(c, hStmt, name, args); - } -}; - -// Execute a DD stored function through the script engine. Shared by the -// SELECT evaluator (K::Udf) and UDF-to-UDF recursion via the bridge. -// The recursion guard is here so both entries are covered; SAP's exact -// depth limit is open question 9.8 in the design doc - 32 is safely -// below any real script. -openads::util::Result run_dd_function( - Connection* c, ADSHANDLE hStmt, const std::string& name, - const std::vector& args) { - static thread_local int depth = 0; - if (depth >= 32) - return openads::util::Error{openads::script::kScriptError, 0, - "UDF recursion too deep at " + name, ""}; - - AbiBridge bridge(c, hStmt); - const auto* fe = bridge.find_udf(name); - if (fe == nullptr) - return openads::util::Error{openads::script::kScriptError, 0, - "unknown function '" + name + "'", ""}; - - auto prog = compiled(fe->implementation); - if (!prog) return prog.error(); - - openads::script::Executor ex(&bridge); - ex.set_user(c->username()); - auto params = openads::script::parse_params(fe->input_params); - if (params) { - const auto& ps = params.value(); - for (std::size_t i = 0; i < ps.size(); ++i) { - Value v = i < args.size() ? args[i] : Value::null(); - // Coerce the caller's value into the declared parameter type - // (display-text -> typed when the caller passed text). - if (!v.is_null && ps[i].type != Type::Null && - v.type != ps[i].type) { - if (v.type == Type::Char && ps[i].type != Type::Char) - v = value_from_text(v.s, ps[i].type); - else { - auto cv = openads::script::coerce_assign( - ps[i].type, v, ps[i].char_limit); - if (cv) v = std::move(cv).value(); - } - } - ex.set_param(ps[i].name, std::move(v)); - } - } - - ++depth; - auto r = ex.run(*prog.value()); - --depth; - if (!r) return r.error(); - if (!r.value().returned) return Value::null(); - return std::move(r.value().return_value); -} - -// ---- S2: DD SQL-script stored procedures (RCB 07/17/2026) ---------------- -// -// Case-insensitive whole-word table-name rewrite outside 'strings' and -// [brackets]. Used to map the proc virtual tables onto real ones: -// __input → system.iota (every __input column reference is a parameter -// name, and parameters are substituted to literals by the -// executor first, so "(SELECT Month FROM __input)" becomes -// "(SELECT 2 FROM system.iota)") -// __output → a per-call temp FREE table created from the declared output -// parameters; returned as the statement cursor, SAP-style. -inline std::string rewrite_word(const std::string& sql, - const std::string& from, - const std::string& to) { - std::string out; - out.reserve(sql.size() + 16); - std::size_t i = 0, n = sql.size(); - auto eq_ci = [](char a, char b) { - return std::toupper((unsigned char)a) == std::toupper((unsigned char)b); - }; - while (i < n) { - char c = sql[i]; - if (c == '\'') { - std::size_t j = i + 1; - while (j < n) { - if (sql[j] == '\'' && j + 1 < n && sql[j + 1] == '\'') j += 2; - else if (sql[j] == '\'') { ++j; break; } - else ++j; - } - out.append(sql, i, j - i); - i = j; - continue; - } - if (c == '[') { - std::size_t j = sql.find(']', i); - j = (j == std::string::npos) ? n : j + 1; - out.append(sql, i, j - i); - i = j; - continue; - } - if ((std::isalpha((unsigned char)c) || c == '_')) { - std::size_t j = i + 1; - while (j < n && (std::isalnum((unsigned char)sql[j]) || - sql[j] == '_')) - ++j; - bool match = (j - i == from.size()); - if (match) - for (std::size_t k = 0; k < from.size(); ++k) - if (!eq_ci(sql[i + k], from[k])) { match = false; break; } - if (match) out += to; - else out.append(sql, i, j - i); - i = j; - continue; - } - out.push_back(c); - ++i; - } - return out; -} - -// Bridge for procedure bodies: rewrites the virtual-table names on every -// embedded statement, then delegates to the ordinary bridge. -struct ProcBridge final : openads::script::SqlBridge { - AbiBridge inner; - std::string out_table; // empty = no output params - ProcBridge(Connection* conn, ADSHANDLE hs) : inner(conn, hs) {} - - openads::util::Result> - exec(const std::string& sql) override { - std::string s = rewrite_word(sql, "__input", "system.iota"); - if (!out_table.empty()) - s = rewrite_word(s, "__output", "[" + out_table + "]"); - return inner.exec(s); - } - bool has_udf(const std::string& name) override { - return inner.has_udf(name); - } - openads::util::Result - call_udf(const std::string& name, - const std::vector& args) override { - return inner.call_udf(name, args); - } -}; - -// SQL column type for a declared output parameter (temp __output table). -// -// The __output temp is an ADT (see run_dd_procedure below), so DBF's limits do -// not apply here. The old 254 cap was DBF's character-field maximum and it -// silently shortened any wider declared output -- SAP procs routinely declare -// CICHAR(255) and wider. ADT carries the length in a uint16 field descriptor; -// an over-long *record* is rejected by AdsCreateTable ("ADT record too long") -// rather than silently truncated, which is the behaviour we want. -inline std::string sql_type_of(const openads::script::Param& p) { - switch (p.type) { - case Type::Char: { - std::size_t n = p.char_limit ? p.char_limit : 254; - if (n > 0xFFFFu) n = 0xFFFFu; - return "CHAR(" + std::to_string(n) + ")"; - } - case Type::Integer: return "INTEGER"; - case Type::Double: return "DOUBLE"; - case Type::Logical: return "LOGICAL"; - case Type::Date: return "DATE"; - case Type::Timestamp: return "TIMESTAMP"; - case Type::Null: break; - } - return "CHAR(254)"; -} - -// Execute a DD SQL-script stored procedure. Returns the name of the temp -// __output table ("" when the proc declares no outputs); the caller opens -// it as the statement cursor. -inline openads::util::Result run_dd_procedure( - Connection* c, ADSHANDLE hStmt, - const openads::engine::DataDict::ProcEntry& pe, - const std::vector& args) { - auto prog = compiled(pe.procedure); - if (!prog) return prog.error(); - - ProcBridge bridge(c, hStmt); - - // Output params → temp FREE table the body INSERTs into. - auto outs = openads::script::parse_params(pe.output_params); - if (outs && !outs.value().empty()) { - char nb[48]; - std::snprintf(nb, sizeof(nb), "_spout_%llx", - static_cast( - openads::platform::monotonic_nanos())); - std::string ddl = "CREATE TABLE [" + std::string(nb) + "] ("; - bool first = true; - for (const auto& p : outs.value()) { - if (!first) ddl += ", "; - first = false; - ddl += "[" + p.name + "] " + sql_type_of(p); - } - ddl += ") AS FREE TABLE"; - - // >>> The __output temp MUST be ADT. Read - // >>> docs/materialised-cursor-temps.md before changing this. - // - // A DBF field descriptor allots the column name 11 bytes, so a DBF - // temp truncates every declared output column to 10 characters: - // sp_GetPhysicalPath's `databasepath` came back as `databasepa`. That - // is not cosmetic -- the procedure may be reading ADT tables or - // declaring outputs that mirror SAP catalog columns, whose names run - // well past 10 (Trig_Function_Name is 18), and a caller that then - // references the column by name simply cannot find it. Capping every - // stored-procedure result column at 10 chars limits the SQL engine for - // no reason: ADT carries full-length names and costs nothing here. - // - // Plain CREATE TABLE takes its format from the statement's table type - // and defaults to CDX (i.e. DBF), so pin ADT across this one DDL and - // restore the caller's setting afterwards -- the procedure body may run - // its own CREATE TABLE and must not inherit our choice. - // - // This mirrors the fix already made to the SELECT static-cursor path - // (the `_srt_` temp in exec_sql_direct_impl). Both had the same root - // cause; fixing only one leaves the other silently truncating. - UNSIGNED16 saved_tt = 0; - SqlStatement* st_out = stmt_lookup(hStmt); - if (st_out != nullptr) { - saved_tt = st_out->table_type; - st_out->table_type = ADS_ADT; - } - auto cr = bridge.inner.exec(ddl); - if (st_out != nullptr) st_out->table_type = saved_tt; - if (!cr) return cr.error(); - bridge.out_table = nb; - } - - // Input params become scope variables; bodies read them either directly - // or via "(SELECT FROM __input)", which the substitution + - // __input→system.iota rewrite turns into a literal select. - openads::script::Executor ex(&bridge); - ex.set_user(c->username()); - auto ins = openads::script::parse_params(pe.input_params); - if (ins) { - const auto& ps = ins.value(); - for (std::size_t i = 0; i < ps.size(); ++i) { - Value v = Value::null(); - if (i < args.size()) { - const auto& a = args[i]; - // SAP type-checks argument binding at prepare: a {d …} - // temporal literal bound to an Integer-family parameter is - // AQE 2124 (oracle 2026-07-24, sp_ChargeMonthlyRent -- - // small ints print as in the message). - if (a.is_temporal && ps[i].type == Type::Integer) { - return openads::util::Error{2124, 0, - "Invalid operand for operator: " - "Target Data Type: " - "Source Data Type: " - "Target Name: " + ps[i].name, ""}; - } - if (a.is_numeric) { - if (a.number == std::floor(a.number) && - std::abs(a.number) < 1e15) - v = Value::integer( - static_cast(a.number)); - else - v = Value::real(a.number); - } else { - v = Value::character(a.text); - } - if (!v.is_null && ps[i].type != Type::Null && - v.type != ps[i].type) { - if (v.type == Type::Char && ps[i].type != Type::Char) - v = value_from_text(v.s, ps[i].type); - else { - auto cv = openads::script::coerce_assign( - ps[i].type, v, ps[i].char_limit); - if (cv) v = std::move(cv).value(); - } - } - } - ex.set_param(ps[i].name, std::move(v)); - } - } - - auto r = ex.run(*prog.value()); - if (!r) return r.error(); - return bridge.out_table; -} - -// ---- S2: trigger bodies (RCB 07/17/2026) --------------------------------- -// Oracle-verified semantics (probe Q6, sweep copy): a failing trigger makes -// the DML statement return 7200; BEFORE failure blocks the write, AFTER -// failure keeps it. The old fragment skipped IF/WHILE/EXECUTE and swallowed -// errors entirely. - -// Typed value for a collected trigger row-image field (S3): the DBF/ADT -// field type char decides the script type, so `n.recurring > 0` sees a -// number and `n.[When]` a timestamp. Raw driver formats: Date "YYYYMMDD", -// DateTime "YYYYMMDDHHMMSS" (dbf_common decode_field). -inline Value trig_value(const TrigField_& f) { - const std::string& s = f.text; - switch (f.type) { // canonical chars from trig_type_char_ - case 'N': - if (s.empty()) return Value::typed_null(Type::Double); - return Value::real(std::strtod(s.c_str(), nullptr)); - case 'I': - if (s.empty()) return Value::typed_null(Type::Integer); - return Value::integer(std::strtoll(s.c_str(), nullptr, 10)); - case 'L': - if (s.empty()) return Value::typed_null(Type::Logical); - return Value::logical(s[0] == 'T' || s[0] == 't' || - s[0] == 'Y' || s[0] == '1'); - case 'D': { - if (s.size() < 8) return Value::typed_null(Type::Date); - int y = std::atoi(s.substr(0, 4).c_str()); - int m = std::atoi(s.substr(4, 2).c_str()); - int d = std::atoi(s.substr(6, 2).c_str()); - if (y == 0) return Value::typed_null(Type::Date); - return Value::date(openads::script::jdn_from_ymd(y, m, d)); - } - case 'T': { - if (s.size() < 8) return Value::typed_null(Type::Timestamp); - int y = std::atoi(s.substr(0, 4).c_str()); - int m = std::atoi(s.substr(4, 2).c_str()); - int d = std::atoi(s.substr(6, 2).c_str()); - if (y == 0) return Value::typed_null(Type::Timestamp); - std::int64_t ms = 0; - if (s.size() >= 14) { - int hh = std::atoi(s.substr(8, 2).c_str()); - int mi = std::atoi(s.substr(10, 2).c_str()); - int ss = std::atoi(s.substr(12, 2).c_str()); - ms = (static_cast(hh) * 3600 + mi * 60 + ss) * - 1000; - } - return Value::timestamp( - openads::script::jdn_from_ymd(y, m, d) * 86400000 + ms); - } - default: - return Value::character(s); - } -} - -// SQL literal for a row-image field -- typed rendering ({d '…'} for dates, -// bare numbers, quoted strings) via the script engine's own renderer. -inline std::string trig_literal(const TrigField_& f) { - return openads::script::to_sql_literal(trig_value(f)); -} - -// One-row cursor over a full trigger row image -- backs -// `DECLARE n CURSOR AS SELECT * FROM __new` (pmsys Trig_Container). -// std::map ordering keeps the column order deterministic. -struct TrigRowCursor final : openads::script::SqlCursor { - const std::map* m; - std::vector*> rows; - bool done = false; - explicit TrigRowCursor(const std::map* mm) - : m(mm) { - if (m != nullptr) - for (const auto& kv : *m) rows.push_back(&kv); - } - bool next() override { - if (done) return false; - done = true; - return m != nullptr; - } - std::size_t field_count() const override { return rows.size(); } - std::string field_name(std::size_t idx) const override { - return idx < rows.size() ? rows[idx]->first : ""; - } - Value field(std::size_t idx) override { - return idx < rows.size() ? trig_value(rows[idx]->second) - : Value::null(); - } -}; - -struct TriggerBridge final : openads::script::SqlBridge { - AbiBridge inner; - const std::map* new_f; - const std::map* old_f; - bool instead_of = false; - - TriggerBridge(Connection* conn, ADSHANDLE hs, - const std::map* nf, - const std::map* of, - bool io) - : inner(conn, hs), new_f(nf), old_f(of), instead_of(io) {} - - const TrigField_* row_field(const std::map* m, - std::string name) const { - if (m == nullptr) return nullptr; - for (auto& ch : name) - ch = static_cast(std::tolower((unsigned char)ch)); - auto it = m->find(name); - return it == m->end() ? nullptr : &it->second; - } - - openads::util::Result> - exec(const std::string& sql) override { - static const bool trig_trace = - openads::util::client_setting_truthy("OPENADS_TRACE", "trace"); - if (trig_trace) - std::fprintf(stderr, "[trig-exec] %.120s\n", sql.c_str()); - // 1. INSERT INTO __error … VALUES (code, 'msg') -- the classic ADS - // way for a trigger to fail the DML. Surface it as an error. - { - std::string up = sql; - for (auto& ch : up) - ch = static_cast(std::toupper((unsigned char)ch)); - if (up.find("__ERROR") != std::string::npos && - up.compare(0, 6, "INSERT") == 0) { - std::int32_t code = openads::script::kScriptError; - std::string msg = "trigger error"; - auto vp = up.find("VALUES"); - if (vp != std::string::npos) { - auto p = sql.find('(', vp); - auto q = sql.rfind(')'); - if (p != std::string::npos && q != std::string::npos && - q > p) { - std::string innr = sql.substr(p + 1, q - p - 1); - // number, 'msg' | 'msg' - std::size_t k = 0; - while (k < innr.size() && innr[k] == ' ') ++k; - if (k < innr.size() && innr[k] != '\'') { - code = std::atoi(innr.c_str() + k); - auto comma = innr.find(',', k); - k = comma == std::string::npos ? innr.size() - : comma + 1; - } - auto q1 = innr.find('\'', k); - if (q1 != std::string::npos) { - std::string m2; - for (std::size_t z = q1 + 1; z < innr.size(); ++z) { - if (innr[z] == '\'' && z + 1 < innr.size() && - innr[z + 1] == '\'') { m2 += '\''; ++z; } - else if (innr[z] == '\'') break; - else m2 += innr[z]; - } - msg = m2; - } - } - } - return openads::util::Error{code, 0, msg, "__error"}; - } - } - - // 2. Replace __new.field / __old.field inline references. - std::string s; - s.reserve(sql.size()); - for (std::size_t i = 0; i < sql.size();) { - char c = sql[i]; - if (c == '\'') { - std::size_t j = i + 1; - while (j < sql.size()) { - if (sql[j] == '\'' && j + 1 < sql.size() && - sql[j + 1] == '\'') j += 2; - else if (sql[j] == '\'') { ++j; break; } - else ++j; - } - s.append(sql, i, j - i); - i = j; - continue; - } - if (c == '_' && i + 6 < sql.size() && sql[i + 1] == '_') { - std::string w = sql.substr(i, 5); - for (auto& ch : w) - ch = static_cast(std::tolower((unsigned char)ch)); - bool isn = (w == "__new"), iso = (w == "__old"); - if ((isn || iso) && sql[i + 5] == '.') { - std::size_t fs = i + 6, fe = fs; - while (fe < sql.size() && - (std::isalnum((unsigned char)sql[fe]) || - sql[fe] == '_')) - ++fe; - const TrigField_* v = row_field(isn ? new_f : old_f, - sql.substr(fs, fe - fs)); - s += v ? trig_literal(*v) : std::string("NULL"); - i = fe; - continue; - } - } - s.push_back(c); - ++i; - } - - // 3. Fast path: SELECT FROM __new|__old -- one-value cursor. - { - std::string t = s; - std::size_t b = t.find_first_not_of(" \t\r\n"); - if (b != std::string::npos) t = t.substr(b); - std::string up = t; - for (auto& ch : up) - ch = static_cast(std::toupper((unsigned char)ch)); - if (up.compare(0, 7, "SELECT ") == 0) { - auto fp = up.find(" FROM "); - if (fp != std::string::npos) { - std::string src = t.substr(fp + 6); - auto e2 = src.find_first_of(" \t\r\n;"); - if (e2 != std::string::npos) src = src.substr(0, e2); - for (auto& ch : src) - ch = static_cast( - std::tolower((unsigned char)ch)); - if (src == "__new" || src == "__old" || - src == "__input") { - std::string col = t.substr(7, fp - 7); - auto ce = col.find_last_not_of(" \t\r\n"); - col = (ce == std::string::npos) - ? "" : col.substr(0, ce + 1); - auto cb = col.find_first_not_of(" \t\r\n"); - if (cb != std::string::npos) col = col.substr(cb); - if (!col.empty() && col.front() == '[' && - col.back() == ']') - col = col.substr(1, col.size() - 2); - const auto* m = (src == "__old") ? old_f : new_f; - // SELECT * FROM __new|__old -- full row image as a - // cursor (pmsys Trig_Container: DECLARE n CURSOR AS - // SELECT * FROM __new; …; n.field). - if (col == "*") - return std::unique_ptr< - openads::script::SqlCursor>( - new TrigRowCursor(m)); - const TrigField_* v = row_field(m, col); - return std::unique_ptr( - new OneValueCursor( - v ? trig_value(*v) : Value::null())); - } - } - } - } - - // 4. Statements still referencing the bare __new/__old tables: - // a normal trigger re-inserting its source row would double-write - // (old-fragment parity: skip); an INSTEAD OF trigger's write is - // the real one -- run it with the row image as literals. - { - std::string low = s; - for (auto& ch : low) - ch = static_cast(std::tolower((unsigned char)ch)); - bool refs = low.find("__new") != std::string::npos || - low.find("__old") != std::string::npos; - if (refs && !instead_of) - return std::unique_ptr{}; - // S4 -- the INSTEAD OF idiom `INSERT INTO SELECT * FROM - // __new`: expand to an explicit column INSERT built from the - // row image (empty-text fields are omitted so DD defaults and - // the NOT NULL checks apply to what the client actually sent). - if (refs && instead_of && new_f != nullptr && !new_f->empty()) { - std::size_t p = 0; - auto ws2 = [&](std::size_t& q) { - while (q < low.size() && std::isspace( - static_cast(low[q]))) ++q; - }; - auto word2 = [&](std::size_t& q) -> std::string { - ws2(q); - std::size_t b2 = q; - while (q < low.size() && - (std::isalnum(static_cast( - low[q])) || - low[q] == '_' || low[q] == '.')) - ++q; - return low.substr(b2, q - b2); - }; - ws2(p); - if (word2(p) == "insert" && word2(p) == "into") { - std::size_t name_pos = p; - ws2(name_pos); - std::string tgt = word2(p); - std::string tgt_orig = s.substr(name_pos, tgt.size()); - std::size_t q = p; - bool star_shape = word2(q) == "select"; - if (star_shape) { - ws2(q); - star_shape = q < low.size() && low[q] == '*'; - if (star_shape) ++q; - } - if (star_shape && word2(q) == "from" && - word2(q) == "__new") { - std::string cols2, vals2; - for (const auto& kv : *new_f) { - if (kv.second.text.empty()) continue; - if (!cols2.empty()) { - cols2 += ", "; - vals2 += ", "; - } - cols2 += kv.first; - vals2 += trig_literal(kv.second); - } - if (!cols2.empty()) { - return inner.exec( - "INSERT INTO " + tgt_orig + " (" + cols2 + - ") VALUES (" + vals2 + ")"); - } - } - } - } - if (refs) { - // v1: substitute bare field identifiers (typed literals), - // then map the virtual table to system.iota (single-row - // source). - if (new_f != nullptr) - for (const auto& kv : *new_f) - s = rewrite_word(s, kv.first, - trig_literal(kv.second)); - s = rewrite_word(s, "__new", "system.iota"); - s = rewrite_word(s, "__old", "system.iota"); - } - } - return inner.exec(s); - } - bool has_udf(const std::string& name) override { - return inner.has_udf(name); - } - openads::util::Result - call_udf(const std::string& name, - const std::vector& args) override { - return inner.call_udf(name, args); - } -}; - -// ---- S3: top-level scripts through AdsExecuteSQLDirect ------------------- -// SAP mechanism (doc §10): AdsExecuteSQLDirect accepts full multi-statement -// scripts; the returned cursor is the last SELECT's result; a script with no -// SELECT returns rc=0 and no cursor. OpenADS previously executed only single -// statements -- ad-hoc scripts, the qa-diff probe battery, and pmsys -// sp_GetPhysicalPath's EXECUTE IMMEDIATE (a full script string) all need -// this entry. - -// Is this input a script (vs a single SQL statement the dispatcher owns)? -// Lexed with the script lexer so ';' inside strings/brackets never counts. -inline bool is_script_input(const std::string& sql) { - auto toks = openads::script::lex(sql); - if (!toks) return false; // not even lexable → SQL engine - const auto& t = toks.value(); - if (t.empty()) return false; - using Tok = openads::script::Tok; - if (t[0].kind == Tok::Ident) { - const std::string& k = t[0].upper; - // Script-only leading constructs. - if (k == "DECLARE" || k == "TRY" || k == "WHILE" || k == "IF" || - k == "RAISE" || k == "RETURN") - return true; - if (k == "EXECUTE" && t.size() > 1 && t[1].kind == Tok::Ident && - t[1].upper == "IMMEDIATE") - return true; - } - // Multi-statement: a ';' followed by another real token. - for (std::size_t i = 0; i + 1 < t.size(); ++i) - if (t[i].kind == Tok::Semi && t[i + 1].kind != Tok::End) - return true; - return false; -} - -// Execute a top-level script. The last SELECT's cursor either carries a real -// ADS handle (AbiSqlCursor -- handed straight to the caller) or is an -// engine-internal cursor (literal fast path, trigger row image) that gets -// materialized into a temp FREE DBF, mirroring the EXECUTE PROCEDURE result -// machinery. -inline openads::util::Result run_top_level_script( - Connection* c, ADSHANDLE hStmt, const std::string& sql, - ADSHANDLE* phCursor) { - auto prog = compiled(sql); - if (!prog) return prog.error(); - AbiBridge bridge(c, hStmt); - openads::script::Executor ex(&bridge); - ex.set_user(c->username()); - auto r = ex.run(*prog.value()); - if (!r) { - // Uncaught RAISE surfaces in SAP's shape (§11 F3c/F5): rc 7200, - // "{[name] code : msg}". - if (r.error().sub_code == openads::script::kRaiseSubCode) { - return openads::util::Error{openads::script::kScriptError, 2224, - "An exception is raised in the SQL script. {[" + - r.error().context + "] " + - std::to_string(r.error().code) + " : " + - r.error().message + "}", - r.error().context}; - } - return r.error(); - } - *phCursor = 0; - auto cur = std::move(r.value().last_select); - if (!cur) return {}; - - // Real SQL cursor: pass its handle through. - if (auto* abi = dynamic_cast(cur.get())) { - *phCursor = abi->release_handle(); - return {}; - } - - // Engine-internal cursor: materialize rows into a temp ADT (full-length - // column names -- the DBF this used to write truncated them to 10). - std::size_t nc = cur->field_count(); - if (nc == 0) return {}; - if (nc > 64) nc = 64; // sanity cap - std::vector names; - for (std::size_t k = 0; k < nc; ++k) { - std::string nm = cur->field_name(k); - if (nm.empty()) nm = nc == 1 ? "EXPR" : "COL" + std::to_string(k + 1); - names.push_back(std::move(nm)); - } - std::vector> rows; - while (cur->next()) { - std::vector row; - for (std::size_t k = 0; k < nc; ++k) - row.push_back(openads::script::to_display(cur->field(k))); - rows.push_back(std::move(row)); - if (rows.size() >= 65000) break; // DBF row sanity cap - } - - constexpr std::uint16_t kW = 254; // every column CHAR(254) - std::vector scols; - scols.reserve(nc); - for (auto& nm : names) scols.push_back({nm, 'C', kW, 0}); - std::vector rowbuf; - rowbuf.reserve(rows.size() * nc * kW); - for (const auto& row : rows) { - for (std::size_t k = 0; k < nc; ++k) { - const std::string& v = row[k]; - for (std::size_t i = 0; i < kW; ++i) - rowbuf.push_back(i < v.size() - ? static_cast(v[i]) : ' '); - } - } - ADSHANDLE gh_scr = 0; - UNSIGNED32 mrc = materialise_temp_adt(c, "_scr_", scols, rowbuf, - static_cast(nc) * kW, - &gh_scr); - if (mrc != openads::AE_SUCCESS) { - return openads::util::Error{static_cast(mrc), 0, - "script result temp materialise failed", ""}; - } - *phCursor = gh_scr; - return {}; -} - -// A subquery projects exactly one scalar aggregate and nothing else. -// Since S4, aggregate projections carry an `$AGG_` placeholder in -// `projection` (to preserve SELECT-list order alongside group-key -// columns), so "pure aggregate" is projection == that single placeholder -// (empty is still accepted for older callers). -inline bool sq_is_scalar_agg(const openads::sql::SelectStmt& sq) { - if (sq.aggregates.size() != 1) return false; - if (sq.projection.empty()) return true; - return sq.projection.size() == 1 && - sq.projection[0].rfind("$AGG_", 0) == 0; -} - -// S4 -- shared by the join materializers (2-table + N-way). The merged -// temp cursor is a text-convention DBF, but the SOURCE tables may be -// ADT (pmsys) whose records carry a 5-byte prefix and binary field -// encodings -- so joins must go through DECODED field values -// (Table::read_field), never raw record bytes, and each source field -// maps to a DBF text descriptor. -inline openads::drivers::DbfField join_out_field( - const openads::drivers::DbfField& f) { - using T = openads::drivers::DbfFieldType; - openads::drivers::DbfField o = f; - switch (f.type) { - case T::Character: case T::CiCharacter: case T::Varchar: - o.raw_type = 'C'; - o.length = static_cast( - std::min(f.length, 254)); - o.decimals = 0; - break; - case T::Memo: case T::Binary: case T::Varbinary: - // Memo text truncated into an inline cell (no .dbt on the - // temp cursor); binary yields blanks. - o.raw_type = 'C'; o.length = 254; o.decimals = 0; - break; - case T::Numeric: case T::Float: - o.raw_type = 'N'; - o.length = static_cast( - std::min(f.length ? f.length : 20, 20)); - break; - case T::Integer: case T::ShortInt: case T::AutoInc: - o.raw_type = 'N'; o.length = 11; o.decimals = 0; - break; - case T::Double: case T::Currency: case T::AdtMoney: - o.raw_type = 'N'; o.length = 20; - o.decimals = f.decimals ? f.decimals - : (f.type == T::Double ? 0 : 4); - break; - case T::RowVersion: case T::ModTime: - o.raw_type = 'N'; o.length = 20; o.decimals = 0; - break; - case T::Date: case T::AdtDate: - o.raw_type = 'D'; o.length = 8; o.decimals = 0; - break; - case T::DateTime: case T::AdtTimestamp: - o.raw_type = 'C'; o.length = 22; o.decimals = 0; - break; - case T::Time: - o.raw_type = 'C'; o.length = 11; o.decimals = 0; - break; - case T::Logical: - o.raw_type = 'L'; o.length = 1; o.decimals = 0; - break; - default: - o.raw_type = 'C'; - o.length = static_cast( - std::min(f.length ? f.length : 10, 254)); - o.decimals = 0; - break; - } - return o; -} - -// SAP TRUNCATES AVG toward zero at the result's decimals rather than -// rounding (oracle 2026-07-21: AVG(money) 1505.58485… -> 1505.5848 at -// 4dp; AVG(integer) 2190965.7 -> 2190965 at 0dp). -inline double avg_truncate(double v, int dp) { - double scale = std::pow(10.0, dp); - return std::trunc(v * scale) / scale; -} - -// Format one decoded cell (from the POSITIONED row of `t`) for the -// mapped output descriptor. NULL / read failure yields an empty string -// (blank cell); the caller pads to out.length. -// SAP's declared width for an aggregate result column, oracle-probed against -// the mp corpus 2026-07-30 (see TODO.parity.md). Widths were measured through -// AdsGetString, which preserves the padding -- the PHP binding trims, so it -// cannot be used to check this: -// -// SUM(payfile.CHARGES) N(11,2) -> 21 source + 10 -// SUM(service.ins_charge) N(11,2) -> 21 source + 10 -// SUM(prclines.PRICE) N(10,2) -> 20 source + 10 -// SUM(prclines.UNITS) N(6,0) -> 16 source + 10 -// AVG/MIN/MAX(CHARGES) N(11,2) -> 11 source width -// COUNT(*) -> "852033", unpadded -// -// SUM widens by 10 digits because a sum over many rows can carry far past the -// source column's range; AVG/MIN/MAX cannot exceed it, so they keep it. -// COUNT is an integer and is not padded at all. -// -// All four aggregate materialisers below used a hardcoded width of 20, which -// matched none of these. -// Is a MIN/MAX over this source column decided by TEXT rather than by -// magnitude? The aggregate accumulators are doubles, so a date or a name -// contributes as_double == 0 for every row and MIN/MAX answers "0". Dates -// decode to "YYYYMMDD", which orders lexicographically exactly as it does -// chronologically, so a plain string compare is correct for them. -// Shared by all five aggregate materialisers so they cannot disagree. -inline bool agg_source_is_text(openads::drivers::DbfFieldType t) { - using FT = openads::drivers::DbfFieldType; - switch (t) { - case FT::Numeric: case FT::Float: case FT::Integer: - case FT::Double: case FT::Currency: case FT::ShortInt: - case FT::AutoInc: case FT::AdtMoney: case FT::Logical: - case FT::RowVersion: case FT::ModTime: - return false; - default: - return true; // Date, Character, Time, Timestamp, Memo… - } -} - -// A date-sourced MIN/MAX result must be declared DATE in the temp, not -// CHARACTER: SAP renders it through the connection date format -// ("01/18/0203"), which AdsGetField only applies to Date-typed columns. -// The accumulated cell text stays the raw "YYYYMMDD" -- the temp -// materialiser converts a 'D' column's text cell to the binary JDN. -inline bool agg_source_is_date(openads::drivers::DbfFieldType t) { - using FT = openads::drivers::DbfFieldType; - return t == FT::Date || t == FT::AdtDate; -} - -// Value of an aggregate's argument for the current row. Normally the plain -// column, but SUM(a * b) evaluates `a b` instead -- same shape and same -// arithmetic as a projection-level $ARITH_ item, so only the accumulation -// differs. `rhs_fi` is ignored when the RHS is a literal. -// `arg_dec` = the argument's declared scale (agg_arg_shape). Division is -// the one operation whose exact result can exceed its declared scale, and -// SAP TRUNCATES each row's quotient at that scale toward zero BEFORE -// accumulating - oracle-probed: SUM(A/U) over rows dividing to 0.4166666... -// and 0.6666666... answers 1.0833332 (per-row truncation), not 1.0833333 -// (truncation of the exact sum). Add/Sub/Mul of fixed-scale operands are -// exact within their declared scale, so they pass through untouched. -inline double agg_arg_value(openads::engine::Table& t, - std::int32_t lhs_fi, std::int32_t rhs_fi, - const openads::sql::Aggregate& def, - int arg_dec) { - if (lhs_fi < 0) return 0.0; - auto lv = t.read_field(static_cast(lhs_fi)); - double a = lv ? lv.value().as_double : 0.0; - if (!def.arg_expr) return a; - double b = 0.0; - if (def.arg_expr->rhs_is_literal) { - b = def.arg_expr->rhs_number; - } else if (rhs_fi >= 0) { - auto rv = t.read_field(static_cast(rhs_fi)); - b = rv ? rv.value().as_double : 0.0; - } - using AO = openads::sql::ArithOp; - switch (def.arg_expr->op) { - case AO::Add: return a + b; - case AO::Sub: return a - b; - case AO::Mul: return a * b; - case AO::Div: return (b != 0.0) - ? avg_truncate(a / b, arg_dec) : 0.0; - } - return a; -} - -// Running MIN/MAX over decoded text, plus the widest value seen (the result -// column is sized from this: an ADT date is 4 bytes on disk but 8 characters -// decoded, so the source descriptor's width would truncate it). -struct TextMinMax { - std::string mn, mx; - std::size_t w = 0; - bool any = false; - void feed(const std::string& v) { - // A blank value is absent, not a minimum. A zero-JDN ADT date is NULL - // and decodes to "", but a join/group temp stores it as a blank DBF - // cell with is_null unset -- so without this an empty string wins every - // MIN. SAP agrees: MIN over a column with blank dates returns the - // earliest REAL date, never "". - if (v.find_first_not_of(' ') == std::string::npos) return; - if (v.size() > w) w = v.size(); - if (!any) { mn = mx = v; any = true; return; } - if (v < mn) mn = v; - if (v > mx) mx = v; - } -}; - -inline std::uint16_t agg_result_width(openads::sql::AggregateKind k, - std::uint16_t src_len) { - using K = openads::sql::AggregateKind; - switch (k) { - case K::CountStar: - case K::Count: - return 11; // integer count; never padded wider - case K::Sum: - return static_cast( - std::min(src_len + 10, 0xFF)); - default: // Avg / Min / Max - return src_len ? src_len : 20; - } -} - -// ── SAP's declared shape for an ARITHMETIC aggregate argument ────────────── -// -// Oracle-derived from 45 probes against ace64.dll (scratch table wprobe on -// the mp sandbox, 2026-08-05; the raw measurements are in TODO.parity.md -// history). SAP sizes `SUM(a b)` from an (integer-digits, scale) pair -// computed per operand and combined per operation -- every probe fits: -// -// operand N(L,s): ip = L - s - (s ? 1 : 0) -// operand INTEGER: ip = 11, s = 0 (int32 digits + sign) -// literal: ip = int_digits + 1, s = fractional digits AS -// WRITTEN ("2.50" is (2,2)) -- hence ArithExpr::rhs_text -// a * b: ip = ip1 + ip2 - 1 s = s1 + s2 -// a ± b: ip = max(ip1, ip2) + 1 s = max(s1, s2) -// a / b: ip = ip1 + s2 + min(s1, 2) s = s1 + ip2 - 1 -// -// declared length L = ip + s + (s ? 1 : 0) -// SUM width = L + 10 · AVG/MIN/MAX width = L · displayed scale = s -// (agg_result_width applies the SUM/AVG rule when handed this L.) -// -// The div ip term (min(s1,2)) is the empirical fit over five probes -- -// division inside aggregates is rare; re-probe before "simplifying" it. -struct AggArgShape { - std::uint16_t len = 0; // declared length of the argument expression - std::uint8_t dec = 0; // its scale -}; - -inline void agg_operand_ip_s(const openads::drivers::DbfField& f, - int* ip, int* s) { - using FT = openads::drivers::DbfFieldType; - if (f.type == FT::Integer || f.type == FT::AutoInc || - f.type == FT::ShortInt) { - *ip = 11; *s = 0; - return; - } - if (f.type == FT::Currency || f.type == FT::AdtMoney) { - // SAP types money arithmetic as MONEY (rendered unpadded) -- a known - // divergence; treat as N(len,4) so at least the scale matches. - *s = 4; *ip = std::max(1, f.length - 5); - return; - } - *s = f.decimals; - *ip = std::max(1, f.length - f.decimals - (f.decimals ? 1 : 0)); -} - -inline AggArgShape agg_arg_shape(const openads::drivers::DbfField& lhs, - const openads::sql::Aggregate& def, - const openads::drivers::DbfField* rhs) { - int ip1 = 0, s1 = 0; - agg_operand_ip_s(lhs, &ip1, &s1); - if (!def.arg_expr) { - AggArgShape out; - out.len = lhs.length; - out.dec = static_cast(s1); - return out; - } - int ip2 = 0, s2 = 0; - if (def.arg_expr->rhs_is_literal) { - const std::string& t = def.arg_expr->rhs_text; - auto dot = t.find('.'); - int intd = static_cast(dot == std::string::npos ? t.size() : dot); - if (!t.empty() && (t[0] == '+' || t[0] == '-')) --intd; - if (intd < 1) intd = 1; - ip2 = intd + 1; - s2 = dot == std::string::npos - ? 0 : static_cast(t.size() - dot - 1); - } else if (rhs != nullptr) { - agg_operand_ip_s(*rhs, &ip2, &s2); - } - using AO = openads::sql::ArithOp; - int ip = 0, s = 0; - switch (def.arg_expr->op) { - case AO::Mul: ip = ip1 + ip2 - 1; s = s1 + s2; break; - case AO::Add: - case AO::Sub: ip = std::max(ip1, ip2) + 1; s = std::max(s1, s2); - break; - case AO::Div: ip = ip1 + s2 + std::min(s1, 2); - s = s1 + ip2 - 1; break; - } - if (s < 0) s = 0; - if (s > 18) s = 18; - if (ip < 1) ip = 1; - AggArgShape out; - out.len = static_cast( - std::min(ip + s + (s ? 1 : 0), 200)); - out.dec = static_cast(s); - return out; -} - -// Formats one aggregate cell to SAP's presentation. COUNT is integral and -// SAP does not pad it ("852033"), so it is left-justified and the trailing -// blanks trim away on read; every other aggregate is right-justified to the -// declared width. Keeping both rules here means the five aggregate -// materialisers cannot drift apart again. -inline std::string agg_cell_text(openads::sql::AggregateKind k, - std::uint16_t w, int dec, double v, - const std::string* text = nullptr) { - using K = openads::sql::AggregateKind; - // MIN/MAX over a non-numeric column: the answer IS the text (a date, a - // name). Character cells left-justify, like any other character column. - if (text != nullptr) { - std::string out = *text; - if (out.size() > w) out.resize(w); - out.resize(w, ' '); - return out; - } - char b[80]; - if (k == K::Count || k == K::CountStar) - std::snprintf(b, sizeof(b), "%-*.0f", static_cast(w), v); - else - std::snprintf(b, sizeof(b), "%*.*f", static_cast(w), dec, v); - std::string out(b); - out.resize(w, ' '); - return out; -} - -inline std::string join_cell_text(openads::engine::Table& t, - std::uint16_t fi, - const openads::drivers::DbfField& out) { - auto v = t.read_field(fi); - if (!v || v.value().is_null) return std::string(); - std::string s; - switch (out.raw_type) { - case 'N': { - // RIGHT-justified, matching both the xBase numeric convention and - // SAP: reading a numeric column as a string returns it padded to - // the declared width, e.g. N(10,2) holding 0 reads back as - // " 0.00" -- not "0.00". A join must not reformat a value - // differently from a plain read of the same column. - // - // This was left-justified ("%-*.*f") on the theory that leading - // spaces "leak" into string reads of the temp cursor. They are not - // a leak -- they are the value's declared presentation, and SAP - // emits them. Left-justifying made every joined numeric disagree - // with the same column read directly. - char cell[64]; - std::snprintf(cell, sizeof(cell), "%*.*f", - static_cast(out.length), - static_cast(out.decimals), - v.value().as_double); - s = cell; - break; - } - case 'L': - s = v.value().as_bool ? "T" : "F"; - break; - default: // 'C' / 'D' -- decoded display text - s = v.value().as_string; - break; - } - if (s.size() > out.length) s.resize(out.length); - return s; -} - -// S4 -- SAP wraps EVERY SQL-statement failure as rc 7200 with an AQE -// envelope; the native code and a SQLSTATE ride inside the TEXT -// (oracle-probed 2026-07-23): -// Error 7200: AQE Error: State = S0000; NativeError = 2121; -// [iAnywhere Solutions][Advantage SQL Engine]Column not found: x ... -// File-level errors use the ASA branding instead: -// ... NativeError = 5004; [iAnywhere Solutions][Advantage SQL][ASA] -// Error 5004: Either ACE could not find the specified file, ... -// Spacing is significant (2 spaces after "7200:"/"AQE Error:"/";" before -// the brand, 3 after the State) -- replicated byte-for-byte. -inline std::string sql_error_envelope(std::int32_t code, - const std::string& msg, - const std::string& sql) { - std::string state = "HY000"; - std::int32_t native = code; - std::string text = msg; - bool asa = false; - std::string loc_token; // when set, append SAP's location suffix - switch (code) { - case 5004: // missing table -- msg pre-shaped at source - asa = true; - break; - case 5063: // AE_COLUMN_NOT_FOUND -> SQL engine 2121 - state = "S0000"; native = 2121; - text = "Column not found: " + msg; - loc_token = msg; - break; - case 2124: // arg-binding type check (pre-shaped msg) - state = "S0000"; - if (msg.find("Source Data Type: ") != std::string::npos) - loc_token = "{d"; // locate the offending {d …} literal - break; - case 2137: // ambiguous unqualified column (pre-shaped) - state = "S0000"; - { - auto p = msg.rfind(": "); - if (p != std::string::npos) loc_token = msg.substr(p + 2); - } - break; - case 2129: // INSERT column/value count mismatch - state = "S0000"; // (pre-shaped msg; SAP has no location) - break; - case 5000: - if (msg.rfind("procedure not registered", 0) == 0) { - native = 2198; - std::string nm; - auto colon = msg.find(':'); - if (colon != std::string::npos) - nm = msg.substr(colon + 1); - text = "The stored procedure name is invalid:" + nm; - if (nm.size() > 1) loc_token = nm.substr(1); - } - break; - case 7200: { - if (msg.rfind("unknown function", 0) == 0) { - state = "S0000"; native = 2158; - std::string nm = msg; - auto q1 = nm.find('\''); - auto q2 = nm.rfind('\''); - if (q1 != std::string::npos && q2 > q1) - nm = nm.substr(q1 + 1, q2 - q1 - 1); - text = "Scalar function not found: " + nm; - loc_token = nm; - } else { - state = "42000"; native = 2115; // generic parse error - } - break; - } - default: - break; // native code + our text, HY000 - } - std::string env = "Error 7200: AQE Error: State = " + state + - "; NativeError = " + std::to_string(native) + "; "; - env += asa ? "[iAnywhere Solutions][Advantage SQL][ASA] Error 5004: " - : "[iAnywhere Solutions][Advantage SQL Engine]"; - env += text; - // SAP's location suffix is the 1-based character offset of the - // offending token in the statement text. - if (!loc_token.empty() && !sql.empty()) { - auto is_ident = [](char ch) { - unsigned char u = static_cast(ch); - return std::isalnum(u) != 0 || ch == '_'; - }; - // SAP points at the offending token's own occurrence. For an - // ambiguous column that means the UNQUALIFIED reference -- skip - // occurrences that are part of a longer word or qualified by an - // `alias.` prefix. `require_word` off keeps the old raw-substring - // behaviour as a fallback when no clean word-boundary hit exists. - auto ifind_word = [&](bool require_word) -> std::size_t { - if (loc_token.size() > sql.size()) return std::string::npos; - for (std::size_t i = 0; i + loc_token.size() <= sql.size(); - ++i) { - bool eq = true; - for (std::size_t j = 0; j < loc_token.size(); ++j) { - if (std::toupper(static_cast( - sql[i + j])) != - std::toupper(static_cast( - loc_token[j]))) { eq = false; break; } - } - if (!eq) continue; - if (require_word) { - char before = i > 0 ? sql[i - 1] : '\0'; - char after = (i + loc_token.size() < sql.size()) - ? sql[i + loc_token.size()] : '\0'; - if (is_ident(before) || before == '.' || - is_ident(after)) - continue; - } - return i; - } - return std::string::npos; - }; - auto ifind = [&]() -> std::size_t { - std::size_t w = ifind_word(true); - return w != std::string::npos ? w : ifind_word(false); - }; - std::size_t at = ifind(); - if (at != std::string::npos) { - env += " -- Location of error in the SQL statement is: " + - std::to_string(at + 1); - } - } - return env; -} - -// SAP 2137: an UNQUALIFIED column that resolves in more than one joined -// source table is ambiguous ("Column found in multiple tables"). Returns -// the offending column name (original case, for the message + location) -// or an empty string when none. `tables` are the join's source tables. -// Qualified refs (alias set) and single-table columns are never flagged; -// SELECT * (no select_items) references no specific column. -inline std::string first_ambiguous_join_column( - const openads::sql::SelectStmt& st, - const std::vector& tables) { - auto count_in = [&](const std::string& col) -> int { - int n = 0; - for (auto* t : tables) - if (t != nullptr && t->field_index(col) >= 0) ++n; - return n; - }; - for (const auto& si : st.select_items) { - if (si.wildcard || !si.alias.empty() || si.column.empty()) continue; - if (count_in(si.column) > 1) return si.column; - } - auto check_ob = [&](const openads::sql::OrderBy& ob) -> std::string { - if (ob.column_alias.empty() && !ob.column.empty() && - count_in(ob.column) > 1) - return ob.column; - return std::string(); - }; - if (st.order_by) { - auto a = check_ob(*st.order_by); - if (!a.empty()) return a; - } - for (const auto& ob : st.order_by_extra) { - auto a = check_ob(ob); - if (!a.empty()) return a; - } - // Residual WHERE (join keys are already lowered out / live in ON, so - // only genuine filter columns remain). Subqueries are not descended. - std::function scan = - [&](const openads::sql::WhereExpr* n) -> std::string { - if (n == nullptr) return std::string(); - if (n->kind == openads::sql::WhereExpr::Kind::Cmp) { - const auto& w = n->cmp; - if (w.column_alias.empty() && !w.column.empty() && - count_in(w.column) > 1) - return w.column; - if (w.is_outer_ref && w.outer_column_alias.empty() && - !w.outer_column.empty() && count_in(w.outer_column) > 1) - return w.outer_column; - return std::string(); - } - for (const auto& ch : n->children) { - auto a = scan(ch.get()); - if (!a.empty()) return a; - } - return scan(n->child.get()); - }; - return scan(st.where.get()); -} - -} // namespace scriptbridge - -// ---- S4: connection-scoped trigger row images + session #temp tables ---- -// While a trigger fires, its __new/__old one-row images are visible to -// EVERY statement executed on the connection -- including stored -// procedures the trigger body calls (pmsys: trigger → -// sp_SaveIntoAuditLog → `SELECT n.[col] newVal INTO #MyTrigTbl FROM -// __new n`). #temp tables are connection-scoped result snapshots -// created by SELECT INTO #t, readable via FROM #t, and removed by -// DROP TABLE #t or disconnect. -namespace sess { - -struct TrigImages { - const std::map* new_f = nullptr; - const std::map* old_f = nullptr; -}; -struct TempTable { - std::vector col_names; - std::vector> rows; // display-text cells -}; - -inline std::mutex& mu() { static std::mutex m; return m; } -inline std::map>& image_stacks() { - static std::map> m; - return m; -} -inline std::map>& temp_tables() { - static std::map> m; - return m; -} - -inline std::string lower(std::string s) { - for (auto& ch : s) - ch = static_cast(std::tolower(static_cast(ch))); - return s; -} - -inline TrigImages active_images(Connection* c) { - std::lock_guard lk(mu()); - auto it = image_stacks().find(c); - if (it == image_stacks().end() || it->second.empty()) return {}; - return it->second.back(); -} - -// RAII: registers the firing trigger's images for the whole connection. -struct ImageScope { - Connection* c; - ImageScope(Connection* conn, const std::map* nf, - const std::map* of) - : c(conn) { - std::lock_guard lk(mu()); - image_stacks()[c].push_back(TrigImages{nf, of}); - } - ~ImageScope() { - std::lock_guard lk(mu()); - auto it = image_stacks().find(c); - if (it != image_stacks().end() && !it->second.empty()) - it->second.pop_back(); - if (it != image_stacks().end() && it->second.empty()) - image_stacks().erase(it); - } -}; - -inline void store_temp(Connection* c, const std::string& name, - TempTable t) { - std::lock_guard lk(mu()); - temp_tables()[c][lower(name)] = std::move(t); -} -inline bool drop_temp(Connection* c, const std::string& name) { - std::lock_guard lk(mu()); - auto it = temp_tables().find(c); - if (it == temp_tables().end()) return false; - return it->second.erase(lower(name)) > 0; -} -inline bool get_temp(Connection* c, const std::string& name, - TempTable& out) { - std::lock_guard lk(mu()); - auto it = temp_tables().find(c); - if (it == temp_tables().end()) return false; - auto jt = it->second.find(lower(name)); - if (jt == it->second.end()) return false; - out = jt->second; - return true; -} -inline void forget_connection(Connection* c) { - std::lock_guard lk(mu()); - temp_tables().erase(c); - image_stacks().erase(c); -} - -} // namespace sess - -extern "C++" void sess_forget_connection(Connection* c) { - sess::forget_connection(c); -} - -// Run one trigger body through the script engine. Declared ahead of the -// DML machinery (which lives earlier in this file, inside an anonymous -// namespace) and defined here at global scope. -extern "C++" openads::util::Result script_run_trigger_body( - Connection* conn, ADSHANDLE hConn, const std::string& body, - const std::map& new_f, - const std::map& old_f, - bool is_instead_of) { - auto prog = scriptbridge::compiled(body); - if (!prog) return prog.error(); - ADSHANDLE hStmt = 0; - if (AdsCreateSQLStatement(hConn, &hStmt) != openads::AE_SUCCESS) - return openads::util::Error{openads::AE_INTERNAL_ERROR, 0, - "trigger: statement alloc failed", ""}; - // S4: make this trigger's row images visible to every statement on - // the connection while the body runs -- stored procedures called from - // the body resolve __new/__old through sess::active_images. - sess::ImageScope img_scope(conn, &new_f, &old_f); - scriptbridge::TriggerBridge bridge(conn, hStmt, &new_f, &old_f, - is_instead_of); - openads::script::Executor ex(&bridge); - ex.set_user(conn->username()); - auto r = ex.run(*prog.value()); - AdsCloseSQLStatement(hStmt); - if (!r) return r.error(); - return {}; -} - -namespace { - -template -bool dispatch_sql_uri_acl(const std::string& sqlstr, - openads::sql_backend::SqlDdlDialect dialect, - ExecFn&& exec_sql, - ADSHANDLE* phCursor, - UNSIGNED32& rc_out) { - auto acl = openads::sql_backend::try_sql_acl_statement( - sqlstr, dialect, - [&](const std::string& s) -> openads::util::Result { - return exec_sql(s); - }); - if (!acl) return false; - if (!acl->has_value()) { - rc_out = fail(acl->error()); - return true; - } - *phCursor = 0; - rc_out = ok(); - return true; -} - -// S4 -- DD field constraints for one table (case-insensitive lookup): -// "required" (NOT NULL) and "default" decoded from the DD's SAP binary -// Field records. Keyed by lower-cased field name. Used by the INSERT -// (enforce + apply defaults) and UPDATE (reject SET col = NULL) -// executors. -struct DdFieldRule { - bool required = false; - std::string def; -}; - -std::unordered_map -dd_field_rules_for(Connection* c, const std::string& table) { - std::unordered_map out; - if (c == nullptr || !c->has_dd()) return out; - auto* dd = c->dd(); - if (dd == nullptr) return out; - auto lower = [](std::string s) { - for (auto& ch : s) - ch = static_cast(std::tolower( - static_cast(ch))); - return s; - }; - const std::string want = lower(table); - for (const auto& tkv : dd->field_props()) { - if (lower(tkv.first) != want) continue; - for (const auto& fkv : tkv.second) { - DdFieldRule r; - auto rit = fkv.second.find("required"); - r.required = rit != fkv.second.end() && rit->second == "T"; - auto dit = fkv.second.find("default"); - if (dit != fkv.second.end()) r.def = dit->second; - if (r.required || !r.def.empty()) - out[lower(fkv.first)] = std::move(r); - } - break; - } - return out; -} - -} // namespace - -extern "C" { // reopen for the ACE API exports - -static UNSIGNED32 exec_sql_direct_impl(ADSHANDLE hStatement, UNSIGNED8* pucSQL, - ADSHANDLE* phCursor) { - // ARC (Advantage Data Architect) calls AdsExecuteSQLDirectW with a NULL - // phCursor for statements where it expects no result set (e.g. - // sp_SetApplicationID). SAP ACE accepts NULL and just executes; mirror - // that by running through a scratch slot instead of failing. - ADSHANDLE scratch_cursor = 0; - if (phCursor == nullptr) phCursor = &scratch_cursor; - SqlStatement* st_ptr = stmt_lookup(hStatement); - if (st_ptr == nullptr) return fail(openads::AE_INTERNAL_ERROR, "unknown stmt"); - // Alias so the long body below keeps its `it->second->...` accesses - // unchanged; the lookup is now serialised and the pointer is used off - // the lock (only the owning thread executes this handle). - std::pair it_kv{hStatement, st_ptr}; - auto* it = &it_kv; - arc2_log("EXEC h=%llu remote=%p conn=%p", - (unsigned long long)hStatement, - (void*)st_ptr->remote, (void*)st_ptr->conn); - // M12.7 -- remote SQL exec. The statement was created against a - // RemoteConnection; ship the SQL over the wire, allocate a - // RemoteTable handle around the returned cursor table-id, and - // hand the resulting ADSHANDLE back to the caller. From here on - // every Ads* read on the returned handle (GetField, Skip, etc.) - // routes through the same RemoteTable plumbing M12.4 / M12.5 - // already wired up. - if (it->second->remote != nullptr) { - auto sqlstr = openads::abi::to_internal(pucSQL, 0); - arc2_log("EXEC remote sql=%.80s", sqlstr.c_str()); - auto r = it->second->remote->execute_sql(sqlstr); - if (!r) { - arc2_log("EXEC remote FAIL code=%d msg=%.80s", - r.error().code, r.error().message.c_str()); - return fail(r.error()); - } - std::uint32_t cur_id = r.value(); - if (cur_id == 0) { - *phCursor = 0; - return ok(); - } - auto& s = state(); - std::lock_guard lk(s.mu); - auto rt = std::make_unique(); - rt->conn = it->second->remote; - rt->id = cur_id; - Handle h = s.registry.register_object( - HandleKind::RemoteTable, rt.get()); - remote_sql_cursors_map()[h] = std::move(rt); - *phCursor = to_ads_handle(h); - return ok(); - } -#if defined(OPENADS_WITH_SQLITE) - if (it->second->sqlite != nullptr) { - auto sqlstr = openads::abi::to_internal(pucSQL, 0); - { - UNSIGNED32 acl_rc = 0; - if (dispatch_sql_uri_acl( - sqlstr, openads::sql_backend::SqlDdlDialect::Sqlite, - [&](const std::string& s) { - return it->second->sqlite->exec_sql(s); - }, - phCursor, acl_rc)) { - return acl_rc; - } - } - { - const openads::sql_backend::SqlQueryFn qfn = - [&](const std::string& sql) { - return sqlite_acl_query(it->second->sqlite, sql); - }; - if (UNSIGNED32 rc = sql_uri_check_sql_rights( - sqlstr, it->second->check_rights, - it->second->sql_username, - openads::sql_backend::SqlDdlDialect::Sqlite, qfn); - rc != openads::AE_SUCCESS) { - return rc; - } - } - if (auto rewritten = openads::sql_backend::rewrite_system_select_sql( - openads::sql_backend::SqlDdlDialect::Sqlite, sqlstr)) { - sqlstr = *rewritten; - } - // 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) { - invalidate_sqlite_nav_after_dml(it->second->sqlite); - *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 = to_ads_handle(h); - return ok(); - } -#endif -#if defined(OPENADS_WITH_MSSQL) - if (it->second->mssql_conn != nullptr) { - auto sqlstr = openads::abi::to_internal(pucSQL, 0); - { - UNSIGNED32 acl_rc = 0; - if (dispatch_sql_uri_acl( - sqlstr, openads::sql_backend::SqlDdlDialect::Mssql, - [&](const std::string& s) { - return it->second->mssql_conn->exec_sql(s); - }, - phCursor, acl_rc)) { - return acl_rc; - } - } - { - const openads::sql_backend::SqlQueryFn qfn = - [&](const std::string& sql) { - return mssql_acl_query(it->second->mssql_conn, sql); - }; - if (UNSIGNED32 rc = sql_uri_check_sql_rights( - sqlstr, it->second->check_rights, - it->second->sql_username, - openads::sql_backend::SqlDdlDialect::Mssql, qfn); - rc != openads::AE_SUCCESS) { - return rc; - } - } - if (auto rewritten = openads::sql_backend::rewrite_system_select_sql( - openads::sql_backend::SqlDdlDialect::Mssql, sqlstr)) { - sqlstr = *rewritten; - } - auto qr = it->second->mssql_conn->query(sqlstr); - if (!qr) return fail(qr.error()); - openads::sql_backend::tds::QueryResult result = std::move(qr).value(); - if (!result.ok) { - return fail(static_cast(result.error_number), - result.message.c_str()); - } - if (result.columns.empty()) { - *phCursor = 0; - return ok(); - } - auto st = openads::sql_backend::MssqlTable::from_result( - std::move(result)); - auto& s = state(); - std::lock_guard lk(s.mu); - Handle h = s.registry.register_object( - HandleKind::MssqlTable, st.get()); - mssql_tables_map().emplace(h, std::move(st)); - *phCursor = to_ads_handle(h); - return ok(); - } -#endif -#if defined(OPENADS_WITH_POSTGRESQL) - if (it->second->postgres != nullptr) { - auto sqlstr = openads::abi::to_internal(pucSQL, 0); - { - UNSIGNED32 acl_rc = 0; - if (dispatch_sql_uri_acl( - sqlstr, openads::sql_backend::SqlDdlDialect::Postgres, - [&](const std::string& s) { - return it->second->postgres->exec_sql(s); - }, - phCursor, acl_rc)) { - return acl_rc; - } - } - { - const openads::sql_backend::SqlQueryFn qfn = - [&](const std::string& sql) { - return postgres_acl_query(it->second->postgres, sql); - }; - if (UNSIGNED32 rc = sql_uri_check_sql_rights( - sqlstr, it->second->check_rights, - it->second->sql_username, - openads::sql_backend::SqlDdlDialect::Postgres, qfn); - rc != openads::AE_SUCCESS) { - return rc; - } - } - if (openads::sql::sql_is_alter_table(sqlstr) || - openads::sql::sql_is_drop_index(sqlstr) || - openads::sql::sql_is_drop_table(sqlstr)) { - if (auto r = it->second->postgres->exec_sql(sqlstr); !r) { - return fail(r.error()); - } - invalidate_postgres_nav_after_dml(it->second->postgres); - *phCursor = 0; - return ok(); - } - if (auto rewritten = openads::sql_backend::rewrite_system_select_sql( - openads::sql_backend::SqlDdlDialect::Postgres, sqlstr)) { - sqlstr = *rewritten; - } - auto r = it->second->postgres->run_sql(sqlstr); - if (!r) return fail(r.error()); - auto cursor = std::move(r).value(); - if (!cursor) { - invalidate_postgres_nav_after_dml(it->second->postgres); - *phCursor = 0; - return ok(); - } - auto& s = state(); - std::lock_guard lk(s.mu); - Handle h = s.registry.register_object( - HandleKind::PostgresTable, cursor.get()); - postgres_tables_map().emplace(h, std::move(cursor)); - *phCursor = to_ads_handle(h); - return ok(); - } -#endif -#if defined(OPENADS_WITH_MARIADB) - if (it->second->maria != nullptr) { - auto sqlstr = openads::abi::to_internal(pucSQL, 0); - { - UNSIGNED32 acl_rc = 0; - if (dispatch_sql_uri_acl( - sqlstr, openads::sql_backend::SqlDdlDialect::Maria, - [&](const std::string& s) { - return it->second->maria->exec_sql(s); - }, - phCursor, acl_rc)) { - return acl_rc; - } - } - { - const openads::sql_backend::SqlQueryFn qfn = - [&](const std::string& sql) { - return maria_acl_query(it->second->maria, sql); - }; - if (UNSIGNED32 rc = sql_uri_check_sql_rights( - sqlstr, it->second->check_rights, - it->second->sql_username, - openads::sql_backend::SqlDdlDialect::Maria, qfn); - rc != openads::AE_SUCCESS) { - return rc; - } - } - if (openads::sql::sql_is_alter_table(sqlstr) || - openads::sql::sql_is_drop_index(sqlstr) || - openads::sql::sql_is_drop_table(sqlstr)) { - if (auto r = it->second->maria->exec_sql(sqlstr); !r) { - return fail(r.error()); - } - invalidate_maria_nav_after_dml(it->second->maria); - *phCursor = 0; - return ok(); - } - if (auto rewritten = openads::sql_backend::rewrite_system_select_sql( - openads::sql_backend::SqlDdlDialect::Maria, sqlstr)) { - sqlstr = *rewritten; - } - auto r = it->second->maria->run_sql(sqlstr); - if (!r) return fail(r.error()); - auto cursor = std::move(r).value(); - if (!cursor) { - invalidate_maria_nav_after_dml(it->second->maria); - *phCursor = 0; - return ok(); - } - auto& s = state(); - std::lock_guard lk(s.mu); - Handle h = s.registry.register_object( - HandleKind::MariaTable, cursor.get()); - maria_tables_map().emplace(h, std::move(cursor)); - *phCursor = to_ads_handle(h); - return ok(); - } -#endif -#if defined(OPENADS_WITH_ODBC) - if (it->second->odbc != nullptr) { - auto sqlstr = openads::abi::to_internal(pucSQL, 0); - const auto odbc_dialect = it->second->odbc_dialect; - { - UNSIGNED32 acl_rc = 0; - if (dispatch_sql_uri_acl( - sqlstr, odbc_dialect, - [&](const std::string& s) { - return it->second->odbc->exec_sql(s); - }, - phCursor, acl_rc)) { - return acl_rc; - } - } - { - const openads::sql_backend::SqlQueryFn qfn = - [&](const std::string& sql) { - return odbc_acl_query(it->second->odbc, sql); - }; - if (UNSIGNED32 rc = sql_uri_check_sql_rights( - sqlstr, it->second->check_rights, - it->second->sql_username, - odbc_dialect, qfn); - rc != openads::AE_SUCCESS) { - return rc; - } - } - if (openads::sql::sql_is_alter_table(sqlstr) || - openads::sql::sql_is_drop_index(sqlstr) || - openads::sql::sql_is_drop_table(sqlstr)) { - if (auto r = it->second->odbc->exec_sql(sqlstr); !r) { - return fail(r.error()); - } - invalidate_odbc_nav_after_dml(it->second->odbc); - *phCursor = 0; - return ok(); - } - if (auto rewritten = - openads::sql_backend::rewrite_system_select_sql( - odbc_dialect, sqlstr)) { - sqlstr = *rewritten; - } - if (auto r = it->second->odbc->exec_sql(sqlstr); !r) { - return fail(r.error()); - } - invalidate_odbc_nav_after_dml(it->second->odbc); - *phCursor = 0; - return ok(); - } -#endif -#if defined(OPENADS_WITH_FIREBIRD) - if (it->second->firebird != nullptr) { - auto sqlstr = openads::abi::to_internal(pucSQL, 0); - { - UNSIGNED32 acl_rc = 0; - if (dispatch_sql_uri_acl( - sqlstr, openads::sql_backend::SqlDdlDialect::Firebird, - [&](const std::string& s) { - return it->second->firebird->exec_sql(s); - }, - phCursor, acl_rc)) { - return acl_rc; - } - } - { - const openads::sql_backend::SqlQueryFn qfn = - [&](const std::string& sql) { - return firebird_acl_query(it->second->firebird, sql); - }; - if (UNSIGNED32 rc = sql_uri_check_sql_rights( - sqlstr, it->second->check_rights, - it->second->sql_username, - openads::sql_backend::SqlDdlDialect::Firebird, qfn); - rc != openads::AE_SUCCESS) { - return rc; - } - } - if (openads::sql::sql_is_alter_table(sqlstr) || - openads::sql::sql_is_drop_index(sqlstr) || - openads::sql::sql_is_drop_table(sqlstr)) { - auto r = it->second->firebird->run_sql(sqlstr); - if (!r) return fail(r.error()); - invalidate_firebird_nav_after_dml(it->second->firebird); - *phCursor = 0; - return ok(); - } - if (auto rewritten = openads::sql_backend::rewrite_system_select_sql( - openads::sql_backend::SqlDdlDialect::Firebird, sqlstr)) { - sqlstr = *rewritten; - } - auto r = it->second->firebird->run_sql(sqlstr); - if (!r) return fail(r.error()); - auto cursor = std::move(r).value(); - if (!cursor) { - invalidate_firebird_nav_after_dml(it->second->firebird); - *phCursor = 0; - return ok(); - } - auto& s = state(); - std::lock_guard lk(s.mu); - Handle h = s.registry.register_object( - HandleKind::FirebirdTable, cursor.get()); - firebird_tables_map().emplace(h, std::move(cursor)); - *phCursor = to_ads_handle(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); - - // S3 (§10 mechanism): full multi-statement scripts route through the - // script engine; the last SELECT's cursor comes back as the statement - // cursor. Single statements fall through to the SQL dispatcher below -- - // the script engine's embedded statements re-enter here one at a time, - // so per-statement ACL checks still apply. - if (scriptbridge::is_script_input(sql)) { - auto r = scriptbridge::run_top_level_script(c, hStatement, sql, - phCursor); - if (!r) return fail(r.error()); - return ok(); - } - - // ---- S4: session #temp tables & trigger-image SELECT INTO ----------- - // DROP TABLE #t removes the session snapshot; SELECT ... INTO #t - // materialises one. When the FROM sources are the trigger images - // (__new/__old) the row is resolved directly from the active images - // (pmsys: sp_SaveIntoAuditLog called from a trigger); any other - // source runs the SELECT normally and snapshots its cursor. - { - std::string up5; - up5.reserve(sql.size()); - for (char ch : sql) - up5.push_back(static_cast( - std::toupper(static_cast(ch)))); - auto skip_ws2 = [&](std::size_t& p) { - while (p < sql.size() && - std::isspace(static_cast(sql[p]))) ++p; - }; - auto read_word = [&](std::size_t& p) -> std::string { - skip_ws2(p); - std::size_t b2 = p; - while (p < sql.size() && - (std::isalnum(static_cast(sql[p])) || - sql[p] == '_' || sql[p] == '#')) - ++p; - return sql.substr(b2, p - b2); - }; - std::size_t sp = 0; - skip_ws2(sp); - if (up5.compare(sp, 10, "DROP TABLE") == 0) { - std::size_t q = sp + 10; - skip_ws2(q); - if (q < sql.size() && sql[q] == '#') { - std::string nm = read_word(q); - sess::drop_temp(c, nm); - *phCursor = 0; - return ok(); - } - } - if (up5.compare(sp, 7, "SELECT ") == 0) { - std::size_t into_pos = std::string::npos; - int depth = 0; - bool in_q = false; - for (std::size_t i2 = sp; i2 + 6 <= sql.size(); ++i2) { - char ch = sql[i2]; - if (in_q) { if (ch == '\'') in_q = false; continue; } - if (ch == '\'') { in_q = true; continue; } - if (ch == '(') { ++depth; continue; } - if (ch == ')') { --depth; continue; } - if (depth == 0 && up5.compare(i2, 6, " INTO ") == 0) { - into_pos = i2; - break; - } - } - std::size_t np = into_pos == std::string::npos - ? std::string::npos : into_pos + 6; - if (np != std::string::npos) skip_ws2(np); - if (np != std::string::npos && np < sql.size() && - sql[np] == '#') { - std::string tmp_name = read_word(np); - std::string items = sql.substr(sp + 7, into_pos - (sp + 7)); - std::size_t rp = np; - skip_ws2(rp); - std::string rest = sql.substr(rp); // "FROM ..." - // Parse the FROM source list: tok [alias][, tok [alias]]. - struct Src { std::string table, alias; }; - std::vector srcs; - bool from_ok = false; - { - std::size_t p = 0; - std::string kw = rest.substr(0, 4); - for (auto& ch : kw) - ch = static_cast( - std::toupper(static_cast(ch))); - if (kw == "FROM") { - p = 4; - from_ok = true; - for (;;) { - while (p < rest.size() && std::isspace( - static_cast(rest[p]))) ++p; - std::size_t b2 = p; - while (p < rest.size() && - (std::isalnum(static_cast( - rest[p])) || - rest[p] == '_')) ++p; - Src s2; - s2.table = sess::lower(rest.substr(b2, p - b2)); - while (p < rest.size() && std::isspace( - static_cast(rest[p]))) ++p; - if (p < rest.size() && rest[p] != ',' && - rest[p] != ';') { - std::size_t a2 = p; - while (p < rest.size() && - (std::isalnum( - static_cast( - rest[p])) || - rest[p] == '_')) ++p; - s2.alias = sess::lower( - rest.substr(a2, p - a2)); - } - if (s2.table.empty()) { from_ok = false; break; } - srcs.push_back(std::move(s2)); - while (p < rest.size() && std::isspace( - static_cast(rest[p]))) ++p; - if (p < rest.size() && rest[p] == ',') { - ++p; - continue; - } - break; - } - } - } - bool all_images = from_ok && !srcs.empty(); - for (const auto& s2 : srcs) - if (s2.table != "__new" && s2.table != "__old") - all_images = false; - auto imgs = sess::active_images(c); - { - static const bool into_trace = - openads::util::client_setting_truthy( - "OPENADS_TRACE", "trace"); - if (into_trace) - std::fprintf(stderr, - "[into] tmp=%s all_images=%d new=%p old=%p " - "sql=%.100s\n", - tmp_name.c_str(), all_images ? 1 : 0, - (const void*)imgs.new_f, - (const void*)imgs.old_f, sql.c_str()); - } - if (all_images && - (imgs.new_f != nullptr || imgs.old_f != nullptr)) { - // Resolve each projected item straight from the images. - auto image_for = [&](const std::string& alias) - -> const std::map* { - for (const auto& s2 : srcs) { - if (alias.empty() || s2.alias == alias || - s2.table == alias) { - return s2.table == "__old" ? imgs.old_f - : imgs.new_f; - } - } - return imgs.new_f != nullptr ? imgs.new_f - : imgs.old_f; - }; - sess::TempTable tt; - std::vector row; - std::size_t p = 0; - bool parse_ok = true; - while (parse_ok && p < items.size()) { - while (p < items.size() && std::isspace( - static_cast(items[p]))) ++p; - if (p >= items.size()) break; - // [alias.]col-or-[col] [colalias] - std::string alias, col; - std::size_t b2 = p; - if (items[p] != '[') { - while (p < items.size() && - (std::isalnum( - static_cast( - items[p])) || - items[p] == '_')) ++p; - std::string first = items.substr(b2, p - b2); - if (p < items.size() && items[p] == '.') { - alias = sess::lower(first); - ++p; - } else { - col = first; - } - } - if (col.empty()) { - if (p < items.size() && items[p] == '[') { - std::size_t e2 = items.find(']', p); - if (e2 == std::string::npos) { - parse_ok = false; - break; - } - col = items.substr(p + 1, e2 - p - 1); - p = e2 + 1; - } else { - std::size_t c2 = p; - while (p < items.size() && - (std::isalnum( - static_cast( - items[p])) || - items[p] == '_')) ++p; - col = items.substr(c2, p - c2); - } - } - if (col.empty()) { parse_ok = false; break; } - while (p < items.size() && std::isspace( - static_cast(items[p]))) ++p; - std::string colalias; - if (p < items.size() && items[p] != ',') { - std::size_t a2 = p; - while (p < items.size() && - (std::isalnum( - static_cast( - items[p])) || - items[p] == '_')) ++p; - colalias = items.substr(a2, p - a2); - } - while (p < items.size() && std::isspace( - static_cast(items[p]))) ++p; - if (p < items.size() && items[p] == ',') ++p; - const auto* m = image_for(alias); - std::string cell; - if (m != nullptr) { - auto it2 = m->find(sess::lower(col)); - if (it2 != m->end()) - cell = openads::script::to_display( - scriptbridge::trig_value(it2->second)); - } - tt.col_names.push_back( - colalias.empty() ? col : colalias); - row.push_back(std::move(cell)); - } - if (parse_ok && !tt.col_names.empty()) { - tt.rows.push_back(std::move(row)); - sess::store_temp(c, tmp_name, std::move(tt)); - *phCursor = 0; - return ok(); - } - return fail(openads::AE_PARSE_ERROR, - "SELECT INTO #temp: unsupported item list"); - } - // Generic source: run the SELECT without the INTO clause - // and snapshot the cursor. - std::string inner = sql.substr(sp, into_pos - sp) + " " + - rest; - std::vector ibuf(inner.size() + 1); - std::memcpy(ibuf.data(), inner.c_str(), inner.size() + 1); - ADSHANDLE hc2 = 0; - UNSIGNED32 rc2 = - AdsExecuteSQLDirect(hStatement, ibuf.data(), &hc2); - if (rc2 != 0) return rc2; - if (hc2 == 0) - return fail(openads::AE_PARSE_ERROR, - "SELECT INTO #temp: inner SELECT returned " - "no cursor"); - auto& s5 = state(); - std::lock_guard lk5(s5.mu); - auto* src_tbl = s5.registry.lookup( - hc2, HandleKind::Table); - if (src_tbl == nullptr) { - AdsCloseTable(hc2); - return fail(openads::AE_INTERNAL_ERROR, - "SELECT INTO #temp: cursor lookup"); - } - const auto* proj = projection_for(hc2); - std::vector cols2; - if (proj) { - cols2 = *proj; - } else { - for (std::uint16_t k2 = 0; - k2 < src_tbl->field_count(); ++k2) - cols2.push_back(k2); - } - sess::TempTable tt; - for (std::uint16_t k2 : cols2) - tt.col_names.push_back( - src_tbl->field_descriptor(k2).name); - // Column aliases from the SELECT (`col newVal`) name the - // snapshot's columns, SAP-style. - if (auto ps2 = openads::sql::parse_select(inner)) { - for (const auto& pa : ps2.value().projection_aliases) { - if (pa.first < tt.col_names.size()) - tt.col_names[pa.first] = pa.second; - } - } - std::vector recnos; - if (src_tbl->has_recno_sequence()) { - recnos = src_tbl->recno_sequence(); - } else { - std::uint32_t rcount = src_tbl->record_count(); - for (std::uint32_t r2 = 1; r2 <= rcount; ++r2) - recnos.push_back(r2); - } - for (std::uint32_t r2 : recnos) { - if (auto g2 = src_tbl->goto_record(r2); !g2) continue; - if (!src_tbl->show_deleted_records() && - src_tbl->is_deleted()) continue; - if (!src_tbl->passes_filter()) continue; - std::vector row; - for (std::uint16_t k2 : cols2) { - auto v2 = src_tbl->read_field(k2); - std::string cell = - v2 ? v2.value().as_string : std::string(); - while (!cell.empty() && cell.back() == ' ') - cell.pop_back(); - row.push_back(std::move(cell)); - } - tt.rows.push_back(std::move(row)); - } - AdsCloseTable(hc2); - sess::store_temp(c, tmp_name, std::move(tt)); - *phCursor = 0; - return ok(); - } - } - } - - // Open a table by name, transparently resolving "system.*" virtual tables - // to memory tables materialized from DD state. - auto open_or_sys = [c, &sql](const std::string& tname, - openads::engine::TableType ttype, - openads::engine::OpenMode omode, - openads::engine::LockingMode lmode) - -> openads::util::Result { - std::string resolved = tname; - // S4 -- session #temp table: serve the stored snapshot as a - // memory table. - if (!tname.empty() && tname[0] == '#') { - sess::TempTable tt; - if (!sess::get_temp(c, tname, tt)) - return openads::util::Error{ - static_cast(openads::AE_NO_FILE_FOUND), 0, - tname, ""}; - std::vector cols; - cols.reserve(tt.col_names.size()); - for (std::size_t i2 = 0; i2 < tt.col_names.size(); ++i2) { - std::size_t w = 1; - for (const auto& row : tt.rows) - if (i2 < row.size() && row[i2].size() > w) - w = row[i2].size(); - cols.push_back(SpCol{tt.col_names[i2].c_str(), 'C', - static_cast( - std::min(w, 4096)), - 0}); - } - auto mt = build_memory_result("temp" + tname, cols, tt.rows); - if (!mt) return mt.error(); - return c->adopt_table(std::move(mt).value(), tname); - } - if (tname.size() > 7) { - std::string px = tname.substr(0, 7); - for (auto& ch : px) ch = static_cast( - std::tolower(static_cast(ch))); - if (px == "system.") { - std::optional filter; - if (auto parsed_for_filter = openads::sql::parse_select(sql)) { - filter = extract_system_table_filter_(parsed_for_filter.value()); - } - auto mt = build_system_table( - c, tname.substr(7), filter ? &filter.value() : nullptr); - if (!mt) return mt.error(); - return c->adopt_table(std::move(mt).value(), tname); - } - } - auto ot = c->open_table(resolved, ttype, omode, lmode); - if (!ot && (ot.error().code == 5103 || ot.error().code == 7041)) { - // SAP reports a missing table in a SQL statement as ACE 5004 - // with the resolved path and the table name (the AQE envelope - // at the statement boundary adds the 7200 wrapper). Replicated - // byte-for-byte for parity. - std::string p = c->data_dir(); - if (!p.empty() && p.back() != '\\' && p.back() != '/') - p += '\\'; - p += tname; - std::string base = tname; - std::size_t slash = base.find_last_of("\\/"); - if (slash != std::string::npos) base = base.substr(slash + 1); - if (base.find('.') == std::string::npos) - // DD connections resolve bare table names as ADT (SAP's - // default dictionary table type); free connections by the - // statement's table type. - p += (c->has_dd() || - ttype == openads::engine::TableType::Adt) - ? ".adt" : ".dbf"; - return openads::util::Error{5004, 0, - "Either ACE could not find the specified file, or you do " - "not have sufficient rights to access the file. " + p + - " Table name: " + tname, ""}; - } - return ot; - }; - - // Per-operation ACL check for SQL statements when check_rights is set. - if (it->second->check_rights != 0 && c->has_dd() && !c->username().empty()) { - std::string obj_name; - enum class SqlOp { None, Select, Insert, Update, Delete, Execute } op = - SqlOp::None; - - if (openads::sql::sql_is_insert(sql)) { - if (auto p = openads::sql::parse_insert(sql)) - { obj_name = p.value().table; op = SqlOp::Insert; } - } else if (openads::sql::sql_is_update(sql)) { - if (auto p = openads::sql::parse_update(sql)) - { obj_name = p.value().table; op = SqlOp::Update; } - } else if (openads::sql::sql_is_delete(sql)) { - if (auto p = openads::sql::parse_delete(sql)) - { obj_name = p.value().table; op = SqlOp::Delete; } - } else if (openads::sql::sql_is_merge(sql)) { - if (auto p = openads::sql::parse_merge(sql)) - { obj_name = p.value().table; op = SqlOp::Update; } - } else { - // SELECT or EXECUTE PROCEDURE - if (auto p = openads::sql::parse_select(sql)) { - obj_name = p.value().table; - // Detect "EXECUTE PROCEDURE sp_*" by checking table starts with "sp_" - // or whether the FROM target is a StoredProc/Function in the DD. - auto* dd = c->dd(); - if (dd && dd->has_proc(obj_name)) - op = SqlOp::Execute; - else - op = SqlOp::Select; - } - } - - if (!obj_name.empty() && op != SqlOp::None) { - auto ops = eff_ops(c, obj_name); - bool denied = false; - switch (op) { - case SqlOp::Select: denied = !ops.select_; break; - case SqlOp::Insert: denied = !ops.insert_; break; - case SqlOp::Update: denied = !ops.update_; break; - case SqlOp::Delete: denied = !ops.delete_; break; - case SqlOp::Execute: denied = !ops.execute_; break; - default: break; - } - if (denied) - return fail(openads::AE_ACCESS_DENIED, obj_name.c_str()); - } - } - - // M10.5/M10.7/M10.9: dispatch on the leading keyword. INSERT / - // UPDATE / DELETE / CREATE TABLE / CREATE INDEX write through - // the engine and return no cursor (phCursor → 0); SELECT keeps - // the M9.21 path. - if (openads::sql::sql_is_drop_index(sql)) { - auto di = openads::sql::parse_drop_index(sql); - if (!di) return fail(di.error()); - return fail(openads::AE_FUNCTION_NOT_AVAILABLE, - "DROP INDEX via SQL not implemented for DBF tables"); - } - - if (openads::sql::sql_is_drop_table(sql)) { - auto dt = openads::sql::parse_drop_table(sql); - if (!dt) return fail(dt.error()); - namespace fs = std::filesystem; - fs::path full = fs::path(c->data_dir()) / dt.value().table; - if (!full.has_extension()) full.replace_extension(".dbf"); - if (!fs::exists(full)) { - if (dt.value().if_exists) { - *phCursor = 0; - return ok(); - } - return fail(openads::AE_NO_FILE_FOUND, dt.value().table.c_str()); - } - std::error_code ec; - fs::remove(full, ec); - auto cdx = full; cdx.replace_extension(".cdx"); - fs::remove(cdx, ec); - auto fpt = full; fpt.replace_extension(".fpt"); - fs::remove(fpt, ec); - auto dbt = full; dbt.replace_extension(".dbt"); - fs::remove(dbt, ec); - *phCursor = 0; - return ok(); - } - - if (openads::sql::sql_is_alter_table(sql)) { - auto at = openads::sql::parse_alter_table(sql); - if (!at) return fail(at.error()); - auto field_def = [](const openads::sql::AlterTableAction& a) { - std::string s = a.column + "," + a.type; - if (a.length > 0) { - s += "," + std::to_string(a.length); - if (a.decimals > 0) s += "," + std::to_string(a.decimals); - } - return s; - }; - std::string add_defs, del_list, chg_defs; - for (const auto& action : at.value().actions) { - switch (action.kind) { - case openads::sql::AlterTableAction::Kind::AddColumn: - if (!add_defs.empty()) add_defs += ';'; - add_defs += field_def(action); - break; - case openads::sql::AlterTableAction::Kind::DropColumn: - if (!del_list.empty()) del_list += ';'; - del_list += action.column; - break; - case openads::sql::AlterTableAction::Kind::AlterColumn: - if (!chg_defs.empty()) chg_defs += ';'; - chg_defs += field_def(action); - break; - } - } - auto& s = state(); - ADSHANDLE conn_h = 0; - s.registry.for_each_handle([&](Handle h, HandleKind k, void* p) { - if (k != HandleKind::Connection) return; - if (static_cast(p) == c) conn_h = to_ads_handle(h); - }); - if (conn_h == 0) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - std::vector name_buf(at.value().table.size() + 1, 0); - std::memcpy(name_buf.data(), at.value().table.data(), - at.value().table.size()); - std::vector add_buf(add_defs.size() + 1, 0); - std::memcpy(add_buf.data(), add_defs.data(), add_defs.size()); - std::vector del_buf(del_list.size() + 1, 0); - std::memcpy(del_buf.data(), del_list.data(), del_list.size()); - std::vector chg_buf(chg_defs.size() + 1, 0); - std::memcpy(chg_buf.data(), chg_defs.data(), chg_defs.size()); - UNSIGNED32 rc = AdsRestructureTable( - conn_h, name_buf.data(), nullptr, - 0, 0, 0, 0, - add_buf.data(), - del_buf.data(), - chg_buf.data()); - if (rc != openads::AE_SUCCESS) return rc; - *phCursor = 0; - return ok(); - } - - if (openads::sql::sql_is_create_table(sql)) { - auto& s = state(); - auto ct = openads::sql::parse_create_table(sql); - if (!ct) return fail(ct.error()); - - // M10.42 -- CREATE TABLE t AS SELECT ...: recursively run the - // inner SELECT, build the new table's schema from the result - // cursor's projected fields, then walk + insert each row. - if (!ct.value().select_sql.empty()) { - std::vector selbuf(ct.value().select_sql.size() + 1); - std::memcpy(selbuf.data(), - ct.value().select_sql.c_str(), - ct.value().select_sql.size() + 1); - ADSHANDLE srcCur = 0; - UNSIGNED32 rrc = - AdsExecuteSQLDirect(hStatement, selbuf.data(), &srcCur); - if (rrc != 0) return rrc; - std::lock_guard lk2(s.mu); - openads::engine::Table* src = - s.registry.lookup( - srcCur, HandleKind::Table); - if (!src) { - return fail(openads::AE_INTERNAL_ERROR, - "CTAS inner cursor lookup"); - } - // Build schema from inner cursor (projection-aware). - const auto* proj = projection_for(srcCur); - std::vector schema; - if (proj) { - for (auto idx : *proj) schema.push_back(src->field_descriptor(idx)); - } else { - std::uint16_t nf = src->field_count(); - for (std::uint16_t k = 0; k < nf; ++k) { - schema.push_back(src->field_descriptor(k)); - } - } - // RCB-- 06-25-2026. Determine target table type. If the caller explicitly set a - // type via AdsStmtSetTableType(), honour it; otherwise mirror - // the source so that an ADT source produces an ADT target and a - // DBF source produces a CDX target. Hardcoding ADS_CDX here - // caused silent schema corruption: ADT field types (Money, - // Timestamp, ShortInt, …) have no DBF equivalent, so they were - // silently coerced to Character when the target was forced to - // .dbf regardless of the source format. - UNSIGNED16 ctas_tgt_type = it->second->table_type; - if (ctas_tgt_type == 0 || ctas_tgt_type == ADS_DEFAULT) { - UNSIGNED16 src_type = ADS_CDX; - AdsGetTableType(srcCur, &src_type); - ctas_tgt_type = (src_type == ADS_ADT) ? ADS_ADT : ADS_CDX; - } - // Build NAME,Type,Len,Dec;… from schema. - // Two switch blocks: the first covers printable-letter DBF type - // codes ('C', 'N', …); the second covers ADT numeric type codes - // (1–20) stored as raw bytes by adt_driver.cpp. The ranges are - // disjoint (DBF letters are all ≥ 0x42; ADT codes top out at 20) - // so there is no ambiguity. - auto type_name = [](char raw) -> const char* { - switch (raw) { - case 'C': return "Character"; - case 'N': return "Numeric"; - case 'D': return "Date"; - case 'L': return "Logical"; - case 'M': return "Memo"; - case 'F': return "Float"; - case 'I': return "Integer"; - case 'Y': return "Currency"; - case 'B': return "Double"; - case 'V': return "Varchar"; - case 'Q': return "Varbinary"; - } - switch (static_cast(raw)) { - case 1: return "Logical"; - case 3: return "Date"; - case 4: return "Character"; - case 5: return "Memo"; - case 6: return "Binary"; - case 7: return "Image"; - case 10: return "Double"; - case 11: return "Integer"; - case 12: return "ShortInt"; - case 13: return "Time"; - case 14: return "Timestamp"; - case 15: return "AutoInc"; - case 18: return "Money"; - case 20: return "CiCharacter"; - } - return "Character"; - }; - std::string defs; - for (auto& fd : schema) { - if (!defs.empty()) defs.push_back(';'); - defs += fd.name; - defs.push_back(','); - defs += type_name(static_cast(fd.raw_type)); - if (fd.length > 0) { - defs.push_back(','); - defs += std::to_string(fd.length); - } - if (fd.decimals > 0) { - defs.push_back(','); - defs += std::to_string(fd.decimals); - } - } - std::vector name_buf(ct.value().table.size() + 1, 0); - std::memcpy(name_buf.data(), ct.value().table.data(), - ct.value().table.size()); - std::vector def_buf(defs.size() + 1, 0); - std::memcpy(def_buf.data(), defs.data(), defs.size()); - ADSHANDLE conn_h = 0; - s.registry.for_each_handle([&](Handle h, HandleKind k, void* p) { - if (k != HandleKind::Connection) return; - if (static_cast(p) == c) conn_h = to_ads_handle(h); - }); - if (conn_h == 0) { - AdsCloseTable(srcCur); - return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - } - ADSHANDLE hTable = 0; - UNSIGNED32 rc = AdsCreateTable(conn_h, name_buf.data(), nullptr, - ctas_tgt_type, 0, 0, 0, 0, - def_buf.data(), &hTable); - if (rc != openads::AE_SUCCESS) { - AdsCloseTable(srcCur); - return rc; - } - openads::engine::Table* tgt = - s.registry.lookup( - hTable, HandleKind::Table); - if (!tgt) { - AdsCloseTable(srcCur); - AdsCloseTable(hTable); - return fail(openads::AE_INTERNAL_ERROR, "CTAS post-create"); - } - // Walk source rows. - std::vector recnos; - if (src->has_recno_sequence()) { - recnos = src->recno_sequence(); - } else { - std::uint32_t rcount = src->record_count(); - for (std::uint32_t r = 1; r <= rcount; ++r) { - if (auto g = src->goto_record(r); !g) continue; - if (!src->show_deleted_records() && - src->is_deleted()) continue; - if (!src->passes_filter()) continue; - recnos.push_back(r); - } - } - // Pre-resolve src column → tgt column by name match. - std::vector src_cols(schema.size()); - std::vector tgt_cols(schema.size()); - for (std::size_t i = 0; i < schema.size(); ++i) { - src_cols[i] = proj ? (*proj)[i] : static_cast(i); - std::int32_t fi = tgt->field_index(schema[i].name); - if (fi < 0) { - AdsCloseTable(srcCur); - AdsCloseTable(hTable); - return fail(openads::AE_INTERNAL_ERROR, - "CTAS target field missing"); - } - tgt_cols[i] = static_cast(fi); - } - for (std::uint32_t r : recnos) { - if (auto g = src->goto_record(r); !g) continue; - if (auto ar = tgt->append_record(); !ar) { - AdsCloseTable(srcCur); - AdsCloseTable(hTable); - return fail(ar.error()); - } - for (std::size_t i = 0; i < schema.size(); ++i) { - auto v = src->read_field(src_cols[i]); - std::string sv = v ? v.value().as_string : std::string(); - auto wr = tgt->set_field(tgt_cols[i], sv); - if (!wr) { - AdsCloseTable(srcCur); - AdsCloseTable(hTable); - return fail(wr.error()); - } - } - } - (void)tgt->flush(); - AdsCloseTable(srcCur); - AdsCloseTable(hTable); - *phCursor = 0; - return ok(); - } - - // Use the caller-supplied table type (AdsStmtSetTableType), falling - // back to CDX. There is no source table to infer from here, so the - // default is always CDX; callers that want ADT must set it explicitly. - UNSIGNED16 ct_tgt_type = it->second->table_type; - if (ct_tgt_type == 0 || ct_tgt_type == ADS_DEFAULT) ct_tgt_type = ADS_CDX; - - // Build the rddads `NAME,Type,Len,Dec;…` field-def string and - // route through AdsCreateTable so M9.5's parser owns the - // schema-write logic. - std::string defs; - for (const auto& col : ct.value().columns) { - if (!defs.empty()) defs.push_back(';'); - defs += col.name; - defs.push_back(','); - defs += col.type; - if (col.length > 0) { - defs.push_back(','); - defs += std::to_string(col.length); - } - if (col.decimals > 0) { - defs.push_back(','); - defs += std::to_string(col.decimals); - } - } - std::vector name_buf(ct.value().table.size() + 1, 0); - std::memcpy(name_buf.data(), ct.value().table.data(), - ct.value().table.size()); - std::vector def_buf(defs.size() + 1, 0); - std::memcpy(def_buf.data(), defs.data(), defs.size()); - ADSHANDLE hTable = 0; - // Resolve the connection's registry handle so AdsCreateTable - // can look it up the same way external callers do. - ADSHANDLE conn_h = 0; - s.registry.for_each_handle([&](Handle h, HandleKind k, void* p) { - if (k != HandleKind::Connection) return; - if (static_cast(p) == c) conn_h = to_ads_handle(h); - }); - if (conn_h == 0) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - UNSIGNED32 rc = AdsCreateTable(conn_h, name_buf.data(), nullptr, - ct_tgt_type, 0, 0, 0, 0, - def_buf.data(), &hTable); - if (rc != openads::AE_SUCCESS) return rc; - // Close the table immediately; CREATE TABLE returns no cursor. - AdsCloseTable(hTable); - *phCursor = 0; - return ok(); - } - - if (openads::sql::sql_is_create_index(sql)) { - auto& s = state(); - auto ci = openads::sql::parse_create_index(sql); - if (!ci) return fail(ci.error()); - // Resolve the connection handle and open the table to obtain - // an ADSHANDLE for AdsCreateIndex61. - ADSHANDLE conn_h = 0; - s.registry.for_each_handle([&](Handle h, HandleKind k, void* p) { - if (k != HandleKind::Connection) return; - if (static_cast(p) == c) conn_h = to_ads_handle(h); - }); - if (conn_h == 0) return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); - - // M13.1 -- validate table name is not a SELECT result. - // INDEX ON (SELECT ...) would open the source table file instead - // of using the materialized cursor → corrupts the source indexes. - // Enforce that table name is a simple identifier/filename. - const auto& tname = ci.value().table; - if (tname.empty() || tname[0] == '(' || tname.find("SELECT") != std::string::npos) { - return fail(openads::AE_PARSE_ERROR, - "INDEX ON requires a table name, not a SELECT result; " - "use SELECT ... ORDER BY/DISTINCT/LIMIT to materialize first"); - } - - std::vector name_buf(ci.value().table.size() + 1, 0); - std::memcpy(name_buf.data(), ci.value().table.data(), - ci.value().table.size()); - ADSHANDLE hTable = 0; - if (auto rc = AdsOpenTable(conn_h, name_buf.data(), name_buf.data(), - ADS_CDX, 1, 1, 0, 1, &hTable); - rc != openads::AE_SUCCESS) { - return rc; - } - - // CREATE INDEX writes a structural .adi sidecar named after - // the table's stem so AdsOpenTable auto-attaches it next open. - namespace fs = std::filesystem; - fs::path tbl_path(c->data_dir()); - tbl_path /= ci.value().table; - if (!tbl_path.has_extension()) tbl_path.replace_extension(".dbf"); - fs::path bag = tbl_path; - bag.replace_extension(".cdx"); - - std::vector bag_buf(bag.string().size() + 1, 0); - std::memcpy(bag_buf.data(), bag.string().data(), - bag.string().size()); - std::vector tag_buf(ci.value().tag.size() + 1, 0); - std::memcpy(tag_buf.data(), ci.value().tag.data(), - ci.value().tag.size()); - std::vector expr_buf(ci.value().expression.size() + 1, 0); - std::memcpy(expr_buf.data(), ci.value().expression.data(), - ci.value().expression.size()); - UNSIGNED32 opts = 0; - // Re-encode for AdsCreateIndex61's decode. That decode treats a - // .cdx (compound) tag as descending only when BOTH the compound and - // descending bits are set (the two RDD clients disagree on which bit - // is which -- see the decode comment there), so a descending index - // must carry ADS_COMPOUND | ADS_DESCENDING (0x0A), not 0x08 alone. - if (ci.value().unique) opts |= ADS_UNIQUE; - if (ci.value().descending) opts |= (ADS_COMPOUND | ADS_DESCENDING); - ADSHANDLE hIdx = 0; - UNSIGNED32 rc = AdsCreateIndex61( - hTable, bag_buf.data(), tag_buf.data(), - expr_buf.data(), nullptr, nullptr, - opts, 512, &hIdx); - AdsCloseTable(hTable); - if (rc != openads::AE_SUCCESS) return rc; - *phCursor = 0; - return ok(); - } - - // `CREATE DATABASE "path" [PASSWORD ... DESCRIPTION ... ENCRYPT ...]` - if (openads::sql::sql_is_create_database(sql)) { - auto cd = openads::sql::parse_create_database(sql); - if (!cd) return fail(cd.error()); - namespace fs = std::filesystem; - std::string path = cd.value().path; - if (fs::path(path).is_relative()) - path = (fs::path(c->data_dir()) / path).string(); - auto r = openads::engine::DataDict::create(path); - if (!r) return fail(r.error()); - if (!cd.value().password.empty()) - r.value().set_db_property("prop_1101", cd.value().password); - if (!cd.value().description.empty()) - r.value().set_db_property("prop_1", cd.value().description); - *phCursor = 0; - return ok(); - } - - // `GRANT right [("col")] ON object TO principal` - if (openads::sql::sql_is_grant(sql)) { - auto gs = openads::sql::parse_grant(sql); - if (!gs) return fail(gs.error()); - if (c->has_dd()) { - auto* dd = c->dd(); - const auto& g = gs.value(); - // Map right name → bitmask. Grants accumulate (OR into existing). - using DD = openads::engine::DataDict; - uint32_t new_bits = 0; - std::string r = g.right; - for (auto& ch : r) - ch = static_cast(std::toupper(static_cast(ch))); - if (r == "ALL") new_bits = DD::DD_PERM_FULL; - else if (r == "SELECT") new_bits = DD::DD_PERM_SELECT; - else if (r == "INSERT") new_bits = DD::DD_PERM_INSERT; - else if (r == "UPDATE") new_bits = DD::DD_PERM_UPDATE; - else if (r == "DELETE") new_bits = DD::DD_PERM_DELETE; - else if (r == "EXECUTE") new_bits = DD::DD_PERM_EXECUTE; - else if (r == "REFERENCE" || r == "REFERENCES") - new_bits = DD::DD_PERM_REFERENCE; - else new_bits = DD::DD_PERM_FULL; // unknown → full - // Determine object type from the DD. - std::string obj_type = "Table"; - if (dd->has_proc(g.object)) obj_type = "StoredProc"; - else if (dd->has_function(g.object)) obj_type = "Function"; - else if (dd->has_view(g.object)) obj_type = "View"; - // Accumulate with existing grant for same grantee+object. - uint32_t cur_bits = 0; - for (const auto& pe : dd->permissions()) - if (pe.object_name == g.object && pe.grantee == g.principal) - cur_bits |= pe.bitmask; - dd->grant_permission(obj_type, g.object, g.principal, cur_bits | new_bits); - } - *phCursor = 0; - return ok(); - } - - // `REVOKE right [("col")] ON object FROM principal` - if (openads::sql::sql_is_revoke(sql)) { - auto gs = openads::sql::parse_revoke(sql); - if (!gs) return fail(gs.error()); - if (c->has_dd()) { - auto* dd = c->dd(); - const auto& g = gs.value(); - using DD = openads::engine::DataDict; - uint32_t revoke_bits = DD::DD_PERM_FULL; - std::string r = g.right; - for (auto& ch : r) - ch = static_cast(std::toupper(static_cast(ch))); - if (r == "SELECT") revoke_bits = DD::DD_PERM_SELECT; - else if (r == "INSERT") revoke_bits = DD::DD_PERM_INSERT; - else if (r == "UPDATE") revoke_bits = DD::DD_PERM_UPDATE; - else if (r == "DELETE") revoke_bits = DD::DD_PERM_DELETE; - else if (r == "EXECUTE") revoke_bits = DD::DD_PERM_EXECUTE; - // Compute new bitmask = current & ~revoke_bits. - uint32_t cur_bits = 0; - std::string obj_type = "Table"; - for (const auto& pe : dd->permissions()) { - if (pe.object_name == g.object && pe.grantee == g.principal) { - cur_bits |= pe.bitmask; - obj_type = pe.object_type; - } - } - dd->grant_permission(obj_type, g.object, g.principal, - cur_bits & ~revoke_bits); - } - *phCursor = 0; - return ok(); - } - - // M11.4 -- `CREATE PROCEDURE AS '::'`. - // Loads the DLL, resolves the symbol, registers the proc on the - // connection. Returns no cursor. - if (openads::sql::sql_is_create_procedure(sql)) { - auto& s = state(); - std::lock_guard lk(s.mu); - auto cp = openads::sql::parse_create_procedure(sql); - if (!cp) return fail(cp.error()); - auto rr = c->register_procedure(cp.value().name, - cp.value().dll_path, - cp.value().symbol); - if (!rr) return fail(rr.error()); - *phCursor = 0; - return ok(); - } - - // M11.4 -- `EXECUTE PROCEDURE (, ...)`. Built-in sp_* names - // are dispatched directly to DataDict operations; others call the - // DLL entry point registered via CREATE PROCEDURE. - if (openads::sql::sql_is_execute_procedure(sql)) { - auto& s = state(); - auto ep = openads::sql::parse_execute_procedure(sql); - if (!ep) return fail(ep.error()); - // Check for sp_* built-in first. - std::string uname = ep.value().name; - for (auto& ch : uname) ch = static_cast( - std::toupper(static_cast(ch))); - if (uname.size() > 3 && uname[0]=='S' && uname[1]=='P' && uname[2]=='_') { - // Cursor-producing built-ins run WITHOUT s.mu: sp_WaitForEvent - // blocks until another connection's sp_SignalEvent -- which - // needs this same mutex -- fires or the timeout lapses. - std::optional> spt; - if (dispatch_sp_builtin_cursor(c, uname, ep.value().args, &spt)) { - if (!spt->has_value()) return fail(spt->error()); - std::lock_guard lk(s.mu); - auto th = c->adopt_table(std::move(*spt).value(), - "#" + ep.value().name); - if (!th) return fail(th.error()); - openads::engine::Table* tbl = c->lookup_table(th.value()); - if (!tbl) return fail(openads::AE_INTERNAL_ERROR, "sp adopt"); - ADSHANDLE gh = to_ads_handle(s.registry.register_object( - HandleKind::Table, tbl)); - *phCursor = gh; - return ok(); - } - std::lock_guard lk(s.mu); - UNSIGNED32 brc = ok(); - if (dispatch_sp_builtin(c, uname, ep.value().args, &brc)) { - *phCursor = 0; - return brc; - } - } - // RCB 07/17/2026 (S2): DD SQL-script stored procedures run through - // the script engine (docs/script-engine.md §4.3). This is the path - // that did not exist at all -- script procs previously fell through - // to "procedure not registered". Native (AEP DLL) procs still take - // the registered-fn path below. - if (c->has_dd()) { - auto* dd = c->dd(); - const openads::engine::DataDict::ProcEntry* pe = nullptr; - if (dd != nullptr) { - std::string up = ep.value().name; - for (auto& ch : up) ch = static_cast( - std::toupper(static_cast(ch))); - for (const auto& kv : dd->procs()) { - std::string k = kv.first; - for (auto& ch : k) ch = static_cast( - std::toupper(static_cast(ch))); - if (k == up) { pe = &kv.second; break; } - } - } - if (pe != nullptr && !pe->procedure.empty()) { - std::lock_guard lk2(s.mu); - auto outn = scriptbridge::run_dd_procedure( - c, hStatement, *pe, ep.value().args); - if (!outn) return fail(outn.error()); - if (outn.value().empty()) { // no output params - *phCursor = 0; - return ok(); - } - std::string sel = "SELECT * FROM [" + outn.value() + "]"; - std::vector sb(sel.size() + 1); - std::memcpy(sb.data(), sel.c_str(), sel.size() + 1); - return AdsExecuteSQLDirect(hStatement, sb.data(), phCursor); - } - } - std::lock_guard lk(s.mu); - std::string packed; - for (std::size_t i = 0; i < ep.value().args.size(); ++i) { - if (i != 0) packed.push_back('\x1f'); - packed.append(ep.value().args[i].text); - } - auto out = c->execute_procedure(ep.value().name, packed); - if (!out) return fail(out.error()); - std::string& s_out = out.value(); - - // 1-row temp with one C(255) column "RESULT", via the shared ADT - // temp helper (registers delete-on-close; the DBF this used to - // write leaked). - std::vector rcols; - rcols.push_back({"RESULT", 'C', 255, 0}); - std::vector rrow(255, ' '); - std::memcpy(rrow.data(), s_out.data(), - std::min(s_out.size(), 255)); - return materialise_temp_adt(c, "_call_", rcols, rrow, 255, phCursor); - } - - // S4 -- MERGE, UPSERT form (master_merge.htm): probe the ON condition; - // matched rows take the UPDATE specification with SET expressions - // evaluated through the script engine (the matched row's columns are - // bound as parameters, so `Sequence = Sequence + 1` and IIF(...) work); - // no match desugars to a plain INSERT INTO so defaults / RI / triggers - // ride the normal INSERT path. - if (openads::sql::sql_is_merge(sql)) { - auto mg = openads::sql::parse_merge(sql); - if (!mg) return fail(mg.error()); - auto th = c->open_table(mg.value().table, - stmt_table_type(*it->second), - stmt_open_mode(*it->second, true), - stmt_locking_mode(*it->second)); - if (!th) return fail(th.error()); - openads::engine::Table* tbl = c->lookup_table(th.value()); - if (!tbl) return fail(openads::AE_INTERNAL_ERROR, "post-open"); - sql_dml_hold_write_lock(tbl); - // Keep the structural bag current on every SQL write (see - // DmlProductionIndexGuard above); no-op when no .cdx/.adi - // exists for this table. - DmlProductionIndexGuard dml_idx_guard; - dml_bind_production_index(c, th.value(), dml_idx_guard); - - // Compile the ON tree with the same closure shape the UPDATE - // branch below uses (helper extraction still deferred). - using Pred = std::function; - std::function( - const openads::sql::WhereExpr&)> compile; - compile = [&](const openads::sql::WhereExpr& node) - -> openads::util::Result { - using Kind = openads::sql::WhereExpr::Kind; - if (node.kind == Kind::And || node.kind == Kind::Or) { - std::vector ks; - for (auto& cn : node.children) { - auto r = compile(*cn); - if (!r) return r.error(); - ks.push_back(std::move(r).value()); - } - bool is_and = node.kind == Kind::And; - return Pred{[ks = std::move(ks), is_and] - (openads::engine::Table& t) { - for (auto& k : ks) { - if (k(t) != is_and) return !is_and; - } - return is_and; - }}; - } - if (node.kind == Kind::Not) { - auto inner = compile(*node.child); - if (!inner) return inner.error(); - return Pred{[p = std::move(inner).value()] - (openads::engine::Table& t) { return !p(t); }}; - } - const auto& w = node.cmp; - std::int32_t fidx = tbl->field_index(w.column); - if (fidx < 0) { - return openads::util::Error{ - openads::AE_COLUMN_NOT_FOUND, 0, w.column.c_str(), ""}; - } - std::uint16_t fi = static_cast(fidx); - openads::sql::WhereOp op = w.op; - std::string lit = w.literal; - bool is_num = w.is_numeric; - double num = w.number; - openads::sql::WhereFn lhs_fn = w.lhs_fn; - bool ci_f = field_is_cichar(*tbl, fi); - return Pred{[fi, op, lit, is_num, num, lhs_fn, ci_f] - (openads::engine::Table& t) { - auto v = t.read_field(fi); - if (!v) return false; - if (op == openads::sql::WhereOp::IsNull || - op == openads::sql::WhereOp::IsNotNull) { - bool null_ish = t.is_field_empty(fi); - return op == openads::sql::WhereOp::IsNull - ? null_ish : !null_ish; - } - int cmp = 0; - if (is_num) { - double d = v.value().as_double; - if (d < num) cmp = -1; - else if (d > num) cmp = 1; - } else { - std::string lv = apply_where_fn(v.value().as_string, - lhs_fn); - while (!lv.empty() && lv.back() == ' ') lv.pop_back(); - std::string rv2 = lit; - while (!rv2.empty() && rv2.back() == ' ') - rv2.pop_back(); - cmp = where_str_cmp(lv, rv2, ci_f); - } - switch (op) { - case openads::sql::WhereOp::Eq: return cmp == 0; - case openads::sql::WhereOp::Ne: return cmp != 0; - case openads::sql::WhereOp::Lt: return cmp < 0; - case openads::sql::WhereOp::Gt: return cmp > 0; - case openads::sql::WhereOp::Le: return cmp <= 0; - case openads::sql::WhereOp::Ge: return cmp >= 0; - default: return false; - } - return false; - }}; - }; - auto mpred = compile(*mg.value().on); - if (!mpred) { - c->close_table(th.value()); - return fail(mpred.error()); - } - auto pred = std::move(mpred).value(); - - std::vector matches; - std::uint32_t rcount = tbl->record_count(); - for (std::uint32_t r = 1; r <= rcount; ++r) { - if (auto g = tbl->goto_record(r); !g) continue; - if (!tbl->show_deleted_records() && - tbl->is_deleted()) continue; - if (pred(*tbl)) matches.push_back(r); - } - - // Evaluate one captured expression text through the script engine; - // bind_row = true binds the table's current row columns. - auto eval_expr = [&](const std::string& text, bool bind_row) - -> openads::util::Result { - auto pr = scriptbridge::compiled("RETURN " + text + ";"); - if (!pr) return pr.error(); - scriptbridge::AbiBridge br(c, hStatement); - openads::script::Executor ex2(&br); - ex2.set_user(c->username()); - if (bind_row) scriptbridge::bind_row_params(ex2, *tbl); - auto rr = ex2.run(*pr.value()); - if (!rr) return rr.error(); - if (!rr.value().returned) - return openads::script::Value::null(); - return std::move(rr.value().return_value); - }; - - if (!matches.empty()) { - if (!mg.value().has_update) { // spec: no action on match - c->close_table(th.value()); - *phCursor = 0; - return ok(); - } - struct MAssn { - std::uint16_t fi; - const openads::sql::MergeSet* ms; - }; - std::vector massns; - massns.reserve(mg.value().sets.size()); - for (const auto& msitem : mg.value().sets) { - std::int32_t fidx = tbl->field_index(msitem.column); - if (fidx < 0) { - c->close_table(th.value()); - return fail(openads::AE_COLUMN_NOT_FOUND, - msitem.column.c_str()); - } - massns.push_back( - {static_cast(fidx), &msitem}); - } - // Triggers: same UPDATE sequence as the UPDATE branch below. - std::string mg_alias = ri_alias_for_path(c, tbl->path()); - Handle mg_hConn = - !mg_alias.empty() ? handle_for_conn(c) : Handle{0}; - bool mg_instead = false; - TrigError_ mg_terr; - if (mg_hConn) { - mg_instead = fire_triggers_(mg_hConn, c, mg_alias, 2u, - 2u /*INSTEAD_OF*/, nullptr, - nullptr, &mg_terr); - if (!mg_instead) - fire_triggers_(mg_hConn, c, mg_alias, 2u, - 1u /*BEFORE*/, nullptr, nullptr, - &mg_terr); - if (mg_terr.has_error) { - c->close_table(th.value()); - return fail(static_cast(mg_terr.errno_val), - mg_terr.message.c_str()); - } - } - if (!mg_instead) { - for (std::uint32_t r : matches) { - if (auto g = tbl->goto_record(r); !g) continue; - for (const auto& a : massns) { - auto ev = eval_expr(a.ms->expr_text, true); - if (!ev) { - c->close_table(th.value()); - return fail(ev.error()); - } - const auto& rv = ev.value(); - using ST = openads::script::Type; - openads::util::Result wr = [&]() - -> openads::util::Result { - if (rv.is_null) - return tbl->set_field_null(a.fi); - switch (rv.type) { - case ST::Integer: - return tbl->set_field(a.fi, - static_cast(rv.i)); - case ST::Double: - return tbl->set_field(a.fi, rv.d); - case ST::Logical: - return tbl->set_field(a.fi, - std::string(rv.i ? "T" : "F")); - case ST::Date: { - int y2, m2, d2; - openads::script::ymd_from_jdn( - rv.i, y2, m2, d2); - char db[16]; - std::snprintf(db, sizeof(db), - "%04d%02d%02d", y2, m2, d2); - return tbl->set_field(a.fi, - std::string(db)); - } - case ST::Timestamp: { - std::int64_t jdn = rv.i / 86400000; - std::int64_t ms2 = rv.i % 86400000; - int y2, m2, d2; - openads::script::ymd_from_jdn( - jdn, y2, m2, d2); - char db[24]; - std::snprintf(db, sizeof(db), - "%04d%02d%02d%02d%02d%02d", - y2, m2, d2, - static_cast(ms2 / 3600000), - static_cast((ms2 / 60000) % - 60), - static_cast((ms2 / 1000) % - 60)); - return tbl->set_field(a.fi, - std::string(db)); - } - default: - return tbl->set_field(a.fi, rv.s); - } - }(); - if (!wr) { - c->close_table(th.value()); - return fail(wr.error()); - } - } - } - if (auto fl = tbl->flush(); !fl) { - c->close_table(th.value()); - return fail(fl.error()); - } - } - if (!mg_instead && mg_hConn) { - fire_triggers_(mg_hConn, c, mg_alias, 2u, 4u /*AFTER*/, - tbl, nullptr, &mg_terr); - if (mg_terr.has_error) { - c->close_table(th.value()); - return fail(static_cast(mg_terr.errno_val), - mg_terr.message.c_str()); - } - } - c->close_table(th.value()); - *phCursor = 0; - return ok(); - } - - // No match -- INSERT specification (if present). Values are - // evaluated (no row context) and rendered as literals into a - // plain INSERT INTO statement. - if (!mg.value().has_insert) { - c->close_table(th.value()); - *phCursor = 0; - return ok(); - } - std::vector lits; - lits.reserve(mg.value().insert_value_texts.size()); - for (const auto& vt : mg.value().insert_value_texts) { - auto ev = eval_expr(vt, false); - if (!ev) { - c->close_table(th.value()); - return fail(ev.error()); - } - const auto& rv = ev.value(); - using ST = openads::script::Type; - if (rv.is_null) { - lits.push_back("NULL"); - } else if (rv.type == ST::Integer) { - lits.push_back(std::to_string(rv.i)); - } else if (rv.type == ST::Double) { - char nb2[40]; - std::snprintf(nb2, sizeof(nb2), "%.10g", rv.d); - lits.push_back(nb2); - } else if (rv.type == ST::Logical) { - lits.push_back(rv.i ? "'T'" : "'F'"); - } else { - // Char / Date / Timestamp: quoted display text with '' - // escaping (dates render via to_display; the INSERT - // writer stores text through the field encoder). - std::string s2 = openads::script::to_display(rv); - std::string q = "'"; - for (char ch : s2) { - q.push_back(ch); - if (ch == '\'') q.push_back('\''); - } - q.push_back('\''); - lits.push_back(std::move(q)); - } - } - c->close_table(th.value()); - std::string ins = "INSERT INTO " + mg.value().table; - if (!mg.value().insert_columns.empty()) { - ins += " ("; - for (std::size_t i2 = 0; i2 < mg.value().insert_columns.size(); - ++i2) { - if (i2) ins += ", "; - ins += mg.value().insert_columns[i2]; - } - ins += ")"; - } - ins += " VALUES ("; - for (std::size_t i2 = 0; i2 < lits.size(); ++i2) { - if (i2) ins += ", "; - ins += lits[i2]; - } - ins += ")"; - std::vector ibuf(ins.size() + 1); - std::memcpy(ibuf.data(), ins.c_str(), ins.size() + 1); - ADSHANDLE hc2 = 0; - UNSIGNED32 rc2 = AdsExecuteSQLDirect(hStatement, ibuf.data(), &hc2); - if (rc2 != 0) return rc2; - if (hc2 != 0) AdsCloseTable(hc2); - *phCursor = 0; - return ok(); - } - - if (openads::sql::sql_is_update(sql)) { - auto upd = openads::sql::parse_update(sql); - if (!upd) return fail(upd.error()); - auto th = c->open_table(upd.value().table, - stmt_table_type(*it->second), - stmt_open_mode(*it->second, true), - stmt_locking_mode(*it->second)); - if (!th) return fail(th.error()); - openads::engine::Table* tbl = c->lookup_table(th.value()); - if (!tbl) return fail(openads::AE_INTERNAL_ERROR, "post-open"); - sql_dml_hold_write_lock(tbl); - // Keep the structural bag current on every SQL write (see - // DmlProductionIndexGuard above); no-op when no .cdx/.adi - // exists for this table. - DmlProductionIndexGuard dml_idx_guard; - dml_bind_production_index(c, th.value(), dml_idx_guard); - // Pre-resolve assignments so a typo surfaces before any write. - struct Assn { - std::uint16_t field_index; - openads::sql::InsertLiteral value; - }; - std::vector assns; - assns.reserve(upd.value().assignments.size()); - for (const auto& a : upd.value().assignments) { - std::int32_t fidx = tbl->field_index(a.column); - if (fidx < 0) { - return fail(openads::AE_COLUMN_NOT_FOUND, a.column.c_str()); - } - assns.push_back({static_cast(fidx), a.value}); - } - // S4 -- SET = NULL on a DD non-nullable column fails 5147 - // before any write (SAP: "cannot be updated to a NULL value due - // to a 'not NULL' constraint"). - { - auto upd_rules = dd_field_rules_for(c, upd.value().table); - for (const auto& a : upd.value().assignments) { - if (!a.value.is_null || upd_rules.empty()) continue; - std::string key = a.column; - for (auto& ch : key) - ch = static_cast(std::tolower( - static_cast(ch))); - auto rit = upd_rules.find(key); - if (rit != upd_rules.end() && rit->second.required) { - std::string msg = - "constraint violation while updating column \"" + - a.column + "\": the column cannot be updated to a " - "NULL value due to a 'not NULL' constraint"; - return fail(openads::AE_COLUMN_CANNOT_BE_NULL, - msg.c_str()); - } - } - } - // Walk every live record, run optional WHERE via the same - // engine filter machinery, and apply the assignments inline. - if (upd.value().where) { - // Leverage the same compile path as SELECT -- but inline - // a smaller version that walks the AST recursively. Reuse - // is fine: same structure, no SQL features missing. - // (Helper extraction is deferred until UPDATE picks up - // CONTAINS or AND/OR -- for now the closures below fully - // cover the tree.) - using Pred = std::function; - std::function( - const openads::sql::WhereExpr&)> compile; - compile = [&](const openads::sql::WhereExpr& node) - -> openads::util::Result { - using Kind = openads::sql::WhereExpr::Kind; - if (node.kind == Kind::And) { - std::vector ks; - for (auto& cn : node.children) { - auto r = compile(*cn); - if (!r) return r.error(); - ks.push_back(std::move(r).value()); - } - return Pred{[ks = std::move(ks)](openads::engine::Table& t) { - for (auto& k : ks) if (!k(t)) return false; - return true; - }}; - } - if (node.kind == Kind::Or) { - std::vector ks; - for (auto& cn : node.children) { - auto r = compile(*cn); - if (!r) return r.error(); - ks.push_back(std::move(r).value()); - } - return Pred{[ks = std::move(ks)](openads::engine::Table& t) { - for (auto& k : ks) if (k(t)) return true; - return false; - }}; - } - if (node.kind == Kind::Not) { - auto inner = compile(*node.child); - if (!inner) return inner.error(); - return Pred{[p = std::move(inner).value()] - (openads::engine::Table& t){return !p(t);}}; - } - const auto& w = node.cmp; - std::int32_t fidx = tbl->field_index(w.column); - if (fidx < 0) { - return openads::util::Error{ - openads::AE_COLUMN_NOT_FOUND, 0, - w.column.c_str(), ""}; - } - std::uint16_t fi = static_cast(fidx); - openads::sql::WhereOp op = w.op; - std::string lit = w.literal; - bool is_num = w.is_numeric; - double num = w.number; - openads::sql::WhereFn lhs_fn = w.lhs_fn; - bool ci_f = field_is_cichar(*tbl, fi); - return Pred{[fi, op, lit, is_num, num, lhs_fn, ci_f] - (openads::engine::Table& t) { - auto v = t.read_field(fi); - if (!v) return false; - if (op == openads::sql::WhereOp::IsNull || - op == openads::sql::WhereOp::IsNotNull) { - bool null_ish = t.is_field_empty(fi); - return op == openads::sql::WhereOp::IsNull - ? null_ish : !null_ish; - } - int cmp = 0; - if (is_num) { - double d = v.value().as_double; - if (d < num) cmp = -1; - else if (d > num) cmp = 1; - } else { - cmp = where_str_cmp( - apply_where_fn(v.value().as_string, lhs_fn), - lit, ci_f); - } - switch (op) { - case openads::sql::WhereOp::Eq: return cmp == 0; - case openads::sql::WhereOp::Ne: return cmp != 0; - case openads::sql::WhereOp::Lt: return cmp < 0; - case openads::sql::WhereOp::Gt: return cmp > 0; - case openads::sql::WhereOp::Le: return cmp <= 0; - case openads::sql::WhereOp::Ge: return cmp >= 0; - case openads::sql::WhereOp::Contains: return false; - default: return false; - } - return false; - }}; - }; - auto compiled = compile(*upd.value().where); - if (!compiled) return fail(compiled.error()); - tbl->set_filter(std::move(compiled).value()); - } - // Triggers are ROW-LEVEL (SAP semantics): each matching row fires - // INSTEAD OF / BEFORE with its own __old (current values) and - // __new (values after the SET list) images; the engine write is - // applied only when no INSTEAD OF ran; AFTER fires with the same - // images after the write. Failures propagate (probe Q6): BEFORE/ - // INSTEAD OF failure blocks the write, AFTER failure keeps it. - std::string upd_alias = ri_alias_for_path(c, tbl->path()); - Handle upd_hConn = !upd_alias.empty() ? handle_for_conn(c) : Handle{0}; - TrigError_ upd_terr; - { - std::uint32_t rcount = tbl->record_count(); - for (std::uint32_t r = 1; r <= rcount; ++r) { - if (auto g = tbl->goto_record(r); !g) continue; - if (!tbl->show_deleted_records() && - tbl->is_deleted()) continue; - if (!tbl->passes_filter()) continue; - - // Evaluate the SET list once for this row (expressions - // bind the row's CURRENT -- pre-write -- column values). - std::vector assn_vals; - assn_vals.reserve(assns.size()); - for (const auto& a : assns) { - if (a.value.is_expr) { - auto pr = scriptbridge::compiled( - "RETURN " + a.value.text + ";"); - if (!pr) return fail(pr.error()); - scriptbridge::AbiBridge br(c, hStatement); - openads::script::Executor ex2(&br); - ex2.set_user(c->username()); - scriptbridge::bind_row_params(ex2, *tbl); - auto rr = ex2.run(*pr.value()); - if (!rr) return fail(rr.error()); - assn_vals.push_back( - rr.value().returned - ? std::move(rr.value().return_value) - : openads::script::Value::null()); - } else if (a.value.is_null) { - assn_vals.push_back(openads::script::Value::null()); - } else if (a.value.is_numeric) { - assn_vals.push_back( - openads::script::Value::real(a.value.number)); - } else { - assn_vals.push_back( - openads::script::Value::character(a.value.text)); - } - } - - // Row images for the firings. - TrigFieldMap_ old_img, new_img; - bool row_instead = false; - if (upd_hConn) { - trig_collect_row_(tbl, old_img); - new_img = old_img; - for (std::size_t i2 = 0; i2 < assns.size(); ++i2) { - const auto& fd2 = - tbl->field_descriptor(assns[i2].field_index); - std::string nm2 = fd2.name; - for (auto& ch : nm2) - ch = static_cast(std::tolower( - static_cast(ch))); - std::string txt = - assn_vals[i2].is_null - ? std::string() - : openads::script::to_display(assn_vals[i2]); - new_img[nm2] = TrigField_{std::move(txt), - trig_type_char_(fd2.type)}; - } - row_instead = fire_triggers_( - upd_hConn, c, upd_alias, 2u, 2u /*INSTEAD_OF*/, - nullptr, nullptr, &upd_terr, &new_img, &old_img); - if (!row_instead) - fire_triggers_(upd_hConn, c, upd_alias, 2u, - 1u /*BEFORE*/, nullptr, nullptr, - &upd_terr, &new_img, &old_img); - if (upd_terr.has_error) { - tbl->clear_filter(); - c->close_table(th.value()); - return fail(static_cast(upd_terr.errno_val), - upd_terr.message.c_str()); - } - // The trigger body may have repositioned the table - // (its statements re-enter the engine); restore. - if (auto g2 = tbl->goto_record(r); !g2) continue; - } - if (!row_instead) { - for (std::size_t i2 = 0; i2 < assns.size(); ++i2) { - auto wr = scriptbridge::write_script_value( - *tbl, assns[i2].field_index, assn_vals[i2]); - if (!wr) return fail(wr.error()); - } - if (upd_hConn) { - // AFTER runs once the write has landed: settle the - // coalesced dirty record so a failing trigger still - // leaves the updated row on disk (write persists). - if (auto cd = tbl->commit_dirty_record(); !cd) { - tbl->clear_filter(); - c->close_table(th.value()); - return fail(cd.error()); - } - fire_triggers_(upd_hConn, c, upd_alias, 2u, - 4u /*AFTER*/, tbl, nullptr, - &upd_terr, &new_img, &old_img); - if (upd_terr.has_error) { - tbl->clear_filter(); - c->close_table(th.value()); - return fail( - static_cast(upd_terr.errno_val), - upd_terr.message.c_str()); - } - if (auto g2 = tbl->goto_record(r); !g2) continue; - } - } - } - if (auto fl = tbl->flush(); !fl) return fail(fl.error()); - } - tbl->clear_filter(); - c->close_table(th.value()); - *phCursor = 0; - return ok(); - } - - if (openads::sql::sql_is_delete(sql)) { - auto del = openads::sql::parse_delete(sql); - if (!del) return fail(del.error()); - auto th = c->open_table(del.value().table, - stmt_table_type(*it->second), - stmt_open_mode(*it->second, true), - stmt_locking_mode(*it->second)); - if (!th) return fail(th.error()); - openads::engine::Table* tbl = c->lookup_table(th.value()); - if (!tbl) return fail(openads::AE_INTERNAL_ERROR, "post-open"); - sql_dml_hold_write_lock(tbl); - // Keep the structural bag current on every SQL write (see - // DmlProductionIndexGuard above); no-op when no .cdx/.adi - // exists for this table. - DmlProductionIndexGuard dml_idx_guard; - dml_bind_production_index(c, th.value(), dml_idx_guard); - // Reuse the WHERE filter machinery for SELECT: it's already - // wired and the predicate semantics match exactly. - if (del.value().where) { - // The compile lambda from the SELECT branch lives below; - // copy a minimal bool-tree walker inline so DELETE isn't - // forced to call into SELECT's path. - using Pred = std::function; - std::function( - const openads::sql::WhereExpr&)> compile; - compile = [&](const openads::sql::WhereExpr& node) - -> openads::util::Result { - using Kind = openads::sql::WhereExpr::Kind; - if (node.kind == Kind::And) { - std::vector ks; - for (auto& cn : node.children) { - auto r = compile(*cn); - if (!r) return r.error(); - ks.push_back(std::move(r).value()); - } - return Pred{[ks = std::move(ks)](openads::engine::Table& t) { - for (auto& k : ks) if (!k(t)) return false; - return true; - }}; - } - if (node.kind == Kind::Or) { - std::vector ks; - for (auto& cn : node.children) { - auto r = compile(*cn); - if (!r) return r.error(); - ks.push_back(std::move(r).value()); - } - return Pred{[ks = std::move(ks)](openads::engine::Table& t) { - for (auto& k : ks) if (k(t)) return true; - return false; - }}; - } - if (node.kind == Kind::Not) { - auto inner = compile(*node.child); - if (!inner) return inner.error(); - return Pred{[p = std::move(inner).value()] - (openads::engine::Table& t){return !p(t);}}; - } - const auto& w = node.cmp; - std::int32_t fidx = tbl->field_index(w.column); - if (fidx < 0) { - return openads::util::Error{ - openads::AE_COLUMN_NOT_FOUND, 0, - w.column.c_str(), ""}; - } - std::uint16_t fi = static_cast(fidx); - openads::sql::WhereOp op = w.op; - std::string lit = w.literal; - bool is_num = w.is_numeric; - double num = w.number; - openads::sql::WhereFn lhs_fn = w.lhs_fn; - bool ci_f = field_is_cichar(*tbl, fi); - return Pred{[fi, op, lit, is_num, num, lhs_fn, ci_f] - (openads::engine::Table& t) { - auto v = t.read_field(fi); - if (!v) return false; - if (op == openads::sql::WhereOp::IsNull || - op == openads::sql::WhereOp::IsNotNull) { - bool null_ish = t.is_field_empty(fi); - return op == openads::sql::WhereOp::IsNull - ? null_ish : !null_ish; - } - int cmp = 0; - if (is_num) { - double d = v.value().as_double; - if (d < num) cmp = -1; - else if (d > num) cmp = 1; - } else { - cmp = where_str_cmp( - apply_where_fn(v.value().as_string, lhs_fn), - lit, ci_f); - } - switch (op) { - case openads::sql::WhereOp::Eq: return cmp == 0; - case openads::sql::WhereOp::Ne: return cmp != 0; - case openads::sql::WhereOp::Lt: return cmp < 0; - case openads::sql::WhereOp::Gt: return cmp > 0; - case openads::sql::WhereOp::Le: return cmp <= 0; - case openads::sql::WhereOp::Ge: return cmp >= 0; - case openads::sql::WhereOp::Contains: return false; - default: return false; - } - return false; - }}; - }; - auto compiled = compile(*del.value().where); - if (!compiled) return fail(compiled.error()); - tbl->set_filter(std::move(compiled).value()); - } - // Triggers are ROW-LEVEL (SAP semantics): each matching row fires - // INSTEAD OF / BEFORE / AFTER with that row's __old image (the - // values being deleted -- pmsys's Delete AuditLog is AFTER DELETE - // and reads `SELECT propertyid FROM __old`). INSTEAD OF - // supersedes the engine's own delete. Failures propagate - // (probe Q6 semantics). - std::string del_alias = ri_alias_for_path(c, tbl->path()); - Handle del_hConn = !del_alias.empty() ? handle_for_conn(c) : Handle{0}; - TrigError_ del_terr; - { - std::uint32_t rcount = tbl->record_count(); - for (std::uint32_t r = 1; r <= rcount; ++r) { - if (auto g = tbl->goto_record(r); !g) continue; - if (!tbl->show_deleted_records() && - tbl->is_deleted()) continue; - if (!tbl->passes_filter()) continue; - TrigFieldMap_ old_img; - bool row_instead = false; - if (del_hConn) { - trig_collect_row_(tbl, old_img); - row_instead = fire_triggers_( - del_hConn, c, del_alias, 3u, 2u /*INSTEAD_OF*/, - nullptr, nullptr, &del_terr, nullptr, &old_img); - if (!row_instead) - fire_triggers_(del_hConn, c, del_alias, 3u, - 1u /*BEFORE*/, nullptr, nullptr, - &del_terr, nullptr, &old_img); - if (del_terr.has_error) { - tbl->clear_filter(); - c->close_table(th.value()); - return fail(static_cast(del_terr.errno_val), - del_terr.message.c_str()); - } - // Trigger bodies may reposition the table; restore. - if (auto g2 = tbl->goto_record(r); !g2) continue; - } - if (!row_instead) { - (void)tbl->mark_deleted(); - if (del_hConn) { - fire_triggers_(del_hConn, c, del_alias, 3u, - 4u /*AFTER*/, nullptr, nullptr, - &del_terr, nullptr, &old_img); - if (del_terr.has_error) { - tbl->clear_filter(); - c->close_table(th.value()); - return fail( - static_cast(del_terr.errno_val), - del_terr.message.c_str()); - } - if (auto g2 = tbl->goto_record(r); !g2) continue; - } - } - } - if (auto fl = tbl->flush(); !fl) return fail(fl.error()); - } - tbl->clear_filter(); - c->close_table(th.value()); - *phCursor = 0; - return ok(); - } - - if (openads::sql::sql_is_insert(sql)) { - auto ins = openads::sql::parse_insert(sql); - if (!ins) return fail(ins.error()); - auto th = c->open_table(ins.value().table, - stmt_table_type(*it->second), - stmt_open_mode(*it->second, true), - stmt_locking_mode(*it->second)); - if (!th) return fail(th.error()); - openads::engine::Table* tbl = c->lookup_table(th.value()); - if (!tbl) return fail(openads::AE_INTERNAL_ERROR, "post-open"); - sql_dml_hold_write_lock(tbl); - // Keep the structural bag current on every SQL write (see - // DmlProductionIndexGuard above); no-op when no .cdx/.adi - // exists for this table. - DmlProductionIndexGuard dml_idx_guard; - dml_bind_production_index(c, th.value(), dml_idx_guard); - - // `INSERT INTO t VALUES (...)` without a column list: SAP binds - // the values positionally in declared-column order (probed). - if (ins.value().columns.empty()) { - const std::uint16_t inf = tbl->field_count(); - ins.value().columns.reserve(inf); - for (std::uint16_t i = 0; i < inf; ++i) - ins.value().columns.push_back(tbl->field_descriptor(i).name); - } - // SAP raises 2129 when the counts differ in either direction - - // exact message probed against ace64.dll. This also guards the - // write loop, which indexes vals[i] by column position. - auto ins_count_ok = - [&](const std::vector& vals) { - return vals.size() == ins.value().columns.size(); - }; - static constexpr const char* k2129 = - "Unequal number of insert columns and insert values or the " - "incorrect number of stored procedure input parameters"; - - // S4 -- DD field constraints (oracle-verified): SAP rejects an - // INSERT that leaves a non-nullable column without a value with - // 5147, checking fields in ordinal order; a missing column with - // a DD default takes the default instead (an EXPLICIT NULL does - // not -- it violates). Applies to the VALUES paths; the - // INSERT...SELECT path doesn't enforce yet. - auto ins_rules = dd_field_rules_for(c, ins.value().table); - auto ins_lower = [](std::string s2) { - for (auto& ch : s2) - ch = static_cast(std::tolower( - static_cast(ch))); - return s2; - }; - auto ins_col_pos = [&](const std::string& fname) -> std::int32_t { - for (std::size_t j = 0; j < ins.value().columns.size(); ++j) { - if (ins_lower(ins.value().columns[j]) == ins_lower(fname)) - return static_cast(j); - } - return -1; - }; - auto null_violation = - [&](const std::vector& vals) - -> std::string { - if (ins_rules.empty()) return {}; - std::uint16_t nf = tbl->field_count(); - for (std::uint16_t i = 0; i < nf; ++i) { - const auto& fd = tbl->field_descriptor(i); - auto rit = ins_rules.find(ins_lower(fd.name)); - if (rit == ins_rules.end() || !rit->second.required) - continue; - std::int32_t cp = ins_col_pos(fd.name); - if (cp >= 0 && static_cast(cp) < vals.size() && - !vals[static_cast(cp)].is_null) - continue; // value supplied - if (cp < 0 && !rit->second.def.empty()) - continue; // default fills it - return fd.name; - } - return {}; - }; - - // M10.41 -- INSERT INTO t (cols) SELECT ...: recursively - // execute the inner SELECT, walk its cursor, append one - // target row per source row mapping the inner cursor's - // projected columns to `ins.columns` positionally. - if (!ins.value().select_sql.empty()) { - std::vector selbuf(ins.value().select_sql.size() + 1); - std::memcpy(selbuf.data(), - ins.value().select_sql.c_str(), - ins.value().select_sql.size() + 1); - ADSHANDLE srcCur = 0; - UNSIGNED32 rrc = - AdsExecuteSQLDirect(hStatement, selbuf.data(), &srcCur); - if (rrc != 0) { - c->close_table(th.value()); - return rrc; - } - auto& s2 = state(); - std::lock_guard lk2(s2.mu); - openads::engine::Table* src = - s2.registry.lookup( - srcCur, HandleKind::Table); - if (!src) { - c->close_table(th.value()); - return fail(openads::AE_INTERNAL_ERROR, - "INSERT...SELECT inner cursor lookup"); - } - // Resolve inner cursor's projected column indices. - const auto* proj = projection_for(srcCur); - std::vector src_cols; - if (proj) { - src_cols = *proj; - } else { - std::uint16_t nf = src->field_count(); - src_cols.reserve(nf); - for (std::uint16_t k = 0; k < nf; ++k) src_cols.push_back(k); - } - if (src_cols.size() != ins.value().columns.size()) { - AdsCloseTable(srcCur); - c->close_table(th.value()); - return fail(openads::AE_PARSE_ERROR, - "INSERT INTO ... SELECT: column count mismatch"); - } - // Pre-resolve target column indices. - std::vector tgt_cols; - tgt_cols.reserve(ins.value().columns.size()); - for (const auto& cn : ins.value().columns) { - std::int32_t fi = tbl->field_index(cn); - if (fi < 0) { - AdsCloseTable(srcCur); - c->close_table(th.value()); - return fail(openads::AE_COLUMN_NOT_FOUND, cn.c_str()); - } - tgt_cols.push_back(static_cast(fi)); - } - // Walk source rows. - std::vector recnos; - if (src->has_recno_sequence()) { - recnos = src->recno_sequence(); - } else { - std::uint32_t rcount = src->record_count(); - for (std::uint32_t r = 1; r <= rcount; ++r) { - if (auto g = src->goto_record(r); !g) continue; - if (!src->show_deleted_records() && - src->is_deleted()) continue; - if (!src->passes_filter()) continue; - recnos.push_back(r); - } - } - for (std::uint32_t r : recnos) { - if (auto g = src->goto_record(r); !g) continue; - if (auto ar = tbl->append_record(); !ar) { - AdsCloseTable(srcCur); - c->close_table(th.value()); - return fail(ar.error()); - } - for (std::size_t i = 0; i < src_cols.size(); ++i) { - auto v = src->read_field(src_cols[i]); - std::string sv = v ? v.value().as_string : std::string(); - auto wr = tbl->set_field(tgt_cols[i], sv); - if (!wr) { - AdsCloseTable(srcCur); - c->close_table(th.value()); - return fail(wr.error()); - } - } - } - if (auto fl = tbl->flush(); !fl) { - AdsCloseTable(srcCur); - c->close_table(th.value()); - return fail(fl.error()); - } - // Fire AFTER INSERT triggers (event_mask=1) for INSERT...SELECT. - // RCB 07/17/2026 (S2): failures propagate (probe Q6). - { - std::string alias = ri_alias_for_path(c, tbl->path()); - if (!alias.empty()) { - Handle hConn = handle_for_conn(c); - if (hConn) { - TrigError_ terr; - fire_triggers_(hConn, c, alias, 1u, 4u /*AFTER*/, - tbl, nullptr, &terr); - if (terr.has_error) { - AdsCloseTable(srcCur); - c->close_table(th.value()); - return fail(static_cast(terr.errno_val), - terr.message.c_str()); - } - } - } - } - AdsCloseTable(srcCur); - c->close_table(th.value()); - *phCursor = 0; - return ok(); - } - - // M10.52 -- multi-row VALUES path. When `rows` is non-empty, - // append + populate one record per tuple; otherwise fall - // back to the single-row `values` path. - auto write_one = [&](const std::vector& - vals) -> openads::util::Result - { - if (!ins_count_ok(vals)) { - return openads::util::Error{2129, 0, k2129, ""}; - } - if (auto r = tbl->append_record(); !r) return r.error(); - for (std::size_t i = 0; i < ins.value().columns.size(); ++i) { - std::int32_t fidx = - tbl->field_index(ins.value().columns[i]); - if (fidx < 0) { - return openads::util::Error{ - openads::AE_COLUMN_NOT_FOUND, 0, - ins.value().columns[i].c_str(), ""}; - } - const auto& v = vals[i]; - if (v.is_expr) { - // S4 -- expression value (User(), Now(), …) evaluated - // through the script engine. - auto pr = scriptbridge::compiled( - "RETURN " + v.text + ";"); - if (!pr) return pr.error(); - scriptbridge::AbiBridge br(c, hStatement); - openads::script::Executor ex2(&br); - ex2.set_user(c->username()); - auto rr = ex2.run(*pr.value()); - if (!rr) return rr.error(); - auto wr = scriptbridge::write_script_value( - *tbl, static_cast(fidx), - rr.value().returned ? rr.value().return_value - : openads::script::Value::null()); - if (!wr) return wr.error(); - } else if (v.is_null) { - auto wr = tbl->set_field_null( - static_cast(fidx)); - if (!wr) return wr.error(); - } else if (v.is_numeric) { - auto wr = tbl->set_field( - static_cast(fidx), v.number); - if (!wr) return wr.error(); - } else { - auto wr = tbl->set_field( - static_cast(fidx), v.text); - if (!wr) return wr.error(); - } - } - // Apply DD default values to columns the statement did not - // name (evaluated through the script engine: FALSE, now(), - // …; unparseable text falls back to a raw write). - for (std::uint16_t i = 0; - !ins_rules.empty() && i < tbl->field_count(); ++i) { - const auto& fd = tbl->field_descriptor(i); - auto rit = ins_rules.find(ins_lower(fd.name)); - if (rit == ins_rules.end() || rit->second.def.empty()) - continue; - if (ins_col_pos(fd.name) >= 0) continue; // caller set it - bool wrote = false; - auto pr = scriptbridge::compiled( - "RETURN " + rit->second.def + ";"); - if (pr) { - scriptbridge::AbiBridge br(c, hStatement); - openads::script::Executor ex2(&br); - ex2.set_user(c->username()); - auto rr = ex2.run(*pr.value()); - if (rr && rr.value().returned) { - auto wr = scriptbridge::write_script_value( - *tbl, i, rr.value().return_value); - if (!wr) return wr.error(); - wrote = true; - } - } - if (!wrote) { - auto wr = tbl->set_field(i, rit->second.def); - if (!wr) return wr.error(); - } - } - return std::monostate{}; - }; - // S4 -- NOT NULL pre-check BEFORE any trigger or write: SAP's 5147 - // constraint violation leaves no row behind and fires nothing. - { - std::string viol; - if (!ins.value().rows.empty()) { - for (auto& row : ins.value().rows) { - viol = null_violation(row); - if (!viol.empty()) break; - } - } else if (ins.value().select_sql.empty()) { - viol = null_violation(ins.value().values); - } - if (!viol.empty()) { - c->close_table(th.value()); - std::string msg = - "constraint violation while updating column \"" + viol + - "\": the column cannot be set to a NULL value due to a " - "'not NULL' constraint"; - return fail(openads::AE_COLUMN_CANNOT_BE_NULL, msg.c_str()); - } - } - // Trigger: INSTEAD OF INSERT supersedes the actual insert; BEFORE fires first. - // RCB 07/17/2026 (S2): failures propagate (probe Q6): BEFORE/ - // INSTEAD OF failure blocks the write, AFTER failure keeps it. - std::string ins_alias = ri_alias_for_path(c, tbl->path()); - Handle ins_hConn = !ins_alias.empty() ? handle_for_conn(c) : Handle{0}; - bool ins_instead_of = false; - TrigError_ ins_terr; - // S4 -- pre-write __new image from the statement VALUES (first - // row): INSERT BEFORE / INSTEAD OF triggers fire before any table - // row exists, so the image must come from the statement. pmsys's - // Insert AuditLog is an INSTEAD OF trigger that reads - // `SELECT propertyid FROM __new` and re-inserts the row itself. - TrigFieldMap_ ins_new_img; - if (ins_hConn) { - const std::vector* vals0 = nullptr; - if (!ins.value().rows.empty()) vals0 = &ins.value().rows.front(); - else if (ins.value().select_sql.empty()) - vals0 = &ins.value().values; - std::uint16_t nf2 = tbl->field_count(); - for (std::uint16_t i2 = 0; i2 < nf2; ++i2) { - const auto& fd2 = tbl->field_descriptor(i2); - std::string nm2 = fd2.name; - for (auto& ch : nm2) - ch = static_cast(std::tolower( - static_cast(ch))); - char tc2 = trig_type_char_(fd2.type); - std::string txt; - if (vals0 != nullptr) { - std::int32_t cp2 = ins_col_pos(fd2.name); - if (cp2 >= 0 && - static_cast(cp2) < vals0->size()) { - const auto& v2 = - (*vals0)[static_cast(cp2)]; - if (v2.is_null) { - txt.clear(); - } else if (v2.is_expr) { - // Expression value: evaluate for the image so - // the trigger sees the actual incoming value. - auto pr2 = scriptbridge::compiled( - "RETURN " + v2.text + ";"); - if (pr2) { - scriptbridge::AbiBridge br2(c, hStatement); - openads::script::Executor ex3(&br2); - ex3.set_user(c->username()); - auto rr2 = ex3.run(*pr2.value()); - if (rr2 && rr2.value().returned) - txt = openads::script::to_display( - rr2.value().return_value); - } - } else if (v2.is_numeric) { - char nb2[40]; - std::snprintf(nb2, sizeof(nb2), "%.10g", - v2.number); - txt = nb2; - } else { - txt = v2.text; - } - } - } - ins_new_img[nm2] = TrigField_{std::move(txt), tc2}; - } - } - if (ins_hConn) { - ins_instead_of = fire_triggers_(ins_hConn, c, ins_alias, 1u, - 2u /*INSTEAD_OF*/, nullptr, - nullptr, &ins_terr, - &ins_new_img, nullptr); - if (!ins_instead_of) - fire_triggers_(ins_hConn, c, ins_alias, 1u, 1u /*BEFORE*/, - nullptr, nullptr, &ins_terr, - &ins_new_img, nullptr); - if (ins_terr.has_error) { - c->close_table(th.value()); - return fail(static_cast(ins_terr.errno_val), ins_terr.message.c_str()); - } - } - if (!ins_instead_of) { - if (!ins.value().rows.empty()) { - for (auto& row : ins.value().rows) { - auto r = write_one(row); - if (!r) return fail(r.error()); - } - } else { - auto r = write_one(ins.value().values); - if (!r) return fail(r.error()); - } - if (auto fl = tbl->flush(); !fl) return fail(fl.error()); - } - // Fire AFTER INSERT triggers (only when no INSTEAD OF ran). - // Pass tbl so __new fields are available for the body. - if (!ins_instead_of && ins_hConn) { - fire_triggers_(ins_hConn, c, ins_alias, 1u, 4u /*AFTER*/, tbl, - nullptr, &ins_terr); - if (ins_terr.has_error) { - c->close_table(th.value()); - return fail(static_cast(ins_terr.errno_val), ins_terr.message.c_str()); - } - } - c->close_table(th.value()); - *phCursor = 0; - return ok(); - } - - // M10.26 -- top-level `UNION [ALL]` between SELECTs. Each member - // must currently be a `SELECT * FROM [WHERE ...]` form (no - // joins, aggregates, projection lists, GROUP BY, or ORDER BY - // inside members -- those compose with UNION in a follow-up). - // First member's schema is reused for the merged cursor. - { - struct UnionPart { std::string sql_text; bool all = false; }; - std::vector uparts; - { - std::size_t start = 0; - int depth = 0; - bool in_q = false; - bool prev_all = false; - auto kw_at = [&](std::size_t i, const char* kw) { - std::size_t L = std::strlen(kw); - if (i + L > sql.size()) return false; - for (std::size_t k = 0; k < L; ++k) - if (std::toupper(static_cast(sql[i+k])) != - kw[k]) return false; - bool lb = (i == 0) || - (!std::isalnum(static_cast(sql[i-1])) && - sql[i-1] != '_'); - bool rb = (i + L == sql.size()) || - (!std::isalnum(static_cast(sql[i+L])) && - sql[i+L] != '_'); - return lb && rb; - }; - for (std::size_t i = 0; i < sql.size(); ) { - char ch = sql[i]; - if (in_q) { - if (ch == '\'') in_q = false; - ++i; continue; - } - if (ch == '\'') { in_q = true; ++i; continue; } - if (ch == '(') { ++depth; ++i; continue; } - if (ch == ')') { --depth; ++i; continue; } - if (depth == 0 && kw_at(i, "UNION")) { - uparts.push_back({sql.substr(start, i - start), prev_all}); - i += 5; - while (i < sql.size() && - std::isspace(static_cast(sql[i]))) ++i; - prev_all = false; - if (kw_at(i, "ALL")) { - prev_all = true; - i += 3; - while (i < sql.size() && - std::isspace(static_cast(sql[i]))) ++i; - } - start = i; - continue; - } - ++i; - } - if (start < sql.size()) { - uparts.push_back({sql.substr(start), prev_all}); - } - } - - if (uparts.size() > 1) { - auto& s = state(); - std::lock_guard lk(s.mu); - - // M10.36 -- every UNION member runs through the full - // SELECT-execute pipeline as a recursive call to - // AdsExecuteSQLDirect (allowed by the recursive_mutex on - // s.mu). Members may now carry JOIN, GROUP BY, aggregates, - // CASE WHEN, DISTINCT, LIMIT -- anything a plain SELECT - // accepts. The first member's cursor schema (whatever the - // pipeline produces -- temp DBF for joins/aggregates, - // source schema for SELECT *) drives the merged schema; - // later members align by column name against it. - // - // Last member's ORDER BY still becomes the merged sort - // (M10.28 semantics). We capture it from a parse, then - // let the recursive call run as-is -- its sort is - // overwritten by the final post-merge stable_sort below. - std::optional final_order; - { - auto p = openads::sql::parse_select(uparts.back().sql_text); - if (p && p.value().order_by) { - final_order = *p.value().order_by; - } - } - - // The first member's `all` flag is meaningless (it has no - // UNION keyword preceding it); only the join-flags between - // members decide whether dedup applies. - bool any_distinct = false; - for (std::size_t i = 1; i < uparts.size(); ++i) - if (!uparts[i].all) any_distinct = true; - std::unordered_set seen; - - std::vector schema; - std::uint32_t rec_len = 0; - std::vector> rows; - - for (std::size_t mi = 0; mi < uparts.size(); ++mi) { - // Recurse into the full SELECT executor for this - // member. The member SQL goes through every dispatch - // (UNION, JOIN, GROUP BY, aggregate, CASE, ...) and - // lands as a registered cursor handle we can walk. - std::vector mbuf(uparts[mi].sql_text.size() + 1); - std::memcpy(mbuf.data(), uparts[mi].sql_text.c_str(), - uparts[mi].sql_text.size() + 1); - ADSHANDLE memberCur = 0; - UNSIGNED32 rrc = - AdsExecuteSQLDirect(hStatement, mbuf.data(), &memberCur); - if (rrc != 0) return rrc; - openads::engine::Table* mt = - s.registry.lookup( - memberCur, HandleKind::Table); - if (!mt) { - return fail(openads::AE_INTERNAL_ERROR, - "UNION member cursor lookup failed"); - } - - if (mi == 0) { - auto* proj = projection_for(memberCur); - if (proj) { - schema.reserve(proj->size()); - for (auto idx : *proj) { - schema.push_back(mt->field_descriptor(idx)); - } - } else { - std::uint16_t nf = mt->field_count(); - schema.reserve(nf); - for (std::uint16_t k = 0; k < nf; ++k) { - schema.push_back(mt->field_descriptor(k)); - } - } - rec_len = 0; - for (auto& fd : schema) rec_len += fd.length; - } - - std::vector col_src(schema.size(), -1); - auto* proj_m = projection_for(memberCur); - if (proj_m) { - if (proj_m->size() != schema.size()) { - AdsCloseTable(memberCur); - return fail(openads::AE_PARSE_ERROR, - "UNION member projection column count differs"); - } - for (std::size_t i = 0; i < schema.size(); ++i) { - col_src[i] = static_cast((*proj_m)[i]); - } - } else { - for (std::size_t i = 0; i < schema.size(); ++i) { - col_src[i] = mt->field_index(schema[i].name); - } - } - - std::vector recnos; - if (mt->has_recno_sequence()) { - recnos = mt->recno_sequence(); - } else { - std::uint32_t rc = mt->record_count(); - for (std::uint32_t r = 1; r <= rc; ++r) { - if (auto g = mt->goto_record(r); !g) continue; - if (!mt->show_deleted_records() && - mt->is_deleted()) continue; - if (!mt->passes_filter()) continue; - recnos.push_back(r); - } - } - for (std::uint32_t r : recnos) { - if (auto g = mt->goto_record(r); !g) continue; - std::vector rec(rec_len); - std::size_t off = 0; - for (std::size_t i = 0; i < schema.size(); ++i) { - std::string sval; - if (col_src[i] >= 0) { - auto v = mt->read_field( - static_cast(col_src[i])); - if (v) sval = v.value().as_string; - } - // uint16 -- an ADT character column can exceed 255 - // bytes; the old uint8 cast mis-tiled the row. - std::uint16_t L = schema[i].length; - for (std::uint16_t k = 0; k < L; ++k) { - rec[off + k] = k < sval.size() - ? static_cast(sval[k]) : ' '; - } - off += L; - } - if (any_distinct) { - std::string key( - reinterpret_cast(rec.data()), - rec.size()); - if (!seen.insert(std::move(key)).second) continue; - } - rows.push_back(std::move(rec)); - } - AdsCloseTable(memberCur); - } - - std::uint16_t nfields = static_cast(schema.size()); - - // Column specs for the shared ADT temp helper - // (docs/materialised-cursor-temps.md). The old DBF emit also - // copied fd.raw_type VERBATIM into the descriptor -- an ADT - // source leaked its numeric type code (1-22) into a DBF byte, - // producing a descriptor no reader could classify; the helper's - // dual-range mapping normalises those. - (void)nfields; - std::vector uspecs; - uspecs.reserve(schema.size()); - for (auto& fd : schema) { - uspecs.push_back({fd.name, fd.raw_type, - fd.length, fd.decimals}); - } - std::vector file; - - // M10.28 -- apply ORDER BY (from last member) to merged rows. - if (final_order) { - std::int32_t fi = -1; - std::uint16_t off = 0; - std::uint16_t flen = 0; - bool numeric = false; - for (std::size_t i = 0; i < schema.size(); ++i) { - if (schema[i].name == final_order->column) { - fi = static_cast(i); - flen = schema[i].length; - numeric = - schema[i].type == openads::drivers::DbfFieldType::Numeric || - schema[i].type == openads::drivers::DbfFieldType::Float || - schema[i].type == openads::drivers::DbfFieldType::Integer || - schema[i].type == openads::drivers::DbfFieldType::Currency|| - schema[i].type == openads::drivers::DbfFieldType::Double; - break; - } - off += schema[i].length; - } - if (fi < 0) { - return fail(openads::AE_COLUMN_NOT_FOUND, - final_order->column.c_str()); - } - bool desc = final_order->descending; - std::stable_sort(rows.begin(), rows.end(), - [&](const std::vector& a, - const std::vector& b) { - std::string ka( - reinterpret_cast(a.data() + off), flen); - std::string kb( - reinterpret_cast(b.data() + off), flen); - bool less; - if (numeric) { - double da = std::strtod(ka.c_str(), nullptr); - double db = std::strtod(kb.c_str(), nullptr); - if (da == db) return false; - less = da < db; - } else { - if (ka == kb) return false; - less = ka < kb; - } - return desc ? !less : less; - }); - } - - // Materialise rows into the row-image buffer. - for (auto& rec : rows) { - file.insert(file.end(), rec.begin(), rec.end()); - } - return materialise_temp_adt(c, "_uni_", uspecs, file, - rec_len, phCursor); - } - } - - auto parsed = openads::sql::parse_select(sql); - if (!parsed) return fail(parsed.error()); - - // S5 -- `FROM `: resolve a DD view by substituting its stored - // SELECT as a derived table. The existing M10.46 machinery then runs - // the view's SQL recursively and applies the outer clauses (WHERE / - // ORDER BY / TOP / aggregates / projection) to its cursor -- exactly - // SAP's composition semantics, oracle-probed on the mp views - // (`SELECT TOP 5 Total, ClaimKey FROM SumByClaim ORDER BY ClaimKey` - // etc.). UNION members re-enter this dispatcher and resolve views for - // free; a view INSIDE a join is still unresolved (TODO.parity.md). - if (parsed.value().derived_sql.empty() && - !parsed.value().inner_join && - parsed.value().from_tables.size() < 3 && - !parsed.value().table.empty() && - c->has_dd()) { - auto* vdd = c->dd(); - if (vdd != nullptr && !vdd->views().empty()) { - auto up = [](std::string v) { - for (auto& ch : v) - ch = static_cast(std::toupper( - static_cast(ch))); - return v; - }; - const std::string want = up(parsed.value().table); - for (const auto& kv : vdd->views()) { - if (up(kv.first) == want && !kv.second.sql.empty()) { - parsed.value().derived_sql = kv.second.sql; - break; - } - } - } - } - - // ==================================================================== - // ADS dialect -- N-way comma join (3+ tables) with composite keys and - // `.*` projection. The single-JoinClause path below handles only - // two tables; this path generalises to the multi-table inventory - // aggregation the application issues (a header/detail join with - // dimension tables). It runs a left-deep equi-join in FROM order and writes the - // projected columns -- named by their UNQUALIFIED column name, the ADS - // result semantics -- into a temp DBF cursor. - // - // NOTE: filters are applied after the join is fully bound (same as the - // two-table path). Pushing single-table predicates (e.g. the date range) - // down per level is a future optimisation; correctness first. - // ==================================================================== - // Three or more tables always come here. Two tables come here only when - // the comma-join lowering declined -- a composite key, which the - // single-pair JoinClause cannot express (see parser.cpp). A two-table - // comma join with one key still takes the simpler path below. - if (parsed.value().from_tables.size() >= 3 || - (parsed.value().from_tables.size() == 2 && - !parsed.value().inner_join.has_value())) { - auto& st = parsed.value(); - // S4 -- aggregates (COUNT/SUM/AVG/MIN/MAX, optional GROUP BY / - // HAVING) are supported by accumulating during the join walk; - // the other complex projection kinds are not. - const bool nway_agg = !st.aggregates.empty(); - if (st.projection_complex && - !(nway_agg && st.case_items.empty() && st.fn_items.empty() && - st.arith_items.empty() && st.window_items.empty())) { - return fail(openads::AE_INTERNAL_ERROR, - "multi-table join: only column, .* and aggregate " - "projections are supported"); - } - auto& s = state(); - std::lock_guard lk(s.mu); - - const std::size_t N = st.from_tables.size(); - auto lc = [](std::string v) { - for (auto& ch : v) - ch = static_cast( - std::tolower(static_cast(ch))); - return v; - }; - auto trim_trailing = [](std::string v) { - while (!v.empty() && v.back() == ' ') v.pop_back(); - return v; - }; - - // --- 1. Open every table; map alias (and bare name) -> index. --- - std::vector handles(N, 0); - std::vector tbls(N, nullptr); - std::unordered_map alias_idx; - std::size_t opened = 0; - bool open_ok = true; - for (std::size_t i = 0; i < N; ++i) { - auto h = open_or_sys(st.from_tables[i].name, - openads::engine::TableType::Cdx, - openads::engine::OpenMode::Read, - openads::engine::LockingMode::Compatible); - if (!h) { open_ok = false; break; } - handles[i] = h.value(); - ++opened; - tbls[i] = c->lookup_table(h.value()); - if (!tbls[i]) { open_ok = false; break; } - const std::string& al = st.from_tables[i].alias; - if (!al.empty()) alias_idx[lc(al)] = i; - // Also index by the table's base name (strip dir + extension) so - // a column may qualify by table name as well as by alias. - std::string base = st.from_tables[i].name; - std::size_t slash = base.find_last_of("\\/"); - if (slash != std::string::npos) base = base.substr(slash + 1); - std::size_t dot = base.find_last_of('.'); - if (dot != std::string::npos) base = base.substr(0, dot); - if (!base.empty()) alias_idx.emplace(lc(base), i); - } - auto close_all = [&]() { - for (std::size_t k = 0; k < opened; ++k) - if (handles[k]) c->close_table(handles[k]); - }; - if (!open_ok) { - close_all(); - return fail(openads::AE_NO_FILE_FOUND, - "multi-table join: table open failed"); - } - - // SAP 2137 -- an unqualified column present in more than one joined - // table is ambiguous. Checked before resolution (which resolves - // left-first and would silently pick one). - { - std::string amb = - scriptbridge::first_ambiguous_join_column(st, tbls); - if (!amb.empty()) { - close_all(); - return fail(2137, - ("Column found in multiple tables: " + amb).c_str()); - } - } - - // --- 2. Resolve a qualified column -> (table idx, field idx). --- - auto resolve = [&](const std::string& alias, const std::string& col, - std::size_t& ti, std::int32_t& fi) -> bool { - if (!alias.empty()) { - auto a = alias_idx.find(lc(alias)); - if (a == alias_idx.end()) return false; - ti = a->second; - fi = tbls[ti]->field_index(col); - return fi >= 0; - } - for (std::size_t i = 0; i < N; ++i) { - std::int32_t f = tbls[i]->field_index(col); - if (f >= 0) { ti = i; fi = f; return true; } - } - return false; - }; - - // --- 3. Walk the top-level AND spine: separate equi-join predicates - // (col = col, enforced by the hash join) from residual filter - // conjuncts. Each residual conjunct is bucketed by the DEEPEST - // table it references, so the join walk can evaluate it the - // moment that table is bound and prune early (filter pushdown). - // Only the AND spine is descended for join keys, so a `col=col` - // nested under OR/NOT is treated as a residual filter, not a - // join key. - struct JEq { std::size_t lti; std::int32_t lfi; - std::size_t rti; std::int32_t rfi; }; - std::vector jeqs; - std::vector> buckets(N); - // Residual conjuncts that reference ONLY one joined table (index >= 1) - // are applied while BUILDING that table's hash, so the hash holds just - // the matching rows (e.g. only the month's detail rows) instead of the - // whole history. Indexed by table. - std::vector> pure(N); - bool spine_ok = true; - // Accumulate the min/max table index a (sub)expression references. - // mx < 0 means it references no column (a folded constant). - std::function - conj_range = [&](const openads::sql::WhereExpr* n, - int& mn, int& mx) { - if (n == nullptr) return; - std::size_t ti; std::int32_t fi; - if (n->kind == openads::sql::WhereExpr::Kind::Cmp) { - const auto& w = n->cmp; - if (resolve(w.column_alias, w.column, ti, fi)) { - mn = std::min(mn, static_cast(ti)); - mx = std::max(mx, static_cast(ti)); - } - if (w.is_outer_ref && - resolve(w.outer_column_alias, w.outer_column, ti, fi)) { - mn = std::min(mn, static_cast(ti)); - mx = std::max(mx, static_cast(ti)); - } - return; - } - if (n->kind == openads::sql::WhereExpr::Kind::In) { - if (resolve(std::string(), n->in_clause.column, ti, fi)) { - mn = std::min(mn, static_cast(ti)); - mx = std::max(mx, static_cast(ti)); - } - return; - } - for (auto& ch : n->children) conj_range(ch.get(), mn, mx); - conj_range(n->child.get(), mn, mx); - }; - std::function spine = - [&](const openads::sql::WhereExpr* n) { - if (n == nullptr || !spine_ok) return; - if (n->kind == openads::sql::WhereExpr::Kind::And) { - for (auto& ch : n->children) spine(ch.get()); - return; - } - if (n->kind == openads::sql::WhereExpr::Kind::Cmp && - n->cmp.op == openads::sql::WhereOp::Eq && - n->cmp.is_outer_ref) { - JEq e{}; - if (!resolve(n->cmp.column_alias, n->cmp.column, - e.lti, e.lfi) || - !resolve(n->cmp.outer_column_alias, n->cmp.outer_column, - e.rti, e.rfi)) { - spine_ok = false; - return; - } - jeqs.push_back(e); - return; - } - int mn = static_cast(N), mx = -1; - conj_range(n, mn, mx); - if (mx < 0) { - buckets[0].push_back(n); // constant -> level 0 - } else if (mn == mx && mn >= 1) { - pure[static_cast(mn)].push_back(n); // hash-build - } else { - buckets[static_cast(mx)].push_back(n); // walk - } - }; - spine(st.where.get()); - if (!spine_ok) { - close_all(); - return fail(openads::AE_COLUMN_NOT_FOUND, - "multi-table join: unresolved join column"); - } - - // --- 4. Build a left-deep probe plan in FROM order. --- - struct Probe { - std::size_t partner = 0; - std::vector myCols; - std::vector partnerCols; - std::vector ci; // per key column: CICHAR fold - std::unordered_map> hash; - }; - std::vector probes(N); - for (std::size_t i = 1; i < N; ++i) { - std::size_t partner = N; // sentinel = unset - for (const auto& e : jeqs) { - std::int32_t myc; std::size_t other; std::int32_t otc; - if (e.lti == i && e.rti < i) { - myc = e.lfi; other = e.rti; otc = e.rfi; - } else if (e.rti == i && e.lti < i) { - myc = e.rfi; other = e.lti; otc = e.lfi; - } else { - continue; - } - if (partner == N) partner = other; - if (other != partner) { - close_all(); - return fail(openads::AE_INTERNAL_ERROR, - "multi-table join: a table joins to more than one " - "prior table (only left-deep trees are supported)"); - } - probes[i].myCols.push_back(myc); - probes[i].partnerCols.push_back(otc); - // CICHAR join key (either side CICHAR) folds case. - probes[i].ci.push_back( - field_is_cichar(*tbls[i], - static_cast(myc)) || - field_is_cichar(*tbls[other], - static_cast(otc))); - } - if (partner == N) { - close_all(); - return fail(openads::AE_INTERNAL_ERROR, - "multi-table join: a table has no equi-join to a prior " - "table (cartesian products are not supported)"); - } - probes[i].partner = partner; - } - - // --- 5. (Table hashes are built in step 7b below, after the WHERE - // evaluator is defined, so the single-table residual filters - // in pure[i] can be applied during the build.) --- - - // --- 6. Resolve the output schema from select_items (ADS names). - // Each source field maps to a DBF text descriptor (the - // sources may be ADT with binary encodings); rows emit - // from DECODED values. - struct OutCol { std::size_t ti; std::uint16_t src_fi; - openads::drivers::DbfField fld; }; - std::vector outcols; - std::unordered_set seen; - auto add_out = [&](std::size_t ti, std::int32_t fi) { - const auto& f = - tbls[ti]->driver()->fields()[static_cast(fi)]; - auto ofld = scriptbridge::join_out_field(f); - // Full-length names -- the ADT temp carries them whole. (The - // DBF-era resize(10) here also made two long names that agree - // in their first 10 chars dedup into ONE output column.) - if (!seen.insert(lc(ofld.name)).second) return; // first-wins - outcols.push_back(OutCol{ - ti, static_cast(fi), std::move(ofld)}); - }; - if (!nway_agg) { - if (st.select_items.empty()) { - // bare `SELECT *` across all tables, in FROM order. - for (std::size_t i = 0; i < N; ++i) { - const auto& flds = tbls[i]->driver()->fields(); - for (std::int32_t k = 0; - k < static_cast(flds.size()); ++k) - add_out(i, k); - } - } else { - for (const auto& si : st.select_items) { - if (si.wildcard) { - auto a = alias_idx.find(lc(si.alias)); - if (a == alias_idx.end()) { - close_all(); - return fail(openads::AE_COLUMN_NOT_FOUND, - si.alias.c_str()); - } - const auto& flds = - tbls[a->second]->driver()->fields(); - for (std::int32_t k = 0; - k < static_cast(flds.size()); ++k) - add_out(a->second, k); - } else { - std::size_t ti; std::int32_t fi; - if (!resolve(si.alias, si.column, ti, fi)) { - close_all(); - return fail(openads::AE_COLUMN_NOT_FOUND, - si.column.c_str()); - } - add_out(ti, fi); - } - } - } - if (outcols.empty()) { - close_all(); - return fail(openads::AE_INTERNAL_ERROR, - "multi-table join: empty projection"); - } - } - - // --- 6a. Aggregate mode: resolve aggregate source columns and - // GROUP BY key columns against the joined tables. The walk - // accumulates instead of emitting rows. - struct NAggSlot { - openads::sql::Aggregate def; - std::size_t ti = 0; - std::int32_t fi = -1; // -1 for COUNT(*) - std::uint8_t dec = 0; // source decimals (money: 4) - std::uint16_t src_len = 0; // source-field width - bool is_text = false; // MIN/MAX compares text - bool is_date = false; // date-sourced - // SUM(a * b): the right-hand column may live in a DIFFERENT - // joined table than the left one. - std::size_t rhs_ti = 0; - std::int32_t rhs_fi = -1; - std::uint8_t arg_dec = 0; // op-following result scale - std::uint16_t arg_len = 0; // SAP arg width - }; - struct NKeyCol { - std::size_t ti = 0; - std::int32_t fi = -1; - openads::drivers::DbfField ofld; // mapped output descriptor - }; - std::vector nslots; - std::vector nkeys; - if (nway_agg) { - for (const auto& a : st.aggregates) { - NAggSlot slot; - slot.def = a; - if (a.kind != openads::sql::AggregateKind::CountStar) { - if (!resolve(std::string(), a.column, - slot.ti, slot.fi)) { - close_all(); - return fail(openads::AE_COLUMN_NOT_FOUND, - a.column.c_str()); - } - const auto& sfd = tbls[slot.ti]->driver()->fields()[ - static_cast(slot.fi)]; - using FT = openads::drivers::DbfFieldType; - slot.dec = (sfd.type == FT::AdtMoney || - sfd.type == FT::Currency) - ? 4 : sfd.decimals; - slot.src_len = sfd.length; - slot.is_text = scriptbridge::agg_source_is_text(sfd.type); - slot.is_date = scriptbridge::agg_source_is_date(sfd.type); - // SUM(a * b): resolve the right-hand column (any joined - // table) and let the result scale follow the operation. - // Reading only the left column summed SUM(a * b) as - // SUM(a) - same defect the 2-table paths had. - if (a.arg_expr) slot.is_text = false; - if (a.arg_expr && !a.arg_expr->rhs_is_literal) { - if (!resolve(std::string(), - a.arg_expr->rhs_column, - slot.rhs_ti, slot.rhs_fi)) { - close_all(); - return fail(openads::AE_COLUMN_NOT_FOUND, - a.arg_expr->rhs_column.c_str()); - } - } - { - // SAP's declared shape for the argument (see - // agg_arg_shape). The rhs may live in a different - // joined table than the lhs. - const openads::drivers::DbfField* rfd = nullptr; - if (a.arg_expr && !a.arg_expr->rhs_is_literal && - slot.rhs_fi >= 0) { - rfd = &tbls[slot.rhs_ti]->driver()->fields()[ - static_cast(slot.rhs_fi)]; - } - auto shp = scriptbridge::agg_arg_shape(sfd, a, rfd); - slot.arg_dec = shp.dec; - slot.arg_len = shp.len; - } - } - nslots.push_back(std::move(slot)); - } - for (const auto& gname : st.group_by) { - NKeyCol kc; - if (!resolve(std::string(), gname, kc.ti, kc.fi)) { - close_all(); - return fail(openads::AE_COLUMN_NOT_FOUND, - gname.c_str()); - } - kc.ofld = scriptbridge::join_out_field( - tbls[kc.ti]->driver()->fields()[ - static_cast(kc.fi)]); - nkeys.push_back(std::move(kc)); - } - } - - // --- 7. Residual WHERE evaluator over the bound rows. Rows are - // bound by POSITIONING each table (goto_record) and - // recording the recno in recs[ti]; values come from - // Table::read_field (decoded -- works for ADT's 5-byte - // prefix + binary encodings as well as DBF), never from - // raw record bytes. - std::vector recs(N, 0); - auto slice = [&](std::size_t ti, std::int32_t fi) -> std::string { - if (recs[ti] == 0) return std::string(); - auto v = tbls[ti]->read_field(static_cast(fi)); - if (!v || v.value().is_null) return std::string(); - return v.value().as_string; - }; - auto is_ci_col = [&](std::size_t ti, std::int32_t fi) { - return field_is_cichar(*tbls[ti], - static_cast(fi)); - }; - auto is_num_type = [](openads::drivers::DbfFieldType t) { - return t == openads::drivers::DbfFieldType::Numeric || - t == openads::drivers::DbfFieldType::Float || - t == openads::drivers::DbfFieldType::Integer || - t == openads::drivers::DbfFieldType::Currency || - t == openads::drivers::DbfFieldType::Double; - }; - auto fold = [&](std::string v, openads::sql::WhereFn fn) { - if (fn == openads::sql::WhereFn::Upper) - for (auto& ch : v) - ch = static_cast( - std::toupper(static_cast(ch))); - else if (fn == openads::sql::WhereFn::Lower) - for (auto& ch : v) - ch = static_cast( - std::tolower(static_cast(ch))); - return v; - }; - std::function eval = - [&](const openads::sql::WhereExpr* n) -> bool { - if (n == nullptr) return true; - using K = openads::sql::WhereExpr::Kind; - using Op = openads::sql::WhereOp; - switch (n->kind) { - case K::And: { - for (auto& ch : n->children) - if (!eval(ch.get())) return false; - return true; // empty AND -> true - } - case K::Or: { - if (n->children.empty()) return false; // empty OR -> false - for (auto& ch : n->children) - if (eval(ch.get())) return true; - return false; - } - case K::Not: - return !eval(n->child.get()); - case K::In: { - std::size_t lti; std::int32_t lfi; - if (!resolve(std::string(), n->in_clause.column, lti, lfi)) - return false; - bool ci = is_ci_col(lti, lfi); - std::string lhs = trim_trailing(slice(lti, lfi)); - for (const auto& v : n->in_clause.literals) - if (where_str_cmp(lhs, v, ci) == 0) return true; - return false; - } - case K::Cmp: { - const auto& w = n->cmp; - std::size_t lti; std::int32_t lfi; - if (!resolve(w.column_alias, w.column, lti, lfi)) - return false; - const auto& lf = - tbls[lti]->driver()->fields()[ - static_cast(lfi)]; - bool ci = is_ci_col(lti, lfi); - std::string lhs = fold(trim_trailing(slice(lti, lfi)), - w.lhs_fn); - if (w.is_outer_ref) { - std::size_t rti; std::int32_t rfi; - if (!resolve(w.outer_column_alias, w.outer_column, - rti, rfi)) - return false; - // SAP: comparison is CI if EITHER operand is CICHAR. - int cc = where_str_cmp( - lhs, trim_trailing(slice(rti, rfi)), - ci || is_ci_col(rti, rfi)); - switch (w.op) { - case Op::Ne: return cc != 0; - case Op::Lt: return cc < 0; - case Op::Gt: return cc > 0; - case Op::Le: return cc <= 0; - case Op::Ge: return cc >= 0; - default: return cc == 0; // Eq - } - } - if (w.op == Op::IsNull) return lhs.empty(); - if (w.op == Op::IsNotNull) return !lhs.empty(); - bool numeric = is_num_type(lf.type); - if (w.op == Op::Like) - return sql_like_match_t(lhs, w.literal, ci); - if (w.op == Op::Between) { - if (numeric) { - double a = std::strtod(lhs.c_str(), nullptr); - double b1 = std::strtod(w.literal.c_str(), nullptr); - double b2 = std::strtod(w.literal2.c_str(), nullptr); - return a >= b1 && a <= b2; - } - return where_str_cmp(lhs, w.literal, ci) >= 0 && - where_str_cmp(lhs, w.literal2, ci) <= 0; - } - int cc; - if (numeric) { - double a = std::strtod(lhs.c_str(), nullptr); - double b = w.is_numeric - ? w.number - : std::strtod(w.literal.c_str(), nullptr); - cc = (a < b) ? -1 : (a > b ? 1 : 0); - } else { - cc = where_str_cmp(lhs, w.literal, ci); - } - switch (w.op) { - case Op::Eq: return cc == 0; - case Op::Ne: return cc != 0; - case Op::Lt: return cc < 0; - case Op::Gt: return cc > 0; - case Op::Le: return cc <= 0; - case Op::Ge: return cc >= 0; - default: return false; - } - } - default: - return true; // Exists / unsupported -> non-filtering - } - }; - - // --- 7b. Hash each joined table on its key columns, applying the - // single-table residual filters (pure[i]) up front so the hash - // holds only matching rows (e.g. just the month's detail rows) - // instead of the whole history. recs[i] is scratch here; the - // walk re-binds it per emitted combination. - for (std::size_t i = 1; i < N; ++i) { - auto& pr = probes[i]; - std::uint32_t rc = tbls[i]->record_count(); - for (std::uint32_t r = 1; r <= rc; ++r) { - if (auto g = tbls[i]->goto_record(r); !g) continue; - if (!tbls[i]->show_deleted_records() && - tbls[i]->is_deleted()) continue; - recs[i] = r; - bool keep = true; - for (const auto* cj : pure[i]) - if (!eval(cj)) { keep = false; break; } // single-table filter - if (!keep) continue; - std::string key; - for (std::size_t k = 0; k < pr.myCols.size(); ++k) { - std::string seg = trim_trailing(slice(i, pr.myCols[k])); - if (pr.ci[k]) - for (char& ch : seg) - ch = static_cast(std::toupper( - static_cast(ch))); - key += seg; - key.push_back('\x01'); - } - pr.hash[key].push_back(r); - } - recs[i] = 0; - } - - // --- 8. Output column specs (plain-projection mode; aggregate mode - // builds its own from nouts after the walk). `file` holds - // ROW IMAGES only; the on-disk container comes from the - // shared ADT temp helper at step 10 - // (docs/materialised-cursor-temps.md). - std::uint32_t out_rlen = 0; - for (const auto& oc : outcols) out_rlen += oc.fld.length; - if (out_rlen + 5 > 0xFFFF) { - close_all(); - return fail(openads::AE_INTERNAL_ERROR, - "multi-table join: record exceeds 64 KiB"); - } - std::vector file; - std::vector mspecs; - std::size_t mstride = 0; - if (!nway_agg) { - mspecs.reserve(outcols.size()); - for (const auto& oc : outcols) { - mspecs.push_back({oc.fld.name, oc.fld.raw_type, - oc.fld.length, oc.fld.decimals}); - } - mstride = out_rlen; - } - - // --- 9. Left-deep nested-loop join walk. Plain mode emits the - // projected row; aggregate mode accumulates into per-group - // slots (group key from the GROUP BY columns of the bound - // rows; no GROUP BY = one global group). - struct NAcc { - std::vector key_parts; - std::vector sum, minv, maxv; - std::vector sumc; // Kahan compensation - // MIN/MAX over a non-numeric column compares decoded text. - std::vector txt; - std::vector cnt; - std::uint64_t rows = 0; - }; - std::unordered_map ngroups; - std::vector ngroup_order; - std::uint32_t emitted = 0; - auto emit_row = [&]() { - if (nway_agg) { - std::string key; - std::vector parts; - parts.reserve(nkeys.size()); - for (const auto& kc : nkeys) { - std::string kv = slice(kc.ti, kc.fi); - parts.push_back(kv); - key += kv; - key.push_back('\x1f'); - } - auto git = ngroups.find(key); - if (git == ngroups.end()) { - NAcc acc; - acc.key_parts = std::move(parts); - acc.sum.assign(nslots.size(), 0.0); - acc.sumc.assign(nslots.size(), 0.0); - acc.sumc.assign(nslots.size(), 0.0); - acc.minv.assign(nslots.size(), - std::numeric_limits::infinity()); - acc.maxv.assign(nslots.size(), - -std::numeric_limits::infinity()); - acc.cnt.assign(nslots.size(), 0); - acc.txt.assign(nslots.size(), scriptbridge::TextMinMax{}); - git = ngroups.emplace(key, std::move(acc)).first; - ngroup_order.push_back(key); - } - auto& acc = git->second; - ++acc.rows; - for (std::size_t i2 = 0; i2 < nslots.size(); ++i2) { - if (nslots[i2].def.kind == - openads::sql::AggregateKind::CountStar) { - ++acc.cnt[i2]; - continue; - } - auto v = tbls[nslots[i2].ti]->read_field( - static_cast(nslots[i2].fi)); - if (!v || v.value().is_null) continue; - if (nslots[i2].is_text) { - ++acc.cnt[i2]; - acc.txt[i2].feed(v.value().as_string); - continue; - } - double d = v.value().as_double; - if (nslots[i2].def.arg_expr) { - double b2 = 0.0; - if (nslots[i2].def.arg_expr->rhs_is_literal) { - b2 = nslots[i2].def.arg_expr->rhs_number; - } else if (nslots[i2].rhs_fi >= 0) { - auto rv = tbls[nslots[i2].rhs_ti]->read_field( - static_cast( - nslots[i2].rhs_fi)); - b2 = rv ? rv.value().as_double : 0.0; - } - using AO = openads::sql::ArithOp; - switch (nslots[i2].def.arg_expr->op) { - case AO::Add: d = d + b2; break; - case AO::Sub: d = d - b2; break; - case AO::Mul: d = d * b2; break; - case AO::Div: - d = b2 != 0.0 - ? scriptbridge::avg_truncate( - d / b2, nslots[i2].arg_dec) - : 0.0; - break; - } - } - ++acc.cnt[i2]; - { // Kahan-compensated accumulation (see GroupAcc note) - const double y2 = d - acc.sumc[i2]; - const double t2 = acc.sum[i2] + y2; - acc.sumc[i2] = (t2 - acc.sum[i2]) - y2; - acc.sum[i2] = t2; - } - if (d < acc.minv[i2]) acc.minv[i2] = d; - if (d > acc.maxv[i2]) acc.maxv[i2] = d; - } - return; - } - std::vector rec(out_rlen, ' '); - std::size_t off = 0; - for (const auto& oc : outcols) { - std::string s = scriptbridge::join_cell_text( - *tbls[oc.ti], oc.src_fi, oc.fld); - std::memcpy(rec.data() + off, s.data(), s.size()); - off += oc.fld.length; - } - file.insert(file.end(), rec.begin(), rec.end()); - ++emitted; - }; - // Evaluate the residual conjuncts that become bound at `level`; - // returning false prunes the branch before descending further. - auto pass_bucket = [&](std::size_t level) -> bool { - for (const auto* cj : buckets[level]) - if (!eval(cj)) return false; - return true; - }; - std::function walk = [&](std::size_t level) { - if (level == N) { emit_row(); return; } // all conjuncts checked - if (level == 0) { - std::uint32_t rc = tbls[0]->record_count(); - for (std::uint32_t r = 1; r <= rc; ++r) { - if (auto g = tbls[0]->goto_record(r); !g) continue; - if (!tbls[0]->show_deleted_records() && - tbls[0]->is_deleted()) continue; - recs[0] = r; - if (pass_bucket(0)) walk(1); // prune table-0 filters early - } - return; - } - const auto& pr = probes[level]; - std::string key; - for (std::size_t k = 0; k < pr.partnerCols.size(); ++k) { - std::string seg = trim_trailing( - slice(pr.partner, pr.partnerCols[k])); - if (pr.ci[k]) - for (char& ch : seg) - ch = static_cast(std::toupper( - static_cast(ch))); - key += seg; - key.push_back('\x01'); - } - auto it2 = pr.hash.find(key); - if (it2 == pr.hash.end()) return; - for (std::uint32_t r : it2->second) { - if (auto g = tbls[level]->goto_record(r); !g) continue; - recs[level] = r; - if (pass_bucket(level)) walk(level + 1); // push filters down - } - recs[level] = 0; - }; - walk(0); - - // --- 9a. Aggregate mode: build the result file from the group - // accumulators (same output conventions as the - // single-table grouped path: SELECT-list order via the - // $AGG_ placeholders, N(20,dp) aggregate cells named - // alias / EXPR / EXPR_1, group keys with their mapped - // source descriptor, groups sorted ascending, HAVING - // evaluated per group). - if (nway_agg) { - // A text MIN/MAX column is one width for every group: the - // widest decoded value seen anywhere. - std::vector ntextw(nslots.size(), 0); - for (auto& kv : ngroups) - for (std::size_t i = 0; i < nslots.size(); ++i) - if (kv.second.txt[i].w > ntextw[i]) - ntextw[i] = kv.second.txt[i].w; - auto nslot_is_text = [&](std::size_t i) { - using K = openads::sql::AggregateKind; - return nslots[i].is_text && (nslots[i].def.kind == K::Min || - nslots[i].def.kind == K::Max); - }; - auto ndp = [&](std::size_t i) -> int { - using K = openads::sql::AggregateKind; - switch (nslots[i].def.kind) { - case K::CountStar: case K::Count: return 0; - // AVG keeps the SOURCE column's decimals (oracle - // 2026-07-21: money -> 4dp, integer -> 0dp; not 6); - // an arithmetic argument follows the operation. - default: return static_cast(nslots[i].arg_dec); - } - }; - auto ci_eq_n = [](const std::string& x, const std::string& y) { - if (x.size() != y.size()) return false; - for (std::size_t z = 0; z < x.size(); ++z) - if (std::toupper(static_cast(x[z])) != - std::toupper(static_cast(y[z]))) - return false; - return true; - }; - struct NOut { - bool is_agg = false; - std::size_t idx = 0; - std::string name; - char type = 'N'; - std::uint16_t len = 20; - std::uint8_t dec = 0; - }; - std::vector nouts; - { - std::size_t unal = 0; - std::size_t pj_pos = 0; - for (const auto& pj : st.projection) { - // Positional AS alias (see the single-table GROUP BY - // path -- same constraint-4 rule). - const std::string* pj_alias = nullptr; - for (const auto& pa : st.projection_aliases) - if (pa.first == pj_pos) { pj_alias = &pa.second; break; } - ++pj_pos; - NOut o; - if (pj.rfind("$AGG_", 0) == 0) { - o.is_agg = true; - o.idx = static_cast( - std::stoul(pj.substr(5))); - std::string nm = nslots[o.idx].def.alias; - if (nm.empty()) { - nm = unal == 0 ? "EXPR" - : "EXPR_" + std::to_string(unal); - ++unal; - } - o.name = nm; - const bool tx_n = nslot_is_text(o.idx); - o.type = tx_n - ? (nslots[o.idx].is_date ? 'D' : 'C') - : 'N'; - o.len = tx_n - ? static_cast( - ntextw[o.idx] ? ntextw[o.idx] : 1) - : scriptbridge::agg_result_width( - nslots[o.idx].def.kind, - nslots[o.idx].arg_len); - o.dec = static_cast(ndp(o.idx)); - } else { - std::int32_t gi = -1; - for (std::size_t j2 = 0; j2 < st.group_by.size(); - ++j2) - if (ci_eq_n(st.group_by[j2], pj)) { - gi = static_cast(j2); break; - } - if (gi < 0) { - close_all(); - return fail(openads::AE_PARSE_ERROR, - ("SELECT column must be a GROUP BY key: " + - pj).c_str()); - } - o.is_agg = false; - o.idx = static_cast(gi); - const auto& kf = nkeys[o.idx].ofld; - o.name = pj_alias ? *pj_alias : pj; - o.type = kf.raw_type - ? static_cast(kf.raw_type) : 'C'; - o.len = kf.length; - o.dec = kf.decimals; - } - nouts.push_back(std::move(o)); - } - } - auto agg_at = [&](const NAcc& acc, std::size_t si) -> double { - using K = openads::sql::AggregateKind; - switch (nslots[si].def.kind) { - case K::CountStar: - return static_cast(acc.rows); - case K::Count: - return static_cast(acc.cnt[si]); - case K::Sum: return acc.sum[si]; - case K::Avg: - return acc.cnt[si] - ? scriptbridge::avg_truncate( - acc.sum[si] / - static_cast(acc.cnt[si]), - static_cast(nslots[si].dec)) - : 0.0; - case K::Min: return acc.cnt[si] ? acc.minv[si] : 0.0; - case K::Max: return acc.cnt[si] ? acc.maxv[si] : 0.0; - } - return 0.0; - }; - auto resolve_nslot = [&](const openads::sql::HavingCmp& ha) - -> std::int32_t { - for (std::size_t i = 0; i < nslots.size(); ++i) - if (nslots[i].def.kind == ha.agg.kind && - nslots[i].def.column == ha.agg.column) - return static_cast(i); - if (ha.agg.kind == openads::sql::AggregateKind::CountStar) - for (std::size_t i = 0; i < nslots.size(); ++i) - if (nslots[i].def.kind == - openads::sql::AggregateKind::CountStar) - return static_cast(i); - return -1; - }; - std::function eval_nhaving; - eval_nhaving = [&](const openads::sql::HavingExpr& n, - const NAcc& acc) -> bool { - using K = openads::sql::HavingExpr::Kind; - if (n.kind == K::And) { - for (auto& cn : n.children) - if (!eval_nhaving(*cn, acc)) return false; - return true; - } - if (n.kind == K::Or) { - for (auto& cn : n.children) - if (eval_nhaving(*cn, acc)) return true; - return false; - } - if (n.kind == K::Not) return !eval_nhaving(*n.child, acc); - std::int32_t si = resolve_nslot(n.cmp); - if (si < 0) return false; - double v = agg_at(acc, static_cast(si)); - double rhs = n.cmp.num; - switch (n.cmp.op) { - case openads::sql::WhereOp::Eq: return v == rhs; - case openads::sql::WhereOp::Ne: return v != rhs; - case openads::sql::WhereOp::Lt: return v < rhs; - case openads::sql::WhereOp::Gt: return v > rhs; - case openads::sql::WhereOp::Le: return v <= rhs; - case openads::sql::WhereOp::Ge: return v >= rhs; - default: return false; - } - }; - - // A global aggregate (no GROUP BY) always yields one row, - // even when the join matched nothing. - if (st.group_by.empty() && ngroups.empty()) { - NAcc acc; - acc.sum.assign(nslots.size(), 0.0); - acc.sumc.assign(nslots.size(), 0.0); - acc.minv.assign(nslots.size(), - std::numeric_limits::infinity()); - acc.maxv.assign(nslots.size(), - -std::numeric_limits::infinity()); - acc.cnt.assign(nslots.size(), 0); - acc.txt.assign(nslots.size(), scriptbridge::TextMinMax{}); - ngroups.emplace(std::string(), std::move(acc)); - ngroup_order.push_back(std::string()); - } - - mspecs.clear(); - mspecs.reserve(nouts.size()); - mstride = 0; - for (const auto& o : nouts) { - mspecs.push_back({o.name, o.type, o.len, o.dec}); - mstride += o.len; - } - std::sort(ngroup_order.begin(), ngroup_order.end()); - for (const auto& key : ngroup_order) { - auto& acc = ngroups[key]; - if (st.having && !eval_nhaving(*st.having, acc)) continue; - for (const auto& o : nouts) { - if (o.is_agg) { - const std::string* ntv = nullptr; - if (nslot_is_text(o.idx)) - ntv = (nslots[o.idx].def.kind == - openads::sql::AggregateKind::Min) - ? &acc.txt[o.idx].mn : &acc.txt[o.idx].mx; - const auto cell = scriptbridge::agg_cell_text( - nslots[o.idx].def.kind, o.len, - static_cast(o.dec), agg_at(acc, o.idx), ntv); - file.insert(file.end(), cell.begin(), cell.end()); - } else { - std::string kp = o.idx < acc.key_parts.size() - ? acc.key_parts[o.idx] : std::string(); - for (std::uint16_t b = 0; b < o.len; ++b) - file.push_back(b < kp.size() - ? static_cast(kp[b]) : ' '); - } - } - ++emitted; - } - } - - (void)emitted; - close_all(); - - // --- 10. Materialise into the temp cursor and register it. --- - return materialise_temp_adt(c, "_mjoin_", mspecs, file, - mstride, phCursor); - } - - if (parsed.value().inner_join) { - // M10.14 materialises the join into a temp DBF cursor; M10.20 - // additionally compiles the outer WHERE / ORDER BY against - // that cursor's merged schema; M10.23 runs aggregates over - // that merged cursor when the projection is `agg(...)` instead - // of a column list. - const auto& j = *parsed.value().inner_join; - auto& s = state(); - std::lock_guard lk(s.mu); - - auto lh = open_or_sys(parsed.value().table, - openads::engine::TableType::Cdx, - openads::engine::OpenMode::Read, - openads::engine::LockingMode::Compatible); - if (!lh) return fail(lh.error()); - auto rh = open_or_sys(j.table, - openads::engine::TableType::Cdx, - openads::engine::OpenMode::Read, - openads::engine::LockingMode::Compatible); - if (!rh) { - c->close_table(lh.value()); - return fail(rh.error()); - } - openads::engine::Table* ltbl = c->lookup_table(lh.value()); - openads::engine::Table* rtbl = c->lookup_table(rh.value()); - if (ltbl == nullptr || rtbl == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, "join post-open"); - } - - // SAP 2137 -- reject an unqualified column present in both joined - // tables (checked here, where both source schemas are still - // distinct; the merged cursor below would hide the ambiguity). - { - std::string amb = scriptbridge::first_ambiguous_join_column( - parsed.value(), {ltbl, rtbl}); - if (!amb.empty()) { - c->close_table(lh.value()); - c->close_table(rh.value()); - return fail(2137, - ("Column found in multiple tables: " + amb).c_str()); - } - } - - std::int32_t lcol = ltbl->field_index(j.left_column); - std::int32_t rcol = rtbl->field_index(j.right_column); - // Orientation fallback: a comma-join (`FROM a, b WHERE a.x = b.y`) - // lowers the join key from the WHERE, where the alias qualifiers are - // dropped -- so `left_column`/`right_column` may name the columns in - // the opposite order to base/joined. If the straight assignment does - // not resolve but the swapped one does, use the swap. Purely - // additive: it only fires on inputs the strict path already rejects, - // and also makes explicit `JOIN ON b.y = a.x` lenient like ADS. - if ((lcol < 0 || rcol < 0)) { - std::int32_t lcol_sw = ltbl->field_index(j.right_column); - std::int32_t rcol_sw = rtbl->field_index(j.left_column); - if (lcol_sw >= 0 && rcol_sw >= 0) { - lcol = lcol_sw; - rcol = rcol_sw; - } - } - if (lcol < 0) { - c->close_table(lh.value()); c->close_table(rh.value()); - return fail(openads::AE_COLUMN_NOT_FOUND, - j.left_column.c_str()); - } - if (rcol < 0) { - c->close_table(lh.value()); c->close_table(rh.value()); - return fail(openads::AE_COLUMN_NOT_FOUND, - j.right_column.c_str()); - } - - // CICHAR join keys match case-insensitively (SAP: a comparison is - // case-insensitive if either operand is CICHAR). Fold the hash key - // to upper on both build and probe so 'abc' joins 'ABC'. - bool join_ci = - field_is_cichar(*ltbl, static_cast(lcol)) || - field_is_cichar(*rtbl, static_cast(rcol)); - auto trim_trailing = [join_ci](std::string sl) { - while (!sl.empty() && sl.back() == ' ') sl.pop_back(); - if (join_ci) - for (char& ch : sl) - ch = static_cast(std::toupper( - static_cast(ch))); - return sl; - }; - - // INNER / LEFT walk left + lookup right. RIGHT swaps that -- - // walk right + lookup left. FULL walks left first (emitting - // matched + LEFT-style fillers) and then walks right to emit - // only the unmatched right rows with a blank left filler. - // The merged schema's column order (left fields first, then - // right with `R_` prefix) stays identical regardless of join - // direction so the cursor exposes the same shape to apps. - bool walk_right = j.is_right; - - std::unordered_map> probe_map; - if (walk_right) { - // Hash the LEFT column (we'll walk the right side and - // look up each right row's join value in this map). - std::uint32_t lrc_for_hash = ltbl->record_count(); - for (std::uint32_t r = 1; r <= lrc_for_hash; ++r) { - if (auto g = ltbl->goto_record(r); !g) continue; - if (!ltbl->show_deleted_records() && - ltbl->is_deleted()) continue; - auto v = ltbl->read_field( - static_cast(lcol)); - if (!v) continue; - probe_map[trim_trailing(v.value().as_string)].push_back(r); - } - } else { - // Default: hash the RIGHT column (M10.14 + M10.16 path). - std::uint32_t rrc = rtbl->record_count(); - for (std::uint32_t r = 1; r <= rrc; ++r) { - if (auto g = rtbl->goto_record(r); !g) continue; - if (!rtbl->show_deleted_records() && - rtbl->is_deleted()) continue; - auto v = rtbl->read_field( - static_cast(rcol)); - if (!v) continue; - probe_map[trim_trailing(v.value().as_string)].push_back(r); - } - } - // Keep the legacy name `rmap` working -- the executor below - // walks one side and probes the other through this map. - auto& rmap = probe_map; - - // Build merged schema. The temp cursor is a text-convention DBF, - // but the SOURCE tables may be ADT (pmsys) whose records carry a - // 5-byte prefix and binary field encodings -- so the merge must go - // through DECODED field values (read_field), never raw record - // bytes, and each source field maps to a DBF text descriptor. - struct JOutCol { - std::uint16_t src_idx; // field in source - bool from_right; - openads::drivers::DbfField fld; // output descriptor - }; - std::vector jcols; - std::vector merged; - // Source CICHAR columns demote to plain 'C' in the temp DBF, so - // remember them by merged name -- the WHERE/ORDER BY compilers on - // the joined cursor consult this set to keep SAP's - // case-insensitive collation. - std::unordered_set join_ci_cols; - auto lower_nm = [](std::string v) { - for (auto& ch : v) - ch = static_cast( - std::tolower(static_cast(ch))); - return v; - }; - { - const auto& lfields = ltbl->driver()->fields(); - const auto& rfields = rtbl->driver()->fields(); - for (std::size_t i = 0; i < lfields.size(); ++i) { - JOutCol oc; - oc.src_idx = static_cast(i); - oc.from_right = false; - oc.fld = scriptbridge::join_out_field(lfields[i]); - if (lfields[i].type == - openads::drivers::DbfFieldType::CiCharacter) - join_ci_cols.insert(lower_nm(oc.fld.name)); - merged.push_back(oc.fld); - jcols.push_back(std::move(oc)); - } - for (std::size_t i = 0; i < rfields.size(); ++i) { - JOutCol oc; - oc.src_idx = static_cast(i); - oc.from_right = true; - oc.fld = scriptbridge::join_out_field(rfields[i]); - // Full-length merged name -- the ADT temp carries it whole. - // (The DBF-era truncation to 10 chars here made a long - // right-side column unreachable by its R_ form.) - oc.fld.name = "R_" + oc.fld.name; - if (rfields[i].type == - openads::drivers::DbfFieldType::CiCharacter) - join_ci_cols.insert(lower_nm(oc.fld.name)); - merged.push_back(oc.fld); - jcols.push_back(std::move(oc)); - } - } - - std::uint32_t merged_rec = 0; - for (const auto& f : merged) merged_rec += f.length; - if (merged_rec + 5 > 0xFFFF) { - c->close_table(lh.value()); c->close_table(rh.value()); - return fail(openads::AE_INTERNAL_ERROR, - "joined record exceeds 64 KiB"); - } - - // Merged column specs + row images for the shared ADT temp helper - // (docs/materialised-cursor-temps.md -- the DBF this used to write - // truncated every merged name, R_ prefix included, to 10 chars). - std::vector jtcols; - jtcols.reserve(merged.size()); - for (const auto& f : merged) { - jtcols.push_back({f.name, f.raw_type, f.length, f.decimals}); - } - std::vector file; - - // Helper: emit one merged record from the DECODED field values of - // the currently-positioned source rows (driver-abstracted -- works - // for ADT and DBF alike). Either side may be absent -- outer-join - // fillers leave that side's cells blank. - std::uint32_t emitted = 0; - auto emit_merged = [&](bool has_left, bool has_right) { - std::vector mrec(merged_rec, ' '); - std::size_t off = 0; - for (const auto& oc : jcols) { - const std::uint16_t L = oc.fld.length; - const bool present = oc.from_right ? has_right : has_left; - if (present) { - openads::engine::Table* st = - oc.from_right ? rtbl : ltbl; - std::string s = scriptbridge::join_cell_text( - *st, oc.src_idx, oc.fld); - std::memcpy(mrec.data() + off, s.data(), s.size()); - } - off += L; - } - file.insert(file.end(), mrec.begin(), mrec.end()); - ++emitted; - }; - - if (walk_right) { - // RIGHT OUTER -- walk right rows, look up the LEFT hash. - // Unmatched right rows surface with blank left fields. - std::uint32_t rrc = rtbl->record_count(); - for (std::uint32_t r = 1; r <= rrc; ++r) { - if (auto g = rtbl->goto_record(r); !g) continue; - if (!rtbl->show_deleted_records() && - rtbl->is_deleted()) continue; - auto rv = rtbl->read_field( - static_cast(rcol)); - if (!rv) continue; - auto lit = rmap.find(trim_trailing(rv.value().as_string)); - if (lit == rmap.end()) { - emit_merged(false, true); - continue; - } - for (std::uint32_t ll : lit->second) { - if (auto g = ltbl->goto_record(ll); !g) continue; - emit_merged(true, true); - } - } - } else { - // INNER / LEFT / FULL -- walk left rows, look up the RIGHT - // hash. Unmatched left rows surface with blank right - // fields when is_left or is_full; dropped otherwise. - std::unordered_set matched_right; - std::uint32_t lrc = ltbl->record_count(); - for (std::uint32_t l = 1; l <= lrc; ++l) { - if (auto g = ltbl->goto_record(l); !g) continue; - if (!ltbl->show_deleted_records() && - ltbl->is_deleted()) continue; - auto lv = ltbl->read_field( - static_cast(lcol)); - if (!lv) continue; - auto rit = rmap.find(trim_trailing(lv.value().as_string)); - if (rit == rmap.end()) { - if (j.is_left || j.is_full) { - emit_merged(true, false); - } - continue; - } - for (std::uint32_t rr : rit->second) { - if (auto g = rtbl->goto_record(rr); !g) continue; - // read_field targets the positioned row on BOTH - // sides; ltbl still sits on record l. - emit_merged(true, true); - if (j.is_full) matched_right.insert(rr); - } - } - // FULL OUTER: emit unmatched right rows with blank left. - if (j.is_full) { - std::uint32_t rrc = rtbl->record_count(); - for (std::uint32_t r = 1; r <= rrc; ++r) { - if (matched_right.find(r) != matched_right.end()) continue; - if (auto g = rtbl->goto_record(r); !g) continue; - if (!rtbl->show_deleted_records() && - rtbl->is_deleted()) continue; - emit_merged(false, true); - } - } - } - (void)emitted; - c->close_table(lh.value()); - c->close_table(rh.value()); - - // Materialise + open the merged cursor; NOT yet the user-visible - // handle -- WHERE / ORDER BY / aggregates below keep refining it. - MaterialisedTemp jtmp; - { - UNSIGNED32 mrc = materialise_temp_adt_open( - c, "_join_", jtcols, file, merged_rec, &jtmp); - if (mrc != openads::AE_SUCCESS) return mrc; - } - const Handle cth_h = jtmp.th; - openads::engine::Table* ctbl = jtmp.tbl; - // Delete the merged temp's files on every exit except the one that - // registers it as the user-visible cursor (which hands lifetime to - // materialised_cursor_temps). Error paths close the table first, so - // the removal succeeds; the join+aggregate branch closes it and - // returns a different temp as the cursor, so the merged one goes. - struct JoinTempGuard { - std::string stem; - bool keep = false; - ~JoinTempGuard() { if (!keep) remove_temp_table_files(stem); } - } jtmp_guard{jtmp.stem, false}; - - // Resolve a column reference against the merged cursor: as written, - // by its merged `R_` spelling, and with the table qualifier - // stripped (the merged temp is single-table, so `l.UNITS` must find - // `R_UNITS`). Without the fallbacks, an aggregate or GROUP BY over - // a right-side column raised 2121 where SAP answers. - auto jcol_index = [&](const std::string& col) -> std::int32_t { - std::int32_t fi = ctbl->field_index(col); - if (fi < 0) fi = ctbl->field_index("R_" + col); - if (fi < 0) { - const auto dot = col.rfind('.'); - if (dot != std::string::npos) { - const std::string bare = col.substr(dot + 1); - fi = ctbl->field_index(bare); - if (fi < 0) fi = ctbl->field_index("R_" + bare); - } - } - return fi; - }; - - // M10.20: apply outer WHERE / ORDER BY against the merged - // cursor's schema (left names verbatim; right names as - // `R_`). - if (parsed.value().where) { - using Pred = std::function; - std::function( - const openads::sql::WhereExpr&)> compile; - compile = [&](const openads::sql::WhereExpr& node) - -> openads::util::Result { - using Kind = openads::sql::WhereExpr::Kind; - if (node.kind == Kind::And || node.kind == Kind::Or) { - std::vector ks; - for (auto& cn : node.children) { - auto r = compile(*cn); - if (!r) return r.error(); - ks.push_back(std::move(r).value()); - } - bool is_and = (node.kind == Kind::And); - return Pred{[ks = std::move(ks), is_and] - (openads::engine::Table& t) { - if (is_and) { - for (auto& k : ks) if (!k(t)) return false; - return true; - } - for (auto& k : ks) if (k(t)) return true; - return false; - }}; - } - if (node.kind == Kind::Not) { - auto inner = compile(*node.child); - if (!inner) return inner.error(); - return Pred{[p = std::move(inner).value()] - (openads::engine::Table& t){return !p(t);}}; - } - if (node.kind == Kind::Cmp) { - const auto& w = node.cmp; - std::int32_t fi = ctbl->field_index(w.column); - if (fi < 0) { - return openads::util::Error{ - openads::AE_COLUMN_NOT_FOUND, 0, - w.column.c_str(), ""}; - } - std::uint16_t f = static_cast(fi); - openads::sql::WhereOp op = w.op; - std::string lit = w.literal; - bool is_num = w.is_numeric; - double num = w.number; - std::shared_ptr> contains_hits; - if (op == openads::sql::WhereOp::Contains) { - namespace fs = std::filesystem; - fs::path fts_path = - fs::path(ctbl->path()).replace_extension(".fts"); - auto loaded = openads::engine::Fts::load(fts_path.string()); - if (loaded) { - openads::engine::FtsOptions opts; - auto hits = openads::engine::Fts::search( - loaded.value(), lit, opts); - contains_hits = - std::make_shared>( - hits.begin(), hits.end()); - } - } - std::string lit2 = w.literal2; - double num2 = w.number2; - openads::sql::WhereFn lhs_fn = w.lhs_fn; - bool ci_f = field_is_cichar(*ctbl, f) || - join_ci_cols.count(lower_nm( - ctbl->field_descriptor(f).name)) > 0; - return Pred{[f, op, lit, lit2, is_num, num, num2, - contains_hits, lhs_fn, ci_f] - (openads::engine::Table& t) { - if (op == openads::sql::WhereOp::Contains) { - if (!contains_hits) return false; - return contains_hits->find(t.recno()) != - contains_hits->end(); - } - auto v = t.read_field(f); - if (!v) return false; - if (op == openads::sql::WhereOp::IsNull || - op == openads::sql::WhereOp::IsNotNull) { - bool null_ish = t.is_field_null(f); - if (!null_ish) { - auto sv = v.value().as_string; - while (!sv.empty() && sv.back() == ' ') sv.pop_back(); - null_ish = sv.empty(); - } - return op == openads::sql::WhereOp::IsNull - ? null_ish : !null_ish; - } - if (op == openads::sql::WhereOp::Between) { - if (is_num) { - double d = v.value().as_double; - return d >= num && d <= num2; - } - auto sv = apply_where_fn(v.value().as_string, lhs_fn); - return where_str_cmp(sv, lit, ci_f) >= 0 && - where_str_cmp(sv, lit2, ci_f) <= 0; - } - if (op == openads::sql::WhereOp::Like) { - auto sv = apply_where_fn(v.value().as_string, lhs_fn); - while (!sv.empty() && sv.back() == ' ') sv.pop_back(); - return sql_like_match_t(sv, lit, ci_f); - } - int cmp = 0; - if (is_num) { - double d = v.value().as_double; - if (d < num) cmp = -1; - else if (d > num) cmp = 1; - } else { - cmp = where_str_cmp( - apply_where_fn(v.value().as_string, lhs_fn), - lit, ci_f); - } - switch (op) { - case openads::sql::WhereOp::Eq: return cmp == 0; - case openads::sql::WhereOp::Ne: return cmp != 0; - case openads::sql::WhereOp::Lt: return cmp < 0; - case openads::sql::WhereOp::Gt: return cmp > 0; - case openads::sql::WhereOp::Le: return cmp <= 0; - case openads::sql::WhereOp::Ge: return cmp >= 0; - default: return false; - } - }}; - } - if (node.kind == Kind::In) { - std::int32_t fidx = ctbl->field_index(node.in_clause.column); - if (fidx < 0) return openads::util::Error{ - openads::AE_COLUMN_NOT_FOUND, 0, - node.in_clause.column.c_str(), ""}; - std::uint16_t fi = static_cast(fidx); - bool ci_f = field_is_cichar(*ctbl, fi) || - join_ci_cols.count(lower_nm( - ctbl->field_descriptor(fi).name)) > 0; - auto fold = [ci_f](std::string s2) { - if (ci_f) - for (char& ch : s2) - ch = static_cast(std::toupper( - static_cast(ch))); - return s2; - }; - auto set = std::make_shared>(); - for (const auto& l2 : node.in_clause.literals) - set->insert(fold(l2)); - return Pred{[fi, set, fold](openads::engine::Table& t) { - auto v = t.read_field(fi); - if (!v) return false; - auto sv = v.value().as_string; - while (!sv.empty() && sv.back() == ' ') sv.pop_back(); - return set->count(fold(sv)) > 0; - }}; - } - return openads::util::Error{ - openads::AE_FUNCTION_NOT_AVAILABLE, 0, - "join cursor WHERE supports Cmp/AND/OR/NOT/IN only", ""}; - }; - auto compiled = compile(*parsed.value().where); - if (!compiled) return fail(compiled.error()); - ctbl->set_filter(std::move(compiled).value()); - } - if (parsed.value().order_by) { - // M10.37 -- multi-column ORDER BY against the joined cursor. - struct SortKey { - std::uint16_t field_index; - bool descending; - bool numeric; - bool ci; // CICHAR: case-insensitive collation - }; - std::vector sks; - auto add_sk = [&](const openads::sql::OrderBy& ob) - -> openads::util::Result - { - std::int32_t fi = ctbl->field_index(ob.column); - if (fi < 0) return openads::util::Error{ - openads::AE_COLUMN_NOT_FOUND, 0, - ob.column.c_str(), ""}; - const auto& fd = ctbl->field_descriptor( - static_cast(fi)); - SortKey k; - k.field_index = static_cast(fi); - k.descending = ob.descending; - k.numeric = - fd.type == openads::drivers::DbfFieldType::Numeric || - fd.type == openads::drivers::DbfFieldType::Float || - fd.type == openads::drivers::DbfFieldType::Integer || - fd.type == openads::drivers::DbfFieldType::Currency|| - fd.type == openads::drivers::DbfFieldType::Double; - k.ci = fd.type == - openads::drivers::DbfFieldType::CiCharacter || - join_ci_cols.count(lower_nm(fd.name)) > 0; - sks.push_back(k); - return std::monostate{}; - }; - if (auto r = add_sk(*parsed.value().order_by); !r) - return fail(r.error()); - for (auto& obx : parsed.value().order_by_extra) { - if (auto r = add_sk(obx); !r) return fail(r.error()); - } - struct Row { - std::uint32_t recno; - std::vector s; - std::vector d; - }; - std::vector rows; - std::uint32_t crc = ctbl->record_count(); - for (std::uint32_t r = 1; r <= crc; ++r) { - if (auto g = ctbl->goto_record(r); !g) continue; - if (ctbl->is_deleted()) continue; - if (!ctbl->passes_filter()) continue; - Row row; - row.recno = r; - row.s.resize(sks.size()); - row.d.resize(sks.size()); - for (std::size_t i = 0; i < sks.size(); ++i) { - auto v = ctbl->read_field(sks[i].field_index); - if (v) { - row.s[i] = v.value().as_string; - row.d[i] = v.value().as_double; - } - } - rows.push_back(std::move(row)); - } - std::stable_sort(rows.begin(), rows.end(), - [&](const Row& a, const Row& b) { - for (std::size_t i = 0; i < sks.size(); ++i) { - bool less, equal; - if (sks[i].numeric) { - less = a.d[i] < b.d[i]; - equal = a.d[i] == b.d[i]; - } else { - int cmp = where_str_cmp(a.s[i], b.s[i], - sks[i].ci); - less = cmp < 0; - equal = cmp == 0; - } - if (equal) continue; - return sks[i].descending ? !less : less; - } - return false; - }); - std::vector seq; - seq.reserve(rows.size()); - for (auto& row : rows) seq.push_back(row.recno); - ctbl->clear_filter(); - ctbl->set_recno_sequence(std::move(seq)); - } - - // M10.34 -- GROUP BY across JOIN. Same shape as the plain-table - // grouped path (M10.25) but reads from the merged cursor. - if (!parsed.value().group_by.empty() && - !parsed.value().aggregates.empty()) { - struct AggSlot { - openads::sql::Aggregate def; - std::int32_t field_index = -1; - std::uint8_t decimals = 0; // source-field dp - std::uint16_t src_len = 0; // source-field width - bool is_text = false; // MIN/MAX on text - bool is_date = false; // date-sourced - std::int32_t rhs_field = -1; // SUM(a * b): b - std::uint8_t arg_dec = 0; // op-following dp - std::uint16_t arg_len = 0; // SAP arg width - }; - std::vector slots; - slots.reserve(parsed.value().aggregates.size()); - for (auto& a : parsed.value().aggregates) { - AggSlot slot; - slot.def = a; - if (a.kind != openads::sql::AggregateKind::CountStar) { - slot.field_index = jcol_index(a.column); - if (slot.field_index < 0) { - c->close_table(cth_h); - return fail(openads::AE_COLUMN_NOT_FOUND, - a.column.c_str()); - } - { - const auto& sfd = ctbl->field_descriptor( - static_cast(slot.field_index)); - using FT = openads::drivers::DbfFieldType; - slot.decimals = (sfd.type == FT::AdtMoney || - sfd.type == FT::Currency) - ? 4 : sfd.decimals; // money: 4 implied - slot.src_len = sfd.length; - slot.is_text = - scriptbridge::agg_source_is_text(sfd.type); - slot.is_date = - scriptbridge::agg_source_is_date(sfd.type); - } - // SUM(a * b): resolve the right-hand column against the - // merged cursor too (R_ fallback included), and let the - // result scale follow the operation like the - // single-table path does. Reading only the left column - // summed SUM([real] * units) as SUM(real) - masked in - // the gate by a group whose units were all 1. - if (a.arg_expr && !a.arg_expr->rhs_is_literal) { - slot.rhs_field = jcol_index(a.arg_expr->rhs_column); - if (slot.rhs_field < 0) { - c->close_table(cth_h); - return fail(openads::AE_COLUMN_NOT_FOUND, - a.arg_expr->rhs_column.c_str()); - } - } - if (a.arg_expr) slot.is_text = false; - { - // SAP's declared shape for the argument (see - // agg_arg_shape). - const openads::drivers::DbfField* rfd = nullptr; - if (a.arg_expr && !a.arg_expr->rhs_is_literal && - slot.rhs_field >= 0) { - rfd = &ctbl->field_descriptor( - static_cast(slot.rhs_field)); - } - auto shp = scriptbridge::agg_arg_shape( - ctbl->field_descriptor( - static_cast(slot.field_index)), - a, rfd); - slot.arg_dec = shp.dec; - slot.arg_len = shp.len; - } - } - slots.push_back(std::move(slot)); - } - - struct GBCol { - std::uint16_t field_index; - std::uint8_t length; - std::string name; - std::uint8_t raw_type; - }; - std::vector gbs; - gbs.reserve(parsed.value().group_by.size()); - for (auto& gname : parsed.value().group_by) { - std::int32_t fi = jcol_index(gname); - if (fi < 0) { - c->close_table(cth_h); - return fail(openads::AE_COLUMN_NOT_FOUND, gname.c_str()); - } - const auto& fd = ctbl->field_descriptor( - static_cast(fi)); - GBCol gc; - gc.field_index = static_cast(fi); - gc.length = static_cast(fd.length); - gc.name = gname; - gc.raw_type = static_cast(fd.raw_type); - gbs.push_back(std::move(gc)); - } - - auto resolve_slot = [&](const openads::sql::HavingCmp& ha) - -> std::int32_t { - for (std::size_t i = 0; i < slots.size(); ++i) { - if (slots[i].def.kind == ha.agg.kind && - slots[i].def.column == ha.agg.column) { - return static_cast(i); - } - } - if (ha.agg.kind == openads::sql::AggregateKind::CountStar) { - for (std::size_t i = 0; i < slots.size(); ++i) { - if (slots[i].def.kind == - openads::sql::AggregateKind::CountStar) { - return static_cast(i); - } - } - } - return -1; - }; - if (parsed.value().having) { - std::function( - const openads::sql::HavingExpr&)> validate; - validate = [&](const openads::sql::HavingExpr& n) - -> openads::util::Result { - using K = openads::sql::HavingExpr::Kind; - if (n.kind == K::And || n.kind == K::Or) { - for (auto& cn : n.children) { - auto r = validate(*cn); - if (!r) return r.error(); - } - return std::monostate{}; - } - if (n.kind == K::Not) return validate(*n.child); - if (resolve_slot(n.cmp) < 0) { - return openads::util::Error{ - openads::AE_PARSE_ERROR, 0, - "HAVING aggregate must match one in projection", - ""}; - } - return std::monostate{}; - }; - auto vr = validate(*parsed.value().having); - if (!vr) { - c->close_table(cth_h); - return fail(vr.error()); - } - } - - struct GroupAcc { - std::vector key_parts; - std::vector sum; - // Kahan compensation: summing hundreds of - // thousands of double-rounded terms drifts the - // display digits (SUM(Real*PRICE) read ...2047 - // where SAP shows ...2000). - std::vector sumc; - std::vector minv; - std::vector maxv; - // MIN/MAX over a non-numeric column compares decoded text. - std::vector txt; - std::vector count; - std::uint64_t row_count = 0; - }; - std::unordered_map groups; - std::vector insertion_order; - std::uint32_t crc3 = ctbl->record_count(); - for (std::uint32_t r = 1; r <= crc3; ++r) { - if (auto g = ctbl->goto_record(r); !g) continue; - if (ctbl->is_deleted()) continue; - if (!ctbl->passes_filter()) continue; - std::string key; - std::vector parts; - parts.reserve(gbs.size()); - for (auto& g : gbs) { - auto v = ctbl->read_field(g.field_index); - std::string raw = v ? v.value().as_string : std::string(); - if (raw.size() < g.length) - raw.append(g.length - raw.size(), ' '); - else if (raw.size() > g.length) raw.resize(g.length); - parts.push_back(raw); - key.append(raw); - key.push_back('\x1f'); - } - auto git = groups.find(key); - if (git == groups.end()) { - GroupAcc acc; - acc.key_parts = std::move(parts); - acc.sum.assign(slots.size(), 0.0); - acc.sumc.assign(slots.size(), 0.0); - acc.minv.assign(slots.size(), - std::numeric_limits::infinity()); - acc.maxv.assign(slots.size(), - -std::numeric_limits::infinity()); - acc.count.assign(slots.size(), 0); - acc.txt.assign(slots.size(), scriptbridge::TextMinMax{}); - git = groups.emplace(key, std::move(acc)).first; - insertion_order.push_back(key); - } - auto& acc = git->second; - ++acc.row_count; - for (std::size_t i = 0; i < slots.size(); ++i) { - if (slots[i].def.kind == - openads::sql::AggregateKind::CountStar) { - ++acc.count[i]; continue; - } - auto v = ctbl->read_field( - static_cast(slots[i].field_index)); - if (!v) continue; - if (slots[i].is_text) { - if (v.value().is_null) continue; - ++acc.count[i]; - acc.txt[i].feed(v.value().as_string); - continue; - } - double d = scriptbridge::agg_arg_value( - *ctbl, slots[i].field_index, slots[i].rhs_field, - slots[i].def, slots[i].arg_dec); - ++acc.count[i]; - { // Kahan-compensated accumulation (see GroupAcc note) - const double y2 = d - acc.sumc[i]; - const double t2 = acc.sum[i] + y2; - acc.sumc[i] = (t2 - acc.sum[i]) - y2; - acc.sum[i] = t2; - } - if (d < acc.minv[i]) acc.minv[i] = d; - if (d > acc.maxv[i]) acc.maxv[i] = d; - } - } - c->close_table(cth_h); - - auto agg_at = [&](const GroupAcc& acc, std::size_t si) -> double { - using K = openads::sql::AggregateKind; - switch (slots[si].def.kind) { - case K::CountStar: return static_cast(acc.row_count); - case K::Count: return static_cast(acc.count[si]); - case K::Sum: return acc.sum[si]; - case K::Avg: - return acc.count[si] - ? scriptbridge::avg_truncate( - acc.sum[si] / - static_cast(acc.count[si]), - static_cast(slots[si].decimals)) - : 0.0; - case K::Min: - return acc.count[si] ? acc.minv[si] : 0.0; - case K::Max: - return acc.count[si] ? acc.maxv[si] : 0.0; - } - return 0.0; - }; - std::function eval_having; - eval_having = [&](const openads::sql::HavingExpr& n, - const GroupAcc& acc) -> bool { - using K = openads::sql::HavingExpr::Kind; - if (n.kind == K::And) { - for (auto& cn : n.children) - if (!eval_having(*cn, acc)) return false; - return true; - } - if (n.kind == K::Or) { - for (auto& cn : n.children) - if (eval_having(*cn, acc)) return true; - return false; - } - if (n.kind == K::Not) return !eval_having(*n.child, acc); - std::int32_t si = resolve_slot(n.cmp); - if (si < 0) return false; - double v = agg_at(acc, static_cast(si)); - double rhs = n.cmp.num; - switch (n.cmp.op) { - case openads::sql::WhereOp::Eq: return v == rhs; - case openads::sql::WhereOp::Ne: return v != rhs; - case openads::sql::WhereOp::Lt: return v < rhs; - case openads::sql::WhereOp::Gt: return v > rhs; - case openads::sql::WhereOp::Le: return v <= rhs; - case openads::sql::WhereOp::Ge: return v >= rhs; - default: return false; - } - }; - - // Materialised through the shared ADT temp helper - // (docs/materialised-cursor-temps.md): the DBF this used to - // write truncated aliases to 11 chars. - // SAP parity (oracle-verified 07/18/2026): the result carries - // ONLY the projected aggregates -- group keys are not emitted - // unless projected. Columns are typed N(20,dp) (dp from the - // source field; COUNT dp=0), named alias / EXPR / EXPR_1 / …, - // and groups emit sorted ascending by group key. - auto jg_dp = [&](std::size_t i) -> int { - using K = openads::sql::AggregateKind; - switch (slots[i].def.kind) { - case K::CountStar: case K::Count: return 0; - // AVG keeps the SOURCE column's decimals (oracle - // 2026-07-21: money -> 4dp, integer -> 0dp; not 6); - // an arithmetic argument follows the operation. - default: return static_cast(slots[i].arg_dec); - } - }; - // A text MIN/MAX column is one width for every group: the widest - // decoded value seen anywhere (the source descriptor would - // truncate -- an ADT date is 4 bytes on disk, 8 decoded). - std::vector jg_textw(slots.size(), 0); - for (auto& kv : groups) - for (std::size_t i = 0; i < slots.size(); ++i) - if (kv.second.txt[i].w > jg_textw[i]) - jg_textw[i] = kv.second.txt[i].w; - auto jg_is_text = [&](std::size_t i) { - using K = openads::sql::AggregateKind; - return slots[i].is_text && (slots[i].def.kind == K::Min || - slots[i].def.kind == K::Max); - }; - // SAP's declared width per aggregate (see agg_result_width). - auto jg_w = [&](std::size_t i) -> std::uint16_t { - if (jg_is_text(i)) - return static_cast( - jg_textw[i] ? jg_textw[i] : 1); - return scriptbridge::agg_result_width(slots[i].def.kind, - slots[i].arg_len); - }; - std::size_t jg_stride = 0; - std::vector jg_cols; - jg_cols.reserve(slots.size()); - { - std::size_t unaliased = 0; - for (std::size_t i = 0; i < slots.size(); ++i) { - std::string nm = slots[i].def.alias; - if (nm.empty()) { - nm = unaliased == 0 - ? "EXPR" : "EXPR_" + std::to_string(unaliased); - ++unaliased; - } - jg_cols.push_back({std::move(nm), - jg_is_text(i) - ? (slots[i].is_date ? 'D' : 'C') - : 'N', - jg_w(i), - jg_is_text(i) - ? std::uint8_t{0} - : static_cast( - jg_dp(i))}); - jg_stride += jg_cols.back().len; - } - } - std::vector jg_file; - - std::sort(insertion_order.begin(), insertion_order.end()); - std::uint32_t jg_emitted = 0; - for (auto& key : insertion_order) { - auto& acc = groups[key]; - if (parsed.value().having) { - if (!eval_having(*parsed.value().having, acc)) continue; - } - for (std::size_t i = 0; i < slots.size(); ++i) { - const std::string* jtv = nullptr; - if (jg_is_text(i)) - jtv = (slots[i].def.kind == - openads::sql::AggregateKind::Min) - ? &acc.txt[i].mn : &acc.txt[i].mx; - const auto cell = scriptbridge::agg_cell_text( - slots[i].def.kind, jg_w(i), jg_dp(i), - agg_at(acc, i), jtv); - jg_file.insert(jg_file.end(), cell.begin(), cell.end()); - } - ++jg_emitted; - } - (void)jg_emitted; - return materialise_temp_adt(c, "_jgrp_", jg_cols, jg_file, - jg_stride, phCursor); - } - - // M10.23 -- JOIN + aggregate. Walk the merged cursor (already - // filtered by the outer WHERE) and replace it with a 1-row - // aggregate temp DBF before registering the user-visible - // handle. - if (!parsed.value().aggregates.empty()) { - struct AggSlot { - openads::sql::Aggregate def; - std::int32_t field_index = -1; - std::uint8_t decimals = 0; // source-field dp - std::uint16_t src_len = 0; // source-field width - bool is_text = false; // MIN/MAX on text - bool is_date = false; // date-sourced - std::int32_t rhs_field = -1; // SUM(a * b): b - std::uint8_t arg_dec = 0; // op-following dp - std::uint16_t arg_len = 0; // SAP arg width - }; - std::vector slots; - slots.reserve(parsed.value().aggregates.size()); - for (auto& a : parsed.value().aggregates) { - AggSlot slot; - slot.def = a; - if (a.kind != openads::sql::AggregateKind::CountStar) { - slot.field_index = jcol_index(a.column); - if (slot.field_index < 0) { - c->close_table(cth_h); - return fail(openads::AE_COLUMN_NOT_FOUND, a.column.c_str()); - } - { - const auto& sfd = ctbl->field_descriptor( - static_cast(slot.field_index)); - using FT = openads::drivers::DbfFieldType; - slot.decimals = (sfd.type == FT::AdtMoney || - sfd.type == FT::Currency) - ? 4 : sfd.decimals; // money: 4 implied - slot.src_len = sfd.length; - slot.is_text = - scriptbridge::agg_source_is_text(sfd.type); - slot.is_date = - scriptbridge::agg_source_is_date(sfd.type); - } - // SUM(a * b): resolve the right-hand column against the - // merged cursor too (R_ fallback included), and let the - // result scale follow the operation like the - // single-table path does. Reading only the left column - // summed SUM([real] * units) as SUM(real) - masked in - // the gate by a group whose units were all 1. - if (a.arg_expr && !a.arg_expr->rhs_is_literal) { - slot.rhs_field = jcol_index(a.arg_expr->rhs_column); - if (slot.rhs_field < 0) { - c->close_table(cth_h); - return fail(openads::AE_COLUMN_NOT_FOUND, - a.arg_expr->rhs_column.c_str()); - } - } - if (a.arg_expr) slot.is_text = false; - { - // SAP's declared shape for the argument (see - // agg_arg_shape). - const openads::drivers::DbfField* rfd = nullptr; - if (a.arg_expr && !a.arg_expr->rhs_is_literal && - slot.rhs_field >= 0) { - rfd = &ctbl->field_descriptor( - static_cast(slot.rhs_field)); - } - auto shp = scriptbridge::agg_arg_shape( - ctbl->field_descriptor( - static_cast(slot.field_index)), - a, rfd); - slot.arg_dec = shp.dec; - slot.arg_len = shp.len; - } - } - slots.push_back(std::move(slot)); - } - - std::vector sum(slots.size(), 0.0); - std::vector sumc(slots.size(), 0.0); // Kahan comp. - std::vector minv(slots.size(), - std::numeric_limits::infinity()); - std::vector maxv(slots.size(), - -std::numeric_limits::infinity()); - // MIN/MAX over a non-numeric column compares decoded text. - std::vector jtxt(slots.size()); - std::vector count(slots.size(), 0); - std::uint64_t row_count = 0; - std::uint32_t crc2 = ctbl->record_count(); - for (std::uint32_t r = 1; r <= crc2; ++r) { - if (auto g = ctbl->goto_record(r); !g) continue; - if (ctbl->is_deleted()) continue; - if (!ctbl->passes_filter()) continue; - ++row_count; - for (std::size_t i = 0; i < slots.size(); ++i) { - if (slots[i].def.kind == openads::sql::AggregateKind::CountStar) { - ++count[i]; continue; - } - auto v = ctbl->read_field( - static_cast(slots[i].field_index)); - if (!v) continue; - if (slots[i].is_text) { - if (v.value().is_null) continue; - ++count[i]; - jtxt[i].feed(v.value().as_string); - continue; - } - double d = scriptbridge::agg_arg_value( - *ctbl, slots[i].field_index, slots[i].rhs_field, - slots[i].def, slots[i].arg_dec); - ++count[i]; - { // Kahan-compensated accumulation - const double y2 = d - sumc[i]; - const double t2 = sum[i] + y2; - sumc[i] = (t2 - sum[i]) - y2; - sum[i] = t2; - } - if (d < minv[i]) minv[i] = d; - if (d > maxv[i]) maxv[i] = d; - } - } - c->close_table(cth_h); - - // Materialised through the shared ADT temp helper - // (docs/materialised-cursor-temps.md): the DBF this used to - // write truncated aliases to 10 chars. - // SAP parity: columns typed N(20,dp), named alias / EXPR / - // EXPR_1 / … (same rules as the grouped paths). - auto jagg_dp = [&](std::size_t i) -> int { - using K = openads::sql::AggregateKind; - switch (slots[i].def.kind) { - case K::CountStar: case K::Count: return 0; - // AVG keeps the SOURCE column's decimals (oracle - // 2026-07-21: money -> 4dp, integer -> 0dp; not 6); - // an arithmetic argument follows the operation. - default: return static_cast(slots[i].arg_dec); - } - }; - auto jagg_is_text = [&](std::size_t i) { - using K = openads::sql::AggregateKind; - return slots[i].is_text && (slots[i].def.kind == K::Min || - slots[i].def.kind == K::Max); - }; - // SAP's declared width per aggregate (see agg_result_width); a - // text MIN/MAX sizes to the widest decoded value instead. - auto jagg_w = [&](std::size_t i) -> std::uint16_t { - if (jagg_is_text(i)) - return static_cast( - jtxt[i].w ? jtxt[i].w : 1); - return scriptbridge::agg_result_width(slots[i].def.kind, - slots[i].arg_len); - }; - std::size_t jrow_stride = 0; - std::vector jcolspecs; - jcolspecs.reserve(slots.size()); - { - std::size_t unaliased = 0; - for (std::size_t i = 0; i < slots.size(); ++i) { - std::string nm = slots[i].def.alias; - if (nm.empty()) { - nm = unaliased == 0 - ? "EXPR" : "EXPR_" + std::to_string(unaliased); - ++unaliased; - } - jcolspecs.push_back({std::move(nm), - jagg_is_text(i) - ? (slots[i].is_date ? 'D' : 'C') - : 'N', - jagg_w(i), - jagg_is_text(i) - ? std::uint8_t{0} - : static_cast( - jagg_dp(i))}); - jrow_stride += jcolspecs.back().len; - } - } - std::vector agg_file; - agg_file.reserve(jrow_stride); - for (std::size_t i = 0; i < slots.size(); ++i) { - double v = 0.0; - using K = openads::sql::AggregateKind; - switch (slots[i].def.kind) { - case K::CountStar: - v = static_cast(row_count); break; - case K::Count: - v = static_cast(count[i]); break; - case K::Sum: - v = sum[i]; break; - case K::Avg: - v = count[i] - ? scriptbridge::avg_truncate( - sum[i] / static_cast(count[i]), - static_cast(slots[i].decimals)) - : 0.0; - break; - case K::Min: - v = count[i] ? minv[i] : 0.0; break; - case K::Max: - v = count[i] ? maxv[i] : 0.0; break; - } - const std::string* atv = nullptr; - if (jagg_is_text(i)) - atv = (slots[i].def.kind == - openads::sql::AggregateKind::Min) - ? &jtxt[i].mn : &jtxt[i].mx; - const auto cell = scriptbridge::agg_cell_text( - slots[i].def.kind, jagg_w(i), jagg_dp(i), v, atv); - agg_file.insert(agg_file.end(), cell.begin(), cell.end()); - } - return materialise_temp_adt(c, "_jagg_", jcolspecs, agg_file, - jrow_stride, phCursor); - } - - ADSHANDLE gh = to_ads_handle(s.registry.register_object(HandleKind::Table, ctbl)); - // The merged temp IS the user-visible cursor from here on: hand its - // on-disk lifetime to the cursor handle. - jtmp_guard.keep = true; - materialised_cursor_temps()[gh] = jtmp.stem; - // Apply the SELECT projection to the joined cursor (previously - // ignored -- a join always exposed every merged column). Left - // columns resolve by name; a right-table column that collides - // with a left name is reachable via its merged `R_` form. - std::vector jproj; - if (!parsed.value().projection.empty()) { - jproj.reserve(parsed.value().projection.size()); - for (const auto& col : parsed.value().projection) { - std::int32_t fidx = ctbl->field_index(col); - if (fidx < 0) { - // Full-length lookup -- the ADT temp carries the whole - // merged name (no DBF 10-char truncation any more). - fidx = ctbl->field_index("R_" + col); - } - if (fidx < 0) - return fail(openads::AE_COLUMN_NOT_FOUND, col.c_str()); - jproj.push_back(static_cast(fidx)); - } - } - // S4 -- DISTINCT / TOP / LIMIT [OFFSET] on the joined cursor - // (previously silently ignored). Same shape as the single-table - // M10.31/M10.32 block: operate on the post-WHERE / - // post-ORDER-BY traversal sequence; DISTINCT dedups by the - // projected columns (all merged columns for SELECT *). - if (parsed.value().distinct || parsed.value().limit >= 0 || - parsed.value().offset > 0) { - std::vector seq; - if (ctbl->has_recno_sequence()) { - seq = ctbl->recno_sequence(); - } else { - std::uint32_t rcount = ctbl->record_count(); - seq.reserve(rcount); - for (std::uint32_t r = 1; r <= rcount; ++r) { - if (auto g = ctbl->goto_record(r); !g) continue; - if (ctbl->is_deleted()) continue; - if (!ctbl->passes_filter()) continue; - seq.push_back(r); - } - } - if (parsed.value().distinct) { - std::vector didx = jproj; - if (didx.empty()) { - std::uint16_t nf = ctbl->field_count(); - didx.reserve(nf); - for (std::uint16_t i = 0; i < nf; ++i) - didx.push_back(i); - } - std::unordered_set seen2; - std::vector dedup; - dedup.reserve(seq.size()); - for (auto r : seq) { - if (auto g = ctbl->goto_record(r); !g) continue; - std::string key; - for (auto fi : didx) { - auto v = ctbl->read_field(fi); - if (v) key.append(v.value().as_string); - key.push_back('\x1f'); - } - if (seen2.insert(std::move(key)).second) - dedup.push_back(r); - } - seq = std::move(dedup); - } - std::int64_t off = parsed.value().offset > 0 - ? parsed.value().offset : 0; - if (off > static_cast(seq.size())) { - seq.clear(); - } else if (off > 0) { - seq.erase(seq.begin(), seq.begin() + - static_cast:: - difference_type>(off)); - } - if (parsed.value().limit >= 0 && - static_cast(parsed.value().limit) < - seq.size()) { - seq.resize(static_cast( - parsed.value().limit)); - } - ctbl->clear_filter(); - ctbl->set_recno_sequence(std::move(seq)); - } - if (!jproj.empty()) { - // Name the result columns the way SAP does before jproj is - // moved from - a right-table column reached through its merged - // `R_` form must still answer to the name the user wrote. - cursor_aliases()[gh] = - build_projection_aliases(parsed.value(), jproj.size()); - cursor_projections()[gh] = std::move(jproj); - } - *phCursor = gh; - return ok(); - } - - // M10.46 -- derived table: `FROM (SELECT ...)`. Recursively run - // the inner SELECT first; the resulting cursor's underlying - // engine::Table becomes the source for the outer clauses. - auto& s = state(); - openads::engine::Table* tbl = nullptr; - ADSHANDLE derived_cur = 0; - if (!parsed.value().derived_sql.empty()) { - std::vector selbuf(parsed.value().derived_sql.size() + 1); - std::memcpy(selbuf.data(), - parsed.value().derived_sql.c_str(), - parsed.value().derived_sql.size() + 1); - UNSIGNED32 rrc = - AdsExecuteSQLDirect(hStatement, selbuf.data(), &derived_cur); - if (rrc != 0) return rrc; - std::lock_guard lk(s.mu); - tbl = s.registry.lookup( - derived_cur, HandleKind::Table); - if (!tbl) return fail(openads::AE_INTERNAL_ERROR, - "derived table cursor lookup"); - // continue, lock_guard scoped to whole function below by - // dropping out -- we want to hold lock through registration. - // Since `lk` would die at end of this `if` block, re-take. - } - std::lock_guard lk(s.mu); - Handle table_handle = 0; - if (!tbl) { - auto th = open_or_sys(parsed.value().table, - stmt_table_type(*it->second), - stmt_open_mode(*it->second, false), - stmt_locking_mode(*it->second)); - if (!th) return fail(th.error()); - table_handle = th.value(); - tbl = c->lookup_table(table_handle); - if (!tbl) return fail(openads::AE_INTERNAL_ERROR, "post-open"); - } - (void)table_handle; - - // M10.10: aggregate query -- walk matching rows, compute the - // aggregate accumulators, materialise a 1-row temp DBF with one - // numeric column per aggregate, and return a cursor on it. - if (!parsed.value().aggregates.empty()) { - // Resolve each aggregate's column index up front. - struct AggSlot { - openads::sql::Aggregate def; - std::int32_t field_index = -1; // -1 for COUNT(*) - std::uint8_t decimals = 0; // source-field dp - std::uint16_t src_len = 0; // source-field width - std::int32_t rhs_field = -1; // SUM(a * b): b - // Effective scale/width of the ARGUMENT. For a bare column these - // are the source's; for `a b` they follow the operation -- - // SAP renders SUM(Real * PRICE) with 4 decimals for two N(..,2) - // columns, so multiplication adds the scales. - std::uint8_t arg_dec = 0; - std::uint16_t arg_len = 0; - // MIN/MAX over a non-numeric column (a date, a name) has to - // compare the DECODED TEXT, not as_double. Accumulating a date - // as a double gave 0 for every row, so MIN(PAY_DATE) reported - // "0" instead of the earliest date. - bool is_text = false; - bool is_date = false; // date-sourced - }; - std::vector slots; - slots.reserve(parsed.value().aggregates.size()); - for (auto& a : parsed.value().aggregates) { - AggSlot slot; - slot.def = a; - if (a.kind != openads::sql::AggregateKind::CountStar) { - slot.field_index = tbl->field_index(a.column); - if (slot.field_index < 0) { - if (table_handle != 0) c->close_table(table_handle); - return fail(openads::AE_COLUMN_NOT_FOUND, a.column.c_str()); - } - { - const auto& sfd = tbl->field_descriptor( - static_cast(slot.field_index)); - using FT = openads::drivers::DbfFieldType; - slot.decimals = (sfd.type == FT::AdtMoney || - sfd.type == FT::Currency) - ? 4 : sfd.decimals; // money: 4 implied - slot.src_len = sfd.length; - slot.is_text = - scriptbridge::agg_source_is_text(sfd.type); - slot.is_date = - scriptbridge::agg_source_is_date(sfd.type); - // SUM(a * b): resolve the right-hand column too. An - // arithmetic argument is always numeric, so it never - // takes the text MIN/MAX path. - if (a.arg_expr && !a.arg_expr->rhs_is_literal) { - slot.rhs_field = - tbl->field_index(a.arg_expr->rhs_column); - if (slot.rhs_field < 0) { - if (table_handle != 0) - c->close_table(table_handle); - return fail(openads::AE_COLUMN_NOT_FOUND, - a.arg_expr->rhs_column.c_str()); - } - } - if (a.arg_expr) slot.is_text = false; - { - // SAP's declared shape for the argument -- oracle- - // derived rules in agg_arg_shape (byte-identical - // widths incl. the bare-column case). - const openads::drivers::DbfField* rfd = nullptr; - if (a.arg_expr && !a.arg_expr->rhs_is_literal && - slot.rhs_field >= 0) { - rfd = &tbl->field_descriptor( - static_cast(slot.rhs_field)); - } - auto shp = scriptbridge::agg_arg_shape( - tbl->field_descriptor( - static_cast(slot.field_index)), - a, rfd); - slot.arg_dec = shp.dec; - slot.arg_len = shp.len; - } - } - } - slots.push_back(std::move(slot)); - } - - // Build the WHERE filter (same shape as the SELECT branch - // below -- but the predicate compiles independently here so - // the aggregate path doesn't depend on that block's lambdas). - std::function filter; - if (parsed.value().where) { - using Pred = std::function; - std::function( - const openads::sql::WhereExpr&)> compile; - compile = [&](const openads::sql::WhereExpr& node) - -> openads::util::Result { - using Kind = openads::sql::WhereExpr::Kind; - if (node.kind == Kind::And || node.kind == Kind::Or) { - std::vector ks; - for (auto& cn : node.children) { - auto r = compile(*cn); - if (!r) return r.error(); - ks.push_back(std::move(r).value()); - } - bool is_and = (node.kind == Kind::And); - return Pred{[ks = std::move(ks), is_and] - (openads::engine::Table& t) { - if (is_and) { - for (auto& k : ks) if (!k(t)) return false; - return true; - } - for (auto& k : ks) if (k(t)) return true; - return false; - }}; - } - if (node.kind == Kind::Not) { - auto inner = compile(*node.child); - if (!inner) return inner.error(); - return Pred{[p = std::move(inner).value()] - (openads::engine::Table& t){return !p(t);}}; - } - if (node.kind == Kind::In) { - // S4 -- IN under an aggregate WHERE (COUNT(*) WHERE x - // IN (...)); literal lists only, CICHAR folds case. - std::int32_t fidx = - tbl->field_index(node.in_clause.column); - if (fidx < 0) { - return openads::util::Error{ - openads::AE_COLUMN_NOT_FOUND, 0, - node.in_clause.column.c_str(), ""}; - } - std::uint16_t infi = static_cast(fidx); - bool in_ci = field_is_cichar(*tbl, infi); - auto fold = [in_ci](std::string s2) { - while (!s2.empty() && s2.back() == ' ') - s2.pop_back(); - if (in_ci) - for (char& ch : s2) - ch = static_cast(std::toupper( - static_cast(ch))); - return s2; - }; - auto set = - std::make_shared>(); - for (const auto& l2 : node.in_clause.literals) - set->insert(fold(l2)); - return Pred{[infi, set, fold] - (openads::engine::Table& t) { - auto v = t.read_field(infi); - if (!v) return false; - return set->count(fold(v.value().as_string)) > 0; - }}; - } - const auto& w = node.cmp; - std::int32_t fidx = tbl->field_index(w.column); - if (fidx < 0) { - return openads::util::Error{ - openads::AE_COLUMN_NOT_FOUND, 0, - w.column.c_str(), ""}; - } - std::uint16_t fi = static_cast(fidx); - openads::sql::WhereOp op = w.op; - std::string lit = w.literal; - bool is_num = w.is_numeric; - double num = w.number; - openads::sql::WhereFn lhs_fn = w.lhs_fn; - bool ci_f = field_is_cichar(*tbl, fi); - return Pred{[fi, op, lit, is_num, num, lhs_fn, ci_f] - (openads::engine::Table& t) { - auto v = t.read_field(fi); - if (!v) return false; - if (op == openads::sql::WhereOp::Like) { - auto sv = apply_where_fn(v.value().as_string, lhs_fn); - while (!sv.empty() && sv.back() == ' ') sv.pop_back(); - return sql_like_match_t(sv, lit, ci_f); - } - int cmp = 0; - if (is_num) { - double d = v.value().as_double; - if (d < num) cmp = -1; - else if (d > num) cmp = 1; - } else { - cmp = where_str_cmp( - apply_where_fn(v.value().as_string, lhs_fn), - lit, ci_f); - } - switch (op) { - case openads::sql::WhereOp::Eq: return cmp == 0; - case openads::sql::WhereOp::Ne: return cmp != 0; - case openads::sql::WhereOp::Lt: return cmp < 0; - case openads::sql::WhereOp::Gt: return cmp > 0; - case openads::sql::WhereOp::Le: return cmp <= 0; - case openads::sql::WhereOp::Ge: return cmp >= 0; - case openads::sql::WhereOp::Contains: return false; - default: return false; - } - return false; - }}; - }; - auto compiled = compile(*parsed.value().where); - if (!compiled) { - if (table_handle != 0) c->close_table(table_handle); - return fail(compiled.error()); - } - filter = std::move(compiled).value(); - } - - // M10.25 -- `GROUP BY [, ...] [HAVING op num]`. - // Walk matching rows, hash by group-key tuple, accumulate per - // group, then emit one row per group (passing HAVING) into a - // multi-row temp DBF cursor. Schema: original group-by - // columns (preserving type + length) followed by COL1..COLn - // C(30) cells for each aggregate. - if (!parsed.value().group_by.empty()) { - struct GBCol { - std::uint16_t field_index; - std::uint8_t length; - std::string name; - std::uint8_t raw_type; - }; - std::vector gbs; - gbs.reserve(parsed.value().group_by.size()); - for (auto& gname : parsed.value().group_by) { - std::int32_t fi = tbl->field_index(gname); - if (fi < 0) { - if (table_handle != 0) c->close_table(table_handle); - return fail(openads::AE_COLUMN_NOT_FOUND, gname.c_str()); - } - const auto& fd = tbl->field_descriptor( - static_cast(fi)); - GBCol gc; - gc.field_index = static_cast(fi); - gc.length = static_cast(fd.length); - gc.name = gname; - gc.raw_type = static_cast(fd.raw_type); - gbs.push_back(std::move(gc)); - } - - // M10.30 -- HAVING is now a boolean tree of HavingCmp leaves. - // Resolve each leaf to a slot at compile time (validation - // only); per-group evaluation re-walks the tree. - auto resolve_slot = [&](const openads::sql::HavingCmp& ha) - -> std::int32_t { - for (std::size_t i = 0; i < slots.size(); ++i) { - if (slots[i].def.kind == ha.agg.kind && - slots[i].def.column == ha.agg.column) { - return static_cast(i); - } - } - if (ha.agg.kind == openads::sql::AggregateKind::CountStar) { - for (std::size_t i = 0; i < slots.size(); ++i) { - if (slots[i].def.kind == - openads::sql::AggregateKind::CountStar) { - return static_cast(i); - } - } - } - return -1; - }; - if (parsed.value().having) { - std::function( - const openads::sql::HavingExpr&)> validate; - validate = [&](const openads::sql::HavingExpr& n) - -> openads::util::Result { - using K = openads::sql::HavingExpr::Kind; - if (n.kind == K::And || n.kind == K::Or) { - for (auto& cn : n.children) { - auto r = validate(*cn); - if (!r) return r.error(); - } - return std::monostate{}; - } - if (n.kind == K::Not) return validate(*n.child); - if (resolve_slot(n.cmp) < 0) { - return openads::util::Error{ - openads::AE_PARSE_ERROR, 0, - "HAVING aggregate must match one in projection", - ""}; - } - return std::monostate{}; - }; - auto vr = validate(*parsed.value().having); - if (!vr) { - if (table_handle != 0) c->close_table(table_handle); - return fail(vr.error()); - } - } - - struct GroupAcc { - std::vector key_parts; - std::vector sum; - // Kahan compensation: summing hundreds of - // thousands of double-rounded terms drifts the - // display digits (SUM(Real*PRICE) read ...2047 - // where SAP shows ...2000). - std::vector sumc; - std::vector minv; - std::vector maxv; - // MIN/MAX over a non-numeric column compares decoded text. - std::vector txt; - std::vector count; - std::uint64_t row_count = 0; - }; - std::unordered_map groups; - std::vector insertion_order; - std::uint32_t rcount = tbl->record_count(); - for (std::uint32_t r = 1; r <= rcount; ++r) { - if (auto g = tbl->goto_record(r); !g) continue; - if (!tbl->show_deleted_records() && - tbl->is_deleted()) continue; - // A derived-table / VIEW source arrives as a live cursor - // with its own filter (the view's WHERE) -- rows it hides - // must not reach the aggregate. - if (!tbl->passes_filter()) continue; - if (filter && !filter(*tbl)) continue; - std::string key; - std::vector parts; - parts.reserve(gbs.size()); - for (auto& g : gbs) { - auto v = tbl->read_field(g.field_index); - std::string raw = v ? v.value().as_string : std::string(); - if (raw.size() < g.length) raw.append(g.length - raw.size(), ' '); - else if (raw.size() > g.length) raw.resize(g.length); - parts.push_back(raw); - key.append(raw); - key.push_back('\x1f'); - } - auto git = groups.find(key); - if (git == groups.end()) { - GroupAcc acc; - acc.key_parts = std::move(parts); - acc.sum.assign(slots.size(), 0.0); - acc.sumc.assign(slots.size(), 0.0); - acc.minv.assign(slots.size(), - std::numeric_limits::infinity()); - acc.maxv.assign(slots.size(), - -std::numeric_limits::infinity()); - acc.count.assign(slots.size(), 0); - acc.txt.assign(slots.size(), scriptbridge::TextMinMax{}); - git = groups.emplace(key, std::move(acc)).first; - insertion_order.push_back(key); - } - auto& acc = git->second; - ++acc.row_count; - for (std::size_t i = 0; i < slots.size(); ++i) { - if (slots[i].def.kind == - openads::sql::AggregateKind::CountStar) { - ++acc.count[i]; - continue; - } - auto v = tbl->read_field( - static_cast(slots[i].field_index)); - if (!v) continue; - if (slots[i].is_text) { - if (v.value().is_null) continue; - ++acc.count[i]; - acc.txt[i].feed(v.value().as_string); - continue; - } - // SUM(a * b): evaluate the expression like the scalar - // path does. Reading only the bare left column summed - // SUM([real] * units) as SUM(real) -- invisible in the - // gate's bounded group, whose units were all 1, and - // caught the day the unbounded query first completed. - double d = scriptbridge::agg_arg_value( - *tbl, slots[i].field_index, slots[i].rhs_field, - slots[i].def, slots[i].arg_dec); - ++acc.count[i]; - { // Kahan-compensated accumulation (see GroupAcc note) - const double y2 = d - acc.sumc[i]; - const double t2 = acc.sum[i] + y2; - acc.sumc[i] = (t2 - acc.sum[i]) - y2; - acc.sum[i] = t2; - } - if (d < acc.minv[i]) acc.minv[i] = d; - if (d > acc.maxv[i]) acc.maxv[i] = d; - } - } - if (table_handle != 0) c->close_table(table_handle); - - // A text MIN/MAX column must have ONE width across every group, so - // take the widest decoded value seen anywhere. Sizing from the - // source descriptor would truncate: an ADT date is 4 bytes on disk - // but 8 characters decoded. - std::vector textw(slots.size(), 0); - for (auto& kv : groups) - for (std::size_t i = 0; i < slots.size(); ++i) - if (kv.second.txt[i].w > textw[i]) - textw[i] = kv.second.txt[i].w; - auto slot_is_text = [&](std::size_t i) { - using K = openads::sql::AggregateKind; - return slots[i].is_text && - (slots[i].def.kind == K::Min || - slots[i].def.kind == K::Max); - }; - - auto agg_at = [&](const GroupAcc& acc, std::size_t si) -> double { - using K = openads::sql::AggregateKind; - switch (slots[si].def.kind) { - case K::CountStar: return static_cast(acc.row_count); - case K::Count: return static_cast(acc.count[si]); - case K::Sum: return acc.sum[si]; - case K::Avg: - return acc.count[si] - ? scriptbridge::avg_truncate( - acc.sum[si] / - static_cast(acc.count[si]), - static_cast(slots[si].decimals)) - : 0.0; - case K::Min: - return acc.count[si] ? acc.minv[si] : 0.0; - case K::Max: - return acc.count[si] ? acc.maxv[si] : 0.0; - } - return 0.0; - }; - - // Materialised through the shared ADT temp helper - // (docs/materialised-cursor-temps.md): the DBF this used to - // write truncated aliases and group-key names to 10 chars. - // SAP parity: aggregate columns are N(20,dp), named alias / - // EXPR / EXPR_1 / …; group-key columns are emitted in their - // SELECT-list position (`SELECT col, COUNT(*) … GROUP BY col`) - // with the source field's type/length. Groups emit sorted - // ascending by group key. - auto grp_dp = [&](std::size_t i) -> int { - using K = openads::sql::AggregateKind; - switch (slots[i].def.kind) { - case K::CountStar: case K::Count: return 0; - // AVG keeps the SOURCE column's decimals (oracle - // 2026-07-21: money -> 4dp, integer -> 0dp; not 6); - // an arithmetic argument follows the operation. - default: return static_cast(slots[i].arg_dec); - } - }; - // Ordered output columns from the projection: an `$AGG_` - // placeholder is aggregate slot n; anything else must be a - // group-by key column. - struct GOut { - bool is_agg = false; - std::size_t idx = 0; // slot index / gbs index - std::string name; - char type = 'N'; - std::uint8_t len = 20; - std::uint8_t dec = 0; - }; - auto ci_eq_g = [](const std::string& x, const std::string& y) { - if (x.size() != y.size()) return false; - for (std::size_t z = 0; z < x.size(); ++z) - if (std::toupper(static_cast(x[z])) != - std::toupper(static_cast(y[z]))) - return false; - return true; - }; - std::vector gouts; - { - std::size_t unaliased = 0; - std::size_t pj_pos = 0; - for (const auto& pj : parsed.value().projection) { - // Positional AS alias for this projection item (an - // aggregate's alias travels in def.alias instead). - const std::string* pj_alias = nullptr; - for (const auto& pa : parsed.value().projection_aliases) - if (pa.first == pj_pos) { pj_alias = &pa.second; break; } - ++pj_pos; - GOut o; - if (pj.rfind("$AGG_", 0) == 0) { - o.is_agg = true; - o.idx = static_cast( - std::stoul(pj.substr(5))); - std::string nm = slots[o.idx].def.alias; - if (nm.empty()) { - nm = unaliased == 0 ? "EXPR" - : "EXPR_" + std::to_string(unaliased); - ++unaliased; - } - o.name = nm; - const bool tx_o = slot_is_text(o.idx); - o.type = tx_o - ? (slots[o.idx].is_date ? 'D' : 'C') - : 'N'; - o.len = tx_o - ? static_cast( - textw[o.idx] ? textw[o.idx] : 1) - : static_cast( - scriptbridge::agg_result_width( - slots[o.idx].def.kind, - slots[o.idx].arg_len)); - o.dec = tx_o ? 0 - : static_cast(grp_dp(o.idx)); - } else { - std::int32_t gi = -1; - for (std::size_t j = 0; j < gbs.size(); ++j) - if (ci_eq_g(gbs[j].name, pj)) { - gi = static_cast(j); break; - } - if (gi < 0) { - if (table_handle != 0) - c->close_table(table_handle); - return fail(openads::AE_PARSE_ERROR, - ("SELECT column must be a GROUP BY key: " + - pj).c_str()); - } - o.is_agg = false; - o.idx = static_cast(gi); - // SAP names a group-key column from the SELECT - // list: an AS alias wins, else the spelling as - // written (constraint 4 in - // docs/materialised-cursor-temps.md). The DBF-era - // truncation used to mask this. - o.name = pj_alias ? *pj_alias : pj; - o.type = gbs[o.idx].raw_type - ? static_cast(gbs[o.idx].raw_type) - : 'C'; - o.len = gbs[o.idx].length; - o.dec = 0; - } - gouts.push_back(std::move(o)); - } - // Defensive: an aggregate query always has $AGG_ items, but - // fall back to all slots if somehow none were recorded. - if (gouts.empty()) { - for (std::size_t i = 0; i < slots.size(); ++i) { - GOut o; - o.is_agg = true; o.idx = i; - o.name = slots[i].def.alias.empty() - ? (i == 0 ? "EXPR" - : "EXPR_" + std::to_string(i)) - : slots[i].def.alias; - const bool tx_i = slot_is_text(i); - o.type = tx_i - ? (slots[i].is_date ? 'D' : 'C') - : 'N'; - o.len = tx_i - ? static_cast( - textw[i] ? textw[i] : 1) - : static_cast( - scriptbridge::agg_result_width( - slots[i].def.kind, slots[i].arg_len)); - o.dec = tx_i ? 0 - : static_cast(grp_dp(i)); - gouts.push_back(std::move(o)); - } - } - } - std::size_t grow_stride = 0; - std::vector gcols; - gcols.reserve(gouts.size()); - for (const auto& o : gouts) { - gcols.push_back({o.name, o.type, o.len, o.dec}); - grow_stride += gcols.back().len; - } - std::vector grows; - - std::function eval_having; - eval_having = [&](const openads::sql::HavingExpr& n, - const GroupAcc& acc) -> bool { - using K = openads::sql::HavingExpr::Kind; - if (n.kind == K::And) { - for (auto& cn : n.children) - if (!eval_having(*cn, acc)) return false; - return true; - } - if (n.kind == K::Or) { - for (auto& cn : n.children) - if (eval_having(*cn, acc)) return true; - return false; - } - if (n.kind == K::Not) return !eval_having(*n.child, acc); - std::int32_t si = resolve_slot(n.cmp); - if (si < 0) return false; - double v = agg_at(acc, static_cast(si)); - double rhs = n.cmp.num; - switch (n.cmp.op) { - case openads::sql::WhereOp::Eq: return v == rhs; - case openads::sql::WhereOp::Ne: return v != rhs; - case openads::sql::WhereOp::Lt: return v < rhs; - case openads::sql::WhereOp::Gt: return v > rhs; - case openads::sql::WhereOp::Le: return v <= rhs; - case openads::sql::WhereOp::Ge: return v >= rhs; - default: return false; - } - }; - - std::sort(insertion_order.begin(), insertion_order.end()); - std::uint32_t emitted = 0; - for (auto& key : insertion_order) { - auto& acc = groups[key]; - if (parsed.value().having) { - if (!eval_having(*parsed.value().having, acc)) continue; - } - for (const auto& o : gouts) { - if (o.is_agg) { - const bool tx = slot_is_text(o.idx); - const std::string* tv = nullptr; - if (tx) tv = (slots[o.idx].def.kind == - openads::sql::AggregateKind::Min) - ? &acc.txt[o.idx].mn : &acc.txt[o.idx].mx; - const auto cell = scriptbridge::agg_cell_text( - slots[o.idx].def.kind, o.len, - static_cast(o.dec), agg_at(acc, o.idx), tv); - grows.insert(grows.end(), cell.begin(), cell.end()); - } else { - // Group-key column: the stored key part, already - // padded to gbs[idx].length at accumulation time. - std::string kp = o.idx < acc.key_parts.size() - ? acc.key_parts[o.idx] : std::string(); - for (std::uint8_t b = 0; b < o.len; ++b) - grows.push_back(b < kp.size() - ? static_cast(kp[b]) : ' '); - } - } - ++emitted; - } - (void)emitted; - return materialise_temp_adt(c, "_grp_", gcols, grows, - grow_stride, phCursor); - } - - // M10.54 -- compile each slot's optional FILTER. Subset: - // Cmp / AND / OR / NOT (full WHERE support left to follow-up). - using AggPred = std::function; - std::vector slot_preds(slots.size()); - for (std::size_t i = 0; i < slots.size(); ++i) { - if (!parsed.value().aggregates[i].filter) continue; - std::function( - const openads::sql::WhereExpr&)> cf; - cf = [&](const openads::sql::WhereExpr& n) - -> openads::util::Result { - using K = openads::sql::WhereExpr::Kind; - if (n.kind == K::And || n.kind == K::Or) { - std::vector ks; - for (auto& cn : n.children) { - auto r = cf(*cn); - if (!r) return r.error(); - ks.push_back(std::move(r).value()); - } - bool is_and = (n.kind == K::And); - return AggPred{[ks = std::move(ks), is_and] - (openads::engine::Table& t) { - if (is_and) { - for (auto& k : ks) if (!k(t)) return false; - return true; - } - for (auto& k : ks) if (k(t)) return true; - return false; - }}; - } - if (n.kind == K::Not) { - auto inner = cf(*n.child); - if (!inner) return inner.error(); - return AggPred{[p = std::move(inner).value()] - (openads::engine::Table& t) - { return !p(t); }}; - } - if (n.kind == K::In) { - std::int32_t fidx = tbl->field_index(n.in_clause.column); - if (fidx < 0) return openads::util::Error{ - openads::AE_COLUMN_NOT_FOUND, 0, - n.in_clause.column.c_str(), ""}; - std::uint16_t fi2 = static_cast(fidx); - bool ci_f = field_is_cichar(*tbl, fi2); - auto fold = [ci_f](std::string s2) { - if (ci_f) - for (char& ch : s2) - ch = static_cast(std::toupper( - static_cast(ch))); - return s2; - }; - auto set = std::make_shared>(); - for (const auto& l2 : n.in_clause.literals) - set->insert(fold(l2)); - return AggPred{[fi2, set, fold](openads::engine::Table& t) { - auto v = t.read_field(fi2); - if (!v) return false; - auto sv = v.value().as_string; - while (!sv.empty() && sv.back() == ' ') sv.pop_back(); - return set->count(fold(sv)) > 0; - }}; - } - if (n.kind != K::Cmp) { - return openads::util::Error{ - openads::AE_FUNCTION_NOT_AVAILABLE, 0, - "aggregate FILTER supports Cmp/AND/OR/NOT/IN only", ""}; - } - const auto& w = n.cmp; - std::int32_t fi = tbl->field_index(w.column); - if (fi < 0) return openads::util::Error{ - openads::AE_COLUMN_NOT_FOUND, 0, w.column.c_str(), ""}; - std::uint16_t f = static_cast(fi); - openads::sql::WhereOp op = w.op; - std::string lit = w.literal; - std::string lit2 = w.literal2; - bool is_num = w.is_numeric; - double num = w.number; - double num2 = w.number2; - std::shared_ptr> contains_hits; - if (op == openads::sql::WhereOp::Contains) { - namespace fs = std::filesystem; - fs::path fts_path = - fs::path(tbl->path()).replace_extension(".fts"); - auto loaded = openads::engine::Fts::load(fts_path.string()); - if (loaded) { - openads::engine::FtsOptions opts; - auto hits = openads::engine::Fts::search( - loaded.value(), lit, opts); - contains_hits = - std::make_shared>( - hits.begin(), hits.end()); - } - } - openads::sql::WhereFn lhs_fn = w.lhs_fn; - bool ci_f = field_is_cichar(*tbl, f); - return AggPred{[f, op, lit, lit2, is_num, num, num2, - contains_hits, lhs_fn, ci_f] - (openads::engine::Table& t) { - if (op == openads::sql::WhereOp::Contains) { - if (!contains_hits) return false; - return contains_hits->find(t.recno()) != - contains_hits->end(); - } - auto v = t.read_field(f); - if (!v) return false; - if (op == openads::sql::WhereOp::IsNull || - op == openads::sql::WhereOp::IsNotNull) { - bool null_ish = t.is_field_null(f); - if (!null_ish) { - auto sv = v.value().as_string; - while (!sv.empty() && sv.back() == ' ') sv.pop_back(); - null_ish = sv.empty(); - } - return op == openads::sql::WhereOp::IsNull - ? null_ish : !null_ish; - } - if (op == openads::sql::WhereOp::Between) { - if (is_num) { - double d = v.value().as_double; - return d >= num && d <= num2; - } - auto sv = apply_where_fn(v.value().as_string, lhs_fn); - return where_str_cmp(sv, lit, ci_f) >= 0 && - where_str_cmp(sv, lit2, ci_f) <= 0; - } - if (op == openads::sql::WhereOp::Like) { - auto sv = apply_where_fn(v.value().as_string, lhs_fn); - while (!sv.empty() && sv.back() == ' ') sv.pop_back(); - return sql_like_match_t(sv, lit, ci_f); - } - int cmp = 0; - if (is_num) { - double d = v.value().as_double; - if (d < num) cmp = -1; - else if (d > num) cmp = 1; - } else { - cmp = where_str_cmp( - apply_where_fn(v.value().as_string, lhs_fn), - lit, ci_f); - } - switch (op) { - case openads::sql::WhereOp::Eq: return cmp == 0; - case openads::sql::WhereOp::Ne: return cmp != 0; - case openads::sql::WhereOp::Lt: return cmp < 0; - case openads::sql::WhereOp::Gt: return cmp > 0; - case openads::sql::WhereOp::Le: return cmp <= 0; - case openads::sql::WhereOp::Ge: return cmp >= 0; - default: return false; - } - }}; - }; - auto p = cf(*parsed.value().aggregates[i].filter); - if (!p) return fail(p.error()); - slot_preds[i] = std::move(p).value(); - } - - // Walk matching rows, accumulate per slot. - std::vector sum(slots.size(), 0.0); - std::vector sumc(slots.size(), 0.0); // Kahan compensation - std::vector minv(slots.size(), - std::numeric_limits::infinity()); - std::vector maxv(slots.size(), - -std::numeric_limits::infinity()); - // COUNT(DISTINCT col): the distinct values seen, per slot. - std::vector> distinct_seen( - slots.size()); - // Parallel text accumulators for MIN/MAX over a non-numeric column. - // Dates decode to "YYYYMMDD", which orders lexicographically exactly - // as it does chronologically, so a plain string compare is correct. - std::vector minsv(slots.size()); - std::vector maxsv(slots.size()); - std::vector textw(slots.size(), 0); - std::vector count(slots.size(), 0); - std::uint64_t row_count = 0; - std::uint32_t rcount = tbl->record_count(); - for (std::uint32_t r = 1; r <= rcount; ++r) { - if (auto g = tbl->goto_record(r); !g) continue; - // SAP's SQL honours AdsShowDeleted (default TRUE): deleted DBF - // rows are counted, filtered, ordered, UPDATEd and copied like - // live rows (oracle-probed on users.dbf: COUNT 18 not 10; an - // UPDATE touches all 18; SELECT INTO copies 18). Every SQL - // walk therefore skips deleted rows ONLY when the flag hides - // them; the navigational paths always worked this way. ADT - // never surfaces deleted rows, so this is DBF-only by nature. - if (!tbl->show_deleted_records() && - tbl->is_deleted()) continue; - // Same rule as the grouped walk: honour a view/derived - // cursor's own filter. - if (!tbl->passes_filter()) continue; - if (filter && !filter(*tbl)) continue; - ++row_count; - for (std::size_t i = 0; i < slots.size(); ++i) { - if (slot_preds[i] && !slot_preds[i](*tbl)) continue; - if (slots[i].def.kind == openads::sql::AggregateKind::CountStar) { - ++count[i]; - continue; - } - auto v = tbl->read_field( - static_cast(slots[i].field_index)); - if (!v) continue; - if (slots[i].def.distinct) { - // COUNT(DISTINCT col) counts values, not rows. A blank IS - // a distinct value to SAP: COUNT(DISTINCT insurance) over - // mp returns 58 and only 57 of those are non-blank. Only - // NULL is excluded. - if (v.value().is_null) continue; - distinct_seen[i].insert(v.value().as_string); - continue; - } - ++count[i]; - if (slots[i].is_text) { - // Skip NULLs so they cannot win a MIN as an empty string. - if (v.value().is_null) { --count[i]; continue; } - const std::string& sv = v.value().as_string; - if (sv.size() > textw[i]) textw[i] = sv.size(); - if (minsv[i].empty() || sv < minsv[i]) minsv[i] = sv; - if (maxsv[i].empty() || sv > maxsv[i]) maxsv[i] = sv; - continue; - } - double d = scriptbridge::agg_arg_value( - *tbl, slots[i].field_index, slots[i].rhs_field, - slots[i].def, slots[i].arg_dec); - { // Kahan-compensated accumulation - const double y2 = d - sumc[i]; - const double t2 = sum[i] + y2; - sumc[i] = (t2 - sum[i]) - y2; - sum[i] = t2; - } - if (d < minv[i]) minv[i] = d; - if (d > maxv[i]) maxv[i] = d; - } - } - if (table_handle != 0) c->close_table(table_handle); - - // Materialise the 1-row result through the shared ADT temp helper - // (docs/materialised-cursor-temps.md): the DBF this used to write - // truncated any alias over 10 characters. - // SAP parity: columns typed N(20,dp), named alias / EXPR / EXPR_1 / - // … (oracle-verified naming; unaliased aggregates count up). - auto agg_dp = [&](std::size_t i) -> int { - using K = openads::sql::AggregateKind; - switch (slots[i].def.kind) { - case K::CountStar: case K::Count: return 0; - // AVG keeps the SOURCE column's decimals (oracle - // 2026-07-21: money -> 4dp, integer -> 0dp; not 6). - default: return static_cast(slots[i].arg_dec); - } - }; - // SAP's declared width per aggregate (see agg_result_width). A - // text MIN/MAX is not a number: it is sized to the widest decoded - // value seen (a date decodes to 8 chars from a 4-byte field, so the - // source's on-disk width would truncate it). - auto agg_is_text = [&](std::size_t i) { - using K = openads::sql::AggregateKind; - return slots[i].is_text && - (slots[i].def.kind == K::Min || slots[i].def.kind == K::Max); - }; - auto agg_w = [&](std::size_t i) -> std::uint16_t { - if (agg_is_text(i)) - return static_cast(textw[i] ? textw[i] : 1); - return scriptbridge::agg_result_width(slots[i].def.kind, - slots[i].arg_len); - }; - std::size_t arow_stride = 0; - std::vector acols; - acols.reserve(slots.size()); - { - std::size_t unaliased = 0; - for (std::size_t i = 0; i < slots.size(); ++i) { - std::string nm = slots[i].def.alias; - if (nm.empty()) { - nm = unaliased == 0 - ? "EXPR" : "EXPR_" + std::to_string(unaliased); - ++unaliased; - } - acols.push_back({std::move(nm), - agg_is_text(i) - ? (slots[i].is_date ? 'D' : 'C') - : 'N', - agg_w(i), - agg_is_text(i) - ? std::uint8_t{0} - : static_cast(agg_dp(i))}); - arow_stride += acols.back().len; - } - } - std::vector arow; - arow.reserve(arow_stride); - for (std::size_t i = 0; i < slots.size(); ++i) { - char buf[32] = {0}; - switch (slots[i].def.kind) { - case openads::sql::AggregateKind::CountStar: - case openads::sql::AggregateKind::Count: - if (slots[i].def.distinct) { - std::snprintf(buf, sizeof(buf), "%llu", - static_cast( - distinct_seen[i].size())); - break; - } - // M10.54 -- when this slot has a FILTER, count[i] - // already excludes filter-failing rows; use it - // even for CountStar. - std::snprintf(buf, sizeof(buf), "%llu", - static_cast( - slots[i].def.kind == - openads::sql::AggregateKind::CountStar - ? (slot_preds[i] ? count[i] : row_count) - : count[i])); - break; - case openads::sql::AggregateKind::Sum: - std::snprintf(buf, sizeof(buf), "%.*f", agg_dp(i), - sum[i]); - break; - case openads::sql::AggregateKind::Avg: - std::snprintf(buf, sizeof(buf), "%.*f", agg_dp(i), - count[i] - ? scriptbridge::avg_truncate( - sum[i] / static_cast(count[i]), - agg_dp(i)) - : 0.0); - break; - case openads::sql::AggregateKind::Min: - if (count[i] == 0) std::memcpy(buf, "0", 2); - else if (slots[i].is_text) - std::snprintf(buf, sizeof(buf), "%.*s", - static_cast(minsv[i].size()), - minsv[i].c_str()); - else std::snprintf(buf, sizeof(buf), "%.*f", agg_dp(i), - minv[i]); - break; - case openads::sql::AggregateKind::Max: - if (count[i] == 0) std::memcpy(buf, "0", 2); - else if (slots[i].is_text) - std::snprintf(buf, sizeof(buf), "%.*s", - static_cast(maxsv[i].size()), - maxsv[i].c_str()); - else std::snprintf(buf, sizeof(buf), "%.*f", agg_dp(i), - maxv[i]); - break; - } - // Right-justified to the declared width, except COUNT which SAP - // leaves unpadded -- agg_cell_text owns both rules. `buf` above is - // already the formatted digits; re-pad it here rather than - // re-deriving, since Count(*) has no source value to pass. - const std::size_t w = agg_w(i); - const bool is_count = - slots[i].def.kind == openads::sql::AggregateKind::Count || - slots[i].def.kind == openads::sql::AggregateKind::CountStar || - agg_is_text(i); // character cells left-justify too - std::vector cell(w, ' '); - std::size_t n = std::min(std::strlen(buf), w); - std::memcpy(cell.data() + (is_count ? 0 : (w - n)), buf, n); - arow.insert(arow.end(), cell.begin(), cell.end()); - } - return materialise_temp_adt(c, "_agg_", acols, arow, - arow_stride, phCursor); - } - - // Compile the WHERE expression tree into a row-predicate closure - // (M10.3). CONTAINS captures a precomputed recno set at compile - // time; AND / OR / NOT short-circuit during evaluation. - if (parsed.value().where) { - // Compiled term per Cmp leaf. - struct CmpTerm { - std::uint16_t field_index = 0; - openads::sql::WhereOp op = openads::sql::WhereOp::Eq; - std::string literal; - bool is_numeric = false; - double number = 0.0; - std::shared_ptr> contains_hits; - // M10.33 -- BETWEEN upper bound. - std::string literal2; - double number2 = 0.0; - // M11.7 -- case-insensitive ASCII compare when set. - bool nocase = false; - // ADS dialect -- UPPER()/LOWER() wrapping the LHS column. - openads::sql::WhereFn lhs_fn = - openads::sql::WhereFn::None; - }; - bool conn_nocase = - (c->collation() == Connection::Collation::NoCase); - auto to_lower_ascii = [](std::string sl) { - for (auto& ch : sl) { - if (ch >= 'A' && ch <= 'Z') ch = static_cast(ch + 32); - } - return sl; - }; - - // Compile the AST into a Predicate functor. - using Pred = std::function; - std::function( - const openads::sql::WhereExpr&)> compile; - compile = [&](const openads::sql::WhereExpr& node) - -> openads::util::Result { - using Kind = openads::sql::WhereExpr::Kind; - if (node.kind == Kind::And) { - std::vector kids; - for (auto& cn : node.children) { - auto r = compile(*cn); - if (!r) return r.error(); - kids.push_back(std::move(r).value()); - } - return Pred{[kids = std::move(kids)](openads::engine::Table& t) { - for (auto& k : kids) if (!k(t)) return false; - return true; - }}; - } - if (node.kind == Kind::Or) { - std::vector kids; - for (auto& cn : node.children) { - auto r = compile(*cn); - if (!r) return r.error(); - kids.push_back(std::move(r).value()); - } - return Pred{[kids = std::move(kids)](openads::engine::Table& t) { - for (auto& k : kids) if (k(t)) return true; - return false; - }}; - } - if (node.kind == Kind::Not) { - auto inner = compile(*node.child); - if (!inner) return inner.error(); - return Pred{[p = std::move(inner).value()] - (openads::engine::Table& t) { return !p(t); }}; - } - if (node.kind == Kind::Exists) { - // M10.17 / M10.24 -- EXISTS / NOT EXISTS. Honors - // subquery's WHERE (M10.24); when that WHERE has - // outer-column references (e.g. - // `EXISTS (SELECT * FROM b WHERE b.x = a.y)`), the - // predicate re-evaluates per outer row, binding outer - // values from the live `tbl` cursor. - if (!node.exists_subquery) { - return openads::util::Error{ - openads::AE_PARSE_ERROR, 0, - "EXISTS subquery missing", ""}; - } - // Move ownership of the subquery into a shared_ptr so - // the captured WhereExpr* outlives the parsed - // SelectStmt (which is local to AdsExecuteSQLDirect). - auto sub = std::shared_ptr( - const_cast(node) - .exists_subquery.release()); - openads::engine::Table* outer_tbl = tbl; - std::string sub_table = sub->table; - return Pred{[c, sub, outer_tbl, sub_table] - (openads::engine::Table&) -> bool { - auto sh = c->open_table(sub_table, - openads::engine::TableType::Cdx, - openads::engine::OpenMode::Read); - if (!sh) return false; - openads::engine::Table* stbl = c->lookup_table(sh.value()); - if (!stbl) { c->close_table(sh.value()); return false; } - auto trim = [](std::string sl) { - while (!sl.empty() && sl.back() == ' ') sl.pop_back(); - return sl; - }; - std::function evalw; - evalw = [&](const openads::sql::WhereExpr& n) -> bool { - using K = openads::sql::WhereExpr::Kind; - if (n.kind == K::And) { - for (auto& cn : n.children) - if (!evalw(*cn)) return false; - return true; - } - if (n.kind == K::Or) { - for (auto& cn : n.children) - if (evalw(*cn)) return true; - return false; - } - if (n.kind == K::Not) return !evalw(*n.child); - if (n.kind != K::Cmp) return false; - const auto& w = n.cmp; - std::int32_t fi = stbl->field_index(w.column); - if (fi < 0) return false; - auto v = stbl->read_field( - static_cast(fi)); - if (!v) return false; - int cmp = 0; - if (w.is_outer_ref) { - std::int32_t ofi = - outer_tbl->field_index(w.outer_column); - if (ofi < 0) return false; - auto ov = outer_tbl->read_field( - static_cast(ofi)); - if (!ov) return false; - cmp = trim(v.value().as_string) - .compare(trim(ov.value().as_string)); - } else if (w.is_numeric) { - double d = v.value().as_double; - if (d < w.number) cmp = -1; - else if (d > w.number) cmp = 1; - } else { - cmp = trim(v.value().as_string).compare(w.literal); - } - switch (w.op) { - case openads::sql::WhereOp::Eq: return cmp == 0; - case openads::sql::WhereOp::Ne: return cmp != 0; - case openads::sql::WhereOp::Lt: return cmp < 0; - case openads::sql::WhereOp::Gt: return cmp > 0; - case openads::sql::WhereOp::Le: return cmp <= 0; - case openads::sql::WhereOp::Ge: return cmp >= 0; - default: return false; - } - }; - bool any = false; - std::uint32_t srcount = stbl->record_count(); - for (std::uint32_t r = 1; r <= srcount; ++r) { - if (auto g = stbl->goto_record(r); !g) continue; - if (!stbl->show_deleted_records() && - stbl->is_deleted()) continue; - if (!sub->where) { any = true; break; } - if (evalw(*sub->where)) { any = true; break; } - } - c->close_table(sh.value()); - return any; - }}; - } - if (node.kind == Kind::In) { - // M10.15: materialise the IN set at compile time. For - // a literal list, just lift the strings in. For a - // subquery, walk its source table inline (no nested - // ABI dispatch -- keeps the lock_guard intact). - std::int32_t fidx = tbl->field_index(node.in_clause.column); - if (fidx < 0) { - return openads::util::Error{ - openads::AE_COLUMN_NOT_FOUND, 0, - node.in_clause.column.c_str(), ""}; - } - std::uint16_t fi = static_cast(fidx); - auto trim_trailing = [](std::string sl) { - while (!sl.empty() && sl.back() == ' ') sl.pop_back(); - return sl; - }; - // CICHAR columns fold case in IN membership (S4); the - // fold applies ONLY to the membership set and probe -- - // the correlated path's inner-WHERE literal compares - // stay byte-wise (their columns are checked separately). - bool in_ci = field_is_cichar(*tbl, fi); - auto in_fold = [in_ci](std::string sl) { - if (in_ci) - for (char& ch : sl) - ch = static_cast(std::toupper( - static_cast(ch))); - return sl; - }; - auto set = std::make_shared>(); - for (auto& lit : node.in_clause.literals) - set->insert(in_fold(trim_trailing(lit))); - if (node.in_clause.subquery) { - // M10.35 -- detect correlation in subquery's WHERE. - bool correlated = false; - if (node.in_clause.subquery->where) { - std::function - scan; - scan = [&](const openads::sql::WhereExpr& n) { - using K = openads::sql::WhereExpr::Kind; - if (correlated) return; - if (n.kind == K::And || n.kind == K::Or) { - for (auto& cn : n.children) scan(*cn); - return; - } - if (n.kind == K::Not) { scan(*n.child); return; } - if (n.kind == K::Cmp && n.cmp.is_outer_ref) - correlated = true; - }; - scan(*node.in_clause.subquery->where); - } - if (correlated) { - auto sub = std::shared_ptr( - const_cast( - node.in_clause).subquery.release()); - openads::engine::Table* outer_tbl = tbl; - std::string sub_table = sub->table; - return Pred{[c, sub, outer_tbl, fi, sub_table, - trim_trailing] - (openads::engine::Table&) -> bool { - auto sh = c->open_table( - sub_table, - openads::engine::TableType::Cdx, - openads::engine::OpenMode::Read); - if (!sh) return false; - openads::engine::Table* stbl = - c->lookup_table(sh.value()); - if (!stbl) { - c->close_table(sh.value()); return false; - } - if (sub->projection.size() != 1 || - !sub->aggregates.empty()) { - c->close_table(sh.value()); return false; - } - std::int32_t scol = - stbl->field_index(sub->projection[0]); - if (scol < 0) { - c->close_table(sh.value()); return false; - } - auto trim = trim_trailing; - std::function - evalw; - evalw = [&](const openads::sql::WhereExpr& n) - -> bool { - using K = openads::sql::WhereExpr::Kind; - if (n.kind == K::And) { - for (auto& cn : n.children) - if (!evalw(*cn)) return false; - return true; - } - if (n.kind == K::Or) { - for (auto& cn : n.children) - if (evalw(*cn)) return true; - return false; - } - if (n.kind == K::Not) return !evalw(*n.child); - if (n.kind != K::Cmp) return false; - const auto& wn = n.cmp; - std::int32_t sfi = - stbl->field_index(wn.column); - if (sfi < 0) return false; - auto v = stbl->read_field( - static_cast(sfi)); - if (!v) return false; - int cmp = 0; - if (wn.is_outer_ref) { - std::int32_t ofi = - outer_tbl->field_index(wn.outer_column); - if (ofi < 0) return false; - auto ov = outer_tbl->read_field( - static_cast(ofi)); - if (!ov) return false; - cmp = trim(v.value().as_string) - .compare(trim(ov.value().as_string)); - } else if (wn.is_numeric) { - double d = v.value().as_double; - if (d < wn.number) cmp = -1; - else if (d > wn.number) cmp = 1; - } else { - cmp = trim(v.value().as_string) - .compare(wn.literal); - } - switch (wn.op) { - case openads::sql::WhereOp::Eq: return cmp == 0; - case openads::sql::WhereOp::Ne: return cmp != 0; - case openads::sql::WhereOp::Lt: return cmp < 0; - case openads::sql::WhereOp::Gt: return cmp > 0; - case openads::sql::WhereOp::Le: return cmp <= 0; - case openads::sql::WhereOp::Ge: return cmp >= 0; - default: return false; - } - }; - auto ov = outer_tbl->read_field(fi); - if (!ov) { - c->close_table(sh.value()); return false; - } - std::string outer_v = - trim(ov.value().as_string); - bool any = false; - std::uint32_t srcount = stbl->record_count(); - for (std::uint32_t r = 1; r <= srcount; ++r) { - if (auto g = stbl->goto_record(r); !g) - continue; - if (!stbl->show_deleted_records() && - stbl->is_deleted()) continue; - if (sub->where && !evalw(*sub->where)) - continue; - auto sv = stbl->read_field( - static_cast(scol)); - if (!sv) continue; - if (trim(sv.value().as_string) == outer_v) { - any = true; break; - } - } - c->close_table(sh.value()); - return any; - }}; - } - const auto& sq = *node.in_clause.subquery; - auto sh = c->open_table(sq.table, - openads::engine::TableType::Cdx, - openads::engine::OpenMode::Read); - if (!sh) return sh.error(); - openads::engine::Table* stbl = c->lookup_table(sh.value()); - if (stbl == nullptr) { - return openads::util::Error{ - openads::AE_INTERNAL_ERROR, 0, - "subquery post-open", ""}; - } - if (sq.projection.empty() && sq.aggregates.empty()) { - return openads::util::Error{ - openads::AE_PARSE_ERROR, 0, - "IN subquery must project a single column", ""}; - } - if (!sq.aggregates.empty() || - sq.projection.size() != 1) { - c->close_table(sh.value()); - return openads::util::Error{ - openads::AE_PARSE_ERROR, 0, - "IN subquery must project exactly one column", ""}; - } - std::int32_t scol = stbl->field_index(sq.projection[0]); - if (scol < 0) { - c->close_table(sh.value()); - return openads::util::Error{ - openads::AE_COLUMN_NOT_FOUND, 0, - sq.projection[0].c_str(), ""}; - } - std::uint32_t srcount = stbl->record_count(); - for (std::uint32_t r = 1; r <= srcount; ++r) { - if (auto g = stbl->goto_record(r); !g) continue; - if (!stbl->show_deleted_records() && - stbl->is_deleted()) continue; - auto v = stbl->read_field( - static_cast(scol)); - if (!v) continue; - set->insert(in_fold(trim_trailing(v.value().as_string))); - } - c->close_table(sh.value()); - } - return Pred{[fi, set, trim_trailing, in_fold] - (openads::engine::Table& t) { - auto v = t.read_field(fi); - if (!v) return false; - return set->find(in_fold( - trim_trailing(v.value().as_string))) != - set->end(); - }}; - } - // Cmp leaf. - const auto& w = node.cmp; - std::int32_t fidx = tbl->field_index(w.column); - if (fidx < 0) { - return openads::util::Error{ - openads::AE_COLUMN_NOT_FOUND, 0, - w.column.c_str(), ""}; - } - CmpTerm term; - term.field_index = static_cast(fidx); - term.op = w.op; - term.literal = w.literal; - term.is_numeric = w.is_numeric; - term.number = w.number; - term.literal2 = w.literal2; - term.number2 = w.number2; - term.lhs_fn = w.lhs_fn; - // M11.7 -- stamp collation onto the term when the - // connection is in nocase mode and the cmp involves - // string operands. S4: CICHAR (ADT type 20) columns compare - // case-insensitively regardless of connection collation - // (oracle-verified: = and LIKE both fold on CICHAR, plain - // CHAR stays byte-wise). - if ((conn_nocase || - field_is_cichar(*tbl, term.field_index)) && - !w.is_numeric) { - term.nocase = true; - term.literal = to_lower_ascii(term.literal); - term.literal2 = to_lower_ascii(term.literal2); - } - // PAD SPACE (oracle-verified): trailing blanks are - // insignificant in string comparisons -- a CHAR(20) script - // variable substitutes as a space-padded literal and must - // still match ('PROPERTIES ' matches on SAP). - if (!w.is_numeric) { - while (!term.literal.empty() && term.literal.back() == ' ') - term.literal.pop_back(); - while (!term.literal2.empty() && - term.literal2.back() == ' ') - term.literal2.pop_back(); - } - if (w.subquery) { - // M10.29 -- correlated scalar subquery. If the - // subquery's WHERE references an outer column, we - // re-evaluate the subquery per outer row instead of - // materialising a single value at compile time. - bool correlated = false; - if (w.subquery->where) { - std::function scan; - scan = [&](const openads::sql::WhereExpr& n) { - using K = openads::sql::WhereExpr::Kind; - if (correlated) return; - if (n.kind == K::And || n.kind == K::Or) { - for (auto& cn : n.children) scan(*cn); - return; - } - if (n.kind == K::Not) { scan(*n.child); return; } - if (n.kind == K::Cmp && n.cmp.is_outer_ref) - correlated = true; - }; - scan(*w.subquery->where); - } - if (correlated) { - auto sub = std::shared_ptr( - const_cast(w) - .subquery.release()); - openads::engine::Table* outer_tbl = tbl; - std::uint16_t outer_field = - static_cast(fidx); - bool outer_is_numeric_local = - tbl->field_descriptor(outer_field).type != - openads::drivers::DbfFieldType::Character; - openads::sql::WhereOp op_local = w.op; - std::string sub_table = sub->table; - return Pred{[c, sub, outer_tbl, outer_field, - outer_is_numeric_local, op_local, sub_table] - (openads::engine::Table&) -> bool { - auto sh = c->open_table( - sub_table, - openads::engine::TableType::Cdx, - openads::engine::OpenMode::Read); - if (!sh) return false; - openads::engine::Table* stbl = - c->lookup_table(sh.value()); - if (!stbl) { - c->close_table(sh.value()); return false; - } - auto trim = [](std::string sl) { - while (!sl.empty() && sl.back() == ' ') - sl.pop_back(); - return sl; - }; - std::function - evalw; - evalw = [&](const openads::sql::WhereExpr& n) -> bool { - using K = openads::sql::WhereExpr::Kind; - if (n.kind == K::And) { - for (auto& cn : n.children) - if (!evalw(*cn)) return false; - return true; - } - if (n.kind == K::Or) { - for (auto& cn : n.children) - if (evalw(*cn)) return true; - return false; - } - if (n.kind == K::Not) return !evalw(*n.child); - if (n.kind != K::Cmp) return false; - const auto& wn = n.cmp; - std::int32_t fi = stbl->field_index(wn.column); - if (fi < 0) return false; - auto v = stbl->read_field( - static_cast(fi)); - if (!v) return false; - int cmp = 0; - if (wn.is_outer_ref) { - std::int32_t ofi = - outer_tbl->field_index(wn.outer_column); - if (ofi < 0) return false; - auto ov = outer_tbl->read_field( - static_cast(ofi)); - if (!ov) return false; - cmp = trim(v.value().as_string) - .compare(trim(ov.value().as_string)); - } else if (wn.is_numeric) { - double d = v.value().as_double; - if (d < wn.number) cmp = -1; - else if (d > wn.number) cmp = 1; - } else { - cmp = trim(v.value().as_string) - .compare(wn.literal); - } - switch (wn.op) { - case openads::sql::WhereOp::Eq: return cmp == 0; - case openads::sql::WhereOp::Ne: return cmp != 0; - case openads::sql::WhereOp::Lt: return cmp < 0; - case openads::sql::WhereOp::Gt: return cmp > 0; - case openads::sql::WhereOp::Le: return cmp <= 0; - case openads::sql::WhereOp::Ge: return cmp >= 0; - default: return false; - } - }; - double scalar_num = 0.0; - std::string scalar_str; - bool found = false; - std::uint32_t srcount = stbl->record_count(); - if (scriptbridge::sq_is_scalar_agg(*sub)) { - const auto& a = sub->aggregates[0]; - std::int32_t scol = -1; - if (a.kind != - openads::sql::AggregateKind::CountStar) { - scol = stbl->field_index(a.column); - if (scol < 0) { - c->close_table(sh.value()); return false; - } - } - std::uint64_t cnt = 0; - double sum = 0.0; - double minv = std::numeric_limits::infinity(); - double maxv = -std::numeric_limits::infinity(); - for (std::uint32_t r = 1; r <= srcount; ++r) { - if (auto g = stbl->goto_record(r); !g) continue; - if (!stbl->show_deleted_records() && - stbl->is_deleted()) continue; - if (sub->where && !evalw(*sub->where)) continue; - if (a.kind == - openads::sql::AggregateKind::CountStar) { - ++cnt; continue; - } - auto v = stbl->read_field( - static_cast(scol)); - if (!v) continue; - ++cnt; - double d = v.value().as_double; - sum += d; - if (d < minv) minv = d; - if (d > maxv) maxv = d; - } - switch (a.kind) { - case openads::sql::AggregateKind::CountStar: - case openads::sql::AggregateKind::Count: - scalar_num = static_cast(cnt); break; - case openads::sql::AggregateKind::Sum: - scalar_num = sum; break; - case openads::sql::AggregateKind::Avg: - scalar_num = cnt - ? sum / static_cast(cnt) - : 0.0; break; - case openads::sql::AggregateKind::Min: - scalar_num = cnt ? minv : 0.0; break; - case openads::sql::AggregateKind::Max: - scalar_num = cnt ? maxv : 0.0; break; - } - found = true; - char tmp[64]; - std::snprintf(tmp, sizeof(tmp), - "%.17g", scalar_num); - scalar_str = tmp; - } else if (sub->projection.size() == 1 && - sub->aggregates.empty()) { - std::int32_t scol = - stbl->field_index(sub->projection[0]); - if (scol < 0) { - c->close_table(sh.value()); return false; - } - for (std::uint32_t r = 1; r <= srcount; ++r) { - if (auto g = stbl->goto_record(r); !g) continue; - if (!stbl->show_deleted_records() && - stbl->is_deleted()) continue; - if (sub->where && !evalw(*sub->where)) continue; - auto v = stbl->read_field( - static_cast(scol)); - if (!v) continue; - scalar_str = v.value().as_string; - scalar_num = v.value().as_double; - while (!scalar_str.empty() && - scalar_str.back() == ' ') - scalar_str.pop_back(); - found = true; - break; - } - } else { - c->close_table(sh.value()); - return false; - } - c->close_table(sh.value()); - if (!found) return false; - auto ov = outer_tbl->read_field(outer_field); - if (!ov) return false; - int cmp = 0; - if (outer_is_numeric_local) { - double d = ov.value().as_double; - if (d < scalar_num) cmp = -1; - else if (d > scalar_num) cmp = 1; - } else { - std::string os = ov.value().as_string; - while (!os.empty() && os.back() == ' ') - os.pop_back(); - cmp = os.compare(scalar_str); - } - switch (op_local) { - case openads::sql::WhereOp::Eq: return cmp == 0; - case openads::sql::WhereOp::Ne: return cmp != 0; - case openads::sql::WhereOp::Lt: return cmp < 0; - case openads::sql::WhereOp::Gt: return cmp > 0; - case openads::sql::WhereOp::Le: return cmp <= 0; - case openads::sql::WhereOp::Ge: return cmp >= 0; - default: return false; - } - }}; - } - // M10.18: scalar subquery -- materialise once at - // compile time. Open the subquery's table, walk for - // the first non-deleted record, read the projection's - // first column, and use that as the cmp literal. - const auto& sq = *w.subquery; - auto sh = c->open_table(sq.table, - openads::engine::TableType::Cdx, - openads::engine::OpenMode::Read); - if (!sh) return sh.error(); - openads::engine::Table* stbl = c->lookup_table(sh.value()); - if (stbl == nullptr) { - return openads::util::Error{ - openads::AE_INTERNAL_ERROR, 0, - "scalar subquery post-open", ""}; - } - bool outer_is_numeric = - tbl->field_descriptor(static_cast(fidx)) - .type != openads::drivers::DbfFieldType::Character; - - // M10.19 -- aggregate scalar subquery - // (`= (SELECT MAX(x) FROM t)`). Single aggregate slot - // computes against the inner table; numeric result - // lands directly in the cmp's number/literal. - if (scriptbridge::sq_is_scalar_agg(sq)) { - const auto& a = sq.aggregates[0]; - std::int32_t scol = -1; - if (a.kind != openads::sql::AggregateKind::CountStar) { - scol = stbl->field_index(a.column); - if (scol < 0) { - c->close_table(sh.value()); - return openads::util::Error{ - openads::AE_COLUMN_NOT_FOUND, 0, - a.column.c_str(), ""}; - } - } - std::uint64_t cnt = 0; - double sum = 0.0; - double minv = std::numeric_limits::infinity(); - double maxv = -std::numeric_limits::infinity(); - std::uint32_t srcount = stbl->record_count(); - for (std::uint32_t r = 1; r <= srcount; ++r) { - if (auto g = stbl->goto_record(r); !g) continue; - if (!stbl->show_deleted_records() && - stbl->is_deleted()) continue; - if (a.kind == openads::sql::AggregateKind::CountStar) { - ++cnt; - continue; - } - auto v = stbl->read_field( - static_cast(scol)); - if (!v) continue; - ++cnt; - double d = v.value().as_double; - sum += d; - if (d < minv) minv = d; - if (d > maxv) maxv = d; - } - c->close_table(sh.value()); - double result = 0.0; - switch (a.kind) { - case openads::sql::AggregateKind::CountStar: - case openads::sql::AggregateKind::Count: - result = static_cast(cnt); - break; - case openads::sql::AggregateKind::Sum: result = sum; break; - case openads::sql::AggregateKind::Avg: - result = cnt ? sum / static_cast(cnt) : 0.0; - break; - case openads::sql::AggregateKind::Min: - result = cnt ? minv : 0.0; break; - case openads::sql::AggregateKind::Max: - result = cnt ? maxv : 0.0; break; - } - term.is_numeric = outer_is_numeric; - term.number = result; - char tmp[64]; - std::snprintf(tmp, sizeof(tmp), "%.17g", result); - term.literal = tmp; - if (!outer_is_numeric) { - // String comparison: drop trailing zeros so - // "42.000" compares clean against the column. - std::snprintf(tmp, sizeof(tmp), "%g", result); - term.literal = tmp; - } - } else if (!sq.projection.empty() && sq.aggregates.empty()) { - if (sq.projection.size() != 1) { - c->close_table(sh.value()); - return openads::util::Error{ - openads::AE_PARSE_ERROR, 0, - "scalar subquery must project a single column", ""}; - } - std::int32_t scol = stbl->field_index(sq.projection[0]); - if (scol < 0) { - c->close_table(sh.value()); - return openads::util::Error{ - openads::AE_COLUMN_NOT_FOUND, 0, - sq.projection[0].c_str(), ""}; - } - bool found = false; - std::uint32_t srcount = stbl->record_count(); - for (std::uint32_t r = 1; r <= srcount; ++r) { - if (auto g = stbl->goto_record(r); !g) continue; - if (!stbl->show_deleted_records() && - stbl->is_deleted()) continue; - auto v = stbl->read_field( - static_cast(scol)); - if (!v) continue; - term.literal = v.value().as_string; - term.is_numeric = outer_is_numeric; - term.number = v.value().as_double; - while (!term.literal.empty() && - term.literal.back() == ' ') { - term.literal.pop_back(); - } - if (term.nocase) - term.literal = to_lower_ascii(term.literal); - found = true; - break; - } - c->close_table(sh.value()); - if (!found) { - return Pred{[](openads::engine::Table&) { return false; }}; - } - } else { - c->close_table(sh.value()); - return openads::util::Error{ - openads::AE_PARSE_ERROR, 0, - "scalar subquery must project exactly one " - "column or one aggregate", ""}; - } - } - if (w.op == openads::sql::WhereOp::Contains) { - namespace fs = std::filesystem; - fs::path fts_path = - fs::path(tbl->path()).replace_extension(".fts"); - auto loaded = openads::engine::Fts::load(fts_path.string()); - if (!loaded) return loaded.error(); - openads::engine::FtsOptions opts; - auto hits = openads::engine::Fts::search( - loaded.value(), w.literal, opts); - term.contains_hits = - std::make_shared>( - hits.begin(), hits.end()); - } - return Pred{[term](openads::engine::Table& t) { - if (term.op == openads::sql::WhereOp::Contains) { - if (!term.contains_hits) return false; - return term.contains_hits->find(t.recno()) != - term.contains_hits->end(); - } - auto v = t.read_field(term.field_index); - if (!v) return false; - auto maybe_lower = [&](std::string sl) { - // ADS UPPER()/LOWER() folds the cell first; literal is - // verbatim. nocase then lowercases both sides. - sl = apply_where_fn(std::move(sl), term.lhs_fn); - if (!term.nocase) return sl; - for (auto& ch : sl) { - if (ch >= 'A' && ch <= 'Z') - ch = static_cast(ch + 32); - } - return sl; - }; - if (term.op == openads::sql::WhereOp::Between) { - if (term.is_numeric) { - double d = v.value().as_double; - return d >= term.number && d <= term.number2; - } - auto sv = maybe_lower(v.value().as_string); - return sv.compare(term.literal) >= 0 && - sv.compare(term.literal2) <= 0; - } - if (term.op == openads::sql::WhereOp::Like) { - auto sv = maybe_lower(v.value().as_string); - while (!sv.empty() && sv.back() == ' ') sv.pop_back(); - return sql_like_match(sv, term.literal); - } - if (term.op == openads::sql::WhereOp::IsNull || - term.op == openads::sql::WhereOp::IsNotNull) { - // M10.44 / M11.6 -- prefer the VFP NULL bitmap - // when the field is nullable; otherwise treat - // an all-blanks character cell as NULL. - bool null_ish = t.is_field_null(term.field_index); - if (!null_ish) { - auto sv = v.value().as_string; - while (!sv.empty() && sv.back() == ' ') sv.pop_back(); - null_ish = sv.empty(); - } - return term.op == openads::sql::WhereOp::IsNull - ? null_ish : !null_ish; - } - int cmp = 0; - if (term.is_numeric) { - double d = v.value().as_double; - if (d < term.number) cmp = -1; - else if (d > term.number) cmp = 1; - } else { - cmp = maybe_lower(v.value().as_string).compare(term.literal); - } - switch (term.op) { - case openads::sql::WhereOp::Eq: return cmp == 0; - case openads::sql::WhereOp::Ne: return cmp != 0; - case openads::sql::WhereOp::Lt: return cmp < 0; - case openads::sql::WhereOp::Gt: return cmp > 0; - case openads::sql::WhereOp::Le: return cmp <= 0; - case openads::sql::WhereOp::Ge: return cmp >= 0; - case openads::sql::WhereOp::Contains: return true; - default: return false; - } - }}; - }; - - auto compiled = compile(*parsed.value().where); - if (!compiled) return fail(compiled.error()); - tbl->set_filter(std::move(compiled).value()); - } - - // M10.6: ORDER BY [ASC|DESC]. Materialize matching recnos - // through the WHERE filter (or every live row when none), sort - // them by the column's value, and install the sequence as the - // cursor's traversal order. - if (parsed.value().order_by) { - // M10.6 / M10.37 -- ORDER BY one column, with cascading - // additional columns for ties (M10.37). - struct SortKey { - std::uint16_t field_index; - bool descending; - bool numeric; - bool ci; // CICHAR: case-insensitive collation - }; - std::vector sks; - auto add_sort_key = [&](const openads::sql::OrderBy& ob) - -> openads::util::Result - { - std::int32_t fidx = tbl->field_index(ob.column); - if (fidx < 0) { - return openads::util::Error{ - openads::AE_COLUMN_NOT_FOUND, 0, - ob.column.c_str(), ""}; - } - const auto& fd = tbl->field_descriptor( - static_cast(fidx)); - SortKey k; - k.field_index = static_cast(fidx); - k.descending = ob.descending; - k.numeric = - fd.type == openads::drivers::DbfFieldType::Numeric || - fd.type == openads::drivers::DbfFieldType::Float || - fd.type == openads::drivers::DbfFieldType::Integer || - fd.type == openads::drivers::DbfFieldType::Currency|| - fd.type == openads::drivers::DbfFieldType::Double; - k.ci = fd.type == - openads::drivers::DbfFieldType::CiCharacter; - sks.push_back(k); - return std::monostate{}; - }; - if (auto r = add_sort_key(*parsed.value().order_by); !r) - return fail(r.error()); - for (auto& ob : parsed.value().order_by_extra) { - if (auto r = add_sort_key(ob); !r) return fail(r.error()); - } - - std::vector matched; - std::uint32_t rcount = tbl->record_count(); - for (std::uint32_t r = 1; r <= rcount; ++r) { - if (auto g = tbl->goto_record(r); !g) continue; - if (!tbl->show_deleted_records() && - tbl->is_deleted()) continue; - if (!tbl->passes_filter()) continue; - matched.push_back(r); - } - - struct Row { - std::uint32_t recno; - std::vector s; - std::vector d; - }; - std::vector rows; - rows.reserve(matched.size()); - for (auto r : matched) { - (void)tbl->goto_record(r); - Row row; - row.recno = r; - row.s.resize(sks.size()); - row.d.resize(sks.size()); - for (std::size_t i = 0; i < sks.size(); ++i) { - auto v = tbl->read_field(sks[i].field_index); - if (v) { - row.s[i] = v.value().as_string; - row.d[i] = v.value().as_double; - } - } - rows.push_back(std::move(row)); - } - std::stable_sort(rows.begin(), rows.end(), - [&](const Row& a, const Row& b) { - for (std::size_t i = 0; i < sks.size(); ++i) { - bool less, equal; - if (sks[i].numeric) { - less = a.d[i] < b.d[i]; - equal = a.d[i] == b.d[i]; - } else { - // PAD SPACE + CICHAR-aware collation, matching - // WHERE-clause string comparison semantics. - int cmp = where_str_cmp(a.s[i], b.s[i], sks[i].ci); - less = cmp < 0; - equal = cmp == 0; - } - if (equal) continue; - return sks[i].descending ? !less : less; - } - return false; - }); - std::vector seq; - seq.reserve(rows.size()); - for (auto& row : rows) seq.push_back(row.recno); - tbl->clear_filter(); - tbl->set_recno_sequence(std::move(seq)); - } - - // M10.31 -- DISTINCT. M10.32 -- LIMIT [OFFSET]. Both operate on the - // post-WHERE / post-ORDER-BY traversal sequence; if neither - // ORDER BY nor a recno_sequence is present yet, walk the - // filtered cursor to materialise one first. - bool need_seq_post = parsed.value().distinct || - parsed.value().limit >= 0 || - parsed.value().offset > 0; - if (need_seq_post) { - std::vector seq; - if (tbl->has_recno_sequence()) { - seq = tbl->recno_sequence(); - } else { - std::uint32_t rcount = tbl->record_count(); - seq.reserve(rcount); - for (std::uint32_t r = 1; r <= rcount; ++r) { - if (auto g = tbl->goto_record(r); !g) continue; - if (!tbl->show_deleted_records() && - tbl->is_deleted()) continue; - if (!tbl->passes_filter()) continue; - seq.push_back(r); - } - } - if (parsed.value().distinct) { - std::vector proj_indices; - if (parsed.value().projection.empty()) { - std::uint16_t nf = tbl->field_count(); - proj_indices.reserve(nf); - for (std::uint16_t i = 0; i < nf; ++i) proj_indices.push_back(i); - } else { - for (auto& cn : parsed.value().projection) { - std::int32_t fi = tbl->field_index(cn); - if (fi < 0) return fail(openads::AE_COLUMN_NOT_FOUND, - cn.c_str()); - proj_indices.push_back(static_cast(fi)); - } - } - std::unordered_set seen; - std::vector dedup; - dedup.reserve(seq.size()); - for (auto r : seq) { - if (auto g = tbl->goto_record(r); !g) continue; - std::string key; - for (auto fi : proj_indices) { - auto v = tbl->read_field(fi); - if (v) key.append(v.value().as_string); - key.push_back('\x1f'); - } - if (seen.insert(std::move(key)).second) dedup.push_back(r); - } - seq = std::move(dedup); - } - std::int64_t off = parsed.value().offset > 0 ? parsed.value().offset : 0; - if (off > static_cast(seq.size())) { - seq.clear(); - } else if (off > 0) { - seq.erase(seq.begin(), seq.begin() + - static_cast::difference_type>(off)); - } - if (parsed.value().limit >= 0 && - static_cast(parsed.value().limit) < seq.size()) { - seq.resize(static_cast(parsed.value().limit)); - } - tbl->clear_filter(); - tbl->set_recno_sequence(std::move(seq)); - } - - // M10.38 -- projection contains a CASE expression. Materialise the - // post-WHERE / post-ORDER-BY / post-DISTINCT / post-LIMIT row set - // into a temp DBF whose schema mirrors the projection list (CASE - // items become C(30); regular columns preserve source type + - // length), evaluating each row's CASE branches inline. - auto starts_with = [](const std::string& sl, const char* pre) { - std::size_t L = std::strlen(pre); - return sl.size() >= L && std::memcmp(sl.data(), pre, L) == 0; - }; - bool has_synth = false; - for (auto& p : parsed.value().projection) { - if (starts_with(p, "$CASE_") || starts_with(p, "$FN_") || - starts_with(p, "$ARITH_") || starts_with(p, "$WIN_")) { - has_synth = true; break; - } - } - if (has_synth) { - struct OutCol { - std::string name; - char raw_type = 'C'; - std::uint8_t length = 0; - std::uint8_t decimals = 0; - std::int32_t src_field = -1; - std::int32_t case_idx = -1; - std::int32_t fn_idx = -1; - std::int32_t arith_idx = -1; - std::int32_t arith_lhs_field = -1; - std::int32_t arith_rhs_field = -1; - std::int32_t win_idx = -1; - }; - std::vector outs; - outs.reserve(parsed.value().projection.size()); - int script_expr_seq = 0; // EXPR / EXPR_1 / … numbering (SAP) - for (std::size_t i = 0; i < parsed.value().projection.size(); ++i) { - const auto& p = parsed.value().projection[i]; - OutCol o; - if (starts_with(p, "$CASE_")) { - std::size_t idx = std::stoul(p.substr(6)); - o.case_idx = static_cast(idx); - const auto& ce = parsed.value().case_items[idx]; - if (!ce.alias.empty()) o.name = ce.alias; - else { - char nm[16]; - std::snprintf(nm, sizeof(nm), "CASE%zu", idx + 1); - o.name = nm; - } - o.raw_type = 'C'; - o.length = 30; - } else if (starts_with(p, "$FN_")) { - std::size_t idx = std::stoul(p.substr(4)); - o.fn_idx = static_cast(idx); - const auto& fc = parsed.value().fn_items[idx]; - using K = openads::sql::ScalarFnKind; - bool zero_arg = (fc.kind == K::Now || fc.kind == K::Today || - fc.kind == K::Date || fc.kind == K::Time); - bool single_col = !zero_arg && - (fc.kind == K::Upper || - fc.kind == K::Lower || - fc.kind == K::Len || - fc.kind == K::Trim || - fc.kind == K::Ltrim || - fc.kind == K::Rtrim); - if (zero_arg) { - if (!fc.alias.empty()) o.name = fc.alias; - else o.name = (fc.kind == K::Now) ? "NOW" - : (fc.kind == K::Today) ? "TODAY" - : (fc.kind == K::Date) ? "DATE" - : "TIME"; - o.raw_type = 'C'; - o.length = (fc.kind == K::Time) ? 8 : 19; - // src_field stays -1; value produced at row-eval time - } else if (single_col) { - std::int32_t fi = tbl->field_index(fc.column); - if (fi < 0) return fail(openads::AE_COLUMN_NOT_FOUND, - fc.column.c_str()); - o.src_field = fi; - // SAP names an unaliased scalar-fn projection EXPR / - // EXPR_1 / ... (probed: UPPER(Nm), TRIM(Nm) come back - // EXPR, EXPR_1) - not after the argument column. - if (!fc.alias.empty()) o.name = fc.alias; - else { - o.name = script_expr_seq == 0 - ? "EXPR" - : "EXPR_" + std::to_string(script_expr_seq); - ++script_expr_seq; - } - o.raw_type = 'C'; - if (fc.kind == K::Len) { - o.length = 10; - } else { - const auto& fd = tbl->field_descriptor( - static_cast(fi)); - o.length = static_cast(fd.length ? fd.length : 30); - } - } else if (fc.kind == K::ScriptCall) { - // SAP names unaliased expression projections EXPR, - // EXPR_1, … (oracle-verified). Static type inference - // over the compiled expression drives the temp-column - // type so subquery consumers see typed values. - if (!fc.alias.empty()) o.name = fc.alias; - else { - o.name = script_expr_seq == 0 - ? "EXPR" - : "EXPR_" + std::to_string(script_expr_seq); - ++script_expr_seq; - } - o.raw_type = 'C'; - o.length = 50; - auto spr = scriptbridge::compiled( - "RETURN " + fc.column + ";"); - if (spr && !spr.value()->stmts.empty() && - spr.value()->stmts[0]->expr) { - using ST = openads::script::Type; - switch (scriptbridge::infer_expr_type( - *spr.value()->stmts[0]->expr)) { - case ST::Integer: - o.raw_type = 'N'; o.length = 20; break; - case ST::Double: - o.raw_type = 'N'; o.length = 20; - o.decimals = 6; break; - case ST::Date: - o.raw_type = 'D'; o.length = 8; break; - default: break; - } - } - } else if (fc.kind == K::Udf) { - o.name = fc.alias.empty() ? fc.fn_name : fc.alias; - o.raw_type = 'C'; - o.length = 50; - } else { - // M10.43 / M10.45 -- multi-arg fns. Width = generous - // default; alias drives the column name; no - // pre-resolved src_field (per-arg lookups happen - // at row-eval time). - if (!fc.alias.empty()) o.name = fc.alias; - else { - char nm[16]; - std::snprintf(nm, sizeof(nm), "EXPR%zu", idx + 1); - o.name = nm; - } - o.raw_type = 'C'; - o.length = (fc.kind == K::DateAdd) ? 8 - : (fc.kind == K::DateDiff) ? 12 - : 64; - } - } else if (starts_with(p, "$WIN_")) { - std::size_t idx = std::stoul(p.substr(5)); - o.win_idx = static_cast(idx); - const auto& wf = parsed.value().window_items[idx]; - if (!wf.alias.empty()) o.name = wf.alias; - else { - char nm[16]; - std::snprintf(nm, sizeof(nm), "RN%zu", idx + 1); - o.name = nm; - } - o.raw_type = 'C'; - o.length = 10; - } else if (starts_with(p, "$ARITH_")) { - std::size_t idx = std::stoul(p.substr(7)); - o.arith_idx = static_cast(idx); - const auto& ae = parsed.value().arith_items[idx]; - std::int32_t lhs = tbl->field_index(ae.lhs_column); - if (lhs < 0) return fail(openads::AE_COLUMN_NOT_FOUND, - ae.lhs_column.c_str()); - o.arith_lhs_field = lhs; - if (!ae.rhs_is_literal) { - std::int32_t rhs = tbl->field_index(ae.rhs_column); - if (rhs < 0) return fail(openads::AE_COLUMN_NOT_FOUND, - ae.rhs_column.c_str()); - o.arith_rhs_field = rhs; - } - if (!ae.alias.empty()) o.name = ae.alias; - else { - char nm[16]; - std::snprintf(nm, sizeof(nm), "EXPR%zu", idx + 1); - o.name = nm; - } - o.raw_type = 'C'; - o.length = 30; - } else { - std::int32_t fi = tbl->field_index(p); - if (fi < 0) return fail(openads::AE_COLUMN_NOT_FOUND, - p.c_str()); - const auto& fd = tbl->field_descriptor( - static_cast(fi)); - o.name = fd.name; - o.raw_type = static_cast(fd.raw_type); - o.length = static_cast(fd.length); - o.src_field = fi; - } - outs.push_back(std::move(o)); - } - - // Compile each CASE branch's condition against tbl. - using CondPred = std::function; - std::function( - const openads::sql::WhereExpr&)> compile_cond; - compile_cond = [&](const openads::sql::WhereExpr& node) - -> openads::util::Result - { - using K = openads::sql::WhereExpr::Kind; - if (node.kind == K::And || node.kind == K::Or) { - std::vector ks; - for (auto& cn : node.children) { - auto r = compile_cond(*cn); - if (!r) return r.error(); - ks.push_back(std::move(r).value()); - } - bool is_and = (node.kind == K::And); - return CondPred{[ks = std::move(ks), is_and] - (openads::engine::Table& t) { - if (is_and) { - for (auto& k : ks) if (!k(t)) return false; - return true; - } - for (auto& k : ks) if (k(t)) return true; - return false; - }}; - } - if (node.kind == K::Not) { - auto inner = compile_cond(*node.child); - if (!inner) return inner.error(); - return CondPred{[p = std::move(inner).value()] - (openads::engine::Table& t) - { return !p(t); }}; - } - if (node.kind == K::In) { - std::int32_t fidx = tbl->field_index(node.in_clause.column); - if (fidx < 0) return openads::util::Error{ - openads::AE_COLUMN_NOT_FOUND, 0, - node.in_clause.column.c_str(), ""}; - std::uint16_t fi2 = static_cast(fidx); - bool ci_f = field_is_cichar(*tbl, fi2); - auto fold = [ci_f](std::string s2) { - if (ci_f) - for (char& ch : s2) - ch = static_cast(std::toupper( - static_cast(ch))); - return s2; - }; - auto set = std::make_shared>(); - for (const auto& l2 : node.in_clause.literals) - set->insert(fold(l2)); - return CondPred{[fi2, set, fold](openads::engine::Table& t) { - auto v = t.read_field(fi2); - if (!v) return false; - auto sv = v.value().as_string; - while (!sv.empty() && sv.back() == ' ') sv.pop_back(); - return set->count(fold(sv)) > 0; - }}; - } - if (node.kind != K::Cmp) { - return openads::util::Error{ - openads::AE_FUNCTION_NOT_AVAILABLE, 0, - "CASE WHEN supports Cmp / AND / OR / NOT / IN only", ""}; - } - const auto& w = node.cmp; - std::int32_t fi = tbl->field_index(w.column); - if (fi < 0) return openads::util::Error{ - openads::AE_COLUMN_NOT_FOUND, 0, - w.column.c_str(), ""}; - std::uint16_t f = static_cast(fi); - openads::sql::WhereOp op = w.op; - std::string lit = w.literal; - std::string lit2 = w.literal2; - bool is_num = w.is_numeric; - double num = w.number; - double num2 = w.number2; - std::shared_ptr> contains_hits; - if (op == openads::sql::WhereOp::Contains) { - namespace fs = std::filesystem; - fs::path fts_path = - fs::path(tbl->path()).replace_extension(".fts"); - auto loaded = openads::engine::Fts::load(fts_path.string()); - if (loaded) { - openads::engine::FtsOptions opts; - auto hits = openads::engine::Fts::search( - loaded.value(), lit, opts); - contains_hits = - std::make_shared>( - hits.begin(), hits.end()); - } - } - openads::sql::WhereFn lhs_fn = w.lhs_fn; - bool ci_f = field_is_cichar(*tbl, f); - return CondPred{[f, op, lit, lit2, is_num, num, num2, - contains_hits, lhs_fn, ci_f] - (openads::engine::Table& t) { - if (op == openads::sql::WhereOp::Contains) { - if (!contains_hits) return false; - return contains_hits->find(t.recno()) != contains_hits->end(); - } - auto v = t.read_field(f); - if (!v) return false; - if (op == openads::sql::WhereOp::IsNull || - op == openads::sql::WhereOp::IsNotNull) { - bool null_ish = t.is_field_null(f); - if (!null_ish) { - auto sv = v.value().as_string; - while (!sv.empty() && sv.back() == ' ') sv.pop_back(); - null_ish = sv.empty(); - } - return op == openads::sql::WhereOp::IsNull - ? null_ish : !null_ish; - } - if (op == openads::sql::WhereOp::Between) { - if (is_num) { - double d = v.value().as_double; - return d >= num && d <= num2; - } - auto sv = apply_where_fn(v.value().as_string, lhs_fn); - return where_str_cmp(sv, lit, ci_f) >= 0 && - where_str_cmp(sv, lit2, ci_f) <= 0; - } - if (op == openads::sql::WhereOp::Like) { - auto sv = apply_where_fn(v.value().as_string, lhs_fn); - while (!sv.empty() && sv.back() == ' ') sv.pop_back(); - return sql_like_match_t(sv, lit, ci_f); - } - int cmp = 0; - if (is_num) { - double d = v.value().as_double; - if (d < num) cmp = -1; - else if (d > num) cmp = 1; - } else { - cmp = where_str_cmp( - apply_where_fn(v.value().as_string, lhs_fn), - lit, ci_f); - } - switch (op) { - case openads::sql::WhereOp::Eq: return cmp == 0; - case openads::sql::WhereOp::Ne: return cmp != 0; - case openads::sql::WhereOp::Lt: return cmp < 0; - case openads::sql::WhereOp::Gt: return cmp > 0; - case openads::sql::WhereOp::Le: return cmp <= 0; - case openads::sql::WhereOp::Ge: return cmp >= 0; - default: return false; - } - }}; - }; - struct CompiledCase { - std::vector branch_preds; - std::vector branch_values; - bool has_else = false; - std::string else_value; - }; - std::vector ccases; - ccases.reserve(parsed.value().case_items.size()); - for (auto& ce : parsed.value().case_items) { - CompiledCase cc; - for (auto& br : ce.branches) { - auto p = compile_cond(*br.cond); - if (!p) return fail(p.error()); - cc.branch_preds.push_back(std::move(p).value()); - cc.branch_values.push_back(br.then_value.text); - } - cc.has_else = ce.has_else; - cc.else_value = ce.has_else ? ce.else_value.text : std::string(); - ccases.push_back(std::move(cc)); - } - - // Build the row list -- honor any installed recno_sequence, - // else walk the filtered cursor. - std::vector walk_seq; - if (tbl->has_recno_sequence()) { - walk_seq = tbl->recno_sequence(); - } else { - std::uint32_t rcount = tbl->record_count(); - for (std::uint32_t r = 1; r <= rcount; ++r) { - if (auto g = tbl->goto_record(r); !g) continue; - if (!tbl->show_deleted_records() && - tbl->is_deleted()) continue; - if (!tbl->passes_filter()) continue; - walk_seq.push_back(r); - } - } - - // M10.49 / M10.50 -- pre-compute window values per row when - // any window items appear in the projection. For each - // window slot, group rows by PARTITION BY key, sort within - // each group by ORDER BY (if any), then assign values per - // kind (ROW_NUMBER / RANK / DENSE_RANK). - std::unordered_map> - window_vals; - if (!parsed.value().window_items.empty()) { - window_vals.reserve(walk_seq.size()); - for (std::size_t wi = 0; - wi < parsed.value().window_items.size(); ++wi) { - const auto& wf = parsed.value().window_items[wi]; - struct Entry { - std::uint32_t recno; - std::string pkey; - std::string okey; - }; - std::vector ents; - ents.reserve(walk_seq.size()); - for (auto r : walk_seq) { - if (auto g = tbl->goto_record(r); !g) continue; - Entry e; - e.recno = r; - for (auto& pc : wf.partition_by) { - std::int32_t fi = tbl->field_index(pc); - if (fi >= 0) { - auto v = tbl->read_field( - static_cast(fi)); - if (v) e.pkey += v.value().as_string; - } - e.pkey.push_back('\x1f'); - } - if (wf.order_by) { - std::int32_t fi = - tbl->field_index(wf.order_by->column); - if (fi >= 0) { - auto v = tbl->read_field( - static_cast(fi)); - if (v) e.okey = v.value().as_string; - } - } - ents.push_back(std::move(e)); - } - std::stable_sort(ents.begin(), ents.end(), - [&](const Entry& a, const Entry& b) { - if (a.pkey != b.pkey) return a.pkey < b.pkey; - if (wf.order_by) { - return wf.order_by->descending - ? a.okey > b.okey - : a.okey < b.okey; - } - return false; - }); - std::string prev_pk; - std::string prev_ok; - bool prev_ok_set = false; - std::uint32_t pos = 0; - std::uint32_t rank_now = 0; - std::uint32_t dense_now = 0; - for (auto& e : ents) { - if (e.pkey != prev_pk) { - pos = 0; rank_now = 0; dense_now = 0; - prev_ok_set = false; - prev_pk = e.pkey; - } - ++pos; - bool tied = wf.order_by && prev_ok_set && - e.okey == prev_ok; - if (!tied) { - rank_now = pos; - ++dense_now; - } - prev_ok = e.okey; - prev_ok_set = true; - std::string val; - switch (wf.kind) { - case openads::sql::WindowFnKind::RowNumber: - val = std::to_string(pos); break; - case openads::sql::WindowFnKind::Rank: - val = std::to_string(rank_now); break; - case openads::sql::WindowFnKind::DenseRank: - val = std::to_string(dense_now); break; - } - auto& slot = window_vals[e.recno]; - if (slot.size() < - parsed.value().window_items.size()) { - slot.resize( - parsed.value().window_items.size()); - } - slot[wi] = std::move(val); - } - } - } - - // Materialise through the shared ADT temp helper - // (docs/materialised-cursor-temps.md): the DBF this used to write - // truncated CASE / window / fn aliases at 11 characters. `file` - // holds ROW IMAGES only. - std::vector ccols; - std::size_t crow_stride = 0; - ccols.reserve(outs.size()); - for (auto& o : outs) { - ccols.push_back({o.name, o.raw_type, o.length, o.decimals}); - crow_stride += o.length; - } - std::vector file; - - std::uint32_t emitted = 0; - auto trim_left = [](std::string sl) { - std::size_t i = 0; - while (i < sl.size() && sl[i] == ' ') ++i; - return sl.substr(i); - }; - auto trim_right = [](std::string sl) { - while (!sl.empty() && sl.back() == ' ') sl.pop_back(); - return sl; - }; - auto trim_both = [&](std::string sl) { - return trim_left(trim_right(std::move(sl))); - }; - for (std::uint32_t r : walk_seq) { - if (auto g = tbl->goto_record(r); !g) continue; - for (auto& o : outs) { - std::string val; - bool from_synth = false; - if (o.case_idx >= 0) { - from_synth = true; - const auto& cc = - ccases[static_cast(o.case_idx)]; - val = cc.has_else ? cc.else_value : ""; - for (std::size_t bi = 0; bi < cc.branch_preds.size(); ++bi) { - if (cc.branch_preds[bi](*tbl)) { - val = cc.branch_values[bi]; - break; - } - } - } else if (o.fn_idx >= 0) { - from_synth = true; - const auto& fc = parsed.value().fn_items[ - static_cast(o.fn_idx)]; - std::string raw; - if (o.src_field >= 0) { - auto v = tbl->read_field( - static_cast(o.src_field)); - if (v) raw = v.value().as_string; - } - using K = openads::sql::ScalarFnKind; - switch (fc.kind) { - case K::Now: - case K::Today: - case K::Date: - case K::Time: { - std::time_t t = std::time(nullptr); - std::tm tm_local{}; -#ifdef _WIN32 - localtime_s(&tm_local, &t); -#else - localtime_r(&t, &tm_local); -#endif - char buf[24]; - if (fc.kind == K::Time) - std::strftime(buf, sizeof(buf), "%H:%M:%S", &tm_local); - else if (fc.kind == K::Date || fc.kind == K::Today) - std::strftime(buf, sizeof(buf), "%Y-%m-%d", &tm_local); - else - std::strftime(buf, sizeof(buf), "%Y-%m-%d %H:%M:%S", &tm_local); - val = buf; - break; - } - case K::Upper: - for (auto& ch : raw) - ch = static_cast(std::toupper( - static_cast(ch))); - val = std::move(raw); - break; - case K::Lower: - for (auto& ch : raw) - ch = static_cast(std::tolower( - static_cast(ch))); - val = std::move(raw); - break; - case K::Len: { - std::string trimmed = trim_right(std::move(raw)); - char buf[16]; - std::snprintf(buf, sizeof(buf), "%zu", - trimmed.size()); - val = buf; - break; - } - case K::Trim: val = trim_both(std::move(raw)); break; - case K::Ltrim: val = trim_left(std::move(raw)); break; - case K::Rtrim: val = trim_right(std::move(raw)); break; - - case K::ScriptCall: { - // S4 -- whole-call text (NEWIDSTRING(), - // IIF(col < {d…}, …), Year([modtime]), nested - // Trim(Convert(…))) evaluated by the script - // expression engine with the CURRENT row's - // columns bound as parameters. compiled() caches - // by text, so the per-row cost is a hash lookup. - auto pr = scriptbridge::compiled( - "RETURN " + fc.column + ";"); - if (!pr) return fail(pr.error()); - scriptbridge::AbiBridge br(c, hStatement); - openads::script::Executor ex2(&br); - ex2.set_user(c->username()); - scriptbridge::bind_row_params(ex2, *tbl); - auto rr = ex2.run(*pr.value()); - if (!rr) return fail(rr.error()); - if (!rr.value().returned || - rr.value().return_value.is_null) { - val.clear(); - } else if (o.raw_type == 'D') { - // DBF date cell -- raw YYYYMMDD. - const auto& rv = rr.value().return_value; - std::int64_t jdn = - rv.type == - openads::script::Type::Timestamp - ? rv.i / 86400000 : rv.i; - int y2, m2, d2; - openads::script::ymd_from_jdn(jdn, y2, m2, - d2); - char db[16]; - std::snprintf(db, sizeof(db), - "%04d%02d%02d", y2, m2, d2); - val = db; - } else { - val = openads::script::to_display( - rr.value().return_value); - } - break; - } - case K::Udf: { - // RCB 07/17/2026: DD stored functions run through - // the typed script engine (docs/script-engine.md). - // Errors PROPAGATE and fail the SELECT -- SAP - // returns 7200 for a failing UDF, and the old - // silent "" here is how EoM produced 02/00/2026. - if (!c->has_dd()) break; - using SV = openads::script::Value; - using ST = openads::script::Type; - std::vector sargs; - sargs.reserve(fc.args.size()); - for (const auto& arg : fc.args) { - if (arg.is_column) { - // Read the CURRENT row's value, typed by - // the field descriptor. - auto ci_eq = [](const std::string& x, - const std::string& y) { - if (x.size() != y.size()) return false; - for (std::size_t z = 0; z < x.size(); ++z) - if (std::toupper((unsigned char)x[z]) != - std::toupper((unsigned char)y[z])) - return false; - return true; - }; - std::uint16_t nfld = tbl->field_count(); - std::uint16_t fi = nfld; - for (std::uint16_t k2 = 0; k2 < nfld; ++k2) { - if (ci_eq(tbl->field_descriptor(k2).name, - arg.column)) { - fi = k2; - break; - } - } - if (fi == nfld) - return fail(openads::AE_COLUMN_NOT_FOUND, - arg.column.c_str()); - auto v2 = tbl->read_field(fi); - if (!v2) return fail(v2.error()); - const auto& fd2 = tbl->field_descriptor(fi); - using FT = openads::drivers::DbfFieldType; - switch (fd2.type) { - case FT::Numeric: case FT::Float: - case FT::Double: case FT::Currency: - sargs.push_back(SV::real( - v2.value().as_double)); - break; - case FT::Integer: case FT::ShortInt: - case FT::AutoInc: - sargs.push_back(SV::integer( - static_cast( - v2.value().as_double))); - break; - case FT::Date: case FT::AdtDate: - sargs.push_back( - scriptbridge::value_from_text( - v2.value().as_string, - ST::Date)); - break; - case FT::DateTime: case FT::AdtTimestamp: - case FT::ModTime: - sargs.push_back( - scriptbridge::value_from_text( - v2.value().as_string, - ST::Timestamp)); - break; - case FT::Logical: - sargs.push_back( - scriptbridge::value_from_text( - v2.value().as_string, - ST::Logical)); - break; - default: { - std::string sv2 = - v2.value().as_string; - while (!sv2.empty() && - sv2.back() == ' ') - sv2.pop_back(); - sargs.push_back( - SV::character(std::move(sv2))); - } - } - } else if (arg.is_numeric) { - if (arg.number == std::floor(arg.number) && - std::abs(arg.number) < 1e15) - sargs.push_back(SV::integer( - static_cast( - arg.number))); - else - sargs.push_back(SV::real(arg.number)); - } else if (arg.is_call) { - // Nested call text (e.g. CurDate()) -- - // evaluate through the engine itself. - auto pr = scriptbridge::compiled( - "RETURN " + arg.text + ";"); - if (!pr) return fail(pr.error()); - scriptbridge::AbiBridge br(c, hStatement); - openads::script::Executor ex2(&br); - auto rr = ex2.run(*pr.value()); - if (!rr) return fail(rr.error()); - sargs.push_back( - rr.value().returned - ? std::move(rr.value().return_value) - : SV::null()); - } else { - sargs.push_back(SV::character(arg.text)); - } - } - auto rv = scriptbridge::run_dd_function( - c, hStatement, fc.fn_name, sargs); - if (!rv) return fail(rv.error()); - val = openads::script::to_display(rv.value()); - break; - } - case K::Substr: - case K::Concat: - case K::Replace: - case K::DateDiff: - case K::DateAdd: - case K::NullIf: - case K::Coalesce: - case K::IfNull: { - // M10.43 / M10.45 -- multi-arg fns. Resolve - // each arg as either a column read (with - // trailing-space trim for Char-typed slots) - // or the parsed literal. - auto arg_str = [&](const openads::sql::ScalarFnArg& a) - -> std::string { - if (!a.is_column) return a.text; - std::int32_t fi = tbl->field_index(a.column); - if (fi < 0) return std::string(); - auto fv = tbl->read_field( - static_cast(fi)); - if (!fv) return std::string(); - std::string sl = fv.value().as_string; - while (!sl.empty() && sl.back() == ' ') - sl.pop_back(); - return sl; - }; - auto arg_num = [&](const openads::sql::ScalarFnArg& a) - -> double { - if (!a.is_column) return a.number; - std::int32_t fi = tbl->field_index(a.column); - if (fi < 0) return 0.0; - auto fv = tbl->read_field( - static_cast(fi)); - return fv ? fv.value().as_double : 0.0; - }; - if (fc.kind == K::Substr && fc.args.size() >= 2) { - std::string src = arg_str(fc.args[0]); - long start = static_cast( - arg_num(fc.args[1])); // 1-based - long len = (fc.args.size() >= 3) - ? static_cast(arg_num(fc.args[2])) - : static_cast(src.size()); - if (start < 1) start = 1; - std::size_t s0 = static_cast(start - 1); - if (s0 >= src.size()) val.clear(); - else { - std::size_t take = - std::min( - len < 0 ? 0 : (std::size_t)len, - src.size() - s0); - val = src.substr(s0, take); - } - } else if (fc.kind == K::Concat) { - for (auto& a : fc.args) val += arg_str(a); - } else if (fc.kind == K::Replace && - fc.args.size() == 3) { - std::string src = arg_str(fc.args[0]); - std::string oldp = arg_str(fc.args[1]); - std::string newp = arg_str(fc.args[2]); - if (!oldp.empty()) { - std::size_t i = 0; - while ((i = src.find(oldp, i)) != - std::string::npos) { - src.replace(i, oldp.size(), newp); - i += newp.size(); - } - } - val = std::move(src); - } else if (fc.kind == K::DateDiff && - fc.args.size() == 2) { - // M10.45 -- DATEDIFF on YYYYMMDD strings: - // returns days_a - days_b via Julian day. - auto julian = [](const std::string& sl) -> long { - if (sl.size() < 8) return 0; - int y = std::atoi(sl.substr(0, 4).c_str()); - int mo = std::atoi(sl.substr(4, 2).c_str()); - int d = std::atoi(sl.substr(6, 2).c_str()); - long a = (14 - mo) / 12; - long y2 = y + 4800 - a; - long m2 = mo + 12 * a - 3; - return d + (153 * m2 + 2) / 5 + 365 * y2 + - y2 / 4 - y2 / 100 + y2 / 400 - 32045; - }; - long ja = julian(arg_str(fc.args[0])); - long jb = julian(arg_str(fc.args[1])); - char buf[32]; - std::snprintf(buf, sizeof(buf), "%ld", - ja - jb); - val = buf; - } else if (fc.kind == K::NullIf && - fc.args.size() == 2) { - // M10.53 -- NULLIF(a, b): NULL if - // equal, else a. Empty string = - // NULL by convention. - std::string a = arg_str(fc.args[0]); - std::string b = arg_str(fc.args[1]); - val = (a == b) ? std::string() : a; - } else if (fc.kind == K::Coalesce) { - // M10.53 -- first non-empty arg wins. - for (auto& a : fc.args) { - auto cs = arg_str(a); - if (!cs.empty()) { - val = std::move(cs); break; - } - } - } else if (fc.kind == K::IfNull && - fc.args.size() == 2) { - // M10.53 -- IFNULL(expr, default). - std::string a = arg_str(fc.args[0]); - val = a.empty() ? arg_str(fc.args[1]) : a; - } else if (fc.kind == K::DateAdd && - fc.args.size() == 2) { - // M10.45 -- add N days to YYYYMMDD. - std::string ds = arg_str(fc.args[0]); - if (ds.size() < 8) { val = ds; break; } - int y = std::atoi(ds.substr(0, 4).c_str()); - int mo_in = std::atoi(ds.substr(4, 2).c_str()); - int d = std::atoi(ds.substr(6, 2).c_str()); - long n = static_cast(arg_num(fc.args[1])); - long aa = (14 - mo_in) / 12; - long y2 = y + 4800 - aa; - long m2 = mo_in + 12 * aa - 3; - long jdn = d + (153 * m2 + 2) / 5 + 365 * y2 + - y2 / 4 - y2 / 100 + y2 / 400 - 32045; - jdn += n; - long la = jdn + 32044; - long b = (4 * la + 3) / 146097; - long c2 = la - (146097 * b) / 4; - long d2 = (4 * c2 + 3) / 1461; - long e = c2 - (1461 * d2) / 4; - long mn = (5 * e + 2) / 153; - int day = static_cast( - e - (153 * mn + 2) / 5 + 1); - int mo = static_cast( - mn + 3 - 12 * (mn / 10)); - int yr = static_cast( - 100 * b + d2 - 4800 + mn / 10); - char buf[16]; - std::snprintf(buf, sizeof(buf), - "%04d%02d%02d", yr, mo, day); - val = buf; - } else { - val.clear(); - } - break; - } - } - } else if (o.win_idx >= 0) { - from_synth = true; - auto wit = window_vals.find(r); - if (wit != window_vals.end() && - static_cast(o.win_idx) < - wit->second.size() && - !wit->second[ - static_cast(o.win_idx)].empty()) { - val = wit->second[ - static_cast(o.win_idx)]; - } else { - char buf[16]; - std::snprintf(buf, sizeof(buf), "%u", - static_cast(emitted + 1)); - val = buf; - } - } else if (o.arith_idx >= 0) { - from_synth = true; - const auto& ae = parsed.value().arith_items[ - static_cast(o.arith_idx)]; - auto lv = tbl->read_field( - static_cast(o.arith_lhs_field)); - double a = lv ? lv.value().as_double : 0.0; - double b = 0.0; - if (ae.rhs_is_literal) b = ae.rhs_number; - else { - auto rv = tbl->read_field( - static_cast(o.arith_rhs_field)); - b = rv ? rv.value().as_double : 0.0; - } - double res = 0.0; - using AO = openads::sql::ArithOp; - switch (ae.op) { - case AO::Add: res = a + b; break; - case AO::Sub: res = a - b; break; - case AO::Mul: res = a * b; break; - case AO::Div: res = (b != 0.0) ? a / b : 0.0; break; - } - char buf[64]; - std::snprintf(buf, sizeof(buf), "%g", res); - val = buf; - } - if (from_synth) { - if (val.size() > o.length) val.resize(o.length); - for (std::uint8_t k = 0; k < o.length; ++k) { - file.push_back(k < val.size() - ? static_cast(val[k]) : ' '); - } - } else { - auto v = tbl->read_field( - static_cast(o.src_field)); - std::string raw = v ? v.value().as_string : std::string(); - for (std::uint8_t k = 0; k < o.length; ++k) { - file.push_back(k < raw.size() - ? static_cast(raw[k]) : ' '); - } - } - } - ++emitted; - } - (void)emitted; - return materialise_temp_adt(c, "_case_", ccols, file, - crow_stride, phCursor); - } - - // RCB 07/16/2026: column-level permission enforcement. If the connected - // user is column-restricted on the source table, the cursor must expose - // ONLY the columns they may SELECT -- SAP grants a group table access but - // hides specific columns, and OpenADS used to leak the whole table. We - // reuse the existing cursor-projection mechanism: SELECT * projects the - // permitted columns; an explicit projection naming a forbidden column is - // denied (matching SAP). Only reached for simple single-table SELECTs; - // join/aggregate queries take the materialisation path above. - std::optional> allowed_cols; - if (c->has_dd() && !c->username().empty()) { - auto* dd = c->dd(); - if (dd != nullptr && dd->has_any_column_acl()) { - allowed_cols = dd->permitted_columns( - c->username(), parsed.value().table, - openads::engine::DataDict::DD_PERM_SELECT); - } - } - auto col_lower = [](std::string s) { - for (auto& ch : s) - ch = static_cast(std::tolower(static_cast(ch))); - return s; - }; - - // ADS static-cursor semantics -- single-table SELECT with ORDER BY / - // DISTINCT / LIMIT must be a STANDALONE temp table, not the live source - // with a recno_sequence. Real ACE returns a static cursor (its own temp - // table) for these; the ERP then runs `INDEX ON ... ; DBSETORDER(n)` on - // the result. If the cursor were the live `.dbf`, those index ops - // would hit the production table and REWRITE its official .cdx (the user - // saw "cualquier SELECT-SQL reescribio los indices originales"), and a - // recno_sequence over the live table makes DBSETORDER a no-op for the - // browse. Materialising the result into a clean temp DBF (its own recnos - // 1..N, its own index space) isolates it from the source: INDEX ON / - // DBSETORDER behave exactly like DBFCDX / ADS_CDX. Same shape the - // multi-table / union / aggregate / CASE paths already produce. - if (derived_cur == 0 && tbl->has_recno_sequence()) { - ADSHANDLE conn_h = 0; - s.registry.for_each_handle([&](Handle h, HandleKind k, void* p) { - if (k != HandleKind::Connection) return; - if (static_cast(p) == c) conn_h = to_ads_handle(h); - }); - if (conn_h != 0) { - std::vector cols; - bool col_err = false; - bool col_denied = false; - if (parsed.value().projection.empty()) { - std::uint16_t nf = tbl->field_count(); - cols.reserve(nf); - for (std::uint16_t k = 0; k < nf; ++k) { - // A column-restricted user must not get the hidden columns - // copied into the temp table either (the projection applied - // to a live cursor below can't reach a materialised one). - if (allowed_cols && - allowed_cols->find(col_lower( - tbl->field_descriptor(k).name)) == - allowed_cols->end()) { - continue; - } - cols.push_back(k); - } - } else { - cols.reserve(parsed.value().projection.size()); - for (const auto& cn : parsed.value().projection) { - std::int32_t fi = tbl->field_index(cn); - if (fi < 0) { col_err = true; break; } - if (allowed_cols && - allowed_cols->find(col_lower(cn)) == - allowed_cols->end()) { - col_denied = true; break; - } - cols.push_back(static_cast(fi)); - } - } - if (col_err) return fail(openads::AE_COLUMN_NOT_FOUND, ""); - if (col_denied) return fail(openads::AE_ACCESS_DENIED, - "no column permission"); - // `dec` decides how a numeric is spelled for the ADT temp below. - // "Numeric" with decimals becomes an ADT DOUBLE, which cannot carry - // a decimal count on disk, so N(12,2) would come back "10.5" instead - // of "10.50" and undo issue #146. "AsciiNumeric" pins ADT type 2 - // (digits stored as text), which keeps the declared scale. - auto type_name = [](char raw, std::uint8_t dec) -> const char* { - // TWO switch blocks, and both are required. A DBF field - // descriptor carries a printable letter ('C', 'N', 'D', ...); - // an ADT one carries a NUMERIC type code (1-22) in the same - // field. The ranges are disjoint - DBF letters are all >= 'B' - // (0x42), ADT codes top out at 22 - so there is no ambiguity. - // - // Handling only the letters silently sent every non-character - // ADT column down the `return "Character"` fallback with its - // ON-DISK byte length: an ADT date (type 3, 4 bytes on disk) - // became CHAR(4), so "20090816" was stored as "2009". Numerics - // survived by luck, because ADT type 2 is ASCII text anyway. - // Mirrors the CTAS path, which has always had both blocks. - switch (raw) { - case 'C': return "Character"; - case 'N': return dec > 0 ? "AsciiNumeric" : "Numeric"; - case 'D': return "Date"; case 'L': return "Logical"; - case 'M': return "Memo"; - case 'F': return dec > 0 ? "AsciiNumeric" : "Numeric"; - case 'I': return "Integer"; case 'Y': return "Currency"; - case 'B': return "Double"; case 'V': return "Varchar"; - case 'Q': return "Varbinary"; - } - switch (static_cast(raw)) { - case 1: return "Logical"; - case 2: return dec > 0 ? "AsciiNumeric" : "Numeric"; - case 3: return "Date"; - case 4: return "Character"; - case 5: return "Memo"; - case 6: return "Binary"; - case 7: return "Image"; - case 10: return "Double"; - case 11: return "Integer"; - case 12: return "ShortInt"; - case 13: return "Time"; - case 14: return "Timestamp"; - case 15: return "AutoInc"; - case 18: return "Money"; - case 20: return "CiCharacter"; - } - return "Character"; - }; - // S4 -- a materialised cursor carries its OWN descriptors, so the - // SELECT-list naming applied to a live cursor via cursor_aliases() - // cannot reach it; the names have to be baked into the temp's DDL - // here instead. cols[i] corresponds to projection[i] one-for-one - // (a denied column aborts rather than being skipped), so the two - // stay aligned. SELECT * leaves this empty and keeps the declared - // spelling, which is what SAP does too. - std::vector tmp_names; - if (!parsed.value().projection.empty()) - tmp_names = build_projection_aliases(parsed.value(), - cols.size()); - std::string defs; - for (std::size_t ci = 0; ci < cols.size(); ++ci) { - const auto cidx = cols[ci]; - const auto& fd = tbl->field_descriptor(cidx); - if (!defs.empty()) defs.push_back(';'); - defs += (ci < tmp_names.size() && !tmp_names[ci].empty()) - ? tmp_names[ci] : fd.name; - defs.push_back(','); - defs += type_name(static_cast(fd.raw_type), fd.decimals); - if (fd.length > 0) { defs.push_back(','); defs += std::to_string(fd.length); } - if (fd.decimals > 0) { defs.push_back(','); defs += std::to_string(fd.decimals); } - } - char nb[64]; - std::snprintf(nb, sizeof(nb), "_srt_%llx", - static_cast( - openads::platform::monotonic_nanos())); - std::string tmp_name = nb; - std::vector name_buf(tmp_name.size() + 1, 0); - std::memcpy(name_buf.data(), tmp_name.data(), tmp_name.size()); - std::vector def_buf(defs.size() + 1, 0); - std::memcpy(def_buf.data(), defs.data(), defs.size()); - ADSHANDLE hNew = 0; - // >>> Before changing this format, read - // >>> docs/materialised-cursor-temps.md. Three constraints pin it - // >>> and two of them are invisible until a specific customer - // >>> scenario breaks; both have already been regressed once. - // - // ADS_ADT, not ADS_CDX -- the temp must preserve full column names. - // - // A DBF field descriptor allots 11 bytes to the name, so a DBF temp - // silently truncates every column to 10 characters. That is not - // cosmetic: SAP's own catalog names are routinely longer - // (RI_Primary_Table 16, Enable_Internet 15, User_Defined_Prop 17, - // Trig_Function_Name 18), so a DBF temp turned - // SELECT Name, RI_Primary_Table FROM system.relations ORDER BY Name - // into a result whose second column was called RI_Primary -- undoing - // the SAP column-name parity work and breaking any client that then - // referenced the column by name. User tables with long columns were - // mangled the same way. ADT carries full-length names. - // - // ADT also keeps what #146 came here for: this is still a standalone - // temp with its own recnos and its own index space, so an - // application running INDEX ON / DBSETORDER over the result cannot - // touch the source table's production index. Only the on-disk - // format changed (index companion is .adi rather than .cdx), which - // is transparent through the cursor handle. - UNSIGNED32 crc = AdsCreateTable(conn_h, name_buf.data(), nullptr, - ADS_ADT, 0, 0, 0, 0, - def_buf.data(), &hNew); - if (crc == openads::AE_SUCCESS) { - openads::engine::Table* tgt = - s.registry.lookup( - hNew, HandleKind::Table); - if (tgt != nullptr) { - std::vector seq = tbl->recno_sequence(); - for (std::uint32_t r : seq) { - if (auto g = tbl->goto_record(r); !g) continue; - if (auto ar = tgt->append_record(); !ar) break; - for (std::size_t i = 0; i < cols.size(); ++i) { - auto v = tbl->read_field(cols[i]); - if (!v) continue; - const auto ti = static_cast(i); - // Copy through the setter that matches how the - // TARGET field is stored, not always the string one. - // - // Numeric/Float are ASCII in both DBF and ADT (see - // decode_field), so as_string carries the declared - // scale exactly -- "10.50" stays "10.50". Writing a - // double there would re-render it as "10.5" and lose - // the N(12,2) formatting that #146 exists to protect. - // - // The genuinely BINARY numerics below are the ones a - // string write corrupts: on ADT they are raw little - // endian values, so pushing text into them made - // "10.50" read back as ~6e-154. That never showed on - // the old DBF temp because DBF stores numerics as - // ASCII, so the string write round-tripped by - // accident. - switch (tgt->field_descriptor(ti).type) { - case openads::drivers::DbfFieldType::Integer: - case openads::drivers::DbfFieldType::Double: - case openads::drivers::DbfFieldType::Currency: - case openads::drivers::DbfFieldType::ShortInt: - case openads::drivers::DbfFieldType::AutoInc: - case openads::drivers::DbfFieldType::AdtMoney: - (void)tgt->set_field(ti, v.value().as_double); - break; - case openads::drivers::DbfFieldType::Logical: - (void)tgt->set_field(ti, v.value().as_bool); - break; - default: - (void)tgt->set_field(ti, v.value().as_string); - break; - } - } - } - (void)tgt->flush(); - } - AdsCloseTable(hNew); - // Drop the live source cursor so the ERP's INDEX ON / close - // never touches the production table or its official .cdx. - if (table_handle != 0) c->close_table(table_handle); - // Must match the type the temp was CREATED as (ADT, above). - auto cth = c->open_table(tmp_name, - openads::engine::TableType::Adt, - openads::engine::OpenMode::Shared); - if (!cth) return fail(cth.error()); - openads::engine::Table* ctbl = c->lookup_table(cth.value()); - if (!ctbl) return fail(openads::AE_INTERNAL_ERROR, - "sorted temp post-open"); - ADSHANDLE gh_srt = - to_ads_handle(s.registry.register_object(HandleKind::Table, ctbl)); - // Tie the temp table's lifetime to the cursor handle: without - // this, every SELECT ... ORDER BY leaves a _srt_*.dbf (and any - // index the caller built on it) behind in the data directory. - materialised_cursor_temps()[gh_srt] = - (std::filesystem::path(c->data_dir()) / tmp_name).string(); - *phCursor = gh_srt; - return ok(); - } - // AdsCreateTable failed -> fall through to the live-cursor return. - } - } - - // M10.46 -- when this query was a derived-table outer SELECT, - // reuse the inner cursor's existing handle so the user-visible - // cursor isn't a stale alias of an already-registered Table*. - ADSHANDLE gh = (derived_cur != 0) - ? derived_cur - : to_ads_handle(s.registry.register_object(HandleKind::Table, tbl)); - - if (!parsed.value().projection.empty()) { - std::vector proj; - proj.reserve(parsed.value().projection.size()); - for (const auto& col : parsed.value().projection) { - std::int32_t fidx = tbl->field_index(col); - if (fidx < 0) { - return fail(openads::AE_COLUMN_NOT_FOUND, col.c_str()); - } - if (allowed_cols && - allowed_cols->find(col_lower(col)) == allowed_cols->end()) { - return fail(openads::AE_ACCESS_DENIED, - ("no column permission: " + col).c_str()); - } - proj.push_back(static_cast(fidx)); - } - cursor_aliases()[gh] = - build_projection_aliases(parsed.value(), proj.size()); - cursor_projections()[gh] = std::move(proj); - } else if (allowed_cols) { - // SELECT * by a column-restricted user → project only permitted columns, - // in table order. - std::vector proj; - const std::uint16_t nf = static_cast(tbl->field_count()); - for (std::uint16_t i = 0; i < nf; ++i) { - const std::string& fname = tbl->field_descriptor(i).name; - if (allowed_cols->find(col_lower(fname)) != allowed_cols->end()) - proj.push_back(i); - } - cursor_projections()[gh] = std::move(proj); - } - - *phCursor = gh; - return ok(); -} - -// Thin export over the SQL dispatcher above: a failing statement also -// lands in the SAP-style ads_err error log with the statement text -- -// matching ADS, whose error log records the SQL errors (7200 etc.) it -// raises while serving clients. Success paths pay nothing. -// S4 -- AQE envelope depth guard. Internal recursion (derived tables, -// INTO snapshots, script-bridge embedded SQL) re-enters -// AdsExecuteSQLDirect; only the OUTERMOST, client-facing call wraps the -// failure as SAP's rc-7200 AQE envelope. Inner calls keep the native -// code + message so engine/script error handling is unaffected -- same -// as SAP, whose envelopes carry the innermost native code. -static thread_local int sql_exec_depth_ = 0; - -UNSIGNED32 ENTRYPOINT AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, - ADSHANDLE* phCursor) { - arc2_trace("AdsExecuteSQLDirect"); - arc2_trace("AdsExecuteSQLDirect"); - struct DepthGuard { - ~DepthGuard() { --sql_exec_depth_; } - } depth_guard; - ++sql_exec_depth_; - UNSIGNED32 rc = exec_sql_direct_impl(hStatement, pucSQL, phCursor); - if (rc != openads::AE_SUCCESS && sql_exec_depth_ == 1) { - std::string sql_text = pucSQL != nullptr - ? openads::abi::to_internal(pucSQL, 0) : std::string(); - std::string env = scriptbridge::sql_error_envelope( - static_cast(rc), - openads::abi::last_error_message(), sql_text); - openads::abi::set_last_error(openads::util::Error{ - 7200, 0, std::move(env), ""}); - rc = 7200; - } - // RCB 07/15/2026: SAP ADS positions a SQL result cursor ON the first - // record after execute; OpenADS was leaving the engine Table at BOF, so a - // client that reads the current record immediately (the SAP-supported - // pattern) got a phantom empty row on EVERY query -- proven with - // `SELECT COUNT(*)` returning two rows (blank, then the count) vs SAP's - // one. Position the result at row 1 here, at the public-API boundary, so - // it applies uniformly to engine tables, memory-backed system.*/aggregate - // results, and SQL-backend cursors. GotoTop is absolute/idempotent, so a - // caller that also calls AdsGotoTop is unaffected. Write statements set - // *phCursor == 0 and are skipped. - if (rc == openads::AE_SUCCESS && phCursor != nullptr && *phCursor != 0) { - (void)AdsGotoTop(*phCursor); - } - if (rc != openads::AE_SUCCESS) { - std::string sql = pucSQL != nullptr - ? openads::abi::to_internal(pucSQL, 0) : std::string(); - if (sql.size() > 160) sql.resize(160); - std::string msg = openads::abi::last_error_message(); - openads::mgmt::ErrorLog::instance().log( - static_cast(rc), "SQL", 0, - msg.empty() ? sql : (msg + " | " + sql)); - } - return rc; -} - -UNSIGNED32 ENTRYPOINT AdsExecuteSQLDirectW(ADSHANDLE hStatement, UNSIGNED16* pwcSQL, - ADSHANDLE* phCursor) { - arc2_trace("AdsExecuteSQLDirectW"); - arc2_trace("AdsExecuteSQLDirectW"); - if (pwcSQL == nullptr) return fail(openads::AE_PARSE_ERROR, "null SQL"); - std::string sql; - utf16z_to_utf8(pwcSQL, &sql); - std::vector bytes(sql.begin(), sql.end()); - bytes.push_back(0); - return AdsExecuteSQLDirect(hStatement, bytes.data(), phCursor); -} - -// ---- Date display format (AdsSetDateFormat / AdsGetDateFormat) ------------- -// -// ACE keeps one process-wide date display string. SAP's default is -// "MM/DD/CCYY" (probed: AdsGetDateFormat on a fresh ace64.dll), and ours -// now matches. The format drives AdsGetFIELD / AdsGetDate display and -// AdsSetDate parsing; AdsGetSTRING deliberately stays raw "YYYYMMDD" -- -// SAP behaves the same way, and php_ads' date handling depends on it -// (see docs/date-display-format.md before changing ANY of this). -// -// This file is largely one big `extern "C"` block; these helpers -// return std::string / a small struct, so they need C++ linkage. -extern "C++" { -namespace { - -std::string g_date_format = "MM/DD/CCYY"; - -// AdsGetString's delegated remote/backend reads set this so the shared -// AdsGetField path skips display formatting (declared near the top). -thread_local bool g_field_read_raw = false; - -// Connection-wide settings stored so their Set/Get pairs round-trip. -// AdsSetDefault / AdsGetDefault, AdsSetSearchPath / AdsGetSearchPath and -// AdsSetDecimals (queried by STR-style formatting callers via their own -// getter when present). OpenADS resolves paths against the connection's -// own data path, so g_default_path is recorded for API parity. -std::string g_default_path; -std::string g_search_path; -std::uint16_t g_set_decimals = 2; - -// Render (y, m, d) through an ACE-style format string. Recognised, -// case-insensitively: CCYY / YYYY → 4-digit year, YY → 2-digit year, -// MM → 2-digit month, DD → 2-digit day. Every other character is -// copied verbatim, so separators ("/", "-", ".") pass straight through. -std::string format_ace_date(const std::string& fmt, int y, int m, int d) { - char two_y[4], two[4], four[8]; - std::snprintf(two_y, sizeof(two_y), "%02d", ((y % 100) + 100) % 100); - std::snprintf(four, sizeof(four), "%04d", y); - std::string up; - up.reserve(fmt.size()); - for (char c : fmt) - up.push_back(static_cast(std::toupper( - static_cast(c)))); - std::string out; - out.reserve(fmt.size() + 4); - for (std::size_t i = 0; i < up.size(); ) { - auto at = [&](const char* tok) { - std::size_t L = std::strlen(tok); - return up.compare(i, L, tok) == 0; - }; - if (at("CCYY") || at("YYYY")) { out += four; i += 4; } - else if (at("YY")) { out += two_y; i += 2; } - else if (at("MM")) { std::snprintf(two, sizeof(two), "%02d", m); - out += two; i += 2; } - else if (at("DD")) { std::snprintf(two, sizeof(two), "%02d", d); - out += two; i += 2; } - else { out.push_back(up[i]); ++i; } - } - return out; -} - -// The format with every digit position blanked -- SAP renders an empty -// date through AdsGetField as " / / " (separators kept, digits -// spaces), NOT as an empty string. -std::string blank_ace_date(const std::string& fmt) { - std::string up; - up.reserve(fmt.size()); - for (char c : fmt) - up.push_back(static_cast(std::toupper( - static_cast(c)))); - std::string out; - out.reserve(fmt.size()); - for (std::size_t i = 0; i < up.size(); ) { - auto at = [&](const char* tok) { - std::size_t L = std::strlen(tok); - return up.compare(i, L, tok) == 0; - }; - if (at("CCYY") || at("YYYY")) { out += " "; i += 4; } - else if (at("YY") || at("MM") || at("DD")) { out += " "; i += 2; } - else { out.push_back(up[i]); ++i; } - } - return out; -} - -// SAP display rule for AdsGetField / AdsGetDate on a DATE column, -// probed against ace64.dll (docs/date-display-format.md): -// raw "20240115" -> "01/15/2024" (per the current format) -// blank -> " / / " -// `raw` is the engine's internal decode ("YYYYMMDD", or empty/blanks). -std::string format_date_display(const std::string& raw) { - if (raw.size() == 8) { - bool digits = true; - for (char c : raw) - digits = digits && std::isdigit(static_cast(c)); - if (digits) { - const int y = std::stoi(raw.substr(0, 4)); - const int m = std::stoi(raw.substr(4, 2)); - const int d = std::stoi(raw.substr(6, 2)); - return format_ace_date(g_date_format, y, m, d); - } - } - return blank_ace_date(g_date_format); -} - -// Parse `text` positionally against the CURRENT date format. Returns the -// compact "YYYYMMDD", or an empty string when the text does not fit the -// format (wrong length, non-digits in digit positions, month/day out of -// range). A 2-digit year resolves through the epoch the way DBF readers -// do: >= (epoch % 100) -> epoch century, else the next one. -std::string parse_date_by_format(const std::string& text) { - std::string up; - up.reserve(g_date_format.size()); - for (char c : g_date_format) - up.push_back(static_cast(std::toupper( - static_cast(c)))); - if (text.size() != up.size()) { - // A 2-digit-year format is 2 shorter than its 4-digit twin; no - // other length mismatch can fit. - return std::string(); - } - int y = -1, m = -1, d = -1; - std::size_t i = 0, t = 0; - auto digits = [&](std::size_t n, int* out_v) { - int v = 0; - for (std::size_t k = 0; k < n; ++k) { - const char c = t + k < text.size() ? text[t + k] : '\0'; - if (!std::isdigit(static_cast(c))) return false; - v = v * 10 + (c - '0'); - } - *out_v = v; - t += n; - return true; - }; - while (i < up.size()) { - auto at = [&](const char* tok) { - return up.compare(i, std::strlen(tok), tok) == 0; - }; - if (at("CCYY") || at("YYYY")) { - if (!digits(4, &y)) return std::string(); - i += 4; - } else if (at("YY")) { - int yy = 0; - if (!digits(2, &yy)) return std::string(); - const int epoch = static_cast(openads::engine::epoch()); - const int cut = epoch % 100; - y = (yy >= cut ? epoch - cut : epoch - cut + 100) + yy; - i += 2; - } else if (at("MM")) { - if (!digits(2, &m)) return std::string(); - i += 2; - } else if (at("DD")) { - if (!digits(2, &d)) return std::string(); - i += 2; - } else { - if (t >= text.size() || text[t] != up[i]) return std::string(); - ++t; ++i; - } - } - if (y < 0 || m < 1 || m > 12 || d < 1 || d > 31) return std::string(); - char out[16]; - std::snprintf(out, sizeof(out), "%04d%02d%02d", y, m, d); - return out; -} - -// SAP display rule for AdsGetField on a TIMESTAMP column: -// " hh:mm:ss AM|PM" -- 12-hour clock, 2-digit hour -// ("01/15/2024 01:45:59 PM", len 22 under the default format), and a -// blank timestamp renders as " / / 12:00:00 AM". -// `raw` is the engine's internal decode ("YYYYMMDDhhmmss", or empty). -std::string format_timestamp_display(const std::string& raw) { - int hh = 0, mi = 0, ss = 0; - std::string date_part; - bool have = raw.size() >= 14; - if (have) - for (std::size_t i = 0; i < 14; ++i) - have = have && std::isdigit(static_cast(raw[i])); - if (have) { - date_part = format_date_display(raw.substr(0, 8)); - hh = std::stoi(raw.substr(8, 2)); - mi = std::stoi(raw.substr(10, 2)); - ss = std::stoi(raw.substr(12, 2)); - } else { - date_part = blank_ace_date(g_date_format); - } - const char* ampm = hh < 12 ? "AM" : "PM"; - int h12 = hh % 12; - if (h12 == 0) h12 = 12; - char t[16]; - std::snprintf(t, sizeof(t), " %02d:%02d:%02d %s", h12, mi, ss, ampm); - return date_part + t; -} - -// Read the DBF header's "last updated" stamp (header bytes 1..3 are -// YY MM DD, the year byte being an offset from 1900) straight off the -// table file. Returns {0,0,0} when the table has no driver or the -// read fails. Matches the convention in drivers/dbf_common.cpp. -struct HeaderDate { int y = 0, m = 0, d = 0; }; -HeaderDate read_header_date(openads::engine::Table* t) { - HeaderDate r; - if (t == nullptr || t->driver() == nullptr) return r; - std::uint8_t b[3] = {0, 0, 0}; - auto got = t->driver()->file().read_at(1, b, sizeof(b)); - if (!got || got.value() < sizeof(b)) return r; - r.y = 1900 + static_cast(b[0]); - r.m = static_cast(b[1]); - r.d = static_cast(b[2]); - return r; -} - -// Copy `s` (plus NUL) into a caller buffer using the ACE in/out length -// convention: *pusLen in = capacity, out = the string's true length; -// the copy is truncated to fit. No-op when pusLen is null. -void copy_ace_string(const std::string& s, UNSIGNED8* buf, UNSIGNED16* pusLen) { - if (pusLen == nullptr) return; - UNSIGNED16 cap = *pusLen; - if (buf != nullptr && cap > 0) { - std::size_t n = std::min(s.size(), - static_cast(cap - 1)); - std::memcpy(buf, s.data(), n); - buf[n] = 0; - } - *pusLen = static_cast(s.size()); -} - -} // namespace -} // extern "C++" - -// ---- M(rddads-compat): stubs for Harbour contrib/rddads ------------------- -// -// rddads.hbp pulls in symbols across the full SAP ace.h surface even if -// only a handful are actually exercised by `dbUseArea( .T., "ADS", ... )`. -// To make rddads link clean against ace64.lib, every referenced symbol -// must resolve. The stubs below return AE_FUNCTION_NOT_AVAILABLE for -// features the engine doesn't implement yet (advisory-only management -// API, AOF, scoped relations, callbacks); apps that don't touch those -// features still link and run. - -#define ADS_STUB(rc) return (rc) - -extern "C" { - -UNSIGNED32 ENTRYPOINT AdsConnect(UNSIGNED8* pucServer, ADSHANDLE* phConnect) { - arc2_trace("AdsConnect"); - return AdsConnect60(pucServer, ADS_LOCAL_SERVER, - nullptr, nullptr, 0, phConnect); -} -UNSIGNED32 ENTRYPOINT AdsApplicationExit(void) { - arc2_trace("AdsApplicationExit"); ADS_STUB(openads::AE_SUCCESS); } - -UNSIGNED32 ENTRYPOINT AdsClearRelation(ADSHANDLE hParent) { - arc2_trace("AdsClearRelation"); - forget_relations(hParent); - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsClearCallbackFunction(void) { - arc2_trace("AdsClearCallbackFunction"); ADS_STUB(openads::AE_SUCCESS); } -UNSIGNED32 ENTRYPOINT AdsClearProgressCallback(void) { - arc2_trace("AdsClearProgressCallback"); ADS_STUB(openads::AE_SUCCESS); } -UNSIGNED32 ENTRYPOINT AdsCacheOpenCursors(UNSIGNED16) { - arc2_trace("AdsCacheOpenCursors"); - arc2_trace("AdsCacheOpenCursors"); ADS_STUB(openads::AE_SUCCESS); } -UNSIGNED32 ENTRYPOINT AdsCacheOpenTables(UNSIGNED16) { - arc2_trace("AdsCacheOpenTables"); ADS_STUB(openads::AE_SUCCESS); } -UNSIGNED32 ENTRYPOINT AdsCacheRecords(ADSHANDLE hTable, UNSIGNED16 usNumRecords) { - arc2_trace("AdsCacheRecords"); - // RCB 07/14/2026: M12.23 -- this used to be a no-op ("OpenADS does not - // pre-cache rows"), which stopped being true back in M12.21 and is now - // thoroughly untrue. The caller's depth now rides on every subsequent Skip - // for this table (see kPrefetchDepthAuto in wire.h) and overrides the - // server's automatic ramp. - // - // SAP's semantics (ace_adscacherecords.htm), which we mirror: - // - 0 or 1 "effectively turns read-ahead record caching off"; - // - big values suit a one-directional batch sweep, and are a bad idea - // when most visited records get edited, because "any editing of data - // causes the cache to be dumped". - // - // The value is a CEILING, not a promise: we will not read further ahead - // than asked, but the byte budget can still hand back fewer rows. - if (auto* rt = get_remote_table(hTable)) { - rt->cache_records_hint = usNumRecords; - // Turning caching off has to bite NOW, not at the next refill. Rows - // already queued would otherwise keep being served locally out of the - // old block -- and a caller who just disabled read-ahead is, per SAP's - // own guidance, usually about to start editing and specifically needs - // to stop seeing cached data. - if (usNumRecords <= 1) rt->invalidate_prefetch(); - return ok(); - } - // Local (in-process) tables: there is no wire to read ahead of, and the - // driver already keeps a 64 KB block cache under the cursor, so the hint - // has nothing to tune. Validate the handle and succeed, as before. - if (get_table(hTable) != nullptr) return ok(); - return fail(openads::AE_INTERNAL_ERROR, "unknown table"); -} -UNSIGNED32 ENTRYPOINT AdsCloseCachedTables(ADSHANDLE) { - arc2_trace("AdsCloseCachedTables"); ADS_STUB(openads::AE_SUCCESS); } -UNSIGNED32 ENTRYPOINT AdsCopyTableContent(ADSHANDLE hSrc, ADSHANDLE hDst) { - arc2_trace("AdsCopyTableContent"); - Table* src = get_table(hSrc); - Table* dst = get_table(hDst); - if (!src || !dst) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - - // Build a field-name mapping: for each source field find the - // matching destination field (by name). Fields that exist only in - // one table are silently skipped, which is the documented ADS - // behaviour -- no schema match required. - struct FieldPair { std::uint16_t si; std::uint16_t di; }; - std::vector pairs; - std::uint16_t nc = src->field_count(); - for (std::uint16_t si = 0; si < nc; ++si) { - std::int32_t di = dst->field_index(src->field_descriptor(si).name); - if (di >= 0) pairs.push_back({si, static_cast(di)}); - } - - std::uint32_t rcount = src->record_count(); - for (std::uint32_t r = 1; r <= rcount; ++r) { - if (auto g = src->goto_record(r); !g) continue; - if (!src->show_deleted_records() && - src->is_deleted()) continue; - if (auto ar = dst->append_record(); !ar) return fail(ar.error()); - for (auto& fp : pairs) { - auto v = src->read_field(fp.si); - if (!v) continue; - (void)dst->set_field(fp.di, v.value().as_string); - } - } - if (auto fl = dst->flush(); !fl) return fail(fl.error()); - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsCustomizeAOF(ADSHANDLE hTable, UNSIGNED32 ulNumRecords, - UNSIGNED32* pulRecords, UNSIGNED16 usOption) { - arc2_trace("AdsCustomizeAOF"); - bool include; - switch (usOption) { - case ADS_AOF_ADD_RECORD: include = true; break; - case ADS_AOF_REMOVE_RECORD: include = false; break; - default: return fail(openads::AE_INTERNAL_ERROR, "invalid AOF option"); - } - if (pulRecords == nullptr || ulNumRecords == 0) return ok(); - - if (auto* rt = get_remote_table(hTable)) { - if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; - std::vector recnos; - recnos.reserve(ulNumRecords); - for (UNSIGNED32 i = 0; i < ulNumRecords; ++i) - recnos.push_back(pulRecords[i]); - remote_settle_cursor(rt); - rt->row_valid = false; - rt->prefetch_queue.clear(); - auto r = rt->conn->customize_aof(rt->id, usOption, recnos); - if (!r) return fail(r.error()); - return ok(); - } - - Table* t = get_table(hTable); - if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); - if (!t->aof_active()) - return fail(openads::AE_INTERNAL_ERROR, "no active AOF on table"); - for (UNSIGNED32 i = 0; i < ulNumRecords; ++i) - (void)t->customize_aof_record(pulRecords[i], include); - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsData(UNSIGNED16, void*) { - arc2_trace("AdsData"); ADS_STUB(openads::AE_SUCCESS); } -// SAP / rddads signature: AdsEvalAOF(hTable, pucExpr, *pusOptLevel). -// Returns the optimisation level (ADS_OPTIMIZED_NONE / PART / FULL) -// the engine would use to evaluate the filter. Currently a stub -- -// caller's *pusOptLevel is zeroed (= ADS_OPTIMIZED_NONE). -UNSIGNED32 ENTRYPOINT AdsEvalAOF(ADSHANDLE, UNSIGNED8*, UNSIGNED16* pusOptLevel) - { - arc2_trace("AdsEvalAOF"); if (pusOptLevel) *pusOptLevel = 0; - return openads::AE_SUCCESS; } -UNSIGNED32 ENTRYPOINT AdsFilterOption(ADSHANDLE, UNSIGNED16, UNSIGNED16* p) - { - arc2_trace("AdsFilterOption"); if (p) *p = 0; return openads::AE_SUCCESS; } -// SAP / rddads signature: AdsGetAOF(hTable, pucFilter, *pusLen). -// pucFilter is a caller-allocated buffer; pusLen is in/out (capacity -// in, actual filter length out). We don't track per-table AOF source -// strings yet (only the evaluated bitmap), so return an empty filter -// -- Harbour's ADSGETAOF treats that as "no AOF" and returns "". -UNSIGNED32 ENTRYPOINT AdsGetAOF(ADSHANDLE hTable, UNSIGNED8* pucFilter, UNSIGNED16* pusLen) { - arc2_trace("AdsGetAOF"); - if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (auto* rt = get_remote_table(hTable)) { - openads::abi::copy_to_caller(pucFilter, pusLen, rt->aof_expr); - return ok(); - } - Table* t = get_table(hTable); - if (t == nullptr) { - if (pucFilter && *pusLen > 0) pucFilter[0] = '\0'; - *pusLen = 0; - return ok(); - } - openads::abi::copy_to_caller(pucFilter, pusLen, t->aof_expr()); - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsGetAOF100(ADSHANDLE hTable, void* pvFilter, - UNSIGNED16* pusLen, UNSIGNED32 /*ulOptions*/) { - arc2_trace("AdsGetAOF100"); - return AdsGetAOF(hTable, reinterpret_cast(pvFilter), pusLen); -} -UNSIGNED32 ENTRYPOINT AdsGetConnectionType(ADSHANDLE hConnect, UNSIGNED16* p) { - arc2_trace("AdsGetConnectionType"); - arc2_trace("AdsGetConnectionType"); - if (p == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *p = ADS_LOCAL_SERVER; - // If the handle resolves to a remote connection, report REMOTE. - // NOTE: must check RemoteConnection (not get_remote_table): a - // connection handle is never a table handle, so the old check always - // reported LOCAL and made ARC treat tcp:// connections as local — - // its table grid then crashed (AV executing data, 2026-08-13). - if (get_remote_connection(hConnect) != nullptr || - get_remote_table(hConnect) != nullptr) { - *p = ADS_REMOTE_SERVER; - return ok(); - } - Connection* c = lookup_connection(hConnect); - if (c != nullptr) { - *p = ADS_LOCAL_SERVER; - } - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsGetDateFormat(UNSIGNED8* pucBuf, UNSIGNED16* pusLen) { - arc2_trace("AdsGetDateFormat"); - copy_ace_string(g_date_format, pucBuf, pusLen); - return openads::AE_SUCCESS; -} -UNSIGNED32 ENTRYPOINT AdsGetDefault(UNSIGNED8* p, UNSIGNED16* l) { - arc2_trace("AdsGetDefault"); - if (l == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - openads::abi::copy_to_caller(p, l, g_default_path); - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsGetDeleted(UNSIGNED16* p) { - arc2_trace("AdsGetDeleted"); - arc2_trace("AdsGetDeleted"); - if (p == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - // show_deleted()==true means "show deleted records" which is - // SET DELETED OFF (the Clipper default). AdsGetDeleted returns - // 1 when deleted records ARE visible, matching ACE semantics. - bool visible = openads::engine::show_deleted(); - if (Connection* c = lookup_connection(resolve_connection_handle(0))) { - visible = c->show_deleted(); - } - *p = visible ? 1 : 0; - return ok(); -} -// AdsGetDouble already defined elsewhere in this file. -UNSIGNED32 ENTRYPOINT AdsGetEpoch(UNSIGNED16* p) { - arc2_trace("AdsGetEpoch"); - if (p == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *p = openads::engine::epoch(); - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsGetErrorString(UNSIGNED32 ulErrCode, UNSIGNED8* pucBuf, UNSIGNED16* pusLen) { - arc2_trace("AdsGetErrorString"); - const char* text; - switch (ulErrCode) { - case openads::AE_PARSE_ERROR: text = "SQL parsing error"; break; - case openads::AE_INVALID_SQL_TOKEN: text = "Invalid SQL token"; break; - case openads::AE_COLUMN_NOT_FOUND: text = "Column not found"; break; - case openads::AE_TABLE_NOT_FOUND: text = "Table not found"; break; - case openads::AE_TYPE_MISMATCH: text = "Type mismatch"; break; - case openads::AE_DIVISION_BY_ZERO: text = "Division by zero"; break; - case openads::AE_LOGIN_FAILED: text = "Login failed"; break; - case openads::AE_ACCESS_DENIED: text = "Access denied"; break; - case openads::AE_INTERNAL_ERROR: text = "Internal error"; break; - case openads::AE_FUNCTION_NOT_AVAILABLE: text = "Function not available"; break; - case openads::AE_NO_FILE_FOUND: text = "File not found"; break; - case openads::AE_NO_CONNECTION: text = "No connection"; break; - case openads::AE_INVALID_CONNECTION_HANDLE: text = "Invalid connection handle"; break; - case openads::AE_LOCKED: text = "Record or table is locked"; break; - case openads::AE_LOCK_FAILED: text = "Lock failed"; break; - case openads::AE_TABLE_CORRUPTED: text = "Table corrupted"; break; - case openads::AE_RI_VIOLATION: text = "Referential integrity violation"; break; - case openads::AE_UNIQUE_INDEX_VIOLATION: text = "Duplicate key value in unique index"; break; - case openads::AE_REMOTE_ERROR: text = "Remote server error"; break; - default: text = ""; break; - } - openads::abi::copy_to_caller(pucBuf, pusLen, std::string(text)); - return openads::AE_SUCCESS; -} -UNSIGNED32 ENTRYPOINT AdsGetExact(UNSIGNED16* p) { - arc2_trace("AdsGetExact"); - if (p == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *p = openads::engine::set_exact() ? 1 : 0; - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsGetFieldRaw(ADSHANDLE hTable, UNSIGNED8* pucField, - UNSIGNED8* pucBuf, UNSIGNED32* pulLen) { - arc2_trace("AdsGetFieldRaw"); - return AdsGetField(hTable, pucField, pucBuf, pulLen, 0); -} -UNSIGNED32 ENTRYPOINT AdsGetFilter(ADSHANDLE hTable, UNSIGNED8* p, UNSIGNED16* l) { - arc2_trace("AdsGetFilter"); - if (l == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (auto* rt = get_remote_table(hTable)) { - openads::abi::copy_to_caller(p, l, rt->filter_expr); - return ok(); - } - Table* t = get_table(hTable); - if (t == nullptr) { - if (p && *l > 0) p[0] = 0; - *l = 0; - return ok(); - } - openads::abi::copy_to_caller(p, l, t->filter_expr()); - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsGetHandleType(ADSHANDLE h, UNSIGNED16* p) { - arc2_trace("AdsGetHandleType"); - if (p == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *p = ADS_NONE; - auto& s = state(); - auto kind = s.registry.kind_of(h); - switch (kind) { - case HandleKind::Connection: { - // ADS_DATABASE_CONNECTION means "connected to a DATA DICTIONARY", - // not merely "is a connection". Reporting it for a free-table - // connection makes Harbour's rddads take its dictionary path and - // open every table with ADS_DEFAULT, expecting the dictionary to - // resolve the format; the default is DBF, so an ADT table is then - // parsed as a DBF (garbage field count and names) and the USE dies - // with EG_CORRUPTION / EDBF_CORRUPT while every ACE call still - // reports success. Only a connection that actually carries a - // dictionary gets the flag. - auto* c = s.registry.lookup(h, HandleKind::Connection); - *p = (c != nullptr && c->has_dd()) ? ADS_DATABASE_CONNECTION - : ADS_NONE; - break; - } - case HandleKind::RemoteConnection: - *p = ADS_DATABASE_CONNECTION; break; - case HandleKind::Table: - case HandleKind::RemoteTable: - case HandleKind::SqliteTable: - case HandleKind::MssqlTable: - case HandleKind::MariaTable: - case HandleKind::PostgresTable: - case HandleKind::OdbcTable: - case HandleKind::FirebirdTable: - *p = ADS_TABLE; break; - case HandleKind::Statement: - *p = ADS_STATEMENT; break; - default: - *p = ADS_NONE; break; - } - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsGetIndexCondition(ADSHANDLE hIndex, UNSIGNED8* p, - UNSIGNED16* l) { - arc2_trace("AdsGetIndexCondition"); - if (l == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - auto& m = index_bindings(); - // Storm fix: reader must hold index_bindings_mu (binds are unlocked now). - std::lock_guard _rc_lk(index_bindings_mu()); - auto it = m.find(hIndex); - if (it == m.end()) { - if (p && *l > 0) p[0] = 0; - *l = 0; - return ok(); - } - std::string cond; - if (it->second.parked) { - cond = it->second.parked->condition(); - } else if (it->second.table && it->second.table->order() - && it->second.table->order()->index()) { - cond = it->second.table->order()->index()->condition(); - } - openads::abi::copy_to_caller(p, l, cond); - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsGetIndexFilename(ADSHANDLE hIndex, UNSIGNED16 usOption, - UNSIGNED8* p, UNSIGNED16* l) { - arc2_trace("AdsGetIndexFilename"); - if (l == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - // usOption: ADS_FULLPATHNAME (1) / ADS_BASENAMEANDEXT (2) / - // ADS_BASENAME (3). rddads' DBOI_BAGNAME passes ADS_BASENAME and - // Harbour compares it against DBFCDX's extension-less bag name - // ("da_main"); the option used to be ignored and every caller got - // the stored (full-ish) path. - auto shape = [usOption](const std::string& stored) -> std::string { - namespace fs = std::filesystem; - fs::path sp(stored); - switch (usOption) { - case ADS_BASENAME: - return sp.stem().string(); - case ADS_BASENAMEANDEXT: - return sp.filename().string(); - case ADS_FULLPATHNAME: - default: { - std::error_code ec; - fs::path abs = fs::absolute(sp, ec); - return ec ? stored : abs.string(); - } - } - }; - // Remote index: return the bag_path stored when the index was opened. - // This lets FWH/rddads serve OrdBagName() / DBOI_BAGNAME without a - // wire round-trip. - if (auto* ri = get_remote_index(hIndex)) { - if (!ri->bag_path.empty()) { - openads::abi::copy_to_caller(p, l, shape(ri->bag_path)); - return ok(); - } - // Fallback: derive from the parent table name if bag_path is empty - // (e.g. old server that doesn't emit it yet). - if (ri->parent && !ri->parent->name.empty()) { - namespace fs = std::filesystem; - fs::path tp(ri->parent->name); - std::string fallback; - auto ext = tp.extension().string(); - for (auto& c : ext) - c = static_cast(std::tolower( - static_cast(c))); - if (ext == ".dbf") fallback = tp.stem().string() + ".cdx"; - else if (ext == ".adt") fallback = tp.stem().string() + ".adi"; - else fallback = ri->parent->name; - openads::abi::copy_to_caller(p, l, shape(fallback)); - return ok(); - } - if (p && *l > 0) p[0] = 0; - *l = 0; - return ok(); - } - auto& m = index_bindings(); - // Storm fix: reader must hold index_bindings_mu (binds are unlocked now). - std::lock_guard _rf_lk(index_bindings_mu()); - auto it = m.find(hIndex); - if (it == m.end()) { - if (p && *l > 0) p[0] = 0; - *l = 0; - return ok(); - } - openads::abi::copy_to_caller(p, l, shape(it->second.path)); - return ok(); -} -// 1-based position of the order `hIndex` within its table's ordinal -// sequence -- the exact inverse of AdsGetIndexHandleByOrder. Harbour -// rddads' OrdNumber() / DBOI_NUMBER calls this after resolving a tag -// name to a handle (contrib/rddads/ads1.c, adsOrderInfo); a stubbed 0 -// made OrdNumber() report 0 for every tag. Returns 0 (natural order) -// for an unknown handle. -UNSIGNED32 ENTRYPOINT AdsGetIndexOrderByHandle(ADSHANDLE hIndex, UNSIGNED16* p) { - arc2_trace("AdsGetIndexOrderByHandle"); - if (p == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *p = 0; - // Remote index handle (RemoteIndex): the ordinal is the tag's 1-based - // position among the orders opened on the parent table. Match by tag - // name, not handle identity, so the answer is stable when the same bag - // is opened twice (production auto-open + explicit dbSetIndex). - if (auto* ri = get_remote_index(hIndex)) { - if (ri->parent == nullptr) return ok(); - for (std::size_t i = 0; i < ri->parent->index_by_tag.size(); ++i) { - if (ri->parent->index_by_tag[i].first == ri->tag_name) { - *p = static_cast(i + 1); - break; - } - } - return ok(); - } - auto& m = index_bindings(); - // Storm fix: reader must hold index_bindings_mu (binds are unlocked now). - std::lock_guard _ro_lk(index_bindings_mu()); - Table* t = nullptr; - std::string tag; - { - auto it = m.find(hIndex); - if (it == m.end()) return ok(); // unknown handle → natural order - t = it->second.table; - tag = it->second.tag_name; // copy before the helper rehashes m - } - if (t == nullptr) return ok(); - // Match by tag name (not handle identity) so the result is stable even - // if more than one binding transiently exists for the same tag. - // (_ro_lk from above still held: m.find below + ordered_index_handles_for - // read the map -- ordered_index_handles_for itself takes the mutex, and - // it is recursive.) - std::vector ordered = ordered_index_handles_for(t); - for (std::size_t i = 0; i < ordered.size(); ++i) { - auto bit = m.find(ordered[i]); - if (bit != m.end() && bit->second.tag_name == tag) { - *p = static_cast(i + 1); - break; - } - } - return ok(); -} -// AdsGetJulian already defined elsewhere in this file. -UNSIGNED32 ENTRYPOINT AdsGetKeyLength(ADSHANDLE hIndex, UNSIGNED16* p) { - arc2_trace("AdsGetKeyLength"); - if (p == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *p = 0; - auto* idx = iindex_for_handle(hIndex); - if (idx == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no index"); - *p = idx->key_length(); - return ok(); -} -// 1-based position of the current record within the active order's key -// sequence (== the record number when no order is active). FWH's -// xBrowse uses this (via Harbour rddads' AdsKeyNo()) as its scrollbar -// position; a stubbed 0 left the browse unable to paint any row. -UNSIGNED32 ENTRYPOINT AdsGetKeyNum(ADSHANDLE hObj, UNSIGNED16 /*usFilterOption*/, - UNSIGNED32* pulKeyNum) { - arc2_trace("AdsGetKeyNum"); - if (pulKeyNum == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *pulKeyNum = 0; - if (auto* ri = get_remote_index(hObj)) { - if (ri->parent == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, "remote index"); - } - return remote_query_key_num(ri->parent, ri, pulKeyNum); - } - if (auto* rt = get_remote_table(hObj)) { - return remote_query_key_num(rt, nullptr, pulKeyNum); - } - Table* t = get_table(hObj); - if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); - auto* ord = t->order(); - if (ord == nullptr || ord->index() == nullptr) { - // natural order: key number == record number - *pulKeyNum = t->recno(); - return ok(); - } - // Active order: logical key number = position in key order + 1. - std::uint32_t rn = t->recno(); - auto* idx = ord->index(); - // CDX: O(1) via the cached ordered-recno walk. - if (auto* cdx = dynamic_cast(idx)) { - std::uint32_t pos = cdx->pos_of_recno_cached(rn); - *pulKeyNum = (pos == 0xFFFFFFFFu) ? 0u : (pos + 1u); - return ok(); - } - // Non-CDX: legacy O(n) walk (cursor restored). - idx->invalidate_cursor(); - auto first = idx->seek_first(); - if (!first) { (void)t->goto_record(rn); return fail(first.error()); } - std::uint32_t pos = 0, found = 0; - auto cur = first.value(); - while (cur.positioned) { - ++pos; - if (cur.recno == rn) { found = pos; break; } - auto nx = idx->next(); - if (!nx) { idx->invalidate_cursor(); (void)t->goto_record(rn); - return fail(nx.error()); } - cur = nx.value(); - } - idx->invalidate_cursor(); - (void)t->goto_record(rn); - *pulKeyNum = found; // 0 when rn isn't in the index walk - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsGetKeyType(ADSHANDLE hIndex, UNSIGNED16* p) { - arc2_trace("AdsGetKeyType"); - if (p == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - // ACE reports the key expression *result type* here, using the - // field-type constants (ADS_STRING=4, ADS_NUMERIC=2, ADS_DATE=3, - // ADS_LOGICAL=1) -- NOT the AdsSeek/AdsSetScope buffer-encoding - // constants (ADS_STRINGKEY=1 / ADS_DOUBLEKEY=2). Harbour's rddads - // switches OrdScope()'s key encoding on this value; returning - // ADS_STRINGKEY (1 == ADS_LOGICAL) made every character key look - // like a logical key, so scopes were sent as a 1-byte "T"/"F" - // instead of the real key value. - *p = ADS_STRING; - // M12.35 — remote index path: query the server for the real key type. - if (auto* ri = get_remote_index(hIndex)) { - auto r = ri->conn->get_key_type(ri->id); - if (r) *p = r.value(); - return ok(); - } - auto* idx = iindex_for_handle(hIndex); - if (idx == nullptr) return ok(); - // Bare-field key: answer from the schema. Date and logical keys are - // Text-encoded on disk, so the key encoding alone cannot tell them - // apart from character keys. - if (Table* t = lookup_table_by_index(hIndex)) { - std::int32_t fi = t->field_index( - openads::engine::strip_alias_qualifiers(idx->expression())); - if (fi >= 0) { - using FT = openads::drivers::DbfFieldType; - switch (t->field_descriptor( - static_cast(fi)).type) { - case FT::Character: case FT::CiCharacter: - case FT::Varchar: case FT::Memo: - *p = ADS_STRING; return ok(); - case FT::Date: case FT::AdtDate: - *p = ADS_DATE; return ok(); - case FT::Logical: - *p = ADS_LOGICAL; return ok(); - case FT::Numeric: case FT::Float: case FT::Integer: - case FT::Double: case FT::Currency: case FT::AdtMoney: - case FT::ShortInt: case FT::AutoInc: - *p = ADS_NUMERIC; return ok(); - default: break; - } - } - } - // Computed expression: fall back to the on-disk key encoding. - switch (idx->key_encoding()) { - case openads::drivers::KeyEncoding::Text: - *p = ADS_STRING; break; - case openads::drivers::KeyEncoding::FoxNumeric: - case openads::drivers::KeyEncoding::NtxNumeric: - *p = ADS_NUMERIC; break; - } - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsGetLastTableUpdate(ADSHANDLE hTable, UNSIGNED8* pucDate, - UNSIGNED16* pusLen) { - arc2_trace("AdsGetLastTableUpdate"); - int y = 0, m = 0, d = 0; - if (auto* rt = get_remote_table(hTable)) { - auto r = rt->conn->get_last_table_update(rt->id); - if (!r) return fail(r.error()); - std::uint32_t v = r.value(); - y = static_cast((v >> 16) & 0xFFFFu); - m = static_cast((v >> 8) & 0xFFu); - d = static_cast( v & 0xFFu); - } else { - Table* tbl = get_table(hTable); - if (tbl == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); - auto hd = read_header_date(tbl); - y = hd.y; m = hd.m; d = hd.d; - } - // rddads' DBI_LASTUPDATE passes the leftover length of its previous - // AdsGetDateFormat call as this call's capacity (8 for "MM/DD/YY") - // and NUL-terminates the buffer itself, so the date must fill the - // buffer WITHOUT reserving a byte for NUL -- copy_ace_string's - // cap-1 behaviour truncated "20260815" to "2026081" and Harbour - // showed an empty LUpdate(). Native ACE semantics: copy - // min(len, cap) bytes, NUL only when there is room. - { - std::string s = format_ace_date(g_date_format, y, m, d); - if (pusLen != nullptr) { - const std::size_t cap = *pusLen; - const std::size_t n = std::min(s.size(), cap); - if (pucDate != nullptr && cap > 0) { - std::memcpy(pucDate, s.data(), n); - if (n < cap) pucDate[n] = 0; - } - *pusLen = static_cast(n); - } - } - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsGetLogical(ADSHANDLE hTable, UNSIGNED8* pucField, UNSIGNED16* pb) { - arc2_trace("AdsGetLogical"); - if (pb == nullptr) return openads::AE_INTERNAL_ERROR; - UNSIGNED8 buf[8] = {0}; - UNSIGNED32 cap = sizeof(buf); - UNSIGNED32 rc = AdsGetField(hTable, pucField, buf, &cap, 0); - if (rc != openads::AE_SUCCESS) return rc; - *pb = (cap > 0 && (buf[0] == 'T' || buf[0] == 't' || - buf[0] == 'Y' || buf[0] == 'y' || - buf[0] == '1')) ? 1 : 0; - return openads::AE_SUCCESS; -} -UNSIGNED32 ENTRYPOINT AdsGetMilliseconds(ADSHANDLE, UNSIGNED8*, SIGNED32* p) - { - arc2_trace("AdsGetMilliseconds"); if (p) *p = 0; return openads::AE_SUCCESS; } -UNSIGNED32 ENTRYPOINT AdsGetNumActiveLinks(ADSHANDLE /*hConnect*/, UNSIGNED16* p) { - arc2_trace("AdsGetNumActiveLinks"); - if (p == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *p = 0; - auto& s = state(); - std::uint16_t count = 0; - s.registry.for_each_handle([&](Handle, HandleKind k, void*) { - if (k == HandleKind::RemoteConnection) ++count; - }); - *p = count; - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsGetNumLocks(ADSHANDLE hTable, UNSIGNED16* p) { - arc2_trace("AdsGetNumLocks"); - if (p == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *p = 0; - // Remote: route through the wire GetAllLocks op and count (was a stub - // returning 0 — lock introspection on remote tables reported nothing). - if (auto* rt = get_remote_table(hTable)) { - // Same ledger fast path as AdsGetAllLocks: while the ledger is - // provably complete (no append auto-lock outstanding, no table - // lock), the count is the ledger size. rddads polls this after - // every commit -- answering locally saves 1 RTT each time. - if (!rt->locks_uncertain && !rt->table_lock_held) { - cli_trace_tbl(rt, "AdsGetNumLocks", - "served from client lock ledger"); - *p = static_cast(rt->held_recs.size()); - return ok(); - } - auto r = rt->conn->get_all_locks(rt->id); - if (!r) return fail(r.error()); - *p = static_cast(r.value().size()); - return ok(); - } - Table* t = get_table(hTable); - if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); - *p = t->lock_count(); - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsGetNumOpenTables(UNSIGNED16* p) { - arc2_trace("AdsGetNumOpenTables"); - if (p == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *p = 0; - auto& s = state(); - std::uint16_t count = 0; - s.registry.for_each_handle([&](Handle, HandleKind k, void*) { - if (k == HandleKind::Table || k == HandleKind::RemoteTable - || k == HandleKind::SqliteTable || k == HandleKind::MssqlTable - || k == HandleKind::MariaTable || k == HandleKind::PostgresTable - || k == HandleKind::OdbcTable || k == HandleKind::FirebirdTable) { - ++count; - } - }); - *p = count; - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsGetRecord(ADSHANDLE hTable, UNSIGNED8* pucRecord, - UNSIGNED32* pulLen) { - arc2_trace("AdsGetRecord"); - if (pulLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (auto* rt = get_remote_table(hTable)) { - if (pucRecord == nullptr || *pulLen == 0) { - auto r = rt->conn->get_record_length(rt->id); - if (!r) return fail(r.error()); - *pulLen = r.value(); - return ok(); - } - // RCB 07/14/2026: BUG FIX -- GetRecord reads the SERVER's current - // record, so the prefetch lag has to be settled first. Rows served - // locally out of the lookahead queue leave the server cursor offset by - // cursor_lag, and this path never settled, so it happily handed back - // the raw image of a record the caller had already skipped past. - // - // KNOWN COST, accepted deliberately: settling forces a round-trip, so a - // scan shaped like Skip(1)/GetRecord/Skip(1)/GetRecord defeats - // read-ahead and pays 1 RTT per row. Correctness has to win -- handing - // back the wrong record is not a trade we get to make -- but it does mean - // AdsGetRecord is the ONE nav-path read that prefetch cannot accelerate. - // Fixing it properly means carrying the raw record image in the - // lookahead block; today the block carries decoded per-field values, - // which is what AdsGetField and friends want and what AdsGetRecord - // specifically cannot use. Tracked in todo.local.md. - if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; - remote_settle_cursor(rt); - auto r = rt->conn->get_record(rt->id); - if (!r) return fail(r.error()); - const auto& buf = r.value(); - const std::uint32_t need = - static_cast(buf.size()); - if (*pulLen < need) { - *pulLen = need; - return fail(openads::AE_INSUFFICIENT_BUFFER, - "record buffer too small"); - } - std::memcpy(pucRecord, buf.data(), need); - *pulLen = need; - return ok(); - } - Table* t = get_table(hTable); - if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); - if (!t->positioned()) { - *pulLen = 0; - return fail(openads::AE_NO_CURRENT_RECORD, "no current record"); - } - const auto& buf = t->record_buffer(); - const std::uint32_t need = static_cast(buf.size()); - // Null buffer (or zero length in) is a size query: report the record - // length so the caller can allocate, then ask again. - if (pucRecord == nullptr || *pulLen == 0) { *pulLen = need; return ok(); } - if (*pulLen < need) { - *pulLen = need; - return fail(openads::AE_INSUFFICIENT_BUFFER, "record buffer too small"); - } - // Raw binary image -- copy verbatim, no NUL terminator. - std::memcpy(pucRecord, buf.data(), need); - *pulLen = need; - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsGetRelKeyPos(ADSHANDLE h, double* p) { - arc2_trace("AdsGetRelKeyPos"); - 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 defined(OPENADS_WITH_FIREBIRD) - if (auto* st = get_firebird_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* ri = get_remote_index(h)) { - if (ri->parent == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, "remote index"); - } - return remote_query_rel_key_pos(ri->parent, ri, p); - } - if (auto* rt = get_remote_table(h)) { - return remote_query_rel_key_pos(rt, nullptr, p); - } - Table* t = get_table(h); - if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); - std::uint32_t rc = t->record_count(); - std::uint32_t rn = t->recno(); - if (rc <= 1) { *p = 0.0; return ok(); } - if (rn == 0) { - // Phantom position (recno 0): EOF -> bottom (1.0), BOF -> top (0.0). - // Returning 0.0 for EOF made the scrollbar snap to the TOP when a - // held PageDown overshot the end, so the browse jumped back up and - // would not scroll down again. Reporting EOF as bottom keeps it put. - *p = t->eof() ? 1.0 : 0.0; - return ok(); - } - - // Active-order path: ADS reports the cursor's relative position - // in the *index walk*, not in raw recno space. Walk the index - // once collecting recnos in order; the cursor's recno's index - // in that list, divided by (total - 1), is the logical Get - // value. Cursor position is restored afterwards so a Get probe - // doesn't move the user-visible cursor. - auto* ord = t->order(); - if (ord != nullptr && ord->index() != nullptr) { - auto* idx = ord->index(); - // CDX: O(1) via the cached ordered-recno walk (built once, reused - // across paints). The previous per-call O(n) walk made TXBrowse - // freeze on large/filtered orders (the scrollbar hits this every - // paint). - if (auto* cdx = - dynamic_cast(idx)) { - const auto& walk = cdx->ordered_recnos_cached(); - if (walk.empty()) { *p = 0.0; return ok(); } - std::uint32_t pos = cdx->pos_of_recno_cached(rn); - if (pos == 0xFFFFFFFFu) { - if (rn > rc) rn = rc; - *p = (static_cast(rn) + 0.5) / - static_cast(rc); - if (*p > 1.0) *p = 1.0; - return ok(); - } - *p = (static_cast(pos) + 0.5) / - static_cast(walk.size()); - return ok(); - } - // Non-CDX: O(1) via the cached ordered-recno walk (built once, - // reused across paints). Mirrors the CDX fast path above. - if (auto* ntx = - dynamic_cast(idx)) { - const auto& walk = ntx->ordered_recnos_cached(); - if (walk.empty()) { *p = 0.0; return ok(); } - std::uint32_t pos = ntx->pos_of_recno_cached(rn); - if (pos == 0xFFFFFFFFu) { - if (rn > rc) rn = rc; - *p = (static_cast(rn) + 0.5) / - static_cast(rc); - if (*p > 1.0) *p = 1.0; - return ok(); - } - *p = (static_cast(pos) + 0.5) / - static_cast(walk.size()); - return ok(); - } - if (auto* adi = - dynamic_cast(idx)) { - const auto& walk = adi->ordered_recnos_cached(); - if (walk.empty()) { *p = 0.0; return ok(); } - std::uint32_t pos = adi->pos_of_recno_cached(rn); - if (pos == 0xFFFFFFFFu) { - if (rn > rc) rn = rc; - *p = (static_cast(rn) + 0.5) / - static_cast(rc); - if (*p > 1.0) *p = 1.0; - return ok(); - } - *p = (static_cast(pos) + 0.5) / - static_cast(walk.size()); - return ok(); - } - // Unknown index type: legacy per-call O(n) walk. - idx->invalidate_cursor(); - auto first = idx->seek_first(); - if (!first) return fail(first.error()); - std::vector walk; - walk.reserve(128); - auto cur = first.value(); - while (cur.positioned) { - walk.push_back(cur.recno); - auto nx = idx->next(); - if (!nx) return fail(nx.error()); - cur = nx.value(); - } - idx->invalidate_cursor(); - (void)t->goto_record(rn); - if (walk.size() <= 1) { *p = 0.0; return ok(); } - std::size_t pos = walk.size(); - for (std::size_t i = 0; i < walk.size(); ++i) { - if (walk[i] == rn) { pos = i; break; } - } - if (pos >= walk.size()) { - // Cursor recno not present in the index -- fall back to - // recno-based fraction so the result stays monotonic. - if (rn > rc) rn = rc; - *p = (static_cast(rn) + 0.5) / - static_cast(rc); - if (*p > 1.0) *p = 1.0; - return ok(); - } - *p = (static_cast(pos) + 0.5) / - static_cast(walk.size()); - return ok(); - } - - // No active order: relative position in raw recno space, using the - // ADS convention rddads documents ("ADS counts relative record - // position in this way"): (0.5 + recno) / reccount. - if (rn > rc) rn = rc; - *p = (static_cast(rn) + 0.5) / static_cast(rc); - if (*p > 1.0) *p = 1.0; - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsGetSearchPath(UNSIGNED8* p, UNSIGNED16* l) { - arc2_trace("AdsGetSearchPath"); - if (l == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - openads::abi::copy_to_caller(p, l, g_search_path); - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsGetTableAlias(ADSHANDLE hTable, UNSIGNED8* p, UNSIGNED16* l) { - arc2_trace("AdsGetTableAlias"); - if (l == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (auto* rt = get_remote_table(hTable)) { - openads::abi::copy_to_caller(p, l, rt->alias); - return ok(); - } - Table* t = get_table(hTable); - if (t == nullptr) { - if (p && *l > 0) p[0] = 0; - *l = 0; - return ok(); - } - openads::abi::copy_to_caller(p, l, t->alias()); - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsGetTableCharType(ADSHANDLE hTable, UNSIGNED16* p) { - arc2_trace("AdsGetTableCharType"); - if (p == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (get_remote_table(hTable) != nullptr) { *p = ADS_ANSI; - arc2_log("CHARTYPE ret %u", (unsigned)*p); return ok(); } - Table* t = get_table(hTable); - if (t == nullptr) - return fail(openads::AE_INTERNAL_ERROR, "no table"); - // RCB 2026-07-10 -- report the char type the table was opened with - // (AdsOpenTable now stores usCharType -- #130 follow-up). Was a - // hard-coded ADS_ANSI. - *p = t->char_type(); - arc2_log("CHARTYPE ret %u", (unsigned)*p); - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsGetTableConType(ADSHANDLE hTable, UNSIGNED16* p) { - arc2_trace("AdsGetTableConType"); - if (p == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - // Delegate to AdsGetTableType which already maps file extensions - // to ACE table-type constants (CDX/NTX/ADT). - return AdsGetTableType(hTable, p); -} -UNSIGNED32 ENTRYPOINT AdsGetTableConnection(ADSHANDLE hTable, ADSHANDLE* p) { - arc2_trace("AdsGetTableConnection"); - if (p == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *p = 0; - if (auto* rt = get_remote_table(hTable)) { - // For remote tables, return the remote-connection handle. - auto& s = state(); - s.registry.for_each_handle([&](Handle h, HandleKind k, void* ptr) { - if (k == HandleKind::RemoteConnection && - ptr == static_cast(rt->conn)) { - *p = to_ads_handle(h); - } - }); - return ok(); - } - Table* t = get_table(hTable); - if (t == nullptr) return ok(); - Connection* c = conn_for_table(t); - if (c == nullptr) return ok(); - // Find the handle for this Connection* in the registry. - auto& s = state(); - s.registry.for_each_handle([&](Handle h, HandleKind k, void* ptr) { - if (k == HandleKind::Connection && - ptr == static_cast(c)) { - *p = to_ads_handle(h); - } - }); - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsIsConnectionAlive(ADSHANDLE hConnect, UNSIGNED16* p) { - arc2_trace("AdsIsConnectionAlive"); - if (p == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *p = 1; // assume alive - if (get_remote_table(hConnect) != nullptr) { - // Remote table: the connection is alive if we can reach it. - // Conservative: if the table handle exists, the connection is alive. - *p = 1; - return ok(); - } - Connection* c = lookup_connection(hConnect); - if (c != nullptr) { - *p = 1; // local connections are always alive - } - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsIsEmpty(ADSHANDLE hTable, UNSIGNED8* pucField, - UNSIGNED16* pbEmpty) { - arc2_trace("AdsIsEmpty"); - if (pbEmpty == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *pbEmpty = 0; - if (get_remote_table(hTable) != nullptr) return ok(); - Table* t = get_table(hTable); - if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); - std::uint16_t idx = 0; - if (!resolve_field_index_h(hTable, t, pucField, &idx)) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - *pbEmpty = t->is_field_empty(idx) ? 1 : 0; - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsIsExprValid(ADSHANDLE, UNSIGNED8*, UNSIGNED16* p) - { - arc2_trace("AdsIsExprValid"); if (p) *p = 1; return openads::AE_SUCCESS; } -// AdsIsFound already defined elsewhere in this file. -UNSIGNED32 ENTRYPOINT AdsIsIndexCustom(ADSHANDLE, UNSIGNED16* p) - { - arc2_trace("AdsIsIndexCustom"); if (p) *p = 0; return openads::AE_SUCCESS; } -UNSIGNED32 ENTRYPOINT AdsIsIndexDescending(ADSHANDLE hIndex, UNSIGNED16* p) { - arc2_trace("AdsIsIndexDescending"); - if (p == nullptr) return openads::AE_INTERNAL_ERROR; - *p = 0; - if (auto* ri = get_remote_index(hIndex)) { - // Physical flag only -- the dynamic AdsSetIndexDirection traversal - // override (see the LOCAL path below) isn't wired over the wire - // protocol yet. - *p = ri->is_descending ? 1 : 0; - return openads::AE_SUCCESS; - } - // For the ACTIVE order, report the *effective* traversal direction -- - // the dynamic flag AdsSetIndexDirection toggles -- not the index's baked - // physical descending flag. rddads' OrdDescend() reads this to decide - // whether to flip; if it always saw the physical flag it could never - // observe a dynamic reversal, and FWH xbrowse header re-sorting (which - // toggles via OrdDescend) would stick after the first click. - { - auto& s = state(); - std::lock_guard lk(s.mu); - // Storm fix: reader must hold index_bindings_mu (binds are unlocked now). - std::lock_guard _rd_lk(index_bindings_mu()); - auto& m = index_bindings(); - auto it = m.find(hIndex); - // The active binding is the only one with no parked IIndex (its - // index lives in Table::order_). Parked/inactive bindings have no - // dynamic traverse state, so fall through to the physical flag. - if (it != m.end() && !it->second.parked && it->second.table) { - if (auto* o = it->second.table->order()) { - *p = o->descending_traverse() ? 1 : 0; - return openads::AE_SUCCESS; - } - } - } - auto* idx = iindex_for_handle(hIndex); - if (idx == nullptr) return openads::AE_INTERNAL_ERROR; - *p = idx->descending() ? 1 : 0; - return openads::AE_SUCCESS; -} -UNSIGNED32 ENTRYPOINT AdsIsIndexUnique(ADSHANDLE hIndex, UNSIGNED16* p) { - arc2_trace("AdsIsIndexUnique"); - if (p == nullptr) return openads::AE_INTERNAL_ERROR; - *p = 0; - if (auto* ri = get_remote_index(hIndex)) { - *p = ri->is_unique ? 1 : 0; - return openads::AE_SUCCESS; - } - auto* idx = iindex_for_handle(hIndex); - if (idx == nullptr) return openads::AE_INTERNAL_ERROR; - *p = idx->unique() ? 1 : 0; - return openads::AE_SUCCESS; -} -UNSIGNED32 ENTRYPOINT AdsIsNull(ADSHANDLE hTable, UNSIGNED8* pucField, - UNSIGNED16* pbNull) { - arc2_trace("AdsIsNull"); - if (pbNull == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *pbNull = 0; - if (get_remote_table(hTable) != nullptr) { - // Remote tables: nullability isn't exposed over the wire yet; - // conservatively report "not null" (same as legacy ACE). - return ok(); - } - Table* t = get_table(hTable); - if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); - std::uint16_t idx = 0; - if (!resolve_field_index_h(hTable, t, pucField, &idx)) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - *pbNull = t->is_field_null(idx) ? 1 : 0; - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsIsNullable(ADSHANDLE hTable, UNSIGNED8* pucField, - UNSIGNED16* pbNullable) { - arc2_trace("AdsIsNullable"); - if (pbNullable == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *pbNullable = 0; - if (get_remote_table(hTable) != nullptr) return ok(); - Table* t = get_table(hTable); - if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); - std::uint16_t idx = 0; - if (!resolve_field_index_h(hTable, t, pucField, &idx)) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - const auto& f = t->field_descriptor(idx); - if (f.adt) { - using FT = openads::drivers::DbfFieldType; - *pbNullable = (f.type != FT::AutoInc && f.type != FT::RowVersion) ? 1 : 0; - } else { - *pbNullable = f.nullable ? 1 : 0; - } - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsIsRecordInAOF(ADSHANDLE, UNSIGNED32, UNSIGNED16* p) - { - arc2_trace("AdsIsRecordInAOF"); if (p) *p = 1; return openads::AE_SUCCESS; } -// ulRecord == 0 means "the current record" (ACE convention). Reports -// whether *this* connection holds an exclusive lock on it. Remote -// handles go over the wire (M12.36 IsRecordLocked opcode); the server -// translates recno 0 to the current record on its side. -UNSIGNED32 ENTRYPOINT AdsIsRecordLocked(ADSHANDLE hTable, UNSIGNED32 ulRecord, - UNSIGNED16* pbLocked) { - arc2_trace("AdsIsRecordLocked"); - if (pbLocked == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *pbLocked = 0; - if (auto* rt = get_remote_table(hTable)) { - auto r = rt->conn->is_record_locked(rt->id, ulRecord); - if (!r) return fail(r.error()); - *pbLocked = r.value(); - return ok(); - } - Table* t = get_table(hTable); - if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); - std::uint32_t rec = (ulRecord == 0) ? t->recno() : ulRecord; - // mtfix11: SAP answers across connections - own registrations plus a - // non-destructive OS lock-byte probe, not this table's list alone. - auto any = t->is_record_locked_any(rec); - if (!any) return fail(any.error()); - *pbLocked = any.value() ? 1 : 0; - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsIsServerLoaded(UNSIGNED8*, UNSIGNED16* p) - { - arc2_trace("AdsIsServerLoaded"); if (p) *p = 1; return openads::AE_SUCCESS; } -UNSIGNED32 ENTRYPOINT AdsIsTableLocked(ADSHANDLE hTable, UNSIGNED16* p) { - arc2_trace("AdsIsTableLocked"); - if (p == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *p = 0; - if (get_remote_table(hTable) != nullptr) { return ok(); } - Table* t = get_table(hTable); - if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); - *p = t->is_table_locked() ? 1 : 0; - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsRefreshAOF(ADSHANDLE hTable) { - arc2_trace("AdsRefreshAOF"); - // Remote AOFs are maintained server-side; nothing to do client-side. - if (get_remote_table(hTable)) return ok(); - Table* t = get_table(hTable); - if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); - if (!t->aof_active()) return ok(); // no AOF to refresh - const std::string cond = t->aof_expr(); - if (cond.empty()) return ok(); // AdsCustomizeAOF-only set - // Re-evaluate the stored AOF expression against current data and - // reinstall the bitmap, so rows that changed since AdsSetAOF are - // re-classified. - auto ast = openads::engine::aof::parse(cond); - if (!ast) return ok(); - auto rep = openads::engine::aof::evaluate_optimised(*ast.value(), *t); - if (!rep) return fail(rep.error()); - t->install_aof_bitmap(std::move(rep.value().bm)); - return ok(); -} - -// --------------------------------------------------------------------------- -// M-BM.1 — Bitmap filter from recno array (BMDBFCDX compat) -// -// These functions expose the in-memory bitmap filter capability from -// xHarbour's BMDBFCDX RDD, adapted to OpenADS' existing AOF bitmap -// infrastructure. The bitmap is stored as std::vector inside -// the Table and is 100% compatible with the existing AdsSetAOF / -// AdsCustomizeAOF / AdsRefreshAOF machinery. -// --------------------------------------------------------------------------- - -UNSIGNED32 ENTRYPOINT AdsBmSetFilter(ADSHANDLE hTable, - UNSIGNED32* pRecnos, - UNSIGNED32 ulCount) { - arc2_trace("AdsBmSetFilter"); - if (get_remote_table(hTable)) return ok(); - Table* t = get_table(hTable); - if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); - if (pRecnos == nullptr || ulCount == 0) { - // Empty array = clear bitmap filter - t->clear_filter(); - return ok(); - } - std::vector recnos(pRecnos, pRecnos + ulCount); - t->install_bitmap_from_recnos(recnos); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsBmGetFilterArray(ADSHANDLE hTable, - UNSIGNED32* pRecnos, - UNSIGNED32* pulCount) { - arc2_trace("AdsBmGetFilterArray"); - if (get_remote_table(hTable)) return ok(); - Table* t = get_table(hTable); - if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); - if (pulCount == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - auto recnos = t->get_bitmap_as_recnos(); - *pulCount = static_cast(recnos.size()); - if (pRecnos != nullptr && !recnos.empty()) { - std::copy(recnos.begin(), recnos.end(), pRecnos); - } - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsBmFilterAdd(ADSHANDLE hTable, - UNSIGNED32* pRecnos, - UNSIGNED32 ulCount) { - arc2_trace("AdsBmFilterAdd"); - if (get_remote_table(hTable)) return ok(); - Table* t = get_table(hTable); - if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); - if (pRecnos == nullptr || ulCount == 0) return ok(); - std::vector recnos(pRecnos, pRecnos + ulCount); - if (!t->add_to_bitmap(recnos)) { - // No bitmap active — install fresh from the provided recnos - t->install_bitmap_from_recnos(recnos); - } - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsBmFilterDel(ADSHANDLE hTable, - UNSIGNED32* pRecnos, - UNSIGNED32 ulCount) { - arc2_trace("AdsBmFilterDel"); - if (get_remote_table(hTable)) return ok(); - Table* t = get_table(hTable); - if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); - if (pRecnos == nullptr || ulCount == 0) return ok(); - std::vector recnos(pRecnos, pRecnos + ulCount); - t->remove_from_bitmap(recnos); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsBmTurbo(ADSHANDLE hTable, UNSIGNED16 usOnOff) { - arc2_trace("AdsBmTurbo"); - if (get_remote_table(hTable)) return ok(); - Table* t = get_table(hTable); - if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); - t->set_bm_turbo(usOnOff != 0); - return ok(); -} - -// --------------------------------------------------------------------------- -// M-BM.3 — Wildcard seek on CDX keys (BMDBFCDX BM_DBSEEKWILD compat) -// -// Performs a pattern-match seek on the current index tag. The pattern -// uses ? (single char) and * (zero or more chars) wildcards, mapped to -// the regex engine for evaluation. When lAll is set, collects ALL -// matching record numbers into the output array instead of just the -// first hit. -// --------------------------------------------------------------------------- - -UNSIGNED32 ENTRYPOINT AdsBmSeekWild(ADSHANDLE hTable, - UNSIGNED8* pucPattern, - UNSIGNED16 usSoftSeek, - UNSIGNED16 usFindLast, - UNSIGNED16 usNext, - UNSIGNED16 usAll, - ADSHANDLE* phResult) { - arc2_trace("AdsBmSeekWild"); - if (get_remote_table(hTable)) { - return fail(openads::AE_FUNCTION_NOT_AVAILABLE, - "AdsBmSeekWild not supported over wire yet"); - } - Table* t = get_table(hTable); - if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); - if (pucPattern == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (t->order() == nullptr || t->order()->index() == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, "no active index"); - } - // Convert wildcard pattern to regex: ? -> ., * -> .* - std::string pattern(reinterpret_cast(pucPattern)); - std::string regex_str; - regex_str.reserve(pattern.size() + 8); - for (char c : pattern) { - if (c == '?') regex_str += '.'; - else if (c == '*') regex_str += ".*"; - else if (c == '.') regex_str += "\\."; - else regex_str += c; - } - regex_str = "^" + regex_str + "$"; - // For now, report the pattern was matched against the current tag. - // Full implementation would iterate the B-tree and test each key - // against the regex; this stub returns success so the X# RDD path - // does not break. - (void)usSoftSeek; (void)usFindLast; (void)usNext; (void)usAll; - if (phResult) *phResult = 0; - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsRegisterCallbackFunction(void*) { - arc2_trace("AdsRegisterCallbackFunction"); ADS_STUB(openads::AE_SUCCESS); } -// 64-bit callback ID sibling for 64-bit platforms; behaves identically -// to AdsRegisterCallbackFunction (see its stub note above). -UNSIGNED32 ENTRYPOINT AdsRegisterCallbackFunction101(void*, SIGNED64) { - arc2_trace("AdsRegisterCallbackFunction101"); ADS_STUB(openads::AE_SUCCESS); } -UNSIGNED32 ENTRYPOINT AdsRegisterProgressCallback(void*) { - arc2_trace("AdsRegisterProgressCallback"); ADS_STUB(openads::AE_SUCCESS); } -UNSIGNED32 ENTRYPOINT AdsSetDateFormat(UNSIGNED8* pucFormat) { - arc2_trace("AdsSetDateFormat"); - arc2_trace("AdsSetDateFormat"); - if (pucFormat != nullptr && pucFormat[0] != 0) { - // SAP normalises the stored format: uppercase, and a 4-digit year - // is always kept as CCYY (probed: SetDateFormat("DD.MM.YYYY") - // reads back "DD.MM.CCYY"). - std::string f(reinterpret_cast(pucFormat)); - for (auto& c : f) - c = static_cast(std::toupper( - static_cast(c))); - for (std::size_t p = f.find("YYYY"); p != std::string::npos; - p = f.find("YYYY", p + 4)) { - f.replace(p, 4, "CCYY"); - } - g_date_format = std::move(f); - } - return openads::AE_SUCCESS; -} -UNSIGNED32 ENTRYPOINT AdsSetDateFormat60(ADSHANDLE /*hConnect*/, - UNSIGNED8* pucFormat) { - arc2_trace("AdsSetDateFormat60"); - return AdsSetDateFormat(pucFormat); -} -UNSIGNED32 ENTRYPOINT AdsSetDecimals(UNSIGNED16 usDecimals) { - arc2_trace("AdsSetDecimals"); - g_set_decimals = usDecimals; - return openads::AE_SUCCESS; -} -UNSIGNED32 ENTRYPOINT AdsGetDecimals(UNSIGNED16* pusDecimals) { - arc2_trace("AdsGetDecimals"); - arc2_trace("AdsGetDecimals"); - if (pusDecimals == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *pusDecimals = g_set_decimals; - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsSetDefault(UNSIGNED8* pucPath) { - arc2_trace("AdsSetDefault"); - g_default_path = pucPath - ? openads::abi::to_internal(pucPath, 0) : std::string(); - return openads::AE_SUCCESS; -} - -// -- AdsSetDefaultConnection / AdsGetDefaultConnection ------------------- -// Explicit connection switching for OAds_F* callers that manage multiple -// connections in a single thread. When an OAds_F* function receives hConn -// == 0 it falls back to the thread-local default set here. -UNSIGNED32 ENTRYPOINT AdsSetDefaultConnection(ADSHANDLE hConn) { - arc2_trace("AdsSetDefaultConnection"); - rddads_default_connection() = hConn; - return openads::AE_SUCCESS; -} - -// -- OAdsSetLogging ----------------------------------------------------- -// Production kill-switch for every log line the DLL can emit: the audit -// channel (OPENADS_LOG_FILE / console RESOLVED lines) and the arc -// bring-up traces (ace_calls.log). Paths, aliases and record counts in -// those lines are developer diagnostics — an app going to production -// calls OAdsSetLogging(0) once at startup so end-user machines stay -// silent. 0 = silent, anything else = re-enable (the default). -// Process-local: affects this client DLL instance (and the engine when -// running in local-server mode, since it shares the process). -UNSIGNED32 ENTRYPOINT OAdsSetLogging(UNSIGNED16 usOn) { - openads::util::set_logging_enabled(usOn != 0); - return openads::AE_SUCCESS; -} - -UNSIGNED32 ENTRYPOINT AdsGetDefaultConnection(ADSHANDLE* phConn) { - arc2_trace("AdsGetDefaultConnection"); - if (phConn == nullptr) return fail(openads::AE_INTERNAL_ERROR, "null out"); - *phConn = rddads_default_connection(); - return openads::AE_SUCCESS; -} -UNSIGNED32 ENTRYPOINT AdsSetEpoch(UNSIGNED16 us) { - arc2_trace("AdsSetEpoch"); - openads::engine::set_epoch(us); - return openads::AE_SUCCESS; -} -UNSIGNED32 ENTRYPOINT AdsSetExact(UNSIGNED16 us) { - arc2_trace("AdsSetExact"); - openads::engine::set_set_exact(us != 0); - return openads::AE_SUCCESS; -} -UNSIGNED32 ENTRYPOINT AdsSetExact22(ADSHANDLE /*hObj*/, - UNSIGNED16 bIgnoreSpaces) { - arc2_trace("AdsSetExact22"); - return AdsSetExact(bIgnoreSpaces); -} -// OpenADS extension (RusSoft Ltda): explicit in-process override for the ADI -// v2 tag layout (see adi_v2_enabled() near the top of this file). Lets a host -// process flip v2 on/off for itself without OPENADS_ADI_V2 -- which, being an -// environment variable, only reaches this DLL if set before the process -// starts. Not part of the SAP ACE API; not in openads_ace.def's Advantage -// compatibility block, listed as its own extension entry. -// -// bEnable: 0 = off, 1 = on, 2 = clear the override (fall back to the env var -// again). The reset value exists for test teardown; a host application never -// needs it -- it decides v2 on/off once and stays there. -UNSIGNED32 ENTRYPOINT AdsSetAdiV2(UNSIGNED16 bEnable) { - const int v = (bEnable == 2) ? -1 : (bEnable != 0 ? 1 : 0); - g_adi_v2_override.store(v, std::memory_order_relaxed); - return openads::AE_SUCCESS; -} -UNSIGNED32 ENTRYPOINT AdsSetFilter(ADSHANDLE hTable, UNSIGNED8* pucFilter) { - arc2_trace("AdsSetFilter"); - if (pucFilter == nullptr) return fail(openads::AE_INTERNAL_ERROR, "null filter"); - if (auto* rt = get_remote_table(hTable)) { - // Remote: store the filter expression for later retrieval. - rt->filter_expr = openads::abi::to_internal(pucFilter, 0); - // Purely local visibility change (no wire frame): expire the - // nav stamp, proven-false answers and cached answers — the - // wire-seq rule cannot see any of them. - rt->last_nav = 0; - rt->pair_valid = false; - rt->anchor_ok = false; - rt->nav_not_bof = false; - rt->nav_not_eof = false; - remote_nav_bound_clear(rt); - return ok(); - } - if (UNSIGNED32 rc = 0; - backend_try_push_filter(hTable, openads::abi::to_internal(pucFilter, 0), rc)) { - return rc; - } - Table* t = get_table(hTable); - if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); - t->set_filter_expr(openads::abi::to_internal(pucFilter, 0)); - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsSetFilter100(ADSHANDLE hTable, void* pvFilter, - UNSIGNED32 /*ulOptions*/) { - arc2_trace("AdsSetFilter100"); - return AdsSetFilter(hTable, reinterpret_cast(pvFilter)); -} -// AdsSetJulian, AdsSetLongLong already defined elsewhere in this file. -UNSIGNED32 ENTRYPOINT AdsSetMilliseconds(ADSHANDLE, UNSIGNED8*, SIGNED32) { - arc2_trace("AdsSetMilliseconds"); ADS_STUB(openads::AE_FUNCTION_NOT_AVAILABLE); } -UNSIGNED32 ENTRYPOINT AdsSetRecord(ADSHANDLE hTable, UNSIGNED8* pucRecord, - UNSIGNED32 ulLen) { - arc2_trace("AdsSetRecord"); - if (pucRecord == nullptr) return fail(openads::AE_INTERNAL_ERROR, "null record"); - if (auto* rt = get_remote_table(hTable)) { - if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; - remote_settle_cursor(rt); - rt->row_valid = false; - rt->key_count_cached = false; // full-record write: conditional - rt->key_counts.clear(); - rt->key_counts.clear(); - // membership may have changed - rt->prefetch_queue.clear(); - auto r = rt->conn->set_record(rt->id, pucRecord, - static_cast(ulLen)); - if (!r) return fail(r.error()); - // Full-record write: mark dirty so the commit's FlushTable and - // the FlushFileBuffers gate see it (the write_dirty snapshot in - // AdsWriteRecord depends on this). - rt->write_dirty = true; - return ok(); - } - Table* t = get_table(hTable); - if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); - auto r = t->set_record_raw(pucRecord, static_cast(ulLen)); - if (!r) return fail(r.error()); - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsSetRelKeyPos(ADSHANDLE h, double pos) { - arc2_trace("AdsSetRelKeyPos"); - if (auto* ri = get_remote_index(h)) { - if (ri->parent == nullptr) { - return fail(openads::AE_INTERNAL_ERROR, "remote index"); - } - openads::network::RemoteTable* rt = ri->parent; - auto act = openads::network::remote_activate_index(ri); - if (!act) return fail(act.error()); - remote_ensure_rec_count(rt); - const std::uint32_t rc = - rt->rec_count_cached ? rt->cached_rec_count : 0u; - if (rc <= 1u) return ok(); - if (pos < 0.0) pos = 0.0; - if (pos > 1.0) pos = 1.0; - std::uint32_t target = static_cast( - pos * static_cast(rc - 1u) + 0.5) + 1u; - if (target > rc) target = rc; - return remote_goto_key_num(rt, ri, target); - } - if (auto* rt = get_remote_table(h)) { - remote_ensure_rec_count(rt); - const std::uint32_t rc = - rt->rec_count_cached ? rt->cached_rec_count : 0u; - if (rc <= 1u) return ok(); - if (pos < 0.0) pos = 0.0; - if (pos > 1.0) pos = 1.0; - if (remote_table_has_index(rt)) { - std::uint32_t target = static_cast( - pos * static_cast(rc - 1u) + 0.5) + 1u; - if (target > rc) target = rc; - return remote_goto_key_num(rt, nullptr, target); - } - std::uint32_t rn = static_cast( - pos * static_cast(rc - 1u) + 0.5) + 1u; - if (rn < 1u) rn = 1u; - if (rn > rc) rn = rc; - return remote_goto_key_num(rt, nullptr, rn); - } - Table* t = get_table(h); - if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); - std::uint32_t rc = t->record_count(); - if (rc == 0) return ok(); - if (pos < 0.0) pos = 0.0; - if (pos > 1.0) pos = 1.0; - - // Active-order path: ADS positions the cursor at fraction `pos` - // through the *index walk*, not through raw recno space. Walk - // the index once to collect every recno in order, then jump to - // walk[round(pos * (total - 1))]. Mirrors the Get path above so - // a round-trip Get-then-Set lands on the same key. - auto* ord = t->order(); - if (ord != nullptr && ord->index() != nullptr) { - auto* idx = ord->index(); - const std::vector* walkp = nullptr; - std::vector walk_local; - if (auto* cdx = - dynamic_cast(idx)) { - walkp = &cdx->ordered_recnos_cached(); // O(1) after first build - } else { - idx->invalidate_cursor(); - auto first = idx->seek_first(); - if (!first) return fail(first.error()); - auto cur = first.value(); - while (cur.positioned) { - walk_local.push_back(cur.recno); - auto nx = idx->next(); - if (!nx) return fail(nx.error()); - cur = nx.value(); - } - idx->invalidate_cursor(); - walkp = &walk_local; - } - const auto& walk = *walkp; - if (walk.empty()) return ok(); - std::size_t target = static_cast( - pos * static_cast(walk.size() - 1) + 0.5); - if (target >= walk.size()) target = walk.size() - 1; - auto r = t->goto_record(walk[target]); - if (!r) return fail(r.error()); - return ok(); - } - - // No active order: position by raw recno fraction. - std::uint32_t rn = static_cast( - pos * static_cast(rc - 1) + 0.5) + 1; - if (rn < 1) rn = 1; - if (rn > rc) rn = rc; - auto r = t->goto_record(rn); - if (!r) return fail(r.error()); - return ok(); -} -UNSIGNED32 set_relation_impl(ADSHANDLE hParent, ADSHANDLE hChild, - UNSIGNED8* pucExpr, bool scoped) { - if (pucExpr == nullptr) return fail(openads::AE_INTERNAL_ERROR, "null expr"); - const bool parent_ok = get_table(hParent) != nullptr || - get_remote_table(hParent) != nullptr || - is_sql_table_handle(hParent); - const bool child_ok = get_table(hChild) != nullptr || - get_remote_table(hChild) != nullptr || - is_sql_table_handle(hChild); - if (!parent_ok) return fail(openads::AE_INTERNAL_ERROR, "unknown parent table"); - if (!child_ok) return fail(openads::AE_INTERNAL_ERROR, "unknown child table"); - std::string expr = openads::abi::to_internal(pucExpr, 0); - relation_map()[hParent].push_back( - AdsRelation{hChild, std::move(expr), scoped}); - apply_relations_for_handle(hParent); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsSetRelation(ADSHANDLE hParent, ADSHANDLE hChild, - UNSIGNED8* pucExpr) { - arc2_trace("AdsSetRelation"); - return set_relation_impl(hParent, hChild, pucExpr, /*scoped=*/false); -} -UNSIGNED32 ENTRYPOINT AdsSetScopedRelation(ADSHANDLE hParent, ADSHANDLE hChild, - UNSIGNED8* pucExpr) { - arc2_trace("AdsSetScopedRelation"); - return set_relation_impl(hParent, hChild, pucExpr, /*scoped=*/true); -} -UNSIGNED32 ENTRYPOINT AdsSetSearchPath(UNSIGNED8* pucPath) { - arc2_trace("AdsSetSearchPath"); - g_search_path = pucPath - ? openads::abi::to_internal(pucPath, 0) : std::string(); - return openads::AE_SUCCESS; -} -// Caller's preferred server-type mask (ADS_LOCAL_SERVER / ADS_REMOTE_SERVER -// / ADS_AIS_SERVER). OpenADS serves both local and remote connections -// regardless, so this is recorded for ACE API parity; nothing consults it -// in a way that would block an otherwise-valid connection. -// -// Internal helper -- give it C++ linkage (it returns a reference, which a -// C-linkage function may not, tripping clang's -Werror=return-type-c-linkage -// inside this file's big `extern "C"` block). Mirrors the extern "C++" island -// used for the other reference-returning helpers above. -extern "C++" { -std::uint16_t& server_type_mask() { - static std::uint16_t m = ADS_LOCAL_SERVER | ADS_REMOTE_SERVER; - return m; -} -} // extern "C++" -UNSIGNED32 ENTRYPOINT AdsSetServerType(UNSIGNED16 usServerOptions) { - arc2_trace("AdsSetServerType"); - arc2_trace("AdsSetServerType"); - server_type_mask() = usServerOptions; - return openads::AE_SUCCESS; -} -UNSIGNED32 ENTRYPOINT AdsShowDeleted(UNSIGNED16 us) { - arc2_trace("AdsShowDeleted"); - arc2_trace("AdsShowDeleted"); - const bool visible = us != 0; - openads::engine::set_show_deleted(visible); - auto& s = state(); - std::vector remotes; - { - std::lock_guard lk(s.mu); - if (Connection* c = lookup_connection(resolve_connection_handle(0))) { - c->set_show_deleted(visible); - } - // M12.31 -- SET DELETED is process-wide on the client but must be - // pushed to every open remote server session; otherwise scoped - // index walks on the server still return deleted rows. - s.registry.for_each_handle([&](Handle, HandleKind k, void* p) { - if (k != HandleKind::RemoteConnection) return; - auto* rc = static_cast(p); - if (rc != nullptr) remotes.push_back(rc); - }); - // RCB 07/14/2026: flipping SET DELETED changes which rows are VISIBLE, - // which retroactively invalidates every row we have already read ahead - // of the cursor. The look-ahead block was walked under the old - // visibility, so without this a scan keeps serving rows from it -- - // locally, with no wire traffic at all to hint that anything is stale -- - // and deleted records keep appearing after SET DELETED ON (or vanish - // after SET DELETED OFF). - // - // Same stale-block family as the AdsSeek / AdsSetIndexOrder cases: any - // operation that changes what the queued rows MEAN has to drop them. - // Purely local state, so it is safe to do under s.mu -- no round-trip, - // unlike the show_deleted() pushes below. - s.registry.for_each_handle([&](Handle, HandleKind k, void* p) { - if (k != HandleKind::RemoteTable) return; - auto* rt = static_cast(p); - if (rt == nullptr) return; - rt->row_valid = false; - rt->invalidate_prefetch(); - // Which keys are visible just changed, so the scoped key - // count and any cached keyno are stale too. - rt->key_count_cached = false; - rt->key_counts.clear(); - rt->key_counts.clear(); - rt->keyno_valid = false; - }); - } - // Release s.mu before the wire round-trips -- the in-process test - // server (and openads_serverd embedded in the same DLL) re-enters - // the ABI on another thread and takes s.mu; holding it here - // deadlocks (same pattern as remote AdsOpenTable / AdsOpenIndex). - for (auto* rc : remotes) { - if (rc->valid()) rc->show_deleted(visible); - } - return openads::AE_SUCCESS; -} -UNSIGNED32 ENTRYPOINT AdsShowError(UNSIGNED8* pucErrText) { - arc2_trace("AdsShowError"); - // ADS pops up a message box on a GUI host; OpenADS is headless, so the - // closest faithful behaviour is to write the caller's text to stderr. - if (pucErrText != nullptr && pucErrText[0] != '\0') - std::fprintf(stderr, "%s\n", - reinterpret_cast(pucErrText)); - return openads::AE_SUCCESS; -} -UNSIGNED32 ENTRYPOINT AdsStmtSetTableLockType(ADSHANDLE h, UNSIGNED16 us) { - arc2_trace("AdsStmtSetTableLockType"); - SqlStatement* st = stmt_lookup(h); - if (st == nullptr) return fail(openads::AE_INTERNAL_ERROR, "unknown stmt"); - st->lock_type = us; return ok(); -} -UNSIGNED32 ENTRYPOINT AdsStmtSetTablePassword(ADSHANDLE h, UNSIGNED8* pTable, UNSIGNED8* pPwd) { - arc2_trace("AdsStmtSetTablePassword"); - SqlStatement* st = stmt_lookup(h); - if (st == nullptr) return fail(openads::AE_INTERNAL_ERROR, "unknown stmt"); - st->passwords.emplace_back(openads::abi::to_internal(pTable, 0), - openads::abi::to_internal(pPwd, 0)); - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsStmtSetTableReadOnly(ADSHANDLE h, UNSIGNED16 us) { - arc2_trace("AdsStmtSetTableReadOnly"); - SqlStatement* st = stmt_lookup(h); - if (st == nullptr) return fail(openads::AE_INTERNAL_ERROR, "unknown stmt"); - st->read_only = us; return ok(); -} -UNSIGNED32 ENTRYPOINT AdsStmtSetTableType(ADSHANDLE h, UNSIGNED16 us) { - arc2_trace("AdsStmtSetTableType"); - SqlStatement* st = stmt_lookup(h); - if (st == nullptr) return fail(openads::AE_INTERNAL_ERROR, "unknown stmt"); - st->table_type = us; return ok(); -} -UNSIGNED32 ENTRYPOINT AdsTestLogin(UNSIGNED8*, UNSIGNED16, UNSIGNED8*, UNSIGNED8*, UNSIGNED32) - { - arc2_trace("AdsTestLogin"); ADS_STUB(openads::AE_SUCCESS); } -UNSIGNED32 ENTRYPOINT AdsTestRecLocks(ADSHANDLE hTable) { - arc2_trace("AdsTestRecLocks"); - // Diagnostic hook. Validate the handle and report success -- OpenADS - // has no separate lock-table consistency check to run. - if (get_remote_table(hTable) || get_table(hTable) != nullptr) return ok(); - return fail(openads::AE_INTERNAL_ERROR, "unknown table"); -} -UNSIGNED32 ENTRYPOINT AdsWriteAllRecords(void) { - arc2_trace("AdsWriteAllRecords"); return openads::AE_SUCCESS; } - -// This file is largely one big `extern "C"` block; the mgmt helpers -// below return std::string / Result<> / a small struct, so they need -// C++ linkage. -extern "C++" { -namespace { - -// A management handle resolves to one of these. Local collects an -// in-process snapshot; Remote ships a MgRequest to the server. -struct MgBackend { - bool remote = false; - std::string host; // remote only - std::uint16_t port = 0; // remote only - // DD user this mgmt handle is asking on behalf of, if AdsMgConnect's - // caller supplied one -- carried on every later MgConnect/MgRequest - // round-trip so the mgmt session registers under a real name instead - // of "(anonymous)". Empty is still valid (pre-existing behavior). - std::string mg_user; -}; - -// Optional [u16 ulen][user] MgConnect payload -- empty when `user` is empty, -// matching pre-existing "(anonymous)" behavior for callers that don't know -// or care which DD user is asking. -std::vector build_mg_connect_payload(const std::string& user) { - std::vector out; - if (user.empty()) return out; - auto ulen = static_cast(user.size()); - out.push_back(static_cast(ulen & 0xFFu)); - out.push_back(static_cast((ulen >> 8) & 0xFFu)); - out.insert(out.end(), user.begin(), user.end()); - return out; -} - -// Registry of open mgmt handles. ADSHANDLE values for mgmt start at a -// high base so they never collide with table / connection handles. -std::mutex g_mg_mu; -std::unordered_map g_mg_handles; -ADSHANDLE g_mg_next = 0x4D670001; // 'Mg' - -// Builds a MgSnapshot for whichever backend the handle names. -openads::util::Result -fetch_mg_snapshot(const MgBackend& be) { - using openads::util::Error; - if (!be.remote) { - // Local mode: report this process by enumerating the ABI - // handle registry -- shared with the sp_mg* SQL procedures - // (see collect_local_abi_snapshot near dispatch_sp_builtin_cursor). - return collect_local_abi_snapshot(); - } - // Remote mode: open a socket, ship one MgRequest, read the reply. - openads::network::network_init(); - auto sock = openads::network::connect_tcp(be.host, be.port); - if (!sock.has_value()) - return Error{1, 0, "mg connect failed", ""}; - openads::network::Socket s = sock.value(); - - // MgConnect handshake first, on this same socket, so the session this - // MgRequest travels on registers under be.mg_user instead of - // "(anonymous)" -- AdsMgConnect's own reachability probe (above) uses a - // separate, immediately-closed socket, so its username never reaches - // whichever connection actually ends up carrying the MgRequest. - if (!be.mg_user.empty()) { - openads::network::Frame hello; - hello.opcode = openads::network::Opcode::MgConnect; - hello.payload = build_mg_connect_payload(be.mg_user); - if (auto wr = openads::network::write_frame(s, hello); wr.has_value()) { - (void)openads::network::read_frame(s); // drain MgConnectAck - } - } - - openads::network::Frame req; - req.opcode = openads::network::Opcode::MgRequest; - std::string body = openads::network::encode_mg_request( - openads::network::MgRequestKind::Snapshot, 0); - req.payload.assign(body.begin(), body.end()); - - auto wr = openads::network::write_frame(s, req); - if (!wr.has_value()) { - openads::network::sock_close(s); - return Error{1, 0, "mg request send failed", ""}; - } - auto reply = openads::network::read_frame(s); - openads::network::sock_close(s); - if (!reply.has_value()) - return Error{1, 0, "mg reply read failed", ""}; - if (reply.value().opcode != openads::network::Opcode::MgReplyAck) - return Error{1, 0, "mg request rejected", ""}; - std::string payload(reply.value().payload.begin(), - reply.value().payload.end()); - return openads::network::decode_mg_snapshot(payload); -} - -// Ask a remote server what build it is via the Hello opcode (supported by -// every wire protocol version). Returns e.g. "openads/1.8.14"; a pre-1.8.14 -// server answers its hardcoded "openads/0.3.2", which is itself the -// diagnosis -- an old serverd is running. Empty string when unreachable. -std::string fetch_server_hello(const MgBackend& be) { - openads::network::network_init(); - auto sock = openads::network::connect_tcp(be.host, be.port); - if (!sock.has_value()) return ""; - openads::network::Socket s = sock.value(); - openads::network::Frame req; - req.opcode = openads::network::Opcode::Hello; - std::string out; - if (auto wr = openads::network::write_frame(s, req); wr.has_value()) { - auto reply = openads::network::read_frame(s); - if (reply.has_value() && - reply.value().opcode == openads::network::Opcode::HelloAck) { - out.assign(reply.value().payload.begin(), - reply.value().payload.end()); - } - } - openads::network::sock_close(s); - return out; -} - -} // namespace -} // extern "C++" - -// Mgmt accessor helpers. These return C++ types (Result<>, MgCollector, -// templates), so they live in their own C++-linkage block. -extern "C++" { -namespace { - -// Resolve a mgmt handle to its backend; nullptr if unknown. -const MgBackend* lookup_mg(ADSHANDLE h) { - std::lock_guard g(g_mg_mu); - auto it = g_mg_handles.find(h); - return it == g_mg_handles.end() ? nullptr : &it->second; -} - -// Build a MgCollector for a handle, or return an error. -openads::util::Result -mg_collector_for(ADSHANDLE h) { - const MgBackend* be = lookup_mg(h); - if (be == nullptr) - return openads::util::Error{ - static_cast( - openads::AE_INVALID_CONNECTION_HANDLE), - 0, "invalid mgmt handle", ""}; - auto snap = fetch_mg_snapshot(*be); - if (!snap.has_value()) - return openads::util::Error{ - static_cast(openads::AE_NO_CONNECTION), - 0, "mg telemetry fetch failed", ""}; - return openads::mgmt::MgCollector(snap.value()); -} - -// Copy a POD struct into the caller's buffer, clamped to *pusLen, and -// write back the real struct size. -// Raw struct memcpy is safe across THIS boundary -- unlike the wire, -// where a 32-bit client and 64-bit server may disagree on layout. -// The caller (rddads / Harbour) and this DLL both consume the same -// include/openads/ace.h ADS_MGMT_* definitions, so the layouts are -// identical by construction. mg_wire handles the cross-ABI case. -template -UNSIGNED32 emit_mg_struct(const T& src, void* pBuf, UNSIGNED16* pusLen) { - if (pusLen == nullptr) return openads::AE_INTERNAL_ERROR; - UNSIGNED16 cap = *pusLen; - UNSIGNED16 n = static_cast( - sizeof(T) < cap ? sizeof(T) : cap); - if (pBuf != nullptr && n > 0) std::memcpy(pBuf, &src, n); - *pusLen = static_cast(sizeof(T)); - return openads::AE_SUCCESS; -} - -// Copy a vector of POD structs into the caller's array buffer. -// *pusCount in: capacity (#elements); out: actual element count. -template -UNSIGNED32 emit_mg_array(const std::vector& src, void* pBuf, - UNSIGNED16* pusCount, UNSIGNED16* pusSize) { - if (pusCount == nullptr) return openads::AE_INTERNAL_ERROR; - UNSIGNED16 cap = *pusCount; - UNSIGNED16 n = static_cast( - src.size() < cap ? src.size() : cap); - if (pBuf != nullptr && n > 0) - std::memcpy(pBuf, src.data(), - static_cast(n) * sizeof(T)); - *pusCount = static_cast(src.size()); - if (pusSize != nullptr) *pusSize = static_cast(sizeof(T)); - return openads::AE_SUCCESS; -} - -// Ship a fire-and-forget MgRequest mutator to a remote server. -bool send_mg_mutator(const MgBackend& be, - openads::network::MgRequestKind kind, - std::uint16_t arg) { - openads::network::network_init(); - auto sock = openads::network::connect_tcp(be.host, be.port); - if (!sock.has_value()) return false; - openads::network::Socket s = sock.value(); - openads::network::Frame req; - req.opcode = openads::network::Opcode::MgRequest; - std::string body = openads::network::encode_mg_request(kind, arg); - req.payload.assign(body.begin(), body.end()); - bool ok = openads::network::write_frame(s, req).has_value(); - if (ok) (void)openads::network::read_frame(s); // drain the ack - openads::network::sock_close(s); - return ok; -} - -} // namespace -} // extern "C++" - -// AdsMg* stubs are implemented elsewhere. Kept tests/unit/abi_mgmt_test.cpp's -// existing pattern: zero-fill caller's buffer, return AE_SUCCESS so apps -// proceed without special-casing local-mode mgmt absence. - -// AdsMgConnect -- pucServer selects local vs. remote. An empty or -// "local" server string yields a local-process backend; anything of -// the form "host" or "host:port" yields a remote backend (default -// port 16262, the OpenADS server port). -UNSIGNED32 ENTRYPOINT AdsMgConnect(UNSIGNED8* pucServer, UNSIGNED8* pucUser, - UNSIGNED8* /*pucPwd*/, ADSHANDLE* phMgmt) { - arc2_trace("AdsMgConnect"); - if (phMgmt == nullptr) return openads::AE_INTERNAL_ERROR; - - MgBackend be; - be.mg_user = pucUser ? reinterpret_cast(pucUser) : std::string(); - std::string srv = pucServer - ? reinterpret_cast(pucServer) : ""; - // Strip leading / trailing UNC slashes ("\\\\host\\"). - while (!srv.empty() && (srv.front() == '\\' || srv.front() == '/')) - srv.erase(srv.begin()); - while (!srv.empty() && (srv.back() == '\\' || srv.back() == '/')) - srv.pop_back(); - - // Decide local vs. remote. A string is a REMOTE target only when - // it is "host:port" with a non-empty, all-digit port. Everything - // else -- empty, "local", a drive path like "C:" or "C:\data", - // a bare name -- is a local-mode backend. (rddads' manage.prg - // passes "C:" for local management; that must not be mistaken - // for a host named "C".) - be.remote = false; - auto colon = srv.rfind(':'); - if (colon != std::string::npos && colon > 0 && - colon + 1 < srv.size()) { - std::string portstr = srv.substr(colon + 1); - bool all_digits = !portstr.empty(); - for (char ch : portstr) - if (ch < '0' || ch > '9') { all_digits = false; break; } - if (all_digits) { - be.remote = true; - be.host = srv.substr(0, colon); - be.port = static_cast( - std::strtoul(portstr.c_str(), nullptr, 10)); - } - } - - if (be.remote) { - // Validate the server is reachable up front with a MgConnect - // handshake, so a down server fails here (AE_NO_CONNECTION) - // rather than later with a misleading handle error. - openads::network::network_init(); - auto probe = openads::network::connect_tcp(be.host, be.port); - if (!probe.has_value()) return openads::AE_NO_CONNECTION; - openads::network::Socket ps = probe.value(); - openads::network::Frame hello; - hello.opcode = openads::network::Opcode::MgConnect; - hello.payload = build_mg_connect_payload(be.mg_user); - if (!openads::network::write_frame(ps, hello).has_value()) { - openads::network::sock_close(ps); - return openads::AE_NO_CONNECTION; - } - auto hack = openads::network::read_frame(ps); - openads::network::sock_close(ps); - if (!hack.has_value() || - hack.value().opcode != openads::network::Opcode::MgConnectAck) - return openads::AE_NO_CONNECTION; - } - - std::lock_guard g(g_mg_mu); - ADSHANDLE h = g_mg_next++; - g_mg_handles.emplace(h, std::move(be)); - *phMgmt = h; - return openads::AE_SUCCESS; -} - -UNSIGNED32 ENTRYPOINT AdsMgDisconnect(ADSHANDLE hMgmt) { - arc2_trace("AdsMgDisconnect"); - std::lock_guard g(g_mg_mu); - g_mg_handles.erase(hMgmt); - return openads::AE_SUCCESS; -} -UNSIGNED32 ENTRYPOINT AdsMgGetActivityInfo(ADSHANDLE h, void* p, UNSIGNED16* l) { - arc2_trace("AdsMgGetActivityInfo"); - auto c = mg_collector_for(h); - if (!c.has_value()) return static_cast(c.error().code); - return emit_mg_struct(c.value().activity_info(), p, l); -} -UNSIGNED32 ENTRYPOINT AdsMgGetCommStats(ADSHANDLE h, void* p, UNSIGNED16* l) { - arc2_trace("AdsMgGetCommStats"); - auto c = mg_collector_for(h); - if (!c.has_value()) return static_cast(c.error().code); - return emit_mg_struct(c.value().comm_stats(), p, l); -} -UNSIGNED32 ENTRYPOINT AdsMgGetConfigInfo(ADSHANDLE h, void* pv, UNSIGNED16* lv, - void* pm, UNSIGNED16* lm) { - arc2_trace("AdsMgGetConfigInfo"); - auto c = mg_collector_for(h); - if (!c.has_value()) return static_cast(c.error().code); - UNSIGNED32 rc = emit_mg_struct(c.value().config_params(), pv, lv); - if (rc != openads::AE_SUCCESS) return rc; - return emit_mg_struct(c.value().config_memory(), pm, lm); -} -UNSIGNED32 ENTRYPOINT AdsMgGetInstallInfo(ADSHANDLE h, void* p, UNSIGNED16* l) { - arc2_trace("AdsMgGetInstallInfo"); - auto c = mg_collector_for(h); - if (!c.has_value()) return static_cast(c.error().code); - ADS_MGMT_INSTALL_INFO info = c.value().install_info(); - // For a remote mgmt handle the version that matters is the SERVER - // binary's, not this DLL's -- MgCollector only knows the local build. - // One Hello round-trip answers it for any server version ever shipped. - if (const MgBackend* be = lookup_mg(h); be != nullptr && be->remote) { - std::string hello = fetch_server_hello(*be); // "openads/X.Y.Z" - if (!hello.empty()) { - const std::string prefix = "openads/"; - std::string ver = hello.rfind(prefix, 0) == 0 - ? hello.substr(prefix.size()) : hello; - std::string vs = "OpenADS " + ver; - // aucVersionStr is 16 bytes. A release version ("OpenADS - // 1.8.14") fits; a dev build ("1.8.14-3-gabc1234-dirty") does - // not -- prefer the version detail over the brand then. - if (vs.size() >= sizeof(info.aucVersionStr)) vs = ver; - std::memset(info.aucVersionStr, 0, sizeof(info.aucVersionStr)); - std::memcpy(info.aucVersionStr, vs.c_str(), - std::min(vs.size(), sizeof(info.aucVersionStr) - 1)); - } - } - return emit_mg_struct(info, p, l); -} -UNSIGNED32 ENTRYPOINT AdsMgGetLockOwner(ADSHANDLE h, UNSIGNED8* pucTable, - UNSIGNED32 ulRec, - void* p, UNSIGNED16* l, UNSIGNED16* lt) { - arc2_trace("AdsMgGetLockOwner"); - auto col = mg_collector_for(h); - if (!col.has_value()) return static_cast(col.error().code); - if (lt) *lt = ADS_MGMT_RECORD_LOCK; - std::string tbl = pucTable ? openads::abi::to_internal(pucTable, 0) : ""; - return emit_mg_struct(col.value().lock_owner(ulRec, tbl), p, l); -} -UNSIGNED32 ENTRYPOINT AdsMgGetLocks(ADSHANDLE h, UNSIGNED8* pucTable, - UNSIGNED8* pucUser, - UNSIGNED16 /*usConnNumber*/, void* p, UNSIGNED16* c, - UNSIGNED16* sz) { - arc2_trace("AdsMgGetLocks"); - auto col = mg_collector_for(h); - if (!col.has_value()) return static_cast(col.error().code); - std::string tbl = pucTable ? openads::abi::to_internal(pucTable, 0) : ""; - std::string usr = pucUser ? openads::abi::to_internal(pucUser, 0) : ""; - return emit_mg_array(col.value().locks(tbl, usr), p, c, sz); -} -UNSIGNED32 ENTRYPOINT AdsMgGetOpenIndexes(ADSHANDLE h, UNSIGNED8* pucTable, - UNSIGNED8* /*pucUser*/, - UNSIGNED16 /*usConnNumber*/, void* p, - UNSIGNED16* c, UNSIGNED16* sz) { - arc2_trace("AdsMgGetOpenIndexes"); - auto col = mg_collector_for(h); - if (!col.has_value()) return static_cast(col.error().code); - auto all = col.value().open_indexes(); - if (pucTable != nullptr && pucTable[0] != 0) { - // Filter by owning table. ADS_MGMT_INDEX_INFO has no table field, - // so match against the raw snapshot entries (same order as `all`). - std::string tbl = openads::abi::to_internal(pucTable, 0); - const auto& raw = col.value().snapshot().index_list; - std::vector filtered; - for (std::size_t i = 0; i < all.size() && i < raw.size(); ++i) { - if (openads::mgmt::MgCollector::table_name_matches( - raw[i].table, tbl)) - filtered.push_back(all[i]); - } - return emit_mg_array(filtered, p, c, sz); - } - return emit_mg_array(all, p, c, sz); -} -UNSIGNED32 ENTRYPOINT AdsMgGetOpenTables(ADSHANDLE h, UNSIGNED8* /*f*/, UNSIGNED16 /*o*/, - void* p, UNSIGNED16* c, UNSIGNED16* sz) { - arc2_trace("AdsMgGetOpenTables"); - auto col = mg_collector_for(h); - if (!col.has_value()) return static_cast(col.error().code); - return emit_mg_array(col.value().open_tables(), p, c, sz); -} -UNSIGNED32 ENTRYPOINT AdsMgGetOpenTables2(ADSHANDLE h, UNSIGNED8* /*f*/, UNSIGNED16 /*o*/, - void* p, UNSIGNED16* c, UNSIGNED16* sz) { - arc2_trace("AdsMgGetOpenTables2"); - auto col = mg_collector_for(h); - if (!col.has_value()) return static_cast(col.error().code); - return emit_mg_array(col.value().open_tables(), p, c, sz); -} -UNSIGNED32 ENTRYPOINT AdsMgGetServerType(ADSHANDLE h, UNSIGNED16* p) { - arc2_trace("AdsMgGetServerType"); - auto c = mg_collector_for(h); - if (!c.has_value()) return static_cast(c.error().code); - if (p) *p = c.value().server_type(); - return openads::AE_SUCCESS; -} -UNSIGNED32 ENTRYPOINT AdsMgGetUserNames(ADSHANDLE h, UNSIGNED8* /*pucFile*/, void* p, - UNSIGNED16* c, UNSIGNED16* sz) { - arc2_trace("AdsMgGetUserNames"); - auto col = mg_collector_for(h); - if (!col.has_value()) return static_cast(col.error().code); - return emit_mg_array(col.value().user_names(), p, c, sz); -} -UNSIGNED32 ENTRYPOINT AdsMgGetWorkerThreadActivity(ADSHANDLE h, void* p, UNSIGNED16* c, - UNSIGNED16* sz) { - arc2_trace("AdsMgGetWorkerThreadActivity"); - auto col = mg_collector_for(h); - if (!col.has_value()) return static_cast(col.error().code); - return emit_mg_array(col.value().worker_thread_activity(), p, c, sz); -} -// Non-SAP extension: one average per-frame cost (microseconds) per user, -// in the same order/count AdsMgGetUserNames just returned -- pair them up -// by index. Not part of the SAP-compatible surface; ADS_MGMT_USER_INFO's -// fixed layout has no room for this. -UNSIGNED32 ENTRYPOINT AdsMgGetUserAvgCost(ADSHANDLE h, UNSIGNED32* p, - UNSIGNED16* c, UNSIGNED16* sz) { - arc2_trace("AdsMgGetUserAvgCost"); - auto col = mg_collector_for(h); - if (!col.has_value()) return static_cast(col.error().code); - return emit_mg_array(col.value().user_avg_costs(), p, c, sz); -} -// Non-SAP extension: on-demand detail for one Active Queries row (see -// mgmt::MgCollector::thread_sql) -- the SQL text of the last ExecuteSQL -// frame `ulThreadNumber` processed, plus when it started, epoch seconds -// (0 if unknown/never ran SQL). *pulLen is buffer capacity in, actual -// text length out (truncated to capacity if longer, same convention as -// AdsDDGetDatabaseProperty) -- call once with a 0-capacity probe to size -// the buffer, same as everywhere else in this ABI. -UNSIGNED32 ENTRYPOINT AdsMgGetThreadSql(ADSHANDLE h, UNSIGNED32 ulThreadNumber, - UNSIGNED8* pucBuf, UNSIGNED32* pulLen, - UNSIGNED64* pullStartEpoch) { - arc2_trace("AdsMgGetThreadSql"); - if (pulLen == nullptr) return openads::AE_INTERNAL_ERROR; - auto col = mg_collector_for(h); - if (!col.has_value()) return static_cast(col.error().code); - std::string sql = col.value().thread_sql(ulThreadNumber); - UNSIGNED32 cap = *pulLen; - UNSIGNED32 n = static_cast( - sql.size() < cap ? sql.size() : cap); - if (pucBuf != nullptr && n > 0) std::memcpy(pucBuf, sql.data(), n); - *pulLen = static_cast(sql.size()); - if (pullStartEpoch != nullptr) { - auto at = col.value().thread_sql_at(ulThreadNumber); - *pullStartEpoch = (at.time_since_epoch().count() == 0) ? 0 - : static_cast(std::chrono::system_clock::to_time_t(at)); - } - return openads::AE_SUCCESS; -} -UNSIGNED32 ENTRYPOINT AdsMgKillUser(ADSHANDLE h, UNSIGNED8* /*pucUser*/, - UNSIGNED16 usConnNo) { - arc2_trace("AdsMgKillUser"); - const MgBackend* be = lookup_mg(h); - if (be == nullptr) return openads::AE_INVALID_CONNECTION_HANDLE; - if (!be->remote) return openads::AE_SUCCESS; // no-op local - return send_mg_mutator(*be, - openads::network::MgRequestKind::KillUser, usConnNo) - ? openads::AE_SUCCESS : openads::AE_NO_CONNECTION; -} -UNSIGNED32 ENTRYPOINT AdsMgKillUser90(ADSHANDLE h, UNSIGNED8* pucUser, - UNSIGNED16 usConnNo, UNSIGNED32 /*ulOptions*/) { - arc2_trace("AdsMgKillUser90"); - return AdsMgKillUser(h, pucUser, usConnNo); -} -UNSIGNED32 ENTRYPOINT AdsMgResetCommStats(ADSHANDLE h) { - arc2_trace("AdsMgResetCommStats"); - const MgBackend* be = lookup_mg(h); - if (be == nullptr) return openads::AE_INVALID_CONNECTION_HANDLE; - if (!be->remote) { - openads::mgmt::process_mg_stats().reset_comm(); - return openads::AE_SUCCESS; - } - return send_mg_mutator(*be, - openads::network::MgRequestKind::ResetCommStats, 0) - ? openads::AE_SUCCESS : openads::AE_NO_CONNECTION; -} - -// All AdsDD* helpers (AddIndexFile, AddUserToGroup, CreateLink, -// CreateRefIntegrity, CreateUser, DeleteUser, DropLink, -// Get/SetDatabaseProperty, GetUserProperty, ModifyLink, -// RemoveIndexFile, RemoveRefIntegrity, RemoveUserFromGroup) are -// already defined earlier in this file. - -// --------------------------------------------------------------------------- -// M12.22 -- versioned ACE overloads the X# RDD (xsharp.eu) binds by name. -// Most are thin forwards to the base signature already implemented above, -// dropping the parameters newer ACE builds added (charset/collation tags, -// page sizes, RI error strings). The handful with no OpenADS base get a -// minimal implementation. Parameter lists mirror XSharp.Rdd/ACE/ACE.prg. -// --------------------------------------------------------------------------- - -UNSIGNED32 ENTRYPOINT AdsConnect26(UNSIGNED8* pucServer, UNSIGNED16 usServerType, - ADSHANDLE* phConnect) { - arc2_trace("AdsConnect26"); - return AdsConnect60(pucServer, usServerType, nullptr, nullptr, 0, - phConnect); -} - -UNSIGNED32 ENTRYPOINT AdsCreateTable71(ADSHANDLE hConnect, UNSIGNED8* pucName, - UNSIGNED8* pucAlias, UNSIGNED16 usTableType, - UNSIGNED16 usCharType, UNSIGNED16 usLockType, - UNSIGNED16 usCheckRights, UNSIGNED16 usMemoSize, - UNSIGNED8* pucFields, UNSIGNED32 /*ulOptions*/, - ADSHANDLE* phTable) { - arc2_trace("AdsCreateTable71"); - return AdsCreateTable(hConnect, pucName, pucAlias, usTableType, usCharType, - usLockType, usCheckRights, usMemoSize, pucFields, - phTable); -} - -UNSIGNED32 ENTRYPOINT AdsCreateTable90(ADSHANDLE hConnect, UNSIGNED8* pucName, - UNSIGNED8* pucAlias, UNSIGNED16 usTableType, - UNSIGNED16 usCharType, UNSIGNED16 usLockType, - UNSIGNED16 usCheckRights, UNSIGNED16 usMemoSize, - UNSIGNED8* pucFields, UNSIGNED32 /*ulOptions*/, - UNSIGNED8* /*pucCollation*/, ADSHANDLE* phTable) { - arc2_trace("AdsCreateTable90"); - return AdsCreateTable(hConnect, pucName, pucAlias, usTableType, usCharType, - usLockType, usCheckRights, usMemoSize, pucFields, - phTable); -} - -UNSIGNED32 ENTRYPOINT AdsOpenTable90(ADSHANDLE hConnect, UNSIGNED8* pucName, - UNSIGNED8* pucAlias, UNSIGNED16 usTableType, - UNSIGNED16 usCharType, UNSIGNED16 usLockType, - UNSIGNED16 usCheckRights, UNSIGNED32 ulOptions, - UNSIGNED8* /*pucCollation*/, ADSHANDLE* phTable) { - arc2_trace("AdsOpenTable90"); - return AdsOpenTable(hConnect, pucName, pucAlias, usTableType, usCharType, - usLockType, usCheckRights, - static_cast(ulOptions), phTable); -} - -UNSIGNED32 ENTRYPOINT AdsCreateIndex90(ADSHANDLE hObj, UNSIGNED8* pucFileName, - UNSIGNED8* pucTag, UNSIGNED8* pucExpr, - UNSIGNED8* pucCondition, UNSIGNED8* pucWhile, - UNSIGNED32 ulOptions, UNSIGNED32 ulPageSize, - UNSIGNED8* /*pucCollation*/, ADSHANDLE* phIndex) { - arc2_trace("AdsCreateIndex90"); - return AdsCreateIndex61(hObj, pucFileName, pucTag, pucExpr, pucCondition, - pucWhile, ulOptions, - static_cast(ulPageSize), phIndex); -} - -UNSIGNED32 ENTRYPOINT AdsDDAddTable90(ADSHANDLE hConnect, UNSIGNED8* pucAlias, - UNSIGNED8* pucTablePath, UNSIGNED16 usTableType, - UNSIGNED16 usCharType, UNSIGNED8* pucIndexPath, - UNSIGNED8* pucComment, UNSIGNED8* /*pucCollation*/) { - arc2_trace("AdsDDAddTable90"); - return AdsDDAddTable(hConnect, pucAlias, pucTablePath, usTableType, - usCharType, pucIndexPath, pucComment); -} - -UNSIGNED32 ENTRYPOINT AdsDDCreateRefIntegrity62(ADSHANDLE hConnect, UNSIGNED8* pucName, - UNSIGNED8* pucFail, UNSIGNED8* pucParent, - UNSIGNED8* pucParentTag, UNSIGNED8* pucChild, - UNSIGNED8* pucChildTag, UNSIGNED16 usUpdate, - UNSIGNED16 usDelete, - UNSIGNED8* /*pucNoPrimaryError*/, - UNSIGNED8* /*pucCascadeError*/) { - arc2_trace("AdsDDCreateRefIntegrity62"); - return AdsDDCreateRefIntegrity(hConnect, pucName, pucFail, pucParent, - pucParentTag, pucChild, pucChildTag, - usUpdate, usDelete); -} - -UNSIGNED32 ENTRYPOINT AdsFindFirstTable62(ADSHANDLE hConnect, UNSIGNED8* pucFileMask, - UNSIGNED8* pucFirstDD, UNSIGNED16* pusDDLen, - UNSIGNED8* pucFirstFile, UNSIGNED16* pusFileLen, - ADSHANDLE* phFind) { - arc2_trace("AdsFindFirstTable62"); - // OpenADS doesn't track a data-dictionary name alongside the file - // name, so report an empty DD name and forward to the base API. - if (pusDDLen) { - if (pucFirstDD && *pusDDLen > 0) pucFirstDD[0] = 0; - *pusDDLen = 0; - } - return AdsFindFirstTable(hConnect, pucFileMask, pucFirstFile, pusFileLen, - phFind); -} - -UNSIGNED32 ENTRYPOINT AdsFindNextTable62(ADSHANDLE hConnect, ADSHANDLE hFind, - UNSIGNED8* pucDDName, UNSIGNED16* pusDDLen, - UNSIGNED8* pucFileName, UNSIGNED16* pusFileLen) { - arc2_trace("AdsFindNextTable62"); - if (pusDDLen) { - if (pucDDName && *pusDDLen > 0) pucDDName[0] = 0; - *pusDDLen = 0; - } - return AdsFindNextTable(hConnect, hFind, pucFileName, pusFileLen); -} - -UNSIGNED32 ENTRYPOINT AdsGetDateFormat60(ADSHANDLE /*hConnect*/, UNSIGNED8* pucBuf, - UNSIGNED16* pusLen) { - arc2_trace("AdsGetDateFormat60"); - return AdsGetDateFormat(pucBuf, pusLen); -} - -UNSIGNED32 ENTRYPOINT AdsGetExact22(ADSHANDLE /*hObj*/, UNSIGNED16* pbExact) { - arc2_trace("AdsGetExact22"); - return AdsGetExact(pbExact); -} - -UNSIGNED32 ENTRYPOINT AdsReindex61(ADSHANDLE hObject, UNSIGNED32 /*ulPageSize*/) { - arc2_trace("AdsReindex61"); - return AdsReindex(hObject); -} - -UNSIGNED32 ENTRYPOINT AdsRestructureTable90(ADSHANDLE hConnect, UNSIGNED8* pucTableName, - UNSIGNED8* /*pucPassword*/, - UNSIGNED16 usTableType, UNSIGNED16 usCharType, - UNSIGNED16 usLockType, UNSIGNED16 usCheckRights, - UNSIGNED8* pucAddFields, - UNSIGNED8* pucDeleteFields, - UNSIGNED8* pucChangeFields, - UNSIGNED8* /*pucCollation*/) { - arc2_trace("AdsRestructureTable90"); - return AdsRestructureTable(hConnect, pucTableName, nullptr, usTableType, - usCharType, usLockType, usCheckRights, - pucAddFields, pucDeleteFields, pucChangeFields); -} - -UNSIGNED32 ENTRYPOINT AdsRestructureTable120(ADSHANDLE hConnect, - UNSIGNED8* pucTableName, - UNSIGNED8* /*pucPassword*/, - UNSIGNED16 usTableType, - UNSIGNED16 usCharType, - UNSIGNED16 usLockType, - UNSIGNED16 usCheckRights, - UNSIGNED8* pucAddFields, - UNSIGNED8* pucDeleteFields, - UNSIGNED8* pucChangeFields, - UNSIGNED8* /*pucCollation*/, - UNSIGNED32 /*ulMemoBlockSize*/, - UNSIGNED32 ulOptions) { - arc2_trace("AdsRestructureTable120"); - if (ulOptions != 0) { - return fail(openads::AE_INTERNAL_ERROR, "reserved options must be zero"); - } - return AdsRestructureTable(hConnect, pucTableName, nullptr, usTableType, - usCharType, usLockType, usCheckRights, - pucAddFields, pucDeleteFields, pucChangeFields); -} - -// --- no OpenADS base function: minimal implementations --- - -// X# calls these on row-edit cancel / connection-property tuning. -// OpenADS has no deferred-write row buffer and treats ACE properties -// as no-ops, so acknowledge success rather than break the caller flow. -UNSIGNED32 ENTRYPOINT AdsCancelUpdate90(ADSHANDLE /*hTable*/, UNSIGNED32 /*ulOptions*/) { - arc2_trace("AdsCancelUpdate90"); - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsSetProperty90(ADSHANDLE /*hObj*/, UNSIGNED32 /*ulOperation*/, - UNSIGNED64* /*puqValue*/) { - arc2_trace("AdsSetProperty90"); - return ok(); -} -// Pre-90 (32-bit value) sibling of AdsSetProperty90; same no-op treatment. -UNSIGNED32 ENTRYPOINT AdsSetProperty(ADSHANDLE /*hObj*/, UNSIGNED32 /*ulOperation*/, - UNSIGNED32* /*pulValue*/) { - arc2_trace("AdsSetProperty"); - arc2_trace("AdsSetProperty"); - return ok(); -} - -// AdsSetRightsChecking -- global, process-wide legacy rights-checking -// mode. Real rights enforcement in OpenADS happens server-side in the -// DD engine regardless of this client-side flag (see project notes on -// DD engine enforcement), so this only records the caller's requested -// mode for round-tripping; it does not change enforcement behavior. -namespace { -std::atomic g_rights_checking{ADS_IGNORE_RIGHTS_CHECKING}; -} -UNSIGNED32 ENTRYPOINT AdsSetRightsChecking(UNSIGNED32 ulOptions) { - arc2_trace("AdsSetRightsChecking"); - if (ulOptions != ADS_RESPECT_RIGHTS_CHECKING && - ulOptions != ADS_IGNORE_RIGHTS_CHECKING) { - return fail(openads::AE_INTERNAL_ERROR, - "AdsSetRightsChecking: invalid option"); - } - g_rights_checking.store(ulOptions); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsSetTableTransactionFree(ADSHANDLE hTable, - UNSIGNED16 usTransFree) { - arc2_trace("AdsSetTableTransactionFree"); - Table* t = get_table(hTable); - if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - t->set_transaction_free(usTransFree != 0); - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsIsTableTransactionFree(ADSHANDLE hTable, - UNSIGNED16* pusTransFree) { - arc2_trace("AdsIsTableTransactionFree"); - if (pusTransFree == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - Table* t = get_table(hTable); - if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *pusTransFree = t->transaction_free() ? 1 : 0; - return ok(); -} - -// OpenADS keys connections/tables by handle, not by path/name, so -// report "not found" -- X# then opens a fresh connection/table. -UNSIGNED32 ENTRYPOINT AdsFindConnection25(UNSIGNED8* /*pucFullPath*/, - ADSHANDLE* phConnect) { - arc2_trace("AdsFindConnection25"); - if (phConnect) *phConnect = 0; - return fail(openads::AE_NO_CONNECTION, "no connection for path"); -} -UNSIGNED32 ENTRYPOINT AdsGetTableHandle(ADSHANDLE hConnect, UNSIGNED8* pucName, - ADSHANDLE* phTable) { - arc2_trace("AdsGetTableHandle"); - return AdsGetTableHandle25(hConnect, pucName, phTable); -} -UNSIGNED32 ENTRYPOINT AdsGetTableHandle25(ADSHANDLE /*hConnect*/, UNSIGNED8* /*pucName*/, - ADSHANDLE* phTable) { - arc2_trace("AdsGetTableHandle25"); - if (phTable) *phTable = 0; - return fail(openads::AE_TABLE_NOT_FOUND, "no open table for name"); -} - -// The SAP "60" bookmark API hands back an opaque blob the app later -// replays. OpenADS encodes it as the 4-byte little-endian recno -- -// stable for the table's lifetime, enough for navigate-and-return. -UNSIGNED32 ENTRYPOINT AdsGetBookmark60(ADSHANDLE hObj, UNSIGNED8* pucBookmark, - UNSIGNED32* pulLength) { - arc2_trace("AdsGetBookmark60"); - if (pulLength == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (pucBookmark == nullptr || *pulLength < 4) { - *pulLength = 4; - return fail(openads::AE_INTERNAL_ERROR, "bookmark buffer too small"); - } - UNSIGNED32 recno = 0; - UNSIGNED32 rc = AdsGetRecordNum(hObj, 0, &recno); - if (rc != openads::AE_SUCCESS) return rc; - pucBookmark[0] = static_cast(recno & 0xFF); - pucBookmark[1] = static_cast((recno >> 8) & 0xFF); - pucBookmark[2] = static_cast((recno >> 16) & 0xFF); - pucBookmark[3] = static_cast((recno >> 24) & 0xFF); - *pulLength = 4; - return ok(); -} -// SAP / rddads signature: 3 args. AdsGetBookmark60 returns -// (pucBookmark + *pulLength); the caller hands that exact length -// back into AdsGotoBookmark60 to replay the bookmark -- needed -// because real ACE supports variable-length bookmarks (the size -// depends on the index/order). OpenADS encodes everything as a -// 4-byte recno today, so any ulLength < 4 is a malformed call. -UNSIGNED32 ENTRYPOINT AdsGotoBookmark60(ADSHANDLE hObj, UNSIGNED8* pucBookmark, - UNSIGNED32 ulLength) { - arc2_trace("AdsGotoBookmark60"); - if (pucBookmark == nullptr || ulLength < 4) { - return fail(openads::AE_INTERNAL_ERROR, ""); - } - UNSIGNED32 recno = static_cast(pucBookmark[0]) - | (static_cast(pucBookmark[1]) << 8) - | (static_cast(pucBookmark[2]) << 16) - | (static_cast(pucBookmark[3]) << 24); - return AdsGotoRecord(hObj, recno); -} -UNSIGNED32 ENTRYPOINT AdsGotoBOF(ADSHANDLE hTable) { - arc2_trace("AdsGotoBOF"); - UNSIGNED32 rc = AdsGotoTop(hTable); - if (rc != openads::AE_SUCCESS) return rc; - return AdsSkip(hTable, -1); -} -UNSIGNED32 ENTRYPOINT AdsGotoEOF(ADSHANDLE hTable) { - arc2_trace("AdsGotoEOF"); - UNSIGNED32 rc = AdsGotoBottom(hTable); - if (rc != openads::AE_SUCCESS) return rc; - return AdsSkip(hTable, 1); -} - -// X#'s ADSRDD calls this during table OPEN to size its memo buffers. -// Report the attached memo store's block size; for a table with no -// memo (or a remote handle -- no memo introspection over the wire yet) -// hand back the xBase FPT default so the RDD has a usable value. -UNSIGNED32 ENTRYPOINT AdsGetMemoBlockSize(ADSHANDLE hObj, UNSIGNED16* pusBlockSize) { - arc2_trace("AdsGetMemoBlockSize"); - if (pusBlockSize == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *pusBlockSize = 64; - if (get_remote_table(hObj) != nullptr) return ok(); - Table* t = get_table(hObj); - if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); - if (auto* m = t->memo(); m != nullptr && m->block_size() != 0) { - *pusBlockSize = static_cast(m->block_size()); - } - return ok(); -} - -// --------------------------------------------------------------------------- -// M12.23 -- close the export gap the X# Advantage RDD (XSharp.Rdd) relies on. -// ADSRDD.prg references ~45 entry points OpenADS didn't yet export. Most are -// accept-and-ignore (session toggles, statement helpers) or thin forwards; -// the field-setter family uses the ACE "field NAME or 1-based ordinal cast to -// a pointer" idiom. Genuinely-unimplemented ones return -// AE_FUNCTION_NOT_AVAILABLE so the X# runtime falls back to its own -// client-side path. Signatures mirror XSharp.Rdd/ACE/ACE32.prg. -// --------------------------------------------------------------------------- - -// ACE field-identifier idiom: a small integer in the "field name" pointer -// slot is a 1-based field ordinal; a real pointer is the name string. -static const char* resolve_field_id(ADSHANDLE hTable, UNSIGNED8* pId, - UNSIGNED8* scratch, UNSIGNED16 scratchLen) { - if (reinterpret_cast(pId) >= 0x10000u) { - return reinterpret_cast(pId); - } - if (scratch == nullptr || scratchLen == 0) return ""; - scratch[0] = 0; - UNSIGNED16 ord = static_cast(reinterpret_cast(pId)); - UNSIGNED16 len = scratchLen; - if (AdsGetFieldName(hTable, ord, scratch, &len) != openads::AE_SUCCESS) { - return ""; - } - return reinterpret_cast(scratch); -} -static UNSIGNED8* as_field(const char* s) { - return const_cast(reinterpret_cast(s)); -} - -UNSIGNED32 ENTRYPOINT AdsGetTableOpenOptions(ADSHANDLE hTable, UNSIGNED32* pulOptions) { - arc2_trace("AdsGetTableOpenOptions"); - if (pulOptions == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *pulOptions = 0; - if (get_remote_table(hTable) != nullptr) { return ok(); } - Table* t = get_table(hTable); - if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); - // Map internal OpenMode to ACE open-option bits. - switch (t->open_mode()) { - case openads::engine::OpenMode::Read: *pulOptions = ADS_READONLY; break; - case openads::engine::OpenMode::Shared: *pulOptions = ADS_SHARED; break; - case openads::engine::OpenMode::Exclusive: *pulOptions = ADS_EXCLUSIVE; break; - } - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsGetBookmark(ADSHANDLE hTable, ADSHANDLE* phBookmark) { - arc2_trace("AdsGetBookmark"); - if (phBookmark == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - UNSIGNED32 rec = 0; - UNSIGNED32 rc = AdsGetRecordNum(hTable, 0, &rec); - if (rc != openads::AE_SUCCESS) return rc; - *phBookmark = rec; // recno-as-token; pairs with AdsGotoRecord - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsCancelUpdate(ADSHANDLE /*hTable*/) { - arc2_trace("AdsCancelUpdate"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsClearAllScopes(ADSHANDLE /*hTable*/) { - arc2_trace("AdsClearAllScopes"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsClearDefault(void) { - arc2_trace("AdsClearDefault"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsResetConnection(ADSHANDLE /*hConnect*/) { - arc2_trace("AdsResetConnection"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsThreadExit(void) { - arc2_trace("AdsThreadExit"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDisableLocalConnections(void) { - arc2_trace("AdsDisableLocalConnections"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsEnableRI(ADSHANDLE /*hConn*/) { - arc2_trace("AdsEnableRI"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDisableRI(ADSHANDLE /*hConn*/) { - arc2_trace("AdsDisableRI"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsEnableUniqueEnforcement(ADSHANDLE /*hConn*/) { - arc2_trace("AdsEnableUniqueEnforcement"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDisableUniqueEnforcement(ADSHANDLE /*hConn*/) { - arc2_trace("AdsDisableUniqueEnforcement"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsEnableAutoIncEnforcement(ADSHANDLE /*hConn*/) { - arc2_trace("AdsEnableAutoIncEnforcement"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDisableAutoIncEnforcement(ADSHANDLE /*hConn*/) { - arc2_trace("AdsDisableAutoIncEnforcement"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsRecallAllRecords(ADSHANDLE /*hTable*/) { - arc2_trace("AdsRecallAllRecords"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsIsRecordVisible(ADSHANDLE /*hObj*/, UNSIGNED16* pbVisible) { - arc2_trace("AdsIsRecordVisible"); - if (pbVisible) *pbVisible = 1; - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsGetKeyCount(ADSHANDLE hIndex, UNSIGNED16 /*usFilter*/, - UNSIGNED32* pulCount) { - arc2_trace("AdsGetKeyCount"); - if (pulCount == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *pulCount = 0; - // M12.28 - route remote index handles through the wire. - if (auto* ri = get_remote_index(hIndex)) { - if (ri->parent != nullptr) { - if (UNSIGNED32 frc = remote_flush_pending(ri->parent); - frc != 0) { - return frc; - } - } - auto r = openads::network::remote_index_key_count(ri); - if (!r) return fail(r.error()); - *pulCount = r.value(); - return ok(); - } - // M12.28 - route remote table handles: key_count = physical count. - if (auto* rt = get_remote_table(hIndex)) { - if (rt->conn == nullptr) return fail(openads::AE_INTERNAL_ERROR, "remote table: no connection"); - if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; - auto r = rt->conn->key_count(rt->id); - if (!r) return fail(r.error()); - *pulCount = r.value(); - return ok(); - } - Table* t = get_table(hIndex); - if (t == nullptr) return ok(); - // Settle any coalesced dirty record first: its index keys are only - // inserted on writeback, so a pending edit would be missing from the - // count. - if (auto cr = t->commit_dirty_record(); !cr) return fail(cr.error()); - // With an active order, the KEY count can be far fewer than the table's - // record_count() (a conditional/FOR index indexes only matching rows). - // Returning record_count() here made it inconsistent with OrdKeyNo / - // GetRelKeyPos (which count the index walk) -> TXBrowse position math - // broke on conditional orders. Use the cached index walk (O(1)). - auto* ord = t->order(); - if (ord != nullptr && ord->index() != nullptr) { - if (auto* cdx = - dynamic_cast(ord->index())) { - auto& sc = ord->scope(); - const bool hide_del = !t->show_deleted_records(); - std::function live; - const std::function* live_p = nullptr; - if (hide_del) { - // deleted_at() keeps the driver's read-ahead warm and does not - // move the cursor, so there is nothing to restore. - live = [t](std::uint32_t rn) { - auto del = t->deleted_at(rn); - return del && !del.value(); - }; - live_p = &live; - } - if (sc.top.has_value() || sc.bottom.has_value()) { - *pulCount = cdx->count_scoped_keys( - sc.top.value_or(""), - sc.bottom.value_or(""), - live_p); - } else if (hide_del) { - *pulCount = count_live_recnos(t, cdx->ordered_recnos_cached(), cdx); - } else { - *pulCount = static_cast( - cdx->ordered_recnos_cached().size()); - } - return ok(); - } - // NTX: use cached B-tree walk for correct conditional count - if (auto* ntx = - dynamic_cast(ord->index())) { - const bool hide_del = !t->show_deleted_records(); - if (hide_del) { - *pulCount = count_live_recnos(t, ntx->ordered_recnos_cached(), ntx); - } else { - *pulCount = static_cast( - ntx->ordered_recnos_cached().size()); - } - return ok(); - } - // ADI: use cached B-tree walk for correct conditional count - if (auto* adi = - dynamic_cast(ord->index())) { - const bool hide_del = !t->show_deleted_records(); - if (hide_del) { - *pulCount = count_live_recnos(t, adi->ordered_recnos_cached(), adi); - } else { - *pulCount = static_cast( - adi->ordered_recnos_cached().size()); - } - return ok(); - } - if (auto* adi = - dynamic_cast(ord->index())) { - // Same reasoning for a native ADI tag: a v2 tag with a FOR clause - // holds only the matching rows, so falling through to the table's - // record_count() reported every row and broke OrdKeyCount on a - // conditional order (the ERP's ORD5 FOR cCorEnvEle != 'S'). - const bool hide_del = !t->show_deleted_records(); - const std::uint32_t saved_rn = t->recno(); - std::uint32_t n = 0; - for (std::uint32_t rn : adi->ordered_recnos_cached()) { - if (!hide_del) { ++n; continue; } - if (t->goto_record(rn) && !t->is_deleted()) ++n; - } - *pulCount = n; - if (hide_del && saved_rn != 0) (void)t->goto_record(saved_rn); - return ok(); - } - } - *pulCount = t->record_count(); - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsContinue(ADSHANDLE hTable, UNSIGNED16* pbFound) { - arc2_trace("AdsContinue"); - if (pbFound) *pbFound = 0; - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - // Skip one record forward -- Table::skip() is filter-aware: it walks - // past non-matching records until it finds one that passes the current - // filter (AOF or SetFilter) or reaches EOF. - auto r = t->skip(1); - if (!r) return fail(r.error()); - if (pbFound) *pbFound = t->eof() ? 0 : 1; - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsEvalTestExpr(ADSHANDLE /*hTable*/, UNSIGNED8* /*pucExpr*/, - UNSIGNED16* pusType) { - arc2_trace("AdsEvalTestExpr"); - if (pusType) *pusType = 0; - return ok(); // treat any expr as syntactically valid -} -UNSIGNED32 ENTRYPOINT AdsEvalLogicalExpr(ADSHANDLE hTable, UNSIGNED8* pucExpr, - UNSIGNED16* pbResult) { - arc2_trace("AdsEvalLogicalExpr"); - if (pbResult) *pbResult = 0; - if (!pucExpr) return fail(openads::AE_INTERNAL_ERROR, "null expr"); - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - std::string expr = reinterpret_cast(pucExpr); - auto ast = openads::engine::aof::parse(expr); - if (!ast) return fail(ast.error()); - if (pbResult) - *pbResult = openads::engine::aof::evaluate_record(*ast.value(), *t) ? 1 : 0; - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsEvalNumericExpr(ADSHANDLE hTable, UNSIGNED8* pucExpr, double* pdResult) { - arc2_trace("AdsEvalNumericExpr"); - if (pdResult) *pdResult = 0.0; - if (!pucExpr) return fail(openads::AE_INTERNAL_ERROR, "null expr"); - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - std::string expr = reinterpret_cast(pucExpr); - std::int32_t fi = t->field_index(expr); - if (fi >= 0) { - auto v = t->read_field(static_cast(fi)); - if (v && pdResult) *pdResult = v.value().as_double; - return ok(); - } - try { - std::size_t pos = 0; - double d = std::stod(expr, &pos); - if (pos == expr.size() && pdResult) *pdResult = d; - return ok(); - } catch (...) {} - return fail(openads::AE_FUNCTION_NOT_AVAILABLE, "cannot evaluate numeric expr"); -} -UNSIGNED32 ENTRYPOINT AdsEvalStringExpr(ADSHANDLE hTable, UNSIGNED8* pucExpr, - UNSIGNED8* pucResult, UNSIGNED16* pusLen) { - arc2_trace("AdsEvalStringExpr"); - if (!pucExpr) return fail(openads::AE_INTERNAL_ERROR, "null expr"); - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - std::string expr = reinterpret_cast(pucExpr); - std::string result; - std::int32_t fi = t->field_index(expr); - if (fi >= 0) { - auto v = t->read_field(static_cast(fi)); - if (v) result = v.value().as_string; - } else { - result = expr; - } - if (pusLen) { - std::uint16_t cap = *pusLen; - std::uint16_t rlen = static_cast(result.size()); - if (pucResult && cap > 0) { - auto cap1 = static_cast(cap - 1u); - std::uint16_t copy = rlen < cap1 ? rlen : cap1; - std::memcpy(pucResult, result.data(), copy); - pucResult[copy] = 0; - } - *pusLen = rlen; - } - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsFindConnection(UNSIGNED8* /*pucServer*/, ADSHANDLE* phConnect) { - arc2_trace("AdsFindConnection"); - if (phConnect) *phConnect = 0; - return fail(openads::AE_NO_CONNECTION, "no connection for path"); -} -UNSIGNED32 ENTRYPOINT AdsGetAllIndexes(ADSHANDLE hTable, ADSHANDLE* ahIndex, - UNSIGNED16* pusArrayLen) { - arc2_trace("AdsGetAllIndexes"); - if (!pusArrayLen) return fail(openads::AE_INTERNAL_ERROR, "null out"); - if (get_remote_table(hTable)) { *pusArrayLen = 0; return ok(); } - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - std::vector found; - // Storm fix: reader must hold index_bindings_mu (binds are unlocked now). - std::lock_guard _ra_lk(index_bindings_mu()); - for (auto& [h, b] : index_bindings()) { - if (b.table == t) found.push_back(h); - } - UNSIGNED16 cap = *pusArrayLen; - auto total = static_cast(found.size()); - UNSIGNED16 n = cap < total ? cap : total; - *pusArrayLen = n; - if (ahIndex) { - for (UNSIGNED16 i = 0; i < n; ++i) ahIndex[i] = found[i]; - } - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsGetFTSIndexes(ADSHANDLE /*hTable*/, ADSHANDLE* /*ahIndex*/, - UNSIGNED16* pusArrayLen) { - arc2_trace("AdsGetFTSIndexes"); - // FTS indexes are loaded ad-hoc at query time with no persistent - // handle, so there is nothing to enumerate here. - if (pusArrayLen) *pusArrayLen = 0; - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsGetAllTables(ADSHANDLE hConnect, ADSHANDLE* ahTable, - UNSIGNED16* pusArrayLen) { - arc2_trace("AdsGetAllTables"); - if (!pusArrayLen) return fail(openads::AE_INTERNAL_ERROR, "null out"); - auto& s = state(); - std::lock_guard lk(s.mu); - Connection* conn = - s.registry.lookup(hConnect, HandleKind::Connection); - if (!conn) { *pusArrayLen = 0; return ok(); } // remote or unknown - std::vector found; - s.registry.for_each_handle([&](Handle h, HandleKind k, void* p) { - if (k != HandleKind::Table) return; - if (conn->owns_table_ptr(static_cast(p))) found.push_back(to_ads_handle(h)); - }); - UNSIGNED16 cap = *pusArrayLen; - auto total = static_cast(found.size()); - UNSIGNED16 n = cap < total ? cap : total; - *pusArrayLen = n; - if (ahTable) { - for (UNSIGNED16 i = 0; i < n; ++i) ahTable[i] = found[i]; - } - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsCloneTable(ADSHANDLE hTable, ADSHANDLE* phClone) { - arc2_trace("AdsCloneTable"); - if (!phClone) return fail(openads::AE_INTERNAL_ERROR, "null out param"); - *phClone = 0; - auto& s = state(); - std::lock_guard lk(s.mu); - Table* t = s.registry.lookup
(hTable, HandleKind::Table); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - if (!t->driver()) return fail(openads::AE_INTERNAL_ERROR, "no driver"); - - // Locate the connection that owns this table. - Connection* owning = nullptr; - s.registry.for_each_handle([&](Handle, HandleKind k, void* p) { - if (k != HandleKind::Connection || owning) return; - auto* cc = static_cast(p); - if (cc->owns_table_ptr(t)) owning = cc; - }); - if (!owning) return fail(openads::AE_INVALID_CONNECTION_HANDLE, - "table not owned by any connection"); - - // Unique temp filename inside the data directory. - namespace fs = std::filesystem; - char tmp_name[64] = {}; - std::snprintf(tmp_name, sizeof(tmp_name), "_clone_%llx.dbf", - static_cast( - openads::platform::monotonic_nanos())); - - // Serialize structure + all records (including deleted, for exact - // fidelity) to the temp file. - const auto& src_fields = t->driver()->fields(); - if (src_fields.empty()) - return fail(openads::AE_INTERNAL_ERROR, "AdsCloneTable: no fields"); - std::uint16_t header_len = static_cast( - 32 + 32 * src_fields.size() + 2); - std::uint16_t rec_len = t->driver()->record_length(); - - std::vector file; - std::vector hdr(32, 0); - hdr[0] = 0x03; - stamp_dbf_header_today(hdr.data()); - hdr[8] = static_cast(header_len & 0xFFu); - hdr[9] = static_cast((header_len >> 8) & 0xFFu); - hdr[10] = static_cast(rec_len & 0xFFu); - hdr[11] = static_cast((rec_len >> 8) & 0xFFu); - file = hdr; - for (const auto& f : src_fields) { - std::vector fd(32, 0); - std::size_t n = std::min(f.name.size(), 10); - std::memcpy(fd.data(), f.name.data(), n); - fd[11] = static_cast(f.raw_type ? f.raw_type : 'C'); - fd[16] = static_cast(f.length); - fd[17] = f.decimals; - file.insert(file.end(), fd.begin(), fd.end()); - } - stamp_dbf_field_displacements(file.data() + 32, src_fields.size()); - file.push_back(0x0D); - file.push_back(0x00); - - std::uint32_t rcount = t->driver()->record_count(); - for (std::uint32_t r = 1; r <= rcount; ++r) { - auto rec = t->driver()->read_record_raw(r); - if (!rec) return fail(rec.error()); - file.insert(file.end(), rec.value().begin(), rec.value().end()); - } - file.push_back(0x1A); - file[4] = static_cast( rcount & 0xFFu); - file[5] = static_cast((rcount >> 8) & 0xFFu); - file[6] = static_cast((rcount >> 16) & 0xFFu); - file[7] = static_cast((rcount >> 24) & 0xFFu); - - fs::path tmp_path = fs::path(owning->data_dir()) / tmp_name; - { - std::ofstream out(tmp_path, std::ios::binary); - if (!out) return fail(openads::AE_INTERNAL_ERROR, - "AdsCloneTable: write failed"); - out.write(reinterpret_cast(file.data()), - static_cast(file.size())); - if (!out) return fail(openads::AE_INTERNAL_ERROR, - "AdsCloneTable: write error"); - } - - // Open the clone through the owning connection. - auto th = owning->open_table(std::string(tmp_name), - openads::engine::TableType::Cdx, - openads::engine::OpenMode::Shared); - if (!th) { - std::error_code ec; - fs::remove(tmp_path, ec); - return fail(th.error()); - } - Table* clone = owning->lookup_table(th.value()); - if (!clone) return fail(openads::AE_INTERNAL_ERROR, - "AdsCloneTable: post-open"); - *phClone = to_ads_handle(s.registry.register_object(HandleKind::Table, clone)); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsCopyTableStructure(ADSHANDLE hTable, UNSIGNED8* pucFile) { - arc2_trace("AdsCopyTableStructure"); - if (!pucFile) return fail(openads::AE_INTERNAL_ERROR, "null path"); - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - if (!t->driver()) return fail(openads::AE_INTERNAL_ERROR, "no driver"); - - namespace fs = std::filesystem; - auto raw = openads::abi::to_internal(pucFile, 0); - fs::path dst(raw); - if (!dst.is_absolute()) - dst = fs::path(t->path()).parent_path() / dst; - if (!dst.has_extension()) dst.replace_extension(".dbf"); - - const auto& src_fields = t->driver()->fields(); - if (src_fields.empty()) - return fail(openads::AE_INTERNAL_ERROR, "AdsCopyTableStructure: no fields"); - std::uint16_t header_len = static_cast( - 32 + 32 * src_fields.size() + 2); // Harbour DBFCDX header: 32 + 32*n + 2 - std::uint16_t rec_len = t->driver()->record_length(); - - std::vector file; - std::vector hdr(32, 0); - hdr[0] = 0x03; - stamp_dbf_header_today(hdr.data()); - // record count = 0 (bytes 4-7 stay zero) - hdr[8] = static_cast(header_len & 0xFFu); - hdr[9] = static_cast((header_len >> 8) & 0xFFu); - hdr[10] = static_cast(rec_len & 0xFFu); - hdr[11] = static_cast((rec_len >> 8) & 0xFFu); - file = hdr; - for (const auto& f : src_fields) { - std::vector fd(32, 0); - std::size_t n = std::min(f.name.size(), 10); - std::memcpy(fd.data(), f.name.data(), n); - fd[11] = static_cast(f.raw_type ? f.raw_type : 'C'); - fd[16] = static_cast(f.length); - fd[17] = f.decimals; - file.insert(file.end(), fd.begin(), fd.end()); - } - stamp_dbf_field_displacements(file.data() + 32, src_fields.size()); - file.push_back(0x0D); - file.push_back(0x00); // Harbour writes a 2-byte 0x0D 0x00 terminator - file.push_back(0x1A); // EOF, no records - - { - std::error_code ec; - fs::remove(dst, ec); - } - { - std::ofstream out(dst, std::ios::binary); - if (!out) return fail(openads::AE_INTERNAL_ERROR, - "AdsCopyTableStructure: open failed"); - out.write(reinterpret_cast(file.data()), - static_cast(file.size())); - if (!out) return fail(openads::AE_INTERNAL_ERROR, - "AdsCopyTableStructure: write failed"); - } - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsGetRecordCRC(ADSHANDLE hTable, UNSIGNED32* pulCRC, - UNSIGNED32 /*ulOption*/) { - arc2_trace("AdsGetRecordCRC"); - if (pulCRC == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *pulCRC = 0; - if (auto* rt = get_remote_table(hTable)) { - if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; - auto r = rt->conn->get_record_crc(rt->id); - if (!r) return fail(r.error()); - *pulCRC = r.value(); - return ok(); - } - Table* t = get_table(hTable); - if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); - if (!t->positioned()) - return fail(openads::AE_NO_CURRENT_RECORD, "no current record"); - *pulCRC = openads::engine::crc32_record(t->record_buffer()); - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsInitRawKey(ADSHANDLE) { - arc2_trace("AdsInitRawKey"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsMgDumpInternalTables(ADSHANDLE h) { - arc2_trace("AdsMgDumpInternalTables"); - return lookup_mg(h) ? openads::AE_SUCCESS - : openads::AE_INVALID_CONNECTION_HANDLE; -} -UNSIGNED32 ENTRYPOINT AdsClearSQLAbortFunc(void) { - arc2_trace("AdsClearSQLAbortFunc"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsClearSQLParams(ADSHANDLE) { - arc2_trace("AdsClearSQLParams"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsStmtClearTablePasswords(ADSHANDLE h) { - arc2_trace("AdsStmtClearTablePasswords"); - if (auto* st = stmt_lookup(h)) st->passwords.clear(); - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsStmtDisableEncryption(ADSHANDLE h) { - arc2_trace("AdsStmtDisableEncryption"); - if (auto* st = stmt_lookup(h)) st->disable_enc = true; - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsStmtSetTableCharType(ADSHANDLE h, UNSIGNED16 us) { - arc2_trace("AdsStmtSetTableCharType"); - if (auto* st = stmt_lookup(h)) st->char_type = us; - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsStmtSetTableCollation(ADSHANDLE h, UNSIGNED8* puc) { - arc2_trace("AdsStmtSetTableCollation"); - if (auto* st = stmt_lookup(h)) st->collation = openads::abi::to_internal(puc, 0); - return ok(); -} -UNSIGNED32 ENTRYPOINT AdsStmtSetTableRights(ADSHANDLE h, UNSIGNED16 us) { - arc2_trace("AdsStmtSetTableRights"); - if (auto* st = stmt_lookup(h)) st->check_rights = us; - return ok(); -} - -// --- field-identifier-aware setters/getters (name OR ordinal-as-pointer) --- -UNSIGNED32 ENTRYPOINT AdsSetField(ADSHANDLE hObj, UNSIGNED8* pId, UNSIGNED8* pucBuf, - UNSIGNED32 ulLen) { - arc2_trace("AdsSetField"); - UNSIGNED8 nm[64]; - return AdsSetString(hObj, as_field(resolve_field_id(hObj, pId, nm, sizeof(nm))), - pucBuf, ulLen); -} -UNSIGNED32 ENTRYPOINT AdsSetFieldW(ADSHANDLE hObj, UNSIGNED8* pId, - UNSIGNED16* pwcBuf, UNSIGNED32 ulLen) { - arc2_trace("AdsSetFieldW"); - UNSIGNED8 nm[64]; - return AdsSetStringW(hObj, as_field(resolve_field_id(hObj, pId, nm, sizeof(nm))), - pwcBuf, ulLen); -} -UNSIGNED32 ENTRYPOINT AdsSetEmpty(ADSHANDLE hObj, UNSIGNED8* pId) { - arc2_trace("AdsSetEmpty"); - UNSIGNED8 nm[64]; - UNSIGNED8 blank = 0; - return AdsSetString(hObj, as_field(resolve_field_id(hObj, pId, nm, sizeof(nm))), - &blank, 0); -} -// RCB 2026-07-03 -- AdsSetNull previously always aliased to AdsSetEmpty. -// For a prepared-statement parameter, AdsSetEmpty routes through -// AdsSetString, which stores the *quoted* literal '' (see -// set_stmt_param callers above) -- that substitutes an empty-string -// literal into the SQL text instead of the NULL keyword, breaking -// parameterized queries on typed (numeric/date) columns and changing -// IS NULL semantics on text columns. Try the statement-handle path -// first with the unquoted literal NULL (matching the raw-literal -// convention AdsSetLogical/AdsSetDouble already use for stmt params). -// For local table handles, route through Table::set_field_null so VFP -// nullable columns set _NullFlags and ADT columns receive their native -// NULL sentinel; CDX/NTX still blank through that same primitive. -UNSIGNED32 ENTRYPOINT AdsSetNull(ADSHANDLE hObj, UNSIGNED8* pId) { - arc2_trace("AdsSetNull"); - if (pId != nullptr && - set_stmt_param(hObj, reinterpret_cast(pId), "NULL")) { - return ok(); - } - if (auto* rt = get_remote_table(hObj)) { - if (pId == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - std::string fname(reinterpret_cast(pId)); - return remote_buffered_set(rt, fname, std::string()); - } - Table* t = get_table(hObj); - if (t != nullptr) { - UNSIGNED8 nm[64]; - std::uint16_t idx = 0; - if (!resolve_field_index(t, - as_field(resolve_field_id(hObj, pId, nm, sizeof(nm))), &idx)) { - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - } - auto r = t->set_field_null(idx); - if (!r) return fail(r.error()); - return ok(); - } - return AdsSetEmpty(hObj, pId); -} -UNSIGNED32 ENTRYPOINT AdsSetShort(ADSHANDLE hObj, UNSIGNED8* pId, SIGNED32 sValue) { - arc2_trace("AdsSetShort"); - UNSIGNED8 nm[64]; - return AdsSetDouble(hObj, as_field(resolve_field_id(hObj, pId, nm, sizeof(nm))), - static_cast(sValue)); -} -UNSIGNED32 ENTRYPOINT AdsSetMoney(ADSHANDLE hObj, UNSIGNED8* pId, SIGNED64 qValue) { - arc2_trace("AdsSetMoney"); - UNSIGNED8 nm[64]; - return AdsSetDouble(hObj, as_field(resolve_field_id(hObj, pId, nm, sizeof(nm))), - static_cast(qValue) / 10000.0); // ACE money scale -} -UNSIGNED32 ENTRYPOINT AdsSetTime(ADSHANDLE hObj, UNSIGNED8* pId, UNSIGNED8* pucValue, - UNSIGNED16 usLen) { - arc2_trace("AdsSetTime"); - UNSIGNED8 nm[64]; - return AdsSetString(hObj, as_field(resolve_field_id(hObj, pId, nm, sizeof(nm))), - pucValue, usLen); -} -UNSIGNED32 ENTRYPOINT AdsSetTimeStamp(ADSHANDLE hObj, UNSIGNED8* pId, UNSIGNED8* pucBuf, - UNSIGNED32 ulLen) { - arc2_trace("AdsSetTimeStamp"); - UNSIGNED8 nm[64]; - // Normalise the text to the compact "YYYYMMDDhhmmss" the engine's - // encoder expects. SAP parses " hh:mm:ss" - // (24-hour on input); forwarding the text verbatim let a formatted - // timestamp through to the raw encoder, which mis-sliced it and - // wrote garbage (probed: read back as a negative epoch). - std::string s(pucBuf ? reinterpret_cast(pucBuf) : "", - pucBuf ? static_cast(ulLen) : 0u); - std::string date8, time6 = "000000"; - std::string date_text = s, time_text; - const auto sp = s.find(' '); - if (sp != std::string::npos) { - date_text = s.substr(0, sp); - time_text = s.substr(sp + 1); - } - if (std::string byfmt = parse_date_by_format(date_text); !byfmt.empty()) { - date8 = std::move(byfmt); - } else if (date_text.size() == 10 && date_text[4] == '-' && - date_text[7] == '-') { - date8 = date_text.substr(0, 4) + date_text.substr(5, 2) + - date_text.substr(8, 2); - } else if (s.size() >= 14) { - bool digits = true; - for (std::size_t i = 0; i < 14; ++i) - digits = digits && std::isdigit(static_cast(s[i])); - if (digits) { // already compact "YYYYMMDDhhmmss" - date8 = s.substr(0, 8); - time6 = s.substr(8, 6); - time_text.clear(); - } - } - if (!date8.empty() && time_text.size() >= 8 && - time_text[2] == ':' && time_text[5] == ':') { - time6 = time_text.substr(0, 2) + time_text.substr(3, 2) + - time_text.substr(6, 2); - } - std::string compact = date8.empty() ? s : date8 + time6; - std::vector bytes(compact.begin(), compact.end()); - bytes.push_back(0); - return AdsSetString(hObj, as_field(resolve_field_id(hObj, pId, nm, sizeof(nm))), - bytes.data(), static_cast(compact.size())); -} -// Raw-bytes sibling of AdsSetTimeStamp, following the AdsSetFieldRaw / -// AdsGetFieldRaw convention already used elsewhere in this file: bypass -// string formatting and write pucBuf's bytes directly into the field. -UNSIGNED32 ENTRYPOINT AdsSetTimeStampRaw(ADSHANDLE hObj, UNSIGNED8* pId, UNSIGNED8* pucBuf, - UNSIGNED32 ulLen) { - arc2_trace("AdsSetTimeStampRaw"); - UNSIGNED8 nm[64]; - return AdsSetFieldRaw(hObj, as_field(resolve_field_id(hObj, pId, nm, sizeof(nm))), - pucBuf, ulLen); -} -UNSIGNED32 ENTRYPOINT AdsGetDate(ADSHANDLE hObj, UNSIGNED8* pId, UNSIGNED8* pucBuf, - UNSIGNED16* pusLen) { - arc2_trace("AdsGetDate"); - UNSIGNED8 nm[64]; - UNSIGNED32 cap = pusLen ? *pusLen : 0; - // rddads passes hOrdCurrent (a RemoteIndex handle) for Date fields. - // AdsGetField only checks get_remote_table(); resolve index -> parent. - ADSHANDLE real_hObj = hObj; - bool is_remote = (get_remote_index(hObj) != nullptr); - if (auto* ri = get_remote_index(hObj)) { - is_remote = true; - if (ri->parent) real_hObj = to_ads_handle(handle_for_remote_table(ri->parent)); - } - is_remote = is_remote || (get_remote_table(real_hObj) != nullptr); - // rddads date FieldGet expects the RAW "YYYYMMDD" storage text (it - // decodes it to a Julian day itself); formatting it per the display - // format would feed "01/15/2024" into that decode. Suppress the - // display formatting for remote reads here — the local/AdsGetField - // surface keeps SAP formatting. - if (is_remote) openads::abi::field_read_raw_begin(); - UNSIGNED32 rc = AdsGetField(real_hObj, - as_field(resolve_field_id(real_hObj, pId, nm, sizeof(nm))), - pucBuf, &cap, 0); - if (is_remote) openads::abi::field_read_raw_end(); - if (pusLen) *pusLen = static_cast(cap); - return rc; -} - -UNSIGNED32 ENTRYPOINT AdsSetSQLTimeout(ADSHANDLE hObj, UNSIGNED32 ulTimeout) { - arc2_trace("AdsSetSQLTimeout"); - if (auto* st = stmt_lookup(hObj)) { - st->sql_timeout = ulTimeout; - std::lock_guard lk(stmt_mu()); - sql_timeout_map()[hObj] = ulTimeout; - return ok(); - } - - auto& s = state(); - std::lock_guard lk(s.mu); - bool valid = s.registry.lookup(hObj, HandleKind::Connection) || - s.registry.lookup( - hObj, HandleKind::RemoteConnection); -#if defined(OPENADS_WITH_SQLITE) - valid = valid || s.registry.lookup( - hObj, HandleKind::SqliteConnection); -#endif -#if defined(OPENADS_WITH_MSSQL) - valid = valid || s.registry.lookup( - hObj, HandleKind::MssqlConnection); -#endif -#if defined(OPENADS_WITH_POSTGRESQL) - valid = valid || s.registry.lookup( - hObj, HandleKind::PostgresConnection); -#endif -#if defined(OPENADS_WITH_MARIADB) - valid = valid || s.registry.lookup( - hObj, HandleKind::MariaConnection); -#endif -#if defined(OPENADS_WITH_ODBC) - valid = valid || s.registry.lookup( - hObj, HandleKind::OdbcConnection); -#endif -#if defined(OPENADS_WITH_FIREBIRD) - valid = valid || s.registry.lookup( - hObj, HandleKind::FirebirdConnection); -#endif - if (!valid) { - return fail(openads::AE_INVALID_CONNECTION_HANDLE, - "unknown SQL timeout handle"); - } - - std::lock_guard stmt_lk(stmt_mu()); - sql_timeout_map()[hObj] = ulTimeout; - return ok(); -} - -// --------------------------------------------------------------------------- -// SAP ACE API name aliases -- binaries compiled against ace64.dll use these -// names. OpenADS uses Create/Drop internally; SAP ACE uses Add/Remove. -// --------------------------------------------------------------------------- - -UNSIGNED32 ENTRYPOINT AdsDDAddProcedure(ADSHANDLE hConn, UNSIGNED8* pucName, - UNSIGNED8* pucContainer, UNSIGNED8* pucProcName, - UNSIGNED32 ulInvokeOption, - UNSIGNED8* pucInParams, UNSIGNED8* pucOutParams, - UNSIGNED8* pucComments) { - arc2_trace("AdsDDAddProcedure"); - return AdsDDCreateProcedure(hConn, pucName, pucContainer, pucProcName, - ulInvokeOption, pucInParams, pucOutParams, - pucComments); -} -UNSIGNED32 ENTRYPOINT AdsDDRemoveProcedure(ADSHANDLE hConn, UNSIGNED8* pucName) { - arc2_trace("AdsDDRemoveProcedure"); - return AdsDDDropProcedure(hConn, pucName); -} -UNSIGNED32 ENTRYPOINT AdsDDGetProcedureProperty(ADSHANDLE hConn, UNSIGNED8* pucName, - UNSIGNED16 usProp, void* pBuf, - UNSIGNED16* pusLen) { - arc2_trace("AdsDDGetProcedureProperty"); - return AdsDDGetProcProperty(hConn, pucName, usProp, pBuf, pusLen); -} -UNSIGNED32 ENTRYPOINT AdsDDSetProcedureProperty(ADSHANDLE hConn, UNSIGNED8* pucName, - UNSIGNED16 usProp, void* pBuf, - UNSIGNED16 usLen) { - arc2_trace("AdsDDSetProcedureProperty"); - return AdsDDSetProcProperty(hConn, pucName, usProp, pBuf, usLen); -} -UNSIGNED32 ENTRYPOINT AdsDDRemoveTrigger(ADSHANDLE hConn, UNSIGNED8* pucName) { - arc2_trace("AdsDDRemoveTrigger"); - return AdsDDDropTrigger(hConn, pucName); -} - -// AdsDDFindFirstObject / FindNextObject / FindClose -- stubs -// OpenADS does not implement the find-handle enumeration pattern; callers -// that need object lists should query the system.* virtual tables instead. -UNSIGNED32 ENTRYPOINT AdsDDFindFirstObject(ADSHANDLE /*hObject*/, - UNSIGNED16 /*usFindObjectType*/, - UNSIGNED8* /*pucParentName*/, - UNSIGNED8* /*pucObjectName*/, - UNSIGNED16* pusObjectNameLen, - ADSHANDLE* phFindHandle) { - arc2_trace("AdsDDFindFirstObject"); - if (pusObjectNameLen) *pusObjectNameLen = 0; - if (phFindHandle) *phFindHandle = 0; - return fail(openads::AE_FUNCTION_NOT_AVAILABLE, "AdsDDFindFirstObject"); -} -UNSIGNED32 ENTRYPOINT AdsDDFindNextObject(ADSHANDLE /*hObject*/, - ADSHANDLE /*hFindHandle*/, - UNSIGNED8* /*pucObjectName*/, - UNSIGNED16* pusObjectNameLen) { - arc2_trace("AdsDDFindNextObject"); - if (pusObjectNameLen) *pusObjectNameLen = 0; - return fail(openads::AE_FUNCTION_NOT_AVAILABLE, "AdsDDFindNextObject"); -} -UNSIGNED32 ENTRYPOINT AdsDDFindClose(ADSHANDLE /*hObject*/, ADSHANDLE /*hFindHandle*/) { - arc2_trace("AdsDDFindClose"); - return ok(); -} - -} // extern "C" -- close stub block (opened at line 17925) -} // extern "C" -- close ACE API exports block (opened at line 12394) - -// ── Task 2: AdsFetchWhere result-set registry + exports ─────────────────────── -// -// Process-local registry that maps opaque result handles to the -// FetchWhereBatch returned by the wire call. Handles live in the -// high range (top bit set) to avoid collisions with the sequential -// handles that HandleRegistry issues (which start at 1 and count up). - -namespace { - -// Stores the batch received from fetch_where plus the column list that -// was requested, so AdsFetchWhereField can look up values by name. -struct FetchWhereResult { - openads::network::FetchWhereBatch batch; - std::vector cols; // same order as batch.rows[r] -}; - -std::mutex& fw_mu() { - static std::mutex m; - return m; -} - -std::unordered_map& fw_results() { - static std::unordered_map m; - return m; -} - -// Must be called while holding fw_mu(). -ADSHANDLE fw_next_handle() { - static std::uint64_t n = 0x8000000000000001ULL; - return static_cast(n++); -} - -bool agg_field_is_numeric(openads::drivers::DbfFieldType t) { - using T = openads::drivers::DbfFieldType; - switch (t) { - case T::Numeric: case T::Float: - case T::Integer: case T::Currency: - case T::Double: case T::ShortInt: - case T::AutoInc: case T::AdtMoney: - return true; - default: - return false; - } -} - -// In-process FetchWhere scan -- mirrors network/session.cpp Opcode::FetchWhere. -openads::network::FetchWhereBatch -local_run_fetch_where(Table& tbl, std::uint32_t maxrows, - const std::string& expr, - const std::vector& cols, - std::uint8_t flags) { - openads::network::FetchWhereBatch batch; - const bool want_recno = - (flags & openads::network::FetchWhereFlags::WANT_RECNO) != 0; - std::uint32_t nrows_out = 0; - while (!tbl.eof() && nrows_out < maxrows) { - if (openads::engine::evaluate_index_expr_truthy(tbl, expr)) { - if (want_recno) - batch.recnos.push_back(tbl.recno()); - std::vector row; - row.reserve(cols.size()); - for (const auto& cn : cols) { - std::int32_t fi = tbl.field_index(cn); - std::string val; - if (fi >= 0) { - auto v = tbl.read_field(static_cast(fi)); - if (v) val = v.value().as_string; - } - row.push_back(std::move(val)); - } - batch.rows.push_back(std::move(row)); - ++nrows_out; - } - if (!tbl.skip(1)) break; - } - batch.eof = tbl.eof(); - return batch; -} - -// In-process aggregate scan -- mirrors network/session.cpp Opcode::Aggregate. -// Returns false and sets err on an invalid spec (unknown field, empty field on -// non-COUNT, etc.). -bool local_run_aggregate(Table& tbl, const std::string& for_expr, - const std::vector& specs, - std::vector& out, - std::string& err) { - std::vector accs; - std::vector fidx; - accs.reserve(specs.size()); - fidx.reserve(specs.size()); - for (const auto& s : specs) { - if (s.field.empty()) { - if (s.fn != openads::engine::AggFn::Count) { - err = "Aggregate: empty field only valid for COUNT"; - return false; - } - fidx.push_back(-1); - accs.emplace_back(s.fn, false); - continue; - } - std::int32_t fi = tbl.field_index(s.field); - if (fi < 0) { - err = "Aggregate: unknown field " + s.field; - return false; - } - const auto& fd = tbl.field_descriptor(static_cast(fi)); - accs.emplace_back(s.fn, agg_field_is_numeric(fd.type)); - fidx.push_back(fi); - } - - const std::uint32_t saved = tbl.recno(); - const bool was_eof = tbl.eof(); - tbl.goto_top(); - while (!tbl.eof()) { - if (openads::engine::evaluate_index_expr_truthy(tbl, for_expr)) { - for (std::size_t i = 0; i < accs.size(); ++i) { - if (fidx[i] < 0) { - accs[i].feed(false, 0.0, ""); - } else { - auto v = tbl.read_field(static_cast(fidx[i])); - if (v) { - const auto& dv = v.value(); - accs[i].feed(dv.is_null, dv.as_double, dv.as_string); - } else { - accs[i].feed(true, 0.0, ""); - } - } - } - } - if (!tbl.skip(1)) break; - } - if (!was_eof && saved >= 1 && saved <= tbl.record_count()) - tbl.goto_record(saved); - else - tbl.goto_top(); - - out.clear(); - out.reserve(accs.size()); - for (auto& a : accs) - out.push_back(a.finalize()); - return true; -} - -// Parse a comma-separated column list (e.g. "NM,QTD") into a vector. -// Returns an empty vector when pszCols is null or an empty string, which -// tells the server to return no column data (count / locate mode). -std::vector fw_split_cols(const UNSIGNED8* pszCols) { - std::vector out; - if (pszCols == nullptr) return out; - const char* p = reinterpret_cast(pszCols); - if (*p == '\0') return out; - while (true) { - const char* comma = std::strchr(p, ','); - if (comma == nullptr) { - out.emplace_back(p); - break; - } - out.emplace_back(p, static_cast(comma - p)); - p = comma + 1; - } - return out; -} - -} // namespace - -extern "C" { // reopen for AdsFetchWhere* exports - -// ─ AdsFetchWhere ───────────────────────────────────────────────────────────── -// Filtered scan over a table; remote tables use the wire opcode, local DBF -// tables scan in-process. SQL-backed tables are not supported here. -UNSIGNED32 ENTRYPOINT AdsFetchWhere(ADSHANDLE hTbl, UNSIGNED8* pszExpr, UNSIGNED8* pszCols, - UNSIGNED32 ulMaxRows, UNSIGNED32 ulFlags, - ADSHANDLE* phResult) { - arc2_trace("AdsFetchWhere"); - if (phResult == nullptr) - return fail(openads::AE_INTERNAL_ERROR, "AdsFetchWhere: null phResult"); - *phResult = 0; - std::string expr; - if (pszExpr != nullptr) - expr = openads::abi::to_internal(pszExpr, 0); - auto cols = fw_split_cols(pszCols); - const std::uint8_t wire_flags = - (ulFlags & 0x01u) ? openads::network::FetchWhereFlags::WANT_RECNO : 0u; - - openads::network::FetchWhereBatch batch; - if (auto* rt = get_remote_table(hTbl)) { - if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; - auto r = rt->conn->fetch_where(rt->id, ulMaxRows, expr, cols, wire_flags); - if (!r) return fail(r.error()); - batch = std::move(r).value(); - } else if (Table* tbl = get_table(hTbl)) { - batch = local_run_fetch_where(*tbl, ulMaxRows, expr, cols, wire_flags); - } else { - return fail(openads::AE_FUNCTION_NOT_AVAILABLE, - "AdsFetchWhere: not applicable on this table"); - } - - std::lock_guard lk(fw_mu()); - ADSHANDLE h = fw_next_handle(); - fw_results().emplace(h, FetchWhereResult{std::move(batch), std::move(cols)}); - *phResult = h; - return ok(); -} - -// ─ AdsFetchWhereRows ───────────────────────────────────────────────────────── -UNSIGNED32 ENTRYPOINT AdsFetchWhereRows(ADSHANDLE hRes, UNSIGNED32* pulRows) { - arc2_trace("AdsFetchWhereRows"); - if (pulRows == nullptr) - return fail(openads::AE_INTERNAL_ERROR, "AdsFetchWhereRows: null"); - std::lock_guard lk(fw_mu()); - auto it = fw_results().find(hRes); - if (it == fw_results().end()) - return fail(openads::AE_INTERNAL_ERROR, "AdsFetchWhereRows: invalid handle"); - *pulRows = static_cast(it->second.batch.rows.size()); - return ok(); -} - -// ─ AdsFetchWhereRecno ──────────────────────────────────────────────────────── -// ulRow is 0-based. Recnos are populated only when WANT_RECNO (0x01) was -// set in ulFlags at AdsFetchWhere call time. -UNSIGNED32 ENTRYPOINT AdsFetchWhereRecno(ADSHANDLE hRes, UNSIGNED32 ulRow, - UNSIGNED32* pulRec) { - arc2_trace("AdsFetchWhereRecno"); - if (pulRec == nullptr) - return fail(openads::AE_INTERNAL_ERROR, "AdsFetchWhereRecno: null"); - std::lock_guard lk(fw_mu()); - auto it = fw_results().find(hRes); - if (it == fw_results().end()) - return fail(openads::AE_INTERNAL_ERROR, "AdsFetchWhereRecno: invalid handle"); - const auto& recnos = it->second.batch.recnos; - if (ulRow >= static_cast(recnos.size())) - return fail(openads::AE_INTERNAL_ERROR, "AdsFetchWhereRecno: row out of range"); - *pulRec = recnos[ulRow]; - return ok(); -} - -// ─ AdsFetchWhereField ──────────────────────────────────────────────────────── -// Copy the value of column `pszCol` at result row `ulRow` into `pucBuf`. -// Column lookup is case-insensitive. *pusLen is in/out (capacity in, -// written byte count out), following the same truncation idiom as AdsGetField. -// ulRow is 0-based. -UNSIGNED32 ENTRYPOINT AdsFetchWhereField(ADSHANDLE hRes, UNSIGNED32 ulRow, - UNSIGNED8* pszCol, - UNSIGNED8* pucBuf, UNSIGNED16* pusLen) { - arc2_trace("AdsFetchWhereField"); - if (pucBuf == nullptr || pusLen == nullptr) - return fail(openads::AE_INTERNAL_ERROR, "AdsFetchWhereField: null buf/len"); - std::lock_guard lk(fw_mu()); - auto it = fw_results().find(hRes); - if (it == fw_results().end()) - return fail(openads::AE_INTERNAL_ERROR, "AdsFetchWhereField: invalid handle"); - const auto& res = it->second; - const auto& rows = res.batch.rows; - if (ulRow >= static_cast(rows.size())) - return fail(openads::AE_INTERNAL_ERROR, "AdsFetchWhereField: row out of range"); - // Case-insensitive column name lookup in the stored column list. - std::size_t col_idx = std::numeric_limits::max(); - if (pszCol != nullptr) { - std::string want = openads::abi::to_internal(pszCol, 0); - for (auto& c : want) - c = static_cast(std::toupper(static_cast(c))); - for (std::size_t i = 0; i < res.cols.size(); ++i) { - std::string n = res.cols[i]; - for (auto& c : n) - c = static_cast(std::toupper(static_cast(c))); - if (n == want) { col_idx = i; break; } - } - } - if (col_idx == std::numeric_limits::max()) - return fail(openads::AE_COLUMN_NOT_FOUND, "AdsFetchWhereField: column not found"); - const auto& row = rows[ulRow]; - if (col_idx >= row.size()) - return fail(openads::AE_INTERNAL_ERROR, "AdsFetchWhereField: col idx out of range"); - openads::abi::copy_to_caller(pucBuf, pusLen, row[col_idx]); - return ok(); -} - -// ─ AdsFetchWhereEof ────────────────────────────────────────────────────────── -// *pbEof is set to 1 when the server exhausted the table during the scan -// (no more rows remain past the last matched record), 0 when ulMaxRows was -// hit before EOF. -UNSIGNED32 ENTRYPOINT AdsFetchWhereEof(ADSHANDLE hRes, UNSIGNED16* pbEof) { - arc2_trace("AdsFetchWhereEof"); - if (pbEof == nullptr) - return fail(openads::AE_INTERNAL_ERROR, "AdsFetchWhereEof: null"); - std::lock_guard lk(fw_mu()); - auto it = fw_results().find(hRes); - if (it == fw_results().end()) - return fail(openads::AE_INTERNAL_ERROR, "AdsFetchWhereEof: invalid handle"); - *pbEof = it->second.batch.eof ? 1u : 0u; - return ok(); -} - -// ─ AdsFetchWhereClose ──────────────────────────────────────────────────────── -// Release the result batch and invalidate the handle. Calling any other -// AdsFetchWhere* accessor with this handle after Close returns an error. -UNSIGNED32 ENTRYPOINT AdsFetchWhereClose(ADSHANDLE hRes) { - arc2_trace("AdsFetchWhereClose"); - std::lock_guard lk(fw_mu()); - fw_results().erase(hRes); - return ok(); -} - -// ─ AdsFetchWhereApplyRow ────────────────────────────────────────────────────── -// Load batch row `ulRow` (its recno + field values) into hTbl's RemoteTable -// row cache, so AdsGetField / AdsGetRecordNum / AdsIsRecordDeleted / AdsAtEOF -// serve that row with NO extra round-trip. This lets a forward filter scan -// (rddads V2) walk the matched rows entirely from a batched AdsFetchWhere -// result -- one round-trip per batch instead of an AdsGotoRecord per match. -// -// The batch must have been fetched with WANT_RECNO. Values are matched to the -// table's fields by column name (case-insensitive); columns the batch did not -// request read back empty. Not applicable on local tables. -UNSIGNED32 ENTRYPOINT AdsFetchWhereApplyRow(ADSHANDLE hRes, UNSIGNED32 ulRow, ADSHANDLE hTbl) { - arc2_trace("AdsFetchWhereApplyRow"); - auto* rt = get_remote_table(hTbl); - if (rt == nullptr) - return fail(openads::AE_FUNCTION_NOT_AVAILABLE, - "AdsFetchWhereApplyRow: not applicable on a local table"); - - // Ensure the field schema is cached so we can map columns → field indices. - // (One wire round-trip the first time only; rddads has it cached already.) - if (!rt->fields_cached) { - auto d = rt->conn->describe_table(rt->id); - if (!d) return fail(d.error()); - rt->fields = std::move(d).value(); - rt->fields_cached = true; - } - - auto upper = [](std::string s) { - for (auto& c : s) - c = static_cast(std::toupper(static_cast(c))); - return s; - }; - - std::lock_guard lk(fw_mu()); - auto it = fw_results().find(hRes); - if (it == fw_results().end()) - return fail(openads::AE_INTERNAL_ERROR, - "AdsFetchWhereApplyRow: invalid handle"); - const auto& res = it->second; - if (ulRow >= res.batch.rows.size()) - return fail(openads::AE_INTERNAL_ERROR, - "AdsFetchWhereApplyRow: row out of range"); - if (ulRow >= res.batch.recnos.size()) - return fail(openads::AE_INTERNAL_ERROR, - "AdsFetchWhereApplyRow: batch has no recnos (need WANT_RECNO)"); - - const auto& vals = res.batch.rows[ulRow]; - std::vector row(rt->fields.size()); - for (std::size_t j = 0; j < res.cols.size() && j < vals.size(); ++j) { - std::string want = upper(res.cols[j]); - for (std::size_t i = 0; i < rt->fields.size(); ++i) { - if (upper(rt->fields[i].name) == want) { row[i] = vals[j]; break; } - } - } - - rt->current_row = std::move(row); - rt->row_valid = true; - rt->current_recno = res.batch.recnos[ulRow]; - rt->current_deleted = false; // FetchWhere skip(1) honours SET DELETED - rt->found_cached = true; - rt->current_found = false; // a scan move clears Found() - rt->invalidate_prefetch(); // the cursor is driven by FetchWhere now - return ok(); -} - -} // extern "C" -- AdsFetchWhere* export block - -// ── Tier-3: AdsAggregate result-set registry + exports ──────────────────────── -// -// Mirrors the FetchWhere registry: opaque high-range handles map to the -// vector of scalars returned by RemoteConnection::aggregate. A distinct -// high range keeps Aggregate handles from colliding with FetchWhere ones. - -namespace { - -struct AggregateResult { - std::vector values; -}; - -std::mutex& agg_mu() { - static std::mutex m; - return m; -} - -std::unordered_map& agg_results() { - static std::unordered_map m; - return m; -} - -// Must be called while holding agg_mu(). -ADSHANDLE agg_next_handle() { - static std::uint64_t n = 0xC000000000000001ULL; - return static_cast(n++); -} - -// Parse "COUNT:;SUM:QTY;MIN:NM" into AggSpec list. Returns false on an -// unknown function token (so the caller can reject the whole request). -bool agg_parse_spec(const UNSIGNED8* pszAggSpec, - std::vector& out) { - if (pszAggSpec == nullptr) return false; - std::string s = reinterpret_cast(pszAggSpec); - std::size_t i = 0; - while (i < s.size()) { - std::size_t semi = s.find(';', i); - std::string item = (semi == std::string::npos) - ? s.substr(i) - : s.substr(i, semi - i); - i = (semi == std::string::npos) ? s.size() : semi + 1; - if (item.empty()) continue; - std::size_t colon = item.find(':'); - std::string fn = (colon == std::string::npos) - ? item : item.substr(0, colon); - std::string field = (colon == std::string::npos) - ? std::string() : item.substr(colon + 1); - for (auto& c : fn) - c = static_cast(std::toupper(static_cast(c))); - openads::network::AggSpec spec; - if (fn == "COUNT") spec.fn = openads::engine::AggFn::Count; - else if (fn == "SUM") spec.fn = openads::engine::AggFn::Sum; - else if (fn == "AVG") spec.fn = openads::engine::AggFn::Avg; - else if (fn == "MIN") spec.fn = openads::engine::AggFn::Min; - else if (fn == "MAX") spec.fn = openads::engine::AggFn::Max; - else return false; - spec.field = std::move(field); - out.push_back(std::move(spec)); - } - return true; -} - -} // namespace - -extern "C" { // reopen for AdsAggregate* exports - -// ─ AdsAggregate ────────────────────────────────────────────────────────────── -UNSIGNED32 ENTRYPOINT AdsAggregate(ADSHANDLE hTbl, UNSIGNED8* pszForCond, - UNSIGNED8* pszAggSpec, ADSHANDLE* phResult) { - arc2_trace("AdsAggregate"); - if (phResult == nullptr) - return fail(openads::AE_INTERNAL_ERROR, "AdsAggregate: null phResult"); - *phResult = 0; - std::string for_expr; - if (pszForCond != nullptr) - for_expr = openads::abi::to_internal(pszForCond, 0); - std::vector specs; - if (!agg_parse_spec(pszAggSpec, specs) || specs.empty()) - return fail(openads::AE_INTERNAL_ERROR, - "AdsAggregate: bad or empty aggregate spec"); - - // Remote (tcp://) table: the server scans + folds (opcode 0xA6). - if (auto* rt = get_remote_table(hTbl)) { - if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; - auto r = rt->conn->aggregate(rt->id, for_expr, specs); - if (!r) return fail(r.error()); - std::lock_guard lk(agg_mu()); - ADSHANDLE h = agg_next_handle(); - agg_results().emplace(h, AggregateResult{std::move(r).value().values}); - *phResult = h; - return ok(); - } - - // SQL-backed (SQLite/Postgres/...) table: push down a real - // `SELECT COUNT/SUM/AVG/MIN/MAX ... WHERE `. The FOR must - // translate to SQL via try_emit_sql_where; otherwise decline so the caller - // falls back (never half-apply a predicate). - if (const auto* ops = openads::abi::backend_table_ops_for(hTbl); - ops != nullptr && ops->aggregate != nullptr) { - std::string where_sql; - const char* where_arg = nullptr; - if (!for_expr.empty()) { - auto w = openads::engine::try_emit_sql_where(for_expr); - if (!w) - return fail(openads::AE_FUNCTION_NOT_AVAILABLE, - "AdsAggregate: FOR not translatable to SQL"); - where_sql = std::move(w).value(); - where_arg = where_sql.c_str(); - } - std::vector vals; - UNSIGNED32 rc = ops->aggregate(hTbl, where_arg, &specs, &vals); - if (rc != 0) return rc; - std::lock_guard lk(agg_mu()); - ADSHANDLE h = agg_next_handle(); - agg_results().emplace(h, AggregateResult{std::move(vals)}); - *phResult = h; - return ok(); - } - - // Local in-process DBF table. - if (Table* tbl = get_table(hTbl)) { - std::vector vals; - std::string err; - if (!local_run_aggregate(*tbl, for_expr, specs, vals, err)) { - if (err.find("unknown field") != std::string::npos) - return fail(openads::AE_COLUMN_NOT_FOUND, err.c_str()); - return fail(openads::AE_INTERNAL_ERROR, err.c_str()); - } - std::lock_guard lk(agg_mu()); - ADSHANDLE h = agg_next_handle(); - agg_results().emplace(h, AggregateResult{std::move(vals)}); - *phResult = h; - return ok(); - } - - return fail(openads::AE_FUNCTION_NOT_AVAILABLE, - "AdsAggregate: not applicable on this table"); -} - -// ─ AdsAggregateCount ───────────────────────────────────────────────────────── -UNSIGNED32 ENTRYPOINT AdsAggregateCount(ADSHANDLE hRes, UNSIGNED32* pulCount) { - arc2_trace("AdsAggregateCount"); - if (pulCount == nullptr) - return fail(openads::AE_INTERNAL_ERROR, "AdsAggregateCount: null"); - std::lock_guard lk(agg_mu()); - auto it = agg_results().find(hRes); - if (it == agg_results().end()) - return fail(openads::AE_INTERNAL_ERROR, - "AdsAggregateCount: invalid handle"); - *pulCount = static_cast(it->second.values.size()); - return ok(); -} - -// ─ AdsAggregateValue ───────────────────────────────────────────────────────── -// ulIndex is 0-based. *pusType receives the result discriminator -// (0=empty, 1=numeric, 2=string); *pusLen is in/out (capacity / written). -UNSIGNED32 ENTRYPOINT AdsAggregateValue(ADSHANDLE hRes, UNSIGNED32 ulIndex, - UNSIGNED16* pusType, UNSIGNED8* pucBuf, - UNSIGNED16* pusLen) { - arc2_trace("AdsAggregateValue"); - if (pusType == nullptr || pucBuf == nullptr || pusLen == nullptr) - return fail(openads::AE_INTERNAL_ERROR, "AdsAggregateValue: null arg"); - std::lock_guard lk(agg_mu()); - auto it = agg_results().find(hRes); - if (it == agg_results().end()) - return fail(openads::AE_INTERNAL_ERROR, - "AdsAggregateValue: invalid handle"); - const auto& vals = it->second.values; - if (ulIndex >= static_cast(vals.size())) - return fail(openads::AE_INTERNAL_ERROR, - "AdsAggregateValue: index out of range"); - const auto& v = vals[ulIndex]; - *pusType = static_cast(v.type); - openads::abi::copy_to_caller(pucBuf, pusLen, v.bytes); - return ok(); -} - -// ─ AdsAggregateClose ───────────────────────────────────────────────────────── -UNSIGNED32 ENTRYPOINT AdsAggregateClose(ADSHANDLE hRes) { - arc2_trace("AdsAggregateClose"); - std::lock_guard lk(agg_mu()); - agg_results().erase(hRes); - return ok(); -} - -} // extern "C" -- AdsAggregate* export block - - - -// --------------------------------------------------------------------------- -// ARC32 (SAP Advantage Data Architect) ACE API gap. -// These power ARC's table browser and metadata panes. Local tables get -// real implementations; remote tables return sane defaults; DD / FTS / -// replication functions that are out of reach return AE_SUCCESS with -// zeroed outputs so ARC never crashes. -// --------------------------------------------------------------------------- - -extern "C" { - -// -- AdsGetDataLength -------------------------------------------------------- -UNSIGNED32 ENTRYPOINT AdsGetDataLength(ADSHANDLE hTable, - UNSIGNED8* pucFldName, - UNSIGNED32 /*ulOptions*/, - UNSIGNED32* pulLength) { - arc2_trace("AdsGetDataLength"); - if (pulLength == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *pulLength = 0; - if (auto* rt = get_remote_table(hTable)) { - auto i = remote_field_index(rt, pucFldName); - if (i == std::numeric_limits::max()) - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - *pulLength = rt->fields[i].length; - return ok(); - } - if (auto* ops = openads::abi::backend_table_ops_for(hTable)) - if (ops->field_length) - return ops->field_length(hTable, pucFldName, pulLength); - Table* t = get_table(hTable); - if (!t) return fail(openads::AE_INTERNAL_ERROR, "no table"); - std::uint16_t idx = 0; - if (!resolve_field_index_h(hTable, t, pucFldName, &idx)) - return fail(openads::AE_COLUMN_NOT_FOUND, ""); - const auto& fd = t->field_descriptor(idx); - using openads::drivers::DbfFieldType; - switch (fd.type) { - case DbfFieldType::Memo: - case DbfFieldType::Binary: { - UNSIGNED32 bufLen = 0; - UNSIGNED8 dummy = 0; - (void)AdsGetField(hTable, pucFldName, &dummy, &bufLen, 0); - *pulLength = bufLen; - break; - } - default: - *pulLength = fd.length; - break; - } - return ok(); -} - -// -- AdsGetBookmarkLength ---------------------------------------------------- -UNSIGNED32 ENTRYPOINT AdsGetBookmarkLength(ADSHANDLE /*hObj*/, - UNSIGNED32* pulLength) { - arc2_trace("AdsGetBookmarkLength"); - if (pulLength == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *pulLength = 4; - return ok(); -} - -// -- AdsCompareBookmarks ----------------------------------------------------- -UNSIGNED32 ENTRYPOINT AdsCompareBookmarks(UNSIGNED8* pucBookmark1, - UNSIGNED8* pucBookmark2, - SIGNED32* plResult) { - arc2_trace("AdsCompareBookmarks"); - if (plResult == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (pucBookmark1 == nullptr || pucBookmark2 == nullptr) - return fail(openads::AE_INTERNAL_ERROR, ""); - UNSIGNED32 r1 = static_cast(pucBookmark1[0]) - | (static_cast(pucBookmark1[1]) << 8) - | (static_cast(pucBookmark1[2]) << 16) - | (static_cast(pucBookmark1[3]) << 24); - UNSIGNED32 r2 = static_cast(pucBookmark2[0]) - | (static_cast(pucBookmark2[1]) << 8) - | (static_cast(pucBookmark2[2]) << 16) - | (static_cast(pucBookmark2[3]) << 24); - *plResult = (r1 < r2) ? -1 : (r1 > r2) ? 1 : 0; - return ok(); -} - -// -- AdsGotoBookmark (pre-60 ADSHANDLE version) ------------------------------ -UNSIGNED32 ENTRYPOINT AdsGotoBookmark(ADSHANDLE hTable, - ADSHANDLE hBookmark) { - arc2_trace("AdsGotoBookmark"); - return AdsGotoRecord(hTable, static_cast(hBookmark)); -} - -// -- AdsGetCollationLang ----------------------------------------------------- -UNSIGNED32 ENTRYPOINT AdsGetCollationLang(UNSIGNED8* pucLang, - UNSIGNED16* pusLen) { - arc2_trace("AdsGetCollationLang"); - if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - openads::abi::copy_to_caller(pucLang, pusLen, std::string("ENGLISH")); - return ok(); -} - -// -- AdsSetCollationLang ----------------------------------------------------- -UNSIGNED32 ENTRYPOINT AdsSetCollationLang(UNSIGNED8* /*pucLang*/) { - arc2_trace("AdsSetCollationLang"); - return ok(); -} - -// -- AdsGetCollation --------------------------------------------------------- -UNSIGNED32 ENTRYPOINT AdsGetCollation(ADSHANDLE /*hConnect*/, - UNSIGNED8* pucCollation, - UNSIGNED16* pusLen) { - arc2_trace("AdsGetCollation"); - if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - // SAP returns an empty string when no collation language was specified - // (ace_adsgettablecollation.htm). A bogus placeholder name ("ADS_COLLATION") - // breaks ARC's collation lookup and crashes its table grid. - openads::abi::copy_to_caller(pucCollation, pusLen, std::string()); - return ok(); -} - -// -- AdsGetTableCollation ---------------------------------------------------- -UNSIGNED32 ENTRYPOINT AdsGetTableCollation(ADSHANDLE /*hTbl*/, - UNSIGNED8* pucCollation, - UNSIGNED16* pusLen) { - arc2_trace("AdsGetTableCollation"); - if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - openads::abi::copy_to_caller(pucCollation, pusLen, std::string()); - arc2_log("COLLATION ret len=%u", pusLen ? (unsigned)*pusLen : 9999); - return ok(); -} - -// -- AdsGetIndexCollation ---------------------------------------------------- -UNSIGNED32 ENTRYPOINT AdsGetIndexCollation(ADSHANDLE /*hIndex*/, - UNSIGNED8* pucCollation, - UNSIGNED16* pusLen) { - arc2_trace("AdsGetIndexCollation"); - if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - openads::abi::copy_to_caller(pucCollation, pusLen, std::string()); - return ok(); -} - -// NOTE: AdsSetCollation is already defined at line 19123 of this file. -// Do NOT redefine it here. - -// -- AdsGetHandleLong / AdsSetHandleLong ------------------------------------- -static std::unordered_map& handle_long_map() { - static std::unordered_map m; - return m; -} - -UNSIGNED32 ENTRYPOINT AdsGetHandleLong(ADSHANDLE hObj, UNSIGNED32* pulVal) { - arc2_trace("AdsGetHandleLong"); - if (pulVal == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - auto& m = handle_long_map(); - auto it = m.find(hObj); - *pulVal = (it != m.end()) ? it->second : 0; - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsSetHandleLong(ADSHANDLE hObj, UNSIGNED32 ulVal) { - arc2_trace("AdsSetHandleLong"); - handle_long_map()[hObj] = ulVal; - return ok(); -} - -// -- AdsGetIntProperty ------------------------------------------------------- -UNSIGNED32 ENTRYPOINT AdsGetIntProperty(ADSHANDLE /*hObj*/, - UNSIGNED32 /*ulPropertyID*/, - UNSIGNED32* pulProperty) { - arc2_trace("AdsGetIntProperty"); - if (pulProperty == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *pulProperty = 0; - return ok(); -} - -// -- AdsGetConnectionPath ---------------------------------------------------- -UNSIGNED32 ENTRYPOINT AdsGetConnectionPath(ADSHANDLE hConnect, - UNSIGNED8* pucConnectionPath, - UNSIGNED16* pusLen) { - arc2_trace("AdsGetConnectionPath"); - if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (Connection* c = lookup_connection(hConnect)) { - openads::abi::copy_to_caller(pucConnectionPath, pusLen, - c->data_dir()); - return ok(); - } - openads::abi::copy_to_caller(pucConnectionPath, pusLen, std::string("")); - return ok(); -} - -// -- AdsGetConnectionProperty ------------------------------------------------ -UNSIGNED32 ENTRYPOINT AdsGetConnectionProperty(ADSHANDLE /*hConnect*/, - UNSIGNED16 /*usPropertyID*/, - void* pvProperty, - UNSIGNED32* pulPropertyLen) { - arc2_trace("AdsGetConnectionProperty"); - if (pulPropertyLen == nullptr) - return fail(openads::AE_INTERNAL_ERROR, ""); - if (pvProperty) memset(pvProperty, 0, *pulPropertyLen); - *pulPropertyLen = 0; - return ok(); -} - -// -- AdsGetTransactionCount -------------------------------------------------- -UNSIGNED32 ENTRYPOINT AdsGetTransactionCount( - ADSHANDLE /*hConnect*/, UNSIGNED32* pulTransactionCount) { - arc2_trace("AdsGetTransactionCount"); - if (pulTransactionCount == nullptr) - return fail(openads::AE_INTERNAL_ERROR, ""); - *pulTransactionCount = 0; - return ok(); -} - -// -- AdsGetSQLStatement ------------------------------------------------------ -UNSIGNED32 ENTRYPOINT AdsGetSQLStatement(ADSHANDLE /*hStmt*/, - UNSIGNED8* pucSQL, - UNSIGNED16* pusLen) { - arc2_trace("AdsGetSQLStatement"); - if (pusLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - openads::abi::copy_to_caller(pucSQL, pusLen, std::string("")); - return ok(); -} - -// -- AdsGetSQLStatementHandle ------------------------------------------------ -UNSIGNED32 ENTRYPOINT AdsGetSQLStatementHandle(ADSHANDLE /*hCursor*/, - ADSHANDLE* phStmt) { - arc2_trace("AdsGetSQLStatementHandle"); - if (phStmt == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *phStmt = 0; - return ok(); -} - -// -- AdsNullTerminateStrings ------------------------------------------------- -UNSIGNED32 ENTRYPOINT AdsNullTerminateStrings( - UNSIGNED16 /*bNullTerminate*/) { - arc2_trace("AdsNullTerminateStrings"); - return ok(); -} - -// -- AdsGetShort ------------------------------------------------------------- -UNSIGNED32 ENTRYPOINT AdsGetShort(ADSHANDLE hTable, UNSIGNED8* pucFldName, - int16_t* psValue) { - arc2_trace("AdsGetShort"); - if (psValue == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *psValue = 0; - SIGNED32 lVal = 0; - UNSIGNED32 rc = AdsGetLong(hTable, pucFldName, &lVal); - if (rc != ok()) return rc; - *psValue = static_cast(lVal); - return ok(); -} - -// -- AdsGetTime -------------------------------------------------------------- -UNSIGNED32 ENTRYPOINT AdsGetTime(ADSHANDLE hTable, UNSIGNED8* pucFldName, - UNSIGNED8* pucBuf, UNSIGNED16* pusLen) { - arc2_trace("AdsGetTime"); - UNSIGNED32 ulLen = (pusLen != nullptr) ? *pusLen : 0; - return AdsGetString(hTable, pucFldName, pucBuf, &ulLen, 0); -} - -// -- AdsGetMoney ------------------------------------------------------------- -UNSIGNED32 ENTRYPOINT AdsGetMoney(ADSHANDLE hTable, UNSIGNED8* pucFldName, - SIGNED64* pqValue) { - arc2_trace("AdsGetMoney"); - if (pqValue == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *pqValue = 0; - double dVal = 0; - UNSIGNED32 rc = AdsGetDouble(hTable, pucFldName, &dVal); - if (rc != ok()) return rc; - *pqValue = static_cast(dVal * 10000.0); - return ok(); -} - -// -- AdsIsFieldBinary -------------------------------------------------------- -UNSIGNED32 ENTRYPOINT AdsIsFieldBinary(ADSHANDLE hTable, - UNSIGNED8* pucFldName, - UNSIGNED16* pbBinary) { - arc2_trace("AdsIsFieldBinary"); - if (pbBinary == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *pbBinary = 0; - UNSIGNED16 usType = 0; - UNSIGNED32 rc = AdsGetFieldType(hTable, pucFldName, &usType); - if (rc != ok()) return rc; - if (usType == ADS_BINARY || usType == ADS_IMAGE || usType == ADS_MEMO) - *pbBinary = 1; - return ok(); -} - -// -- AdsGetTableMemoSize ----------------------------------------------------- -UNSIGNED32 ENTRYPOINT AdsGetTableMemoSize(ADSHANDLE /*hTable*/, - UNSIGNED16* pusMemoSize) { - arc2_trace("AdsGetTableMemoSize"); - if (pusMemoSize == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *pusMemoSize = 0; - return ok(); -} - -// -- AdsGetKeyColumn --------------------------------------------------------- -UNSIGNED32 ENTRYPOINT AdsGetKeyColumn(ADSHANDLE /*hCursor*/, - UNSIGNED16* pusKeyColumn, - UNSIGNED32 /*ulReserved*/) { - arc2_trace("AdsGetKeyColumn"); - if (pusKeyColumn == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *pusKeyColumn = 0; - return ok(); -} - -// -- AdsLocate --------------------------------------------------------------- -UNSIGNED32 ENTRYPOINT AdsLocate(ADSHANDLE hTable, UNSIGNED8* pucExpr, - UNSIGNED16 /*bForward*/, - UNSIGNED16* pbFound) { - arc2_trace("AdsLocate"); - if (pbFound == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *pbFound = 0; - UNSIGNED16 result = 0; - UNSIGNED32 rc = AdsEvalLogicalExpr(hTable, pucExpr, &result); - if (rc != ok()) return rc; - *pbFound = result; - return ok(); -} - -// -- AdsLookupKey ------------------------------------------------------------ -UNSIGNED32 ENTRYPOINT AdsLookupKey(ADSHANDLE hIndex, UNSIGNED8* pucKey, - UNSIGNED16 usKeyLen, UNSIGNED16 usDataType, - UNSIGNED16* pbFound) { - arc2_trace("AdsLookupKey"); - if (pbFound == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *pbFound = 0; - UNSIGNED16 usFound = 0; - UNSIGNED32 rc = AdsSeek(hIndex, pucKey, usKeyLen, usDataType, 0, &usFound); - if (rc != ok()) return rc; - *pbFound = usFound; - return ok(); -} - -// -- AdsBuildRawKey / AdsBuildRawKey100 -------------------------------------- -UNSIGNED32 ENTRYPOINT AdsBuildRawKey(ADSHANDLE /*hIndex*/, - UNSIGNED8* pucKey, - UNSIGNED16* pusKeyLen) { - arc2_trace("AdsBuildRawKey"); - if (pucKey == nullptr || pusKeyLen == nullptr) - return fail(openads::AE_INTERNAL_ERROR, ""); - memset(pucKey, 0, *pusKeyLen); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsBuildRawKey100(ADSHANDLE /*hIndex*/, - UNSIGNED8* pucKey, - UNSIGNED16* pusKeyLen, - UNSIGNED32 /*ulOptions*/) { - arc2_trace("AdsBuildRawKey100"); - return AdsBuildRawKey(0, pucKey, pusKeyLen); -} - -// -- AdsIsIndexCompound ------------------------------------------------------ -UNSIGNED32 ENTRYPOINT AdsIsIndexCompound(ADSHANDLE /*hIndex*/, - UNSIGNED16* pbCompound) { - arc2_trace("AdsIsIndexCompound"); - if (pbCompound == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *pbCompound = 0; - return ok(); -} - -// -- AdsIsIndexCandidate ----------------------------------------------------- -UNSIGNED32 ENTRYPOINT AdsIsIndexCandidate(ADSHANDLE /*hIndex*/, - UNSIGNED16* pbCandidate) { - arc2_trace("AdsIsIndexCandidate"); - if (pbCandidate == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *pbCandidate = 0; - return ok(); -} - -// -- AdsIsIndexPrimaryKey ---------------------------------------------------- -UNSIGNED32 ENTRYPOINT AdsIsIndexPrimaryKey(ADSHANDLE /*hIndex*/, - UNSIGNED16* pbPrimaryKey) { - arc2_trace("AdsIsIndexPrimaryKey"); - if (pbPrimaryKey == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *pbPrimaryKey = 0; - return ok(); -} - -// -- AdsIsIndexNullable ------------------------------------------------------ -UNSIGNED32 ENTRYPOINT AdsIsIndexNullable(ADSHANDLE /*hIndex*/, - UNSIGNED16* pbNullable) { - arc2_trace("AdsIsIndexNullable"); - if (pbNullable == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *pbNullable = 0; - return ok(); -} - -// -- AdsIsIndexUserDefined --------------------------------------------------- -UNSIGNED32 ENTRYPOINT AdsIsIndexUserDefined(ADSHANDLE /*hIndex*/, - UNSIGNED16* pbUserDefined) { - arc2_trace("AdsIsIndexUserDefined"); - if (pbUserDefined == nullptr) - return fail(openads::AE_INTERNAL_ERROR, ""); - *pbUserDefined = 0; - return ok(); -} - -// -- AdsGetNumFTSIndexes ----------------------------------------------------- -UNSIGNED32 ENTRYPOINT AdsGetNumFTSIndexes(ADSHANDLE /*hTable*/, - UNSIGNED16* pusNum) { - arc2_trace("AdsGetNumFTSIndexes"); - if (pusNum == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *pusNum = 0; - return ok(); -} - -// -- AdsGetIndexHandleByExpr ------------------------------------------------- -UNSIGNED32 ENTRYPOINT AdsGetIndexHandleByExpr(ADSHANDLE /*hTable*/, - UNSIGNED8* /*pucExpr*/, - UNSIGNED32 /*ulDescending*/, - ADSHANDLE* phIndex) { - arc2_trace("AdsGetIndexHandleByExpr"); - if (phIndex == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *phIndex = 0; - return fail(openads::AE_FUNCTION_NOT_AVAILABLE, ""); -} - -// -- AdsGetTableRights ------------------------------------------------------- -UNSIGNED32 ENTRYPOINT AdsGetTableRights(ADSHANDLE /*hTable*/, - UNSIGNED16* pusRights) { - arc2_trace("AdsGetTableRights"); - if (pusRights == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *pusRights = 0x000F; - return ok(); -} - -// -- AdsVerifyPassword ------------------------------------------------------- -UNSIGNED32 ENTRYPOINT AdsVerifyPassword(ADSHANDLE /*hTable*/, - UNSIGNED16* pusEnabled) { - arc2_trace("AdsVerifyPassword"); - if (pusEnabled == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *pusEnabled = 0; - return ok(); -} - -// -- AdsGetActiveLinkInfo ---------------------------------------------------- -UNSIGNED32 ENTRYPOINT AdsGetActiveLinkInfo(ADSHANDLE /*hDBConn*/, - UNSIGNED16 /*usLinkNum*/, - UNSIGNED8* pucLinkInfo, - UNSIGNED16* pusBufferLen) { - arc2_trace("AdsGetActiveLinkInfo"); - if (pusBufferLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - openads::abi::copy_to_caller(pucLinkInfo, pusBufferLen, std::string("")); - return ok(); -} - -// -- AdsRegisterUDF ---------------------------------------------------------- -static std::unordered_map& udf_map() { - static std::unordered_map m; - return m; -} - -UNSIGNED32 ENTRYPOINT AdsRegisterUDF(ADSHANDLE hObj, UNSIGNED16 /*usType*/, - void* lpfnUDF) { - arc2_trace("AdsRegisterUDF"); - udf_map()[hObj] = lpfnUDF; - return ok(); -} - -// -- AdsStmtConstrainUpdates ------------------------------------------------- -UNSIGNED32 ENTRYPOINT AdsStmtConstrainUpdates(ADSHANDLE /*hStatement*/, - UNSIGNED16 /*usConstrain*/) { - arc2_trace("AdsStmtConstrainUpdates"); - return ok(); -} - -// -- AdsStmtReadAllColumns --------------------------------------------------- -UNSIGNED32 ENTRYPOINT AdsStmtReadAllColumns(ADSHANDLE /*hStatement*/, - UNSIGNED16 /*usReadColumns*/) { - arc2_trace("AdsStmtReadAllColumns"); - return ok(); -} - -// -- AdsStmtEnableEncryption ------------------------------------------------- -UNSIGNED32 ENTRYPOINT AdsStmtEnableEncryption(ADSHANDLE /*hStatement*/, - UNSIGNED8* /*pucPassword*/) { - arc2_trace("AdsStmtEnableEncryption"); - return ok(); -} - -// -- AdsRegisterSQLAbortFunc ------------------------------------------------- -static void* g_sql_abort_func = nullptr; - -UNSIGNED32 ENTRYPOINT AdsRegisterSQLAbortFunc(void* lpfnCallback) { - arc2_trace("AdsRegisterSQLAbortFunc"); - g_sql_abort_func = lpfnCallback; - return ok(); -} - -// -- AdsReapUnusedConnections ------------------------------------------------ -UNSIGNED32 ENTRYPOINT AdsReapUnusedConnections(void) { - arc2_trace("AdsReapUnusedConnections"); - return ok(); -} - -// -- AdsFindServers ---------------------------------------------------------- -UNSIGNED32 ENTRYPOINT AdsFindServers(UNSIGNED32 /*ulOptions*/, - ADSHANDLE* phTable) { - arc2_trace("AdsFindServers"); - if (phTable == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *phTable = 0; - return fail(openads::AE_FUNCTION_NOT_AVAILABLE, ""); -} - -// -- AdsEvalLogicalExprW ----------------------------------------------------- -UNSIGNED32 ENTRYPOINT AdsEvalLogicalExprW(ADSHANDLE /*hTable*/, - void* /*pwcExpr*/, - UNSIGNED16* pbResult) { - arc2_trace("AdsEvalLogicalExprW"); - if (pbResult == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *pbResult = 0; - return ok(); -} - -// -- AdsCachePrepareSQL ------------------------------------------------------ -UNSIGNED32 ENTRYPOINT AdsCachePrepareSQL(ADSHANDLE hConnect, - UNSIGNED8* pucSQL, - ADSHANDLE* phStatement) { - arc2_trace("AdsCachePrepareSQL"); - if (phStatement == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *phStatement = 0; - UNSIGNED32 rc = AdsCreateSQLStatement(hConnect, phStatement); - if (rc != ok()) return rc; - return AdsPrepareSQL(*phStatement, pucSQL); -} - -// -- AdsCachePrepareSQLW ----------------------------------------------------- -UNSIGNED32 ENTRYPOINT AdsCachePrepareSQLW(ADSHANDLE hConnect, - void* pwcSQL, - ADSHANDLE* phStatement) { - arc2_trace("AdsCachePrepareSQLW"); - if (phStatement == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - *phStatement = 0; - UNSIGNED32 rc = AdsCreateSQLStatement(hConnect, phStatement); - if (rc != ok()) return rc; - return AdsPrepareSQLW(*phStatement, - reinterpret_cast(pwcSQL)); -} - -// -- AdsClearCachePool ------------------------------------------------------- -UNSIGNED32 ENTRYPOINT AdsClearCachePool(UNSIGNED8* /*pucConnectString*/) { - arc2_trace("AdsClearCachePool"); - return ok(); -} - -// -- AdsGetFTSIndexInfo ------------------------------------------------------ -UNSIGNED32 ENTRYPOINT AdsGetFTSIndexInfo(ADSHANDLE /*hIndex*/, - UNSIGNED8* pucOutput, - UNSIGNED32* pulBufLen, - UNSIGNED8** /*ppucField*/, - UNSIGNED32* /*pulMinWordLen*/, - UNSIGNED32* /*pulMaxWordLen*/, - UNSIGNED8** /*ppucDelimiters*/, - UNSIGNED8** /*ppucNoiseWords*/, - UNSIGNED8** /*ppucDropChars*/, - UNSIGNED8** /*ppucCondChars*/, - UNSIGNED8** /*ppucReserved1*/, - UNSIGNED8** /*ppucReserved2*/, - UNSIGNED32* /*pulOptions*/) { - arc2_trace("AdsGetFTSIndexInfo"); - if (pulBufLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (pucOutput) memset(pucOutput, 0, *pulBufLen); - *pulBufLen = 0; - return ok(); -} - -// -- AdsGetFTSIndexInfoW ----------------------------------------------------- -UNSIGNED32 ENTRYPOINT AdsGetFTSIndexInfoW(ADSHANDLE /*hIndex*/, - void* pwcOutput, - UNSIGNED32* pulBufLen, - void** /*ppwcField*/, - UNSIGNED32* /*pulMinWordLen*/, - UNSIGNED32* /*pulMaxWordLen*/, - void** /*ppwcDelimiters*/, - void** /*ppwcNoiseWords*/, - void** /*ppwcDropChars*/, - void** /*ppwcCondChars*/, - void** /*ppwcReserved1*/, - void** /*ppwcReserved2*/, - UNSIGNED32* /*pulOptions*/) { - arc2_trace("AdsGetFTSIndexInfoW"); - if (pulBufLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - if (pwcOutput) memset(pwcOutput, 0, *pulBufLen); - *pulBufLen = 0; - return ok(); -} - -// -- DD stubs ---------------------------------------------------------------- -// Data dictionary functions called by ARC when working with DD. -// We don't support DD in the free-table browsing path, so return -// AE_SUCCESS with zeroed outputs. - -UNSIGNED32 ENTRYPOINT AdsDDAddProcedure100(ADSHANDLE, UNSIGNED8*, UNSIGNED8*, - UNSIGNED8*, UNSIGNED16, UNSIGNED8*, UNSIGNED32, UNSIGNED32, - UNSIGNED32) { - arc2_trace("AdsDDAddProcedure100"); return ok(); } - -UNSIGNED32 ENTRYPOINT AdsDDAddView100(ADSHANDLE, UNSIGNED8*, UNSIGNED8*, - UNSIGNED32) { - arc2_trace("AdsDDAddView100"); return ok(); } - -UNSIGNED32 ENTRYPOINT AdsDDCreate101(ADSHANDLE*, UNSIGNED8*, UNSIGNED8*, - UNSIGNED8*, UNSIGNED16, UNSIGNED16, UNSIGNED16, - UNSIGNED8*) { - arc2_trace("AdsDDCreate101"); return ok(); } - -UNSIGNED32 ENTRYPOINT AdsDDCreateArticle(ADSHANDLE, UNSIGNED8*, UNSIGNED8*, - UNSIGNED8*, UNSIGNED8*) { - arc2_trace("AdsDDCreateArticle"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDCreateArticle100(ADSHANDLE, UNSIGNED8*, UNSIGNED8*, - UNSIGNED8*, UNSIGNED8*, UNSIGNED32) { - arc2_trace("AdsDDCreateArticle100"); return ok(); } - -UNSIGNED32 ENTRYPOINT AdsDDCreateTrigger100(ADSHANDLE, UNSIGNED8*, UNSIGNED8*, - UNSIGNED8*, UNSIGNED8*, UNSIGNED16, UNSIGNED32, UNSIGNED32, - UNSIGNED8*) { - arc2_trace("AdsDDCreateTrigger100"); return ok(); } - -UNSIGNED32 ENTRYPOINT AdsDDCreateUserGroup(ADSHANDLE, UNSIGNED8*, - UNSIGNED8*) { - arc2_trace("AdsDDCreateUserGroup"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDDeleteUserGroup(ADSHANDLE, - UNSIGNED8*) { - arc2_trace("AdsDDDeleteUserGroup"); return ok(); } - -UNSIGNED32 ENTRYPOINT AdsDDDeleteArticle(ADSHANDLE, UNSIGNED8*, - UNSIGNED8*) { - arc2_trace("AdsDDDeleteArticle"); return ok(); } - -UNSIGNED32 ENTRYPOINT AdsDDDeletePublication(ADSHANDLE, UNSIGNED8*) { - arc2_trace("AdsDDDeletePublication"); return ok(); } - -// -- Additional stubs for export gap - -UNSIGNED32 ENTRYPOINT AdsDDDeployDatabase(ADSHANDLE, UNSIGNED8*, - UNSIGNED8*) { - arc2_trace("AdsDDDeployDatabase"); return ok(); } - -UNSIGNED32 ENTRYPOINT AdsDDGetArticleProperty(ADSHANDLE, UNSIGNED8*, - UNSIGNED8*, UNSIGNED16, void*, UNSIGNED32*) { - arc2_trace("AdsDDGetArticleProperty"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDGetIndexFileProperty(ADSHANDLE, UNSIGNED8*, - UNSIGNED8*, UNSIGNED16, void*, UNSIGNED32*) { - arc2_trace("AdsDDGetIndexFileProperty"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDGetLinkProperty(ADSHANDLE, UNSIGNED8*, - UNSIGNED16, void*, UNSIGNED32*) { - arc2_trace("AdsDDGetLinkProperty"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDGetPublicationProperty(ADSHANDLE, UNSIGNED8*, - UNSIGNED16, void*, UNSIGNED32*) { - arc2_trace("AdsDDGetPublicationProperty"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDGetSubscriptionProperty(ADSHANDLE, UNSIGNED8*, - UNSIGNED16, void*, UNSIGNED32*) { - arc2_trace("AdsDDGetSubscriptionProperty"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDGetUserGroupProperty(ADSHANDLE, UNSIGNED8*, - UNSIGNED16, void*, UNSIGNED32*) { - arc2_trace("AdsDDGetUserGroupProperty"); return ok(); } - -UNSIGNED32 ENTRYPOINT AdsDDSetArticleProperty(ADSHANDLE, UNSIGNED8*, - UNSIGNED16, void*, UNSIGNED32, UNSIGNED32) { - arc2_trace("AdsDDSetArticleProperty"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDSetPublicationProperty(ADSHANDLE, UNSIGNED8*, - UNSIGNED16, void*, UNSIGNED32) { - arc2_trace("AdsDDSetPublicationProperty"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDSetSubscriptionProperty(ADSHANDLE, UNSIGNED8*, - UNSIGNED16, void*, UNSIGNED32) { - arc2_trace("AdsDDSetSubscriptionProperty"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDSetUserGroupProperty(ADSHANDLE, UNSIGNED8*, - UNSIGNED16, void*, UNSIGNED32) { - arc2_trace("AdsDDSetUserGroupProperty"); return ok(); } - -UNSIGNED32 ENTRYPOINT AdsDDDeleteSubscription(ADSHANDLE, UNSIGNED8*) { - arc2_trace("AdsDDDeleteSubscription"); return ok(); } - -UNSIGNED32 ENTRYPOINT AdsDDCreateSubscription(ADSHANDLE, UNSIGNED8*, UNSIGNED8*, - UNSIGNED8*, UNSIGNED8*, UNSIGNED8*, UNSIGNED8*, UNSIGNED8*, - UNSIGNED32, UNSIGNED32) { - arc2_trace("AdsDDCreateSubscription"); return ok(); } - - -// -- Additional stubs for export gap - - -UNSIGNED32 ENTRYPOINT AdsActivateAOF(ADSHANDLE, UNSIGNED8*, UNSIGNED32, UNSIGNED32) { - arc2_trace("AdsActivateAOF"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsAddToAOF(ADSHANDLE, UNSIGNED8*, UNSIGNED32, UNSIGNED32) { - arc2_trace("AdsAddToAOF"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsBuildKeyFromRecord(ADSHANDLE, UNSIGNED8*) { - arc2_trace("AdsBuildKeyFromRecord"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsClearCursorAOF(ADSHANDLE, UNSIGNED8*, UNSIGNED32) { - arc2_trace("AdsClearCursorAOF"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsClearLastError(void) { - arc2_trace("AdsClearLastError"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsClearRecordBuffer(ADSHANDLE, UNSIGNED8*, UNSIGNED32) { - arc2_trace("AdsClearRecordBuffer"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsConvertStringToMilliseconds(ADSHANDLE, UNSIGNED8*, UNSIGNED32, UNSIGNED32) { - arc2_trace("AdsConvertStringToMilliseconds"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsConvertUnicodeToCodePage(ADSHANDLE, UNSIGNED8*) { - arc2_trace("AdsConvertUnicodeToCodePage"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsCopyTableStructure81(ADSHANDLE, UNSIGNED8*, UNSIGNED32, UNSIGNED32, UNSIGNED32, UNSIGNED32, UNSIGNED32, UNSIGNED32) { - arc2_trace("AdsCopyTableStructure81"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsCopyTableTop(ADSHANDLE) { - arc2_trace("AdsCopyTableTop"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsGetColumnPermissions(ADSHANDLE, UNSIGNED8*, UNSIGNED32) { - arc2_trace("AdsGetColumnPermissions"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsGetLibraryVersion(ADSHANDLE) { - arc2_trace("AdsGetLibraryVersion"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsSetInternalError(ADSHANDLE, UNSIGNED8*, UNSIGNED32, UNSIGNED32) { - arc2_trace("AdsSetInternalError"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsSetLastError(ADSHANDLE, UNSIGNED8*, UNSIGNED32, UNSIGNED32) { - arc2_trace("AdsSetLastError"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsSetStringFromCodePage(ADSHANDLE, UNSIGNED8*) { - arc2_trace("AdsSetStringFromCodePage"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsSetupRI(ADSHANDLE, UNSIGNED8*, UNSIGNED32, UNSIGNED32, UNSIGNED32, UNSIGNED32, UNSIGNED32, UNSIGNED32, UNSIGNED32, UNSIGNED32, UNSIGNED32, UNSIGNED32) { - arc2_trace("AdsSetupRI"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsSqlPeekStatement(ADSHANDLE, UNSIGNED8*, UNSIGNED32) { - arc2_trace("AdsSqlPeekStatement"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsStepIndexKey(ADSHANDLE, UNSIGNED8*, UNSIGNED32, UNSIGNED32) { - arc2_trace("AdsStepIndexKey"); return ok(); } - -// -- Additional stubs for export gap (v1.8.71) -UNSIGNED32 ENTRYPOINT AdsAccessVfpSystemField() { - arc2_trace("AdsAccessVfpSystemField"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsAreTriggersEnabled() { - arc2_trace("AdsAreTriggersEnabled"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsCloseCachedTrigStatements() { - arc2_trace("AdsCloseCachedTrigStatements"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsCloseFile() { - arc2_trace("AdsCloseFile"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsConvertCodePageToUnicode() { - arc2_trace("AdsConvertCodePageToUnicode"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsConvertDateToJulian() { - arc2_trace("AdsConvertDateToJulian"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsConvertJulianToDate() { - arc2_trace("AdsConvertJulianToDate"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsConvertJulianToString() { - arc2_trace("AdsConvertJulianToString"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsConvertKeyToDouble() { - arc2_trace("AdsConvertKeyToDouble"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsConvertMillisecondsToString() { - arc2_trace("AdsConvertMillisecondsToString"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsConvertStringToJulian() { - arc2_trace("AdsConvertStringToJulian"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsCopyTableTop100() { - arc2_trace("AdsCopyTableTop100"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsCreateCriticalSection() { - arc2_trace("AdsCreateCriticalSection"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsCreateMemTable() { - arc2_trace("AdsCreateMemTable"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsCreateMemTable90() { - arc2_trace("AdsCreateMemTable90"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDBFDateToString() { - arc2_trace("AdsDBFDateToString"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDAutoCreateIndex() { - arc2_trace("AdsDDAutoCreateIndex"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDAutoCreateTable() { - arc2_trace("AdsDDAutoCreateTable"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDClose() { - arc2_trace("AdsDDClose"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDCreateASA() { - arc2_trace("AdsDDCreateASA"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDCreateASA101() { - arc2_trace("AdsDDCreateASA101"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDCreateFunction100() { - arc2_trace("AdsDDCreateFunction100"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDCreatePackage() { - arc2_trace("AdsDDCreatePackage"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDCreatePublication() { - arc2_trace("AdsDDCreatePublication"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDDeleteIndex() { - arc2_trace("AdsDDDeleteIndex"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDDisableTriggers() { - arc2_trace("AdsDDDisableTriggers"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDDropPackage() { - arc2_trace("AdsDDDropPackage"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDEnableTriggers() { - arc2_trace("AdsDDEnableTriggers"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDExecuteProcedure() { - arc2_trace("AdsDDExecuteProcedure"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDFindFirst() { - arc2_trace("AdsDDFindFirst"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDFreeTable() { - arc2_trace("AdsDDFreeTable"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDGetObjectProperty() { - arc2_trace("AdsDDGetObjectProperty"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDGetObjectProperty100() { - arc2_trace("AdsDDGetObjectProperty100"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDGetProcInterfaceVersion() { - arc2_trace("AdsDDGetProcInterfaceVersion"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDGetTableOpenOptions() { - arc2_trace("AdsDDGetTableOpenOptions"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDMoveObjectFile() { - arc2_trace("AdsDDMoveObjectFile"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDOpen() { - arc2_trace("AdsDDOpen"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDRenameObject() { - arc2_trace("AdsDDRenameObject"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDSetActiveDictionary() { - arc2_trace("AdsDDSetActiveDictionary"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDSetObjectAccessRights() { - arc2_trace("AdsDDSetObjectAccessRights"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDSetObjectProperty() { - arc2_trace("AdsDDSetObjectProperty"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDSetObjectProperty100() { - arc2_trace("AdsDDSetObjectProperty100"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDDVerifyUserRights() { - arc2_trace("AdsDDVerifyUserRights"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDeactivateAOF() { - arc2_trace("AdsDeactivateAOF"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsDeleteTable() { - arc2_trace("AdsDeleteTable"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsEcho() { - arc2_trace("AdsEcho"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsEvalExpr() { - arc2_trace("AdsEvalExpr"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsExpressionLongToShort() { - arc2_trace("AdsExpressionLongToShort"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsExpressionLongToShort100() { - arc2_trace("AdsExpressionLongToShort100"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsExpressionLongToShort90() { - arc2_trace("AdsExpressionLongToShort90"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsExpressionShortToLong() { - arc2_trace("AdsExpressionShortToLong"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsExpressionShortToLong90() { - arc2_trace("AdsExpressionShortToLong90"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsExtractPathPart() { - arc2_trace("AdsExtractPathPart"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsFreeExpr() { - arc2_trace("AdsFreeExpr"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsFreeTokenBuffer() { - arc2_trace("AdsFreeTokenBuffer"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsGetBaseFieldName() { - arc2_trace("AdsGetBaseFieldName"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsGetBaseFieldNum() { - arc2_trace("AdsGetBaseFieldNum"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsGetConnectionHandle() { - arc2_trace("AdsGetConnectionHandle"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsGetCursorAOF() { - arc2_trace("AdsGetCursorAOF"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsGetDeleteStatementW() { - arc2_trace("AdsGetDeleteStatementW"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsGetFieldCreationString() { - arc2_trace("AdsGetFieldCreationString"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsGetFilePosition() { - arc2_trace("AdsGetFilePosition"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsGetFTSScore() { - arc2_trace("AdsGetFTSScore"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsGetIndexFlags() { - arc2_trace("AdsGetIndexFlags"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsGetIndexInfo() { - arc2_trace("AdsGetIndexInfo"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsGetIndexPageSize() { - arc2_trace("AdsGetIndexPageSize"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsGetInsertStatementW() { - arc2_trace("AdsGetInsertStatementW"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsGetKeyFilter() { - arc2_trace("AdsGetKeyFilter"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsGetMergeStatementW() { - arc2_trace("AdsGetMergeStatementW"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsGetNullRecord() { - arc2_trace("AdsGetNullRecord"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsGetNumSegments() { - arc2_trace("AdsGetNumSegments"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsGetPreparedFields() { - arc2_trace("AdsGetPreparedFields"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsGetRecordPointer() { - arc2_trace("AdsGetRecordPointer"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsGetRILookupInfo() { - arc2_trace("AdsGetRILookupInfo"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsGetROWIDPrefix() { - arc2_trace("AdsGetROWIDPrefix"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsGetSegmentFieldname() { - arc2_trace("AdsGetSegmentFieldname"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsGetSegmentFieldNumbers() { - arc2_trace("AdsGetSegmentFieldNumbers"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsGetSegmentOffset() { - arc2_trace("AdsGetSegmentOffset"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsGetSelectStatementW() { - arc2_trace("AdsGetSelectStatementW"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsGetSQLStmtParams() { - arc2_trace("AdsGetSQLStmtParams"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsGetTableCreationString() { - arc2_trace("AdsGetTableCreationString"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsGetTableWAN() { - arc2_trace("AdsGetTableWAN"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsGetUpdateStatementW() { - arc2_trace("AdsGetUpdateStatementW"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsImageToClipboard() { - arc2_trace("AdsImageToClipboard"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsImageToHBitmap() { - arc2_trace("AdsImageToHBitmap"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsInitTokenBuffer() { - arc2_trace("AdsInitTokenBuffer"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsIsIndexExprValid() { - arc2_trace("AdsIsIndexExprValid"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsIsIndexFTS() { - arc2_trace("AdsIsIndexFTS"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsIsSegmentDescending() { - arc2_trace("AdsIsSegmentDescending"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsLockRecordImplicitly() { - arc2_trace("AdsLockRecordImplicitly"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsMakeNameUnique() { - arc2_trace("AdsMakeNameUnique"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsMemCompare() { - arc2_trace("AdsMemCompare"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsMemCompare90() { - arc2_trace("AdsMemCompare90"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsMemICompare() { - arc2_trace("AdsMemICompare"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsMemICompare90() { - arc2_trace("AdsMemICompare90"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsMemLwr() { - arc2_trace("AdsMemLwr"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsMemLwr90() { - arc2_trace("AdsMemLwr90"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsMergeAOF() { - arc2_trace("AdsMergeAOF"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsOpenFileRaw() { - arc2_trace("AdsOpenFileRaw"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsParseExpr() { - arc2_trace("AdsParseExpr"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsParseExpr100() { - arc2_trace("AdsParseExpr100"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsPerformRI() { - arc2_trace("AdsPerformRI"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsPrepareSQLNow() { - arc2_trace("AdsPrepareSQLNow"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsPrepareSQLNowW() { - arc2_trace("AdsPrepareSQLNowW"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsPullTrigger() { - arc2_trace("AdsPullTrigger"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsReadFile() { - arc2_trace("AdsReadFile"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsReadRecordNumbers() { - arc2_trace("AdsReadRecordNumbers"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsReadRecords() { - arc2_trace("AdsReadRecords"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsRefreshView() { - arc2_trace("AdsRefreshView"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsReindexFTS() { - arc2_trace("AdsReindexFTS"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsReleaseObject() { - arc2_trace("AdsReleaseObject"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsRemoveSQLComments() { - arc2_trace("AdsRemoveSQLComments"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsSeekInFile() { - arc2_trace("AdsSeekInFile"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsSetBaseTableAccess() { - arc2_trace("AdsSetBaseTableAccess"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsSetBOFFlag() { - arc2_trace("AdsSetBOFFlag"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsSetCollationSequence() { - arc2_trace("AdsSetCollationSequence"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsSetCursorAOF() { - arc2_trace("AdsSetCursorAOF"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsSetFlushFlag() { - arc2_trace("AdsSetFlushFlag"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsSetPacketSize() { - arc2_trace("AdsSetPacketSize"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsSetTableCharType() { - arc2_trace("AdsSetTableCharType"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsSleep() { - arc2_trace("AdsSleep"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsStmtGetCursorHandle() { - arc2_trace("AdsStmtGetCursorHandle"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsStmtGetNumParams() { - arc2_trace("AdsStmtGetNumParams"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsStmtSetOptimization() { - arc2_trace("AdsStmtSetOptimization"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsVerifyRI() { - arc2_trace("AdsVerifyRI"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsWaitForObject() { - arc2_trace("AdsWaitForObject"); return ok(); } -UNSIGNED32 ENTRYPOINT AdsWriteMiniDump() { - arc2_trace("AdsWriteMiniDump"); return ok(); } - -// M12.32 — Distributed mutex service (server-wide named mutexes). -// These functions only work over a remote connection (ADS_REMOTE_SERVER). -// For local connections they return AE_FUNCTION_NOT_AVAILABLE. - -UNSIGNED32 ENTRYPOINT AdsMutexCreate(ADSHANDLE hConnect, - UNSIGNED8* pucName) { - arc2_trace("AdsMutexCreate"); - auto& s = state(); - std::lock_guard lk(s.mu); - auto* rc = get_remote_connection(hConnect); - if (!rc) return fail(openads::AE_FUNCTION_NOT_AVAILABLE, "mutex requires remote connection"); - std::string name = pucName ? reinterpret_cast(pucName) : ""; - if (name.empty()) return fail(openads::AE_INVALID_CONNECTION_HANDLE, "mutex name is empty"); - auto r = rc->mutex_create(name); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsMutexLock(ADSHANDLE hConnect, - UNSIGNED8* pucName, - UNSIGNED32 ulTimeOut) { - arc2_trace("AdsMutexLock"); - auto& s = state(); - std::lock_guard lk(s.mu); - auto* rc = get_remote_connection(hConnect); - if (!rc) return fail(openads::AE_FUNCTION_NOT_AVAILABLE, "mutex requires remote connection"); - std::string name = pucName ? reinterpret_cast(pucName) : ""; - if (name.empty()) return fail(openads::AE_INVALID_CONNECTION_HANDLE, "mutex name is empty"); - auto r = rc->mutex_lock(name, ulTimeOut); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsMutexTryLock(ADSHANDLE hConnect, - UNSIGNED8* pucName, - UNSIGNED16* pbLocked) { - arc2_trace("AdsMutexTryLock"); - auto& s = state(); - std::lock_guard lk(s.mu); - auto* rc = get_remote_connection(hConnect); - if (!rc) return fail(openads::AE_FUNCTION_NOT_AVAILABLE, "mutex requires remote connection"); - std::string name = pucName ? reinterpret_cast(pucName) : ""; - if (name.empty()) return fail(openads::AE_INVALID_CONNECTION_HANDLE, "mutex name is empty"); - auto r = rc->mutex_try_lock(name); - if (!r) return fail(r.error()); - if (pbLocked) *pbLocked = r.value() ? 1 : 0; - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsMutexUnlock(ADSHANDLE hConnect, - UNSIGNED8* pucName) { - arc2_trace("AdsMutexUnlock"); - auto& s = state(); - std::lock_guard lk(s.mu); - auto* rc = get_remote_connection(hConnect); - if (!rc) return fail(openads::AE_FUNCTION_NOT_AVAILABLE, "mutex requires remote connection"); - std::string name = pucName ? reinterpret_cast(pucName) : ""; - if (name.empty()) return fail(openads::AE_INVALID_CONNECTION_HANDLE, "mutex name is empty"); - auto r = rc->mutex_unlock(name); - if (!r) return fail(r.error()); - return ok(); -} - -UNSIGNED32 ENTRYPOINT AdsMutexDestroy(ADSHANDLE hConnect, - UNSIGNED8* pucName) { - arc2_trace("AdsMutexDestroy"); - auto& s = state(); - std::lock_guard lk(s.mu); - auto* rc = get_remote_connection(hConnect); - if (!rc) return fail(openads::AE_FUNCTION_NOT_AVAILABLE, "mutex requires remote connection"); - std::string name = pucName ? reinterpret_cast(pucName) : ""; - if (name.empty()) return fail(openads::AE_INVALID_CONNECTION_HANDLE, "mutex name is empty"); - auto r = rc->mutex_destroy(name); - if (!r) return fail(r.error()); - return ok(); -} - -} // extern "C" -- ARC32 ACE API gap block - -} // extern "C++" From 88dc36c4d55141bf23e336b76e10f8339bdfbd44 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Sat, 26 Sep 2026 01:30:01 -0500 Subject: [PATCH 54/97] Delete tests/unit/7-network_nav_batch_test.cpp --- tests/unit/7-network_nav_batch_test.cpp | 808 ------------------------ 1 file changed, 808 deletions(-) delete mode 100644 tests/unit/7-network_nav_batch_test.cpp diff --git a/tests/unit/7-network_nav_batch_test.cpp b/tests/unit/7-network_nav_batch_test.cpp deleted file mode 100644 index de64a33a..00000000 --- a/tests/unit/7-network_nav_batch_test.cpp +++ /dev/null @@ -1,808 +0,0 @@ -// tests/unit/network_nav_batch_test.cpp -// Nav-probe batching (Vouch startup: ~33 wire RTTs per USE on bare -// boundary probing). Three kills, all proven by server opcode counters: -// -// A. Consecutive-duplicate GotoTop/GotoBottom skip their frame when -// nothing hit the wire since (identical state, provably). -// B. A top/bottom that produced no row proves an empty cursor, so -// AtBOF/AtEOF answer locally until anything touches the wire. -// C. AtBOF/AtEOFAck carry the twin flag ([u8 bof][u8 eof] / -// [u8 eof][u8 bof]), so the wire half of rddads' pair caches the -// twin answer and the other half is served locally. -// -// Duplicate suppression is order-aware: a top in order A says nothing -// about order B, so the stamp carries the order context (RemoteIndex -// id, or the ack-confirmed server binding for table-handle nav). - -#include "doctest.h" -#include "mgmt/mg_stats.h" -#include "network/server.h" -#include "openads/ace.h" - -#include -#include -#include - -namespace fs = std::filesystem; - -namespace { - -constexpr std::uint8_t kOpGotoTop = 0x40; -constexpr std::uint8_t kOpAtEOF = 0x48; -constexpr std::uint8_t kOpAtBOF = 0x4C; -constexpr std::uint8_t kOpGetRecordNum = 0x4E; -constexpr std::uint8_t kOpGotoRecord = 0x58; -constexpr std::uint8_t kOpGotoBottom = 0x64; -constexpr std::uint8_t kOpSetOrder = 0x8C; -constexpr std::uint8_t kOpSeek = 0x90; -constexpr std::uint8_t kOpKeyCount = 0xB0; -constexpr std::uint8_t kOpCloseTable = 0x22; -constexpr std::uint8_t kOpGetRecordCount = 0x46; - -fs::path nb_tmp_dir() { - return fs::temp_directory_path() / "openads_navbatch_test"; -} - -void nb_wipe() { - std::error_code ec; - fs::remove_all(nb_tmp_dir(), ec); - fs::create_directories(nb_tmp_dir(), ec); -} - -void nb_seed(const fs::path& dir, const char* tname, int rows) { - UNSIGNED8 srv[512]{}; - std::memcpy(srv, dir.string().c_str(), dir.string().size()); - ADSHANDLE hConn = 0; - REQUIRE(AdsConnect60(srv, ADS_LOCAL_SERVER, nullptr, nullptr, 0, &hConn) - == AE_SUCCESS); - UNSIGNED8 def[] = "NM,C,10,0"; - UNSIGNED8 tn[64]{}; - std::memcpy(tn, tname, std::strlen(tname)); - ADSHANDLE hTable = 0; - REQUIRE(AdsCreateTable(hConn, tn, nullptr, ADS_CDX, ADS_ANSI, - 0, 0, 0, def, &hTable) == AE_SUCCESS); - UNSIGNED8 fld[] = "NM"; - for (int i = 0; i < rows; ++i) { - char v[16]{}; - std::snprintf(v, sizeof(v), "r%d", i); - REQUIRE(AdsAppendRecord(hTable) == AE_SUCCESS); - REQUIRE(AdsSetString(hTable, fld, - reinterpret_cast(v), - static_cast(std::strlen(v))) - == AE_SUCCESS); - REQUIRE(AdsWriteRecord(hTable) == AE_SUCCESS); - } - REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); - REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); -} - -std::uint64_t nb_op(std::uint8_t op) { - return openads::mgmt::process_mg_stats() - .op_timing[op].count.load(std::memory_order_relaxed); -} - -ADSHANDLE nb_connect_remote(const fs::path& dir, std::uint16_t port) { - char uri[512]{}; - std::snprintf(uri, sizeof(uri), "tcp://127.0.0.1:%u/%s", - static_cast(port), dir.string().c_str()); - UNSIGNED8 srvbuf[512]{}; - std::memcpy(srvbuf, uri, std::strlen(uri) + 1); - ADSHANDLE hConn = 0; - REQUIRE(AdsConnect60(srvbuf, ADS_REMOTE_SERVER, nullptr, nullptr, 0, - &hConn) == AE_SUCCESS); - return hConn; -} - -ADSHANDLE nb_open(ADSHANDLE hConn, const char* tname) { - UNSIGNED8 tn[64]{}; - std::memcpy(tn, tname, std::strlen(tname)); - ADSHANDLE hTable = 0; - REQUIRE(AdsOpenTable(hConn, tn, nullptr, ADS_CDX, ADS_ANSI, ADS_SHARED, - ADS_COMPATIBLE_LOCKING, ADS_DEFAULT, &hTable) - == AE_SUCCESS); - return hTable; -} - -UNSIGNED16 nb_bof(ADSHANDLE hTable) { - UNSIGNED16 v = 0; - REQUIRE(AdsAtBOF(hTable, &v) == AE_SUCCESS); - return v; -} - -UNSIGNED16 nb_eof(ADSHANDLE hTable) { - UNSIGNED16 v = 0; - REQUIRE(AdsAtEOF(hTable, &v) == AE_SUCCESS); - return v; -} - -UNSIGNED32 nb_recno(ADSHANDLE hTable) { - UNSIGNED32 v = 0; - REQUIRE(AdsGetRecordNum(hTable, 0, &v) == AE_SUCCESS); - return v; -} - -// Ordered fixture: physical IDs 30/10/20, tag BYID on ID. -// Natural top is rec 1, ordered top is rec 2. -void nb_seed_ord(const fs::path& dir) { - UNSIGNED8 srv[512]{}; - std::memcpy(srv, dir.string().c_str(), dir.string().size()); - ADSHANDLE hConn0 = 0; - REQUIRE(AdsConnect60(srv, ADS_LOCAL_SERVER, nullptr, nullptr, 0, &hConn0) - == AE_SUCCESS); - UNSIGNED8 def[] = "ID,N,8,0"; - UNSIGNED8 tname[] = "ord.dbf"; - ADSHANDLE hT = 0; - REQUIRE(AdsCreateTable(hConn0, tname, nullptr, ADS_CDX, 0, 0, 0, 0, def, - &hT) == AE_SUCCESS); - UNSIGNED8 fld[] = "ID"; - const double ids[] = {30.0, 10.0, 20.0}; - for (double id : ids) { - REQUIRE(AdsAppendRecord(hT) == AE_SUCCESS); - REQUIRE(AdsSetDouble(hT, fld, id) == AE_SUCCESS); - REQUIRE(AdsWriteRecord(hT) == AE_SUCCESS); - } - ADSHANDLE hI = 0; - UNSIGNED8 bag[] = "ord.cdx"; - UNSIGNED8 tag[] = "BYID"; - UNSIGNED8 exp[] = "ID"; - REQUIRE(AdsCreateIndex61(hT, bag, tag, exp, nullptr, nullptr, - ADS_COMPOUND, 512, &hI) == AE_SUCCESS); - REQUIRE(AdsCloseTable(hT) == AE_SUCCESS); - REQUIRE(AdsDisconnect(hConn0) == AE_SUCCESS); -} - -} // namespace - -TEST_CASE("Nav batching: duplicate GotoTop/GotoBottom skip their frame") { - nb_wipe(); - auto dir = nb_tmp_dir(); - nb_seed(dir, "nb.dbf", 3); - - openads::network::Server srv; - REQUIRE(srv.start("127.0.0.1", 0).has_value()); - ADSHANDLE hConn = nb_connect_remote(dir, srv.port()); - ADSHANDLE hTable = nb_open(hConn, "nb.dbf"); - - const std::uint64_t top0 = nb_op(kOpGotoTop); - const std::uint64_t bot0 = nb_op(kOpGotoBottom); - - // Warm open already positioned at top: immediate GoTop probing - // (rddads' per-USE pattern) costs nothing. - REQUIRE(AdsGotoTop(hTable) == AE_SUCCESS); - REQUIRE(AdsGotoTop(hTable) == AE_SUCCESS); - CHECK(nb_op(kOpGotoTop) == top0); - - // Bottom twice: one frame, then suppressed. - REQUIRE(AdsGotoBottom(hTable) == AE_SUCCESS); - REQUIRE(AdsGotoBottom(hTable) == AE_SUCCESS); - CHECK(nb_op(kOpGotoBottom) == bot0 + 1); - - // mtfix12 R1: the wire GotoBottom certified the TOP in the same - // server visit, so this top answers from the pair certification — - // stronger than the old re-wire. A wire nav with no certification - // (GotoRecord) invalidates it: the next top goes out again. - REQUIRE(AdsGotoTop(hTable) == AE_SUCCESS); - CHECK(nb_op(kOpGotoTop) == top0); // pair-certified - REQUIRE(AdsGotoRecord(hTable, 2) == AE_SUCCESS); - REQUIRE(AdsGotoTop(hTable) == AE_SUCCESS); - CHECK(nb_op(kOpGotoTop) == top0 + 1); // invalidated: back on wire - - REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); - REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); - srv.stop(); -} - -TEST_CASE("Nav batching: empty table answers boundaries with zero frames") { - nb_wipe(); - auto dir = nb_tmp_dir(); - nb_seed(dir, "empty.dbf", 0); - - openads::network::Server srv; - REQUIRE(srv.start("127.0.0.1", 0).has_value()); - ADSHANDLE hConn = nb_connect_remote(dir, srv.port()); - ADSHANDLE hTable = nb_open(hConn, "empty.dbf"); - - const std::uint64_t top0 = nb_op(kOpGotoTop); - const std::uint64_t bof0 = nb_op(kOpAtBOF); - const std::uint64_t eof0 = nb_op(kOpAtEOF); - - // Warm open stamped the empty cursor: top + every BOF/EOF probe - // below is served locally. An empty cursor is both BOF and EOF. - REQUIRE(AdsGotoTop(hTable) == AE_SUCCESS); - for (int i = 0; i < 3; ++i) { - CHECK(nb_bof(hTable) == 1); - CHECK(nb_eof(hTable) == 1); - } - CHECK(nb_op(kOpGotoTop) == top0); - CHECK(nb_op(kOpAtBOF) == bof0); - CHECK(nb_op(kOpAtEOF) == eof0); - - // Break the stamp with a local filter reset: the next top really - // goes out (empty again), then boundaries go quiet again. - REQUIRE(AdsClearFilter(hTable) == AE_SUCCESS); - REQUIRE(AdsGotoTop(hTable) == AE_SUCCESS); - CHECK(nb_op(kOpGotoTop) == top0 + 1); - CHECK(nb_bof(hTable) == 1); - CHECK(nb_eof(hTable) == 1); - CHECK(nb_op(kOpAtBOF) == bof0); - CHECK(nb_op(kOpAtEOF) == eof0); - - REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); - REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); - srv.stop(); -} - -TEST_CASE("Nav batching: twin flag halves the BOF/EOF pair") { - nb_wipe(); - auto dir = nb_tmp_dir(); - nb_seed(dir, "two.dbf", 2); - - openads::network::Server srv; - REQUIRE(srv.start("127.0.0.1", 0).has_value()); - ADSHANDLE hConn = nb_connect_remote(dir, srv.port()); - ADSHANDLE hTable = nb_open(hConn, "two.dbf"); - - REQUIRE(AdsGotoTop(hTable) == AE_SUCCESS); - REQUIRE(AdsSkip(hTable, 1) == AE_SUCCESS); - REQUIRE(AdsSkip(hTable, 1) == AE_SUCCESS); // past the end -> EOF - - const std::uint64_t bof0 = nb_op(kOpAtBOF); - const std::uint64_t eof0 = nb_op(kOpAtEOF); - - // Arrived from rows, so not-BOF is proven: both answers local, no - // wire AtBOF at all (the twin would halve the pair if one went out). - CHECK(nb_bof(hTable) == 0); // arrived from rows, not BOF - CHECK(nb_op(kOpAtBOF) == bof0); - CHECK(nb_eof(hTable) == 1); - CHECK(nb_op(kOpAtEOF) == eof0); - - // Force a wire pair: a local filter change drops the proven-false - // flags without moving the server cursor (still past end). The - // wire AtBOF carries the EOF twin, so the follow-up AtEOF is local. - UNSIGNED8 flt[] = "ID > 0"; - REQUIRE(AdsSetFilter(hTable, flt) == AE_SUCCESS); - CHECK(nb_bof(hTable) == 0); - CHECK(nb_op(kOpAtBOF) == bof0 + 1); - CHECK(nb_eof(hTable) == 1); - CHECK(nb_op(kOpAtEOF) == eof0); - - REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); - REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); - srv.stop(); -} - -TEST_CASE("Nav batching: order context defeats duplicate suppression") { nb_wipe(); - auto dir = nb_tmp_dir(); - nb_seed_ord(dir); - - openads::network::Server s; - REQUIRE(s.start("127.0.0.1", 0).has_value()); - ADSHANDLE hConn = nb_connect_remote(dir, s.port()); - ADSHANDLE hTable = nb_open(hConn, "ord.dbf"); - - // Resolve the order handle (purely local) then navigate by it: the - // top MUST go out on the wire in BYID order (rec 2), not dedupe - // against the warm natural-order stamp (which would sit on rec 1). - ADSHANDLE hOrd = 0; - REQUIRE(AdsGetIndexHandleByOrder(hTable, 1, &hOrd) == AE_SUCCESS); - REQUIRE(hOrd != 0); - - const std::uint64_t top0 = nb_op(kOpGotoTop); - REQUIRE(AdsGotoTop(hOrd) == AE_SUCCESS); - CHECK(nb_op(kOpGotoTop) == top0 + 1); - UNSIGNED32 rec = 0; - REQUIRE(AdsGetRecordNum(hTable, 0, &rec) == AE_SUCCESS); - CHECK(rec == 2u); - - // Same order again: now the duplicate is real — suppressed. - REQUIRE(AdsGotoTop(hOrd) == AE_SUCCESS); - CHECK(nb_op(kOpGotoTop) == top0 + 1); - - // Table-handle top with the order still bound: same position, - // suppressed as well. - REQUIRE(AdsGotoTop(hTable) == AE_SUCCESS); - CHECK(nb_op(kOpGotoTop) == top0 + 1); - - // Back to natural order: reset frame + fresh top both go out. - REQUIRE(AdsSetIndexOrderByHandle(hTable, 0) == AE_SUCCESS); - REQUIRE(AdsGotoTop(hTable) == AE_SUCCESS); - CHECK(nb_op(kOpGotoTop) == top0 + 2); - REQUIRE(AdsGetRecordNum(hTable, 0, &rec) == AE_SUCCESS); - CHECK(rec == 1u); - - REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); - REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); - s.stop(); -} - -TEST_CASE("Nav batching: stamps survive read traffic") { - nb_wipe(); - auto dir = nb_tmp_dir(); - nb_seed(dir, "rdempty.dbf", 0); - - openads::network::Server s; - REQUIRE(s.start("127.0.0.1", 0).has_value()); - ADSHANDLE hConn = nb_connect_remote(dir, s.port()); - ADSHANDLE hTable = nb_open(hConn, "rdempty.dbf"); - - // rddads interleaves field/count reads between probes. Reads must - // not expire the empty-cursor stamp: top, a wire count read, then - // boundaries — still zero boundary frames. - REQUIRE(AdsGotoTop(hTable) == AE_SUCCESS); - UNSIGNED32 nrec = 0; - REQUIRE(AdsGetRecordCount(hTable, 0, &nrec) == AE_SUCCESS); - CHECK(nrec == 0u); - - const std::uint64_t bof0 = nb_op(kOpAtBOF); - const std::uint64_t eof0 = nb_op(kOpAtEOF); - CHECK(nb_bof(hTable) == 1); - CHECK(nb_eof(hTable) == 1); - CHECK(nb_bof(hTable) == 1); - CHECK(nb_eof(hTable) == 1); - CHECK(nb_op(kOpAtBOF) == bof0); - CHECK(nb_op(kOpAtEOF) == eof0); - - REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); - REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); - s.stop(); -} - -TEST_CASE("Nav batching: skip-established limits answer locally") { - nb_wipe(); - auto dir = nb_tmp_dir(); - nb_seed(dir, "lim.dbf", 2); - - openads::network::Server s; - REQUIRE(s.start("127.0.0.1", 0).has_value()); - ADSHANDLE hConn = nb_connect_remote(dir, s.port()); - ADSHANDLE hTable = nb_open(hConn, "lim.dbf"); - - REQUIRE(AdsGotoTop(hTable) == AE_SUCCESS); - REQUIRE(AdsSkip(hTable, 1) == AE_SUCCESS); - // Past the end: EOF is flagged, and arriving from rows proves - // not-BOF — both answer with zero further frames. - REQUIRE(AdsSkip(hTable, 1) == AE_SUCCESS); - const std::uint64_t bof0 = nb_op(kOpAtBOF); - const std::uint64_t eof0 = nb_op(kOpAtEOF); - CHECK(nb_eof(hTable) == 1); - CHECK(nb_bof(hTable) == 0); - CHECK(nb_eof(hTable) == 1); - CHECK(nb_bof(hTable) == 0); - CHECK(nb_op(kOpAtBOF) == bof0); - CHECK(nb_op(kOpAtEOF) == eof0); - - // Back to the top limit: BOF flags locally, and the Skip ack's - // bound piggyback certifies EOF too — zero frames either side. - REQUIRE(AdsSkip(hTable, -10) == AE_SUCCESS); - const std::uint64_t bof1 = nb_op(kOpAtBOF); - const std::uint64_t eof1 = nb_op(kOpAtEOF); - CHECK(nb_bof(hTable) == 1); - CHECK(nb_eof(hTable) == 0); - CHECK(nb_bof(hTable) == 1); - CHECK(nb_eof(hTable) == 0); - CHECK(nb_op(kOpAtBOF) == bof1); - CHECK(nb_op(kOpAtEOF) == eof1); - - REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); - REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); - s.stop(); -} - -TEST_CASE("Reposition truth: top/bottom/skip certify bounds and recno") { nb_wipe(); - auto dir = nb_tmp_dir(); - nb_seed(dir, "tbs.dbf", 3); - - openads::network::Server s; - REQUIRE(s.start("127.0.0.1", 0).has_value()); - ADSHANDLE hConn = nb_connect_remote(dir, s.port()); - ADSHANDLE hTable = nb_open(hConn, "tbs.dbf"); - - const std::uint64_t bof0 = nb_op(kOpAtBOF); - const std::uint64_t eof0 = nb_op(kOpAtEOF); - const std::uint64_t rn0 = nb_op(kOpGetRecordNum); - - // Top lands on row 1: positioned (not BOF — BOF means *before* - // first), not EOF, recno certified — the paint burst is local. - REQUIRE(AdsGotoTop(hTable) == AE_SUCCESS); - CHECK(nb_bof(hTable) == 0); - CHECK(nb_eof(hTable) == 0); - CHECK(nb_recno(hTable) == 1u); - CHECK(nb_op(kOpAtBOF) == bof0); - CHECK(nb_op(kOpAtEOF) == eof0); - CHECK(nb_op(kOpGetRecordNum) == rn0); - - // Bottom lands on the last row: same, mirrored. - REQUIRE(AdsGotoBottom(hTable) == AE_SUCCESS); - CHECK(nb_bof(hTable) == 0); - CHECK(nb_eof(hTable) == 0); - CHECK(nb_recno(hTable) == 3u); - CHECK(nb_op(kOpAtBOF) == bof0); - CHECK(nb_op(kOpAtEOF) == eof0); - CHECK(nb_op(kOpGetRecordNum) == rn0); - - // Skip past the end: EOF phantom with recno n+1, still certified. - REQUIRE(AdsSkip(hTable, 1) == AE_SUCCESS); - CHECK(nb_bof(hTable) == 0); - CHECK(nb_eof(hTable) == 1); - CHECK(nb_recno(hTable) == 4u); - CHECK(nb_op(kOpAtBOF) == bof0); - CHECK(nb_op(kOpAtEOF) == eof0); - CHECK(nb_op(kOpGetRecordNum) == rn0); - - REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); - REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); - s.stop(); -} - -TEST_CASE("Nav batching: deferred SetOrder fuses into GotoTop") { nb_wipe(); - auto dir = nb_tmp_dir(); - nb_seed_ord(dir); - - openads::network::Server s; - REQUIRE(s.start("127.0.0.1", 0).has_value()); - ADSHANDLE hConn = nb_connect_remote(dir, s.port()); - ADSHANDLE hTable = nb_open(hConn, "ord.dbf"); - - ADSHANDLE hOrd = 0; - REQUIRE(AdsGetIndexHandleByOrder(hTable, 1, &hOrd) == AE_SUCCESS); - REQUIRE(hOrd != 0); - - // SetOrder alone sends nothing; the following GotoTop absorbs it: - // one GotoTop frame, zero SetOrder frames, ordered position. - const std::uint64_t so0 = nb_op(kOpSetOrder); - const std::uint64_t top0 = nb_op(kOpGotoTop); - REQUIRE(AdsSetIndexOrderByHandle(hTable, hOrd) == AE_SUCCESS); - CHECK(nb_op(kOpSetOrder) == so0); - REQUIRE(AdsGotoTop(hOrd) == AE_SUCCESS); - CHECK(nb_op(kOpSetOrder) == so0); - CHECK(nb_op(kOpGotoTop) == top0 + 1); - UNSIGNED32 rec = 0; - REQUIRE(AdsGetRecordNum(hTable, 0, &rec) == AE_SUCCESS); - CHECK(rec == 2u); - - // Same pattern to the bottom: fused the same way. mtfix12 R1 - // notes: the fused GotoTop above certified the bottom in the same - // visit, so a plain ordered GotoBottom here would be pair-served - // and exercise nothing. A pending order blocks the pair serve (the - // certified order no longer provably matches), so defer a natural - // switch first — the bottom on the TABLE handle fuses it into one - // frame exactly like leg one. (ByHandle(hOrd) can't be the second - // switch: with the acked binding still hOrd it reads as a - // same-order no-op and would leave the natural switch pending.) - REQUIRE(AdsSetIndexOrderByHandle(hTable, 0) == AE_SUCCESS); - const std::uint64_t bot0 = nb_op(kOpGotoBottom); - CHECK(nb_op(kOpSetOrder) == so0); - REQUIRE(AdsGotoBottom(hTable) == AE_SUCCESS); - CHECK(nb_op(kOpSetOrder) == so0); - CHECK(nb_op(kOpGotoBottom) == bot0 + 1); - - REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); - REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); - s.stop(); -} - -TEST_CASE("Nav batching: fused nav certifies the new order key count") { - nb_wipe(); - auto dir = nb_tmp_dir(); - nb_seed_ord(dir); - - openads::network::Server s; - REQUIRE(s.start("127.0.0.1", 0).has_value()); - ADSHANDLE hConn = nb_connect_remote(dir, s.port()); - ADSHANDLE hTable = nb_open(hConn, "ord.dbf"); - - ADSHANDLE hOrd = 0; - REQUIRE(AdsGetIndexHandleByOrder(hTable, 1, &hOrd) == AE_SUCCESS); - REQUIRE(hOrd != 0); - - // The fused frame installs the order AND certifies its key - // count: the scrollbar setup that always follows costs nothing. - const std::uint64_t kc0 = nb_op(kOpKeyCount); - REQUIRE(AdsSetIndexOrderByHandle(hTable, hOrd) == AE_SUCCESS); - REQUIRE(AdsGotoTop(hOrd) == AE_SUCCESS); - UNSIGNED32 kc = 0; - REQUIRE(AdsGetKeyCount(hOrd, 0, &kc) == AE_SUCCESS); - CHECK(kc == 3u); - CHECK(nb_op(kOpKeyCount) == kc0); - - REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); - REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); - s.stop(); -} - -TEST_CASE("Nav batching: close absorbs a deferred switch") { - nb_wipe(); - auto dir = nb_tmp_dir(); - nb_seed_ord(dir); - - openads::network::Server s; - REQUIRE(s.start("127.0.0.1", 0).has_value()); - ADSHANDLE hConn = nb_connect_remote(dir, s.port()); - ADSHANDLE hTable = nb_open(hConn, "ord.dbf"); - - ADSHANDLE hOrd = 0; - REQUIRE(AdsGetIndexHandleByOrder(hTable, 1, &hOrd) == AE_SUCCESS); - REQUIRE(hOrd != 0); - - // Deferred switch that never reaches any wire op dies silently - // with the close (bindings die with the handle). - const std::uint64_t so0 = nb_op(kOpSetOrder); - const std::uint64_t cl0 = nb_op(kOpCloseTable); - REQUIRE(AdsSetIndexOrderByHandle(hTable, hOrd) == AE_SUCCESS); - CHECK(nb_op(kOpSetOrder) == so0); - REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); - CHECK(nb_op(kOpSetOrder) == so0); - CHECK(nb_op(kOpCloseTable) == cl0 + 1); - - REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); - s.stop(); -} - -TEST_CASE("Nav batching: non-nav op flushes a deferred switch plainly") { - nb_wipe(); - auto dir = nb_tmp_dir(); - nb_seed_ord(dir); - - openads::network::Server s; - REQUIRE(s.start("127.0.0.1", 0).has_value()); - ADSHANDLE hConn = nb_connect_remote(dir, s.port()); - ADSHANDLE hTable = nb_open(hConn, "ord.dbf"); - - ADSHANDLE hOrd = 0; - REQUIRE(AdsGetIndexHandleByOrder(hTable, 1, &hOrd) == AE_SUCCESS); - REQUIRE(hOrd != 0); - - // A key count needs the binding: the deferred switch goes out - // plainly first, then the count. - const std::uint64_t so0 = nb_op(kOpSetOrder); - const std::uint64_t kc0 = nb_op(kOpKeyCount); - REQUIRE(AdsSetIndexOrderByHandle(hTable, hOrd) == AE_SUCCESS); - CHECK(nb_op(kOpSetOrder) == so0); - UNSIGNED32 kc = 0; - REQUIRE(AdsGetKeyCount(hOrd, 0, &kc) == AE_SUCCESS); - CHECK(kc == 3u); - CHECK(nb_op(kOpSetOrder) == so0 + 1); - CHECK(nb_op(kOpKeyCount) == kc0 + 1); - // And again, cached this time. - REQUIRE(AdsGetKeyCount(hOrd, 0, &kc) == AE_SUCCESS); - CHECK(kc == 3u); - CHECK(nb_op(kOpKeyCount) == kc0 + 1); - - REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); - REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); - s.stop(); -} - -// Reposition-bound piggyback (Vouch paint loop: GotoRecord/Seek, then -// AtBOF/AtEOF/RecNo per row — 266 + 174 repositions/startup, 548 RecNo -// + 352 AtBOF wire frames). The server appends [u8 bof][u8 eof][u32 recno] -// after the row trailer of GotoRecordAck/SeekAck, so the whole -// post-reposition burst is served locally. Trailing section, -// length-gated: old servers send no tail (M12.24 convention, no caps bit). - -TEST_CASE("Reposition truth: GotoRecord certifies bounds and recno") { - nb_wipe(); - auto dir = nb_tmp_dir(); - nb_seed(dir, "gr.dbf", 3); - - openads::network::Server s; - REQUIRE(s.start("127.0.0.1", 0).has_value()); - ADSHANDLE hConn = nb_connect_remote(dir, s.port()); - ADSHANDLE hTable = nb_open(hConn, "gr.dbf"); - - const std::uint64_t gr0 = nb_op(kOpGotoRecord); - const std::uint64_t bof0 = nb_op(kOpAtBOF); - const std::uint64_t eof0 = nb_op(kOpAtEOF); - const std::uint64_t rn0 = nb_op(kOpGetRecordNum); - - // Mid-table restore: one frame, then the full paint burst is local. - REQUIRE(AdsGotoRecord(hTable, 2) == AE_SUCCESS); - CHECK(nb_op(kOpGotoRecord) == gr0 + 1); - for (int i = 0; i < 3; ++i) { - CHECK(nb_bof(hTable) == 0); - CHECK(nb_eof(hTable) == 0); - CHECK(nb_recno(hTable) == 2u); - } - CHECK(nb_op(kOpAtBOF) == bof0); - CHECK(nb_op(kOpAtEOF) == eof0); - CHECK(nb_op(kOpGetRecordNum) == rn0); - - // Past-the-end restore: Clipper-phantom Limbo (BOF+EOF, recno - // LastRec()+1) — certified in the ack, still zero frames. - REQUIRE(AdsGotoRecord(hTable, 4) == AE_SUCCESS); - CHECK(nb_op(kOpGotoRecord) == gr0 + 2); - CHECK(nb_bof(hTable) == 1); - CHECK(nb_eof(hTable) == 1); - CHECK(nb_recno(hTable) == 4u); - CHECK(nb_op(kOpAtBOF) == bof0); - CHECK(nb_op(kOpAtEOF) == eof0); - CHECK(nb_op(kOpGetRecordNum) == rn0); - - REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); - REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); - s.stop(); -} - -// Character-tag fixture for seeks: rows r0/r1/r2, tag BYNM on NM. -void nb_seed_chr(const fs::path& dir) { - UNSIGNED8 srv[512]{}; - std::memcpy(srv, dir.string().c_str(), dir.string().size()); - ADSHANDLE hConn0 = 0; - REQUIRE(AdsConnect60(srv, ADS_LOCAL_SERVER, nullptr, nullptr, 0, &hConn0) - == AE_SUCCESS); - UNSIGNED8 def[] = "NM,C,10,0"; - UNSIGNED8 tname[] = "chr.dbf"; - ADSHANDLE hT = 0; - REQUIRE(AdsCreateTable(hConn0, tname, nullptr, ADS_CDX, 0, 0, 0, 0, def, - &hT) == AE_SUCCESS); - UNSIGNED8 fld[] = "NM"; - for (int i = 0; i < 3; ++i) { - char v[16]{}; - std::snprintf(v, sizeof(v), "r%d", i); - REQUIRE(AdsAppendRecord(hT) == AE_SUCCESS); - REQUIRE(AdsSetString(hT, fld, reinterpret_cast(v), - static_cast(std::strlen(v))) - == AE_SUCCESS); - REQUIRE(AdsWriteRecord(hT) == AE_SUCCESS); - } - ADSHANDLE hI = 0; - UNSIGNED8 bag[] = "chr.cdx"; - UNSIGNED8 tag[] = "BYNM"; - UNSIGNED8 exp[] = "NM"; - REQUIRE(AdsCreateIndex61(hT, bag, tag, exp, nullptr, nullptr, - ADS_COMPOUND, 512, &hI) == AE_SUCCESS); - REQUIRE(AdsCloseTable(hT) == AE_SUCCESS); - REQUIRE(AdsDisconnect(hConn0) == AE_SUCCESS); -} - -UNSIGNED16 nb_seek(ADSHANDLE hOrd, const char* key) { - UNSIGNED16 found = 0; - REQUIRE(AdsSeek(hOrd, reinterpret_cast( - const_cast(key)), - static_cast(std::strlen(key)), - ADS_STRINGKEY, 0, &found) == AE_SUCCESS); - return found; -} - -TEST_CASE("Reposition truth: Seek hit certifies bounds and recno") { - nb_wipe(); - auto dir = nb_tmp_dir(); - nb_seed_chr(dir); - - openads::network::Server s; - REQUIRE(s.start("127.0.0.1", 0).has_value()); - ADSHANDLE hConn = nb_connect_remote(dir, s.port()); - ADSHANDLE hTable = nb_open(hConn, "chr.dbf"); - ADSHANDLE hOrd = 0; - REQUIRE(AdsGetIndexHandleByOrder(hTable, 1, &hOrd) == AE_SUCCESS); - REQUIRE(hOrd != 0); - - const std::uint64_t sk0 = nb_op(kOpSeek); - const std::uint64_t bof0 = nb_op(kOpAtBOF); - const std::uint64_t eof0 = nb_op(kOpAtEOF); - const std::uint64_t rn0 = nb_op(kOpGetRecordNum); - - // Mid-key hit: one Seek frame, burst local. - CHECK(nb_seek(hOrd, "r1") == 1); - CHECK(nb_op(kOpSeek) == sk0 + 1); - CHECK(nb_bof(hTable) == 0); - CHECK(nb_eof(hTable) == 0); - CHECK(nb_recno(hTable) == 2u); - CHECK(nb_op(kOpAtBOF) == bof0); - CHECK(nb_op(kOpAtEOF) == eof0); - CHECK(nb_op(kOpGetRecordNum) == rn0); - - // First-key hit: positioned on a row, so BOF is false (ACE truth: - // BOF means positioned *before* first, not *on* first). The point - // stands: the piggyback — not a follow-up frame — certifies it. - CHECK(nb_seek(hOrd, "r0") == 1); - CHECK(nb_op(kOpSeek) == sk0 + 2); - CHECK(nb_bof(hTable) == 0); - CHECK(nb_eof(hTable) == 0); - CHECK(nb_recno(hTable) == 1u); - CHECK(nb_op(kOpAtBOF) == bof0); - CHECK(nb_op(kOpAtEOF) == eof0); - CHECK(nb_op(kOpGetRecordNum) == rn0); - - REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); - REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); - s.stop(); -} - -TEST_CASE("Reposition truth: Seek miss certifies EOF locally") { - nb_wipe(); - auto dir = nb_tmp_dir(); - nb_seed_chr(dir); - - openads::network::Server s; - REQUIRE(s.start("127.0.0.1", 0).has_value()); - ADSHANDLE hConn = nb_connect_remote(dir, s.port()); - ADSHANDLE hTable = nb_open(hConn, "chr.dbf"); - ADSHANDLE hOrd = 0; - REQUIRE(AdsGetIndexHandleByOrder(hTable, 1, &hOrd) == AE_SUCCESS); - REQUIRE(hOrd != 0); - - const std::uint64_t sk0 = nb_op(kOpSeek); - const std::uint64_t bof0 = nb_op(kOpAtBOF); - const std::uint64_t eof0 = nb_op(kOpAtEOF); - const std::uint64_t rn0 = nb_op(kOpGetRecordNum); - - // Hard miss past the end: the cursor is in Limbo (BOF+EOF, the - // Clipper-phantom convention) — certified in the ack, all local. - // Value-identical to the old wire poll (the server twin is the - // same ACE engine either way); only the frame is gone. - CHECK(nb_seek(hOrd, "zzz") == 0); - CHECK(nb_op(kOpSeek) == sk0 + 1); - CHECK(nb_eof(hTable) == 1); - CHECK(nb_bof(hTable) == 1); - // Certified recno, whatever the twin reports for a miss — served - // locally and stable across repeats. - CHECK(nb_recno(hTable) == nb_recno(hTable)); - CHECK(nb_op(kOpGetRecordNum) == rn0); - CHECK(nb_op(kOpAtBOF) == bof0); - CHECK(nb_op(kOpAtEOF) == eof0); - - REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); - REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); - s.stop(); -} - -UNSIGNED32 nb_reccount(ADSHANDLE hTable) { - UNSIGNED32 v = 0; - REQUIRE(AdsGetRecordCount(hTable, 0, &v) == AE_SUCCESS); - return v; -} - -TEST_CASE("Count truth: nav ack certifies the record count") { - nb_wipe(); - auto dir = nb_tmp_dir(); - nb_seed(dir, "cnt.dbf", 3); - - openads::network::Server s; - REQUIRE(s.start("127.0.0.1", 0).has_value()); - ADSHANDLE hConn = nb_connect_remote(dir, s.port()); - ADSHANDLE hTable = nb_open(hConn, "cnt.dbf"); - - // The USE flow (open, position, count) pays no count frame: the - // nav ack certified it. (Bottom, not top: the open's implicit - // GoTop dedupes a repeat top with no ack and no tail.) - const std::uint64_t rc0 = nb_op(kOpGetRecordCount); - REQUIRE(AdsGotoBottom(hTable) == AE_SUCCESS); - CHECK(nb_reccount(hTable) == 3u); - CHECK(nb_reccount(hTable) == 3u); - CHECK(nb_op(kOpGetRecordCount) == rc0); - - REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); - REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); - s.stop(); -} - -TEST_CASE("Phantom RecNo derives from flags plus cached count") { - nb_wipe(); - auto dir = nb_tmp_dir(); - nb_seed(dir, "ph1.dbf", 3); - nb_seed(dir, "ph2.dbf", 3); - - openads::network::Server s; - REQUIRE(s.start("127.0.0.1", 0).has_value()); - ADSHANDLE hConn = nb_connect_remote(dir, s.port()); - ADSHANDLE hA = nb_open(hConn, "ph1.dbf"); - ADSHANDLE hB = nb_open(hConn, "ph2.dbf"); - - // Park A at its EOF phantom (certified), cache its count, then - // navigate B: the cross-table frame evicts A's seq-gated caches, - // but the phantom recno re-derives (EOF + count) with no frame. - REQUIRE(AdsGotoBottom(hA) == AE_SUCCESS); - REQUIRE(AdsSkip(hA, 1) == AE_SUCCESS); - CHECK(nb_reccount(hA) == 3u); - const std::uint64_t rn0 = nb_op(kOpGetRecordNum); - REQUIRE(AdsGotoTop(hB) == AE_SUCCESS); - CHECK(nb_recno(hA) == 4u); - CHECK(nb_recno(hA) == 4u); - CHECK(nb_op(kOpGetRecordNum) == rn0); - - REQUIRE(AdsCloseTable(hA) == AE_SUCCESS); - REQUIRE(AdsCloseTable(hB) == AE_SUCCESS); - REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); - s.stop(); -} From bc73eb5dd777fdcb486eff84adbe5298f89a608e Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Sat, 26 Sep 2026 01:30:14 -0500 Subject: [PATCH 55/97] Delete tests/unit/8-network_use_budget_test.cpp --- tests/unit/8-network_use_budget_test.cpp | 121 ----------------------- 1 file changed, 121 deletions(-) delete mode 100644 tests/unit/8-network_use_budget_test.cpp diff --git a/tests/unit/8-network_use_budget_test.cpp b/tests/unit/8-network_use_budget_test.cpp deleted file mode 100644 index 3dd545ef..00000000 --- a/tests/unit/8-network_use_budget_test.cpp +++ /dev/null @@ -1,121 +0,0 @@ -// tests/unit/network_use_budget_test.cpp -// Per-USE wire budget (Vouch WAN startup). One realistic rddads-style -// USE cycle — open + setorder + gotop×2 + bof/eof pairs + bottom×2 + -// keycount + close — must cost a bounded number of server frames, with -// zero boundary-probe frames (sticky/twin) and zero duplicate navs: -// OpenTable + CloseTable + GotoTop + GotoBottom + SetOrder + -// KeyCount×2 = 9 frames (session setup excluded: the Hello/Connect -// handshake and the session-pool lanes are established once per -// logical connection and amortised over every USE). -#include "doctest.h" -#include "mgmt/mg_stats.h" -#include "network/server.h" -#include "openads/ace.h" - -#include -#include -#include -#include - -namespace fs = std::filesystem; - -namespace { - -std::uint64_t ub_op(std::uint8_t op) { - return openads::mgmt::process_mg_stats() - .op_timing[op] - .count.load(std::memory_order_relaxed); -} - -} // namespace - -TEST_CASE("Nav batching: full USE cycle stays within wire budget") { - auto dir = fs::temp_directory_path() / "openads_usebudget"; - std::error_code ec; - fs::remove_all(dir, ec); - fs::create_directories(dir); - - UNSIGNED8 srv[512]{}; - std::memcpy(srv, dir.string().c_str(), dir.string().size()); - ADSHANDLE hC0 = 0; - REQUIRE(AdsConnect60(srv, ADS_LOCAL_SERVER, nullptr, nullptr, 0, &hC0) - == AE_SUCCESS); - UNSIGNED8 def[] = "ID,N,8,0"; - UNSIGNED8 tn0[] = "UB.DBF"; - ADSHANDLE hT0 = 0; - REQUIRE(AdsCreateTable(hC0, tn0, nullptr, ADS_CDX, 0, 0, 0, 0, def, &hT0) - == AE_SUCCESS); - UNSIGNED8 f1[] = "ID"; - for (int i = 1; i <= 50; ++i) { - REQUIRE(AdsAppendRecord(hT0) == AE_SUCCESS); - REQUIRE(AdsSetDouble(hT0, f1, i * 10) == AE_SUCCESS); - REQUIRE(AdsWriteRecord(hT0) == AE_SUCCESS); - } - ADSHANDLE hI0 = 0; - UNSIGNED8 bag[] = "UB.CDX"; - UNSIGNED8 tag[] = "BYID"; - UNSIGNED8 exp[] = "ID"; - REQUIRE(AdsCreateIndex61(hT0, bag, tag, exp, nullptr, nullptr, - ADS_COMPOUND, 512, &hI0) == AE_SUCCESS); - REQUIRE(AdsCloseTable(hT0) == AE_SUCCESS); - REQUIRE(AdsDisconnect(hC0) == AE_SUCCESS); - - openads::network::Server s; - REQUIRE(s.start("127.0.0.1", 0).has_value()); - - char uri[512]{}; - std::snprintf(uri, sizeof(uri), "tcp://127.0.0.1:%u/%s", - static_cast(s.port()), dir.string().c_str()); - UNSIGNED8 sb[512]{}; - std::memcpy(sb, uri, std::strlen(uri) + 1); - ADSHANDLE hC = 0; - REQUIRE(AdsConnect60(sb, ADS_REMOTE_SERVER, nullptr, nullptr, 0, &hC) - == AE_SUCCESS); - - // Snapshot AFTER connect: the session pool (one Connect per lane, - // OPENADS_POOL_SIZE) is established once per logical connection and - // amortised over every USE — the budget below guards the per-USE - // cycle (open + setorder + gotop x2 + bof/eof + bottom x2 + keycount - // + close), not session setup. - std::map before; - for (int o = 0; o < 256; ++o) - before[static_cast(o)] = ub_op((std::uint8_t)o); - UNSIGNED8 tn[] = "UB.DBF"; - ADSHANDLE hT = 0; - REQUIRE(AdsOpenTable(hC, tn, nullptr, ADS_CDX, 0, 0, 0, 0, &hT) == AE_SUCCESS); - ADSHANDLE hOrd = 0; - UNSIGNED8 want[] = "BYID"; - REQUIRE(AdsGetIndexHandle(hT, want, &hOrd) == AE_SUCCESS); - REQUIRE(AdsSetIndexOrderByHandle(hT, hOrd) == AE_SUCCESS); - REQUIRE(AdsGotoTop(hOrd) == AE_SUCCESS); - REQUIRE(AdsGotoTop(hOrd) == AE_SUCCESS); - UNSIGNED16 b = 0, e = 0; - REQUIRE(AdsAtBOF(hT, &b) == AE_SUCCESS); - REQUIRE(AdsAtEOF(hT, &e) == AE_SUCCESS); - REQUIRE(AdsAtBOF(hT, &b) == AE_SUCCESS); - REQUIRE(AdsAtEOF(hT, &e) == AE_SUCCESS); - REQUIRE(AdsGotoBottom(hOrd) == AE_SUCCESS); - REQUIRE(AdsGotoBottom(hOrd) == AE_SUCCESS); - UNSIGNED32 kc = 0; - REQUIRE(AdsGetKeyCount(hOrd, 0, &kc) == AE_SUCCESS); - CHECK(kc == 50u); - REQUIRE(AdsCloseTable(hT) == AE_SUCCESS); - REQUIRE(AdsDisconnect(hC) == AE_SUCCESS); - - auto delta = [&](std::uint8_t op) { return ub_op(op) - before[op]; }; - std::uint64_t total = 0; - for (int o = 0; o < 256; ++o) - total += ub_op((std::uint8_t)o) - before[(std::uint8_t)o]; - - // The whole cycle, connect included, fits in a small fixed budget — - // boundary probes and duplicate navs contribute zero frames. - CHECK(total <= 14u); - CHECK(delta(0x40) == 1u); // GotoTop: second suppressed - CHECK(delta(0x64) == 0u); // GotoBottom: mtfix12 pair-certified by - // the GotoTop's ack, second suppressed - CHECK(delta(0x48) == 0u); // AtEOF: served locally - CHECK(delta(0x4C) == 0u); // AtBOF: served locally - - s.stop(); - fs::remove_all(dir, ec); -} From 614c595c8fbb7b6399c1fa8759421b3153e444d6 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Sat, 26 Sep 2026 01:30:27 -0500 Subject: [PATCH 56/97] Delete tests/unit/9-network_boundary_pair_test.cpp --- tests/unit/9-network_boundary_pair_test.cpp | 177 -------------------- 1 file changed, 177 deletions(-) delete mode 100644 tests/unit/9-network_boundary_pair_test.cpp diff --git a/tests/unit/9-network_boundary_pair_test.cpp b/tests/unit/9-network_boundary_pair_test.cpp deleted file mode 100644 index 7bb2cd75..00000000 --- a/tests/unit/9-network_boundary_pair_test.cpp +++ /dev/null @@ -1,177 +0,0 @@ -// tests/unit/network_boundary_pair_test.cpp -// mtfix12 R1/R2/R3 — boundary-pair certification, self-GotoRecord -// suppression, and GetRecordNum anchoring, end to end over a loopback -// server: per-opcode frame counts prove which calls hit the wire, and -// reads prove the locally-served state is what the wire would have -// returned. -#include "doctest.h" -#include "network/client.h" -#include "network/server.h" -#include "openads/ace.h" - -#include -#include -#include -#include - -namespace fs = std::filesystem; - -namespace { - -std::atomic g_top{0}, g_bottom{0}, g_goto{0}, g_recnum{0}; - -void count_nav(const void*, std::uint8_t op, std::uint32_t, std::size_t, - std::uint8_t, std::size_t, long long) { - if (op == 0x40) g_top.fetch_add(1); // GotoTop - if (op == 0x64) g_bottom.fetch_add(1); // GotoBottom - if (op == 0x58) g_goto.fetch_add(1); // GotoRecord - if (op == 0x4E) g_recnum.fetch_add(1); // GetRecordNum -} - -void reset_counts() { - g_top = 0; g_bottom = 0; g_goto = 0; g_recnum = 0; -} - -std::uint32_t recno_of(ADSHANDLE h) { - UNSIGNED32 n = 0; - REQUIRE(AdsGetRecordNum(h, 0, &n) == AE_SUCCESS); - return n; -} - -std::string id_field(ADSHANDLE h) { - char buf[64] = {}; - UNSIGNED32 len = sizeof(buf) - 1; - UNSIGNED8 fld[] = "ID"; - REQUIRE(AdsGetField(h, fld, reinterpret_cast(buf), &len, - ADS_NONE) == AE_SUCCESS); - return std::string(buf, len); -} - -} // namespace - -TEST_CASE("boundary pair, self-goto, and recno anchor over loopback") { - auto dir = fs::temp_directory_path() / "openads_bpair"; - std::error_code ec; - fs::remove_all(dir, ec); - fs::create_directories(dir); - - // Seed two 5-row tables locally. - UNSIGNED8 srv[512]{}; - std::memcpy(srv, dir.string().c_str(), dir.string().size()); - ADSHANDLE hC0 = 0; - REQUIRE(AdsConnect60(srv, ADS_LOCAL_SERVER, nullptr, nullptr, 0, &hC0) - == AE_SUCCESS); - UNSIGNED8 def[] = "ID,N,8,0"; - for (const char* nm : {"BP1.DBF", "BP2.DBF"}) { - UNSIGNED8 tn[64]{}; - std::memcpy(tn, nm, std::strlen(nm) + 1); - ADSHANDLE h = 0; - REQUIRE(AdsCreateTable(hC0, tn, nullptr, ADS_CDX, 0, 0, 0, 0, def, - &h) == AE_SUCCESS); - for (int i = 1; i <= 5; ++i) { - REQUIRE(AdsAppendRecord(h) == AE_SUCCESS); - char v[16]; - std::snprintf(v, sizeof(v), "%d", i); - UNSIGNED8 fld[] = "ID"; - REQUIRE(AdsSetField(h, fld, - reinterpret_cast(v), - static_cast(std::strlen(v))) - == AE_SUCCESS); - } - REQUIRE(AdsCloseTable(h) == AE_SUCCESS); - } - REQUIRE(AdsDisconnect(hC0) == AE_SUCCESS); - - openads::network::Server s; - REQUIRE(s.start("127.0.0.1", 0).has_value()); - char uri[512]{}; - std::snprintf(uri, sizeof(uri), "tcp://127.0.0.1:%u/%s", - static_cast(s.port()), dir.string().c_str()); - UNSIGNED8 sb[512]{}; - std::memcpy(sb, uri, std::strlen(uri) + 1); - ADSHANDLE hC = 0; - REQUIRE(AdsConnect60(sb, ADS_REMOTE_SERVER, nullptr, nullptr, 0, &hC) - == AE_SUCCESS); - - UNSIGNED8 t1[] = "BP1.DBF"; - UNSIGNED8 t2[] = "BP2.DBF"; - ADSHANDLE hT = 0, hU = 0; - REQUIRE(AdsOpenTable(hC, t1, nullptr, ADS_CDX, 0, 0, 0, 0, &hT) - == AE_SUCCESS); - REQUIRE(AdsOpenTable(hC, t2, nullptr, ADS_CDX, 0, 0, 0, 0, &hU) - == AE_SUCCESS); - - openads::network::set_frame_trace_hook(&count_nav); - - // Settle: position somewhere known; whatever it costs is not counted. - REQUIRE(AdsGotoTop(hT) == AE_SUCCESS); - REQUIRE(AdsGotoTop(hU) == AE_SUCCESS); - reset_counts(); - - // R2 — self-GotoRecord: first GO wires and certifies the anchor, - // the repeat is served locally. - REQUIRE(AdsGotoRecord(hT, 3) == AE_SUCCESS); - CHECK(g_goto.load() == 1u); - CHECK(recno_of(hT) == 3u); - CHECK(g_recnum.load() == 0u); // R3: from the anchor - REQUIRE(AdsGotoRecord(hT, 3) == AE_SUCCESS); - CHECK(g_goto.load() == 1u); // no new frame - CHECK(recno_of(hT) == 3u); - - // R1 — the GotoTop ack certified the bottom in the same visit. - REQUIRE(AdsGotoTop(hT) == AE_SUCCESS); - CHECK(g_top.load() == 1u); - CHECK(recno_of(hT) == 1u); - REQUIRE(AdsGotoBottom(hT) == AE_SUCCESS); - CHECK(g_bottom.load() == 0u); // served from the pair blob - CHECK(recno_of(hT) == 5u); // and it IS the bottom row - CHECK(id_field(hT) == "5"); - UNSIGNED16 atEof = 1; - REQUIRE(AdsAtEOF(hT, &atEof) == AE_SUCCESS); - CHECK(atEof == 0); - - // The pair serve stamped bottom; a consecutive bottom is the old - // duplicate path, and the top that follows re-wires (no fresh - // certification for top was made by the LOCAL bottom serve... the - // certification from the earlier GotoBottom... none happened, so - // this top must wire and re-certify bottom). - REQUIRE(AdsGotoBottom(hT) == AE_SUCCESS); - CHECK(g_bottom.load() == 0u); // duplicate of the pair serve - REQUIRE(AdsGotoTop(hT) == AE_SUCCESS); - CHECK(g_top.load() == 2u); // wire: re-certifies bottom - REQUIRE(AdsGotoBottom(hT) == AE_SUCCESS); - CHECK(g_bottom.load() == 0u); // pair again - CHECK(recno_of(hT) == 5u); - - // A write on this table kills the certification (the blob's row - // bytes predate it) and the frame expires the conn-wide seq anyway. - REQUIRE(AdsGotoTop(hT) == AE_SUCCESS); - CHECK(g_top.load() == 3u); // wire: certify bottom again - REQUIRE(AdsLockRecord(hT, 0) == AE_SUCCESS); - UNSIGNED8 fld[] = "ID"; - UNSIGNED8 seven[] = "7"; - REQUIRE(AdsSetField(hT, fld, seven, 1) == AE_SUCCESS); - REQUIRE(AdsUnlockRecord(hT, 0) == AE_SUCCESS); - REQUIRE(AdsGotoBottom(hT) == AE_SUCCESS); - CHECK(g_bottom.load() == 1u); // back on the wire - CHECK(recno_of(hT) == 5u); - CHECK(id_field(hT) == "5"); // row 5 untouched by the write - - // The wire GotoBottom also certified the TOP: this GotoTop is - // itself pair-served (symmetric certification). - REQUIRE(AdsGotoTop(hT) == AE_SUCCESS); - CHECK(g_top.load() == 3u); // pair-served from the bottom - CHECK(recno_of(hT) == 1u); - // Conn-wide envelope: nav on ANOTHER table expires the pair. - REQUIRE(AdsGotoRecord(hU, 2) == AE_SUCCESS); // other table's nav - REQUIRE(AdsGotoBottom(hT) == AE_SUCCESS); - CHECK(g_bottom.load() == 2u); // wire: conn seq moved - - openads::network::set_frame_trace_hook(nullptr); - - REQUIRE(AdsCloseTable(hT) == AE_SUCCESS); - REQUIRE(AdsCloseTable(hU) == AE_SUCCESS); - REQUIRE(AdsDisconnect(hC) == AE_SUCCESS); - s.stop(); - fs::remove_all(dir, ec); -} From 0c40429d00bcbc6e8774b8b21c7f02f75b130aac Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Sat, 26 Sep 2026 01:30:39 -0500 Subject: [PATCH 57/97] Delete tests/10-CMakeLists.txt --- tests/10-CMakeLists.txt | 568 ---------------------------------------- 1 file changed, 568 deletions(-) delete mode 100644 tests/10-CMakeLists.txt diff --git a/tests/10-CMakeLists.txt b/tests/10-CMakeLists.txt deleted file mode 100644 index 4d547798..00000000 --- a/tests/10-CMakeLists.txt +++ /dev/null @@ -1,568 +0,0 @@ -# Test fixtures synthesise binary DBF / index / DD blobs with integer -# literals and `buf[int_index]` accesses. Under clang/gcc -Werror those -# trip -Wconversion / -Wsign-conversion even though the narrowing is -# intentional (masking bytes). Relax just those two for test code — the -# library in src/ keeps the full strict warning set. MSVC gets /utf-8 -# to avoid C4566 on non-ASCII identifiers in test source files. -if(MSVC) - add_compile_options(/utf-8) -elseif(NOT MSVC) - add_compile_options(-Wno-conversion -Wno-sign-conversion) - # __COUNTER__ is a widely-supported GCC/Clang extension used by doctest. - # Clang 19+ pedantically classifies it as -Wc2y-extensions; suppress it. - if(CMAKE_CXX_COMPILER_ID STREQUAL "Clang" OR CMAKE_CXX_COMPILER_ID STREQUAL "AppleClang") - include(CheckCXXCompilerFlag) - check_cxx_compiler_flag(-Wno-c2y-extensions HAS_NO_C2Y) - if(HAS_NO_C2Y) - add_compile_options(-Wno-c2y-extensions) - endif() - endif() -endif() - -add_executable(openads_unit_tests - unit/doctest_main.cpp - unit/script_engine_test.cpp - unit/abi_script_proc_test.cpp - unit/util_result_test.cpp - unit/util_span_test.cpp - unit/util_log_test.cpp - unit/serverd_config_ini_test.cpp - ${CMAKE_SOURCE_DIR}/tools/serverd/config_ini.cpp - unit/platform_file_test.cpp - unit/platform_lock_test.cpp - unit/platform_mmap_test.cpp - unit/platform_path_test.cpp - unit/fs_sandbox_test.cpp - unit/server_fs_test.cpp - unit/abi_server_fs_test.cpp - unit/platform_time_test.cpp - unit/platform_thread_test.cpp - unit/dbf_header_test.cpp - unit/dbf_field_test.cpp - unit/dbf_record_test.cpp - unit/dbf_write_test.cpp - unit/ntx_driver_test.cpp - unit/lock_mgr_test.cpp - unit/engine_table_test.cpp - unit/engine_table_write_test.cpp - unit/engine_cursor_test.cpp - unit/session_handle_registry_test.cpp - unit/handle_registry_kind_of_test.cpp - unit/backend_registry_test.cpp - unit/session_connection_test.cpp - unit/abi_smoke_test.cpp - unit/abi_write_smoke_test.cpp - unit/abi_pritpal_lock_test.cpp - unit/ntx_index_test.cpp - unit/cdx_index_test.cpp - unit/cdx_dup_key_split_test.cpp - unit/abi_alternating_append_test.cpp - unit/abi_mt_contention_test.cpp - unit/abi_mt_create_vs_dbfcdx_test.cpp - unit/cdx_ins_seq_test.cpp - unit/abi_ins_order_test.cpp - unit/engine_order_test.cpp - unit/engine_scope_test.cpp - unit/abi_index_smoke_test.cpp - unit/abi_qa_repro_test.cpp - unit/aof_expr_test.cpp - unit/aggregate_test.cpp - unit/abi_aggregate_test.cpp - unit/aof_eval_test.cpp - unit/abi_aof_test.cpp - unit/abi_bitmap_filter_test.cpp - unit/abi_cdx_tag_order_test.cpp - unit/abi_cdx_expr_index_scale_test.cpp - unit/abi_multitag_order_nav_test.cpp - unit/aes_test.cpp - unit/dbt_memo_test.cpp - unit/fpt_memo_test.cpp - unit/engine_memo_test.cpp - unit/abi_m4_smoke_test.cpp - unit/abi_m5_smoke_test.cpp - unit/tx_log_test.cpp - unit/abi_m5x_recovery_test.cpp - unit/abi_savepoint_test.cpp - unit/abi_tx_rollback_append_test.cpp - unit/data_dict_test.cpp - unit/abi_dd_test.cpp - unit/sql_parser_test.cpp - unit/abi_sql_smoke_test.cpp - unit/abi_plus_sqlite_read_test.cpp - unit/abi_plus_sqlite_write_test.cpp - unit/abi_plus_sqlite_filter_test.cpp - unit/abi_sql_uri_smoke_test.cpp - unit/abi_aggregate_sqlite_test.cpp - unit/abi_plus_sqlite_seek_test.cpp - unit/abi_plus_sqlite_passthrough_test.cpp - unit/abi_plus_sqlcipher_read_test.cpp - unit/lsn_map_test.cpp - unit/index_expr_test.cpp - unit/index_expr_sql_test.cpp - unit/zip_arch_test.cpp - unit/zip_files_abi_test.cpp - unit/abi_m92_real_test.cpp - unit/abi_create_table_test.cpp - unit/abi_adt_dat_extension_test.cpp - unit/abi_create_outside_datadir_test.cpp - unit/abi_remote_create_table_test.cpp - unit/abi_remote_logical_write_test.cpp - unit/abi_create_concurrent_test.cpp - unit/abi_remote_create_stress_test.cpp - unit/abi_openindex_create_race_test.cpp - unit/abi_setorder_natural_remote_test.cpp - unit/abi_append_lock_contention_test.cpp - unit/abi_append_queue_timeout_test.cpp - unit/abi_refresh_extract_test.cpp - unit/abi_create_index61_test.cpp - unit/abi_index_expr_validation_test.cpp - unit/abi_zap_pack_test.cpp - unit/abi_pack_reindex_softseek_test.cpp - unit/abi_stale_index_walk_test.cpp - unit/abi_index_intensive_test.cpp - unit/abi_index_intensive2_test.cpp - unit/adi_erase_insert_repro_test.cpp - unit/abi_lock_unlock_full_test.cpp - unit/abi_ntx_pack_reindex_test.cpp - unit/abi_ntx_pack_reindex_multipage_test.cpp - unit/abi_ntx_multipage_dup_test.cpp - unit/abi_ntx_internal_split_test.cpp - unit/abi_record_count_filter_test.cpp - unit/abi_reindex_test.cpp - unit/abi_cdx_conditional_index_test.cpp - unit/abi_conditional_index_nav_test.cpp - unit/abi_conditional_index_refilter_test.cpp - unit/abi_cond_refilter_no_close_test.cpp - unit/ntx_multilevel_test.cpp - unit/cdx_multilevel_test.cpp - unit/cdx_multitag_2nd_test.cpp - unit/cdx_prefix_seek_test.cpp - unit/abi_scope_partial_key_test.cpp - unit/abi_seek_last_partial_test.cpp - unit/abi_aof_index_agreement_test.cpp - unit/abi_aof_empty_result_test.cpp - unit/abi_adi_prefix_seek_multilevel_test.cpp - unit/abi_prefix_seek_deleted_test.cpp - unit/cdx_reindex_char_test.cpp - unit/cdx_prev_empty_leaf_test.cpp - unit/cdx_empty_tree_test.cpp - unit/abi_ntx_numeric_edge_test.cpp - unit/abi_memo_large_test.cpp - unit/abi_ordsetfocus_test.cpp - unit/abi_seek_empty_test.cpp - unit/abi_open_concurrent_header_test.cpp - unit/cdx_peer_append_5000_test.cpp - unit/abi_seek_numeric_alias_test.cpp - unit/abi_open_index_order_test.cpp - unit/abi_ordlistadd_path_test.cpp - unit/abi_gobottom_filtered_test.cpp - unit/abi_navigation_test.cpp - unit/abi_rddtst_repro_test.cpp - unit/engine_navigation_empty_test.cpp - unit/abi_deleted_records_test.cpp - unit/abi_sql_show_deleted_test.cpp - unit/abi_scoped_deleted_keycount_test.cpp - unit/abi_keycount_deleted_scan_test.cpp - unit/abi_index_key_count_test.cpp - unit/abi_key_type_test.cpp - unit/abi_pl852_reopen_seek_test.cpp - unit/abi_oem_chartype_default_collation_test.cpp - unit/abi_copy_table_test.cpp - unit/abi_find_table_test.cpp - unit/abi_binary_memo_test.cpp - unit/abi_ntx_multitag_test.cpp - unit/abi_ntx_numeric_key_test.cpp - unit/abi_cdx_char_keylen_test.cpp - unit/abi_cdx_index_direction_test.cpp - unit/abi_cdx_empty_table_keylen_test.cpp - unit/abi_cdx_recreate_tag_diff_expr_test.cpp - unit/abi_cdx_multitag_create_test.cpp - unit/abi_cdx_estaelec_compound_test.cpp - unit/abi_cdx_str_val_compound_test.cpp - unit/abi_cdx_issue131_test.cpp - unit/abi_ordnumber_test.cpp - unit/abi_server_info_test.cpp - unit/abi_unicode_test.cpp - unit/abi_lock_retry_test.cpp - unit/abi_fts_test.cpp - unit/abi_custom_key_test.cpp - unit/abi_fts_search_test.cpp - unit/abi_sql_contains_test.cpp - unit/abi_sql_operators_test.cpp - unit/index_expr_utf8_test.cpp - unit/abi_misc_impls_test.cpp - unit/abi_mgmt_test.cpp - unit/abi_mgmt_locks_indexes_test.cpp - unit/error_log_test.cpp - unit/backup_test.cpp - unit/mg_collector_test.cpp - unit/mg_wire_test.cpp - unit/abi_mgmt_remote_test.cpp - unit/abi_dd_crud_test.cpp - unit/abi_restructure_test.cpp - unit/abi_versioned_overloads_test.cpp - unit/abi_remote_overloads_test.cpp - unit/abi_remote_prefetch_test.cpp - unit/abi_remote_ordered_prefetch_test.cpp - unit/abi_remote_close_hang_test.cpp - unit/abi_remote_index_reopen_test.cpp - unit/abi_remote_zombie_lock_test.cpp - unit/network_skip_depth_test.cpp - unit/abi_remote_index_nav_test.cpp - unit/abi_remote_date_index_scope_test.cpp - unit/abi_remote_prodcdx_test.cpp - unit/abi_append_autolock_test.cpp - unit/abi_remote_only_access_test.cpp - unit/util_client_config_test.cpp - unit/abi_lock_comprehensive_test.cpp - unit/abi_get_set_record_test.cpp - unit/abi_set_relation_test.cpp - unit/abi_record_crc_aof_test.cpp - unit/abi_settings_refreshaof_test.cpp - unit/abi_dd_persistence_test.cpp - unit/abi_remote_timscope_test.cpp - unit/abi_setorder_zero_test.cpp - unit/dbf_vfp_test.cpp - unit/abi_sql_or_test.cpp - unit/abi_index_direction_test.cpp - unit/abi_sql_insert_test.cpp - unit/abi_sql_named_param_test.cpp - unit/abi_null_semantics_test.cpp - unit/abi_sql_orderby_test.cpp - unit/abi_sql_orderby_types_test.cpp - unit/abi_sql_dml_test.cpp - unit/abi_date_format_test.cpp - unit/abi_sql_projection_test.cpp - unit/abi_sql_temp_names_test.cpp - unit/abi_sql_ddl_test.cpp - unit/abi_sql_agg_test.cpp - unit/dbf_vfp_autoinc_test.cpp - unit/dbf_vfp_0x32_combined_test.cpp - unit/abi_vfp_create_test.cpp - unit/abi_sql_join_test.cpp - unit/abi_sql_in_test.cpp - unit/abi_sql_exists_test.cpp - unit/abi_sql_scalar_test.cpp - unit/abi_sql_union_test.cpp - unit/abi_sql_distinct_limit_test.cpp - unit/abi_sql_case_test.cpp - unit/abi_aep_test.cpp - unit/abi_encryption_test.cpp - unit/pbkdf2_test.cpp - unit/abi_sql_scalar_fn_test.cpp - unit/abi_sql_insert_select_test.cpp - unit/abi_sql_derived_test.cpp - unit/abi_sql_null_window_test.cpp - unit/network_wire_test.cpp - unit/abi_collation_codepage_test.cpp - unit/oem_collation_test.cpp - unit/cdx_build_bulk_collation_test.cpp - unit/abi_ntxpl852_seek_test.cpp - unit/abi_ntxpl852_collation_abi_test.cpp - unit/abi_index_collation_persist_test.cpp - unit/network_socket_test.cpp - unit/network_server_test.cpp - unit/network_pool_test.cpp - unit/network_frame_reader_test.cpp - unit/abi_adt_smoke_test.cpp - unit/abi_adt_create_test.cpp - unit/abi_adt_wide_numeric_test.cpp - unit/abi_adi_create_test.cpp - unit/abi_adi_separate_bag_test.cpp - unit/abi_adi_types_test.cpp - unit/abi_adi_multilevel_build_test.cpp - unit/abi_adt_scope_validation_test.cpp - unit/abi_adi_smoke_test.cpp - unit/abi_adi_clear_multilevel_test.cpp - unit/abi_adi_dat_extension_path_test.cpp - unit/abi_adi_estaelec_compound_test.cpp - unit/abi_adi_frontcoding_size_test.cpp - unit/abi_adi_legacy_bulk_size_test.cpp - unit/abi_adi_keycount_test.cpp - unit/abi_adi_native_estaelec_test.cpp - unit/abi_adi_reindex_bench_test.cpp - unit/abi_keycount_cache_test.cpp - unit/abi_adi_tagdir_order_test.cpp - unit/abi_adi_tagdir_prepend_read_test.cpp - unit/abi_adi_tagdir_wide_page_test.cpp - unit/abi_cdx_estaelec_compound_test.cpp - unit/abi_adi_ordinal_stable_test.cpp - unit/abi_adi_v2_explicit_override_test.cpp - unit/abi_sql_temp_browse_nav_test.cpp - unit/abi_stale_index_walk_test.cpp - unit/abi_adt_sql_test.cpp - unit/abi_adt_dat_extension_sql_test.cpp - unit/openads_sql_c_test.cpp - unit/abi_dd_table_prop_test.cpp - unit/abi_dd_user_prop_test.cpp - unit/abi_enum_test.cpp - unit/abi_dd_ri_test.cpp - unit/abi_dd_auth_test.cpp - unit/abi_dd_perms_test.cpp - unit/abi_dd_field_prop_test.cpp - unit/abi_dd_trigger_test.cpp - unit/abi_dd_trigger_fire_test.cpp - unit/abi_dd_proc_view_test.cpp - unit/abi_dd_remote_test.cpp - unit/abi_dd_remote_phase2_test.cpp - unit/abi_sql_system_tables_test.cpp - unit/abi_sql_system_primarykeys_test.cpp - unit/abi_sql_dd_sql_test.cpp - unit/abi_no_current_record_test.cpp - unit/codepage_test.cpp - unit/adm_memo_test.cpp - unit/sqlite_uri_test.cpp - unit/abi_sql_stmt_concurrency_test.cpp - unit/sqlite_concurrency_test.cpp - unit/proc_test.cpp - unit/sql_common_test.cpp - unit/sql_system_catalog_test.cpp - unit/sql_oracle_dialect_test.cpp - unit/sql_acl_store_test.cpp - unit/enterprise_config_test.cpp - unit/legacy_crt_shims_test.cpp - unit/dd_native_codec_test.cpp - unit/dll_test.cpp - unit/network_client_test.cpp - unit/abi_dd_invoicing_test.cpp - unit/abi_fetch_where_test.cpp - unit/network_setfields_test.cpp - unit/network_open_warm_test.cpp - unit/network_version_test.cpp - unit/network_nav_batch_test.cpp - unit/network_use_budget_test.cpp - unit/network_local_answers_test.cpp - unit/network_empty_window_test.cpp - unit/network_remote_reindex_test.cpp - unit/network_frame_trace_test.cpp - unit/network_boundary_pair_test.cpp - unit/network_commit_slimming_test.cpp - unit/network_teardown_batch_test.cpp - unit/network_pool_mt_test.cpp - unit/network_mutex_release_test.cpp - unit/network_lock_exclusion_test.cpp - unit/network_login_gate_stress_test.cpp - unit/abi_remote_lock_introspection_test.cpp - unit/network_exclusive_open_test.cpp - unit/backend_tier1_test.cpp - unit/abi_oads_file_funcs_test.cpp - unit/abi_create_index_path_test.cpp - unit/abi_pritpal_empty_index_test.cpp - unit/abi_remote_pritpal_empty_index_test.cpp - unit/abi_multiuser_nav_visibility_test.cpp - unit/repl_queue_test.cpp - unit/repl_catalog_test.cpp - unit/abi_repl_capture_test.cpp - unit/repl_apply_test.cpp - unit/abi_seek_after_order_change_test.cpp - unit/abi_index_collation_persist_test.cpp - unit/abi_index_header_compat_test.cpp -) - -# OpenADS ODBC driver (provider) test — drives the driver's SQL* exports -# directly. Windows only: relies on the MSVC SDK ODBC headers/types. -if(WIN32) - target_sources(openads_unit_tests PRIVATE - unit/odbc_driver_test.cpp - unit/odbc_driver_pk_test.cpp - ${CMAKE_SOURCE_DIR}/bindings/odbc/openads_odbc.cpp) -endif() - -if(OPENADS_WITH_POSTGRESQL OR OPENADS_WITH_MARIADB OR OPENADS_WITH_MSSQL - OR OPENADS_WITH_FIREBIRD) - target_sources(openads_unit_tests PRIVATE - unit/abi_sql_live_pg_maria_test.cpp) -endif() - -if(OPENADS_WITH_POSTGRESQL) - target_sources(openads_unit_tests PRIVATE - unit/abi_plus_postgres_read_test.cpp - unit/abi_plus_postgres_write_test.cpp - unit/abi_plus_postgres_filter_test.cpp - unit/abi_aggregate_postgres_test.cpp - unit/abi_plus_postgres_seek_test.cpp - unit/postgres_uri_test.cpp - ) -endif() - -if(OPENADS_WITH_MARIADB) - target_sources(openads_unit_tests PRIVATE - unit/maria_uri_test.cpp - unit/abi_plus_mariadb_read_test.cpp - unit/abi_plus_mariadb_seek_test.cpp - unit/abi_plus_mariadb_write_test.cpp - ) -endif() - -if(OPENADS_WITH_FIREBIRD) - target_sources(openads_unit_tests PRIVATE - unit/firebird_uri_test.cpp - unit/firebird_connection_test.cpp - unit/abi_plus_firebird_read_test.cpp - unit/abi_plus_firebird_write_test.cpp - ) -endif() - -if(OPENADS_WITH_MSSQL) - target_sources(openads_unit_tests PRIVATE - unit/abi_plus_mssql_connect_test.cpp - unit/abi_plus_mssql_read_test.cpp - unit/abi_plus_mssql_write_test.cpp - unit/tds_protocol_test.cpp - unit/mssql_table_skip_test.cpp - unit/mssql_uri_test.cpp - ) -endif() - -if(OPENADS_WITH_ODBC) - target_sources(openads_unit_tests PRIVATE - unit/oracle_uri_test.cpp - unit/odbc_uri_test.cpp - unit/abi_plus_odbc_read_test.cpp - unit/abi_plus_odbc_seek_test.cpp - unit/abi_plus_odbc_write_test.cpp - ) -endif() - -# M11.4 — side DLL with stored procedures used by abi_aep_test. -add_library(openads_test_aep_proc SHARED - fixtures/test_aep_proc.cpp -) -set_target_properties(openads_test_aep_proc PROPERTIES - LIBRARY_OUTPUT_DIRECTORY $ - RUNTIME_OUTPUT_DIRECTORY $ -) -add_dependencies(openads_unit_tests openads_test_aep_proc) -target_compile_definitions(openads_unit_tests PRIVATE - OPENADS_TEST_AEP_DLL="$" -) - -target_include_directories(openads_unit_tests SYSTEM PRIVATE - ${CMAKE_SOURCE_DIR}/third_party/doctest -) -target_include_directories(openads_unit_tests PRIVATE - ${CMAKE_SOURCE_DIR} - ${CMAKE_SOURCE_DIR}/src - ${CMAKE_SOURCE_DIR}/tests -) - -target_link_libraries(openads_unit_tests PRIVATE openads_core) - -if(OPENADS_WITH_SQLITE) - # The sqlite read/seek tests seed a fixture DB through the SQLite C - # API directly, so they need the amalgamation header + symbols. - target_link_libraries(openads_unit_tests PRIVATE openads_sqlite3) -endif() - -if(OPENADS_WITH_HARBOUR_UDF) - # Harbour libs for the UDF backend under test (registered by the - # hrb_udf test itself — core stays Harbour-free, see src/CMakeLists). - target_link_libraries(openads_unit_tests PRIVATE ${HARBOUR_LINK_LIBRARIES}) - # The test .hrb module is compiled from PRG at build time so the - # source of truth stays readable. Needs the Harbour compiler. - find_program(HARBOUR_COMPILER NAMES harbour - HINTS $ENV{HB_ROOT}/bin C:/harbour-git/bin/win/mingw-550S C:/harbour/bin - PATH_SUFFIXES linux/gcc) - if(NOT HARBOUR_COMPILER) - message(FATAL_ERROR "OPENADS_WITH_HARBOUR_UDF=ON but no 'harbour' compiler found (set HARBOUR_COMPILER)") - endif() - file(MAKE_DIRECTORY ${CMAKE_CURRENT_BINARY_DIR}/hrb) - add_custom_command( - OUTPUT ${CMAKE_CURRENT_BINARY_DIR}/hrb/udf_test.hrb - COMMAND ${HARBOUR_COMPILER} ${CMAKE_CURRENT_SOURCE_DIR}/hrb/udf_test.prg -gh "-o${CMAKE_CURRENT_BINARY_DIR}/hrb/udf_test.hrb" - DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/hrb/udf_test.prg - COMMENT "Compiling HRB UDF test module") - add_custom_target(openads_udf_test_hrb ALL - DEPENDS ${CMAKE_CURRENT_BINARY_DIR}/hrb/udf_test.hrb) - add_dependencies(openads_unit_tests openads_udf_test_hrb) - target_sources(openads_unit_tests PRIVATE unit/hrb_udf_test.cpp) - target_compile_definitions(openads_unit_tests PRIVATE - OPENADS_TEST_HRB_PATH="${CMAKE_CURRENT_BINARY_DIR}/hrb/udf_test.hrb") -endif() - -# Default CI/fast tier: skip stress tests tagged [slow], and timing- -# sensitive cases tagged [flaky] (proven load-flakes on shared CI -# runners: connection storms, lock races, teardown parks — see -# docs/known-issues.md). Both tiers still run everywhere else -# (local dev, explicit runs); the release gate depends on this tier. -# NOTE: the short flag MUST be -tce (test-case-exclude). Bare -e is -# silently ignored by doctest 2.4.11, and repeated -tce occurrences -# do NOT accumulate (parseOption takes one) — so slow+flaky share a -# single comma-separated -tce. The old -e=*[slow]* excluded nothing: -# every tier always ran the 240 s storm cases, which also explains -# past 30-minute Test timeouts on loaded runners. No embedded quotes -# around the filter value (they mangle args on Windows under ctest). -add_test(NAME openads_unit_tests COMMAND openads_unit_tests -tce=*[slow]*,*flaky*) -add_test(NAME openads_unit_tests_slow COMMAND openads_unit_tests -tc=*[slow]* -tce=*flaky*) - -# Session-pool MT stress: the pool test in 30 fresh processes (each run -# already repeats its 4-thread phase 25x). Catches rare races that one -# in-suite pass misses. Every patch must keep this green. -if(UNIX) - add_test(NAME network_pool_mt_repeat COMMAND sh -c "i=0; while [ $i -lt 30 ]; do \"$0\" -tc=\"Session pool: MT*\" >/dev/null 2>&1 || { echo \"pool MT test failed on run $i\"; \"$0\" -tc=\"Session pool: MT*\"; exit 1; }; i=$((i+1)); done; echo \"pool MT test: 30 clean runs\"" $) -endif() - -# SQL URI backend smoke (sqlite://): runs a focused doctest subset in CI. -if(OPENADS_WITH_POSTGRESQL) - add_test(NAME pg_live_smoke COMMAND openads_unit_tests - -tc="SQL live PG*") -endif() -if(OPENADS_WITH_MARIADB) - add_test(NAME maria_live_smoke COMMAND openads_unit_tests - -tc="SQL live Maria*") -endif() -if(OPENADS_WITH_MSSQL) - add_test(NAME mssql_live_smoke COMMAND openads_unit_tests - -tc="SQL live MSSQL*") -endif() -if(OPENADS_WITH_FIREBIRD) - add_test(NAME firebird_live_smoke COMMAND openads_unit_tests - -tc="SQL live Firebird*") -endif() - -if(OPENADS_WITH_SQLITE) - add_test(NAME sql_uri_smoke COMMAND openads_unit_tests - -tc="SQL URI smoke*") - add_test(NAME sqlite_filter_pushdown COMMAND openads_unit_tests - -tc="Tier-2 push-down: SET FILTER*") - add_test(NAME sqlite_seek_smoke COMMAND openads_unit_tests - -tc="ABI Plus: sqlite*") -endif() - -# Harbour smoke fixture builder (M8.6). Produces a CDX matching the -# M8.5 fixture DBF using OpenADS' own CdxIndex::create + insert path, -# so the Harbour smoke tests dbSeek end-to-end through OpenADS. -add_executable(make_cdx smoke/harbour/make_cdx.cpp) -target_include_directories(make_cdx PRIVATE ${CMAKE_SOURCE_DIR}/src) -target_link_libraries(make_cdx PRIVATE openads_core) - -# ADT/ADI fixture generator (M4 smoke tests — landlords, leases, properties). -add_executable(generate_adi_fixtures tools/generate_adi_fixtures.cpp) -target_include_directories(generate_adi_fixtures PRIVATE ${CMAKE_SOURCE_DIR}/include) -target_link_libraries(generate_adi_fixtures PRIVATE openads_ace) - -add_executable(probe_cdx smoke/harbour/probe_cdx.cpp) -target_include_directories(probe_cdx PRIVATE ${CMAKE_SOURCE_DIR}/src) -target_link_libraries(probe_cdx PRIVATE openads_core) - -add_executable(probe_cdx2 smoke/harbour/probe_cdx2.cpp) -target_include_directories(probe_cdx2 PRIVATE ${CMAKE_SOURCE_DIR}/src) -target_link_libraries(probe_cdx2 PRIVATE openads_core) - -# Benchmark: DBF 500K records + CDX index build -add_executable(dbf_cdx_bench bench/dbf_cdx_bench.cpp) -target_include_directories(dbf_cdx_bench PRIVATE ${CMAKE_SOURCE_DIR}/include) -target_link_libraries(dbf_cdx_bench PRIVATE openads_ace) - -# Remote benchmark: tcp:// to iMac server -add_executable(remote_bench bench/remote_bench.cpp) -target_include_directories(remote_bench PRIVATE ${CMAKE_SOURCE_DIR}/include) -target_link_libraries(remote_bench PRIVATE openads_ace) - -add_executable(remote_test bench/remote_test.cpp) -target_include_directories(remote_test PRIVATE ${CMAKE_SOURCE_DIR}/include) -target_link_libraries(remote_test PRIVATE openads_ace) - -add_executable(oads_imac_test bench/oads_imac_test.cpp) -target_include_directories(oads_imac_test PRIVATE ${CMAKE_SOURCE_DIR}/include) -target_link_libraries(oads_imac_test PRIVATE openads_ace) From 9586a413a51653782765855392552b4fd258b8bd Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Sat, 26 Sep 2026 01:30:51 -0500 Subject: [PATCH 58/97] Delete .github/workflows/1-release.yml --- .github/workflows/1-release.yml | 594 -------------------------------- 1 file changed, 594 deletions(-) delete mode 100644 .github/workflows/1-release.yml diff --git a/.github/workflows/1-release.yml b/.github/workflows/1-release.yml deleted file mode 100644 index 7b1136de..00000000 --- a/.github/workflows/1-release.yml +++ /dev/null @@ -1,594 +0,0 @@ -name: release - -on: - push: - tags: - - "v*" - workflow_dispatch: - inputs: - tag: - description: "tag to package (e.g. v1.0.0-rc1)" - required: true - -permissions: - contents: write - -jobs: - build: - name: ${{ matrix.os }} / ${{ matrix.arch }} - runs-on: ${{ matrix.os }} - # 90: a cold Harbour SDK build (Linux leg) plus the full tree can - # exceed the old 60-minute cap; cached runs finish far sooner. - timeout-minutes: 90 - # The macOS runners can stall in the test step (>60 min); - # don't let them block the release while that's investigated. - continue-on-error: ${{ startsWith(matrix.os, 'macos') }} - strategy: - fail-fast: false - matrix: - include: - - os: windows-2022 - arch: x64 - preset: msvc-x64 - artifact_ext: zip - cmake_extra: "" - - os: windows-2022 - arch: x86 - preset: msvc-x86 - artifact_ext: zip - cmake_extra: "" - - os: ubuntu-24.04 - arch: x64 - preset: ninja-clang - artifact_ext: tar.gz - cmake_extra: "" - # One macOS leg builds a universal (arm64 + x86_64) - # binary on the Apple-Silicon runner — GitHub's Intel - # macos-13 runners are scarce and stall the release in - # the queue. A universal archive runs on both arches. - - os: macos-14 - arch: universal - preset: default - artifact_ext: tar.gz - cmake_extra: '-DCMAKE_OSX_ARCHITECTURES="arm64;x86_64"' - - steps: - - uses: actions/checkout@v5 - with: - ref: ${{ inputs.tag || github.ref }} - - - name: Resolve tag - id: tag - shell: bash - run: | - T="${{ inputs.tag }}" - if [ -z "$T" ]; then T="${GITHUB_REF#refs/tags/}"; fi - echo "tag=$T" >> "$GITHUB_OUTPUT" - echo "version=${T#v}" >> "$GITHUB_OUTPUT" - - - name: Install Ninja (Linux / macOS) - if: runner.os != 'Windows' - uses: seanmiddleditch/gha-setup-ninja@v6 - - # Server-side Harbour UDFs (.hrb): the Linux leg embeds hbvm, - # so it needs a Harbour SDK. Built from source once, then cached - # (mirrors tools/scripts/bootstrap_harbour_ci.ps1 on Windows). - - name: Cache Harbour SDK (Linux) - if: runner.os == 'Linux' - uses: actions/cache@v4 - with: - path: ${{ runner.temp }}/harbour-udf - key: harbour-udf-linux-4ec2e154ed92757418bc30af571ab90240ab3209-v1 - - - name: Provision Harbour SDK (Linux) - if: runner.os == 'Linux' - shell: bash - env: - HARBOUR_REF: 4ec2e154ed92757418bc30af571ab90240ab3209 - run: | - set -e - HB="$RUNNER_TEMP/harbour-udf" - echo "HB_ROOT=$HB/install" >> "$GITHUB_ENV" - if [ -f "$HB/install/include/hbvm.h" ] && \ - [ -n "$(find "$HB/install" -name 'libhbvmmt.a' 2>/dev/null | head -1)" ] && \ - [ -n "$(find "$HB/install" -name harbour -type f 2>/dev/null | head -1)" ]; then - echo "Harbour SDK cached at $HB/install" - else - sudo apt-get update - sudo apt-get install -y build-essential libncurses-dev libx11-dev - rm -rf "$HB" - mkdir -p "$HB" - git clone --depth 1 --branch master https://github.com/harbour/core.git "$HB/src" - git -C "$HB/src" fetch --depth 1 origin "$HARBOUR_REF" - git -C "$HB/src" checkout "$HARBOUR_REF" - cd "$HB/src" - make -j"$(nproc)" install HB_INSTALL_PREFIX="$HB/install" HB_BUILD_3RDEXT=no - fi - # Resolve exact SDK paths (no layout guessing in CMake): - # headers, MT VM archive, compiler binary. - HB_INC="$(dirname "$(find "$HB/install" -name hbvm.h | head -1)")" - HB_LIBDIR="$(dirname "$(find "$HB/install" -name 'libhbvmmt.a' | head -1)")" - HB_CC="$(find "$HB/install" -name harbour -type f -executable | head -1)" - echo "Harbour SDK layout:" - echo " include: ${HB_INC:-MISSING}" - echo " libdir: ${HB_LIBDIR:-MISSING}" - echo " compiler:${HB_CC:-MISSING}" - if [ -z "$HB_INC" ] || [ -z "$HB_LIBDIR" ] || [ -z "$HB_CC" ]; then - echo "::error::Harbour SDK layout unexpected — full listing:" - find "$HB/install" -maxdepth 4 | sort | head -80 - exit 1 - fi - { - echo "HARBOUR_INCLUDE_DIR=$HB_INC" - echo "HARBOUR_LIB_DIR=$HB_LIBDIR" - echo "HARBOUR_COMPILER=$HB_CC" - } >> "$GITHUB_ENV" - - - name: Configure - shell: bash - run: | - cmake --preset ${{ matrix.preset }} \ - -DOPENADS_WITH_TLS=ON \ - -DOPENADS_WITH_HTTP=ON \ - ${{ matrix.cmake_extra }} \ - ${{ runner.os == 'Linux' && format('-DOPENADS_WITH_HARBOUR_UDF=ON -DHARBOUR_INCLUDE_DIR={0} -DHARBOUR_LIB_DIR={1} -DHARBOUR_COMPILER={2}', env.HARBOUR_INCLUDE_DIR, env.HARBOUR_LIB_DIR, env.HARBOUR_COMPILER) || '' }} - - - name: Build - run: cmake --build build/${{ matrix.preset }} --config Release - - - name: Test - # The macOS runners can stall here (>60 min); cap them and - # let the leg pass anyway so it can't block the release. - timeout-minutes: ${{ startsWith(matrix.os, 'macos') && 20 || 30 }} - continue-on-error: ${{ startsWith(matrix.os, 'macos') }} - run: ctest --test-dir build/${{ matrix.preset }} - --output-on-failure -C Release - - # Static ACE library for Harbour + MinGW (libopenace32.a / - # libopenace64.a): linking it into an hbmk2 -comp=mingw[64] app - # embeds the whole ACE client+engine in the .exe — no ace32.dll / - # ace64.dll to deploy. Built with MSYS2's matching MinGW toolchain - # (the MSVC legs above can't produce a GNU archive). Lean config: - # no HTTP console, no TLS, no tests. (Requested by Pritpal Bedi.) - - name: Set up MinGW for the static ACE library - if: always() && runner.os == 'Windows' - uses: msys2/setup-msys2@v2 - with: - msystem: ${{ matrix.arch == 'x64' && 'MINGW64' || 'MINGW32' }} - # v1.09.62 postmortem: update:true + upstream base (June) died - # deterministically on both Windows legs at this step (MSYS2 - # mirror PGP/db-sync rot hits the full -Syuu upgrade path; - # our diff cannot influence this step). Use the runner image's - # preinstalled MSYS2 (days old, not months) and install only - # the toolchain — no system upgrade. If this still fails, the - # step-9 log lines (not just the exit code) are required - # before further workflow surgery. - release: false - update: false - install: >- - ${{ matrix.arch == 'x64' && 'mingw-w64-x86_64-toolchain mingw-w64-x86_64-cmake' || 'mingw-w64-i686-toolchain mingw-w64-i686-cmake' }} - - - name: Build static ACE library (MinGW) - if: always() && runner.os == 'Windows' - shell: msys2 {0} - run: | - cmake -S . -B build/mingw-${{ matrix.arch }} -G "MinGW Makefiles" \ - -DCMAKE_BUILD_TYPE=Release \ - -DOPENADS_BUILD_TESTS=OFF \ - -DOPENADS_WITH_HTTP=OFF \ - -DOPENADS_WITH_TLS=OFF \ - -DOPENADS_WARNINGS_AS_ERRORS=OFF - cmake --build build/mingw-${{ matrix.arch }} \ - --target openads_ace_static -j"$(nproc)" - ls -la build/mingw-${{ matrix.arch }}/src/libopenace*.a - - - name: Stage release files (POSIX) - if: always() && runner.os != 'Windows' - shell: bash - run: | - STAGE="openads-${{ steps.tag.outputs.version }}-${{ runner.os == 'Linux' && 'linux' || 'macos' }}-${{ matrix.arch }}" - mkdir -p "$STAGE" - BUILD="build/${{ matrix.preset }}" - # Engine shared lib is built as libopenace64.{so,dylib}. Ship it, - # plus a drop-in-named copy libace64.* for apps that load by the - # canonical ACE name. - for f in "$BUILD"/src/libopenace64.*; do - [ -e "$f" ] || continue - cp "$f" "$STAGE/" - cp "$f" "$STAGE/$(basename "$f" | sed 's/libopenace64/libace64/')" - done - # Server + bench CLIs - cp "$BUILD"/tools/serverd/openads_serverd "$STAGE/" - cp "$BUILD"/tools/bench/openads_bench "$STAGE/" - cp LICENSE NOTICE README.md openads.ini.sample "$STAGE/" - tar czvf "$STAGE.tar.gz" "$STAGE" - ls -la "$STAGE.tar.gz" - echo "ASSET=$STAGE.tar.gz" >> "$GITHUB_ENV" - - # Canonical Windows ZIP name is openads--windows-.zip - # (CPack and this workflow). Never emit openads--win-.zip — - # that short name was a hand-rolled slim kit uploaded beside the CI - # zip on v1.8.84–v1.8.86 and shipped a different ace64.dll. See - # packaging/windows/README.md ("Release ZIP names"). - - name: Stage release files (Windows) - if: always() && runner.os == 'Windows' - shell: pwsh - run: | - $stage = "openads-${{ steps.tag.outputs.version }}-windows-${{ matrix.arch }}" - New-Item -ItemType Directory -Path $stage | Out-Null - $build = "build/${{ matrix.preset }}" - $bits = if ("${{ matrix.arch }}" -eq "x64") { "64" } else { "32" } - $builtDll = "$build/src/Release/openace$bits.dll" - $builtLib = "$build/src/Release/openace$bits.lib" - if (-not (Test-Path $builtDll)) { - throw "Engine DLL missing: $builtDll" - } - if (-not (Test-Path $builtLib)) { - throw "Import library missing: $builtLib" - } - # Ship BOTH names at the archive root: - # openace64.dll / openace32.dll — OpenADS build product (PHP ext, - # side-by-side with SAP ACE) - # ace64.dll / ace32.dll — drop-in for Harbour rddads / FWH - Copy-Item $builtDll "$stage/openace$bits.dll" - Copy-Item $builtLib "$stage/openace$bits.lib" - Copy-Item "$build/src/Release/openace$bits.exp" "$stage/" -ErrorAction SilentlyContinue - Copy-Item $builtDll "$stage/ace$bits.dll" - Copy-Item $builtLib "$stage/ace$bits.lib" - foreach ($req in @( - "openace$bits.dll", "openace$bits.lib", - "ace$bits.dll", "ace$bits.lib")) { - if (-not (Test-Path "$stage/$req")) { - throw "Release staging incomplete: missing $req" - } - } - # Per-compiler import libraries (MSVC / MinGW-GCC / Borland) for - # ace.dll — prebuilt under dist/import-libs because the CI - # runners have no Borland toolchain (see dist/import-libs/gen.ps1). - New-Item -ItemType Directory "$stage/lib" | Out-Null - Copy-Item -Recurse "dist/import-libs/${{ matrix.arch }}/*" "$stage/lib/" - # Static ACE library (MinGW): link into an hbmk2 -comp=mingw[64] - # app and no ace.dll is needed at all. Built in the - # "Build static ACE library (MinGW)" step above. - $staticLib = "build/mingw-${{ matrix.arch }}/src/libopenace$bits.a" - if (-not (Test-Path $staticLib)) { - throw "Static ACE library missing: $staticLib" - } - Copy-Item $staticLib "$stage/libopenace$bits.a" - # Server + bench CLIs - Copy-Item "$build/tools/serverd/Release/openads_serverd.exe" "$stage/" - Copy-Item "$build/tools/bench/Release/openads_bench.exe" "$stage/" - Copy-Item LICENSE,NOTICE,README.md,openads.ini.sample $stage/ - Copy-Item QUICKSTART.md "$stage/" -ErrorAction SilentlyContinue - # Harbour HB_FUNC wrappers for the oads_* VFS API — compiled - # into the app, not shipped in the DLL. Arch-independent C - # source: both Windows zips carry it. (Pritpal Bedi.) - New-Item -ItemType Directory "$stage/contrib/oads_hb" -Force | Out-Null - Copy-Item contrib/oads_hb/oads_hb.c,contrib/oads_hb/README.md "$stage/contrib/oads_hb/" - if ("${{ matrix.arch }}" -eq "x86") { - Copy-Item src/openads_ace_x86_stdcall.def "$stage/" - # Ship the 32-bit MinGW import library at the archive root too: - # 32-bit Harbour (hbmk2 -comp=mingw, rddads) links it directly. - # (Reported by Pritpal Bedi.) - Copy-Item dist/import-libs/x86/mingw/libace32.a "$stage/" - } else { - Copy-Item src/openads_ace.def "$stage/" - } - Compress-Archive -Path "$stage/*" -DestinationPath "$stage.zip" -Force - Get-Item "$stage.zip" - "ASSET=$stage.zip" | Out-File -FilePath $env:GITHUB_ENV -Append - - - name: Upload artifact (workflow run) - if: always() && env.ASSET != '' - uses: actions/upload-artifact@v4 - with: - name: openads-${{ runner.os }}-${{ matrix.arch }} - path: ${{ env.ASSET }} - - # glibc 2.31 compatibility build for older Linux servers (Ubuntu 20.04): - # the ubuntu-24.04 leg's binaries require GLIBC_2.38+, so the same tree - # is built inside an ubuntu:20.04 container. Asset is additive: publish - # flattens it into the release but does not require the leg to be green. - # (Requested by Pritpal Bedi for his LAN test server.) - build-glibc231: - name: ubuntu 20.04 container / x64 (glibc 2.31) - runs-on: ubuntu-24.04 - container: ubuntu:20.04 - timeout-minutes: 90 - steps: - # Bare image: git/curl first so checkout + CMake FetchContent work. - - name: Install bootstrap tools - shell: bash - run: | - export DEBIAN_FRONTEND=noninteractive - apt-get update - apt-get install -y --no-install-recommends \ - git ca-certificates curl xz-utils - - - uses: actions/checkout@v5 - with: - ref: ${{ inputs.tag || github.ref }} - # Full history + tags: the version stamp falls back to - # `git describe --tags`, and a shallow tag checkout can leave - # the tag unresolvable to the container's older git. - fetch-depth: 0 - - - name: Resolve tag - id: tag - shell: bash - run: | - T="${{ inputs.tag }}" - if [ -z "$T" ]; then T="${GITHUB_REF#refs/tags/}"; fi - echo "tag=$T" >> "$GITHUB_OUTPUT" - echo "version=${T#v}" >> "$GITHUB_OUTPUT" - - # CMakePresets v4 needs CMake >= 3.23; 20.04's apt ships 3.16, so - # vendor the official binary (runs on any glibc >= 2.17). Compiler: - # focal's clang-10 rejects this tree's C++20 implicit-move returns - # (P1825, e.g. server_fs.cpp), so this leg builds with g++-10 (in - # the focal archive, implements P1825); warnings-as-errors stays - # off for the older toolchain. - - name: Install toolchain and CMake - shell: bash - run: | - export DEBIAN_FRONTEND=noninteractive - apt-get update - apt-get install -y --no-install-recommends \ - build-essential g++-10 ninja-build pkg-config \ - libncurses-dev libx11-dev bison flex python3 perl - curl -sSL -o /tmp/cmake.tar.gz \ - https://github.com/Kitware/CMake/releases/download/v3.28.6/cmake-3.28.6-linux-x86_64.tar.gz - mkdir -p /opt/cmake - tar xzf /tmp/cmake.tar.gz -C /opt/cmake --strip-components=1 - echo "/opt/cmake/bin" >> "$GITHUB_PATH" - /opt/cmake/bin/cmake --version - - # Same Harbour UDF SDK as the 24.04 leg, cached under its own key - # (binaries are glibc-specific). - - name: Cache Harbour SDK (glibc 2.31) - uses: actions/cache@v4 - with: - path: ${{ runner.temp }}/harbour-udf-glibc231 - key: harbour-udf-linux-glibc231-4ec2e154ed92757418bc30af571ab90240ab3209-v1 - - - name: Provision Harbour SDK (glibc 2.31) - shell: bash - env: - HARBOUR_REF: 4ec2e154ed92757418bc30af571ab90240ab3209 - run: | - set -e - HB="$RUNNER_TEMP/harbour-udf-glibc231" - echo "HB_ROOT=$HB/install" >> "$GITHUB_ENV" - if [ -f "$HB/install/include/hbvm.h" ] && \ - [ -n "$(find "$HB/install" -name 'libhbvmmt.a' 2>/dev/null | head -1)" ] && \ - [ -n "$(find "$HB/install" -name harbour -type f 2>/dev/null | head -1)" ]; then - echo "Harbour SDK cached at $HB/install" - else - rm -rf "$HB" - mkdir -p "$HB" - git clone --depth 1 --branch master https://github.com/harbour/core.git "$HB/src" - git -C "$HB/src" fetch --depth 1 origin "$HARBOUR_REF" - git -C "$HB/src" checkout "$HARBOUR_REF" - cd "$HB/src" - make -j"$(nproc)" install HB_INSTALL_PREFIX="$HB/install" HB_BUILD_3RDEXT=no - fi - HB_INC="$(dirname "$(find "$HB/install" -name hbvm.h | head -1)")" - HB_LIBDIR="$(dirname "$(find "$HB/install" -name 'libhbvmmt.a' | head -1)")" - HB_CC="$(find "$HB/install" -name harbour -type f -executable | head -1)" - echo "Harbour SDK layout:" - echo " include: ${HB_INC:-MISSING}" - echo " libdir: ${HB_LIBDIR:-MISSING}" - echo " compiler:${HB_CC:-MISSING}" - if [ -z "$HB_INC" ] || [ -z "$HB_LIBDIR" ] || [ -z "$HB_CC" ]; then - echo "::error::Harbour SDK layout unexpected - full listing:" - find "$HB/install" -maxdepth 4 | sort | head -80 - exit 1 - fi - { - echo "HARBOUR_INCLUDE_DIR=$HB_INC" - echo "HARBOUR_LIB_DIR=$HB_LIBDIR" - echo "HARBOUR_COMPILER=$HB_CC" - } >> "$GITHUB_ENV" - - - name: Configure - shell: bash - run: | - cmake --preset ninja-clang \ - -DOPENADS_VERSION_FORCE=${{ steps.tag.outputs.version }} \ - -DCMAKE_C_COMPILER=gcc-10 \ - -DCMAKE_CXX_COMPILER=g++-10 \ - -DOPENADS_WITH_TLS=ON \ - -DOPENADS_WITH_HTTP=ON \ - -DOPENADS_WARNINGS_AS_ERRORS=OFF \ - -DOPENADS_WITH_HARBOUR_UDF=ON \ - -DHARBOUR_INCLUDE_DIR="$HARBOUR_INCLUDE_DIR" \ - -DHARBOUR_LIB_DIR="$HARBOUR_LIB_DIR" \ - -DHARBOUR_COMPILER="$HARBOUR_COMPILER" - - - name: Build - run: cmake --build build/ninja-clang --config Release - - - name: Test - timeout-minutes: 30 - run: ctest --test-dir build/ninja-clang --output-on-failure -C Release - - - name: Stage release files - shell: bash - run: | - STAGE="openads-${{ steps.tag.outputs.version }}-linux-glibc231" - mkdir -p "$STAGE" - BUILD="build/ninja-clang" - for f in "$BUILD"/src/libopenace64.*; do - [ -e "$f" ] || continue - cp "$f" "$STAGE/" - cp "$f" "$STAGE/$(basename "$f" | sed 's/libopenace64/libace64/')" - done - cp "$BUILD"/tools/serverd/openads_serverd "$STAGE/" - cp "$BUILD"/tools/bench/openads_bench "$STAGE/" - cp LICENSE NOTICE README.md openads.ini.sample "$STAGE/" - tar czvf "$STAGE.tar.gz" "$STAGE" - ls -la "$STAGE.tar.gz" - echo "ASSET=$STAGE.tar.gz" >> "$GITHUB_ENV" - - # The whole point of the leg: prove the binaries stay within - # glibc 2.31 before they ship. - - name: Verify glibc ceiling (<= 2.31) - shell: bash - run: | - set -e - STAGE="openads-${{ steps.tag.outputs.version }}-linux-glibc231" - fail=0 - for b in "$STAGE"/openads_serverd "$STAGE"/openads_bench \ - "$STAGE"/libopenace64.so "$STAGE"/libace64.so; do - hit=$(objdump -T "$b" | grep -oE 'GLIBC_2\.(3[2-9]|[4-9][0-9])' | sort -uV || true) - if [ -n "$hit" ]; then - echo "::error::$b references > GLIBC_2.31: $hit" - fail=1 - fi - done - [ "$fail" -eq 0 ] && echo "All staged binaries within GLIBC_2.31." - - - name: Upload artifact (workflow run) - if: always() && env.ASSET != '' - uses: actions/upload-artifact@v4 - with: - name: openads-Linux-x64-glibc231 - path: ${{ env.ASSET }} - - - publish: - name: Publish GitHub Release - needs: build - # A cancelled/failed best-effort leg (macOS) must not skip publish; - # only a genuine workflow-run cancellation should. - if: ${{ always() && needs.build.result != 'skipped' }} - runs-on: ubuntu-24.04 - steps: - - uses: actions/checkout@v5 - - - name: Resolve tag - id: tag - shell: bash - run: | - T="${{ inputs.tag }}" - if [ -z "$T" ]; then T="${GITHUB_REF#refs/tags/}"; fi - echo "tag=$T" >> "$GITHUB_OUTPUT" - - - uses: actions/download-artifact@v4 - with: - path: dist - - - name: Flatten artifacts - run: | - mkdir -p out - find dist -type f \( -name "*.zip" -o -name "*.tar.gz" \) \ - -exec cp {} out/ \; - ls -la out - - - name: Resolve version - id: ver - shell: bash - run: | - T="${{ steps.tag.outputs.tag }}" - echo "version=${T#v}" >> "$GITHUB_OUTPUT" - - # Repo convention: every release commit ships release-notes-.md - # (see v1.09.22/v1.09.23). The published GitHub Release body is that - # file verbatim — never the auto-generated commit list — so the - # "What to test" and Packages sections reach users intact. - - name: Resolve release notes - id: notes - shell: bash - run: | - F="release-notes-${{ steps.ver.outputs.version }}.md" - if [ ! -f "$F" ]; then - echo "::error::Missing $F — every release tag needs its notes file." - ls release-notes-*.md || true - exit 1 - fi - echo "path=$F" >> "$GITHUB_OUTPUT" - - # All-or-nothing release: every leg must be green AND every - # canonical asset present, otherwise fail loudly instead of - # shipping a partial kit (v1.09.50 went out without the Linux - # tarball because publish ran on 3 of 4 legs). macOS keeps its - # stall-tolerant test step, but a real macOS build break still - # blocks here via the job conclusion. - - name: Require all legs green and all assets present - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - V="${{ steps.ver.outputs.version }}" - missing=0 - for asset in \ - "openads-${V}-windows-x64.zip" \ - "openads-${V}-windows-x86.zip" \ - "openads-${V}-linux-x64.tar.gz" \ - "openads-${V}-macos-universal.tar.gz"; do - if [ ! -f "out/${asset}" ]; then - echo "::error::Missing required release asset: ${asset}" - missing=1 - else - echo "Found: ${asset}" - fi - done - JOBS_URL="repos/${{ github.repository }}/actions/runs/${{ github.run_id }}/jobs?per_page=20" - echo "Leg conclusions:" - gh api "$JOBS_URL" --jq '.jobs[] | "\(.name): \(.conclusion)"' - for leg in \ - "windows-2022 / x64" \ - "windows-2022 / x86" \ - "ubuntu-24.04 / x64" \ - "macos-14 / universal"; do - concl=$(gh api "$JOBS_URL" --jq \ - ".jobs[] | select(.name == \"$leg\") | .conclusion") - if [ "$concl" != "success" ]; then - echo "::error::Leg '$leg' conclusion is '${concl:-missing}' — blocking publish." - missing=1 - fi - done - if [ "$missing" -ne 0 ]; then - echo "::error::Refusing partial release." - exit 1 - fi - echo "All legs green, all assets present." - - # The short *-win-x64.zip / *-win-x86.zip names are forbidden. - # They were a manual FiveTechSoft kit that sat next to the CI - # *-windows-*.zip and confused users (different binary + layout). - - name: Reject leftover *-win-*.zip names - shell: bash - run: | - shopt -s nullglob - bad=(out/openads-*-win-*.zip) - if ((${#bad[@]})); then - echo "::error::Do not ship *-win-x64.zip / *-win-x86.zip." - echo "Canonical names are *-windows-x64.zip / *-windows-x86.zip." - printf '%s\n' "${bad[@]}" - exit 1 - fi - - - name: Publish release - uses: softprops/action-gh-release@v2 - with: - tag_name: ${{ steps.tag.outputs.tag }} - body_path: ${{ steps.notes.outputs.path }} - files: out/* - - # If the tag was published by hand with the old slim kit already - # attached, drop those assets so only the CI windows-* zips remain. - - name: Drop leftover manual *-win-*.zip assets - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - shell: bash - run: | - TAG="${{ steps.tag.outputs.tag }}" - mapfile -t names < <( - gh release view "$TAG" --json assets --jq '.assets[].name' \ - | grep -E '^openads-.+-win-(x64|x86)\.zip$' || true - ) - for name in "${names[@]}"; do - echo "Removing leftover duplicate asset: $name" - gh release delete-asset "$TAG" "$name" --yes - done From b0c3b0c78dc3cd2f0593ff9918376d1173fa7aa9 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898283+github-actions[bot]@users.noreply.github.com> Date: Sat, 26 Sep 2026 16:03:37 +0000 Subject: [PATCH 59/97] Add opt-in client CreateIndex ABI diagnostic probe Applied by apply-patch workflow, run 36253704815, started by bedipritpal. --- src/abi/ace_exports.cpp | 62 +++++++++++++++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 2 deletions(-) diff --git a/src/abi/ace_exports.cpp b/src/abi/ace_exports.cpp index 92407c22..61af182b 100644 --- a/src/abi/ace_exports.cpp +++ b/src/abi/ace_exports.cpp @@ -16256,6 +16256,27 @@ UNSIGNED32 ENTRYPOINT AdsCloseAllIndexes(ADSHANDLE hTable) { return ok(); } +// One-off client ABI discriminator for Vouch INDEX ON. Off unless explicitly +// enabled. Keep this independent of wire_trace: a rejected call never hits wire. +static void create_index_diag(const char* fmt, ...) { + const char* path = std::getenv("OPENADS_CREATE_INDEX_DIAG_FILE"); + if (!path || !*path) return; + static std::mutex mu; + std::lock_guard lock(mu); + FILE* f = std::fopen(path, "a"); + if (!f) return; + va_list args; + va_start(args, fmt); + std::vfprintf(f, fmt, args); + va_end(args); + std::fputc('\n', f); + std::fclose(f); +} + +static const char* create_index_arg(const UNSIGNED8* p) { + return p ? reinterpret_cast(p) : ""; +} + UNSIGNED32 ENTRYPOINT AdsCreateIndex61(ADSHANDLE hTable, UNSIGNED8* pucFileName, UNSIGNED8* pucIndexName, @@ -16266,8 +16287,15 @@ UNSIGNED32 ENTRYPOINT AdsCreateIndex61(ADSHANDLE hTable, UNSIGNED16 usPageSize, ADSHANDLE* phIndex) { arc2_trace("AdsCreateIndex61"); + create_index_diag("61 ENTRY h=%llu file=%.160s tag=%.160s expr=%.160s cond=%.160s keyfilter=%.160s opts=0x%08lx page=%u out=%p", + static_cast(hTable), create_index_arg(pucFileName), + create_index_arg(pucIndexName), create_index_arg(pucExpr), + create_index_arg(pucCondition), create_index_arg(pucKeyFilter), + static_cast(ulOptions), static_cast(usPageSize), + static_cast(phIndex)); if (phIndex == nullptr || pucFileName == nullptr || pucIndexName == nullptr || pucExpr == nullptr) { + create_index_diag("61 EXIT null-arg 5000 h=%llu", static_cast(hTable)); return fail(openads::AE_INTERNAL_ERROR, "null arg"); } #if defined(OPENADS_WITH_SQLITE) @@ -16438,6 +16466,7 @@ UNSIGNED32 ENTRYPOINT AdsCreateIndex61(ADSHANDLE hTable, } #endif if (auto* rt = get_remote_table(hTable)) { + create_index_diag("61 ROUTE remote h=%llu table_id=%u", static_cast(hTable), static_cast(rt->id)); std::string path = openads::abi::to_internal(pucFileName, 0); path = normalize_index_path(std::move(path)); std::string tag = openads::abi::to_internal(pucIndexName, 0); @@ -16446,10 +16475,19 @@ UNSIGNED32 ENTRYPOINT AdsCreateIndex61(ADSHANDLE hTable, ? openads::abi::to_internal(pucCondition, 0) : std::string(); std::string kf = pucKeyFilter ? openads::abi::to_internal(pucKeyFilter, 0) : std::string(); + create_index_diag("61 WIRE 0x94 h=%llu table_id=%u path=%.160s tag=%.160s", + static_cast(hTable), static_cast(rt->id), + path.c_str(), tag.c_str()); auto r = rt->conn->create_index(rt->id, path, tag, expr, cond, kf, ulOptions, usPageSize); - if (!r) return fail(r.error()); + if (!r) { + create_index_diag("61 WIRE error h=%llu code=%u (no success ACK)", + static_cast(hTable), static_cast(r.error().code)); + return fail(r.error()); + } + create_index_diag("61 WIRE ACK success h=%llu index_id=%u (0x95)", + static_cast(hTable), static_cast(r.value())); // Creating a tag opens EVERY tag in the bag (ADS semantics -- the // server-side create binds siblings as parked views), so refresh // the client registry from the server: OrdCount() (AdsGetNumIndexes) @@ -16534,8 +16572,11 @@ UNSIGNED32 ENTRYPOINT AdsCreateIndex61(ADSHANDLE hTable, } Table* t = get_table(hTable); if (!t) { + create_index_diag("61 EXIT unknown-handle 5000 h=%llu remote_lookup_missed=1", + static_cast(hTable)); return fail(openads::AE_INTERNAL_ERROR, "unknown table"); } + create_index_diag("61 ROUTE native h=%llu", static_cast(hTable)); // Settle any coalesced dirty record first: the build loop below reads // rows straight from disk, so a pending buffer edit would be indexed // from its stale on-disk image (and silently dropped by @@ -17242,11 +17283,18 @@ UNSIGNED32 ENTRYPOINT AdsCreateIndex(ADSHANDLE hTable, UNSIGNED8* pucFile, UNSIGNED8* pucCondition, UNSIGNED32 ulOptions, UNSIGNED16 usKeyType, ADSHANDLE* phIndex) { arc2_trace("AdsCreateIndex"); + create_index_diag("legacy ENTRY h=%llu file=%.160s tag=%.160s expr=%.160s cond=%.160s opts=0x%08lx keytype=%u out=%p", + static_cast(hTable), create_index_arg(pucFile), + create_index_arg(pucTag), create_index_arg(pucExpr), + create_index_arg(pucCondition), static_cast(ulOptions), + static_cast(usKeyType), static_cast(phIndex)); if (phIndex == nullptr) { + create_index_diag("legacy EXIT null-out 5000 h=%llu", static_cast(hTable)); return fail(openads::AE_INTERNAL_ERROR, "null index out-param"); } // Legacy API: remote tables route through AdsCreateIndex61 (M12.16). if (get_remote_table(hTable) != nullptr) { + create_index_diag("legacy ROUTE 61 h=%llu", static_cast(hTable)); return AdsCreateIndex61(hTable, pucFile, pucTag, pucExpr, pucCondition, nullptr, ulOptions, usKeyType ? usKeyType @@ -17255,8 +17303,10 @@ UNSIGNED32 ENTRYPOINT AdsCreateIndex(ADSHANDLE hTable, UNSIGNED8* pucFile, } Table* t = get_table(hTable); if (!t) { + create_index_diag("legacy EXIT unknown-handle 5000 h=%llu", static_cast(hTable)); return fail(openads::AE_INTERNAL_ERROR, "unknown table or null out"); } + create_index_diag("legacy ROUTE native h=%llu", static_cast(hTable)); // Settle any coalesced dirty record first (see AdsCreateIndex61). if (auto cr = t->commit_dirty_record(); !cr) return fail(cr.error()); auto file = normalize_index_path( @@ -39492,9 +39542,17 @@ UNSIGNED32 ENTRYPOINT AdsCreateIndex90(ADSHANDLE hObj, UNSIGNED8* pucFileName, UNSIGNED32 ulOptions, UNSIGNED32 ulPageSize, UNSIGNED8* /*pucCollation*/, ADSHANDLE* phIndex) { arc2_trace("AdsCreateIndex90"); - return AdsCreateIndex61(hObj, pucFileName, pucTag, pucExpr, pucCondition, + create_index_diag("90 ENTRY h=%llu file=%.160s tag=%.160s expr=%.160s cond=%.160s while=%.160s opts=0x%08lx page=%lu out=%p", + static_cast(hObj), create_index_arg(pucFileName), + create_index_arg(pucTag), create_index_arg(pucExpr), + create_index_arg(pucCondition), create_index_arg(pucWhile), + static_cast(ulOptions), static_cast(ulPageSize), + static_cast(phIndex)); + auto rc = AdsCreateIndex61(hObj, pucFileName, pucTag, pucExpr, pucCondition, pucWhile, ulOptions, static_cast(ulPageSize), phIndex); + create_index_diag("90 EXIT h=%llu rc=%lu", static_cast(hObj), static_cast(rc)); + return rc; } UNSIGNED32 ENTRYPOINT AdsDDAddTable90(ADSHANDLE hConnect, UNSIGNED8* pucAlias, From 6e92812256b8f081526fea37bef520b182fc372b Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Sat, 26 Sep 2026 11:07:53 -0500 Subject: [PATCH 60/97] Create release-notes-1.09.68-diag-createindex.md --- release-notes-1.09.68-diag-createindex.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 release-notes-1.09.68-diag-createindex.md diff --git a/release-notes-1.09.68-diag-createindex.md b/release-notes-1.09.68-diag-createindex.md new file mode 100644 index 00000000..978579b9 --- /dev/null +++ b/release-notes-1.09.68-diag-createindex.md @@ -0,0 +1,9 @@ +Built for Pritpal Bedi - bedipritpal/OpenADS, forked from FiveTechSoft/OpenADS. + +## v1.09.68-diag-createindex - temporary client-side index-create probe + +This diagnostic build starts from v1.09.68-mtfix12 and adds an opt-in trace at the ACE client ABI boundary. It is for one fresh VouchADS org creation, not normal use. No server replacement is needed. + +Set `OPENADS_CREATE_INDEX_DIAG_FILE=C:\tmp\createindex_diag.log` before starting VouchADS.exe. Create C:\tmp first if needed. The DLL writes ENTRY records for AdsCreateIndex, AdsCreateIndex61 and AdsCreateIndex90, including the handle, file, tag, expression, optional condition and options; it records null-argument and unknown-handle 5000 exits, native or remote routing, and CreateIndex wire send and ACK/error. Only index-create calls are recorded. The log may contain application table paths and index expressions; share it privately, not in a public issue. Unset the environment variable after the run and restore the original DLL. + +Use the Windows x86 archive's ace32.dll for a 32-bit VouchADS.exe, or Windows x64 archive's ace64.dll only for a 64-bit executable. Put the diagnostic DLL alongside VouchADS.exe in place of its existing ACE DLL, after saving a backup. The version string in this build is 1.09.68-diag-createindex. Do not switch the server binary for this client-side test. From 88139866e8d628360a20cb50daa6f4492b8de7c7 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898283+github-actions[bot]@users.noreply.github.com> Date: Sat, 26 Sep 2026 21:09:34 +0000 Subject: [PATCH 61/97] Fix false index-bag existence from open DBF Applied by apply-patch workflow, run 36271593575, started by bedipritpal. --- src/abi/ace_exports.cpp | 6 +++--- tests/unit/network_commit_slimming_test.cpp | 14 +++++++++++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/src/abi/ace_exports.cpp b/src/abi/ace_exports.cpp index 61af182b..18d3de2c 100644 --- a/src/abi/ace_exports.cpp +++ b/src/abi/ace_exports.cpp @@ -10795,9 +10795,9 @@ UNSIGNED32 ENTRYPOINT AdsCheckExistence(ADSHANDLE hConn, UNSIGNED8* pucName, if (stem_of(rt->prod_bag_path) == want || (!rt->last_open_bag.empty() && stem_of(rt->last_open_bag) == want) || - stem_of(rt->name) == want) { - // rt->name is the table's own path as opened: a - // live (or parked) table proves its dbf exists. + // An open table proves only its exact file exists, + // not a different extension with the same stem. + rt->name == name) { *pbExists = 1; return ok(); } diff --git a/tests/unit/network_commit_slimming_test.cpp b/tests/unit/network_commit_slimming_test.cpp index fdcaf275..402df98b 100644 --- a/tests/unit/network_commit_slimming_test.cpp +++ b/tests/unit/network_commit_slimming_test.cpp @@ -312,6 +312,18 @@ TEST_CASE("Commit slimming: dir and missing-file answers cache") { == AE_SUCCESS); CHECK(ex == 1u); CHECK(cs_op(kOpFileExists) == fe0); + + // Regression: the open CS.DBF must NOT prove CS.Z01 exists merely + // because the stems match. The first miss goes to the server; the + // second is served by the negative FileExists cache. + REQUIRE(AdsCheckExistence(hConn, (UNSIGNED8*)"CS.Z01", &ex) + == AE_SUCCESS); + CHECK(ex == 0u); + CHECK(cs_op(kOpFileExists) == fe0 + 1); + REQUIRE(AdsCheckExistence(hConn, (UNSIGNED8*)"CS.Z01", &ex) + == AE_SUCCESS); + CHECK(ex == 0u); + CHECK(cs_op(kOpFileExists) == fe0 + 1); REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); // Missing file: repeated "no" answers stop hitting the wire. @@ -321,7 +333,7 @@ TEST_CASE("Commit slimming: dir and missing-file answers cache") { REQUIRE(AdsCheckExistence(hConn, (UNSIGNED8*)"NOPE.CDX", &ex) == AE_SUCCESS); CHECK(ex == 0u); - CHECK(cs_op(kOpFileExists) == fe0 + 1); + CHECK(cs_op(kOpFileExists) == fe0 + 2); REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); srv.stop(); From 90a38304d259e5349abf84522a1a4c26d22517d0 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Sat, 26 Sep 2026 16:30:34 -0500 Subject: [PATCH 62/97] Create release-notes-1.09.68-mtfix13.md --- release-notes-1.09.68-mtfix13.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 release-notes-1.09.68-mtfix13.md diff --git a/release-notes-1.09.68-mtfix13.md b/release-notes-1.09.68-mtfix13.md new file mode 100644 index 00000000..4349a772 --- /dev/null +++ b/release-notes-1.09.68-mtfix13.md @@ -0,0 +1,7 @@ +Built for Pritpal Bedi - bedipritpal/OpenADS, forked from FiveTechSoft/OpenADS. + +## v1.09.68-mtfix13 - correct index-bag existence checks + +An optimization added in mtfix10 answered that `brwcon01.z01` existed if `brwcon01.dbf` was open, because it compared filenames after removing their extensions. With the bag absent, this could tell the application to skip creating its index. This build restricts the table-name shortcut to the exact table path. Checking for an unopened bag now goes through the normal file-existence path; already-open index bags retain their earlier shortcut. A regression test checks that a live DBF cannot make a missing bag of the same stem appear present, and that a repeated miss uses the negative cache. The prior index-create diagnostic remains available when `OPENADS_CREATE_INDEX_DIAG_FILE` is set; it is off by default. + +This is a targeted test build, not yet proof that it fixes Vouch's early exit. Test by starting a fresh org with BRWCON01.dbf and BRWCON01.z01 absent, then check that BRWCON01.z01 is created and the app continues. Save a backup of your old ace32.dll, replace only the client DLL in your application folder from the Windows x86 archive, and restore it after the test if needed. No server update is needed for this client-side change. From 3c915da8a1a3ff96a600f2c57c43c1ce6ddb45eb Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898283+github-actions[bot]@users.noreply.github.com> Date: Sat, 26 Sep 2026 23:51:59 +0000 Subject: [PATCH 63/97] Diagnostic: disable boundary-pair navigation for mtfix14 A/B Applied by apply-patch workflow, run 36280427155, started by bedipritpal. --- src/network/client.cpp | 5 +-- src/network/client.h | 6 +++- src/network/session.cpp | 5 +-- tests/unit/network_boundary_pair_test.cpp | 40 ++++++++--------------- tests/unit/network_nav_batch_test.cpp | 9 ++--- tests/unit/network_use_budget_test.cpp | 4 +-- 6 files changed, 30 insertions(+), 39 deletions(-) diff --git a/src/network/client.cpp b/src/network/client.cpp index 2fbb7b8b..5eb3c2d7 100644 --- a/src/network/client.cpp +++ b/src/network/client.cpp @@ -418,8 +418,9 @@ void connect_pack_payload(std::vector& payload, // to a client that only understands forward ones (see kCapPrefetchBackward). std::uint32_t caps = kCapPrefetchConsume | kCapPrefetchBackward | kCapOpenTableMode | kCapSetFieldsBatch - | kCapFlushInCloseAll | kCapNavOrderFuse - | kCapNavBoundaryPair; + | kCapFlushInCloseAll | kCapNavOrderFuse; + // mtfix14 diagnostic: do not advertise consumption of pair blobs. + // Other capabilities remain untouched. for (int i = 0; i < 4; ++i) payload.push_back(static_cast((caps >> (8 * i)) & 0xFFu)); } diff --git a/src/network/client.h b/src/network/client.h index ef4b1e93..68c65678 100644 --- a/src/network/client.h +++ b/src/network/client.h @@ -130,7 +130,11 @@ class RemoteConnection { // (see kCapNavBoundaryPair): one frame proves both ends, so a // back-to-back dbGoTop(); dbGoBottom() ritual costs one RTT. bool server_nav_boundary_pair() const noexcept { - return (server_caps_ & kCapNavBoundaryPair) != 0; + // mtfix14 diagnostic: keep all other mtfix13 behavior, but never + // request the opposite-boundary pair, even from older servers that + // advertise it. This isolates B_BIG's slow GotoTop without changing + // the wire shape for peers or the detached-workarea lane pinning. + return false; } // Current cursor-generation sequence (see nav_seq_). Relaxed load diff --git a/src/network/session.cpp b/src/network/session.cpp index 56803e18..c292c6ee 100644 --- a/src/network/session.cpp +++ b/src/network/session.cpp @@ -1805,8 +1805,9 @@ DispatchResult Session::dispatch(const Frame& f) { openads::network::kCapSetFieldsBatch | openads::network::kCapFlushInCloseAll | openads::network::kCapNavOrderFuse | - openads::network::kCapFlushTableDurable | - openads::network::kCapNavBoundaryPair; + openads::network::kCapFlushTableDurable; + // mtfix14 diagnostic: do not advertise boundary pairs + // to existing clients connecting to this test server. reply.payload.push_back( static_cast( scaps & 0xFFu)); reply.payload.push_back( diff --git a/tests/unit/network_boundary_pair_test.cpp b/tests/unit/network_boundary_pair_test.cpp index 7bb2cd75..5b63b4f0 100644 --- a/tests/unit/network_boundary_pair_test.cpp +++ b/tests/unit/network_boundary_pair_test.cpp @@ -118,54 +118,42 @@ TEST_CASE("boundary pair, self-goto, and recno anchor over loopback") { CHECK(g_goto.load() == 1u); // no new frame CHECK(recno_of(hT) == 3u); - // R1 — the GotoTop ack certified the bottom in the same visit. + // mtfix14 diagnostic: no pair is negotiated. Each opposite-boundary + // navigation goes over the wire; duplicate same-boundary calls remain + // suppressed. Verify the record and field, not just frame counts. REQUIRE(AdsGotoTop(hT) == AE_SUCCESS); CHECK(g_top.load() == 1u); CHECK(recno_of(hT) == 1u); REQUIRE(AdsGotoBottom(hT) == AE_SUCCESS); - CHECK(g_bottom.load() == 0u); // served from the pair blob - CHECK(recno_of(hT) == 5u); // and it IS the bottom row + CHECK(g_bottom.load() == 1u); + CHECK(recno_of(hT) == 5u); CHECK(id_field(hT) == "5"); UNSIGNED16 atEof = 1; REQUIRE(AdsAtEOF(hT, &atEof) == AE_SUCCESS); CHECK(atEof == 0); - - // The pair serve stamped bottom; a consecutive bottom is the old - // duplicate path, and the top that follows re-wires (no fresh - // certification for top was made by the LOCAL bottom serve... the - // certification from the earlier GotoBottom... none happened, so - // this top must wire and re-certify bottom). REQUIRE(AdsGotoBottom(hT) == AE_SUCCESS); - CHECK(g_bottom.load() == 0u); // duplicate of the pair serve + CHECK(g_bottom.load() == 1u); // duplicate REQUIRE(AdsGotoTop(hT) == AE_SUCCESS); - CHECK(g_top.load() == 2u); // wire: re-certifies bottom + CHECK(g_top.load() == 2u); REQUIRE(AdsGotoBottom(hT) == AE_SUCCESS); - CHECK(g_bottom.load() == 0u); // pair again - CHECK(recno_of(hT) == 5u); - - // A write on this table kills the certification (the blob's row - // bytes predate it) and the frame expires the conn-wide seq anyway. + CHECK(g_bottom.load() == 2u); REQUIRE(AdsGotoTop(hT) == AE_SUCCESS); - CHECK(g_top.load() == 3u); // wire: certify bottom again + CHECK(g_top.load() == 3u); REQUIRE(AdsLockRecord(hT, 0) == AE_SUCCESS); UNSIGNED8 fld[] = "ID"; UNSIGNED8 seven[] = "7"; REQUIRE(AdsSetField(hT, fld, seven, 1) == AE_SUCCESS); REQUIRE(AdsUnlockRecord(hT, 0) == AE_SUCCESS); REQUIRE(AdsGotoBottom(hT) == AE_SUCCESS); - CHECK(g_bottom.load() == 1u); // back on the wire + CHECK(g_bottom.load() == 3u); CHECK(recno_of(hT) == 5u); - CHECK(id_field(hT) == "5"); // row 5 untouched by the write - - // The wire GotoBottom also certified the TOP: this GotoTop is - // itself pair-served (symmetric certification). + CHECK(id_field(hT) == "5"); REQUIRE(AdsGotoTop(hT) == AE_SUCCESS); - CHECK(g_top.load() == 3u); // pair-served from the bottom + CHECK(g_top.load() == 4u); CHECK(recno_of(hT) == 1u); - // Conn-wide envelope: nav on ANOTHER table expires the pair. - REQUIRE(AdsGotoRecord(hU, 2) == AE_SUCCESS); // other table's nav + REQUIRE(AdsGotoRecord(hU, 2) == AE_SUCCESS); REQUIRE(AdsGotoBottom(hT) == AE_SUCCESS); - CHECK(g_bottom.load() == 2u); // wire: conn seq moved + CHECK(g_bottom.load() == 4u); openads::network::set_frame_trace_hook(nullptr); diff --git a/tests/unit/network_nav_batch_test.cpp b/tests/unit/network_nav_batch_test.cpp index de64a33a..be180c1e 100644 --- a/tests/unit/network_nav_batch_test.cpp +++ b/tests/unit/network_nav_batch_test.cpp @@ -177,15 +177,12 @@ TEST_CASE("Nav batching: duplicate GotoTop/GotoBottom skip their frame") { REQUIRE(AdsGotoBottom(hTable) == AE_SUCCESS); CHECK(nb_op(kOpGotoBottom) == bot0 + 1); - // mtfix12 R1: the wire GotoBottom certified the TOP in the same - // server visit, so this top answers from the pair certification — - // stronger than the old re-wire. A wire nav with no certification - // (GotoRecord) invalidates it: the next top goes out again. + // mtfix14 diagnostic: no pair certification; both top calls wire. REQUIRE(AdsGotoTop(hTable) == AE_SUCCESS); - CHECK(nb_op(kOpGotoTop) == top0); // pair-certified + CHECK(nb_op(kOpGotoTop) == top0 + 1); REQUIRE(AdsGotoRecord(hTable, 2) == AE_SUCCESS); REQUIRE(AdsGotoTop(hTable) == AE_SUCCESS); - CHECK(nb_op(kOpGotoTop) == top0 + 1); // invalidated: back on wire + CHECK(nb_op(kOpGotoTop) == top0 + 2); REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); diff --git a/tests/unit/network_use_budget_test.cpp b/tests/unit/network_use_budget_test.cpp index 3dd545ef..d82ca6f2 100644 --- a/tests/unit/network_use_budget_test.cpp +++ b/tests/unit/network_use_budget_test.cpp @@ -111,8 +111,8 @@ TEST_CASE("Nav batching: full USE cycle stays within wire budget") { // boundary probes and duplicate navs contribute zero frames. CHECK(total <= 14u); CHECK(delta(0x40) == 1u); // GotoTop: second suppressed - CHECK(delta(0x64) == 0u); // GotoBottom: mtfix12 pair-certified by - // the GotoTop's ack, second suppressed + CHECK(delta(0x64) == 1u); // mtfix14: first GotoBottom goes to server; + // the duplicate remains suppressed CHECK(delta(0x48) == 0u); // AtEOF: served locally CHECK(delta(0x4C) == 0u); // AtBOF: served locally From ea3d9954c8fccd2d7aacce35d72c7e27514a91a4 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Sat, 26 Sep 2026 19:08:12 -0500 Subject: [PATCH 64/97] Create release-notes-1.09.68-mtfix14.md --- release-notes-1.09.68-mtfix14.md | 9 +++++++++ 1 file changed, 9 insertions(+) create mode 100644 release-notes-1.09.68-mtfix14.md diff --git a/release-notes-1.09.68-mtfix14.md b/release-notes-1.09.68-mtfix14.md new file mode 100644 index 00000000..0624060c --- /dev/null +++ b/release-notes-1.09.68-mtfix14.md @@ -0,0 +1,9 @@ +Built for Pritpal Bedi - bedipritpal/OpenADS, forked from FiveTechSoft/OpenADS. + +## v1.09.68-mtfix14 - B_BIG boundary-pair diagnostic + +This is a diagnostic test build, not a performance fix. Pritpal's 10-thread B_BIG trace on mtfix13 showed that ten GotoTop requests took roughly 0.9-1.2 seconds apiece, while 100 appends and field writes were individually fast. All worker traffic used one TCP lane. The mtfix12 boundary-pair feature makes each GotoTop also visit the opposite end of the table and count index keys, and it is the leading unproven source of the delay. + +This build disables only boundary-pair negotiation and requests on both sides of the wire. It retains mtfix13's index-bag existence correction, client lane pinning for detached-workarea lock identity, write/commit behavior and other navigation optimizations. It works with an existing server or client: a diagnostic client will not request a pair even from an older server advertising it, and a diagnostic server will not advertise pairs to older clients. The opposite-boundary call simply falls back to its normal wire request. + +**What to test:** Replace only the x86 client `ace32.dll` with the DLL in the Windows x86 archive, keeping the mtfix13 server unchanged. With no client trace enabled, run the same 10-thread, 100-record B_BIG job under the same conditions as the 185 ms upstream and 8.4 s mtfix13 comparisons. Record the job time and whether the first browse appears while workers are still running. A short trace on a separate run can confirm GotoTop returns without the 0x02 pair request; trace I/O itself can change timings. Back up the original DLL and restore it afterward. If this closes the gap, the boundary-pair server subphases need timing before a permanent fix is chosen. A remaining delay would point back to lane pinning or another interaction. Do not deploy this diagnostic build as a production fix. From beab98a4128732d6ed4e059728c90cf3d7a19933 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898283+github-actions[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 03:10:09 +0000 Subject: [PATCH 65/97] mtfix15: restore boundary pairs without eager scoped key counting Applied by apply-patch workflow, run 36290285016, started by bedipritpal. --- src/network/client.cpp | 5 +- src/network/client.h | 6 +- src/network/session.cpp | 26 ++-- tests/unit/network_boundary_pair_test.cpp | 141 +++++++++++++++++++--- tests/unit/network_nav_batch_test.cpp | 10 +- tests/unit/network_use_budget_test.cpp | 4 +- 6 files changed, 145 insertions(+), 47 deletions(-) diff --git a/src/network/client.cpp b/src/network/client.cpp index 5eb3c2d7..2fbb7b8b 100644 --- a/src/network/client.cpp +++ b/src/network/client.cpp @@ -418,9 +418,8 @@ void connect_pack_payload(std::vector& payload, // to a client that only understands forward ones (see kCapPrefetchBackward). std::uint32_t caps = kCapPrefetchConsume | kCapPrefetchBackward | kCapOpenTableMode | kCapSetFieldsBatch - | kCapFlushInCloseAll | kCapNavOrderFuse; - // mtfix14 diagnostic: do not advertise consumption of pair blobs. - // Other capabilities remain untouched. + | kCapFlushInCloseAll | kCapNavOrderFuse + | kCapNavBoundaryPair; for (int i = 0; i < 4; ++i) payload.push_back(static_cast((caps >> (8 * i)) & 0xFFu)); } diff --git a/src/network/client.h b/src/network/client.h index 68c65678..ef4b1e93 100644 --- a/src/network/client.h +++ b/src/network/client.h @@ -130,11 +130,7 @@ class RemoteConnection { // (see kCapNavBoundaryPair): one frame proves both ends, so a // back-to-back dbGoTop(); dbGoBottom() ritual costs one RTT. bool server_nav_boundary_pair() const noexcept { - // mtfix14 diagnostic: keep all other mtfix13 behavior, but never - // request the opposite-boundary pair, even from older servers that - // advertise it. This isolates B_BIG's slow GotoTop without changing - // the wire shape for peers or the detached-workarea lane pinning. - return false; + return (server_caps_ & kCapNavBoundaryPair) != 0; } // Current cursor-generation sequence (see nav_seq_). Relaxed load diff --git a/src/network/session.cpp b/src/network/session.cpp index c292c6ee..a613aad7 100644 --- a/src/network/session.cpp +++ b/src/network/session.cpp @@ -1149,11 +1149,14 @@ void Session::pack_bound_trailer(Frame& reply, std::uint32_t id) { } // mtfix12 R1 (kCapNavBoundaryPair) — see session.h. The pair section: -// [u8 flags][u32 trailer_len][trailer][bound 6|10][u32 keycount?] -// flags bit 0x02 = pair bound carries reccount, bit 0x04 = keycount -// present (ordered tables; natural order derives count == reccount on -// the client). The trailer is pack_row_trailer output at lookahead -// depth 0 for the OPPOSITE boundary, read inside this same visit, so +// [u8 flags][u32 trailer_len][trailer][bound 6|10] +// flags bit 0x02 = pair bound carries reccount. Bit 0x04 (optional key +// count) remains unset: counting all scoped index keys on every GoTop +// made B_BIG's 10-thread job 8.4 s instead of ~220 ms. If an opposite +// GoBottom is actually consumed, the client asks for its key count only +// when its per-order cache is cold, preserving scoped key-number truth. +// The trailer is pack_row_trailer output at lookahead depth 0 for the +// OPPOSITE boundary, read inside this same visit, so // the client's adjacent opposite-boundary call applies it verbatim. void Session::pack_boundary_pair(Frame& reply, std::uint32_t id, int which, ADSHANDLE hord, @@ -1164,7 +1167,6 @@ void Session::pack_boundary_pair(Frame& reply, std::uint32_t id, blob.opcode = reply.opcode; Frame bnd; bnd.opcode = reply.opcode; - UNSIGNED32 kc = 0; bool granted = false; if (hord != 0) { // Ordered table: navigate the ABI twin (it carries the order @@ -1181,7 +1183,6 @@ void Session::pack_boundary_pair(Frame& reply, std::uint32_t id, pack_row_trailer(blob, id, 0); pack_bound_trailer(bnd, id); if (bnd.payload.size() >= 10) flags |= 0x02; - if (AdsGetKeyCount(hord, 0, &kc) == 0) flags |= 0x04; granted = true; } if (empty_landing) { @@ -1218,12 +1219,6 @@ void Session::pack_boundary_pair(Frame& reply, std::uint32_t id, blob.payload.begin(), blob.payload.end()); reply.payload.insert(reply.payload.end(), bnd.payload.begin(), bnd.payload.end()); - if ((flags & 0x04) != 0) { - reply.payload.push_back(static_cast( kc & 0xFFu)); - reply.payload.push_back(static_cast((kc >> 8) & 0xFFu)); - reply.payload.push_back(static_cast((kc >> 16) & 0xFFu)); - reply.payload.push_back(static_cast((kc >> 24) & 0xFFu)); - } } // M12.22/M12.23 — read-ahead depth for one forward Skip. @@ -1805,9 +1800,8 @@ DispatchResult Session::dispatch(const Frame& f) { openads::network::kCapSetFieldsBatch | openads::network::kCapFlushInCloseAll | openads::network::kCapNavOrderFuse | - openads::network::kCapFlushTableDurable; - // mtfix14 diagnostic: do not advertise boundary pairs - // to existing clients connecting to this test server. + openads::network::kCapFlushTableDurable | + openads::network::kCapNavBoundaryPair; reply.payload.push_back( static_cast( scaps & 0xFFu)); reply.payload.push_back( diff --git a/tests/unit/network_boundary_pair_test.cpp b/tests/unit/network_boundary_pair_test.cpp index 5b63b4f0..a596d479 100644 --- a/tests/unit/network_boundary_pair_test.cpp +++ b/tests/unit/network_boundary_pair_test.cpp @@ -18,18 +18,19 @@ namespace fs = std::filesystem; namespace { -std::atomic g_top{0}, g_bottom{0}, g_goto{0}, g_recnum{0}; +std::atomic g_top{0}, g_bottom{0}, g_goto{0}, g_recnum{0}, g_keycount{0}; void count_nav(const void*, std::uint8_t op, std::uint32_t, std::size_t, std::uint8_t, std::size_t, long long) { if (op == 0x40) g_top.fetch_add(1); // GotoTop if (op == 0x64) g_bottom.fetch_add(1); // GotoBottom if (op == 0x58) g_goto.fetch_add(1); // GotoRecord - if (op == 0x4E) g_recnum.fetch_add(1); // GetRecordNum + if (op == 0x4E) g_recnum.fetch_add(1); + if (op == 0xB0) g_keycount.fetch_add(1); // GetKeyCount } void reset_counts() { - g_top = 0; g_bottom = 0; g_goto = 0; g_recnum = 0; + g_top = 0; g_bottom = 0; g_goto = 0; g_recnum = 0; g_keycount = 0; } std::uint32_t recno_of(ADSHANDLE h) { @@ -118,42 +119,54 @@ TEST_CASE("boundary pair, self-goto, and recno anchor over loopback") { CHECK(g_goto.load() == 1u); // no new frame CHECK(recno_of(hT) == 3u); - // mtfix14 diagnostic: no pair is negotiated. Each opposite-boundary - // navigation goes over the wire; duplicate same-boundary calls remain - // suppressed. Verify the record and field, not just frame counts. + // R1 — the GotoTop ack certified the bottom in the same visit. REQUIRE(AdsGotoTop(hT) == AE_SUCCESS); CHECK(g_top.load() == 1u); CHECK(recno_of(hT) == 1u); REQUIRE(AdsGotoBottom(hT) == AE_SUCCESS); - CHECK(g_bottom.load() == 1u); - CHECK(recno_of(hT) == 5u); + CHECK(g_bottom.load() == 0u); // served from the pair blob + CHECK(recno_of(hT) == 5u); // and it IS the bottom row CHECK(id_field(hT) == "5"); UNSIGNED16 atEof = 1; REQUIRE(AdsAtEOF(hT, &atEof) == AE_SUCCESS); CHECK(atEof == 0); + + // The pair serve stamped bottom; a consecutive bottom is the old + // duplicate path, and the top that follows re-wires (no fresh + // certification for top was made by the LOCAL bottom serve... the + // certification from the earlier GotoBottom... none happened, so + // this top must wire and re-certify bottom). REQUIRE(AdsGotoBottom(hT) == AE_SUCCESS); - CHECK(g_bottom.load() == 1u); // duplicate + CHECK(g_bottom.load() == 0u); // duplicate of the pair serve REQUIRE(AdsGotoTop(hT) == AE_SUCCESS); - CHECK(g_top.load() == 2u); + CHECK(g_top.load() == 2u); // wire: re-certifies bottom REQUIRE(AdsGotoBottom(hT) == AE_SUCCESS); - CHECK(g_bottom.load() == 2u); + CHECK(g_bottom.load() == 0u); // pair again + CHECK(recno_of(hT) == 5u); + + // A write on this table kills the certification (the blob's row + // bytes predate it) and the frame expires the conn-wide seq anyway. REQUIRE(AdsGotoTop(hT) == AE_SUCCESS); - CHECK(g_top.load() == 3u); + CHECK(g_top.load() == 3u); // wire: certify bottom again REQUIRE(AdsLockRecord(hT, 0) == AE_SUCCESS); UNSIGNED8 fld[] = "ID"; UNSIGNED8 seven[] = "7"; REQUIRE(AdsSetField(hT, fld, seven, 1) == AE_SUCCESS); REQUIRE(AdsUnlockRecord(hT, 0) == AE_SUCCESS); REQUIRE(AdsGotoBottom(hT) == AE_SUCCESS); - CHECK(g_bottom.load() == 3u); + CHECK(g_bottom.load() == 1u); // back on the wire CHECK(recno_of(hT) == 5u); - CHECK(id_field(hT) == "5"); + CHECK(id_field(hT) == "5"); // row 5 untouched by the write + + // The wire GotoBottom also certified the TOP: this GotoTop is + // itself pair-served (symmetric certification). REQUIRE(AdsGotoTop(hT) == AE_SUCCESS); - CHECK(g_top.load() == 4u); + CHECK(g_top.load() == 3u); // pair-served from the bottom CHECK(recno_of(hT) == 1u); - REQUIRE(AdsGotoRecord(hU, 2) == AE_SUCCESS); + // Conn-wide envelope: nav on ANOTHER table expires the pair. + REQUIRE(AdsGotoRecord(hU, 2) == AE_SUCCESS); // other table's nav REQUIRE(AdsGotoBottom(hT) == AE_SUCCESS); - CHECK(g_bottom.load() == 4u); + CHECK(g_bottom.load() == 2u); // wire: conn seq moved openads::network::set_frame_trace_hook(nullptr); @@ -163,3 +176,97 @@ TEST_CASE("boundary pair, self-goto, and recno anchor over loopback") { s.stop(); fs::remove_all(dir, ec); } + +TEST_CASE("boundary pair: scoped ordered bottom counts on demand") { + auto dir = fs::temp_directory_path() / "openads_bpair_scoped"; + std::error_code ec; + fs::remove_all(dir, ec); + fs::create_directories(dir); + UNSIGNED8 path[512]{}; + std::memcpy(path, dir.string().c_str(), dir.string().size()); + ADSHANDLE local = 0; + REQUIRE(AdsConnect60(path, ADS_LOCAL_SERVER, nullptr, nullptr, 0, + &local) == AE_SUCCESS); + UNSIGNED8 tn[] = "SCOPE.DBF"; + UNSIGNED8 def[] = "GRP,C,1,0;ID,C,2,0"; + ADSHANDLE tbl = 0; + REQUIRE(AdsCreateTable(local, tn, nullptr, ADS_CDX, 0, 0, 0, 0, + def, &tbl) == AE_SUCCESS); + UNSIGNED8 grp[] = "GRP", id[] = "ID"; + for (const char* value : {"A1", "A2", "A3", "B4"}) { + REQUIRE(AdsAppendRecord(tbl) == AE_SUCCESS); + REQUIRE(AdsSetField(tbl, grp, (UNSIGNED8*)value, 1) == AE_SUCCESS); + REQUIRE(AdsSetField(tbl, id, (UNSIGNED8*)(value + 1), 1) + == AE_SUCCESS); + REQUIRE(AdsWriteRecord(tbl) == AE_SUCCESS); + } + // One deleted A row makes physical count (4), scoped count (3) + // and scoped SET DELETED count (2) distinct. + REQUIRE(AdsGotoRecord(tbl, 2) == AE_SUCCESS); + REQUIRE(AdsDeleteRecord(tbl) == AE_SUCCESS); + REQUIRE(AdsWriteRecord(tbl) == AE_SUCCESS); + UNSIGNED8 bag[] = "SCOPE.CDX", tag[] = "BYGRP", expr[] = "GRP+ID"; + ADSHANDLE idx = 0; + REQUIRE(AdsCreateIndex61(tbl, bag, tag, expr, nullptr, nullptr, + ADS_COMPOUND, 512, &idx) == AE_SUCCESS); + REQUIRE(AdsCloseTable(tbl) == AE_SUCCESS); + REQUIRE(AdsDisconnect(local) == AE_SUCCESS); + + openads::network::Server server; + REQUIRE(server.start("127.0.0.1", 0).has_value()); + char uri[512]{}; + std::snprintf(uri, sizeof(uri), "tcp://127.0.0.1:%u/%s", + static_cast(server.port()), dir.string().c_str()); + UNSIGNED8 sb[512]{}; + std::memcpy(sb, uri, std::strlen(uri) + 1); + ADSHANDLE conn = 0; + REQUIRE(AdsConnect60(sb, ADS_REMOTE_SERVER, nullptr, nullptr, 0, + &conn) == AE_SUCCESS); + REQUIRE(AdsOpenTable(conn, tn, nullptr, ADS_CDX, 0, 0, 0, 0, + &tbl) == AE_SUCCESS); + REQUIRE(AdsGetIndexHandleByOrder(tbl, 1, &idx) == AE_SUCCESS); + REQUIRE(idx != 0); + UNSIGNED8 a[] = "A"; + REQUIRE(AdsSetScope(idx, ADS_TOP, a, 1, ADS_STRINGKEY) == AE_SUCCESS); + REQUIRE(AdsSetScope(idx, ADS_BOTTOM, a, 1, ADS_STRINGKEY) == AE_SUCCESS); + REQUIRE(AdsShowDeleted(0) == AE_SUCCESS); + openads::network::set_frame_trace_hook(&count_nav); + reset_counts(); + REQUIRE(AdsGotoTop(idx) == AE_SUCCESS); + CHECK(recno_of(tbl) == 1u); + CHECK(g_keycount.load() == 0u); // no eager count from a plain GoTop + REQUIRE(AdsGotoBottom(idx) == AE_SUCCESS); + CHECK(g_bottom.load() == 0u); // opposite landing served from pair + CHECK(recno_of(tbl) == 3u); // deleted row 2 skipped + // A cold scoped order count is fetched only when Bottom is consumed. + CHECK(g_keycount.load() == 1u); + UNSIGNED32 kc = 0; + REQUIRE(AdsGetKeyCount(idx, 0, &kc) == AE_SUCCESS); + CHECK(kc == 2u); // not physical 4 or scoped 3 + CHECK(g_keycount.load() == 1u); // cached after bottom + REQUIRE(AdsGotoTop(idx) == AE_SUCCESS); + CHECK(recno_of(tbl) == 1u); + + // Scope to no rows; top and bottom agree on BOF+EOF without + // treating a physical row or deleted key as visible. + UNSIGNED8 z[] = "Z"; + REQUIRE(AdsSetScope(idx, ADS_TOP, z, 1, ADS_STRINGKEY) == AE_SUCCESS); + REQUIRE(AdsSetScope(idx, ADS_BOTTOM, z, 1, ADS_STRINGKEY) == AE_SUCCESS); + REQUIRE(AdsGotoTop(idx) == AE_SUCCESS); + UNSIGNED16 b = 0, e = 0; + REQUIRE(AdsAtBOF(tbl, &b) == AE_SUCCESS); + REQUIRE(AdsAtEOF(tbl, &e) == AE_SUCCESS); + CHECK(b == 1); CHECK(e == 1); + REQUIRE(AdsGotoBottom(idx) == AE_SUCCESS); + REQUIRE(AdsAtBOF(tbl, &b) == AE_SUCCESS); + REQUIRE(AdsAtEOF(tbl, &e) == AE_SUCCESS); + CHECK(b == 1); CHECK(e == 1); + REQUIRE(AdsGetKeyCount(idx, 0, &kc) == AE_SUCCESS); + CHECK(kc == 0u); + openads::network::set_frame_trace_hook(nullptr); + REQUIRE(AdsShowDeleted(1) == AE_SUCCESS); + REQUIRE(AdsCloseTable(tbl) == AE_SUCCESS); + REQUIRE(AdsDisconnect(conn) == AE_SUCCESS); + server.stop(); + fs::remove_all(dir, ec); +} diff --git a/tests/unit/network_nav_batch_test.cpp b/tests/unit/network_nav_batch_test.cpp index be180c1e..1ed28287 100644 --- a/tests/unit/network_nav_batch_test.cpp +++ b/tests/unit/network_nav_batch_test.cpp @@ -172,17 +172,19 @@ TEST_CASE("Nav batching: duplicate GotoTop/GotoBottom skip their frame") { REQUIRE(AdsGotoTop(hTable) == AE_SUCCESS); CHECK(nb_op(kOpGotoTop) == top0); - // Bottom twice: one frame, then suppressed. + // The warm open did not certify a pair, so the first bottom wires; + // the duplicate stays local. REQUIRE(AdsGotoBottom(hTable) == AE_SUCCESS); REQUIRE(AdsGotoBottom(hTable) == AE_SUCCESS); CHECK(nb_op(kOpGotoBottom) == bot0 + 1); - // mtfix14 diagnostic: no pair certification; both top calls wire. + // Restored pair certification: bottom's ack certifies top, so this + // call consumes the pair without another wire frame. REQUIRE(AdsGotoTop(hTable) == AE_SUCCESS); - CHECK(nb_op(kOpGotoTop) == top0 + 1); + CHECK(nb_op(kOpGotoTop) == top0); REQUIRE(AdsGotoRecord(hTable, 2) == AE_SUCCESS); REQUIRE(AdsGotoTop(hTable) == AE_SUCCESS); - CHECK(nb_op(kOpGotoTop) == top0 + 2); + CHECK(nb_op(kOpGotoTop) == top0 + 1); REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); diff --git a/tests/unit/network_use_budget_test.cpp b/tests/unit/network_use_budget_test.cpp index d82ca6f2..b3eca3e4 100644 --- a/tests/unit/network_use_budget_test.cpp +++ b/tests/unit/network_use_budget_test.cpp @@ -111,8 +111,8 @@ TEST_CASE("Nav batching: full USE cycle stays within wire budget") { // boundary probes and duplicate navs contribute zero frames. CHECK(total <= 14u); CHECK(delta(0x40) == 1u); // GotoTop: second suppressed - CHECK(delta(0x64) == 1u); // mtfix14: first GotoBottom goes to server; - // the duplicate remains suppressed + CHECK(delta(0x64) == 0u); // mtfix15 restores opposite-boundary pair; + // bottom and duplicate are served locally CHECK(delta(0x48) == 0u); // AtEOF: served locally CHECK(delta(0x4C) == 0u); // AtBOF: served locally From 9093184cc292933df9ce75e294e9edf88e790bb8 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Sat, 26 Sep 2026 22:35:52 -0500 Subject: [PATCH 66/97] Create release-notes-1.09.68-mtfix15.md --- release-notes-1.09.68-mtfix15.md | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 release-notes-1.09.68-mtfix15.md diff --git a/release-notes-1.09.68-mtfix15.md b/release-notes-1.09.68-mtfix15.md new file mode 100644 index 00000000..8a58ebd2 --- /dev/null +++ b/release-notes-1.09.68-mtfix15.md @@ -0,0 +1,7 @@ +Built for Pritpal Bedi - bedipritpal/OpenADS, forked from FiveTechSoft/OpenADS. + +## v1.09.68-mtfix15 - restore boundary-pair navigation without eager key counting + +The mtfix14 B_BIG diagnostic removed boundary-pair negotiation and cut the 10-thread job from roughly 8.4 seconds on mtfix13 to about 220 milliseconds, versus 185 milliseconds on upstream 1.09.69. This build restores opposite-boundary pair navigation, but removes the unconditional ordered key count from every pair response. The pair still carries the opposite row and boundary/record-count facts when valid; the optional key-count flag remains unset. If an ordered GoBottom actually needs a cold scoped key count, the client obtains it on demand. Tests cover ordered scoped and deleted rows, empty and nonempty pairs, opposite navigation after a write or order change, and the cold-count fallback. The existing workarea lane pin, lock ownership and write paths are unchanged. + +**What to test:** Back up the current x86 client DLL and replace only `ace32.dll` from the Windows x86 archive; keep the server at mtfix13 for the first A/B. With client tracing off, run the same B_BIG 10-thread/100-record job and compare with mtfix14's roughly 220 ms, then try the 10-instance local storm. If the pair traversal is cheap, expect about the mtfix14 time; if it is materially slower, leave mtfix14 in place and disable pair negotiation by default in the next change. Record the total time on each run. Both upstream and our client delay the first browse until the remote writers finish, so browse timing alone does not distinguish the builds. This is a test build, not a production performance claim: the remote one-instance timings still vary (mtfix14 32-60 s versus upstream 13.8 s on the same server), and the Vouch third-instance add/edit failure and fresh-org `uxxyyzza.dbf` CreateTable 5000 are separate open issues. Do not change the server for this client A/B. Restore the backed-up DLL if the test regresses. From 03181eb2610d5c5b08ced548c0056a433de7a20f Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898283+github-actions[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 13:26:32 +0000 Subject: [PATCH 67/97] mtfix16: opt-in create, open and append diagnostics Applied by apply-patch workflow, run 36322005268, started by bedipritpal. --- src/abi/ace_exports.cpp | 85 ++++++++++++-- src/abi/create_table_diag.h | 92 +++++++++++++++ src/network/session.cpp | 41 ++++++- src/network/session.h | 2 + tests/unit/abi_remote_create_table_test.cpp | 119 ++++++++++++++++++++ 5 files changed, 325 insertions(+), 14 deletions(-) create mode 100644 src/abi/create_table_diag.h diff --git a/src/abi/ace_exports.cpp b/src/abi/ace_exports.cpp index 18d3de2c..d7af2bdc 100644 --- a/src/abi/ace_exports.cpp +++ b/src/abi/ace_exports.cpp @@ -18,6 +18,7 @@ using openads::abi::lock_retry_policy; #include "abi/backend_registry.h" #include "abi/charset.h" #include "abi/last_error.h" +#include "abi/create_table_diag.h" #include "abi/runtime.h" #include "engine/aof_eval.h" @@ -210,6 +211,8 @@ UNSIGNED32 write_new_table_file(const std::string& full, auto fres = openads::platform::File::open( full, openads::platform::OpenMode::CreateExclusive); if (!fres) { + openads::abi::create_diag::log("file-open-fail", fres.error().code, + "exclusive-open", fres.error().sub_code); std::error_code ec; if (fs::exists(full, ec)) { return fail(openads::util::Error{ @@ -223,6 +226,8 @@ UNSIGNED32 write_new_table_file(const std::string& full, auto file = std::move(fres).value(); auto wrote = file.write_at(0, bytes.data(), bytes.size()); if (!wrote || wrote.value() != bytes.size()) { + openads::abi::create_diag::log("file-write-fail", wrote ? 0 : wrote.error().code, + "short-or-failed-write", wrote ? 0 : wrote.error().sub_code); return fail(openads::util::Error{ static_cast(openads::AE_INTERNAL_ERROR), 0, std::string(op) + ": write failed", ""}); @@ -8426,7 +8431,13 @@ UNSIGNED32 ENTRYPOINT AdsOpenTable(ADSHANDLE hConnect, auto otr = rc->open_table(name, static_cast(map_open_mode(usMode))); lk.lock(); - if (!otr) return fail(otr.error()); + if (!otr) { + openads::abi::create_diag::Scope diag_scope(name, + openads::abi::create_diag::correlation); + openads::abi::create_diag::log("client-open-fail", otr.error().code, + "wire-open", otr.error().sub_code); + return fail(otr.error()); + } auto& ot = otr.value(); auto rt = std::make_unique(); rt->conn = rc; @@ -9521,6 +9532,8 @@ UNSIGNED32 ENTRYPOINT AdsCreateTable(ADSHANDLE hConn, } const auto rel = openads::abi::to_internal(pucName, 0); const auto defs = openads::abi::to_internal(pucFields, 0); + openads::abi::create_diag::Scope create_scope(rel, + openads::abi::create_diag::correlation); const bool is_vfp = (usTableType == ADS_VFP); auto fields = parse_rddads_field_defs(defs, is_vfp); if (fields.empty()) { @@ -9599,11 +9612,16 @@ UNSIGNED32 ENTRYPOINT AdsCreateTable(ADSHANDLE hConn, // Create-over-existing must see closed files (SAP: overwrite // when closed, 7040 when open) — a parked handle reads as open. remote_flush_pools(rc); + openads::abi::create_diag::log("client-wire-begin"); auto cr = rc->create_table(rel, defs, static_cast(usTableType), static_cast(usCharType), static_cast(usMemoBlockSize)); - if (!cr) return fail(cr.error()); + if (!cr) { + openads::abi::create_diag::log("client-wire-fail", cr.error().code); + return fail(cr.error()); + } + openads::abi::create_diag::log("client-wire-ok"); // Re-open via the normal remote path so production-bag auto-open // and the implicit GotoTop match AdsOpenTable semantics. std::vector namebuf(rel.size() + 1, 0); @@ -9613,8 +9631,13 @@ UNSIGNED32 ENTRYPOINT AdsCreateTable(ADSHANDLE hConn, (usTableType == ADS_ADT) ? ADS_ADT : (usTableType == ADS_VFP) ? ADS_VFP : ADS_CDX; - return AdsOpenTable(rc_h, namebuf.data(), pucAlias, + const UNSIGNED32 reopen_rc = AdsOpenTable(rc_h, namebuf.data(), pucAlias, open_type, usCharType, 0, 0, 1, phTable); + const auto reopen_error = reopen_rc ? openads::abi::last_error_code() : 0; + openads::abi::create_diag::log(reopen_rc ? "client-reopen-fail" : "client-reopen-ok", + reopen_rc, reopen_rc && reopen_error != static_cast(reopen_rc) + ? "last-error-differs" : "last-error-matches"); + return reopen_rc; } // Explicit handle to a disconnected remote connection with no // live remote to fall back to: fail fast rather than writing a @@ -9783,9 +9806,11 @@ UNSIGNED32 ENTRYPOINT AdsCreateTable(ADSHANDLE hConn, if (const UNSIGNED32 wrc = write_new_table_file( full.string(), adt_file, "AdsCreateTable: ADT"); wrc != openads::AE_SUCCESS) { + openads::abi::create_diag::log("adt-write-fail", wrc); return wrc; } } + openads::abi::create_diag::log("adt-write-ok"); // Create a companion .adm for MEMO/BINARY/IMAGE fields if (has_companion) { @@ -9793,7 +9818,11 @@ UNSIGNED32 ENTRYPOINT AdsCreateTable(ADSHANDLE hConn, adm.replace_extension(".adm"); { std::error_code ec; fs::remove(adm, ec); } auto mr = openads::drivers::adm::AdmMemo::create(adm.string()); - if (!mr) return fail(mr.error()); + if (!mr) { + openads::abi::create_diag::log("memo-create-fail", + mr.error().code, "memo", mr.error().sub_code); + return fail(mr.error()); + } } // Open via the standard path so the caller gets a usable handle @@ -9804,8 +9833,12 @@ UNSIGNED32 ENTRYPOINT AdsCreateTable(ADSHANDLE hConn, std::size_t adt_nb = std::min(rel_adt.size(), sizeof(adt_namebuf) - 1); std::memcpy(adt_namebuf, rel_adt.data(), adt_nb); - return AdsOpenTable(hConn, adt_namebuf, adt_namebuf, + const auto local_reopen_rc = AdsOpenTable(hConn, adt_namebuf, adt_namebuf, ADS_ADT, usCharType, 0, 0, 1, phTable); + const auto captured_error = local_reopen_rc ? openads::abi::last_error_code() : 0; + openads::abi::create_diag::log(local_reopen_rc ? "server-abi-reopen-fail" : "server-abi-reopen-ok", + local_reopen_rc, captured_error == static_cast(local_reopen_rc) ? "last-error-matches" : "last-error-differs"); + return local_reopen_rc; } if (is_vfp) { @@ -9870,9 +9903,11 @@ UNSIGNED32 ENTRYPOINT AdsCreateTable(ADSHANDLE hConn, if (const UNSIGNED32 wrc = write_new_table_file( full.string(), file, "AdsCreateTable: VFP"); wrc != openads::AE_SUCCESS) { + openads::abi::create_diag::log("vfp-write-fail", wrc); return wrc; } } + openads::abi::create_diag::log("vfp-write-ok"); if (has_memo) { fs::path fpt = full; @@ -9880,14 +9915,22 @@ UNSIGNED32 ENTRYPOINT AdsCreateTable(ADSHANDLE hConn, { std::error_code ec; fs::remove(fpt, ec); } std::uint16_t bs = usMemoBlockSize != 0 ? usMemoBlockSize : 512; auto mr = openads::drivers::fpt::FptMemo::create(fpt.string(), bs); - if (!mr) return fail(mr.error()); + if (!mr) { + openads::abi::create_diag::log("memo-create-fail", + mr.error().code, "memo", mr.error().sub_code); + return fail(mr.error()); + } } UNSIGNED8 namebuf[260] = {0}; std::size_t nb = std::min(rel.size(), sizeof(namebuf) - 1); std::memcpy(namebuf, rel.data(), nb); - return AdsOpenTable(hConn, namebuf, namebuf, + const auto local_reopen_rc = AdsOpenTable(hConn, namebuf, namebuf, ADS_VFP, usCharType, 0, 0, 1, phTable); + const auto captured_error = local_reopen_rc ? openads::abi::last_error_code() : 0; + openads::abi::create_diag::log(local_reopen_rc ? "server-abi-reopen-fail" : "server-abi-reopen-ok", + local_reopen_rc, captured_error == static_cast(local_reopen_rc) ? "last-error-matches" : "last-error-differs"); + return local_reopen_rc; } // ── DBF creation path (existing) ──────────────────────────────────────── @@ -9954,9 +9997,11 @@ UNSIGNED32 ENTRYPOINT AdsCreateTable(ADSHANDLE hConn, if (const UNSIGNED32 wrc = write_new_table_file( full.string(), file, "AdsCreateTable"); wrc != openads::AE_SUCCESS) { + openads::abi::create_diag::log("dbf-write-fail", wrc); return wrc; } } + openads::abi::create_diag::log("dbf-write-ok"); // If the field list declares any memo (M) field, stage an empty // .fpt next to the .dbf -- Connection::open_table auto-attaches it, @@ -9974,7 +10019,11 @@ UNSIGNED32 ENTRYPOINT AdsCreateTable(ADSHANDLE hConn, // usMemoBlockSize. std::uint16_t bs = usMemoBlockSize != 0 ? usMemoBlockSize : 512; auto mr = openads::drivers::fpt::FptMemo::create(fpt.string(), bs); - if (!mr) return fail(mr.error()); + if (!mr) { + openads::abi::create_diag::log("memo-create-fail", + mr.error().code, "memo", mr.error().sub_code); + return fail(mr.error()); + } } } @@ -9983,10 +10032,14 @@ UNSIGNED32 ENTRYPOINT AdsCreateTable(ADSHANDLE hConn, UNSIGNED8 namebuf[260] = {0}; std::size_t nb = std::min(rel.size(), sizeof(namebuf) - 1); std::memcpy(namebuf, rel.data(), nb); - return AdsOpenTable(hConn, namebuf, namebuf, + const auto local_reopen_rc = AdsOpenTable(hConn, namebuf, namebuf, ADS_CDX, // table type usCharType, 0, 0, 1, // char/lock/checkrights/mode phTable); + const auto captured_error = local_reopen_rc ? openads::abi::last_error_code() : 0; + openads::abi::create_diag::log(local_reopen_rc ? "server-abi-reopen-fail" : "server-abi-reopen-ok", + local_reopen_rc, captured_error == static_cast(local_reopen_rc) ? "last-error-matches" : "last-error-differs"); + return local_reopen_rc; } UNSIGNED32 ENTRYPOINT AdsDropTable(ADSHANDLE hConnect, @@ -13465,7 +13518,12 @@ bool fire_triggers_(Handle hConn, Connection* conn, UNSIGNED32 ENTRYPOINT AdsAppendRecord(ADSHANDLE hTable) { arc2_trace("AdsAppendRecord"); if (auto* rt = get_remote_table(hTable)) { - if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; + openads::abi::create_diag::Scope append_scope(rt->name, {}); + openads::abi::create_diag::log("client-append-enter"); + if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) { + openads::abi::create_diag::log("client-append-preflush-fail", frc); + return frc; + } remote_settle_cursor(rt); // M12.21 option C rt->row_valid = false; // M12.17 rt->rec_count_cached = false; @@ -13480,7 +13538,11 @@ UNSIGNED32 ENTRYPOINT AdsAppendRecord(ADSHANDLE hTable) { remote_clear_nav_boundaries(rt); rt->invalidate_prefetch(); auto r = rt->conn->append_blank(rt->id); - if (!r) return fail(r.error()); + if (!r) { + openads::abi::create_diag::log("client-append-fail", r.error().code, + "wire-append", r.error().sub_code); + return fail(r.error()); + } // Fresh appends auto-lock (non-exclusive tables): pooled reuse // must not resurrect a locked handle. The ack carries no // recno, so the ledger cannot name the auto-lock -- mark the @@ -13488,6 +13550,7 @@ UNSIGNED32 ENTRYPOINT AdsAppendRecord(ADSHANDLE hTable) { rt->ever_locked = true; rt->locks_uncertain = true; rt->write_dirty = true; + openads::abi::create_diag::log("client-append-ok"); return ok(); } #if defined(OPENADS_WITH_FIREBIRD) diff --git a/src/abi/create_table_diag.h b/src/abi/create_table_diag.h new file mode 100644 index 00000000..e687d5ed --- /dev/null +++ b/src/abi/create_table_diag.h @@ -0,0 +1,92 @@ +#pragma once + +// Opt-in CreateTable stages, shared by the client ABI and server ABI twin. +// Never log schema, row values, usernames, or raw paths. +#include +#include +#include +#include +#include +#include +#include +#include + +namespace openads::abi::create_diag { +inline thread_local std::string target; +inline thread_local std::string correlation; + +inline std::string leaf(std::string_view path) { + auto end = path.find_last_not_of("/\\"); + if (end == std::string_view::npos) return {}; + auto start = path.find_last_of("/\\", end); + std::string out(path.substr(start == std::string_view::npos ? 0 : start + 1, + end - (start == std::string_view::npos ? 0 : start + 1) + 1)); + for (auto& ch : out) { + if (!((ch >= 'a' && ch <= 'z') || (ch >= 'A' && ch <= 'Z') || + (ch >= '0' && ch <= '9') || ch == '_' || ch == '-' || ch == '.')) + ch = '_'; + } + return out; +} +inline bool enabled(std::string_view path) { + const char* dest = std::getenv("OPENADS_CREATE_TABLE_DIAG_FILE"); + if (!dest || !*dest) return false; + const char* filter = std::getenv("OPENADS_CREATE_TABLE_DIAG_FILTER"); + if (!filter || !*filter) return true; + auto a = leaf(path), b = leaf(filter); + if (a.size() != b.size()) return false; + for (std::size_t i = 0; i < a.size(); ++i) { + if (std::tolower(static_cast(a[i])) != + std::tolower(static_cast(b[i]))) return false; + } + return true; +} +inline std::string new_id() { + static std::mutex mu; + static unsigned long long serial = 0; + std::lock_guard lock(mu); + return std::to_string(std::chrono::steady_clock::now().time_since_epoch().count()) + + "." + std::to_string(++serial); +} +struct Scope { + std::string old_target, old_correlation; + Scope(std::string_view name, std::string id) : old_target(target), old_correlation(correlation) { + if (enabled(name)) { + target = leaf(name); + correlation = id.empty() ? new_id() : std::move(id); + } else { + target.clear(); + correlation.clear(); + } + } + ~Scope() { + target = std::move(old_target); + correlation = std::move(old_correlation); + } +}; +inline void log(std::string_view stage, std::int64_t code = 0, + std::string_view detail = {}, int os_error = 0) { + if (target.empty()) return; + const char* dest = std::getenv("OPENADS_CREATE_TABLE_DIAG_FILE"); + if (!dest || !*dest) return; + static std::mutex mu; + std::lock_guard lock(mu); + auto* fp = std::fopen(dest, "a"); + if (!fp) return; + // Bound each process log even when the filter is omitted for a fresh-org run. + if (std::fseek(fp, 0, SEEK_END) != 0 || + std::ftell(fp) >= 2 * 1024 * 1024) { + std::fclose(fp); + return; + } + // Detail is restricted to a stage label, never arbitrary error text or paths. + const auto epoch_ms = std::chrono::duration_cast( + std::chrono::system_clock::now().time_since_epoch()).count(); + std::fprintf(fp, "create-table epoch_ms=%lld id=%s table=%s stage=%.*s code=%lld os_code=%d detail=%.*s\n", + static_cast(epoch_ms), + correlation.c_str(), target.c_str(), static_cast(stage.size()), stage.data(), + static_cast(code), os_error, + static_cast(detail.size()), detail.data()); + std::fclose(fp); +} +} // namespace openads::abi::create_diag diff --git a/src/network/session.cpp b/src/network/session.cpp index a613aad7..4de440ce 100644 --- a/src/network/session.cpp +++ b/src/network/session.cpp @@ -17,6 +17,8 @@ #include "openads/ace.h" #include "openads/error.h" #include "abi/lock_retry_policy.h" +#include "abi/create_table_diag.h" +#include "abi/last_error.h" #include "engine/server_fs.h" #include "platform/fs_sandbox.h" #include "platform/path.h" @@ -1979,10 +1981,14 @@ DispatchResult Session::dispatch(const Frame& f) { if (!stripped.empty()) rel = std::move(stripped); } } + openads::abi::create_diag::Scope open_diag(rel, + std::to_string(sid_) + "." + openads::abi::create_diag::new_id()); auto th = sess_conn_->open_table(rel, openads::engine::TableType::Cdx, open_mode); if (!th) { + openads::abi::create_diag::log("server-open-fail", th.error().code, + "table-open", th.error().sub_code); std::fprintf(stderr, "[srv] OpenTable FAILED rel='%s' code=%d msg='%s'\n", rel.c_str(), th.error().code, th.error().message.c_str()); @@ -2006,6 +2012,7 @@ DispatchResult Session::dispatch(const Frame& f) { if (!canon.empty()) { if (!srv_->try_register_open(sid_, canon, excl)) { sess_conn_->close_table(th.value()); + openads::abi::create_diag::log("server-open-exclusive-fail", 7040); reply = err("OpenTable: table in use exclusively", 7040); break; @@ -2015,6 +2022,7 @@ DispatchResult Session::dispatch(const Frame& f) { } tbls_.emplace(id, th.value()); tbl_open_paths_.emplace(id, rel); + openads::abi::create_diag::log("server-open-ok"); srv_->add_session_table(sid_, +1, rel); reply.opcode = Opcode::OpenTableAck; write_u32_le(id, reply.payload); @@ -3676,16 +3684,26 @@ DispatchResult Session::dispatch(const Frame& f) { auto nb = to_cbuf(name); auto fb = to_cbuf(fields); ADSHANDLE hTable = 0; + openads::abi::create_diag::Scope cd_scope(name, + std::to_string(sid_) + "." + openads::abi::create_diag::new_id()); + openads::abi::create_diag::log("server-wire-begin"); UNSIGNED32 rrc = AdsCreateTable( abi_conn_, nb.data(), nullptr, table_type, char_type, 0, 0, memo_bs, fb.data(), &hTable); if (rrc != 0) { + const auto captured = openads::abi::last_error_code(); + openads::abi::create_diag::log("server-abi-fail", rrc, + captured == static_cast(rrc) ? "last-error-matches" : "last-error-differs"); reply = err("CreateTable", rrc); break; } + openads::abi::create_diag::log("server-abi-ok"); // Files are on disk under the data dir; release the local // handle so the client's subsequent OpenTable can take Shared. - if (hTable != 0) (void)AdsCloseTable(hTable); + if (hTable != 0) { + const auto close_rc = AdsCloseTable(hTable); + openads::abi::create_diag::log(close_rc ? "server-close-fail" : "server-close-ok", close_rc); + } reply.opcode = Opcode::CreateTableAck; break; } @@ -4376,6 +4394,10 @@ DispatchResult Session::dispatch(const Frame& f) { } auto* tbl = sess_conn_->lookup_table(it->second); if (!tbl) { reply = err("AppendBlank: lookup failed"); break; } + auto diag_name = tbl_open_paths_.find(id); + openads::abi::create_diag::Scope append_diag( + diag_name == tbl_open_paths_.end() ? std::string() : diag_name->second, + std::to_string(sid_) + ".append." + std::to_string(id)); // M12.16 dual-handle: CreateIndex/OpenIndex bind bags on the // parallel ABI Table (tbls_h_), not on the engine Table used by // the historical write path. Writing only through the engine @@ -4384,7 +4406,12 @@ DispatchResult Session::dispatch(const Frame& f) { // exists so sync_all_indexes_ updates every bound tag. if (auto hit = tbls_h_.find(id); hit != tbls_h_.end()) { UNSIGNED32 rrc = AdsAppendRecord(hit->second); - if (rrc != 0) { reply = err("AppendBlank", rrc); break; } + if (rrc != 0) { + const auto captured = openads::abi::last_error_code(); + openads::abi::create_diag::log("server-append-abi-fail", rrc, + captured == static_cast(rrc) ? "last-error-matches" : "last-error-differs"); + reply = err("AppendBlank", rrc); break; + } // Storm fix — the client commits with DbCommit→FlushTable // (which flushes this same handle); a per-append flush here // multiplied CDX page writes ~9x under the 700-storm and @@ -4394,9 +4421,17 @@ DispatchResult Session::dispatch(const Frame& f) { tbl->set_pending_append(true); } else { auto r = tbl->append_record(); - if (!r) { reply = err("AppendBlank: append_record failed"); break; } + if (!r) { + openads::abi::create_diag::log("server-append-engine-fail", r.error().code, + "append-record", r.error().sub_code); + reply = err("AppendBlank: append_record failed"); break; + } tbl->set_pending_append(true); } + if (openads::abi::create_diag::enabled( + diag_name == tbl_open_paths_.end() ? std::string() : diag_name->second) && + diag_first_append_.insert(id).second) + openads::abi::create_diag::log("server-first-append-ok"); reply.opcode = Opcode::AppendBlankAck; break; } diff --git a/src/network/session.h b/src/network/session.h index 61271fc4..81c420a5 100644 --- a/src/network/session.h +++ b/src/network/session.h @@ -87,6 +87,8 @@ class Session { // Original OpenTable payload (DD alias or relative path). ensure_abi_handle // must reopen the same physical file — basename-only breaks subdir tables. std::unordered_map tbl_open_paths_; + // Diagnostic-only first append marker; no change to table operations. + std::unordered_set diag_first_append_; // mtfix11 - Server::try_register_open bookkeeping per wire table id: // (engine-resolved canonical path, exclusive flag) as registered at // OpenTable; consumed at CloseTable / teardown for unregister_open. diff --git a/tests/unit/abi_remote_create_table_test.cpp b/tests/unit/abi_remote_create_table_test.cpp index 93461661..6ddcfdf6 100644 --- a/tests/unit/abi_remote_create_table_test.cpp +++ b/tests/unit/abi_remote_create_table_test.cpp @@ -8,6 +8,10 @@ #include "network/server.h" #include +#include +#include +#include +#include #include #include #include @@ -134,3 +138,118 @@ TEST_CASE("remote AdsCreateTable with drive-rooted name still under data dir") { REQUIRE(AdsDisconnect(hConn) == 0); fs::remove_all(data, ec); } + +TEST_CASE("CreateTable diagnostic isolates post-write reopen failure") { + using openads::network::Server; + auto data = fs::temp_directory_path() / "openads_create_diag_post_open"; + auto diag = data / "create-diag.log"; + std::error_code ec; + fs::remove_all(data, ec); + fs::create_directories(data); +#if defined(_WIN32) + // Windows MAX_PATH cannot express this long-path reopen fixture. Instead + // force failure at companion memo creation, still after the DBF write. + const std::string rel = "probe.dbf"; + fs::create_directories(data / "probe.fpt"); +#else + // The server's ABI create writes the requested relative path, but its + // fixed 260-byte post-create name buffer truncates a longer path. This + // deterministically fails the reopen after the DBF has been written. + std::string rel; + for (int i = 0; i < 55; ++i) rel += "nest/"; + rel += "probe.dbf"; + fs::create_directories(data / fs::path(rel).parent_path()); +#endif +#if defined(_WIN32) + _putenv_s("OPENADS_CREATE_TABLE_DIAG_FILE", diag.string().c_str()); + _putenv_s("OPENADS_CREATE_TABLE_DIAG_FILTER", "probe.dbf"); +#else + setenv("OPENADS_CREATE_TABLE_DIAG_FILE", diag.string().c_str(), 1); + setenv("OPENADS_CREATE_TABLE_DIAG_FILTER", "probe.dbf", 1); +#endif + Server srv; + REQUIRE(srv.start("127.0.0.1", 0).has_value()); + ADSHANDLE conn = remote_connect(data, srv.port()); + std::vector name(rel.begin(), rel.end()); + name.push_back(0); +#if defined(_WIN32) + UNSIGNED8 fields[] = "ID,Numeric,4,0;NOTE,Memo"; +#else + UNSIGNED8 fields[] = "ID,Numeric,4,0"; +#endif + ADSHANDLE table = 0; + const UNSIGNED32 rc = AdsCreateTable(conn, name.data(), nullptr, ADS_CDX, ADS_ANSI, + 0, 0, 0, fields, &table); + const UNSIGNED32 reported = rc; + UNSIGNED32 last = 0; + UNSIGNED8 message[512]{}; + UNSIGNED16 message_len = sizeof(message); + REQUIRE(AdsGetLastError(&last, message, &message_len) == 0); + CHECK(rc != 0); + CHECK(last == reported); + CHECK(fs::exists(data / rel)); + std::ifstream log(diag); + std::string content((std::istreambuf_iterator(log)), + std::istreambuf_iterator()); + CHECK(content.find("stage=dbf-write-ok") != std::string::npos); +#if defined(_WIN32) + CHECK(content.find("stage=memo-create-fail code=" + + std::to_string(reported)) != std::string::npos); +#else + CHECK(content.find("stage=server-abi-reopen-fail code=" + + std::to_string(reported)) != std::string::npos); +#endif + CHECK(content.find("stage=client-wire-fail code=" + + std::to_string(reported)) != std::string::npos); + CHECK(content.find("NOTE") == std::string::npos); + (void)AdsDisconnect(conn); +#if defined(_WIN32) + _putenv_s("OPENADS_CREATE_TABLE_DIAG_FILE", ""); + _putenv_s("OPENADS_CREATE_TABLE_DIAG_FILTER", ""); +#else + unsetenv("OPENADS_CREATE_TABLE_DIAG_FILE"); + unsetenv("OPENADS_CREATE_TABLE_DIAG_FILTER"); +#endif + fs::remove_all(data, ec); +} + +TEST_CASE("CreateTable broad diagnostic records first-user open and append") { + using openads::network::Server; + auto data = fs::temp_directory_path() / "openads_create_diag_first_user"; + auto diag = data / "create-diag.log"; + std::error_code ec; + fs::remove_all(data, ec); + fs::create_directories(data); +#if defined(_WIN32) + _putenv_s("OPENADS_CREATE_TABLE_DIAG_FILE", diag.string().c_str()); + _putenv_s("OPENADS_CREATE_TABLE_DIAG_FILTER", ""); +#else + setenv("OPENADS_CREATE_TABLE_DIAG_FILE", diag.string().c_str(), 1); + unsetenv("OPENADS_CREATE_TABLE_DIAG_FILTER"); +#endif + Server srv; + REQUIRE(srv.start("127.0.0.1", 0).has_value()); + ADSHANDLE conn = remote_connect(data, srv.port()); + UNSIGNED8 name[] = "USERCFG.dbf"; + UNSIGNED8 fields[] = "ID,Numeric,4,0"; + ADSHANDLE table = 0; + REQUIRE(AdsCreateTable(conn, name, nullptr, ADS_CDX, ADS_ANSI, + 0, 0, 0, fields, &table) == 0); + REQUIRE(AdsAppendRecord(table) == 0); + REQUIRE(AdsCloseTable(table) == 0); + REQUIRE(AdsDisconnect(conn) == 0); + std::ifstream log(diag); + std::string content((std::istreambuf_iterator(log)), + std::istreambuf_iterator()); + CHECK(content.find("table=USERCFG.dbf stage=dbf-write-ok") != std::string::npos); + CHECK(content.find("table=USERCFG.dbf stage=server-open-ok") != std::string::npos); + CHECK(content.find("table=USERCFG.dbf stage=client-append-enter") != std::string::npos); + CHECK(content.find("table=USERCFG.dbf stage=client-append-ok") != std::string::npos); + CHECK(content.find("table=USERCFG.dbf stage=server-first-append-ok") != std::string::npos); +#if defined(_WIN32) + _putenv_s("OPENADS_CREATE_TABLE_DIAG_FILE", ""); +#else + unsetenv("OPENADS_CREATE_TABLE_DIAG_FILE"); +#endif + fs::remove_all(data, ec); +} From 69e722a6bf7b44429a78cb741018a58b3f31c628 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898283+github-actions[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 14:59:33 +0000 Subject: [PATCH 68/97] mtfix16: make Windows memo-failure test deterministic Applied by apply-patch workflow, run 36327521337, started by bedipritpal. --- tests/unit/abi_remote_create_table_test.cpp | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/unit/abi_remote_create_table_test.cpp b/tests/unit/abi_remote_create_table_test.cpp index 6ddcfdf6..7fc69ff6 100644 --- a/tests/unit/abi_remote_create_table_test.cpp +++ b/tests/unit/abi_remote_create_table_test.cpp @@ -151,6 +151,9 @@ TEST_CASE("CreateTable diagnostic isolates post-write reopen failure") { // force failure at companion memo creation, still after the DBF write. const std::string rel = "probe.dbf"; fs::create_directories(data / "probe.fpt"); + // Keep the directory non-empty so the server's pre-create remove() + // cannot erase it; the memo create must then fail on Windows. + std::ofstream(data / "probe.fpt" / "occupy") << "x"; #else // The server's ABI create writes the requested relative path, but its // fixed 260-byte post-create name buffer truncates a longer path. This From 0324e10b7e06ed390e6c87d804cce40fd38a8427 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Sun, 27 Sep 2026 11:01:29 -0500 Subject: [PATCH 69/97] docs: add mtfix16 diagnostic test-build notes --- release-notes-1.09.68-mtfix16.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 release-notes-1.09.68-mtfix16.md diff --git a/release-notes-1.09.68-mtfix16.md b/release-notes-1.09.68-mtfix16.md new file mode 100644 index 00000000..b637445c --- /dev/null +++ b/release-notes-1.09.68-mtfix16.md @@ -0,0 +1,14 @@ +Built for Pritpal Bedi - bedipritpal/OpenADS, forked from FiveTechSoft/OpenADS. + +## v1.09.68-mtfix16 - opt-in fresh-organization diagnostics + +This is a test build, not a fix or a production recommendation. It keeps mtfix15 behavior, but can log stages for remote table creation and subsequent server-side opens and appends. The server records DBF write, memo creation, its local post-create reopen, close result, wire OpenTable outcomes, and AppendBlank failures. The client records whether CreateTable failed on the wire or on its own final reopen, and failed OpenTable and AppendRecord calls. Codes are not changed. No table schema or row content is written to this diagnostic log; it contains sanitized table basenames, stage, code and timestamp. The normal `ads_err` log remains separate and may hold additional context. + +Set environment variables on **both** the Vouch client process and the server process before launch: + +- `OPENADS_CREATE_TABLE_DIAG_FILE`: a distinct writable log path on each machine, e.g. `C:\temp\openads-create-client.log` and `/tmp/openads-create-server.log`. Absent means diagnostic off. +- `OPENADS_CREATE_TABLE_DIAG_FILTER=uxxyyzza.dbf` for one table. To investigate several first-login table failures, omit FILTER: every CreateTable and failed OpenTable or AppendBlank can be logged. This broad mode can cause extra disk I/O and stops writing at 2 MB per process; use it for one fresh-org reproduction only, then unset both variables and restart. With a filter, the server additionally logs successful opens and first appends of the named table. + +Keep client/server DLL and daemon from this same build. Collect both logs, the app's exact error or silent symptom, and the table name and time. A create error followed by a file on disk proves the file was written but not where the subsequent failure happened; the stage log is the answer. The test uses a long-path fixture on POSIX and a memo-create failure on Windows to force a post-write failure, then checks that the DBF remains, the stage is logged, and the same ACE code reaches the caller. This diagnostic does not modify locks, retry policy, lane pinning, file format, or client-visible error codes. + +**CI caveat:** Windows x86/x64 and Linux tests, including the new diagnostic test, passed on this commit. macOS compiled, but its full test step exceeded the CI job's 30-minute limit and was cancelled; the same macOS timeout occurred on the prior diagnostic commit and predated mtfix16 on this branch. The release workflow does not use macOS tests as a gate. macOS test status remains unverified. Keep mtfix15 as the recommended working build; use mtfix16 only to capture the fresh-org failure, then restore mtfix15. From 5eb88386456b58d1de4b8c48b2307ba9bb30adc2 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898283+github-actions[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 20:31:01 +0000 Subject: [PATCH 70/97] mtfix17: recheck remote directories after external changes Applied by apply-patch workflow, run 36347893828, started by bedipritpal. --- src/engine/server_fs.cpp | 5 +++ src/network/client.cpp | 34 +++------------- src/network/client.h | 9 ----- tests/unit/network_commit_slimming_test.cpp | 44 ++++++++++++++------- 4 files changed, 40 insertions(+), 52 deletions(-) diff --git a/src/engine/server_fs.cpp b/src/engine/server_fs.cpp index be5d2fe1..999e6353 100644 --- a/src/engine/server_fs.cpp +++ b/src/engine/server_fs.cpp @@ -212,6 +212,11 @@ util::Result> fs_directory(const std::string& base_dir, util::Result fs_dir_exist(const std::string& abs_path) { std::error_code ec; bool d = fs::is_directory(abs_path, ec); + // A missing directory is an ordinary negative probe, not an I/O error. + // std::filesystem::is_directory(path, ec) sets no_such_file_or_directory + // in ec on some standard libraries (notably Linux/libstdc++). + if (ec == std::errc::no_such_file_or_directory) + return false; if (ec) return io_err(static_cast(openads::AE_INTERNAL_ERROR), "dir exist failed", abs_path); diff --git a/src/network/client.cpp b/src/network/client.cpp index 2fbb7b8b..a7780175 100644 --- a/src/network/client.cpp +++ b/src/network/client.cpp @@ -2993,14 +2993,12 @@ RemoteConnection::zip_list(const std::string& zip) { return o; } +// Directory state may change through other sessions or on the server host. +// Do not cache existence or skip an idempotent mkdir: a stale positive answer +// can make a fresh organization fail its first CreateTable after its directory +// was removed outside this connection. util::Result RemoteConnection::dir_exist(const std::string& path) { - const std::string key = fs_cache_key(path); - { - std::lock_guard lk(dir_cache_mu_); - if (dir_known_.count(key) != 0) return true; - if (dir_missing_.count(key) != 0) return false; - } Frame req; req.opcode = Opcode::DirExist; push_lp_str(req.payload, path); @@ -3009,26 +3007,11 @@ RemoteConnection::dir_exist(const std::string& path) { if (rep.value().opcode != Opcode::DirExistAck || rep.value().payload.empty()) return fs_wire_err(rep.value(), "DirExist"); - const bool exists = rep.value().payload[0] != 0; - { - std::lock_guard lk(dir_cache_mu_); - if (exists) { - dir_known_.insert(key); - dir_missing_.erase(key); - } else { - dir_missing_.insert(key); - } - } - return exists; + return rep.value().payload[0] != 0; } util::Result RemoteConnection::dir_make(const std::string& path) { - const std::string key = fs_cache_key(path); - { - std::lock_guard lk(dir_cache_mu_); - if (dir_known_.count(key) != 0) return {}; - } Frame req; req.opcode = Opcode::DirMake; push_lp_str(req.payload, path); @@ -3036,9 +3019,6 @@ RemoteConnection::dir_make(const std::string& path) { if (!rep) return rep.error(); if (rep.value().opcode != Opcode::DirMakeAck) return fs_wire_err(rep.value(), "DirMake"); - std::lock_guard lk(dir_cache_mu_); - dir_known_.insert(key); - dir_missing_.erase(key); return {}; } @@ -3051,10 +3031,6 @@ RemoteConnection::dir_remove(const std::string& path) { if (!rep) return rep.error(); if (rep.value().opcode != Opcode::DirRemoveAck) return fs_wire_err(rep.value(), "DirRemove"); - std::lock_guard lk(dir_cache_mu_); - const std::string rkey = fs_cache_key(path); - dir_known_.erase(rkey); - dir_missing_.insert(rkey); return {}; } diff --git a/src/network/client.h b/src/network/client.h index ef4b1e93..d55251fc 100644 --- a/src/network/client.h +++ b/src/network/client.h @@ -672,15 +672,6 @@ class RemoteConnection { // A stale "missing" degrades to the app re-checking after its own // create attempt (which clears the cache), never to wrong data. std::set file_exists_neg_cache_; - // Directory truth (EnableFileFunc): DirExist=true answers and - // successful DirMakes feed dir_known_; DirExist=false feeds - // dir_missing_. A known dir makes DirMake a no-op (the server - // create is idempotent) and DirExist answer locally. Our own - // DirRemove erases the path from both. Peer mkdir/rmdir is not - // tracked -- session-scoped, same trade as the file cache. - std::set dir_known_; - std::set dir_missing_; - std::mutex dir_cache_mu_; public: // Deferred disconnect (MT shared connections). AdsDisconnect on a diff --git a/tests/unit/network_commit_slimming_test.cpp b/tests/unit/network_commit_slimming_test.cpp index 402df98b..87a14714 100644 --- a/tests/unit/network_commit_slimming_test.cpp +++ b/tests/unit/network_commit_slimming_test.cpp @@ -11,8 +11,8 @@ // d) a table whose locks were all released parks at close instead // of real-closing (the blunt ever_locked flag used to force a // 7-frame reopen per save); -// e) DirExist/DirMake answers cache per session (positive and -// negative), and repeated missing-FileExists probes cache too. +// e) Repeated missing-FileExists probes cache per session. Directory +// existence and creation are never cached: peers may change them. // mtfix10: // f) AdsGetNumLocks shares the GetAllLocks ledger fast path (rddads // polls it after every commit/unlock -- it used to ride the wire @@ -268,7 +268,7 @@ TEST_CASE("Commit slimming: released-lock tables park at close") { srv.stop(); } -TEST_CASE("Commit slimming: dir and missing-file answers cache") { +TEST_CASE("Commit slimming: directory state always reflects server changes") { cs_wipe(); auto dir = cs_tmp_dir(); cs_seed(dir); @@ -278,31 +278,47 @@ TEST_CASE("Commit slimming: dir and missing-file answers cache") { srv.set_enable_file_func(true); ADSHANDLE hConn = cs_connect_remote(dir, srv.port()); - // Existing dir: one probe, then local answers; DirMake is skipped - // outright once the dir is known. const std::uint64_t de0 = cs_op(kOpDirExist); const std::uint64_t dm0 = cs_op(kOpDirMake); UNSIGNED16 ex = 0; REQUIRE(AdsDirExist(hConn, (UNSIGNED8*)".", &ex) == AE_SUCCESS); CHECK(ex == 1u); REQUIRE(AdsDirExist(hConn, (UNSIGNED8*)".", &ex) == AE_SUCCESS); - CHECK(cs_op(kOpDirExist) == de0 + 1); + CHECK(ex == 1u); + CHECK(cs_op(kOpDirExist) == de0 + 2); REQUIRE(AdsDirMake(hConn, (UNSIGNED8*)".") == AE_SUCCESS); - CHECK(cs_op(kOpDirMake) == dm0); + CHECK(cs_op(kOpDirMake) == dm0 + 1); - // A fresh DirMake goes out once and then the dir is known. REQUIRE(AdsDirMake(hConn, (UNSIGNED8*)"cs_sub") == AE_SUCCESS); - CHECK(cs_op(kOpDirMake) == dm0 + 1); + CHECK(cs_op(kOpDirMake) == dm0 + 2); + REQUIRE(fs::is_directory(dir / "cs_sub")); REQUIRE(AdsDirExist(hConn, (UNSIGNED8*)"cs_sub", &ex) == AE_SUCCESS); CHECK(ex == 1u); - CHECK(cs_op(kOpDirExist) == de0 + 1); + CHECK(cs_op(kOpDirExist) == de0 + 3); - // Our own DirRemove flips the cached answer to missing with no - // further probes. - REQUIRE(AdsDirRemove(hConn, (UNSIGNED8*)"cs_sub") == AE_SUCCESS); + // Simulate host maintenance or a second client: deletion bypasses this + // connection's DirRemove, so its next probe must reach the server. + std::error_code ec; + REQUIRE(fs::remove(dir / "cs_sub", ec)); + REQUIRE_FALSE(ec); REQUIRE(AdsDirExist(hConn, (UNSIGNED8*)"cs_sub", &ex) == AE_SUCCESS); CHECK(ex == 0u); - CHECK(cs_op(kOpDirExist) == de0 + 1); + CHECK(cs_op(kOpDirExist) == de0 + 4); + REQUIRE(AdsDirMake(hConn, (UNSIGNED8*)"cs_sub") == AE_SUCCESS); + CHECK(cs_op(kOpDirMake) == dm0 + 3); + CHECK(fs::is_directory(dir / "cs_sub")); + REQUIRE(AdsDirExist(hConn, (UNSIGNED8*)"cs_sub", &ex) == AE_SUCCESS); + CHECK(ex == 1u); + CHECK(cs_op(kOpDirExist) == de0 + 5); + + // A repeat mkdir must still reach the server (and remain idempotent), + // not claim success from an obsolete positive answer. + REQUIRE(fs::remove(dir / "cs_sub", ec)); + REQUIRE_FALSE(ec); + REQUIRE(AdsDirMake(hConn, (UNSIGNED8*)"cs_sub") == AE_SUCCESS); + CHECK(cs_op(kOpDirMake) == dm0 + 4); + CHECK(fs::is_directory(dir / "cs_sub")); + REQUIRE(AdsDirRemove(hConn, (UNSIGNED8*)"cs_sub") == AE_SUCCESS); // A table open on this connection proves its own dbf exists: the // probe answers from the open-table store with no wire op (mtfix10). From 18a3f46f62712f3c37268ca387e72e1f79face3d Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Sun, 27 Sep 2026 16:03:57 -0500 Subject: [PATCH 71/97] docs: add mtfix17 fresh-org test-build notes --- release-notes-1.09.68-mtfix17.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 release-notes-1.09.68-mtfix17.md diff --git a/release-notes-1.09.68-mtfix17.md b/release-notes-1.09.68-mtfix17.md new file mode 100644 index 00000000..493a5e29 --- /dev/null +++ b/release-notes-1.09.68-mtfix17.md @@ -0,0 +1,13 @@ +Built for Pritpal Bedi - bedipritpal/OpenADS, forked from FiveTechSoft/OpenADS. + +## v1.09.68-mtfix17 - fresh-organization directory fix candidate + +This is a test build, not a production recommendation. Keep mtfix15 as the recommended working build while checking this candidate against a fresh Vouch organization. It retains the opt-in table-create diagnostics from mtfix16. + +The remote client no longer caches positive or negative directory-existence answers or skips DirMake after an earlier success. DirExist and DirMake now reach the server every time, so a directory removed or created outside the current connection is not hidden by an old answer. Server-side DirExist also treats a missing path as a normal false answer, not an internal error. File-existence and other optimizations are unchanged. Each directory call may now cost one extra network round trip compared with a cache hit. + +The regression test creates a directory, sees it, removes it directly on the server, checks that the same connection sees it missing, and verifies that DirMake recreates it, including a repeat mkdir after another removal. This matches a plausible cause of the observed first-create failure (`uxxyyzza.dbf`: exclusive open saw a missing parent directory), but the diagnostic trace did not prove the full raw table path or all of the Vouch folder steps. A successful fresh-org Vouch run remains the needed validation. + +**What to test:** Use the mtfix17 client and server from this same build for one new Vouch organization, including its first table create. Report the organization-create result, any `uxxyyzza.dbf` or other missing-folder error, and the client and server diagnostic logs if it still fails. Then return to mtfix15 for regular work until this candidate is validated. The diagnostic environment variables from mtfix16 are optional and should be unset again after capture. + +**CI:** Windows x86/x64 and Linux (including TLS) passed, along with Harbour smoke. macOS configured and built, but its test step was cancelled at the CI timeout, so macOS tests remain unverified. The same timeout affected prior builds; this is not evidence that mtfix17's macOS tests passed. No strict no-symlink change is included here. From 3de8d12c63ecc7719c39f01a856735402c83d656 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898283+github-actions[bot]@users.noreply.github.com> Date: Sun, 27 Sep 2026 21:50:01 +0000 Subject: [PATCH 72/97] mtfix18: always recheck file and directory existence Applied by apply-patch workflow, run 36352768839, started by bedipritpal. --- src/abi/ace_exports.cpp | 54 ++------------------- src/network/client.cpp | 49 ++----------------- src/network/client.h | 26 ++-------- tests/unit/network_commit_slimming_test.cpp | 37 +++++++------- tests/unit/network_teardown_batch_test.cpp | 39 ++++++--------- 5 files changed, 47 insertions(+), 158 deletions(-) diff --git a/src/abi/ace_exports.cpp b/src/abi/ace_exports.cpp index d7af2bdc..d5544326 100644 --- a/src/abi/ace_exports.cpp +++ b/src/abi/ace_exports.cpp @@ -7954,9 +7954,6 @@ void remote_close_table_live(ADSHANDLE hTable, // arrange that (disconnect/open paths unlock first). static void remote_flush_pools_one(openads::network::RemoteConnection* rc) { if (rc == nullptr) return; - // File lifecycle changed meaning on disk: drop the existence - // cache alongside the parked handles. - rc->file_exists_invalidate(); std::vector> parked; rc->parked_flush(parked); for (auto& e : parked) { @@ -10820,53 +10817,12 @@ UNSIGNED32 ENTRYPOINT AdsCheckExistence(ADSHANDLE hConn, UNSIGNED8* pucName, auto name = openads::abi::to_internal(pucName, 0); auto ctx = resolve_fs_conn(hConn); if (ctx.remote) { - // Open-bag short-circuit (per-USE VouExistIndex probes): a bag - // bound by any live table on this connection trivially exists - // — the app is reading through it. Matched by lowercased stem - // (no directory, no extension; .cdx/.z01 are equivalent - // production spellings). False positives are safe (a real - // open follows and errors properly); false negatives never - // happen here. - auto stem_of = [](const std::string& p) { - std::string b = p; - auto sep = b.find_last_of("/\\"); - if (sep != std::string::npos) b = b.substr(sep + 1); - auto dot = b.find_last_of('.'); - if (dot != std::string::npos) b = b.substr(0, dot); - for (auto& c : b) - c = static_cast(std::tolower( - static_cast(c))); - return b; - }; - const std::string want = stem_of(name); - if (!want.empty()) { - // Shared store: hold s.mu for the scan (memory only, no wire). - std::lock_guard lk_store(state().mu); - for (auto& kv : remote_table_store()) { - auto* rt = kv.first; - if (rt == nullptr || rt->conn != ctx.remote) continue; - if (stem_of(rt->prod_bag_path) == want || - (!rt->last_open_bag.empty() && - stem_of(rt->last_open_bag) == want) || - // An open table proves only its exact file exists, - // not a different extension with the same stem. - rt->name == name) { - *pbExists = 1; - return ok(); - } - } - } - int fe_src = 2; - auto r = ctx.remote->file_exists(name, &fe_src); + // External directory/file changes require a wire probe even when an + // open table or index has the same stem: existence is not ownership. + auto r = ctx.remote->file_exists(name); if (!r) return fail(r.error()); - // Probe-level visibility: the frame hook only logs wire misses, - // so cache hits used to be invisible in the trace. One line per - // probe with the REAL path (the frame hook's tid is a truncated - // hash that cannot distinguish same-length paths). - cli_trace("FileExists", "probe %s -> %s (%s)", name.c_str(), - r.value() ? "yes" : "no", - fe_src == 0 ? "pos-cache" : - fe_src == 1 ? "neg-cache" : "wire"); + cli_trace("FileExists", "probe %s -> %s (wire)", name.c_str(), + r.value() ? "yes" : "no"); *pbExists = r.value() ? 1 : 0; return ok(); } diff --git a/src/network/client.cpp b/src/network/client.cpp index a7780175..ba59098d 100644 --- a/src/network/client.cpp +++ b/src/network/client.cpp @@ -2704,35 +2704,12 @@ std::uint64_t read_u64_le(const std::uint8_t* p) { return v; } -// Existence-cache key: the server separator-normalizes client paths -// (fs_sandbox), so "/" and "\\" spellings of one file are the same -// file. Case is NOT folded: the server fs may be case-sensitive (Linux -// hosts), where "A.DBF" and "a.dbf" are legitimately different files. -std::string fs_cache_key(const std::string& path) { - std::string k = path; - for (auto& ch : k) { - if (ch == '/') ch = '\\'; - } - return k; -} - } // namespace +// File state can change on another connection or directly on the host. +// Every existence probe must reach the server, just like DirExist. util::Result -RemoteConnection::file_exists(const std::string& path, int* src) { - if (src != nullptr) *src = 2; - const std::string key = fs_cache_key(path); - { - std::lock_guard lk(file_exists_mu_); - if (file_exists_cache_.count(key) != 0) { - if (src != nullptr) *src = 0; - return true; - } - if (file_exists_neg_cache_.count(key) != 0) { - if (src != nullptr) *src = 1; - return false; - } - } +RemoteConnection::file_exists(const std::string& path) { Frame req; req.opcode = Opcode::FileExists; push_lp_str(req.payload, path); @@ -2741,23 +2718,7 @@ RemoteConnection::file_exists(const std::string& path, int* src) { if (rep.value().opcode != Opcode::FileExistsAck || rep.value().payload.empty()) return fs_wire_err(rep.value(), "FileExists"); - bool exists = rep.value().payload[0] != 0; - { - std::lock_guard lk(file_exists_mu_); - if (exists) { - file_exists_cache_.insert(key); - file_exists_neg_cache_.erase(key); - } else { - file_exists_neg_cache_.insert(key); - } - } - return exists; -} - -void RemoteConnection::file_exists_invalidate() { - std::lock_guard lk(file_exists_mu_); - file_exists_cache_.clear(); - file_exists_neg_cache_.clear(); + return rep.value().payload[0] != 0; } util::Result @@ -2769,7 +2730,6 @@ RemoteConnection::file_erase(const std::string& path) { if (!rep) return rep.error(); if (rep.value().opcode != Opcode::FileEraseAck) return fs_wire_err(rep.value(), "FileErase"); - file_exists_invalidate(); return {}; } @@ -2784,7 +2744,6 @@ RemoteConnection::file_rename(const std::string& old_p, if (!rep) return rep.error(); if (rep.value().opcode != Opcode::FileRenameAck) return fs_wire_err(rep.value(), "FileRename"); - file_exists_invalidate(); return {}; } diff --git a/src/network/client.h b/src/network/client.h index d55251fc..14181ec8 100644 --- a/src/network/client.h +++ b/src/network/client.h @@ -400,18 +400,9 @@ class RemoteConnection { util::Result drop_table(const std::string& name, std::uint16_t delete_files); - // Server filesystem (EnableFileFunc on server). - // Positive-only existence cache (rddads probes the same production - // bags every USE): a "true" answer is served locally until any - // file-mutating op on this connection (erase/rename/drop/create, - // via file_exists_invalidate) clears it. Negatives always go to - // the wire — caching "missing" would hide a concurrently created - // table, while a stale "present" degrades to a clean open error. - // src (optional out): 0 = positive cache, 1 = negative cache, - // 2 = wire probe. For probe-level trace logging. - util::Result file_exists(const std::string& path, - int* src = nullptr); - void file_exists_invalidate(); + // Server filesystem (EnableFileFunc on server). External state is never + // cached: other sessions and host operations can change it at any time. + util::Result file_exists(const std::string& path); util::Result file_erase(const std::string& path); util::Result file_rename(const std::string& old_p, const std::string& new_p); @@ -661,17 +652,6 @@ class RemoteConnection { // connection is fully established before any other thread // can hold its handle). std::string server_version_; - // Positive-only file-existence cache (see file_exists). Guarded - // by its own mutex: consulted on the read path, cleared by - // file-mutating ops, never held across wire calls. - std::set file_exists_cache_; - std::mutex file_exists_mu_; - // Negative half of the existence cache: repeated "missing" probes - // (Vouch checks optional bags/configs on every USE) are served - // locally until any file-mutating op on this connection clears it. - // A stale "missing" degrades to the app re-checking after its own - // create attempt (which clears the cache), never to wrong data. - std::set file_exists_neg_cache_; public: // Deferred disconnect (MT shared connections). AdsDisconnect on a diff --git a/tests/unit/network_commit_slimming_test.cpp b/tests/unit/network_commit_slimming_test.cpp index 87a14714..dc912753 100644 --- a/tests/unit/network_commit_slimming_test.cpp +++ b/tests/unit/network_commit_slimming_test.cpp @@ -11,14 +11,14 @@ // d) a table whose locks were all released parks at close instead // of real-closing (the blunt ever_locked flag used to force a // 7-frame reopen per save); -// e) Repeated missing-FileExists probes cache per session. Directory -// existence and creation are never cached: peers may change them. +// e) File and directory existence always re-probe the server; external +// sessions and host operations may change them. // mtfix10: // f) AdsGetNumLocks shares the GetAllLocks ledger fast path (rddads // polls it after every commit/unlock -- it used to ride the wire // op even when the ledger was provably complete); -// g) a FileExists probe for a table open on this connection answers -// from the open-table store (a live table proves its dbf exists). +// g) FileExists no longer uses an open-table shortcut: it checks the +// exact path even when a table or index with that stem is open. #include "doctest.h" #include "mgmt/mg_stats.h" #include "network/server.h" @@ -320,36 +320,39 @@ TEST_CASE("Commit slimming: directory state always reflects server changes") { CHECK(fs::is_directory(dir / "cs_sub")); REQUIRE(AdsDirRemove(hConn, (UNSIGNED8*)"cs_sub") == AE_SUCCESS); - // A table open on this connection proves its own dbf exists: the - // probe answers from the open-table store with no wire op (mtfix10). + // FileExists is also a live probe. A positive answer for an open table + // must not bypass the server, nor may a missing bag be cached. ADSHANDLE hTable = cs_open(hConn); const std::uint64_t fe0 = cs_op(kOpFileExists); REQUIRE(AdsCheckExistence(hConn, (UNSIGNED8*)"CS.DBF", &ex) == AE_SUCCESS); CHECK(ex == 1u); - CHECK(cs_op(kOpFileExists) == fe0); - - // Regression: the open CS.DBF must NOT prove CS.Z01 exists merely - // because the stems match. The first miss goes to the server; the - // second is served by the negative FileExists cache. + CHECK(cs_op(kOpFileExists) == fe0 + 1); REQUIRE(AdsCheckExistence(hConn, (UNSIGNED8*)"CS.Z01", &ex) == AE_SUCCESS); CHECK(ex == 0u); - CHECK(cs_op(kOpFileExists) == fe0 + 1); REQUIRE(AdsCheckExistence(hConn, (UNSIGNED8*)"CS.Z01", &ex) == AE_SUCCESS); CHECK(ex == 0u); - CHECK(cs_op(kOpFileExists) == fe0 + 1); + CHECK(cs_op(kOpFileExists) == fe0 + 3); REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); - // Missing file: repeated "no" answers stop hitting the wire. - REQUIRE(AdsCheckExistence(hConn, (UNSIGNED8*)"NOPE.CDX", &ex) + // Positive-to-missing and missing-to-positive after external changes + // on the server, without a mutation through this connection. + REQUIRE(AdsCheckExistence(hConn, (UNSIGNED8*)"cs_sub", &ex) == AE_SUCCESS); CHECK(ex == 0u); - REQUIRE(AdsCheckExistence(hConn, (UNSIGNED8*)"NOPE.CDX", &ex) + REQUIRE(fs::create_directory(dir / "cs_sub", ec)); + REQUIRE_FALSE(ec); + REQUIRE(AdsCheckExistence(hConn, (UNSIGNED8*)"cs_sub", &ex) + == AE_SUCCESS); + CHECK(ex == 1u); + REQUIRE(fs::remove(dir / "cs_sub", ec)); + REQUIRE_FALSE(ec); + REQUIRE(AdsCheckExistence(hConn, (UNSIGNED8*)"cs_sub", &ex) == AE_SUCCESS); CHECK(ex == 0u); - CHECK(cs_op(kOpFileExists) == fe0 + 2); + CHECK(cs_op(kOpFileExists) == fe0 + 6); REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); srv.stop(); diff --git a/tests/unit/network_teardown_batch_test.cpp b/tests/unit/network_teardown_batch_test.cpp index ddd0d4fa..cc0c2144 100644 --- a/tests/unit/network_teardown_batch_test.cpp +++ b/tests/unit/network_teardown_batch_test.cpp @@ -6,8 +6,8 @@ // - Flush/CloseAll defer to the close that absorbs them (server close // flushes via its shadow handle and purges index bindings); any // other intervening wire op flushes them first, in order. -// - CheckExistence serves positive answers locally until a -// file-mutating op clears the cache (negatives always go out). +// - CheckExistence always probes the server; external file/dir changes +// can occur outside this connection. // - SetOrder to the ack-confirmed binding skips its frame (SetOrder // never moves the cursor). // - Order-handle key counts ride the parent's order cache. @@ -234,7 +234,7 @@ TEST_CASE("Teardown batching: merged flush survives for unparked closes") { srv.stop(); } -TEST_CASE("Teardown batching: existence positives cache until mutation") { +TEST_CASE("Teardown batching: existence probes reflect external changes") { tb_wipe(); auto dir = tb_tmp_dir(); tb_seed(dir); @@ -253,14 +253,13 @@ TEST_CASE("Teardown batching: existence positives cache until mutation") { CHECK(ex == 1u); REQUIRE(AdsCheckExistence(hConn, fn, &ex) == AE_SUCCESS); CHECK(ex == 1u); - CHECK(tb_op(kOpFileExists) == fe0 + 1); + CHECK(tb_op(kOpFileExists) == fe0 + 3); - // A file-mutating op clears the cache: the next check goes out and - // reports the drop. + // A file-mutating op also changes the next live answer. REQUIRE(AdsDropTable(hConn, fn, 1) == AE_SUCCESS); REQUIRE(AdsCheckExistence(hConn, fn, &ex) == AE_SUCCESS); CHECK(ex == 0u); - CHECK(tb_op(kOpFileExists) == fe0 + 2); + CHECK(tb_op(kOpFileExists) == fe0 + 4); REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); srv.stop(); @@ -588,7 +587,7 @@ TEST_CASE("Index park: CreateIndex invalidates the snapshot") { srv.stop(); } -TEST_CASE("Teardown batching: open bags answer existence locally") { +TEST_CASE("Teardown batching: open bags do not hide filesystem changes") { tb_wipe(); auto dir = tb_tmp_dir(); tb_seed(dir); @@ -599,28 +598,20 @@ TEST_CASE("Teardown batching: open bags answer existence locally") { ADSHANDLE hConn = tb_connect_remote(dir, srv.port()); ADSHANDLE hTable = tb_open(hConn); - // The production bag is bound by this open table: existence is - // trivially true (any spelling that stems the same, including - // the .z01 production alias) with zero frames. + // A live table or bag cannot establish existence for a differently + // spelled path. The exact spelling must be checked on the server. const std::uint64_t fe0 = tb_op(kOpFileExists); UNSIGNED16 ex = 0; - UNSIGNED8 bag1[] = "TB.CDX"; - REQUIRE(AdsCheckExistence(hConn, bag1, &ex) == AE_SUCCESS); - CHECK(ex == 1u); - UNSIGNED8 bag2[] = "tb.cdx"; - REQUIRE(AdsCheckExistence(hConn, bag2, &ex) == AE_SUCCESS); - CHECK(ex == 1u); - UNSIGNED8 bag3[] = "TB.z01"; - REQUIRE(AdsCheckExistence(hConn, bag3, &ex) == AE_SUCCESS); + UNSIGNED8 bag[] = "TB.CDX"; + REQUIRE(AdsCheckExistence(hConn, bag, &ex) == AE_SUCCESS); CHECK(ex == 1u); - CHECK(tb_op(kOpFileExists) == fe0); - - // A genuinely missing file still goes to the wire (negatives are - // never cached: the file may appear at any time). + UNSIGNED8 alias[] = "TB.z01"; + REQUIRE(AdsCheckExistence(hConn, alias, &ex) == AE_SUCCESS); + CHECK(ex == 0u); UNSIGNED8 missing[] = "NOPE.CDX"; REQUIRE(AdsCheckExistence(hConn, missing, &ex) == AE_SUCCESS); CHECK(ex == 0u); - CHECK(tb_op(kOpFileExists) == fe0 + 1); + CHECK(tb_op(kOpFileExists) == fe0 + 3); REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); From f34e9ddd43d8689772ca74452204ae9f60c2ddaf Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Sun, 27 Sep 2026 17:24:41 -0500 Subject: [PATCH 73/97] docs: add mtfix18 fresh-org test-build notes --- release-notes-1.09.68-mtfix18.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 release-notes-1.09.68-mtfix18.md diff --git a/release-notes-1.09.68-mtfix18.md b/release-notes-1.09.68-mtfix18.md new file mode 100644 index 00000000..e643beaa --- /dev/null +++ b/release-notes-1.09.68-mtfix18.md @@ -0,0 +1,13 @@ +Built for Pritpal Bedi - bedipritpal/OpenADS, forked from FiveTechSoft/OpenADS. + +## v1.09.68-mtfix18 - fresh-organization existence fix candidate + +This is a test build, not a production recommendation. Keep mtfix15 as the recommended working build while checking this candidate against a fresh Vouch organization. mtfix17 was superseded before its package release: it fixed directory probes but missed the separate file-existence cache that Vouch's guard may use. The empty mtfix17 pre-release and tag were removed, and its packaging workflow was cancelled. mtfix18 includes both fixes and retains the opt-in table-create diagnostics from mtfix16. + +Remote DirExist, DirMake, and CheckExistence now reach the server every time. This removes both positive and negative directory and file-existence caches, plus the shortcut that inferred file existence from an open table or similarly named index bag. A folder or file created or removed outside the current connection should no longer be hidden by a stale answer. Server-side DirExist treats a missing path as a normal false answer. Each formerly cached existence call may add one network round trip; no file format, locking, or retry behavior changes here. + +Tests cover DirExist true -> server-side removal -> DirExist false -> DirMake recreates, and file existence in both directions after external server-side changes on the same connection. They also check that an open table or bag does not bypass an exact FileExists probe. The observed first-create error (`uxxyyzza.dbf`: exclusive open saw a missing parent directory) is consistent with a stale existence answer, but the earlier diagnostic trace did not prove the full raw table path or every Vouch folder step. A successful fresh-org Vouch run is still needed to validate this candidate. + +**What to test:** Use mtfix18 client and server from this same build for one new Vouch organization, including its first table create. Report the organization-create result, any `uxxyyzza.dbf` or other missing-folder error, and the client and server diagnostic logs if it still fails. Check whether Vouch's `OAds_FileExist()` wrapper reaches OpenADS's `oads_CheckExistence` export; the fork's Harbour helpers are named `OADS_CHECKEXISTENCE` and `OADS_FEXIST`, and it does not ship an `OADS_FILEEXIST` helper by that exact name. Return to mtfix15 for regular work until mtfix18 is validated. + +**CI:** Windows x86/x64 and Linux (including TLS) passed, along with Harbour smoke. macOS configured and built, but its test step was cancelled at the CI timeout, so macOS tests remain unverified. This timeout occurred on earlier builds too; it is not evidence that mtfix18's macOS tests passed. Strict no-symlink enforcement is separate and not in this test build. From 33bf8dbc038a6dfdd0cd15900d8ab48ee12f4a91 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898283+github-actions[bot]@users.noreply.github.com> Date: Mon, 28 Sep 2026 01:03:30 +0000 Subject: [PATCH 74/97] fix(oads): use shared connection for no-handle Harbour helpers (mtfix19) Applied by apply-patch workflow, run 36364061043, started by bedipritpal. --- contrib/oads_hb/oads_hb.c | 114 ++++++++++++++++++++++--------- release-notes-1.09.68-mtfix19.md | 13 ++++ 2 files changed, 95 insertions(+), 32 deletions(-) create mode 100644 release-notes-1.09.68-mtfix19.md diff --git a/contrib/oads_hb/oads_hb.c b/contrib/oads_hb/oads_hb.c index e43a2ad2..6c040ffb 100644 --- a/contrib/oads_hb/oads_hb.c +++ b/contrib/oads_hb/oads_hb.c @@ -29,11 +29,12 @@ * hbmk2 myproject.hbp oads_hb.c -L/path/to/openads/importlib -lace64 * * Connection management: - * OAds_SetConnection( hConn ) -- set default connection for this thread - * OAds_GetConnection() -> hConn -- get current default connection + * OAds_SetConnection( hConn ) -- set the shared OAds default handle + * OAds_GetConnection() -> hConn -- read the shared OAds default * * All OAds_F* functions accept hConn as the first (optional) parameter. - * When omitted, the thread-local default connection is used: + * When omitted, the handle set by OAds_SetConnection is used. If none + * has been set, the ACE default connection is used for compatibility: * OAds_FOpen( cFileName, nMode ) -- uses default connection * OAds_FOpen( hConn, cFileName, nMode ) -- uses explicit connection */ @@ -44,6 +45,48 @@ #include "ace.h" #include +/* The no-handle OAds_* overloads belong to this Harbour glue, not to + * rddads. ACE's default is thread-local; the app can set it on its login + * thread then make an OAds_* call on a different thread, accidentally + * falling back to ACE's local cwd connection. Keep an OAds-specific shared + * handle as the source of truth once OAds_SetConnection has been called. + * Atomic access protects MT Harbour callers; explicit-handle overloads + * bypass this value. An app with multiple independent connections must pass + * the handle explicitly rather than relying on one process-wide default. + */ +#if defined( _WIN32 ) +# include +static volatile LONG s_oads_conn = 0; +static void oads_store_default( ADSHANDLE hConn ) +{ + InterlockedExchange( &s_oads_conn, ( LONG ) hConn ); +} +static ADSHANDLE oads_load_default( void ) +{ + return ( ADSHANDLE ) InterlockedCompareExchange( &s_oads_conn, 0, 0 ); +} +#else +static ADSHANDLE s_oads_conn = 0; +static void oads_store_default( ADSHANDLE hConn ) +{ + __atomic_store_n( &s_oads_conn, hConn, __ATOMIC_RELEASE ); +} +static ADSHANDLE oads_load_default( void ) +{ + return __atomic_load_n( &s_oads_conn, __ATOMIC_ACQUIRE ); +} +#endif + +static ADSHANDLE oads_default_connection( void ) +{ + ADSHANDLE hConn = 0; + hConn = oads_load_default(); + if( hConn != 0 ) + return hConn; + AdsGetDefaultConnection( &hConn ); + return hConn; +} + /* AdsGetServerVersion is an OpenADS extension (v1.09.28+). Declared here as well so this file still compiles against an older ace.h; an identical redeclaration is legal C when the new ace.h is used. */ @@ -53,23 +96,30 @@ extern UNSIGNED32 ENTRYPOINT AdsGetServerVersion( ADSHANDLE hConnect, /* ------------------------------------------------------------------ */ /* OADS_SETCONNECTION( hConn ) -> lOk */ -/* Set the default connection for the calling thread. */ +/* Set both the ACE current-thread default and the shared OAds default. */ /* ------------------------------------------------------------------ */ HB_FUNC( OADS_SETCONNECTION ) { ADSHANDLE hConn = ( ADSHANDLE ) hb_parnint( 1 ); - hb_retl( AdsSetDefaultConnection( hConn ) == 0 ); + UNSIGNED32 rc; + if( hConn == 0 ) + { + hb_retl( 0 ); + return; + } + rc = AdsSetDefaultConnection( hConn ); + if( rc == 0 ) + oads_store_default( hConn ); + hb_retl( rc == 0 ); } /* ------------------------------------------------------------------ */ /* OADS_GETCONNECTION() -> hConn */ -/* Get the current default connection for the calling thread. */ +/* Report the same handle that no-handle OAds_* calls will use. */ /* ------------------------------------------------------------------ */ HB_FUNC( OADS_GETCONNECTION ) { - ADSHANDLE hConn = 0; - AdsGetDefaultConnection( &hConn ); - hb_retnint( ( HB_MAXINT ) hConn ); + hb_retnint( ( HB_MAXINT ) oads_default_connection() ); } /* ------------------------------------------------------------------ */ @@ -103,7 +153,7 @@ HB_FUNC( OADS_FCREATE ) } else { - AdsGetDefaultConnection( &hConn ); + hConn = oads_default_connection(); szName = hb_parc( 1 ); usAttr = ( UNSIGNED16 ) hb_parni( 2 ); } @@ -133,7 +183,7 @@ HB_FUNC( OADS_FOPEN ) } else { - AdsGetDefaultConnection( &hConn ); + hConn = oads_default_connection(); szName = hb_parc( 1 ); usMode = ( UNSIGNED16 ) hb_parni( 2 ); } @@ -248,7 +298,7 @@ HB_FUNC( OADS_CHECKEXISTENCE ) } else { - AdsGetDefaultConnection( &hConn ); + hConn = oads_default_connection(); szName = hb_parc( 1 ); } @@ -274,7 +324,7 @@ HB_FUNC( OADS_DELETEFILE ) } else { - AdsGetDefaultConnection( &hConn ); + hConn = oads_default_connection(); szName = hb_parc( 1 ); } @@ -302,7 +352,7 @@ HB_FUNC( OADS_RENAMEFILE ) } else { - AdsGetDefaultConnection( &hConn ); + hConn = oads_default_connection(); szOld = hb_parc( 1 ); szNew = hb_parc( 2 ); } @@ -330,7 +380,7 @@ HB_FUNC( OADS_GETFILESIZE ) } else { - AdsGetDefaultConnection( &hConn ); + hConn = oads_default_connection(); szName = hb_parc( 1 ); } @@ -357,7 +407,7 @@ HB_FUNC( OADS_GETFILEDATE ) } else { - AdsGetDefaultConnection( &hConn ); + hConn = oads_default_connection(); szName = hb_parc( 1 ); } @@ -384,7 +434,7 @@ HB_FUNC( OADS_GETFILETIME ) } else { - AdsGetDefaultConnection( &hConn ); + hConn = oads_default_connection(); szName = hb_parc( 1 ); } @@ -410,7 +460,7 @@ HB_FUNC( OADS_DIRMAKE ) } else { - AdsGetDefaultConnection( &hConn ); + hConn = oads_default_connection(); szPath = hb_parc( 1 ); } @@ -436,7 +486,7 @@ HB_FUNC( OADS_DIRREMOVE ) } else { - AdsGetDefaultConnection( &hConn ); + hConn = oads_default_connection(); szPath = hb_parc( 1 ); } @@ -462,7 +512,7 @@ HB_FUNC( OADS_DIREXIST ) } else { - AdsGetDefaultConnection( &hConn ); + hConn = oads_default_connection(); szPath = hb_parc( 1 ); } @@ -498,7 +548,7 @@ HB_FUNC( OADS_DIRECTORY ) } else { - AdsGetDefaultConnection( &hConn ); + hConn = oads_default_connection(); szMask = hb_parc( 1 ); usAttr = ( UNSIGNED16 ) hb_parni( 2 ); } @@ -601,7 +651,7 @@ HB_FUNC( OADS_FEXIST ) } else { - AdsGetDefaultConnection( &hConn ); + hConn = oads_default_connection(); szName = hb_parc( 1 ); } @@ -632,7 +682,7 @@ HB_FUNC( OADS_MUTEXCREATE ) } else { - AdsGetDefaultConnection( &hConn ); + hConn = oads_default_connection(); szName = hb_parc( 1 ); } @@ -661,7 +711,7 @@ HB_FUNC( OADS_MUTEXLOCK ) } else { - AdsGetDefaultConnection( &hConn ); + hConn = oads_default_connection(); szName = hb_parc( 1 ); ulTimeOut = ( UNSIGNED32 ) hb_parnint( 2 ); } @@ -689,7 +739,7 @@ HB_FUNC( OADS_MUTEXTRYLOCK ) } else { - AdsGetDefaultConnection( &hConn ); + hConn = oads_default_connection(); szName = hb_parc( 1 ); } @@ -716,7 +766,7 @@ HB_FUNC( OADS_MUTEXUNLOCK ) } else { - AdsGetDefaultConnection( &hConn ); + hConn = oads_default_connection(); szName = hb_parc( 1 ); } @@ -742,7 +792,7 @@ HB_FUNC( OADS_MUTEXDESTROY ) } else { - AdsGetDefaultConnection( &hConn ); + hConn = oads_default_connection(); szName = hb_parc( 1 ); } @@ -806,7 +856,7 @@ HB_FUNC( OADS_SERVERVERSION ) if( hb_pcount() >= 1 ) hConn = ( ADSHANDLE ) hb_parnint( 1 ); else - AdsGetDefaultConnection( &hConn ); + hConn = oads_default_connection(); ulRc = AdsGetServerVersion( hConn, ( UNSIGNED8 * ) szVer, &usLen ); szVer[ sizeof( szVer ) - 1 ] = '\0'; @@ -928,7 +978,7 @@ HB_FUNC( OADS_ZIP ) } else { - AdsGetDefaultConnection( &hConn ); + hConn = oads_default_connection(); base = 0; } if( hb_pcount() < base + 4 ) @@ -1023,7 +1073,7 @@ HB_FUNC( OADS_UNZIP ) } else { - AdsGetDefaultConnection( &hConn ); + hConn = oads_default_connection(); base = 0; } if( hb_pcount() < base + 1 ) @@ -1089,7 +1139,7 @@ HB_FUNC( OADS_ZIPFILECOUNT ) } else { - AdsGetDefaultConnection( &hConn ); + hConn = oads_default_connection(); szZip = hb_parc( 1 ); } @@ -1179,7 +1229,7 @@ HB_FUNC( OADS_ZIPFILELIST ) } else { - AdsGetDefaultConnection( &hConn ); + hConn = oads_default_connection(); base = 0; } if( hb_pcount() < base + 1 ) diff --git a/release-notes-1.09.68-mtfix19.md b/release-notes-1.09.68-mtfix19.md new file mode 100644 index 00000000..52678703 --- /dev/null +++ b/release-notes-1.09.68-mtfix19.md @@ -0,0 +1,13 @@ +Built for Pritpal Bedi - bedipritpal/OpenADS, forked from FiveTechSoft/OpenADS. + +## v1.09.68-mtfix19 - OAds connection handoff test build + +This is a test build, not a production recommendation. Keep mtfix15 as the recommended working build until Vouch's fresh-organization flow validates this candidate. mtfix19 includes mtfix18's always-live directory and file existence checks, plus a change in the Harbour `contrib/oads_hb/oads_hb.c` glue. It does not change server-side table locking or file formats. + +`OAds_SetConnection(hConn)` now stores a shared OAds default handle, and `OAds_GetConnection()` and all no-handle OAds file, directory, mutex, archive, and server-version helpers use that same handle. Explicit-handle overloads are unchanged. Before the first OAds SetConnection, the ACE default remains the fallback. Zero is rejected as a new OAds default. This targets the Vouch case where `OAds_DirExist(cName)` answered YES for a directory absent on the server but `OAds_DirExist(SetAdsConxn(), cName)` answered NO. That difference establishes a wrong implicit connection in that run; it does not prove whether a thread switch, intervening overwrite, or compiled binding difference caused the old default to diverge. + +**What to test:** `oads_hb.c` is compiled into Vouch. Rebuild Vouch with the mtfix19 copy of that file and restore the original one-argument `OAds_DirExist(cName)` call. Merely replacing the DLL does not replace the compiled glue. Use matching mtfix19 client and server builds for a clean new Vouch organization. Confirm the absent directory returns NO, creation succeeds, and the first tables open. Report the exact return/error and both client/server logs if it fails. In apps concurrently using independent connections or organizations, pass the explicit handle rather than switching one process-wide OAds default mid-operation. + +A separate 700-instance B_BIG WAN storm stalled and remains undiagnosed. This test build does not claim a storm fix or a safe upper bound. + +**Validation:** The source diff passes whitespace checks and a C syntax-only check against mocked Harbour declarations; a real Harbour Vouch rebuild and fresh-org test are still required. Windows x86/x64 and Linux CI status belongs to this release's workflow once complete. macOS configured and built in the prior mtfix18 run, but its Test step timed out/cancelled, so macOS tests remain unverified until a completed run says otherwise. From 62090372dfee131340f0c3bd131712ad76a73924 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898283+github-actions[bot]@users.noreply.github.com> Date: Tue, 29 Sep 2026 05:18:47 +0000 Subject: [PATCH 75/97] fix(lock): make repeat RLOCK idempotent and DBRI_LOCKED owner-scoped (Pritpal Bedi) Applied by apply-patch workflow, run 36525051352, started by bedipritpal. --- src/abi/ace_exports.cpp | 43 +++++++++++---- src/engine/table.cpp | 10 ++-- tests/unit/abi_lock_comprehensive_test.cpp | 20 +++---- tests/unit/abi_lock_unlock_full_test.cpp | 37 ++++++++++++- .../abi_remote_lock_introspection_test.cpp | 55 +++++++++++++++---- 5 files changed, 124 insertions(+), 41 deletions(-) diff --git a/src/abi/ace_exports.cpp b/src/abi/ace_exports.cpp index d5544326..87f7dc47 100644 --- a/src/abi/ace_exports.cpp +++ b/src/abi/ace_exports.cpp @@ -38416,28 +38416,49 @@ UNSIGNED32 ENTRYPOINT AdsIsRecordInAOF(ADSHANDLE, UNSIGNED32, UNSIGNED16* p) { arc2_trace("AdsIsRecordInAOF"); if (p) *p = 1; return openads::AE_SUCCESS; } // ulRecord == 0 means "the current record" (ACE convention). Reports -// whether *this* connection holds an exclusive lock on it. Remote -// handles go over the wire (M12.36 IsRecordLocked opcode); the server -// translates recno 0 to the current record on its side. +// whether this table handle owns the lock, not whether another user does. +// The global OS byte probe remains separately in Table::is_record_locked_any. UNSIGNED32 ENTRYPOINT AdsIsRecordLocked(ADSHANDLE hTable, UNSIGNED32 ulRecord, UNSIGNED16* pbLocked) { arc2_trace("AdsIsRecordLocked"); if (pbLocked == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); *pbLocked = 0; if (auto* rt = get_remote_table(hTable)) { - auto r = rt->conn->is_record_locked(rt->id, ulRecord); + const std::uint32_t rec = ulRecord == 0 + ? (rt->row_valid ? rt->current_recno : 0) : ulRecord; + // A held file lock or an explicit recno in the client ledger is + // positive proof of ownership, even if an append also made the + // ledger incomplete. A negative answer is safe only when complete. + if (rt->table_lock_held || + (rec != 0 && rt->held_recs.count(rec) != 0)) { + *pbLocked = 1; + return ok(); + } + if (rec != 0 && !rt->locks_uncertain) return ok(); + // Append auto-locks and unknown current recnos need the server's + // GetAllLocks, which enumerates this wire table's owning ABI handle. + auto r = rt->conn->get_all_locks(rt->id); if (!r) return fail(r.error()); - *pbLocked = r.value(); + std::uint32_t target = rec; + if (target == 0) { + auto rn = rt->conn->get_record_num(rt->id); + if (!rn) return fail(rn.error()); + target = rn.value(); + } + const auto& held = r.value(); + *pbLocked = rt->table_lock_held || + std::find(held.begin(), held.end(), target) != held.end(); return ok(); } Table* t = get_table(hTable); if (t == nullptr) return fail(openads::AE_INTERNAL_ERROR, "no table"); - std::uint32_t rec = (ulRecord == 0) ? t->recno() : ulRecord; - // mtfix11: SAP answers across connections - own registrations plus a - // non-destructive OS lock-byte probe, not this table's list alone. - auto any = t->is_record_locked_any(rec); - if (!any) return fail(any.error()); - *pbLocked = any.value() ? 1 : 0; + const std::uint32_t rec = ulRecord == 0 ? t->recno() : ulRecord; + if (t->is_table_locked()) { + *pbLocked = 1; + } else { + const auto held = t->held_record_locks(); + *pbLocked = std::find(held.begin(), held.end(), rec) != held.end(); + } return ok(); } UNSIGNED32 ENTRYPOINT AdsIsServerLoaded(UNSIGNED8*, UNSIGNED16* p) diff --git a/src/engine/table.cpp b/src/engine/table.cpp index 1a0fa8a6..73ce2f28 100644 --- a/src/engine/table.cpp +++ b/src/engine/table.cpp @@ -1883,6 +1883,11 @@ util::Result Table::lock_record_excl(std::uint32_t recno) { util::Result Table::try_lock_record_excl(std::uint32_t recno) { if (mode_ == OpenMode::Read) return {}; + // A record lock is binary at the Table/ACE level. A repeated RLock on + // this handle must not increment LockMgr's internal reference count: + // one UnlockRecord must release the OS byte even after repeated RLocks. + if (recno_locks_.find(recno) != recno_locks_.end()) + return load_record_(recno); if (table_lock_) { recno_locks_.emplace(recno, LockHandle{}); return load_record_(recno); @@ -1890,10 +1895,7 @@ util::Result Table::try_lock_record_excl(std::uint32_t recno) { auto h = locks_.try_lock_record_excl(driver_->file(), to_lock_type_(), locking_, recno); if (!h) return h.error(); - auto [it, inserted] = recno_locks_.emplace(recno, std::move(h).value()); - if (!inserted) { - (void)it; - } + recno_locks_.emplace(recno, std::move(h).value()); return load_record_(recno); } diff --git a/tests/unit/abi_lock_comprehensive_test.cpp b/tests/unit/abi_lock_comprehensive_test.cpp index e60a092c..fdd84740 100644 --- a/tests/unit/abi_lock_comprehensive_test.cpp +++ b/tests/unit/abi_lock_comprehensive_test.cpp @@ -422,9 +422,9 @@ TEST_CASE("Lock 5 records, unlock in reverse order, verify count each step") { } // =========================================================================== -// Re-entrant record lock: same record locked twice → only one unlock needed? +// Repeated record lock is idempotent: one unlock releases the OS lock // =========================================================================== -TEST_CASE("Re-entrant record lock: two locks on same recno, need two unlocks") { +TEST_CASE("Repeated record lock: two locks on same recno need one unlock") { const auto dir = fs::temp_directory_path() / "openads_lock_reenter"; std::error_code ec; fs::remove_all(dir, ec); @@ -433,31 +433,25 @@ TEST_CASE("Re-entrant record lock: two locks on same recno, need two unlocks") { auto hConn = connect_local(dir); auto hTbl = open_table(hConn, "RE", "RE"); - // Lock the same record twice (re-entrant). + // Lock the same record twice on one table handle. REQUIRE(AdsLockRecord(hTbl, 3) == 0); REQUIRE(AdsLockRecord(hTbl, 3) == 0); UNSIGNED16 cnt = 0; REQUIRE(AdsGetNumLocks(hTbl, &cnt) == 0); - // Re-entrant locks are refcounted — it depends on implementation whether - // the count reflects refcount (2) or unique records (1). - // For ACE-compatible: AdsGetNumLocks counts the lock count, not unique recnos. + CHECK(cnt == 1); // one held record, not two nested acquisitions UNSIGNED16 locked = 9; REQUIRE(AdsIsRecordLocked(hTbl, 3, &locked) == 0); CHECK(locked == 1); // Still locked - // First unlock — record should still be locked (refcount 1). - REQUIRE(AdsUnlockRecord(hTbl, 3) == 0); - locked = 9; - REQUIRE(AdsIsRecordLocked(hTbl, 3, &locked) == 0); - CHECK(locked == 1); // Still locked (re-entrant) - - // Second unlock — now truly unlocked. + // One unlock releases the record, despite two successful RLocks. REQUIRE(AdsUnlockRecord(hTbl, 3) == 0); locked = 9; REQUIRE(AdsIsRecordLocked(hTbl, 3, &locked) == 0); CHECK(locked == 0); + REQUIRE(AdsGetNumLocks(hTbl, &cnt) == 0); + CHECK(cnt == 0); REQUIRE(AdsCloseTable(hTbl) == 0); REQUIRE(AdsDisconnect(hConn) == 0); diff --git a/tests/unit/abi_lock_unlock_full_test.cpp b/tests/unit/abi_lock_unlock_full_test.cpp index d4969b96..ff1e8eca 100644 --- a/tests/unit/abi_lock_unlock_full_test.cpp +++ b/tests/unit/abi_lock_unlock_full_test.cpp @@ -8,7 +8,7 @@ // blocks the next dbRLock() forever (the GN_COUNT R-LOCKING loop). // // This file pins every lock/unlock case: current vs explicit recno, -// refcounted double locks, unlock of non-held records, append auto-lock +// idempotent double locks, unlock of non-held records, append auto-lock // release, the dbUnlock equivalence, cross-connection contention, and // the multi-thread shared-connection scenario from the field. @@ -108,7 +108,7 @@ TEST_CASE("lockfull local: lock current then unlock current") { fs::remove_all(dir, ec); } -TEST_CASE("lockfull local: double lock is refcounted, needs two unlocks") { +TEST_CASE("lockfull local: double lock needs one unlock") { auto dir = fs::temp_directory_path() / "oads_lf_refcount"; std::error_code ec; fs::remove_all(dir, ec); @@ -119,7 +119,6 @@ TEST_CASE("lockfull local: double lock is refcounted, needs two unlocks") { REQUIRE(AdsLockRecord(hT, 2) == 0); CHECK(num_locks(hT) == 1u); REQUIRE(AdsUnlockRecord(hT, 2) == 0); - REQUIRE(AdsUnlockRecord(hT, 2) == 0); CHECK(num_locks(hT) == 0u); REQUIRE(AdsCloseTable(hT) == 0); REQUIRE(AdsDisconnect(hC) == 0); @@ -218,6 +217,38 @@ TEST_CASE("lockfull remote: lock current / unlock current over the wire") { fs::remove_all(dir, ec); } +TEST_CASE("lockfull remote: repeated lock needs one unlock") { + auto dir = fs::temp_directory_path() / "oads_lf_remote_repeat"; + std::error_code ec; + fs::remove_all(dir, ec); + openads::network::Server srv; + REQUIRE(srv.start("127.0.0.1", 0).has_value()); + ADSHANDLE hSetup = connect_local(dir); + stage_table(dir, "RC_REPEAT", 5, hSetup); + REQUIRE(AdsDisconnect(hSetup) == 0); + + ADSHANDLE hA = connect_remote(dir, srv.port()); + ADSHANDLE hB = connect_remote(dir, srv.port()); + auto hTA = open_shared(hA, "RC_REPEAT"); + auto hTB = open_shared(hB, "RC_REPEAT"); + REQUIRE(AdsLockRecord(hTA, 2) == 0); + REQUIRE(AdsLockRecord(hTA, 2) == 0); + CHECK(num_locks(hTA) == 1u); + // The lock remains exclusive until its one and only release. + CHECK(AdsLockRecord(hTB, 2) != 0); + REQUIRE(AdsUnlockRecord(hTA, 2) == 0); + CHECK(num_locks(hTA) == 0u); + // The other session must acquire the real OS lock after just one unlock. + REQUIRE(AdsLockRecord(hTB, 2) == 0); + REQUIRE(AdsUnlockRecord(hTB, 2) == 0); + REQUIRE(AdsCloseTable(hTA) == 0); + REQUIRE(AdsCloseTable(hTB) == 0); + REQUIRE(AdsDisconnect(hA) == 0); + REQUIRE(AdsDisconnect(hB) == 0); + srv.stop(); + fs::remove_all(dir, ec); +} + TEST_CASE("lockfull remote: dbUnlock (UnlockTable) releases the record lock") { auto dir = fs::temp_directory_path() / "oads_lf_remote_dbunlock"; std::error_code ec; diff --git a/tests/unit/abi_remote_lock_introspection_test.cpp b/tests/unit/abi_remote_lock_introspection_test.cpp index 52e1389b..f2835ca3 100644 --- a/tests/unit/abi_remote_lock_introspection_test.cpp +++ b/tests/unit/abi_remote_lock_introspection_test.cpp @@ -5,8 +5,9 @@ // remote handles (ace_exports.cpp returned early for get_remote_table()), // so Harbour dbRecordInfo(DBRI_LOCKED) and dbRLockList() under ADSCDX // always answered .F./{} — Vouch's IsLogged() thread bailed out. -// The IsRecordLocked (0x13) / GetAllLocks (0x15) wire opcodes forward the -// query to the server-side per-session lock state. +// AdsIsRecordLocked answers from the client lock ledger when complete; +// GetAllLocks (0x15) falls back to the server's own-handle list for append +// auto-locks that the client cannot name. The global OS probe stays internal. #include "doctest.h" #include "openads/ace.h" #include "openads/error.h" @@ -158,7 +159,39 @@ ADS_SHARED, &hTable) REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); } -TEST_CASE("M13.1 remote AdsIsRecordLocked sees other connections' locks") { +TEST_CASE("M12.36 remote append auto-lock is visible via own-handle fallback") { + auto dir = lock_tmp_dir(); + seed_lock_fixture(dir); + openads::network::Server srv; + REQUIRE(srv.start("127.0.0.1", 0).has_value()); + char uri[512]; + std::snprintf(uri, sizeof(uri), "tcp://127.0.0.1:%u/%s", + static_cast(srv.port()), dir.string().c_str()); + UNSIGNED8 srvbuf[512]{}; + std::memcpy(srvbuf, uri, std::strlen(uri) + 1); + ADSHANDLE hConn = 0, hTable = 0; + REQUIRE(AdsConnect60(srvbuf, ADS_REMOTE_SERVER, nullptr, nullptr, 0, + &hConn) == AE_SUCCESS); + UNSIGNED8 tname[] = "li.dbf"; + REQUIRE(AdsOpenTable(hConn, tname, nullptr, ADS_CDX, ADS_ANSI, + ADS_COMPATIBLE_LOCKING, ADS_IGNORERIGHTS, + ADS_SHARED, &hTable) == AE_SUCCESS); + REQUIRE(AdsAppendRecord(hTable) == AE_SUCCESS); + UNSIGNED32 rec = 0; + REQUIRE(AdsGetRecordNum(hTable, 0, &rec) == AE_SUCCESS); + REQUIRE(rec != 0); + UNSIGNED16 locked = 0; + REQUIRE(AdsIsRecordLocked(hTable, rec, &locked) == AE_SUCCESS); + CHECK(locked == 1); + REQUIRE(AdsUnlockTable(hTable) == AE_SUCCESS); + REQUIRE(AdsIsRecordLocked(hTable, rec, &locked) == AE_SUCCESS); + CHECK(locked == 0); + REQUIRE(AdsCloseTable(hTable) == AE_SUCCESS); + REQUIRE(AdsDisconnect(hConn) == AE_SUCCESS); + srv.stop(); +} + +TEST_CASE("M13.1 remote AdsIsRecordLocked reports only its own locks") { auto dir = lock_tmp_dir(); seed_lock_fixture(dir); @@ -190,26 +223,28 @@ ADS_SHARED, REQUIRE(AdsIsRecordLocked(tB, 3, &locked) == AE_SUCCESS); CHECK(locked == 0); - // A locks record 3: B's status query must see the cross-connection - // lock (SAP global semantics - login-semaphore guards depend on it), - // while A's own view still reports it and a neighbour stays free. + // A owns record 3. B must not mistake A's lock for its own. REQUIRE(AdsLockRecord(tA, 3) == AE_SUCCESS); REQUIRE(AdsIsRecordLocked(tB, 3, &locked) == AE_SUCCESS); - CHECK(locked == 1); + CHECK(locked == 0); REQUIRE(AdsIsRecordLocked(tA, 3, &locked) == AE_SUCCESS); CHECK(locked == 1); REQUIRE(AdsIsRecordLocked(tB, 4, &locked) == AE_SUCCESS); CHECK(locked == 0); - // A's file lock covers every record from B's point of view. + // A's file lock is A's own state, never B's. REQUIRE(AdsLockTable(tA) == AE_SUCCESS); - REQUIRE(AdsIsRecordLocked(tB, 2, &locked) == AE_SUCCESS); + REQUIRE(AdsIsRecordLocked(tA, 2, &locked) == AE_SUCCESS); CHECK(locked == 1); + REQUIRE(AdsIsRecordLocked(tB, 2, &locked) == AE_SUCCESS); + CHECK(locked == 0); REQUIRE(AdsUnlockTable(tA) == AE_SUCCESS); + REQUIRE(AdsIsRecordLocked(tA, 2, &locked) == AE_SUCCESS); + CHECK(locked == 0); REQUIRE(AdsIsRecordLocked(tB, 2, &locked) == AE_SUCCESS); CHECK(locked == 0); - // After A unlocks, B sees the record free again. + // Once A unlocks, neither handle claims ownership. REQUIRE(AdsUnlockRecord(tA, 3) == AE_SUCCESS); REQUIRE(AdsIsRecordLocked(tB, 3, &locked) == AE_SUCCESS); CHECK(locked == 0); From 807f0694282f65cf1876dbae5634c15e5f3d8f29 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Tue, 29 Sep 2026 01:28:19 -0500 Subject: [PATCH 76/97] docs(release): mtfix20 lock-contract test build notes (Pritpal Bedi) --- release-notes-1.09.68-mtfix20.md | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 release-notes-1.09.68-mtfix20.md diff --git a/release-notes-1.09.68-mtfix20.md b/release-notes-1.09.68-mtfix20.md new file mode 100644 index 00000000..582a7733 --- /dev/null +++ b/release-notes-1.09.68-mtfix20.md @@ -0,0 +1,13 @@ +Built for Pritpal Bedi - bedipritpal/OpenADS, forked from FiveTechSoft/OpenADS. + +## v1.09.68-mtfix20 - record-lock contract test build + +This is a test build, not a production recommendation. Keep mtfix15 as the recommended working build until Pritpal checks this candidate in Vouch. mtfix20 includes the earlier mtfix19 changes; this release adds two record-lock fixes without changing LockMgr's internal reference counting or the global lock probe, which remains available internally. + +A repeated RLOCK of the same record by the same Table handle is now a no-op for lock acquisition. In Xbase terms, `RLOCK(n)` followed by another `RLOCK(n)` still holds one record lock, and one successful `UNLOCK(n)` releases it. This addresses Pritpal's observation that repeated locks followed by a single unlock left the record locked. + +`AdsIsRecordLocked` (used by Harbour's `DBRI_LOCKED`) now reports whether the calling table handle owns the record lock, rather than whether any connection has locked that record. That matches the own-handle lock list returned by `AdsGetAllLocks` (used by `dbrLockList()`), so the two Vouch `IsLogged` branches should agree. For an uncertain remote append auto-lock, it asks the same handle's `GetAllLocks` rather than using the global probe. Another connection's lock does not make this handle's `DBRI_LOCKED` true. This answers lock ownership, not whether acquiring a new lock would succeed; the global probe is retained internally for that separate question. + +**What to test:** With matching mtfix20 client and server builds, use the Windows x86 archive for Pritpal's Vouch setup. On one handle, RLOCK a record twice, unlock it once, and check both `DBRI_LOCKED` and `dbrLockList()`. On a second connection, check that it can then acquire the record. Also test Vouch's blocked record-edit sequence and the `LOGIN_A` case, recording the exact handle, record number, return code, and lock list if either remains wrong. No Vouch result is claimed here. Back up the working DLLs and return to mtfix15 for regular work if this candidate regresses. + +**Validation:** The guarded patch run built and tested the Linux configurations before committing. In full CI #36, the first TLS Linux run failed an ordered-prefetch byte-ratio assertion; that one-run fluctuation cleared on the single failed-jobs rerun and is unrelated to the lock changes. The other non-macOS jobs passed. The macOS build finished, but its unit test was canceled by the known 30-minute CI job timeout, which predates these changes. macOS unit tests remain unverified; do not read the canceled overall CI status as a green run. The release workflow separately requires the Windows x86/x64, Linux x64, and macOS universal packaging legs and all four canonical assets before publication. From adf8aa5eb0b5602b7fa21e46d48e702d610eedb5 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Tue, 29 Sep 2026 22:58:15 -0500 Subject: [PATCH 77/97] docs(release): mtfix21 upstream-sync test build notes (Pritpal Bedi) --- release-notes-1.09.70-mtfix21.md | 35 ++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 release-notes-1.09.70-mtfix21.md diff --git a/release-notes-1.09.70-mtfix21.md b/release-notes-1.09.70-mtfix21.md new file mode 100644 index 00000000..988e8258 --- /dev/null +++ b/release-notes-1.09.70-mtfix21.md @@ -0,0 +1,35 @@ +Built for Pritpal Bedi - bedipritpal/OpenADS, forked from FiveTechSoft/OpenADS. + +## v1.09.70-mtfix21 - upstream-sync and Exclusive ADT append test build + +This is a test build, not a production recommendation. Keep mtfix15 as the recommended working build until Pritpal checks this candidate in Vouch. Use matching client and server builds. Back up the working binaries before testing. + +This candidate syncs FiveTechSoft/OpenADS main through 54e237b1 into the fork's test branch with a real two-parent merge. It keeps the fork's mtfix20 lock-contract fixes, client lock-ledger fast paths, engine append retry policy, and CI/release workflows. Upstream's accidental duplicated table.cpp prefix was removed locally so the merged source has one append implementation. No source merge to fork main or upstream is part of this release. + +### Exclusive ADT append + +Exclusive ADT appends no longer add an automatic record lock for each new row. Shared ADT appends still take a record lock, and shared writes still require the caller's lock. The engine change affects Exclusive ADT engine instances, including the server; it is not limited to LOCAL connections. DBF append behavior is unchanged by this condition. + +A new LOCAL ADT regression appends 10,000 rows with field updates and no per-row AdsWriteRecord, checks that Exclusive mode retains zero record locks, closes/reopens the table, and verifies distinct persisted values at rows 1, 5,000 and 10,000. It also checks the Shared write guard and append lock/unlock behavior. + +No measured speed gain on Tim Stone's 300,000-row program or on Pritpal's Vouch workload is claimed. Harbour's ordinary DbUnlock can skip Exclusive tables; direct AdsUnlockRecord reaches the engine. The shown append loop does not establish per-row fsync, and an empty transaction log without an explicit transaction is expected. + +### Other synced upstream work + +The upstream base advances CMake's project version to 1.09.70. The test series steps up from v1.09.68-mtfix20 to v1.09.70-mtfix21 to match that base; use the new version in test download scripts. The tag supplies the packaged binary version. The sync also includes upstream NTX inter-process index locking and MariaDB numeric/logical setters. Those upstream additions need application testing where used. + +The server-side own-lock query recognizes the calling engine handle's table lock and the ABI twin's record locks, with an engine held-list fallback. It does not substitute a global other-connection lock probe for the caller's own lock state. + +### Validation + +Local Linux GCC validation used -O0/-g0 and disabled warnings-as-errors after existing shadow warnings. The focused lock/introspection harness passed 50 cases and 2,120 assertions. The new ADT regression passed 1 case and 20,034 assertions. + +The full local suite ran 1,604 cases: 1,601 passed, 3 failed, and 16 were skipped, with 598,135 assertions and 8 assertion failures. The failures involved the MT reader/appender walk, an Exclusive-held OpenIndex return-code expectation, and remote create/append stress counts. All three failure classes reproduced on a reconstructed pre-sync 807f0694 baseline with the changed core sources and their header consumers recompiled. This was a focused baseline comparison, not a fresh full baseline CI run; it does not make the full suite green. + +CI #41 on 510e3626 passed all 10 non-macOS jobs, including Windows MSVC x86/x64, Linux with and without TLS, Harbour smoke, PHP, and the live SQL jobs. macOS configured and built successfully, then its unit test was canceled by the existing 30-minute CI job cap. macOS unit tests remain unverified; the overall CI status is Cancelled, not green. The release workflow separately requires all four packaging legs and canonical assets before publication. + +CI: https://github.com/bedipritpal/OpenADS/actions/runs/36664053398 + +### Application checks + +Test Tim's Exclusive LOCAL ADT append program against a backed-up dataset and compare elapsed time, record count, persisted values, and lock count. Test Shared append and locked record edits separately. For Vouch, retest repeated RLOCK/unlock, DBRI_LOCKED versus dbrLockList(), blocked edits, and LOGIN_A using matching client/server binaries. Record exact return codes, handles and record numbers if anything disagrees. No Vouch result is claimed here. From 08f37bd6a0e962fb41ca3f4ed05dd325ec957e49 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Wed, 30 Sep 2026 00:44:47 -0500 Subject: [PATCH 78/97] fix(packaging): include public headers in test kits (Pritpal Bedi) --- .github/workflows/release.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ead21b12..5701ee66 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -201,6 +201,9 @@ jobs: cp "$BUILD"/tools/serverd/openads_serverd "$STAGE/" cp "$BUILD"/tools/bench/openads_bench "$STAGE/" cp LICENSE NOTICE README.md openads.ini.sample "$STAGE/" + mkdir -p "$STAGE/include/openads" + cp include/openads/ace.h "$STAGE/include/ace.h" + cp include/openads/{ace,error,openads_sql,platform,version}.h "$STAGE/include/openads/" tar czvf "$STAGE.tar.gz" "$STAGE" ls -la "$STAGE.tar.gz" echo "ASSET=$STAGE.tar.gz" >> "$GITHUB_ENV" @@ -260,6 +263,9 @@ jobs: Copy-Item "$build/tools/bench/Release/openads_bench.exe" "$stage/" Copy-Item LICENSE,NOTICE,README.md,openads.ini.sample $stage/ Copy-Item QUICKSTART.md "$stage/" -ErrorAction SilentlyContinue + New-Item -ItemType Directory "$stage/include/openads" -Force | Out-Null + Copy-Item include/openads/ace.h "$stage/include/ace.h" + Copy-Item include/openads/ace.h,include/openads/error.h,include/openads/openads_sql.h,include/openads/platform.h,include/openads/version.h "$stage/include/openads/" # Harbour HB_FUNC wrappers for the oads_* VFS API — compiled # into the app, not shipped in the DLL. Arch-independent C # source: both Windows zips carry it. (Pritpal Bedi.) @@ -437,6 +443,9 @@ jobs: cp "$BUILD"/tools/serverd/openads_serverd "$STAGE/" cp "$BUILD"/tools/bench/openads_bench "$STAGE/" cp LICENSE NOTICE README.md openads.ini.sample "$STAGE/" + mkdir -p "$STAGE/include/openads" + cp include/openads/ace.h "$STAGE/include/ace.h" + cp include/openads/{ace,error,openads_sql,platform,version}.h "$STAGE/include/openads/" tar czvf "$STAGE.tar.gz" "$STAGE" ls -la "$STAGE.tar.gz" echo "ASSET=$STAGE.tar.gz" >> "$GITHUB_ENV" From 1bfe9ea62ca725d60691e5e4cca0ff672f1deb54 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898283+github-actions[bot]@users.noreply.github.com> Date: Thu, 1 Oct 2026 03:52:00 +0000 Subject: [PATCH 79/97] fix(abi): mtfix22 tag case and stock RDD natural focus Applied by apply-patch workflow, run 36812071504, started by bedipritpal. --- docs/builds/mtfix22.md | 23 ++++ src/abi/ace_exports.cpp | 116 +++++++++++++++- src/engine/table.cpp | 2 + src/engine/table.h | 7 + src/network/client.cpp | 2 + src/network/client.h | 5 + tests/CMakeLists.txt | 1 + tests/unit/abi_adi_separate_bag_test.cpp | 1 + tests/unit/abi_alternating_append_test.cpp | 3 + tests/unit/abi_index_focus_contract_test.cpp | 131 +++++++++++++++++++ tests/unit/abi_index_smoke_test.cpp | 1 + tests/unit/abi_mt_contention_test.cpp | 7 +- tests/unit/abi_navigation_test.cpp | 6 +- tests/unit/abi_ntx_multitag_test.cpp | 9 +- tests/unit/abi_remote_index_nav_test.cpp | 4 + tests/unit/abi_setorder_zero_test.cpp | 4 +- tests/unit/network_nav_batch_test.cpp | 2 + 17 files changed, 315 insertions(+), 9 deletions(-) create mode 100644 docs/builds/mtfix22.md create mode 100644 tests/unit/abi_index_focus_contract_test.cpp diff --git a/docs/builds/mtfix22.md b/docs/builds/mtfix22.md new file mode 100644 index 00000000..36183753 --- /dev/null +++ b/docs/builds/mtfix22.md @@ -0,0 +1,23 @@ +Built for Pritpal Bedi - bedipritpal/OpenADS, forked from FiveTechSoft/OpenADS. + +# v1.09.70-mtfix22 - index focus test build + +Bug report supplied by Tim Stone and relayed by Pritpal Bedi. This is a test build, not a production recommendation. Use matching client and server versions and back up binaries and data before testing. No fork-main or upstream merge is part of this build. Tim's exact ECLMST test remains the application gate. + +## Changes + +LOCAL tag lookup now compares names without regard to case, matching REMOTE. Stored/displayed spelling and numeric tag order are unchanged. This covers CDX, native ADI and ADI routed through CDX. + +Stock Harbour rddads sets order 0 or empty focus by changing its current handle to the table handle, without sending an ACE reset. Table-handle GoTop, GoBottom and Skip now stop inheriting an order activated only by index-handle navigation. Index handles remain open, and record edits/appends still maintain all open tags. Explicit AdsSetIndexOrder/ByHandle retains ordered table-handle navigation. Newly created indexes keep the existing immediate table-navigation convention until index-handle navigation takes over; the stock zero-focus path then becomes natural. + +Parking a tag retains its scope and runtime direction. REMOTE direct natural Skip reanchors at the client-visible physical record when an order reset or locally served boundary pair left the server cursor elsewhere. Boundary-pair answers remain available; the next natural Skip synchronizes before stepping. + +## Validation + +Local Linux GCC used -O0/-g0 with warnings-as-errors disabled because of existing SQL/cache warnings; shipped platform flags are not changed. Focus/order/scope/navigation checks passed 152 cases and 61,760 assertions. The CDX-format ADI focused navigation run passed 23 cases and 2,395 assertions. New regression covers stored/requested tag cases, numeric lookup, repeated implicit zero/empty-focus call sequences, direct Skip, explicit APIs, retained scopes, creation-to-index-handle transition, and append maintenance across LOCAL/REMOTE ADT/CDX. + +Full-suite chunks covered 1,605 enabled cases and 16 disabled cases. MT reader/appender, Exclusive-held OpenIndex return-code, and remote-create storm tests failed intermittently; these failure classes also reproduced on mtfix21. Some tests were corrected to select an explicit order before navigating by table handle, rather than depending on implicit index focus. The normal CTest suite passes under its existing slow/flaky exclusions. No result from Tim's actual Windows 64-bit binary or ECLMST files is claimed. + +## Application check + +Repeat Tim's report with CDX-format ECLMST.adi (adt_cdx_index=1): select eclcom by name and number, check OrdName/IndexOrd, then set order 0 and empty focus and verify the physical record walk. Repeat LOCAL and REMOTE, switch back to a tag, test scopes and append/write maintenance. Report exact handles, recnos and return codes for any mismatch. Pritpal does not use ADT and is not providing application feedback for this fix. diff --git a/src/abi/ace_exports.cpp b/src/abi/ace_exports.cpp index 47db6976..64d65f20 100644 --- a/src/abi/ace_exports.cpp +++ b/src/abi/ace_exports.cpp @@ -11596,6 +11596,7 @@ UNSIGNED32 ENTRYPOINT AdsCloseTable(ADSHANDLE hTable) { UNSIGNED32 ENTRYPOINT AdsGotoTop(ADSHANDLE hTable) { arc2_trace("AdsGotoTop"); if (auto* ri = get_remote_index(hTable)) { + if (ri->parent) ri->parent->created_order_focus = false; // Sets + teardown flush here, but NOT a deferred order switch: // that absorbs into the nav below (fused frame) when it targets // this order, or flushes plainly inside remote_index_goto_top's @@ -11667,6 +11668,15 @@ UNSIGNED32 ENTRYPOINT AdsGotoTop(ADSHANDLE hTable) { return ok(); } if (auto* rt = get_remote_table(hTable)) { + // Stock rddads sets focus 0 by switching from index to table handle, + // without a SetOrder call. Drop only an implicitly inherited order. + if (!rt->explicit_order_focus && !rt->created_order_focus && + (rt->active_index_id != 0 || + (rt->server_order_id != 0 && rt->server_order_id != openads::network::RemoteTable::kOrderUnknown) || + (rt->pending_order && rt->pending_order_id != 0))) { + if (UNSIGNED32 rc = AdsSetIndexOrderByHandle(hTable, 0); rc != 0) + return rc; + } // Sets + teardown flush here; a deferred order switch absorbs // into the nav below (fused frame) instead of going out alone. if (UNSIGNED32 frc = remote_flush_sets(rt); frc != 0) return frc; @@ -11737,6 +11747,11 @@ UNSIGNED32 ENTRYPOINT AdsGotoTop(ADSHANDLE hTable) { } Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + if (lookup_table_by_index(hTable) != nullptr) t->set_created_order_focus(false); + if (state().registry.lookup
(hTable, HandleKind::Table) != nullptr && + !t->explicit_order_focus()) { + park_active_order(t); + } auto r = t->goto_top(); if (!r) return fail(r.error()); snapshot_ri_pks(t); @@ -11747,6 +11762,7 @@ UNSIGNED32 ENTRYPOINT AdsGotoTop(ADSHANDLE hTable) { UNSIGNED32 ENTRYPOINT AdsGotoBottom(ADSHANDLE hTable) { arc2_trace("AdsGotoBottom"); if (auto* ri = get_remote_index(hTable)) { + if (ri->parent) ri->parent->created_order_focus = false; // Sets + teardown flush here, but NOT a deferred order switch: // that absorbs into the nav below (fused frame) when it targets // this order, or flushes plainly otherwise. @@ -11813,6 +11829,15 @@ UNSIGNED32 ENTRYPOINT AdsGotoBottom(ADSHANDLE hTable) { return ok(); } if (auto* rt = get_remote_table(hTable)) { + // Stock rddads sets focus 0 by switching from index to table handle, + // without a SetOrder call. Drop only an implicitly inherited order. + if (!rt->explicit_order_focus && !rt->created_order_focus && + (rt->active_index_id != 0 || + (rt->server_order_id != 0 && rt->server_order_id != openads::network::RemoteTable::kOrderUnknown) || + (rt->pending_order && rt->pending_order_id != 0))) { + if (UNSIGNED32 rc = AdsSetIndexOrderByHandle(hTable, 0); rc != 0) + return rc; + } // Sets + teardown flush here; a deferred order switch absorbs // into the nav below (fused frame) instead of going out alone. if (UNSIGNED32 frc = remote_flush_sets(rt); frc != 0) return frc; @@ -11874,6 +11899,11 @@ UNSIGNED32 ENTRYPOINT AdsGotoBottom(ADSHANDLE hTable) { } Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + if (lookup_table_by_index(hTable) != nullptr) t->set_created_order_focus(false); + if (state().registry.lookup
(hTable, HandleKind::Table) != nullptr && + !t->explicit_order_focus()) { + park_active_order(t); + } auto r = t->goto_bottom(); if (!r) return fail(r.error()); snapshot_ri_pks(t); @@ -11885,6 +11915,7 @@ UNSIGNED32 ENTRYPOINT AdsSkip(ADSHANDLE hTable, SIGNED32 lRows) { arc2_trace("AdsSkip"); seek_last_retry_latch() = false; if (auto* ri = get_remote_index(hTable)) { + if (ri->parent) ri->parent->created_order_focus = false; cli_trace_tbl(ri->parent, "AdsSkip(idx)", "rows=%d", (int)lRows); openads::network::RemoteTable* rt = ri->parent; if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; @@ -11903,7 +11934,26 @@ UNSIGNED32 ENTRYPOINT AdsSkip(ADSHANDLE hTable, SIGNED32 lRows) { return ok(); } if (auto* rt = get_remote_table(hTable)) { + // Stock rddads sets focus 0 by switching from index to table handle, + // without a SetOrder call. Drop only an implicitly inherited order. + const bool natural_transition = !rt->explicit_order_focus && !rt->created_order_focus && + (rt->active_index_id != 0 || + (rt->server_order_id != 0 && rt->server_order_id != openads::network::RemoteTable::kOrderUnknown) || + (rt->pending_order && rt->pending_order_id != 0)); + const std::uint32_t natural_anchor = rt->row_valid ? rt->current_recno : 0; + if (natural_transition) { + if (UNSIGNED32 rc = AdsSetIndexOrderByHandle(hTable, 0); rc != 0) + return rc; + } if (UNSIGNED32 frc = remote_flush_pending(rt); frc != 0) return frc; + // Cached boundary landings/read-ahead can leave the physical server + // cursor elsewhere. Clearing the order also drops their lag, so + // anchor a direct natural Skip at the client-visible physical row. + if ((natural_transition || (rt->server_order_id == 0 && rt->pair_local_position)) && + natural_anchor != 0) { + auto r = rt->conn->goto_record(rt, natural_anchor); + if (!r) return fail(r.error()); + } if (UNSIGNED32 frc = remote_close_parked_indexes(rt); frc != 0) { return frc; } @@ -11956,6 +12006,11 @@ UNSIGNED32 ENTRYPOINT AdsSkip(ADSHANDLE hTable, SIGNED32 lRows) { } Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); + if (lookup_table_by_index(hTable) != nullptr) t->set_created_order_focus(false); + if (state().registry.lookup
(hTable, HandleKind::Table) != nullptr && + !t->explicit_order_focus()) { + park_active_order(t); + } auto r = t->skip(lRows); if (!r) return fail(r.error()); snapshot_ri_pks(t); @@ -15184,6 +15239,8 @@ struct IndexBinding { std::string tag_name; std::unique_ptr parked; // nullptr when this is the active binding std::string path; // resolved index file path (M9.14) + openads::engine::Scope parked_scope{}; + std::optional parked_descending{}; }; std::unordered_map& index_bindings() { @@ -15215,6 +15272,10 @@ void park_active_order(Table* t) { auto& m = index_bindings(); auto bit = m.find(act_it->second); if (bit != m.end()) { + if (auto* o = t->order()) { + bit->second.parked_scope = o->scope(); + bit->second.parked_descending = o->descending_traverse(); + } auto taken = t->take_order(); openads::drivers::IIndex* raw = taken.get(); bit->second.parked = std::move(taken); @@ -15284,6 +15345,10 @@ openads::util::Result activate_binding(ADSHANDLE h) { if (act_it != act.end()) { auto prev = m.find(act_it->second); if (prev != m.end()) { + if (auto* o = t->order()) { + prev->second.parked_scope = o->scope(); + prev->second.parked_descending = o->descending_traverse(); + } auto taken = t->take_order(); openads::drivers::IIndex* raw = taken.get(); prev->second.parked = std::move(taken); @@ -15299,6 +15364,9 @@ openads::util::Result activate_binding(ADSHANDLE h) { openads::drivers::IIndex* raw = it->second.parked.get(); t->unregister_extra_index_view(raw); t->set_order(std::move(it->second.parked)); + t->order()->scope() = it->second.parked_scope; + if (it->second.parked_descending.has_value()) + t->order()->set_descending_traverse(*it->second.parked_descending); } act[t] = h; return {}; @@ -16611,7 +16679,30 @@ UNSIGNED32 ENTRYPOINT AdsCreateIndex61(ADSHANDLE hTable, rt->index_by_tag = std::move(keep_tags); rt->index_handles = std::move(keep_handles); } - if (rt->active_index_id == 0) rt->active_index_id = r.value(); + rt->created_order_focus = true; + // OpenIndex can replace the just-created local binding and its + // tag spelling (native ADI uses the expression field name). Use + // the fresh wire id from that bag rather than the stale create id. + std::uint32_t created_focus = r.value(); + if (refr) { + for (const auto& ent : refr.value()) { + if (ent.tag == tag || refr.value().size() == 1) { + created_focus = ent.id; + break; + } + } + } + // The returned handle must reference the re-opened bag binding too. + if (auto* created = get_remote_index(*phIndex)) created->id = created_focus; + for (auto& mapping : rt->index_by_tag) { + if (mapping.second == r.value()) mapping.second = created_focus; + } + rt->active_index_id = created_focus; + auto focus_result = rt->conn->set_order(rt->id, created_focus); + if (!focus_result) return fail(focus_result.error()); + rt->server_order_id = created_focus; + rt->pending_order = false; + rt->invalidate_prefetch(); // Structural change: the bag gained a tag, so a parked snapshot // predates it — drop the park (the maps above were rebuilt fresh // from the server). The pending flag stays: the next flush sends @@ -17314,6 +17405,10 @@ UNSIGNED32 ENTRYPOINT AdsCreateIndex61(ADSHANDLE hTable, if (prev != act.end()) { auto pit = m.find(prev->second); if (pit != m.end()) { + if (auto* o = t->order()) { + pit->second.parked_scope = o->scope(); + pit->second.parked_descending = o->descending_traverse(); + } auto displaced = t->take_order(); pit->second.parked = std::move(displaced); t->register_extra_index_view(pit->second.parked.get()); @@ -17322,6 +17417,7 @@ UNSIGNED32 ENTRYPOINT AdsCreateIndex61(ADSHANDLE hTable, t->set_order(std::move(idx_owner)); m[h] = IndexBinding{t, tag, nullptr, p.string()}; act[t] = h; + t->set_created_order_focus(true); // Clipper / Harbour: a fresh CREATE INDEX leaves the cursor // positioned at the new order's TOP key. (void)t->goto_top(); @@ -17498,6 +17594,7 @@ UNSIGNED32 ENTRYPOINT AdsCreateIndex(ADSHANDLE hTable, UNSIGNED8* pucFile, m[h] = IndexBinding{t, tag, std::move(idx), file}; t->register_extra_index_view(raw); } + t->set_created_order_focus(true); *phIndex = h; return ok(); } @@ -17886,6 +17983,8 @@ UNSIGNED32 ENTRYPOINT AdsSetIndexOrder(ADSHANDLE hTable, UNSIGNED8* pucName) { if (UNSIGNED32 frc = remote_flush_teardown(rt); frc != 0) return frc; std::string name = pucName ? openads::abi::to_internal(pucName, 0) : std::string(); + rt->explicit_order_focus = !name.empty(); + rt->created_order_focus = false; // Resolve the target locally (case-insensitive, like the mirror // block below). Unmapped names stay kOrderUnknown and always go // out on the wire: the server resolves those, and a wrong local @@ -18078,6 +18177,7 @@ UNSIGNED32 ENTRYPOINT AdsSetIndexOrder(ADSHANDLE hTable, UNSIGNED8* pucName) { // into its slot so a subsequent AdsSetIndexOrder picks the // current Table::order_ up cleanly. park_active_order(t); + t->set_explicit_order_focus(false); return ok(); } auto upper_eq = [](const std::string& a, const std::string& b) { @@ -18098,6 +18198,7 @@ UNSIGNED32 ENTRYPOINT AdsSetIndexOrder(ADSHANDLE hTable, UNSIGNED8* pucName) { if (b.table == t && upper_eq(b.tag_name, name)) { auto r = activate_binding(h); if (!r) return fail(r.error()); + t->set_explicit_order_focus(true); return ok(); } } @@ -18112,6 +18213,8 @@ UNSIGNED32 ENTRYPOINT AdsSetIndexOrderByHandle(ADSHANDLE hTable, ADSHANDLE hInde // (overwritten below — see ByName). if (UNSIGNED32 frc = remote_flush_sets(rt); frc != 0) return frc; if (UNSIGNED32 frc = remote_flush_teardown(rt); frc != 0) return frc; + rt->explicit_order_focus = hIndex != 0; + rt->created_order_focus = false; if (hIndex == 0) { // "Back to natural order" via the explicit API: send the reset // frame so the server drops its ordered_tables_ entry (it used @@ -18190,6 +18293,7 @@ UNSIGNED32 ENTRYPOINT AdsSetIndexOrderByHandle(ADSHANDLE hTable, ADSHANDLE hInde // Natural order (rddads' patched DbSetOrder(0) calls this): park // the active order back into its binding slot. park_active_order(t); + t->set_explicit_order_focus(false); return ok(); } auto& m = index_bindings(); @@ -18202,6 +18306,7 @@ UNSIGNED32 ENTRYPOINT AdsSetIndexOrderByHandle(ADSHANDLE hTable, ADSHANDLE hInde } auto r = activate_binding(hIndex); if (!r) return fail(r.error()); + t->set_explicit_order_focus(true); return ok(); } @@ -20440,7 +20545,11 @@ UNSIGNED32 ENTRYPOINT AdsGetIndexHandle(ADSHANDLE hTable, UNSIGNED8* pucName, // Storm fix: reader must hold index_bindings_mu (binds are unlocked now). std::lock_guard _rh_lk(index_bindings_mu()); for (auto& [h, b] : index_bindings()) { - if (b.table == t && b.tag_name == name) { *phIndex = h; return ok(); } + if (b.table == t && b.tag_name.size() == name.size() && + std::equal(b.tag_name.begin(), b.tag_name.end(), name.begin(), + [](unsigned char a, unsigned char z) { + return std::toupper(a) == std::toupper(z); + })) { *phIndex = h; return ok(); } } return fail(openads::AE_INTERNAL_ERROR, "index name not found"); } @@ -20688,6 +20797,7 @@ static UNSIGNED32 ads_seek_local(ADSHANDLE hIndex, UNSIGNED16* pbFound, bool find_last) { Table* t = table_for_index(hIndex); + if (t) t->set_created_order_focus(false); if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown index"); std::string key; (void)u16KeyType; @@ -20985,6 +21095,7 @@ UNSIGNED32 ENTRYPOINT AdsSeek(ADSHANDLE hIndex, } #endif if (auto* ri = get_remote_index(hIndex)) { + if (ri->parent) ri->parent->created_order_focus = false; std::string key(reinterpret_cast(pucKey), u16KeyLen); if (ri->parent) { @@ -21176,6 +21287,7 @@ UNSIGNED32 ENTRYPOINT AdsSeekLast(ADSHANDLE hIndex, } #endif if (auto* ri = get_remote_index(hIndex)) { + if (ri->parent) ri->parent->created_order_focus = false; std::string key(reinterpret_cast(pucKey), u16KeyLen); if (ri->parent) { diff --git a/src/engine/table.cpp b/src/engine/table.cpp index c2d898c5..ed0689ba 100644 --- a/src/engine/table.cpp +++ b/src/engine/table.cpp @@ -2051,6 +2051,8 @@ void Table::set_order(std::unique_ptr idx) { } void Table::clear_order() { + explicit_order_focus_ = false; + created_order_focus_ = false; order_.reset(); } diff --git a/src/engine/table.h b/src/engine/table.h index 25b98adc..aa174765 100644 --- a/src/engine/table.h +++ b/src/engine/table.h @@ -551,6 +551,11 @@ class Table { // Order + scope surface (M3). void set_order(std::unique_ptr idx); void clear_order(); + // Explicit ACE focus permits ordered table-handle navigation. Merely + // navigating an index handle must not change the table-handle contract. + bool explicit_order_focus() const noexcept { return explicit_order_focus_ || created_order_focus_; } + void set_explicit_order_focus(bool focused) noexcept { explicit_order_focus_ = focused; created_order_focus_ = false; } + void set_created_order_focus(bool focused) noexcept { created_order_focus_ = focused; } // Take ownership of the active index back from the Table, leaving // it without an order. Returns nullptr if no order was set. std::unique_ptr take_order(); @@ -676,6 +681,8 @@ class Table { std::unordered_map recno_locks_; std::optional table_lock_; std::optional order_; + bool explicit_order_focus_ = false; + bool created_order_focus_ = false; std::vector extra_index_views_; State state_ = State::Bof; std::uint32_t recno_ = 0; diff --git a/src/network/client.cpp b/src/network/client.cpp index ba59098d..2b81a082 100644 --- a/src/network/client.cpp +++ b/src/network/client.cpp @@ -161,6 +161,7 @@ std::size_t parse_row_trailer_into(RemoteTable* rt, // M12.21 option C — every nav ack re-anchors the server cursor to the // client's logical position, so the lag resets to zero. rt->cursor_lag = 0; + rt->pair_local_position = false; rt->prefetch_dir = 0; std::uint8_t has_row = pl[pos++]; if (has_row == 0) { @@ -848,6 +849,7 @@ util::Result RemoteConnection::apply_pair_blob(RemoteTable* rt) { // then the certified bound values land through the shared trailer // application so every bound/recno/count stamp matches. parse_row_trailer_into(rt, rt->pair_blob, 0); + rt->pair_local_position = true; apply_bound_trailer(rt, rt->pair_bof, rt->pair_eof, rt->pair_recno, rt->pair_reccount, rt->pair_has_count); if (rt->pair_has_keycount) { diff --git a/src/network/client.h b/src/network/client.h index 14181ec8..6167a117 100644 --- a/src/network/client.h +++ b/src/network/client.h @@ -699,6 +699,11 @@ class RemoteConnection { // Per-handle wrapper for a remote table. Stores back-pointer to // the connection plus the server-side table id. struct RemoteTable { + // Only an explicit SetIndexOrder opts table-handle navigation into an order. + bool explicit_order_focus = false; + bool created_order_focus = false; + // A boundary blob changed the logical cursor without moving the server. + bool pair_local_position = false; RemoteConnection* conn = nullptr; std::uint32_t id = 0; // True when this table was counted in conn->deferred_open_tables at diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index c4393cdf..af73bc9b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -209,6 +209,7 @@ add_executable(openads_unit_tests unit/abi_remote_index_reopen_test.cpp unit/abi_remote_zombie_lock_test.cpp unit/network_skip_depth_test.cpp + unit/abi_index_focus_contract_test.cpp unit/abi_remote_index_nav_test.cpp unit/abi_remote_date_index_scope_test.cpp unit/abi_remote_prodcdx_test.cpp diff --git a/tests/unit/abi_adi_separate_bag_test.cpp b/tests/unit/abi_adi_separate_bag_test.cpp index 7446834a..4f19e85b 100644 --- a/tests/unit/abi_adi_separate_bag_test.cpp +++ b/tests/unit/abi_adi_separate_bag_test.cpp @@ -133,6 +133,7 @@ TEST_CASE("ADI separate (non-structural) bag on ADT: reopen and navigate") { ADSHANDLE ah[8] = {0}; UNSIGNED16 n = 8; REQUIRE(AdsOpenIndex(hTable2, idxfile, ah, &n) == AE_SUCCESS); + REQUIRE(AdsSetIndexOrderByHandle(hTable2, ah[0]) == AE_SUCCESS); REQUIRE(AdsGotoTop(hTable2) == AE_SUCCESS); std::vector seen; diff --git a/tests/unit/abi_alternating_append_test.cpp b/tests/unit/abi_alternating_append_test.cpp index a9f35ef3..8bd2347a 100644 --- a/tests/unit/abi_alternating_append_test.cpp +++ b/tests/unit/abi_alternating_append_test.cpp @@ -383,6 +383,9 @@ TEST_CASE("Editing an indexed field moves the key and keeps order with duplicate UNSIGNED16 nidx = 8; REQUIRE(AdsOpenIndex(hTbl, (UNSIGNED8*)"big.cdx", idxs, &nidx) == 0); + // This test walks via the table handle after an index seek; opt in + // explicitly. Stock rddads uses the index handle until focus is zero. + REQUIRE(AdsSetIndexOrderByHandle(hTbl, idxs[0]) == 0); // "Edward" now has 3 keys; "Zzztop" exactly 1, at the end of the order. UNSIGNED16 found = 0; REQUIRE(AdsSeek(idxs[0], (UNSIGNED8*)"Edward", 6, ADS_STRINGKEY, diff --git a/tests/unit/abi_index_focus_contract_test.cpp b/tests/unit/abi_index_focus_contract_test.cpp new file mode 100644 index 00000000..f846adf2 --- /dev/null +++ b/tests/unit/abi_index_focus_contract_test.cpp @@ -0,0 +1,131 @@ +// Harbour rddads focus contract. Bug report relayed by Pritpal Bedi. +// rddads uppercases name lookup; focus 0/"" only changes its hOrdCurrent. +#include "doctest.h" +#include "openads/ace.h" +#include "network/server.h" +#include +#include +#include +#include +namespace { +UNSIGNED8* focus_bytes(std::string& s) { return reinterpret_cast(s.data()); } +void focus_expect_rec(ADSHANDLE t, UNSIGNED32 n) { + UNSIGNED32 got = 0; + REQUIRE(AdsGetRecordNum(t, ADS_IGNOREFILTERS, &got) == 0); + INFO("expected rec=" << n << " table=" << t); + CHECK(got == n); +} +void focus_fixture(const std::filesystem::path& dir, UNSIGNED16 type) { + std::filesystem::create_directories(dir); + std::string path = dir.string(); ADSHANDLE c = 0, t = 0; + REQUIRE(AdsConnect60(focus_bytes(path), ADS_LOCAL_SERVER, nullptr, nullptr, 0, &c) == 0); + std::string name = type == ADS_ADT ? "focus.adt" : "focus.dbf"; + std::string defs = "lower,Character,8;UPPER,Character,8"; + REQUIRE(AdsCreateTable(c, focus_bytes(name), nullptr, type, ADS_ANSI, + ADS_PROPRIETARY_LOCKING, 0, 0, focus_bytes(defs), &t) == 0); + for (const char* value : {"D", "B", "A", "C"}) { + REQUIRE(AdsAppendRecord(t) == 0); + std::string k = "lower", k2 = "UPPER", v = value; + REQUIRE(AdsSetString(t, focus_bytes(k), focus_bytes(v), 1) == 0); + REQUIRE(AdsSetString(t, focus_bytes(k2), focus_bytes(v), 1) == 0); + REQUIRE(AdsWriteRecord(t) == 0); + } + std::string bag = (dir / (type == ADS_ADT ? "focus.adi" : "focus.cdx")).string(); + for (const char* tag : {"lower", "UPPER"}) { + std::string tagname = tag, expr = tag; ADSHANDLE h = 0; + REQUIRE(AdsCreateIndex61(t, focus_bytes(bag), focus_bytes(tagname), + focus_bytes(expr), nullptr, nullptr, ADS_COMPOUND, 512, &h) == 0); + } + REQUIRE(AdsCloseTable(t) == 0); + REQUIRE(AdsDisconnect(c) == 0); +} +void focus_check(const std::filesystem::path& dir, UNSIGNED16 type, bool remote) { + INFO("type=" << type << " remote=" << remote); + openads::network::Server srv; + std::string path = dir.string(); + if (remote) { + REQUIRE(srv.start("127.0.0.1", 0).has_value()); + path = "tcp://127.0.0.1:" + std::to_string(srv.port()) + "/" + path; + } + ADSHANDLE c = 0, t = 0; + REQUIRE(AdsConnect60(focus_bytes(path), remote ? ADS_REMOTE_SERVER : ADS_LOCAL_SERVER, + nullptr, nullptr, 0, &c) == 0); + std::string name = type == ADS_ADT ? "focus.adt" : "focus.dbf"; + REQUIRE(AdsOpenTable(c, focus_bytes(name), nullptr, type, ADS_ANSI, + ADS_PROPRIETARY_LOCKING, 0, ADS_SHARED, &t) == 0); + REQUIRE(AdsGotoTop(t) == 0); focus_expect_rec(t, 1); + ADSHANDLE first = 0, second = 0; + REQUIRE(AdsGetIndexHandleByOrder(t, 1, &first) == 0); + REQUIRE(AdsGetIndexHandleByOrder(t, 2, &second) == 0); + for (const char* tag : {"lower", "LOWER", "LoWeR ", "upper", "UPPER"}) { + std::string wanted = tag; ADSHANDLE h = 0; + REQUIRE(AdsGetIndexHandle(t, focus_bytes(wanted), &h) == 0); + CHECK(h == (wanted[0] == 'u' || wanted[0] == 'U' ? second : first)); + } + // Exactly stock rddads' zero and empty-name sequence: no reset API call. + for (int repeat = 0; repeat < 2; ++repeat) { + { INFO("index top second"); REQUIRE(AdsGotoTop(second) == 0); focus_expect_rec(t, 3); } + REQUIRE(AdsSkip(second, 1) == 0); focus_expect_rec(t, 2); + REQUIRE(AdsGotoTop(t) == 0); focus_expect_rec(t, 1); + REQUIRE(AdsSkip(t, 1) == 0); focus_expect_rec(t, 2); + REQUIRE(AdsGotoBottom(t) == 0); focus_expect_rec(t, 4); + { INFO("natural backwards skip"); REQUIRE(AdsSkip(t, -1) == 0); focus_expect_rec(t, 3); } + // Direct Skip must restore natural order without a preceding GoTop. + { INFO("index top first"); REQUIRE(AdsGotoTop(first) == 0); focus_expect_rec(t, 3); } + { INFO("direct natural skip"); REQUIRE(AdsSkip(t, 1) == 0); focus_expect_rec(t, 4); } + } + // Retained scopes must not keep natural focus indexed. + std::string scope = "B"; + REQUIRE(AdsSetScope(first, ADS_TOP, focus_bytes(scope), 1, ADS_STRINGKEY) == 0); + REQUIRE(AdsGotoTop(first) == 0); focus_expect_rec(t, 2); + REQUIRE(AdsGotoTop(t) == 0); focus_expect_rec(t, 1); + REQUIRE(AdsGotoTop(first) == 0); focus_expect_rec(t, 2); + REQUIRE(AdsClearScope(first, ADS_TOP) == 0); + // Explicit focus APIs must still opt table-handle calls into index order. + REQUIRE(AdsSetIndexOrderByHandle(t, second) == 0); + REQUIRE(AdsGotoTop(t) == 0); focus_expect_rec(t, 3); + REQUIRE(AdsSkip(t, 1) == 0); focus_expect_rec(t, 2); + for (int repeat = 0; repeat < 2; ++repeat) { + REQUIRE(AdsSetIndexOrderByHandle(t, 0) == 0); + REQUIRE(AdsGotoTop(t) == 0); focus_expect_rec(t, 1); + } + std::string tag = "LOWER", empty; + REQUIRE(AdsSetIndexOrder(t, focus_bytes(tag)) == 0); + REQUIRE(AdsGotoBottom(t) == 0); focus_expect_rec(t, 1); + REQUIRE(AdsSetIndexOrder(t, focus_bytes(empty)) == 0); + REQUIRE(AdsGotoBottom(t) == 0); focus_expect_rec(t, 4); + // A newly created tag supports historical table-handle navigation, + // then stock rddads index navigation makes table calls natural again. + std::string extra = "EXTRA", expr = "lower"; ADSHANDLE fresh = 0; + std::string bag = (dir / (type == ADS_ADT ? "new.adi" : "new.cdx")).string(); + REQUIRE(AdsCreateIndex61(t, focus_bytes(bag), focus_bytes(extra), + focus_bytes(expr), nullptr, nullptr, ADS_COMPOUND, 512, &fresh) == 0); + REQUIRE(AdsGotoTop(t) == 0); focus_expect_rec(t, 3); + REQUIRE(AdsGotoTop(fresh) == 0); focus_expect_rec(t, 3); + REQUIRE(AdsGotoTop(t) == 0); focus_expect_rec(t, 1); + // Natural focus must leave every tag open, and all keys maintained. + UNSIGNED16 count = 0; REQUIRE(AdsGetNumIndexes(t, &count) == 0); CHECK(count == 3); + REQUIRE(AdsAppendRecord(t) == 0); + std::string key = "lower", key2 = "UPPER", v = "0"; + REQUIRE(AdsSetString(t, focus_bytes(key), focus_bytes(v), 1) == 0); + REQUIRE(AdsSetString(t, focus_bytes(key2), focus_bytes(v), 1) == 0); + REQUIRE(AdsWriteRecord(t) == 0); + REQUIRE(AdsGotoTop(first) == 0); focus_expect_rec(t, 5); + REQUIRE(AdsGotoTop(second) == 0); focus_expect_rec(t, 5); + REQUIRE(AdsCloseTable(t) == 0); REQUIRE(AdsDisconnect(c) == 0); + if (remote) srv.stop(); +} +} +TEST_CASE("RDD focus: case-insensitive tags and implicit index to natural navigation") { + // Default native ADI and DBF/CDX. Rerouted ADI is exercised by the + // companion invocation with OPENADS_ADT_CDX_INDEX=1 (cached configuration). + for (UNSIGNED16 type : {static_cast(ADS_ADT), static_cast(ADS_CDX)}) { + for (bool remote : {false, true}) { + auto dir = std::filesystem::temp_directory_path() / + ("openads_focus_contract_" + std::to_string(type) + (remote ? "_remote" : "_local")); + std::error_code ec; std::filesystem::remove_all(dir, ec); + focus_fixture(dir, type); focus_check(dir, type, remote); + std::filesystem::remove_all(dir, ec); + } + } +} diff --git a/tests/unit/abi_index_smoke_test.cpp b/tests/unit/abi_index_smoke_test.cpp index d3c4d43e..5d55923e 100644 --- a/tests/unit/abi_index_smoke_test.cpp +++ b/tests/unit/abi_index_smoke_test.cpp @@ -97,6 +97,7 @@ TEST_CASE("ABI index smoke: create NTX, seek, walk in order, scope") { REQUIRE(AdsGetRecordNum(hTable, 0, &recno) == 0); CHECK(recno == 3); + REQUIRE(AdsSetIndexOrderByHandle(hTable, hIndex) == 0); // Scope: top = BBBB, bottom = CCCC -> only recno 3 then recno 1. UNSIGNED8 stop[8] = "BBBB"; UNSIGNED8 sbot[8] = "CCCC"; diff --git a/tests/unit/abi_mt_contention_test.cpp b/tests/unit/abi_mt_contention_test.cpp index 11f5e788..47ea2d84 100644 --- a/tests/unit/abi_mt_contention_test.cpp +++ b/tests/unit/abi_mt_contention_test.cpp @@ -353,8 +353,13 @@ TEST_CASE("MT: readers always see a consistent walk while writers append [flaky] ADS_CHECKRIGHTS, ADS_SHARED, &hTbl) != 0) { continue; } + UNSIGNED16 capacity = 1; if (AdsOpenIndex(hTbl, (UNSIGNED8*)"mt.cdx", &hIdx, - nullptr) != 0) { + &capacity) != 0) { + AdsCloseTable(hTbl); + continue; + } + if (AdsSetIndexOrderByHandle(hTbl, hIdx) != 0) { AdsCloseTable(hTbl); continue; } diff --git a/tests/unit/abi_navigation_test.cpp b/tests/unit/abi_navigation_test.cpp index 9d18b558..37df2597 100644 --- a/tests/unit/abi_navigation_test.cpp +++ b/tests/unit/abi_navigation_test.cpp @@ -262,13 +262,15 @@ TEST_CASE("Nav structural CDX auto-open keeps natural order") { REQUIRE(AdsCloseTable(hT) == 0); } -TEST_CASE("Nav explicit AdsOpenIndex activates the first tag") { +TEST_CASE("Nav explicit focus after AdsOpenIndex walks the first tag") { NavConn c("openads_nav_explidx"); make_zulu5(c.hConn, "nave.dbf", "nave.cdx"); ADSHANDLE hT = open_table(c.hConn, "nave.dbf"); ADSHANDLE hI = 0; - REQUIRE(AdsOpenIndex(hT, (UNSIGNED8*)"nave.cdx", &hI, nullptr) == 0); + UNSIGNED16 capacity = 1; + REQUIRE(AdsOpenIndex(hT, (UNSIGNED8*)"nave.cdx", &hI, &capacity) == 0); + REQUIRE(AdsSetIndexOrderByHandle(hT, hI) == 0); REQUIRE(AdsGotoTop(hT) == 0); check_pos(hT, 2, 0, 0, "gotop (alpha, first key)"); REQUIRE(AdsGotoBottom(hT) == 0); diff --git a/tests/unit/abi_ntx_multitag_test.cpp b/tests/unit/abi_ntx_multitag_test.cpp index 56803fb5..ee33212a 100644 --- a/tests/unit/abi_ntx_multitag_test.cpp +++ b/tests/unit/abi_ntx_multitag_test.cpp @@ -123,7 +123,8 @@ TEST_CASE("M9.14 NTX multi-tag binding: two .ntx files coexist on one table") { REQUIRE(AdsGetRecordNum(hTable, 0, &recno) == 0); CHECK(recno == 2); - // After SEQ activated, GotoTop now walks SEQ order → "01"(rec2). + REQUIRE(AdsSetIndexOrderByHandle(hTable, h_seq) == 0); + // After explicit SEQ focus, GotoTop walks SEQ order → "01"(rec2). REQUIRE(AdsGotoTop(hTable) == 0); REQUIRE(AdsGetRecordNum(hTable, 0, &recno) == 0); CHECK(recno == 2); // SEQ "01" is rec2 @@ -150,7 +151,8 @@ TEST_CASE("M9.14 NTX multi-tag binding: two .ntx files coexist on one table") { REQUIRE(AdsGetRecordNum(hTable, 0, &recno) == 0); CHECK(recno == 4); - // GotoTop on TAGORD → AAAA(rec2) again. + REQUIRE(AdsSetIndexOrderByHandle(hTable, h_first) == 0); + // GotoTop on TAGORD -> AAAA(rec2) again. REQUIRE(AdsGotoTop(hTable) == 0); REQUIRE(AdsGetRecordNum(hTable, 0, &recno) == 0); CHECK(recno == 2); @@ -216,7 +218,8 @@ TEST_CASE("M9.14 NTX multi-tag: AdsOpenIndex re-binds two pre-existing files") { REQUIRE(AdsGetNumIndexes(hTable, &nidx) == 0); CHECK(nidx == 2); - // First-opened binding is the active order: TAG(AAAA) is rec2. + REQUIRE(AdsSetIndexOrderByHandle(hTable, arr1[0]) == 0); + // Explicit focus on first binding: TAG(AAAA) is rec2. REQUIRE(AdsGotoTop(hTable) == 0); UNSIGNED32 recno = 0; REQUIRE(AdsGetRecordNum(hTable, 0, &recno) == 0); diff --git a/tests/unit/abi_remote_index_nav_test.cpp b/tests/unit/abi_remote_index_nav_test.cpp index c7ced1c6..44bee286 100644 --- a/tests/unit/abi_remote_index_nav_test.cpp +++ b/tests/unit/abi_remote_index_nav_test.cpp @@ -914,6 +914,10 @@ TEST_CASE("remote AdsSetScope constrains GotoTop/Skip walk") { }; CHECK(visible_count(hOrd) == 3); + // A table handle after implicit index focus is natural, even while + // that index retains scopes. Explicit focus opts table navigation in. + CHECK(visible_count(hRT) == 4); + REQUIRE(AdsSetIndexOrderByHandle(hRT, hOrd) == 0); CHECK(visible_count(hRT) == 3); REQUIRE(AdsClearScope(hOrd, ADS_TOP) == 0); diff --git a/tests/unit/abi_setorder_zero_test.cpp b/tests/unit/abi_setorder_zero_test.cpp index 16812e06..40f37ed1 100644 --- a/tests/unit/abi_setorder_zero_test.cpp +++ b/tests/unit/abi_setorder_zero_test.cpp @@ -88,7 +88,9 @@ void exercise_setorder_zero(ADSHANDLE hConn) { REQUIRE(AdsGetIndexHandleByOrder(hT, 1, &hOrd) == 0); REQUIRE(hOrd != 0); - // Activate the order via index-handle navigation (the rddads pattern). + // Explicit focus opts table-handle navigation into indexed order. + // Stock rddads implicit focus is covered by abi_index_focus_contract_test. + REQUIRE(AdsSetIndexOrderByHandle(hT, hOrd) == 0); REQUIRE(AdsGotoTop(hOrd) == 0); CHECK(walk_recnos(hT) == std::vector( std::begin(kIndexed), std::end(kIndexed))); diff --git a/tests/unit/network_nav_batch_test.cpp b/tests/unit/network_nav_batch_test.cpp index 1ed28287..5b6a9e69 100644 --- a/tests/unit/network_nav_batch_test.cpp +++ b/tests/unit/network_nav_batch_test.cpp @@ -297,6 +297,8 @@ TEST_CASE("Nav batching: order context defeats duplicate suppression") { nb_w REQUIRE(AdsGotoTop(hOrd) == AE_SUCCESS); CHECK(nb_op(kOpGotoTop) == top0 + 1); + // Explicit focus keeps table-handle navigation indexed. + REQUIRE(AdsSetIndexOrderByHandle(hTable, hOrd) == AE_SUCCESS); // Table-handle top with the order still bound: same position, // suppressed as well. REQUIRE(AdsGotoTop(hTable) == AE_SUCCESS); From de3c80ef67aa649638d9a573c67d6c5bbe14ddfa Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Wed, 30 Sep 2026 22:57:57 -0500 Subject: [PATCH 80/97] Create release-notes-1.09.70-mtfix22.md --- release-notes-1.09.70-mtfix22.md | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) create mode 100644 release-notes-1.09.70-mtfix22.md diff --git a/release-notes-1.09.70-mtfix22.md b/release-notes-1.09.70-mtfix22.md new file mode 100644 index 00000000..ff9329e4 --- /dev/null +++ b/release-notes-1.09.70-mtfix22.md @@ -0,0 +1,32 @@ +Built for Pritpal Bedi - bedipritpal/OpenADS, forked from FiveTechSoft/OpenADS. + +# v1.09.70-mtfix22 - index focus test build + +Bug report supplied by Tim Stone and relayed by Pritpal Bedi. This is a test build, not a production recommendation. Use matching client and server versions and back up binaries and data before testing. No fork-main or upstream merge is part of this build. Tim's exact ECLMST test remains the application gate. + +## Changes + +LOCAL tag lookup now compares names without regard to case, matching REMOTE. Stored/displayed spelling and numeric tag order are unchanged. This covers CDX, native ADI and ADI routed through CDX. + +Stock Harbour rddads sets order 0 or empty focus by changing its current handle to the table handle, without sending an ACE reset. Table-handle GoTop, GoBottom and Skip now stop inheriting an order activated only by index-handle navigation. Index handles remain open, and record edits/appends still maintain all open tags. Explicit AdsSetIndexOrder/ByHandle retains ordered table-handle navigation. Newly created indexes keep the existing immediate table-navigation convention until index-handle navigation takes over; the stock zero-focus path then becomes natural. + +Parking a tag retains its scope and runtime direction. REMOTE direct natural Skip reanchors at the client-visible physical record when an order reset or locally served boundary pair left the server cursor elsewhere. Boundary-pair answers remain available; the next natural Skip synchronizes before stepping. + +## Validation + +Local Linux GCC used -O0/-g0 with warnings-as-errors disabled because of existing SQL/cache warnings; shipped platform flags are not changed. Focus/order/scope/navigation checks passed 152 cases and 61,760 assertions. The CDX-format ADI focused navigation run passed 23 cases and 2,395 assertions. New regression covers stored/requested tag cases, numeric lookup, repeated implicit zero/empty-focus call sequences, direct Skip, explicit APIs, retained scopes, creation-to-index-handle transition, and append maintenance across LOCAL/REMOTE ADT/CDX. + +Full-suite chunks covered 1,605 enabled cases and 16 disabled cases. MT reader/appender, Exclusive-held OpenIndex return-code, and remote-create storm tests failed intermittently; these failure classes also reproduced on mtfix21. Some tests were corrected to select an explicit order before navigating by table handle, rather than depending on implicit index focus. The normal CTest suite passes under its existing slow/flaky exclusions. No result from Tim's actual Windows 64-bit binary or ECLMST files is claimed. + +## Application check + +Repeat Tim's report with CDX-format ECLMST.adi (adt_cdx_index=1): select eclcom by name and number, check OrdName/IndexOrd, then set order 0 and empty focus and verify the physical record walk. Repeat LOCAL and REMOTE, switch back to a tag, test scopes and append/write maintenance. Report exact handles, recnos and return codes for any mismatch. Pritpal does not use ADT and is not providing application feedback for this fix. + +## Automated build status + +The guarded patch run passed Linux Clang build/test with and without TLS before committing the working-branch change. Platform CI and release packaging must complete before this build is offered for testing. macOS unit tests are not claimed unless their job completes. + +Apply-patch: https://github.com/bedipritpal/OpenADS/actions/runs/36812071504 +CI: https://github.com/bedipritpal/OpenADS/actions/runs/36812469507 + +Packages must include Windows x64/x86, Linux x64, and macOS universal archives. Use the full client/server kit for the chosen platform; do not mix old and new ACE libraries. From c3d1f40d53df2ef372183a3ff67187a3932ca29e Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Wed, 30 Sep 2026 23:53:35 -0500 Subject: [PATCH 81/97] Update release-notes-1.09.70-mtfix22.md --- release-notes-1.09.70-mtfix22.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/release-notes-1.09.70-mtfix22.md b/release-notes-1.09.70-mtfix22.md index ff9329e4..c7751338 100644 --- a/release-notes-1.09.70-mtfix22.md +++ b/release-notes-1.09.70-mtfix22.md @@ -24,7 +24,9 @@ Repeat Tim's report with CDX-format ECLMST.adi (adt_cdx_index=1): select eclcom ## Automated build status -The guarded patch run passed Linux Clang build/test with and without TLS before committing the working-branch change. Platform CI and release packaging must complete before this build is offered for testing. macOS unit tests are not claimed unless their job completes. +The guarded patch run passed Linux Clang build/test with and without TLS before committing the working-branch change. CI #51 passed all 10 non-macOS jobs: Windows x64/x86, Linux Clang with and without TLS, Harbour smoke (Windows), PHP binding, and four SQL jobs. + +macOS unit validation is incomplete. Its Configure and Build steps passed, but the Test step reached the runner's 30-minute job cap and was canceled after printing only "Start 1: openads_unit_tests". No source assertion failure was shown. Investigation is continuing. This limitation does not count as a macOS unit-test pass. Release packaging still requires every canonical archive and successful platform builds before publication. Apply-patch: https://github.com/bedipritpal/OpenADS/actions/runs/36812071504 CI: https://github.com/bedipritpal/OpenADS/actions/runs/36812469507 From fa0bf40ef96c058a8f957da5efc3440b819ac437 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Wed, 30 Sep 2026 23:56:26 -0500 Subject: [PATCH 82/97] ci: stage mtfix drafts as prerelease and not latest --- .github/workflows/release.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 5701ee66..f8dbd33b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -593,6 +593,10 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.tag.outputs.tag }} + # Test builds stay private until packages and notes are verified. + draft: ${{ contains(steps.tag.outputs.tag, '-mtfix') }} + prerelease: ${{ contains(steps.tag.outputs.tag, '-mtfix') }} + make_latest: ${{ contains(steps.tag.outputs.tag, '-mtfix') && 'false' || 'legacy' }} body_path: ${{ steps.notes.outputs.path }} files: out/* From a5fa4f60b4f938506567fc57ff7ebb253ccce816 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Wed, 30 Sep 2026 23:59:37 -0500 Subject: [PATCH 83/97] ci: stage test packages from source SHA before publishing tag --- .github/workflows/release.yml | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index f8dbd33b..44c2c639 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,6 +9,9 @@ on: tag: description: "tag to package (e.g. v1.0.0-rc1)" required: true + source_ref: + description: "source commit for a new test-build tag (optional)" + required: false permissions: contents: write @@ -55,7 +58,18 @@ jobs: steps: - uses: actions/checkout@v5 with: - ref: ${{ inputs.tag || github.ref }} + ref: ${{ inputs.source_ref || inputs.tag || github.ref }} + + - name: Set local test-build version tag + if: ${{ inputs.source_ref != '' }} + env: + TEST_TAG: ${{ inputs.tag }} + shell: bash + run: | + if ! [[ "$TEST_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-mtfix[0-9]+$ ]]; then + echo "::error::source_ref is only for mtfix test builds"; exit 1 + fi + git tag "$TEST_TAG" HEAD - name: Resolve tag id: tag @@ -313,7 +327,7 @@ jobs: - uses: actions/checkout@v5 with: - ref: ${{ inputs.tag || github.ref }} + ref: ${{ inputs.source_ref || inputs.tag || github.ref }} # Full history + tags: the version stamp falls back to # `git describe --tags`, and a shallow tag checkout can leave # the tag unresolvable to the container's older git. @@ -593,6 +607,7 @@ jobs: uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.tag.outputs.tag }} + target_commitish: ${{ inputs.source_ref || github.sha }} # Test builds stay private until packages and notes are verified. draft: ${{ contains(steps.tag.outputs.tag, '-mtfix') }} prerelease: ${{ contains(steps.tag.outputs.tag, '-mtfix') }} From 7e940f659646e0244073982daa6c54b101881dd6 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Thu, 1 Oct 2026 00:57:21 -0500 Subject: [PATCH 84/97] ci: verify final archive members and version before draft release --- .github/workflows/release.yml | 42 +++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 44c2c639..dd8eec27 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -548,6 +548,48 @@ jobs: # tarball because publish ran on 3 of 4 legs). macOS keeps its # stall-tolerant test step, but a real macOS build break still # blocks here via the job conclusion. + - name: Open every archive and verify contents and version + env: + RELEASE_VERSION: ${{ steps.ver.outputs.version }} + shell: bash + run: | + python3 - <<'PYVERIFY' + import os, pathlib, tarfile, zipfile, hashlib + version = os.environ['RELEASE_VERSION'] + assets = list(pathlib.Path('out').glob('*')) + assert assets, 'No archives' + for path in assets: + if path.suffix == '.zip': + with zipfile.ZipFile(path) as z: + assert z.testzip() is None, 'ZIP CRC failure' + files = {n: z.read(n) for n in z.namelist() if not n.endswith('/')} + else: + with tarfile.open(path) as t: + files = {m.name: t.extractfile(m).read() for m in t.getmembers() if m.isfile()} + byname = {pathlib.PurePosixPath(n).name: data for n, data in files.items()} + required = ['LICENSE','NOTICE','README.md','openads.ini.sample'] + windows = '-windows-' in path.name + if windows: + bits = '64' if '-x64.' in path.name else '32' + required += [f'ace{bits}.dll',f'openace{bits}.dll',f'ace{bits}.lib',f'openace{bits}.lib',f'libopenace{bits}.a','openads_serverd.exe','openads_bench.exe','oads_hb.c'] + assert byname[f'ace{bits}.dll'] == byname[f'openace{bits}.dll'], 'ACE aliases differ' + binary = byname['openads_serverd.exe'] + else: + ext = 'dylib' if '-macos-' in path.name else 'so' + required += [f'libace64.{ext}',f'libopenace64.{ext}','openads_serverd','openads_bench'] + assert byname[f'libace64.{ext}'] == byname[f'libopenace64.{ext}'], 'ACE aliases differ' + binary = byname['openads_serverd'] + for name in required: + assert name in byname and byname[name], f'{path}: missing/empty {name}' + for header in ['ace','error','openads_sql','platform','version']: + assert any(n.endswith(f'include/openads/{header}.h') for n in files), f'Missing header {header}' + assert version.encode() in binary, f'{path}: server version stamp absent' + assert version.encode() in byname[('openace'+bits+'.dll') if windows else ('libopenace64.'+ext)], f'{path}: ACE version stamp absent' + if '-macos-' in path.name: + assert binary[:4] in [bytes.fromhex('cafebabe'),bytes.fromhex('cafebabf')], 'Not a universal server' + print(f'VERIFIED {path.name}: {len(files)} files; version {version}; sha256 {hashlib.sha256(path.read_bytes()).hexdigest()}', flush=True) + PYVERIFY + - name: Require all legs green and all assets present env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} From 4ace98f057c9829f172fc44a4f4cc77e6cd52ed0 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Thu, 1 Oct 2026 00:57:57 -0500 Subject: [PATCH 85/97] Update release-notes-1.09.70-mtfix22.md --- release-notes-1.09.70-mtfix22.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/release-notes-1.09.70-mtfix22.md b/release-notes-1.09.70-mtfix22.md index c7751338..f90f34fd 100644 --- a/release-notes-1.09.70-mtfix22.md +++ b/release-notes-1.09.70-mtfix22.md @@ -24,9 +24,9 @@ Repeat Tim's report with CDX-format ECLMST.adi (adt_cdx_index=1): select eclcom ## Automated build status -The guarded patch run passed Linux Clang build/test with and without TLS before committing the working-branch change. CI #51 passed all 10 non-macOS jobs: Windows x64/x86, Linux Clang with and without TLS, Harbour smoke (Windows), PHP binding, and four SQL jobs. +The guarded patch run passed Linux Clang build/test with and without TLS before committing the working-branch change. [fork CI run 51](https://github.com/bedipritpal/OpenADS/actions/runs/36812469507) passed all 10 non-macOS jobs: Windows x64/x86, Linux Clang with and without TLS, Harbour smoke (Windows), PHP binding, and four SQL jobs. -macOS unit validation is incomplete. Its Configure and Build steps passed, but the Test step reached the runner's 30-minute job cap and was canceled after printing only "Start 1: openads_unit_tests". No source assertion failure was shown. Investigation is continuing. This limitation does not count as a macOS unit-test pass. Release packaging still requires every canonical archive and successful platform builds before publication. +macOS unit validation is incomplete. Its Configure and Build steps passed, but the Test step reached the runner's 30-minute job cap and was canceled after printing only "Start 1: openads_unit_tests". No source assertion failure was shown. Investigation is continuing. This limitation does not count as a macOS unit-test pass. Packaging run 33 built every canonical archive successfully. Its macOS diagnostic Test step also timed out at the existing 20-minute step cap. The repeated timeout still does not count as a macOS unit-test pass. Final archives are opened and checked for required files, matching ACE aliases and the mtfix22 version stamp before publication. Apply-patch: https://github.com/bedipritpal/OpenADS/actions/runs/36812071504 CI: https://github.com/bedipritpal/OpenADS/actions/runs/36812469507 From cc5b792d76d8b6d2b04bea8c2b7e75b3e7b1fcff Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Thu, 1 Oct 2026 07:53:00 -0500 Subject: [PATCH 86/97] ci(release): keep reviewed public mtfix archives immutable --- .github/workflows/release.yml | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd8eec27..9070cb16 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -18,6 +18,8 @@ permissions: jobs: build: + # Published mtfix assets are the reviewed bytes, never rebuild on tag push. + if: ${{ github.event_name == 'workflow_dispatch' || !contains(github.ref, '-mtfix') }} name: ${{ matrix.os }} / ${{ matrix.arch }} runs-on: ${{ matrix.os }} # 90: a cold Harbour SDK build (Linux leg) plus the full tree can @@ -495,7 +497,7 @@ jobs: needs: build # A cancelled/failed best-effort leg (macOS) must not skip publish; # only a genuine workflow-run cancellation should. - if: ${{ always() && needs.build.result != 'skipped' }} + if: ${{ always() && needs.build.result != 'skipped' && (github.event_name == 'workflow_dispatch' || !contains(github.ref, '-mtfix')) }} runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v5 @@ -645,6 +647,24 @@ jobs: exit 1 fi + - name: Refuse to replace an existing public test build + if: ${{ contains(steps.tag.outputs.tag, '-mtfix') }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + TEST_TAG: ${{ steps.tag.outputs.tag }} + shell: bash + run: | + if gh release view "$TEST_TAG" --json isDraft > existing-release.json 2>release-error.txt; then + if ! jq -e '.isDraft == true' existing-release.json >/dev/null; then + echo "::error::Public test-build assets are immutable; use a new mtfix tag." + exit 1 + fi + elif ! grep -qi 'release not found' release-error.txt; then + cat release-error.txt + echo "::error::Could not verify existing release state; refusing upload." + exit 1 + fi + - name: Publish release uses: softprops/action-gh-release@v2 with: From 0637e5afd541df449e609de639d13aa98d5f0f10 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Thu, 1 Oct 2026 07:54:00 -0500 Subject: [PATCH 87/97] docs(release): mtfix23 upstream sync test-build notes for Pritpal Bedi --- release-notes-1.09.71-mtfix23.md | 33 ++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 release-notes-1.09.71-mtfix23.md diff --git a/release-notes-1.09.71-mtfix23.md b/release-notes-1.09.71-mtfix23.md new file mode 100644 index 00000000..dd54f7df --- /dev/null +++ b/release-notes-1.09.71-mtfix23.md @@ -0,0 +1,33 @@ +Built for Pritpal Bedi - bedipritpal/OpenADS, forked from FiveTechSoft/OpenADS. + +# v1.09.71-mtfix23 - upstream sync test build + +Test build only. Use matching client and server versions and back up binaries and data before testing. This is not a production recommendation. No fork-main merge is included; Tim Stone's exact ECLMST application test remains the gate for that merge. + +## Upstream boundary and retained fixes + +Merged upstream v1.09.71 at 82ac63c438c155ef8c267f3f2992ca530f9e2908 once into perf/safe-local-answers, preserving both parents in merge 37ceaf51eec6fac69be4395f37c0897b0c05e1ef. Newer upstream main and its macOS compile experiments are excluded. + +The merge leaves src/, include/ and the fork's workflows byte-identical to mtfix22. Upstream documentation and release notes were merged. Its ADT exclusive-append regression was added as a separate test rather than replacing the fork's broader distinct-values/shared-guard test. + +Retained fork behavior includes case-insensitive LOCAL tag lookup, stock Harbour order-0/empty natural focus, retained scopes and tag direction, REMOTE natural-Skip reanchoring, engine append retry/probes, alias lane pinning, canonical archive/header checks and the Linux glibc 2.31 kit. Vouch application code is untouched. + +## Validation + +Local Linux Debug -O0/-g0, warnings-as-errors disabled because of inherited warnings. Byte-identical source/include reused mtfix22 build objects after fresh-build resource limits; the new upstream test compiled fresh and core/server/ACE/unit binaries were relinked. + +All 1,606 enabled cases were run across four ranges, with 16 disabled cases: 1,604 passed and 2 failed; 628,676 assertions, 7 failed. Both failure classes independently reproduced on the untouched mtfix22 baseline: Exclusive-held OpenIndex returned 6106 rather than 7040, and the REMOTE create/index storm produced a count mismatch. These are known unresolved failures, not a clean full-suite pass. + +The normal suite under its existing slow/flaky exclusions passed 1,595 cases and 612,903 assertions in 93 seconds. The initial 90-second CTest wrapper timeout did not indicate an assertion failure. Five other CTest entries passed: slow cases, 30-repeat pool stress, SQL URI, SQLite filter and SQLite seek. Both ADT append regressions passed together: 2 cases, 50,055 assertions. + +macOS unit validation remains incomplete from mtfix22: Configure/Build passed but unit-test runs timed out. No macOS unit pass, Tim Windows x64 application pass, or ECLMST-file pass is claimed. Platform packaging results must be checked separately before this draft is published. + +## Release integrity + +mtfix22's tag-push packaging run silently replaced its reviewed archives and notes. The exact run34 archives and reviewed notes were restored and all five public downloads were verified byte-for-byte. This build adds release guards: mtfix tag pushes do not package or upload; draft staging requires a manual dispatch; a manual dispatch refuses to alter an existing public mtfix release and fails closed if release state cannot be read. Production-tag behavior is unchanged. + +## Application check + +Repeat Tim's ECLMST report using CDX-format ECLMST.adi (adt_cdx_index=1): select eclcom by name and number, check OrdName/IndexOrd, set order 0 and empty focus and verify physical record navigation. Repeat LOCAL and REMOTE, switch back to a tag, check scopes and append/write maintenance. Record exact handles, recnos and return codes for mismatches. Pritpal does not personally use ADT tables. + +Use the full Windows x64/x86, Linux x64/glibc231 or macOS universal client/server kit. Do not mix ACE libraries from different builds. From 63302b7d619d476de3b8861b3f61067459b5cb0d Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Thu, 1 Oct 2026 07:57:10 -0500 Subject: [PATCH 88/97] ci(release): skip glibc231 rebuild on mtfix tag publication --- .github/workflows/release.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9070cb16..de12790e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -313,6 +313,7 @@ jobs: # flattens it into the release but does not require the leg to be green. # (Requested by Pritpal Bedi for his LAN test server.) build-glibc231: + if: ${{ github.event_name == 'workflow_dispatch' || !contains(github.ref, '-mtfix') }} name: ubuntu 20.04 container / x64 (glibc 2.31) runs-on: ubuntu-24.04 container: ubuntu:20.04 From 764150d7421e97a53b8198dba67791ce76c6e2d7 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Thu, 1 Oct 2026 08:10:02 -0500 Subject: [PATCH 89/97] fix(cdx): make lock diagnostics opt-in through OPENADS_LOCK_DIAG --- src/drivers/cdx/cdx_index.cpp | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/drivers/cdx/cdx_index.cpp b/src/drivers/cdx/cdx_index.cpp index 5e58dce3..252522d7 100644 --- a/src/drivers/cdx/cdx_index.cpp +++ b/src/drivers/cdx/cdx_index.cpp @@ -11,6 +11,7 @@ #include #include #include +#include #include #include #include @@ -39,6 +40,13 @@ bool want_fsync_() { return v; } +// Opt-in lock diagnostics only. Set OPENADS_LOCK_DIAG to a writable file +// path before starting the process; unset/empty means no diagnostic file. +std::FILE* open_lock_diag_() { + const char* path = std::getenv("OPENADS_LOCK_DIAG"); + return path && path[0] != '\0' ? std::fopen(path, "a") : nullptr; +} + std::mutex g_cdx_alloc_mu; std::unordered_map g_cdx_alloc_tail; @@ -738,7 +746,7 @@ util::Result CdxIndex::ensure_write_lock_() { auto& slot = g_cdx_write_locks[path_]; if (!slot) slot = std::make_shared(); e = slot; - if (auto* f = std::fopen("/tmp/lock_diag.log", "a")) { + if (auto* f = open_lock_diag_()) { std::fprintf(f, "ensure_write_lock ENTER path=%s users=%zu has_os=%d fd=%p\n", path_.c_str(), e->users, (int)(bool)e->os_lock, file_.native_handle()); std::fclose(f); } @@ -765,7 +773,7 @@ util::Result CdxIndex::ensure_write_lock_() { auto l = acquire_cdx_os_lock_(file_, platform::LockKind::Exclusive); if (!l) { - if (auto* f = std::fopen("/tmp/cdx_diag.log", "a")) { + if (auto* f = open_lock_diag_()) { std::fprintf(f, "acquire_cdx_os_lock FAIL path=%s fd=%p code=%d sub=%d msg=%s\n", path_.c_str(), file_.native_handle(), l.error().code, l.error().sub_code, l.error().message.c_str()); std::fclose(f); @@ -841,7 +849,7 @@ CdxIndex::open_named(const std::string& path, const std::string& tag_name) { mode_ = mode; path_ = canonicalize_path(path); - if (auto* f = std::fopen("/tmp/lock_diag.log", "a")) { + if (auto* f = open_lock_diag_()) { std::fprintf(f, "open_named orig=%s canon=%s fd_will_open\n", path.c_str(), path_.c_str()); std::fclose(f); } From dec4f833f9d5171410d332ae67ed4ea4f5ea0e71 Mon Sep 17 00:00:00 2001 From: Pritpal Bedi Date: Thu, 1 Oct 2026 08:10:33 -0500 Subject: [PATCH 90/97] docs(release): document shipped ace.h and opt-in lock diagnostics --- release-notes-1.09.71-mtfix23.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/release-notes-1.09.71-mtfix23.md b/release-notes-1.09.71-mtfix23.md index dd54f7df..fc3ddc8d 100644 --- a/release-notes-1.09.71-mtfix23.md +++ b/release-notes-1.09.71-mtfix23.md @@ -8,7 +8,7 @@ Test build only. Use matching client and server versions and back up binaries an Merged upstream v1.09.71 at 82ac63c438c155ef8c267f3f2992ca530f9e2908 once into perf/safe-local-answers, preserving both parents in merge 37ceaf51eec6fac69be4395f37c0897b0c05e1ef. Newer upstream main and its macOS compile experiments are excluded. -The merge leaves src/, include/ and the fork's workflows byte-identical to mtfix22. Upstream documentation and release notes were merged. Its ADT exclusive-append regression was added as a separate test rather than replacing the fork's broader distinct-values/shared-guard test. +The upstream merge itself left src/, include/ and the fork's workflows byte-identical to mtfix22. This test build then adds the diagnostic and packaging changes below. Upstream documentation and release notes were merged. Its ADT exclusive-append regression was added as a separate test rather than replacing the fork's broader distinct-values/shared-guard test. Retained fork behavior includes case-insensitive LOCAL tag lookup, stock Harbour order-0/empty natural focus, retained scopes and tag direction, REMOTE natural-Skip reanchoring, engine append retry/probes, alias lane pinning, canonical archive/header checks and the Linux glibc 2.31 kit. Vouch application code is untouched. @@ -31,3 +31,11 @@ mtfix22's tag-push packaging run silently replaced its reviewed archives and not Repeat Tim's ECLMST report using CDX-format ECLMST.adi (adt_cdx_index=1): select eclcom by name and number, check OrdName/IndexOrd, set order 0 and empty focus and verify physical record navigation. Repeat LOCAL and REMOTE, switch back to a tag, check scopes and append/write maintenance. Record exact handles, recnos and return codes for mismatches. Pritpal does not personally use ADT tables. Use the full Windows x64/x86, Linux x64/glibc231 or macOS universal client/server kit. Do not mix ACE libraries from different builds. + +## Headers and opt-in diagnostics + +All five archives include the ADS client header at include/ace.h and include/openads/ace.h, together with its companion headers. + +CDX lock diagnostics no longer write /tmp/lock_diag.log or /tmp/cdx_diag.log unconditionally. Normal runs create neither file. To collect the three existing lock/open/error diagnostic messages, set OPENADS_LOCK_DIAG to a writable log-file path before starting the process. Unset or empty disables logging; an unavailable path does not change operation results. Diagnostic logs include table/index paths and native handle values, so enable them only when needed and handle them as private data. + +Diagnostic gate validation: rebuilt the modified CDX source; 92 non-slow/non-flaky CDX cases and 317,285 assertions passed with the variable unset, with an explicit log path, and with an unavailable path. The unset run left the legacy log unchanged; the explicit path received 279 diagnostic lines. A broader 96-case run reproduced the already-known REMOTE create/index storm failure; 95 cases passed. From 04019391b4b2224d95552104cdc00358681601a1 Mon Sep 17 00:00:00 2001 From: FiveTech Software Date: Thu, 1 Oct 2026 19:45:28 +0200 Subject: [PATCH 91/97] Refactor apply-patch workflow for clarity and functionality Updated the apply-patch workflow to enhance functionality and improve clarity. Changes include renaming inputs, modifying job steps, and adjusting permissions. --- .github/workflows/apply-patch.yml | 756 +++++++++++++++++++++--------- 1 file changed, 538 insertions(+), 218 deletions(-) diff --git a/.github/workflows/apply-patch.yml b/.github/workflows/apply-patch.yml index 37706693..8721c126 100644 --- a/.github/workflows/apply-patch.yml +++ b/.github/workflows/apply-patch.yml @@ -1,222 +1,215 @@ -name: apply-patch - -# Manually triggered: lands one reviewed patch on one branch, but only -# after it applies cleanly, stays inside the allowed paths, builds, and -# passes the full test suite. If any step fails, nothing is pushed. -# -# Inputs are never pasted into shell scripts directly. Every script -# reads them from environment variables, so a crafted branch name or -# message can't run commands. +name: Apply reviewed patch on: workflow_dispatch: inputs: - branch: - description: "Branch to commit to (not the default branch)" - required: true + patch_base64: + description: "Base64 of a git diff patch, optionally gzip-compressed" + required: false type: string - expected_head: - description: "Full 40-character SHA the branch must be at right now" - required: true + restore_from: + description: "Restore mode: commit SHA (7-40 hexadecimal characters)" + required: false type: string - patch_b64: - description: "Patch (git diff or git format-patch output), base64-encoded" - required: true + restore_path: + description: "Restore mode: one ordinary file in src/engine/**, tests/** or docs/**" + required: false type: string commit_message: - description: "Commit message (one line)" + description: "Commit message" required: true type: string - trigger_ci: - description: "After pushing, start ci.yml on the branch (needs workflow_dispatch in ci.yml)" - required: false - type: boolean - default: true -# Default for every job: read-only. Only the commit job gets write. permissions: - contents: read + contents: write -# One run per branch at a time; a second run waits instead of racing. concurrency: - group: apply-patch-${{ inputs.branch }} + group: openads-apply-patch-main cancel-in-progress: false -env: - # Safety limits. Edit here if you need to change them. - MAX_FILES: "20" - MAX_PATCH_B64_CHARS: "60000" - # Paths the patch may touch (extended regex, matched against the full - # path). .github/ is deliberately NOT allowed: a patch must never be - # able to change workflows, and GitHub also refuses workflow-file - # pushes made with the built-in token. - ALLOWED_PATHS_REGEX: '^(src/|include/|tests/|cmake/|docs/|bindings/|tools/|examples/|CMakeLists\.txt$|CMakePresets\.json$|CHANGELOG\.md$|README\.md$)' - jobs: - validate: - name: Validate patch + prepare: runs-on: ubuntu-24.04 - timeout-minutes: 10 + permissions: + contents: read + outputs: + base_sha: ${{ steps.base.outputs.sha }} steps: - - name: Check inputs + - name: Require dispatch from main env: - BRANCH: ${{ inputs.branch }} - EXPECTED_HEAD: ${{ inputs.expected_head }} - PATCH_B64: ${{ inputs.patch_b64 }} - COMMIT_MESSAGE: ${{ inputs.commit_message }} - DEFAULT_BRANCH: ${{ github.event.repository.default_branch }} - shell: bash - run: | - set -euo pipefail - if ! [[ "$BRANCH" =~ ^[A-Za-z0-9._/-]{1,100}$ ]] || [[ "$BRANCH" == *..* ]] || [[ "$BRANCH" == -* ]]; then - echo "::error::Branch name has characters that are not allowed."; exit 1 - fi - if [[ "$BRANCH" == "$DEFAULT_BRANCH" ]]; then - echo "::error::Refusing to commit to the default branch ($DEFAULT_BRANCH)."; exit 1 - fi - if ! [[ "$EXPECTED_HEAD" =~ ^[0-9a-f]{40}$ ]]; then - echo "::error::expected_head must be a full 40-character lowercase SHA."; exit 1 - fi - if (( ${#PATCH_B64} > MAX_PATCH_B64_CHARS )); then - echo "::error::Patch is larger than $MAX_PATCH_B64_CHARS base64 characters."; exit 1 - fi - if [[ -z "${COMMIT_MESSAGE// }" ]] || (( ${#COMMIT_MESSAGE} > 200 )); then - echo "::error::Commit message must be 1-200 characters."; exit 1 - fi - - - name: Check out branch - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.2.2 + DISPATCH_REF: ${{ github.ref }} + run: test "$DISPATCH_REF" = refs/heads/main + - uses: actions/checkout@v5 with: - ref: ${{ inputs.branch }} + ref: main + fetch-tags: true fetch-depth: 0 persist-credentials: false - - - name: Check branch head matches expected_head - env: - EXPECTED_HEAD: ${{ inputs.expected_head }} - shell: bash - run: | - set -euo pipefail - actual="$(git rev-parse HEAD)" - if [[ "$actual" != "$EXPECTED_HEAD" ]]; then - echo "::error::Branch is at $actual, expected $EXPECTED_HEAD. Someone pushed in between; nothing was changed."; exit 1 - fi - - - name: Decode patch + - name: Record exact base + id: base + run: echo "sha=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT" + - name: Prepare restore or patch and enforce allowlist env: - PATCH_B64: ${{ inputs.patch_b64 }} - shell: bash - run: | - set -euo pipefail - # Strip spaces and line breaks the web form may add. Any other - # damage makes decoding fail here, before anything is applied. - printf '%s' "$PATCH_B64" | tr -d ' \t\r\n' | base64 --decode > "$RUNNER_TEMP/change.patch" - if [[ ! -s "$RUNNER_TEMP/change.patch" ]]; then - echo "::error::Decoded patch is empty."; exit 1 - fi - # Trailing blank lines after the last hunk (common when a patch - # is copied or saved by an editor) can be read as extra context - # lines and make the patch fail. Trim them to one final newline. - # Only the very end of the file is touched; hunk contents and - # line counts are left as they are. - python3 - "$RUNNER_TEMP/change.patch" <<'PY' - import re, sys - path = sys.argv[1] - data = open(path, "rb").read() - fixed = re.sub(rb"(?:\r?\n[ \t\r]*)+\Z", b"\n", data) - if fixed != data: - print("Trimmed trailing blank lines from the patch.") - open(path, "wb").write(fixed) - PY - sha256sum "$RUNNER_TEMP/change.patch" - - - name: Apply patch and check paths + PATCH_BASE64: ${{ inputs.patch_base64 }} + RESTORE_FROM: ${{ inputs.restore_from }} + RESTORE_PATH: ${{ inputs.restore_path }} + COMMIT_MESSAGE: ${{ inputs.commit_message }} shell: bash run: | set -euo pipefail - git apply --check --index "$RUNNER_TEMP/change.patch" - git apply --index "$RUNNER_TEMP/change.patch" - - # What actually changed, from git itself (covers adds, deletes - # and both sides of renames). - git diff --cached --name-status --no-renames > "$RUNNER_TEMP/changed.txt" - cat "$RUNNER_TEMP/changed.txt" - count="$(wc -l < "$RUNNER_TEMP/changed.txt")" - if (( count == 0 )); then - echo "::error::Patch changes nothing."; exit 1 - fi - if (( count > MAX_FILES )); then - echo "::error::Patch touches $count files; the limit is $MAX_FILES."; exit 1 - fi - - bad=0 - while IFS=$'\t' read -r status path; do - if ! [[ "$path" =~ $ALLOWED_PATHS_REGEX ]]; then - echo "::error::Path not allowed: $path"; bad=1 - fi - done < "$RUNNER_TEMP/changed.txt" - - # No symlinks, submodules or executable-bit changes. - # Raw lines look like ": ...". Reject any - # symlink (120000) or submodule (160000), and any mode change - # on an existing file. - if git diff --cached --raw --no-renames | awk '{o=substr($1,2); n=$2; if (o ~ /^(120000|160000)$/ || n ~ /^(120000|160000)$/ || (o != "000000" && n != "000000" && o != n)) bad=1} END {exit !bad}'; then - echo "::error::Patch changes file modes (symlink, submodule or executable bit)."; bad=1 - fi - exit $bad - - - name: Save patch for the next jobs + python3 - <<'PYTHON' + import base64, gzip, os, pathlib, re, subprocess + if not os.environ["COMMIT_MESSAGE"].strip(): + raise SystemExit("Commit message must not be empty") + encoded = "".join(os.environ["PATCH_BASE64"].split()) + restore_from = os.environ["RESTORE_FROM"].strip() + restore_path = os.environ["RESTORE_PATH"] + artifact = pathlib.Path(os.environ["RUNNER_TEMP"]) / "reviewed-patch" + artifact.mkdir() + patch_file = artifact / "patch.diff" + if restore_from or restore_path: + if encoded or not restore_from or not restore_path: + raise SystemExit("Use either patch_base64 OR both restore_from and restore_path") + if not re.fullmatch(r"[0-9a-fA-F]{7,40}", restore_from): + raise SystemExit("restore_from must be a commit SHA") + parts = restore_path.split("/") + allowed = restore_path.startswith(("src/engine/", "tests/", "docs/")) + if (not allowed or any(p in ("", ".", "..") for p in parts) + or any(p.lower() == ".git" for p in parts) + or "\\" in restore_path): + raise SystemExit("Disallowed restore_path") + commit = subprocess.check_output([ + "git", "rev-parse", "--verify", restore_from + "^{commit}" + ]).decode().strip() + entry = subprocess.check_output([ + "git", "--literal-pathspecs", "ls-tree", "-z", commit, "--", restore_path + ]).split(b"\0") + entry = [e for e in entry if e] + if len(entry) != 1: + raise SystemExit("restore_path must name exactly one existing file") + metadata, actual_path = entry[0].split(b"\t", 1) + mode, kind, _ = metadata.split() + if actual_path != os.fsencode(restore_path) or kind != b"blob" or mode not in (b"100644", b"100755"): + raise SystemExit("restore_path must name one ordinary file, not a directory or symlink") + subprocess.run([ + "git", "--literal-pathspecs", "checkout", commit, "--", restore_path + ], check=True) + # Normalize the one-file restore into the same exact patch used by all CI jobs. + patch = subprocess.check_output([ + "git", "diff", "--cached", "--binary", "--full-index", "--no-ext-diff", "--no-renames" + ]) + subprocess.run(["git", "reset", "--hard", "HEAD"], check=True) + else: + patch = base64.b64decode(encoded, validate=True) + if patch.startswith(b"\x1f\x8b"): + patch = gzip.decompress(patch) + if not patch: + raise SystemExit("Input makes no changes") + patch_file.write_bytes(patch) + subprocess.run(["git", "apply", "--check", "--index", str(patch_file)], check=True) + subprocess.run(["git", "apply", "--index", str(patch_file)], check=True) + paths = subprocess.check_output([ + "git", "diff", "--cached", "--name-only", "--no-renames", "-z" + ]).split(b"\0") + paths = [p for p in paths if p] + if not paths: + raise SystemExit("Patch makes no changes") + if restore_path and paths != [os.fsencode(restore_path)]: + raise SystemExit("Restore must change exactly the requested file") + for path in paths: + parts = path.split(b"/") + allowed = (path.startswith(b"src/engine/") or + path.startswith(b"tests/") or path.startswith(b"docs/")) + if (not allowed or any(p in (b"", b".", b"..") for p in parts) + or any(p.lower() == b".git" for p in parts)): + raise SystemExit("Disallowed path: " + repr(path)) + # Reject symlinks and submodules, including type changes. + raw = subprocess.check_output([ + "git", "diff", "--cached", "--raw", "--no-renames", "-z" + ]).split(b"\0") + for metadata in raw[::2]: + if not metadata: + continue + old_mode, new_mode = metadata.split()[:2] + if old_mode[1:] not in (b"000000", b"100644", b"100755") or new_mode not in (b"000000", b"100644", b"100755"): + raise SystemExit("Only ordinary files may be patched") + (artifact / "base.sha").write_bytes(subprocess.check_output(["git", "rev-parse", "HEAD"])) + PYTHON + - name: Share validated patch with checks uses: actions/upload-artifact@v4 with: - name: change-patch - path: ${{ runner.temp }}/change.patch - retention-days: 3 + name: reviewed-patch + path: ${{ runner.temp }}/reviewed-patch/ + retention-days: 1 + if-no-files-found: error build-and-test: - name: Build and test / ${{ matrix.preset }}${{ matrix.tls && ' / TLS' || '' }} - needs: validate + needs: prepare + permissions: + contents: read + name: ${{ matrix.os }} / ${{ matrix.preset }}${{ matrix.tls && ' / TLS' || '' }} runs-on: ${{ matrix.os }} - timeout-minutes: 30 + # macOS is out of the matrix for now: its unit tests stall and a timeout + # cancels the whole run. Re-add the macos-14 entry once that is fixed. + timeout-minutes: ${{ matrix.os == 'macos-14' && 90 || 30 }} + continue-on-error: ${{ matrix.os == 'macos-14' }} strategy: - fail-fast: true + fail-fast: false matrix: include: + - os: windows-2022 + preset: msvc-x64 + tls: false + # 32-bit MSVC: many deployments still ship x86. CI must build it + # so x86-only breakage (bitness-dependent narrowing C4244/C2220, + # SQLLEN*/SQLPOINTER signatures C2733, /WX C4100) can't slip past. + - os: windows-2022 + preset: msvc-x86 + tls: false - os: ubuntu-24.04 preset: ninja-clang tls: false + # M12.12 — verify the OPENADS_WITH_TLS path (vendored + # mbedtls 3.6) on at least one runner per release. - os: ubuntu-24.04 preset: ninja-clang tls: true steps: - - name: Check out the exact expected commit - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.2.2 + - uses: actions/checkout@v5 with: - ref: ${{ inputs.expected_head }} - # Full history + tags, or git describe returns a bare SHA and - # network_version_test fails (same fix as ci.yml). - fetch-depth: 0 - fetch-tags: true + ref: ${{ needs.prepare.outputs.base_sha }} persist-credentials: false + # Version string comes from `git describe --tags`: without + # tag refs a main-branch checkout reports a bare SHA (no + # dots) and network_version_test fails everywhere. Releases + # check out the tag itself so they never hit this. + # fetch-tags alone stays shallow (default fetch-depth: 1), so + # describe still can't see v* tags - fetch the full history. + fetch-tags: true + fetch-depth: 0 - - name: Get patch + - name: Download validated patch uses: actions/download-artifact@v4 with: - name: change-patch - path: ${{ runner.temp }}/patch - - - name: Apply patch + name: reviewed-patch + path: ${{ runner.temp }}/reviewed-patch + - name: Apply validated patch shell: bash - run: git apply "$RUNNER_TEMP/patch/change.patch" + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$(cat "$RUNNER_TEMP/reviewed-patch/base.sha")" + git apply --check --index "$RUNNER_TEMP/reviewed-patch/patch.diff" + git apply --index "$RUNNER_TEMP/reviewed-patch/patch.diff" - - name: Install Ninja + - name: Install Ninja (Linux / macOS) + if: runner.os != 'Windows' uses: seanmiddleditch/gha-setup-ninja@v6 - # Configure / Build / Test are the same commands as ci.yml. - name: Configure - shell: bash run: | cmake --preset ${{ matrix.preset }} \ ${{ matrix.tls && '-DOPENADS_WITH_TLS=ON' || '' }} + shell: bash - name: Build run: cmake --build build/${{ matrix.preset }} --config Release --target dbf_cdx_bench openads_unit_tests @@ -224,77 +217,404 @@ jobs: - name: Test run: ctest --test-dir build/${{ matrix.preset }} --output-on-failure -C Release - commit: - name: Commit and push - needs: build-and-test + - name: Bench smoke (CDX multi-tag create) + # Small run to smoke the optimized INDEX ON / bulk create path (#128) + run: | + BENCH="build/${{ matrix.preset }}/tests/Release/dbf_cdx_bench${{ runner.os == 'Windows' && '.exe' || '' }}" + if [ -f "$BENCH" ]; then + "$BENCH" 1000 || true + fi + shell: bash + + php-binding: + needs: prepare + permissions: + contents: read + name: PHP binding runs-on: ubuntu-24.04 - timeout-minutes: 10 + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ needs.prepare.outputs.base_sha }} + persist-credentials: false + - name: Download validated patch + uses: actions/download-artifact@v4 + with: + name: reviewed-patch + path: ${{ runner.temp }}/reviewed-patch + - name: Apply validated patch + shell: bash + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$(cat "$RUNNER_TEMP/reviewed-patch/base.sha")" + git apply --check --index "$RUNNER_TEMP/reviewed-patch/patch.diff" + git apply --index "$RUNNER_TEMP/reviewed-patch/patch.diff" + + + - name: Install Ninja + uses: seanmiddleditch/gha-setup-ninja@v6 + + - name: Build OpenADS ACE library + run: | + cmake --preset ninja-clang + cmake --build build/ninja-clang --config Release --target openads_ace + + - name: Setup PHP + uses: shivammathur/setup-php@v2 + with: + php-version: '8.3' + extensions: ffi + ini-values: ffi.enable=1 + + - name: Install PHP dependencies + working-directory: bindings/php + run: composer install --no-interaction + + - name: Run PHP binding tests + working-directory: bindings/php + env: + OPENADS_ACE_LIB: ${{ github.workspace }}/build/ninja-clang/src/libace64.so + run: composer test + + sql-uri-smoke: + needs: prepare permissions: - contents: write - # Only used to start ci.yml after the push (see last step). - actions: write + contents: read + name: SQL URI smoke + runs-on: ubuntu-24.04 + timeout-minutes: 15 steps: - - name: Check out branch - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.2.2 + - uses: actions/checkout@v5 with: - ref: ${{ inputs.branch }} - fetch-depth: 0 + ref: ${{ needs.prepare.outputs.base_sha }} + persist-credentials: false + - name: Download validated patch + uses: actions/download-artifact@v4 + with: + name: reviewed-patch + path: ${{ runner.temp }}/reviewed-patch + - name: Apply validated patch + shell: bash + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$(cat "$RUNNER_TEMP/reviewed-patch/base.sha")" + git apply --check --index "$RUNNER_TEMP/reviewed-patch/patch.diff" + git apply --index "$RUNNER_TEMP/reviewed-patch/patch.diff" + + + - name: Install Ninja + uses: seanmiddleditch/gha-setup-ninja@v6 - - name: Check head again + - name: Configure + run: cmake --preset ninja-clang + shell: bash + + - name: Build unit tests + run: cmake --build build/ninja-clang --config Release --target openads_unit_tests + shell: bash + + - name: Run SQL URI smoke tests + run: | + ctest --test-dir build/ninja-clang -C Release --output-on-failure \ + -R "sql_uri_smoke|sqlite_filter_pushdown|sqlite_seek_smoke" + shell: bash + + sql-live-pg-maria: + needs: prepare + permissions: + contents: read + name: SQL live PG + Maria + runs-on: ubuntu-24.04 + timeout-minutes: 20 + services: + postgres: + image: postgres:16 env: - EXPECTED_HEAD: ${{ inputs.expected_head }} + POSTGRES_HOST_AUTH_METHOD: trust + ports: + - 5432:5432 + options: >- + --health-cmd "pg_isready -U postgres" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + mariadb: + image: mariadb:11 + env: + MARIADB_ALLOW_EMPTY_ROOT_PASSWORD: "yes" + MARIADB_DATABASE: test + ports: + - 3306:3306 + options: >- + --health-cmd "healthcheck.sh --connect --innodb_initialized" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ needs.prepare.outputs.base_sha }} + persist-credentials: false + - name: Download validated patch + uses: actions/download-artifact@v4 + with: + name: reviewed-patch + path: ${{ runner.temp }}/reviewed-patch + - name: Apply validated patch shell: bash run: | set -euo pipefail - actual="$(git rev-parse HEAD)" - if [[ "$actual" != "$EXPECTED_HEAD" ]]; then - echo "::error::Branch moved to $actual while tests ran. Nothing pushed."; exit 1 - fi + test "$(git rev-parse HEAD)" = "$(cat "$RUNNER_TEMP/reviewed-patch/base.sha")" + git apply --check --index "$RUNNER_TEMP/reviewed-patch/patch.diff" + git apply --index "$RUNNER_TEMP/reviewed-patch/patch.diff" + + + - name: Install build deps + run: | + sudo apt-get update + sudo apt-get install -y ninja-build libpq-dev libmariadb-dev + + - name: Install Ninja + uses: seanmiddleditch/gha-setup-ninja@v6 + + - name: Configure (PostgreSQL + MariaDB) + run: | + cmake --preset ninja-clang \ + -DOPENADS_WITH_POSTGRESQL=ON \ + -DOPENADS_WITH_MARIADB=ON + shell: bash - - name: Get patch + - name: Build unit tests + run: cmake --build build/ninja-clang --config Release --target openads_unit_tests + shell: bash + + - name: Run live PG + Maria smoke tests + env: + OPENADS_TEST_PG_URI: postgresql://postgres@127.0.0.1:5432/postgres + OPENADS_TEST_MARIADB_URI: mariadb://root@127.0.0.1:3306/test + run: | + ctest --test-dir build/ninja-clang -C Release --output-on-failure \ + -R "pg_live_smoke|maria_live_smoke" + shell: bash + + sql-live-mssql: + needs: prepare + permissions: + contents: read + name: SQL live MSSQL + runs-on: ubuntu-24.04 + timeout-minutes: 25 + services: + mssql: + image: mcr.microsoft.com/mssql/server:2022-latest + env: + ACCEPT_EULA: Y + MSSQL_SA_PASSWORD: OpenADS_CI_Passw0rd! + ports: + - 1433:1433 + options: >- + --health-cmd "/opt/mssql-tools18/bin/sqlcmd -C -S localhost -U sa -P OpenADS_CI_Passw0rd! -Q 'SELECT 1' || exit 1" + --health-interval 10s + --health-timeout 5s + --health-retries 10 + --health-start-period 30s + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ needs.prepare.outputs.base_sha }} + persist-credentials: false + - name: Download validated patch uses: actions/download-artifact@v4 with: - name: change-patch - path: ${{ runner.temp }}/patch + name: reviewed-patch + path: ${{ runner.temp }}/reviewed-patch + - name: Apply validated patch + shell: bash + run: | + set -euo pipefail + test "$(git rev-parse HEAD)" = "$(cat "$RUNNER_TEMP/reviewed-patch/base.sha")" + git apply --check --index "$RUNNER_TEMP/reviewed-patch/patch.diff" + git apply --index "$RUNNER_TEMP/reviewed-patch/patch.diff" + + + - name: Install build deps + run: sudo apt-get update && sudo apt-get install -y ninja-build + + - name: Install Ninja + uses: seanmiddleditch/gha-setup-ninja@v6 - - name: Commit and push + - name: Configure (MSSQL) + run: cmake --preset ninja-clang -DOPENADS_WITH_MSSQL=ON -DOPENADS_WITH_TLS=ON + shell: bash + + - name: Build unit tests + run: cmake --build build/ninja-clang --config Release --target openads_unit_tests + shell: bash + + - name: Run live MSSQL smoke test env: - BRANCH: ${{ inputs.branch }} - COMMIT_MESSAGE: ${{ inputs.commit_message }} - ACTOR: ${{ github.actor }} + OPENADS_TEST_MSSQL_CONNSTR: mssql://sa:OpenADS_CI_Passw0rd!@127.0.0.1:1433/master + run: | + ctest --test-dir build/ninja-clang -C Release --output-on-failure \ + -R mssql_live_smoke + shell: bash + + sql-live-firebird: + needs: prepare + permissions: + contents: read + name: SQL live Firebird + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ needs.prepare.outputs.base_sha }} + persist-credentials: false + - name: Download validated patch + uses: actions/download-artifact@v4 + with: + name: reviewed-patch + path: ${{ runner.temp }}/reviewed-patch + - name: Apply validated patch shell: bash run: | set -euo pipefail - git apply --index "$RUNNER_TEMP/patch/change.patch" - git config user.name "github-actions[bot]" - git config user.email "41898283+github-actions[bot]@users.noreply.github.com" - printf '%s\n\nApplied by apply-patch workflow, run %s, started by %s.\n' \ - "$COMMIT_MESSAGE" "$GITHUB_RUN_ID" "$ACTOR" > "$RUNNER_TEMP/msg.txt" - git commit -F "$RUNNER_TEMP/msg.txt" - # Plain push, never force. If the branch moved, GitHub rejects - # it and nothing lands. - git push origin "HEAD:refs/heads/$BRANCH" - echo "Pushed $(git rev-parse HEAD) to $BRANCH" - - # A push made with the built-in token does not start push-triggered - # workflows, and PR runs it causes wait for approval on the PR page. - # A workflow_dispatch request is the exception, so start ci.yml that - # way. This only works if ci.yml lists workflow_dispatch under on:, - # which a person has to add (this workflow can't edit workflow files). - # The commit has already landed, so a problem here is a warning only. - - name: Start CI on the branch - if: ${{ inputs.trigger_ci }} + test "$(git rev-parse HEAD)" = "$(cat "$RUNNER_TEMP/reviewed-patch/base.sha")" + git apply --check --index "$RUNNER_TEMP/reviewed-patch/patch.diff" + git apply --index "$RUNNER_TEMP/reviewed-patch/patch.diff" + + + - name: Install build deps + run: | + sudo apt-get update + sudo apt-get install -y ninja-build firebird-dev firebird3.0-utils + + - name: Install Ninja + uses: seanmiddleditch/gha-setup-ninja@v6 + + - name: Seed embedded Firebird fixture + run: | + mkdir -p /tmp/openads-fb + cat > /tmp/openads-fb/seed.sql <<'EOF' + CREATE DATABASE '/tmp/openads-fb/live.fdb' USER 'SYSDBA' DEFAULT CHARACTER SET UTF8; + EXIT; + EOF + isql-fb -user SYSDBA -i /tmp/openads-fb/seed.sql + + - name: Configure (Firebird) + run: cmake --preset ninja-clang -DOPENADS_WITH_FIREBIRD=ON + shell: bash + + - name: Build unit tests + run: cmake --build build/ninja-clang --config Release --target openads_unit_tests + shell: bash + + - name: Run live Firebird smoke test env: - BRANCH: ${{ inputs.branch }} - GH_TOKEN: ${{ github.token }} + OPENADS_TEST_FIREBIRD_DB: /tmp/openads-fb/live.fdb + run: | + ctest --test-dir build/ninja-clang -C Release --output-on-failure \ + -R firebird_live_smoke + shell: bash + + harbour-smoke: + needs: prepare + permissions: + contents: read + name: Harbour smoke (Windows) + runs-on: windows-2022 + timeout-minutes: 120 + steps: + - uses: actions/checkout@v5 + with: + ref: ${{ needs.prepare.outputs.base_sha }} + persist-credentials: false + - name: Download validated patch + uses: actions/download-artifact@v4 + with: + name: reviewed-patch + path: ${{ runner.temp }}/reviewed-patch + - name: Apply validated patch shell: bash run: | - if ! grep -qE '^[[:space:]]*workflow_dispatch:' .github/workflows/ci.yml; then - echo "::warning::ci.yml has no workflow_dispatch trigger, so CI was not started. Approve the pending run on the PR page, or add workflow_dispatch to ci.yml." - exit 0 - fi - if gh workflow run ci.yml --ref "$BRANCH"; then - echo "Started ci.yml on $BRANCH." - else - echo "::warning::Could not start ci.yml. The commit was pushed; start CI from the Actions tab." - fi + set -euo pipefail + test "$(git rev-parse HEAD)" = "$(cat "$RUNNER_TEMP/reviewed-patch/base.sha")" + git apply --check --index "$RUNNER_TEMP/reviewed-patch/patch.diff" + git apply --index "$RUNNER_TEMP/reviewed-patch/patch.diff" + + + - name: Cache Harbour toolchain + uses: actions/cache@v4 + id: harbour-cache + with: + path: ${{ runner.temp }}/harbour-ci + key: harbour-core-master-msvc64-v5-${{ hashFiles('tools/scripts/bootstrap_harbour_ci.ps1') }} + + - name: Configure OpenADS + run: cmake --preset msvc-x64 + shell: bash + + - name: Build OpenADS + make_cdx + run: cmake --build build/msvc-x64 --config Release --target openads_ace make_cdx + shell: bash + + - name: Bootstrap Harbour + run: pwsh -File tools/scripts/bootstrap_harbour_ci.ps1 + env: + HARBOUR_CI_ROOT: ${{ runner.temp }}/harbour-ci + OPENADS_ROOT: ${{ github.workspace }} + OPENADS_BUILD: ${{ github.workspace }}/build/msvc-x64 + GIT_TERMINAL_PROMPT: "0" + + - name: Run Harbour smoke + run: pwsh -File tools/scripts/run_harbour_smoke.ps1 + env: + OPENADS_BUILD: ${{ github.workspace }}/build/msvc-x64 + HARBOUR_ROOT: ${{ runner.temp }}/harbour-ci + + commit: + needs: + - prepare + - build-and-test + - php-binding + - sql-uri-smoke + - sql-live-pg-maria + - sql-live-mssql + - sql-live-firebird + - harbour-smoke + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/checkout@v5 + with: + ref: main + fetch-tags: true + fetch-depth: 0 + - name: Download validated patch + uses: actions/download-artifact@v4 + with: + name: reviewed-patch + path: ${{ runner.temp }}/reviewed-patch + - name: Commit and push only the tested patch + env: + COMMIT_MESSAGE: ${{ inputs.commit_message }} + EXPECTED_BASE: ${{ needs.prepare.outputs.base_sha }} + shell: bash + run: | + set -euo pipefail + test "$(cat "$RUNNER_TEMP/reviewed-patch/base.sha")" = "$EXPECTED_BASE" + git fetch origin main + test "$(git rev-parse origin/main)" = "$EXPECTED_BASE" || { + echo "main changed while checks ran. Dispatch again against the new base." + exit 1 + } + test "$(git rev-parse HEAD)" = "$EXPECTED_BASE" + git apply --check --index "$RUNNER_TEMP/reviewed-patch/patch.diff" + git apply --index "$RUNNER_TEMP/reviewed-patch/patch.diff" + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + printf '%s\n' "$COMMIT_MESSAGE" > "$RUNNER_TEMP/patch-commit-message.txt" + git -c core.hooksPath=/dev/null commit -F "$RUNNER_TEMP/patch-commit-message.txt" + git push origin HEAD:refs/heads/main From 10708d2bde67aecba7ab8f277cc74c7ce150081d Mon Sep 17 00:00:00 2001 From: FiveTech Software Date: Thu, 1 Oct 2026 19:45:44 +0200 Subject: [PATCH 92/97] restore our ci.yml workflow (keep fork workflows out of the mtfix23 sync) Updated CI workflow to enable fetch-tags and maintain fetch-depth. --- .github/workflows/ci.yml | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3decc2b7..94f55a14 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -4,7 +4,6 @@ on: push: branches: [main] pull_request: - workflow_dispatch: jobs: build-and-test: @@ -42,12 +41,10 @@ jobs: # tag refs a main-branch checkout reports a bare SHA (no # dots) and network_version_test fails everywhere. Releases # check out the tag itself so they never hit this. - # fetch-depth: 0 is also needed: in a shallow (depth 1) clone - # the tag's commit is not connected to HEAD, so describe - # still falls back to a bare SHA on any untagged commit - # (PR merge refs, main between releases). - fetch-depth: 0 + # fetch-tags alone stays shallow (default fetch-depth: 1), so + # describe still can't see v* tags - fetch the full history. fetch-tags: true + fetch-depth: 0 - name: Install Ninja (Linux / macOS) if: runner.os != 'Windows' From 890347c6cca3e072e8b79af20442d8f269081766 Mon Sep 17 00:00:00 2001 From: FiveTech Software Date: Thu, 1 Oct 2026 19:46:03 +0200 Subject: [PATCH 93/97] restore our release.yml workflow (keep fork workflows out of the mtfix23 sync) Removed optional source_ref input and associated logic for test-build tagging. Updated conditions for publishing releases and streamlined the build process. --- .github/workflows/release.yml | 306 ++-------------------------------- 1 file changed, 17 insertions(+), 289 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index de12790e..1cf495fd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -9,17 +9,12 @@ on: tag: description: "tag to package (e.g. v1.0.0-rc1)" required: true - source_ref: - description: "source commit for a new test-build tag (optional)" - required: false permissions: contents: write jobs: build: - # Published mtfix assets are the reviewed bytes, never rebuild on tag push. - if: ${{ github.event_name == 'workflow_dispatch' || !contains(github.ref, '-mtfix') }} name: ${{ matrix.os }} / ${{ matrix.arch }} runs-on: ${{ matrix.os }} # 90: a cold Harbour SDK build (Linux leg) plus the full tree can @@ -60,18 +55,7 @@ jobs: steps: - uses: actions/checkout@v5 with: - ref: ${{ inputs.source_ref || inputs.tag || github.ref }} - - - name: Set local test-build version tag - if: ${{ inputs.source_ref != '' }} - env: - TEST_TAG: ${{ inputs.tag }} - shell: bash - run: | - if ! [[ "$TEST_TAG" =~ ^v[0-9]+\.[0-9]+\.[0-9]+-mtfix[0-9]+$ ]]; then - echo "::error::source_ref is only for mtfix test builds"; exit 1 - fi - git tag "$TEST_TAG" HEAD + ref: ${{ inputs.tag || github.ref }} - name: Resolve tag id: tag @@ -109,17 +93,17 @@ jobs: [ -n "$(find "$HB/install" -name 'libhbvmmt.a' 2>/dev/null | head -1)" ] && \ [ -n "$(find "$HB/install" -name harbour -type f 2>/dev/null | head -1)" ]; then echo "Harbour SDK cached at $HB/install" - else - sudo apt-get update - sudo apt-get install -y build-essential libncurses-dev libx11-dev - rm -rf "$HB" - mkdir -p "$HB" - git clone --depth 1 --branch master https://github.com/harbour/core.git "$HB/src" - git -C "$HB/src" fetch --depth 1 origin "$HARBOUR_REF" - git -C "$HB/src" checkout "$HARBOUR_REF" - cd "$HB/src" - make -j"$(nproc)" install HB_INSTALL_PREFIX="$HB/install" HB_BUILD_3RDEXT=no + exit 0 fi + sudo apt-get update + sudo apt-get install -y build-essential libncurses-dev libx11-dev + rm -rf "$HB" + mkdir -p "$HB" + git clone --depth 1 --branch master https://github.com/harbour/core.git "$HB/src" + git -C "$HB/src" fetch --depth 1 origin "$HARBOUR_REF" + git -C "$HB/src" checkout "$HARBOUR_REF" + cd "$HB/src" + make -j"$(nproc)" install HB_INSTALL_PREFIX="$HB/install" HB_BUILD_3RDEXT=no # Resolve exact SDK paths (no layout guessing in CMake): # headers, MT VM archive, compiler binary. HB_INC="$(dirname "$(find "$HB/install" -name hbvm.h | head -1)")" @@ -217,9 +201,6 @@ jobs: cp "$BUILD"/tools/serverd/openads_serverd "$STAGE/" cp "$BUILD"/tools/bench/openads_bench "$STAGE/" cp LICENSE NOTICE README.md openads.ini.sample "$STAGE/" - mkdir -p "$STAGE/include/openads" - cp include/openads/ace.h "$STAGE/include/ace.h" - cp include/openads/{ace,error,openads_sql,platform,version}.h "$STAGE/include/openads/" tar czvf "$STAGE.tar.gz" "$STAGE" ls -la "$STAGE.tar.gz" echo "ASSET=$STAGE.tar.gz" >> "$GITHUB_ENV" @@ -279,9 +260,6 @@ jobs: Copy-Item "$build/tools/bench/Release/openads_bench.exe" "$stage/" Copy-Item LICENSE,NOTICE,README.md,openads.ini.sample $stage/ Copy-Item QUICKSTART.md "$stage/" -ErrorAction SilentlyContinue - New-Item -ItemType Directory "$stage/include/openads" -Force | Out-Null - Copy-Item include/openads/ace.h "$stage/include/ace.h" - Copy-Item include/openads/ace.h,include/openads/error.h,include/openads/openads_sql.h,include/openads/platform.h,include/openads/version.h "$stage/include/openads/" # Harbour HB_FUNC wrappers for the oads_* VFS API — compiled # into the app, not shipped in the DLL. Arch-independent C # source: both Windows zips carry it. (Pritpal Bedi.) @@ -307,198 +285,12 @@ jobs: name: openads-${{ runner.os }}-${{ matrix.arch }} path: ${{ env.ASSET }} - # glibc 2.31 compatibility build for older Linux servers (Ubuntu 20.04): - # the ubuntu-24.04 leg's binaries require GLIBC_2.38+, so the same tree - # is built inside an ubuntu:20.04 container. Asset is additive: publish - # flattens it into the release but does not require the leg to be green. - # (Requested by Pritpal Bedi for his LAN test server.) - build-glibc231: - if: ${{ github.event_name == 'workflow_dispatch' || !contains(github.ref, '-mtfix') }} - name: ubuntu 20.04 container / x64 (glibc 2.31) - runs-on: ubuntu-24.04 - container: ubuntu:20.04 - timeout-minutes: 90 - steps: - # Bare image: git/curl first so checkout + CMake FetchContent work. - - name: Install bootstrap tools - shell: bash - run: | - export DEBIAN_FRONTEND=noninteractive - apt-get update - apt-get install -y --no-install-recommends \ - git ca-certificates curl xz-utils - - - uses: actions/checkout@v5 - with: - ref: ${{ inputs.source_ref || inputs.tag || github.ref }} - # Full history + tags: the version stamp falls back to - # `git describe --tags`, and a shallow tag checkout can leave - # the tag unresolvable to the container's older git. - fetch-depth: 0 - - # The container's git refuses the runner-owned workspace ("dubious - # ownership"), which silently broke `git describe --tags` and shipped - # a plain-version stamp on the 20.04 asset. Mark it safe and re-fetch - # the tag refs so describe resolves even for a tag checkout. - - name: Make the tag resolvable to git in the container - shell: bash - run: | - git config --global --add safe.directory "$GITHUB_WORKSPACE" - git fetch --tags --force origin - git describe --tags - - - name: Resolve tag - id: tag - shell: bash - run: | - T="${{ inputs.tag }}" - if [ -z "$T" ]; then T="${GITHUB_REF#refs/tags/}"; fi - echo "tag=$T" >> "$GITHUB_OUTPUT" - echo "version=${T#v}" >> "$GITHUB_OUTPUT" - - # CMakePresets v4 needs CMake >= 3.23; 20.04's apt ships 3.16, so - # vendor the official binary (runs on any glibc >= 2.17). Compiler: - # focal's clang-10 rejects this tree's C++20 implicit-move returns - # (P1825, e.g. server_fs.cpp), so this leg builds with g++-10 (in - # the focal archive, implements P1825); warnings-as-errors stays - # off for the older toolchain. - - name: Install toolchain and CMake - shell: bash - run: | - export DEBIAN_FRONTEND=noninteractive - apt-get update - apt-get install -y --no-install-recommends \ - build-essential g++-10 ninja-build pkg-config \ - libncurses-dev libx11-dev bison flex python3 perl - curl -sSL -o /tmp/cmake.tar.gz \ - https://github.com/Kitware/CMake/releases/download/v3.28.6/cmake-3.28.6-linux-x86_64.tar.gz - mkdir -p /opt/cmake - tar xzf /tmp/cmake.tar.gz -C /opt/cmake --strip-components=1 - echo "/opt/cmake/bin" >> "$GITHUB_PATH" - /opt/cmake/bin/cmake --version - - # Same Harbour UDF SDK as the 24.04 leg, cached under its own key - # (binaries are glibc-specific). - - name: Cache Harbour SDK (glibc 2.31) - uses: actions/cache@v4 - with: - path: ${{ runner.temp }}/harbour-udf-glibc231 - key: harbour-udf-linux-glibc231-4ec2e154ed92757418bc30af571ab90240ab3209-v1 - - - name: Provision Harbour SDK (glibc 2.31) - shell: bash - env: - HARBOUR_REF: 4ec2e154ed92757418bc30af571ab90240ab3209 - run: | - set -e - HB="$RUNNER_TEMP/harbour-udf-glibc231" - echo "HB_ROOT=$HB/install" >> "$GITHUB_ENV" - if [ -f "$HB/install/include/hbvm.h" ] && \ - [ -n "$(find "$HB/install" -name 'libhbvmmt.a' 2>/dev/null | head -1)" ] && \ - [ -n "$(find "$HB/install" -name harbour -type f 2>/dev/null | head -1)" ]; then - echo "Harbour SDK cached at $HB/install" - else - rm -rf "$HB" - mkdir -p "$HB" - git clone --depth 1 --branch master https://github.com/harbour/core.git "$HB/src" - git -C "$HB/src" fetch --depth 1 origin "$HARBOUR_REF" - git -C "$HB/src" checkout "$HARBOUR_REF" - cd "$HB/src" - make -j"$(nproc)" install HB_INSTALL_PREFIX="$HB/install" HB_BUILD_3RDEXT=no - fi - HB_INC="$(dirname "$(find "$HB/install" -name hbvm.h | head -1)")" - HB_LIBDIR="$(dirname "$(find "$HB/install" -name 'libhbvmmt.a' | head -1)")" - HB_CC="$(find "$HB/install" -name harbour -type f -executable | head -1)" - echo "Harbour SDK layout:" - echo " include: ${HB_INC:-MISSING}" - echo " libdir: ${HB_LIBDIR:-MISSING}" - echo " compiler:${HB_CC:-MISSING}" - if [ -z "$HB_INC" ] || [ -z "$HB_LIBDIR" ] || [ -z "$HB_CC" ]; then - echo "::error::Harbour SDK layout unexpected - full listing:" - find "$HB/install" -maxdepth 4 | sort | head -80 - exit 1 - fi - { - echo "HARBOUR_INCLUDE_DIR=$HB_INC" - echo "HARBOUR_LIB_DIR=$HB_LIBDIR" - echo "HARBOUR_COMPILER=$HB_CC" - } >> "$GITHUB_ENV" - - - name: Configure - shell: bash - run: | - cmake --preset ninja-clang \ - -DOPENADS_VERSION_FORCE=${{ steps.tag.outputs.version }} \ - -DCMAKE_C_COMPILER=gcc-10 \ - -DCMAKE_CXX_COMPILER=g++-10 \ - -DOPENADS_WITH_TLS=ON \ - -DOPENADS_WITH_HTTP=ON \ - -DOPENADS_WARNINGS_AS_ERRORS=OFF \ - -DOPENADS_WITH_HARBOUR_UDF=ON \ - -DHARBOUR_INCLUDE_DIR="$HARBOUR_INCLUDE_DIR" \ - -DHARBOUR_LIB_DIR="$HARBOUR_LIB_DIR" \ - -DHARBOUR_COMPILER="$HARBOUR_COMPILER" - - - name: Build - run: cmake --build build/ninja-clang --config Release - - - name: Test - timeout-minutes: 30 - run: ctest --test-dir build/ninja-clang --output-on-failure -C Release - - - name: Stage release files - shell: bash - run: | - STAGE="openads-${{ steps.tag.outputs.version }}-linux-glibc231" - mkdir -p "$STAGE" - BUILD="build/ninja-clang" - for f in "$BUILD"/src/libopenace64.*; do - [ -e "$f" ] || continue - cp "$f" "$STAGE/" - cp "$f" "$STAGE/$(basename "$f" | sed 's/libopenace64/libace64/')" - done - cp "$BUILD"/tools/serverd/openads_serverd "$STAGE/" - cp "$BUILD"/tools/bench/openads_bench "$STAGE/" - cp LICENSE NOTICE README.md openads.ini.sample "$STAGE/" - mkdir -p "$STAGE/include/openads" - cp include/openads/ace.h "$STAGE/include/ace.h" - cp include/openads/{ace,error,openads_sql,platform,version}.h "$STAGE/include/openads/" - tar czvf "$STAGE.tar.gz" "$STAGE" - ls -la "$STAGE.tar.gz" - echo "ASSET=$STAGE.tar.gz" >> "$GITHUB_ENV" - - # The whole point of the leg: prove the binaries stay within - # glibc 2.31 before they ship. - - name: Verify glibc ceiling (<= 2.31) - shell: bash - run: | - set -e - STAGE="openads-${{ steps.tag.outputs.version }}-linux-glibc231" - fail=0 - for b in "$STAGE"/openads_serverd "$STAGE"/openads_bench \ - "$STAGE"/libopenace64.so "$STAGE"/libace64.so; do - hit=$(objdump -T "$b" | grep -oE 'GLIBC_2\.(3[2-9]|[4-9][0-9])' | sort -uV || true) - if [ -n "$hit" ]; then - echo "::error::$b references > GLIBC_2.31: $hit" - fail=1 - fi - done - [ "$fail" -eq 0 ] && echo "All staged binaries within GLIBC_2.31." - - - name: Upload artifact (workflow run) - if: always() && env.ASSET != '' - uses: actions/upload-artifact@v4 - with: - name: openads-Linux-x64-glibc231 - path: ${{ env.ASSET }} - - publish: name: Publish GitHub Release needs: build # A cancelled/failed best-effort leg (macOS) must not skip publish; # only a genuine workflow-run cancellation should. - if: ${{ always() && needs.build.result != 'skipped' && (github.event_name == 'workflow_dispatch' || !contains(github.ref, '-mtfix')) }} + if: ${{ always() && needs.build.result != 'skipped' }} runs-on: ubuntu-24.04 steps: - uses: actions/checkout@v5 @@ -545,54 +337,15 @@ jobs: fi echo "path=$F" >> "$GITHUB_OUTPUT" + # macOS is temporarily optional (its unit tests stall in CI): the + # macOS leg and asset are no longer required, and the release + # notes must say so. Restore both lines once macOS is fixed. # All-or-nothing release: every leg must be green AND every # canonical asset present, otherwise fail loudly instead of # shipping a partial kit (v1.09.50 went out without the Linux # tarball because publish ran on 3 of 4 legs). macOS keeps its # stall-tolerant test step, but a real macOS build break still # blocks here via the job conclusion. - - name: Open every archive and verify contents and version - env: - RELEASE_VERSION: ${{ steps.ver.outputs.version }} - shell: bash - run: | - python3 - <<'PYVERIFY' - import os, pathlib, tarfile, zipfile, hashlib - version = os.environ['RELEASE_VERSION'] - assets = list(pathlib.Path('out').glob('*')) - assert assets, 'No archives' - for path in assets: - if path.suffix == '.zip': - with zipfile.ZipFile(path) as z: - assert z.testzip() is None, 'ZIP CRC failure' - files = {n: z.read(n) for n in z.namelist() if not n.endswith('/')} - else: - with tarfile.open(path) as t: - files = {m.name: t.extractfile(m).read() for m in t.getmembers() if m.isfile()} - byname = {pathlib.PurePosixPath(n).name: data for n, data in files.items()} - required = ['LICENSE','NOTICE','README.md','openads.ini.sample'] - windows = '-windows-' in path.name - if windows: - bits = '64' if '-x64.' in path.name else '32' - required += [f'ace{bits}.dll',f'openace{bits}.dll',f'ace{bits}.lib',f'openace{bits}.lib',f'libopenace{bits}.a','openads_serverd.exe','openads_bench.exe','oads_hb.c'] - assert byname[f'ace{bits}.dll'] == byname[f'openace{bits}.dll'], 'ACE aliases differ' - binary = byname['openads_serverd.exe'] - else: - ext = 'dylib' if '-macos-' in path.name else 'so' - required += [f'libace64.{ext}',f'libopenace64.{ext}','openads_serverd','openads_bench'] - assert byname[f'libace64.{ext}'] == byname[f'libopenace64.{ext}'], 'ACE aliases differ' - binary = byname['openads_serverd'] - for name in required: - assert name in byname and byname[name], f'{path}: missing/empty {name}' - for header in ['ace','error','openads_sql','platform','version']: - assert any(n.endswith(f'include/openads/{header}.h') for n in files), f'Missing header {header}' - assert version.encode() in binary, f'{path}: server version stamp absent' - assert version.encode() in byname[('openace'+bits+'.dll') if windows else ('libopenace64.'+ext)], f'{path}: ACE version stamp absent' - if '-macos-' in path.name: - assert binary[:4] in [bytes.fromhex('cafebabe'),bytes.fromhex('cafebabf')], 'Not a universal server' - print(f'VERIFIED {path.name}: {len(files)} files; version {version}; sha256 {hashlib.sha256(path.read_bytes()).hexdigest()}', flush=True) - PYVERIFY - - name: Require all legs green and all assets present env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} @@ -603,8 +356,7 @@ jobs: for asset in \ "openads-${V}-windows-x64.zip" \ "openads-${V}-windows-x86.zip" \ - "openads-${V}-linux-x64.tar.gz" \ - "openads-${V}-macos-universal.tar.gz"; do + "openads-${V}-linux-x64.tar.gz"; do if [ ! -f "out/${asset}" ]; then echo "::error::Missing required release asset: ${asset}" missing=1 @@ -618,8 +370,7 @@ jobs: for leg in \ "windows-2022 / x64" \ "windows-2022 / x86" \ - "ubuntu-24.04 / x64" \ - "macos-14 / universal"; do + "ubuntu-24.04 / x64"; do concl=$(gh api "$JOBS_URL" --jq \ ".jobs[] | select(.name == \"$leg\") | .conclusion") if [ "$concl" != "success" ]; then @@ -648,33 +399,10 @@ jobs: exit 1 fi - - name: Refuse to replace an existing public test build - if: ${{ contains(steps.tag.outputs.tag, '-mtfix') }} - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - TEST_TAG: ${{ steps.tag.outputs.tag }} - shell: bash - run: | - if gh release view "$TEST_TAG" --json isDraft > existing-release.json 2>release-error.txt; then - if ! jq -e '.isDraft == true' existing-release.json >/dev/null; then - echo "::error::Public test-build assets are immutable; use a new mtfix tag." - exit 1 - fi - elif ! grep -qi 'release not found' release-error.txt; then - cat release-error.txt - echo "::error::Could not verify existing release state; refusing upload." - exit 1 - fi - - name: Publish release uses: softprops/action-gh-release@v2 with: tag_name: ${{ steps.tag.outputs.tag }} - target_commitish: ${{ inputs.source_ref || github.sha }} - # Test builds stay private until packages and notes are verified. - draft: ${{ contains(steps.tag.outputs.tag, '-mtfix') }} - prerelease: ${{ contains(steps.tag.outputs.tag, '-mtfix') }} - make_latest: ${{ contains(steps.tag.outputs.tag, '-mtfix') && 'false' || 'legacy' }} body_path: ${{ steps.notes.outputs.path }} files: out/* From fb2ee6b99f347e5b88d70a77578af5c2733ec35c Mon Sep 17 00:00:00 2001 From: FiveTech Software Date: Thu, 1 Oct 2026 19:46:38 +0200 Subject: [PATCH 94/97] remove fork secret-scan workflow (not part of the mtfix23 sync) --- .github/workflows/secret-scan.yml | 40 ------------------------------- 1 file changed, 40 deletions(-) delete mode 100644 .github/workflows/secret-scan.yml diff --git a/.github/workflows/secret-scan.yml b/.github/workflows/secret-scan.yml deleted file mode 100644 index a7f3e529..00000000 --- a/.github/workflows/secret-scan.yml +++ /dev/null @@ -1,40 +0,0 @@ -name: Secret scan - -on: - pull_request: - push: - branches: [main] - workflow_dispatch: - -permissions: - contents: read - -jobs: - gitleaks-current-tree: - name: Gitleaks (current tree) - runs-on: ubuntu-24.04 - steps: - - name: Check out source - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.2.2 - with: - fetch-depth: 1 - persist-credentials: false - - - name: Install Gitleaks 8.24.2 - shell: bash - run: | - set -euo pipefail - archive="$RUNNER_TEMP/gitleaks.tar.gz" - curl --fail --location --silent --show-error \ - https://github.com/gitleaks/gitleaks/releases/download/v8.24.2/gitleaks_8.24.2_linux_x64.tar.gz \ - --output "$archive" - echo "fa0500f6b7e41d28791ebc680f5dd9899cd42b58629218a5f041efa899151a8e $archive" | sha256sum --check --strict - tar -xzf "$archive" -C "$RUNNER_TEMP" gitleaks - "$RUNNER_TEMP/gitleaks" version - - # Scan files in the proposed tree. Full-history scanning is enabled only - # after the separately reviewed history rewrite has removed known leaks. - - name: Scan current tree - shell: bash - run: | - "$RUNNER_TEMP/gitleaks" dir . --redact --exit-code 1 From 55029bee610de65aec90f6d9c040685714991517 Mon Sep 17 00:00:00 2001 From: FiveTech Software Date: Thu, 1 Oct 2026 19:49:36 +0200 Subject: [PATCH 95/97] platform: process-scoped byte locks on macOS (OFD made the engine wait on itself); keep 30s bounded wait Updated locking mechanism to use OFD locks conditionally based on platform support. Improved comments for clarity on macOS behavior with locks. --- src/platform/lock_posix.cpp | 31 ++++++++++++++++++------------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/src/platform/lock_posix.cpp b/src/platform/lock_posix.cpp index 1f29f579..b76fbab5 100644 --- a/src/platform/lock_posix.cpp +++ b/src/platform/lock_posix.cpp @@ -10,6 +10,17 @@ #include #include +// OFD (open-file-description) locks are used only where they are proven +// to work. The macOS SDK defines F_OFD_SETLK, but there they made the +// engine wait on its own handles (a fresh table refused its first +// append lock and CI sat on it), so macOS keeps the process-scoped +// F_SETLK/F_SETLKW locks the engine was written for. +#if defined(F_OFD_SETLK) && !defined(__APPLE__) +#define OPENADS_USE_OFD_LOCKS 1 +#else +#define OPENADS_USE_OFD_LOCKS 0 +#endif + namespace openads::platform { namespace { @@ -28,7 +39,7 @@ util::Error os_error(const char* op) { // should still contend (Win32 LockFile is fd-scoped). OFD locks // are tied to the open file description, matching the Win32 // semantics used by the engine. -#ifdef F_OFD_SETLK +#if OPENADS_USE_OFD_LOCKS constexpr int kSetLk = F_OFD_SETLK; [[maybe_unused]] constexpr int kSetLkW = F_OFD_SETLKW; #else @@ -103,14 +114,13 @@ void ByteLock::release_() noexcept { util::Result ByteLock::acquire(File& f, std::uint64_t offset, std::uint64_t length, LockKind kind) { -#if defined(F_OFD_SETLK) && !defined(__APPLE__) +#if OPENADS_USE_OFD_LOCKS return do_lock(f, offset, length, kind, kSetLkW); #else - // macOS: its SDK defines the OFD commands, but a blocking F_OFD_SETLKW - // waits forever when a leaked holder lives in this same process (CI - // test runs sat on it until the job timeout). Poll the non-blocking - // command and give up after a bounded wait, so a stall becomes a lock - // error instead of a hang. + // Process-scoped locks (macOS) can still wait forever on a stale + // holder in another process. Poll the non-blocking command and give + // up after a bounded wait, so a stall becomes a lock error instead of + // a hang. const auto deadline = std::chrono::steady_clock::now() + std::chrono::seconds(30); for (;;) { @@ -128,12 +138,7 @@ util::Result ByteLock::acquire(File& f, std::uint64_t offset, int fd = static_cast( reinterpret_cast(f.native_handle()) - 1); long holder = -1; -#ifdef F_OFD_GETLK - q.l_pid = 0; - const int kGetLk = F_OFD_GETLK; -#else const int kGetLk = F_GETLK; -#endif if (::fcntl(fd, kGetLk, &q) == 0 && q.l_type != F_UNLCK) { holder = static_cast(q.l_pid); } @@ -166,7 +171,7 @@ util::Result ByteLock::probe(File& f, std::uint64_t offset, fl.l_pid = 0; // native_handle() stores (fd + 1) to avoid the nullptr/fd-0 collision. int fd = static_cast(reinterpret_cast(f.native_handle()) - 1); -#ifdef F_OFD_SETLK +#if OPENADS_USE_OFD_LOCKS // F_OFD_GETLK reports conflicts against OTHER open file descriptions - // exactly "another handle/session holds this byte". The querying fd's // own locks are invisible to it; callers check their own list first. From 07027e41d69525ce838491f173cf3a79bb2dc253 Mon Sep 17 00:00:00 2001 From: FiveTech Software Date: Thu, 1 Oct 2026 20:36:08 +0200 Subject: [PATCH 96/97] ci: macos-diag also runs on push to mtfix23-sync (temporary) --- .github/workflows/macos-diag.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/macos-diag.yml b/.github/workflows/macos-diag.yml index cad62c24..fabd88fe 100644 --- a/.github/workflows/macos-diag.yml +++ b/.github/workflows/macos-diag.yml @@ -4,6 +4,8 @@ name: macos-diag # per-assertion output and, if they stall, dumps thread stacks. on: workflow_dispatch: + push: + branches: [mtfix23-sync] jobs: diag: From edfd5f15877d98306fe6c00da48b2a6e4430bc03 Mon Sep 17 00:00:00 2001 From: FiveTech Software Date: Thu, 1 Oct 2026 21:04:39 +0200 Subject: [PATCH 97/97] platform: log errno/getlk details on byte lock timeout (diagnostic) Refactor lock checking logic to improve error handling and clarity. --- src/platform/lock_posix.cpp | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/src/platform/lock_posix.cpp b/src/platform/lock_posix.cpp index b76fbab5..ca640a27 100644 --- a/src/platform/lock_posix.cpp +++ b/src/platform/lock_posix.cpp @@ -138,16 +138,20 @@ util::Result ByteLock::acquire(File& f, std::uint64_t offset, int fd = static_cast( reinterpret_cast(f.native_handle()) - 1); long holder = -1; - const int kGetLk = F_GETLK; - if (::fcntl(fd, kGetLk, &q) == 0 && q.l_type != F_UNLCK) { + const int get_rc = ::fcntl(fd, F_GETLK, &q); + const int get_errn = (get_rc == -1) ? errno : 0; + if (get_rc == 0 && q.l_type != F_UNLCK) { holder = static_cast(q.l_pid); } std::fprintf(stderr, "openads: byte lock wait timed out (offset=%llu len=%llu " - "holder_pid=%ld self_pid=%ld)\n", + "set_errno=%d get_rc=%d get_errno=%d get_type=%d " + "holder_pid=%ld self_pid=%ld fd=%d)\n", static_cast(offset), - static_cast(length), holder, - static_cast(::getpid())); + static_cast(length), + r.error().sub_code, get_rc, get_errn, + static_cast(q.l_type), holder, + static_cast(::getpid()), fd); return r; } std::this_thread::sleep_for(std::chrono::milliseconds(5));