From 109f39854fe88174a20ff09eb23345adde91aa19 Mon Sep 17 00:00:00 2001 From: DEVAI Date: Thu, 18 Jun 2026 13:37:17 -0300 Subject: [PATCH 1/8] =?UTF-8?q?feat(tools):=20add=20openmonitor=20?= =?UTF-8?q?=E2=80=94=20TUI=20and=20web=20dashboard=20for=20openads=5Fserve?= =?UTF-8?q?rd?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wire Mg telemetry + Studio HTTP sessions. See tools/openmonitor/README.md. --- .gitignore | 3 + README.md | 1 + tools/openmonitor/.cargo/config.toml | 2 + tools/openmonitor/Cargo.lock | 2134 +++++++++++++++++++++++ tools/openmonitor/Cargo.toml | 28 + tools/openmonitor/README.md | 78 + tools/openmonitor/assets/dashboard.html | 333 ++++ tools/openmonitor/build.bat | 15 + tools/openmonitor/src/history.rs | 96 + tools/openmonitor/src/http_api.rs | 112 ++ tools/openmonitor/src/main.rs | 513 ++++++ tools/openmonitor/src/snapshot.rs | 222 +++ tools/openmonitor/src/web.rs | 109 ++ tools/openmonitor/src/wire.rs | 230 +++ 14 files changed, 3876 insertions(+) create mode 100644 tools/openmonitor/.cargo/config.toml create mode 100644 tools/openmonitor/Cargo.lock create mode 100644 tools/openmonitor/Cargo.toml create mode 100644 tools/openmonitor/README.md create mode 100644 tools/openmonitor/assets/dashboard.html create mode 100644 tools/openmonitor/build.bat create mode 100644 tools/openmonitor/src/history.rs create mode 100644 tools/openmonitor/src/http_api.rs create mode 100644 tools/openmonitor/src/main.rs create mode 100644 tools/openmonitor/src/snapshot.rs create mode 100644 tools/openmonitor/src/web.rs create mode 100644 tools/openmonitor/src/wire.rs diff --git a/.gitignore b/.gitignore index 8cf04512..25eee3fe 100644 --- a/.gitignore +++ b/.gitignore @@ -93,6 +93,9 @@ examples/fivewin/err.txt .DS_Store Thumbs.db +# Rust (openmonitor) +tools/openmonitor/target/ + # Compiler / linker artefacts *.obj *.o diff --git a/README.md b/README.md index 55527c8e..f10598c9 100644 --- a/README.md +++ b/README.md @@ -1195,6 +1195,7 @@ OpenADS/ │ ├── import_dd/ # openads_import_dd — SAP .add → OpenADS import │ │ └── main.cpp │ ├── bench/ # openads_bench — synthetic SQL benchmark +│ ├── openmonitor/ # openmonitor — TUI + web dashboard for openads_serverd │ ├── harbour_patch/ # rddads compatibility patches + ADS baseline fixture │ ├── scripts/ │ │ ├── systemd/ # openads-serverd.service (Linux) diff --git a/tools/openmonitor/.cargo/config.toml b/tools/openmonitor/.cargo/config.toml new file mode 100644 index 00000000..0c17df09 --- /dev/null +++ b/tools/openmonitor/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.x86_64-pc-windows-msvc] +rustflags = ["-C", "target-feature=+crt-static"] \ No newline at end of file diff --git a/tools/openmonitor/Cargo.lock b/tools/openmonitor/Cargo.lock new file mode 100644 index 00000000..62193f49 --- /dev/null +++ b/tools/openmonitor/Cargo.lock @@ -0,0 +1,2134 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys 0.61.2", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "bitflags" +version = "2.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593" + +[[package]] +name = "cassowary" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df8670b8c7b9dae1793364eafadf7239c40d669904660c5960d74cfd80b46a53" + +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + +[[package]] +name = "cc" +version = "1.2.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dad887fd958be91b5098c0248def011f4523ab786cd411be668777e55063501f" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + +[[package]] +name = "clap" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "compact_str" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7fd622ebbb56a5b2ccb651b32b911cdeb2a9b4b11776b2473bf26a26a286244e" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "rustversion", + "ryu", + "static_assertions", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "crossterm" +version = "0.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" +dependencies = [ + "bitflags", + "crossterm_winapi", + "mio", + "parking_lot", + "rustix 0.38.44", + "signal-hook", + "signal-hook-mio", + "winapi", +] + +[[package]] +name = "crossterm_winapi" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b" +dependencies = [ + "winapi", +] + +[[package]] +name = "darling" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +dependencies = [ + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn", +] + +[[package]] +name = "darling_macro" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +dependencies = [ + "darling_core", + "quote", + "syn", +] + +[[package]] +name = "displaydoc" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-core", + "futures-io", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "h2" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6cb093c84e8bd9b188d4c4a8cb6579fc016968d14c99882163cd3ff402a4f155" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "allocator-api2", + "equivalent", + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "h2", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "system-configuration", + "tokio", + "tower-service", + "tracing", + "windows-registry", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", +] + +[[package]] +name = "indoc" +version = "2.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79cf5c93f93228cf8efb3ba362535fb11199ac548a09ce117c9b1adc3030d706" +dependencies = [ + "rustversion", +] + +[[package]] +name = "instability" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eb2d60ef19920a3a9193c3e371f726ec1dafc045dac788d0fb3704272458971" +dependencies = [ + "darling", + "indoc", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "03d04c30968dffe80775bd4d7fb676131cd04a1fb46d2686dbffbaec2d9dfd31" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "linux-raw-sys" +version = "0.4.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26c52dbd32dccf2d10cac7725f8eae5296885fb5703b261f7d0a0739ec807ab" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" + +[[package]] +name = "lru" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "234cf4f4a04dc1f57e24b96cc0cd600cf2af460d4161ac5ecdd0af8e1f3b2a38" +dependencies = [ + "hashbrown 0.15.5", +] + +[[package]] +name = "matchit" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mio" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +dependencies = [ + "libc", + "log", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "openmonitor" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "chrono", + "clap", + "crossterm", + "ratatui", + "reqwest", + "serde", + "serde_json", + "tokio", +] + +[[package]] +name = "openssl" +version = "0.10.81" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77823a27f0babb03091cb9ed9ef80af3b39dbc82f97e8fa530374b7dafd87a45" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b47e7e6bb2c38cd930d25a23b40fa52e068c10e85f3e03a7f5ba5aaca5713695" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "ratatui" +version = "0.29.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eabd94c2f37801c20583fc49dd5cd6b0ba68c716787c2dd6ed18571e1e63117b" +dependencies = [ + "bitflags", + "cassowary", + "compact_str", + "crossterm", + "indoc", + "instability", + "itertools", + "lru", + "paste", + "strum", + "unicode-segmentation", + "unicode-truncate", + "unicode-width 0.2.0", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustix" +version = "0.38.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.4.15", + "windows-sys 0.59.0", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys 0.12.1", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls" +version = "0.23.40" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +dependencies = [ + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "serde_json" +version = "1.0.150" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "signal-hook" +version = "0.3.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2" +dependencies = [ + "libc", + "signal-hook-registry", +] + +[[package]] +name = "signal-hook-mio" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc" +dependencies = [ + "libc", + "mio", + "signal-hook", +] + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "socket2" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "strum" +version = "0.26.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fec0f0aef304996cf250b31b5a10dee7980c85da9d759361292b8bca5a18f06" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6bee85a5a24955dc440386795aa378cd9cf82acd5f764469152d2270e581be" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "rustversion", + "syn", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.118" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix 1.1.4", + "windows-sys 0.61.2", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-core", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-truncate" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3644627a5af5fa321c95b9b235a72fd24cd29c648c2c379431e6628655627bf" +dependencies = [ + "itertools", + "unicode-segmentation", + "unicode-width 0.1.14", +] + +[[package]] +name = "unicode-width" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd6e30e90baa6f72411720665d41d89b9a3d039dc45b8faea1ddd07f617f6af" + +[[package]] +name = "unicode-width" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fc81956842c57dac11422a97c3b8195a1ff727f06e85c84ed2e8aa277c9a0fd" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ddb3f79143bced6de84270411622a2699cee572fc0875aeaf1e7867cf9fca1a" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "503b14d284f2c8dac03b819967e155ea753f573586193b2b2c95990cb5d69280" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e21a184b13fb19e157296e2c46056aec9092264fab83e4ba59e68c61b323c3d" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fecefd9c35bd935a20fc3fc344b5f29138961e4f47fb03297d88f2587afb5ebd" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.125" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23939e44bb9a5d7576fa2b563dc2e136628f1224e88a8deed09e04858b77871f" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6430a72df5eb332242960fe84b3002a241163998241eb596d4f739b9757061d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.59.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/tools/openmonitor/Cargo.toml b/tools/openmonitor/Cargo.toml new file mode 100644 index 00000000..534d9778 --- /dev/null +++ b/tools/openmonitor/Cargo.toml @@ -0,0 +1,28 @@ +[package] +name = "openmonitor" +version = "0.1.0" +edition = "2021" +description = "Console monitor for openads server (wire telemetry + HTTP sessions)" + +[[bin]] +name = "openmonitor" +path = "src/main.rs" + +[dependencies] +anyhow = "1" +axum = "0.8" +chrono = { version = "0.4", default-features = false, features = ["clock"] } +clap = { version = "4", features = ["derive"] } +crossterm = "0.28" +ratatui = "0.29" +reqwest = { version = "0.12", features = ["blocking", "json"] } +serde = { version = "1", features = ["derive"] } +serde_json = "1" +tokio = { version = "1", features = ["rt-multi-thread", "net"] } + +[profile.release] +opt-level = "z" +lto = true +codegen-units = 1 +panic = "abort" +strip = true \ No newline at end of file diff --git a/tools/openmonitor/README.md b/tools/openmonitor/README.md new file mode 100644 index 00000000..e0e72860 --- /dev/null +++ b/tools/openmonitor/README.md @@ -0,0 +1,78 @@ +# openmonitor + +Terminal and web monitor for a running **openads server** (`openads_serverd`). + +Reads live telemetry from: + +- **Wire** — `MgRequest` / `MgSnapshot` (uptime, connections, open tables, packet counters) +- **HTTP** — Studio API (`/api/health`, `/api/server/sessions`, kill session) + +Useful when developing Harbour / OpenADS clients over `tcp://` or debugging a local server instance. + +## Build + +Requires [Rust](https://rustup.rs/) (`cargo` on `PATH`). On Windows MSVC link tools are recommended for the static CRT build (Visual Studio Build Tools or `vcvars64.bat`). + +```bat +cd tools\openmonitor +build.bat +``` + +```sh +cd tools/openmonitor +cargo build --release +``` + +Binary: `target/release/openmonitor` (`.exe` on Windows). + +## Usage + +Start `openads_serverd` first, then: + +```text +openmonitor --wire-port 16262 --http http://127.0.0.1:16263 +``` + +One-shot snapshot (scripts / CI): + +```text +openmonitor --wire-port 16262 --http http://127.0.0.1:16263 --once +``` + +Web dashboard (default bind `127.0.0.1:9850`): + +```text +openmonitor --wire-port 16262 --http http://127.0.0.1:16263 --web +``` + +### Common flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--wire-host` | `127.0.0.1` | openads server wire host | +| `--wire-port` | `16262` | openads server wire port | +| `--http` | — | Studio HTTP base URL (enables session list + kill in TUI) | +| `--web` | off | Serve browser dashboard | +| `--web-port` | `9850` | Web UI port | +| `--once` | off | Print one snapshot and exit | +| `--interval` | `2` | TUI refresh seconds | + +### TUI keys + +| Key | Action | +|-----|--------| +| `q` / `Esc` | Quit | +| `r` | Refresh now | +| `k` | Kill selected HTTP session (requires `--http`) | + +## Wireshark + +With the server listening on the wire port: + +``` +tcp.port == 16262 +``` + +## Related + +Full local lab scripts (server launchers, optional Harbour probe) live in the companion repo [openads-wire-lab](https://github.com/Admnwk/openads-wire-lab). \ No newline at end of file diff --git a/tools/openmonitor/assets/dashboard.html b/tools/openmonitor/assets/dashboard.html new file mode 100644 index 00000000..147ec948 --- /dev/null +++ b/tools/openmonitor/assets/dashboard.html @@ -0,0 +1,333 @@ + + + + + +openmonitor + + + +
+
+
+

openmonitor

+

openads server

+
+
+ + + +
+
+ +
+
+
+
+

Atividade wire

+
+

Usuários wire

+
+
+
+

Sessões HTTP

+
+

Studio

