diff --git a/.github/scripts/win-odbc-headers.sh b/.github/scripts/win-odbc-headers.sh new file mode 100644 index 00000000..6946ed09 --- /dev/null +++ b/.github/scripts/win-odbc-headers.sh @@ -0,0 +1,110 @@ +#!/usr/bin/env bash +# --------------------------------------------------------------------------- +# 把 Windows SDK 里那份 **ODBC 头目录**(`/Include//um`)找出来,打到 +# stdout 上(只有路径这一行;诊断全部走 stderr,因为这个脚本的调用方式是 +# `ODBC_INC=$(bash .github/scripts/win-odbc-headers.sh)`)。 +# +# 给谁用:`release.yml` 的两条 **MSVC** 腿(msvc-x64 / msvc-arm64)。它们要 +# `-DUVCPP_DB_ODBC_INCLUDE_DIR=` 指到一份 `sql.h` 上 —— db 的第四个后端(ODBC) +# 从 1.7.2 起进发布包,而它**默认 ON、找不到头只 WARNING 一句就自己关掉** +# (`CMakeLists.txt` 里那段 `message(WARNING)` + `set(... OFF)` 是普通变量赋值 ⇒ +# `CMakeCache.txt` 里照旧写着 ON)。所以"这条腿有没有弄到头"这件事**只能**从生成的 +# `uvcpp_config.h` 里看出来,而下面这一步必须在"取不到"时**当场红**,不能静默过去 +# —— 静默过去就是一个少一个后端的包,而且它会一路走到发版。 +# +# 为什么是 SDK 那份、而不是像两条 MinGW 腿那样用 MSYS2 的 unixODBC: +# +# · 微软的 ODBC 头**本来就是 SDK 的一部分**(`sql.h`/`sqlext.h`/`sqltypes.h` +# 都在 `Include//um/` 下),随工具链一起装、版本跟着工具链走,不需要 +# 网络、不需要解包、也不引入第二种头来源; +# · 这份头**已经在这个后端上验过**:wlzaweb 的 CI(`.github/workflows/release-win.yml`) +# 里 libuvcpp 的 ODBC 后端就是在 SDK 头上编出来的,并且连上真 SQL Server +# (LocalDB + ODBC Driver 17)跑通了业务口径 —— 那套流程里找头的那段逻辑就是 +# 下面这段,这次是把它从那边搬过来。 +# · 两条 MinGW 腿不走这条路:SDK 的头是给 MSVC 的(`__declspec`、SAL、`#pragma`), +# 混进 mingw 工具链是拿一整套没人试过的组合去赌。它们用 unixODBC 那份(上游给 +# mingw 编的就是它),运行期反正加载的都是系统 `odbc32.dll`,两种头在这个后端 +# 里是等价的(`src/db/uvcpp_db_odbc.cpp` 头部那条"不假设 `SQLWCHAR` 宽度"就是 +# 为这件事写的)。 +# +# **真正承重的是"挑哪一版 SDK"这一句,别把它改成字典序。** 这是实测出来的,不是 +# 推断:`ls` 的字典序会把 `10.0.17763.0`(第一个 compat-only 的 SDK)排在最前, +# 于是 `um` 指向一份老 SDK,而工具箱里那份 ``/`winnt.h` 来自新 SDK —— +# 两边混着用,报出来的是 `oaidl.h` / `propidlbase.h` / `sqltypes.h` 里成百条的 +# `C4430` / `C2144`(实测一次是 283 + 166 条),指着 `typedef INT64 SQLLEN;` 这种行, +# 看上去像 ODBC 或 SDK 坏了,其实只是版本对不上。**默认 SDK(VS 生成器自己挑的那份) +# 就是版本号最大的那个**,所以"按版本号取最大"正是"和我们正在用的工具链同一份"。 +# +# `-V`(GNU sort 的版本排序)在 Git for Windows 的 `sort` 上有,两条 MSVC 腿的 +# `shell: bash` 就是它。 +# +# 判据落在**文件在不在**(与 `win-client-headers.sh` / `win-openssl-deps.sh` 同一 +# 条规矩),不落在任何命令的退出码上。找不到时把"真实目录长什么样"打出来再退 1 —— +# 镜像升级换 SDK 是常事,那时候要能一眼看出是路径变了还是真没有。 +# +# 可选的命令行参数:给一个或多个"搜索根"(默认是两张镜像上的 `Windows Kits/10/Include`)。 +# 参数只为**本机自测**存在(在 Linux 上造两棵假树,验证"按版本号取最大"这一条), +# CI 里不传。 +# --------------------------------------------------------------------------- +set -u + +if [ "$#" -gt 0 ]; then + ROOTS=("$@") +else + ROOTS=( + "/c/Program Files (x86)/Windows Kits/10/Include" + "/c/Program Files/Windows Kits/10/Include" + ) +fi + +cands="" +for root in "${ROOTS[@]}"; do + [ -d "$root" ] || continue + for hdr in "$root"/*/um/sql.h; do + # 没有匹配时 glob 会原样留着(`set -u` 下也不会报),所以这里要判一下。 + [ -f "$hdr" ] || continue + cands="${cands}$(basename "$(dirname "$(dirname "$hdr")")") $(dirname "$hdr") +" + done +done + +if [ -z "$cands" ]; then + echo "::error title=win-no-odbc-headers::在 SDK 里找不到 sql.h —— db 的 ODBC 后端会静默降级(生成头里 UVCPP_DB_ODBC_ENABLE=0),包少一个后端" + echo "找过的根(这些目录现在长这样):" >&2 + for root in "${ROOTS[@]}"; do + echo "--- $root" >&2 + if [ -d "$root" ]; then + ls -1 "$root" >&2 || true + echo " 该根下递归命中的 sql.h:" >&2 + find "$root" -name 'sql.h' 2>/dev/null | sed 's/^/ /' >&2 || true + else + echo " (这个目录不存在)" >&2 + fi + done + exit 1 +fi + +# 按版本号取最大(不是字典序 —— 理由见文件头)。`cut -f1` 之后用 `sort -V` 排的是 +# 版本号本身,再把对应的那一行取回来;这样同一个版本号出现两次也无所谓。 +ver=$(printf '%s' "$cands" | cut -f1 | sort -V | tail -1) +dir=$(printf '%s' "$cands" | awk -F'\t' -v v="$ver" '$1==v {print $2; exit}') + +if [ ! -f "$dir/sql.h" ]; then + echo "::error title=win-odbc-headers-race::挑出来的目录里没有 sql.h:$dir" + printf '%s' "$cands" >&2 + exit 1 +fi + +echo "候选(SDK 版本 → um 目录),取版本号最大的那个:" >&2 +printf '%s' "$cands" | sed 's/^/ /' >&2 +echo "选中: SDK $ver → $dir" >&2 + +# 路径里有空格与括号(`Program Files (x86)`),所以给 CMake 的是 **Windows 形式** +# 且**带引号**的一份。MSVC 腿的 shell 是 Git Bash(有 cygpath);万一没有, +# 就退成原样的 POSIX 形式 —— CMake 在 Git Bash 下也认 `/c/...` 这种写法,只是 +# 显式给 Windows 形式更稳(也更好在日志里读)。 +if command -v cygpath >/dev/null 2>&1; then + cygpath -m "$dir" +else + echo "$dir" +fi diff --git a/.github/workflows/ci-linux-ubuntu.yml b/.github/workflows/ci-linux-ubuntu.yml index 46e86796..8c1b02a2 100644 --- a/.github/workflows/ci-linux-ubuntu.yml +++ b/.github/workflows/ci-linux-ubuntu.yml @@ -104,7 +104,7 @@ jobs: # 模块关掉时它们被 `tests/functional/CMakeLists.txt` 的 glob+FILTER 摘掉, # 而 ctest 报 "100% tests passed" 时并不会说"少了几条"。 # - # **不需要任何新包**:pugixml 走 FetchContent(`CMakeLists.txt:1667`), + # **不需要任何新包**:pugixml 走 FetchContent(`CMakeLists.txt:1736`), # 装包表与 `web` 那一格逐字相同 —— 这样两边红绿不同只可能归因到那个开关。 # 四个门禁串见 doc/ci-guide.md §5。 - feature: wsdl @@ -139,7 +139,7 @@ jobs: # nlohmann 那侧照编照跑(`web_app_request_func` 里深度预扫描的五条断言 # 也在),yyjson 那侧的新用例才拿得到对照物。 # - # **不需要任何新包**:yyjson 走 FetchContent(`CMakeLists.txt:1727`, + # **不需要任何新包**:yyjson 走 FetchContent(`CMakeLists.txt:1796`, # `GIT_TAG` 钉在 `YYJSON_VERSION`),装包表与 `web` 那一格逐字相同 —— # 这样两边红绿不同只可能归因到那个开关。 # 四个门禁串见 doc/ci-guide.md §5。 diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml index c8da07ee..c8b22d2c 100644 --- a/.github/workflows/ci-macos.yml +++ b/.github/workflows/ci-macos.yml @@ -60,7 +60,7 @@ jobs: # 与 wsdl 不同:macOS **没有** wsdl 格(§1 的"未覆盖的格"表里那一行写的 # 是"不发版、风险低"),而 yyjson 这一格加得起 —— 它不需要任何新包。 # - # yyjson 走 FetchContent(根 `CMakeLists.txt:1727`,`GIT_TAG` 钉在 + # yyjson 走 FetchContent(根 `CMakeLists.txt:1796`,`GIT_TAG` 钉在 # `YYJSON_VERSION`),**不需要新包**;装包表与 `web` 逐字相同 —— 这样两边 # 红绿不同只可能归因到那个开关。 # 四个门禁串见 doc/ci-guide.md §5。 diff --git a/.github/workflows/ci-mingw64.yml b/.github/workflows/ci-mingw64.yml index 05309239..7cfd768b 100644 --- a/.github/workflows/ci-mingw64.yml +++ b/.github/workflows/ci-mingw64.yml @@ -62,13 +62,13 @@ jobs: # 不带 preset ⇒ 这个开关默认 OFF ⇒ `find_library` 在 MSYS2 里撞上 # C:/msys64/mingw64/lib/libuv.dll.a(runner 镜像自带 libuv)⇒ 链成 # 共享库,产物依赖 `libuv-1.dll`(MSYS2 那份的 DLL 名带 -1)。 - # 注意 `BUILD_SHARED_LIBS` 在这一路上救不了场:CMakeLists.txt:1404-1404 判的是 + # 注意 `BUILD_SHARED_LIBS` 在这一路上救不了场:CMakeLists.txt:1473-1473 判的是 # `TARGET uv_a`,而 find_library 给的是一个**路径**,不是 target, # 所以那个分支根本进不去。 # # ② -DBUILD_SHARED_LIBS=OFF —— 管 llhttp(根 CMakeLists.txt:6 默认 ON ⇒ # llhttp_shared ⇒ 依赖 libllhttp.dll)。它不管 uvcpp 自己 - # (CMakeLists.txt:1915-1915 是显式的 `add_library(uvcpp SHARED ...)`)。 + # (CMakeLists.txt:1984-1984 是显式的 `add_library(uvcpp SHARED ...)`)。 # 本地 A/B 过:OFF 时链接行是 `_deps/llhttp-build/libllhttp.a`,ON 时是 # `libllhttp.dll.a`。装到 FetchContent 的 libuv 上时它顺带也管 libuv # (708 行挑 `uv_a`),但那一路在 CI 上本来就走不到。 diff --git a/.github/workflows/ci-windows-msvc.yml b/.github/workflows/ci-windows-msvc.yml index 998e75f1..5ad92b8e 100644 --- a/.github/workflows/ci-windows-msvc.yml +++ b/.github/workflows/ci-windows-msvc.yml @@ -63,7 +63,7 @@ jobs: # **发版那一刻** —— 而 release.yml 的 msvc-x64 / msvc-arm64 两条腿都开着 # 它。这一格就是那两条腿在 push/PR 阶段的替身。 # - # pugixml 走 FetchContent(根 `CMakeLists.txt:1667`,`GIT_TAG` 钉在 + # pugixml 走 FetchContent(根 `CMakeLists.txt:1736`,`GIT_TAG` 钉在 # `PUGIXML_VERSION`),**不需要新包**;它与 zlib 同一个形状,所以这一格 # 的装包需求与 `web` 逐字相同。 # 四个门禁串见 doc/ci-guide.md §5。 @@ -95,7 +95,7 @@ jobs: # nlohmann 再比 `==`」—— 平台间差异正是在这种地方现形。 # (yaml 里这段 `-D` 之所以放在块序列里、不折成一行,见 §1。) # - # yyjson 走 FetchContent(根 `CMakeLists.txt:1727`,`GIT_TAG` 钉在 + # yyjson 走 FetchContent(根 `CMakeLists.txt:1796`,`GIT_TAG` 钉在 # `YYJSON_VERSION`),**不需要新包**;它与 zlib 同一个形状,所以这一格 # 的装包需求与 `web` 逐字相同。 # 四个门禁串见 doc/ci-guide.md §5。 @@ -340,7 +340,7 @@ jobs: run: | case "${{ matrix.feature }}" in # wsdl 也在这一组:pugixml 走 FetchContent,与 zlib 一样不是系统依赖。 - # yyjson 同理(`CMakeLists.txt:1727` 的 FetchContent)。 + # yyjson 同理(`CMakeLists.txt:1796` 的 FetchContent)。 # db 也是:它的 SQLite 是**从源码编**的(`UVCPP_DB_SQLITE_FROM_SOURCE`), # 所以 Windows 上不需要任何 sqlite 包 —— 这一格一个系统依赖都没有, # 别把它塞进 win-openssl-deps.sh(那是 OpenSSL 专用的)。 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 48e08a4a..a6515056 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -114,6 +114,67 @@ permissions: # 回去,它立刻红);Linux 两条腿另外多一条 `nm -D -u` 的判据,量的是"还剩 # 几个未定义的 mysql_/PQ 符号"—— 那是 `ldd` 看不见的另一半(漏改一处调用点 # 的形状,见那一步的注释)。 +# +# --------------------------------------------------------------------------- +# 1.7.2 起:db 的**第四个**后端 ODBC —— 这一节补的是"最容易被静默漏掉"的那一个 +# --------------------------------------------------------------------------- +# ⑧ UVCPP_ENABLE_DB_ODBC —— 与 ⑦ 的三个后端不同,这一个**默认就是 ON**,所以它 +# 不在"默认 OFF 且发布腿没传"那个差集里(那个差集在 1.7.0 之后已经是空的)。 +# 加它不是为了补差集,是因为它的失败形状**恰好在差集之外**、而正确性只由生成 +# 头那颗宏决定: +# +# `CMakeLists.txt` 对它的处理是"找不到 `sql.h` ⇒ `message(WARNING)` + +# `set(UVCPP_ENABLE_DB_ODBC OFF)`"。那是一句**普通变量赋值** ⇒ +# `CMakeCache.txt` 里照旧写着 `UVCPP_ENABLE_DB_ODBC:BOOL=ON`,只有生成的 +# `uvcpp_config.h` 里那颗宏是 0(这一条与 1.5.0 那次 QUIC/HTTP3 的静默降级 +# 是同一个形状,理由写在 mingw-x64 那条断言步的注释里)。所以:**读 cache +# 永远看不出这个后端没进去,只有读生成头才看得出** —— 这就是它必须进那份 +# 断言清单的原因,也是"默认 ON"不能当作"不用管"的原因。 +# +# (`UVCPP_DB_RUNTIME_LOAD` 那一步为什么救不了:它管的是**链接**,编不编这个 +# 后端是**头**在决定。两条腿各自独立,任一条没配好就少一个后端。) +# +# 六条腿的头**两种来路**,与 ⑦ 的三路同一条纪律(能取到就是取到,取不到当场 +# 红,绝不"跳过这一步继续编"): +# · mingw-x64 / mingw-arm64 —— MSYS2 的 `mingw-w64-{x86_64,clang-aarch64}-unixodbc` +# 进了 job 的 `install:` 列表。**注意它的头不在 `include/` 下**,而在 +# `$MINGW_PREFIX/include/unixodbc/`(2026-10-16 在 `packages.msys2.org` 上 +# 逐条核过两个 arch 的文件表:`/mingw64/include/unixodbc/sql.h`、 +# `/clangarm64/include/unixodbc/sql.h`)—— 指到 `include/` 会**找不到**, +# 然后就是上面那个静默降级,所以下面 configure 里那份 `_INCLUDE_DIR` +# 必须带着 `/unixodbc` 这一段。 +# · msvc-x64 / msvc-arm64 —— 用 Windows SDK 里那份(`/Include//um/sql.h`), +# 由 `.github/scripts/win-odbc-headers.sh` 找出来。理由:Microsoft 的 ODBC +# 头就是 SDK 的一部分、随工具链一起装、且**已经是这个后端验证过的那一份** +# (wlzaweb 的 CI 里 libuvcpp 的 ODBC 后端就是在 SDK 头上编出来、并连上真 +# SQL Server 跑通业务口径的)。 +# +# **那段脚本真正承重的地方是"挑哪一版 SDK"**:按**版本号**取最大的那个, +# 不是字典序。这条是实测出来的 —— 字典序会把 `10.0.17763.0`(第一个 +# compat-only 的 SDK)排在 `10.0.22621.0` 前面,而把老 SDK 的 `um` 混进新 +# 工具链的头搜索路径,报出来的是 `oaidl.h`/`propidlbase.h`/`sqltypes.h` 里 +# 成百条的 C4430/C2144,看上去像 ODBC 的锅,其实一行关系都没有。脚本里那段 +# 注释写了怎么复现。 +# +# 两条 mingw 腿拿到的是 **unixODBC** 的头、不是微软那套:这是有意的,也是 +# `packages.msys2.org` 上给 mingw 编的唯一一份。两个后果都是我们要的: +# · 运行期那份 `libodbc` 也是 unixODBC —— 头与实现同一份源码,不存在"按微软 +# 的头编、却在 unixODBC 上跑"的分叉(`src/db/uvcpp_db_odbc.cpp` 头部那条 +# `SQLWCHAR` 宽度不假设正是为这件事写的:2 字节走 UTF-16、4 字节走 UTF-32, +# 两种头的差异在那里被消化掉); +# · Windows 上真正加载的仍然是**系统** `odbc32.dll`(`uvcpp_db_odbc.cpp` 的候选 +# 名里 Windows 那一支就是它)—— 所以 mingw 包里连 unixODBC 的 .dll 都不需要, +# 头只是编译期的事。 +# +# **两条 mingw 腿的导入表白名单一个字都不要动。** 那份白名单(下面 +# "Assert the DLL is self-contained" 那一步)里**没有** `odbc32.dll`,这是想要 +# 的性质:真被链进去时它会当场红,红了就是"运行期加载失效"的信号。为了让断言 +# 变绿而把 `odbc32.dll` 加进白名单,等于把"Windows 上也按需加载"这条唯一的守卫 +# 拆掉。 +# +# Linux 两条腿多一条**负面**判据:`ldd` 的白名单加 `odbc` 分支(命中即红 = +# runtime load 没生效、被直接链进去了),`nm -D -u` 那条计数加 `SQL[A-Z]` +# (ODBC 符号也不该留下未定义残留)。 jobs: # --------------------------------------------------------------------------- @@ -145,6 +206,12 @@ jobs: # `$MINGW_PREFIX/include/libpq-fe.h`(2026-10-14 在 `packages.msys2.org` # 上逐个核过文件表),下面 configure 里那两个 `_INCLUDE_DIR` 就是照 # 这个布局指过去的。 + # + # 最后那个 `unixodbc` 是 1.7.2 起的(db 的第四个后端)。**它的头不在 + # `include/` 下而在 `include/unixodbc/` 下** —— 同一个包页上核过文件表, + # 所以下面 configure 里那份 `_INCLUDE_DIR` 带着 `/unixodbc` 这一段。 + # 拿它是为了**编**(头),运行期加载的是 Windows 系统的 `odbc32.dll`, + # 与这个包里的 .dll 无关(整段理由在文件头 ⑧)。 install: >- mingw-w64-x86_64-gcc mingw-w64-x86_64-cmake @@ -153,6 +220,7 @@ jobs: mingw-w64-x86_64-python mingw-w64-x86_64-libmariadbclient mingw-w64-x86_64-postgresql + mingw-w64-x86_64-unixodbc - name: Configure & Build run: | @@ -161,15 +229,15 @@ jobs: # ① UVCPP_BUILD_LIBUV_FROM_SOURCE=ON:不带 preset 时它默认 OFF, # `find_library` 会撞上 runner 镜像 MSYS2 自带的 libuv.dll.a,链成 # 共享库 ⇒ 依赖 libuv-1.dll。BUILD_SHARED_LIBS 救不了这一路 - # (CMakeLists.txt:1404-1404 判的是 TARGET uv_a,而 find_library 给的是路径)。 + # (CMakeLists.txt:1473-1473 判的是 TARGET uv_a,而 find_library 给的是路径)。 # ② BUILD_SHARED_LIBS=OFF:管 llhttp(根 CMakeLists.txt:6 默认 ON ⇒ # llhttp_shared ⇒ 依赖 libllhttp.dll)。zlib 不受影响, - # CMakeLists.txt:2284-2285 明确链 zlibstatic。 + # CMakeLists.txt:2353-2354 明确链 zlibstatic。 # 本机所有树都走 CMakePresets(它有 ①),所以这两条从来没在本机复现过。 # # ③ UVCPP_ENABLE_NGHTTP2=ON:这个开关**默认 OFF**(CMakeLists.txt:159-159), # 而这几个打包装的 job 一直没传它 —— 于是包里根本没有 h2,README 却在 - # 介绍它。nghttp2 是唯一被强制静态的依赖(CMakeLists.txt:607-621), + # 介绍它。nghttp2 是唯一被强制静态的依赖(CMakeLists.txt:620-634), # 所以开它不会给产物添任何 dll。六个打包 job 都必须传,任何一个漏了 # 就会发一个"同版本号但少一半功能"的包。 # @@ -182,9 +250,10 @@ jobs: # `EntryPointNotFoundException`。C 层是薄包装:不加外部依赖、不多产物、 # 不多一个 dll,所以开它不改变发布包的模块集合。 # - # ⑤ UVCPP_ENABLE_DB=ON + UVCPP_DB_SQLITE_FROM_SOURCE=ON(MySQL/PG 显式关): + # ⑤ UVCPP_ENABLE_DB=ON + UVCPP_DB_SQLITE_FROM_SOURCE=ON(1.7.0 起 + # MySQL/PG 也显式 ON,1.7.2 起 ODBC 同样): # "发布包带数据库模块"是这一版的一条**发布判据**,而 SQLite 走**自编 - # amalgamation**、另两个后端**显式关**。四条理由全是本机实测出来的, + # amalgamation**。四条理由全是本机实测出来的, # 完整版写在 CMakeLists.txt 那段 SQLite 发现上面与 doc/db-guide.md # 「预编译包里的 SQLite 是从源码编的」: # · 链系统 `libsqlite3.so` ⇒ 产物多一条 DT_NEEDED,正是下面那条 ldd @@ -192,13 +261,22 @@ jobs: # · 链系统 `libsqlite3.a` ⇒ Ubuntu 24.04 上那份不是 PIC,塞进 .so # 当场 `relocation R_X86_64_PC32 … can not be used when making a # shared object`; - # · MySQL/PG 必须显式 `=OFF`:runner 上装着 libpq-dev 时 - # `find_package(PostgreSQL QUIET)` 会**静默**成功,包里就多一条 - # `libpq.so.5` —— 那种包在开发机上照样跑得动,只有换台机器才炸; + # · MySQL/PG 在 1.7.0 **之前**是显式 `=OFF` 的:runner 上装着 + # libpq-dev 时 `find_package(PostgreSQL QUIET)` 会**静默**成功, + # 包里就多一条 `libpq.so.5` —— 那种包在开发机上照样跑得动,只有 + # 换台机器才炸。1.7.0 起改成"显式 `=ON` + 显式给头目录 + 靠 + # `UVCPP_DB_RUNTIME_LOAD=ON` 把链接行擦干净"(文件头 ⑦ 那三种 + # 头的来路):要的是**两个后端都在包里**,而不是拿 `=OFF` 换一条 + # 好看的链接行。这一条今天仍然承重 —— 谁把头目录那一行删掉, + # `find_package` 那一路就又有了机会。 + # · ODBC 是同一形状的第四个:它**默认 ON**,但找不到 `sql.h` 时 + # 只 WARNING 一句就自己关掉(cache 里照旧写着 ON)—— 这一组里 + # 最容易"少一个后端而没有任何东西响"的就是它。所以它除了显式 + # `=ON`,还必须同时给头目录,并被下面那条断言盯着(文件头 ⑧)。 # · 三个都关掉时整个模块会被强制关掉,所以 SQLite 那一条不能省。 - # 与 ③④ 同一条纪律:**十个 configure 一个都不许漏**。这条路不需要任何 - # 新包、不添任何步骤 —— 依赖是配置期从 sqlite.org 取的一份钉死哈希的 - # zip(本机实测:哈希与 sqlite.org 下载页上印的那个逐字相同)。 + # 与 ③④ 同一条纪律:**十个 configure 一个都不许漏**。SQLite 这条路 + # 不需要任何新包、不添任何步骤 —— 依赖是配置期从 sqlite.org 取的一份 + # 钉死哈希的 zip(本机实测:哈希与 sqlite.org 下载页上印的那个逐字相同)。 cmake -S . -B build-mingw -G Ninja -DCMAKE_BUILD_TYPE=Release \ -DBUILD_SHARED_LIBS=OFF \ -DUVCPP_BUILD_LIBUV_FROM_SOURCE=ON \ @@ -213,9 +291,11 @@ jobs: -DUVCPP_ENABLE_BROTLI=ON -DUVCPP_BROTLI_FROM_SOURCE=ON \ -DUVCPP_ENABLE_DB=ON -DUVCPP_DB_SQLITE_FROM_SOURCE=ON \ -DUVCPP_ENABLE_DB_MYSQL=ON -DUVCPP_ENABLE_DB_PGSQL=ON \ + -DUVCPP_ENABLE_DB_ODBC=ON \ -DUVCPP_DB_RUNTIME_LOAD=ON \ -DUVCPP_DB_MYSQL_INCLUDE_DIR="$(cygpath -m "$MINGW_PREFIX")/include/mysql" \ -DUVCPP_DB_PGSQL_INCLUDE_DIR="$(cygpath -m "$MINGW_PREFIX")/include" \ + -DUVCPP_DB_ODBC_INCLUDE_DIR="$(cygpath -m "$MINGW_PREFIX")/include/unixodbc" \ -DUVCPP_BUILD_EXPAND=ON -DUVCPP_STATIC_RUNTIME=ON \ -DOPENSSL_USE_STATIC_LIBS=ON \ -DCMAKE_DISABLE_FIND_PACKAGE_ZLIB=ON @@ -241,9 +321,11 @@ jobs: -DUVCPP_ENABLE_BROTLI=ON -DUVCPP_BROTLI_FROM_SOURCE=ON \ -DUVCPP_ENABLE_DB=ON -DUVCPP_DB_SQLITE_FROM_SOURCE=ON \ -DUVCPP_ENABLE_DB_MYSQL=ON -DUVCPP_ENABLE_DB_PGSQL=ON \ + -DUVCPP_ENABLE_DB_ODBC=ON \ -DUVCPP_DB_RUNTIME_LOAD=ON \ -DUVCPP_DB_MYSQL_INCLUDE_DIR="$(cygpath -m "$MINGW_PREFIX")/include/mysql" \ -DUVCPP_DB_PGSQL_INCLUDE_DIR="$(cygpath -m "$MINGW_PREFIX")/include" \ + -DUVCPP_DB_ODBC_INCLUDE_DIR="$(cygpath -m "$MINGW_PREFIX")/include/unixodbc" \ -DUVCPP_BUILD_EXPAND=ON -DUVCPP_STATIC_RUNTIME=ON \ -DOPENSSL_USE_STATIC_LIBS=ON \ -DCMAKE_DISABLE_FIND_PACKAGE_ZLIB=ON @@ -302,12 +384,21 @@ jobs: # 打开它同时要满足 `UVCPP_BUILD_WEBAPP=ON`(WSDL 的前提,否则配置期静默降级), # 而六条腿本来就都开着。 # - # **`UVCPP_DB_ENABLE` / 三个后端宏同理。** db 模块的开关与宏同名加后缀, + # **`UVCPP_DB_ENABLE` / 四个后端宏同理。** db 模块的开关与宏同名加后缀, # **但这一族的内层宏是按后端各一枚**(`UVCPP_DB_SQLITE_ENABLE` / - # `UVCPP_DB_MYSQL_ENABLE` / `UVCPP_DB_PGSQL_ENABLE`)。**1.7.0 起三枚都在这一串 - # 里** —— 在那之前这里只量 `UVCPP_DB_SQLITE_ENABLE`,因为另两个被显式 - # `=OFF`。为什么现在开着、以及"开着"与"链接行上有它"为什么不是一回事,见 - # mingw-x64 配置那一步 ⑤ 那段注释。 + # `UVCPP_DB_MYSQL_ENABLE` / `UVCPP_DB_PGSQL_ENABLE` / + # `UVCPP_DB_ODBC_ENABLE`)。**1.7.0 起前三枚都在这一串里** —— 在那之前这里只量 + # `UVCPP_DB_SQLITE_ENABLE`,因为另两个被显式 `=OFF`。为什么现在开着、以及"开着" + # 与"链接行上有它"为什么不是一回事,见 mingw-x64 配置那一步 ⑤ 那段注释。 + # + # **`UVCPP_DB_ODBC_ENABLE` 是 1.7.2 新加的第四枚**,也是这一串里唯一一个 + # **"开关默认 ON、仍必须量"**的:它不走"默认 OFF + 发布腿显式开"那条路,而是 + # 走"找不到 `sql.h` 就 `set(... OFF)`"那条静默降级 —— 于是**这正是**上面那段 + # 说的"cache 里照旧 ON、生成头里是 0"的形状。换句话说:对前三枚,这条断言拦的 + # 是"发布腿忘了传开关";对这一枚,它拦的是"这条腿没弄到头"。两者的判据是同一个 + # (生成头),但第二种更隐蔽 —— 开关默认 ON 这件事本身会让人以为它不用管。 + # 整段理由(六条腿各自去哪儿弄头、mingw 那份为什么在 `include/unixodbc/` 下) + # 在文件头 ⑧。 # # **`UVCPP_YYJSON_ENABLE` / `UVCPP_BROTLI_ENABLE` 是 1.7.0 新加的两枚。** 它们 # 与 WSDL 那次同形(默认 OFF、发布腿显式开),但更隐蔽一点:yyjson 在没有这两行 @@ -344,6 +435,7 @@ jobs: UVCPP_HTTP3_ENABLE UVCPP_WSDL_ENABLE \ UVCPP_DB_ENABLE UVCPP_DB_SQLITE_ENABLE \ UVCPP_DB_MYSQL_ENABLE UVCPP_DB_PGSQL_ENABLE \ + UVCPP_DB_ODBC_ENABLE \ UVCPP_YYJSON_ENABLE UVCPP_BROTLI_ENABLE done exit $rc @@ -397,6 +489,12 @@ jobs: # `UVCPP_DB_RUNTIME_LOAD=ON` 省的是链接、省不掉编译 —— 头必须在。 # 这两个包名 2026-10-14 在 `packages.msys2.org` 上核过(`clang-aarch64` # 仓里都在,版本与 x86_64 那条仓同步发布)。 + # + # `unixodbc` 是 1.7.2 起的第四个后端,2026-10-16 在同一个站上核过 + # (`clangarm64` 仓里有,2.3.14-1,与 `mingw64` 那份同版本),**头的布局 + # 也一样在 `include/unixodbc/` 下** —— 下面 configure 里那份 `_INCLUDE_DIR` + # 与 mingw-x64 逐字相同。理由(为什么 mingw 两条腿用 unixODBC 的头而不是 + # 微软 SDK 那份)在文件头 ⑧。 install: >- mingw-w64-clang-aarch64-clang mingw-w64-clang-aarch64-cmake @@ -407,6 +505,7 @@ jobs: mingw-w64-clang-aarch64-libunwind mingw-w64-clang-aarch64-libmariadbclient mingw-w64-clang-aarch64-postgresql + mingw-w64-clang-aarch64-unixodbc # 这条腿的机器本机不存在(没有 arm64 的 Windows 开发机),三个探点只能在这里 # 实测。这一步**故意不设断言**:要的是数据,把关交给下面真正会拦人的白名单 @@ -465,9 +564,11 @@ jobs: -DUVCPP_ENABLE_BROTLI=ON -DUVCPP_BROTLI_FROM_SOURCE=ON \ -DUVCPP_ENABLE_DB=ON -DUVCPP_DB_SQLITE_FROM_SOURCE=ON \ -DUVCPP_ENABLE_DB_MYSQL=ON -DUVCPP_ENABLE_DB_PGSQL=ON \ + -DUVCPP_ENABLE_DB_ODBC=ON \ -DUVCPP_DB_RUNTIME_LOAD=ON \ -DUVCPP_DB_MYSQL_INCLUDE_DIR="$(cygpath -m "$MINGW_PREFIX")/include/mysql" \ -DUVCPP_DB_PGSQL_INCLUDE_DIR="$(cygpath -m "$MINGW_PREFIX")/include" \ + -DUVCPP_DB_ODBC_INCLUDE_DIR="$(cygpath -m "$MINGW_PREFIX")/include/unixodbc" \ -DUVCPP_BUILD_EXPAND=ON -DUVCPP_STATIC_RUNTIME=ON \ -DOPENSSL_USE_STATIC_LIBS=ON \ -DCMAKE_DISABLE_FIND_PACKAGE_ZLIB=ON @@ -490,9 +591,11 @@ jobs: -DUVCPP_ENABLE_BROTLI=ON -DUVCPP_BROTLI_FROM_SOURCE=ON \ -DUVCPP_ENABLE_DB=ON -DUVCPP_DB_SQLITE_FROM_SOURCE=ON \ -DUVCPP_ENABLE_DB_MYSQL=ON -DUVCPP_ENABLE_DB_PGSQL=ON \ + -DUVCPP_ENABLE_DB_ODBC=ON \ -DUVCPP_DB_RUNTIME_LOAD=ON \ -DUVCPP_DB_MYSQL_INCLUDE_DIR="$(cygpath -m "$MINGW_PREFIX")/include/mysql" \ -DUVCPP_DB_PGSQL_INCLUDE_DIR="$(cygpath -m "$MINGW_PREFIX")/include" \ + -DUVCPP_DB_ODBC_INCLUDE_DIR="$(cygpath -m "$MINGW_PREFIX")/include/unixodbc" \ -DUVCPP_BUILD_EXPAND=ON -DUVCPP_STATIC_RUNTIME=ON \ -DOPENSSL_USE_STATIC_LIBS=ON \ -DCMAKE_DISABLE_FIND_PACKAGE_ZLIB=ON @@ -563,6 +666,7 @@ jobs: UVCPP_HTTP3_ENABLE UVCPP_WSDL_ENABLE \ UVCPP_DB_ENABLE UVCPP_DB_SQLITE_ENABLE \ UVCPP_DB_MYSQL_ENABLE UVCPP_DB_PGSQL_ENABLE \ + UVCPP_DB_ODBC_ENABLE \ UVCPP_YYJSON_ENABLE UVCPP_BROTLI_ENABLE done exit $rc @@ -604,7 +708,7 @@ jobs: - uses: actions/checkout@v4 # 不靠镜像里"碰巧装了 OpenSSL":没找到时 `find_package(OpenSSL QUIET)` 会把 - # UVCPP_ENABLE_OPENSSL 静默置 OFF(CMakeLists.txt:534-534),那样发出去的包 + # UVCPP_ENABLE_OPENSSL 静默置 OFF(CMakeLists.txt:547-547),那样发出去的包 # 一句 SSL 都没有 —— 而它的生成头会**如实**说 0,使用者照配也拿不到 SSL。 # # 这条要求由 `.github/scripts/win-openssl-deps.sh` 兑现,而且比原来那一行更硬: @@ -635,8 +739,17 @@ jobs: set -o pipefail # 同 mingw-x64:① 让 libuv 从源码静态编(runner 上若有系统 libuv, # 不带这个开关就会链成 uv.dll),② 让 llhttp 静态(否则 llhttp.dll)。 - # zlib 不受 ② 影响(CMakeLists.txt:2284-2285 明确链 zlibstatic)。 + # zlib 不受 ② 影响(CMakeLists.txt:2353-2354 明确链 zlibstatic)。 # MSVC 侧 uvcpp 自己仍走 /MD + 包内自带 VC++ 运行库,与此无关。 + # + # ODBC 的**头**:db 的第四个后端(1.7.2 起进发布包),头在 Windows SDK + # 里,但要自己找出来 —— `find_path(sql.h)` 不会去翻 SDK 的 `um` 目录, + # 找不到就直接静默降级(生成头里那颗宏是 0,cache 里照旧 ON)。整段理由 + # (为什么用 SDK 那份而不是 unixODBC、为什么**必须按版本号取最大**、别改成 + # 字典序)在 `.github/scripts/win-odbc-headers.sh` 头部。 + # 取不到时它 exit 1 ⇒ 这一步当场停(`shell: bash` 是 `-eo pipefail`), + # 不会带着一份少后端的配置继续编下去。 + odbc_inc=$(bash .github/scripts/win-odbc-headers.sh) cmake -S . -B build-msvc -G "Visual Studio 17 2022" -A x64 \ -DBUILD_SHARED_LIBS=OFF \ -DUVCPP_BUILD_LIBUV_FROM_SOURCE=ON \ @@ -651,9 +764,11 @@ jobs: -DUVCPP_ENABLE_BROTLI=ON -DUVCPP_BROTLI_FROM_SOURCE=ON \ -DUVCPP_ENABLE_DB=ON -DUVCPP_DB_SQLITE_FROM_SOURCE=ON \ -DUVCPP_ENABLE_DB_MYSQL=ON -DUVCPP_ENABLE_DB_PGSQL=ON \ + -DUVCPP_ENABLE_DB_ODBC=ON \ -DUVCPP_DB_RUNTIME_LOAD=ON \ -DUVCPP_DB_MYSQL_INCLUDE_DIR=C:/client-headers/mysql \ -DUVCPP_DB_PGSQL_INCLUDE_DIR=C:/client-headers/pgsql \ + -DUVCPP_DB_ODBC_INCLUDE_DIR="$odbc_inc" \ -DUVCPP_BUILD_EXPAND=ON \ -DCMAKE_DISABLE_FIND_PACKAGE_ZLIB=ON cmake --build build-msvc --config Release --parallel 4 @@ -699,6 +814,7 @@ jobs: UVCPP_QUIC_ENABLE UVCPP_HTTP3_ENABLE UVCPP_WSDL_ENABLE \ UVCPP_DB_ENABLE UVCPP_DB_SQLITE_ENABLE \ UVCPP_DB_MYSQL_ENABLE UVCPP_DB_PGSQL_ENABLE \ + UVCPP_DB_ODBC_ENABLE \ UVCPP_YYJSON_ENABLE UVCPP_BROTLI_ENABLE exit $rc @@ -868,6 +984,12 @@ jobs: # OPENSSL_ROOT_DIR 必须显式给:不给的话 find_package 会在镜像里乱找, # 而 choco 那份是 x64 的 —— 把 x64 的 .lib 链进 arm64 是响亮失败, # 但"找不到 ⇒ QUIET 静默关掉 SSL"是静默坏包,两种都不能靠运气。 + # + # ODBC 的头与 msvc-x64 逐字同源(`win-odbc-headers.sh`):SDK 那份、 + # 按版本号取最大的 `um`。这条腿的镜像是 windows-11-arm,SDK 版本号与 + # windows-2022 未必相同 —— 所以这里**必须**还是走那个脚本去算,不能把 + # 那条腿实测到的路径抄过来。 + odbc_inc=$(bash .github/scripts/win-odbc-headers.sh) cmake -S . -B build-msvc -A arm64 -T v143 \ -DBUILD_SHARED_LIBS=OFF \ -DUVCPP_BUILD_LIBUV_FROM_SOURCE=ON \ @@ -882,9 +1004,11 @@ jobs: -DUVCPP_ENABLE_BROTLI=ON -DUVCPP_BROTLI_FROM_SOURCE=ON \ -DUVCPP_ENABLE_DB=ON -DUVCPP_DB_SQLITE_FROM_SOURCE=ON \ -DUVCPP_ENABLE_DB_MYSQL=ON -DUVCPP_ENABLE_DB_PGSQL=ON \ + -DUVCPP_ENABLE_DB_ODBC=ON \ -DUVCPP_DB_RUNTIME_LOAD=ON \ -DUVCPP_DB_MYSQL_INCLUDE_DIR=C:/client-headers/mysql \ -DUVCPP_DB_PGSQL_INCLUDE_DIR=C:/client-headers/pgsql \ + -DUVCPP_DB_ODBC_INCLUDE_DIR="$odbc_inc" \ -DUVCPP_BUILD_EXPAND=ON \ -DOPENSSL_ROOT_DIR="$(cygpath -m "$GITHUB_WORKSPACE")/openssl-arm64" \ -DCMAKE_DISABLE_FIND_PACKAGE_ZLIB=ON @@ -962,6 +1086,7 @@ jobs: UVCPP_QUIC_ENABLE UVCPP_HTTP3_ENABLE UVCPP_WSDL_ENABLE \ UVCPP_DB_ENABLE UVCPP_DB_SQLITE_ENABLE \ UVCPP_DB_MYSQL_ENABLE UVCPP_DB_PGSQL_ENABLE \ + UVCPP_DB_ODBC_ENABLE \ UVCPP_YYJSON_ENABLE UVCPP_BROTLI_ENABLE exit $rc @@ -1027,10 +1152,63 @@ jobs: # 取不到就当场红,绝不"跳过这一步继续编" —— 那样编出来的包少两个后端, # 而它会一路走到发版。 # - # 目录约定:`$HOME/client-headers/usr/include/{mysql,postgresql}`,与下面 - # 两个 configure 里那四个 `-DUVCPP_DB_*_INCLUDE_DIR=` **必须同表**。 + # `unixodbc-dev` 是 1.7.2 起的第三个(db 的第四个后端 ODBC,头在 + # `usr/include/sql.h`,与另两个同一个 `dpkg -x` 里摊出来)。写在这里是 + # 为了**同一个机制**,不是同一个理由:它**不依赖 libssl-dev**(本机 + # `apt-cache depends` 读过:只依赖 libodbc2/libodbccr2/libodbcinst2 三个 + # 运行库),也就是说这一条即便 `install` 也不会破坏上面那条"不装 + # libssl-dev"的规矩。仍然不 install,理由是另外两条: + # · 这三份头要的是同一种东西、同一个判据、同一个目录约定,三条包名走 + # 两个机制只会让人下次改漏一处; + # · 这台机器上**没有** odbc 的运行库(`.deb` 里那份 `.so` 摊在 + # `$HOME/client-headers` 下、不在链接器的搜索路径里),于是"产物没链 + # libodbc"这件事多一层保障:`UVCPP_DB_RUNTIME_LOAD=ON` 一旦被人从下面 + # configure 里删掉,`find_library(odbc)` 在这里也找不到东西可链 —— + # 而下面那条 ldd 断言是最后一道(那时它才有机会红)。 + # (ODBC 在 Linux 上要的**只是头**:`UVCPP_DB_RUNTIME_LOAD=ON` 时 + # `CMakeLists.txt` 连 `find_library(odbc)` 都不跑,运行期那份 `libodbc.so.2` + # 是**使用者**机器上的事 —— 这正是这个开关的全部意义。) + # + # 目录约定:`$HOME/client-headers/usr/include/{mysql,postgresql}` 与 + # `$HOME/client-headers/usr/include`(ODBC 的 `sql.h` 直接落在 `include/` + # 下,与前两个不同层 —— 本机 `dpkg -x` 后 `find` 过一遍),与下面两个 + # configure 里那**六个** `-DUVCPP_DB_*_INCLUDE_DIR=` **必须同表**。 # 改这里就要改那里。 # + # ⚠️ `unixodbc-dev` 的头**布局随发行版变**,而且它是三个包里唯一一个会 + # 因此编不过的 —— 这件事只有真编一次才会露出来。1.7.2 第一次空转量到两份: + # + # jammy 2.3.9-5ubuntu0.1(`ubuntu-22.04` 与 `ubuntu-22.04-arm` 两条腿) + # usr/include/{sql,sqlext,sqltypes,sqlucode,…}.h 平的,就这一层 + # `sqltypes.h` 只 `#include `;**全包没有 `unixodbc.h`** + # noble 2.3.12(开发机那份) + # usr/include//unixodbc.h + # usr/include//unixODBC/unixodbc_conf.h + # `sqltypes.h` 第 56 行有一句 `#include "unixodbc.h"` ← 少挂就编不过 + # + # (`` 是 `x86_64-linux-gnu` / `aarch64-linux-gnu`,两条腿各一份。) + # 真机上这两层本来都在编译器的默认搜索路径里(`/usr/include` 与 gcc 自己的 + # multiarch 目录),所以**装了 unixodbc-dev 的人一辈子看不到这个分层**; + # 我们只把 `usr/include` 这一层交给 `-DUVCPP_DB_ODBC_INCLUDE_DIR=`,在 noble + # 那种布局下症状是一句 `sqltypes.h:56:10: fatal error: unixodbc.h: 没有那个 + # 文件或目录` —— 看着像头坏了,其实是搜索路径少一层。 + # + # 补法是**把那份头挂到公共头旁边**(符号链接,不是拷贝),这样 `-I + # usr/include` 一个目录就够。`unixodbc.h` 里那句 + # `#include "unixODBC/unixodbc_conf.h"` 是按**打开它的那个路径**解析的, + # 所以 `unixODBC/` 这个目录也要一起挂 —— 少挂它,下一个错就是那句找不到 + # `unixodbc_conf.h`。 + # + # ⚠️⚠️ **但这只在 noble 那种布局下需要。** jammy 是平的、连 `unixodbc.h` + # 都没有,硬要求它存在就是把判据写成了"发行版的属性"而不是"我们的要求" + # —— 第一版正是这么写的,于是在两条 jammy 腿上把一个**完全正常**的包判红 + # (`linux-odbc-headers`,2026-10-17 那次空转)。所以下面那段改成"**有就挂、 + # 没有就算了**",真正的判据是**编一次**(见同一步末尾那个 probe)。 + # 另外:开发机是 Mint 22.3(基于 noble),照它验出来的布局**不能当普遍规律**。 + # + # 这条只影响"把头摊在一个目录里"的做法,与 `CMakeLists.txt` 无关:在真机上 + # `find_path(sql.h)` 拿到的就是 `/usr/include`,下面那一层由 gcc 自己带上。 + # # **版本号必须自己给**(`pkg=ver`),不能交给 `apt-get download` 去挑: # 它挑的是"版本号最大的那个",而这张镜像上 PGDG 的 `libpq-dev` 是 # `18.6-1.pgdg22.04+2` —— 版本最高,且在这张镜像上**没有可取回的源**, @@ -1056,7 +1234,7 @@ jobs: mkdir -p "$HOME/client-headers" ( cd "$HOME/client-headers" - for p in libmysqlclient-dev libpq-dev; do + for p in libmysqlclient-dev libpq-dev unixodbc-dev; do ok="" for v in $(apt-cache madison "$p" \ | awk -F'|' '{gsub(/^[ \t]+|[ \t]+$/,"",$2); if (!seen[$2]++) print $2}'); do @@ -1073,8 +1251,35 @@ jobs: for d in ./*.deb; do dpkg -x "$d" .; done rm -f ./*.deb ) + # `unixodbc-dev` 的头:noble 那种布局要挂、jammy 那种不用(两者的差见本步 + # 上面那一大段)。所以这里是"**有就挂**" —— 没找到**不是**错误。 + _inc="$HOME/client-headers/usr/include" + _uoh=$(find "$_inc" -mindepth 2 -maxdepth 2 -name unixodbc.h -print -quit) + if [ -n "$_uoh" ]; then + # 用绝对路径:`$_inc` 来自 `$HOME`,本来就是绝对的。`ln -sfn` 的目标按 + # **链接所在目录**解析,喂相对路径会建出一个自指的坏链接(本机试过: + # `noble/usr/include/unixodbc.h -> noble/usr/include/noble/…`,照样编不过)。 + ln -sfn "$_uoh" "$_inc/unixodbc.h" + ln -sfn "$(dirname "$_uoh")/unixODBC" "$_inc/unixODBC" + fi test -f "$HOME/client-headers/usr/include/mysql/mysql.h" test -f "$HOME/client-headers/usr/include/postgresql/libpq-fe.h" + test -f "$HOME/client-headers/usr/include/sql.h" + # ODBC 这一份的判据**不是 `unixodbc.h` 在不在**(那是发行版的属性,不是我们 + # 的要求,见本步上面那一大段),而是把后端真正 include 的那两个头**编一次**: + # 后端只有 `#include ` 与 ``(Windows 上多一个 `` + # 且必须排在最前),没有别的隐含要求 —— 所以这个 probe 就是它的编译期前提 + # 本身,发行版怎么换、布局怎么变都判得对。用 `c++ -std=c++11` 是因为真实的 + # TU 是 C++ 源文件(那两个是 C 头),与库同一个标准。 + _probe=$(mktemp -d) + printf '#include \n#include \n' > "$_probe/probe.cpp" + if ! c++ -std=c++11 -I "$_inc" -fsyntax-only "$_probe/probe.cpp" 2>"$_probe/err.txt"; then + echo "::error title=linux-odbc-headers::-I $_inc 编不过 / —— ODBC 后端会静默降级(生成头里 UVCPP_DB_ODBC_ENABLE=0),包少一个后端" + sed 's/^/ /' "$_probe/err.txt" || true + find "$_inc" -maxdepth 3 -name '*.h' | sort | sed 's/^/ /' || true + exit 1 + fi + rm -rf "$_probe" - name: Build OpenSSL with the QUIC API run: | @@ -1180,9 +1385,11 @@ jobs: -DUVCPP_ENABLE_BROTLI=ON -DUVCPP_BROTLI_FROM_SOURCE=ON \ -DUVCPP_ENABLE_DB=ON -DUVCPP_DB_SQLITE_FROM_SOURCE=ON \ -DUVCPP_ENABLE_DB_MYSQL=ON -DUVCPP_ENABLE_DB_PGSQL=ON \ + -DUVCPP_ENABLE_DB_ODBC=ON \ -DUVCPP_DB_RUNTIME_LOAD=ON \ -DUVCPP_DB_MYSQL_INCLUDE_DIR="$HOME/client-headers/usr/include/mysql" \ -DUVCPP_DB_PGSQL_INCLUDE_DIR="$HOME/client-headers/usr/include/postgresql" \ + -DUVCPP_DB_ODBC_INCLUDE_DIR="$HOME/client-headers/usr/include" \ -DOPENSSL_USE_STATIC_LIBS=ON \ -DOPENSSL_ROOT_DIR="$HOME/openssl-quic" \ -DUVCPP_BUILD_EXPAND=ON \ @@ -1206,9 +1413,11 @@ jobs: -DUVCPP_ENABLE_BROTLI=ON -DUVCPP_BROTLI_FROM_SOURCE=ON \ -DUVCPP_ENABLE_DB=ON -DUVCPP_DB_SQLITE_FROM_SOURCE=ON \ -DUVCPP_ENABLE_DB_MYSQL=ON -DUVCPP_ENABLE_DB_PGSQL=ON \ + -DUVCPP_ENABLE_DB_ODBC=ON \ -DUVCPP_DB_RUNTIME_LOAD=ON \ -DUVCPP_DB_MYSQL_INCLUDE_DIR="$HOME/client-headers/usr/include/mysql" \ -DUVCPP_DB_PGSQL_INCLUDE_DIR="$HOME/client-headers/usr/include/postgresql" \ + -DUVCPP_DB_ODBC_INCLUDE_DIR="$HOME/client-headers/usr/include" \ -DOPENSSL_USE_STATIC_LIBS=ON \ -DOPENSSL_ROOT_DIR="$HOME/openssl-quic" \ -DUVCPP_BUILD_EXPAND=ON \ @@ -1252,7 +1461,12 @@ jobs: # `libbrotlicommon`)会一条不响地进包。它同时还是那条 `UVCPP_BROTLI_ # FROM_SOURCE` 开关的**兜底**:开关哪天被删掉、而 runner 上正好有 # libbrotli,这里当场红。 - bad=$(ldd "$so" | grep -oE 'lib(ssl|crypto|z|brotli|uv|llhttp|nghttp2|ngtcp2|nghttp3|sqlite3|mysqlclient|pq)[^ ]*\.so[^ ]*' | sort -u || true) + # `odbc` 是 1.7.2 起的:ODBC 那一族(`libodbc`/`libodbcinst`/`libodbccr`) + # 与 mysql/pq 同形 —— `UVCPP_DB_RUNTIME_LOAD=ON` 时链接行上不该有它。 + # 这条比另两个更值得留神:**这条腿上根本没装 unixODBC 的运行库**(只 + # `dpkg -x` 摊了头,那份 `libodbc.so.2` 不在链接器搜索路径里),所以 + # 真出现在这里,说明有人把运行库装上了并把 `RUNTIME_LOAD` 那一档弄丢了。 + bad=$(ldd "$so" | grep -oE 'lib(ssl|crypto|z|brotli|uv|llhttp|nghttp2|ngtcp2|nghttp3|sqlite3|mysqlclient|pq|odbc)[^ ]*\.so[^ ]*' | sort -u || true) if [ -n "$bad" ]; then echo "::error title=linux-not-self-contained::$so 动态依赖了第三方库: $bad" rc=1 @@ -1277,9 +1491,15 @@ jobs: # 0 是今天的事实(本机 build-dbdlopen 量过);`--no-undefined` # (CMakeLists.txt 那一段)是同一件事的另一半 —— 两条都在,任何一边 # 被人删掉另一条还会响。 - undef=$(nm -D -u "$so" 2>/dev/null | grep -cE 'mysql_|PQ' || true) + # + # `SQL[A-Z]` 是 1.7.2 起补进来的第三个前缀(ODBC 那 24 个符号全以 + # `SQL` 开头、第四个大写字母接着)。落地时对本机一次真配置的 + # `libuvcpp.so` 量过:加了它之后仍然是 0(没加之前量到的也是 0 —— + # 这条判据今天**不会**红,它拦的是"哪天有人把 `uvcpp_db_odbc_api.h` + # 里那张符号表换回直接调用")。 + undef=$(nm -D -u "$so" 2>/dev/null | grep -cE 'mysql_|PQ|SQL[A-Z]' || true) if [ "$undef" != "0" ]; then - echo "::error title=linux-db-symbols-still-linked::$so 里还有 $undef 个未定义的 mysql_/PQ 符号 —— 运行期加载那一档没生效(漏改的调用点,或 UVCPP_DB_RUNTIME_LOAD 被关掉了)" + echo "::error title=linux-db-symbols-still-linked::$so 里还有 $undef 个未定义的 mysql_/PQ/SQL* 符号 —— 运行期加载那一档没生效(漏改的调用点,或 UVCPP_DB_RUNTIME_LOAD 被关掉了)" rc=1 fi done @@ -1315,6 +1535,7 @@ jobs: UVCPP_HTTP3_ENABLE UVCPP_WSDL_ENABLE \ UVCPP_DB_ENABLE UVCPP_DB_SQLITE_ENABLE \ UVCPP_DB_MYSQL_ENABLE UVCPP_DB_PGSQL_ENABLE \ + UVCPP_DB_ODBC_ENABLE \ UVCPP_YYJSON_ENABLE UVCPP_BROTLI_ENABLE done exit $rc @@ -1359,7 +1580,9 @@ jobs: # MySQL/PG 的头:只取 `.deb` 摊开、不 install(整段理由链在 linux-x64 那 # 一步的注释里)。这条腿的 `apt-get download` 拿到的是 **arm64 的 .deb**, # 头是同一份源码生成的,与 x64 那条腿取到的逐字节相同的那几个文件。 - # 目录约定同表:`$HOME/client-headers/usr/include/{mysql,postgresql}`。 + # 目录约定同表:`$HOME/client-headers/usr/include/{mysql,postgresql}` + # 加 `$HOME/client-headers/usr/include`(ODBC 的 `sql.h`;1.7.2 起的第三个 + # 包 `unixodbc-dev`,为什么也走"只摊开不 install"在 linux-x64 那一步)。 # "一版一版试取"的理由链在 linux-x64 那一步的注释里(PGDG 的候选版本最高、 # 却没有可取回的源 ⇒ 不显式给版本号时 `apt-get download` 以 100 退出;而 # `-t` 与 preferences 都改不动它的候选,按来源名去筛也筛不准,全是实测的)。 @@ -1368,7 +1591,7 @@ jobs: mkdir -p "$HOME/client-headers" ( cd "$HOME/client-headers" - for p in libmysqlclient-dev libpq-dev; do + for p in libmysqlclient-dev libpq-dev unixodbc-dev; do ok="" for v in $(apt-cache madison "$p" \ | awk -F'|' '{gsub(/^[ \t]+|[ \t]+$/,"",$2); if (!seen[$2]++) print $2}'); do @@ -1385,8 +1608,35 @@ jobs: for d in ./*.deb; do dpkg -x "$d" .; done rm -f ./*.deb ) + # `unixodbc-dev` 的头:noble 那种布局要挂、jammy 那种不用(两者的差见本步 + # 上面那一大段)。所以这里是"**有就挂**" —— 没找到**不是**错误。 + _inc="$HOME/client-headers/usr/include" + _uoh=$(find "$_inc" -mindepth 2 -maxdepth 2 -name unixodbc.h -print -quit) + if [ -n "$_uoh" ]; then + # 用绝对路径:`$_inc` 来自 `$HOME`,本来就是绝对的。`ln -sfn` 的目标按 + # **链接所在目录**解析,喂相对路径会建出一个自指的坏链接(本机试过: + # `noble/usr/include/unixodbc.h -> noble/usr/include/noble/…`,照样编不过)。 + ln -sfn "$_uoh" "$_inc/unixodbc.h" + ln -sfn "$(dirname "$_uoh")/unixODBC" "$_inc/unixODBC" + fi test -f "$HOME/client-headers/usr/include/mysql/mysql.h" test -f "$HOME/client-headers/usr/include/postgresql/libpq-fe.h" + test -f "$HOME/client-headers/usr/include/sql.h" + # ODBC 这一份的判据**不是 `unixodbc.h` 在不在**(那是发行版的属性,不是我们 + # 的要求,见本步上面那一大段),而是把后端真正 include 的那两个头**编一次**: + # 后端只有 `#include ` 与 ``(Windows 上多一个 `` + # 且必须排在最前),没有别的隐含要求 —— 所以这个 probe 就是它的编译期前提 + # 本身,发行版怎么换、布局怎么变都判得对。用 `c++ -std=c++11` 是因为真实的 + # TU 是 C++ 源文件(那两个是 C 头),与库同一个标准。 + _probe=$(mktemp -d) + printf '#include \n#include \n' > "$_probe/probe.cpp" + if ! c++ -std=c++11 -I "$_inc" -fsyntax-only "$_probe/probe.cpp" 2>"$_probe/err.txt"; then + echo "::error title=linux-odbc-headers::-I $_inc 编不过 / —— ODBC 后端会静默降级(生成头里 UVCPP_DB_ODBC_ENABLE=0),包少一个后端" + sed 's/^/ /' "$_probe/err.txt" || true + find "$_inc" -maxdepth 3 -name '*.h' | sort | sed 's/^/ /' || true + exit 1 + fi + rm -rf "$_probe" # 与 linux-x64 那一同名步骤逐字同构(同一份源码、同一套参数、同一条自检), # 只有 `--prefix` 下的那份库是 aarch64 的 —— OpenSSL 的 Configure 按机器 @@ -1432,9 +1682,11 @@ jobs: -DUVCPP_ENABLE_BROTLI=ON -DUVCPP_BROTLI_FROM_SOURCE=ON \ -DUVCPP_ENABLE_DB=ON -DUVCPP_DB_SQLITE_FROM_SOURCE=ON \ -DUVCPP_ENABLE_DB_MYSQL=ON -DUVCPP_ENABLE_DB_PGSQL=ON \ + -DUVCPP_ENABLE_DB_ODBC=ON \ -DUVCPP_DB_RUNTIME_LOAD=ON \ -DUVCPP_DB_MYSQL_INCLUDE_DIR="$HOME/client-headers/usr/include/mysql" \ -DUVCPP_DB_PGSQL_INCLUDE_DIR="$HOME/client-headers/usr/include/postgresql" \ + -DUVCPP_DB_ODBC_INCLUDE_DIR="$HOME/client-headers/usr/include" \ -DOPENSSL_USE_STATIC_LIBS=ON \ -DOPENSSL_ROOT_DIR="$HOME/openssl-quic" \ -DUVCPP_BUILD_EXPAND=ON \ @@ -1457,9 +1709,11 @@ jobs: -DUVCPP_ENABLE_BROTLI=ON -DUVCPP_BROTLI_FROM_SOURCE=ON \ -DUVCPP_ENABLE_DB=ON -DUVCPP_DB_SQLITE_FROM_SOURCE=ON \ -DUVCPP_ENABLE_DB_MYSQL=ON -DUVCPP_ENABLE_DB_PGSQL=ON \ + -DUVCPP_ENABLE_DB_ODBC=ON \ -DUVCPP_DB_RUNTIME_LOAD=ON \ -DUVCPP_DB_MYSQL_INCLUDE_DIR="$HOME/client-headers/usr/include/mysql" \ -DUVCPP_DB_PGSQL_INCLUDE_DIR="$HOME/client-headers/usr/include/postgresql" \ + -DUVCPP_DB_ODBC_INCLUDE_DIR="$HOME/client-headers/usr/include" \ -DOPENSSL_USE_STATIC_LIBS=ON \ -DOPENSSL_ROOT_DIR="$HOME/openssl-quic" \ -DUVCPP_BUILD_EXPAND=ON \ @@ -1494,7 +1748,12 @@ jobs: # `libbrotlicommon`)会一条不响地进包。它同时还是那条 `UVCPP_BROTLI_ # FROM_SOURCE` 开关的**兜底**:开关哪天被删掉、而 runner 上正好有 # libbrotli,这里当场红。 - bad=$(ldd "$so" | grep -oE 'lib(ssl|crypto|z|brotli|uv|llhttp|nghttp2|ngtcp2|nghttp3|sqlite3|mysqlclient|pq)[^ ]*\.so[^ ]*' | sort -u || true) + # `odbc` 是 1.7.2 起的:ODBC 那一族(`libodbc`/`libodbcinst`/`libodbccr`) + # 与 mysql/pq 同形 —— `UVCPP_DB_RUNTIME_LOAD=ON` 时链接行上不该有它。 + # 这条比另两个更值得留神:**这条腿上根本没装 unixODBC 的运行库**(只 + # `dpkg -x` 摊了头,那份 `libodbc.so.2` 不在链接器搜索路径里),所以 + # 真出现在这里,说明有人把运行库装上了并把 `RUNTIME_LOAD` 那一档弄丢了。 + bad=$(ldd "$so" | grep -oE 'lib(ssl|crypto|z|brotli|uv|llhttp|nghttp2|ngtcp2|nghttp3|sqlite3|mysqlclient|pq|odbc)[^ ]*\.so[^ ]*' | sort -u || true) if [ -n "$bad" ]; then echo "::error title=linux-not-self-contained::$so 动态依赖了第三方库: $bad" rc=1 @@ -1519,9 +1778,15 @@ jobs: # 0 是今天的事实(本机 build-dbdlopen 量过);`--no-undefined` # (CMakeLists.txt 那一段)是同一件事的另一半 —— 两条都在,任何一边 # 被人删掉另一条还会响。 - undef=$(nm -D -u "$so" 2>/dev/null | grep -cE 'mysql_|PQ' || true) + # + # `SQL[A-Z]` 是 1.7.2 起补进来的第三个前缀(ODBC 那 24 个符号全以 + # `SQL` 开头、第四个大写字母接着)。落地时对本机一次真配置的 + # `libuvcpp.so` 量过:加了它之后仍然是 0(没加之前量到的也是 0 —— + # 这条判据今天**不会**红,它拦的是"哪天有人把 `uvcpp_db_odbc_api.h` + # 里那张符号表换回直接调用")。 + undef=$(nm -D -u "$so" 2>/dev/null | grep -cE 'mysql_|PQ|SQL[A-Z]' || true) if [ "$undef" != "0" ]; then - echo "::error title=linux-db-symbols-still-linked::$so 里还有 $undef 个未定义的 mysql_/PQ 符号 —— 运行期加载那一档没生效(漏改的调用点,或 UVCPP_DB_RUNTIME_LOAD 被关掉了)" + echo "::error title=linux-db-symbols-still-linked::$so 里还有 $undef 个未定义的 mysql_/PQ/SQL* 符号 —— 运行期加载那一档没生效(漏改的调用点,或 UVCPP_DB_RUNTIME_LOAD 被关掉了)" rc=1 fi done @@ -1557,6 +1822,7 @@ jobs: UVCPP_HTTP3_ENABLE UVCPP_WSDL_ENABLE \ UVCPP_DB_ENABLE UVCPP_DB_SQLITE_ENABLE \ UVCPP_DB_MYSQL_ENABLE UVCPP_DB_PGSQL_ENABLE \ + UVCPP_DB_ODBC_ENABLE \ UVCPP_YYJSON_ENABLE UVCPP_BROTLI_ENABLE done exit $rc diff --git a/CHANGELOG.md b/CHANGELOG.md index 92fc883f..f4e9cd39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -305,7 +305,7 @@ `doc/quic-guide.md` §8 的措辞是"契约改了",不是"缺口补了"(`1.4.1`) - **那一对私有头。** `uvcpp_quic_session.h` 里是 `ngtcp2_conn*`、`SSL*` 与 `ngtcp2_path_storage`,字段布局跟着 ngtcp2 的版本走 —— 它是第二个私有头,与 - `uvcpp_quic_ngtcp2.h` 并列。两个都不安装(`CMakeLists.txt:3047-3047`)、打包也排除 + `uvcpp_quic_ngtcp2.h` 并列。两个都不安装(`CMakeLists.txt:3118-3118`)、打包也排除 (`tests/tools/package_release.py` 的 `PRIVATE_HEADERS`);量过: `cmake --install build-quic --prefix /tmp/inst` 落进 `include/quic/` 的正好是那 四个公开头(`1.4.1`) @@ -683,7 +683,7 @@ `std::string` / `uint32_t` / `const char*`,**不认识连接、不认识响应**,所以 `UVCPP_BUILD_WEB=OFF` 时照编照装。`uvcpp_grpc_server.{h,cpp}` 把上面那半接到 `uvcpp_http_server` 上,它的每一个参数都是 web 层的类型,web 关掉时**从构建与安装 - 两处都摘掉**(`CMakeLists.txt:1951-1953`、`CMakeLists.txt:2939-2944`)—— + 两处都摘掉**(`CMakeLists.txt:2020-2022`、`CMakeLists.txt:3010-3015`)—— 实测:`-DUVCPP_BUILD_WEB=OFF` 的树上 `src/grpc/uvcpp_grpc.cpp` 照编、装到前缀里的 只有 `include/grpc/uvcpp_grpc.h`;`build-capi-all` 上前缀里两个头都在。 **不加 `UVCPP_ENABLE_GRPC`**:两半的依赖是死的,多一个旋钮只会多一个能与 @@ -787,11 +787,143 @@ ### 数据库(`src/db/`) -一个连接一个 `uvcpp_db_client`,**同一套接口盖住 SQLite / MySQL / PostgreSQL** -三个后端,结果行按表取(列名或列号)。**默认关**(`UVCPP_ENABLE_DB`),而且是本库 -**唯一需要第三方客户端库**的模块(libsqlite3 / libmysqlclient / libpq)—— 三个后端 -一个都没找到时整个模块被强制关掉并打一条说明怎么修的 warning,而不是留一个 -"能配置、链不上"的组合(`1.5.3`) +一个连接一个 `uvcpp_db_client`,**同一套接口盖住 SQLite / MySQL / PostgreSQL / +ODBC** 四个后端,结果行按表取(列名或列号)。**默认关**(`UVCPP_ENABLE_DB`),而且是 +本库**唯一需要第三方客户端库**的模块(libsqlite3 / libmysqlclient / libpq / 驱动 +管理器 + 具体 ODBC 驱动)—— 四个后端一个都没找到时整个模块被强制关掉并打一条说明 +怎么修的 warning,而不是留一个"能配置、链不上"的组合(`1.5.3`) + +- **ODBC 后端:它是第四个后端,但与前三个不同类 —— 它对着的不是一种数据库,是一个 + 接口**(`1.7.1-dev`)。前三个各自钉死一种客户端库与一种方言,`odbc://` 连到哪张 + 库、哪种方言、甚至哪个厂商,全由连接串里的 `Driver=`(或 `DSN=`)决定;本驱动 + **只试那一个驱动名,不遍历**。进它的直接原因是 SQL Server:微软没给 SQL Server + 一个可链接的 C 客户端库,官方 `msodbcsql18` **本身就是**一个 ODBC 驱动,想接它就 + 只能走这条路(这也是本仓第一个不是"为了某一种库"而写的后端)。 + + 于是有两条**必须分开看**的事实:`UVCPP_DB_ODBC_ENABLE` 说的是"这份产物里有没有这个 + 后端",而"要连的那个数据源有没有装驱动"**只有 `open()` 才知道** —— 驱动名写错时 + 拿到的是驱动管理器给的原文("找不到数据源、也没有默认驱动"),不是我们编的话。 + 配置期只要两样:`sql.h` 与驱动管理器(Linux 的 `libodbc`、Windows 的系统 `odbc32`)。 + **它也认 `UVCPP_DB_RUNTIME_LOAD`**:起先这里写的是"驱动管理器本来就该在、而且很小, + 所以不走那条路",那句话**只在 Windows 上成立** —— 下一节第一条就是按实测改它的 + (本机 `-- db: ODBC 后端开(驱动管理器 …/libodbc.so)`,编、链 rc=0)。 + + 写这一片时踩到一处值得记下来的头文件陷阱:unixODBC 的 `sqltypes.h` 第 75 行有一句 + `#define BOOL int`(给没有 `` 的平台补齐 ODBC 那套类型表),它会把 + `src/db/uvcpp_db_odbc.cpp` 里唯一一处 `uvcpp_db_type::BOOL` 展开成 + `uvcpp_db_type::int`,报出来的错是"expected unqualified-id before 'int'" —— 离真正 + 的原因隔着一层。收尾是一句 `#undef BOOL`(ODBC 头已经预处理完,这个宏使命结束, + 本文件从头到尾不用它)。微软那套 `sqltypes.h` 不发这个宏(Windows 上 `BOOL` 是 + `` 里的 typedef,对 `enum class` 的限定名没影响),所以那行在 Windows 上 + 是空操作。 + +- **ODBC 后端进了共用套件,套件立刻抓出它自己的六处**(`1.7.1-dev`)。用例在 + `tests/functional/db_odbc_func.cpp`:方言按 `Driver=` 认出来的驱动族分派 + (`odbc_sqlite_dialect` / `odbc_mssql_dialect`),认不出的族退 **3**。本机对着 + sqliteodbc + unixODBC 跑:**181 通过 / 0 失败 / 3 不适用**,另有 **57 条 ODBC + 专属**(日志里单独一段)。这一组数全是**量出来的**,不是照着文档推的: + + 1. `SQLBindParameter` 只**记指针**(缓冲**和** `StrLen_or_IndPtr` 都记),绑定的 + 存储必须活到 `SQLExecute` 之后,而且**每个参数各一份**。共用一份时,四个参数 + `(1, 2, 't0', 'uu0')` 存进去读回来是 `(2, 2, 't', 'u')` —— 每个都只剩最后一次 + 写入的一个字节; + 2. `SQL_C_WCHAR` 的长度指示器单位是**字节**:给字符数会把 `"张三"` 截成 `"张"`、 + `'abcdef'` 截成 `'abc'`; + 3. 空串与 NULL 是靠**指针**分的:有效指针 + `ind=0` 是空串,`nullptr` + `ind=0` 是 + NULL。空串上 `std::string::data()` 给不给非空指针是实现自由,所以代码里备了两个 + 哑元(`kEmptyByte` / `kEmptyWide`)——这条错了的话,写空串会**响**在非空约束上, + 而不是静默存成 NULL; + 4. 分块读时一个**满**块只给 `BufferUnits - 1` 个数据单元(驱动给 NUL 留位),按整块 + 计数会把 3000 字节读成 3001 字节; + 5. `SQLGetInfo` 的 W 形态返回的长度也**是字节**:拿它当单元数会让引号字符 `"` 变成 + 2 个"字符",于是"内层引号双写"那一支被跳过,怪表名 `uvcpp_odd"name` 被拼成 + `"uvcpp_odd"name"`。改成认尾部的 NUL,不看那个长度; + 6. 无参数那条快路径绕过了 `SQLNumParams` 检查:语句里写了 `?` 却一个参数都不给时 + 报的是 `EXEC_FAILED`,而这是**绑定**阶段的错,该报 `BIND_FAILED`。 + + 另有两处是**驱动的自由**,不是我们的 bug —— 套件改成"按声明的能力判",不硬凑断言: + sqliteodbc 的 `SQLPrepare` 只**登记**不解析(语法错与表不存在要到 `SQLExecute` 才 + 报),因此 `db_suite.h` 多了一个 `dialect::prepares_eagerly()`;它同样**不接受** + `$1` 占位符(`parameter marker count incorrect`),所以 ODBC→SQLite 的 + `accepts_dollar_placeholder()` 是 `false`(原来是照"ODBC 应该有"猜的 `true`)。 + 这两条改的是共用套件,所以 SQLite 那一家按同一份套件重跑过:**196 通过 / 0 失败 / + 1 不适用**,改动没有扰动别的后端。 + + 顺手把两条套件原本没走到的路也**量了**并钉成断言(都过):二进制跨块 + (3 KB BLOB,逐字节相等 —— `SQL_C_BINARY` 没有终止符,满块给满 `kChunk`),以及 + 4 字节 UTF-8 字符(UTF-16 是**代理对**)正好骑在块边界上(写路径按代理对编码, + 驱动不会在缓冲边界把它劈开)。 + + **没跑的是 ODBC 到 SQL Server 那一族**:`odbc_mssql_dialect` 写好了(`INT + IDENTITY(1,1) PRIMARY KEY` / `NVARCHAR(MAX)` / `VARBINARY(MAX)` / `DATETIME2` / + `BIT` / `DECIMAL(18,2)`),但这台机器上没装 msodbcsql18。别把它当成已经对过。 + +- **ODBC 也接上了 `UVCPP_DB_RUNTIME_LOAD`:它的"客户端库"就是驱动管理器** + (`1.7.1-dev`,默认 `OFF`)。修的是上面那条里的一句判断:把驱动管理器链上,在 + **Linux / macOS 上就是一条 `DT_NEEDED libodbc.so.2`** —— 正是这个开关要消灭的形状 + ("没装 unixODBC 的机器连加载这份 `.so` 都做不到")。**只有 Windows 是例外**: + `odbc32.dll` 属于操作系统,和 `ws2_32` 同类,链接它没有代价。本机两档实测 —— + 两棵树(`/tmp/uvcpp-off` 与 `/tmp/uvcpp-rt`)**只差这一个开关**,其余开关逐条相同: + + ``` + $ readelf -d /tmp/uvcpp-off/libuvcpp.so | grep NEEDED # 关(默认) + NEEDED libuv.so.1 + NEEDED libsqlite3.so.0 + NEEDED libodbc.so.2 + $ readelf -d /tmp/uvcpp-rt/libuvcpp.so | grep NEEDED # -DUVCPP_DB_RUNTIME_LOAD=ON + NEEDED libuv.so.1 + NEEDED libsqlite3.so.0 + $ nm -u …/uvcpp_db_odbc.cpp.o | grep -c ' U SQL' # 24 → 0 + ``` + + 那张函数表是**从 `nm -u` 的目标文件里数出来的 24 个入口**,不是照着 grep 抄的: + `SQLGetInfo` 在文件里只出现在一句注释里(用的是 W 形态 `SQLGetInfoW`),照着文本 + 抄会把一个没人提供的符号抄进表里。新私有头 `src/db/uvcpp_db_odbc_api.h` 与 + `uvcpp_db_pgsql_api.h` 同形(进安装排除与 `package_release.py` 的 + `PRIVATE_HEADERS`),64 处调用点改成 `api().SQLxxx(...)`,`open()` 先看 + `uvcpp_db_odbc_load_error()`,非空就报 `NO_DRIVER` 并把候选名与各自的失败原因写在 + 里面。**`#error` 那道闸门这次是必需的**:`ODBC_STD` 会把 `SQLAllocHandle` 重定向到 + `SQLAllocHandleStd`、`UNICODE` 会把 `SQLSetStmtAttr` 重定向到 `SQLSetStmtAttrW` + (两处都在头文件里的条件宏里),按名字取符号的构建方式与直接链的会**分叉**,所以 + 编这个 `.cpp` 时两者都必须未定义,否则当场红。负路径也量了: + `UVCPP_DB_ODBC_LIBRARY=/nonexistent/libodbc.so.2` ⇒ `no_driver` 加那句逐个列出试过 + 哪些库名的话。两档各跑一遍套件,读数一致:ODBC **181 / 0 / 3**(另 57 条 ODBC + 专属)、SQLite **196 / 0 / 1**。默认 `OFF` 的行为逐字节不变。 + +- **"这台机器没装驱动管理器"从一句手工复现的话,变成一个有用例钉住的形状** + (`1.7.2-dev`)。上面那条里的负路径(`UVCPP_DB_ODBC_LIBRARY=/nonexistent/…` ⇒ + `no_driver`)此前**没有任何自动化**:`tests/`、`bench/` 里三个 `UVCPP_DB_*_LIBRARY` + 旋钮全零命中,它只活在文档里当一段"你可以这样试"。新用例 + `tests/functional/db_odbc_nodrv_func.cpp` 把用户那句要求("没有 ODBC 动态库时其他 + 功能正常运行、用的时候报异常")逐条钉住:`open("odbc://u:p@127.0.0.1:1/…")` 回的是 + **`NO_DRIVER`**(不是含糊的 `OPEN_FAILED`、更不是崩),`is_open()` 为假,`last_error()` + 是一句说得出**试过哪几个库名**的人话,同一进程里再开一个 `sqlite::memory:` 照常 + CREATE/INSERT/SELECT。 + + 前提不去卸载任何东西 —— 由 ctest 的 `ENVIRONMENT` 属性把那个旋钮指到一个不存在的 + 路径(这正是 `uvcpp_db_runtime_load.h` 里写明这个旋钮的用途)。**它必须是独立的 + 可执行文件**:ODBC 的符号表走 magic static、句柄永不释放,同一个进程里成功加载过 + 一次之后,再设环境变量就不生效了。前提不成立(变量没传到、或路径居然存在)时返回 + **非零即红**,不是跳过 —— 这个前提是我们自己设的,没有不确定性可言,退回跳过只会 + 让这条用例悄悄失效。 + + 顺手修掉一处同门的老毛病:`test_db_odbc_func` 原来不在 `SKIP_RETURN_CODE 3` 那一串 + 里,于是在装了 `unixodbc-dev` 却没配数据源的机器上(CI 就是)它显示成 `Failed`, + 而真相是"未判定"。判据现在归位。 + +- **两个 ODBC 新文件补上 UTF-8 BOM**(`1.7.2-dev`)。`src/db/uvcpp_db_odbc_api.h` + 里全是中文注释却没带 BOM,而 `src/db/` 下 24 个文件里只有它与 + `uvcpp_db_odbc.cpp` 没带(前两个后端各自的 `*_api.h` 都带)。这条不是风格问题: + MSVC 读无 BOM 的源文件时按系统 ANSI 代码页(简中 936/GBK)解码,一个三字节汉字被 + 当成两个双字节字符、字节边界整体错位,该在注释里的 `*` 变成运算符、`*/` 落到注释 + 外,于是其后每个头都在"注释外"被解析、一路级联到 ``,**报错地点与病因 + 完全无关**。仓内看不见,因为顶层 `CMakeLists.txt` 的 `/utf-8` 是**目录作用域**的 —— + 它进不了导出集,也到不了"直接把源码树给 msvc"那条消费路径。 + + 抓到它的是 `config-contract` 腿的 `check_header_encoding()`(判的是 `src/` 下每一个 + `.h`,不只安装集里那些)。它本来就该在分支自己的 CI 上红过:`feature/db-odbc` 没有 + 对着 master 开过 PR,而这几个文件是随 `1.7.1-dev` 那几个提交进来的。`package_release.py` + 里那段"含非 ASCII 又没 BOM 就补一个"的兜底**救不了**它 —— 私有头不进包。 - **MySQL / PostgreSQL 的客户端库改成可以运行时加载:`UVCPP_DB_RUNTIME_LOAD`** (`1.6.54-dev`,默认 `OFF`)。直接链的后果不是"少个功能"而是**进程起不来**: @@ -2726,6 +2858,96 @@ ### 配置、打包与 CI +- **`package_release.py` 的"Windows 自带模块"表补上 `odbc32.dll`**(`1.7.2-dev`)。 + 这张表只用来判断"要不要打进包里",漏一个的代价不是静默坏包而是**打不出包**:db 的 + ODBC 后端在 `UVCPP_DB_RUNTIME_LOAD=OFF`(**默认档**)的 Windows 构建上就是链 + `odbc32`,于是 `uvcpp.dll` 的导入表里有它,而打包那步的自检会把"非 Windows 自带" + 的导入项逐个去 `runtime_roots` 里找、找不到就 `missing[]` + 退出 2 —— 也就是说 + **默认配置在 Windows 上根本产不出包**(`odbc32.dll` 属操作系统组件,System32 里 + 必有,与 `ws2_32.dll` 同类,既不可再分发也不需要分发)。 + + 这条是 MSVC 那两条 `config-contract` 腿抓到的:它们**不传** `UVCPP_DB_RUNTIME_LOAD` + (走默认档、链导入库),而六条发布腿传 `ON`(符号由 `LoadLibrary` 取、导入表里没有 + 它)—— 所以**发版路径上看不见这个形状**,只有 PR 上那两条腿能看见。这也正是那两条腿 + 与发布腿"配置不逐字相同"的价值所在:它们各自量到一个方向的坏包。 + +- **六条发布腿显式打开 db 的第四个后端(ODBC),并且每一条都显式给出 `sql.h` 在哪** + (`1.7.2-dev`)。此前 `release.yml` 全文零次提到 odbc:`UVCPP_ENABLE_DB_ODBC` 默认就是 + `ON`,而找不到 `sql.h` 时走的是 `message(WARNING)` + 普通变量 `set(... OFF)` —— + `CMakeCache.txt` 里照旧写着 `ON`、生成头里却是 `0`、包里少一个后端,**一条断言都不会 + 红**。十条 configure 全部补上 `-DUVCPP_ENABLE_DB_ODBC=ON` 与 + `-DUVCPP_DB_ODBC_INCLUDE_DIR=`,头的来源按腿分两种:两条 MinGW 腿用 MSYS2 的 + `mingw-w64-{x86_64,clang-aarch64}-unixodbc`(唯一一套**给 mingw 编的** ODBC 头, + 落在 `$MINGW_PREFIX/include/unixodbc/`;SDK 那份是给 MSVC 的,混进 mingw 是拿一个 + 没人试过的组合去赌),两条 MSVC 腿用 Windows SDK 的 `/Include//um` + (新脚本 `.github/scripts/win-odbc-headers.sh` 挑出来);两条 Linux 腿照 MySQL/PG 的 + 老规矩 `apt-get download unixodbc-dev` 加 `dpkg -x`,**不 install**。 + + 三个只有真编一次才会露出来的坑,都写进 `release.yml` 的注释了:SDK 那份**不能按字典 + 序**挑(字典序把 `10.0.17763.0` 排在最前,于是 `um` 来自老 SDK 而 `` 来自 + 新的,报出来是 `oaidl.h` / `propidlbase.h` / `sqltypes.h` 里成百条 `C4430` / + `C2144`);两条 Linux 腿的 `ldd` 名单与 `nm -D -u` 计数各补一条 ODBC 判据,而两条 + MinGW 腿的导入表白名单 **一个字都没动** —— 里面没有 `odbc32.dll`,那正是"运行期加载 + 失效就会红"的唯一守卫。六条腿的模块断言(读的是生成头,不是 cache)因此从 17 枚变 + **18 枚**。 + +- **`unixodbc-dev` 的头布局随发行版变,而第一版判据把它写成了"发行版的属性"** + (`1.7.2-dev`)。第一次空转(2026-10-17)两条 Linux 腿当场红在 + `linux-odbc-headers::unixodbc-dev 里没摊出 unixodbc.h`——**守卫本身没错**(宁可红, + 也不静默发一个少后端的包),错的是那个谓词。实测两份 `.deb` 的文件表: + + | | `sqltypes.h` 里的 include | `unixodbc.h` | + |---|---|---| + | jammy `2.3.9-5ubuntu0.1`(`ubuntu-22.04` / `-arm`) | 只有 `#include ` | **全包没有** | + | noble `2.3.12`(开发机 Mint 22.3 那份) | 多一句 `#include "unixodbc.h"` | `usr/include//unixodbc.h` | + + 也就是说"少挂一层"这件事**只有 noble 那种布局才有**,在 jammy 上要求 `unixodbc.h` + 存在,等于把一个完全正常的包判红。改法是两半:软链那步变成"**有就挂、没有就算了**", + 判据本身换成**编一次**——把后端真正 include 的 `` + `` 用 + `c++ -std=c++11 -I <摊开的 include 目录> -fsyntax-only` 过一遍。后者与发行版、与布局 + 都无关,而且直接就是"这个后端能不能编"本身(本地对 jammy 平布局、noble 拆布局、以及 + 故意放一个空 `sql.h` 三种情形各跑过一遍,前两种绿、第三种红并打出编译器原文)。 + 软链那步另有一处细节写进注释了:`ln -sfn` 的目标按**链接所在目录**解析,喂相对路径会 + 建出自指的坏链接(`$HOME` 派生出来的 `_inc` 本来就是绝对的,所以发布腿上不会犯)。 + **教训**:开发机的发行版不能当普遍规律 —— 这个坑就是照 noble 验出来、在 jammy 上红的。 + +- **两条 MinGW 发布腿在真编之前一次都没编过,一编就红:unixODBC 那份 `sqltypes.h` 的 + "补 windows 类型"段与 `winnt.h` 硬碰**(`1.7.2-dev`)。同一次空转里 `mingw-x64` / + `mingw-arm64` 两条腿红在 `Configure & Build`,而整条腿**只有一处** error: + + sqltypes.h 第 109 行: error: conflicting declaration 'typedef short unsigned int WCHAR' + winnt.h 第 320 行: note: previous declaration as 'typedef wchar_t WCHAR' + + 病灶是那段补丁的**开关**——它不叫"有没有 ``",叫 + `ALREADY_HAVE_WINDOWS_TYPE`,而**这个宏谁都不定义**,所以 `` 明明已经在前面 + 进来过,它照样把 ODBC 那套类型补一遍。补出来的 `CHAR` / `WORD` / `DWORD` / `BYTE` / + `LPDWORD` 与 `winnt.h` 逐字相同(C++ 里重复 typedef 合法,一声不响),只有 `WCHAR` + 不同:它给 `unsigned short`,`winnt.h` 给的是 `wchar_t`。MSVC 上永远碰不到,因为 + Windows SDK 那份 `sqltypes.h` 是微软自己写的、没有这一整段补丁 —— **所以这个坑与上一段 + 的"头布局"同源:都只在一条腿的平台上现形**。 + + 解法用这段补丁自己的开关 `SQL_WCHART_CONVERT`(`sqltypes.h` 第 106 行):打开后 `WCHAR` 就是 + `wchar_t`,与 `winnt.h` 逐字相同、冲突消失。这不是权宜之计,语义上本来就该这样 —— + Windows 的 ODBC 宽字符接口就是 `wchar_t`(2 字节),`unsigned short` 那个是 unixODBC 给 + "没有 `` 的平台"准备的默认值(它的注释自己写的)。`SQLWCHAR` 由 + `typedef WCHAR` 派生,跟着一起对上,**尺寸不变、仍是 2 字节** ⇒ 后端里那条"按 + `sizeof(SQLWCHAR)` 编译期分 UTF-16 / UTF-32"的转换走的还是 MSVC 上已经在跑的那条路, + **不新增代码路径**。判据写成"`_WIN32` 但不是 `_MSC_VER`"而不是 `__MINGW32__`(真正的分界 + 是"手上这份 `sqltypes.h` 是 unixODBC 的还是微软的";万一有人拿 mingw 配 SDK 的头,这个宏 + 在微软那份里一次都没被引用、是空操作);**Linux 上必须不开** —— 那里 `wchar_t` 是 4 + 字节,开了就是把 `SQLWCHAR` 从 2 字节改成 4 字节,那是改 ABI 不是修 bug。 + + 同一条腿还带一句 `'CALLBACK' redefined` **警告**(同一段补丁的 `#define CALLBACK` 空定义 + 盖掉了 `minwindef.h` 的 `#define CALLBACK __stdcall`,而且它**出完 include 不复原**)。 + 它不致命,但等于把这个宏借走不还,所以顺手用 `push_macro` / `#undef` / `pop_macro` 借 + 出去再还回来;万一哪个编译器不认这两个 pragma,按标准它们是被忽略的,结果退回原样、 + 不更坏。 + + **教训与上一段同一个,只是换了个维度**:上一段是"开发机的发行版不能当普遍规律",这一段 + 是"**Linux 上编得过不能推出 mingw 上也编得过**"—— 这两条腿的头是按 `packages.msys2.org` + 的文件表核的、不是编出来的,而"文件表里有 `sql.h`"与"这份 `sql.h` 能和 `` + 一起编"是两件事。 + - **`RELEASE.md` 的「发布包」那张表补上 1.7.0 那一轮**(`1.6.61-dev`)。表原来只有 `1.5.2` / `1.5.3` 两行(小标题也写着"两轮扩容"),而 1.7.0 把包里的模块从十三个扩到 十七个这件事**没进表** —— 按"哪一版多了什么"去查的人会查到一份停在这里的账。补上 @@ -2894,17 +3116,17 @@ dlopen 只是把这条路变成**每个装了 libpq 的人**的默认路径。)把我们的嵌入依赖藏起来, libpq 就会解析到**系统**那份、与它自己的 libcrypto 自洽。 - 修法一(`CMakeLists.txt:2445-2487`,`UNIX AND NOT APPLE`,只给共享目标 + 修法一(`CMakeLists.txt:2516-2558`,`UNIX AND NOT APPLE`,只给共享目标 `uvcpp`/`uvcpp_s`):`target_link_options(… PRIVATE "LINKER:--exclude-libs,ALL")`。 它只藏**来自静态归档**的符号,而这两个目标是 - `add_library(uvcpp SHARED ${UVCPP_SOURCES} …)`(`CMakeLists.txt:2147-2147`) + `add_library(uvcpp SHARED ${UVCPP_SOURCES} …)`(`CMakeLists.txt:2216-2216`) **直接拿我们自己的目标文件**建的、不经归档 ⇒ 我们自己的 API 与 `uvcpp_c_*` 那套 C ABI 照样导出。只给共享目标加是因为静态库没有导出表,藏了反而让 `uvcpp_a` 的消费者 在最终链接时找不到本该由我们提供的符号;只给 ELF 加是因为 Apple 的 ld 没有这个选项 (那边是 `-exported_symbols_list`),而发布腿里本来也没有 macOS。 修法二:`uvcpp_yyjson` 是 **OBJECT** 库,它的目标文件经 - `$`(`CMakeLists.txt:2776-2776`)**直接进最终链接、也不经归档** ⇒ + `$`(`CMakeLists.txt:2847-2847`)**直接进最终链接、也不经归档** ⇒ 上面那条对它无效(实测加不加都是 43 个 `yyjson_*`)。那边改用 `C_VISIBILITY_PRESET hidden` **加** `target_compile_definitions(uvcpp_yyjson PRIVATE yyjson_api=)`。**第二半是承重的**:上游 `yyjson.h` 给每个公开函数**显式**贴了 @@ -2948,16 +3170,16 @@ 记过是陷阱(它只骗得过 CMake 的 `find_*`,骗不过编译器自己的默认搜索路径)。 修法照 `UVCPP_DB_SQLITE_FROM_SOURCE`(`CMakeLists.txt:189`)的命名与形状加一个 - **默认 OFF** 的选项(`CMakeLists.txt:233-234`),把整段发现包进 - `if(NOT UVCPP_BROTLI_FROM_SOURCE)`(`CMakeLists.txt:409-409`)。注意是**连发现都不 + **默认 OFF** 的选项(`CMakeLists.txt:246-247`),把整段发现包进 + `if(NOT UVCPP_BROTLI_FROM_SOURCE)`(`CMakeLists.txt:422-422`)。注意是**连发现都不 调用**,不是"找到了也偏不用":`find_path`/`find_library` 的结果会写进 cache, 调用过一次,`UVCPP_BROTLI_INCLUDE_DIR` 之类就一直留在那棵树里,同一棵树翻开关时 会把上一轮的答案漏过来。所以每个"找到系统那份"的判据都**显式**带上 - `NOT UVCPP_BROTLI_FROM_SOURCE`(`CMakeLists.txt:420-420`、`CMakeLists.txt:424-424`、 - `CMakeLists.txt:432-432`),而不是指望"没调用 = 没找到"。取源码那条支路一个字没改: + `NOT UVCPP_BROTLI_FROM_SOURCE`(`CMakeLists.txt:433-433`、`CMakeLists.txt:437-437`、 + `CMakeLists.txt:445-445`),而不是指望"没调用 = 没找到"。取源码那条支路一个字没改: 选项开着 ⇒ 跳过发现 ⇒ `BROTLIENC_LIB` 仍是 `NOTFOUND` ⇒ 自然落进那一支 ⇒ - 那里照旧 `set(UVCPP_BROTLI_FROM_SOURCE TRUE)`(`CMakeLists.txt:510-510`),链接段 - (`CMakeLists.txt:2629-2629`)也就照旧按 `$` 取静态库。默认值一个都没 + 那里照旧 `set(UVCPP_BROTLI_FROM_SOURCE TRUE)`(`CMakeLists.txt:523-523`),链接段 + (`CMakeLists.txt:2700-2700`)也就照旧按 `$` 取静态库。默认值一个都没 动,所以既有使用者的行为逐字节不变。 判据(本机,`build-brotli2`:`-DUVCPP_ENABLE_BROTLI=ON @@ -3143,7 +3365,7 @@ `flags` = `web` 加一个 `-DUVCPP_ENABLE_YYJSON=ON`,四道门禁串 `UVCPP_ENABLE_YYJSON:BOOL=ON` / `yyjson integrated` / `Including yyjson module in build` / `test_yyjson_doc_func`;装包表与 `web` **逐字相同**(yyjson 走 FetchContent - —— `CMakeLists.txt:1727`,`GIT_TAG` 钉在 `YYJSON_VERSION` —— **不发包管理器**), + —— `CMakeLists.txt:1796`,`GIT_TAG` 钉在 `YYJSON_VERSION` —— **不发包管理器**), 这样两边红绿不同只可能归因到那个开关。 **为什么 macOS 有一格、而 `wsdl` 没有**:`ci-guide.md` §1 给缺 macOS `wsdl` 的理由是 diff --git a/CMakeLists.txt b/CMakeLists.txt index 8db4fc15..19a81a3b 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -190,23 +190,36 @@ option(UVCPP_DB_SQLITE_FROM_SOURCE "Build the SQLite backend from a pinned source amalgamation (static) instead of the system libsqlite3" OFF) option(UVCPP_ENABLE_DB_MYSQL "Build the MySQL backend of the db module (needs libmysqlclient)" ON) option(UVCPP_ENABLE_DB_PGSQL "Build the PostgreSQL backend of the db module (needs libpq)" ON) -# MySQL / PostgreSQL 两个后端**不在链接行上出现**,改成运行时打开客户端库。 +# ODBC 后端是**通用**的:它只要求一套 ODBC 头加一个驱动管理器,具体连什么库由 +# 连接串里的 `Driver=` 决定。接 SQL Server 走的就是这条路 —— 微软没有给 SQL Server +# 一个可链接的 C 客户端库,`msodbcsql18` 本身就是 ODBC 驱动。 +# +# 与另两个后端一样,它也认 `UVCPP_DB_RUNTIME_LOAD`:链上的是**驱动管理器** +# (Linux 的 libodbc、macOS 的 libodbc.dylib、Windows 的 odbc32),而 Linux/macOS +# 上它是一个普通包 —— 没装的机器会栽在那条 `DT_NEEDED libodbc.so.2` 上,与 +# libmysqlclient/libpq 完全同构。Windows 上 `odbc32.dll` 属于操作系统,链接它没有 +# 这个代价,所以这一档主要在 Linux/macOS 的包上说话。真正按需加载的**具体驱动** +# 是另一件事,那件事由驱动管理器自己干,不归我们管。 +option(UVCPP_ENABLE_DB_ODBC + "Build the ODBC backend of the db module (needs ODBC headers + a driver manager; the data source is chosen by the connection string)" ON) +# MySQL / PostgreSQL / ODBC 三个后端**不在链接行上出现**,改成运行时打开客户端库 +# (ODBC 那一份是**驱动管理器**,具体驱动由它自己加载)。 # # 这是给**预编译包**用的:`DT_NEEDED`(Windows 上是导入表)是**加载期**解析的, -# 链上 libmysqlclient/libpq 就等于"没装这两种客户端库的机器上连加载都失败"—— -# 哪怕使用者只跑 HTTP、一行 db 代码都不碰。开了这个开关之后,那种机器上除"真去连 -# MySQL/PG"之外一切照常,而真去连时拿到的是 `uvcpp_db_status::NO_DRIVER` 加一句 -# 写清了试过哪些库名的话(`uvcpp_db_client::last_error()`)。 +# 链上 libmysqlclient/libpq/libodbc 就等于"没装这些客户端库的机器上连加载都失败" +# ——哪怕使用者只跑 HTTP、一行 db 代码都不碰。开了这个开关之后,那种机器上除"真去 +# 连 MySQL/PG/ODBC"之外一切照常,而真去连时拿到的是 `uvcpp_db_status::NO_DRIVER` +# 加一句写清了试过哪些库名的话(`uvcpp_db_client::last_error()`)。 # -# 注意它**只管"符号从哪来"**,不改"后端在不在":仍然要有 `mysql.h`/`libpq-fe.h` -# 才能把后端编进来(头是编译期的东西,运行时加载也省不掉),`UVCPP_DB_*_ENABLE` -# 的取值照旧由头在不在决定。所以这个开关**不会**让一个原本编不出 MySQL 后端的 -# 环境凭空多出这个后端。 +# 注意它**只管"符号从哪来"**,不改"后端在不在":仍然要有 +# `mysql.h`/`libpq-fe.h`/`sql.h` 才能把后端编进来(头是编译期的东西,运行时加载也 +# 省不掉),`UVCPP_DB_*_ENABLE` 的取值照旧由头在不在决定。所以这个开关**不会**让 +# 一个原本编不出某个后端的环境凭空多出这个后端。 # # 与 `UVCPP_DB_SQLITE_FROM_SOURCE` / `UVCPP_BROTLI_FROM_SOURCE`(上面)是同一个 # 形状:名字说的是"这份依赖怎么来"。 option(UVCPP_DB_RUNTIME_LOAD - "Load the MySQL/PostgreSQL client libraries at run time instead of linking them (keeps the artifact free of DT_NEEDED entries for them)" OFF) + "Load the MySQL/PostgreSQL/ODBC client libraries at run time instead of linking them (keeps the artifact free of DT_NEEDED entries for them)" OFF) # 可运行示例(examples/)。默认关:它是给使用者看的,不进库本身。 option(UVCPP_BUILD_EXAMPLES "Build the examples in examples/" OFF) @@ -1217,12 +1230,68 @@ if(UVCPP_ENABLE_DB) endif() endif() - # ---- 三个都关掉 ⇒ 整个模块关掉(见上面那段理由)---- + if(UVCPP_ENABLE_DB_ODBC) + # 只要两样:头(sql.h)与驱动管理器(Linux 的 libodbc / Windows 的 odbc32)。 + # 找的是 `sql.h` 而不是 `sqltypes.h` —— 后者在很多包的残留里都有,前者才代表 + # 一套完整的 ODBC 头。 + find_path(UVCPP_DB_ODBC_INCLUDE_DIR + NAMES sql.h + PATH_SUFFIXES include odbc include/odbc) + if(NOT UVCPP_DB_ODBC_INCLUDE_DIR) + message(WARNING + "UVCPP_ENABLE_DB_ODBC=ON 但找不到 sql.h。已强制关闭这个后端 —— " + "其余后端照编。Debian/Ubuntu 上装 unixodbc-dev,RHEL 系是 unixODBC-devel。") + set(UVCPP_ENABLE_DB_ODBC OFF) + elseif(WIN32) + # Windows 的 ODBC 是系统组件:头在 SDK 里,库就是 odbc32.dll(一定在)。 + # `UVCPP_DB_RUNTIME_LOAD=ON` 时连这条导入库也不要(符号由 `LoadLibrary` 取), + # 差别只是链接行上少一条 —— 这个平台上两种做法都安全。 + list(APPEND UVCPP_DB_INCLUDE_DIRS "${UVCPP_DB_ODBC_INCLUDE_DIR}") + if(UVCPP_DB_RUNTIME_LOAD) + message(STATUS + "db: ODBC 后端开(运行时加载 odbc32.dll,不链导入库;" + "头来自 ${UVCPP_DB_ODBC_INCLUDE_DIR})") + else() + list(APPEND UVCPP_DB_LINK_LIBS odbc32) + message(STATUS "db: ODBC 后端开(Windows 系统 odbc32)") + endif() + else() + # 这里找的是 unixODBC 的 `libodbc`。注意 `libodbc.so` 这个**开发符号链接** + # 是 unixodbc-dev 才给的:只装运行时的 unixodbc 时,`.so.2` 在而 `.so` 不在, + # 于是头与库都找不到 —— 会走到上面那条"找不到 sql.h", + # 而不是在这里报一句让人去猜"为什么装了 unixodbc 还不行"。 + # + # `UVCPP_DB_RUNTIME_LOAD=ON` 时**不找库**(与 MySQL 那段同一条理由:链接行上 + # 多一条 libodbc 正是这个开关要消掉的东西)—— 于是配置期只剩"有没有 sql.h" + # 这一件事,"这台机器没装 unixODBC 运行库"不再等于"编不出这个后端"。 + if(NOT UVCPP_DB_RUNTIME_LOAD) + find_library(UVCPP_DB_ODBC_LIBRARY NAMES odbc) + endif() + if(UVCPP_DB_RUNTIME_LOAD) + list(APPEND UVCPP_DB_INCLUDE_DIRS "${UVCPP_DB_ODBC_INCLUDE_DIR}") + message(STATUS + "db: ODBC 后端开(运行时加载,不链 libodbc;" + "头来自 ${UVCPP_DB_ODBC_INCLUDE_DIR})") + elseif(UVCPP_DB_ODBC_LIBRARY) + list(APPEND UVCPP_DB_INCLUDE_DIRS "${UVCPP_DB_ODBC_INCLUDE_DIR}") + list(APPEND UVCPP_DB_LINK_LIBS "${UVCPP_DB_ODBC_LIBRARY}") + message(STATUS "db: ODBC 后端开(驱动管理器 ${UVCPP_DB_ODBC_LIBRARY})") + else() + message(WARNING + "UVCPP_ENABLE_DB_ODBC=ON 但只找到 sql.h、找不到 libodbc。已强制关闭这个" + "后端 —— 其余后端照编。Debian/Ubuntu 上装 unixodbc-dev(只装 unixodbc " + "运行时不带 .so 符号链接),或者用 -DUVCPP_DB_RUNTIME_LOAD=ON 只要头。") + set(UVCPP_ENABLE_DB_ODBC OFF) + endif() + endif() + endif() + + # ---- 四个都关掉 ⇒ 整个模块关掉(见上面那段理由)---- if(NOT UVCPP_ENABLE_DB_SQLITE AND NOT UVCPP_ENABLE_DB_MYSQL AND - NOT UVCPP_ENABLE_DB_PGSQL) + NOT UVCPP_ENABLE_DB_PGSQL AND NOT UVCPP_ENABLE_DB_ODBC) message(WARNING - "UVCPP_ENABLE_DB=ON 但 SQLite / MySQL / PostgreSQL 三个后端一个都没成 —— " - "这样的库会编出一个谁也打不开的 uvcpp_db_client。已强制关闭 db 模块。") + "UVCPP_ENABLE_DB=ON 但 SQLite / MySQL / PostgreSQL / ODBC 四个后端一个都没" + "成 —— 这样的库会编出一个谁也打不开的 uvcpp_db_client。已强制关闭 db 模块。") set(UVCPP_ENABLE_DB OFF) endif() endif() @@ -1870,11 +1939,11 @@ else() add_compile_definitions(UVCPP_DB_ENABLE=0) message(STATUS "Database module disabled (UVCPP_DB_ENABLE=0)") endif() -# 三个后端宏**不受主开关守卫**:主开关关掉时它们照样按"这个后端编不编"取 0。 +# 四个后端宏**不受主开关守卫**:主开关关掉时它们照样按"这个后端编不编"取 0。 # 写成 `if(UVCPP_ENABLE_DB) ... else 三个都 0` 的话,`uvcpp_db_factory.cpp` 里 # 那句 `#if UVCPP_DB_SQLITE_ENABLE` 在模块关掉时是**未定义标识符**(求值为 0, # 结果碰巧一样)—— 碰巧一样的东西不该靠碰巧。 -foreach(_uvcpp_db_backend SQLITE MYSQL PGSQL) +foreach(_uvcpp_db_backend SQLITE MYSQL PGSQL ODBC) if(UVCPP_ENABLE_DB_${_uvcpp_db_backend}) add_compile_definitions(UVCPP_DB_${_uvcpp_db_backend}_ENABLE=1) else() @@ -2312,6 +2381,7 @@ _uvcpp_literal01(UVCPP_DB_ENABLE UVCPP_ENABLE_DB) _uvcpp_literal01(UVCPP_DB_SQLITE_ENABLE UVCPP_ENABLE_DB_SQLITE) _uvcpp_literal01(UVCPP_DB_MYSQL_ENABLE UVCPP_ENABLE_DB_MYSQL) _uvcpp_literal01(UVCPP_DB_PGSQL_ENABLE UVCPP_ENABLE_DB_PGSQL) +_uvcpp_literal01(UVCPP_DB_ODBC_ENABLE UVCPP_ENABLE_DB_ODBC) # 名字两边**一样**,这是这份文件里唯一的一处(别的都是 `UVCPP_ENABLE_X` 对 # `UVCPP_X_ENABLE`)。理由:它不属于那两个族里的任何一个 —— 既不是"开关某个 # 模块"(`UVCPP_ENABLE_*`),也不只是"这份依赖怎么来"(`*_FROM_SOURCE`,那两个 @@ -2389,6 +2459,7 @@ foreach (_uvcpp_t uvcpp uvcpp_a uvcpp_s uvcpp_a_s) UVCPP_DB_SQLITE_ENABLE=${UVCPP_DB_SQLITE_ENABLE} UVCPP_DB_MYSQL_ENABLE=${UVCPP_DB_MYSQL_ENABLE} UVCPP_DB_PGSQL_ENABLE=${UVCPP_DB_PGSQL_ENABLE} + UVCPP_DB_ODBC_ENABLE=${UVCPP_DB_ODBC_ENABLE} UVCPP_DB_RUNTIME_LOAD=${UVCPP_DB_RUNTIME_LOAD} UVCPP_TRY_WRITE_ENABLE=${UVCPP_TRY_WRITE_ENABLE} UVCPP_TRY_WRITE_MIN_BYTES=${UVCPP_TRY_WRITE_MIN_BYTES} @@ -3110,9 +3181,10 @@ endif() # 它一旦装出去,"驱动接口可以随便改"这句话就不成立了。三个后端的 .cpp 与 # `uvcpp_db_factory.cpp` 都带它,所以它必须留在源树里。 # -# 另外三份也是私有的(`UVCPP_DB_RUNTIME_LOAD` 那套): -# * `uvcpp_db_mysql_api.h` / `uvcpp_db_pgsql_api.h` —— 函数指针表,就是后端 -# 的内部 ABI;它们还各自 `#include ` / ``, +# 另外四份也是私有的(`UVCPP_DB_RUNTIME_LOAD` 那套): +# * `uvcpp_db_mysql_api.h` / `uvcpp_db_pgsql_api.h` / `uvcpp_db_odbc_api.h` +# —— 函数指针表,就是后端的内部 ABI;它们还各自 +# `#include ` / `` / ``, # 而**公开面一个客户端库的类型都不出现**(`uvcpp_db_client.h` 只带自己的 # 头),装出去等于让使用者以为那份客户端头也是我们的依赖。 # * `uvcpp_db_runtime_load.h` —— `dlopen`/`dlsym` 的两个原语,纯内部。 @@ -3124,6 +3196,7 @@ file(GLOB DB_HEADER_FILES "${CMAKE_SOURCE_DIR}/src/db/*.h") list(FILTER DB_HEADER_FILES EXCLUDE REGEX "uvcpp_db_driver\\.h$") list(FILTER DB_HEADER_FILES EXCLUDE REGEX "uvcpp_db_mysql_api\\.h$") list(FILTER DB_HEADER_FILES EXCLUDE REGEX "uvcpp_db_pgsql_api\\.h$") +list(FILTER DB_HEADER_FILES EXCLUDE REGEX "uvcpp_db_odbc_api\\.h$") list(FILTER DB_HEADER_FILES EXCLUDE REGEX "uvcpp_db_runtime_load\\.h$") if(DB_HEADER_FILES AND UVCPP_ENABLE_DB) install(FILES ${DB_HEADER_FILES} DESTINATION include/db) diff --git a/README.md b/README.md index 4373e96a..5135224a 100644 --- a/README.md +++ b/README.md @@ -310,12 +310,13 @@ OpenSSL ≥ 3.2 with the QUIC API for QUIC and HTTP/3), and how the switches int | `UVCPP_ENABLE_BROTLI` | `OFF` | Enable the **brotli body compressor** — `br` as a second `Content-Encoding` for HTTP response bodies next to gzip/deflate, picked by the same `Accept-Encoding` negotiation (`br` wins on `q`-ties with gzip because it is the smaller of the two; `*` and the zlib-less build are covered too). **What it buys is bytes on the wire, not speed**: at the default quality the same body is ~11 % smaller than gzip level 6, and because the compressed variant is served out of the response cache the per-request cost is the same as gzip's — an entry that compresses without a cache tag pays for the encode every request. The default quality is `5` (brotli's own default), deliberately not `0`. **Turned off, nothing changes** (no `br` in `Accept-Encoding` negotiation, and the exported symbol set / `DT_NEEDED` of the library are unchanged). brotli is a **private** dependency, found via pkg-config/`find_library` when the system has it and built from source via `FetchContent` (tag pinned to `BROTLI_VERSION`) otherwise — **two** static libraries, so a static-library user links `-lbrotlienc -lbrotlidec` themselves. **As of 1.7.0 it is in the release package** — every release leg passes `UVCPP_BROTLI_FROM_SOURCE=ON` so the package stays self-contained — and five CI jobs really build it: the Ubuntu `brotli` + `brotli-no-zlib`, macOS and MSVC matrix entries plus the single `ci-mingw64.yml` job. See [`doc/web-http-guide.md`](doc/web-http-guide.md) | | `UVCPP_BROTLI_FROM_SOURCE` | `OFF` | Skip the system lookup entirely (`find_package(PkgConfig)` → `find_path`/`find_library`) and build brotli from the source pinned by `BROTLI_VERSION`, linking it statically. **The lookup runs system-first**, so on a machine that happens to have `libbrotli-dev` the "links the static `.a`" promise silently turns into three `DT_NEEDED` entries (`libbrotlienc` / `libbrotlidec` / `libbrotlicommon`) — and nothing turns red, because the release workflow's `ldd` blacklist does not name brotli. Any release leg that enables brotli passes this so its package stays self-contained. The discovery block is skipped *without being called* (its results land in the CMake cache, so calling it once would leak the previous answer into the next configure of the same tree). Default `OFF` leaves today's behaviour byte-for-byte. See [`doc/web-http-guide.md`](doc/web-http-guide.md) | | `UVCPP_ENABLE_CAPI` | `OFF` | Export the **C ABI** (`src/capi/`, C99 headers for C#/P-Invoke and any other FFI) out of the same `uvcpp` library — no extra artifact. Requires `UVCPP_BUILD_NET=ON` and `UVCPP_BUILD_WEB=ON`; force-disabled without them (the C surface spans net/web/webapp). **Turned on for every release configuration.** See [`doc/capi-guide.md`](doc/capi-guide.md) | -| `UVCPP_ENABLE_DB` | `OFF` | Enable the **database module** (`src/db/`): one `uvcpp_db_client` per connection over SQLite / MySQL / PostgreSQL, with table-style access to the result rows. It is the only module that **requires a third-party client library** (libsqlite3 / libmysqlclient / libpq) — force-disabled when none of the three is found. **Every release configuration turns it on, with all three backends**: SQLite is compiled in from source, and MySQL/PostgreSQL are in the package but loaded at run time (next rows). See [`doc/db-guide.md`](doc/db-guide.md) | +| `UVCPP_ENABLE_DB` | `OFF` | Enable the **database module** (`src/db/`): one `uvcpp_db_client` per connection over SQLite / MySQL / PostgreSQL / ODBC, with table-style access to the result rows. It is the only module that **requires a third-party client library** (libsqlite3 / libmysqlclient / libpq / an ODBC driver manager) — force-disabled when none of the four is found. **Every release configuration turns it on, with all three client-library backends**: SQLite is compiled in from source, and MySQL/PostgreSQL are in the package but loaded at run time (next rows); whether the ODBC backend is in a given package depends on whether that build machine had `sql.h` (next-next row). See [`doc/db-guide.md`](doc/db-guide.md) | | `UVCPP_ENABLE_DB_SQLITE` | `ON` | Build the SQLite backend of the db module (needs `sqlite3.h` + libsqlite3). Not found ⇒ **this one backend is force-disabled with a warning**; the others still build | | `UVCPP_DB_SQLITE_FROM_SOURCE` | `OFF` | Build that SQLite backend from a **hash-pinned source amalgamation** (one static `sqlite3.c`, forced PIC) instead of `find_package(SQLite3)`. **The six release legs use it**: linking the system `libsqlite3.so` would add a `DT_NEEDED` and break the packages' self-contained promise, while the system `libsqlite3.a` on Ubuntu 24.04 is not PIC (verified: `R_X86_64_PC32 … can not be used when making a shared object`). Needs network at configure time, or the zip placed by hand; both are described in [`doc/db-guide.md`](doc/db-guide.md) | | `UVCPP_ENABLE_DB_MYSQL` | `ON` | Build the MySQL backend (needs `mysql.h` + libmysqlclient). Non-standard installs: `-DCMAKE_PREFIX_PATH=…` or `-DUVCPP_DB_MYSQL_INCLUDE_DIR=… -DUVCPP_DB_MYSQL_LIBRARY=…`. **On by default, and on in every release package** (with `UVCPP_DB_RUNTIME_LOAD=ON`, which is what keeps the package loadable without libmysqlclient) | | `UVCPP_ENABLE_DB_PGSQL` | `ON` | Build the PostgreSQL backend (needs `libpq-fe.h` + libpq). Non-standard installs: `-DCMAKE_PREFIX_PATH=…` or `-DPostgreSQL_INCLUDE_DIR=… -DPostgreSQL_LIBRARY=…`. **On by default, and on in every release package** (with `UVCPP_DB_RUNTIME_LOAD=ON`) | -| `UVCPP_DB_RUNTIME_LOAD` | `OFF` | Take the MySQL / PostgreSQL symbols from a client library loaded **at run time** (`dlopen` / `LoadLibrary`) instead of linking it. **The six release legs use it, and the reason is the packages' users, not the library**: `DT_NEEDED` (the PE import table on Windows) is resolved at **load time**, so linking libmysqlclient/libpq means a machine that has never installed either cannot even *load* the library — even if it only ever runs HTTP and never touches `db`. With this on, such a machine runs everything except "actually connecting to MySQL/PG", where it gets `uvcpp_db_status::NO_DRIVER` plus a message naming the candidates that were tried (`UVCPP_DB_MYSQL_LIBRARY` / `UVCPP_DB_PGSQL_LIBRARY` override the search). It changes only *where the symbols come from*, not *which backends exist*: `mysql.h` / `libpq-fe.h` are still needed at configure time (`MYSQL_BIND`'s layout has to come from the real header), so `UVCPP_DB_MYSQL_ENABLE` / `UVCPP_DB_PGSQL_ENABLE` still mean "the header was found". Default `OFF` leaves today's behaviour byte-for-byte. See [`doc/db-guide.md`](doc/db-guide.md) | +| `UVCPP_ENABLE_DB_ODBC` | `ON` | Build the ODBC backend of the db module (needs `sql.h` + a driver manager: `libodbc` on Linux, the system `odbc32` on Windows). Unlike the three above, it targets **an interface, not one database** — *what* it connects to is chosen by the connection string's `Driver=` / `DSN=` (Microsoft ships no linkable C client library for SQL Server; `msodbcsql18` *is* an ODBC driver), and it tries no other driver name. Not found ⇒ **this one backend is force-disabled with a warning**; the others still build. It honours `UVCPP_DB_RUNTIME_LOAD` like MySQL/PG: what gets linked is the **driver manager**, which is a client library of the same kind (a plain unixODBC package on Linux/macOS; on Windows `odbc32.dll` belongs to the OS, so linking it costs nothing). So "is this backend built" and "is there a driver for the data source you open" are two different questions — the second is answered at `open()`, in the driver manager's own words | +| `UVCPP_DB_RUNTIME_LOAD` | `OFF` | Take the MySQL / PostgreSQL / ODBC symbols from a client library loaded **at run time** (`dlopen` / `LoadLibrary`) instead of linking it; for ODBC that library is the **driver manager** (the actual driver is still loaded by the manager itself). **The six release legs use it, and the reason is the packages' users, not the library**: `DT_NEEDED` (the PE import table on Windows) is resolved at **load time**, so linking libmysqlclient/libpq/libodbc means a machine that has never installed those libraries cannot even *load* the library — even if it only ever runs HTTP and never touches `db`. With this on, such a machine runs everything except "actually connecting to MySQL/PG/ODBC", where it gets `uvcpp_db_status::NO_DRIVER` plus a message naming the candidates that were tried (`UVCPP_DB_MYSQL_LIBRARY` / `UVCPP_DB_PGSQL_LIBRARY` / `UVCPP_DB_ODBC_LIBRARY` override the search). It changes only *where the symbols come from*, not *which backends exist*: `mysql.h` / `libpq-fe.h` / `sql.h` are still needed at configure time (`MYSQL_BIND`'s layout has to come from the real header), so `UVCPP_DB_MYSQL_ENABLE` / `UVCPP_DB_PGSQL_ENABLE` / `UVCPP_DB_ODBC_ENABLE` still mean "the header was found". Default `OFF` leaves today's behaviour byte-for-byte. See [`doc/db-guide.md`](doc/db-guide.md) | | `UVCPP_USE_SYSTEM_LIBUV` | `ON` | Prefer system-installed libuv | | `UVCPP_BUILD_LIBUV_FROM_SOURCE` | `OFF` | Fetch and build libuv from source via `FetchContent` | | `UVCPP_STATIC_RUNTIME` | `OFF` | Statically link the compiler runtime (`libgcc`/`libstdc++`) into the library. **MinGW and Linux only — a no-op on MSVC**, which uses `/MD` and ships `vcruntime`/`msvcp` in the package. **On Linux it also applies to every executable, and a *shared* `libuvcpp.so` built with it cannot be combined with a consumer that uses the system `libstdc++.so.6`** — both copies instantiate the same COMDAT templates (`std::once_flag`'s `__once_proxy`, `std::__future_base::_State_baseV2`) and ELF's global symbol scope makes one side jump into the other's frame, so the failure is a run-time segfault or `double free`, **not a link error**. **That is why the release workflow's two Linux legs no longer pass it** (1.7.0 onwards) — the Linux packages therefore need the system `libstdc++.so.6` / `libgcc_s.so.1`, which every distro with a C++ runtime already has. The two MinGW legs still pass it: PE has no global symbol scope, so there is nothing to interpose, and a MinGW user's machine may not have `libstdc++-6.dll`. See [`doc/build-guide.md`](doc/build-guide.md) | diff --git a/README.zh.md b/README.zh.md index 983ca5f1..ecab4357 100644 --- a/README.zh.md +++ b/README.zh.md @@ -302,12 +302,13 @@ WebSocket,`-DUVCPP_ENABLE_OPENSSL=ON` 是 HTTPS,`-DUVCPP_BUILD_WEBAPP=ON` | `UVCPP_ENABLE_BROTLI` | `OFF` | 启用 **brotli 响应体编码器** —— 除 gzip/deflate 之外多一种 `Content-Encoding: br`,由同一套 `Accept-Encoding` 协商选出来(`q` 相同时 br 胜出,因为它更小;`*` 与"没编 zlib"的那种构建也各有断言)。**它换到的是线上字节数,不是速度**:默认质量下同一份响应体比 gzip 6 档小约 11 %,而压缩变体是从响应缓存里取的,所以每请求代价与 gzip 相同 —— 不填 cache tag 的条目会每请求重压一遍。默认质量是 `5`(brotli 自己的默认值),**故意不是 `0`**。**关掉时逐字不变**(协商里没有 `br`;库的导出符号集与 `DT_NEEDED` 都不动)。brotli 是**私有**依赖:系统里有就走 pkg-config/`find_library`,没有就走 `FetchContent` 取源码(tag 钉在 `BROTLI_VERSION`)—— **两个**静态库,所以静态库使用者要自己链 `-lbrotlienc -lbrotlidec`。**1.7.0 起它进了发布包** —— 每条发布腿都传 `UVCPP_BROTLI_FROM_SOURCE=ON`,产物才自包含 —— 另有五个 CI 作业真的开着它:Ubuntu 的 `brotli` 与 `brotli-no-zlib`、macOS、MSVC 四个矩阵条目,外加 `ci-mingw64.yml` 那条单 job。见 [`doc/web-http-guide.md`](doc/web-http-guide.md) | | `UVCPP_BROTLI_FROM_SOURCE` | `OFF` | 跳过整个系统查找(`find_package(PkgConfig)` → `find_path`/`find_library`),直接用 `BROTLI_VERSION` 钉住的那份源码编 brotli,静态链进来。**查找顺序是先系统后源码**,所以在碰巧装了 `libbrotli-dev` 的机器上,"链的是静态 `.a`"会**静默**变成三条 `DT_NEEDED`(`libbrotlienc` / `libbrotlidec` / `libbrotlicommon`)—— 而且不会有任何断言变红,因为发布流程那条 `ldd` 黑名单里并没有 brotli。凡是打开 brotli 的发布腿都传它,产物才自包含。跳过是**连发现都不调用**(`find_*` 的结果会写进 CMake cache,调用过一次就会把上一轮的答案漏进同一棵树的下一轮配置)。默认 `OFF`,今天的行为逐字节不变。见 [`doc/web-http-guide.md`](doc/web-http-guide.md) | | `UVCPP_ENABLE_CAPI` | `OFF` | 导出 **C ABI**(`src/capi/`,C99 头,给 C#/P-Invoke 以及别的 FFI 用),编进**同一个** `uvcpp` 库 —— 不多一个产物。需要 `UVCPP_BUILD_NET=ON` 与 `UVCPP_BUILD_WEB=ON`,缺一即强制关闭(C 面横跨 net/web/webapp)。**每一个发布配置都打开它。** 见 [`doc/capi-guide.md`](doc/capi-guide.md) | -| `UVCPP_ENABLE_DB` | `OFF` | 启用**数据库模块**(`src/db/`):一个连接一个 `uvcpp_db_client`,底下是 SQLite / MySQL / PostgreSQL 三个后端,结果行按表的方式取。它是全仓**唯一一个必需第三方客户端库**(libsqlite3 / libmysqlclient / libpq)的模块 —— 三个都找不到就强制关闭。**每一个发布配置都打开它,而且三个后端都在**:SQLite 从源码编进来,MySQL/PostgreSQL 也在包里、但走运行时加载(见下面两行)。见 [`doc/db-guide.md`](doc/db-guide.md) | +| `UVCPP_ENABLE_DB` | `OFF` | 启用**数据库模块**(`src/db/`):一个连接一个 `uvcpp_db_client`,底下是 SQLite / MySQL / PostgreSQL / ODBC 四个后端,结果行按表的方式取。它是全仓**唯一一个必需第三方客户端库**(libsqlite3 / libmysqlclient / libpq / 一份 ODBC 驱动管理器)的模块 —— 四个都找不到就强制关闭。**每一个发布配置都打开它,而且三个客户端库后端都在**:SQLite 从源码编进来,MySQL/PostgreSQL 也在包里、但走运行时加载(见下面两行);ODBC 那个在不在包里,取决于出包那台机器有没有 `sql.h`(见再下面一行)。见 [`doc/db-guide.md`](doc/db-guide.md) | | `UVCPP_ENABLE_DB_SQLITE` | `ON` | 编 db 模块的 SQLite 后端(要 `sqlite3.h` 与 libsqlite3)。找不到时**只强制关掉这一个**并出声,其余后端照编 | | `UVCPP_DB_SQLITE_FROM_SOURCE` | `OFF` | 这个 SQLite 后端改成用一份**钉死哈希的源码 amalgamation** 自己编(一个静态 `sqlite3.c`,强制 PIC),不走 `find_package(SQLite3)`。**六条发布腿走的就是它**:链系统的 `libsqlite3.so` 会多一条 `DT_NEEDED`、破坏发布包的"自包含"承诺,而 Ubuntu 24.04 上系统的 `libsqlite3.a` 不是 PIC(实测 `R_X86_64_PC32 … can not be used when making a shared object`)。配置期需要联网,或者自己把 zip 放到位 —— 两种做法都写在 [`doc/db-guide.md`](doc/db-guide.md) 里 | | `UVCPP_ENABLE_DB_MYSQL` | `ON` | 编 MySQL 后端(要 `mysql.h` 与 libmysqlclient)。装在非标准位置时:`-DCMAKE_PREFIX_PATH=…` 或 `-DUVCPP_DB_MYSQL_INCLUDE_DIR=… -DUVCPP_DB_MYSQL_LIBRARY=…`。**默认就是开的,每个发布包里也都开着**(同时用 `UVCPP_DB_RUNTIME_LOAD=ON`,这正是"没装 libmysqlclient 也能加载这份包"的原因) | | `UVCPP_ENABLE_DB_PGSQL` | `ON` | 编 PostgreSQL 后端(要 `libpq-fe.h` 与 libpq)。装在非标准位置时:`-DCMAKE_PREFIX_PATH=…` 或 `-DPostgreSQL_INCLUDE_DIR=… -DPostgreSQL_LIBRARY=…`。**默认就是开的,每个发布包里也都开着**(同时用 `UVCPP_DB_RUNTIME_LOAD=ON`) | -| `UVCPP_DB_RUNTIME_LOAD` | `OFF` | MySQL / PostgreSQL 的符号不从链接行上来,改成**运行时**打开一份客户端库(`dlopen` / `LoadLibrary`)再取。**六条发布腿用的就是它,而理由在包的**使用者**身上,不在库里**:`DT_NEEDED`(Windows 上是导入表)是**加载期**解析的,链上 libmysqlclient/libpq 就等于"从没装过这两种客户端库的机器连**加载**这份库都做不到"—— 哪怕它只跑 HTTP、一行 `db` 代码都不碰。开了之后,那种机器上除"真去连 MySQL/PG"之外一切照常,而真去连时拿到的是 `uvcpp_db_status::NO_DRIVER` 加一句写清了试过哪些库名的话(可用环境变量 `UVCPP_DB_MYSQL_LIBRARY` / `UVCPP_DB_PGSQL_LIBRARY` 指定路径)。它改的只是**符号从哪来**,不是**有哪些后端**:配置期仍然要 `mysql.h` / `libpq-fe.h`(`MYSQL_BIND` 的布局必须从真的头里读),所以 `UVCPP_DB_MYSQL_ENABLE` / `UVCPP_DB_PGSQL_ENABLE` 的含义照旧是"头找到了"。默认 `OFF`,今天的行为逐字节不变。见 [`doc/db-guide.md`](doc/db-guide.md) | +| `UVCPP_ENABLE_DB_ODBC` | `ON` | 编 db 模块的 ODBC 后端(要 `sql.h` 与一份驱动管理器:Linux 上是 `libodbc`,Windows 上是系统的 `odbc32`)。它与上面三个不是一类:那三个各对着**一种**数据库,这个对着**一个接口** —— 连什么库由连接串里的 `Driver=` / `DSN=` 决定(微软没给 SQL Server 可链接的 C 客户端库,`msodbcsql18` 本身就是 ODBC 驱动),而且它不去试别的驱动名。找不到时**只强制关掉这一个**并出声,其余后端照编。它与 MySQL/PG 一样认 `UVCPP_DB_RUNTIME_LOAD`:链上的是**驱动管理器**,那也是"客户端库"那一类(Linux / macOS 上是普通包 unixODBC;Windows 上 `odbc32.dll` 属于操作系统,链接它不算代价)。于是"这个后端编没编"与"你要连的数据源有没有驱动"是两件事 —— 后者到 `open()` 才有答案,用的是驱动管理器的原话。见 [`doc/db-guide.md`](doc/db-guide.md) | +| `UVCPP_DB_RUNTIME_LOAD` | `OFF` | MySQL / PostgreSQL / ODBC 的符号不从链接行上来,改成**运行时**打开一份客户端库(`dlopen` / `LoadLibrary`)再取;ODBC 那一份打开的是**驱动管理器**,具体驱动仍由它自己加载。**六条发布腿用的就是它,而理由在包的**使用者**身上,不在库里**:`DT_NEEDED`(Windows 上是导入表)是**加载期**解析的,链上 libmysqlclient/libpq/libodbc 就等于"从没装过这些客户端库的机器连**加载**这份库都做不到"—— 哪怕它只跑 HTTP、一行 `db` 代码都不碰。开了之后,那种机器上除"真去连 MySQL/PG/ODBC"之外一切照常,而真去连时拿到的是 `uvcpp_db_status::NO_DRIVER` 加一句写清了试过哪些库名的话(可用环境变量 `UVCPP_DB_MYSQL_LIBRARY` / `UVCPP_DB_PGSQL_LIBRARY` / `UVCPP_DB_ODBC_LIBRARY` 指定路径)。它改的只是**符号从哪来**,不是**有哪些后端**:配置期仍然要 `mysql.h` / `libpq-fe.h` / `sql.h`(`MYSQL_BIND` 的布局必须从真的头里读),所以 `UVCPP_DB_MYSQL_ENABLE` / `UVCPP_DB_PGSQL_ENABLE` / `UVCPP_DB_ODBC_ENABLE` 的含义照旧是"头找到了"。默认 `OFF`,今天的行为逐字节不变。见 [`doc/db-guide.md`](doc/db-guide.md) | | `UVCPP_USE_SYSTEM_LIBUV` | `ON` | 优先使用系统安装的 libuv | | `UVCPP_BUILD_LIBUV_FROM_SOURCE` | `OFF` | 用 `FetchContent` 拉取并源码构建 libuv | | `UVCPP_STATIC_RUNTIME` | `OFF` | 把编译器运行时(`libgcc`/`libstdc++`)静态链进库。**仅 MinGW 与 Linux 有效,MSVC 上是空操作** —— MSVC 用 `/MD`,发布包里自带 `vcruntime`/`msvcp`。**在 Linux 上它还同时作用于每个可执行文件,而带它的*共享* `libuvcpp.so` 与"用系统 `libstdc++.so.6` 的使用者"是互斥的** —— 两份 libstdc++ 各自实例化同一批 COMDAT 模板(`std::once_flag` 的 `__once_proxy`、`std::__future_base::_State_baseV2`),而 ELF 的全局符号作用域让一侧跳进另一侧的栈帧,于是失败形状是运行期段错误或 `double free`,**不是链接错误**。**这就是发布流程的两条 Linux 腿都不再传它的原因**(1.7.0 起)—— Linux 包因此需要系统里的 `libstdc++.so.6` / `libgcc_s.so.1`,任何带 C++ 运行时的发行版本来就有。两条 MinGW 腿照旧传:PE 没有全局符号作用域,不存在互相顶掉,而 MinGW 用户机器上未必有 `libstdc++-6.dll`。见 [`doc/build-guide.md`](doc/build-guide.md) | diff --git a/RELEASE.md b/RELEASE.md index 8a388c7b..cd60a195 100644 --- a/RELEASE.md +++ b/RELEASE.md @@ -1146,11 +1146,11 @@ g++ -std=c++11 -g -I include your_app.cpp -L lib -luvcppd -o your_app.exe 引入,它登记的 install 规则引用了一个从未构建的 `libuv.dll`,且它的规则排在 本项目的规则之前 —— 一失败就整体中止,本项目的头文件与库一个都装不出来 (Linux 上没有这个 `.dll`,同一条 install 实测走得通,2026-10-12 验过)。 - 本次的 zip 绕过它、照 `CMakeLists.txt:2870-3176` 的规则手工组装,与 install + 本次的 zip 绕过它、照 `CMakeLists.txt:2941-3249` 的规则手工组装,与 install 只剩**一处**刻意的差异:**libuv 的头放在 `include/` 顶层**(本库的公开头写的是 `#include `,放进 `include/libuv/` 会找不到)。原先还有第二处 —— zip 里 补了 `zlib.h` / `zconf.h`,而 install 规则里没有这一条 —— **2026-10-12 起 - install 规则自己也装这两个头**(`CMakeLists.txt:3156-3159`),两边落到同一个 + install 规则自己也装这两个头**(`CMakeLists.txt:3229-3232`),两边落到同一个 位置,这处差异消失。 ### MSVC 从源码树 / 安装树取头时要加 `/utf-8` —— 头文件侧已修(1.2.0) diff --git a/cmake/uvcpp_config.h.in b/cmake/uvcpp_config.h.in index 550a66ae..dbdad204 100644 --- a/cmake/uvcpp_config.h.in +++ b/cmake/uvcpp_config.h.in @@ -155,6 +155,18 @@ # define UVCPP_DB_PGSQL_ENABLE @UVCPP_DB_PGSQL_ENABLE@ #endif +/* ODBC 后端。注意它与上面三个不是一类:SQLite/MySQL/PostgreSQL 各对着**一种** + 数据库,而这个对着**一个接口** —— 连什么库由连接串里的 `Driver=`(或 `DSN=`) + 决定,本驱动不去试别的驱动名。所以"这个后端在不在"与"要连的那个数据源有没有 + 驱动"是两件事:后者要到 `open()` 才知道,报错是驱动管理器给的原文(驱动名写错 + 时就是它那句"找不到数据源、也没有默认驱动")。 */ +#if defined(UVCPP_DB_ODBC_ENABLE) && (UVCPP_DB_ODBC_ENABLE != @UVCPP_DB_ODBC_ENABLE@) +# error "UVCPP_DB_ODBC_ENABLE=@UVCPP_DB_ODBC_ENABLE@ in this build; remove your own definition of it" +#endif +#ifndef UVCPP_DB_ODBC_ENABLE +# define UVCPP_DB_ODBC_ENABLE @UVCPP_DB_ODBC_ENABLE@ +#endif + /* MySQL / PostgreSQL 两个后端的符号是**链接进来的**还是**运行时取**的。 它是"这份产物长什么样",不是"有哪些模块",但同样必须让使用者看得见:这两条 路的**失败方式**不一样(链接的那条在加载期就报缺库、进程起不来;运行时加载的 diff --git a/doc/ci-guide.md b/doc/ci-guide.md index 9bd81a35..aae80520 100644 --- a/doc/ci-guide.md +++ b/doc/ci-guide.md @@ -278,7 +278,7 @@ removed on 2026-09-17 after measuring them instead of trusting the label: | `test_memory_pool` | "Pre-existing hang (multi-thread pool alloc on Windows)" | 0 failures, ≤1 s per run | Both had been exclusions for defects fixed long before. `test_memory_pool`'s is -documented: it was a missing-DLL-copy bug (see `CMakeLists.txt:3217-3217`), fixed and +documented: it was a missing-DLL-copy bug (see `CMakeLists.txt:3290-3290`), fixed and left in the exclude list anyway. `test_tcp_func`'s dual-loop teardown is most likely the `~uvcpp_tcp_server` fix, which is what removed the two `sleep_for` calls that were joining the worker thread — that is an inference from the @@ -454,7 +454,7 @@ MSVC. MinGW and macOS are the gap (see §1). entry at all and shipped two classes of defect (#35). - **It is `web` plus one switch**, exactly as `zlib-off` is `web` minus one, and its dep list is **byte-identical to `web`'s**. That is deliberate: `pugixml` is pulled by `FetchContent` - (`CMakeLists.txt:1667`, `GIT_TAG` pinned to `PUGIXML_VERSION`), not by a package manager, so + (`CMakeLists.txt:1736`, `GIT_TAG` pinned to `PUGIXML_VERSION`), not by a package manager, so there is nothing to install — and keeping the package set identical means a red/green difference between the two legs can only be attributed to the switch itself. - **All four gates are load-bearing here.** Gate ② is `pugixml integrated` (the FetchContent @@ -501,7 +501,7 @@ table lost its `MinGW + yyjson` row rather than gaining a row here). compiles this header with **libc++**, whose `double` formatting is not libstdc++'s. The byte-identical write-back assertion is precisely the kind that differs across those two. - **`flags` is `web` plus one switch, and the dep list is byte-identical to `web`'s on all - three legs.** `yyjson` is pulled by `FetchContent` (`CMakeLists.txt:1727`, `GIT_TAG` pinned + three legs.** `yyjson` is pulled by `FetchContent` (`CMakeLists.txt:1796`, `GIT_TAG` pinned to `YYJSON_VERSION`) and built as an OBJECT library, not found by a package manager, so there is nothing to install — and keeping the package set identical means a red/green difference can only be attributed to the switch. diff --git a/doc/db-guide.md b/doc/db-guide.md index 23e6785a..c1fcb87d 100644 --- a/doc/db-guide.md +++ b/doc/db-guide.md @@ -1,8 +1,10 @@ # 数据库模块指南 `src/db/` 是**库外的一层薄封装**:一个连接 = 一个 `uvcpp_db_client`,底下挂 -SQLite / MySQL / PostgreSQL 三个后端,SQL 由调用方写,结果按**表**取(列名 → -值)。它不参与事件循环,也不是 ORM —— 没有模型、没有迁移、没有关系映射。 +SQLite / MySQL / PostgreSQL / ODBC 四个后端,SQL 由调用方写,结果按**表**取 +(列名 → 值)。前三个各对着**一种**数据库,第四个对着的是一个**接口** —— 连什么库由 +连接串里的驱动名决定(见 [§1](#1-怎么开) 的「ODBC:驱动管理器与**具体驱动**」)。 +它不参与事件循环,也不是 ORM —— 没有模型、没有迁移、没有关系映射。 (建在它上面、替你把 `uv_queue_work` 那段样板写掉的 `uvcpp_db_async` 与 `uvcpp_db_pool` 才碰循环,见 [§3](#3-公开面);**底座本身**不认识 `uvcpp_loop`。) @@ -10,12 +12,19 @@ SQLite / MySQL / PostgreSQL 三个后端,SQL 由调用方写,结果按**表* > > **同步的底座**(异步门面与连接池建在它上面)、**一个 client 一个连接**、 > **不自动重连**、**返回码不抛异常**, -> 把三个后端在"同一条 SQL 该得到同一个结果"这件事上钉到一套契约里, -> 并把三家**真不一样**的地方如实写在这里,而不是假装它们一样。 +> 把 SQLite / MySQL / PostgreSQL 三个后端在"同一条 SQL 该得到同一个结果"这件事上 +> 钉到一套契约里(第四个后端 ODBC 也进了这套套件,但它的方言按数据源分派 —— +> [§6](#6-三个后端钉住的一致与如实列出的三处不同) 写了它偏在哪、 +> [§11](#11-没做的如实列出) 写了哪一族还没跑过),并把各家**真不一样**的地方如实 +> 写在这里,而不是假装它们一样。 > > 它默认**关**(`UVCPP_ENABLE_DB=OFF`):它是全仓唯一一个必需第三方客户端库的 -> 模块。发布配置**显式打开**它,所以预编译包里带着这个模块 —— 而且**三个后端都在** -> (1.7.0 起;SQLite 从源码编,MySQL/PG 编进来但库走运行时加载,见 §1 与 §3)。 +> 模块。发布配置**显式打开**它,所以预编译包里带着这个模块 —— `1.7.0` 那个包里 +> SQLite / MySQL / PostgreSQL **三个都在**(SQLite 从源码编,MySQL/PG 编进来但库走 +> 运行时加载,见 §1 与 §3)。ODBC 是第四个后端,`1.7.2-dev` 起六条发布腿也**显式 +> 打开**它(**还没有哪个已发布的包带过它** —— `1.7.1` 这个号没发过);它和 MySQL/PG +> 走同一条运行时加载(链上的是**驱动管理器**,也是"客户端库"那一类),所以带着它 +> 的包在没装驱动管理器的机器上照样起得来,只是真去连时拿到 `NO_DRIVER`。 ## 目录 @@ -47,15 +56,16 @@ cmake --build build-db -j | `UVCPP_DB_SQLITE_FROM_SOURCE` | `OFF` | 上面那个 SQLite 后端改用**钉死哈希的源码 amalgamation**自己编,不走 `find_package`。**六条发布腿都是这个**(见下一节) | | `UVCPP_ENABLE_DB_MYSQL` | `ON` | 编 MySQL 后端(要 `mysql.h` + libmysqlclient) | | `UVCPP_ENABLE_DB_PGSQL` | `ON` | 编 PostgreSQL 后端(要 `libpq-fe.h` + libpq) | -| `UVCPP_DB_RUNTIME_LOAD` | `OFF` | MySQL/PG 的**客户端库**改成运行时加载(`dlopen`/`LoadLibrary`),只是**不在链接行上出现** —— 头还是要(见下面「运行时加载」一节) | +| `UVCPP_ENABLE_DB_ODBC` | `ON` | 编 ODBC 后端(要 `sql.h` + 驱动管理器)。**连什么库不在这里定** —— 那是连接串里 `Driver=` 的事(见下面「ODBC:驱动管理器与**具体驱动**」) | +| `UVCPP_DB_RUNTIME_LOAD` | `OFF` | MySQL/PG/ODBC 的**客户端库**改成运行时加载(`dlopen`/`LoadLibrary`),只是**不在链接行上出现** —— 头还是要(见下面「运行时加载」一节)。ODBC 那一份加载的是**驱动管理器**,具体驱动仍由它自己加载 | -三个子开关的语义是**"找得到就编、找不到就降级并出声"**,不是"我要不要用它": +四个子开关的语义是**"找得到就编、找不到就降级并出声"**,不是"我要不要用它": 想明确裁掉哪一个才写 `=OFF`(那时连"找不到"的警告都不会有)。 降级链是**两级**,两级都是 `message(WARNING)` + 普通变量 `set(... OFF)`: 1. 某一个后端找不到 → 关掉**那一个**,其余照编; -2. 三个都关掉了 → 关掉**整个模块** —— 那样的库能编出一个 `uvcpp_db_client`, +2. 四个都关掉了 → 关掉**整个模块** —— 那样的库能编出一个 `uvcpp_db_client`, 但它谁也打不开,属于"能配置、跑不起来"。 所以 `CMakeCache.txt` 里 `UVCPP_ENABLE_DB_MYSQL:BOOL=ON` **不代表**它编进去了 @@ -65,9 +75,15 @@ cmake --build build-db -j -- db: SQLite 后端开(3.45.1) -- db: MySQL 后端开(/usr/lib/x86_64-linux-gnu/libmysqlclient.so) -- db: PostgreSQL 后端开(libpq 16.15) +-- db: ODBC 后端开(驱动管理器 /usr/lib/x86_64-linux-gnu/libodbc.so) -- Including db module in build (database integration) ``` +ODBC 那一行报的是**驱动管理器**(Windows 上是 `-- db: ODBC 后端开(Windows 系统 +odbc32)`)—— 它**不是**"这台机器连得上某个数据源":那件事只有 `open()` 才知道, +见下面「ODBC:驱动管理器与**具体驱动**」。找不到 `sql.h`、或只找到头没找到 +`libodbc` 时,这一行换成一条 warning 并**只关掉 ODBC 这一个后端**,其余照编。 + 开了 `UVCPP_DB_RUNTIME_LOAD` 时这两行会换个说法(后半截是**头**的出处, 不再是库的出处): @@ -84,13 +100,14 @@ cmake --build build-db -j #include int main() { - // "sqlite,mysql,postgres" —— 只列**真的编进来**的那几个 + // "sqlite,mysql,postgres,odbc" —— 只列**真的编进来**的那几个 std::printf("%s\n", uvcpp::uvcpp_db_drivers().c_str()); return 0; } ``` -自己编的包少哪个后端时(**官方预编译包 1.7.0 起三个都在**,不会有这一行缺项), +自己编的包少哪个后端时(**官方预编译包 1.7.0 里三个都在**,不会有这一行缺项; +`1.7.2-dev` 起六条发布腿**显式**开 ODBC,所以它也会出现在这一行里), **这一行是第一个该看的地方**。 > **注意这一行说的是"编进来没有",不是"这台机器上能不能用"。** @@ -100,10 +117,12 @@ int main() { ### 客户端库运行时加载:`UVCPP_DB_RUNTIME_LOAD` -**为什么要有它。** 直接链客户端库时,`libpq.so.5` / `libmysqlclient.so.21` 会 -变成产物的一条 `DT_NEEDED`,而 **`DT_NEEDED` 是加载期解析的**:装了 libuvcpp -的机器上只要缺这一条,**进程根本起不来** —— 一行 db 代码都没跑,甚至这个程序 -压根不碰数据库。Windows 同理(PE 的导入表)。 +**为什么要有它。** 直接链客户端库时,`libpq.so.5` / `libmysqlclient.so.21` / +`libodbc.so.2` 会变成产物的一条 `DT_NEEDED`,而 **`DT_NEEDED` 是加载期解析的**: +装了 libuvcpp 的机器上只要缺这一条,**进程根本起不来** —— 一行 db 代码都没跑, +甚至这个程序压根不碰数据库。Windows 同理(PE 的导入表),**只有 `odbc32.dll` 是 +例外**:它属于操作系统,链接它不算代价 —— 所以这一档对 ODBC 的意义集中在 +Linux / macOS 的产物上(那上面 ODBC 的"客户端库"是普通包 unixODBC)。 本机实测(同一个 `hello`,只 `printf`,一行 db 代码不跑): @@ -121,24 +140,30 @@ $ ./hello ```bash cmake -S . -B build-db -DUVCPP_ENABLE_DB=ON \ -DUVCPP_ENABLE_DB_MYSQL=ON -DUVCPP_ENABLE_DB_PGSQL=ON \ + -DUVCPP_ENABLE_DB_ODBC=ON \ -DUVCPP_DB_RUNTIME_LOAD=ON ``` | | 直接链(默认,`OFF`) | 运行时加载(`ON`) | |---|---|---| -| 链接期要 | `mysql.h`/`libpq-fe.h` **+** 客户端库 | 只要头(`libmysqlclient-dev` / `libpq-dev`) | -| 产物的 `DT_NEEDED` | 多两条 | **一条都不多** | -| 机器上没装客户端库 | **进程起不来** | 进程照常跑;HTTP、SQLite 一切照常 | -| 真去连 MySQL/PG | 能连 | 返回 `NO_DRIVER` + `last_error()` 里写明**试过哪几个库名** | -| 找库的办法 | 链接器(`/etc/ld.so.cache`、`RPATH`) | 固定的候选名列表,或 `UVCPP_DB_MYSQL_LIBRARY` / `UVCPP_DB_PGSQL_LIBRARY` | +| 链接期要 | `mysql.h`/`libpq-fe.h`/`sql.h` **+** 客户端库 | 只要头(`libmysqlclient-dev` / `libpq-dev` / `unixodbc-dev`) | +| 产物的 `DT_NEEDED` | 多三条 | **一条都不多** | +| 机器上没装那些库 | **进程起不来** | 进程照常跑;HTTP、SQLite 一切照常 | +| 真去连 MySQL/PG/ODBC | 能连 | 返回 `NO_DRIVER` + `last_error()` 里写明**试过哪几个库名** | +| 找库的办法 | 链接器(`/etc/ld.so.cache`、`RPATH`) | 固定的候选名列表,或 `UVCPP_DB_MYSQL_LIBRARY` / `UVCPP_DB_PGSQL_LIBRARY` / `UVCPP_DB_ODBC_LIBRARY` | + +(最后那一行那三个环境变量是**可以拿来造条件**的:它一设,候选名一个都不再试 +—— 所以**指到一个不存在的路径**就等于"这台机器没装这个客户端库",这是确定性地 +复现这一档的**唯一**办法,也是 `test_db_odbc_nodrv_func` 用的办法,见 §10。 +它是**排他**的:想指到别处就得把**完整路径**给对。) 候选名(按顺序试,全试完才报错): -| 平台 | MySQL | libpq | -|---|---|---| -| Linux | `libmysqlclient.so.21`、`libmysqlclient.so.24`、`libmariadb.so.3`、`libmysqlclient.so` | `libpq.so.5`、`libpq.so` | -| Windows | `libmysql.dll`、`libmariadb.dll`、`mysqlclient.dll` | `libpq.dll` | -| macOS | `libmysqlclient.21.dylib`、`libmariadb.3.dylib`、`libmysqlclient.dylib` | `libpq.5.dylib`、`libpq.dylib` | +| 平台 | MySQL | libpq | ODBC(驱动管理器) | +|---|---|---|---| +| Linux | `libmysqlclient.so.21`、`libmysqlclient.so.24`、`libmariadb.so.3`、`libmysqlclient.so` | `libpq.so.5`、`libpq.so` | `libodbc.so.2`、`libodbc.so` | +| Windows | `libmysql.dll`、`libmariadb.dll`、`mysqlclient.dll` | `libpq.dll` | `odbc32.dll`(系统件,一定在) | +| macOS | `libmysqlclient.21.dylib`、`libmariadb.3.dylib`、`libmysqlclient.dylib` | `libpq.5.dylib`、`libpq.dylib` | `libodbc.2.dylib`、`libodbc.dylib` | 两个环境变量是**显式指定**,设了它就**只试这一个**(不再走候选名列表)—— 装在非标准前缀下的人用它,而它同时是**"没装库"那条路的确定性旋钮**: @@ -155,31 +180,144 @@ UVCPP_DB_PGSQL_LIBRARY=/nonexistent/libpq.so.5 ./myapp 都可能还在),卸掉比留着危险。 **它只改"符号从哪来",不改后端在不在**:`UVCPP_DB_MYSQL_ENABLE` / -`UVCPP_DB_PGSQL_ENABLE` 那两个宏照旧是"头找得到就是 1", +`UVCPP_DB_PGSQL_ENABLE` / `UVCPP_DB_ODBC_ENABLE` 那三个宏照旧是"头找得到就是 1", `uvcpp_db_drivers()` 的输出也不变。换句话说 `NO_DRIVER` 现在有**两条**来路 (后端没编进来 / 编进来了但这台机器没库),`last_error()` 会说清是哪一条 —— 详见 `uvcpp_db_status.h` 里 `NO_DRIVER` 那一段的文档注释。 **共享库上这一档还多一条链接选项 `--no-undefined`**(ELF):调用点全改成经 `api()` 那张 `dlsym` 函数指针表走,而**共享库默认允许未定义符号** —— -漏绑/漏改一处,`ld` 不吭声,产物却带着一个没人提供的 `mysql_query`, -于是**每个使用者**在加载这个 `.so` 时就炸。`--no-undefined` 把这条不变式交还 -给链接器:漏一处就是链接期红。 +漏绑/漏改一处,`ld` 不吭声,产物却带着一个没人提供的 `mysql_query`(或 +`SQLExecDirectW`),于是**每个使用者**在加载这个 `.so` 时就炸。`--no-undefined` +把这条不变式交还给链接器:漏一处就是链接期红。加 ODBC 那一份时这条兜底直接生效 +过 —— `nm -u …/uvcpp_db_odbc.cpp.o` 里剩下 **0 个** `SQL*` 未定义符号。 + +### ODBC:驱动管理器与**具体驱动**是两件事 + +前三个后端各自钉死一种客户端库(libsqlite3 / libmysqlclient / libpq),ODBC 对着 +的是一个**接口**:编进来的是 ODBC **驱动管理器**(Linux 的 `libodbc`、Windows 的 +系统 `odbc32`),**连到哪张库**由连接串里的 `Driver=`(或 `DSN=`)决定。那个**具体 +驱动**(比如 SQL Server 的 `ODBC Driver 18 for SQL Server`,官方 `msodbcsql18`) +**既不归构建管、也不归这个库管**:装没装、装的是哪一版,只有 `open()` 那一刻才 +知道。接 SQL Server 走的就是这条路 —— 微软没给它一个可链接的 C 客户端库,它给的 +就是 ODBC 驱动,所以在 C 层没有第二个选择;而这个后端本身是**按通用写的**:能连 +SQL Server,也能连任何装得出 ODBC 驱动的数据源(Oracle、DB2、达梦……),全文件 +没有一处"是 SQL Server 就怎么着"。 + +由此有三条与前三个后端不同的、值得先知道的: + +* **开关说的是"这份产物有没有这个后端",不是"能不能连上"。** 配置期只要 `sql.h` + 与驱动管理器两样。缺 `sql.h` 时打一条 warning(Debian/Ubuntu 上装 `unixodbc-dev`, + RHEL 系 `unixODBC-devel`)并只关掉 ODBC 这一个后端。**只装运行时 `unixodbc` 的 + 机器上也会走到这条**:那个包给的是 `libodbc.so.2`,而 `.so` 这个**开发符号链接** + 是 `unixodbc-dev` 才有的 —— 所以要的是 `unixodbc-dev`,不是 `unixodbc`。 +* **连接失败时报的是驱动管理器给的原文**(驱动名写错时就是它那句"找不到数据源、 + 也没有默认驱动"),本模块不把它改写成自己的话:这句话是判"我的连接串写错了" + 还是"这台机器没装驱动"唯一有用的信息。它**不走 `NO_DRIVER` 那条路** —— + `NO_DRIVER` 的含义是"这个后端没编进这份包"(见 [§2](#2-连接串))。 +* **它也走 `UVCPP_DB_RUNTIME_LOAD`:链上的是驱动管理器,那也是"客户端库"。** + 起先这里写的是"ODBC 不走这条路,因为驱动管理器本来就该在、而且很小" —— 那句话 + **只在 Windows 上成立**(`odbc32.dll` 属于操作系统,和 `ws2_32` 同类,链接它没有 + 代价);Linux / macOS 上驱动管理器是一个普通包(unixODBC),链上它就是在产物里 + 加一条 `DT_NEEDED libodbc.so.2`,正是上面「运行时加载」那一节要消灭的形状: + + ``` + 两棵树只差 `-DUVCPP_DB_RUNTIME_LOAD` 这一个开关(其余逐条相同): + + $ readelf -d /tmp/uvcpp-off/libuvcpp.so | grep NEEDED # 关(默认) + NEEDED libuv.so.1 + NEEDED libsqlite3.so.0 + NEEDED libodbc.so.2 + NEEDED libstdc++.so.6 + + $ readelf -d /tmp/uvcpp-rt/libuvcpp.so | grep NEEDED # -DUVCPP_DB_RUNTIME_LOAD=ON + NEEDED libuv.so.1 + NEEDED libsqlite3.so.0 + NEEDED libstdc++.so.6 + ``` + + 于是它和 MySQL/PG 同一条规矩:**开** ⇒ 产物里不出现这个库,"这台机器没装 + unixODBC"推迟到**真去连**那一次(拿到的是 `NO_DRIVER` 加一句写清了试过哪些库名 + 的话);**关**(默认)⇒ 老实链上。两件事别混:**驱动管理器**是这一层的依赖, + 它下面的**具体驱动**由它自己 `dlopen`,那是 `open()` 那一刻的事(上面第一条)。 + + 候选名是各平台的惯例名(Linux `libodbc.so.2` / `libodbc.so`、macOS + `libodbc.2.dylib` / `libodbc.dylib`、Windows `odbc32.dll`),要指到别处用 + `UVCPP_DB_ODBC_LIBRARY`。**iODBC 有意不在候选名里**:它是另一个驱动管理器实现, + 符号名一样但行为没在本仓量过 —— 想用它就显式指路,别让"打开成功"盖住"换了一个 + 没量过的实现"。 + +#### 为什么不学 SQLite 那份,把 ODBC **静态链进去** + +这个问题一定会被再问一遍("SQLite 能全静态编进去,ODBC 为什么不行、为什么还要装 +东西"),所以答案写在这里,而不是留给下一个人再想一次: + +* **静态链消灭的是"管理器这层可替换",换不来"不用装东西"。** ODBC 后端的契约是 + "编进来的是一层**接口**,连哪张库由 `Driver=`/`DSN=` 在 `open()` 那一刻决定"。 + 驱动管理器存在的意义就是按那个名字去 `dlopen` **具体驱动**(`msodbcsql18` 之类)。 + 把管理器静态链上,那份 `dlopen` 一样要发生、那个具体驱动一样得装 —— 也就是说 + `1.7.2` 的静态链版本到了现场,**该装的还是装,只是少了一层的可替换性**。 +* **Windows 上它连"合规"都过不去。** `odbc32.dll` 是操作系统组件,不可再分发 + (同一类里还有 `ws2_32.dll`)。把它静态链进自己的产物,既没有上游给的静态库, + 也不是允许的做法 —— 而目标平台正是 Windows。 +* **Linux/macOS 上它有害。** 驱动管理器在那是普通包(unixODBC),静态链进去多一条 + `DT_NEEDED`,正是上面「客户端库运行时加载」那一节要消灭的形状。 +* **它也不解决这份文档开头那个问题。** 真正会缺的是**具体驱动**(比如 + `ODBC Driver 18 for SQL Server`),那是个几 MB 的安装包,跟本库怎么编没关系。 + +所以这一档的答案是:**静态链不适用,也不需要**。产物里编进的是那 1600 多行后端代码 +(本来就在 `libuvcpp.so`/`.dll` 里),缺的只是"管理器这一层的一次 `dlopen`";要 +ODBC 的机器上装一份驱动管理器 + 具体驱动就能用,没装的机器上**其余三个后端一切 +照常**,只有真去连 `odbc://` 时拿到 `NO_DRIVER` 与一句写清了试过哪些库名的话 +(这一条有独立用例钉着:`tests/functional/db_odbc_nodrv_func.cpp`,见 §10)。 + +连接串里的默认驱动名(`ODBC Driver 18 for SQL Server`)**只是默认**:`?Driver=` 或 +`?DSN=` 一写就换掉(见 [§2](#2-连接串))。 ### 预编译包里的 SQLite 是**从源码编的**:`UVCPP_DB_SQLITE_FROM_SOURCE` -六条发布腿各传这几个 `-D`(**1.7.0 起**;在那之前后两行是 `=OFF`): +六条发布腿各传这几个 `-D`(**1.7.0 起**前五行、**`1.7.2` 起**第六行;在那之前后两行是 `=OFF`): ``` -DUVCPP_ENABLE_DB=ON -DUVCPP_DB_SQLITE_FROM_SOURCE=ON \ -DUVCPP_ENABLE_DB_MYSQL=ON -DUVCPP_ENABLE_DB_PGSQL=ON \ --DUVCPP_DB_RUNTIME_LOAD=ON +-DUVCPP_DB_RUNTIME_LOAD=ON -DUVCPP_ENABLE_DB_ODBC=ON \ +-DUVCPP_DB_ODBC_INCLUDE_DIR=<那份 sql.h 在哪> ``` -也就是说 —— **预编译包里的 db 模块三个后端都在**(`uvcpp_db_drivers()` 会打印 -`sqlite,mysql,postgres` —— 是**驱动的稳定短名**,`pgsql` 只是 `scheme` 的别名之一, -见下面的别名表),但 MySQL/PG 的**客户端库**是运行时才打开的,链接行上 -不出现(见下面「客户端库运行时加载」那一节)。SQLite 那一半必须从源码编,不是漏配, +也就是说 —— **`1.7.0` 那个预编译包里的 db 模块三个后端都在**(`uvcpp_db_drivers()` +会打印 `sqlite,mysql,postgres` —— 是**驱动的稳定短名**,`pgsql` 只是 `scheme` 的 +别名之一,见下面的别名表),但 MySQL/PG 的**客户端库**是运行时才打开的,链接行上 +不出现(见下面「客户端库运行时加载」那一节)。 + +**ODBC 是第四个,`1.7.2-dev` 起六条腿都显式开、且每一条都显式给出头在哪** +(**`1.7.1` 这个号没发过,所以还没有哪个已发布的包带过它**)。这件事**必须显式**, +不能靠默认值:`UVCPP_ENABLE_DB_ODBC` 默认就是 `ON`,而它找不到 `sql.h` 时的后果是 +**静默降级** —— `message(WARNING)` + 普通变量 `set(... OFF)`,`CMakeCache.txt` 里 +照样写着 `ON`、生成头里却是 0、包里少一个后端,**一条断言都不会红**。所以发布腿上是 +两件事一起写: + +| 腿 | `sql.h` 从哪来 | 为什么是它 | +|---|---|---| +| `mingw-x64` / `mingw-arm64` | MSYS2 包 `mingw-w64-{x86_64,clang-aarch64}-unixodbc`,头在 `$MINGW_PREFIX/include/unixodbc/` | 这是唯一一套**给 mingw 编的** ODBC 头;Windows SDK 那份是给 MSVC 的(`__declspec`/SAL/`#pragma`),混进 mingw 是拿一个没人试过的组合去赌。运行期反正加载系统 `odbc32.dll`,两种头在这个后端里等价 | +| `msvc-x64` / `msvc-arm64` | Windows SDK 的 `/Include//um`,由 `.github/scripts/win-odbc-headers.sh` **按版本号取最大**的那一份 | SDK 自带、随工具链走,不用网络也不用解包。取"版本号最大"而不是字典序:字典序会把 `10.0.17763.0` 排在最前,于是 `um` 来自一份老 SDK 而 `` 来自新的,报出来是 `oaidl.h`/`propidlbase.h`/`sqltypes.h` 里成百条 `C4430`/`C2144`,看着像 SDK 坏了 | +| `linux-x64` / `linux-arm64` | `apt-get download unixodbc-dev` + `dpkg -x`(**不 install**,与 `libmysqlclient-dev`/`libpq-dev` 同一个机制) | 与那两份头同一条规矩:只要头。腿里 `-DUVCPP_DB_RUNTIME_LOAD=ON`,配置期连 `find_library(odbc)` 都不跑,所以 ODBC 不会往产物里多添一条 `DT_NEEDED libodbc.so.2` | + +> Linux 那份头有个**只有把包摊到一个目录里才会遇到**的坑:Debian 把 +> `usr/include` 与 `usr/include//` 分成两层,而 `sqltypes.h` 里有一句 +> `#include "unixodbc.h"`(`unixodbc.h` 在**第二层**)。真机上两层都在编译器的默认 +> 搜索路径里,所以装了 `unixodbc-dev` 的人一辈子看不到这个分层;发布腿只把 +> `usr/include` 这一层交给 `-D`,于是它掉了 —— 症状是编译 `sqltypes.h` 时那句 +> `#include` 报 `fatal error: unixodbc.h: 没有那个文件或目录`。腿里把那一层 +> **软链**到公共头旁边补上(见 `release.yml` 里 `ln -sfn … unixodbc.h` 那两行)。 + +六条腿的产物断言里也因此多了一枚 `UVCPP_DB_ODBC_ENABLE` —— 它读的是**生成头** +(`/include/uvcpp/uvcpp_config.h`),不是 `CMakeCache.txt`:上面那条"cache 里 +照旧 ON"的降级形状只有读生成头才拦得住。两条 Linux 腿还各多两条负向判据 +(`ldd` 的名单里加 `odbc`、`nm -D -u` 的计数加 `SQL[A-Z]`),两条 mingw 腿的导入表 +白名单**一个字都没动** —— 里面没有 `odbc32.dll`,这正是想要的:真链进去了它就会红。 + +SQLite 那一半必须从源码编,不是漏配, 是两条实测理由把另两条路都堵死了: | 想链系统的那份 | 实测结果 | @@ -237,6 +375,10 @@ cmake -DUVCPP_ENABLE_DB=ON \ cmake -DUVCPP_ENABLE_DB=ON \ -DPostgreSQL_INCLUDE_DIR=/opt/pgsql/include \ -DPostgreSQL_LIBRARY=/opt/pgsql/lib/libpq.so .. +# ODBC 是本模块自己的两个 find_* +cmake -DUVCPP_ENABLE_DB=ON \ + -DUVCPP_DB_ODBC_INCLUDE_DIR=/opt/unixodbc/include \ + -DUVCPP_DB_ODBC_LIBRARY=/opt/unixodbc/lib/libodbc.so .. ``` **开了 `UVCPP_DB_RUNTIME_LOAD`**:位置的找法分成两截 —— @@ -259,12 +401,13 @@ cmake -DUVCPP_ENABLE_DB=ON \ ### 静态库上的已知缺口 -三个客户端库挂在 `$` + `PRIVATE` 下:**共享库**上 -`DT_NEEDED` 会跟着走,使用方什么都不用做;**静态库**(`uvcpp_a` / `uvcpp_a_s`) -上那一条不传给消费者,使用方要自己链: +四个后端用到的库(`sqlite3` / `pq` / `mysqlclient` / ODBC 的驱动管理器 `odbc`) +挂在 `$` + `PRIVATE` 下:**共享库**上 `DT_NEEDED` 会跟着走, +使用方什么都不用做;**静态库**(`uvcpp_a` / `uvcpp_a_s`)上那一条不传给消费者, +使用方要自己链: ```cmake -target_link_libraries(myapp PRIVATE uvcpp_a sqlite3 pq mysqlclient) +target_link_libraries(myapp PRIVATE uvcpp_a sqlite3 pq mysqlclient odbc) ``` 这是**故意**的取舍,与 zlib / nghttp2 那几处同形:把绝对路径写进导出集会过得了 @@ -274,7 +417,8 @@ target_link_libraries(myapp PRIVATE uvcpp_a sqlite3 pq mysqlclient) **开了 `UVCPP_DB_RUNTIME_LOAD` 时这一节不适用**:MySQL/PG 的客户端库根本不在 `UVCPP_DB_LINK_LIBS` 里(那正是那个开关干的事),所以静态库的使用方也**不用** 再补 `pq`/`mysqlclient` —— 只剩 SQLite 那一条要不要补,取决于它是系统那份还是 -`UVCPP_DB_SQLITE_FROM_SOURCE` 编进来的静态目标。 +`UVCPP_DB_SQLITE_FROM_SOURCE` 编进来的静态目标。**ODBC 那一条不受这个开关影响**: +驱动管理器照旧在链接行上,所以静态库的使用方照旧要补(Windows 上是 `odbc32`)。 --- @@ -284,16 +428,32 @@ target_link_libraries(myapp PRIVATE uvcpp_a sqlite3 pq mysqlclient) sqlite:///abs/path/app.db sqlite://rel/path.db sqlite::memory: mysql://user:pass@host:3306/dbname postgres://user:pass@host:5432/dbname +odbc://user:pass@host:1433/dbname?Driver={ODBC Driver 18 for SQL Server} +odbc:?DSN=MyDsn ``` | 项 | 规则 | |---|---| | scheme 别名 | `postgres` / `postgresql` / `pgsql` / `pg` → `postgres`;`mariadb` → `mysql`。**别名归一化之后**才去做"认不认得"的判断 | -| 缺省端口 | MySQL 3306、PostgreSQL 5432 | +| 缺省端口 | MySQL 3306、PostgreSQL 5432。**ODBC 没有缺省端口**:不写就不是 `tcp:host,port` 而是 `Server=tcp:host` | | 密码 | 可省(`mysql://root@host/db`)。URL 里带特殊字符要自己转义,本模块不做 URL 解码 | | SQLite 的路径 | `sqlite://` 后面**原样**当一个路径用(不解析 user/host/port)。父目录不存在时驱动会**建**出来 | +| ODBC 的 host / 库名 | `host:port` → `Server=tcp:host,port`,`/dbname` → `Database=`,`user:pass@` → `Uid=` / `Pwd=` | +| ODBC 的 `?` 之后 | **原样**追加到 ODBC 连接串末尾(本模块不解析它,`;` 分隔),所以那里能写任何驱动认得的键 | | 没带 scheme | `BAD_URL`。报错原文里会带上传进来的整串 | +ODBC 那两行的**默认值只补缺**:连接串里已经写了 `Driver=` / `DSN=` / `FILEDSN=` +就**一个都不补**(`?DSN=MyDsn` 那行就是完全不碰主机与库名的用法);没写时补的是 +`Driver={ODBC Driver 18 for SQL Server}` 加一条 `Encrypt=no`(18 版默认强制加密, +而内网库多半没配证书 —— 不显式关掉的话,使用者会先撞上一个"证书不受信任"的握手 +错误,然后去怀疑连接串写错了)。同样,没给账号时补 `Trusted_Connection=yes` +(Windows 集成认证:同域部署下更该用的方式,而且没有口令进连接串,也就不会进日志), +`?Uid=` / `?User=` 里写了一个就不补。 + +`Server=tcp:host,port` 这个**带逗号的写法是 SQL Server 的**。别的驱动的连接串文法 +各认各的,认不出 `,port` 的那个会忽略它或报错 —— 这是"通用驱动"在连接串这一层的 +固有代价,写在这里而不是藏起来:换数据源时 `?` 之后通常也要跟着换。 + ### `BAD_URL` 与 `NO_DRIVER` 是两件事 这是本模块唯一一处"两个返回码看着像、含义差很远"的分界,值得单说: @@ -306,6 +466,11 @@ postgres://user:pass@host:5432/dbname **不是**「这份构建里有没有」。用后者的话两种情况都是假,分不开 —— 而"打不开连接" 这个笼统说法,恰恰是最没用的一句错误信息。用例里两条都有断言。 +ODBC 上还有**第三种**"打不开",它在返回码上不算前两种:后端编进来了、驱动管理器 +也在,缺的是**那个数据源的驱动**(或者驱动名写错了)。这一种要到 `open()` 才知道, +返回的是驱动管理器给的原话,`last_error()` 里就是它 —— 别去 `NO_DRIVER` 那边找 +(那条只会在"这份包没编 ODBC 后端"时出现)。 + --- ## 3. 公开面 @@ -703,16 +868,40 @@ std::string quote_table(uvcpp::uvcpp_db_client& db, ## 6. 三个后端:钉住的一致,与如实列出的三处不同 +> **这一节的标题说的是 SQLite / MySQL / PostgreSQL 三家**,ODBC 单独放在紧接着的 +> 「ODBC 换了一列」里。ODBC **已经进了同一个共用套件** +> (`tests/functional/db_odbc_func.cpp`;方言按 `Driver=` 认出来的驱动族挑,读数见 +> 下面表格的最后一行),而**本仓**跑过的那条路是 ODBC 到 SQLite。SQL Server 那一族 +> 本仓量不到([§11](#11-没做的如实列出) 说了为什么,以及**下游一个私有项目的 CI +> 已经把它跑到了哪一步**)。读到"三个后端"、"三家"时,ODBC 不在里面。 + ### 钉住的一致(共用套件 `tests/functional/db_suite.h`,三家各跑一遍) 本机实测的读数(数字各不相同是**设计如此**,见下面"三处真不一样"): -| 后端 | 通过 | 不适用 | 日志里的那两行 | +| 后端 | 通过 | 不适用 | 不适用的那几行 | |---|---|---|---| -| SQLite | 157 | 2 | `没编进来的后端 -> NO_DRIVER`、`原生时间类型标签`(它没有原生时间类型) | -| MySQL | 159 | 1 | `没编进来的后端 -> NO_DRIVER`(这份构建三个后端都在) | +| SQLite | 196 | 1 | `原生时间类型标签`(它没有原生时间类型) | +| ODBC(到 SQLite) | 181 | 3 | `原生时间类型标签`、`last_insert_id`、`外键约束` | +| MySQL | 159 | 1 | ——(沿用旧读数) | | PostgreSQL | 159 | 1 | 同上 | +> ODBC 那一行的 181/3 是**关掉 MySQL 与 PG** 时量的。把另外两个后端一起打开会是 +> **180/4**:多的那条不适用是 `没编进来的后端 -> NO_DRIVER` —— 它只有在"这份构建 +> 真的缺了某个后端"时才是个能跑的断言,四个后端都在的树上它按设计记 N/A。 +> 两个读数都是对的,差别在构建配置,不在后端。(`1.7.2` 的发布包是四个都在的那一 +> 种,所以现场看到的是 180/4。) + +ODBC 那一行之外还有**另一组 ODBC 专属用例**(日志里单独一段,`-- ODBC 专属 --`, +57 通过 / 0 失败):共用套件判"三家一致"的那些契约,ODBC 专属那一组判它自己的坑。 + +口径写清楚:读数是**这台机器、这份构建**(SQLite 与 ODBC 编进来,MySQL / +PostgreSQL 没编)跑出来的;SQLite 那一行在 ODBC 关掉重编重跑时同样是 196 / 1, +所以它不随"编不编 ODBC"变。MySQL / PostgreSQL 两行**是上一轮的读数**,本机没有 +可跑的 MySQL/PG 服务端,这一轮只重测了 SQLite 与 ODBC。SQLite 那一行同样和更早的 +文档对不上(旧数字是 157 / 2,本树上复现不出来)—— 复现不出来的旧读数不留着当 +"约等于",直接换成现测数字。 + "不适用"是**独立的一格计数器**,与"通过"分开打印 —— 跳过永远不会在日志里 伪装成通过。 @@ -748,6 +937,32 @@ std::string quote_table(uvcpp::uvcpp_db_client& db, `"YYYY-MM-DD HH:MM:SS"`):这是三家都能无损往返、且人眼直接可读的表示。 SQLite 本来就没有日期类型,PostgreSQL 的文本格式恰好就是这个形状。 +### ODBC 换了一列(差别在**驱动**里,不在后端里) + +前三家的差别在**库里**;ODBC 的差别在**驱动**里。同一个 `odbc` 后端,`Driver=` 指的 +是 SQLite 的 ODBC 驱动还是 SQL Server 的,占位符写法、自增主键声明、定点列声明就跟着 +变 —— 这也是它必须有两个 `dialect` 实现(`odbc_sqlite_dialect` / `odbc_mssql_dialect`) +的原因:断开的是"后端"与"方言"的绑定。共用套件里"不能分叉断言"这条规矩对它同样成立, +多出来的自由度**只**体现在方言上。 + +| 项 | ODBC 到 SQLite(sqliteodbc,跑过) | ODBC 到 SQL Server(**本仓没量**) | +|---|---|---| +| `$1` 占位符 | **不接受**(`parameter marker count incorrect`) | —— | +| 语法错 / 表不存在 | 报 `EXEC_FAILED`:`SQLPrepare` 只**登记**,不解析 SQL | —— | +| 自增主键 | `INTEGER PRIMARY KEY AUTOINCREMENT` | —— | +| 定点(钱) | 没有,`TEXT` 存字符串 | —— | +| 外键 | 连接里不开 `PRAGMA foreign_keys` → **不**强制 | —— | +| `last_insert_id()` | 没有这条路 → 不适用 | —— | + +右列的破折号**不是"待填"**,是"**本仓**量不到" —— 跑这一列要一台真 SQL Server,而 +本仓的 CI 里没有、也不该有(见 §11)。这些列声明写在 `odbc_mssql_dialect` 里,等着 +一台真库来对。 + +第二行那条是 `dialect::prepares_eagerly()` 这个钩子存在的理由:`SQLPrepare` 是当下 +解析 SQL、还是只把语句登记下来等 `SQLExecute` 才报错,是驱动的自由。套件不假定它, +只按声明的能力判返回码 —— 说"预处理阶段失败"的地方在这类驱动上报的是 `EXEC_FAILED`, +而那同样是真的失败,只是**晚一步**。 + ### 后端各自怎么读结果(想看清"为什么某条断言长这样"时看这里) * **SQLite**:`sqlite3_column_*` 按**运行期**类型取,与列的声明类型无关 @@ -877,8 +1092,13 @@ uvcpp::uvcpp_db_status transfer(uvcpp::uvcpp_db_client& db) { ## 10. 本地怎么跑 -三条用例在 `tests/functional/`:`db_sqlite_func.cpp`(**不需要服务端**,永远真 -跑)、`db_mysql_func.cpp`、`db_pgsql_func.cpp`。共用套件在 `db_suite.h`。 +四条用例在 `tests/functional/`:`db_sqlite_func.cpp`(**不需要服务端**,永远真 +跑)、`db_mysql_func.cpp`、`db_pgsql_func.cpp`、`db_odbc_func.cpp`。共用套件在 +`db_suite.h`。 + +**ODBC 那一条要一个真的数据源**(服务器、或一个本地文件库的驱动),所以它排在第 +四:给不出 URL 时退 3(未判定),而不是拿一份默认方言硬跑。它的**方言按 `Driver=` +挑** —— ODBC 的方言属于数据源、不属于这个后端,认不出就退 3: ```bash cmake -S . -B build-db3 -DUVCPP_BUILD_TESTS=ON -DUVCPP_ENABLE_DB=ON @@ -892,6 +1112,16 @@ export UVCPP_DB_TEST_MYSQL_URL='mysql://root@127.0.0.1:3306/uvcpp_test' export UVCPP_DB_TEST_PGSQL_URL='postgresql://uvcpp@127.0.0.1:5432/uvcpp_test' ./build-db3/tests/functional/test_db_mysql_func ./build-db3/tests/functional/test_db_pgsql_func + +# ODBC:SQL Server(本后端存在的理由) +export UVCPP_DB_TEST_ODBC_URL='odbc://sa:pw@127.0.0.1:1433/uvcpp_test?Driver={ODBC Driver 18 for SQL Server}' +./build-db3/tests/functional/test_db_odbc_func + +# ODBC:也可以走 sqliteodbc —— 不用服务端,一条命令就能把这条用例跑起来 +# (Ubuntu:apt-get install unixodbc libsqliteodbc,驱动落在 +# /usr/lib/x86_64-linux-gnu/odbc/libsqlite3odbc.so) +export UVCPP_DB_TEST_ODBC_URL='odbc:?Driver=/usr/lib/x86_64-linux-gnu/odbc/libsqlite3odbc.so;Database=/tmp/uvcpp_odbc.db' +./build-db3/tests/functional/test_db_odbc_func ``` **套件里有 `DROP TABLE` / `CREATE TABLE`,URL 必须指向一个一次性的库。** @@ -901,7 +1131,27 @@ export UVCPP_DB_TEST_PGSQL_URL='postgresql://uvcpp@127.0.0.1:5432/uvcpp_test' |---|---| | `UVCPP_DB_TEST_MYSQL_URL` | MySQL 用例的连接串。没有它 → 退出码 **3**(未判定) | | `UVCPP_DB_TEST_PGSQL_URL` | PostgreSQL 用例的连接串,同上 | +| `UVCPP_DB_TEST_ODBC_URL` | ODBC 用例的连接串,同上(认不出 `Driver=` 是哪一族也退 3) | | `UVCPP_DB_TEST_REQUIRE` | 设了它,缺 URL 就是**失败**(退出码 1),不是跳过 | +| `UVCPP_DB_ODBC_LIBRARY` | 指到驱动管理器的完整路径。**指到一个不存在的路径**就等于"这台机器没装驱动管理器"—— `test_db_odbc_nodrv_func` 就是这么跑的(ctest 的 `ENVIRONMENT` 属性替它设好,见 §1 那张候选名表) | + +**`test_db_odbc_nodrv_func` 是另一条,与上面三条形状不同**:它不要数据源,要的是 +"**这台机器上没有驱动管理器**",验的是"没装 unixODBC 时 `open("odbc://…")` 回 +`NO_DRIVER` + 一句人话、而同一进程里 SQLite 照常可用"。所以它**不需要任何环境变量 +就跟着 `ctest` 跑**(前提由 `tests/functional/CMakeLists.txt` 里的 `ENVIRONMENT` +设好),前提不成立时是**红**、不是跳过 —— 它不该有"这台机器没配好"这种借口。 +手动跑: + +```bash +UVCPP_DB_ODBC_LIBRARY=/nonexistent/libodbc.so.2 ./build-db3/tests/functional/test_db_odbc_nodrv_func +``` + +它必须是**独立可执行文件**(不能并进 `test_db_odbc_func`):ODBC 的符号加载走 +magic static,**同一个进程里成功加载过一次之后,再设这个环境变量也不再生效**。 + +**ODBC 这一条不在 CI 里**:那几台 runner 上没有任何 ODBC 数据源,装了驱动管理器也 +只够跑"驱动名不存在时报 `OPEN_FAILED`"(那是它自己的一条断言),跑不了共用套件。 +所以它的读数只能在本地产出 —— 见 [§11](#11-没做的如实列出) 里哪些还真没有。 退出码 3 是照本仓 gate 脚本的规矩来的:**「未判定」不是「通过」**。ctest 那边用 `SKIP_RETURN_CODE 3` 把它报成 `***Skipped` —— 与 `Passed` 在日志里是两个词。 @@ -926,6 +1176,31 @@ Windows 上走的是 `UVCPP_DB_SQLITE_FROM_SOURCE`(MSYS2 若装了 ## 11. 没做的(如实列出) +* **ODBC 到 SQL Server 本身 —— *本仓*没量,但下游的 CI 已经跑到"真库上执行查询" + 这一步了。** 先说本仓这一半:这个后端进了共用套件(`db_odbc_func.cpp`, + `UVCPP_DB_TEST_ODBC_URL`),但本仓跑过的那条路是 ODBC 到 SQLite(sqliteodbc, + Ubuntu,驱动管理器 unixODBC)。SQL Server 那一族的方言(`odbc_mssql_dialect`)在 + **本仓的测试集里一次都没执行过** —— 这里加不进去:跑它要一台真 SQL Server,而 + CI 里没有。 + 再说另一半,因为它比"完全没验过"要强,不写出来会让人误判成熟度:**下游那个 + 私有项目**(这个后端存在的理由就是它)的 CI 里有一段"探得到就跑、探不到就跳过" + 的步骤,在 Windows runner 上找到 SQL Server LocalDB 与一个能连它的 ODBC 驱动 + 时,会让它打出来的包**真的建一条连接、真的执行查询、真的把结果读回来** + (`SELECT DB_NAME(), CONVERT(varchar(30), GETDATE(), 120), @@VERSION`,走的就是 + 本库的 ODBC 后端),并且另外用一份夹具库把两条业务口径(会员统计、引车员检测 + 统计)的数字逐个对过 —— 那是 `odbc_mssql_dialect` 第一次落到真 SQL Server 上。 + **但那是另一个仓库的 CI**:本仓既不能复现它、也不该把"某台机器上恰好有 + LocalDB"写成自己的一条判据。所以两句话一起成立:**方言在真库上跑通过(下游 + 验的),而本仓这一列读数仍然是空的**。下面的表说的就是后者。 +* **ODBC 到 SQL Server 那些**逐条**读数**(`IDENTITY` / `DATETIME2` / `BIT` 落成 + 什么类型、`$1` 占位符在 mssql 方言下写成什么、DSN 形式的连接串)。这些是上面 + 那条"本仓没量"的具体条目:真去跑真库时,红的第一条先怀疑那份方言的写法,而不是 + 后端本身。**在这一列被谁真量出来之前,别把"ODBC 到 SQL Server"当成与另外三家 + 同等"验证过"的后端。** +* **ODBC 的 `last_insert_id()`(除了 SQL Server 那一族)。** 它发的就是 + `SCOPE_IDENTITY()`,别的数据源不认,如实返回 `UNSUPPORTED`(实测:sqliteodbc 上 + 报 `UNSUPPORTED` 并留下原因,不是编一个 0 出来)。别的数据源要报自增值,得先有 + 一条按数据源分派的写法。 * **协程(`co_await` / C++20 那套)。** 异步门面给的是**回调 + future**两种形状 (见 [§3](#3-公开面)),本库是 C++11 的,不引入协程。 * **门面的背压。** 不限制在途条数 —— 一个连接本来就是串行的,排队等锁不会更快。 diff --git a/doc/expand-guide.md b/doc/expand-guide.md index 95e7efbc..b3ecab2a 100644 --- a/doc/expand-guide.md +++ b/doc/expand-guide.md @@ -95,18 +95,18 @@ cmake -S . -B build -DUVCPP_BUILD_EXPAND=ON | `-DUVCPP_BUILD_EXPAND=ON` | `UVCPP_ENABLE_MEMORY_POOL=1` | | `-DUVCPP_BUILD_EXPAND=OFF`(默认) | `UVCPP_ENABLE_MEMORY_POOL=0` | -选项到宏的映射在 `CMakeLists.txt:1544-1550` 一处完成。用预编译包时**什么都不用传** —— +选项到宏的映射在 `CMakeLists.txt:1613-1619` 一处完成。用预编译包时**什么都不用传** —— 包里的 `include/uvcpp/uvcpp_config.h` 就是那次构建的值,自己再传一个冲突的值是硬 `#error`。 关掉时 `src/expand/` 整目录被 `list(FILTER ... EXCLUDE REGEX "src/expand/")` 排除 -(`CMakeLists.txt:1907-1916`)。于是"关掉之后包含它"的表现取决于**你的头是怎么来的** +(`CMakeLists.txt:1976-1985`)。于是"关掉之后包含它"的表现取决于**你的头是怎么来的** —— 而能不能编过,只由"那个头在不在你的 include 路径里"决定: | 你怎么构建 | `#include ` 的结果 | |---|---| | 源码树自己构建(没开开关) | **编得过,链接失败** —— 头还在 `src/expand/` 下,且四个头里**没有任何一个用宏守卫自己**(唯一提到这个宏的地方是 `src/expand/uvcpp_page_heap.h:8` 的一句注释),没有 `#error` 兜底;但符号一个都没编进库 | | 用**官方预编译包** | **什么都不用传,直接用** —— 六条发布腿的十处 configure 都传 `-DUVCPP_BUILD_EXPAND=ON`(`release.yml` 里十处都有),包里那个 `uvcpp_config.h` 的 `UVCPP_ENABLE_MEMORY_POOL` 是 **1**,`include/expand/` 那四个头也在。上面那句"用预编译包什么都不用传"在这里是**字面意思** | -| 自己 `cmake --install` 到一个前缀(没开开关) | **编译期就报找不到头** —— 这条路按开关装头(`CMakeLists.txt:2920-2923`),`include/expand/` 不会被装出去 | +| 自己 `cmake --install` 到一个前缀(没开开关) | **编译期就报找不到头** —— 这条路按开关装头(`CMakeLists.txt:2991-2994`),`include/expand/` 不会被装出去 | | 自己跑 `package_release.py` 打的包(没开开关) | 与第一行同 —— **编得过、链接失败**。它拷头是按 `MODULES` 逐个目录拷的(`tests/tools/package_release.py`),**不看这个开关**,所以 `include/expand/` 照样在包里;但包里那个宏是 0、库里也没有那些符号 | > **别把第三、四行记成一回事**:`cmake --install` 与 `package_release.py` 是两套独立的 diff --git a/doc/grpc-guide.md b/doc/grpc-guide.md index 778c44f1..c60cbd72 100644 --- a/doc/grpc-guide.md +++ b/doc/grpc-guide.md @@ -10,11 +10,11 @@ 这个分法不是洁癖,是**依赖事实**:编解码那半的每一个参数都是 `std::string` / `uint32_t` (`src/grpc/uvcpp_grpc.h:100-194`),而服务端那半的每个参数都是 web 层的类型 (`src/grpc/uvcpp_grpc_server.h:116-132`)。所以关掉 web 模块时前者照编照装、后者 -**从构建与安装里都摘掉**(`CMakeLists.txt:1951-1953`、`CMakeLists.txt:2939-2944`)—— +**从构建与安装里都摘掉**(`CMakeLists.txt:2020-2022`、`CMakeLists.txt:3010-3015`)—— 装在 `include/grpc/` 下,而不是 `include/web/` 下。 - 打开方式:**没有开关**。编解码那半任何配置下都在;服务端那半跟着 `UVCPP_BUILD_WEB` - 走(`CMakeLists.txt:1951-1951`)。加一个 `UVCPP_ENABLE_GRPC` 只会多一个能与 + 走(`CMakeLists.txt:2020-2020`)。加一个 `UVCPP_ENABLE_GRPC` 只会多一个能与 `UVCPP_BUILD_WEB` 打架的旋钮,而两半的依赖是死的、不需要调用方表态。 - 包含方式:``、``。 - 依赖:帧与 varint 那半**只依赖 `` 与 `libuv` 的错误码**( diff --git a/doc/http2-guide.md b/doc/http2-guide.md index a924efc9..bb7f8673 100644 --- a/doc/http2-guide.md +++ b/doc/http2-guide.md @@ -11,14 +11,14 @@ `web/` 的两条线(`uvcpp_http_server` / `uvcpp_http_client`)各有一份接好的实现可以照抄, 本页在 [§5](#5-服务端怎么回一条响应) 与 [§6](#6-客户端怎么发一条请求) 给了它们的调用序列。 -- 打开方式:`-DUVCPP_ENABLE_NGHTTP2=ON`。**默认 OFF**(`CMakeLists.txt:236-236`), +- 打开方式:`-DUVCPP_ENABLE_NGHTTP2=ON`。**默认 OFF**(`CMakeLists.txt:249-249`), 而且缺 OpenSSL 或缺 web 模块时会被**强制**置 OFF 并打 warning - (`CMakeLists.txt:595-595`、`CMakeLists.txt:604-604`)—— 因为 `uvcpp_h2_stream` + (`CMakeLists.txt:608-608`、`CMakeLists.txt:617-617`)—— 因为 `uvcpp_h2_stream` 里直接放 `uvcpp_http_request` / `uvcpp_http_response` (`src/http2/uvcpp_h2_session.h:70`)。 - 包含方式:``、``、 ``。私有的 `` **不安装** - (`CMakeLists.txt:3029-3029`)。 + (`CMakeLists.txt:3100-3100`)。 > 本页讲**怎么用**。它与 [HTTP/2 支持现状](./http2-status.md) 分工:那篇讲 > **实现进度、写死的折衷、已知缺口**,本页不重复那些,需要时直接链过去。 @@ -72,12 +72,12 @@ int drain(std::string& out, size_t max_bytes = 0); // src/http2/u ## 2. 编译期条件 `UVCPP_NGHTTP2_ENABLE` **在任何构建里都有定义**,只是值不同:ON ⇒ `1`,OFF ⇒ `0` -(`CMakeLists.txt:2307-2307`)。三个公开头把**全部内容**包在 `#if UVCPP_NGHTTP2_ENABLE` 里 +(`CMakeLists.txt:2376-2376`)。三个公开头把**全部内容**包在 `#if UVCPP_NGHTTP2_ENABLE` 里 (`src/http2/uvcpp_h2_common.h:19`、`src/http2/uvcpp_h2_session.h:33`、`src/http2/uvcpp_h2_connection.h:27`)。 为 0 时:头**还在**(源码树里能 include、也编得过),但里面**一个类型都不声明** —— 写 `uvcpp_h2_session s(true);` 会"未定义类型"。安装出来的包里则**根本没有这几个头** -(`CMakeLists.txt:3029-3029` 只在开关 ON 时装)。 +(`CMakeLists.txt:3100-3100` 只在开关 ON 时装)。 所以使用者的写法是把自己的那段包起来: diff --git a/doc/http2-status.md b/doc/http2-status.md index 09bb465b..88b4058d 100644 --- a/doc/http2-status.md +++ b/doc/http2-status.md @@ -82,13 +82,13 @@ 一份更权威的 ALPN 名单 —— 设过就不覆盖。 - **关掉 nghttp2 也能编**:`http2/*.h` 不出现在任何公开头里,`src/web/uvcpp_http_client.h:43-45` 写明了理由(引了就把 nghttp2 的 include 路径扩散给每个使用者与测试 TU)。 - `UVCPP_ENABLE_NGHTTP2` 默认 OFF(`CMakeLists.txt:236-236`),缺 OpenSSL 或缺 web + `UVCPP_ENABLE_NGHTTP2` 默认 OFF(`CMakeLists.txt:249-249`),缺 OpenSSL 或缺 web 模块时还会被强制置 OFF 并告警。 ### 1.4 依赖与产物 `nghttp2` 是**唯一被强制静态**的依赖:`uvcpp_nghttp2_configure()` -(`CMakeLists.txt:626-626`,在 function 作用域里 `BUILD_STATIC_LIBS ON`)把一个 +(`CMakeLists.txt:639-639`,在 function 作用域里 `BUILD_STATIC_LIBS ON`)把一个 静态 `nghttp2_static` 交给链接器。实测 `pe_imports()`:开了 h2 的 DLL 与没开的 相比,非白名单依赖集合**都是** `llhttp.dll uv.dll zlib1.dll` —— 开 h2 不新增 任何 DLL 依赖。 @@ -377,7 +377,7 @@ ctest --test-dir build-h2 -C Release --timeout 60 - `copy_test_dlls` 是 `add_custom_target(... ALL ...)`,命令全是 `${CMAKE_COMMAND} -E copy_if_different`,**一个 `pwsh` 都不沾**,构建日志里 "Copy uvcpp/libuv DLLs into test folders" 那行就是它。它确实会跑。真正 - `0xc0000135` 满屏的根因是这张目标**曾经没带 `ALL`**(见 `CMakeLists.txt:3325-3325` + `0xc0000135` 满屏的根因是这张目标**曾经没带 `ALL`**(见 `CMakeLists.txt:3398-3398` 的注释),与 `pwsh` 无关。 - 日志里那句 `'pwsh.exe' 不是内部或外部命令` 来自 **vcpkg 的 `scripts/buildsystems/msbuild/vcpkg.targets`**(`applocal.ps1`),而且它自己 diff --git a/doc/http3-guide.md b/doc/http3-guide.md index 5b64b45a..3d410286 100644 --- a/doc/http3-guide.md +++ b/doc/http3-guide.md @@ -24,18 +24,18 @@ - 打开方式:`-DUVCPP_ENABLE_HTTP3=ON`。**默认 OFF**(`CMakeLists.txt:143-143`), 而且下面两种情况会被**强制**置 OFF 并打 warning,而不是留一个"能配置、链不上、 跑不起来"的组合: - 1. 没开 QUIC(`CMakeLists.txt:960-960`)—— HTTP/3 是 QUIC 之上的一条应用协议, + 1. 没开 QUIC(`CMakeLists.txt:973-973`)—— HTTP/3 是 QUIC 之上的一条应用协议, 没有 QUIC 它没有字节可以跑在上面; - 2. 没开 web 层(`CMakeLists.txt:967-967`)—— 这条传输的公开面就在 + 2. 没开 web 层(`CMakeLists.txt:980-980`)—— 这条传输的公开面就在 `uvcpp_http_client` / `uvcpp_http_server` 上,web 关掉时它没有使用者。 - 包含方式:``、``。 私有的 `` 与 `` **都不安装** - (`CMakeLists.txt:3070-3070`)—— 理由见 [§7](#7-典型坑) 第一条。 + (`CMakeLists.txt:3141-3141`)—— 理由见 [§7](#7-典型坑) 第一条。 - 两个公开头整段套在 `#if UVCPP_HTTP3_ENABLE` 里,web 层那边新加的 `listen_quic()` / `set_http3_enabled()` 也是,所以**不开关就一个名字都看不到**。 这与 `quic/`、`web/`、`ssl/`、`http2/`、`wsdl/` 同档。 -- 开关与依赖:nghttp3 走 FetchContent(`NGHTTP3_VERSION`,`CMakeLists.txt:250-250`), - 静态链入 `nghttp3_static`(`CMakeLists.txt:2732-2732`)—— 详见 +- 开关与依赖:nghttp3 走 FetchContent(`NGHTTP3_VERSION`,`CMakeLists.txt:263-263`), + 静态链入 `nghttp3_static`(`CMakeLists.txt:2803-2803`)—— 详见 [§5](#5-cmake-接线与那条-check-撞名的坑)。 > 本指南里的签名、默认值、行为都对着当前源码核过。凡是"这一版没做"的地方都明确 @@ -360,14 +360,14 @@ void connect_over_http3(uvcpp_http_client& cli, int udp_port) { ## 5. CMake 接线与那条 `check` 撞名的坑 1. `option(UVCPP_ENABLE_HTTP3 ...)` 挨着 QUIC 那个(`CMakeLists.txt:143-143`), - **默认 OFF**;两条守卫(`CMakeLists.txt:960-960`、`CMakeLists.txt:967-967`)用普通变量 `set(... OFF)` 降级并出声 —— + **默认 OFF**;两条守卫(`CMakeLists.txt:973-973`、`CMakeLists.txt:980-980`)用普通变量 `set(... OFF)` 降级并出声 —— 照 QUIC 那套。**别只 grep `CMakeCache.txt`**:降级不是 cache 写,所以 cache 里 仍然是 `UVCPP_ENABLE_HTTP3:BOOL=ON`,一个"被降级了"的配置和一个"真开了"的配置 在 cache 那一层长得一模一样。配置成功的标志是这行 STATUS: ``` - nghttp3 integrated (tag=v1.18.0, static) # CMakeLists.txt:1342-1342 - Including http3 module in build (nghttp3 v1.18.0) # CMakeLists.txt:2023-2023 + nghttp3 integrated (tag=v1.18.0, static) # CMakeLists.txt:1411-1411 + Including http3 module in build (nghttp3 v1.18.0) # CMakeLists.txt:2092-2092 ``` 2. **依赖用 `FetchContent_Declare` + `Populate` + `add_subdirectory(... EXCLUDE_FROM_ALL)`, @@ -385,17 +385,17 @@ void connect_over_http3(uvcpp_http_client& cli, int udp_port) { 要删掉 `build*/_deps/uvcpp-nghttp3-src/` 再 configure。 4. **后置断言两条**:`if(NOT TARGET nghttp3_static)` FATAL - (`CMakeLists.txt:1330-1330`;**裸目标名,没有 ALIAS**,别去猜 + (`CMakeLists.txt:1399-1399`;**裸目标名,没有 ALIAS**,别去猜 `nghttp3::nghttp3_static`),以及把 `POSITION_INDEPENDENT_CODE` 读回来断言 - (`CMakeLists.txt:1265-1265`)—— 后者是"Linux 上 `libuvcpp.so` 链接失败"这件事的成因。 + (`CMakeLists.txt:1334-1334`)—— 后者是"Linux 上 `libuvcpp.so` 链接失败"这件事的成因。 5. **三处编译定义同步**:`cmake/uvcpp_config.h.in` 的 `UVCPP_HTTP3_ENABLE` 段、 目录级 `add_compile_definitions`、PUBLIC `target_compile_definitions`, - 外加 `_uvcpp_literal01(UVCPP_HTTP3_ENABLE UVCPP_ENABLE_HTTP3)`(`CMakeLists.txt:2309-2309`)。 + 外加 `_uvcpp_literal01(UVCPP_HTTP3_ENABLE UVCPP_ENABLE_HTTP3)`(`CMakeLists.txt:2378-2378`)。 四处不一致会以 `#error` 或静默的 ABI 错配收场,`check_config_contract.py` 盯着 其中一个方向。 -6. **链接是 PRIVATE**(`CMakeLists.txt:2732-2732` 的 `$`): +6. **链接是 PRIVATE**(`CMakeLists.txt:2803-2803` 的 `$`): nghttp3 的头路径不传播给使用者,所以**功能测试不能** `#include `;想知道版本就用 `uvcpp_h3_connection::nghttp3_version()` (`src/http3/uvcpp_h3_connection.h:287`),它经公开头调用、链接期解析。 @@ -488,7 +488,7 @@ h1 写泵、TCP 的 ALPN 列表与分支 —— **全部不改**。唯一的例 1. **别指望从公开 h3 头里看到 nghttp3。** `` 只出现在私有的 `src/http3/uvcpp_h3_nghttp3.h` 里,而它和持有 nghttp3 句柄的 - `src/http3/uvcpp_h3_session.h` **两个都不安装**(`CMakeLists.txt:3070-3070`)、打包 + `src/http3/uvcpp_h3_session.h` **两个都不安装**(`CMakeLists.txt:3141-3141`)、打包 也排除。理由与 quic 那一对逐字相同:一是使用者不该被逼着去配 nghttp3 的搜索 路径才能 include 一个本库的头;二是 `uvcpp_h3_session.h` 的字段布局直接跟着 nghttp3 的版本走 —— 一旦漏进公开面,那个版本就变成了本库的 ABI。这处排除与 @@ -530,7 +530,7 @@ h1 写泵、TCP 的 ALPN 列表与分支 —— **全部不改**。唯一的例 9. **`h3_header` / `h3_response` 这类小件不能花括号初始化,这是 C++11 的硬规矩。** 它们有一格带默认成员初值(`kind = h3_header_kind::REGULAR`),而"C++11 里带 默认成员初值的类**不算聚合体**"—— 这条限制到 C++14 才放开。本库的编译标准是 - C++11(`CMakeLists.txt:252-252` 的 `set(CMAKE_CXX_STANDARD 11)`),所以 + C++11(`CMakeLists.txt:265-265` 的 `set(CMAKE_CXX_STANDARD 11)`),所以 `h3_header{"accept", "*/*", …}` 在消费者那里编不过,报的是 `no matching function for call to 'h3_header::h3_header()'`。**逐格赋值**不会踩到它,本库自己的代码也是这么写的 diff --git a/doc/json-guide.md b/doc/json-guide.md index 1caa87b0..0490d435 100644 --- a/doc/json-guide.md +++ b/doc/json-guide.md @@ -44,7 +44,7 @@ ## 2. 编译期条件与包含 - **没有开关。** 它是核心模块,`UVCPP_ENABLE_NET` / `_WEB` / `_WEBAPP` 一个都不影响它; - `CMakeLists.txt:1443-1443` 那个收 `src/uvcpp/` 下源文件的 GLOB 已经把它收进去了。 + `CMakeLists.txt:1512-1512` 那个收 `src/uvcpp/` 下源文件的 GLOB 已经把它收进去了。 - **不需要 nlohmann、不需要 OpenSSL、不需要 zlib。** 头文件只带 `` 与 ``,实现只多带 ` `。 - **C++11**(本仓没有 per-target 覆盖),所以没有 `std::string_view`、没有 `if constexpr`。 diff --git a/doc/json-reflect-guide.md b/doc/json-reflect-guide.md index 925432be..5bca100b 100644 --- a/doc/json-reflect-guide.md +++ b/doc/json-reflect-guide.md @@ -93,7 +93,7 @@ struct doc_thing { ``` 两个头都**不在 `` 里**:那个聚合头只收 `uvcpp_define/version/export` 与 -`handle/loop/req` 三组。两个头都被 `CMakeLists.txt:1443-1443` 与 webapp 那份同形的 GLOB 自动 +`handle/loop/req` 三组。两个头都被 `CMakeLists.txt:1512-1512` 与 webapp 那份同形的 GLOB 自动 收进构建与安装,**不需要往任何清单里加一行**。 - **没有开关。** 写出侧不吃 `UVCPP_ENABLE_*` 任何一个;读入侧只吃它本来就依赖的 diff --git a/doc/quic-guide.md b/doc/quic-guide.md index 2454f66a..9f7bbf37 100644 --- a/doc/quic-guide.md +++ b/doc/quic-guide.md @@ -31,16 +31,16 @@ - 打开方式:`-DUVCPP_ENABLE_QUIC=ON`。**默认 OFF**(`CMakeLists.txt:122-122`),而且 下面三种情况会被**强制**置 OFF 并打 warning,而不是留一个"能配置、链不上、 跑不起来"的组合: - 1. 没开 OpenSSL(`CMakeLists.txt:721-721`)—— QUIC 建在 TLS 1.3 上,ALPN 是 TLS + 1. 没开 OpenSSL(`CMakeLists.txt:734-734`)—— QUIC 建在 TLS 1.3 上,ALPN 是 TLS 扩展,**没有明文 QUIC** 这回事; - 2. 没开 net 层(`CMakeLists.txt:728-728`)—— QUIC 是 net 层的一条链路协议,并且复用 + 2. 没开 net 层(`CMakeLists.txt:741-741`)—— QUIC 是 net 层的一条链路协议,并且复用 `src/net/uvcpp_net_read.h` 的读事件契约; - 3. OpenSSL 找得到、但**不带 QUIC API**(`CMakeLists.txt:774-774`)—— 判据见 + 3. OpenSSL 找得到、但**不带 QUIC API**(`CMakeLists.txt:787-787`)—— 判据见 [§2](#2-编译期条件一份带-quic-api-的-openssl--32)。 - 包含方式:``、``、 ``、``。私有的 `` 与 `` **都不安装** - (`CMakeLists.txt:3047-3047`)—— 理由见 [§7](#7-典型坑) 第一条。 + (`CMakeLists.txt:3118-3118`)—— 理由见 [§7](#7-典型坑) 第一条。 - 四个公开头**全部**整段套在 `#if UVCPP_QUIC_ENABLE` 里,所以**不开关就一个类都 看不到**。这与 `web/`、`ssl/`、`http2/`、`http3/`、`wsdl/` 同档。 @@ -154,7 +154,7 @@ push 一例:本臂跨度 67、组间差 42),所以判据落在噪声里 Windows 且 `UVCPP_ENABLE_QUIC=ON` → `ON`,其余一律 `OFF`。非 Windows 上还有一层 **降级**:显式传 `-DUVCPP_ENABLE_UDP_GSO=ON` 也会被按回 `OFF`(配置期报一行 note), 所以这个宏在非 Windows 上**恒为 0、整段代码不编进去** —— 是"不生效",不是"编进来 -但是死的"。派生值必须算在 QUIC 的**静默降级链之后**(`CMakeLists.txt:785-785` 之后), +但是死的"。派生值必须算在 QUIC 的**静默降级链之后**(`CMakeLists.txt:798-798` 之后), 否则一条"QUIC 已被降级掉"的配置会把 GSO 打开。 **两层闸门。** @@ -215,10 +215,10 @@ libngtcp2 支持两个分支,判据写在它自己的 `CMakeLists.txt` 里, | 上面那个没有、`SSL_set_quic_tls_cbs` 有 | mainline OpenSSL ≥ 3.2(`HAVE_OSSL`) | **`ngtcp2_crypto_ossl_static`** ← 我们链的是这个 | | 两个都没有 | — | 一个都不建,configure 期 `FATAL_ERROR` | -本库的探针**复刻**了这个顺序(`CMakeLists.txt:764-764`、`CMakeLists.txt:767-767`)。这不是 +本库的探针**复刻**了这个顺序(`CMakeLists.txt:777-777`、`CMakeLists.txt:780-780`)。这不是 仪式:只查 `SSL_set_quic_tls_cbs` 的探针会在 quictls 树上"通过",然后去链一个**从来 没被建出来**的目标。所以探针的结论是"没有 provide、但有 cbs"才放行 -(`CMakeLists.txt:774-774`);另外两格都强制关闭并出声。 +(`CMakeLists.txt:787-787`);另外两格都强制关闭并出声。 > **Ubuntu 24.04 自带的 3.0.13 不行。** 3.0/3.1 没有那两个符号中的任何一个。这不是 > 保守估计 —— 对着本机那份 `libssl.so.3` 量过:两个符号数都是 0。 @@ -253,8 +253,8 @@ nghttp2 / zlib / pugixml 的用法一致。 配置成功的标志是两行 `message(STATUS)`,CI 也正是 grep 这两行: ``` -ngtcp2 integrated (tag=v1.25.0, static) # CMakeLists.txt:942-942 -Including quic module in build (ngtcp2 v1.25.0) # CMakeLists.txt:2000-2000 +ngtcp2 integrated (tag=v1.25.0, static) # CMakeLists.txt:955-955 +Including quic module in build (ngtcp2 v1.25.0) # CMakeLists.txt:2069-2069 ``` > **只 grep `CMakeCache.txt` 是不够的。** 三条降级用的都是**普通变量** @@ -614,7 +614,7 @@ TCP 的 `PEER_CLOSED` 本来就是对端 FIN —— 于是这个字段对 TCP 1. **别指望从公开头里看到 ngtcp2。** `` 只出现在私有的 `src/quic/uvcpp_quic_ngtcp2.h` 里,而它和持有 ngtcp2 句柄的 - `src/quic/uvcpp_quic_session.h` **两个都不安装**(`CMakeLists.txt:3047-3047`)、打包 + `src/quic/uvcpp_quic_session.h` **两个都不安装**(`CMakeLists.txt:3118-3118`)、打包 也被排除。理由有两条:一是使用者不该被逼着去配 ngtcp2 的搜索路径才能 include 一个本库的头;二是 `uvcpp_quic_session.h` 的成员里就有 `ngtcp2_conn*`、`SSL*`、 `ngtcp2_path_storage`,它的字段布局直接跟着 ngtcp2 的版本走 —— 一旦漏进公开面, @@ -628,8 +628,8 @@ TCP 的 `PEER_CLOSED` 本来就是对端 FIN —— 于是这个字段对 TCP 调用,符号在链接期解析,照样证明 ngtcp2 真被链上。 3. **`src/quic/` 的源文件被 `list(FILTER … EXCLUDE REGEX "src/quic/")` 排除** - (`CMakeLists.txt:1995-1995`,头文件那一条在 `:1597`),而且"开了 QUIC 但目录是空的" - 会**当场 FATAL**(`CMakeLists.txt:2009-2009`)。后者防的是一棵树同时骗过三道看起来 + (`CMakeLists.txt:2064-2064`,头文件那一条在 `:1597`),而且"开了 QUIC 但目录是空的" + 会**当场 FATAL**(`CMakeLists.txt:2078-2078`)。后者防的是一棵树同时骗过三道看起来 很像门禁的东西:cache 里 `QUIC=ON`、日志里有 `ngtcp2 integrated`、编译也过 —— 而 `src/quic/` 一个 `.cpp` 都没有,**零行 QUIC 代码被编译过**。"没测"必须表现为 **失败**,不是表现为**通过**。 diff --git a/doc/ssl-guide.md b/doc/ssl-guide.md index 43c9378c..ba28af15 100644 --- a/doc/ssl-guide.md +++ b/doc/ssl-guide.md @@ -65,7 +65,7 @@ TLS 在这库里是**过滤层**,不是独立的传输实现:装到 `uvcpp_t 或链接错误**,只会在运行时读到错位的垃圾。 关掉这个选项时:`src/ssl/` 会被从源文件与头文件列表里排除,**而且根本不安装 ssl 头** -(`CMakeLists.txt:3019-3020`)——使用者连 `#include ` 都会 +(`CMakeLists.txt:3090-3091`)——使用者连 `#include ` 都会 找不到文件。同理,`uvcpp_tcp_server::set_ssl_context()` 在关掉时**连声明都不存在**。 运行时"关掉 TLS"不是宏,是 `set_ssl_context(nullptr)`。 diff --git a/doc/web-http-guide.md b/doc/web-http-guide.md index 0003e85e..ce276018 100644 --- a/doc/web-http-guide.md +++ b/doc/web-http-guide.md @@ -87,10 +87,10 @@ | HTTP/2 | `UVCPP_NGHTTP2_ENABLE` | `set_http2_enabled()` 那一组消失 | 两行"响应压缩"是**同一个块的两个来源**:压缩器的总开关是 -`UVCPP_HTTP_COMPRESS_ENABLE`,它由那两个宏**或**出来(`CMakeLists.txt:1819-1819`)—— +`UVCPP_HTTP_COMPRESS_ENABLE`,它由那两个宏**或**出来(`CMakeLists.txt:1888-1888`)—— 任一为 1 就有这个块,两个都为 0 才整块消失。三个"块"的开关都默认 OFF,brotli -那条也是(`CMakeLists.txt:222-236` 那一组 `option()`,四个块开关默认全是 OFF)。编译进 `UVCPP_SRC_FILES` 的是 -`src/web/*.cpp` 全体(`CMakeLists.txt:1457-1457`),但每个 `.cpp` 里的功能块同样带宏。 +那条也是(`CMakeLists.txt:235-249` 那一组 `option()`,四个块开关默认全是 OFF)。编译进 `UVCPP_SRC_FILES` 的是 +`src/web/*.cpp` 全体(`CMakeLists.txt:1526-1526`),但每个 `.cpp` 里的功能块同样带宏。 `uvcpp_http_server` **自己不碰 TLS** —— 它没有任何 SSL API。要上 https 必须 `get_tcp_server()->set_ssl_context(...)`,**而且必须在 `listen()` 之前** @@ -730,7 +730,7 @@ zlib 编进来时**压缩默认开**(`src/web/uvcpp_http_server.h:1814-1814` 1 档是 65.4 → 42.8 µs、1 566 → 1 957 字节),所以默认值一个字节都没动。 **第六个旋钮:`br`(brotli)。** `UVCPP_ENABLE_BROTLI`(默认 OFF, -`CMakeLists.txt:223-223`)给压缩器加上第二种编码:`br` 与 gzip/deflate 走**同一套** +`CMakeLists.txt:236-236`)给压缩器加上第二种编码:`br` 与 gzip/deflate 走**同一套** 协商(`http_compress::parse_accept_encoding()`,`Accept-Encoding` 里 `q` 相同时 `br` > `gzip` > `deflate`,`*` 兜底、`br;q=0` 排除、指名要一个本构建编不出来的 编码时**原样发原文**而不是拿另一种编码的字节冒充 —— diff --git a/doc/webapp-support-guide.md b/doc/webapp-support-guide.md index 10272963..33087876 100644 --- a/doc/webapp-support-guide.md +++ b/doc/webapp-support-guide.md @@ -72,7 +72,7 @@ **这七个头里没有一个用 `UVCPP_WEBAPP_ENABLE` 守卫自己。** 21 个 webapp 头 里只有 `src/webapp/uvcpp_web_ws_client.h:52` 一处(`#endif` 在 `:402`)用了这个宏。 所以"没开 webapp 却 include 了这七个头"是**链接期失败**(那几个 `.cpp` 没进构建, -`CMakeLists.txt:1959-1960`),不是编译期被 `#if` 挡住。这与 `uvcpp_web_ws_client.h` +`CMakeLists.txt:2028-2029`),不是编译期被 `#if` 挡住。这与 `uvcpp_web_ws_client.h` 的做法不一致 —— 看上去是历史的,不是设计。 头在包里的位置是平铺的 `include/webapp/`: diff --git a/src/db/uvcpp_db_driver.h b/src/db/uvcpp_db_driver.h index bc415c47..8fe3c496 100644 --- a/src/db/uvcpp_db_driver.h +++ b/src/db/uvcpp_db_driver.h @@ -54,7 +54,7 @@ namespace db_detail { /// 密码里带 `@` 是常见的(`p@ssw0rd`),按第一个切会把密码截断、把剩下的 /// 当成主机名 —— 症状是「连接被拒」,而配置看起来完全正常。 struct uvcpp_db_url { - std::string scheme; // 规范化后的 "sqlite" / "mysql" / "postgres" + std::string scheme; // 规范化后的 "sqlite" / "mysql" / "postgres" / "odbc" std::string user; std::string password; std::string host; @@ -71,7 +71,7 @@ class uvcpp_db_driver { public: virtual ~uvcpp_db_driver() = default; - /// 驱动的稳定短名:"sqlite" / "mysql" / "postgres"。 + /// 驱动的稳定短名:"sqlite" / "mysql" / "postgres" / "odbc"。 virtual const char* name() const = 0; virtual uvcpp_db_status open(const uvcpp_db_url& url, std::string* err) = 0; @@ -123,6 +123,10 @@ class uvcpp_db_driver { std::unique_ptr uvcpp_db_make_sqlite(); std::unique_ptr uvcpp_db_make_mysql(); std::unique_ptr uvcpp_db_make_pgsql(); +/// ODBC 是**通用**驱动,不是 SQL Server 专用:口径由连接串里的 `Driver=` 决定。 +/// 之所以它在,是因为微软没给 SQL Server 一个可链接的 C 客户端库 —— 官方的 +/// `msodbcsql18` 就是 ODBC 驱动。见 `uvcpp_db_odbc.cpp` 的文件头。 +std::unique_ptr uvcpp_db_make_odbc(); /// 按 scheme 造驱动。没编进来的后端返回 `nullptr`(**不是**抛异常、 /// 也不是造一个打不开的壳)—— 调用方据此报 `NO_DRIVER`。 @@ -142,7 +146,7 @@ bool uvcpp_db_driver_available(const std::string& scheme); /// 传进来的必须是 `uvcpp_db_url::parse()` 规范化之后的 scheme。 bool uvcpp_db_driver_known(const std::string& scheme); -/// 编进来的后端名,逗号分隔、按 sqlite,mysql,postgres 顺序。空串表示一个都没有。 +/// 编进来的后端名,逗号分隔、按 sqlite,mysql,postgres,odbc 顺序。空串表示一个都没有。 std::string uvcpp_db_available_drivers(); } // namespace db_detail diff --git a/src/db/uvcpp_db_factory.cpp b/src/db/uvcpp_db_factory.cpp index cfc06fbc..37a2eba0 100644 --- a/src/db/uvcpp_db_factory.cpp +++ b/src/db/uvcpp_db_factory.cpp @@ -209,6 +209,13 @@ std::unique_ptr uvcpp_db_make_driver( return uvcpp_db_make_pgsql(); #else return nullptr; +#endif + } + if (scheme == "odbc") { +#if UVCPP_DB_ODBC_ENABLE + return uvcpp_db_make_odbc(); +#else + return nullptr; #endif } return nullptr; @@ -234,19 +241,27 @@ bool uvcpp_db_driver_available(const std::string& scheme) { return true; #else return false; +#endif + } + if (scheme == "odbc") { +#if UVCPP_DB_ODBC_ENABLE + return true; +#else + return false; #endif } return false; } bool uvcpp_db_driver_known(const std::string& scheme) { - return scheme == "sqlite" || scheme == "mysql" || scheme == "postgres"; + return scheme == "sqlite" || scheme == "mysql" || scheme == "postgres" || + scheme == "odbc"; } std::string uvcpp_db_available_drivers() { std::string out; - const char* kAll[] = {"sqlite", "mysql", "postgres"}; - for (size_t i = 0; i < 3; ++i) { + const char* kAll[] = {"sqlite", "mysql", "postgres", "odbc"}; + for (size_t i = 0; i < 4; ++i) { if (!uvcpp_db_driver_available(kAll[i])) continue; if (!out.empty()) out.push_back(','); out.append(kAll[i]); diff --git a/src/db/uvcpp_db_odbc.cpp b/src/db/uvcpp_db_odbc.cpp new file mode 100644 index 00000000..97f75c43 --- /dev/null +++ b/src/db/uvcpp_db_odbc.cpp @@ -0,0 +1,1692 @@ +/** + * @file src/db/uvcpp_db_odbc.cpp + * @brief ODBC 后端:一份驱动,覆盖所有提供 ODBC 驱动的数据源。 + * @author zhuweiye + * @version 1.0.0 + * + * ## 为什么接 SQL Server 这件事落在 ODBC 上 + * + * 微软**没有**给 SQL Server 一个可以链接的 C 客户端库 —— 它给的就是 ODBC 驱动 + * (`msodbcsql18`,Windows 上随系统或单独安装)。所以"接 SQL Server"在 C 层 + * 就是"接 ODBC",不存在第二个选择。 + * + * 而既然路口是通用的,这个后端就**按通用写**:能连 SQL Server,也能连任何装得出 + * ODBC 驱动的数据源(Oracle、DB2、达梦……)。全文件没有一处 `if (是 SQL Server)`。 + * 唯一带"默认值"色彩的是连接串里那个驱动名(见 `build_connection_string`), + * 而它只是**默认**,`?Driver=` 一写就换掉。 + * + * ## 坑一:`SQLWCHAR` 的宽度不是固定的 + * + * 微软的 ODBC 头里 `SQLWCHAR` 是 `unsigned short`(2 字节,UTF-16)。unixODBC + * **默认也是 2 字节**(`sqltypes.h` 里 `typedef unsigned short WCHAR`),但它的 + * `typedef` 被 `#ifdef SQL_WCHART_CONVERT` 夹着,而那个宏是**使用方**在 + * `#include ` 之前定义的(头里的原话:"if this is set, then use a 4 byte + * unicode definition, instead of the 2 byte definition that MS use")。定义了就 + * 变 `wchar_t`(Linux 上 4 字节,UTF-32)。 + * + * 本仓自己在**非 MSVC 的 Windows**(mingw)上把它打开,且只在那里 —— 不是为了改宽度 + * (那里 `wchar_t` 也是 2 字节),是因为不打开就编不过:unixODBC 补出来的 `WCHAR` 与 + * `winnt.h` 的 `typedef wchar_t WCHAR` 是硬冲突。证据与理由在 `uvcpp_db_odbc_api.h`。 + * + * 所以宽度既不能按平台假设,也不能按驱动管理器假设 —— 它是**编译这份代码的人** + * 定的。按 2 字节算字符偏移、实际是 4 字节时,每个字符的后半截会被当成下一个 + * 字符的开头,中文表名第一个字就错,而且是**静默**错。 + * + * 下面不假设宽度:转换按 `sizeof(SQLWCHAR)` 分支,2 字节走 UTF-16、4 字节走 + * UTF-32。判据取**编译期的 `sizeof`**,不是平台宏 —— 平台宏会漏掉"Linux 上装了 + * 微软驱动管理器"这种组合,而那正是 CI 与开发机常见的样子。 + * + * 宽度定了之后,还有第二件要记的事:**长度指示器的单位是字节**(见坑四)。 + * + * ## 坑二:文本参数不能用 ANSI 形态送 + * + * 文本参数一律绑 `SQL_C_WCHAR`,不绑 `SQL_C_CHAR`。绑 `SQL_C_CHAR` 时驱动按 + * **连接的 ANSI 代码页**解释那串字节,而本库内部是 UTF-8:在中文 Windows + * (CP936)上恰好能蒙对,在英文系统(CP1252)上中文条件就变成乱码去匹配, + * 结果不是报错而是**查不到** —— 比报错难查得多。 + * + * ## 坑三:不绑定列,逐格 `SQLGetData` + * + * `SQLBindCol` 要求给每一列一个**定长**缓冲,而定长这个数本身就是要猜的:猜小了 + * 长文本被静默截断,猜大了每行都白占内存。这里改用 `SQLGetData` 的分块循环, + * 两头都不猜。代价是每格多一次函数调用 —— 对报表与表浏览这类用途完全划算。 + * + * ## 坑四:`SQL_C_WCHAR` 的长度指示器是**字节** + * + * `SQLBindParameter` 的第 5 个参数(ColumnSize)在 `SQL_C_WCHAR` 下按**字符**数 + * 给,而第 8 个(BufferLength)与长度指示器按**字节**数给。单位不一致,而且按字符 + * 给指示器时**不报错**:驱动从缓冲里只搬 `n` 个字节,于是 `n` 个字符存进去变成 + * `floor(n/2)` 个 —— "张三" 变 "张",纯 ASCII 就是砍一半。实测矩阵里,"2 字节单元 + + * 字节长度" 存回 'abcdef'/'张三' 完好,"2 字节单元 + 字符长度" 复现出 'abc'/'张', + * 与本模块当时的症状一致。 + * + * ## 坑五:绑定缓冲必须是**每个参数一份** + * + * `SQLBindParameter` 只记指针 —— 缓冲、`StrLen_or_IndPtr` 都是。执行发生在**所有** + * 参数都绑完之后,所以拿几个成员变量在循环里反复赋值,等于让每个参数都指向最后一 + * 轮写进去的那份数据;`(?, ?, ?, ?)` 逐行绑 (1,2,'t0','uu0')、(11,12,'t1','uu1')、 + * (21,22,'t2','uu2'),实测库里进去的是 (2,2,'t','u')、(12,12,'t','u')、(22,22,'t','u') + * —— 四个参数全成了最后一个的值,**一个错都不报**。绑定存储因此是 + * `std::vector`,下标一一对应,且**先 `resize` 再绑**。 + */ + +#include + +#if UVCPP_DB_ENABLE && UVCPP_DB_ODBC_ENABLE + +// ODBC 头(以及"要哪些符号"的那份清单)都在这里:清单与结构体各用一半。 +#include "db/uvcpp_db_odbc_api.h" + +#if UVCPP_DB_RUNTIME_LOAD +#include "db/uvcpp_db_runtime_load.h" +#endif + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace uvcpp { +namespace db_detail { + +// --------------------------------------------------------------------------- +// 那 24 个符号从哪来 —— 与前两个后端同一个形状,理由(`DT_NEEDED` 是加载期解析的、 +// 链接器兜底在这条路上不存在)详见 `uvcpp_db_mysql.cpp` 同一处。 +// +// ODBC 这一份比 MySQL/PG 多一层意思:链上的是**驱动管理器**。Linux 与 macOS 上它是 +// 一个普通包(unixODBC),没装的机器会因为一条 `DT_NEEDED libodbc.so.2` **连 +// `libuvcpp.so` 都加载不了**,哪怕程序一行 db 代码都不碰 —— 与 libmysqlclient/libpq +// 一模一样的形状。Windows 上 `odbc32.dll` 属于操作系统(和 `ws2_32` 同类),链接它 +// 没有这个代价,所以这条开关主要在 Linux/macOS 的包上有意义。 +// --------------------------------------------------------------------------- +#if UVCPP_DB_RUNTIME_LOAD + +namespace { + +/// 候选名。带版本号的 soname 在前:`libodbc.so` 那个符号链接属于 `-dev` 包, +/// 只装了 `unixodbc` 的机器上没有它。Windows 与 macOS 侧没有 soname 这一套, +/// 用各自的惯例名(`odbc32.dll` / `libodbc.2.dylib`)。 +/// +/// **iODBC 不在候选名里**:它是另一个驱动管理器实现,符号名相同但行为没有在本仓 +/// 量过 —— 想用它就显式指路(`UVCPP_DB_ODBC_LIBRARY`),别让"打开成功"掩盖 +/// "换了一个没量过的实现"。 +const char* const kDriverManagerNames[] = { +#if defined(_WIN32) + "odbc32.dll", + nullptr +#elif defined(__APPLE__) + "libodbc.2.dylib", + "libodbc.dylib", + nullptr +#else + "libodbc.so.2", + "libodbc.so", + nullptr +#endif +}; + +/// 逐个取符号,缺一个就整张表作废(理由见 MySQL 那边)。 +bool bind_manager_api(void* handle, odbc_api* t, const char** missing) { +#define UVCPP_DB_ODBC_BIND(name) \ + t->name = reinterpret_castname)>( \ + uvcpp_db_lookup_symbol(handle, #name)); \ + if (t->name == nullptr) { \ + *missing = #name; \ + return false; \ + } + UVCPP_DB_ODBC_SYMBOLS(UVCPP_DB_ODBC_BIND) +#undef UVCPP_DB_ODBC_BIND + return true; +} + +struct api_state { + odbc_api table; + bool ok; + std::string loaded_name; + std::string error; +}; + +api_state load_manager_api() { + api_state st = {odbc_api(), false, std::string(), std::string()}; + + std::string tried; + void* handle = uvcpp_db_open_client_library(kDriverManagerNames, + "UVCPP_DB_ODBC_LIBRARY", &tried, + &st.loaded_name); + if (handle == nullptr) { + st.error = + "加载 ODBC 驱动管理器失败,试过这些名字都没打开:" + tried + + "。装一份驱动管理器(Debian/Ubuntu 上是 unixodbc,RHEL 系是 unixODBC," + "macOS 上是 Homebrew 的 unixodbc),或者把环境变量 UVCPP_DB_ODBC_LIBRARY " + "指到它的完整路径。只用别的后端时不用管这一条。注意这是**管理器**那一层:" + "管理器在、某个数据源的驱动没装,是 open() 时才会知道的事(那时的报错" + "原文来自管理器)。"; + return st; + } + + const char* missing = nullptr; + if (!bind_manager_api(handle, &st.table, &missing)) { + st.error = "ODBC 驱动管理器 " + st.loaded_name + " 里缺少符号 " + missing + + " —— 版本太旧或不是一套完整的 ODBC 驱动管理器。"; + return st; + } + st.ok = true; + return st; +} + +api_state& manager_api_state() { + // 第一次真要用 ODBC 时才加载,不是加载这份库的时候。理由同 MySQL 那边。 + static api_state st = load_manager_api(); + return st; +} + +} // namespace + +const odbc_api& uvcpp_db_odbc_api() { return manager_api_state().table; } + +const std::string& uvcpp_db_odbc_load_error() { + return manager_api_state().error; +} + +#else // 直接链接 + +namespace { + +#define UVCPP_DB_ODBC_ADDR(name) &::name, +const odbc_api kManagerApi = {UVCPP_DB_ODBC_SYMBOLS(UVCPP_DB_ODBC_ADDR)}; +#undef UVCPP_DB_ODBC_ADDR + +} // namespace + +const odbc_api& uvcpp_db_odbc_api() { return kManagerApi; } + +#endif // UVCPP_DB_RUNTIME_LOAD + +namespace { + +/// 那把函数表,短名字。下面每一处对驱动管理器的调用都写成 `api().SQLxxx(...)`。 +const odbc_api& api() { return uvcpp_db_odbc_api(); } + +// --------------------------------------------------------------------------- +// UTF-8 <-> SQLWCHAR +// +// 四个后端里只有 ODBC 需要这一层:SQLite 收 UTF-8 字节串、libpq 收 UTF-8 字节串、 +// MySQL 也能收 UTF-8;而 ODBC 的宽接口收的是 `SQLWCHAR`。自己写而不引 ICU —— 本仓 +// 的"薄封装"定位(见 doc/db-guide.md)不允许为一次编码转换拖进一个几十 MB 的依赖。 +// --------------------------------------------------------------------------- + +/// 取下一个 UTF-8 码点。`*i` 是读位置;越界或非法序列返回 false 并把 `*i` 推到 +/// 能确认的边界,调用方据此截断。 +/// +/// 非法序列**不抛也不替代**:截断在这里,调用方拿到的是转换到那一处为止的结果。 +/// 与"猜一个字符补上"相比,截断至少不会把编造的数据写进库里。 +bool utf8_next(const std::string& s, size_t* i, uint32_t* cp) { + if (*i >= s.size()) return false; + const unsigned char c0 = static_cast(s[*i]); + size_t extra = 0; + uint32_t v = 0; + if (c0 < 0x80) { + *cp = c0; + *i += 1; + return true; + } else if ((c0 & 0xE0) == 0xC0) { + extra = 1; + v = c0 & 0x1Fu; + } else if ((c0 & 0xF0) == 0xE0) { + extra = 2; + v = c0 & 0x0Fu; + } else if ((c0 & 0xF8) == 0xF0) { + extra = 3; + v = c0 & 0x07u; + } else { + *i += 1; // 落单的续字节,或 5 字节以上的老式序列 + return false; + } + if (*i + 1 + extra > s.size()) { // 尾部被截断的多字节序列 + *i = s.size(); + return false; + } + for (size_t k = 1; k <= extra; ++k) { + const unsigned char ck = static_cast(s[*i + k]); + if ((ck & 0xC0) != 0x80) { + *i += 1; + return false; + } + v = (v << 6) | (ck & 0x3Fu); + } + *i += 1 + extra; + *cp = v; + return true; +} + +/// UTF-8 串 -> `SQLWCHAR` 串。 +/// +/// `sizeof(SQLWCHAR)` 为 2 时写 UTF-16(含代理对),为 4 时直接写 UTF-32。超出 +/// U+10FFFF 的码点按替换字符处理 —— 那种码点不存在,写进去只会让驱动端报一个 +/// 位置很难定位的错。 +std::basic_string to_sqlwchar(const std::string& utf8) { + std::basic_string out; + out.reserve(utf8.size()); + size_t i = 0; + uint32_t cp = 0; + while (utf8_next(utf8, &i, &cp)) { + if (cp > 0x10FFFF) cp = 0xFFFD; + if (sizeof(SQLWCHAR) == 2) { + if (cp <= 0xFFFF) { + out.push_back(static_cast(cp)); + } else { + const uint32_t v = cp - 0x10000; + out.push_back(static_cast(0xD800 + (v >> 10))); + out.push_back(static_cast(0xDC00 + (v & 0x3FF))); + } + } else { + out.push_back(static_cast(cp)); + } + } + return out; +} + +/// `SQLWCHAR` 串 -> UTF-8 串。`n` 是**字符个数**,不是字节数 —— ODBC 的长度指示器 +/// 一律按字符计(唯一例外是 `SQL_C_WCHAR` 的返回长度,见 `read_text_column`), +/// 把字符数当字节数用会把中文尾部整段截掉。 +std::string to_utf8(const SQLWCHAR* p, size_t n) { + std::string out; + if (!p) return out; + out.reserve(n * 3 / 2 + 1); + for (size_t k = 0; k < n; ++k) { + uint32_t cp = static_cast(p[k]); + if (sizeof(SQLWCHAR) == 2) { + if (cp >= 0xD800 && cp <= 0xDBFF && k + 1 < n) { + const uint32_t lo = static_cast(p[k + 1]); + if (lo >= 0xDC00 && lo <= 0xDFFF) { + cp = 0x10000 + ((cp - 0xD800) << 10) + (lo - 0xDC00); + ++k; + } + } else if (cp >= 0xD800 && cp <= 0xDFFF) { + cp = 0xFFFD; // 落单的代理码元 + } + } + if (cp < 0x80) { + out.push_back(static_cast(cp)); + } else if (cp < 0x800) { + out.push_back(static_cast(0xC0 | (cp >> 6))); + out.push_back(static_cast(0x80 | (cp & 0x3F))); + } else if (cp < 0x10000) { + out.push_back(static_cast(0xE0 | (cp >> 12))); + out.push_back(static_cast(0x80 | ((cp >> 6) & 0x3F))); + out.push_back(static_cast(0x80 | (cp & 0x3F))); + } else { + out.push_back(static_cast(0xF0 | (cp >> 18))); + out.push_back(static_cast(0x80 | ((cp >> 12) & 0x3F))); + out.push_back(static_cast(0x80 | ((cp >> 6) & 0x3F))); + out.push_back(static_cast(0x80 | (cp & 0x3F))); + } + } + return out; +} + +/// 一个 `SQLWCHAR` 串 + 它的**字符**长度,喂给 ODBC 用。 +/// +/// 一律显式传长度、不传 `SQL_NTS`:后者要求串以 0 结尾,而 SQL 文本里出现 +/// U+0000 是可能的(字符串字面量里的二进制)。传长度是唯一正确的喂法。 +class wstr { + public: + explicit wstr(const std::string& utf8) : s_(to_sqlwchar(utf8)) {} + SQLWCHAR* p() { return s_.empty() ? nullptr : &s_[0]; } + /// `SQLExecDirectW` / `SQLPrepareW` 的 TextLength 是 `SQLINTEGER`。 + SQLINTEGER len() const { return static_cast(s_.size()); } + /// `SQLTablesW` / `SQLColumnsW` / `SQLPrimaryKeysW` 的名字长度是 `SQLSMALLINT`。 + /// 两个都留着而不是只留一个再到处 cast:这两个宽度在 ODBC 里就是不一样的, + /// 让调用点写成它真正要的那个类型,比在每个调用点写 `static_cast` 更难写错。 + SQLSMALLINT slen() const { + return static_cast(s_.size()); + } + + private: + std::basic_string s_; +}; + +// --------------------------------------------------------------------------- +// 诊断 +// --------------------------------------------------------------------------- + +/// 把一个 ODBC 句柄上挂着的**全部**诊断记录拼成一句话。 +/// +/// 只取第一条是不够的:SQL Server 的报错常常第一条是"语句无效"、第二条才是 +/// "对象名 'dbo.检测收费' 无效",而真正要看的是第二条。 +std::string odbc_diag(SQLSMALLINT handle_type, SQLHANDLE handle) { + std::string out; + SQLWCHAR state[8]; + SQLWCHAR msg[1024]; + SQLINTEGER native = 0; + SQLSMALLINT len = 0; + for (SQLSMALLINT rec = 1;; ++rec) { + const SQLRETURN rc = api().SQLGetDiagRecW( + handle_type, handle, rec, state, &native, msg, + static_cast(sizeof(msg) / sizeof(msg[0]) - 1), &len); + if (rc != SQL_SUCCESS && rc != SQL_SUCCESS_WITH_INFO) break; + if (!out.empty()) out += ";"; + out += "["; + out += to_utf8(state, 5); + out += "] "; + out += to_utf8(msg, static_cast(len)); + } + if (out.empty()) out = "(驱动没给出诊断信息)"; + return out; +} + +/// 第一诊断记录的 SQLSTATE,取不到给空串。 +std::string odbc_sqlstate(SQLSMALLINT handle_type, SQLHANDLE handle) { + SQLWCHAR state[8]; + SQLINTEGER native = 0; + SQLSMALLINT len = 0; + if (!SQL_SUCCEEDED(api().SQLGetDiagRecW(handle_type, handle, 1, state, &native, + nullptr, 0, &len))) { + return std::string(); + } + return to_utf8(state, 5); +} + +constexpr int kDefaultTimeoutMs = 5000; +/// `SQLGetData` 的分块大小(单位是 `SQLWCHAR` / 字节)。取 2048 是个折中: +/// 报表行几十个字段时不会反复扩容,32 KB 的文本也只要 16 次往返。 +constexpr size_t kChunk = 2048; +/// 超过这个长度就走 LONG 类型。SQL Server 的 nvarchar 上限是 4000,报成 +/// `SQL_WVARCHAR` 送长文本时驱动会报"字符串被截断"。 +constexpr size_t kMaxPlainChars = 4000; + +/// `SQLDescribeCol` 的结果,够本驱动映射值类型用了。 +struct column_desc { + std::string name; + SQLSMALLINT data_type = SQL_UNKNOWN_TYPE; + SQLSMALLINT nullable = SQL_NULLABLE_UNKNOWN; + SQLULEN size = 0; +}; + +/// SQL 摘要,进报错信息用。整条 SQL 可能上万字符,日志里留前 120 字足够定位。 +std::string sql_brief(const std::string& sql) { + std::string one; + one.reserve(sql.size()); + for (size_t i = 0; i < sql.size(); ++i) { + const char c = sql[i]; + one.push_back(c == '\n' || c == '\r' || c == '\t' ? ' ' : c); + } + if (one.size() <= 120) return one; + return one.substr(0, 120) + "…"; +} + +/// 空串 / 空 BLOB 绑定时交给驱动的那枚**有效**指针。 +/// +/// 不能用 `nullptr` 顶替"零长度":实测 sqliteodbc 上 `nullptr` + 长度 0 会被存成 +/// **NULL**,而"有效指针 + 长度 0"才存成空串 —— 于是 `e_val NOT NULL DEFAULT ''` +/// 那种列直接报约束冲突,症状是"插入失败",离真正的原因(缓冲指针是空的)隔着 +/// 一层。规范上 `SQL_NULL_DATA` 才是 NULL 的表达,长度 0 是空串;有的驱动把 +/// "缓冲是空指针"也当成 NULL 的一种写法,这一手就是防它。 +/// +/// 一字节哨兵够用:长度是 0,驱动一个字节都不会读,更不会写它 —— 所以几个线程 +/// 同时绑各自的空串也安全(这几个全局量从头到尾只被读)。 +unsigned char kEmptyByte = 0; +SQLWCHAR kEmptyWide = 0; + +} // namespace + +class odbc_driver : public uvcpp_db_driver { + public: + odbc_driver() {} + ~odbc_driver() { close(); } + + const char* name() const { return "odbc"; } + bool is_open() const { return dbc_ != nullptr; } + + uvcpp_db_status open(const uvcpp_db_url& url, std::string* err) { + // 驱动管理器没到手就没什么可做的了(`UVCPP_DB_RUNTIME_LOAD=OFF` 时这一句永远 + // 是空串,因为那时符号是链接期解析的)—— 报的是加载那句人话,不是"连接失败"。 + const std::string& load_error = uvcpp_db_odbc_load_error(); + if (!load_error.empty()) { + *err = load_error; + return uvcpp_db_status::NO_DRIVER; + } + + close(); + + // 每个连接一份 ENV。共享一份能省一点,但要引入引用计数和一条"谁最后走谁 + // 释放"的时序 —— 而 `uvcpp_db_client` 本来就是一个连接一个驱动对象, + // 省下的那点内存换不来这份复杂度。 + const uvcpp_db_status ost = open_env(err); + if (!uvcpp_db_ok(ost)) return ost; + + SQLRETURN rc = api().SQLAllocHandle(SQL_HANDLE_DBC, env_, &dbc_); + if (!SQL_SUCCEEDED(rc)) { + *err = "分配 ODBC 连接句柄失败:" + odbc_diag(SQL_HANDLE_ENV, env_); + release_all(); + return uvcpp_db_status::OPEN_FAILED; + } + + // 只读模式:在**连接之前**设下去。 + // + // 这不是本模块的护栏(护栏该在应用层),而是"这道连接只用来查"这件事让驱动 + // 也记一份 —— 哪天上层的只读判断漏了,这里还有一道。ODBC 没规定驱动必须实现 + // 它,不支持的驱动会返回错误,所以失败**当警告吞掉**:一个不支持只读模式的 + // 数据源不该因此完全连不上。 + api().SQLSetConnectAttr( + dbc_, SQL_ATTR_ACCESS_MODE, + reinterpret_cast(static_cast(SQL_MODE_READ_ONLY)), + 0); + + const int login_s = login_timeout_ms_ / 1000; + if (login_s > 0) { + api().SQLSetConnectAttr( + dbc_, SQL_ATTR_LOGIN_TIMEOUT, + reinterpret_cast(static_cast(login_s)), 0); + } + + const std::string conn = build_connection_string(url); + wstr wconn(conn); + SQLWCHAR out_buf[1024]; + SQLSMALLINT out_len = 0; + rc = api().SQLDriverConnectW( + dbc_, nullptr, wconn.p(), wconn.slen(), out_buf, + static_cast(sizeof(out_buf) / sizeof(out_buf[0]) - 1), + &out_len, SQL_DRIVER_NOPROMPT); + if (!SQL_SUCCEEDED(rc)) { + *err = "ODBC 连接失败:" + odbc_diag(SQL_HANDLE_DBC, dbc_) + + "(连接串:" + redact(conn) + ")"; + release_all(); + return uvcpp_db_status::OPEN_FAILED; + } + + quote_ = query_identifier_quote(); + + // 自动提交从"开"起步:事务由 begin/commit/rollback 显式开合。不预设成关, + // 是因为 `uvcpp_db_client` 之外还有人直接用 query(),那种用法下"忘了 commit" + // 比"没事务"更难查。 + in_transaction_ = false; + return uvcpp_db_status::OK; + } + + void close() { + if (dbc_) { + if (in_transaction_) { + // 开关事务的连接被直接关掉时,ODBC 的行为是"回滚",这里显式结束一次, + // 不把结果交给实现细节。 + api().SQLEndTran(SQL_HANDLE_DBC, dbc_, SQL_ROLLBACK); + in_transaction_ = false; + } + api().SQLDisconnect(dbc_); + } + release_all(); + } + + uvcpp_db_status ping(std::string* err) { + if (!dbc_) { + *err = "ODBC 连接没打开"; + return uvcpp_db_status::NOT_CONNECTED; + } + // 真发一次极轻的往返,而不是只看本地句柄非空 —— 连接被防火墙/NAT 掐掉时 + // 本地句柄照样非空。 + SQLHSTMT stmt = nullptr; + const uvcpp_db_status ast = alloc_stmt(&stmt, err); + if (!uvcpp_db_ok(ast)) return ast; + const SQLRETURN rc = api().SQLExecDirectW(stmt, const_cast(kPing), + SQL_NTS); + if (!SQL_SUCCEEDED(rc)) { + *err = "ODBC ping 失败:" + odbc_diag(SQL_HANDLE_STMT, stmt); + api().SQLFreeHandle(SQL_HANDLE_STMT, stmt); + return uvcpp_db_status::EXEC_FAILED; + } + const SQLRETURN fr = api().SQLFetch(stmt); + api().SQLFreeHandle(SQL_HANDLE_STMT, stmt); + if (!SQL_SUCCEEDED(fr)) { + *err = "ODBC ping 取不到结果"; + return uvcpp_db_status::EXEC_FAILED; + } + return uvcpp_db_status::OK; + } + + uvcpp_db_status query(const std::string& sql, + const std::vector& params, + uvcpp_db_table* out, std::string* err) { + if (!dbc_) { + *err = "ODBC 连接没打开"; + return uvcpp_db_status::NOT_CONNECTED; + } + if (!out) { + *err = "query 的 out 是空的"; + return uvcpp_db_status::MISUSE; + } + if (sql.empty()) { + *err = "SQL 是空串"; + return uvcpp_db_status::MISUSE; + } + + SQLHSTMT stmt = nullptr; + const uvcpp_db_status ast = alloc_stmt(&stmt, err); + if (!uvcpp_db_ok(ast)) return ast; + const uvcpp_db_status est = exec_on(stmt, sql, params, err); + if (!uvcpp_db_ok(est)) { + api().SQLFreeHandle(SQL_HANDLE_STMT, stmt); + return est; + } + const uvcpp_db_status rst = fetch_all(stmt, out, err); + api().SQLFreeHandle(SQL_HANDLE_STMT, stmt); + return rst; + } + + uvcpp_db_status execute(const std::string& sql, + const std::vector& params, + int64_t* affected, std::string* err) { + if (affected) *affected = -1; + if (!dbc_) { + *err = "ODBC 连接没打开"; + return uvcpp_db_status::NOT_CONNECTED; + } + if (sql.empty()) { + *err = "SQL 是空串"; + return uvcpp_db_status::MISUSE; + } + + SQLHSTMT stmt = nullptr; + const uvcpp_db_status ast = alloc_stmt(&stmt, err); + if (!uvcpp_db_ok(ast)) return ast; + const uvcpp_db_status est = exec_on(stmt, sql, params, err); + if (!uvcpp_db_ok(est)) { + api().SQLFreeHandle(SQL_HANDLE_STMT, stmt); + return est; + } + + // DDL 与部分 INSERT 报不出行数,ODBC 用 SQL_NO_TOTAL(-1) 表达,而那正好是 + // "报不出"这个契约(头文件写的:后端报不出时写 -1)。 + SQLLEN rows = 0; + if (affected && SQL_SUCCEEDED(api().SQLRowCount(stmt, &rows)) && rows >= 0) { + *affected = static_cast(rows); + } + api().SQLFreeHandle(SQL_HANDLE_STMT, stmt); + return uvcpp_db_status::OK; + } + + uvcpp_db_status last_insert_id(uvcpp_db_value* out, std::string* err) { + if (!out) { + *err = "last_insert_id 的 out 是空的"; + return uvcpp_db_status::MISUSE; + } + out->set_null(); + if (!dbc_) { + *err = "ODBC 连接没打开"; + return uvcpp_db_status::NOT_CONNECTED; + } + // SQL Server 的正解是 `SCOPE_IDENTITY()`:只认**本作用域**里刚插的那行。 + // `@@IDENTITY` 会被触发器里的插入顶掉(插 A 表却拿到触发器写进 B 表的 id), + // 那是这条路上最容易踩的错。别的数据源不认 SCOPE_IDENTITY 时会报错,那时 + // 如实返回 UNSUPPORTED —— 契约里"报不出"就是这个码,不是硬编一个 0。 + SQLHSTMT stmt = nullptr; + const uvcpp_db_status ast = alloc_stmt(&stmt, err); + if (!uvcpp_db_ok(ast)) return ast; + const SQLRETURN rc = api().SQLExecDirectW( + stmt, const_cast(kScopeIdentity), SQL_NTS); + if (!SQL_SUCCEEDED(rc)) { + api().SQLFreeHandle(SQL_HANDLE_STMT, stmt); + *err = "这个数据源报不出自增值(SCOPE_IDENTITY 不被支持)"; + return uvcpp_db_status::UNSUPPORTED; + } + bool is_null = false; + int64_t v = 0; + const uvcpp_db_status st = read_scalar_int64(stmt, 1, &v, &is_null, err); + api().SQLFreeHandle(SQL_HANDLE_STMT, stmt); + if (!uvcpp_db_ok(st)) return st; + if (is_null) { + *err = "这条连接还没成功 INSERT 过,拿不到自增 id"; + return uvcpp_db_status::UNSUPPORTED; + } + out->set_int64(v); + return uvcpp_db_status::OK; + } + + uvcpp_db_status begin(std::string* err) { + if (!dbc_) { + *err = "ODBC 连接没打开"; + return uvcpp_db_status::NOT_CONNECTED; + } + // ODBC 开事务的动作是"关自动提交",而重复关一次**不报错** —— 所以另三个 + // 后端那种"重复 begin 由后端报错"在这里不会自然发生,得自己拦。 + // + // 拦在这里是有意的:静默把两层事务折成一层,调用方内层的 rollback 会把外层的 + // 写入一起回滚掉,而它以为自己只撤了内层。 + if (in_transaction_) { + *err = "事务已经开着,本模块不支持嵌套事务"; + return uvcpp_db_status::EXEC_FAILED; + } + const SQLRETURN rc = api().SQLSetConnectAttr( + dbc_, SQL_ATTR_AUTOCOMMIT, + reinterpret_cast( + static_cast(SQL_AUTOCOMMIT_OFF)), + 0); + if (!SQL_SUCCEEDED(rc)) { + *err = "开启 ODBC 事务失败:" + odbc_diag(SQL_HANDLE_DBC, dbc_); + return uvcpp_db_status::EXEC_FAILED; + } + in_transaction_ = true; + return uvcpp_db_status::OK; + } + + uvcpp_db_status commit(std::string* err) { return end_tran(SQL_COMMIT, err); } + uvcpp_db_status rollback(std::string* err) { + return end_tran(SQL_ROLLBACK, err); + } + + /// 转义**内容**,不含两侧引号。 + /// + /// ODBC 没有"转义一个字符串字面量"的接口 —— 转义规则取决于下面接的是哪个 + /// 数据源。所以这里按 SQL 标准的做法办:单引号翻倍,反斜杠**不动**。反斜杠是 + /// MySQL 的规矩,在 SQL Server / PostgreSQL 上改它就是**改变数据**(`a\n` + /// 会存成 `an`)。 + /// + /// 要绝对稳妥用 `query(sql, params, ...)` 的参数绑定 —— 那条路才是本模块推荐给 + /// 调用方的,`escape()` 只给"拼 DDL、拼 IN 列表"这类绕不开的场合。 + std::string escape(const std::string& text) { + std::string out; + out.reserve(text.size() + 8); + for (size_t i = 0; i < text.size(); ++i) { + if (text[i] == '\'') out.push_back('\''); + out.push_back(text[i]); + } + return out; + } + + std::string quote_identifier(const std::string& ident) { + // 引号字符来自 `SQLGetInfo`,不是写死的 —— 这正是"通用驱动"该有的样子: + // SQL Server 报 `"`,有的数据源报 `` ` ``。 + // + // 它是**串**而不是字符(ODBC 的类型就是串),虽然现实中总是单字符。所以 + // 两侧按整串包,内部的转义只在单字符时做 —— 多字符引号没有"翻倍"这个说法, + // 硬编一种做法反而会在那种数据源上产生语法错的标识符。 + std::string out; + out.reserve(ident.size() + 2 * quote_.size()); + out += quote_; + if (quote_.size() == 1) { + for (size_t i = 0; i < ident.size(); ++i) { + if (ident[i] == quote_[0]) out += quote_; + out.push_back(ident[i]); + } + } else { + out += ident; + } + out += quote_; + return out; + } + + uvcpp_db_status table_names(std::vector* out, std::string* err) { + if (!out) { + *err = "table_names 的 out 是空的"; + return uvcpp_db_status::MISUSE; + } + if (!dbc_) { + *err = "ODBC 连接没打开"; + return uvcpp_db_status::NOT_CONNECTED; + } + out->clear(); + + SQLHSTMT stmt = nullptr; + const uvcpp_db_status ast = alloc_stmt(&stmt, err); + if (!uvcpp_db_ok(ast)) return ast; + + // SQLTables 的四组 (串, 长度) 依序是 目录 / 模式 / 表名 / **类型**。前三个给 + // NULL = 不过滤(当前库、全部模式、全部名字),只有第四组钉住。 + // + // 顺序在这里是很容易写错的一处:把 "TABLE" 填到**表名**那一格(第三组)会让 + // 这个函数安静地只列出名字叫 "TABLE" 的表 —— 不报错,只是结果是空的。 + static const SQLWCHAR kTableType[] = {'T', 'A', 'B', 'L', 'E', 0}; + const SQLRETURN rc = + api().SQLTablesW(stmt, nullptr, 0, nullptr, 0, nullptr, 0, + const_cast(kTableType), SQL_NTS); + if (!SQL_SUCCEEDED(rc)) { + *err = "ODBC 列不出表:" + odbc_diag(SQL_HANDLE_STMT, stmt); + api().SQLFreeHandle(SQL_HANDLE_STMT, stmt); + return uvcpp_db_status::EXEC_FAILED; + } + + // 结果集是 TABLE_CAT / TABLE_SCHEM / TABLE_NAME / TABLE_TYPE / REMARKS, + // 只取**表名**(不拼 `schema.table`),与另三个后端口径一致;套件钉的是 + // "刚建的表能被列出来",拼上模式名会让它匹配不上。 + std::vector names; + for (;;) { + const SQLRETURN fr = api().SQLFetch(stmt); + if (fr == SQL_NO_DATA) break; + if (!SQL_SUCCEEDED(fr)) { + *err = "ODBC 取表名失败:" + odbc_diag(SQL_HANDLE_STMT, stmt); + api().SQLFreeHandle(SQL_HANDLE_STMT, stmt); + return uvcpp_db_status::EXEC_FAILED; + } + bool is_null = false; + std::string name; + const uvcpp_db_status st = read_text_column(stmt, 3, &name, &is_null, err); + if (!uvcpp_db_ok(st)) { + api().SQLFreeHandle(SQL_HANDLE_STMT, stmt); + return st; + } + if (!is_null && !name.empty()) names.push_back(name); + } + api().SQLFreeHandle(SQL_HANDLE_STMT, stmt); + + // 排序去重放在本地做:SQLTables 的顺序由驱动决定,而同一个库在 msodbcsql 与 + // FreeTDS 下给出的顺序不一样 —— 那会让"同一份操作在四个后端得到同一份结果" + // 在表清单这一项上破掉。另外多模式库里同名表会出现多次,也在这里收掉。 + std::sort(names.begin(), names.end()); + names.erase(std::unique(names.begin(), names.end()), names.end()); + *out = std::move(names); + return uvcpp_db_status::OK; + } + + uvcpp_db_status table_schema(const std::string& table, uvcpp_db_table* out, + std::string* err) { + if (!out) { + *err = "table_schema 的 out 是空的"; + return uvcpp_db_status::MISUSE; + } + if (!dbc_) { + *err = "ODBC 连接没打开"; + return uvcpp_db_status::NOT_CONNECTED; + } + + // 主键列先单独取一份,下面标 key 用 —— SQLColumns 的结果里没有主键信息。 + std::vector pk_columns; + { + SQLHSTMT pk = nullptr; + if (uvcpp_db_ok(alloc_stmt(&pk, err))) { + wstr wt(table); + if (SQL_SUCCEEDED(api().SQLPrimaryKeysW(pk, nullptr, 0, nullptr, 0, wt.p(), + wt.slen()))) { + for (;;) { + const SQLRETURN fr = api().SQLFetch(pk); + if (fr == SQL_NO_DATA) break; + if (!SQL_SUCCEEDED(fr)) break; + bool is_null = false; + std::string name; + // 结果是 CAT/SCHEM/TABLE/COLUMN_NAME/KEY_SEQ/PK_NAME,列名在第 4 位。 + if (uvcpp_db_ok(read_text_column(pk, 4, &name, &is_null, err)) && + !is_null) { + pk_columns.push_back(name); + } + } + } + api().SQLFreeHandle(SQL_HANDLE_STMT, pk); + } + // 取不到主键不是错误:视图与无主键的表都取不到,而它们照样有列。 + } + + SQLHSTMT stmt = nullptr; + const uvcpp_db_status ast = alloc_stmt(&stmt, err); + if (!uvcpp_db_ok(ast)) return ast; + wstr wt(table); + const SQLRETURN rc = api().SQLColumnsW(stmt, nullptr, 0, nullptr, 0, wt.p(), + wt.slen(), nullptr, 0); + if (!SQL_SUCCEEDED(rc)) { + *err = "ODBC 取表结构失败:" + odbc_diag(SQL_HANDLE_STMT, stmt); + api().SQLFreeHandle(SQL_HANDLE_STMT, stmt); + return uvcpp_db_status::EXEC_FAILED; + } + + // 归一成四个后端共同的形状:name / type / nullable / key / default。 + // 列序号按 ODBC 3.x 的 SQLColumns 结果集定义(1 起): + // 4 COLUMN_NAME / 6 TYPE_NAME / 11 NULLABLE / 13 COLUMN_DEF + out->clear(); + out->reset_columns({"name", "type", "nullable", "key", "default"}); + out->set_table_name(table); + + for (;;) { + const SQLRETURN fr = api().SQLFetch(stmt); + if (fr == SQL_NO_DATA) break; + if (!SQL_SUCCEEDED(fr)) { + *err = "ODBC 取表结构失败:" + odbc_diag(SQL_HANDLE_STMT, stmt); + api().SQLFreeHandle(SQL_HANDLE_STMT, stmt); + return uvcpp_db_status::EXEC_FAILED; + } + bool null_name = false, null_type = false, null_def = false; + std::string col_name, type_name, column_def; + uvcpp_db_status st = read_text_column(stmt, 4, &col_name, &null_name, err); + if (uvcpp_db_ok(st)) { + st = read_text_column(stmt, 6, &type_name, &null_type, err); + } + if (uvcpp_db_ok(st)) { + st = read_text_column(stmt, 13, &column_def, &null_def, err); + } + if (!uvcpp_db_ok(st)) { + api().SQLFreeHandle(SQL_HANDLE_STMT, stmt); + return st; + } + bool null_nullable = false; + int64_t nullable = 0; + st = read_scalar_int64(stmt, 11, &nullable, &null_nullable, err); + if (!uvcpp_db_ok(st)) { + api().SQLFreeHandle(SQL_HANDLE_STMT, stmt); + return st; + } + + const bool in_pk = + std::find(pk_columns.begin(), pk_columns.end(), col_name) != + pk_columns.end(); + + uvcpp_db_row& row = out->add_row(); + row.push(uvcpp_db_value(col_name)); + row.push(uvcpp_db_value(type_name)); + // NULLABLE 是 SQL_NO_NULLS(0) / SQL_NULLABLE(1) / SQL_NULLABLE_UNKNOWN(2)。 + // 未知按"可空"报:报 NO 会让调用方以为这列一定有值,报 YES 最多让人多判 + // 一次空 —— 两种错的代价不对称。 + row.push(uvcpp_db_value(nullable == SQL_NO_NULLS ? "NO" : "YES")); + row.push(uvcpp_db_value(in_pk ? "PRI" : "")); + row.push(null_def ? uvcpp_db_value() : uvcpp_db_value(column_def)); + } + api().SQLFreeHandle(SQL_HANDLE_STMT, stmt); + return uvcpp_db_status::OK; + } + + void set_timeout_ms(int ms) { + timeout_ms_ = ms > 0 ? ms : 0; + // 登录超时单独一份:查询超时是每语句的,管不到 TCP 建连那一段。 + login_timeout_ms_ = ms > 0 ? ms : kDefaultTimeoutMs; + } + + private: + // ------------------------------------------------------------------------- + // 连接串 + // ------------------------------------------------------------------------- + + /// 把本模块的 URL 拼成一条 ODBC 连接串。 + /// + /// 形状(`odbc://` 后面照旧是 `user:password@host:port/dbname?opts`): + /// + /// odbc://sa:pw@172.25.183.9:1433/SYSDB_GB38900 + /// odbc://sa:pw@host/db?Driver=ODBC+Driver+17+for+SQL+Server + /// odbc:///DSN=我的数据源;Uid=u;Pwd=p ← `?` 之后原样进串 + /// odbc://user@host/db ← 不给口令 = Windows 集成认证 + /// + /// 两条规矩: + /// * `?` 之后的原文**原样**进串,所以写 `?DSN=x`、`?Driver=y`,或写一整条 + /// ODBC 串都行; + /// * URL 里给出的 host/port/database/user/password 只在**串里还没有**同名键时 + /// 才补进去 —— 这样 `?Server=别的机器` 能盖掉 URL 里的 host,而不是出现两个 + /// Server 键(ODBC 对重复键的行为各家不一,别赌)。 + std::string build_connection_string(const uvcpp_db_url& url) const { + const std::vector present = scan_keys(url.options); + struct has_key { + const std::vector* keys; + bool operator()(const char* key) const { + for (size_t i = 0; i < keys->size(); ++i) { + if (equals_ci((*keys)[i], key)) return true; + } + return false; + } + }; + const has_key has = {&present}; + + std::vector > kv; + if (!has("Server") && !url.host.empty()) { + // `tcp:host,port` 是 SQL Server 的写法;别的驱动认不出 `,port` 会忽略它或 + // 报错 —— 这是"通用驱动"在连接串这一层的固有代价,写进注释而不是藏起来。 + kv.push_back(std::make_pair( + std::string("Server"), + url.port ? "tcp:" + url.host + "," + std::to_string(url.port) + : "tcp:" + url.host)); + } + if (!has("Database") && !url.database.empty()) { + kv.push_back(std::make_pair(std::string("Database"), url.database)); + } + if (!has("Uid") && !has("User") && !url.user.empty()) { + kv.push_back(std::make_pair(std::string("Uid"), url.user)); + } + if (!has("Pwd") && !has("Password") && !url.password.empty()) { + kv.push_back(std::make_pair(std::string("Pwd"), url.password)); + } + + const bool no_credentials = url.user.empty() && !has("Uid") && !has("User"); + if (no_credentials && !has("Trusted_Connection")) { + // 不给账号 = 走 Windows 集成认证。这比"忘了填口令就报错"更好:集成认证本来 + // 就是同域部署下更该用的方式(没有口令进连接串,也就不会进日志)。 + kv.push_back( + std::make_pair(std::string("Trusted_Connection"), std::string("yes"))); + } + + std::string out; + for (size_t i = 0; i < kv.size(); ++i) { + out += kv[i].first; + out += "="; + out += kv[i].second; + out += ";"; + } + + // 默认驱动名。**只是默认**:`?Driver=` 或 `?DSN=` 一写就换掉,换成任何 ODBC + // 数据源都行。选它当默认是因为本后端的主要用途是 SQL Server,而这个驱动名是 + // 微软在 Windows 与 Linux 上统一的正式名字。 + if (!has("DSN") && !has("Driver") && !has("FILEDSN")) { + out += "Driver={ODBC Driver 18 for SQL Server};"; + if (!has("Encrypt")) { + // 18 版默认强制加密,而内网库多半没配证书 —— 不显式关掉的话,使用者会先 + // 撞上一个"证书不受信任"的握手错误,然后去怀疑连接串写错了。 + out += "Encrypt=no;"; + } + } + + // `?` 之后的原文最后追加。上面的 has() 已经保证这里不会与前面的键重名。 + if (!url.options.empty()) { + out += url.options; + if (!out.empty() && out[out.size() - 1] != ';') out += ";"; + } + return out; + } + + /// 报错里回显连接串时把口令抹掉 —— 连接串是会进日志的。 + static std::string redact(const std::string& conn) { + static const char* const kKeys[] = {"Pwd=", "Password=", "PWD="}; + std::string out = conn; + for (size_t k = 0; k < sizeof(kKeys) / sizeof(kKeys[0]); ++k) { + size_t pos = 0; + while ((pos = out.find(kKeys[k], pos)) != std::string::npos) { + const size_t begin = pos + std::strlen(kKeys[k]); + size_t end = out.find(';', begin); + if (end == std::string::npos) end = out.size(); + out.replace(begin, end - begin, "***"); + pos = begin + 3; + } + } + return out; + } + + static bool equals_ci(const std::string& a, const char* b) { + const size_t n = std::strlen(b); + if (a.size() != n) return false; + for (size_t i = 0; i < n; ++i) { + char x = a[i], y = b[i]; + if (x >= 'A' && x <= 'Z') x = static_cast(x - 'A' + 'a'); + if (y >= 'A' && y <= 'Z') y = static_cast(y - 'A' + 'a'); + if (x != y) return false; + } + return true; + } + + /// 从 `Key=Value;Key=Value` 里抠出键名。 + static std::vector scan_keys(const std::string& options) { + std::vector keys; + size_t pos = 0; + while (pos < options.size()) { + size_t semi = options.find(';', pos); + if (semi == std::string::npos) semi = options.size(); + const std::string item = options.substr(pos, semi - pos); + const size_t eq = item.find('='); + if (eq != std::string::npos && eq > 0) { + std::string key = item.substr(0, eq); + while (!key.empty() && + (key[key.size() - 1] == ' ' || key[key.size() - 1] == '\t')) { + key.erase(key.size() - 1); + } + if (!key.empty()) keys.push_back(key); + } + pos = semi + 1; + } + return keys; + } + + // ------------------------------------------------------------------------- + // 句柄 + // ------------------------------------------------------------------------- + + uvcpp_db_status open_env(std::string* err) { + SQLRETURN rc = api().SQLAllocHandle(SQL_HANDLE_ENV, SQL_NULL_HANDLE, &env_); + if (!SQL_SUCCEEDED(rc)) { + *err = "分配 ODBC 环境句柄失败(装了 ODBC 驱动管理器吗?)"; + return uvcpp_db_status::NO_DRIVER; + } + // 声明 ODBC 3.x 而不是驱动管理器的默认值:不声明时拿到的可能是 ODBC 2 的 + // 行为,而 SQLColumns / 诊断记录的形状在 ODBC 2 与 3 下不一样。 + rc = api().SQLSetEnvAttr(env_, SQL_ATTR_ODBC_VERSION, + reinterpret_cast( + static_cast(SQL_OV_ODBC3)), + 0); + if (!SQL_SUCCEEDED(rc)) { + *err = "这个 ODBC 驱动管理器不接受 ODBC 3.x:" + + odbc_diag(SQL_HANDLE_ENV, env_); + release_env(); + return uvcpp_db_status::NO_DRIVER; + } + return uvcpp_db_status::OK; + } + + void release_env() { + if (env_) { + api().SQLFreeHandle(SQL_HANDLE_ENV, env_); + env_ = nullptr; + } + } + + void release_all() { + if (dbc_) { + api().SQLFreeHandle(SQL_HANDLE_DBC, dbc_); + dbc_ = nullptr; + } + release_env(); + } + + uvcpp_db_status alloc_stmt(SQLHSTMT* out, std::string* err) { + const SQLRETURN rc = api().SQLAllocHandle(SQL_HANDLE_STMT, dbc_, out); + if (!SQL_SUCCEEDED(rc)) { + *err = "分配 ODBC 语句句柄失败:" + odbc_diag(SQL_HANDLE_DBC, dbc_); + return uvcpp_db_status::NOT_CONNECTED; + } + if (timeout_ms_ > 0) { + // SQL_ATTR_QUERY_TIMEOUT 的单位是**秒**,且"0 = 不超时"。不足一秒的取值 + // 向上取整成 1 —— 退成 0 会变成永不超时,那不是调用方要的。 + int secs = (timeout_ms_ + 999) / 1000; + if (secs < 1) secs = 1; + api().SQLSetStmtAttr(*out, SQL_ATTR_QUERY_TIMEOUT, + reinterpret_cast(static_cast(secs)), 0); + } + return uvcpp_db_status::OK; + } + + std::string query_identifier_quote() { + SQLWCHAR buf[16]; + SQLSMALLINT len = 0; + const SQLRETURN rc = api().SQLGetInfoW(dbc_, SQL_IDENTIFIER_QUOTE_CHAR, buf, + sizeof(buf), &len); + if (!SQL_SUCCEEDED(rc)) return "\""; + + // 取串用**结尾 NUL**,不用 `len`。 + // + // `SQLGetInfo` 的 `len` 报的是**字节数**(W 形态也是),按"字符个数"用它就会 + // 把结尾那个 NUL 也当内容收进引号串里 —— 于是 `quote_` 从 1 个字符变成 + // 2 个(`"` 后面跟一个 NUL),下面 `quote_.size() == 1` 那条"内部引号翻倍" + // 的分支再也进不去。症状是**静默**的:普通标识符照旧能拼(NUL 在驱动那头被 + // 截掉),一旦标识符里真有引号,拼出来的就是语法错的 SQL。实测:表名 + // `uvcpp_odd"name` 拼成 `"uvcpp_odd"name"`,驱动报 unrecognized + // token;引号串 `"`(实测 `len=2` 字节)。 + // + // 结尾 NUL 是规范保证的,而且"报字节数"和"报字符数"两种驱动都自洽 —— 比猜 + // `len` 的单位稳。 + size_t units = 0; + while (units < sizeof(buf) / sizeof(buf[0]) && buf[units] != 0) ++units; + if (units == 0) return "\""; // 空串按标准走 + return to_utf8(buf, units); + } + + // ------------------------------------------------------------------------- + // 执行 + // ------------------------------------------------------------------------- + + uvcpp_db_status exec_on(SQLHSTMT stmt, const std::string& sql, + const std::vector& params, + std::string* err) { + // 无参数走 SQLExecDirect:省一次 prepare,而且 DDL 在有些驱动上压根 + // prepare 不了(MySQL 那边为同一件事单独留了条裸通道,这里同理)。 + // + // 但 SQL 里**有占位符**却一个参数都没给时不能走这条:裸执行下去,有的驱动把 + // 没绑的占位符当 NULL(`WHERE id = ?` 变 `WHERE id = NULL`,静默 0 行),有的 + // 报 "unbound parameters"(`EXEC_FAILED`)。两种都不是要的答案 —— 参数个数 + // 对不上是 `BIND_FAILED`,而且要**在执行之前**报出来。判据取"SQL 里有没有 + // `?`"这个保守扫描:它在字符串字面量里也会命中,代价只是多走一次 prepare, + // 而漏判的代价是静默错数据(实测 sqliteodbc:0 个参数 + 1 个占位符,裸执行 + // 拿到的是 `EXEC_FAILED: unbound parameters in query`)。 + const bool may_bind = !params.empty() || sql.find('?') != std::string::npos; + if (!may_bind) { + wstr wsql(sql); + const SQLRETURN rc = api().SQLExecDirectW(stmt, wsql.p(), wsql.len()); + if (!SQL_SUCCEEDED(rc)) { + *err = "ODBC 执行失败:" + odbc_diag(SQL_HANDLE_STMT, stmt) + + "(SQL:" + sql_brief(sql) + ")"; + // 语法错 / 表不存在 / 对象名无效,ODBC 都在 execute 这一步报,而契约要求 + // 它们是 PREPARE_FAILED。判据取 SQLSTATE 前两位:42 类是语法与访问规则错 + // (42S02 表不存在、42S22 列不存在、42000 语法错)。 + const std::string state = odbc_sqlstate(SQL_HANDLE_STMT, stmt); + return (state.size() >= 2 && state[0] == '4' && state[1] == '2') + ? uvcpp_db_status::PREPARE_FAILED + : uvcpp_db_status::EXEC_FAILED; + } + return uvcpp_db_status::OK; + } + + wstr wsql(sql); + SQLRETURN rc = api().SQLPrepareW(stmt, wsql.p(), wsql.len()); + if (!SQL_SUCCEEDED(rc)) { + *err = "ODBC 准备语句失败:" + odbc_diag(SQL_HANDLE_STMT, stmt) + + "(SQL:" + sql_brief(sql) + ")"; + return uvcpp_db_status::PREPARE_FAILED; + } + + // 占位符个数对不上要在**绑定之前**报出来。这个检查不是多余的:参数少给时有的 + // 驱动会把没绑的占位符当 NULL 用,于是 `WHERE id = ?` 变成 `WHERE id = NULL`, + // 静默返回 0 行 —— 症状是"数据没了",而不是"报错了"。 + SQLSMALLINT want = 0; + rc = api().SQLNumParams(stmt, &want); + if (SQL_SUCCEEDED(rc) && static_cast(want) != params.size()) { + *err = "参数个数对不上:SQL 里 " + std::to_string(want) + " 个占位符,传了 " + + std::to_string(params.size()) + " 个"; + return uvcpp_db_status::BIND_FAILED; + } + + const uvcpp_db_status bst = bind_all(stmt, params, err); + if (!uvcpp_db_ok(bst)) return bst; + + rc = api().SQLExecute(stmt); + if (!SQL_SUCCEEDED(rc)) { + *err = "ODBC 执行失败:" + odbc_diag(SQL_HANDLE_STMT, stmt); + return uvcpp_db_status::EXEC_FAILED; + } + return uvcpp_db_status::OK; + } + + /// 绑定全部参数。 + /// + /// 文本一律送 `SQL_C_WCHAR`(理由见文件头坑二)。数值按各自的 C 类型送,让驱动 + /// 端做转换 —— 全送文本也能跑,但那样服务端拿不到类型信息,索引与隐式转换都会 + /// 退化。 + /// + /// 这里有两处**静默**失败的坑,都踩过: + /// + /// 1. 缓冲是**每个参数一份**(`bound_`),不是一份共用。`SQLBindParameter` 只记 + /// 指针,共用一份的话最后绑的那个参数会把前面每个都覆盖掉 —— 实测四个参数 + /// (1,2,'t0','uu0')/(11,12,'t1','uu1')… 全变成 (2,2,'t','u') 这类值,不报错。 + /// 2. `SQL_C_WCHAR` 的长度指示器单位是**字节**,不是字符。按字符给的症状是长文本 + /// 被砍掉一半(`floor(n/2)`):"张三" 存进去变成 "张",`n` 个字符存 `n/2` 个。 + uvcpp_db_status bind_all(SQLHSTMT stmt, + const std::vector& params, + std::string* err) { + // ★ 一次 resize 到位,然后在下面**只写元素、不增删**:`SQLBindParameter` 交出去 + // 的是 `&bound_[i]` 里那些成员的地址,绑定中途再分配会把它们全变成野指针。 + bound_.clear(); + bound_.resize(params.size()); + + for (size_t i = 0; i < params.size(); ++i) { + const uvcpp_db_value& v = params[i]; + bound_param& b = bound_[i]; + const SQLUSMALLINT index = static_cast(i + 1); + SQLRETURN rc = SQL_SUCCESS; + + if (v.is_null()) { + // 空值的两个条件:C 类型随便(不读缓冲),长度指示器必须是 SQL_NULL_DATA。 + rc = api().SQLBindParameter(stmt, index, SQL_PARAM_INPUT, SQL_C_CHAR, + SQL_VARCHAR, 0, 0, nullptr, 0, &b.null_ind); + } else { + switch (v.type()) { + case uvcpp_db_type::INT64: + b.i64 = v.to_int64(); + rc = api().SQLBindParameter(stmt, index, SQL_PARAM_INPUT, SQL_C_SBIGINT, + SQL_BIGINT, 0, 0, &b.i64, 0, nullptr); + break; + case uvcpp_db_type::UINT64: + b.u64 = v.to_uint64(); + // 超 INT64_MAX 的 uint64 不能塞进 SQL_BIGINT —— 那会在服务端变成负数。 + // 本模块没有"无符号大整数"这种 SQL 参数类型,所以拒绝,而不是截断。 + if (b.u64 > static_cast(INT64_MAX)) { + *err = "第 " + std::to_string(i + 1) + + " 个参数是无符号数且超过 int64 上限,本模块不支持"; + return uvcpp_db_status::BIND_FAILED; + } + rc = api().SQLBindParameter(stmt, index, SQL_PARAM_INPUT, SQL_C_UBIGINT, + SQL_BIGINT, 0, 0, &b.u64, 0, nullptr); + break; + case uvcpp_db_type::DOUBLE: + b.dbl = v.to_double(); + rc = api().SQLBindParameter(stmt, index, SQL_PARAM_INPUT, SQL_C_DOUBLE, + SQL_DOUBLE, 0, 0, &b.dbl, 0, nullptr); + break; + case uvcpp_db_type::BOOL: + b.bit = v.to_bool() ? 1 : 0; + rc = api().SQLBindParameter(stmt, index, SQL_PARAM_INPUT, SQL_C_BIT, + SQL_BIT, 0, 0, &b.bit, 0, nullptr); + break; + case uvcpp_db_type::BLOB: + b.bin = v.bytes(); + b.len = static_cast(b.bin.size()); + rc = api().SQLBindParameter( + stmt, index, SQL_PARAM_INPUT, SQL_C_BINARY, + b.bin.size() > 8000 ? SQL_LONGVARBINARY : SQL_VARBINARY, + static_cast(b.bin.size()), 0, + b.bin.empty() ? static_cast(&kEmptyByte) + : static_cast(&b.bin[0]), + static_cast(b.bin.size()), &b.len); + break; + default: { // TEXT / DATE / TIME / DATETIME + b.text = to_sqlwchar(v.bytes()); + std::basic_string& w = b.text; + // 第 5 个参数(ColumnSize)在 `SQL_C_WCHAR` 下按**字符**数给,第 8 个 + // (BufferLength)与指示器按**字节**数给 —— 两者的单位不一样,这正是 + // 上面坑 2 的来处。 + b.len = static_cast(w.size() * sizeof(SQLWCHAR)); + const bool long_text = w.size() > kMaxPlainChars; + rc = api().SQLBindParameter( + stmt, index, SQL_PARAM_INPUT, SQL_C_WCHAR, + long_text ? SQL_WLONGVARCHAR : SQL_WVARCHAR, + static_cast(w.size()), 0, + w.empty() ? static_cast(&kEmptyWide) + : static_cast(&w[0]), + static_cast(w.size() * sizeof(SQLWCHAR)), &b.len); + break; + } + } + } + + if (!SQL_SUCCEEDED(rc)) { + *err = "绑定第 " + std::to_string(i + 1) + " 个参数失败:" + + odbc_diag(SQL_HANDLE_STMT, stmt); + return uvcpp_db_status::BIND_FAILED; + } + } + return uvcpp_db_status::OK; + } + + // ------------------------------------------------------------------------- + // 取结果 + // ------------------------------------------------------------------------- + + uvcpp_db_status fetch_all(SQLHSTMT stmt, uvcpp_db_table* out, std::string* err) { + SQLSMALLINT column_count = 0; + SQLRETURN rc = api().SQLNumResultCols(stmt, &column_count); + if (!SQL_SUCCEEDED(rc)) { + *err = "ODBC 取结果列数失败:" + odbc_diag(SQL_HANDLE_STMT, stmt); + return uvcpp_db_status::EXEC_FAILED; + } + + // 先把每一列的名字与类型问清楚,再逐行走 SQLGetData(理由见文件头坑三)。 + std::vector cols(static_cast(column_count)); + std::vector names; + names.reserve(static_cast(column_count)); + + for (SQLSMALLINT i = 0; i < column_count; ++i) { + SQLWCHAR name_buf[256]; + SQLSMALLINT name_len = 0; + SQLSMALLINT data_type = SQL_UNKNOWN_TYPE; + SQLULEN column_size = 0; + SQLSMALLINT decimal_digits = 0; + SQLSMALLINT nullable = SQL_NULLABLE_UNKNOWN; + rc = api().SQLDescribeColW( + stmt, static_cast(i + 1), name_buf, + static_cast(sizeof(name_buf) / sizeof(name_buf[0]) - 1), + &name_len, &data_type, &column_size, &decimal_digits, &nullable); + if (!SQL_SUCCEEDED(rc)) { + *err = "ODBC 取列描述失败:" + odbc_diag(SQL_HANDLE_STMT, stmt); + return uvcpp_db_status::EXEC_FAILED; + } + column_desc& ci = cols[static_cast(i)]; + // name_len 可能超过缓冲(列名超 255 字符),那样拿到的是截断的名字 —— 记在 + // 注释里而不加一段重取逻辑:256 字符的列名在真实库里不存在。 + ci.name = to_utf8(name_buf, static_cast(name_len)); + ci.data_type = data_type; + ci.nullable = nullable; + ci.size = column_size; + names.push_back(ci.name); + } + out->reset_columns(std::move(names)); + + for (;;) { + rc = api().SQLFetch(stmt); + if (rc == SQL_NO_DATA) break; + if (!SQL_SUCCEEDED(rc)) { + *err = "ODBC 取行失败:" + odbc_diag(SQL_HANDLE_STMT, stmt); + return uvcpp_db_status::EXEC_FAILED; + } + uvcpp_db_row& row = out->add_row(); + for (size_t i = 0; i < cols.size(); ++i) { + const uvcpp_db_status st = cell_value( + stmt, static_cast(i + 1), cols[i].data_type, &row, err); + if (!uvcpp_db_ok(st)) return st; + } + } + + // 一条语句可能带多个结果集(存储过程里中途再 SELECT 一次就是)。契约只描述 + // "一个结果表",所以只取第一个,但要把剩下的**丢掉**,否则下一次在同一句柄上 + // 执行会撞上"还有未读结果"。 + while (SQL_SUCCEEDED(api().SQLMoreResults(stmt))) { + // 空转,直到没有更多结果集 + } + + // 注意:SELECT 不设 `affected_rows`(与 pgsql 后端的 query 路径一致,那里只有 + // `execute` 的出参才带行数)。套件钉的是同一份操作在四个后端上得到同一份结果, + // 这里单独多填一项就会破。 + return uvcpp_db_status::OK; + } + + /// 读一个定长 C 类型。`buf`/`len` 由调用方按目标类型给。 + /// 返回 false 表示这次 SQLGetData 真出错了(不是 NULL)。 + bool read_fixed(SQLHSTMT stmt, SQLUSMALLINT col, SQLSMALLINT c_type, void* buf, + size_t len, bool* is_null) { + SQLLEN ind = 0; + const SQLRETURN rc = api().SQLGetData(stmt, col, c_type, buf, len, &ind); + if (rc == SQL_NO_DATA || ind == SQL_NULL_DATA) { + *is_null = true; + return true; + } + if (!SQL_SUCCEEDED(rc)) return false; + *is_null = false; + return true; + } + + /// 取一格的 `int64`。给自增 id 与表结构里的 NULLABLE 用 —— 都是单列单值。 + uvcpp_db_status read_scalar_int64(SQLHSTMT stmt, SQLUSMALLINT col, int64_t* out, + bool* is_null, std::string* err) { + SQLBIGINT v = 0; + if (read_fixed(stmt, col, SQL_C_SBIGINT, &v, sizeof(v), is_null)) { + if (!*is_null) *out = static_cast(v); + return uvcpp_db_status::OK; + } + // 撞上"这列不是数值"时退回文本再解析。 + bool text_null = false; + std::string text; + const uvcpp_db_status st = read_text_column(stmt, col, &text, &text_null, err); + if (!uvcpp_db_ok(st)) return st; + if (text_null) { + *is_null = true; + return uvcpp_db_status::OK; + } + *is_null = false; + *out = static_cast(std::strtoll(text.c_str(), nullptr, 10)); + return uvcpp_db_status::OK; + } + + /// 取一格的文本。分块循环,所以再长的文本也不会被截断。 + /// + /// 空串与 NULL 在这里分得很清:NULL 是 `ind == SQL_NULL_DATA`,空串是读到了内容 + /// 而长度为 0。契约里"空串不是 NULL"落到的就是这一行。 + uvcpp_db_status read_text_column(SQLHSTMT stmt, SQLUSMALLINT col, + std::string* out, bool* is_null, + std::string* err) { + std::basic_string acc; + SQLWCHAR buf[kChunk]; + for (;;) { + SQLLEN ind = 0; + const SQLRETURN rc = + api().SQLGetData(stmt, col, SQL_C_WCHAR, buf, sizeof(buf), &ind); + if (rc == SQL_NO_DATA) break; + if (ind == SQL_NULL_DATA) { + *is_null = true; + out->clear(); + return uvcpp_db_status::OK; + } + if (!SQL_SUCCEEDED(rc) && rc != SQL_SUCCESS_WITH_INFO) { + *err = "ODBC 取列值失败:" + odbc_diag(SQL_HANDLE_STMT, stmt); + return uvcpp_db_status::EXEC_FAILED; + } + // SQL_C_WCHAR 时 ind 报的是**字节数**(这是 ODBC 里唯一按字节报长度的 + // 地方),换算成字符个数的锅在这里背 —— 直接把 ind 当字符数用,结果会 + // 少一半,而且中文越长错得越明显。 + // + // 还有第二件事:`ind` 是**整个值**的字节数,不是这一块拿到的;而驱动在 + // 填满缓冲时**不会**用光最后一个单元 —— 它要给结尾 NUL 留位置。所以一块 + // 最多装 `kChunk - 1` 个数据单元,按 `kChunk` 夹会多收一个,把那个 NUL + // 当成数据。实测(sqliteodbc,3000 字符、缓冲 2048 单元):第 0 块报 + // `ind=6000` 却只写了 2047 个数据单元 + NUL;按 `ind/宽度` 记成 2048, + // 最后读出来 3001 个字符,**尾巴上多一个 0x00 字节**。 + size_t chars = static_cast(ind) / sizeof(SQLWCHAR); + if (chars > kChunk - 1) chars = kChunk - 1; + acc.append(buf, chars); + if (rc == SQL_SUCCESS) break; + // SQL_SUCCESS_WITH_INFO = 还有下一块 + } + *is_null = false; + *out = to_utf8(acc.empty() ? nullptr : acc.data(), acc.size()); + return uvcpp_db_status::OK; + } + + /// 取一格的二进制。同样分块。 + uvcpp_db_status read_binary_column(SQLHSTMT stmt, SQLUSMALLINT col, + std::string* out, bool* is_null, + std::string* err) { + out->clear(); + unsigned char buf[kChunk]; + for (;;) { + SQLLEN ind = 0; + const SQLRETURN rc = + api().SQLGetData(stmt, col, SQL_C_BINARY, buf, sizeof(buf), &ind); + if (rc == SQL_NO_DATA) break; + if (ind == SQL_NULL_DATA) { + *is_null = true; + return uvcpp_db_status::OK; + } + if (!SQL_SUCCEEDED(rc) && rc != SQL_SUCCESS_WITH_INFO) { + *err = "ODBC 取二进制列失败:" + odbc_diag(SQL_HANDLE_STMT, stmt); + return uvcpp_db_status::EXEC_FAILED; + } + // 与文本相反:SQL_C_BINARY 时 ind 就是字节数,而且内容可能带 0x00, + // 所以只能按长度收,不能靠 strlen。 + size_t bytes = static_cast(ind); + if (bytes > kChunk) bytes = kChunk; + out->append(reinterpret_cast(buf), bytes); + if (rc == SQL_SUCCESS) break; + } + *is_null = false; + return uvcpp_db_status::OK; + } + + /// 一格的 ODBC 类型 -> `uvcpp_db_value`。分类口径与另三个后端对齐(共用套件钉住 + /// 的就是这件事),只有两处是 ODBC 特有的: + /// + /// * **定长(DECIMAL/NUMERIC)读成 double** —— 与 MySQL/PG 两个后端一致,本模块 + /// 没有定点类型。整数形态的 numeric 走 int64(判据与 pgsql 的 `value_from_pg` + /// 逐字相同:没有 `.`/`e`/`E` 且整段落进 int64)。要精确的钱就把列建成 TEXT, + /// 见 doc/db-guide.md; + /// * **日期时间不交给驱动格式化**,拿 `SQL_DATE_STRUCT` 自己拼 `"YYYY-MM-DD"`。 + /// 让驱动格式化的话,同一个 timestamp 在 msodbcsql 与 FreeTDS 下会给出不同的 + /// 小数位与分隔符,而契约要求四个后端的文本形态逐字节相同。 + uvcpp_db_status cell_value(SQLHSTMT stmt, SQLUSMALLINT col, + SQLSMALLINT data_type, uvcpp_db_row* row, + std::string* err) { + bool is_null = false; + switch (data_type) { + case SQL_BIT: { + unsigned char b = 0; + if (read_fixed(stmt, col, SQL_C_BIT, &b, sizeof(b), &is_null)) { + row->push(is_null ? uvcpp_db_value() : uvcpp_db_value(b != 0)); + return uvcpp_db_status::OK; + } + break; + } + case SQL_TINYINT: + case SQL_SMALLINT: + case SQL_INTEGER: + case SQL_BIGINT: { + int64_t v = 0; + const uvcpp_db_status st = read_scalar_int64(stmt, col, &v, &is_null, err); + if (!uvcpp_db_ok(st)) return st; + row->push(is_null ? uvcpp_db_value() : uvcpp_db_value(v)); + return uvcpp_db_status::OK; + } + case SQL_REAL: + case SQL_FLOAT: + case SQL_DOUBLE: { + double d = 0.0; + if (read_fixed(stmt, col, SQL_C_DOUBLE, &d, sizeof(d), &is_null)) { + row->push(is_null ? uvcpp_db_value() : uvcpp_db_value(d)); + return uvcpp_db_status::OK; + } + break; + } + case SQL_DECIMAL: + case SQL_NUMERIC: { + // 取文本形态再自己判,而不是让驱动直接转 double —— 这里要能区分"整数 + // 形态的 numeric"(走 int64)与"带小数点的"(走 double)。 + bool text_null = false; + std::string text; + const uvcpp_db_status st = + read_text_column(stmt, col, &text, &text_null, err); + if (!uvcpp_db_ok(st)) return st; + row->push(text_null ? uvcpp_db_value() : numeric_value(text)); + return uvcpp_db_status::OK; + } + case SQL_BINARY: + case SQL_VARBINARY: + case SQL_LONGVARBINARY: { + std::string bytes; + const uvcpp_db_status st = + read_binary_column(stmt, col, &bytes, &is_null, err); + if (!uvcpp_db_ok(st)) return st; + row->push(is_null ? uvcpp_db_value() : uvcpp_db_value::blob(bytes)); + return uvcpp_db_status::OK; + } + case SQL_TYPE_DATE: + case SQL_DATE: { + SQL_DATE_STRUCT ds; + std::memset(&ds, 0, sizeof(ds)); + if (read_fixed(stmt, col, SQL_C_TYPE_DATE, &ds, sizeof(ds), &is_null)) { + row->push(is_null ? uvcpp_db_value() + : uvcpp_db_value::date(format_date(ds))); + return uvcpp_db_status::OK; + } + break; + } + case SQL_TYPE_TIME: + case SQL_TIME: { + SQL_TIME_STRUCT ts; + std::memset(&ts, 0, sizeof(ts)); + if (read_fixed(stmt, col, SQL_C_TYPE_TIME, &ts, sizeof(ts), &is_null)) { + row->push(is_null ? uvcpp_db_value() + : uvcpp_db_value::time(format_time(ts))); + return uvcpp_db_status::OK; + } + break; + } + case SQL_TYPE_TIMESTAMP: + case SQL_TIMESTAMP: { + SQL_TIMESTAMP_STRUCT ts; + std::memset(&ts, 0, sizeof(ts)); + if (read_fixed(stmt, col, SQL_C_TYPE_TIMESTAMP, &ts, sizeof(ts), + &is_null)) { + row->push(is_null ? uvcpp_db_value() + : uvcpp_db_value::datetime(format_timestamp(ts))); + return uvcpp_db_status::OK; + } + break; + } + default: + break; + } + + // 其余一律按文本收:`uniqueidentifier`、`xml`、各家私有的字符串类型都落在这里, + // 走 SQL_C_WCHAR 都能拿到人读的形态。 + bool text_null = false; + std::string text; + const uvcpp_db_status st = read_text_column(stmt, col, &text, &text_null, err); + if (!uvcpp_db_ok(st)) return st; + row->push(text_null ? uvcpp_db_value() : uvcpp_db_value(text)); + return uvcpp_db_status::OK; + } + + /// 定长文本 -> int64 或 double。判据与 pgsql 的 `value_from_pg` 的 numeric 分支 + /// 逐字相同,这样同一个 DECIMAL 列在两个后端上得到同一个类型标签。 + static uvcpp_db_value numeric_value(const std::string& text) { + if (text.find_first_of(".eE") == std::string::npos) { + errno = 0; + char* end = nullptr; + const long long v = std::strtoll(text.c_str(), &end, 10); + if (errno == 0 && end && *end == '\0') { + return uvcpp_db_value(static_cast(v)); + } + } + return uvcpp_db_value(std::strtod(text.c_str(), nullptr)); + } + + static std::string format_date(const SQL_DATE_STRUCT& d) { + char buf[32]; + std::snprintf(buf, sizeof(buf), "%04d-%02d-%02d", static_cast(d.year), + static_cast(d.month), static_cast(d.day)); + return std::string(buf); + } + + static std::string format_time(const SQL_TIME_STRUCT& t) { + char buf[32]; + std::snprintf(buf, sizeof(buf), "%02d:%02d:%02d", static_cast(t.hour), + static_cast(t.minute), static_cast(t.second)); + return std::string(buf); + } + + static std::string format_timestamp(const SQL_TIMESTAMP_STRUCT& t) { + char buf[48]; + // 小数秒**丢掉**:另三个后端的 DATETIME 形态就是秒级 + // (`"YYYY-MM-DD HH:MM:SS"`),这里多带几位小数会让同一个值在四个后端上 + // 文本形态不一致。要小数秒的场合得显式 CAST 成字符串自己取。 + std::snprintf(buf, sizeof(buf), "%04d-%02d-%02d %02d:%02d:%02d", + static_cast(t.year), static_cast(t.month), + static_cast(t.day), static_cast(t.hour), + static_cast(t.minute), static_cast(t.second)); + return std::string(buf); + } + + uvcpp_db_status end_tran(SQLSMALLINT how, std::string* err) { + if (!dbc_) { + *err = "ODBC 连接没打开"; + return uvcpp_db_status::NOT_CONNECTED; + } + if (!in_transaction_) { + *err = "没有开着的事务"; + return uvcpp_db_status::EXEC_FAILED; + } + const SQLRETURN rc = api().SQLEndTran(SQL_HANDLE_DBC, dbc_, how); + if (!SQL_SUCCEEDED(rc)) { + *err = std::string("ODBC ") + (how == SQL_COMMIT ? "提交" : "回滚") + + "失败:" + odbc_diag(SQL_HANDLE_DBC, dbc_); + return uvcpp_db_status::EXEC_FAILED; + } + // 回到自动提交,下一次 begin 才能重新关一次。 + api().SQLSetConnectAttr(dbc_, SQL_ATTR_AUTOCOMMIT, + reinterpret_cast( + static_cast(SQL_AUTOCOMMIT_ON)), + 0); + in_transaction_ = false; + return uvcpp_db_status::OK; + } + + /// 这两条常量**每一位单独写**,不能用 `L"SELECT 1"` —— `L"..."` 的宽度是 + /// `wchar_t`(Linux 上 4 字节),而 `SQLWCHAR` 可能是 2 字节,那正好是文件头 + /// 坑一的翻版:在 unixODBC 上编译得过、跑起来全错。 + static const SQLWCHAR kPing[]; + static const SQLWCHAR kScopeIdentity[]; + + SQLHENV env_ = nullptr; + SQLHDBC dbc_ = nullptr; + bool in_transaction_ = false; + int timeout_ms_ = kDefaultTimeoutMs; + int login_timeout_ms_ = kDefaultTimeoutMs; + /// 标识符引号(`SQLGetInfo(SQL_IDENTIFIER_QUOTE_CHAR)` 的结果)。连上之前 + /// 按标准取 `"`。 + std::string quote_ = "\""; + + /// 一个参数的**私有**绑定存储。 + /// + /// `SQLBindParameter` 只记指针(`StrLen_or_IndPtr` 记的也是指针),而 `SQLExecute` + /// 发生在**所有**参数都绑完之后 —— 所以每个参数都得有自己的一份存储。用成员变量 + /// 共用一份的症状是**静默**的:`(?, ?, ?, ?)` 逐行绑 (1,2,'t0','uu0')、(11,12,…) + /// 实测四列全变成最后一个参数的值,一个错都不报(见 `bind_all`)。 + struct bound_param { + std::basic_string text; // SQL_C_WCHAR 的字符缓冲 + std::string bin; // SQL_C_BINARY 的字节缓冲 + int64_t i64 = 0; + uint64_t u64 = 0; + double dbl = 0.0; + unsigned char bit = 0; + SQLLEN len = 0; // 文本/二进制的长度指示器,单位**字节** + SQLLEN null_ind = SQL_NULL_DATA; + }; + + /// 绑定缓冲。是成员而不是局部变量,因为 `SQLBindParameter` 只记指针、数据要活到 + /// `SQLExecute`。**先 `resize` 再绑**,绑定期间不许再让它重新分配(不要 `push_back`) + /// —— 那会让已经交出去的指针全部失效。见 `bind_all`。 + std::vector bound_; +}; + +const SQLWCHAR odbc_driver::kPing[] = {'S', 'E', 'L', 'E', 'C', 'T', + ' ', '1', 0}; + +const SQLWCHAR odbc_driver::kScopeIdentity[] = { + 'S', 'E', 'L', 'E', 'C', 'T', ' ', 'C', 'A', 'S', 'T', '(', 'S', 'C', 'O', + 'P', 'E', '_', 'I', 'D', 'E', 'N', 'T', 'I', 'T', 'Y', '(', ')', ' ', 'A', + 'S', ' ', 'B', 'I', 'G', 'I', 'N', 'T', ')', 0}; + +std::unique_ptr uvcpp_db_make_odbc() { + return std::unique_ptr(new odbc_driver()); +} + +} // namespace db_detail +} // namespace uvcpp + +#endif // UVCPP_DB_ENABLE && UVCPP_DB_ODBC_ENABLE diff --git a/src/db/uvcpp_db_odbc_api.h b/src/db/uvcpp_db_odbc_api.h new file mode 100644 index 00000000..e42e0f5f --- /dev/null +++ b/src/db/uvcpp_db_odbc_api.h @@ -0,0 +1,195 @@ +/** + * @file src/db/uvcpp_db_odbc_api.h + * @brief ODBC(驱动管理器)函数表:`UVCPP_DB_RUNTIME_LOAD` 的那一半。 + * @author zhuweiye + * @version 1.0.0 + * + * 与 `uvcpp_db_mysql_api.h` / `uvcpp_db_pgsql_api.h` 是同一个形状 —— 成员名就是符号 + * 名、清单只写一遍、类型从 ODBC 头的声明里 `decltype` 反推。为什么这么写、手抄三遍 + * 会怎么坏,`uvcpp_db_mysql_api.h` 那份注释说得更细,这里只说 ODBC 自己的三条: + * + * · **链的是驱动管理器,不是具体驱动。** 这张表里的 24 个符号来自 Linux 的 + * `libodbc.so.2`、Windows 的 `odbc32.dll`、macOS 的 `libodbc.2.dylib`;真正连 + * 哪个库(SQL Server 的 `msodbcsql18`、SQLite 的 `libsqlite3odbc.so`……)由**管理 + * 器**自己 `dlopen`,与这里无关。所以"管理器在不在"是这一层的事,"要连的数据源 + * 装没装驱动"仍然是 `open()` 才知道的事 —— 两件事别混。 + * · 清单取自 `nm -u …uvcpp_db_odbc.cpp.o | sort`(**不是**照着源码里的标识符抄: + * `SQLGetInfo` 那种只出现在一句注释里的名字抄进来就是个永远找不到的符号 —— + * PostgreSQL 那份注释里也踩过同一个坑)。逐个与 `libodbc.so.2` 的导出核过, + * 24 个全在(unixODBC 2.3.11)。 + * · 这 24 个全是**真函数**,不是宏(见下面那道 `#error`)。 + */ + +#pragma once +#ifndef SRC_DB_UVCPP_DB_ODBC_API_H +#define SRC_DB_UVCPP_DB_ODBC_API_H + +#include + +#if UVCPP_DB_ENABLE && UVCPP_DB_ODBC_ENABLE + +// ODBC 头里有两条**条件重定向宏**,它们会把名字换成另一个符号: +// `sqlext.h:2057` 的 `#define SQLAllocHandle SQLAllocHandleStd`(在 `ODBC_STD` 下, +// 而且换了之后**语义也不一样**)、`sqlucode.h:754` 的 +// `#define SQLSetStmtAttr SQLSetStmtAttrW`(在 `UNICODE` 下,Windows SDK 里是一整族)。 +// +// 本后端是**按名字**说话的:运行时加载那一半用 `#name` 去查符号,而 `#` **不展开** +// 实参(这正是我们要的,也正是 pgsql 那份清单敢用 `#name` 的原因)。于是"调用点被 +// 宏重定向到 X、查的却是 Y"会在两种构建方式之间分叉,而且**不报错**。堵死在门口, +// 比事后对着 `nm` 猜便宜:这两个宏谁都不该在编本文件时是开着的。 +#if defined(ODBC_STD) || defined(UNICODE) +#error "ODBC 后端按符号名做运行时加载:编 src/db/uvcpp_db_odbc.cpp 时 ODBC_STD 与 UNICODE 都必须未定义(它们会把 SQL* 名字重定向到别的符号,两种构建方式会分叉)。" +#endif + +// Windows 的 ODBC 头**不是自给自足**的,`` 必须先来。 +// +// 微软那套 `sqltypes.h` 是拿 Windows 自己的类型拼出 ODBC 类型表的:`SQLLEN` / +// `SQLULEN` 走 `INT64` / `UINT64`(`winnt.h`),`SQLHWND` 走 `HWND`(`windef.h`), +// `SQLGUID` 走 `GUID`(`guiddef.h`)—— 全在 `` 里。少了它,第一条错是 +// `sqltypes.h(64,9): error C4430: missing type specifier` 指着 `typedef INT64 SQLLEN;`, +// 看上去像 Windows SDK 坏了,其实一行关系都没有(真试过:照着这个症状去换 SDK 版本, +// 换到最新的一样红)。 +// +// unixODBC 的 `sqltypes.h` 相反:它**自己补一套**类型(`#define BOOL int` 那种就是这套补丁 +// 的一部分),所以 Linux 上漏掉这个顺序照样编得过。补不补看的是一个叫 +// `ALREADY_HAVE_WINDOWS_TYPE` 的宏、**不是** `` 在不在 —— 于是 mingw 上会多出 +// 下面那段只有 mingw 才有的第二层。顺序坑本身只在 Windows 现形 —— 也正因为这样,它在这份 +// 代码第一次上 Windows 之前一直没露头。 +// +// `WIN32_LEAN_AND_MEAN` + `NOMINMAX` 是本文件自带的两条卫生:这个是**公共头**,谁包含 +// 它就等于把 `` 拖进自己的 TU(本仓 `uvcpp_web_json_reflect.h` 那一段注释记 +// 的正是跟着来的 `std::numeric_limits::max` 被当成宏调用)。少拉一半 Windows 头,外加 +// 不放出 `min` / `max` 两个宏,代价为零。 +#if defined(_WIN32) +# ifndef WIN32_LEAN_AND_MEAN +# define WIN32_LEAN_AND_MEAN +# endif +# ifndef NOMINMAX +# define NOMINMAX +# endif +# include +#endif + +// mingw 上的第二层。unixODBC 那份 `sqltypes.h` 里"补 windows 类型"那一段的开关是 +// `ALREADY_HAVE_WINDOWS_TYPE` —— **这个宏谁都不定义**,所以哪怕 `` 刚从上面进来 +// 过,它照样补一遍。补出来的 `CHAR` / `WORD` / `DWORD` / `BYTE` / `LPDWORD` 与 `winnt.h` +// 逐字相同(C++ 里重复 typedef 合法,一声不响),但 `WCHAR` 不同:它给 `unsigned short`, +// 而 `winnt.h` 早已 `typedef wchar_t WCHAR` —— **硬冲突,也是两条 mingw 发布腿唯一的编译 +// 错误**: +// +// sqltypes.h 第 109 行: error: conflicting declaration 'typedef short unsigned int WCHAR' +// winnt.h 第 320 行: note: previous declaration as 'typedef wchar_t WCHAR' +// +// MSVC 上不冲突:Windows SDK 那份 `sqltypes.h` 是微软自己写的,没有这一整段补丁。这个坑与 +// 上面那个顺序坑同源,**只在 mingw 现形**。 +// +// 解法就是这段补丁自己的开关 `SQL_WCHART_CONVERT`(`sqltypes.h` 第 106 行):打开后 `WCHAR` 是 +// `wchar_t`,与 `winnt.h` 逐字相同,冲突消失。语义上也更对 —— Windows 的 ODBC 宽字符接口 +// 本来就是 `wchar_t`(2 字节),`unsigned short` 那个是 unixODBC 给"没有 `` 的 +// 平台"准备的默认值(它自己的注释就这么写的,`sqltypes.h` 第 30-38 行)。`SQLWCHAR` 由 +// `typedef WCHAR` 派生(第 467 行),跟着一起对,**尺寸不变、仍是 2 字节**。 +// +// 判据写成"Windows 上但不是 MSVC"而不是 `__MINGW32__`:真正的分界是"手上这份 `sqltypes.h` +// 是 unixODBC 的还是微软的",而 Windows 上非 MSVC 的工具链拿到的就是 unixODBC 那份;万一 +// 有人拿 mingw 去配 SDK 的头,这个宏在微软那份里**一次都没被引用**,是空操作、不会误伤。 +// Linux 上必须**不**开:那里 `wchar_t` 是 4 字节,开了就把 `SQLWCHAR` 从 2 字节改成 4 字节 +// —— 那是改 ABI,不是修 bug。 +#if defined(_WIN32) && !defined(_MSC_VER) +# define SQL_WCHART_CONVERT +// 同一段补丁里的 `#define CALLBACK`(空定义)会盖掉 `minwindef.h` 的 +// `#define CALLBACK __stdcall`,GCC 因此报一句 `'CALLBACK' redefined`;而它**出完 include +// 也不复原**,等于把这个宏借走不还。借出去、还回来:`#undef` 之后 unixODBC 那句成了全新 +// 定义(不报重定义),`pop_macro` 再把 `minwindef` 的原定义还回去。万一哪个编译器不认这 +// 两个 pragma,它们按标准是被忽略的,结果就退回今天这个样子(一句警告 + 宏留在空定义), +// 不更坏。 +# pragma push_macro("CALLBACK") +# undef CALLBACK +#endif + +#include +#include + +#if defined(_WIN32) && !defined(_MSC_VER) +# pragma pop_macro("CALLBACK") +#endif + +// unixODBC 的 `sqltypes.h` 第 75 行有一句 `#define BOOL int`(在没有 `` +// 的平台上补齐 ODBC 那套类型表)。它会把这个文件里唯一一处 `uvcpp_db_type::BOOL` +// 展开成 `uvcpp_db_type::int`,报出来的错是 "expected unqualified-id before 'int'" +// —— 离真正的原因隔着一层。这里直接 `#undef`:ODBC 头已经预处理完,这个宏的使命 +// 结束了(理由与观察详见 `uvcpp_db_odbc.cpp` 文件头)。微软那套 `sqltypes.h` 不发 +// 这个宏(Windows 上 `BOOL` 是上面那个 `` 里的 typedef),所以在 Windows +// 上这行是空操作 —— 这也是为什么上面那个 include 不能省:不包含 `` 时 +// `BOOL` 既不是 ODBC 头给的宏、也没有 typedef 兜着,`uvcpp_db_type::BOOL` 会直接 +// 变成未定义标识符。 +#undef BOOL + +#include + +namespace uvcpp { +namespace db_detail { + +/// 这张库要用的**全部**驱动管理器符号(24 个)。 +/// 顺序与 `nm -u …uvcpp_db_odbc.cpp.o | sort` 一致。 +/// +/// 全是 `W` 形态的宽接口(或没有 A/W 之分的那些,如 `SQLAllocHandle`):本后端的 +/// 文本一律走 `SQLWCHAR`,见 `uvcpp_db_odbc.cpp` 文件头「坑二」。 +#define UVCPP_DB_ODBC_SYMBOLS(X) \ + X(SQLAllocHandle) \ + X(SQLBindParameter) \ + X(SQLColumnsW) \ + X(SQLDescribeColW) \ + X(SQLDisconnect) \ + X(SQLDriverConnectW) \ + X(SQLEndTran) \ + X(SQLExecDirectW) \ + X(SQLExecute) \ + X(SQLFetch) \ + X(SQLFreeHandle) \ + X(SQLGetData) \ + X(SQLGetDiagRecW) \ + X(SQLGetInfoW) \ + X(SQLMoreResults) \ + X(SQLNumParams) \ + X(SQLNumResultCols) \ + X(SQLPrepareW) \ + X(SQLPrimaryKeysW) \ + X(SQLRowCount) \ + X(SQLSetConnectAttr) \ + X(SQLSetEnvAttr) \ + X(SQLSetStmtAttr) \ + X(SQLTablesW) + +/// 函数表。成员名 = 符号名。 +struct odbc_api { +#define UVCPP_DB_ODBC_DECLARE(name) decltype(&::name) name; + UVCPP_DB_ODBC_SYMBOLS(UVCPP_DB_ODBC_DECLARE) +#undef UVCPP_DB_ODBC_DECLARE +}; + +#if UVCPP_DB_RUNTIME_LOAD + +/// 取函数表。永不返回空,理由见 `uvcpp_db_mysql_api.h` 里同一处。 +const odbc_api& uvcpp_db_odbc_api(); + +/// 加载失败的原因(人读的一句话);成功是空串。进程级、只算一次。 +const std::string& uvcpp_db_odbc_load_error(); + +#else // 直接链接 + +/// 编译期就填好的表。 +const odbc_api& uvcpp_db_odbc_api(); + +/// 直接链接形态下永远是空串。见 `uvcpp_db_mysql_api.h` 里同一处的理由。 +inline const std::string& uvcpp_db_odbc_load_error() { + static const std::string kNoError; + return kNoError; +} + +#endif // UVCPP_DB_RUNTIME_LOAD + +} // namespace db_detail +} // namespace uvcpp + +#endif // UVCPP_DB_ENABLE && UVCPP_DB_ODBC_ENABLE +#endif // SRC_DB_UVCPP_DB_ODBC_API_H diff --git a/tests/functional/CMakeLists.txt b/tests/functional/CMakeLists.txt index 9dce7ea7..e86b9102 100644 --- a/tests/functional/CMakeLists.txt +++ b/tests/functional/CMakeLists.txt @@ -135,6 +135,9 @@ endif() if(NOT UVCPP_ENABLE_DB_PGSQL) list(FILTER FUNC_SOURCES EXCLUDE REGEX "/[^/]*db_pgsql[^/]*\.cpp$") endif() +if(NOT UVCPP_ENABLE_DB_ODBC) + list(FILTER FUNC_SOURCES EXCLUDE REGEX "/[^/]*db_odbc[^/]*\.cpp$") +endif() foreach(test_src ${FUNC_SOURCES}) get_filename_component(test_name ${test_src} NAME_WE) @@ -233,21 +236,53 @@ if(UVCPP_ENABLE_DB AND UVCPP_DB_LINK_LIBS) endforeach() endif() -# MySQL / PostgreSQL 那两条要一个**真的服务端**,所以它们自己按环境变量决定 -# 跑还是判"未判定"(退出码 3)。ctest 得知道 3 是什么意思,否则"这台机器上 +# MySQL / PostgreSQL / ODBC 那三条要一个**真的服务端**(ODBC 要的是一份它的 +# `UVCPP_DB_TEST_ODBC_URL` 指得到的、装得出驱动的数据源),所以它们自己按环境变量 +# 决定跑还是判"未判定"(退出码 3)。ctest 得知道 3 是什么意思,否则"这台机器上 # 没有 MySQL"会显示成 `Failed` —— 而把它改成 `return 0` 又会显示成 `Passed`, # 那是本仓最忌讳的那种绿:**没测过**长得和**测过了**一模一样。 # # `SKIP_RETURN_CODE` 是 ctest 自带的那一格(3.0 起),报出来的是 # `***Skipped`,与 `Passed` 在日志里是两个词。 # +# `test_db_odbc_func` 是 1.7.2 补进这一串的:原来这份构建里**只有它漏了**这一格, +# 而它的漏法和另两条不同 —— 另两条在 CI 里从来都是"URL 已经给了"(服务容器起来 +# 了),所以没设 `SKIP_RETURN_CODE` 时是**当场红**、改对了才有跳过语义;ODBC 这条 +# 到今天为止**没有任何一格 CI 给过 `UVCPP_DB_TEST_ODBC_URL`**,于是谁在装了 +# `unixodbc-dev` 的机器上跑一次 `ctest`(这正好是"ODBC 后端在、只是没数据源"这个 +# 最常见的开发机形态),看到的都是 `Failed` —— 而真相是"未判定"。它不属于下面 +# 那条"永远真跑"的例外:没有数据源时它连一条 SQL 都发不出去。 +# # SQLite 那条**不设**这个属性:它不需要服务端,永远真跑。给它也设上的话, # 将来某条路径误返回 3 就会从"红"退化成"跳过"。 # # CI 上还会额外设 `UVCPP_DB_TEST_REQUIRE=1`:那时缺 URL 是**失败**(退出码 1), -# 不是跳过 —— 服务容器都起了还跳过,说明配置写错了,必须红。 -foreach(_db_remote test_db_mysql_func test_db_pgsql_func) +# 不是跳过 —— 服务容器都起了还跳过,说明配置写错了,必须红。(这一条对 ODBC 同样 +# 成立:哪天有哪一格真起了个数据源,就该把那格的 `UVCPP_DB_TEST_REQUIRE=1` 也配上。) +foreach(_db_remote test_db_mysql_func test_db_pgsql_func test_db_odbc_func) if(TARGET ${_db_remote}) set_tests_properties(${_db_remote} PROPERTIES SKIP_RETURN_CODE 3) endif() endforeach() + +# `test_db_odbc_nodrv_func` 的前提是"这台机器上**没有** ODBC 驱动管理器",而它 +# **不去卸载任何东西**:把 `UVCPP_DB_ODBC_LIBRARY` 指到一个不存在的路径就行 +# (那个旋钮写在文档里的用途就是这个,见 `src/db/uvcpp_db_runtime_load.h`)。 +# 于是"没装 unixODBC"这件事在这里是**确定性的**,与 runner 上装没装无关 —— +# 这也是为什么它不设 `SKIP_RETURN_CODE`:没有"机器没配好所以跳过"这一档, +# 前提完全在我们手里,不成立就该红(用例自己会检查那个路径真的不存在)。 +# +# 它是**独立目标**而不是并进 `db_odbc_func.cpp`:ODBC 的符号加载走 magic static, +# 进程内一旦成功加载过管理器,之后再设环境变量也不生效("句柄永不释放"的后果)。 +# 所以要测"加载失败",必须在一个还没碰过 ODBC 的进程里设好这个变量 —— ctest 的 +# `ENVIRONMENT` 属性恰好是唯一能保证这件事的地方。 +if(TARGET test_db_odbc_nodrv_func) + if(WIN32) + # Z: 在 CI 的 Windows 镜像上不存在(`win-client-headers.sh` 那些步骤都不用盘符)。 + set(_uvcpp_odbc_nodrv_path "Z:/nonexistent/odbc32.dll") + else() + set(_uvcpp_odbc_nodrv_path "/nonexistent/libodbc.so.2") + endif() + set_tests_properties(test_db_odbc_nodrv_func PROPERTIES + ENVIRONMENT "UVCPP_DB_ODBC_LIBRARY=${_uvcpp_odbc_nodrv_path}") +endif() diff --git a/tests/functional/db_odbc_func.cpp b/tests/functional/db_odbc_func.cpp new file mode 100644 index 00000000..1b4cdbac --- /dev/null +++ b/tests/functional/db_odbc_func.cpp @@ -0,0 +1,491 @@ +/** + * @file tests/functional/db_odbc_func.cpp + * @brief 数据库模块 · ODBC 后端:跑共用套件(`db_suite.h`)+ 三条 ODBC 专属判据。 + * + * ## 这一条要一个**真的数据源**,而"哪个数据源"是给出来的,不是猜的 + * + * 连接串从环境变量 `UVCPP_DB_TEST_ODBC_URL` 取,例: + * + * # SQL Server(本后端存在的理由) + * UVCPP_DB_TEST_ODBC_URL='odbc://sa:pw@127.0.0.1:1433/uvcpp_test?Driver={ODBC Driver 18 for SQL Server}' + * # SQLite,走 sqliteodbc —— 不需要任何服务端,适合先跑通一条 + * UVCPP_DB_TEST_ODBC_URL='odbc:?Driver=/usr/lib/x86_64-linux-gnu/odbc/libsqlite3odbc.so;Database=/tmp/uvcpp.db' + * + * 没给 URL 时: + * + * * 没设 `UVCPP_DB_TEST_REQUIRE` → 打印一行说明、**退出码 3**。ctest 那边用 + * `SKIP_RETURN_CODE 3` 把它报成 `***Skipped`,与 `Passed` 是两个词。 + * * 设了 `UVCPP_DB_TEST_REQUIRE=1` → **退出码 1(红)**。CI 上库都起了还 + * 跳过,说明是配置写错了,这种时候"绿"是最坏的结果。 + * + * 退出码 3 是照本仓 gate 脚本的规矩来的:**「未判定」不是「通过」**。 + * + * ## 为什么这个文件里有**方言分支**,而另外三家没有 + * + * ODBC 是通用驱动:**方言属于数据源,不属于这个后端**。同一份 `db_odbc_func.cpp` + * 连 SQL Server 与连 SQLite,`CREATE TABLE` 的写法、自增列的定义、有没有原生时间 + * 类型,全都不一样。所以这里按 URL 里的 `Driver=` 挑一份 `dialect`: + * + * * 认得出 → 用对应的方言跑共用套件; + * * 认不出 → **退出码 3(未判定)**,不是拿一份默认方言硬跑。硬跑的结局是把 + * "这个数据源的 DDL 和我们的套件不一致"报成"ODBC 后端坏了",而这两件事的 + * 修法完全不同。 + * + * 目前的方言表:`sqlite`(sqliteodbc)与 `sql server`(`msodbcsql*`)。加一族就是 + * 加一个 `dialect` 子类 + 一条 `driver_family_of()` 分支。 + * + * ## 这个库会被**改写** + * + * 套件里有 `DROP TABLE` / `CREATE TABLE`,所以 URL 必须指向一个**一次性的**库。 + * 用例自己的表名都是 `uvcpp_db_*` 前缀,但别拿生产库试。 + * + * ## 三条 ODBC 专属判据(共用套件覆盖不到的地方) + * + * 1. **四个参数各不相同、且各自是各自的值。** `SQLBindParameter` 只记指针, + * 缓冲共用一份时每个参数都会读到最后一个的值,**而且不报错** —— 见 + * `test_binding_is_per_parameter()`。 + * 2. **宽字符参数的来回是逐字节的。** 长度指示器按字符给会静默砍半 + * (`floor(n/2)`,"张三" 变 "张"),见 `test_wide_text_round_trip()`。 + * 3. **`?Driver=` 真的交给了驱动管理器。** 驱动名不存在时是"打不开", + * 不是"悄悄换了另一个驱动",见 `test_driver_passthrough()`。 + */ + +#include +#include +#include +#include +#include + +#include + +#include "db_suite.h" + +namespace { + +using namespace uvcpp_db_test; + +/// URL 里那个 `Driver=`(或 `DSN=`)指向哪一族数据源。 +enum driver_family { kFamilySqlite, kFamilySqlServer, kFamilyUnknown }; + +std::string lower(std::string s) { + for (size_t i = 0; i < s.size(); ++i) { + if (s[i] >= 'A' && s[i] <= 'Z') s[i] = static_cast(s[i] - 'A' + 'a'); + } + return s; +} + +/// 认族**只看 URL 文本**,不去连。连上再问 `SQLGetInfo(SQL_DBMS_NAME)` 更准, +/// 但那时已经晚了 —— 方言要在建表之前定,而建表是套件做的第一件事。 +driver_family driver_family_of(const std::string& url) { + const std::string u = lower(url); + if (u.find("sqlite") != std::string::npos) return kFamilySqlite; + // `msodbcsql18` / `{ODBC Driver 18 for SQL Server}` / `SQL Server Native Client` + // 都落在这里。 + if (u.find("sql server") != std::string::npos || + u.find("msodbcsql") != std::string::npos || + u.find("sqlncli") != std::string::npos) { + return kFamilySqlServer; + } + return kFamilyUnknown; +} + +/// 长文本那一列怎么声明。SQL Server 上 `TEXT` 已废弃、`VARCHAR` 又会按代码页折 +/// 中文,得用 `NVARCHAR(MAX)`;SQLite 只有 `TEXT`。 +std::string text_column_decl(driver_family fam) { + return fam == kFamilySqlServer ? "NVARCHAR(MAX)" : "TEXT"; +} + +/// 二进制那一列怎么声明。SQL Server 上没有 `BLOB` 这个类型名。 +std::string blob_column_decl(driver_family fam) { + return fam == kFamilySqlServer ? "VARBINARY(MAX)" : "BLOB"; +} + +/// SQLite,经由 sqliteodbc。 +/// +/// 与 `db_sqlite_func.cpp` 里那份**本库 SQLite 后端**的方言只差一处: +/// `enforces_foreign_keys()` 在这条路上是 **false**。SQLite 的外键默认是关的, +/// 本库的 SQLite 后端会替连接发一句 `PRAGMA foreign_keys=ON`,而 ODBC 这条路上 +/// **没人发这一句** —— ODBC 只有 SQL,没有"pragma"这种概念,驱动也不会替谁开。 +/// 同一个数据源、同一个库、同一个 schema,走两条路外键行为不同:这不是 bug, +/// 是"谁负责开那个开关"的差别,而它值得在测试里钉住。 +class odbc_sqlite_dialect : public dialect { + public: + const char* name() const { return "odbc"; } + + std::string ph(size_t) const { return "?"; } + + /// **false,与"本库 SQLite 后端收 `$1`"相反** —— 这是量出来的:占位符是 + /// **驱动**在数,sqliteodbc 只认 `?`,给它 `$1` 报 + /// `parameter marker count incorrect`(`PREPARE_FAILED`)。所以"SQLite 收 + /// `$1`"是那个后端的性质,不是这个数据库的性质;ODBC 这一层把它盖掉了。 + bool accepts_dollar_placeholder() const { return false; } + + /// **false**:sqliteodbc 的 `SQLPrepare` 是空操作(SQLite 要到第一次 step 才 + /// 解析 SQL),于是语法错、表不存在都要等到执行才报 —— `test_errors()` 那两条 + /// 得到的是 `EXEC_FAILED`。实测的。 + bool prepares_eagerly() const { return false; } + + std::string serial_pk_type() const { return "INTEGER PRIMARY KEY AUTOINCREMENT"; } + + std::string create_types_table(const std::string& t) const { + // `flag_val` 是 INTEGER:SQLite 没有布尔类型,0/1 就是它的布尔 —— + // 判的是 `to_bool()` 的值,不是类型标签。 + // `e_val` 特意带 `NOT NULL DEFAULT ''`:把空串折成 NULL 的实现会在这里 + // 撞上非空约束,失败得**响亮**。 + return "CREATE TABLE " + t + + " (id INTEGER PRIMARY KEY AUTOINCREMENT," + " i_val INTEGER, d_val REAL, s_val TEXT, b_val BLOB," + " n_val TEXT, e_val TEXT NOT NULL DEFAULT '', flag_val INTEGER," + " dt_val TEXT, dd_val TEXT, tt_val TEXT, " + decimal_column_decl() + ")"; + } + + std::string create_serial_table(const std::string& t) const { + return "CREATE TABLE " + t + " (id " + serial_pk_type() + ", name TEXT)"; + } + + std::string decimal_column_decl() const { return "dec_val TEXT"; } + + bool has_native_temporal() const { return false; } + bool decimal_is_native() const { return false; } + + /// **本后端的 `last_insert_id()` 在这条路上报不出来**(它发的是 SQL Server 的 + /// `SCOPE_IDENTITY()`,SQLite 不认),如实返回 `UNSUPPORTED`。所以这一条在共用 + /// 套件里是"不适用",而它**真的**返回什么由 + /// `test_last_insert_id_is_honest()` 单独钉住 —— 不适用不等于没人管。 + bool supports_last_insert_id() const { return false; } + + bool enforces_foreign_keys() const { return false; } +}; + +/// SQL Server,经由 `msodbcsql*`。 +/// +/// **这一支在本机没有跑过**(手上没有 SQL Server 实例),所以它是一份照着 +/// SQL Server 语法写的方言,不是量出来的读数:`INT IDENTITY(1,1)` 是自增列的 +/// 写法、`NVARCHAR(MAX)` 承接长文本、`BIT` 是布尔、`DECIMAL(18,2)` 是原生定点。 +/// 拿它去跑真 SQL Server 是**第一次**跑,读数以那次为准;哪条断言红了,先怀疑 +/// 这里是照书写错了,而不是先怀疑后端。 +class odbc_mssql_dialect : public dialect { + public: + const char* name() const { return "odbc"; } + + /// SQL Server 的 ODBC 驱动只认 `?`,不认 `$1`。 + std::string ph(size_t) const { return "?"; } + + std::string serial_pk_type() const { return "INT IDENTITY(1,1) PRIMARY KEY"; } + + std::string create_types_table(const std::string& t) const { + return "CREATE TABLE " + t + + " (id INT IDENTITY(1,1) PRIMARY KEY," + " i_val BIGINT, d_val FLOAT, s_val " + text_column_decl(kFamilySqlServer) + + ", b_val VARBINARY(MAX), n_val NVARCHAR(64)," + " e_val NVARCHAR(64) NOT NULL DEFAULT '', flag_val BIT," + " dt_val DATETIME2, dd_val DATE, tt_val TIME, " + + decimal_column_decl() + ")"; + } + + std::string create_serial_table(const std::string& t) const { + return "CREATE TABLE " + t + " (id " + serial_pk_type() + + ", name NVARCHAR(64))"; + } + + std::string decimal_column_decl() const { return "dec_val DECIMAL(18,2)"; } + + bool has_native_temporal() const { return true; } + bool decimal_is_native() const { return true; } + bool supports_last_insert_id() const { return true; } + bool enforces_foreign_keys() const { return true; } +}; + +// --------------------------------------------------------------------------- +// ODBC 专属判据 +// --------------------------------------------------------------------------- + +/// 坑五的见证:**每个参数都得有自己的缓冲**。 +/// +/// `SQLBindParameter` 只记指针(`StrLen_or_IndPtr` 也是一枚指针),而执行发生在 +/// **所有**参数都绑完之后。用几个成员变量在循环里反复赋值,等于让每个参数都指向 +/// 最后那一轮写进去的数据 —— 实测过:`(?, ?, ?, ?)` 逐行绑 +/// (1,2,'t0','uu0')、(11,12,'t1','uu1')、(21,22,'t2','uu2'),库里进去的是 +/// (2,2,'t','u')、(12,12,'t','u')、(22,22,'t','u'),**一个错都不报**。 +/// +/// 四种类型的列都放进来:整数两列、文本两列。只放一列数值的话,共用一份缓冲反而 +/// 恰好是对的(只有一个参数),这条判据就白写了。 +void test_binding_is_per_parameter(harness& h, driver_family fam) { + const std::string text_col = text_column_decl(fam); + const std::string table = "uvcpp_db_odbc_bind"; + h.db->execute("DROP TABLE " + table); + h.ok(h.db->execute("CREATE TABLE " + table + " (a BIGINT, b BIGINT, c " + + text_col + ", d " + text_col + ")"), + "建绑定判据表"); + + const std::string sql = + "INSERT INTO " + table + " (a, b, c, d) VALUES (?, ?, ?, ?)"; + for (int i = 0; i < 3; ++i) { + const std::string suffix(1, static_cast('0' + i)); + h.ok(h.db->execute(sql, {int64_t(1 + i * 10), int64_t(2 + i * 10), + std::string("t") + suffix, + std::string("uu") + suffix}), + "插第 " + std::to_string(i) + " 行(四个参数各不同)"); + } + + uvcpp_db_table t; + h.ok(h.db->query("SELECT a, b, c, d FROM " + table + " ORDER BY a", + static_cast(&t)), + "读回四列"); + h.eq_int(static_cast(t.row_count()), 3, "三行都在"); + if (t.row_count() != 3) return; + + // 四列都要对。只对第一列的话,"所有参数都成了最后一个的值"这种错会被漏掉 + // —— 第一列恰好是它自己那一轮写进去的(共用的缓冲里最后是最后一行)。 + const int64_t want_a[] = {1, 11, 21}; + const int64_t want_b[] = {2, 12, 22}; + const char* want_c[] = {"t0", "t1", "t2"}; + const char* want_d[] = {"uu0", "uu1", "uu2"}; + for (size_t r = 0; r < 3; ++r) { + const std::string row = "第 " + std::to_string(r) + " 行 "; + h.eq_int(t.at(r, 0).to_int64(), want_a[r], row + "a"); + h.eq_int(t.at(r, 1).to_int64(), want_b[r], row + "b"); + h.eq_text(t.at(r, 2).to_text(), want_c[r], row + "c"); + h.eq_text(t.at(r, 3).to_text(), want_d[r], row + "d"); + } + + h.db->execute("DROP TABLE " + table); +} + +/// 坑四的见证:**宽字符参数的长度指示器单位是字节**。 +/// +/// 按字符给的症状是长文本被砍掉一半(`floor(n/2)` 个字符活下来):"张三" 存成 +/// "张"。**ASCII 也中招**("abcdef" 存成 "abc"),所以"只有中文才出问题"这个判断 +/// 会把方向带偏 —— 这条判据里纯 ASCII 与中文都要有。 +void test_wide_text_round_trip(harness& h, driver_family fam) { + const std::string table = "uvcpp_db_odbc_wide"; + h.db->execute("DROP TABLE " + table); + h.ok(h.db->execute("CREATE TABLE " + table + " (k BIGINT, v " + + text_column_decl(fam) + ")"), + "建宽字符判据表"); + + // 字符数取偶数(4/2/6)与奇数(3 字中文按 UTF-8 是 9 字节)都有:砍半在两种 + // 长度上都看得见,奇数的还会掉尾字。 + const char* samples[] = {"abcd", "ab", "张三", "张三丰测试", + "混合 mixed 文本 12345"}; + const size_t n = sizeof(samples) / sizeof(samples[0]); + for (size_t i = 0; i < n; ++i) { + h.ok(h.db->execute("INSERT INTO " + table + " (k, v) VALUES (?, ?)", + {int64_t(i), std::string(samples[i])}), + std::string("插入 [") + samples[i] + "]"); + } + + uvcpp_db_table t; + h.ok(h.db->query("SELECT k, v FROM " + table + " ORDER BY k", + static_cast(&t)), + "读回宽字符列"); + h.eq_int(static_cast(t.row_count()), static_cast(n), + "样本条数对得上"); + if (t.row_count() != n) return; + + for (size_t i = 0; i < n; ++i) { + h.eq_text(t.at(i, 1).to_text(), samples[i], + std::string("往返逐字节一致:[") + samples[i] + "]"); + } + + // 参数化的**条件**也要能查回来 —— 上面测的是"存进去对不对",这一步测的是 + // "拿它当条件找得到吗",两条路都要过绑定与宽字符编码。 + for (size_t i = 0; i < n; ++i) { + uvcpp_db_table one; + h.ok(h.db->query("SELECT k FROM " + table + " WHERE v = ?", + {std::string(samples[i])}, + static_cast(&one)), + std::string("拿 [") + samples[i] + "] 当条件查"); + h.eq_int(static_cast(one.row_count()), 1, + std::string("按文本条件查到 1 行:[") + samples[i] + "]"); + } + + h.db->execute("DROP TABLE " + table); +} + +/// `last_insert_id()` 报不出来时必须是 `UNSUPPORTED`,不是 `OK` + 一个编出来的 0。 +/// +/// 共用套件在 `supports_last_insert_id() == false` 时把整组标成"不适用",所以 +/// **没有别的判据**在看这条路 —— 而它恰恰是最容易被写成"返回个 0 算了"的地方。 +/// 编出来的 0 会成为调用方 SQL 里的 `WHERE id = 0`,查不到任何行,症状是 +/// "刚插进去的数据不见了"。 +void test_last_insert_id_is_honest(harness& h, driver_family fam, + const std::string& url) { + if (fam == kFamilySqlServer) { + // SQL Server 上这条路是真的实现了的(`SCOPE_IDENTITY()`),归共用套件判。 + h.skip("报不出来时返回 UNSUPPORTED", "SQL Server 上这条能报出来"); + return; + } + + uvcpp_db_client db; + if (db.open(url) != uvcpp_db_status::OK) { + h.check(false, "last_insert_id 判据要一个连接:" + db.last_error()); + return; + } + uvcpp_db_value out; + const uvcpp_db_status st = db.last_insert_id(&out); + h.eq_status(st, uvcpp_db_status::UNSUPPORTED, + "SQLite 这一路上 last_insert_id -> UNSUPPORTED"); + h.check(!db.last_error().empty(), "报不出来时必须留下人能读的原因"); + db.close(); +} + +/// `?Driver=` 是**原样**交给驱动管理器的,所以驱动名写错就是"打不开"。 +/// +/// 这条判据挡的是另一种实现:把不认识的驱动名悄悄换成某个默认驱动 —— 那样 +/// `Driver=MySQL` 会连上一个完全不同的数据源,而调用方以为连对了。驱动名不存在时 +/// 驱动管理器返回 `SQL_ERROR`(`IM002` 一类),落在 **`OPEN_FAILED`**,不是 +/// `NO_DRIVER`:`NO_DRIVER` 在这个模块里的意思是"这个后端没编进这份库",而 ODBC +/// 后端明明编进来了,缺的是数据源那头的驱动。两个码混起来,排障第一步就会走到 +/// "换个包"上去。 +void test_driver_passthrough(harness& h) { + uvcpp_db_client tmp; + const uvcpp_db_status st = tmp.open( + "odbc://u:p@127.0.0.1:1/db?Driver={uvcpp_no_such_driver_xyz}"); + h.eq_status(st, uvcpp_db_status::OPEN_FAILED, + "不存在的 Driver= -> OPEN_FAILED(不是 NO_DRIVER)"); + h.check(!tmp.is_open(), "打不开之后 is_open() 必须是 false"); + h.check(!tmp.last_error().empty(), "打不开必须留下 last_error"); + + // 驱动名给了、但那个 `.so` 路径是假的:同样是打不开,不是"照默认驱动连"。 + uvcpp_db_client tmp2; + const uvcpp_db_status st2 = tmp2.open( + "odbc:?Driver=/nonexistent/uvcpp_no_such_driver.so;Database=/tmp/x.db"); + h.check(st2 == uvcpp_db_status::OPEN_FAILED || + st2 == uvcpp_db_status::BAD_URL, + std::string("假的驱动 .so 路径 -> 打不开(实得 ") + + uvcpp::uvcpp_db_status_name(st2) + ")"); +} + +/// 分块读的两条边界,共用套件都够不着(它的 blob 只有 300 字节、文本虽然是 3000 +/// 字节但全是 ASCII)。 +/// +/// * **二进制跨块**:`SQL_C_BINARY` 没有结尾 NUL,可以用满整块 —— 与文本那条 +/// "每块只用 `kChunk - 1` 个单元"的规矩相反。两条分支搞混就是静默少一个字节 +/// 或多一个字节,而 300 字节的样本永远碰不到。 +/// * **4 字节 UTF-8 字符骑在块边界上**:`U+20000` 这类字符在 UTF-16 里是一对 +/// 代理,边界切在代理对中间就会读成两个 `U+FFFD`。样本特意把它的位置摆在 +/// "第一块正好写到它"的地方(前面 2046 个 ASCII),所以这条不只是"长文本能 +/// 读回来",而是"切在代理对中间会怎样"。实测 sqliteodbc 不切代理对,本库的 +/// `to_utf8()` 只在**同一块内**拼代理对。 +void test_chunk_boundaries(harness& h, driver_family fam) { + const std::string table = "uvcpp_db_odbc_chunk"; + h.db->execute("DROP TABLE " + table); + h.ok(h.db->execute("CREATE TABLE " + table + " (k BIGINT, b " + + blob_column_decl(fam) + ", s " + text_column_decl(fam) + ")"), + "建分块边界表"); + + // 3000 字节的 blob:> kChunk(2048),必然跨块。 + std::string blob; + for (int i = 0; i < 3000; ++i) { + blob.push_back(static_cast(i % 251)); // 251 是质数:三个周期不重合 + } + h.ok(h.db->execute("INSERT INTO " + table + " (k, b) VALUES (?, ?)", + {int64_t(1), uvcpp_db_value::blob(blob)}), + "插入 3000 字节 blob"); + + // 2046 个 'a' + U+20000(UTF-8 四字节 / UTF-16 一对代理)+ 100 个 'b' + std::string wide; + for (int i = 0; i < 2046; ++i) wide += "a"; + wide += "\xF0\xA0\x80\x80"; + for (int i = 0; i < 100; ++i) wide += "b"; + h.ok(h.db->execute("INSERT INTO " + table + " (k, s) VALUES (?, ?)", + {int64_t(2), wide}), + "插入代理对骑在块边界上的样本"); + + uvcpp_db_table t; + h.ok(h.db->query("SELECT b, s FROM " + table + " ORDER BY k", + static_cast(&t)), + "读回两行"); + h.eq_int(static_cast(t.row_count()), 2, "两行都在"); + if (t.row_count() != 2) return; + + h.eq_int(static_cast(t.at(0, 0).bytes().size()), 3000, + "二进制跨块:长度是 3000 字节"); + h.check(t.at(0, 0).bytes() == blob, "二进制跨块:逐字节相等"); + h.check(t.at(1, 1).to_text() == wide, + "代理对跨块:逐字节相等(实得 " + + std::to_string(t.at(1, 1).to_text().size()) + " 字节,真值 " + + std::to_string(wide.size()) + ")"); + + // 拿它当**条件**查:写路径(UTF-8 -> UTF-16 的代理对编码)也要对得上。 + uvcpp_db_table one; + h.ok(h.db->query("SELECT k FROM " + table + " WHERE s = ?", {wide}, + static_cast(&one)), + "拿代理对样本当条件查"); + h.eq_int(static_cast(one.row_count()), 1, "条件命中 1 行"); + + h.db->execute("DROP TABLE " + table); +} + +} // namespace + +int main() { + const char* url = std::getenv("UVCPP_DB_TEST_ODBC_URL"); + const char* require = std::getenv("UVCPP_DB_TEST_REQUIRE"); + + if (url == nullptr || *url == '\0') { + if (require != nullptr && *require != '\0') { + std::cerr << "UVCPP_DB_TEST_REQUIRE 已设,但 UVCPP_DB_TEST_ODBC_URL 是空的" + << " —— 这是失败,不是跳过(CI 上数据源应该已经起来了)" + << std::endl; + return 1; + } + std::cout << "SKIP: 没给 UVCPP_DB_TEST_ODBC_URL,ODBC 后端这一条未判定" + << std::endl; + return 3; // ctest 的 SKIP_RETURN_CODE + } + + // 先确认这个库真的带了 ODBC 后端。CMake 那边已经把"没编进来"的用例摘掉了, + // 所以走到这里还说没有,就是配置错了 —— 报错,不跳过。 + if (uvcpp::uvcpp_db_drivers().find("odbc") == std::string::npos) { + std::cerr << "这份构建没有 ODBC 后端(已编入:" << uvcpp::uvcpp_db_drivers() + << ")—— 这一条用例不该被注册" << std::endl; + return 1; + } + + const std::string u(url); + const driver_family fam = driver_family_of(u); + + if (fam == kFamilyUnknown) { + // 认不出方言时**退出码 3**:这不是"ODBC 后端坏了",是"这个数据源的 DDL + // 本用例还不会写"。硬拿一份默认方言跑,红的会是套件里的建表语句,而那条红 + // 会指向完全错误的方向。 + std::cout << "SKIP: 认不出这个 URL 的方言(" << u + << ")—— 共用套件要按数据源的 DDL 跑,本用例现在只认 sqlite 与 " + "sql server 两族。ODBC 后端这一条**未判定**" << std::endl; + return 3; + } + + odbc_sqlite_dialect sqlite_d; + odbc_mssql_dialect mssql_d; + const dialect& d = (fam == kFamilySqlite) + ? static_cast(sqlite_d) + : static_cast(mssql_d); + + std::cout << "-- ODBC (" << u << ") --" << std::endl; + const int suite_rc = run_suite(u, d); + + std::cout << "-- ODBC 专属 --" << std::endl; + harness h; + uvcpp_db_client db; + h.db = &db; + h.d = &d; + if (db.open(u) == uvcpp_db_status::OK) { + test_binding_is_per_parameter(h, fam); + test_wide_text_round_trip(h, fam); + test_chunk_boundaries(h, fam); + test_last_insert_id_is_honest(h, fam, u); + test_driver_passthrough(h); + db.close(); + } else { + h.check(false, "专属判据要一个打开的连接,但 open 失败:" + db.last_error()); + } + + std::cout << "-- ODBC 专属:" << h.passed << " 通过 / " << h.failed << " 失败 / " + << h.not_applicable << " 不适用 --" << std::endl; + std::cout << (suite_rc == 0 && h.failed == 0 ? "db_odbc_func: 全部通过" + : "db_odbc_func: 有失败") + << std::endl; + return (suite_rc == 0 && h.failed == 0) ? 0 : 1; +} diff --git a/tests/functional/db_odbc_nodrv_func.cpp b/tests/functional/db_odbc_nodrv_func.cpp new file mode 100644 index 00000000..7fd6ae5e --- /dev/null +++ b/tests/functional/db_odbc_nodrv_func.cpp @@ -0,0 +1,183 @@ +/** + * @file tests/functional/db_odbc_nodrv_func.cpp + * @brief 数据库模块 · ODBC 后端:**这台机器上没有驱动管理器**时的行为。 + * + * ## 这条用例在验什么 + * + * 预编译包里带着 db 的四个后端,而 ODBC 那一支要的不是"某一个客户端库",是**驱动 + * 管理器**(Linux 的 `libodbc.so.2`、Windows 的 `odbc32.dll`、macOS 的 + * `libodbc.2.dylib`)。管理器是运行期 `dlopen` / `LoadLibraryA` 找的 + * (`UVCPP_DB_RUNTIME_LOAD=ON`,所以它**不在** `DT_NEEDED` / 导入表里)—— 于是 + * "使用者机器上没装 unixODBC"是**预期之内**的部署形态,不是错误。这条用例钉住这个 + * 形态下的三件事: + * + * 1. `open("odbc://…")` 回 **`NO_DRIVER`** —— 不崩、不 abort,也不是"连接失败" + * 那种含糊的码(`OPEN_FAILED` 在这个模块里的意思是"管理器在、数据源连不上", + * 两个码混起来,排障第一步就会走到"换个包"上去); + * 2. `last_error()` 是一句**人话**,而且**说得出它试过什么**(带上 + * `UVCPP_DB_ODBC_LIBRARY` 那个值和解析出的路径)—— 现场第一步就是看这句话; + * 3. **同一个进程里别的后端照常可用**(下面真跑一遍 SQLite 的建表/插入/查询/ + * 读回)—— "少一份 ODBC 动态库"不该波及整份库,这正是"一份二进制带全部功能、 + * 要 ODBC 的人只多带一份动态库"那句话的后半句。 + * + * ## 怎么把"没装管理器"确定性地做出来 + * + * 不去真的卸载 unixODBC,而是把 `UVCPP_DB_ODBC_LIBRARY` 指到一个**不存在的路径**上。 + * 这不是取巧,是这个旋钮写在文档里的用途(`src/db/uvcpp_db_runtime_load.h`:指到 + * 不存在的路径即可确定性地复现"这台机器没装客户端库",所以测试不依赖机器上装没装 + * 东西);它同时还是"显式指定就该是显式的"那条约定的必然结果 —— 环境变量一设, + * 候选名一个都不再试。 + * + * **必须在自己的进程里跑**,所以这是独立目标、而不是并进 `db_odbc_func.cpp`: + * 符号加载走的是 magic static,进程内一旦成功加载过 ODBC 管理器,之后再设环境变量 + * 也不生效(那是"句柄永不释放"的直接后果,理由在 `uvcpp_db_runtime_load.h` 里)。 + * 换句话说:**这条用例只有在进程开头的第一次 ODBC 接触之前设好环境变量才有意义**, + * 而"独立目标 + ctest 的 ENVIRONMENT 属性"恰好就是唯一能保证这件事的办法。 + * + * ## 前提由 ctest 给(`tests/functional/CMakeLists.txt` 里那条 ENVIRONMENT) + * + * 这一条的前提取自环境变量,而那份环境变量是**我们自己**在 CMake 里设的 —— 机器上 + * 装了什么、没装什么,都不影响它(本机装没装 unixODBC 都跑得动)。所以: + * + * * 没设 `UVCPP_DB_ODBC_LIBRARY`,或它指到一个**真实存在**的文件 → **红**(退出码 + * 1),不是跳过。这不是"未判定",是"这条用例没按它的前提跑"。与 mysql/pgsql 那 + * 两条"CI 上服务端都起了还跳过就必须红"同一条规矩,只是这里更强:前提完全在 + * 我们手里,没有任何"机器没配好"可以当借口。 + * * 想手动跑: + * `UVCPP_DB_ODBC_LIBRARY=/nonexistent/libodbc.so.2 ./test_db_odbc_nodrv_func` + * (Windows 上用 `Z:/nonexistent/odbc32.dll` 这种肯定不存在的路径)。 + */ + +#include +#include +#include +#include + +#include + +#include "db_suite.h" + +namespace { + +// 与 `db_odbc_func.cpp` / `db_sqlite_func.cpp` 同一个形状:断言类型全在 +// `uvcpp_db_test` 里,下面这些辅助函数跟着它一起在匿名命名空间里,`main()` +// 再靠这个 using 指令从全局找得到 `harness` / `uvcpp_db_client`。 +using namespace uvcpp_db_test; + +/// 路径存在吗。用 `std::fopen` 而不是 ``:本仓的 C++ 标准是 11 +/// (根 `CMakeLists.txt` 的 `CMAKE_CXX_STANDARD`),而这里要判的是"这个文件在不在", +/// 一个 `fopen` 足够,不值得为它抬标准或者挂一份 `-lstdc++fs`。 +bool path_exists(const std::string& p) { + std::FILE* f = std::fopen(p.c_str(), "rb"); + if (f == nullptr) return false; + std::fclose(f); + return true; +} + +/// 前提不成立时的统一出口:**红**,并把"该怎么跑"说出来。 +int premise_broken(const std::string& why) { + std::cerr << "这一条用例的前提没满足:" << why << "\n" + << "它必须在 UVCPP_DB_ODBC_LIBRARY 指向一个**不存在**的路径时跑" + "(ctest 那边由 tests/functional/CMakeLists.txt 的 ENVIRONMENT 属性" + "设好)。手动跑的话:\n" + << " UVCPP_DB_ODBC_LIBRARY=/nonexistent/libodbc.so.2 " + "./test_db_odbc_nodrv_func\n" + << "(Windows 上用 Z:/nonexistent/odbc32.dll 这种肯定不存在的路径)" + << std::endl; + return 1; +} + +} // namespace + +int main() { + const char* override_path = std::getenv("UVCPP_DB_ODBC_LIBRARY"); + if (override_path == nullptr || *override_path == '\0') { + return premise_broken("UVCPP_DB_ODBC_LIBRARY 没设"); + } + if (path_exists(override_path)) { + return premise_broken(std::string("UVCPP_DB_ODBC_LIBRARY 指的 ") + override_path + + " 真的存在 —— 那这一条就变成了「管理器在、连不上数据源」," + "测的不是同一件事"); + } + + // 先确认这份构建真的带了 ODBC 后端:CMake 那边已经把"没编进来"的用例摘掉了 + // (`NOT UVCPP_ENABLE_DB_ODBC` 那条 FILTER),所以走到这里还说没有就是配置错了 + // —— 报错,不跳过。下面那条"管理器不在 ⇒ NO_DRIVER"的判据只有在后端**编进来了** + // 的时候才有意义:没编进来时 `open("odbc://…")` 也会回 NO_DRIVER,但那是 + // "没有这个后端",与"后端在、管理器不在"完全是两件事。 + if (uvcpp::uvcpp_db_drivers().find("odbc") == std::string::npos) { + std::cerr << "这份构建没有 ODBC 后端(已编入:" << uvcpp::uvcpp_db_drivers() + << ")—— 这一条用例不该被注册" << std::endl; + return 1; + } + + harness h; + int failures = 0; + + std::cout << "-- ODBC 后端在、驱动管理器不在 --" << std::endl; + + uvcpp_db_client odbc; + h.db = &odbc; + const uvcpp_db_status st = odbc.open("odbc://u:p@127.0.0.1:1/uvcpp_nodrv"); + h.eq_status(st, uvcpp_db_status::NO_DRIVER, + "open() 回 NO_DRIVER(不是 OPEN_FAILED、更不是崩)"); + h.check(!odbc.is_open(), "打不开之后 is_open() 必须是 false"); + + const std::string err = odbc.last_error(); + h.check(err.find("加载 ODBC 驱动管理器失败") != std::string::npos, + "last_error 说得清是**管理器**没加载上(而不是「连接失败」)"); + h.check(err.find("UVCPP_DB_ODBC_LIBRARY=") != std::string::npos, + "last_error 里带上了试过的那个环境变量名"); + h.check(err.find(override_path) != std::string::npos, + std::string("last_error 里带上了解析出的路径本身(") + override_path + ")"); + h.check(err.find("只用别的后端时不用管这一条") != std::string::npos, + "last_error 里说清了「只是不用 ODBC 的话不受影响」"); + if (h.failed != 0) { + // 错误信息本身不对时,别接着跑 SQLite 那一段 —— 那会把"报错写得不清楚"淹没在 + // 一串无关的通过里。 + std::cerr << "last_error 原文:" << err << std::endl; + } + + // 同一进程里再开一次 ODBC:加载失败这件事被 magic static 记着,第二次必须还是 + // NO_DRIVER —— 而不是"第一次失败、第二次换条路"。这条看着多余,但它正是"加载 + // 只发生一次、且失败可重复"那两条约定的见证:谁哪天把 `manager_api_state()` 改成 + // 每次重试(或者反过来,第一次失败后置成不可恢复的硬错),这里会先响。 + uvcpp_db_client odbc_again; + h.db = &odbc_again; + h.eq_status(odbc_again.open("odbc://u:p@127.0.0.1:1/uvcpp_nodrv"), + uvcpp_db_status::NO_DRIVER, "同一进程里第二次 open 同样回 NO_DRIVER"); + h.check(!odbc_again.is_open(), "第二次也没打开"); + + // 其他后端照常 —— 这才是"一份二进制带全部功能、要 ODBC 的只多带一份动态库"的后半句。 + // 用 `sqlite::memory:`(`doc/db-guide.md` 里记的写法):不落文件、不用临时目录, + // 也就没有"上一轮跑剩下的库文件"这种偶发。 + std::cout << "-- 同一进程里 SQLite 照常可用 --" << std::endl; + uvcpp_db_client lite; + h.db = &lite; + h.ok(lite.open("sqlite::memory:"), "SQLite 后端照常打开"); + + h.ok(lite.execute("CREATE TABLE uvcpp_nodrv (k INTEGER PRIMARY KEY, v TEXT)"), + "建表"); + h.ok(lite.execute("INSERT INTO uvcpp_nodrv (k, v) VALUES (?, ?)", + {int64_t(7), std::string("仍然可用")}), + "插入一行(带参数)"); + + uvcpp_db_table t; + h.ok(lite.query("SELECT k, v FROM uvcpp_nodrv", static_cast(&t)), + "查回来"); + h.eq_int(static_cast(t.row_count()), 1, "一行"); + if (t.row_count() == 1) { + h.eq_int(t.at(0, 0).to_int64(), 7, "k 对得上"); + h.eq_text(t.at(0, 1).to_text(), "仍然可用", "v 逐字节对得上(UTF-8 中文)"); + } + lite.close(); + h.check(!lite.is_open(), "close() 之后 is_open() 是 false"); + + std::cout << "-- 结果:" << h.passed << " 通过 / " << h.failed << " 失败 --" + << std::endl; + failures += h.failed; + std::cout << (failures == 0 ? "db_odbc_nodrv_func: 全部通过" + : "db_odbc_nodrv_func: 有失败") + << std::endl; + return failures == 0 ? 0 : 1; +} diff --git a/tests/functional/db_suite.h b/tests/functional/db_suite.h index e080a11d..7ddf827d 100644 --- a/tests/functional/db_suite.h +++ b/tests/functional/db_suite.h @@ -73,8 +73,20 @@ class dialect { /// 在 SQLite 上跑绿,**不**代表它在 PostgreSQL 上也行 —— 那边报 /// `PREPARE_FAILED`(`syntax error at end of input`)。所以"绿了"这件事得按 /// 后端分开说,见证见 `test_placeholder_dialect()`。 + /// + /// **同一个数据源走 ODBC 会变成 false**:ODBC 到 SQLite 这条路上占位符是 + /// 驱动在数,sqliteodbc 只认 `?`(实测 `$1` 报 + /// `parameter marker count incorrect`)。所以"SQLite 收 `$1`"是**那个后端**的 + /// 性质,不是 SQLite 这个数据库的性质。 virtual bool accepts_dollar_placeholder() const { return false; } + /// `SQLPrepare` 在这个后端/驱动上是不是**真的**准备了。默认 true。 + /// + /// false 表示准备是空操作,SQL 要到执行那一刻才被解析 —— 于是语法错、表不存在 + /// 这类错在 `test_errors()` 里报的是 `EXEC_FAILED` 而不是 `PREPARE_FAILED`。 + /// ODBC 把"什么时候解析"交给驱动,sqliteodbc 就是这种(读数是实测的)。 + virtual bool prepares_eagerly() const { return true; } + /// 一张"每种类型来一列"的表。`e_val` 三家的声明都必须带 /// `NOT NULL DEFAULT ''` —— 见 `test_empty_string_is_not_null()`。 virtual std::string create_types_table(const std::string& t) const = 0; @@ -620,14 +632,21 @@ inline void test_schema(harness& h, const std::string& table) { /// 10. 错误路径。**每一条都必须"不执行任何语句"地失败**,而不是执行一半。 inline void test_errors(harness& h, const std::string& table) { + // 语法错与表不存在,在**准备期**就报出来的后端是 PREPARE_FAILED;准备是空操作 + // 的那种驱动(`prepares_eagerly() == false`,ODBC 到 sqliteodbc 就是)要到执行 + // 那一刻才知道,报的必然是 EXEC_FAILED。这不是两种"坏",是同一个错被两条路 + // 发现 —— 所以判据按后端分开写,而不是把某个后端的读数当成通用契约。 + const uvcpp_db_status want_prepare = + h.d->prepares_eagerly() ? uvcpp_db_status::PREPARE_FAILED + : uvcpp_db_status::EXEC_FAILED; + const std::string bad_sql = "SELCT * FROM " + table; uvcpp_db_table t; - h.eq_status(h.db->query(bad_sql, &t), uvcpp_db_status::PREPARE_FAILED, - "语法错的 SQL -> PREPARE_FAILED"); + h.eq_status(h.db->query(bad_sql, &t), want_prepare, "语法错的 SQL"); uvcpp_db_table t2; h.eq_status(h.db->query("SELECT * FROM uvcpp_no_such_table_xyz", &t2), - uvcpp_db_status::PREPARE_FAILED, "不存在的表 -> PREPARE_FAILED"); + want_prepare, "不存在的表"); // 参数个数对不上:三家必须在**本地**或**服务端**同一处拒绝,且报同一个码。 // 不统一的话,调用方就没法写一句 `if (st == BIND_FAILED) 修参数`。 diff --git a/tests/tools/check_doc_snippets.py b/tests/tools/check_doc_snippets.py index 8566593d..84e7d112 100644 --- a/tests/tools/check_doc_snippets.py +++ b/tests/tools/check_doc_snippets.py @@ -289,7 +289,7 @@ def first_line_marker(body): # `quic/` 属于**第一档**("宏为 0 时类会消失"):本模块的**四个**公开头整段套在 # `#if UVCPP_QUIC_ENABLE` 里(另外两个头 `uvcpp_quic_ngtcp2.h` 与 # `uvcpp_quic_session.h` 是私有的,不进包、文档也不该引它们)。模块关掉时 -# `CMakeLists.txt:3049-3049` 的 install 规则确实不装它们, +# `CMakeLists.txt:3120-3120` 的 install 规则确实不装它们, # 但那条只挡住 `cmake --install`:`package_release.py` 是从 `src/` 逐目录拷头的、 # 与开关无关(同上面 wsdl 那段记的形状,实测过 —— 一份 `UVCPP_ENABLE_QUIC=OFF` # 的包里有 `include/quic/` 四个头,而没有 `uvcpp_quic_ngtcp2.h`)。所以在**发布包** diff --git a/tests/tools/check_docs.py b/tests/tools/check_docs.py index d4aec7d8..5e13db23 100644 --- a/tests/tools/check_docs.py +++ b/tests/tools/check_docs.py @@ -65,13 +65,13 @@ def ok(what): # --------------------------------------------------------------------------- # **必须允许前导空白**:`UVCPP_BUILD_FUNCTIONAL` 是缩进在 `if(UVCPP_BUILD_TESTS)` -# 里面的(CMakeLists.txt:2833-2833),写成 `^option\(` 会静默少一个 —— 而少的那一个 +# 里面的(CMakeLists.txt:2904-2904),写成 `^option\(` 会静默少一个 —— 而少的那一个 # 恰好是"只在某个前提成立时才存在"的那一类,也就是最该被盯住的。 OPTION_RE = re.compile(r"^\s*option\(\s*([A-Za-z_][A-Za-z0-9_]*)", re.M) # `UVCPP_TRY_WRITE_MIN_BYTES` 跨两行(`set(` 换行后才写值),而 `[^)]*` 是取反字符类、 # 天然跨行,所以不用 DOTALL。**只认 `CACHE STRING`**:`set(UVCPP_LIB_TARGET ... CACHE -# INTERNAL ...)`(CMakeLists.txt:2819-2819)同样是 `UVCPP_` 前缀 + CACHE 形状,但它是个内部 +# INTERNAL ...)`(CMakeLists.txt:2890-2890)同样是 `UVCPP_` 前缀 + CACHE 形状,但它是个内部 # 变量,不是旋钮。 CACHE_STRING_RE = re.compile( r"^\s*set\(\s*(UVCPP_[A-Za-z0-9_]*)\s+[^)]*CACHE\s+STRING", re.M) diff --git a/tests/tools/doc_line_refs.lock b/tests/tools/doc_line_refs.lock index 141f0e5f..d46eae97 100644 --- a/tests/tools/doc_line_refs.lock +++ b/tests/tools/doc_line_refs.lock @@ -3,81 +3,81 @@ # 格式:<文件>:<起>-<止> # 后两列是**尾部锚点**(`-` = 区间只有一行、没有这个锚点):用来判「区间被内部插/删了行」,理由见脚本文件头。 CMakeLists.txt:122-122 147faa0531ba28f7 147faa0531ba28f7 - -CMakeLists.txt:1265-1265 bca1eb88b6c022d6 bca1eb88b6c022d6 - CMakeLists.txt:13-15 8268b2f87aeffcb3 45a477e305de30bc a2610ceafefb36ae -CMakeLists.txt:1330-1330 7507f15e2a39705f 7507f15e2a39705f - -CMakeLists.txt:1342-1342 ae83b5db663cfc83 ae83b5db663cfc83 - -CMakeLists.txt:1404-1404 4c32db63ce9707d7 4c32db63ce9707d7 - +CMakeLists.txt:1334-1334 bca1eb88b6c022d6 bca1eb88b6c022d6 - +CMakeLists.txt:1399-1399 7507f15e2a39705f 7507f15e2a39705f - +CMakeLists.txt:1411-1411 ae83b5db663cfc83 ae83b5db663cfc83 - CMakeLists.txt:143-143 c60affd86ed9f92a c60affd86ed9f92a - -CMakeLists.txt:1443-1443 c9a89b64a410bd35 c9a89b64a410bd35 - -CMakeLists.txt:1457-1457 1aa4a028404af983 1aa4a028404af983 - -CMakeLists.txt:1528-1536 69e14c6236c163b2 d6de6970f4db1a07 b9d79421b34560fa -CMakeLists.txt:1544-1550 c5fc893aa7d18fa3 d483a8300239fe81 84596bbfaa11fe0a +CMakeLists.txt:1473-1473 4c32db63ce9707d7 4c32db63ce9707d7 - +CMakeLists.txt:1512-1512 c9a89b64a410bd35 c9a89b64a410bd35 - +CMakeLists.txt:1526-1526 1aa4a028404af983 1aa4a028404af983 - CMakeLists.txt:159-159 ec21dd9177da1017 ec21dd9177da1017 - +CMakeLists.txt:1597-1605 69e14c6236c163b2 d6de6970f4db1a07 b9d79421b34560fa CMakeLists.txt:16-16 622cef3e23980a0b 622cef3e23980a0b - -CMakeLists.txt:1667-1667 81476067003985a5 81476067003985a5 - -CMakeLists.txt:1727-1727 81476067003985a5 81476067003985a5 - -CMakeLists.txt:1819-1819 efedb37c4c19a3c0 efedb37c4c19a3c0 - +CMakeLists.txt:1613-1619 c5fc893aa7d18fa3 d483a8300239fe81 84596bbfaa11fe0a +CMakeLists.txt:1736-1736 81476067003985a5 81476067003985a5 - +CMakeLists.txt:1796-1796 81476067003985a5 81476067003985a5 - +CMakeLists.txt:1888-1888 efedb37c4c19a3c0 efedb37c4c19a3c0 - CMakeLists.txt:189-189 2c0786b391ddc1c8 2c0786b391ddc1c8 - -CMakeLists.txt:1907-1916 7e0525f67ce2a0c8 d483a8300239fe81 7e66cb3bad5c4ce1 -CMakeLists.txt:1915-1915 7e66cb3bad5c4ce1 7e66cb3bad5c4ce1 - -CMakeLists.txt:1951-1951 e6eaf14767522ccb e6eaf14767522ccb - -CMakeLists.txt:1951-1953 3b4c91b678196c72 2edef8311cc16c7d 4203c17caf76ca71 -CMakeLists.txt:1959-1960 1295d64247ee5ebf f6eb70bb68e31c3d bc0f1401494fb527 -CMakeLists.txt:1995-1995 dd1f1235f833821d dd1f1235f833821d - -CMakeLists.txt:2000-2000 110771a2981514e9 110771a2981514e9 - -CMakeLists.txt:2009-2009 519a4cb6beee59f4 519a4cb6beee59f4 - -CMakeLists.txt:2023-2023 3639f058cd92a2cd 3639f058cd92a2cd - -CMakeLists.txt:2147-2147 ba10ebbf2070090e ba10ebbf2070090e - -CMakeLists.txt:222-236 59b22ad7fdc516ee 89e15674e4730156 c5cb01bc0af84c23 -CMakeLists.txt:223-223 695784a24021eabf 695784a24021eabf - -CMakeLists.txt:2284-2285 544cae034c4b7dd9 ae23b666e16ecffe f0116ba37d513d24 -CMakeLists.txt:2307-2307 4169bb4ce7b4c0da 4169bb4ce7b4c0da - -CMakeLists.txt:2309-2309 1d054c55978f517a 1d054c55978f517a - -CMakeLists.txt:233-234 f98b9cc25b0f3082 9bdbf2dd785f40c4 7e30539740cb0a1b -CMakeLists.txt:236-236 89e15674e4730156 89e15674e4730156 - -CMakeLists.txt:2445-2487 a9e96e5f6c968f9c d483a8300239fe81 a0b5d54b55c00ee6 -CMakeLists.txt:250-250 5897d2538d488fb9 5897d2538d488fb9 - -CMakeLists.txt:252-252 ee4796ba98a0da0b ee4796ba98a0da0b - -CMakeLists.txt:2629-2629 f1e99550dd80dc7d f1e99550dd80dc7d - -CMakeLists.txt:2732-2732 9b420934030132df 9b420934030132df - -CMakeLists.txt:2776-2776 9e1f3efdb9da2727 9e1f3efdb9da2727 - -CMakeLists.txt:2819-2819 017f72fdf67f5f9c 017f72fdf67f5f9c - -CMakeLists.txt:2833-2833 ba7837489a6f698d ba7837489a6f698d - -CMakeLists.txt:2870-3176 20ace9b2e9920247 04c7527b764b2205 da39a3ee5e6b4b0d -CMakeLists.txt:2920-2923 9ee2634d8590c8a8 d483a8300239fe81 eedf8624b192179d -CMakeLists.txt:2939-2944 136099b61c02b874 2da86ae33be9196d 439ae4e87a125249 +CMakeLists.txt:1976-1985 7e0525f67ce2a0c8 d483a8300239fe81 7e66cb3bad5c4ce1 +CMakeLists.txt:1984-1984 7e66cb3bad5c4ce1 7e66cb3bad5c4ce1 - +CMakeLists.txt:2020-2020 e6eaf14767522ccb e6eaf14767522ccb - +CMakeLists.txt:2020-2022 3b4c91b678196c72 2edef8311cc16c7d 4203c17caf76ca71 +CMakeLists.txt:2028-2029 1295d64247ee5ebf f6eb70bb68e31c3d bc0f1401494fb527 +CMakeLists.txt:2064-2064 dd1f1235f833821d dd1f1235f833821d - +CMakeLists.txt:2069-2069 110771a2981514e9 110771a2981514e9 - +CMakeLists.txt:2078-2078 519a4cb6beee59f4 519a4cb6beee59f4 - +CMakeLists.txt:2092-2092 3639f058cd92a2cd 3639f058cd92a2cd - +CMakeLists.txt:2216-2216 ba10ebbf2070090e ba10ebbf2070090e - +CMakeLists.txt:235-249 59b22ad7fdc516ee 89e15674e4730156 c5cb01bc0af84c23 +CMakeLists.txt:2353-2354 544cae034c4b7dd9 ae23b666e16ecffe f0116ba37d513d24 +CMakeLists.txt:236-236 695784a24021eabf 695784a24021eabf - +CMakeLists.txt:2376-2376 4169bb4ce7b4c0da 4169bb4ce7b4c0da - +CMakeLists.txt:2378-2378 1d054c55978f517a 1d054c55978f517a - +CMakeLists.txt:246-247 f98b9cc25b0f3082 9bdbf2dd785f40c4 7e30539740cb0a1b +CMakeLists.txt:249-249 89e15674e4730156 89e15674e4730156 - +CMakeLists.txt:2516-2558 a9e96e5f6c968f9c d483a8300239fe81 a0b5d54b55c00ee6 +CMakeLists.txt:263-263 5897d2538d488fb9 5897d2538d488fb9 - +CMakeLists.txt:265-265 ee4796ba98a0da0b ee4796ba98a0da0b - +CMakeLists.txt:2700-2700 f1e99550dd80dc7d f1e99550dd80dc7d - +CMakeLists.txt:2803-2803 9b420934030132df 9b420934030132df - +CMakeLists.txt:2847-2847 9e1f3efdb9da2727 9e1f3efdb9da2727 - +CMakeLists.txt:2890-2890 017f72fdf67f5f9c 017f72fdf67f5f9c - +CMakeLists.txt:2904-2904 ba7837489a6f698d ba7837489a6f698d - +CMakeLists.txt:2941-3249 3aad0a2647842616 04c7527b764b2205 da39a3ee5e6b4b0d +CMakeLists.txt:2991-2994 9ee2634d8590c8a8 d483a8300239fe81 eedf8624b192179d CMakeLists.txt:30-37 0805a0e3033f88a3 1bc5922231c799b4 a0b5d54b55c00ee6 -CMakeLists.txt:3019-3020 0783bddb14762bbf 77ae7433df62df69 7b7da528bdd6b3dc -CMakeLists.txt:3029-3029 dc47a271c1e60add dc47a271c1e60add - -CMakeLists.txt:3047-3047 03e1cd70f2221b46 03e1cd70f2221b46 - -CMakeLists.txt:3049-3049 67e118285cc2d304 67e118285cc2d304 - -CMakeLists.txt:3070-3070 23328eefdf8e4fee 23328eefdf8e4fee - -CMakeLists.txt:3156-3159 809b3c536e6c4740 d483a8300239fe81 2da83c91fe5c72e5 -CMakeLists.txt:3217-3217 714c97211cb46a65 714c97211cb46a65 - -CMakeLists.txt:3325-3325 cd4e49682028492d cd4e49682028492d - -CMakeLists.txt:409-409 285b3125d1799b48 285b3125d1799b48 - -CMakeLists.txt:420-420 aa41a613c3dea36a aa41a613c3dea36a - -CMakeLists.txt:424-424 2765438f70cc5afe 2765438f70cc5afe - -CMakeLists.txt:432-432 9e61c1888f63da12 9e61c1888f63da12 - -CMakeLists.txt:510-510 85fa3e4ffdce19a1 85fa3e4ffdce19a1 - -CMakeLists.txt:534-534 adf1bab5cd4b11a0 adf1bab5cd4b11a0 - -CMakeLists.txt:595-595 66f195432ad07018 66f195432ad07018 - +CMakeLists.txt:3010-3015 136099b61c02b874 2da86ae33be9196d 439ae4e87a125249 +CMakeLists.txt:3090-3091 0783bddb14762bbf 77ae7433df62df69 7b7da528bdd6b3dc +CMakeLists.txt:3100-3100 dc47a271c1e60add dc47a271c1e60add - +CMakeLists.txt:3118-3118 03e1cd70f2221b46 03e1cd70f2221b46 - +CMakeLists.txt:3120-3120 67e118285cc2d304 67e118285cc2d304 - +CMakeLists.txt:3141-3141 23328eefdf8e4fee 23328eefdf8e4fee - +CMakeLists.txt:3229-3232 809b3c536e6c4740 d483a8300239fe81 2da83c91fe5c72e5 +CMakeLists.txt:3290-3290 714c97211cb46a65 714c97211cb46a65 - +CMakeLists.txt:3398-3398 cd4e49682028492d cd4e49682028492d - +CMakeLists.txt:422-422 285b3125d1799b48 285b3125d1799b48 - +CMakeLists.txt:433-433 aa41a613c3dea36a aa41a613c3dea36a - +CMakeLists.txt:437-437 2765438f70cc5afe 2765438f70cc5afe - +CMakeLists.txt:445-445 9e61c1888f63da12 9e61c1888f63da12 - +CMakeLists.txt:523-523 85fa3e4ffdce19a1 85fa3e4ffdce19a1 - +CMakeLists.txt:547-547 adf1bab5cd4b11a0 adf1bab5cd4b11a0 - CMakeLists.txt:6-6 835af525b88bec74 835af525b88bec74 - -CMakeLists.txt:604-604 d8012e3f7923cf4f d8012e3f7923cf4f - -CMakeLists.txt:607-621 97141d86d37823fc 75774cbf835cb0b5 fc4313fc81980d8b -CMakeLists.txt:626-626 3a22c2d0cd98d748 3a22c2d0cd98d748 - +CMakeLists.txt:608-608 66f195432ad07018 66f195432ad07018 - +CMakeLists.txt:617-617 d8012e3f7923cf4f d8012e3f7923cf4f - +CMakeLists.txt:620-634 97141d86d37823fc 75774cbf835cb0b5 fc4313fc81980d8b +CMakeLists.txt:639-639 3a22c2d0cd98d748 3a22c2d0cd98d748 - CMakeLists.txt:65-65 1dba8bdfc9071f92 1dba8bdfc9071f92 - CMakeLists.txt:71-71 7b19e4255d610053 7b19e4255d610053 - -CMakeLists.txt:721-721 13f759f390b3188b 13f759f390b3188b - -CMakeLists.txt:728-728 1751d7d914bca144 1751d7d914bca144 - -CMakeLists.txt:764-764 aa18c45ef7f6795a aa18c45ef7f6795a - -CMakeLists.txt:767-767 162cf1539ee5438b 162cf1539ee5438b - -CMakeLists.txt:774-774 955da32ed2122e62 955da32ed2122e62 - -CMakeLists.txt:785-785 12974823b546187f 12974823b546187f - -CMakeLists.txt:942-942 646230d9ebf93ef4 646230d9ebf93ef4 - -CMakeLists.txt:960-960 075f8ca073d6e384 075f8ca073d6e384 - -CMakeLists.txt:967-967 f209c420bc688e6b f209c420bc688e6b - +CMakeLists.txt:734-734 13f759f390b3188b 13f759f390b3188b - +CMakeLists.txt:741-741 1751d7d914bca144 1751d7d914bca144 - +CMakeLists.txt:777-777 aa18c45ef7f6795a aa18c45ef7f6795a - +CMakeLists.txt:780-780 162cf1539ee5438b 162cf1539ee5438b - +CMakeLists.txt:787-787 955da32ed2122e62 955da32ed2122e62 - +CMakeLists.txt:798-798 12974823b546187f 12974823b546187f - +CMakeLists.txt:955-955 646230d9ebf93ef4 646230d9ebf93ef4 - +CMakeLists.txt:973-973 075f8ca073d6e384 075f8ca073d6e384 - +CMakeLists.txt:980-980 f209c420bc688e6b f209c420bc688e6b - src/expand/uvcpp_memory_pool.cpp:256-273 f515fb3e4707d626 c2b7df6201fdd336 0c9608d989883adc src/expand/uvcpp_memory_pool.cpp:275-292 b59d73701badb183 c2b7df6201fdd336 0c9608d989883adc src/expand/uvcpp_memory_pool.cpp:361-361 24b885117f83459e 24b885117f83459e - diff --git a/tests/tools/package_release.py b/tests/tools/package_release.py index f5d98166..119d57bd 100644 --- a/tests/tools/package_release.py +++ b/tests/tools/package_release.py @@ -30,7 +30,7 @@ ROOT = os.path.dirname(os.path.dirname(HERE)) # tests/tools -> tests -> repo def _header_version(): - """版本号的唯一来源是 `src/uvcpp/uvcpp_version.h`(`CMakeLists.txt:1528-1536` + """版本号的唯一来源是 `src/uvcpp/uvcpp_version.h`(`CMakeLists.txt:1597-1605` 在 configure 期读的也是它)。这里以前写死 "1.1.0":标签打到 v1.1.5 时,产出的 zip 名和 uvcpp.pc 的 Version 仍然自称 1.1.0 —— 两者只差一个字符串,出包时谁也 不会去核对。读不到就停下,不自作主张退回默认值:一个名字说谎的包比不出包更坏。 @@ -103,13 +103,14 @@ def num(macro): "uvcpp_db_driver.h", # 同样私有,但是另一条理由:`UVCPP_DB_RUNTIME_LOAD` 用的 # **函数指针表**就是后端的内部 ABI(漏绑一个符号就是空指针 - # 调用,没有任何链接期兜底),而两份 api 头还各自 - # `#include ` / `` —— 发出去等于 - # 让使用者以为客户端库的头也是我们的依赖。公开面 + # 调用,没有任何链接期兜底),而三份 api 头还各自 + # `#include ` / `` / `` —— + # 发出去等于让使用者以为客户端库的头也是我们的依赖。公开面 # (`uvcpp_db_client.h`)一个客户端库的类型都不出现。 # `uvcpp_db_runtime_load.h` 是 `dlopen`/`dlsym` 的两个原语。 "uvcpp_db_mysql_api.h", "uvcpp_db_pgsql_api.h", + "uvcpp_db_odbc_api.h", "uvcpp_db_runtime_load.h"} # 按**构建树**决定要不要发的头:与 `PRIVATE_HEADERS` 那条规矩不同,它们不是 @@ -122,9 +123,9 @@ def num(macro): # 的错误码,任何配置下都发。 # # `CMakeLists.txt` 的 `list(FILTER UVCPP_HEADERS EXCLUDE REGEX -# "src/grpc/uvcpp_grpc_server\\.h")`(`CMakeLists.txt:1951-1953`)与 +# "src/grpc/uvcpp_grpc_server\\.h")`(`CMakeLists.txt:2020-2022`)与 # `list(FILTER GRPC_HEADER_FILES EXCLUDE REGEX "uvcpp_grpc_server\\.h$")` -# (`CMakeLists.txt:2939-2944`)是同一条排除的两处 —— **三处必须一起改**: +# (`CMakeLists.txt:3010-3015`)是同一条排除的两处 —— **三处必须一起改**: # 这里漏了只影响发布包,CMake 那两处漏了只影响 `cmake --install`,三边都不报错。 WEB_GATED_HEADERS = {"uvcpp_grpc_server.h"} @@ -251,7 +252,14 @@ def find_dir(candidates): "comdlg32.dll", "crypt32.dll", "cryptbase.dll", "dbghelp.dll", "dnsapi.dll", "dwmapi.dll", "gdi32.dll", "gdi32full.dll", "imm32.dll", "iphlpapi.dll", "kernel32.dll", "kernelbase.dll", "msvcrt.dll", "mswsock.dll", - "netapi32.dll", "normaliz.dll", "ntdll.dll", "ole32.dll", "oleaut32.dll", + # `odbc32.dll` 是 1.7.2 补进来的,Windows 的 ODBC 驱动管理器:System32 里必有 + # (和 `ws2_32.dll` 同类,属操作系统组件,不可再分发也不需要分发)。db 模块的 + # ODBC 后端在 `UVCPP_DB_RUNTIME_LOAD=OFF`(**默认档**)时就是链它,所以少了这一 + # 行的后果是"默认配置在 Windows 上打不出包":`imports 里非 Windows 自带的` 会列 + # 上它,接着按 `runtime_roots` 找不到 ⇒ 记进 `missing[]` ⇒ 退出 2。 + # 抓到它的是 MSVC 那两条 config-contract 腿(它们不传 `RUNTIME_LOAD`,于是走 + # 默认档、链了导入库;六条发布腿传 `ON`,导入表里没有它 —— 所以发版路径上看不见)。 + "netapi32.dll", "normaliz.dll", "ntdll.dll", "odbc32.dll", "ole32.dll", "oleaut32.dll", "powrprof.dll", "psapi.dll", "rpcrt4.dll", "secur32.dll", "setupapi.dll", "shell32.dll", "shlwapi.dll", "user32.dll", "userenv.dll", "usp10.dll", "version.dll", "win32u.dll", "winmm.dll", "winspool.drv", "ws2_32.dll", @@ -1156,7 +1164,7 @@ def main(): # **曾经是差异 2**:补 zlib.h / zconf.h —— `web/uvcpp_ws_parser.h` 在 # UVCPP_ZLIB_ENABLE=1 时要 include 它,而 install 规则里原先没有这一条。 - # 2026-10-12 起 install 规则自己也装了(`CMakeLists.txt:3156-3159`),所以这 + # 2026-10-12 起 install 规则自己也装了(`CMakeLists.txt:3229-3232`),所以这 # 不再是"刻意的差异",只是本脚本手工组装 stage、**不走 `cmake --install`** 时 # 对同一件事的重复一遍 —— 两边必须落到同一个位置(`include/` 顶层)。 z = dep_src(tree, repo, "zlib-131") or dep_src(tree, repo, "zlib")