+
+
+
+
+ openmonitor + +
+
+ + + \ No newline at end of file diff --git a/tools/openmonitor/build.bat b/tools/openmonitor/build.bat new file mode 100644 index 00000000..ddd01dc9 --- /dev/null +++ b/tools/openmonitor/build.bat @@ -0,0 +1,15 @@ +@echo off +setlocal enableDelayedExpansion + +where cargo >nul 2>&1 +if errorlevel 1 ( + echo [ERROR] cargo not found on PATH — install Rust from https://rustup.rs/ + exit /b 1 +) + +cd /d "%~dp0" +echo [BUILD] openmonitor release... +cargo build --release %* +if errorlevel 1 exit /b 1 +echo [OK] target\release\openmonitor.exe +exit /b 0 \ No newline at end of file diff --git a/tools/openmonitor/src/history.rs b/tools/openmonitor/src/history.rs new file mode 100644 index 00000000..986db80f --- /dev/null +++ b/tools/openmonitor/src/history.rs @@ -0,0 +1,96 @@ +use crate::wire::MgSnapshot; +use std::time::Instant; + +const DEFAULT_CAPACITY: usize = 60; + +pub struct MetricHistory { + capacity: usize, + connections: Vec, + users: Vec, + ops_per_sec: Vec, + pkt_per_sec: Vec, + rss_kb: Vec, + last_ops: u64, + last_packets: u64, + last_sample: Option, +} + +impl Default for MetricHistory { + fn default() -> Self { + Self::new(DEFAULT_CAPACITY) + } +} + +impl MetricHistory { + pub fn new(capacity: usize) -> Self { + Self { + capacity: capacity.max(8), + connections: Vec::new(), + users: Vec::new(), + ops_per_sec: Vec::new(), + pkt_per_sec: Vec::new(), + rss_kb: Vec::new(), + last_ops: 0, + last_packets: 0, + last_sample: None, + } + } + + pub fn push(&mut self, snap: &MgSnapshot) { + let now = Instant::now(); + let (ops_rate, pkt_rate) = match self.last_sample { + Some(prev) => { + let secs = now.duration_since(prev).as_secs_f64().max(0.5); + let ops = snap.operations.saturating_sub(self.last_ops) as f64 / secs; + let pkts = snap + .packets_in + .saturating_add(snap.packets_out) + .saturating_sub(self.last_packets) as f64 + / secs; + (ops.round() as u64, pkts.round() as u64) + } + None => (0, 0), + }; + self.last_ops = snap.operations; + self.last_packets = snap.packets_in.saturating_add(snap.packets_out); + self.last_sample = Some(now); + + trim_push(&mut self.connections, snap.connections as u64, self.capacity); + trim_push(&mut self.users, snap.users as u64, self.capacity); + trim_push(&mut self.ops_per_sec, ops_rate, self.capacity); + trim_push(&mut self.pkt_per_sec, pkt_rate, self.capacity); + trim_push(&mut self.rss_kb, snap.rss_bytes / 1024, self.capacity); + } + + pub fn connections(&self) -> &[u64] { + &self.connections + } + + pub fn users(&self) -> &[u64] { + &self.users + } + + pub fn ops_per_sec(&self) -> &[u64] { + &self.ops_per_sec + } + + pub fn pkt_per_sec(&self) -> &[u64] { + &self.pkt_per_sec + } + + pub fn rss_kb(&self) -> &[u64] { + &self.rss_kb + } + + pub fn max_in(series: &[u64]) -> u64 { + series.iter().copied().max().unwrap_or(1).max(1) + } +} + +fn trim_push(series: &mut Vec, value: u64, capacity: usize) { + series.push(value); + if series.len() > capacity { + let drop = series.len() - capacity; + series.drain(0..drop); + } +} \ No newline at end of file diff --git a/tools/openmonitor/src/http_api.rs b/tools/openmonitor/src/http_api.rs new file mode 100644 index 00000000..696ef04e --- /dev/null +++ b/tools/openmonitor/src/http_api.rs @@ -0,0 +1,112 @@ +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Health { + pub status: String, + pub engine: String, + pub mode: String, + pub data_dir: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Session { + pub id: u64, + pub peer_ip: String, + pub peer_port: u16, + pub user: String, + pub data_dir: String, + pub connected_secs: i64, + pub idle_secs: i64, + pub frames_in: u64, + pub frames_out: u64, + pub open_tables: u32, +} + +#[derive(Debug, Clone, Default)] +pub struct HttpSnapshot { + pub health: Option, + pub sessions: Vec, + pub server_info: Option, + pub error: Option, +} + +pub struct HttpClient { + base: String, + client: reqwest::blocking::Client, +} + +impl HttpClient { + pub fn new(base: &str) -> Self { + let base = base.trim_end_matches('/').to_string(); + Self { + base, + client: reqwest::blocking::Client::builder() + .timeout(std::time::Duration::from_secs(5)) + .build() + .unwrap_or_default(), + } + } + + pub fn poll(&self) -> HttpSnapshot { + let mut snap = HttpSnapshot::default(); + match self.client.get(format!("{}/api/health", self.base)).send() { + Ok(r) if r.status().is_success() => { + snap.health = r.json().ok(); + } + Ok(r) => snap.error = Some(format!("health HTTP {}", r.status())), + Err(e) => snap.error = Some(format!("health: {e}")), + } + match self + .client + .get(format!("{}/api/server/sessions", self.base)) + .send() + { + Ok(r) if r.status().is_success() => { + if let Ok(v) = r.json::() { + if let Some(arr) = v.get("sessions").and_then(|a| a.as_array()) { + for item in arr { + if let Ok(s) = serde_json::from_value::(item.clone()) { + snap.sessions.push(s); + } + } + } + } + } + Ok(r) => { + if snap.error.is_none() { + snap.error = Some(format!("sessions HTTP {}", r.status())); + } + } + Err(e) => { + if snap.error.is_none() { + snap.error = Some(format!("sessions: {e}")); + } + } + } + match self + .client + .get(format!("{}/api/server/info", self.base)) + .send() + { + Ok(r) if r.status().is_success() => snap.server_info = r.json().ok(), + _ => {} + } + snap + } + + pub fn kill_session(&self, id: u64) -> Result<()> { + let url = format!("{}/api/server/sessions/{id}/kill", self.base); + let r = self + .client + .post(&url) + .send() + .with_context(|| format!("POST {url}"))?; + if r.status().is_success() { + Ok(()) + } else { + Err(anyhow::anyhow!("kill session failed: HTTP {}", r.status())) + } + } +} \ No newline at end of file diff --git a/tools/openmonitor/src/main.rs b/tools/openmonitor/src/main.rs new file mode 100644 index 00000000..079098f6 --- /dev/null +++ b/tools/openmonitor/src/main.rs @@ -0,0 +1,513 @@ +mod history; +mod http_api; +mod snapshot; +mod web; +mod wire; + +use anyhow::Result; +use clap::Parser; +use crossterm::event::{self, Event, KeyCode, KeyEventKind}; +use crossterm::terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen}; +use crossterm::ExecutableCommand; +use http_api::HttpClient; +use ratatui::layout::{Constraint, Direction, Layout}; +use ratatui::style::{Color, Modifier, Style}; +use ratatui::symbols; +use ratatui::text::{Line, Span}; +use ratatui::widgets::{Block, Borders, Cell, Paragraph, Row, Sparkline, Table}; +use ratatui::Frame; +use snapshot::{MonitorConfig, MonitorState}; +use std::io::stdout; +use std::time::{Duration, Instant}; + +#[derive(Parser, Debug)] +#[command(name = "openmonitor", about = "Monitor for openads server")] +struct Args { + /// Wire host (default 127.0.0.1) + #[arg(long, default_value = "127.0.0.1")] + wire_host: String, + + /// Wire TCP port (openads server lab default 16262) + #[arg(long, default_value_t = 16262)] + wire_port: u16, + + /// HTTP base URL for Studio API (optional, e.g. http://127.0.0.1:6263) + #[arg(long)] + http: Option, + + /// Print one snapshot as text and exit (for scripts / CI) + #[arg(long)] + once: bool, + + /// Web dashboard (sober UI in the browser) + #[arg(long)] + web: bool, + + /// Web UI bind host + #[arg(long, default_value = "127.0.0.1")] + web_host: String, + + /// Web UI port (default 9850 — avoids 8080/16263 collisions) + #[arg(long, default_value_t = 9850)] + web_port: u16, + + /// Refresh interval in seconds + #[arg(long, default_value_t = 2)] + interval: u64, +} + +impl Args { + fn monitor_config(&self) -> MonitorConfig { + MonitorConfig { + wire_host: self.wire_host.clone(), + wire_port: self.wire_port, + http: self.http.clone(), + interval_secs: self.interval, + } + } +} + +struct TuiState { + inner: MonitorState, + selected_session: usize, + last_refresh: Instant, + status_msg: String, + show_graphs: bool, +} + +impl TuiState { + fn refresh(&mut self, cfg: &MonitorConfig) { + self.inner.refresh(cfg); + self.last_refresh = Instant::now(); + } +} + +fn main() -> Result<()> { + let args = Args::parse(); + let cfg = args.monitor_config(); + let mut app = TuiState { + inner: MonitorState { + mg: None, + mg_err: None, + http: http_api::HttpSnapshot::default(), + history: history::MetricHistory::default(), + }, + selected_session: 0, + last_refresh: Instant::now(), + status_msg: String::new(), + show_graphs: true, + }; + app.refresh(&cfg); + + if args.once { + print_once(&app.inner, &cfg); + return Ok(()); + } + + if args.web { + return web::run(cfg, &args.web_host, args.web_port); + } + + run_tui(&mut app, &cfg, &args) +} + +fn print_once(state: &MonitorState, cfg: &MonitorConfig) { + let snap = state.to_snapshot(cfg); + println!("openmonitor — openads server"); + println!("wire: {}:{}", cfg.wire_host, cfg.wire_port); + if let Some(h) = &cfg.http { + println!("http: {h}"); + } + println!(); + let w = &snap.wire; + if w.online { + println!("uptime : {}", w.uptime.as_deref().unwrap_or("—")); + println!("connections : {} (max {})", w.connections, w.max_connections); + println!("users : {} (max {})", w.users, w.max_users); + println!("tables : {}", w.tables); + println!("operations : {}", w.operations); + println!("packets in/out: {} / {}", w.packets_in, w.packets_out); + println!("rss : {}", w.rss); + println!("listener : port {}", w.listener_port); + println!("connected users: {}", w.wire_users.len()); + for u in &w.wire_users { + println!(" #{} {} @ {}", u.conn_no, u.name, u.address); + } + } else if let Some(e) = &w.error { + println!("[wire error] {e}"); + } + if cfg.http.is_some() { + let h = &snap.http; + if let Some(status) = &h.status { + println!(); + println!("http status : {status}"); + println!("http mode : {}", h.mode.as_deref().unwrap_or("—")); + println!("data_dir : {}", h.data_dir.as_deref().unwrap_or("—")); + } + println!("http sessions: {}", h.sessions.len()); + for s in &h.sessions { + println!( + " id={} {}:{} user={} tables={} idle={}s", + s.id, s.peer_ip, s.peer_port, s.user, s.open_tables, s.idle_secs + ); + } + if let Some(e) = &h.error { + println!("[http error] {e}"); + } + } +} + +fn run_tui(app: &mut TuiState, cfg: &MonitorConfig, args: &Args) -> Result<()> { + enable_raw_mode()?; + stdout().execute(EnterAlternateScreen)?; + let mut term = ratatui::init(); + + let tick = Duration::from_secs(cfg.interval_secs); + let mut needs_redraw = true; + + loop { + if needs_redraw || app.last_refresh.elapsed() >= tick { + app.refresh(cfg); + needs_redraw = true; + } + + if needs_redraw { + term.draw(|f| ui(f, app, cfg, args))?; + needs_redraw = false; + } + + if event::poll(Duration::from_millis(200))? { + if let Event::Key(key) = event::read()? { + if key.kind != KeyEventKind::Press { + continue; + } + match key.code { + KeyCode::Char('q') | KeyCode::Esc => break, + KeyCode::Char('r') => { + app.refresh(cfg); + app.status_msg = "refreshed".into(); + needs_redraw = true; + } + KeyCode::Up => { + app.selected_session = app.selected_session.saturating_sub(1); + needs_redraw = true; + } + KeyCode::Down => { + if !app.inner.http.sessions.is_empty() { + app.selected_session = + (app.selected_session + 1).min(app.inner.http.sessions.len() - 1); + } + needs_redraw = true; + } + KeyCode::Char('g') => { + app.show_graphs = !app.show_graphs; + app.status_msg = if app.show_graphs { + "graphs on".into() + } else { + "graphs off".into() + }; + needs_redraw = true; + } + KeyCode::Char('k') => { + if let (Some(base), Some(sess)) = + (&cfg.http, app.inner.http.sessions.get(app.selected_session)) + { + let client = HttpClient::new(base); + match client.kill_session(sess.id) { + Ok(()) => { + app.status_msg = format!("killed session {}", sess.id); + app.refresh(cfg); + } + Err(e) => app.status_msg = format!("kill failed: {e}"), + } + needs_redraw = true; + } else { + app.status_msg = "kill needs --http and a selected session".into(); + needs_redraw = true; + } + } + _ => {} + } + } + } + } + + disable_raw_mode()?; + stdout().execute(LeaveAlternateScreen)?; + ratatui::restore(); + Ok(()) +} + +fn ui(f: &mut Frame, app: &TuiState, cfg: &MonitorConfig, args: &Args) { + let graph_rows = if app.show_graphs { 7 } else { 0 }; + let comm_rows = if app.show_graphs { 6 } else { 8 }; + let chunks = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Length(3), + Constraint::Min(5), + Constraint::Length(graph_rows), + Constraint::Length(comm_rows), + Constraint::Length(3), + ]) + .split(f.area()); + + let title = format!( + " openmonitor — openads server wire {}:{}", + cfg.wire_host, cfg.wire_port + ); + let http_line = cfg + .http + .as_deref() + .map(|u| format!(" http {u}")) + .unwrap_or_default(); + f.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled(title, Style::default().add_modifier(Modifier::BOLD)), + Span::raw(http_line), + ])) + .block(Block::default().borders(Borders::ALL).title("OpenMonitor")), + chunks[0], + ); + + let mid = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Percentage(45), Constraint::Percentage(55)]) + .split(chunks[1]); + + f.render_widget(activity_panel(app), mid[0]); + f.render_widget(sessions_panel(app), mid[1]); + let comm_idx = if app.show_graphs { 3 } else { 2 }; + let foot_idx = if app.show_graphs { 4 } else { 3 }; + if app.show_graphs { + render_graphs(f, app, chunks[2]); + } + f.render_widget(comm_panel(app), chunks[comm_idx]); + let web_hint = if args.web { "" } else { " w=use --web" }; + f.render_widget( + Paragraph::new(format!( + " {} | q quit r refresh g graphs up/down k kill (HTTP){}", + app.status_msg, web_hint + )) + .style(Style::default().fg(Color::DarkGray)), + chunks[foot_idx], + ); +} + +fn render_graphs(f: &mut Frame, app: &TuiState, area: ratatui::layout::Rect) { + let cols = Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage(20), + Constraint::Percentage(20), + Constraint::Percentage(20), + Constraint::Percentage(20), + Constraint::Percentage(20), + ]) + .split(area); + + let panels = [ + ( + "Connections", + app.inner.history.connections(), + Color::Cyan, + app.inner.mg.as_ref().map(|s| s.max_connections as u64).unwrap_or(1), + ), + ( + "Users", + app.inner.history.users(), + Color::Green, + app.inner.mg.as_ref().map(|s| s.max_users as u64).unwrap_or(1), + ), + ( + "Ops/s", + app.inner.history.ops_per_sec(), + Color::Yellow, + history::MetricHistory::max_in(app.inner.history.ops_per_sec()), + ), + ( + "Pkts/s", + app.inner.history.pkt_per_sec(), + Color::Magenta, + history::MetricHistory::max_in(app.inner.history.pkt_per_sec()), + ), + ( + "RSS KB", + app.inner.history.rss_kb(), + Color::Blue, + history::MetricHistory::max_in(app.inner.history.rss_kb()), + ), + ]; + + for (i, (title, data, color, max_val)) in panels.into_iter().enumerate() { + let max = max_val.max(1); + let spark_data: Vec = if data.is_empty() { + vec![0] + } else { + data.to_vec() + }; + let last = spark_data.last().copied().unwrap_or(0); + let widget = Sparkline::default() + .block( + Block::default() + .borders(Borders::ALL) + .title(format!("{title} ({last})")), + ) + .data(&spark_data) + .max(max) + .style(Style::default().fg(color)) + .bar_set(symbols::bar::NINE_LEVELS); + f.render_widget(widget, cols[i]); + } +} + +fn activity_panel(app: &TuiState) -> Paragraph<'_> { + let mut lines = Vec::new(); + if let Some(s) = &app.inner.mg { + lines.push(Line::from(vec![ + Span::styled("Uptime ", Style::default().fg(Color::Cyan)), + Span::raw(wire::format_uptime(s.uptime_seconds)), + ])); + lines.push(Line::from(format!( + "Connections {} / max {}", + s.connections, s.max_connections + ))); + lines.push(Line::from(format!( + "Users {} / max {}", + s.users, s.max_users + ))); + lines.push(Line::from(format!("Tables open {}", s.tables))); + lines.push(Line::from(format!("Work areas {}", s.workareas))); + lines.push(Line::from(format!("Locks {}", s.locks))); + lines.push(Line::from(format!("Operations {}", s.operations))); + lines.push(Line::from(format!("Errors logged {}", s.logged_errors))); + lines.push(Line::from(format!( + "Memory RSS {}", + wire::format_bytes(s.rss_bytes) + ))); + lines.push(Line::from(format!("Listener port {}", s.server_port))); + if !s.user_list.is_empty() { + lines.push(Line::from("")); + lines.push(Line::from(Span::styled( + "Wire users", + Style::default().add_modifier(Modifier::UNDERLINED), + ))); + for u in s.user_list.iter().take(8) { + lines.push(Line::from(format!( + " #{} {} @ {}", + u.conn_no, u.name, u.address + ))); + } + } + } else if let Some(e) = &app.inner.mg_err { + lines.push(Line::from(Span::styled( + format!("Wire offline: {e}"), + Style::default().fg(Color::Red), + ))); + } + if let Some(h) = &app.inner.http.health { + lines.push(Line::from("")); + lines.push(Line::from(format!("Studio mode {}", h.mode))); + lines.push(Line::from(format!("Data dir {}", h.data_dir))); + } + Paragraph::new(lines).block( + Block::default() + .borders(Borders::ALL) + .title("Activity (wire Mg)"), + ) +} + +fn sessions_panel(app: &TuiState) -> Table<'_> { + let header = Row::new(vec!["ID", "Peer", "User", "Tbl", "Idle"]) + .style(Style::default().add_modifier(Modifier::BOLD)) + .bottom_margin(1); + let rows: Vec = app + .inner + .http + .sessions + .iter() + .enumerate() + .map(|(i, s)| { + let style = if i == app.selected_session { + Style::default().bg(Color::DarkGray) + } else { + Style::default() + }; + Row::new(vec![ + Cell::from(s.id.to_string()), + Cell::from(format!("{}:{}", s.peer_ip, s.peer_port)), + Cell::from(s.user.clone()), + Cell::from(s.open_tables.to_string()), + Cell::from(format!("{}s", s.idle_secs)), + ]) + .style(style) + }) + .collect(); + let hint = if app.inner.http.sessions.is_empty() { + "no HTTP sessions (start server with --http-port)" + } else { + "HTTP wire sessions" + }; + Table::new( + rows, + [ + Constraint::Length(6), + Constraint::Length(18), + Constraint::Min(10), + Constraint::Length(4), + Constraint::Length(6), + ], + ) + .header(header) + .block(Block::default().borders(Borders::ALL).title(hint)) +} + +fn comm_panel(app: &TuiState) -> Paragraph<'_> { + let mut lines = Vec::new(); + if let Some(s) = &app.inner.mg { + lines.push(Line::from(format!( + "Packets in/out {} / {}", + s.packets_in, s.packets_out + ))); + lines.push(Line::from(format!( + "Bytes in/out {} / {}", + wire::format_bytes(s.bytes_in), + wire::format_bytes(s.bytes_out) + ))); + lines.push(Line::from(format!( + "Disconnects {} Partial connects {}", + s.disconnects, s.partial_connects + ))); + if !s.table_list.is_empty() { + lines.push(Line::from(Span::styled( + "Open tables (wire)", + Style::default().add_modifier(Modifier::UNDERLINED), + ))); + for t in s.table_list.iter().take(5) { + lines.push(Line::from(format!( + " {} — {} (#{})", + t.name, t.user, t.conn_no + ))); + } + } + } + if let Some(info) = &app.inner.http.server_info { + if let Some(v) = info.get("version").and_then(|v| v.as_str()) { + lines.push(Line::from(format!("Engine version {v}"))); + } + if let Some(n) = info.get("tables").and_then(|v| v.as_array()) { + lines.push(Line::from(format!("Data tables (disk) {}", n.len()))); + } + } + if let Some(e) = &app.inner.http.error { + lines.push(Line::from(Span::styled( + format!("HTTP: {e}"), + Style::default().fg(Color::Yellow), + ))); + } + Paragraph::new(lines).block( + Block::default() + .borders(Borders::ALL) + .title("Communication / data"), + ) +} \ No newline at end of file diff --git a/tools/openmonitor/src/snapshot.rs b/tools/openmonitor/src/snapshot.rs new file mode 100644 index 00000000..6237b39c --- /dev/null +++ b/tools/openmonitor/src/snapshot.rs @@ -0,0 +1,222 @@ +use crate::history::MetricHistory; +use crate::http_api::{Health, HttpSnapshot, Session}; +use crate::wire::{self, MgSnapshot}; +use serde::Serialize; + +#[derive(Debug, Clone)] +pub struct MonitorConfig { + pub wire_host: String, + pub wire_port: u16, + pub http: Option, + pub interval_secs: u64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct SeriesView { + pub label: String, + pub values: Vec, + pub current: u64, + pub max: u64, +} + +#[derive(Debug, Clone, Serialize)] +pub struct WireUserView { + pub conn_no: u16, + pub name: String, + pub address: String, +} + +#[derive(Debug, Clone, Serialize)] +pub struct WireView { + pub online: bool, + pub error: Option, + pub uptime: Option, + pub connections: u32, + pub max_connections: u32, + pub users: u32, + pub max_users: u32, + pub tables: u32, + pub operations: u64, + pub packets_in: u64, + pub packets_out: u64, + pub bytes_in: String, + pub bytes_out: String, + pub rss: String, + pub listener_port: u16, + pub wire_users: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct HttpView { + pub configured: bool, + pub error: Option, + pub status: Option, + pub mode: Option, + pub data_dir: Option, + pub version: Option, + pub disk_tables: Option, + pub sessions: Vec, +} + +#[derive(Debug, Clone, Serialize)] +pub struct MonitorSnapshot { + pub wire_host: String, + pub wire_port: u16, + pub http_url: Option, + pub updated_at: String, + pub wire: WireView, + pub http: HttpView, + pub series: Vec, +} + +pub struct MonitorState { + pub mg: Option, + pub mg_err: Option, + pub http: HttpSnapshot, + pub history: MetricHistory, +} + +impl MonitorState { + pub fn refresh(&mut self, cfg: &MonitorConfig) { + match wire::fetch_mg_snapshot(&cfg.wire_host, cfg.wire_port) { + Ok(s) => { + self.history.push(&s); + self.mg = Some(s); + self.mg_err = None; + } + Err(e) => { + self.mg = None; + self.mg_err = Some(e.to_string()); + } + } + if let Some(base) = &cfg.http { + self.http = crate::http_api::HttpClient::new(base).poll(); + } else { + self.http = HttpSnapshot::default(); + } + } + + pub fn to_snapshot(&self, cfg: &MonitorConfig) -> MonitorSnapshot { + let wire = build_wire_view(&self.mg, &self.mg_err); + let http = build_http_view(&self.http, cfg.http.is_some()); + let series = build_series(&self.history, &self.mg); + MonitorSnapshot { + wire_host: cfg.wire_host.clone(), + wire_port: cfg.wire_port, + http_url: cfg.http.clone(), + updated_at: chrono::Local::now().format("%H:%M:%S").to_string(), + wire, + http, + series, + } + } +} + +fn build_wire_view(mg: &Option, err: &Option) -> WireView { + match mg { + Some(s) => WireView { + online: true, + error: None, + uptime: Some(wire::format_uptime(s.uptime_seconds)), + connections: s.connections, + max_connections: s.max_connections, + users: s.users, + max_users: s.max_users, + tables: s.tables, + operations: s.operations, + packets_in: s.packets_in, + packets_out: s.packets_out, + bytes_in: wire::format_bytes(s.bytes_in), + bytes_out: wire::format_bytes(s.bytes_out), + rss: wire::format_bytes(s.rss_bytes), + listener_port: s.server_port, + wire_users: s + .user_list + .iter() + .map(|u| WireUserView { + conn_no: u.conn_no, + name: u.name.clone(), + address: u.address.clone(), + }) + .collect(), + }, + None => WireView { + online: false, + error: err.clone(), + uptime: None, + connections: 0, + max_connections: 0, + users: 0, + max_users: 0, + tables: 0, + operations: 0, + packets_in: 0, + packets_out: 0, + bytes_in: "—".into(), + bytes_out: "—".into(), + rss: "—".into(), + listener_port: 0, + wire_users: vec![], + }, + } +} + +fn build_http_view(http: &HttpSnapshot, configured: bool) -> HttpView { + let health: Option<&Health> = http.health.as_ref(); + HttpView { + configured, + error: http.error.clone(), + status: health.map(|h| h.status.clone()), + mode: health.map(|h| h.mode.clone()), + data_dir: health.map(|h| h.data_dir.clone()), + version: http + .server_info + .as_ref() + .and_then(|v| v.get("version")) + .and_then(|v| v.as_str()) + .map(str::to_string), + disk_tables: http + .server_info + .as_ref() + .and_then(|v| v.get("tables")) + .and_then(|v| v.as_array()) + .map(|a| a.len()), + sessions: http.sessions.clone(), + } +} + +fn build_series(history: &MetricHistory, mg: &Option) -> Vec { + let max_conn = mg.as_ref().map(|s| s.max_connections as u64).unwrap_or(1); + let max_users = mg.as_ref().map(|s| s.max_users as u64).unwrap_or(1); + vec![ + series_one("Conexões", history.connections(), max_conn), + series_one("Usuários", history.users(), max_users), + series_one( + "Ops/s", + history.ops_per_sec(), + MetricHistory::max_in(history.ops_per_sec()), + ), + series_one( + "Pkts/s", + history.pkt_per_sec(), + MetricHistory::max_in(history.pkt_per_sec()), + ), + series_one( + "RSS KB", + history.rss_kb(), + MetricHistory::max_in(history.rss_kb()), + ), + ] +} + +fn series_one(label: &str, data: &[u64], scale_max: u64) -> SeriesView { + let values: Vec = if data.is_empty() { vec![0] } else { data.to_vec() }; + let current = *values.last().unwrap_or(&0); + let peak = values.iter().copied().max().unwrap_or(1).max(scale_max).max(1); + SeriesView { + label: label.to_string(), + values, + current, + max: peak, + } +} \ No newline at end of file diff --git a/tools/openmonitor/src/web.rs b/tools/openmonitor/src/web.rs new file mode 100644 index 00000000..6bba7dcc --- /dev/null +++ b/tools/openmonitor/src/web.rs @@ -0,0 +1,109 @@ +use crate::http_api::HttpClient; +use crate::snapshot::{MonitorConfig, MonitorSnapshot, MonitorState}; +use anyhow::{Context, Result}; +use axum::{ + extract::{Path, State}, + http::StatusCode, + response::{Html, IntoResponse, Json}, + routing::{get, post}, + Router, +}; +use std::sync::{Arc, RwLock}; +use std::thread; +use std::time::Duration; +use tokio::net::TcpListener; + +#[derive(Clone)] +struct WebState { + cfg: MonitorConfig, + snap: Arc>, + poll: Arc>, +} + +pub fn run(cfg: MonitorConfig, host: &str, port: u16) -> Result<()> { + let poll = Arc::new(RwLock::new(MonitorState { + mg: None, + mg_err: None, + http: Default::default(), + history: Default::default(), + })); + + { + let mut state = poll.write().unwrap(); + state.refresh(&cfg); + } + + let initial = poll.read().unwrap().to_snapshot(&cfg); + let snap = Arc::new(RwLock::new(initial)); + let web = WebState { + cfg: cfg.clone(), + snap: snap.clone(), + poll: poll.clone(), + }; + + let interval = Duration::from_secs(cfg.interval_secs.max(1)); + let poller_cfg = cfg.clone(); + thread::spawn(move || loop { + thread::sleep(interval); + if let Ok(mut state) = poll.write() { + state.refresh(&poller_cfg); + if let Ok(mut out) = snap.write() { + *out = state.to_snapshot(&poller_cfg); + } + } + }); + + let rt = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .context("tokio runtime")?; + + rt.block_on(async move { + let app = Router::new() + .route("/", get(index)) + .route("/api/snapshot", get(api_snapshot)) + .route("/api/sessions/{id}/kill", post(api_kill_session)) + .with_state(web); + + let addr = format!("{host}:{port}"); + let listener = TcpListener::bind(&addr) + .await + .with_context(|| format!("bind web UI {addr}"))?; + eprintln!("[openmonitor] web UI http://{addr}/"); + axum::serve(listener, app) + .await + .context("axum serve")?; + Ok::<(), anyhow::Error>(()) + })?; + + Ok(()) +} + +async fn index() -> Html<&'static str> { + Html(include_str!("../assets/dashboard.html")) +} + +async fn api_snapshot(State(st): State) -> Json { + Json(st.snap.read().unwrap().clone()) +} + +async fn api_kill_session( + State(st): State, + Path(id): Path, +) -> impl IntoResponse { + let Some(base) = st.cfg.http.clone() else { + return (StatusCode::BAD_REQUEST, "monitor started without --http").into_response(); + }; + match HttpClient::new(&base).kill_session(id) { + Ok(()) => { + if let Ok(mut state) = st.poll.write() { + state.refresh(&st.cfg); + if let Ok(mut out) = st.snap.write() { + *out = state.to_snapshot(&st.cfg); + } + } + StatusCode::OK.into_response() + } + Err(e) => (StatusCode::BAD_GATEWAY, e.to_string()).into_response(), + } +} \ No newline at end of file diff --git a/tools/openmonitor/src/wire.rs b/tools/openmonitor/src/wire.rs new file mode 100644 index 00000000..cbc841dc --- /dev/null +++ b/tools/openmonitor/src/wire.rs @@ -0,0 +1,230 @@ +use anyhow::{anyhow, Context, Result}; +use std::io::{Read, Write}; +use std::net::TcpStream; +use std::time::Duration; + +const OPCODE_MG_REQUEST: u8 = 0xA2; +const OPCODE_MG_REPLY_ACK: u8 = 0xA3; + +#[derive(Debug, Clone, Default)] +pub struct MgUser { + pub name: String, + pub address: String, + pub os_login: String, + pub conn_no: u16, + pub connected_at: u64, +} + +#[derive(Debug, Clone, Default)] +pub struct MgTable { + pub name: String, + pub user: String, + pub conn_no: u16, + pub open_mode: u16, + pub lock_type: u16, +} + +#[derive(Debug, Clone, Default)] +pub struct MgSnapshot { + pub users: u32, + pub connections: u32, + pub workareas: u32, + pub tables: u32, + pub indexes: u32, + pub locks: u32, + pub worker_threads: u32, + pub server_type: u16, + pub rss_bytes: u64, + pub server_port: u16, + pub uptime_seconds: u64, + pub packets_in: u64, + pub packets_out: u64, + pub bytes_in: u64, + pub bytes_out: u64, + pub disconnects: u64, + pub partial_connects: u64, + pub operations: u64, + pub logged_errors: u64, + pub max_users: u32, + pub max_connections: u32, + pub user_list: Vec, + pub table_list: Vec, +} + +struct Reader<'a> { + data: &'a [u8], + pos: usize, +} + +impl<'a> Reader<'a> { + fn new(data: &'a [u8]) -> Self { + Self { data, pos: 0 } + } + + fn remaining(&self) -> usize { + self.data.len().saturating_sub(self.pos) + } + + fn u16(&mut self) -> Result { + if self.remaining() < 2 { + return Err(anyhow!("truncated u16")); + } + let v = u16::from_le_bytes([self.data[self.pos], self.data[self.pos + 1]]); + self.pos += 2; + Ok(v) + } + + fn u32(&mut self) -> Result { + if self.remaining() < 4 { + return Err(anyhow!("truncated u32")); + } + let mut buf = [0u8; 4]; + buf.copy_from_slice(&self.data[self.pos..self.pos + 4]); + self.pos += 4; + Ok(u32::from_le_bytes(buf)) + } + + fn u64(&mut self) -> Result { + if self.remaining() < 8 { + return Err(anyhow!("truncated u64")); + } + let mut buf = [0u8; 8]; + buf.copy_from_slice(&self.data[self.pos..self.pos + 8]); + self.pos += 8; + Ok(u64::from_le_bytes(buf)) + } + + fn str(&mut self) -> Result { + let n = self.u16()? as usize; + if self.remaining() < n { + return Err(anyhow!("truncated string")); + } + let s = std::str::from_utf8(&self.data[self.pos..self.pos + n]) + .context("invalid utf-8 in mg snapshot")? + .to_string(); + self.pos += n; + Ok(s) + } +} + +pub fn decode_mg_snapshot(payload: &[u8]) -> Result { + let mut r = Reader::new(payload); + let mut s = MgSnapshot::default(); + s.users = r.u32()?; + s.connections = r.u32()?; + s.workareas = r.u32()?; + s.tables = r.u32()?; + s.indexes = r.u32()?; + s.locks = r.u32()?; + s.worker_threads = r.u32()?; + s.server_type = r.u16()?; + s.rss_bytes = r.u64()?; + s.server_port = r.u16()?; + s.uptime_seconds = r.u64()?; + s.packets_in = r.u64()?; + s.packets_out = r.u64()?; + s.bytes_in = r.u64()?; + s.bytes_out = r.u64()?; + s.disconnects = r.u64()?; + s.partial_connects = r.u64()?; + s.operations = r.u64()?; + s.logged_errors = r.u64()?; + s.max_users = r.u32()?; + s.max_connections = r.u32()?; + let _ = r.u32()?; + let _ = r.u32()?; + let _ = r.u32()?; + let _ = r.u32()?; + + let nu = r.u32()?; + for _ in 0..nu { + s.user_list.push(MgUser { + name: r.str()?, + address: r.str()?, + os_login: r.str()?, + conn_no: r.u16()?, + connected_at: r.u64()?, + }); + } + let nt = r.u32()?; + for _ in 0..nt { + s.table_list.push(MgTable { + name: r.str()?, + user: r.str()?, + conn_no: r.u16()?, + open_mode: r.u16()?, + lock_type: r.u16()?, + }); + } + Ok(s) +} + +fn write_frame(stream: &mut TcpStream, opcode: u8, payload: &[u8]) -> Result<()> { + let len = payload.len() as u32; + let mut header = [0u8; 5]; + header[0..4].copy_from_slice(&len.to_be_bytes()); + header[4] = opcode; + stream + .write_all(&header) + .context("write frame header")?; + if !payload.is_empty() { + stream.write_all(payload).context("write frame payload")?; + } + Ok(()) +} + +fn read_frame(stream: &mut TcpStream) -> Result<(u8, Vec)> { + let mut header = [0u8; 5]; + stream + .read_exact(&mut header) + .context("read frame header")?; + let len = u32::from_be_bytes([header[0], header[1], header[2], header[3]]) as usize; + let opcode = header[4]; + let mut payload = vec![0u8; len]; + if len > 0 { + stream + .read_exact(&mut payload) + .context("read frame payload")?; + } + Ok((opcode, payload)) +} + +pub fn fetch_mg_snapshot(host: &str, port: u16) -> Result { + let addr = format!("{host}:{port}"); + let mut stream = TcpStream::connect(&addr).with_context(|| format!("connect {addr}"))?; + stream.set_read_timeout(Some(Duration::from_secs(5)))?; + stream.set_write_timeout(Some(Duration::from_secs(5)))?; + + // Snapshot request: kind=0x01, arg=0 + let req_payload = [0x01u8, 0x00, 0x00]; + write_frame(&mut stream, OPCODE_MG_REQUEST, &req_payload)?; + + let (opcode, payload) = read_frame(&mut stream)?; + if opcode != OPCODE_MG_REPLY_ACK { + return Err(anyhow!("unexpected mg reply opcode 0x{opcode:02X}")); + } + decode_mg_snapshot(&payload) +} + +pub fn format_uptime(secs: u64) -> String { + let days = secs / 86_400; + let hours = (secs % 86_400) / 3_600; + let mins = (secs % 3_600) / 60; + let s = secs % 60; + format!("{days}d {hours}h {mins}m {s}s") +} + +pub fn format_bytes(n: u64) -> String { + const KB: u64 = 1024; + const MB: u64 = KB * 1024; + const GB: u64 = MB * 1024; + if n >= GB { + format!("{:.2} GB", n as f64 / GB as f64) + } else if n >= MB { + format!("{:.2} MB", n as f64 / MB as f64) + } else if n >= KB { + format!("{:.1} KB", n as f64 / KB as f64) + } else { + format!("{n} B") + } +} \ No newline at end of file From 8c202f7e9db96387310c9e850c5ef694c457bebc Mon Sep 17 00:00:00 2001 From: Admnwk Date: Sat, 20 Jun 2026 15:01:29 -0300 Subject: [PATCH 2/8] feat(openads-plus): postgresql read and seek behind ACE ABI libpq backend with postgresql:// URI, parameterized queries, E2E doctest (seed via OPENADS_TEST_PG_URI), and NMake build script. No third-party SQL amalgamation. --- CMakeLists.txt | 61 ++ docs/OPENADS_PLUS.md | 39 + src/CMakeLists.txt | 14 + src/abi/ace_exports.cpp | 856 +++++++++++++++------ src/session/handle_registry.h | 6 +- src/sql_backend/postgres_backend.cpp | 112 +++ src/sql_backend/postgres_backend.h | 23 + src/sql_backend/postgres_connection.cpp | 507 ++++++++++++ src/sql_backend/postgres_connection.h | 62 ++ src/sql_backend/postgres_index.h | 15 + src/sql_backend/postgres_table.h | 41 + src/sql_backend/postgres_uri.cpp | 22 + src/sql_backend/postgres_uri.h | 14 + src/sql_backend/sql_common.cpp | 17 + src/sql_backend/sql_common.h | 9 + tests/CMakeLists.txt | 7 + tests/unit/abi_plus_postgres_read_test.cpp | 119 +++ tests/unit/abi_plus_postgres_seek_test.cpp | 84 ++ tools/scripts/build_nmake_postgres.bat | 27 + 19 files changed, 1800 insertions(+), 235 deletions(-) create mode 100644 docs/OPENADS_PLUS.md create mode 100644 src/sql_backend/postgres_backend.cpp create mode 100644 src/sql_backend/postgres_backend.h create mode 100644 src/sql_backend/postgres_connection.cpp create mode 100644 src/sql_backend/postgres_connection.h create mode 100644 src/sql_backend/postgres_index.h create mode 100644 src/sql_backend/postgres_table.h create mode 100644 src/sql_backend/postgres_uri.cpp create mode 100644 src/sql_backend/postgres_uri.h create mode 100644 src/sql_backend/sql_common.cpp create mode 100644 src/sql_backend/sql_common.h create mode 100644 tests/unit/abi_plus_postgres_read_test.cpp create mode 100644 tests/unit/abi_plus_postgres_seek_test.cpp create mode 100644 tools/scripts/build_nmake_postgres.bat diff --git a/CMakeLists.txt b/CMakeLists.txt index d7f38db5..dfb21429 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -68,6 +68,9 @@ option(OPENADS_WITH_TLS "Enable TLS transport (vendors mbedtls 3.6 LTS via Fetch # and nlohmann/json (MIT) at configure time via FetchContent. option(OPENADS_WITH_HTTP "Enable embedded HTTP web console (Studio) in openads_serverd / ace64.dll" ON) +# Optional PostgreSQL table driver (libpq). OFF by default. +option(OPENADS_WITH_POSTGRESQL "Enable PostgreSQL-backed table driver (libpq)" OFF) + # Pull in mbedtls FIRST so our strict /WX -Werror flags don't bleed # into the upstream sources (C4200 zero-sized arrays etc). Each # OpenADS target adds the strict flags target-locally below via @@ -126,6 +129,64 @@ if(OPENADS_WITH_HTTP) message(STATUS "OpenADS: HTTP web console enabled (cpp-httplib + nlohmann/json)") 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(MSVC) add_compile_options(/W4 /permissive-) add_compile_definitions(_CRT_SECURE_NO_WARNINGS) diff --git a/docs/OPENADS_PLUS.md b/docs/OPENADS_PLUS.md new file mode 100644 index 00000000..8c1c1486 --- /dev/null +++ b/docs/OPENADS_PLUS.md @@ -0,0 +1,39 @@ +# OpenADS Plus — PostgreSQL + +Extensão aditiva do [OpenADS](https://github.com/FiveTechSoft/OpenADS): tabelas PostgreSQL atrás da ABI ACE. DBF e wire inalterados. + +## Deploy rápido + +```bat +set OPENADS_TOOLCHAIN_ROOT= +tools\scripts\build_nmake_postgres.bat +``` + +Saída: `build\pg\src\openace32.dll` — copiar para a pasta do `.exe` Harbour antes de outra `ace*.dll`. + +## Conexão + +`AdsConnect60("postgresql://user:pass@host:5432/dbname")` + +## Teste ponta a ponta + +```bat +set OPENADS_TEST_PG_URI=postgresql://user:pass@127.0.0.1:5432/testdb +build\pg\tests\openads_unit_tests.exe --test-case="*postgresql*" +``` + +O teste cria/derruba a tabela `clientes`, insere 3 linhas e valida navegação + SEEK pela ABI. Sem URI definida, os casos E2E fazem SKIP (CI não quebra). + +## Segurança + +- Nomes de tabela/coluna: só identificadores ASCII seguros (`[A-Za-z0-9_]`). +- Valores de SEEK e chaves: parâmetros preparados (`$1`), nunca concatenados. +- URI montada em runtime no app — sem paths hardcoded no código. + +## Capacidades + +| Recurso | Status | +|---------|--------| +| Read + navegação | Sim | +| SEEK por coluna | Sim | +| Write | Planejado | \ No newline at end of file diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index 8c996c24..6c52c447 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -58,6 +58,15 @@ add_library(openads_core STATIC network/mg_wire.cpp ) +if(OPENADS_WITH_POSTGRESQL) + target_sources(openads_core PRIVATE + sql_backend/sql_common.cpp + sql_backend/postgres_uri.cpp + sql_backend/postgres_backend.cpp + sql_backend/postgres_connection.cpp + ) +endif() + if(WIN32) target_sources(openads_core PRIVATE platform/file_win32.cpp @@ -98,6 +107,11 @@ if(OPENADS_WITH_TLS) MbedTLS::mbedtls MbedTLS::mbedx509 MbedTLS::mbedcrypto) endif() +if(OPENADS_WITH_POSTGRESQL) + target_compile_definitions(openads_core PUBLIC OPENADS_WITH_POSTGRESQL=1) + target_link_libraries(openads_core PUBLIC openads_libpq) +endif() + # ---------------------------------------------------------------------- # Drop-in replacement DLL: ace32.dll (x86) / ace64.dll (x64). # Harbour's contrib/rddads links against ace32.lib / ace64.lib import diff --git a/src/abi/ace_exports.cpp b/src/abi/ace_exports.cpp index bad7d68a..5d45b429 100644 --- a/src/abi/ace_exports.cpp +++ b/src/abi/ace_exports.cpp @@ -24,6 +24,11 @@ #include "mgmt/mg_stats.h" #include "session/connection.h" #include "session/handle_registry.h" +#if defined(OPENADS_WITH_POSTGRESQL) +#include "sql_backend/postgres_connection.h" +#include "sql_backend/postgres_index.h" +#include "sql_backend/postgres_uri.h" +#endif #include "drivers/dbf_common.h" #include "drivers/index_trait.h" #include "drivers/ntx/ntx_index.h" @@ -65,7 +70,7 @@ using openads::session::Handle; using openads::session::HandleKind; struct ProcessState { - // M10.36 — recursive_mutex so UNION dispatch can re-enter + // 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; @@ -103,7 +108,7 @@ openads::engine::TableType map_type(UNSIGNED16 t) { } // 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 +// 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 @@ -125,7 +130,7 @@ void stamp_dbf_header_today(std::uint8_t* hdr) { 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 + // 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 @@ -176,7 +181,7 @@ cursor_projections() { return m; } -// Remote SQL cursors map — moved out of AdsExecuteSQLDirect so that +// 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 activate_binding(ADSHANDLE h); void purge_bindings_for_table(Table* t); -// M12.5 — remote-table lookup helper. Returns nullptr when the +// 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(); @@ -257,7 +262,82 @@ openads::network::RemoteTable* get_remote_table(ADSHANDLE h) { h, HandleKind::RemoteTable); } -// M12.16 — same dispatch helper for remote-index handles. Returns +#if defined(OPENADS_WITH_POSTGRESQL) +std::unordered_map>& +postgres_conns_map() { + static std::unordered_map> m; + return m; +} + +std::unordered_map>& +postgres_tables_map() { + static std::unordered_map> m; + return m; +} + +openads::sql_backend::PostgresTable* get_postgres_table(ADSHANDLE h) { + auto& s = state(); + return s.registry.lookup( + h, HandleKind::PostgresTable); +} + +std::unordered_map>& +postgres_indexes_map() { + static std::unordered_map> m; + return m; +} + +openads::sql_backend::PostgresIndex* get_postgres_index(ADSHANDLE h) { + auto& s = state(); + return s.registry.lookup( + h, HandleKind::PostgresIndex); +} + +std::size_t postgres_field_index(openads::sql_backend::PostgresTable* st, + UNSIGNED8* pucField) { + if (!st->fields_cached) { + // 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(); + } + } + 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(); @@ -302,7 +382,7 @@ Table* get_table(ADSHANDLE h) { 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 + // 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 @@ -318,7 +398,7 @@ Table* get_table(ADSHANDLE h) { // 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 +// 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) { @@ -332,7 +412,7 @@ std::string pad_char_field(std::string s, std::size_t width) { // --------------------------------------------------------------------------- // "AdsAppendRecord called but AdsWriteRecord hasn't fired yet" is tracked -// per-Table via Table::pending_append() — see table.h. It used to be a +// 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 @@ -429,7 +509,7 @@ openads::util::Result ri_check_insert(Connection* conn, Table& child) { // 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 + if (fk_val.empty()) continue; // NULL / blank FK ÔåÆ skip auto ph = conn->open_table(rule.parent, openads::engine::TableType::Cdx, @@ -487,7 +567,7 @@ openads::util::Result ri_enforce_delete(Connection* conn, Table& parent) { openads::engine::TableType::Cdx, need_write ? openads::engine::OpenMode::Shared : openads::engine::OpenMode::Read); - if (!ch) continue; // can't open child → skip rule + 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; @@ -535,7 +615,7 @@ openads::util::Result ri_enforce_delete(Connection* conn, Table& parent) { // 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 +// 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). @@ -594,13 +674,13 @@ openads::util::Result ri_enforce_update(Connection* conn, Table& parent) { 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 + 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 + // 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. @@ -669,7 +749,7 @@ openads::util::Result ri_enforce_update(Connection* conn, Table& parent) { // 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. +// 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(); @@ -678,7 +758,7 @@ openads::util::Result ri_enforce_update(Connection* conn, Table& parent) { } // Returns per-operation effective permissions for the connected user on object. -// If no DD / no ACL defined → all ops open. +// 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; @@ -703,7 +783,7 @@ std::string name_to_alias(const openads::engine::DataDict* dd, } // namespace -// RCB 2026-05-22 17:03 — set_stmt_param is defined later in the file alongside +// 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 @@ -726,14 +806,14 @@ UNSIGNED32 AdsConnect60(UNSIGNED8* pucServer, UNSIGNED16 /*usServerType*/, if (phConnect == nullptr) return fail(openads::AE_INTERNAL_ERROR, "phConnect is null"); auto path = openads::abi::to_internal(pucServer, 0); - // M12.5 — `tcp://host:port/` routes the connection + // 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; + // 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 + // 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 @@ -747,7 +827,7 @@ UNSIGNED32 AdsConnect60(UNSIGNED8* pucServer, UNSIGNED16 /*usServerType*/, std::string pw = pucPwd ? openads::abi::to_internal(pucPwd, 0) : std::string(); openads::network::TlsConfig cfg; - // For now, no CA bundle plumbed through the public ABI — + // For now, no CA bundle plumbed through the public ABI ÔÇö // dev / self-signed setups skip verification. A future // milestone will let the caller pass a CA cert via an // AdsSetTlsCa-style entry point. @@ -802,6 +882,25 @@ UNSIGNED32 AdsConnect60(UNSIGNED8* pucServer, UNSIGNED16 /*usServerType*/, return ok(); } } +#if defined(OPENADS_WITH_POSTGRESQL) + { + openads::sql_backend::PostgresUri suri; + if (openads::sql_backend::parse_postgres_uri(path, suri)) { + auto opened = openads::sql_backend::PostgresConnection::open(suri); + if (!opened) return fail(opened.error()); + auto holder = std::make_unique( + std::move(opened).value()); + openads::sql_backend::PostgresConnection* raw = holder.get(); + auto& s = state(); + std::lock_guard lk(s.mu); + Handle h = s.registry.register_object( + HandleKind::PostgresConnection, raw); + postgres_conns_map().emplace(h, std::move(holder)); + *phConnect = h; + return ok(); + } + } +#endif auto opened = Connection::open(path); if (!opened) return fail(opened.error()); auto holder = std::make_unique(std::move(opened).value()); @@ -847,6 +946,20 @@ UNSIGNED32 AdsDisconnect(ADSHANDLE hConnect) { { auto& s_local = state(); std::lock_guard lk_local(s_local.mu); +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* sc = s_local.registry.lookup( + hConnect, HandleKind::PostgresConnection)) { + for (auto& kv : postgres_tables_map()) { + if (kv.second && kv.second->conn == sc) { + kv.second->conn = nullptr; + } + } + sc->disconnect(); + postgres_conns_map().erase(hConnect); + s_local.registry.release(hConnect); + return ok(); + } +#endif if (auto* rc = s_local.registry.lookup( hConnect, HandleKind::RemoteConnection)) { // Null out rt->conn on any open SQL cursors that reference this @@ -863,12 +976,12 @@ UNSIGNED32 AdsDisconnect(ADSHANDLE hConnect) { 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 + // 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) { // Collect this connection's still-open Table handles. `s.conns.erase` - // below frees the Connection — and with it every Table it owns — so + // 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 @@ -911,7 +1024,7 @@ UNSIGNED32 AdsOpenTable(ADSHANDLE hConnect, "phTable is null"); auto& s = state(); std::lock_guard lk(s.mu); - // M12.5 — remote connection handle: route through wire client. + // M12.5 ÔÇö remote connection handle: route through wire client. if (auto* rc = s.registry.lookup( hConnect, HandleKind::RemoteConnection)) { auto name = openads::abi::to_internal(pucName, 0); @@ -933,6 +1046,21 @@ UNSIGNED32 AdsOpenTable(ADSHANDLE hConnect, *phTable = gh; return ok(); } +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* sc = s.registry.lookup( + hConnect, HandleKind::PostgresConnection)) { + auto name = openads::abi::to_internal(pucName, 0); + auto tbl = sc->open_table(name); + if (!tbl) return fail(tbl.error()); + auto st = std::move(tbl).value(); + st->conn = sc; + Handle gh = s.registry.register_object( + HandleKind::PostgresTable, st.get()); + postgres_tables_map().emplace(gh, std::move(st)); + *phTable = gh; + return ok(); + } +#endif auto* conn = s.registry.lookup(hConnect, HandleKind::Connection); if (conn == nullptr) { ADSHANDLE def = get_or_create_default_connection(); @@ -985,12 +1113,12 @@ UNSIGNED32 AdsOpenTable(ADSHANDLE hConnect, Handle gh = s.registry.register_object(HandleKind::Table, tbl); *phTable = gh; - // M-AOF.6 — production-CDX auto-open. ADS / rddads convention: + // 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 — + // 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; @@ -1008,7 +1136,7 @@ UNSIGNED32 AdsOpenTable(ADSHANDLE hConnect, (void)AdsOpenIndex(gh, b.data(), arr, &alen); } } - // ADI auto-open: same convention for ADT tables — opening `.adt` + // ADI auto-open: same convention for ADT tables ÔÇö opening `.adt` // auto-binds `.adi` if it exists, so every tag inside it becomes // navigable without an explicit AdsOpenIndex call. if (tp.extension() == ".adt" || tp.extension() == ".ADT") { @@ -1087,9 +1215,9 @@ UNSIGNED32 AdsGetRecordLength(ADSHANDLE hTable, UNSIGNED32* pulLen) { extern "C++" { namespace { -// M10.33 — standard SQL LIKE pattern. `%` matches any sequence +// M10.33 ÔÇö standard SQL LIKE pattern. `%` matches any sequence // (including empty), `_` matches a single character. Greedy match -// with backtracking — adequate for short DBF cells. +// 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; @@ -1174,7 +1302,7 @@ DbfTypeSpec dbf_type_for(const std::string& name) { 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 ── + // ÔöÇÔöÇ 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")) @@ -1199,7 +1327,7 @@ std::string trim(std::string s) { return s; } -// rddads `NAME,Type,Len,Dec;…` parser. Empty `defs` returns an empty +// 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; @@ -1301,7 +1429,7 @@ UNSIGNED32 AdsCreateTable(ADSHANDLE hConn, Connection* c = s.registry.lookup(hConn, HandleKind::Connection); if (c == nullptr) { - // rddads passes 0 when the host PRG never AdsConnect'd — + // rddads passes 0 when the host PRG never AdsConnect'd ÔÇö // fall back to a CWD-rooted default connection. ADSHANDLE def = get_or_create_default_connection(); c = s.registry.lookup(def, HandleKind::Connection); @@ -1324,7 +1452,7 @@ UNSIGNED32 AdsCreateTable(ADSHANDLE hConn, } if (is_adt) { - // ── ADT creation path ─────────────────────────────────────────────── + // ÔöÇÔöÇ ADT creation path ÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇ if (full.extension() != ".adt") full.replace_extension(".adt"); std::vector specs; @@ -1412,7 +1540,7 @@ UNSIGNED32 AdsCreateTable(ADSHANDLE hConn, ADS_ADT, 0, 0, 0, 1, phTable); } - // ── DBF creation path (existing) ──────────────────────────────────────── + // ÔöÇÔöÇ DBF creation path (existing) ÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇÔöÇ // Compute header + record sizes. std::uint16_t header_len = static_cast( 32 + 32 * fields.size() + 1); @@ -1460,7 +1588,7 @@ UNSIGNED32 AdsCreateTable(ADSHANDLE hConn, } // If the field list declares any memo (M) field, stage an empty - // .fpt next to the .dbf — Connection::open_table auto-attaches it, + // .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). { @@ -1491,7 +1619,7 @@ UNSIGNED32 AdsCreateTable(ADSHANDLE hConn, // --- M9.26 AdsRestructureTable (ADD-only) ---------------------------------- // -// Real ACE rebuilds the DBF with three field-def strings — add, +// 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 @@ -1531,9 +1659,9 @@ UNSIGNED32 AdsRestructureTable(ADSHANDLE hConnect, : std::string(); auto add_fields = parse_rddads_field_defs(add); - // CHANGE list (M10.12): same shape as ADD (NAME,Type,Len,Dec;…). + // 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 + // 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); @@ -1542,7 +1670,7 @@ UNSIGNED32 AdsRestructureTable(ADSHANDLE hConnect, change_map[cf.name] = cf; } - // DELETE list is a `;`-separated list of bare field names — + // DELETE list is a `;`-separated list of bare field names ÔÇö // unlike pucAddFields the entries carry no type / len info. std::unordered_set del_set; { @@ -1603,7 +1731,7 @@ UNSIGNED32 AdsRestructureTable(ADSHANDLE hConnect, 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 old_type = '\0'; // non-'\0' ÔåÆ type conversion char new_type = '\0'; // target raw type }; std::vector plan; @@ -1646,7 +1774,7 @@ UNSIGNED32 AdsRestructureTable(ADSHANDLE hConnect, if (plan.empty()) { return fail(openads::AE_INTERNAL_ERROR, "AdsRestructureTable: every field deleted " - "without an ADD — would leave the table empty"); + "without an ADD ÔÇö would leave the table empty"); } std::vector merged; merged.reserve(plan.size()); @@ -1760,7 +1888,7 @@ UNSIGNED32 AdsRestructureTable(ADSHANDLE hConnect, std::memcpy(new_buf.data() + out_off, nb, copy); } } else { - // D↔C and other pairs: raw copy up to min length. + // 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, @@ -1915,6 +2043,16 @@ UNSIGNED32 AdsCloseAllTables(void) { UNSIGNED32 AdsCloseTable(ADSHANDLE hTable) { { +#if defined(OPENADS_WITH_POSTGRESQL) + if (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(); + } +#endif if (auto* rt = get_remote_table(hTable)) { // conn is nulled out by AdsDisconnect before the RemoteConnection // is freed; skip the wire close op if the connection is already gone. @@ -1950,13 +2088,23 @@ UNSIGNED32 AdsCloseTable(ADSHANDLE hTable) { UNSIGNED32 AdsGotoTop(ADSHANDLE hTable) { if (auto* rt = get_remote_table(hTable)) { - // M12.18 — rt-aware overload parses the row trailer in the + // M12.18 ÔÇö rt-aware overload parses the row trailer in the // same RTT, so AdsGetField immediately after GoTop hits // the cache. auto r = rt->conn->goto_top(rt); if (!r) return fail(r.error()); return ok(); } +#if defined(OPENADS_WITH_POSTGRESQL) + if (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(); + } +#endif Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); auto r = t->goto_top(); @@ -1971,6 +2119,16 @@ UNSIGNED32 AdsGotoBottom(ADSHANDLE hTable) { if (!r) return fail(r.error()); return ok(); } +#if defined(OPENADS_WITH_POSTGRESQL) + if (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(); + } +#endif Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); auto r = t->goto_bottom(); @@ -1982,7 +2140,7 @@ UNSIGNED32 AdsGotoBottom(ADSHANDLE hTable) { UNSIGNED32 AdsSkip(ADSHANDLE hTable, SIGNED32 lRows) { seek_last_retry_latch() = false; if (auto* rt = get_remote_table(hTable)) { - // M12.21 — sequential prefetch: Skip(1) drains the queue + // M12.21 ÔÇö sequential prefetch: Skip(1) drains the queue // populated by the previous Skip's lookahead block. Zero // RTT for every cached step. if (lRows == 1 && !rt->prefetch_queue.empty()) { @@ -2000,6 +2158,16 @@ UNSIGNED32 AdsSkip(ADSHANDLE hTable, SIGNED32 lRows) { if (!r) return fail(r.error()); return ok(); } +#if defined(OPENADS_WITH_POSTGRESQL) + if (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(); + } +#endif Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); auto r = t->skip(lRows); @@ -2016,6 +2184,18 @@ UNSIGNED32 AdsAtEOF(ADSHANDLE hTable, UNSIGNED16* pbAtEnd) { *pbAtEnd = r.value() ? 1 : 0; return ok(); } +#if defined(OPENADS_WITH_POSTGRESQL) + if (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(); + } +#endif Table* t = get_table(hTable); if (!t || pbAtEnd == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); *pbAtEnd = t->eof() ? 1 : 0; @@ -2030,6 +2210,17 @@ UNSIGNED32 AdsAtBOF(ADSHANDLE hTable, UNSIGNED16* pbAtBegin) { *pbAtBegin = r.value() ? 1 : 0; return ok(); } +#if defined(OPENADS_WITH_POSTGRESQL) + if (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()); + *pbAtBegin = r.value() ? 1 : 0; + return ok(); + } +#endif Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); *pbAtBegin = t->bof() ? 1 : 0; @@ -2048,6 +2239,19 @@ UNSIGNED32 AdsGetNumFields(ADSHANDLE hTable, UNSIGNED16* pusFields) { *pusFields = static_cast(rt->fields.size()); return ok(); } +#if defined(OPENADS_WITH_POSTGRESQL) + if (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()); + } + *pusFields = static_cast(st->fields.size()); + return ok(); + } +#endif Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); if (auto* p = projection_for(hTable); p != nullptr) { @@ -2074,6 +2278,23 @@ UNSIGNED32 AdsGetFieldName(ADSHANDLE hTable, UNSIGNED16 usFieldNum, rt->fields[usFieldNum - 1].name); return ok(); } +#if defined(OPENADS_WITH_POSTGRESQL) + if (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 (usFieldNum == 0 || usFieldNum > st->fields.size()) { + return fail(openads::AE_COLUMN_NOT_FOUND, ""); + } + openads::abi::copy_to_caller(pucBuf, pusLen, + st->fields[usFieldNum - 1].name); + return ok(); + } +#endif Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); auto* p = projection_for(hTable); @@ -2108,7 +2329,7 @@ std::size_t remote_field_index(openads::network::RemoteTable* rt, rt->fields = std::move(r).value(); rt->fields_cached = true; } - // ACE "field name OR 1-based ordinal cast to a pointer" idiom — X#'s + // 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. @@ -2151,6 +2372,16 @@ UNSIGNED32 AdsGetFieldType(ADSHANDLE hTable, UNSIGNED8* pucField, *pusType = rt->fields[i].type; return ok(); } +#if defined(OPENADS_WITH_POSTGRESQL) + if (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(); + } +#endif Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); std::uint16_t idx = 0; @@ -2172,6 +2403,16 @@ UNSIGNED32 AdsGetFieldLength(ADSHANDLE hTable, UNSIGNED8* pucField, *pulLen = rt->fields[i].length; return ok(); } +#if defined(OPENADS_WITH_POSTGRESQL) + if (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(); + } +#endif Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); std::uint16_t idx = 0; @@ -2214,6 +2455,16 @@ UNSIGNED32 AdsGetFieldDecimals(ADSHANDLE hTable, UNSIGNED8* pucField, *pusDec = rt->fields[i].decimals; return ok(); } +#if defined(OPENADS_WITH_POSTGRESQL) + if (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(); + } +#endif Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); std::uint16_t idx = 0; @@ -2359,7 +2610,7 @@ UNSIGNED32 AdsGetRecordNum(ADSHANDLE hTable, UNSIGNED16 /*bFilterOption*/, UNSIGNED32* pulRecordNum) { 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 + // 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) { @@ -2371,6 +2622,15 @@ UNSIGNED32 AdsGetRecordNum(ADSHANDLE hTable, UNSIGNED16 /*bFilterOption*/, *pulRecordNum = r.value(); return ok(); } +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* st = get_postgres_table(hTable)) { + if (!st->positioned || !st->row_valid) { + return fail(5026, "no current record"); + } + *pulRecordNum = static_cast(st->current_recno); + return ok(); + } +#endif Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); *pulRecordNum = t->recno(); @@ -2381,7 +2641,7 @@ UNSIGNED32 AdsGetRecordCount(ADSHANDLE hTable, UNSIGNED16 /*bFilterOption*/, UNSIGNED32* pulRecordCount) { 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 + // 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) @@ -2397,16 +2657,34 @@ UNSIGNED32 AdsGetRecordCount(ADSHANDLE hTable, UNSIGNED16 /*bFilterOption*/, *pulRecordCount = rt->cached_rec_count; return ok(); } +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* st = get_postgres_table(hTable)) { + if (pulRecordCount == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (st->conn == nullptr) { + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + } + if (st->rec_count_cached) { + *pulRecordCount = st->cached_rec_count; + return ok(); + } + auto r = st->conn->record_count(st); + if (!r) return fail(r.error()); + st->cached_rec_count = r.value(); + st->rec_count_cached = true; + *pulRecordCount = st->cached_rec_count; + return ok(); + } +#endif Table* t = get_table(hTable); if (!t || pulRecordCount == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); - // M10.31 / M10.32 — when SQL has materialised a traversal sequence + // 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 + // 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. @@ -2430,10 +2708,10 @@ UNSIGNED32 AdsGetField(ADSHANDLE hTable, UNSIGNED8* pucField, UNSIGNED16 /*usOption*/) { 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 + // 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, + // xbrowse-style W cols ├ù H rows repaint: 1 RTT per row, // zero RTT per cell. if (!rt->row_valid) { auto fr = rt->conn->fetch_current_row(rt); @@ -2451,7 +2729,7 @@ UNSIGNED32 AdsGetField(ADSHANDLE hTable, UNSIGNED8* pucField, openads::abi::copy_to_caller(pucBuf, pulLen, val); return ok(); } - // EoF / no row — fall through to a plain GetField round- + // EoF / no row ÔÇö fall through to a plain GetField round- // trip; preserves the prior behaviour for callers that // probe past the end of the table. auto fname = openads::abi::to_internal(pucField, 0); @@ -2467,6 +2745,27 @@ UNSIGNED32 AdsGetField(ADSHANDLE hTable, UNSIGNED8* pucField, openads::abi::copy_to_caller(pucBuf, pulLen, val); return ok(); } +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* st = get_postgres_table(hTable)) { + if (pulLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); + if (st->conn == nullptr) { + return fail(openads::AE_INVALID_CONNECTION_HANDLE, ""); + } + auto fname = openads::abi::to_internal(pucField, 0); + bool is_null = false; + std::string val; + auto r = st->conn->read_field(st, fname, val, is_null); + if (!r) return fail(r.error()); + if (is_null) val.clear(); + auto fi = postgres_field_index(st, pucField); + if (fi != std::numeric_limits::max() && + st->fields[fi].type == ADS_STRING) { + val = pad_char_field(std::move(val), st->fields[fi].length); + } + openads::abi::copy_to_caller(pucBuf, pulLen, val); + return ok(); + } +#endif Table* t = get_table(hTable); if (!t || pulLen == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); std::uint16_t idx = 0; @@ -2500,7 +2799,7 @@ UNSIGNED32 AdsGetLastError(UNSIGNED32* pulCode, UNSIGNED8* pucBuf, // SAP / rddads signature: 5 args. // AdsGetVersion(&ulMajor, &ulMinor, &ucLetter, ucDesc, &usDescLen) // -// pucLetter : single ASCII letter (NOT a UNSIGNED32 codepoint) — +// 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 @@ -2530,7 +2829,7 @@ UNSIGNED32 AdsGetVersion(UNSIGNED32* pulMajor, UNSIGNED32* pulMinor, // 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 +// (date string, time string, milliseconds since midnight) ÔÇö the previous // 2-arg stub left rddads' on-stack pucDateBuf / pucTimeBuf uninitialised. namespace { @@ -2569,7 +2868,7 @@ UNSIGNED32 AdsGetServerTime(ADSHANDLE /*hConnect*/, return openads::AE_SUCCESS; } -// ── Trigger execution ────────────────────────────────────────────────────────── +// ÔöÇÔöÇ 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. @@ -2594,7 +2893,7 @@ Handle handle_for_conn(Connection* c) { return found; } -// Return the SQL body to execute for a trigger — prefer container unless it +// 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; @@ -2602,13 +2901,13 @@ static const std::string& trigger_sql_body(const openads::engine::DataDict::Trig return e.container; } -// ── Procedural trigger body executor ──────────────────────────────────────── +// ÔöÇÔöÇ 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, +// 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) +// __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 @@ -2695,7 +2994,7 @@ static void trig_collect_row_(Table* t, TrigFieldMap_& m) { } // Evaluate a SET expression RHS, returning a SQL-ready value string. -// __new/old field refs → SQL-quoted string. SQL functions/literals → as-is. +// __new/old field refs ÔåÆ SQL-quoted string. SQL functions/literals ÔåÆ as-is. static std::string trig_eval_rhs_( const std::string& rhs, const TrigFieldMap_& new_f, @@ -2899,7 +3198,7 @@ static TrigError_ trig_execute_body_( std::string ts = trig_trim_(raw); if (ts.empty()) continue; - // DECLARE @var [TYPE] — register variable; DECLARE name CURSOR — skip + // DECLARE @var [TYPE] ÔÇö register variable; DECLARE name CURSOR ÔÇö skip if (trig_ci_pfx_(ts, "DECLARE", 7)) { std::size_t p = 7; while (p < ts.size() && ts[p] == ' ') ++p; @@ -2937,21 +3236,21 @@ static TrigError_ trig_execute_body_( continue; } - // OPEN cursor AS SELECT ... — cursor loops not supported; skip + // OPEN cursor AS SELECT ... ÔÇö cursor loops not supported; skip if (trig_ci_pfx_(ts, "OPEN", 4)) continue; - // FETCH / CLOSE cursor — skip + // FETCH / CLOSE cursor ÔÇö skip if (trig_ci_pfx_(ts, "FETCH", 5)) continue; if (trig_ci_pfx_(ts, "CLOSE", 5)) continue; - // WHILE ... DO ... END (cursor loop) — skip entire block + // WHILE ... DO ... END (cursor loop) ÔÇö skip entire block if (trig_ci_pfx_(ts, "WHILE", 5)) continue; - // IF ... THEN / ELSE / END — skip conditional blocks + // IF ... THEN / ELSE / END ÔÇö skip conditional blocks if (trig_ci_pfx_(ts, "IF ", 3) || trig_ci_pfx_(ts, "ELSEIF", 6) || trig_ci_pfx_(ts, "ELSE", 4) || trig_ci_pfx_(ts, "END", 3)) continue; - // EXECUTE IMMEDIATE / EXECUTE PROCEDURE — not supported; skip + // EXECUTE IMMEDIATE / EXECUTE PROCEDURE ÔÇö not supported; skip if (trig_ci_pfx_(ts, "EXECUTE", 7)) continue; - // DROP TABLE #... — session temp tables; skip + // DROP TABLE #... ÔÇö session temp tables; skip if (trig_ci_pfx_(ts, "DROP TABLE #", 12)) continue; - // INSERT INTO __error (errno, message) VALUES (...) — capture error and stop + // INSERT INTO __error (errno, message) VALUES (...) ÔÇö capture error and stop { std::string tsu = ts; for (auto& c : tsu) c = static_cast(std::toupper((unsigned char)c)); @@ -2961,7 +3260,7 @@ static TrigError_ trig_execute_body_( } } // For non-INSTEAD OF triggers: INSERT ... SELECT ... FROM __new or __old is - // a trigger trying to re-insert the source row — skip to avoid duplicate writes. + // a trigger trying to re-insert the source row ÔÇö skip to avoid duplicate writes. // For INSTEAD OF triggers: this INSERT is the actual write the trigger performs; // fall through and execute it. if (!is_instead_of && trig_ci_pfx_(ts, "INSERT", 6) && @@ -2985,7 +3284,7 @@ static TrigError_ trig_execute_body_( return TrigError_{}; } -// fire_triggers_ — fire all enabled, matching triggers for a given event + timing. +// 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, @@ -3101,7 +3400,7 @@ UNSIGNED32 AdsAppendRecord(ADSHANDLE hTable) { 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. X#'s ADSRDD relies on this — its GoHot + // is automatically locked. X#'s ADSRDD relies on this ÔÇö its GoHot // refuses to write a record it sees as unlocked. Best-effort: the // lock layer no-ops in read/exclusive modes, and a lock contention // here doesn't invalidate the append itself. @@ -3135,7 +3434,7 @@ UNSIGNED32 AdsWriteRecord(ADSHANDLE hTable) { } } - // Trigger firing order: INSTEAD OF → (skip flush) OR BEFORE → flush → AFTER + // Trigger firing order: INSTEAD OF ÔåÆ (skip flush) OR BEFORE ÔåÆ flush ÔåÆ AFTER if (Connection* conn = conn_for_table(t)) { std::string alias = ri_alias_for_path(conn, t->path()); if (!alias.empty()) { @@ -3180,7 +3479,7 @@ UNSIGNED32 AdsDeleteRecord(ADSHANDLE hTable) { return fail(ri.error()); } - // Trigger firing order for DELETE: INSTEAD OF → (skip delete) OR BEFORE → delete → AFTER + // Trigger firing order for DELETE: INSTEAD OF ÔåÆ (skip delete) OR BEFORE ÔåÆ delete ÔåÆ AFTER if (Connection* conn = conn_for_table(t)) { std::string alias = ri_alias_for_path(conn, t->path()); if (!alias.empty()) { @@ -3227,7 +3526,7 @@ UNSIGNED32 AdsRecallRecord(ADSHANDLE hTable) { UNSIGNED32 AdsIsRecordDeleted(ADSHANDLE hTable, UNSIGNED16* pbDeleted) { 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. + // M12.18 ÔÇö deleted flag rides with the row trailer. if (rt->row_valid) { *pbDeleted = rt->current_deleted ? 1 : 0; return ok(); @@ -3237,6 +3536,12 @@ UNSIGNED32 AdsIsRecordDeleted(ADSHANDLE hTable, UNSIGNED16* pbDeleted) { *pbDeleted = r.value() ? 1 : 0; return ok(); } +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* st = get_postgres_table(hTable)) { + *pbDeleted = st->current_deleted ? 1 : 0; + return ok(); + } +#endif Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, ""); *pbDeleted = t->is_deleted() ? 1 : 0; @@ -3245,7 +3550,7 @@ UNSIGNED32 AdsIsRecordDeleted(ADSHANDLE hTable, UNSIGNED16* pbDeleted) { UNSIGNED32 AdsSetString(ADSHANDLE hTable, UNSIGNED8* pucField, UNSIGNED8* pucValue, UNSIGNED32 ulLen) { - // RCB 2026-05-22 17:03 — AdsSet* previously had no awareness of statement + // 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 @@ -3289,7 +3594,7 @@ UNSIGNED32 AdsSetString(ADSHANDLE hTable, UNSIGNED8* pucField, UNSIGNED32 AdsSetLogical(ADSHANDLE hTable, UNSIGNED8* pucField, UNSIGNED16 bValue) { - // RCB 2026-05-22 17:03 — same statement-handle gap as AdsSetString. + // 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) @@ -3310,7 +3615,7 @@ UNSIGNED32 AdsSetLogical(ADSHANDLE hTable, UNSIGNED8* pucField, UNSIGNED32 AdsSetDouble(ADSHANDLE hTable, UNSIGNED8* pucField, double dValue) { - // RCB 2026-05-22 17:03 — same statement-handle gap as AdsSetString. + // 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 @@ -3341,7 +3646,7 @@ UNSIGNED32 AdsSetLongLong(ADSHANDLE hTable, UNSIGNED8* pucField, namespace { -// Inverse of to_julian — convert a Clipper Julian Day Number back to +// 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; @@ -3368,7 +3673,7 @@ UNSIGNED32 AdsGetMemoLength(ADSHANDLE hTable, UNSIGNED8* pucField, UNSIGNED32* pulLen) { 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 + // Reuse the existing GetField wire op ÔÇö the returned string // is the full memo content; size() is the memo length. std::string fname = openads::abi::to_internal(pucField, 0); auto v = rt->conn->get_field(rt->id, fname); @@ -3661,7 +3966,7 @@ UNSIGNED32 AdsLockRecord(ADSHANDLE hTable, UNSIGNED32 ulRecord) { } Table* t = get_table(hTable); if (!t) return fail(openads::AE_INTERNAL_ERROR, "unknown table"); - // ulRecord == 0 → the current record (ACE convention). Resolving it + // 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; @@ -3732,7 +4037,7 @@ namespace { // 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 +// 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 { @@ -3755,7 +4060,7 @@ std::unordered_map& active_binding_for() { } // Drop every binding tied to `t`. Called from AdsCloseTable / AdsCloseAllTables -// / AdsDisconnect — without this, a Connection teardown leaves the bindings +// / 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) { @@ -3805,7 +4110,7 @@ openads::util::Result activate_binding(ADSHANDLE h) { // 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 + // 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()) { @@ -3840,7 +4145,7 @@ Table* table_for_index(ADSHANDLE hIndex) { 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 + // 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); @@ -3923,6 +4228,38 @@ UNSIGNED32 AdsOpenIndex(ADSHANDLE hTable, UNSIGNED8* pucName, if (ahIndex == nullptr) { return fail(openads::AE_INTERNAL_ERROR, "null out"); } +#if defined(OPENADS_WITH_POSTGRESQL) + if (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] = gh; + if (pu16ArrayLen != nullptr) { + *pu16ArrayLen = 1; + } + postgres_indexes_map().emplace(gh, std::move(si)); + return ok(); + } +#endif if (auto* rt = get_remote_table(hTable)) { std::string path = openads::abi::to_internal(pucName, 0); auto r = rt->conn->open_index(rt->id, path); @@ -4010,7 +4347,7 @@ UNSIGNED32 AdsOpenIndex(ADSHANDLE hTable, UNSIGNED8* pucName, } 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 + // 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); @@ -4078,6 +4415,16 @@ UNSIGNED32 AdsOpenIndex(ADSHANDLE hTable, UNSIGNED8* pucName, } UNSIGNED32 AdsCloseIndex(ADSHANDLE hIndex) { +#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()); @@ -4182,11 +4529,11 @@ UNSIGNED32 AdsCreateIndex61(ADSHANDLE hTable, namespace fs = std::filesystem; fs::path p; if (bag.empty()) { - // No bag name supplied → structural CDX: same stem as the table, + // No bag name supplied ÔåÆ structural CDX: same stem as the table, // same directory. Mirrors the auto-open logic in AdsOpenTable90 // (tp.replace_extension(".cdx")). Using tdir/"" on Windows // std::filesystem appends a trailing separator, making - // replace_extension produce ".cdx" with no stem — one shared file + // replace_extension produce ".cdx" with no stem ÔÇö one shared file // for the whole directory instead of one per table. p = fs::path(t->path()).replace_extension(".cdx"); } else { @@ -4203,7 +4550,7 @@ UNSIGNED32 AdsCreateIndex61(ADSHANDLE hTable, // ADS_UNIQUE 0x01 ADS_DESCENDING 0x02 ADS_CUSTOM 0x04 // ADS_COMPOUND 0x08 // ADS_COMPOUND is redundant here (compound-ness comes from the - // .cdx extension) and MUST be ignored for direction — rddads and + // .cdx extension) and MUST be ignored for direction ÔÇö rddads and // X#'s ADSRDD set it for every CDX/NTX tag. Reading it as // "descending" (the old `& 0x08` bug) built every order // descending: AdsGotoTop landed on the last key and SKIP walked @@ -4281,7 +4628,7 @@ UNSIGNED32 AdsCreateIndex61(ADSHANDLE hTable, auto rec_count = t->record_count(); for (std::uint32_t r = 1; r <= rec_count; ++r) { if (auto g = t->goto_record(r); !g) return fail(g.error()); - // DBFCDX inserts deleted rows too — the index is a logical + // 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. @@ -4346,7 +4693,7 @@ UNSIGNED32 AdsCreateIndex61(ADSHANDLE hTable, // 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 + // B+tree) so the stale binding must vanish too ÔÇö otherwise // ordinal lookups iterate over both the old and new bindings. for (auto it = m.begin(); it != m.end(); ) { if (it->second.table == t && it->second.tag_name == tag) { @@ -4466,7 +4813,7 @@ UNSIGNED32 AdsDeleteIndex(ADSHANDLE hIndex) { // / 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 +// 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. @@ -4536,7 +4883,7 @@ UNSIGNED32 AdsDeleteCustomKey(ADSHANDLE hIndex) { // --- 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 +// 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. @@ -4648,7 +4995,7 @@ UNSIGNED32 AdsFailedTransactionRecovery(UNSIGNED8* pucServer) { 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 + // 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. @@ -4674,7 +5021,7 @@ UNSIGNED32 AdsGetAllLocks(ADSHANDLE hTable, UNSIGNED32* paRecnos, return ok(); } -// M12.16c — switch the active order on `hTable` to the binding +// 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). @@ -4794,7 +5141,7 @@ UNSIGNED32 AdsSkipUnique(ADSHANDLE hIndex, SIGNED32 lDirection) { // 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 +// `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 AdsFTSSearch(ADSHANDLE /*hConnect*/, @@ -4829,7 +5176,7 @@ UNSIGNED32 AdsFTSSearch(ADSHANDLE /*hConnect*/, // 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 +// 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. @@ -5039,7 +5386,7 @@ UNSIGNED32 AdsDDGetUserProperty(ADSHANDLE hConn, UNSIGNED8* pucUser, 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. + // ADS_DD_USER_BAD_LOGINS (1103) ÔÇö always 0, returned as uint16. if (usProp == 1103) { UNSIGNED16 zero = 0; UNSIGNED16 n = std::min(cap, sizeof(UNSIGNED16)); @@ -5047,7 +5394,7 @@ UNSIGNED32 AdsDDGetUserProperty(ADSHANDLE hConn, UNSIGNED8* pucUser, *pusLen = sizeof(UNSIGNED16); return ok(); } - // ADS_DD_USER_GROUP_MEMBERSHIP (1102) — comma-separated group list. + // ADS_DD_USER_GROUP_MEMBERSHIP (1102) ÔÇö comma-separated group list. if (usProp == 1102) { std::string groups; for (const auto& g : dd->groups_of(user)) { @@ -5079,10 +5426,10 @@ UNSIGNED32 AdsDDSetUserProperty(ADSHANDLE hConn, UNSIGNED8* pucUser, 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. + // 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. + // 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); @@ -5159,12 +5506,12 @@ UNSIGNED32 AdsDDGetTableProperty(ADSHANDLE hConn, UNSIGNED8* pucTable, case ADS_DD_TABLE_RELATIVE_PATH: // 211 return put_str(rel); - case ADS_DD_TABLE_PATH: { // 205 — absolute path + 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 + case ADS_DD_TABLE_TYPE: { // 204 ÔÇö infer from extension fs::path p(rel); std::string ext = p.extension().string(); for (auto& ch : ext) @@ -5181,7 +5528,7 @@ UNSIGNED32 AdsDDGetTableProperty(ADSHANDLE hConn, UNSIGNED8* pucTable, case ADS_DD_TABLE_OBJ_ID: // 208 return put_u32(0); - case ADS_DD_TABLE_FIELD_COUNT: // 206 — requires opening table + case ADS_DD_TABLE_FIELD_COUNT: // 206 ÔÇö requires opening table return put_u32(0); case ADS_DD_TABLE_ENCRYPTION: // 214 @@ -5552,7 +5899,7 @@ UNSIGNED32 AdsDDGetTriggerProperty(ADSHANDLE hConn, UNSIGNED8* pucName, 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 */ + 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); @@ -5600,7 +5947,7 @@ UNSIGNED32 AdsDDSetTriggerProperty(ADSHANDLE hConn, UNSIGNED8* pucName, 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 */ + case 1401: /* ADS_DD_TRIG_EVENT_TYPE (SAP ACE) ÔÇö decode combined ADS constant */ { std::uint32_t combined = 0; parse_u32(combined); @@ -5886,8 +6233,8 @@ UNSIGNED32 AdsDDDropView(ADSHANDLE hConn, UNSIGNED8* pucName) { // --------------------------------------------------------------------------- // SAP ACE aliases not yet covered above -// AdsDDAddView / AdsDDRemoveView — thin aliases for Create/Drop. -// AdsDDGetPermissions / AdsDDGrantPermission / AdsDDRevokePermission — +// AdsDDAddView / AdsDDRemoveView ÔÇö thin aliases for Create/Drop. +// AdsDDGetPermissions / AdsDDGrantPermission / AdsDDRevokePermission ÔÇö // fine-grained object ACL helpers used by the php_advantage extension. // --------------------------------------------------------------------------- @@ -6195,11 +6542,11 @@ UNSIGNED32 AdsGetIndexHandleByOrder(ADSHANDLE hTable, UNSIGNED16 usOrder, } // Harbour rddads' INDEX command (with the default fAll && !fAdditive // condition) calls ORDLSTCLEAR before each AdsCreateIndex61. That - // wipes every binding we held for the table — but the on-disk CDX + // wipes every binding we held for the table ÔÇö but the on-disk CDX // bag still has all the prior tags. ORDSETFOCUS(N) is supposed to // address the N-th tag *in the file* (creation order), so re-bind // any tag in the active CDX that lost its binding and use the - // file's struct-tag insertion order — not handle IDs — as the + // file's struct-tag insertion order ÔÇö not handle IDs ÔÇö as the // authoritative ordinal sequence. auto& m = index_bindings(); auto& act = active_binding_for(); @@ -6301,8 +6648,8 @@ UNSIGNED32 AdsSetIndexDirection(ADSHANDLE hIndex, UNSIGNED16 usDir) { if (o == nullptr) { return fail(openads::AE_INTERNAL_ERROR, "no active order"); } - // ACE convention: usDir == 0 (ADS_ASCENDING) → forward; non-zero - // (ADS_DESCENDING) → reverse. + // ACE convention: usDir == 0 (ADS_ASCENDING) ÔåÆ forward; non-zero + // (ADS_DESCENDING) ÔåÆ reverse. const_cast(o)->set_descending_traverse( usDir != 0); return ok(); @@ -6311,16 +6658,22 @@ UNSIGNED32 AdsSetIndexDirection(ADSHANDLE hIndex, UNSIGNED16 usDir) { // ACE / rddads signature: 6 args. // AdsSeek(hIndex, pucKey, u16KeyLen, u16KeyType, u16SeekType, &u16Found) // -// u16KeyType : ADS_STRINGKEY / ADS_NUMERICKEY / ... — describes +// 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 +// decide whether Found() should report .T. ÔÇö return the flag the // engine set inside seek_key. UNSIGNED32 AdsIsFound(ADSHANDLE hTable, UNSIGNED16* pbFound) { if (pbFound == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* st = get_postgres_table(hTable)) { + *pbFound = st->last_seek_found ? 1 : 0; + return ok(); + } +#endif if (auto* rt = get_remote_table(hTable)) { auto r = rt->conn->is_found(rt->id); if (!r) return fail(r.error()); @@ -6339,6 +6692,24 @@ UNSIGNED32 AdsSeek(ADSHANDLE hIndex, UNSIGNED16 u16KeyType, UNSIGNED16 u16SeekType, UNSIGNED16* pbFound) { +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* si = get_postgres_index(hIndex)) { + if (si->parent == nullptr || si->parent->conn == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "postgres index orphan"); + } + std::string key(reinterpret_cast(pucKey), u16KeyLen); + const bool soft = (u16SeekType & 1u) != 0; + si->parent->row_valid = false; + auto r = si->parent->conn->seek_index( + si->parent, si->column, key, soft, /*last=*/false); + if (!r) return fail(r.error()); + const bool found = r.value(); + si->last_seek_found = found; + if (pbFound) *pbFound = found ? 1 : 0; + (void)u16KeyType; + return ok(); + } +#endif if (auto* ri = get_remote_index(hIndex)) { std::string key(reinterpret_cast(pucKey), u16KeyLen); @@ -6403,7 +6774,7 @@ UNSIGNED32 AdsSeek(ADSHANDLE hIndex, // 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 + // 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 @@ -6412,7 +6783,7 @@ UNSIGNED32 AdsSeek(ADSHANDLE hIndex, // 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 + // 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()); @@ -6438,6 +6809,23 @@ UNSIGNED32 AdsSeekLast(ADSHANDLE hIndex, UNSIGNED16 u16KeyLen, UNSIGNED16 u16KeyType, UNSIGNED16* pbFound) { +#if defined(OPENADS_WITH_POSTGRESQL) + if (auto* si = get_postgres_index(hIndex)) { + if (si->parent == nullptr || si->parent->conn == nullptr) { + return fail(openads::AE_INTERNAL_ERROR, "postgres index orphan"); + } + std::string key(reinterpret_cast(pucKey), u16KeyLen); + si->parent->row_valid = false; + auto r = si->parent->conn->seek_index( + si->parent, si->column, key, /*soft=*/false, /*last=*/true); + if (!r) return fail(r.error()); + const bool found = r.value(); + si->last_seek_found = found; + if (pbFound) *pbFound = found ? 1 : 0; + (void)u16KeyType; + return ok(); + } +#endif if (auto* ri = get_remote_index(hIndex)) { std::string key(reinterpret_cast(pucKey), u16KeyLen); @@ -6466,7 +6854,7 @@ UNSIGNED32 AdsSeekLast(ADSHANDLE hIndex, // 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 / ... — +// 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 @@ -6594,7 +6982,7 @@ UNSIGNED32 AdsCopyTable(ADSHANDLE hHandle, 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 + // records (deleted rows skipped ÔÇö filter options beyond // ADS_RESPECTFILTERS land later). const auto& src_fields = t->driver()->fields(); if (src_fields.empty()) { @@ -6663,7 +7051,7 @@ UNSIGNED32 AdsCopyTable(ADSHANDLE hHandle, // AdsCopyTableContents(hSrc, hDst, usFilterOption) // // usFilterOption : ADS_IGNOREFILTERS (0) / ADS_RESPECTFILTERS (1). -// We iterate raw records and skip the DBF tombstone byte — that +// 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. @@ -6726,7 +7114,7 @@ UNSIGNED32 AdsZapTable_DEFERRED(ADSHANDLE /*hTable*/) { "AdsZapTable lands in M4 alongside memo store"); } -// M-AOF.3 — wire AdsSetAOF / AdsClearAOF to the +// 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 @@ -6735,7 +7123,7 @@ UNSIGNED32 AdsZapTable_DEFERRED(ADSHANDLE /*hTable*/) { // entry-point contract. // // AdsGetAOFOptLevel still reports ADS_OPTIMIZED_NONE because the -// V1 bitmap is built by a full table scan — no indexes are +// 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 @@ -6760,7 +7148,7 @@ UNSIGNED32 AdsSetAOF(ADSHANDLE hTable, UNSIGNED8* pucCondition, auto ast = openads::engine::aof::parse(cond); if (!ast) { // An expression outside the optimisable AOF subset (e.g. - // `Empty(NAME)`, `UPPER(NAME) = 'A'`) is not an error — ADS + // `Empty(NAME)`, `UPPER(NAME) = 'A'`) is not an error ÔÇö ADS // just declines to optimise it and the client RDD applies the // filter itself. Drop any prior AOF, report OPTIMIZED_NONE, // and succeed so the caller's own row filter takes over. @@ -7030,7 +7418,7 @@ UNSIGNED32 AdsSetBinary(ADSHANDLE hTable, UNSIGNED8* pucField, PendingBinaryKey key{t, idx}; auto it = m.find(key); if (ulOffset == 0) { - // First chunk — reset (or create) the accumulator and lock in + // 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; @@ -7082,7 +7470,7 @@ UNSIGNED32 AdsGetLastAutoinc(ADSHANDLE hTable, UNSIGNED32* pulValue) { 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 + // type support. For now report 0 ÔÇö the field still reads as part // of the record buffer for non-autoinc types. *pulValue = 0; return ok(); @@ -7119,7 +7507,7 @@ UNSIGNED32 AdsIsRecordEncrypted(ADSHANDLE /*hTable*/, UNSIGNED16* pbEncrypted) { return ok(); } -// M11.2 — convert a plain CDX table to OpenADS-encrypted in place. +// 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*). @@ -7209,15 +7597,15 @@ UNSIGNED32 AdsInTransaction(ADSHANDLE hConnect, UNSIGNED16* pbInTx) { return ok(); } -// M11.2 — set the encryption password on a connection. Affects +// 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 — +// 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 +// 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 +// ÔÇö UTF-8 may grow up to 3x); `pulLen` carries the input length // in and the output length out. UNSIGNED32 AdsConvertOemToAnsi(UNSIGNED8* pucBuf, UNSIGNED32* pulLen) { if (pucBuf == nullptr || pulLen == nullptr) { @@ -7246,7 +7634,7 @@ UNSIGNED32 AdsConvertAnsiToOem(UNSIGNED8* pucBuf, UNSIGNED32* pulLen) { return ok(); } -// M11.7 — set the connection's string-compare collation. Names: +// 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 AdsSetCollation(ADSHANDLE hConnect, UNSIGNED8* pucName) { @@ -7304,7 +7692,7 @@ UNSIGNED32 AdsCreateSavepoint(ADSHANDLE hConnect, UNSIGNED8* pucName, return ok(); } -// M11.3 — release a savepoint without rolling back. The work done +// M11.3 ÔÇö release a savepoint without rolling back. The work done // since CreateSavepoint stays part of the enclosing transaction. UNSIGNED32 AdsReleaseSavepoint(ADSHANDLE hConnect, UNSIGNED8* pucName) { auto& s = state(); @@ -7509,7 +7897,7 @@ struct SqlStatement { Connection* conn = nullptr; openads::network::RemoteConnection* remote = nullptr; std::string sql; - // RCB 2026-05-22 17:03 — The original struct stored only the raw SQL string. + // 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 @@ -7519,10 +7907,10 @@ struct SqlStatement { // before handing the final SQL to the parser. std::unordered_map params; // Per-statement table-open overrides set by AdsStmt* helpers. - UNSIGNED16 table_type = 0; // 0 = ADS_DEFAULT → CDX - UNSIGNED16 lock_type = 0; // 0 = default → compatible locking + UNSIGNED16 table_type = 0; // 0 = ADS_DEFAULT ÔåÆ CDX + UNSIGNED16 lock_type = 0; // 0 = default ÔåÆ compatible locking UNSIGNED16 char_type = 0; - UNSIGNED16 read_only = 0; // non-zero → open read-only + UNSIGNED16 read_only = 0; // non-zero ÔåÆ open read-only UNSIGNED16 check_rights = 0; bool disable_enc = false; std::string collation; @@ -7539,7 +7927,7 @@ ADSHANDLE next_stmt_handle() { return ++n; } -// RCB 2026-05-22 17:03 — Statement handles live in stmt_map() which is a plain +// 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 @@ -7644,7 +8032,7 @@ UNSIGNED32 AdsExecuteSQL(ADSHANDLE hStatement, ADSHANDLE* phCursor) { if (it->second->sql.empty()) { return fail(openads::AE_PARSE_ERROR, "no prepared SQL"); } - // RCB 2026-05-22 17:03 — The original code copied the raw prepared SQL + // RCB 2026-05-22 17:03 ÔÇö The original code copied the raw prepared SQL // directly into the buffer and executed it, so :name placeholders were // passed to the parser verbatim and caused a parse error. Now that // AdsSet* stores literal values in SqlStatement::params we do a simple @@ -7695,7 +8083,7 @@ extern "C++" std::string build_system_dbf(Connection* c, std::string sys_name) { -> std::string { static const char kSig[] = "Advantage Table"; // 15 chars, no NUL - // Compute ADT storage sizes: CICHAR → col.length bytes, INTEGER → 4 bytes + // Compute ADT storage sizes: CICHAR ÔåÆ col.length bytes, INTEGER ÔåÆ 4 bytes struct FI { std::uint16_t adt_type; std::uint16_t storage; std::uint16_t rec_off; }; std::vector fi; std::uint32_t rlen = 1; // 1 byte delete flag @@ -7703,7 +8091,7 @@ extern "C++" std::string build_system_dbf(Connection* c, std::string sys_name) { FI f{}; if (col.type == 'N') { f.adt_type = 11; f.storage = 4; } else if (col.type == 'L') { f.adt_type = 1; f.storage = 1; } - else { f.adt_type = 20; f.storage = col.length; } // 'C' → CICHAR + else { f.adt_type = 20; f.storage = col.length; } // 'C' ÔåÆ CICHAR f.rec_off = static_cast(rlen); rlen += f.storage; fi.push_back(f); @@ -7767,7 +8155,7 @@ extern "C++" std::string build_system_dbf(Connection* c, std::string sys_name) { dst[1] = static_cast((uiv >> 8) & 0xFFu); dst[2] = static_cast((uiv >> 16) & 0xFFu); dst[3] = static_cast((uiv >> 24) & 0xFFu); - } else if (fi[ci].adt_type == 1u) { // LOGICAL: 1 byte — 'T'(0x54)/'F'(0x46) + } else if (fi[ci].adt_type == 1u) { // LOGICAL: 1 byte ÔÇö 'T'(0x54)/'F'(0x46) dst[0] = (val == "1" || val == "T" || val == "t") ? 'T' : 'F'; } else { // CICHAR: space-padded std::size_t n2 = std::min(val.size(), fi[ci].storage); @@ -8061,7 +8449,7 @@ extern "C++" std::string build_system_dbf(Connection* c, std::string sys_name) { }; const std::string& user = c->username(); if (user.empty()) { - // No logged-in user — return empty (caller treats as open access). + // No logged-in user ÔÇö return empty (caller treats as open access). return build(cols, {}); } auto entries = dd->get_all_effective_perms(user); @@ -8449,12 +8837,12 @@ extern "C++" bool dispatch_sp_builtin( } if (!dd) { *prc = fail(openads::AE_FUNCTION_NOT_AVAILABLE, "no DD"); return true; } if (scope_u == "ALL") { - // All triggers in the DD — persist + // 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 + // 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; @@ -8464,7 +8852,7 @@ extern "C++" bool dispatch_sp_builtin( 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 + // 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; @@ -8501,7 +8889,7 @@ extern "C++" bool dispatch_sp_builtin( return false; } -} // extern "C" — temporarily closed so proc:: helpers get C++ linkage +} // extern "C" ÔÇö temporarily closed so proc:: helpers get C++ linkage // ============================================================ // Procedural-body mini-interpreter for DD stored functions. @@ -8625,7 +9013,7 @@ static std::string call_builtin(const std::string& fn_up, const std::vector=3) { @@ -8688,7 +9076,7 @@ static std::string eval(const std::string& expr_in, Scope& scope, ADSHANDLE hStm if (op_pos!=std::string::npos) { std::string lv=eval(e.substr(0,op_pos),scope,hStmt); std::string rv=eval(e.substr(op_pos+1),scope,hStmt); - // Both numeric → arithmetic + // Both numeric ÔåÆ arithmetic char *ep1,*ep2; double a=std::strtod(lv.c_str(),&ep1), b=std::strtod(rv.c_str(),&ep2); if (ep1!=lv.c_str()&&*ep1=='\0' && ep2!=rv.c_str()&&*ep2=='\0') { @@ -8796,12 +9184,12 @@ static std::string exec_body(const std::string& body, Scope& scope, ADSHANDLE hS } // IF cond THEN stmts [ELSE stmts] END IF // (Implemented as a single-pass block search within the statement list. - // For now, skip IF blocks — they are handled by re-parsing the body + // For now, skip IF blocks ÔÇö they are handled by re-parsing the body // with IF as a sub-body delimiter.) if (su.rfind("IF ",0)==0) { // Minimal IF: find THEN and ELSE/END within subsequent statements. // Build true/false sub-bodies from the statement stream. - // This is complex; skip for first pass — most DD functions + // This is complex; skip for first pass ÔÇö most DD functions // don't need it when expressions use IIF() instead. continue; } @@ -8836,7 +9224,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, auto& m = stmt_map(); auto it = m.find(hStatement); if (it == m.end()) return fail(openads::AE_INTERNAL_ERROR, "unknown stmt"); - // M12.7 — remote SQL exec. The statement was created against a + // 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 @@ -8939,14 +9327,14 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, // 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 engine and return no cursor (phCursor ÔåÆ 0); SELECT keeps // the M9.21 path. 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 + // 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()) { @@ -8977,7 +9365,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, schema.push_back(src->field_descriptor(k)); } } - // Build NAME,Type,Len,Dec;… from schema. + // Build NAME,Type,Len,Dec;ÔǪ from schema. auto type_name = [](char raw) -> const char* { switch (raw) { case 'C': return "Character"; @@ -9052,7 +9440,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, recnos.push_back(r); } } - // Pre-resolve src column → tgt column by name match. + // 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) { @@ -9091,7 +9479,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, return ok(); } - // Build the rddads `NAME,Type,Len,Dec;…` field-def string and + // 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; @@ -9249,7 +9637,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, return ok(); } - // M11.4 — `CREATE PROCEDURE AS '::'`. + // 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)) { @@ -9265,7 +9653,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, return ok(); } - // M11.4 — `EXECUTE PROCEDURE (, ...)`. Built-in sp_* names + // 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)) { @@ -9368,11 +9756,11 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, // 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 + // 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 + // CONTAINS or AND/OR ÔÇö for now the closures below fully // cover the tree.) using Pred = std::function; std::function( @@ -9619,7 +10007,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, openads::engine::Table* tbl = c->lookup_table(th.value()); if (!tbl) return fail(openads::AE_INTERNAL_ERROR, "post-open"); - // M10.41 — INSERT INTO t (cols) SELECT ...: recursively + // 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. @@ -9723,7 +10111,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, return ok(); } - // M10.52 — multi-row VALUES path. When `rows` is non-empty, + // 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& @@ -9781,10 +10169,10 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, return ok(); } - // M10.26 — top-level `UNION [ALL]` between SELECTs. Each member + // 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). + // 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; }; @@ -9843,19 +10231,19 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, auto& s = state(); std::lock_guard lk(s.mu); - // M10.36 — every UNION member runs through the full + // 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 + // CASE WHEN, DISTINCT, LIMIT ÔÇö anything a plain SELECT // accepts. The first member's cursor schema (whatever the - // pipeline produces — temp DBF for joins/aggregates, + // 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 + // let the recursive call run as-is ÔÇö its sort is // overwritten by the final post-merge stable_sort below. std::optional final_order; { @@ -9999,7 +10387,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, } file.push_back(0x0D); - // M10.28 — apply ORDER BY (from last member) to merged rows. + // M10.28 ÔÇö apply ORDER BY (from last member) to merged rows. if (final_order) { std::int32_t fi = -1; std::uint16_t off = 1; @@ -10132,7 +10520,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, return sl; }; - // INNER / LEFT walk left + lookup right. RIGHT swaps that — + // 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. @@ -10166,7 +10554,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, probe_map[trim_trailing(v.value().as_string)].push_back(r); } } - // Keep the legacy name `rmap` working — the executor below + // Keep the legacy name `rmap` working ÔÇö the executor below // walks one side and probes the other through this map. auto& rmap = probe_map; @@ -10215,7 +10603,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, file.push_back(0x0D); // Helper: emit one merged record with explicit left/right - // byte slices. Either side may be null — outer-join fillers + // byte slices. Either side may be null ÔÇö outer-join fillers // pass nullptr for the side that has no match. std::uint32_t emitted = 0; auto emit_merged = [&](const std::uint8_t* lbytes, std::size_t lsize, @@ -10233,7 +10621,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, }; if (walk_right) { - // RIGHT OUTER — walk right rows, look up the LEFT hash. + // 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) { @@ -10259,7 +10647,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, } } } else { - // INNER / LEFT / FULL — walk left rows, look up the RIGHT + // 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; @@ -10478,7 +10866,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, ctbl->set_filter(std::move(compiled).value()); } if (parsed.value().order_by) { - // M10.37 — multi-column ORDER BY against the joined cursor. + // M10.37 ÔÇö multi-column ORDER BY against the joined cursor. struct SortKey { std::uint16_t field_index; bool descending; @@ -10558,7 +10946,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, ctbl->set_recno_sequence(std::move(seq)); } - // M10.34 — GROUP BY across JOIN. Same shape as the plain-table + // 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()) { @@ -10880,7 +11268,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, return ok(); } - // M10.23 — JOIN + aggregate. Walk the merged cursor (already + // 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. @@ -11023,7 +11411,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, return ok(); } - // M10.46 — derived table: `FROM (SELECT ...)`. Recursively run + // 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(); @@ -11043,7 +11431,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, 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. + // 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); @@ -11060,7 +11448,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, } (void)table_handle; - // M10.10: aggregate query — walk matching rows, compute the + // 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()) { @@ -11085,7 +11473,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, } // Build the WHERE filter (same shape as the SELECT branch - // below — but the predicate compiles independently here so + // 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) { @@ -11164,7 +11552,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, filter = std::move(compiled).value(); } - // M10.25 — `GROUP BY [, ...] [HAVING op num]`. + // 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 @@ -11195,7 +11583,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, gbs.push_back(std::move(gc)); } - // M10.30 — HAVING is now a boolean tree of HavingCmp leaves. + // 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) @@ -11473,7 +11861,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, return ok(); } - // M10.54 — compile each slot's optional FILTER. Subset: + // 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()); @@ -11690,7 +12078,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, switch (slots[i].def.kind) { case openads::sql::AggregateKind::CountStar: case openads::sql::AggregateKind::Count: - // M10.54 — when this slot has a FILTER, count[i] + // 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", @@ -11757,10 +12145,10 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, bool is_numeric = false; double number = 0.0; std::shared_ptr> contains_hits; - // M10.33 — BETWEEN upper bound. + // M10.33 ÔÇö BETWEEN upper bound. std::string literal2; double number2 = 0.0; - // M11.7 — case-insensitive ASCII compare when set. + // M11.7 ÔÇö case-insensitive ASCII compare when set. bool nocase = false; }; bool conn_nocase = @@ -11810,7 +12198,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, (openads::engine::Table& t) { return !p(t); }}; } if (node.kind == Kind::Exists) { - // M10.17 / M10.24 — EXISTS / NOT EXISTS. Honors + // 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 @@ -11905,7 +12293,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, // 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). + // 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{ @@ -11920,7 +12308,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, auto set = std::make_shared>(); for (auto& lit : node.in_clause.literals) set->insert(lit); if (node.in_clause.subquery) { - // M10.35 — detect correlation in subquery's WHERE. + // M10.35 ÔÇö detect correlation in subquery's WHERE. bool correlated = false; if (node.in_clause.subquery->where) { std::function @@ -12109,7 +12497,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, term.number = w.number; term.literal2 = w.literal2; term.number2 = w.number2; - // M11.7 — stamp collation onto the term when the + // M11.7 ÔÇö stamp collation onto the term when the // connection is in nocase mode and the cmp involves // string operands. if (conn_nocase && !w.is_numeric) { @@ -12118,7 +12506,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, term.literal2 = to_lower_ascii(term.literal2); } if (w.subquery) { - // M10.29 — correlated scalar subquery. If the + // 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. @@ -12326,7 +12714,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, } }}; } - // M10.18: scalar subquery — materialise once at + // 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. @@ -12345,7 +12733,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, tbl->field_descriptor(static_cast(fidx)) .type != openads::drivers::DbfFieldType::Character; - // M10.19 — aggregate scalar subquery + // 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. @@ -12498,7 +12886,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, } if (term.op == openads::sql::WhereOp::IsNull || term.op == openads::sql::WhereOp::IsNotNull) { - // M10.44 / M11.6 — prefer the VFP NULL bitmap + // 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); @@ -12541,7 +12929,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, // 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 + // M10.6 / M10.37 ÔÇö ORDER BY one column, with cascading // additional columns for ties (M10.37). struct SortKey { std::uint16_t field_index; @@ -12632,7 +13020,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, tbl->set_recno_sequence(std::move(seq)); } - // M10.31 — DISTINCT. M10.32 — LIMIT [OFFSET]. Both operate on the + // 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. @@ -12697,7 +13085,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, tbl->set_recno_sequence(std::move(seq)); } - // M10.38 — projection contains a CASE expression. Materialise the + // 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 + @@ -12786,7 +13174,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, o.raw_type = 'C'; o.length = 50; } else { - // M10.43 / M10.45 — multi-arg fns. Width = generous + // 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). @@ -13004,7 +13392,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, ccases.push_back(std::move(cc)); } - // Build the row list — honor any installed recno_sequence, + // Build the row list ÔÇö honor any installed recno_sequence, // else walk the filtered cursor. std::vector walk_seq; if (tbl->has_recno_sequence()) { @@ -13019,7 +13407,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, } } - // M10.49 / M10.50 — pre-compute window values per row when + // 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 @@ -13254,7 +13642,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, // procedural body through the mini-interpreter. { proc::Scope scope; - // Parse "name TYPE, name TYPE, ..." → parameter names + // Parse "name TYPE, name TYPE, ..." ÔåÆ parameter names std::vector pnames; { std::size_t ix = 0; @@ -13302,7 +13690,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, case K::NullIf: case K::Coalesce: case K::IfNull: { - // M10.43 / M10.45 — multi-arg fns. Resolve + // 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. @@ -13363,7 +13751,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, val = std::move(src); } else if (fc.kind == K::DateDiff && fc.args.size() == 2) { - // M10.45 — DATEDIFF on YYYYMMDD strings: + // 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; @@ -13384,14 +13772,14 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, val = buf; } else if (fc.kind == K::NullIf && fc.args.size() == 2) { - // M10.53 — NULLIF(a, b): NULL if + // 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. + // M10.53 ÔÇö first non-empty arg wins. for (auto& a : fc.args) { auto cs = arg_str(a); if (!cs.empty()) { @@ -13400,12 +13788,12 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, } } else if (fc.kind == K::IfNull && fc.args.size() == 2) { - // M10.53 — IFNULL(expr, default). + // 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. + // 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()); @@ -13523,7 +13911,7 @@ UNSIGNED32 AdsExecuteSQLDirect(ADSHANDLE hStatement, UNSIGNED8* pucSQL, return ok(); } - // M10.46 — when this query was a derived-table outer SELECT, + // 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) @@ -13562,8 +13950,8 @@ namespace { std::string g_date_format = "yyyy-mm-dd"; // 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 +// 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]; @@ -13662,7 +14050,7 @@ UNSIGNED32 AdsCopyTableContent(ADSHANDLE hSrc, ADSHANDLE hDst) { // 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. + // 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(); @@ -13690,7 +14078,7 @@ UNSIGNED32 AdsCustomizeAOF(ADSHANDLE, UNSIGNED32, UNSIGNED32*, UNSIGNED16) UNSIGNED32 AdsData(UNSIGNED16, void*) { 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 — +// the engine would use to evaluate the filter. Currently a stub ÔÇö // caller's *pusOptLevel is zeroed (= ADS_OPTIMIZED_NONE). UNSIGNED32 AdsEvalAOF(ADSHANDLE, UNSIGNED8*, UNSIGNED16* pusOptLevel) { if (pusOptLevel) *pusOptLevel = 0; @@ -13701,7 +14089,7 @@ UNSIGNED32 AdsFilterOption(ADSHANDLE, UNSIGNED16, UNSIGNED16* p) // 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 "". +// ÔÇö Harbour's ADSGETAOF treats that as "no AOF" and returns "". UNSIGNED32 AdsGetAOF(ADSHANDLE, UNSIGNED8* pucFilter, UNSIGNED16* pusLen) { if (pusLen != nullptr) { @@ -13860,7 +14248,7 @@ UNSIGNED32 AdsGetRelKeyPos(ADSHANDLE h, double* p) { if (p == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); *p = 0.0; if (auto* rt = get_remote_table(h)) { - // M12.19 — scrollbar callers (xbrowse) hit this every paint. + // M12.19 ÔÇö scrollbar callers (xbrowse) hit this every paint. // current_recno arrives with the row trailer (M12.18) and // cached_rec_count survives every nav, so the hot path is // 0 RTT. Fall back to a wire RTT only if either piece of @@ -13923,7 +14311,7 @@ UNSIGNED32 AdsGetRelKeyPos(ADSHANDLE h, double* p) { if (walk[i] == rn) { pos = i; break; } } if (pos >= walk.size()) { - // Cursor recno not present in the index — fall back to + // 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 - 1) / @@ -13981,7 +14369,7 @@ UNSIGNED32 AdsIsRecordInAOF(ADSHANDLE, UNSIGNED32, UNSIGNED16* p) { 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 aren't introspected yet — they report 0. +// handles aren't introspected yet ÔÇö they report 0. UNSIGNED32 AdsIsRecordLocked(ADSHANDLE hTable, UNSIGNED32 ulRecord, UNSIGNED16* pbLocked) { if (pbLocked == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); @@ -14126,7 +14514,7 @@ fetch_mg_snapshot(const MgBackend& be) { if (!be.remote) { // Local mode: report this process by enumerating the ABI // handle registry for real open-connection / open-table - // counts. The per-table list stays empty in local mode — + // counts. The per-table list stays empty in local mode ÔÇö // counts are what the management surface needs; resolving // each handle to a table name would require engine::Table // introspection that is out of scope here. @@ -14219,7 +14607,7 @@ mg_collector_for(ADSHANDLE h) { // 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, +// 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 @@ -14277,7 +14665,7 @@ bool send_mg_mutator(const MgBackend& be, // 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 +// 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). @@ -14296,8 +14684,8 @@ UNSIGNED32 AdsMgConnect(UNSIGNED8* pucServer, UNSIGNED8* /*pucUser*/, // 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 + // 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; @@ -14451,7 +14839,7 @@ UNSIGNED32 AdsMgResetCommStats(ADSHANDLE h) { // already defined earlier in this file. // --------------------------------------------------------------------------- -// M12.22 — versioned ACE overloads the X# RDD (xsharp.eu) binds by name. +// 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 @@ -14590,7 +14978,7 @@ UNSIGNED32 AdsSetProperty90(ADSHANDLE /*hObj*/, UNSIGNED32 /*ulOperation*/, } // OpenADS keys connections/tables by handle, not by path/name, so -// report "not found" — X# then opens a fresh connection/table. +// report "not found" ÔÇö X# then opens a fresh connection/table. UNSIGNED32 AdsFindConnection25(UNSIGNED8* /*pucFullPath*/, ADSHANDLE* phConnect) { if (phConnect) *phConnect = 0; @@ -14603,7 +14991,7 @@ UNSIGNED32 AdsGetTableHandle25(ADSHANDLE /*hConnect*/, UNSIGNED8* /*pucName*/, } // The SAP "60" bookmark API hands back an opaque blob the app later -// replays. OpenADS encodes it as the 4-byte little-endian recno — +// replays. OpenADS encodes it as the 4-byte little-endian recno ÔÇö // stable for the table's lifetime, enough for navigate-and-return. UNSIGNED32 AdsGetBookmark60(ADSHANDLE hObj, UNSIGNED8* pucBookmark, UNSIGNED32* pulLength) { @@ -14624,7 +15012,7 @@ UNSIGNED32 AdsGetBookmark60(ADSHANDLE hObj, UNSIGNED8* pucBookmark, } // SAP / rddads signature: 3 args. AdsGetBookmark60 returns // (pucBookmark + *pulLength); the caller hands that exact length -// back into AdsGotoBookmark60 to replay the bookmark — needed +// 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. @@ -14642,7 +15030,7 @@ UNSIGNED32 AdsGotoBookmark60(ADSHANDLE hObj, UNSIGNED8* pucBookmark, // 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) +// 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 AdsGetMemoBlockSize(ADSHANDLE hObj, UNSIGNED16* pusBlockSize) { if (pusBlockSize == nullptr) return fail(openads::AE_INTERNAL_ERROR, ""); @@ -14657,7 +15045,7 @@ UNSIGNED32 AdsGetMemoBlockSize(ADSHANDLE hObj, UNSIGNED16* pusBlockSize) { } // --------------------------------------------------------------------------- -// M12.23 — close the export gap the X# Advantage RDD (XSharp.Rdd) relies on. +// 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 @@ -14726,7 +15114,7 @@ UNSIGNED32 AdsContinue(ADSHANDLE hTable, UNSIGNED16* pbFound) { 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 + // 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); @@ -15051,7 +15439,7 @@ UNSIGNED32 AdsSetEmpty(ADSHANDLE hObj, UNSIGNED8* pId) { &blank, 0); } UNSIGNED32 AdsSetNull(ADSHANDLE hObj, UNSIGNED8* pId) { - return AdsSetEmpty(hObj, pId); // DBF has no SQL NULL — store empty + return AdsSetEmpty(hObj, pId); // DBF has no SQL NULL ÔÇö store empty } UNSIGNED32 AdsSetShort(ADSHANDLE hObj, UNSIGNED8* pId, SIGNED32 sValue) { UNSIGNED8 nm[64]; @@ -15087,7 +15475,7 @@ UNSIGNED32 AdsGetDate(ADSHANDLE hObj, UNSIGNED8* pId, UNSIGNED8* pucBuf, } // --------------------------------------------------------------------------- -// SAP ACE API name aliases — binaries compiled against ace64.dll use these +// SAP ACE API name aliases ÔÇö binaries compiled against ace64.dll use these // names. OpenADS uses Create/Drop internally; SAP ACE uses Add/Remove. // --------------------------------------------------------------------------- @@ -15117,7 +15505,7 @@ UNSIGNED32 AdsDDRemoveTrigger(ADSHANDLE hConn, UNSIGNED8* pucName) { return AdsDDDropTrigger(hConn, pucName); } -// AdsDDFindFirstObject / FindNextObject / FindClose — stubs +// 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 AdsDDFindFirstObject(ADSHANDLE /*hObject*/, diff --git a/src/session/handle_registry.h b/src/session/handle_registry.h index 1d585538..70ef4123 100644 --- a/src/session/handle_registry.h +++ b/src/session/handle_registry.h @@ -20,7 +20,11 @@ enum class HandleKind { // M12.16 — remote (TCP) index handle. Wraps a server-side // index id; ABI calls (AdsSeek, AdsCloseIndex, …) route the // hIndex through the wire instead of a local IIndex. - RemoteIndex = 8 + RemoteIndex = 8, + // Optional SQL backend (postgresql:// …). + PostgresConnection = 9, + PostgresTable = 10, + PostgresIndex = 11 }; using Handle = std::uint64_t; diff --git a/src/sql_backend/postgres_backend.cpp b/src/sql_backend/postgres_backend.cpp new file mode 100644 index 00000000..77fe63e8 --- /dev/null +++ b/src/sql_backend/postgres_backend.cpp @@ -0,0 +1,112 @@ +#include "sql_backend/postgres_backend.h" + +#include "openads/ace.h" + +#include +#include + +#if defined(OPENADS_WITH_POSTGRESQL) +#include +#endif + +namespace openads::sql_backend { + +PostgresTable::FieldDesc map_pg_column(const char* name, + const char* data_type, + bool nullable, + int char_max_len, + int numeric_precision, + int numeric_scale) { + PostgresTable::FieldDesc fd; + fd.name = name ? name : ""; + fd.nullable = nullable; + + std::string dt = data_type ? data_type : ""; + for (auto& c : dt) { + c = static_cast(std::tolower(static_cast(c))); + } + + if (dt.find("int") != std::string::npos || + dt == "smallint" || dt == "bigint" || dt == "serial" || + dt == "bigserial") { + fd.type = ADS_INTEGER; + fd.length = 4; + fd.decimals = 0; + } else if (dt.find("double") != std::string::npos || + dt.find("real") != std::string::npos || + dt.find("numeric") != std::string::npos || + dt.find("decimal") != std::string::npos || + dt.find("money") != std::string::npos) { + fd.type = ADS_DOUBLE; + fd.length = 8; + fd.decimals = numeric_scale > 0 + ? static_cast(numeric_scale) + : 6; + (void)numeric_precision; + } else if (dt.find("bytea") != std::string::npos) { + fd.type = ADS_BINARY; + fd.length = 10; + fd.decimals = 0; + } else { + fd.type = ADS_STRING; + fd.length = char_max_len > 0 + ? static_cast(char_max_len) + : 64; + fd.decimals = 0; + } + return fd; +} + +#if defined(OPENADS_WITH_POSTGRESQL) + +std::string format_pg_value(pg_result* res, int row, int col, bool& is_null) { + is_null = false; + if (PQgetisnull(res, row, col)) { + is_null = true; + return {}; + } + const char* val = PQgetvalue(res, row, col); + if (val == nullptr) { + is_null = true; + return {}; + } + return val; +} + +util::Error postgres_error(const char* context, const char* msg) { + return util::Error{5001, 0, + std::string(context) + ": " + (msg ? msg : ""), + ""}; +} + +#else + +std::string format_pg_value(pg_result*, int, int, bool& is_null) { + is_null = true; + return {}; +} + +util::Error postgres_error(const char* context, const char* msg) { + (void)context; + (void)msg; + return util::Error{5004, 0, "postgresql backend disabled", ""}; +} + +#endif + +std::size_t field_index_ci(const PostgresTable& tbl, const std::string& name) { + std::string want = name; + for (auto& c : want) { + c = static_cast(std::toupper(static_cast(c))); + } + for (std::size_t i = 0; i < tbl.fields.size(); ++i) { + std::string have = tbl.fields[i].name; + for (auto& c : have) { + c = static_cast(std::toupper(static_cast(c))); + } + if (have == want) return i; + } + return static_cast(-1); +} + +} // namespace openads::sql_backend \ No newline at end of file diff --git a/src/sql_backend/postgres_backend.h b/src/sql_backend/postgres_backend.h new file mode 100644 index 00000000..9278c5a8 --- /dev/null +++ b/src/sql_backend/postgres_backend.h @@ -0,0 +1,23 @@ +#pragma once + +#include "sql_backend/postgres_table.h" +#include "util/result.h" + +struct pg_result; + +namespace openads::sql_backend { + +PostgresTable::FieldDesc map_pg_column(const char* name, + const char* data_type, + bool nullable, + int char_max_len, + int numeric_precision, + int numeric_scale); + +std::string format_pg_value(pg_result* res, int row, int col, bool& is_null); + +util::Error postgres_error(const char* context, const char* msg); + +std::size_t field_index_ci(const PostgresTable& tbl, const std::string& name); + +} // namespace openads::sql_backend \ No newline at end of file diff --git a/src/sql_backend/postgres_connection.cpp b/src/sql_backend/postgres_connection.cpp new file mode 100644 index 00000000..f1763015 --- /dev/null +++ b/src/sql_backend/postgres_connection.cpp @@ -0,0 +1,507 @@ +#include "sql_backend/postgres_connection.h" + +#include "sql_backend/postgres_backend.h" +#include "sql_backend/sql_common.h" + +#include + +#if defined(OPENADS_WITH_POSTGRESQL) +#include +#endif + +namespace openads::sql_backend { + +namespace { + +#if defined(OPENADS_WITH_POSTGRESQL) + +util::Result load_current_row(PGconn* conn, PostgresTable* tbl); + +util::Result> +describe_table_impl(PGconn* conn, PostgresTable* tbl) { + if (!is_safe_identifier(tbl->name)) { + return util::Error{5001, 0, "invalid table name", tbl->name}; + } + const char* params[1] = {tbl->name.c_str()}; + PGresult* res = PQexecParams( + conn, + "SELECT column_name, data_type, is_nullable, " + "character_maximum_length, numeric_precision, numeric_scale " + "FROM information_schema.columns " + "WHERE table_schema = ANY (current_schemas(true)) " + "AND table_name = $1 " + "ORDER BY ordinal_position", + 1, nullptr, params, nullptr, nullptr, 0); + if (PQresultStatus(res) != PGRES_TUPLES_OK) { + const char* msg = PQerrorMessage(conn); + PQclear(res); + return postgres_error("describe_table", msg); + } + const int rows = PQntuples(res); + if (rows <= 0) { + PQclear(res); + return util::Error{5001, 0, "table not found or has no columns", + tbl->name}; + } + std::vector out; + out.reserve(static_cast(rows)); + for (int r = 0; r < rows; ++r) { + const char* nullable = PQgetvalue(res, r, 2); + out.push_back(map_pg_column( + PQgetvalue(res, r, 0), + PQgetvalue(res, r, 1), + nullable && nullable[0] == 'Y', + std::atoi(PQgetvalue(res, r, 3)), + std::atoi(PQgetvalue(res, r, 4)), + std::atoi(PQgetvalue(res, r, 5)))); + } + PQclear(res); + tbl->fields = out; + tbl->fields_cached = true; + return out; +} + +util::Result position_at_ctid(PGconn* conn, PostgresTable* tbl, + const std::string& ctid) { + if (tbl == nullptr || conn == nullptr) { + return util::Error{5001, 0, "invalid postgres table state", ""}; + } + auto it = std::find(tbl->ctids.begin(), tbl->ctids.end(), ctid); + if (it == tbl->ctids.end()) { + tbl->positioned = false; + tbl->row_valid = false; + return util::Result{}; + } + tbl->pos = static_cast( + std::distance(tbl->ctids.begin(), it)); + tbl->positioned = true; + tbl->current_recno = static_cast(tbl->pos + 1); + return load_current_row(conn, tbl); +} + +util::Result load_current_row(PGconn* conn, PostgresTable* tbl) { + if (tbl == nullptr || conn == nullptr) { + return util::Error{5001, 0, "invalid postgres table state", ""}; + } + if (!tbl->positioned) { + tbl->row_valid = false; + tbl->current_row.clear(); + tbl->current_nulls.clear(); + return util::Result{}; + } + if (!tbl->fields_cached) { + auto d = describe_table_impl(conn, tbl); + if (!d) return d.error(); + } + + const std::string sql = + "SELECT * FROM \"" + tbl->name + "\" WHERE ctid = $1::tid"; + const std::string& ctid = tbl->ctids[tbl->pos]; + const char* params[1] = {ctid.c_str()}; + PGresult* res = PQexecParams(conn, sql.c_str(), 1, nullptr, params, + nullptr, nullptr, 0); + if (PQresultStatus(res) != PGRES_TUPLES_OK) { + const char* msg = PQerrorMessage(conn); + PQclear(res); + return postgres_error("load row", msg); + } + + tbl->current_row.clear(); + tbl->current_nulls.clear(); + tbl->row_valid = false; + + if (PQntuples(res) == 1) { + const int cols = PQnfields(res); + tbl->current_row.resize(static_cast(cols)); + tbl->current_nulls.resize(static_cast(cols)); + for (int c = 0; c < cols; ++c) { + bool is_null = false; + tbl->current_row[c] = format_pg_value(res, 0, c, is_null); + tbl->current_nulls[c] = is_null; + } + tbl->row_valid = true; + } else { + tbl->positioned = false; + } + PQclear(res); + return util::Result{}; +} + +#endif + +} // namespace + +struct PostgresConnection::Impl { +#if defined(OPENADS_WITH_POSTGRESQL) + PGconn* conn = nullptr; +#endif +}; + +PostgresConnection::PostgresConnection() = default; +PostgresConnection::~PostgresConnection() { disconnect(); } + +PostgresConnection::PostgresConnection(PostgresConnection&& other) noexcept + : impl_(std::move(other.impl_)), conninfo_(std::move(other.conninfo_)) {} + +PostgresConnection& PostgresConnection::operator=( + PostgresConnection&& other) noexcept { + if (this != &other) { + disconnect(); + impl_ = std::move(other.impl_); + conninfo_ = std::move(other.conninfo_); + } + return *this; +} + +util::Result PostgresConnection::open( + const PostgresUri& uri) { +#if defined(OPENADS_WITH_POSTGRESQL) + PostgresConnection conn; + conn.conninfo_ = uri.conninfo; + conn.impl_ = std::make_unique(); + + PGconn* raw = PQconnectdb(uri.conninfo.c_str()); + if (raw == nullptr) { + return util::Error{5001, 0, "PQconnectdb failed", ""}; + } + if (PQstatus(raw) != CONNECTION_OK) { + util::Error e = postgres_error("connect", PQerrorMessage(raw)); + PQfinish(raw); + return e; + } + conn.impl_->conn = raw; + return std::move(conn); +#else + (void)uri; + return util::Error{5004, 0, + "postgresql backend requires " + "OPENADS_WITH_POSTGRESQL=ON", + ""}; +#endif +} + +void PostgresConnection::disconnect() noexcept { +#if defined(OPENADS_WITH_POSTGRESQL) + if (impl_ && impl_->conn) { + PQfinish(impl_->conn); + impl_->conn = nullptr; + } +#endif + impl_.reset(); +} + +bool PostgresConnection::valid() const noexcept { +#if defined(OPENADS_WITH_POSTGRESQL) + return impl_ && impl_->conn != nullptr && + PQstatus(impl_->conn) == CONNECTION_OK; +#else + return false; +#endif +} + +util::Result> +PostgresConnection::open_table(const std::string& table_name) { +#if defined(OPENADS_WITH_POSTGRESQL) + if (!valid()) { + return util::Error{5001, 0, "postgres connection not open", ""}; + } + if (!is_safe_identifier(table_name)) { + return util::Error{5001, 0, "invalid table name", table_name}; + } + + auto tbl = std::make_unique(); + tbl->conn = this; + tbl->name = table_name; + + const std::string sql = + "SELECT ctid::text FROM \"" + table_name + "\" ORDER BY ctid"; + PGresult* res = PQexec(impl_->conn, sql.c_str()); + if (PQresultStatus(res) != PGRES_TUPLES_OK) { + const char* msg = PQerrorMessage(impl_->conn); + PQclear(res); + return postgres_error("ctid list", msg); + } + const int rows = PQntuples(res); + tbl->ctids.reserve(static_cast(rows)); + for (int r = 0; r < rows; ++r) { + tbl->ctids.emplace_back(PQgetvalue(res, r, 0)); + } + PQclear(res); + + tbl->cached_rec_count = static_cast(tbl->ctids.size()); + tbl->rec_count_cached = true; + tbl->positioned = false; + tbl->row_valid = false; + tbl->current_recno = 0; + tbl->current_deleted = false; + tbl->pos = 0; + + if (auto d = describe_table_impl(impl_->conn, tbl.get()); !d) { + return d.error(); + } + return tbl; +#else + (void)table_name; + return util::Error{5004, 0, + "postgresql backend requires " + "OPENADS_WITH_POSTGRESQL=ON", + ""}; +#endif +} + +util::Result PostgresConnection::goto_top(PostgresTable* tbl) { +#if defined(OPENADS_WITH_POSTGRESQL) + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid postgres goto_top", ""}; + } + if (tbl->ctids.empty()) { + tbl->positioned = false; + tbl->row_valid = false; + tbl->current_recno = 0; + tbl->pos = 0; + return util::Result{}; + } + tbl->pos = 0; + tbl->positioned = true; + tbl->current_recno = 1; + return load_current_row(impl_->conn, tbl); +#else + (void)tbl; + return util::Error{5004, 0, "postgresql backend disabled", ""}; +#endif +} + +util::Result PostgresConnection::goto_bottom(PostgresTable* tbl) { +#if defined(OPENADS_WITH_POSTGRESQL) + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid postgres goto_bottom", ""}; + } + if (tbl->ctids.empty()) { + tbl->positioned = false; + tbl->row_valid = false; + tbl->current_recno = 0; + tbl->pos = 0; + return util::Result{}; + } + tbl->pos = tbl->ctids.size() - 1; + tbl->positioned = true; + tbl->current_recno = static_cast(tbl->pos + 1); + return load_current_row(impl_->conn, tbl); +#else + (void)tbl; + return util::Error{5004, 0, "postgresql backend disabled", ""}; +#endif +} + +util::Result PostgresConnection::skip(PostgresTable* tbl, + std::int32_t step) { +#if defined(OPENADS_WITH_POSTGRESQL) + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid postgres skip", ""}; + } + if (step == 0) return util::Result{}; + if (tbl->ctids.empty()) { + tbl->positioned = false; + tbl->row_valid = false; + tbl->pos = 0; + return util::Result{}; + } + + std::int64_t next = 0; + if (!tbl->positioned) { + if (tbl->pos == 0) { + if (step > 0) next = step - 1; + else return util::Error{5026, 0, "bof", ""}; + } else { + if (step < 0) { + next = static_cast(tbl->pos) + step; + } else { + return util::Result{}; + } + } + } else { + next = static_cast(tbl->pos) + step; + } + + if (next < 0) { + tbl->positioned = false; + tbl->row_valid = false; + tbl->pos = 0; + return util::Error{5026, 0, "bof", ""}; + } + if (static_cast(next) >= tbl->ctids.size()) { + tbl->positioned = false; + tbl->row_valid = false; + tbl->pos = tbl->ctids.size(); + return util::Result{}; + } + + tbl->pos = static_cast(next); + tbl->positioned = true; + tbl->current_recno = static_cast(tbl->pos + 1); + return load_current_row(impl_->conn, tbl); +#else + (void)tbl; + (void)step; + return util::Error{5004, 0, "postgresql backend disabled", ""}; +#endif +} + +util::Result PostgresConnection::at_eof(PostgresTable* tbl) const { +#if defined(OPENADS_WITH_POSTGRESQL) + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid postgres at_eof", ""}; + } + if (tbl->ctids.empty()) return true; + if (!tbl->positioned && tbl->pos >= tbl->ctids.size()) return true; + return false; +#else + (void)tbl; + return util::Error{5004, 0, "postgresql backend disabled", ""}; +#endif +} + +util::Result PostgresConnection::at_bof(PostgresTable* tbl) const { +#if defined(OPENADS_WITH_POSTGRESQL) + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid postgres at_bof", ""}; + } + if (tbl->ctids.empty()) return true; + return !tbl->positioned && tbl->pos == 0; +#else + (void)tbl; + return util::Error{5004, 0, "postgresql backend disabled", ""}; +#endif +} + +util::Result PostgresConnection::record_count(PostgresTable* tbl) { +#if defined(OPENADS_WITH_POSTGRESQL) + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid postgres record_count", ""}; + } + if (tbl->rec_count_cached) return tbl->cached_rec_count; + tbl->cached_rec_count = static_cast(tbl->ctids.size()); + tbl->rec_count_cached = true; + return tbl->cached_rec_count; +#else + (void)tbl; + return util::Error{5004, 0, "postgresql backend disabled", ""}; +#endif +} + +util::Result> +PostgresConnection::describe_table(PostgresTable* tbl) { +#if defined(OPENADS_WITH_POSTGRESQL) + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid postgres describe_table", ""}; + } + if (tbl->fields_cached) return tbl->fields; + return describe_table_impl(impl_->conn, tbl); +#else + (void)tbl; + return util::Error{5004, 0, "postgresql backend disabled", ""}; +#endif +} + +util::Result PostgresConnection::read_field( + PostgresTable* tbl, const std::string& field_name, + std::string& buf, bool& is_null) const { +#if defined(OPENADS_WITH_POSTGRESQL) + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid postgres read_field", ""}; + } + if (!tbl->fields_cached) { + return util::Error{5001, 0, "schema not cached", ""}; + } + if (!tbl->row_valid) { + return util::Error{5026, 0, "no current record", ""}; + } + + const std::size_t idx = field_index_ci(*tbl, field_name); + if (idx == static_cast(-1)) { + return util::Error{5063, 0, "column not found", field_name}; + } + if (idx >= tbl->current_row.size()) { + return util::Error{5001, 0, "row cache mismatch", ""}; + } + is_null = tbl->current_nulls[idx]; + buf = tbl->current_row[idx]; + return util::Result{}; +#else + (void)tbl; + (void)field_name; + (void)buf; + (void)is_null; + return util::Error{5004, 0, "postgresql backend disabled", ""}; +#endif +} + +util::Result PostgresConnection::seek_index( + PostgresTable* tbl, const std::string& column, const std::string& key, + bool soft, bool last_key) { +#if defined(OPENADS_WITH_POSTGRESQL) + if (!valid() || tbl == nullptr) { + return util::Error{5001, 0, "invalid postgres seek", ""}; + } + if (!is_safe_identifier(column)) { + return util::Error{5001, 0, "invalid seek column", column}; + } + if (!tbl->fields_cached) { + if (auto d = describe_table_impl(impl_->conn, tbl); !d) return d.error(); + } + if (field_index_ci(*tbl, column) == static_cast(-1)) { + return util::Error{5063, 0, "seek column not found", column}; + } + + std::string sql; + if (last_key) { + sql = soft + ? "SELECT ctid::text FROM \"" + tbl->name + "\" WHERE \"" + + column + "\" <= $1 ORDER BY \"" + column + "\" DESC LIMIT 1" + : "SELECT ctid::text FROM \"" + tbl->name + "\" WHERE \"" + + column + "\" = $1 ORDER BY \"" + column + "\" DESC LIMIT 1"; + } else { + sql = soft + ? "SELECT ctid::text FROM \"" + tbl->name + "\" WHERE \"" + + column + "\" >= $1 ORDER BY \"" + column + "\" ASC LIMIT 1" + : "SELECT ctid::text FROM \"" + tbl->name + "\" WHERE \"" + + column + "\" = $1 LIMIT 1"; + } + + const char* params[1] = {key.c_str()}; + PGresult* res = PQexecParams(impl_->conn, sql.c_str(), 1, nullptr, params, + nullptr, nullptr, 0); + if (PQresultStatus(res) != PGRES_TUPLES_OK) { + const char* msg = PQerrorMessage(impl_->conn); + PQclear(res); + return postgres_error("seek", msg); + } + + bool found = false; + if (PQntuples(res) == 1) { + const std::string ctid = PQgetvalue(res, 0, 0); + PQclear(res); + if (auto p = position_at_ctid(impl_->conn, tbl, ctid); !p) { + return p.error(); + } + found = tbl->positioned && tbl->row_valid; + tbl->last_seek_found = found; + } else { + PQclear(res); + tbl->positioned = false; + tbl->row_valid = false; + found = false; + tbl->last_seek_found = false; + } + return found; +#else + (void)tbl; + (void)column; + (void)key; + (void)soft; + (void)last_key; + return util::Error{5004, 0, "postgresql backend disabled", ""}; +#endif +} + +} // namespace openads::sql_backend \ No newline at end of file diff --git a/src/sql_backend/postgres_connection.h b/src/sql_backend/postgres_connection.h new file mode 100644 index 00000000..b42b9d64 --- /dev/null +++ b/src/sql_backend/postgres_connection.h @@ -0,0 +1,62 @@ +#pragma once + +#include "sql_backend/postgres_table.h" +#include "sql_backend/postgres_uri.h" +#include "util/result.h" + +#include +#include +#include + +namespace openads::sql_backend { + +class PostgresConnection { +public: + PostgresConnection(); + ~PostgresConnection(); + + PostgresConnection(PostgresConnection&&) noexcept; + PostgresConnection& operator=(PostgresConnection&&) noexcept; + + PostgresConnection(const PostgresConnection&) = delete; + PostgresConnection& operator=(const PostgresConnection&) = delete; + + static util::Result open(const PostgresUri& uri); + + void disconnect() noexcept; + bool valid() const noexcept; + + util::Result> + open_table(const std::string& table_name); + + util::Result goto_top(PostgresTable* tbl); + util::Result goto_bottom(PostgresTable* tbl); + util::Result skip(PostgresTable* tbl, std::int32_t step); + + util::Result at_eof(PostgresTable* tbl) const; + util::Result at_bof(PostgresTable* tbl) const; + util::Result record_count(PostgresTable* tbl); + + util::Result> + describe_table(PostgresTable* tbl); + + util::Result read_field(PostgresTable* tbl, + const std::string& field_name, + std::string& buf, + bool& is_null) const; + + util::Result seek_index(PostgresTable* tbl, + const std::string& column, + const std::string& key, + bool soft, + bool last_key); + + const std::string& conninfo() const noexcept { return conninfo_; } + +private: + struct Impl; + std::unique_ptr impl_; + std::string conninfo_; +}; + +} // namespace openads::sql_backend \ No newline at end of file diff --git a/src/sql_backend/postgres_index.h b/src/sql_backend/postgres_index.h new file mode 100644 index 00000000..47360482 --- /dev/null +++ b/src/sql_backend/postgres_index.h @@ -0,0 +1,15 @@ +#pragma once + +#include + +namespace openads::sql_backend { + +struct PostgresTable; + +struct PostgresIndex { + PostgresTable* parent = nullptr; + std::string column; + bool last_seek_found = false; +}; + +} // namespace openads::sql_backend \ No newline at end of file diff --git a/src/sql_backend/postgres_table.h b/src/sql_backend/postgres_table.h new file mode 100644 index 00000000..30f0f593 --- /dev/null +++ b/src/sql_backend/postgres_table.h @@ -0,0 +1,41 @@ +#pragma once + +#include +#include +#include + +namespace openads::sql_backend { + +class PostgresConnection; + +struct PostgresTable { + PostgresConnection* conn = nullptr; + std::string name; + + struct FieldDesc { + std::string name; + std::uint16_t type = 0; + std::uint32_t length = 0; + std::uint16_t decimals = 0; + bool nullable = true; + }; + + std::vector fields; + bool fields_cached = false; + + std::vector current_row; + std::vector current_nulls; + bool row_valid = false; + + std::uint32_t current_recno = 0; + bool current_deleted = false; + std::uint32_t cached_rec_count = 0; + bool rec_count_cached = false; + + std::vector ctids; + std::size_t pos = 0; + bool positioned = false; + bool last_seek_found = false; +}; + +} // namespace openads::sql_backend \ No newline at end of file diff --git a/src/sql_backend/postgres_uri.cpp b/src/sql_backend/postgres_uri.cpp new file mode 100644 index 00000000..405be376 --- /dev/null +++ b/src/sql_backend/postgres_uri.cpp @@ -0,0 +1,22 @@ +#include "sql_backend/postgres_uri.h" + +namespace openads::sql_backend { + +bool parse_postgres_uri(const std::string& uri, PostgresUri& out) { + static constexpr const char* kPg = "postgresql://"; + static constexpr const char* kPg2 = "postgres://"; + const auto plen = std::char_traits::length(kPg); + const auto plen2 = std::char_traits::length(kPg2); + + if (uri.size() >= plen && uri.compare(0, plen, kPg) == 0) { + out.conninfo = uri; + return true; + } + if (uri.size() >= plen2 && uri.compare(0, plen2, kPg2) == 0) { + out.conninfo = "postgresql://" + uri.substr(plen2); + return true; + } + return false; +} + +} // namespace openads::sql_backend \ No newline at end of file diff --git a/src/sql_backend/postgres_uri.h b/src/sql_backend/postgres_uri.h new file mode 100644 index 00000000..86993eb5 --- /dev/null +++ b/src/sql_backend/postgres_uri.h @@ -0,0 +1,14 @@ +#pragma once + +#include + +namespace openads::sql_backend { + +// Full libpq connection URI after `postgresql://` / `postgres://` prefix. +struct PostgresUri { + std::string conninfo; +}; + +bool parse_postgres_uri(const std::string& uri, PostgresUri& out); + +} // namespace openads::sql_backend \ No newline at end of file diff --git a/src/sql_backend/sql_common.cpp b/src/sql_backend/sql_common.cpp new file mode 100644 index 00000000..a9d44f81 --- /dev/null +++ b/src/sql_backend/sql_common.cpp @@ -0,0 +1,17 @@ +#include "sql_backend/sql_common.h" + +namespace openads::sql_backend { + +bool is_safe_identifier(const std::string& name) { + if (name.empty()) return false; + for (char c : name) { + const bool ok = (c >= 'a' && c <= 'z') || + (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || + c == '_'; + if (!ok) return false; + } + return true; +} + +} // namespace openads::sql_backend \ No newline at end of file diff --git a/src/sql_backend/sql_common.h b/src/sql_backend/sql_common.h new file mode 100644 index 00000000..55edb27d --- /dev/null +++ b/src/sql_backend/sql_common.h @@ -0,0 +1,9 @@ +#pragma once + +#include + +namespace openads::sql_backend { + +bool is_safe_identifier(const std::string& name); + +} // namespace openads::sql_backend \ No newline at end of file diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 21d33f6f..6080d9cb 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -142,6 +142,13 @@ add_executable(openads_unit_tests unit/abi_sql_dd_sql_test.cpp ) +if(OPENADS_WITH_POSTGRESQL) + target_sources(openads_unit_tests PRIVATE + unit/abi_plus_postgres_read_test.cpp + unit/abi_plus_postgres_seek_test.cpp + ) +endif() + # M11.4 — side DLL with stored procedures used by abi_aep_test. add_library(openads_test_aep_proc SHARED fixtures/test_aep_proc.cpp diff --git a/tests/unit/abi_plus_postgres_read_test.cpp b/tests/unit/abi_plus_postgres_read_test.cpp new file mode 100644 index 00000000..04011edf --- /dev/null +++ b/tests/unit/abi_plus_postgres_read_test.cpp @@ -0,0 +1,119 @@ +#include "doctest.h" +#include "openads/ace.h" +#include "openads/error.h" + +#include +#include +#include +#include +#include + +#if defined(OPENADS_WITH_POSTGRESQL) +#include +#endif + +#if defined(OPENADS_WITH_POSTGRESQL) + +namespace { + +void seed_fixture(PGconn* conn) { + auto exec = [&](const char* sql) { + PGresult* res = PQexec(conn, sql); + if (PQresultStatus(res) != PGRES_COMMAND_OK) { + const char* msg = PQerrorMessage(conn); + PQclear(res); + FAIL(std::string("seed failed: ") + (msg ? msg : "")); + } + PQclear(res); + }; + exec("DROP TABLE IF EXISTS clientes"); + exec("CREATE TABLE clientes (" + "id INTEGER PRIMARY KEY, nome TEXT, saldo DOUBLE PRECISION)"); + exec("INSERT INTO clientes (id, nome, saldo) VALUES " + "(1, 'Ana', 10.5), (2, 'Bob', NULL), (3, 'Cid', 0.0)"); +} + +std::string field_str(ADSHANDLE hTable, const char* name) { + UNSIGNED8 fld[32]; + std::memcpy(fld, name, std::strlen(name) + 1); + UNSIGNED8 buf[128] = {0}; + UNSIGNED32 cap = sizeof(buf); + REQUIRE(AdsGetField(hTable, fld, buf, &cap, 0) == 0); + return std::string(reinterpret_cast(buf), cap); +} + +} // namespace + +TEST_CASE("ABI: postgresql read-only AdsOpenTable navigation") { + const char* uri_env = std::getenv("OPENADS_TEST_PG_URI"); + if (uri_env == nullptr || uri_env[0] == '\0') { + SKIP("Set OPENADS_TEST_PG_URI (postgresql://...) for E2E test."); + } + + PGconn* seed = PQconnectdb(uri_env); + if (PQstatus(seed) != CONNECTION_OK) { + PQfinish(seed); + SKIP("Cannot connect with OPENADS_TEST_PG_URI."); + } + seed_fixture(seed); + PQfinish(seed); + + const std::string uri = uri_env; + std::vector srv(uri.size() + 1); + std::memcpy(srv.data(), uri.c_str(), uri.size() + 1); + + ADSHANDLE hConn = 0; + REQUIRE(AdsConnect60(srv.data(), ADS_LOCAL_SERVER, + nullptr, nullptr, 0, &hConn) == 0); + + UNSIGNED8 tbl_name[32] = "clientes"; + ADSHANDLE hTable = 0; + REQUIRE(AdsOpenTable(hConn, tbl_name, tbl_name, + ADS_DEFAULT, 0, 0, 0, ADS_READONLY, + &hTable) == 0); + + UNSIGNED16 nfields = 0; + REQUIRE(AdsGetNumFields(hTable, &nfields) == 0); + CHECK(nfields == 3); + + UNSIGNED32 count = 0; + REQUIRE(AdsGetRecordCount(hTable, 0, &count) == 0); + CHECK(count == 3); + + REQUIRE(AdsGotoTop(hTable) == 0); + + UNSIGNED16 bof = 1; + REQUIRE(AdsAtBOF(hTable, &bof) == 0); + CHECK(bof == 0); + + CHECK(field_str(hTable, "nome") == std::string(64, ' ').replace(0, 3, "Ana")); + + REQUIRE(AdsSkip(hTable, 1) == 0); + CHECK(field_str(hTable, "saldo").empty()); + + REQUIRE(AdsSkip(hTable, 1) == 0); + CHECK(field_str(hTable, "nome") == std::string(64, ' ').replace(0, 3, "Cid")); + + UNSIGNED16 eof = 0; + REQUIRE(AdsAtEOF(hTable, &eof) == 0); + CHECK(eof == 0); + + REQUIRE(AdsSkip(hTable, 1) == 0); + REQUIRE(AdsAtEOF(hTable, &eof) == 0); + CHECK(eof == 1); + + REQUIRE(AdsCloseTable(hTable) == 0); + REQUIRE(AdsDisconnect(hConn) == 0); +} + +#else + +TEST_CASE("ABI: postgresql backend disabled at compile time") { + UNSIGNED8 uri[] = "postgresql://127.0.0.1/none"; + ADSHANDLE hConn = 0; + const UNSIGNED32 rc = AdsConnect60(uri, ADS_LOCAL_SERVER, + nullptr, nullptr, 0, &hConn); + CHECK(rc == openads::AE_FUNCTION_NOT_AVAILABLE); +} + +#endif \ No newline at end of file diff --git a/tests/unit/abi_plus_postgres_seek_test.cpp b/tests/unit/abi_plus_postgres_seek_test.cpp new file mode 100644 index 00000000..8436c48d --- /dev/null +++ b/tests/unit/abi_plus_postgres_seek_test.cpp @@ -0,0 +1,84 @@ +#include "doctest.h" +#include "openads/ace.h" +#include "openads/error.h" + +#include +#include +#include +#include +#include + +#if defined(OPENADS_WITH_POSTGRESQL) +#include +#endif + +#if defined(OPENADS_WITH_POSTGRESQL) + +namespace { + +void seed_fixture(PGconn* conn) { + auto exec = [&](const char* sql) { + PGresult* res = PQexec(conn, sql); + if (PQresultStatus(res) != PGRES_COMMAND_OK) { + PQclear(res); + FAIL("seed failed"); + } + PQclear(res); + }; + exec("DROP TABLE IF EXISTS clientes"); + exec("CREATE TABLE clientes (id INTEGER PRIMARY KEY, nome TEXT)"); + exec("INSERT INTO clientes (id, nome) VALUES (1,'Ana'),(2,'Bob'),(3,'Cid')"); +} + +} // namespace + +TEST_CASE("ABI: postgresql AdsSeek on column index") { + const char* uri_env = std::getenv("OPENADS_TEST_PG_URI"); + if (uri_env == nullptr || uri_env[0] == '\0') { + SKIP("Set OPENADS_TEST_PG_URI for E2E test."); + } + + PGconn* seed = PQconnectdb(uri_env); + if (PQstatus(seed) != CONNECTION_OK) { + PQfinish(seed); + SKIP("Cannot connect with OPENADS_TEST_PG_URI."); + } + seed_fixture(seed); + PQfinish(seed); + + const std::string uri = uri_env; + std::vector srv(uri.size() + 1); + std::memcpy(srv.data(), uri.c_str(), uri.size() + 1); + + ADSHANDLE hConn = 0; + REQUIRE(AdsConnect60(srv.data(), ADS_LOCAL_SERVER, + nullptr, nullptr, 0, &hConn) == 0); + + UNSIGNED8 tbl[32] = "clientes"; + ADSHANDLE hTable = 0; + REQUIRE(AdsOpenTable(hConn, tbl, tbl, ADS_DEFAULT, 0, 0, 0, + ADS_READONLY, &hTable) == 0); + + UNSIGNED8 tag[16] = "id"; + ADSHANDLE ahIndex[1] = {0}; + UNSIGNED16 nIdx = 0; + REQUIRE(AdsOpenIndex(hTable, tag, ahIndex, &nIdx) == 0); + REQUIRE(nIdx == 1); + + UNSIGNED8 key[] = "2"; + UNSIGNED16 found = 0; + REQUIRE(AdsSeek(ahIndex[0], key, 1, 0, 0, &found) == 0); + CHECK(found == 1); + + UNSIGNED8 buf[64] = {0}; + UNSIGNED32 cap = sizeof(buf); + REQUIRE(AdsGetField(hTable, (UNSIGNED8*)"nome", buf, &cap, 0) == 0); + CHECK(std::string(reinterpret_cast(buf), cap) == + std::string(64, ' ').replace(0, 3, "Bob")); + + REQUIRE(AdsCloseIndex(ahIndex[0]) == 0); + REQUIRE(AdsCloseTable(hTable) == 0); + REQUIRE(AdsDisconnect(hConn) == 0); +} + +#endif \ No newline at end of file diff --git a/tools/scripts/build_nmake_postgres.bat b/tools/scripts/build_nmake_postgres.bat new file mode 100644 index 00000000..b7e65737 --- /dev/null +++ b/tools/scripts/build_nmake_postgres.bat @@ -0,0 +1,27 @@ +@echo off +if not defined OPENADS_TOOLCHAIN_ROOT ( + echo ERROR: set OPENADS_TOOLCHAIN_ROOT to your MSVC toolchain root. + exit /b 1 +) +if not defined OPENADS_LIBPQ_INCLUDE ( + if exist "%OPENADS_TOOLCHAIN_ROOT%\pgsql\include\libpq-fe.h" ( + set "OPENADS_LIBPQ_INCLUDE=%OPENADS_TOOLCHAIN_ROOT%\pgsql\include" + ) +) +if not defined OPENADS_LIBPQ_LIBRARY ( + if exist "%OPENADS_TOOLCHAIN_ROOT%\libpq\x86\libpq.lib" ( + set "OPENADS_LIBPQ_LIBRARY=%OPENADS_TOOLCHAIN_ROOT%\libpq\x86\libpq.lib" + ) +) +call "%OPENADS_TOOLCHAIN_ROOT%\msvc\setup_x86.bat" +if exist "%OPENADS_TOOLCHAIN_ROOT%\winlibs-x86_64\bin" ( + set "PATH=%OPENADS_TOOLCHAIN_ROOT%\winlibs-x86_64\bin;%PATH%" +) +cd /d "%~dp0..\.." +set "EXTRA=-DOPENADS_WITH_POSTGRESQL=ON -DOPENADS_WITH_HTTP=OFF -DOPENADS_WARNINGS_AS_ERRORS=OFF" +if defined OPENADS_LIBPQ_INCLUDE set "EXTRA=%EXTRA% -DOPENADS_LIBPQ_INCLUDE=%OPENADS_LIBPQ_INCLUDE%" +if defined OPENADS_LIBPQ_LIBRARY set "EXTRA=%EXTRA% -DOPENADS_LIBPQ_LIBRARY=%OPENADS_LIBPQ_LIBRARY%" +cmake -S . -B build\pg -G "NMake Makefiles" -DCMAKE_BUILD_TYPE=Release %EXTRA% +if errorlevel 1 exit /b 1 +cmake --build build\pg +exit /b %ERRORLEVEL% \ No newline at end of file From 49b84ac8df274876cea4df9d86c9a531a1fa64e8 Mon Sep 17 00:00:00 2001 From: Admnwk Date: Sat, 20 Jun 2026 15:29:57 -0300 Subject: [PATCH 3/8] test: postgresql ABI e2e read/seek against real libpq Add pgsql:// URI alias and compile-time disabled rejection. Fix doctest tests: default local PG URI, correct OpenIndex array capacity. --- src/abi/ace_exports.cpp | 14 +++++ src/sql_backend/postgres_uri.cpp | 10 +++- src/sql_backend/postgres_uri.h | 2 +- tests/unit/abi_plus_postgres_read_test.cpp | 32 ++++++---- tests/unit/abi_plus_postgres_seek_test.cpp | 68 +++++++++++++++------- 5 files changed, 90 insertions(+), 36 deletions(-) diff --git a/src/abi/ace_exports.cpp b/src/abi/ace_exports.cpp index 5d45b429..63fa4858 100644 --- a/src/abi/ace_exports.cpp +++ b/src/abi/ace_exports.cpp @@ -900,6 +900,20 @@ UNSIGNED32 AdsConnect60(UNSIGNED8* pucServer, UNSIGNED16 /*usServerType*/, 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()); diff --git a/src/sql_backend/postgres_uri.cpp b/src/sql_backend/postgres_uri.cpp index 405be376..ccf0b4a4 100644 --- a/src/sql_backend/postgres_uri.cpp +++ b/src/sql_backend/postgres_uri.cpp @@ -3,10 +3,12 @@ namespace openads::sql_backend { bool parse_postgres_uri(const std::string& uri, PostgresUri& out) { - static constexpr const char* kPg = "postgresql://"; - static constexpr const char* kPg2 = "postgres://"; + static constexpr const char* kPg = "postgresql://"; + static constexpr const char* kPg2 = "postgres://"; + static constexpr const char* kPg3 = "pgsql://"; const auto plen = std::char_traits::length(kPg); const auto plen2 = std::char_traits::length(kPg2); + const auto plen3 = std::char_traits::length(kPg3); if (uri.size() >= plen && uri.compare(0, plen, kPg) == 0) { out.conninfo = uri; @@ -16,6 +18,10 @@ bool parse_postgres_uri(const std::string& uri, PostgresUri& out) { out.conninfo = "postgresql://" + uri.substr(plen2); return true; } + if (uri.size() >= plen3 && uri.compare(0, plen3, kPg3) == 0) { + out.conninfo = "postgresql://" + uri.substr(plen3); + return true; + } return false; } diff --git a/src/sql_backend/postgres_uri.h b/src/sql_backend/postgres_uri.h index 86993eb5..c58502ce 100644 --- a/src/sql_backend/postgres_uri.h +++ b/src/sql_backend/postgres_uri.h @@ -4,7 +4,7 @@ namespace openads::sql_backend { -// Full libpq connection URI after `postgresql://` / `postgres://` prefix. +// Full libpq connection URI after `postgresql://`, `postgres://`, or `pgsql://`. struct PostgresUri { std::string conninfo; }; diff --git a/tests/unit/abi_plus_postgres_read_test.cpp b/tests/unit/abi_plus_postgres_read_test.cpp index 04011edf..5c7a48da 100644 --- a/tests/unit/abi_plus_postgres_read_test.cpp +++ b/tests/unit/abi_plus_postgres_read_test.cpp @@ -16,13 +16,29 @@ namespace { +constexpr const char* kDefaultPgUri = + "postgresql://postgres@127.0.0.1:5433/postgres"; + +const char* test_pg_uri() { + const char* uri_env = std::getenv("OPENADS_TEST_PG_URI"); + if (uri_env != nullptr && uri_env[0] != '\0') { + return uri_env; + } + return kDefaultPgUri; +} + void seed_fixture(PGconn* conn) { auto exec = [&](const char* sql) { PGresult* res = PQexec(conn, sql); if (PQresultStatus(res) != PGRES_COMMAND_OK) { const char* msg = PQerrorMessage(conn); PQclear(res); - FAIL(std::string("seed failed: ") + (msg ? msg : "")); + std::string detail = "seed failed"; + if (msg != nullptr && msg[0] != '\0') { + detail += ": "; + detail += msg; + } + FAIL(detail); } PQclear(res); }; @@ -45,20 +61,14 @@ std::string field_str(ADSHANDLE hTable, const char* name) { } // namespace TEST_CASE("ABI: postgresql read-only AdsOpenTable navigation") { - const char* uri_env = std::getenv("OPENADS_TEST_PG_URI"); - if (uri_env == nullptr || uri_env[0] == '\0') { - SKIP("Set OPENADS_TEST_PG_URI (postgresql://...) for E2E test."); - } + const char* uri_cstr = test_pg_uri(); - PGconn* seed = PQconnectdb(uri_env); - if (PQstatus(seed) != CONNECTION_OK) { - PQfinish(seed); - SKIP("Cannot connect with OPENADS_TEST_PG_URI."); - } + PGconn* seed = PQconnectdb(uri_cstr); + REQUIRE(PQstatus(seed) == CONNECTION_OK); seed_fixture(seed); PQfinish(seed); - const std::string uri = uri_env; + const std::string uri = uri_cstr; std::vector srv(uri.size() + 1); std::memcpy(srv.data(), uri.c_str(), uri.size() + 1); diff --git a/tests/unit/abi_plus_postgres_seek_test.cpp b/tests/unit/abi_plus_postgres_seek_test.cpp index 8436c48d..015ecbdc 100644 --- a/tests/unit/abi_plus_postgres_seek_test.cpp +++ b/tests/unit/abi_plus_postgres_seek_test.cpp @@ -16,6 +16,17 @@ namespace { +constexpr const char* kDefaultPgUri = + "postgresql://postgres@127.0.0.1:5433/postgres"; + +const char* test_pg_uri() { + const char* uri_env = std::getenv("OPENADS_TEST_PG_URI"); + if (uri_env != nullptr && uri_env[0] != '\0') { + return uri_env; + } + return kDefaultPgUri; +} + void seed_fixture(PGconn* conn) { auto exec = [&](const char* sql) { PGresult* res = PQexec(conn, sql); @@ -30,23 +41,26 @@ void seed_fixture(PGconn* conn) { exec("INSERT INTO clientes (id, nome) VALUES (1,'Ana'),(2,'Bob'),(3,'Cid')"); } +std::string field_str(ADSHANDLE hTable, const char* name) { + UNSIGNED8 fld[32]; + std::memcpy(fld, name, std::strlen(name) + 1); + UNSIGNED8 buf[128] = {0}; + UNSIGNED32 cap = sizeof(buf); + REQUIRE(AdsGetField(hTable, fld, buf, &cap, 0) == 0); + return std::string(reinterpret_cast(buf), cap); +} + } // namespace TEST_CASE("ABI: postgresql AdsSeek on column index") { - const char* uri_env = std::getenv("OPENADS_TEST_PG_URI"); - if (uri_env == nullptr || uri_env[0] == '\0') { - SKIP("Set OPENADS_TEST_PG_URI for E2E test."); - } + const char* uri_cstr = test_pg_uri(); - PGconn* seed = PQconnectdb(uri_env); - if (PQstatus(seed) != CONNECTION_OK) { - PQfinish(seed); - SKIP("Cannot connect with OPENADS_TEST_PG_URI."); - } + PGconn* seed = PQconnectdb(uri_cstr); + REQUIRE(PQstatus(seed) == CONNECTION_OK); seed_fixture(seed); PQfinish(seed); - const std::string uri = uri_env; + const std::string uri = uri_cstr; std::vector srv(uri.size() + 1); std::memcpy(srv.data(), uri.c_str(), uri.size() + 1); @@ -60,23 +74,33 @@ TEST_CASE("ABI: postgresql AdsSeek on column index") { ADS_READONLY, &hTable) == 0); UNSIGNED8 tag[16] = "id"; - ADSHANDLE ahIndex[1] = {0}; - UNSIGNED16 nIdx = 0; - REQUIRE(AdsOpenIndex(hTable, tag, ahIndex, &nIdx) == 0); - REQUIRE(nIdx == 1); + ADSHANDLE hIndex = 0; + UNSIGNED16 nIdx = 1; + REQUIRE(AdsOpenIndex(hTable, tag, &hIndex, &nIdx) == 0); + CHECK(nIdx == 1); - UNSIGNED8 key[] = "2"; + const char key[] = "2"; UNSIGNED16 found = 0; - REQUIRE(AdsSeek(ahIndex[0], key, 1, 0, 0, &found) == 0); + REQUIRE(AdsSeek(hIndex, + reinterpret_cast(const_cast(key)), + static_cast(std::strlen(key)), + ADS_STRINGKEY, 0, &found) == 0); CHECK(found == 1); - UNSIGNED8 buf[64] = {0}; - UNSIGNED32 cap = sizeof(buf); - REQUIRE(AdsGetField(hTable, (UNSIGNED8*)"nome", buf, &cap, 0) == 0); - CHECK(std::string(reinterpret_cast(buf), cap) == - std::string(64, ' ').replace(0, 3, "Bob")); + UNSIGNED16 is_found = 0; + REQUIRE(AdsIsFound(hTable, &is_found) == 0); + CHECK(is_found == 1); + + CHECK(field_str(hTable, "nome").find("Bob") != std::string::npos); + + const char miss[] = "9"; + REQUIRE(AdsSeek(hIndex, + reinterpret_cast(const_cast(miss)), + static_cast(std::strlen(miss)), + ADS_STRINGKEY, 0, &found) == 0); + CHECK(found == 0); - REQUIRE(AdsCloseIndex(ahIndex[0]) == 0); + REQUIRE(AdsCloseIndex(hIndex) == 0); REQUIRE(AdsCloseTable(hTable) == 0); REQUIRE(AdsDisconnect(hConn) == 0); } From 40cbb9505fa63dc3e99b0e60607eb4053dc258b4 Mon Sep 17 00:00:00 2001 From: Admnwk Date: Sat, 20 Jun 2026 15:32:04 -0300 Subject: [PATCH 4/8] fix(build): static-link MinGW runtime on Windows Eliminates libgcc_s_seh-1.dll dependency; add run_postgres_tests.bat wrapper. --- CMakeLists.txt | 3 +++ tools/scripts/build_nmake_postgres.bat | 4 ++- tools/scripts/run_postgres_tests.bat | 34 ++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 1 deletion(-) create mode 100644 tools/scripts/run_postgres_tests.bat diff --git a/CMakeLists.txt b/CMakeLists.txt index dfb21429..e0e8e5f2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -193,6 +193,9 @@ if(MSVC) if(OPENADS_WARNINGS_AS_ERRORS) add_compile_options(/WX) endif() +elseif(WIN32 AND CMAKE_CXX_COMPILER_ID STREQUAL "GNU") + # MinGW/winlibs: static link toolchain runtime; libpq.dll still loads at runtime. + add_link_options(-static) else() add_compile_options(-Wall -Wextra -Wpedantic -Wshadow -Wconversion) # _CRT_SECURE_NO_WARNINGS: needed when clang-cl targets MSVC CRT on Windows; diff --git a/tools/scripts/build_nmake_postgres.bat b/tools/scripts/build_nmake_postgres.bat index b7e65737..9a426cd4 100644 --- a/tools/scripts/build_nmake_postgres.bat +++ b/tools/scripts/build_nmake_postgres.bat @@ -9,7 +9,9 @@ if not defined OPENADS_LIBPQ_INCLUDE ( ) ) if not defined OPENADS_LIBPQ_LIBRARY ( - if exist "%OPENADS_TOOLCHAIN_ROOT%\libpq\x86\libpq.lib" ( + if exist "%OPENADS_TOOLCHAIN_ROOT%\pgsql\lib\libpq.lib" ( + set "OPENADS_LIBPQ_LIBRARY=%OPENADS_TOOLCHAIN_ROOT%\pgsql\lib\libpq.lib" + ) else if exist "%OPENADS_TOOLCHAIN_ROOT%\libpq\x86\libpq.lib" ( set "OPENADS_LIBPQ_LIBRARY=%OPENADS_TOOLCHAIN_ROOT%\libpq\x86\libpq.lib" ) ) diff --git a/tools/scripts/run_postgres_tests.bat b/tools/scripts/run_postgres_tests.bat new file mode 100644 index 00000000..b4390902 --- /dev/null +++ b/tools/scripts/run_postgres_tests.bat @@ -0,0 +1,34 @@ +@echo off +REM Run PostgreSQL ABI e2e tests from build\pg (or pass BUILD_DIR). +setlocal +set "ROOT=%~dp0..\.." +set "BUILD=%ROOT%\build\pg" +if not "%~1"=="" set "BUILD=%~1" + +if not exist "%BUILD%\tests\openads_unit_tests.exe" ( + echo ERROR: %BUILD%\tests\openads_unit_tests.exe not found. Build first. + exit /b 1 +) + +if not defined OPENADS_TOOLCHAIN_ROOT ( + if defined DEVAI_ROOT ( + set "OPENADS_TOOLCHAIN_ROOT=%DEVAI_ROOT%\_UtlAI" + ) +) +if not defined OPENADS_TOOLCHAIN_ROOT ( + echo ERROR: set OPENADS_TOOLCHAIN_ROOT or DEVAI_ROOT for libpq / winlibs PATH. + exit /b 1 +) + +set "PATH=%OPENADS_TOOLCHAIN_ROOT%\pgsql\bin;%PATH%" +if exist "%OPENADS_TOOLCHAIN_ROOT%\winlibs-x86_64\bin" ( + set "PATH=%OPENADS_TOOLCHAIN_ROOT%\winlibs-x86_64\bin;%PATH%" +) + +if not defined OPENADS_TEST_PG_URI ( + set "OPENADS_TEST_PG_URI=postgresql://postgres@127.0.0.1:5433/postgres" +) + +cd /d "%BUILD%" +tests\openads_unit_tests.exe --test-case=*postgresql* %* +exit /b %ERRORLEVEL% \ No newline at end of file From 7b97f0e0c81636fef51c1398f624f29f10ab17b6 Mon Sep 17 00:00:00 2001 From: Admnwk Date: Sat, 20 Jun 2026 15:32:32 -0300 Subject: [PATCH 5/8] docs(scripts): winlibs PATH fix and MSVC x64 postgres build recipe --- tools/scripts/build_msvc_x64_postgres.bat | 29 +++++++++++++++++++++++ tools/scripts/run_postgres_tests.bat | 10 ++++++++ 2 files changed, 39 insertions(+) create mode 100644 tools/scripts/build_msvc_x64_postgres.bat diff --git a/tools/scripts/build_msvc_x64_postgres.bat b/tools/scripts/build_msvc_x64_postgres.bat new file mode 100644 index 00000000..586ba258 --- /dev/null +++ b/tools/scripts/build_msvc_x64_postgres.bat @@ -0,0 +1,29 @@ +@echo off +REM PostgreSQL backend build with MSVC cl (no winlibs GCC runtime at run time). +if not defined OPENADS_TOOLCHAIN_ROOT ( + if defined DEVAI_ROOT set "OPENADS_TOOLCHAIN_ROOT=%DEVAI_ROOT%\_UtlAI" +) +if not defined OPENADS_TOOLCHAIN_ROOT ( + echo ERROR: set OPENADS_TOOLCHAIN_ROOT or DEVAI_ROOT. + exit /b 1 +) +if not defined OPENADS_LIBPQ_INCLUDE ( + set "OPENADS_LIBPQ_INCLUDE=%OPENADS_TOOLCHAIN_ROOT%\pgsql\include" +) +if not defined OPENADS_LIBPQ_LIBRARY ( + set "OPENADS_LIBPQ_LIBRARY=%OPENADS_TOOLCHAIN_ROOT%\pgsql\lib\libpq.lib" +) +call "%OPENADS_TOOLCHAIN_ROOT%\msvc\setup_x64.bat" +if exist "%OPENADS_TOOLCHAIN_ROOT%\winlibs-x86_64\bin" ( + set "PATH=%OPENADS_TOOLCHAIN_ROOT%\winlibs-x86_64\bin;%PATH%" +) +cd /d "%~dp0..\.." +cmake -S . -B build\pg-msvc -G "NMake Makefiles" -DCMAKE_BUILD_TYPE=Release ^ + -DCMAKE_C_COMPILER=cl -DCMAKE_CXX_COMPILER=cl ^ + -DOPENADS_WITH_POSTGRESQL=ON -DOPENADS_WITH_HTTP=OFF ^ + -DOPENADS_WARNINGS_AS_ERRORS=OFF ^ + -DOPENADS_LIBPQ_INCLUDE=%OPENADS_LIBPQ_INCLUDE% ^ + -DOPENADS_LIBPQ_LIBRARY=%OPENADS_LIBPQ_LIBRARY% +if errorlevel 1 exit /b 1 +cmake --build build\pg-msvc +exit /b %ERRORLEVEL% \ No newline at end of file diff --git a/tools/scripts/run_postgres_tests.bat b/tools/scripts/run_postgres_tests.bat index b4390902..3b358a23 100644 --- a/tools/scripts/run_postgres_tests.bat +++ b/tools/scripts/run_postgres_tests.bat @@ -1,5 +1,15 @@ @echo off REM Run PostgreSQL ABI e2e tests from build\pg (or pass BUILD_DIR). +REM +REM GCC/Ninja builds link against winlibs runtime DLLs. Before running the +REM exe by hand, prepend winlibs to PATH (libgcc_s_seh-1, libstdc++-6, +REM libwinpthread-1 live there): +REM set PATH=%OPENADS_TOOLCHAIN_ROOT%\winlibs-x86_64\bin;%PATH% +REM This script does that automatically when DEVAI_ROOT/OPENADS_TOOLCHAIN_ROOT +REM is set. Also needs pgsql\bin for libpq.dll. +REM +REM Cleaner long-term: build with MSVC cl (see build_msvc_x64_postgres.bat) so +REM no GCC runtime is required at test time. setlocal set "ROOT=%~dp0..\.." set "BUILD=%ROOT%\build\pg" From 12b8947b544c3e53090f71d5968d9ff6b753bb51 Mon Sep 17 00:00:00 2001 From: Admnwk Date: Sat, 20 Jun 2026 16:35:36 -0300 Subject: [PATCH 6/8] refactor(openads-plus): postgres navigates by primary key, not ctid ctid is not a stable row identifier (it moves on UPDATE/VACUUM), so it is unsafe as the cursor key for write-back. Switch open_table snapshot, load_current_row and seek_index to a primary-key snapshot mirroring the MariaDB backend, binding values with parameterized PQexecParams. PK columns are discovered from information_schema. ABI read+seek e2e green against PostgreSQL 17.10. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/sql_backend/postgres_connection.cpp | 165 ++++++++++++++++++------ src/sql_backend/postgres_table.h | 6 +- 2 files changed, 133 insertions(+), 38 deletions(-) diff --git a/src/sql_backend/postgres_connection.cpp b/src/sql_backend/postgres_connection.cpp index f1763015..ed22b51e 100644 --- a/src/sql_backend/postgres_connection.cpp +++ b/src/sql_backend/postgres_connection.cpp @@ -4,6 +4,7 @@ #include "sql_backend/sql_common.h" #include +#include #if defined(OPENADS_WITH_POSTGRESQL) #include @@ -15,8 +16,65 @@ namespace { #if defined(OPENADS_WITH_POSTGRESQL) +std::string quote_ident(const std::string& name) { + return '"' + name + '"'; +} + +std::string pk_select_list(const PostgresTable& tbl) { + std::string out; + for (std::size_t i = 0; i < tbl.pk_columns.size(); ++i) { + if (i > 0) out += ", "; + out += quote_ident(tbl.pk_columns[i]); + } + return out; +} + +// "col1" = $1 AND "col2" = $2 ... (placeholders bound positionally) +std::string pk_where_clause(const PostgresTable& tbl) { + std::string sql; + for (std::size_t i = 0; i < tbl.pk_columns.size(); ++i) { + if (i > 0) sql += " AND "; + sql += quote_ident(tbl.pk_columns[i]) + " = $" + + std::to_string(i + 1); + } + return sql; +} + util::Result load_current_row(PGconn* conn, PostgresTable* tbl); +util::Result> +discover_pk_columns(PGconn* conn, const std::string& table_name) { + const char* params[1] = {table_name.c_str()}; + PGresult* res = PQexecParams( + conn, + "SELECT kcu.column_name " + "FROM information_schema.table_constraints tc " + "JOIN information_schema.key_column_usage kcu " + " ON kcu.constraint_schema = tc.constraint_schema " + " AND kcu.constraint_name = tc.constraint_name " + "WHERE tc.constraint_type = 'PRIMARY KEY' " + " AND tc.table_schema = ANY (current_schemas(true)) " + " AND tc.table_name = $1 " + "ORDER BY kcu.ordinal_position", + 1, nullptr, params, nullptr, nullptr, 0); + if (PQresultStatus(res) != PGRES_TUPLES_OK) { + const char* msg = PQerrorMessage(conn); + PQclear(res); + return postgres_error("pk discovery", msg); + } + const int rows = PQntuples(res); + std::vector cols; + cols.reserve(static_cast(rows)); + for (int r = 0; r < rows; ++r) { + cols.emplace_back(PQgetvalue(res, r, 0)); + } + PQclear(res); + if (cols.empty()) { + return util::Error{5001, 0, "table has no primary key", table_name}; + } + return cols; +} + util::Result> describe_table_impl(PGconn* conn, PostgresTable* tbl) { if (!is_safe_identifier(tbl->name)) { @@ -61,19 +119,23 @@ describe_table_impl(PGconn* conn, PostgresTable* tbl) { return out; } -util::Result position_at_ctid(PGconn* conn, PostgresTable* tbl, - const std::string& ctid) { +util::Result position_at_pk(PGconn* conn, PostgresTable* tbl, + const PostgresTable::PkRow& pk) { if (tbl == nullptr || conn == nullptr) { return util::Error{5001, 0, "invalid postgres table state", ""}; } - auto it = std::find(tbl->ctids.begin(), tbl->ctids.end(), ctid); - if (it == tbl->ctids.end()) { + auto it = std::find_if( + tbl->pk_snapshot.begin(), tbl->pk_snapshot.end(), + [&](const PostgresTable::PkRow& row) { + return row.values == pk.values; + }); + if (it == tbl->pk_snapshot.end()) { tbl->positioned = false; tbl->row_valid = false; return util::Result{}; } tbl->pos = static_cast( - std::distance(tbl->ctids.begin(), it)); + std::distance(tbl->pk_snapshot.begin(), it)); tbl->positioned = true; tbl->current_recno = static_cast(tbl->pos + 1); return load_current_row(conn, tbl); @@ -95,11 +157,16 @@ util::Result load_current_row(PGconn* conn, PostgresTable* tbl) { } const std::string sql = - "SELECT * FROM \"" + tbl->name + "\" WHERE ctid = $1::tid"; - const std::string& ctid = tbl->ctids[tbl->pos]; - const char* params[1] = {ctid.c_str()}; - PGresult* res = PQexecParams(conn, sql.c_str(), 1, nullptr, params, - nullptr, nullptr, 0); + "SELECT * FROM " + quote_ident(tbl->name) + " WHERE " + + pk_where_clause(*tbl); + const PostgresTable::PkRow& pk = tbl->pk_snapshot[tbl->pos]; + std::vector params; + params.reserve(pk.values.size()); + for (const std::string& v : pk.values) params.push_back(v.c_str()); + + PGresult* res = PQexecParams( + conn, sql.c_str(), static_cast(params.size()), nullptr, + params.data(), nullptr, nullptr, 0); if (PQresultStatus(res) != PGRES_TUPLES_OK) { const char* msg = PQerrorMessage(conn); PQclear(res); @@ -213,22 +280,36 @@ PostgresConnection::open_table(const std::string& table_name) { tbl->conn = this; tbl->name = table_name; + auto pk = discover_pk_columns(impl_->conn, table_name); + if (!pk) return pk.error(); + tbl->pk_columns = std::move(pk).value(); + + const std::string sel = pk_select_list(*tbl); const std::string sql = - "SELECT ctid::text FROM \"" + table_name + "\" ORDER BY ctid"; + "SELECT " + sel + " FROM " + quote_ident(table_name) + + " ORDER BY " + sel; PGresult* res = PQexec(impl_->conn, sql.c_str()); if (PQresultStatus(res) != PGRES_TUPLES_OK) { const char* msg = PQerrorMessage(impl_->conn); PQclear(res); - return postgres_error("ctid list", msg); + return postgres_error("pk snapshot", msg); } - const int rows = PQntuples(res); - tbl->ctids.reserve(static_cast(rows)); + const int rows = PQntuples(res); + const int pk_cols = PQnfields(res); + tbl->pk_snapshot.reserve(static_cast(rows)); for (int r = 0; r < rows; ++r) { - tbl->ctids.emplace_back(PQgetvalue(res, r, 0)); + PostgresTable::PkRow pk_row; + pk_row.values.resize(static_cast(pk_cols)); + for (int c = 0; c < pk_cols; ++c) { + pk_row.values[static_cast(c)] = + PQgetisnull(res, r, c) ? std::string{} + : PQgetvalue(res, r, c); + } + tbl->pk_snapshot.push_back(std::move(pk_row)); } PQclear(res); - tbl->cached_rec_count = static_cast(tbl->ctids.size()); + tbl->cached_rec_count = static_cast(tbl->pk_snapshot.size()); tbl->rec_count_cached = true; tbl->positioned = false; tbl->row_valid = false; @@ -254,7 +335,7 @@ util::Result PostgresConnection::goto_top(PostgresTable* tbl) { if (!valid() || tbl == nullptr) { return util::Error{5001, 0, "invalid postgres goto_top", ""}; } - if (tbl->ctids.empty()) { + if (tbl->pk_snapshot.empty()) { tbl->positioned = false; tbl->row_valid = false; tbl->current_recno = 0; @@ -276,14 +357,14 @@ util::Result PostgresConnection::goto_bottom(PostgresTable* tbl) { if (!valid() || tbl == nullptr) { return util::Error{5001, 0, "invalid postgres goto_bottom", ""}; } - if (tbl->ctids.empty()) { + if (tbl->pk_snapshot.empty()) { tbl->positioned = false; tbl->row_valid = false; tbl->current_recno = 0; tbl->pos = 0; return util::Result{}; } - tbl->pos = tbl->ctids.size() - 1; + tbl->pos = tbl->pk_snapshot.size() - 1; tbl->positioned = true; tbl->current_recno = static_cast(tbl->pos + 1); return load_current_row(impl_->conn, tbl); @@ -300,7 +381,7 @@ util::Result PostgresConnection::skip(PostgresTable* tbl, return util::Error{5001, 0, "invalid postgres skip", ""}; } if (step == 0) return util::Result{}; - if (tbl->ctids.empty()) { + if (tbl->pk_snapshot.empty()) { tbl->positioned = false; tbl->row_valid = false; tbl->pos = 0; @@ -329,10 +410,10 @@ util::Result PostgresConnection::skip(PostgresTable* tbl, tbl->pos = 0; return util::Error{5026, 0, "bof", ""}; } - if (static_cast(next) >= tbl->ctids.size()) { + if (static_cast(next) >= tbl->pk_snapshot.size()) { tbl->positioned = false; tbl->row_valid = false; - tbl->pos = tbl->ctids.size(); + tbl->pos = tbl->pk_snapshot.size(); return util::Result{}; } @@ -352,8 +433,8 @@ util::Result PostgresConnection::at_eof(PostgresTable* tbl) const { if (!valid() || tbl == nullptr) { return util::Error{5001, 0, "invalid postgres at_eof", ""}; } - if (tbl->ctids.empty()) return true; - if (!tbl->positioned && tbl->pos >= tbl->ctids.size()) return true; + if (tbl->pk_snapshot.empty()) return true; + if (!tbl->positioned && tbl->pos >= tbl->pk_snapshot.size()) return true; return false; #else (void)tbl; @@ -366,7 +447,7 @@ util::Result PostgresConnection::at_bof(PostgresTable* tbl) const { if (!valid() || tbl == nullptr) { return util::Error{5001, 0, "invalid postgres at_bof", ""}; } - if (tbl->ctids.empty()) return true; + if (tbl->pk_snapshot.empty()) return true; return !tbl->positioned && tbl->pos == 0; #else (void)tbl; @@ -380,7 +461,7 @@ util::Result PostgresConnection::record_count(PostgresTable* tbl) return util::Error{5001, 0, "invalid postgres record_count", ""}; } if (tbl->rec_count_cached) return tbl->cached_rec_count; - tbl->cached_rec_count = static_cast(tbl->ctids.size()); + tbl->cached_rec_count = static_cast(tbl->pk_snapshot.size()); tbl->rec_count_cached = true; return tbl->cached_rec_count; #else @@ -453,19 +534,22 @@ util::Result PostgresConnection::seek_index( return util::Error{5063, 0, "seek column not found", column}; } + const std::string sel = pk_select_list(*tbl); + const std::string qcol = quote_ident(column); + const std::string from = " FROM " + quote_ident(tbl->name) + " WHERE "; + std::string sql; if (last_key) { sql = soft - ? "SELECT ctid::text FROM \"" + tbl->name + "\" WHERE \"" + - column + "\" <= $1 ORDER BY \"" + column + "\" DESC LIMIT 1" - : "SELECT ctid::text FROM \"" + tbl->name + "\" WHERE \"" + - column + "\" = $1 ORDER BY \"" + column + "\" DESC LIMIT 1"; + ? "SELECT " + sel + from + qcol + " <= $1 ORDER BY " + qcol + + " DESC LIMIT 1" + : "SELECT " + sel + from + qcol + " = $1 ORDER BY " + qcol + + " DESC LIMIT 1"; } else { sql = soft - ? "SELECT ctid::text FROM \"" + tbl->name + "\" WHERE \"" + - column + "\" >= $1 ORDER BY \"" + column + "\" ASC LIMIT 1" - : "SELECT ctid::text FROM \"" + tbl->name + "\" WHERE \"" + - column + "\" = $1 LIMIT 1"; + ? "SELECT " + sel + from + qcol + " >= $1 ORDER BY " + qcol + + " ASC LIMIT 1" + : "SELECT " + sel + from + qcol + " = $1 LIMIT 1"; } const char* params[1] = {key.c_str()}; @@ -479,9 +563,16 @@ util::Result PostgresConnection::seek_index( bool found = false; if (PQntuples(res) == 1) { - const std::string ctid = PQgetvalue(res, 0, 0); + const int pk_cols = PQnfields(res); + PostgresTable::PkRow pk; + pk.values.resize(static_cast(pk_cols)); + for (int c = 0; c < pk_cols; ++c) { + pk.values[static_cast(c)] = + PQgetisnull(res, 0, c) ? std::string{} + : PQgetvalue(res, 0, c); + } PQclear(res); - if (auto p = position_at_ctid(impl_->conn, tbl, ctid); !p) { + if (auto p = position_at_pk(impl_->conn, tbl, pk); !p) { return p.error(); } found = tbl->positioned && tbl->row_valid; @@ -504,4 +595,4 @@ util::Result PostgresConnection::seek_index( #endif } -} // namespace openads::sql_backend \ No newline at end of file +} // namespace openads::sql_backend diff --git a/src/sql_backend/postgres_table.h b/src/sql_backend/postgres_table.h index 30f0f593..18d6bfd0 100644 --- a/src/sql_backend/postgres_table.h +++ b/src/sql_backend/postgres_table.h @@ -32,7 +32,11 @@ struct PostgresTable { std::uint32_t cached_rec_count = 0; bool rec_count_cached = false; - std::vector ctids; + std::vector pk_columns; + struct PkRow { + std::vector values; + }; + std::vector pk_snapshot; std::size_t pos = 0; bool positioned = false; bool last_seek_found = false; From acc9f63347619de4fbc61db65ba211df5b8f7779 Mon Sep 17 00:00:00 2001 From: Admnwk Date: Sat, 20 Jun 2026 17:16:44 -0300 Subject: [PATCH 7/8] chore(scripts): require OPENADS_TOOLCHAIN_ROOT explicitly Build/test helper scripts no longer hard-code an environment-specific toolchain root fallback; they require OPENADS_TOOLCHAIN_ROOT to point at the directory holding the msvc, winlibs and pgsql/mariadb client dependencies. Co-Authored-By: Claude Opus 4.8 (1M context) --- tools/scripts/build_msvc_x64_postgres.bat | 6 ++---- tools/scripts/run_postgres_tests.bat | 12 ++++-------- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/tools/scripts/build_msvc_x64_postgres.bat b/tools/scripts/build_msvc_x64_postgres.bat index 586ba258..e2b3a543 100644 --- a/tools/scripts/build_msvc_x64_postgres.bat +++ b/tools/scripts/build_msvc_x64_postgres.bat @@ -1,10 +1,8 @@ @echo off REM PostgreSQL backend build with MSVC cl (no winlibs GCC runtime at run time). if not defined OPENADS_TOOLCHAIN_ROOT ( - if defined DEVAI_ROOT set "OPENADS_TOOLCHAIN_ROOT=%DEVAI_ROOT%\_UtlAI" -) -if not defined OPENADS_TOOLCHAIN_ROOT ( - echo ERROR: set OPENADS_TOOLCHAIN_ROOT or DEVAI_ROOT. + echo ERROR: set OPENADS_TOOLCHAIN_ROOT to the directory that holds the + echo msvc, winlibs and pgsql dependency folders. exit /b 1 ) if not defined OPENADS_LIBPQ_INCLUDE ( diff --git a/tools/scripts/run_postgres_tests.bat b/tools/scripts/run_postgres_tests.bat index 3b358a23..3af25bf8 100644 --- a/tools/scripts/run_postgres_tests.bat +++ b/tools/scripts/run_postgres_tests.bat @@ -5,8 +5,8 @@ REM GCC/Ninja builds link against winlibs runtime DLLs. Before running the REM exe by hand, prepend winlibs to PATH (libgcc_s_seh-1, libstdc++-6, REM libwinpthread-1 live there): REM set PATH=%OPENADS_TOOLCHAIN_ROOT%\winlibs-x86_64\bin;%PATH% -REM This script does that automatically when DEVAI_ROOT/OPENADS_TOOLCHAIN_ROOT -REM is set. Also needs pgsql\bin for libpq.dll. +REM This script does that automatically when OPENADS_TOOLCHAIN_ROOT is set. +REM Also needs pgsql\bin for libpq.dll. REM REM Cleaner long-term: build with MSVC cl (see build_msvc_x64_postgres.bat) so REM no GCC runtime is required at test time. @@ -21,12 +21,8 @@ if not exist "%BUILD%\tests\openads_unit_tests.exe" ( ) if not defined OPENADS_TOOLCHAIN_ROOT ( - if defined DEVAI_ROOT ( - set "OPENADS_TOOLCHAIN_ROOT=%DEVAI_ROOT%\_UtlAI" - ) -) -if not defined OPENADS_TOOLCHAIN_ROOT ( - echo ERROR: set OPENADS_TOOLCHAIN_ROOT or DEVAI_ROOT for libpq / winlibs PATH. + echo ERROR: set OPENADS_TOOLCHAIN_ROOT to the directory that holds pgsql + echo and winlibs (for libpq / winlibs PATH). exit /b 1 ) From bdab6eb922f3b56ccbc5014a870aa72d1f364e36 Mon Sep 17 00:00:00 2001 From: Admnwk Date: Sat, 20 Jun 2026 17:29:30 -0300 Subject: [PATCH 8/8] fix(openads-plus): address review on the postgres backend - load_current_row selects an explicit column list in schema order instead of SELECT *, so current_row stays aligned with the cached field order even if physical and logical column order differ. - seek_index ORDER BY now appends the primary key as a tie-breaker, making duplicate-key seeks deterministic. - quote_ident doubles any embedded double-quote (identifier safety). - field_index_ci compares case-insensitively without allocating a string per field. Co-Authored-By: Claude Opus 4.8 (1M context) --- src/sql_backend/postgres_backend.cpp | 15 ++++++--- src/sql_backend/postgres_connection.cpp | 42 ++++++++++++++++++++----- 2 files changed, 46 insertions(+), 11 deletions(-) diff --git a/src/sql_backend/postgres_backend.cpp b/src/sql_backend/postgres_backend.cpp index 77fe63e8..505603d1 100644 --- a/src/sql_backend/postgres_backend.cpp +++ b/src/sql_backend/postgres_backend.cpp @@ -100,11 +100,18 @@ std::size_t field_index_ci(const PostgresTable& tbl, const std::string& name) { c = static_cast(std::toupper(static_cast(c))); } for (std::size_t i = 0; i < tbl.fields.size(); ++i) { - std::string have = tbl.fields[i].name; - for (auto& c : have) { - c = static_cast(std::toupper(static_cast(c))); + const std::string& have = tbl.fields[i].name; + if (have.size() != want.size()) continue; + bool eq = true; + for (std::size_t k = 0; k < have.size(); ++k) { + if (static_cast( + std::toupper(static_cast(have[k]))) != + want[k]) { + eq = false; + break; + } } - if (have == want) return i; + if (eq) return i; } return static_cast(-1); } diff --git a/src/sql_backend/postgres_connection.cpp b/src/sql_backend/postgres_connection.cpp index ed22b51e..02be3fad 100644 --- a/src/sql_backend/postgres_connection.cpp +++ b/src/sql_backend/postgres_connection.cpp @@ -17,7 +17,13 @@ namespace { #if defined(OPENADS_WITH_POSTGRESQL) std::string quote_ident(const std::string& name) { - return '"' + name + '"'; + std::string out = "\""; + for (char c : name) { + if (c == '"') out += "\"\""; // double any embedded quote + else out += c; + } + out += '"'; + return out; } std::string pk_select_list(const PostgresTable& tbl) { @@ -29,6 +35,17 @@ std::string pk_select_list(const PostgresTable& tbl) { return out; } +// primary-key columns with an explicit direction ("pk1" ASC, "pk2" ASC) — a +// deterministic tie-breaker so a seek never returns an arbitrary row on dup keys. +std::string pk_order_by(const PostgresTable& tbl, const char* dir) { + std::string out; + for (std::size_t i = 0; i < tbl.pk_columns.size(); ++i) { + if (i > 0) out += ", "; + out += quote_ident(tbl.pk_columns[i]) + ' ' + dir; + } + return out; +} + // "col1" = $1 AND "col2" = $2 ... (placeholders bound positionally) std::string pk_where_clause(const PostgresTable& tbl) { std::string sql; @@ -156,8 +173,15 @@ util::Result load_current_row(PGconn* conn, PostgresTable* tbl) { if (!d) return d.error(); } + // Explicit column list in tbl->fields order (NOT "SELECT *"): keeps + // current_row[i] aligned with fields[i] regardless of physical column order. + std::string collist; + for (std::size_t i = 0; i < tbl->fields.size(); ++i) { + if (i > 0) collist += ", "; + collist += quote_ident(tbl->fields[i].name); + } const std::string sql = - "SELECT * FROM " + quote_ident(tbl->name) + " WHERE " + + "SELECT " + collist + " FROM " + quote_ident(tbl->name) + " WHERE " + pk_where_clause(*tbl); const PostgresTable::PkRow& pk = tbl->pk_snapshot[tbl->pos]; std::vector params; @@ -538,18 +562,22 @@ util::Result PostgresConnection::seek_index( const std::string qcol = quote_ident(column); const std::string from = " FROM " + quote_ident(tbl->name) + " WHERE "; + const std::string pk_asc = pk_order_by(*tbl, "ASC"); + const std::string pk_desc = pk_order_by(*tbl, "DESC"); + std::string sql; if (last_key) { sql = soft ? "SELECT " + sel + from + qcol + " <= $1 ORDER BY " + qcol + - " DESC LIMIT 1" - : "SELECT " + sel + from + qcol + " = $1 ORDER BY " + qcol + - " DESC LIMIT 1"; + " DESC, " + pk_desc + " LIMIT 1" + : "SELECT " + sel + from + qcol + " = $1 ORDER BY " + pk_desc + + " LIMIT 1"; } else { sql = soft ? "SELECT " + sel + from + qcol + " >= $1 ORDER BY " + qcol + - " ASC LIMIT 1" - : "SELECT " + sel + from + qcol + " = $1 LIMIT 1"; + " ASC, " + pk_asc + " LIMIT 1" + : "SELECT " + sel + from + qcol + " = $1 ORDER BY " + pk_asc + + " LIMIT 1"; } const char* params[1] = {key.c_str()};