diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..c5293df --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,282 @@ +--- +name: Build and Test + +# TODO(@maltesander): The Trino integration suite (./integration-tests/setup.sh +# followed by ./integration-tests/run-tests.sh) is not run +# here. The core compose stack is Trino plus Postgres; the +# oauth and spooling profiles add Keycloak and MinIO, are +# opt-in, and are not proposed for CI. Whether the core +# stack fits a standard runner is unmeasured, and +# ubuntu-24.04 is 4 vCPU / 16 GB, so it should be tried +# rather than assumed. If it does not fit, record the +# measured failure here in place of this note. Until then, +# run it locally before a release. Unit tests run on every +# PR below. + +permissions: + contents: read + +on: + push: + branches: + - main + pull_request: + merge_group: + +# Supersede in-flight runs on the same ref. Never cancel in a merge queue: a +# cancelled merge_group run reports failure and evicts the PR from the queue. +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.event_name == 'pull_request' }} + +env: + CARGO_TERM_COLOR: always + RUST_TOOLCHAIN_VERSION: "1.95.0" + +# Every job below names a runner image rather than a `-latest` alias, so an +# image roll cannot change what a merge is gated on. The label cannot be lifted +# into a variable: `runs-on` accepts no `env` context, and the one context that +# would work, `vars`, holds its value in repository settings rather than here. +jobs: + # Formatting, clippy (which is what enforces the unwrap_used / + # unwrap_in_result / panic denies from Cargo.toml), cargo-deny and + # cargo-sort. This lives in this workflow rather than its own because + # `needs:` cannot cross workflows, and a lint gate the required check does + # not observe is not a gate. + pre-commit: + name: pre-commit + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + # The cargo-test pre-commit hook links libodbc via odbc-sys. + - name: Install host dependencies + uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0 + with: + packages: unixodbc-dev + # A cache key, not a runner label, but it tracks the runner image so + # that bumping the image invalidates the cached .deb files. + version: ubuntu-24.04 + + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + - name: Install Rust ${{ env.RUST_TOOLCHAIN_VERSION }} toolchain + uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # 1.95.0 + with: + toolchain: ${{ env.RUST_TOOLCHAIN_VERSION }} + components: rustfmt, clippy + + - name: Setup Rust Cache + uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 + + - name: Install cargo-deny and cargo-sort + uses: taiki-e/install-action@97a5807a604e12de3a13b52d868ebecaeeea757c # v2.75.4 + with: + tool: cargo-deny,cargo-sort + + - uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5.6.0 + with: + python-version: "3.12" + + - uses: pre-commit/action@2c7b3805fd2a0fd8c1884dcaebf91fc102a13ecd # v3.0.1 + + unit-tests: + name: Unit Tests + runs-on: ubuntu-24.04 + timeout-minutes: 20 + steps: + # odbc-sys links against libodbc/libodbcinst, so the unixODBC dev + # libraries must be present to link the test binaries (no running Driver + # Manager is needed — only the libraries). + - name: Install host dependencies + uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0 + with: + packages: unixodbc-dev + # A cache key, not a runner label, but it tracks the runner image so + # that bumping the image invalidates the cached .deb files. + version: ubuntu-24.04 + + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + - name: Install Rust ${{ env.RUST_TOOLCHAIN_VERSION }} toolchain + uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b + with: + toolchain: ${{ env.RUST_TOOLCHAIN_VERSION }} + + - name: Setup Rust Cache + uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 + + # --locked so CI tests the dependency versions Cargo.lock pins, and so a + # Cargo.toml change without a matching lockfile update fails here instead + # of drifting. + - name: Run unit tests + run: cargo test --locked + + unit-tests-windows: + name: Unit Tests (Windows) + runs-on: windows-2022 + timeout-minutes: 30 + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + # aws-lc-sys reaches this crate through trino-rust-client -> reqwest -> + # rustls, and its build script assembles x86_64 code with NASM. + # Installed explicitly rather than relied on from the runner image, so a + # future image change cannot turn this job red for a reason unrelated to + # the driver. + - name: Install NASM (required to build aws-lc-sys) + run: choco install nasm --no-progress -y + + - name: Add NASM to PATH + run: echo "C:\Program Files\NASM" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + - name: Install Rust ${{ env.RUST_TOOLCHAIN_VERSION }} toolchain + uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b + with: + toolchain: ${{ env.RUST_TOOLCHAIN_VERSION }} + targets: x86_64-pc-windows-gnu + + # A separate cache key: the Linux job's artefacts are a different target + # triple and sharing the key would thrash both. + - name: Setup Rust Cache + uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 + with: + key: windows-gnu-test + + # The GNU target's linker. The runner image ships MSYS2, but its mingw64 + # bin directory is not on PATH by default. + - name: Add MinGW to PATH + run: echo "C:\msys64\mingw64\bin" | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append + + # `--target x86_64-pc-windows-gnu`, not the runner's default MSVC triple. + # That is the target release.yaml builds and packaging/build-archives.sh + # ships, and a suite passing against a toolchain nobody receives is only + # evidence about that toolchain. odbc-sys links odbc32, which comes with + # the Windows SDK already on the runner, so there is no equivalent of the + # unixodbc-dev install the Linux jobs need. + - name: Run unit tests + run: cargo test --locked --target x86_64-pc-windows-gnu + + # Builds both shipping artifacts the way release.yaml does, and checks the two + # properties that are invisible in a unit test run: that the DLL exports the + # ODBC entry points, and that what each artifact links at load time still + # matches packaging/sbom-native.json. The SBOM declares native dependencies by + # hand, since no cargo metadata describes them, so nothing but this check keeps + # the declaration true. + release-artifacts: + name: Release Artifacts + runs-on: ubuntu-24.04 + timeout-minutes: 20 + needs: [unit-tests] + steps: + - name: Install MinGW cross-compiler + run: sudo apt-get update && sudo apt-get install -y gcc-mingw-w64-x86-64 unixodbc-dev + + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + - name: Install Rust ${{ env.RUST_TOOLCHAIN_VERSION }} toolchain + uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b + with: + toolchain: ${{ env.RUST_TOOLCHAIN_VERSION }} + targets: x86_64-pc-windows-gnu + + - name: Setup Rust Cache + uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 + with: + key: windows-gnu + + - name: Build Linux shared library + run: cargo build --locked --release + + - name: Build Windows DLL + run: cargo build --locked --target x86_64-pc-windows-gnu --release + + # Named symbols and a floor, not a printed count. The count alone used to + # be piped into `xargs echo`, whose exit status is what the step was + # graded on, so a DLL exporting nothing at all passed. The Driver Manager + # resolves these by name, and a missing one is a load failure on a user's + # machine that no unit test can see. + # + # ConfigDSNW is in the list because it is the only Windows-only export: + # core builds it nowhere else, and it is what the ODBC Administrator's + # Add.../Configure... buttons call. The Linux .so has 60 of these; the DLL + # has 61. + - name: Verify DLL exports + run: | + DLL=target/x86_64-pc-windows-gnu/release/stackable_odbc_trino.dll + EXPORTS=$(x86_64-w64-mingw32-objdump -p "$DLL" \ + | awk '/Export Address Table/,/Ordinal base/' \ + | grep -oE '\b(SQL|Config)[A-Za-z]+\b' | sort -u) + echo "$EXPORTS" | tr '\n' ' '; echo + + missing="" + for sym in SQLAllocHandle SQLFreeHandle SQLDriverConnectW SQLConnectW \ + SQLBrowseConnectW SQLDisconnect SQLPrepareW SQLExecute \ + SQLExecDirectW SQLBindParameter SQLDescribeParam SQLFetch \ + SQLGetData SQLNumResultCols SQLDescribeColW SQLGetInfoW \ + SQLGetTypeInfoW SQLGetDiagRecW SQLTablesW SQLColumnsW \ + SQLEndTran SQLCancel ConfigDSNW; do + grep -qx "$sym" <<< "$EXPORTS" || missing="$missing $sym" + done + if [ -n "$missing" ]; then + echo "::error::the DLL does not export:$missing" + exit 1 + fi + + # A floor as well as the named set, so a wholesale regression in + # core's forward_ffi! is caught even if these particular names survive. + count=$(echo "$EXPORTS" | grep -c .) + if [ "$count" -lt 55 ]; then + echo "::error::only $count ODBC symbols exported; expected at least 55" + exit 1 + fi + echo "Trino DLL: $count ODBC symbols exported, all required names present" + + # Two different assertions behind one flag. For the .so it compares + # DT_NEEDED against the sonames sbom-native.json declares, in both + # directions. For the .dll it asserts the mingw runtime is still linked + # statically: the release archive ships no runtime DLL, so an artifact + # that imported one would fail to load on a user's machine. + # + # Only the release binaries are checked, and only here rather than in + # release.yaml, because a pull request is where a dependency change can + # still be reverted cheaply. + - name: Verify declared native dependencies + run: | + ./packaging/sbom.sh --check-native target/release/libstackable_odbc_trino.so + ./packaging/sbom.sh --check-native target/x86_64-pc-windows-gnu/release/stackable_odbc_trino.dll + + # Single required check for branch protection rules. + finished: + name: Finished Build and Test + if: always() + needs: + - pre-commit + - unit-tests + - unit-tests-windows + - release-artifacts + runs-on: ubuntu-24.04 + timeout-minutes: 5 + steps: + # Derived from needs.* rather than a hand-written list of job names: a job + # added to `needs` above but forgotten here would otherwise be silently + # non-blocking, which is exactly how the lint gate came to sit in a + # workflow this check never observed. + - name: Check job results + env: + RESULTS: ${{ join(needs.*.result, ' ') }} + run: | + for result in $RESULTS; do + if [[ "$result" != "success" ]]; then + echo "One or more jobs did not succeed: $RESULTS" + exit 1 + fi + done + echo "All jobs passed: $RESULTS" diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..703b51f --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,256 @@ +--- +name: Release + +on: + push: + tags: + # The tag format cargo-release produces; see release.toml. + - "v*" + workflow_dispatch: + +# Read at the top level; the two jobs that need more grant it to themselves. +# Attestation needs an OIDC token, and only the publishing job writes. +permissions: + contents: read + +env: + CARGO_TERM_COLOR: always + RUST_TOOLCHAIN_VERSION: "1.95.0" + # Both pinned rather than floating. packaging/test-sbom.sh asserts the shape of + # what syft emits and how cargo-auditable's .dep-v0 section reads, so a release + # of either that changes that shape has to be adopted deliberately and + # re-verified, not picked up silently on the next tag push. + SYFT_VERSION: "v1.50.0" + CARGO_AUDITABLE_VERSION: "0.7.5" + +# Every job below names a runner image rather than a `-latest` alias, so an +# image roll cannot change what a tagged release is built against. See +# build.yaml for why the label is repeated rather than named once. +jobs: + verify-version: + name: Verify tag matches Cargo.toml + runs-on: ubuntu-24.04 + outputs: + version: ${{ steps.extract.outputs.version }} + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + - id: extract + name: Compare tag and Cargo.toml version + run: | + TAG="${GITHUB_REF#refs/tags/}" + TAG_VERSION="${TAG#v}" + CARGO_VERSION=$(grep -m1 '^version = ' Cargo.toml | sed -E 's/version = "(.+)"/\1/') + if [ "$TAG_VERSION" != "$CARGO_VERSION" ]; then + echo "::error::Tag $TAG says version $TAG_VERSION but Cargo.toml has $CARGO_VERSION" + exit 1 + fi + echo "version=$CARGO_VERSION" >> "$GITHUB_OUTPUT" + echo "Verified: releasing stackable-odbc-trino $CARGO_VERSION" + + build-and-package: + name: Build and package release archives + runs-on: ubuntu-24.04 + needs: [verify-version] + # id-token and attestations are what actions/attest-* exchange for a + # Sigstore signing certificate; contents stays read, since this job + # publishes nothing. + permissions: + contents: read + id-token: write + attestations: write + steps: + - name: Install host dependencies + run: | + sudo apt-get update + sudo apt-get install -y unixodbc-dev gcc-mingw-w64-x86-64 zip + + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + - name: Install Rust ${{ env.RUST_TOOLCHAIN_VERSION }} toolchain + uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b + with: + toolchain: ${{ env.RUST_TOOLCHAIN_VERSION }} + targets: x86_64-pc-windows-gnu + + - name: Setup Rust Cache + uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 + with: + key: release + + # packaging/sbom.sh reads the .dep-v0 section cargo-auditable embeds, and + # refuses an artifact without one. Both tools are therefore preconditions + # of packaging, not optional extras. + - name: Install cargo-auditable + uses: taiki-e/install-action@97a5807a604e12de3a13b52d868ebecaeeea757c # v2.75.4 + with: + tool: cargo-auditable@${{ env.CARGO_AUDITABLE_VERSION }} + + - name: Install syft + uses: anchore/sbom-action/download-syft@e22c389904149dbc22b58101806040fa8d37a610 # v0.24.0 + with: + syft-version: ${{ env.SYFT_VERSION }} + + # `cargo auditable build`, not `cargo build`: the plain form links the same + # code but embeds no dependency graph, and the SBOM then lists a handful of + # components instead of the whole tree. + # + # --locked so the released binary is built from the versions Cargo.lock + # pins. The SBOM describes what was linked, so an unlocked build would + # produce an accurate document about an unintended dependency set. + - name: Build Linux release binary + run: cargo auditable build --locked --release + + - name: Build Windows release binary (cross) + run: cargo auditable build --locked --release --target x86_64-pc-windows-gnu + + - name: Assemble release archives + env: + VERSION: ${{ needs.verify-version.outputs.version }} + run: ./packaging/build-archives.sh + + - name: Sanity-check archive contents + env: + VERSION: ${{ needs.verify-version.outputs.version }} + run: | + DIST=packaging/dist + LINUX="$DIST/stackable-odbc-trino-${VERSION}-linux-x64.tar.gz" + WINDOWS="$DIST/stackable-odbc-trino-${VERSION}-windows-x64.zip" + MEZ="$DIST/StackableTrinoODBC-${VERSION}.mez" + + echo "--- Linux archive ---" + tar -tzf "$LINUX" + for f in libstackable_odbc_trino.so install.sh uninstall.sh README.md LICENSE \ + libstackable_odbc_trino.so.cdx.json; do + tar -tzf "$LINUX" | grep -qx "./$f" || { echo "::error::missing $f in linux archive"; exit 1; } + done + # Asserted absent, not merely unlisted. Power BI Desktop is + # Windows-only, so the connector ships in the zip and as a standalone + # asset; README.md spent a release claiming the tarball carried it + # too, and a check that only looks for missing files cannot catch a + # promise about a file that was never there. + if tar -tzf "$LINUX" | grep -q '\.mez$'; then + echo "::error::the linux archive carries a .mez; README.md says it does not" + exit 1 + fi + + echo "--- Windows archive ---" + unzip -l "$WINDOWS" + # configure-dsn.ps1 is checked because install.bat refuses to run + # without it: it is the ODBC Administrator's Add.../Configure... dialog. + for f in stackable_odbc_trino.dll StackableTrinoODBC.mez install.bat uninstall.bat \ + configure-dsn.ps1 README.md LICENSE \ + stackable_odbc_trino.dll.cdx.json StackableTrinoODBC.mez.cdx.json; do + unzip -l "$WINDOWS" | grep -q " $f\$" || { echo "::error::missing $f in windows archive"; exit 1; } + done + + echo "--- Standalone .mez ---" + test -f "$MEZ" || { echo "::error::missing standalone .mez"; exit 1; } + unzip -l "$MEZ" | grep -q 'StackableTrinoODBC.pq' || { echo "::error::.mez missing StackableTrinoODBC.pq"; exit 1; } + + echo "Archive sanity check passed." + + # TODO(@maltesander): The published binaries are unsigned. Authenticode for + # stackable_odbc_trino.dll and `MakePQX sign` for the + # .mez both need a code-signing certificate, which has + # not been bought. Until then Windows SmartScreen warns + # on the installer and Power BI refuses the connector + # unless the user lowers its security level. The + # attestations below are a different guarantee: they + # prove where an artifact was built, not who vouches + # for it, and no operating system consults them. + + # Signs a statement that these files came out of this workflow, at this + # commit, and records it in the public transparency log. Verified with + # `gh attestation verify --repo stackabletech/stackable-odbc-trino`. + # sha256sums.txt is included so the SBOM assets, which it covers, are + # reachable from an attested file. + - name: Attest build provenance + uses: actions/attest-build-provenance@0f67c3f4856b2e3261c31976d6725780e5e4c373 # v4.1.1 + with: + subject-path: | + packaging/dist/*.tar.gz + packaging/dist/*.zip + packaging/dist/*.mez + packaging/dist/sha256sums.txt + + # One call per artifact, because each binds exactly one SBOM to one + # subject. The CycloneDX document is the one attested; the SPDX one beside + # it is a conversion of the same data for consumers that need that format. + - name: Attest SBOM for the Linux archive + uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0 + with: + subject-path: packaging/dist/stackable-odbc-trino-${{ needs.verify-version.outputs.version }}-linux-x64.tar.gz + sbom-path: packaging/dist/stackable-odbc-trino-${{ needs.verify-version.outputs.version }}-linux-x64.cdx.json + + - name: Attest SBOM for the Windows archive + uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0 + with: + subject-path: packaging/dist/stackable-odbc-trino-${{ needs.verify-version.outputs.version }}-windows-x64.zip + sbom-path: packaging/dist/stackable-odbc-trino-${{ needs.verify-version.outputs.version }}-windows-x64.cdx.json + + - name: Attest SBOM for the standalone connector + uses: actions/attest-sbom@c604332985a26aa8cf1bdc465b92731239ec6b9e # v4.1.0 + with: + subject-path: packaging/dist/StackableTrinoODBC-${{ needs.verify-version.outputs.version }}.mez + sbom-path: packaging/dist/StackableTrinoODBC-${{ needs.verify-version.outputs.version }}.cdx.json + + - name: Upload archives as workflow artifacts + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: release-archives + path: packaging/dist/* + retention-days: 7 + + publish-release: + name: Publish GitHub Release + runs-on: ubuntu-24.04 + needs: [verify-version, build-and-package] + # The only job that writes, and it writes exactly one thing: the release. + permissions: + contents: write + steps: + - name: Download archives + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: release-archives + path: dist + + - name: Determine prerelease flag + id: prerelease + env: + VERSION: ${{ needs.verify-version.outputs.version }} + run: | + if [[ "$VERSION" == *-* ]]; then + echo "flag=true" >> "$GITHUB_OUTPUT" + else + echo "flag=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create GitHub Release + uses: softprops/action-gh-release@72f2c25fcb47643c292f7107632f7a47c1df5cd8 # v2.3.2 + with: + tag_name: ${{ github.ref_name }} + name: stackable-odbc-trino ${{ needs.verify-version.outputs.version }} + generate_release_notes: true + prerelease: ${{ steps.prerelease.outputs.flag }} + # Each archive already carries its own CycloneDX SBOM, so an offline + # install has one. The six standalone documents are here for whoever + # needs to read an SBOM without downloading and unpacking a release, + # and in SPDX as well as CycloneDX because tools take one or the other. + # sha256sums.txt covers every file listed above it. + files: | + dist/stackable-odbc-trino-${{ needs.verify-version.outputs.version }}-linux-x64.tar.gz + dist/stackable-odbc-trino-${{ needs.verify-version.outputs.version }}-windows-x64.zip + dist/StackableTrinoODBC-${{ needs.verify-version.outputs.version }}.mez + dist/stackable-odbc-trino-${{ needs.verify-version.outputs.version }}-linux-x64.cdx.json + dist/stackable-odbc-trino-${{ needs.verify-version.outputs.version }}-linux-x64.spdx.json + dist/stackable-odbc-trino-${{ needs.verify-version.outputs.version }}-windows-x64.cdx.json + dist/stackable-odbc-trino-${{ needs.verify-version.outputs.version }}-windows-x64.spdx.json + dist/StackableTrinoODBC-${{ needs.verify-version.outputs.version }}.cdx.json + dist/StackableTrinoODBC-${{ needs.verify-version.outputs.version }}.spdx.json + dist/sha256sums.txt diff --git a/.github/workflows/scorecard.yaml b/.github/workflows/scorecard.yaml new file mode 100644 index 0000000..01c3cca --- /dev/null +++ b/.github/workflows/scorecard.yaml @@ -0,0 +1,49 @@ +--- +name: OpenSSF Scorecard + +# Scorecard grades repository configuration rather than the crate. +# It scores pinned action SHAs, workflow permissions and release +# provenance, which makes it a regression check on the supply-chain work. +# +# `publish_results` and the SARIF upload both require a public repository. +# Runs before this one goes public are expected to fail. + +on: + branch_protection_rule: + schedule: + # Every Monday at 05:30 UTC: https://crontab.guru/#30_5_*_*_1 + - cron: '30 5 * * 1' + push: + branches: + - main + workflow_dispatch: + +permissions: read-all + +jobs: + analysis: + name: Scorecard analysis + runs-on: ubuntu-24.04 + permissions: + # Upload the results to the code-scanning dashboard. + security-events: write + # Publish results to the public Scorecard API, which backs the badge. + id-token: write + contents: read + actions: read + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + - name: Run analysis + uses: ossf/scorecard-action@2d1146689b8cda280b9bc96326124645441f03bc # v2.4.4 + with: + results_file: results.sarif + results_format: sarif + publish_results: true + + - name: Upload results to code scanning + uses: github/codeql-action/upload-sarif@a2983b8bed1923f44751c5c43237f479442827b3 # v3.37.4 + with: + sarif_file: results.sarif diff --git a/.github/workflows/security_audit.yaml b/.github/workflows/security_audit.yaml new file mode 100644 index 0000000..ef7afc1 --- /dev/null +++ b/.github/workflows/security_audit.yaml @@ -0,0 +1,25 @@ +--- +name: Daily Security Audit + +on: + schedule: + # Run every day at 04:15 UTC: https://crontab.guru/#15_4_*_*_* + - cron: '15 4 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + audit: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + # This `token` is the action's own input, not checkout's: audit-check + # needs it to post the advisory annotations onto the run. + - uses: rustsec/audit-check@69366f33c96575abad1ee0dba8212993eecbe998 # v2.0.0 + with: + token: ${{ secrets.GITHUB_TOKEN }} diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8aaa152 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +debug/ +target/ +**/*.rs.bk +.worktrees/ + +.idea/ +*.iws +*.iml +.vscode/ + +# Generated via ctags -R. +tags + +# Local agent working notes (SDD reports); never part of the shipped tree +.superpowers/ + +# Power Query connector build output +connector/bin/ + +# Release packaging output +packaging/dist/ + +# Python bytecode from the test scripts +__pycache__/ +*.pyc + +# Local cargo overrides, e.g. a [patch] pointing core at a sibling checkout. +# See CONTRIBUTING.md. Never committed: it would redirect everyone else's build. +.cargo/ diff --git a/.markdownlint.yaml b/.markdownlint.yaml new file mode 100644 index 0000000..783004c --- /dev/null +++ b/.markdownlint.yaml @@ -0,0 +1,28 @@ +--- +# All defaults or options can be checked here: +# https://github.com/DavidAnson/markdownlint/blob/main/schema/.markdownlint.yaml + +# Default state for all rules +default: true + +# MD013/line-length - Line length +MD013: + # Number of characters + line_length: 9999 + # Number of characters for headings + heading_line_length: 9999 + # Number of characters for code blocks + code_block_line_length: 9999 + +# MD024/no-duplicate-heading/no-duplicate-header - Multiple headings with the same content +MD024: + # Only check sibling headings + siblings_only: true + +# MD040/fenced-code-language - Fenced code blocks should have a language specified +# We use plain fenced blocks for ODBC config files and output examples +MD040: false + +# MD060/table-column-style - Table column alignment +# Too strict for our tables +MD060: false diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..a83be51 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,83 @@ +--- +default_language_version: + node: system + +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: cef0300fd0fc4d2a87a85fa2093c6b283ea36f4b # 5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-toml + - id: check-merge-conflict + - id: mixed-line-ending + - id: detect-aws-credentials + args: ["--allow-missing-credentials"] + - id: detect-private-key + + - repo: https://github.com/igorshubovych/markdownlint-cli + rev: 192ad822316c3a22fb3d3cc8aa6eafa0b8488360 # 0.45.0 + hooks: + - id: markdownlint + + - repo: https://github.com/koalaman/shellcheck-precommit + rev: 2491238703a5d3415bb2b7ff11388bf775372f29 # 0.10.0 + hooks: + - id: shellcheck + # -x follows `source`d files. The integration-test scripts share + # lib.sh, and without it every one of them reports SC1091 for a file + # that is right there and checkable. + args: ["--severity=info", "-x"] + + - repo: local + hooks: + - id: cargo-test + name: cargo-test + language: system + entry: cargo test --locked + stages: [pre-commit, pre-merge-commit] + pass_filenames: false + files: \.rs$|Cargo\.(toml|lock) + + - id: cargo-rustfmt + name: cargo-rustfmt + language: system + entry: cargo fmt --all -- --check + stages: [pre-commit, pre-merge-commit] + pass_filenames: false + files: \.rs$ + + - id: cargo-clippy + name: cargo-clippy + language: system + entry: cargo clippy --locked --all-targets -- -D warnings + stages: [pre-commit, pre-merge-commit] + pass_filenames: false + files: \.rs$ + + # Not `--all-targets`: that builds the test target, sets `cfg(test)`, and + # would pull the `#[cfg(test)]` modules into the check. + - id: cargo-doc + name: cargo-doc + language: system + entry: env RUSTDOCFLAGS=-Dwarnings cargo doc --locked --no-deps --document-private-items + stages: [pre-commit, pre-merge-commit] + pass_filenames: false + files: \.rs$|Cargo\.(toml|lock) + + - id: cargo-sort + name: cargo-sort + language: system + entry: cargo sort --grouped --check + stages: [pre-commit, pre-merge-commit] + pass_filenames: false + files: Cargo\.toml$ + + - id: cargo-deny + name: cargo-deny + language: system + entry: cargo deny --locked check + stages: [pre-commit, pre-merge-commit] + pass_filenames: false + files: Cargo\.(toml|lock)|deny\.toml diff --git a/.readme/static/borrowed/Icon_Stackable.svg b/.readme/static/borrowed/Icon_Stackable.svg new file mode 100644 index 0000000..35e132a --- /dev/null +++ b/.readme/static/borrowed/Icon_Stackable.svg @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + + + + + + diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..19f4e71 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,1996 @@ +# Agent Guide + +Implementation details for AI agents working on `stackable-odbc-trino`. + +This crate is an ODBC driver for [Trino](https://trino.io). It contains **only** +Trino-specific code: the `Backend` and `StatementBackend` implementations, +connection-string parsing, Trino-to-ODBC type conversion, ODBC escape-sequence +translation, and the catalog and metadata functions. Everything generic lives in +[`stackable-odbc-core`](https://github.com/stackabletech/stackable-odbc-core): +handle management, UTF-16 marshalling, diagnostics, panic safety, and the +exported C ABI entry points. + +## Quick Reference + +| Topic | When to Read | +|-------|-------------| +| [Architecture of this crate](#architecture-of-this-crate) | Finding the module a change belongs in | +| [Relationship to core](#relationship-to-stackable-odbc-core) | Deciding whether a change belongs here at all | +| [Conventions](#conventions) | Any code change | +| [Backend error mapping](#backend-error-mapping) | Touching an error path | +| [Connection string keys](#connection-string-keys) | Adding or changing a parameter | +| [ODBC behaviour and design rationale](#odbc-behaviour-and-design-rationale) | Changing anything an application can observe | +| [Testing](#testing) | Writing or running tests | +| [Packaging and release](#packaging-and-release) | Cutting a release | + +```bash +cargo build # needs unixodbc-dev +cargo test # unit + FFI tests that need no server +cargo clippy --all-targets -- -D warnings +pre-commit run --all-files # the gate; run before every commit + +./integration-tests/setup.sh # start the stack (Docker), write ODBC config +./integration-tests/run-tests.sh # run the integration suite +./integration-tests/setup.sh --profile all # plus keycloak and minio +./integration-tests/run-tests.sh --suite tls # one suite by name +./integration-tests/scripts/teardown.sh # stop the stack +``` + +## Architecture of this crate + +| Module | What it does | +|--------|--------------| +| `src/lib.rs` | Module wiring and the `forward_ffi!` invocation. The entire export surface. | +| `src/backend.rs` | `TrinoBackend`, `TrinoConnection`, `TrinoStatement`; the `Backend` impl; `map_trino_error` | +| `src/backend/execute.rs` | `exec_direct`, `execute`, paging, `StatementBackend` (fetch, `column_count`, `describe_col`, `close_cursor`) | +| `src/backend/info.rs` | `SQLGetInfo` answers, the largest module. Typed `get_info` plus the raw `get_info_raw` path for info types with no `InfoType` variant | +| `src/backend/metadata.rs` | All ten catalog functions, plus the catalog / schema / table-type enumerations. Each returns typed rows; core builds and sorts the result set | +| `src/backend/describe_param.rs` | `SQLDescribeParam`, answered from `DESCRIBE INPUT` on a prepared statement, plus the per-connection cache that keeps it to one round trip per statement | +| `src/backend/params.rs` | Parameter interpolation. Trino has no wire-level parameter binding, so bound values are rendered into the SQL as literals. The escaping rules live here | +| `src/backend/prompt.rs` | Presenting an interactive OAuth 2.0 login URL: core's `Prompter` implemented as `BrowserPrompter`, and the adapter to the client's `RedirectHandler`. The only user of the `open` dependency | +| `src/backend/setup.rs` | `Backend::configure_dsn`, which answers the ODBC Data Source Administrator's **Add…** and **Configure…** buttons by running `packaging/windows/configure-dsn.ps1` | +| `src/backend/types/connect_params.rs` | Connection-string parsing, with `Redacted` secrets | +| `src/escape_dialect.rs` | ODBC escape sequences (`{fn ...}`, `{d ...}`, `{oj ...}`) → Trino SQL | +| `src/type_conversion.rs` | Trino type signatures → `SqlDataType`, and Trino values → `ColumnValue` | +| `src/ffi_integration_tests.rs` | Tests that drive the real C ABI entry points | + +### The Tokio bridge + +Core's `Backend` trait is synchronous, but `trino-rust-client` is async. Each +`TrinoConnection` therefore owns a current-thread Tokio runtime and every call +into the client goes through `conn.runtime.block_on(...)`. Never introduce a +second runtime, and never `block_on` from inside an async context. + +### Paging + +Trino's REST protocol returns results as a chain of pages linked by `nextUri`. +`exec_direct` polls until the first page carrying column metadata arrives, since +a query can return several empty pages first, and stores the descriptors on the +statement. + +This matters for correctness beyond fetching. Core infers cursor state from +`StatementBackend::column_count`, which must therefore be accurate as soon as +`execute` / `exec_direct` returns, not merely after the first `fetch`. + +## Relationship to stackable-odbc-core + +`stackable-odbc-core` is its own repository, pulled in as a git dependency until +it is published to crates.io: + +```toml +stackable-odbc-core = { git = "https://github.com/stackabletech/stackable-odbc-core.git", tag = "v0.1.0" } +``` + +`Cargo.toml` carries a matching `TODO`, and `deny.toml` allows the repository by +name so that any *other* git dependency still fails `cargo deny`. The reference +is a tag, not a branch, so which core a build takes is stated in `Cargo.toml` +and moving to a newer core is a reviewable one-line edit; `Cargo.lock` pins the +commit the tag resolved to. `cargo publish` still cannot run: crates.io accepts +no git dependency, which is what the `TODO` clears. + +To build against a local core checkout, put a `[patch]` in your own +`.cargo/config.toml` rather than editing `Cargo.toml`, so the override cannot be +committed or shipped. `CONTRIBUTING.md` gives the snippet, and the SBOM gate in +`packaging/test-sbom.sh` fails if a path-sourced component other than the root +package reaches a release artifact. + +`Cargo.toml` carries a second `TODO`, on `trino-rust-client`. That dependency is +a git dependency on the fork's `stackable-main` branch, with the `spooling` +feature enabled, and it switches back to a crates.io version dep once the fork's +changes are released upstream. The API to read when checking what the client can +express is that branch, not the published crate. + +| Concern | Owner | +|---------|-------| +| Handle allocation, tag validation, `panic_safe` | core | +| UTF-16 marshalling, diagnostics, `SQLGetDiagRec` | core | +| The exported C ABI entry points (`forward_ffi!`) | core | +| Generic `SQLGetInfo` defaults, cursor-state tracking | core | +| `Backend` / `StatementBackend` trait definitions | core | +| Connecting to Trino, executing, fetching, cancelling | this crate | +| Trino type → SQL type mapping, value conversion | this crate | +| Catalog and metadata queries | this crate | +| Connection-string parsing | this crate | +| ODBC escape-sequence translation | this crate | + +`src/lib.rs` is the whole export surface: + +```rust +stackable_odbc_core::forward_ffi!(crate::backend::TrinoBackend); +``` + +That one line expands to every `#[unsafe(no_mangle)] pub unsafe extern "system"` +entry point. A new ODBC function is exported by adding it to core's +`forward_ffi!` macro, not here; this crate only implements whatever new trait +method it calls. + +## Conventions + +- Edition 2024, Rust 1.95.0 (pinned in `rust-toolchain.toml`) +- `snafu` for errors (the `unwrap_used`, `unwrap_in_result` and `panic` clippy + lints are denied outside tests) +- `tracing` for logging (not `println!` or `log`) +- `odbc-sys` links against `libodbc`/`libodbcinst`, so building or testing needs + the unixODBC dev libraries installed (`unixodbc-dev` on Debian/Ubuntu). No DSN + or running Driver Manager is required for `cargo test`. + +### Changelog + +This project keeps a [Keep a Changelog](https://keepachangelog.com/en/1.1.0/) +`CHANGELOG.md` and follows [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +Every user-facing change gets an entry under `## [Unreleased]` in the +appropriate `Added` / `Changed` / `Fixed` / `Removed` group. For a driver, +"user-facing" means anything an ODBC application can observe: a changed +SQLSTATE, a changed `SQLGetInfo` value, a new connection-string key, a different +type mapping. + +### Logging in backend methods + +Every `Backend` / `StatementBackend` method logs at entry with `tracing::debug!`, +naming the method as the application sees it: + +```rust +tracing::debug!(%sql, "TrinoBackend::exec_direct"); +``` + +Never log passwords, tokens, or connection-string content. `ConnectParams` wraps +secrets in `Redacted` for exactly this reason. Use `warn!` for intentional spec +deviations and for degraded behaviour, an unparseable Trino type signature say. +Do not `error!` for failures already expressed as a returned `TrinoError`: core +logs those at the FFI boundary, and doing both double-logs. + +### Named constants + +ODBC attribute values, info values, and bitmap constants must use named `const` +definitions, never raw integer literals for spec-defined values. Name them after +the ODBC spec name (`SQL_CB_CLOSE`, `SQL_TC_DML`, `SQL_OJ_LEFT`). + +**This applies to tests too**, and `src/backend/info.rs`'s `EXPECTED` snapshot +table is where raw literals creep back in most easily, with the spec name +relegated to a trailing comment. A comment is not a constant. + +Prefer an `odbc-sys` type over a new constant when one exists. Most spec values +are already modelled, and all are re-exported from `stackable_odbc_core::types`: + +| Value | Use | +|-------|-----| +| `SQL_BIGINT`, `SQL_VARCHAR`, … | `SqlDataType::EXT_BIG_INT.0` (note the `.0`) | +| `SQL_C_SBIGINT`, `SQL_C_WCHAR`, … | `CDataType::SBigInt as i16` | +| `SQL_PARAM_INPUT`, … | `ParamType::Input as i16` | +| `SQL_ATTR_*` | `StatementAttribute::*` / `ConnectionAttribute::*` | + +This crate declares no `odbc-sys` dependency of its own. Core re-exports it as +`stackable_odbc_core::odbc_sys`, and that is the one to reach for when a type is +needed that `types` does not re-export (`odbc_sys::Timestamp`, say). Declaring +`odbc-sys` separately lets cargo resolve a different version, and two versions +of a `#[repr(C)]` type are two different types to the compiler, with two +layouts, for a struct read out of a buffer core wrote. + +### Non-exhaustive types from core + +`ColumnDescriptor`, `TypeInfoRow`, `EscapeDialect`, +`CatalogResultColumnWidths` and the ten catalog row types (`TableRow`, +`ColumnRow`, `TablePrivilegeRow`, …) are `#[non_exhaustive]`, so struct-literal +syntax does not compile here and `..Default::default()` is not an escape hatch +either. Build them with the constructor plus `with_*` builders: + +```rust +ColumnDescriptor::new(name, sql_type) + .with_type_name(type_name) + .with_precision_scale(precision, scale) + +EscapeDialect::ansi_default().with_identifier_quotes(&[('"', '"')]) +``` + +`TypeInfoRow`'s string-setting builders (`new`, `with_literal_affixes`, +`with_create_params`, `with_local_type_name`) take `impl Into>`, and `Into` cannot run in a const context, so those four are not `const`. +The rows therefore live in `info::trino_type_info()`, built once behind a +`OnceLock`, rather than in a `static`. The remaining builders touch no string +field and stay `const`. + +Set only what differs from the default. The omitted builders are the row +claiming the least-committal value, which is why every row leaves `nullable` +and `searchable` alone. `nullable` is a `Nullable`, not a raw `i16`, matching +`ColumnDescriptor::nullable`. + +The catalog row types are the exception to the `with_*` naming: their setters +are named after their fields, because core generates them from the field list +with a `macro_rules!` that cannot build an identifier from parts. Each takes +`impl Into`, so an `Option` column accepts a bare `String` *or* the +`Option`, and a `String` column accepts a `&str`: + +```rust +TableRow::default() + .catalog(cat_val.as_str().map(str::to_string)) // Option + .name(name) // String + .table_type(odbc_type.to_string()) +``` + +Adding a column to a spec result set is a core-only change that generates one +more setter, so leave the columns the data source cannot answer unset rather +than spelling out a `None` for each. + +### Type cast safety + +Use `T::try_from(x)` over a bare `as T` wherever truncation is possible. Trino +returns 64-bit precision and scale values that ODBC exposes as 32- and 16-bit, +so `src/backend/execute.rs` and `src/backend/metadata.rs` are full of legitimate +narrowing. Do it fallibly, with a `warn!` on the fallback path. + +### Backend error mapping + +Every error originating from `trino-rust-client` must be routed through +`map_trino_error` (`src/backend.rs`). Never hand-build a `TrinoError` or +`OdbcError` from a client error at the call site. That function is the single +place that decides the SQLSTATE, and bypassing it silently degrades specific +codes to `HY000`. It yields `08S01` for link failures, `HYT00` for timeouts and +`28000` for auth errors. + +It also decides what reaches `SQLGetDiagRec` beyond the SQLSTATE. Anything it +does not classify becomes `TrinoError::Query`, which keeps the failure as its +`source` and lifts `QueryError::error_code` into the native error, so a +server-side rejection reaches the application as its own Trino code rather than +`0`. A variant that flattens the failure into a `String` throws both away, which +is why the specific arms are the exception and not the pattern. + +`CommunicationLinkFailure` is one of those exceptions, so `flatten_causes` walks +the reqwest error's chain into the message before the variant is built. Without +it a refused port, a certificate signed by an authority the client does not +trust and a host that does not resolve all reach the application as +`error sending request for url (...)`: `is_connect()` is set for all three, and +`reqwest::Error` names only its own layer while the sentence separating them +sits further down `source()`. Segments already present are dropped, because the +layers quote each other. + +Measured against the compose stack, each failure now ends in its own sentence: + +| what went wrong | what the message ends with | +|---|---| +| a trust anchor that signed nothing | `invalid peer certificate: UnknownIssuer` | +| connected by IP, so Jetty served its internal certificate | `invalid peer certificate: Other(OtherError(CaUsedAsEndEntity))` | +| nothing listening on the port | `tcp connect error: Connection refused (os error 111)` | +| the host does not resolve | `dns error: failed to lookup address information: Name or service not known` | + +The two certificate rows differ, which the SNI note below does not lead one to +expect: an unmatched SNI reaches a *different* certificate rather than a +mismatched one, and rustls rejects that as a CA presented as an end entity. The +one case still indistinguishable is a `Certificate=` file that is not a +certificate, which reads `UnknownIssuer` like an untrusted anchor because +reqwest defers parsing to the handshake rather than to `Ssl::read_pem`. + +The same applies wherever a reqwest error is stringified rather than attached, +which is three places: that arm, the timeout arm beside it, and the client build +in `connect`. The last one matters most for TLS, because +`reqwest::ClientBuilder::build` reports a trust store it cannot assemble as a +bare `builder error` and the client wraps that as `Error::HttpError`. Arms +carrying a `source` need none of this: `QueryCause::Transport` holds the client +error whole, and core's `Diagnostics` walks from there through reqwest to +whatever rustls said. + +The `source` is a `QueryCause`, not the client error itself, and `query_cause` +is the one place that decides which. A transport error is kept whole; its +`Display` is a single line. A server-side `QueryError` is reduced to +`[error_name]: message`, because its own `Display` renders `failure_info`, the +coordinator's Java stack, and core walks the whole causal chain into the +diagnostic. Measured against a live coordinator that put 1,700 to 15,000 +characters into every message, `DIVISION_BY_ZERO` being the worst at roughly +30 KB of UTF-16 across ~168 frames. + +Nothing actionable is lost. The stack describes the coordinator's internals, the +application already gets Trino's error code verbatim through `NativeErrorPtr`, +and the summary naming the failure is what led the message anyway. The full +`failure_info` is logged at `debug` instead, which is what `ODBC_LOG_LEVEL` / +`ODBC_LOG_FILE` exist for. + +Two of those arms carry weight beyond diagnostics: `validate_connection` matches +on `AuthFailure` and `QueryTimeout` to keep them at their own SQLSTATE instead +of reclassifying them as `08001`. Collapsing the classified variants into one +would move that silently. + +Two shapes occur: + +```rust +// Transport errors (trino_rust_client::error::Error) map directly. +conn.runtime.block_on(conn.client.get::(sql)).map_err(map_trino_error)? + +// Server-side query errors (QueryError on `page.error`) convert first. +// `From for Error` routes Trino error code 4 (PERMISSION_DENIED) +// to `Error::Forbidden`, which is what produces 28000. +if let Some(error) = page.error.take() { + return Err(map_trino_error(error.into())); +} +``` + +Every `Backend` and `StatementBackend` method returns `Self::Error`, which is +`TrinoError` for both, so there is no second error type to convert to at a call +site: `.map_err(map_trino_error)?` is the whole idiom. + +**Prefer `map_trino_error_on(&liveness, e)` wherever a `Liveness` handle is in +scope.** That is every path with a `TrinoConnection`, a `TrinoCancelToken`, or a +`TrinoStatement` (through its `map_client_error` helper). It delegates the +entire classification to `map_trino_error` and only observes the result, so the +"one place decides the SQLSTATE" rule is intact. What it adds is the +connection-level failure reaching `SQL_ATTR_CONNECTION_DEAD`. The bare +`map_trino_error` stays correct where no handle exists, and is what the wrapper +calls. + +Hand-built errors are correct in two cases only. The first is an *internal* +invariant violation that never came from the client: "get_data called before +fetch", a missing runtime handle, a poisoned mutex. The second is a +connection-setup failure where the call-site context ("failed to build Trino +client") is more useful than the mapped variant. + +Build those as an `OdbcError` and convert with `.into()` when they need a +SQLSTATE no `TrinoError` variant carries, `24000` on an abandoned result set +say. `TrinoError::Odbc` holds it and the reverse conversion unwraps it, so the +SQLSTATE and message survive intact rather than being remapped to `HY000`. That +variant is also what `From for TrinoError` produces, which is the +bound core requires so a defaulted trait body can construct an error and still +name `Self::Error`. + +### 08001 versus 08S01 + +`08001` ("client unable to establish connection") is only valid from the +connection functions. Once a connection exists, a failing link is `08S01` +("communication link failure"). That is the code the diagnostics tables of +`SQLExecute`, `SQLFetch`, `SQLGetInfo` and the rest list. + +This driver's `connect` performs no network I/O, only building the HTTP client, +so every failure `map_trino_error` sees is post-connection and maps to `08S01`. + +## Connection string keys + +`src/backend/types/connect_params.rs` parses them and is the authoritative list. +Keys are case-insensitive. + +The full table lives in [`README.md`](README.md#connecting), for the people who +install the driver rather than work on it. **A new key means two edits**: the +parser and that table. + +Four keys hold secrets, and `Backend::sensitive_connect_keywords` declares each +so that core never logs its value: `AccessToken` (with its `Token` alias), +`ExtraCredentials`, `ExtraHeaders` and `ProxyPassword`. Aliases are matched +whole and case-insensitively, so each one is listed individually. + +Five keys take a list of `name:value` pairs in JDBC's format verbatim: +`SessionProperties`, `ResourceEstimates`, `ExtraCredentials`, `Roles` and +`ExtraHeaders`. In a connection string those values need `{braces}`, because +JDBC separates pairs with `;` and so does ODBC; see +[README.md](README.md#values-that-contain-a-semicolon). Three further rules are +the driver's rather than the syntax's: + +- Unbraced, core's parser ends the value at the first `;` and discards the rest + as an unrecognised parameter, so every pair but the first vanishes silently. + `session_properties_unbraced_keep_only_the_first_pair` pins that, so the + requirement is recorded as behaviour and not only in prose. +- Only the *first* `:` splits a pair, so a value may contain one + (`s3://bucket/path`, `10:00`). +- A malformed pair fails the connection rather than being skipped. A dropped + session property changes how the query runs, and the result computed without + it is plausible enough that nobody would look. + +`QueryTimeout` is the *default* for the per-request HTTP timeout, not the last +word. `SQL_ATTR_CONNECTION_TIMEOUT` overrides it and `SQL_ATTR_LOGIN_TIMEOUT` +separately bounds the login round trip; see +[Timeouts, liveness, and the hooks left defaulted](#timeouts-liveness-and-the-hooks-left-defaulted). + +### TLS + +`TlsVerify` has three modes, not two, and takes both vocabularies. `true` and +`full` verify the chain *and* the hostname, `ca` verifies the chain only, and +`false` and `none` verify nothing. `SSLVerification` is an alias, so a value +lifted from a JDBC URL transfers unchanged. Both keys accept both vocabularies, +because there is no sense in which one name owns one set of words. + +Setting both keys is an error unless they resolve to the same mode. They are one +setting, and silently preferring either would leave the other looking honoured +when it was not, for a value whose failure mode is an unauthenticated +connection. + +**`ca` requires `Certificate`**, and `connect_params` rejects the combination +before the client sees it. rustls only permits skipping hostname verification +when the trust store is supplied explicitly, which excludes the platform's own +roots, so `ca` without a chain to verify against would trust nothing at all. The +client reports this too; catching it here names the connection-string keys +rather than the builder methods. + +`ca` exists for a coordinator reached under a name its certificate does not +carry, an IP or an internal DNS name. It is a much narrower compromise than +`none`, which is why it gets a quieter `warn!`: the certificate is still +verified, just not bound to a name. + +`ClientCertificate` is mutual TLS, and is independent of the two above. Either +may be set alone, and both feed one `Ssl`. It takes **one PEM file holding the +certificate chain followed by a PKCS#8 private key**. The client builds +`reqwest` on rustls, which accepts neither PKCS#12 nor JKS, so JDBC's +`SSLKeyStorePath` / `SSLKeyStoreType` have no equivalent and the key is named +for what it takes rather than for JDBC parity it cannot deliver. + +### Interactive OAuth 2.0 + +`ExternalAuthentication=true` selects Trino's external-authentication flow: the +coordinator answers with a login URL, a person visits it, and the client polls +for the bearer token. It needs `https`, excludes `Password` and `AccessToken`, +and is refused under `SQL_DRIVER_NOPROMPT`. `ExternalAuthenticationTimeout` is +the budget for one login, in seconds, defaulting to 300. Four things about the +path matter. + +**Core decides whether a connect may prompt; this driver decides how.** +`SQLDriverConnect`'s *DriverCompletion* is the spec's control over interaction, +and only core sees it. `TrinoBackend::prompter` declares what the driver +*could* do: `BrowserPrompter`, in `src/backend/prompt.rs`, which logs the URL +and then opens a browser. Core hands it back through `ConnectParams::prompter` +only when the call permits prompting. `connect` reads it from there and never +calls `Backend::prompter` itself, so under `SQL_DRIVER_NOPROMPT` it receives +`None`, there is nothing to call, and the rule cannot be forgotten. `open` is a +dependency of this crate and not of core. + +The log comes before the browser and happens unconditionally, because a Driver +Manager discards the driver's stderr. Under `isql`, Power BI or Excel, +`ODBC_LOG_FILE` / `ODBC_LOG_LEVEL` are the only channel that survives. A failed +browser launch is therefore **not** an error: the flow can still be completed +from the logged URL, since the client polls rather than waiting on the handler. + +**One login per identity per process.** The client caches the token in the +`Arc` behind an `Auth`, so clones share a login and a second +`Auth::new_oauth2` means a second browser. This driver builds a `Client` per +connection, so `OAUTH2_LOGINS` in `src/backend.rs` keys an `Auth` on +`(secure, host, port, user)` and hands out clones. Without it a pool warming +ten connections would open ten browsers. Expiry needs no handling: a stale +token yields a `401` and the client re-runs the flow behind the same `Arc`. + +**`SQL_ATTR_LOGIN_TIMEOUT` does not bound the interactive wait**, and one +`warn!` says so when both are set. The flow fires on the first `401`, inside +`validate_connection`, which is the very round trip `login_deadline` bounds. But +applications set login timeouts assuming a machine round trip, and a tool +defaulting to 15s would abort every login while the user was still typing. +`ExternalAuthenticationTimeout` bounds it instead. + +**`User` is optional under `ExternalAuthentication`, and `X-Trino-User` is then +left off entirely.** + +Trino settles why in `HttpRequestSessionContextFactory`. The header is +*optional* whenever a request carries an authenticated identity. The user falls +back to the token's, and `"User must be set"` fires only when neither exists: + +```java +String user = trinoUser != null ? trinoUser : authenticatedIdentity.map(Identity::getUser).orElse(null); +assertRequest(user != null, "User must be set"); +``` + +And a header that *disagrees* with the authenticated identity is read as an +impersonation request: + +```java +if (!authenticatedIdentity.getUser().equals(originalIdentity.getUser())) { + accessControl.checkCanImpersonateUser(authenticatedIdentity, originalIdentity.getUser()); +} +``` + +So a `User` that does not match what the identity provider's user-mapping +produces would fail the connection with an impersonation denial, for typing your +own name in the wrong form. Where the principal holds impersonation rights it +would instead run the session as somebody else, silently. Asking the operator to +invent one is therefore not a safe default, which is why `connect` calls +`ClientBuilder::without_user` when none is given. + +A `User` supplied *alongside* `ExternalAuthentication` is still honoured. One +that matches the provider's mapping is harmless, and one that does not is the +application asking for impersonation, which Trino judges on its own rules. +`SessionUser` is unaffected: naming somebody to run as while authenticating as +yourself is what it is for. + +This needs `trino-rust-client` to be able to omit the header at all. +`Session::user` is `Option` and `ClientBuilder::without_user` exists for +this. Building with neither a user nor authentication is the client's +`Error::MissingUser`, so the case Trino would reject with `User must be set` +never reaches the wire. + +## ODBC behaviour and design rationale + +What this driver reports, and why. Everything here is observable by an +application, so a change to any of it is a changelog entry. + +### Capability declarations take a connection + +The 33 required capability methods, plus `get_type_info` and `escape_dialect`, +take `&Self::Connection`. `SQLGetInfo` is a per-connection call, so what the +data source can do belongs to the connection rather than to the driver binary. +This driver answers all but one of them without reading it, because every value +is a fact about Trino-the-engine or about this driver's own SQL generation. +`TrinoConnection::server_major` is there for the ones that should eventually +gate on the coordinator's version. `dbms_version` is the exception, and reads +`TrinoConnection::dbms_version`. + +`cursor_commit_behavior`, `cursor_rollback_behavior`, +`catalog_result_column_widths`, `driver_name` and `driver_version` keep no +connection. The first three are consumed on paths that have none. The last two +describe the driver rather than the data source, and the Windows Driver Manager +asks for them before `SQLDriverConnectW`. Declaring those two is what lets core +answer the whole pre-connect identity group itself, so this driver's +`get_info_pre_connect` overrides nothing for the Driver Manager's benefit. + +**Nothing that a capability method declares may also have an arm in +`backend/info.rs`.** An arm there wins for `SQLGetInfo` while the method keeps +driving `SQLGetConnectAttr` and the `HY024` validation in +`sql_set_connect_attr`, so the two can disagree for one connection. The list of +info types this applies to is in the `_ => {}` comment at the end of +`trino_get_info`'s match. Ten info types are declared this way. + +Pre-connect, core passes `None` and skips every declaration that needs a +connection, substituting its own benign default. So a value this driver reports +when connected is not necessarily what `SQLGetInfo` returns before +`SQLDriverConnectW`. `info::get_info_snapshot` asserts the connected answers; +`get_info_every_named_info_type_has_the_declared_shape_pre_connect` covers the +other side. + +### Why the catalog cannot be set + +`TrinoBackend::current_catalog` reports the catalog the **session** is on, read +from `Client::session_snapshot`, and core feeds it to both readers the spec +makes synonyms: `SQLGetConnectAttr(SQL_ATTR_CURRENT_CATALOG)` and +`SQLGetInfo(SQL_DATABASE_NAME)`. That is the one place the value lives. Neither +has an arm in `info.rs`, and adding one would let the two disagree. The +`Catalog` connection-string value is the fallback, for the window before any +response has been seen. + +The session is the source because `USE postgresql.public` moves the +coordinator's catalog and reports it back in `X-Trino-Set-Catalog`, which the +client tracks. Measured against the live stack: `SQL_DATABASE_NAME` is `tpcds` +before, `postgresql` after, and an unqualified `SELECT count(*) FROM customers` +then resolves in `postgresql.public`. Reporting the connection-string value +there would name a catalog the session had left, while the application's own +unqualified names resolved somewhere else. +`backend_current_catalog_follows_a_use_statement` pins it. The snapshot takes a +read lock and performs no I/O, so a pool reading the attribute on every checkout +pays nothing for it. + +`Backend::set_current_catalog` is **not** implemented, so +`SQLSetConnectAttr(SQL_ATTR_CURRENT_CATALOG)` reports core's defaulted `HYC00`. +Trino cannot switch a catalog without also switching the schema, and it is the +second half that makes accepting the call a lie. Measured against a live +coordinator: + +| Statement | Result | +|---|---| +| `USE postgresql.public` | `X-Trino-Set-Catalog: postgresql`, `X-Trino-Set-Schema: public` | +| `USE postgresql` | `NOT_FOUND`, parsed as a *schema* named `postgresql` | + +`USE` is the only statement that moves the session catalog and its grammar +requires a schema, so honouring "set the catalog to X" means inventing one. +Every catalog has an `information_schema`, so the invention would succeed and +then leave an unqualified `SELECT ... FROM orders` resolving inside it. That is +the application's names pointing somewhere it never asked for, which is the +failure `HYC00` exists to avoid, displaced from the catalog to the schema. +Reconnecting under a new catalog is worse: it drops the session's prepared +statements and its connection pool, under a call the application thinks is an +attribute write. + +`trino-rust-client` is not the constraint and would need no change. It already +tracks `X-Trino-Set-Catalog` into its session and carries the new value on later +requests. Trino's grammar is the constraint. Revisit if `SET SESSION CATALOG`, +or any catalog-only form of `USE`, ever lands. + +A tool that sets the attribute during connection setup therefore sees +`SQL_ERROR`. Neither unixODBC nor the Power Query connector does, since the +connector passes `Catalog` in the connection string. All four +`integration-tests/run-tests.sh` configurations (DSN and DSN-less, verified and +unverified TLS) plus the Windows Driver Manager suite connect unaffected. + +### The three identity strings + +`SQL_DATA_SOURCE_NAME`, `SQL_SERVER_NAME` and `SQL_USER_NAME` are answered from +arms in `backend/info.rs`, reading fields `connect` fills in. Core answers each +with the empty string and says why: "the DM supplies the DSN; core has none", +and the other two are "carried in the connection string, not known here". Both +reasons are true of core and false of this driver. + +Only `SQL_DATA_SOURCE_NAME` has a spec-defined empty answer, and only "if the +connection string did not contain the `DSN` keyword". The other two have no such +clause, so an empty answer is a non-answer to an application rendering +"connected as". + +| Value | Source | +|---|---| +| `SQL_DATA_SOURCE_NAME` | `ConnectParams::dsn()`, empty when the application connected by driver | +| `SQL_SERVER_NAME` | the `Host` connection-string value | +| `SQL_USER_NAME` | Trino's `current_user`, read at connect | + +**`SQL_USER_NAME` is probed, not taken from `User`.** The spec defines it as +"the name used in a particular database, which can be different from the login +name", and here it does differ. Under `ExternalAuthentication` there is no +`User` at all, since `connect` calls `ClientBuilder::without_user`, and the +coordinator derives the identity from the token: the connection string names +nobody while the session runs as somebody. `SessionUser` is the other direction, +but only where the deployment grants impersonation. Against the test stack it is +refused at connect with `Access Denied: User admin cannot impersonate user +analyst`, which is the same rule `suites/test_oauth.py` measures for a +disagreeing `User`. So **`SessionUser` cannot be demonstrated here** and the +`ExternalAuthentication` case is the one that carries the argument. + +`session_user_name` orders the fallbacks for a failed probe: `SessionUser`, then +`User`, then empty. `SessionUser` comes first because a connection that carried +one and still succeeded is one whose impersonation Trino permitted. It is pinned +by unit test. + +It costs no round trip. `connect` already asks `SELECT version()` for +`SQL_DBMS_VER`, and `probe_session` widens that to +`SELECT version(), current_user`. An unparseable version does not discard the +user, which is why the two are read independently rather than through a shared +early return. + +Unlike the catalog, this is a **connect-time capture rather than a +`Client::session_snapshot` read**. Trino has no set-user response header and no +statement that moves the identity a session runs as, so there is nothing for a +snapshot to follow. + +Arms rather than capability declarations, because none of the three is a fact +about Trino-the-engine. Each is a property of the one connection, the way +`SQL_DBMS_VER` is. Pre-connect, core passes `None` and its empty default stands. + +`disconnected_trino_conn` sets `server_name` and `user_name` to match the +`ClientBuilder` it fabricates, so `get_info_snapshot` asserts real values; +leaving them blank would let both regress to core's non-answer with the snapshot +still green. The DSN path has no unit coverage at all, since it arrives through +`SQLDriverConnectW`'s connection string, so it is asserted in +`suites/test_integration.py`, which `run-tests.sh` drives over both a DSN and a +DSN-less configuration. + +### The catalog functions return rows, not statements + +The ten catalog methods (`tables`, `columns`, `primary_keys`, `foreign_keys`, +`statistics`, `special_columns`, `table_privileges`, `column_privileges`, +`procedures`, `procedure_columns`) each take one of core's sealed query types +(`TablesQuery`, `ColumnsQuery`, …) and return a `Vec` of its typed row structs +(`TableRow`, `ColumnRow`, …), both in `stackable_odbc_core::types`. Core +converts the rows to `ColumnValue`s in spec column order, sorts them, and serves +the result set. So this crate never builds a `TrinoStatement` for a catalog call +and never names a result-set descriptor. Four consequences, each easy to undo by +accident: + +- **No `ORDER BY` in `src/backend/metadata.rs`.** Core sorts every result set + into its spec order, using `Backend::null_collation` so the sort cannot + contradict what `SQLGetInfo` reports. A backend-side `ORDER BY` is redundant + server-side work. +- **No `SQL_ATTR_METADATA_ID` handling.** Core normalises identifier arguments + before it calls this crate, from `identifier_case` and + `search_pattern_escape`, both of which this driver already declares. What + arrives here is always an ordinary pattern. +- **No `SQL_ALL_*` special-casing in `tables`.** Core detects the three + enumerations on the raw arguments and answers them from `catalogs`, + `schemas` and `table_types` instead; `tables` is not called at all. +- **No `TableType` value-list parsing.** Core splits and unquotes it; + `TablesQuery::table_types` is a `&[String]`, where an empty slice means no + filter. + +**The query object is passed on to `src/backend/metadata.rs`, not unpacked in +the `Backend` impl.** Each type is `#[non_exhaustive]` with crate-private fields +and an accessor per argument, so a filter core adds later reaches this crate +without changing a signature anywhere. Destructuring at the trait impl and +handing `metadata` a positional list would spend that: the argument run it +removes, six `Option<&str>` on `foreign_keys` where a crossed pk/fk pair +compiles silently, would move one call down. + +`table_types` is required and returns `["TABLE", "VIEW"]`, the two +`information_schema.tables.table_type` values `metadata::tables` maps, upper +case per the spec. `catalogs` and `schemas` are *defaulted* in the trait but +mandatory here: both `supports_catalogs` and `supports_schemas` answer `true`, +and a backend that claims either and leaves the method defaulted answers `HYC00` +to that enumeration. Both query `system.jdbc.*` rather than `information_schema`, +which is what lets them work before a session catalog is set, exactly the state +an application is in when it asks. + +#### What Trino can and cannot answer + +Six of the ten return no rows, and the reason differs by group. Each is stated +explicitly rather than left to the trait default, so the reason is recorded +beside the answer and the call is logged like every other backend method. + +| Method | Source | Why | +|--------|--------|-----| +| `tables`, `columns` | `information_schema` | Real data. | +| `catalogs`, `schemas` | `system.jdbc.*` | Real data, no session catalog needed. | +| `table_privileges` | `information_schema.table_privileges` | Real query; see below. | +| `primary_keys`, `foreign_keys` | n/a | Trino has no key metadata ([trino#22408]). | +| `statistics`, `special_columns` | n/a | No cross-connector index metadata, no rowid. | +| `column_privileges` | n/a | Trino grants on tables, never on columns. | +| `procedures`, `procedure_columns` | n/a | See below. | + +**`table_privileges` queries unconditionally, and most connectors answer +nothing.** Every catalog has an `information_schema.table_privileges` whose +columns line up with ODBC's, and Trino's own JDBC driver reads the same table. +It is populated from the connector's permission management, so only connectors +that implement it return rows: Hive and Iceberg under `sql-standard` security. A +connector without it answers zero rows rather than an error, which is why the +driver queries unconditionally instead of gating on the catalog. + +The test stack has one catalog in each group. `hive` runs `sql-standard` +security and returns rows, which is where `metadata::table_privilege_row` is +exercised end to end, in `suites/test_c_abi.py`. `tpcds` and `postgresql` return +none, and `GRANT` on either answers `NOT_SUPPORTED: Catalog does not support +permission management`. + +**Adding `GRANT` statements to `integration-tests/stack/postgres/init.sql` would +not change that.** Trino synthesises its own `information_schema` rather than +passing it through, and the base JDBC connector implements no permission +management. A grant made directly in PostgreSQL is therefore visible in +PostgreSQL's `information_schema.table_privileges` and not in the `postgresql` +catalog's. Verified against the running stack; do not retry it. + +`metadata::table_privilege_row` is split out as a pure function with unit tests +feeding it the rows a coordinator returns, so the conversion is covered for +shapes the stack cannot produce. The integration tests assert on top of that +that the query is accepted and the result set is described. + +**`procedures` publishes nothing to read.** Trino has callable procedures, and +`CALL system.runtime.kill_query(...)` is one, with an unregistered name +answering `PROCEDURE_NOT_FOUND`. But no metadata names them. +`system.jdbc.procedures` and `system.jdbc.procedure_columns` exist for JDBC +compatibility and are hardwired empty, and `system.metadata` has no procedures +table. This is consistent with the `SQL_ACCESSIBLE_PROCEDURES` = `"N"` reported +from `info`. + +[trino#22408]: https://github.com/trinodb/trino/issues/22408 + +### Describing parameters + +`SQLDescribeParam` is answered from Trino, not guessed: `DESCRIBE INPUT` on a +prepared statement returns a type per parameter. Three things about the path +matter, and `src/backend/describe_param.rs` documents each at its site: + +- **The `PREPARE` goes through `Client::execute`, never the bound-parameter + path.** `params::interpolate` would replace the statement's own `?` markers + with the absent parameter values, registering a statement with no parameters. + That is what `SQLExecDirect("PREPARE p FROM ... ?")` does, and why + `DESCRIBE INPUT` reads empty when driven that way. +- **`PREPARE` and `DEALLOCATE` are not `query_all_rows`.** They declare no + columns, and `query_all_rows` deserialises rows, so it fails on them. +- **`DEALLOCATE` is not housekeeping.** A session's prepared statements ride on + every subsequent request as an `X-Trino-Prepared-Statement` header, so a + leaked entry grows every later request by the whole query text. + +`Backend::describe_param` is called once per *parameter* and gets no statement +handle, so the result is cached on `TrinoConnection`, keyed by SQL text. Core +walks a statement's parameters consecutively, so one entry is enough to collapse +n round trips into one. The key is what stops a second statement being answered +from the first one's entry. That failure mode is a *wrong specific type*, which +an application cannot distinguish from a real answer, and which +`describe_param_re_describes_when_the_statement_changes` pins. + +Anything unanswerable returns `Ok(None)` and lets core report its documented +`VARCHAR` guess. Trino declines to prepare plenty of legitimate statements, and +a uniform documented guess beats both a failed call and an invented type. + +### Cancellation + +`Backend::cancel` receives a `TrinoCancelToken`, never a statement. `SQLCancel` +may run on a thread holding no lock on the connection while another thread +executes on the same statement, and a `&mut Self::Statement` cannot exist under +that constraint. + +The token carries the client and runtime, captured from the connection when core +builds it, plus a shared `CancelState`. Trino names a query only once the +coordinator accepts it, so `exec_direct` fills the state's `query_id` slot as +soon as the submit returns, before the metadata-polling loop, since a queued +query is exactly when an application reaches for `SQLCancel`. + +`CancelState::cancelled` is the return path. A cancelled query cannot be paged +any further: `get_next` fails after a server-side cancel and leaves the pooled +TCP socket carrying residual bytes, which surfaces later as an unrelated query +failing. `fetch` and `close_cursor` read the flag and stop touching `next_uri`. + +**A cancelled `fetch` reports `HY008`, never `NoData`.** `NoData` says "your +result set ended", which is false when rows were discarded, and the difference +is not cosmetic. Core relabels a fetch *error* to `HYT00` when its query timer +fired, and has nothing to relabel when the fetch succeeds. Were `fetch` to +answer `NoData` here, a query timeout whose cancel landed between page requests +would reach the application as an empty result set with no diagnostic at all, +indistinguishable from an empty table. That landing is rare, because the cancel +usually arrives while a request is in flight, which is the other path below. It +is caught by `test_query_timeout_fires_through_the_driver_manager` on roughly +one run in twenty. `cancelled_between_requests` builds the error, and +`cancel_from_another_thread_while_fetching` requires `HY008` from both paths +rather than accepting either ending. + +`begin_query` clears the flag as well as setting the id. Core mints a **new +token at every statement-producing call**, so a re-execute arrives with a fresh +`CancelState`, and `cancel` sets the flag only after a `DELETE` that needed an +id `begin_query` had recorded. No reachable path leaves a cancellation pending +there. The clear is kept because the flag's purpose is to keep a live query off +a stale one's teardown, and the one point that knows a new query has begun is +where that belongs. + +`Backend::is_cancelled` reads the same flag, and is what turns it into `HY008`: +`cancel` signals, `is_cancelled` observes, and core discards the backend's own +SQLSTATE when it answers `true`. + +That flag covers only the cancel that lands *between* requests. A cancel landing +while a page request is in flight is recognised from Trino's own `USER_CANCELED` +code instead, in `map_trino_error`. The flag races there, because the cancelling +thread sets it only after its `DELETE` returns, by which time the coordinator +may already have failed the in-flight request. The server's verdict needs no +cross-thread ordering and also catches a query killed by something else, such as +`CALL system.runtime.kill_query`. The two are complementary, which is why +`is_cancelled` and the `OperationCancelled` arm both exist. + +#### `Threading = 2` is required, not tuning + +`packaging/linux/install.sh` and `integration-tests/setup.sh` both write +`Threading = 2` into the driver's `odbcinst.ini` section. unixODBC's default is +`3`, which serialises at the environment level and holds a cross-thread +`SQLCancel` behind the call it was meant to interrupt. Measured against a live +coordinator on a query that runs ~24s, cancelling after 2s: + +| | `Threading = 3` | `Threading = 2` | +|---|---|---| +| `SQLCancel` returns | only after the fetch does | immediately | +| `SQLFetch` raises | `HY010` after 23.9s | `HY008` after 2.0s | + +`HY010` after the query completed on its own is the cancel accomplishing +nothing, reported to the application as a sequence error it did not commit. + +**`SQL_ATTR_QUERY_TIMEOUT` is not affected by unixODBC's threading policy and +fires under either setting.** Core enforces that deadline from a timer thread +that calls `Backend::cancel` *directly*, inside the `.so`. It does not cross +unixODBC, so no threading policy can serialise it: `HYT00` at 2.0s under both +settings, measured. Do not cite the query timeout as the reason for +`Threading = 2`; the reason is `SQLCancel`. + +Neither the Rust FFI tests nor `integration-tests/suites/test_c_abi.py` catch a +regression here. Both call the exported entry points directly, with no Driver +Manager in the loop, so unixODBC's threading policy never applies to them. Only +the pyodbc and `isql` paths go through it, which is why +`test_cross_thread_cancel_interrupts_a_running_fetch` lives in +`integration-tests/suites/test_integration.py` and says so in its failure +message. + +That path yields `TrinoError::OperationCancelled`, which is `HY008`. That is the +SQLSTATE the spec gives a function interrupted by `SQLCancel` from another +thread, with no `(DM)` annotation, so it is the driver's to report. Core has no +named constructor for it, because core documents `HY008` as never returned by a +driver ("not applicable; the `Backend` trait is synchronous"), which cross-thread +`SQLCancel` contradicts. This driver builds it with `SqlState::new`. +`end_page_fetch` keeps that case off `abandon_result_set`: a cancellation the +application asked for is a finished result set, not the undefined cursor +position `24000` describes. + +The six catalog functions, and the `catalogs` / `schemas` enumerations, take the +token but record nothing in it, so `SQLCancel` cannot interrupt them. Four do no +I/O at all; the other four go through `query_all_rows` → `Client::get_all`, +which pages to exhaustion inside the client and never surfaces a query id. + +### Timeouts, liveness, and the hooks left defaulted + +Core offers a defaulted hook for each attribute whose spec row makes it the data +source's job. This driver takes up three and leaves three: + +| Hook | Answer | Why | +|------|--------|-----| +| `set_query_timeout` | `QueryTimeout::CoreCancels` | Trino has no per-statement server-side deadline this driver can set. See below. | +| `connection_dead` | `TrinoConnection::liveness` | A flag the error path sets; never a probe. | +| `is_cancelled` | `CancelState::cancelled` | The observing half of `cancel`; see [Cancellation](#cancellation). | +| `set_access_mode` | defaulted `Ok(())` | Trino has no read-only session mode, and the spec makes `SQL_ATTR_ACCESS_MODE` a *hint*: "the driver is not required to prevent such statements from being submitted". Accepting and ignoring misleads nobody. | +| `set_max_rows` | defaulted → `01S02` | Trino can cap a result set only through `LIMIT` in the SQL the application wrote. The spec forbids emulating: "a driver should not emulate SQL_ATTR_MAX_ROWS behavior". | +| `set_max_length` | defaulted → `01S02` | Same. The attribute exists "to reduce network traffic", which truncating after the bytes arrive cannot achieve. | + +**Why `CoreCancels` and not `DataSource`.** `SET SESSION query_max_run_time` +would work, since `trino-rust-client` tracks `X-Trino-Set-Session` and it would +stick, and it is rejected on two counts. It is a *session* property, so every +statement on the connection would get the most recently set value, where core's +timer is armed per statement and matches the attribute's real scope. And it +would put a round trip inside `SQLSetStmtAttr`, which applications call freely +and the spec does not expect to block. Core's usual argument for `DataSource` is +that the server stops the work rather than the client abandoning it, and that +does not bite here: `Backend::cancel` issues Trino's `DELETE /v1/query/{id}` and +the coordinator does stop. + +**The deadline covers the fetch, which is where Trino's time goes.** Trino +answers with column metadata before it has computed a row, so `exec_direct` +returns in milliseconds and every second of a slow query is spent paging inside +`fetch`. Core arms its timer at `SQLFetch` as well as at the statement-producing +calls for this reason. `SQLFetch`'s diagnostics table carries `HYT00` ("the +query timeout period expired before the data source returned the requested +result set") with no `(DM)` marker. `SQLGetData` is left unarmed on core's side: +its table carries `HYT01` and no `HYT00` row at all. + +Asserted end to end in two places, and both are needed. +`integration-tests/suites/test_c_abi.py` proves the driver and core cooperate +with no Driver Manager in the loop; +`integration-tests/suites/test_integration.py` proves it survives unixODBC. Each +also asserts the *elapsed time*, because `HYT00` arriving after the query +finished on its own is the timeout not working, reported as though it were. Each +then runs a further query on the same connection, which is what catches the +residual-byte failure described under [Cancellation](#cancellation). + +**`SQL_ATTR_CONNECTION_DEAD` is answered from a flag, never a probe.** A +connection pool reads it on every checkout, so a round trip would be paid far +more often than a query runs. `Liveness` is an `Arc` shared by the +connection, every statement it produced and its cancel tokens. A `SQLFetch` that +cannot reach the coordinator is the most likely place to learn the link is gone, +and the fact belongs to the connection. Only +`TrinoError::CommunicationLinkFailure` sets it: a timeout, an auth rejection and +a server-side query error all leave the link up, and `SQL_CD_TRUE` asserts the +connection *has been lost*, not that something went wrong. + +**`SQL_ATTR_LOGIN_TIMEOUT` and `SQL_ATTR_CONNECTION_TIMEOUT` arrive on +`ConnectParams`**, as dedicated accessors rather than connection-string keys. +They came from `SQLSetConnectAttr`, and `to_connection_string` is what +`SQLDriverConnect` echoes back to the application. `connect` maps them with two +pure functions, `request_timeout` and `login_deadline`, which is where their +`Some(0)` cases are pinned by test: + +- `connection_timeout` becomes the HTTP client's per-request timeout, + overriding the `QueryTimeout` connection-string key when the application set + one. `Some(0)` is "there is no timeout" and must **not** be read as unset, + which would silently reimpose the key's 30-second cap. +- `login_timeout` bounds `validate_connection`, the one round trip that decides + whether `SQLDriverConnect` succeeds, via `query_all_rows_within`. It is + applied there rather than on the client because the client's timeout also + bounds every later query, and the two attributes are set separately. + `Some(0)` is "wait indefinitely", the same as unset. + +### Transactions + +`SQL_ATTR_AUTOCOMMIT` selects manual-commit mode and `SQLEndTran` commits or +rolls back, over Trino's own `START TRANSACTION` / `COMMIT` / `ROLLBACK` and the +`X-Trino-Transaction-Id` header the client tracks. + +`SQLSetConnectAttr` records the mode and issues nothing. The transaction opens +at the first statement, from `TrinoConnection::ensure_transaction` in +`exec_direct`. That is narrower than it looks. Trino carries the transaction id +in a **session** header, so once one is open every request the client makes +joins it, the catalog functions included, and a failing one aborts the +application's transaction. The lazy open decides when the window opens, never +who is inside it. + +**`SQLEndTran` with nothing open must not reach the coordinator.** Trino answers +`NOT_IN_TRANSACTION`, while `SQLEndTran`'s page requires `SQL_SUCCESS` when no +transaction is active. `end_tran` therefore returns early without I/O. +`disconnect` rolls back an open transaction rather than leaving it to Trino's +idle timeout. + +Trino's `SET SESSION` transaction access mode has no ODBC counterpart here: +`set_access_mode` stays defaulted, for the reason in the hook table under +[Timeouts, liveness, and the hooks left defaulted](#timeouts-liveness-and-the-hooks-left-defaulted). +`multiple_active_txn` is `true`, because each connection carries its own Trino +session and therefore its own transaction. One *session* holds at most one, +which is what `NOT_SUPPORTED: Nested transactions not supported` reports. + +#### Any statement error aborts the whole transaction + +Measured against Trino 483. After any failure, a `NOT_SUPPORTED` one included, +every later statement answers `TRANSACTION_ALREADY_ABORTED`, **`COMMIT` +included**, and the transaction id is left in place. Only `ROLLBACK` recovers +the session and clears it. + +So `SQLEndTran(SQL_COMMIT)` on an aborted transaction sends a `ROLLBACK` and +then reports failure, with `25S03`. Both halves matter: + +- Reporting success would tell an application its writes landed when they were + discarded. +- `25S03` rather than `HY000` because `SQLEndTran`'s **Suspended State** section + names `25S03`, `40001`, `40002` and `HYC00` as the four SQLSTATEs that confirm + the transaction did not complete. Any other one leaves the Driver Manager + holding the connection in a suspended state, where only read-only functions + work until `SQLDisconnect`, and the rollback has just left this connection + perfectly usable. Core has no named constructor for it, so `sql_state` carries + `TRANSACTION_ROLLED_BACK`. + +**The failure is not always visible where the statement was submitted.** Trino +sends column metadata before it has evaluated a row, so `SELECT 1/0` returns +successfully from `exec_direct` and fails while its pages are read. +`TransactionState` is therefore shared between the connection and every +statement it produces, the way `CancelState` is, and +`TrinoStatement::map_client_error` marks the abort. `query_all_rows` routes +through the same place. + +#### A commit closes every open cursor + +`cursor_commit_behavior` and `cursor_rollback_behavior` are both +`CursorBehavior::Close` (`SQL_CB_CLOSE`), measured rather than assumed: paging a +result set after its transaction ends answers `GENERIC_INTERNAL_ERROR: Already +finished`. Three controls rule out the alternatives, since the same held cursor +resumes across an unrelated statement on the same session, and across no +transaction at all, delivering every remaining row. + +That has a sharp edge for `close_cursor`, which drains the remaining pages to +keep the pooled socket clean. After a commit those pages are dead, so the drain +would fail rather than clean anything. `TransactionState` carries an epoch, a +statement records the one it executed under, and `close_cursor` skips the drain +when the connection has moved past it. The connection bumps the epoch *before* +sending the `COMMIT`, so a `close_cursor` racing it cannot slip through. + +#### What the abort flag means, and what may set it + +`TransactionState::aborted` is a statement about *the transaction that is open +now*. Trino aborts the whole transaction on any statement error and then refuses +everything until a rollback, `COMMIT` included, so the driver has to know before +`SQLEndTran` asks it to commit. + +Manual-commit mode is not the same as having a transaction. The mode is set by +`SQLSetConnectAttr` and a transaction opens at the first statement that needs +one (`ensure_transaction`, called only from `exec_direct`), so there is a window +with the mode on and nothing begun. `note_statement_error` cannot tell the +difference: a `TrinoStatement` holds the shared `TransactionState` and not the +client, so it cannot ask whether one is open. + +`begun` therefore clears the flag whenever a transaction opens, and `end_tran` +clears it on the path where none was. Between them the flag is unobservable +outside the life of a transaction. Without that, a failing catalog lookup +between `SQL_AUTOCOMMIT_OFF` and the first statement left the flag set, `ended` +never ran to clear it, and the next transaction was born aborted: its statements +all succeeded and the commit rolled them back with `25S03`. + +#### Every request joins the open transaction, including the driver's own + +Trino carries the transaction id in a *session* header, so once one is open +every request the client makes joins it. That includes two the application never +wrote: the `information_schema` queries behind the catalog functions, and the +`PREPARE` / `DESCRIBE INPUT` / `DEALLOCATE` round trip behind +`SQLDescribeParam`. A failure in either aborts the application's transaction. + +Both used to convert their failures into a success: `query_information_schema` +turned `CATALOG_NOT_FOUND` into an empty result set, and `describe_param` +returned `Ok(None)` so core could fall back to its uniform `VARCHAR` guess. Both +substitutions are right outside a transaction, where the cost is nothing, and +wrong inside one, where the call reports success while having killed the +transaction. The symptom then arrives much later, as every subsequent statement +failing for a reason nothing reported at the point it happened. + +So both are conditional on `in_transaction()`. Suppressing the abort instead was +rejected: Trino really did abort it, and the flag would then be a lie. + +`describe_param` strips the trailing statement terminator for the same family of +reasons. It wraps the application's SQL in a `PREPARE`, and Trino's grammar has +no terminator, so without the strip a statement that `exec_direct` submits and +runs is one `SQLDescribeParam` cannot describe, and inside a transaction that +failure is no longer silent. + +#### Isolation levels are vetted by the connector, not the parser + +`START TRANSACTION ISOLATION LEVEL X` always parses; the failure lands on the +first statement that touches a catalog, as `UNSUPPORTED_ISOLATION_LEVEL`: + +| Level | tpcds | postgresql | hive | +|---|---|---|---| +| READ UNCOMMITTED | ok | ok | ok | +| READ COMMITTED | ok | ok | `UNSUPPORTED_ISOLATION_LEVEL` | +| REPEATABLE READ | ok | `UNSUPPORTED_ISOLATION_LEVEL` | same | +| SERIALIZABLE | ok | `UNSUPPORTED_ISOLATION_LEVEL` | same | + +One connection can span catalogs that disagree, so `txn_isolation_options` +advertises `SQL_TXN_READ_UNCOMMITTED` alone. That is the level a bare +`START TRANSACTION` gets, and the only one every catalog accepts. Core then +rejects the rest with `HY024` before they reach the wire, which is a refused +attribute rather than a mysterious failed query. `SQL_TXN_CAPABLE` is +`SQL_TC_DML`: DDL in a transaction is an error on every JDBC-backed catalog, and +understating the hive catalog, where it works, is the safe direction. + +#### A pooled connection keeps the commit mode it was returned with + +**Measured, and it bites.** pyodbc enables ODBC connection pooling by default. A +pooled connection is handed back to the application without the driver being +reconnected. A dozen pyodbc connections produced two `TrinoBackend::connect` +calls and no `disconnect` at all, so a connection arrives still in whatever +commit mode the previous borrower left. A `CREATE TABLE` on a "fresh" connection +then runs inside that manual-commit transaction, reports success, and is +discarded when the connection is next recycled. + +The driver cannot see the reuse: the Driver Manager neither disconnects nor +tells it. The ODBC-sanctioned signal is `SQL_ATTR_RESET_CONNECTION`, which the +Driver Manager sets before returning a connection to the pool and which core +does not implement yet. Until it does, this is a real hazard for a pooling +application that ever turns autocommit off, and `suites/test_transactions.py` +sets `pyodbc.pooling = False` so it measures the driver rather than the pool. + +#### Where this is tested + +`suites/test_transactions.py` drives the whole contract through unixODBC and +needs no profile, since the hive catalog is in the base stack. The backend tests +in `src/backend.rs` cover the same ground against the `Backend` impl directly +(`cargo test -- --ignored backend`), and +`autocommit_round_trips_and_end_tran_with_nothing_open_succeeds` plus the +`transactions` group in `suites/test_c_abi.py` cover the entry points with no +Driver Manager in the loop. + +Every scenario that writes names the `hive` catalog. Two Hive limits shape them, +and both look like test bugs when met cold. Two inserts into the same +*unpartitioned* table in one transaction fail (`Inserting into an unpartitioned +table that were added, altered, or inserted into in the same transaction is not +supported`), so the multi-statement case uses two tables. And a table written in +a transaction cannot be read back before the commit, so row counts are taken +from a second connection afterwards. + +## Testing + +Everything lives under `integration-tests/`, split by kind: `scripts/` is the +bash, `stack/` the docker material, `suites/` the Python, `perf/` the profiling +tooling, `windows/` the VM harness, and `generated/` every produced artefact. +`generated/` is gitignored and safe to delete; `setup.sh` rebuilds it. +[`integration-tests/README.md`](integration-tests/README.md) is the runbook; +this section is why the stack is shaped the way it is. + +### Unit and FFI tests + +```bash +cargo test # must produce zero warnings +``` + +Backend tests exercise the `Backend` impl directly. +`src/ffi_integration_tests.rs` drives the real C ABI entry points, which is the +right place for the array-fetch and batch-parameter paths +(`SQL_ATTR_ROW_ARRAY_SIZE`, `SQL_ATTR_ROWS_FETCHED_PTR`, +`SQL_ATTR_PARAMSET_SIZE`): those require direct calls with pre-allocated column +and parameter buffers, which Rust handles cleanly and Python does not. + +Tests needing a live Trino are `#[ignore]`d, so a bare `cargo test` stays +self-contained. + +Core's `conformance` and `test_support` modules are behind its default-off +`test-support` feature, enabled by the `[dev-dependencies]` entry on +`stackable-odbc-core` so it never reaches the shipped `cdylib`. `conformance` +supplies the `SQLGetInfo` return-shape checks and `info_group_inconsistencies`. +`test_support` supplies `attach_connection` / `detach_connection`, which put a +network-free `TrinoConnection` into a connection handle so the *connected* +`SQLGetInfo` path can be tested offline. Core's `handles` module is +`pub(crate)`, so these are the supported way to do that. Do not look for a way +to reach the handle directly. + +`info_group_inconsistencies` checks the `SQLGetInfo` groups whose members +constrain each other, vendor terminology against `SQL_CATALOG_NAME` and +`SQL_TXN_CAPABLE` against the two isolation declarations, and returns one +message per violation. Core cannot police these at runtime, because +`TrinoBackend::get_info` runs first and is entitled to answer anything, so the +invariants live in the shared harness and each driver runs them against its own +backend. `get_info_groups_that_constrain_each_other_agree` is the call site +here. It is what would catch `txn_capable` reporting `SQL_TC_DML` while +`default_txn_isolation` and `txn_isolation_options` stayed at `0`. + +`SQLFreeHandle` refuses a connection handle that still holds a connection +(`HY010`), so such a test must `detach_connection` before freeing, which is what +`cleanup_injected_conn` does. Calling `SQLDisconnect` instead would invoke +`TrinoBackend::disconnect` on a connection that never opened a session. + +The FFI tests that do need a server share one ODBC connection (`OnceLock`) and +are `#[serial]`. The backend tests use a separate `TrinoConnection` and must run +in isolation: + +```bash +cargo test -- --ignored backend +``` + +Do **not** run those alongside the FFI tests. Two independent reqwest connection +pools hitting the same coordinator cause intermittent TCP socket corruption. + +### Integration tests + +Requires Docker and docker-compose. **This suite does not run in CI**; run it +locally before a release. Whether the core stack fits a standard GitHub runner +has not been measured, and the `TODO` at the top of +`.github/workflows/build.yaml` tracks that question. + +```bash +./integration-tests/setup.sh # spin up Trino, build the driver, write ODBC config (~60s first run) +./integration-tests/run-tests.sh # run Linux tests, then tear Trino down +``` + +`--skip-build` skips the cargo build; `--skip-delete` leaves Trino running. +Expected output is `XX passed, 0 failed` (pyodbc, once per config) then the same +for the FFI suite. What matters is `0 failed`. The totals move whenever tests +are added, so do not treat them as fixed. + +The test instance has three catalogs. `tpcds` holds TPC-DS benchmark data, is +read-only and has no constraints. `postgresql` is PostgreSQL, whose test schema +in `integration-tests/stack/postgres/init.sql` provides primary keys, foreign +keys, indexes and the ODBC-relevant column types. `hive` is described under +[The hive catalog](#the-hive-catalog-and-why-it-is-not-behind-a-profile). + +For interactive testing with `isql`, after `setup.sh`: + +```bash +export ODBCSYSINI=$(pwd)/integration-tests/generated +export ODBCINI=$(pwd)/integration-tests/generated/odbc.ini +isql -3 trino_https -v + +# DSNs in integration-tests/generated/odbc.ini: trino_https, +# trino_https_verify_false (TlsVerify=false), trino_postgresql, and +# trino_oauth (ExternalAuthentication, needs the oauth profile; opens a real +# browser, which will warn about the test CA) + +docker compose -f integration-tests/stack/compose.yaml logs -f # watch incoming requests +``` + +### Capturing suite output + +`uv run` in this environment fails when its stdout is a **regular file**: the +process exits 120 and the file is left empty. Piping is unaffected, so capture +output with `tee`, never with `>`: + +```bash +uv run --with pyodbc python3 integration-tests/suites/test_sql_surface.py "$CONN" 2>&1 | tee run.log # good +uv run --with pyodbc python3 integration-tests/suites/test_sql_surface.py "$CONN" > run.log 2>&1 # loses everything +``` + +Reproduced with `uv run --with pyodbc python3 -c "print('x')"` alone, so it is +neither the driver nor any suite (uv 0.11.21). +`integration-tests/suites/test_c_abi.py` needs no `uv`, being standard library +only, and redirects fine. + +The failure looks like a hang: the run completes, the output vanishes, and the +only evidence left is a non-zero exit. + +### The stack is HTTPS only + +The coordinator serves 8443 and nothing else: `http-server.http.enabled=false`, +and 8080 is neither published nor bound. OAuth 2.0 requires TLS, and this is +closer to a real deployment. + +**The driver's `Protocol=http` connection-string value therefore has no +integration coverage.** Its parsing is unit-tested and nothing exercises the +connection. That is an accepted consequence of the above, not an oversight. + +`internal-communication.https.required=true` is mandatory rather than tuning. +With no plaintext listener there is no HTTP internal URI, and without it Trino +fails to start with `NullPointerException: internalUri is null`. + +### Certificates + +`scripts/gen-certs.sh` builds one CA and signs the coordinator, client and +Keycloak leaves from it, into `generated/certs/`. + +The truststore is built with **keytool, never `openssl pkcs12 -export +-nokeys`**. openssl writes the certificate into a certBag carrying no Oracle +trusted-certificate attribute, and Java reads the result as "0 entries": a valid +PKCS12 file that is empty as a trust store. It fails silently, and the only +symptoms are a client-certificate handshake dying with `tlsv1 alert internal +error` and 503s fetching internal memory info. `gen-certs.sh` asserts the +truststore holds a `trustedCertEntry`, because the file existing proves nothing. + +**Jetty selects the certificate on SNI, and serves Trino's internal self-signed +`CN=` certificate for anything it cannot match.** So a name +the coordinator's certificate does not carry yields a *different certificate*, +not a hostname mismatch. Connecting by IP address is worse: TLS sends no SNI for +an IP literal, so the fallback is served every time. Measured: + +| SNI sent | certificate served | +|---|---| +| `localhost` | `CN=localhost`, the CA-signed leaf | +| `trino` | `CN=localhost`, since `DNS:trino` is in the SAN | +| `nosuchname.example` | `CN=test`, Trino's internal certificate | +| none, connecting by IP | `CN=test` | + +Two consequences. `suites/test_tls.py` cannot assert that `TlsVerify=ca` ignores +the hostname, and records that as a `NOTE` with the two ways out that were tried +and failed. And the Windows VM, which reaches the host by IP, maps `trino` to +the gateway in its own hosts file so SNI is sent and the verified-TLS +configurations stay meaningful. + +### Profiles + +Compose profiles make the heavier services opt-in. The unprofiled set is the +core stack. + +| Profile | Services | Buys | +|---|---|---| +| *(none)* | `postgres`, `trino` | tpcds, postgresql and hive catalogs, HTTPS, PASSWORD and CERTIFICATE auth, transactional writes, a non-empty `SQLTablePrivileges` | +| `oauth` | `keycloak` | The OAuth 2.0 flow, through `suites/test_oauth.py` | +| `spooling` | `minio`, `minio-init` | The spooling protocol, through the `Encoding` key | + +```bash +./integration-tests/setup.sh --profile oauth,spooling # or PROFILES=all +``` + +Compose profiles select *services*; they cannot vary a mounted file's contents, +and Trino will not start when `config.properties` names an OAuth issuer or an S3 +endpoint that is not running. So `scripts/gen-trino-config.sh` assembles +`generated/trino/` from `stack/trino/` fragments driven by the same profile +list. A value that *changes* between profiles cannot be appended, because a +duplicate key is a Trino startup error. Those are `@PLACEHOLDER@` substitutions, +and an unresolved one fails the assembly rather than reaching Trino as a +literal. + +A profile change recreates the coordinator. Without that, compose would start +the new service and leave `trino` on the config it already has, so enabling a +profile would appear to do nothing. + +A suite that needs an inactive profile is **skipped, naming the profile that +would enable it**. An unrun suite must never be printable as a passing one. + +### The hive catalog, and why it is not behind a profile + +The `hive` catalog is in the base stack because it costs no container: a file +metastore on a path the coordinator can write needs neither a metastore service +nor object storage. It is what makes two things testable at all. + +**It is the only connector that accepts a write outside autocommit.** Trino's +coordinator refuses the rest with `AUTOCOMMIT_WRITE_CONFLICT: Catalog only +supports writes using autocommit`, raised by +`InMemoryTransactionManager$TransactionMetadata`. The refusal is gated on the +SPI's `Connector.isSingleStatementWritesOnly()`, whose default body is +`iconst_1; ireturn`: + +| Plugin | Overrides it | +|---|---| +| `trino-hive` | yes, from `HiveConfig` (`hive.single-statement-writes`) | +| `trino-base-jdbc`, so postgresql | no, inherits `true` | +| `trino-iceberg` | no, inherits `true` | +| `trino-delta-lake` | no, inherits `true` | +| `trino-memory` | no, inherits `true` | + +So a rollback cannot be demonstrated against `postgresql`, and **Iceberg is not +an alternative**. That is read from the shipped bytecode of Trino 483, not from +documentation. PostgreSQL's own transactionality is irrelevant, because the +coordinator refuses the write before any SQL reaches PostgreSQL. + +**`hive.security=sql-standard` is what fills +`information_schema.table_privileges`**, which gives `SQLTablePrivileges` rows +to convert and exercises `metadata::table_privilege_row` end to end. + +Two consequences that look like defects when met cold: + +- **The warehouse is not a named volume.** Docker mounts one root-owned, and + the coordinator runs as `trino`, so it could not write it at all. The + warehouse therefore lives in the container's own writable layer under + `/tmp/hive-warehouse`, which Trino creates on first use, and recreating the + container starts from an empty metastore. +- **`CREATE SCHEMA` needs the `admin` role**, so an ordinary connection meets + `Access Denied: Cannot create schema`. `scripts/seed-hive.sh` creates the + schema with `X-Trino-Role: hive=ROLE{admin}` on every `setup.sh`, and that is + the only statement that needs it: once the schema exists and `admin` owns it, + an ordinary connection creates tables, writes, reads and grants without a + role. The seed is idempotent, and it drains the statement's `nextUri` because + Trino runs a statement as the client pages it. + +### SQL surface pen test + +```bash +uv run --with pyodbc python3 integration-tests/suites/test_sql_surface.py "" +``` + +Walks the SQL a BI tool emits: join shapes, aggregates and the `GROUP BY` +extensions, window functions, subqueries and CTEs, set operations, parameters in +every clause that accepts one, the ODBC catalog functions, and the statement +forms whose result columns carry no declared length. + +That last group is the one to keep. `DESCRIBE`, `SHOW` and `EXPLAIN` return +unbounded `varchar` columns, so the driver has to describe a column whose size +it cannot know, and an application sizes its buffers from what it says. + +### Folding contract test + +```bash +uv run --with pyodbc python3 integration-tests/suites/test_folding_contract.py "" +``` + +The Power Query connector's SQL declarations, checked against the driver and +Trino. Nothing else loads the `.pq`: every other suite drives the driver +directly, and the only other check on folding is a human clicking "View Native +Query" in Power BI Desktop, one step at a time. Without this test a connector +declaration can drift from what the driver reports or what Trino accepts with +nothing noticing. + +It parses the connector rather than transcribing it, so the two cannot drift: + +- **Every `Constant` visitor field name is a driver `TYPE_NAME`.** Power Query + looks each one up by `typeInfo[TYPE_NAME]` from `SQLGetTypeInfo`, so a name + matching nothing can never fire, and a dead name hides the absence of the + live one it should have been. +- **Every CAST target is a type Trino has.** `NUMERIC` and `FLOAT` are not. +- **The row-limiting clause the `AstVisitor` builds is run**, including the + order it concatenates `OFFSET` and `LIMIT` in. Trino's grammar is + `OFFSET count LIMIT count` and rejects the reverse, so only a fold carrying + both a skip and a take exercises it. +- **`SupportsDerivedTable` and `SupportsTop`** are checked against what Trino + does. + +A `NOTE` lists driver `TYPE_NAME`s with no visitor entry. That is not a failure, +since Power Query evaluates such a constant locally instead of folding it. But +nothing in the connector lists the types it does not handle, so the gap is +otherwise invisible. + +### Raw C ABI pen test + +```bash +python3 integration-tests/suites/test_c_abi.py # needs a running Trino; standard library only +``` + +`integration-tests/suites/test_c_abi.py` loads the `.so` with `ctypes` and calls +the exported entry points **with no Driver Manager in the loop**. unixODBC +answers a large part of the ODBC state machine itself, so the driver's own +handling of out-of-order and malformed calls is invisible to the pyodbc and +`isql` suites. This is the only place it is exercised. + +It is also the only suite that reaches `SQLTablePrivilegesW` and +`SQLColumnPrivilegesW` at all: pyodbc exposes no `tablePrivileges()` or +`columnPrivileges()` method, so neither the integration nor the SQL-surface +suite can call them. `SQLColumnPrivileges`' `HY009` for a null `TableName` is +probed here for the same reason, and only for that function. It is the one of +the four privilege and procedure functions whose spec page states that sentence +without a **(DM)** marker, so the other three must not report it. + +Every entry point called here needs its `argtypes` and `restype` declared in +`load()`. `SQLRETURN` is a 16-bit `SQLSMALLINT`, and an undeclared function +leaves ctypes reading the return register as a 32-bit `int`, where `SQL_ERROR` +arrives as `65535` and every comparison against `-1` silently fails. + +That also means the spec's **(DM)** diagnostics must not be expected here: +nothing produces them, so a probe demanding one would assert the absence of a +component rather than the presence of a behaviour. `SQLExecDirect` answering +`HY010` rather than `08003` on an unconnected connection is correct for this +reason, not a defect. + +Output is `PASS` / `FAIL` / `NOTE`. A `NOTE` is an observation the driver is +entitled to make either way, not a gap. The one currently emitted records that a +statement can be allocated before connecting, because `SQLAllocHandle`'s `08003` +for that case is Driver-Manager-owned. + +A `NOTE` may also be marked `KNOWN`, for a gap diagnosed and recorded rather +than asserted so the suite stays green until the owning crate changes. Tighten a +`KNOWN` into a `check` as soon as its fix lands, or it becomes a permanent blind +spot. None are open. + +Two assertions in the suite pin rules that are easy to get wrong again: + +- **Integer statement attributes are written at the full `SQLULEN` width.** + Every non-pointer attribute on the `SQLSetStmtAttr` page is declared "An + SQLULEN value", not one is `SQLUINTEGER`, and `SQLULEN` is 64-bit on a 64-bit + platform. `BufferLength` is ignored for a non-string value, so a four-byte + write leaves an application calling + `SQLULEN v; SQLGetStmtAttr(s, SQL_ATTR_MAX_ROWS, &v, 0, NULL);` reading + whatever was on its stack in the top half of `v`. Checked for six attributes + here and for all nineteen in + `statement_attributes_are_written_at_the_full_sqlulen_width`. + + **Do not carry the rule across to connection attributes.** Only + `SQL_ATTR_ASYNC_ENABLE` and `SQL_ATTR_ODBC_CURSORS` are `SQLULEN` there; the + rest are `SQLUINTEGER`, and widening those writes eight bytes into the four an + application allocated. + +- **The declared SQL type survives parameter binding.** `SQL_C_CHAR` + + `SQL_NUMERIC`, which is what a client sends for a numeric delivered as text, + reaches Trino as a `decimal`, so `WHERE decimal_col = ?` works. Arriving as a + string instead fails with `TYPE_MISMATCH: decimal(10,2) = varchar(5)` on an + ordinary BI filter. The `bound parameter types` group also requires `07002` + for an unbound parameter marker. + +### Type-transform fuzz + +```bash +python3 integration-tests/suites/test_type_matrix.py # needs a running Trino; standard library only +``` + +Drives every (Trino value, C data type) pair through `SQLGetData`, 37 values +against 13 C types plus 14 NULLs against all 13, and checks the result against +invariants rather than a transcribed copy of the ODBC conversion matrix. +Transcribing the matrix would mostly test the transcription. These are the +properties whose violation is a defect: + +1. The call returns. No pair may crash or hang. +2. A failure carries a SQLSTATE. `SQL_ERROR` with no diagnostic record leaves + an application with an error it cannot interpret. +3. NULL is reported as `SQL_NULL_DATA`, for every target type. +4. A value that does not fit reports `22003`, not a truncated number. +5. Text that is not a number reports `22018`, not a zero. +6. A successful text conversion round-trips. + +Also covers the integer boundary values, the IEEE specials per float type, and +the statement terminator and comment placements. + +A Trino `BOOLEAN` reads back as `"1"`/`"0"`, not `"true"`/`"false"`: it is +described as `SQL_BIT`, and that is what the conversion matrix renders. Do not +"fix" that expectation. + +### The OAuth 2.0 flow + +The `oauth` profile brings up Keycloak, and +`integration-tests/suites/test_oauth.py` drives the whole interactive flow +against it. Do not replace any of it with a mock token endpoint: that would +exercise the driver's own plumbing and none of the coordinator behaviour in +doubt. + +| What | Scenario | +|------|----------| +| The end-to-end flow: `401`, login URL, browser, poll, bearer token | `one_login_serves_many_connections` | +| That three connections on one identity open exactly **one** browser | same scenario, counted from the browser's own launch record | +| That omitting `X-Trino-User` works, and Trino resolves the user from the token | `the_token_supplies_the_user` | +| That a matching `User` is honoured, and a *disagreeing* one is refused | `a_matching_user_is_honoured`, `a_disagreeing_user_is_refused` | +| `28000` for a login the identity provider refuses, and for one nobody completes | `a_refused_login_reports_28000`, `an_abandoned_login_times_out` | +| `ExternalAuthenticationTimeout` firing, measured against the elapsed time | `an_abandoned_login_times_out` | +| Both sides of the *DriverCompletion* gate, including through unixODBC | `noprompt_is_refused`, `the_driver_manager_forwards_the_completion` | + +**A `User` disagreeing with the token is refused, not ignored.** Trino's default +system access control denies `checkCanImpersonateUser`, so the connection fails +with `28000` and `Access Denied: User admin cannot impersonate user impostor`. +That is the measured behaviour, and it is why `User` is optional under +`ExternalAuthentication` and the header is omitted entirely: an operator obliged +to invent one would have the connection refused for their own account. + +It also constrains the suite. Every scenario expecting a *successful* connect +has to use the identity provider's own user or none at all, so a fresh +`OAUTH2_LOGINS` key cannot be obtained by naming a different `User`. Only +scenarios expecting failure can, because they never reach the impersonation +check. + +**The suite cannot use pyodbc.** pyodbc calls `SQLDriverConnectW` with +`SQL_DRIVER_NOPROMPT` unconditionally, including for a `DSN=` string, and core +reads that as forbidding a prompt, so every `ExternalAuthentication` connection +made through pyodbc is refused. The suite loads the driver with `ctypes` and +passes `SQL_DRIVER_COMPLETE` itself. `isql` is unaffected, because `SQLConnect` +carries no *DriverCompletion* and core reads the absent argument as permitting a +prompt. The `trino_oauth` DSN exists for exactly that manual path. + +**The browser is a `PATH`-shadowed `xdg-open`, not `$BROWSER`.** `open` 5.4.0 +ignores `$BROWSER` and runs `xdg-open` first and unconditionally. `xdg-open` +consults `$BROWSER` only in its `generic` desktop-environment branch: on a +machine with a session it dispatches to `gio`, which opens a real browser. +`suites/oauth_browser.py` therefore always **exits 0**, because a non-zero exit +sends `open::that` on to `gio open`, and it reports its outcome through a JSONL +record instead. That record is also how the suite counts browser launches, and +what turns a broken login into a diagnosis rather than a suite that waits out +the login budget with nothing to show. + +### The spooling protocol + +The `spooling` profile brings up MinIO and configures the coordinator to spool. +The driver reads a spooled result when the `Encoding` connection-string key +advertises an encoding, and returns every row inline when it does not. + +Off by default because `protocol.spooling.retrieval-mode=storage` has the +*client* fetch segments straight from object storage: a workstation that cannot +reach the bucket would fail queries that succeed without the key, and the driver +cannot know that in advance. Trino's JDBC driver leaves its `encoding` property +unset for the same reason. A coordinator that does not support the requested +encoding **ignores the header and answers inline**, measured against the live +coordinator with `bogus` and with `json+snappy,json`, and end to end through the +driver by running `suites/test_spooling.py` against a stack with no spooling +manager. So setting the key can never fail a connection. + +`Client::decode_page` is the decoder, at both page-decode sites in +`src/backend/execute.rs`: a direct page's rows arrive as they are, a spooled +page's segments are fetched, decoded and acknowledged. The catalog and metadata +functions need nothing, because `query_all_rows` goes through `Client::get_all`, +which pages on `QueryPager` and resolves segments itself. + +`TrinoStatement::raw_columns` keeps Trino's own `Column` metadata for the result +set. A spooled segment carries values without names or types and is decoded +against that metadata, while Trino sends it on one page only, so the statement +holds it for every later page. + +**What spools is bytes, not rows**, which is the trap for anyone extending +`suites/test_spooling.py`. Measured on this stack under `Encoding=json+zstd`: + +| Query | Segments | +|---|---| +| `SELECT 1`, and 900 rows of `customer` | 1 inline, 0 spooled | +| 20,000 rows of `customer`, **all** columns | 2 inline, 25 spooled | +| the same 20,000 rows, four columns | inline only, 0 spooled | + +A narrow projection never reaches object storage, so the suite's queries are +`SELECT *`. Rows that arrive spooled are byte-identical to rows that arrive +inline, so a row count proves nothing either. Each scenario reads the driver's +log for `Successfully fetched remote spooled segment`, which the client emits +once per *remote* segment and never for an inline one. That log is opened once +per process, since core pins its subscriber on the first connection, so the +suite sets `ODBC_LOG_FILE` once and reads the file in deltas. + +The suite has **no required profile**. With `spooling` active it drives the +protocol; without it, it asserts the fallback above. Each stack state skips the +other's scenarios by name, so neither is a blind spot. + +A client is expected to acknowledge each segment, which deletes it. Segments +from a client that does not are left to the coordinator's `fs.segment.ttl`, 12 +hours by default, so a long-lived stack accumulates them. Abandoning a spooled +result set leaves its remaining segments unacknowledged for that reason. +`close_cursor` drains the remaining pages to keep the pooled socket clean and +discards their data; fetching those segments in order to acknowledge them would +download exactly the data the application abandoned. + +Four settings in `stack/trino/spooling/` carry weight: + +- **`retrieval-mode=coordinator_proxy`**, where the default is `storage`. It is + the only mode that does not require the *client* to reach object storage, and + the driver runs on the host, where `minio` does not resolve. Confirmed by the + segment URIs, which name the coordinator's `/v1/spooled/download/...` rather + than a pre-signed MinIO URI. Covering `storage` means publishing MinIO's port + *and* adding `127.0.0.1 minio` to the host's `/etc/hosts`, so it belongs in an + opt-in variant that skips with a reason rather than passing silently. +- **`fs.segment.encryption=false`**, where the default is `true` and means SSE-C. + MinIO here serves plain HTTP with no key material, so a segment cannot be + written at all with it on. +- **`initial-segment-size=16kB` and `max-segment-size=64kB`**, against defaults of + 8MB and 16MB. Without them a result would need tens of megabytes before a + second segment appeared, and the retrieval loop is what needs exercising. +- **`protocol.spooling.inlining` is left at its default of enabled**, because + that is what a real deployment does. The consequence is the test's to carry: + the first 1000 rows, up to 128kB, arrive inline, so a query has to exceed that + before anything is spooled. + +### Windows VM tests + +The same suites, driven through the Windows ODBC Driver Manager over WinRM. See +[`integration-tests/windows/WINDOWS.md`](integration-tests/windows/WINDOWS.md) +for VM creation and the full reference. + +```bash +./integration-tests/run-tests.sh --windows # Linux + Windows +uv run --with pywinrm python3 integration-tests/windows/windows_test.py # Windows only; Trino must be up +``` + +Four configurations, matching the Linux run: DSN and DSN-less crossed with +verified and unverified TLS. Each records its result rather than aborting the +run, so one failing configuration does not hide the other three. + +Three things the VM needs that the Linux run does not: + +- **`harness.py` travels with `test_integration.py`.** The suite imports it, + and the VM only receives the files this script deploys. +- **`ca.crt` is deployed too**, so the verified configurations can verify + rather than only skip. +- **`trino` is mapped to the gateway in the VM's own hosts file**, so TLS sends + SNI and the verified configurations reach a certificate they can verify. See + [Certificates](#certificates). + +**Do not diagnose a Windows failure without rebuilding the DLL first.** +`--skip-build` reuses whatever is in `target/x86_64-pc-windows-gnu/release/`, +which can predate the feature under test by days. The driver's own log is what +gives a stale DLL away: `SQL_ATTR_QUERY_TIMEOUT=2 not supported, substituting 0` +is what core reports for a backend with no `set_query_timeout`, which this +driver implements. + +### Benchmarks + +A Criterion fetch-throughput benchmark lives in `benches/fetch_trino.rs`. It +needs a running Trino and a URL: + +```bash +TRINO_BENCH_URL=http://localhost:8080 cargo bench +``` + +`BENCH_ROWS` and `TRINO_BENCH_QUERY` override the row count and query. + +### What runs in core, not here + +Miri and cargo-fuzz. Miri cannot execute the OpenSSL and aws-lc-rs code +`trino-rust-client` links in, and both fuzz targets exercise core APIs. Core is +pure Rust and holds all the raw-pointer marshalling, so that is where the +undefined-behaviour risk lives and where both are run. + +## Packaging and release + +### Cutting a release + +```bash +release/release.sh minor # dry run; cargo-release is dry-run by default +release/release.sh minor --execute +``` + +That bumps `Cargo.toml`, rewrites `CHANGELOG.md` and the version examples in +`packaging/README.md`, commits, and pushes a signed `v` tag. The tag +triggers `.github/workflows/release.yaml`, which builds both binaries with +`cargo auditable build --locked --release`, runs `build-archives.sh`, attests the +result and publishes the GitHub Release. `release.toml` restricts this to `main`. + +syft and cargo-auditable are installed at pinned versions, named once in the +workflow's `env`. Both are preconditions of packaging rather than extras: +`sbom.sh` refuses a binary carrying no `.dep-v0` section, so a plain `cargo +build` fails the release at the packaging step. + +`actions/attest-build-provenance` signs a statement that the three archives and +`sha256sums.txt` came out of this workflow at this commit, and +`actions/attest-sbom` binds each artifact to its CycloneDX document. Both record +into the public transparency log, and a consumer checks one with +`gh attestation verify --repo stackabletech/stackable-odbc-trino`. This +proves **where** an artifact was built, not who vouches for it: the binaries are +unsigned, and the code-signing certificate that would change that is an open +`TODO` in the workflow. + +Publishing to crates.io is disabled (`publish = false`) and blocked anyway while +`stackable-odbc-core` and `trino-rust-client` are git dependencies, which +crates.io does not accept. + +### The release archives + +`packaging/build-archives.sh` assembles three release artefacts into +`packaging/dist/`, given `$VERSION` and both release binaries: + +- `stackable-odbc-trino--linux-x64.tar.gz`, the `.so` plus install + scripts +- `stackable-odbc-trino--windows-x64.zip`, the `.dll`, the `.mez`, the + `.bat` scripts and `configure-dsn.ps1` +- `StackableTrinoODBC-.mez`, the standalone Power BI asset + +### The Windows DSN dialog + +`packaging/windows/configure-dsn.ps1` is a WinForms dialog covering the whole +connection-string surface. It is reached two ways: run directly, and from the +ODBC Data Source Administrator's **Add…** and **Configure…** buttons, which +load the driver's setup DLL and ask it for a dialog. +`TrinoBackend::configure_dsn` in `src/backend/setup.rs` is what answers them, by +running this same script with `-Emit`. + +Layout, the read path, the write path and validation are generated from one +`$Fields` table, and `dsn_keys_match_the_connection_string_parser` in +`src/lib.rs` fails `cargo test` if that table and the `PARAM_` constants ever +disagree in either direction. **That one table is why the Administrator's button +reuses the script rather than getting a dialog written in Rust**: a second +dialog would be a second list of every keyword to keep in step, and no test +would compare it against the first. + +The write goes through `SQLConfigDataSourceW`, so the driver's own `ConfigDSN` +stays in the loop. Two things about it are measured rather than assumed: + +- **`SQLInstallerErrorW` returns a 16-bit `RETCODE`.** Declared as a 4-byte + `bool` it silently yields no error record at all, which makes a failed write + look like a write with no explanation. +- **The five `name:value;name2:value2` keys are written bare.** Braces belong + to connection-string syntax, where `;` separates parameters. Measured against + the live driver: a bare DSN value applies both session properties, a braced + one fails the connection with `08001`. + +Secrets are written only when their **Save** box is ticked, which is off by +default. A saved secret is stored unencrypted, and a System data source puts it +in HKLM where every local user can read it. + +#### Test connection cannot drive an interactive login + +**`System.Data.Odbc` calls `SQLDriverConnectW` with `SQL_DRIVER_NOPROMPT`**, the +same as pyodbc, so a connection made from the dialog's Test button may never +show a login URL. Measured through the Windows Driver Manager against a live +Keycloak: an `ExternalAuthentication` test returns + +```text +[28000] ExternalAuthentication needs to show a login URL, and this connection + was made with SQL_DRIVER_NOPROMPT; supply AccessToken instead +``` + +which is right, and useful, and still lands under a **Connection failed** +heading that reads as the settings being wrong. The button therefore checks the +key first and reports that the login cannot be driven from here, with no attempt +made. + +The data source itself is unaffected. Writing it works, and an application that +passes `SQL_DRIVER_COMPLETE` opens a browser normally, verified on the VM where +the driver launched Edge and Keycloak's login page rendered. + +Testing it properly would mean replacing `System.Data.Odbc` with a direct +`SQLDriverConnectW` at `SQL_DRIVER_COMPLETE`, which the script could do since it +already P/Invokes `odbccp32` for `ConfigDSN`. That also means reading the result +columns through raw ODBC in PowerShell, so it is left until an OAuth user asks +for it. + +#### The Administrator's buttons, through `Backend::configure_dsn` + +Core owns all of `ConfigDSN` (validating *fRequest*, rejecting `DRIVER=`, +merging the data source's stored keywords in for `Config` and `Remove`, calling +`SQLValidDSN`, and writing through `SQLWriteDSNToIni`). `src/backend/setup.rs` +supplies only the dialog. Five things about the path matter: + +- **A null `hwndParent` never prompts, and that is what stops the recursion.** + The spec makes it behaviour rather than an optimisation ("the function will + not display any dialog boxes if the handle is null"), and the script's own + `Write-Dsn` calls `SQLConfigDataSourceW` with `IntPtr::Zero`. So when the + standalone dialog writes, the hook it re-enters passes straight through + instead of launching a second copy of the script. `odbcconf`'s `CONFIGDSN` + is headless for the same reason, which is why the Windows harness creates its + DSNs without a dialog appearing. +- **`Remove` opens no dialog.** The Administrator has already confirmed the + deletion, and the driver keeps nothing outside `ODBC.INI` to clean up. +- **The attributes travel over a pipe, as JSON, never a temp file.** A `Config` + request arrives with the whole stored section merged in, `PWD` included. + Those values are already unencrypted in the registry, and a temp file would + be a *second* place to read them from. +- **The exit code carries the verdict, because stdout carries the payload**: 0 + accepted, 2 cancelled, anything else a failure whose stderr becomes the + message core posts with `SQLPostInstallerError`. A cancel is `Ok(None)` and + posts no error at all. +- **The dialog is found beside the DLL**, via `GetModuleHandleExW` + + `GetModuleFileNameW`. `std::env::current_exe()` cannot be used: `ConfigDSN` + runs inside `odbcad32.exe` and would answer with the Administrator's path. + `install.bat` therefore copies `configure-dsn.ps1` as a hard requirement. + +`-Emit` differs from the standalone dialog in four ways, each forced by core +being the writer. The User/System radios are hidden, since the Administrator +already chose the scope and set the installer's config mode. The name box is +read-only when a `DSN` keyword arrived, which the spec requires and core +enforces on the returned map. The prefill comes from the pipe rather than a +second `ODBC.INI` read. And the form is `TopMost`, or it opens behind the window +that asked for it. Keywords the `$Fields` table does not model are returned +exactly as they arrived. On a **Configure…** that is the rest of the data +source's section, and dropping them would delete settings nobody touched. + +Only the two OS calls are `#[cfg(windows)]`. `dialog_needed`, the JSON exchange +and `interpret_outcome` are plain functions with unit tests that run on Linux, +so a change to any of those decisions breaks the build where the work is done +rather than where it ships. + +#### Where the buttons are tested + +`integration-tests/windows/dsn_dialog_test.py` is the **only** check on +`configure_dsn`. Everything else reaches the driver through a connection, and +both `odbcconf` and `configure-dsn.ps1` call `SQLConfigDataSource` with a null +*hwndParent*, the headless path, so nothing else opens the dialog at all. It +drives `odbcad32` on the VM's console session and captures six screenshots into +`integration-tests/generated/windows-dialog/`. + +Cancel and Remove are asserted but not photographed: what they produce is a +transient dialog and an empty list, and both are checked against the registry +instead. The mechanics that took measuring are documented at the top of the +script. Two of them come up before anything else. **`GetWindowTextW` cannot read +an edit control's text across a process boundary** and answers empty, which +reads exactly like a failed write. And **a synthetic mouse click on an inactive +window is consumed by the activation**, so buttons take a posted `BM_CLICK` and +only handle-less things, tab strips and list rows, are clicked by coordinate. + +### The Windows version resource + +`build.rs` embeds a `VERSIONINFO` into the driver DLL. Without one the ODBC Data +Source Administrator lists the driver as `Not marked` under both **Version** and +**Company**, which is what every Rust `cdylib` gets: rustc emits no resource. +Measured on Windows Server 2022, `sqlsrv32.dll` lists as `10.00.20348.01` / +`Microsoft Corporation` and carries exactly those strings. + +Three things about it are chosen rather than incidental: + +- **Every value comes from cargo's environment**, so the resource cannot + disagree with `Cargo.toml`. `CARGO_PKG_AUTHORS` supplies the company with + the address stripped, `CARGO_PKG_DESCRIPTION` the product name. +- **`release.toml` has no rule for it**, unlike + `connector/StackableTrinoODBC.pq`. The version is `CARGO_PKG_VERSION`, which + is the file `cargo-release` already bumps, so there is no second copy to + drift and nothing for a test to police. +- **The gate is `CARGO_CFG_TARGET_OS`, never `cfg!(windows)`.** A build script + is compiled for the *host*, and the release DLL is cross-compiled from Linux, + where `cfg!(windows)` is false. It would skip the resource on precisely the + build that ships. Only the gnu toolchain is wired up, since that is what the + release workflow uses; an MSVC target needs `rc.exe` and gets a + `cargo:warning` instead of a failure. + +### The Power Query connector + +`connector/` holds the Power Query custom connector source; `connector/build.sh` +zips it into the `.mez`. + +`connector/StackableTrinoODBC.pq` carries its own `[Version = "..."]`, which +Power BI reads to decide whether an installed `.mez` supersedes the one already +present. **It tracks the Cargo version**: `release.toml` rewrites it in the same +commit as the bump, exactly as it does `packaging/README.md`, and +`connector_version_matches_the_crate` in `src/lib.rs` fails `cargo test` if the +two ever part. Do not edit it by hand. A `.mez` and a `.so` naming different +versions would make a bug report ambiguous, since it quotes whichever the +reporter installed. + +`StackableTrinoODBC.Contents` takes `optional options as record`, and +`Config_AdvancedOptions` is the list of keys it accepts. +`connector_options_are_connection_string_keys` in `src/lib.rs` checks that list +against the `PARAM_` constants, and against `StackableTrinoODBC.OptionsType` in +both directions: the list is what the connection string is built from, the type +is only what the Get Data dialog renders, and nothing in Power Query relates +them. + +The four keys `Backend::sensitive_connect_keywords` declares are absent from the +list: `AccessToken`, `ExtraCredentials`, `ExtraHeaders`, `ProxyPassword`. An +option set here is stored in the query text inside the `.pbix`, which is a file +people mail to each other. + +**`SessionProperties`, `ResourceEstimates` and `Roles` are unverified through +Power Query.** All three carry `;`, and whether `Odbc.DataSource` escapes a +record value containing one is not established here: nothing in this repo +executes the `.pq`, since `suites/test_folding_contract.py` parses it and Power +BI is what runs it. They are passed unbraced, relying on Power Query's own +escaping. **Before a release, set `SessionProperties` to two pairs in Power BI +Desktop and confirm both apply.** If only the first does, brace those three in +the connector the way `Build-ConnectionString` does in `configure-dsn.ps1`. +`DirectQuery` needs no such check and no option of its own: it is a `Publish` +capability (`SupportsDirectQuery`), and Power BI draws the Import/DirectQuery +selector itself. + +### The SBOM + +`packaging/sbom.sh ` writes `.cdx.json` and +`.spdx.json` for one release artifact, in four stages: syft extracts +the component list, `cargo metadata` enriches it, `packaging/sbom-native.json` +supplies what cargo cannot see, and a finalize pass makes the artifact the +document's subject. + +**SPDX is converted from the finished CycloneDX, never generated afresh**, so +the enrichment and the native fragment reach both formats from one +implementation and cannot drift apart. CycloneDX is what ships inside the +archive; SPDX exists for procurement processes that ask for it by name. + +`build-archives.sh` generates all three artifacts' SBOMs, puts the CycloneDX one +into each staging directory so it travels inside the archive, publishes both +formats per artifact as release assets under versioned names, and writes +`sha256sums.txt` over everything. The SBOM inside an archive records the sha256 +of the binary shipped beside it. + +**The artifact must be built with `cargo auditable`.** Syft reads the `.dep-v0` +section that embeds, so the SBOM describes what linked rather than what +`Cargo.toml` asked for, and dev-dependencies are excluded by construction. +`sbom.sh` refuses an artifact carrying no such section, because one built with +plain `cargo build` yields a handful of components and still looks like a valid +document. + +Enrichment exists because syft's raw output is not shippable. `cargo-auditable` +embeds only name, version and source kind, so **no component carries a license**, +and a git or path dependency emits a purl indistinguishable from a crates.io +package. A scanner resolving `pkg:cargo/trino-rust-client@0.11.0` would reach +the real upstream crate, which is not what shipped. Enrichment fills every +license from `cargo metadata`, rewrites a git dependency's purl to carry +`?vcs_url=` with the **resolved commit** rather than the branch or tag, and +marks a path dependency `pkg:generic` plus a `stackable:cargo-source` property. + +All of it keys off `cargo metadata`'s source *kind*, never off a crate name, so +a dependency moving between path, git and crates.io needs no change to the +script. + +Finalize hoists the artifact into `metadata.component` with its sha256 and drops +syft's self-entries, one of which is named by the absolute build path. That is +both where the subject belongs and what keeps the builder's directory layout out +of the release. The entries are selected by **having no purl**, not by type: the +ELF artifact yields one of type `file`, while the PE artifact yields that plus a +second of type `application`. Every real component has a purl, the crates from +enrichment and the native ones from the fragment alike. + +#### The `.mez` takes a different path + +The Power Query connector is M source in a zip. Syft finds nothing in it and +there is no cargo graph to enrich, so `sbom.sh` recognises the extension and +builds the document directly: the connector is the subject, its version read +from the `[Version = "..."]` in `StackableTrinoODBC.pq`, and the component list +is **empty**. That is the honest answer rather than a gap, because the connector +has no third-party dependencies. Running it through the pipeline instead fails, +since syft reports `components: null` for an archive it cannot catalogue. + +#### The native fragment, and why the two platforms differ + +`packaging/sbom-native.json` is hand-maintained, so +`sbom.sh --check-native ` verifies it and fails on drift. The +`release-artifacts` job in `.github/workflows/build.yaml` runs it against both +release binaries on every pull request, which is where a dependency change can +still be reverted cheaply. What it verifies differs by platform, because the +platforms contribute different things: + +| | Linux `.so` | Windows `.dll` | +|---|---|---| +| Declared components | unixODBC | mingw-w64 runtime, libgcc | +| How they are linked | dynamically, at load time | statically, into the artifact | +| What `--check-native` asserts | the `DT_NEEDED` set matches the declared sonames | no toolchain runtime DLL is imported | + +The driver links **`libodbcinst.so.2` alone**; it does not link `libodbc`, +despite `odbc-sys` naming both. The Windows DLL imports only the operating +system's own libraries, `odbccp32.dll` included, and those are the platform +rather than dependencies, so none is declared, for the same reason `libc` is not +declared on Linux. What is declared there is the toolchain runtime, because it +is statically linked and therefore redistributed inside the artifact. + +The Windows assertion is the inverse of the Linux one on purpose. The release +archive ships no runtime DLL, so an artifact that imported `libgcc_s_seh-1.dll` +would fail to load on a machine without mingw installed. + +`./packaging/test-sbom.sh` runs every assertion against the real release +artifacts and needs no Trino. It builds the `.so` with `cargo auditable` if it +is absent, and skips the Windows checks with a message when the cross build is +not present. Both artifacts are generated as well as checked, because they take +different branches through augment and finalize, and a Linux-only run leaves the +PE paths unexercised. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..5a6bd53 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,73 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [Unreleased] + +First release, so this section describes what the driver offers rather than +what changed. + +### Added + +**Querying.** An ODBC 3.80 driver for [Trino](https://trino.io) on Linux and +Windows. Queries, result sets fetched a row or an array at a time, bound +parameters singly and in batches, and the ODBC escape sequences `{fn ...}`, +`{d ...}` and `{oj ...}` translated into Trino SQL. Trino's types map to their +SQL equivalents, the parametric decimal, timestamp and interval types included. + +**Metadata.** Catalogs, schemas, tables, columns, table types and table +privileges, so a tool can browse the data source instead of asking you to type +table names. `SQLDescribeParam` answers from Trino's `DESCRIBE INPUT` instead of +guessing, so a filter on a `decimal` column keeps its type. + +**Authentication.** Username and password, a bearer token, a client certificate +for mutual TLS, and Trino's interactive OAuth 2.0 flow, which opens a browser +and picks up the token once the login completes. One login is shared across the +process, so an application opening ten connections opens one browser tab. +`SessionUser` runs statements as another user while you authenticate as +yourself, and `Roles` selects the authorisation role per catalog. + +**TLS.** Three verification modes: full, certificate chain only, and none. The +middle one verifies against your CA while skipping the hostname check, which is +what a coordinator reached under an internal name needs. + +**Transactions.** Turn autocommit off and the driver opens a Trino transaction +on the next statement, then commits or rolls back on request. A statement that +fails aborts the transaction, and the driver rolls back and reports that the +commit did not happen. + +**Cancellation and timeouts.** `SQLCancel` from another thread asks the +coordinator to kill the query, so it stops consuming cluster time. +`SQL_ATTR_QUERY_TIMEOUT` covers fetching as well as execution, which is where a +Trino query spends its time. + +**Large results.** Setting `Encoding` turns on Trino's spooling protocol. It is +off by default, and a coordinator that does not support it answers normally, so +enabling it cannot break a connection that already worked. + +**Connection options.** 34 connection-string keys covering session properties, +resource estimates, extra credentials, client tags, proxies, time zone and +locale. Keys shared with Trino's JDBC driver take the same format, so a value +copied out of a JDBC URL works unchanged. + +**Packaging.** Installers for Linux and Windows, a Windows dialog for creating a +data source, and a Power Query custom connector for Power BI that folds filters, +joins, grouping and row limits into the SQL it sends. Every release artifact +ships with a CycloneDX SBOM, is published alongside an SPDX document, and is +covered by `sha256sums.txt`. + +### Known limitations + +- Trino publishes no primary keys, foreign keys, indexes or stored procedures, + so those lookups return no rows. +- The current catalog cannot be changed after connecting. Set `Catalog` in the + connection string instead. +- `SQL_ATTR_MAX_ROWS` and `SQL_ATTR_MAX_LENGTH` are reported as unsupported + rather than emulated. +- Only the `READ UNCOMMITTED` isolation level is offered, because it is the one + every Trino catalog accepts. + +[Unreleased]: https://github.com/stackabletech/stackable-odbc-trino/commits/HEAD diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..53ab73e --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,53 @@ +# Project Rules + +Read and follow @AGENTS.md. It holds the architecture, the patterns and the +procedures, and it is where the reasoning behind every rule below lives. + +## Non-Negotiable Rules + +- **ODBC spec compliance is mandatory.** Read the spec page for every function + whose behaviour you change. The generic FFI entry points live in + `stackable-odbc-core`, but what this driver returns from `get_info`, + `get_info_raw`, the catalog functions and the type-conversion paths is + directly observable by applications, and each has a spec-defined shape and + value range. Never claim a SQLSTATE or an info value is wrong without + checking the actual spec table first. Pay attention to **(DM)** annotations: + those SQLSTATEs are returned by the Driver Manager, not the driver. +- **Route every client error through `map_trino_error`.** It is the single + place that decides the SQLSTATE. See + [Backend error mapping](AGENTS.md#backend-error-mapping). +- **Use `odbc-sys` types**, re-exported from `stackable_odbc_core::types`. + Never redefine what it provides, and never add an `odbc-sys` dependency to + this crate's `Cargo.toml`. See [Named constants](AGENTS.md#named-constants). +- **Convert raw integers to typed enums at the boundary** with core's + `xxx_from_raw()` functions, never `transmute`. +- **Never work around a defect or a gap in `stackable-odbc-core` or + `trino-rust-client`.** Fix the cause where it lives and adapt this driver to + the corrected API. +- **Run `pre-commit run --all-files` before every commit.** It is the single + source of truth for what must pass. + +## Scope + +- Do not modify files outside the scope of the current task. +- Do not add features, refactoring, or "improvements" beyond what was asked. +- If unsure whether something is in scope, ask. + +## Data Retrieval + +Never read entire files by default. Survey, locate, then extract. + +1. Survey first. Check the file size with `stat -c%s file` before reading it. + Anything over 50 KB must be sliced, not read whole; several modules in + `src/` are. +2. Navigate definitions with ctags. Run `ctags -R .` once to build the index, + then `grep "^SymbolName" tags` for the exact file and line of any function, + struct or trait. No file reading needed. +3. Locate with Grep. Find patterns, keywords or usages before reading. Use `-C` + for context lines. +4. Extract with Read, using `offset` and `limit` once you know the line range. +5. Read structured data with a tool that understands it: `jq` for JSON, `yq` + for YAML. Never read raw markup whole. +6. Survey the filesystem with `tree -L 2 -I '.git|target|node_modules'`, not a + recursive `ls`. +7. Verify edits with `git diff -u` rather than re-reading the file. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..825dbbd --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,146 @@ +# Contributing + +Thanks for considering a contribution. Bug reports, connection strings that +fail, and reports of a tool that will not talk to the driver are all useful. + +- **Questions and ideas:** [GitHub Discussions](https://github.com/orgs/stackabletech/discussions) + or [Discord](https://discord.gg/7kZ3BNnCAF). +- **Bugs:** open an issue. Please say which platform, which Driver Manager + (unixODBC or the Windows one), which application, and which Trino version. + A driver log helps most of all: set `ODBC_LOG_FILE` and `ODBC_LOG_LEVEL=debug` + and attach the result, with any passwords removed. +- **Security problems:** do not open an issue. See [SECURITY.md](SECURITY.md). + +## Building + +You need the unixODBC development libraries, because the ODBC bindings link +against them. You do not need a running Trino or any ODBC configuration to +build and run the unit tests. + +```bash +sudo apt-get install unixodbc-dev # Debian/Ubuntu +``` + +```bash +git clone https://github.com/stackabletech/stackable-odbc-trino +cd stackable-odbc-trino +cargo build --release +``` + +That produces `target/release/libstackable_odbc_trino.so`. + +Everything generic about being an ODBC driver lives in +[`stackable-odbc-core`](https://github.com/stackabletech/stackable-odbc-core): +handle management, UTF-16 marshalling, diagnostics, panic safety and the +exported C entry points. This repository holds only the Trino-specific half. +Cargo fetches core for you, so there is nothing to clone by hand. + +### Working on core at the same time + +To build against a local checkout of core rather than the fetched one, add a +`[patch]` to your own `.cargo/config.toml`, which is not checked in: + +```toml +[patch."https://github.com/stackabletech/stackable-odbc-core.git"] +stackable-odbc-core = { path = "../stackable-odbc-core" } +``` + +`.cargo/` is gitignored, so the override cannot be committed. **`Cargo.lock` +can**: cargo rewrites core's entry to the local path while the patch is active, +so check `git status` before committing. Remove the override, or push your core +changes, before you rely on a build. + +The toolchain version is pinned in `rust-toolchain.toml`, so rustup will fetch +the right one on first build. + +### Windows + +Cross-compile with MinGW (`gcc-mingw-w64-x86-64`): + +```bash +rustup target add x86_64-pc-windows-gnu +cargo build --release --target x86_64-pc-windows-gnu +``` + +That produces `target/x86_64-pc-windows-gnu/release/stackable_odbc_trino.dll`. + +Anything destined for a release archive is built with +[`cargo auditable`](https://github.com/rust-secure-code/cargo-auditable), which +embeds the dependency list the SBOM is generated from. See +[`packaging/README.md`](packaging/README.md). + +## Testing + +```bash +cargo test # unit and FFI tests; no server needed +cargo clippy --all-targets -- -D warnings +``` + +`cargo test` must produce zero warnings. Tests that need a live Trino are +marked `#[ignore]`, so a bare `cargo test` stays self-contained. + +The integration suite runs against a real Trino in Docker. It does not run in +CI, so run it locally before a release: + +```bash +./integration-tests/setup.sh # start the stack, build the driver, write ODBC config +./integration-tests/run-tests.sh # run the suites, then tear the stack down +``` + +See [`integration-tests/README.md`](integration-tests/README.md) for the flags, +the optional compose profiles, and how to get an interactive session against the +running stack. The Windows suites run the same tests through the Windows Driver +Manager in a VM; see +[`integration-tests/windows/WINDOWS.md`](integration-tests/windows/WINDOWS.md). + +## Before you commit + +```bash +pre-commit run --all-files +``` + +That is the gate, and it is the single source of truth for what must pass. It +runs rustfmt, clippy, `cargo test`, `cargo doc` (with warnings denied, so a +broken intra-doc link fails the commit), `cargo sort`, `cargo deny`, shellcheck, +markdownlint, and a secret scan. + +Two of those are not in the build steps above, so install them once: + +```bash +cargo install cargo-deny cargo-sort +``` + +Two more things a change usually needs: + +- **A changelog entry**, under `## [Unreleased]` in + [`CHANGELOG.md`](CHANGELOG.md), if an ODBC application can observe the + difference. A changed SQLSTATE, a changed `SQLGetInfo` value, a new + connection-string key or a different type mapping all count. +- **A new connection-string key means two edits**: the parser in + `src/backend/types/connect_params.rs`, and the table in + [`README.md`](README.md). The Windows dialog is generated from the parser, and + a test in `src/lib.rs` fails if the two disagree. + +## Where things live + +[`AGENTS.md`](AGENTS.md) is the working reference: module layout, the split +against `stackable-odbc-core`, the error-mapping rules, and the measured Trino +and Driver Manager behaviour behind the design decisions. Read the section that +covers whatever you are about to change. It is written for AI coding agents and +human contributors alike. + +Two rules are worth stating here, because they are the ones most easily broken +by a reasonable-looking change: + +- **Read the ODBC spec page for any function whose behaviour you change.** What + the driver returns from `SQLGetInfo`, from the catalog functions and from the + type-conversion paths is directly observable by applications, and each has a + spec-defined shape and value range. +- **Route every client error through `map_trino_error`.** It is the single place + that decides the SQLSTATE and carries Trino's own error code through to + `SQLGetDiagRec`. Building an error at the call site quietly degrades it. + +## License + +By contributing you agree that your contribution is licensed under +[Apache-2.0](LICENSE). diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..489cd0f --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,2903 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "async-stream" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5a71a6f37880a80d1d7f19efd781e4b5de42c88f0722cc13bcb6cc2cfe8476" +dependencies = [ + "async-stream-impl", + "futures-core", + "pin-project-lite", +] + +[[package]] +name = "async-stream-impl" +version = "0.3.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "aws-lc-rs" +version = "1.17.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00bdb5da18dac48ca2cc7cd4a98e533e8635a58e2361d13a1a4ee3888e0d72f1" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.43.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43103168cc76fe62678a375e722fc9cb3a0146159ac5828bc4f0dfd755c2224c" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", + "pkg-config", +] + +[[package]] +name = "backon" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cffb0e931875b666fc4fcb20fee52e9bbd1ef836fd9e9e04ec21555f9f85f7ef" +dependencies = [ + "fastrand", + "gloo-timers", + "tokio", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b25655df2c3cdd83c5e5b293b88acd880332b2ddadd7c30ac43144fdc0033da9" + +[[package]] +name = "bigdecimal" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4d6867f1565b3aad85681f1015055b087fcfd840d6aeee6eee7f2da317603695" +dependencies = [ + "autocfg", + "libm", + "num-bigint", + "num-integer", + "num-traits", +] + +[[package]] +name = "bit-set" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08807e080ed7f9d5433fa9b275196cfc35414f66a0c79d864dc51a0d825231a3" +dependencies = [ + "bit-vec", +] + +[[package]] +name = "bit-vec" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" + +[[package]] +name = "bitflags" +version = "2.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" + +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "jobserver", + "libc", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cfg_aliases" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f079e83a288787bcd14a6aea84cee5c87a67c5a3e660c30f557a3d24761b3527" + +[[package]] +name = "chacha20" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d524456ba66e72eb8b115ff89e01e497f8e6d11d78b70b1aa13c0fbd97540a81" +dependencies = [ + "cfg-if", + "cpufeatures", + "rand_core 0.10.1", +] + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "chrono-tz" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3" +dependencies = [ + "chrono", + "phf", +] + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "301b56658598e48f3648647ac6fc887be7e7108eddfa4e9b63fcf3ec58c0cadf" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94a65403d1a1bd28f7dc68eb8506e8874808ee5eecb59298de588e2e1407a078" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "cmake" +version = "0.1.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" +dependencies = [ + "cc", +] + +[[package]] +name = "combine" +version = "4.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba5a308b75df32fe02788e748662718f03fde005016435c444eea572398219fd" +dependencies = [ + "bytes", + "memchr", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + +[[package]] +name = "crc32fast" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "criterion" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" +dependencies = [ + "alloca", + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools 0.13.0", + "num-traits", + "oorandom", + "page_size", + "plotters", + "rayon", + "regex", + "serde", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" +dependencies = [ + "cast", + "itertools 0.13.0", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.119", + "unicode-xid", +] + +[[package]] +name = "displaydoc" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "dunce" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" + +[[package]] +name = "either" +version = "1.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e5e8f6c15a24b9a3ee5efec809ccd006d3b30e8b3bb63c39af737c7f87daa1d" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + +[[package]] +name = "futures" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-executor" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" + +[[package]] +name = "futures-macro" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "futures-sink" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "wasi", + "wasm-bindgen", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" +dependencies = [ + "cfg-if", + "libc", + "r-efi 5.3.0", + "wasip2", +] + +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "js-sys", + "libc", + "r-efi 6.0.0", + "rand_core 0.10.1", + "wasm-bindgen", +] + +[[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f41fd6a08e4d4ec69df65976da761afd5ad5e58a9d4acb46bd1c953a9e3ff2" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "hyper" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d22053281f852e11534f5198498373cbb59295120a20771d90f7ed1897490a72" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http", + "http-body", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33ca68d021ef39cf6463ab54c1d0f5daf03377b70561305bb89a8f83aab66e0f" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-channel", + "futures-util", + "http", + "http-body", + "hyper", + "ipnet", + "libc", + "percent-encoding", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "is-docker" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "928bae27f42bc99b60d9ac7334e3a21d10ad8f1835a4e12ec3ec0464765ed1b3" +dependencies = [ + "once_cell", +] + +[[package]] +name = "is-wsl" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "173609498df190136aa7dea1a91db051746d339e18476eed5ca40521f02d7aa5" +dependencies = [ + "is-docker", + "once_cell", +] + +[[package]] +name = "iterable" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c151dfd6ab7dff5ca5567d82041bb286f07469ece85c1e2444a6d26d7057a65f" +dependencies = [ + "itertools 0.10.5", +] + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "jni" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5efd9a482cf3a427f00d6b35f14332adc7902ce91efb778580e180ff90fa3498" +dependencies = [ + "cfg-if", + "combine", + "jni-macros", + "jni-sys", + "log", + "simd_cesu8", + "thiserror", + "walkdir", + "windows-link", +] + +[[package]] +name = "jni-macros" +version = "0.22.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a00109accc170f0bdb141fed3e393c565b6f5e072365c3bd58f5b062591560a3" +dependencies = [ + "proc-macro2", + "quote", + "rustc_version", + "simd_cesu8", + "syn 2.0.119", +] + +[[package]] +name = "jni-sys" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6377a88cb3910bee9b0fa88d4f42e1d2da8e79915598f65fb0c7ee14c878af2" +dependencies = [ + "jni-sys-macros", +] + +[[package]] +name = "jni-sys-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38c0b942f458fe50cdac086d2f946512305e5631e720728f2a61aabcd47a6264" +dependencies = [ + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jobserver" +version = "0.1.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c00acbd29eabad4a2392fa0e921c874934dbbf4194312ad20f04a0ed67a3cb3" +dependencies = [ + "getrandom 0.4.3", + "libc", +] + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "libm" +version = "0.2.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + +[[package]] +name = "lz4" +version = "1.28.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a20b523e860d03443e98350ceaac5e71c6ba89aea7d960769ec3ce37f4de5af4" +dependencies = [ + "lz4-sys", +] + +[[package]] +name = "lz4-sys" +version = "1.11.1+lz4-1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6bd8c0d6c6ed0cd30b3652886bb8711dc4bb01d637a68105a3d5158039b418e6" +dependencies = [ + "cc", + "libc", +] + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d65c71f1ce40ab09135ce117d742b9f8a19ff91a41a8b57ed50bc2de59c427" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[package]] +name = "num-integer" +version = "0.1.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +dependencies = [ + "num-traits", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "odbc-sys" +version = "0.31.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "245cb4fe8236df4fd352ba96075d754233c6509d654d9f1c1482158b7d6c083d" + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "open" +version = "5.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0b3d059e795d52b8a72fef45658620edd4d9c359b338564aa14391ffa511ed5" +dependencies = [ + "is-wsl", + "libc", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "paste" +version = "1.0.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "phf" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7" +dependencies = [ + "phf_shared", +] + +[[package]] +name = "phf_shared" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981" +dependencies = [ + "siphasher", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "proptest" +version = "1.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" +dependencies = [ + "bit-set", + "bit-vec", + "bitflags", + "num-traits", + "rand 0.9.5", + "rand_chacha", + "rand_xorshift", + "regex-syntax", + "rusty-fork", + "tempfile", + "unarray", +] + +[[package]] +name = "quick-error" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1d01941d82fa2ab50be1e79e6714289dd7cde78eba4c074bc5a4374f650dfe0" + +[[package]] +name = "quinn" +version = "0.11.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c1a41e437b6bbd489372cd4971de128e85c855f56c57f283d20ff016cf7c0a8" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4bfc015262b9df63c8845072ce59068853ff5872180c2ce2f13038b970e560" +dependencies = [ + "aws-lc-rs", + "bytes", + "getrandom 0.4.3", + "lru-slab", + "rand 0.10.2", + "rand_pcg", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35a133f956daabe89a61a685c2649f13d82d5aa4bd5d12d1277e1072a21c0694" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.52.0", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" +dependencies = [ + "rand_chacha", + "rand_core 0.9.5", +] + +[[package]] +name = "rand" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c7f5fa3a058cd35567ef9bfa5e75732bee0f9e4c55fa90477bef2dfcdbc4be80" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + +[[package]] +name = "rand_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core 0.9.5", +] + +[[package]] +name = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reqwest" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" +dependencies = [ + "base64 0.22.1", + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "rustls-platform-verifier", + "serde", + "serde_json", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustc-hash" +version = "2.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b1e7f9a428571be2dc5bc0505c13fb6bf936822b894ec87abf8a08a4e51742d" + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls" +version = "0.23.43" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0283386ce02abc0151e1761d08802dfe86c173b0b494af5cbc086574e453da06" +dependencies = [ + "aws-lc-rs", + "once_cell", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-native-certs" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" +dependencies = [ + "openssl-probe", + "rustls-pki-types", + "schannel", + "security-framework", +] + +[[package]] +name = "rustls-pki-types" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-platform-verifier" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26d1e2536ce4f35f4846aa13bff16bd0ff40157cdb14cc056c7b14ba41233ba0" +dependencies = [ + "core-foundation", + "core-foundation-sys", + "jni", + "log", + "once_cell", + "rustls", + "rustls-native-certs", + "rustls-platform-verifier-android", + "rustls-webpki", + "security-framework", + "security-framework-sys", + "webpki-root-certs", + "windows-sys 0.52.0", +] + +[[package]] +name = "rustls-platform-verifier-android" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" + +[[package]] +name = "rustls-webpki" +version = "0.103.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +dependencies = [ + "aws-lc-rs", + "ring", + "rustls-pki-types", + "untrusted", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "rusty-fork" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc6bf79ff24e648f6da1f8d1f011e9cac26491b619e6b9280f2b47f1774e6ee2" +dependencies = [ + "fnv", + "quick-error", + "tempfile", + "wait-timeout", +] + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags", + "core-foundation", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serial_test" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6df5ed973ad8d834e09f824f9e9f449af6b9a3745f78dec7cc752770bd3bf11" +dependencies = [ + "futures-executor", + "futures-util", + "log", + "once_cell", + "parking_lot", + "serial_test_derive", +] + +[[package]] +name = "serial_test_derive" +version = "4.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a22144e767da4ddd8416dbf383700542ffd8a5dc493dfecedfe1fe3ad03c98ae" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simd_cesu8" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11031e251abf8611c80f460e19dbdeb54a66db918e49c65a7065b46ac7aec520" +dependencies = [ + "rustc_version", + "simdutf8", +] + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" + +[[package]] +name = "siphasher" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "snafu" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e45cb604038abb7b926b679887b3226d8d0f23874b66623625a0454be425a4b7" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "287f59010008f0d7cf5e3b03196d666c1acc46c8d3e9cf34c28a1a7157601e72" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + +[[package]] +name = "stackable-odbc-core" +version = "0.1.0" +source = "git+https://github.com/stackabletech/stackable-odbc-core.git?tag=v0.1.0#23c924489e135d1d3da1d1664ae16bf8656d5aa3" +dependencies = [ + "odbc-sys", + "snafu", + "tracing", + "tracing-appender", + "tracing-subscriber", +] + +[[package]] +name = "stackable-odbc-trino" +version = "0.0.1" +dependencies = [ + "base64 0.23.0", + "chrono", + "chrono-tz", + "criterion", + "open", + "proptest", + "serde_json", + "serial_test", + "snafu", + "stackable-odbc-core", + "tokio", + "tracing", + "trino-rust-client", +] + +[[package]] +name = "structmeta" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e1575d8d40908d70f6fd05537266b90ae71b15dbbe7a8b7dffa2b759306d329" +dependencies = [ + "proc-macro2", + "quote", + "structmeta-derive", + "syn 2.0.119", +] + +[[package]] +name = "structmeta-derive" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "152a0b65a590ff6c3da95cabe2353ee04e6167c896b28e3b14478c2636c922fc" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "symlink" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7973cce6668464ea31f176d85b13c7ab3bba2cb3b77a2ed26abd7801688010a" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53e9bae58849f64dfa4f5d5ae372c8341f7305f82a3868709269343628b659a3" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.3.4", + "once_cell", + "rustix", + "windows-sys 0.52.0", +] + +[[package]] +name = "thiserror" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09a43598840e33d5b0331f38c5e30d13bb11c11210a4b58f0d9b18a5a5eefcd9" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43cbfe0cf76104d42a574802844187e84a305e531ed54455f11fbde0f10541cd" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "time" +version = "0.3.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" +dependencies = [ + "deranged", + "num-conv", + "powerfmt", + "serde_core", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" + +[[package]] +name = "time-macros" +version = "0.2.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[package]] +name = "tinyvec" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb4ebadaa0af04fab11ae01eb5f9fdb5f9c5b875506e210e71c07873528baa7f" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + +[[package]] +name = "tokio" +version = "1.53.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "socket2", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "bitflags", + "bytes", + "futures-util", + "http", + "http-body", + "pin-project-lite", + "tower", + "tower-layer", + "tower-service", + "url", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-appender" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "050686193eb999b4bb3bc2acfa891a13da00f79734704c4b8b4ef1a10b368a3c" +dependencies = [ + "crossbeam-channel", + "symlink", + "thiserror", + "time", + "tracing-subscriber", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "trino-rust-client" +version = "0.11.0" +source = "git+https://github.com/stackabletech/trino-rust-client.git?branch=stackable-main#98b44b01bdcb2c38fe60d174f890286b1e39ec5e" +dependencies = [ + "async-stream", + "backon", + "base64 0.22.1", + "bigdecimal", + "chrono", + "chrono-tz", + "derive_more", + "flate2", + "futures", + "http", + "iterable", + "lazy_static", + "lz4", + "open", + "paste", + "regex", + "reqwest", + "serde", + "serde_json", + "thiserror", + "tokio", + "tracing", + "trino-rust-client-macros", + "url", + "uuid", + "zstd", +] + +[[package]] +name = "trino-rust-client-macros" +version = "0.7.2" +source = "git+https://github.com/stackabletech/trino-rust-client.git?branch=stackable-main#98b44b01bdcb2c38fe60d174f890286b1e39ec5e" +dependencies = [ + "proc-macro2", + "quote", + "structmeta", + "syn 2.0.119", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "unarray" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eaea85b334db583fe3274d12b4cd1880032beab409c0d774be044d4480ab9a94" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.24.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf3923a6f5c4c6382e0b653c4117f48d631ea17f38ed86e2a828e6f7412f5239" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "wait-timeout" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ac3b126d3914f9849036f826e054cbabdc8519970b8998ddaf3b5bd3c65f11" +dependencies = [ + "libc", +] + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.76" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c62df1340f32221cb9c54d6a27b030e3dba64361d4a95bed55f9aacb44da291d" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-root-certs" +version = "1.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +dependencies = [ + "rustls-pki-types", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.52.0", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_gnullvm", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yoke" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b5a105cd7b140f6eeec8acff2ea38135d3cab283ada58540f629fe51e46696eb" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.55" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fe976fb70c78cd64cccfe3a6fc142244e8a77b70959b30faf9d0ac37ee228eb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zstd" +version = "0.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91ee311a569c327171651566e07972200e76fcfe2242a4fa446149a3881c08a" +dependencies = [ + "zstd-safe", +] + +[[package]] +name = "zstd-safe" +version = "7.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f49c4d5f0abb602a93fb8736af2a4f4dd9512e36f7f570d66e65ff867ed3b9d" +dependencies = [ + "zstd-sys", +] + +[[package]] +name = "zstd-sys" +version = "2.0.16+zstd.1.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e19ebc2adc8f83e43039e79776e3fda8ca919132d68a1fed6a5faca2683748" +dependencies = [ + "cc", + "pkg-config", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..c1a7a43 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,61 @@ +[package] +name = "stackable-odbc-trino" +version = "0.0.1" +edition = "2024" +rust-version = "1.95.0" +authors = ["Stackable GmbH "] +license = "Apache-2.0" +description = "ODBC driver for Trino, built on the stackable-odbc-core framework." +repository = "https://github.com/stackabletech/stackable-odbc-trino" +readme = "README.md" +keywords = ["odbc", "trino", "driver", "ffi", "sql"] +categories = ["database", "external-ffi-bindings", "api-bindings"] + +[lib] +crate-type = ["cdylib", "rlib"] + +[dependencies] +base64 = "0.23" +chrono = { version = "0.4", default-features = false } +chrono-tz = "0.10" +open = "5" +serde_json = "1" +snafu = "0.9" +# TODO: switch to a crates.io version dep once stackable-odbc-core is published. +# `deny.toml` allows this repository by name, and `cargo publish` stays blocked +# until then. Pinned to a tag rather than a branch, so bumping core is a +# reviewable edit to this line instead of whatever the branch happens to point +# at. To build against a local checkout, use a `[patch]` in your own +# `.cargo/config.toml` rather than editing this line; see CONTRIBUTING.md. +stackable-odbc-core = { git = "https://github.com/stackabletech/stackable-odbc-core.git", tag = "v0.1.0" } +# `time` is needed directly by `query_all_rows_within`, which bounds the login +# round trip with `tokio::time::timeout`. It resolves without being declared, +# because reqwest enables it, but a direct use must not rely on another crate's +# feature selection staying put. +tokio = { version = "1.51", features = ["rt", "macros", "time"] } +tracing = "0.1" +# TODO: switch back to a crates.io version dep once the fork's changes are +# released upstream. +trino-rust-client = { git = "https://github.com/stackabletech/trino-rust-client.git", branch = "stackable-main", features = [ + "spooling", +] } + +[dev-dependencies] +criterion = { version = "0.8", features = ["html_reports"] } +proptest = "1" +serial_test = "4" +# The `test-support` feature gates core's `conformance` and `test_support` +# modules: the info-type shape checks and the connection-injection hooks the +# offline FFI tests need. It is default-off because it is test code, so it is +# enabled here rather than on the [dependencies] entry above -- that keeps it +# out of the shipped cdylib. +stackable-odbc-core = { git = "https://github.com/stackabletech/stackable-odbc-core.git", tag = "v0.1.0", features = ["test-support"] } + +[lints.clippy] +unwrap_in_result = "deny" +unwrap_used = "deny" +panic = "deny" + +[[bench]] +name = "fetch_trino" +harness = false diff --git a/README.md b/README.md new file mode 100644 index 0000000..a029391 --- /dev/null +++ b/README.md @@ -0,0 +1,330 @@ + + +

+ Stackable Logo +

+ +

Stackable ODBC Driver for Trino

+ +

Plug Power BI, Excel, Tableau or Python straight into Trino.

+ +[![Build and Test](https://github.com/stackabletech/stackable-odbc-trino/actions/workflows/build.yaml/badge.svg)](https://github.com/stackabletech/stackable-odbc-trino/actions/workflows/build.yaml) +[![Security Audit](https://github.com/stackabletech/stackable-odbc-trino/actions/workflows/security_audit.yaml/badge.svg)](https://github.com/stackabletech/stackable-odbc-trino/actions/workflows/security_audit.yaml) +[![OpenSSF Scorecard](https://api.securityscorecards.dev/projects/github.com/stackabletech/stackable-odbc-trino/badge)](https://scorecard.dev/viewer/?uri=github.com/stackabletech/stackable-odbc-trino) +[![Apache License 2.0](https://img.shields.io/badge/license-Apache--2.0-green)](./LICENSE) +[![ODBC 3.80](https://img.shields.io/badge/ODBC-3.80-blue)](#compatibility) +[![Platforms](https://img.shields.io/badge/platforms-Linux%20%7C%20Windows-blue)](#quick-start) +[![Trino](https://img.shields.io/badge/Trino-compatible-blue)](https://trino.io) +[![Power BI](https://img.shields.io/badge/Power%20BI-connector%20included-blue)](#power-bi) + +[Stackable Data Platform](https://stackable.tech/) | [Platform Docs](https://docs.stackable.tech/) | [Discussions](https://github.com/orgs/stackabletech/discussions) | [Discord](https://discord.gg/7kZ3BNnCAF) + +## What is this? + +[Trino](https://trino.io) runs SQL across many systems at once, so one query can +join a table in PostgreSQL against files in S3 and a Kafka topic. Most desktop +analytics tools cannot talk to Trino directly, but nearly all of them speak +ODBC. + +This is the ODBC driver for Trino. Install it, and Power BI, Excel, Tableau, +`isql` and Python's `pyodbc` can query Trino like any other database. +Linux and Windows are both first-class targets. + +## Quick start + +Download an archive from the +[releases page](https://github.com/stackabletech/stackable-odbc-trino/releases). + +### Windows + +1. Unzip `stackable-odbc-trino--windows-x64.zip`. +2. Right-click `install.bat` and choose **Run as administrator**. This registers + the driver with Windows. +3. Open **ODBC Data Sources (64-bit)** from the Start menu, click **Add**, and + pick `stackable_odbc_trino` from the list. Fill in your coordinator's + hostname, port and login, then click **OK**. + +Step 3 creates a *DSN*: a saved connection with a name. Once it exists, every +tool on the machine can pick it from a list instead of asking you to type a +connection string. + +The archive also ships `configure-dsn.ps1`, a standalone dialog covering every +option below, if you would rather script the setup or see the full surface in +one window. + +### Linux + +You need unixODBC (the `unixodbc` package). Installing the driver registers it +system-wide, so it needs root. + +```bash +mkdir /tmp/trino-odbc +tar xzf stackable-odbc-trino--linux-x64.tar.gz -C /tmp/trino-odbc +cd /tmp/trino-odbc +sudo ./install.sh +``` + +Check it worked with `odbcinst -q -d`, which should list +`[stackable_odbc_trino]`. + +### Power BI + +Power BI Desktop is Windows-only, so the connector ships in the Windows archive +and as a standalone `StackableTrinoODBC-.mez` on the releases page, not +in the Linux tarball. It is a Power Query custom connector, and it gives Trino a +proper entry in the **Get Data** dialog instead of the generic ODBC one. + +1. Copy the `.mez` into `%USERPROFILE%\Documents\Power BI Desktop\Custom Connectors\`. +2. In **File > Options > Security**, allow any extension to load. +3. Restart Power BI Desktop. **Stackable Trino** now appears under **Get Data**. + +### Your first query + +Assuming a Trino instance is reachable on the given host and port: + +```python +import pyodbc + +conn = pyodbc.connect( + "Driver=stackable_odbc_trino;Host=trino.example.com;Port=8443;" + "User=me;Password=secret" +) +for row in conn.cursor().execute("SELECT name FROM tpch.tiny.nation LIMIT 5"): + print(row.name) +``` + +For the full install and uninstall reference, see +[`packaging/README.md`](packaging/README.md). + +## Connecting + +Connection strings are `Key=Value` pairs joined by `;`. Keys are +case-insensitive. + +```text +Driver=stackable_odbc_trino;Host=trino.example.com;Port=8443;User=admin;Password=secret;Catalog=hive;Schema=default +``` + +The keys most people need: + +| Key | Required | Meaning | +|-----|----------|---------| +| `Host` | Yes | Trino coordinator hostname | +| `Port` | Yes | Coordinator port | +| `User` | Yes¹ | Username. Alias: `UID`. ¹Optional under `ExternalAuthentication`, where the login supplies it | +| `Password` | No | Password. Alias: `PWD` | +| `Catalog` | No | Catalog to start in | +| `Schema` | No | Schema to start in | +| `TlsVerify` | No | `true`/`full` (default), `ca`, or `false`/`none` | +| `ExternalAuthentication` | No | `true` for the browser login | + +
+All connection options (click to expand) + +The authoritative list is `src/backend/types/connect_params.rs`. + +| Key | Required | Meaning | +|-----|----------|---------| +| `Host` | Yes | Trino coordinator hostname | +| `Port` | Yes | Coordinator port | +| `User` | Yes¹ | Username (Basic Auth). Alias: `UID`. ¹Optional under `ExternalAuthentication`, where the identity provider supplies it | +| `Password` | No | Password (Basic Auth). Alias: `PWD` | +| `Protocol` | No | `https` (default) or `http` | +| `Catalog` | No | Default catalog | +| `Schema` | No | Default schema | +| `Source` | No | Query source Trino records and can route on. Default `stackable-odbc-trino/` | +| `ClientTags` | No | Comma-separated Trino client tags, which select a resource group | +| `TlsVerify` | No | `true`/`full` (default), `ca`, or `false`/`none`. Alias: `SSLVerification` | +| `Certificate` | No | Path to a PEM CA certificate for server verification. It becomes the only trust anchor, so the machine's CA store no longer applies. Required by `ca`, refused with `Protocol=http` | +| `ClientCertificate` | No | Path to a PEM holding a client certificate chain and its PKCS#8 key, for mutual TLS. Refused with `Protocol=http` | +| `AccessToken` | No | JWT bearer token. Alias: `Token` | +| `ExternalAuthentication` | No | `true` selects Trino's interactive OAuth 2.0 flow. Needs `https`, and excludes `Password` and `AccessToken` | +| `ExternalAuthenticationTimeout` | No | Budget for one interactive login, in seconds. Default 300 | +| `QueryTimeout` | No | Per-request HTTP timeout in seconds (default 30). `0` disables it; anything that is not a whole number is refused. Alias: `LoginTimeout` | +| `Encoding` | No | Trino's spooled query-data encoding: `json`, `json+zstd` or `json+lz4`. Unset returns every row inline. JDBC's `encoding` | +| `SessionProperties` | No | Trino session properties, `{name:value;name2:value2}` | +| `ResourceEstimates` | No | Scheduling hints, same form | +| `ExtraCredentials` | No | Connector-level credentials, same form | +| `Roles` | No | Authorisation role per catalog, `{catalog:role;catalog2:ALL}` | +| `SessionUser` | No | User statements run as, while `User` still authenticates. JDBC's `sessionUser` | +| `Path` | No | Default SQL path for resolving unqualified function names | +| `TimeZone` | No | IANA session time zone (`Europe/Berlin`). Unset leaves the coordinator's | +| `Locale` | No | Locale for locale-dependent formatting, sent as `X-Trino-Language` | +| `ClientInfo` | No | Free-form client metadata Trino records against the query | +| `TraceToken` | No | Correlation token Trino records against the query | +| `ExtraHeaders` | No | Extra HTTP headers, `{name:value;name2:value2}` | +| `ClientCapabilities` | No | Comma-separated extra capabilities, on top of `PARAMETRIC_DATETIME` and `PATH` | +| `Proxy` | No | HTTP/HTTPS proxy URL for every request. Credentials in the URL are rejected | +| `ProxyUser` | No | Proxy Basic username. Requires `ProxyPassword` | +| `ProxyPassword` | No | Proxy Basic password | +| `DisableCompression` | No | `true` or `false` (default) | +| `MaxAttempts` | No | Request retry budget. Unset leaves the client's own | + +
+ +### Values that contain a semicolon + +Five keys take a list of pairs: `SessionProperties`, `ResourceEstimates`, +`ExtraCredentials`, `Roles` and `ExtraHeaders`. They use JDBC's format exactly, +so a value copied out of a JDBC URL works unchanged. That format separates pairs +with `;`, which is also what separates one connection-string key from the next, +so wrap those values in braces: + +```text +SessionProperties={query_max_run_time:10m;example.foo:bar};Encoding=json+zstd +``` + +Without the braces, the connection string ends the value at the first `;` and +silently discards every pair but the first. + +In a DSN it is the other way round. Braces are connection-string syntax, so the +value is stored bare: + +```text +SessionProperties=query_max_run_time:10m;example.foo:bar +``` + +Braces in a DSN fail the connection outright, so the mistake is at least loud in +that direction. The Windows dialog handles both cases for you. + +The Power BI connector is different again. In Power Query's Advanced Editor the +options are fields of a record — the square brackets in the call below — and +`Odbc.DataSource` builds the connection string from that record, escaping the +values itself. Write the value bare, as in a DSN, and add no braces: + +```text +StackableTrinoODBC.Contents("trino.example.com", 8443, "hive", null, "me", null, + [SessionProperties = "query_max_run_time:10m;example.foo:bar"]) +``` + +## What you get + +- **Sign in the way your company already does.** Username and password, a bearer + token, a client certificate, or a browser login through Trino's OAuth 2.0 + flow. For the browser login the driver shows you a URL, you sign in with your + normal account, and it picks up the token when you are done. That login is + shared across the whole application, so a tool opening ten connections opens + one browser tab. + +- **Run queries as somebody else, on purpose.** `SessionUser` authenticates as + you but runs the SQL under another user's name, which is how a shared service + keeps per-user permissions. `Roles` picks the authorisation role per catalog, + which Hive and Iceberg need before they will let you write anything. + +- **Encryption has a middle setting.** Most drivers offer full verification or + none, so one coordinator reached under an internal hostname ends up with + checking switched off everywhere. `TlsVerify=ca` still verifies the + certificate against your CA and only skips the hostname match. + +- **The stop button stops the query.** Cancelling from your tool tells the + coordinator to kill the query, so it stops consuming cluster time. Query + timeouts work the same way, and cover the time spent receiving rows rather + than only the time spent starting the query. + +- **Your tool can browse the data.** Catalogs, schemas, tables, columns, types + and privileges all show up in the object browser, so you can click through + what is there instead of guessing table names. + +- **Real transactions.** Turn autocommit off and the driver opens a Trino + transaction on your next statement, then commits or rolls back when you say + so. If a statement inside the transaction fails, Trino abandons the whole + thing, and the driver rolls back and tells you the commit did not happen + rather than reporting a success that threw your writes away. + +- **Power BI can leave the work in Trino.** In DirectQuery mode the bundled + connector turns report interactions into SQL, pushing filters, joins, + grouping and row limits down to Trino, so a report over a billion-row table + asks Trino for the answer instead of dragging the table across the network + first. In Import mode that folding still applies to the steps in the Power + Query editor at refresh time, but the result is then loaded into the local + model and everything after that happens on your machine. + +- **Big results can skip the coordinator.** Setting `Encoding=json+zstd` turns + on Trino's spooling protocol, where large results travel through object + storage instead of streaming through the coordinator a page at a time. It is + off by default because not every machine can reach that storage, and a + coordinator that does not support it answers normally, so switching it on + cannot break a connection that already worked. + +## Limits + +Each of these is reported to your tool as unsupported rather than quietly +ignored, so the tool can react instead of trusting a wrong answer. + +- **No primary keys, foreign keys, indexes or stored procedures.** Trino + publishes no metadata for any of them, so those lookups return nothing. +- **The catalog cannot be changed after connecting.** Trino's `USE` moves the + catalog and the schema together, so honouring "switch to catalog X" would mean + inventing a schema, and your unqualified table names would start resolving + somewhere you never asked for. Set `Catalog` when you connect. +- **Row and field size limits are not faked.** Trino can only limit a result set + through `LIMIT` in the SQL you wrote. +- **One isolation level.** Trino catalogs disagree about which levels they + accept, so the driver offers the one they all support and refuses the rest up + front, rather than letting a query fail later for a reason nobody can see. + +## Compatibility + +| Component | Support | +|---|---| +| ODBC | 3.80 | +| Platforms | Linux x86-64, Windows x86-64 | +| Driver Managers | unixODBC, and the Windows Driver Manager | +| Trino | tested against 483 | +| Tested with | Power BI Desktop, `pyodbc`, `isql` | + +Older Trino versions are likely to work, since the driver uses the stable REST +protocol, but 483 is what the test suite runs against. + +## Troubleshooting + +**Turn on logging first.** The driver logs to a file when you ask it to, and +that is usually enough to see what a tool is really sending: + +```bash +export ODBC_LOG_LEVEL=debug +export ODBC_LOG_FILE=/tmp/trino-odbc.log +``` + +On Windows, set the same two as environment variables. The log may contain your +SQL, so check it before sharing. + +**The driver does not appear in the list.** On Linux, run `odbcinst -q -d`; if +`[stackable_odbc_trino]` is missing, the install did not complete. On Windows, +make sure you opened **ODBC Data Sources (64-bit)**: a 64-bit driver is invisible +to the 32-bit Administrator, and both are in the Start menu under similar names. + +**TLS errors.** The default verifies the certificate chain and the hostname. If +your coordinator's certificate does not carry the name you are connecting under, +use `TlsVerify=ca` with `Certificate` pointing at your CA's PEM file. That still +verifies the certificate and only relaxes the name check. + +`Certificate` names the trust anchor rather than adding one, so the machine's +own CA store stops applying to that connection. A coordinator with a publicly +issued certificate needs no `Certificate` at all; setting one that did not sign +the coordinator's chain is refused even when the machine trusts that chain. + +**Only the first session property applies.** Wrap the value in braces. See +[Values that contain a semicolon](#values-that-contain-a-semicolon). + +**The browser login never opens.** Some tools, `pyodbc` among them, tell the +driver it may not display anything. The driver reports this rather than hanging. +Use `AccessToken` with those tools, or connect through one that allows a prompt. + +## Getting help + +- [GitHub Discussions](https://github.com/orgs/stackabletech/discussions) for + questions +- [Discord](https://discord.gg/7kZ3BNnCAF) to talk to us +- [Issues](https://github.com/stackabletech/stackable-odbc-trino/issues) for + bugs, and [SECURITY.md](SECURITY.md) for anything security-related + +## Contributing + +See [CONTRIBUTING.md](CONTRIBUTING.md) for building from source, running the +tests, and how the repository is laid out. [CHANGELOG.md](CHANGELOG.md) records +what changed in each release. + +## License + +Apache-2.0 diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..8452df5 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,33 @@ +# Security Policy + +## Reporting a Vulnerability + +Please report security vulnerabilities privately, not through a public issue. + +The preferred channel is GitHub's private vulnerability reporting: open the +**Security** tab of this repository and choose **Report a vulnerability**. This +reaches the maintainers directly and keeps the report confidential until a fix +is available. + +If you cannot use that channel, email `info@stackable.tech` with `SECURITY` in +the subject line. + +Please include the driver version, the platform and Driver Manager in use, the +Trino version where relevant, and the steps needed to reproduce the issue. + +## What to Expect + +We aim to acknowledge a report within three working days and to give an initial +assessment within ten. We will keep you informed while a fix is prepared, and we +will credit you in the advisory unless you ask us not to. + +## Supported Versions + +Security fixes are made against the most recent release and the `main` branch. +While the driver is below 1.0, fixes are not backported to earlier releases: +upgrade to the current release to receive them. + +## Disclosure + +Fixed vulnerabilities are published as GitHub Security Advisories against this +repository, naming the affected versions and the release that carries the fix. diff --git a/benches/fetch_trino.rs b/benches/fetch_trino.rs new file mode 100644 index 0000000..a3ddccc --- /dev/null +++ b/benches/fetch_trino.rs @@ -0,0 +1,340 @@ +//! End-to-end fetch-path benchmarks for the Trino backend. +//! +//! Opt-in: requires a running Trino coordinator. Skips silently if +//! `TRINO_BENCH_URL` is unset (so `cargo bench` still works on a +//! plain checkout). +//! +//! Run: +//! ./integration-tests/setup.sh +//! TRINO_BENCH_URL=https://localhost:8443 cargo bench +//! +//! Override workload size: +//! TRINO_BENCH_URL=https://localhost:8443 BENCH_ROWS=1000000 cargo bench +//! +//! The default query uses Trino's `SEQUENCE` builtin and does not require any +//! particular catalog. To override: +//! TRINO_BENCH_QUERY="SELECT * FROM tpcds.tiny.store_sales LIMIT 100000" \ +//! TRINO_BENCH_URL=https://localhost:8443 cargo bench + +use std::ffi::c_void; +use std::hint::black_box; +use std::time::Duration; + +use criterion::{BatchSize, BenchmarkId, Criterion, Throughput, criterion_group, criterion_main}; +use stackable_odbc_core::ffi; +use stackable_odbc_core::types::{CDataType, FreeStmtOption, HandleType, SqlReturn}; +use stackable_odbc_trino::TrinoBackend; + +#[derive(Clone)] +struct BenchConfig { + rows: usize, + cols: usize, + repeat_get_data: usize, + url: String, + query_override: Option, +} + +fn env_or(name: &str, default: T) -> T { + std::env::var(name) + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(default) +} + +/// Returns `None` if `TRINO_BENCH_URL` is unset, so the caller should print a skip +/// message and return early. +fn bench_config() -> Option { + let url = std::env::var("TRINO_BENCH_URL").ok()?; + Some(BenchConfig { + rows: env_or("BENCH_ROWS", 100_000), + cols: env_or("BENCH_COLS", 20), + repeat_get_data: env_or("BENCH_REPEAT_GET_DATA", 3), + url, + query_override: std::env::var("TRINO_BENCH_QUERY").ok(), + }) +} + +fn configure_for_size(c: Criterion, rows: usize) -> Criterion { + if rows > 250_000 { + c.sample_size(20) + .measurement_time(Duration::from_secs(60)) + .warm_up_time(Duration::from_secs(5)) + } else { + c + } +} + +fn shape_a_split(n_cols: usize) -> (usize, usize, usize) { + let s = (n_cols * 4) / 10; + let d = n_cols / 10; + let i = n_cols - s - d; + debug_assert_eq!(i + s + d, n_cols, "shape_a_split must total n_cols"); + (i, s, d) +} + +/// Generate Shape A as a Trino SQL query backed by SEQUENCE. +fn shape_a_query(rows: usize, cols: usize) -> String { + let (n_i, n_s, n_d) = shape_a_split(cols); + let mut select_exprs: Vec = Vec::with_capacity(cols); + for i in 0..n_i { + select_exprs.push(format!("(n + {i}) AS i_{i}")); + } + for i in 0..n_s { + // Fixed 32-char string: a stable payload size keeps row widths + // comparable across benchmark runs. + select_exprs.push(format!( + "CAST('xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx' AS VARCHAR) AS s_{i}" + )); + } + for i in 0..n_d { + select_exprs.push(format!("CAST(12345678.90 AS DECIMAL(18,2)) AS d_{i}")); + } + format!( + "SELECT {} FROM UNNEST(SEQUENCE(1, {rows})) AS t(n)", + select_exprs.join(", "), + ) +} + +/// Build the connection string. setup.sh serves HTTPS only, on 8443. +fn connect_string(url: &str) -> String { + // Parse `http://host:port` minimally; reject unsupported forms loudly. + let (proto, rest) = url.split_once("://").unwrap_or(("http", url)); + let (host, port) = rest.split_once(':').unwrap_or((rest, "8443")); + format!("Host={host};Port={port};User=admin;Protocol={proto}") +} + +unsafe fn alloc_handles() -> (*mut c_void, *mut c_void, *mut c_void) { + unsafe { + let mut env: *mut c_void = std::ptr::null_mut(); + let _ = ffi::handle::sql_alloc_handle::( + HandleType::Env as i16, + std::ptr::null_mut(), + &mut env, + ); + let mut conn: *mut c_void = std::ptr::null_mut(); + let _ = + ffi::handle::sql_alloc_handle::(HandleType::Dbc as i16, env, &mut conn); + let mut stmt: *mut c_void = std::ptr::null_mut(); + let _ = + ffi::handle::sql_alloc_handle::(HandleType::Stmt as i16, conn, &mut stmt); + (env, conn, stmt) + } +} + +unsafe fn connect_trino(conn: *mut c_void, conn_str: &str) -> SqlReturn { + unsafe { + let wide: Vec = conn_str.encode_utf16().collect(); + ffi::connect::sql_driver_connect_w::( + conn, + std::ptr::null_mut(), + wide.as_ptr(), + wide.len() as i16, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + 0, + ) + } +} + +unsafe fn exec_direct(stmt: *mut c_void, sql: &str) -> SqlReturn { + unsafe { + let wide: Vec = sql.encode_utf16().collect(); + ffi::execute::sql_exec_direct_w::(stmt, wide.as_ptr(), wide.len() as i32) + } +} + +unsafe fn cleanup(env: *mut c_void, conn: *mut c_void, stmt: *mut c_void) { + unsafe { + let _ = ffi::handle::sql_free_handle::(HandleType::Stmt as i16, stmt); + let _ = ffi::connect::sql_disconnect::(conn); + let _ = ffi::handle::sql_free_handle::(HandleType::Dbc as i16, conn); + let _ = ffi::handle::sql_free_handle::(HandleType::Env as i16, env); + } +} + +unsafe fn drain_late_binding(stmt: *mut c_void, n_cols: u16) -> usize { + unsafe { + let mut buf = vec![0u8; 4096]; + let mut ind: isize = 0; + let mut count = 0usize; + while ffi::fetch::sql_fetch::(stmt) == SqlReturn::SUCCESS { + for col in 1..=n_cols { + let _ = ffi::fetch::sql_get_data::( + stmt, + col, + CDataType::Default as i16, + buf.as_mut_ptr() as *mut c_void, + buf.len() as isize, + &mut ind, + ); + count += 1; + } + } + count + } +} + +struct Bound { + buf: Vec, + ind: isize, +} + +/// Bind every column once; returns the binding storage. Caller must keep the +/// returned Vec alive for as long as the bindings are in effect. +unsafe fn bind_columns_trino(stmt: *mut c_void, n_cols: u16) -> Vec { + unsafe { + let mut bindings: Vec = (0..n_cols) + .map(|_| Bound { + buf: vec![0u8; 4096], + ind: 0, + }) + .collect(); + for (i, b) in bindings.iter_mut().enumerate() { + let _ = ffi::bind::sql_bind_col::( + stmt, + (i + 1) as u16, + CDataType::Default as i16, + b.buf.as_mut_ptr() as *mut c_void, + b.buf.len() as isize, + &mut b.ind, + ); + } + bindings + } +} + +/// Drain a result set whose columns have already been bound via `bind_columns_trino`. +/// Returns total cells fetched (n_cols × n_rows). +unsafe fn drain_bound_columns(stmt: *mut c_void, n_cols: u16) -> usize { + unsafe { + let mut count = 0usize; + while ffi::fetch::sql_fetch::(stmt) == SqlReturn::SUCCESS { + count += n_cols as usize; + } + count + } +} + +unsafe fn drain_repeat_get_data(stmt: *mut c_void, n_cols: u16, repeats: usize) -> usize { + unsafe { + let mut buf = vec![0u8; 4096]; + let mut ind: isize = 0; + let mut count = 0usize; + while ffi::fetch::sql_fetch::(stmt) == SqlReturn::SUCCESS { + for col in 1..=n_cols { + for _ in 0..repeats { + let _ = ffi::fetch::sql_get_data::( + stmt, + col, + CDataType::Default as i16, + buf.as_mut_ptr() as *mut c_void, + buf.len() as isize, + &mut ind, + ); + count += 1; + } + } + } + count + } +} + +fn bench_trino(c: &mut Criterion) { + let Some(cfg) = bench_config() else { + eprintln!("[fetch_trino] TRINO_BENCH_URL unset, skipping Trino benches."); + return; + }; + + let conn_str = connect_string(&cfg.url); + let query = cfg + .query_override + .clone() + .unwrap_or_else(|| shape_a_query(cfg.rows, cfg.cols)); + let label = format!("{}x{}", cfg.rows, cfg.cols); + + let (env, conn, stmt) = unsafe { alloc_handles() }; + let connect_ret = unsafe { connect_trino(conn, &conn_str) }; + if connect_ret != SqlReturn::SUCCESS { + unsafe { cleanup(env, conn, stmt) }; + eprintln!( + "[fetch_trino] Connect to {} failed (SqlReturn {:?}); skipping.", + cfg.url, connect_ret + ); + return; + } + + let mut group = c.benchmark_group("trino/shape_a"); + group.throughput(Throughput::Elements((cfg.rows * cfg.cols) as u64)); + + group.bench_function(BenchmarkId::new("late_binding", &label), |b| { + b.iter_batched( + || unsafe { + let _ = ffi::cursor::sql_close_cursor::(stmt); + assert_eq!(exec_direct(stmt, &query), SqlReturn::SUCCESS); + }, + |_| { + black_box(unsafe { drain_late_binding(stmt, cfg.cols as u16) }); + }, + BatchSize::PerIteration, + ); + }); + + group.bench_function(BenchmarkId::new("bound_columns", &label), |b| { + // Close the cursor left open by the late_binding bench's last iteration. + let _ = unsafe { ffi::cursor::sql_close_cursor::(stmt) }; + // Bind columns once, before the bench loop. Bindings survive SQLCloseCursor + // per ODBC spec, so they remain in effect across all iterations. + assert_eq!(unsafe { exec_direct(stmt, &query) }, SqlReturn::SUCCESS); + let _bindings = unsafe { bind_columns_trino(stmt, cfg.cols as u16) }; + let _ = unsafe { ffi::cursor::sql_close_cursor::(stmt) }; + + b.iter_batched( + || unsafe { + let _ = ffi::cursor::sql_close_cursor::(stmt); + assert_eq!(exec_direct(stmt, &query), SqlReturn::SUCCESS); + }, + |_| { + black_box(unsafe { drain_bound_columns(stmt, cfg.cols as u16) }); + }, + BatchSize::PerIteration, + ); + }); + + let repeat_id = BenchmarkId::new(format!("repeat_get_data_x{}", cfg.repeat_get_data), &label); + group.bench_function(repeat_id, |b| { + // Unbind columns from the bound_columns bench: its buffers are freed + // when that closure exits, so fetching with stale bindings is UB. + let _ = unsafe { + ffi::handle::sql_free_stmt::(stmt, FreeStmtOption::Unbind as u16) + }; + b.iter_batched( + || unsafe { + let _ = ffi::cursor::sql_close_cursor::(stmt); + assert_eq!(exec_direct(stmt, &query), SqlReturn::SUCCESS); + }, + |_| { + black_box(unsafe { + drain_repeat_get_data(stmt, cfg.cols as u16, cfg.repeat_get_data) + }); + }, + BatchSize::PerIteration, + ); + }); + + group.finish(); + + unsafe { cleanup(env, conn, stmt) }; +} + +fn benches() -> Criterion { + let rows = bench_config().map(|c| c.rows).unwrap_or(0); + configure_for_size(Criterion::default(), rows) +} + +criterion_group! { + name = benches_group; + config = benches(); + targets = bench_trino +} +criterion_main!(benches_group); diff --git a/build.rs b/build.rs new file mode 100644 index 0000000..acacfc4 --- /dev/null +++ b/build.rs @@ -0,0 +1,202 @@ +//! Embeds a Windows `VERSIONINFO` resource in the driver DLL. +//! +//! The ODBC Data Source Administrator reads its **Version** and **Company** +//! columns from the driver file's version resource, and prints `Not marked` +//! for a file that carries none. Every Rust `cdylib` carries none, since rustc +//! emits no such resource. Measured on Windows Server 2022: `sqlsrv32.dll` +//! lists as `10.00.20348.01` / `Microsoft Corporation` and carries exactly +//! those two strings. +//! +//! Nothing here is hand-maintained. Every string comes from `Cargo.toml` +//! through cargo's own environment, so the resource cannot disagree with the +//! package. The version follows `CARGO_PKG_VERSION`, and therefore whatever +//! `cargo-release` wrote into `Cargo.toml`, which is why `release.toml` has no +//! rule for this file. `connector/StackableTrinoODBC.pq` needs one because it +//! carries its own version literal with nothing deriving it. + +use std::path::PathBuf; +use std::process::Command; + +fn main() { + println!("cargo:rerun-if-changed=build.rs"); + println!("cargo:rerun-if-env-changed=CARGO_PKG_VERSION"); + println!("cargo:rerun-if-env-changed=WINDRES"); + + // `CARGO_CFG_TARGET_OS`, never `cfg!(windows)`: a build script is compiled + // for and run on the *host*, so `cfg!(windows)` describes the machine doing + // the building. The release DLL is cross-compiled from Linux, where it is + // false, so it would skip the resource on precisely the build that ships. + if std::env::var("CARGO_CFG_TARGET_OS").as_deref() != Ok("windows") { + return; + } + + // Only the GNU toolchain is wired up. An MSVC target needs `rc.exe` from a + // Visual Studio installation, which this repo never builds with: + // `.github/workflows/release.yaml` installs `gcc-mingw-w64-x86-64` and + // builds `x86_64-pc-windows-gnu`. Warn rather than fail, so a local MSVC + // build still works. It produces a DLL the Administrator lists as + // `Not marked`. + let target_env = std::env::var("CARGO_CFG_TARGET_ENV").unwrap_or_default(); + if target_env != "gnu" { + println!( + "cargo:warning=no version resource embedded: the {target_env} Windows toolchain \ + needs rc.exe, and only the gnu toolchain's windres is wired up in build.rs. \ + The ODBC Data Source Administrator will list this driver as \"Not marked\"." + ); + return; + } + + let out_dir = PathBuf::from( + std::env::var("OUT_DIR").expect("cargo always sets OUT_DIR for a build script"), + ); + let rc_path = out_dir.join("version.rc"); + let obj_path = out_dir.join("version.o"); + + if let Err(e) = std::fs::write(&rc_path, version_rc()) { + fail(&format!("could not write {}: {e}", rc_path.display())); + } + + let windres = windres_command(); + let status = Command::new(&windres) + .arg("--input") + .arg(&rc_path) + .arg("--output") + .arg(&obj_path) + // COFF, so the result is an object file the linker takes like any + // other. windres defaults to emitting an `.rc` back out. + .arg("--output-format=coff") + .status(); + + match status { + Ok(s) if s.success() => {} + Ok(s) => fail(&format!( + "`{windres}` failed with {s} on {}", + rc_path.display() + )), + Err(e) => fail(&format!( + "could not run `{windres}`: {e}\n\ + A Windows build needs windres to embed the driver's version resource. \ + On Debian and Ubuntu it is in binutils-mingw-w64-x86-64, which \ + gcc-mingw-w64-x86-64 already depends on. Set WINDRES to override the name." + )), + } + + // `-cdylib`, not the unsuffixed form: the resource belongs to the shipped + // DLL, and the unsuffixed flag would also be handed to the linker for every + // test and benchmark binary. + println!("cargo:rustc-link-arg-cdylib={}", obj_path.display()); +} + +/// Abort the build with a message. +/// +/// `exit` rather than `panic!`, which the crate's clippy configuration denies. +/// Cargo renders a build script's stderr and its exit status as a build error +/// either way, and this way adds no backtrace for nobody to read. +fn fail(message: &str) -> ! { + eprintln!("error: {message}"); + std::process::exit(1); +} + +/// The `windres` to invoke. +/// +/// The cross-prefixed name first, because that is what a Linux host has: the +/// bare `windres` there, if it exists at all, targets the host. `WINDRES` +/// overrides both, for a toolchain under a different prefix. +fn windres_command() -> String { + if let Ok(explicit) = std::env::var("WINDRES") { + return explicit; + } + let prefixed = "x86_64-w64-mingw32-windres"; + if Command::new(prefixed).arg("--version").output().is_ok() { + return prefixed.to_string(); + } + "windres".to_string() +} + +/// The resource script, built entirely from cargo's environment. +fn version_rc() -> String { + let version = std::env::var("CARGO_PKG_VERSION").unwrap_or_default(); + let major = env_num("CARGO_PKG_VERSION_MAJOR"); + let minor = env_num("CARGO_PKG_VERSION_MINOR"); + let patch = env_num("CARGO_PKG_VERSION_PATCH"); + let description = std::env::var("CARGO_PKG_DESCRIPTION").unwrap_or_default(); + let license = std::env::var("CARGO_PKG_LICENSE").unwrap_or_default(); + let company = company_name(); + + // The lib name is the package name with hyphens replaced, which is what + // both the `.so` and the `.dll` are named after. + let file_name = format!( + "{}.dll", + std::env::var("CARGO_PKG_NAME") + .unwrap_or_default() + .replace('-', "_") + ); + + // Literal numeric constants rather than `#include `, so the + // script does not depend on the mingw headers being on windres's include + // path: VOS_NT_WINDOWS32 (0x40004) and VFT_DLL (0x2). + // + // The 040904b0 block is US English, Unicode, and the VarFileInfo + // translation below must name the same pair or the strings are ignored. + format!( + r#"1 VERSIONINFO +FILEVERSION {major},{minor},{patch},0 +PRODUCTVERSION {major},{minor},{patch},0 +FILEOS 0x40004L +FILETYPE 0x2L +BEGIN + BLOCK "StringFileInfo" + BEGIN + BLOCK "040904b0" + BEGIN + VALUE "CompanyName", "{company}" + VALUE "FileDescription", "{description}" + VALUE "FileVersion", "{version}" + VALUE "InternalName", "{file_name}" + VALUE "LegalCopyright", "Copyright the {company} authors. Licensed under {license}." + VALUE "OriginalFilename", "{file_name}" + VALUE "ProductName", "{description}" + VALUE "ProductVersion", "{version}" + END + END + BLOCK "VarFileInfo" + BEGIN + VALUE "Translation", 0x409, 1200 + END +END +"#, + company = rc_escape(&company), + description = rc_escape(&description), + license = rc_escape(&license), + version = rc_escape(&version), + file_name = rc_escape(&file_name), + ) +} + +/// `CARGO_PKG_AUTHORS` without the address, so `Cargo.toml`'s +/// `Stackable GmbH ` becomes the company the ODBC +/// Administrator shows. Only the first author: the column holds one name. +fn company_name() -> String { + let authors = std::env::var("CARGO_PKG_AUTHORS").unwrap_or_default(); + let first = authors.split(':').next().unwrap_or_default(); + first + .split('<') + .next() + .unwrap_or_default() + .trim() + .to_string() +} + +fn env_num(key: &str) -> u16 { + std::env::var(key) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(0) +} + +/// Quote and backslash are the two characters an `.rc` string cannot carry +/// raw. None of the values used here contains either today; escaping them +/// anyway keeps a future `description` from producing an unparseable script. +fn rc_escape(value: &str) -> String { + value.replace('\\', r"\\").replace('"', r#"\""#) +} diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..f69b4a6 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,2 @@ +allow-unwrap-in-tests = true +allow-panic-in-tests = true diff --git a/connector/DEVELOPING.md b/connector/DEVELOPING.md new file mode 100644 index 0000000..60cebc2 --- /dev/null +++ b/connector/DEVELOPING.md @@ -0,0 +1,129 @@ +# Developing the Power Query connector + +Maintainer notes for `connector/`. To install or build the `.mez`, see +[`README.md`](README.md). + +## Project structure + +| File | Included in .mez | Purpose | +|------|------------------|---------| +| `StackableTrinoODBC.pq` | Yes | Main connector logic (M language) | +| `Diagnostics.pqm` | Yes | Trace logging helper (from Microsoft sample) | +| `OdbcConstants.pqm` | Yes | ODBC constants translated to M (from Microsoft sample) | +| `resources.resx` | Yes | UI strings (button text, labels) | +| `StackableTrinoODBC*.png` | Yes | Icons, the Stackable mark at seven sizes. See below | +| `StackableTrinoODBC.query.pq` | No | Test query for the Power Query SDK debugger | +| `build.sh` | No | Builds the .mez on Linux | + +## Configuration + +Key settings in `StackableTrinoODBC.pq` (top of file): + +| Setting | Default | Purpose | +|---------|---------|---------| +| `Config_DriverName` | `stackable_odbc_trino` | ODBC driver name as registered | +| `Config_AdvancedOptions` | see the file | Connection-string keys `StackableTrinoODBC.Contents` accepts in its `options` record | +| `Config_SqlConformance` | `SQL_SC_SQL92_FULL` (8) | Overrides driver's reported conformance | +| `Config_LimitClauseKind` | `LimitClauseKind.LimitOffset` | SQL syntax for row limits | +| `Config_UseParameterBindings` | `true` | Bind parameters rather than inlining literals | +| `Config_StringLiteralEscapeCharacters` | `{ "'" }` | How a literal quote is escaped in generated SQL | +| `Config_UseCastInsteadOfConvert` | `true` | Trino uses CAST, not CONVERT | +| `Config_EnableDirectQuery` | `true` | Enables DirectQuery mode in Power BI | + +`Config_AdvancedOptions` omits the four keys the driver declares sensitive: +`AccessToken`, `ExtraCredentials`, `ExtraHeaders` and `ProxyPassword`. An option +set here is stored in the query text inside the `.pbix`, which is a file people +mail to each other. + +Two `cargo test` checks keep the connector and the driver in step. +`connector_options_are_connection_string_keys` in `src/lib.rs` checks +`Config_AdvancedOptions` against the driver's own `PARAM_` constants. +`connector_version_matches_the_crate` checks the `[Version = "..."]` at the top +of the `.pq` against `Cargo.toml`. Do not edit that version by hand; +`release.toml` rewrites it during a release. + +## Icons + +The seven PNGs are the Stackable mark, rendered from +`.readme/static/borrowed/Icon_Stackable.svg`. Power BI shows them in the +**Get Data** list. The `.pq` groups them into `Icon16` = {16, 20, 24, 32} and +`Icon32` = {32, 40, 48, 64}, and Power BI picks a group by display scaling, so +the 16 is only ever seen at 100% DPI. + +Those seven sizes are the whole set Power Query defines, and every connector in +Microsoft's `DataConnectors` samples ships exactly them. A size outside the two +groups is loaded by nothing: the icons are read with `Extension.Contents` from +inside the `.mez`, so they reach Power BI and nothing else. The ODBC Driver +Manager never sees them, and lists the driver from the `VERSIONINFO` resource +`build.rs` embeds in the DLL. + +16, 20 and 24 are rendered edge to edge, the rest with 9% padding. The mark has +six horizontal bands, which at 16px is under three pixels each, so the small +sizes need every pixel they can get. They stay soft at 16, and there is no +simplified variant for the small sizes. + +The tile is the Stackable mark rather than Trino's. Naming the connector +"Stackable Trino" is nominative use of a trademark and is fine. Shipping the +Trino Software Foundation's logo as this product's tile would imply an +endorsement nobody gave. + +To regenerate after a brand asset changes (needs no system packages, `uv` +fetches the renderer): + +```bash +uv run --with cairosvg python3 -c ' +import cairosvg +src = open(".readme/static/borrowed/Icon_Stackable.svg").read() +body = src.split(">", 1)[1].rsplit("", 1)[0] +for size in (16, 20, 24, 32, 40, 48, 64): + pad = 0.0 if size <= 24 else 0.09 + inner = size * (1 - 2 * pad) + scale = min(inner / 507.97, inner / 517.33) + dx, dy = (size - 507.97 * scale) / 2, (size - 517.33 * scale) / 2 + cairosvg.svg2png( + bytestring=( + f"" + f"{body}" + ).encode(), + write_to=f"connector/StackableTrinoODBC{size}.png", + output_width=size, output_height=size, + ) +' +``` + +## Testing with the Power Query SDK + +1. Open `connector/` in VS Code with the Power Query SDK extension +2. Open `StackableTrinoODBC.query.pq` +3. Ctrl+Shift+Alt+E to evaluate. This runs the test query against a local Trino + instance without installing the `.mez`. + +## Verifying query folding + +Automatically, on Linux, against a running Trino: + +```bash +uv run --with pyodbc python3 integration-tests/suites/test_folding_contract.py "" +``` + +This is the only thing that reads the `.pq` outside Power BI, so it is the only +guard against a connector declaration drifting from what the driver reports or +what Trino accepts. It parses the connector rather than transcribing it, and +checks three things: + +- every `Constant` visitor field name is a real driver `TYPE_NAME` +- every CAST target is a type Trino has +- the row-limiting clause the `AstVisitor` builds runs, including the order it + concatenates the two clauses in. Trino's grammar is + `OFFSET count LIMIT count` and rejects the reverse. + +By hand, in Power BI Desktop, after connecting: + +1. Open the Power Query Editor (Transform Data) +2. Add a filter, sort, or aggregation step +3. Right-click the step → **View Native Query** +4. If it is greyed out, the step did not fold. Check the connector + configuration. + +ODBC tracing on Windows shows the exact SQL sent to the driver. diff --git a/connector/Diagnostics.pqm b/connector/Diagnostics.pqm new file mode 100644 index 0000000..56a0066 --- /dev/null +++ b/connector/Diagnostics.pqm @@ -0,0 +1,234 @@ +// Trace-logging helpers for the Power Query connector. +// +// Vendored from Microsoft's Power Query connector samples, unmodified apart +// from stripped trailing whitespace and this header: +// https://github.com/microsoft/DataConnectors/blob/master/samples/ODBC/SqlODBC/Diagnostics.pqm +// +// connector/build.sh zips it into the .mez. Nothing in +// StackableTrinoODBC.pq loads it today; it is kept so a connector change that +// needs trace logging can call Extension.LoadFunction("Diagnostics.pqm"). +// Take fixes from upstream rather than editing it here. + +let + Diagnostics.LogValue = (prefix, value, optional delayed) => + Diagnostics.Trace( + TraceLevel.Information, + prefix & ": " & (try Diagnostics.ValueToText(value) otherwise ""), + value, + delayed + ), + Diagnostics.LogValue2 = (prefix, value, result, optional delayed) => + Diagnostics.Trace(TraceLevel.Information, prefix & ": " & Diagnostics.ValueToText(value), result, delayed), + Diagnostics.LogFailure = (text, function) => + let + result = try function() + in + if result[HasError] then + Diagnostics.LogValue2(text, result[Error], () => error result[Error], true) + else + result[Value], + Diagnostics.WrapFunctionResult = (innerFunction as function, outerFunction as function) as function => + Function.From(Value.Type(innerFunction), (list) => outerFunction(() => Function.Invoke(innerFunction, list))), + Diagnostics.WrapHandlers = (handlers as record) as record => + Record.FromList( + List.Transform( + Record.FieldNames(handlers), + (h) => + Diagnostics.WrapFunctionResult(Record.Field(handlers, h), (fn) => Diagnostics.LogFailure(h, fn)) + ), + Record.FieldNames(handlers) + ), + Diagnostics.ValueToText = (value) => + let + List.TransformAndCombine = (list, transform, separator) => + Text.Combine(List.Transform(list, transform), separator), + Serialize.Binary = (x) => "#binary(" & Serialize(Binary.ToList(x)) & ") ", + Serialize.Function = (x) => + _serialize_function_param_type( + Type.FunctionParameters(Value.Type(x)), Type.FunctionRequiredParameters(Value.Type(x)) + ) + & " as " + & _serialize_function_return_type(Value.Type(x)) + & " => (...) ", + Serialize.List = (x) => "{" & List.TransformAndCombine(x, Serialize, ", ") & "} ", + Serialize.Record = (x) => + "[ " + & List.TransformAndCombine( + Record.FieldNames(x), + (item) => Serialize.Identifier(item) & " = " & Serialize(Record.Field(x, item)), + ", " + ) + & " ] ", + Serialize.Table = (x) => + "#table( type " & _serialize_table_type(Value.Type(x)) & ", " & Serialize(Table.ToRows(x)) & ") ", + Serialize.Identifier = Expression.Identifier, + Serialize.Type = (x) => "type " & _serialize_typename(x), + _serialize_typename = (x, optional funtype as logical) => + // Optional parameter: Is this being used as part of a function signature? + let + isFunctionType = (x as type) => + try if Type.FunctionReturn(x) is type then true else false otherwise false, + isTableType = (x as type) => + try if Type.TableSchema(x) is table then true else false otherwise false, + isRecordType = (x as type) => + try if Type.ClosedRecord(x) is type then true else false otherwise false, + isListType = (x as type) => try if Type.ListItem(x) is type then true else false otherwise false + in + if funtype = null and isTableType(x) then + _serialize_table_type(x) + else if funtype = null and isListType(x) then + "{ " & @_serialize_typename(Type.ListItem(x)) & " }" + else if funtype = null and isFunctionType(x) then + "function " & _serialize_function_type(x) + else if funtype = null and isRecordType(x) then + _serialize_record_type(x) + else if x = type any then + "any" + else + let + base = Type.NonNullable(x) + in + (if Type.IsNullable(x) then "nullable " else "") + & ( + if base = type anynonnull then + "anynonnull" + else if base = type binary then + "binary" + else if base = type date then + "date" + else if base = type datetime then + "datetime" + else if base = type datetimezone then + "datetimezone" + else if base = type duration then + "duration" + else if base = type logical then + "logical" + else if base = type none then + "none" + else if base = type null then + "null" + else if base = type number then + "number" + else if base = type text then + "text" + else if base = type time then + "time" + else if base = type type then + "type" + else + // Abstract types + if base = type function then + "function" + else if base = type table then + "table" + else if base = type record then + "record" + else if base = type list then + "list" + else + "any /*Actually unknown type*/" + ), + _serialize_table_type = (x) => + let + schema = Type.TableSchema(x) + in + "table " + & ( + if Table.IsEmpty(schema) then + "" + else + "[" + & List.TransformAndCombine( + Table.ToRecords(Table.Sort(schema, "Position")), + each Serialize.Identifier(_[Name]) & " = " & _[Kind], + ", " + ) + & "] " + ), + _serialize_record_type = (x) => + let + flds = Type.RecordFields(x) + in + if Record.FieldCount(flds) = 0 then + "record" + else + "[" + & List.TransformAndCombine( + Record.FieldNames(flds), + (item) => + Serialize.Identifier(item) + & "=" + & _serialize_typename(Record.Field(flds, item)[Type]), + ", " + ) + & (if Type.IsOpenRecord(x) then ", ..." else "") + & "]", + _serialize_function_type = (x) => + _serialize_function_param_type(Type.FunctionParameters(x), Type.FunctionRequiredParameters(x)) + & " as " + & _serialize_function_return_type(x), + _serialize_function_param_type = (t, n) => + let + funsig = Table.ToRecords( + Table.TransformColumns( + Table.AddIndexColumn(Record.ToTable(t), "isOptional", 1), {"isOptional", (x) => x > n} + ) + ) + in + "(" + & List.TransformAndCombine( + funsig, + (item) => + (if item[isOptional] then "optional " else "") + & Serialize.Identifier(item[Name]) + & " as " + & _serialize_typename(item[Value], true), + ", " + ) + & ")", + _serialize_function_return_type = (x) => _serialize_typename(Type.FunctionReturn(x), true), + Serialize = (x) as text => + if x is binary then + try Serialize.Binary(x) otherwise "null /*serialize failed*/" + else if x is date then + try Expression.Constant(x) otherwise "null /*serialize failed*/" + else if x is datetime then + try Expression.Constant(x) otherwise "null /*serialize failed*/" + else if x is datetimezone then + try Expression.Constant(x) otherwise "null /*serialize failed*/" + else if x is duration then + try Expression.Constant(x) otherwise "null /*serialize failed*/" + else if x is function then + try Serialize.Function(x) otherwise "null /*serialize failed*/" + else if x is list then + try Serialize.List(x) otherwise "null /*serialize failed*/" + else if x is logical then + try Expression.Constant(x) otherwise "null /*serialize failed*/" + else if x is null then + try Expression.Constant(x) otherwise "null /*serialize failed*/" + else if x is number then + try Expression.Constant(x) otherwise "null /*serialize failed*/" + else if x is record then + try Serialize.Record(x) otherwise "null /*serialize failed*/" + else if x is table then + try Serialize.Table(x) otherwise "null /*serialize failed*/" + else if x is text then + try Expression.Constant(x) otherwise "null /*serialize failed*/" + else if x is time then + try Expression.Constant(x) otherwise "null /*serialize failed*/" + else if x is type then + try Serialize.Type(x) otherwise "null /*serialize failed*/" + else + "[#_unable_to_serialize_#]" + in + try Serialize(value) otherwise "" +in + [ + LogValue = Diagnostics.LogValue, + LogValue2 = Diagnostics.LogValue2, + LogFailure = Diagnostics.LogFailure, + WrapFunctionResult = Diagnostics.WrapFunctionResult, + WrapHandlers = Diagnostics.WrapHandlers, + ValueToText = Diagnostics.ValueToText + ] diff --git a/connector/OdbcConstants.pqm b/connector/OdbcConstants.pqm new file mode 100644 index 0000000..49d95d4 --- /dev/null +++ b/connector/OdbcConstants.pqm @@ -0,0 +1,1181 @@ +// The ODBC constants from sqlext.h, translated to M. +// +// Vendored from Microsoft's Power Query connector samples, unmodified apart +// from stripped trailing whitespace and this header: +// https://github.com/microsoft/DataConnectors/blob/master/samples/ODBC/SqlODBC/OdbcConstants.pqm +// +// The values themselves come from +// https://github.com/Microsoft/ODBC-Specification/blob/master/Windows/inc/sqlext.h +// +// StackableTrinoODBC.pq loads it as `ODBC` with Extension.LoadFunction. Take +// fixes from upstream rather than editing it here, including the one `todo` +// comment further down, which is upstream's. +[ + Flags = (flags as list) => + if (List.IsEmpty(flags)) then + 0 + else + let + Loop = List.Generate( + () => [i = 0, Combined = flags{0}], + each [i] < List.Count(flags), + each [Combined = Number.BitwiseOr([Combined], flags{i}), i = [i] + 1], + each [Combined] + ), + Result = List.Last(Loop) + in + Result, + SQL_HANDLE = [ + ENV = 1, + DBC = 2, + STMT = 3, + DESC = 4 + ], + RetCode = [ + SUCCESS = 0, + SUCCESS_WITH_INFO = 1, + ERROR = -1, + INVALID_HANDLE = -2, + NO_DATA = 100 + ], + SQL_CONVERT = [ + BIGINT = 53, + BINARY = 54, + BIT = 55, + CHAR = 56, + DATE = 57, + DECIMAL = 58, + DOUBLE = 59, + FLOAT = 60, + INTEGER = 61, + LONGVARCHAR = 62, + NUMERIC = 63, + REAL = 64, + SMALLINT = 65, + TIME = 66, + TIMESTAMP = 67, + TINYINT = 68, + VARBINARY = 69, + VARCHAR = 70, + LONGVARBINARY = 71 + ], + SQL_ROW = [ + PROCEED = 0, + IGNORE = 1, + SUCCESS = 0, + DELETED = 1, + UPDATED = 2, + NOROW = 3, + ADDED = 4, + ERROR = 5, + SUCCESS_WITH_INFO = 6 + ], + SQL_CVT = [ + // None = 0, + CHAR = 0x00000001, + NUMERIC = 0x00000002, + DECIMAL = 0x00000004, + INTEGER = 0x00000008, + SMALLINT = 0x00000010, + FLOAT = 0x00000020, + REAL = 0x00000040, + DOUBLE = 0x00000080, + VARCHAR = 0x00000100, + LONGVARCHAR = 0x00000200, + BINARY = 0x00000400, + VARBINARY = 0x00000800, + BIT = 0x00001000, + TINYINT = 0x00002000, + BIGINT = 0x00004000, + DATE = 0x00008000, + TIME = 0x00010000, + TIMESTAMP = 0x00020000, + LONGVARBINARY = 0x00040000, + INTERVAL_YEAR_MONTH = 0x00080000, + INTERVAL_DAY_TIME = 0x00100000, + WCHAR = 0x00200000, + WLONGVARCHAR = 0x00400000, + WVARCHAR = 0x00800000, + GUID = 0x01000000 + ], + STMT = [ + CLOSE = 0, + DROP = 1, + UNBIND = 2, + RESET_PARAMS = 3 + ], + SQL_MAX = [ + NUMERIC_LEN = 16 + ], + SQL_IS = [ + POINTER = -4, + INTEGER = -6, + UINTEGER = -5, + SMALLINT = -8 + ], + // SQL Server specific defines + // + // from Odbcss.h + SQL_HC = [ + OFF = 0 /* FOR BROWSE columns are hidden */, + ON = 1 /* FOR BROWSE columns are exposed */ + ], + // from Odbcss.h + SQL_NB = [ + // NO_BROWSETABLE is off + OFF = 0, + // NO_BROWSETABLE is on + ON = 1 + ], + // SQLColAttributes driver specific defines. + // SQLSet/GetDescField driver specific defines. + // Microsoft has 1200 thru 1249 reserved for Microsoft SQL Server driver usage. + // + // from Odbcss.h + SQL_CA_SS = [ + // SQL_CA_SS_BASE + BASE = 1200, + // Column is hidden (FOR BROWSE) + COLUMN_HIDDEN = 1200 + 11, + // Column is key column (FOR BROWSE) + COLUMN_KEY = 1200 + 12, + VARIANT_TYPE = 1200 + 15, + VARIANT_SQL_TYPE = 1200 + 16, + VARIANT_SERVER_TYPE = 1200 + 17 + ], + // from Odbcss.h + SQL_SOPT_SS = [ + // SQL_SOPT_SS_BASE + BASE = 1225, + // Expose FOR BROWSE hidden columns + HIDDEN_COLUMNS = 1225 + 2, + // Set NOBROWSETABLE option + NOBROWSETABLE = 1225 + 3 + ], + SQL_COMMIT = 0, + // Commit + SQL_ROLLBACK = 1, + // Abort + // static public readonly IntPtr SQL_AUTOCOMMIT_OFF = IntPtr.Zero; + // static public readonly IntPtr SQL_AUTOCOMMIT_ON = new IntPtr(1); + SQL_TRANSACTION = [ + READ_UNCOMMITTED = 0x00000001, + READ_COMMITTED = 0x00000002, + REPEATABLE_READ = 0x00000004, + SERIALIZABLE = 0x00000008, + SNAPSHOT = 0x00000020 + // VSDD 414121: SQL_TXN_SS_SNAPSHOT == 0x20 (sqlncli.h) + ], + SQL_PARAM = [ + // SQL_PARAM_TYPE_UNKNOWN + TYPE_UNKNOWN = 0, + // SQL_PARAM_INPUT + INPUT = 1, + // SQL_PARAM_INPUT_OUTPUT + INPUT_OUTPUT = 2, + // SQL_RESULT_COL + RESULT_COL = 3, + // SQL_PARAM_OUTPUT + OUTPUT = 4, + // SQL_RETURN_VALUE + RETURN_VALUE = 5 + ], + SQL_DESC = [ + // from sql.h (ODBCVER >= 3.0) + // + COUNT = 1001, + TYPE = 1002, + LENGTH = 1003, + OCTET_LENGTH_PTR = 1004, + PRECISION = 1005, + SCALE = 1006, + DATETIME_INTERVAL_CODE = 1007, + NULLABLE = 1008, + INDICATOR_PTR = 1009, + DATA_PTR = 1010, + NAME = 1011, + UNNAMED = 1012, + OCTET_LENGTH = 1013, + ALLOC_TYPE = 1099, + // from sqlext.h (ODBCVER >= 3.0) + // + CONCISE_TYPE = SQL_COLUMN[TYPE], + DISPLAY_SIZE = SQL_COLUMN[DISPLAY_SIZE], + UNSIGNED = SQL_COLUMN[UNSIGNED], + UPDATABLE = SQL_COLUMN[UPDATABLE], + AUTO_UNIQUE_VALUE = SQL_COLUMN[AUTO_INCREMENT], + TYPE_NAME = SQL_COLUMN[TYPE_NAME], + TABLE_NAME = SQL_COLUMN[TABLE_NAME], + SCHEMA_NAME = SQL_COLUMN[OWNER_NAME], + CATALOG_NAME = SQL_COLUMN[QUALIFIER_NAME], + BASE_COLUMN_NAME = 22, + BASE_TABLE_NAME = 23, + NUM_PREC_RADIX = 32 + ], + // ODBC version 2.0 style attributes + // All IdentifierValues are ODBC 1.0 unless marked differently + // + SQL_COLUMN = [ + COUNT = 0, + NAME = 1, + TYPE = 2, + LENGTH = 3, + PRECISION = 4, + SCALE = 5, + DISPLAY_SIZE = 6, + NULLABLE = 7, + UNSIGNED = 8, + MONEY = 9, + UPDATABLE = 10, + AUTO_INCREMENT = 11, + CASE_SENSITIVE = 12, + SEARCHABLE = 13, + TYPE_NAME = 14, + // (ODBC 2.0) + TABLE_NAME = 15, + // (ODBC 2.0) + OWNER_NAME = 16, + // (ODBC 2.0) + QUALIFIER_NAME = 17, + LABEL = 18 + ], + // values from sqlext.h + SQL_SQL92_RELATIONAL_JOIN_OPERATORS = [ + // SQL_SRJO_CORRESPONDING_CLAUSE + CORRESPONDING_CLAUSE = 0x00000001, + // SQL_SRJO_CROSS_JOIN + CROSS_JOIN = 0x00000002, + // SQL_SRJO_EXCEPT_JOIN + EXCEPT_JOIN = 0x00000004, + // SQL_SRJO_FULL_OUTER_JOIN + FULL_OUTER_JOIN = 0x00000008, + // SQL_SRJO_INNER_JOIN + INNER_JOIN = 0x00000010, + // SQL_SRJO_INTERSECT_JOIN + INTERSECT_JOIN = 0x00000020, + // SQL_SRJO_LEFT_OUTER_JOIN + LEFT_OUTER_JOIN = 0x00000040, + // SQL_SRJO_NATURAL_JOIN + NATURAL_JOIN = 0x00000080, + // SQL_SRJO_RIGHT_OUTER_JOIN + RIGHT_OUTER_JOIN = 0x00000100, + // SQL_SRJO_UNION_JOIN + UNION_JOIN = 0x00000200 + ], + // values from sqlext.h + SQL_QU = [ + SQL_QU_DML_STATEMENTS = 0x00000001, + SQL_QU_PROCEDURE_INVOCATION = 0x00000002, + SQL_QU_TABLE_DEFINITION = 0x00000004, + SQL_QU_INDEX_DEFINITION = 0x00000008, + SQL_QU_PRIVILEGE_DEFINITION = 0x00000010 + ], + // values from sql.h + SQL_OJ_CAPABILITIES = [ + // SQL_OJ_LEFT + LEFT = 0x00000001, + // SQL_OJ_RIGHT + RIGHT = 0x00000002, + // SQL_OJ_FULL + FULL = 0x00000004, + // SQL_OJ_NESTED + NESTED = 0x00000008, + // SQL_OJ_NOT_ORDERED + NOT_ORDERED = 0x00000010, + // SQL_OJ_INNER + INNER = 0x00000020, + // SQL_OJ_ALLCOMPARISION+OPS + ALL_COMPARISON_OPS = 0x00000040 + ], + SQL_UPDATABLE = [ + // SQL_ATTR_READ_ONLY + READONLY = 0, + // SQL_ATTR_WRITE + WRITE = 1, + // SQL_ATTR_READWRITE_UNKNOWN + READWRITE_UNKNOWN = 2 + ], + SQL_IDENTIFIER_CASE = [ + // SQL_IC_UPPER + UPPER = 1, + // SQL_IC_LOWER + LOWER = 2, + // SQL_IC_SENSITIVE + SENSITIVE = 3, + // SQL_IC_MIXED + MIXED = 4 + ], + // Uniqueness parameter in the SQLStatistics function + SQL_INDEX = [ + UNIQUE = 0, + ALL = 1 + ], + // Reserved parameter in the SQLStatistics function + SQL_STATISTICS_RESERVED = [ + // SQL_QUICK + QUICK = 0, + // SQL_ENSURE + ENSURE = 1 + ], + // Identifier type parameter in the SQLSpecialColumns function + SQL_SPECIALCOLS = [ + // SQL_BEST_ROWID + BEST_ROWID = 1, + // SQL_ROWVER + ROWVER = 2 + ], + // Scope parameter in the SQLSpecialColumns function + SQL_SCOPE = [ + // SQL_SCOPE_CURROW + CURROW = 0, + // SQL_SCOPE_TRANSACTION + TRANSACTION = 1, + // SQL_SCOPE_SESSION + SESSION = 2 + ], + SQL_NULLABILITY = [ + // SQL_NO_NULLS + NO_NULLS = 0, + // SQL_NULLABLE + NULLABLE = 1, + // SQL_NULLABLE_UNKNOWN + UNKNOWN = 2 + ], + SQL_SEARCHABLE = [ + // SQL_UNSEARCHABLE + UNSEARCHABLE = 0, + // SQL_LIKE_ONLY + LIKE_ONLY = 1, + // SQL_ALL_EXCEPT_LIKE + ALL_EXCEPT_LIKE = 2, + // SQL_SEARCHABLE + SEARCHABLE = 3 + ], + SQL_UNNAMED = [ + // SQL_NAMED + NAMED = 0, + // SQL_UNNAMED + UNNAMED = 1 + ], + // todo:move + // internal constants + // not odbc specific + // + HANDLER = [ + IGNORE = 0x00000000, + THROW = 0x00000001 + ], + // values for SQLStatistics TYPE column + SQL_STATISTICSTYPE = [ + // TABLE Statistics + TABLE_STAT = 0, + // CLUSTERED index statistics + INDEX_CLUSTERED = 1, + // HASHED index statistics + INDEX_HASHED = 2, + // OTHER index statistics + INDEX_OTHER = 3 + ], + // values for SQLProcedures PROCEDURE_TYPE column + SQL_PROCEDURETYPE = [ + // procedure is of unknow type + UNKNOWN = 0, + // procedure is a procedure + PROCEDURE = 1, + // procedure is a function + FUNCTION = 2 + ], + // private constants + // to define data types (see below) + // + // SQL_SIGNED_OFFSET + SIGNED_OFFSET = -20, + // SQL_UNSIGNED_OFFSET + UNSIGNED_OFFSET = -22, + // C Data Types + SQL_C = [ + CHAR = 1, + WCHAR = -8, + SLONG = 4 + SIGNED_OFFSET, + ULONG = 4 + UNSIGNED_OFFSET, + SSHORT = 5 + SIGNED_OFFSET, + USHORT = 5 + UNSIGNED_OFFSET, + FLOAT = 7, + DOUBLE = 8, + BIT = -7, + STINYINT = -6 + SIGNED_OFFSET, + UTINYINT = -6 + UNSIGNED_OFFSET, + SBIGINT = -5 + SIGNED_OFFSET, + UBIGINT = -5 + UNSIGNED_OFFSET, + BINARY = -2, + TIMESTAMP = 11, + TYPE_DATE = 91, + TYPE_TIME = 92, + TYPE_TIMESTAMP = 93, + NUMERIC = 2, + GUID = -11, + DEFAULT = 99, + ARD_TYPE = -99 + ], + // SQL Data Types + SQL_TYPE = [ + // Base data types (sql.h) + UNKNOWN = 0, + NULL = 0, + CHAR = 1, + NUMERIC = 2, + DECIMAL = 3, + INTEGER = 4, + SMALLINT = 5, + FLOAT = 6, + REAL = 7, + DOUBLE = 8, + DATETIME = 9, + // V3 Only + VARCHAR = 12, + // Unicode types (sqlucode.h) + WCHAR = -8, + WVARCHAR = -9, + WLONGVARCHAR = -10, + // Extended data types (sqlext.h) + INTERVAL = 10, + // V3 Only + TIME = 10, + TIMESTAMP = 11, + LONGVARCHAR = -1, + BINARY = -2, + VARBINARY = -3, + LONGVARBINARY = -4, + BIGINT = -5, + TINYINT = -6, + BIT = -7, + GUID = -11, + // V3 Only + // One-parameter shortcuts for date/time data types. + TYPE_DATE = 91, + TYPE_TIME = 92, + TYPE_TIMESTAMP = 93, + // SQL Server Types -150 to -159 (sqlncli.h) + SS_VARIANT = -150, + SS_UDT = -151, + SS_XML = -152, + SS_TABLE = -153, + SS_TIME2 = -154, + SS_TIMESTAMPOFFSET = -155 + ], + // SQL_ALL_TYPES = 0, + // static public readonly IntPtr SQL_HANDLE_NULL = IntPtr.Zero; + SQL_LENGTH = [ + SQL_IGNORE = -6, + SQL_DEFAULT_PARAM = -5, + SQL_NO_TOTAL = -4, + SQL_NTS = -3, + SQL_DATA_AT_EXEC = -2, + SQL_NULL_DATA = -1 + ], + SQL_DEFAULT_PARAM = -5, + // column ordinals for SQLProcedureColumns result set + // this column ordinals are not defined in any c/c++ header but in the ODBC Programmer's Reference under SQLProcedureColumns + // + COLUMN_NAME = 4, + COLUMN_TYPE = 5, + DATA_TYPE = 6, + COLUMN_SIZE = 8, + DECIMAL_DIGITS = 10, + NUM_PREC_RADIX = 11, + SQL_ATTR = [ + ODBC_VERSION = 200, + CONNECTION_POOLING = 201, + AUTOCOMMIT = 102, + TXN_ISOLATION = 108, + CURRENT_CATALOG = 109, + LOGIN_TIMEOUT = 103, + QUERY_TIMEOUT = 0, + CONNECTION_DEAD = 1209, + SQL_COPT_SS_BASE = 1200, + SQL_COPT_SS_ENLIST_IN_DTC = (1200 + 7), + SQL_COPT_SS_TXN_ISOLATION = (1200 + 27), + MAX_LENGTH = 3, + ROW_BIND_TYPE = 5, + CURSOR_TYPE = 6, + RETRIEVE_DATA = 11, + ROW_STATUS_PTR = 25, + ROWS_FETCHED_PTR = 26, + ROW_ARRAY_SIZE = 27, + // ODBC 3.0 + APP_ROW_DESC = 10010, + APP_PARAM_DESC = 10011, + IMP_ROW_DESC = 10012, + IMP_PARAM_DESC = 10013, + METADATA_ID = 10014, + // ODBC 4.0 + PRIVATE_DRIVER_LOCATION = 204 + ], + SQL_RD = [ + OFF = 0, + ON = 1 + ], + SQL_GD = [ + // None = 0, + ANY_COLUMN = 1, + ANY_ORDER = 2, + BLOCK = 4, + BOUND = 8, + OUTPUT_PARAMS = 16 + ], + // SQLGetInfo + /* + SQL_INFO = + [ + SQL_ACTIVE_CONNECTIONS = 0, + SQL_MAX_DRIVER_CONNECTIONS = 0, + SQL_MAX_CONCURRENT_ACTIVITIES = 1, + SQL_ACTIVE_STATEMENTS = 1, + SQL_DATA_SOURCE_NAME = 2, + SQL_DRIVER_HDBC, + SQL_DRIVER_HENV, + SQL_DRIVER_HSTMT, + SQL_DRIVER_NAME, + SQL_DRIVER_VER, + SQL_FETCH_DIRECTION, + SQL_ODBC_API_CONFORMANCE, + SQL_ODBC_VER, + SQL_ROW_UPDATES, + SQL_ODBC_SAG_CLI_CONFORMANCE, + SQL_SERVER_NAME, + SQL_SEARCH_PATTERN_ESCAPE, + SQL_ODBC_SQL_CONFORMANCE, + + SQL_DATABASE_NAME, + SQL_DBMS_NAME, + SQL_DBMS_VER, + + SQL_ACCESSIBLE_TABLES, + SQL_ACCESSIBLE_PROCEDURES, + SQL_PROCEDURES, + SQL_CONCAT_NULL_BEHAVIOR, + SQL_CURSOR_COMMIT_BEHAVIOR, + SQL_CURSOR_ROLLBACK_BEHAVIOR, + SQL_DATA_SOURCE_READ_ONLY, + SQL_DEFAULT_TXN_ISOLATION, + SQL_EXPRESSIONS_IN_ORDERBY, + SQL_IDENTIFIER_CASE, + SQL_IDENTIFIER_QUOTE_CHAR, + SQL_MAX_COLUMN_NAME_LEN, + SQL_MAX_CURSOR_NAME_LEN, + SQL_MAX_OWNER_NAME_LEN, + SQL_MAX_SCHEMA_NAME_LEN = 32, + SQL_MAX_PROCEDURE_NAME_LEN, + SQL_MAX_QUALIFIER_NAME_LEN, + SQL_MAX_CATALOG_NAME_LEN = 34, + SQL_MAX_TABLE_NAME_LEN, + SQL_MULT_RESULT_SETS, + SQL_MULTIPLE_ACTIVE_TXN, + SQL_OUTER_JOINS, + SQL_SCHEMA_TERM, + SQL_PROCEDURE_TERM, + SQL_CATALOG_NAME_SEPARATOR, + SQL_CATALOG_TERM, + SQL_SCROLL_CONCURRENCY, + SQL_SCROLL_OPTIONS, + SQL_TABLE_TERM, + SQL_TXN_CAPABLE, + SQL_USER_NAME, + + SQL_CONVERT_FUNCTIONS, + SQL_NUMERIC_FUNCTIONS, + SQL_STRING_FUNCTIONS, + SQL_SYSTEM_FUNCTIONS, + SQL_TIMEDATE_FUNCTIONS, + + SQL_CONVERT_BIGINT, + SQL_CONVERT_BINARY, + SQL_CONVERT_BIT, + SQL_CONVERT_CHAR, + SQL_CONVERT_DATE, + SQL_CONVERT_DECIMAL, + SQL_CONVERT_DOUBLE, + SQL_CONVERT_FLOAT, + SQL_CONVERT_INTEGER, + SQL_CONVERT_LONGVARCHAR, + SQL_CONVERT_NUMERIC, + SQL_CONVERT_REAL, + SQL_CONVERT_SMALLINT, + SQL_CONVERT_TIME, + SQL_CONVERT_TIMESTAMP, + SQL_CONVERT_TINYINT, + SQL_CONVERT_VARBINARY, + SQL_CONVERT_VARCHAR, + SQL_CONVERT_LONGVARBINARY, + + SQL_TXN_ISOLATION_OPTION, + SQL_ODBC_SQL_OPT_IEF, + SQL_INTEGRITY = 73, + SQL_CORRELATION_NAME, + SQL_NON_NULLABLE_COLUMNS, + SQL_DRIVER_HLIB, + SQL_DRIVER_ODBC_VER, + SQL_LOCK_TYPES, + SQL_POS_OPERATIONS, + SQL_POSITIONED_STATEMENTS, + SQL_GETDATA_EXTENSIONS, + SQL_BOOKMARK_PERSISTENCE, + SQL_STATIC_SENSITIVITY, + SQL_FILE_USAGE, + SQL_NULL_COLLATION, + SQL_ALTER_TABLE, + SQL_COLUMN_ALIAS, + SQL_GROUP_BY, + SQL_KEYWORDS, + SQL_ORDER_BY_COLUMNS_IN_SELECT, + SQL_SCHEMA_USAGE, + SQL_CATALOG_USAGE, + SQL_QUOTED_IDENTIFIER_CASE, + SQL_SPECIAL_CHARACTERS, + SQL_SUBQUERIES, + SQL_UNION_STATEMENT, + SQL_MAX_COLUMNS_IN_GROUP_BY, + SQL_MAX_COLUMNS_IN_INDEX, + SQL_MAX_COLUMNS_IN_ORDER_BY, + SQL_MAX_COLUMNS_IN_SELECT, + SQL_MAX_COLUMNS_IN_TABLE, + SQL_MAX_INDEX_SIZE, + SQL_MAX_ROW_SIZE_INCLUDES_LONG, + SQL_MAX_ROW_SIZE, + SQL_MAX_STATEMENT_LEN, + SQL_MAX_TABLES_IN_SELECT, + SQL_MAX_USER_NAME_LEN, + SQL_MAX_CHAR_LITERAL_LEN, + SQL_TIMEDATE_ADD_INTERVALS, + SQL_TIMEDATE_DIFF_INTERVALS, + SQL_NEED_LONG_DATA_LEN, + SQL_MAX_BINARY_LITERAL_LEN, + SQL_LIKE_ESCAPE_CLAUSE, + SQL_CATALOG_LOCATION, + SQL_OJ_CAPABILITIES, + + SQL_ACTIVE_ENVIRONMENTS, + SQL_ALTER_DOMAIN, + SQL_SQL_CONFORMANCE, + SQL_DATETIME_LITERALS, + SQL_BATCH_ROW_COUNT, + SQL_BATCH_SUPPORT, + SQL_CONVERT_WCHAR, + SQL_CONVERT_INTERVAL_DAY_TIME, + SQL_CONVERT_INTERVAL_YEAR_MONTH, + SQL_CONVERT_WLONGVARCHAR, + SQL_CONVERT_WVARCHAR, + SQL_CREATE_ASSERTION, + SQL_CREATE_CHARACTER_SET, + SQL_CREATE_COLLATION, + SQL_CREATE_DOMAIN, + SQL_CREATE_SCHEMA, + SQL_CREATE_TABLE, + SQL_CREATE_TRANSLATION, + SQL_CREATE_VIEW, + SQL_DRIVER_HDESC, + SQL_DROP_ASSERTION, + SQL_DROP_CHARACTER_SET, + SQL_DROP_COLLATION, + SQL_DROP_DOMAIN, + SQL_DROP_SCHEMA, + SQL_DROP_TABLE, + SQL_DROP_TRANSLATION, + SQL_DROP_VIEW, + SQL_DYNAMIC_CURSOR_ATTRIBUTES1, + SQL_DYNAMIC_CURSOR_ATTRIBUTES2, + SQL_FORWARD_ONLY_CURSOR_ATTRIBUTES1, + SQL_FORWARD_ONLY_CURSOR_ATTRIBUTES2, + SQL_INDEX_KEYWORDS, + SQL_INFO_SCHEMA_VIEWS, + SQL_KEYSET_CURSOR_ATTRIBUTES1, + SQL_KEYSET_CURSOR_ATTRIBUTES2, + SQL_ODBC_INTERFACE_CONFORMANCE, + SQL_PARAM_ARRAY_ROW_COUNTS, + SQL_PARAM_ARRAY_SELECTS, + SQL_SQL92_DATETIME_FUNCTIONS, + SQL_SQL92_FOREIGN_KEY_DELETE_RULE, + SQL_SQL92_FOREIGN_KEY_UPDATE_RULE, + SQL_SQL92_GRANT, + SQL_SQL92_NUMERIC_VALUE_FUNCTIONS, + SQL_SQL92_PREDICATES, + SQL_SQL92_RELATIONAL_JOIN_OPERATORS, + SQL_SQL92_REVOKE, + SQL_SQL92_ROW_VALUE_CONSTRUCTOR, + SQL_SQL92_STRING_FUNCTIONS, + SQL_SQL92_VALUE_EXPRESSIONS, + SQL_STANDARD_CLI_CONFORMANCE, + SQL_STATIC_CURSOR_ATTRIBUTES1, + SQL_STATIC_CURSOR_ATTRIBUTES2, + SQL_AGGREGATE_FUNCTIONS, + SQL_DDL_INDEX, + SQL_DM_VER, + SQL_INSERT_STATEMENT, + SQL_CONVERT_GUID, + + SQL_XOPEN_CLI_YEAR = 10000, + SQL_CURSOR_SENSITIVITY, + SQL_DESCRIBE_PARAMETER, + SQL_CATALOG_NAME, + SQL_COLLATION_SEQ, + SQL_MAX_IDENTIFIER_LEN, + SQL_ASYNC_MODE = 10021, + SQL_MAX_ASYNC_CONCURRENT_STATEMENTS, + + SQL_DTC_TRANSITION_COST = 1750, + ], +*/ + SQL_OAC = [ + SQL_OAC_None = 0x0000, + SQL_OAC_LEVEL1 = 0x0001, + SQL_OAC_LEVEL2 = 0x0002 + ], + SQL_OSC = [ + SQL_OSC_MINIMUM = 0x0000, + SQL_OSC_CORE = 0x0001, + SQL_OSC_EXTENDED = 0x0002 + ], + SQL_SCC = [ + SQL_SCC_XOPEN_CLI_VERSION1 = 0x00000001, + SQL_SCC_ISO92_CLI = 0x00000002 + ], + SQL_SVE = [ + SQL_SVE_CASE = 0x00000001, + SQL_SVE_CAST = 0x00000002, + SQL_SVE_COALESCE = 0x00000004, + SQL_SVE_NULLIF = 0x00000008 + ], + SQL_SSF = [ + SQL_SSF_CONVERT = 0x00000001, + SQL_SSF_LOWER = 0x00000002, + SQL_SSF_UPPER = 0x00000004, + SQL_SSF_SUBSTRING = 0x00000008, + SQL_SSF_TRANSLATE = 0x00000010, + SQL_SSF_TRIM_BOTH = 0x00000020, + SQL_SSF_TRIM_LEADING = 0x00000040, + SQL_SSF_TRIM_TRAILING = 0x00000080 + ], + SQL_SP = [ + // None = 0, + SQL_SP_EXISTS = 0x00000001, + SQL_SP_ISNOTNULL = 0x00000002, + SQL_SP_ISNULL = 0x00000004, + SQL_SP_MATCH_FULL = 0x00000008, + SQL_SP_MATCH_PARTIAL = 0x00000010, + SQL_SP_MATCH_UNIQUE_FULL = 0x00000020, + SQL_SP_MATCH_UNIQUE_PARTIAL = 0x00000040, + SQL_SP_OVERLAPS = 0x00000080, + SQL_SP_UNIQUE = 0x00000100, + SQL_SP_LIKE = 0x00000200, + SQL_SP_IN = 0x00000400, + SQL_SP_BETWEEN = 0x00000800, + SQL_SP_COMPARISON = 0x00001000, + SQL_SP_QUANTIFIED_COMPARISON = 0x00002000, + All = 0x0000FFFF + ], + SQL_OIC = [ + SQL_OIC_CORE = 1, + SQL_OIC_LEVEL1 = 2, + SQL_OIC_LEVEL2 = 3 + ], + SQL_USAGE = [ + SQL_U_DML_STATEMENTS = 0x00000001, + SQL_U_PROCEDURE_INVOCATION = 0x00000002, + SQL_U_TABLE_DEFINITION = 0x00000004, + SQL_U_INDEX_DEFINITION = 0x00000008, + SQL_U_PRIVILEGE_DEFINITION = 0x00000010 + ], + SQL_GB = [ + SQL_GB_NOT_SUPPORTED = 0, + SQL_GB_GROUP_BY_EQUALS_SELECT = 1, + SQL_GB_GROUP_BY_CONTAINS_SELECT = 2, + SQL_GB_NO_RELATION = 3, + SQL_GB_COLLATE = 4 + ], + SQL_NC = [ + SQL_NC_END = 0, + SQL_NC_HIGH = 1, + SQL_NC_LOW = 2, + SQL_NC_START = 3 + ], + SQL_CN = [ + SQL_CN_None = 0, + SQL_CN_DIFFERENT = 1, + SQL_CN_ANY = 2 + ], + SQL_NNC = [ + SQL_NNC_NULL = 0, + SQL_NNC_NON_NULL = 1 + ], + SQL_CB = [ + SQL_CB_NULL = 0, + SQL_CB_NON_NULL = 1 + ], + SQL_FD_FETCH = [ + SQL_FD_FETCH_NEXT = 0x00000001, + SQL_FD_FETCH_FIRST = 0x00000002, + SQL_FD_FETCH_LAST = 0x00000004, + SQL_FD_FETCH_PRIOR = 0x00000008, + SQL_FD_FETCH_ABSOLUTE = 0x00000010, + SQL_FD_FETCH_RELATIVE = 0x00000020, + SQL_FD_FETCH_BOOKMARK = 0x00000080 + ], + SQL_SQ = [ + SQL_SQ_COMPARISON = 0x00000001, + SQL_SQ_EXISTS = 0x00000002, + SQL_SQ_IN = 0x00000004, + SQL_SQ_QUANTIFIED = 0x00000008, + SQL_SQ_CORRELATED_SUBQUERIES = 0x00000010 + ], + SQL_U = [ + SQL_U_UNION = 0x00000001, + SQL_U_UNION_ALL = 0x00000002 + ], + SQL_BP = [ + SQL_BP_CLOSE = 0x00000001, + SQL_BP_DELETE = 0x00000002, + SQL_BP_DROP = 0x00000004, + SQL_BP_TRANSACTION = 0x00000008, + SQL_BP_UPDATE = 0x00000010, + SQL_BP_OTHER_HSTMT = 0x00000020, + SQL_BP_SCROLL = 0x00000040 + ], + SQL_QL = [ + SQL_QL_START = 0x0001, + SQL_QL_END = 0x0002 + ], + SQL_OJ = [ + SQL_OJ_LEFT = 0x00000001, + SQL_OJ_RIGHT = 0x00000002, + SQL_OJ_FULL = 0x00000004, + SQL_OJ_NESTED = 0x00000008, + SQL_OJ_NOT_ORDERED = 0x00000010, + SQL_OJ_INNER = 0x00000020, + SQL_OJ_ALL_COMPARISON_OPS = 0x00000040 + ], + SQL_FN_CVT = [ + // None = 0, + SQL_FN_CVT_CONVERT = 0x00000001, + SQL_FN_CVT_CAST = 0x00000002 + ], + SQL_FN_NUM = [ + // None = 0, + SQL_FN_NUM_ABS = 0x00000001, + SQL_FN_NUM_ACOS = 0x00000002, + SQL_FN_NUM_ASIN = 0x00000004, + SQL_FN_NUM_ATAN = 0x00000008, + SQL_FN_NUM_ATAN2 = 0x00000010, + SQL_FN_NUM_CEILING = 0x00000020, + SQL_FN_NUM_COS = 0x00000040, + SQL_FN_NUM_COT = 0x00000080, + SQL_FN_NUM_EXP = 0x00000100, + SQL_FN_NUM_FLOOR = 0x00000200, + SQL_FN_NUM_LOG = 0x00000400, + SQL_FN_NUM_MOD = 0x00000800, + SQL_FN_NUM_SIGN = 0x00001000, + SQL_FN_NUM_SIN = 0x00002000, + SQL_FN_NUM_SQRT = 0x00004000, + SQL_FN_NUM_TAN = 0x00008000, + SQL_FN_NUM_PI = 0x00010000, + SQL_FN_NUM_RAND = 0x00020000, + SQL_FN_NUM_DEGREES = 0x00040000, + SQL_FN_NUM_LOG10 = 0x00080000, + SQL_FN_NUM_POWER = 0x00100000, + SQL_FN_NUM_RADIANS = 0x00200000, + SQL_FN_NUM_ROUND = 0x00400000, + SQL_FN_NUM_TRUNCATE = 0x00800000 + ], + SQL_SNVF = [ + SQL_SNVF_BIT_LENGTH = 0x00000001, + SQL_SNVF_CHAR_LENGTH = 0x00000002, + SQL_SNVF_CHARACTER_LENGTH = 0x00000004, + SQL_SNVF_EXTRACT = 0x00000008, + SQL_SNVF_OCTET_LENGTH = 0x00000010, + SQL_SNVF_POSITION = 0x00000020 + ], + SQL_FN_STR = [ + // None = 0, + SQL_FN_STR_CONCAT = 0x00000001, + SQL_FN_STR_INSERT = 0x00000002, + SQL_FN_STR_LEFT = 0x00000004, + SQL_FN_STR_LTRIM = 0x00000008, + SQL_FN_STR_LENGTH = 0x00000010, + SQL_FN_STR_LOCATE = 0x00000020, + SQL_FN_STR_LCASE = 0x00000040, + SQL_FN_STR_REPEAT = 0x00000080, + SQL_FN_STR_REPLACE = 0x00000100, + SQL_FN_STR_RIGHT = 0x00000200, + SQL_FN_STR_RTRIM = 0x00000400, + SQL_FN_STR_SUBSTRING = 0x00000800, + SQL_FN_STR_UCASE = 0x00001000, + SQL_FN_STR_ASCII = 0x00002000, + SQL_FN_STR_CHAR = 0x00004000, + SQL_FN_STR_DIFFERENCE = 0x00008000, + SQL_FN_STR_LOCATE_2 = 0x00010000, + SQL_FN_STR_SOUNDEX = 0x00020000, + SQL_FN_STR_SPACE = 0x00040000, + SQL_FN_STR_BIT_LENGTH = 0x00080000, + SQL_FN_STR_CHAR_LENGTH = 0x00100000, + SQL_FN_STR_CHARACTER_LENGTH = 0x00200000, + SQL_FN_STR_OCTET_LENGTH = 0x00400000, + SQL_FN_STR_POSITION = 0x00800000 + ], + SQL_FN_SYSTEM = [ + // None = 0, + SQL_FN_SYS_USERNAME = 0x00000001, + SQL_FN_SYS_DBNAME = 0x00000002, + SQL_FN_SYS_IFNULL = 0x00000004 + ], + SQL_FN_TD = [ + // None = 0, + SQL_FN_TD_NOW = 0x00000001, + SQL_FN_TD_CURDATE = 0x00000002, + SQL_FN_TD_DAYOFMONTH = 0x00000004, + SQL_FN_TD_DAYOFWEEK = 0x00000008, + SQL_FN_TD_DAYOFYEAR = 0x00000010, + SQL_FN_TD_MONTH = 0x00000020, + SQL_FN_TD_QUARTER = 0x00000040, + SQL_FN_TD_WEEK = 0x00000080, + SQL_FN_TD_YEAR = 0x00000100, + SQL_FN_TD_CURTIME = 0x00000200, + SQL_FN_TD_HOUR = 0x00000400, + SQL_FN_TD_MINUTE = 0x00000800, + SQL_FN_TD_SECOND = 0x00001000, + SQL_FN_TD_TIMESTAMPADD = 0x00002000, + SQL_FN_TD_TIMESTAMPDIFF = 0x00004000, + SQL_FN_TD_DAYNAME = 0x00008000, + SQL_FN_TD_MONTHNAME = 0x00010000, + SQL_FN_TD_CURRENT_DATE = 0x00020000, + SQL_FN_TD_CURRENT_TIME = 0x00040000, + SQL_FN_TD_CURRENT_TIMESTAMP = 0x00080000, + SQL_FN_TD_EXTRACT = 0x00100000 + ], + SQL_SDF = [ + SQL_SDF_CURRENT_DATE = 0x00000001, + SQL_SDF_CURRENT_TIME = 0x00000002, + SQL_SDF_CURRENT_TIMESTAMP = 0x00000004 + ], + SQL_TSI = [ + // None = 0, + SQL_TSI_FRAC_SECOND = 0x00000001, + SQL_TSI_SECOND = 0x00000002, + SQL_TSI_MINUTE = 0x00000004, + SQL_TSI_HOUR = 0x00000008, + SQL_TSI_DAY = 0x00000010, + SQL_TSI_WEEK = 0x00000020, + SQL_TSI_MONTH = 0x00000040, + SQL_TSI_QUARTER = 0x00000080, + SQL_TSI_YEAR = 0x00000100 + ], + SQL_AF = [ + // None = 0, + SQL_AF_AVG = 0x00000001, + SQL_AF_COUNT = 0x00000002, + SQL_AF_MAX = 0x00000004, + SQL_AF_MIN = 0x00000008, + SQL_AF_SUM = 0x00000010, + SQL_AF_DISTINCT = 0x00000020, + SQL_AF_ALL = 0x00000040, + All = 0xFF + ], + SQL_SC = [ + // None = 0, + SQL_SC_SQL92_ENTRY = 0x00000001, + SQL_SC_FIPS127_2_TRANSITIONAL = 0x00000002, + SQL_SC_SQL92_INTERMEDIATE = 0x00000004, + SQL_SC_SQL92_FULL = 0x00000008 + ], + SQL_DL_SQL92 = [ + SQL_DL_SQL92_DATE = 0x00000001, + SQL_DL_SQL92_TIME = 0x00000002, + SQL_DL_SQL92_TIMESTAMP = 0x00000004, + SQL_DL_SQL92_INTERVAL_YEAR = 0x00000008, + SQL_DL_SQL92_INTERVAL_MONTH = 0x00000010, + SQL_DL_SQL92_INTERVAL_DAY = 0x00000020, + SQL_DL_SQL92_INTERVAL_HOUR = 0x00000040, + SQL_DL_SQL92_INTERVAL_MINUTE = 0x00000080, + SQL_DL_SQL92_INTERVAL_SECOND = 0x00000100, + SQL_DL_SQL92_INTERVAL_YEAR_TO_MONTH = 0x00000200, + SQL_DL_SQL92_INTERVAL_DAY_TO_HOUR = 0x00000400, + SQL_DL_SQL92_INTERVAL_DAY_TO_MINUTE = 0x00000800, + SQL_DL_SQL92_INTERVAL_DAY_TO_SECOND = 0x00001000, + SQL_DL_SQL92_INTERVAL_HOUR_TO_MINUTE = 0x00002000, + SQL_DL_SQL92_INTERVAL_HOUR_TO_SECOND = 0x00004000, + SQL_DL_SQL92_INTERVAL_MINUTE_TO_SECOND = 0x00008000 + ], + SQL_IK = [ + SQL_IK_NONE = 0x00000000, + SQL_IK_ASC = 0x00000001, + SQL_IK_DESC = 0x00000002, + // SQL_IK_ASC | SQL_IK_DESC + SQL_IK_ALL = 0x00000003 + ], + SQL_ISV = [ + SQL_ISV_ASSERTIONS = 0x00000001, + SQL_ISV_CHARACTER_SETS = 0x00000002, + SQL_ISV_CHECK_CONSTRAINTS = 0x00000004, + SQL_ISV_COLLATIONS = 0x00000008, + SQL_ISV_COLUMN_DOMAIN_USAGE = 0x00000010, + SQL_ISV_COLUMN_PRIVILEGES = 0x00000020, + SQL_ISV_COLUMNS = 0x00000040, + SQL_ISV_CONSTRAINT_COLUMN_USAGE = 0x00000080, + SQL_ISV_CONSTRAINT_TABLE_USAGE = 0x00000100, + SQL_ISV_DOMAIN_CONSTRAINTS = 0x00000200, + SQL_ISV_DOMAINS = 0x00000400, + SQL_ISV_KEY_COLUMN_USAGE = 0x00000800, + SQL_ISV_REFERENTIAL_CONSTRAINTS = 0x00001000, + SQL_ISV_SCHEMATA = 0x00002000, + SQL_ISV_SQL_LANGUAGES = 0x00004000, + SQL_ISV_TABLE_CONSTRAINTS = 0x00008000, + SQL_ISV_TABLE_PRIVILEGES = 0x00010000, + SQL_ISV_TABLES = 0x00020000, + SQL_ISV_TRANSLATIONS = 0x00040000, + SQL_ISV_USAGE_PRIVILEGES = 0x00080000, + SQL_ISV_VIEW_COLUMN_USAGE = 0x00100000, + SQL_ISV_VIEW_TABLE_USAGE = 0x00200000, + SQL_ISV_VIEWS = 0x00400000 + ], + SQL_SRJO = [ + // None = 0, + SQL_SRJO_CORRESPONDING_CLAUSE = 0x00000001, + SQL_SRJO_CROSS_JOIN = 0x00000002, + SQL_SRJO_EXCEPT_JOIN = 0x00000004, + SQL_SRJO_FULL_OUTER_JOIN = 0x00000008, + SQL_SRJO_INNER_JOIN = 0x00000010, + SQL_SRJO_INTERSECT_JOIN = 0x00000020, + SQL_SRJO_LEFT_OUTER_JOIN = 0x00000040, + SQL_SRJO_NATURAL_JOIN = 0x00000080, + SQL_SRJO_RIGHT_OUTER_JOIN = 0x00000100, + SQL_SRJO_UNION_JOIN = 0x00000200 + ], + SQL_SRVC = [ + SQL_SRVC_VALUE_EXPRESSION = 0x00000001, + SQL_SRVC_NULL = 0x00000002, + SQL_SRVC_DEFAULT = 0x00000004, + SQL_SRVC_ROW_SUBQUERY = 0x00000008 + ], + // public static readonly int SQL_OV_ODBC3 = 3; + // public const Int32 SQL_NTS = -3; + // flags for null-terminated string + // Pooling + SQL_CP = [ + OFF = 0, + ONE_PER_DRIVER = 1, + ONE_PER_HENV = 2 + ], + /* + public const Int32 SQL_CD_TRUE = 1; + public const Int32 SQL_CD_FALSE = 0; + + public const Int32 SQL_DTC_DONE = 0; + public const Int32 SQL_IS_POINTER = -4; + public const Int32 SQL_IS_PTR = 1; +*/ + SQL_DRIVER = [ + NOPROMPT = 0, + COMPLETE = 1, + PROMPT = 2, + COMPLETE_REQUIRED = 3 + ], + // Column set for SQLPrimaryKeys + SQL_PRIMARYKEYS = [ + /* + // TABLE_CAT + CATALOGNAME = 1, + // TABLE_SCHEM + SCHEMANAME = 2, + // TABLE_NAME + TABLENAME = 3, + */ + // COLUMN_NAME + COLUMNNAME = 4 + /* + // KEY_SEQ + KEY_SEQ = 5, + // PK_NAME + PKNAME = 6, + */ + ], + // Column set for SQLStatistics + SQL_STATISTICS = [ + /* + // TABLE_CAT + CATALOGNAME = 1, + // TABLE_SCHEM + SCHEMANAME = 2, + // TABLE_NAME + TABLENAME = 3, + // NON_UNIQUE + NONUNIQUE = 4, + // INDEX_QUALIFIER + INDEXQUALIFIER = 5, + */ + // INDEX_NAME + INDEXNAME = 6, + /* + // TYPE + TYPE = 7, + */ + // ORDINAL_POSITION + ORDINAL_POSITION = 8, + // COLUMN_NAME + COLUMN_NAME = 9 + /* + // ASC_OR_DESC + ASC_OR_DESC = 10, + // CARDINALITY + CARDINALITY = 11, + // PAGES + PAGES = 12, + // FILTER_CONDITION + FILTER_CONDITION = 13, + */ + ], + // Column set for SQLSpecialColumns + SQL_SPECIALCOLUMNSET = [ + /* + // SCOPE + SCOPE = 1, + */ + // COLUMN_NAME + COLUMN_NAME = 2 + /* + // DATA_TYPE + DATA_TYPE = 3, + // TYPE_NAME + TYPE_NAME = 4, + // COLUMN_SIZE + COLUMN_SIZE = 5, + // BUFFER_LENGTH + BUFFER_LENGTH = 6, + // DECIMAL_DIGITS + DECIMAL_DIGITS = 7, + // PSEUDO_COLUMN + PSEUDO_COLUMN = 8, + */ + ], + SQL_DIAG = [ + CURSOR_ROW_COUNT = -1249, + ROW_NUMBER = -1248, + COLUMN_NUMBER = -1247, + RETURNCODE = 1, + NUMBER = 2, + ROW_COUNT = 3, + SQLSTATE = 4, + NATIVE = 5, + MESSAGE_TEXT = 6, + DYNAMIC_FUNCTION = 7, + CLASS_ORIGIN = 8, + SUBCLASS_ORIGIN = 9, + CONNECTION_NAME = 10, + SERVER_NAME = 11, + DYNAMIC_FUNCTION_CODE = 12 + ], + SQL_SU = [ + SQL_SU_DML_STATEMENTS = 0x00000001, + SQL_SU_PROCEDURE_INVOCATION = 0x00000002, + SQL_SU_TABLE_DEFINITION = 0x00000004, + SQL_SU_INDEX_DEFINITION = 0x00000008, + SQL_SU_PRIVILEGE_DEFINITION = 0x00000010 + ] +] diff --git a/connector/README.md b/connector/README.md new file mode 100644 index 0000000..f5c5e48 --- /dev/null +++ b/connector/README.md @@ -0,0 +1,51 @@ +# Stackable Trino, Power Query Custom Connector + +A Power Query custom connector (`.mez`) that lets Power BI Desktop query Trino +through the Stackable ODBC driver, DirectQuery included. + +## What it does + +Power BI can already reach any ODBC driver through its generic ODBC source. +This connector gives Trino its own entry in the **Get Data** dialog and tells +Power BI how to generate SQL that Trino accepts: + +- **LIMIT/OFFSET** instead of TOP N +- **CAST** instead of CONVERT +- **Double-quote identifiers** instead of square brackets +- Reports full SQL-92 conformance, so Power BI folds filters, sorts, + aggregations and joins into the query it sends rather than computing them + locally + +## Prerequisites + +- Power BI Desktop, which is Windows only +- The `stackable_odbc_trino` ODBC driver registered on the same machine. See + [`packaging/README.md`](../packaging/README.md#windows-x86_64). + +## Installing + +The `.mez` ships in the Windows release archive and is also published on its +own. For the install steps see +[`packaging/README.md`](../packaging/README.md#power-bi-custom-connector-windows-only). + +## Building + +On Linux, or anywhere with `zip`: + +```bash +./connector/build.sh +``` + +Output: `connector/bin/StackableTrinoODBC.mez` + +On Windows with the Power Query SDK (a VS Code extension): + +1. Open the `connector/` folder in VS Code +2. Install the "Power Query SDK" extension +3. Ctrl+Shift+B → "MakePQX" + +## Developing + +[`DEVELOPING.md`](DEVELOPING.md) covers the project layout, the connector's +configuration settings, regenerating the icons, and how to verify query +folding. diff --git a/connector/StackableTrinoODBC.pq b/connector/StackableTrinoODBC.pq new file mode 100644 index 0000000..f02377f --- /dev/null +++ b/connector/StackableTrinoODBC.pq @@ -0,0 +1,590 @@ +// Power Query custom connector for Trino via the Stackable ODBC driver. +// +// Enables DirectQuery in PowerBI Desktop against a Trino cluster. Overrides +// SQL generation defaults so PowerBI emits Trino-compatible SQL: LIMIT/OFFSET +// instead of TOP N, double-quote identifiers instead of square brackets, and +// CAST instead of CONVERT. +// +[Version = "0.0.1"] +section StackableTrinoODBC; + +// --------------------------------------------------------------------------- +// Configuration +// --------------------------------------------------------------------------- + +// The ODBC driver name as registered in odbcinst.ini / the Windows registry. +Config_DriverName = "stackable_odbc_trino"; + +// Connection-string keys reachable through the `options` record. +// +// Every name is a key that src/backend/types/connect_params.rs accepts; +// `connector_options_are_connection_string_keys` in src/lib.rs fails the build +// if one is not, because an option the driver does not recognise is discarded +// at connect and reads as the setting having no effect. +// +// The four keys the driver declares sensitive through +// `Backend::sensitive_connect_keywords` are absent. An option set here is +// stored in the query text inside the .pbix, which is a file people mail to +// each other. AccessToken, ExtraCredentials, ExtraHeaders and ProxyPassword +// therefore stay on the connection string or the data source, where a +// credential store can hold them. +Config_AdvancedOptions = { + "Source", "ClientTags", "Path", "TimeZone", "Locale", "ClientInfo", + "TraceToken", "SessionUser", "Roles", + "TlsVerify", "Certificate", "ClientCertificate", + "SessionProperties", "ResourceEstimates", "ClientCapabilities", "Encoding", + "Proxy", "ProxyUser", + "QueryTimeout", "DisableCompression", "MaxAttempts", + "ExternalAuthentication", "ExternalAuthenticationTimeout" +}; + +// SQL conformance override. +// +// This reports SQL_SC_SQL92_FULL where the driver's own SQLGetInfo answers 0, +// and both are right for their own audience. To an ODBC application the info +// type is a conformance claim, and Trino does not reach even Entry level: its +// CREATE TABLE rejects PRIMARY KEY, UNIQUE, CHECK and REFERENCES, each with a +// SYNTAX_ERROR, and that one requirement rules the level out. Entry level's +// other demand, COMMIT and ROLLBACK, the driver does meet: it reports +// SQL_TC_DML, so the constraint grammar is the whole of the argument. To +// Power Query it is the knob that unlocks SQL generation, and Microsoft's own +// guidance is to turn it up and narrow the specifics elsewhere: "In Power +// Query scenarios, the connector is used in a Read Only mode. Most drivers +// will want to report a SQL_SC_SQL92_FULL compliance level, and override +// specific SQL generation behavior using the SQLGetInfo and SQLGetFunctions +// properties." +// +// SupportsDerivedTable also keys off this: it defaults to false for any +// conformance level below SQL_SC_SQL92_FULL, and derived tables are required +// for many DirectQuery scenarios. It is set explicitly below regardless. +// +// https://learn.microsoft.com/en-us/power-query/odbc-parameters +Config_SqlConformance = ODBC[SQL_SC][SQL_SC_SQL92_FULL]; // 8 + +// Trino uses LIMIT x OFFSET y syntax. +Config_LimitClauseKind = LimitClauseKind.LimitOffset; + +// Let Power Query bind parameters rather than inlining literals. +// +// The driver honours the declared `ParameterType`, so a numeric delivered as +// characters reaches Trino as a decimal instead of a string, and an unbound +// marker is reported rather than silently substituted. +// +// Setting this to `false` adds `SQL_API_SQLBINDPARAMETER = false`, which +// contradicts the driver's own `get_functions` declaration of `BindParameter`. +// An override in this file is for what the driver gets *wrong*. It also routes +// every constant through the `Constant` visitor instead. +Config_UseParameterBindings = true; + +// Trino uses single-quote escaping for string literals. +Config_StringLiteralEscapeCharacters = { "'" }; + +// Trino uses CAST, not CONVERT. +Config_UseCastInsteadOfConvert = true; + +// Enable DirectQuery mode. +Config_EnableDirectQuery = true; + +// --------------------------------------------------------------------------- +// Data source function +// --------------------------------------------------------------------------- + +// The published function is the implementation re-typed, so the Get Data +// dialog renders a caption per parameter and an "Advanced options" section +// rather than asking for a bare record. +[DataSource.Kind = "StackableTrinoODBC", Publish = "StackableTrinoODBC.Publish"] +shared StackableTrinoODBC.Contents = + Value.ReplaceType(StackableTrinoODBCImpl, StackableTrinoODBC.ContentsType); + +StackableTrinoODBC.ContentsType = type function ( + server as (type text meta [ + Documentation.FieldCaption = "Server", + Documentation.SampleValues = {"trino.example.com"} + ]), + port as (type number meta [ + Documentation.FieldCaption = "Port", + Documentation.SampleValues = {8443} + ]), + catalog as (type text meta [ + Documentation.FieldCaption = "Catalog", + Documentation.SampleValues = {"hive"} + ]), + optional schema as (type text meta [ + Documentation.FieldCaption = "Schema" + ]), + optional user as (type text meta [ + Documentation.FieldCaption = "User", + Documentation.FieldDescription = + "Ignored when you sign in with a username and password, which supplies its own. " + & "Leave it empty under external authentication, where the identity provider " + & "decides the identity and a name given here is read as an impersonation request. " + & "Required otherwise." + ]), + optional protocol as (type text meta [ + Documentation.FieldCaption = "Protocol", + Documentation.AllowedValues = {"https", "http"} + ]), + optional options as (StackableTrinoODBC.OptionsType meta [ + Documentation.FieldCaption = "Advanced options" + ]) +) as table meta [ + Documentation.Name = "Stackable Trino" +]; + +// Keep in step with Config_AdvancedOptions. That list is what the connection +// string is built from and what the build checks against the driver's parser; +// this type is only what the dialog renders. +StackableTrinoODBC.OptionsType = type [ + optional Source = (type text meta [Documentation.FieldCaption = "Source"]), + optional ClientTags = (type text meta [Documentation.FieldCaption = "Client tags"]), + optional Path = (type text meta [Documentation.FieldCaption = "SQL path"]), + optional TimeZone = (type text meta [ + Documentation.FieldCaption = "Time zone", + Documentation.SampleValues = {"Europe/Berlin"} + ]), + optional Locale = (type text meta [Documentation.FieldCaption = "Locale"]), + optional ClientInfo = (type text meta [Documentation.FieldCaption = "Client info"]), + optional TraceToken = (type text meta [Documentation.FieldCaption = "Trace token"]), + optional SessionUser = (type text meta [Documentation.FieldCaption = "Session user"]), + // The role name is written bare. connect_params.rs renders Trino's own + // `ROLE{name}` wire spelling itself, so a sample carrying it would be + // double-wrapped into ROLE{ROLE{admin}}, and the braces would collide with + // connection-string syntax besides. `ALL` and `NONE` are the two keywords. + optional Roles = (type text meta [ + Documentation.FieldCaption = "Roles", + Documentation.SampleValues = {"hive:admin"} + ]), + optional TlsVerify = (type text meta [ + Documentation.FieldCaption = "TLS verification", + Documentation.AllowedValues = {"full", "ca", "none"} + ]), + optional Certificate = (type text meta [Documentation.FieldCaption = "CA certificate"]), + optional ClientCertificate = (type text meta [ + Documentation.FieldCaption = "Client certificate" + ]), + // Bare, without the {braces} a hand-written connection string needs around + // a `;`-separated value. This is the third convention for one format and + // the reason is the same each time: braces are connection-string escaping, + // and here the connection string is assembled from a record by + // `Odbc.DataSource`, which escapes what it serialises. The same holds for + // ResourceEstimates and ClientCapabilities below. README.md's "Values that + // contain a semicolon" covers the two hand-written cases. + optional SessionProperties = (type text meta [ + Documentation.FieldCaption = "Session properties", + Documentation.SampleValues = {"query_max_run_time:10m;example.foo:bar"} + ]), + optional ResourceEstimates = (type text meta [ + Documentation.FieldCaption = "Resource estimates" + ]), + optional ClientCapabilities = (type text meta [ + Documentation.FieldCaption = "Client capabilities" + ]), + optional Encoding = (type text meta [ + Documentation.FieldCaption = "Spooling encoding", + Documentation.AllowedValues = {"json", "json+zstd", "json+lz4"} + ]), + optional Proxy = (type text meta [Documentation.FieldCaption = "Proxy URL"]), + optional ProxyUser = (type text meta [Documentation.FieldCaption = "Proxy user"]), + optional QueryTimeout = (type number meta [ + Documentation.FieldCaption = "Query timeout (s)" + ]), + optional DisableCompression = (type logical meta [ + Documentation.FieldCaption = "Disable compression" + ]), + optional MaxAttempts = (type number meta [Documentation.FieldCaption = "Max attempts"]), + optional ExternalAuthentication = (type logical meta [ + Documentation.FieldCaption = "External authentication" + ]), + optional ExternalAuthenticationTimeout = (type number meta [ + Documentation.FieldCaption = "External auth timeout (s)" + ]) +]; + +StackableTrinoODBCImpl = ( + server as text, + port as number, + catalog as text, + optional schema as text, + optional user as text, + optional protocol as text, + optional options as record +) as table => + let + // Matches the driver's own default. An unencrypted connection should + // be something the user chose, not what they got by leaving the + // optional protocol argument out; a plaintext coordinator takes + // protocol = "http". + effectiveProtocol = if protocol <> null then protocol else "https", + + ConnectionString = [ + Driver = Config_DriverName, + Host = server, + Port = Number.ToText(port), + Catalog = catalog, + Protocol = effectiveProtocol + ], + + // Schema is optional in the connection string. + ConnectionStringWithSchema = + if schema <> null then + ConnectionString & [Schema = schema] + else + ConnectionString, + + // An unrecognised option is an error rather than a value quietly + // dropped: a misspelled key would otherwise change nothing, and a + // report whose TimeZone silently did not apply still renders. + suppliedOptions = if options <> null then options else [], + unknownOptions = + List.Difference(Record.FieldNames(suppliedOptions), Config_AdvancedOptions), + checkedOptions = + if List.Count(unknownOptions) > 0 then + error "Unknown option: " & Text.Combine(unknownOptions, ", ") + else + suppliedOptions, + + // The dialog supplies every declared field, null for the ones left + // blank, so a blank must be removed rather than stringified: + // Text.From(null) is null, not "", and Odbc.DataSource refuses a + // null-valued connection-string property with "The value for property + // 'X' was of type Null, but we expected Number or Text". + presentOptions = Record.SelectFields( + checkedOptions, + List.Select( + Record.FieldNames(checkedOptions), + each Record.Field(checkedOptions, _) <> null + ) + ), + + // A connection string carries text. Numbers and logicals reach the + // driver as "30" and "TRUE", both of which its parser accepts. + optionsAsText = Record.FromList( + List.Transform( + Record.FieldValues(presentOptions), + each if _ is text then _ else Text.From(_) + ), + Record.FieldNames(presentOptions) + ), + + ConnectionStringWithOptions = ConnectionStringWithSchema & optionsAsText, + + // Credentials: PowerBI supplies username/password via the + // credential system. Map them to the ODBC connection string. + // + // Under any other credential kind the `User` argument is passed + // through, and an absent one is left absent rather than given a + // stand-in name. That is not tidiness: under ExternalAuthentication + // the identity provider decides who the session runs as, and Trino + // reads a `User` that disagrees with the token's identity as an + // impersonation request and refuses the connection. Inventing a name + // here would make the connector's own "External authentication" + // advanced option fail every time. connect_params.rs's + // PARAM_EXTERNAL_AUTHENTICATION carries the same reasoning for the + // driver side. + Credential = Extension.CurrentCredential(), + CredentialConnectionString = + if Credential[AuthenticationKind]? = "UsernamePassword" then + [User = Credential[Username], Password = Credential[Password]] + else if user <> null then + [User = user] + else + [], + + // Build ODBC options with Trino-specific overrides. + defaultConfig = BuildOdbcConfig(), + + SqlCapabilities = defaultConfig[SqlCapabilities] & [ + FractionalSecondsScale = 3, + // GroupByCapabilities has no entry here, because it is not a + // documented SqlCapabilities field. The GROUP BY relationship + // belongs in the SQLGetInfo record instead, under SQL_GROUP_BY, + // which the driver answers with SQL_GB_GROUP_BY_CONTAINS_SELECT. + // That value is also the accurate one: Trino rejects a + // non-aggregated select column absent from GROUP BY with + // EXPRESSION_NOT_AGGREGATE, so it does not support GROUP BY + // without restrictions. + SupportsDerivedTable = true, + SupportsTop = false + ], + + // Nothing is overridden. An override here silently wins over + // SQLGetInfoW and cannot be corrected by fixing the driver, so the + // record is reserved for what the driver gets wrong, and this group it + // answers honestly: SQL_SQL92_PREDICATES, SQL_AGGREGATE_FUNCTIONS, + // SQL_SQL92_RELATIONAL_JOIN_OPERATORS, SQL_SQL92_VALUE_EXPRESSIONS and + // SQL_IDENTIFIER_QUOTE_CHAR. + // + // Two of them must never be overridden with a flat value. The driver + // gates SQL_SQL92_PREDICATES and SQL_SQL92_RELATIONAL_JOIN_OPERATORS on + // the coordinator's version, because MATCH and UNIQUE arrived in Trino + // 482, OVERLAPS in 483 and CORRESPONDING in 475. A flat 0x3FFF or 0x3FF + // asserts all of them on every server, and 0x3FF also claims NATURAL + // JOIN, which a live 467 rejects with "NOT_SUPPORTED: Natural join not + // supported", and UNION JOIN, which has no production in Trino's + // grammar at any version. Folding any of those produces SQL the + // coordinator refuses. + // + // Nothing Power Query generates is lost: comparison, IN, + // LIKE, BETWEEN, IS NULL, EXISTS and the four join types are all in + // the driver's ungated set. + SQLGetInfo = defaultConfig[SQLGetInfo], + + SQLColumns = (catalogName, schemaName, tableName, columnName, source) => + source, + + // The AstVisitor tells Power Query how to emit row limiting; without + // it, folding fails even though LimitClauseKind.LimitOffset is set in + // SqlCapabilities. + // + // Trino's grammar is `OFFSET count LIMIT count`, in that order. + // `LIMIT 2 OFFSET 2` is rejected outright with "mismatched input + // 'OFFSET'". Only a fold carrying both a skip and a take emits the + // pair, so take-only folding never exposes the ordering. + // `test_folding_contract.py` runs whatever this builds. + AstVisitor = [ + LimitClause = (skip, take) => + let + offset = if skip <> null and skip > 0 then Text.Format("OFFSET #{0}", {skip}) else "", + separator = if offset <> "" and take <> null then " " else "", + limit = if take <> null then Text.Format("LIMIT #{0}", {take}) else "" + in + [ + Text = offset & separator & limit, + Location = "AfterQuerySpecification" + ], + // Tell Power Query how to emit CAST expressions for typed + // literals. Required for aggregation folding on non-SQL-Server + // databases (from the PostgreSQL DirectQuery reference connector). + // + // Each record field is looked up by `typeInfo[TYPE_NAME]`, which is + // this driver's own SQLGetTypeInfo output, so the field names are + // Trino type names. A PostgreSQL name such as `TEXT`, `TIMESTAMPTZ`, + // `TIMETZ`, `NUMERIC` or `FLOAT` matches nothing here and would fold + // nothing. `test_folding_contract.py` asserts every field name + // against SQLGetTypeInfo and every cast target against Trino. + Constant = + let + Quote = each Text.Format("'#{0}'", { _ }), + Cast = (value, typeName) => [ + Text = Text.Format("CAST(#{0} as #{1})", { value, typeName }) + ], + // An entry is added only where the cast is unambiguous. A + // wrong one is worse than an absent one: absent, Power + // Query evaluates the constant locally and the answer is + // still right, only unfolded. Microsoft documents this + // override as deprecated ("Providing an override for the + // Constant value within AstVisitor has been deprecated and + // may be removed in future implementations"), so the list + // is kept to what pays for itself. + // + // Three groups have no entry. Each is measured against a + // live Trino 483 coordinator: + // + // CHAR `CAST('abc' AS CHAR)` is char(1), so it + // truncates to 'a' and does not equal + // `CAST('abc' AS CHAR(3))`. An entry would fold + // an equality filter into one matching no rows. + // Trino cannot spell the width here, and + // `varchar = char(n)` already compares true. + // INTERVAL DAY TO SECOND, INTERVAL YEAR TO MONTH + // Trino cannot cast varchar to either: "Cannot + // cast varchar(10) to interval day to second". + // There is no CAST target to name. + // VARBINARY The constant is an M binary value, and + // rendering it as Trino's `X'..'` literal + // cannot be verified without Power BI Desktop. + // + // TODO: add UUID, JSON, TIME WITH TIME ZONE and TIMESTAMP + // WITH TIME ZONE. Each has a valid Trino cast, but the + // rendering turns on whether Power Query hands a + // text-valued constant to the visitor already quoted, which + // the VARCHAR entry below also rests on. Confirm that in + // Power BI Desktop, then add all four. + Visitor = [ + DECIMAL = each Cast(_, "DECIMAL"), + INTEGER = each Cast(_, "INTEGER"), + BIGINT = each Cast(_, "BIGINT"), + SMALLINT = each Cast(_, "SMALLINT"), + TINYINT = each Cast(_, "TINYINT"), + REAL = each Cast(_, "REAL"), + DOUBLE = each Cast(_, "DOUBLE PRECISION"), + BOOLEAN = each Cast(_, "BOOLEAN"), + DATE = each Cast(Quote(Date.ToText(_, "yyyy-MM-dd")), "DATE"), + VARCHAR = each Cast(_, "VARCHAR"), + // `fffffff`, not `sssssss`: in a custom format string + // `s` is the second and `f` is the fractional second, + // so the latter spelling renders the second eight times + // over instead of a fraction. Seven digits because an M + // datetime resolves to 100ns and Trino reads the width + // as the literal's precision; lower case so trailing + // zeros are kept and the width stays fixed, which `F` + // would not do. Trino coerces a wider literal to the + // column's own precision, so an equality filter on a + // `timestamp(3)` still matches. + TIMESTAMP = each Cast(Quote(DateTime.ToText(_, "yyyy-MM-dd HH:mm:ss.fffffff")), "TIMESTAMP"), + TIME = each Cast(Quote(Time.ToText(_, "HH:mm:ss.fffffff")), "TIME") + ] + in + (typeInfo, ast) => Record.FieldOrDefault(Visitor, typeInfo[TYPE_NAME], each null)(ast[Value]) + ], + + // Pass through SQLGetTypeInfo from the driver unchanged. + SQLGetTypeInfo = (types) => types, + + OdbcDatasource = Odbc.DataSource( + ConnectionStringWithOptions, + [ + HierarchicalNavigation = true, + SoftNumbers = true, + TolerateConcatOverflow = true, + ClientConnectionPooling = true, + CredentialConnectionString = CredentialConnectionString, + SqlCapabilities = SqlCapabilities, + SQLGetInfo = SQLGetInfo, + SQLColumns = SQLColumns, + SQLGetTypeInfo = SQLGetTypeInfo, + AstVisitor = AstVisitor + ] + ) + in + OdbcDatasource; + +// --------------------------------------------------------------------------- +// Data source kind +// --------------------------------------------------------------------------- + +StackableTrinoODBC = [ + TestConnection = (dataSourcePath) => + let + json = Json.Document(dataSourcePath), + server = json[server], + port = json[port], + catalog = json[catalog] + in + {"StackableTrinoODBC.Contents", server, port, catalog}, + Authentication = [ + UsernamePassword = [], + Implicit = [] + ], + Label = Extension.LoadString("DataSourceLabel") +]; + +// --------------------------------------------------------------------------- +// Publishing metadata +// --------------------------------------------------------------------------- + +StackableTrinoODBC.Publish = [ + Category = "Database", + ButtonText = {Extension.LoadString("ButtonTitle"), Extension.LoadString("ButtonHelp")}, + LearnMoreUrl = "https://trino.io/", + SupportsDirectQuery = Config_EnableDirectQuery, + SourceImage = StackableTrinoODBC.Icons, + SourceTypeImage = StackableTrinoODBC.Icons +]; + +StackableTrinoODBC.Icons = [ + Icon16 = { + Extension.Contents("StackableTrinoODBC16.png"), + Extension.Contents("StackableTrinoODBC20.png"), + Extension.Contents("StackableTrinoODBC24.png"), + Extension.Contents("StackableTrinoODBC32.png") + }, + Icon32 = { + Extension.Contents("StackableTrinoODBC32.png"), + Extension.Contents("StackableTrinoODBC40.png"), + Extension.Contents("StackableTrinoODBC48.png"), + Extension.Contents("StackableTrinoODBC64.png") + } +]; + +// --------------------------------------------------------------------------- +// ODBC configuration builder +// --------------------------------------------------------------------------- + +BuildOdbcConfig = () as record => + let + Merge = (previous as record, optional caps as record, optional funcs as record, optional getInfo as record) as record => + let + newCaps = if (caps <> null) then previous[SqlCapabilities] & caps else previous[SqlCapabilities], + newFuncs = if (funcs <> null) then previous[SQLGetFunctions] & funcs else previous[SQLGetFunctions], + newGetInfo = if (getInfo <> null) then previous[SQLGetInfo] & getInfo else previous[SQLGetInfo] + in + [SqlCapabilities = newCaps, SQLGetFunctions = newFuncs, SQLGetInfo = newGetInfo], + + defaultConfig = [ + SqlCapabilities = [], + SQLGetFunctions = [], + SQLGetInfo = [] + ], + + // Parameter bindings disabled, so inline literals instead. + withParams = + if (Config_UseParameterBindings = false) then + let + caps = [ + SupportsNumericLiterals = true, + SupportsStringLiterals = true, + SupportsOdbcDateLiterals = true, + SupportsOdbcTimeLiterals = true, + SupportsOdbcTimestampLiterals = true + ], + funcs = [ + SQL_API_SQLBINDPARAMETER = false + ] + in + Merge(defaultConfig, caps, funcs) + else + defaultConfig, + + // String literal escaping. + withEscape = + if (Config_StringLiteralEscapeCharacters <> null) then + Merge(withParams, [StringLiteralEscapeCharacters = Config_StringLiteralEscapeCharacters]) + else + withParams, + + // LIMIT/OFFSET clause style. + withLimitClauseKind = + Merge(withEscape, [LimitClauseKind = Config_LimitClauseKind]), + + // CAST instead of CONVERT. + // + // Kept even though the driver already reports SQL_FN_CVT_CAST and no + // CONVERT, because the M engine's documented default is to attempt + // CONVERT and this is the documented way to redirect it. The override + // agrees with the driver rather than contradicting it. + withCastOrConvert = + if (Config_UseCastInsteadOfConvert <> null) then + let + value = + if (Config_UseCastInsteadOfConvert = true) then + ODBC[SQL_FN_CVT][SQL_FN_CVT_CAST] + else + ODBC[SQL_FN_CVT][SQL_FN_CVT_CONVERT], + getInfo = [SQL_CONVERT_FUNCTIONS = value] + in + Merge(withLimitClauseKind, null, null, getInfo) + else + withLimitClauseKind, + + // SQL conformance override. + withSqlConformance = + if (Config_SqlConformance <> null) then + Merge(withCastOrConvert, null, null, [SQL_SQL_CONFORMANCE = Config_SqlConformance]) + else + withCastOrConvert + in + withSqlConformance; + +// --------------------------------------------------------------------------- +// Helper modules (loaded from .pqm files bundled in the .mez) +// --------------------------------------------------------------------------- + +Extension.LoadFunction = (name as text) => + let + binary = Extension.Contents(name), + asText = Text.FromBinary(binary) + in + Expression.Evaluate(asText, #shared); + +ODBC = Extension.LoadFunction("OdbcConstants.pqm"); diff --git a/connector/StackableTrinoODBC.query.pq b/connector/StackableTrinoODBC.query.pq new file mode 100644 index 0000000..29087c4 --- /dev/null +++ b/connector/StackableTrinoODBC.query.pq @@ -0,0 +1,29 @@ +// Test query for the StackableTrinoODBC connector. +// Evaluate this file in the Power Query SDK (VS Code) to test the connector. +// +// Targets the repository's own stack, which `./integration-tests/setup.sh` +// starts. That coordinator serves HTTPS on 8443 and nothing else, and its +// certificate is signed by the CA `scripts/gen-certs.sh` generates, which no +// machine trusts, hence TlsVerify. Point elsewhere by editing the arguments; a +// plaintext coordinator takes 8080 and "http". +// +// `protocol` is passed rather than left out: omitting it selects "https", the +// same default the driver applies. +// +// The password is not here. Set a UsernamePassword credential in the SDK and +// it supplies both the user and the password, overriding the "admin" below, +// which is only read under anonymous authentication. The stack's credentials +// are admin/admin. +// +// Navigation is two steps because the connector sets +// HierarchicalNavigation = true: schema, then table. The Schema argument sets +// the session default for unqualified names and does not collapse that. +let + result = StackableTrinoODBC.Contents( + "localhost", 8443, "tpcds", "sf1", "admin", "https", + [TlsVerify = "false"] + ), + schema = result{[Name = "sf1"]}[Data], + customer = schema{[Name = "customer"]}[Data] +in + Table.FirstN(customer, 5) diff --git a/connector/StackableTrinoODBC16.png b/connector/StackableTrinoODBC16.png new file mode 100644 index 0000000..774fb31 Binary files /dev/null and b/connector/StackableTrinoODBC16.png differ diff --git a/connector/StackableTrinoODBC20.png b/connector/StackableTrinoODBC20.png new file mode 100644 index 0000000..13044cf Binary files /dev/null and b/connector/StackableTrinoODBC20.png differ diff --git a/connector/StackableTrinoODBC24.png b/connector/StackableTrinoODBC24.png new file mode 100644 index 0000000..867f275 Binary files /dev/null and b/connector/StackableTrinoODBC24.png differ diff --git a/connector/StackableTrinoODBC32.png b/connector/StackableTrinoODBC32.png new file mode 100644 index 0000000..0956f18 Binary files /dev/null and b/connector/StackableTrinoODBC32.png differ diff --git a/connector/StackableTrinoODBC40.png b/connector/StackableTrinoODBC40.png new file mode 100644 index 0000000..36b452f Binary files /dev/null and b/connector/StackableTrinoODBC40.png differ diff --git a/connector/StackableTrinoODBC48.png b/connector/StackableTrinoODBC48.png new file mode 100644 index 0000000..e16bb3d Binary files /dev/null and b/connector/StackableTrinoODBC48.png differ diff --git a/connector/StackableTrinoODBC64.png b/connector/StackableTrinoODBC64.png new file mode 100644 index 0000000..b29cb39 Binary files /dev/null and b/connector/StackableTrinoODBC64.png differ diff --git a/connector/build.sh b/connector/build.sh new file mode 100755 index 0000000..7caa774 --- /dev/null +++ b/connector/build.sh @@ -0,0 +1,28 @@ +#!/usr/bin/env bash +set -euo pipefail + +# Build the StackableTrinoODBC.mez Power Query custom connector. +# A .mez is just a ZIP archive containing the .pq, .pqm, .resx, and .png files. + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +OUT_DIR="$SCRIPT_DIR/bin" +MEZ_FILE="$OUT_DIR/StackableTrinoODBC.mez" + +mkdir -p "$OUT_DIR" +rm -f "$MEZ_FILE" + +cd "$SCRIPT_DIR" +zip -j "$MEZ_FILE" \ + StackableTrinoODBC.pq \ + Diagnostics.pqm \ + OdbcConstants.pqm \ + resources.resx \ + StackableTrinoODBC*.png + +echo "Built: $MEZ_FILE ($(du -h "$MEZ_FILE" | cut -f1))" +echo "" +echo "To install in Power BI Desktop:" +echo " 1. Copy $MEZ_FILE to: %USERPROFILE%\\Documents\\Power BI Desktop\\Custom Connectors\\" +echo " 2. Enable custom connectors: File > Options > Security > Allow any extension" +echo " 3. Restart Power BI Desktop" +echo " 4. Get Data > More > Database > Stackable Trino" diff --git a/connector/resources.resx b/connector/resources.resx new file mode 100644 index 0000000..604cc67 --- /dev/null +++ b/connector/resources.resx @@ -0,0 +1,52 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + Stackable Trino + + + Connect to a Trino cluster via the Stackable ODBC driver + + + Stackable Trino + + diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..64f1748 --- /dev/null +++ b/deny.toml @@ -0,0 +1,53 @@ +# Cargo deny configuration for stackable-odbc-trino +# Based on operator-rs conventions. +# Run: cargo deny check + +[graph] +targets = [ + { triple = "x86_64-unknown-linux-gnu" }, + { triple = "aarch64-unknown-linux-gnu" }, + { triple = "x86_64-pc-windows-gnu" }, +] + +[advisories] +yanked = "deny" +ignore = [ + # https://rustsec.org/advisories/RUSTSEC-2024-0436 + # The "paste" crate is no longer maintained. Transitive dependency via + # trino-rust-client. No direct usage in our code, minimal impact. + # Same ignore as operator-rs. + "RUSTSEC-2024-0436", +] + +[bans] +multiple-versions = "allow" + +[licenses] +unused-allowed-license = "allow" +confidence-threshold = 1.0 +allow = [ + "Apache-2.0", + "BSD-2-Clause", + "BSD-3-Clause", + "CC0-1.0", + "ISC", + "MIT", + "MPL-2.0", + "Unicode-3.0", + "Unicode-DFS-2016", + "Zlib", + "Unlicense", +] +private = { ignore = true } + +[sources] +unknown-registry = "deny" +unknown-git = "deny" +# Two Stackable repositories are allowed, each with a TODO on its dependency in +# Cargo.toml: the Trino client is taken from Stackable's fork until its changes +# are released upstream, and stackable-odbc-core is taken from git until it is +# published to crates.io. Any other git dependency still fails the gate. +allow-git = [ + "https://github.com/stackabletech/stackable-odbc-core.git", + "https://github.com/stackabletech/trino-rust-client.git", +] diff --git a/integration-tests/.gitignore b/integration-tests/.gitignore new file mode 100644 index 0000000..8b84a8c --- /dev/null +++ b/integration-tests/.gitignore @@ -0,0 +1,2 @@ +generated/ +__pycache__/ diff --git a/integration-tests/README.md b/integration-tests/README.md new file mode 100644 index 0000000..d117b1f --- /dev/null +++ b/integration-tests/README.md @@ -0,0 +1,141 @@ +# Integration tests + +Everything needed to run the Trino ODBC driver against a real coordinator. +This file is the runbook. Why the stack is shaped the way it is (the `hive` +catalog, the spooling suite, what SNI rules out) is in +[AGENTS.md](../AGENTS.md#testing). + +```bash +./integration-tests/setup.sh # start the stack, generate config, build the driver +./integration-tests/run-tests.sh # run the suites, then tear the stack down +./integration-tests/scripts/teardown.sh +``` + +`run-tests.sh` calls `setup.sh` itself if the stack has not been set up. + +The coordinator serves HTTPS on 8443 and nothing else, so every connection +here is TLS. + +`Protocol=http` therefore has no coverage in this stack, and that is a +decision rather than an oversight: plaintext HTTP is not a configuration +anyone deploys, and a listener for it would cost a config fragment and a +profile for a mode with no users. + +## Layout + +| Directory | Holds | +|---|---| +| `scripts/` | All the bash. `lib.sh` is sourced by the rest and owns the paths, the profile parsing and the readiness helpers. | +| `stack/` | All the docker material: `compose.yaml`, the Trino config fragments, the Postgres init SQL. | +| `suites/` | All the Python. `harness.py` is shared; every `test_*.py` is a suite; `registry.py` is the list of them, read by both runners. | +| `perf/` | Profiling and stress tooling. `profile_stress.sh` runs `test_stress.py`'s BI-shaped queries with the driver's profiling output on, and `parse_profile.py` renders the log it writes as a per-query table. | +| `windows/` | The Windows VM harness and its libvirt definitions. See [WINDOWS.md](windows/WINDOWS.md). | +| `generated/` | Every produced artefact: certificates, secrets, the assembled Trino config, the ODBC ini files, `stack.env`. Gitignored, and safe to delete. | + +## Profiles + +The unprofiled set is the core stack. Everything heavier is opt-in. + +| Profile | Services added | Buys | +|---|---|---| +| *(none)* | `postgres`, `trino` | The `tpcds`, `postgresql` and `hive` catalogs, HTTPS, password and client-certificate auth, transactions, a non-empty `SQLTablePrivileges` | +| `oauth` | `keycloak` | The OAuth 2.0 flow, through `suites/test_oauth.py` | +| `spooling` | `minio`, `minio-init` | The spooling protocol end to end, through `suites/test_spooling.py` | + +```bash +./integration-tests/setup.sh --profile oauth +./integration-tests/setup.sh --profile oauth,spooling +PROFILES=all ./integration-tests/setup.sh +``` + +Changing profiles recreates the coordinator, because its configuration is +assembled per profile rather than mounted from the checkout. A suite whose +profile is not active is skipped and says which profile would enable it. + +Two suites need no profile and assert something in either stack state: +`test_spooling.py` drives the spooled protocol when `spooling` is active and +the inline fallback when it is not. `test_transactions.py` writes to the +`hive` catalog, which is +[part of the core stack](../AGENTS.md#the-hive-catalog-and-why-it-is-not-behind-a-profile). + +## Flags + +| Flag | Script | Effect | +|---|---|---| +| `--profile ` | `setup.sh` | Comma or space separated, or `all`. `--profile=` and the `PROFILES` environment variable do the same | +| `--suite ` | `run-tests.sh` | Run only the suites whose name contains the substring | +| `--skip-build` | `run-tests.sh` | Skip the cargo build | +| `--skip-delete` | `run-tests.sh` | Leave the stack running afterwards | +| `--windows` | `run-tests.sh` | Also run the suites on the Windows VM | + +`setup.sh` rejects any argument it does not recognise. `run-tests.sh` forwards +the ones it does not recognise to `windows/windows_test.py`, so that script's +flags can be passed straight through. `--suite` is forwarded too, so a filtered +run filters both platforms rather than silently meaning "on Linux only". + +## The suite registry + +`suites/registry.py` is the one list of suites. `scripts/run-tests.sh` reads it +through `registry.py --bash`, and `windows/windows_test.py` imports it. Adding a +suite means adding an entry there, and nothing else. + +Each entry states the suite's script, the profile it needs, how it takes its +configuration, whether it needs `pyodbc`, and whether it runs on Windows. A +suite that does not run on Windows carries the reason in its own entry, and the +runner prints it as a `SKIP`, so an unrun suite is never printable as a passing +one. `test_harness.py` checks what can be checked without a stack: that every +script and deployed file exists, that names are unique, and that a suite excluded +from Windows gives a reason. + +What the registry deliberately does not hold is the command. The two runners +invoke Python differently, and the four-configuration connect matrix is not the +same on both: Linux crosses DSN names out of the generated `odbc.ini`, while +Windows crosses a registry-registered DSN and connects by address for the +unverified cases so that no SNI is sent. Those are two matrices that happen to +share four labels. + +## Certificates + +`scripts/gen-certs.sh` builds one CA into `generated/certs/` and signs the +coordinator, client and Keycloak leaves from it. + +Jetty picks its certificate from the SNI the client sends, so a connection +that verifies has to use a name the coordinator's certificate carries. An IP +address sends no SNI and gets Trino's internal self-signed certificate +instead. [AGENTS.md](../AGENTS.md#certificates) has the measured table, +`suites/test_tls.py` records what it rules out, and `windows/windows_test.py` +works around it with a hosts entry in the VM. + +## Interactive use + +```bash +export ODBCSYSINI=$(pwd)/integration-tests/generated +export ODBCINI=$(pwd)/integration-tests/generated/odbc.ini +isql -3 trino_https -v +``` + +DSNs: `trino_https`, `trino_https_verify_false`, `trino_postgresql`, +`trino_oauth`. + +```bash +docker compose -f integration-tests/stack/compose.yaml logs -f trino +``` + +### An interactive OAuth 2.0 login + +Needs the `oauth` profile and the `trino_oauth` DSN: + +```bash +./integration-tests/setup.sh --profile oauth +isql -3 trino_oauth -v +``` + +`isql` connects through `SQLConnect`, which carries no *DriverCompletion*, so +the driver is allowed to prompt and a real browser opens on Keycloak's login +page. It will warn about the test CA, which `scripts/gen-certs.sh` generates +and no browser trusts. The credentials are the `KEYCLOAK_USER` and +`KEYCLOAK_PASSWORD` values in `generated/stack.env`. + +pyodbc cannot do this. It passes `SQL_DRIVER_NOPROMPT` unconditionally, so the +driver refuses the connection; `suites/test_oauth.py` uses `ctypes` for that +reason. diff --git a/integration-tests/perf/parse_profile.py b/integration-tests/perf/parse_profile.py new file mode 100644 index 0000000..5b77a0a --- /dev/null +++ b/integration-tests/perf/parse_profile.py @@ -0,0 +1,176 @@ +#!/usr/bin/env python3 +""" +Parse ODBC driver profiling logs and produce a per-query summary table. + +The driver emits structured tracing output when ODBC_PROFILING=1: + - "trino server stats" lines with Trino-side timing per page + - "query profiling summary" lines with per-query aggregates + - Span close events with time.busy for trino.submit, trino.fetch_page, etc. + +This script reads the log file and produces a summary table showing where +time is spent for each query. + +Usage: + python3 parse_profile.py /tmp/odbc_profile.log +""" + +import re +import sys + + +def parse_kv(line: str) -> dict[str, str]: + """Extract key=value pairs from a tracing log line. + + Handles both unquoted values (key=123) and quoted values (key="some string"). + """ + pairs = {} + for m in re.finditer(r'(\w+)=(?:"([^"]*)"|(\S+))', line): + key = m.group(1) + val = m.group(2) if m.group(2) is not None else m.group(3) + pairs[key] = val + return pairs + + +class QueryProfile: + """Accumulates profiling data for a single query.""" + + def __init__(self, query_id: str): + self.query_id = query_id + self.pages = 0 + self.empty_pages = 0 + self.total_rows = 0 + self.fetch_ms = 0.0 + self.convert_ms = 0.0 + self.trino_elapsed_ms = 0.0 + self.trino_cpu_ms = 0.0 + self.trino_queued_ms = 0.0 + self.trino_rows = 0 + self.trino_bytes = 0 + self.trino_peak_mem = 0 + self.trino_state = "" + + +def main(): + if len(sys.argv) != 2: + print(f"Usage: {sys.argv[0]} ") + sys.exit(2) + + log_path = sys.argv[1] + + try: + with open(log_path) as f: + lines = f.readlines() + except FileNotFoundError: + print(f"Error: log file not found: {log_path}") + sys.exit(1) + + if not lines: + print("Log file is empty. Did the stress test run with ODBC_PROFILING=1?") + sys.exit(1) + + # Process lines sequentially. The last "trino server stats" line before a + # "query profiling summary" line contains the final cumulative Trino stats + # for that query. + queries: list[QueryProfile] = [] + last_stats: dict[str, str] = {} + + for line in lines: + if "trino server stats" in line: + last_stats = parse_kv(line) + + elif "query profiling summary" in line: + kv = parse_kv(line) + qid = kv.get("query_id", "unknown") + # Clean up the query_id from tracing's Debug format: Some("...") -> ... + qid = qid.replace("Some(", "").rstrip(")").strip('"') + + q = QueryProfile(qid) + q.pages = int(kv.get("pages", 0)) + q.empty_pages = int(kv.get("empty_pages", 0)) + q.total_rows = int(kv.get("total_rows", 0)) + q.fetch_ms = float(kv.get("fetch_ms", 0)) + q.convert_ms = float(kv.get("convert_ms", 0)) + + # Apply the last seen Trino stats (cumulative final values) + if last_stats: + q.trino_elapsed_ms = float(last_stats.get("trino_elapsed_ms", 0)) + q.trino_cpu_ms = float(last_stats.get("trino_cpu_ms", 0)) + q.trino_queued_ms = float(last_stats.get("trino_queued_ms", 0)) + q.trino_rows = int(last_stats.get("trino_rows", 0)) + q.trino_bytes = int(last_stats.get("trino_bytes", 0)) + q.trino_peak_mem = int(last_stats.get("trino_peak_mem", 0)) + q.trino_state = last_stats.get("trino_state", "") + + queries.append(q) + last_stats = {} + + if not queries: + print("No query profiling summaries found in the log.") + print("Ensure the driver was built with the profiling instrumentation") + print("and ODBC_LOG_LEVEL=info ODBC_PROFILING=1 were set.") + sys.exit(1) + + # Print summary table. + hdr = (f"{'Query ID':<24} | {'Pages':>5} | {'Empty':>5} | {'Rows':>8} | " + f"{'Trino ms':>9} | {'Fetch ms':>9} | {'Cvt ms':>6} | " + f"{'Overhead':>9} | {'Rows/s':>8} | {'Trino state':<10}") + sep = "-" * len(hdr) + print(hdr) + print(sep) + + total_fetch = 0.0 + total_convert = 0.0 + total_trino = 0.0 + total_rows = 0 + total_empty = 0 + + for q in queries: + overhead_ms = q.fetch_ms - q.trino_elapsed_ms + if q.fetch_ms > 0: + rows_per_sec = int(q.total_rows / (q.fetch_ms / 1000.0)) + else: + rows_per_sec = 0 + + display_id = q.query_id[-22:] if len(q.query_id) > 24 else q.query_id + + print(f"{display_id:<24} | {q.pages:>5} | {q.empty_pages:>5} | " + f"{q.total_rows:>8} | {q.trino_elapsed_ms:>9.0f} | " + f"{q.fetch_ms:>9.0f} | {q.convert_ms:>6.0f} | " + f"{overhead_ms:>9.0f} | {rows_per_sec:>8} | {q.trino_state:<10}") + + total_fetch += q.fetch_ms + total_convert += q.convert_ms + total_trino += q.trino_elapsed_ms + total_rows += q.total_rows + total_empty += q.empty_pages + + print() + total_overhead = total_fetch - total_trino + print(f"Total: {len(queries)} queries, {total_rows} rows, " + f"{total_empty} empty pages across all queries") + print(f" Trino server time: {total_trino:>8.0f} ms") + print(f" HTTP fetch time: {total_fetch:>8.0f} ms") + print(f" Row conversion: {total_convert:>8.0f} ms") + print(f" Client overhead: {total_overhead:>8.0f} ms " + f"(HTTP round-trip + JSON deserialisation + empty page polling)") + if total_fetch > 0: + pct = (total_overhead / total_fetch) * 100 + print(f" Overhead fraction: {pct:>7.1f}%") + + # Breakdown by category + print() + print("Breakdown:") + if total_fetch > 0: + print(f" Trino execution: {total_trino / total_fetch * 100:>5.1f}% of fetch time") + print(f" Client overhead: {abs(total_overhead) / total_fetch * 100:>5.1f}% of fetch time" + f" (HTTP round-trips + JSON deserialisation)") + print(f" Row conversion: {total_convert / total_fetch * 100:>5.1f}% of fetch time" + f" (row.clone() + json_to_column_value)") + if total_empty > 0: + avg_empty = total_empty / len(queries) + print(f" Empty pages: {total_empty} total" + f" (avg {avg_empty:.1f}/query): Trino REST polling while server works") + + +if __name__ == "__main__": + main() diff --git a/integration-tests/perf/profile_stress.sh b/integration-tests/perf/profile_stress.sh new file mode 100755 index 0000000..dbbbbf0 --- /dev/null +++ b/integration-tests/perf/profile_stress.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Profile the Trino REST client during stress tests. +# +# Usage: +# ./integration-tests/perf/profile_stress.sh "Driver=/path/to/driver.so;Host=localhost;Port=8443;User=admin;Password=admin;Protocol=https;Catalog=tpcds;TlsVerify=false" +# ./integration-tests/perf/profile_stress.sh "DSN=trino_https" +# +# Output: +# - Stress test results (PASS/FAIL per test with elapsed time) +# - Profiling summary table (per-query breakdown of Trino vs client overhead) +# - Raw log at /tmp/odbc_profile.log +# +# Requires: Trino running (./integration-tests/setup.sh), driver built (cargo build) + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +LOG_FILE="${ODBC_PROFILE_LOG:-/tmp/odbc_profile.log}" + +if [ $# -lt 1 ]; then + echo "Usage: $0 " + echo "" + echo "Example:" + echo " $0 \"Driver=\$(pwd)/target/debug/libstackable_odbc_trino.so;Host=localhost;Port=8443;User=admin;Password=admin;Protocol=https;Catalog=tpcds;TlsVerify=false\"" + exit 2 +fi + +CONN_STR="$1" + +# Clear previous log +: > "$LOG_FILE" + +echo "=== Trino REST Client Profiling ===" +echo "Log file: $LOG_FILE" +echo "" + +# Set ODBC config if not already set (for DSN-less connections this is optional, +# but needed if using DSN names). setup.sh writes both files into generated/, +# with the driver path and the test CA of this checkout. +GENERATED="$(cd "$SCRIPT_DIR/.." && pwd)/generated" +export ODBCSYSINI="${ODBCSYSINI:-$GENERATED}" +export ODBCINI="${ODBCINI:-$GENERATED/odbc.ini}" + +# Enable profiling: info-level logging with span timing to file. +export ODBC_LOG_LEVEL=info +export ODBC_PROFILING=1 +export ODBC_LOG_FILE="$LOG_FILE" + +echo "--- Stress Test Output ---" +echo "" + +# Run the stress test. Allow failure (exit code 1 = test failures) so we still +# parse the profile log. +uv run --with pyodbc python3 "$SCRIPT_DIR/test_stress.py" "$CONN_STR" || true + +echo "" +echo "--- Profiling Summary ---" +echo "" + +# Parse the profile log and produce the summary table. +python3 "$SCRIPT_DIR/parse_profile.py" "$LOG_FILE" diff --git a/integration-tests/perf/test_stress.py b/integration-tests/perf/test_stress.py new file mode 100644 index 0000000..5be3ec0 --- /dev/null +++ b/integration-tests/perf/test_stress.py @@ -0,0 +1,276 @@ +#!/usr/bin/env python3 +""" +BI stress tests for the Trino ODBC driver. + +Exercises query patterns typical of PowerBI and similar BI tools: multi-table +JOINs, UNIONs, subqueries, CTEs, window functions, large result sets, and +wide rows. All queries are read-only against the tpcds sf1 catalogue. + +Usage: + python3 integration-tests/perf/test_stress.py "Driver=/path/to/driver.so;Host=localhost;Port=8080;User=admin;Protocol=http;Catalog=tpcds" + python3 integration-tests/perf/test_stress.py "DSN=test_trino" + +Requires a running Trino (integration-tests/setup.sh) and `pip install pyodbc`. +Needs no compose profile: the tpcds catalog is in the base stack. +""" + +import os +import sys + +import pyodbc + +sys.path.insert( + 0, os.path.join(os.path.dirname(os.path.abspath(__file__)), "..", "suites") +) + +from harness import Results, Stack # noqa: E402 + +R = Results("stress") + + +def main(): + # A connection string may be passed positionally; with no argument the + # local stack describes itself. + conn_str = sys.argv[1] if len(sys.argv) > 1 else Stack.load().conn_str() + conn = pyodbc.connect(conn_str, autocommit=True) + cur = conn.cursor() + + # ------------------------------------------------------------------ + # 1. Multi-table JOINs + # ------------------------------------------------------------------ + + def test_two_table_join(): + cur.execute(""" + SELECT c.c_first_name, c.c_last_name, SUM(ss.ss_net_paid) AS total_spend + FROM tpcds.sf1.customer c + JOIN tpcds.sf1.store_sales ss ON c.c_customer_sk = ss.ss_customer_sk + GROUP BY c.c_first_name, c.c_last_name + ORDER BY total_spend DESC + LIMIT 10 + """) + rows = cur.fetchall() + assert len(rows) == 10, f"expected 10 rows, got {len(rows)}" + for row in rows: + assert row[2] is not None and row[2] > 0, f"total_spend should be > 0, got {row[2]!r}" + + R.run("Two-table INNER JOIN with aggregation", test_two_table_join) + + def test_three_table_star_join(): + cur.execute(""" + SELECT c.c_first_name, i.i_product_name, SUM(ss.ss_quantity) AS total_qty + FROM tpcds.sf1.store_sales ss + JOIN tpcds.sf1.customer c ON ss.ss_customer_sk = c.c_customer_sk + JOIN tpcds.sf1.item i ON ss.ss_item_sk = i.i_item_sk + GROUP BY c.c_first_name, i.i_product_name + ORDER BY total_qty DESC + LIMIT 10 + """) + rows = cur.fetchall() + assert len(rows) == 10, f"expected 10 rows, got {len(rows)}" + # TPC-DS data can have NULLs in name columns; the test exercises the + # three-table JOIN path, not NULL handling (that's test 5a). + for row in rows: + assert row[2] is not None, "total_qty should not be NULL" + + R.run("Three-table star-schema JOIN", test_three_table_star_join) + + def test_left_join_nulls(): + cur.execute(""" + SELECT c.c_customer_sk, c.c_first_name, ws.ws_order_number + FROM tpcds.sf1.customer c + LEFT JOIN tpcds.sf1.web_sales ws ON c.c_customer_sk = ws.ws_bill_customer_sk + WHERE c.c_customer_sk BETWEEN 1 AND 20 + ORDER BY c.c_customer_sk + """) + rows = cur.fetchall() + assert len(rows) >= 20, f"expected >= 20 rows, got {len(rows)}" + has_null = any(row[2] is None for row in rows) + assert has_null, "expected at least one NULL ws_order_number from LEFT JOIN" + + R.run("LEFT JOIN producing NULLs", test_left_join_nulls) + + # ------------------------------------------------------------------ + # 2. Subqueries and CTEs + # ------------------------------------------------------------------ + + def test_correlated_subquery(): + cur.execute(""" + SELECT c_customer_sk, c_first_name + FROM tpcds.sf1.customer c + WHERE c_customer_sk IN ( + SELECT ss_customer_sk + FROM tpcds.sf1.store_sales + WHERE ss_net_paid > 100 + ) + LIMIT 10 + """) + rows = cur.fetchall() + assert len(rows) == 10, f"expected 10 rows, got {len(rows)}" + for row in rows: + assert row[0] > 0, f"c_customer_sk should be > 0, got {row[0]}" + + R.run("Correlated subquery", test_correlated_subquery) + + def test_cte(): + cur.execute(""" + WITH top_items AS ( + SELECT ss_item_sk, SUM(ss_quantity) AS total_qty + FROM tpcds.sf1.store_sales + GROUP BY ss_item_sk + ORDER BY total_qty DESC + LIMIT 5 + ) + SELECT i.i_product_name, t.total_qty + FROM top_items t + JOIN tpcds.sf1.item i ON t.ss_item_sk = i.i_item_sk + """) + rows = cur.fetchall() + assert len(rows) == 5, f"expected 5 rows, got {len(rows)}" + for row in rows: + assert row[1] is not None and row[1] > 0, f"total_qty should be > 0, got {row[1]!r}" + + R.run("CTE / WITH clause", test_cte) + + # ------------------------------------------------------------------ + # 3. UNION + # ------------------------------------------------------------------ + + def test_union_all(): + cur.execute(""" + SELECT 'store' AS channel, ss_customer_sk AS customer_sk, ss_net_paid AS amount + FROM tpcds.sf1.store_sales + WHERE ss_customer_sk BETWEEN 1 AND 5 + UNION ALL + SELECT 'web', ws_bill_customer_sk, ws_net_paid + FROM tpcds.sf1.web_sales + WHERE ws_bill_customer_sk BETWEEN 1 AND 5 + """) + rows = cur.fetchall() + assert len(rows) > 0, "expected rows from UNION ALL" + channels = {row[0].strip() for row in rows} + assert "store" in channels, f"expected 'store' channel, got {channels}" + assert "web" in channels, f"expected 'web' channel, got {channels}" + + R.run("UNION ALL across sales channels", test_union_all) + + def test_union_dedup(): + cur.execute(""" + SELECT ss_customer_sk AS customer_sk + FROM tpcds.sf1.store_sales WHERE ss_customer_sk BETWEEN 1 AND 10 + UNION + SELECT ws_bill_customer_sk + FROM tpcds.sf1.web_sales WHERE ws_bill_customer_sk BETWEEN 1 AND 10 + """) + rows = cur.fetchall() + assert len(rows) <= 10, f"expected <= 10 deduped rows, got {len(rows)}" + for row in rows: + assert 1 <= row[0] <= 10, f"customer_sk {row[0]} out of range [1, 10]" + + R.run("UNION with dedup", test_union_dedup) + + # ------------------------------------------------------------------ + # 4. Large result sets + # ------------------------------------------------------------------ + + def test_large_result_set(): + cur.execute(""" + SELECT c_customer_sk, c_first_name, c_last_name, c_birth_year + FROM tpcds.sf1.customer + WHERE c_customer_sk <= 15000 + """) + rows = cur.fetchall() + assert len(rows) >= 10000, f"expected >= 10000 rows, got {len(rows)}" + + R.run("Fetch 10,000+ rows", test_large_result_set) + + def test_wide_result_set(): + cur.execute(""" + SELECT + c.c_customer_sk, c.c_first_name, c.c_last_name, + c.c_birth_year, c.c_birth_month, c.c_birth_country, + c.c_email_address, c.c_login, + ss.ss_quantity, ss.ss_net_paid, ss.ss_net_profit + FROM tpcds.sf1.store_sales ss + JOIN tpcds.sf1.customer c ON ss.ss_customer_sk = c.c_customer_sk + WHERE c.c_customer_sk = 1 + """) + rows = cur.fetchall() + assert len(rows) >= 1, f"expected >= 1 row, got {len(rows)}" + assert cur.description is not None + col_names = [d[0] for d in cur.description] + assert len(col_names) == 11, f"expected 11 columns, got {len(col_names)}" + + R.run("Wide result set (11 columns)", test_wide_result_set) + + # ------------------------------------------------------------------ + # 5. NULL and edge cases + # ------------------------------------------------------------------ + + def test_nulls_in_various_positions(): + cur.execute(""" + SELECT c_customer_sk, c_email_address, c_birth_country, c_login + FROM tpcds.sf1.customer + WHERE c_customer_sk BETWEEN 1 AND 50 + """) + rows = cur.fetchall() + assert len(rows) >= 50, f"expected >= 50 rows, got {len(rows)}" + has_none = any( + any(col is None for col in row[1:]) + for row in rows + ) + assert has_none, "expected at least one NULL in nullable columns" + + R.run("NULLs in various column positions", test_nulls_in_various_positions) + + def test_empty_result_set(): + cur.execute("SELECT c_customer_sk FROM tpcds.sf1.customer WHERE 1 = 0") + rows = cur.fetchall() + assert len(rows) == 0, f"expected 0 rows, got {len(rows)}" + + R.run("Empty result set", test_empty_result_set) + + # ------------------------------------------------------------------ + # 6. Complex aggregation + # ------------------------------------------------------------------ + + def test_group_by_having_on_join(): + cur.execute(""" + SELECT c.c_first_name, COUNT(*) AS purchase_count, SUM(ss.ss_net_paid) AS total + FROM tpcds.sf1.store_sales ss + JOIN tpcds.sf1.customer c ON ss.ss_customer_sk = c.c_customer_sk + GROUP BY c.c_first_name + HAVING COUNT(*) > 100 + ORDER BY total DESC + LIMIT 10 + """) + rows = cur.fetchall() + assert len(rows) == 10, f"expected 10 rows, got {len(rows)}" + for row in rows: + assert row[1] > 100, f"purchase_count should be > 100, got {row[1]}" + assert row[2] is not None and row[2] > 0, f"total should be > 0, got {row[2]!r}" + + R.run("GROUP BY + HAVING on a JOIN", test_group_by_having_on_join) + + def test_window_function(): + cur.execute(""" + SELECT c_customer_sk, c_first_name, c_birth_year, + ROW_NUMBER() OVER (PARTITION BY c_birth_year ORDER BY c_customer_sk) AS rn + FROM tpcds.sf1.customer + WHERE c_birth_year IS NOT NULL AND c_customer_sk <= 1000 + """) + rows = cur.fetchall() + assert len(rows) > 0, "expected rows" + for row in rows: + assert row[3] is not None and row[3] > 0, f"rn should be a positive integer, got {row[3]!r}" + + R.run("Window function (ROW_NUMBER OVER PARTITION BY)", test_window_function) + + # === Cleanup === + conn.close() + + # === Summary === + sys.exit(R.summary()) + + +if __name__ == "__main__": + main() diff --git a/integration-tests/run-tests.sh b/integration-tests/run-tests.sh new file mode 100755 index 0000000..308e84d --- /dev/null +++ b/integration-tests/run-tests.sh @@ -0,0 +1,3 @@ +#!/usr/bin/env bash +# Wrapper. The logic lives in scripts/run-tests.sh. +exec "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/scripts/run-tests.sh" "$@" diff --git a/integration-tests/scripts/gen-certs.sh b/integration-tests/scripts/gen-certs.sh new file mode 100755 index 0000000..f274602 --- /dev/null +++ b/integration-tests/scripts/gen-certs.sh @@ -0,0 +1,96 @@ +#!/usr/bin/env bash +# One CA, and every leaf signed from it. +# +# The coordinator's SAN carries the names a client connects by, and Jetty +# selects the certificate on SNI. Anything it cannot match gets the self-signed +# certificate Trino generates for internal communication instead, so a name +# missing from this list does not yield a hostname mismatch against this +# certificate; it yields a different certificate. suites/test_tls.py documents +# what that rules out. +set -euo pipefail +# shellcheck source-path=SCRIPTDIR source=lib.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +# The host-only network gateway, for the Windows VM. Override for a non-default +# libvirt subnet. +HOST_GATEWAY="${ODBC_TEST_HOST_GATEWAY:-192.168.197.1}" + +mkdir -p "$CERT_DIR" + +if [[ -f "$CERT_DIR/ca.crt" && -f "$CERT_DIR/keystore.p12" && -f "$CERT_DIR/client.pem" ]]; then + echo "Certificates already exist, skipping generation." + exit 0 +fi + +echo "--- certificate authority ---" +openssl req -x509 -newkey rsa:4096 -nodes -days 3650 -sha256 \ + -keyout "$CERT_DIR/ca.key" -out "$CERT_DIR/ca.crt" \ + -subj "/CN=stackable-odbc-trino-test-ca" \ + -addext "basicConstraints=critical,CA:TRUE,pathlen:0" \ + -addext "keyUsage=critical,keyCertSign,cRLSign" + +# gen_leaf +gen_leaf() { + local name="$1" subj="$2" san="$3" eku="$4" + openssl req -newkey rsa:2048 -nodes -sha256 \ + -keyout "$CERT_DIR/$name.key" -out "$CERT_DIR/$name.csr" -subj "$subj" + openssl x509 -req -in "$CERT_DIR/$name.csr" -days 3650 -sha256 \ + -CA "$CERT_DIR/ca.crt" -CAkey "$CERT_DIR/ca.key" -CAcreateserial \ + -out "$CERT_DIR/$name.crt" \ + -extfile <(printf 'subjectAltName=%s\nbasicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature,keyEncipherment\nextendedKeyUsage=%s\n' "$san" "$eku") + rm -f "$CERT_DIR/$name.csr" +} + +echo "--- coordinator certificate ---" +gen_leaf trino "/CN=localhost" \ + "DNS:localhost,DNS:trino,IP:${HOST_GATEWAY}" serverAuth + +echo "--- client certificate (mutual TLS) ---" +# CN is the Trino username: http-server.authentication.certificate maps the +# subject to a principal, and the user-mapping pattern extracts this CN. +gen_leaf client "/CN=${TRINO_USER}" "DNS:${TRINO_USER}" clientAuth + +echo "--- keycloak certificate ---" +gen_leaf keycloak "/CN=keycloak" "DNS:keycloak,DNS:localhost" serverAuth + +echo "--- coordinator keystore ---" +openssl pkcs12 -export \ + -in "$CERT_DIR/trino.crt" -inkey "$CERT_DIR/trino.key" \ + -certfile "$CERT_DIR/ca.crt" -name trino \ + -out "$CERT_DIR/keystore.p12" -passout "pass:${KEYSTORE_PASSWORD}" + +echo "--- truststore (the CA alone) ---" +# Serves two jobs: Trino's client-certificate truststore, and (because Trino +# derives its internal http clients' truststore from the same setting) the +# trust anchor for the HTTPS-only internal discovery loop. +# +# keytool, not `openssl pkcs12 -export -nokeys`. openssl writes the certificate +# into a certBag with no Oracle trusted-certificate attribute, and Java then +# reports "Your keystore contains 0 entries". That is an empty truststore +# failing silently: the coordinator still starts, but it validates no client +# certificate and logs 503s fetching its own memory info over TLS. +rm -f "$CERT_DIR/truststore.p12" +keytool -importcert -noprompt -trustcacerts \ + -alias ca -file "$CERT_DIR/ca.crt" \ + -keystore "$CERT_DIR/truststore.p12" -storetype PKCS12 \ + -storepass "$KEYSTORE_PASSWORD" + +# Assert it is readable and non-empty. The failure above produced a valid +# PKCS12 file that Java saw as empty, so the file existing proves nothing. +entries="$(keytool -list -keystore "$CERT_DIR/truststore.p12" -storetype PKCS12 \ + -storepass "$KEYSTORE_PASSWORD" 2>/dev/null | grep -c 'trustedCertEntry' || true)" +if [[ "$entries" -lt 1 ]]; then + echo "ERROR: truststore.p12 holds no trustedCertEntry; Java would read it as empty" >&2 + exit 1 +fi +echo "truststore holds $entries trusted certificate(s)." + +echo "--- client PEM for the driver's ClientCertificate key ---" +# One file: the certificate chain, then the PKCS#8 private key. The driver +# builds reqwest on rustls, which accepts neither PKCS#12 nor JKS, so this is +# the only form the ClientCertificate connection-string key takes. +openssl pkcs8 -topk8 -nocrypt -in "$CERT_DIR/client.key" -out "$CERT_DIR/client.pk8" +cat "$CERT_DIR/client.crt" "$CERT_DIR/ca.crt" "$CERT_DIR/client.pk8" > "$CERT_DIR/client.pem" +rm -f "$CERT_DIR/client.pk8" + +echo "Certificates written to $CERT_DIR" diff --git a/integration-tests/scripts/gen-keycloak-config.sh b/integration-tests/scripts/gen-keycloak-config.sh new file mode 100755 index 0000000..24e6783 --- /dev/null +++ b/integration-tests/scripts/gen-keycloak-config.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Assembles generated/keycloak/ from stack/keycloak/, and makes Keycloak's +# private key readable inside its container. +# +# The realm names the client secret, the realm name, the user and the Trino +# callback port, and so does the Trino config fragment. Substituting both from +# lib.sh is what stops the two disagreeing. Everything mounted into a container +# comes from generated/, never from the checkout. +# +# Three fields in realm-trino.json carry a requirement JSON cannot state itself: +# +# directAccessGrantsEnabled enables the password grant, which is how a token's +# claims are read with no browser in the loop. +# the oidc-audience-mapper puts the client id into `aud`. Keycloak's default +# access-token audience is `account`, and Trino +# requires its own client id to be present. +# requiredActions: [] with a non-temporary credential, so Keycloak does +# not interpose an "update password" page that the +# test browser would not fill in. +set -euo pipefail +# shellcheck source-path=SCRIPTDIR source=lib.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +OUT="$GENERATED/keycloak" +# Created even when the profile is off, so the compose mount always resolves. +mkdir -p "$OUT" + +if [[ ",$PROFILES," != *",oauth,"* ]]; then + echo "oauth profile inactive; skipping Keycloak config." + exit 0 +fi + +# Keycloak reads the key file directly and its container user need not be +# uid 1000, while openssl writes 0600. Done on every run rather than in +# gen-certs.sh, which exits early when the certificates already exist. +chmod 0644 "$CERT_DIR/keycloak.key" + +sed -e "s|@KEYCLOAK_REALM@|$KEYCLOAK_REALM|g" \ + -e "s|@KEYCLOAK_USER@|$KEYCLOAK_USER|g" \ + -e "s|@KEYCLOAK_PASSWORD@|$KEYCLOAK_PASSWORD|g" \ + -e "s|@OAUTH_CLIENT_ID@|$OAUTH_CLIENT_ID|g" \ + -e "s|@OAUTH_CLIENT_SECRET@|$OAUTH_CLIENT_SECRET|g" \ + -e "s|@TRINO_HTTPS_PORT@|$TRINO_HTTPS_PORT|g" \ + "$STACK_DIR/keycloak/realm-trino.json" > "$OUT/realm-trino.json" + +# An unresolved placeholder would reach Keycloak as a literal client secret, +# which fails later as an opaque `invalid_client`. +if grep -qE '@[A-Z_]+@' "$OUT/realm-trino.json"; then + echo "ERROR: unresolved placeholder in the assembled realm:" >&2 + grep -nE '@[A-Z_]+@' "$OUT/realm-trino.json" >&2 + exit 2 +fi + +echo "Assembled the Keycloak realm into $OUT" diff --git a/integration-tests/scripts/gen-odbc-config.sh b/integration-tests/scripts/gen-odbc-config.sh new file mode 100755 index 0000000..cb9bbdc --- /dev/null +++ b/integration-tests/scripts/gen-odbc-config.sh @@ -0,0 +1,94 @@ +#!/usr/bin/env bash +# Writes odbcinst.ini, odbc.ini and stack.env into generated/. +set -euo pipefail +# shellcheck source-path=SCRIPTDIR source=lib.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +# The certificate the coordinator is verified against: the test CA, which +# signed the coordinator's leaf. +SERVER_CERT="$CERT_DIR/ca.crt" + +# Threading = 2 must match packaging/linux/install.sh. unixODBC's default of 3 +# serialises a cross-thread SQLCancel behind the call it was meant to interrupt. +# See "Threading = 2 is required, not tuning" in AGENTS.md; suites/ +# test_integration.py's cross-thread cancel test is what notices a regression. +cat > "$GENERATED/odbcinst.ini" << EOF +[stackable_odbc_trino] +Driver = $DRIVER_PATH +Threading = 2 +EOF + +cat > "$GENERATED/odbc.ini" << EOF +[trino_https] +Driver = stackable_odbc_trino +Host = $TRINO_HOST +Port = $TRINO_HTTPS_PORT +User = $TRINO_USER +Password = $TRINO_PASSWORD +Protocol = https +Catalog = $TRINO_CATALOG +Certificate = $SERVER_CERT + +[trino_https_verify_false] +Driver = stackable_odbc_trino +Host = $TRINO_HOST +Port = $TRINO_HTTPS_PORT +User = $TRINO_USER +Password = $TRINO_PASSWORD +Protocol = https +Catalog = $TRINO_CATALOG +TlsVerify = false + +[trino_postgresql] +Driver = stackable_odbc_trino +Host = $TRINO_HOST +Port = $TRINO_HTTPS_PORT +User = $TRINO_USER +Password = $TRINO_PASSWORD +Protocol = https +Catalog = postgresql +Certificate = $SERVER_CERT + +[trino_oauth] +# Needs the oauth profile. isql connects by DSN through SQLConnect, which carries +# no DriverCompletion, and core reads the absent argument as permitting a prompt, +# so this is the one interactive path a person can drive by hand. A real browser +# will warn about the test CA. +# +# User is omitted. The identity provider supplies it, and a User disagreeing +# with the token is refused as an impersonation attempt. +Driver = stackable_odbc_trino +Host = $TRINO_HOST +Port = $TRINO_HTTPS_PORT +Protocol = https +Catalog = $TRINO_CATALOG +Certificate = $SERVER_CERT +ExternalAuthentication = true +EOF + +# One description of the running stack. Bash sources it; harness.Stack parses +# it. Every connection string in every suite is built from here, so a port, a +# credential or a certificate path is stated once. +cat > "$STACK_ENV" << EOF +# generated by scripts/gen-odbc-config.sh, do not edit +DRIVER_PATH=$DRIVER_PATH +TRINO_HOST=$TRINO_HOST +TRINO_HTTPS_PORT=$TRINO_HTTPS_PORT +TRINO_USER=$TRINO_USER +TRINO_PASSWORD=$TRINO_PASSWORD +TRINO_CATALOG=$TRINO_CATALOG +CA_CERT=$SERVER_CERT +CLIENT_PEM=$CERT_DIR/client.pem +# Keycloak, for suites/test_oauth.py. The client secret is omitted because +# nothing on the Python side needs it, and the coordinator gets it from the +# assembled config. +KEYCLOAK_ISSUER=$KEYCLOAK_ISSUER +KEYCLOAK_USER=$KEYCLOAK_USER +KEYCLOAK_PASSWORD=$KEYCLOAK_PASSWORD +OAUTH_CLIENT_ID=$OAUTH_CLIENT_ID +ODBCSYSINI=$GENERATED +ODBCINI=$GENERATED/odbc.ini +PROFILES=${PROFILES:-} +EOF + +echo "Wrote odbcinst.ini, odbc.ini and stack.env to $GENERATED" diff --git a/integration-tests/scripts/gen-secrets.sh b/integration-tests/scripts/gen-secrets.sh new file mode 100755 index 0000000..f4e5f00 --- /dev/null +++ b/integration-tests/scripts/gen-secrets.sh @@ -0,0 +1,24 @@ +#!/usr/bin/env bash +# Generates the secrets the stack needs. Idempotent. +set -euo pipefail +# shellcheck source-path=SCRIPTDIR source=lib.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +PASSFILE="$GENERATED/password.db" + +if [[ -f "$PASSFILE" ]]; then + echo "password.db already exists, skipping." + exit 0 +fi + +if command -v htpasswd &>/dev/null; then + # Trino requires bcrypt cost >= 8; htpasswd's -B defaults to 5, so -C 10. + htpasswd -c -B -C 10 -b "$PASSFILE" "$TRINO_USER" "$TRINO_PASSWORD" +else + python3 -c " +import bcrypt, sys +h = bcrypt.hashpw(sys.argv[2].encode(), bcrypt.gensalt(rounds=10)).decode() +print(f'{sys.argv[1]}:{h}') +" "$TRINO_USER" "$TRINO_PASSWORD" > "$PASSFILE" +fi +echo "password.db created." diff --git a/integration-tests/scripts/gen-trino-config.sh b/integration-tests/scripts/gen-trino-config.sh new file mode 100755 index 0000000..9681822 --- /dev/null +++ b/integration-tests/scripts/gen-trino-config.sh @@ -0,0 +1,85 @@ +#!/usr/bin/env bash +# Assembles generated/trino/ from stack/trino/ fragments, per active profile. +# +# Compose profiles select *services*; they cannot vary a mounted file's +# contents. And Trino refuses to start when config.properties names an OAuth +# issuer, an S3 endpoint or a metastore that is not running, so a superset +# configuration is not available either. Hence assembly. +# +# A fragment directory may contain: +# *.properties copied in whole (spooling-manager.properties, say) +# catalog/*.properties copied into catalog/ +# config.properties.d/* appended onto config.properties +# +# Appending is why a value that *changes* between profiles cannot be a +# fragment: a duplicate key is a Trino startup error. Those are @PLACEHOLDER@ +# substitutions instead, resolved at the end. +set -euo pipefail +# shellcheck source-path=SCRIPTDIR source=lib.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +OUT="$GENERATED/trino" +rm -rf "$OUT" +mkdir -p "$OUT/catalog" + +cp -r "$STACK_DIR/trino/base/." "$OUT/" + +for profile in ${PROFILES//,/ }; do + src="$STACK_DIR/trino/$profile" + if [[ ! -d "$src" ]]; then + echo "ERROR: no config fragment directory for profile '$profile'" >&2 + exit 2 + fi + + shopt -s nullglob + for f in "$src"/*.properties; do cp "$f" "$OUT/"; done + for f in "$src"/catalog/*.properties; do cp "$f" "$OUT/catalog/"; done + for f in "$src"/config.properties.d/*; do + printf '\n# --- from profile: %s ---\n' "$profile" >> "$OUT/config.properties" + cat "$f" >> "$OUT/config.properties" + done + shopt -u nullglob +done + +# --- computed values --- +# CERTIFICATE first, so a connection presenting a client certificate is +# authenticated by it and one that presents none falls through to PASSWORD. +# +# OAUTH2 goes last, and the ordering decides what the client is tested against. +# Trino emits one `WWW-Authenticate` header per configured type in this order, so +# `Basic realm="Trino"` precedes the Bearer challenge. That is the arrangement +# the client's header scan has to survive, and a stack emitting the Bearer +# challenge first would let a client reading only the first header pass. +AUTH_TYPES="CERTIFICATE,PASSWORD" +if [[ ",$PROFILES," == *",oauth,"* ]]; then + AUTH_TYPES="$AUTH_TYPES,OAUTH2" +fi + +# Every substitution is applied to every assembled file, rather than to +# config.properties alone: a profile may contribute a whole *.properties of its +# own (spooling-manager.properties does), and one left unsubstituted would reach +# Trino as a literal @NAME@ credential. +shopt -s nullglob +TARGETS=("$OUT"/*.properties "$OUT"/catalog/*.properties) +shopt -u nullglob + +sed -i \ + -e "s|@AUTH_TYPES@|$AUTH_TYPES|g" \ + -e "s|@OAUTH_CLIENT_ID@|$OAUTH_CLIENT_ID|g" \ + -e "s|@OAUTH_CLIENT_SECRET@|$OAUTH_CLIENT_SECRET|g" \ + -e "s|@SPOOLING_SECRET@|$SPOOLING_SECRET|g" \ + -e "s|@SPOOLING_BUCKET@|$SPOOLING_BUCKET|g" \ + -e "s|@MINIO_ACCESS_KEY@|$MINIO_ACCESS_KEY|g" \ + -e "s|@MINIO_SECRET_KEY@|$MINIO_SECRET_KEY|g" \ + "${TARGETS[@]}" + +# An unresolved placeholder reaches Trino as a literal, which is why these are +# @NAME@ rather than an empty default: a fragment added later that introduces +# one without teaching this script about it fails here instead. +if grep -qE '@[A-Z_]+@' "${TARGETS[@]}"; then + echo "ERROR: unresolved placeholder in the assembled config:" >&2 + grep -nE '@[A-Z_]+@' "${TARGETS[@]}" >&2 + exit 2 +fi + +echo "Assembled Trino config for profiles '${PROFILES:-}' into $OUT" diff --git a/integration-tests/scripts/lib.sh b/integration-tests/scripts/lib.sh new file mode 100755 index 0000000..ba18d17 --- /dev/null +++ b/integration-tests/scripts/lib.sh @@ -0,0 +1,170 @@ +#!/usr/bin/env bash +# Shared paths, profile handling and helpers. Sourced, never executed. +# +# SC2034: every variable below is consumed by a script that sources this file, +# which shellcheck cannot see from here. +# shellcheck disable=SC2034 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TEST_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +PROJECT_DIR="$(cd "$TEST_DIR/.." && pwd)" + +STACK_DIR="$TEST_DIR/stack" +GENERATED="$TEST_DIR/generated" +CERT_DIR="$GENERATED/certs" +COMPOSE_FILE="$STACK_DIR/compose.yaml" +STACK_ENV="$GENERATED/stack.env" + +DRIVER_PATH="$PROJECT_DIR/target/debug/libstackable_odbc_trino.so" + +TRINO_HOST="localhost" +TRINO_HTTPS_PORT=8443 +TRINO_USER="admin" +TRINO_PASSWORD="admin" +TRINO_CATALOG="tpcds" +# The schema scripts/seed-hive.sh creates in the hive catalog, and the one every +# suite that needs a writable, transactional table works in. +HIVE_SCHEMA="tx" +KEYSTORE_PASSWORD="changeit" + +# Keycloak, for the `oauth` profile. 8444 on the host because Trino holds 8443; +# inside its own container Keycloak listens on 8443. +KEYCLOAK_HOST="localhost" +KEYCLOAK_HTTPS_PORT=8444 +KEYCLOAK_REALM="trino" +# The browser-facing base URL. This is also the `iss` claim Keycloak stamps into +# its tokens, because every generated URL derives from KC_HOSTNAME, so it is +# what Trino's `issuer` property has to be set to. +KEYCLOAK_ISSUER="https://$KEYCLOAK_HOST:$KEYCLOAK_HTTPS_PORT/realms/$KEYCLOAK_REALM" +# The same principal password.db and the client certificate resolve to, so the +# Trino session user is `admin` however the connection authenticated and +# `current_user` is comparable across all three. +KEYCLOAK_USER="$TRINO_USER" +KEYCLOAK_PASSWORD="$TRINO_PASSWORD" +OAUTH_CLIENT_ID="trino" +OAUTH_CLIENT_SECRET="trino-test-client-secret" + +# MinIO and the spooling protocol, for the `spooling` profile. +# MinIO's port is not published. Under +# protocol.spooling.retrieval-mode=coordinator_proxy the client never reaches +# object storage; only the coordinator does, over the compose network. +# +# Exported, because compose.yaml interpolates these three rather than repeating +# them: Trino gets them substituted into spooling-manager.properties and MinIO +# gets them from its environment, and a mismatch would surface only when a query +# tried to spool. The defaults in compose.yaml exist so `docker compose logs` +# still parses the file outside the harness. +export MINIO_ACCESS_KEY="minioadmin" +export MINIO_SECRET_KEY="minioadmin" +export SPOOLING_BUCKET="spooling" +# A 256-bit base64 key, fixed rather than generated per setup: the stack is +# reproducible and nothing here is a real secret. +SPOOLING_SECRET="W0PUdc6us24Z5Ki2Oi92/iaLd8Oksfxge59U2EHmKwo=" + +# Every profile this stack knows about. `--profile all` expands to this. +ALL_PROFILES="oauth spooling" + +mkdir -p "$GENERATED" + +# `docker compose` with the right file, from the right directory: compose +# resolves relative volume paths against the compose file's own directory. +compose() { + (cd "$STACK_DIR" && COMPOSE_PROFILES="${PROFILES:-}" docker compose -f "$COMPOSE_FILE" "$@") +} + +# parse_profiles . Normalises a comma or space separated list, expands +# `all`, and rejects an unknown name rather than starting a stack that silently +# lacks what was asked for. +parse_profiles() { + local raw="${1:-}" out=() p + raw="${raw//,/ }" + for p in $raw; do + if [[ "$p" == "all" ]]; then + # shellcheck disable=SC2206 # word splitting is intended here + out=($ALL_PROFILES) + break + fi + if [[ " $ALL_PROFILES " != *" $p "* ]]; then + echo "ERROR: unknown profile '$p'. Known: $ALL_PROFILES all" >&2 + exit 2 + fi + out+=("$p") + done + local IFS=, + echo "${out[*]:-}" +} + +# service_running . True while its container exists and is running. +service_running() { + local id + id="$(compose ps -q "$1" 2>/dev/null)" || return 1 + [[ -n "$id" ]] || return 1 + [[ "$(docker inspect -f '{{.State.Running}}' "$id" 2>/dev/null)" == "true" ]] +} + +# wait_for +# +# The command is run in this shell, so a shell function works and the nested +# quoting a `bash -c "curl ... | grep ..."` would need does not arise. +# +# The wait gives up the moment 's container stops running, and prints +# its log either way. A coordinator that rejects its own configuration exits +# within seconds and stays exited, so spending the whole budget on it wastes +# minutes and then reports a timeout, while the log says exactly which property +# is wrong. +wait_for() { + local service="$1" what="$2" limit="$3"; shift 3 + local waited=0 + while ! "$@" &>/dev/null; do + if ! service_running "$service"; then + echo "ERROR: the $service container is not running. Its last log lines:" >&2 + compose logs --tail 40 "$service" >&2 + return 1 + fi + if (( waited >= limit )); then + echo "ERROR: $what did not become ready in ${limit}s. Last log lines:" >&2 + compose logs --tail 40 "$service" >&2 + return 1 + fi + sleep 5 + waited=$(( waited + 5 )) + echo " Waiting for $what... (${waited}s)" + done + echo "$what is ready." +} + +# wait_for_init +# +# The counterpart of wait_for for a one-shot container, whose success *is* +# exiting. `docker compose wait` cannot serve here: it considers only running +# containers and answers "no containers for project" once the container has +# finished, which for an init container is the usual case by the time anything +# asks. +wait_for_init() { + local service="$1" limit="$2" waited=0 id state + while true; do + id="$(compose ps -aq "$service" 2>/dev/null || true)" + if [[ -n "$id" ]]; then + state="$(docker inspect -f '{{.State.Status}}:{{.State.ExitCode}}' "$id" 2>/dev/null || true)" + case "$state" in + exited:0) + echo "$service completed." + return 0 + ;; + exited:*) + echo "ERROR: $service exited with code ${state#exited:}. Its log:" >&2 + compose logs --tail 40 "$service" >&2 + return 1 + ;; + esac + fi + if (( waited >= limit )); then + echo "ERROR: $service did not complete in ${limit}s. Its log:" >&2 + compose logs --tail 40 "$service" >&2 + return 1 + fi + sleep 2 + waited=$(( waited + 2 )) + echo " Waiting for $service... (${waited}s)" + done +} diff --git a/integration-tests/scripts/run-tests.sh b/integration-tests/scripts/run-tests.sh new file mode 100755 index 0000000..80116d9 --- /dev/null +++ b/integration-tests/scripts/run-tests.sh @@ -0,0 +1,157 @@ +#!/usr/bin/env bash +# Runs Trino integration tests (Linux) and optionally Windows VM tests. +# Calls setup.sh automatically if Trino is not already running. +# Tears down the Docker Compose stack on exit (unless --skip-delete is passed). +# +# Usage: +# ./integration-tests/run-tests.sh # Linux tests only +# ./integration-tests/run-tests.sh --windows # Linux + Windows VM tests +# ./integration-tests/run-tests.sh --skip-build # skip the cargo build (also passed to windows_test.py) +# ./integration-tests/run-tests.sh --skip-delete # keep the stack running afterwards +# ./integration-tests/run-tests.sh --suite tls # only suites whose name matches +set -euo pipefail +# shellcheck source-path=SCRIPTDIR source=lib.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +RUN_WINDOWS=false +SKIP_DELETE=false +SKIP_BUILD=false +WINDOWS_EXTRA_ARGS=() + +SUITE_FILTER="" +while [[ $# -gt 0 ]]; do + case "$1" in + --windows) RUN_WINDOWS=true; shift ;; + --skip-delete) SKIP_DELETE=true; shift ;; + --skip-build) SKIP_BUILD=true; WINDOWS_EXTRA_ARGS+=("$1"); shift ;; + --suite) SUITE_FILTER="$2"; shift 2 ;; + --suite=*) SUITE_FILTER="${1#*=}"; shift ;; + *) WINDOWS_EXTRA_ARGS+=("$1"); shift ;; + esac +done + +if [[ "$SKIP_DELETE" == false ]]; then + trap 'echo "=== Tearing down the stack ===" && "$SCRIPT_DIR/teardown.sh"' EXIT +fi + +# --- Set the stack up if it has not been --- +# Two conditions, and neither implies the other. stack.env is what the suites +# read, so a running coordinator without it is unusable. But `generated/` +# survives a teardown, so the file on its own says only that the stack was +# configured once, possibly in a previous session. +# +# Checking the file alone let a whole run execute against a coordinator that was +# not there. That reports as a plausible mix rather than as an error: every +# check needing the server fails with `unable to reach Trino server`, while the +# ones asserting a *refusal* pass, because a refusal is what they wanted and a +# dead port supplies one. A suite must not be able to pass for that reason. +if [[ ! -f "$STACK_ENV" ]]; then + echo "=== Stack not set up, calling setup.sh ===" + "$SCRIPT_DIR/setup.sh" +elif ! service_running trino; then + echo "=== Stack configured but not running, calling setup.sh ===" + "$SCRIPT_DIR/setup.sh" +fi + +# One description of the stack, shared with the suites. Carries ODBCSYSINI and +# ODBCINI, so the Driver Manager and the suites cannot disagree about which +# config they are reading. +set -a +# shellcheck source=/dev/null +source "$STACK_ENV" +set +a + +# --- Rebuild the driver --- +# setup.sh also builds, but it is skipped when Trino is already running. Without +# this, editing driver source and re-running would silently test the previous +# .so. cargo is incremental, so this is a no-op when nothing changed. +if [[ "$SKIP_BUILD" == false ]]; then + echo "=== Building stackable-odbc-trino ===" + (cd "$PROJECT_DIR" && cargo build) +fi + +# --- the suite registry --- +# Read from suites/registry.py, which windows/windows_test.py reads too, so a +# suite is added in one place rather than in one runner and not the other. +# +# Each line is name|required profile|command. An empty profile means the core +# stack. A suite whose profile is not active is SKIPPED and says which profile +# would enable it: an unrun suite must never be printable as a passing one. +# Connection strings are built from stack.env, so a port, a credential or a +# certificate path is stated in exactly one place. +export TEST_DIR STACK_ENV + +mapfile -t SUITES < <(python3 "$TEST_DIR/suites/registry.py" --bash) +if [[ ${#SUITES[@]} -eq 0 ]]; then + echo "ERROR: suites/registry.py listed no suites" >&2 + exit 1 +fi + +failed_suites=() +skipped_suites=() + +for entry in "${SUITES[@]}"; do + name="${entry%%|*}"; rest="${entry#*|}" + need="${rest%%|*}"; cmd="${rest#*|}" + + if [[ -n "$SUITE_FILTER" && "$name" != *"$SUITE_FILTER"* ]]; then + continue + fi + if [[ -n "$need" && ",${PROFILES:-}," != *",$need,"* ]]; then + echo "SKIP $name: profile '$need' is not active (setup.sh --profile $need)" + skipped_suites+=("$name") + continue + fi + + echo "=== Running $name ===" + if ! eval "$cmd"; then + failed_suites+=("$name") + fi +done + +# --- Linux: Rust FFI integration tests --- +# Outside the registry because it is cargo, not a Python suite, and its output +# convention is different. It still honours --suite and still reports into the +# same summary, so a failure here cannot abort the run before the totals print. +# +# Only FFI tests are run. The backend::tests integration tests use a separate +# TrinoConnection with its own reqwest pool, and running both groups against +# the same coordinator causes intermittent connection pool corruption. Run +# those in isolation with: +# cargo test -- --ignored backend +if [[ -z "$SUITE_FILTER" || "ffi" == *"$SUITE_FILTER"* ]]; then + echo "=== Running ffi (cargo) ===" + if ! (cd "$PROJECT_DIR" && cargo test -- --ignored ffi_integration_tests); then + failed_suites+=("ffi (cargo)") + fi +fi + +# --- Windows VM tests (optional) --- +# Before the summary, so its result is part of the single verdict rather than +# unreachable behind a Linux failure. +if [[ "$RUN_WINDOWS" == true ]]; then + echo "=== Running windows (VM) ===" + # The filter applies to both runners: they read one registry, so --suite + # naming a suite must not silently mean "on Linux only". + if [[ -n "$SUITE_FILTER" ]]; then + WINDOWS_EXTRA_ARGS+=(--suite "$SUITE_FILTER") + fi + if ! uv run --with pywinrm python3 "$TEST_DIR/windows/windows_test.py" "${WINDOWS_EXTRA_ARGS[@]+"${WINDOWS_EXTRA_ARGS[@]}"}"; then + failed_suites+=("windows (VM)") + fi +fi + +# --- suite summary --- +echo "" +echo "=== Suite summary ===" +# Guarded: `printf '%s\n' "${empty[@]}"` still prints one empty line, which in +# a summary reads as an unnamed skipped suite. +if [[ ${#skipped_suites[@]} -gt 0 ]]; then + printf ' SKIP %s\n' "${skipped_suites[@]}" +fi +if [[ ${#failed_suites[@]} -gt 0 ]]; then + printf ' FAIL %s\n' "${failed_suites[@]}" + echo "${#failed_suites[@]} suite(s) failed" + exit 1 +fi +echo "all selected suites passed" diff --git a/integration-tests/scripts/seed-hive.sh b/integration-tests/scripts/seed-hive.sh new file mode 100755 index 0000000..9473ede --- /dev/null +++ b/integration-tests/scripts/seed-hive.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +# Creates the hive catalog's schema, which is the one thing sql-standard +# security will not let an ordinary connection do. +# +# Under hive.security=sql-standard, CREATE SCHEMA requires the admin role, so a +# suite connecting normally gets `Access Denied: Cannot create schema`. Once the +# schema exists and admin owns it, everything else an ordinary connection needs +# works without a role: creating tables, writing, reading and granting. So this +# seeds exactly the schema and stops. +# +# Idempotent, and run on every setup: the file metastore lives in the +# coordinator's writable layer, so recreating the container starts it empty. +set -euo pipefail +# shellcheck source-path=SCRIPTDIR source=lib.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +BASE="https://$TRINO_HOST:$TRINO_HTTPS_PORT" + +# Submit a statement and follow nextUri to the end. +# +# The drain is not optional: Trino runs a statement as the client pages it, so a +# POST whose response is never followed leaves the DDL unexecuted. +trino_run() { + local sql="$1" role="${2:-}" + local curl_args=( + -sf --cacert "$CERT_DIR/ca.crt" -u "$TRINO_USER:$TRINO_PASSWORD" + -H "X-Trino-Catalog: hive" -H "X-Trino-Schema: $HIVE_SCHEMA" + ) + if [[ -n "$role" ]]; then + curl_args+=(-H "X-Trino-Role: hive=ROLE{$role}") + fi + + local response next + response="$(curl "${curl_args[@]}" -X POST -d "$sql" "$BASE/v1/statement")" + while :; do + if [[ "$response" == *'"failureInfo"'* ]]; then + echo "ERROR: seeding the hive catalog failed on: $sql" >&2 + echo "$response" >&2 + return 1 + fi + # The response is JSON and this is a single flat field, so a sed match + # is enough; the stack scripts carry no jq dependency. + next="$(sed -n 's/.*"nextUri":"\([^"]*\)".*/\1/p' <<<"$response")" + if [[ -z "$next" ]]; then + return 0 + fi + response="$(curl "${curl_args[@]}" "$next")" + done +} + +trino_run "CREATE SCHEMA IF NOT EXISTS hive.$HIVE_SCHEMA" admin +echo "Seeded hive.$HIVE_SCHEMA" diff --git a/integration-tests/scripts/setup.sh b/integration-tests/scripts/setup.sh new file mode 100755 index 0000000..34c5973 --- /dev/null +++ b/integration-tests/scripts/setup.sh @@ -0,0 +1,143 @@ +#!/usr/bin/env bash +# Brings up the test stack: generates certificates, secrets and ODBC config, +# starts Docker Compose, waits for readiness, and builds the driver. +# +# The stack keeps running after this exits. Use run-tests.sh to run the suites, +# or scripts/teardown.sh to stop. +# +# Usage: +# ./integration-tests/setup.sh +# ./integration-tests/setup.sh --profile oauth,spooling +# PROFILES=all ./integration-tests/setup.sh +set -euo pipefail +# shellcheck source-path=SCRIPTDIR source=lib.sh +source "$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/lib.sh" + +PROFILES_ARG="${PROFILES:-}" +while [[ $# -gt 0 ]]; do + case "$1" in + --profile) PROFILES_ARG="$2"; shift 2 ;; + --profile=*) PROFILES_ARG="${1#*=}"; shift ;; + *) echo "unknown argument: $1" >&2; exit 2 ;; + esac +done +PROFILES="$(parse_profiles "$PROFILES_ARG")" +export PROFILES + +# --- Dependency checks (fail fast) --- +missing=() +docker compose version &>/dev/null || missing+=("'docker compose' v2 plugin: sudo apt install docker-compose-plugin OR https://docs.docker.com/compose/install/") +command -v cargo &>/dev/null || missing+=("'cargo': https://rustup.rs") +command -v curl &>/dev/null || missing+=("'curl': sudo apt install curl") +command -v openssl &>/dev/null || missing+=("'openssl': sudo apt install openssl") +# keytool builds the truststore: openssl cannot write a PKCS12 that Java reads +# as a trust anchor. See the comment in gen-certs.sh. +command -v keytool &>/dev/null || missing+=("'keytool': sudo apt install default-jdk-headless") +if [[ ! -f "$GENERATED/password.db" ]] && ! command -v htpasswd &>/dev/null && ! python3 -c "import bcrypt" 2>/dev/null; then + missing+=("'htpasswd' or 'python3-bcrypt': sudo apt install apache2-utils OR pip install bcrypt") +fi +if [[ ${#missing[@]} -gt 0 ]]; then + echo "ERROR: Missing required dependencies:" >&2 + printf ' - %s\n' "${missing[@]}" >&2 + exit 1 +fi + +echo "=== Profiles: ${PROFILES:-} ===" + +echo "=== Generating secrets ===" +"$SCRIPT_DIR/gen-secrets.sh" + +echo "=== Generating TLS material ===" +"$SCRIPT_DIR/gen-certs.sh" + +echo "=== Assembling the Keycloak realm ===" +"$SCRIPT_DIR/gen-keycloak-config.sh" + +echo "=== Assembling Trino config ===" +"$SCRIPT_DIR/gen-trino-config.sh" + +echo "=== Starting the stack ===" +# A profile change must recreate the coordinator. Compose would start the new +# service and leave trino running on its already-assembled config, so +# enabling a profile would appear to do nothing at all. +PROFILE_STAMP="$GENERATED/.profiles" +RECREATE=() +if [[ -f "$PROFILE_STAMP" && "$(cat "$PROFILE_STAMP")" != "$PROFILES" ]]; then + was="$(cat "$PROFILE_STAMP")" + echo "Profiles changed (${was:-} -> ${PROFILES:-}); recreating Trino" + RECREATE=(--force-recreate) +fi +printf '%s' "$PROFILES" > "$PROFILE_STAMP" + +compose up -d "${RECREATE[@]+"${RECREATE[@]}"}" + +if [[ ",$PROFILES," == *",spooling,"* ]]; then + echo "=== Waiting for the MinIO bucket ===" + # Trino does not create the bucket, and a query only discovers the absence + # when it tries to spool, so the failure would land far from its cause. + wait_for_init minio-init 120 +fi + +# Readiness probes are shell functions, not `bash -c` strings: wait_for runs +# "$@" in this shell, so a function works directly and the nested quoting a +# curl-plus-grep pipeline inside a -c argument would need goes away entirely. +trino_ready() { + # "starting":false means the coordinator finished its startup sequence and + # can schedule work. HTTP 200 alone is not enough: /v1/info answers while + # the node is still initialising, which surfaces later as + # NO_NODES_AVAILABLE on a data query. + curl -sf --cacert "$CERT_DIR/ca.crt" \ + "https://$TRINO_HOST:$TRINO_HTTPS_PORT/v1/info" | grep -q '"starting":false' +} + +postgresql_catalog_ready() { + # -u rather than X-Trino-User: PASSWORD authentication is mandatory now + # that there is no allow-insecure-over-http path to slip through, and the + # authenticated identity supplies the user. + curl -sf --cacert "$CERT_DIR/ca.crt" -u "$TRINO_USER:$TRINO_PASSWORD" \ + -X POST "https://$TRINO_HOST:$TRINO_HTTPS_PORT/v1/statement" \ + -H "X-Trino-Catalog: postgresql" \ + -H "X-Trino-Schema: public" \ + -d "SELECT 1 FROM postgresql.public.customers LIMIT 1" | grep -q '"stats"' +} + +echo "=== Waiting for Trino ===" +wait_for trino "Trino" 600 trino_ready + +echo "=== Waiting for the postgresql catalog ===" +wait_for trino "postgresql catalog" 60 postgresql_catalog_ready + +echo "=== Seeding the hive catalog ===" +"$SCRIPT_DIR/seed-hive.sh" + +if [[ ",$PROFILES," == *",oauth,"* ]]; then + keycloak_ready() { + # The discovery document rather than /health/ready: this is the endpoint + # whose absence breaks the flow, and it proves the realm import + # finished, which a health probe does not. + curl -sf --cacert "$CERT_DIR/ca.crt" \ + "$KEYCLOAK_ISSUER/.well-known/openid-configuration" | + grep -q '"token_endpoint"' + } + echo "=== Waiting for Keycloak ===" + wait_for keycloak "Keycloak" 180 keycloak_ready +fi + +echo "=== Building the driver ===" +(cd "$PROJECT_DIR" && cargo build) + +echo "=== Writing ODBC config ===" +"$SCRIPT_DIR/gen-odbc-config.sh" + +cat < certificate for internal communication, +# which Jetty serves as the default when a client sends no SNI, see the note +# in suites/test_tls.py about why that rules out testing by IP address. +internal-communication.https.required=true + +# localhost inside the container is the coordinator itself, and the leaf +# carries DNS:localhost. Trusting it needs the CA in the JVM truststore; see +# jvm.config. +discovery.uri=https://localhost:8443 diff --git a/integration-tests/stack/trino/base/jvm.config b/integration-tests/stack/trino/base/jvm.config new file mode 100644 index 0000000..a1aed35 --- /dev/null +++ b/integration-tests/stack/trino/base/jvm.config @@ -0,0 +1,19 @@ +-server +-Xmx2G +-XX:+UseG1GC +-XX:G1HeapRegionSize=32M +-XX:+ExplicitGCInvokesConcurrent +-XX:+HeapDumpOnOutOfMemoryError +-XX:+ExitOnOutOfMemoryError +-Djdk.attach.allowAttachSelf=true +-Djdk.nio.maxCachedBufferSize=2000000 +-Dfile.encoding=UTF-8 +# The coordinator must trust its own certificate: with the plaintext listener +# off, discovery.uri is https and the announcement loop talks to itself over +# TLS. This *replaces* the JDK default truststore rather than adding to it, so +# public certificate authorities are no longer trusted. That is correct for a test +# stack whose only TLS peers are this CA's leaves, and what lets Trino trust +# Keycloak later with no further work. +-Djavax.net.ssl.trustStore=/etc/trino-secrets/tls/truststore.p12 +-Djavax.net.ssl.trustStoreType=PKCS12 +-Djavax.net.ssl.trustStorePassword=changeit diff --git a/integration-tests/stack/trino/base/log.properties b/integration-tests/stack/trino/base/log.properties new file mode 100644 index 0000000..9b30ad4 --- /dev/null +++ b/integration-tests/stack/trino/base/log.properties @@ -0,0 +1 @@ +io.trino=DEBUG diff --git a/integration-tests/stack/trino/base/node.properties b/integration-tests/stack/trino/base/node.properties new file mode 100644 index 0000000..565c527 --- /dev/null +++ b/integration-tests/stack/trino/base/node.properties @@ -0,0 +1,3 @@ +node.environment=test +node.id=trino-odbc-test +node.data-dir=/data/trino diff --git a/integration-tests/stack/trino/base/password-authenticator.properties b/integration-tests/stack/trino/base/password-authenticator.properties new file mode 100644 index 0000000..62d6167 --- /dev/null +++ b/integration-tests/stack/trino/base/password-authenticator.properties @@ -0,0 +1,2 @@ +password-authenticator.name=file +file.password-file=/etc/trino-secrets/password.db diff --git a/integration-tests/stack/trino/oauth/.gitkeep b/integration-tests/stack/trino/oauth/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/integration-tests/stack/trino/oauth/config.properties.d/oauth2.properties b/integration-tests/stack/trino/oauth/config.properties.d/oauth2.properties new file mode 100644 index 0000000..2d4fbcb --- /dev/null +++ b/integration-tests/stack/trino/oauth/config.properties.d/oauth2.properties @@ -0,0 +1,28 @@ +# OIDC discovery is off, and every endpoint is named explicitly, because +# discovery is fetched from {issuer}/.well-known/openid-configuration and +# `localhost:8444` inside this container is Trino itself. +# +# The split is the point. `auth-url` is the only frontchannel URL and has to be +# reachable from the host, where the browser runs. `token-url` and `jwks-url` +# are the only backchannel URLs and have to be reachable from the compose +# network. `issuer` matches the `iss` claim Keycloak stamps, which derives from +# its KC_HOSTNAME and is the browser-facing form either way. +http-server.authentication.oauth2.oidc.discovery=false +http-server.authentication.oauth2.issuer=https://localhost:8444/realms/trino +http-server.authentication.oauth2.auth-url=https://localhost:8444/realms/trino/protocol/openid-connect/auth +http-server.authentication.oauth2.token-url=https://keycloak:8443/realms/trino/protocol/openid-connect/token +http-server.authentication.oauth2.jwks-url=https://keycloak:8443/realms/trino/protocol/openid-connect/certs + +# `oidc.use-userinfo-endpoint` is deliberately absent. It belongs to the config +# class Trino binds only when discovery is enabled, so with discovery off airlift +# reports it as an unused property and the coordinator refuses to start. There is +# no userinfo endpoint to disable here: discovery is what would have supplied +# one. +http-server.authentication.oauth2.client-id=@OAUTH_CLIENT_ID@ +http-server.authentication.oauth2.client-secret=@OAUTH_CLIENT_SECRET@ +http-server.authentication.oauth2.scopes=openid + +# The default is `sub`, a Keycloak UUID. `preferred_username` makes the session +# user `admin`, the same principal password.db and the client certificate +# resolve to, so a query's `current_user` is the same however it authenticated. +http-server.authentication.oauth2.principal-field=preferred_username diff --git a/integration-tests/stack/trino/spooling/.gitkeep b/integration-tests/stack/trino/spooling/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/integration-tests/stack/trino/spooling/config.properties.d/spooling.properties b/integration-tests/stack/trino/spooling/config.properties.d/spooling.properties new file mode 100644 index 0000000..7f8f8dc --- /dev/null +++ b/integration-tests/stack/trino/spooling/config.properties.d/spooling.properties @@ -0,0 +1,19 @@ +protocol.spooling.enabled=true +protocol.spooling.shared-secret-key=@SPOOLING_SECRET@ + +# coordinator_proxy is the only mode that does not require the *client* to reach +# object storage, and the driver runs on the host where `minio` does not resolve. +# The default is storage, which hands the client a pre-signed URI naming the +# minio service by its compose name. +protocol.spooling.retrieval-mode=coordinator_proxy + +# Far below the 8MB and 16MB defaults, so a few thousand rows produce several +# segments and the retrieval loop is exercised in seconds rather than needing +# tens of megabytes of result. +protocol.spooling.initial-segment-size=16kB +protocol.spooling.max-segment-size=64kB + +# protocol.spooling.inlining is left at its default of enabled, because that is +# what a real deployment does. The consequence belongs in the test rather than in +# this file: the first 1000 rows, up to 128kB, come back inline, so a query has +# to exceed that before anything is spooled at all. diff --git a/integration-tests/stack/trino/spooling/spooling-manager.properties b/integration-tests/stack/trino/spooling/spooling-manager.properties new file mode 100644 index 0000000..3415aa2 --- /dev/null +++ b/integration-tests/stack/trino/spooling/spooling-manager.properties @@ -0,0 +1,15 @@ +spooling-manager.name=filesystem +fs.location=s3://@SPOOLING_BUCKET@/ +fs.s3.enabled=true + +# Defaults to true, which is SSE-C. MinIO here serves plain HTTP inside the +# compose network with no key material configured, so segment encryption has to +# be off for a segment to be written at all. +fs.segment.encryption=false + +s3.endpoint=http://minio:9000 +s3.region=us-east-1 +s3.aws-access-key=@MINIO_ACCESS_KEY@ +s3.aws-secret-key=@MINIO_SECRET_KEY@ +# MinIO serves buckets as a path, not as a subdomain of the endpoint. +s3.path-style-access=true diff --git a/integration-tests/suites/harness.py b/integration-tests/suites/harness.py new file mode 100644 index 0000000..c78c493 --- /dev/null +++ b/integration-tests/suites/harness.py @@ -0,0 +1,164 @@ +#!/usr/bin/env python3 +"""Shared machinery for the integration suites. + +Standard library only, and `pyodbc` is imported lazily inside `Stack.connect`. +`test_c_abi.py` and `test_type_matrix.py` load the driver's `.so` with `ctypes` +and depend on neither a Driver Manager, nor `uv`, nor pyodbc. A module-scope +pyodbc import here would give them all three silently. +""" + +import os +import time + +DEFAULT_STACK_ENV = os.path.join( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))), + "generated", + "stack.env", +) + + +class Results: + """PASS/FAIL/NOTE/SKIP accounting for one suite run. + + `bad` rather than `fail` because `test_type_matrix.py` has its own + module-level `fail(kind, detail)` with different semantics, and a silent + collision is worse than an unlovely name. + """ + + def __init__(self, title): + self.title = title + self.passed = 0 + self.failed = 0 + self.notes = 0 + self.skipped = 0 + + def ok(self, label, detail=""): + self.passed += 1 + print(f"PASS {label}{': ' + detail if detail else ''}") + + def bad(self, label, detail=""): + self.failed += 1 + print(f"FAIL {label}{': ' + detail if detail else ''}") + + def check(self, label, cond, detail=""): + """Record a boolean assertion. Returns the condition, so a caller can + skip dependent work without re-evaluating it.""" + if cond: + self.ok(label, detail) + else: + self.bad(label, detail) + return bool(cond) + + def run(self, label, fn): + """Run a callable, recording an exception as a failure with its + message. Prints elapsed time: a suite that slows down is a finding.""" + t0 = time.monotonic() + try: + fn() + print(f"PASS {label} ({time.monotonic() - t0:.1f}s)") + self.passed += 1 + except Exception as e: + print(f"FAIL {label} ({time.monotonic() - t0:.1f}s): {e}") + self.failed += 1 + + def note(self, label, text): + """An observation the driver is entitled to make either way. Not a + gap, and never counted as a pass.""" + self.notes += 1 + print(f"NOTE {label}: {text}") + + def skip(self, label, reason): + """A test that did not run. The reason is mandatory: an unrun test must + never be indistinguishable from a passing one.""" + self.skipped += 1 + print(f"SKIP {label}: {reason}") + + def summary(self): + parts = [f"{self.passed} passed", f"{self.failed} failed"] + if self.skipped: + parts.append(f"{self.skipped} skipped") + if self.notes: + parts.append(f"{self.notes} notes") + print(f"\n{', '.join(parts)}") + return 1 if self.failed else 0 + + +class Stack: + """The running test stack, as described by `generated/stack.env`. + + One file describes the stack and both bash and Python read it, so a port, + a credential or a certificate path is stated once. Every suite builds its + connection strings from here rather than hardcoding one, which is what + keeps a change like the move to HTTPS out of every suite at once. + """ + + def __init__(self, values): + self._values = values + + @classmethod + def load(cls, path=None): + path = path or os.environ.get("ODBC_TEST_STACK_ENV") or DEFAULT_STACK_ENV + if not os.path.exists(path): + raise SystemExit( + f"stack.env not found at {path}\nrun: ./integration-tests/setup.sh" + ) + values = {} + with open(path, encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + key, _, value = line.partition("=") + value = value.strip() + if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + value = value[1:-1] + values[key.strip()] = value + return cls(values) + + def get(self, key, default=None): + return self._values.get(key, default) + + @property + def profiles(self): + return [p for p in self.get("PROFILES", "").split(",") if p] + + def has_profile(self, name): + return name in self.profiles + + @property + def driver_ref(self): + """What `Driver=` in a connection string names. + + On Linux a Driver Manager accepts the library path, so `DRIVER_PATH` + serves both this and the ctypes suites that dlopen the file. On Windows + the two are different strings: the Driver Manager wants the name the + driver is registered under, and only the ctypes suites want the DLL's + path. `DRIVER_NAME` carries the former where they differ. + """ + return self.get("DRIVER_NAME") or self.get("DRIVER_PATH") + + def conn_str(self, **overrides): + """A DSN-less connection string. An override of `None` removes the key, + which is how a suite tests, say, connecting with no `Password`.""" + params = { + "Driver": self.driver_ref, + "Host": self.get("TRINO_HOST"), + "Port": self.get("TRINO_HTTPS_PORT"), + "User": self.get("TRINO_USER"), + "Password": self.get("TRINO_PASSWORD"), + "Protocol": "https", + "Catalog": self.get("TRINO_CATALOG"), + "Certificate": self.get("CA_CERT"), + } + params.update(overrides) + return ";".join(f"{k}={v}" for k, v in params.items() if v is not None) + + def dsn(self, name): + return f"DSN={name}" + + def connect(self, **overrides): + """Connect through the Driver Manager. pyodbc is imported here rather + than at module scope so the ctypes suites keep their zero dependencies.""" + import pyodbc + + return pyodbc.connect(self.conn_str(**overrides), autocommit=True) diff --git a/integration-tests/suites/oauth_browser.py b/integration-tests/suites/oauth_browser.py new file mode 100644 index 0000000..56e4fdb --- /dev/null +++ b/integration-tests/suites/oauth_browser.py @@ -0,0 +1,192 @@ +#!/usr/bin/env python3 +"""The browser half of Trino's interactive OAuth 2.0 flow, for tests. + +The driver presents a login URL through `open::that`, which on Linux runs +`xdg-open` first and unconditionally. `open` 5.4.0 ignores `$BROWSER`, as its own +documentation says, so `test_oauth.py` writes a shim named `xdg-open` that execs +this file and prepends its directory to PATH before connecting. + +**This script always exits 0.** A non-zero exit sends `open::that` on to the next +opener in its list, `gio open`, which opens a real browser window on any machine +with a desktop session. Outcomes are reported through the JSONL file named by +ODBC_TEST_OAUTH_RECORD instead, one object per invocation. That file is also +what turns a broken login into a diagnosis rather than a suite that waits out +`ExternalAuthenticationTimeout` with nothing to show. + +Modes, from ODBC_TEST_OAUTH_MODE: + + login complete the login against Keycloak + noop record the URL and complete nothing, so the login is abandoned + deny answer Trino's callback with error=access_denied, which is the + redirect an identity provider sends when the person declines + +Usage, normally through the shim: + + ODBC_TEST_OAUTH_MODE=login ODBC_TEST_OAUTH_RECORD=/tmp/rec.jsonl \\ + python3 integration-tests/suites/oauth_browser.py +""" + +import html as html_mod +import http.cookiejar +import json +import os +import re +import ssl +import sys +import time +import urllib.error +import urllib.parse +import urllib.request + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from harness import Stack # noqa: E402 + +# Keycloak's login form. Its action carries a single-use session code, so it has +# to be read out of the page rather than constructed. The id and the action can +# appear in either order in the tag, hence two expressions rather than one. +LOGIN_FORM_TAG = re.compile(r"]*kc-form-login[^>]*>", re.I) +ANY_FORM_TAG = re.compile(r"]*>", re.I) +FORM_ACTION = re.compile(r'action="([^"]+)"', re.I) + +TIMEOUT_SECONDS = 30 + + +def record(path, entry): + if not path: + return + with open(path, "a", encoding="utf-8") as f: + f.write(json.dumps(entry) + "\n") + + +def build_opener(ca_cert): + """An opener that trusts the test CA and keeps cookies. + + Keycloak carries its authentication session in cookies and the CA is not a + public one, so both are required; a default opener fails the handshake. + """ + ctx = ssl.create_default_context(cafile=ca_cert) + return urllib.request.build_opener( + urllib.request.HTTPSHandler(context=ctx), + urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar()), + ) + + +def fetch(op, url, data=None): + """GET, or POST when `data` is given, following redirects. + + Returns (status, final_url, body). An HTTP error status is returned rather + than raised: Trino answers its own callback with a status this script does + not control, and a 4xx there is still a delivered redirect. + """ + req = urllib.request.Request( + url, + data=urllib.parse.urlencode(data).encode() if data else None, + headers={"User-Agent": "stackable-odbc-trino-test-browser"}, + ) + try: + with op.open(req, timeout=TIMEOUT_SECONDS) as resp: + return resp.status, resp.geturl(), resp.read().decode("utf-8", "replace") + except urllib.error.HTTPError as e: + return e.code, e.geturl(), e.read().decode("utf-8", "replace") + + +def login_form_action(page, base_url): + """The absolute URL the login form posts to, or None.""" + tag = LOGIN_FORM_TAG.search(page) or ANY_FORM_TAG.search(page) + if not tag: + return None + action = FORM_ACTION.search(tag.group(0)) + if not action: + return None + # The action is HTML-escaped in the page and may be relative. + return urllib.parse.urljoin(base_url, html_mod.unescape(action.group(1))) + + +def do_login(op, url, user, password): + """Follow the login URL to Keycloak, submit the form, land on the callback.""" + _status, page_url, page = fetch(op, url) + action = login_form_action(page, page_url) + if action is None: + raise RuntimeError(f"no login form at {page_url}; page begins {page[:200]!r}") + + _status, final_url, page = fetch( + op, action, {"username": user, "password": password, "credentialId": ""} + ) + if LOGIN_FORM_TAG.search(page): + # Keycloak re-renders the login page with an error rather than + # redirecting, so the credentials were rejected. + raise RuntimeError( + f"still on the Keycloak login page after posting credentials: {final_url}" + ) + return final_url + + +def do_deny(op, url): + """Answer Trino's callback the way a refusing identity provider would. + + The login URL redirects to Keycloak's authorization endpoint, whose query + carries both the `state` Trino signed and the `redirect_uri` it asked to be + called back on. Handing that state back on that URL with `error` set, and no + code, is exactly what an identity provider sends when the person declines, + so this needs no Keycloak interaction at all. + """ + _status, auth_url, _page = fetch(op, url) + query = urllib.parse.parse_qs(urllib.parse.urlparse(auth_url).query) + state = query.get("state", [None])[0] + redirect_uri = query.get("redirect_uri", [None])[0] + if not state or not redirect_uri: + raise RuntimeError(f"no state or redirect_uri on the authorization URL: {auth_url}") + + denied = ( + redirect_uri + + "?" + + urllib.parse.urlencode( + { + "error": "access_denied", + "error_description": "the test declined the login", + "state": state, + } + ) + ) + status, final_url, _page = fetch(op, denied) + return f"{final_url} (HTTP {status})" + + +def main(argv): + started = time.monotonic() + record_path = os.environ.get("ODBC_TEST_OAUTH_RECORD") + mode = os.environ.get("ODBC_TEST_OAUTH_MODE", "login") + url = argv[1] if len(argv) > 1 else "" + entry = {"mode": mode, "url": url} + + try: + if not url: + raise RuntimeError("no URL argument") + stack = Stack.load() + if mode == "noop": + entry["outcome"] = "presented" + elif mode == "login": + op = build_opener(stack.get("CA_CERT")) + entry["final_url"] = do_login( + op, url, stack.get("KEYCLOAK_USER"), stack.get("KEYCLOAK_PASSWORD") + ) + entry["outcome"] = "logged-in" + elif mode == "deny": + entry["final_url"] = do_deny(build_opener(stack.get("CA_CERT")), url) + entry["outcome"] = "denied" + else: + raise RuntimeError(f"unknown ODBC_TEST_OAUTH_MODE {mode!r}") + except Exception as e: # noqa: BLE001 - every failure is recorded, never raised + entry["outcome"] = "error" + entry["error"] = f"{type(e).__name__}: {e}" + + entry["seconds"] = round(time.monotonic() - started, 2) + record(record_path, entry) + # Always zero, whatever happened. A non-zero exit makes `open::that` try + # `gio open` next, and a real browser window appears. + return 0 + + +if __name__ == "__main__": + sys.exit(main(sys.argv)) diff --git a/integration-tests/suites/odbc_abi.py b/integration-tests/suites/odbc_abi.py new file mode 100644 index 0000000..93bfa06 --- /dev/null +++ b/integration-tests/suites/odbc_abi.py @@ -0,0 +1,170 @@ +#!/usr/bin/env python3 +"""The raw ODBC C ABI, for the suites that call it without a Driver Manager. + +`test_c_abi.py` and `test_oauth.py` both load a shared object with ctypes and +call the exported entry points directly. This module is the plumbing they share: +the wide-string helper, the signature declarations, and the diagnostic readers. + +`load` takes a path, so it serves the driver's own `.so` and unixODBC's +`libodbc.so.2` equally. `SQLRETURN` is a 16-bit `SQLSMALLINT`, and an undeclared +function leaves ctypes reading the return register as a 32-bit int, where +`SQL_ERROR` arrives as 65535 and every comparison against -1 silently fails. +That is why every entry point either suite calls is declared here. +""" + +import ctypes + +# --- handle types --- +SQL_HANDLE_ENV = 1 +SQL_HANDLE_DBC = 2 +SQL_HANDLE_STMT = 3 +SQL_HANDLE_DESC = 4 + +# --- return codes --- +SQL_SUCCESS = 0 +SQL_SUCCESS_WITH_INFO = 1 +SQL_NO_DATA = 100 +SQL_ERROR = -1 +SQL_INVALID_HANDLE = -2 + +SQL_NTS = -3 +SQL_NULL_HANDLE = None + +SQL_ATTR_ODBC_VERSION = 200 +SQL_OV_ODBC3 = 3 + +# --- SQLDriverConnect DriverCompletion --- +# Only NOPROMPT forbids the driver from prompting; the other three permit it. +# pyodbc passes NOPROMPT unconditionally, which is why an interactive +# authentication test cannot go through pyodbc at all. +SQL_DRIVER_NOPROMPT = 0 +SQL_DRIVER_COMPLETE = 1 +SQL_DRIVER_PROMPT = 2 +SQL_DRIVER_COMPLETE_REQUIRED = 3 + + +def w(s): + """A SQLWCHAR buffer for `s`. SQLWCHAR is 16-bit on Linux.""" + buf = ctypes.create_string_buffer(s.encode("utf-16-le") + b"\x00\x00") + return ctypes.cast(buf, ctypes.POINTER(ctypes.c_uint16)), buf + + +def load(path): + lib = ctypes.CDLL(path) + P = ctypes.c_void_p + W = ctypes.POINTER(ctypes.c_uint16) + S, I, L = ctypes.c_int16, ctypes.c_int32, ctypes.c_int64 + + sig = { + "SQLAllocHandle": ([S, P, ctypes.POINTER(P)], S), + "SQLFreeHandle": ([S, P], S), + "SQLSetEnvAttr": ([P, I, P, I], S), + "SQLGetEnvAttr": ([P, I, P, I, ctypes.POINTER(I)], S), + "SQLDriverConnectW": ([P, P, W, S, W, S, ctypes.POINTER(S), ctypes.c_uint16], S), + "SQLDisconnect": ([P], S), + "SQLExecDirectW": ([P, W, I], S), + "SQLPrepareW": ([P, W, I], S), + "SQLExecute": ([P], S), + "SQLFetch": ([P], S), + "SQLGetData": ([P, ctypes.c_uint16, S, P, L, ctypes.POINTER(L)], S), + "SQLNumResultCols": ([P, ctypes.POINTER(S)], S), + "SQLRowCount": ([P, ctypes.POINTER(L)], S), + "SQLCloseCursor": ([P], S), + "SQLFreeStmt": ([P, ctypes.c_uint16], S), + "SQLSetStmtAttrW": ([P, I, P, I], S), + "SQLGetStmtAttrW": ([P, I, P, I, ctypes.POINTER(I)], S), + "SQLSetConnectAttrW": ([P, I, P, I], S), + "SQLGetConnectAttrW": ([P, I, P, I, ctypes.POINTER(I)], S), + "SQLGetDiagRecW": ( + [S, P, S, W, ctypes.POINTER(I), W, S, ctypes.POINTER(S)], + S, + ), + "SQLGetInfoW": ([P, ctypes.c_uint16, P, S, ctypes.POINTER(S)], S), + "SQLCancel": ([P], S), + "SQLNumParams": ([P, ctypes.POINTER(S)], S), + # ParameterSizePtr is a SQLULEN, which is 64-bit on this platform; + # declaring it 32-bit would read half of it and half of the next slot. + # pyodbc exposes no equivalent, so ctypes is the only way to reach it. + "SQLDescribeParam": ( + [ + P, + ctypes.c_uint16, + ctypes.POINTER(S), + ctypes.POINTER(ctypes.c_uint64), + ctypes.POINTER(S), + ctypes.POINTER(S), + ], + S, + ), + "SQLBindParameter": ( + [P, ctypes.c_uint16, S, S, S, ctypes.c_size_t, S, P, L, ctypes.POINTER(L)], + S, + ), + # SQLRETURN is a 16-bit SQLSMALLINT: an undeclared function leaves + # ctypes reading a 32-bit register, where SQL_ERROR arrives as 65535. + "SQLEndTran": ([S, P, S], S), + "SQLTablePrivilegesW": ([P, W, S, W, S, W, S], S), + "SQLColumnPrivilegesW": ([P, W, S, W, S, W, S, W, S], S), + "SQLProceduresW": ([P, W, S, W, S, W, S], S), + "SQLProcedureColumnsW": ([P, W, S, W, S, W, S, W, S], S), + } + for name, (args, res) in sig.items(): + fn = getattr(lib, name) + fn.argtypes = args + fn.restype = res + return lib + + +def _diag_record(lib, htype, handle): + """Diagnostic record 1 as (sqlstate, message), or ('', '') when absent.""" + state = (ctypes.c_uint16 * 6)() + msg = (ctypes.c_uint16 * 1024)() + native = ctypes.c_int32(0) + textlen = ctypes.c_int16(0) + ret = lib.SQLGetDiagRecW( + htype, + handle, + 1, + ctypes.cast(state, ctypes.POINTER(ctypes.c_uint16)), + ctypes.byref(native), + ctypes.cast(msg, ctypes.POINTER(ctypes.c_uint16)), + 1024, + ctypes.byref(textlen), + ) + if ret not in (SQL_SUCCESS, SQL_SUCCESS_WITH_INFO): + return "", "" + return ( + "".join(chr(c) for c in state if c).strip(), + "".join(chr(c) for c in msg[: max(textlen.value, 0)]), + ) + + +def sqlstate(lib, htype, handle): + """The SQLSTATE of diagnostic record 1, or '' when there is none.""" + return _diag_record(lib, htype, handle)[0] + + +def diag_message(lib, htype, handle): + """The message text of diagnostic record 1, or '' when there is none. + + A SQLSTATE alone is not enough to diagnose a failed connect: the SQLSTATE + says what kind of failure it was, and the message says which of several + connection-string problems produced it. + """ + return _diag_record(lib, htype, handle)[1] + + +def read_wide_info(lib, dbc, info_type): + """A character-shaped SQLGetInfoW answer, as a str.""" + buf = (ctypes.c_uint16 * 256)() + length = ctypes.c_int16(0) + ret = lib.SQLGetInfoW( + dbc, + info_type, + ctypes.cast(buf, ctypes.c_void_p), + 512, + ctypes.byref(length), + ) + if ret not in (SQL_SUCCESS, SQL_SUCCESS_WITH_INFO): + return None + return "".join(chr(c) for c in buf[: max(length.value, 0) // 2]) diff --git a/integration-tests/suites/registry.py b/integration-tests/suites/registry.py new file mode 100644 index 0000000..005664a --- /dev/null +++ b/integration-tests/suites/registry.py @@ -0,0 +1,213 @@ +#!/usr/bin/env python3 +"""The suite registry: one list of suites, read by both runners. + +`scripts/run-tests.sh` and `windows/windows_test.py` each carried their own +idea of what the suites are. The Linux side grew to eleven entries while the +Windows side stayed at one, and nothing in either file said that was a +decision rather than an oversight. `SUITES` below is now the only list, and a +suite that does not run on Windows has to say why in its own entry. + +What the registry holds is facts about a suite, not the command that runs it. +The two runners invoke Python differently and cannot share a command string: +Linux runs `uv run --with pyodbc python3 ` in a shell, while Windows runs +an absolute interpreter over WinRM with the driver's logging environment set +and a log-retrieval step afterwards. Each renders its own invocation from +`argv` and `pyodbc`. + +The four-configuration connect matrix is deliberately *not* shared. Linux +crosses DSN names out of the generated `odbc.ini` with a DSN-less string; +Windows crosses a registry-registered DSN and connects by address for the +unverified cases, so that no SNI is sent. They are two different matrices that +happen to have the same four labels. `LINUX_CONFIGS` is here because this file +renders the Linux commands; the Windows configurations live in +`windows/windows_test.py`. + +Run this file to see what the Linux runner will execute: + + python3 integration-tests/suites/registry.py --bash +""" + +import os +import shlex +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from harness import Stack # noqa: E402 + +TEST_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) +PROJECT_DIR = os.path.dirname(TEST_DIR) + + +class Suite: + """One entry in the registry. + + `profile` the compose profile the suite needs, or "" for the base stack. + `argv` how the suite takes its configuration: + "conn" a connection string + "driver+conn" the driver's library path, then a connection string + "driver" the driver's library path + "none" nothing; it reads `stack.env` itself + `pyodbc` whether the suite imports pyodbc. The ctypes suites do not, and + running them under `uv run --with pyodbc` would hide a missing + dependency behind an installed one. + `matrix` run once per connect configuration rather than once. + `windows` True to run on the Windows VM, or the reason it does not. A + string here is printed as a SKIP, because an unrun suite must + never be indistinguishable from a passing one. + `deploy` repo-relative files the suite reads, beyond its own script and + what every suite gets. Only the Windows runner uses this. + """ + + def __init__(self, name, script, *, profile="", argv="conn", pyodbc=True, + matrix=False, windows=True, deploy=()): + self.name = name + self.script = script + self.profile = profile + self.argv = argv + self.pyodbc = pyodbc + self.matrix = matrix + self.windows = windows + self.deploy = deploy + + @property + def runs_on_windows(self): + return self.windows is True + + @property + def windows_skip_reason(self): + return "" if self.windows is True else self.windows + + +SUITES = [ + Suite("integration", "test_integration.py", matrix=True), + Suite( + "harness unit tests", "test_harness.py", argv="none", pyodbc=False, + windows="it tests harness.py, which is platform-independent Python " + "and reaches neither the driver nor a Driver Manager", + ), + Suite("sql surface", "test_sql_surface.py"), + # Parses the advertised capability bitmaps out of the driver's own source + # and executes one `{fn ...}` per bit, so the rule info.rs states -- a bit + # is set only when the escape becomes Trino SQL that runs -- is checked + # against a coordinator rather than against a list of names. + Suite( + "escape sequences", "test_escapes.py", + deploy=( + "src/backend/info.rs", + "src/backend.rs", + "src/escape_dialect.rs", + ), + ), + # argv="none": every check varies one connection-string key against an + # otherwise identical connection, so it builds its own strings from + # stack.env rather than taking one. + Suite("session keys", "test_session_keys.py", argv="none"), + Suite( + "folding contract", "test_folding_contract.py", + # It parses the connector's Constant visitor out of the .pq source, so + # the connector travels with it. + deploy=("connector/StackableTrinoODBC.pq",), + ), + Suite( + "tls", "test_tls.py", argv="none", + # keycloak.crt is a leaf signed by the same CA, used as a trust anchor + # that signed nothing; client.pem is the mutual-TLS identity. ca.crt is + # deployed for every suite. + deploy=( + "integration-tests/generated/certs/keycloak.crt", + "integration-tests/generated/certs/client.pem", + ), + ), + Suite("raw C ABI", "test_c_abi.py", argv="driver+conn", pyodbc=False), + # ctypes, because pyodbc exposes no SQLDescribeParam at all: the call it + # covers is reachable only through the C ABI. + Suite("describe param", "test_describe_param.py", argv="driver+conn", pyodbc=False), + Suite("type matrix", "test_type_matrix.py", argv="driver+conn", pyodbc=False), + # No required profile: with `spooling` active it drives the spooled + # protocol, and without it asserts the fallback a coordinator with no + # spooling manager produces. Both are real assertions, so neither stack + # state is a blind spot. + Suite("spooling", "test_spooling.py", argv="none"), + # No required profile either: the hive catalog it writes to is in the base + # stack, because a file metastore costs no container. + Suite("transactions", "test_transactions.py", argv="none"), + # ctypes rather than pyodbc, and no connection string: pyodbc passes + # SQL_DRIVER_NOPROMPT unconditionally, which core reads as forbidding the + # prompt an interactive login needs, so this suite builds its own. + Suite( + "oauth", "test_oauth.py", profile="oauth", argv="driver", pyodbc=False, + # The browser login itself does work through the Windows Driver + # Manager, measured by hand against this stack. What is missing is + # unattended: the suite's Driver Manager scenario loads libodbc.so.2 by + # name, and Keycloak's frontchannel issuer is https://localhost:8444, + # which inside the VM is the VM. Both are fixable; neither is done. + windows="needs an odbc32.dll branch for the Driver Manager scenario, " + "and a Keycloak issuer the VM resolves to the host", + ), +] + +# The Linux connect matrix. The DSN names are the ones scripts/gen-odbc-config.sh +# writes into the generated odbc.ini. +LINUX_CONFIGS = [ + ("DSN-less, verified TLS", lambda s: s.conn_str()), + ("DSN-less, TlsVerify=false", + lambda s: s.conn_str(TlsVerify="false", Certificate=None)), + ("DSN, verified TLS", lambda s: s.dsn("trino_https")), + ("DSN, TlsVerify=false", lambda s: s.dsn("trino_https_verify_false")), +] + + +def linux_entries(stack): + """Yield (name, profile, command) for every suite, matrix expanded.""" + driver = stack.get("DRIVER_PATH") + for suite in SUITES: + if suite.matrix: + for label, build in LINUX_CONFIGS: + yield ( + f"{suite.name} ({label})", + suite.profile, + _linux_command(suite, driver, build(stack)), + ) + else: + yield ( + suite.name, + suite.profile, + _linux_command(suite, driver, stack.conn_str()), + ) + + +def suite_argv(suite, driver, conn): + """The suite's own arguments, for a runner that renders its interpreter.""" + if suite.argv == "conn": + return [conn] + if suite.argv == "driver+conn": + return [driver, conn] + if suite.argv == "driver": + return [driver] + if suite.argv == "none": + return [] + raise ValueError(f"{suite.name}: unknown argv kind {suite.argv!r}") + + +def _linux_command(suite, driver, conn): + interpreter = ( + ["uv", "run", "--with", "pyodbc", "python3"] if suite.pyodbc else ["python3"] + ) + parts = interpreter + [os.path.join(TEST_DIR, "suites", suite.script)] + parts += suite_argv(suite, driver, conn) + return " ".join(shlex.quote(p) for p in parts) + + +def main(): + if len(sys.argv) != 2 or sys.argv[1] != "--bash": + print("usage: registry.py --bash", file=sys.stderr) + return 2 + stack = Stack.load(os.environ.get("STACK_ENV")) + for name, profile, command in linux_entries(stack): + print(f"{name}|{profile}|{command}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/integration-tests/suites/test_c_abi.py b/integration-tests/suites/test_c_abi.py new file mode 100755 index 0000000..c2195bf --- /dev/null +++ b/integration-tests/suites/test_c_abi.py @@ -0,0 +1,1157 @@ +#!/usr/bin/env python3 +""" +Raw C ABI pen test for the Trino ODBC driver. + +Loads the driver's shared object with ctypes and calls its exported entry +points directly, with **no Driver Manager in the loop**. unixODBC intercepts a +large part of the ODBC state machine and answers it itself, so a driver's own +handling of an out-of-order or malformed call is invisible to any test that +goes through pyodbc or isql. Everything asserted here is the driver's own +behaviour. + +That also means the spec's **(DM)** diagnostics must not be expected. Where the +spec attributes a SQLSTATE to the Driver Manager, nothing produces it here, and +a probe that demanded it would be asserting the absence of a component rather +than the presence of a behaviour. Those probes assert what the driver does, +with a comment naming the (DM) diagnostic they do not demand. + +Covers: handle lifecycle and parentage, invalid and stale handles, double free, +use after free, connection state, cursor state, prepare / execute / re-execute, +SQLFreeStmt options, and statement and connection attribute round-trips. + +Usage: + python3 integration-tests/suites/test_c_abi.py [path/to/libstackable_odbc_trino.so] [conn-str] + +Requires a running Trino (integration-tests/setup.sh). No compose profile is +needed: the tpcds and hive catalogs are both in the base stack. Only the Python +standard library is used (ctypes, not pyodbc). +""" + +import ctypes +import os +import sys +import time + +# --- ODBC constants ------------------------------------------------------- +# Named rather than inlined, per the project's own rule about spec values. + +# The handle types, return codes and DriverCompletion values live in odbc_abi, +# which this suite shares with test_oauth.py; they are imported below. + +# SQLFreeStmt options +SQL_CLOSE = 0 +SQL_DROP = 1 +SQL_UNBIND = 2 +SQL_RESET_PARAMS = 3 + +# Statement attributes +SQL_ATTR_QUERY_TIMEOUT = 0 +SQL_ATTR_MAX_ROWS = 1 +SQL_ATTR_NOSCAN = 2 +SQL_ATTR_MAX_LENGTH = 3 +SQL_ATTR_ASYNC_ENABLE = 4 +SQL_ATTR_CURSOR_TYPE = 6 +SQL_ATTR_CONCURRENCY = 7 +SQL_ATTR_KEYSET_SIZE = 8 +SQL_ATTR_SIMULATE_CURSOR = 10 +SQL_ATTR_RETRIEVE_DATA = 11 +SQL_ATTR_USE_BOOKMARKS = 12 +SQL_ATTR_ENABLE_AUTO_IPD = 15 +SQL_ATTR_PARAM_STATUS_PTR = 20 +SQL_ATTR_PARAMS_PROCESSED_PTR = 21 +SQL_ATTR_PARAMSET_SIZE = 22 +SQL_ATTR_ROW_ARRAY_SIZE = 27 +SQL_ATTR_CURSOR_SCROLLABLE = -1 +SQL_ATTR_CURSOR_SENSITIVITY = -2 +SQL_ATTR_METADATA_ID = 10014 +SQL_CURSOR_FORWARD_ONLY = 0 +SQL_CURSOR_STATIC = 3 + +# Values the driver substitutes to, and the values it refuses. +SQL_CONCUR_READ_ONLY = 1 +SQL_SC_NON_UNIQUE = 0 +SQL_NONSCROLLABLE = 0 +SQL_UB_VARIABLE = 2 +SQL_RD_OFF = 0 +SQL_SENSITIVE = 2 +SQL_ASYNC_ENABLE_OFF = 0 +SQL_ASYNC_ENABLE_ON = 1 + +# SQL_ATTR_PARAM_STATUS_PTR element values. +SQL_PARAM_SUCCESS = 0 +SQL_PARAM_ERROR = 5 + +# Connection attributes +SQL_ATTR_AUTOCOMMIT = 102 +SQL_ATTR_TXN_ISOLATION = 108 +SQL_ATTR_CURRENT_CATALOG = 109 +SQL_ATTR_PACKET_SIZE = 112 +SQL_ATTR_ENLIST_IN_DTC = 1207 +SQL_AUTOCOMMIT_ON = 1 +SQL_TXN_SERIALIZABLE = 8 + +# Info types read back beside the attributes that mirror them. +SQL_DATABASE_NAME = 16 + +SQL_TRUE = 1 +SQL_FALSE = 0 + +SQL_C_CHAR = 1 +SQL_C_SBIGINT = -25 +SQL_BIGINT = -5 + +# SQLBindParameter arguments used by the bound-parameter probes. +SQL_PARAM_INPUT = 1 +SQL_NUMERIC = 2 + +# SQL_ATTR_AUTOCOMMIT and its two values, plus SQLEndTran's completion types. +SQL_ATTR_AUTOCOMMIT = 102 +SQL_AUTOCOMMIT_OFF = 0 +SQL_AUTOCOMMIT_ON = 1 +SQL_COMMIT = 0 +SQL_ROLLBACK = 1 + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from harness import Results, Stack # noqa: E402 +from odbc_abi import ( # noqa: E402 + SQL_ATTR_ODBC_VERSION, + SQL_DRIVER_NOPROMPT, + SQL_ERROR, + SQL_HANDLE_DBC, + SQL_HANDLE_DESC, + SQL_HANDLE_ENV, + SQL_HANDLE_STMT, + SQL_INVALID_HANDLE, + SQL_NO_DATA, + SQL_NTS, + SQL_NULL_HANDLE, + SQL_OV_ODBC3, + SQL_SUCCESS, + SQL_SUCCESS_WITH_INFO, + load, + read_wide_info, + sqlstate, + w, +) + +R = Results("raw C ABI") + + +RET_NAMES = { + SQL_SUCCESS: "SUCCESS", + SQL_SUCCESS_WITH_INFO: "SUCCESS_WITH_INFO", + SQL_NO_DATA: "NO_DATA", + SQL_ERROR: "ERROR", + SQL_INVALID_HANDLE: "INVALID_HANDLE", +} + + +def rname(r): + return RET_NAMES.get(r, str(r)) + + +def check(label, got, want, state=None, got_state=None): + """Assert a return code, and optionally the SQLSTATE that came with it. + + Kept here rather than in the harness: it speaks in ODBC return codes and + SQLSTATEs, which is this suite's vocabulary, not generic machinery. + """ + want_list = want if isinstance(want, (list, tuple)) else [want] + ok = got in want_list + detail = "" + if ok and state is not None: + ok = got_state == state + detail = f" (SQLSTATE {got_state or ''}, expected {state})" + elif got_state: + detail = f" (SQLSTATE {got_state})" + if ok: + R.ok(f"{label}: {rname(got)}{detail}") + else: + expect = "/".join(rname(x) for x in want_list) + R.bad(f"{label}: got {rname(got)}{detail}, expected {expect}") + + +def note(label, text): + """An observation the driver is entitled to make either way.""" + R.note(label, text) + + +def main(): + here = os.path.dirname(os.path.abspath(__file__)) + default_so = os.path.join( + here, "..", "..", "target", "debug", "libstackable_odbc_trino.so" + ) + so = sys.argv[1] if len(sys.argv) > 1 else default_so + conn_str = sys.argv[2] if len(sys.argv) > 2 else Stack.load().conn_str() + + if not os.path.exists(so): + print(f"driver not found: {so}\nrun: cargo build") + return 2 + + lib = load(so) + P = ctypes.c_void_p + + print(f"=== raw C ABI pen test (no Driver Manager) ===\ndriver: {so}\n") + + # --------------------------------------------------------------- + print("--- handle lifecycle ---") + env = P() + r = lib.SQLAllocHandle(SQL_HANDLE_ENV, None, ctypes.byref(env)) + check("alloc env", r, SQL_SUCCESS) + + r = lib.SQLSetEnvAttr(env, SQL_ATTR_ODBC_VERSION, P(SQL_OV_ODBC3), 0) + check("set ODBC version 3", r, SQL_SUCCESS) + + dbc = P() + r = lib.SQLAllocHandle(SQL_HANDLE_DBC, env, ctypes.byref(dbc)) + check("alloc connection", r, SQL_SUCCESS) + + # The env still owns a connection, so it must refuse to be freed. + r = lib.SQLFreeHandle(SQL_HANDLE_ENV, env) + check( + "free env with a live connection", + r, + SQL_ERROR, + state="HY010", + got_state=sqlstate(lib, SQL_HANDLE_ENV, env), + ) + + # --------------------------------------------------------------- + print("\n--- invalid and mismatched handles ---") + bogus = P(0xDEADBEEF) + out = P() + r = lib.SQLAllocHandle(SQL_HANDLE_DBC, bogus, ctypes.byref(out)) + check("alloc connection on a non-handle parent", r, SQL_INVALID_HANDLE) + + r = lib.SQLFreeHandle(SQL_HANDLE_ENV, None) + check("free a null handle", r, SQL_INVALID_HANDLE) + + r = lib.SQLFreeHandle(SQL_HANDLE_STMT, env) + check("free an env under the wrong handle type", r, SQL_INVALID_HANDLE) + + r = lib.SQLAllocHandle(SQL_HANDLE_STMT, env, ctypes.byref(out)) + check("alloc statement parented on an env", r, SQL_INVALID_HANDLE) + + # --------------------------------------------------------------- + print("\n--- statement on an unconnected connection ---") + stmt0 = P() + r = lib.SQLAllocHandle(SQL_HANDLE_STMT, dbc, ctypes.byref(stmt0)) + # The spec's 08003 for this is (DM)-owned, so the driver is entitled to + # allocate: a statement on a not-yet-open connection is legal here. + note("alloc statement before connecting", f"{rname(r)} (08003 here is DM-owned)") + if r == SQL_SUCCESS: + sql, _keep = w("SELECT 1") + r = lib.SQLExecDirectW(stmt0, sql, SQL_NTS) + # HY010, not 08003: SQLExecDirect's 08003 is (DM)-annotated, so with no + # Driver Manager loaded nothing produces it, and the driver reports the + # sequence error instead. + check( + "execute on an unconnected connection", + r, + SQL_ERROR, + state="HY010", + got_state=sqlstate(lib, SQL_HANDLE_STMT, stmt0), + ) + lib.SQLFreeHandle(SQL_HANDLE_STMT, stmt0) + + r = lib.SQLDisconnect(dbc) + check( + "disconnect while not connected", + r, + SQL_ERROR, + state="08003", + got_state=sqlstate(lib, SQL_HANDLE_DBC, dbc), + ) + + # --------------------------------------------------------------- + print("\n--- connect ---") + cs, _keep_cs = w(conn_str) + outbuf = (ctypes.c_uint16 * 1024)() + outlen = ctypes.c_int16(0) + r = lib.SQLDriverConnectW( + dbc, + None, + cs, + SQL_NTS, + ctypes.cast(outbuf, ctypes.POINTER(ctypes.c_uint16)), + 1024, + ctypes.byref(outlen), + SQL_DRIVER_NOPROMPT, + ) + check( + "SQLDriverConnectW", + r, + [SQL_SUCCESS, SQL_SUCCESS_WITH_INFO], + got_state=sqlstate(lib, SQL_HANDLE_DBC, dbc), + ) + if r not in (SQL_SUCCESS, SQL_SUCCESS_WITH_INFO): + print("\ncannot continue without a connection; is Trino running?") + return 1 + + cs2, _keep_cs2 = w(conn_str) + r = lib.SQLDriverConnectW( + dbc, + None, + cs2, + SQL_NTS, + ctypes.cast(outbuf, ctypes.POINTER(ctypes.c_uint16)), + 1024, + ctypes.byref(outlen), + SQL_DRIVER_NOPROMPT, + ) + check( + "connect on an already-connected handle", + r, + SQL_ERROR, + state="08002", + got_state=sqlstate(lib, SQL_HANDLE_DBC, dbc), + ) + + stmt = P() + r = lib.SQLAllocHandle(SQL_HANDLE_STMT, dbc, ctypes.byref(stmt)) + check("alloc statement", r, SQL_SUCCESS) + + # Dirty the connection's diagnostic queue immediately before the free, with + # no call in between: this driver reports SQL_TC_NONE, so every isolation + # level is invalid and this leaves HY024 as record 1. + # + # It has to be immediately before. The 08002 from the failed second connect + # above is already gone by here, because SQLAllocHandle clears at entry too + # and the statement allocation sits between the two. + r = lib.SQLSetConnectAttrW(dbc, SQL_ATTR_TXN_ISOLATION, P(SQL_TXN_SERIALIZABLE), 0) + check( + "set SQL_ATTR_TXN_ISOLATION on a transaction-less driver", + r, + SQL_ERROR, + state="HY024", + got_state=sqlstate(lib, SQL_HANDLE_DBC, dbc), + ) + + # A function clears the handle's diagnostics at entry, so the HY010 this + # posts must be record 1 rather than sitting behind the HY024 above. An + # application reading the first record after a failed free would otherwise + # act on the previous call's SQLSTATE. + r = lib.SQLFreeHandle(SQL_HANDLE_DBC, dbc) + check( + "free connection while still connected, over a dirty queue", + r, + SQL_ERROR, + state="HY010", + got_state=sqlstate(lib, SQL_HANDLE_DBC, dbc), + ) + + # --------------------------------------------------------------- + print("\n--- cursor state with no cursor ---") + # HY010, not 24000. 24000 is for a statement that *was* executed but has no + # result set, HY010 for one never put in an executed state. This statement + # is the latter. + r = lib.SQLFetch(stmt) + check( + "fetch on a never-executed statement", + r, + SQL_ERROR, + state="HY010", + got_state=sqlstate(lib, SQL_HANDLE_STMT, stmt), + ) + + ind = ctypes.c_int64(0) + buf = ctypes.create_string_buffer(64) + r = lib.SQLGetData(stmt, 1, SQL_C_CHAR, ctypes.cast(buf, P), 64, ctypes.byref(ind)) + check( + "get_data with no cursor", + r, + SQL_ERROR, + state="24000", + got_state=sqlstate(lib, SQL_HANDLE_STMT, stmt), + ) + + r = lib.SQLCloseCursor(stmt) + check( + "close_cursor with no cursor", + r, + SQL_ERROR, + state="24000", + got_state=sqlstate(lib, SQL_HANDLE_STMT, stmt), + ) + + r = lib.SQLExecute(stmt) + check( + "execute with nothing prepared", + r, + SQL_ERROR, + state="HY010", + got_state=sqlstate(lib, SQL_HANDLE_STMT, stmt), + ) + + cols = ctypes.c_int16(-1) + r = lib.SQLNumResultCols(stmt, ctypes.byref(cols)) + check( + "num_result_cols before execute", + r, + SQL_ERROR, + state="HY010", + got_state=sqlstate(lib, SQL_HANDLE_STMT, stmt), + ) + + # --------------------------------------------------------------- + print("\n--- prepare / execute / re-execute ---") + sql, _k1 = w("SELECT 1 AS n") + r = lib.SQLPrepareW(stmt, sql, SQL_NTS) + check("prepare", r, SQL_SUCCESS) + + # SQL_ATTR_CURSOR_TYPE may not be set once a statement is prepared. + r = lib.SQLSetStmtAttrW(stmt, SQL_ATTR_CURSOR_TYPE, P(SQL_CURSOR_STATIC), 0) + check( + "set cursor type after prepare", + r, + SQL_ERROR, + state="HY011", + got_state=sqlstate(lib, SQL_HANDLE_STMT, stmt), + ) + + for attempt in (1, 2): + r = lib.SQLExecute(stmt) + check(f"execute (attempt {attempt})", r, SQL_SUCCESS) + r = lib.SQLNumResultCols(stmt, ctypes.byref(cols)) + check(f"num_result_cols after execute ({attempt})", r, SQL_SUCCESS) + if cols.value != 1: + note(f"num_result_cols after execute ({attempt})", f"got {cols.value}") + r = lib.SQLFetch(stmt) + check(f"fetch row ({attempt})", r, SQL_SUCCESS) + r = lib.SQLFetch(stmt) + check(f"fetch past the last row ({attempt})", r, SQL_NO_DATA) + r = lib.SQLCloseCursor(stmt) + check(f"close cursor ({attempt})", r, SQL_SUCCESS) + + # --------------------------------------------------------------- + print("\n--- SQLFreeStmt options ---") + sql, _k2 = w("SELECT 1 AS n") + lib.SQLExecDirectW(stmt, sql, SQL_NTS) + r = lib.SQLFreeStmt(stmt, SQL_CLOSE) + check("free_stmt SQL_CLOSE with an open cursor", r, SQL_SUCCESS) + r = lib.SQLFreeStmt(stmt, SQL_CLOSE) + check("free_stmt SQL_CLOSE with no cursor", r, SQL_SUCCESS) + r = lib.SQLFreeStmt(stmt, SQL_UNBIND) + check("free_stmt SQL_UNBIND", r, SQL_SUCCESS) + r = lib.SQLFreeStmt(stmt, SQL_RESET_PARAMS) + check("free_stmt SQL_RESET_PARAMS", r, SQL_SUCCESS) + # The SQLSTATE matters as much as the return code: an SQL_ERROR carrying no + # diagnostic record leaves an application with an error it cannot interpret. + r = lib.SQLFreeStmt(stmt, 99) + check( + "free_stmt with an undefined option", + r, + SQL_ERROR, + state="HY092", + got_state=sqlstate(lib, SQL_HANDLE_STMT, stmt), + ) + + # --------------------------------------------------------------- + print("\n--- statement attributes: substituted values (01S02) ---") + # The spec's 01S02 row closes the set of statement attributes a driver may + # substitute for. For each, the driver must store the value it will use, + # which is what makes the row's parenthesis true: "(SQLGetStmtAttr can be + # called to determine the temporarily substituted value.)". The read-back is + # asserted, not merely observed. A driver that kept the requested value + # would be claiming a block cursor it does not implement, and an application + # reading its own number back has no way to tell. + # + # SQL_ATTR_QUERY_TIMEOUT is absent from the list because the driver enforces + # it (Backend::set_query_timeout answers QueryTimeout::CoreCancels), so it + # is accepted rather than substituted. It is checked on its own below. + # + # SQLULEN is 64-bit here, so the read-back buffer is too, and it is zeroed + # before each read. The full-width write is asserted separately below. + # + # A *fresh* statement, not the shared one: SQL_ATTR_CONCURRENCY, + # SQL_ATTR_CURSOR_TYPE, SQL_ATTR_SIMULATE_CURSOR and SQL_ATTR_USE_BOOKMARKS + # "must be set before the statement is executed", and the shared handle has + # been prepared and executed by now. Setting one of those on it correctly + # answers HY011, which is what the group below this one asserts. + val = ctypes.c_uint64(0) + outlen32 = ctypes.c_int32(0) + attr_stmt = ctypes.c_void_p() + r = lib.SQLAllocHandle(SQL_HANDLE_STMT, dbc, ctypes.byref(attr_stmt)) + check("allocate a fresh statement for the attribute probes", r, SQL_SUCCESS) + for label, attr, asked, substituted in ( + ("SQL_ATTR_CONCURRENCY", SQL_ATTR_CONCURRENCY, 2, SQL_CONCUR_READ_ONLY), + ("SQL_ATTR_CURSOR_TYPE", SQL_ATTR_CURSOR_TYPE, SQL_CURSOR_STATIC, SQL_CURSOR_FORWARD_ONLY), + ("SQL_ATTR_KEYSET_SIZE", SQL_ATTR_KEYSET_SIZE, 50, 0), + ("SQL_ATTR_MAX_LENGTH", SQL_ATTR_MAX_LENGTH, 4096, 0), + ("SQL_ATTR_MAX_ROWS", SQL_ATTR_MAX_ROWS, 100, 0), + ("SQL_ATTR_ROW_ARRAY_SIZE", SQL_ATTR_ROW_ARRAY_SIZE, 10, 1), + ("SQL_ATTR_SIMULATE_CURSOR", SQL_ATTR_SIMULATE_CURSOR, 2, SQL_SC_NON_UNIQUE), + # Two deviations from the spec's closed list, documented in core. + # Substituting keeps SQL_ATTR_CURSOR_SCROLLABLE consistent with + # SQL_ATTR_CURSOR_TYPE. For SQL_ATTR_PARAMSET_SIZE, refusing would fail + # a call every parameter-array-capable tool makes, while accepting it + # verbatim would silently drop every set past the first. + ("SQL_ATTR_CURSOR_SCROLLABLE", SQL_ATTR_CURSOR_SCROLLABLE, 1, SQL_NONSCROLLABLE), + ("SQL_ATTR_PARAMSET_SIZE", SQL_ATTR_PARAMSET_SIZE, 500, 1), + ): + r = lib.SQLSetStmtAttrW(attr_stmt, attr, P(asked), 0) + check( + f"set {label}={asked} (unsupported)", + r, + SQL_SUCCESS_WITH_INFO, + state="01S02", + got_state=sqlstate(lib, SQL_HANDLE_STMT, attr_stmt), + ) + val.value = 0 + r = lib.SQLGetStmtAttrW(attr_stmt, attr, ctypes.byref(val), 8, ctypes.byref(outlen32)) + check(f"get {label}", r, SQL_SUCCESS) + if r == SQL_SUCCESS: + check( + f"{label} reads back the substituted value", + SQL_SUCCESS if val.value == substituted else SQL_ERROR, + SQL_SUCCESS, + got_state=f"read {val.value}, expected {substituted}", + ) + + # SQL_ATTR_QUERY_TIMEOUT is the one attribute on that list this driver + # enforces, so it must be accepted plainly and read back unchanged. Core + # arms its timer only when Backend::set_query_timeout answers Ok, and + # SQLGetStmtAttr reporting 42 rather than 0 is how an application learns the + # deadline is really in force. + # + # Core arms the deadline from a timer thread inside the .so, so no Driver + # Manager threading policy can serialise it and this suite exercises the + # same path a unixODBC client gets. + r = lib.SQLSetStmtAttrW(attr_stmt, SQL_ATTR_QUERY_TIMEOUT, P(42), 0) + check( + "set SQL_ATTR_QUERY_TIMEOUT=42 (enforced, not substituted)", + r, + SQL_SUCCESS, + got_state=sqlstate(lib, SQL_HANDLE_STMT, attr_stmt), + ) + val.value = 0 + r = lib.SQLGetStmtAttrW( + attr_stmt, SQL_ATTR_QUERY_TIMEOUT, ctypes.byref(val), 8, ctypes.byref(outlen32) + ) + check("get SQL_ATTR_QUERY_TIMEOUT", r, SQL_SUCCESS) + check( + "SQL_ATTR_QUERY_TIMEOUT reads back the value that was set", + SQL_SUCCESS if val.value == 42 else SQL_ERROR, + SQL_SUCCESS, + got_state=f"read {val.value}, expected 42", + ) + + # The deadline reaches the call that waits. Trino returns column metadata + # before it has computed anything, so SQLExecDirect finishes in milliseconds + # and every second of a slow query is spent paging inside SQLFetch. That is + # where SQL_ATTR_QUERY_TIMEOUT has to bite or it bounds nothing. SQLFetch's + # diagnostics table carries HYT00 ("the query timeout period expired before + # the data source returned the requested result set. The timeout period is + # set through SQLSetStmtAttr, SQL_ATTR_QUERY_TIMEOUT") with no (DM) marker, + # so it is the driver's to return. + # + # A dedicated statement and a query that takes ~24s uncancelled, so a + # regression is a hard failure rather than a flake. Elapsed time is checked + # too: HYT00 arriving after the query finished on its own would be the + # timeout not working, reported as though it were. + timeout_stmt = ctypes.c_void_p() + r = lib.SQLAllocHandle(SQL_HANDLE_STMT, dbc, ctypes.byref(timeout_stmt)) + check("allocate a statement for the query-timeout probe", r, SQL_SUCCESS) + r = lib.SQLSetStmtAttrW(timeout_stmt, SQL_ATTR_QUERY_TIMEOUT, P(2), 0) + check("set a 2-second deadline", r, SQL_SUCCESS) + + slow, _keep_slow = w("SELECT count(*) FROM tpcds.sf10.store_sales") + r = lib.SQLExecDirectW(timeout_stmt, slow, SQL_NTS) + check( + "execute returns before the query has run (Trino sends metadata first)", + r, + SQL_SUCCESS, + got_state=sqlstate(lib, SQL_HANDLE_STMT, timeout_stmt), + ) + + t0 = time.monotonic() + r = lib.SQLFetch(timeout_stmt) + elapsed = time.monotonic() - t0 + check( + "SQLFetch past the deadline reports HYT00", + r, + SQL_ERROR, + state="HYT00", + got_state=sqlstate(lib, SQL_HANDLE_STMT, timeout_stmt), + ) + check( + "the deadline fired on time rather than after the query finished", + SQL_SUCCESS if elapsed < 15 else SQL_ERROR, + SQL_SUCCESS, + got_state=f"SQLFetch took {elapsed:.1f}s against a 2s deadline " + f"(the query runs ~24s uncancelled)", + ) + + # The cancelled query must not strand the connection. A server-side cancel + # leaves the pooled TCP socket carrying residual bytes if anything keeps + # paging it, which surfaces later as an unrelated query failing. The + # assertion is therefore that the next query on the same connection works. + r = lib.SQLFreeHandle(SQL_HANDLE_STMT, timeout_stmt) + check("free the timed-out statement", r, SQL_SUCCESS) + + after_stmt = ctypes.c_void_p() + lib.SQLAllocHandle(SQL_HANDLE_STMT, dbc, ctypes.byref(after_stmt)) + probe, _keep_probe = w("SELECT 1") + r = lib.SQLExecDirectW(after_stmt, probe, SQL_NTS) + check( + "the connection still works after a timed-out query", + r, + SQL_SUCCESS, + got_state=sqlstate(lib, SQL_HANDLE_STMT, after_stmt), + ) + check("fetch the row after the timeout", lib.SQLFetch(after_stmt), SQL_SUCCESS) + lib.SQLFreeHandle(SQL_HANDLE_STMT, after_stmt) + + for label, attr, expected in ( + ("SQL_ATTR_MAX_ROWS", SQL_ATTR_MAX_ROWS, 0), + ("SQL_ATTR_QUERY_TIMEOUT", SQL_ATTR_QUERY_TIMEOUT, 42), + ("SQL_ATTR_ROW_ARRAY_SIZE", SQL_ATTR_ROW_ARRAY_SIZE, 1), + ("SQL_ATTR_CONCURRENCY", SQL_ATTR_CONCURRENCY, SQL_CONCUR_READ_ONLY), + ("SQL_ATTR_NOSCAN", SQL_ATTR_NOSCAN, 0), + ("SQL_ATTR_METADATA_ID", SQL_ATTR_METADATA_ID, SQL_FALSE), + ): + val.value = 0xFFFFFFFFFFFFFFFF + outlen32.value = 0 + r = lib.SQLGetStmtAttrW( + attr_stmt, attr, ctypes.byref(val), 8, ctypes.byref(outlen32) + ) + check(f"get {label} into a poisoned SQLULEN", r, SQL_SUCCESS) + check( + f"{label} is written at the full SQLULEN width", + SQL_SUCCESS if (val.value == expected and outlen32.value == 8) else SQL_ERROR, + SQL_SUCCESS, + got_state=f"read 0x{val.value:016x} with StringLength {outlen32.value}", + ) + + lib.SQLFreeHandle(SQL_HANDLE_STMT, attr_stmt) + + print("\n--- statement attributes: too late to set (HY011) ---") + # The four the spec's Comments say "must be set before the statement is + # executed". The shared `stmt` has been prepared and executed by this point, + # so each is refused, and refused *before* the substitution and HYC00 rules + # above are consulted. That is why those had to run on a fresh handle. An + # application that sets one of these mid-cursor is asking for a different + # cursor over a result set that already exists. + for label, attr, value in ( + ("SQL_ATTR_CONCURRENCY", SQL_ATTR_CONCURRENCY, 2), + ("SQL_ATTR_CURSOR_TYPE", SQL_ATTR_CURSOR_TYPE, SQL_CURSOR_STATIC), + ("SQL_ATTR_SIMULATE_CURSOR", SQL_ATTR_SIMULATE_CURSOR, 2), + ("SQL_ATTR_USE_BOOKMARKS", SQL_ATTR_USE_BOOKMARKS, SQL_UB_VARIABLE), + ): + r = lib.SQLSetStmtAttrW(stmt, attr, P(value), 0) + check( + f"set {label} after the statement was executed", + r, + SQL_ERROR, + state="HY011", + got_state=sqlstate(lib, SQL_HANDLE_STMT, stmt), + ) + + # Accepted silently, by decision rather than by omission: core relaxes the + # spec's HY092 here for Driver Manager and tool compatibility, and says so + # at the call site. Asserted so the relaxation cannot be reverted silently. + r = lib.SQLSetStmtAttrW(stmt, 9999, P(1), 0) + check("set an undefined statement attribute (relaxed)", r, SQL_SUCCESS) + + r = lib.SQLGetConnectAttrW( + dbc, SQL_ATTR_AUTOCOMMIT, ctypes.byref(val), 4, ctypes.byref(outlen32) + ) + check("get SQL_ATTR_AUTOCOMMIT", r, SQL_SUCCESS) + + print("\n--- connection attributes the driver owns ---") + # SQL_ATTR_PACKET_SIZE: the spec states this one directly. "If the + # application sets packet size after a connection has already been made, + # the driver will return SQLSTATE HY011 (Attribute cannot be set now)". + r = lib.SQLSetConnectAttrW(dbc, SQL_ATTR_PACKET_SIZE, P(8192), 0) + check( + "set SQL_ATTR_PACKET_SIZE after connecting", + r, + SQL_ERROR, + state="HY011", + got_state=sqlstate(lib, SQL_HANDLE_DBC, dbc), + ) + # Neither is implemented and neither has a substitution to offer: this + # driver reports SQL_AM_NONE for SQL_ASYNC_MODE and enlists in no + # distributed transaction, so accepting either would leave an application + # believing in behaviour it does not get. + r = lib.SQLSetConnectAttrW(dbc, SQL_ATTR_ENLIST_IN_DTC, P(1), 0) + check( + "set SQL_ATTR_ENLIST_IN_DTC", + r, + SQL_ERROR, + state="HYC00", + got_state=sqlstate(lib, SQL_HANDLE_DBC, dbc), + ) + r = lib.SQLSetConnectAttrW(dbc, SQL_ATTR_ASYNC_ENABLE, P(SQL_ASYNC_ENABLE_ON), 0) + check( + "set SQL_ATTR_ASYNC_ENABLE=SQL_ASYNC_ENABLE_ON", + r, + SQL_ERROR, + state="HYC00", + got_state=sqlstate(lib, SQL_HANDLE_DBC, dbc), + ) + r = lib.SQLSetConnectAttrW(dbc, SQL_ATTR_ASYNC_ENABLE, P(SQL_ASYNC_ENABLE_OFF), 0) + check("set SQL_ATTR_ASYNC_ENABLE=SQL_ASYNC_ENABLE_OFF", r, SQL_SUCCESS) + + print("\n--- SQL_ATTR_METADATA_ID inherits from the connection ---") + # SQLSetStmtAttr's Comments make this one of exactly two attributes an + # application may set at the connection level. It is not a cosmetic + # read-back: the value decides whether the catalog functions treat their + # arguments as identifiers or as search patterns. A connection-level set + # that reached no statement would answer SQL_SUCCESS, echo the value back + # from SQLGetConnectAttr, and then apply pattern semantics with no + # diagnostic. Per the ODBC 2.x rule it inherits, the value is the default + # for statements allocated afterwards only, so the pre-existing one is + # checked too. + r = lib.SQLSetConnectAttrW(dbc, SQL_ATTR_METADATA_ID, P(SQL_TRUE), 0) + check("set SQL_ATTR_METADATA_ID on the connection", r, SQL_SUCCESS) + inherit_stmt = ctypes.c_void_p() + r = lib.SQLAllocHandle(SQL_HANDLE_STMT, dbc, ctypes.byref(inherit_stmt)) + check("allocate a statement after setting it", r, SQL_SUCCESS) + val.value = 0 + r = lib.SQLGetStmtAttrW( + inherit_stmt, SQL_ATTR_METADATA_ID, ctypes.byref(val), 8, ctypes.byref(outlen32) + ) + check("get SQL_ATTR_METADATA_ID on the new statement", r, SQL_SUCCESS) + check( + "the new statement inherited SQL_TRUE", + SQL_SUCCESS if val.value == SQL_TRUE else SQL_ERROR, + SQL_SUCCESS, + got_state=f"read {val.value}", + ) + val.value = 0 + lib.SQLGetStmtAttrW( + stmt, SQL_ATTR_METADATA_ID, ctypes.byref(val), 8, ctypes.byref(outlen32) + ) + check( + "a statement that already existed is untouched", + SQL_SUCCESS if val.value == SQL_FALSE else SQL_ERROR, + SQL_SUCCESS, + got_state=f"read {val.value}", + ) + lib.SQLFreeHandle(SQL_HANDLE_STMT, inherit_stmt) + lib.SQLSetConnectAttrW(dbc, SQL_ATTR_METADATA_ID, P(SQL_FALSE), 0) + + print("\n--- an execution reports its parameter set ---") + # The parameter-side counterpart of what SQLFetch writes through + # SQL_ATTR_ROWS_FETCHED_PTR. An application binds a status array to detect + # per-set errors, so a driver that left the buffer untouched would be + # indistinguishable from one reporting that every set succeeded. The buffers + # are poisoned before the call for that reason. + param_stmt = ctypes.c_void_p() + lib.SQLAllocHandle(SQL_HANDLE_STMT, dbc, ctypes.byref(param_stmt)) + processed = ctypes.c_uint64(0xFFFFFFFFFFFFFFFF) + status = (ctypes.c_uint16 * 4)(*([0xBEEF] * 4)) + r = lib.SQLSetStmtAttrW( + param_stmt, SQL_ATTR_PARAMS_PROCESSED_PTR, ctypes.cast(ctypes.byref(processed), P), 0 + ) + check("set SQL_ATTR_PARAMS_PROCESSED_PTR", r, SQL_SUCCESS) + r = lib.SQLSetStmtAttrW( + param_stmt, SQL_ATTR_PARAM_STATUS_PTR, ctypes.cast(status, P), 0 + ) + check("set SQL_ATTR_PARAM_STATUS_PTR", r, SQL_SUCCESS) + + pval = ctypes.c_int64(42) + plen = ctypes.c_int64(8) + r = lib.SQLBindParameter( + param_stmt, + 1, + SQL_PARAM_INPUT, + SQL_C_SBIGINT, + SQL_BIGINT, + 19, + 0, + ctypes.cast(ctypes.byref(pval), P), + 8, + ctypes.byref(plen), + ) + check("bind the parameter", r, SQL_SUCCESS) + sql, _kp = w("SELECT CAST(? AS bigint)") + r = lib.SQLExecDirectW(param_stmt, sql, SQL_NTS) + check("execute with one parameter set", r, SQL_SUCCESS) + check( + "the processed count is 1", + SQL_SUCCESS if processed.value == 1 else SQL_ERROR, + SQL_SUCCESS, + got_state=f"read {processed.value}", + ) + check( + "the status element is SQL_PARAM_SUCCESS", + SQL_SUCCESS if status[0] == SQL_PARAM_SUCCESS else SQL_ERROR, + SQL_SUCCESS, + got_state=f"read {status[0]}", + ) + check( + "the sets past the first are untouched", + SQL_SUCCESS if status[1] == 0xBEEF else SQL_ERROR, + SQL_SUCCESS, + got_state=f"read {status[1]:#x}", + ) + lib.SQLCloseCursor(param_stmt) + + # The failing half. The rejection comes from the coordinator rather than + # from parameter handling, which is the case an application binds a status + # array for. + processed.value = 0xFFFFFFFFFFFFFFFF + status[0] = 0xBEEF + sql, _kq = w("SELECT ? FROM does_not_exist_zzz") + r = lib.SQLExecDirectW(param_stmt, sql, SQL_NTS) + check("execute a statement the coordinator rejects", r, SQL_ERROR) + check( + "the processed count includes the error set", + SQL_SUCCESS if processed.value == 1 else SQL_ERROR, + SQL_SUCCESS, + got_state=f"read {processed.value}", + ) + check( + "the status element is SQL_PARAM_ERROR", + SQL_SUCCESS if status[0] == SQL_PARAM_ERROR else SQL_ERROR, + SQL_SUCCESS, + got_state=f"read {status[0]}", + ) + lib.SQLFreeHandle(SQL_HANDLE_STMT, param_stmt) + + print("\n--- SQL_DATABASE_NAME and SQL_ATTR_CURRENT_CATALOG ---") + # The spec makes these one value under two names: "in ODBC 3.x, the value + # returned for this InfoType can also be returned by calling + # SQLGetConnectAttr with an Attribute argument of SQL_ATTR_CURRENT_CATALOG". + dbname = read_wide_info(lib, dbc, SQL_DATABASE_NAME) + check( + "SQLGetInfo(SQL_DATABASE_NAME) is the connected catalog", + SQL_SUCCESS if dbname == "tpcds" else SQL_ERROR, + SQL_SUCCESS, + got_state=f"read {dbname!r}", + ) + catbuf = (ctypes.c_uint16 * 256)() + outlen32.value = 0 + r = lib.SQLGetConnectAttrW( + dbc, SQL_ATTR_CURRENT_CATALOG, ctypes.cast(catbuf, P), 512, ctypes.byref(outlen32) + ) + check("get SQL_ATTR_CURRENT_CATALOG", r, SQL_SUCCESS) + current = "".join(chr(c) for c in catbuf[: max(outlen32.value, 0) // 2]) + check( + "SQL_ATTR_CURRENT_CATALOG agrees with SQL_DATABASE_NAME", + SQL_SUCCESS if current == dbname else SQL_ERROR, + SQL_SUCCESS, + got_state=f"attribute {current!r}, info type {dbname!r}", + ) + + # Setting it is refused rather than silently accepted. Trino's only + # catalog-switching statement is `USE`, whose grammar requires a schema, so + # honouring this would mean inventing one and moving where the session's + # unqualified names resolve. Storing the value and returning SQL_SUCCESS + # would tell an application its names had moved when nothing had. + newcat, _kc = w("postgresql") + r = lib.SQLSetConnectAttrW( + dbc, SQL_ATTR_CURRENT_CATALOG, ctypes.cast(newcat, P), SQL_NTS + ) + check( + "set SQL_ATTR_CURRENT_CATALOG", + r, + SQL_ERROR, + state="HYC00", + got_state=sqlstate(lib, SQL_HANDLE_DBC, dbc), + ) + check( + "a refused catalog switch leaves both readers where they were", + SQL_SUCCESS if read_wide_info(lib, dbc, SQL_DATABASE_NAME) == dbname else SQL_ERROR, + SQL_SUCCESS, + got_state=f"SQL_DATABASE_NAME is now {read_wide_info(lib, dbc, SQL_DATABASE_NAME)!r}", + ) + + # --------------------------------------------------------------- + print("\n--- diagnostics are cleared at function entry ---") + # The spec clears a handle's diagnostics at the start of every function + # called on it, except SQLGetDiagRec/SQLGetDiagField. Without that, an + # application reads the *previous* error after a failure and acts on the + # wrong SQLSTATE. Provoke a known error, then provoke a different one, and + # see which record comes back first. + r = lib.SQLSetStmtAttrW(stmt, SQL_ATTR_ROW_ARRAY_SIZE, P(10), 0) # leaves 01S02 + first = sqlstate(lib, SQL_HANDLE_STMT, stmt) + r = lib.SQLGetData(stmt, 1, SQL_C_CHAR, ctypes.cast(buf, P), 64, ctypes.byref(ind)) + second = sqlstate(lib, SQL_HANDLE_STMT, stmt) + check("get_data after an unrelated 01S02", r, SQL_ERROR) + if second == first: + note( + "diagnostics cleared at entry", + f"KNOWN: record 1 is still {first} from the previous call; the " + "later error is queued behind it", + ) + else: + check( + "diagnostics cleared at entry", + r, + SQL_ERROR, + state="24000", + got_state=second, + ) + + # --------------------------------------------------------------- + print("\n--- transactions ---") + # SQL_ATTR_AUTOCOMMIT off is manual-commit mode. The driver records it and + # opens the transaction at the first statement, so this reaches no + # coordinator and cannot fail for a reason unrelated to the attribute. + r = lib.SQLSetConnectAttrW(dbc, SQL_ATTR_AUTOCOMMIT, SQL_AUTOCOMMIT_OFF, 0) + check( + "autocommit can be turned off", + r, + SQL_SUCCESS, + got_state=sqlstate(lib, SQL_HANDLE_DBC, dbc), + ) + + # SQLEndTran's own page: "calling SQLEndTran with either SQL_COMMIT or + # SQL_ROLLBACK when no transaction is active returns SQL_SUCCESS". Trino + # answers NOT_IN_TRANSACTION to the same statement, so a driver that + # forwards it would fail a call the spec requires to succeed. + check( + "commit with no transaction open", + lib.SQLEndTran(SQL_HANDLE_DBC, dbc, SQL_COMMIT), + SQL_SUCCESS, + got_state=sqlstate(lib, SQL_HANDLE_DBC, dbc), + ) + check( + "rollback with no transaction open", + lib.SQLEndTran(SQL_HANDLE_DBC, dbc, SQL_ROLLBACK), + SQL_SUCCESS, + got_state=sqlstate(lib, SQL_HANDLE_DBC, dbc), + ) + + r = lib.SQLSetConnectAttrW(dbc, SQL_ATTR_AUTOCOMMIT, SQL_AUTOCOMMIT_ON, 0) + check( + "autocommit can be turned back on", + r, + SQL_SUCCESS, + got_state=sqlstate(lib, SQL_HANDLE_DBC, dbc), + ) + + print("\n--- privilege catalog functions ---") + # pyodbc exposes no tablePrivileges()/columnPrivileges(), so this is the + # only suite that reaches SQLTablePrivilegesW and SQLColumnPrivilegesW at + # all. Against tpcds both answer an empty result set, for different reasons: + # + # - SQLTablePrivileges runs a real query against + # information_schema.table_privileges. It is empty for tpcds because that + # connector implements no permission management, not because the driver + # declines to look. A SQL_ERROR here means the query was rejected. The + # hive catalog runs sql-standard security, is probed below, and does + # return rows. + # - SQLColumnPrivileges reads nothing anywhere. Trino grants on tables, + # never on columns, and publishes no column-privilege metadata. + # + # Both must still describe their result set, because an application sizes + # its buffers from SQLNumResultCols before it fetches anything. + cat, _kp1 = w("tpcds") + sch, _kp2 = w("sf1") + tbl, _kp3 = w("call_center") + pct, _kp4 = w("%") + + r = lib.SQLTablePrivilegesW(stmt, cat, SQL_NTS, sch, SQL_NTS, tbl, SQL_NTS) + check( + "table privileges on a real table", + r, + SQL_SUCCESS, + got_state=sqlstate(lib, SQL_HANDLE_STMT, stmt), + ) + ncols = ctypes.c_short(0) + lib.SQLNumResultCols(stmt, ctypes.byref(ncols)) + check("table privileges describes 7 columns", ncols.value, 7) + check("table privileges is empty here", lib.SQLFetch(stmt), SQL_NO_DATA) + lib.SQLFreeStmt(stmt, SQL_CLOSE) + + r = lib.SQLColumnPrivilegesW( + stmt, cat, SQL_NTS, sch, SQL_NTS, tbl, SQL_NTS, pct, SQL_NTS + ) + check( + "column privileges on a real table", + r, + SQL_SUCCESS, + got_state=sqlstate(lib, SQL_HANDLE_STMT, stmt), + ) + ncols = ctypes.c_short(0) + lib.SQLNumResultCols(stmt, ctypes.byref(ncols)) + check("column privileges describes 8 columns", ncols.value, 8) + check("column privileges is empty", lib.SQLFetch(stmt), SQL_NO_DATA) + lib.SQLFreeStmt(stmt, SQL_CLOSE) + + # SQLColumnPrivileges is the only one of the four privilege/procedure + # functions whose spec page states "The TableName argument was a null + # pointer" *without* a (DM) marker, so the driver owns that HY009. Its + # three neighbours must not report one, which is why only this is probed. + r = lib.SQLColumnPrivilegesW( + stmt, cat, SQL_NTS, sch, SQL_NTS, None, SQL_NTS, pct, SQL_NTS + ) + check( + "column privileges with a null TableName", + r, + SQL_ERROR, + state="HY009", + got_state=sqlstate(lib, SQL_HANDLE_STMT, stmt), + ) + lib.SQLFreeStmt(stmt, SQL_CLOSE) + + # The hive catalog runs sql-standard security, so this is the only place + # metadata::table_privilege_row is exercised end to end rather than by its + # unit tests alone. The table is created here rather than assumed, so the + # probe does not depend on another suite having run first. + for setup_sql in ( + "CREATE TABLE IF NOT EXISTS hive.tx.c_abi_privileges (id integer)", + "GRANT SELECT ON hive.tx.c_abi_privileges TO USER bob", + ): + sql, _kp = w(setup_sql) + rc = lib.SQLExecDirectW(stmt, sql, SQL_NTS) + check( + f"setup: {setup_sql.split()[0]} for the privileges probe", + rc in (SQL_SUCCESS, SQL_SUCCESS_WITH_INFO, SQL_NO_DATA), + True, + got_state=sqlstate(lib, SQL_HANDLE_STMT, stmt), + ) + lib.SQLFreeStmt(stmt, SQL_CLOSE) + + hcat, _kp5 = w("hive") + hsch, _kp6 = w("tx") + htbl, _kp7 = w("c_abi_privileges") + r = lib.SQLTablePrivilegesW(stmt, hcat, SQL_NTS, hsch, SQL_NTS, htbl, SQL_NTS) + check( + "table privileges on a hive table", + r, + SQL_SUCCESS, + got_state=sqlstate(lib, SQL_HANDLE_STMT, stmt), + ) + check( + "table privileges returns rows where the connector grants", + lib.SQLFetch(stmt), + SQL_SUCCESS, + ) + lib.SQLFreeStmt(stmt, SQL_CLOSE) + + # --------------------------------------------------------------- + print("\n--- procedure catalog functions ---") + # Trino has callable procedures (CALL system.runtime.kill_query(...) is one) + # but publishes no metadata naming them: system.jdbc.procedures is a + # JDBC-compatibility view that is hardwired empty. An empty result set is + # therefore the honest answer, and it must still be a described one. + sysc, _kp5 = w("system") + runt, _kp6 = w("runtime") + + r = lib.SQLProceduresW(stmt, sysc, SQL_NTS, runt, SQL_NTS, pct, SQL_NTS) + check( + "procedures in system.runtime", + r, + SQL_SUCCESS, + got_state=sqlstate(lib, SQL_HANDLE_STMT, stmt), + ) + ncols = ctypes.c_short(0) + lib.SQLNumResultCols(stmt, ctypes.byref(ncols)) + check("procedures describes 8 columns", ncols.value, 8) + check("procedures is empty", lib.SQLFetch(stmt), SQL_NO_DATA) + lib.SQLFreeStmt(stmt, SQL_CLOSE) + + r = lib.SQLProcedureColumnsW( + stmt, sysc, SQL_NTS, runt, SQL_NTS, pct, SQL_NTS, pct, SQL_NTS + ) + check( + "procedure columns in system.runtime", + r, + SQL_SUCCESS, + got_state=sqlstate(lib, SQL_HANDLE_STMT, stmt), + ) + ncols = ctypes.c_short(0) + lib.SQLNumResultCols(stmt, ctypes.byref(ncols)) + check("procedure columns describes 19 columns", ncols.value, 19) + check("procedure columns is empty", lib.SQLFetch(stmt), SQL_NO_DATA) + lib.SQLFreeStmt(stmt, SQL_CLOSE) + + # --------------------------------------------------------------- + print("\n--- bound parameter types ---") + # Two probes over core's parameter handling, reached through this driver. + # Each pins a defect an application sees rather than a call's return code, + # and each falls back to a KNOWN note if it cannot be asserted. Tighten a + # note back into a `check` as soon as it can be. + # + # 1. SQLBindParameter's declared SQL type must survive. SQL_C_CHAR + + # SQL_NUMERIC is what a client sends for a numeric delivered as text, so + # a core that matched on the C type alone would hand Trino a string and + # `WHERE decimal_col = ?` would fail with TYPE_MISMATCH. + dec = ctypes.create_string_buffer(b"12.34") + dec_ind = ctypes.c_longlong(SQL_NTS) + sql, _kd = w("SELECT CAST(? AS VARCHAR)") + lib.SQLFreeStmt(stmt, SQL_CLOSE) + r = lib.SQLBindParameter( + stmt, 1, SQL_PARAM_INPUT, SQL_C_CHAR, SQL_NUMERIC, 10, 2, + ctypes.cast(dec, P), 0, ctypes.byref(dec_ind), + ) + check("bind a NUMERIC parameter delivered as characters", r, SQL_SUCCESS) + typed, _kt = w("SELECT typeof(?)") + r = lib.SQLExecDirectW(stmt, typed, SQL_NTS) + if r in (SQL_SUCCESS, SQL_SUCCESS_WITH_INFO) and lib.SQLFetch(stmt) == SQL_SUCCESS: + got = ctypes.create_string_buffer(64) + got_ind = ctypes.c_longlong(0) + lib.SQLGetData(stmt, 1, SQL_C_CHAR, ctypes.cast(got, P), 64, ctypes.byref(got_ind)) + trino_type = got.value.decode(errors="replace") + check( + f"NUMERIC parameter reaches Trino as a decimal (got {trino_type!r})", + SQL_SUCCESS if trino_type.startswith("decimal") else SQL_ERROR, + SQL_SUCCESS, + ) + else: + note("NUMERIC parameter type", "KNOWN: could not read the parameter's type back") + lib.SQLFreeStmt(stmt, SQL_RESET_PARAMS) + lib.SQLFreeStmt(stmt, SQL_CLOSE) + + # 2. A statement with a parameter marker and nothing bound. The spec's + # answer is 07002 (COUNT field incorrect). Padding the marker with NULL + # instead runs the statement with a value the application never supplied + # and tells it nothing. + unbound, _ku = w("SELECT 1 WHERE 2 = ?") + r = lib.SQLExecDirectW(stmt, unbound, SQL_NTS) + state = sqlstate(lib, SQL_HANDLE_STMT, stmt) + if r == SQL_ERROR and state == "07002": + check("unbound parameter marker", r, SQL_ERROR, state="07002", got_state=state) + else: + note( + "unbound parameter marker", + f"KNOWN: got {rname(r)} (SQLSTATE {state or 'none'}); core's collect_params " + "substitutes NULL for an unbound marker instead of reporting 07002", + ) + lib.SQLFreeStmt(stmt, SQL_CLOSE) + + print("\n--- cancel with nothing running ---") + r = lib.SQLCancel(stmt) + check("cancel an idle statement", r, SQL_SUCCESS) + + # --------------------------------------------------------------- + print("\n--- double free and use after free ---") + r = lib.SQLFreeHandle(SQL_HANDLE_STMT, stmt) + check("free statement", r, SQL_SUCCESS) + + r = lib.SQLFreeHandle(SQL_HANDLE_STMT, stmt) + check("free the same statement twice", r, SQL_INVALID_HANDLE) + + r = lib.SQLFetch(stmt) + check("fetch on a freed statement", r, SQL_INVALID_HANDLE) + + sql, _k3 = w("SELECT 1") + r = lib.SQLExecDirectW(stmt, sql, SQL_NTS) + check("execute on a freed statement", r, SQL_INVALID_HANDLE) + + r = lib.SQLDisconnect(dbc) + check("disconnect", r, SQL_SUCCESS) + + r = lib.SQLFreeHandle(SQL_HANDLE_DBC, dbc) + check("free connection", r, SQL_SUCCESS) + + r = lib.SQLFreeHandle(SQL_HANDLE_DBC, dbc) + check("free the same connection twice", r, SQL_INVALID_HANDLE) + + r = lib.SQLFreeHandle(SQL_HANDLE_ENV, env) + check("free env once its children are gone", r, SQL_SUCCESS) + + r = lib.SQLFreeHandle(SQL_HANDLE_ENV, env) + check("free the same env twice", r, SQL_INVALID_HANDLE) + + return R.summary() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/integration-tests/suites/test_describe_param.py b/integration-tests/suites/test_describe_param.py new file mode 100644 index 0000000..4cb0f76 --- /dev/null +++ b/integration-tests/suites/test_describe_param.py @@ -0,0 +1,324 @@ +#!/usr/bin/env python3 +"""`SQLDescribeParam`, against Trino's own `DESCRIBE INPUT`. + +Nothing exercised this against a coordinator. `describe_param.rs` was covered +only by unit tests over its row-to-descriptor conversion, so everything around +that conversion -- the `PREPARE` / `DESCRIBE INPUT` / `DEALLOCATE` round trip, +the per-connection cache, the fixed session-wide statement name, and what +happens when any of it fails -- was unverified. + +ctypes rather than pyodbc, because pyodbc exposes no `SQLDescribeParam`. There +is no Driver Manager in the loop either, so what is asserted is the driver's +own answer. + +What the call is *for* is the point of the first section. Core's fallback is a +uniform `VARCHAR(SQL_DEFAULT_PARAM_SIZE)` for every parameter, which makes a +client send a number as text and get a type error back. A driver that answered +the fallback for everything would pass any test that only checked for success, +so each probe asserts the specific type Trino inferred. + +Usage: + python3 integration-tests/suites/test_describe_param.py [path/to/lib...so] [conn-str] + +Requires a running Trino (integration-tests/setup.sh). No compose profile: the +tpcds and hive catalogs are both in the base stack. Standard library only. +""" + +import ctypes +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from harness import Results, Stack # noqa: E402 +from odbc_abi import ( # noqa: E402 + SQL_DRIVER_NOPROMPT, + SQL_HANDLE_DBC, + SQL_HANDLE_ENV, + SQL_HANDLE_STMT, + SQL_NTS, + SQL_SUCCESS, + SQL_SUCCESS_WITH_INFO, + SQL_ATTR_ODBC_VERSION, + SQL_OV_ODBC3, + load, + sqlstate, + w, +) + +R = Results("SQLDescribeParam") + +# Concise SQL types, from the ODBC spec's own table. Named rather than inlined, +# per the project's rule about spec values. +SQL_CHAR = 1 +SQL_INTEGER = 4 +SQL_VARCHAR = 12 +SQL_DECIMAL = 3 +SQL_BIGINT = -5 +SQL_WCHAR = -8 +SQL_WVARCHAR = -9 +SQL_TYPE_DATE = 91 + +TYPE_NAMES = { + SQL_CHAR: "SQL_CHAR", + SQL_INTEGER: "SQL_INTEGER", + SQL_VARCHAR: "SQL_VARCHAR", + SQL_DECIMAL: "SQL_DECIMAL", + SQL_BIGINT: "SQL_BIGINT", + SQL_WCHAR: "SQL_WCHAR", + SQL_WVARCHAR: "SQL_WVARCHAR", + SQL_TYPE_DATE: "SQL_TYPE_DATE", +} + +# Connection attribute, for the transaction probe at the end. +SQL_ATTR_AUTOCOMMIT = 102 +SQL_AUTOCOMMIT_OFF = 0 + +# The types core would answer with if the DESCRIBE INPUT round trip did not +# happen. A probe returning this has not proved anything, so the type probes +# assert against it explicitly rather than only against the expected value. +CORE_FALLBACK_TYPES = (SQL_VARCHAR, SQL_WVARCHAR) + + +def type_name(code): + return TYPE_NAMES.get(code, str(code)) + + +def describe(lib, stmt, n): + """`SQLDescribeParam` for parameter `n`, as (ret, type, size, digits, nullable).""" + dtype = ctypes.c_int16(0) + size = ctypes.c_uint64(0) + digits = ctypes.c_int16(0) + nullable = ctypes.c_int16(0) + ret = lib.SQLDescribeParam( + stmt, n, + ctypes.byref(dtype), ctypes.byref(size), + ctypes.byref(digits), ctypes.byref(nullable), + ) + return ret, dtype.value, size.value, digits.value, nullable.value + + +def prepare(lib, dbc, sql): + """A fresh statement handle with `sql` prepared on it.""" + stmt = ctypes.c_void_p() + if lib.SQLAllocHandle(SQL_HANDLE_STMT, dbc, ctypes.byref(stmt)) != SQL_SUCCESS: + return None + text, _keep = w(sql) + ret = lib.SQLPrepareW(stmt, text, SQL_NTS) + if ret not in (SQL_SUCCESS, SQL_SUCCESS_WITH_INFO): + lib.SQLFreeHandle(SQL_HANDLE_STMT, stmt) + return None + # `_keep` must outlive the call, and it does: SQLPrepareW copies the text. + return stmt + + +def probe_types(lib, dbc, label, sql, expected): + """Prepare `sql` and assert each parameter's described type. + + `expected` is a list of (sql_type, minimum_size) per parameter, in order. + """ + stmt = prepare(lib, dbc, sql) + if stmt is None: + R.bad(label, "the statement could not be prepared") + return + + count = ctypes.c_int16(0) + lib.SQLNumParams(stmt, ctypes.byref(count)) + R.check( + f"{label}: SQLNumParams", + count.value == len(expected), + "" if count.value == len(expected) else f" expected {len(expected)}, got {count.value}", + ) + + for i, (want_type, want_min_size) in enumerate(expected, start=1): + ret, dtype, size, digits, _nullable = describe(lib, stmt, i) + if ret not in (SQL_SUCCESS, SQL_SUCCESS_WITH_INFO): + R.bad( + f"{label}: parameter {i}", + f"SQLDescribeParam failed, {sqlstate(lib, SQL_HANDLE_STMT, stmt)}", + ) + continue + ok = dtype == want_type + R.check( + f"{label}: parameter {i} is {type_name(want_type)}", + ok, + "" if ok else f" got {type_name(dtype)} (size {size}, digits {digits})", + ) + # The whole point of the round trip: core's fallback is one uniform + # character type, so a driver answering that has described nothing. + if want_type not in CORE_FALLBACK_TYPES: + R.check( + f"{label}: parameter {i} is not core's generic fallback", + dtype not in CORE_FALLBACK_TYPES, + "" if dtype not in CORE_FALLBACK_TYPES + else " DESCRIBE INPUT did not happen, or its answer was discarded", + ) + if want_min_size is not None: + R.check( + f"{label}: parameter {i} carries a size", + size >= want_min_size, + "" if size >= want_min_size else f" expected >= {want_min_size}, got {size}", + ) + + lib.SQLFreeHandle(SQL_HANDLE_STMT, stmt) + + +def main(): + stack = Stack.load() + driver = sys.argv[1] if len(sys.argv) > 1 else stack.get("DRIVER_PATH") + conn_str = sys.argv[2] if len(sys.argv) > 2 else stack.conn_str() + + lib = load(driver) + env = ctypes.c_void_p() + lib.SQLAllocHandle(SQL_HANDLE_ENV, None, ctypes.byref(env)) + lib.SQLSetEnvAttr(env, SQL_ATTR_ODBC_VERSION, ctypes.c_void_p(SQL_OV_ODBC3), 0) + dbc = ctypes.c_void_p() + lib.SQLAllocHandle(SQL_HANDLE_DBC, env, ctypes.byref(dbc)) + + cs, _keep = w(conn_str) + outbuf = (ctypes.c_uint16 * 1024)() + outlen = ctypes.c_int16(0) + ret = lib.SQLDriverConnectW( + dbc, None, cs, SQL_NTS, + ctypes.cast(outbuf, ctypes.POINTER(ctypes.c_uint16)), 1024, + ctypes.byref(outlen), SQL_DRIVER_NOPROMPT, + ) + if ret not in (SQL_SUCCESS, SQL_SUCCESS_WITH_INFO): + print(f"cannot connect ({sqlstate(lib, SQL_HANDLE_DBC, dbc)}); is Trino running?") + return 1 + + print("=== SQLDescribeParam ===\n") + + # ------------------------------------------------------------------ + print("--- the type Trino inferred, not core's uniform guess ---") + # tpcds.sf1.customer types, read from the catalog: c_customer_sk is bigint, + # c_customer_id is char(16), c_birth_year is integer. + probe_types( + lib, dbc, "three columns of different types", + "SELECT 1 FROM tpcds.sf1.customer " + "WHERE c_customer_sk = ? AND c_customer_id = ? AND c_birth_year = ?", + [(SQL_BIGINT, None), (SQL_WCHAR, 16), (SQL_INTEGER, None)], + ) + # The case the CHANGELOG names: "a filter on a `decimal` column keeps its + # type". i_current_price is decimal(7,2). + probe_types( + lib, dbc, "a decimal column", + "SELECT 1 FROM tpcds.sf1.item WHERE i_current_price = ?", + [(SQL_DECIMAL, 7)], + ) + probe_types( + lib, dbc, "a date column", + "SELECT 1 FROM tpcds.sf1.date_dim WHERE d_date = ?", + [(SQL_TYPE_DATE, None)], + ) + + # ------------------------------------------------------------------ + print("\n--- the trailing statement terminator ---") + # Trino's grammar has no terminator, so `PREPARE x FROM SELECT ... ;` is a + # syntax error. `exec_direct` has always stripped it; `describe_param` wraps + # the same SQL in a PREPARE and did not, so a statement an application could + # prepare and run was one this could not describe. + probe_types( + lib, dbc, "a statement ending in a semicolon", + "SELECT 1 FROM tpcds.sf1.customer WHERE c_customer_sk = ? ;", + [(SQL_BIGINT, None)], + ) + + # ------------------------------------------------------------------ + print("\n--- the per-connection cache ---") + # `Backend::describe_param` receives no statement handle, so core calls it + # once per parameter. Without the cache a ten-parameter statement would cost + # ten round trips. Asserted by describing the same statement's parameters + # repeatedly and requiring a stable answer: a cache keyed on the wrong thing + # would answer parameter 1's type for parameter 2. + stmt = prepare( + lib, dbc, + "SELECT 1 FROM tpcds.sf1.customer WHERE c_customer_sk = ? AND c_birth_year = ?", + ) + if stmt is None: + R.bad("cache: prepare", "the statement could not be prepared") + else: + seen = [] + for _ in range(3): + for i in (1, 2): + seen.append(describe(lib, stmt, i)[1]) + R.check( + "repeated describes answer the same types in the same order", + seen == [SQL_BIGINT, SQL_INTEGER] * 3, + "" if seen == [SQL_BIGINT, SQL_INTEGER] * 3 + else f" got {[type_name(t) for t in seen]}", + ) + lib.SQLFreeHandle(SQL_HANDLE_STMT, stmt) + + # A different statement on the same connection must not be served the first + # one's answer: the cache is keyed on the SQL text. + probe_types( + lib, dbc, "a second statement is not served the first one's cache", + "SELECT 1 FROM tpcds.sf1.item WHERE i_current_price = ?", + [(SQL_DECIMAL, 7)], + ) + + # ------------------------------------------------------------------ + print("\n--- a statement Trino cannot prepare ---") + # Outside a transaction this degrades rather than failing: Trino declines to + # prepare plenty of legitimate statements, and core's fallback is usable for + # a call that only sizes a buffer. + stmt = prepare(lib, dbc, "SELECT 1 FROM no_such_catalog.s.t WHERE x = ?") + if stmt is None: + R.note("an unpreparable statement", "SQLPrepare itself refused it, so there is nothing to describe") + else: + ret, dtype, _size, _digits, _nullable = describe(lib, stmt, 1) + R.check( + "an unpreparable statement falls back rather than failing", + ret in (SQL_SUCCESS, SQL_SUCCESS_WITH_INFO), + "" if ret in (SQL_SUCCESS, SQL_SUCCESS_WITH_INFO) + else f" {sqlstate(lib, SQL_HANDLE_STMT, stmt)}", + ) + if ret in (SQL_SUCCESS, SQL_SUCCESS_WITH_INFO): + R.check( + "and the fallback is core's uniform character type", + dtype in CORE_FALLBACK_TYPES, + "" if dtype in CORE_FALLBACK_TYPES else f" got {type_name(dtype)}", + ) + lib.SQLFreeHandle(SQL_HANDLE_STMT, stmt) + + # ------------------------------------------------------------------ + print("\n--- inside a transaction, the same failure is reported ---") + # Trino carries the transaction id in a session header, so the driver's own + # PREPARE joins whatever the application has open, and a statement error + # aborts the whole transaction. Falling back silently there would report + # success from SQLDescribeParam while the application's transaction had just + # been killed by a round trip it did not make and cannot see. + lib.SQLSetConnectAttrW( + dbc, SQL_ATTR_AUTOCOMMIT, ctypes.c_void_p(SQL_AUTOCOMMIT_OFF), 0 + ) + # Open the transaction with a statement that works. + opener = ctypes.c_void_p() + lib.SQLAllocHandle(SQL_HANDLE_STMT, dbc, ctypes.byref(opener)) + sql, _k = w("SELECT 1") + lib.SQLExecDirectW(opener, sql, SQL_NTS) + lib.SQLFreeHandle(SQL_HANDLE_STMT, opener) + + stmt = prepare(lib, dbc, "SELECT 1 FROM no_such_catalog.s.t WHERE x = ?") + if stmt is None: + R.note("unpreparable inside a transaction", "SQLPrepare itself refused it") + else: + ret, _dtype, _size, _digits, _nullable = describe(lib, stmt, 1) + R.check( + "an unpreparable statement inside a transaction reports the failure", + ret not in (SQL_SUCCESS, SQL_SUCCESS_WITH_INFO), + "" if ret not in (SQL_SUCCESS, SQL_SUCCESS_WITH_INFO) + else " reported success, so the aborted transaction went unmentioned", + ) + lib.SQLFreeHandle(SQL_HANDLE_STMT, stmt) + + lib.SQLEndTran(SQL_HANDLE_DBC, dbc, 1) # SQL_ROLLBACK, to free the session + + lib.SQLDisconnect(dbc) + lib.SQLFreeHandle(SQL_HANDLE_DBC, dbc) + lib.SQLFreeHandle(SQL_HANDLE_ENV, env) + return R.summary() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/integration-tests/suites/test_escapes.py b/integration-tests/suites/test_escapes.py new file mode 100644 index 0000000..c13bcbd --- /dev/null +++ b/integration-tests/suites/test_escapes.py @@ -0,0 +1,481 @@ +#!/usr/bin/env python3 +"""Escape-sequence contract: every capability the driver advertises, executed. + +`src/backend/info.rs` states the rule these bitmaps follow: + + a bit may only be set when `translate_escapes`, driven by + `crate::escape_dialect`, turns that escape into Trino SQL that runs. A bit + whose name `rewrite_scalar_fn` does not handle reaches the coordinator + verbatim and fails there. + +Until this suite existed nothing checked that against a coordinator. The Rust +side has `untranslatable_escapes_are_never_advertised`, which compares a bitmap +against a list of names, and unit tests that compare the rewriter's output +against expected strings. Neither submits the result to Trino, so an argument +order, a unit spelling or a value conversion could be wrong in both places at +once and agree with itself. + +What is checked here: + +1. **Every advertised scalar function runs.** The claimed names are parsed out + of `src/backend/info.rs` rather than transcribed, so a bit added there + without an entry in `CALLS` below fails this suite instead of shipping + unchecked. +2. **The rewritten ones return the right value.** A rename cannot be wrong in + an interesting way, but `DAYOFWEEK` renumbers, `TIMESTAMPDIFF` has an + argument order, `LOG` is a different function from Trino's `log`, and + `RAND` drops a seed Trino would read as a bound. Those are asserted on the + result, not on the absence of an error. `ATAN2` is asserted the same way + despite having no rewrite, because passing its arguments through in the + caller's order is a deliberate deviation from the ODBC appendix and the + inputs are chosen so that the two readings disagree. +3. **Every `SQL_TSI_*` interval the driver advertises** works in both + `TIMESTAMPADD` and `TIMESTAMPDIFF`, parsed from `TRINO_TIMESTAMP_INTERVALS` + in `src/backend.rs`. +4. **Every `{fn CONVERT}` target**, parsed from `trino_convert_target` in + `src/escape_dialect.rs`, because `SQL_CONVERT_FUNCTIONS` reports + `SQL_FN_CVT_CAST` and a client reading that may send any ODBC type keyword. +5. **The `{d}`, `{t}` and `{ts}` literal escapes**, which have their own + renderers in `escape_dialect::dialect()`. + +Usage: + uv run --with pyodbc python3 integration-tests/suites/test_escapes.py "" + +Requires a running Trino (integration-tests/setup.sh). Needs no compose +profile: every query here is catalog-free. +""" + +import os +import re +import sys + +import pyodbc + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from harness import Results, Stack # noqa: E402 + +R = Results("escape sequences") + +PROJECT_DIR = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) +INFO_RS = os.path.join(PROJECT_DIR, "src", "backend", "info.rs") +BACKEND_RS = os.path.join(PROJECT_DIR, "src", "backend.rs") +DIALECT_RS = os.path.join(PROJECT_DIR, "src", "escape_dialect.rs") + +# One `{fn ...}` call per advertised capability, and the value it must return. +# +# `None` for an expected value means "assert only that it runs": the result is +# either the clock, the session, or a float whose exact text is not the point. +# Everything the dialect *transforms* carries a value, because that is where a +# translation can be plausible and wrong. +# +# The keys are the `SQL_FN_*` names as `src/backend/info.rs` spells them. A name +# advertised there and missing here is a failure, not a skip. +CALLS = { + # --- strings --------------------------------------------------------- + "SQL_FN_STR_CONCAT": ("{fn CONCAT('ab', 'cd')}", "abcd"), + # Rewritten to the two-argument `ltrim`, because ODBC removes *blanks* and + # Trino's one-argument form removes every kind of whitespace. The leading + # tab has to survive; a pass-through would eat it and answer "ab". + "SQL_FN_STR_LTRIM": ("{fn LTRIM(' ' || chr(9) || 'ab')}", "\tab"), + # Rewritten to `length(rtrim(x, ' '))`: ODBC counts characters "excluding + # trailing blanks" and Trino's `length` counts them. The trailing spaces + # are the discriminator, and a pass-through answers 6. + "SQL_FN_STR_LENGTH": ("{fn LENGTH('abc ')}", 3), + # Renamed to `lower`. + "SQL_FN_STR_LCASE": ("{fn LCASE('AbC')}", "abc"), + # Renamed to `upper`. + "SQL_FN_STR_UCASE": ("{fn UCASE('AbC')}", "ABC"), + # Rewritten to `position(sub IN str)`. ODBC's argument order is the + # opposite of Trino's `strpos`, so a wrong rewrite still returns a number. + "SQL_FN_STR_LOCATE_2": ("{fn LOCATE('cd', 'abcdef')}", 3), + # Already Trino's syntax, so this proves the escape is left alone. + "SQL_FN_STR_POSITION": ("{fn POSITION('cd' IN 'abcdef')}", 3), + "SQL_FN_STR_REPLACE": ("{fn REPLACE('abcabc', 'b', 'X')}", "aXcaXc"), + # The RTRIM half of the same blanks-versus-whitespace split as LTRIM above. + "SQL_FN_STR_RTRIM": ("{fn RTRIM('ab' || chr(9) || ' ')}", "ab\t"), + "SQL_FN_STR_SUBSTRING": ("{fn SUBSTRING('abcdef', 2, 3)}", "bcd"), + # Renamed to `chr`. + "SQL_FN_STR_CHAR": ("{fn CHAR(65)}", "A"), + # Passed through. Trino gained soundex() in 356; see the bitmap's comment. + "SQL_FN_STR_SOUNDEX": ("{fn SOUNDEX('Robert')}", "R163"), + # --- numerics -------------------------------------------------------- + "SQL_FN_NUM_ABS": ("{fn ABS(-3)}", 3), + "SQL_FN_NUM_ACOS": ("{fn ACOS(1)}", 0.0), + "SQL_FN_NUM_ASIN": ("{fn ASIN(0)}", 0.0), + "SQL_FN_NUM_ATAN": ("{fn ATAN(0)}", 0.0), + # Passed through with the arguments in the order the caller wrote them, + # which is a deliberate deviation from the ODBC appendix; the reasoning is + # in `rewrite_scalar_fn`, next to the arm ATAN2 deliberately does not have. + # These inputs discriminate: reading the first argument as y, which is what + # Trino and every peer implementation do, gives atan(1/2) below, while the + # appendix's first-argument-is-x reading would give atan(2) = 1.1071487. + "SQL_FN_NUM_ATAN2": ("{fn ATAN2(1, 2)}", 0.4636476090008061), + "SQL_FN_NUM_CEILING": ("{fn CEILING(1.2)}", 2), + "SQL_FN_NUM_COS": ("{fn COS(0)}", 1.0), + "SQL_FN_NUM_EXP": ("{fn EXP(0)}", 1.0), + "SQL_FN_NUM_FLOOR": ("{fn FLOOR(1.8)}", 1), + # ODBC's LOG is the natural logarithm and Trino's `log` is base-b, so this + # is renamed to `ln`. LOG(1) is 0 either way; the discriminator is that a + # base-b `log` needs two arguments and would fail to resolve. + "SQL_FN_NUM_LOG": ("{fn LOG(1)}", 0.0), + "SQL_FN_NUM_MOD": ("{fn MOD(7, 3)}", 1), + "SQL_FN_NUM_SIGN": ("{fn SIGN(-5)}", -1), + "SQL_FN_NUM_SIN": ("{fn SIN(0)}", 0.0), + "SQL_FN_NUM_SQRT": ("{fn SQRT(9)}", 3.0), + "SQL_FN_NUM_TAN": ("{fn TAN(0)}", 0.0), + "SQL_FN_NUM_PI": ("{fn PI()}", None), + # The seeded form: Trino reads that argument as a bound and would answer an + # integer in [0, 5). The rewrite drops it, so the result must be a fraction. + "SQL_FN_NUM_RAND": ("{fn RAND(5)}", None), + "SQL_FN_NUM_DEGREES": ("{fn DEGREES(0)}", 0.0), + "SQL_FN_NUM_LOG10": ("{fn LOG10(100)}", 2.0), + "SQL_FN_NUM_POWER": ("{fn POWER(2, 3)}", 8.0), + "SQL_FN_NUM_RADIANS": ("{fn RADIANS(0)}", 0.0), + # Passed through, and the negative digit count is the case worth asserting: + # ODBC specifies rounding to the left of the decimal point for a negative + # argument, and Trino's math reference documents that only for `truncate`. + # It works for round too, so this pins the behaviour the docs omit. + "SQL_FN_NUM_ROUND": ("{fn ROUND(1234.5, -2)}", 1200.0), + # Rewritten to scaled arithmetic. Trino's two-argument `truncate` is + # declared over `decimal` alone, so this has to be a DOUBLE: a decimal + # literal here would pass against the pass-through that FUNCTION_NOT_FOUNDs + # on every float column, which is the case ODBC's `numeric_exp` covers. + "SQL_FN_NUM_TRUNCATE": ("{fn TRUNCATE(CAST(1.99 AS DOUBLE), 1)}", 1.9), + # --- system ---------------------------------------------------------- + # Both lose their parentheses: Trino takes them as bare SQL-92 keywords. + "SQL_FN_SYS_USERNAME": ("{fn USERNAME()}", None), + "SQL_FN_SYS_DBNAME": ("{fn DBNAME()}", None), + # Renamed to two-argument `coalesce`. + "SQL_FN_SYS_IFNULL": ("{fn IFNULL(NULL, 'fallback')}", "fallback"), + # --- date and time --------------------------------------------------- + "SQL_FN_TD_NOW": ("{fn NOW()}", None), + "SQL_FN_TD_CURDATE": ("{fn CURDATE()}", None), + "SQL_FN_TD_CURTIME": ("{fn CURTIME()}", None), + "SQL_FN_TD_CURRENT_DATE": ("{fn CURRENT_DATE()}", None), + "SQL_FN_TD_CURRENT_TIME": ("{fn CURRENT_TIME()}", None), + "SQL_FN_TD_CURRENT_TIMESTAMP": ("{fn CURRENT_TIMESTAMP()}", None), + "SQL_FN_TD_DAYOFMONTH": ("{fn DAYOFMONTH(DATE '2021-02-03')}", 3), + # 2021-02-07 is a Sunday, which is 1 in ODBC's numbering and 7 in Trino's + # ISO one. A rename alone returns 7 here: plausible, and silently wrong. + "SQL_FN_TD_DAYOFWEEK": ("{fn DAYOFWEEK(DATE '2021-02-07')}", 1), + "SQL_FN_TD_DAYOFYEAR": ("{fn DAYOFYEAR(DATE '2021-02-03')}", 34), + "SQL_FN_TD_MONTH": ("{fn MONTH(DATE '2021-02-03')}", 2), + "SQL_FN_TD_QUARTER": ("{fn QUARTER(DATE '2021-02-03')}", 1), + # Trino's week() is ISO-numbered, which the bitmap documents as a caveat, + # so this asserts only that it runs. + "SQL_FN_TD_WEEK": ("{fn WEEK(DATE '2021-02-03')}", None), + "SQL_FN_TD_YEAR": ("{fn YEAR(DATE '2021-02-03')}", 2021), + "SQL_FN_TD_HOUR": ("{fn HOUR(TIMESTAMP '2021-02-03 04:05:06')}", 4), + "SQL_FN_TD_MINUTE": ("{fn MINUTE(TIMESTAMP '2021-02-03 04:05:06')}", 5), + "SQL_FN_TD_SECOND": ("{fn SECOND(TIMESTAMP '2021-02-03 04:05:06')}", 6), + "SQL_FN_TD_EXTRACT": ("{fn EXTRACT(YEAR FROM DATE '2021-02-03')}", 2021), + # Covered per interval unit below as well; this pins the plain shape. + "SQL_FN_TD_TIMESTAMPADD": + ("{fn TIMESTAMPADD(SQL_TSI_DAY, 2, DATE '2021-02-03')}", None), + # ODBC defines TIMESTAMPDIFF(interval, a, b) as b - a. Reversed, this + # returns -2 rather than 2, which is exactly as plausible. + "SQL_FN_TD_TIMESTAMPDIFF": + ("{fn TIMESTAMPDIFF(SQL_TSI_DAY, DATE '2021-02-03', DATE '2021-02-05')}", 2), +} + +# The bitmaps whose names are checked, and the Rust constant each is built from. +BITMAPS = { + "SQL_STRING_FUNCTIONS": "TRINO_STRING_FUNCTIONS", + "SQL_NUMERIC_FUNCTIONS": "TRINO_NUMERIC_FUNCTIONS", + "SQL_SYSTEM_FUNCTIONS": "TRINO_SYSTEM_FUNCTIONS", + "SQL_TIMEDATE_FUNCTIONS": "TRINO_TIMEDATE_FUNCTIONS", +} + +def escape_keyword(bitmap_name): + """The `{fn}` keyword for an interval whose *bit* is named `bitmap_name`. + + ODBC names the bit `SQL_FN_TSI_DAY` and the escape keyword `SQL_TSI_DAY`, + so the two differ by the `FN_`. Passing the bit's name through unchanged + reaches Trino as a bare identifier and fails with `COLUMN_NOT_FOUND`, which + is the same failure `escape_dialect.rs` describes for an unhandled name. + """ + return bitmap_name.replace("SQL_FN_TSI_", "SQL_TSI_") + + +# One `TIMESTAMPADD` / `TIMESTAMPDIFF` probe per interval keyword, as +# (added-to-2021-02-03T04:05:06, difference between two values one unit apart). +# The pair proves the unit reached Trino as the right word: `date_add('day',...)` +# and `date_add('hour',...)` both run, and only the result tells them apart. +INTERVAL_PROBES = { + "SQL_FN_TSI_SECOND": ("TIMESTAMP '2021-02-03 04:05:06'", "TIMESTAMP '2021-02-03 04:05:07'"), + "SQL_FN_TSI_MINUTE": ("TIMESTAMP '2021-02-03 04:05:06'", "TIMESTAMP '2021-02-03 04:06:06'"), + "SQL_FN_TSI_HOUR": ("TIMESTAMP '2021-02-03 04:05:06'", "TIMESTAMP '2021-02-03 05:05:06'"), + "SQL_FN_TSI_DAY": ("TIMESTAMP '2021-02-03 04:05:06'", "TIMESTAMP '2021-02-04 04:05:06'"), + "SQL_FN_TSI_WEEK": ("TIMESTAMP '2021-02-03 04:05:06'", "TIMESTAMP '2021-02-10 04:05:06'"), + "SQL_FN_TSI_MONTH": ("TIMESTAMP '2021-02-03 04:05:06'", "TIMESTAMP '2021-03-03 04:05:06'"), + "SQL_FN_TSI_QUARTER": ("TIMESTAMP '2021-02-03 04:05:06'", "TIMESTAMP '2021-05-03 04:05:06'"), + "SQL_FN_TSI_YEAR": ("TIMESTAMP '2021-02-03 04:05:06'", "TIMESTAMP '2022-02-03 04:05:06'"), +} + +# A value each ODBC CONVERT target can be reached from. `{fn CONVERT}` carries +# no length, so a character target has to come from something short. +CONVERT_SOURCES = { + "SQL_BIGINT": "1", "SQL_INTEGER": "1", "SQL_SMALLINT": "1", "SQL_TINYINT": "1", + "SQL_DOUBLE": "1", "SQL_FLOAT": "1", "SQL_REAL": "1", + "SQL_DECIMAL": "1", "SQL_NUMERIC": "1", + "SQL_BIT": "true", + "SQL_CHAR": "'ab'", "SQL_VARCHAR": "'ab'", "SQL_LONGVARCHAR": "'ab'", + "SQL_WCHAR": "'ab'", "SQL_WVARCHAR": "'ab'", "SQL_WLONGVARCHAR": "'ab'", + "SQL_BINARY": "'ab'", "SQL_VARBINARY": "'ab'", "SQL_LONGVARBINARY": "'ab'", + "SQL_DATE": "'2021-02-03'", "SQL_TYPE_DATE": "'2021-02-03'", + "SQL_TIME": "'04:05:06'", "SQL_TYPE_TIME": "'04:05:06'", + "SQL_TIMESTAMP": "'2021-02-03 04:05:06'", + "SQL_TYPE_TIMESTAMP": "'2021-02-03 04:05:06'", + "SQL_GUID": "'12151fd2-7586-11e9-8f9e-2a86e4085a59'", +} + + +def read(path): + with open(path, encoding="utf-8") as f: + return f.read() + + +def advertised_names(source, constant): + """The `SQL_FN_*` names OR'd together into `constant`. + + Parsed from the driver's own source, so a capability added to the bitmap + without a probe here fails this suite rather than shipping unchecked. That + is the whole point: a hand-copied list would drift silently, which is what + happened to the bitmap's own documentation. + """ + m = re.search( + rf"const {constant}: u32 =(.*?);", source, re.DOTALL + ) + if not m: + return [] + return re.findall(r"\b(SQL_FN_[A-Z0-9_]+)\b", m.group(1)) + + +def convert_targets(source): + """The ODBC type keywords `trino_convert_target` maps, with their targets.""" + m = re.search( + r"fn trino_convert_target\(.*?\n\}", source, re.DOTALL + ) + if not m: + return {} + targets = {} + for keywords, trino in re.findall( + r'((?:"SQL_[A-Z_]+"\s*\|?\s*)+)=>\s*Some\("([A-Z ]+)"\)', m.group(0) + ): + for kw in re.findall(r'"(SQL_[A-Z_]+)"', keywords): + targets[kw] = trino + return targets + + +def scalar(cur, sql): + return cur.execute(f"SELECT {sql}").fetchone()[0] + + +def matches(got, expected): + """Compare loosely enough for Trino's numeric types, exactly otherwise. + + A Trino DECIMAL arrives as `decimal.Decimal` and a DOUBLE as `float`, and + which one a scalar expression yields is Trino's business, not the escape's. + """ + if expected is None: + return True + if isinstance(expected, float) or isinstance(expected, int): + try: + return abs(float(got) - float(expected)) < 1e-9 + except (TypeError, ValueError): + return False + return str(got) == str(expected) + + +def main(): + conn_str = sys.argv[1] if len(sys.argv) > 1 else Stack.load().conn_str() + + info_src = read(INFO_RS) + backend_src = read(BACKEND_RS) + dialect_src = read(DIALECT_RS) + + conn = pyodbc.connect(conn_str, autocommit=True) + cur = conn.cursor() + + print("=== escape sequences ===") + + # ------------------------------------------------------------------ + print("\n--- every advertised scalar function has a probe ---") + # Checked before anything runs. A bit added to a bitmap without an entry in + # CALLS is a capability nothing here executes, and a suite that quietly + # skipped it would report a clean run over an unchecked claim. + claimed = [] + for info_type, constant in BITMAPS.items(): + names = advertised_names(info_src, constant) + R.check( + f"{constant} parsed out of info.rs", + bool(names), + "" if names else " (the suite cannot see what the driver claims)", + ) + claimed.extend(names) + unprobed = sorted(set(claimed) - set(CALLS)) + R.check( + "every advertised name has a probe in CALLS", + not unprobed, + "" if not unprobed else f" add one for: {', '.join(unprobed)}", + ) + stale = sorted(set(CALLS) - set(claimed)) + R.check( + "every probe in CALLS is still advertised", + not stale, + "" if not stale else f" no longer in a bitmap: {', '.join(stale)}", + ) + + # ------------------------------------------------------------------ + print(f"\n--- {len(claimed)} advertised scalar functions execute ---") + for name in sorted(set(claimed) & set(CALLS)): + call, expected = CALLS[name] + try: + got = scalar(cur, call) + except pyodbc.Error as e: + R.bad(f"{name}: {call}", str(e)[:110]) + continue + R.check( + f"{name}: {call}", + matches(got, expected), + "" if matches(got, expected) else f" expected {expected!r}, got {got!r}", + ) + + # The two whose value is a range rather than a constant. + try: + pi = float(scalar(cur, "{fn PI()}")) + ok = abs(pi - 3.14159265358979) < 1e-9 + R.check("{fn PI()} is pi", ok, "" if ok else f" (got {pi})") + except (pyodbc.Error, TypeError, ValueError) as e: + R.bad("{fn PI()} is pi", str(e)[:110]) + try: + # The seed is dropped, so this must be a fraction in [0, 1). Trino + # reading the 5 as a bound would give an integer in [0, 5). + rand = float(scalar(cur, "{fn RAND(5)}")) + R.check( + "{fn RAND(seed)} returns a fraction, not an integer in [0, seed)", + 0.0 <= rand < 1.0, + "" if 0.0 <= rand < 1.0 else f" (got {rand})", + ) + except (pyodbc.Error, TypeError, ValueError) as e: + R.bad("{fn RAND(seed)} returns a fraction", str(e)[:110]) + + # ------------------------------------------------------------------ + print("\n--- every advertised interval unit works in both directions ---") + intervals = advertised_names(backend_src, "TRINO_TIMESTAMP_INTERVALS") + R.check("TRINO_TIMESTAMP_INTERVALS parsed out of backend.rs", bool(intervals)) + unprobed = sorted(set(intervals) - set(INTERVAL_PROBES)) + R.check( + "every advertised interval has a probe", + not unprobed, + "" if not unprobed else f" add one for: {', '.join(unprobed)}", + ) + for bit in intervals: + if bit not in INTERVAL_PROBES: + continue + unit = escape_keyword(bit) + base, one_later = INTERVAL_PROBES[bit] + # ODBC defines TIMESTAMPDIFF(interval, a, b) as b - a, so this is +1. + # A reversed argument order returns -1, which no error would reveal. + try: + diff = scalar(cur, f"{{fn TIMESTAMPDIFF({unit}, {base}, {one_later})}}") + R.check( + f"TIMESTAMPDIFF({unit}) counts b - a", + int(diff) == 1, + "" if int(diff) == 1 else f" expected 1, got {diff}", + ) + except (pyodbc.Error, TypeError, ValueError) as e: + R.bad(f"TIMESTAMPDIFF({unit})", str(e)[:110]) + # And adding one unit to the base reaches the later value. This is what + # proves the unit reached Trino as the right word: `date_add('day',...)` + # and `date_add('hour',...)` both run, and only the result separates them. + try: + added = scalar(cur, f"{{fn TIMESTAMPADD({unit}, 1, {base})}}") + expected = scalar(cur, one_later) + R.check( + f"TIMESTAMPADD({unit}) advances by one unit", + str(added) == str(expected), + "" if str(added) == str(expected) else f" expected {expected}, got {added}", + ) + except (pyodbc.Error, TypeError, ValueError) as e: + R.bad(f"TIMESTAMPADD({unit})", str(e)[:110]) + + # ------------------------------------------------------------------ + print("\n--- every {fn CONVERT} target casts ---") + # SQL_CONVERT_FUNCTIONS reports SQL_FN_CVT_CAST, so a client may send any + # ODBC type keyword. One with no arm reaches Trino as a bare identifier and + # fails with COLUMN_NOT_FOUND, which is what the dialect's own comment says. + targets = convert_targets(dialect_src) + R.check( + "trino_convert_target parsed out of escape_dialect.rs", + len(targets) > 15, + "" if len(targets) > 15 else f" (parsed {sorted(targets)})", + ) + missing_source = sorted(set(targets) - set(CONVERT_SOURCES)) + R.check( + "every CONVERT target has a source value to cast from", + not missing_source, + "" if not missing_source else f" add one for: {', '.join(missing_source)}", + ) + for keyword in sorted(targets): + if keyword not in CONVERT_SOURCES: + continue + call = f"{{fn CONVERT({CONVERT_SOURCES[keyword]}, {keyword})}}" + try: + scalar(cur, call) + R.ok(f"{call} -> {targets[keyword]}") + except pyodbc.Error as e: + R.bad(f"{call} -> {targets[keyword]}", str(e)[:110]) + + # ------------------------------------------------------------------ + print("\n--- {fn TRUNCATE} keeps the argument's type ---") + # Trino's two-argument `truncate` is decimal-only, so the escape scales into + # the single-argument form. ODBC requires TRUNCATE to return "the same data + # type as the input parameters", which holds only because the scale factor + # is an integer literal: `power(10, d)` is double-valued and would drag + # decimal and real to double. Asserted on `typeof`, because the value alone + # cannot tell the two rewrites apart. + for source, want_type in [ + ("CAST(1.99 AS DECIMAL(3,2))", "decimal"), + ("CAST(1.99 AS DOUBLE)", "double"), + ("CAST(1.99 AS REAL)", "real"), + ]: + for digits in (1, 0, -1): + call = f"{{fn TRUNCATE({source}, {digits})}}" + try: + got = scalar(cur, f"typeof(({call}))") + ok = str(got).startswith(want_type) + R.check( + f"{call} -> {want_type}", + ok, + "" if ok else f" (got {got!r}, so the scale factor widened it)", + ) + except pyodbc.Error as e: + R.bad(f"{call} -> {want_type}", str(e)[:110]) + + # ------------------------------------------------------------------ + print("\n--- the {d} {t} {ts} literal escapes ---") + # Rendered by `render_date`/`render_time`/`render_timestamp` in + # escape_dialect.rs, which prefix Trino's own type keyword. Asserted on the + # value, so a renderer that produced a parseable literal for the wrong + # instant is caught. + for call, expected in [ + ("{d '2021-02-03'}", "2021-02-03"), + ("{t '04:05:06'}", "04:05:06"), + ("{ts '2021-02-03 04:05:06'}", "2021-02-03 04:05:06"), + ]: + try: + got = scalar(cur, call) + ok = str(got).startswith(expected) + R.check(f"{call}", ok, "" if ok else f" (got {got!r})") + except pyodbc.Error as e: + R.bad(f"{call}", str(e)[:110]) + + cur.close() + conn.close() + return R.summary() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/integration-tests/suites/test_folding_contract.py b/integration-tests/suites/test_folding_contract.py new file mode 100644 index 0000000..5fb8f5b --- /dev/null +++ b/integration-tests/suites/test_folding_contract.py @@ -0,0 +1,375 @@ +#!/usr/bin/env python3 +""" +Folding contract test: the Power Query connector against the driver and Trino. + +The connector tells Power Query how to render SQL: which constants it can cast, +what a LIMIT clause looks like, which capabilities to assume. No other suite +exercises any of that. The pyodbc, C ABI and surface tests drive the driver +directly and never load the `.mez`, and the only other check on folding is a +human clicking "View Native Query" in Power BI Desktop, one step at a time. A +connector declaration can therefore drift from what the driver reports or what +Trino accepts, and nothing notices. + +This checks the three halves that are mechanically checkable: + +1. **The Constant visitor's keys.** Power Query looks each one up by + `typeInfo[TYPE_NAME]`, which is the driver's own `SQLGetTypeInfo` output. A + key that matches no TYPE_NAME can never fire, so the cast it declares is + dead. A TYPE_NAME with no key folds nothing, so Power Query evaluates that + constant locally instead of sending it. + +2. **The SQL the connector emits.** Every CAST target it names has to be a type + Trino has, and the LIMIT/OFFSET form its AstVisitor builds has to + parse. Both are asserted by running them. + +3. **The temporal format strings.** A visitor entry for a date, time or + timestamp renders the value through a .NET custom format string before + quoting it, and that string is not checked by anything else here: a target + type can be valid while the literal handed to it is not. `.sssssss` looks + plausible and is seven seconds fields rather than a fractional second, + because in a custom format string `s` is the second and `f` is the fraction. + The formats are translated and the rendered literal is sent to Trino. + +Usage: + uv run --with pyodbc python3 integration-tests/suites/test_folding_contract.py "" + +Requires a running Trino (integration-tests/setup.sh) and `pip install pyodbc`, +normally through `uv run --with pyodbc`. Needs no compose profile: every query +here runs against the base stack. + +Output is PASS / FAIL / NOTE, matching test_c_abi.py. A NOTE is a folding gap: +legal, but it means Power Query falls back to local evaluation for that type. +""" + +import os +import re +import sys + +import pyodbc + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from harness import Results, Stack # noqa: E402 + +R = Results("folding contract") + +CONNECTOR = os.path.join( + os.path.dirname(os.path.abspath(__file__)), "..", "..", "connector", "StackableTrinoODBC.pq" +) + +# A literal of each type, for the CAST the connector would emit. The value only +# has to be castable; the point is whether Trino knows the target type. +SAMPLE = { + "DATE": "'2020-01-01'", + "TIMESTAMP": "'2020-01-01 12:00:00'", + "TIME": "'12:00:00'", + "BOOLEAN": "true", +} + +# TYPE_NAMEs that exist only so the Windows Driver Manager's +# SQLGetTypeInfo(SQL_CHAR=1) / SQLGetTypeInfo(SQL_VARCHAR=12) lookups find a +# row (`DM_COMPAT_ONLY` in src/backend/info.rs). `trino_bare_type_name` never +# returns either for a real column, so Power Query can never look a visitor +# entry up by one, an entry for them would be dead config, and listing them +# as a folding gap misreports a gap that cannot exist. +DM_COMPAT_ONLY = {"SQL_CHAR", "SQL_VARCHAR"} + + +def check(label, ok, detail=""): + R.check(f"{label}{detail}", ok) + + +def note(label, text): + R.note(label, text) + + +def parse_constant_visitor(source): + """Map each Constant-visitor key to the SQL type it casts to. + + Matches the `KEY = each Cast(..., "TYPE")` entries of the AstVisitor's + Constant record. Parsed from the connector rather than transcribed, so this + test cannot drift from it. + """ + visitor = {} + # The cast target is the last quoted argument of the Cast(...) call, and it + # is always upper case. Anchoring on that distinguishes it from an earlier + # quoted argument. `Cast(Quote(Date.ToText(_, "yyyy-MM-dd")), "DATE")` + # would otherwise yield the date format instead of the type. + for key, target in re.findall( + r'(\w+)\s*=\s*each\s+Cast\(.*?"([A-Z][A-Z ]*)"\s*\)', source + ): + visitor[key] = target + return visitor + + +def parse_temporal_formats(source): + """Map each Constant-visitor key to the .NET format string it renders with. + + Only the entries that quote a rendered value have one: + `Cast(Quote(DateTime.ToText(_, "")), "TIMESTAMP")`. + """ + return dict( + re.findall( + r'(\w+)\s*=\s*each\s+Cast\(\s*Quote\(\s*(?:Date|DateTime|Time)' + r'\.ToText\(\s*_\s*,\s*"([^"]+)"', + source, + ) + ) + + +# The .NET custom date/time specifiers the connector is allowed to use, longest +# first so `mm` is matched before `m` would be. Anything else is rejected rather +# than guessed at: an unrecognised specifier is exactly the defect this looks +# for, and silently passing it through would render it as a literal. +NET_SPECIFIERS = [ + ("yyyy", "%Y"), + ("MM", "%m"), + ("dd", "%d"), + ("HH", "%H"), + ("mm", "%M"), + ("ss", "%S"), +] + +# The instant every rendered literal describes, chosen so each field is +# distinct: a format that swapped month for minute, or seconds for the +# fraction, produces a different string rather than an accidentally equal one. +SAMPLE_INSTANT = { + "%Y": "2021", + "%m": "02", + "%d": "03", + "%H": "04", + "%M": "05", + "%S": "06", +} +SAMPLE_FRACTION = "1234567" + + +def render_net_format(fmt): + """Render `fmt` for `SAMPLE_INSTANT`, or raise ValueError if it cannot be. + + A run of `f`/`F` is the fractional second, rendered to the width asked for. + A run of any other letter that is not a known specifier is an error: `sss` + is not a wider seconds field, it is a mistake for `fff`. + """ + out = [] + i = 0 + while i < len(fmt): + ch = fmt[i] + # The run of `ch` starting at `i`, measured on the remaining suffix. + run = len(fmt[i:]) - len(fmt[i:].lstrip(ch)) + if ch in "fF": + if run > 7: + raise ValueError(f"fractional-seconds run of {run} exceeds .NET's 7 digits") + out.append(SAMPLE_FRACTION[:run].ljust(run, "0")) + i += run + continue + for token, strf in NET_SPECIFIERS: + if fmt.startswith(token, i): + # A longer run of the same letter is not the specifier: `sssssss` + # starts with `ss` but is not a wider seconds field, and reading + # it as one would hide exactly the defect this looks for. + if run != len(token): + raise ValueError( + f"{ch!r} repeated {run} times is not a specifier; " + f"{token!r} is the field, and 'f' is the fractional second" + ) + out.append(SAMPLE_INSTANT[strf]) + i += run + break + else: + if ch.isalpha(): + raise ValueError(f"unrecognised format specifier {ch!r}") + out.append(ch) + i += 1 + return "".join(out) + + +def parse_limit_clause(source): + """Render the row-limiting clause the AstVisitor builds for skip and take. + + Reads the format strings *and* the order the `Text = ...` expression + concatenates them in. Trino's grammar is `OFFSET count LIMIT count` and + rejects the reverse, so only the order proves the clause is usable. + """ + formats = { + name: fmt + for name, fmt in re.findall( + r'(limit|offset)\s*=\s*if\b.*?Text\.Format\("([^"]+)"', source + ) + } + if "limit" not in formats or "offset" not in formats: + return None + + order = re.search(r"Text\s*=\s*([a-zA-Z&\s]+?),\s*\n", source) + if not order: + return None + names = [n for n in re.findall(r"\b(limit|offset)\b", order.group(1))] + if sorted(names) != ["limit", "offset"]: + return None + + return " ".join(formats[n].strip().replace("#{0}", "2") for n in names) + + +def main(): + # A connection string may be passed positionally; with no argument the + # local stack describes itself. + conn_str = sys.argv[1] if len(sys.argv) > 1 else Stack.load().conn_str() + + with open(CONNECTOR, encoding="utf-8") as f: + source = f.read() + + visitor = parse_constant_visitor(source) + if not visitor: + print("FAIL could not parse the Constant visitor out of the connector") + return 1 + + conn = pyodbc.connect(conn_str, autocommit=True) + cur = conn.cursor() + type_names = {r[0] for r in cur.getTypeInfo().fetchall()} + + print(f"=== folding contract ===\nconnector: {os.path.normpath(CONNECTOR)}") + print(f"visitor entries: {len(visitor)}, driver TYPE_NAMEs: {len(type_names)}\n") + + # ------------------------------------------------------------------ + print("--- every Constant visitor key is a TYPE_NAME the driver reports ---") + # Power Query looks the key up by TYPE_NAME. One that matches nothing is + # dead config, and dead config hides the absence of the entry that should + # have been there. That is how a Postgres-derived key list survives in a + # Trino connector. + for key in sorted(visitor): + check( + f"visitor key {key!r} is a driver TYPE_NAME", + key in type_names, + "" if key in type_names else f" (casts to {visitor[key]}, can never fire)", + ) + + # ------------------------------------------------------------------ + print("\n--- every CAST target the connector emits is a Trino type ---") + for key in sorted(visitor): + target = visitor[key] + sample = SAMPLE.get(target, "1") + sql = f"SELECT CAST({sample} AS {target})" + try: + cur.execute(sql).fetchall() + check(f"CAST(... AS {target}) for key {key}", True) + except pyodbc.Error as e: + check(f"CAST(... AS {target}) for key {key}", False, f" {str(e)[:90]}") + + # ------------------------------------------------------------------ + print("\n--- the temporal literals the Constant visitor renders parse ---") + # A valid CAST target with an unrenderable literal folds a filter into SQL + # Trino rejects, or worse, into one it accepts with the wrong instant. The + # target check above cannot see this: it substitutes its own SAMPLE literal. + formats = parse_temporal_formats(source) + check( + "the temporal visitor entries declare a format string", + len(formats) >= 3, + "" if len(formats) >= 3 else f" (parsed {sorted(formats)})", + ) + for key in sorted(formats): + target = visitor.get(key, key) + try: + literal = render_net_format(formats[key]) + except ValueError as e: + check(f"{key} format {formats[key]!r} is renderable", False, f" {e}") + continue + sql = f"SELECT CAST('{literal}' AS {target})" + try: + got = cur.execute(sql).fetchone()[0] + # Round-tripped, not merely accepted: Trino parses a great many + # malformed-looking strings, and the failure that matters is a + # literal that lands on a different instant. + rendered_back = str(got) + fields_present = all( + v in rendered_back for v in ("2021", "02", "03", "04", "05", "06") + if v in literal + ) + check( + f"{key} renders {literal!r}, which CASTs to {target}", + fields_present, + "" if fields_present else f" (Trino read it back as {rendered_back!r})", + ) + except pyodbc.Error as e: + check( + f"{key} renders {literal!r}, which CASTs to {target}", + False, + f" {str(e)[:90]}", + ) + + # ------------------------------------------------------------------ + print("\n--- the row-limiting clause the AstVisitor builds parses ---") + rendered = parse_limit_clause(source) + check("AstVisitor's LimitClause could be parsed", rendered is not None) + if rendered: + # Skip 2 of 1..5 and take 2, so the rows themselves prove the clause + # was applied in the intended sense and not merely accepted. + sql = f"SELECT x FROM (VALUES 1,2,3,4,5) t(x) ORDER BY x {rendered}" + try: + rows = [r[0] for r in cur.execute(sql).fetchall()] + check(f"{rendered!r} skips 2 and takes 2", rows == [3, 4], f" (got {rows})") + except pyodbc.Error as e: + check(f"{rendered!r} executes", False, f" {str(e)[:90]}") + + # ------------------------------------------------------------------ + print("\n--- declared SqlCapabilities match what Trino does ---") + # SupportsDerivedTable = true: Power Query wraps folded queries in a + # subselect, so this has to hold or every folded query breaks. + if "SupportsDerivedTable = true" in source: + try: + cur.execute("SELECT count(*) FROM (SELECT 1 AS x) s").fetchall() + check("SupportsDerivedTable = true", True) + except pyodbc.Error as e: + check("SupportsDerivedTable = true", False, f" {str(e)[:90]}") + + # SupportsTop = false: Trino has no TOP, so the declaration is honest only + # if TOP really is rejected. A driver that started accepting it would make + # the connector needlessly emit LIMIT. + if "SupportsTop = false" in source: + try: + cur.execute("SELECT TOP 1 x FROM (VALUES 1,2) t(x)").fetchall() + check("SupportsTop = false", False, " Trino accepted TOP after all") + except pyodbc.Error: + check("SupportsTop = false", True, " (Trino rejects TOP, as declared)") + + # ------------------------------------------------------------------ + print("\n--- the connector does not disable what the driver supports ---") + # The connector's own rule is that an override is for what the driver gets + # *wrong*, because it silently wins and cannot be corrected by fixing the + # driver. Setting `Config_UseParameterBindings = false` declares + # `SQL_API_SQLBINDPARAMETER = false`, which contradicts the driver: + # `get_functions` lists `BindParameter`, and test_sql_surface.py exercises + # parameters in every clause that takes one. + # + # Asserted on the flag rather than on the string `SQL_API_SQLBINDPARAMETER + # = false`, which also appears in the branch the flag makes unreachable. + bindings_on = re.search(r"Config_UseParameterBindings\s*=\s*true", source) is not None + check( + "Config_UseParameterBindings leaves SQLBindParameter enabled", + bindings_on, + "" if bindings_on else " (set false, which disables a function the driver declares)", + ) + + # ------------------------------------------------------------------ + print("\n--- driver types with no Constant visitor entry ---") + # Not a failure: an absent key makes Power Query evaluate that constant + # locally rather than fold it. It is named because a missing entry is + # invisible from the connector alone: nothing there lists the types it does + # not handle. + unhandled = sorted(type_names - set(visitor) - DM_COMPAT_ONLY) + if unhandled: + note( + "constants that do not fold", + f"{len(unhandled)} driver TYPE_NAMEs have no visitor entry: " + + ", ".join(unhandled), + ) + else: + check("every driver TYPE_NAME has a visitor entry", True) + + cur.close() + conn.close() + + return R.summary() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/integration-tests/suites/test_harness.py b/integration-tests/suites/test_harness.py new file mode 100644 index 0000000..effa7c4 --- /dev/null +++ b/integration-tests/suites/test_harness.py @@ -0,0 +1,269 @@ +#!/usr/bin/env python3 +"""Unit tests for the shared suite harness. + +Standard library only, and needs no running stack. This is the one suite in +`suites/` that tests the test infrastructure rather than the driver. +""" + +import io +import os +import sys +import tempfile +import unittest +from contextlib import redirect_stdout + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +import registry # noqa: E402 +from harness import Results, Stack # noqa: E402 + + +class TestResults(unittest.TestCase): + def test_ok_counts_and_prints_pass(self): + r = Results("t") + out = io.StringIO() + with redirect_stdout(out): + r.ok("a thing") + self.assertEqual(r.passed, 1) + self.assertEqual(r.failed, 0) + self.assertIn("PASS a thing", out.getvalue()) + + def test_bad_counts_and_prints_fail_with_detail(self): + r = Results("t") + out = io.StringIO() + with redirect_stdout(out): + r.bad("a thing", "because reasons") + self.assertEqual(r.failed, 1) + self.assertIn("FAIL a thing: because reasons", out.getvalue()) + + def test_check_returns_its_condition(self): + r = Results("t") + with redirect_stdout(io.StringIO()): + self.assertTrue(r.check("yes", True)) + self.assertFalse(r.check("no", False)) + self.assertEqual((r.passed, r.failed), (1, 1)) + + def test_run_records_a_raising_callable_as_a_failure(self): + r = Results("t") + out = io.StringIO() + with redirect_stdout(out): + r.run("explodes", lambda: (_ for _ in ()).throw(ValueError("boom"))) + self.assertEqual(r.failed, 1) + self.assertIn("boom", out.getvalue()) + + def test_run_prints_elapsed_time(self): + r = Results("t") + out = io.StringIO() + with redirect_stdout(out): + r.run("quick", lambda: None) + self.assertEqual(r.passed, 1) + self.assertRegex(out.getvalue(), r"PASS quick \(\d+\.\ds\)") + + def test_skip_is_counted_separately_and_names_a_reason(self): + r = Results("t") + out = io.StringIO() + with redirect_stdout(out): + r.skip("tls", "profile 'oauth' is not active") + self.assertEqual((r.passed, r.failed, r.skipped), (0, 0, 1)) + self.assertIn("SKIP tls: profile 'oauth' is not active", out.getvalue()) + + def test_note_is_neither_pass_nor_fail(self): + r = Results("t") + with redirect_stdout(io.StringIO()): + r.note("observation", "a statement may be allocated before connecting") + self.assertEqual((r.passed, r.failed, r.notes), (0, 0, 1)) + + def test_summary_exit_code_is_zero_only_when_nothing_failed(self): + r = Results("t") + with redirect_stdout(io.StringIO()): + r.ok("a") + self.assertEqual(r.summary(), 0) + r.bad("b") + self.assertEqual(r.summary(), 1) + + def test_summary_reports_skips_so_they_cannot_read_as_passes(self): + r = Results("t") + out = io.StringIO() + with redirect_stdout(out): + r.ok("a") + r.skip("b", "no profile") + r.summary() + self.assertIn("1 passed, 0 failed, 1 skipped", out.getvalue()) + + +STACK_ENV = """\ +# generated by scripts/gen-odbc-config.sh, do not edit +DRIVER_PATH=/tmp/libstackable_odbc_trino.so +TRINO_HOST=localhost +TRINO_HTTPS_PORT=8443 +TRINO_USER=admin +TRINO_PASSWORD=admin +TRINO_CATALOG=tpcds +CA_CERT=/tmp/certs/ca.crt +CLIENT_PEM="/tmp/certs/client.pem" +PROFILES=oauth,spooling +""" + + +class TestStack(unittest.TestCase): + def setUp(self): + fd, self.path = tempfile.mkstemp(suffix=".env") + with os.fdopen(fd, "w") as f: + f.write(STACK_ENV) + self.addCleanup(os.unlink, self.path) + self.stack = Stack.load(self.path) + + def test_comments_and_blank_lines_are_ignored(self): + self.assertIsNone(self.stack.get("# generated by scripts/gen-odbc-config.sh")) + self.assertEqual(self.stack.get("TRINO_HOST"), "localhost") + + def test_quoted_values_are_unquoted(self): + self.assertEqual(self.stack.get("CLIENT_PEM"), "/tmp/certs/client.pem") + + def test_missing_key_returns_the_default(self): + self.assertEqual(self.stack.get("NOPE", "fallback"), "fallback") + + def test_profiles_split_on_comma(self): + self.assertEqual(self.stack.profiles, ["oauth", "spooling"]) + self.assertTrue(self.stack.has_profile("spooling")) + self.assertFalse(self.stack.has_profile("nosuchprofile")) + + def test_empty_profiles_is_an_empty_list_not_a_list_containing_empty(self): + with open(self.path, "a") as f: + f.write("PROFILES=\n") + self.assertEqual(Stack.load(self.path).profiles, []) + + def test_conn_str_defaults_to_https_with_the_ca(self): + cs = self.stack.conn_str() + self.assertIn("Driver=/tmp/libstackable_odbc_trino.so", cs) + self.assertIn("Host=localhost", cs) + self.assertIn("Port=8443", cs) + self.assertIn("Protocol=https", cs) + self.assertIn("Certificate=/tmp/certs/ca.crt", cs) + self.assertIn("Catalog=tpcds", cs) + + def test_conn_str_override_replaces_a_value(self): + self.assertIn("Catalog=postgresql", self.stack.conn_str(Catalog="postgresql")) + + def test_conn_str_override_of_none_removes_the_key(self): + cs = self.stack.conn_str(Password=None) + self.assertNotIn("Password=", cs) + self.assertIn("User=admin", cs) + + def test_conn_str_adds_a_key_that_has_no_default(self): + cs = self.stack.conn_str(TlsVerify="ca") + self.assertIn("TlsVerify=ca", cs) + + def test_conn_str_is_semicolon_separated_with_no_trailing_separator(self): + cs = self.stack.conn_str() + self.assertFalse(cs.endswith(";")) + self.assertNotIn(";;", cs) + + def test_dsn_string_carries_only_the_dsn(self): + self.assertEqual(self.stack.dsn("trino_https"), "DSN=trino_https") + + def test_a_missing_stack_env_names_the_command_that_writes_it(self): + with self.assertRaises(SystemExit) as caught: + Stack.load("/nonexistent/stack.env") + self.assertIn("setup.sh", str(caught.exception)) + + def test_driver_ref_is_the_path_when_no_name_is_given(self): + self.assertEqual(self.stack.driver_ref, "/tmp/libstackable_odbc_trino.so") + + def test_driver_name_names_the_driver_without_moving_the_path(self): + """The Windows split: the Driver Manager loads by registered name, + while the ctypes suites still need the DLL on disk.""" + with open(self.path, "a") as f: + f.write("DRIVER_NAME=stackable_odbc_trino\n") + stack = Stack.load(self.path) + self.assertEqual(stack.driver_ref, "stackable_odbc_trino") + self.assertEqual(stack.get("DRIVER_PATH"), "/tmp/libstackable_odbc_trino.so") + self.assertIn("Driver=stackable_odbc_trino;", stack.conn_str()) + + +class TestRegistry(unittest.TestCase): + """The registry is read by two runners, and a mistake in it surfaces on + whichever one is run next rather than here. These are the checks that do + not need a stack, a VM or a driver.""" + + def test_every_suite_script_exists(self): + here = os.path.dirname(os.path.abspath(__file__)) + for suite in registry.SUITES: + self.assertTrue( + os.path.exists(os.path.join(here, suite.script)), + f"{suite.name}: {suite.script} does not exist", + ) + + def test_every_deploy_path_exists_and_is_repo_relative(self): + """A `deploy` entry is resolved against the repository root, and a path + that does not exist is a Windows-only failure at deploy time.""" + for suite in registry.SUITES: + for rel in suite.deploy: + self.assertFalse(os.path.isabs(rel), f"{suite.name}: {rel} is absolute") + self.assertTrue( + os.path.exists(os.path.join(registry.PROJECT_DIR, rel)), + f"{suite.name}: {rel} does not exist", + ) + + def test_every_argv_kind_is_one_the_runners_render(self): + for suite in registry.SUITES: + self.assertIn( + suite.argv, ("conn", "driver+conn", "driver", "none"), suite.name, + ) + + def test_suite_names_are_unique_and_carry_no_field_separator(self): + """The Linux runner passes entries through a `name|profile|command` + line, so a `|` in a name would split into the wrong fields.""" + names = [s.name for s in registry.SUITES] + self.assertEqual(len(names), len(set(names))) + for name in names: + self.assertNotIn("|", name) + + def test_a_suite_not_running_on_windows_gives_a_reason(self): + """`windows=False` would print an empty SKIP reason, which is exactly + the unrun-but-looks-fine case the harness exists to prevent.""" + for suite in registry.SUITES: + if not suite.runs_on_windows: + self.assertTrue( + suite.windows_skip_reason.strip(), + f"{suite.name}: skipped on Windows with no reason", + ) + + def test_linux_entries_expand_the_matrix_and_quote_their_arguments(self): + stack = Stack.load(_TEMP_STACK_ENV[0]) + entries = list(registry.linux_entries(stack)) + names = [name for name, _, _ in entries] + self.assertEqual( + len([n for n in names if n.startswith("integration (")]), + len(registry.LINUX_CONFIGS), + ) + # The connection string carries semicolons, which an unquoted command + # would let the shell read as statement separators. + conn = [c for n, _, c in entries if n == "sql surface"][0] + self.assertIn("'Driver=/tmp/libstackable_odbc_trino.so;", conn) + + def test_a_suite_needing_a_profile_names_one_the_stack_can_have(self): + for suite in registry.SUITES: + if suite.profile: + self.assertIn(suite.profile, ("oauth", "spooling", "hive"), suite.name) + + +# registry.linux_entries needs a Stack, and building one needs a file. The +# TestStack fixture is per-test, so the registry tests get their own. +_TEMP_STACK_ENV = [] + + +def setUpModule(): + fd, path = tempfile.mkstemp(suffix=".env") + with os.fdopen(fd, "w") as f: + f.write(STACK_ENV) + _TEMP_STACK_ENV.append(path) + + +def tearDownModule(): + for path in _TEMP_STACK_ENV: + os.unlink(path) + + +if __name__ == "__main__": + unittest.main() diff --git a/integration-tests/suites/test_integration.py b/integration-tests/suites/test_integration.py new file mode 100644 index 0000000..684efd6 --- /dev/null +++ b/integration-tests/suites/test_integration.py @@ -0,0 +1,761 @@ +#!/usr/bin/env python3 +""" +Integration tests for the Trino ODBC driver. + +Runs through the ODBC Driver Manager (unixODBC on Linux, odbc32.dll on Windows) +using pyodbc. Tests connection, metadata queries, SELECT, aggregation, and +parameterised statements against the tpcds catalog (read-only, ships with Trino). + +Usage: + python3 integration-tests/suites/test_integration.py "Driver=/path/to/driver.so;Host=localhost;Port=8080;User=admin;Protocol=http;Catalog=tpcds" + python3 integration-tests/suites/test_integration.py "DSN=test_trino" + +Requires a running Trino (integration-tests/setup.sh) and `pip install pyodbc`. +Needs no compose profile: the tpcds catalog is in the base stack. +""" + +import os +import sys + +import pyodbc + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from harness import Results, Stack # noqa: E402 + +R = Results("integration") + + +def conn_str_value(conn_str, wanted_key): + """The value of `wanted_key` in a connection string, or None if absent.""" + for part in conn_str.split(";"): + key, _, value = part.partition("=") + if key.strip().lower() == wanted_key: + return value.strip() + return None + + +def conn_str_catalog(conn_str): + """The `Catalog` value of a connection string, or None for a DSN.""" + return conn_str_value(conn_str, "catalog") + + +def main(): + # A connection string may be passed positionally. run-tests.sh does, to + # drive this suite against several configurations, and windows_test.py does + # because the VM's stack is not this one. With no argument, the local stack + # describes itself. + conn_str = sys.argv[1] if len(sys.argv) > 1 else Stack.load().conn_str() + conn = pyodbc.connect(conn_str, autocommit=True) + cur = conn.cursor() + + # ------------------------------------------------------------------ + # Basic connectivity + # ------------------------------------------------------------------ + def test_select_1(): + cur.execute("SELECT 1") + row = cur.fetchone() + assert row is not None + assert row[0] == 1, f"expected 1, got {row[0]!r}" + + R.run("SELECT 1", test_select_1) + + # ------------------------------------------------------------------ + # Metadata queries + # ------------------------------------------------------------------ + def test_show_catalogs(): + cur.execute("SHOW CATALOGS") + catalogs = [r[0].strip() for r in cur.fetchall()] + assert "tpcds" in catalogs, f"tpcds not in catalogs: {catalogs}" + + R.run("SHOW CATALOGS", test_show_catalogs) + + def test_show_schemas(): + cur.execute("SHOW SCHEMAS FROM tpcds") + schemas = [r[0].strip() for r in cur.fetchall()] + assert "sf1" in schemas, f"sf1 not in schemas: {schemas}" + + R.run("SHOW SCHEMAS FROM tpcds", test_show_schemas) + + def test_show_tables(): + cur.execute("SHOW TABLES FROM tpcds.sf1") + tables = [r[0].strip() for r in cur.fetchall()] + assert "customer" in tables, f"customer not in tables: {tables}" + assert "item" in tables, f"item not in tables: {tables}" + + R.run("SHOW TABLES FROM tpcds.sf1", test_show_tables) + + # ------------------------------------------------------------------ + # SELECT queries + # ------------------------------------------------------------------ + def test_select_with_limit(): + cur.execute("SELECT c_customer_sk, c_first_name, c_last_name FROM tpcds.sf1.customer LIMIT 5") + rows = cur.fetchall() + assert len(rows) == 5, f"expected 5 rows, got {len(rows)}" + assert rows[0][0] is not None, "c_customer_sk should not be NULL" + + R.run("SELECT with LIMIT", test_select_with_limit) + + def test_select_with_where(): + cur.execute("SELECT c_first_name, c_last_name FROM tpcds.sf1.customer WHERE c_customer_sk = 1") + row = cur.fetchone() + assert row is not None, "expected a row for c_customer_sk=1" + assert row[0].strip() == "Javier", f"expected Javier, got {row[0]!r}" + + R.run("SELECT with WHERE", test_select_with_where) + + def test_empty_result(): + cur.execute("SELECT c_customer_sk FROM tpcds.sf1.customer WHERE 1 = 0") + rows = cur.fetchall() + assert len(rows) == 0, f"expected 0 rows, got {len(rows)}" + + R.run("Empty result set (WHERE 1=0)", test_empty_result) + + def test_column_metadata(): + cur.execute("SELECT c_customer_sk, c_first_name, c_birth_year FROM tpcds.sf1.customer LIMIT 1") + assert cur.description is not None, "cursor.description should not be None" + col_names = [d[0] for d in cur.description] + assert len(col_names) == 3, f"expected 3 columns, got {len(col_names)}" + cur.fetchall() + + R.run("Column metadata", test_column_metadata) + + # ------------------------------------------------------------------ + # Aggregation + # ------------------------------------------------------------------ + def test_count(): + cur.execute("SELECT COUNT(*) FROM tpcds.sf1.customer") + row = cur.fetchone() + assert row is not None + count = row[0] + assert count > 0, f"expected count > 0, got {count}" + + R.run("COUNT(*)", test_count) + + def test_group_by(): + cur.execute(""" + SELECT c_birth_month, COUNT(*) AS cnt + FROM tpcds.sf1.customer + WHERE c_birth_month IS NOT NULL + GROUP BY c_birth_month + ORDER BY c_birth_month + """) + rows = cur.fetchall() + assert len(rows) == 12, f"expected 12 months, got {len(rows)}" + assert rows[0][0] == 1, f"first month should be 1, got {rows[0][0]}" + + R.run("GROUP BY + ORDER BY", test_group_by) + + def test_aggregation_functions(): + cur.execute(""" + SELECT MIN(c_birth_year), MAX(c_birth_year), COUNT(DISTINCT c_birth_year) + FROM tpcds.sf1.customer + WHERE c_birth_year IS NOT NULL + """) + row = cur.fetchone() + assert row is not None + min_year, max_year, distinct_years = row[0], row[1], row[2] + assert min_year < max_year, f"min {min_year} should be < max {max_year}" + assert distinct_years > 1, f"expected multiple distinct years, got {distinct_years}" + + R.run("MIN + MAX + COUNT(DISTINCT)", test_aggregation_functions) + + # ------------------------------------------------------------------ + # Multiple WHERE conditions, with literals rather than parameters + # ------------------------------------------------------------------ + def test_where_integer(): + cur.execute("SELECT c_first_name FROM tpcds.sf1.customer WHERE c_customer_sk = 1") + row = cur.fetchone() + assert row is not None, "expected a row for c_customer_sk=1" + assert row[0].strip() == "Javier", f"expected Javier, got {row[0]!r}" + + R.run("WHERE integer equality", test_where_integer) + + def test_where_range(): + cur.execute("SELECT COUNT(*) FROM tpcds.sf1.customer WHERE c_customer_sk BETWEEN 1 AND 10") + row = cur.fetchone() + assert row[0] == 10, f"expected 10, got {row[0]}" + + R.run("WHERE BETWEEN range", test_where_range) + + def test_reexecute_different_queries(): + for sk, expected_name in [(1, "Javier"), (2, "Amy"), (3, "Latisha")]: + cur.execute(f"SELECT c_first_name FROM tpcds.sf1.customer WHERE c_customer_sk = {sk}") + row = cur.fetchone() + assert row is not None, f"no row for c_customer_sk={sk}" + assert row[0].strip() == expected_name, f"expected {expected_name!r}, got {row[0]!r}" + + R.run("Re-execute with different queries", test_reexecute_different_queries) + + # ------------------------------------------------------------------ + # Multiple sequential queries + # ------------------------------------------------------------------ + def test_sequential_queries(): + cur.execute("SELECT 1") + assert cur.fetchone()[0] == 1 + cur.execute("SELECT 2") + assert cur.fetchone()[0] == 2 + cur.execute("SELECT COUNT(*) FROM tpcds.sf1.customer") + assert cur.fetchone()[0] > 0 + + R.run("Sequential queries on same cursor", test_sequential_queries) + + # ------------------------------------------------------------------ + # Unicode roundtrip + # ------------------------------------------------------------------ + def test_unicode_roundtrip(): + cases = [ + ("Japanese", "日本語"), + ("emoji", "🎉🦀"), + ("accents", "café résumé"), + ("mixed accents", "Ünïcödé"), + ] + for label, val in cases: + cur.execute(f"SELECT '{val}'") + row = cur.fetchone() + assert row is not None, f"no row for {label}" + assert row[0] == val, f"{label}: expected {val!r}, got {row[0]!r}" + + R.run("Unicode roundtrip (Japanese, emoji, accents)", test_unicode_roundtrip) + + # ------------------------------------------------------------------ + # SQLGetData type coercion + # ------------------------------------------------------------------ + def test_getdata_integer_as_char(): + import struct + # c_customer_sk is a BIGINT column in Trino's TPC-DS schema (SQL_BIGINT = -5). + # Registering an output converter causes pyodbc to skip SQLBindCol and + # instead call SQLGetData(SQL_C_BINARY) for that column, exercising the + # integer→binary coercion path. The converter decodes the 8-byte LE value. + SQL_BIGINT = -5 + received = [] + def decode_integer(b): + val = struct.unpack("` behind an `Auth`, and this + driver builds a `Client` per connection, so without `OAUTH2_LOGINS` a pool + warming three connections would open three browsers. + + The open connections are handed back so the next scenario can reuse the key + without a login of its own. + """ + shim.reset("login") + opened = [] + for i in range(3): + t0 = time.monotonic() + ret, env, dbc = odbc_connect(lib, oauth_conn(stack)) + opened.append((env, dbc)) + ok = check_connected( + lib, + dbc, + ret, + f"connection {i + 1} of 3 authenticates interactively " + f"({time.monotonic() - t0:.1f}s)", + ) + if not ok: + for failure in shim.failures(): + R.bad("the browser reported", failure.get("error", "")) + for env, dbc in opened: + disconnect(lib, env, dbc) + return [] + + R.check( + "exactly one browser launch serves three connections", + len(shim.launches()) == 1, + f"{len(shim.launches())} launches recorded: {shim.outcomes()}", + ) + return opened + + +def scenario_the_token_supplies_the_user(lib, stack, shim, opened): + """`X-Trino-User` is left off entirely and Trino resolves the user from the + token. Reusing the previous scenario's key also asserts the login cache: no + second browser may be launched.""" + if not opened: + R.skip( + "the token supplies the session user", + "the interactive connection did not succeed", + ) + return + _env, dbc = opened[0] + user = scalar(lib, dbc, "SELECT current_user") + R.check( + "the session user comes from the token, with no X-Trino-User sent", + user == stack.get("TRINO_USER"), + f"current_user is {user!r}, expected {stack.get('TRINO_USER')!r}", + ) + R.check( + "reusing the login opened no second browser", + len(shim.launches()) == 1, + f"{len(shim.launches())} launches recorded", + ) + + +def scenario_a_matching_user_is_honoured(lib, stack, shim): + """A `User` equal to what the identity provider's mapping produces is + harmless, so naming it must not fail the connection. + + Its own entry in `OAUTH2_LOGINS`, since the key carries the user and the + first scenario connected with none. + """ + shim.reset("login") + ret, env, dbc = odbc_connect(lib, oauth_conn(stack, User=stack.get("TRINO_USER"))) + try: + if not check_connected( + lib, dbc, ret, "a User matching the token's identity is accepted" + ): + return + user = scalar(lib, dbc, "SELECT current_user") + R.check( + "the session runs as that same user", + user == stack.get("TRINO_USER"), + f"current_user is {user!r}", + ) + finally: + disconnect(lib, env, dbc) + + +def scenario_a_disagreeing_user_is_refused(lib, stack, shim): + """A `User` that disagrees with the token's identity is an impersonation + request, and Trino refuses it. + + This is the behaviour that made `User` optional under + `ExternalAuthentication`: Trino takes the session user from the + authenticated identity when the header is absent, and puts a header that + disagrees through `checkCanImpersonateUser`. Trino's default system access + control denies that, so an operator obliged to invent a `User` gets the + connection refused for their own account. + """ + shim.reset("login") + ret, env, dbc = odbc_connect(lib, oauth_conn(stack, User=IMPOSTOR)) + try: + state = sqlstate(lib, SQL_HANDLE_DBC, dbc) + message = diag_message(lib, SQL_HANDLE_DBC, dbc) + refused = ret == SQL_ERROR + R.check( + "a User disagreeing with the token is refused", + refused, + # Shown only on failure: connecting means the session is running as + # somebody the application never authenticated as. + "" if refused else f"returned {ret}, so the impersonation was granted", + ) + R.check( + "the refusal reports 28000", + state == INVALID_AUTH_SPEC, + f"state is {state!r}: {message}", + ) + R.check( + "the diagnostic names the impersonation", + "impersonate" in message.lower(), + f"message is {message!r}", + ) + finally: + disconnect(lib, env, dbc) + + +def scenario_an_abandoned_login_times_out(lib, stack, shim): + """A login nobody completes ends at `ExternalAuthenticationTimeout`, with + `28000`. + + The elapsed time is asserted as well as the SQLSTATE: a failure arriving + after some other budget expired would be the timeout not working, reported + as though it were. + """ + shim.reset("noop") + t0 = time.monotonic() + ret, env, dbc = odbc_connect( + lib, + oauth_conn( + stack, + User="abandoned", + ExternalAuthenticationTimeout=str(ABANDON_BUDGET_SECONDS), + ), + ) + elapsed = time.monotonic() - t0 + try: + state = sqlstate(lib, SQL_HANDLE_DBC, dbc) + failed = ret == SQL_ERROR + R.check( + f"an abandoned login fails the connection ({elapsed:.1f}s)", + failed, + "" if failed else f"returned {ret} with state {state!r}", + ) + R.check( + "an abandoned login reports 28000", + state == INVALID_AUTH_SPEC, + f"state is {state!r}: {diag_message(lib, SQL_HANDLE_DBC, dbc)}", + ) + R.check( + "the login budget is what ended the wait", + ABANDON_BUDGET_SECONDS - 1 <= elapsed <= ABANDON_BUDGET_SECONDS + 15, + f"{elapsed:.1f}s against a {ABANDON_BUDGET_SECONDS}s budget", + ) + presented = shim.outcomes() == ["presented"] + R.check( + "the browser was launched and presented the URL", + presented, + "" if presented else f"records: {shim.launches()}", + ) + finally: + disconnect(lib, env, dbc) + + +def scenario_a_refused_login_reports_28000(lib, stack, shim): + """A login the identity provider refuses fails at once, not at the budget. + + Elapsed time is what separates the two failures: a refusal the coordinator + ignored would still end the connection, just at + `ExternalAuthenticationTimeout`, and the SQLSTATE alone cannot tell them + apart. + """ + shim.reset("deny") + t0 = time.monotonic() + ret, env, dbc = odbc_connect(lib, oauth_conn(stack, User="refused")) + elapsed = time.monotonic() - t0 + try: + denied = shim.outcomes() == ["denied"] + if not R.check( + "the browser delivered a refusal", + denied, + "" if denied else f"records: {shim.launches()}", + ): + return + state = sqlstate(lib, SQL_HANDLE_DBC, dbc) + failed = ret == SQL_ERROR + R.check( + f"a refused login fails the connection ({elapsed:.1f}s)", + failed, + "" if failed else f"returned {ret} with state {state!r}", + ) + R.check( + "a refused login reports 28000", + state == INVALID_AUTH_SPEC, + f"state is {state!r}: {diag_message(lib, SQL_HANDLE_DBC, dbc)}", + ) + prompt = elapsed < LOGIN_BUDGET_SECONDS - 5 + R.check( + "the refusal ended the login rather than the budget", + prompt, + "" + if prompt + else f"{elapsed:.1f}s against a {LOGIN_BUDGET_SECONDS}s budget, so the " + "coordinator waited the flow out instead of failing it", + ) + finally: + disconnect(lib, env, dbc) + + +def scenario_the_driver_manager_forwards_the_completion(stack, shim): + """unixODBC passes a non-NOPROMPT *DriverCompletion* through to the driver. + + Every other scenario here loads the driver directly, so nothing else would + notice a Driver Manager that flattened the argument. This one goes through + `libodbc.so.2`, which is the path `isql`, Power BI and Excel take. It takes + no driver path: the connection string carries `Driver`, and the Driver + Manager is what loads it. + + **The connect succeeding is the whole proof, and no browser launch is + required for it.** `resolve_auth` refuses `ExternalAuthentication` on the + prompting flag alone, before `oauth2_auth` is ever consulted, so a connection + the Driver Manager had marked `SQL_DRIVER_NOPROMPT` would fail here even with + a token already cached. Which is just as well: unixODBC loads the same `.so` + this process already has open, so `OAUTH2_LOGINS` is shared with every + scenario above and a login this identity has performed is served from cache. + Naming a fresh `User` to force a login is not the way out, because a `User` + disagreeing with the token is refused as an impersonation attempt. + """ + try: + dm = load("libodbc.so.2") + except OSError as e: + R.skip( + "unixODBC forwards the DriverCompletion", f"libodbc.so.2 not loadable: {e}" + ) + return + + shim.reset("login") + ret, env, dbc = odbc_connect(dm, oauth_conn(stack)) + try: + if not check_connected( + dm, dbc, ret, "unixODBC forwards a non-NOPROMPT DriverCompletion" + ): + return + user = scalar(dm, dbc, "SELECT current_user") + R.check( + "the Driver Manager path resolves the same session user", + user == stack.get("TRINO_USER"), + f"current_user is {user!r}", + ) + finally: + disconnect(dm, env, dbc) + + +def scenario_noprompt_is_refused(lib, stack, shim): + """`SQL_DRIVER_NOPROMPT` forbids the prompt an interactive login needs, so + the connection is refused before any network I/O. + + Needs no identity provider, and belongs beside its opposite: the two + together are what say the *DriverCompletion* gate is the thing deciding. + This is also the reason the suite cannot use pyodbc, which passes this value + unconditionally. + """ + shim.reset("login") + ret, env, dbc = odbc_connect( + lib, oauth_conn(stack, User="noprompt"), completion=SQL_DRIVER_NOPROMPT + ) + try: + state = sqlstate(lib, SQL_HANDLE_DBC, dbc) + message = diag_message(lib, SQL_HANDLE_DBC, dbc) + refused = ret == SQL_ERROR + R.check( + "ExternalAuthentication under SQL_DRIVER_NOPROMPT is refused", + refused, + "" if refused else f"returned {ret}", + ) + R.check( + "the refusal reports 28000", + state == INVALID_AUTH_SPEC, + f"state is {state!r}: {message}", + ) + R.check( + "the diagnostic names SQL_DRIVER_NOPROMPT", + "NOPROMPT" in message, + f"message is {message!r}", + ) + no_browser = shim.launches() == [] + R.check( + "no browser was launched", + no_browser, + "" if no_browser else f"records: {shim.launches()}", + ) + finally: + disconnect(lib, env, dbc) + + +def main(): + stack = Stack.load() + driver = sys.argv[1] if len(sys.argv) > 1 else stack.get("DRIVER_PATH") + if not stack.has_profile("oauth"): + R.skip( + "the whole suite", + "profile 'oauth' is not active (setup.sh --profile oauth)", + ) + return R.summary() + + lib = load(driver) + shim = Shim(os.path.join(os.path.dirname(SUITES_DIR), "generated")) + + print("\n--- the interactive flow, and the login cache ---") + opened = scenario_one_login_serves_many_connections(lib, stack, shim) + scenario_the_token_supplies_the_user(lib, stack, shim, opened) + for env, dbc in opened: + disconnect(lib, env, dbc) + + print("\n--- the X-Trino-User trap ---") + scenario_a_matching_user_is_honoured(lib, stack, shim) + scenario_a_disagreeing_user_is_refused(lib, stack, shim) + + print("\n--- a login that does not succeed ---") + scenario_an_abandoned_login_times_out(lib, stack, shim) + scenario_a_refused_login_reports_28000(lib, stack, shim) + + print("\n--- who is allowed to prompt ---") + scenario_noprompt_is_refused(lib, stack, shim) + scenario_the_driver_manager_forwards_the_completion(stack, shim) + + return R.summary() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/integration-tests/suites/test_session_keys.py b/integration-tests/suites/test_session_keys.py new file mode 100644 index 0000000..422fed0 --- /dev/null +++ b/integration-tests/suites/test_session_keys.py @@ -0,0 +1,250 @@ +#!/usr/bin/env python3 +"""Session connection-string keys, checked against what the coordinator saw. + +`connect_params.rs` parses 35 keys and unit-tests the parsing of all of them. +What no suite checked was the other half: that a parsed value becomes the right +Trino header and has the effect README.md's table promises. Seventeen keys had +no live coverage at all, so a key could parse perfectly and reach the +coordinator as nothing. + +That is not a hypothetical failure mode for this driver. `Certificate` under +`Protocol=http` was accepted and silently discarded, and the connector's `Roles` +sample double-wrapped a value the driver already wraps. Both are the same shape: +the value looks applied and is not. + +Three strengths of assertion, in descending order, and each key is covered by +the strongest one Trino makes available: + +1. **Observed.** The value comes back out of the session: `current_timezone()`, + `current_path`, `SHOW SESSION`, `SHOW CURRENT ROLES`, + `system.runtime.queries.source`, and a locale-dependent `format_datetime`. +2. **Refused.** A deliberately invalid value is rejected by the coordinator or + the client. A rejection proves the value travelled, which is most of what an + "observed" check proves, so it covers the keys Trino accepts silently. +3. **Accepted.** The connection succeeds and a query runs. Weak, and recorded + as a NOTE rather than a PASS, because it cannot tell a working header from a + discarded one. Only used where Trino offers nothing better. + +Usage: + uv run --with pyodbc python3 integration-tests/suites/test_session_keys.py "" + +Requires a running Trino (integration-tests/setup.sh). Needs no compose profile. +The `hive` catalog it reads roles from is part of the base stack. +""" + +import os +import sys + +import pyodbc + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from harness import Results, Stack # noqa: E402 + +R = Results("session connection-string keys") + + +def scalar(conn, sql): + return conn.cursor().execute(sql).fetchone()[0] + + +def rows(conn, sql): + return conn.cursor().execute(sql).fetchall() + + +def observed(stack, key, value, label, probe, expected): + """Connect with one key set and assert what the session reports back.""" + try: + conn = stack.connect(**{key: value}) + except pyodbc.Error as e: + R.bad(f"{key}={value}", f"the connection was refused: {str(e)[:100]}") + return + try: + got = scalar(conn, probe) + ok = str(got) == str(expected) + R.check( + f"{key}={value} -> {label}", + ok, + "" if ok else f" expected {expected!r}, got {got!r}", + ) + except pyodbc.Error as e: + R.bad(f"{key}={value} -> {label}", str(e)[:100]) + finally: + conn.close() + + +def refused(stack, key, value, why, *, expect_sqlstate=None): + """Connect with a deliberately invalid value and assert it is rejected. + + A key Trino accepts silently cannot be observed, but it can be *dis*proved: + if a bad value is refused, the good value reached the coordinator too. + """ + try: + conn = stack.connect(**{key: value}) + except pyodbc.Error as e: + state = e.args[0] if e.args else "" + ok = expect_sqlstate is None or state == expect_sqlstate + R.check( + f"{key}={value} is refused ({why})", + ok, + "" if ok else f" expected SQLSTATE {expect_sqlstate}, got {state}", + ) + return + conn.close() + R.bad( + f"{key}={value} is refused ({why})", + "the connection succeeded, so the value did not reach Trino", + ) + + +def accepted(stack, key, value, why): + """Connect with the key set and run a query. The weakest of the three.""" + try: + conn = stack.connect(**{key: value}) + scalar(conn, "SELECT 1") + conn.close() + except pyodbc.Error as e: + R.bad(f"{key}={value} is accepted", str(e)[:100]) + return + R.note( + f"{key}={value}", + f"connects and queries, but {why}, so this does not prove the header " + f"was sent", + ) + + +def main(): + # No connection-string argument, unlike most suites: every check here varies + # one key against an otherwise identical connection, so it has to build the + # strings rather than be handed one. `Stack` is what does that. + stack = Stack.load() + + print("=== session connection-string keys ===\n") + + # ------------------------------------------------------------------ + print("--- observed: the value comes back out of the session ---") + + # Trino resolves current_timestamp, TIMESTAMP WITH TIME ZONE literals and + # every AT TIME ZONE against the session zone. Unset, those follow the + # coordinator's JVM, which is a property of the server rather than of the + # query, so a TimeZone that did not apply is hours of silent error. + observed(stack, "TimeZone", "Europe/Berlin", "current_timezone()", + "SELECT current_timezone()", "Europe/Berlin") + + # The default SQL path is empty, so this cannot pass by accident. + observed(stack, "Path", "system.builtin", "current_path", + "SELECT current_path", "system.builtin") + + # Trino's query history shows this, and resource-group rules route on it. + observed(stack, "Source", "odbc-session-key-suite", + "system.runtime.queries.source", + "SELECT source FROM system.runtime.queries " + "WHERE source = 'odbc-session-key-suite' LIMIT 1", + "odbc-session-key-suite") + + # `X-Trino-Language`. Observable because month names are locale-dependent: + # the default renders 'February' and de-DE renders 'Februar'. Without this + # the key was parsed, sent, and never once shown to do anything. + observed(stack, "Locale", "de-DE", "a German month name", + "SELECT format_datetime(DATE '2021-02-03', 'MMMM')", "Februar") + + # The braces are the connection-string escaping README.md devotes a section + # to, so this exercises the whole path: core unwraps them, the parser splits + # on ';' and ':', and the property reaches the session. + try: + conn = stack.connect(SessionProperties="{query_max_run_time:10m}") + session = {r[0]: (r[1], r[2]) for r in rows(conn, "SHOW SESSION")} + value, default = session.get("query_max_run_time", (None, None)) + R.check( + "SessionProperties={query_max_run_time:10m} -> SHOW SESSION", + value == "10m" and default != "10m", + "" if value == "10m" else f" value={value!r} default={default!r}", + ) + conn.close() + except pyodbc.Error as e: + R.bad("SessionProperties reaches SHOW SESSION", str(e)[:100]) + + # Roles are what Hive and Iceberg under sql-standard security check, and so + # what decides whether SQLTablePrivileges returns a row. The name is written + # bare here: connect_params.rs renders Trino's own ROLE{...} spelling. + try: + conn = stack.connect(Roles="{hive:admin}") + current = {r[0] for r in rows(conn, "SHOW CURRENT ROLES FROM hive")} + R.check( + "Roles={hive:admin} -> SHOW CURRENT ROLES", + "admin" in current, + "" if "admin" in current else f" got {sorted(current)}", + ) + conn.close() + except pyodbc.Error as e: + R.bad("Roles reaches SHOW CURRENT ROLES", str(e)[:100]) + + # ------------------------------------------------------------------ + print("\n--- refused: an invalid value is rejected, so a valid one travels ---") + + # Trino validates the property name, so a bogus one proves the map is sent + # rather than dropped. + refused(stack, "SessionProperties", "{not_a_real_property:1}", + "INVALID_SESSION_PROPERTY") + + # The coordinator answers 400 for an unknown estimate name. ResourceEstimates + # has no positive probe: a scheduling hint has no reading in the session. + refused(stack, "ResourceEstimates", "{NOT_A_REAL_ESTIMATE:1h}", + "the coordinator rejects the estimate name") + + # A role that does not exist is an authorisation failure, which is 28000 + # rather than a connect failure. That the SQLSTATE is specific is part of + # the claim: the driver routes it through map_trino_error. + refused(stack, "Roles", "{hive:no_such_role}", + "the role does not exist", expect_sqlstate="28000") + + # Not Trino's rejection but the client's, and worth pinning: reqwest appends + # rather than replaces, so a header the client already manages would be sent + # twice. connect_params.rs documents this as the reason the value is refused + # when the client is built. + refused(stack, "ExtraHeaders", "{X-Trino-User:someone}", + "the client manages that header") + + # ------------------------------------------------------------------ + print("\n--- accepted: the connection works, which is all Trino exposes ---") + + # A benign extra header, the case a gateway needs. Trino ignores unknown + # headers, so there is nothing to read back; the refusal above is what + # proves the map is delivered. + accepted(stack, "ExtraHeaders", "{X-Odbc-Probe:yes}", + "Trino ignores headers it does not know") + accepted(stack, "ExtraCredentials", "{probe.token:abc123}", + "the credential is forwarded to a connector, and neither test " + "catalog reads one") + accepted(stack, "ClientCapabilities", "ODBC_PROBE", + "Trino ignores capabilities it does not know") + accepted(stack, "ClientTags", "bi,adhoc", + "tags select a resource group, and the test stack configures none") + accepted(stack, "ClientInfo", "odbc-session-key-suite", + "Trino records it against the query but publishes no column for it") + accepted(stack, "TraceToken", "odbc-probe-1", + "Trino records it against the query but publishes no column for it") + accepted(stack, "DisableCompression", "true", + "the effect is a request header the Driver Manager does not expose") + accepted(stack, "MaxAttempts", "3", + "a retry budget shows only under a fault this stack cannot inject") + + # ------------------------------------------------------------------ + print("\n--- not covered here ---") + R.skip( + "AccessToken", + "needs a bearer token; test_oauth.py obtains one under the 'oauth' " + "profile and is where that belongs", + ) + R.skip( + "Proxy, ProxyUser, ProxyPassword", + "needs an HTTP proxy in the compose stack; the parser's own rules " + "(userinfo refused, the two credentials required together, neither " + "without Proxy) are unit-tested in connect_params.rs", + ) + + return R.summary() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/integration-tests/suites/test_spooling.py b/integration-tests/suites/test_spooling.py new file mode 100644 index 0000000..2adc34d --- /dev/null +++ b/integration-tests/suites/test_spooling.py @@ -0,0 +1,309 @@ +#!/usr/bin/env python3 +"""Trino's spooled protocol, through the driver's `Encoding` key. + +Requires the `spooling` profile for the scenarios that need the coordinator to +spool: `./integration-tests/setup.sh --profile spooling`. The fallback scenario +is the other way round and runs when the profile is *off*, since a coordinator +with no spooling manager is what most deployments are. + +Measured against this stack's coordinator (Trino 483) by driving the REST API +directly with an `X-Trino-Query-Data-Encoding` header and counting segments by +type: + + SELECT 1 -> 1 inline segment, 0 spooled + 900 rows of customer -> 1 inline segment, 0 spooled + 20,000 rows, all columns -> 2 inline, 25 spooled + 20,000 rows, 4 columns -> inline only, 0 spooled + +The last row is the trap: **what spools is bytes, not rows.** A narrow +projection of the same 20,000 rows stays under the coordinator's inlining +threshold and never reaches object storage, which is why the queries here are +`SELECT *`. The scenarios read the driver's log for the segment fetches rather +than trusting a row count, because rows that arrive inline are byte-identical to +rows that arrive spooled and prove nothing about this driver's decode path. +""" + +import os +import sys +import tempfile +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from harness import Results, Stack # noqa: E402 + +# The client logs this once per *remote* segment, in +# `trino-rust-client/src/spooling/fetcher.rs`. An inline segment never produces +# one, which is exactly the distinction the thresholds above turn on. +SEGMENT_LOG_LINE = "Successfully fetched remote spooled segment" + +# Every column of 20,000 rows: measured at 25 spooled segments, where the same +# rows projected to four columns spool nothing at all. Ordered so two runs' +# results are comparable row by row. +BIG_QUERY = "SELECT * FROM tpcds.sf1.customer ORDER BY c_customer_sk LIMIT 20000" +BIG_QUERY_ROWS = 20000 + + +LOG = {"path": None, "offset": 0} + + +def start_logging(directory): + """Point the driver's log at one file for the whole process. + + Core initialises its `tracing` subscriber on the first connection and pins + the file for good (a `std::sync::Once`), so setting `ODBC_LOG_FILE` per query + would be silently ignored after the first connect and every later scenario + would read an empty file. One file, read in deltas, is what makes the + per-query counts meaningful.""" + LOG["path"] = os.path.join(directory, "driver.log") + LOG["offset"] = 0 + os.environ["ODBC_LOG_LEVEL"] = "info" + os.environ["ODBC_LOG_FILE"] = LOG["path"] + + +def new_log_text(): + """Everything the driver appended to the log since the previous call.""" + path = LOG["path"] + if not path or not os.path.exists(path): + return "" + with open(path, "rb") as f: + f.seek(LOG["offset"]) + data = f.read() + LOG["offset"] += len(data) + return data.decode("utf-8", errors="replace") + + +def scenario(results, label, fn): + """Run one scenario, recording an exception as a single failure. + + `Results.run` is not used because each scenario does its own `check` + accounting, and a wrapper PASS printed beside an inner FAIL reads as though + something passed. The elapsed time is still printed: a suite that slows down + is a finding.""" + start = time.monotonic() + try: + fn() + except Exception as e: # noqa: BLE001 + results.bad(label, f"raised after {time.monotonic() - start:.1f}s: {e}") + else: + print(f" {label}: {time.monotonic() - start:.1f}s") + + +def run_query(stack, sql, **overrides): + """Run `sql` on a fresh connection and return (rows, the log it produced).""" + import pyodbc + + conn = pyodbc.connect(stack.conn_str(**overrides), autocommit=True) + try: + cur = conn.cursor() + cur.execute(sql) + rows = [tuple(r) for r in cur.fetchall()] + finally: + conn.close() + + return rows, new_log_text() + + +def spooled_matches_direct(stack, results): + """A spooled result set equals the inline one, and segments were really + fetched. Without the log check this would pass on an inlined result and + prove nothing.""" + direct, direct_log = run_query(stack, BIG_QUERY) + spooled, spooled_log = run_query(stack, BIG_QUERY, Encoding="json+zstd") + + results.check( + "spooled row count matches direct", + len(spooled) == len(direct) == BIG_QUERY_ROWS, + f"direct={len(direct)} spooled={len(spooled)}", + ) + results.check("spooled rows are identical to direct", spooled == direct) + fetched = spooled_log.count(SEGMENT_LOG_LINE) + results.check( + "the driver fetched remote spooled segments", + fetched > 0, + f"{fetched} segment fetches logged; 0 would mean the result was inlined " + f"and this scenario proved nothing", + ) + results.check( + "the direct run fetched no segment", + direct_log.count(SEGMENT_LOG_LINE) == 0, + "no Encoding key was set, so nothing may be spooled", + ) + + +def an_inline_segment_decodes(stack, results): + """With an encoding set, a small result arrives as one *inline* segment. + That is a different decode path from a remote fetch, and the one every short + query takes once spooling is on.""" + rows, log = run_query(stack, "SELECT 1", Encoding="json+zstd") + + results.check("an inline segment decodes", rows == [(1,)], f"got {rows}") + results.check( + "no remote segment was fetched for a one-row result", + log.count(SEGMENT_LOG_LINE) == 0, + "Trino inlines the first 1000 rows, so a remote fetch here would mean " + "the inlining threshold moved", + ) + + +def every_encoding_agrees(stack, results): + """The three encodings are three wire formats for one result.""" + baseline, _ = run_query(stack, BIG_QUERY) + for encoding in ("json", "json+zstd", "json+lz4"): + rows, log = run_query(stack, BIG_QUERY, Encoding=encoding) + results.check( + f"{encoding} returns the direct result", + rows == baseline, + f"{len(rows)} rows, {log.count(SEGMENT_LOG_LINE)} segments fetched", + ) + + +def a_coordinator_without_spooling_falls_back(stack, results): + """Most coordinators have no spooling manager configured. The header is then + ignored and the rows arrive inline, so setting `Encoding` must not fail the + query.""" + rows, log = run_query(stack, BIG_QUERY, Encoding="json+zstd") + + results.check( + "rows arrive inline when the coordinator does not spool", + len(rows) == BIG_QUERY_ROWS, + f"{len(rows)} rows", + ) + results.check( + "no segment was fetched", + log.count(SEGMENT_LOG_LINE) == 0, + "a segment fetch here would mean the coordinator did spool, so this run " + "is not the fallback case it claims to be", + ) + + +def cancel_during_a_spooled_fetch_reports_hy008(stack, results): + """SQLCancel from another thread, while the fetch is downloading segments. + + HY008 is the spec's code for a function interrupted by SQLCancel, and it + needs `Threading = 2` in odbcinst.ini, which setup.sh writes. + + unixODBC sometimes answers first. Walking the cancel across the fetch window + on this stack, delays past ~2.3s produced + `[unixODBC][Driver Manager]Function sequence error` (HY010) on `SQLFetch` or + `SQLGetData` instead: the cancelling thread's `SQLCancel` moves the Driver + Manager's own statement state while the fetching thread is mid-loop, and the + DM then refuses that thread's next call before it reaches the driver. It + happens on the direct protocol too, so it is not spooling-specific, and + HY010 is `(DM)`-marked, so the driver cannot influence it. Such a run is + recorded as a NOTE: it proves nothing about this driver either way. + + What separates that from a real defect is *when* the error arrives. Promptly + after the cancel means the cancel worked. HY010 arriving only around the time + the query would have finished on its own is the `Threading = 3` signature, + where unixODBC serialised the cancelling thread behind the fetch, and that is + a failure.""" + import threading + import time + + import pyodbc + + conn = pyodbc.connect(stack.conn_str(Encoding="json+zstd"), autocommit=True) + try: + cur = conn.cursor() + # Every column, for the same reason as `BIG_QUERY`: a narrow projection + # never leaves the coordinator, so the cancel would not land on a segment + # download. sf10 keeps the query running past the two-second mark. + cur.execute("SELECT * FROM tpcds.sf10.customer ORDER BY c_customer_sk") + + def cancel_after_two_seconds(): + time.sleep(2) + cur.cancel() + + canceller = threading.Thread(target=cancel_after_two_seconds) + canceller.start() + start = time.monotonic() + try: + cur.fetchall() + results.bad( + "cancel during a spooled fetch reports HY008", + "the fetch completed; the query was too small to still be " + "running after 2s", + ) + except pyodbc.Error as e: + elapsed = time.monotonic() - start + state = e.args[0] + label = "cancel during a spooled fetch reports HY008" + prompt = elapsed < 4.0 + if state == "HY010" and prompt: + results.note( + label, + f"unixODBC answered first with its own HY010 after " + f"{elapsed:.1f}s: {e}. (DM)-marked, so the driver was never " + f"reached and its own path is unverified in this run", + ) + else: + results.check( + label, + state == "HY008" and prompt, + f"got {state} after {elapsed:.1f}s: {e}" + + ( + "; an error only after the query would have finished on " + "its own means unixODBC serialised the cancelling thread. " + "Check that Threading = 2 is set in odbcinst.ini" + if not prompt + else "" + ), + ) + finally: + canceller.join() + finally: + conn.close() + + +def main(): + stack = Stack.load() + results = Results("spooling") + absent = "profile 'spooling' is not active (setup.sh --profile spooling)" + log_dir = tempfile.TemporaryDirectory() + start_logging(log_dir.name) + + if stack.has_profile("spooling"): + scenario( + results, + "spooled result equals direct", + lambda: spooled_matches_direct(stack, results), + ) + scenario( + results, + "inline segment decodes", + lambda: an_inline_segment_decodes(stack, results), + ) + scenario( + results, "every encoding agrees", lambda: every_encoding_agrees(stack, results) + ) + scenario( + results, + "cancel during a spooled fetch", + lambda: cancel_during_a_spooled_fetch_reports_hy008(stack, results), + ) + results.skip( + "coordinator without spooling falls back", + "the 'spooling' profile IS active, so this coordinator spools; run " + "setup.sh without --profile spooling for the fallback case", + ) + else: + for label in ( + "spooled result equals direct", + "inline segment decodes", + "every encoding agrees", + "cancel during a spooled fetch", + ): + results.skip(label, absent) + scenario( + results, + "coordinator without spooling falls back", + lambda: a_coordinator_without_spooling_falls_back(stack, results), + ) + + code = results.summary() + log_dir.cleanup() + sys.exit(code) + + +if __name__ == "__main__": + main() diff --git a/integration-tests/suites/test_sql_surface.py b/integration-tests/suites/test_sql_surface.py new file mode 100755 index 0000000..cde5b31 --- /dev/null +++ b/integration-tests/suites/test_sql_surface.py @@ -0,0 +1,386 @@ +#!/usr/bin/env python3 +""" +SQL surface pen test for the Trino ODBC driver. + +Walks the SQL a BI tool emits and checks the driver carries it through intact: +joins of every shape, aggregates, the GROUP BY extensions, window functions, +subqueries, CTEs, set operations, parameters in every clause that accepts one, +the ODBC catalog functions, and the statement forms whose result columns have no +declared length (DESCRIBE, SHOW, EXPLAIN). + +Where a query has one right answer it is asserted. Where it does not (a plan +listing, a server-dependent count), the assertion is that it returns a result of +the expected shape, which is still enough to catch a translation or fetch +failure. + +Every query is read-only, against the tpcds and postgresql catalogs. + +Usage: + python3 integration-tests/suites/test_sql_surface.py "" + python3 integration-tests/suites/test_sql_surface.py "DSN=trino_http" + +Requires a running Trino (integration-tests/setup.sh) and `pip install pyodbc`, +normally through `uv run --with pyodbc`. Needs no compose profile: the tpcds and +postgresql catalogs are both in the base stack. +""" + +import os +import sys +import time + +import pyodbc + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from harness import Results, Stack # noqa: E402 + +R = Results("sql surface") + +# A query that hangs is worse than one that errors: it takes the whole suite +# with it and gives no diagnosis. Nothing here should come close. +QUERY_TIMEOUT_SECONDS = 60 + + +def main(): + # A connection string may be passed positionally; with no argument the + # local stack describes itself. + conn_str = sys.argv[1] if len(sys.argv) > 1 else Stack.load().conn_str() + conn = pyodbc.connect(conn_str, autocommit=True) + conn.timeout = QUERY_TIMEOUT_SECONDS + cur = conn.cursor() + + def scalar(sql, want, params=None): + got = cur.execute(sql, params).fetchone()[0] if params else cur.execute(sql).fetchone()[0] + assert got == want, f"expected {want!r}, got {got!r}" + + def rows(sql, want_count=None, min_count=None, params=None): + got = (cur.execute(sql, params) if params else cur.execute(sql)).fetchall() + if want_count is not None: + assert len(got) == want_count, f"expected {want_count} rows, got {len(got)}" + if min_count is not None: + assert len(got) >= min_count, f"expected >= {min_count} rows, got {len(got)}" + return got + + def shape(sql, min_cols=1, min_rows=1): + """Executes and returns rows; asserts only the result's shape.""" + cur.execute(sql) + assert cur.description is not None, "no result set" + assert len(cur.description) >= min_cols, f"expected >= {min_cols} columns" + got = cur.fetchall() + assert len(got) >= min_rows, f"expected >= {min_rows} rows, got {len(got)}" + return got + + # ------------------------------------------------------------------ + print("--- joins ---") + R.run("inner join", lambda: scalar( + "SELECT count(*) FROM (VALUES 1,2,3) a(x) JOIN (VALUES 2,3,4) b(y) ON a.x = b.y", 2)) + R.run("left outer join", lambda: scalar( + "SELECT count(*) FROM (VALUES 1,2,3) a(x) LEFT JOIN (VALUES 2) b(y) ON a.x = b.y", 3)) + R.run("right outer join", lambda: scalar( + "SELECT count(*) FROM (VALUES 1) a(x) RIGHT JOIN (VALUES 1,2,3) b(y) ON a.x = b.y", 3)) + R.run("full outer join", lambda: scalar( + "SELECT count(*) FROM (VALUES 1,2) a(x) FULL JOIN (VALUES 2,3) b(y) ON a.x = b.y", 3)) + R.run("cross join", lambda: scalar( + "SELECT count(*) FROM (VALUES 1,2,3) a(x) CROSS JOIN (VALUES 1,2) b(y)", 6)) + R.run("non-equi join", lambda: scalar( + "SELECT count(*) FROM (VALUES 1,2,3) a(x) JOIN (VALUES 1,2,3) b(y) ON a.x < b.y", 3)) + R.run("self join", lambda: scalar( + "SELECT count(*) FROM (VALUES 1,2,3) a(x) JOIN (VALUES 1,2,3) b(x) ON a.x = b.x", 3)) + R.run("three-way join", lambda: scalar( + "SELECT count(*) FROM (VALUES 1,2) a(x) JOIN (VALUES 1,2) b(y) ON a.x=b.y " + "JOIN (VALUES 1,2) c(z) ON b.y=c.z", 2)) + # The {oj} escape, which SQL_OUTER_JOIN_CAPABILITIES advertises. + R.run("ODBC {oj} escape", lambda: scalar( + "SELECT count(*) FROM {oj (VALUES 1,2,3) a(x) LEFT OUTER JOIN (VALUES 2) b(y) " + "ON a.x = b.y}", 3)) + + # ------------------------------------------------------------------ + print("\n--- aggregates and GROUP BY ---") + R.run("count/sum/avg/min/max", lambda: scalar( + "SELECT count(*) + sum(x) + min(x) + max(x) FROM (VALUES 1,2,3) t(x)", 3 + 6 + 1 + 3)) + R.run("count(DISTINCT)", lambda: scalar( + "SELECT count(DISTINCT x) FROM (VALUES 1,1,2) t(x)", 2)) + R.run("GROUP BY", lambda: rows( + "SELECT x, count(*) FROM (VALUES 1,1,2) t(x) GROUP BY x", want_count=2)) + R.run("HAVING", lambda: rows( + "SELECT x FROM (VALUES 1,1,2) t(x) GROUP BY x HAVING count(*) > 1", want_count=1)) + R.run("GROUPING SETS", lambda: rows( + "SELECT x, y, count(*) FROM (VALUES (1,1),(1,2)) t(x,y) " + "GROUP BY GROUPING SETS ((x),(y))", min_count=2)) + R.run("ROLLUP", lambda: rows( + "SELECT x, count(*) FROM (VALUES 1,2) t(x) GROUP BY ROLLUP (x)", min_count=2)) + R.run("CUBE", lambda: rows( + "SELECT x, y, count(*) FROM (VALUES (1,1),(2,2)) t(x,y) GROUP BY CUBE (x,y)", + min_count=3)) + + # ------------------------------------------------------------------ + print("\n--- window functions ---") + R.run("row_number", lambda: scalar( + "SELECT max(rn) FROM (SELECT row_number() OVER (ORDER BY x) rn " + "FROM (VALUES 1,2,3) t(x)) s", 3)) + R.run("rank with PARTITION BY", lambda: rows( + "SELECT rank() OVER (PARTITION BY x ORDER BY y) FROM (VALUES (1,1),(1,2)) t(x,y)", + want_count=2)) + R.run("lag/lead", lambda: rows( + "SELECT lag(x) OVER (ORDER BY x), lead(x) OVER (ORDER BY x) " + "FROM (VALUES 1,2,3) t(x)", want_count=3)) + R.run("running sum frame", lambda: scalar( + "SELECT max(s) FROM (SELECT sum(x) OVER (ORDER BY x ROWS BETWEEN UNBOUNDED " + "PRECEDING AND CURRENT ROW) s FROM (VALUES 1,2,3) t(x)) q", 6)) + + # ------------------------------------------------------------------ + print("\n--- subqueries and CTEs ---") + R.run("scalar subquery", lambda: scalar("SELECT (SELECT max(x) FROM (VALUES 1,2,3) t(x))", 3)) + R.run("IN subquery", lambda: scalar( + "SELECT count(*) FROM (VALUES 1,2,3) a(x) WHERE a.x IN (SELECT y FROM (VALUES 1,2) b(y))", + 2)) + R.run("EXISTS subquery", lambda: scalar( + "SELECT count(*) FROM (VALUES 1,2) a(x) WHERE EXISTS " + "(SELECT 1 FROM (VALUES 1) b(y) WHERE b.y = a.x)", 1)) + R.run("correlated subquery", lambda: scalar( + "SELECT count(*) FROM (VALUES 1,2) a(x) WHERE a.x = " + "(SELECT max(y) FROM (VALUES 1) b(y))", 1)) + R.run("quantified comparison (ANY/ALL)", lambda: scalar( + "SELECT count(*) FROM (VALUES 1,2,3) a(x) WHERE a.x > ALL (SELECT y FROM (VALUES 1) b(y))", + 2)) + R.run("CTE", lambda: scalar("WITH c AS (SELECT 1 AS x) SELECT x FROM c", 1)) + R.run("multiple CTEs", lambda: scalar( + "WITH a AS (SELECT 1 x), b AS (SELECT 2 y) SELECT a.x + b.y FROM a, b", 3)) + R.run("derived table", lambda: scalar( + "SELECT count(*) FROM (SELECT x FROM (VALUES 1,2,3) t(x)) s", 3)) + + # ------------------------------------------------------------------ + print("\n--- set operations ---") + R.run("UNION", lambda: scalar( + "SELECT count(*) FROM (SELECT 1 UNION SELECT 1 UNION SELECT 2) t", 2)) + R.run("UNION ALL", lambda: scalar( + "SELECT count(*) FROM (SELECT 1 UNION ALL SELECT 1) t", 2)) + R.run("INTERSECT", lambda: scalar( + "SELECT count(*) FROM (SELECT 1 INTERSECT SELECT 1) t", 1)) + R.run("EXCEPT", lambda: scalar( + "SELECT count(*) FROM (SELECT 1 EXCEPT SELECT 2) t", 1)) + + # ------------------------------------------------------------------ + print("\n--- parameters in every clause that takes one ---") + R.run("parameter in SELECT", lambda: scalar("SELECT CAST(? AS INTEGER)", 7, params=[7])) + R.run("parameter in WHERE", lambda: scalar( + "SELECT count(*) FROM (VALUES 1,2,3) t(x) WHERE x > ?", 2, params=[1])) + R.run("parameter in HAVING", lambda: rows( + "SELECT x FROM (VALUES 1,1,2) t(x) GROUP BY x HAVING count(*) > ?", + want_count=1, params=[1])) + R.run("parameter in IN list", lambda: scalar( + "SELECT count(*) FROM (VALUES 1,2,3) t(x) WHERE x IN (?, ?)", 2, params=[1, 2])) + R.run("two parameters, order preserved", lambda: scalar( + "SELECT CAST(? AS VARCHAR) || CAST(? AS VARCHAR)", "ab", params=["a", "b"])) + R.run("parameter in a join condition", lambda: scalar( + "SELECT count(*) FROM (VALUES 1,2) a(x) JOIN (VALUES 1,2) b(y) ON a.x = b.y " + "AND a.x > ?", 1, params=[1])) + R.run("NULL parameter", lambda: scalar("SELECT CAST(? AS INTEGER) IS NULL", True, params=[None])) + # LIMIT gets its own probe: Trino does not accept a parameter there, so + # the driver rendering the value as a literal is what makes it work at all. + R.run("parameter in LIMIT", lambda: rows( + "SELECT x FROM (VALUES 1,2,3) t(x) LIMIT ?", want_count=2, params=[2])) + + # ------------------------------------------------------------------ + print("\n--- batched parameters ---") + # The CHANGELOG claims "bound parameters singly and in batches", and only + # the singly half was covered. Trino has no wire-level parameter binding, so + # every set in a batch becomes its own interpolated statement: what has to + # hold is that all of them are submitted, in order, with their own values. + # + # A write, because that is what `executemany` is for, and against the hive + # catalog, which is the writable one in the base stack. Each probe makes and + # drops its own table so a failed run leaves nothing behind for the next. + batch_table = "hive.tx.odbc_batch_probe" + + def with_batch_table(fn): + cur.execute(f"DROP TABLE IF EXISTS {batch_table}") + cur.execute(f"CREATE TABLE {batch_table} (id integer, label varchar)") + try: + fn() + finally: + cur.execute(f"DROP TABLE IF EXISTS {batch_table}") + + def batch_inserts_every_row(): + cur.executemany( + f"INSERT INTO {batch_table} VALUES (?, ?)", + [(1, "one"), (2, "two"), (3, "three")], + ) + got = cur.execute( + f"SELECT id, label FROM {batch_table} ORDER BY id" + ).fetchall() + assert [tuple(r) for r in got] == [(1, "one"), (2, "two"), (3, "three")], got + + R.run("executemany inserts every set", lambda: with_batch_table(batch_inserts_every_row)) + + def batch_keeps_values_with_their_row(): + # Values that would still look plausible if a set were reused or the + # two columns crossed: the strings do not match the numbers. + cur.executemany( + f"INSERT INTO {batch_table} VALUES (?, ?)", + [(10, "b"), (20, "a"), (30, "c")], + ) + got = cur.execute( + f"SELECT id, label FROM {batch_table} ORDER BY id" + ).fetchall() + assert [tuple(r) for r in got] == [(10, "b"), (20, "a"), (30, "c")], got + + R.run("executemany keeps each value with its own row", + lambda: with_batch_table(batch_keeps_values_with_their_row)) + + def batch_handles_quotes_and_nulls(): + # The escaping path, once per set: `params::quote_string` doubles an + # embedded quote, and a NULL becomes the keyword rather than a string. + cur.executemany( + f"INSERT INTO {batch_table} VALUES (?, ?)", + [(1, "O'Brien"), (2, None), (3, "a;b")], + ) + got = cur.execute( + f"SELECT id, label FROM {batch_table} ORDER BY id" + ).fetchall() + assert [tuple(r) for r in got] == [(1, "O'Brien"), (2, None), (3, "a;b")], got + + R.run("executemany escapes each set independently", + lambda: with_batch_table(batch_handles_quotes_and_nulls)) + + def prepared_handle_is_reusable(): + # `execute` swaps the result into the existing handle and restores the + # template, so the same prepared statement runs again with new values. + # That is the main reason to prepare, and nothing else asserted it. + first = cur.execute("SELECT CAST(? AS INTEGER)", [1]).fetchone()[0] + second = cur.execute("SELECT CAST(? AS INTEGER)", [2]).fetchone()[0] + assert (first, second) == (1, 2), (first, second) + + R.run("a prepared statement re-executes with new values", prepared_handle_is_reusable) + + # ------------------------------------------------------------------ + print("\n--- statement forms with undeclared column lengths ---") + # These return varchar columns with no declared length. They are grouped + # because that is the property under test: the driver has to describe a + # column whose size it cannot know, and an application sizes its buffers + # from what it says. + R.run("DESCRIBE", lambda: shape("DESCRIBE tpcds.sf1.customer", min_cols=2, min_rows=1)) + R.run("SHOW TABLES", lambda: shape("SHOW TABLES FROM tpcds.sf1", min_rows=1)) + R.run("SHOW SCHEMAS", lambda: shape("SHOW SCHEMAS FROM tpcds", min_rows=1)) + R.run("SHOW COLUMNS", lambda: shape("SHOW COLUMNS FROM tpcds.sf1.customer", min_rows=1)) + R.run("EXPLAIN", lambda: shape("EXPLAIN SELECT 1", min_rows=1)) + R.run("EXPLAIN (TYPE LOGICAL)", lambda: shape("EXPLAIN (TYPE LOGICAL) SELECT 1", min_rows=1)) + R.run("EXPLAIN ANALYZE", lambda: shape("EXPLAIN ANALYZE SELECT 1", min_rows=1)) + R.run("SHOW FUNCTIONS", lambda: shape("SHOW FUNCTIONS", min_rows=1)) + + # ------------------------------------------------------------------ + print("\n--- ODBC catalog functions ---") + R.run("SQLTables", lambda: ( + cur.tables(catalog="tpcds", schema="sf1").fetchall() or + (_ for _ in ()).throw(AssertionError("no tables")))) + R.run("SQLTables catalog enumeration", lambda: ( + cur.tables(catalog="%", schema="", table="").fetchall() or + (_ for _ in ()).throw(AssertionError("no catalogs")))) + R.run("SQLTables schema enumeration", lambda: ( + cur.tables(catalog="", schema="%", table="").fetchall() or + (_ for _ in ()).throw(AssertionError("no schemas")))) + R.run("SQLTables table-type enumeration", lambda: ( + cur.tables(catalog="", schema="", table="", tableType="%").fetchall() or + (_ for _ in ()).throw(AssertionError("no table types")))) + R.run("SQLColumns", lambda: ( + cur.columns(catalog="tpcds", schema="sf1", table="customer").fetchall() or + (_ for _ in ()).throw(AssertionError("no columns")))) + R.run("SQLGetTypeInfo", lambda: ( + cur.getTypeInfo().fetchall() or + (_ for _ in ()).throw(AssertionError("no type info")))) + + def datetime_columns_report_the_verbose_type(): + """SQLColumns and SQLGetTypeInfo must not disagree about a datetime. + + The spec has SQL_DATA_TYPE carry the *verbose* type, SQL_DATETIME (9), + with the concise type in DATA_TYPE and the subcode in SQL_DATETIME_SUB. + Reporting the concise type from SQLColumns would make the driver + contradict SQLGetTypeInfo about the same column. + """ + SQL_DATETIME = 9 + # SQLGetTypeInfo columns: DATA_TYPE 2, SQL_DATA_TYPE 16, SQL_DATETIME_SUB 17. + by_concise = {r[1]: (r[15], r[16]) for r in cur.getTypeInfo().fetchall()} + # SQLColumns columns: DATA_TYPE 5, SQL_DATA_TYPE 14, SQL_DATETIME_SUB 15. + # + # postgresql rather than the connected tpcds, because it is the catalog + # carrying date, time and timestamp columns together. Reaching another + # catalog at all is what the qualified `information_schema` reference + # makes possible. + rows = cur.columns(catalog="postgresql", schema="public", + table="types_test").fetchall() + assert rows, "no columns returned for the postgresql catalog" + seen = 0 + for r in rows: + concise, verbose, sub = r[4], r[13], r[14] + if concise not in by_concise: + continue + assert (verbose, sub) == by_concise[concise], ( + f"{r[3]}: SQLColumns says ({verbose}, {sub}), " + f"SQLGetTypeInfo says {by_concise[concise]}") + if verbose == SQL_DATETIME: + seen += 1 + assert sub is not None, f"{r[3]}: SQL_DATETIME with no subcode" + assert seen >= 3, f"expected date, time and timestamp columns, saw {seen}" + + R.run("SQLColumns datetime verbose type agrees with SQLGetTypeInfo", + datetime_columns_report_the_verbose_type) + + def catalog_functions_reach_an_unconnected_catalog(): + """A named catalog other than the session one must still resolve. + + Trino resolves a bare `information_schema` through the session catalog, + and each catalog's copy describes only itself, so filtering on + `table_catalog` could never reach another catalog. An application that + enumerates catalogs, which is what Power Query's navigator does, would + then find every catalog but its own empty. + """ + assert cur.tables(catalog="postgresql", schema="public").fetchall(), \ + "no tables for the postgresql catalog" + assert cur.columns(catalog="postgresql", schema="public", + table="types_test").fetchall(), \ + "no columns for the postgresql catalog" + # A catalog that does not exist is a filter matching nothing, not an + # error: Trino answers CATALOG_NOT_FOUND and the driver absorbs it. + assert cur.tables(catalog="no_such_catalog").fetchall() == [], \ + "a missing catalog must be empty, not an error" + + R.run("catalog functions reach an unconnected catalog", + catalog_functions_reach_an_unconnected_catalog) + # Trino exposes no key or index metadata, so an empty result set is the + # correct answer and the assertion is that the call succeeds and describes + # its columns rather than erroring. + R.run("SQLPrimaryKeys (empty is correct)", + lambda: cur.primaryKeys(catalog="tpcds", schema="sf1", table="customer").fetchall()) + R.run("SQLStatistics (empty is correct)", + lambda: cur.statistics(catalog="tpcds", schema="sf1", table="customer").fetchall()) + # Trino has callable procedures (CALL system.runtime.kill_query(...)) + # but publishes no metadata naming them, so an empty result set is the + # honest answer here too. pyodbc exposes no tablePrivileges() or + # columnPrivileges(), so those two are covered in test_c_abi.py instead. + R.run("SQLProcedures (empty is correct)", + lambda: cur.procedures(catalog="system", schema="runtime").fetchall()) + R.run("SQLProcedureColumns (empty is correct)", + lambda: cur.procedureColumns(catalog="system", schema="runtime").fetchall()) + + # ------------------------------------------------------------------ + print("\n--- ordering, distinct, and null handling ---") + R.run("ORDER BY on an unselected column", lambda: rows( + "SELECT y FROM (VALUES (1,'b'),(2,'a')) t(x,y) ORDER BY x", want_count=2)) + R.run("ORDER BY an expression", lambda: rows( + "SELECT x FROM (VALUES 1,2) t(x) ORDER BY -x", want_count=2)) + R.run("NULLS sort last by default", lambda: scalar( + "SELECT x FROM (VALUES 1, NULL) t(x) ORDER BY x LIMIT 1", 1)) + R.run("DISTINCT", lambda: scalar( + "SELECT count(*) FROM (SELECT DISTINCT x FROM (VALUES 1,1,2) t(x)) s", 2)) + R.run("CASE expression", lambda: scalar( + "SELECT CASE WHEN 1 = 1 THEN 'y' ELSE 'n' END", "y")) + R.run("COALESCE over NULL", lambda: scalar("SELECT coalesce(CAST(NULL AS INTEGER), 5)", 5)) + + cur.close() + conn.close() + + return R.summary() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/integration-tests/suites/test_tls.py b/integration-tests/suites/test_tls.py new file mode 100644 index 0000000..dcb62d7 --- /dev/null +++ b/integration-tests/suites/test_tls.py @@ -0,0 +1,260 @@ +#!/usr/bin/env python3 +"""TLS verification modes and mutual TLS, against the test CA. + +Drives `TlsVerify`, `SSLVerification`, `Certificate` and `ClientCertificate` +against a real coordinator. Their parsing is unit-tested in +`src/backend/types/connect_params.rs`; this is where the TLS behaviour itself is +covered. + +What each mode is checked for: + + full chain verified, and the name checked + ca chain verified, the name not checked + none nothing verified + +Chain verification is checked in all three by pointing `Certificate` at +`keycloak.crt`, a leaf signed by the same CA and therefore not a trust anchor: +`full` and `ca` must refuse it, `none` must not care. + +**The name half of `ca` is not covered here, and cannot be.** Doing so needs the +coordinator to present its own certificate under a name that certificate does +not carry, and Jetty will not: it selects on SNI, and for any SNI it cannot +match it serves the self-signed `CN=` certificate Trino +generates for internal communication. Connecting by IP address is worse, since +TLS sends no SNI for an IP literal and the fallback certificate is served every +time. Measured against this stack: + + SNI=localhost -> CN=localhost (the CA-signed leaf) + SNI=trino -> CN=localhost (DNS:trino is in the SAN) + SNI=nosuchname.example -> CN=test (Trino's internal certificate) + no SNI, by IP -> CN=test + +Neither way of removing the second certificate works, both measured against +the live stack, so do not retry them: + + * removing `internal-communication.https.required` stops the certificate + being generated, and stops Trino starting: + "NullPointerException: internalUri is null". With no plaintext listener + that setting is what supplies the internal URI. + * pointing `internal-communication.https.keystore.path` at the CA-signed + keystore, with `node.internal-address=trino` so the name resolves, + removes the second certificate and leaves every query stuck in RUNNING. + +Closing this needs a TLS endpoint that serves one fixed certificate. The +Keycloak the `oauth` profile brings in is a candidate, but the driver only ever +speaks to Trino, so it would test rustls rather than the driver. + +Requires a running Trino with the certificates `integration-tests/setup.sh` +generates, and `pip install pyodbc`. Needs no compose profile. + +Usage: + uv run --with pyodbc python3 integration-tests/suites/test_tls.py +""" + +import os +import sys +import time + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) + +from harness import Results, Stack # noqa: E402 + +R = Results("tls") + +# A name the coordinator's certificate carries, so Jetty selects it on SNI. The +# stack states which one: `localhost` on the host, and the name mapped into the +# VM's hosts file when this runs on Windows. An address cannot stand in, because +# TLS sends no SNI for an IP literal. +NAMED = "localhost" + + +def connects(stack, **overrides): + """Try a connection and a trivial query. Returns (ok, message, seconds).""" + import pyodbc + + t0 = time.monotonic() + try: + conn = pyodbc.connect(stack.conn_str(**overrides), autocommit=True) + try: + cur = conn.cursor() + cur.execute("SELECT 1") + return cur.fetchone()[0] == 1, "", time.monotonic() - t0 + finally: + conn.close() + except Exception as e: + return False, str(e).replace("\n", " ")[:150], time.monotonic() - t0 + + +def expect_ok(stack, label, **overrides): + ok, msg, secs = connects(stack, **overrides) + R.check(f"{label} ({secs:.1f}s)", ok, msg) + + +def expect_refused(stack, label, why, **overrides): + ok, _msg, secs = connects(stack, **overrides) + R.check(f"{label} ({secs:.1f}s)", not ok, why if ok else "") + + +def main(): + global NAMED + + stack = Stack.load() + NAMED = stack.get("TRINO_HOST", NAMED) + ca = stack.get("CA_CERT") + client_pem = stack.get("CLIENT_PEM") + # A leaf signed by the same CA, so it is not itself a trust anchor and + # cannot have signed the coordinator's certificate. Note this cannot be + # client.pem: that file carries the CA in its chain, so it *is* a valid + # anchor and a connection using it rightly succeeds. + unrelated = os.path.join(os.path.dirname(ca), "keycloak.crt") + + print("\n--- each mode accepts the coordinator's own certificate ---") + expect_ok(stack, "full verifies chain and name", Host=NAMED, TlsVerify="full") + expect_ok(stack, "full is the default when TlsVerify is unset", Host=NAMED) + expect_ok(stack, "ca verifies the chain", Host=NAMED, TlsVerify="ca") + expect_ok( + stack, "none verifies nothing", Host=NAMED, TlsVerify="none", Certificate=None + ) + + print("\n--- chain verification, against a certificate that signed nothing ---") + expect_refused( + stack, + "full refuses a trust anchor that did not sign the chain", + "connected with a leaf certificate as the trust anchor", + Host=NAMED, + TlsVerify="full", + Certificate=unrelated, + ) + expect_refused( + stack, + "ca refuses a trust anchor that did not sign the chain", + "connected with a leaf certificate as the trust anchor; ca skips the " + "name check, never the chain", + Host=NAMED, + TlsVerify="ca", + Certificate=unrelated, + ) + expect_ok( + stack, + "none accepts a trust anchor that signed nothing", + Host=NAMED, + TlsVerify="none", + Certificate=unrelated, + ) + + R.note( + "ca ignoring the name", + "not covered: Jetty serves Trino's internal self-signed certificate for " + "any SNI it cannot match, so the coordinator's own certificate cannot be " + "reached under a name it does not carry. See this file's docstring.", + ) + + print("\n--- SSLVerification is an alias, and both keys take both vocabularies ---") + expect_ok( + stack, + "SSLVerification=true means full", + Host=NAMED, + TlsVerify=None, + SSLVerification="true", + ) + expect_ok( + stack, + "SSLVerification=ca takes the TlsVerify vocabulary", + Host=NAMED, + TlsVerify=None, + SSLVerification="ca", + ) + expect_ok( + stack, + "TlsVerify=none takes the SSLVerification vocabulary", + Host=NAMED, + TlsVerify="none", + Certificate=None, + ) + expect_ok( + stack, + "both keys set to the same mode is accepted", + Host=NAMED, + TlsVerify="full", + SSLVerification="full", + ) + + print("\n--- refused before a socket is opened ---") + expect_refused( + stack, + "ca without Certificate is refused", + "connected; rustls can only skip the name check when the trust store is " + "given explicitly, so this cannot be honoured", + Host=NAMED, + TlsVerify="ca", + Certificate=None, + ) + expect_refused( + stack, + "TlsVerify and SSLVerification disagreeing is refused", + "connected; one of the two was silently preferred, for a setting whose " + "failure mode is an unauthenticated connection", + Host=NAMED, + TlsVerify="full", + SSLVerification="none", + ) + expect_refused( + stack, + "an unknown verification word is refused", + "connected; a typo must not read as leaving verification on or off", + Host=NAMED, + TlsVerify="yes", + ) + + print("\n--- mutual TLS ---") + if not os.path.exists(client_pem): + R.skip( + "mutual TLS", + f"{client_pem} is missing; run ./integration-tests/setup.sh", + ) + else: + # The coordinator authenticates the certificate's CN as a Trino user, + # so this must succeed with no Password at all. + expect_ok( + stack, + "ClientCertificate authenticates with no Password", + Host=NAMED, + ClientCertificate=client_pem, + Password=None, + ) + expect_ok( + stack, + "ClientCertificate alongside a Password is accepted", + Host=NAMED, + ClientCertificate=client_pem, + ) + expect_ok( + stack, + "ClientCertificate is independent of the verification mode", + Host=NAMED, + TlsVerify="ca", + ClientCertificate=client_pem, + Password=None, + ) + expect_refused( + stack, + "a missing ClientCertificate file is refused", + "connected without the certificate file existing", + Host=NAMED, + ClientCertificate="/nonexistent/client.pem", + Password=None, + ) + expect_refused( + stack, + "a ClientCertificate carrying no private key is refused", + "connected with a file holding a certificate and no key", + Host=NAMED, + ClientCertificate=ca, + Password=None, + ) + + return R.summary() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/integration-tests/suites/test_transactions.py b/integration-tests/suites/test_transactions.py new file mode 100644 index 0000000..c344cf8 --- /dev/null +++ b/integration-tests/suites/test_transactions.py @@ -0,0 +1,320 @@ +#!/usr/bin/env python3 +"""ODBC manual-commit transactions, through unixODBC. + +Needs no profile: the `hive` catalog is in the base stack. + +Every scenario that writes names `hive`. It is the only connector Trino ships +that accepts a write outside autocommit. The coordinator gates that on the SPI's +`Connector.isSingleStatementWritesOnly()`, and `tpcds` and `postgresql` answer +`AUTOCOMMIT_WRITE_CONFLICT`. See the hive catalog section in `AGENTS.md`. + +Three measured Trino behaviours shape what is asserted here, and each is the +reason a scenario looks the way it does rather than the obvious way: + + - **Any statement error aborts the whole transaction**, and Trino then + refuses everything including `COMMIT`. So the failed-statement scenario + expects `SQLEndTran(SQL_COMMIT)` to *fail*, and expects the connection to + keep working afterwards. + - **Two inserts into the same unpartitioned Hive table in one transaction + fail** (`Inserting into an unpartitioned table that were added, altered, + or inserted into in the same transaction is not supported`). The + multi-statement scenario therefore writes to two tables, which is also the + stronger atomicity claim. + - **A table written in a transaction cannot be read back before the commit** + (`NOT_SUPPORTED: Cannot read from a table ... that was modified within + transaction`). Row counts are taken from a second connection, after the + transaction ends. +""" + +import os +import sys +import time +import uuid + +import pyodbc + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from harness import Results, Stack # noqa: E402 + +# pyodbc enables ODBC connection pooling by default, and a pooled connection is +# handed back to the application without the driver being reconnected, so it +# arrives still carrying whatever commit mode the previous borrower left on it. +# Measured here: a dozen pyodbc connections produced two `TrinoBackend::connect` +# calls and no `disconnect` at all, and a `CREATE TABLE` on a "fresh" connection +# ran inside the previous borrower's manual-commit mode and was discarded, +# reporting success. +# +# Turned off so this suite measures the driver rather than the Driver Manager's +# pooling. The hazard itself is real and is recorded in AGENTS.md. +pyodbc.pooling = False + +SCHEMA = "hive.tx" + +# pyodbc exposes neither of these, so they are spelled out rather than taken +# from it: `SQL_ATTR_TXN_ISOLATION` and the level the driver must refuse. +SQL_ATTR_TXN_ISOLATION = 108 +SQL_TXN_SERIALIZABLE = 8 + + +def scenario(results, label, fn): + """Run one scenario, recording an exception as a single failure. + + `Results.run` is not used because each scenario does its own `check` + accounting, and a wrapper PASS printed beside an inner FAIL reads as though + something passed.""" + start = time.monotonic() + try: + fn() + except Exception as e: # noqa: BLE001 + results.bad(label, f"raised after {time.monotonic() - start:.1f}s: {e}") + else: + print(f" {label}: {time.monotonic() - start:.1f}s") + + +def unique_table(prefix): + """A table name of this run's own, so a suite left half-finished by an + earlier failure cannot make the next run pass or fail for the wrong + reason.""" + return f"{SCHEMA}.{prefix}_{uuid.uuid4().hex[:8]}" + + +def make_table(conn, table): + conn.cursor().execute(f"CREATE TABLE {table} (id integer)") + + +def drop_table(conn, table): + try: + conn.cursor().execute(f"DROP TABLE IF EXISTS {table}") + except Exception: # noqa: BLE001 + # Cleanup only. A failure here must not mask the scenario's own result. + pass + + +def count_rows(stack, table): + """Count from a *fresh* connection, which is what makes a commit or a + rollback observable rather than merely reported.""" + with stack.connect() as conn: + return conn.cursor().execute(f"SELECT count(*) FROM {table}").fetchone()[0] + + +def a_rollback_discards_a_write(stack, results): + table = unique_table("rollback") + with stack.connect() as setup: + make_table(setup, table) + try: + conn = stack.connect() + conn.autocommit = False + conn.cursor().execute(f"INSERT INTO {table} VALUES (1)") + conn.rollback() + conn.close() + + results.check( + "a rolled-back insert is not there", + count_rows(stack, table) == 0, + f"count is {count_rows(stack, table)}", + ) + finally: + with stack.connect() as cleanup: + drop_table(cleanup, table) + + +def a_commit_publishes_a_write(stack, results): + table = unique_table("commit") + with stack.connect() as setup: + make_table(setup, table) + try: + conn = stack.connect() + conn.autocommit = False + conn.cursor().execute(f"INSERT INTO {table} VALUES (1)") + conn.commit() + conn.close() + + results.check( + "a committed insert is visible to another connection", + count_rows(stack, table) == 1, + f"count is {count_rows(stack, table)}", + ) + finally: + with stack.connect() as cleanup: + drop_table(cleanup, table) + + +def a_commit_spanning_two_tables_is_atomic(stack, results): + """Two tables, not two inserts into one: Hive refuses a second insert into + the same unpartitioned table inside one transaction.""" + first, second = unique_table("atomic_a"), unique_table("atomic_b") + with stack.connect() as setup: + make_table(setup, first) + make_table(setup, second) + try: + conn = stack.connect() + conn.autocommit = False + conn.cursor().execute(f"INSERT INTO {first} VALUES (1)") + conn.cursor().execute(f"INSERT INTO {second} VALUES (2)") + conn.commit() + conn.close() + + results.check( + "both tables carry the commit", + count_rows(stack, first) == 1 and count_rows(stack, second) == 1, + f"{count_rows(stack, first)} and {count_rows(stack, second)}", + ) + finally: + with stack.connect() as cleanup: + drop_table(cleanup, first) + drop_table(cleanup, second) + + +def a_failed_statement_aborts_the_transaction(stack, results): + """Trino aborts the whole transaction on any statement error and then + refuses the commit, so the driver rolls back and reports the failure. + + Reporting success would tell an application its writes landed when they + were discarded, and the connection has to survive: the SQLSTATE is `25S03` + precisely so the Driver Manager does not suspend it.""" + table = unique_table("aborted") + with stack.connect() as setup: + make_table(setup, table) + try: + conn = stack.connect() + conn.autocommit = False + conn.cursor().execute(f"INSERT INTO {table} VALUES (1)") + + try: + conn.cursor().execute("SELECT 1/0").fetchall() + results.bad("division by zero fails", "it succeeded") + return + except Exception: # noqa: BLE001 + pass + + committed = None + try: + conn.commit() + committed = True + except Exception as e: # noqa: BLE001 + committed = False + state = getattr(e, "args", ["", ""])[0] + results.check( + "committing an aborted transaction reports 25S03", + state == "25S03", + f"SQLSTATE {state}", + ) + if committed: + results.bad( + "committing an aborted transaction fails", + "it reported success, so the application believes writes landed", + ) + + results.check( + "the discarded insert is not there", + count_rows(stack, table) == 0, + f"count is {count_rows(stack, table)}", + ) + + # Why the driver rolls back rather than leaving the session wedged: + # Trino refuses every statement on an aborted transaction. + conn.autocommit = True + value = conn.cursor().execute("SELECT 1").fetchone()[0] + results.check("the connection still works", value == 1, f"got {value}") + conn.close() + finally: + with stack.connect() as cleanup: + drop_table(cleanup, table) + + +def a_commit_closes_an_open_cursor(stack, results): + """`SQL_CURSOR_COMMIT_BEHAVIOR` is `SQL_CB_CLOSE`, from the application's + side. Trino discards a transaction's result sets when it ends, so fetching + on afterwards must not quietly return more rows.""" + conn = stack.connect() + conn.autocommit = False + cursor = conn.cursor() + cursor.execute("SELECT c_customer_sk FROM tpcds.sf1.customer ORDER BY 1") + first = cursor.fetchone() + results.check("the cursor produced a row before the commit", first is not None) + + conn.commit() + + try: + row = cursor.fetchone() + except Exception as e: # noqa: BLE001 + results.ok("fetching after the commit is refused", f"{type(e).__name__}") + else: + results.check( + "the cursor was closed by the commit", + row is None, + "it returned another row, so the cursor outlived its transaction", + ) + conn.autocommit = True + conn.close() + + +def autocommit_is_the_default(stack, results): + """No explicit transaction, and the write is visible elsewhere with no + commit, which is what ODBC's default commit mode means.""" + table = unique_table("autocommit") + with stack.connect() as setup: + make_table(setup, table) + try: + with stack.connect() as conn: + conn.cursor().execute(f"INSERT INTO {table} VALUES (1)") + results.check( + "an autocommit write needs no commit", + count_rows(stack, table) == 1, + f"count is {count_rows(stack, table)}", + ) + finally: + with stack.connect() as cleanup: + drop_table(cleanup, table) + + +def an_unsupported_isolation_level_is_refused_by_the_driver(stack, results): + """`SQL_TXN_ISOLATION_OPTION` advertises only `SQL_TXN_READ_UNCOMMITTED`, + so core rejects the rest with `HY024` before they reach the wire. + + The alternative would be advertising Trino's whole grammar and letting the + *connector* reject the level on the first statement that touches a catalog, + which reaches the application as a mysterious failed query rather than as a + refused attribute.""" + conn = stack.connect() + try: + conn.set_attr(SQL_ATTR_TXN_ISOLATION, SQL_TXN_SERIALIZABLE) + except Exception as e: # noqa: BLE001 + state = getattr(e, "args", ["", ""])[0] + results.check( + "SQL_TXN_SERIALIZABLE is refused with HY024", + state == "HY024", + f"SQLSTATE {state}", + ) + else: + results.bad( + "SQL_TXN_SERIALIZABLE is refused", + "it was accepted, but no Trino connector honours it", + ) + finally: + conn.close() + + +def main(): + stack = Stack.load() + results = Results("transactions") + + for label, fn in ( + ("a rollback discards a write", a_rollback_discards_a_write), + ("a commit publishes a write", a_commit_publishes_a_write), + ("a commit spanning two tables is atomic", a_commit_spanning_two_tables_is_atomic), + ("a failed statement aborts the transaction", a_failed_statement_aborts_the_transaction), + ("a commit closes an open cursor", a_commit_closes_an_open_cursor), + ("autocommit is the default", autocommit_is_the_default), + ( + "an unsupported isolation level is refused", + an_unsupported_isolation_level_is_refused_by_the_driver, + ), + ): + scenario(results, label, lambda fn=fn: fn(stack, results)) + + sys.exit(results.summary()) + + +if __name__ == "__main__": + main() diff --git a/integration-tests/suites/test_type_matrix.py b/integration-tests/suites/test_type_matrix.py new file mode 100755 index 0000000..e4c3883 --- /dev/null +++ b/integration-tests/suites/test_type_matrix.py @@ -0,0 +1,455 @@ +#!/usr/bin/env python3 +""" +Type-transform fuzz for the Trino ODBC driver. + +Drives every (Trino value, C data type) pair through `SQLGetData` on the raw C +ABI and checks the outcome against invariants rather than against a transcribed +copy of the ODBC conversion matrix. Transcribing the matrix would mostly test +the transcription. The invariants below are the properties whose violation is a +defect, and they hold for every cell of it. + + 1. The call returns. No pair may crash, abort or hang the process. + 2. A failure carries a SQLSTATE. `SQL_ERROR` with no diagnostic record + leaves an application with an error it cannot interpret. + 3. NULL is reported as NULL. `SQL_NULL_DATA` in the indicator, for every + target type, whatever the source type is. + 4. A value that does not fit reports 22003, not a truncated number. + 5. Text that is not a number reports 22018, not a zero. + 6. A successful conversion round-trips. Where the value is checkable as + text, what comes back is what went in. + +Covers the full type-transform matrix, NULL and the IEEE specials per type, +integer boundary values, and trailing semicolons. + +Usage: + python3 integration-tests/suites/test_type_matrix.py [path/to/driver.so] [conn-str] + +Requires a running Trino (integration-tests/setup.sh). Needs no compose profile: +the tpcds catalog is in the base stack. Standard library only (ctypes, no pyodbc +and no uv), so its output survives being redirected to a file. +""" + +import ctypes +import os +import sys + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from harness import Results, Stack # noqa: E402 +from test_c_abi import ( # noqa: E402 + SQL_ATTR_ODBC_VERSION, + SQL_DRIVER_NOPROMPT, + SQL_ERROR, + SQL_HANDLE_DBC, + SQL_HANDLE_ENV, + SQL_HANDLE_STMT, + SQL_NTS, + SQL_OV_ODBC3, + SQL_SUCCESS, + SQL_SUCCESS_WITH_INFO, + load, + sqlstate, + w, +) + +P = ctypes.c_void_p + +# C data types, from odbc_sys::CDataType. +C_CHAR = 1 +C_WCHAR = -8 +C_BIT = -7 +C_STINYINT = -26 +C_SSHORT = -15 +C_SLONG = -16 +C_SBIGINT = -25 +C_FLOAT = 7 +C_DOUBLE = 8 +C_BINARY = -2 +C_TYPE_DATE = 91 +C_TYPE_TIME = 92 +C_TYPE_TIMESTAMP = 93 + +C_TYPES = [ + ("SQL_C_CHAR", C_CHAR), + ("SQL_C_WCHAR", C_WCHAR), + ("SQL_C_BIT", C_BIT), + ("SQL_C_STINYINT", C_STINYINT), + ("SQL_C_SSHORT", C_SSHORT), + ("SQL_C_SLONG", C_SLONG), + ("SQL_C_SBIGINT", C_SBIGINT), + ("SQL_C_FLOAT", C_FLOAT), + ("SQL_C_DOUBLE", C_DOUBLE), + ("SQL_C_BINARY", C_BINARY), + ("SQL_C_TYPE_DATE", C_TYPE_DATE), + ("SQL_C_TYPE_TIME", C_TYPE_TIME), + ("SQL_C_TYPE_TIMESTAMP", C_TYPE_TIMESTAMP), +] + +SQL_NULL_DATA = -1 + +# Spec SQLSTATEs this fuzz reasons about. +STATE_OUT_OF_RANGE = "22003" # Numeric value out of range +STATE_BAD_CAST = "22018" # Invalid character value for cast +STATE_TRUNCATED = "01004" # String data, right truncated +STATE_RESTRICTED = "07006" # Restricted data type attribute violation + +# (label, Trino expression, expected text when read as SQL_C_CHAR or None) +# +# Boundary values are the exact limits of each Trino integer type, because an +# off-by-one in a narrowing conversion shows up nowhere else. +VALUES = [ + # "1"/"0", not "true"/"false": a Trino BOOLEAN is described as SQL_BIT + # (verified through SQLDescribeCol), and the ODBC conversion matrix renders + # SQL_BIT as the character "1" or "0". Reading the Trino spelling back would + # mean the driver was not honouring the type it advertises. + ("boolean true", "CAST(true AS BOOLEAN)", "1"), + ("boolean false", "CAST(false AS BOOLEAN)", "0"), + ("tinyint min", "CAST(-128 AS TINYINT)", "-128"), + ("tinyint max", "CAST(127 AS TINYINT)", "127"), + ("smallint min", "CAST(-32768 AS SMALLINT)", "-32768"), + ("smallint max", "CAST(32767 AS SMALLINT)", "32767"), + ("integer min", "CAST(-2147483648 AS INTEGER)", "-2147483648"), + ("integer max", "CAST(2147483647 AS INTEGER)", "2147483647"), + ("bigint min", "CAST(-9223372036854775808 AS BIGINT)", "-9223372036854775808"), + ("bigint max", "CAST(9223372036854775807 AS BIGINT)", "9223372036854775807"), + ("bigint zero", "CAST(0 AS BIGINT)", "0"), + ("real", "CAST(1.5 AS REAL)", None), + ("real nan", "CAST(nan() AS REAL)", None), + ("real inf", "CAST(infinity() AS REAL)", None), + ("real -inf", "CAST(-infinity() AS REAL)", None), + ("double", "CAST(1.5 AS DOUBLE)", None), + ("double nan", "CAST(nan() AS DOUBLE)", None), + ("double inf", "CAST(infinity() AS DOUBLE)", None), + ("double -inf", "CAST(-infinity() AS DOUBLE)", None), + ("decimal", "CAST(123.45 AS DECIMAL(10,2))", "123.45"), + ("decimal negative", "CAST(-123.45 AS DECIMAL(10,2))", "-123.45"), + ("varchar text", "CAST('hello' AS VARCHAR)", "hello"), + ("varchar numeric text", "CAST('42' AS VARCHAR)", "42"), + ("varchar empty", "CAST('' AS VARCHAR)", ""), + ("varchar overflowing bigint", "CAST('99999999999999999999' AS VARCHAR)", None), + ("char(5)", "CAST('ab' AS CHAR(5))", None), + ("varbinary", "CAST('a' AS VARBINARY)", None), + ("date", "CAST('2020-02-03' AS DATE)", "2020-02-03"), + ("time", "CAST('04:05:06' AS TIME)", None), + ("timestamp", "CAST('2020-02-03 04:05:06' AS TIMESTAMP)", None), + ("timestamp tz", "CAST('2020-02-03 04:05:06 UTC' AS TIMESTAMP WITH TIME ZONE)", None), + ("uuid", "CAST('12151fd2-7586-11e9-8f9e-2a86e4085a59' AS UUID)", None), + ("json", "CAST('{\"a\":1}' AS JSON)", None), + ("interval day", "INTERVAL '2' DAY", None), + ("interval year", "INTERVAL '2' YEAR", None), + ("array", "ARRAY[1,2,3]", None), + ("row", "CAST(ROW(1,'a') AS ROW(x INTEGER, y VARCHAR))", None), +] + +# Every value above, as its own NULL. NULL must be reported as NULL for every +# target type. A driver that reports a NULL as 0 or "" corrupts data silently. +NULL_VALUES = [ + ("null boolean", "CAST(NULL AS BOOLEAN)"), + ("null tinyint", "CAST(NULL AS TINYINT)"), + ("null integer", "CAST(NULL AS INTEGER)"), + ("null bigint", "CAST(NULL AS BIGINT)"), + ("null double", "CAST(NULL AS DOUBLE)"), + ("null real", "CAST(NULL AS REAL)"), + ("null decimal", "CAST(NULL AS DECIMAL(10,2))"), + ("null varchar", "CAST(NULL AS VARCHAR)"), + ("null varbinary", "CAST(NULL AS VARBINARY)"), + ("null date", "CAST(NULL AS DATE)"), + ("null time", "CAST(NULL AS TIME)"), + ("null timestamp", "CAST(NULL AS TIMESTAMP)"), + ("null uuid", "CAST(NULL AS UUID)"), + ("null json", "CAST(NULL AS JSON)"), +] + +# Statements whose terminator or comment placement the parser has to survive. +TERMINATORS = [ + ("plain", "SELECT 1 AS n"), + ("one semicolon", "SELECT 1 AS n;"), + ("semicolon and spaces", "SELECT 1 AS n ; "), + ("two semicolons", "SELECT 1 AS n;;"), + ("semicolon and newline", "SELECT 1 AS n;\n"), + ("semicolon in a literal", "SELECT ';' AS n"), + ("semicolon in a literal, then terminator", "SELECT ';' AS n;"), + ("semicolon in a quoted identifier", 'SELECT 1 AS "a;b"'), + ("semicolon inside a line comment", "SELECT 1 AS n -- ;"), + ("semicolon inside a block comment", "SELECT 1 AS n /* ; */"), +] + +R = Results("type matrix") +violations = [] + + +# These write R's counters directly rather than going through ok()/bad(): +# the suite prints per *violation*, not per check. 37 values against 13 C +# types is 481 PASS lines nobody reads. +def fail(kind, detail): + R.failed += 1 + violations.append(f"{kind}: {detail}") + + +def ok(): + R.passed += 1 + + +class Driver: + def __init__(self, so, conn_str): + self.lib = load(so) + self.env = P() + self.lib.SQLAllocHandle(SQL_HANDLE_ENV, None, ctypes.byref(self.env)) + self.lib.SQLSetEnvAttr(self.env, SQL_ATTR_ODBC_VERSION, P(SQL_OV_ODBC3), 0) + self.dbc = P() + self.lib.SQLAllocHandle(SQL_HANDLE_DBC, self.env, ctypes.byref(self.dbc)) + cs, self._keep = w(conn_str) + ob = (ctypes.c_uint16 * 1024)() + ol = ctypes.c_int16(0) + r = self.lib.SQLDriverConnectW( + self.dbc, None, cs, SQL_NTS, + ctypes.cast(ob, ctypes.POINTER(ctypes.c_uint16)), 1024, + ctypes.byref(ol), SQL_DRIVER_NOPROMPT, + ) + if r not in (SQL_SUCCESS, SQL_SUCCESS_WITH_INFO): + raise SystemExit("could not connect; is Trino running? (integration-tests/setup.sh)") + + def fetch_as(self, expr, c_type): + """Run `SELECT ` and read column 1 as `c_type`. + + Returns (ret, sqlstate, indicator, raw_bytes). + """ + lib = self.lib + stmt = P() + lib.SQLAllocHandle(SQL_HANDLE_STMT, self.dbc, ctypes.byref(stmt)) + try: + sql, _k = w(f"SELECT {expr}") + r = lib.SQLExecDirectW(stmt, sql, SQL_NTS) + if r not in (SQL_SUCCESS, SQL_SUCCESS_WITH_INFO): + return ("EXEC", sqlstate(lib, SQL_HANDLE_STMT, stmt), None, None) + r = lib.SQLFetch(stmt) + if r not in (SQL_SUCCESS, SQL_SUCCESS_WITH_INFO): + return ("FETCH", sqlstate(lib, SQL_HANDLE_STMT, stmt), None, None) + buf = ctypes.create_string_buffer(512) + ind = ctypes.c_int64(0) + r = lib.SQLGetData( + stmt, 1, c_type, ctypes.cast(buf, P), 512, ctypes.byref(ind) + ) + return (r, sqlstate(lib, SQL_HANDLE_STMT, stmt), ind.value, buf.raw) + finally: + lib.SQLFreeHandle(SQL_HANDLE_STMT, stmt) + + def close(self): + self.lib.SQLDisconnect(self.dbc) + self.lib.SQLFreeHandle(SQL_HANDLE_DBC, self.dbc) + self.lib.SQLFreeHandle(SQL_HANDLE_ENV, self.env) + + +def i32_max_as_u64(): + """The largest column size a driver may honestly report as a number. + + `i32::MAX` is the established "unbounded but reportable" convention; + anything above it means a signed sentinel was written into an unsigned + out-parameter and wrapped. + """ + return 2**31 - 1 + + +def as_text(raw, c_type): + if c_type == C_WCHAR: + u = ctypes.cast(raw, ctypes.POINTER(ctypes.c_uint16)) + out = [] + for i in range(256): + if u[i] == 0: + break + out.append(chr(u[i])) + return "".join(out) + return raw.split(b"\x00", 1)[0].decode("utf-8", "replace") + + +def main(): + here = os.path.dirname(os.path.abspath(__file__)) + so = sys.argv[1] if len(sys.argv) > 1 else os.path.join( + here, "..", "..", "target", "debug", "libstackable_odbc_trino.so" + ) + conn_str = sys.argv[2] if len(sys.argv) > 2 else Stack.load().conn_str() + if not os.path.exists(so): + print(f"driver not found: {so}\nrun: cargo build") + return 2 + + d = Driver(so, conn_str) + print("=== type-transform fuzz ===\n") + + # -- invariants 1, 2 and 6 over the full matrix ------------------- + print(f"--- {len(VALUES)} values x {len(C_TYPES)} C types ---") + for label, expr, want_text in VALUES: + for cname, ctype in C_TYPES: + ret, state, ind, raw = d.fetch_as(expr, ctype) + cell = f"{label} -> {cname}" + + if ret in ("EXEC", "FETCH"): + fail("query failed", f"{cell}: {ret} {state}") + continue + + # Invariant 2: a failure must carry a SQLSTATE. + if ret == SQL_ERROR and not state: + fail("error with no SQLSTATE", cell) + continue + + if ret in (SQL_SUCCESS, SQL_SUCCESS_WITH_INFO): + # Invariant 6: a successful text conversion round-trips. + if want_text is not None and ctype in (C_CHAR, C_WCHAR): + got = as_text(raw, ctype) + if got != want_text: + fail( + "round-trip mismatch", + f"{cell}: sent {want_text!r}, read {got!r}", + ) + continue + ok() + + # -- invariant 4: overflow is 22003, not a truncated number ------- + print("\n--- integer overflow must report 22003 ---") + OVERFLOW = [ + ("bigint max -> SQL_C_SLONG", "CAST(9223372036854775807 AS BIGINT)", C_SLONG), + ("bigint max -> SQL_C_SSHORT", "CAST(9223372036854775807 AS BIGINT)", C_SSHORT), + ("bigint max -> SQL_C_STINYINT", "CAST(9223372036854775807 AS BIGINT)", C_STINYINT), + ("integer max -> SQL_C_SSHORT", "CAST(2147483647 AS INTEGER)", C_SSHORT), + ("integer max -> SQL_C_STINYINT", "CAST(2147483647 AS INTEGER)", C_STINYINT), + ("smallint max -> SQL_C_STINYINT", "CAST(32767 AS SMALLINT)", C_STINYINT), + ("bigint min -> SQL_C_SLONG", "CAST(-9223372036854775808 AS BIGINT)", C_SLONG), + ] + for cell, expr, ctype in OVERFLOW: + ret, state, ind, raw = d.fetch_as(expr, ctype) + if ret in (SQL_SUCCESS, SQL_SUCCESS_WITH_INFO): + fail( + "silent overflow", + f"{cell}: succeeded where the value cannot fit; expected {STATE_OUT_OF_RANGE}", + ) + elif state != STATE_OUT_OF_RANGE: + fail("wrong overflow SQLSTATE", f"{cell}: {state or ''} " + f"(expected {STATE_OUT_OF_RANGE})") + else: + ok() + + # -- invariant 5: non-numeric text is 22018, not zero ------------- + print("\n--- non-numeric text must report 22018 ---") + for cname, ctype in [ + ("SQL_C_SLONG", C_SLONG), + ("SQL_C_SBIGINT", C_SBIGINT), + ("SQL_C_DOUBLE", C_DOUBLE), + ("SQL_C_SSHORT", C_SSHORT), + ]: + cell = f"varchar 'abc' -> {cname}" + ret, state, ind, raw = d.fetch_as("CAST('abc' AS VARCHAR)", ctype) + if ret in (SQL_SUCCESS, SQL_SUCCESS_WITH_INFO): + fail("silent bad cast", f"{cell}: succeeded; expected {STATE_BAD_CAST}") + elif state != STATE_BAD_CAST: + fail("wrong bad-cast SQLSTATE", + f"{cell}: {state or ''} (expected {STATE_BAD_CAST})") + else: + ok() + + # -- invariant 3: NULL is NULL for every target type -------------- + print(f"\n--- {len(NULL_VALUES)} NULLs x {len(C_TYPES)} C types ---") + for label, expr in NULL_VALUES: + for cname, ctype in C_TYPES: + cell = f"{label} -> {cname}" + ret, state, ind, raw = d.fetch_as(expr, ctype) + if ret in ("EXEC", "FETCH"): + fail("query failed", f"{cell}: {ret} {state}") + elif ret in (SQL_SUCCESS, SQL_SUCCESS_WITH_INFO): + if ind != SQL_NULL_DATA: + fail( + "NULL not reported as NULL", + f"{cell}: indicator {ind}, expected SQL_NULL_DATA ({SQL_NULL_DATA})", + ) + else: + ok() + elif not state: + fail("error with no SQLSTATE", cell) + else: + # Refusing the conversion outright is legitimate; reporting the + # NULL as data is not, and that is what the branch above checks. + ok() + + # -- the IEEE specials as text ------------------------------------ + # Their spelling is Trino's and Java's, not Rust's Display: an application + # reading a DOUBLE as text must not see `inf` where every other Trino client + # says `Infinity`, and a value that round-trips through two clients should + # not change spelling on the way. + print("\n--- IEEE specials render with Trino's spelling ---") + for expr, want in [ + ("CAST(infinity() AS DOUBLE)", "Infinity"), + ("CAST(-infinity() AS DOUBLE)", "-Infinity"), + ("CAST(nan() AS DOUBLE)", "NaN"), + ("CAST(infinity() AS REAL)", "Infinity"), + ]: + ret, state, ind, raw = d.fetch_as(expr, C_CHAR) + if ret not in (SQL_SUCCESS, SQL_SUCCESS_WITH_INFO): + fail("special not readable as text", f"{expr}: {state or ret}") + continue + got = as_text(raw, C_CHAR) + if got != want: + fail("wrong spelling", f"{expr}: read {got!r}, expected {want!r}") + else: + ok() + + # -- an undeterminable column size is 0, not a wrapped -1 ---------- + # SQLDescribeCol's ColumnSizePtr is a SQLULEN, and the spec's answer for a + # size the driver cannot determine is 0. Reporting SQL_NO_TOTAL there + # instead surfaced as 18,446,744,073,709,551,612, which an application + # sizing a buffer from would try to allocate. + print("\n--- undeterminable column sizes report 0 ---") + lib = d.lib + lib.SQLDescribeColW.argtypes = [ + P, ctypes.c_uint16, ctypes.POINTER(ctypes.c_uint16), ctypes.c_int16, + ctypes.POINTER(ctypes.c_int16), ctypes.POINTER(ctypes.c_int16), + ctypes.POINTER(ctypes.c_uint64), ctypes.POINTER(ctypes.c_int16), + ctypes.POINTER(ctypes.c_int16), + ] + lib.SQLDescribeColW.restype = ctypes.c_int16 + for stmt_sql in ("DESCRIBE tpcds.sf1.customer", "SHOW TABLES FROM tpcds.sf1"): + stmt = P(); lib.SQLAllocHandle(SQL_HANDLE_STMT, d.dbc, ctypes.byref(stmt)) + wsql, _k = w(stmt_sql) + if lib.SQLExecDirectW(stmt, wsql, SQL_NTS) in (SQL_SUCCESS, SQL_SUCCESS_WITH_INFO): + nm = (ctypes.c_uint16 * 64)(); nl = ctypes.c_int16(0); dt = ctypes.c_int16(0) + sz = ctypes.c_uint64(0); dd = ctypes.c_int16(0); nu = ctypes.c_int16(0) + lib.SQLDescribeColW( + stmt, 1, ctypes.cast(nm, ctypes.POINTER(ctypes.c_uint16)), 64, + ctypes.byref(nl), ctypes.byref(dt), ctypes.byref(sz), + ctypes.byref(dd), ctypes.byref(nu), + ) + if sz.value > i32_max_as_u64(): + fail( + "absurd column size", + f"{stmt_sql}: SQLDescribeCol reported {sz.value:,}", + ) + else: + ok() + else: + fail("query failed", stmt_sql) + lib.SQLFreeHandle(SQL_HANDLE_STMT, stmt) + + # -- trailing semicolons and comment placement -------------------- + print(f"\n--- {len(TERMINATORS)} statement terminator forms ---") + for label, sql in TERMINATORS: + stmt = P() + d.lib.SQLAllocHandle(SQL_HANDLE_STMT, d.dbc, ctypes.byref(stmt)) + wsql, _k = w(sql) + ret = d.lib.SQLExecDirectW(stmt, wsql, SQL_NTS) + state = sqlstate(d.lib, SQL_HANDLE_STMT, stmt) + if ret in (SQL_SUCCESS, SQL_SUCCESS_WITH_INFO): + ok() + else: + fail("terminator rejected", f"{label}: {sql!r} -> {state or ''}") + d.lib.SQLFreeHandle(SQL_HANDLE_STMT, stmt) + + d.close() + + if violations: + print("\nviolations:") + seen = set() + for v in violations: + if v not in seen: + seen.add(v) + print(f" {v}") + return R.summary() + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/integration-tests/windows/WINDOWS.md b/integration-tests/windows/WINDOWS.md new file mode 100644 index 0000000..6848f63 --- /dev/null +++ b/integration-tests/windows/WINDOWS.md @@ -0,0 +1,465 @@ +# Windows testing + +The suites in `suites/`, driven through the Windows ODBC Driver Manager over +WinRM, plus a GUI check on the driver's setup dialog. The target is a +disposable Windows Server VM on a host-only libvirt network, created by the +Ansible playbook in `vm/`. + +Which suites run is decided by [`suites/registry.py`](../suites/registry.py), +the same list `scripts/run-tests.sh` reads, so a suite is added once. A suite +that does not run here says why in its entry, and the run prints it as a `SKIP`. +Today that is `test_harness.py`, which tests platform-independent Python and +reaches neither the driver nor a Driver Manager, and `test_oauth.py`, which +needs an `odbc32.dll` branch for its Driver Manager scenario and a Keycloak +issuer the VM resolves to the host. + +The VM's credentials are `Administrator` / `Asdf1234`, the defaults in +`windows_test.py` and `dsn_dialog_test.py`. They are not a secret: the machine +is local, throwaway, and reachable only from the host that created it. Pass +`--user` and `--password` for a VM built some other way. + +## Quick start: running the tests + +If the VM does not exist yet, build it first: [Prerequisites](#prerequisites), +then [Creating the VM](#creating-the-vm). + +Trino must be running on the host via `./integration-tests/setup.sh`. Start +the VM and its networks (skip whatever is already running): + +```bash +virsh --connect qemu:///system net-start stackable-odbc-test-hostnet +virsh --connect qemu:///system net-start stackable-odbc-test-internet +virsh --connect qemu:///system start stackable-odbc-test +``` + +Then run from the Linux host. `uv` installs `pywinrm` itself: + +```bash +uv run --with pywinrm python3 integration-tests/windows/windows_test.py +``` + +`test_integration.py` runs once per connect configuration, over four: DSN and +DSN-less crossed with verified and unverified TLS. All four are HTTPS on port +8443, because the stack serves nothing else. Every other suite runs once, +against the verified DSN-less configuration. Each run records its own result, +so one failure never hides the rest, and the script ends with a summary. + +### What the VM gets + +The VM mirrors the repository layout under `C:\odbc_test_trino`, rather than +holding a flat pile of files, because the suites address their neighbours by +relative path: `test_folding_contract.py` reads +`../../connector/StackableTrinoODBC.pq`, and `harness.Stack` defaults to +`../generated/stack.env`. Mirroring makes those resolve on the VM as they do on +the host, so no suite needs a Windows branch to find its own inputs. + +```text +C:\odbc_test_trino\ + stackable_odbc_trino.dll the driver + configure-dsn.ps1 beside the DLL, where ConfigDSN looks for it + install.bat, uninstall.bat the shipped installers, for check_installers + archive\ a staged copy of the four files above, made + and removed by check_installers + connector\ what the folding contract suite parses + integration-tests\suites\ every .py from suites/ + integration-tests\generated\ stack.env and certs\ +``` + +### The installer round trip + +Before it registers the driver its own way, `windows_test.py` runs the shipped +`install.bat` and `uninstall.bat` out of `archive\`, which stages the four files +the release zip puts side by side. `install.bat` refuses to run unless the DLL +and `configure-dsn.ps1` are beside it, so the layout is part of what is checked. + +It then reads the state each one left, rather than trusting the exit code: + +- after install, both files under `%ProgramFiles%\Stackable\ODBC`, the + `stackable_odbc_trino` key under `HKLM\SOFTWARE\ODBC\ODBCINST.INI`, **and** an + entry in that key's `ODBC Drivers` listing, which is what populates the + Administrator's Drivers tab +- after uninstall, none of the four, and the install directory gone + +`odbcconf.exe` exits 0 whether or not the action it was given succeeded, which +is why the state is read rather than the status. `register_driver` works around +the same unreliability by force-writing `Driver` and `Setup` after its own +`odbcconf` call. + +This runs first because the uninstaller deregisters the driver: after it, the +harness registers its own copy, and every suite below depends on that. + +`integration-tests\generated\stack.env` is the VM's own view of the stack, +written by `windows_test.py` and kept on the host as +`generated/windows-stack.env` for inspection. It carries the same keys +`scripts/gen-odbc-config.sh` writes, with the paths and the host the VM sees, +which is what lets the suites that read `stack.env` rather than taking a +connection string (`test_tls.py`, `test_spooling.py`, `test_transactions.py`) +run here at all. It carries one key the host's does not: `DRIVER_NAME`, because +the Windows Driver Manager loads a driver by its registered name while the +ctypes suites want the DLL's path, and on Linux one string serves both. + +The compose profiles are the host stack's, since that is the coordinator the VM +connects to. A suite gated on a profile is gated on the same one here. + +**Do not diagnose a Windows failure without rebuilding the DLL first.** +`--skip-build` reuses whatever sits in `target/x86_64-pc-windows-gnu/release/`, +which can predate the feature under test by days. + +### Options + +`--help` lists them all. The ones that come up: + +| Flag | Default | Effect | +|---|---|---| +| `--skip-build` | off | Use the DLL already in `target/`, rather than rebuilding. See the warning above | +| `--target {gnu,msvc}` | `gnu` | Which Windows target to build and deploy. `msvc` needs an MSVC-capable linker on the host; see [Building the DLL](#building-the-dll) | +| `--host
` | discovered from the libvirt DHCP leases | VM IP or hostname | +| `--vm-network ` | `stackable-odbc-test-hostnet` | The libvirt network that discovery reads leases from | +| `--user`, `--password` | `Administrator`, `Asdf1234` | WinRM credentials | +| `--gateway ` | `$ODBC_TEST_HOST_GATEWAY`, else `192.168.197.1` | The host-only gateway the VM reaches the host on. `scripts/gen-certs.sh` reads the same environment variable, so the coordinator's certificate covers the address the VM connects to | +| `--trino-host
` | the same as `--gateway` | Where the VM reaches Trino. The name `trino` is mapped to it in the VM's hosts file | +| `--suite ` | unset | Run only the suites whose name contains the substring. `run-tests.sh --suite` forwards to this | + +The verified configurations connect to `trino` rather than to an address. TLS +sends no SNI for an IP literal, and Jetty then serves Trino's internal +self-signed certificate instead of the CA-signed one, which no verification +can accept. The unverified configurations use the address directly, which is +what an operator who has not set up a name would do. + +### The setup dialog + +`dsn_dialog_test.py` drives the ODBC Data Source Administrator's **Add…**, +**Configure…** and **Remove** buttons, which is the only thing that exercises +`TrinoBackend::configure_dsn`. Every other suite reaches the driver through a +connection, and both `odbcconf` and `configure-dsn.ps1` call +`SQLConfigDataSource` with a null *hwndParent*, so they take the headless path. + +**It is not in the registry, and that is deliberate.** Every registered suite +is a Python file deployed to the VM and run there over WinRM. This one runs on +the *host*, driving `virsh screenshot` against the VM's framebuffer, because +WinRM lands in session 0 and session 0 has no desktop to photograph. Giving it +an entry would mean the registry described two unrelated things. + +Run `windows_test.py` first. It deploys the DLL and `configure-dsn.ps1`, which +the driver looks for beside it. + +```bash +uv run --with pywinrm python3 integration-tests/windows/dsn_dialog_test.py + +# Leave the Administrator open afterwards, to poke at by hand +uv run --with pywinrm python3 integration-tests/windows/dsn_dialog_test.py --keep-open +``` + +It takes `--host`, `--user`, `--password`, `--vm-network` and `--trino-host` +with the same meanings as above, plus `--domain` for the libvirt domain name +`virsh screenshot` is called with. + +Six screenshots of the **Add…** path land in +`integration-tests/generated/windows-dialog/`, taken from the VM's framebuffer +with `virsh screenshot`. WinRM lands in session 0, which has no desktop to +photograph. They are what to look at when a check reports a mismatch. The +mechanics that took measuring are documented at the top of the script. + +### Using a different hypervisor (VirtualBox, Hyper-V, etc.) + +The VM lifecycle section below uses QEMU/KVM via libvirt, and the test script +discovers the VM IP from libvirt DHCP leases. Windows in a different +hypervisor works too. Pass the VM's IP directly: + +```bash +uv run --with pywinrm python3 integration-tests/windows/windows_test.py --host +``` + +The VM must have WinRM enabled on port 5985 with NTLM auth, and Python 3 plus +pyodbc installed. `dsn_dialog_test.py` additionally needs libvirt, because it +screenshots through `virsh`. + +### OpenSSL legacy provider + +WinRM uses NTLM authentication, which requires MD4, disabled by default in +modern OpenSSL. The test script sets `OPENSSL_CONF` to point at +`integration-tests/windows/openssl_legacy.cnf`, which enables the legacy +provider. + +If you see `unsupported hash type md4` errors, check that the file exists and +that you have not overridden `OPENSSL_CONF` in your environment. + +## VM lifecycle + +### Prerequisites + +QEMU/KVM and libvirt must be installed and working as system services. +`nix-shell` provides only Ansible and the Python bindings, not the +virtualisation stack itself. Verify with: + +```bash +virsh --connect qemu:///system list --all +``` + +If this fails, install and configure QEMU/KVM and libvirt for your distro. You +will also need a `default` storage pool (`virsh pool-list`), and your user must +be in the `libvirt` group. + +**Note:** QEMU typically runs as a dedicated user (e.g. `libvirt-qemu`) that +cannot read files under your home directory. If the playbook fails with a +permission error on the ISO or the virtio drivers, grant read access with ACLs +(e.g. `setfacl -m u:libvirt-qemu:r /path/to/file.iso`, and +`setfacl -m u:libvirt-qemu:x` on each parent directory). + +On Ubuntu 24.04 that is: + +```bash +sudo apt install -y qemu-system-x86 qemu-utils libvirt-daemon-system \ + libvirt-clients virtinst bridge-utils virt-viewer virt-manager acl +sudo adduser $USER libvirt +sudo adduser $USER kvm +# log out and back in, then verify: +virsh --connect qemu:///system list --all +# uv (Python tool runner, used by the test script): +pipx install uv +``` + +Package names differ on other distros. + +### Creating the VM + +`WINDOWS_ISO` points at a Windows Server evaluation ISO you download yourself. +The table below names the one the current image was built from. + +```bash +export WINDOWS_ISO=~/Downloads/SERVER_EVAL_x64FRE_en-us.iso + +cd integration-tests/windows/vm +nix-shell # loads Ansible + libvirt Python bindings +ansible-playbook start.yaml -i inventory.ini +``` + +The playbook creates a QEMU/KVM VM with two networks (host-only and NAT), +boots the Windows ISO, and waits for the guest agent. `Autounattend.xml` +installs Python and pyodbc. + +First run takes ~30 minutes, for the Windows install and the downloads. Use +`virt-viewer` or `virt-manager` to watch progress: + +```bash +virt-viewer --connect qemu:///system stackable-odbc-test +``` + +### What the current VM image was built with + +A snapshot of the image in use, not a set of requirements. Each pin and the +paths derived from it have to move together, which is why they are collected +here. + +| Thing | Value | Set in | +|---|---|---| +| Guest OS | Windows Server 2022 evaluation, [from the evalcenter](https://www.microsoft.com/en-us/evalcenter/evaluate-windows-server-2022) | `$WINDOWS_ISO`, checked by `vm/start.yaml` | +| Guest Python | 3.12.9, at `C:\Program Files\Python312\python.exe` | `vm/files/windows-install-config/Autounattend.xml`, and `REMOTE_PYTHON` in `windows_test.py` | +| virtio-win drivers | 0.1.248 | `vm/start.yaml`, downloaded and checksummed | +| LLVM for the MSVC cross build | `llvmPackages_18` | the `nix-shell` line under [Building the DLL](#building-the-dll) | + +### Shutting down + +```bash +virsh --connect qemu:///system shutdown stackable-odbc-test +``` + +The VM definition and disk persist, so the next `start` is fast. + +### Tearing down completely + +Remove the VM, its disk, and the virtual networks: + +```bash +virsh --connect qemu:///system destroy stackable-odbc-test +virsh --connect qemu:///system undefine stackable-odbc-test +virsh --connect qemu:///system vol-delete --pool default stackable-odbc-test.qcow2 + +virsh --connect qemu:///system net-destroy stackable-odbc-test-hostnet +virsh --connect qemu:///system net-destroy stackable-odbc-test-internet +virsh --connect qemu:///system net-undefine stackable-odbc-test-hostnet +virsh --connect qemu:///system net-undefine stackable-odbc-test-internet +``` + +## Reference: driver and DSN management + +`windows_test.py` builds the DLL, registers the driver and creates the DSNs +itself. These commands are for working on the VM by hand. + +### Building the DLL + +The mingw cross-compile recipe is in +[CONTRIBUTING.md](../../CONTRIBUTING.md#windows), and is what +`windows_test.py` runs. It produces: + +```text +target/x86_64-pc-windows-gnu/release/stackable_odbc_trino.dll +``` + +MSVC is the alternative, selected with `--target msvc`. `cargo build` then +needs an MSVC-capable linker on the host, which `cargo-xwin` supplies +(`cargo install cargo-xwin`, plus nix for LLVM): + +```bash +nix-shell -p llvmPackages_18.clang llvmPackages_18.lld llvmPackages_18.llvm --run \ + "cargo xwin build --release --target x86_64-pc-windows-msvc" +``` + +Output: `target/x86_64-pc-windows-msvc/release/stackable_odbc_trino.dll` + +Both produce DLLs that work with the Windows Driver Manager. Prefer mingw; +use MSVC to match a target environment exactly. + +A DLL for testing is built with plain `cargo build`, which is what the harness +runs. A DLL destined for a release archive is built with `cargo auditable` +instead, because the SBOM is generated from the dependency list that embeds. +See [`packaging/README.md`](../../packaging/README.md). + +### Registering the driver + +All commands below run in `cmd.exe` as Administrator. Adjust the DLL path as +needed. + +```cmd +odbcconf.exe /A {INSTALLDRIVER "stackable_odbc_trino|Driver=C:\Users\Administrator\Downloads\stackable_odbc_trino.dll|Setup=C:\Users\Administrator\Downloads\stackable_odbc_trino.dll|"} +``` + +`Driver=` and `Setup=` must point at the same DLL, which exports both the ODBC +API functions and the `ConfigDSNW` setup entry point. + +### Creating a DSN + +Three ways, in descending order of convenience. + +**The ODBC Data Source Administrator.** `odbcad32.exe` → **Add…** → select +`stackable_odbc_trino`, or **Configure…** on an existing data source. Both +display the driver's dialog: `ConfigDSN` reaches +`TrinoBackend::configure_dsn`, which runs `configure-dsn.ps1` with `-Emit` and +hands the keywords back for core to write. The script must be installed +alongside the DLL, which `install.bat` does. + +**The dialog on its own**, the same WinForms dialog without the Administrator. +It writes through `SQLConfigDataSourceW`, so the driver's own `ConfigDSN` +stays in the loop: + +```powershell +powershell -ExecutionPolicy Bypass -File configure-dsn.ps1 +``` + +**`odbcconf`**, which is what the test harness uses. It passes a null +*hwndParent*, so no dialog is displayed and the keywords on the command line +are written as given: + +```cmd +odbcconf.exe /A {CONFIGDSN "stackable_odbc_trino" "DSN=MyTrino|Host=trino.example.com|Port=8443|User=admin|Password=secret|Catalog=hive|Schema=default|"} +``` + +A DSN written by hand stores the five `name:value;name2:value2` keys **bare**, +where a connection string braces them. See +[the root README](../../README.md#values-that-contain-a-semicolon). +`configure-dsn.ps1` handles it for you. + +### Connection string parameters + +The full table is in the [root README](../../README.md#connecting), and the +authoritative list is `src/backend/types/connect_params.rs`. `Host` and `Port` +are the only required keys, plus `User` outside `ExternalAuthentication`. The +examples in this file add `Password`, `Protocol`, `Catalog`, `Schema` and +`TlsVerify`. + +### Verifying registration + +Open `%SystemRoot%\System32\odbcad32.exe` (64-bit) and confirm: + +- **Drivers tab**: `stackable_odbc_trino` is listed, with a version and a + company rather than `Not marked` +- **User DSN tab**: `MyTrino` (or whatever DSN name you chose) is listed +- **Add…** with `stackable_odbc_trino` selected opens the driver's own dialog; + so does **Configure…** on an existing data source. See + [Creating a DSN](#creating-a-dsn) + +### Unregistering + +Remove a DSN (User DSN entries are stored under `HKCU`): + +```cmd +reg delete "HKCU\SOFTWARE\ODBC\ODBC.INI\MyTrino" /f +reg delete "HKCU\SOFTWARE\ODBC\ODBC.INI\ODBC Data Sources" /v "MyTrino" /f +``` + +Remove the driver, via the registry, as `odbcconf` does not support +`REMOVEDRIVER`: + +```cmd +reg delete "HKLM\SOFTWARE\ODBC\ODBCINST.INI\stackable_odbc_trino" /f +reg delete "HKLM\SOFTWARE\ODBC\ODBCINST.INI\ODBC Drivers" /v "stackable_odbc_trino" /f +``` + +## Reference: manual testing (optional) + +Nothing below is needed to run the suites. It is a smoke-test cookbook for a +PowerShell session on the VM. + +### PowerShell smoke test + +PowerShell's `System.Data.Odbc` is built into .NET, so no extra tools are +needed. This example uses an inline `VALUES` list, so it needs no table and no +writable catalog. The driver must be registered first, which the test script +does, and Trino must be reachable at the given host. + +The compose stack serves HTTPS on 8443 and nothing else. Its certificate comes +from a CA no machine trusts by default, so `TlsVerify=false` is what makes a +hand-typed connection work against it. Point at a coordinator with a real +certificate and it can come off. + +```powershell +$conn = New-Object System.Data.Odbc.OdbcConnection("Driver=stackable_odbc_trino;Host=;Port=8443;User=admin;Password=admin;Protocol=https;TlsVerify=false") +$conn.Open() +Write-Host "Connected: $($conn.State)" + +$cmd = $conn.CreateCommand() +$cmd.CommandText = "SELECT * FROM (VALUES (1, 'Alice', 75000.50), (2, 'Bob', 62000.00)) AS t(id, name, value)" +$reader = $cmd.ExecuteReader() +while ($reader.Read()) { + Write-Host "$($reader[0]) | $($reader[1]) | $($reader[2])" +} +$reader.Close() + +$conn.Close() +Write-Host "Done" +``` + +Expected output: + +```text +Connected: Open +1 | Alice | 75000.50 +2 | Bob | 62000.00 +Done +``` + +**DSN-based connection.** `windows_test.py` registers a DSN named +`test_trino`. To create one yourself, see [Creating a DSN](#creating-a-dsn), +then: + +```powershell +$c = New-Object System.Data.Odbc.OdbcConnection("DSN=MyTrino"); $c.Open(); Write-Host "Connected: $($c.State)"; $c.Close() +``` + +### Running a suite manually + +The harness deploys every suite, the test CA and the VM's `stack.env`; see +[What the VM gets](#what-the-vm-gets) for the layout. To run one without the +wrapper script, from a PowerShell session on the VM: + +```powershell +cd C:\odbc_test_trino\integration-tests\suites +& "C:\Program Files\Python312\python.exe" .\test_integration.py "Driver=stackable_odbc_trino;Host=;Port=8443;User=admin;Password=admin;Protocol=https;TlsVerify=false;Catalog=tpcds" +``` + +The suites that take no connection string read the deployed `stack.env` +instead, so they need no argument at all: + +```powershell +& "C:\Program Files\Python312\python.exe" .\test_tls.py +``` diff --git a/integration-tests/windows/dsn_dialog_test.py b/integration-tests/windows/dsn_dialog_test.py new file mode 100644 index 0000000..6bac26e --- /dev/null +++ b/integration-tests/windows/dsn_dialog_test.py @@ -0,0 +1,770 @@ +#!/usr/bin/env python3 +"""Drive the Windows ODBC Data Source Administrator's buttons, with screenshots. + +This is the only check on `TrinoBackend::configure_dsn`. Everything else in +`integration-tests/` reaches the driver through a connection: `odbcconf` and +`configure-dsn.ps1` both call `SQLConfigDataSource` with a **null** hwndParent, +which is the headless path, so nothing else exercises the dialog at all. + + ./integration-tests/setup.sh # Trino must be up + ./integration-tests/windows/windows_test.py # deploys the DLL and script + uv run --with pywinrm python3 \ + integration-tests/windows/dsn_dialog_test.py + +Requires a running Trino and the Windows VM. Needs no compose profile: the +dialog's Test button connects to the base stack. + +Screenshots land in `integration-tests/generated/windows-dialog/`, numbered in +the order they were taken, and are what a reviewer looks at when a step reports +a mismatch. `--keep-open` leaves the Administrator up to poke at by hand. + +They cover the **Add…** path only, through to the data source appearing in the +Administrator's list and reopening under **Configure…**. Cancel and Remove are +checked but not photographed: what they produce is a *transient* dialog and an +empty list, neither of which a picture settles, and both are asserted against +the registry instead. + +Four things about the mechanics decide whether a step measures anything, and +each one fails by giving a plausible wrong answer rather than an error: + +- **WinRM lands in session 0**, which has no visible desktop. A GUI started + from it is invisible and an in-guest screenshot is blank. Every UI step + therefore runs through a scheduled task with an interactive logon type, which + lands in the console session the VM auto-logs into, and screenshots come from + `virsh screenshot` on the host, which captures that session's framebuffer. +- **Neither dialog ships a UI Automation provider**, so every control arrives + as a generic `Pane` with no `InvokePattern` and, unreliably, no + `ValuePattern`. Buttons take a posted `BM_CLICK`, text fields take + `WM_SETTEXT`, and the tab strip, which is not a control with a handle at all, + takes a mouse click at its coordinates. +- **Control text is read from UI Automation's `Name`, never `GetWindowTextW`**, + which does not retrieve an edit control's text across a process boundary and + answers empty. That empty answer reads exactly like the write having failed. +- **`BM_CLICK` is posted, never sent.** A button that opens a modal dialog does + not return from its click until that dialog closes, so `SendMessage` hangs the + driving script for as long as the dialog is up, and the stuck instance makes + every later scheduled run refuse to start. +""" +import argparse +import base64 +import json +import subprocess +import sys +import time +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent +TEST_DIR = SCRIPT_DIR.parent +SHOT_DIR = TEST_DIR / "generated" / "windows-dialog" + +REMOTE_DIR = r"C:\odbc_test_trino" +DRIVER_NAME = "stackable_odbc_trino" +DSN_NAME = "trino_dialog_test" +TRINO_VM_HOSTNAME = "trino" + +# WinRM runs each command through cmd.exe, which caps its command line at 8191 +# characters, and pywinrm re-encodes the script as UTF-16 base64 on the way, so +# about 2.7x. A chunk this size stays under that with room to spare. A larger +# one fails the upload *silently*, leaving the previous script in place to run +# again, which looks exactly like the new one having no effect. +UPLOAD_CHUNK = 1200 + +# The PowerShell every UI step is prefixed with. Kept in one string so a step +# body reads as the actions it performs. +UIA_PRELUDE = r""" +$ErrorActionPreference = 'Stop' +Add-Type -AssemblyName UIAutomationClient +Add-Type -AssemblyName UIAutomationTypes +$AE = [System.Windows.Automation.AutomationElement] +$TS = [System.Windows.Automation.TreeScope] +$TRUE_COND = [System.Windows.Automation.Condition]::TrueCondition + +Add-Type -TypeDefinition @" +using System; +using System.Runtime.InteropServices; +public class Win32 { + [DllImport("user32.dll")] + public static extern bool PostMessage(IntPtr h, uint msg, IntPtr wp, IntPtr lp); + [DllImport("user32.dll")] public static extern bool SetCursorPos(int x, int y); + [DllImport("user32.dll")] + public static extern void mouse_event(uint f, uint dx, uint dy, uint d, IntPtr e); + [DllImport("user32.dll", CharSet=CharSet.Unicode)] + public static extern IntPtr SendMessage(IntPtr h, uint msg, IntPtr wp, string lp); + [DllImport("user32.dll", EntryPoint="GetWindowLongPtrW")] + public static extern IntPtr GetWindowLongPtr(IntPtr h, int idx); +} +"@ + +function Find-Win([string]$Like, [int]$Tries = 40) { + foreach ($i in 1..$Tries) { + foreach ($w in $AE::RootElement.FindAll($TS::Children, $TRUE_COND)) { + if ($w.Current.Name -like $Like) { return $w } + } + Start-Sleep -Milliseconds 500 + } + throw "no window matching '$Like'" +} + +function Find-Ctl($Win, [string]$Like, [int]$Tries = 40) { + foreach ($i in 1..$Tries) { + foreach ($c in $Win.FindAll($TS::Descendants, $TRUE_COND)) { + if ($c.Current.Name -like $Like) { return $c } + } + Start-Sleep -Milliseconds 500 + } + throw "no control matching '$Like' in '$($Win.Current.Name)'" +} + +# The Create New Data Source dialog reports an *empty* Name to UI Automation, +# so it can only be found by a control it owns. +function Find-CtlAnywhere([string]$Name, [int]$Tries = 40) { + foreach ($i in 1..$Tries) { + foreach ($w in $AE::RootElement.FindAll($TS::Children, $TRUE_COND)) { + foreach ($c in $w.FindAll($TS::Descendants, $TRUE_COND)) { + if ($c.Current.Name -eq $Name) { return $c } + } + } + Start-Sleep -Milliseconds 500 + } + throw "no control named '$Name' on any window" +} + +function Invoke-Ctl($Ctl) { + # Posted, never sent: a button that opens a modal dialog does not return + # from its click until that dialog closes. + [void][Win32]::PostMessage([IntPtr]$Ctl.Current.NativeWindowHandle, + 0x00F5, [IntPtr]::Zero, [IntPtr]::Zero) # BM_CLICK +} + +function Click-Point([int]$X, [int]$Y) { + [void][Win32]::SetCursorPos($X, $Y) + Start-Sleep -Milliseconds 200 + [Win32]::mouse_event(0x0002, 0, 0, 0, [IntPtr]::Zero) # LEFTDOWN + [Win32]::mouse_event(0x0004, 0, 0, 0, [IntPtr]::Zero) # LEFTUP + Start-Sleep -Milliseconds 400 +} + +function Click-Ctl($Ctl) { + $r = $Ctl.Current.BoundingRectangle + Click-Point ([int]($r.X + $r.Width / 2)) ([int]($r.Y + $r.Height / 2)) +} + +# A list view's rows are not exposed to UI Automation either. Neither the driver +# list nor the data source list has a child per row, so a row is selected by +# clicking where it sits. The caller supplies the index, computed +# from the registry, because the control cannot be asked what it contains. +function Click-ListRow($Win, [int]$Index) { + $list = $null + foreach ($c in $Win.FindAll($TS::Descendants, $TRUE_COND)) { + if ($c.Current.Name -eq "List1") { $list = $c } + } + if (-not $list) { throw "no list on '$($Win.Current.Name)'" } + $r = $list.Current.BoundingRectangle + # Below the column header, then one row height per row. + Click-Point ([int]($r.X + 60)) ([int]($r.Y + 22 + $Index * 17 + 8)) + Start-Sleep -Milliseconds 400 +} + +# The tab strip is not a control with a handle, so it is clicked by position +# along the top edge of the tab control. +function Click-Tab($Win, [int]$Index) { + $tabs = $null + foreach ($c in $Win.FindAll($TS::Descendants, $TRUE_COND)) { + if ($c.Current.AutomationId -eq "1") { $tabs = $c } + } + if (-not $tabs) { throw "no tab control" } + $r = $tabs.Current.BoundingRectangle + # Tab widths vary with their captions, so walk the measured offsets the + # dialog's own six tabs sit at rather than assuming a uniform width. + $offsets = @(34, 108, 166, 213, 258, 310) + Click-Point ([int]($r.X + $offsets[$Index])) ([int]($r.Y + 11)) +} + +# A control's text is read from UI Automation's Name, never with +# GetWindowTextW: that does not retrieve an edit control's text across a +# process boundary and answers empty instead. It answered empty for a field +# that had just been filled in, which read as the write having failed and sent +# this script chasing three input mechanisms that were all working. +function Get-CtlText($Ctl) { $Ctl.Current.Name } + +function Get-FieldText($Win, [string]$Label) { + # Re-found rather than cached: an element handed out before a write can + # answer from a stale property cache. + (Get-FieldCtl $Win $Label).Current.Name +} + +function Test-CtlReadOnly($Ctl) { + # ES_READONLY. There is no ValuePattern to ask: the dialog exposes no UI + # Automation provider, so every control is a raw Pane. + $style = [int64][Win32]::GetWindowLongPtr([IntPtr]$Ctl.Current.NativeWindowHandle, -16) + ($style -band 0x0800) -ne 0 +} + +# Each field is an edit box that follows its label in creation order, and the +# labels are the only named thing on a tab. A control is looked up by its label +# rather than by a fixed index because the descendant list gains and loses +# leading entries between dialog instances: an index addressing the name box on +# one run addresses its *label* on the next, and a label has no ValuePattern. +function Get-FieldCtl($Win, [string]$Label) { + $all = @($Win.FindAll($TS::Descendants, $TRUE_COND)) + for ($i = 0; $i -lt $all.Count; $i++) { + if ($all[$i].Current.Name -ne $Label) { continue } + # A secret's row is label, Save box, edit; a file's row is label, Browse + # button, edit. Taking the element straight after the label would yield a + # CheckBox for Password, which has no ValuePattern. + for ($j = $i + 1; $j -lt $all.Count; $j++) { + $n = $all[$j].Current.Name + if ($n -eq "Save" -or $n -eq "Browse...") { continue } + return $all[$j] + } + } + throw "no field labelled '$Label'" +} + +$WM_SETTEXT = 0x000C + +function Set-Field($Win, [string]$Label, [string]$Value) { + <# + WM_SETTEXT, which replaces the whole contents and needs no focus, so a + pre-filled field does not have to be cleared first. + + ValuePattern is not usable: the raw-window provider offers it on these + edits only some of the time, and the same dialog answered "Unsupported + Pattern" for every one of its controls on a later run. + #> + $ctl = Get-FieldCtl $Win $Label + [void][Win32]::SendMessage([IntPtr]$ctl.Current.NativeWindowHandle, + $WM_SETTEXT, [IntPtr]::Zero, $Value) + Start-Sleep -Milliseconds 150 + + # Read it back. A field that did not take the value is this function's whole + # failure mode, and it is otherwise invisible until an assertion far + # downstream reports something unrelated. + $got = Get-FieldText $Win $Label + if ($got -ne $Value) { throw "field '$Label' holds '$got' after setting '$Value'" } +} +""" + + +class Vm: + """The VM, reachable two ways: WinRM for state, the console for the UI.""" + + def __init__(self, host, user, password, domain, verbose): + import winrm + + self.session = winrm.Session( + f"http://{host}:5985/wsman", auth=(user, password), transport="ntlm" + ) + self.domain = domain + self.verbose = verbose + + def ps(self, script, check=True): + r = self.session.run_ps(script) + out = r.std_out.decode(errors="replace").strip() + err = r.std_err.decode(errors="replace").strip() + if check and r.status_code != 0: + print(f" WinRM command failed ({r.status_code}): {out} {err}", + file=sys.stderr) + return r.status_code, out, err + + def wake(self): + """Wake the console's display. + + It blanks after a few minutes idle, and a blanked console screenshots + as a solid black frame while every UI step still reports success. The + run looks fine and the evidence is worthless. + """ + subprocess.run( + ["virsh", "--connect", "qemu:///system", "send-key", self.domain, + "KEY_LEFTSHIFT"], + check=True, capture_output=True, + ) + time.sleep(2) + + def shot(self, name): + SHOT_DIR.mkdir(parents=True, exist_ok=True) + path = SHOT_DIR / f"{name}.png" + # virsh, not an in-guest capture: WinRM is in session 0, which has no + # desktop to photograph. + subprocess.run( + ["virsh", "--connect", "qemu:///system", "screenshot", self.domain, str(path)], + check=True, capture_output=True, + ) + print(f" shot {path.relative_to(TEST_DIR.parent)}") + return path + + def ui(self, body, timeout=120): + """Run PowerShell on the console session and return what it printed. + + A scheduled task has no stdout to capture, so the script writes its + output to a file and a sentinel beside it, and this polls for the + sentinel. + """ + script = ( + UIA_PRELUDE + + "\n& {\n try {\n" + body + + '\n } catch { "ERROR: $($_.Exception.Message)" }\n}' + + rf' | Out-File -Encoding UTF8 "{REMOTE_DIR}\_uia.out"' + "\n" + + rf'Set-Content -Path "{REMOTE_DIR}\_uia.done" -Value ok' + "\n" + ) + self.ps(rf'Remove-Item "{REMOTE_DIR}\_uia.out","{REMOTE_DIR}\_uia.done",' + rf'"{REMOTE_DIR}\_uia.b64" -EA SilentlyContinue', check=False) + + b64 = base64.b64encode(script.encode("utf-8")).decode() + for i in range(0, len(b64), UPLOAD_CHUNK): + rc, out, err = self.ps( + f'Add-Content -Path "{REMOTE_DIR}\\_uia.b64" ' + f'-Value "{b64[i:i + UPLOAD_CHUNK]}" -NoNewline') + if rc != 0: + sys.exit(f"uploading the UI script failed: {out} {err}") + rc, out, err = self.ps( + f'[IO.File]::WriteAllBytes("{REMOTE_DIR}\\_uia.ps1", ' + f'[Convert]::FromBase64String((Get-Content "{REMOTE_DIR}\\_uia.b64" -Raw).Trim()))') + if rc != 0: + sys.exit(f"writing the UI script failed: {out} {err}") + + # An instance still stuck from an earlier step would make schtasks + # decline to start this one, since the default is IgnoreNew. + self.ps(r'Get-CimInstance Win32_Process -Filter "Name=' + "'powershell.exe'" + r'" | ' + r'Where-Object { $_.CommandLine -like "*_uia.ps1*" } | ' + r'ForEach-Object { Stop-Process -Id $_.ProcessId -Force }', check=False) + self.ps(r'schtasks /delete /tn "OdbcUia" /f 2>$null | Out-Null', check=False) + rc, out, err = self.ps( + r'schtasks /create /tn "OdbcUia" /tr ' + rf'"powershell -ExecutionPolicy Bypass -WindowStyle Hidden -File {REMOTE_DIR}\_uia.ps1" ' + r'/sc once /st 23:59 /it /f /rl highest') + if rc != 0: + sys.exit(f"creating the UI task failed: {out} {err}") + self.ps(r'schtasks /run /tn "OdbcUia"') + + deadline = time.time() + timeout + while time.time() < deadline: + _, out, _ = self.ps(rf'Test-Path "{REMOTE_DIR}\_uia.done"', check=False) + if out.strip() == "True": + break + time.sleep(1) + _, result, _ = self.ps( + rf'Get-Content "{REMOTE_DIR}\_uia.out" -Raw -EA SilentlyContinue', check=False) + if self.verbose and result: + for line in result.splitlines(): + print(f" | {line}") + if result.startswith("ERROR:"): + raise RuntimeError(result.strip()) + return result + + def driver_row(self, name): + """Which row `name` occupies in the Create New Data Source list. + + The list is a plain `SysListView32` with no UI Automation provider, so + its items cannot be found by name and the row has to be clicked by + position. odbcad32 lists the registered drivers in ordinal order, which + puts every capitalised name ahead of a lower-case one: `SQL Server` + sorts before `stackable_odbc_trino`. Pressing Finish without selecting + first configures whichever driver happens to be first, which on a VM + with the stock SQL Server driver present opens *its* wizard and looks + like the driver's own dialog failing to appear. + """ + _, out, _ = self.ps(r''' +(Get-ItemProperty "HKLM:\SOFTWARE\ODBC\ODBCINST.INI\ODBC Drivers").PSObject.Properties | + Where-Object { $_.Name -notlike "PS*" } | ForEach-Object { $_.Name }''') + drivers = sorted(line.strip() for line in out.splitlines() if line.strip()) + if name not in drivers: + sys.exit(f"{name} is not registered on the VM. Run windows_test.py first.") + return drivers.index(name), drivers + + def dsn_row(self, name): + """Which row `name` occupies in the Administrator's User DSN list.""" + _, out, _ = self.ps(r''' +(Get-ItemProperty "HKCU:\Software\ODBC\ODBC.INI\ODBC Data Sources" -EA SilentlyContinue).PSObject.Properties | + Where-Object { $_.Name -notlike "PS*" } | ForEach-Object { $_.Name }''') + names = sorted(line.strip() for line in out.splitlines() if line.strip()) + if name not in names: + return None, names + return names.index(name), names + + def dsn_values(self, name): + """The data source's stored keywords, as the registry holds them.""" + _, out, _ = self.ps(rf''' +$k = "HKCU:\Software\ODBC\ODBC.INI\{name}" +if (-not (Test-Path $k)) {{ "{{}}" }} else {{ + $h = @{{}} + (Get-ItemProperty $k).PSObject.Properties | + Where-Object {{ $_.Name -notlike "PS*" }} | + ForEach-Object {{ $h[$_.Name] = "$($_.Value)" }} + $h | ConvertTo-Json -Compress +}}''', check=False) + try: + return json.loads(out or "{}") + except json.JSONDecodeError: + return {} + + def dialog_processes(self): + _, out, _ = self.ps(r'''(Get-CimInstance Win32_Process -Filter "Name='powershell.exe'" | + Where-Object { $_.CommandLine -like "*configure-dsn*" } | Measure-Object).Count''', check=False) + return int(out or 0) + + +class Report: + def __init__(self): + self.failures = [] + + def check(self, ok, label, detail=""): + print(f" {'PASS' if ok else 'FAIL'} {label}" + + (f" [{detail}]" if detail and not ok else "")) + if not ok: + self.failures.append(f"{label}: {detail}") + return ok + + +def main(): + args = parse_args() + SHOT_DIR.mkdir(parents=True, exist_ok=True) + vm = Vm(args.host, args.user, args.password, args.domain, args.verbose) + r = Report() + + print("=== Preparing ===") + reset(vm) + + print("=== The driver lists with a version and a company ===") + driver_identity(vm, r) + + print("=== Add... ===") + add_data_source(vm, r, args.trino_host) + + print("=== Configure... ===") + configure_data_source(vm, r) + + print("=== Remove ===") + remove_data_source(vm, r) + + if not args.keep_open: + vm.ps(r'Get-Process odbcad32 -EA SilentlyContinue | Stop-Process -Force', check=False) + cleanup(vm) + + print("") + print("=== Dialog summary ===") + if r.failures: + for f in r.failures: + print(f" FAIL {f}") + print(f"{len(r.failures)} check(s) failed; see {SHOT_DIR}") + sys.exit(1) + print(f"all checks passed; screenshots in {SHOT_DIR}") + + +def reset(vm): + """Start from no data source and no dialog, so a rerun is not a no-op.""" + vm.wake() + # Edge's first-run page opens full screen over everything and grabs the + # foreground. It covered a screenshot completely, and the window it + # obscured was the one the step was waiting for. + vm.ps(r'Get-Process msedge -EA SilentlyContinue | Stop-Process -Force', check=False) + vm.ps(r''' +$k = "HKLM:\SOFTWARE\Policies\Microsoft\Edge" +if (-not (Test-Path $k)) { New-Item -Path $k -Force | Out-Null } +New-ItemProperty -Path $k -Name "HideFirstRunExperience" -Value 1 -PropertyType DWord -Force | Out-Null +New-ItemProperty -Path $k -Name "PreventFirstRunPage" -Value 1 -PropertyType DWord -Force | Out-Null +''', check=False) + vm.ps(r'''Get-CimInstance Win32_Process -Filter "Name='powershell.exe'" | + Where-Object { $_.CommandLine -like "*configure-dsn*" } | + ForEach-Object { Stop-Process -Id $_.ProcessId -Force }''', check=False) + vm.ps(r'Get-Process odbcad32 -EA SilentlyContinue | Stop-Process -Force', check=False) + vm.ps(rf''' +$idx = "HKCU:\Software\ODBC\ODBC.INI\ODBC Data Sources" +Remove-ItemProperty -Path $idx -Name "{DSN_NAME}" -Force -EA SilentlyContinue +Remove-Item -Path "HKCU:\Software\ODBC\ODBC.INI\{DSN_NAME}" -Recurse -Force -EA SilentlyContinue +''', check=False) + rc, out, _ = vm.ps(rf'Test-Path "{REMOTE_DIR}\configure-dsn.ps1"', check=False) + if out.strip() != "True": + sys.exit(f"{REMOTE_DIR}\\configure-dsn.ps1 is missing. Run windows_test.py first: " + "the driver's ConfigDSN looks for it beside the DLL.") + time.sleep(1) + + +def driver_identity(vm, r): + """The ODBC Administrator's Version and Company columns. + + Both are read from the DLL's `VERSIONINFO` resource, which `build.rs` + embeds. A driver without one lists as `Not marked` twice. + """ + _, out, _ = vm.ps(rf''' +$d = Get-Item "{REMOTE_DIR}\{DRIVER_NAME}.dll" +@{{ FileVersion = "$($d.VersionInfo.FileVersion)" + CompanyName = "$($d.VersionInfo.CompanyName)" }} | ConvertTo-Json -Compress''') + try: + info = json.loads(out or "{}") + except json.JSONDecodeError: + info = {} + r.check(bool(info.get("FileVersion")), "the DLL carries a file version", + f"got {info.get('FileVersion')!r}") + r.check(info.get("CompanyName") == "Stackable GmbH", "the DLL names its company", + f"got {info.get('CompanyName')!r}") + + +def add_data_source(vm, r, trino_host): + row, drivers = vm.driver_row(DRIVER_NAME) + print(f" {DRIVER_NAME} is row {row} of {len(drivers)}: {drivers}") + + vm.ui(r''' +Start-Process "C:\Windows\System32\odbcad32.exe" +$w = Find-Win "ODBC Data Source Administrator*" +Start-Sleep -Seconds 1 +Invoke-Ctl (Find-Ctl $w "Add...") +Start-Sleep -Seconds 2 +"Add... clicked" +''') + + # Select the driver's row, then screenshot: the highlight in + # 01_create_new_data_source.png is the evidence the right one was picked. + vm.ui(rf''' +$list = Find-CtlAnywhere "List1" +$r = $list.Current.BoundingRectangle +# Below the column header, then one row height per row. Measured against the +# dialog at its fixed size, which it has no way to change. +Click-Point ([int]($r.X + 60)) ([int]($r.Y + 22 + {row} * 17 + 8)) +Start-Sleep -Milliseconds 600 +"row {row} clicked" +''') + vm.shot("01_create_new_data_source") + + # Finish is what calls ConfigDSN(hwnd, ODBC_ADD_DSN, driver, "") with an + # empty attribute list, which is the case core's hook ordering exists for: + # there is no DSN keyword until the dialog has produced one. + # + # Finding the driver's own dialog by title is also what catches a mis-aimed + # row click: another driver's wizard would open instead, and this reports + # that rather than timing out somewhere later. + vm.ui(r''' +Invoke-Ctl (Find-CtlAnywhere "Finish") +Start-Sleep -Seconds 6 +$d = Find-Win "Stackable Trino ODBC*" 20 +"the driver's dialog opened: " + $d.Current.Name +''') + vm.shot("02_driver_dialog") + + out = vm.ui(r''' +$d = Find-Win "Stackable Trino ODBC*" +"name_readonly=" + (Test-CtlReadOnly (Get-FieldCtl $d "Data source name")) +$scope = $false +foreach ($c in $d.FindAll($TS::Descendants, $TRUE_COND)) { + if ($c.Current.Name -eq "System") { $scope = $true } +} +"scope_shown=" + $scope +''') + r.check("name_readonly=False" in out, + "Add... leaves the data source name editable", out.strip()) + r.check("scope_shown=False" in out, + "the User/System radios are hidden, since core performs the write", + out.strip()) + + print(" filling the dialog in") + vm.ui(rf''' +$d = Find-Win "Stackable Trino ODBC*" +Set-Field $d "Data source name" "{DSN_NAME}" +Set-Field $d "Host" "{trino_host}" +Set-Field $d "Catalog" "tpcds" +Click-Tab $d 1 +Start-Sleep -Milliseconds 800 +Set-Field $d "User" "admin" +Set-Field $d "Password" "admin" +# The Save box beside a secret, off by default, so the password is written. +$all = @($d.FindAll($TS::Descendants, $TRUE_COND)) +for ($i = 0; $i -lt $all.Count; $i++) {{ + if ($all[$i].Current.Name -eq "Password") {{ Click-Ctl $all[$i + 1] }} +}} +Click-Tab $d 2 +Start-Sleep -Milliseconds 800 +Set-Field $d "CA certificate" "{REMOTE_DIR}\ca.crt" +Click-Tab $d 0 +Start-Sleep -Milliseconds 600 +"filled" +''') + vm.shot("03_dialog_filled") + + print(" testing the connection") + vm.ui(r''' +$d = Find-Win "Stackable Trino ODBC*" +Invoke-Ctl (Find-Ctl $d "Test connection") +Start-Sleep -Seconds 12 +"tested" +''', timeout=180) + vm.shot("04_test_connection") + out = vm.ui(r''' +$v = Find-Win "Connection succeeded*" 6 +foreach ($c in $v.FindAll($TS::Descendants, $TRUE_COND)) { " " + $c.Current.Name } +Invoke-Ctl (Find-Ctl $v "OK") +Start-Sleep -Seconds 1 +"dismissed" +''') + r.check("dismissed" in out, "Test connection reports success", out.strip()) + for field in ("Host:", "Version:", "User:", "Catalog:"): + r.check(field in out, f"the result names {field.rstrip(':')}", out.strip()) + + print(" writing the data source") + # Asserting the dialog closed is what keeps a failure here local: a + # lingering dialog is silently re-found by every later step, so Configure... + # reported on the Add dialog and the screenshots for both were identical. + vm.ui(r''' +$d = Find-Win "Stackable Trino ODBC*" +Invoke-Ctl (Find-Ctl $d "OK") +Start-Sleep -Seconds 5 +$still = $true +try { $null = Find-Win "Stackable Trino ODBC*" 4 } catch { $still = $false } +if ($still) { throw "the dialog is still open after OK" } +"written" +''') + values = vm.dsn_values(DSN_NAME) + r.check(values.get("host") == trino_host, "the data source was written", + f"got {values!r}") + r.check(values.get("catalog") == "tpcds", "the keywords survived the dialog", + f"catalog={values.get('catalog')!r}") + # Written only because the Save box was ticked above. + r.check(values.get("password") == "admin", "a saved secret is written", + f"password={values.get('password')!r}") + # SQLWriteDSNToIni adds this; the driver never writes it itself. + r.check("Driver" in values, "core wrote the section through SQLWriteDSNToIni", + f"keys={sorted(values)}") + + vm.ui(r''' +$w = Find-Win "ODBC Data Source Administrator*" +Click-Ctl $w +Start-Sleep -Seconds 1 +"raised" +''') + vm.shot("05_administrator_lists_it") + + +def configure_data_source(vm, r): + row, names = vm.dsn_row(DSN_NAME) + if row is None: + sys.exit(f"{DSN_NAME} was never written; nothing to configure (have {names})") + vm.ui(rf''' +$w = Find-Win "ODBC Data Source Administrator*" +Click-ListRow $w {row} +Invoke-Ctl (Find-Ctl $w "Configure...") +Start-Sleep -Seconds 6 +"configure clicked" +''') + vm.shot("06_configure_prefilled") + + out = vm.ui(r''' +$d = Find-Win "Stackable Trino ODBC*" +$name = Get-FieldCtl $d "Data source name" +"name_value=" + (Get-CtlText $name) +"name_readonly=" + (Test-CtlReadOnly $name) +Click-Tab $d 1 +Start-Sleep -Milliseconds 800 +$all = @($d.FindAll($TS::Descendants, $TRUE_COND)) +for ($i = 0; $i -lt $all.Count; $i++) { + if ($all[$i].Current.Name -eq "User") { "user=" + (Get-CtlText (Get-FieldCtl $d "User")) } + if ($all[$i].Current.Name -eq "Password") { "password=" + (Get-CtlText (Get-FieldCtl $d "Password")) } +} +''') + r.check(f"name_value={DSN_NAME}" in out, + "Configure... prefills from the stored keywords", out.strip()) + # The spec: "if a data source name was passed to it, ConfigDSN displays + # that name but does not allow the user to change it." Core enforces it on + # the map coming back, so an editable box would only fail the call. + r.check("name_readonly=True" in out, + "the data source name is read-only on Configure...", out.strip()) + r.check("user=admin" in out, "core merged the stored section in", out.strip()) + r.check("password=admin" in out, "a stored secret is prefilled", out.strip()) + + print(" cancelling") + before = vm.dsn_values(DSN_NAME) + vm.ui(r''' +$d = Find-Win "Stackable Trino ODBC*" +Click-Tab $d 0 +Start-Sleep -Milliseconds 600 +Set-Field $d "Catalog" "CANCELLED_MUST_NOT_PERSIST" +Start-Sleep -Milliseconds 300 +Invoke-Ctl (Find-Ctl $d "Cancel") +Start-Sleep -Seconds 5 +$still = $true +try { $null = Find-Win "Stackable Trino ODBC*" 4 } catch { $still = $false } +if ($still) { throw "the dialog is still open after Cancel" } +"cancelled" +''') + after = vm.dsn_values(DSN_NAME) + r.check(after == before, "a cancelled dialog writes nothing", + f"{before!r} -> {after!r}") + # Ok(None) from the hook, which core turns into FALSE with no installer + # error posted, so the Administrator shows nothing. + _, boxes, _ = vm.ps(r'''(Get-Process odbcad32 -EA SilentlyContinue | + Where-Object { $_.MainWindowTitle -like "*Error*" } | Measure-Object).Count''', check=False) + r.check(boxes.strip() in ("0", ""), "cancelling posts no installer error", boxes) + + +def remove_data_source(vm, r): + """Remove reaches the hook, which must not open a dialog for it.""" + row, names = vm.dsn_row(DSN_NAME) + if row is None: + sys.exit(f"{DSN_NAME} is not present; nothing to remove (have {names})") + vm.ui(rf''' +$w = Find-Win "ODBC Data Source Administrator*" +Click-ListRow $w {row} +Invoke-Ctl (Find-Ctl $w "Remove") +Start-Sleep -Seconds 3 +"remove clicked" +''') + # The Administrator asks for confirmation itself, which is the reason the + # driver adds none. Found by the button it owns rather than by title: the + # window is not named what its caption suggests, and a title guess failed + # here while the dialog was plainly on screen. + out = vm.ui(r''' +$yes = Find-CtlAnywhere "Yes" 10 +"confirm_shown=true" +Invoke-Ctl $yes +Start-Sleep -Seconds 4 +"confirmed" +''') + r.check("confirm_shown=true" in out, + "the Administrator confirms the removal itself", out.strip()) + r.check(vm.dialog_processes() == 0, + "Remove opens no driver dialog", "a dialog process was spawned") + r.check(vm.dsn_values(DSN_NAME) == {}, "the data source is gone", + f"{vm.dsn_values(DSN_NAME)!r}") + + +def cleanup(vm): + vm.ps(rf'Remove-Item "{REMOTE_DIR}\_uia.*" -EA SilentlyContinue', check=False) + vm.ps(r'schtasks /delete /tn "OdbcUia" /f 2>$null | Out-Null', check=False) + + +def parse_args(): + p = argparse.ArgumentParser( + description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + p.add_argument("--host", help="VM IP; discovered from libvirt when omitted") + p.add_argument("--user", default="Administrator") + p.add_argument("--password", default="Asdf1234") + p.add_argument("--domain", default="stackable-odbc-test", + help="libvirt domain, for virsh screenshot") + p.add_argument("--vm-network", default="stackable-odbc-test-hostnet") + p.add_argument("--trino-host", default=TRINO_VM_HOSTNAME, + help="the Host value the dialog is filled in with. The default " + "is the name the VM's hosts file maps to the gateway, so " + "TLS sends SNI and the coordinator's certificate verifies") + p.add_argument("--keep-open", action="store_true", + help="leave the Administrator running afterwards") + p.add_argument("-v", "--verbose", action="store_true", + help="echo what each UI step printed") + args = p.parse_args() + if not args.host: + args.host = discover_vm_ip(args.vm_network) + return args + + +def discover_vm_ip(network): + out = subprocess.run( + ["virsh", "--connect", "qemu:///system", "net-dhcp-leases", network], + capture_output=True, text=True, check=True).stdout + for line in out.splitlines(): + for field in line.split(): + if "/" in field and field.count(".") == 3: + ip = field.split("/")[0] + print(f"Found VM at {ip}") + return ip + sys.exit(f"no DHCP lease on {network}; is the VM running?") + + +if __name__ == "__main__": + main() diff --git a/integration-tests/windows/openssl_legacy.cnf b/integration-tests/windows/openssl_legacy.cnf new file mode 100644 index 0000000..8eefe61 --- /dev/null +++ b/integration-tests/windows/openssl_legacy.cnf @@ -0,0 +1,21 @@ +# OpenSSL configuration that enables the legacy provider. +# Required for WinRM NTLM authentication, which uses MD4 +# (disabled by default in modern OpenSSL). +# +# Usage: OPENSSL_CONF=windows/openssl_legacy.cnf python3 ... +# The test/windows_test.py script sets this automatically. + +openssl_conf = openssl_init + +[openssl_init] +providers = provider_sect + +[provider_sect] +default = default_sect +legacy = legacy_sect + +[default_sect] +activate = 1 + +[legacy_sect] +activate = 1 diff --git a/integration-tests/windows/vm/files/windows-install-config/Autounattend.xml b/integration-tests/windows/vm/files/windows-install-config/Autounattend.xml new file mode 100644 index 0000000..b9d2dd4 --- /dev/null +++ b/integration-tests/windows/vm/files/windows-install-config/Autounattend.xml @@ -0,0 +1,138 @@ + + + + + + + + + E:\ + + + + + + + + + Primary + true + 1 + + + + + 1 + 1 + NTFS + true + C + + + 0 + true + + + + + + + /IMAGE/NAME + Windows Server 2022 SERVERDATACENTER + + + true + + + + + true + + + + + en-US + + en-US + sv-SE + + + + + + + + 1 + certutil -addstore TrustedPublisher A:\redhat-drivers.crt + + + + 2 + reg add HKLM\System\CurrentControlSet\Control\Network\NewNetworkWindowOff /f + + + + + 3 + reg add HKLM\System\CurrentControlSet\Control\TimeZoneInformation /v RealTimeIsUniversal /t REG_DWORD /d 1 /f + + + + + sble-addc + + + + + + + Asdf1234 + true</PlainText> + </AdministratorPassword> + </UserAccounts> + <AutoLogon> + <Enabled>true</Enabled> + <Username>Administrator</Username> + <Password> + <Value>Asdf1234</Value> + <PlainText>true</PlainText> + </Password> + </AutoLogon> + <FirstLogonCommands> + <!-- Install QEMU guest tools --> + <!-- NOTE: MUST happen in OOBE stage since Ansible assumes that having guest tools (more specifically, the qemu guest agent) available means the install is ready to proceed --> + <SynchronousCommand wcm:action="add"> + <Order>1</Order> + <!-- QEMU guest tools are on virtio-win drive --> + <CommandLine>F:\virtio-win-guest-tools.exe /passive</CommandLine> + <RequiresUserInput>true</RequiresUserInput> + </SynchronousCommand> + <!-- Download and install SPICE guest tools (clipboard sync, resolution adjustment) --> + <SynchronousCommand wcm:action="add"> + <Order>2</Order> + <CommandLine>powershell -Command "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -Uri 'https://www.spice-space.org/download/windows/spice-guest-tools/spice-guest-tools-0.141/spice-guest-tools-0.141.exe' -OutFile C:\spice-guest-tools.exe; Start-Process C:\spice-guest-tools.exe -ArgumentList '/S' -Wait; Remove-Item C:\spice-guest-tools.exe"</CommandLine> + <RequiresUserInput>true</RequiresUserInput> + </SynchronousCommand> + <!-- Install Python --> + <SynchronousCommand wcm:action="add"> + <Order>3</Order> + <CommandLine>powershell -Command "[Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12; Invoke-WebRequest -Uri 'https://www.python.org/ftp/python/3.12.9/python-3.12.9-amd64.exe' -OutFile C:\python-installer.exe; Start-Process C:\python-installer.exe -ArgumentList '/quiet InstallAllUsers=1 PrependPath=1' -Wait; Remove-Item C:\python-installer.exe"</CommandLine> + <RequiresUserInput>true</RequiresUserInput> + </SynchronousCommand> + <!-- Install pyodbc --> + <SynchronousCommand wcm:action="add"> + <Order>4</Order> + <CommandLine>powershell -Command "$env:Path = [System.Environment]::GetEnvironmentVariable('Path', 'Machine'); python -m pip install pyodbc"</CommandLine> + <RequiresUserInput>true</RequiresUserInput> + </SynchronousCommand> + <!-- Signal that all setup is complete (used by start.yaml to wait) --> + <SynchronousCommand wcm:action="add"> + <Order>5</Order> + <CommandLine>powershell -Command "Set-Content -Path C:\setup_complete.txt -Value (Get-Date -Format o)"</CommandLine> + <RequiresUserInput>true</RequiresUserInput> + </SynchronousCommand> + </FirstLogonCommands> + </component> + </settings> + <cpi:offlineImage xmlns:cpi="urn:schemas-microsoft-com:cpi" cpi:source="wim:c:/users/administrator/desktop/install.wim#Windows Server 2022 SERVERDATACENTER"/> +</unattend> diff --git a/integration-tests/windows/vm/files/windows-install-config/redhat-drivers.crt b/integration-tests/windows/vm/files/windows-install-config/redhat-drivers.crt new file mode 100644 index 0000000..14c1faf --- /dev/null +++ b/integration-tests/windows/vm/files/windows-install-config/redhat-drivers.crt @@ -0,0 +1,29 @@ +-----BEGIN CERTIFICATE----- +MIIFBjCCA+6gAwIBAgIQVsbSZ63gf3LutGA7v4TOpTANBgkqhkiG9w0BAQUFADCB +tDELMAkGA1UEBhMCVVMxFzAVBgNVBAoTDlZlcmlTaWduLCBJbmMuMR8wHQYDVQQL +ExZWZXJpU2lnbiBUcnVzdCBOZXR3b3JrMTswOQYDVQQLEzJUZXJtcyBvZiB1c2Ug +YXQgaHR0cHM6Ly93d3cudmVyaXNpZ24uY29tL3JwYSAoYykxMDEuMCwGA1UEAxMl +VmVyaVNpZ24gQ2xhc3MgMyBDb2RlIFNpZ25pbmcgMjAxMCBDQTAeFw0xNjAzMTgw +MDAwMDBaFw0xODEyMjkyMzU5NTlaMGgxCzAJBgNVBAYTAlVTMRcwFQYDVQQIEw5O +b3J0aCBDYXJvbGluYTEQMA4GA1UEBxMHUmFsZWlnaDEWMBQGA1UEChQNUmVkIEhh +dCwgSW5jLjEWMBQGA1UEAxQNUmVkIEhhdCwgSW5jLjCCASIwDQYJKoZIhvcNAQEB +BQADggEPADCCAQoCggEBAMA3SYpIcNIEzqqy1PNimjt3bVY1KuIuvDABkx8hKUG6 +rl9WDZ7ibcW6f3cKgr1bKOAeOsMSDu6i/FzB7Csd9u/a/YkASAIIw48q9iD4K6lb +Kvd+26eJCUVyLHcWlzVkqIEFcvCrvaqaU/YlX/antLWyHGbtOtSdN3FfY5pvvTbW +xf8PJBWGO3nV9CVL1DMK3wSn3bRNbkTLttdIUYdgiX+q8QjbM/VyGz7nA9UvGO0n +FWTZRdoiKWI7HA0Wm7TjW3GSxwDgoFb2BZYDDNSlfzQpZmvnKth/fQzNDwumhDw7 +tVicu/Y8E7BLhGwxFEaP0xZtENTpn+1f0TxPxpzL2zMCAwEAAaOCAV0wggFZMAkG +A1UdEwQCMAAwDgYDVR0PAQH/BAQDAgeAMCsGA1UdHwQkMCIwIKAeoByGGmh0dHA6 +Ly9zZi5zeW1jYi5jb20vc2YuY3JsMGEGA1UdIARaMFgwVgYGZ4EMAQQBMEwwIwYI +KwYBBQUHAgEWF2h0dHBzOi8vZC5zeW1jYi5jb20vY3BzMCUGCCsGAQUFBwICMBkM +F2h0dHBzOi8vZC5zeW1jYi5jb20vcnBhMBMGA1UdJQQMMAoGCCsGAQUFBwMDMFcG +CCsGAQUFBwEBBEswSTAfBggrBgEFBQcwAYYTaHR0cDovL3NmLnN5bWNkLmNvbTAm +BggrBgEFBQcwAoYaaHR0cDovL3NmLnN5bWNiLmNvbS9zZi5jcnQwHwYDVR0jBBgw +FoAUz5mp6nsm9EvJjo/X8AUm7+PSp50wHQYDVR0OBBYEFL/39F5yNDVDib3B3Uk3 +I8XJSrxaMA0GCSqGSIb3DQEBBQUAA4IBAQDWtaW0Dar82t1AdSalPEXshygnvh87 +Rce6PnM2/6j/ijo2DqwdlJBNjIOU4kxTFp8jEq8oM5Td48p03eCNsE23xrZl5qim +xguIfHqeiBaLeQmxZavTHPNM667lQWPAfTGXHJb3RTT4siowcmGhxwJ3NGP0gNKC +PHW09x3CdMNCIBfYw07cc6h9+Vm2Ysm9MhqnVhvROj+AahuhvfT9K0MJd3IcEpjX +Z7aMX78Vt9/vrAIUR8EJ54YGgQsF/G9Adzs6fsfEw5Nrk8R0pueRMHRTMSroTe0V +Ae2nvuUU6rVI30q8+UjQCxu/ji1/JnitNkUyOPyC46zL+kfHYSnld8U1 +-----END CERTIFICATE----- diff --git a/integration-tests/windows/vm/inventory.ini b/integration-tests/windows/vm/inventory.ini new file mode 100644 index 0000000..5744fad --- /dev/null +++ b/integration-tests/windows/vm/inventory.ini @@ -0,0 +1,2 @@ +[windows] +sble-addc ansible_connection=community.libvirt.libvirt_qemu ansible_libvirt_uri=qemu:///system ansible_host=stackable-odbc-test ansible_shell_type=powershell diff --git a/integration-tests/windows/vm/shell.nix b/integration-tests/windows/vm/shell.nix new file mode 100644 index 0000000..d2e68a8 --- /dev/null +++ b/integration-tests/windows/vm/shell.nix @@ -0,0 +1,22 @@ +{ pkgs ? import <nixpkgs> { } }: + +let + python = pkgs.python3; + extraAnsibleDeps = pypkgs: [ + pypkgs.libvirt + pypkgs.lxml + ]; +in +pkgs.mkShell rec { + buildInputs = [ ansible ]; + + LC_ALL = "C.UTF-8"; + + ansible = python.pkgs.toPythonApplication + (python.pkgs.ansible-core.overridePythonAttrs (old: { + dependencies = (old.dependencies or []) ++ extraAnsibleDeps python.pkgs; + })); + + ansiblePython = python.withPackages extraAnsibleDeps; + ANSIBLE_PYTHON_INTERPRETER = ansiblePython + "/bin/python"; +} diff --git a/integration-tests/windows/vm/start.yaml b/integration-tests/windows/vm/start.yaml new file mode 100644 index 0000000..3eaf317 --- /dev/null +++ b/integration-tests/windows/vm/start.yaml @@ -0,0 +1,148 @@ +- name: Create VM and install Windows + hosts: localhost + connection: local + gather_facts: false + vars: + libvirt_uri: qemu:///system + install_iso_windows: "{{ lookup('env', 'WINDOWS_ISO') }}" + + vm_name: stackable-odbc-test + vm_memory_mib: 4096 + vm_vcpus: 8 + vm_disk_name: stackable-odbc-test.qcow2 + vm_disk_pool: default + vm_disk_size_gib: 30 + vm_disk_format: qcow2 + + vm_network_hostnet_name: stackable-odbc-test-hostnet + vm_network_hostnet_subnet: 192.168.197.0/24 + vm_network_internet_name: stackable-odbc-test-internet + vm_network_internet_subnet: 192.168.196.0/24 + + install_iso_virtio_win_url: https://fedorapeople.org/groups/virt/virtio-win/direct-downloads/archive-virtio/virtio-win-0.1.248-1/virtio-win-0.1.248.iso + install_iso_virtio_win_checksum: sha256:d5b5739cf297f0538d263e30678d5a09bba470a7c6bcbd8dff74e44153f16549 + install_iso_virtio_win: "{{ lookup('first_found', 'target') }}/virtio-win.iso" + + tasks: + - name: Create target folder + ansible.builtin.file: + path: target + state: directory + + - name: Find Windows ISO + ansible.builtin.stat: + path: "{{ install_iso_windows }}" + get_checksum: false + register: install_iso_windows_stat + + - name: Complain about missing Windows ISO + ansible.builtin.fail: + msg: >- + Windows ISO not found. Set the WINDOWS_ISO environment variable to + the path of your Windows Server 2022 evaluation ISO, e.g.: + export WINDOWS_ISO=~/Downloads/SERVER_EVAL_x64FRE_en-us.iso + Download from https://www.microsoft.com/en-us/evalcenter/evaluate-windows-server-2022 + when: install_iso_windows == '' or not install_iso_windows_stat.stat.exists + + - name: Download virtio-win drivers + ansible.builtin.get_url: + dest: "{{ install_iso_virtio_win }}" + url: "{{ install_iso_virtio_win_url }}" + checksum: "{{ install_iso_virtio_win_checksum }}" + + - name: Create VM Network + community.libvirt.virt_net: + name: "{{ vm_network_hostnet_name }}" + command: define + xml: "{{ lookup('template', 'templates/windows-vm-network.xml.j2') }}" + uri: "{{ libvirt_uri }}" + + - name: Start VM Network + community.libvirt.virt_net: + name: "{{ vm_network_hostnet_name }}" + state: active + uri: "{{ libvirt_uri }}" + + - name: Create VM Network (Internet) + community.libvirt.virt_net: + name: "{{ vm_network_internet_name }}" + command: define + xml: "{{ lookup('template', 'templates/windows-vm-network-internet.xml.j2') }}" + uri: "{{ libvirt_uri }}" + + - name: Start VM Network (Internet) + community.libvirt.virt_net: + name: "{{ vm_network_internet_name }}" + state: active + uri: "{{ libvirt_uri }}" + + - name: Create VM + community.libvirt.virt: + command: define + xml: "{{ lookup('template', 'templates/windows-vm.xml.j2') }}" + mutate_flags: + - ADD_UUID + - ADD_MAC_ADDRESSES + uri: "{{ libvirt_uri }}" + + - name: Check if VM Volume already exists + ansible.builtin.command: + cmd: virsh --connect "{{ libvirt_uri }}" vol-info --pool "{{ vm_disk_pool }}" --vol "{{ vm_disk_name }}" + register: result_check_vm_disk + failed_when: false + changed_when: result_check_vm_disk.rc != 0 + + - name: Create VM Volume + when: result_check_vm_disk is changed + ansible.builtin.command: + cmd: virsh --connect "{{ libvirt_uri }}" vol-create --pool "{{ vm_disk_pool }}" --file /dev/stdin + stdin: "{{ lookup('template', 'templates/windows-vm-volume.xml.j2') }}" + + - name: Start VM + community.libvirt.virt: + name: "{{ vm_name }}" + state: running + uri: "{{ libvirt_uri }}" + +- name: Wait for Windows to finish installing + hosts: localhost + connection: local + gather_facts: false + vars: + libvirt_uri: qemu:///system + vm_name: stackable-odbc-test + vm_network_hostnet_name: stackable-odbc-test-hostnet + tasks: + - name: Wait for QEMU guest agent + ansible.builtin.command: + cmd: virsh --connect "{{ libvirt_uri }}" qemu-agent-command "{{ vm_name }}" '{"execute":"guest-ping"}' + register: guest_ping + until: guest_ping.rc == 0 + retries: 120 + delay: 15 + changed_when: false + + - name: Get VM IP from DHCP leases + ansible.builtin.command: + cmd: virsh --connect "{{ libvirt_uri }}" net-dhcp-leases "{{ vm_network_hostnet_name }}" + register: dhcp_leases + changed_when: false + + - name: Extract VM IP + ansible.builtin.set_fact: + vm_ip: "{{ dhcp_leases.stdout | regex_search('ipv4\\s+(\\d+\\.\\d+\\.\\d+\\.\\d+)/', '\\1') | first }}" + + - name: Wait for WinRM + ansible.builtin.wait_for: + host: "{{ vm_ip }}" + port: 5985 + timeout: 1800 + delay: 10 + + - name: VM is ready + ansible.builtin.debug: + msg: >- + Windows VM ready at {{ vm_ip }}. + Note: FirstLogonCommands (Python, pyodbc) may still be running. + The test script waits for setup to complete automatically. + Run tests with: uv run --with pywinrm python3 integration-tests/windows/windows_test.py diff --git a/integration-tests/windows/vm/templates/windows-vm-network-internet.xml.j2 b/integration-tests/windows/vm/templates/windows-vm-network-internet.xml.j2 new file mode 100644 index 0000000..fa7abfe --- /dev/null +++ b/integration-tests/windows/vm/templates/windows-vm-network-internet.xml.j2 @@ -0,0 +1,14 @@ +<network connections="1"> + <name>{{ vm_network_internet_name }}</name> + <forward mode="nat"/> + <bridge stp='on' delay='0'/> + <ip + address="{{ vm_network_internet_subnet | ansible.utils.ipaddr('next_usable') }}" + netmask="{{ vm_network_internet_subnet | ansible.utils.ipaddr('netmask') }}"> + <dhcp> + <range + start="{{ vm_network_internet_subnet | ansible.utils.next_nth_usable(2) }}" + end="{{ vm_network_internet_subnet | ansible.utils.ipaddr('last_usable') }}"/> + </dhcp> + </ip> +</network> diff --git a/integration-tests/windows/vm/templates/windows-vm-network.xml.j2 b/integration-tests/windows/vm/templates/windows-vm-network.xml.j2 new file mode 100644 index 0000000..687f8bd --- /dev/null +++ b/integration-tests/windows/vm/templates/windows-vm-network.xml.j2 @@ -0,0 +1,13 @@ +<network connections="1"> + <name>{{ vm_network_hostnet_name }}</name> + <forward mode="route"/> + <ip + address="{{ vm_network_hostnet_subnet | ansible.utils.ipaddr('next_usable') }}" + netmask="{{ vm_network_hostnet_subnet | ansible.utils.ipaddr('netmask') }}"> + <dhcp> + <range + start="{{ vm_network_hostnet_subnet | ansible.utils.next_nth_usable(2) }}" + end="{{ vm_network_hostnet_subnet | ansible.utils.ipaddr('last_usable') }}"/> + </dhcp> + </ip> +</network> diff --git a/integration-tests/windows/vm/templates/windows-vm-volume.xml.j2 b/integration-tests/windows/vm/templates/windows-vm-volume.xml.j2 new file mode 100644 index 0000000..6f41a06 --- /dev/null +++ b/integration-tests/windows/vm/templates/windows-vm-volume.xml.j2 @@ -0,0 +1,7 @@ +<volume> + <name>{{ vm_disk_name }}</name> + <capacity unit="GiB">{{ vm_disk_size_gib }}</capacity> + <target> + <format type="{{ vm_disk_format }}"/> + </target> +</volume> diff --git a/integration-tests/windows/vm/templates/windows-vm.xml.j2 b/integration-tests/windows/vm/templates/windows-vm.xml.j2 new file mode 100644 index 0000000..cf49fa7 --- /dev/null +++ b/integration-tests/windows/vm/templates/windows-vm.xml.j2 @@ -0,0 +1,99 @@ +<domain type="kvm"> + <name>{{ vm_name }}</name> + <metadata> + <libosinfo:libosinfo xmlns:libosinfo="http://libosinfo.org/xmlns/libvirt/domain/1.0"> + <libosinfo:os id="http://microsoft.com/win/2k22"/> + </libosinfo:libosinfo> + </metadata> + <memory unit="MiB">{{ vm_memory_mib }}</memory> + <currentMemory unit="MiB">{{ vm_memory_mib }}</currentMemory> + <vcpu placement="static">{{ vm_vcpus }}</vcpu> + <os + {# EFI seems to vary more between distributions, and makes Windows always do the "press any key to install" prompt #} + {# firmware="efi" #}> + <type arch="x86_64" machine="pc-q35-8.0">hvm</type> + </os> + <features> + <acpi/> + <apic/> + <hyperv mode="custom"> + <relaxed state="on"/> + <vapic state="on"/> + <spinlocks state="on" retries="8191"/> + </hyperv> + <vmport state="off"/> + </features> + <cpu mode="host-passthrough" check="none" migratable="on"/> + {# Our unattended install config reconfigures Windows to read UTC time from RTC #} + <clock offset="utc"> + <timer name="rtc" tickpolicy="catchup"/> + <timer name="pit" tickpolicy="delay"/> + <timer name="hpet" present="no"/> + <timer name="hypervclock" present="yes"/> + </clock> + <devices> + <disk type="volume" device="disk"> + <driver name="qemu" type="{{ vm_disk_format }}" discard="unmap"/> + <source pool="{{ vm_disk_pool }}" volume="{{ vm_disk_name }}"/> + <target dev="sda" bus="scsi"/> + <boot order="1"/> + </disk> + <disk type="file" device="cdrom"> + <driver name="qemu" type="raw"/> + <source file="{{ install_iso_windows }}"/> + <target dev="sdb" bus="sata"/> + <readonly/> + <boot order="2"/> + </disk> + <disk type="file" device="cdrom"> + <driver name="qemu" type="raw"/> + <source file="{{ install_iso_virtio_win }}"/> + <target dev="sdc" bus="sata"/> + <readonly/> + </disk> + {# Windows seems to ignore unattended install configs on USB drives #} + <disk type="dir" device="floppy"> + <driver name="qemu" type="fat"/> + <source dir="{{ playbook_dir }}/files/windows-install-config"/> + <target dev="fda"/> + <readonly/> + </disk> + <controller type="scsi" index="0" model="virtio-scsi"/> + <!-- Docker/Kind does not route traffic into libvirt NAT networks properly, so configure a host-only network --> + <interface type="network"> + <source network="{{ vm_network_hostnet_name }}"/> + <model type="virtio"/> + <alias name="ua-net-hostnet"/> + </interface> + <!-- Routed networks require extra configuration to provide internet access, so provide a NATed secondary network interface instead --> + <interface type="network"> + <source network="{{ vm_network_internet_name }}"/> + <model type="virtio"/> + <alias name="ua-net-internet"/> + </interface> + <serial type="pty"/> + <console type="pty"> + <target type="serial" port="0"/> + </console> + <channel type="spicevmc"> + <target type="virtio" name="com.redhat.spice.0"/> + </channel> + <channel type="unix"> + <target type="virtio" name="org.qemu.guest_agent.0"/> + </channel> + <input type="tablet" bus="usb"/> + <input type="mouse" bus="ps2"/> + <input type="keyboard" bus="ps2"/> + <graphics type="spice" autoport="yes"> + <listen type="address"/> + <image compression="off"/> + </graphics> + <video> + <model type="qxl" ram="65536" vram="65536" vgamem="16384" heads="1" primary="yes"/> + </video> + <redirdev bus="usb" type="spicevmc"/> + <redirdev bus="usb" type="spicevmc"/> + <watchdog model="itco" action="reset"/> + <memballoon model="virtio"/> + </devices> +</domain> diff --git a/integration-tests/windows/windows_test.py b/integration-tests/windows/windows_test.py new file mode 100644 index 0000000..b2ef9a6 --- /dev/null +++ b/integration-tests/windows/windows_test.py @@ -0,0 +1,780 @@ +#!/usr/bin/env python3 +""" +Run the Trino integration suites on a Windows VM over WinRM. + +Builds the ODBC driver DLL, discovers the VM, deploys the suites, registers +the driver, and runs everything `suites/registry.py` marks as running on +Windows, through the Windows Driver Manager. Trino must be running on the host +(via integration-tests/setup.sh) before running this script. + +Which suites those are is not decided here. `suites/registry.py` is the one +list, shared with `scripts/run-tests.sh`, and a suite that does not run here +carries its reason in that file. What *is* decided here is how a suite is +invoked on the VM, and the four connect configurations, which are not the same +four the Linux runner uses: these cross a registry-registered DSN with a +DSN-less string, and connect by address for the unverified cases so that no SNI +is sent. + +Usage: + uv run --with pywinrm python3 integration-tests/windows/windows_test.py + uv run --with pywinrm python3 integration-tests/windows/windows_test.py --skip-build + uv run --with pywinrm python3 integration-tests/windows/windows_test.py --host 192.168.197.138 + uv run --with pywinrm python3 integration-tests/windows/windows_test.py --suite tls + +Requires a running Trino on the host, the Windows VM, and `pip install pywinrm`. +A suite needing a compose profile is skipped unless the host stack was set up +with it. +""" + +import argparse +import http.server +import os +import re +import socket +import subprocess +import sys +import threading +import time +from pathlib import Path + +SCRIPT_DIR = Path(__file__).resolve().parent # integration-tests/windows +TEST_DIR = SCRIPT_DIR.parent # integration-tests +PROJECT_DIR = TEST_DIR.parent +OPENSSL_CNF = SCRIPT_DIR / "openssl_legacy.cnf" + +sys.path.insert(0, str(TEST_DIR / "suites")) + +from harness import Stack # noqa: E402 +from registry import SUITES, suite_argv # noqa: E402 + +# The VM mirrors the repository layout under one directory, rather than holding +# a flat pile of files. The suites address their neighbours by relative path: +# `test_folding_contract.py` reads `../../connector/StackableTrinoODBC.pq`, and +# `harness.Stack` defaults to `../generated/stack.env`. Mirroring makes all of +# that resolve on the VM exactly as it does on the host, so no suite needs a +# Windows branch to find its own inputs. +REMOTE_DIR = r"C:\odbc_test_trino" +REMOTE_SUITES = rf"{REMOTE_DIR}\integration-tests\suites" +REMOTE_GENERATED = rf"{REMOTE_DIR}\integration-tests\generated" +REMOTE_CERTS = rf"{REMOTE_GENERATED}\certs" +REMOTE_CA = rf"{REMOTE_CERTS}\ca.crt" +# The DLL stays at the root rather than under a mirrored `packaging/windows/`, +# because `configure-dsn.ps1` has to sit beside it: that is where the driver's +# ConfigDSN looks for the script. Their adjacency is a requirement, not a +# layout choice. +REMOTE_DLL = rf"{REMOTE_DIR}\stackable_odbc_trino.dll" +REMOTE_LOG = rf"{REMOTE_DIR}\stackable_odbc_trino.log" + +# Written on the host, served to the VM, and kept for inspection: a failing +# suite that read the wrong host or certificate is diagnosed from this file. +HOST_VM_STACK_ENV = TEST_DIR / "generated" / "windows-stack.env" + +DRIVER_NAME = "stackable_odbc_trino" +DSN_NAME = "test_trino" + +# Absolute path to Python on the VM. +REMOTE_PYTHON = r'"C:\Program Files\Python312\python.exe"' + +# The host-only network gateway. The VM reaches the host (and Docker) through +# this IP. Override with --gateway or ODBC_TEST_HOST_GATEWAY for a non-default +# subnet. +DEFAULT_HOST_GATEWAY = "192.168.197.1" +HTTP_PORT = 8081 # avoid conflict with the file server's own use + +# The VM reaches the host by IP, and TLS sends no SNI for an IP literal, so +# Jetty serves Trino's internal self-signed certificate instead of the +# CA-signed one and verification cannot succeed. Mapping a name the +# coordinator's certificate carries (DNS:trino, see scripts/gen-certs.sh) to +# the gateway in the VM's hosts file makes SNI work, which is what keeps the +# verified-TLS configurations meaningful here. +TRINO_VM_HOSTNAME = "trino" + + +def main(): + args = parse_args() + setup_openssl() + + # Verify Trino is running on the host before doing anything on the VM. + check_trino_reachable() + + if not args.skip_build: + build_dll(args.target) + + dll_path = resolve_dll_path(args.target) + host_stack = Stack.load() + trino_host = args.trino_host or args.gateway + vm_stack = write_vm_stack_env(host_stack) + + host = args.host or discover_vm_ip(args.vm_network) + + import winrm + + print(f"=== Connecting to {host} via WinRM ===") + session = winrm.Session( + f"http://{host}:5985/wsman", + auth=(args.user, args.password), + transport="ntlm", + ) + + # Verify connectivity + r = session.run_ps("hostname") + hostname = r.std_out.decode().strip() + if r.status_code != 0: + print(f"ERROR: WinRM connection failed: {r.std_err.decode()}", file=sys.stderr) + sys.exit(1) + print(f"Connected to {hostname}") + + # Wait for VM setup to complete (Python, pyodbc installed). + wait_for_setup(session) + + deploy(session, args.gateway, dll_path) + + # Before `register_driver`, because the uninstaller it runs deregisters the + # driver: doing it afterwards would tear down the registration every suite + # below depends on. + check_installers(session) + + print("=== Registering ODBC driver ===") + register_driver(session) + + map_trino_hostname(session, trino_host) + + run_suites(session, args, vm_stack, trino_host) + + +def run_suites(session, args, vm_stack, trino_host): + """Run every suite the registry marks as running on Windows. + + A failure records rather than aborts. Aborting on the first one hid every + later suite and configuration entirely, so a single flaky case cost the + whole run's information. + """ + # The DSN-less verified string every non-matrix suite runs against, the + # counterpart of the Linux runner's CONN_HTTPS. Built from the VM's own + # stack.env, so it cannot disagree with what the suites reading that file + # will connect with. + conn_verified = vm_stack.conn_str() + driver = vm_stack.get("DRIVER_PATH") + profiles = vm_stack.profiles + + failed, skipped = [], [] + + for suite in SUITES: + if args.suite and args.suite not in suite.name: + continue + if not suite.runs_on_windows: + print(f"SKIP {suite.name}: {suite.windows_skip_reason}") + skipped.append(suite.name) + continue + if suite.profile and suite.profile not in profiles: + print(f"SKIP {suite.name}: profile '{suite.profile}' is not active " + f"(setup.sh --profile {suite.profile})") + skipped.append(suite.name) + continue + + if suite.matrix: + failed += run_matrix(session, suite, vm_stack, driver, trino_host) + else: + print(f"=== Running {suite.name} ===") + argv = suite_argv(suite, driver, conn_verified) + if run_remote(session, suite.script, argv) != 0: + failed.append(suite.name) + + print("") + print("=== Windows summary ===") + if skipped: + for name in skipped: + print(f" SKIP {name}") + if failed: + for name in failed: + print(f" FAIL {name}") + print(f"{len(failed)} suite run(s) failed") + sys.exit(1) + print("all selected suites passed") + + +def run_matrix(session, suite, vm_stack, driver, trino_host): + """Run one suite over the four Windows connect configurations. + + DSN and DSN-less crossed with verified and unverified TLS. The verified + ones connect by TRINO_VM_HOSTNAME so that SNI is sent; the unverified ones + use the address directly, which is what an operator who has not set up a + name would do, and which no verification could accept. + + The DSN configurations re-register `DSN_NAME` in between, so the order here + is load bearing and this stays a sequence rather than a table. + """ + failed = [] + + def run_config(label, conn_str): + name = f"{suite.name} ({label})" + print(f"=== Running {name} ===") + if run_remote(session, suite.script, suite_argv(suite, driver, conn_str)) != 0: + failed.append(name) + + run_config("DSN-less, verified TLS", vm_stack.conn_str()) + run_config("DSN-less, TlsVerify=false", vm_stack.conn_str( + Host=trino_host, TlsVerify="false", Certificate=None, + )) + + print("=== Registering DSN (verified TLS) ===") + register_dsn(session, TRINO_VM_HOSTNAME, protocol="https", port=8443, + extra=f"Certificate={REMOTE_CA}") + run_config("DSN, verified TLS", f"DSN={DSN_NAME}") + + print("=== Registering DSN (TlsVerify=false) ===") + register_dsn(session, trino_host, protocol="https", port=8443, + extra="TlsVerify=false") + run_config("DSN, TlsVerify=false", f"DSN={DSN_NAME}") + + return failed + + +def write_vm_stack_env(host_stack) -> Stack: + """Write the VM's `stack.env` and return it parsed. + + The suites that read `stack.env` rather than taking a connection string + (tls, spooling, transactions) were unrunnable on Windows for want of this + file alone: the host's names the driver's `.so`, a `localhost` the VM is + not, and certificate paths under the user's home directory. + + Everything that is a property of the *stack* is copied from the host's + file, so a credential or a port stays stated in one place. Only what the VM + sees differently is rewritten. + """ + values = { + # Two keys where the host needs one. The Windows Driver Manager loads a + # driver by its registered name, while the ctypes suites want the DLL's + # path; on Linux one string serves both. See `Stack.driver_ref`. + "DRIVER_NAME": DRIVER_NAME, + "DRIVER_PATH": REMOTE_DLL, + # The name mapped into the VM's hosts file, not the host's `localhost`. + # It is also a name the coordinator's certificate carries, so TLS sends + # an SNI Jetty can match. + "TRINO_HOST": TRINO_VM_HOSTNAME, + "TRINO_HTTPS_PORT": host_stack.get("TRINO_HTTPS_PORT"), + "TRINO_USER": host_stack.get("TRINO_USER"), + "TRINO_PASSWORD": host_stack.get("TRINO_PASSWORD"), + "TRINO_CATALOG": host_stack.get("TRINO_CATALOG"), + "CA_CERT": rf"{REMOTE_CERTS}\ca.crt", + "CLIENT_PEM": rf"{REMOTE_CERTS}\client.pem", + # The profiles are the host stack's, because that is the coordinator the + # VM connects to. A suite gated on one is gated on the same one here. + "PROFILES": ",".join(host_stack.profiles), + } + HOST_VM_STACK_ENV.parent.mkdir(parents=True, exist_ok=True) + with open(HOST_VM_STACK_ENV, "w", encoding="utf-8") as f: + f.write("# generated by windows/windows_test.py, do not edit\n") + f.write("# the VM's view of the stack; the host's is generated/stack.env\n") + for key, value in values.items(): + f.write(f"{key}={value}\n") + return Stack.load(str(HOST_VM_STACK_ENV)) + + +def deploy(session, gateway: str, dll_path: Path): + """Serve the suites over HTTP and have the VM download them. + + The file map is keyed by repository-relative path and the VM recreates that + layout, which is what lets a suite find the connector source or the + certificates by the same relative path it uses on the host. + """ + print("=== Deploying files via HTTP ===") + files = { + "stackable_odbc_trino.dll": dll_path, + # Deployed even though the automated configurations never open a + # dialog: without it the ODBC Administrator's Add... button fails on + # the VM, and that is the one thing the harness could not otherwise be + # used to check. + "configure-dsn.ps1": PROJECT_DIR / "packaging" / "windows" / "configure-dsn.ps1", + # The shipped installers, so `check_installers` can run the real thing + # rather than a paraphrase of it. They are what a user runs, and until + # this harness ran them nothing did: `register_driver` below registers + # the driver its own way. + "install.bat": PROJECT_DIR / "packaging" / "windows" / "install.bat", + "uninstall.bat": PROJECT_DIR / "packaging" / "windows" / "uninstall.bat", + # The test CA, so the VM can verify the coordinator rather than only + # skip it. Needed by every configuration, not by any one suite. + "integration-tests/generated/certs/ca.crt": + TEST_DIR / "generated" / "certs" / "ca.crt", + # The VM's own view of the stack, which is what the suites reading + # stack.env rather than taking a connection string work from. + "integration-tests/generated/stack.env": HOST_VM_STACK_ENV, + } + # The whole suites directory, not the scripts the registry selects. The + # suites import shared modules from beside themselves (`harness`, and + # `odbc_abi` for the two ctypes suites), and deploying a computed list meant + # a new shared module was a deployment failure on the VM and nowhere else. + # They are a few hundred kilobytes in total, so nothing is bought by + # deploying fewer of them. + for script in sorted((TEST_DIR / "suites").glob("*.py")): + files[f"integration-tests/suites/{script.name}"] = script + for suite in SUITES: + if suite.runs_on_windows: + for rel in suite.deploy: + files[rel] = PROJECT_DIR / rel + + missing = [str(p) for p in files.values() if not p.exists()] + if missing: + print("ERROR: missing files; run ./integration-tests/setup.sh\n " + + "\n ".join(missing), file=sys.stderr) + sys.exit(1) + + with http_file_server(files) as port: + base_url = f"http://{gateway}:{port}" + # A PowerShell loop rather than one Invoke-WebRequest per file: the + # command line is sent over WinRM, and a couple of dozen of them + # spelled out reliably exceeded what it would carry. + names = ", ".join(f"'{name}'" for name in files) + # .Replace rather than -replace, so that neither side is a regex. A + # path separator is not a pattern, and -replace would make the + # substitution depend on .NET replacement-pattern rules. + r = session.run_ps( + f'$ProgressPreference = "SilentlyContinue"; ' + f'foreach ($f in @({names})) {{ ' + f' $dest = Join-Path "{REMOTE_DIR}" $f.Replace("/", "\\"); ' + f' New-Item -ItemType Directory -Force -Path (Split-Path $dest) | Out-Null; ' + f' Invoke-WebRequest -Uri "{base_url}/$f" -OutFile $dest; ' + f'}}' + ) + if r.status_code != 0: + print(f"ERROR: file download failed:\n{r.std_err.decode()}", file=sys.stderr) + sys.exit(1) + + print(f" {len(files)} files, DLL {dll_path.stat().st_size / 1024:.0f} KB") + + +def map_trino_hostname(session, gateway: str): + """Point TRINO_VM_HOSTNAME at the host in the VM's hosts file. + + TLS sends no SNI for an IP literal, and Jetty serves Trino's internal + self-signed certificate for anything it cannot match on SNI, so connecting + by address can never verify against the CA-signed certificate. A name the + certificate carries fixes that, and the VM's hosts file is the only place + to put it. + """ + print(f"=== Mapping {TRINO_VM_HOSTNAME} -> {gateway} in the VM hosts file ===") + hosts = r"C:\Windows\System32\drivers\etc\hosts" + ps = ( + f'$h = "{hosts}"; ' + f'$line = "{gateway}`t{TRINO_VM_HOSTNAME}"; ' + f'$kept = (Get-Content $h) | Where-Object {{ $_ -notmatch "\\s{TRINO_VM_HOSTNAME}\\s*$" }}; ' + f'Set-Content -Path $h -Value ($kept + $line)' + ) + r = session.run_ps(ps) + if r.status_code != 0: + print(f"ERROR: could not write the VM hosts file:\n{r.std_err.decode()}", + file=sys.stderr) + sys.exit(1) + + +def parse_args(): + p = argparse.ArgumentParser( + description="Run Trino ODBC integration tests on a Windows VM.", + ) + p.add_argument( + "--skip-build", + action="store_true", + help="skip cargo build, use existing DLL", + ) + p.add_argument( + "--target", + choices=["gnu", "msvc"], + default="gnu", + help="cross-compilation target (default: gnu)", + ) + p.add_argument( + "--host", + help="VM IP or hostname (default: auto-discover from DHCP leases)", + ) + p.add_argument( + "--vm-network", + default="stackable-odbc-test-hostnet", + help="libvirt network for IP discovery (default: stackable-odbc-test-hostnet)", + ) + p.add_argument( + "--user", + default="Administrator", + help="WinRM username (default: Administrator)", + ) + p.add_argument( + "--password", + default="Asdf1234", + help="WinRM password (default: Asdf1234)", + ) + p.add_argument( + "--gateway", + default=os.environ.get("ODBC_TEST_HOST_GATEWAY", DEFAULT_HOST_GATEWAY), + help=( + "host-only network gateway IP the VM uses to reach the host " + f"(default: $ODBC_TEST_HOST_GATEWAY or {DEFAULT_HOST_GATEWAY})" + ), + ) + p.add_argument( + "--trino-host", + default=None, + help="Trino host as seen from the VM (default: same as --gateway)", + ) + p.add_argument( + "--suite", + default="", + help="only run suites whose name contains this string", + ) + return p.parse_args() + + +def setup_openssl(): + """Point OPENSSL_CONF at the legacy provider config for NTLM/MD4.""" + if "OPENSSL_CONF" in os.environ: + return + if OPENSSL_CNF.exists(): + os.environ["OPENSSL_CONF"] = str(OPENSSL_CNF) + else: + print( + f"WARNING: {OPENSSL_CNF} not found. WinRM NTLM auth may fail\n" + "if your OpenSSL does not have the legacy provider enabled.\n" + "See windows/WINDOWS.md for details.", + file=sys.stderr, + ) + + +def check_trino_reachable(): + """Verify Trino is running on the host before deploying to the VM.""" + print("=== Checking Trino is reachable on host ===") + try: + result = subprocess.run( + ["curl", "-sf", "--cacert", + str(TEST_DIR / "generated" / "certs" / "ca.crt"), + "-u", "admin:admin", "https://localhost:8443/v1/info"], + capture_output=True, timeout=5, + ) + if result.returncode == 0: + print("Trino is running") + return + except (FileNotFoundError, subprocess.TimeoutExpired): + pass + print( + "ERROR: Trino is not reachable at https://localhost:8443/v1/info\n" + "Start it with: ./integration-tests/setup.sh", + file=sys.stderr, + ) + sys.exit(1) + + +def build_dll(target: str): + """Cross-compile the Trino ODBC driver for Windows.""" + rust_target = ( + "x86_64-pc-windows-msvc" if target == "msvc" else "x86_64-pc-windows-gnu" + ) + cmd = [ + "cargo", "build", "--release", + "--target", rust_target, + "-p", "stackable-odbc-trino", + ] + print(f"=== Building DLL ({rust_target}) ===") + result = subprocess.run(cmd, cwd=PROJECT_DIR) + if result.returncode != 0: + print("ERROR: cargo build failed", file=sys.stderr) + sys.exit(1) + + +def resolve_dll_path(target: str) -> Path: + """Return the path to the built DLL.""" + rust_target = ( + "x86_64-pc-windows-msvc" if target == "msvc" else "x86_64-pc-windows-gnu" + ) + dll = PROJECT_DIR / "target" / rust_target / "release" / "stackable_odbc_trino.dll" + if not dll.exists(): + print(f"ERROR: DLL not found at {dll}", file=sys.stderr) + print("Run without --skip-build, or check your build output.", file=sys.stderr) + sys.exit(1) + return dll + + +def discover_vm_ip(network: str) -> str: + """Get the VM IP from libvirt DHCP leases.""" + print(f"=== Discovering VM IP from network {network} ===") + try: + result = subprocess.run( + ["virsh", "--connect", "qemu:///system", "net-dhcp-leases", network], + capture_output=True, text=True, check=True, + ) + except FileNotFoundError: + print("ERROR: virsh not found. Install libvirt or use --host.", file=sys.stderr) + sys.exit(1) + except subprocess.CalledProcessError as e: + print( + f"ERROR: could not query DHCP leases for network '{network}'.\n" + "Is the VM running? See windows/WINDOWS.md for setup.\n" + f"virsh output: {e.stderr}", + file=sys.stderr, + ) + sys.exit(1) + + ips = re.findall(r"ipv4\s+([\d.]+)/", result.stdout) + if not ips: + print( + f"ERROR: no DHCP leases found on network '{network}'.\n" + "Is the VM running? See windows/WINDOWS.md for setup.", + file=sys.stderr, + ) + sys.exit(1) + + ip = ips[-1] + print(f"Found VM at {ip}") + return ip + + +def wait_for_setup(session): + """Wait for VM FirstLogonCommands to finish (sentinel file).""" + r = session.run_ps(r"Test-Path C:\setup_complete.txt") + if r.std_out.decode().strip() == "True": + return + + print("=== Waiting for VM setup to complete ===") + for i in range(60): + time.sleep(10) + r = session.run_ps(r"Test-Path C:\setup_complete.txt") + if r.std_out.decode().strip() == "True": + print(" Setup complete") + return + print(f" still waiting... ({(i + 1) * 10}s)", end="\r") + + print( + "\nERROR: timed out waiting for VM setup (C:\\setup_complete.txt).\n" + "The FirstLogonCommands in Autounattend.xml may have failed.\n" + "Check the VM with: virt-viewer --connect qemu:///system stackable-odbc-test", + file=sys.stderr, + ) + sys.exit(1) + + +def ps_quote(value: str) -> str: + """Quote a value as a PowerShell single-quoted string. + + Connection strings carry semicolons and backslashes, both of which a + double-quoted PowerShell string would interpret. Single quotes are literal + throughout, and a literal single quote is written by doubling it. + """ + escaped = value.replace("'", "''") + return f"'{escaped}'" + + +def run_remote(session, script: str, argv) -> int: + """Run one suite on the VM and return its exit code.""" + args = " ".join(ps_quote(a) for a in argv) + # Enable driver-side debug logging and DM tracing. + r = session.run_ps( + f'$env:ODBC_LOG_LEVEL = "debug"; ' + f'$env:ODBC_LOG_FILE = "{REMOTE_LOG}"; ' + f'& {REMOTE_PYTHON} "{REMOTE_SUITES}\\{script}" {args}' + ) + stdout = r.std_out.decode("utf-8", errors="replace") + print(stdout, end="") + + if r.std_err: + stderr = r.std_err.decode("utf-8", errors="replace") + if "CLIXML" not in stderr: + print(stderr, end="", file=sys.stderr) + + # Retrieve the driver trace log, then clear it for the next run. + lr = session.run_ps( + f'if (Test-Path "{REMOTE_LOG}") {{ Get-Content "{REMOTE_LOG}" -Tail 200 }}' + ) + log_content = lr.std_out.decode("utf-8", errors="replace").strip() + if log_content: + print("\n=== stackable_odbc_trino.log (last 200 lines) ===") + print(log_content) + session.run_ps(f'Remove-Item -Force -ErrorAction SilentlyContinue "{REMOTE_LOG}"') + + return r.status_code + + +class _FileServer(http.server.SimpleHTTPRequestHandler): + """HTTP handler that serves specific files from a lookup table.""" + + file_map: dict[str, Path] = {} + + def do_GET(self): + name = self.path.lstrip("/") + path = self.file_map.get(name) + if path is None or not path.exists(): + self.send_error(404, f"Not found: {name}") + return + data = path.read_bytes() + self.send_response(200) + self.send_header("Content-Length", str(len(data))) + self.send_header("Content-Type", "application/octet-stream") + self.end_headers() + self.wfile.write(data) + + def log_message(self, format, *args): + pass + + +class http_file_server: + """Context manager that runs a temporary HTTP server in a background thread.""" + + def __init__(self, file_map: dict[str, Path]): + self.file_map = file_map + self.server = None + self.thread = None + + def __enter__(self) -> int: + handler = type( + "_Handler", + (_FileServer,), + {"file_map": self.file_map}, + ) + port = HTTP_PORT if _port_available(HTTP_PORT) else 0 + self.server = http.server.HTTPServer(("0.0.0.0", port), handler) + if port == 0: + port = self.server.server_address[1] + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + return port + + def __exit__(self, *_): + if self.server: + self.server.shutdown() + if self.thread: + self.thread.join(timeout=5) + + +def _port_available(port: int) -> bool: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + try: + s.bind(("0.0.0.0", port)) + return True + except OSError: + return False + + +def check_installers(session): + """Run the shipped install.bat and uninstall.bat, and check what they left. + + Everything else in this file registers the driver its own way, so the two + scripts a user actually runs were exercised by nothing. Both halves have + failed silently in the past for the same reason: `odbcconf.exe` reports + success whether or not the action succeeded, so install.bat's `errorlevel` + check proved nothing, and uninstall.bat deleted only the DLL while + install.bat had placed two files. + + The archive layout is reproduced rather than assumed: install.bat refuses to + run unless the DLL and configure-dsn.ps1 sit beside it, which is exactly the + property worth testing. + """ + print("=== Checking the shipped installers ===") + staging = rf"{REMOTE_DIR}\archive" + install_dir = r"$env:ProgramFiles\Stackable\ODBC" + key = r"HKLM:\SOFTWARE\ODBC\ODBCINST.INI\stackable_odbc_trino" + listing = r"HKLM:\SOFTWARE\ODBC\ODBCINST.INI\ODBC Drivers" + + # Start from a clean slate: a driver left registered by an earlier run would + # make the install check pass without installing anything. + session.run_ps( + f'cmd.exe /c "{staging}\\uninstall.bat" | Out-Null; ' + f'Remove-Item -Recurse -Force -ErrorAction SilentlyContinue "{staging}"' + ) + + r = session.run_ps( + f'New-Item -ItemType Directory -Force -Path "{staging}" | Out-Null; ' + f'Copy-Item "{REMOTE_DIR}\\stackable_odbc_trino.dll","{REMOTE_DIR}\\configure-dsn.ps1",' + f'"{REMOTE_DIR}\\install.bat","{REMOTE_DIR}\\uninstall.bat" -Destination "{staging}"; ' + f'cmd.exe /c "{staging}\\install.bat"' + ) + if r.status_code != 0: + print(f"ERROR: install.bat failed:\n{r.std_out.decode()}\n{r.std_err.decode()}", + file=sys.stderr) + sys.exit(1) + + # What the ODBC Administrator reads. `Driver` alone is not enough: the + # Drivers tab is populated from the "ODBC Drivers" listing, and a driver + # present in one and not the other is invisible. + r = session.run_ps( + f'$ok = $true; ' + f'foreach ($f in "stackable_odbc_trino.dll","configure-dsn.ps1") {{ ' + f' if (-not (Test-Path (Join-Path "{install_dir}" $f))) ' + f' {{ Write-Output "missing installed file: $f"; $ok = $false }} }}; ' + f'if (-not (Test-Path "{key}")) ' + f' {{ Write-Output "missing registry key"; $ok = $false }}; ' + f'if (-not (Get-ItemProperty -Path "{listing}" -Name "stackable_odbc_trino" ' + f' -ErrorAction SilentlyContinue)) ' + f' {{ Write-Output "not listed under ODBC Drivers"; $ok = $false }}; ' + f'if ($ok) {{ Write-Output "INSTALL-OK" }}' + ) + out = r.std_out.decode().strip() + if "INSTALL-OK" not in out: + print(f"ERROR: install.bat reported success but left an incomplete install:\n{out}", + file=sys.stderr) + sys.exit(1) + print(" install.bat: both files placed, driver registered and listed") + + r = session.run_ps(f'cmd.exe /c "{staging}\\uninstall.bat"') + if r.status_code != 0: + print(f"ERROR: uninstall.bat failed:\n{r.std_err.decode()}", file=sys.stderr) + sys.exit(1) + + r = session.run_ps( + f'$left = @(); ' + f'foreach ($f in "stackable_odbc_trino.dll","configure-dsn.ps1") {{ ' + f' if (Test-Path (Join-Path "{install_dir}" $f)) {{ $left += $f }} }}; ' + f'if (Test-Path "{install_dir}") {{ $left += "the install directory" }}; ' + f'if (Test-Path "{key}") {{ $left += "the registry key" }}; ' + f'if (Get-ItemProperty -Path "{listing}" -Name "stackable_odbc_trino" ' + f' -ErrorAction SilentlyContinue) {{ $left += "the ODBC Drivers entry" }}; ' + f'if ($left.Count -eq 0) {{ Write-Output "UNINSTALL-OK" }} ' + f'else {{ Write-Output ("left behind: " + ($left -join ", ")) }}' + ) + out = r.std_out.decode().strip() + if "UNINSTALL-OK" not in out: + print(f"ERROR: uninstall.bat did not clean up:\n{out}", file=sys.stderr) + sys.exit(1) + print(" uninstall.bat: both files, the directory and both registry entries removed") + + session.run_ps(f'Remove-Item -Recurse -Force -ErrorAction SilentlyContinue "{staging}"') + + +def register_driver(session): + """Register the ODBC driver via registry + odbcconf.exe. + + INSTALLDRIVER alone won't update the DLL path if the driver is already + registered (it only increments UsageCount). Force-update via the registry + to ensure the freshly deployed DLL is always used. + """ + cmd = ( + f'odbcconf.exe /A {{INSTALLDRIVER ' + f'"{DRIVER_NAME}|Driver={REMOTE_DLL}|Setup={REMOTE_DLL}|"}}' + ) + r = session.run_cmd("cmd.exe", ["/c", cmd]) + if r.status_code != 0: + print(f"ERROR: driver registration failed: {r.std_err.decode()}", file=sys.stderr) + sys.exit(1) + session.run_ps( + f'Set-ItemProperty ' + f'"HKLM:\\SOFTWARE\\ODBC\\ODBCINST.INI\\{DRIVER_NAME}" ' + f'-Name "Driver" -Value "{REMOTE_DLL}"; ' + f'Set-ItemProperty ' + f'"HKLM:\\SOFTWARE\\ODBC\\ODBCINST.INI\\{DRIVER_NAME}" ' + f'-Name "Setup" -Value "{REMOTE_DLL}"' + ) + print(f"Driver '{DRIVER_NAME}' registered") + + +def register_dsn(session, trino_host: str, *, protocol: str = "https", + port: int = 8443, extra: str = ""): + """Register (or re-register) a DSN for a Trino connection.""" + extra_fields = f"|{extra}" if extra else "" + cmd = ( + f'odbcconf.exe /A {{CONFIGDSN "{DRIVER_NAME}" ' + f'"DSN={DSN_NAME}|Host={trino_host}|Port={port}|User=admin|Password=admin' + f'|Protocol={protocol}|Catalog=tpcds{extra_fields}|"}}' + ) + r = session.run_cmd("cmd.exe", ["/c", cmd]) + if r.status_code != 0: + print(f"ERROR: DSN registration failed: {r.std_err.decode()}", file=sys.stderr) + sys.exit(1) + print(f"DSN '{DSN_NAME}' registered ({protocol}:{port})") + + +if __name__ == "__main__": + main() diff --git a/packaging/README.md b/packaging/README.md new file mode 100644 index 0000000..c45babc --- /dev/null +++ b/packaging/README.md @@ -0,0 +1,267 @@ +# Stackable Trino ODBC Driver + +ODBC 3.x driver for [Trino](https://trino.io/), targeting Power BI +DirectQuery and generic ODBC consumers on Linux and Windows. + +This file ships inside both release archives. If you have just extracted one, +start at [Installation](#installation). + +## What is in the archive + +`stackable-odbc-trino-<version>-linux-x64.tar.gz`: + +| File | Purpose | +|------|---------| +| `libstackable_odbc_trino.so` | The driver | +| `install.sh`, `uninstall.sh` | Registration with unixODBC | +| `libstackable_odbc_trino.so.cdx.json` | CycloneDX SBOM for the driver | +| `README.md`, `LICENSE` | This file, and Apache-2.0 | + +`stackable-odbc-trino-<version>-windows-x64.zip`: + +| File | Purpose | +|------|---------| +| `stackable_odbc_trino.dll` | The driver | +| `install.bat`, `uninstall.bat` | Registration with the Windows Driver Manager | +| `configure-dsn.ps1` | The data source dialog | +| `StackableTrinoODBC.mez` | Power Query custom connector for Power BI | +| `stackable_odbc_trino.dll.cdx.json` | CycloneDX SBOM for the driver | +| `StackableTrinoODBC.mez.cdx.json` | CycloneDX SBOM for the connector | +| `README.md`, `LICENSE` | This file, and Apache-2.0 | + +The connector is also published on its own, as +`StackableTrinoODBC-<version>.mez`. + +The release page carries `sha256sums.txt` over every published file. Verify a +download with `sha256sum -c sha256sums.txt`, run from the directory you +downloaded into. + +## Installation + +### Linux (x86_64) + +Requires `unixODBC` (the `unixodbc` package) and root privileges for +`odbcinst` registration. + +```bash +mkdir /tmp/trino-odbc +tar xzf stackable-odbc-trino-<version>-linux-x64.tar.gz -C /tmp/trino-odbc +cd /tmp/trino-odbc +sudo ./install.sh +``` + +Verify with `odbcinst -q -d`. The output should include +`[stackable_odbc_trino]`. + +To uninstall: + +```bash +sudo ./uninstall.sh +``` + +If you created any DSNs, also remove them from `/etc/odbc.ini` (or +`~/.odbc.ini`). + +### Windows (x86_64) + +Extract the `.zip`, open an **Administrator** Command Prompt (`cmd.exe`) in +the extracted folder, then: + +```cmd +install.bat +``` + +This copies the driver and `configure-dsn.ps1` to +`%ProgramFiles%\Stackable\ODBC` and registers the driver. Verify with the ODBC +Data Source Administrator (`%SystemRoot%\System32\odbcad32.exe`). The Drivers +tab should list `stackable_odbc_trino`. + +To uninstall: + +```cmd +uninstall.bat +``` + +If you created any DSNs, also remove them via the registry: + +```cmd +reg delete "HKCU\SOFTWARE\ODBC\ODBC.INI\YourDsnName" /f +reg delete "HKCU\SOFTWARE\ODBC\ODBC.INI\ODBC Data Sources" /v "YourDsnName" /f +``` + +### Power BI custom connector (Windows only) + +`StackableTrinoODBC.mez` is a Power Query custom connector that gives Trino its +own entry in the **Get Data** dialog and enables DirectQuery. Install the +driver first, then: + +1. Copy `StackableTrinoODBC.mez` to the Custom Connectors folder (create it if + it does not exist): + + ```cmd + mkdir "%USERPROFILE%\Documents\Power BI Desktop\Custom Connectors" + copy StackableTrinoODBC.mez "%USERPROFILE%\Documents\Power BI Desktop\Custom Connectors\" + ``` + +2. Open Power BI Desktop → **File** → **Options and settings** → + **Options** → **Security** → **Data Extensions** → select + **Allow any extension to load without validation or warning** +3. Restart Power BI Desktop +4. **Get Data** → **More** → **Database** → **Stackable Trino** + +## Create a DSN (optional) + +A DSN stores connection parameters under a name, so an application can pick it +from a list instead of asking for a full connection string. It is optional: +the DSN-less connection strings below work without one. + +### Windows: the dialog + +Open the **ODBC Data Source Administrator** (`odbcad32.exe`), press **Add…** +and choose `stackable_odbc_trino`. The driver's dialog covers every +connection-string option in one window, and **Configure…** reopens it on an +existing data source. + +The same dialog runs on its own, without the Administrator: + +```cmd +powershell -ExecutionPolicy Bypass -File "%ProgramFiles%\Stackable\ODBC\configure-dsn.ps1" +``` + +`install.bat` puts `configure-dsn.ps1` beside the driver DLL. The +Administrator's buttons need it there, so do not move it. + +Secrets are written only when their **Save** box is ticked, which is off by +default. A saved secret is stored unencrypted, and a System data source puts it +in `HKLM`, where every local user can read it. + +**Test connection** is unavailable for **External authentication**, because +testing connects in a way that forbids the driver from opening a login page. +The dialog says so rather than reporting a connection failure. Save the data +source and use it from your application, which opens a browser when it +connects. + +### Windows: scripted + +```cmd +odbcconf.exe /A {CONFIGDSN "stackable_odbc_trino" "DSN=Trino|Host=trino.example.com|Port=8443|User=admin|Password=secret|Catalog=hive|Schema=default|"} +``` + +> **PowerShell users:** `odbcconf.exe` commands with `{...}` use `cmd.exe` +> syntax. In PowerShell, wrap the argument in single quotes: +> `odbcconf.exe /A '{CONFIGDSN ...}'`. + +The DSN appears under the **User DSN** tab in ODBC Data Source Administrator. + +### Linux + +Add a section to `/etc/odbc.ini` (or `~/.odbc.ini` for a per-user DSN): + +```ini +[Trino] +Driver = stackable_odbc_trino +Host = trino.example.com +Port = 8443 +User = admin +Password = secret +Catalog = hive +Schema = default +``` + +## Connection string + +DSN-less, HTTPS with username and password. `Protocol` defaults to `https`, so +it can be left out: + +```text +Driver=stackable_odbc_trino;Host=trino.example.com;Port=8443;User=admin;Password=secret;Catalog=hive;Schema=default +``` + +DSN-less, plaintext HTTP: + +```text +Driver=stackable_odbc_trino;Host=trino.example.com;Port=8080;Protocol=http;User=admin;Catalog=hive;Schema=default +``` + +Every connection option is listed in the +[project README](https://github.com/stackabletech/stackable-odbc-trino#connecting). +Five of them take a `name:value;name2:value2` list and need `{braces}` in a +connection string but not in a DSN; see +[Values that contain a semicolon](https://github.com/stackabletech/stackable-odbc-trino#values-that-contain-a-semicolon). +The Windows dialog handles that difference for you. + +## Support + +- [Issues](https://github.com/stackabletech/stackable-odbc-trino/issues) for + bugs and feature requests +- [Discussions](https://github.com/orgs/stackabletech/discussions) for questions +- [Discord](https://discord.gg/7kZ3BNnCAF) to talk to us + +For a connection or query problem, attach a driver log. Set `ODBC_LOG_LEVEL` to +`debug` and `ODBC_LOG_FILE` to a writable path in the environment of the +application that loads the driver, then reproduce the problem. Logging is off +unless `ODBC_LOG_LEVEL` is set. + +## The SBOM + +Each archive carries the CycloneDX SBOM for what is inside it, so an offline +install has it without going back to the release page. The SBOM records the +sha256 of the binary shipped beside it. SPDX is published alongside the release, +for tooling that asks for that format by name. + +It is generated from the binary rather than from `Cargo.toml`, so it lists what +linked, with development dependencies excluded by construction. It also covers +what cargo cannot see: the Linux build links unixODBC at load time, and the +Windows build carries the mingw-w64 runtime and libgcc statically. Those +components, with their licences, are declared in +[`packaging/sbom-native.json`](https://github.com/stackabletech/stackable-odbc-trino/blob/main/packaging/sbom-native.json). + +## Building the release archives + +Everything below is for people building the driver themselves. Set up the +compiler and the unixODBC development libraries first, following +[CONTRIBUTING.md](https://github.com/stackabletech/stackable-odbc-trino/blob/main/CONTRIBUTING.md). + +From the **repository root**: + +```bash +# One-time: the Windows cross-compilation target and the SBOM tooling. +# Both tools are version-pinned to what .github/workflows/release.yaml installs, +# because packaging/test-sbom.sh asserts the shape of what syft emits and how +# cargo-auditable's .dep-v0 section reads. A different version of either can +# produce a different SBOM from the same binary. +rustup target add x86_64-pc-windows-gnu +cargo install cargo-auditable@0.7.5 +# syft v1.50.0: see https://github.com/anchore/syft for install options + +# Build the Linux and Windows binaries. +# --locked, as release.yaml uses: it builds against the versions Cargo.lock +# pins, so the SBOM describes the dependency set that ships rather than +# whatever resolved today. +cargo auditable build --locked --release +cargo auditable build --locked --release --target x86_64-pc-windows-gnu + +# Package into release archives. The version comes from Cargo.toml, which is +# also what the DLL's version resource and the connector's .pq carry, so there +# is nothing to pass and nothing to keep in step. Setting VERSION to anything +# other than that version is refused rather than producing an archive whose name +# disagrees with the driver inside it. To release a new version, bump all three +# together with release/release.sh. +./packaging/build-archives.sh +``` + +`cargo auditable` is required, not a preference: it embeds the dependency list +that the SBOM is generated from, and `packaging/sbom.sh` refuses a binary +without it. + +The result, in `packaging/dist/`: + +- `stackable-odbc-trino-<version>-linux-x64.tar.gz` +- `stackable-odbc-trino-<version>-windows-x64.zip` +- `StackableTrinoODBC-<version>.mez`, the standalone connector +- a CycloneDX (`.cdx.json`) and an SPDX (`.spdx.json`) SBOM per artefact +- `sha256sums.txt` over everything above + +`./packaging/test-sbom.sh` runs every SBOM assertion against the real +artefacts, including the component count and the per-platform native +components. It needs no running Trino. diff --git a/packaging/build-archives.sh b/packaging/build-archives.sh new file mode 100755 index 0000000..4da89ff --- /dev/null +++ b/packaging/build-archives.sh @@ -0,0 +1,162 @@ +#!/usr/bin/env bash +# Assemble release archives for stackable-odbc-trino. +# +# Preconditions: +# - $VERSION unset, or set to the version in Cargo.toml. It defaults to that +# version, so a local build needs no argument; release.yaml sets it from the +# release tag, which its verify-version job has already checked against +# Cargo.toml. +# - target/release/libstackable_odbc_trino.so exists +# - target/x86_64-pc-windows-gnu/release/stackable_odbc_trino.dll exists +# - both built with `cargo auditable`, which embeds the .dep-v0 section the +# SBOM is generated from. sbom.sh refuses an artifact without it. +# - syft on PATH +# +# Output (written to packaging/dist/): +# - stackable-odbc-trino-<version>-linux-x64.tar.gz +# - stackable-odbc-trino-<version>-windows-x64.zip (includes StackableTrinoODBC.mez) +# - StackableTrinoODBC-<version>.mez (standalone Power BI asset) +# - a CycloneDX and an SPDX SBOM per artifact, six files +# - sha256sums.txt over everything above +# +# Each archive also carries the CycloneDX SBOM for what it contains, so an +# offline or air-gapped install has it without going back to the release page. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +DIST_DIR="$REPO_ROOT/packaging/dist" +LINUX_SO="$REPO_ROOT/target/release/libstackable_odbc_trino.so" +WINDOWS_DLL="$REPO_ROOT/target/x86_64-pc-windows-gnu/release/stackable_odbc_trino.dll" +LICENSE_FILE="$REPO_ROOT/LICENSE" +PACKAGING_DIR="$REPO_ROOT/packaging" +CONNECTOR_DIR="$REPO_ROOT/connector" +MEZ_SOURCE="$CONNECTOR_DIR/bin/StackableTrinoODBC.mez" + +if [ ! -f "$LINUX_SO" ]; then + echo "ERROR: $LINUX_SO not found. Run 'cargo auditable build --locked --release' first." >&2 + exit 1 +fi +if [ ! -f "$WINDOWS_DLL" ]; then + echo "ERROR: $WINDOWS_DLL not found. Run 'cargo auditable build --locked --release --target x86_64-pc-windows-gnu' first." >&2 + exit 1 +fi +if [ ! -f "$LICENSE_FILE" ]; then + echo "ERROR: LICENSE file not found at $LICENSE_FILE" >&2 + exit 1 +fi + +# $VERSION names the archives; the crate version is what build.rs compiled into +# the DLL's VERSIONINFO resource and what the .pq carries. Nothing links the +# two, so without this an archive called 0.1.0 can hold a DLL the ODBC Data +# Source Administrator lists as 0.0.1, and the mismatch is invisible until +# someone reads a bug report. +# +# The crate version is therefore the default rather than something to be typed: +# it is the only value that can be correct, so requiring it as an argument only +# creates the chance of getting it wrong. release.yaml still passes $VERSION +# explicitly, from a tag its verify-version job has already compared against +# Cargo.toml, and that agreeing value is checked here rather than assumed. +CRATE_VERSION="$(cargo metadata --no-deps --format-version 1 --manifest-path "$REPO_ROOT/Cargo.toml" | jq -r '.packages[0].version')" +VERSION="${VERSION:-$CRATE_VERSION}" +if [ "$VERSION" != "$CRATE_VERSION" ]; then + echo "ERROR: VERSION=$VERSION but Cargo.toml declares $CRATE_VERSION." >&2 + echo " The archives would be named $VERSION while the DLL's version resource" >&2 + echo " and connector/StackableTrinoODBC.pq both report $CRATE_VERSION." >&2 + echo " Release with 'release/release.sh <patch|minor|major> --execute', which" >&2 + echo " bumps all three together, or leave VERSION unset to package the tree" >&2 + echo " as it stands." >&2 + exit 1 +fi + +# Build the .mez using the connector's own build script. +(cd "$CONNECTOR_DIR" && ./build.sh) +if [ ! -f "$MEZ_SOURCE" ]; then + echo "ERROR: connector build did not produce $MEZ_SOURCE" >&2 + exit 1 +fi + +# Cleared, not merely created. `sha256sums.txt` below globs the whole +# directory, so artefacts left by a run at a different $VERSION would be +# checksummed into this release's manifest, and the "Built:" count would +# describe a directory rather than a release. CI starts from a fresh checkout +# and never sees this; the person following packaging/README.md does. +rm -rf "$DIST_DIR" +mkdir -p "$DIST_DIR" + +# --- SBOMs --- +# Generated first, because each archive carries the one describing its contents. +# sbom.sh writes <basename>.cdx.json and <basename>.spdx.json. +SBOM_DIR="$DIST_DIR/sbom" +rm -rf "$SBOM_DIR" +mkdir -p "$SBOM_DIR" + +"$PACKAGING_DIR/sbom.sh" "$LINUX_SO" "$SBOM_DIR" +"$PACKAGING_DIR/sbom.sh" "$WINDOWS_DLL" "$SBOM_DIR" +"$PACKAGING_DIR/sbom.sh" "$MEZ_SOURCE" "$SBOM_DIR" + +LINUX_SBOM="$SBOM_DIR/$(basename "$LINUX_SO").cdx.json" +WINDOWS_SBOM="$SBOM_DIR/$(basename "$WINDOWS_DLL").cdx.json" +MEZ_SBOM="$SBOM_DIR/$(basename "$MEZ_SOURCE").cdx.json" + +# --- Linux archive --- +LINUX_STAGING="$DIST_DIR/staging-linux" +rm -rf "$LINUX_STAGING" +mkdir -p "$LINUX_STAGING" +cp "$LINUX_SO" "$LINUX_STAGING/" +cp "$PACKAGING_DIR/linux/install.sh" "$LINUX_STAGING/" +cp "$PACKAGING_DIR/linux/uninstall.sh" "$LINUX_STAGING/" +cp "$PACKAGING_DIR/README.md" "$LINUX_STAGING/" +cp "$LICENSE_FILE" "$LINUX_STAGING/" +cp "$LINUX_SBOM" "$LINUX_STAGING/" +chmod +x "$LINUX_STAGING/install.sh" "$LINUX_STAGING/uninstall.sh" + +LINUX_ARCHIVE="stackable-odbc-trino-${VERSION}-linux-x64.tar.gz" +tar -czf "$DIST_DIR/$LINUX_ARCHIVE" -C "$LINUX_STAGING" . +rm -rf "$LINUX_STAGING" + +# --- Windows archive (includes StackableTrinoODBC.mez) --- +WINDOWS_STAGING="$DIST_DIR/staging-windows" +rm -rf "$WINDOWS_STAGING" +mkdir -p "$WINDOWS_STAGING" +cp "$WINDOWS_DLL" "$WINDOWS_STAGING/" +cp "$MEZ_SOURCE" "$WINDOWS_STAGING/StackableTrinoODBC.mez" +cp "$PACKAGING_DIR/windows/install.bat" "$WINDOWS_STAGING/" +cp "$PACKAGING_DIR/windows/uninstall.bat" "$WINDOWS_STAGING/" +cp "$PACKAGING_DIR/windows/configure-dsn.ps1" "$WINDOWS_STAGING/" +cp "$PACKAGING_DIR/README.md" "$WINDOWS_STAGING/" +cp "$LICENSE_FILE" "$WINDOWS_STAGING/" +# Two, because this archive ships both the driver and the connector. +cp "$WINDOWS_SBOM" "$MEZ_SBOM" "$WINDOWS_STAGING/" + +WINDOWS_ARCHIVE="stackable-odbc-trino-${VERSION}-windows-x64.zip" +(cd "$WINDOWS_STAGING" && zip -r "$DIST_DIR/$WINDOWS_ARCHIVE" .) +rm -rf "$WINDOWS_STAGING" + +# --- Standalone .mez asset --- +STANDALONE_MEZ="StackableTrinoODBC-${VERSION}.mez" +cp "$MEZ_SOURCE" "$DIST_DIR/$STANDALONE_MEZ" + +# --- SBOMs as release assets --- +# Named with the version, so an asset downloaded on its own still says which +# release it describes. +for fmt in cdx spdx; do + cp "$SBOM_DIR/$(basename "$LINUX_SO").$fmt.json" \ + "$DIST_DIR/stackable-odbc-trino-${VERSION}-linux-x64.$fmt.json" + cp "$SBOM_DIR/$(basename "$WINDOWS_DLL").$fmt.json" \ + "$DIST_DIR/stackable-odbc-trino-${VERSION}-windows-x64.$fmt.json" + cp "$SBOM_DIR/$(basename "$MEZ_SOURCE").$fmt.json" \ + "$DIST_DIR/StackableTrinoODBC-${VERSION}.$fmt.json" +done +rm -rf "$SBOM_DIR" + +# --- Checksums --- +# Over every published file, generated last so it covers the SBOMs too. Paths +# are relative, so `sha256sum -c sha256sums.txt` works from the download +# directory. +(cd "$DIST_DIR" && sha256sum ./*.tar.gz ./*.zip ./*.mez ./*.json > sha256sums.txt) + +echo "Built:" +echo " $DIST_DIR/$LINUX_ARCHIVE" +echo " $DIST_DIR/$WINDOWS_ARCHIVE" +echo " $DIST_DIR/$STANDALONE_MEZ" +echo " $DIST_DIR/sha256sums.txt ($(wc -l < "$DIST_DIR/sha256sums.txt") entries)" diff --git a/packaging/linux/install.sh b/packaging/linux/install.sh new file mode 100755 index 0000000..8449c79 --- /dev/null +++ b/packaging/linux/install.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Install the Stackable Trino ODBC driver on Linux. +# Must be run as root (or via sudo). +# INSTALL_DIR environment variable overrides the default install path. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +LIB_DIR="$SCRIPT_DIR" +INSTALL_DIR="${INSTALL_DIR:-/usr/local/lib/stackable-odbc}" +DRIVER_LIB="libstackable_odbc_trino.so" + +if [ "$EUID" -ne 0 ]; then + echo "This script must be run as root (or via sudo)." >&2 + exit 1 +fi + +if [ ! -f "$LIB_DIR/$DRIVER_LIB" ]; then + echo "ERROR: $DRIVER_LIB not found next to install.sh at $LIB_DIR" >&2 + exit 1 +fi + +mkdir -p "$INSTALL_DIR" +cp "$LIB_DIR/$DRIVER_LIB" "$INSTALL_DIR/" + +TMP_INI="$(mktemp)" +trap 'rm -f "$TMP_INI"' EXIT +# Threading=2 asks unixODBC to serialise at the connection level rather than at +# its default environment level (3), and it is required for correctness. At +# Threading=3 a cross-thread SQLCancel is held behind the call it was meant to +# interrupt. Measured on a query that runs ~24s: Threading=3 raised HY010 from +# the fetch after 23.9s, Threading=2 raised HY008 after 2.0s. +# +# SQL_ATTR_QUERY_TIMEOUT is unaffected and fires either way. See +# "Threading = 2 is required, not tuning" in the project's AGENTS.md. +cat > "$TMP_INI" <<EOF +[stackable_odbc_trino] +Description=Stackable ODBC driver for Trino +Driver=$INSTALL_DIR/$DRIVER_LIB +Setup=$INSTALL_DIR/$DRIVER_LIB +FileUsage=1 +Threading=2 +EOF + +odbcinst -i -d -f "$TMP_INI" + +echo "Stackable Trino ODBC driver installed to $INSTALL_DIR." +echo "Verify with: odbcinst -q -d" +echo "" +echo "To create a DSN (optional), see README.md." diff --git a/packaging/linux/uninstall.sh b/packaging/linux/uninstall.sh new file mode 100755 index 0000000..ee280cb --- /dev/null +++ b/packaging/linux/uninstall.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# Uninstall the Stackable Trino ODBC driver on Linux. +# Must be run as root (or via sudo). +set -euo pipefail + +INSTALL_DIR="${INSTALL_DIR:-/usr/local/lib/stackable-odbc}" +DRIVER_LIB="libstackable_odbc_trino.so" + +if [ "$EUID" -ne 0 ]; then + echo "This script must be run as root (or via sudo)." >&2 + exit 1 +fi + +odbcinst -u -d -n "stackable_odbc_trino" || true +rm -f "$INSTALL_DIR/$DRIVER_LIB" +rmdir --ignore-fail-on-non-empty "$INSTALL_DIR" 2>/dev/null || true + +echo "Stackable Trino ODBC driver uninstalled." +echo "If you created any DSNs, remove them from /etc/odbc.ini (or ~/.odbc.ini)." diff --git a/packaging/sbom-native.json b/packaging/sbom-native.json new file mode 100644 index 0000000..2c48c17 --- /dev/null +++ b/packaging/sbom-native.json @@ -0,0 +1,70 @@ +{ + "_comment": "Components linked at load time, which cargo cannot see. The version recorded here is the one built against on the build host; the version actually loaded is whatever the user's machine provides. Keyed by artifact platform. Verified against readelf -d by `sbom.sh --check-native`.", + "linux": [ + { + "type": "library", + "name": "unixodbc", + "version": "2.3.12", + "purl": "pkg:generic/unixodbc@2.3.12", + "description": "unixODBC Driver Manager. libodbcinst.so.2 is linked at load time for driver and DSN registry access.", + "licenses": [ + { + "license": { + "id": "LGPL-2.1-or-later" + } + } + ], + "properties": [ + { + "name": "stackable:link-kind", + "value": "dynamic" + }, + { + "name": "stackable:soname", + "value": "libodbcinst.so.2" + } + ] + } + ], + "_windows_comment": "The DLL imports only Windows' own libraries, odbccp32.dll included, and those are the platform rather than dependencies, so none is listed here for the same reason libc is not listed for Linux. What is listed is the toolchain runtime, which is linked statically and therefore redistributed inside the artifact: the DLL imports no libgcc_s_seh-1.dll, libwinpthread-1.dll or libstdc++-6.dll, while carrying mingw_*, __gcc_register_frame, _Unwind_* and pthread_* internally.", + "windows": [ + { + "type": "library", + "name": "mingw-w64-runtime", + "version": "11.0.1", + "purl": "pkg:generic/mingw-w64-runtime@11.0.1", + "description": "mingw-w64 C runtime and winpthreads, statically linked into the DLL by the x86_64-pc-windows-gnu target.", + "licenses": [ + { + "license": { + "name": "Permissive mix: BSD-2-Clause-NetBSD, BSD-3-Clause, ISC, Cygwin and David-Gay. No single SPDX identifier covers it; see the mingw-w64 COPYING." + } + } + ], + "properties": [ + { + "name": "stackable:link-kind", + "value": "static" + } + ] + }, + { + "type": "library", + "name": "libgcc", + "version": "13.2.0", + "purl": "pkg:generic/libgcc@13.2.0", + "description": "GCC low-level runtime and unwinder, statically linked into the DLL by the x86_64-pc-windows-gnu target.", + "licenses": [ + { + "expression": "GPL-3.0-or-later WITH GCC-exception-3.1" + } + ], + "properties": [ + { + "name": "stackable:link-kind", + "value": "static" + } + ] + } + ] +} diff --git a/packaging/sbom.sh b/packaging/sbom.sh new file mode 100755 index 0000000..5dc7616 --- /dev/null +++ b/packaging/sbom.sh @@ -0,0 +1,293 @@ +#!/usr/bin/env bash +# Generate a CycloneDX SBOM for one release artifact. +# +# Usage: +# sbom.sh <artifact> <outdir> write <outdir>/<basename>.cdx.json +# +# The artifact must be built with `cargo auditable`, which embeds a .dep-v0 +# section holding the crates that were linked in. Syft reads that section, so the +# component list describes what shipped rather than what Cargo.toml asked for, +# and dev-dependencies are excluded by construction. +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" + +# Overridable so the tests can feed a drifted fragment on purpose. +SBOM_NATIVE="${SBOM_NATIVE:-$REPO_ROOT/packaging/sbom-native.json}" + +usage() { + cat >&2 <<'EOF' +usage: sbom.sh <artifact> <outdir> + write <outdir>/<basename>.cdx.json + + sbom.sh --check-native <artifact> + verify sbom-native.json against what the artifact actually links +EOF + exit 2 +} + +# Libraries supplied by the toolchain and libc are the platform, not components, +# so they are excluded the same way the Windows branch excludes the operating +# system's own DLLs. Everything else the ELF object needs at load time must be +# declared in the fragment. +IGNORED_SONAMES='^(libc\.so\.|libm\.so\.|libpthread\.so\.|libdl\.so\.|librt\.so\.|libgcc_s\.so\.|ld-linux)' + +# The Windows artifact declares no load-time component at all, because it +# imports only the operating system's libraries. What must hold instead is that +# the toolchain runtime stays *statically* linked: the release archive ships no +# runtime DLL, so an artifact importing one would fail to load on a machine +# without mingw installed. +FORBIDDEN_WINDOWS_IMPORTS='^(libgcc_s_seh-1|libgcc_s_dw2-1|libwinpthread-1|libstdc\+\+-6)\.dll$' + +check_native_elf() { + local artifact="$1" needed declared + needed="$(readelf -d "$artifact" \ + | sed -n 's/.*(NEEDED).*\[\(.*\)\]/\1/p' \ + | grep -Ev "$IGNORED_SONAMES" \ + | sort)" + declared="$(jq -r '.linux[].properties[]? | select(.name == "stackable:soname") | .value' \ + "$SBOM_NATIVE" | sort)" + + if [ "$needed" = "$declared" ]; then + echo "PASS: sbom-native.json matches the artifact's DT_NEEDED set" + return 0 + fi + + echo "FAIL: sbom-native.json has drifted from $artifact" >&2 + echo " linked but undeclared:" >&2 + comm -23 <(echo "$needed") <(echo "$declared") | sed 's/^/ /' >&2 + echo " declared but not linked:" >&2 + comm -13 <(echo "$needed") <(echo "$declared") | sed 's/^/ /' >&2 + return 1 +} + +check_native_pe() { + local artifact="$1" dynamic + dynamic="$(objdump -p "$artifact" \ + | sed -n 's/^\tDLL Name: //p' \ + | tr '[:upper:]' '[:lower:]' \ + | sort -u \ + | grep -E "$FORBIDDEN_WINDOWS_IMPORTS" || true)" + + if [ -z "$dynamic" ]; then + echo "PASS: the toolchain runtime is statically linked into the artifact" + return 0 + fi + + echo "FAIL: $artifact imports the toolchain runtime dynamically" >&2 + echo "$dynamic" | sed 's/^/ /' >&2 + echo " The release archive ships no runtime DLL, so this artifact would fail" >&2 + echo " to load on a machine without mingw installed. Either restore static" >&2 + echo " linking or ship the runtime and declare it in sbom-native.json." >&2 + return 1 +} + +if [ "${1:-}" = "--check-native" ]; then + [ "$#" -eq 2 ] || usage + [ -f "$2" ] || { echo "ERROR: artifact not found: $2" >&2; exit 1; } + case "$2" in + *.so) check_native_elf "$2" ;; + *.dll) check_native_pe "$2" ;; + *) echo "ERROR: cannot check native links of $2" >&2; exit 1 ;; + esac + exit $? +fi + +[ "$#" -eq 2 ] || usage + +ARTIFACT="$1" +OUTDIR="$2" + +[ -f "$ARTIFACT" ] || { echo "ERROR: artifact not found: $ARTIFACT" >&2; exit 1; } +mkdir -p "$OUTDIR" + +BASENAME="$(basename "$ARTIFACT")" +OUT="$OUTDIR/$BASENAME.cdx.json" +OUT_SPDX="$OUTDIR/$BASENAME.spdx.json" + +# An artifact built with plain `cargo build` carries no .dep-v0 section, and +# syft then reports a handful of components rather than the whole graph. That +# failure is silent and the result looks like a valid SBOM, so refuse it here +# rather than shipping a document that understates what is in the binary. +require_audit_section() { + local artifact="$1" found=0 + case "$artifact" in + *.so) found="$(readelf -S -W "$artifact" 2>/dev/null | grep -c '\.dep-v0' || true)" ;; + *.dll) found="$(objdump -h "$artifact" 2>/dev/null | grep -c '\.dep-v0' || true)" ;; + *) return 0 ;; + esac + + if [ "${found:-0}" -eq 0 ]; then + echo "ERROR: $artifact carries no .dep-v0 section." >&2 + echo " It was built with plain cargo, so the dependency graph is not in it" >&2 + echo " and the SBOM would list only a few components. Rebuild with:" >&2 + case "$artifact" in + *.dll) echo " cargo auditable build --locked --release --target x86_64-pc-windows-gnu" >&2 ;; + *) echo " cargo auditable build --locked --release" >&2 ;; + esac + exit 1 + fi +} + +# The Power Query connector is M source in a zip. Syft finds nothing in it and +# there is no cargo graph to enrich, so its document is built directly rather +# than run through the pipeline. An empty component list is the honest answer: +# the connector has no third-party dependencies. +build_mez_sbom() { + local artifact="$1" sha version serial + sha="$(sha256sum "$artifact" | cut -d' ' -f1)" + + # release.toml keeps this in step with the crate version; see AGENTS.md. + version="$(grep -oE '\[Version = "[^"]+"\]' "$REPO_ROOT/connector/StackableTrinoODBC.pq" \ + | head -1 | sed -E 's/.*"(.*)".*/\1/')" + [ -n "$version" ] || { echo "ERROR: no [Version = \"...\"] in StackableTrinoODBC.pq" >&2; exit 1; } + + # Derived from the artifact digest so the same input yields the same document. + serial="urn:uuid:${sha:0:8}-${sha:8:4}-${sha:12:4}-${sha:16:4}-${sha:20:12}" + + jq -n --arg name "$BASENAME" --arg sha "$sha" --arg version "$version" \ + --arg serial "$serial" --arg rustc "$(rustc --version)" \ + '{ + bomFormat: "CycloneDX", + specVersion: "1.7", + serialNumber: $serial, + version: 1, + metadata: { + component: { + type: "application", + name: $name, + version: $version, + description: "Power Query custom connector for the Stackable ODBC driver for Trino.", + licenses: [ { license: { id: "Apache-2.0" } } ], + hashes: [ { alg: "SHA-256", content: $sha } ] + }, + properties: [ { name: "stackable:rustc-version", value: $rustc } ] + }, + components: [] + }' > "$OUT" + + syft convert "$OUT" -o spdx-json="$OUT_SPDX" --quiet + + echo "Wrote $OUT" + echo "Wrote $OUT_SPDX" +} + +if [ "${BASENAME##*.}" = "mez" ]; then + build_mez_sbom "$ARTIFACT" + exit 0 +fi + +require_audit_section "$ARTIFACT" + +RAW="$OUTDIR/.$BASENAME.raw.json" +LOOKUP="$OUTDIR/.$BASENAME.lookup.json" +ENRICHED="$OUTDIR/.$BASENAME.enriched.json" +AUGMENTED="$OUTDIR/.$BASENAME.augmented.json" + +# --- extract --------------------------------------------------------------- +syft "$ARTIFACT" -o cyclonedx-json="$RAW" --quiet + +# --- enrich ---------------------------------------------------------------- +# cargo-auditable embeds only name, version and source kind, so syft's output +# carries no licenses, and a git or path dependency is indistinguishable from a +# crates.io package. A scanner resolving pkg:cargo/trino-rust-client@0.11.0 +# would reach the real upstream crate, which is not what shipped. +# +# Everything below keys off cargo metadata's source *kind*, never off a crate +# name, so a dependency moving between path, git and crates.io needs no change +# here. +cargo metadata --locked --format-version 1 --manifest-path "$REPO_ROOT/Cargo.toml" \ + | jq '[ .packages[] + | { key: "\(.name)@\(.version)", + value: { + license: .license, + kind: (if .source == null then "path" + elif (.source | startswith("git+")) then "git" + else "registry" end), + vcs: (if ((.source // "") | startswith("git+")) + then "git+" + (.source | sub("^git\\+"; "") | sub("[?#].*$"; "")) + + "@" + (.source | capture("#(?<rev>[0-9a-f]+)$").rev) + else null end) + } } ] | from_entries' > "$LOOKUP" + +# The rev comes from the resolved source in Cargo.lock, not from the branch or +# tag name, so the purl names an immutable commit. +jq --slurpfile lut "$LOOKUP" ' + ($lut[0]) as $L + | .components |= map( + . as $c + | ($L["\($c.name)@\($c.version)"]) as $m + | if $m == null then . else + .licenses = ( + if $m.license == null then [] + elif ($m.license | test(" OR | AND |/")) + then [ { expression: $m.license } ] + else [ { license: { id: $m.license } } ] end) + | .purl = ( + if $m.kind == "git" then "\(.purl)?vcs_url=\($m.vcs)" + elif $m.kind == "path" then "pkg:generic/\(.name)@\(.version)" + else .purl end) + | .properties = ( + (.properties // [] | map(select(.name | startswith("syft:cpe23") | not))) + + (if $m.kind == "path" + then [ { name: "stackable:cargo-source", value: "path" } ] + else [] end)) + end)' "$RAW" > "$ENRICHED" + +# --- augment --------------------------------------------------------------- +# Components the toolchain contributes are invisible to cargo, and the two +# platforms contribute different ones: the ELF object links unixODBC at load +# time, while the Windows DLL imports only the operating system's own libraries +# and instead carries the mingw runtime statically. +case "$BASENAME" in + *.so) NATIVE_KEY="linux" ;; + *.dll) NATIVE_KEY="windows" ;; + *) NATIVE_KEY="" ;; +esac + +if [ -n "$NATIVE_KEY" ]; then + jq --slurpfile native "$SBOM_NATIVE" \ + --arg key "$NATIVE_KEY" \ + '.components += ($native[0][$key] // [])' "$ENRICHED" > "$AUGMENTED" +else + cp "$ENRICHED" "$AUGMENTED" +fi + +# --- finalize -------------------------------------------------------------- +# Syft reports the scanned artifact as an ordinary component: type "file" named +# by its absolute path on the build host, and for the PE artifact a second +# type "application" entry as well. Both are the *subject* of this document +# rather than dependencies, so they move to metadata.component, and the build +# path stops travelling with the release. +# +# They are selected by having no purl rather than by type, because the types +# differ between the two artifact formats. Every real component has one: the +# cargo crates from the enrich stage, the native ones from the fragment. +ARTIFACT_SHA="$(sha256sum "$ARTIFACT" | cut -d' ' -f1)" +RUSTC_VERSION="$(rustc --version)" + +jq --arg name "$BASENAME" \ + --arg sha "$ARTIFACT_SHA" \ + --arg rustc "$RUSTC_VERSION" \ + ' + .components |= map(select((.purl // "") != "")) + | .metadata.component = { + type: "library", + name: $name, + hashes: [ { alg: "SHA-256", content: $sha } ] + } + | .metadata.properties = ((.metadata.properties // []) + [ + { name: "stackable:rustc-version", value: $rustc } + ])' "$AUGMENTED" > "$OUT" + +# --- convert --------------------------------------------------------------- +# SPDX is converted from the finished CycloneDX rather than generated afresh, so +# the enrichment and the native fragment reach both formats from one +# implementation and cannot drift apart. Some procurement processes ask for SPDX +# by name; CycloneDX is what ships inside the archive. +syft convert "$OUT" -o spdx-json="$OUT_SPDX" --quiet + +rm -f "$RAW" "$LOOKUP" "$ENRICHED" "$AUGMENTED" + +echo "Wrote $OUT" +echo "Wrote $OUT_SPDX" diff --git a/packaging/test-sbom.sh b/packaging/test-sbom.sh new file mode 100755 index 0000000..ae6325a --- /dev/null +++ b/packaging/test-sbom.sh @@ -0,0 +1,266 @@ +#!/usr/bin/env bash +# Assertions for packaging/sbom.sh, run against the real release artifact. +# +# Needs the release .so, syft and cargo-auditable. Builds the .so if absent. +# Run from anywhere: ./packaging/test-sbom.sh +set -euo pipefail + +REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +SO="$REPO_ROOT/target/release/libstackable_odbc_trino.so" +WORK="$(mktemp -d)" +trap 'rm -rf "$WORK"' EXIT + +FAILURES=0 +check() { + local label="$1" actual="$2" expected="$3" + if [ "$actual" = "$expected" ]; then + echo "PASS $label" + else + echo "FAIL $label: expected '$expected', got '$actual'" + FAILURES=$((FAILURES + 1)) + fi +} + +# The purl sbom.sh should emit for a git-sourced package, built from what +# Cargo.lock actually resolved. +# +# Derived rather than written out as a literal, because both git dependencies +# move: the fork tracks a branch, and core's tag is bumped per release. A +# hardcoded commit turns every routine bump into a failure of this suite, which +# is what happened. The fork's rev moved and the literal here did not, so the +# check reported a defect that was only a stale expectation. +# +# This still asserts the real property. The commit comes from the lockfile, not +# from the SBOM, so a purl naming a branch name, a truncated rev or the wrong +# package fails exactly as before. +locked_git_purl() { + awk -v want="$1" ' + /^\[\[package\]\]/ { pkg = ""; ver = ""; next } + /^name = / { pkg = $3; gsub(/"/, "", pkg); next } + /^version = / { ver = $3; gsub(/"/, "", ver); next } + /^source = "git\+/ { + if (pkg != want) next + src = $0; sub(/^source = "/, "", src); sub(/"$/, "", src) + repo = src; sub(/^git\+/, "", repo); sub(/[?#].*$/, "", repo) + rev = src; sub(/^.*#/, "", rev) + printf "pkg:cargo/%s@%s?vcs_url=git+%s@%s\n", pkg, ver, repo, rev + exit + } + ' "$REPO_ROOT/Cargo.lock" +} + +FORK_PURL="$(locked_git_purl trino-rust-client)" +CORE_PURL="$(locked_git_purl stackable-odbc-core)" + +# Aborts rather than running on. An empty expectation compares equal to nothing +# the SBOM emits, so the checks below would still fail, but they would blame +# the SBOM for a lockfile the helper could not read. Once either dependency +# moves to crates.io, delete its checks rather than letting this fire. +for named in "trino-rust-client=$FORK_PURL" "stackable-odbc-core=$CORE_PURL"; do + if [ -z "${named#*=}" ]; then + echo "ERROR: Cargo.lock records no git source for ${named%%=*}." >&2 + exit 1 + fi +done + +if [ ! -f "$SO" ]; then + echo "Building the release artifact with cargo auditable..." + (cd "$REPO_ROOT" && cargo auditable build --locked --release) +fi + +"$REPO_ROOT/packaging/sbom.sh" "$SO" "$WORK" +SBOM="$WORK/libstackable_odbc_trino.so.cdx.json" + +SPDX="$WORK/libstackable_odbc_trino.so.spdx.json" + +check "SBOM file is written" "$([ -f "$SBOM" ] && echo yes || echo no)" "yes" +check "SPDX file is written" "$([ -f "$SPDX" ] && echo yes || echo no)" "yes" +check "component count" "$(jq '.components | length' "$SBOM")" "167" + +check "every component is licensed" \ + "$(jq '[.components[] | select((.licenses // []) | length == 0)] | length' "$SBOM")" "0" + +check "no bare pkg:cargo purl on a git or path dep" \ + "$(jq '[.components[] | select(.purl != null) + | select(.name == "trino-rust-client" or .name == "stackable-odbc-core") + | select(.purl | test("^pkg:cargo/[^?]*$"))] | length' "$SBOM")" "0" + +check "the fork purl names an immutable commit" \ + "$(jq -r '.components[] | select(.name == "trino-rust-client") | .purl' "$SBOM")" \ + "$FORK_PURL" + +check "syft cpe23 noise is stripped" \ + "$(jq '[.components[].properties[]? | select(.name | startswith("syft:cpe23"))] | length' "$SBOM")" "0" + +check "dev-dependencies are absent" \ + "$(jq '[.components[] | select(.name | test("^(criterion|proptest|serial_test)$"))] | length' "$SBOM")" "0" + +check "the native component is merged in" \ + "$(jq '[.components[] | select(.name == "unixodbc")] | length' "$SBOM")" "1" + +check "the native component keeps its soname" \ + "$(jq -r '.components[] | select(.name == "unixodbc") + | .properties[] | select(.name == "stackable:soname") | .value' "$SBOM")" \ + "libodbcinst.so.2" + +check "the Windows runtime is not merged into a Linux SBOM" \ + "$(jq '[.components[] | select(.name == "libgcc" or .name == "mingw-w64-runtime")] | length' "$SBOM")" "0" + +check "the artifact is the SBOM subject" \ + "$(jq -r '.metadata.component.name' "$SBOM")" "libstackable_odbc_trino.so" + +check "the subject carries a sha256" \ + "$(jq -r '.metadata.component.hashes[]? | select(.alg == "SHA-256") | .content' "$SBOM" | tr -d '\n' | wc -c)" "64" + +check "no absolute build path leaks" \ + "$(jq -r '[.. | strings | select(startswith("/home/") or startswith("/build/"))] | length' "$SBOM")" "0" + +check "the rust toolchain is recorded" \ + "$(jq '[.metadata.properties[]? | select(.name == "stackable:rustc-version")] | length' "$SBOM")" "1" + +# One: stackable-odbc-trino, the root package, which is path-local permanently. +# The gate is not "zero path components": it is that the only path-sourced +# component is the root package, which is what catches a developer's local +# `[patch]` override shipping in a release artifact. +check "path-sourced components" \ + "$(jq '[.components[].properties[]? | select(.name == "stackable:cargo-source" and .value == "path")] | length' "$SBOM")" "1" + +check "the only path-sourced component is the root package" \ + "$(jq -r '[.components[] + | select(.properties[]? | select(.name == "stackable:cargo-source" and .value == "path")) + | .name] | join(",")' "$SBOM")" \ + "stackable-odbc-trino" + +# Core is pinned by tag, and the purl must still name the commit that tag +# resolved to rather than the tag itself: a tag can be moved, so a purl carrying +# `@v0.1.0` would not identify the source the artifact was built from. +check "core's purl names an immutable commit" \ + "$(jq -r '.components[] | select(.name == "stackable-odbc-core") | .purl' "$SBOM")" \ + "$CORE_PURL" + +# --- SPDX ------------------------------------------------------------------ +# SPDX is converted from the enriched CycloneDX rather than generated afresh, so +# the enrichment reaches both formats from one implementation. These assert the +# conversion carries it across. + +check "SPDX carries the enriched licenses" \ + "$(jq '[.packages[] | select((.licenseDeclared // "NOASSERTION") == "NOASSERTION")] | length' "$SPDX")" "2" + +check "SPDX carries the fork's vcs_url purl" \ + "$(jq -r '[.packages[] | select(.name == "trino-rust-client") | .externalRefs[]? | select(.referenceType == "purl") | .referenceLocator] | first' "$SPDX")" \ + "$FORK_PURL" + +check "SPDX carries the native component" \ + "$(jq '[.packages[] | select(.name == "unixodbc")] | length' "$SPDX")" "1" + +check "SPDX leaks no build path" \ + "$(jq '[.. | strings | select(startswith("/home/") or startswith("/build/"))] | length' "$SPDX")" "0" + +# --- --check-native -------------------------------------------------------- + +check "--check-native passes on the current fragment" \ + "$("$REPO_ROOT/packaging/sbom.sh" --check-native "$SO" >/dev/null 2>&1 && echo ok || echo failed)" "ok" + +# Drift must be detected, not tolerated. Feed it a fragment with the entry +# removed and require a non-zero exit. +jq 'del(.linux[0])' "$REPO_ROOT/packaging/sbom-native.json" > "$WORK/drifted.json" +check "--check-native detects a missing entry" \ + "$(SBOM_NATIVE="$WORK/drifted.json" "$REPO_ROOT/packaging/sbom.sh" --check-native "$SO" >/dev/null 2>&1 && echo ok || echo failed)" "failed" + +# The wrong soname must be caught too, which is the mistake of naming libodbc +# where the artifact links libodbcinst. +jq '.linux[0].properties |= map(if .name == "stackable:soname" then .value = "libodbc.so.2" else . end)' \ + "$REPO_ROOT/packaging/sbom-native.json" > "$WORK/wrong-soname.json" +check "--check-native detects a wrong soname" \ + "$(SBOM_NATIVE="$WORK/wrong-soname.json" "$REPO_ROOT/packaging/sbom.sh" --check-native "$SO" >/dev/null 2>&1 && echo ok || echo failed)" "failed" + +# The Windows branch asserts a different invariant: the mingw runtime must stay +# statically linked, because the archive ships no runtime DLL alongside it. +DLL="$REPO_ROOT/target/x86_64-pc-windows-gnu/release/stackable_odbc_trino.dll" +if [ -f "$DLL" ]; then + check "--check-native passes on the Windows DLL" \ + "$("$REPO_ROOT/packaging/sbom.sh" --check-native "$DLL" >/dev/null 2>&1 && echo ok || echo failed)" "ok" + + # --- the Windows SBOM ---------------------------------------------------- + # Generated as well as checked, because the two artifact formats take + # different branches through augment and finalize. Syft emits a second + # self-entry of type "application" for the PE artifact, which the Linux run + # never exercises. + "$REPO_ROOT/packaging/sbom.sh" "$DLL" "$WORK" >/dev/null + WSBOM="$WORK/stackable_odbc_trino.dll.cdx.json" + + check "Windows: every component is licensed" \ + "$(jq '[.components[] | select((.licenses // []) | length == 0)] | length' "$WSBOM")" "0" + + check "Windows: no self-entry survives" \ + "$(jq '[.components[] | select((.purl // "") == "")] | length' "$WSBOM")" "0" + + check "Windows: the toolchain runtime is declared" \ + "$(jq -r '[.components[] | select(.name == "mingw-w64-runtime" or .name == "libgcc") | .name] | sort | join(",")' "$WSBOM")" \ + "libgcc,mingw-w64-runtime" + + check "Windows: unixODBC is not merged in" \ + "$(jq '[.components[] | select(.name == "unixodbc")] | length' "$WSBOM")" "0" + + check "Windows: no absolute build path leaks" \ + "$(jq '[.. | strings | select(startswith("/home/") or startswith("/build/"))] | length' "$WSBOM")" "0" + + check "Windows: the artifact is the SBOM subject" \ + "$(jq -r '.metadata.component.name' "$WSBOM")" "stackable_odbc_trino.dll" +else + echo "SKIP Windows checks: DLL not built (cargo auditable build --locked --release --target x86_64-pc-windows-gnu)" +fi + +# --- the .mez --------------------------------------------------------------- +# The Power Query connector is M source in a zip, so syft finds nothing in it +# and there is no cargo graph to enrich. Its SBOM is built directly: the +# connector is the subject, and it has no dependencies. +MEZ="$REPO_ROOT/connector/bin/StackableTrinoODBC.mez" +if [ ! -f "$MEZ" ]; then + (cd "$REPO_ROOT/connector" && ./build.sh >/dev/null 2>&1) || true +fi + +if [ -f "$MEZ" ]; then + "$REPO_ROOT/packaging/sbom.sh" "$MEZ" "$WORK" >/dev/null + MSBOM="$WORK/StackableTrinoODBC.mez.cdx.json" + MSPDX="$WORK/StackableTrinoODBC.mez.spdx.json" + + check "mez: CycloneDX is written" "$([ -f "$MSBOM" ] && echo yes || echo no)" "yes" + check "mez: SPDX is written" "$([ -f "$MSPDX" ] && echo yes || echo no)" "yes" + + check "mez: the connector is the subject" \ + "$(jq -r '.metadata.component.name' "$MSBOM")" "StackableTrinoODBC.mez" + + check "mez: the subject carries a sha256" \ + "$(jq -r '.metadata.component.hashes[]? | select(.alg == "SHA-256") | .content' "$MSBOM" | tr -d '\n' | wc -c)" "64" + + # The version is read from the connector's own [Version = "..."], which + # release.toml keeps in step with the crate version. + check "mez: the subject version matches the .pq" \ + "$(jq -r '.metadata.component.version' "$MSBOM")" \ + "$(grep -oE '\[Version = "[^"]+"\]' "$REPO_ROOT/connector/StackableTrinoODBC.pq" | head -1 | sed -E 's/.*"(.*)".*/\1/')" + + # Zero is the honest answer, not a gap: the connector is pure M with no + # third-party dependencies. + check "mez: no components" "$(jq '.components | length' "$MSBOM")" "0" + + check "mez: no build path leaks" \ + "$(jq '[.. | strings | select(startswith("/home/") or startswith("/build/"))] | length' "$MSBOM")" "0" +else + echo "SKIP mez checks: connector/bin/StackableTrinoODBC.mez could not be built" +fi + +# An artifact built without cargo auditable must be refused, not silently turned +# into a near-empty SBOM. Strip the section to prove the guard fires. +cp "$SO" "$WORK/no-audit.so" +objcopy --remove-section=.dep-v0 "$WORK/no-audit.so" 2>/dev/null || true +check "an artifact without .dep-v0 is refused" \ + "$("$REPO_ROOT/packaging/sbom.sh" "$WORK/no-audit.so" "$WORK/refused" >/dev/null 2>&1 && echo ok || echo refused)" "refused" + +echo +if [ "$FAILURES" -eq 0 ]; then + echo "All checks passed." +else + echo "$FAILURES check(s) failed." + exit 1 +fi diff --git a/packaging/windows/configure-dsn.ps1 b/packaging/windows/configure-dsn.ps1 new file mode 100644 index 0000000..fc9f51c --- /dev/null +++ b/packaging/windows/configure-dsn.ps1 @@ -0,0 +1,971 @@ +<# +.SYNOPSIS + Create or edit a Stackable Trino ODBC data source. + +.DESCRIPTION + Presents a dialog covering the driver's whole connection-string surface and + writes the result as an ODBC data source. + + The write goes through the installer's SQLConfigDataSource, which calls the + driver's own ConfigDSN entry point, rather than writing the registry + directly. That keeps the driver in the loop and inherits whatever validation + it performs. + + This is also what the ODBC Data Source Administrator's "Add..." and + "Configure..." buttons display. Those load the driver's setup DLL and ask + it for a dialog; the driver's Backend::configure_dsn hook runs this script + with -Emit and writes the keywords it returns. Run the script directly to + get the same dialog without going through the Administrator. + +.PARAMETER Dsn + Data source to edit. Omitted, the dialog starts empty. + +.PARAMETER System + Start on System scope (HKLM) rather than User (HKCU). Needs elevation. + +.PARAMETER NoGui + Write the data source from -Set without displaying a dialog. Intended for + scripted installs and for testing the write path. + +.PARAMETER Set + Key/value pairs for -NoGui, keyed by connection-string keyword. + +.PARAMETER Emit + Display the dialog and print the resulting keywords to stdout as JSON + instead of writing a data source. Reads the keywords to pre-fill from + stdin, also as JSON. This is the mode the driver's ConfigDSN hook uses: + the driver, not this script, performs the write. + + Exit codes are the channel for the verdict, because stdout carries the + payload: 0 accepted, 2 cancelled, anything else a failure whose reason is + on stderr. + +.EXAMPLE + .\configure-dsn.ps1 + +.EXAMPLE + .\configure-dsn.ps1 -Dsn trino_prod + +.EXAMPLE + .\configure-dsn.ps1 -NoGui -Set @{ DSN='trino'; Host='trino.example.com' + Port='8443'; User='admin'; Catalog='tpcds' } +#> +[CmdletBinding()] +param( + [string]$Dsn, + [switch]$System, + [switch]$NoGui, + [hashtable]$Set, + [switch]$Emit, + [string]$DriverName = 'stackable_odbc_trino' +) + +Set-StrictMode -Version 2.0 +$ErrorActionPreference = 'Stop' + +# --------------------------------------------------------------------------- +# Field table +# --------------------------------------------------------------------------- +# The one place a connection-string keyword is named. Layout, the read path, +# the write path and validation are all generated from this, so adding a +# keyword is one entry here rather than an edit in four places. +# +# Key the connection-string keyword, lower case, matching the PARAM_ +# constants in src/backend/types/connect_params.rs. +# Type Text | Int | Bool | Enum | File | Secret | Pairs +# Pairs a name:value;name2:value2 list. Brace-wrapped in a connection +# string, bare in a data source; see Build-ConnectionString. +# Alias an equivalent keyword the driver also accepts. Shown once, written +# under Key. Recorded so the parser-vs-dialog test can account for it. +# +# `dsn_keys_match_the_connection_string_parser` in src/lib.rs fails the build +# if this list and the parser ever disagree. + +$script:Fields = @( + # --- Connection -------------------------------------------------------- + @{ Key='host'; Label='Host'; Tab='Connection'; Type='Text'; Required=$true + Help='Trino coordinator hostname.' } + @{ Key='port'; Label='Port'; Tab='Connection'; Type='Int'; Required=$true + Default='8443'; Help='Coordinator port.' } + @{ Key='protocol'; Label='Protocol'; Tab='Connection'; Type='Enum' + Values=@('https','http'); Default='https'; Help='Transport. Default https.' } + @{ Key='catalog'; Label='Catalog'; Tab='Connection'; Type='Text' + Help='Default catalog.' } + @{ Key='schema'; Label='Schema'; Tab='Connection'; Type='Text' + Help='Default schema.' } + @{ Key='source'; Label='Source'; Tab='Connection'; Type='Text' + Help='Query source Trino records and can route on.' } + @{ Key='clienttags'; Label='Client tags'; Tab='Connection'; Type='Text' + Help='Comma-separated tags, which select a resource group.' } + @{ Key='path'; Label='SQL path'; Tab='Connection'; Type='Text' + Help='Default path for resolving unqualified function names.' } + @{ Key='timezone'; Label='Time zone'; Tab='Connection'; Type='Text' + Help='IANA zone, e.g. Europe/Berlin. Unset leaves the coordinator''s.' } + @{ Key='locale'; Label='Locale'; Tab='Connection'; Type='Text' + Help='Locale for locale-dependent formatting.' } + @{ Key='clientinfo'; Label='Client info'; Tab='Connection'; Type='Text' + Help='Free-form metadata Trino records against the query.' } + @{ Key='tracetoken'; Label='Trace token'; Tab='Connection'; Type='Text' + Help='Correlation token Trino records against the query.' } + + # --- Authentication ---------------------------------------------------- + @{ Key='user'; Label='User'; Tab='Authentication'; Type='Text' + Help='Username. Optional under external authentication, where the identity provider supplies it.' } + @{ Key='password'; Label='Password'; Tab='Authentication'; Type='Secret' + Help='Password for Basic authentication.' } + @{ Key='accesstoken'; Label='Access token'; Tab='Authentication'; Type='Secret' + Alias='token'; Help='JWT bearer token.' } + # Label kept short: the field column is 160px and a longer one wraps into + # the row beneath it. The detail lives in the tooltip. + @{ Key='externalauthentication'; Label='External authentication' + Tab='Authentication'; Type='Bool' + Help='Trino''s interactive OAuth 2.0 login. Needs https, and excludes Password and Access token.' } + @{ Key='externalauthenticationtimeout'; Label='External auth timeout (s)' + Tab='Authentication'; Type='Int'; Default='300' + Help='Budget for one interactive login. Not bounded by the login timeout.' } + @{ Key='sessionuser'; Label='Session user'; Tab='Authentication'; Type='Text' + Help='User statements run as, while User still authenticates. Needs impersonation rights.' } + @{ Key='roles'; Label='Roles'; Tab='Authentication'; Type='Pairs' + Help='Authorisation role per catalog: catalog:role;catalog2:ALL' } + @{ Key='extracredentials'; Label='Extra credentials'; Tab='Authentication'; Type='Pairs' + Secret=$true; Help='Connector-level credentials: name:value;name2:value2' } + + # --- TLS --------------------------------------------------------------- + @{ Key='tlsverify'; Label='Verification'; Tab='TLS'; Type='Enum' + Values=@('full','ca','none'); Default='full'; Alias='sslverification' + Help='full verifies chain and hostname, ca verifies the chain only (requires a CA certificate), none verifies nothing.' } + @{ Key='certificate'; Label='CA certificate'; Tab='TLS'; Type='File' + Help='PEM CA certificate for server verification. Required by ca.' } + @{ Key='clientcertificate'; Label='Client certificate'; Tab='TLS'; Type='File' + Help='One PEM holding a client certificate chain followed by its PKCS#8 key, for mutual TLS.' } + + # --- Session ----------------------------------------------------------- + @{ Key='sessionproperties'; Label='Session properties'; Tab='Session'; Type='Pairs' + Help='name:value;name2:value2' } + @{ Key='resourceestimates'; Label='Resource estimates'; Tab='Session'; Type='Pairs' + Help='Scheduling hints: name:value;name2:value2' } + @{ Key='clientcapabilities'; Label='Client capabilities'; Tab='Session'; Type='Text' + Help='Comma-separated, on top of PARAMETRIC_DATETIME and PATH.' } + @{ Key='encoding'; Label='Spooling encoding'; Tab='Session'; Type='Enum' + Values=@('','json','json+zstd','json+lz4') + Help='Spooled query-data encoding. Unset returns every row inline.' } + + # --- Proxy ------------------------------------------------------------- + @{ Key='proxy'; Label='Proxy URL'; Tab='Proxy'; Type='Text' + Help='HTTP/HTTPS proxy for every request. Credentials in the URL are rejected.' } + @{ Key='proxyuser'; Label='Proxy user'; Tab='Proxy'; Type='Text' + Help='Proxy Basic username. Requires a proxy password.' } + @{ Key='proxypassword'; Label='Proxy password'; Tab='Proxy'; Type='Secret' + Help='Proxy Basic password.' } + + # --- Advanced ---------------------------------------------------------- + @{ Key='querytimeout'; Label='Query timeout (s)'; Tab='Advanced'; Type='Int' + Default='30'; Alias='logintimeout' + Help='Per-request HTTP timeout. Overridden by SQL_ATTR_CONNECTION_TIMEOUT when the application sets one.' } + @{ Key='disablecompression'; Label='Disable compression'; Tab='Advanced'; Type='Bool' + Help='Turn off response compression.' } + @{ Key='maxattempts'; Label='Max attempts'; Tab='Advanced'; Type='Int' + Help='Request retry budget. Unset leaves the client''s own default.' } + @{ Key='extraheaders'; Label='Extra headers'; Tab='Advanced'; Type='Pairs' + Secret=$true; Help='Extra HTTP headers: name:value;name2:value2' } +) + +$script:TabOrder = @('Connection','Authentication','TLS','Session','Proxy','Advanced') + +function Get-Field { param([string]$Key) $script:Fields | Where-Object { $_.Key -eq $Key } } +function Test-FieldSecret { + param($Field) + if ($Field.Type -eq 'Secret') { return $true } + if ($Field.Contains('Secret') -and $Field.Secret) { return $true } + return $false +} +function Get-FieldDefault { + param($Field) + if ($Field.Contains('Default')) { return $Field.Default } + return '' +} + +function ConvertTo-FieldValues { + <# + Normalise a caller-supplied keyword map onto the field table's own + keys: case folded, aliases resolved, DSN lifted out as the name. + + Shared by -NoGui and -Emit, the two paths whose input comes from a + caller rather than from the dialog, and so the only two that can be + handed a keyword the table does not carry. + + Unknown keywords are kept aside in Extra rather than rejected. -Emit + receives a data source's whole stored section, which carries keywords + this dialog does not model (Driver, and anything written by hand), and + returning fewer keywords than arrived would delete them. + -NoGui rejects them instead: there the map is something a person just + typed, so an unrecognised keyword is far more likely a typo than a + keyword worth preserving, and silently ignoring it would write a data + source missing the setting they asked for. + #> + param([hashtable]$Set, [switch]$KeepUnknown) + + $values = @{} + $extra = @{} + $name = '' + foreach ($k in $Set.Keys) { + $lk = "$k".ToLowerInvariant() + if ($lk -eq 'dsn') { $name = "$($Set[$k])"; continue } + $f = Get-Field $lk + if (-not $f) { + # Try the aliases before rejecting: a value lifted from a JDBC URL + # or an existing connection string should transfer unchanged. + $f = $script:Fields | Where-Object { $_.Contains('Alias') -and $_.Alias -eq $lk } + } + if (-not $f) { + if ($KeepUnknown) { $extra[$k] = "$($Set[$k])"; continue } + throw "Unknown connection-string keyword: $k" + } + $values[$f.Key] = "$($Set[$k])" + } + @{ Values = $values; Name = $name; Extra = $extra } +} + +# --------------------------------------------------------------------------- +# ODBC installer interop +# --------------------------------------------------------------------------- + +Add-Type @" +using System; +using System.Runtime.InteropServices; +using System.Text; + +public static class OdbcInstaller { + // BOOL, so 4 bytes: the default bool marshalling is correct here. + [DllImport("odbccp32.dll", CharSet = CharSet.Unicode, SetLastError = true)] + public static extern bool SQLConfigDataSourceW(IntPtr hwndParent, ushort fRequest, + string lpszDriver, string lpszAttributes); + + // RETCODE is SQLSMALLINT: 16 bits. Declaring this as bool reads the wrong + // width and loses the error record entirely. + [DllImport("odbccp32.dll", CharSet = CharSet.Unicode)] + public static extern short SQLInstallerErrorW(ushort iError, out int pfErrorCode, + StringBuilder lpszErrorMsg, ushort cbErrorMsgMax, out ushort pcbErrorMsg); + + [DllImport("odbccp32.dll", CharSet = CharSet.Unicode)] + public static extern int SQLGetPrivateProfileStringW(string lpszSection, string lpszEntry, + string lpszDefault, StringBuilder RetBuffer, int cbRetBuffer, string lpszFilename); + + [DllImport("odbccp32.dll")] + public static extern bool SQLSetConfigMode(ushort wConfigMode); +} +"@ -ErrorAction SilentlyContinue + +# ConfigDSN fRequest values, from odbcinst.h. +$script:ODBC_ADD_DSN = 1 +$script:ODBC_CONFIG_DSN = 2 +$script:ODBC_ADD_SYS_DSN = 4 +$script:ODBC_CONFIG_SYS_DSN = 5 +# SQLSetConfigMode values. +$script:ODBC_USER_DSN = 1 +$script:ODBC_SYSTEM_DSN = 2 + +function Test-Elevated { + $id = [Security.Principal.WindowsIdentity]::GetCurrent() + (New-Object Security.Principal.WindowsPrincipal $id).IsInRole( + [Security.Principal.WindowsBuiltInRole]::Administrator) +} + +function Get-InstallerErrors { + <# Drain the installer error buffer. Empty when the last call succeeded. #> + $out = @() + for ($i = 1; $i -le 8; $i++) { + $code = 0; $pcb = 0 + $sb = New-Object System.Text.StringBuilder 1024 + $rc = [OdbcInstaller]::SQLInstallerErrorW([uint16]$i, [ref]$code, $sb, [uint16]1024, [ref]$pcb) + # SQL_SUCCESS = 0, SQL_SUCCESS_WITH_INFO = 1; anything else ends the list. + if ($rc -ne 0 -and $rc -ne 1) { break } + $out += "[$code] $($sb.ToString())" + } + $out +} + +function Read-Dsn { + <# + Pre-fill from an existing data source. Returns a hashtable keyed by + connection-string keyword, holding only the keywords present. + #> + param([string]$Name, [bool]$IsSystem) + + $mode = if ($IsSystem) { $script:ODBC_SYSTEM_DSN } else { $script:ODBC_USER_DSN } + [void][OdbcInstaller]::SQLSetConfigMode([uint16]$mode) + + $values = @{} + foreach ($f in $script:Fields) { + $sb = New-Object System.Text.StringBuilder 4096 + $n = [OdbcInstaller]::SQLGetPrivateProfileStringW($Name, $f.Key, '', $sb, 4096, 'ODBC.INI') + if ($n -gt 0) { + $values[$f.Key] = $sb.ToString() + continue + } + # A data source written by hand may carry the alias instead. + if ($f.Contains('Alias')) { + $sb2 = New-Object System.Text.StringBuilder 4096 + $n2 = [OdbcInstaller]::SQLGetPrivateProfileStringW($Name, $f.Alias, '', $sb2, 4096, 'ODBC.INI') + if ($n2 -gt 0) { $values[$f.Key] = $sb2.ToString() } + } + } + [void][OdbcInstaller]::SQLSetConfigMode(0) + $values +} + +function Get-ExistingDsnNames { + param([bool]$IsSystem) + $hive = if ($IsSystem) { 'HKLM:' } else { 'HKCU:' } + $path = "$hive\SOFTWARE\ODBC\ODBC.INI\ODBC Data Sources" + if (-not (Test-Path $path)) { return @() } + $item = Get-Item $path + $item.GetValueNames() | Where-Object { $item.GetValue($_) -eq $DriverName } | Sort-Object +} + +function Write-Dsn { + <# + Write the data source through the driver's own ConfigDSN. + + Pairs values are written bare. The braces the five name:value keys need + belong to connection-string syntax, where `;` separates parameters; a + data source keeps each value in its own registry value, so a brace here + would be stored as part of the value. + #> + param([hashtable]$Values, [string]$Name, [bool]$IsSystem, [bool]$Replace) + + $pairs = @("DSN=$Name") + foreach ($f in $script:Fields) { + if (-not $Values.Contains($f.Key)) { continue } + $v = $Values[$f.Key] + if ([string]::IsNullOrEmpty($v)) { continue } + $pairs += "$($f.Key)=$v" + } + # ConfigDSN takes a doubly null-terminated list of keyword-value pairs. + $attributes = ($pairs -join "`0") + "`0" + + $request = if ($IsSystem) { + if ($Replace) { $script:ODBC_CONFIG_SYS_DSN } else { $script:ODBC_ADD_SYS_DSN } + } else { + if ($Replace) { $script:ODBC_CONFIG_DSN } else { $script:ODBC_ADD_DSN } + } + + [void](Get-InstallerErrors) # clear anything stale before the call + $ok = [OdbcInstaller]::SQLConfigDataSourceW([IntPtr]::Zero, [uint16]$request, + $DriverName, $attributes) + if (-not $ok) { + # @() around the call: PowerShell unrolls an empty array return to + # $null, and Set-StrictMode makes .Count on it an error. + $errs = @(Get-InstallerErrors) + $detail = if ($errs.Count) { $errs -join "`r`n" } else { 'the installer reported no detail' } + throw "Writing the data source failed:`r`n$detail" + } +} + +function Build-ConnectionString { + <# + A DSN-less connection string for the Test button, so a configuration is + proved before it is written. + + The five name:value;name2:value2 keys are brace-wrapped here and only + here: `;` separates connection-string parameters, so an unbraced value + would be truncated at its first `;` and every pair but the first would + be dropped as an unrecognised parameter. + #> + param([hashtable]$Values) + + $parts = @("Driver=$DriverName") + foreach ($f in $script:Fields) { + if (-not $Values.Contains($f.Key)) { continue } + $v = $Values[$f.Key] + if ([string]::IsNullOrEmpty($v)) { continue } + if ($f.Type -eq 'Pairs') { $v = '{' + $v.Trim('{','}') + '}' } + $parts += "$($f.Key)=$v" + } + ($parts -join ';') + ';' +} + +function Test-DsnConnection { + param([hashtable]$Values) + + $cs = Build-ConnectionString $Values + $conn = New-Object System.Data.Odbc.OdbcConnection $cs + # Bounds the attempt so an unreachable coordinator cannot hang the dialog. + $timeout = 15 + if ($Values.Contains('querytimeout') -and $Values['querytimeout']) { + [void][int]::TryParse($Values['querytimeout'], [ref]$timeout) + } + $conn.ConnectionTimeout = $timeout + try { + $conn.Open() + $cmd = $conn.CreateCommand() + # current_catalog and current_schema report what the session + # resolved to, which is not always what was typed: an unset Schema + # leaves the coordinator's default, and Trino answers with it. + $cmd.CommandText = 'SELECT version(), current_user, current_catalog, current_schema' + $r = $cmd.ExecuteReader() + $host_port = "$($Values['host']):$($Values['port'])" + $facts = @() + if ($r.Read()) { + # Null when the session is on no catalog at all, which is what an + # unset Catalog gives and is worth showing as such rather than blank. + $cat = if ($r.IsDBNull(2)) { '(none)' } else { "$($r[2])" } + $sch = if ($r.IsDBNull(3)) { '(none)' } else { "$($r[3])" } + # Objects rather than two-element arrays: PowerShell flattens a + # nested array literal, so a list of pairs collapses into a list of + # strings and indexing a "pair" then indexes into a *string*. + $facts = @( + [PSCustomObject]@{ Name = 'Host'; Value = $host_port } + [PSCustomObject]@{ Name = 'Version'; Value = "$($r[0])" } + [PSCustomObject]@{ Name = 'User'; Value = "$($r[1])" } + [PSCustomObject]@{ Name = 'Catalog'; Value = $cat } + [PSCustomObject]@{ Name = 'Schema'; Value = $sch } + ) + } else { + $facts = @([PSCustomObject]@{ Name = 'Host'; Value = $host_port }) + } + $r.Close() + return @{ Ok = $true; Message = 'Connected.'; Facts = $facts } + } catch { + return @{ Ok = $false; Message = $_.Exception.Message; Facts = @() } + } finally { + if ($conn.State -ne 'Closed') { $conn.Close() } + } +} + +function Show-ConnectionResult { + <# + Report a connection test. + + A success gets its own small form rather than a MessageBox, because the + facts are a two-column table and a MessageBox cannot align one: its + font is proportional, so padding a label with spaces lines nothing up. + A failure stays a MessageBox, because the driver's diagnostic is a + paragraph and not a table. + #> + param([hashtable]$Result) + + if (-not $Result.Ok) { + [void][System.Windows.Forms.MessageBox]::Show($Result.Message, + 'Connection failed', 'OK', 'Error') + return + } + + $dlg = New-Object System.Windows.Forms.Form + $dlg.Text = 'Connection succeeded' + $dlg.FormBorderStyle = 'FixedDialog' + $dlg.StartPosition = 'CenterScreen' + $dlg.MinimizeBox = $false + $dlg.MaximizeBox = $false + $dlg.ShowInTaskbar = $false + # Same reason the main dialog sets it under -Emit: this belongs to a + # separate process from the ODBC Administrator that is waiting on it. + $dlg.TopMost = [bool]$Emit + # The form sizes itself to the layout below. Positioning by hand from a + # panel's Right/Bottom does not work, because an AutoSize panel has not + # been measured yet at that point. That yields a window sized from stale + # bounds, invisible and modal, which locks its parent out of all input with + # nothing on screen to explain why. + $dlg.AutoSize = $true + $dlg.AutoSizeMode = 'GrowAndShrink' + $dlg.Padding = New-Object System.Windows.Forms.Padding(14) + + $root = New-Object System.Windows.Forms.TableLayoutPanel + $root.ColumnCount = 2 + $root.AutoSize = $true + $root.AutoSizeMode = 'GrowAndShrink' + $root.Dock = 'Fill' + + $icon = New-Object System.Windows.Forms.PictureBox + $icon.Image = [System.Drawing.SystemIcons]::Information.ToBitmap() + $icon.SizeMode = 'AutoSize' + $icon.Margin = New-Object System.Windows.Forms.Padding(4, 4, 14, 8) + $root.Controls.Add($icon, 0, 0) + + $head = New-Object System.Windows.Forms.Label + $head.Text = $Result.Message + $head.Font = New-Object System.Drawing.Font($dlg.Font, [System.Drawing.FontStyle]::Bold) + $head.AutoSize = $true + $head.Margin = New-Object System.Windows.Forms.Padding(0, 8, 0, 10) + $root.Controls.Add($head, 1, 0) + + # Two columns, so the values share a left edge whatever the labels measure. + $grid = New-Object System.Windows.Forms.TableLayoutPanel + $grid.ColumnCount = 2 + $grid.AutoSize = $true + $grid.AutoSizeMode = 'GrowAndShrink' + $grid.Margin = New-Object System.Windows.Forms.Padding(0) + foreach ($f in $Result.Facts) { + $k = New-Object System.Windows.Forms.Label + $k.Text = "$($f.Name):" + $k.AutoSize = $true + $k.Margin = New-Object System.Windows.Forms.Padding(0, 3, 16, 3) + $v = New-Object System.Windows.Forms.Label + $v.Text = $f.Value + $v.AutoSize = $true + $v.Margin = New-Object System.Windows.Forms.Padding(0, 3, 0, 3) + $grid.Controls.Add($k) + $grid.Controls.Add($v) + } + $root.Controls.Add($grid, 1, 1) + + $ok = New-Object System.Windows.Forms.Button + $ok.Text = 'OK' + $ok.Size = New-Object System.Drawing.Size(90, 28) + $ok.Anchor = 'Right' + $ok.Margin = New-Object System.Windows.Forms.Padding(0, 16, 0, 0) + $ok.DialogResult = [System.Windows.Forms.DialogResult]::OK + $root.Controls.Add($ok, 1, 2) + + $dlg.Controls.Add($root) + $dlg.AcceptButton = $ok + $dlg.CancelButton = $ok + + [void]$dlg.ShowDialog() + $dlg.Dispose() +} + +function Test-Values { + <# + Only the rules that are cheap and certain here. Everything else is left + to the driver, which is the authority and reports through + SQLGetDiagRec; duplicating its rules would let the two disagree. + #> + param([hashtable]$Values, [string]$Name) + + $problems = @() + if ([string]::IsNullOrWhiteSpace($Name)) { $problems += 'A data source name is required.' } + foreach ($f in $script:Fields | Where-Object { $_.Contains('Required') -and $_.Required }) { + if (-not $Values.Contains($f.Key) -or [string]::IsNullOrWhiteSpace($Values[$f.Key])) { + $problems += "$($f.Label) is required." + } + } + foreach ($f in $script:Fields | Where-Object { $_.Type -eq 'Int' }) { + if ($Values.Contains($f.Key) -and $Values[$f.Key]) { + $n = 0 + if (-not [int]::TryParse($Values[$f.Key], [ref]$n)) { + $problems += "$($f.Label) must be a whole number." + } + } + } + # ca verifies the chain without binding it to a hostname, which rustls + # permits only against an explicitly supplied trust store. + if ($Values.Contains('tlsverify') -and $Values['tlsverify'] -eq 'ca') { + if (-not $Values.Contains('certificate') -or -not $Values['certificate']) { + $problems += 'Verification "ca" requires a CA certificate.' + } + } + # Neither certificate is read at all over plain HTTP, and the driver refuses + # the combination rather than connecting unverified while a certificate path + # sits in the data source looking as though it applied. Caught here so the + # dialog says so while the fields are on screen, instead of at first connect. + # + # `tlsverify` is not checked: the driver tolerates it over http precisely + # because this dialog's Enum fields always write their default, so every + # data source it produces names one. + if ($Values.Contains('protocol') -and $Values['protocol'] -eq 'http') { + foreach ($pair in @(@('certificate', 'A CA certificate'), + @('clientcertificate', 'A client certificate'))) { + if ($Values.Contains($pair[0]) -and $Values[$pair[0]]) { + $problems += "$($pair[1]) cannot be used with Transport ""http""; there is no TLS session for it to apply to." + } + } + } + $problems +} + +# --------------------------------------------------------------------------- +# Headless path +# --------------------------------------------------------------------------- + +if ($NoGui) { + if (-not $Set) { throw '-NoGui requires -Set.' } + + $parsed = ConvertTo-FieldValues $Set + $values = $parsed.Values + $name = if ($parsed.Name) { $parsed.Name } else { $Dsn } + + $problems = @(Test-Values $values $name) + if ($problems.Count) { throw ($problems -join "`r`n") } + + if ($System -and -not (Test-Elevated)) { + throw 'A System data source needs an elevated session. Run as Administrator, or omit -System.' + } + $exists = @(Get-ExistingDsnNames ([bool]$System)) -contains $name + Write-Dsn $values $name ([bool]$System) $exists + $scope = if ($System) { 'System' } else { 'User' } + Write-Output "$scope data source '$name' written." + return +} + +# --------------------------------------------------------------------------- +# Emit mode input +# --------------------------------------------------------------------------- +# The keywords to pre-fill arrive on stdin as a JSON object. A pipe rather +# than a file because a Configure... payload carries the data source's stored +# secrets: the driver merges the whole ODBC.INI section in before calling, so +# a temp file here would put a password on disk for the life of the dialog. + +$script:EmitExtra = @{} +$script:EmitPrefill = @{} +$script:EmitValues = @{} +$script:EmitNameFixed = $false + +if ($Emit) { + $stdin = [Console]::In.ReadToEnd() + $incoming = @{} + if (-not [string]::IsNullOrWhiteSpace($stdin)) { + $json = $stdin | ConvertFrom-Json + foreach ($p in $json.PSObject.Properties) { $incoming[$p.Name] = "$($p.Value)" } + } + + $parsed = ConvertTo-FieldValues $incoming -KeepUnknown + $script:EmitExtra = $parsed.Extra + $script:EmitPrefill = $parsed.Values + if ($parsed.Name) { + $Dsn = $parsed.Name + # The spec: "if a data source name was passed to it, ConfigDSN displays + # that name but does not allow the user to change it." The driver's + # core enforces this on the map coming back, so an editable box here + # would only produce a failed call. + $script:EmitNameFixed = $true + } +} + +# --------------------------------------------------------------------------- +# Dialog +# --------------------------------------------------------------------------- + +Add-Type -AssemblyName System.Windows.Forms +Add-Type -AssemblyName System.Drawing +[System.Windows.Forms.Application]::EnableVisualStyles() + +$form = New-Object System.Windows.Forms.Form +$form.Text = 'Stackable Trino ODBC - Data Source' +$form.Size = New-Object System.Drawing.Size(620, 560) +$form.StartPosition = 'CenterScreen' +$form.FormBorderStyle = 'FixedDialog' +$form.MaximizeBox = $false +# The ODBC Administrator owns the foreground while it waits on ConfigDSN, and +# this dialog belongs to a separate process, so without this it opens behind +# the window that asked for it. +$form.TopMost = [bool]$Emit + +$tip = New-Object System.Windows.Forms.ToolTip +$tip.AutoPopDelay = 20000 + +# --- header: name and scope --- +$lblName = New-Object System.Windows.Forms.Label +$lblName.Text = 'Data source name' +$lblName.Location = New-Object System.Drawing.Point(12, 15) +$lblName.Size = New-Object System.Drawing.Size(130, 20) +$form.Controls.Add($lblName) + +$txtName = New-Object System.Windows.Forms.TextBox +$txtName.Location = New-Object System.Drawing.Point(148, 12) +$txtName.Size = New-Object System.Drawing.Size(200, 22) +$form.Controls.Add($txtName) + +$rbUser = New-Object System.Windows.Forms.RadioButton +$rbUser.Text = 'User' +$rbUser.Location = New-Object System.Drawing.Point(370, 11) +$rbUser.Size = New-Object System.Drawing.Size(60, 24) +$rbUser.Checked = -not $System +$form.Controls.Add($rbUser) + +$rbSystem = New-Object System.Windows.Forms.RadioButton +$rbSystem.Text = 'System' +$rbSystem.Location = New-Object System.Drawing.Point(434, 11) +$rbSystem.Size = New-Object System.Drawing.Size(80, 24) +$rbSystem.Checked = [bool]$System +$form.Controls.Add($rbSystem) + +$lblElev = New-Object System.Windows.Forms.Label +$lblElev.Location = New-Object System.Drawing.Point(370, 36) +$lblElev.Size = New-Object System.Drawing.Size(220, 18) +$lblElev.ForeColor = [System.Drawing.Color]::FromArgb(160, 90, 0) +if (-not (Test-Elevated)) { + $lblElev.Text = 'System needs an elevated session' + $rbSystem.Enabled = $false + if ($System) { $rbUser.Checked = $true } +} +$form.Controls.Add($lblElev) + +# Under -Emit the driver performs the write, and the Administrator has already +# chosen the scope and set the installer's config mode accordingly. Offering a +# choice the dialog cannot honour would be a lie, so the radios go away. +if ($Emit) { + $rbUser.Visible = $false + $rbSystem.Visible = $false + $lblElev.Visible = $false +} +if ($script:EmitNameFixed) { $txtName.ReadOnly = $true } + +# --- tabs, built from the field table --- +$tabs = New-Object System.Windows.Forms.TabControl +$tabs.Location = New-Object System.Drawing.Point(12, 62) +$tabs.Size = New-Object System.Drawing.Size(580, 400) +$form.Controls.Add($tabs) + +$script:Controls = @{} +$script:SaveSecret = @{} + +foreach ($tabName in $script:TabOrder) { + $page = New-Object System.Windows.Forms.TabPage + $page.Text = $tabName + $page.AutoScroll = $true + + $y = 14 + foreach ($f in $script:Fields | Where-Object { $_.Tab -eq $tabName }) { + $label = New-Object System.Windows.Forms.Label + $label.Text = $f.Label + $label.Location = New-Object System.Drawing.Point(12, ($y + 3)) + $label.Size = New-Object System.Drawing.Size(160, 20) + $page.Controls.Add($label) + + $ctl = $null + switch ($f.Type) { + 'Bool' { + $ctl = New-Object System.Windows.Forms.CheckBox + $ctl.Location = New-Object System.Drawing.Point(178, $y) + $ctl.Size = New-Object System.Drawing.Size(24, 22) + } + 'Enum' { + $ctl = New-Object System.Windows.Forms.ComboBox + $ctl.DropDownStyle = 'DropDownList' + $ctl.Location = New-Object System.Drawing.Point(178, $y) + $ctl.Size = New-Object System.Drawing.Size(180, 22) + foreach ($v in $f.Values) { [void]$ctl.Items.Add($v) } + $ctl.SelectedItem = (Get-FieldDefault $f) + } + 'File' { + $ctl = New-Object System.Windows.Forms.TextBox + $ctl.Location = New-Object System.Drawing.Point(178, $y) + $ctl.Size = New-Object System.Drawing.Size(280, 22) + $browse = New-Object System.Windows.Forms.Button + $browse.Text = 'Browse...' + $browse.Location = New-Object System.Drawing.Point(464, ($y - 1)) + $browse.Size = New-Object System.Drawing.Size(80, 24) + $target = $ctl + $browse.Add_Click({ + $dlg = New-Object System.Windows.Forms.OpenFileDialog + $dlg.Filter = 'PEM files (*.pem;*.crt)|*.pem;*.crt|All files (*.*)|*.*' + if ($dlg.ShowDialog() -eq 'OK') { $target.Text = $dlg.FileName } + }.GetNewClosure()) + $page.Controls.Add($browse) + } + default { + $ctl = New-Object System.Windows.Forms.TextBox + $ctl.Location = New-Object System.Drawing.Point(178, $y) + if (Test-FieldSecret $f) { + # Narrower, to leave room for the Save box beside it. + $ctl.Size = New-Object System.Drawing.Size(286, 22) + $ctl.UseSystemPasswordChar = $true + } else { + $ctl.Size = New-Object System.Drawing.Size(366, 22) + } + $ctl.Text = (Get-FieldDefault $f) + } + } + + # A data source keeps its values as plain registry values, so a saved + # secret is stored unencrypted, and a System data source puts it in + # HKLM where every local user can read it. Off by default, so the + # application supplies the secret at connect time unless the person + # configuring it asks otherwise. + if (Test-FieldSecret $f) { + $save = New-Object System.Windows.Forms.CheckBox + $save.Text = 'Save' + $save.Location = New-Object System.Drawing.Point(470, ($y + 1)) + $save.Size = New-Object System.Drawing.Size(70, 22) + $save.Checked = $false + $tip.SetToolTip($save, 'Store this value in the data source. It is written unencrypted.') + $page.Controls.Add($save) + $script:SaveSecret[$f.Key] = $save + } + + if ($f.Contains('Help')) { $tip.SetToolTip($ctl, $f.Help) } + $page.Controls.Add($ctl) + $script:Controls[$f.Key] = $ctl + $y += 30 + } + + [void]$tabs.TabPages.Add($page) +} + +function Get-FormValues { + <# + .PARAMETER IncludeUnsavedSecrets + Include secrets whose Save box is clear. Test connection needs + them, since the point of testing before writing is to try a value + you are not going to store. The write path must not. + #> + param([switch]$IncludeUnsavedSecrets) + + $values = @{} + foreach ($f in $script:Fields) { + $ctl = $script:Controls[$f.Key] + $v = switch ($f.Type) { + 'Bool' { if ($ctl.Checked) { 'true' } else { '' } } + 'Enum' { if ($null -eq $ctl.SelectedItem) { '' } else { "$($ctl.SelectedItem)" } } + default { $ctl.Text } + } + if ((Test-FieldSecret $f) -and -not $IncludeUnsavedSecrets) { + if (-not $script:SaveSecret[$f.Key].Checked) { continue } + } + if (-not [string]::IsNullOrEmpty($v)) { $values[$f.Key] = $v } + } + $values +} + +function Set-FormValues { + param([hashtable]$Values) + foreach ($f in $script:Fields) { + if (-not $Values.Contains($f.Key)) { continue } + $ctl = $script:Controls[$f.Key] + $v = $Values[$f.Key] + switch ($f.Type) { + 'Bool' { $ctl.Checked = ($v -match '^(?i:true|1|yes)$') } + 'Enum' { if ($ctl.Items.Contains($v)) { $ctl.SelectedItem = $v } } + default { $ctl.Text = $v } + } + # A secret that is already in the data source is already saved, so + # leaving the box clear would silently drop it on the next OK. + if (Test-FieldSecret $f) { $script:SaveSecret[$f.Key].Checked = $true } + } +} + +# --- buttons --- +$lblSecrets = New-Object System.Windows.Forms.Label +$lblSecrets.Text = 'Secrets are only stored when "Save" is ticked, and are written unencrypted.' +$lblSecrets.Location = New-Object System.Drawing.Point(12, 506) +$lblSecrets.Size = New-Object System.Drawing.Size(580, 18) +$lblSecrets.ForeColor = [System.Drawing.Color]::FromArgb(110, 110, 110) +$form.Controls.Add($lblSecrets) + +$btnTest = New-Object System.Windows.Forms.Button +$btnTest.Text = 'Test connection' +$btnTest.Location = New-Object System.Drawing.Point(12, 474) +$btnTest.Size = New-Object System.Drawing.Size(130, 30) +$btnTest.Add_Click({ + $values = Get-FormValues -IncludeUnsavedSecrets + $problems = @(Test-Values $values $txtName.Text) + if ($problems.Count) { + [void][System.Windows.Forms.MessageBox]::Show(($problems -join "`r`n"), + 'Incomplete', 'OK', 'Warning') + return + } + # An interactive login cannot be driven from here, so the button says so + # rather than attempting one. This button connects through .NET's ODBC + # provider, which calls SQLDriverConnectW with SQL_DRIVER_NOPROMPT, and the + # driver refuses to show a login URL under it. Measured against the live + # driver, the attempt returns 28000 "ExternalAuthentication needs to show a + # login URL, and this connection was made with SQL_DRIVER_NOPROMPT". That is + # correct and a good diagnostic, but under a "Connection failed" heading it + # reads as the settings being wrong, which they are not. + if ($values.Contains('externalauthentication') -and + $values['externalauthentication'] -match '^(?i:true|1|yes)$') { + [void][System.Windows.Forms.MessageBox]::Show( + ("External authentication cannot be tested from this dialog.`r`n`r`n" + + "Testing connects through .NET's ODBC provider, which tells the " + + "driver that no prompt may be shown, so the login URL this data " + + "source needs can never be opened here.`r`n`r`n" + + "Save the data source and connect from an application that permits " + + "prompting; the driver opens a browser then. The other settings on " + + "this page have been checked and are complete."), + 'Cannot be tested here', 'OK', 'Information') + return + } + $form.Cursor = [System.Windows.Forms.Cursors]::WaitCursor + $btnTest.Enabled = $false + # One catch around the whole thing: WinForms swallows an exception thrown + # from a handler, so anything uncaught here leaves the button looking as + # though it did nothing at all. + try { + try { $result = Test-DsnConnection $values } + finally { $form.Cursor = [System.Windows.Forms.Cursors]::Default; $btnTest.Enabled = $true } + Show-ConnectionResult $result + } catch { + [void][System.Windows.Forms.MessageBox]::Show($_.Exception.ToString(), + 'Could not test the connection', 'OK', 'Error') + } +}) +$form.Controls.Add($btnTest) + +$btnOk = New-Object System.Windows.Forms.Button +$btnOk.Text = 'OK' +$btnOk.Location = New-Object System.Drawing.Point(406, 474) +$btnOk.Size = New-Object System.Drawing.Size(90, 30) +$btnOk.Add_Click({ + $values = Get-FormValues + $problems = @(Test-Values $values $txtName.Text) + if ($problems.Count) { + [void][System.Windows.Forms.MessageBox]::Show(($problems -join "`r`n"), + 'Incomplete', 'OK', 'Warning') + return + } + if (-not $Emit) { + $isSystem = $rbSystem.Checked + $exists = @(Get-ExistingDsnNames $isSystem) -contains $txtName.Text + try { + Write-Dsn $values $txtName.Text $isSystem $exists + } catch { + [void][System.Windows.Forms.MessageBox]::Show($_.Exception.Message, + 'Could not write the data source', 'OK', 'Error') + return + } + } + # The handler is a scriptblock with its own scope, and -Emit needs these + # after ShowDialog returns. + $script:EmitValues = $values + $form.DialogResult = [System.Windows.Forms.DialogResult]::OK + $form.Close() +}) +$form.Controls.Add($btnOk) + +$btnCancel = New-Object System.Windows.Forms.Button +$btnCancel.Text = 'Cancel' +$btnCancel.Location = New-Object System.Drawing.Point(502, 474) +$btnCancel.Size = New-Object System.Drawing.Size(90, 30) +$btnCancel.Add_Click({ $form.DialogResult = [System.Windows.Forms.DialogResult]::Cancel; $form.Close() }) +$form.Controls.Add($btnCancel) +$form.CancelButton = $btnCancel + +# --- pre-fill when editing --- +if ($Dsn) { $txtName.Text = $Dsn } +if ($Emit) { + # The driver has already merged the data source's stored keywords in, so + # reading ODBC.INI again here would only be able to disagree with it. + if ($script:EmitPrefill.Count) { Set-FormValues $script:EmitPrefill } +} elseif ($Dsn) { + $existing = Read-Dsn $Dsn ([bool]$System) + if ($existing.Count) { Set-FormValues $existing } +} + +$result = $form.ShowDialog() + +if (-not $Emit) { + if ($result -eq [System.Windows.Forms.DialogResult]::OK) { + $scope = if ($rbSystem.Checked) { 'System' } else { 'User' } + Write-Output "$scope data source '$($txtName.Text)' written." + } + return +} + +# --- emit mode: the verdict is the exit code, the payload is stdout --- +if ($result -ne [System.Windows.Forms.DialogResult]::OK) { + # Cancelled. The driver returns Ok(None) and ConfigDSN posts no installer + # error, because nothing failed. + exit 2 +} + +$out = [ordered]@{ DSN = $txtName.Text } +# Keywords the dialog does not model are returned exactly as they arrived. On +# a Configure... this is the whole rest of the data source's section, and +# dropping them would delete settings the user never touched. +foreach ($k in $script:EmitExtra.Keys) { $out[$k] = $script:EmitExtra[$k] } +foreach ($f in $script:Fields) { + if ($script:EmitValues.Contains($f.Key)) { $out[$f.Key] = $script:EmitValues[$f.Key] } +} +[Console]::Out.Write(($out | ConvertTo-Json -Compress -Depth 3)) +exit 0 diff --git a/packaging/windows/install.bat b/packaging/windows/install.bat new file mode 100644 index 0000000..fbefae3 --- /dev/null +++ b/packaging/windows/install.bat @@ -0,0 +1,77 @@ +@echo off +rem Install the Stackable Trino ODBC driver on Windows. +rem Must be run from an Administrator Command Prompt. +setlocal + +set "INSTALL_DIR=%ProgramFiles%\Stackable\ODBC" +set "DRIVER_DLL=stackable_odbc_trino.dll" + +if not exist "%~dp0%DRIVER_DLL%" ( + echo ERROR: %DRIVER_DLL% not found next to install.bat. + exit /b 1 +) + +rem The driver's ConfigDSN runs this script to display its setup dialog, so the +rem ODBC Data Source Administrator's "Add..." button needs it installed +rem alongside the DLL. Checked here rather than after the copy so a missing +rem file is reported before the driver is registered. +if not exist "%~dp0configure-dsn.ps1" ( + echo ERROR: configure-dsn.ps1 not found next to install.bat. + echo The driver needs it for the ODBC Administrator's "Add..." dialog. + exit /b 1 +) + +if not exist "%INSTALL_DIR%" mkdir "%INSTALL_DIR%" + +copy /Y "%~dp0%DRIVER_DLL%" "%INSTALL_DIR%\" >nul +if errorlevel 1 ( + echo ERROR: Failed to copy DLL. Are you running as Administrator? + exit /b 1 +) + +rem Copied before the driver is registered, not after: registering is what makes +rem the ODBC Administrator's "Add..." button reach ConfigDSN, and ConfigDSN runs +rem this script. Doing it the other way round leaves a window in which the +rem button is live and its dialog is missing. +copy /Y "%~dp0configure-dsn.ps1" "%INSTALL_DIR%\" >nul +if errorlevel 1 ( + echo ERROR: Failed to copy configure-dsn.ps1. + exit /b 1 +) + +odbcconf.exe /A {INSTALLDRIVER "stackable_odbc_trino|Driver=%INSTALL_DIR%\%DRIVER_DLL%|Setup=%INSTALL_DIR%\%DRIVER_DLL%|"} + +rem odbcconf reports success whether or not the action it was given succeeded, +rem so its exit code proves nothing and the registry is asked instead. Without +rem this, a failed registration prints "installed successfully" and the driver +rem is then simply absent from the ODBC Administrator with no explanation -- +rem which is the first symptom README.md's troubleshooting section covers. +rem +rem integration-tests/windows/windows_test.py works around the same unreliability +rem by force-writing these values after its own odbcconf call. +reg query "HKLM\SOFTWARE\ODBC\ODBCINST.INI\stackable_odbc_trino" /v Driver >nul 2>&1 +if errorlevel 1 ( + echo ERROR: Driver registration failed: odbcconf.exe did not create + echo HKLM\SOFTWARE\ODBC\ODBCINST.INI\stackable_odbc_trino + echo Are you running from an Administrator Command Prompt? + exit /b 1 +) + +rem The driver is registered only if it is also listed here; the ODBC +rem Administrator reads this value to populate its Drivers tab. +reg query "HKLM\SOFTWARE\ODBC\ODBCINST.INI\ODBC Drivers" /v "stackable_odbc_trino" >nul 2>&1 +if errorlevel 1 ( + echo ERROR: Driver registration is incomplete: stackable_odbc_trino is missing + echo from HKLM\SOFTWARE\ODBC\ODBCINST.INI\ODBC Drivers + exit /b 1 +) + +echo Stackable Trino ODBC driver installed to %INSTALL_DIR%. +echo Verify with: ODBC Data Source Administrator (odbcad32.exe) +echo. +echo To create a DSN, use the ODBC Data Source Administrator's "Add..." button, +echo or run the same dialog directly: +echo powershell -ExecutionPolicy Bypass -File "%INSTALL_DIR%\configure-dsn.ps1" +echo See README.md for the odbcconf and registry alternatives. +echo For Power BI users: see README.md for StackableTrinoODBC.mez installation steps. +endlocal diff --git a/packaging/windows/uninstall.bat b/packaging/windows/uninstall.bat new file mode 100644 index 0000000..99ef7cd --- /dev/null +++ b/packaging/windows/uninstall.bat @@ -0,0 +1,35 @@ +@echo off +rem Uninstall the Stackable Trino ODBC driver on Windows. +rem Must be run from an Administrator Command Prompt (cmd.exe). +setlocal + +set "INSTALL_DIR=%ProgramFiles%\Stackable\ODBC" +set "DRIVER_DLL=stackable_odbc_trino.dll" +set "DIALOG_SCRIPT=configure-dsn.ps1" +set "DRIVER_NAME=stackable_odbc_trino" + +rem Remove the driver registration from the registry. +reg delete "HKLM\SOFTWARE\ODBC\ODBCINST.INI\%DRIVER_NAME%" /f >nul 2>&1 +reg delete "HKLM\SOFTWARE\ODBC\ODBCINST.INI\ODBC Drivers" /v "%DRIVER_NAME%" /f >nul 2>&1 + +rem Both files install.bat placed, not just the driver: it copies the dialog +rem script beside the DLL and refuses to run without it, so leaving the script +rem behind leaves half an installation. +if exist "%INSTALL_DIR%\%DRIVER_DLL%" del /F /Q "%INSTALL_DIR%\%DRIVER_DLL%" +if exist "%INSTALL_DIR%\%DIALOG_SCRIPT%" del /F /Q "%INSTALL_DIR%\%DIALOG_SCRIPT%" + +rem Remove the directories this installer created, innermost first, and only +rem when empty: rmdir without /S fails on a non-empty directory, which is the +rem wanted behaviour if an administrator put something else in there. +rem %ProgramFiles%\Stackable is removed too, but only if this was the last +rem Stackable product on the machine. +if exist "%INSTALL_DIR%" rmdir "%INSTALL_DIR%" >nul 2>&1 +if exist "%ProgramFiles%\Stackable" rmdir "%ProgramFiles%\Stackable" >nul 2>&1 + +echo Stackable Trino ODBC driver uninstalled. +echo. +echo Note: StackableTrinoODBC.mez in Power BI's Custom Connectors folder must be removed manually. +echo If you created any DSNs, remove them with: +echo reg delete "HKCU\SOFTWARE\ODBC\ODBC.INI\YourDsnName" /f +echo reg delete "HKCU\SOFTWARE\ODBC\ODBC.INI\ODBC Data Sources" /v "YourDsnName" /f +endlocal diff --git a/release.toml b/release.toml new file mode 100644 index 0000000..359e9a5 --- /dev/null +++ b/release.toml @@ -0,0 +1,80 @@ +# cargo-release configuration for stackable-odbc-trino. +# +# cargo-release is dry-run by default; --execute is required to mutate state. +# Publication to crates.io is deliberately disabled: this configuration only +# bumps the version, rewrites CHANGELOG.md and packaging/README.md, commits, +# tags and pushes. The v{version} tag then triggers +# .github/workflows/release.yaml, which builds both binaries, assembles the +# release archives and publishes the GitHub Release. + +publish = false +push = true +# Signing is requested here rather than left to the releaser's `tag.gpgsign` / +# `commit.gpgsign`, so a release tag is signed regardless of whose machine it +# is cut on — and fails loudly instead of silently producing an unsigned tag +# when no signing key is configured. +sign-tag = true +sign-commit = true +consolidate-commits = false + +# Day-to-day work happens on feature branches. Without this, a stray +# `--execute` would tag whichever branch happened to be checked out. +allow-branch = ["main"] + +# No git hooks are installed in .git/hooks, so pre-commit does not run on the +# commit cargo-release makes. Run it explicitly instead. This executes against +# the pre-bump tree, so it validates code rather than version strings. +pre-release-hook = ["pre-commit", "run", "--all-files"] + +# Both follow the conventional-commit style used throughout this repo's +# history, rather than cargo-release's defaults. +pre-release-commit-message = "chore(release): version {{version}}" +tag-message = "chore(release): version {{version}}" + +[[pre-release-replacements]] +file = "CHANGELOG.md" +search = "## \\[Unreleased\\]" +replace = "## [Unreleased]\n\n## [{{version}}] — {{date}}" +exactly = 1 + +# The next two rules maintain the link-reference footer. Exactly one of them +# applies to any given release, and the order matters: replacements run +# sequentially, so the compare-form rule must come first. Reversed, the +# `commits/HEAD` rule would write a `compare/...` link that the compare-form +# rule then matched in the same pass, emitting the tag link twice. + +# Subsequent-release case: rewrite the `[Unreleased]` compare link to point at +# the new version, and prepend a `[{version}]` tag link. With `min = 0`, this +# rule is a silent no-op on the first release, when the footer still holds the +# `commits/HEAD` placeholder; after that it matches on every release. +[[pre-release-replacements]] +file = "CHANGELOG.md" +search = "\\[Unreleased\\]: https://github.com/stackabletech/stackable-odbc-trino/compare/v[0-9]+\\.[0-9]+\\.[0-9]+\\.\\.\\.HEAD" +replace = "[Unreleased]: https://github.com/stackabletech/stackable-odbc-trino/compare/v{{version}}...HEAD\n[{{version}}]: https://github.com/stackabletech/stackable-odbc-trino/releases/tag/v{{version}}" +min = 0 + +# First-release case: the initial placeholder points at `commits/HEAD`. +# Fires exactly once — on the first release — after which the line is in the +# `compare/...` form the rule above owns, and this one never matches again. +[[pre-release-replacements]] +file = "CHANGELOG.md" +search = "\\[Unreleased\\]: https://github.com/stackabletech/stackable-odbc-trino/commits/HEAD" +replace = "[Unreleased]: https://github.com/stackabletech/stackable-odbc-trino/compare/v{{version}}...HEAD\n[{{version}}]: https://github.com/stackabletech/stackable-odbc-trino/releases/tag/v{{version}}" +min = 0 + +# packaging/README.md has no rule, deliberately. It carries no release version: +# the build command takes its version from Cargo.toml, and the artefact names it +# lists use a `<version>` placeholder because they document a naming scheme +# rather than one release. The only version literals left in that file pin syft +# and cargo-auditable, which track their own upstreams and must survive a +# release untouched. + +# Bumping it here rather than in connector/build.sh keeps the checked-in file +# truthful: a .mez built from a working tree reports the same version as the +# .so built beside it, which is what a bug report quotes. +# `connector_version_matches_the_crate` fails the build if the two ever part. +[[pre-release-replacements]] +file = "connector/StackableTrinoODBC.pq" +search = "\\[Version = \"[0-9]+\\.[0-9]+\\.[0-9]+\"\\]" +replace = "[Version = \"{{version}}\"]" +exactly = 1 diff --git a/release/release.sh b/release/release.sh new file mode 100755 index 0000000..1e44810 --- /dev/null +++ b/release/release.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# Convenience wrapper around cargo-release. +# +# Usage: +# release/release.sh patch # dry-run a patch release +# release/release.sh minor # dry-run a minor release +# release/release.sh major # dry-run a major release +# release/release.sh minor --execute # actually perform the release +# +# cargo-release is dry-run by default; --execute is required to mutate state. +# See release.toml for what a release rewrites (CHANGELOG.md, README.md) and +# for the `main`-only branch restriction. +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "usage: release.sh <patch|minor|major> [--execute]" >&2 + exit 2 +fi + +BUMP="$1" +shift + +exec cargo release "$BUMP" "$@" diff --git a/renovate.json b/renovate.json new file mode 100644 index 0000000..41d5797 --- /dev/null +++ b/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "local>stackabletech/.github:renovate-config" + ] +} diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..f92020c --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,3 @@ +[toolchain] +channel = "1.95.0" +profile = "default" diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..01e2232 --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,6 @@ +style_edition = "2024" +imports_granularity = "Crate" +group_imports = "StdExternalCrate" +reorder_impl_items = true +use_field_init_shorthand = true +format_code_in_doc_comments = true diff --git a/src/backend.rs b/src/backend.rs new file mode 100644 index 0000000..558b106 --- /dev/null +++ b/src/backend.rs @@ -0,0 +1,4343 @@ +//! Core type definitions for the Trino backend ([`TrinoBackend`], +//! [`TrinoConnection`], [`TrinoStatement`]) plus `connect`, `disconnect`, +//! `end_tran`, error mapping, and the thin [`Backend`] delegation layer. +//! Statement execution, catalog metadata, `SQLGetInfo`, and parameter binding +//! live in the submodules. + +use std::borrow::Cow; +use std::collections::HashMap; +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::Duration; + +use snafu::Snafu; +use stackable_odbc_core::types::QueryTimeout; +use stackable_odbc_core::{ + backend::Backend, + errors::OdbcError, + prompt::Prompter, + setup::{ConfigRequest, SetupError}, + types::{ + ColumnDescriptor, ColumnPrivilegeRow, ColumnPrivilegesQuery, ColumnRow, ColumnValue, + ColumnsQuery, ConnectParams, CursorBehavior, ExecuteOutcome, ForeignKeyRow, + ForeignKeysQuery, InfoValue, ParamDescriptor, PrimaryKeyRow, PrimaryKeysQuery, + ProcedureColumnRow, ProcedureColumnsQuery, ProcedureRow, ProceduresQuery, SQL_CB_NULL, + SQL_CN_ANY, SQL_FN_CVT_CAST, SQL_FN_TSI_DAY, SQL_FN_TSI_HOUR, SQL_FN_TSI_MINUTE, + SQL_FN_TSI_MONTH, SQL_FN_TSI_QUARTER, SQL_FN_TSI_SECOND, SQL_FN_TSI_WEEK, SQL_FN_TSI_YEAR, + SQL_GB_GROUP_BY_CONTAINS_SELECT, SQL_IC_LOWER, SQL_NC_END, SQL_NNC_NON_NULL, + SQL_SQ_COMPARISON, SQL_SQ_CORRELATED_SUBQUERIES, SQL_SQ_EXISTS, SQL_SQ_IN, + SQL_SQ_QUANTIFIED, SQL_TC_DML, SQL_TXN_READ_UNCOMMITTED, SQL_U_UNION, SQL_U_UNION_ALL, + SpecialColumnRow, SpecialColumnsQuery, SqlState, StatisticsQuery, StatisticsRow, + TablePrivilegeRow, TablePrivilegesQuery, TableRow, TablesQuery, TypeInfoRow, ValueWarning, + format_odbc_version, parse_dotted_version, + }, +}; +use trino_rust_client::{ + Client, ClientBuilder, TlsVerification, Trino, + auth::{Auth, OAuth2Config}, + proxy::Proxy, + ssl::Ssl, +}; + +use crate::backend::prompt::{BrowserPrompter, ClientRedirect}; + +mod describe_param; +mod execute; +mod prompt; +mod setup; + +/// The request timeout used for `SQL_ATTR_CONNECTION_TIMEOUT = 0`, the spec's +/// "there is no timeout". +/// +/// `ClientBuilder::client_request_timeout` takes a `Duration` and reaches +/// `reqwest::ClientBuilder::timeout`, neither of which can express "none", so +/// the value has to stand in for it. `u32::MAX` seconds is roughly 136 years: +/// long enough that no application can tell it from waiting forever, and small +/// enough that adding it to an `Instant` cannot overflow. +const NO_TIMEOUT: Duration = Duration::from_secs(u32::MAX as u64); + +/// The per-request timeout to build the HTTP client with. +/// +/// `SQL_ATTR_CONNECTION_TIMEOUT` bounds "any request on the connection", which +/// is exactly what the client's per-request timeout is, so it wins over the +/// `QueryTimeout` connection-string key when the application set one: an +/// attribute set through the API is the more specific instruction, and the key +/// stays the default for everything that sets nothing. +/// +/// `Some(0)` is the spec's "there is no timeout" and must not be read as +/// unset, which would reimpose a 30-second cap on an application that +/// explicitly asked for none. See [`NO_TIMEOUT`] for why it is a duration +/// rather than an absence. +fn request_timeout(connection_timeout: Option<u32>, from_connection_string: Duration) -> Duration { + match connection_timeout { + Some(0) => NO_TIMEOUT, + Some(secs) => Duration::from_secs(u64::from(secs)), + // The key sets the same thing as the attribute, so its `0` means the + // same thing. Passed through as a zero `Duration` it reaches reqwest as + // a request that expires before it is sent, failing every query on the + // connection, which is not what an operator writing `QueryTimeout=0` + // can have meant. + None if from_connection_string.is_zero() => NO_TIMEOUT, + None => from_connection_string, + } +} + +/// The deadline to bound the login round trip with, if any. +/// +/// `Some(0)` is the spec's "the timeout is disabled and a connection attempt +/// will wait indefinitely", which is what no deadline already does, so it +/// collapses onto the unset case. +fn login_deadline(login_timeout: Option<u32>) -> Option<Duration> { + match login_timeout { + Some(0) | None => None, + Some(secs) => Some(Duration::from_secs(u64::from(secs))), + } +} + +/// The name to report as `SQL_USER_NAME`, which the spec defines as "the name +/// used in a particular database, which can be different from the login name". +/// +/// The two differ here. Under `ExternalAuthentication` there is no `User` at +/// all, since `connect` calls `ClientBuilder::without_user`, and the +/// coordinator derives the identity from the token: the connection string names +/// nobody while the session runs as somebody. So `probed`, Trino's own +/// `current_user` as read by [`probe_session`], wins whenever it is available, +/// for the reason [`TrinoBackend::current_catalog`] reads the session. +/// +/// Only a failed probe reaches the fallbacks, ordered by how close each is to +/// the question. `SessionUser` comes first: a connection that carried one and +/// still succeeded is one whose impersonation Trino permitted, so the session +/// runs as that name, while `User` merely authenticated. The empty string is +/// the spec's "not available" for a string info type, reachable only when a +/// failed probe meets an `ExternalAuthentication` connection that named no +/// user. +/// +/// `SessionUser` cannot be demonstrated against the test stack: its default +/// access control refuses the connect with `Access Denied: User admin cannot +/// impersonate user analyst`, the same rule +/// `integration-tests/suites/test_oauth.py` measures for a disagreeing `User`. +fn session_user_name( + probed: Option<&str>, + session_user: Option<&str>, + user: Option<&str>, +) -> String { + probed + .or(session_user) + .or(user) + .unwrap_or_default() + .to_string() +} +// `pub(crate)` only under `cfg(test)`: the FFI integration tests +// (`ffi_integration_tests.rs`, a sibling of this module under `lib.rs`) need to +// reach the `TRINO_*` capability bitmap constants declared in `info`. Non-test +// callers of this module are descendants of `backend` and can already see a +// plain private `mod info`. +#[cfg(test)] +pub(crate) mod info; +#[cfg(not(test))] +mod info; +mod metadata; +mod params; +mod types; + +/// The interval units Trino's `date_add` / `date_diff` accept, reported for +/// both `SQL_TIMEDATE_ADD_INTERVALS` and `SQL_TIMEDATE_DIFF_INTERVALS`. +/// +/// Kept in step with `crate::escape_dialect::trino_interval_unit`, which is +/// what turns each of these into the quoted unit Trino wants; +/// `advertised_intervals_are_all_rewritable` asserts the two agree. +pub(crate) const TRINO_TIMESTAMP_INTERVALS: u32 = SQL_FN_TSI_SECOND + | SQL_FN_TSI_MINUTE + | SQL_FN_TSI_HOUR + | SQL_FN_TSI_DAY + | SQL_FN_TSI_WEEK + | SQL_FN_TSI_MONTH + | SQL_FN_TSI_QUARTER + | SQL_FN_TSI_YEAR; + +/// SQLSTATE for "operation canceled", which core provides no named +/// constructor for. See [`TrinoError::OperationCancelled`]. +const SQL_STATE_CANCELLED: &str = "HY008"; + +/// Trino's `USER_CANCELED` error code, reported when a query is killed while a +/// request against it is in flight. +/// +/// From Trino's `StandardErrorCode` enum, where it sits in the `USER_ERROR` +/// range alongside `PERMISSION_DENIED` (4), which the arm below it names. +const TRINO_ERROR_USER_CANCELED: i32 = 3; + +/// The cause attached to a [`TrinoError::Query`] or +/// [`TrinoError::OperationCancelled`]. +/// +/// Two shapes, because the two kinds of failure carry very different amounts +/// of text. A transport error's own `Display` is a single line and is kept +/// whole. A server-side `QueryError` renders the coordinator's entire +/// `failure_info`, its Java stack, and core walks the whole causal chain into +/// the `SQLGetDiagRec` message, so keeping it whole put between 1,700 and +/// 15,000 characters into every diagnostic (measured against a live +/// coordinator; `DIVISION_BY_ZERO` was the worst at ~30 KB of UTF-16 across +/// ~168 frames). +/// +/// None of that is actionable through ODBC: the stack describes the +/// coordinator's internals, the application already gets Trino's own error code +/// verbatim through `NativeErrorPtr`, and the summary naming the failure is the +/// first line. The stack is therefore logged at `debug` rather than marshalled: +/// `ODBC_LOG_LEVEL` and `ODBC_LOG_FILE` are what a person debugging Trino +/// itself reaches for. +#[derive(Debug, Snafu)] +pub enum QueryCause { + /// A transport failure, kept verbatim; its `Display` is already one line. + #[snafu(display("{source}"))] + Transport { + source: trino_rust_client::error::Error, + }, + /// A server-side rejection, reduced to what an application can act on. + #[snafu(display("query error [{error_name}]: {message}"))] + Server { error_name: String, message: String }, +} + +/// Split a client error into the native error code and the cause to attach. +/// +/// The single place `failure_info` is dropped, so the decision is made once for +/// every arm that carries a cause. +fn query_cause(e: trino_rust_client::error::Error) -> (QueryCause, i32) { + use trino_rust_client::error::Error; + match e { + Error::Query(query_error) => { + // Logged rather than discarded: this is the only copy, and it is + // what a person debugging the coordinator wants. + if let Some(ref failure) = query_error.failure_info { + tracing::debug!( + error_name = %query_error.error_name, + failure_info = ?failure, + "Trino failure_info; omitted from the SQLGetDiagRec message, \ + which carries the summary and the native error code" + ); + } + let native_error = query_error.error_code; + ( + QueryCause::Server { + error_name: query_error.error_name, + message: query_error.message, + }, + native_error, + ) + } + // A transport failure has no Trino error code, and `0` is the spec's + // "no native code". + other => (QueryCause::Transport { source: other }, 0), + } +} + +/// Whether this connection's link to the coordinator is still believed usable. +/// +/// Backs [`Backend::connection_dead`], which a connection pool reads on every +/// checkout. The spec's own note on that attribute is that "a driver can +/// improve performance by minimizing the number of times that information is +/// sent or requested from the server", so this is a flag the error path sets +/// rather than a probe: no round trip happens when it is read. +/// +/// Shared by `Arc` between the [`TrinoConnection`], every [`TrinoStatement`] +/// it produced and its [`TrinoCancelToken`]s. Each of those issues its own HTTP +/// requests, and a link failure observed on any of them is a fact about the +/// connection they all share: a `SQLFetch` that cannot reach the coordinator +/// is the most likely place to learn it. +#[derive(Debug, Clone, Default)] +pub(crate) struct Liveness(Arc<AtomicBool>); + +impl Liveness { + /// Mark the link dead if `error` is a connection-level failure. + /// + /// Only [`TrinoError::CommunicationLinkFailure`] counts, which + /// [`map_trino_error`] produces for a reqwest error whose `is_connect()` is + /// set. A timeout, an auth rejection and a server-side query failure all + /// leave the link intact, and `SQL_CD_TRUE` asserts the connection *has + /// been lost* rather than merely that something went wrong. The asymmetry + /// core documents applies: `false` means "not known to be dead", so + /// under-reporting here is the safe direction. + fn note(&self, error: &TrinoError) { + if matches!(error, TrinoError::CommunicationLinkFailure { .. }) { + self.0.store(true, Ordering::SeqCst); + } + } + + fn is_dead(&self) -> bool { + self.0.load(Ordering::SeqCst) + } +} + +/// [`map_trino_error`], recording a connection-level failure on the way past. +/// +/// This is not a second place that decides the SQLSTATE: it delegates the +/// whole classification and only observes the result, so the rule that every +/// client error is classified in exactly one place still holds. Use it wherever +/// a [`Liveness`] handle is in scope; the bare [`map_trino_error`] remains +/// correct where none is. +pub(crate) fn map_trino_error_on( + liveness: &Liveness, + e: trino_rust_client::error::Error, +) -> TrinoError { + let mapped = map_trino_error(e); + liveness.note(&mapped); + mapped +} + +/// The error a statement reports for a cancellation it learned about from the +/// token, with no failed request to classify. +/// +/// This is the between-requests half of the pair +/// [`TrinoError::OperationCancelled`] documents; the in-flight half comes from +/// [`map_trino_error`]. Both reach the application as `HY008`, and core +/// relabels either to `HYT00` when the failure follows a query timeout it +/// armed. +/// +/// Reporting Trino's own `USER_CANCELED` name and code 3 here is a statement of +/// fact: `cancel` publishes the flag alongside a `DELETE` it also reports on, +/// so the coordinator has been told to stop. +pub(crate) fn cancelled_between_requests() -> TrinoError { + TrinoError::OperationCancelled { + source: QueryCause::Server { + error_name: "USER_CANCELED".to_owned(), + message: "the query was cancelled".to_owned(), + }, + native_error: TRINO_ERROR_USER_CANCELED, + } +} + +/// An error's own text followed by every cause beneath it, joined with `: `. +/// +/// [`TrinoError::CommunicationLinkFailure`] carries a `String` rather than a +/// source, so whatever is not flattened here is lost: core's `Diagnostics` +/// walks a cause chain, but only one still attached to the error it is handed. +/// +/// Worth flattening because `reqwest::Error` names only its own layer. A +/// refused port, a certificate signed by an authority the client does not +/// trust, and a host that does not resolve all display as `error sending +/// request for url (...)`, and the sentence separating them sits one or more +/// `source()` calls further down. Without it an application holding only the +/// diagnostic record cannot tell a TLS rejection from a coordinator that is +/// switched off. +/// +/// A cause whose text the message already carries is skipped, because the +/// layers quote each other and a repeated segment lengthens the record without +/// adding to it. +fn flatten_causes(err: &(dyn std::error::Error + 'static)) -> String { + let mut text = err.to_string(); + let mut cause = err.source(); + while let Some(e) = cause { + let segment = e.to_string(); + if !text.contains(&segment) { + text.push_str(": "); + text.push_str(&segment); + } + cause = e.source(); + } + text +} + +/// Classify a `trino_rust_client` error into the [`TrinoError`] variant whose +/// SQLSTATE the failure deserves: `08S01` for a lost link, `HYT00` for a +/// timeout, `28000` for an authentication rejection, `HY008` for a cancelled +/// query, `HY000` for everything else. +/// +/// **Every** error from the client library goes through here. Hand-building a +/// `TrinoError` at the call site degrades a specific SQLSTATE to `HY000` and +/// throws away Trino's own error code, which `SQLGetDiagRec` reports through +/// `NativeErrorPtr` and is the only value an application can act on. +/// +/// Prefer [`map_trino_error_on`] wherever a [`Liveness`] handle is in scope: it +/// delegates the whole classification here and only observes the result, which +/// is what carries a connection-level failure to +/// `SQL_ATTR_CONNECTION_DEAD`. +pub(crate) fn map_trino_error(e: trino_rust_client::error::Error) -> TrinoError { + use trino_rust_client::error::Error; + match e { + // Checked before the catch-all `Error::Query` arm at the bottom, which + // would otherwise swallow this into `TrinoError::Query` and report + // HY000 for a cancellation the spec gives its own SQLSTATE. + Error::Query(ref query_error) if query_error.error_code == TRINO_ERROR_USER_CANCELED => { + let (source, native_error) = query_cause(e); + TrinoError::OperationCancelled { + source, + native_error, + } + } + Error::HttpError(ref req_err) if req_err.is_connect() => { + TrinoError::CommunicationLinkFailure { + message: format!("unable to reach Trino server: {}", flatten_causes(req_err)), + } + } + Error::HttpError(ref req_err) if req_err.is_timeout() => TrinoError::QueryTimeout { + message: format!("request timed out: {}", flatten_causes(req_err)), + }, + Error::HttpNotOk(ref status, ref reason) + if status.as_u16() == 401 || status.as_u16() == 403 => + { + TrinoError::AuthFailure { + message: format!("HTTP {status}: {reason}"), + } + } + Error::Forbidden { ref message } => TrinoError::AuthFailure { + message: message.clone(), + }, + // A login that was refused by the identity provider, or one nobody + // completed inside `ExternalAuthenticationTimeout`. Both arrive as + // `Error::OAuth2`, and both are authentication failures rather than + // query failures. `validate_connection` keeps `AuthFailure` at its own + // SQLSTATE instead of reclassifying it to `08001`, which is what makes + // the code reach an application that failed during connect. + Error::OAuth2(ref message) => TrinoError::AuthFailure { + message: format!("OAuth2 authentication failed: {message}"), + }, + Error::ReachMaxAttempt(n) => TrinoError::QueryTimeout { + message: format!("query failed after {n} retry attempts"), + }, + // The client's own transaction-state errors: a nesting attempt, an end + // with nothing open, or a `START TRANSACTION` whose id never came back + // in `X-Trino-Started-Transaction-Id`. None is a server-side rejection, + // so none carries a Trino error code, and the catch-all below would + // present a driver-side protocol failure as a failed query with a + // native code of `0`. + // + // `HY000` by way of `TrinoError::General`, which is what the spec asks + // for: "an error occurred for which there was no specific SQLSTATE". + // ODBC's transaction-state codes are `25S01`, `25S02` and `25S03`, and + // all three belong to `SQLEndTran`'s table for *global* transaction + // outcomes, not to the statement functions this surfaces through. + Error::Transaction(ref message) => TrinoError::General { + message: format!("transaction error: {message}"), + }, + // Everything else, which is where a server-side `Error::Query` lands. + // Its `QueryError` is the only shape carrying Trino's own error code; + // a transport failure has none, and `0` is the spec's "no native + // code". + // + // PERMISSION_DENIED (code 4) never reaches here: the client's + // `From<QueryError> for Error` turns it into `Error::Forbidden` and + // drops the code on the way, so that one maps to 28000 above with a + // native code of 0. + other => { + let (source, native_error) = query_cause(other); + TrinoError::Query { + source, + native_error, + } + } + } +} + +/// Log Trino server-side stats from a `QueryResult` page. +/// +/// These stats come directly from the Trino coordinator and are the authoritative +/// measure of server-side execution time, separating it from HTTP/client overhead. +pub(crate) fn log_page_stats(stats: &trino_rust_client::Stat, page_number: u32) { + tracing::info!( + page = page_number, + trino_elapsed_ms = stats.elapsed_time_millis, + trino_cpu_ms = stats.cpu_time_millis, + trino_wall_ms = stats.wall_time_millis, + trino_queued_ms = stats.queued_time_millis, + trino_rows = stats.processed_rows, + trino_bytes = stats.processed_bytes, + trino_peak_mem = stats.peak_memory_bytes, + trino_state = %stats.state, + "trino server stats" + ); +} + +/// Execute a SQL query and collect all rows. +/// +/// Delegates to `client.get_all()`, which paginates internally using +/// `get_retry()`/`get_next_retry()` (exponential backoff) and preserves column +/// metadata for zero-row results. Trino error code 4 (PERMISSION_DENIED) is +/// converted to `Error::Forbidden` by the client's `From<QueryError>` impl, so +/// [`map_trino_error`] yields SQLSTATE 28000; other query failures arrive as +/// `Error::Query` and map to the general variant. +/// +/// Note: `get_all()` exposes no per-page stats, so [`log_page_stats`] is not +/// called here. The main query path in `execute.rs` still logs per-page stats. +pub(crate) fn query_all_rows( + conn: &TrinoConnection, + sql: String, +) -> Result<Vec<trino_rust_client::Row>, TrinoError> { + query_all_rows_within(conn, sql, None) +} + +/// [`query_all_rows`], optionally abandoned after `deadline`. +/// +/// The deadline is imposed inside the runtime rather than on the HTTP client, +/// because the client's own request timeout is a per-request bound configured +/// once at connect and shared by every later query. This one bounds the whole +/// call, paging included, which is what `SQL_ATTR_LOGIN_TIMEOUT` asks for +/// and what a per-request timeout cannot express. +/// +/// Expiry is `HYT00`, via [`TrinoError::QueryTimeout`]: the spec tells a driver +/// to "return SQLSTATE HYT00 (Timeout expired) anytime that it is possible to +/// time out in a situation not associated with query execution or login", and +/// `SQLDriverConnect`'s own diagnostics table lists `HYT00` for the login case +/// as well. `validate_connection` passes that variant through unreclassified +/// for exactly this reason. +fn query_all_rows_within( + conn: &TrinoConnection, + sql: String, + deadline: Option<Duration>, +) -> Result<Vec<trino_rust_client::Row>, TrinoError> { + let _span = tracing::info_span!("trino.query_all_rows").entered(); + + let query = conn.client.get_all::<trino_rust_client::Row>(sql); + let result = match deadline { + Some(d) => conn + .runtime + .block_on(async { tokio::time::timeout(d, query).await }) + .map_err(|_| TrinoError::QueryTimeout { + message: format!("timed out after {} seconds", d.as_secs()), + })?, + None => conn.runtime.block_on(query), + }; + + let rows = result + // `statement_error`, not `map_trino_error_on`: this runs the catalog + // functions and `validate_connection`, and a statement failing inside + // an open transaction aborts the whole transaction whichever path + // submitted it. + .map_err(|e| conn.statement_error(e))? + .into_vec(); + + tracing::info!(total_rows = rows.len(), "query_all_rows complete"); + + Ok(rows) +} + +/// Build the statement used to prove the connection works. +/// +/// With no catalog configured this is `SELECT 1`, which Trino answers without +/// touching a connector while still running the full authentication path. +/// +/// When a catalog *is* configured the probe must also prove that the catalog +/// exists, and `SELECT 1` does not: Trino never resolves the session catalog +/// for a query that does not reference one, so a nonexistent catalog would +/// connect happily and only fail at the application's first real query. +/// +/// `LIKE ''` is what keeps this bounded. Catalog resolution happens before the +/// pattern is applied, so an unknown catalog still fails with +/// `CATALOG_NOT_FOUND`, but no schema is named `''`, so the probe returns zero +/// rows instead of every schema in the catalog, which may be thousands. +fn validation_query(catalog: Option<&str>) -> String { + match catalog { + // Delimited identifier: a `"` inside the name is escaped by doubling. + Some(cat) => format!("SHOW SCHEMAS FROM \"{}\" LIKE ''", cat.replace('"', "\"\"")), + None => "SELECT 1".to_string(), + } +} + +/// Prove that the freshly built client can reach and use Trino. +/// +/// `ClientBuilder::build` performs no I/O. Without this round trip +/// `SQLDriverConnect` would report success against an unreachable coordinator, +/// wrong credentials or a catalog that does not exist, and the failure would +/// surface on the application's first query, long after the spec and every +/// application expect a connection error. +/// +/// Failures are reclassified for the connect-time context: a transport failure +/// becomes `08001`, the SQLSTATE for establishing a connection, where +/// [`map_trino_error`] produces `08S01` for the same error mid-session. A +/// connection whose catalog does not resolve can run nothing, so it counts as a +/// failure to establish too. Two variants are already right for connect time +/// and pass through unchanged: `AuthFailure` keeps `28000` and `QueryTimeout` +/// keeps `HYT00`, both more specific than `08001`. +/// +/// `login_timeout` is `SQL_ATTR_LOGIN_TIMEOUT`, the seconds "to wait for a +/// login request to complete before returning to the application", and this +/// call is that login. It is applied here and not on the HTTP client, whose +/// request timeout also bounds every later query; the two attributes are set +/// separately. `Some(0)` never arrives: [`login_deadline`] collapses the spec's +/// "wait indefinitely" onto the unset case. +fn validate_connection( + conn: &TrinoConnection, + catalog: Option<&str>, + login_timeout: Option<Duration>, +) -> Result<(), TrinoError> { + let _span = tracing::info_span!("trino.validate_connection").entered(); + + match query_all_rows_within(conn, validation_query(catalog), login_timeout) { + Ok(_) => { + tracing::debug!("connection validated"); + Ok(()) + } + // Already correct for connect time, and more specific than 08001. + Err(e @ (TrinoError::AuthFailure { .. } | TrinoError::QueryTimeout { .. })) => Err(e), + Err(e) => Err(connection_failed(e)), + } +} + +/// Restate a validation failure as 08001, the SQLSTATE the spec reserves for +/// the connection functions, keeping what caused it. +/// +/// Trino's error code is lifted out of the restated error because +/// `SQLGetDiagRec` reads the native error from the diagnostic record rather +/// than from the causal chain, so a code left inside the source would not +/// reach the application. +fn connection_failed(e: TrinoError) -> TrinoError { + let native_error = match &e { + TrinoError::Query { native_error, .. } => *native_error, + _ => 0, + }; + TrinoError::ConnectionFailed { + source: Box::new(e), + native_error, + } +} + +/// Ask the coordinator the two facts about itself the driver cannot derive, +/// for `SQL_DBMS_VER` and `SQL_USER_NAME`. +/// +/// One round trip for both. They are asked together because the alternative is +/// a second one on every connect, and a BI tool opens connections far more +/// often than it reads either value. +/// +/// Any failure degrades rather than propagates. A driver must not refuse a +/// connection because it could not learn the server version: the connection +/// is already proven usable by [`validate_connection`] before this runs, and +/// `""` is the spec's own "not available" for both info types. The user has a +/// further fallback in [`session_user_name`], which the caller applies. +/// +/// Trino's `version()` returns a bare integer for modern releases (`"467"`) +/// and a dotted string for pre-0.216 releases (`"0.215"`); development builds +/// append a suffix (`"468-SNAPSHOT"`). [`parse_dotted_version`] handles all +/// three. An unparseable version does not discard the user, which is why the +/// two are read into the result independently rather than through a shared +/// early return. +fn probe_session(conn: &TrinoConnection) -> SessionProbe { + let rows = match query_all_rows(conn, "SELECT version(), current_user".to_string()) { + Ok(rows) => rows, + Err(e) => { + tracing::warn!(error = %e, "could not read the Trino server version and session user"); + return SessionProbe::default(); + } + }; + + let values = rows.first().map(|row| row.value()).unwrap_or_default(); + let column = |index: usize| values.get(index).and_then(|v| v.as_str()); + + let mut probe = SessionProbe { + user: column(1).map(str::to_string), + ..SessionProbe::default() + }; + if probe.user.is_none() { + tracing::warn!("SELECT current_user returned no usable value"); + } + + let Some(raw) = column(0) else { + tracing::warn!("SELECT version() returned no usable value"); + return probe; + }; + + match parse_dotted_version(raw) { + Some((major, minor, release)) => { + let formatted = format_odbc_version(major, minor, release); + tracing::debug!( + raw, + formatted, + major, + user = probe.user, + "Trino session probe" + ); + // The spec permits appending the data source's own version string + // after the ##.##.#### prefix. + probe.dbms_version = format!("{formatted} ({raw})"); + probe.server_major = major; + } + None => tracing::warn!(raw, "could not parse the Trino server version"), + } + probe +} + +/// What [`probe_session`] learned from the coordinator. +/// +/// The default, an empty string with major `0` and no user, is the "probe failed" +/// state. It makes `SQL_DBMS_VER` report the spec's "not available", gates +/// every version-dependent capability flag off, and sends `SQL_USER_NAME` to +/// [`session_user_name`]'s connection-string fallbacks. +#[derive(Default)] +struct SessionProbe { + /// Rendered for `SQL_DBMS_VER`, in the ODBC `##.##.####` form. + dbms_version: String, + /// The major version as a number, for capability gating. + server_major: u32, + /// Trino's `current_user`, for `SQL_USER_NAME`. + user: Option<String>, +} + +/// The Trino [`stackable_odbc_core::backend::Backend`] implementation. +/// +/// A zero-sized type: it carries no state, serving only as the type parameter +/// that [`stackable_odbc_core::forward_ffi!`] instantiates the generic ODBC C ABI entry +/// points with. All per-connection state lives in `TrinoConnection`. +pub struct TrinoBackend; + +/// Everything one ODBC connection owns: the Trino HTTP client, the Tokio +/// runtime every call into it blocks on, and the facts about the session that +/// `SQLGetInfo` and `SQLGetConnectAttr` report. +/// +/// One runtime per connection is the whole of the async bridge. Core's +/// `Backend` trait is synchronous and `trino-rust-client` is not, so every +/// client call goes through `conn.runtime.block_on(...)`; never introduce a +/// second runtime, and never `block_on` from inside an async context. +/// +/// The three `Arc`-shared fields ([`Liveness`], [`TransactionState`] and, by +/// way of [`TrinoCancelToken`], [`CancelState`]) travel to every statement this +/// connection produces, because a statement is where a lost link, an aborted +/// transaction and a cancelled query are all first seen. +pub struct TrinoConnection { + pub runtime: Arc<tokio::runtime::Runtime>, + pub client: Arc<Client>, + /// The coordinator's version, already rendered in the ODBC `##.##.####` + /// form `SQL_DBMS_VER` requires. Empty when the probe failed, which is the + /// ODBC spec's own representation of "not available". + /// + /// Captured once at connect rather than per `SQLGetInfo` call: a Trino + /// coordinator's version cannot change under a live connection, and + /// `SQLGetInfo` is called often enough by BI tools that a per-call query + /// would be a visible cost. + pub dbms_version: String, + /// The coordinator's major version as a number, for capability gating. + /// + /// Several SQL-92 features Trino gained recently, `CORRESPONDING` (475), + /// `MATCH` and `UNIQUE` (482) and `OVERLAPS` (483), change what + /// `SQL_SQL92_PREDICATES` and `SQL_SQL92_RELATIONAL_JOIN_OPERATORS` may + /// honestly claim; the capability-gating logic reads this field to decide. + /// + /// `0` when the probe failed, which gates every version-dependent flag + /// off. Understating capability is the safe direction: a BI tool folds + /// less than it could, rather than emitting SQL the server rejects. + pub server_major: u32, + /// The `DSN` the application connected with, for `SQL_DATA_SOURCE_NAME`. + /// + /// Empty when the connection string named no DSN, which is the one case the + /// spec defines the empty string for: the value "is the value of the DSN + /// keyword in the connection string passed to the driver", and empty "if + /// the connection string did not contain the DSN keyword (such as when it + /// contains the DRIVER keyword)". Both of `run-tests.sh`'s configurations + /// are covered by that pair. + /// + /// Core supplies it through `ConnectParams::dsn()` on both connection + /// entry points: `SQLDriverConnectW` from the connection string, and + /// `SQLConnectW` from its *ServerName* argument. + pub data_source_name: String, + /// The coordinator this connection was opened against, for + /// `SQL_SERVER_NAME`. + /// + /// The `Host` connection-string value rather than anything read back: the + /// spec asks for "the name of the server that the data source is associated + /// with", and the name the application reached the coordinator by is that + /// name. A coordinator does not report its own hostname, and reporting one + /// resolved from DNS would name a host the application never used. + pub server_name: String, + /// The session's own user, for `SQL_USER_NAME`. + /// + /// Trino's `current_user`, read once at connect by [`probe_session`] and + /// falling back through [`session_user_name`] when the probe failed. The + /// session cannot move it afterwards: Trino has no set-user response header + /// and no statement that changes the identity a session runs as, which is + /// what makes this a connect-time capture rather than a + /// [`Client::session_snapshot`] read like the catalog. + pub user_name: String, + /// The catalog this connection was opened against, from the `Catalog` + /// connection-string key, or `None` when it named none. + /// + /// Reported as `SQL_DATABASE_NAME`, which the spec defines as the current + /// database in use and treats as the `SQLGetConnectAttr` / + /// `SQL_ATTR_CURRENT_CATALOG` value. Core's shared default is the empty + /// string, correct only for a backend that cannot answer. This one can. + pub catalog: Option<String>, + /// The most recent `SQLDescribeParam` answer, keyed by the statement it + /// describes. + /// + /// `Backend::describe_param` is called once per *parameter* and receives + /// no statement handle, so without this a ten-parameter statement would + /// cost ten round trips to answer what one `DESCRIBE INPUT` already said. + /// Core walks a statement's parameters consecutively, so a single entry + /// keyed on the SQL text collapses that to one. + pub describe_param_cache: Mutex<Option<describe_param::CachedParams>>, + /// Whether the link to the coordinator has been observed to fail. + /// + /// Read by [`Backend::connection_dead`]; written by + /// [`map_trino_error_on`] from wherever the failure was seen, including a + /// statement this connection produced. See [`Liveness`]. + pub(crate) liveness: Liveness, + /// The commit mode and transaction state, shared with every statement this + /// connection produces. See [`TransactionState`]. + pub(crate) txn: Arc<TransactionState>, +} + +/// The commit mode and the state of the session's transaction. +/// +/// Shared behind an `Arc` by the connection and every statement it produces, +/// for the same reason [`CancelState`] is: a statement learns things the +/// connection has to know. A `SELECT 1/0` is the case that forces it: +/// `exec_direct` polls only until column metadata arrives, and Trino sends +/// metadata before it has evaluated a row, so a statement that fails at +/// execution time returns `Ok` and reports the failure from `fetch` instead. +/// Without a shared handle the connection would believe an aborted transaction +/// was alive, and answer `SQLEndTran(SQL_COMMIT)` with a commit Trino refuses. +#[derive(Debug)] +pub(crate) struct TransactionState { + /// Whether the connection is in autocommit mode, which is ODBC's default. + /// + /// Written by [`Backend::set_autocommit`] and read by the statement paths, + /// which open a transaction only when it is off. + autocommit: AtomicBool, + /// Whether the open transaction has been aborted by a failed statement. + /// + /// Trino aborts the whole transaction on any statement error and then + /// refuses everything, `COMMIT` included, until a `ROLLBACK`. + aborted: AtomicBool, + /// Incremented by every [`Backend::end_tran`] that reaches the wire. + /// + /// A statement records the epoch it executed under and compares: ending a + /// transaction discards the coordinator's result sets, so the page drain + /// that normally keeps the pooled socket clean would fail instead. + epoch: AtomicU64, +} + +impl Default for TransactionState { + fn default() -> Self { + Self { + // ODBC's default commit mode, which every connection starts in. + autocommit: AtomicBool::new(true), + aborted: AtomicBool::new(false), + epoch: AtomicU64::new(0), + } + } +} + +impl TransactionState { + /// Whether the connection is in autocommit mode. + pub(crate) fn autocommit(&self) -> bool { + self.autocommit.load(Ordering::SeqCst) + } + + /// Record the commit mode `SQLSetConnectAttr` asked for. + pub(crate) fn set_autocommit(&self, enabled: bool) { + self.autocommit.store(enabled, Ordering::SeqCst); + } + + /// Whether a failed statement has aborted the open transaction. + pub(crate) fn aborted(&self) -> bool { + self.aborted.load(Ordering::SeqCst) + } + + /// Record that a statement inside the transaction failed. + /// + /// A no-op in autocommit mode, where each statement stands alone and one + /// failure says nothing about the next. + /// + /// Manual-commit mode is not the same as "a transaction is open": one opens + /// at the first statement that needs it, so there is a window in which this + /// mode is set and nothing has begun. A failure in that window sets the flag + /// for a transaction that does not exist yet, which is why [`Self::begun`] + /// clears it. The alternative, asking whether one is open, is not available + /// to every caller: a [`TrinoStatement`] holds this state and not the + /// client. + pub(crate) fn note_statement_error(&self) { + if !self.autocommit() { + self.aborted.store(true, Ordering::SeqCst); + } + } + + /// Record that a transaction has just been opened. + /// + /// Clears the abort flag, which is a statement about *the transaction now + /// open* and must not carry a verdict from before there was one. + /// + /// Without this, a failing catalog function or `DESCRIBE INPUT` in + /// manual-commit mode set the flag with nothing open, [`Self::ended`] never + /// ran to clear it, and the next transaction was born already aborted: its + /// statements succeeded, and `SQLEndTran(SQL_COMMIT)` then rolled it back + /// and reported `25S03` for a failure that predated it. + /// + /// The epoch is deliberately untouched. It tracks result sets discarded by + /// a transaction *ending*, and opening one discards nothing. + pub(crate) fn begun(&self) { + self.aborted.store(false, Ordering::SeqCst); + } + + /// The epoch a statement executing now would record. + pub(crate) fn epoch(&self) -> u64 { + self.epoch.load(Ordering::SeqCst) + } + + /// End the current transaction's epoch and clear the aborted flag. + pub(crate) fn ended(&self) { + self.epoch.fetch_add(1, Ordering::SeqCst); + self.aborted.store(false, Ordering::SeqCst); + } + + /// Whether a result set recorded at `epoch` has outlived its transaction. + pub(crate) fn outlived(&self, epoch: u64) -> bool { + self.epoch() != epoch + } +} + +impl TrinoConnection { + /// Whether the session currently holds a Trino transaction id. + /// + /// Read from the client rather than tracked here: the client owns the id, + /// captures it from `X-Trino-Started-Transaction-Id` and clears it on the + /// response to a `COMMIT`. A second copy here could disagree with the + /// headers going out. + pub(crate) fn in_transaction(&self) -> bool { + self.runtime + .block_on(self.client.transaction_id()) + .is_active() + } + + /// Open a transaction if manual-commit mode wants one and none is open. + /// + /// Called from the statement paths rather than from + /// [`Backend::set_autocommit`], because `SQLSetConnectAttr` is not a call + /// applications expect to block on a round trip, and a transaction opened + /// there would be held for however long the application takes to run its + /// first statement. + /// + /// The choke point is narrower than the guarantee. Trino carries the + /// transaction id in a *session* header, so once one is open every request + /// the client makes joins it, the catalog functions included, and a failing + /// one aborts the application's transaction. This decides when the window + /// opens, never who is inside it. + pub(crate) fn ensure_transaction(&self) -> Result<(), TrinoError> { + if self.txn.autocommit() || self.in_transaction() { + return Ok(()); + } + tracing::debug!("opening a transaction for manual-commit mode"); + self.runtime + .block_on(self.client.begin_transaction()) + .map_err(|e| map_trino_error_on(&self.liveness, e))?; + // A fresh transaction has not been aborted, whatever failed before it + // existed. See `TransactionState::begun`. + self.txn.begun(); + Ok(()) + } + + /// Record that a statement inside the transaction failed. + /// + /// Trino aborts the whole transaction on any statement error, a + /// `NOT_SUPPORTED` one included, and then refuses everything until a + /// `ROLLBACK`, `COMMIT` included, which is what makes this flag necessary + /// rather than merely informative. [`TrinoBackend::end_tran`] reads it to + /// send the rollback the session needs. + pub(crate) fn note_statement_error(&self) { + self.txn.note_statement_error(); + } + + /// Map a client error from a statement this connection is running. + /// + /// Delegates the whole classification to [`map_trino_error_on`], so the + /// single place that decides the SQLSTATE is still the only one; what this + /// adds is [`TrinoConnection::note_statement_error`], which every statement + /// failure has to reach for an open transaction to be known dead. + pub(crate) fn statement_error(&self, e: trino_rust_client::error::Error) -> TrinoError { + self.note_statement_error(); + map_trino_error_on(&self.liveness, e) + } +} + +/// The state a `SQLCancel` on one thread and the executing statement on +/// another both reach. +/// +/// Held behind an `Arc` by both [`TrinoCancelToken`] and the +/// [`TrinoStatement`] the token's statement produced, which is what lets +/// `cancel` communicate with a statement it has no reference to. +#[derive(Debug, Default)] +pub(crate) struct CancelState { + /// The Trino query id of the work currently in flight, or `None` when + /// nothing cancellable is running. + /// + /// Trino only names a query once the coordinator has accepted it, so this + /// is empty for the duration of the submitting request. A `SQLCancel` + /// landing in that window finds nothing to cancel and succeeds, which is + /// what the spec asks for when there is no processing to interrupt. + query_id: Mutex<Option<String>>, + /// Set once a query has been cancelled server-side, and read by the fetch + /// and teardown paths to keep them off `next_uri`. + /// + /// After a server-side cancel, polling `get_next` fails and leaves the + /// pooled TCP socket carrying residual bytes, which surfaces later as an + /// unrelated query failing on the same reqwest pool. `cancel` receives a + /// token rather than the statement, so it cannot clear `next_uri` itself; + /// the statement observes this flag instead. + cancelled: AtomicBool, +} + +impl CancelState { + /// Record the query the statement just submitted, and clear any earlier + /// cancellation. + /// + /// Called at the point a statement-producing call learns its query id. + /// + /// The reset keeps a live query off a stale one's teardown, and this is the + /// one point that knows a new query has begun. No reachable path arrives + /// with a cancellation pending: core mints a new token at every + /// statement-producing call, so a re-execute gets a fresh [`CancelState`] + /// whose flag is already clear. The clear is explicit anyway, because + /// nothing else would notice if that stopped holding. + pub(crate) fn begin_query(&self, query_id: String) { + self.cancelled.store(false, Ordering::SeqCst); + match self.query_id.lock() { + Ok(mut slot) => *slot = Some(query_id), + // A poisoned lock means a panic while the slot was held. Losing the + // id costs cancellability for this query; it must not also fail the + // query, which is running perfectly well. + Err(_) => { + tracing::warn!("cancel state was poisoned; this query will not be cancellable") + } + } + } + + /// Whether the statement's query has been cancelled server-side. + pub(crate) fn is_cancelled(&self) -> bool { + self.cancelled.load(Ordering::SeqCst) + } +} + +/// The value [`Backend::cancel`] receives, and the only handle it gets on the +/// query it is asked to interrupt. +/// +/// The client and runtime are captured from the connection at construction +/// rather than resolved inside `cancel`, which is the rule +/// [`Backend::cancel_token`] states: MariaDB's ODBC-401 assembled its cancel +/// channel lazily, after the originating connection's TLS settings were gone, +/// and silently failed to cancel encrypted connections. +pub struct TrinoCancelToken { + client: Arc<Client>, + runtime: Arc<tokio::runtime::Runtime>, + state: Arc<CancelState>, + /// The connection's liveness flag: `cancel` issues its own `DELETE`, so it + /// is one more place a lost link can first be observed. + liveness: Liveness, +} + +/// Builds a `TrinoConnection` that performs no network I/O. +/// +/// `ClientBuilder::build` only assembles a `reqwest::Client` (URL parsing, +/// timeout config), and building a `tokio::runtime::Runtime` is a local +/// operation; neither talks to a server. Validation happens in the separate +/// `validate_connection` call [`TrinoBackend::connect`] makes afterwards. +/// +/// Every capability declaration takes a `&TrinoConnection`, so the offline +/// tests that assert what this driver reports need one. They assert values that +/// do not depend on the coordinator, which is what this stands in for. +#[cfg(test)] +pub(crate) fn disconnected_trino_conn() -> TrinoConnection { + disconnected_trino_conn_with_catalog(None) +} + +/// [`disconnected_trino_conn`] with a `Catalog` connection-string value, for +/// the tests that assert what [`TrinoBackend::current_catalog`] feeds to +/// `SQL_ATTR_CURRENT_CATALOG` and `SQL_DATABASE_NAME`. Those two are only +/// interesting when there is a catalog to report. +#[cfg(test)] +pub(crate) fn disconnected_trino_conn_with_catalog(catalog: Option<&str>) -> TrinoConnection { + let client = ClientBuilder::new("test", "localhost") + .port(8080) + .build() + .expect("ClientBuilder::build performs no I/O and cannot fail here"); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("Runtime::build performs no I/O and cannot fail here"); + TrinoConnection { + runtime: Arc::new(runtime), + client: Arc::new(client), + // No live server was contacted, so no version was probed either, so + // this mirrors the "probe failed" state `TrinoBackend::connect` would + // leave behind if `fetch_server_version` could not reach a coordinator. + dbms_version: String::new(), + server_major: 0, + // The three identity strings, matching the client built above rather + // than left empty: `TrinoBackend::connect` derives the first two + // without reaching the coordinator, so a fabricated connection that + // left them blank would let `SQL_SERVER_NAME` and `SQL_USER_NAME` + // regress to core's non-answer with the snapshot still passing. + // + // The DSN is the exception, and its emptiness is defined rather than + // missing: nothing here connected by DSN, which is the one case the + // spec gives the empty string to. + data_source_name: String::new(), + server_name: "localhost".to_string(), + user_name: "test".to_string(), + // `None` is the `SQL_DATABASE_NAME` / `SQL_ATTR_CURRENT_CATALOG` + // "not available" case, which both readers render as the empty string. + catalog: catalog.map(str::to_string), + describe_param_cache: Mutex::new(None), + // Nothing has been attempted, so nothing has been observed to fail, + // which is `SQL_CD_FALSE`, "not known to be dead". + liveness: Liveness::default(), + // Autocommit, and no transaction: this connection reaches no + // coordinator, so none can be opened on it. + txn: Arc::new(TransactionState::default()), + } +} + +/// One ODBC statement handle: the SQL, the result set's column metadata, the +/// page of rows currently buffered, and the link back to the connection that +/// produced it. +/// +/// Trino streams a result set as a chain of pages joined by `next_uri`, so a +/// statement holds one page at a time and fetches the next on demand. Three +/// consequences shape the fields below. The client, runtime and `next_uri` must +/// outlive the call that created the statement, so they are held here rather +/// than borrowed. Trino sends column metadata on one page only, so +/// `raw_columns` keeps it for every later page, spooled ones included. And a +/// page fetch is where a lost link, an aborted transaction and a cancelled +/// query are usually first seen, so `liveness`, `txn` and `cancel_state` are +/// shared back to the connection. +/// +/// Every one of those five is `None` for a statement that reaches no network: +/// a prepared-but-unexecuted handle, and the catalog result sets core builds in +/// memory. +pub struct TrinoStatement { + /// SQL stored by SQLPrepare, consumed by SQLExecute. + pending_sql: Option<String>, + /// Column metadata (available from the first Trino response page). + pub(crate) columns: Vec<ColumnDescriptor>, + /// Column types from Trino (needed for converting subsequent pages). + trino_types: Vec<(String, trino_rust_client::TrinoTy)>, + /// Trino's own column metadata for the result set, kept because a spooled + /// segment is decoded against it and Trino sends it on one page only. + /// + /// Empty for a statement that reaches no network: the prepared-but-unexecuted + /// handle and the in-memory catalog results. + pub(crate) raw_columns: Vec<trino_rust_client::models::Column>, + /// Current in-memory batch of converted rows. + pub(crate) batch: Vec<Vec<ColumnValue>>, + /// The `(row, column)` cells of the current batch, both zero-based, whose + /// conversion dropped fractional-seconds digits. + /// + /// Recorded at conversion time because that is the only point the wire text + /// still exists, and read by `get_data` to arm the `01S07` that + /// `take_value_warning` hands back. A set rather than a flag per cell: only + /// a `time`/`timestamp` column declared beyond nine fractional digits can + /// put anything in it, so it is empty for all but a few result sets, and an + /// empty set costs no allocation. + pub(crate) truncated_cells: std::collections::HashSet<(usize, usize)>, + /// The warning the last `get_data` armed, cleared by `take_value_warning`. + pub(crate) pending_value_warning: Option<ValueWarning>, + /// Position within the current batch (0 = before first row). + batch_cursor: usize, + /// Set once a page fetch has failed. The result set is then unusable: the + /// rows of the last good page must not be readable, and a further fetch + /// must report an error rather than a spurious `NoData`. + pub(crate) fetch_failed: bool, + /// URL for the next page of results from Trino, or None if exhausted. + next_uri: Option<String>, + /// Trino query ID for cancellation via DELETE /v1/query/{id}. + query_id: Option<String>, + /// Shared reference to the Trino HTTP client (for fetching next pages). + client: Option<Arc<Client>>, + /// Shared reference to the tokio runtime (for block_on in fetch). + runtime: Option<Arc<tokio::runtime::Runtime>>, + /// The half of the cancel token this statement can see. + /// + /// `None` for the statements built entirely in memory (the catalog + /// results and `tables_list_table_types`), which hold no `next_uri` and so + /// have nothing for a cancellation to stop. + pub(crate) cancel_state: Option<Arc<CancelState>>, + /// The connection's transaction state, shared so this statement can both + /// report a failure that aborts the transaction and tell whether one ended + /// under it. + /// + /// `None` for the statements built entirely in memory, which reach no + /// coordinator and hold no `next_uri`. + pub(crate) txn: Option<Arc<TransactionState>>, + /// The transaction epoch this statement executed under, compared against + /// [`TransactionState::epoch`] by `close_cursor`. + pub(crate) txn_epoch: u64, + /// The connection's liveness flag, for the page fetches this statement + /// issues itself. `None` alongside `client` and `runtime`, for the + /// statements built entirely in memory, which reach no network. + pub(crate) liveness: Option<Liveness>, + + // --- Profiling counters (always present; ~48 bytes, negligible overhead) --- + /// Total number of Trino REST API pages fetched for this query. + page_count: u32, + /// Number of pages that contained zero data rows (Trino planning/empty pages). + empty_page_count: u32, + /// Total number of data rows fetched across all pages. + total_rows_fetched: u64, + /// Cumulative wall time spent in HTTP calls (`block_on(client.get_next(...))`). + total_fetch_time: std::time::Duration, + /// Cumulative wall time spent in `convert_rows()` (clone + JSON → ColumnValue). + total_convert_time: std::time::Duration, +} + +#[derive(Debug, Snafu)] +pub enum TrinoError { + #[snafu(display("Trino error: {message}"))] + General { message: String }, + #[snafu(display("Tokio runtime error: {source}"))] + Runtime { source: std::io::Error }, + #[snafu(display("Missing parameter: {name}"))] + MissingParam { name: String }, + #[snafu(display("{feature} is not implemented"))] + NotImplemented { feature: String }, + /// A usable connection to Trino could not be established. + /// + /// Produced only by [`connection_failed`], called from + /// [`validate_connection`], which is the only place that runs before the + /// ODBC connection exists and so the only place 08001 is the correct + /// SQLSTATE. + /// + /// The failure it restates is kept whole, as the source, and never + /// flattened with `to_string()`. Half the errors reaching it are + /// [`TrinoError::Query`], whose own `Display` is empty by design because + /// core walks its `source` instead, so flattening yields the bare words + /// "query failed" and discards both the cause and Trino's error code. + /// `native_error` is lifted out of the restated error for the same reason: + /// `SQLGetDiagRec` reads it from the record, not from the chain. + #[snafu(display("connection failed"))] + ConnectionFailed { + source: Box<TrinoError>, + native_error: i32, + }, + /// The link to the Trino coordinator failed while a request was in flight. + /// + /// This is 08S01, not 08001: `TrinoBackend::connect` performs no network + /// I/O (it only builds the HTTP client), so by the time any request can + /// fail the ODBC connection is already established, and 08001 is reserved + /// by the spec for the connection functions. + #[snafu(display("Communication link failure: {message}"))] + CommunicationLinkFailure { message: String }, + /// The HTTP request to Trino exceeded the configured timeout. + #[snafu(display("Query timed out: {message}"))] + QueryTimeout { message: String }, + /// Trino rejected the request with an authentication/authorization error. + #[snafu(display("Authentication failed: {message}"))] + AuthFailure { message: String }, + /// Authentication configuration is invalid (e.g. a bearer token supplied + /// over plain HTTP, or both a password and a token). Maps to SQLSTATE 28000. + #[snafu(display("{message}"))] + AuthConfig { message: String }, + /// A failure from `trino-rust-client` that [`map_trino_error`] does not + /// classify into one of the specific variants above. + /// + /// The client error is kept as the cause rather than flattened into a + /// string, and `native_error` carries Trino's own error code when the + /// coordinator supplied one. `SQLGetDiagRec` reports that code verbatim + /// through `NativeErrorPtr`, where it is the only value an application can + /// act on; every failure reporting `0` tells it nothing. + /// + /// The message does not interpolate `source`: core walks the whole causal + /// chain when it builds the diagnostic record, so doing both would print + /// the client error twice. + #[snafu(display("query failed"))] + Query { + source: QueryCause, + native_error: i32, + }, + /// The query was cancelled while this request was in flight. + /// + /// Maps to `HY008`, which the spec defines for exactly this case: + /// "the function was called, and before it completed execution, + /// `SQLCancel` ... was called on the StatementHandle from a different + /// thread in a multithreaded application". That clause carries no `(DM)` + /// annotation, so it is the driver's to report, not the Driver Manager's. + /// + /// Two paths produce it, and both are needed. A cancel landing while a page + /// request is in flight is recognised from Trino's own `USER_CANCELED` + /// error code, in [`map_trino_error`]; the server's verdict needs no + /// cross-thread ordering and also covers a query killed by someone else, + /// such as `CALL system.runtime.kill_query`. A cancel landing *between* + /// requests fails no response, so the next `fetch` sees only + /// [`CancelState::is_cancelled`] and builds the error from + /// [`cancelled_between_requests`]. + #[snafu(display("query was cancelled"))] + OperationCancelled { + source: QueryCause, + native_error: i32, + }, + /// An error core itself produced. + /// + /// `Backend::Error` is bounded by `From<OdbcError>` so that a defaulted + /// trait body can construct an error and still name `Self::Error`. This is + /// that conversion's landing site. It wraps rather than flattens: the + /// `From<TrinoError> for OdbcError` direction unwraps it unchanged, so the + /// SQLSTATE core chose survives a round trip through this type instead of + /// being degraded to `HY000`. + #[snafu(display("{source}"))] + Odbc { source: OdbcError }, +} + +impl From<OdbcError> for TrinoError { + fn from(source: OdbcError) -> Self { + TrinoError::Odbc { source } + } +} + +impl From<TrinoError> for OdbcError { + fn from(e: TrinoError) -> Self { + use stackable_odbc_core::types::SqlState; + match e { + // Unwrapped, not re-wrapped: this is the other half of + // `From<OdbcError> for TrinoError`, and preserving the SQLSTATE + // core already chose is the whole point of that variant. + TrinoError::Odbc { source } => source, + TrinoError::Query { + source, + native_error, + } => OdbcError::general("query failed", SqlState::general_error()) + .with_native_error(native_error) + .with_source(source), + TrinoError::NotImplemented { ref feature } => OdbcError::NotImplemented { + feature: feature.clone(), + }, + TrinoError::ConnectionFailed { + source, + native_error, + } => OdbcError::general( + "connection failed", + SqlState::client_unable_to_establish_connection(), + ) + .with_native_error(native_error) + .with_source(source), + TrinoError::CommunicationLinkFailure { ref message } => { + OdbcError::general(message.clone(), SqlState::communication_link_failure()) + } + TrinoError::QueryTimeout { ref message } => { + OdbcError::general(message.clone(), SqlState::timeout_expired()) + } + TrinoError::AuthFailure { ref message } => { + OdbcError::general(message.clone(), SqlState::invalid_auth_spec()) + } + TrinoError::AuthConfig { ref message } => { + OdbcError::general(message.clone(), SqlState::invalid_auth_spec()) + } + // `SqlState::new` rather than a named constructor: core has none + // for HY008, because it documents HY008 as never returned by a + // driver ("not applicable; the `Backend` trait is synchronous"). + // Cross-thread `SQLCancel` made that false. See the CHANGELOG. + TrinoError::OperationCancelled { + source, + native_error, + } => OdbcError::general("query was cancelled", SqlState::new(SQL_STATE_CANCELLED)) + .with_native_error(native_error) + .with_source(source), + _ => OdbcError::general(e.to_string(), SqlState::general_error()), + } + } +} + +/// Decide the client `Auth` from the resolved connection parameters. +/// +/// - token + password -> Err (ambiguous authentication) +/// - token + !secure -> Err (a bearer token must not travel over plain HTTP) +/// - token + secure -> Jwt +/// - no token + secure -> Basic(user, password): Basic even when no +/// password is supplied, as `Basic(user, None)` +/// - no token + !secure -> None (user-only `X-Trino-User` header; a password +/// supplied over HTTP is dropped with a warning by `connect`, not here) +fn resolve_auth( + secure: bool, + password: Option<&str>, + access_token: Option<&str>, + external: bool, + may_prompt: bool, +) -> Result<AuthChoice, TrinoError> { + match (external, access_token, password) { + (true, Some(_), _) | (true, _, Some(_)) => Err(TrinoError::AuthConfig { + message: "ExternalAuthentication cannot be combined with a password or an access \ + token; provide only one" + .into(), + }), + (true, None, None) if !secure => Err(TrinoError::AuthConfig { + message: "ExternalAuthentication requires Protocol=https; the bearer token it \ + obtains will not be sent over plain HTTP" + .into(), + }), + // The application passed `SQL_DRIVER_NOPROMPT`, or something else in + // the stack forbade prompting. `SQL_DRIVER_NOPROMPT`'s own clause says + // a driver without enough information to connect returns `SQL_ERROR`, + // and an interactive login is exactly the information we lack. + (true, None, None) if !may_prompt => Err(TrinoError::AuthConfig { + message: "ExternalAuthentication needs to show a login URL, and this connection \ + was made with SQL_DRIVER_NOPROMPT; supply AccessToken instead" + .into(), + }), + (true, None, None) => Ok(AuthChoice::External), + (false, Some(_), Some(_)) => Err(TrinoError::AuthConfig { + message: "both a password and an access token were supplied; provide only one".into(), + }), + (false, Some(_), None) if !secure => Err(TrinoError::AuthConfig { + message: "an access token requires Protocol=https; it will not be sent over plain HTTP" + .into(), + }), + (false, Some(_), None) => Ok(AuthChoice::Jwt), + // No token: Basic over HTTPS, even with no password, as + // `Basic(user, None)`; user-only over HTTP. + (false, None, _) if secure => Ok(AuthChoice::Basic), + (false, None, _) => Ok(AuthChoice::None), + } +} + +/// Which authentication the connection parameters select. +/// +/// Deciding is kept apart from constructing because `Auth::OAuth2` is not built +/// per connection: it carries the shared token cache below, so `connect` looks +/// one up rather than minting a second interactive login. Keeping this a plain +/// decision leaves [`resolve_auth`] pure and directly testable. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AuthChoice { + /// No `Authorization` header; the user travels as `X-Trino-User` alone. + None, + Basic, + Jwt, + External, +} + +/// The identity an OAuth 2.0 token was issued for. +/// +/// The coordinator and the user are the most this driver can know: the real +/// identity is decided by the identity provider during the browser flow and is +/// never visible here. Keying on what the application asked for is what stops +/// two connections with different credentials sharing one token. +#[derive(Debug, PartialEq, Eq, Hash)] +struct OAuth2Key { + secure: bool, + host: String, + port: u16, + /// Usually `None`, because `ExternalAuthentication` makes `User` optional and the + /// identity provider decides. Kept in the key because a connection that + /// *does* name one is asking for a different session, and must not be + /// served from the login of a connection that did not. + user: Option<String>, +} + +/// One interactive login per identity, for the life of the process. +/// +/// The client caches the bearer token in the `Arc<OAuth2State>` behind an +/// `Auth`, so clones of one `Auth` share a login and a second +/// `Auth::new_oauth2` means a second browser. This driver builds a `Client` per +/// connection, so without this a pool warming ten connections would open ten +/// browsers. +/// +/// Expiry needs no handling: a stale token yields a `401`, and the client +/// re-runs the flow and re-caches behind the same `Arc`. +static OAUTH2_LOGINS: OnceLock<Mutex<HashMap<OAuth2Key, Auth>>> = OnceLock::new(); + +/// The shared `Auth` for `key`, running the interactive flow only the first time. +fn oauth2_auth( + key: OAuth2Key, + prompter: Arc<dyn Prompter>, + poll_timeout: Duration, +) -> Result<Auth, TrinoError> { + let logins = OAUTH2_LOGINS.get_or_init(|| Mutex::new(HashMap::new())); + let mut logins = logins.lock().map_err(|_| { + // Hand-built: a poisoned mutex is an internal invariant violation that + // never came from the client, which is what this exception is for. + OdbcError::general( + "the OAuth2 login cache is poisoned", + stackable_odbc_core::types::SqlState::general_error(), + ) + })?; + + if let Some(auth) = logins.get(&key) { + tracing::debug!(host = key.host, port = key.port, "reusing the OAuth2 login"); + return Ok(auth.clone()); + } + + let auth = Auth::new_oauth2_with_config(OAuth2Config { + handler: Arc::new(ClientRedirect::new(prompter)), + poll_timeout, + ..OAuth2Config::default() + }); + logins.insert(key, auth.clone()); + Ok(auth) +} + +impl Backend for TrinoBackend { + type CancelToken = TrinoCancelToken; + type Connection = TrinoConnection; + type Error = TrinoError; + type Statement = TrinoStatement; + + /// How this driver shows an interactive OAuth 2.0 login URL. + /// + /// Declaring it says only what the driver *could* do. Whether a given + /// connect may use it is core's decision, from `SQLDriverConnect`'s + /// *DriverCompletion*, and `connect` reads the answer back from + /// [`ConnectParams::prompter`] rather than calling this. + fn prompter() -> Option<Arc<dyn Prompter>> { + Some(Arc::new(BrowserPrompter)) + } + + /// The DSN setup dialog the ODBC Administrator's **Add…** and + /// **Configure…** buttons display. + /// + /// See the `backend::setup` module for how it is presented, and why the + /// dialog is `packaging/windows/configure-dsn.ps1` rather than a second + /// implementation in Rust. + fn configure_dsn( + hwnd_parent: *mut std::ffi::c_void, + request: ConfigRequest, + attributes: HashMap<String, String>, + ) -> Result<Option<HashMap<String, String>>, SetupError> { + setup::configure_dsn(hwnd_parent, request, attributes) + } + + fn connect(params: &ConnectParams) -> Result<TrinoConnection, TrinoError> { + let p = types::connect_params::TrinoConnectParams::try_from(params)?; + + let request_timeout = request_timeout(params.connection_timeout(), p.query_timeout()); + let login_timeout = login_deadline(params.login_timeout()); + + tracing::debug!( + host = p.host(), + port = p.port(), + user = p.user(), + secure = p.secure(), + tls_verification = ?p.tls_verification(), + request_timeout_secs = request_timeout.as_secs(), + login_timeout_secs = login_timeout.map(|d| d.as_secs()), + "TrinoBackend::connect" + ); + + // Over plain HTTP, Trino uses the X-Trino-User header set by ClientBuilder::new. + // Basic auth (password) is only sent over HTTPS: Trino rejects passwords over HTTP + // even when allow-insecure-over-http=true is configured. + // `source` is what Trino's query history and its resource-group rules + // see; `client_tags` is what those rules match on. Both are set before + // anything else so a query is attributable even if the rest fails. + // No user means `ExternalAuthentication`: the identity provider decides + // who the session runs as, and `X-Trino-User` is left off so Trino takes + // the user from the authenticated identity rather than reading an + // invented name as an impersonation attempt. + let mut builder = match p.user() { + Some(user) => ClientBuilder::new(user, p.host()), + None => ClientBuilder::without_user(p.host()), + } + .port(p.port()) + .secure(p.secure()) + .source(p.source()) + .client_tags(p.client_tags().clone()) + .client_request_timeout(request_timeout); + // `None` here means either that this driver declared no prompter or + // that the application passed `SQL_DRIVER_NOPROMPT`. Core does not + // distinguish the two, and neither does anything below. + let prompter = params.prompter(); + let auth = match resolve_auth( + p.secure(), + p.password(), + p.access_token(), + p.external_authentication(), + prompter.is_some(), + )? { + AuthChoice::External => { + if params.login_timeout().is_some() { + tracing::warn!( + external_auth_timeout_secs = p.external_auth_timeout().as_secs(), + "SQL_ATTR_LOGIN_TIMEOUT is not applied to an interactive OAuth2 login: \ + it waits on a person, not on the data source. The login is bounded by \ + ExternalAuthenticationTimeout instead." + ); + } + let prompter = prompter.ok_or_else(|| TrinoError::AuthConfig { + message: "ExternalAuthentication was selected without a prompter".into(), + })?; + Some(oauth2_auth( + OAuth2Key { + secure: p.secure(), + host: p.host().to_string(), + port: p.port(), + user: p.user().map(str::to_string), + }, + prompter, + p.external_auth_timeout(), + )?) + } + AuthChoice::Jwt => p.access_token().map(|t| Auth::Jwt(t.to_string())), + // Only reachable when `ExternalAuthentication` is off, which is + // exactly when `User` was required, so the name is always present. + AuthChoice::Basic => p + .user() + .map(|user| Auth::Basic(user.to_string(), p.password().map(str::to_string))), + AuthChoice::None => None, + }; + match auth { + Some(auth) => { + builder = builder.auth(auth).auth_http_insecure(false); + } + None => { + if !p.secure() && p.password().is_some() { + tracing::warn!( + "Password was supplied but Protocol is http; it will not be sent. \ + Use Protocol=https to authenticate with a password." + ); + } + } + } + + if let Some(cat) = p.catalog() { + builder = builder.catalog(cat); + } + if let Some(sch) = p.schema() { + builder = builder.schema(sch); + } + + // Set unconditionally where the client's own default is what an unset + // key should mean, and conditionally where it is not: calling + // `properties` with an empty map is the same as not calling it, but + // `max_attempt(0)` is not the same as leaving the client's budget + // alone. + if !p.session_properties().is_empty() { + builder = builder.properties(p.session_properties().clone()); + } + if !p.extra_credentials().is_empty() { + builder = builder.extra_credentials(p.extra_credentials().clone()); + } + if !p.resource_estimates().is_empty() { + builder = builder.resource_estimates(p.resource_estimates().clone()); + } + if let Some(path) = p.path() { + builder = builder.path(path); + } + if let Some(info) = p.client_info() { + builder = builder.client_info(info); + } + if let Some(token) = p.trace_token() { + builder = builder.trace_token(token); + } + // Impersonation: `X-Trino-User` becomes this, while the credentials + // stay those of `User`. Session state accumulates per client, so one + // ODBC connection per impersonated user is what keeps a `SET SESSION` + // made for one from reaching another. + if let Some(session_user) = p.session_user() { + builder = builder.session_user(session_user); + } + if let Some(locale) = p.locale() { + builder = builder.locale(locale); + } + if !p.roles().is_empty() { + builder = builder.roles(p.roles().clone()); + } + if let Some(tz) = p.time_zone() { + builder = builder.timezone(tz); + } + // A name the client manages is rejected by `build` below, rather than + // sent alongside the client's own value: `reqwest` appends, so the + // request would carry both. + if !p.extra_headers().is_empty() { + builder = builder.extra_headers(p.extra_headers().clone()); + } + if !p.client_capabilities().is_empty() { + builder = builder.client_capabilities(p.client_capabilities().clone()); + } + if let Some(url) = p.proxy() { + // `Proxy::all` is where a `socks5://` URL is refused, and it + // phrases that better than a check here would. + let mut proxy = Proxy::all(url).map_err(|e| TrinoError::General { + message: format!( + "invalid value for {}: {e}", + types::connect_params::PARAM_PROXY + ), + })?; + if let Some((user, password)) = p.proxy_credentials() { + proxy = proxy.basic_auth(user, password); + } + builder = builder.proxy(proxy); + } + if p.compression_disabled() { + builder = builder.compression_disabled(true); + } + if let Some(attempts) = p.max_attempts() { + builder = builder.max_attempt(attempts); + } + if let Some(encoding) = p.spooling_encoding() { + // Spooled pages are decoded by `Client::decode_page` in + // `execute.rs`; the catalog paths go through `get_all`, which + // resolves them itself. + tracing::debug!(encoding = %encoding, "advertising Trino's spooled protocol"); + builder = builder.spooling_encoding(encoding); + } + + if p.secure() { + match p.tls_verification() { + TlsVerification::None => tracing::warn!( + "TlsVerify=false: TLS certificate verification is disabled. \ + The connection is encrypted but not authenticated, so it is \ + vulnerable to man-in-the-middle attacks. Use Certificate=<pem> \ + to verify against a private CA instead." + ), + // Still authenticated, just not to a name, so this is a + // narrower compromise than `None` and gets a quieter notice. + TlsVerification::CaOnly => tracing::warn!( + "TlsVerify=ca: the certificate chain is verified but the hostname \ + is not, so a certificate this CA issued for any name is accepted." + ), + _ => {} + } + builder = builder.tls_verification(p.tls_verification()); + + // One `Ssl` carries both: a private CA to verify the coordinator + // against, and this client's own certificate for mutual TLS. They + // are independent, and either may be set alone. + let mut ssl = Ssl::new(); + let mut ssl_configured = false; + if let Some(cert_path) = p.certificate() { + let root_cert = + Ssl::read_pem(&cert_path.to_owned()).map_err(|e| TrinoError::General { + message: format!("failed to read certificate at {cert_path}: {e}"), + })?; + ssl = ssl.root_cert(root_cert); + ssl_configured = true; + } + if let Some(identity_path) = p.client_certificate() { + let identity = trino_rust_client::ssl::Identity::read_pem( + &identity_path.to_owned(), + ) + .map_err(|e| TrinoError::General { + message: format!("failed to read client certificate at {identity_path}: {e}"), + })?; + ssl = ssl.identity(identity); + ssl_configured = true; + } + if ssl_configured { + builder = builder.ssl(ssl); + } + } + + // Flattened, because this is where a TLS trust store the client cannot + // assemble surfaces. `reqwest::ClientBuilder::build` reports that as a + // bare `builder error`, which the client wraps as `Error::HttpError`, + // and the reason a certificate was refused sits one `source()` down. + let client = builder.build().map_err(|e| TrinoError::General { + message: format!("failed to build Trino client: {}", flatten_causes(&e)), + })?; + + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .map_err(|e| TrinoError::Runtime { source: e })?; + + let mut conn = TrinoConnection { + runtime: Arc::new(runtime), + client: Arc::new(client), + dbms_version: String::new(), + server_major: 0, + // Core supplies the DSN on both connection entry points, so this is + // the whole of `SQL_DATA_SOURCE_NAME`: absent means the application + // connected by driver rather than by DSN, which is the case the + // spec makes the empty string. + data_source_name: params.dsn().unwrap_or_default().to_string(), + server_name: p.host().to_string(), + // Filled in below, from the coordinator. Not `p.user()` here: that + // would be the login name, and the point of the probe is that the + // two differ. + user_name: String::new(), + catalog: p.catalog().map(str::to_owned), + describe_param_cache: Mutex::new(None), + liveness: Liveness::default(), + txn: Arc::new(TransactionState::default()), + }; + validate_connection(&conn, p.catalog(), login_timeout)?; + let probe = probe_session(&conn); + conn.dbms_version = probe.dbms_version; + conn.server_major = probe.server_major; + conn.user_name = session_user_name(probe.user.as_deref(), p.session_user(), p.user()); + Ok(conn) + } + + /// Rolls back an open transaction before dropping the connection. + /// + /// An abandoned transaction stays open on the coordinator until Trino's own + /// idle timeout, holding whatever it has locked. A failure here is logged + /// rather than returned: the application is disconnecting, and there is + /// nothing it could do with the error. + fn disconnect(conn: &mut TrinoConnection) -> Result<(), TrinoError> { + tracing::debug!("TrinoBackend::disconnect"); + if conn.in_transaction() + && let Err(e) = Self::end_tran(conn, false) + { + tracing::warn!( + error = %e, + "failed to roll back on disconnect; the transaction stays open on the \ + coordinator until its idle timeout" + ); + } + Ok(()) // runtime drops on its own + } + + fn browse_connect_attrs() -> Cow<'static, [Cow<'static, str>]> { + Cow::Borrowed(&[ + Cow::Borrowed("host"), + Cow::Borrowed("port"), + Cow::Borrowed("user"), + ]) + } + + /// Commits or rolls back the session's transaction, over Trino's own + /// `COMMIT` / `ROLLBACK` and the `X-Trino-Transaction-Id` header the client + /// tracks. + /// + /// Returns early with no I/O when nothing is open: Trino answers + /// `NOT_IN_TRANSACTION` there, while `SQLEndTran`'s page requires + /// `SQL_SUCCESS` when no transaction is active. + /// + /// This is one of a group that must agree, since each is separately + /// observable: [`Backend::txn_capable`] (`SQL_TC_DML`), + /// [`Backend::default_txn_isolation`] and + /// [`Backend::txn_isolation_options`] (both `SQL_TXN_READ_UNCOMMITTED`, + /// the only level every catalog accepts), [`Backend::set_autocommit`], + /// and [`Backend::cursor_commit_behavior`] / `cursor_rollback_behavior` + /// (both `Close`, measured). + fn end_tran(conn: &TrinoConnection, commit: bool) -> Result<(), TrinoError> { + tracing::debug!(commit, "TrinoBackend::end_tran"); + if !conn.in_transaction() { + // Nothing is open, so nothing is aborted. Clearing here as well as + // in `ensure_transaction` keeps the flag's meaning ("the currently + // open transaction has been aborted") true at every point rather + // than only where it is read. + conn.txn.begun(); + return Ok(()); + } + + // Read before `ended()` clears it. + // + // Trino refuses a COMMIT on an aborted transaction and leaves the id in + // place, so the session stays wedged until something rolls back. A + // rollback is therefore what goes out, and the commit is reported as + // the failure it is. Telling an application its writes landed when they + // were discarded is the one outcome to avoid. + let aborted = conn.txn.aborted(); + + // Every open result set on this connection dies with the transaction, + // so the epoch moves before the wire call rather than after: a + // `close_cursor` racing this must not drain pages the coordinator is + // already discarding. + conn.txn.ended(); + let result = if commit && !aborted { + conn.runtime.block_on(conn.client.commit()) + } else { + conn.runtime.block_on(conn.client.rollback()) + }; + result.map_err(|e| map_trino_error_on(&conn.liveness, e))?; + + if aborted && commit { + // `25S03`, not `HY000`, and the difference is observable. + // `SQLEndTran`'s Suspended State section names `25S03`, `40001`, + // `40002` and `HYC00` as the four SQLSTATEs that confirm the + // transaction did not complete; any other one leaves the Driver + // Manager holding the connection in a suspended state, where only + // read-only functions work until `SQLDisconnect`. The rollback + // above left this connection perfectly usable, so suspending it + // would be a worse outcome than the failed commit itself. + return Err(OdbcError::general( + "the transaction was rolled back: Trino aborted it when an earlier \ + statement failed, and refuses to commit an aborted transaction", + SqlState::transaction_rolled_back(), + ) + .into()); + } + Ok(()) + } + + /// Trino has no manual-commit session mode, so this records the mode and + /// issues nothing. The transaction opens at the first statement that needs + /// one; see `TrinoConnection::ensure_transaction`. + /// + /// Switching *to* autocommit commits what is open first, which the + /// `SQL_ATTR_AUTOCOMMIT` page requires of a driver whose data source has + /// no autocommit mode of its own. + /// + /// The new mode is recorded whatever that commit did, and the commit's + /// failure is reported afterwards. By the time [`Backend::end_tran`] can + /// fail it has already moved the epoch and cleared the abort flag, and in + /// the case that fails most often, a commit on a transaction Trino aborted, + /// it has also sent the rollback: the transaction is gone either way. An + /// early return would leave this connection in manual-commit mode with + /// nothing open while the application had been told the switch failed, so + /// the next statement would silently open a transaction nobody asked for. + fn set_autocommit(conn: &TrinoConnection, enabled: bool) -> Result<(), TrinoError> { + tracing::debug!(enabled, "TrinoBackend::set_autocommit"); + let ended = if enabled && conn.in_transaction() { + Self::end_tran(conn, true) + } else { + Ok(()) + }; + conn.txn.set_autocommit(enabled); + ended + } + + /// Trino names a query only once the coordinator accepts it, so the token + /// is the empty shared slot [`Backend::cancel_token`] describes for exactly + /// that case: the client and runtime are captured here, and whichever + /// statement-producing call submits the query fills in the id. + fn cancel_token(conn: &TrinoConnection) -> TrinoCancelToken { + tracing::debug!("TrinoBackend::cancel_token"); + TrinoCancelToken { + client: Arc::clone(&conn.client), + runtime: Arc::clone(&conn.runtime), + state: Arc::new(CancelState::default()), + liveness: conn.liveness.clone(), + } + } + + fn cancel(token: &TrinoCancelToken) -> Result<(), TrinoError> { + execute::cancel(token) + } + + /// The other half of [`Backend::cancel`]: `cancel` signals the token, this + /// observes it, and core turns a `true` here into the `HY008` the spec + /// gives a function interrupted by `SQLCancel`. + /// + /// This covers the cancel that lands *between* page requests; see + /// `TrinoError::OperationCancelled` for the in-flight half and why both + /// exist. + fn is_cancelled(token: &TrinoCancelToken) -> bool { + token.state.is_cancelled() + } + + /// Core enforces `SQL_ATTR_QUERY_TIMEOUT` by cancelling, because Trino + /// offers no per-statement server-side deadline this driver can set. + /// + /// The alternative, `SET SESSION query_max_run_time`, would work, since + /// `trino-rust-client` tracks `X-Trino-Set-Session`, and is rejected on two + /// counts. It is a *session* property, so every statement on the connection + /// would get the most recently set value, where core's timer is armed per + /// statement and matches the attribute's scope. And it would cost a round + /// trip inside `SQLSetStmtAttr`, which applications call freely and the + /// spec does not expect to block. + /// + /// [`QueryTimeout::CoreCancels`] asserts that [`Backend::cancel`] really + /// cancels, and it does: it issues Trino's `DELETE /v1/query/{id}`, so the + /// coordinator stops the work. That property is what normally argues for + /// [`QueryTimeout::DataSource`]. [`Backend::is_cancelled`] is implemented + /// alongside, as this variant requires. + /// + /// The deadline covers `SQLFetch`, which is what makes it useful against + /// Trino: the coordinator answers with column metadata before it has + /// computed a row, so `exec_direct` returns in milliseconds and a slow + /// query spends its time paging. + /// + /// unixODBC's `Threading` level does not affect it, unlike a cross-thread + /// `SQLCancel`: core's timer calls [`Backend::cancel`] directly from inside + /// this shared object, so it never crosses the Driver Manager. + fn set_query_timeout( + _conn: &TrinoConnection, + seconds: usize, + ) -> Result<QueryTimeout, TrinoError> { + tracing::debug!(seconds, "TrinoBackend::set_query_timeout"); + Ok(QueryTimeout::CoreCancels) + } + + /// Answered from the flag `map_trino_error_on` sets, never from a probe. + /// + /// A connection pool reads this on every checkout, and the spec asks a + /// driver to minimise what it sends to the server, so a round trip here + /// would be paid on a path that runs far more often than a query does. + fn connection_dead(conn: &TrinoConnection) -> bool { + let dead = conn.liveness.is_dead(); + tracing::debug!(dead, "TrinoBackend::connection_dead"); + dead + } + + // --- Delegations --- + + fn exec_direct( + conn: &TrinoConnection, + cancel: &TrinoCancelToken, + sql: &str, + ) -> Result<TrinoStatement, TrinoError> { + execute::exec_direct(conn, cancel, sql) + } + + fn prepare( + conn: &TrinoConnection, + cancel: &TrinoCancelToken, + sql: &str, + ) -> Result<TrinoStatement, TrinoError> { + execute::prepare(conn, cancel, sql) + } + + fn execute( + conn: &TrinoConnection, + cancel: &TrinoCancelToken, + stmt: &mut TrinoStatement, + params: &[ColumnValue], + ) -> Result<ExecuteOutcome, TrinoError> { + execute::execute(conn, cancel, stmt, params) + } + + // --- Capability statements --- + // + // Core derives the matching `SQLGetInfo` values from these, so none of + // them may also be answered from `info::trino_get_info`: an arm there + // would shadow the hook for `SQLGetInfo` while the hook kept driving + // `SQLGetConnectAttr` and the `HY024` validation in `sql_set_connect_attr`. + // + // Every value below was measured against a live coordinator, not read off + // the documentation; the probe for each is named in its doc comment. + + /// Trino qualifies names as `catalog.schema.table`, and this driver's + /// catalog functions query `information_schema` in a named catalog. + fn supports_catalogs(_conn: &TrinoConnection) -> bool { + true + } + + fn supports_schemas(_conn: &TrinoConnection) -> bool { + true + } + + fn alter_table_support(_conn: &TrinoConnection) -> u32 { + info::TRINO_ALTER_TABLE + } + + fn outer_join_capabilities(_conn: &TrinoConnection) -> u32 { + info::TRINO_OUTER_JOIN_CAPABILITIES + } + + /// `GROUP BY` must contain every non-aggregated column in the select list, + /// and may contain columns that are not in it: `SELECT a, b ... GROUP BY a` + /// fails with `EXPRESSION_NOT_AGGREGATE`, while `SELECT a ... GROUP BY a, b` + /// succeeds. That is `SQL_GB_GROUP_BY_CONTAINS_SELECT` exactly. + fn group_by(_conn: &TrinoConnection) -> u16 { + SQL_GB_GROUP_BY_CONTAINS_SELECT + } + + /// Trino's default null ordering is `NULLS LAST` *regardless of the + /// ordering direction*: `ORDER BY x` and `ORDER BY x DESC` both place NULLs + /// last. `SQL_NC_END` is the value for that. `SQL_NC_HIGH` would mean the + /// position follows `ASC`/`DESC`, which is a different data source. + /// + /// <https://trino.io/docs/current/sql/select.html> + fn null_collation(_conn: &TrinoConnection) -> u16 { + SQL_NC_END + } + + /// Table correlation names are supported and unrestricted: + /// `FROM (VALUES 1) AS x(a)` binds a name unrelated to the table's own. + fn correlation_name(_conn: &TrinoConnection) -> u16 { + SQL_CN_ANY + } + + /// The connection-string keywords whose values are bearer tokens. + /// + /// Core keeps a substring heuristic underneath that already catches all + /// three of this driver's secrets (`password`, and `token` for both spellings + /// below), so declaring them redacts nothing that leaked before. It states + /// the driver's own vocabulary explicitly instead of relying on another + /// crate's pattern list to keep matching it, and it is what makes a + /// `{:?}` on the `ConnectParams` handed to `connect` redact too. + /// + /// `Password` is core's own spec-defined keyword, redacted there by name. + fn sensitive_connect_keywords() -> Cow<'static, [Cow<'static, str>]> { + use types::connect_params::{ + PARAM_ACCESS_TOKEN, PARAM_EXTRA_CREDENTIALS, PARAM_EXTRA_HEADERS, PARAM_PROXY_PASSWORD, + PARAM_TOKEN, + }; + Cow::Borrowed(&[ + Cow::Borrowed(PARAM_PROXY_PASSWORD), + Cow::Borrowed(PARAM_ACCESS_TOKEN), + Cow::Borrowed(PARAM_TOKEN), + // Whole values are credentials the connection forwards to a + // connector: an S3 key, a Kerberos ticket. Core has no way to + // know a `name:value` list holds secrets, so it has to be declared. + Cow::Borrowed(PARAM_EXTRA_CREDENTIALS), + // Not credentials by definition, unlike the three above, but a + // header a gateway demands is routinely an API key and the name + // alone does not say. Declared because the cost of redacting a + // header that turns out to be innocuous is nothing, and the cost of + // echoing one that was not is a key in a log. + Cow::Borrowed(PARAM_EXTRA_HEADERS), + ]) + } + + /// Trino folds unquoted identifiers to lower case and stores them that + /// way, so `SELECT * FROM Foo` and `SELECT * FROM foo` name the same + /// table and `SQLTables` reports it as `foo`. + /// + /// This is what an application reads to decide how to quote generated + /// SQL, which is why core requires the hook rather than defaulting it: + /// every one of the four `SQL_IC_*` values is a different claim about how + /// the data source folds identifiers, and no default can be legal. + /// + /// <https://trino.io/docs/current/language/reserved.html> + fn identifier_case(_conn: &TrinoConnection) -> u16 { + SQL_IC_LOWER + } + + /// `SQL_IC_LOWER` as well: a *quoted* identifier is case-insensitive too, + /// and the system catalog stores it lower case. + /// + /// Not the `SQL_IC_SENSITIVE` a reader of the SQL standard would expect, + /// and measured against a live coordinator. A column created in PostgreSQL + /// as `"MixedCol"`, reached through the `postgresql` catalog: + /// + /// | Probe | Result | + /// |---|---| + /// | `information_schema.columns.column_name` | `mixedcol` | + /// | `SELECT "MixedCol" FROM ...` | 1 row | + /// | `SELECT "mixedcol" FROM ...` | 1 row | + /// | `SELECT "MIXEDCOL" FROM ...` | 1 row | + /// + /// All three spellings resolve, so quoted identifiers are case-*in*sensitive, + /// which rules out `SQL_IC_SENSITIVE`, and the catalog reports the name + /// folded down, which rules out `SQL_IC_MIXED`. That is `SQL_IC_LOWER`'s + /// definition exactly. The same holds one level up: `CREATE TABLE + /// postgresql.s."MixedCase"` lands in PostgreSQL as `mixedcase`. + /// + /// An application generating SQL from `SQLColumns` / `SQLTables` output + /// reads this to decide how to quote. `SQL_IC_SENSITIVE` would tell it a + /// quoted name must match the catalog's spelling exactly, which Trino does + /// not require. + fn quoted_identifier_case(_conn: &TrinoConnection) -> u16 { + SQL_IC_LOWER + } + + /// `ALTER TABLE ... ADD COLUMN f integer NOT NULL` is accepted, so the + /// `NOT NULL` column constraint is supported. + fn non_nullable_columns(_conn: &TrinoConnection) -> u16 { + SQL_NNC_NON_NULL + } + + /// `ORDER BY lower(s)` is accepted, not just bare column references. + fn expressions_in_order_by(_conn: &TrinoConnection) -> bool { + true + } + + /// Trino conforms to no SQL-92 level this info type can name. + /// + /// Entry level requires referential integrity in `CREATE TABLE`, and + /// Trino's grammar rejects all four constraint forms outright: `PRIMARY + /// KEY`, `UNIQUE`, `CHECK` and `REFERENCES` each fail with `SYNTAX_ERROR`. + /// That one requirement rules the level out, and is the same measurement + /// behind `SQL_INTEGRITY = "N"`. Entry level's other demand, `COMMIT` and + /// `ROLLBACK`, *is* met, so the constraint grammar is the whole of the + /// argument and a Trino release that accepted `PRIMARY KEY` calls for + /// re-examining this. + /// + /// `0` is not one of the four `SQL_SC_*` values, the spec's list having no + /// "conforms to nothing" entry, and it is the only honest answer when the + /// lowest named level is not met. `SQL_SC_SQL92_ENTRY` would be the + /// overstatement the capability hooks exist to prevent. + /// + /// [`Backend::group_by`] reporting `SQL_GB_GROUP_BY_CONTAINS_SELECT` is no + /// reason to avoid entry level. `CONTAINS_SELECT` is strictly more + /// permissive than the `EQUALS_SELECT` the spec names for an entry-level + /// driver, and the spec directs applications to read the general level here + /// and "use the other information types to determine variations from the + /// stated standards compliance level". + fn sql_conformance(_conn: &TrinoConnection) -> u32 { + 0 + } + + /// The units `{fn TIMESTAMPADD}` / `{fn TIMESTAMPDIFF}` accept, which is + /// every unit `crate::escape_dialect::trino_interval_unit` can rewrite: + /// the two lists are the same list, and a unit named here that the + /// dialect declines would be a claim an application cannot use. + /// + /// `SQL_FN_TSI_FRAC_SECOND` is absent. ODBC defines it as + /// billionths of a second; Trino's `date_add`/`date_diff` reject + /// `nanosecond` and their finest unit is `millisecond`, which ODBC has no + /// bit for. Claiming it would be a factor of a million out. + fn timedate_add_intervals(_conn: &TrinoConnection) -> u32 { + TRINO_TIMESTAMP_INTERVALS + } + + fn timedate_diff_intervals(_conn: &TrinoConnection) -> u32 { + TRINO_TIMESTAMP_INTERVALS + } + + /// Every `SQL_SQ_*` predicate accepts a subquery, correlated ones + /// included: each of `= (SELECT ...)`, `= ANY (SELECT ...)`, + /// `<= ALL (SELECT ...)`, `IN (SELECT ...)` and `EXISTS (SELECT ...)` + /// runs, as does a subquery referencing the outer query's row. + fn subqueries(_conn: &TrinoConnection) -> u32 { + SQL_SQ_COMPARISON + | SQL_SQ_EXISTS + | SQL_SQ_IN + | SQL_SQ_QUANTIFIED + | SQL_SQ_CORRELATED_SUBQUERIES + } + + fn column_alias(_conn: &TrinoConnection) -> bool { + true + } + + /// `concat('a', NULL)` and `'a' || NULL` both evaluate to NULL, which is + /// `SQL_CB_NULL`. + fn concat_null_behavior(_conn: &TrinoConnection) -> u16 { + SQL_CB_NULL + } + + fn union_support(_conn: &TrinoConnection) -> u32 { + SQL_U_UNION | SQL_U_UNION_ALL + } + + /// `CAST` only. Trino has no `CONVERT` scalar function: + /// `CONVERT('1', INTEGER)` fails to resolve. + fn convert_functions(_conn: &TrinoConnection) -> u32 { + SQL_FN_CVT_CAST + } + + /// `false`: `SELECT b FROM t ORDER BY a` runs, so a column may be ordered + /// by without being selected. + fn order_by_columns_in_select(_conn: &TrinoConnection) -> bool { + false + } + + /// `false`. Trino *can* filter `information_schema` by privilege, but only + /// when the deployment configures access control, and with the default + /// allow-all it does not, and the driver cannot tell which it is talking + /// to. `SQL_ACCESSIBLE_TABLES = "Y"` is a guarantee about the connected + /// principal, so it must not be made on a maybe. + fn accessible_tables(_conn: &TrinoConnection) -> bool { + false + } + + /// `false`. The guarantee is that the connected user can execute every + /// procedure `SQLProcedures` returns; this driver's + /// [`Backend::procedures`] returns none, because Trino publishes no + /// metadata naming them (see AGENTS.md). Answering `"Y"` about an empty + /// set would be vacuously true and read as a claim about a set that does + /// not exist, so it stays `"N"`, which is what `SQL_ACCESSIBLE_PROCEDURES` + /// reports. + fn accessible_procedures(_conn: &TrinoConnection) -> bool { + false + } + + /// `false`. The Integrity Enhancement Facility is referential-integrity + /// DDL, and Trino's grammar rejects all four constraint forms outright: + /// `PRIMARY KEY`, `UNIQUE`, `CHECK` and `REFERENCES` each fail with + /// `SYNTAX_ERROR` against a live coordinator. That is the same measurement + /// [`TrinoBackend::sql_conformance`] cites for refusing SQL-92 entry level. + fn integrity(_conn: &TrinoConnection) -> bool { + false + } + + /// The characters legal in an unquoted identifier beyond `a`–`z`, `A`–`Z`, + /// `0`–`9` and `_`: none, so the empty string. + /// + /// A measurement rather than an understatement: `SELECT 1 AS a@b` and the + /// same with `:`, `$`, `#`, `-` and a space each fail with `SYNTAX_ERROR` + /// against a live coordinator, while `a_b` and `ab` succeed. Trino's + /// `IDENTIFIER` production is `(LETTER | '_') (LETTER | DIGIT | '_')*`, and + /// that is exactly the set ODBC excludes from this info type. + fn special_characters(_conn: &TrinoConnection) -> Cow<'static, str> { + Cow::Borrowed("") + } + + /// `false`: writes reach whichever connector backs the catalog, and + /// `CREATE TABLE`, `INSERT` and `DROP TABLE` all run against the + /// PostgreSQL catalog in the test stack. + fn data_source_read_only(_conn: &TrinoConnection) -> bool { + false + } + + /// Backslash, which is what `metadata.rs` emits: a catalog-function + /// pattern containing a wildcard becomes `LIKE '...' ESCAPE '\'`. + fn search_pattern_escape(_conn: &TrinoConnection) -> Cow<'static, str> { + Cow::Borrowed("\\") + } + + /// Trino's reserved words, raw: core subtracts ODBC's own, sorts and + /// joins them into `SQL_KEYWORDS`. See `info::TRINO_RESERVED_KEYWORDS` + /// for where the list comes from and why it is static rather than probed. + fn keywords(_conn: &TrinoConnection) -> Cow<'static, [Cow<'static, str>]> { + Cow::Borrowed(info::reserved_keywords()) + } + + /// `SQL_TC_DML`: transactions carry DML, and DDL inside one is an error. + /// + /// True for `postgresql` and every JDBC-backed catalog, where a + /// `CREATE TABLE` inside a transaction is `AUTOCOMMIT_WRITE_CONFLICT`. It + /// understates the `hive` catalog, where DDL in a transaction works, and + /// understating is the safe direction: an application that believes this is + /// never wrong, while one believing `SQL_TC_ALL` would be wrong on the + /// catalog most applications use. + /// + /// `u16`, not `u32`: `SQL_TXN_CAPABLE` is an `SQLUSMALLINT` per the + /// `SQLGetInfo` page, while the `SQL_TC_*` constants are typed `u32` for + /// use in bitmask expressions. + fn txn_capable(_conn: &TrinoConnection) -> u16 { + SQL_TC_DML as u16 + } + + /// `true`. Each connection carries its own Trino session and therefore its + /// own `X-Trino-Transaction-Id`, so transactions on separate connections + /// are independent. + /// + /// One *session* holds at most one transaction, which is what Trino reports + /// as `NOT_SUPPORTED: Nested transactions not supported`, and is a + /// different question from this one. + fn multiple_active_txn(_conn: &TrinoConnection) -> bool { + true + } + + /// `SQL_TXN_READ_UNCOMMITTED`, the level Trino applies to a bare + /// `START TRANSACTION`, which is what this driver issues. + fn default_txn_isolation(_conn: &TrinoConnection) -> u32 { + SQL_TXN_READ_UNCOMMITTED + } + + /// `SQL_TXN_READ_UNCOMMITTED` alone, the only level every catalog accepts. + /// + /// Trino's grammar takes all four, but the *connector* vets the level, and + /// not until the first statement that touches a catalog. Measured against + /// the test stack: + /// + /// | Level | tpcds | postgresql | hive | + /// |---|---|---|---| + /// | READ UNCOMMITTED | ok | ok | ok | + /// | READ COMMITTED | ok | ok | `UNSUPPORTED_ISOLATION_LEVEL` | + /// | REPEATABLE READ | ok | `UNSUPPORTED_ISOLATION_LEVEL` | same | + /// | SERIALIZABLE | ok | `UNSUPPORTED_ISOLATION_LEVEL` | same | + /// + /// One connection can span catalogs that disagree, so anything wider would + /// be a promise this driver cannot keep, and it would be broken as a + /// failed query rather than as a refused attribute. Core's + /// `validate_txn_isolation` rejects every other level with `HY024` before + /// it reaches the wire. + fn txn_isolation_options(_conn: &TrinoConnection) -> u32 { + SQL_TXN_READ_UNCOMMITTED + } + + /// `SQL_CB_CLOSE`. Trino discards a transaction's result sets when it ends: + /// a page request afterwards answers `GENERAL_INTERNAL_ERROR: Already + /// finished`. + /// + /// Measured against a live coordinator, with controls that rule out the + /// alternatives: the same held cursor resumes across an unrelated statement + /// on the same session, and across no transaction at all, delivering every + /// remaining row. Only the transaction ending kills it. + /// + /// `Close` rather than `Delete` because the access plan survives; Trino has + /// nothing that a commit unprepares. + fn cursor_commit_behavior() -> CursorBehavior { + CursorBehavior::Close + } + + /// `SQL_CB_CLOSE`, for the reason given on + /// [`TrinoBackend::cursor_commit_behavior`]: a rollback ends the + /// transaction exactly as a commit does, and takes the result sets with it. + fn cursor_rollback_behavior() -> CursorBehavior { + CursorBehavior::Close + } + + // --- Identity --- + // + // The two driver-level answers take no connection, because the Windows + // Driver Manager asks for them before `SQLDriverConnectW`. Declaring both + // is what lets core answer the whole pre-connect group the DM wants + // (`SQL_DRIVER_NAME`, `SQL_DRIVER_VER`, `SQL_DRIVER_ODBC_VER`, + // `SQL_ASYNC_DBC_FUNCTIONS` and `SQL_MAX_CONCURRENT_ACTIVITIES`), so + // `get_info_pre_connect` overrides nothing for its benefit. + + /// The name the driver is registered under in `odbcinst.ini` and in the + /// Windows registry; `packaging/` writes exactly this string. + fn driver_name() -> Cow<'static, str> { + Cow::Borrowed("stackable-odbc-trino") + } + + /// This crate's `Cargo.toml` version, in the spec's `##.##.####` form. + fn driver_version() -> Cow<'static, str> { + Cow::Owned(stackable_odbc_core::driver_version!()) + } + + /// The DBMS is Trino whatever the connection reached: this driver speaks + /// only Trino's REST protocol, so there is no other answer. + fn dbms_name(_conn: &TrinoConnection) -> Cow<'static, str> { + Cow::Borrowed("Trino") + } + + /// The coordinator's own version, captured from the `X-Trino-Server`-style + /// probe `connect` runs, already normalised to `##.##.####`. + /// + /// Connection-dependent, which is why the pre-connect path in + /// `info::get_info_pre_connect` answers the empty string instead: before + /// a connection exists there is no server to report a version for, and the + /// empty string is the spec's "not available". + fn dbms_version(conn: &TrinoConnection) -> Cow<'static, str> { + Cow::Owned(conn.dbms_version.clone()) + } + + /// The catalog the session is on **now**, which is not necessarily the one + /// the `Catalog` connection-string key named. + /// + /// This is the one place the value lives. Core feeds it to both readers the + /// spec makes synonyms (`SQLGetConnectAttr(SQL_ATTR_CURRENT_CATALOG)` and + /// `SQLGetInfo(SQL_DATABASE_NAME)`), so there is no arm for either in + /// `info.rs`. `None` when neither the session nor the connection string + /// names one, which both readers render as the empty string, the spec's + /// "not available". + /// + /// Read from the client's session rather than from `ConnectParams`, because + /// `USE postgresql.public` moves the coordinator's session catalog and says + /// so in `X-Trino-Set-Catalog`, which the client tracks. Reporting the + /// connection-string value after that names a catalog the session left, and + /// unqualified names in the application's own SQL resolve against the one + /// reported here. + /// + /// The snapshot takes a read lock and performs no I/O, so this stays cheap + /// enough for a connection pool that reads the attribute on every checkout. + /// `ConnectParams` remains the fallback for the window before the first + /// response has been seen. + /// + /// [`Backend::set_current_catalog`] is *not* implemented, so an application + /// can read this catalog and cannot change it through ODBC, only by + /// executing `USE`. See below. + fn current_catalog(conn: &TrinoConnection) -> Option<Cow<'static, str>> { + conn.runtime + .block_on(conn.client.session_snapshot()) + .catalog + .or_else(|| conn.catalog.clone()) + .map(Cow::Owned) + } + + // `set_current_catalog` keeps core's default, which reports `HYC00`, so + // `SQLSetConnectAttr(SQL_ATTR_CURRENT_CATALOG)` fails instead of + // pretending. `USE` is the only statement moving the session catalog, and + // its grammar requires a schema (`USE postgresql` is `NOT_FOUND`, parsed as + // a schema name), so honouring "set the catalog to X" means inventing one. + // AGENTS.md, "Why the catalog cannot be set", carries the measurements and + // the rejected alternatives. Revisit if `SET SESSION CATALOG`, or any + // catalog-only form of `USE`, ever lands. + + fn get_info( + conn: &TrinoConnection, + info_type: stackable_odbc_core::types::InfoType, + ) -> Result<InfoValue, TrinoError> { + info::get_info(conn, info_type) + } + + fn get_info_pre_connect( + info_type: stackable_odbc_core::types::InfoType, + ) -> Result<InfoValue, TrinoError> { + info::get_info_pre_connect(info_type) + } + + fn get_info_raw( + conn: &TrinoConnection, + info_type: u16, + ) -> Option<Result<InfoValue, TrinoError>> { + info::get_info_raw(conn, info_type) + } + + fn get_functions() -> Cow<'static, [stackable_odbc_core::function_id::FunctionId]> { + Cow::Borrowed(info::get_functions()) + } + + fn get_type_info(_conn: &TrinoConnection) -> Cow<'static, [TypeInfoRow]> { + Cow::Borrowed(info::get_type_info()) + } + + // The six catalog functions, and the two enumerations that do I/O, take a + // cancel token they do not record anything in, so `SQLCancel` cannot + // interrupt them. + // + // Four of them (`primary_keys`, `foreign_keys`, `statistics`, + // `special_columns`) return no rows without touching the network, so there + // is nothing to cancel. `tables`, `columns`, `catalogs` and `schemas` do + // query Trino, but through `query_all_rows` -> `Client::get_all`, which + // pages to exhaustion inside the client and never surfaces the query id a + // DELETE needs. Making them cancellable means replacing that with manual + // paging. + fn tables( + conn: &TrinoConnection, + _cancel: &TrinoCancelToken, + query: &TablesQuery<'_>, + ) -> Result<Vec<TableRow>, TrinoError> { + metadata::tables(conn, query) + } + + /// The two `information_schema.tables.table_type` values `metadata::tables` + /// maps to an ODBC `TABLE_TYPE`; it drops every other row, so this is the + /// complete list of types a `SQLTables` result set can carry. + fn table_types(_conn: &TrinoConnection) -> Vec<Cow<'static, str>> { + metadata::table_types() + } + + /// Required rather than defaulted because [`Self::supports_catalogs`] + /// answers `true`: a backend that claims catalogs and leaves this alone + /// answers `HYC00` to `SQLTables`' `SQL_ALL_CATALOGS` enumeration. + fn catalogs( + conn: &TrinoConnection, + _cancel: &TrinoCancelToken, + ) -> Result<Vec<String>, TrinoError> { + metadata::catalogs(conn) + } + + /// Required for the same reason as [`Self::catalogs`], against + /// [`Self::supports_schemas`]. + fn schemas( + conn: &TrinoConnection, + _cancel: &TrinoCancelToken, + ) -> Result<Vec<String>, TrinoError> { + metadata::schemas(conn) + } + + fn columns( + conn: &TrinoConnection, + _cancel: &TrinoCancelToken, + query: &ColumnsQuery<'_>, + ) -> Result<Vec<ColumnRow>, TrinoError> { + metadata::columns(conn, query) + } + + fn primary_keys( + conn: &TrinoConnection, + _cancel: &TrinoCancelToken, + query: &PrimaryKeysQuery<'_>, + ) -> Result<Vec<PrimaryKeyRow>, TrinoError> { + metadata::primary_keys(conn, query) + } + + fn foreign_keys( + conn: &TrinoConnection, + _cancel: &TrinoCancelToken, + query: &ForeignKeysQuery<'_>, + ) -> Result<Vec<ForeignKeyRow>, TrinoError> { + metadata::foreign_keys(conn, query) + } + + fn statistics( + conn: &TrinoConnection, + _cancel: &TrinoCancelToken, + query: &StatisticsQuery<'_>, + ) -> Result<Vec<StatisticsRow>, TrinoError> { + metadata::statistics(conn, query) + } + + fn special_columns( + conn: &TrinoConnection, + _cancel: &TrinoCancelToken, + query: &SpecialColumnsQuery<'_>, + ) -> Result<Vec<SpecialColumnRow>, TrinoError> { + metadata::special_columns(conn, query) + } + + /// Answered from Trino's `DESCRIBE INPUT`, so a client sizing its buffers + /// from `SQLDescribeParam` gets the type Trino inferred rather than core's + /// generic `VARCHAR`. See `backend::describe_param` for the round trip and + /// why it cannot go through the bound-parameter path. + fn describe_param( + conn: &TrinoConnection, + sql: &str, + parameter_number: u16, + ) -> Result<Option<ParamDescriptor>, TrinoError> { + describe_param::describe_param(conn, sql, parameter_number) + } + + // The four catalog functions core defaults to an empty result set. Only + // `table_privileges` has anything to read: Trino models table-level + // privileges in `information_schema.table_privileges` and nothing else in + // this group. All four are stated rather than left defaulted so the reason + // is recorded next to the answer and the call is logged like every other + // backend method; see `metadata` for what each one checked. + fn table_privileges( + conn: &TrinoConnection, + _cancel: &TrinoCancelToken, + query: &TablePrivilegesQuery<'_>, + ) -> Result<Vec<TablePrivilegeRow>, TrinoError> { + metadata::table_privileges(conn, query) + } + + fn column_privileges( + conn: &TrinoConnection, + _cancel: &TrinoCancelToken, + query: &ColumnPrivilegesQuery<'_>, + ) -> Result<Vec<ColumnPrivilegeRow>, TrinoError> { + metadata::column_privileges(conn, query) + } + + fn procedures( + conn: &TrinoConnection, + _cancel: &TrinoCancelToken, + query: &ProceduresQuery<'_>, + ) -> Result<Vec<ProcedureRow>, TrinoError> { + metadata::procedures(conn, query) + } + + fn procedure_columns( + conn: &TrinoConnection, + _cancel: &TrinoCancelToken, + query: &ProcedureColumnsQuery<'_>, + ) -> Result<Vec<ProcedureColumnRow>, TrinoError> { + metadata::procedure_columns(conn, query) + } + + /// Trino's `{fn}`/`{d}`/`{t}`/`{ts}` escape-translation dialect. See + /// `crate::escape_dialect` for the remap table and its justification + /// against the `SQL_*_FUNCTIONS` bitmaps in `backend/info.rs`. + fn escape_dialect(_conn: &TrinoConnection) -> stackable_odbc_core::escape::EscapeDialect { + crate::escape_dialect::dialect() + } +} + +#[cfg(test)] +mod auth_tests { + use super::*; + + /// Not requesting external authentication, and being allowed to prompt, + /// is the shape of every case but the OAuth 2.0 ones. + fn without_external( + secure: bool, + password: Option<&str>, + access_token: Option<&str>, + ) -> Result<AuthChoice, TrinoError> { + resolve_auth(secure, password, access_token, false, true) + } + + #[test] + fn jwt_over_https_selected() { + assert_eq!( + without_external(true, None, Some("tok")).unwrap(), + AuthChoice::Jwt + ); + } + + #[test] + fn jwt_over_http_rejected() { + let e = without_external(false, None, Some("tok")).unwrap_err(); + assert!(matches!(e, TrinoError::AuthConfig { .. }), "got {e:?}"); + } + + #[test] + fn token_and_password_rejected() { + let e = without_external(true, Some("pw"), Some("tok")).unwrap_err(); + assert!(matches!(e, TrinoError::AuthConfig { .. }), "got {e:?}"); + } + + #[test] + fn basic_over_https_when_password_only() { + assert_eq!( + without_external(true, Some("pw"), None).unwrap(), + AuthChoice::Basic + ); + } + + #[test] + fn basic_over_https_when_no_credentials_preserves_prior_behavior() { + assert_eq!( + without_external(true, None, None).unwrap(), + AuthChoice::Basic + ); + } + + #[test] + fn external_over_https_selected() { + assert_eq!( + resolve_auth(true, None, None, true, true).unwrap(), + AuthChoice::External + ); + } + + #[test] + fn external_over_http_rejected() { + let e = resolve_auth(false, None, None, true, true).unwrap_err(); + assert!(matches!(e, TrinoError::AuthConfig { .. }), "got {e:?}"); + } + + /// The application passed `SQL_DRIVER_NOPROMPT`, so there is no way to show + /// a login URL and no non-interactive credential to fall back to. + #[test] + fn external_without_a_prompter_rejected() { + let e = resolve_auth(true, None, None, true, false).unwrap_err(); + assert!( + matches!(&e, TrinoError::AuthConfig { message } if message.contains("SQL_DRIVER_NOPROMPT")), + "got {e:?}" + ); + } + + #[test] + fn external_with_a_password_or_token_rejected() { + for (password, token) in [(Some("pw"), None), (None, Some("tok"))] { + let e = resolve_auth(true, password, token, true, true).unwrap_err(); + assert!( + matches!(e, TrinoError::AuthConfig { .. }), + "expected ambiguous-authentication for {password:?}/{token:?}, got {e:?}" + ); + } + } + + /// A prompter being available changes nothing when nobody asked for the + /// interactive flow. + #[test] + fn a_prompter_alone_does_not_select_external() { + assert_eq!( + resolve_auth(true, Some("pw"), None, false, true).unwrap(), + AuthChoice::Basic + ); + } + + #[test] + fn no_auth_when_neither() { + assert_eq!( + without_external(false, None, None).unwrap(), + AuthChoice::None + ); + // password over http is dropped upstream in connect(), not here: + assert_eq!( + without_external(false, Some("pw"), None).unwrap(), + AuthChoice::None + ); + } +} + +#[cfg(test)] +mod tests { + use serial_test::serial; + use stackable_odbc_core::{ + backend::StatementBackend, + types::{ColumnValue, FetchResult, InfoType}, + }; + + use super::*; + + /// Host, port, credentials and TLS for the tests that need a live + /// coordinator. + /// + /// `./integration-tests/setup.sh` serves HTTPS only, so these verify + /// against the test CA. The path is resolved from `CARGO_MANIFEST_DIR` at + /// compile time rather than written out: `generated/` is produced per + /// checkout, so a literal path would only work in one of them. + /// + /// The credentials are part of the constant because the stack requires + /// PASSWORD authentication on every request. `admin` is the only principal + /// in the generated `password.db`. + /// + /// The parse-only tests below do *not* use this: their port and protocol + /// are incidental to what they assert. + const LIVE: &str = concat!( + "Host=localhost;Port=8443;Protocol=https;User=admin;Password=admin;Certificate=", + env!("CARGO_MANIFEST_DIR"), + "/integration-tests/generated/certs/ca.crt" + ); + + /// A reqwest error whose `is_connect()` is set, which is what + /// [`map_trino_error`] turns into `08S01`. + /// + /// Built by asking the client to reach a port nothing listens on rather + /// than by constructing a `reqwest::Error`, which has no public + /// constructor. Port 1 on the loopback interface is refused immediately, so + /// this costs no wall time and reaches no network. + fn connect_failure() -> trino_rust_client::error::Error { + let client = ClientBuilder::new("test", "127.0.0.1") + .port(1) + .build() + .expect("ClientBuilder::build performs no I/O"); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("Runtime::build performs no I/O"); + runtime + .block_on(client.get_all::<trino_rust_client::Row>("SELECT 1".to_string())) + .expect_err("nothing listens on 127.0.0.1:1") + } + + /// A reqwest error whose `is_timeout()` is set, which is what + /// [`map_trino_error`] turns into `HYT00`. + /// + /// Built against a listener on the loopback interface that accepts the + /// connection and then answers nothing, so the request is still in flight + /// when the client's own timeout fires. A refused port would produce a + /// connect error instead, and no coordinator is needed to make a request + /// take longer than it is allowed to. + fn timeout_failure() -> trino_rust_client::error::Error { + let listener = + std::net::TcpListener::bind("127.0.0.1:0").expect("the loopback interface is bindable"); + let port = listener + .local_addr() + .expect("a bound listener has an address") + .port(); + // Holds the accepted connection open, which is what makes the request + // time out rather than fail. Detached, and outlived by the test. + std::thread::spawn(move || { + let _accepted = listener.accept(); + std::thread::sleep(Duration::from_secs(5)); + }); + + let client = ClientBuilder::new("test", "127.0.0.1") + .port(port) + .client_request_timeout(Duration::from_millis(250)) + .build() + .expect("ClientBuilder::build performs no I/O"); + let runtime = tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("Runtime::build performs no I/O"); + runtime + .block_on(client.get_all::<trino_rust_client::Row>("SELECT 1".to_string())) + .expect_err("a listener that answers nothing cannot satisfy the request") + } + + /// The sibling of `a_link_failure_names_the_cause_beneath_reqwest`, for the + /// arm that classifies a timeout. reqwest hides the same detail here, and a + /// timeout waiting for a connection is a different operational problem from + /// one waiting for a coordinator that accepted the request and is still + /// thinking about it. + #[test] + fn a_timeout_names_the_cause_beneath_reqwest() { + let mapped = map_trino_error(timeout_failure()); + let TrinoError::QueryTimeout { message } = mapped else { + panic!("a request that outran its timeout is HYT00, not {mapped:?}"); + }; + assert!( + message.contains("timed out") && message.len() > "request timed out: ".len(), + "the message must name what timed out beneath reqwest, got: {message}" + ); + } + + /// `SQL_ATTR_CONNECTION_DEAD` asserts the connection *has been lost*, so a + /// connection that has observed nothing must answer `SQL_CD_FALSE`. The + /// asymmetry is the point: `false` means "not known to be dead", and a pool + /// that reads `true` here discards the connection. + #[test] + fn connection_dead_is_false_until_the_link_is_observed_to_fail() { + let conn = disconnected_trino_conn(); + assert!( + !TrinoBackend::connection_dead(&conn), + "a connection that has attempted nothing has not been lost" + ); + } + + /// The flag is set from wherever the failure was seen, including a + /// statement's page fetch, because `SQL_ATTR_CONNECTION_DEAD` is a fact + /// about the connection, not about the handle that noticed. + #[test] + fn connection_dead_is_true_once_a_link_failure_is_mapped() { + let conn = disconnected_trino_conn(); + let mapped = map_trino_error_on(&conn.liveness, connect_failure()); + + assert!( + matches!(mapped, TrinoError::CommunicationLinkFailure { .. }), + "an unreachable coordinator is a link failure, not {mapped:?}" + ); + assert!( + TrinoBackend::connection_dead(&conn), + "a mapped link failure must be visible through SQL_ATTR_CONNECTION_DEAD" + ); + } + + /// A refused port, a certificate the client will not trust and a host that + /// does not resolve all satisfy `is_connect()`, so one arm classifies all + /// three and the message is the only thing separating them. + /// + /// `reqwest::Error`'s own `Display` separates nothing: it reports + /// `error sending request for url (...)` for every one of them and leaves + /// the discriminating text in `source()`. An application holding nothing + /// but the diagnostic record, which is every Power BI user, then cannot + /// tell a rejected certificate from a coordinator that is switched off. + #[test] + fn a_link_failure_names_the_cause_beneath_reqwest() { + let mapped = map_trino_error(connect_failure()); + let TrinoError::CommunicationLinkFailure { message } = mapped else { + panic!("an unreachable coordinator is a link failure, not {mapped:?}"); + }; + assert!( + message.contains("refused"), + "the message must name what failed beneath reqwest, got: {message}" + ); + } + + /// One error wrapping another, for asserting the shape of the flattening + /// without depending on any library's wording. + #[derive(Debug)] + struct Layer { + text: String, + source: Option<Box<Layer>>, + } + + impl std::fmt::Display for Layer { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.write_str(&self.text) + } + } + + impl std::error::Error for Layer { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + self.source + .as_ref() + .map(|s| s.as_ref() as &(dyn std::error::Error + 'static)) + } + } + + /// Builds a chain from outermost to innermost. + fn layers(texts: &[&str]) -> Layer { + let mut chain: Option<Box<Layer>> = None; + for text in texts.iter().rev() { + chain = Some(Box::new(Layer { + text: (*text).to_owned(), + source: chain, + })); + } + *chain.expect("layers() is only called with a non-empty slice") + } + + /// The rejection an untrusted certificate produces is worded by rustls, not + /// here, so what this side must guarantee is that every layer reaches the + /// message however deep it sits. Asserted on a synthetic chain for exactly + /// that reason: it holds whatever rustls decides to say. + #[test] + fn flatten_causes_reaches_every_layer() { + let chain = layers(&[ + "error sending request for url (https://trino:8443/v1/statement)", + "client error (Connect)", + "invalid peer certificate: UnknownIssuer", + ]); + + assert_eq!( + flatten_causes(&chain), + "error sending request for url (https://trino:8443/v1/statement): \ + client error (Connect): invalid peer certificate: UnknownIssuer" + ); + } + + /// reqwest's layers quote the text of the layer beneath them, so a naive + /// walk prints the same sentence several times and buries the one line that + /// matters. A cause already present is dropped. + #[test] + fn flatten_causes_drops_a_cause_the_message_already_carries() { + let chain = layers(&["outer: inner detail", "inner detail"]); + + assert_eq!(flatten_causes(&chain), "outer: inner detail"); + } + + /// Only a link failure counts. A server-side query rejection leaves the + /// connection perfectly usable, and reporting `SQL_CD_TRUE` for one would + /// make a pool discard a healthy connection on every application error. + #[test] + fn connection_dead_ignores_failures_that_leave_the_link_intact() { + let conn = disconnected_trino_conn(); + conn.liveness.note(&TrinoError::Query { + source: QueryCause::Server { + error_name: "SYNTAX_ERROR".into(), + message: "line 1:1: mismatched input".into(), + }, + native_error: 1, + }); + conn.liveness.note(&TrinoError::AuthFailure { + message: "HTTP 401".into(), + }); + conn.liveness.note(&TrinoError::QueryTimeout { + message: "request timed out".into(), + }); + + assert!( + !TrinoBackend::connection_dead(&conn), + "a query error, an auth rejection and a timeout all leave the link up" + ); + } + + /// `cancel` signals the token and `is_cancelled` observes it; core turns + /// the latter into `HY008`. A token that has not been cancelled must not + /// claim it was, or every unrelated failure on the statement would be + /// reported as an application-requested cancellation. + #[test] + fn is_cancelled_tracks_the_token_core_passes_back() { + let conn = disconnected_trino_conn(); + let token = TrinoBackend::cancel_token(&conn); + + assert!( + !TrinoBackend::is_cancelled(&token), + "a fresh token has not been cancelled" + ); + + token + .state + .begin_query("20260729_000000_00000_abcde".into()); + assert!( + !TrinoBackend::is_cancelled(&token), + "submitting a query does not cancel it" + ); + + // What `execute::cancel` publishes once its DELETE has succeeded. + token.state.cancelled.store(true, Ordering::SeqCst); + assert!( + TrinoBackend::is_cancelled(&token), + "a server-side cancel must be observable through the token" + ); + } + + /// Core arms its timer only for `CoreCancels`, so this is what decides + /// whether `SQL_ATTR_QUERY_TIMEOUT` is honoured at all. Returning it + /// asserts that `cancel` really cancels; see the method's own doc. + #[test] + fn query_timeout_is_enforced_by_core_cancelling() { + let conn = disconnected_trino_conn(); + assert_eq!( + TrinoBackend::set_query_timeout(&conn, 30) + .expect("this driver accepts a query timeout"), + QueryTimeout::CoreCancels, + ); + } + + /// `SQL_ATTR_CONNECTION_TIMEOUT` bounds every request on the connection, so + /// it overrides the `QueryTimeout` key; `Some(0)` is the spec's "there is + /// no timeout" and must not be confused with unset. + #[test] + fn connection_timeout_attribute_wins_over_the_connection_string_key() { + let from_key = Duration::from_secs(30); + + assert_eq!( + request_timeout(None, from_key), + from_key, + "an application that set nothing keeps the connection-string default" + ); + assert_eq!( + request_timeout(Some(5), from_key), + Duration::from_secs(5), + "an attribute the application set is the more specific instruction" + ); + assert_eq!( + request_timeout(Some(0), from_key), + NO_TIMEOUT, + "0 is 'no timeout', not 'unset': reimposing the key's 30s would \ + cap a connection that asked for none" + ); + } + + /// The attribute and the key set the same thing, so `0` cannot mean + /// opposite things on the two paths. `SQL_ATTR_CONNECTION_TIMEOUT = 0` is + /// the spec's "there is no timeout"; `QueryTimeout=0` reaching reqwest as a + /// zero duration is a request that expires before it is sent, which fails + /// every query on the connection and is not something an operator can have + /// meant by "no timeout". + #[test] + fn a_zero_query_timeout_key_means_the_same_as_a_zero_attribute() { + assert_eq!( + request_timeout(None, Duration::ZERO), + NO_TIMEOUT, + "QueryTimeout=0 must mean what SQL_ATTR_CONNECTION_TIMEOUT=0 means" + ); + } + + /// `SQL_ATTR_LOGIN_TIMEOUT` of `0` is "the timeout is disabled and a + /// connection attempt will wait indefinitely", which is the same behaviour + /// as setting none. + #[test] + fn login_timeout_of_zero_means_wait_indefinitely() { + assert_eq!(login_deadline(None), None); + assert_eq!(login_deadline(Some(0)), None); + assert_eq!(login_deadline(Some(15)), Some(Duration::from_secs(15))); + } + + /// `SQL_USER_NAME` is "the name used in a particular database, which can be + /// different from the login name", so what the session itself reports wins + /// over anything the connection string said. Under + /// `ExternalAuthentication` there is no `User` at all and the identity + /// provider's mapping is the only source; under `SessionUser` the two + /// differ outright. + #[test] + fn user_name_prefers_what_the_session_reports() { + assert_eq!( + session_user_name(Some("mapped_by_the_idp"), Some("run_as"), Some("login")), + "mapped_by_the_idp" + ); + } + + /// The probe is allowed to fail without failing the connection, so the + /// fallbacks have to be ordered. `SessionUser` is what statements would + /// have run as, which is what this info type asks for; `User` merely + /// authenticated. + #[test] + fn user_name_falls_back_to_session_user_before_the_login_name() { + assert_eq!( + session_user_name(None, Some("run_as"), Some("login")), + "run_as" + ); + assert_eq!(session_user_name(None, None, Some("login")), "login"); + } + + /// `ExternalAuthentication` requires no `User`, so a failed probe can leave + /// nothing to report. The empty string is the spec's "not available" for a + /// string info type, the same answer `SQL_DBMS_VER` gives for a failed + /// version probe. + #[test] + fn user_name_is_empty_when_nothing_can_name_the_user() { + assert_eq!(session_user_name(None, None, None), ""); + } + + #[test] + fn get_type_info_returns_all_trino_types() { + let conn = disconnected_trino_conn(); + let types = TrinoBackend::get_type_info(&conn); + assert!(!types.is_empty(), "should return at least one type"); + + let names: Vec<&str> = types.iter().map(|t| t.type_name()).collect(); + for expected in &[ + "BOOLEAN", + "TINYINT", + "SMALLINT", + "INTEGER", + "BIGINT", + "REAL", + "DOUBLE", + "DECIMAL", + "VARCHAR", + "CHAR", + "VARBINARY", + "DATE", + "TIME", + "TIMESTAMP", + "JSON", + "UUID", + ] { + assert!(names.contains(expected), "missing type: {expected}"); + } + + // Datetime types must have sql_data_type=9 (SQL_DATETIME) and a sub-type code + let date = types.iter().find(|t| t.type_name() == "DATE").unwrap(); + assert_eq!(date.sql_data_type(), 9); + assert_eq!(date.sql_datetime_sub(), Some(1)); + + let time = types.iter().find(|t| t.type_name() == "TIME").unwrap(); + assert_eq!(time.sql_data_type(), 9); + assert_eq!(time.sql_datetime_sub(), Some(2)); + + let ts = types.iter().find(|t| t.type_name() == "TIMESTAMP").unwrap(); + assert_eq!(ts.sql_data_type(), 9); + assert_eq!(ts.sql_datetime_sub(), Some(3)); + + // Integer types must have num_prec_radix=10 and unsigned=false + let bigint = types.iter().find(|t| t.type_name() == "BIGINT").unwrap(); + assert_eq!(bigint.num_prec_radix(), Some(10)); + assert_eq!(bigint.unsigned(), Some(false)); + } + + // ----------------------------------------------------------------------- + // ODBC special catalog enumeration mode tests + // ----------------------------------------------------------------------- + + /// The `SQL_ALL_TABLE_TYPES` enumeration is a static declaration and needs + /// no Trino connection. Core turns these into the result set (every + /// column but `TABLE_TYPE` NULL), so this pins the values, not the shape. + /// + /// Upper case is spec-mandated: applications specify table types in upper + /// case and the driver maps them to whatever the data source needs. + #[test] + fn table_types_are_table_and_view_in_upper_case() { + assert_eq!(metadata::table_types(), vec!["TABLE", "VIEW"]); + } + + /// The `LIKE ''` filter must return nothing even for a catalog with many + /// schemas: the probe proves the catalog resolves, it is not a listing. + #[test] + #[ignore = "requires Trino at localhost:8443; run with: cargo test -- --ignored backend"] + fn validation_query_returns_no_rows_for_a_populated_catalog() { + let params = ConnectParams::parse(LIVE).unwrap(); + let conn = TrinoBackend::connect(&params).unwrap(); + let rows = query_all_rows(&conn, validation_query(Some("tpcds"))).unwrap(); + assert!(rows.is_empty(), "probe returned {} rows", rows.len()); + } + + /// The validation query resolves the session catalog, so a catalog that + /// does not exist is caught at connect rather than at the first query. + #[test] + #[ignore = "requires Trino at localhost:8443; run with: cargo test -- --ignored backend"] + fn connect_with_unknown_catalog_fails_with_08001() { + use stackable_odbc_core::{errors::OdbcError, types::sql_state}; + + let params = ConnectParams::parse(&format!("{LIVE};Catalog=no_such_catalog")).unwrap(); + let Err(err) = TrinoBackend::connect(&params) else { + panic!("connect with an unknown catalog must fail"); + }; + assert_eq!( + OdbcError::from(err).sqlstate().as_str(), + sql_state::CLIENT_UNABLE_TO_ESTABLISH_CONNECTION + ); + } + + /// A successful connect runs a validation query, so this needs a live Trino. + #[test] + #[ignore = "requires Trino at localhost:8443; run with: cargo test -- --ignored backend"] + fn connect_creates_runtime() { + let params = ConnectParams::parse(LIVE).unwrap(); + let mut conn = TrinoBackend::connect(&params).unwrap(); + TrinoBackend::disconnect(&mut conn).unwrap(); + } + + #[test] + fn connect_missing_host_returns_error() { + let params = ConnectParams::parse("Port=8080;User=admin;Password=admin").unwrap(); + assert!(TrinoBackend::connect(&params).is_err()); + } + + #[test] + fn connect_invalid_port_returns_error() { + let params = ConnectParams::parse("Host=localhost;Port=notanumber;User=admin").unwrap(); + assert!(TrinoBackend::connect(&params).is_err()); + } + + // These assert on the parsed parameters rather than calling `connect`, + // which cannot succeed without a live Trino: it runs a validation query. + // What they cover is the parsing. + + #[test] + fn connect_custom_query_timeout_accepted() { + let params = ConnectParams::parse( + "Host=localhost;Port=8080;Protocol=http;User=test;QueryTimeout=60", + ) + .unwrap(); + let p = types::connect_params::TrinoConnectParams::try_from(&params).unwrap(); + assert_eq!(p.query_timeout().as_secs(), 60); + } + + #[test] + fn connect_login_timeout_alias_accepted() { + let params = ConnectParams::parse( + "Host=localhost;Port=8080;Protocol=http;User=test;LoginTimeout=10", + ) + .unwrap(); + let p = types::connect_params::TrinoConnectParams::try_from(&params).unwrap(); + assert_eq!(p.query_timeout().as_secs(), 10); + } + + /// Refused, not defaulted. The old behaviour logged a `warn!` and used 30s, + /// which no application can see, so a mistyped timeout looked applied and + /// was not. `MaxAttempts` and `ExternalAuthenticationTimeout` both refuse, + /// and the argument is the same for all three. + #[test] + fn connect_rejects_an_unparseable_query_timeout() { + for (key, value) in [ + ("QueryTimeout", "soon"), + ("QueryTimeout", "-1"), + ("QueryTimeout", "30s"), + ("LoginTimeout", "later"), + ] { + let params = ConnectParams::parse(&format!( + "Host=localhost;Port=8080;Protocol=http;User=test;{key}={value}" + )) + .unwrap(); + let Err(err) = types::connect_params::TrinoConnectParams::try_from(&params) else { + panic!("{key}={value} must be refused"); + }; + let message = err.to_string(); + assert!( + message.contains(&key.to_lowercase()) && message.contains(value), + "the error must name the key and the offending value: {message}" + ); + } + } + + /// `0` is the spec's "no timeout" rather than a mistake, and + /// `request_timeout` turns it into `NO_TIMEOUT`. It must not be swept up by + /// the rejection above. + #[test] + fn connect_accepts_a_zero_query_timeout() { + let params = + ConnectParams::parse("Host=localhost;Port=8080;Protocol=http;User=test;QueryTimeout=0") + .unwrap(); + let p = types::connect_params::TrinoConnectParams::try_from(&params).unwrap(); + assert_eq!(p.query_timeout(), Duration::ZERO); + assert_eq!(request_timeout(None, p.query_timeout()), NO_TIMEOUT); + } + + /// A DSN editor that writes every keyword it knows leaves an untouched + /// field blank. Blank is unset, not invalid: refusing it would break data + /// sources this driver did not write, for a value nobody chose. + #[test] + fn connect_treats_a_blank_query_timeout_as_unset() { + for pair in ["QueryTimeout=", "LoginTimeout=", "QueryTimeout= "] { + let params = ConnectParams::parse(&format!( + "Host=localhost;Port=8080;Protocol=http;User=test;{pair}" + )) + .unwrap(); + let p = types::connect_params::TrinoConnectParams::try_from(&params) + .unwrap_or_else(|e| panic!("{pair:?} must parse: {e}")); + assert_eq!(p.query_timeout().as_secs(), 30, "for {pair:?}"); + } + } + + #[test] + fn validation_query_without_catalog_is_catalog_free() { + assert_eq!(validation_query(None), "SELECT 1"); + } + + #[test] + fn validation_query_with_catalog_resolves_it() { + assert_eq!( + validation_query(Some("tpcds")), + r#"SHOW SCHEMAS FROM "tpcds" LIKE ''"# + ); + } + + #[test] + fn validation_query_escapes_quotes_in_catalog_name() { + assert_eq!( + validation_query(Some(r#"we"ird"#)), + r#"SHOW SCHEMAS FROM "we""ird" LIKE ''"# + ); + } + + /// The session's catalog outranks the connection string's, which is what + /// makes `SQL_ATTR_CURRENT_CATALOG` follow a `USE`. Asserted offline: the + /// snapshot is client-side state, so a client built with a catalog reports + /// it with no coordinator in the loop, exactly as one that learnt it from + /// `X-Trino-Set-Catalog` would. + #[test] + fn current_catalog_prefers_the_session_over_the_connection_string() { + let client = ClientBuilder::new("test", "localhost") + .port(8080) + .catalog("moved_to") + .build() + .expect("ClientBuilder::build performs no I/O and cannot fail here"); + let conn = TrinoConnection { + client: Arc::new(client), + ..disconnected_trino_conn_with_catalog(Some("connected_with")) + }; + + assert_eq!( + TrinoBackend::current_catalog(&conn).as_deref(), + Some("moved_to") + ); + } + + /// `USE` moves the session catalog, and the reported one moves with it. + /// + /// Takes its own connection rather than [`shared_trino_conn`]: `USE` + /// changes session state for every later statement on that connection, so + /// running it on the shared one would leak a different catalog into + /// whichever test ran next. + /// + /// Measured against the live stack, this is the whole point of reading the + /// session: before the switch `SQL_DATABASE_NAME` is `tpcds`, after it + /// `postgresql`, and `SELECT count(*) FROM customers`, unqualified, + /// resolves in `postgresql.public`. Reporting `tpcds` there would name a + /// catalog the session had left, while the application's own unqualified + /// names resolved somewhere else. + #[test] + #[serial(backend)] + #[ignore = "requires Trino; run in isolation: cargo test -- --ignored backend"] + fn backend_current_catalog_follows_a_use_statement() { + let params = ConnectParams::parse(&format!("{LIVE};Catalog=tpcds")).expect("parse params"); + let conn = TrinoBackend::connect(&params).expect("connect"); + + assert_eq!( + TrinoBackend::current_catalog(&conn).as_deref(), + Some("tpcds"), + "the connection string's catalog is reported before any USE" + ); + + let cancel = TrinoBackend::cancel_token(&conn); + TrinoBackend::exec_direct(&conn, &cancel, "USE postgresql.public").expect("USE"); + + assert_eq!( + TrinoBackend::current_catalog(&conn).as_deref(), + Some("postgresql"), + "USE moved the session catalog, so the reported one has to move too" + ); + } + + /// `SQL_USER_NAME` is answered from the coordinator, not from the + /// connection string: `SessionUser` and an `ExternalAuthentication` login + /// both make the effective user differ from the one that authenticated, + /// and `current_user` is the only thing that knows which. + /// + /// `SQL_SERVER_NAME` and `SQL_DATA_SOURCE_NAME` ride along because all + /// three are settled by the same connect, and `LIVE` is a DSN-less + /// connection string, which is the one case the spec does define the empty + /// string for. + #[test] + #[serial(backend)] + #[ignore = "requires Trino; run in isolation: cargo test -- --ignored backend"] + fn backend_identity_strings_come_from_the_connection() { + let params = ConnectParams::parse(LIVE).expect("parse params"); + let conn = TrinoBackend::connect(&params).expect("connect"); + + for (info_type, expected) in [ + (InfoType::UserName, "admin"), + (InfoType::ServerName, "localhost"), + (InfoType::DataSourceName, ""), + ] { + assert_eq!( + TrinoBackend::get_info(&conn, info_type).expect("get_info"), + InfoValue::String(expected.to_string()), + "{info_type:?}" + ); + } + } + + /// The `Catalog` connection-string value is still reported while the + /// session names none, which is the window before the first response has + /// been seen. + #[test] + fn current_catalog_falls_back_to_the_connection_string() { + let conn = disconnected_trino_conn_with_catalog(Some("from_the_dsn")); + assert_eq!( + TrinoBackend::current_catalog(&conn).as_deref(), + Some("from_the_dsn") + ); + } + + /// Neither source names one, which both readers render as the empty string. + #[test] + fn current_catalog_is_none_when_nothing_names_one() { + let conn = disconnected_trino_conn(); + assert_eq!(TrinoBackend::current_catalog(&conn), None); + } + + #[test] + fn error_mapping_connection_failed_produces_08001() { + use stackable_odbc_core::{errors::OdbcError, types::sql_state}; + let err = connection_failed(TrinoError::CommunicationLinkFailure { + message: "connection refused".into(), + }); + let odbc_err: OdbcError = err.into(); + assert_eq!( + odbc_err.sqlstate().as_str(), + sql_state::CLIENT_UNABLE_TO_ESTABLISH_CONNECTION + ); + } + + /// An unreachable coordinator must be reported by `connect` itself, not + /// deferred to the application's first query. + #[test] + fn connect_to_unreachable_server_fails_with_08001() { + use stackable_odbc_core::{errors::OdbcError, types::sql_state}; + + // Port 1 is reserved and never listening; the client does not retry + // connection-refused, so this fails fast. + let params = ConnectParams::parse("Host=127.0.0.1;Port=1;User=test").unwrap(); + let Err(err) = TrinoBackend::connect(&params) else { + panic!("connect to an unreachable server must fail"); + }; + assert_eq!( + OdbcError::from(err).sqlstate().as_str(), + sql_state::CLIENT_UNABLE_TO_ESTABLISH_CONNECTION + ); + } + + /// A declaration naming a keyword this driver never reads is a silent + /// no-op, so it is asserted against the constants the parser uses rather + /// than against string literals repeated here. + /// + /// `Password` is absent for a reason: it is core's own spec-defined + /// keyword, redacted there by name rather than by this hook. + #[test] + fn every_trino_specific_secret_keyword_is_declared_sensitive() { + use types::connect_params::{ + PARAM_ACCESS_TOKEN, PARAM_EXTRA_CREDENTIALS, PARAM_EXTRA_HEADERS, PARAM_TOKEN, + }; + + let declared = TrinoBackend::sensitive_connect_keywords(); + for key in [ + PARAM_ACCESS_TOKEN, + PARAM_TOKEN, + PARAM_EXTRA_CREDENTIALS, + PARAM_EXTRA_HEADERS, + ] { + assert!( + declared.iter().any(|d| d == key), + "{key} can carry a credential but is not declared sensitive; declared: {declared:?}" + ); + } + } + + /// Builds a Trino server-side query error with the given code and name. + fn query_error(error_code: i32, error_name: &str) -> trino_rust_client::models::QueryError { + trino_rust_client::models::QueryError { + message: "line 1:8: mismatched input 'FROM'".into(), + sql_state: None, + error_code, + error_name: error_name.into(), + error_type: "USER_ERROR".into(), + error_location: None, + failure_info: None, + } + } + + /// `SQLGetDiagRec` reports the native error through `NativeErrorPtr`, and + /// Trino's own error taxonomy is the only thing that can meaningfully go + /// there. Zero for every failure tells an application nothing. + #[test] + fn a_query_error_carries_trinos_own_code_as_the_native_error() { + use stackable_odbc_core::errors::OdbcError; + + // SYNTAX_ERROR is Trino error code 1. + let odbc_err = OdbcError::from(map_trino_error(query_error(1, "SYNTAX_ERROR").into())); + + assert_eq!(odbc_err.native_error(), 1); + } + + /// The diagnostic message is built from the whole causal chain, so the + /// client error has to stay attached rather than being flattened into a + /// string at the point of mapping. + #[test] + fn a_query_error_keeps_the_client_error_as_its_cause() { + use stackable_odbc_core::errors::OdbcError; + + let odbc_err = OdbcError::from(map_trino_error(query_error(1, "SYNTAX_ERROR").into())); + + let cause = odbc_err.cause().expect("the client error must be retained"); + assert!( + cause.to_string().contains("SYNTAX_ERROR"), + "the cause must name the Trino error, got: {cause}" + ); + } + + /// `SQLGetData`'s and `SQLDescribeCol`'s `07009` rows both carry the clause + /// "the value specified for the argument *ColumnNumber* was greater than + /// the number of columns in the result set" with no `(DM)` marker, so it is + /// the driver's to return. `HY000` says only "something went wrong", where + /// an application walking columns until it runs out reads `07009` as the + /// end of the descriptor list. + /// + /// Column 0 already answers `07009` from core, which refuses the bookmark + /// binding before it reaches a backend. + #[test] + fn a_column_past_the_result_set_reports_07009() { + use stackable_odbc_core::{errors::OdbcError, types::sql_state}; + + for err in [ + execute::column_out_of_range(2, 1), + execute::column_index_must_be_positive(), + ] { + assert_eq!( + OdbcError::from(err).sqlstate().as_str(), + sql_state::INVALID_DESCRIPTOR_INDEX + ); + } + } + + /// A token whose coordinator is not listening, so its `DELETE` fails fast + /// with a connection refusal rather than a timeout. + fn token_to_nowhere() -> TrinoCancelToken { + let client = ClientBuilder::new("test", "127.0.0.1") + .port(1) + .build() + .expect("ClientBuilder::build performs no I/O"); + TrinoCancelToken { + client: Arc::new(client), + runtime: Arc::new( + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build() + .expect("Runtime::build performs no I/O"), + ), + state: Arc::new(CancelState::default()), + liveness: Liveness::default(), + } + } + + /// `SQLCancel` and `SQL_ATTR_QUERY_TIMEOUT` both mean "stop this + /// statement", and this flag is what `fetch` reads to stop. Setting it only + /// once the `DELETE` has succeeded makes the deadline unenforceable in + /// exactly the conditions that produce one: measured against a scripted + /// coordinator whose cancel endpoint errors, a 3-second query timeout fired + /// on schedule, `cancel` ran, the `DELETE` failed, and `SQLFetch` then + /// paged 86,287 times over 40 seconds without returning. With the same + /// server answering the `DELETE` normally it ended at 3.0s with `HYT00`. + #[test] + fn a_cancel_whose_delete_fails_still_stops_the_statement() { + let token = token_to_nowhere(); + token + .state + .begin_query("20260801_000000_00001_x".to_string()); + + let result = TrinoBackend::cancel(&token); + + assert!( + result.is_err(), + "a DELETE that did not reach the coordinator must still be reported" + ); + assert!( + TrinoBackend::is_cancelled(&token), + "the statement must be marked cancelled even so, or nothing stops \ + the fetch loop" + ); + } + + /// `TrinoError::Query`'s own `Display` is empty, because core walks its + /// `source` when it builds the diagnostic. Reclassifying it to 08001 by way + /// of `to_string()` therefore yields the bare words "query failed" and + /// throws away both the cause and Trino's error code, which is every + /// connect-time failure that is not an auth or timeout error: a malformed + /// session property, an undecodable page, a 500 from a proxy. + #[test] + fn a_failed_connect_keeps_the_cause_that_explains_it() { + use stackable_odbc_core::errors::OdbcError; + + let odbc_err = OdbcError::from(connection_failed(map_trino_error( + query_error(46, "MALFORMED_SESSION_PROPERTY").into(), + ))); + + let cause = odbc_err.cause().expect("the cause must survive 08001"); + assert!( + chain_text(&odbc_err).contains("MALFORMED_SESSION_PROPERTY"), + "the diagnostic must name what failed, got: {cause}" + ); + } + + #[test] + fn a_failed_connect_keeps_trinos_error_code() { + use stackable_odbc_core::errors::OdbcError; + + let odbc_err = OdbcError::from(connection_failed(map_trino_error( + query_error(46, "MALFORMED_SESSION_PROPERTY").into(), + ))); + + assert_eq!(odbc_err.native_error(), 46); + } + + /// The reclassification is the whole point: 08001 is valid only from the + /// connection functions, and `validate_connection` is the one caller. + #[test] + fn a_failed_connect_still_reports_08001() { + use stackable_odbc_core::{errors::OdbcError, types::sql_state}; + + let odbc_err = OdbcError::from(connection_failed(map_trino_error( + query_error(46, "MALFORMED_SESSION_PROPERTY").into(), + ))); + + assert_eq!( + odbc_err.sqlstate().as_str(), + sql_state::CLIENT_UNABLE_TO_ESTABLISH_CONNECTION + ); + } + + /// A variant that already says something useful must not be buried. + #[test] + fn a_failed_connect_keeps_a_plain_messages_text() { + use stackable_odbc_core::errors::OdbcError; + + let odbc_err = OdbcError::from(connection_failed(TrinoError::General { + message: "no runtime available".into(), + })); + + assert!( + chain_text(&odbc_err).contains("no runtime available"), + "got: {}", + chain_text(&odbc_err) + ); + } + + /// The message an application reads, assembled the way + /// `Diagnostics::push` assembles it: the error, then every cause. + fn chain_text(err: &stackable_odbc_core::errors::OdbcError) -> String { + let mut text = err.to_string(); + let mut cause: Option<&(dyn std::error::Error + 'static)> = + err.cause().map(|e| e as &(dyn std::error::Error + 'static)); + while let Some(e) = cause { + text.push_str(&format!(": {e}")); + cause = e.source(); + } + text + } + + /// A failure that never came from the coordinator has no Trino code, and + /// `0` is the spec's value for "no native code" rather than a made-up one. + #[test] + fn an_error_without_a_trino_code_reports_zero() { + use stackable_odbc_core::errors::OdbcError; + + let odbc_err = OdbcError::from(TrinoError::General { + message: "no runtime available".into(), + }); + + assert_eq!(odbc_err.native_error(), 0); + } + + #[test] + fn error_mapping_communication_link_failure_produces_08s01() { + use stackable_odbc_core::{errors::OdbcError, types::sql_state}; + let err = TrinoError::CommunicationLinkFailure { + message: "connection refused".into(), + }; + let odbc_err: OdbcError = err.into(); + assert_eq!( + odbc_err.sqlstate().as_str(), + sql_state::COMMUNICATION_LINK_FAILURE + ); + } + + #[test] + fn error_mapping_query_timeout_produces_hyt00() { + use stackable_odbc_core::{errors::OdbcError, types::sql_state}; + let err = TrinoError::QueryTimeout { + message: "timed out".into(), + }; + let odbc_err: OdbcError = err.into(); + assert_eq!(odbc_err.sqlstate().as_str(), sql_state::TIMEOUT_EXPIRED); + } + + #[test] + fn error_mapping_auth_failure_produces_28000() { + use stackable_odbc_core::{errors::OdbcError, types::sql_state}; + let err = TrinoError::AuthFailure { + message: "HTTP 401: Unauthorized".into(), + }; + let odbc_err: OdbcError = err.into(); + assert_eq!(odbc_err.sqlstate().as_str(), sql_state::INVALID_AUTH_SPEC); + } + + #[test] + fn error_mapping_auth_config_produces_28000() { + use stackable_odbc_core::{errors::OdbcError, types::sql_state}; + let err = TrinoError::AuthConfig { + message: "both a password and an access token were supplied; provide only one".into(), + }; + let odbc_err: OdbcError = err.into(); + assert_eq!(odbc_err.sqlstate().as_str(), sql_state::INVALID_AUTH_SPEC); + } + + /// A refused or abandoned interactive login is an authentication failure. + /// + /// Without its own arm it falls into `map_trino_error`'s catch-all and + /// arrives as `HY000`, which tells a tool nothing about what to do next. + /// `28000` is also what `validate_connection` preserves rather than + /// reclassifying to `08001`, so the code survives the connect it happened + /// during. + #[test] + fn error_mapping_oauth2_failure_produces_28000() { + use stackable_odbc_core::{errors::OdbcError, types::sql_state}; + + let mapped = map_trino_error(trino_rust_client::error::Error::OAuth2( + "OAuth2 login not completed within 5s".into(), + )); + + assert!( + matches!(mapped, TrinoError::AuthFailure { .. }), + "an OAuth2 failure is an authentication failure, not {mapped:?}" + ); + + let odbc_err: OdbcError = mapped.into(); + assert_eq!(odbc_err.sqlstate().as_str(), sql_state::INVALID_AUTH_SPEC); + } + + // ----------------------------------------------------------------------- + // Integration tests (require a live Trino instance) + // ----------------------------------------------------------------------- + // + // All integration tests share a single TrinoConnection (via OnceLock). + // This mirrors production usage where one ODBC connection serves many + // queries, and avoids rapid connect/disconnect cycles that expose Trino + // coordinator timing sensitivity between independent reqwest connection + // pools. + + fn shared_trino_conn() -> &'static TrinoConnection { + use std::sync::OnceLock; + static CONN: OnceLock<TrinoConnection> = OnceLock::new(); + CONN.get_or_init(|| { + let params = + ConnectParams::parse(&format!("{LIVE};Catalog=tpcds")).expect("parse params"); + TrinoBackend::connect(&params).expect("shared backend connection") + }) + } + + // These tests create a separate TrinoConnection (with its own reqwest + // pool) from the FFI integration tests. Running both groups against the + // same Trino coordinator causes intermittent failures because the two + // pools' TCP sockets can interfere at the server level. Use + // `cargo test -- --ignored backend` to + // run them in isolation. The FFI integration tests provide equivalent + // coverage via the ODBC call stack. + + /// The `Backend` trait's own `exec_direct` path. + /// + /// Named so that no filter can select it and + /// `ffi_integration_tests::exec_direct_select_and_fetch` (the same query + /// through the C ABI) at the same time. `cargo test` filters by substring, + /// and the two suites must never share a process: each opens its own + /// reqwest connection pool, and two pools against one coordinator corrupt + /// each other's TCP sockets (see the note above). Neither name is a + /// substring of the other, which is what keeps `cargo test <either name>` + /// selecting one suite. + #[test] + #[serial(backend)] + #[ignore = "requires Trino; run in isolation: cargo test -- --ignored backend"] + fn backend_exec_direct_selects_and_fetches() { + use stackable_odbc_core::types::CDataType; + let conn = shared_trino_conn(); + let cancel = TrinoBackend::cancel_token(conn); + let mut stmt = + TrinoBackend::exec_direct(conn, &cancel, "SELECT 1 AS n").expect("exec_direct"); + + assert_eq!(stmt.column_count(), 1); + assert_eq!(stmt.describe_col(1).expect("describe_col").name(), "n"); + + assert_eq!(stmt.fetch().expect("fetch"), FetchResult::Row); + let val = stmt.get_data(1, CDataType::SLong).expect("get_data"); + assert!( + matches!(*val, ColumnValue::I32(1) | ColumnValue::I64(1)), + "expected 1, got {val:?}" + ); + assert_eq!(stmt.fetch().expect("fetch 2"), FetchResult::NoData); + } + + #[test] + #[serial(backend)] + #[ignore = "requires Trino; run in isolation: cargo test -- --ignored backend"] + fn streaming_large_result_fetches_all_pages() { + use stackable_odbc_core::types::CDataType; + let conn = shared_trino_conn(); + let cancel = TrinoBackend::cancel_token(conn); + let mut stmt = TrinoBackend::exec_direct( + conn, + &cancel, + "SELECT c_customer_sk FROM tpcds.sf1.customer WHERE c_customer_sk <= 15000", + ) + .expect("exec_direct"); + + assert_eq!(stmt.column_count(), 1); + + let mut count = 0usize; + while let FetchResult::Row = stmt.fetch().expect("fetch") { + let val = stmt.get_data(1, CDataType::SLong).expect("get_data"); + assert!( + matches!(*val, ColumnValue::I32(_) | ColumnValue::I64(_)), + "unexpected value: {val:?}" + ); + count += 1; + } + assert!(count >= 10_000, "expected >= 10,000 rows, got {count}"); + } + + #[test] + #[serial(backend)] + #[ignore = "requires Trino; run in isolation: cargo test -- --ignored backend"] + fn cancel_mid_stream() { + // This test uses its own connection (not shared_trino_conn) because + // cancel leaves the reqwest connection pool with a dirty TCP socket + // that has unread response bytes. Using a separate connection ensures + // the dirty pool is destroyed when this test ends, rather than + // poisoning subsequent tests on the shared connection. + let params = ConnectParams::parse(&format!("{LIVE};Catalog=tpcds")).expect("parse params"); + let conn = TrinoBackend::connect(&params).expect("connect"); + let cancel = TrinoBackend::cancel_token(&conn); + let mut stmt = TrinoBackend::exec_direct( + &conn, + &cancel, + "SELECT c_customer_sk FROM tpcds.sf1.customer", + ) + .expect("exec_direct"); + + // Fetch a few rows to ensure the query is running. + for _ in 0..5 { + assert_eq!(stmt.fetch().expect("fetch"), FetchResult::Row); + } + + let result = TrinoBackend::cancel(&cancel); + assert!(result.is_ok(), "cancel failed: {result:?}"); + + // `cancel` holds the token, not the statement, so it publishes the + // cancellation through the token rather than clearing `next_uri`. The + // statement must observe that on its next fetch and stop polling: + // draining a cancelled query is what corrupts the pool. + // + // The observation is reported as `HY008`, not `NoData`. `NoData` says + // "your result set ended", which is false when rows were discarded, and + // it would let a query timeout enforced by cancelling arrive as an + // empty result set with no diagnostic at all. + let err = stmt + .fetch() + .expect_err("a cancelled statement must report the cancellation, not NoData"); + assert!( + matches!(err, TrinoError::OperationCancelled { .. }), + "expected OperationCancelled (HY008), got {err:?}" + ); + assert!( + stmt.next_uri.is_none(), + "the cancelled statement must have dropped its next page URI" + ); + } + + /// The scenario `Backend::CancelToken` exists for: `SQLCancel` arriving on + /// a different thread from the one executing the statement. + /// + /// A `cancel(&mut Self::Statement)` signature could not express it at all: + /// the executing thread holds that `&mut`. It also runs two threads inside + /// `block_on` on the same *current-thread* runtime, which is the shape this + /// driver's Tokio bridge has and the one place the design could deadlock + /// instead of cancelling. + #[test] + #[serial(backend)] + #[ignore = "requires Trino; run in isolation: cargo test -- --ignored backend"] + fn cancel_from_another_thread_while_fetching() { + // Its own connection, for the dirty-socket reason `cancel_mid_stream` + // documents above. + let params = ConnectParams::parse(&format!("{LIVE};Catalog=tpcds")).expect("parse params"); + let conn = TrinoBackend::connect(&params).expect("connect"); + let cancel = Arc::new(TrinoBackend::cancel_token(&conn)); + + // A query large enough that it is still streaming when the cancel + // lands, so the cancelling thread really does contend for the runtime. + let mut stmt = TrinoBackend::exec_direct( + &conn, + &cancel, + "SELECT c_customer_sk FROM tpcds.sf1.customer", + ) + .expect("exec_direct"); + + assert_eq!(stmt.fetch().expect("first fetch"), FetchResult::Row); + + let canceller = { + let cancel = Arc::clone(&cancel); + std::thread::spawn(move || TrinoBackend::cancel(&cancel)) + }; + + // Keep fetching until the cancellation is observed. This must + // terminate: either the rows run out or the cancellation stops the + // loop. A hang here is the deadlock this test is looking for. + let mut rows = 0u64; + let outcome = loop { + match stmt.fetch() { + Ok(FetchResult::Row) => rows += 1, + other => break other, + } + }; + + let cancelled = canceller.join().expect("cancelling thread panicked"); + assert!( + cancelled.is_ok(), + "cross-thread cancel failed: {cancelled:?}" + ); + + // `HY008` whichever way the race fell. Two paths reach it, depending on + // whether a page request happened to be in flight when the `DELETE` + // landed: + // + // - in flight: the coordinator fails that request with `USER_CANCELED`, + // which `map_trino_error` classifies; + // - between requests: nothing was interrupted, so the next `fetch` + // finds only the token's flag and builds the same error from + // `cancelled_between_requests`. + // + // Both endings must report `HY008`. Which one a run takes is outside + // anyone's control, but what the application is *told* must not depend + // on that: accepting `NoData` for the between-requests case would let a + // query timeout enforced by cancelling surface as an empty result set + // with no diagnostic. + let odbc = OdbcError::from( + outcome.expect_err("a fetch stopped by SQLCancel must report the cancellation"), + ); + assert_eq!( + odbc.sqlstate(), + stackable_odbc_core::types::SqlState::new(SQL_STATE_CANCELLED), + "a fetch interrupted by SQLCancel must report HY008, got {odbc:?}" + ); + + // The proof the cancel took effect. tpcds.sf1.customer holds + // 100,000 rows; had the cancel been a no-op the loop would have drained + // all of them and every assertion above would pass vacuously. + const CUSTOMER_ROWS: u64 = 100_000; + assert!( + rows < CUSTOMER_ROWS, + "the cancel did not stop the stream: fetched all {rows} rows" + ); + assert!( + stmt.next_uri.is_none(), + "the statement must not be left holding a next page URI, {rows} rows in" + ); + + // Not `fetch_failed`: a cancellation is not a failed fetch, so a + // further fetch reports the cancellation again rather than the `24000` + // an abandoned result set would give. The distinction is what keeps the + // handle re-executable, and a re-execute arrives with a fresh token, + // so the flag does not follow it. + let again = OdbcError::from( + stmt.fetch() + .expect_err("a cancelled statement stays cancelled until it is re-executed"), + ); + assert_eq!( + again.sqlstate(), + stackable_odbc_core::types::SqlState::new(SQL_STATE_CANCELLED), + "expected HY008 again, got {again:?}" + ); + } + + /// Guards SQL_DBMS_VER: it must be queried from the server, not a frozen + /// literal such as "467", which is wrong against every coordinator that is + /// not 467 and malformed against the spec's ##.##.#### requirement + /// regardless. + #[test] + #[ignore = "requires Trino at localhost:8443; run with: cargo test -- --ignored backend"] + fn dbms_version_is_read_from_the_server() { + let params = ConnectParams::parse(LIVE).unwrap(); + let conn = TrinoBackend::connect(&params).unwrap(); + assert!( + !conn.dbms_version.is_empty(), + "the version probe returned nothing" + ); + let prefix = conn.dbms_version.split(' ').next().unwrap_or(""); + let parts: Vec<&str> = prefix.split('.').collect(); + assert_eq!( + parts.len(), + 3, + "SQL_DBMS_VER must start with ##.##.####, got {:?}", + conn.dbms_version + ); + assert!( + parts.iter().all(|p| p.chars().all(|c| c.is_ascii_digit())), + "SQL_DBMS_VER prefix must be all digits and dots: {:?}", + conn.dbms_version + ); + } + + // The transaction scenarios below name the `hive` catalog for their + // writes. It is the only connector Trino ships that accepts a write + // outside autocommit; `tpcds` and `postgresql` answer + // AUTOCOMMIT_WRITE_CONFLICT. See the hive catalog section in AGENTS.md. + + /// A transaction-state error from the client is not a server-side query + /// rejection, and must not be reported as one: `TrinoError::Query` carries + /// a native error code it would have to invent. + #[test] + fn a_client_transaction_error_is_not_reported_as_a_query_failure() { + let mapped = map_trino_error(trino_rust_client::error::Error::Transaction( + "a transaction is already active".to_string(), + )); + assert!( + !matches!(mapped, TrinoError::Query { .. }), + "expected a general error, got {mapped:?}" + ); + assert!( + mapped + .to_string() + .contains("a transaction is already active"), + "the client's own message has to survive: {mapped}" + ); + } + + /// Manual-commit mode records the mode and issues nothing: the transaction + /// opens at the first statement, not inside `SQLSetConnectAttr`. + #[test] + #[ignore = "requires Trino; run in isolation: cargo test -- --ignored backend"] + fn set_autocommit_off_opens_no_transaction_by_itself() { + let params = ConnectParams::parse(LIVE).unwrap(); + let conn = TrinoBackend::connect(&params).unwrap(); + TrinoBackend::set_autocommit(&conn, false).expect("manual-commit mode is supported"); + assert!(!conn.in_transaction()); + } + + #[test] + #[ignore = "requires Trino; run in isolation: cargo test -- --ignored backend"] + fn a_statement_opens_the_transaction_in_manual_commit_mode() { + let params = ConnectParams::parse(LIVE).unwrap(); + let conn = TrinoBackend::connect(&params).unwrap(); + let cancel = TrinoBackend::cancel_token(&conn); + + TrinoBackend::set_autocommit(&conn, false).expect("manual-commit mode is supported"); + execute::exec_direct(&conn, &cancel, "SELECT 1").expect("query runs"); + assert!(conn.in_transaction(), "the first statement opens it"); + + TrinoBackend::end_tran(&conn, false).expect("rollback"); + assert!(!conn.in_transaction()); + } + + #[test] + #[ignore = "requires Trino; run in isolation: cargo test -- --ignored backend"] + fn autocommit_mode_opens_no_transaction() { + let params = ConnectParams::parse(LIVE).unwrap(); + let conn = TrinoBackend::connect(&params).unwrap(); + let cancel = TrinoBackend::cancel_token(&conn); + execute::exec_direct(&conn, &cancel, "SELECT 1").expect("query runs"); + assert!(!conn.in_transaction()); + } + + /// `COMMIT` with nothing open is `NOT_IN_TRANSACTION` on the wire, while + /// `SQLEndTran` is required to succeed, so neither may reach the + /// coordinator. + #[test] + #[ignore = "requires Trino; run in isolation: cargo test -- --ignored backend"] + fn end_tran_is_a_no_op_when_no_transaction_is_open() { + let params = ConnectParams::parse(LIVE).unwrap(); + let conn = TrinoBackend::connect(&params).unwrap(); + TrinoBackend::end_tran(&conn, true).expect("a commit with nothing open succeeds"); + TrinoBackend::end_tran(&conn, false).expect("so does a rollback"); + } + + /// The contract in one test: a write inside a transaction, discarded. + #[test] + #[ignore = "requires Trino; run in isolation: cargo test -- --ignored backend"] + fn a_rollback_discards_a_write() { + let params = ConnectParams::parse(LIVE).unwrap(); + let conn = TrinoBackend::connect(&params).unwrap(); + let cancel = TrinoBackend::cancel_token(&conn); + let table = "hive.tx.backend_rollback_probe"; + + exec(&conn, &cancel, &format!("DROP TABLE IF EXISTS {table}")); + exec( + &conn, + &cancel, + &format!("CREATE TABLE {table} (id integer)"), + ); + + TrinoBackend::set_autocommit(&conn, false).expect("manual-commit mode is supported"); + exec(&conn, &cancel, &format!("INSERT INTO {table} VALUES (1)")); + TrinoBackend::end_tran(&conn, false).expect("rollback"); + TrinoBackend::set_autocommit(&conn, true).expect("back to autocommit"); + + assert_eq!( + count_rows(&conn, table), + 0, + "the rolled-back insert must be gone" + ); + exec(&conn, &cancel, &format!("DROP TABLE IF EXISTS {table}")); + } + + #[test] + #[ignore = "requires Trino; run in isolation: cargo test -- --ignored backend"] + fn a_commit_publishes_a_write() { + let params = ConnectParams::parse(LIVE).unwrap(); + let conn = TrinoBackend::connect(&params).unwrap(); + let cancel = TrinoBackend::cancel_token(&conn); + let table = "hive.tx.backend_commit_probe"; + + exec(&conn, &cancel, &format!("DROP TABLE IF EXISTS {table}")); + exec( + &conn, + &cancel, + &format!("CREATE TABLE {table} (id integer)"), + ); + + TrinoBackend::set_autocommit(&conn, false).expect("manual-commit mode is supported"); + exec(&conn, &cancel, &format!("INSERT INTO {table} VALUES (1)")); + TrinoBackend::end_tran(&conn, true).expect("commit"); + TrinoBackend::set_autocommit(&conn, true).expect("back to autocommit"); + + assert_eq!(count_rows(&conn, table), 1, "the committed insert survives"); + exec(&conn, &cancel, &format!("DROP TABLE IF EXISTS {table}")); + } + + /// The abort flag describes the transaction that is open *now*, so opening + /// one clears whatever was recorded before it existed. + /// + /// Manual-commit mode is set by `SQLSetConnectAttr` and a transaction opens + /// at the first statement that needs one, so there is a window with the mode + /// on and nothing begun. A catalog function or `DESCRIBE INPUT` failing in + /// that window reaches `note_statement_error`, and nothing used to clear + /// what it set: `ended` runs only from `end_tran`, which returns early when + /// nothing is open. The next transaction was then born aborted, and + /// committing it rolled back statements that had all succeeded. + #[test] + fn a_new_transaction_does_not_inherit_an_earlier_abort() { + let txn = TransactionState::default(); + txn.set_autocommit(false); + + // The window: manual-commit mode, nothing open, something fails. + txn.note_statement_error(); + assert!(txn.aborted(), "the flag is set with no transaction open"); + + txn.begun(); + assert!( + !txn.aborted(), + "a transaction that has just opened cannot already be aborted" + ); + } + + /// `end_tran` reads the flag only when a transaction is open, and clears it + /// on the path where none is. Together with `begun` that makes the flag + /// unobservable outside the life of a transaction. + #[test] + fn ending_nothing_clears_a_stale_abort() { + let txn = TransactionState::default(); + txn.set_autocommit(false); + txn.note_statement_error(); + + // What `end_tran`'s early return does. + txn.begun(); + assert!(!txn.aborted()); + } + + /// A real abort still survives to the commit that has to refuse. The fix + /// above must not have turned the flag off for the case it exists for. + #[test] + fn an_abort_inside_the_transaction_still_reaches_the_commit() { + let txn = TransactionState::default(); + txn.set_autocommit(false); + txn.begun(); + + txn.note_statement_error(); + assert!( + txn.aborted(), + "a statement failing inside the open transaction still aborts it" + ); + + txn.ended(); + assert!(!txn.aborted(), "ending the transaction clears it"); + } + + /// Autocommit mode records nothing: each statement stands alone, so one + /// failure says nothing about the next. + #[test] + fn autocommit_mode_records_no_abort() { + let txn = TransactionState::default(); + assert!(txn.autocommit(), "ODBC's default"); + txn.note_statement_error(); + assert!(!txn.aborted()); + } + + /// The whole sequence the offline tests above model, against a coordinator. + /// + /// A failure between `SQL_AUTOCOMMIT_OFF` and the first statement used to + /// poison the transaction that had not opened yet, so this commit rolled + /// back and reported `25S03` for statements that had all succeeded. + #[test] + #[ignore = "requires Trino; run in isolation: cargo test -- --ignored backend"] + fn a_failure_before_the_transaction_opens_does_not_doom_it() { + let params = ConnectParams::parse(LIVE).unwrap(); + let conn = TrinoBackend::connect(&params).unwrap(); + let cancel = TrinoBackend::cancel_token(&conn); + + TrinoBackend::set_autocommit(&conn, false).expect("manual-commit mode is supported"); + assert!( + !conn.in_transaction(), + "the mode alone opens nothing; the first statement does" + ); + + // The kind of failure `describe_param` and the catalog functions can + // produce: it reaches `statement_error`, and no transaction is open. + assert!( + query_all_rows(&conn, "SELECT * FROM no_such_catalog.s.t".to_string()).is_err(), + "an unresolvable catalog must fail" + ); + + execute::exec_direct(&conn, &cancel, "SELECT 1").expect("opens the transaction"); + assert!(conn.in_transaction()); + assert!( + !conn.txn.aborted(), + "the transaction opened after the failure and did not inherit it" + ); + + TrinoBackend::end_tran(&conn, true) + .expect("a transaction whose statements all succeeded must commit"); + } + + /// Switching to autocommit records the mode even when the commit it has to + /// make first fails, so the driver and the application do not disagree + /// about which mode the connection is in. + #[test] + #[ignore = "requires Trino; run in isolation: cargo test -- --ignored backend"] + fn switching_to_autocommit_records_the_mode_even_when_the_commit_fails() { + let params = ConnectParams::parse(LIVE).unwrap(); + let conn = TrinoBackend::connect(&params).unwrap(); + let cancel = TrinoBackend::cancel_token(&conn); + + TrinoBackend::set_autocommit(&conn, false).expect("manual-commit mode is supported"); + execute::exec_direct(&conn, &cancel, "SELECT 1").expect("opens the transaction"); + assert!( + query_all_rows(&conn, "SELECT 1/0".to_string()).is_err(), + "division by zero must fail" + ); + assert!(conn.txn.aborted()); + + // Trino refuses to commit an aborted transaction, so this reports the + // failure. The mode must still have moved: `end_tran` has already sent + // the rollback by the time it returns the error. + assert!( + TrinoBackend::set_autocommit(&conn, true).is_err(), + "committing an aborted transaction must be reported as the failure it is" + ); + assert!( + conn.txn.autocommit(), + "the connection was left in manual-commit mode after being told the \ + switch to autocommit failed; the next statement would then open a \ + transaction the application never asked for" + ); + assert!( + !conn.in_transaction(), + "the rollback end_tran sent freed the session" + ); + } + + /// Trino aborts the whole transaction on any statement error, so the + /// driver has to know the transaction is dead before `SQLEndTran` asks it + /// to commit. + #[test] + #[ignore = "requires Trino; run in isolation: cargo test -- --ignored backend"] + fn a_failed_statement_aborts_the_transaction() { + let params = ConnectParams::parse(LIVE).unwrap(); + let conn = TrinoBackend::connect(&params).unwrap(); + let cancel = TrinoBackend::cancel_token(&conn); + + TrinoBackend::set_autocommit(&conn, false).expect("manual-commit mode is supported"); + execute::exec_direct(&conn, &cancel, "SELECT 1").expect("opens the transaction"); + // Through `query_all_rows`, because `exec_direct` alone does not see + // this failure: Trino sends column metadata before it has evaluated a + // row, so the statement is created and the division fails while its + // pages are read. + assert!( + query_all_rows(&conn, "SELECT 1/0".to_string()).is_err(), + "division by zero must fail" + ); + + assert!( + conn.txn.aborted(), + "any statement error aborts a Trino transaction" + ); + TrinoBackend::end_tran(&conn, false).expect("rollback frees the session"); + } + + /// Committing an aborted transaction reports the failure it is, rolls back + /// so the session survives, and reports `25S03` so the Driver Manager does + /// not suspend a connection that is perfectly usable. + #[test] + #[ignore = "requires Trino; run in isolation: cargo test -- --ignored backend"] + fn committing_an_aborted_transaction_reports_25s03_and_frees_the_session() { + use stackable_odbc_core::types::sql_state; + + let params = ConnectParams::parse(LIVE).unwrap(); + let conn = TrinoBackend::connect(&params).unwrap(); + let cancel = TrinoBackend::cancel_token(&conn); + + TrinoBackend::set_autocommit(&conn, false).expect("manual-commit mode is supported"); + execute::exec_direct(&conn, &cancel, "SELECT 1").expect("opens the transaction"); + // Through `query_all_rows`, because `exec_direct` alone does not see + // this failure: Trino sends column metadata before it has evaluated a + // row, so the statement is created and the division fails while its + // pages are read. + assert!( + query_all_rows(&conn, "SELECT 1/0".to_string()).is_err(), + "division by zero must fail" + ); + + let Err(err) = TrinoBackend::end_tran(&conn, true) else { + panic!("committing an aborted transaction must not report success"); + }; + assert_eq!( + OdbcError::from(err).sqlstate().as_str(), + sql_state::TRANSACTION_ROLLED_BACK + ); + assert!( + !conn.in_transaction(), + "the rollback has to have run, or every later statement fails" + ); + + TrinoBackend::set_autocommit(&conn, true).expect("back to autocommit"); + execute::exec_direct(&conn, &cancel, "SELECT 1").expect("the session recovered"); + } + + /// An abandoned transaction holds coordinator state until Trino's idle + /// timeout, so disconnecting rolls it back. + #[test] + #[ignore = "requires Trino; run in isolation: cargo test -- --ignored backend"] + fn disconnect_rolls_back_an_open_transaction() { + let params = ConnectParams::parse(LIVE).unwrap(); + let mut conn = TrinoBackend::connect(&params).unwrap(); + let cancel = TrinoBackend::cancel_token(&conn); + + TrinoBackend::set_autocommit(&conn, false).expect("manual-commit mode is supported"); + execute::exec_direct(&conn, &cancel, "SELECT 1").expect("opens the transaction"); + assert!(conn.in_transaction()); + + TrinoBackend::disconnect(&mut conn).expect("disconnect succeeds"); + assert!(!conn.in_transaction()); + } + + /// Run a statement for its effect, failing with the SQL that broke rather + /// than a bare unwrap. + fn exec(conn: &TrinoConnection, cancel: &TrinoCancelToken, sql: &str) { + if let Err(e) = execute::exec_direct(conn, cancel, sql) { + panic!("{sql} failed: {e}"); + } + } + + /// `SELECT count(*)` through the query path the catalog functions use. + fn count_rows(conn: &TrinoConnection, table: &str) -> i64 { + let rows: Vec<trino_rust_client::Row> = + query_all_rows(conn, format!("SELECT count(*) FROM {table}")).expect("count runs"); + rows.first() + .and_then(|r| r.clone().into_json().first().and_then(|v| v.as_i64())) + .expect("count(*) returns one integer") + } +} diff --git a/src/backend/describe_param.rs b/src/backend/describe_param.rs new file mode 100644 index 0000000..a0efe87 --- /dev/null +++ b/src/backend/describe_param.rs @@ -0,0 +1,288 @@ +//! `SQLDescribeParam` support, answered from Trino's `DESCRIBE INPUT`. +//! +//! Trino describes a prepared statement's parameters, so this driver does not +//! have to fall back to core's generic `VARCHAR(SQL_DEFAULT_PARAM_SIZE)`, the +//! answer that makes a client send a number as text and get a type error back. +//! +//! Reaching it takes three statements, because `DESCRIBE INPUT` names a +//! prepared statement rather than taking SQL: +//! +//! ```text +//! PREPARE <name> FROM <sql> -- registers it in the session +//! DESCRIBE INPUT <name> -- one row per parameter: Position, Type +//! DEALLOCATE PREPARE <name> -- drops it again +//! ``` +//! +//! The `PREPARE` must go through the client directly, **never** through the +//! bound-parameter path. Its `?` markers belong to the statement being +//! described, not to this call, and there are no values to substitute for +//! them: `params::interpolate` would consume them, and core rejects the +//! shortfall with `07002` before it even gets that far. Either way the +//! statement has to reach Trino verbatim, or `DESCRIBE INPUT` describes +//! something with no parameters in it. +//! +//! `DEALLOCATE` is not optional housekeeping. A session's prepared statements +//! ride on **every** subsequent request as an `X-Trino-Prepared-Statement` +//! header, so leaving a large query text registered would grow each later +//! request by that much and eventually breach the coordinator's header limit. + +use stackable_odbc_core::types::ParamDescriptor; + +use super::{TrinoConnection, TrinoError, query_all_rows}; +use crate::type_conversion::{trino_type_name_to_sql_type, type_name_precision, type_name_scale}; + +/// The parameters of one statement, as `DESCRIBE INPUT` reported them. +#[derive(Debug, Clone)] +pub struct CachedParams { + /// The SQL these describe, as `describe_param` received it. + sql: String, + /// Indexed by position: element `n` describes ODBC parameter `n + 1`. + params: Vec<ParamDescriptor>, +} + +/// The prepared-statement name used to reach `DESCRIBE INPUT`. +/// +/// Fixed rather than generated: a fresh name per call would accumulate in the +/// session until `DEALLOCATE` ran, and re-using one bounds the damage to a +/// single entry if a `DEALLOCATE` is ever lost. Prefixed to keep it clear of +/// any name an application prepared itself. +const DESCRIBE_PARAM_STATEMENT: &str = "stackable_odbc_describe_input"; + +/// Build one descriptor from a Trino type signature. +/// +/// Precision and scale come from the signature itself (`char(20)`, +/// `decimal(10,2)`) through the same two helpers `SQLColumns` uses, so a +/// parameter and a column of the same Trino type never disagree. A type +/// carrying neither keeps `ParamDescriptor::new`'s zeroes, which is what the +/// spec has a driver report when the value is not applicable. +fn param_descriptor(type_name: &str) -> ParamDescriptor { + let mut descriptor = ParamDescriptor::new(trino_type_name_to_sql_type(type_name)); + if let Some(precision) = type_name_precision(type_name).and_then(|p| u64::try_from(p).ok()) { + descriptor = descriptor.with_parameter_size(precision); + } + if let Some(scale) = type_name_scale(type_name).and_then(|s| i16::try_from(s).ok()) { + descriptor = descriptor.with_decimal_digits(scale); + } + descriptor +} + +/// Convert `DESCRIBE INPUT`'s rows into one descriptor per parameter. +/// +/// Each row is `Position` (0-based) and `Type` (a Trino type signature such as +/// `bigint` or `char(20)`). The result is indexed by position, so element `n` +/// describes ODBC parameter `n + 1`. +/// +/// `Position` is read rather than trusted to match row order: indexing by row +/// order would silently misdescribe every parameter if Trino ever reordered +/// them, and a wrong specific type is indistinguishable from a real answer. +fn describe_input_rows_to_params(rows: &[Vec<serde_json::Value>]) -> Vec<ParamDescriptor> { + let mut by_position: Vec<(usize, ParamDescriptor)> = rows + .iter() + .filter_map(|row| { + let position = usize::try_from(row.first()?.as_i64()?).ok()?; + let type_name = row.get(1)?.as_str()?; + Some((position, param_descriptor(type_name))) + }) + .collect(); + by_position.sort_by_key(|(position, _)| *position); + by_position.into_iter().map(|(_, param)| param).collect() +} + +/// Describe parameter `parameter_number` (1-based) of `sql`. +/// +/// `Ok(None)` for anything that cannot be answered: a statement Trino +/// declines to prepare, a parameter past the end, a failed round trip. Core +/// then reports its documented uniform guess, which is a better outcome than +/// failing `SQLDescribeParam` outright or inventing a specific type. +pub(super) fn describe_param( + conn: &TrinoConnection, + sql: &str, + parameter_number: u16, +) -> Result<Option<ParamDescriptor>, TrinoError> { + tracing::debug!(%sql, parameter_number, "TrinoBackend::describe_param"); + + // Parameter numbers are 1-based; core rejects 0 before reaching a + // backend, so this is belt and braces rather than a live path. + let Some(index) = usize::from(parameter_number).checked_sub(1) else { + return Ok(None); + }; + + let mut cache = conn + .describe_param_cache + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + + if cache.as_ref().is_none_or(|cached| cached.sql != sql) { + // Read before the round trip, because a failure inside it is what makes + // the answer differ. Trino carries the transaction id in a session + // header, so the `PREPARE` below joins whatever the application has + // open, and a statement error aborts the whole transaction. + let in_transaction = conn.in_transaction(); + match fetch_input_params(conn, sql) { + Ok(params) => { + *cache = Some(CachedParams { + sql: sql.to_string(), + params, + }); + } + // Outside a transaction this is not an error path for the + // application: Trino declines to prepare plenty of legitimate + // statements, and core's uniform `VARCHAR` fallback is a usable + // answer for a call that only sizes a buffer. + Err(e) if !in_transaction => { + tracing::debug!(%sql, error = %e, "DESCRIBE INPUT failed; leaving the parameter to core's default"); + return Ok(None); + } + // Inside one it is. The transaction is now aborted server-side, so + // every later statement on this connection fails until a rollback + // and `SQLEndTran(SQL_COMMIT)` will refuse. Swallowing that would + // report success from `SQLDescribeParam` while the application's + // transaction had just been killed by a round trip it did not make + // and cannot see. The alternative, suppressing the abort, would be + // a lie about what Trino did. + Err(e) => { + tracing::warn!( + %sql, + error = %e, + "DESCRIBE INPUT failed inside an open transaction, which Trino \ + therefore aborted; reporting it rather than falling back" + ); + return Err(e); + } + } + } + + Ok(cache + .as_ref() + .and_then(|cached| cached.params.get(index)) + .copied()) +} + +/// Run a statement that returns no result set, discarding its update count. +fn execute_statement(conn: &TrinoConnection, sql: String) -> Result<(), TrinoError> { + conn.runtime + .block_on(conn.client.execute(sql)) + .map_err(|e| super::map_trino_error_on(&conn.liveness, e))?; + Ok(()) +} + +/// Run the `PREPARE` / `DESCRIBE INPUT` / `DEALLOCATE` round trip. +/// +/// `DEALLOCATE` runs whether or not the describe succeeded, so a failure +/// cannot leave the statement registered on the session. +fn fetch_input_params( + conn: &TrinoConnection, + sql: &str, +) -> Result<Vec<ParamDescriptor>, TrinoError> { + // The terminator is stripped here as well as in `execute`, and for the same + // reason: Trino's grammar has none, so `PREPARE x FROM SELECT ? ;` is a + // syntax error. `exec_direct` strips it before submitting, so a statement + // an application prepares and executes successfully would otherwise be one + // this cannot describe, and inside a transaction that failure is no longer + // silent. + let sql = crate::backend::execute::strip_trailing_semicolons(sql); + + // `PREPARE` and `DEALLOCATE` produce no result set, so they go through + // `Client::execute`; `query_all_rows` deserialises rows and fails on a + // statement that declares no columns. + execute_statement( + conn, + format!("PREPARE {DESCRIBE_PARAM_STATEMENT} FROM {sql}"), + )?; + + let described = query_all_rows(conn, format!("DESCRIBE INPUT {DESCRIBE_PARAM_STATEMENT}")); + + if let Err(e) = execute_statement( + conn, + format!("DEALLOCATE PREPARE {DESCRIBE_PARAM_STATEMENT}"), + ) { + // A warning rather than a failure: the describe itself may have + // succeeded, and the cost of a leaked entry is a larger header on + // later requests, not a wrong answer. + tracing::warn!(error = %e, "could not deallocate the describe-input statement"); + } + + let rows: Vec<Vec<serde_json::Value>> = described? + .into_iter() + .map(|row| row.into_json().into_iter().collect()) + .collect(); + + Ok(describe_input_rows_to_params(&rows)) +} + +#[cfg(test)] +mod tests { + use super::*; + use stackable_odbc_core::types::SqlDataType; + + /// `DESCRIBE INPUT` returns one row per parameter: `Position` (0-based) + /// and `Type` (a Trino type signature). + fn row(position: i64, ty: &str) -> Vec<serde_json::Value> { + vec![serde_json::json!(position), serde_json::json!(ty)] + } + + /// The `PREPARE` this module wraps the application's SQL in has to see the + /// same text `exec_direct` submits. Trino's grammar has no terminator, so + /// `PREPARE x FROM SELECT ? ;` is a syntax error, and a statement that + /// prepares and executes perfectly well would be one this cannot describe. + /// + /// Asserted on the shared helper rather than by reaching a coordinator, + /// which `fetch_input_params` needs; the live half is + /// `test_describe_param.py`. + #[test] + fn the_prepared_text_carries_no_statement_terminator() { + use crate::backend::execute::strip_trailing_semicolons; + + for (sql, expected) in [ + ("SELECT ? ;", "SELECT ?"), + ("SELECT ?;", "SELECT ?"), + ("SELECT ? ;; ", "SELECT ?"), + ("SELECT ?", "SELECT ?"), + // Not the trailing character, so not a terminator. + ("SELECT ';'", "SELECT ';'"), + ] { + assert_eq!( + format!( + "PREPARE {DESCRIBE_PARAM_STATEMENT} FROM {}", + strip_trailing_semicolons(sql) + ), + format!("PREPARE {DESCRIBE_PARAM_STATEMENT} FROM {expected}"), + "for {sql:?}" + ); + } + } + + #[test] + fn describe_input_rows_become_one_descriptor_per_parameter_in_position_order() { + let params = describe_input_rows_to_params(&[row(0, "bigint"), row(1, "char(20)")]); + + assert_eq!(params.len(), 2); + assert_eq!(params[0].data_type(), SqlDataType::EXT_BIG_INT); + assert_eq!(params[1].data_type(), SqlDataType::EXT_W_CHAR); + } + + #[test] + fn parameters_are_ordered_by_position_not_by_row_order() { + // Indexing by row order would describe parameter 1 as the char and + // parameter 2 as the bigint. A wrong specific type is worse than no + // answer, because an application cannot tell it apart from a real one. + let params = describe_input_rows_to_params(&[row(1, "char(20)"), row(0, "bigint")]); + + assert_eq!(params.len(), 2); + assert_eq!(params[0].data_type(), SqlDataType::EXT_BIG_INT); + assert_eq!(params[1].data_type(), SqlDataType::EXT_W_CHAR); + } + + #[test] + fn a_parametric_type_carries_its_precision_and_scale() { + // Sizing a buffer is what SQLDescribeParam is for, so the type alone + // is not enough: char(20) must say 20, and decimal(10,2) must say + // both 10 and 2. + let params = describe_input_rows_to_params(&[row(0, "char(20)"), row(1, "decimal(10,2)")]); + + assert_eq!(params[0].parameter_size(), 20); + assert_eq!(params[0].decimal_digits(), 0); + assert_eq!(params[1].parameter_size(), 10); + assert_eq!(params[1].decimal_digits(), 2); + } +} diff --git a/src/backend/execute.rs b/src/backend/execute.rs new file mode 100644 index 0000000..70c6a8e --- /dev/null +++ b/src/backend/execute.rs @@ -0,0 +1,967 @@ +//! Statement execution for the Trino backend: `exec_direct`, `prepare` and +//! `execute`, plus the [`StatementBackend`] implementation that streams result +//! rows back through the shared `stackable-odbc-core` fetch path. + +use std::collections::HashSet; +use std::sync::Arc; +use std::sync::atomic::Ordering; +use std::time::Instant; + +use stackable_odbc_core::backend::StatementBackend; +use stackable_odbc_core::errors::OdbcError; +use stackable_odbc_core::types::{ + CDataType, ColumnDescriptor, ColumnValue, ExecuteOutcome, FetchResult, SqlState, ValueWarning, +}; +use trino_rust_client::{Row, TrinoTy}; + +use super::info::trino_bare_type_name; +use super::{ + TrinoCancelToken, TrinoConnection, TrinoError, TrinoStatement, log_page_stats, map_trino_error, + map_trino_error_on, +}; +use crate::type_conversion::{ + TrinoTypeName, discards_fractional_seconds, json_to_column_value, trino_ty_precision, + trino_ty_scale, trino_ty_to_sql_type, type_name_precision, type_name_scale, +}; + +/// Convert decoded Trino rows into `Vec<Vec<ColumnValue>>`, alongside the cells +/// whose conversion dropped fractional-seconds digits. +/// +/// Takes ownership to avoid cloning every `Row`. Transport decoding happens +/// first, in [`decode_page_rows`], which is what resolves a spooled page's +/// segments; this is the value-level step and cannot fail. +/// +/// The truncation set is collected here because the wire text exists nowhere +/// else: by the time `get_data` hands a value out, the discarded digits are +/// gone. See [`discards_fractional_seconds`] for what counts as a loss. +fn convert_rows( + rows: Vec<Row>, + types: &[(String, TrinoTy)], +) -> (Vec<Vec<ColumnValue>>, HashSet<(usize, usize)>) { + let mut truncated = HashSet::new(); + let batch = rows + .into_iter() + .enumerate() + .map(|(row_idx, row)| { + row.into_json() + .into_iter() + .zip(types.iter()) + .enumerate() + .map(|(col_idx, (val, (_, ty)))| { + if discards_fractional_seconds(&val, ty) { + truncated.insert((row_idx, col_idx)); + } + json_to_column_value(val, ty) + }) + .collect() + }) + .collect(); + (batch, truncated) +} + +/// Decode one Trino response page into rows. +/// +/// A page the coordinator spooled carries segment references rather than +/// values, and the segments may live in object storage, so decoding needs the +/// HTTP client and the query's column metadata. `Client::decode_page` owns both +/// cases: a direct page's rows are returned as they arrived, a spooled page's +/// segments are fetched, decoded against `raw_columns` and acknowledged. +/// +/// `raw_columns` is passed for every page because Trino sends the metadata on +/// one page only, while any later page may be spooled. +fn decode_page_rows( + runtime: &tokio::runtime::Runtime, + client: &trino_rust_client::client::Client, + data: Option<trino_rust_client::models::QueryResultData<Row>>, + raw_columns: &[trino_rust_client::models::Column], +) -> Result<Vec<Row>, trino_rust_client::error::Error> { + let Some(data) = data else { + return Ok(Vec::new()); + }; + let columns = (!raw_columns.is_empty()).then_some(raw_columns); + runtime.block_on(client.decode_page(data, columns)) +} + +/// Remove the statement terminator an application left on the end. +/// +/// Trino's REST API takes one statement per request and its grammar has no +/// terminator, so a trailing `;` is a syntax error rather than a no-op: +/// `SELECT 1;` fails with `SYNTAX_ERROR` at the semicolon's own column. ODBC +/// applications and query tools routinely send one (`isql` submits the line as +/// typed, and SQL editors commonly append it), so a driver that passes it +/// through rejects statements every other client accepts. +/// +/// Only the *trailing* run is removed, after trailing whitespace. That is +/// enough to be safe without parsing: a statement whose last token is a string +/// literal or quoted identifier ends with the closing quote, so a semicolon +/// inside one is never the final character and never seen here. An embedded +/// semicolon is left alone, and Trino rejects it, correctly, since it does not +/// accept multiple statements per request. +/// +/// A comment *after* the terminator (`SELECT 1; -- done`) is not handled: the +/// trailing character is then the comment, not the semicolon. Recognising it +/// would mean parsing comments, which is a larger change than the case +/// justifies. +/// +/// Shared with `backend::describe_param`, which wraps the same SQL in a +/// `PREPARE`. Both have to strip, or a statement an application prepares and +/// executes successfully is one `SQLDescribeParam` cannot describe. +pub(super) fn strip_trailing_semicolons(sql: &str) -> &str { + let mut trimmed = sql.trim_end(); + while let Some(rest) = trimmed.strip_suffix(';') { + trimmed = rest.trim_end(); + } + trimmed +} + +/// Submit `sql` to Trino and return the statement holding its first page. +/// +/// Polls until a page carrying column metadata arrives, because +/// `StatementBackend::column_count` must be accurate as soon as this returns: +/// core infers cursor state from it. A queued or planning query emits +/// metadata-less pages until the coordinator is ready, so the wait can be long, +/// and the query id is published to the cancel token before it starts. +/// +/// Rows are not waited for. Trino sends metadata before it has evaluated a row, +/// so a statement that fails at execution time returns `Ok` here and reports +/// the failure from [`TrinoStatement::fetch`]. +pub(super) fn exec_direct( + conn: &TrinoConnection, + cancel: &TrinoCancelToken, + sql: &str, +) -> Result<TrinoStatement, TrinoError> { + // Every path carrying application SQL funnels through here (`execute` + // calls this after interpolating parameters), so this is the one place the + // terminator has to be dropped. + let stripped = strip_trailing_semicolons(sql); + if stripped.len() != sql.len() { + tracing::debug!("stripped a trailing statement terminator; Trino's grammar has none"); + } + let sql = stripped; + tracing::debug!(%sql, "TrinoBackend::exec_direct"); + + // Before the submit, so the statement runs inside the transaction rather + // than opening one behind itself. `execute` reaches here too, after + // interpolating its parameters, so this is the only site that needs it. + conn.ensure_transaction()?; + + let submit_start = Instant::now(); + let mut page = { + let _span = tracing::info_span!("trino.submit").entered(); + conn.runtime + .block_on(conn.client.get::<Row>(sql.to_string())) + .map_err(|e| conn.statement_error(e))? + }; + let submit_elapsed = submit_start.elapsed(); + + // Publish the id before polling for metadata rather than after. A query + // that is queued or planning emits metadata-less pages for as long as the + // coordinator is busy, and that wait is precisely when an application + // reaches for `SQLCancel`; recording the id only once the loop below breaks + // would leave the whole wait uncancellable. + cancel.state.begin_query(page.id.clone()); + + log_page_stats(&page.stats, 1); + + let mut page_count: u32 = 1; + let mut empty_page_count: u32 = 0; + let mut total_fetch_time = submit_elapsed; + + if page.columns.is_none() { + let _span = tracing::info_span!("trino.poll_metadata").entered(); + while page.columns.is_none() { + if let Some(error) = page.error.take() { + return Err(conn.statement_error(error.into())); + } + let next_url = page.next_uri.as_ref().ok_or_else(|| TrinoError::General { + message: "query returned no columns and no next page".into(), + })?; + + empty_page_count += 1; + let fetch_start = Instant::now(); + page = conn + .runtime + .block_on(conn.client.get_next(next_url)) + .map_err(|e| conn.statement_error(e))?; + total_fetch_time += fetch_start.elapsed(); + page_count += 1; + + log_page_stats(&page.stats, page_count); + } + tracing::info!(empty_pages = empty_page_count, "metadata polling complete"); + } + + if let Some(error) = page.error.take() { + return Err(conn.statement_error(error.into())); + } + + // `Column` carries the real name and Trino's native type text + // ("varchar(50)", "decimal(10,2)"). `TrinoTy::from_column` consumes the + // `Column` and discards both (including the varchar length, which + // `RawTrinoTy::VarChar` drops entirely), so destructure first and derive + // size/scale from the native type text via the same parser the catalog + // path (`SQLColumns`) uses. `TrinoTy` remains the fallback for a type + // string the parser does not recognise (e.g. compound types). + let raw_columns = page.columns.take().unwrap_or_default(); + // Cloned because the loop below consumes each `Column` (`TrinoTy::from_column` + // takes it by value) and a spooled page fetched later still has to be decoded + // against the metadata. + let kept_columns = raw_columns.clone(); + + let mut trino_types: Vec<(String, TrinoTy)> = Vec::with_capacity(raw_columns.len()); + let mut columns: Vec<ColumnDescriptor> = Vec::with_capacity(raw_columns.len()); + + for column in raw_columns { + let native_name = column.ty.clone(); + let column_name = column.name.clone(); + let ty = match TrinoTy::from_column(column) { + Ok((_, ty)) => ty, + Err(error) => { + tracing::warn!( + column = %column_name, + trino_type = %native_name, + %error, + "could not parse Trino type signature; describing column as unknown" + ); + TrinoTy::Unknown + } + }; + + let sql_type = TrinoTypeName::parse(&native_name) + .map(|t| t.sql_type()) + .unwrap_or_else(|| trino_ty_to_sql_type(&ty)); + + let precision = type_name_precision(&native_name) + .and_then(|p| u32::try_from(p).ok()) + .unwrap_or_else(|| trino_ty_precision(&ty)); + let scale = type_name_scale(&native_name) + .and_then(|s| i16::try_from(s).ok()) + .unwrap_or_else(|| trino_ty_scale(&ty)); + + // Nullability is left at `ColumnDescriptor::new`'s + // `SQL_NULLABLE_UNKNOWN`. Trino's REST protocol describes a result + // column with a name and a type and nothing else, so this driver cannot + // determine whether a column accepts NULL, and the ODBC spec defines + // the third value for exactly that case. Claiming `SQL_NULLABLE` + // instead would be a guess that happens to be safe for a projection of + // a nullable base column and wrong for a `COUNT(*)`; claiming + // `SQL_NO_NULLS` would tell an application it may skip a NULL check it + // needs. + columns.push( + ColumnDescriptor::new(column_name.clone(), sql_type) + // Spec (SQL_DESC_TYPE_NAME / SQLColumns.TYPE_NAME): both list + // bare examples ("CHAR", "VARCHAR", ...), not parameterised + // declarations: "varchar(50)" is a *declaration*, and matches + // no `SQLGetTypeInfo` row. `trino_bare_type_name` returns the + // bare name that does (see its doc comment in + // `backend/info.rs`); precision/scale still come from + // `native_name`, so the declared length is not lost, only + // moved out of the name. + .with_type_name(trino_bare_type_name(&native_name, sql_type)) + .with_precision_scale(precision, scale), + ); + trino_types.push((column_name, ty)); + } + + // Read out before `page.data` is consumed below. + let next_uri = page.next_uri; + let query_id = page.id; + + let convert_start = Instant::now(); + let (batch, truncated_cells) = { + let _span = tracing::info_span!("trino.convert_batch", page = page_count).entered(); + let rows = decode_page_rows(&conn.runtime, &conn.client, page.data, &kept_columns) + .map_err(|e| conn.statement_error(e))?; + convert_rows(rows, &trino_types) + }; + let total_convert_time = convert_start.elapsed(); + let total_rows_fetched = batch.len() as u64; + + tracing::debug!( + columns = columns.len(), + batch_rows = batch.len(), + has_next = next_uri.is_some(), + "exec_direct: first page with columns received" + ); + + Ok(TrinoStatement { + pending_sql: None, + columns, + trino_types, + raw_columns: kept_columns, + batch, + truncated_cells, + pending_value_warning: None, + batch_cursor: 0, + fetch_failed: false, + next_uri, + query_id: Some(query_id), + client: Some(Arc::clone(&conn.client)), + runtime: Some(Arc::clone(&conn.runtime)), + cancel_state: Some(Arc::clone(&cancel.state)), + txn: Some(Arc::clone(&conn.txn)), + txn_epoch: conn.txn.epoch(), + liveness: Some(conn.liveness.clone()), + page_count, + empty_page_count, + total_rows_fetched, + total_fetch_time, + total_convert_time, + }) +} + +/// Store the SQL for a later `SQLExecute`. +/// +/// The cancel token is untouched: preparing runs no query on the coordinator, +/// so there is nothing yet for `SQLCancel` to name. `execute` fills the token's +/// slot when it submits. +pub(super) fn prepare( + _conn: &TrinoConnection, + _cancel: &TrinoCancelToken, + sql: &str, +) -> Result<TrinoStatement, TrinoError> { + tracing::debug!(sql, "TrinoBackend::prepare"); + Ok(TrinoStatement { + pending_sql: Some(sql.to_string()), + columns: Vec::new(), + trino_types: Vec::new(), + raw_columns: Vec::new(), + batch: Vec::new(), + truncated_cells: HashSet::new(), + pending_value_warning: None, + batch_cursor: 0, + fetch_failed: false, + next_uri: None, + query_id: None, + client: None, + runtime: None, + cancel_state: None, + // No network, so no failure to report and no result set a transaction + // could take with it. + txn: None, + txn_epoch: 0, + liveness: None, + page_count: 0, + empty_page_count: 0, + total_rows_fetched: 0, + total_fetch_time: std::time::Duration::ZERO, + total_convert_time: std::time::Duration::ZERO, + }) +} + +/// Run the SQL [`prepare`] stored on `stmt`, with `params` rendered into it. +/// +/// Trino has no wire-level parameter binding, so the values become literals and +/// the result goes through [`exec_direct`]. The handle stays re-executable: the +/// template is kept, so the same prepared statement can be run again with +/// different values. +pub(super) fn execute( + conn: &TrinoConnection, + cancel: &TrinoCancelToken, + stmt: &mut TrinoStatement, + params: &[ColumnValue], +) -> Result<ExecuteOutcome, TrinoError> { + // Cloned rather than taken: the template is needed again to re-execute the + // same prepared statement with different parameter values, which is the + // main reason to prepare in the first place. + let template = stmt + .pending_sql + .clone() + .ok_or_else(|| TrinoError::General { + message: "execute called without a prepared statement".into(), + })?; + + // Trino has no wire-level parameter binding, so bound values are rendered + // into the SQL as literals. See `super::params` for the escaping rules. + let sql = super::params::interpolate_params(&template, params)?; + tracing::debug!(sql, "TrinoBackend::execute"); + + let mut result = exec_direct(conn, cancel, &sql)?; + // Swap into the existing statement handle. The old `stmt` fields are moved + // into `result` which is then dropped; its Drop impl will drain any + // residual pages from the *previous* query. + std::mem::swap(stmt, &mut result); + // exec_direct returns a statement with no pending SQL; restore the template + // so the handle stays re-executable. + stmt.pending_sql = Some(template); + // Trino has no wire-level parameter binding and therefore no output params. + Ok(ExecuteOutcome::default()) +} + +/// A column number past the end of the result set. +/// +/// `SQLGetData` and `SQLDescribeCol` both list "the value specified for the +/// argument *ColumnNumber* was greater than the number of columns in the result +/// set" under `07009`, with no `(DM)` marker, so it is the driver's to return. +/// An application walking columns until it runs out reads that as the end of +/// the descriptor list; `HY000` tells it only that something went wrong. +pub(super) fn column_out_of_range(col: u16, have: usize) -> TrinoError { + OdbcError::general( + format!("column {col} out of range (have {have} columns)"), + SqlState::invalid_descriptor_index(), + ) + .into() +} + +/// Column 0 reaching a backend at all. Core refuses the bookmark binding +/// first, so this is defence in depth for a driver loaded without a Driver +/// Manager, and `07009` is the same SQLSTATE core answers there. +pub(super) fn column_index_must_be_positive() -> TrinoError { + OdbcError::general( + "column index must be >= 1", + SqlState::invalid_descriptor_index(), + ) + .into() +} + +/// Cancel a running Trino query through the REST API. +/// +/// Called by `SQLCancel`, possibly from a thread holding no lock on the +/// connection while another thread executes on the same statement. Everything +/// this needs is therefore reached through the token: the statement itself may +/// be under `&mut` on the other thread and is not touchable from here. +pub(super) fn cancel(token: &TrinoCancelToken) -> Result<(), TrinoError> { + // Taken, not read: a second SQLCancel for the same query has nothing left + // to do, and Trino answers a DELETE for an already-cancelled query with an + // error nothing can act on. + let query_id = match token.state.query_id.lock() { + Ok(mut slot) => slot.take(), + // A poisoned lock is an internal invariant violation rather than a + // client failure, so it is hand-built rather than routed through + // `map_trino_error` (see AGENTS.md), and built as an `OdbcError` so its + // SQLSTATE and message reach `SQLGetDiagRec` unchanged: a + // `TrinoError::Odbc` is unwrapped, not re-mapped. + Err(_) => { + return Err(TrinoError::from(OdbcError::general( + "cancel state was poisoned by an earlier panic", + SqlState::general_error(), + ))); + } + }; + + let Some(query_id) = query_id else { + tracing::debug!("SQLCancel: no query ID available (query may be finished)"); + return Ok(()); + }; + + tracing::debug!(query_id = %query_id, "cancelling Trino query"); + + // The cancellation is published *before* the DELETE, and unconditionally. + // `is_cancelled` is what stops the fetch loop, and the caller's intent to + // stop does not depend on a round trip: a DELETE that fails while the + // coordinator keeps answering pages would otherwise leave `SQLCancel` and + // `SQL_ATTR_QUERY_TIMEOUT` unenforceable, which + // `a_cancel_whose_delete_fails_still_stops_the_statement` measures. + // + // Setting it here also closes a race against `map_trino_error`'s + // `USER_CANCELED` arm: the coordinator can fail the in-flight request + // before the cancelling thread has recorded anything. + // + // Nothing is stranded when the DELETE fails. A cancelled result set is + // abandoned either way, and reqwest evicts the socket it leaves behind on + // its 90-second idle timeout. + token.state.cancelled.store(true, Ordering::SeqCst); + + // The failure is still reported. Core logs it, and a caller that wanted the + // coordinator to stop deserves to know it may not have. + token + .runtime + .block_on(token.client.cancel(&query_id)) + .map_err(|e| map_trino_error_on(&token.liveness, e))?; + + Ok(()) +} + +impl TrinoStatement { + /// Whether a commit or rollback has discarded this statement's result set. + /// + /// Trino answers `GENERIC_INTERNAL_ERROR: Already finished` for a page + /// request on a result set whose transaction has ended, so `close_cursor` + /// has to skip its drain rather than attempt it. The connection bumps its + /// epoch before it sends the `COMMIT`, so a statement whose recorded epoch + /// has fallen behind is one whose rows the coordinator is already + /// discarding. + /// + /// `false` for a statement built without an epoch: the in-memory catalog + /// results, which hold no `next_uri` and so have nothing to drain. + fn result_set_died_with_a_transaction(&self) -> bool { + self.txn + .as_ref() + .is_some_and(|txn| txn.outlived(self.txn_epoch)) + } + + /// Whether a `SQLCancel` on another thread has already stopped this + /// statement's query server-side. + /// + /// `false` for a statement built without a cancel state: the in-memory + /// catalog results, which hold no `next_uri` and so have nothing to stop. + fn is_cancelled(&self) -> bool { + self.cancel_state + .as_ref() + .is_some_and(|state| state.is_cancelled()) + } + + /// Classify a client error, recording a lost link on the connection this + /// statement came from. + /// + /// A page fetch is the most likely place a connection failure is first + /// seen, and `SQL_ATTR_CONNECTION_DEAD` is a fact about the connection + /// rather than about the statement, so the observation has to travel back. + /// Falls back to the bare mapper for a statement built without a liveness + /// handle, which is the in-memory catalog results that reach no network. + fn map_client_error(&self, e: trino_rust_client::error::Error) -> TrinoError { + // A statement is where a Trino failure usually becomes visible, and a + // failure inside a transaction aborts the whole thing. `exec_direct` + // cannot see it on its own: Trino sends column metadata before it has + // evaluated a row, so `SELECT 1/0` returns a statement and fails here. + if let Some(txn) = &self.txn { + txn.note_statement_error(); + } + match &self.liveness { + Some(liveness) => map_trino_error_on(liveness, e), + None => map_trino_error(e), + } + } + + /// Discard the result set after a failed page fetch and return `err`. + /// + /// The rows of the last successfully fetched page are still buffered when a + /// fetch fails. Leaving them in place would let `SQLGetData` keep returning + /// data for a row the application was told it never received, so the batch + /// is dropped, the cursor is rewound and the statement is marked failed. + fn abandon_result_set(&mut self, err: TrinoError) -> TrinoError { + tracing::debug!( + page = self.page_count, + "abandoning Trino result set after a failed page fetch" + ); + self.batch.clear(); + self.truncated_cells.clear(); + self.batch_cursor = 0; + self.next_uri = None; + self.fetch_failed = true; + err + } + + /// Turn a failed page fetch into the right outcome, separating a + /// cancellation from a failure. + /// + /// Both discard the buffered rows and leave the statement in different + /// states. A failure marks it `fetch_failed`, so a further fetch reports + /// the `24000` an undefined cursor position calls for. A cancellation is + /// not an abandonment, since the application asked for it, so the statement + /// is kept off `abandon_result_set` and its cursor counts as finished. + /// + /// Finished is not exhausted. The cancellation is still an error, and every + /// later fetch reports `HY008` too, from the `is_cancelled` check at the + /// top of [`TrinoStatement::fetch`]. `NoData` would say "your result set + /// ended", which is false when rows were discarded, and would let a query + /// timeout enforced by cancelling reach the application as an empty result + /// set with no diagnostic. + /// + /// Either way `next_uri = None` suppresses the drain: paging a cancelled + /// query fails and leaves the pooled socket dirty. + fn end_page_fetch(&mut self, err: TrinoError) -> TrinoError { + if matches!(err, TrinoError::OperationCancelled { .. }) { + tracing::debug!( + page = self.page_count, + "Trino reported the query as cancelled; ending the result set" + ); + self.batch.clear(); + self.truncated_cells.clear(); + self.batch_cursor = 0; + self.next_uri = None; + return err; + } + self.abandon_result_set(err) + } +} + +impl StatementBackend for TrinoStatement { + type Error = TrinoError; + + fn fetch(&mut self) -> Result<FetchResult, TrinoError> { + // A previous page fetch failed, so the cursor position is undefined and + // the result set cannot be resumed. Report that rather than the + // `NoData` an exhausted `next_uri` would otherwise produce. + if self.fetch_failed { + return Err(OdbcError::general( + "the result set was abandoned by an earlier fetch failure", + SqlState::invalid_cursor_state(), + ) + .into()); + } + + // Looping rather than recursing: Trino emits empty data pages while a + // query is queued or planning, and a long-queued query on a busy + // coordinator can produce arbitrarily many of them. Recursing once per + // empty page exhausts the stack, and a stack overflow aborts the host + // process rather than unwinding into panic_safe. + loop { + // A concurrent SQLCancel, or a query timeout core enforced by + // cancelling, has already stopped this query server-side. Discard + // what is left, and above all do not poll `next_uri`; see `cancel` + // for why that dirties the socket. + // + // This is the half of the cancel `map_trino_error` cannot see: one + // landing between page requests leaves no failed response to + // classify. `end_page_fetch` reports it as `HY008`, and core + // relabels that to `HYT00` when its own timer fired. + if self.is_cancelled() { + return Err(self.end_page_fetch(super::cancelled_between_requests())); + } + + if self.batch_cursor < self.batch.len() { + self.batch_cursor += 1; + return Ok(FetchResult::Row); + } + + let next_url = match self.next_uri.take() { + Some(url) => url, + None => return Ok(FetchResult::NoData), + }; + + let client = self.client.as_ref().ok_or_else(|| { + TrinoError::from(OdbcError::general( + "no client available for streaming fetch", + SqlState::general_error(), + )) + })?; + let runtime = self.runtime.as_ref().ok_or_else(|| { + TrinoError::from(OdbcError::general( + "no runtime available for streaming fetch", + SqlState::general_error(), + )) + })?; + + tracing::debug!(url = %next_url, "fetching next page from Trino"); + + let fetch_start = Instant::now(); + let fetched = { + let _span = tracing::info_span!("trino.fetch_page").entered(); + runtime.block_on(client.get_next(&next_url)) + }; + self.total_fetch_time += fetch_start.elapsed(); + self.page_count += 1; + + // Both failure paths below must abandon the result set: the rows of + // the previous page are still in `self.batch` and would otherwise + // remain readable through `get_data` after the error was reported. + let mut page: trino_rust_client::QueryResult<Row> = match fetched { + Ok(page) => page, + Err(e) => { + let mapped = self.map_client_error(e); + return Err(self.end_page_fetch(mapped)); + } + }; + + if let Some(error) = page.error.take() { + let mapped = self.map_client_error(error.into()); + return Err(self.end_page_fetch(mapped)); + } + + log_page_stats(&page.stats, self.page_count); + + // Read out before `page.data` is consumed below. + self.next_uri = page.next_uri; + + let convert_start = Instant::now(); + let decoded = { + let _span = + tracing::info_span!("trino.convert_batch", page = self.page_count).entered(); + decode_page_rows(runtime, client, page.data, &self.raw_columns) + }; + (self.batch, self.truncated_cells) = match decoded { + Ok(rows) => convert_rows(rows, &self.trino_types), + Err(e) => { + let mapped = self.map_client_error(e); + return Err(self.end_page_fetch(mapped)); + } + }; + + if self.batch.is_empty() { + self.empty_page_count += 1; + } + self.total_convert_time += convert_start.elapsed(); + self.total_rows_fetched += self.batch.len() as u64; + self.batch_cursor = 0; + + tracing::debug!( + batch_rows = self.batch.len(), + has_next = self.next_uri.is_some(), + "next page received" + ); + + // Fall through to the top of the loop: a non-empty batch is + // consumed there, and an empty one advances to the next page (or + // returns NoData when next_uri is exhausted). + } + } + + fn get_data( + &mut self, + col: u16, + _target_type: CDataType, + ) -> Result<std::borrow::Cow<'_, ColumnValue>, TrinoError> { + if self.batch_cursor == 0 { + return Err(OdbcError::general( + "get_data called before fetch", + SqlState::general_error(), + ) + .into()); + } + let col_idx = (col as usize) + .checked_sub(1) + .ok_or_else(column_index_must_be_positive)?; + // Arm the warning before the borrow of `self.batch` below, which holds + // for the rest of the function. Guarded on the set being empty so the + // usual result set, which has no column that can truncate, pays one + // length check per value rather than a hash. + self.pending_value_warning = (!self.truncated_cells.is_empty() + && self + .truncated_cells + .contains(&(self.batch_cursor - 1, col_idx))) + .then_some(ValueWarning::FractionalTruncation); + let row = &self.batch[self.batch_cursor - 1]; + row.get(col_idx) + .map(std::borrow::Cow::Borrowed) + .ok_or_else(|| column_out_of_range(col, row.len())) + } + + fn take_value_warning(&mut self) -> Option<ValueWarning> { + self.pending_value_warning.take() + } + + fn column_count(&self) -> i16 { + // `SQLNumResultCols` writes through a `SQLSMALLINT *`, so the count is + // narrowed here rather than in core: this is where the real number is + // known. Saturating is the only option the ABI leaves, there being no + // `SQL_NO_TOTAL` for a column count, and a Trino result set with more + // than 32767 columns is beyond anything the coordinator will plan. + i16::try_from(self.columns.len()).unwrap_or_else(|_| { + tracing::warn!( + columns = self.columns.len(), + "result set has more columns than SQLSMALLINT can express; reporting i16::MAX" + ); + i16::MAX + }) + } + + fn describe_col(&self, col: u16) -> Result<std::borrow::Cow<'_, ColumnDescriptor>, TrinoError> { + let idx = (col as usize) + .checked_sub(1) + .ok_or_else(column_index_must_be_positive)?; + // Borrowed, not cloned: `SQLColAttribute` calls this once per column + // per attribute, and the descriptors live on the statement for as long + // as the result set does. + self.columns + .get(idx) + .map(std::borrow::Cow::Borrowed) + .ok_or_else(|| column_out_of_range(col, self.columns.len())) + } + + fn row_count(&self) -> Option<i64> { + // Rows stream page by page, so the total is unknown until the result + // set is exhausted. + None + } + + fn close_cursor(&mut self) -> Result<(), TrinoError> { + // Drain the remaining Trino response pages, so the socket goes back to + // reqwest's pool clean. Residual bytes left on it corrupt whichever + // later query reuses it. + // + // Skipped in the two states where the drain would fail and leave + // exactly those bytes: a query cancelled server-side, whose `get_next` + // fails, and a result set a commit or rollback has already discarded + // (see `result_set_died_with_a_transaction`). + let mut drain_failure = None; + if self.is_cancelled() || self.result_set_died_with_a_transaction() { + self.next_uri = None; + } else if let (Some(client), Some(runtime)) = (&self.client, &self.runtime) { + let mut next = self.next_uri.take(); + while let Some(url) = next { + match runtime.block_on(client.get_next::<trino_rust_client::Row>(&url)) { + Ok(page) => next = page.next_uri, + Err(e) => { + // A failed drain leaves the pooled socket dirty, which + // surfaces later as an unrelated query failing. Core + // records what this returns on the statement's own + // diagnostic queue, so it reaches the application + // rather than only the log, but the teardown below + // still runs first, so one dirty socket does not also + // strand the statement. + tracing::warn!( + error = %e, + "failed to drain remaining Trino pages; the pooled \ + connection may carry residual bytes" + ); + drain_failure = Some(self.map_client_error(e)); + break; + } + } + } + } + + self.batch.clear(); + self.truncated_cells.clear(); + self.batch_cursor = 0; + self.next_uri = None; + self.query_id = None; + // The failed result set is gone; the handle is reusable for a new query. + self.fetch_failed = false; + + match drain_failure { + Some(e) => Err(e), + None => Ok(()), + } + } +} + +/// Log the profiling summary and drain residual HTTP pages on drop. +/// +/// The profiling summary is logged here (not in `close_cursor`) because +/// `close_cursor` may not be called for fully-consumed queries where +/// `next_uri` is already `None`. Drop always runs. +impl Drop for TrinoStatement { + fn drop(&mut self) { + // Only a query that reached the coordinator has anything to report. + if self.page_count > 0 { + tracing::info!( + query_id = ?self.query_id, + pages = self.page_count, + empty_pages = self.empty_page_count, + total_rows = self.total_rows_fetched, + fetch_ms = self.total_fetch_time.as_millis() as u64, + convert_ms = self.total_convert_time.as_millis() as u64, + "query profiling summary" + ); + } + + // Drain residual pages so reqwest's connection pool isn't corrupted. + // The result is discarded rather than propagated: there is nowhere for + // a drop to report a diagnostic, and `close_cursor` has already logged + // the failure at `warn!`. + if self.next_uri.is_some() { + let _ = self.close_cursor(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::backend::TransactionState; + + /// A statement carrying a `next_uri` and an epoch that has fallen behind + /// the connection's, which is what a commit leaves behind. + fn statement_at_epoch(txn: &Arc<TransactionState>) -> TrinoStatement { + TrinoStatement { + pending_sql: None, + columns: Vec::new(), + trino_types: Vec::new(), + raw_columns: Vec::new(), + batch: Vec::new(), + truncated_cells: HashSet::new(), + pending_value_warning: None, + batch_cursor: 0, + fetch_failed: false, + // An unreachable host: a drain that is wrongly attempted fails + // rather than quietly succeeding against something real. + next_uri: Some("https://example.invalid/v1/statement/x/1".to_string()), + query_id: None, + client: None, + runtime: None, + cancel_state: None, + txn: Some(Arc::clone(txn)), + txn_epoch: txn.epoch(), + liveness: None, + page_count: 0, + empty_page_count: 0, + total_rows_fetched: 0, + total_fetch_time: std::time::Duration::ZERO, + total_convert_time: std::time::Duration::ZERO, + } + } + + /// Trino answers `GENERIC_INTERNAL_ERROR: Already finished` for a page + /// request on a result set whose transaction has ended, so the drain that + /// keeps the pooled socket clean has to be skipped rather than attempted. + #[test] + fn close_cursor_skips_the_drain_when_a_transaction_ended_under_it() { + let txn = Arc::new(TransactionState::default()); + let mut stmt = statement_at_epoch(&txn); + + txn.ended(); + + stmt.close_cursor() + .expect("closing a cursor a commit already discarded succeeds"); + assert!(stmt.next_uri.is_none()); + } + + /// The other half: with the epoch unchanged the statement still owns its + /// result set, so the guard must not fire and swallow the drain. + #[test] + fn close_cursor_drains_while_the_transaction_still_holds_the_result_set() { + let txn = Arc::new(TransactionState::default()); + let stmt = statement_at_epoch(&txn); + + assert!( + !stmt.result_set_died_with_a_transaction(), + "an unchanged epoch means the coordinator still holds these rows" + ); + } + + #[test] + fn a_trailing_semicolon_is_removed() { + assert_eq!(strip_trailing_semicolons("SELECT 1;"), "SELECT 1"); + assert_eq!(strip_trailing_semicolons("SELECT 1 ; "), "SELECT 1"); + assert_eq!(strip_trailing_semicolons("SELECT 1;\n"), "SELECT 1"); + // Repeated, because one strip leaving a second terminator behind would + // fail exactly as the original did. + assert_eq!(strip_trailing_semicolons("SELECT 1;;"), "SELECT 1"); + assert_eq!(strip_trailing_semicolons("SELECT 1 ; ; "), "SELECT 1"); + } + + #[test] + fn a_statement_without_one_is_untouched() { + assert_eq!(strip_trailing_semicolons("SELECT 1"), "SELECT 1"); + assert_eq!( + strip_trailing_semicolons("SELECT * FROM t"), + "SELECT * FROM t" + ); + } + + /// A semicolon inside a literal is data, not a terminator. + /// + /// These are safe for free rather than by special handling: a statement + /// ending in a literal ends with the closing quote, so the trailing + /// character is never the semicolon. The test pins that reasoning, because + /// a future "smarter" strip that scanned for any semicolon would corrupt + /// every one of them. + #[test] + fn a_semicolon_inside_a_literal_survives() { + assert_eq!(strip_trailing_semicolons("SELECT ';'"), "SELECT ';'"); + assert_eq!(strip_trailing_semicolons("SELECT 'a;b'"), "SELECT 'a;b'"); + assert_eq!( + strip_trailing_semicolons("SELECT * FROM t WHERE x = ';'"), + "SELECT * FROM t WHERE x = ';'" + ); + assert_eq!( + strip_trailing_semicolons(r#"SELECT "weird;name" FROM t"#), + r#"SELECT "weird;name" FROM t"# + ); + // A literal that ends the statement *and* is followed by a terminator: + // the terminator goes, the literal does not. + assert_eq!(strip_trailing_semicolons("SELECT ';';"), "SELECT ';'"); + } + + /// Nothing sensible is left to send, so nothing is invented: the empty + /// statement reaches Trino and is reported as its own syntax error rather + /// than being turned into something the application did not write. + #[test] + fn a_statement_of_only_semicolons_reduces_to_empty() { + assert_eq!(strip_trailing_semicolons(";"), ""); + assert_eq!(strip_trailing_semicolons(" ;; "), ""); + assert_eq!(strip_trailing_semicolons(""), ""); + } +} diff --git a/src/backend/info.rs b/src/backend/info.rs new file mode 100644 index 0000000..d6e823c --- /dev/null +++ b/src/backend/info.rs @@ -0,0 +1,2296 @@ +//! `SQLGetInfo`, `SQLGetTypeInfo` and `SQLGetFunctions` support for the Trino +//! backend: the `get_info` / `get_info_pre_connect` / `get_info_raw` +//! handlers, the exported-function bitmap, the static type-info rows, and the +//! Trino capability bitmaps (`TRINO_*`), several of which are version-gated on +//! the coordinator's reported version. + +use std::borrow::Cow; +use std::sync::OnceLock; + +use stackable_odbc_core::backend::{common_get_info_raw, default_get_info}; +use stackable_odbc_core::function_id::FunctionId; +use stackable_odbc_core::types::{ + InfoType, InfoValue, MaxPrecision, MaxScale, SQL_AF_ALL, SQL_AF_AVG, SQL_AF_COUNT, + SQL_AF_DISTINCT, SQL_AF_MAX, SQL_AF_MIN, SQL_AF_SUM, SQL_AGGREGATE_FUNCTIONS, + SQL_AT_ADD_COLUMN_SINGLE, SQL_AT_ADD_CONSTRAINT, SQL_AT_DROP_COLUMN, SQL_CL_START, + SQL_CODE_DATE, SQL_CODE_TIME, SQL_CODE_TIMESTAMP, SQL_CU_DML_STATEMENTS, + SQL_CU_PRIVILEGE_DEFINITION, SQL_CU_PROCEDURE_INVOCATION, SQL_CU_TABLE_DEFINITION, + SQL_FN_NUM_ABS, SQL_FN_NUM_ACOS, SQL_FN_NUM_ASIN, SQL_FN_NUM_ATAN, SQL_FN_NUM_ATAN2, + SQL_FN_NUM_CEILING, SQL_FN_NUM_COS, SQL_FN_NUM_DEGREES, SQL_FN_NUM_EXP, SQL_FN_NUM_FLOOR, + SQL_FN_NUM_LOG, SQL_FN_NUM_LOG10, SQL_FN_NUM_MOD, SQL_FN_NUM_PI, SQL_FN_NUM_POWER, + SQL_FN_NUM_RADIANS, SQL_FN_NUM_RAND, SQL_FN_NUM_ROUND, SQL_FN_NUM_SIGN, SQL_FN_NUM_SIN, + SQL_FN_NUM_SQRT, SQL_FN_NUM_TAN, SQL_FN_NUM_TRUNCATE, SQL_FN_STR_CHAR, SQL_FN_STR_CONCAT, + SQL_FN_STR_LCASE, SQL_FN_STR_LENGTH, SQL_FN_STR_LOCATE_2, SQL_FN_STR_LTRIM, + SQL_FN_STR_POSITION, SQL_FN_STR_REPLACE, SQL_FN_STR_RTRIM, SQL_FN_STR_SOUNDEX, + SQL_FN_STR_SUBSTRING, SQL_FN_STR_UCASE, SQL_FN_SYS_DBNAME, SQL_FN_SYS_IFNULL, + SQL_FN_SYS_USERNAME, SQL_FN_TD_CURDATE, SQL_FN_TD_CURRENT_DATE, SQL_FN_TD_CURRENT_TIME, + SQL_FN_TD_CURRENT_TIMESTAMP, SQL_FN_TD_CURTIME, SQL_FN_TD_DAYOFMONTH, SQL_FN_TD_DAYOFWEEK, + SQL_FN_TD_DAYOFYEAR, SQL_FN_TD_EXTRACT, SQL_FN_TD_HOUR, SQL_FN_TD_MINUTE, SQL_FN_TD_MONTH, + SQL_FN_TD_NOW, SQL_FN_TD_QUARTER, SQL_FN_TD_SECOND, SQL_FN_TD_TIMESTAMPADD, + SQL_FN_TD_TIMESTAMPDIFF, SQL_FN_TD_WEEK, SQL_FN_TD_YEAR, SQL_GD_ANY_COLUMN, SQL_GD_ANY_ORDER, + SQL_GD_BOUND, SQL_LIKE_ESCAPE_CLAUSE, SQL_NUMERIC_FUNCTIONS, SQL_OJ_ALL_COMPARISON_OPS, + SQL_OJ_FULL, SQL_OJ_INNER, SQL_OJ_LEFT, SQL_OJ_NESTED, SQL_OJ_NOT_ORDERED, SQL_OJ_RIGHT, + SQL_OUTER_JOINS, SQL_SP_BETWEEN, SQL_SP_COMPARISON, SQL_SP_EXISTS, SQL_SP_IN, SQL_SP_ISNOTNULL, + SQL_SP_ISNULL, SQL_SP_LIKE, SQL_SP_MATCH_FULL, SQL_SP_MATCH_PARTIAL, SQL_SP_MATCH_UNIQUE_FULL, + SQL_SP_MATCH_UNIQUE_PARTIAL, SQL_SP_OVERLAPS, SQL_SP_QUANTIFIED_COMPARISON, SQL_SP_UNIQUE, + SQL_SQL92_PREDICATES, SQL_SQL92_RELATIONAL_JOIN_OPERATORS, SQL_SQL92_VALUE_EXPRESSIONS, + SQL_SRJO_CORRESPONDING_CLAUSE, SQL_SRJO_CROSS_JOIN, SQL_SRJO_EXCEPT_JOIN, + SQL_SRJO_FULL_OUTER_JOIN, SQL_SRJO_INNER_JOIN, SQL_SRJO_INTERSECT_JOIN, + SQL_SRJO_LEFT_OUTER_JOIN, SQL_SRJO_RIGHT_OUTER_JOIN, SQL_STRING_FUNCTIONS, + SQL_SU_DML_STATEMENTS, SQL_SU_PRIVILEGE_DEFINITION, SQL_SU_PROCEDURE_INVOCATION, + SQL_SU_TABLE_DEFINITION, SQL_SVE_CASE, SQL_SVE_CAST, SQL_SVE_COALESCE, SQL_SVE_NULLIF, + SQL_SYSTEM_FUNCTIONS, SQL_TIMEDATE_FUNCTIONS, SqlDataType, TypeInfoRow, catalog_column_size, +}; + +use super::TrinoBackend; +use super::TrinoConnection; +use super::TrinoError; +use crate::type_conversion::{MAX_FRACTIONAL_SECONDS_PRECISION, TrinoTypeName}; + +/// Trino's documented maximum DECIMAL precision (and, since Trino ties a +/// DECIMAL's maximum scale to its maximum precision, also its maximum scale). +/// <https://trino.io/docs/current/language/types.html#decimal> +const MAX_DECIMAL_PRECISION: i32 = 38; +const MAX_DECIMAL_SCALE: i16 = 38; + +/// Trino wire-format extension for `TIME WITH TIME ZONE` / +/// `TIMESTAMP WITH TIME ZONE` COLUMN_SIZE, layered on top of the ODBC +/// "Column Size" appendix's plain TIME/TIMESTAMP formula. Neither type is an +/// ODBC concise type (ODBC 3.x has no `SQL_TYPE_TIME_WITH_TIMEZONE` / +/// `SQL_TYPE_TIMESTAMP_WITH_TIMEZONE`; those only exist in ODBC 4.0, which no +/// Driver Manager implements), so the appendix defines no row for them: this +/// is Trino's own wire format, not an appendix value, hence living here +/// rather than in `stackable-odbc-core`. +/// +/// Both types append a glued numeric offset, e.g. `"13:14:15.123456789012+02:00"` +/// (see `parse_trino_time_with_tz`) / `"...+02:00"` (see +/// `parse_trino_timestamp_tz`), in `type_conversion.rs`. +const TRINO_TZ_OFFSET_SUFFIX_LEN: i32 = 6; // "+HH:MM" +/// `TIMESTAMP WITH TIME ZONE` additionally inserts a space before the offset +/// (`"... HH:MM:SS.ffffffffffff +02:00"`), unlike `TIME WITH TIME ZONE`, +/// which glues the offset directly onto the seconds +/// (`"HH:MM:SS.ffffffffffff+02:00"`); see the two parsers cited above for +/// the exact wire formats this mirrors. +const TRINO_TZ_TIMESTAMP_SPACE_LEN: i32 = 1; + +/// Static type information for Trino's type system. +/// +/// Reference: <https://learn.microsoft.com/en-us/sql/odbc/reference/syntax/sqlgettypeinfo-function> +/// Trino types: <https://trino.io/docs/current/language/types.html> +/// +/// Every `column_size` value below is computed via `catalog_column_size` +/// (the ODBC "Column Size" appendix formula, evaluated at this data source's +/// maximum supported precision/scale) rather than hand-written; see +/// `stackable_odbc_core::types::column_size` module docs for why `SQLGetTypeInfo`'s +/// COLUMN_SIZE must never be a literal copied from the per-column path (or +/// vice versa). +/// +/// Rows are sorted by DATA_TYPE ascending (as signed i16, so ODBC extension +/// types with negative codes sort first), then by TYPE_NAME ascending within +/// an equal DATA_TYPE, per the SQLGetTypeInfo spec's "ordered by DATA_TYPE and +/// then ... TYPE_NAME" requirement. This invariant is asserted directly by +/// `type_info_rows_sorted_by_data_type_then_type_name` below; keep new rows +/// in the correct sorted position rather than appending them. +fn trino_type_info() -> &'static [TypeInfoRow] { + static ROWS: OnceLock<Vec<TypeInfoRow>> = OnceLock::new(); + ROWS.get_or_init(|| { + vec![ + // INTERVAL DAY TO SECOND has no dedicated ODBC interval type in + // `trino_ty_to_sql_type` (see the "String-representable types + // without a dedicated ODBC type" comment in type_conversion.rs). + // Trino renders interval values as text, so DATA_TYPE is the + // EXT_W_VARCHAR this driver reports for them, as it is for + // INTERVAL YEAR TO MONTH, JSON, UUID and VARCHAR below. + // + // TYPE_NAME comes from `TrinoTypeName::IntervalDayToSecond::name()` + // and not a literal, so this row and `trino_bare_type_name`'s + // parser cannot drift apart. Without a matching `TrinoTypeName` + // variant no real interval column could report this TYPE_NAME: + // `trino_bare_type_name` would fall through to "VARCHAR". Pinned by + // `every_type_info_row_is_reachable_via_trino_bare_type_name`. + TypeInfoRow::new( + TrinoTypeName::IntervalDayToSecond.name(), + SqlDataType::EXT_W_VARCHAR, + ) + .with_column_size(catalog_column_size( + SqlDataType::EXT_W_VARCHAR, + MaxPrecision(i32::MAX), + MaxScale(0), + )) + .with_literal_affixes(Some("'"), Some("'")), + // INTERVAL YEAR TO MONTH, for the reason INTERVAL DAY TO SECOND + // above gives, including sourcing TYPE_NAME from + // `TrinoTypeName::name()`. + TypeInfoRow::new( + TrinoTypeName::IntervalYearToMonth.name(), + SqlDataType::EXT_W_VARCHAR, + ) + .with_column_size(catalog_column_size( + SqlDataType::EXT_W_VARCHAR, + MaxPrecision(i32::MAX), + MaxScale(0), + )) + .with_literal_affixes(Some("'"), Some("'")), + // JSON: no ODBC type of its own, so it is reported as text. + TypeInfoRow::new(TrinoTypeName::Json.name(), SqlDataType::EXT_W_VARCHAR) + .with_column_size(catalog_column_size( + SqlDataType::EXT_W_VARCHAR, + MaxPrecision(i32::MAX), + MaxScale(0), + )) + .with_literal_affixes(Some("'"), Some("'")) + .with_case_sensitive(true), + // UUID: 36 characters of text, which is where the size comes from. + TypeInfoRow::new(TrinoTypeName::Uuid.name(), SqlDataType::EXT_W_VARCHAR) + .with_column_size(catalog_column_size( + SqlDataType::EXT_W_VARCHAR, + MaxPrecision(36), + MaxScale(0), + )) + .with_literal_affixes(Some("'"), Some("'")), + TypeInfoRow::new(TrinoTypeName::Varchar.name(), SqlDataType::EXT_W_VARCHAR) + .with_column_size(catalog_column_size( + SqlDataType::EXT_W_VARCHAR, + MaxPrecision(i32::MAX), + MaxScale(0), + )) + .with_literal_affixes(Some("'"), Some("'")) + .with_create_params(Some("max length")) + .with_case_sensitive(true), + TypeInfoRow::new(TrinoTypeName::Char.name(), SqlDataType::EXT_W_CHAR) + .with_column_size(catalog_column_size( + SqlDataType::EXT_W_CHAR, + MaxPrecision(u16::MAX as i32), + MaxScale(0), + )) + .with_literal_affixes(Some("'"), Some("'")) + .with_create_params(Some("length")) + .with_case_sensitive(true), + TypeInfoRow::new(TrinoTypeName::Boolean.name(), SqlDataType::EXT_BIT).with_column_size( + catalog_column_size(SqlDataType::EXT_BIT, MaxPrecision(0), MaxScale(0)), + ), + TypeInfoRow::new(TrinoTypeName::TinyInt.name(), SqlDataType::EXT_TINY_INT) + .with_column_size(catalog_column_size( + SqlDataType::EXT_TINY_INT, + MaxPrecision(0), + MaxScale(0), + )) + .with_unsigned(Some(false)) + .with_auto_unique_value(Some(false)) + .with_scale_range(Some(0), Some(0)) + .with_num_prec_radix(Some(10)), + TypeInfoRow::new(TrinoTypeName::BigInt.name(), SqlDataType::EXT_BIG_INT) + .with_column_size(catalog_column_size( + SqlDataType::EXT_BIG_INT, + MaxPrecision(0), + MaxScale(0), + )) + .with_unsigned(Some(false)) + .with_auto_unique_value(Some(false)) + .with_scale_range(Some(0), Some(0)) + .with_num_prec_radix(Some(10)), + TypeInfoRow::new( + TrinoTypeName::Varbinary.name(), + SqlDataType::EXT_LONG_VAR_BINARY, + ) + .with_column_size(catalog_column_size( + SqlDataType::EXT_LONG_VAR_BINARY, + MaxPrecision(i32::MAX), + MaxScale(0), + )) + .with_literal_affixes(Some("X'"), Some("'")), + // SQL_CHAR (1): ANSI alias. See the SQL_VARCHAR comment further + // down this list for why the TYPE_NAME differs from CHAR's. + TypeInfoRow::new("SQL_CHAR", SqlDataType::CHAR) + .with_column_size(catalog_column_size( + SqlDataType::CHAR, + MaxPrecision(u16::MAX as i32), + MaxScale(0), + )) + .with_literal_affixes(Some("'"), Some("'")) + .with_create_params(Some("length")) + .with_case_sensitive(true), + TypeInfoRow::new(TrinoTypeName::Decimal.name(), SqlDataType::DECIMAL) + .with_column_size(catalog_column_size( + SqlDataType::DECIMAL, + MaxPrecision(MAX_DECIMAL_PRECISION), + MaxScale(MAX_DECIMAL_SCALE), + )) + .with_create_params(Some("precision,scale")) + .with_unsigned(Some(false)) + .with_auto_unique_value(Some(false)) + .with_scale_range(Some(0), Some(MAX_DECIMAL_SCALE)) + .with_num_prec_radix(Some(10)), + TypeInfoRow::new(TrinoTypeName::Integer.name(), SqlDataType::INTEGER) + .with_column_size(catalog_column_size( + SqlDataType::INTEGER, + MaxPrecision(0), + MaxScale(0), + )) + .with_unsigned(Some(false)) + .with_auto_unique_value(Some(false)) + .with_scale_range(Some(0), Some(0)) + .with_num_prec_radix(Some(10)), + TypeInfoRow::new(TrinoTypeName::SmallInt.name(), SqlDataType::SMALLINT) + .with_column_size(catalog_column_size( + SqlDataType::SMALLINT, + MaxPrecision(0), + MaxScale(0), + )) + .with_unsigned(Some(false)) + .with_auto_unique_value(Some(false)) + .with_scale_range(Some(0), Some(0)) + .with_num_prec_radix(Some(10)), + TypeInfoRow::new(TrinoTypeName::Real.name(), SqlDataType::REAL) + .with_column_size(catalog_column_size( + SqlDataType::REAL, + MaxPrecision(0), + MaxScale(0), + )) + .with_unsigned(Some(false)) + .with_num_prec_radix(Some(2)), + TypeInfoRow::new(TrinoTypeName::Double.name(), SqlDataType::DOUBLE) + .with_column_size(catalog_column_size( + SqlDataType::DOUBLE, + MaxPrecision(0), + MaxScale(0), + )) + .with_unsigned(Some(false)) + .with_num_prec_radix(Some(2)), + // SQL_VARCHAR (12): ANSI alias, needed by pyodbc and the Windows + // DM. A DM querying SQLGetTypeInfo(SQL_VARCHAR=12) that finds no + // matching row refuses to perform type conversions such as + // bigint to string. The TYPE_NAME has to differ from the WVARCHAR + // entry's "VARCHAR": Power Query builds a record keyed by + // TYPE_NAME and crashes on duplicates. + TypeInfoRow::new("SQL_VARCHAR", SqlDataType::VARCHAR) + .with_column_size(catalog_column_size( + SqlDataType::VARCHAR, + MaxPrecision(i32::MAX), + MaxScale(0), + )) + .with_literal_affixes(Some("'"), Some("'")) + .with_create_params(Some("max length")) + .with_case_sensitive(true), + TypeInfoRow::new(TrinoTypeName::Date.name(), SqlDataType::DATE) + .with_column_size(catalog_column_size( + SqlDataType::DATE, + MaxPrecision(0), + MaxScale(0), + )) + .with_literal_affixes(Some("DATE '"), Some("'")) + .with_verbose_type(SqlDataType::DATETIME.0, Some(SQL_CODE_DATE)), + TypeInfoRow::new(TrinoTypeName::Time.name(), SqlDataType::TIME) + .with_column_size(catalog_column_size( + SqlDataType::TIME, + MaxPrecision(0), + MaxScale(MAX_FRACTIONAL_SECONDS_PRECISION), + )) + .with_literal_affixes(Some("TIME '"), Some("'")) + .with_create_params(Some("precision")) + .with_scale_range(Some(0), Some(MAX_FRACTIONAL_SECONDS_PRECISION)) + .with_verbose_type(SqlDataType::DATETIME.0, Some(SQL_CODE_TIME)), + // TIME WITH TIME ZONE shares plain TIME's DATA_TYPE (see + // TrinoTypeName::sql_type) and still needs its own row: an + // application looking SQLGetTypeInfo up by TYPE_NAME, to build a + // CREATE TABLE statement say, would otherwise not find this type + // at all. Grouped immediately after TIME, per the spec's "ordered + // by DATA_TYPE". + TypeInfoRow::new(TrinoTypeName::TimeWithTimeZone.name(), SqlDataType::TIME) + .with_column_size( + catalog_column_size( + SqlDataType::TIME, + MaxPrecision(0), + MaxScale(MAX_FRACTIONAL_SECONDS_PRECISION), + ) + TRINO_TZ_OFFSET_SUFFIX_LEN, + ) + .with_literal_affixes(Some("TIME '"), Some("'")) + .with_create_params(Some("precision")) + .with_scale_range(Some(0), Some(MAX_FRACTIONAL_SECONDS_PRECISION)) + .with_verbose_type(SqlDataType::DATETIME.0, Some(SQL_CODE_TIME)), + TypeInfoRow::new(TrinoTypeName::Timestamp.name(), SqlDataType::TIMESTAMP) + .with_column_size(catalog_column_size( + SqlDataType::TIMESTAMP, + MaxPrecision(0), + MaxScale(MAX_FRACTIONAL_SECONDS_PRECISION), + )) + .with_literal_affixes(Some("TIMESTAMP '"), Some("'")) + .with_create_params(Some("precision")) + .with_scale_range(Some(0), Some(MAX_FRACTIONAL_SECONDS_PRECISION)) + .with_verbose_type(SqlDataType::DATETIME.0, Some(SQL_CODE_TIMESTAMP)), + // TIMESTAMP WITH TIME ZONE shares plain TIMESTAMP's DATA_TYPE, + // for the reason TIME WITH TIME ZONE above gives. + TypeInfoRow::new( + TrinoTypeName::TimestampWithTimeZone.name(), + SqlDataType::TIMESTAMP, + ) + .with_column_size( + catalog_column_size( + SqlDataType::TIMESTAMP, + MaxPrecision(0), + MaxScale(MAX_FRACTIONAL_SECONDS_PRECISION), + ) + TRINO_TZ_TIMESTAMP_SPACE_LEN + + TRINO_TZ_OFFSET_SUFFIX_LEN, + ) + .with_literal_affixes(Some("TIMESTAMP '"), Some("'")) + .with_create_params(Some("precision")) + .with_scale_range(Some(0), Some(MAX_FRACTIONAL_SECONDS_PRECISION)) + .with_verbose_type(SqlDataType::DATETIME.0, Some(SQL_CODE_TIMESTAMP)), + ] + }) +} + +/// One of the connection's own strings, or the empty string pre-connect. +/// +/// The empty string is what core substitutes for every declaration it cannot +/// reach without a connection, so answering it here keeps the pre-connect shape +/// identical to the one `get_info_every_named_info_type_has_the_declared_shape_pre_connect` +/// asserts. +fn conn_string(conn: Option<&TrinoConnection>, field: fn(&TrinoConnection) -> &String) -> String { + conn.map(field).cloned().unwrap_or_default() +} + +/// Info lookup shared by the connected and pre-connect paths. +/// +/// Only the three identity strings read `conn`, through [`conn_string`], so +/// every other arm answers without a live Trino and the unit tests need none. +/// `conn` is otherwise threaded straight through to core's fall-through: the +/// capability declarations `default_get_info` consults each take a connection, +/// so it answers the full set with `Some(conn)` and only what is knowable +/// without a data source with `None`. +fn trino_get_info( + conn: Option<&TrinoConnection>, + info_type: InfoType, +) -> Result<InfoValue, TrinoError> { + match info_type { + // A schema-qualified name (`schema.table`) is usable in DML, in a + // `CALL schema.procedure()` invocation, in `CREATE`/`ALTER`/`DROP + // TABLE`, and in `GRANT`/`REVOKE`, all confirmed against the Trino + // SQL statement reference. `SQL_SU_INDEX_DEFINITION` is absent: + // Trino's grammar has no `CREATE INDEX`/`DROP INDEX` statement at + // all, so no schema-qualified name is ever usable there. + // + // Do not claim `SQL_SU_INDEX_DEFINITION` in place of + // `SQL_SU_PRIVILEGE_DEFINITION`. They are bits `0x08` and `0x10` of one + // nibble, which makes the swap easy to miss, and it would overclaim a + // statement Trino cannot execute while underclaiming one it can. + InfoType::SchemaUsage => { + return Ok(InfoValue::U32( + SQL_SU_DML_STATEMENTS + | SQL_SU_PROCEDURE_INVOCATION + | SQL_SU_TABLE_DEFINITION + | SQL_SU_PRIVILEGE_DEFINITION, + )); + } + // Same statement coverage as SQL_SCHEMA_USAGE above, just for a + // catalog-qualified name (`catalog.schema.table`); Trino resolves + // both forms through the same qualified-name grammar production, so + // whatever works schema-qualified also works catalog-qualified. + InfoType::CatalogUsage => { + return Ok(InfoValue::U32( + SQL_CU_DML_STATEMENTS + | SQL_CU_PROCEDURE_INVOCATION + | SQL_CU_TABLE_DEFINITION + | SQL_CU_PRIVILEGE_DEFINITION, + )); + } + // Trino's qualified names read catalog.schema.table, catalog first. + InfoType::CatalogLocation => return Ok(InfoValue::U16(SQL_CL_START)), + // SQL_GD_BLOCK is not claimed. It means `SQLGetData` may be called for + // a row in a block cursor after a bulk fetch, and there are no block + // cursors here: `SQLSetStmtAttrW` + // (`stackable-odbc-core/src/ffi/stmt_attr.rs`) rejects any + // SQL_ATTR_ROW_ARRAY_SIZE other than 1, substituting 1 back with + // 01S02, so an application can never obtain a multi-row rowset. + // + // SQL_GD_BOUND does hold: `sql_get_data` + // (`stackable-odbc-core/src/ffi/fetch.rs`) never checks + // `stmt.bindings` before reading a column, so a column bound with + // `SQLBindCol` can still be fetched again through `SQLGetData`. + InfoType::GetDataExtensions => { + return Ok(InfoValue::U32( + SQL_GD_ANY_COLUMN | SQL_GD_ANY_ORDER | SQL_GD_BOUND, + )); + } + // Three `"Y"`/`"N"` info types with no arm in core's + // `default_get_info`. Without these they reach an application as the + // empty string, which is not one of the two values the spec defines + // for any of them. + // + // `SQL_MULT_RESULT_SETS`: a Trino statement produces exactly one + // result set and `SQLMoreResults` never reports another. + // `SQL_NEED_LONG_DATA_LEN`: bound parameters are interpolated into the + // SQL as literals (`crate::backend::params`), so no length is ever + // needed ahead of the value. `SQL_MAX_ROW_SIZE_INCLUDES_LONG`: + // `SQL_MAX_ROW_SIZE` is `0`, the spec's "no specified limit or the + // limit is unknown", so there is no maximum for long columns to count + // against. + InfoType::MultResultSets | InfoType::NeedLongDataLen | InfoType::MaxRowSizeIncludesLong => { + return Ok(InfoValue::String("N".into())); + } + // The three identity strings core answers with the empty string + // because it has nothing else to give: "the DM supplies the DSN; core + // has none", and the other two are "carried in the connection string, + // not known here". True of core, false of this driver, which settles + // all three at connect. + // + // Only `SQL_DATA_SOURCE_NAME` has a spec-defined empty answer, and only + // for a connection string carrying no `DSN` keyword. The other two have + // no such clause, so an empty answer there is a non-answer: an + // application renders "connected as" from `SQL_USER_NAME`, and under + // `ExternalAuthentication` the connection string names nobody at all + // while the coordinator derives the identity from the token. See + // `session_user_name` for the rest of that argument. + // + // Arms rather than capability declarations because none of the three is + // a fact about Trino-the-engine: each is a property of the one + // connection, the way `SQL_DBMS_VER` is. Pre-connect, `conn` is `None` + // and core's empty default stands, which is all the driver could + // honestly say before a connection exists. + InfoType::DataSourceName => { + return Ok(InfoValue::String(conn_string(conn, |c| { + &c.data_source_name + }))); + } + InfoType::ServerName => { + return Ok(InfoValue::String(conn_string(conn, |c| &c.server_name))); + } + InfoType::UserName => { + return Ok(InfoValue::String(conn_string(conn, |c| &c.user_name))); + } + // Nothing a capability method on `TrinoBackend` declares may also have + // an arm here. An arm wins for `SQLGetInfo` while the method keeps + // driving `SQLGetConnectAttr` and the `HY024` validation in + // `sql_set_connect_attr`, so the two can disagree for one connection. + // + // The sixteen info types this covers, with the method core derives each + // from: SQL_CATALOG_NAME (`supports_catalogs`), SQL_NULL_COLLATION + // (`null_collation`), SQL_OJ_CAPABILITIES (`outer_join_capabilities`), + // SQL_IDENTIFIER_CASE (`identifier_case`), SQL_DEFAULT_TXN_ISOLATION + // (`default_txn_isolation`), SQL_TXN_ISOLATION_OPTION + // (`txn_isolation_options`), SQL_DRIVER_NAME, SQL_DRIVER_VER, + // SQL_DBMS_NAME, SQL_DBMS_VER, SQL_TXN_CAPABLE, + // SQL_QUOTED_IDENTIFIER_CASE, SQL_INTEGRITY, SQL_MULTIPLE_ACTIVE_TXN, + // SQL_SPECIAL_CHARACTERS and SQL_ACCESSIBLE_PROCEDURES. See the method + // implementations in `crate::backend`, which is where each value lives. + _ => {} + } + + // Fall through to shared defaults. Core reads the catalog column widths + // from `TrinoBackend::catalog_result_column_widths` on the type parameter, + // so the `SQL_MAX_*_NAME_LEN` group cannot disagree with what this backend + // reports everywhere else. + default_get_info::<TrinoBackend>(conn, info_type).ok_or_else(|| TrinoError::NotImplemented { + feature: format!("get_info({info_type:?})"), + }) +} + +/// `SQLGetInfo` on a connected handle, for an info type core has an +/// [`InfoType`] variant for. +/// +/// Answers from an arm in [`trino_get_info`] where this driver knows better +/// than core, and otherwise from core's `default_get_info`, which reads the +/// capability declarations on `TrinoBackend`. The rule callers depend on: +/// nothing a capability method declares may also have an arm here, or the two +/// can disagree for one connection. Info types with no `InfoType` variant go +/// through [`get_info_raw`] instead, which runs first. +pub(super) fn get_info( + conn: &TrinoConnection, + info_type: InfoType, +) -> Result<InfoValue, TrinoError> { + // SQL_DBMS_VER is connection-dependent but needs no arm here: core's + // `default_get_info` reads it from `TrinoBackend::dbms_version`, which is + // handed the same connection. + trino_get_info(Some(conn), info_type) +} + +/// `SQLGetInfo` before `SQLDriverConnect`, which the Windows Driver Manager +/// does for the identity group. +/// +/// The same lookup with no connection, so core skips every declaration that +/// needs one and substitutes its own benign default. A value this driver +/// reports when connected is therefore not necessarily what an application sees +/// here, and returning `SQL_ERROR` instead is not an option: it corrupts the +/// Windows DM's state (see AGENTS.md). +pub(super) fn get_info_pre_connect(info_type: InfoType) -> Result<InfoValue, TrinoError> { + // Before a connection exists there is no server to report a version for. + // The empty string is the spec's "not available"; returning SQL_ERROR here + // would corrupt the Windows DM's state (see AGENTS.md). + if info_type == InfoType::DbmsVer { + return Ok(InfoValue::String(String::new())); + } + trino_get_info(None, info_type) +} + +/// Trino releases at which SQL-92 features this driver reports became available. +/// Sourced from the Trino release notes. +const TRINO_CORRESPONDING_SINCE: u32 = 475; +const TRINO_MATCH_AND_UNIQUE_SINCE: u32 = 482; +const TRINO_OVERLAPS_SINCE: u32 = 483; + +/// `SQL_ALTER_TABLE`: the `ALTER TABLE` clauses Trino's grammar accepts, each +/// confirmed against a live coordinator rather than read off the docs. +/// +/// | Statement | Result | +/// |---|---| +/// | `ADD COLUMN d varchar` | accepted → `SQL_AT_ADD_COLUMN_SINGLE` | +/// | `ADD COLUMN f integer NOT NULL` | accepted → `SQL_AT_ADD_CONSTRAINT` | +/// | `DROP COLUMN c` | accepted → `SQL_AT_DROP_COLUMN` | +/// | `ADD COLUMN e integer DEFAULT 1` | `SYNTAX_ERROR` | +/// | `DROP COLUMN b CASCADE` / `RESTRICT` | `SYNTAX_ERROR` | +/// | `ALTER COLUMN a SET DEFAULT 1` | `SYNTAX_ERROR` | +/// | `ADD CONSTRAINT pk_a PRIMARY KEY (a)` | `SYNTAX_ERROR` | +/// +/// `SQL_AT_DROP_COLUMN` is the ODBC 2.0 flag, used because ODBC 3.0 has no bit +/// for a `DROP COLUMN` without `CASCADE`/`RESTRICT`, the only form Trino has. +/// `SQL_AT_ADD_CONSTRAINT` *is* a live ODBC 3.0 bit (FIPS Transitional level) +/// despite sitting in `sql.h` beside the two deprecated ones. +/// +/// None of the four `SQL_AT_CONSTRAINT_*` deferrability bits are claimed: +/// Trino has no `DEFERRABLE`/`INITIALLY DEFERRED` syntax. +pub(super) const TRINO_ALTER_TABLE: u32 = + SQL_AT_ADD_COLUMN_SINGLE | SQL_AT_ADD_CONSTRAINT | SQL_AT_DROP_COLUMN; + +/// `SQL_OJ_CAPABILITIES`: Trino supports `LEFT`, `RIGHT`, `FULL` and `INNER` +/// outer joins, nested outer joins, all comparison operators in the `ON` +/// clause, and does not require the outer-join tables in any particular order. +pub(super) const TRINO_OUTER_JOIN_CAPABILITIES: u32 = SQL_OJ_LEFT + | SQL_OJ_RIGHT + | SQL_OJ_FULL + | SQL_OJ_NESTED + | SQL_OJ_NOT_ORDERED + | SQL_OJ_INNER + | SQL_OJ_ALL_COMPARISON_OPS; + +/// `SQL_AGGREGATE_FUNCTIONS`: every ODBC aggregate has a Trino equivalent. +/// `DISTINCT`/`ALL` come from the `setQuantifier` production in Trino's +/// grammar rather than the function reference, which does not spell them out. +/// <https://trino.io/docs/current/functions/aggregate.html> +pub(crate) const TRINO_AGGREGATE_FUNCTIONS: u32 = + SQL_AF_AVG | SQL_AF_COUNT | SQL_AF_MAX | SQL_AF_MIN | SQL_AF_SUM | SQL_AF_DISTINCT | SQL_AF_ALL; + +/// `SQL_SQL92_VALUE_EXPRESSIONS`: all four are present. +/// <https://trino.io/docs/current/functions/conditional.html>, +/// <https://trino.io/docs/current/functions/conversion.html> +pub(crate) const TRINO_SQL92_VALUE_EXPRESSIONS: u32 = + SQL_SVE_CASE | SQL_SVE_CAST | SQL_SVE_COALESCE | SQL_SVE_NULLIF; + +/// `SQL_NUMERIC_FUNCTIONS`: every defined ODBC numeric function has a Trino +/// equivalent except `COT`, which Trino's math reference does not list (its +/// trigonometric set is acos/asin/atan/atan2/cos/cosh/sin/sinh/tan/tanh). +/// +/// ODBC's `LOG` is the natural logarithm, so it maps to Trino's `ln()`; +/// Trino's own `log(b, x)` is base-b and is a different function. +/// +/// `RAND` is the other name whose one-argument form means something else in +/// Trino. ODBC's argument is a *seed* and the result is a float in `[0, 1)`; +/// Trino's `rand(n)` returns an integer in `[0, n)`, so `{fn RAND(5)}` passed +/// through verbatim yields a different type over a different range. +/// [`crate::escape_dialect::rewrite_scalar_fn`] therefore drops the seed and +/// emits a bare `random()`, which keeps ODBC's type and range and loses only +/// reproducibility. Trino has no seeded generator to keep it with. +/// +/// `TRUNCATE` is the third name whose Trino form does not cover ODBC's. Trino +/// declares the two-argument `truncate` over `decimal` only, so a double or +/// real argument fails FUNCTION_NOT_FOUND, while ODBC's `numeric_exp` covers +/// SQL_FLOAT, SQL_REAL and SQL_DOUBLE. The rewrite scales by a power of ten to +/// reach the single-argument `truncate`, which Trino does define over those +/// types. Scaling by an integer literal keeps the argument's own type, as ODBC +/// requires of TRUNCATE, so only a digit count this crate cannot fold, a column +/// or a parameter marker, widens the result to double. +/// +/// `ROUND` needs no rewrite despite sitting next to `TRUNCATE`. Trino's math +/// reference documents a negative second argument for `truncate` alone, but +/// `round` accepts one too, over double, real, decimal and bigint alike, and +/// returns the input's type, which is what ODBC asks of it. Scaling it the way +/// `TRUNCATE` is scaled would break that type preservation for no gain. +/// +/// `ATAN2` is advertised and passed through with its arguments in the order +/// the application wrote them, which is a deliberate deviation from the ODBC +/// appendix. That text reads the first argument as x, while Trino, PostgreSQL, +/// MySQL, Oracle and SQL Server's own `ATN2` all read it as y. The full +/// reasoning, and the peer drivers it was checked against, sit next to the +/// absent arm in [`crate::escape_dialect::rewrite_scalar_fn`]. +/// +/// <https://trino.io/docs/current/functions/math.html> +pub(crate) const TRINO_NUMERIC_FUNCTIONS: u32 = SQL_FN_NUM_ABS + | SQL_FN_NUM_ACOS + | SQL_FN_NUM_ASIN + | SQL_FN_NUM_ATAN + | SQL_FN_NUM_ATAN2 + | SQL_FN_NUM_CEILING + | SQL_FN_NUM_COS + | SQL_FN_NUM_EXP + | SQL_FN_NUM_FLOOR + | SQL_FN_NUM_LOG + | SQL_FN_NUM_MOD + | SQL_FN_NUM_SIGN + | SQL_FN_NUM_SIN + | SQL_FN_NUM_SQRT + | SQL_FN_NUM_TAN + | SQL_FN_NUM_PI + | SQL_FN_NUM_RAND + | SQL_FN_NUM_DEGREES + | SQL_FN_NUM_LOG10 + | SQL_FN_NUM_POWER + | SQL_FN_NUM_RADIANS + | SQL_FN_NUM_ROUND + | SQL_FN_NUM_TRUNCATE; + +/// Trino's reserved words, as `SQL_KEYWORDS` (89) needs them: the raw list, +/// which core then filters against `ODBC_RESERVED_KEYWORDS`, sorts and joins. +/// Of the 83 below, 22 survive that subtraction. +/// +/// Transcribed from <https://trino.io/docs/current/language/reserved.html> +/// rather than read out of the server, because there is nothing to read. +/// Trino has no equivalent of SQLite's `sqlite3_keyword_name`: `system.jdbc` +/// (the schema backing the JDBC driver's `DatabaseMetaData`, and so the one +/// place such a list would live) has no keywords table, nor does +/// `system.metadata`, and this driver speaks HTTP so there is no library to +/// ask. Trino's own JDBC driver hardcodes `getSQLKeywords()` for the same +/// reason. +/// +/// **Not** gated on `server_major`, unlike the SQL-92 predicate and +/// join-operator bitmaps. The safe direction is inverted here: over- +/// reporting a keyword only makes an application quote an identifier it need +/// not have, while under-reporting leaves a reserved word unquoted +/// and the statement fails to parse. So this tracks the newest list rather +/// than the connected server's. The drift is small and additive: of the +/// twelve sampled against a live 467, eleven were already reserved and only +/// `AUTO` was newer. +pub(crate) const TRINO_RESERVED_KEYWORDS: &[&str] = &[ + "ALTER", + "AND", + "AS", + "AUTO", + "BETWEEN", + "BY", + "CASE", + "CAST", + "CONSTRAINT", + "CREATE", + "CROSS", + "CUBE", + "CURRENT_CATALOG", + "CURRENT_DATE", + "CURRENT_PATH", + "CURRENT_ROLE", + "CURRENT_SCHEMA", + "CURRENT_TIME", + "CURRENT_TIMESTAMP", + "CURRENT_USER", + "DEALLOCATE", + "DELETE", + "DESCRIBE", + "DISTINCT", + "DROP", + "ELSE", + "END", + "ESCAPE", + "EXCEPT", + "EXISTS", + "EXTRACT", + "FALSE", + "FOR", + "FROM", + "FULL", + "GROUP", + "GROUPING", + "HAVING", + "IN", + "INNER", + "INSERT", + "INTERSECT", + "INTO", + "IS", + "JOIN", + "JSON_ARRAY", + "JSON_EXISTS", + "JSON_OBJECT", + "JSON_QUERY", + "JSON_TABLE", + "JSON_VALUE", + "LEFT", + "LIKE", + "LISTAGG", + "LOCALTIME", + "LOCALTIMESTAMP", + "NATURAL", + "NORMALIZE", + "NOT", + "NULL", + "ON", + "OR", + "ORDER", + "OUTER", + "OVERLAPS", + "PREPARE", + "RECURSIVE", + "RIGHT", + "ROLLUP", + "SELECT", + "SKIP", + "TABLE", + "THEN", + "TRIM", + "TRUE", + "UESCAPE", + "UNION", + "UNNEST", + "USING", + "VALUES", + "WHEN", + "WHERE", + "WITH", +]; + +/// [`TRINO_RESERVED_KEYWORDS`] in the `Cow` form +/// [`stackable_odbc_core::backend::Backend::keywords`] returns. +/// +/// The const above stays a plain `&[&str]` so the list itself remains readable; +/// wrapping each of the 83 entries in `Cow::Borrowed` at the literal would +/// bury it. The lift is done once behind a `OnceLock` rather than per call +/// because `SQL_KEYWORDS` is one of the info types BI tools query on every +/// connect. +pub(super) fn reserved_keywords() -> &'static [Cow<'static, str>] { + static KEYWORDS: OnceLock<Vec<Cow<'static, str>>> = OnceLock::new(); + KEYWORDS.get_or_init(|| { + TRINO_RESERVED_KEYWORDS + .iter() + .copied() + .map(Cow::Borrowed) + .collect() + }) +} + +/// What the `SQL_*_FUNCTIONS` bitmaps mean here. +/// +/// The spec defines them in terms of the ODBC scalar-function escape, not what +/// the data source can do by some other spelling: "an application can determine +/// which string functions are supported by a driver by calling `SQLGetInfo` +/// with an *information type* of `SQL_STRING_FUNCTIONS`", and what it emits +/// next is `{fn NAME(...)}`. +/// +/// So a bit may only be set when +/// `stackable_odbc_core::escape::translate_escapes`, driven by +/// [`crate::escape_dialect`], turns that escape into Trino SQL that runs. A bit +/// whose name `EscapeDialect::rewrite_scalar_fn` does not handle reaches the +/// coordinator verbatim and fails there, as `FUNCTION_NOT_FOUND: 'curdate'` or +/// `COLUMN_NOT_FOUND: 'sql_tsi_day'`. +/// `untranslatable_escapes_are_never_advertised` holds the two in step, and +/// `crate::escape_dialect` records what each name becomes. +/// +/// `SQL_STRING_FUNCTIONS`: `LCASE` is `lower()`, `UCASE` is `upper()`, `CHAR` +/// is `chr()`, `LOCATE(a, b)` becomes `position(a IN b)`, and the rest are +/// spelled identically in Trino. +/// +/// `SQL_FN_STR_LOCATE_2`, not `SQL_FN_STR_LOCATE`: the spec splits the two +/// forms and only the two-argument one is claimed. ODBC's optional third +/// argument is a start offset, where the third argument of Trino's `strpos()` +/// is an occurrence index, so there is nothing to rewrite it to. +/// +/// Absent, each for its own reason: `LEFT`, `RIGHT`, `SPACE` and `INSERT` (no +/// such function); `REPEAT` (Trino's `repeat` is an *array* function); `ASCII` +/// (`codepoint()` requires exactly one character, where ODBC's `ASCII` takes +/// the leftmost character of any string); `DIFFERENCE` +/// (`levenshtein_distance()` is a different metric); the four `*_LENGTH` +/// variants, which Trino does not document. +/// +/// `LENGTH`, `LTRIM` and `RTRIM` all hinge on ODBC's word "blanks", which means +/// the space character alone. Trino reads all three as whitespace-wide, so +/// `length()` counts trailing spaces ODBC excludes, and the one-argument +/// `ltrim`/`rtrim` strip trailing tabs and newlines ODBC keeps. +/// [`crate::escape_dialect::rewrite_scalar_fn`] therefore routes each through +/// the two-argument trim with an explicit `' '`. +/// +/// `SOUNDEX` needs no rewrite either, and is **not** gated on `server_major` +/// although it is the one name here that Trino has not always had: it arrived +/// in Trino 356 (April 2021, `trinodb/trino#4022`). The version-gated bitmaps +/// below guard `CORRESPONDING` (475), `MATCH` and `UNIQUE` (482) and `OVERLAPS` +/// (483), which are releases a deployment plausibly predates. 356 is not: it is +/// older than every Trino this driver has been run against, and older than the +/// REST protocol behaviour the rest of this crate assumes. A coordinator that +/// old has larger problems here than one unresolvable function name. +/// +/// <https://trino.io/docs/current/functions/string.html> +pub(crate) const TRINO_STRING_FUNCTIONS: u32 = SQL_FN_STR_CONCAT + | SQL_FN_STR_LTRIM + | SQL_FN_STR_LENGTH + | SQL_FN_STR_LCASE + | SQL_FN_STR_LOCATE_2 + | SQL_FN_STR_POSITION + | SQL_FN_STR_REPLACE + | SQL_FN_STR_RTRIM + | SQL_FN_STR_SUBSTRING + | SQL_FN_STR_UCASE + | SQL_FN_STR_CHAR + | SQL_FN_STR_SOUNDEX; + +/// `SQL_SYSTEM_FUNCTIONS`: `USERNAME` is the bare `current_user` keyword, +/// `DBNAME` the bare `current_catalog`, and `IFNULL` is `coalesce(a, b)` +/// (Trino documents no `ifnull`/`nvl`, but two-argument `coalesce` is exactly +/// equivalent). The first two need the escape's `()` removed, which is +/// [`crate::escape_dialect::rewrite_scalar_fn`]'s job. +/// <https://trino.io/docs/current/functions/session.html>, +/// <https://trino.io/docs/current/functions/conditional.html> +pub(crate) const TRINO_SYSTEM_FUNCTIONS: u32 = + SQL_FN_SYS_USERNAME | SQL_FN_SYS_DBNAME | SQL_FN_SYS_IFNULL; + +/// `SQL_TIMEDATE_FUNCTIONS`: the names Trino spells identically, plus the +/// rewritten ones: `CURDATE`/`CURTIME` and the three ODBC 3.x `CURRENT_*` +/// forms become bare keywords, `TIMESTAMPADD`/`TIMESTAMPDIFF` become +/// `date_add`/`date_diff` with the unit re-quoted, and `DAYOFWEEK` becomes an +/// expression converting Trino's ISO numbering to ODBC's. +/// +/// `EXTRACT` needs no rewrite: ODBC's `EXTRACT(field FROM source)` is already +/// Trino's syntax, so the escape passes through untouched. +/// +/// One caveat: Trino's `week()` is ISO week numbering, and the divergence at a +/// year boundary is the whole year rather than a single week. `week(DATE +/// '2021-01-01')` measures 53, where a convention that starts week 1 on +/// January 1, as SQL Server's does, answers 1. It is left as it is: ODBC fixes +/// only the 1-53 range, which ISO numbering stays inside, and never says which +/// convention produces it. +/// +/// Absent: `DAYNAME` and `MONTHNAME`, which Trino has no function for, only +/// `format_datetime()` with a pattern. +/// <https://trino.io/docs/current/functions/datetime.html> +pub(crate) const TRINO_TIMEDATE_FUNCTIONS: u32 = SQL_FN_TD_NOW + | SQL_FN_TD_CURDATE + | SQL_FN_TD_CURTIME + | SQL_FN_TD_CURRENT_DATE + | SQL_FN_TD_CURRENT_TIME + | SQL_FN_TD_CURRENT_TIMESTAMP + | SQL_FN_TD_DAYOFMONTH + | SQL_FN_TD_DAYOFWEEK + | SQL_FN_TD_DAYOFYEAR + | SQL_FN_TD_MONTH + | SQL_FN_TD_QUARTER + | SQL_FN_TD_WEEK + | SQL_FN_TD_YEAR + | SQL_FN_TD_HOUR + | SQL_FN_TD_MINUTE + | SQL_FN_TD_SECOND + | SQL_FN_TD_TIMESTAMPADD + | SQL_FN_TD_TIMESTAMPDIFF + | SQL_FN_TD_EXTRACT; + +/// `SQL_SQL92_PREDICATES` for a coordinator of major version `server_major`. +/// +/// `MATCH` and `UNIQUE` arrived in Trino 482 and `OVERLAPS` in 483, so a +/// server older than that must not claim them: a BI tool that folds an +/// unsupported predicate gets a parse error, which is worse than not folding. +/// `server_major` is `0` when the version probe failed, which gates all three +/// off. +/// +/// <https://trino.io/docs/current/functions/comparison.html> +fn sql92_predicates(server_major: u32) -> u32 { + let mut predicates = SQL_SP_EXISTS + | SQL_SP_ISNOTNULL + | SQL_SP_ISNULL + | SQL_SP_LIKE + | SQL_SP_IN + | SQL_SP_BETWEEN + | SQL_SP_COMPARISON + | SQL_SP_QUANTIFIED_COMPARISON; + + if server_major >= TRINO_MATCH_AND_UNIQUE_SINCE { + predicates |= SQL_SP_MATCH_FULL + | SQL_SP_MATCH_PARTIAL + | SQL_SP_MATCH_UNIQUE_FULL + | SQL_SP_MATCH_UNIQUE_PARTIAL + | SQL_SP_UNIQUE; + } + if server_major >= TRINO_OVERLAPS_SINCE { + predicates |= SQL_SP_OVERLAPS; + } + predicates +} + +/// `SQL_SQL92_RELATIONAL_JOIN_OPERATORS` for a coordinator of major version +/// `server_major`. +/// +/// `CORRESPONDING` on a set operation arrived in Trino 475. `UNION JOIN` has +/// no production in Trino's grammar at any version, so it is never claimed. +/// +/// `NATURAL JOIN` is *never* claimed, at any version, despite being +/// grammatically present: `SqlBase.g4` accepts it and a live Trino 467 +/// coordinator then rejects it at analysis time with `NOT_SUPPORTED: Natural +/// join not supported`. Grammar acceptance alone overstates capability. +/// +/// <https://trino.io/docs/current/sql/select.html> +fn sql92_join_operators(server_major: u32) -> u32 { + let mut operators = SQL_SRJO_CROSS_JOIN + | SQL_SRJO_EXCEPT_JOIN + | SQL_SRJO_FULL_OUTER_JOIN + | SQL_SRJO_INNER_JOIN + | SQL_SRJO_INTERSECT_JOIN + | SQL_SRJO_LEFT_OUTER_JOIN + | SQL_SRJO_RIGHT_OUTER_JOIN; + + if server_major >= TRINO_CORRESPONDING_SINCE { + operators |= SQL_SRJO_CORRESPONDING_CLAUSE; + } + operators +} + +/// `SQLGetInfo` for an info type core has no [`InfoType`] variant for, given as +/// a raw `u16`. +/// +/// This stage runs *before* the Driver-Manager-safe default and wins outright +/// for the types matched below (see `info_type_default_response` in +/// `stackable-odbc-core/src/ffi/info.rs`), which is why the capability bitmaps +/// Power BI reads to decide what it can fold live here rather than in +/// [`get_info`]. `None` hands the type back to core. +pub(super) fn get_info_raw( + conn: &TrinoConnection, + info_type: u16, +) -> Option<Result<InfoValue, TrinoError>> { + // The scalar-function bitmaps describe Trino *equivalents*, not literal + // ODBC escape-sequence support: `SQLExecDirectW`, `SQLPrepareW` and + // `SQLNativeSqlW` translate `{fn NAME(...)}` escapes + // (`stackable_odbc_core::escape::translate_escapes`, driven by + // `TrinoBackend::escape_dialect()`; see `crate::escape_dialect`), so + // `{fn ABS(x)}` becomes `ABS(x)` and names Trino spells differently are + // remapped (`UCASE` to `upper`, `LOG` to `ln`, `IFNULL` to `coalesce`). + // + // A handful need an argument-syntax change no bare name substitution can + // make: `LOCATE`, `CURDATE`/`CURTIME`, `TIMESTAMPADD`/`TIMESTAMPDIFF`, + // `USERNAME`/`DBNAME` and `DAYOFWEEK`. Each has a rewrite in + // `crate::escape_dialect::rewrite_scalar_fn`, and that module's doc + // comment says what each becomes and why renaming alone cannot do it. + match info_type { + SQL_AGGREGATE_FUNCTIONS => Some(Ok(InfoValue::U32(TRINO_AGGREGATE_FUNCTIONS))), + SQL_SQL92_PREDICATES => Some(Ok(InfoValue::U32(sql92_predicates(conn.server_major)))), + SQL_SQL92_RELATIONAL_JOIN_OPERATORS => { + Some(Ok(InfoValue::U32(sql92_join_operators(conn.server_major)))) + } + SQL_SQL92_VALUE_EXPRESSIONS => Some(Ok(InfoValue::U32(TRINO_SQL92_VALUE_EXPRESSIONS))), + SQL_NUMERIC_FUNCTIONS => Some(Ok(InfoValue::U32(TRINO_NUMERIC_FUNCTIONS))), + SQL_STRING_FUNCTIONS => Some(Ok(InfoValue::U32(TRINO_STRING_FUNCTIONS))), + SQL_SYSTEM_FUNCTIONS => Some(Ok(InfoValue::U32(TRINO_SYSTEM_FUNCTIONS))), + SQL_TIMEDATE_FUNCTIONS => Some(Ok(InfoValue::U32(TRINO_TIMEDATE_FUNCTIONS))), + // Trino supports LIKE ... ESCAPE and full outer joins. + SQL_LIKE_ESCAPE_CLAUSE => Some(Ok(InfoValue::String("Y".into()))), + SQL_OUTER_JOINS => Some(Ok(InfoValue::String("Y".into()))), + // SQL_DATABASE_NAME has no arm. The spec makes it and + // `SQLGetConnectAttr(SQL_ATTR_CURRENT_CATALOG)` one value under two + // names, and core reads both from `TrinoBackend::current_catalog`: the + // session's catalog, else the connection string's. An arm here would + // answer only the info type and let the two disagree, so that one + // connection reports `tpcds` from `SQLGetInfo` and `""` from + // `SQLGetConnectAttr`. + _ => common_get_info_raw::<TrinoBackend>(Some(conn), info_type).map(Ok), + } +} + +/// The functions this driver reports as supported through `SQLGetFunctions`. +/// +/// Together with [`TRINO_WITHHELD_FUNCTIONS`] this partitions +/// `CORE_EXPORTED_FUNCTIONS` exactly, which +/// `every_core_exported_function_is_advertised_or_withheld` asserts. The list +/// is opt-in rather than `CORE_EXPORTED_FUNCTIONS` minus the withheld set, +/// and the direction matters: core exporting a symbol answers "does this entry +/// point exist?", not "does this driver implement the function behind it?". +/// Deriving the list would make a newly exported core function advertised +/// without anyone deciding it works, and the Windows Driver Manager builds its +/// dispatch table from this answer. +const TRINO_ADVERTISED_FUNCTIONS: &[FunctionId] = &[ + FunctionId::AllocHandle, + FunctionId::FreeHandle, + FunctionId::Connect, + FunctionId::DriverConnect, + FunctionId::Disconnect, + FunctionId::GetInfo, + FunctionId::GetFunctions, + FunctionId::GetDiagRec, + FunctionId::ExecDirect, + FunctionId::Prepare, + FunctionId::Execute, + FunctionId::Fetch, + FunctionId::GetData, + FunctionId::NumResultCols, + FunctionId::DescribeCol, + FunctionId::ColAttribute, + FunctionId::RowCount, + FunctionId::CloseCursor, + FunctionId::FreeStmt, + FunctionId::MoreResults, + FunctionId::Tables, + FunctionId::Columns, + FunctionId::GetTypeInfo, + // Attribute and diagnostic functions: the Windows DM uses these + // heavily. Missing from the 3.x bitmap causes NULL dispatch crashes. + FunctionId::SetEnvAttr, + FunctionId::GetEnvAttr, + FunctionId::SetConnectAttr, + FunctionId::GetConnectAttr, + FunctionId::SetStmtAttr, + FunctionId::GetStmtAttr, + FunctionId::GetDiagField, + FunctionId::BindCol, + FunctionId::Cancel, + FunctionId::EndTran, + FunctionId::FetchScroll, + // Core exports it and fetches through the same body as `SQLFetch`, so + // reporting false would deny a function this driver performs. `SQL_FETCH_NEXT` + // fetches; every other orientation is `HY106`, which is what a + // forward-only cursor owes an application. + FunctionId::ExtendedFetch, + FunctionId::BindParameter, + FunctionId::NativeSql, + FunctionId::NumParams, + FunctionId::PrimaryKeys, + FunctionId::ForeignKeys, + FunctionId::Statistics, + FunctionId::SpecialColumns, + FunctionId::Procedures, + FunctionId::ProcedureColumns, + FunctionId::GetCursorName, + FunctionId::SetCursorName, + FunctionId::ColumnPrivileges, + FunctionId::TablePrivileges, + FunctionId::DescribeParam, + // Data-at-execution (fully implemented in stackable-odbc-core) and the remaining + // exported entry points that delegate to a real implementation. Listed so + // the Windows DM 3.x dispatch bitmap has no gaps. + FunctionId::ParamData, + FunctionId::PutData, + FunctionId::BrowseConnect, + FunctionId::BulkOperations, + FunctionId::SetPos, + // The descriptor functions. Core implements them against both the implicit + // descriptors an application reaches through + // `SQLGetStmtAttr(SQL_ATTR_APP_ROW_DESC)` and its three siblings, and the + // explicit ones it allocates with `SQLAllocHandle(SQL_HANDLE_DESC)` and + // copies with `SQLCopyDesc`. Reporting a working function unsupported is + // the mirror of reporting a missing one supported: the Driver Manager + // answers `IM001` and the application never calls it. + FunctionId::GetDescField, + FunctionId::SetDescField, + FunctionId::GetDescRec, + FunctionId::SetDescRec, + FunctionId::CopyDesc, +]; + +/// The functions core exports an entry point for that this driver declines to +/// advertise, each with the reason. +/// +/// Empty, because every entry point core exports is one this driver implements. +/// The deprecated ODBC 2.x functions belong to the Driver Manager's mapping +/// rather than to a 3.x driver, and core withholds them itself in its +/// `CORE_UNEXPORTED_FUNCTIONS`, with the reason recorded there, so no exported +/// entry point is left here to decline. +/// +/// The list stays because it is where a refusal goes. `SQLGetFunctions` is what +/// the Windows Driver Manager builds its dispatch table from, and an +/// application reading the bitmap calls what it finds there, so a function core +/// starts exporting has to be advertised or refused explicitly. +/// +/// Nothing reads it at runtime, and that is the point: `get_functions` returns +/// [`TRINO_ADVERTISED_FUNCTIONS`] directly instead of subtracting this from +/// `CORE_EXPORTED_FUNCTIONS`, so a function core adds is advertised only once +/// someone says it works. What consumes it is +/// `every_core_exported_function_is_advertised_or_withheld`, which turns +/// "someone says so" into a build failure. `#[cfg(test)]` would compile it out +/// of the driver and file the reasoning under test scaffolding, which is the +/// opposite of why it is written down. +// `allow` rather than `expect`: the lib is compiled both as a library, where +// this is dead, and as a test target, where the partition test reads it, so +// an expectation would go unfulfilled in one of the two and fail the build. +#[allow(dead_code)] +const TRINO_WITHHELD_FUNCTIONS: &[(FunctionId, &str)] = &[]; + +/// The `SQLGetFunctions` answer: every ODBC function this driver implements. +/// +/// [`TRINO_ADVERTISED_FUNCTIONS`] verbatim, which is what makes the list +/// opt-in. The Windows Driver Manager builds its dispatch table from this, so a +/// name here that core exports no entry point for hands it a null pointer. +pub(super) fn get_functions() -> &'static [FunctionId] { + TRINO_ADVERTISED_FUNCTIONS +} + +/// The `SQLGetTypeInfo` result set: one row per Trino type, plus two ANSI +/// aliases the Windows Driver Manager needs to find. +/// +/// Built once behind a `OnceLock` and sorted as the spec requires, by +/// `DATA_TYPE` then `TYPE_NAME`. An application looks a row up by `TYPE_NAME`, +/// so every name here must be one [`trino_bare_type_name`] can produce for a +/// real column, or the row advertises a type nothing can ever report. +pub(super) fn get_type_info() -> &'static [TypeInfoRow] { + trino_type_info() +} + +/// Bare, uppercase data-source-dependent type name for a column, shared by +/// `SQL_DESC_TYPE_NAME` (`SQLColAttributeW`, via `execute.rs`) and +/// `SQLColumns.TYPE_NAME` (`metadata.rs`), so the two never disagree, and so +/// that name always matches a row in [`trino_type_info`] (the same table +/// `SQLGetTypeInfo` returns via [`get_type_info`]). +/// +/// Spec (`SQL_DESC_TYPE_NAME`): "Data source-dependent data type name; for +/// example, "CHAR", "VARCHAR", "MONEY", "LONG VARBINARY", or "CHAR ( ) FOR BIT +/// DATA"." (`SQLColumns.TYPE_NAME` is worded identically, modulo a typo in the +/// truncated "LONG VARBINAR" example.) Every example in both pages is a bare +/// name, not a parameterised declaration, "CHAR ( )" being an empty placeholder +/// for a length and not a filled-in one. So `native`, a declaration such as +/// `"varchar(50)"`, is never returned verbatim. Nothing is lost: `execute.rs` +/// carries the declared length separately, through `type_name_precision` and +/// `type_name_scale`. +/// +/// `native` is Trino's own type-name string +/// (`information_schema.columns.data_type`, or a query column's `Column::ty`) +/// and `sql_type` is the `SqlDataType` the caller already computed for it. +/// Parsing `native` through [`TrinoTypeName`] comes first because several ODBC +/// types share a `DATA_TYPE` with a differently named sibling row (`TIME` +/// against `TIME WITH TIME ZONE`, `TIMESTAMP` against `TIMESTAMP WITH TIME +/// ZONE`): only the native string can say which one a column is. +/// +/// A failed parse falls back to a canonical name chosen here for `sql_type`, +/// never to whichever [`trino_type_info`] row sorts first under that +/// `DATA_TYPE`. That row is `INTERVAL DAY TO SECOND`, an accident of the +/// table's required sort order, and it would misname every compound type: +/// ARRAY, MAP, ROW, TUPLE, `ipaddress` and anything else with no dedicated +/// `TrinoTypeName` variant. `trino_ty_to_sql_type` renders all of them as +/// `EXT_W_VARCHAR` text, so `VARCHAR` is the honest name for that `DATA_TYPE`. +pub(super) fn trino_bare_type_name(native: &str, sql_type: SqlDataType) -> String { + if let Some(ty) = TrinoTypeName::parse(native) { + return ty.name().to_string(); + } + if sql_type == SqlDataType::EXT_W_VARCHAR { + return TrinoTypeName::Varchar.name().to_string(); + } + // No native string reaches this arm today with any other `sql_type` + // (see `trino_bare_type_name_returns_the_expected_name` below, which + // pins every known fallback input to the `EXT_W_VARCHAR` arm above); + // this driver has no established canonical name for any other + // DATA_TYPE reaching here. Spec (`SQL_DESC_TYPE_NAME`): "If the type is + // unknown, an empty string is returned." + tracing::warn!( + native, + ?sql_type, + "trino_bare_type_name: no canonical name established for this SqlDataType; \ + reporting TYPE_NAME as empty string per spec" + ); + String::new() +} + +#[cfg(test)] +mod tests { + + /// Fixed-size types: the "Column Size" appendix formula for these takes + /// no backend-specific parameter, so the row's value must equal the + /// formula applied to *the row's own* `data_type`. Deriving the expected + /// value from `row.data_type` rather than repeating the table's own + /// arguments is what makes this catch a row built with the wrong + /// `SqlDataType`, the one way two drivers could disagree on a value the + /// spec defines as backend-independent. + /// + /// This replaces a cross-driver test crate that compared the two drivers' + /// tables directly. That crate had to link both drivers into one binary, + /// which duplicates every `extern "system"` ODBC export (see the note in + /// this crate's Cargo.toml), and it pinned expected sizes as literals, + /// the very pattern deriving from the formula exists to remove. + #[test] + fn fixed_size_type_info_rows_use_the_backend_independent_formula() { + // Arguments are ignored by the formula for every type listed here; + // any value proves the point, so use absurd ones. + const IGNORED_PRECISION: MaxPrecision = MaxPrecision(-1); + const IGNORED_SCALE: MaxScale = MaxScale(-1); + + const BACKEND_INDEPENDENT: &[SqlDataType] = &[ + SqlDataType::EXT_BIT, + SqlDataType::EXT_TINY_INT, + SqlDataType::SMALLINT, + SqlDataType::INTEGER, + SqlDataType::EXT_BIG_INT, + SqlDataType::REAL, + SqlDataType::DOUBLE, + SqlDataType::DATE, + ]; + + for row in trino_type_info() { + if !BACKEND_INDEPENDENT.contains(&row.data_type()) { + continue; + } + let expected = catalog_column_size(row.data_type(), IGNORED_PRECISION, IGNORED_SCALE); + assert_eq!( + row.column_size(), + expected, + "{} (DATA_TYPE {:?}): COLUMN_SIZE is {} but the \ + backend-independent appendix formula for that DATA_TYPE \ + gives {}: the row is built from a different SqlDataType \ + than it reports", + row.type_name(), + row.data_type(), + row.column_size(), + expected + ); + } + } + use super::*; + use stackable_odbc_core::types::{ + DEFAULT_IDENTIFIER_LEN, InfoType, InfoValue, SQL_AM_NONE, SQL_ASYNC_DBC_NOT_CAPABLE, + SQL_CA1_NEXT, SQL_CA2_READ_ONLY_CONCURRENCY, SQL_CB_CLOSE, SQL_CB_NULL, + SQL_DRIVER_ODBC_VER_STRING, SQL_FN_CVT_CAST, SQL_FN_STR_LOCATE, SQL_FN_STR_POSITION, + SQL_FN_SYS_DBNAME, SQL_FN_SYS_USERNAME, SQL_FN_TD_CURDATE, SQL_FN_TD_CURRENT_DATE, + SQL_FN_TD_CURRENT_TIME, SQL_FN_TD_CURRENT_TIMESTAMP, SQL_FN_TD_CURTIME, + SQL_FN_TD_DAYOFWEEK, SQL_FN_TD_TIMESTAMPADD, SQL_FN_TD_TIMESTAMPDIFF, + SQL_GB_GROUP_BY_CONTAINS_SELECT, SQL_GD_ANY_COLUMN, SQL_GD_ANY_ORDER, SQL_GD_BOUND, + SQL_IC_LOWER, SQL_MAX_CURSOR_NAME_LEN, SQL_NC_END, SQL_OIC_CORE, SQL_SO_FORWARD_ONLY, + SQL_SQ_COMPARISON, SQL_SQ_CORRELATED_SUBQUERIES, SQL_SQ_EXISTS, SQL_SQ_IN, + SQL_SQ_QUANTIFIED, SQL_TC_DML, SQL_TXN_READ_UNCOMMITTED, SQL_U_UNION, SQL_U_UNION_ALL, + SQL_UNSPECIFIED, + }; + + enum Expected { + Str(&'static str), + U16(u16), + U32(u32), + } + + #[rustfmt::skip] + const EXPECTED: &[(InfoType, Expected)] = &[ + // --- String values --- + (InfoType::DriverName, Expected::Str("stackable-odbc-trino")), + (InfoType::DbmsName, Expected::Str("Trino")), + (InfoType::DriverOdbcVer, Expected::Str(SQL_DRIVER_ODBC_VER_STRING)), + (InfoType::SearchPatternEscape, Expected::Str("\\")), + (InfoType::IdentifierQuoteChar, Expected::Str("\"")), + (InfoType::CatalogTerm, Expected::Str("catalog")), + (InfoType::SchemaTerm, Expected::Str("schema")), + (InfoType::CatalogNameSeparator, Expected::Str(".")), + (InfoType::ColumnAlias, Expected::Str("Y")), + (InfoType::OrderByColumnsInSelect, Expected::Str("N")), + (InfoType::CatalogName, Expected::Str("Y")), + // The connection `disconnected_trino_conn` fabricates: no DSN, and the + // host and user its `ClientBuilder` was given. Empty only for the DSN, + // which is the one of the three the spec defines an empty answer for. + (InfoType::DataSourceName, Expected::Str("")), + (InfoType::ServerName, Expected::Str("localhost")), + (InfoType::UserName, Expected::Str("test")), + (InfoType::DataSourceReadOnly, Expected::Str("N")), + // "N": Trino can filter information_schema by privilege, but only + // when the deployment configures access control; see + // TrinoBackend::accessible_tables. + (InfoType::AccessibleTables, Expected::Str("N")), + (InfoType::AccessibleProcedures, Expected::Str("N")), + (InfoType::Integrity, Expected::Str("N")), + // "": no character beyond a-z, A-Z, 0-9 and _ is legal in an unquoted + // identifier. Trino's IDENTIFIER production is + // (LETTER | '_') (LETTER | DIGIT | '_')*, which is exactly the set this + // info type excludes, so the list of extras is empty. Measured in + // TrinoBackend::special_characters. + (InfoType::SpecialCharacters, Expected::Str("")), + (InfoType::XopenCliYear, Expected::Str("1995")), + // "": the spec's "if this is unknown, an empty string will be + // returned". Trino has no collation concept to name, ordering varchar + // by Unicode code point with no server default to report. + (InfoType::CollationSeq, Expected::Str("")), + (InfoType::DescribeParameter, Expected::Str("Y")), + // --- U16 values --- + (InfoType::GroupBy, Expected::U16(SQL_GB_GROUP_BY_CONTAINS_SELECT)), + // 0 = "no specified limit", not "unknown": both count something this + // driver imposes no cap on. A connection is one HTTP client and a Tokio + // runtime, and a statement is one query id, so nothing here runs out at + // a number either could name. + (InfoType::MaxDriverConnections, Expected::U16(0)), + (InfoType::MaxConcurrentActivities, Expected::U16(0)), + (InfoType::ConcatNullBehavior, Expected::U16(SQL_CB_NULL)), + // Derived by stackable-odbc-core from Backend::cursor_commit_behavior, + // which this driver declares as CursorBehavior::Close: Trino discards a + // transaction's result sets when it ends, so a page request afterwards + // answers GENERIC_INTERNAL_ERROR: Already finished. + (InfoType::CursorCommitBehaviour, Expected::U16(SQL_CB_CLOSE)), + (InfoType::IdentifierCase, Expected::U16(SQL_IC_LOWER)), + (InfoType::MaxColumnNameLen, Expected::U16(DEFAULT_IDENTIFIER_LEN)), + (InfoType::MaxCursorNameLen, Expected::U16(SQL_MAX_CURSOR_NAME_LEN)), + (InfoType::MaxSchemaNameLen, Expected::U16(DEFAULT_IDENTIFIER_LEN)), + (InfoType::MaxCatalogNameLen, Expected::U16(DEFAULT_IDENTIFIER_LEN)), + (InfoType::MaxTableNameLen, Expected::U16(DEFAULT_IDENTIFIER_LEN)), + (InfoType::NullCollation, Expected::U16(SQL_NC_END)), + // The size limits. The spec gives every one of them the same `0`, for + // "no specified limit or the limit is unknown", so the value cannot say + // which of the two it means and the reason is recorded here instead. + // None of them is a placeholder awaiting a real number: the spec offers + // no other way to say what each of these says. + // + // No limit. Trino's grammar and planner cap none of these, so an + // application that reads a bound here would be told one that does not + // exist. The FIPS conformance minimums the spec lists for three of them + // (6 columns in GROUP BY, 100 in a select list, 15 tables in a FROM) + // are floors for a driver that reports a limit at all, and `0` is + // already more permissive than any of them. + (InfoType::MaxColumnsInGroupBy, Expected::U16(0)), + (InfoType::MaxColumnsInOrderBy, Expected::U16(0)), + (InfoType::MaxColumnsInSelect, Expected::U16(0)), + (InfoType::MaxTablesInSelect, Expected::U16(0)), + (InfoType::MaxUserNameLen, Expected::U16(0)), + // Not applicable, which the spec has no separate value for. Trino has + // no `CREATE INDEX` in its grammar at all, which is also why + // `SQL_SCHEMA_USAGE` omits `SQL_SU_INDEX_DEFINITION` and why + // `SQLStatistics` returns no rows. + (InfoType::MaxColumnsInIndex, Expected::U16(0)), + // Genuinely unknown, and the one of this group that is. The cap belongs + // to whichever connector backs the catalog and they disagree (PostgreSQL + // stops at 1600, Hive is effectively unbounded), while one connection + // spans catalogs. That is the same argument that keeps + // `txn_isolation_options` down to the level every catalog accepts. + (InfoType::MaxColumnsInTable, Expected::U16(0)), + // No limit, and a driver fact rather than a Trino one: an environment + // holds a handle table, and nothing in core caps how many exist. + (InfoType::ActiveEnvironments, Expected::U16(0)), + (InfoType::MaxIdentifierLen, Expected::U16(DEFAULT_IDENTIFIER_LEN)), + (InfoType::CatalogLocation, Expected::U16(SQL_CL_START)), + // TransactionCapable is SQLUSMALLINT per spec, not SQLUINTEGER, and is + // declared by `TrinoBackend::txn_capable`. SQL_TC_DML because DDL + // inside a transaction is an error on every JDBC-backed catalog. + (InfoType::TransactionCapable, Expected::U16(SQL_TC_DML as u16)), + // --- U32 values --- + // CursorSensitivity is SQLUINTEGER per spec, not SQLUSMALLINT; see + // the matching comment in stackable-odbc-core's default_get_info. + // `SQL_UNSPECIFIED`, not `SQL_INSENSITIVE`: core's fetch streams rows + // from this backend as the application asks for them, so it makes no + // promise about rows it has not read. Core owns this value; there is no + // `Backend` hook and no arm here. + (InfoType::CursorSensitivity, Expected::U32(SQL_UNSPECIFIED as u32)), + (InfoType::Subqueries, Expected::U32(SQL_SQ_COMPARISON | SQL_SQ_EXISTS | SQL_SQ_IN | SQL_SQ_QUANTIFIED | SQL_SQ_CORRELATED_SUBQUERIES)), + (InfoType::UnionStatement, Expected::U32(SQL_U_UNION | SQL_U_UNION_ALL)), + // Trino's level for a bare START TRANSACTION, which is what this + // driver issues. + (InfoType::DefaultTxnIsolation, Expected::U32(SQL_TXN_READ_UNCOMMITTED)), + (InfoType::ScrollOptions, Expected::U32(SQL_SO_FORWARD_ONLY)), + (InfoType::ConvertFunctions, Expected::U32(SQL_FN_CVT_CAST)), + // SQL_TXN_ISOLATION_OPTION. Only READ UNCOMMITTED, because the + // connector vets the level and the three test catalogs disagree above + // it; see `TrinoBackend::txn_isolation_options`. + (InfoType::TransactionIsolationProtocol, Expected::U32(SQL_TXN_READ_UNCOMMITTED)), + (InfoType::AlterTable, Expected::U32(SQL_AT_ADD_COLUMN_SINGLE | SQL_AT_ADD_CONSTRAINT | SQL_AT_DROP_COLUMN)), + // Not applicable: no `CREATE INDEX`, as for SQL_MAX_COLUMNS_IN_INDEX. + (InfoType::MaxIndexSize, Expected::U32(0)), + // No limit. Trino caps no row width, and the FIPS floors the spec lists + // (2,000 bytes at Entry level) are again floors for a driver reporting + // a limit at all. + (InfoType::MaxRowSize, Expected::U32(0)), + // Unknown, and unknowable from here. Trino does cap statement length, + // through the coordinator's `query.max-length` config property, but no + // `SHOW` or session property exposes it to a client. Reporting its + // 1,000,000-character default would name a bound wrong on any tuned + // deployment, in the direction that makes an application refuse SQL the + // server would have accepted. + (InfoType::MaxStatementLen, Expected::U32(0)), + (InfoType::OuterJoinCapabilities, Expected::U32(SQL_OJ_LEFT | SQL_OJ_RIGHT | SQL_OJ_FULL | SQL_OJ_NESTED | SQL_OJ_NOT_ORDERED | SQL_OJ_INNER | SQL_OJ_ALL_COMPARISON_OPS)), + // 0 is not one of the four SQL_SC_* values: the spec's list has no + // "conforms to nothing" entry, and Trino meets not even Entry level, + // whose referential-integrity requirement its grammar rejects outright. + // See `TrinoBackend::sql_conformance`. + (InfoType::SqlConformance, Expected::U32(0)), + (InfoType::OdbcInterfaceConformance, Expected::U32(SQL_OIC_CORE)), + (InfoType::AsyncMode, Expected::U32(SQL_AM_NONE)), + (InfoType::AsyncDbcFunctions, Expected::U32(SQL_ASYNC_DBC_NOT_CAPABLE)), + (InfoType::SchemaUsage, Expected::U32(SQL_SU_DML_STATEMENTS | SQL_SU_PROCEDURE_INVOCATION | SQL_SU_TABLE_DEFINITION | SQL_SU_PRIVILEGE_DEFINITION)), + (InfoType::CatalogUsage, Expected::U32(SQL_CU_DML_STATEMENTS | SQL_CU_PROCEDURE_INVOCATION | SQL_CU_TABLE_DEFINITION | SQL_CU_PRIVILEGE_DEFINITION)), + (InfoType::GetDataExtensions, Expected::U32(SQL_GD_ANY_COLUMN | SQL_GD_ANY_ORDER | SQL_GD_BOUND)), + // The cursor-attribute bitmaps, all core-owned: there is no `Backend` + // hook and no arm here, because which cursors exist is a fact about + // core's fetch rather than about Trino. Only the forward-only pair is + // non-empty, because `SQL_SCROLL_OPTIONS` above claims only + // `SQL_SO_FORWARD_ONLY`. + // + // The six zeros mean "supports none of these", which is not a + // placeholder: a dynamic, keyset-driven or static cursor cannot be + // opened here at all, and claiming a bit in one is what makes an + // application call `SQLFetchScroll` with an orientation core rejects. + (InfoType::DynamicCursorAttributes1, Expected::U32(0)), + (InfoType::DynamicCursorAttributes2, Expected::U32(0)), + (InfoType::ForwardOnlyCursorAttributes1, Expected::U32(SQL_CA1_NEXT)), + // What `SQLSetStmtAttr` already does with `SQL_ATTR_CONCURRENCY`: + // `SQL_CONCUR_READ_ONLY` is the one value it accepts unchanged, so + // reporting `0` would contradict the attribute it just accepted. The + // rest of the bitmask describes updatable cursors, row-count exactness + // and positioned-statement simulation, and stays clear. + (InfoType::ForwardOnlyCursorAttributes2, Expected::U32(SQL_CA2_READ_ONLY_CONCURRENCY)), + (InfoType::KeysetCursorAttributes1, Expected::U32(0)), + (InfoType::KeysetCursorAttributes2, Expected::U32(0)), + (InfoType::StaticCursorAttributes1, Expected::U32(0)), + (InfoType::StaticCursorAttributes2, Expected::U32(0)), + ]; + + /// The three identity strings this driver knows and core cannot. + /// + /// Core answers each with the empty string, which the spec defines for + /// exactly one of them and only in one case: `SQL_DATA_SOURCE_NAME` is + /// empty "if the connection string did not contain the DSN keyword". + /// `SQL_SERVER_NAME` and `SQL_USER_NAME` have no such clause, so an empty + /// answer there is a non-answer rather than a defined value. + #[test] + fn the_identity_strings_are_read_from_the_connection() { + let mut conn = crate::backend::disconnected_trino_conn(); + conn.data_source_name = "trino_https".to_string(); + conn.server_name = "coordinator.internal".to_string(); + conn.user_name = "mapped_by_the_idp".to_string(); + + for (info_type, expected) in [ + (InfoType::DataSourceName, "trino_https"), + (InfoType::ServerName, "coordinator.internal"), + (InfoType::UserName, "mapped_by_the_idp"), + ] { + assert_eq!( + trino_get_info(Some(&conn), info_type).expect("get_info"), + InfoValue::String(expected.to_string()), + "{info_type:?} was not read from the connection" + ); + } + } + + /// Asserted on the *connected* path. Several of these answers come from + /// capability declarations that take a `&TrinoConnection`, so + /// `default_get_info` declines them without one and core substitutes its + /// benign pre-connect default; + /// `get_info_every_named_info_type_has_the_declared_shape_pre_connect` + /// covers that side. This table is about the values the driver reports to a + /// connected application. + #[test] + fn get_info_snapshot() { + let conn = crate::backend::disconnected_trino_conn(); + for (info_type, expected) in EXPECTED { + let actual = trino_get_info(Some(&conn), *info_type) + .unwrap_or_else(|e| panic!("get_info returned error for {info_type:?}: {e:?}")); + match (expected, &actual) { + (Expected::Str(s), InfoValue::String(v)) => { + assert_eq!(v.as_str(), *s, "wrong value for {info_type:?}") + } + (Expected::U16(n), InfoValue::U16(v)) => { + assert_eq!(v, n, "wrong value for {info_type:?}") + } + (Expected::U32(n), InfoValue::U32(v)) => { + assert_eq!(v, n, "wrong value for {info_type:?}") + } + _ => panic!("type mismatch for {info_type:?}: got {actual:?}"), + } + } + } + + /// SQL_DRIVER_VER is derived from Cargo.toml, so it cannot be asserted + /// against a hard-coded literal, which would drift from the crate + /// version. Assert the spec's shape instead. + #[test] + fn driver_ver_is_well_formed() { + let InfoValue::String(v) = trino_get_info(None, InfoType::DriverVer).unwrap() else { + panic!("expected String for DriverVer"); + }; + let parts: Vec<&str> = v.split('.').collect(); + assert_eq!( + parts.len(), + 3, + "SQL_DRIVER_VER must be ##.##.####, got {v:?}" + ); + assert!( + parts[0].len() >= 2 && parts[1].len() >= 2 && parts[2].len() >= 4, + "SQL_DRIVER_VER field widths wrong: {v:?}" + ); + assert!( + parts.iter().all(|p| p.chars().all(|c| c.is_ascii_digit())), + "SQL_DRIVER_VER must be all digits and dots: {v:?}" + ); + } + + #[test] + fn every_reportable_type_has_a_type_info_row() { + // A driver reporting a type via SQLColumns/SQLDescribeCol + // that has no corresponding SQLGetTypeInfo row is unusable to + // applications that consult the type list to decide how to bind. + // + // A hand-copied list of declared type strings would fail to catch a + // *new* `TrinoTypeName` variant/mapping arm that yields a + // `SqlDataType` with no row, since nothing forces the hand-copied + // list to grow when the enum does. Iterating + // `TrinoTypeName::ALL_VARIANTS` instead closes that gap: it is + // paired with `TrinoTypeName::assert_all_variants_listed`, an + // exhaustive match with no wildcard arm, so adding a variant to the + // enum without adding it to `ALL_VARIANTS` is a compile error, not a + // silently-passing test. + for ty in TrinoTypeName::ALL_VARIANTS { + TrinoTypeName::assert_all_variants_listed(ty); + let reported = ty.sql_type(); + assert!( + trino_type_info() + .iter() + .any(|row| row.data_type() == reported), + "TrinoTypeName::{ty:?} is reported as {reported:?}, which has no \ + SQLGetTypeInfo row" + ); + } + + // Residual gap: types outside the closed `TrinoTypeName` enum + // (compound/niche types this driver recognises only by string, via + // `trino_type_name_to_sql_type`'s fallback, not by a dedicated + // variant) are not covered by the exhaustiveness check above. All of + // them fall back to SQL_WVARCHAR today (already covered by + // `TrinoTypeName::Varchar` in the loop above), so this loop adds no + // additional row coverage; it exists only to pin the concrete + // fallback inputs the previous hand-written list checked, in case + // that default ever changes. + for decl in ["ipaddress", "array(integer)", "row(x integer, y varchar)"] { + let reported = crate::type_conversion::trino_type_name_to_sql_type(decl); + assert!( + trino_type_info() + .iter() + .any(|row| row.data_type() == reported), + "declared type {decl:?} is reported as {reported:?}, \ + which has no SQLGetTypeInfo row" + ); + } + } + + #[test] + fn trino_bare_type_name_returns_the_expected_name() { + // The invariant: SQL_DESC_TYPE_NAME + // (`execute.rs`) and SQLColumns.TYPE_NAME (`metadata.rs`) both call + // `trino_bare_type_name`, so pin here that its result always matches + // a row's TYPE_NAME for that same DATA_TYPE, including the native + // parameterised forms ("varchar(50)", not "VARCHAR") + // and the WITH TIME ZONE variants, which share a DATA_TYPE with + // their plain counterpart but must resolve to their own distinct row. + // + // This asserts the *exact* expected name, not just that *some* + // row shares the DATA_TYPE; a membership-only check is what lets + // the "first row wins" fallback bug through: `("row(x integer)", + // EXT_W_VARCHAR)` matches *a* row ("INTERVAL DAY TO SECOND") without + // matching the *right* one, yet still passes. + let cases = [ + ("varchar(50)", SqlDataType::EXT_W_VARCHAR, "VARCHAR"), + ("char(10)", SqlDataType::EXT_W_CHAR, "CHAR"), + ("decimal(10,2)", SqlDataType::DECIMAL, "DECIMAL"), + ("bigint", SqlDataType::EXT_BIG_INT, "BIGINT"), + ("time", SqlDataType::TIME, "TIME"), + ( + "time with time zone", + SqlDataType::TIME, + "TIME WITH TIME ZONE", + ), + ("timestamp(3)", SqlDataType::TIMESTAMP, "TIMESTAMP"), + ( + "timestamp(3) with time zone", + SqlDataType::TIMESTAMP, + "TIMESTAMP WITH TIME ZONE", + ), + // Compound / unmodelled types: `TrinoTypeName::parse` returns + // `None` for every one of these, so they exercise the canonical + // `EXT_W_VARCHAR` fallback, which must resolve to "VARCHAR" + // rather than to "INTERVAL DAY TO SECOND" by table-sort accident. + ("row(x integer)", SqlDataType::EXT_W_VARCHAR, "VARCHAR"), + ("array(integer)", SqlDataType::EXT_W_VARCHAR, "VARCHAR"), + ( + "map(varchar, integer)", + SqlDataType::EXT_W_VARCHAR, + "VARCHAR", + ), + ("ipaddress", SqlDataType::EXT_W_VARCHAR, "VARCHAR"), + // INTERVAL types: unlike the compound/unmodelled cases just + // above, these *are* modelled (`TrinoTypeName::IntervalDayToSecond`/ + // `IntervalYearToMonth`), so they must resolve to their own + // name, not fall through to the VARCHAR fallback. + ( + "interval day to second", + SqlDataType::EXT_W_VARCHAR, + "INTERVAL DAY TO SECOND", + ), + ( + "interval year to month", + SqlDataType::EXT_W_VARCHAR, + "INTERVAL YEAR TO MONTH", + ), + ]; + for (native, sql_type, expected_name) in cases { + let name = trino_bare_type_name(native, sql_type); + assert_eq!( + name, expected_name, + "trino_bare_type_name({native:?}, {sql_type:?}) returned an unexpected name" + ); + assert!( + trino_type_info() + .iter() + .any(|row| row.type_name() == name && row.data_type() == sql_type), + "trino_bare_type_name({native:?}, {sql_type:?}) returned {name:?}, which is \ + not a matching SQLGetTypeInfo row" + ); + } + } + + #[test] + fn type_info_rows_have_unique_data_types_per_name() { + let mut seen = std::collections::HashSet::new(); + for row in trino_type_info() { + assert!( + seen.insert(row.type_name()), + "duplicate type_name in trino_type_info(): {}", + row.type_name() + ); + } + } + + #[test] + fn with_time_zone_and_interval_type_names_have_dedicated_rows() { + // WITH TIME ZONE and INTERVAL types share a DATA_TYPE with + // their plain/text counterparts, so without a row of their own an + // application looking up SQLGetTypeInfo by TYPE_NAME (e.g. to build + // a CREATE TABLE statement) could not find "TIME WITH TIME ZONE" or + // "INTERVAL DAY TO SECOND" at all; the data_type-presence check in + // `every_reportable_type_has_a_type_info_row` above does not catch + // this, since these types are already reported under a shared + // DATA_TYPE (TIME/TIMESTAMP/VARCHAR); this test guards the distinct + // TYPE_NAME entries specifically. + for name in [ + "TIME WITH TIME ZONE", + "TIMESTAMP WITH TIME ZONE", + "INTERVAL DAY TO SECOND", + "INTERVAL YEAR TO MONTH", + ] { + assert!( + trino_type_info().iter().any(|row| row.type_name() == name), + "missing SQLGetTypeInfo row for {name:?}" + ); + } + } + + #[test] + fn every_type_info_row_is_reachable_via_trino_bare_type_name() { + // The inverse of `every_reportable_type_has_a_type_info_row` above, + // which guards that every `TrinoTypeName` variant maps to *some* row. + // This one guards that every `trino_type_info` row's TYPE_NAME can be + // *produced* by `trino_bare_type_name` for a real column, and not + // merely advertised in the catalog. A row that fails it is a type an + // application enumerating `SQLGetTypeInfo` can see and no real column + // can ever claim. + // + // Reachability is checked by feeding the row's own lowercased TYPE_NAME + // back in as the native Trino type-name string. That works for every + // row derived from a `TrinoTypeName` variant, because + // `TrinoTypeName::name()` and `TrinoTypeName::parse()` are exact + // case-insensitive inverses, pinned per variant by the `parse_*` tests + // in this module and in `type_conversion.rs`. + // + // SQL_CHAR (1) and SQL_VARCHAR (12) are exceptions: ANSI-alias rows + // present only so the Windows DM and pyodbc find a match when they + // query by those legacy type codes (see their comments in + // `trino_type_info`). Every text-affinity column resolves to "CHAR" or + // "VARCHAR", the WCHAR-based rows, so these two names are unreachable + // by design. + const DM_COMPAT_ONLY: &[&str] = &["SQL_CHAR", "SQL_VARCHAR"]; + + for row in trino_type_info() { + if DM_COMPAT_ONLY.contains(&row.type_name()) { + continue; + } + let native = row.type_name().to_lowercase(); + let produced = trino_bare_type_name(&native, row.data_type()); + assert_eq!( + produced, + row.type_name(), + "trino_type_info() row {:?} (DATA_TYPE={:?}) is not reachable via \ + trino_bare_type_name (got {produced:?} instead): no real column can \ + ever be reported under this TYPE_NAME", + row.type_name(), + row.data_type() + ); + } + } + + // NOTE: do not assert that the WITH TIME ZONE rows' catalog COLUMN_SIZE + // equals `TrinoTypeName::fixed_precision()`, the query path's per-column + // precision. That conflates two quantities that must not be equated: + // SQLGetTypeInfo's COLUMN_SIZE ("the maximum column size the server + // supports") and a column's own reported precision are different + // quantities by spec and are *not* supposed to agree once a data + // source's maximum exceeds what a single driver-internal struct can + // carry. These values are covered instead by: + // - the spec-table test in `stackable_odbc_core::types::column_size` (asserts the + // shared formula against the ODBC appendix directly, not against this + // driver's own other numbers); + // - `catalog_column_size_matches_max_fractional_seconds_precision_formula` + // below (pins the four temporal rows' actual values against the + // formula + the live-verified Trino maximum); + // - `fixed_size_type_info_rows_use_the_backend_independent_formula` (checks + // the fixed-size rows against the shared `catalog_column_size` formula). + + #[test] + fn catalog_column_size_matches_max_fractional_seconds_precision_formula() { + // TIME WITH TIME ZONE's and TIMESTAMP WITH TIME ZONE's COLUMN_SIZE + // and MAXIMUM_SCALE must reflect Trino's live-verified maximum + // fractional-seconds precision of 12, not some smaller value, and the + // four temporal rows must agree: the plain and WITH TIME ZONE variant + // of one base type share a MAXIMUM_SCALE. + let time = find_row(TrinoTypeName::Time.name()); + let time_tz = find_row(TrinoTypeName::TimeWithTimeZone.name()); + let timestamp = find_row(TrinoTypeName::Timestamp.name()); + let timestamp_tz = find_row(TrinoTypeName::TimestampWithTimeZone.name()); + + assert_eq!(time.column_size(), 21); // 9 + 12 + assert_eq!(time_tz.column_size(), 27); // 9 + 12 + 6 ("+HH:MM") + assert_eq!(timestamp.column_size(), 32); // 20 + 12 + assert_eq!(timestamp_tz.column_size(), 39); // 20 + 12 + 1 (space) + 6 + + for row in [time, time_tz, timestamp, timestamp_tz] { + assert_eq!( + row.maximum_scale(), + Some(MAX_FRACTIONAL_SECONDS_PRECISION), + "{:?} MAXIMUM_SCALE must equal Trino's real maximum", + row.type_name() + ); + } + } + + fn find_row(type_name: &str) -> &'static TypeInfoRow { + trino_type_info() + .iter() + .find(|row| row.type_name() == type_name) + .unwrap_or_else(|| panic!("no SQLGetTypeInfo row for {type_name:?}")) + } + + #[test] + fn type_info_rows_sorted_by_data_type_then_type_name() { + // Spec (SQLGetTypeInfo): "ordered by DATA_TYPE and then ... TYPE_NAME, + // both ascending." DATA_TYPE is a signed i16 (negative for ODBC + // extension types), so the comparison must not treat it as unsigned. + // This walks adjacent pairs rather than asserting a fixed sequence, + // so it keeps holding as rows are added or reordered. + for pair in trino_type_info().windows(2) { + let (prev, next) = (&pair[0], &pair[1]); + assert!( + prev.data_type().0 <= next.data_type().0, + "trino_type_info() not sorted by DATA_TYPE: {:?} (DATA_TYPE={}) \ + appears before {:?} (DATA_TYPE={})", + prev.type_name(), + prev.data_type().0, + next.type_name(), + next.data_type().0 + ); + if prev.data_type() == next.data_type() { + assert!( + prev.type_name() <= next.type_name(), + "rows sharing DATA_TYPE={} not sorted by TYPE_NAME: {:?} appears \ + before {:?}", + prev.data_type().0, + prev.type_name(), + next.type_name() + ); + } + } + } + + /// `driver_version!()` must resolve `SQL_DRIVER_VER` from *this* crate's + /// `CARGO_PKG_VERSION` at the macro's call site, not from `stackable-odbc-core`'s + /// version at `stackable-odbc-core`'s compile time. + /// + /// The test can distinguish the two only because this crate's version + /// differs from `stackable-odbc-core`'s (see this crate's `Cargo.toml`): a + /// macro resolving against core would return a string the recomputation + /// here, from this crate's own `CARGO_PKG_VERSION`, does not match. A driver + /// crate whose version equalled core's could not tell them apart, so the + /// guarantee is asserted here, where the two diverge. + #[test] + fn driver_version_tracks_the_crate_version() { + let (major, minor, release) = + stackable_odbc_core::types::parse_dotted_version(env!("CARGO_PKG_VERSION")) + .expect("Cargo always supplies a parseable package version"); + assert_eq!( + stackable_odbc_core::driver_version!(), + stackable_odbc_core::types::format_odbc_version(major, minor, release) + ); + } + + use stackable_odbc_core::types::*; + + /// Trino 467 predates MATCH (482), UNIQUE (482) and OVERLAPS (483), so a + /// server that old must not claim them; it must still claim BETWEEN, + /// COMPARISON and QUANTIFIED_COMPARISON, which Trino has always had. + #[test] + fn sql92_predicates_for_an_old_server() { + assert_eq!( + sql92_predicates(467), + SQL_SP_EXISTS + | SQL_SP_ISNOTNULL + | SQL_SP_ISNULL + | SQL_SP_LIKE + | SQL_SP_IN + | SQL_SP_BETWEEN + | SQL_SP_COMPARISON + | SQL_SP_QUANTIFIED_COMPARISON + ); + } + + #[test] + fn sql92_predicates_gain_match_and_unique_at_482() { + let before = sql92_predicates(481); + let after = sql92_predicates(482); + assert_eq!(before & SQL_SP_MATCH_FULL, 0); + assert_eq!( + after & (SQL_SP_MATCH_FULL | SQL_SP_MATCH_PARTIAL | SQL_SP_UNIQUE), + SQL_SP_MATCH_FULL | SQL_SP_MATCH_PARTIAL | SQL_SP_UNIQUE + ); + assert_eq!( + after & SQL_SP_OVERLAPS, + 0, + "OVERLAPS arrives at 483, not 482" + ); + } + + #[test] + fn sql92_predicates_gain_overlaps_at_483() { + assert_eq!(sql92_predicates(482) & SQL_SP_OVERLAPS, 0); + assert_eq!(sql92_predicates(483) & SQL_SP_OVERLAPS, SQL_SP_OVERLAPS); + } + + /// A failed version probe leaves server_major at 0. Every version-gated + /// flag must be off, so the driver understates rather than overstates. + #[test] + fn an_unknown_server_version_claims_no_gated_features() { + let p = sql92_predicates(0); + assert_eq!( + p & (SQL_SP_MATCH_FULL + | SQL_SP_MATCH_PARTIAL + | SQL_SP_MATCH_UNIQUE_FULL + | SQL_SP_MATCH_UNIQUE_PARTIAL + | SQL_SP_OVERLAPS + | SQL_SP_UNIQUE), + 0 + ); + assert_eq!(sql92_join_operators(0) & SQL_SRJO_CORRESPONDING_CLAUSE, 0); + } + + /// CORRESPONDING arrives at 475; UNION JOIN does not exist in Trino at + /// any version and must never be claimed. + /// + /// NATURAL JOIN is also never claimed, at any version: it is accepted by + /// Trino's grammar but rejected at analysis time (live-verified against + /// Trino 467: `NOT_SUPPORTED: Natural join not supported`); see + /// `sql92_join_operators`'s doc comment. + #[test] + fn sql92_join_operators_track_the_server_version() { + assert_eq!(sql92_join_operators(474) & SQL_SRJO_CORRESPONDING_CLAUSE, 0); + assert_eq!( + sql92_join_operators(475) & SQL_SRJO_CORRESPONDING_CLAUSE, + SQL_SRJO_CORRESPONDING_CLAUSE + ); + for v in [467, 475, 483] { + assert_eq!( + sql92_join_operators(v) & SQL_SRJO_UNION_JOIN, + 0, + "Trino has no UNION JOIN at any version" + ); + assert_eq!( + sql92_join_operators(v) & SQL_SRJO_NATURAL_JOIN, + 0, + "Trino rejects NATURAL JOIN at analysis time (live-verified against 467)" + ); + } + } + + /// Only defined SQL_FN_NUM_* flags may be set (no bit outside the range, + /// such as bit 24), and COT must not be claimed: Trino has no cot(). + #[test] + fn numeric_functions_claim_only_defined_flags_trino_has() { + let all_defined = SQL_FN_NUM_ABS + | SQL_FN_NUM_ACOS + | SQL_FN_NUM_ASIN + | SQL_FN_NUM_ATAN + | SQL_FN_NUM_ATAN2 + | SQL_FN_NUM_CEILING + | SQL_FN_NUM_COS + | SQL_FN_NUM_COT + | SQL_FN_NUM_EXP + | SQL_FN_NUM_FLOOR + | SQL_FN_NUM_LOG + | SQL_FN_NUM_MOD + | SQL_FN_NUM_SIGN + | SQL_FN_NUM_SIN + | SQL_FN_NUM_SQRT + | SQL_FN_NUM_TAN + | SQL_FN_NUM_PI + | SQL_FN_NUM_RAND + | SQL_FN_NUM_DEGREES + | SQL_FN_NUM_LOG10 + | SQL_FN_NUM_POWER + | SQL_FN_NUM_RADIANS + | SQL_FN_NUM_ROUND + | SQL_FN_NUM_TRUNCATE; + assert_eq!( + TRINO_NUMERIC_FUNCTIONS & !all_defined, + 0, + "a bit outside the defined SQL_FN_NUM_* range is set" + ); + assert_eq!( + TRINO_NUMERIC_FUNCTIONS & SQL_FN_NUM_COT, + 0, + "Trino has no cot() function" + ); + assert_eq!( + TRINO_NUMERIC_FUNCTIONS, + all_defined & !SQL_FN_NUM_COT, + "Trino supports every defined numeric function except COT" + ); + } + + /// RIGHT and ASCII must not be claimed (Trino lacks them); LTRIM and + /// RTRIM must be (Trino has them). + #[test] + fn string_functions_match_trinos_documented_set() { + assert_eq!( + TRINO_STRING_FUNCTIONS, + SQL_FN_STR_CONCAT + | SQL_FN_STR_LTRIM + | SQL_FN_STR_LENGTH + | SQL_FN_STR_LCASE + | SQL_FN_STR_LOCATE_2 + | SQL_FN_STR_POSITION + | SQL_FN_STR_REPLACE + | SQL_FN_STR_RTRIM + | SQL_FN_STR_SUBSTRING + | SQL_FN_STR_UCASE + | SQL_FN_STR_CHAR + | SQL_FN_STR_SOUNDEX + ); + for absent in [ + SQL_FN_STR_RIGHT, + SQL_FN_STR_LEFT, + SQL_FN_STR_ASCII, + SQL_FN_STR_REPEAT, + SQL_FN_STR_INSERT, + SQL_FN_STR_DIFFERENCE, + SQL_FN_STR_SPACE, + SQL_FN_STR_LOCATE, + SQL_FN_STR_BIT_LENGTH, + SQL_FN_STR_CHAR_LENGTH, + SQL_FN_STR_CHARACTER_LENGTH, + SQL_FN_STR_OCTET_LENGTH, + ] { + assert_eq!(TRINO_STRING_FUNCTIONS & absent, 0); + } + } + + /// All three of USERNAME, DBNAME and IFNULL, each under its correct flag + /// (IFNULL is a distinct flag from DBNAME's 0x02). The first two are + /// rewrites rather than remaps; see + /// [`every_advertised_rewrite_has_a_translation`]. + #[test] + fn system_functions_include_all_three_equivalents() { + assert_eq!( + TRINO_SYSTEM_FUNCTIONS, + SQL_FN_SYS_USERNAME | SQL_FN_SYS_DBNAME | SQL_FN_SYS_IFNULL + ); + } + + /// The invariant the `SQL_*_FUNCTIONS` bitmaps exist to keep: a bit is + /// advertised only when `{fn NAME(...)}` survives translation into Trino + /// SQL that runs. + /// + /// Every name below needs an argument-syntax change that + /// `EscapeDialect::remap_scalar_fn` cannot make, since it only swaps the + /// identifier in front of the parentheses. Each is advertised only because + /// `rewrite_scalar_fn` handles it, so this asserts both halves together: + /// the bit is set *and* the rewrite exists. Advertising one without the + /// other sends a client that reads the bitmap and emits the escape into + /// `FUNCTION_NOT_FOUND: \'curdate\'` or `COLUMN_NOT_FOUND: \'sql_tsi_day\'`. + /// + /// `DAYOFWEEK` is the one where the rewrite matters most. Trino has + /// `day_of_week()`, so a rename would succeed and return a silently wrong, + /// ISO-numbered day. + #[test] + fn every_advertised_rewrite_has_a_translation() { + for (bitmap, name, flag, args) in [ + ( + TRINO_STRING_FUNCTIONS, + "LOCATE", + SQL_FN_STR_LOCATE_2, + "\'b\', \'ab\'", + ), + (TRINO_SYSTEM_FUNCTIONS, "USERNAME", SQL_FN_SYS_USERNAME, ""), + (TRINO_SYSTEM_FUNCTIONS, "DBNAME", SQL_FN_SYS_DBNAME, ""), + (TRINO_TIMEDATE_FUNCTIONS, "CURDATE", SQL_FN_TD_CURDATE, ""), + (TRINO_TIMEDATE_FUNCTIONS, "CURTIME", SQL_FN_TD_CURTIME, ""), + ( + TRINO_TIMEDATE_FUNCTIONS, + "CURRENT_DATE", + SQL_FN_TD_CURRENT_DATE, + "", + ), + ( + TRINO_TIMEDATE_FUNCTIONS, + "CURRENT_TIME", + SQL_FN_TD_CURRENT_TIME, + "", + ), + ( + TRINO_TIMEDATE_FUNCTIONS, + "CURRENT_TIMESTAMP", + SQL_FN_TD_CURRENT_TIMESTAMP, + "", + ), + ( + TRINO_TIMEDATE_FUNCTIONS, + "TIMESTAMPADD", + SQL_FN_TD_TIMESTAMPADD, + "SQL_TSI_DAY, 1, t", + ), + ( + TRINO_TIMEDATE_FUNCTIONS, + "TIMESTAMPDIFF", + SQL_FN_TD_TIMESTAMPDIFF, + "SQL_TSI_DAY, a, b", + ), + ( + TRINO_TIMEDATE_FUNCTIONS, + "DAYOFWEEK", + SQL_FN_TD_DAYOFWEEK, + "d", + ), + ] { + assert_ne!( + bitmap & flag, + 0, + "{name} has a rewrite but is not advertised" + ); + assert!( + crate::escape_dialect::rewrite_scalar_fn(name, args).is_some(), + "{name} is advertised but `{{fn {name}({args})}}` has no rewrite" + ); + } + } + + /// `POSITION` is advertised with no translation at all, and that is + /// correct: ODBC spells it `POSITION(exp IN exp)`, which is already Trino\'s + /// syntax, so the escape passes through untouched. + /// + /// `SQL_FN_STR_LOCATE`, the *three*-argument form, must stay unadvertised. + /// ODBC\'s third argument is a start offset and the third argument of + /// Trino\'s `strpos()` is an occurrence index, so the rewrite declines it. + /// Advertising the bit would promise a call that then falls through + /// untranslated. + #[test] + fn locate_advertises_only_the_two_argument_form() { + assert_ne!(TRINO_STRING_FUNCTIONS & SQL_FN_STR_POSITION, 0); + assert_eq!( + crate::escape_dialect::rewrite_scalar_fn("POSITION", "\'b\' IN \'ab\'"), + None, + "POSITION needs no rewrite: ODBC already spells it Trino\'s way" + ); + + assert_eq!(TRINO_STRING_FUNCTIONS & SQL_FN_STR_LOCATE, 0); + assert_eq!( + crate::escape_dialect::rewrite_scalar_fn("LOCATE", "\'b\', \'ab\', 2"), + None, + "the three-argument LOCATE has no Trino equivalent" + ); + } + + /// NOW, every DAYOF*, EXTRACT, the field extractors, all three ODBC 3.x + /// CURRENT_* flags and both TIMESTAMP* flags must be claimed. + #[test] + fn timedate_functions_match_trinos_documented_set() { + assert_eq!( + TRINO_TIMEDATE_FUNCTIONS, + SQL_FN_TD_NOW + | SQL_FN_TD_CURDATE + | SQL_FN_TD_CURTIME + | SQL_FN_TD_CURRENT_DATE + | SQL_FN_TD_CURRENT_TIME + | SQL_FN_TD_CURRENT_TIMESTAMP + | SQL_FN_TD_DAYOFMONTH + | SQL_FN_TD_DAYOFWEEK + | SQL_FN_TD_DAYOFYEAR + | SQL_FN_TD_MONTH + | SQL_FN_TD_QUARTER + | SQL_FN_TD_WEEK + | SQL_FN_TD_YEAR + | SQL_FN_TD_HOUR + | SQL_FN_TD_MINUTE + | SQL_FN_TD_SECOND + | SQL_FN_TD_TIMESTAMPADD + | SQL_FN_TD_TIMESTAMPDIFF + | SQL_FN_TD_EXTRACT + ); + assert_eq!(TRINO_TIMEDATE_FUNCTIONS & SQL_FN_TD_DAYNAME, 0); + assert_eq!(TRINO_TIMEDATE_FUNCTIONS & SQL_FN_TD_MONTHNAME, 0); + } + + /// Both assertions are needed, and the pairing is the point. The named-OR + /// checks *which* flags were selected; the raw hex checks that the flag + /// constants carry the values `sqlext.h` gives them. Either alone would + /// pass a transcription error in the other. + /// + /// The hex literals are the one exception to AGENTS.md's rule that a + /// spec-defined value is written as a named constant and never as an + /// integer. Here the literal *is* the independent check: naming it would + /// restate the constant the other assertion already uses. + #[test] + fn aggregate_and_value_expression_bitmaps_are_unchanged() { + assert_eq!( + TRINO_AGGREGATE_FUNCTIONS, + SQL_AF_AVG + | SQL_AF_COUNT + | SQL_AF_MAX + | SQL_AF_MIN + | SQL_AF_SUM + | SQL_AF_DISTINCT + | SQL_AF_ALL + ); + assert_eq!(TRINO_AGGREGATE_FUNCTIONS, 0x7F); + assert_eq!( + TRINO_SQL92_VALUE_EXPRESSIONS, + SQL_SVE_CASE | SQL_SVE_CAST | SQL_SVE_COALESCE | SQL_SVE_NULLIF + ); + assert_eq!(TRINO_SQL92_VALUE_EXPRESSIONS, 0x0F); + } + + /// `SQL_KEYWORDS` is Trino's reserved words *minus* the ones ODBC already + /// reserves, so this asserts the value an application receives rather than + /// the raw list the hook returns: the subtraction is core's, and getting + /// it wrong in either direction is what the info type exists to prevent. + /// + /// The expected string is written out rather than recomputed from + /// `TRINO_RESERVED_KEYWORDS`, which would just restate the implementation. + #[test] + fn sql_keywords_excludes_the_words_odbc_already_reserves() { + use stackable_odbc_core::backend::Backend; + use stackable_odbc_core::types::ODBC_RESERVED_KEYWORDS; + + let conn = crate::backend::disconnected_trino_conn(); + let keywords = TrinoBackend::keywords(&conn); + let reported: Vec<&str> = keywords + .iter() + .map(Cow::as_ref) + .filter(|k| { + !ODBC_RESERVED_KEYWORDS + .iter() + .any(|r| r.eq_ignore_ascii_case(k)) + }) + .collect(); + + assert_eq!( + reported.join(","), + "AUTO,CUBE,CURRENT_CATALOG,CURRENT_PATH,CURRENT_ROLE,CURRENT_SCHEMA,\ + GROUPING,JSON_ARRAY,JSON_EXISTS,JSON_OBJECT,JSON_QUERY,JSON_TABLE,\ + JSON_VALUE,LISTAGG,LOCALTIME,LOCALTIMESTAMP,NORMALIZE,RECURSIVE,\ + ROLLUP,SKIP,UESCAPE,UNNEST" + ); + + // The words Trino shares with ODBC must not be reported: an + // application already knows SELECT is reserved. + for shared in ["SELECT", "FROM", "WHERE", "JOIN", "CREATE"] { + assert!( + TRINO_RESERVED_KEYWORDS.contains(&shared), + "{shared} should be in the raw list" + ); + assert!( + !reported.contains(&shared), + "{shared} is an ODBC reserved word and must be subtracted out" + ); + } + } + + /// A duplicate would be reported twice, and a lowercase entry would slip + /// past a case-sensitive reader of the value even though core's own + /// subtraction is case-insensitive. + #[test] + fn trino_reserved_keywords_are_unique_sorted_and_upper_case() { + let mut sorted = TRINO_RESERVED_KEYWORDS.to_vec(); + sorted.sort_unstable(); + assert_eq!( + TRINO_RESERVED_KEYWORDS, + &sorted[..], + "the list should stay sorted so it can be diffed against the docs" + ); + + let mut deduped = sorted.clone(); + deduped.dedup(); + assert_eq!( + deduped.len(), + TRINO_RESERVED_KEYWORDS.len(), + "duplicate entry" + ); + + for k in TRINO_RESERVED_KEYWORDS { + assert_eq!(*k, k.to_ascii_uppercase(), "{k} should be upper case"); + } + } + + #[test] + fn get_functions_advertises_data_at_execution() { + let f = get_functions(); + assert!(f.contains(&FunctionId::ParamData), "SQLParamData missing"); + assert!(f.contains(&FunctionId::PutData), "SQLPutData missing"); + } + + /// `SQLGetFunctions` is what the Windows Driver Manager builds its dispatch + /// table from, so claiming a function core does not export hands it a null + /// pointer to call. + #[test] + fn no_advertised_function_is_one_core_does_not_export() { + use stackable_odbc_core::function_id::CORE_EXPORTED_FUNCTIONS; + + for id in get_functions() { + assert!( + CORE_EXPORTED_FUNCTIONS.contains(id), + "{id:?} is advertised but core generates no entry point for it" + ); + } + } + + /// The two lists together are this driver's answer to "do you support this + /// function?" for every entry point core exports. A function in neither has + /// no answer, and that is exactly what a newly exported core function looks + /// like, so this failing is the point at which someone decides whether + /// the driver implements it, rather than it going unadvertised unnoticed. + /// + /// Mirrors core's own `every_function_id_is_declared_exported_or_not`, one + /// level up. + #[test] + fn every_core_exported_function_is_advertised_or_withheld() { + use stackable_odbc_core::function_id::CORE_EXPORTED_FUNCTIONS; + + for id in CORE_EXPORTED_FUNCTIONS { + let advertised = TRINO_ADVERTISED_FUNCTIONS.contains(id); + let withheld = TRINO_WITHHELD_FUNCTIONS.iter().any(|(w, _)| w == id); + assert!( + advertised ^ withheld, + "{id:?} must appear in exactly one of TRINO_ADVERTISED_FUNCTIONS \ + (advertised={advertised}) and TRINO_WITHHELD_FUNCTIONS \ + (withheld={withheld})" + ); + } + + // Catches an entry in either list that core does not export at all, + // which the loop above cannot see because it iterates core's list. + assert_eq!( + TRINO_ADVERTISED_FUNCTIONS.len() + TRINO_WITHHELD_FUNCTIONS.len(), + CORE_EXPORTED_FUNCTIONS.len(), + "the two lists must partition CORE_EXPORTED_FUNCTIONS exactly" + ); + } + + /// Every withheld entry carries a reason, because the reason is the whole + /// value of recording the decision. + #[test] + fn every_withheld_function_says_why() { + for (id, reason) in TRINO_WITHHELD_FUNCTIONS { + assert!( + !reason.trim().is_empty(), + "{id:?} is withheld with no reason" + ); + } + } + + #[test] + fn get_functions_has_no_duplicates() { + let f = get_functions(); + let ids: Vec<u16> = f.iter().map(|id| *id as u16).collect(); + let mut sorted = ids.clone(); + sorted.sort_unstable(); + sorted.dedup(); + assert_eq!( + sorted.len(), + ids.len(), + "duplicate FunctionId in get_functions" + ); + } +} diff --git a/src/backend/metadata.rs b/src/backend/metadata.rs new file mode 100644 index 0000000..47b13d9 --- /dev/null +++ b/src/backend/metadata.rs @@ -0,0 +1,1044 @@ +//! Catalog metadata for the Trino backend: the ten catalog functions +//! (`tables`, `columns`, `primary_keys`, `foreign_keys`, `statistics`, +//! `special_columns`, `table_privileges`, `column_privileges`, `procedures`, +//! `procedure_columns`) plus the `catalogs` / `schemas` / `table_types` +//! enumerations, built by querying Trino's `information_schema` and +//! `system.jdbc`, with the private WHERE-clause and ODBC-wildcard helpers +//! those queries share. +//! +//! Every function here returns rows, never a statement: core converts them to +//! `ColumnValue`s in spec column order, sorts them into the order each +//! function's spec page defines, and serves the result set. That is why none +//! of the queries below carries an `ORDER BY`. +//! +//! Six of the ten return no rows because Trino has nothing to answer them +//! with. Each says which on its own doc comment; `AGENTS.md` has the table. + +use std::borrow::Cow; + +use stackable_odbc_core::types::{ + ColumnPrivilegeRow, ColumnPrivilegesQuery, ColumnRow, ColumnsQuery, ForeignKeyRow, + ForeignKeysQuery, Nullable, PrimaryKeyRow, PrimaryKeysQuery, ProcedureColumnRow, + ProcedureColumnsQuery, ProcedureRow, ProceduresQuery, SQL_CODE_DATE, SQL_CODE_TIME, + SQL_CODE_TIMESTAMP, SpecialColumnRow, SpecialColumnsQuery, SqlDataType, StatisticsQuery, + StatisticsRow, TablePrivilegeRow, TablePrivilegesQuery, TableRow, TablesQuery, +}; + +use super::{TrinoConnection, TrinoError, info::trino_bare_type_name, query_all_rows}; +use crate::type_conversion::{trino_type_name_to_sql_type, type_name_precision, type_name_scale}; + +/// Check whether a string contains unescaped ODBC wildcard characters. +/// +/// The ODBC spec (§8.3 "Pattern Value Arguments") defines `%` (match any +/// sequence) and `_` (match one character) as wildcards in catalog function +/// arguments such as SQLTablesW and SQLColumnsW. A backslash escapes +/// wildcards: `\_` means a literal underscore, `\%` means a literal percent. +/// +/// This function returns `true` if the string contains at least one +/// **unescaped** wildcard, i.e. a `%` or `_` that is not preceded by `\`. +fn has_odbc_wildcards(s: &str) -> bool { + let bytes = s.as_bytes(); + for i in 0..bytes.len() { + if (bytes[i] == b'%' || bytes[i] == b'_') && (i == 0 || bytes[i - 1] != b'\\') { + return true; + } + } + false +} + +/// Build one WHERE condition for an `information_schema` query, from one +/// catalog-function argument. +/// +/// The condition's shape follows the argument's: +/// +/// - **With unescaped wildcards**: a `LIKE` clause carrying an explicit +/// `ESCAPE '\'`, so Trino honours ODBC's backslash convention. +/// - **Without**: an exact `=` match, with the escape backslashes stripped so +/// `call\_center` matches the table named `call_center`. +/// +/// The Power Query connector is not involved. It controls how Power Query +/// generates user-facing SQL, while the ODBC catalog functions are the +/// driver's alone; its `SQLColumns` callback sees only the result table this +/// query produced. +fn push_filter(conditions: &mut Vec<String>, column: &str, value: &str) { + let escaped = value.replace('\'', "''"); + if has_odbc_wildcards(&escaped) { + conditions.push(format!("{column} LIKE '{escaped}' ESCAPE '\\'")); + } else { + let literal = escaped.replace("\\_", "_").replace("\\%", "%"); + conditions.push(format!("{column} = '{literal}'")); + } +} + +/// Trino's error code for a catalog that does not exist. +/// +/// The catalog functions turn it into an empty result set: the spec has them +/// filter, and a filter naming something absent selects nothing. Failing +/// instead would make an application browsing a stale catalog list error +/// rather than find nothing there. +const TRINO_CATALOG_NOT_FOUND: i32 = 44; + +/// The `information_schema.<table>` reference a catalog-scoped query runs +/// against. +/// +/// Trino resolves a bare `information_schema` through the **session** catalog, +/// and each catalog's copy describes only itself. Filtering on `table_catalog` +/// alone therefore cannot reach another catalog: an application enumerating +/// catalogs would find every one but the connected one empty, whatever the +/// WHERE clause said. +/// +/// The catalog is an *identifier* here, not a literal, so it is delimited and +/// its embedded quotes doubled. A pattern cannot appear in a FROM clause, so +/// a wildcarded catalog (and the absent and `%` cases) keeps the session +/// catalog. +fn information_schema_ref(catalog: Option<&str>, table: &str) -> String { + match catalog.filter(|c| !c.is_empty() && *c != "%" && !has_odbc_wildcards(c)) { + Some(cat) => { + // Unescape the ODBC escapes first: `my\_cat` names the catalog + // `my_cat`, exactly as `push_filter` does for a literal. + let name = cat.replace("\\_", "_").replace("\\%", "%"); + format!( + "\"{}\".information_schema.{table}", + name.replace('"', "\"\"") + ) + } + None => format!("information_schema.{table}"), + } +} + +/// Run a catalog-scoped `information_schema` query, treating a missing catalog +/// as an empty result set rather than an error. +/// +/// Except inside a transaction. Trino carries the transaction id in a session +/// header, so this query joins whatever the application has open, and a +/// statement error aborts the whole transaction. Turning that into an empty +/// result set would report success from `SQLTables` while the application's +/// transaction had just been killed by a round trip it did not make: every +/// later statement would fail, and `SQLEndTran(SQL_COMMIT)` would refuse, with +/// nothing having been reported at the point it happened. +/// +/// Outside a transaction the substitution costs nothing and is what the spec +/// asks for: these functions filter, and a filter naming something absent +/// selects nothing. +fn query_information_schema( + conn: &TrinoConnection, + sql: String, +) -> Result<Vec<trino_rust_client::Row>, TrinoError> { + let in_transaction = conn.in_transaction(); + match query_all_rows(conn, sql) { + Err(e) if trino_error_code(&e) == Some(TRINO_CATALOG_NOT_FOUND) && !in_transaction => { + tracing::debug!("catalog not found; reporting an empty result set"); + Ok(Vec::new()) + } + Err(e) if trino_error_code(&e) == Some(TRINO_CATALOG_NOT_FOUND) => { + tracing::warn!( + error = %e, + "the catalog does not exist, and the lookup ran inside an open \ + transaction, which Trino therefore aborted; reporting it rather \ + than substituting an empty result set" + ); + Err(e) + } + other => other, + } +} + +/// Trino's own error code for a failure, when it carried one. +fn trino_error_code(error: &TrinoError) -> Option<i32> { + match error { + TrinoError::Query { native_error, .. } => Some(*native_error), + _ => None, + } +} + +/// The WHERE clause for an `information_schema.tables` query. +/// +/// An absent, empty or `%` argument means "match everything", so it +/// contributes no condition rather than a `LIKE '%'`. +fn build_tables_where_clause( + catalog: Option<&str>, + schema: Option<&str>, + table: Option<&str>, +) -> String { + let mut conditions: Vec<String> = Vec::new(); + if let Some(cat) = catalog.filter(|s| !s.is_empty() && *s != "%") { + push_filter(&mut conditions, "table_catalog", cat); + } + if let Some(sch) = schema.filter(|s| !s.is_empty() && *s != "%") { + push_filter(&mut conditions, "table_schema", sch); + } + if let Some(tbl) = table.filter(|s| !s.is_empty() && *s != "%") { + push_filter(&mut conditions, "table_name", tbl); + } + if conditions.is_empty() { + String::new() + } else { + format!(" WHERE {}", conditions.join(" AND ")) + } +} + +/// The WHERE clause for an `information_schema.columns` query: the same three +/// filters as [`build_tables_where_clause`], plus the column name. +fn build_columns_where_clause( + catalog: Option<&str>, + schema: Option<&str>, + table: Option<&str>, + column: Option<&str>, +) -> String { + let mut conditions: Vec<String> = Vec::new(); + if let Some(cat) = catalog.filter(|s| !s.is_empty() && *s != "%") { + push_filter(&mut conditions, "table_catalog", cat); + } + if let Some(sch) = schema.filter(|s| !s.is_empty() && *s != "%") { + push_filter(&mut conditions, "table_schema", sch); + } + if let Some(tbl) = table.filter(|s| !s.is_empty() && *s != "%") { + push_filter(&mut conditions, "table_name", tbl); + } + if let Some(col) = column.filter(|s| !s.is_empty() && *s != "%") { + push_filter(&mut conditions, "column_name", col); + } + if conditions.is_empty() { + String::new() + } else { + format!(" WHERE {}", conditions.join(" AND ")) + } +} + +/// Parse precision and scale from a Trino data_type string. +/// E.g. "varchar(100)" → (Some(100), None), "decimal(10,2)" → (Some(10), Some(2)). +fn parse_trino_precision_scale(data_type: &str) -> (Option<i32>, Option<i32>) { + (type_name_precision(data_type), type_name_scale(data_type)) +} + +/// Bytes per character used for `CHAR_OCTET_LENGTH`. UTF-16 encodes a character +/// in at most 4 bytes (a surrogate pair). +const BYTES_PER_CHAR: i32 = 4; + +/// `NUM_PREC_RADIX` for a numeric column: Trino reports precision for every +/// numeric type in decimal digits, including `REAL` and `DOUBLE`. +const NUM_PREC_RADIX_DECIMAL: i16 = 10; + +/// A column's `SQL_DATA_TYPE` and `SQL_DATETIME_SUB` from its concise type. +/// +/// Spec (`SQLColumns`): `SQL_DATA_TYPE` is the *verbose* type, which differs +/// from `DATA_TYPE` only for datetimes and intervals: "For datetime and +/// interval data types, this column returns `SQL_DATETIME` or `SQL_INTERVAL`, +/// and the `SQL_DATETIME_SUB` field returns the subcode. For other data types, +/// this column returns a NULL." +/// +/// `SQLGetTypeInfo` answers the same way for the same types, through +/// `TypeInfoRow::with_verbose_type` in `info`. Reporting the concise type here +/// would make the driver contradict itself about a `DATE` column: `91` from +/// `SQLColumns` against `9` plus subcode `1` from `SQLGetTypeInfo`. +/// +/// Trino's two interval types are absent. They map to `SQL_WVARCHAR`, not to +/// an ODBC interval type (see `type_conversion::trino_ty_to_sql_type`), so +/// `SQL_INTERVAL` would be a claim this driver's own type mapping does not +/// make. +fn verbose_type(concise: SqlDataType) -> (i16, Option<i16>) { + match concise { + SqlDataType::DATE => (SqlDataType::DATETIME.0, Some(SQL_CODE_DATE)), + SqlDataType::TIME => (SqlDataType::DATETIME.0, Some(SQL_CODE_TIME)), + SqlDataType::TIMESTAMP => (SqlDataType::DATETIME.0, Some(SQL_CODE_TIMESTAMP)), + other => (other.0, None), + } +} + +/// `CHAR_OCTET_LENGTH` for a column: the maximum length in bytes of a +/// character column. All other data types, including binary, return NULL. +/// +/// Character columns store their declared length in UTF-16 characters, so it +/// is multiplied by `BYTES_PER_CHAR`; returns NULL when that product does not +/// fit an `i32` (Trino reports unbounded varchar as `varchar(2147483647)` in +/// some connectors, and `2147483647 * 4` overflows). +/// +/// `VARBINARY` (→ `SQL_LONGVARBINARY`) gets no byte-length branch, because +/// NULL is the only answer Trino can support. Its type system carries no +/// length parameter for `varbinary` at all: verified against a live +/// coordinator, `information_schema.columns.data_type` reports the bare string +/// `"varbinary"` for every binary column, with nothing for +/// `type_name_precision` to parse (`TrinoTypeName::Varbinary` has +/// `has_precision_param() == false` and `fixed_precision() == None`). A branch +/// keyed on `ty_precision` could never execute, since that argument is +/// unconditionally `None` for `EXT_LONG_VAR_BINARY`. +fn char_octet_length(sql_type: SqlDataType, ty_precision: Option<i32>) -> Option<i32> { + let is_character = matches!( + sql_type, + SqlDataType::EXT_W_VARCHAR + | SqlDataType::EXT_W_CHAR + | SqlDataType::VARCHAR + | SqlDataType::CHAR + ); + + if is_character { + return ty_precision.and_then(|n| n.checked_mul(BYTES_PER_CHAR)); + } + None +} + +/// The catalog names, for `SQLTables`' `SQL_ALL_CATALOGS` enumeration. +/// +/// Uses `system.jdbc.catalogs`, which works without a default session catalog +/// where `information_schema` does not. This is exactly the call an +/// application makes before it has picked one. +pub(super) fn catalogs(conn: &TrinoConnection) -> Result<Vec<String>, TrinoError> { + tracing::debug!("TrinoBackend::catalogs"); + + let sql = "SELECT table_cat FROM system.jdbc.catalogs"; + let rows = query_all_rows(conn, sql.to_string())?; + Ok(rows + .into_iter() + .filter_map(|row| Some(row.into_json().into_iter().next()?.as_str()?.to_string())) + .collect()) +} + +/// The schema names, for `SQLTables`' `SQL_ALL_SCHEMAS` enumeration. +/// +/// Uses `system.jdbc.schemas` for the same reason as [`catalogs`]. Core NULLs +/// out every column but `TABLE_SCHEM`, which is what the spec requires of this +/// enumeration. +pub(super) fn schemas(conn: &TrinoConnection) -> Result<Vec<String>, TrinoError> { + tracing::debug!("TrinoBackend::schemas"); + + let sql = "SELECT table_schem FROM system.jdbc.schemas"; + let rows = query_all_rows(conn, sql.to_string())?; + Ok(rows + .into_iter() + .filter_map(|row| Some(row.into_json().into_iter().next()?.as_str()?.to_string())) + .collect()) +} + +/// The table types Trino has, for `SQLTables`' `SQL_ALL_TABLE_TYPES` +/// enumeration. +/// +/// These are the two `information_schema.tables.table_type` values [`tables`] +/// maps and reports; anything else is dropped there, so listing more here +/// would advertise a type no row can ever carry. +pub(super) fn table_types() -> Vec<Cow<'static, str>> { + vec![ + Cow::Borrowed(TABLE_TYPE_TABLE), + Cow::Borrowed(TABLE_TYPE_VIEW), + ] +} + +/// ODBC's `TABLE_TYPE` for Trino's `BASE TABLE`. +const TABLE_TYPE_TABLE: &str = "TABLE"; + +/// ODBC's `TABLE_TYPE` for Trino's `VIEW`. +const TABLE_TYPE_VIEW: &str = "VIEW"; + +/// Return the tables and views matching the given filters, from +/// `information_schema.tables`. +/// +/// Trino's `BASE TABLE` becomes ODBC's `TABLE`; a `table_type` that is neither +/// that nor `VIEW` drops the row, because [`table_types`] advertises only +/// those two and a row must carry a type an application was told to expect. +pub(super) fn tables( + conn: &TrinoConnection, + query: &TablesQuery<'_>, +) -> Result<Vec<TableRow>, TrinoError> { + let (catalog, schema, table) = (query.catalog(), query.schema(), query.table()); + let table_types = query.table_types(); + tracing::debug!(catalog, schema, table, ?table_types, "TrinoBackend::tables"); + + // No ORDER BY: core sorts the result set into the spec's order. The three + // `SQL_ALL_*` enumerations never reach here either: core serves them from + // `catalogs`, `schemas` and `table_types` above. + let sql = format!( + "SELECT table_catalog, table_schema, table_name, table_type \ + FROM {}{}", + information_schema_ref(catalog, "tables"), + build_tables_where_clause(catalog, schema, table) + ); + + let rows = query_information_schema(conn, sql)?; + + // Core split and unquoted the value list; an empty slice is "no filter". + // The spec has applications supply table types in upper case, so folding + // here is tolerance for those that do not, not a requirement. + let allowed_types: Vec<String> = table_types.iter().map(|t| t.to_uppercase()).collect(); + + Ok(rows + .into_iter() + .filter_map(|row| { + let mut vals = row.into_json().into_iter(); + let cat_val = vals.next()?; + let sch_val = vals.next()?; + let name = vals.next()?.as_str()?.to_string(); + let raw_type = vals.next()?.as_str()?.to_uppercase(); + // Trino reports "BASE TABLE"; ODBC expects "TABLE". + let odbc_type = match raw_type.as_str() { + "BASE TABLE" => TABLE_TYPE_TABLE, + "VIEW" => TABLE_TYPE_VIEW, + other => { + tracing::warn!(other, "unknown table_type from Trino, skipping row"); + return None; + } + }; + + if !allowed_types.is_empty() && !allowed_types.iter().any(|a| a == odbc_type) { + return None; + } + + // `remarks` is left at its default `None`: Trino's + // `information_schema.tables` has no comment column. Every setter + // takes `impl Into<T>`, so an `Option<String>` column accepts the + // `Option` directly as well as a bare `String`. + Some( + TableRow::default() + .catalog(cat_val.as_str().map(str::to_string)) + .schema(sch_val.as_str().map(str::to_string)) + .name(name) + .table_type(odbc_type.to_string()), + ) + }) + .collect()) +} + +/// Return the columns matching the given filters, from +/// `information_schema.columns`. +/// +/// Trino's `information_schema.columns` has no +/// `character_maximum_length`, `numeric_precision` or `numeric_scale`, so +/// every size ODBC asks for is parsed out of the `data_type` string instead +/// (`varchar(100)` → precision 100, `decimal(10,2)` → precision 10, scale 2). +/// A row whose `table_name`, `column_name` or `ordinal_position` is not of the +/// expected type is dropped with a `warn!` rather than reported with a +/// substitute. +pub(super) fn columns( + conn: &TrinoConnection, + query: &ColumnsQuery<'_>, +) -> Result<Vec<ColumnRow>, TrinoError> { + let (catalog, schema, table, column) = ( + query.catalog(), + query.schema(), + query.table(), + query.column(), + ); + tracing::debug!(catalog, schema, table, column, "TrinoBackend::columns"); + + // 0-based column positions in the SELECT list below. + // Must stay in sync with the query. + #[derive(Clone, Copy)] + enum QueryCol { + TableCatalog = 0, + TableSchema = 1, + TableName = 2, + ColumnName = 3, + OrdinalPosition = 4, + ColumnDefault = 5, + IsNullable = 6, + DataType = 7, + } + impl QueryCol { + fn idx(self) -> usize { + self as usize + } + } + + // No ORDER BY: core sorts the result set into the spec's order + // (TABLE_CAT, TABLE_SCHEM, TABLE_NAME, ORDINAL_POSITION). + let sql = format!( + "SELECT table_catalog, table_schema, table_name, column_name, \ + ordinal_position, column_default, is_nullable, data_type \ + FROM {}{}", + information_schema_ref(catalog, "columns"), + build_columns_where_clause(catalog, schema, table, column) + ); + + let rows = query_information_schema(conn, sql)?; + + Ok(rows + .into_iter() + .filter_map(|row| { + let vals: Vec<serde_json::Value> = row.into_json().into_iter().collect(); + let get_str = |qc: QueryCol| -> Option<String> { + vals.get(qc.idx())?.as_str().map(str::to_string) + }; + let get_i32 = |qc: QueryCol| -> Option<i32> { + vals.get(qc.idx())? + .as_i64() + .and_then(|v| i32::try_from(v).ok()) + }; + + let table_cat = get_str(QueryCol::TableCatalog); + let table_sch = get_str(QueryCol::TableSchema); + let table_name = match get_str(QueryCol::TableName) { + Some(s) => s, + None => { + tracing::warn!("table_name was not a string, skipping row"); + return None; + } + }; + let col_name = match get_str(QueryCol::ColumnName) { + Some(s) => s, + None => { + tracing::warn!("column_name was not a string, skipping row"); + return None; + } + }; + let ordinal = match get_i32(QueryCol::OrdinalPosition) { + Some(n) => n, + None => { + tracing::warn!("ordinal_position was not an integer, skipping row"); + return None; + } + }; + let col_def = get_str(QueryCol::ColumnDefault); + let is_null_str = get_str(QueryCol::IsNullable).unwrap_or_default(); + let data_type_raw = get_str(QueryCol::DataType).unwrap_or_default(); + + let sql_type = trino_type_name_to_sql_type(&data_type_raw); + + // TYPE_NAME in SQLColumnsW must match SQLGetTypeInfo's TYPE_NAME + // (uppercase base name without parameters, see + // `trino_bare_type_name`'s doc comment for the spec citation). + // Also used, identically, for SQL_DESC_TYPE_NAME in execute.rs, + // so the two never disagree. + let data_type = trino_bare_type_name(&data_type_raw, sql_type); + let nullable = if is_null_str.eq_ignore_ascii_case("YES") { + Nullable::SqlNullable + } else if is_null_str.eq_ignore_ascii_case("NO") { + Nullable::SqlNoNulls + } else { + Nullable::SqlNullableUnknown + }; + + let is_numeric = matches!( + sql_type, + SqlDataType::EXT_TINY_INT + | SqlDataType::SMALLINT + | SqlDataType::INTEGER + | SqlDataType::EXT_BIG_INT + | SqlDataType::REAL + | SqlDataType::DOUBLE + | SqlDataType::DECIMAL + ); + let (ty_precision, ty_scale) = parse_trino_precision_scale(&data_type_raw); + // COLUMN_SIZE is reported for every type `type_name_precision` + // can resolve a value for: parametric types (VARCHAR/CHAR/ + // DECIMAL, read from the type string) and fixed-precision types + // (the integer/float/boolean/date/time family, via + // `TrinoTypeName::fixed_precision`). Gate on `ty_precision` + // itself rather than re-deriving a list of "types with a size" + // here, so a new type is taught to + // `fixed_precision`/`has_precision_param` once and there is no + // second enumeration to fall out of step with it. + let col_size = ty_precision; + let num_prec_radix = if is_numeric { + Some(NUM_PREC_RADIX_DECIMAL) + } else { + None + }; + // DECIMAL_DIGITS. Spec (SQLColumns): "The total number of + // significant digits to the right of the decimal point. For + // SQL_TYPE_TIME and SQL_TYPE_TIMESTAMP, this column contains the + // number of digits in the fractional seconds component. ... NULL + // is returned for data types where DECIMAL_DIGITS is not + // applicable." So it is meaningful for DECIMAL/NUMERIC *and* for + // TIME/TIMESTAMP, while integer and floating-point types report + // NULL. `ty_scale` carries the right quantity for both cases; see + // `type_name_scale`. + let decimal_digits = if matches!( + sql_type, + SqlDataType::DECIMAL | SqlDataType::TIME | SqlDataType::TIMESTAMP + ) { + ty_scale.and_then(|s| i16::try_from(s).ok()) + } else { + None + }; + let char_octet = char_octet_length(sql_type, ty_precision); + let (verbose, datetime_sub) = verbose_type(sql_type); + + // `buffer_length` and `remarks` keep their default `None`: the + // spec lets a driver omit BUFFER_LENGTH, and Trino's + // `information_schema.columns` has no comment column. + Some( + ColumnRow::default() + .catalog(table_cat) + .schema(table_sch) + .table_name(table_name) + .column_name(col_name) + .data_type(sql_type.0) + .type_name(data_type) + .column_size(col_size) + .decimal_digits(decimal_digits) + .num_prec_radix(num_prec_radix) + .nullable(i16::from(nullable)) + .column_def(col_def) + .sql_data_type(verbose) + .sql_datetime_sub(datetime_sub) + .char_octet_length(char_octet) + .ordinal_position(ordinal) + .is_nullable(nullable.as_is_nullable_str().to_string()), + ) + }) + .collect()) +} + +/// 0-based positions in `table_privileges`' SELECT list. Must stay in sync +/// with the query in [`table_privileges`]. +#[derive(Clone, Copy)] +enum PrivilegeCol { + TableCatalog = 0, + TableSchema = 1, + TableName = 2, + Grantor = 3, + Grantee = 4, + PrivilegeType = 5, + IsGrantable = 6, +} + +/// Convert one `information_schema.table_privileges` row to a +/// [`TablePrivilegeRow`], or drop it if a column the spec marks not-NULL is +/// missing or is not a string. +/// +/// Split out from [`table_privileges`] because it is the only part of that +/// function a live coordinator in this project's test stack cannot exercise: +/// neither test catalog implements permission management, so the query is +/// always empty there (`GRANT` on either answers `NOT_SUPPORTED`). The unit +/// tests feed it the rows a coordinator with `sql-standard` security would +/// return. +fn table_privilege_row(vals: &[serde_json::Value]) -> Option<TablePrivilegeRow> { + let get = |col: PrivilegeCol| -> Option<String> { + vals.get(col as usize)?.as_str().map(str::to_string) + }; + + // TABLE_NAME, GRANTEE and PRIVILEGE are the three columns the spec marks + // "not NULL"; a row missing one is not a row an application can use. + let table_name = get(PrivilegeCol::TableName)?; + let grantee = get(PrivilegeCol::Grantee)?; + let privilege = get(PrivilegeCol::PrivilegeType)?; + + Some( + TablePrivilegeRow::default() + .catalog(get(PrivilegeCol::TableCatalog)) + .schema(get(PrivilegeCol::TableSchema)) + .table_name(table_name) + // Nullable in both directions: Trino leaves `grantor` NULL for a + // privilege nobody explicitly granted, and ODBC's GRANTOR is + // nullable. + .grantor(get(PrivilegeCol::Grantor)) + .grantee(grantee) + .privilege(privilege) + // Trino spells this 'YES'/'NO', which is exactly ODBC's vocabulary + // for IS_GRANTABLE, so it passes through unmapped. + .is_grantable(get(PrivilegeCol::IsGrantable)), + ) +} + +/// Return the table-level privileges on the matching tables. +/// +/// Trino models these: every catalog has an `information_schema.table_privileges` +/// whose columns line up with ODBC's, and its own JDBC driver reads the same +/// table for `DatabaseMetaData.getTablePrivileges()`. +/// +/// It is populated from the connector's permission management, so it is +/// non-empty only for connectors that implement it: Hive and Iceberg under +/// `sql-standard` security, say. A connector without it answers with zero rows +/// rather than an error, which is why this queries unconditionally instead of +/// gating on the catalog. The test stack has one catalog in each group: `hive` +/// runs under `sql-standard` security and returns rows, while `tpcds` and +/// `postgresql` return none. [`table_privilege_row`] carries the unit tests +/// that cover the conversion itself. +/// +/// Note that Trino's `information_schema` is synthesised by Trino, not passed +/// through to the underlying database: a `GRANT` issued directly in PostgreSQL +/// is visible in PostgreSQL's own `information_schema.table_privileges` and +/// **not** in the `postgresql` catalog's, because the base JDBC connector +/// implements no permission management. Verified against the test stack. +pub(super) fn table_privileges( + conn: &TrinoConnection, + query: &TablePrivilegesQuery<'_>, +) -> Result<Vec<TablePrivilegeRow>, TrinoError> { + let (catalog, schema, table) = (query.catalog(), query.schema(), query.table()); + tracing::debug!(catalog, schema, table, "TrinoBackend::table_privileges"); + + // No ORDER BY: core sorts by TABLE_CAT, TABLE_SCHEM, TABLE_NAME, + // PRIVILEGE, GRANTEE. Note that PRIVILEGE outranks GRANTEE here. + let sql = format!( + "SELECT table_catalog, table_schema, table_name, grantor, grantee, \ + privilege_type, is_grantable \ + FROM {}{}", + information_schema_ref(catalog, "table_privileges"), + build_tables_where_clause(catalog, schema, table) + ); + + let rows = query_information_schema(conn, sql)?; + + Ok(rows + .into_iter() + .filter_map(|row| { + let vals: Vec<serde_json::Value> = row.into_json().into_iter().collect(); + let converted = table_privilege_row(&vals); + if converted.is_none() { + tracing::warn!("table_privileges row missing a non-NULL column, skipping"); + } + converted + }) + .collect()) +} + +/// Return the column-level privileges on a single table. +/// +/// Trino exposes no column-level privilege metadata at all: there is no +/// `information_schema.column_privileges` in any catalog, and no `system.jdbc` +/// equivalent. Privileges in Trino are granted on a table, never on a column, +/// so there is nothing to narrow [`table_privileges`] down with either. +/// +/// Core defaults this to no rows, but it is stated here so the reason is +/// recorded and the call is logged like every other backend method. +pub(super) fn column_privileges( + _conn: &TrinoConnection, + query: &ColumnPrivilegesQuery<'_>, +) -> Result<Vec<ColumnPrivilegeRow>, TrinoError> { + tracing::debug!( + catalog = query.catalog(), + schema = query.schema(), + table = query.table(), + column = query.column(), + "TrinoBackend::column_privileges (empty: Trino grants on tables, not columns)" + ); + Ok(Vec::new()) +} + +/// Return the stored procedures matching the given filters. +/// +/// Trino has procedures (`CALL system.runtime.kill_query(...)` is one, and +/// calling an unregistered name answers `PROCEDURE_NOT_FOUND`), but it +/// publishes no metadata describing them. `system.jdbc.procedures` exists for +/// JDBC compatibility and is hardwired empty (verified against a live +/// coordinator: `system.runtime.kill_query` is callable while that table +/// returns zero rows), and `system.metadata` has no procedures table. +/// +/// This is consistent with the `SQL_ACCESSIBLE_PROCEDURES` = `"N"` this driver +/// already reports from `info`. +pub(super) fn procedures( + _conn: &TrinoConnection, + query: &ProceduresQuery<'_>, +) -> Result<Vec<ProcedureRow>, TrinoError> { + tracing::debug!( + catalog = query.catalog(), + schema = query.schema(), + proc_name = query.proc_name(), + "TrinoBackend::procedures (empty: Trino publishes no procedure metadata)" + ); + Ok(Vec::new()) +} + +/// Return the parameters and result-set columns of the matching procedures. +/// +/// Empty for the same reason as [`procedures`]: `system.jdbc.procedure_columns` +/// is the matching hardwired-empty JDBC compatibility view. A driver that +/// cannot name a procedure cannot describe its parameters either. +pub(super) fn procedure_columns( + _conn: &TrinoConnection, + query: &ProcedureColumnsQuery<'_>, +) -> Result<Vec<ProcedureColumnRow>, TrinoError> { + tracing::debug!( + catalog = query.catalog(), + schema = query.schema(), + proc_name = query.proc_name(), + column = query.column(), + "TrinoBackend::procedure_columns (empty: Trino publishes no procedure metadata)" + ); + Ok(Vec::new()) +} + +/// Return primary key columns for the given table. +/// +/// Trino has no concept of primary keys at the engine level: no connector +/// exposes `information_schema.table_constraints` or `key_column_usage`. +/// Trino's own JDBC driver answers `DatabaseMetaData.getPrimaryKeys()` with +/// `WHERE false`, and this matches it. +/// +/// No rows, which core serves as `SQL_SUCCESS` with the spec's six-column +/// schema, so a BI tool's schema discovery proceeds instead of erroring. +/// +/// Ref: <https://github.com/trinodb/trino/issues/22408> +pub(super) fn primary_keys( + _conn: &TrinoConnection, + query: &PrimaryKeysQuery<'_>, +) -> Result<Vec<PrimaryKeyRow>, TrinoError> { + tracing::debug!( + catalog = query.catalog(), + schema = query.schema(), + table = query.table(), + "TrinoBackend::primary_keys (empty: Trino has no PK metadata)" + ); + Ok(Vec::new()) +} + +/// Return foreign key relationships. +/// +/// Trino has no concept of foreign keys, the same limitation as primary keys: +/// no connector exposes `information_schema.referential_constraints`. +/// +/// Ref: <https://github.com/trinodb/trino/issues/22408> +pub(super) fn foreign_keys( + _conn: &TrinoConnection, + query: &ForeignKeysQuery<'_>, +) -> Result<Vec<ForeignKeyRow>, TrinoError> { + tracing::debug!( + pk_catalog = query.pk_catalog(), + pk_schema = query.pk_schema(), + pk_table = query.pk_table(), + fk_catalog = query.fk_catalog(), + fk_schema = query.fk_schema(), + fk_table = query.fk_table(), + "TrinoBackend::foreign_keys (empty: Trino has no FK metadata)" + ); + Ok(Vec::new()) +} + +/// Return index statistics for a table. +/// +/// Trino exposes no cross-connector index or cardinality metadata: there is no +/// engine-level equivalent of `SQLStatistics`, and index shape is a +/// per-connector physical detail Trino hides. Stated here rather than left to +/// the trait default, matching `primary_keys` and `foreign_keys`, so the +/// reason is recorded and the call is logged. +pub(super) fn statistics( + _conn: &TrinoConnection, + query: &StatisticsQuery<'_>, +) -> Result<Vec<StatisticsRow>, TrinoError> { + tracing::debug!( + catalog = query.catalog(), + schema = query.schema(), + table = query.table(), + "TrinoBackend::statistics (empty: Trino exposes no index metadata)" + ); + Ok(Vec::new()) +} + +/// Return the optimal row-identifier or row-version columns for a table. +/// +/// Trino has no rowid, no row-version column, and no engine-level unique-key +/// metadata to derive an optimal identifier from, so there is nothing to +/// report. Stated here rather than left to the trait default, like +/// `statistics`. +pub(super) fn special_columns( + _conn: &TrinoConnection, + query: &SpecialColumnsQuery<'_>, +) -> Result<Vec<SpecialColumnRow>, TrinoError> { + tracing::debug!( + catalog = query.catalog(), + schema = query.schema(), + table = query.table(), + "TrinoBackend::special_columns (empty: Trino has no rowid/row-version metadata)" + ); + Ok(Vec::new()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn char_octet_length_does_not_overflow() { + // Trino reports unbounded varchar as varchar(2147483647) in some + // connectors; 2147483647 * BYTES_PER_CHAR wraps to -4 in release, + // panics in debug. + assert_eq!( + char_octet_length(SqlDataType::EXT_W_VARCHAR, Some(i32::MAX)), + None + ); + } + + #[test] + fn char_octet_length_is_null_for_binary_columns() { + // Trino's varbinary carries no length anywhere in its type system: + // `information_schema.columns.data_type` is the bare string + // "varbinary" for every binary column (verified against a live + // coordinator), so the real call path can never produce a `Some` + // `ty_precision` for EXT_LONG_VAR_BINARY. NULL is the honest, + // reachable answer; this pins it rather than feeding an impossible + // `Some(_)` input. + assert_eq!( + char_octet_length(SqlDataType::EXT_LONG_VAR_BINARY, None), + None + ); + } + + #[test] + fn char_octet_length_is_four_times_size_for_character_columns() { + assert_eq!( + char_octet_length(SqlDataType::EXT_W_VARCHAR, Some(50)), + Some(50 * BYTES_PER_CHAR) + ); + } + + #[test] + fn char_octet_length_is_null_for_non_character_non_binary_columns() { + // INTEGER is neither character nor binary data. + assert_eq!(char_octet_length(SqlDataType::INTEGER, Some(10)), None); + } + + #[test] + fn char_octet_length_is_null_without_a_declared_length() { + assert_eq!(char_octet_length(SqlDataType::EXT_W_VARCHAR, None), None); + } + + /// The row a coordinator with `sql-standard` security returns. Neither + /// catalog in this project's test stack implements permission management, + /// so this shape cannot be obtained from the live server the integration + /// tests run against: it is transcribed from + /// `information_schema.table_privileges`' column list, which was read off + /// the running coordinator. + fn privilege_json(grantor: serde_json::Value) -> Vec<serde_json::Value> { + use serde_json::json; + vec![ + json!("tpcds"), // table_catalog + json!("sf1"), // table_schema + json!("call_center"), // table_name + grantor, // grantor + json!("alice"), // grantee + json!("SELECT"), // privilege_type + json!("NO"), // is_grantable + ] + } + + #[test] + fn table_privilege_row_maps_every_column_to_its_spec_position() { + let row = table_privilege_row(&privilege_json(serde_json::json!("admin"))).unwrap(); + assert_eq!(row.catalog.as_deref(), Some("tpcds")); + assert_eq!(row.schema.as_deref(), Some("sf1")); + assert_eq!(row.table_name, "call_center"); + assert_eq!(row.grantor.as_deref(), Some("admin")); + assert_eq!(row.grantee, "alice"); + assert_eq!(row.privilege, "SELECT"); + assert_eq!(row.is_grantable.as_deref(), Some("NO")); + } + + #[test] + fn table_privilege_row_keeps_a_null_grantor_null() { + // ODBC's GRANTOR is nullable and Trino leaves it NULL for a privilege + // nobody explicitly granted, so this must not become the string + // "null" or an empty string. + let row = table_privilege_row(&privilege_json(serde_json::Value::Null)).unwrap(); + assert_eq!(row.grantor, None); + } + + #[test] + fn table_privilege_row_drops_a_row_missing_a_non_null_column() { + // TABLE_NAME, GRANTEE and PRIVILEGE are the spec's not-NULL columns. + // A row without one cannot be described to an application, and + // core would serve it as an empty string rather than an error. + for missing in [ + PrivilegeCol::TableName, + PrivilegeCol::Grantee, + PrivilegeCol::PrivilegeType, + ] { + let mut vals = privilege_json(serde_json::json!("admin")); + vals[missing as usize] = serde_json::Value::Null; + assert!( + table_privilege_row(&vals).is_none(), + "a NULL in column {} must drop the row", + missing as usize + ); + } + } + + #[test] + fn table_privilege_row_drops_a_short_row() { + // A truncated row must not panic on the index. + assert!(table_privilege_row(&[]).is_none()); + assert!(table_privilege_row(&privilege_json(serde_json::Value::Null)[..3]).is_none()); + } + + #[test] + fn a_datetime_column_reports_the_verbose_type_and_its_subcode() { + // Spec, SQLColumns: "SQL_DATA_TYPE ... For datetime and interval data + // types, this column returns SQL_DATETIME or SQL_INTERVAL, and the + // SQL_DATETIME_SUB field returns the subcode." `SQLGetTypeInfo` + // already answers this way for the same types, so reporting the + // concise type here makes the driver contradict itself. + for (concise, sub) in [ + (SqlDataType::DATE, SQL_CODE_DATE), + (SqlDataType::TIME, SQL_CODE_TIME), + (SqlDataType::TIMESTAMP, SQL_CODE_TIMESTAMP), + ] { + assert_eq!( + verbose_type(concise), + (SqlDataType::DATETIME.0, Some(sub)), + "{concise:?} must report SQL_DATETIME plus its subcode" + ); + } + } + + #[test] + fn a_non_datetime_column_reports_its_own_type_and_a_null_subcode() { + // Same spec paragraph: "For other data types, this column returns a + // NULL." Anything else would have an application read a subcode that + // means nothing. + for concise in [ + SqlDataType::EXT_BIG_INT, + SqlDataType::EXT_W_VARCHAR, + SqlDataType::DECIMAL, + ] { + assert_eq!(verbose_type(concise), (concise.0, None)); + } + } + + #[test] + fn an_exact_catalog_qualifies_the_information_schema_reference() { + // Trino resolves a bare `information_schema` through the *session* + // catalog, and each catalog's copy describes only itself. An + // unqualified reference matches nothing for any other catalog, + // however the WHERE clause is written. + assert_eq!( + information_schema_ref(Some("postgresql"), "columns"), + "\"postgresql\".information_schema.columns" + ); + } + + #[test] + fn a_catalog_name_is_quoted_as_an_identifier() { + // It lands in the FROM clause, so it is an identifier, not a literal: + // delimited, with an embedded quote doubled. + assert_eq!( + information_schema_ref(Some("we\"ird"), "tables"), + "\"we\"\"ird\".information_schema.tables" + ); + } + + #[test] + fn an_absent_or_wildcard_catalog_leaves_the_reference_unqualified() { + // No catalog, or a pattern rather than a name, keeps the session + // catalog: there is no single catalog to qualify with, and a pattern + // cannot appear in a FROM clause. + for catalog in [None, Some(""), Some("%"), Some("pg%")] { + assert_eq!( + information_schema_ref(catalog, "tables"), + "information_schema.tables", + "{catalog:?} must not be qualified" + ); + } + } + + #[test] + fn push_filter_escaped_percent_is_literal_exact_match() { + // `foo\%` is a literal '%', emitted as `=` (not LIKE). + let mut conds = Vec::new(); + push_filter(&mut conds, "table_name", "foo\\%"); + assert_eq!(conds, vec!["table_name = 'foo%'".to_string()]); + } + + #[test] + fn push_filter_escaped_underscore_is_literal_exact_match() { + let mut conds = Vec::new(); + push_filter(&mut conds, "table_name", "call\\_center"); + assert_eq!(conds, vec!["table_name = 'call_center'".to_string()]); + } + + #[test] + fn push_filter_unescaped_wildcard_is_like() { + let mut conds = Vec::new(); + push_filter(&mut conds, "table_name", "call%"); + assert_eq!( + conds, + vec!["table_name LIKE 'call%' ESCAPE '\\'".to_string()] + ); + } +} diff --git a/src/backend/params.rs b/src/backend/params.rs new file mode 100644 index 0000000..f3259fe --- /dev/null +++ b/src/backend/params.rs @@ -0,0 +1,730 @@ +//! Parameter binding for the Trino backend. +//! +//! The Trino REST API has no wire-level parameter binding: `Client::get` takes +//! only a SQL string, and the client's `PREPARE`/`EXECUTE USING` support is +//! fixed at connection-build time so it cannot carry per-statement values. +//! Bound parameters are therefore rendered into the SQL as literals here. +//! +//! Because this builds SQL text from application input, every variant that can +//! carry attacker-controlled characters is either quoted (`String`, `Json`) or +//! validated against a strict grammar (`Decimal`). Numeric and temporal +//! variants are formatted from typed values and cannot inject. + +use stackable_odbc_core::errors::OdbcError; +use stackable_odbc_core::types::{ColumnValue, SqlState}; + +use super::TrinoError; + +/// Substitute `?` placeholders in `sql` with the rendered `params`. +/// +/// Placeholders inside single-quoted string literals are ignored, matching the +/// placeholder counting in `stackable_odbc_core::ffi::params::count_params`: the two +/// must agree or the parameter indices shift. +pub(super) fn interpolate_params(sql: &str, params: &[ColumnValue]) -> Result<String, TrinoError> { + let mut out = String::with_capacity(sql.len()); + let mut next = 0usize; + let mut in_string = false; + let mut chars = sql.chars().peekable(); + + while let Some(c) = chars.next() { + match (c, in_string) { + ('\'', false) => { + in_string = true; + out.push(c); + } + ('\'', true) => { + out.push(c); + // An escaped quote ('') stays inside the string literal. + if chars.peek() == Some(&'\'') { + out.push('\''); + chars.next(); + } else { + in_string = false; + } + } + ('?', false) => { + let value = params.get(next).ok_or_else(|| TrinoError::General { + message: format!( + "not enough parameters bound: placeholder {} has no value ({} bound)", + next + 1, + params.len() + ), + })?; + out.push_str(&render_literal(value)?); + next += 1; + } + _ => out.push(c), + } + } + + if next != params.len() { + return Err(TrinoError::General { + message: format!( + "parameter count mismatch: {} bound, {} placeholders in statement", + params.len(), + next + ), + }); + } + + Ok(out) +} + +/// Render a single bound value as a Trino SQL literal. +fn render_literal(value: &ColumnValue) -> Result<String, TrinoError> { + let unsupported = |what: &str| TrinoError::General { + message: format!("{what} is not supported as a bound parameter"), + }; + + Ok(match value { + ColumnValue::Null => "NULL".to_string(), + ColumnValue::Bool(b) => if *b { "TRUE" } else { "FALSE" }.to_string(), + + ColumnValue::I8(v) => v.to_string(), + ColumnValue::I16(v) => v.to_string(), + ColumnValue::I32(v) => v.to_string(), + ColumnValue::I64(v) => v.to_string(), + + // NaN and the infinities have no Trino literal form. + ColumnValue::F32(v) => { + if !v.is_finite() { + return Err(unsupported("non-finite REAL")); + } + format!("REAL '{v}'") + } + ColumnValue::F64(v) => { + if !v.is_finite() { + return Err(unsupported("non-finite DOUBLE")); + } + format!("DOUBLE '{v}'") + } + + ColumnValue::String(s) => quote_string(s), + // Trino parses JSON from a string literal, so the same quoting applies. + ColumnValue::Json(s) => format!("JSON {}", quote_string(s)), + + // Decimal is carried as text to preserve precision, so it is the one + // numeric variant that could smuggle SQL. Validate before emitting. + ColumnValue::Decimal(s) => { + if !is_plain_decimal(s) { + return Err(TrinoError::General { + message: format!("invalid DECIMAL parameter value: {s:?}"), + }); + } + format!("DECIMAL '{s}'") + } + + ColumnValue::Bytes(b) => { + use std::fmt::Write; + let mut hex = String::with_capacity(b.len() * 2); + for byte in b { + let _ = write!(hex, "{byte:02X}"); + } + format!("X'{hex}'") + } + ColumnValue::Guid(b) => format!( + "UUID '{:02x}{:02x}{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}-{:02x}{:02x}{:02x}{:02x}{:02x}{:02x}'", + b[0], + b[1], + b[2], + b[3], + b[4], + b[5], + b[6], + b[7], + b[8], + b[9], + b[10], + b[11], + b[12], + b[13], + b[14], + b[15] + ), + + ColumnValue::Date { year, month, day } => { + check_date(*year, *month, *day)?; + format!("DATE '{}-{month:02}-{day:02}'", year4(*year)) + } + ColumnValue::Time { + hour, + minute, + second, + fraction, + } => { + check_time(*hour, *minute, *second)?; + format!( + "TIME '{hour:02}:{minute:02}:{second:02}.{}'", + nanos(*fraction) + ) + } + ColumnValue::Timestamp { + year, + month, + day, + hour, + minute, + second, + fraction, + } => { + check_date(*year, *month, *day)?; + check_time(*hour, *minute, *second)?; + format!( + "TIMESTAMP '{}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02}.{}'", + year4(*year), + nanos(*fraction) + ) + } + ColumnValue::TimestampTz { + year, + month, + day, + hour, + minute, + second, + fraction, + timezone_offset_minutes, + } => { + let sign = if *timezone_offset_minutes < 0 { + '-' + } else { + '+' + }; + check_date(*year, *month, *day)?; + check_time(*hour, *minute, *second)?; + let abs = timezone_offset_minutes.abs(); + format!( + "TIMESTAMP '{}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02}.{} {sign}{:02}:{:02}'", + year4(*year), + nanos(*fraction), + abs / 60, + abs % 60 + ) + } + + // Constructing these safely requires per-element type context that the + // ODBC bind API does not provide. + ColumnValue::Array(_) => return Err(unsupported("ARRAY")), + ColumnValue::Map(_) => return Err(unsupported("MAP")), + ColumnValue::Row(_) => return Err(unsupported("ROW")), + ColumnValue::IntervalYearMonth { .. } => return Err(unsupported("INTERVAL YEAR TO MONTH")), + ColumnValue::IntervalDayTime { .. } => return Err(unsupported("INTERVAL DAY TO SECOND")), + + // ColumnValue is #[non_exhaustive]. A variant added upstream must fail + // loudly here rather than be rendered by a guess: this function emits + // SQL text, so an unreviewed rendering is an injection risk. The value + // itself is kept out of the message, which reaches a diagnostic. + _ => return Err(unsupported("this parameter type")), + }) +} + +/// SQLSTATE 22007 for a bound temporal value that names no real instant. +/// +/// `SQLExecute`'s and `SQLExecDirect`'s diagnostics both list 22007 for "the +/// data sent for a parameter ... was an invalid date, time, or timestamp +/// value", with no `(DM)` marker. Rendering the value into a literal instead +/// sends the coordinator something it cannot parse, and the application gets +/// `HY000 [INVALID_LITERAL]` quoting SQL it never wrote, for a value that never +/// had to leave the process. +fn invalid_datetime(what: &str, rendered: String) -> TrinoError { + OdbcError::general( + format!("bound {what} is not a valid value: {rendered}"), + SqlState::invalid_datetime_format(), + ) + .into() +} + +/// Reject a year/month/day that names no real date. +/// +/// Calendar-aware rather than a range check, because Feb 30 and Feb 29 of a +/// common year pass every per-field bound and are still not dates. +fn check_date(year: i16, month: u16, day: u16) -> Result<(), TrinoError> { + let valid = chrono::NaiveDate::from_ymd_opt(i32::from(year), u32::from(month), u32::from(day)) + .is_some(); + if valid { + Ok(()) + } else { + Err(invalid_datetime( + "date", + format!("{}-{month:02}-{day:02}", year4(year)), + )) + } +} + +/// Reject an hour/minute/second outside the clock. +/// +/// The second goes to 61, not 59: `SQL_TIME_STRUCT` and `SQL_TIMESTAMP_STRUCT` +/// are documented as carrying 0-61 because the spec permits leap seconds, so +/// refusing 60 would reject a value the struct is defined to hold. Trino +/// rejects them itself, which is its decision to make. +fn check_time(hour: u16, minute: u16, second: u16) -> Result<(), TrinoError> { + if hour < 24 && minute < 60 && second <= 61 { + Ok(()) + } else { + Err(invalid_datetime( + "time", + format!("{hour:02}:{minute:02}:{second:02}"), + )) + } +} + +/// The fractional-seconds field of a temporal literal, always nine digits. +/// +/// `SQL_TIMESTAMP_STRUCT::fraction` is nanoseconds and Trino accepts up to +/// `timestamp(12)`, so every digit the application bound is rendered. Dividing +/// to milliseconds discards six of them, which stores a value the application +/// never bound and makes `WHERE ts = ?` miss the row it was looking for. +/// +/// The width is fixed rather than trimmed because a value at or above one +/// second is out of range for the field: `1_000_000_000` formatted at its +/// natural width is ten digits, and Trino reads the extra one as greater +/// precision rather than as an error, turning one second into a tenth of one. +/// Saturating keeps the literal honest about the range the field can express. +fn nanos(fraction: u32) -> String { + format!("{:09}", fraction.min(999_999_999)) +} + +/// The year field of a temporal literal, always four digits and signed. +/// +/// A `SQL_DATE_STRUCT` year is an `i16`, and `{year:04}` spends one of its four +/// slots on the sign, so year -1 renders as `-001`: a different year, which +/// Trino accepts and this driver's own read path then cannot parse. +fn year4(year: i16) -> String { + if year < 0 { + format!("-{:04}", year.unsigned_abs()) + } else { + format!("{year:04}") + } +} + +/// Wrap `s` in single quotes, doubling any embedded quote. +/// +/// Backslash is not an escape character in Trino string literals (standard SQL +/// semantics), so doubling the quote is sufficient and complete. +fn quote_string(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('\''); + for c in s.chars() { + if c == '\'' { + out.push('\''); + } + out.push(c); + } + out.push('\''); + out +} + +/// True when `s` is a bare decimal number: optional sign, digits, optional +/// fractional part. Strict, because a DECIMAL literal cannot be quoted: +/// anything else is rejected rather than escaped. +fn is_plain_decimal(s: &str) -> bool { + let body = s + .strip_prefix('-') + .or_else(|| s.strip_prefix('+')) + .unwrap_or(s); + if body.is_empty() { + return false; + } + match body.split_once('.') { + None => body.bytes().all(|b| b.is_ascii_digit()), + Some((int, frac)) => { + !int.is_empty() + && !frac.is_empty() + && int.bytes().all(|b| b.is_ascii_digit()) + && frac.bytes().all(|b| b.is_ascii_digit()) + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn interp(sql: &str, params: &[ColumnValue]) -> String { + interpolate_params(sql, params).expect("interpolation succeeded") + } + + #[test] + fn integer_parameter_is_substituted() { + assert_eq!( + interp("SELECT * FROM t WHERE id = ?", &[ColumnValue::I64(42)]), + "SELECT * FROM t WHERE id = 42" + ); + } + + #[test] + fn multiple_parameters_substitute_in_order() { + assert_eq!( + interp( + "SELECT ? , ?", + &[ColumnValue::I32(1), ColumnValue::String("a".into())] + ), + "SELECT 1 , 'a'" + ); + } + + #[test] + fn null_renders_as_null_keyword() { + assert_eq!(interp("SELECT ?", &[ColumnValue::Null]), "SELECT NULL"); + } + + #[test] + fn string_quotes_are_doubled() { + assert_eq!( + interp("SELECT ?", &[ColumnValue::String("O'Brien".into())]), + "SELECT 'O''Brien'" + ); + } + + #[test] + fn sql_injection_attempt_stays_inside_the_literal() { + // The classic payload must end up as data, not as statement structure. + let evil = "'; DROP TABLE users; --"; + let out = interp( + "SELECT * FROM t WHERE name = ?", + &[ColumnValue::String(evil.into())], + ); + assert_eq!( + out, + "SELECT * FROM t WHERE name = '''; DROP TABLE users; --'" + ); + // Exactly one quoted literal: quote count must be even. + assert_eq!(out.matches('\'').count() % 2, 0); + } + + #[test] + fn placeholder_inside_string_literal_is_not_substituted() { + // Matches count_params, which also ignores `?` inside a literal. + assert_eq!( + interp("SELECT '?' , ?", &[ColumnValue::I32(7)]), + "SELECT '?' , 7" + ); + } + + #[test] + fn escaped_quote_inside_literal_is_preserved() { + assert_eq!( + interp("SELECT 'it''s ?' , ?", &[ColumnValue::I32(1)]), + "SELECT 'it''s ?' , 1" + ); + } + + #[test] + fn too_few_parameters_is_an_error() { + let err = interpolate_params("SELECT ?, ?", &[ColumnValue::I32(1)]).unwrap_err(); + assert!( + format!("{err:?}").contains("not enough parameters"), + "unexpected error: {err:?}" + ); + } + + #[test] + fn too_many_parameters_is_an_error() { + let err = interpolate_params("SELECT ?", &[ColumnValue::I32(1), ColumnValue::I32(2)]) + .unwrap_err(); + assert!( + format!("{err:?}").contains("parameter count mismatch"), + "unexpected error: {err:?}" + ); + } + + #[test] + fn decimal_rejects_non_numeric_text() { + let err = interpolate_params( + "SELECT ?", + &[ColumnValue::Decimal("1'; DROP TABLE t; --".into())], + ) + .unwrap_err(); + assert!( + format!("{err:?}").contains("invalid DECIMAL"), + "unexpected error: {err:?}" + ); + } + + #[test] + fn decimal_accepts_plain_numbers() { + assert_eq!( + interp("SELECT ?", &[ColumnValue::Decimal("-12.345".into())]), + "SELECT DECIMAL '-12.345'" + ); + } + + #[test] + fn bytes_render_as_hex_literal() { + assert_eq!( + interp("SELECT ?", &[ColumnValue::Bytes(vec![0xDE, 0xAD, 0x00])]), + "SELECT X'DEAD00'" + ); + } + + #[test] + fn bool_renders_as_keyword() { + assert_eq!( + interp( + "SELECT ?, ?", + &[ColumnValue::Bool(true), ColumnValue::Bool(false)] + ), + "SELECT TRUE, FALSE" + ); + } + + #[test] + fn date_and_timestamp_render_with_type_prefix() { + assert_eq!( + interp( + "SELECT ?", + &[ColumnValue::Date { + year: 2026, + month: 7, + day: 4 + }] + ), + "SELECT DATE '2026-07-04'" + ); + assert_eq!( + interp( + "SELECT ?", + &[ColumnValue::Timestamp { + year: 2026, + month: 7, + day: 4, + hour: 1, + minute: 2, + second: 3, + fraction: 500_000_000 + }] + ), + "SELECT TIMESTAMP '2026-07-04 01:02:03.500000000'" + ); + } + + /// `SQL_TIMESTAMP_STRUCT::fraction` is nanoseconds and Trino goes to + /// `timestamp(12)`, so every digit the application bound has to survive. + /// Rendering milliseconds silently stores a different value than the one + /// bound, and makes `WHERE ts = ?` miss a row that exists. + /// + /// The case above cannot catch that: 500_000_000 is exactly representable + /// in milliseconds, so it survives either rendering. + #[test] + fn a_bound_timestamp_keeps_every_nanosecond() { + assert_eq!( + interp( + "SELECT ?", + &[ColumnValue::Timestamp { + year: 2026, + month: 7, + day: 4, + hour: 1, + minute: 2, + second: 3, + fraction: 123_456_789 + }] + ), + "SELECT TIMESTAMP '2026-07-04 01:02:03.123456789'" + ); + } + + #[test] + fn a_bound_time_keeps_every_nanosecond() { + assert_eq!( + interp( + "SELECT ?", + &[ColumnValue::Time { + hour: 1, + minute: 2, + second: 3, + fraction: 123_456_789 + }] + ), + "SELECT TIME '01:02:03.123456789'" + ); + } + + #[test] + fn a_bound_timestamp_with_zone_keeps_every_nanosecond() { + assert_eq!( + interp( + "SELECT ?", + &[ColumnValue::TimestampTz { + year: 2026, + month: 7, + day: 4, + hour: 1, + minute: 2, + second: 3, + fraction: 123_456_789, + timezone_offset_minutes: -330 + }] + ), + "SELECT TIMESTAMP '2026-07-04 01:02:03.123456789 -05:30'" + ); + } + + /// A whole second's worth of nanoseconds is out of range for the field. + /// Dividing to milliseconds renders it as `.1000`, a four-digit + /// "millisecond" component that Trino reads as `timestamp(4)`, so one + /// second becomes a tenth of one: wrong by a factor of ten, reported as + /// success. + #[test] + fn an_out_of_range_fraction_never_renders_a_wider_field() { + for fraction in [1_000_000_000, u32::MAX] { + let sql = interp( + "SELECT ?", + &[ColumnValue::Timestamp { + year: 2026, + month: 7, + day: 4, + hour: 1, + minute: 2, + second: 3, + fraction, + }], + ); + let digits = sql + .rsplit_once('.') + .expect("a fractional part") + .1 + .trim_end_matches('\''); + assert_eq!( + digits.len(), + 9, + "the fractional field must stay nine digits, got {sql}" + ); + } + } + + /// An out-of-range field in a bound `SQL_DATE_STRUCT` / + /// `SQL_TIMESTAMP_STRUCT` would otherwise reach the coordinator as a + /// literal and come back as `HY000 [INVALID_LITERAL]`, quoting SQL the + /// application never wrote. + /// `SQLExecute`'s diagnostics list `22007` for "the data sent for a + /// parameter ... was an invalid date, time, or timestamp value", and it is + /// the driver's to return: the value never had to leave the process. + #[test] + fn an_invalid_bound_date_reports_22007_rather_than_reaching_trino() { + use stackable_odbc_core::{errors::OdbcError, types::sql_state}; + + let invalid = [ + ("month 13", 2020, 13, 1), + ("month 0", 2020, 0, 1), + ("day 0", 2020, 1, 0), + ("day 32", 2020, 1, 32), + ("feb 30", 2020, 2, 30), + ("feb 29 in a common year", 2021, 2, 29), + ]; + for (label, year, month, day) in invalid { + let err = interpolate_params("SELECT ?", &[ColumnValue::Date { year, month, day }]) + .expect_err(&format!("{label} must be refused")); + assert_eq!( + OdbcError::from(err).sqlstate().as_str(), + sql_state::INVALID_DATETIME_FORMAT, + "{label}" + ); + } + } + + #[test] + fn an_invalid_bound_time_reports_22007() { + use stackable_odbc_core::{errors::OdbcError, types::sql_state}; + + for (label, hour, minute, second) in [ + ("hour 24", 24, 0, 0), + ("minute 60", 0, 60, 0), + ("second 62", 0, 0, 62), + ] { + let err = interpolate_params( + "SELECT ?", + &[ColumnValue::Time { + hour, + minute, + second, + fraction: 0, + }], + ) + .expect_err(&format!("{label} must be refused")); + assert_eq!( + OdbcError::from(err).sqlstate().as_str(), + sql_state::INVALID_DATETIME_FORMAT, + "{label}" + ); + } + } + + /// `SQL_TIME_STRUCT`'s second field is documented as 0-61, because the + /// spec permits leap seconds. Rejecting 60 or 61 here would refuse a value + /// the ODBC struct is defined to carry, so they are passed on and Trino + /// decides. + #[test] + fn a_leap_second_is_not_refused_by_the_driver() { + for second in [60, 61] { + assert!( + interpolate_params( + "SELECT ?", + &[ColumnValue::Time { + hour: 23, + minute: 59, + second, + fraction: 0 + }] + ) + .is_ok(), + "second {second} is within SQL_TIME_STRUCT's documented range" + ); + } + } + + /// The valid cases must keep working, including a real leap day. + #[test] + fn a_valid_bound_date_is_unaffected() { + assert_eq!( + interp( + "SELECT ?", + &[ColumnValue::Date { + year: 2020, + month: 2, + day: 29 + }] + ), + "SELECT DATE '2020-02-29'" + ); + } + + /// A `SQL_DATE_STRUCT` year is signed. `{year:04}` spends one of its four + /// slots on the sign, so year -1 renders as `-001-01-01`: a different + /// year, accepted by Trino and then unreadable by this driver's own read + /// path. + #[test] + fn a_negative_year_keeps_all_four_digits() { + assert_eq!( + interp( + "SELECT ?", + &[ColumnValue::Date { + year: -1, + month: 1, + day: 1 + }] + ), + "SELECT DATE '-0001-01-01'" + ); + } + + #[test] + fn non_finite_floats_are_rejected() { + assert!(interpolate_params("SELECT ?", &[ColumnValue::F64(f64::NAN)]).is_err()); + assert!(interpolate_params("SELECT ?", &[ColumnValue::F64(f64::INFINITY)]).is_err()); + } + + #[test] + fn unsupported_composite_types_are_rejected() { + let err = interpolate_params("SELECT ?", &[ColumnValue::Array(vec![])]).unwrap_err(); + assert!( + format!("{err:?}").contains("ARRAY"), + "unexpected error: {err:?}" + ); + } + + #[test] + fn statement_without_placeholders_is_unchanged() { + assert_eq!(interp("SELECT 1", &[]), "SELECT 1"); + } +} diff --git a/src/backend/prompt.rs b/src/backend/prompt.rs new file mode 100644 index 0000000..c1d5e5c --- /dev/null +++ b/src/backend/prompt.rs @@ -0,0 +1,136 @@ +//! Presenting an interactive login URL to the user. +//! +//! Trino's OAuth 2.0 external authentication answers the first request with a +//! login URL a human has to visit, and then issues the token once they have. +//! Core decides *whether* this connect may prompt at all: that is +//! `SQLDriverConnect`'s *DriverCompletion*, and core hands back a +//! [`Prompter`] only when the answer is yes. This module is the *how*, and the +//! only place the `open` dependency is used. + +use std::sync::Arc; + +use stackable_odbc_core::{errors::OdbcError, prompt::Prompter}; +use trino_rust_client::{auth::RedirectHandler, error::Error as ClientError}; + +/// Shows a login URL by logging it and opening the system browser. +pub(crate) struct BrowserPrompter; + +impl Prompter for BrowserPrompter { + /// Logs the URL, then makes a best-effort attempt to open a browser. + /// + /// The log comes first and happens unconditionally, because it is the only + /// channel that always survives: a Driver Manager discards whatever the + /// driver writes to stderr, so `ODBC_LOG_FILE` / `ODBC_LOG_LEVEL` are what + /// make the URL reachable at all under `isql`, Power BI or Excel. + /// + /// A failed browser launch is **not** an error. There may be no display, no + /// browser, or no permission to start one, and the flow can still be + /// completed by opening the logged URL by hand, because the client polls + /// for the token rather than waiting on this call. Reporting `IM008` here + /// would fail a connect that was still perfectly able to succeed. + fn present_url(&self, url: &str) -> Result<(), OdbcError> { + tracing::info!(%url, "OAuth2 login required: open this URL to authenticate"); + if let Err(e) = open::that(url) { + tracing::warn!( + %e, + "could not open a browser; the login URL logged above has to be opened manually" + ); + } + Ok(()) + } +} + +/// Adapts core's [`Prompter`] to the client's [`RedirectHandler`]. +/// +/// The client owns the OAuth 2.0 flow and calls its handler once per login; +/// core owns the decision that a login may be shown at all. This is the seam +/// between them, and it exists so the `Prompter` core handed back is the object +/// actually used, not an equivalent this driver rebuilt for itself. +pub(crate) struct ClientRedirect(Arc<dyn Prompter>); + +impl ClientRedirect { + /// Wrap the [`Prompter`] core handed back, so the client's login URL + /// reaches it. + pub(crate) fn new(prompter: Arc<dyn Prompter>) -> Self { + Self(prompter) + } +} + +impl RedirectHandler for ClientRedirect { + fn redirect(&self, url: &str) -> Result<(), ClientError> { + self.0 + .present_url(url) + .map_err(|e| ClientError::OAuth2(format!("could not present the login URL: {e}"))) + } +} + +#[cfg(test)] +mod tests { + use std::sync::{Arc, Mutex}; + + use stackable_odbc_core::types::SqlState; + + use super::*; + + /// Records what it was asked to present, so the adapter can be tested + /// without a browser. + struct Recording(Mutex<Vec<String>>); + + impl Prompter for Recording { + fn present_url(&self, url: &str) -> Result<(), OdbcError> { + match self.0.lock() { + Ok(mut seen) => seen.push(url.to_string()), + Err(_) => { + return Err(OdbcError::general( + "recording prompter poisoned", + SqlState::general_error(), + )); + } + } + Ok(()) + } + } + + struct Failing; + + impl Prompter for Failing { + fn present_url(&self, _url: &str) -> Result<(), OdbcError> { + Err(OdbcError::general("no display", SqlState::general_error())) + } + } + + #[test] + fn the_adapter_passes_the_url_through_to_the_prompter() { + let recording = Arc::new(Recording(Mutex::new(Vec::new()))); + let adapter = ClientRedirect::new(recording.clone()); + + assert!( + adapter + .redirect("https://trino.example.com/oauth2/token/init/abc") + .is_ok() + ); + + let seen = recording.0.lock().expect("not poisoned"); + assert_eq!( + seen.as_slice(), + ["https://trino.example.com/oauth2/token/init/abc"] + ); + } + + /// A prompter that cannot show anything must reach the client as an OAuth2 + /// failure, not be swallowed: the client is what turns it into a failed + /// login rather than an indefinite poll. + #[test] + fn a_prompter_failure_becomes_a_client_oauth2_error() { + let adapter = ClientRedirect::new(Arc::new(Failing)); + + let err = adapter + .redirect("https://trino.example.com/oauth2/token/init/abc") + .expect_err("a failing prompter must not report success"); + + assert!( + matches!(&err, ClientError::OAuth2(m) if m.contains("no display")), + "expected the cause to survive into the client error, got {err:?}" + ); + } +} diff --git a/src/backend/setup.rs b/src/backend/setup.rs new file mode 100644 index 0000000..5f9b757 --- /dev/null +++ b/src/backend/setup.rs @@ -0,0 +1,429 @@ +//! The driver's DSN setup dialog, behind +//! [`Backend::configure_dsn`](stackable_odbc_core::backend::Backend::configure_dsn). +//! +//! Core owns all of `ConfigDSN`: validating *fRequest*, rejecting `DRIVER=`, +//! merging the data source's stored keywords in, calling `SQLValidDSN` and +//! writing through `SQLWriteDSNToIni`. This module supplies the one thing that +//! varies per driver, which is asking a person which keywords the data source +//! needs. +//! +//! The dialog itself is `packaging/windows/configure-dsn.ps1`, run with +//! `-Emit`, which prints the keywords it collected instead of writing them. +//! Reusing the script is what keeps one list of keywords: its `$Fields` table +//! names all 34 of them, and `dsn_keys_match_the_connection_string_parser` in +//! `src/lib.rs` fails the build if that table and the parser disagree. A +//! second dialog written in Rust would be a second list, checked by nothing. +//! +//! Only the two OS calls are `#[cfg(windows)]`; every decision is a plain +//! function with unit tests that run on Linux. + +use std::collections::HashMap; + +use stackable_odbc_core::setup::{ConfigRequest, SetupError}; + +/// The dialog script, looked for beside the driver's own DLL. +/// +/// `install.bat` must copy it there, as a hard requirement rather than +/// best-effort: without it the Administrator's **Add…** and **Configure…** +/// buttons have no dialog to run, and every such request fails. +/// +/// The `cfg_attr`s below, here and on the three functions that follow, are +/// core's own idiom for the parts of `ConfigDSN` that only Windows reaches: +/// they stay compiled and unit-tested everywhere, so a change breaks the build +/// on the platform this is developed on rather than on the one it ships to. +#[cfg_attr(not(windows), allow(dead_code))] +const DIALOG_SCRIPT: &str = "configure-dsn.ps1"; + +/// The dialog collected keywords: its stdout is the JSON map. +#[cfg_attr(not(windows), allow(dead_code))] +const EXIT_ACCEPTED: i32 = 0; +/// The user cancelled. Not a failure, so `ConfigDSN` posts no installer error. +#[cfg_attr(not(windows), allow(dead_code))] +const EXIT_CANCELLED: i32 = 2; + +/// Whether this call is allowed to put a dialog on the screen. +/// +/// Two things say no: +/// +/// - **A null `hwndParent`.** The spec is explicit: "The function will not +/// display any dialog boxes if the handle is null." It is also what keeps +/// this from recursing. `configure-dsn.ps1`, run standalone, writes its data +/// source through `SQLConfigDataSourceW` with a null *hwndParent*, which +/// re-enters this hook. This rule makes that re-entry headless, so the +/// script is not asked to launch itself. +/// - **`Remove`.** The Administrator has already asked the user to confirm the +/// deletion, and this driver keeps nothing outside `ODBC.INI` that a removal +/// would need to clean up: no cached token, no keytab. A second confirmation +/// would only be a second chance to answer the same question differently. +/// +/// `Add` and `Config` prompt. Everything else passes the attributes through +/// unchanged, which is exactly core's defaulted behaviour. +fn dialog_needed(hwnd_is_null: bool, request: ConfigRequest) -> bool { + if hwnd_is_null { + return false; + } + match request { + ConfigRequest::Add | ConfigRequest::Config => true, + ConfigRequest::Remove => false, + } +} + +/// The attribute map, as the dialog reads it on stdin. +/// +/// A pipe rather than a temp file, because a `Config` request arrives with the +/// data source's whole stored section merged in by core, including `PWD` and +/// whatever else `sensitive_connect_keywords` names. Those are already in the +/// registry unencrypted; putting them in a second place, with a filename any +/// other process on the machine can guess, would be a new exposure rather than +/// the same one. +#[cfg_attr(not(windows), allow(dead_code))] +fn encode_attributes(attributes: &HashMap<String, String>) -> Result<String, SetupError> { + serde_json::to_string(attributes).map_err(|e| { + // No value in the message: it goes to the installer error buffer and + // the ODBC Administrator displays it. + SetupError::request_failed(format!( + "could not encode the data source's keywords for the setup dialog: {e}" + )) + }) +} + +/// What the dialog decided, read back from its exit code and stdout. +/// +/// The exit code carries the verdict because stdout carries the payload. A +/// dialog cannot report "cancelled" in-band without inventing a sentinel that +/// some future keyword value could collide with. +#[cfg_attr(not(windows), allow(dead_code))] +fn interpret_outcome( + code: Option<i32>, + stdout: &str, + stderr: &str, +) -> Result<Option<HashMap<String, String>>, SetupError> { + match code { + Some(EXIT_ACCEPTED) => { + let attrs: HashMap<String, String> = + serde_json::from_str(stdout.trim()).map_err(|e| { + SetupError::request_failed(format!( + "the setup dialog returned something that is not a keyword list: {e}" + )) + })?; + Ok(Some(attrs)) + } + Some(EXIT_CANCELLED) => Ok(None), + other => { + // PowerShell writes a terminating error to stderr and exits 1. Pass + // it on: it is the only account of what went wrong, and without it + // the Administrator shows a bare "could not perform the operation". + let detail = stderr.trim(); + let detail = if detail.is_empty() { + "it reported no reason".to_string() + } else { + detail.to_string() + }; + let how = match other { + Some(c) => format!("exited with code {c}"), + None => "was terminated by a signal".to_string(), + }; + Err(SetupError::request_failed(format!( + "the setup dialog {how}: {detail}" + ))) + } + } +} + +/// Present the dialog and return the keywords it collected. +/// +/// See [`Backend::configure_dsn`](stackable_odbc_core::backend::Backend::configure_dsn) +/// for the contract this satisfies. +pub(super) fn configure_dsn( + hwnd_parent: *mut std::ffi::c_void, + request: ConfigRequest, + attributes: HashMap<String, String>, +) -> Result<Option<HashMap<String, String>>, SetupError> { + // Keyword names only, never values: this map routinely carries `PWD`. + tracing::debug!( + ?request, + headless = hwnd_parent.is_null(), + keywords = attributes.len(), + "TrinoBackend::configure_dsn" + ); + + if !dialog_needed(hwnd_parent.is_null(), request) { + return Ok(Some(attributes)); + } + + #[cfg(windows)] + { + let payload = encode_attributes(&attributes)?; + let (code, stdout, stderr) = windows::run_dialog(&payload)?; + interpret_outcome(code, &stdout, &stderr) + } + #[cfg(not(windows))] + { + // `ConfigDSNW` is a Windows export and core does not build it + // elsewhere, so this is unreachable rather than a gap. Passing the + // attributes through is what the caller would have got anyway. + tracing::warn!( + "ConfigDSN asked for a setup dialog, which this driver only has on \ + Windows; proceeding with the keywords as supplied" + ); + Ok(Some(attributes)) + } +} + +#[cfg(windows)] +mod windows { + //! Finding the dialog and running it. The only two OS calls in the module. + + use std::ffi::{OsString, c_void}; + use std::io::Write as _; + use std::os::windows::ffi::OsStringExt as _; + use std::os::windows::process::CommandExt as _; + use std::path::PathBuf; + use std::process::{Command, Stdio}; + + use stackable_odbc_core::setup::SetupError; + + use super::DIALOG_SCRIPT; + + /// `GetModuleHandleExW` flags, from `libloaderapi.h`. Together they mean + /// "the module containing this address, without taking a reference". + /// Taking a reference here would pin the driver DLL in the + /// Administrator's process for its lifetime. + const GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT: u32 = 0x0000_0002; + const GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS: u32 = 0x0000_0004; + + /// `CreateProcess`'s flag from `winbase.h`, so PowerShell does not flash a + /// console window over the Administrator for as long as the dialog is up. + const CREATE_NO_WINDOW: u32 = 0x0800_0000; + + // Two functions from kernel32, which is the operating system rather than a + // dependency, the same reason the Windows SBOM declares no import of it. + // Declaring them here rather than taking `windows-sys` keeps the driver's + // dependency graph, and so its SBOM, unchanged by a setup dialog. + #[link(name = "kernel32")] + unsafe extern "system" { + fn GetModuleHandleExW( + dw_flags: u32, + lp_module_name: *const u16, + ph_module: *mut *mut c_void, + ) -> i32; + fn GetModuleFileNameW(h_module: *mut c_void, lp_filename: *mut u16, n_size: u32) -> u32; + } + + /// The directory holding this DLL, identified from an address inside the + /// module. This function's own address is one. + /// + /// `std::env::current_exe()` answers with the Administrator's path, since + /// `ConfigDSN` runs inside `odbcad32.exe`. + fn driver_directory() -> Result<PathBuf, SetupError> { + let mut module: *mut c_void = std::ptr::null_mut(); + // SAFETY: the flags are the documented pair for an address lookup, the + // address is this function's own and so certainly inside the module, + // and `module` is a live out-pointer for the duration of the call. + let ok = unsafe { + GetModuleHandleExW( + GET_MODULE_HANDLE_EX_FLAG_FROM_ADDRESS + | GET_MODULE_HANDLE_EX_FLAG_UNCHANGED_REFCOUNT, + driver_directory as *const u16, + &raw mut module, + ) + }; + if ok == 0 { + return Err(SetupError::request_failed( + "could not identify the driver DLL's own module".to_string(), + )); + } + + // MAX_PATH is not a limit on a path, only on the buffer most callers + // pass, so grow until the name fits rather than truncating it. A + // truncated path would name a directory that does not exist, and the + // failure would read as a missing script. + let mut buf = vec![0u16; 260]; + loop { + // SAFETY: `buf` is a live allocation of `buf.len()` u16s, which is + // exactly what the length argument claims. + let len = unsafe { GetModuleFileNameW(module, buf.as_mut_ptr(), buf.len() as u32) }; + if len == 0 { + return Err(SetupError::request_failed( + "could not read the driver DLL's own path".to_string(), + )); + } + if (len as usize) < buf.len() { + buf.truncate(len as usize); + break; + } + buf.resize(buf.len() * 2, 0); + } + + let path = PathBuf::from(OsString::from_wide(&buf)); + path.parent().map(PathBuf::from).ok_or_else(|| { + SetupError::request_failed(format!( + "the driver DLL's path has no directory: {}", + path.display() + )) + }) + } + + /// Run the dialog, feeding it `payload` on stdin. + /// + /// Returns the exit code and both output streams; deciding what they mean + /// is [`super::interpret_outcome`]'s job. + pub(super) fn run_dialog(payload: &str) -> Result<(Option<i32>, String, String), SetupError> { + let script = driver_directory()?.join(DIALOG_SCRIPT); + if !script.is_file() { + // Naming the path is the whole value of this error: the usual + // cause is a DLL registered from wherever it was unzipped, with + // the rest of the archive left behind. + return Err(SetupError::request_failed(format!( + "the setup dialog {DIALOG_SCRIPT} was not found beside the driver \ + (looked for {}). Reinstall with install.bat, which places both together.", + script.display() + ))); + } + + let mut child = Command::new("powershell.exe") + .args(["-NoProfile", "-ExecutionPolicy", "Bypass", "-File"]) + .arg(&script) + .arg("-Emit") + .creation_flags(CREATE_NO_WINDOW) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .spawn() + .map_err(|e| { + SetupError::request_failed(format!("could not run the setup dialog: {e}")) + })?; + + // Scoped so the pipe closes before the wait below. PowerShell's + // `[Console]::In.ReadToEnd()` does not return until it does, and this + // process does not read stdout until the wait, so leaving it open + // deadlocks both sides. + { + let mut stdin = child.stdin.take().ok_or_else(|| { + SetupError::request_failed("the setup dialog's stdin was not available".to_string()) + })?; + stdin.write_all(payload.as_bytes()).map_err(|e| { + SetupError::request_failed(format!( + "could not send the data source's keywords to the setup dialog: {e}" + )) + })?; + } + + let out = child.wait_with_output().map_err(|e| { + SetupError::request_failed(format!("the setup dialog could not be waited on: {e}")) + })?; + + Ok(( + out.status.code(), + String::from_utf8_lossy(&out.stdout).into_owned(), + String::from_utf8_lossy(&out.stderr).into_owned(), + )) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// A null `hwndParent` must never prompt, for every request. The spec says + /// so, and it is also what stops `configure-dsn.ps1`'s own + /// `SQLConfigDataSourceW` write, which passes a null handle, from + /// re-entering this hook and launching the script a second time. + #[test] + fn a_null_parent_window_never_prompts() { + for request in [ + ConfigRequest::Add, + ConfigRequest::Config, + ConfigRequest::Remove, + ] { + assert!( + !dialog_needed(true, request), + "{request:?} with a null hwndParent must not display a dialog" + ); + } + } + + /// Add and Configure prompt; Remove does not. The Administrator confirms a + /// removal itself, and this driver has nothing outside `ODBC.INI` to clean. + #[test] + fn only_add_and_config_prompt() { + assert!(dialog_needed(false, ConfigRequest::Add)); + assert!(dialog_needed(false, ConfigRequest::Config)); + assert!(!dialog_needed(false, ConfigRequest::Remove)); + } + + #[test] + fn attributes_survive_the_exchange() { + let mut attrs = HashMap::new(); + attrs.insert("DSN".to_string(), "trino_prod".to_string()); + attrs.insert("Host".to_string(), "trino.example.com".to_string()); + // A value carrying the characters that would break a command line or a + // `key=value` line, which is why the exchange is JSON over a pipe. + attrs.insert( + "SessionProperties".to_string(), + "query_max_run_time:10m;example.foo:bar \"quoted\"".to_string(), + ); + + let encoded = encode_attributes(&attrs).expect("a string map encodes"); + let decoded = interpret_outcome(Some(EXIT_ACCEPTED), &encoded, "") + .expect("exit 0 with a keyword list is an acceptance") + .expect("an acceptance carries a map"); + assert_eq!(decoded, attrs); + } + + /// Cancelling is `Ok(None)`, which core turns into FALSE with no installer + /// error posted. An `Err` here would put "could not perform the operation" + /// in front of a user who changed their mind. + #[test] + fn cancelling_is_not_a_failure() { + let outcome = interpret_outcome(Some(EXIT_CANCELLED), "", "") + .expect("a cancelled dialog is not an error"); + assert_eq!(outcome, None); + } + + /// PowerShell exits 1 on a terminating error, having written the reason to + /// stderr. That reason is the only account of what went wrong, so it has to + /// reach the message core posts. + #[test] + fn a_failing_dialog_reports_its_stderr() { + let err = interpret_outcome(Some(1), "", "Set-StrictMode: variable is not set\n") + .expect_err("a non-zero, non-cancel exit is a failure"); + assert!( + err.message.contains("variable is not set"), + "the dialog's own reason must survive into the installer error: {}", + err.message + ); + assert!( + err.message.contains("exited with code 1"), + "the exit code belongs in the message too: {}", + err.message + ); + } + + /// A dialog that exits 0 but prints something else is a failure, not an + /// empty data source. Accepting it would write a data source with no + /// keywords at all, which fails much later and much less clearly. + #[test] + fn an_unreadable_reply_is_a_failure() { + let err = interpret_outcome(Some(EXIT_ACCEPTED), "not json at all", "") + .expect_err("a reply that is not a keyword list cannot be written"); + assert!( + err.message.contains("not a keyword list"), + "unexpected message: {}", + err.message + ); + } + + /// A dialog killed by a signal has no exit code, and must not be mistaken + /// for either an acceptance or a cancellation. + #[test] + fn a_killed_dialog_is_a_failure() { + let err = + interpret_outcome(None, "", "").expect_err("no exit code cannot be read as a verdict"); + assert!( + err.message.contains("terminated by a signal"), + "unexpected message: {}", + err.message + ); + } +} diff --git a/src/backend/types/connect_params.rs b/src/backend/types/connect_params.rs new file mode 100644 index 0000000..acdc7bd --- /dev/null +++ b/src/backend/types/connect_params.rs @@ -0,0 +1,1675 @@ +//! `TrinoConnectParams`: every Trino connection setting, parsed from the +//! generic `stackable-odbc-core` connection-string key/value map. +//! +//! The `PARAM_*` constants are the authoritative key list, and cover the +//! coordinator address and credentials, all four authentication methods, the +//! three TLS verification modes and both certificate paths, the session +//! controls Trino carries in headers (catalog, schema, path, time zone, +//! locale, roles, session properties, resource estimates, client tags), +//! spooling, proxying, retries and the auditing fields. `README.md` carries +//! the same list as a table, for people who install the driver rather than +//! work on it, so a new key means two edits: this file and that table. +//! +//! Secrets are wrapped in `Redacted`, and the keys carrying them are declared +//! to core in `Backend::sensitive_connect_keywords`. + +use std::collections::{HashMap, HashSet}; +use std::time::Duration; + +use stackable_odbc_core::types::{ConnectParams, Redacted}; +use trino_rust_client::TlsVerification; +use trino_rust_client::Tz; +use trino_rust_client::selected_role::{RoleType, SelectedRole}; +use trino_rust_client::spooling::SpoolingEncoding; + +use super::super::TrinoError; + +/// Coordinator hostname. Required. +pub(crate) const PARAM_HOST: &str = "host"; +/// Coordinator port. Required. +pub(crate) const PARAM_PORT: &str = "port"; +/// Transport protocol: `"https"` (default) or `"http"`. +pub(crate) const PARAM_PROTOCOL: &str = "protocol"; +/// How strictly the coordinator's TLS certificate is checked. +/// +/// `"true"` (default) or `"full"` verify the chain and the hostname; `"ca"` +/// verifies the chain only; `"false"` or `"none"` verify nothing. The three +/// words are JDBC's `SSLVerification` values, so a value copied out of a JDBC +/// URL transfers, and the booleans are accepted for the same setting. +pub(crate) const PARAM_TLS_VERIFY: &str = "tlsverify"; +/// JDBC's name for [`PARAM_TLS_VERIFY`]. Setting both is an error unless they +/// agree; see [`parse_tls_verification`]. +pub(crate) const PARAM_SSL_VERIFICATION: &str = "sslverification"; +/// Path to a PEM certificate file for TLS verification. +pub(crate) const PARAM_CERTIFICATE: &str = "certificate"; +/// Path to a PEM file holding a client certificate chain and its PKCS#8 +/// private key, for mutual TLS. +/// +/// One file, and PEM only: `trino-rust-client` builds `reqwest` on rustls, +/// which accepts neither PKCS#12 nor JKS, so JDBC's `SSLKeyStorePath` and +/// `SSLKeyStoreType` have no equivalent here and the key is named for the one +/// thing it takes. +pub(crate) const PARAM_CLIENT_CERTIFICATE: &str = "clientcertificate"; +/// Per-request HTTP timeout in seconds. Default: 30. +pub(crate) const PARAM_QUERY_TIMEOUT: &str = "querytimeout"; +/// ODBC-standard alias for [`PARAM_QUERY_TIMEOUT`]. +pub(crate) const PARAM_LOGIN_TIMEOUT: &str = "logintimeout"; +/// Catalog the session starts in. A `USE` statement moves it from there. +pub(crate) const PARAM_CATALOG: &str = "catalog"; +/// Schema the session starts in, against which unqualified names resolve. +pub(crate) const PARAM_SCHEMA: &str = "schema"; +/// Name this connection reports as Trino's query source. +pub(crate) const PARAM_SOURCE: &str = "source"; +/// Comma-separated Trino client tags, which select a resource group. +pub(crate) const PARAM_CLIENT_TAGS: &str = "clienttags"; +/// Trino JWT bearer token (sent as `Authorization: Bearer <token>`). +pub(crate) const PARAM_ACCESS_TOKEN: &str = "accesstoken"; +/// Alias for [`PARAM_ACCESS_TOKEN`]. +pub(crate) const PARAM_TOKEN: &str = "token"; +/// Trino session properties, in JDBC's `name:value;name2:value2` form. +pub(crate) const PARAM_SESSION_PROPERTIES: &str = "sessionproperties"; +/// Connector-level credentials passed through to the data source, in JDBC's +/// `name:value;name2:value2` form. Carries secrets. +pub(crate) const PARAM_EXTRA_CREDENTIALS: &str = "extracredentials"; +/// Scheduling hints, in the same `name:value;name2:value2` form. +pub(crate) const PARAM_RESOURCE_ESTIMATES: &str = "resourceestimates"; +/// Default SQL path for resolving unqualified function names. +pub(crate) const PARAM_PATH: &str = "path"; +/// Free-form client metadata Trino records against the query. +pub(crate) const PARAM_CLIENT_INFO: &str = "clientinfo"; +/// Correlation token Trino records against the query. +pub(crate) const PARAM_TRACE_TOKEN: &str = "tracetoken"; +/// HTTP or HTTPS proxy every request is routed through, as a URL. +/// +/// `socks5://` is rejected: routing through SOCKS needs a `reqwest` feature +/// `trino-rust-client` does not enable, and the client says so when the +/// connection is built rather than failing later at connect time. +/// +/// Credentials belong in [`PARAM_PROXY_USER`] and [`PARAM_PROXY_PASSWORD`], not +/// in the URL's userinfo; see [`proxy_url`]. +pub(crate) const PARAM_PROXY: &str = "proxy"; +/// Username for a proxy that demands HTTP Basic authentication. +pub(crate) const PARAM_PROXY_USER: &str = "proxyuser"; +/// Password for [`PARAM_PROXY_USER`]. **Secret.** +pub(crate) const PARAM_PROXY_PASSWORD: &str = "proxypassword"; +/// Extra HTTP headers on every request, in the same `name:value;name2:value2` +/// form as the other key-value keys. +/// +/// For gateways and reverse proxies that require one. A name the client already +/// manages is rejected when the client is built, because `reqwest` appends +/// rather than replaces and the request would carry two values for it. +/// +/// **Secret.** A gateway header routinely carries an API key, and nothing in +/// the name tells the driver whether this one does, so the whole value is +/// declared in `Backend::sensitive_connect_keywords`. +pub(crate) const PARAM_EXTRA_HEADERS: &str = "extraheaders"; +/// Comma-separated Trino client capabilities, on top of the two the client +/// always sends. +/// +/// `PARAMETRIC_DATETIME` and `PATH` are sent unconditionally and cannot be +/// dropped: the client's own type decoder depends on both. +pub(crate) const PARAM_CLIENT_CAPABILITIES: &str = "clientcapabilities"; +/// IANA time zone the session runs in, sent as `X-Trino-Time-Zone`. +/// +/// Trino resolves `current_timestamp`, `TIMESTAMP WITH TIME ZONE` literals and +/// every `AT TIME ZONE` against the session zone, so leaving it unset means +/// those follow whatever zone the coordinator's JVM happens to be in, which is +/// a property of the server, not of the query. +pub(crate) const PARAM_TIME_ZONE: &str = "timezone"; +/// Authorisation role per catalog, in the same `name:value;name2:value2` form +/// as the other key-value keys: `Roles={hive:admin;iceberg:ALL}`. +/// +/// The value is a role name, or the keyword `ALL` or `NONE`, which is the shape +/// JDBC's `roles` property takes. Trino's own `X-Trino-Role` spelling wraps a +/// name in braces (`ROLE{admin}`), and that would collide with the braces the +/// connection string already needs around a `;`-separated value, so the name is +/// written bare here and [`selected_role`] renders the wire form. +/// +/// Roles are what Hive and Iceberg under `sql-standard` security check, and +/// therefore what decides whether `SQLTablePrivileges` returns a row. +pub(crate) const PARAM_ROLES: &str = "roles"; +/// User the statements run as, while authentication stays with the `User` +/// connection-string keyword, which core defines and `ConnectParams` carries. +/// +/// JDBC spells it `sessionUser`. Trino sends it as `X-Trino-User`, so the +/// coordinator applies the impersonated user's permissions and records it +/// against the query, while the connection still authenticates as the +/// principal in `User`. +pub(crate) const PARAM_SESSION_USER: &str = "sessionuser"; +/// Locale Trino formats locale-dependent values in, sent as +/// `X-Trino-Language`. +pub(crate) const PARAM_LOCALE: &str = "locale"; +/// Disable HTTP response compression: `"true"` or `"false"` (default). +pub(crate) const PARAM_DISABLE_COMPRESSION: &str = "disablecompression"; +/// How many times a request is attempted before it fails. +pub(crate) const PARAM_MAX_ATTEMPTS: &str = "maxattempts"; +/// Advertise Trino's spooled protocol with this query-data encoding: `"json"`, +/// `"json+zstd"` or `"json+lz4"`. JDBC spells it `encoding`. +/// +/// Unset sends no `X-Trino-Query-Data-Encoding` header, so the coordinator +/// returns every row inline. Off by default because +/// `protocol.spooling.retrieval-mode=storage` has the *client* fetch segments +/// straight from object storage, and a workstation that cannot reach the bucket +/// would fail queries that succeed without the key. A coordinator that does not +/// support the requested encoding ignores the header and answers inline, so +/// setting it can never fail a connection. +pub(crate) const PARAM_ENCODING: &str = "encoding"; +/// Authenticate with Trino's interactive OAuth 2.0 external-authentication +/// flow: `"true"` or `"false"` (default). JDBC's `externalAuthentication`. +/// +/// Needs `Protocol=https`, and cannot be combined with `Password` or +/// [`PARAM_ACCESS_TOKEN`]. Interactive by nature, since a person has to visit +/// the login URL the driver presents, so it is refused on a connection made with +/// `SQL_DRIVER_NOPROMPT`. Unattended callers use [`PARAM_ACCESS_TOKEN`]. +/// +/// `User` is optional here, and omitting it leaves `X-Trino-User` off the +/// request entirely, so Trino takes the identity from the token. A `User` that +/// disagrees with the provider's mapping reads as an impersonation request and +/// is refused, which is why no value is invented for it. +pub(crate) const PARAM_EXTERNAL_AUTHENTICATION: &str = "externalauthentication"; +/// Whole budget for one interactive login, in seconds. Default +/// [`DEFAULT_EXTERNAL_AUTH_TIMEOUT_SECS`]. JDBC's +/// `externalAuthenticationTimeout`, which counts minutes rather than seconds. +/// +/// Separate from `SQL_ATTR_LOGIN_TIMEOUT`, which does **not** bound this wait. +/// Applications set login timeouts assuming a machine round trip, and a tool +/// defaulting to 15s would otherwise abort every login while the user was +/// still typing their password. +pub(crate) const PARAM_EXTERNAL_AUTH_TIMEOUT: &str = "externalauthenticationtimeout"; + +/// Separator between the pairs of a key-value connection-string parameter. +/// +/// `;` is JDBC's, and it is also the ODBC connection-string separator, so a +/// value using it has to be `{}`-wrapped, which core's parser supports and +/// [`parse_key_value_pairs`] documents. That is the price of matching JDBC, +/// which lets the value an operator already has in a JDBC URL transfer +/// unchanged. +const PAIR_SEPARATOR: char = ';'; + +/// Separator between a key and its value, JDBC's again. +/// +/// `:` rather than `=`, which means a value may contain `=` without escaping. +/// Only the *first* occurrence splits, so a value may contain `:` too, which +/// matters, because a session property value is routinely a URL or a duration. +const KEY_VALUE_SEPARATOR: char = ':'; + +/// Transport used when the connection string names none. +/// +/// `https`, so that an unencrypted connection is something an application +/// asked for rather than something it got by staying silent. A coordinator +/// serving plaintext needs an explicit `Protocol=http`. +const DEFAULT_PROTOCOL: &str = "https"; +const DEFAULT_QUERY_TIMEOUT_SECS: u64 = 30; + +/// Default budget for one interactive OAuth 2.0 login. +/// +/// Matches `trino-rust-client`'s own default, which is what the flow gets when +/// [`PARAM_EXTERNAL_AUTH_TIMEOUT`] is unset. Generous on purpose: it bounds a +/// person finding a browser window, signing in, and possibly completing a +/// second factor. +const DEFAULT_EXTERNAL_AUTH_TIMEOUT_SECS: u64 = 300; + +/// Query source reported when the connection string names none. +/// +/// Trino shows this in its query history and can route on it, so a default +/// that names the driver is what lets an operator tell this driver's traffic +/// apart. `ClientBuilder::new` would otherwise leave it as the client +/// library's own name, which identifies neither the driver nor the +/// application. +/// +/// The Cargo version rides along, `name/version`, matching how HTTP +/// user-agents are conventionally spelled. An operator reading the query log +/// after a rollout can then tell one driver build from another, which is +/// exactly when the question gets asked. +pub(crate) const DEFAULT_SOURCE: &str = concat!("stackable-odbc-trino/", env!("CARGO_PKG_VERSION")); + +/// Parse a `name:value;name2:value2` parameter into a map. +/// +/// The form is the Trino JDBC driver's for `sessionProperties` and +/// `extraCredentials`, so a value copied from a JDBC URL works unchanged. It +/// does need `{}` around it in an ODBC connection string, because `;` is what +/// separates one connection-string parameter from the next: +/// +/// ```text +/// SessionProperties={query_max_run_time:10m;example.foo:bar} +/// ``` +/// +/// Without the braces core's parser ends the value at the first `;` and treats +/// the rest as another parameter, which it then discards as unrecognised, +/// silently dropping every property but the first. +/// +/// A malformed pair is an error rather than a skip. A dropped session property +/// changes how the query runs, and an operator who mistyped one would otherwise +/// see a plausible result computed under settings they did not ask for. +fn parse_key_value_pairs(key: &str, raw: &str) -> Result<HashMap<String, String>, TrinoError> { + let mut map = HashMap::new(); + + for pair in raw.split(PAIR_SEPARATOR) { + // Trailing or doubled separators are the shape a hand-edited string + // takes; they carry no pair and no ambiguity, so they are skipped + // rather than rejected. + if pair.trim().is_empty() { + continue; + } + let (name, value) = + pair.split_once(KEY_VALUE_SEPARATOR) + .ok_or_else(|| TrinoError::General { + message: format!( + "invalid value for {key}: {pair:?} is not \ + \"name{KEY_VALUE_SEPARATOR}value\". Pairs are separated by \ + {PAIR_SEPARATOR:?}, so the whole value needs {{braces}} in a \ + connection string" + ), + })?; + + let name = name.trim(); + if name.is_empty() { + return Err(TrinoError::General { + message: format!("invalid value for {key}: {pair:?} has an empty name"), + }); + } + map.insert(name.to_string(), value.trim().to_string()); + } + + Ok(map) +} + +/// Validate a proxy URL, rejecting credentials written into its userinfo. +/// +/// `http://user:pass@proxy:3128` is a natural thing to write and would put a +/// password somewhere the driver cannot redact: the whole value is one +/// connection-string key, and `SQLDriverConnect` echoes back every key it was +/// not told is sensitive. Splitting the credentials into their own keys is what +/// lets [`PARAM_PROXY_PASSWORD`] be declared and the URL stay readable in a +/// diagnostic, so userinfo is an error naming the two keys, rather than a +/// silent leak. +/// +/// The scheme is left to `Proxy::all`, which rejects anything but http and +/// https and phrases it better than a second check here would. +fn proxy_url(raw: &str) -> Result<String, TrinoError> { + // Only the authority can carry userinfo, and it ends at the first `/`, + // `?` or `#` after the scheme, so a path or query containing `@` is not + // mistaken for one. + let after_scheme = raw.split_once("://").map_or(raw, |(_, rest)| rest); + let authority = after_scheme + .split(['/', '?', '#']) + .next() + .unwrap_or(after_scheme); + if authority.contains('@') { + return Err(TrinoError::General { + message: format!( + "invalid value for {PARAM_PROXY}: credentials in the URL are not \ + accepted, because the whole value is echoed back by \ + SQLDriverConnect. Use {PARAM_PROXY_USER} and \ + {PARAM_PROXY_PASSWORD} instead" + ), + }); + } + Ok(raw.to_string()) +} + +/// A connection-string role value as `X-Trino-Role` spells it. +/// +/// `ALL` and `NONE` are Trino's two keywords and are matched case-insensitively, +/// the way every other connection-string value is. Anything else is a role +/// name, which the wire format wraps as `ROLE{name}`, done here rather than +/// asked of the operator, because the braces would have to be escaped past +/// core's connection-string parser to survive. +fn selected_role(value: &str) -> SelectedRole { + match value.to_ascii_uppercase().as_str() { + "ALL" => SelectedRole::new(RoleType::All, None), + "NONE" => SelectedRole::new(RoleType::None, None), + _ => SelectedRole::new(RoleType::Role, Some(value.to_string())), + } +} + +/// Resolve `TlsVerify` and its JDBC alias `SSLVerification` into one value. +/// +/// Both spellings accept both vocabularies, so `TlsVerify=CA` and +/// `SSLVerification=false` are as valid as the pairings you would expect, +/// there is no sense in which one key owns one set of words, and rejecting a +/// mixed pairing would only surprise. +/// +/// Setting both keys is an error unless they resolve to the same thing. They +/// are one setting under two names, and silently preferring either would leave +/// the other looking honoured when it was not, for a value whose failure mode +/// is an unauthenticated connection. +fn parse_tls_verification( + tls_verify: Option<&str>, + ssl_verification: Option<&str>, +) -> Result<TlsVerification, TrinoError> { + let parse_one = |key: &str, raw: &str| match raw { + v if v.eq_ignore_ascii_case("true") || v.eq_ignore_ascii_case("full") => { + Ok(TlsVerification::Full) + } + v if v.eq_ignore_ascii_case("ca") => Ok(TlsVerification::CaOnly), + v if v.eq_ignore_ascii_case("false") || v.eq_ignore_ascii_case("none") => { + Ok(TlsVerification::None) + } + v => Err(TrinoError::General { + message: format!( + "invalid value for {key}: {v:?}, expected \"true\"/\"full\", \"ca\", \ + or \"false\"/\"none\"" + ), + }), + }; + + match (tls_verify, ssl_verification) { + (None, None) => Ok(TlsVerification::Full), + (Some(v), None) => parse_one(PARAM_TLS_VERIFY, v), + (None, Some(v)) => parse_one(PARAM_SSL_VERIFICATION, v), + (Some(a), Some(b)) => { + let (a, b) = ( + parse_one(PARAM_TLS_VERIFY, a)?, + parse_one(PARAM_SSL_VERIFICATION, b)?, + ); + if a == b { + Ok(a) + } else { + Err(TrinoError::General { + message: format!( + "{PARAM_TLS_VERIFY} and {PARAM_SSL_VERIFICATION} are the same \ + setting and were given different values ({a:?} and {b:?}); \ + set only one" + ), + }) + } + } + } +} + +/// Parse a `"true"` / `"false"` parameter, case-insensitively. +/// +/// Rejects anything else rather than defaulting: every boolean here turns a +/// protection or a behaviour off, and a typo silently reading as "leave it on" +/// is the failure mode `TlsVerify` already guards against. +fn parse_bool(key: &str, raw: &str) -> Result<bool, TrinoError> { + match raw { + v if v.eq_ignore_ascii_case("true") => Ok(true), + v if v.eq_ignore_ascii_case("false") => Ok(false), + v => Err(TrinoError::General { + message: format!("invalid value for {key}: {v:?}, expected \"true\" or \"false\""), + }), + } +} + +/// Parsed and validated Trino connection parameters. +#[derive(Debug)] +pub(crate) struct TrinoConnectParams { + host: String, + port: u16, + /// `None` only under `ExternalAuthentication`, where the identity provider + /// supplies it and `X-Trino-User` is left off entirely. + user: Option<String>, + password: Redacted<Option<String>>, + access_token: Redacted<Option<String>>, + secure: bool, + tls_verification: TlsVerification, + certificate: Option<String>, + client_certificate: Option<String>, + query_timeout: Duration, + catalog: Option<String>, + schema: Option<String>, + source: String, + client_tags: HashSet<String>, + session_properties: HashMap<String, String>, + /// Redacted for the same reason as `password`: these are credentials the + /// connection forwards to a connector, and `Debug` on this struct reaches + /// the log. + extra_credentials: Redacted<HashMap<String, String>>, + resource_estimates: HashMap<String, String>, + path: Option<String>, + client_info: Option<String>, + trace_token: Option<String>, + session_user: Option<String>, + locale: Option<String>, + roles: HashMap<String, SelectedRole>, + time_zone: Option<Tz>, + /// Redacted for the same reason as `extra_credentials`: a gateway header + /// is a plausible place for an API key, and `Debug` reaches the log. + extra_headers: Redacted<HashMap<String, String>>, + client_capabilities: HashSet<String>, + proxy: Option<String>, + proxy_user: Option<String>, + proxy_password: Redacted<Option<String>>, + external_authentication: bool, + external_auth_timeout: Duration, + compression_disabled: bool, + max_attempts: Option<usize>, + spooling_encoding: Option<SpoolingEncoding>, +} + +impl TrinoConnectParams { + pub fn host(&self) -> &str { + &self.host + } + + pub fn port(&self) -> u16 { + self.port + } + + /// `None` leaves `X-Trino-User` off, which only `ExternalAuthentication` + /// permits; see the field. + pub fn user(&self) -> Option<&str> { + self.user.as_deref() + } + + pub fn password(&self) -> Option<&str> { + self.password.0.as_deref() + } + + pub fn access_token(&self) -> Option<&str> { + self.access_token.0.as_deref() + } + + pub fn secure(&self) -> bool { + self.secure + } + + pub fn source(&self) -> &str { + &self.source + } + + pub fn client_tags(&self) -> &HashSet<String> { + &self.client_tags + } + + pub fn tls_verification(&self) -> TlsVerification { + self.tls_verification + } + + /// PEM holding a client certificate chain and its PKCS#8 key, for mutual + /// TLS. See [`PARAM_CLIENT_CERTIFICATE`]. + pub fn client_certificate(&self) -> Option<&str> { + self.client_certificate.as_deref() + } + + pub fn certificate(&self) -> Option<&str> { + self.certificate.as_deref() + } + + pub fn query_timeout(&self) -> Duration { + self.query_timeout + } + + pub fn catalog(&self) -> Option<&str> { + self.catalog.as_deref() + } + + pub fn session_properties(&self) -> &HashMap<String, String> { + &self.session_properties + } + + pub fn extra_credentials(&self) -> &HashMap<String, String> { + &self.extra_credentials.0 + } + + pub fn resource_estimates(&self) -> &HashMap<String, String> { + &self.resource_estimates + } + + pub fn path(&self) -> Option<&str> { + self.path.as_deref() + } + + pub fn client_info(&self) -> Option<&str> { + self.client_info.as_deref() + } + + pub fn trace_token(&self) -> Option<&str> { + self.trace_token.as_deref() + } + + /// The user statements run as, when it differs from the authenticating one. + pub fn session_user(&self) -> Option<&str> { + self.session_user.as_deref() + } + + pub fn locale(&self) -> Option<&str> { + self.locale.as_deref() + } + + /// Authorisation role per catalog. Empty leaves the coordinator's default, + /// which for `sql-standard` security is no role at all. + pub fn roles(&self) -> &HashMap<String, SelectedRole> { + &self.roles + } + + /// `None` leaves the coordinator's own zone in force. + pub fn time_zone(&self) -> Option<Tz> { + self.time_zone + } + + pub fn extra_headers(&self) -> &HashMap<String, String> { + &self.extra_headers.0 + } + + /// `None` routes directly, which is `reqwest`'s own default: this driver + /// does not read the `HTTP_PROXY` environment. + pub fn proxy(&self) -> Option<&str> { + self.proxy.as_deref() + } + + /// Whether to authenticate with the interactive OAuth 2.0 flow. + pub fn external_authentication(&self) -> bool { + self.external_authentication + } + + /// Budget for one interactive login. Meaningless unless + /// [`Self::external_authentication`] is set. + pub fn external_auth_timeout(&self) -> Duration { + self.external_auth_timeout + } + + /// The proxy's Basic credentials, both or neither. + pub fn proxy_credentials(&self) -> Option<(&str, &str)> { + match (self.proxy_user.as_deref(), self.proxy_password.0.as_deref()) { + (Some(user), Some(password)) => Some((user, password)), + _ => None, + } + } + + /// Capabilities on top of the two the client always sends. + pub fn client_capabilities(&self) -> &HashSet<String> { + &self.client_capabilities + } + + pub fn compression_disabled(&self) -> bool { + self.compression_disabled + } + + /// `None` leaves `trino-rust-client`'s own retry budget in place, which is + /// the honest default: the driver has no better number than the client's. + pub fn max_attempts(&self) -> Option<usize> { + self.max_attempts + } + + /// `None` sends no encoding header, which is the coordinator's inline + /// protocol. + pub fn spooling_encoding(&self) -> Option<SpoolingEncoding> { + self.spooling_encoding + } + + pub fn schema(&self) -> Option<&str> { + self.schema.as_deref() + } +} + +impl TryFrom<&ConnectParams> for TrinoConnectParams { + type Error = TrinoError; + + fn try_from(params: &ConnectParams) -> Result<Self, TrinoError> { + let host = params + .get(PARAM_HOST) + .ok_or_else(|| TrinoError::MissingParam { + name: PARAM_HOST.into(), + })?; + let port_str = params + .get(PARAM_PORT) + .ok_or_else(|| TrinoError::MissingParam { + name: PARAM_PORT.into(), + })?; + let port: u16 = port_str.parse().map_err(|_| TrinoError::General { + message: format!("invalid port: {port_str}"), + })?; + let external_authentication = match params.get(PARAM_EXTERNAL_AUTHENTICATION) { + None => false, + Some(v) => parse_bool(PARAM_EXTERNAL_AUTHENTICATION, v)?, + }; + // Required, except when the identity provider decides who you are. + // Trino takes the user from the authenticated identity when + // `X-Trino-User` is absent, and reads one that *disagrees* with that + // identity as an impersonation request, so under + // `ExternalAuthentication` a name we asked the operator to invent would + // be refused for their own account. `SessionUser` is how impersonation + // is asked for explicitly. + let user = match params.user() { + Ok(u) => Some(u.to_string()), + Err(_) if external_authentication => None, + Err(_) => { + return Err(TrinoError::MissingParam { + name: "user".into(), + }); + } + }; + let password = params.password().map(str::to_string); + let access_token = params + .get(PARAM_ACCESS_TOKEN) + .or_else(|| params.get(PARAM_TOKEN)) + .map(str::to_string); + // Matched case-insensitively and validated: an unrecognised or + // differently-cased value must not silently fall back to plaintext, + // because the password is only sent over a secure transport. + let secure = match params.get(PARAM_PROTOCOL).unwrap_or(DEFAULT_PROTOCOL) { + v if v.eq_ignore_ascii_case("https") => true, + v if v.eq_ignore_ascii_case("http") => false, + v => { + return Err(TrinoError::General { + message: format!( + "invalid value for {PARAM_PROTOCOL}: {v:?}, expected \"http\" or \"https\"" + ), + }); + } + }; + let tls_verification = parse_tls_verification( + params.get(PARAM_TLS_VERIFY), + params.get(PARAM_SSL_VERIFICATION), + )?; + let certificate = params.get(PARAM_CERTIFICATE).map(str::to_string); + let client_certificate = params.get(PARAM_CLIENT_CERTIFICATE).map(str::to_string); + // Both certificate paths are inert over plain HTTP: `connect` applies + // the TLS group only when the transport is secure, so neither file is + // opened and an unreadable or wrong-CA path goes unreported. Set + // alongside `Protocol=http` they are a request the driver cannot + // honour, and one whose silent version is dangerous: an operator who + // wrote `Certificate=<pem>` believes the coordinator is being verified + // against it, and an operator who wrote `ClientCertificate=<pem>` + // believes they are presenting that identity. + // + // Rejected rather than warned, matching `ProxyUser` without `Proxy`, + // `ExternalAuthentication` without https, and a mistyped `TimeZone`; a + // `tracing::warn!` is not a channel an application sees. + // + // `TlsVerify` and `SSLVerification` are deliberately *not* rejected + // here. They are equally inert, but harmlessly so: http is unverified + // whatever they say, so neither can create a false belief the protocol + // has not already created. They are also written unconditionally by + // `packaging/windows/configure-dsn.ps1`, whose Enum fields always carry + // their default, so every dialog-written DSN names one and rejecting it + // would refuse plaintext data sources the dialog itself produces. + if !secure { + let named: Vec<&str> = [ + (PARAM_CERTIFICATE, certificate.is_some()), + (PARAM_CLIENT_CERTIFICATE, client_certificate.is_some()), + ] + .into_iter() + .filter_map(|(key, present)| present.then_some(key)) + .collect(); + if !named.is_empty() { + return Err(TrinoError::General { + message: format!( + "{} cannot be combined with {PARAM_PROTOCOL}=http: there is no TLS \ + session for it to apply to, so it would be read from nowhere and \ + verify nothing. Remove it, or set {PARAM_PROTOCOL}=https", + named.join(" and ") + ), + }); + } + } + // rustls only permits skipping hostname verification when the trust + // store is supplied explicitly, which excludes the platform's own + // roots, so `CaOnly` without a root certificate would trust nothing + // at all. The client reports this at build time; catching it here names + // the two connection-string keys instead of the builder methods. + if tls_verification == TlsVerification::CaOnly && certificate.is_none() { + return Err(TrinoError::General { + message: format!( + "{PARAM_TLS_VERIFY}=ca verifies the certificate chain but not the \ + hostname, and needs the chain to verify against: supply \ + {PARAM_CERTIFICATE}=<pem>" + ), + }); + } + + let pairs = |key: &'static str| match params.get(key) { + None => Ok(HashMap::new()), + Some(raw) => parse_key_value_pairs(key, raw), + }; + let session_properties = pairs(PARAM_SESSION_PROPERTIES)?; + let extra_credentials = pairs(PARAM_EXTRA_CREDENTIALS)?; + let extra_headers = pairs(PARAM_EXTRA_HEADERS)?; + + let proxy = match params.get(PARAM_PROXY) { + None => None, + Some(raw) => Some(proxy_url(raw)?), + }; + let proxy_user = params.get(PARAM_PROXY_USER).map(str::to_string); + let proxy_password = params.get(PARAM_PROXY_PASSWORD).map(str::to_string); + // Half a credential authenticates to nothing. Rejected rather than + // dropped, because the connection would then fail at the proxy with a + // 407 that names neither key. + if proxy_user.is_some() != proxy_password.is_some() { + return Err(TrinoError::General { + message: format!( + "{PARAM_PROXY_USER} and {PARAM_PROXY_PASSWORD} must be set together" + ), + }); + } + if proxy.is_none() && proxy_user.is_some() { + return Err(TrinoError::General { + message: format!("{PARAM_PROXY_USER} was set without {PARAM_PROXY}"), + }); + } + let resource_estimates = pairs(PARAM_RESOURCE_ESTIMATES)?; + let roles = pairs(PARAM_ROLES)? + .into_iter() + .map(|(catalog, role)| (catalog, selected_role(&role))) + .collect(); + + // Rejected rather than ignored. A zone the operator mistyped would + // otherwise leave every `current_timestamp` and `AT TIME ZONE` on the + // coordinator's own zone, which is a plausible-looking answer that is + // silently hours out. + let time_zone = match params.get(PARAM_TIME_ZONE) { + None => None, + Some(raw) => Some(raw.parse::<Tz>().map_err(|_| TrinoError::General { + message: format!( + "invalid value for {PARAM_TIME_ZONE}: {raw:?} is not an IANA \ + time zone name, such as \"Europe/Berlin\" or \"UTC\"" + ), + })?), + }; + + let compression_disabled = match params.get(PARAM_DISABLE_COMPRESSION) { + None => false, + Some(v) => parse_bool(PARAM_DISABLE_COMPRESSION, v)?, + }; + + // Lower-cased before matching, like every other value this parser + // accepts: core matches the key case-insensitively, and a DSN written + // `Encoding=JSON+ZSTD` means the same thing. + let spooling_encoding = match params.get(PARAM_ENCODING) { + None => None, + Some(raw) => Some( + SpoolingEncoding::try_from(raw.to_ascii_lowercase().as_str()).map_err(|_| { + TrinoError::General { + message: format!( + "invalid value for {PARAM_ENCODING}: {raw:?}, expected \ + \"json\", \"json+zstd\" or \"json+lz4\"" + ), + } + })?, + ), + }; + + // Rejected rather than defaulted, like `MaxAttempts` below: a login + // budget quietly reverting to 300s is invisible until someone is sitting + // in front of a browser wondering why the connection gave up early, + // or did not give up at all. + let external_auth_timeout = match params.get(PARAM_EXTERNAL_AUTH_TIMEOUT) { + None => DEFAULT_EXTERNAL_AUTH_TIMEOUT_SECS, + Some(v) => match v.parse::<u64>() { + // Zero would mean the flow times out before the browser opens. + Ok(0) | Err(_) => { + return Err(TrinoError::General { + message: format!( + "invalid value for {PARAM_EXTERNAL_AUTH_TIMEOUT}: {v:?}, \ + expected a positive number of seconds" + ), + }); + } + Ok(n) => n, + }, + }; + + // Rejected rather than defaulted, as every numeric key here is. + // Telling the operator the value never took effect is the better + // answer: a retry budget silently reverting to the client's default is + // invisible until a flaky network makes it matter. + // + // `0` differs between the three. It is refused here and for + // `ExternalAuthenticationTimeout`, where "never send the request" and + // "time out before the browser opens" are not budgets an application + // can have meant, and accepted for `QueryTimeout`, where the spec gives + // it the meaning "no timeout". + let max_attempts = match params.get(PARAM_MAX_ATTEMPTS) { + None => None, + Some(v) => match v.parse::<usize>() { + // Zero attempts would mean "never send the request", which is + // not a budget an application can have meant. + Ok(0) | Err(_) => { + return Err(TrinoError::General { + message: format!( + "invalid value for {PARAM_MAX_ATTEMPTS}: {v:?}, \ + expected a positive integer" + ), + }); + } + Ok(n) => Some(n), + }, + }; + // Rejected rather than defaulted, like `MaxAttempts` and + // `ExternalAuthenticationTimeout` above. A timeout that silently + // reverted to 30s used to be reported only through `tracing::warn!`, + // which is not a channel an application sees: the value looked applied + // and was not, and the symptom arrived much later as a query that gave + // up at a time nobody configured. + // + // An empty value is unset rather than invalid, which is the one + // concession to the ODBC-standard `LoginTimeout` spelling: a DSN editor + // that writes every keyword it knows leaves an untouched field blank, + // and refusing to connect over that would be a regression for data + // sources this driver did not write. `Source` treats blank the same way. + // + // `0` stays legal and is not the default: it is the spec's "no + // timeout", turned into a duration by `backend::request_timeout`. + let timeout_key = params + .get(PARAM_QUERY_TIMEOUT) + .map(|v| (PARAM_QUERY_TIMEOUT, v)) + .or_else(|| { + params + .get(PARAM_LOGIN_TIMEOUT) + .map(|v| (PARAM_LOGIN_TIMEOUT, v)) + }) + .filter(|(_, v)| !v.trim().is_empty()); + let query_timeout_secs: u64 = match timeout_key { + None => DEFAULT_QUERY_TIMEOUT_SECS, + Some((key, v)) => v.trim().parse().map_err(|_| TrinoError::General { + message: format!( + "invalid value for {key}: {v:?}, expected a whole number of \ + seconds ({key}=0 disables the timeout)" + ), + })?, + }; + + Ok(TrinoConnectParams { + host: host.to_string(), + port, + user, + password: Redacted(password), + access_token: Redacted(access_token), + secure, + tls_verification, + certificate, + client_certificate, + query_timeout: Duration::from_secs(query_timeout_secs), + catalog: params.get(PARAM_CATALOG).map(str::to_string), + schema: params.get(PARAM_SCHEMA).map(str::to_string), + source: params + .get(PARAM_SOURCE) + .filter(|s| !s.trim().is_empty()) + .unwrap_or(DEFAULT_SOURCE) + .to_string(), + // Trino matches resource-group selectors against whole tags, so + // surrounding space would make " bi" miss a rule written for "bi". + // An empty element is dropped rather than sent as an empty tag. + client_tags: params + .get(PARAM_CLIENT_TAGS) + .map(|raw| { + raw.split(',') + .map(str::trim) + .filter(|tag| !tag.is_empty()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(), + session_properties, + extra_credentials: Redacted(extra_credentials), + resource_estimates, + path: params.get(PARAM_PATH).map(str::to_string), + client_info: params.get(PARAM_CLIENT_INFO).map(str::to_string), + trace_token: params.get(PARAM_TRACE_TOKEN).map(str::to_string), + session_user: params.get(PARAM_SESSION_USER).map(str::to_string), + locale: params.get(PARAM_LOCALE).map(str::to_string), + roles, + time_zone, + extra_headers: Redacted(extra_headers), + proxy, + proxy_user, + proxy_password: Redacted(proxy_password), + // Split like `client_tags`, and for the same reasons: trimmed so a + // written-out list does not send " FOO", and empty elements dropped + // rather than sent as an empty capability. + client_capabilities: params + .get(PARAM_CLIENT_CAPABILITIES) + .map(|raw| { + raw.split(',') + .map(str::trim) + .filter(|cap| !cap.is_empty()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default(), + external_authentication, + external_auth_timeout: Duration::from_secs(external_auth_timeout), + compression_disabled, + max_attempts, + spooling_encoding, + }) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn parse(s: &str) -> TrinoConnectParams { + let params = ConnectParams::parse(s).unwrap(); + TrinoConnectParams::try_from(&params).unwrap() + } + + fn parse_err(s: &str) -> TrinoError { + let params = ConnectParams::parse(s).unwrap(); + TrinoConnectParams::try_from(&params).unwrap_err() + } + + // ----------------------------------------------------------------------- + // User, and when it may be left out + // ----------------------------------------------------------------------- + + /// Trino takes the user from the authenticated identity when the header is + /// absent, and reads one that *disagrees* with that identity as an + /// impersonation request, so under `ExternalAuthentication` the operator + /// must not have to invent a name that would then be refused. + #[test] + fn user_may_be_omitted_under_external_authentication() { + let p = parse("Host=localhost;Port=8443;Protocol=https;ExternalAuthentication=true"); + assert_eq!(p.user(), None); + assert!(p.external_authentication()); + } + + /// The interactive flow does not stop an operator naming a user, and one + /// that matches the provider's mapping is harmless, so it is still read. + #[test] + fn user_is_kept_when_given_alongside_external_authentication() { + let p = + parse("Host=localhost;Port=8443;Protocol=https;ExternalAuthentication=true;User=alice"); + assert_eq!(p.user(), Some("alice")); + } + + /// Without the interactive flow nothing else establishes an identity, so + /// omitting it would reach Trino as `User must be set`. + #[test] + fn user_is_still_required_without_external_authentication() { + let e = parse_err("Host=localhost;Port=8080"); + assert!( + matches!(&e, TrinoError::MissingParam { name } if name == "user"), + "got {e:?}" + ); + } + + #[test] + fn the_external_auth_timeout_defaults_and_rejects_nonsense() { + let base = "Host=localhost;Port=8443;Protocol=https;ExternalAuthentication=true"; + assert_eq!( + parse(base).external_auth_timeout(), + Duration::from_secs(DEFAULT_EXTERNAL_AUTH_TIMEOUT_SECS) + ); + assert_eq!( + parse(&format!("{base};ExternalAuthenticationTimeout=90")).external_auth_timeout(), + Duration::from_secs(90) + ); + // Zero would time the flow out before the browser opened. + for bad in ["0", "-1", "soon"] { + let e = parse_err(&format!("{base};ExternalAuthenticationTimeout={bad}")); + assert!( + matches!(e, TrinoError::General { .. }), + "{bad:?} must be rejected, got {e:?}" + ); + } + } + + #[test] + fn source_defaults_to_the_driver_name_and_version() { + // Trino's query history shows this. Left unset, every query from this + // driver is indistinguishable from any other client's, and without + // the version, one driver build is indistinguishable from another, + // which is what an operator needs when a regression appears in the + // query log after a rollout. + let p = parse("Host=localhost;Port=8080;User=admin"); + assert_eq!( + p.source(), + format!("stackable-odbc-trino/{}", env!("CARGO_PKG_VERSION")) + ); + } + + #[test] + fn source_can_be_overridden() { + let p = parse("Host=localhost;Port=8080;User=admin;Source=powerbi"); + assert_eq!(p.source(), "powerbi"); + } + + #[test] + fn client_tags_are_split_on_commas_and_trimmed() { + // Trino selects a resource group from these, so " bi , adhoc " has to + // reach the coordinator as two clean tags, not one padded string. + let p = parse("Host=localhost;Port=8080;User=admin;ClientTags= bi , adhoc "); + let mut tags: Vec<&str> = p.client_tags().iter().map(String::as_str).collect(); + tags.sort_unstable(); + assert_eq!(tags, vec!["adhoc", "bi"]); + } + + #[test] + fn client_tags_are_empty_when_unset() { + let p = parse("Host=localhost;Port=8080;User=admin"); + assert!(p.client_tags().is_empty()); + } + + /// The `{}` wrapping is not optional and not cosmetic: `;` separates one + /// connection-string parameter from the next, so an unbraced multi-pair + /// value is truncated at the first `;` by core's parser before this code + /// ever sees it. Both halves are asserted here so the requirement is + /// recorded as a behaviour rather than only in prose. + #[test] + fn session_properties_take_jdbcs_form_inside_braces() { + let p = parse( + "Host=localhost;Port=8080;User=admin;\ + SessionProperties={query_max_run_time:10m;example.foo:bar}", + ); + assert_eq!( + p.session_properties() + .get("query_max_run_time") + .map(String::as_str), + Some("10m") + ); + assert_eq!( + p.session_properties() + .get("example.foo") + .map(String::as_str), + Some("bar") + ); + } + + #[test] + fn session_properties_unbraced_keep_only_the_first_pair() { + let p = parse( + "Host=localhost;Port=8080;User=admin;\ + SessionProperties=query_max_run_time:10m;example.foo:bar", + ); + assert_eq!( + p.session_properties().len(), + 1, + "core's parser ends the value at the first ';', so the rest is a \ + separate (unrecognised) parameter: this is why braces are required" + ); + } + + /// Only the *first* separator splits, so a value may contain `:`, which + /// is not a corner case: `http://…` and `10:00` are ordinary property + /// values. + #[test] + fn a_property_value_may_contain_the_separator() { + let p = parse( + "Host=localhost;Port=8080;User=admin;\ + SessionProperties={exchange.base-directories:s3://bucket/path}", + ); + assert_eq!( + p.session_properties() + .get("exchange.base-directories") + .map(String::as_str), + Some("s3://bucket/path") + ); + } + + /// A dropped property changes how the query runs, so a typo has to fail + /// the connection rather than produce a plausible answer computed under + /// settings nobody asked for. + #[test] + fn a_malformed_pair_is_rejected() { + let err = + parse_err("Host=localhost;Port=8080;User=admin;SessionProperties={query_max_run_time}"); + let message = err.to_string(); + assert!( + message.contains("sessionproperties") && message.contains("query_max_run_time"), + "the error must name the key and the offending pair: {message}" + ); + } + + #[test] + fn an_empty_property_name_is_rejected() { + let err = parse_err("Host=localhost;Port=8080;User=admin;SessionProperties={:orphan}"); + assert!(err.to_string().contains("empty name"), "{err}"); + } + + #[test] + fn key_value_parameters_are_empty_when_unset() { + let p = parse("Host=localhost;Port=8080;User=admin"); + assert!(p.session_properties().is_empty()); + assert!(p.extra_credentials().is_empty()); + assert!(p.resource_estimates().is_empty()); + } + + #[test] + fn extra_credentials_and_resource_estimates_use_the_same_form() { + let p = parse( + "Host=localhost;Port=8080;User=admin;\ + ExtraCredentials={s3.token:abc123;kerberos:xyz};\ + ResourceEstimates={EXECUTION_TIME:1h}", + ); + assert_eq!(p.extra_credentials().len(), 2); + assert_eq!( + p.extra_credentials().get("s3.token").map(String::as_str), + Some("abc123") + ); + assert_eq!( + p.resource_estimates() + .get("EXECUTION_TIME") + .map(String::as_str), + Some("1h") + ); + } + + /// `Debug` on this struct reaches the log, and these are credentials being + /// forwarded to a connector. + #[test] + fn extra_credentials_are_redacted_in_debug() { + let p = parse("Host=localhost;Port=8080;User=admin;ExtraCredentials={s3.token:hunter2}"); + let rendered = format!("{p:?}"); + assert!( + !rendered.contains("hunter2"), + "the credential leaked into Debug output: {rendered}" + ); + } + + #[test] + fn the_plain_string_keys_round_trip() { + let p = parse( + "Host=localhost;Port=8080;User=admin;\ + Path=system.builtin;ClientInfo=dashboard-7;TraceToken=abc-123", + ); + assert_eq!(p.path(), Some("system.builtin")); + assert_eq!(p.client_info(), Some("dashboard-7")); + assert_eq!(p.trace_token(), Some("abc-123")); + } + + /// Impersonation is a second user alongside the authenticating one, not a + /// replacement for it: `User` still carries the credentials. + #[test] + fn session_user_is_separate_from_the_authenticating_user() { + let p = parse("Host=localhost;Port=8080;User=svc_bi;SessionUser=alice"); + assert_eq!(p.user(), Some("svc_bi")); + assert_eq!(p.session_user(), Some("alice")); + } + + #[test] + fn session_user_and_locale_are_unset_by_default() { + let p = parse("Host=localhost;Port=8080;User=admin"); + assert_eq!(p.session_user(), None); + assert_eq!(p.locale(), None); + } + + #[test] + fn locale_round_trips() { + let p = parse("Host=localhost;Port=8080;User=admin;Locale=de-DE"); + assert_eq!(p.locale(), Some("de-DE")); + } + + /// A bare name is a role name, and the two keywords are keywords. The + /// rendered form is what goes on the wire as `X-Trino-Role`, which is why + /// it is asserted rather than the enum: the braces are the part an operator + /// must not have to write. + #[test] + fn roles_render_the_wire_form_from_bare_names_and_keywords() { + let p = parse("Host=localhost;Port=8080;User=admin;Roles={hive:admin;iceberg:ALL;pg:none}"); + assert_eq!(p.roles()["hive"].to_string(), "ROLE{admin}"); + assert_eq!(p.roles()["iceberg"].to_string(), "ALL"); + // Matched case-insensitively, like every other connection-string value. + assert_eq!(p.roles()["pg"].to_string(), "NONE"); + } + + #[test] + fn a_proxy_takes_a_url_and_optional_basic_credentials() { + let p = parse( + "Host=localhost;Port=8080;User=admin;\ + Proxy=http://proxy.internal:3128;ProxyUser=bob;ProxyPassword=s3cret", + ); + assert_eq!(p.proxy(), Some("http://proxy.internal:3128")); + assert_eq!(p.proxy_credentials(), Some(("bob", "s3cret"))); + } + + #[test] + fn a_proxy_without_credentials_has_none() { + let p = parse("Host=localhost;Port=8080;User=admin;Proxy=http://proxy.internal:3128"); + assert_eq!(p.proxy_credentials(), None); + assert_eq!(parse("Host=localhost;Port=8080;User=admin").proxy(), None); + } + + /// The whole `Proxy` value is echoed back by `SQLDriverConnect`, so a + /// password written into the URL is one the driver cannot redact. + #[test] + fn credentials_in_the_proxy_url_are_rejected() { + let err = parse_err( + "Host=localhost;Port=8080;User=admin;Proxy=http://bob:s3cret@proxy.internal:3128", + ); + assert!( + err.to_string().contains(PARAM_PROXY_PASSWORD), + "the message must name the key to use instead: {err}" + ); + } + + /// An `@` in a path is not userinfo, and rejecting it would refuse a legal + /// URL. + #[test] + fn an_at_sign_outside_the_authority_is_not_credentials() { + let p = parse("Host=localhost;Port=8080;User=admin;Proxy=http://proxy.internal/a@b"); + assert_eq!(p.proxy(), Some("http://proxy.internal/a@b")); + } + + #[test] + fn half_a_proxy_credential_is_rejected() { + let err = parse_err( + "Host=localhost;Port=8080;User=admin;Proxy=http://proxy.internal:3128;ProxyUser=bob", + ); + assert!( + err.to_string().contains("together"), + "a username with no password authenticates to nothing: {err}" + ); + } + + #[test] + fn the_proxy_password_is_redacted_in_debug() { + let p = parse( + "Host=localhost;Port=8080;User=admin;\ + Proxy=http://proxy.internal:3128;ProxyUser=bob;ProxyPassword=hunter2", + ); + let rendered = format!("{p:?}"); + assert!( + !rendered.contains("hunter2"), + "the proxy password leaked into Debug output: {rendered}" + ); + } + + #[test] + fn extra_headers_take_the_key_value_form() { + let p = parse( + "Host=localhost;Port=8080;User=admin;\ + ExtraHeaders={X-Gateway-Key:abc123;X-Tenant:eu-west}", + ); + assert_eq!( + p.extra_headers().get("X-Gateway-Key").map(String::as_str), + Some("abc123") + ); + assert_eq!( + p.extra_headers().get("X-Tenant").map(String::as_str), + Some("eu-west") + ); + } + + /// A gateway header is a plausible place for an API key, and `Debug` on + /// this struct reaches the log. + #[test] + fn extra_headers_are_redacted_in_debug() { + let p = parse("Host=localhost;Port=8080;User=admin;ExtraHeaders={X-Key:hunter2}"); + let rendered = format!("{p:?}"); + assert!( + !rendered.contains("hunter2"), + "the header value leaked into Debug output: {rendered}" + ); + } + + #[test] + fn client_capabilities_split_on_commas_and_trim() { + let p = + parse("Host=localhost;Port=8080;User=admin;ClientCapabilities=SESSION_AUTHORIZATION, "); + assert_eq!(p.client_capabilities().len(), 1); + assert!(p.client_capabilities().contains("SESSION_AUTHORIZATION")); + } + + #[test] + fn extra_headers_and_capabilities_are_empty_by_default() { + let p = parse("Host=localhost;Port=8080;User=admin"); + assert!(p.extra_headers().is_empty()); + assert!(p.client_capabilities().is_empty()); + } + + #[test] + fn time_zone_takes_an_iana_name() { + let p = parse("Host=localhost;Port=8080;User=admin;TimeZone=Europe/Berlin"); + assert_eq!( + p.time_zone().map(|tz| tz.to_string()), + Some("Europe/Berlin".to_string()) + ); + assert_eq!( + parse("Host=localhost;Port=8080;User=admin").time_zone(), + None + ); + } + + /// A mistyped zone leaves every `current_timestamp` on the coordinator's + /// own, which is a wrong answer that looks right. + #[test] + fn an_unknown_time_zone_is_rejected() { + let err = parse_err("Host=localhost;Port=8080;User=admin;TimeZone=Europe/Berlim"); + assert!( + err.to_string().contains("IANA"), + "the message must say what a valid value looks like: {err}" + ); + } + + #[test] + fn roles_are_empty_by_default() { + assert!( + parse("Host=localhost;Port=8080;User=admin") + .roles() + .is_empty() + ); + } + + /// The same malformed-pair rule as the other key-value keys: a dropped role + /// is a silently different permission set. + #[test] + fn a_role_without_a_catalog_is_rejected() { + let err = parse_err("Host=localhost;Port=8080;User=admin;Roles={hive:admin;bogus}"); + assert!( + err.to_string().contains("roles"), + "the message must name the key: {err}" + ); + } + + #[test] + fn compression_is_enabled_unless_disabled() { + assert!(!parse("Host=localhost;Port=8080;User=admin").compression_disabled()); + assert!( + parse("Host=localhost;Port=8080;User=admin;DisableCompression=TRUE") + .compression_disabled() + ); + assert!( + parse_err("Host=localhost;Port=8080;User=admin;DisableCompression=yes") + .to_string() + .contains("disablecompression") + ); + } + + #[test] + fn encoding_is_unset_by_default() { + // Unset means no `X-Trino-Query-Data-Encoding` header, so the + // coordinator returns rows inline. Spooling is opt-in because + // `retrieval-mode=storage` has the client fetch segments from object + // storage directly, which a workstation may not be able to reach. + assert!( + parse("Host=localhost;Port=8080;User=admin") + .spooling_encoding() + .is_none() + ); + } + + #[test] + fn encoding_accepts_the_three_json_forms() { + for (value, expected) in [ + ("json", SpoolingEncoding::Json), + ("json+zstd", SpoolingEncoding::JsonZstd), + ("JSON+LZ4", SpoolingEncoding::JsonLz4), + ] { + let params = parse(&format!( + "Host=localhost;Port=8080;User=admin;Encoding={value}" + )); + assert_eq!( + params.spooling_encoding(), + Some(expected), + "Encoding={value} must parse" + ); + } + } + + #[test] + fn encoding_rejects_an_unknown_value() { + // Failing the connection rather than dropping the key: a silently + // ignored encoding leaves an application believing it asked for + // spooling, with nothing to show that it did not get it. + assert!( + parse_err("Host=localhost;Port=8080;User=admin;Encoding=json+snappy") + .to_string() + .contains("encoding") + ); + } + + /// `None` leaves the client's own budget alone, which is different from + /// any number this driver could pick. + #[test] + fn max_attempts_is_unset_by_default_and_must_be_positive() { + assert_eq!( + parse("Host=localhost;Port=8080;User=admin").max_attempts(), + None + ); + assert_eq!( + parse("Host=localhost;Port=8080;User=admin;MaxAttempts=5").max_attempts(), + Some(5) + ); + for bad in ["0", "-1", "many"] { + let err = parse_err(&format!( + "Host=localhost;Port=8080;User=admin;MaxAttempts={bad}" + )); + assert!( + err.to_string().contains("maxattempts"), + "MaxAttempts={bad} must be rejected by name: {err}" + ); + } + } + + #[test] + fn tls_verify_defaults_to_true() { + let p = parse("Host=localhost;Port=8080;User=admin"); + assert_eq!(p.tls_verification(), TlsVerification::Full); + } + + #[test] + fn tls_verify_true_accepted() { + let p = parse("Host=localhost;Port=8080;User=admin;TlsVerify=true"); + assert_eq!(p.tls_verification(), TlsVerification::Full); + } + + #[test] + fn tls_verify_false_accepted() { + let p = parse("Host=localhost;Port=8080;User=admin;TlsVerify=false"); + assert_eq!(p.tls_verification(), TlsVerification::None); + } + + #[test] + fn tls_verify_case_insensitive() { + let p = parse("Host=localhost;Port=8080;User=admin;TlsVerify=True"); + assert_eq!(p.tls_verification(), TlsVerification::Full); + let p = parse("Host=localhost;Port=8080;User=admin;TlsVerify=FALSE"); + assert_eq!(p.tls_verification(), TlsVerification::None); + } + + // JDBC spells the three modes FULL / CA / NONE. Both keys take both + // vocabularies, so a value copied out of a JDBC URL transfers, and + // `true` / `false` name the same two of them. + + #[test] + fn tls_verify_accepts_the_jdbc_vocabulary() { + let base = "Host=localhost;Port=8443;User=admin"; + for (value, expected) in [ + ("full", TlsVerification::Full), + ("FULL", TlsVerification::Full), + ("none", TlsVerification::None), + ("None", TlsVerification::None), + ] { + assert_eq!( + parse(&format!("{base};TlsVerify={value}")).tls_verification(), + expected, + "TlsVerify={value}" + ); + } + } + + #[test] + fn ssl_verification_is_an_alias_and_takes_both_vocabularies() { + let base = "Host=localhost;Port=8443;User=admin"; + assert_eq!( + parse(&format!("{base};SSLVerification=NONE")).tls_verification(), + TlsVerification::None + ); + assert_eq!( + parse(&format!("{base};SSLVerification=false")).tls_verification(), + TlsVerification::None + ); + } + + /// `CaOnly` replaces the trust store under rustls, so without a chain to + /// verify against it would trust nothing at all. + #[test] + fn ca_only_requires_a_certificate() { + let base = "Host=localhost;Port=8443;User=admin"; + let e = parse_err(&format!("{base};TlsVerify=ca")); + assert!( + matches!(&e, TrinoError::General { message } if message.contains(PARAM_CERTIFICATE)), + "the error must name the key that fixes it, got {e:?}" + ); + assert_eq!( + parse(&format!("{base};TlsVerify=ca;Certificate=/tmp/root.pem")).tls_verification(), + TlsVerification::CaOnly + ); + } + + /// One setting under two names. Silently preferring either would leave the + /// other looking honoured for a value whose failure mode is an + /// unauthenticated connection. + #[test] + fn the_two_spellings_may_agree_but_not_disagree() { + let base = "Host=localhost;Port=8443;User=admin"; + assert_eq!( + parse(&format!("{base};TlsVerify=false;SSLVerification=NONE")).tls_verification(), + TlsVerification::None + ); + let e = parse_err(&format!("{base};TlsVerify=true;SSLVerification=NONE")); + assert!(matches!(e, TrinoError::General { .. }), "got {e:?}"); + } + + #[test] + fn the_client_certificate_is_a_path_and_defaults_to_none() { + let base = "Host=localhost;Port=8443;User=admin"; + assert_eq!(parse(base).client_certificate(), None); + assert_eq!( + parse(&format!("{base};ClientCertificate=/tmp/client.pem")).client_certificate(), + Some("/tmp/client.pem") + ); + } + + #[test] + fn tls_verify_invalid_value_returns_error() { + let err = parse_err("Host=localhost;Port=8080;User=admin;TlsVerify=yes"); + assert!( + matches!(err, TrinoError::General { ref message } if message.contains("tlsverify")), + "expected error mentioning tlsverify, got: {err:?}" + ); + } + + #[test] + fn protocol_defaults_to_https() { + // The safe direction for an omitted value: an unencrypted connection + // should be something an application asked for, not something it got + // by saying nothing. A plaintext coordinator needs `Protocol=http`. + let p = parse("Host=localhost;Port=8080;User=admin"); + assert!(p.secure()); + } + + #[test] + fn protocol_http_opts_out_of_tls() { + let p = parse("Host=localhost;Port=8080;User=admin;Protocol=http"); + assert!(!p.secure()); + } + + #[test] + fn protocol_https_accepted() { + let p = parse("Host=localhost;Port=8080;User=admin;Protocol=https"); + assert!(p.secure()); + } + + /// `connect` applies the TLS group only when the transport is secure, so a + /// certificate named alongside `Protocol=http` is never even read from + /// disk. Accepting it silently tells an operator their connection is + /// verified against that file when nothing verifies anything. + #[test] + fn a_certificate_is_refused_over_plain_http() { + let base = "Host=localhost;Port=8080;User=admin;Protocol=http"; + for key in ["Certificate", "ClientCertificate"] { + let err = parse_err(&format!("{base};{key}=/tmp/root.pem")); + let message = err.to_string(); + assert!( + message.contains(&key.to_lowercase()) && message.contains("protocol"), + "{key} over http must be refused by a message naming it and the \ + protocol key: {message}" + ); + } + // Both at once are named together rather than one at a time, so an + // operator fixes the connection string in one pass. + let err = parse_err(&format!( + "{base};Certificate=/tmp/root.pem;ClientCertificate=/tmp/client.pem" + )); + let message = err.to_string(); + assert!( + message.contains("certificate") && message.contains("clientcertificate"), + "both keys must be named: {message}" + ); + } + + /// The verification mode is equally inert over http and equally harmless: + /// the protocol has already said the connection is unverified. It is also + /// written unconditionally by `configure-dsn.ps1`, whose Enum fields always + /// carry their default, so refusing it would refuse plaintext data sources + /// the driver's own dialog produces. + #[test] + fn the_verification_mode_is_tolerated_over_plain_http() { + let base = "Host=localhost;Port=8080;User=admin;Protocol=http"; + for pair in ["TlsVerify=full", "TlsVerify=none", "SSLVerification=full"] { + let p = parse(&format!("{base};{pair}")); + assert!(!p.secure(), "{pair} must still parse over http"); + } + } + + #[test] + fn protocol_case_insensitive() { + // A case variant must not silently downgrade the connection to + // plaintext: the password is only sent when the transport is secure. + for s in ["HTTPS", "Https", "hTTps"] { + let p = parse(&format!("Host=localhost;Port=8080;User=admin;Protocol={s}")); + assert!(p.secure(), "Protocol={s} was not treated as secure"); + } + let p = parse("Host=localhost;Port=8080;User=admin;Protocol=HTTP"); + assert!(!p.secure()); + } + + #[test] + fn protocol_invalid_value_returns_error() { + let err = parse_err("Host=localhost;Port=8080;User=admin;Protocol=ftp"); + assert!( + matches!(err, TrinoError::General { ref message } if message.contains("protocol")), + "expected error mentioning protocol, got: {err:?}" + ); + } + + #[test] + fn debug_redacts_password() { + let p = parse("Host=localhost;Port=8080;User=admin;Password=s3cr3t"); + let debug_str = format!("{p:?}"); + assert!( + !debug_str.contains("s3cr3t"), + "password must be redacted: {debug_str}" + ); + assert!( + debug_str.contains("*****"), + "expected ***** in: {debug_str}" + ); + assert!( + debug_str.contains("localhost"), + "host should be visible: {debug_str}" + ); + } + + #[test] + fn debug_no_password_shows_none() { + let p = parse("Host=localhost;Port=8080;User=admin"); + let debug_str = format!("{p:?}"); + // Redacted<Option<String>> with None still prints as "*****": field is always hidden + assert!( + !debug_str.contains("localhost\")"), + "redacted field should not leak: {debug_str}" + ); + } + + #[test] + fn access_token_parsed_from_accesstoken_key() { + let p = parse("Host=h;Port=8080;User=u;Protocol=https;AccessToken=abc.def.ghi"); + assert_eq!(p.access_token(), Some("abc.def.ghi")); + } + + #[test] + fn access_token_parsed_from_token_alias() { + let p = parse("Host=h;Port=8080;User=u;Protocol=https;Token=abc.def.ghi"); + assert_eq!(p.access_token(), Some("abc.def.ghi")); + } + + #[test] + fn access_token_absent_is_none() { + let p = parse("Host=h;Port=8080;User=u"); + assert_eq!(p.access_token(), None); + } + + #[test] + fn debug_redacts_access_token() { + let p = parse("Host=h;Port=8080;User=u;Protocol=https;AccessToken=s3cr3t.jwt"); + let s = format!("{p:?}"); + assert!(!s.contains("s3cr3t"), "token must be redacted: {s}"); + } +} diff --git a/src/backend/types/mod.rs b/src/backend/types/mod.rs new file mode 100644 index 0000000..d9a965a --- /dev/null +++ b/src/backend/types/mod.rs @@ -0,0 +1,3 @@ +//! Trino-specific types parsed from the ODBC connection string. + +pub(crate) mod connect_params; diff --git a/src/escape_dialect.rs b/src/escape_dialect.rs new file mode 100644 index 0000000..a15ad0a --- /dev/null +++ b/src/escape_dialect.rs @@ -0,0 +1,892 @@ +//! Trino escape-translation dialect: `"`-quoted identifiers, Trino date/time +//! literals, and the `{fn}` scalar-function remap for the names Trino spells +//! differently from ODBC. +//! +//! Both function hooks correspond exactly to the `SQL_*_FUNCTIONS` bitmaps +//! `src/backend/info.rs` advertises: a bit is advertised only if +//! `{fn NAME(...)}` translates into Trino SQL that runs. A bit without a +//! translation is a capability an application is told it has and cannot use. +//! +//! Two hooks, for two kinds of difference: +//! +//! - [`remap_scalar_fn`] swaps the identifier in front of the parentheses and +//! never sees the arguments, which is all a spelling difference needs: +//! `UCASE` → `upper`, `LOG` → `ln`. +//! - [`rewrite_scalar_fn`] receives the whole call and returns its +//! replacement, for the functions where ODBC and Trino agree on the +//! capability but not on the syntax: `LOCATE(a, b)` → `position(a IN b)`, +//! the `CURDATE`/`USERNAME` family → bare keywords with the `()` removed, +//! `TIMESTAMPADD(SQL_TSI_DAY, ...)` → `date_add('day', ...)` with the unit +//! re-quoted, `DAYOFWEEK` → an expression converting Trino's ISO day +//! numbering to ODBC's, `LENGTH`/`LTRIM`/`RTRIM` → the two-argument trims +//! that take ODBC's "blanks" literally, and `TRUNCATE` → scaled arithmetic, +//! because Trino's two-argument form is declared over `decimal` alone. +//! +//! Everything else passes through unchanged (`None`). `POSITION` needs no +//! hook, because ODBC spells it `POSITION(exp IN exp)`, which is already +//! Trino's syntax; `NOW`, `MONTH`, `QUARTER`, `WEEK`, `YEAR`, `HOUR`, +//! `MINUTE`, `SECOND`, `EXTRACT` and the numeric and string functions with no +//! arm below agree with Trino on both the name and the signature, verified +//! against <https://trino.io/docs/current/functions/string.html>, +//! <https://trino.io/docs/current/functions/math.html> and +//! <https://trino.io/docs/current/functions/datetime.html>. +//! +//! `ATAN2` is the one name where that agreement covers the spelling but not +//! the ODBC appendix's argument order. It passes through deliberately; the +//! reasoning is recorded at the end of [`rewrite_scalar_fn`]. +use stackable_odbc_core::escape::EscapeDialect; + +/// Remap an ODBC `{fn NAME(...)}` scalar-function name to Trino's spelling. +/// `None` passes the name through unchanged (same spelling in both). +pub(crate) fn remap_scalar_fn(name: &str) -> Option<&'static str> { + match name.to_ascii_uppercase().as_str() { + // SQL_FN_STR_UCASE / SQL_FN_STR_LCASE / SQL_FN_STR_CHAR + "UCASE" => Some("upper"), + "LCASE" => Some("lower"), + "CHAR" => Some("chr"), + // SQL_FN_NUM_LOG: ODBC's LOG is the natural logarithm, while Trino's + // own `log(b, x)` is base-b and a different function, so this maps to + // `ln()` specifically (see the SQL_NUMERIC_FUNCTIONS doc comment in + // backend/info.rs). + "LOG" => Some("ln"), + // SQL_FN_SYS_IFNULL: Trino has no `ifnull`, but two-argument + // `coalesce(a, b)` is exactly equivalent (same doc comment). + "IFNULL" => Some("coalesce"), + // SQL_FN_TD_DAYOFMONTH / SQL_FN_TD_DAYOFYEAR: same semantics as + // ODBC (1-31 / 1-366), just spelled with underscores in Trino. + // DAYOFWEEK is NOT remapped here, because its numbering differs; it + // goes through `rewrite_scalar_fn` instead. + "DAYOFMONTH" => Some("day_of_month"), + "DAYOFYEAR" => Some("day_of_year"), + _ => None, + } +} + +/// ODBC interval keyword → the unit string Trino's `date_add` / `date_diff` +/// take as their first argument. +/// +/// `SQL_TSI_FRAC_SECOND` is absent: ODBC defines it as billionths of a second +/// and Trino's finest unit is `millisecond`, which would silently be a +/// million times coarser. That is why `SQL_FN_TSI_FRAC_SECOND` is left out of +/// [`Backend::timedate_add_intervals`](stackable_odbc_core::backend::Backend::timedate_add_intervals) +/// too, and the two must agree. +fn trino_interval_unit(keyword: &str) -> Option<&'static str> { + match keyword.trim().to_ascii_uppercase().as_str() { + "SQL_TSI_SECOND" => Some("second"), + "SQL_TSI_MINUTE" => Some("minute"), + "SQL_TSI_HOUR" => Some("hour"), + "SQL_TSI_DAY" => Some("day"), + "SQL_TSI_WEEK" => Some("week"), + "SQL_TSI_MONTH" => Some("month"), + "SQL_TSI_QUARTER" => Some("quarter"), + "SQL_TSI_YEAR" => Some("year"), + _ => None, + } +} + +/// ODBC type keyword → the Trino type `{fn CONVERT(value, SQL_type)}` casts to. +/// +/// The whole set of keywords the spec defines for this escape is covered, +/// because `SQL_CONVERT_FUNCTIONS` reports `SQL_FN_CVT_CAST`: a client reading +/// that bitmap may send any of them, and one without an arm here reaches Trino +/// as a bare identifier and fails with `COLUMN_NOT_FOUND`. +/// +/// Two mappings are not the obvious ones, both measured against a live +/// coordinator rather than read off the documentation: +/// +/// - `SQL_CHAR` maps to `VARCHAR`, not to Trino's `CHAR`. A bare `CHAR` in +/// Trino is `CHAR(1)`, so `CAST('hello world' AS CHAR)` returns `"h"`, and +/// the escape would truncate every conversion to one character. ODBC's +/// `{fn CONVERT}` carries no length to give `CHAR(n)` instead. +/// - `SQL_FLOAT` maps to `DOUBLE`. ODBC's `SQL_FLOAT` is double precision, +/// and Trino's single-precision type, `REAL`, is ODBC's `SQL_REAL`. +/// +/// The `SQL_INTERVAL_*` keywords are absent: a bare `CAST` from an arbitrary +/// expression cannot reach Trino's interval types, so there is nothing honest +/// to rewrite them to. Declining leaves the call on the fallback path instead +/// of casting to something the application did not ask for. +fn trino_convert_target(keyword: &str) -> Option<&'static str> { + match keyword.trim().to_ascii_uppercase().as_str() { + "SQL_BIGINT" => Some("BIGINT"), + "SQL_INTEGER" => Some("INTEGER"), + "SQL_SMALLINT" => Some("SMALLINT"), + "SQL_TINYINT" => Some("TINYINT"), + "SQL_DOUBLE" | "SQL_FLOAT" => Some("DOUBLE"), + "SQL_REAL" => Some("REAL"), + "SQL_DECIMAL" | "SQL_NUMERIC" => Some("DECIMAL"), + "SQL_BIT" => Some("BOOLEAN"), + "SQL_CHAR" | "SQL_VARCHAR" | "SQL_LONGVARCHAR" | "SQL_WCHAR" | "SQL_WVARCHAR" + | "SQL_WLONGVARCHAR" => Some("VARCHAR"), + "SQL_BINARY" | "SQL_VARBINARY" | "SQL_LONGVARBINARY" => Some("VARBINARY"), + "SQL_DATE" | "SQL_TYPE_DATE" => Some("DATE"), + "SQL_TIME" | "SQL_TYPE_TIME" => Some("TIME"), + "SQL_TIMESTAMP" | "SQL_TYPE_TIMESTAMP" => Some("TIMESTAMP"), + "SQL_GUID" => Some("UUID"), + _ => None, + } +} + +/// Split a `{fn ...}` argument list on its top-level commas. +/// +/// Core hands the argument text over whole, because only the dialect knows +/// each function's arity and a naive split would corrupt +/// `{fn LOCATE(',', x)}`. So this walks the text with the same awareness core +/// applies to the statement: a comma inside a string literal, a quoted +/// identifier, a comment or a nested parenthesis is not a separator. +fn split_args(args: &str) -> Vec<&str> { + let bytes: Vec<char> = args.chars().collect(); + let mut parts = Vec::new(); + let mut depth = 0usize; + let mut start = 0usize; + let mut i = 0usize; + // Byte offsets, so the returned slices borrow from `args` directly. + let mut char_to_byte = vec![0usize; bytes.len() + 1]; + let mut acc = 0usize; + for (n, c) in bytes.iter().enumerate() { + char_to_byte[n] = acc; + acc += c.len_utf8(); + } + char_to_byte[bytes.len()] = acc; + + while i < bytes.len() { + let c = bytes[i]; + if c == '\'' || c == '"' { + let quote = c; + i += 1; + while i < bytes.len() { + if bytes[i] == quote { + // A doubled quote stays inside the literal. + if bytes.get(i + 1) == Some(&quote) { + i += 2; + continue; + } + break; + } + i += 1; + } + i += 1; + } else if c == '-' && bytes.get(i + 1) == Some(&'-') { + while i < bytes.len() && bytes[i] != '\n' { + i += 1; + } + } else if c == '/' && bytes.get(i + 1) == Some(&'*') { + i += 2; + while i < bytes.len() && !(bytes[i] == '*' && bytes.get(i + 1) == Some(&'/')) { + i += 1; + } + i += 2; + } else { + if c == '(' { + depth += 1; + } else if c == ')' { + depth = depth.saturating_sub(1); + } else if c == ',' && depth == 0 { + parts.push(args[char_to_byte[start]..char_to_byte[i]].trim()); + start = i + 1; + } + i += 1; + } + } + parts.push(args[char_to_byte[start.min(bytes.len())]..].trim()); + parts +} + +/// Rewrite a whole `{fn NAME(args)}` escape into Trino SQL. +/// +/// This is for the functions a rename alone cannot reach: ODBC and Trino +/// agree on the capability but not on the argument syntax, the parentheses, +/// or the numbering. Every one of them is advertised in the `SQL_*_FUNCTIONS` +/// bitmaps in `backend/info.rs`, and the two must stay in step: a bit there +/// without an arm here is a claim an application cannot use. +/// +/// Returning `None` falls back to [`remap_scalar_fn`] plus verbatim +/// arguments, which is the right answer for every function Trino spells the +/// same way and for a call whose argument count this cannot honour. +pub(crate) fn rewrite_scalar_fn(name: &str, args: &str) -> Option<String> { + let upper = name.to_ascii_uppercase(); + let parts = split_args(args); + let empty = args.trim().is_empty(); + + match upper.as_str() { + // SQL_FN_STR_LOCATE_2: `position(substring IN string)` takes ODBC's + // argument order. Only the two-argument form: ODBC's optional third + // argument is a *start offset*, where the third argument of Trino's + // `strpos` is an occurrence index, so there is nothing to rewrite it + // to. That is why `SQL_FN_STR_LOCATE` (the three-argument form) is not + // advertised while `SQL_FN_STR_LOCATE_2` is. + "LOCATE" if parts.len() == 2 => Some(format!("position({} IN {})", parts[0], parts[1])), + + // SQL_FN_STR_LENGTH / SQL_FN_STR_LTRIM / SQL_FN_STR_RTRIM all turn on + // ODBC's word "blanks", which means the space character and nothing + // else. Trino reads the same three operations as whitespace-wide, so + // each needs the trimmed set pinned to a literal space rather than the + // name passed through: + // + // - LENGTH is specified as "the number of characters in string_exp, + // excluding trailing blanks", while Trino's `length` counts them. + // Measured against a coordinator, `length(CAST('ab' AS char(5)))` is + // 5 where ODBC asks for 2, and `length('abc ')` is 6 where ODBC + // asks for 3. The gap is not confined to padded `char(n)`: any value + // carrying trailing spaces is counted wrong. + // - LTRIM and RTRIM are specified as removing leading and trailing + // *blanks*. Trino's one-argument `ltrim`/`rtrim` remove every kind of + // trailing whitespace, so a tab or a newline is eaten from data ODBC + // says to keep. + // + // The two-argument forms take the exact set, so `rtrim(x, ' ')` trims + // spaces and preserves a trailing tab. They are NULL-safe, matching the + // pass-through they replace. + "LENGTH" if parts.len() == 1 => Some(format!("length(rtrim({}, ' '))", parts[0])), + "LTRIM" if parts.len() == 1 => Some(format!("ltrim({}, ' ')", parts[0])), + "RTRIM" if parts.len() == 1 => Some(format!("rtrim({}, ' ')", parts[0])), + + // SQL_FN_TD_CURDATE / CURTIME and the three ODBC 3.x CURRENT_* forms. + // Trino takes these as bare SQL-92 keywords, so the whole escape, + // trailing `()` included, has to go. This is what `remap_scalar_fn` + // could not express. + "CURDATE" | "CURRENT_DATE" if empty => Some("current_date".into()), + "CURTIME" | "CURRENT_TIME" if empty => Some("current_time".into()), + "CURRENT_TIMESTAMP" if empty => Some("current_timestamp".into()), + + // SQL_FN_SYS_USERNAME / SQL_FN_SYS_DBNAME: bare keywords again. + // `current_catalog` is NULL when the connection set no catalog, which + // is the honest answer to "which database am I in" in that case. + "USERNAME" if empty => Some("current_user".into()), + "DBNAME" if empty => Some("current_catalog".into()), + + // SQL_FN_TD_TIMESTAMPADD / SQL_FN_TD_TIMESTAMPDIFF: ODBC passes the + // unit as an unquoted keyword and Trino wants a string literal, so + // the argument has to be re-quoted, not just the name swapped. + // Argument order matches: ODBC's TIMESTAMPDIFF(interval, ts1, ts2) is + // ts2 - ts1, and so is Trino's date_diff(unit, ts1, ts2). + "TIMESTAMPADD" if parts.len() == 3 => { + let unit = trino_interval_unit(parts[0])?; + Some(format!("date_add('{unit}', {}, {})", parts[1], parts[2])) + } + "TIMESTAMPDIFF" if parts.len() == 3 => { + let unit = trino_interval_unit(parts[0])?; + Some(format!("date_diff('{unit}', {}, {})", parts[1], parts[2])) + } + + // SQL_FN_CVT_CAST: ODBC passes the target as an unquoted `SQL_*` + // keyword in an argument position, which is neither a Trino type name + // nor even valid there: `CONVERT(x, SQL_INTEGER)` reaches the server as + // a two-argument function call and fails resolving `sql_integer` as a + // column. The whole call has to become a `CAST`. + "CONVERT" if parts.len() == 2 => { + let target = trino_convert_target(parts[1]).or_else(|| { + tracing::warn!( + odbc_type = parts[1], + "no Trino type for this ODBC CONVERT target; leaving the escape untranslated" + ); + None + })?; + Some(format!("CAST({} AS {target})", parts[0])) + } + + // SQL_FN_NUM_RAND: ODBC's optional argument is a seed and the result is + // a float in [0, 1). Trino's `rand(n)` takes a *bound* and returns an + // integer in [0, n), so passing the call through verbatim answers a + // different type over a different range: `{fn RAND(5)}` would yield 0-4 + // rather than a fraction. Trino has no seeded generator to rewrite the + // seed onto, so it is dropped and the zero-argument form emitted, which + // keeps the type and the range and loses only reproducibility. Silently + // returning the wrong distribution is the worse of the two. + "RAND" if parts.len() == 1 && !empty => { + tracing::warn!( + seed = parts[0], + "Trino has no seeded random(); {{fn RAND(seed)}} is translated to \ + random(), which is not reproducible" + ); + Some("random()".into()) + } + + // SQL_FN_NUM_TRUNCATE: ODBC's TRUNCATE takes a `numeric_exp`, which the + // appendix defines as covering SQL_FLOAT, SQL_REAL and SQL_DOUBLE among + // others, but Trino's two-argument `truncate` is declared over `decimal` + // alone. `truncate(CAST(1.99 AS DOUBLE), 1)` does not resolve at all: it + // fails FUNCTION_NOT_FOUND with "Expected: truncate(decimal(p,s), ...)". + // Passing the call through therefore works for a decimal column and + // fails outright for a double or real one, which is exactly the claim + // this module's header says an advertised bit must not make. + // + // Scaling by a power of ten reaches the single-argument `truncate`, + // which Trino does define over double and real, so the rewrite covers + // the whole numeric domain. See [`rewrite_truncate`] for how the scale + // factor is chosen, which is what decides the result's type. + "TRUNCATE" if parts.len() == 2 => Some(rewrite_truncate(parts[0], parts[1])), + + // SQL_FN_TD_DAYOFWEEK: Trino's `day_of_week` is ISO-numbered + // (1 = Monday .. 7 = Sunday) and ODBC specifies 1 = Sunday .. + // 7 = Saturday, so the *value* needs converting, not just the name: + // `(iso % 7) + 1` maps Monday 1 -> 2 and Sunday 7 -> 1. + // Renaming alone returns a plausible, silently wrong day. + "DAYOFWEEK" if parts.len() == 1 => Some(format!("((day_of_week({}) % 7) + 1)", parts[0])), + + // SQL_FN_NUM_ATAN2 has no arm on purpose, and the omission is a + // decision rather than an oversight. + // + // ODBC's appendix reads `ATAN2(float_exp1, float_exp2)` as "the + // arctangent of the x and y coordinates, specified by float_exp1 and + // float_exp2, respectively", so the literal text puts x first. Trino's + // `atan2(y, x)` puts y first, and so does every other implementation + // that was checked: PostgreSQL, MySQL, Oracle, C, Java, Python, and + // SQL Server's own `ATN2`, whose documented example evaluates + // `ATN2(129.44, 35.175643)` to 1.30545, which is atan(129.44/35.175643) + // and therefore first-argument-is-y. + // + // Microsoft's engine thus contradicts Microsoft's own appendix, and + // psqlodbc, which does remap LOG, LENGTH and DAYOFWEEK exactly as this + // module does, carries ATAN2 in its table only as a commented-out + // `built_in` and passes it through untouched. Swapping here would make + // this the single driver in the ecosystem answering the complementary + // angle, breaking any application ported from another ODBC driver in + // order to match a sentence no implementation honours. + // + // The deviation from the appendix text is therefore intentional and is + // pinned by a discriminating case in the integration suite, one whose + // two readings give different non-zero answers. + _ => None, + } +} + +/// Largest `|d|` that still scales by an integer literal: 10^18 fits in a +/// Trino `bigint`, 10^19 does not. +const TRUNCATE_LITERAL_SCALE_LIMIT: i32 = 18; + +/// The body of the `SQL_FN_NUM_TRUNCATE` rewrite: `{fn TRUNCATE(value, digits)}` +/// scaled into the single-argument `truncate` Trino defines over every numeric +/// type. +/// +/// ODBC says TRUNCATE "returns values of the same data type as the input +/// parameters", and which scale factor is used decides whether that holds. +/// `power(10, d)` is double-valued, so it drags a decimal or real argument to +/// double. An integer literal does not: Trino promotes `decimal * bigint` to +/// decimal and `real * bigint` to real, so the argument's own type survives the +/// round trip. Measured against a coordinator, `truncate(CAST(1.99 AS +/// DECIMAL(3,2)) * 10) / 10` is an exact `decimal` 1.9 and the same expression +/// over a `real` stays `real`, where the `power` form answers `double` for +/// both. The decimal's scale does widen, because Trino's decimal division adds +/// scale, but the type an application reads from `SQLDescribeCol` is still +/// SQL_DECIMAL and the value is still exact. +/// +/// A literal `digits` is therefore scaled by `10^|d|` written out in full, with +/// the sign choosing multiply-then-divide or divide-then-multiply so a negative +/// `d` zeroes digits to the left of the point, as ODBC specifies. `d == 0` needs +/// no scaling at all. +/// +/// Anything else falls back to `power`. That covers `digits` given as a column +/// or a parameter marker, which is legal ODBC and cannot be folded here, and +/// `|d| > 18`, where the literal would exceed `bigint`. Those calls widen to +/// double, which stays the better of the two deviations: a widened numeric type +/// is something an application can still read and work with, where the +/// unrewritten call leaves it FUNCTION_NOT_FOUND and nothing at all. +fn rewrite_truncate(value: &str, digits: &str) -> String { + match digits.trim().parse::<i32>() { + Ok(0) => format!("truncate({value})"), + Ok(d) if (1..=TRUNCATE_LITERAL_SCALE_LIMIT).contains(&d) => { + let scale = 10i64.pow(d as u32); + format!("(truncate({value} * {scale}) / {scale})") + } + Ok(d) if (-TRUNCATE_LITERAL_SCALE_LIMIT..0).contains(&d) => { + let scale = 10i64.pow(d.unsigned_abs()); + format!("(truncate({value} / {scale}) * {scale})") + } + _ => format!("(truncate({value} * power(10, {digits})) / power(10, {digits}))"), + } +} + +fn render_date(x: &str) -> String { + format!("DATE {x}") +} +fn render_time(x: &str) -> String { + format!("TIME {x}") +} +fn render_timestamp(x: &str) -> String { + format!("TIMESTAMP {x}") +} + +/// Trino's `EscapeDialect`: `"`-quoted identifiers (Trino's ANSI-standard +/// quoting) and Trino-spelled date/time/timestamp literals. +pub(crate) fn dialect() -> EscapeDialect { + EscapeDialect::ansi_default() + .with_identifier_quotes(&[('"', '"')]) + .with_remap_scalar_fn(remap_scalar_fn) + .with_rewrite_scalar_fn(rewrite_scalar_fn) + .with_datetime_renderers(render_date, render_time, render_timestamp) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// `split_args` is the one piece of parsing this crate does that core + /// declines to: core hands the argument text over whole because only the + /// dialect knows each function's arity, and warns that splitting naively + /// would corrupt `{fn LOCATE(',', x)}`. So the comma cases below are the + /// point of the function, not edge cases around it: a separator that is + /// really a character inside a literal, an identifier, a comment or a + /// nested call. + #[test] + fn split_args_splits_only_on_top_level_commas() { + for (input, expected) in [ + // Nothing to split. + ("", vec![""]), + ("x", vec!["x"]), + ("a, b", vec!["a", "b"]), + // Whitespace around a separator is not part of the argument. + (" a , b ", vec!["a", "b"]), + // A comma inside a string literal is data. This is core's own + // example of what a naive split destroys. + ("','", vec!["','"]), + ("',', x", vec!["','", "x"]), + ("'a,b', 'c,d'", vec!["'a,b'", "'c,d'"]), + // A doubled quote stays inside the literal, so the comma after it + // is still data. + ("'it''s, fine', x", vec!["'it''s, fine'", "x"]), + // Quoted identifiers follow the same rule as string literals. + ("\"a,b\", c", vec!["\"a,b\"", "c"]), + ( + "\"say \"\"hi\"\", now\", c", + vec!["\"say \"\"hi\"\", now\"", "c"], + ), + // A nested call's own arguments are not this call's arguments. + ("f(a, b), c", vec!["f(a, b)", "c"]), + ("f(g(a, b), c), d", vec!["f(g(a, b), c)", "d"]), + // Comments can hide a comma too. + ("a -- one, two\n, b", vec!["a -- one, two", "b"]), + ("a /* one, two */, b", vec!["a /* one, two */", "b"]), + // An empty trailing argument is still an argument: the caller + // checks arity, so this must not silently look like one fewer. + ("a,", vec!["a", ""]), + (",a", vec!["", "a"]), + // Multi-byte characters must not shift the slice boundaries. + ("'héllo, wörld', x", vec!["'héllo, wörld'", "x"]), + ] { + assert_eq!( + split_args(input), + expected, + "split_args({input:?}) did not split on top-level commas only" + ); + } + } + + /// An unbalanced or unterminated argument list must not panic or lose + /// text. Core only offers the hook when the call's parentheses balance, + /// but the text between them is arbitrary and this must survive it. + #[test] + fn split_args_survives_malformed_input() { + assert_eq!(split_args("'unterminated, x"), vec!["'unterminated, x"]); + assert_eq!(split_args("\"unterminated, x"), vec!["\"unterminated, x"]); + assert_eq!(split_args("f(a, b"), vec!["f(a, b"]); + assert_eq!(split_args("a) , b"), vec!["a)", "b"]); + assert_eq!(split_args("/* unterminated, x"), vec!["/* unterminated, x"]); + } + + /// Each rewrite, in the form core hands it over: the name, and the + /// argument text between the outer parentheses. + #[test] + fn rewrites_produce_trinos_spelling() { + for (name, args, expected) in [ + // The IN keyword ODBC's LOCATE does not have. + ("LOCATE", "'b', 'ab'", "position('b' IN 'ab')"), + ("locate", "'b', 'ab'", "position('b' IN 'ab')"), + // Bare keywords: the escape's own `()` has to disappear. + ("CURDATE", "", "current_date"), + ("CURRENT_DATE", "", "current_date"), + ("CURTIME", "", "current_time"), + ("CURRENT_TIME", "", "current_time"), + ("CURRENT_TIMESTAMP", "", "current_timestamp"), + ("USERNAME", "", "current_user"), + ("DBNAME", "", "current_catalog"), + // The interval keyword becomes a quoted unit. + ("TIMESTAMPADD", "SQL_TSI_DAY, 1, t", "date_add('day', 1, t)"), + ( + "TIMESTAMPDIFF", + "SQL_TSI_YEAR, a, b", + "date_diff('year', a, b)", + ), + // Value conversion, not a rename. + ("DAYOFWEEK", "d", "((day_of_week(d) % 7) + 1)"), + // The seed is dropped rather than passed through: Trino reads that + // argument as a bound. See the arm for why losing reproducibility + // beats answering a different type. + ("RAND", "5", "random()"), + ] { + assert_eq!( + rewrite_scalar_fn(name, args).as_deref(), + Some(expected), + "{{fn {name}({args})}} rewrote wrongly" + ); + } + } + + /// `{fn CONVERT(value, SQL_type)}` becomes a `CAST`, which is what + /// `SQL_CONVERT_FUNCTIONS` reporting `SQL_FN_CVT_CAST` promises. + /// + /// Every ODBC type keyword the spec defines for this escape is covered, + /// because a client reading the bitmap is entitled to send any of them. + #[test] + fn convert_becomes_a_cast_for_every_odbc_type_keyword() { + for (keyword, trino) in [ + ("SQL_BIGINT", "BIGINT"), + ("SQL_INTEGER", "INTEGER"), + ("SQL_SMALLINT", "SMALLINT"), + ("SQL_TINYINT", "TINYINT"), + ("SQL_DOUBLE", "DOUBLE"), + ("SQL_FLOAT", "DOUBLE"), + ("SQL_REAL", "REAL"), + ("SQL_DECIMAL", "DECIMAL"), + ("SQL_NUMERIC", "DECIMAL"), + ("SQL_BIT", "BOOLEAN"), + ("SQL_CHAR", "VARCHAR"), + ("SQL_VARCHAR", "VARCHAR"), + ("SQL_LONGVARCHAR", "VARCHAR"), + ("SQL_WCHAR", "VARCHAR"), + ("SQL_WVARCHAR", "VARCHAR"), + ("SQL_WLONGVARCHAR", "VARCHAR"), + ("SQL_BINARY", "VARBINARY"), + ("SQL_VARBINARY", "VARBINARY"), + ("SQL_LONGVARBINARY", "VARBINARY"), + ("SQL_DATE", "DATE"), + ("SQL_TYPE_DATE", "DATE"), + ("SQL_TIME", "TIME"), + ("SQL_TYPE_TIME", "TIME"), + ("SQL_TIMESTAMP", "TIMESTAMP"), + ("SQL_TYPE_TIMESTAMP", "TIMESTAMP"), + ("SQL_GUID", "UUID"), + ] { + assert_eq!( + rewrite_scalar_fn("CONVERT", &format!("x, {keyword}")).as_deref(), + Some(format!("CAST(x AS {trino})").as_str()), + "{{fn CONVERT(x, {keyword})}} rewrote wrongly" + ); + } + } + + /// The keyword is matched case-insensitively and with surrounding space + /// tolerated, the same as `TIMESTAMPADD`'s interval keyword. + #[test] + fn convert_keyword_is_case_and_space_insensitive() { + assert_eq!( + rewrite_scalar_fn("CONVERT", "x, sql_integer ").as_deref(), + Some("CAST(x AS INTEGER)") + ); + assert_eq!( + rewrite_scalar_fn("convert", "x, Sql_Integer").as_deref(), + Some("CAST(x AS INTEGER)") + ); + } + + /// `SQL_CHAR` maps to `VARCHAR`, not to Trino's `CHAR`. + /// + /// Measured, not assumed: `CAST('hello world' AS CHAR)` returns `"h"` on a + /// live coordinator, because a bare `CHAR` in Trino is `CHAR(1)`. Mapping + /// the ODBC keyword to it would silently truncate every conversion to one + /// character, which is worse than not translating at all. + #[test] + fn convert_to_char_does_not_map_to_trinos_truncating_char() { + let rewritten = rewrite_scalar_fn("CONVERT", "'hello world', SQL_CHAR") + .expect("SQL_CHAR is a mapped keyword"); + assert!( + !rewritten.contains("AS CHAR)"), + "SQL_CHAR must not become Trino's CHAR(1): {rewritten}" + ); + assert_eq!(rewritten, "CAST('hello world' AS VARCHAR)"); + } + + /// Declining is how a call this cannot honour reaches the fallback path + /// unchanged, rather than being rewritten into something wrong. + #[test] + fn rewrites_decline_what_they_cannot_honour() { + // ODBC's three-argument LOCATE takes a start offset; the third + // argument of Trino's strpos() is an occurrence index, so there is + // nothing to rewrite it to. This is why SQL_FN_STR_LOCATE_2 is + // advertised and SQL_FN_STR_LOCATE is not. + assert_eq!(rewrite_scalar_fn("LOCATE", "'b', 'ab', 2"), None); + // FRAC_SECOND is billionths of a second in ODBC and Trino's finest + // unit is millisecond, so it must not be silently accepted. + assert_eq!( + rewrite_scalar_fn("TIMESTAMPADD", "SQL_TSI_FRAC_SECOND, 1, t"), + None + ); + assert_eq!( + rewrite_scalar_fn("TIMESTAMPADD", "SQL_TSI_NONSENSE, 1, t"), + None + ); + // Wrong arity falls through rather than producing malformed SQL. + assert_eq!(rewrite_scalar_fn("TIMESTAMPADD", "SQL_TSI_DAY, 1"), None); + assert_eq!(rewrite_scalar_fn("DAYOFWEEK", "a, b"), None); + // `{fn RAND()}` is already Trino's `rand()`, so only the seeded form + // needs rewriting; the bare one falls through untouched. + assert_eq!(rewrite_scalar_fn("RAND", ""), None); + assert_eq!(rewrite_scalar_fn("RAND", "a, b"), None); + // The precision forms of CURRENT_TIME/CURRENT_TIMESTAMP pass through + // instead: Trino accepts `CURRENT_TIMESTAMP(6)` as written. + assert_eq!(rewrite_scalar_fn("CURRENT_TIMESTAMP", "6"), None); + // ODBC's "blanks" is the space alone, so all three pin the trimmed set + // rather than taking Trino's whitespace-wide default. + assert_eq!( + rewrite_scalar_fn("LENGTH", "x").as_deref(), + Some("length(rtrim(x, ' '))") + ); + assert_eq!( + rewrite_scalar_fn("LTRIM", "x").as_deref(), + Some("ltrim(x, ' ')") + ); + assert_eq!( + rewrite_scalar_fn("RTRIM", "x").as_deref(), + Some("rtrim(x, ' ')") + ); + // Only the one-argument forms ODBC defines. + assert_eq!(rewrite_scalar_fn("LENGTH", "x, y"), None); + assert_eq!(rewrite_scalar_fn("RTRIM", "x, y"), None); + + // TRUNCATE scales into the single-argument `truncate`, which Trino + // defines over double and real; the two-argument one is decimal-only. + // A literal digit count scales by an integer, which is what keeps a + // decimal or real argument out of double. + assert_eq!( + rewrite_scalar_fn("TRUNCATE", "x, 2").as_deref(), + Some("(truncate(x * 100) / 100)") + ); + // A negative one zeroes digits left of the point, so it divides first. + assert_eq!( + rewrite_scalar_fn("TRUNCATE", "x, -1").as_deref(), + Some("(truncate(x / 10) * 10)") + ); + // Nothing to scale by. + assert_eq!( + rewrite_scalar_fn("TRUNCATE", "x, 0").as_deref(), + Some("truncate(x)") + ); + // Whitespace around the count is still a literal. + assert_eq!( + rewrite_scalar_fn("TRUNCATE", "x, 3 ").as_deref(), + Some("(truncate(x * 1000) / 1000)") + ); + // A digit count that is not a literal is legal ODBC and cannot be + // folded, so it takes the `power` fallback and widens to double. + assert_eq!( + rewrite_scalar_fn("TRUNCATE", "x, d").as_deref(), + Some("(truncate(x * power(10, d)) / power(10, d))") + ); + assert_eq!( + rewrite_scalar_fn("TRUNCATE", "x, ?").as_deref(), + Some("(truncate(x * power(10, ?)) / power(10, ?))") + ); + // 10^19 exceeds bigint, so the boundary falls back too. + assert_eq!( + rewrite_scalar_fn("TRUNCATE", "x, 18").as_deref(), + Some("(truncate(x * 1000000000000000000) / 1000000000000000000)") + ); + assert_eq!( + rewrite_scalar_fn("TRUNCATE", "x, 19").as_deref(), + Some("(truncate(x * power(10, 19)) / power(10, 19))") + ); + assert_eq!( + rewrite_scalar_fn("TRUNCATE", "x, -18").as_deref(), + Some("(truncate(x / 1000000000000000000) * 1000000000000000000)") + ); + assert_eq!( + rewrite_scalar_fn("TRUNCATE", "x, -19").as_deref(), + Some("(truncate(x * power(10, -19)) / power(10, -19))") + ); + // ODBC has no one-argument TRUNCATE, so there is nothing to rewrite. + assert_eq!(rewrite_scalar_fn("TRUNCATE", "x"), None); + + // A function with no rewrite is remap_scalar_fn's business. + assert_eq!(rewrite_scalar_fn("UCASE", "x"), None); + // ATAN2 reaches Trino with its arguments in the order the application + // wrote them, deviating from the ODBC appendix on purpose. Neither hook + // touches it, so both have to decline; see the comment on the absent + // arm. Asserting this keeps the deviation a decision under test rather + // than something a later edit can reverse without noticing. + assert_eq!(rewrite_scalar_fn("ATAN2", "1, 2"), None); + assert_eq!(remap_scalar_fn("ATAN2"), None); + // An ODBC type keyword with no Trino equivalent, and a value that is + // not a type keyword at all. Guessing a target type would produce a + // cast the application never asked for. + assert_eq!( + rewrite_scalar_fn("CONVERT", "x, SQL_INTERVAL_DAY_TO_SECOND"), + None + ); + assert_eq!(rewrite_scalar_fn("CONVERT", "x, NOT_A_TYPE"), None); + // Wrong arity falls through rather than producing malformed SQL. + assert_eq!(rewrite_scalar_fn("CONVERT", "x"), None); + assert_eq!(rewrite_scalar_fn("CONVERT", "x, SQL_INTEGER, y"), None); + } + + /// `SQL_TIMEDATE_ADD_INTERVALS` / `SQL_TIMEDATE_DIFF_INTERVALS` name the + /// units `TIMESTAMPADD`/`TIMESTAMPDIFF` accept, so every bit advertised + /// there must be a unit [`trino_interval_unit`] can rewrite. + /// Otherwise the driver names an interval whose escape then falls through + /// untranslated. + /// + /// `FRAC_SECOND` is the one that must stay out: ODBC defines it as + /// billionths of a second, Trino's finest unit is `millisecond`, and + /// mapping one to the other would be a factor of a million out. + #[test] + fn advertised_intervals_are_all_rewritable() { + use stackable_odbc_core::types::{ + SQL_FN_TSI_DAY, SQL_FN_TSI_FRAC_SECOND, SQL_FN_TSI_HOUR, SQL_FN_TSI_MINUTE, + SQL_FN_TSI_MONTH, SQL_FN_TSI_QUARTER, SQL_FN_TSI_SECOND, SQL_FN_TSI_WEEK, + SQL_FN_TSI_YEAR, + }; + + let advertised = crate::backend::TRINO_TIMESTAMP_INTERVALS; + for (flag, keyword) in [ + (SQL_FN_TSI_SECOND, "SQL_TSI_SECOND"), + (SQL_FN_TSI_MINUTE, "SQL_TSI_MINUTE"), + (SQL_FN_TSI_HOUR, "SQL_TSI_HOUR"), + (SQL_FN_TSI_DAY, "SQL_TSI_DAY"), + (SQL_FN_TSI_WEEK, "SQL_TSI_WEEK"), + (SQL_FN_TSI_MONTH, "SQL_TSI_MONTH"), + (SQL_FN_TSI_QUARTER, "SQL_TSI_QUARTER"), + (SQL_FN_TSI_YEAR, "SQL_TSI_YEAR"), + ] { + assert_ne!( + advertised & flag, + 0, + "{keyword} is rewritable but unclaimed" + ); + assert!( + trino_interval_unit(keyword).is_some(), + "{keyword} is claimed but has no Trino unit" + ); + } + + assert_eq!(advertised & SQL_FN_TSI_FRAC_SECOND, 0); + assert_eq!(trino_interval_unit("SQL_TSI_FRAC_SECOND"), None); + } + + /// A comma inside a literal must survive the whole rewrite, not just + /// `split_args` in isolation: this is core's stated worst case. + #[test] + fn rewrite_preserves_a_comma_inside_a_literal() { + assert_eq!( + rewrite_scalar_fn("LOCATE", "',', x").as_deref(), + Some("position(',' IN x)") + ); + } + + #[test] + fn ucase_maps_to_upper() { + assert_eq!(remap_scalar_fn("UCASE"), Some("upper")); + assert_eq!(remap_scalar_fn("ucase"), Some("upper")); + } + + #[test] + fn lcase_maps_to_lower() { + assert_eq!(remap_scalar_fn("LCASE"), Some("lower")); + } + + #[test] + fn char_maps_to_chr() { + assert_eq!(remap_scalar_fn("CHAR"), Some("chr")); + } + + #[test] + fn log_maps_to_ln() { + assert_eq!(remap_scalar_fn("LOG"), Some("ln")); + } + + #[test] + fn ifnull_maps_to_coalesce() { + assert_eq!(remap_scalar_fn("IFNULL"), Some("coalesce")); + } + + #[test] + fn dayofmonth_maps_to_day_of_month() { + assert_eq!(remap_scalar_fn("DAYOFMONTH"), Some("day_of_month")); + } + + #[test] + fn dayofyear_maps_to_day_of_year() { + assert_eq!(remap_scalar_fn("DAYOFYEAR"), Some("day_of_year")); + } + + #[test] + fn abs_passes_through() { + assert_eq!(remap_scalar_fn("ABS"), None); + } + + #[test] + fn ceiling_passes_through() { + assert_eq!(remap_scalar_fn("CEILING"), None); + } + + #[test] + fn concat_passes_through() { + assert_eq!(remap_scalar_fn("CONCAT"), None); + } + + #[test] + fn substring_passes_through() { + assert_eq!(remap_scalar_fn("SUBSTRING"), None); + } + + #[test] + fn now_passes_through() { + assert_eq!(remap_scalar_fn("NOW"), None); + } + + // NOT remapped despite being advertised; see the module doc. + #[test] + fn locate_not_remapped() { + assert_eq!(remap_scalar_fn("LOCATE"), None); + } + + #[test] + fn dayofweek_not_remapped() { + assert_eq!(remap_scalar_fn("DAYOFWEEK"), None); + } + + #[test] + fn curdate_not_remapped() { + assert_eq!(remap_scalar_fn("CURDATE"), None); + } + + #[test] + fn timestampadd_not_remapped() { + assert_eq!(remap_scalar_fn("TIMESTAMPADD"), None); + } + + #[test] + fn username_not_remapped() { + assert_eq!(remap_scalar_fn("USERNAME"), None); + } + + #[test] + fn date_literal_is_trino_form() { + assert_eq!(render_date("'2020-01-01'"), "DATE '2020-01-01'"); + } + + #[test] + fn time_literal_is_trino_form() { + assert_eq!(render_time("'10:00:00'"), "TIME '10:00:00'"); + } + + #[test] + fn timestamp_literal_is_trino_form() { + assert_eq!( + render_timestamp("'2020-01-01 00:00:00'"), + "TIMESTAMP '2020-01-01 00:00:00'" + ); + } + + #[test] + fn dialect_uses_double_quote_identifiers() { + assert_eq!(dialect().identifier_quotes(), &[('"', '"')]); + } + + #[test] + fn end_to_end_fn_and_date_translate() { + let out = stackable_odbc_core::escape::translate_escapes( + "SELECT {fn UCASE(name)} FROM t WHERE d = {d '2020-01-01'}", + &dialect(), + ) + .unwrap(); + assert_eq!(out, "SELECT upper(name) FROM t WHERE d = DATE '2020-01-01'"); + } +} diff --git a/src/ffi_integration_tests.rs b/src/ffi_integration_tests.rs new file mode 100644 index 0000000..e64cdc7 --- /dev/null +++ b/src/ffi_integration_tests.rs @@ -0,0 +1,5163 @@ +//! FFI-level integration tests for the Trino backend. +//! +//! All tests require Trino running at localhost:8443. +//! Start with: `./integration-tests/setup.sh` +//! +//! Run with: `cargo test -- --ignored ffi_integration_tests` +//! +//! All tests share a single ODBC connection via [`SHARED_CONN`] (created once +//! via `OnceLock`). This mirrors production usage where one connection serves +//! many queries, and avoids creating multiple `reqwest` connection pools that +//! can interfere with each other on the same Trino coordinator. +//! +//! Tests are marked `#[serial]` (from the `serial_test` crate) to prevent +//! concurrent access to the shared connection. Do NOT run these alongside +//! the `backend::tests` integration tests: they use a separate +//! `TrinoConnection` with its own reqwest pool, and the two pools cause +//! intermittent TCP socket corruption. Run backend tests in isolation: +//! `cargo test -- --ignored backend` + +use std::ffi::c_void; +use std::sync::OnceLock; + +use serial_test::serial; +use stackable_odbc_core::conformance::{ + all_info_types, genuine_convert_info_types, observe_info_value_kind, observe_u32_value, +}; +use stackable_odbc_core::ffi; +// Core's re-export, not a dependency of this crate's own: `odbc-sys` appears in +// the trait signatures core exposes, and two versions of a `#[repr(C)]` type +// are two different types to the compiler. `Timestamp` below is read back out +// of a buffer core wrote, so it has to be core's. +use stackable_odbc_core::odbc_sys; +use stackable_odbc_core::test_support::{attach_connection, detach_connection}; +use stackable_odbc_core::types::{ + AttrOdbcVersion, CDataType, Desc, EnvironmentAttribute, HandleType, HeaderDiagnosticIdentifier, + InfoType, ParamType, SQL_AUTOCOMMIT_OFF, SQL_AUTOCOMMIT_ON, SQL_FETCH_BOOKMARK, SQL_IC_LOWER, + SQL_INDEX_UNIQUE, SQL_LOCK_NO_CHANGE, SQL_NTS, SQL_NULL_DATA, SQL_PARAM_ERROR, + SQL_PARAM_SUCCESS, SQL_POSITION, SQL_QUICK, SqlDataType, SqlReturn, StatementAttribute, + expected_kind, +}; + +use crate::backend::info::{ + TRINO_AGGREGATE_FUNCTIONS, TRINO_NUMERIC_FUNCTIONS, TRINO_SQL92_VALUE_EXPRESSIONS, + TRINO_STRING_FUNCTIONS, TRINO_SYSTEM_FUNCTIONS, TRINO_TIMEDATE_FUNCTIONS, +}; +use crate::backend::{TrinoBackend, disconnected_trino_conn, disconnected_trino_conn_with_catalog}; + +/// The coordinator serves HTTPS only, so these tests verify against the test +/// CA. The path is resolved from `CARGO_MANIFEST_DIR` at compile time rather +/// than hardcoded: `generated/` is produced per checkout, and a literal path +/// would only work in one of them. +const CONN_STR: &str = concat!( + "Host=localhost;Port=8443;Protocol=https;User=admin;Password=admin;Catalog=tpcds;Certificate=", + env!("CARGO_MANIFEST_DIR"), + "/integration-tests/generated/certs/ca.crt" +); + +// --------------------------------------------------------------------------- +// Shared connection infrastructure +// --------------------------------------------------------------------------- +// +// Most tests need a connected ODBC handle but don't test the connection +// lifecycle itself. Reusing a single env + conn across tests mirrors how a +// real ODBC client works (one connection, many statements) and avoids rapid +// connect/disconnect cycles that expose Trino server-side timing sensitivity. +// +// Tests that specifically exercise connection/disconnection (e.g. +// connect_and_disconnect_lifecycle) use the standalone alloc_handles() + +// connect_trino() + cleanup() helpers instead. + +/// Wrapper around raw ODBC handle pointers so they can be stored in OnceLock. +/// +/// SAFETY: the raw pointers are heap-allocated ODBC handles that live for the +/// entire test process. They are only accessed by tests running under +/// #[serial], so there is no concurrent mutation. +struct SharedHandles(*mut c_void, *mut c_void); +unsafe impl Sync for SharedHandles {} +unsafe impl Send for SharedHandles {} + +/// Process-wide shared env + conn handles, connected once. +static SHARED_CONN: OnceLock<SharedHandles> = OnceLock::new(); + +/// Returns (env, conn) that are connected to Trino. Created on first call, +/// reused thereafter. Panics if the connection fails. +fn shared_conn() -> (*mut c_void, *mut c_void) { + let h = SHARED_CONN.get_or_init(|| unsafe { + let mut env: *mut c_void = std::ptr::null_mut(); + assert_eq!( + ffi::handle::sql_alloc_handle::<TrinoBackend>( + HandleType::Env as i16, + std::ptr::null_mut(), + &mut env, + ), + SqlReturn::SUCCESS + ); + let mut conn: *mut c_void = std::ptr::null_mut(); + assert_eq!( + ffi::handle::sql_alloc_handle::<TrinoBackend>(HandleType::Dbc as i16, env, &mut conn,), + SqlReturn::SUCCESS + ); + assert_eq!( + connect_trino(conn), + SqlReturn::SUCCESS, + "shared connection failed" + ); + SharedHandles(env, conn) + }); + (h.0, h.1) +} + +/// Allocate a fresh statement handle on the shared connection. +unsafe fn alloc_stmt() -> (*mut c_void, *mut c_void, *mut c_void) { + let (env, conn) = shared_conn(); + let mut stmt: *mut c_void = std::ptr::null_mut(); + unsafe { + assert_eq!( + ffi::handle::sql_alloc_handle::<TrinoBackend>(HandleType::Stmt as i16, conn, &mut stmt,), + SqlReturn::SUCCESS + ); + } + (env, conn, stmt) +} + +/// The first diagnostic record on a statement, as `SQLSTATE: message`, or a +/// placeholder when there is none. +/// +/// For use in an assertion message: a catalog call that unexpectedly fails +/// says nothing useful on its own, and the SQLSTATE plus the server's message +/// is the difference between "the query was rejected" and a guess. +unsafe fn diag_message(stmt: *mut c_void) -> String { + let mut state = [0u16; 6]; + let mut msg = [0u16; 1024]; + let mut msg_len: i16 = 0; + let mut native: i32 = 0; + let ret = unsafe { + ffi::diag::sql_get_diag_rec_w::<TrinoBackend>( + HandleType::Stmt as i16, + stmt, + 1, + state.as_mut_ptr(), + &mut native, + msg.as_mut_ptr(), + msg.len() as i16, + &mut msg_len, + ) + }; + if ret != SqlReturn::SUCCESS { + return "<no diagnostic record>".to_string(); + } + // The SQLSTATE buffer is a 5-character string plus its NUL terminator. + let state = String::from_utf16_lossy(&state[..5]); + let text = String::from_utf16_lossy(&msg[..msg_len as usize]); + format!("{state}: {text}") +} + +/// Free just the statement handle. Drains any in-flight result first. +/// The shared env + conn are left intact for the next test. +unsafe fn cleanup_stmt(stmt: *mut c_void) { + unsafe { + while ffi::fetch::sql_fetch::<TrinoBackend>(stmt) == SqlReturn::SUCCESS {} + let _ = ffi::handle::sql_free_handle::<TrinoBackend>(HandleType::Stmt as i16, stmt); + } +} + +// --------------------------------------------------------------------------- +// Standalone helpers for lifecycle tests (allocate + connect + disconnect) +// --------------------------------------------------------------------------- + +/// Helper: allocate env + conn + stmt handles using the Trino backend. +unsafe fn alloc_handles() -> (*mut c_void, *mut c_void, *mut c_void) { + unsafe { + let mut env: *mut c_void = std::ptr::null_mut(); + let _ = ffi::handle::sql_alloc_handle::<TrinoBackend>( + HandleType::Env as i16, + std::ptr::null_mut(), + &mut env, + ); + let mut conn: *mut c_void = std::ptr::null_mut(); + let _ = + ffi::handle::sql_alloc_handle::<TrinoBackend>(HandleType::Dbc as i16, env, &mut conn); + let mut stmt: *mut c_void = std::ptr::null_mut(); + let _ = + ffi::handle::sql_alloc_handle::<TrinoBackend>(HandleType::Stmt as i16, conn, &mut stmt); + (env, conn, stmt) + } +} + +/// Helper: connect to Trino at localhost:8443. +unsafe fn connect_trino(conn: *mut c_void) -> SqlReturn { + let wide: Vec<u16> = CONN_STR.encode_utf16().collect(); + unsafe { + ffi::connect::sql_driver_connect_w::<TrinoBackend>( + conn, + std::ptr::null_mut(), + wide.as_ptr(), + SQL_NTS as i16, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + 0, + ) + } +} + +/// Helper: execute a SQL statement. +unsafe fn exec_direct(stmt: *mut c_void, sql: &str) -> SqlReturn { + let wide: Vec<u16> = sql.encode_utf16().collect(); + unsafe { + ffi::execute::sql_exec_direct_w::<TrinoBackend>(stmt, wide.as_ptr(), wide.len() as i32) + } +} + +/// Helper: free all handles (for lifecycle tests that own their connection). +unsafe fn cleanup(env: *mut c_void, conn: *mut c_void, stmt: *mut c_void) { + unsafe { + while ffi::fetch::sql_fetch::<TrinoBackend>(stmt) == SqlReturn::SUCCESS {} + let _ = ffi::handle::sql_free_handle::<TrinoBackend>(HandleType::Stmt as i16, stmt); + let _ = ffi::connect::sql_disconnect::<TrinoBackend>(conn); + let _ = ffi::handle::sql_free_handle::<TrinoBackend>(HandleType::Dbc as i16, conn); + let _ = ffi::handle::sql_free_handle::<TrinoBackend>(HandleType::Env as i16, env); + } +} + +// --------------------------------------------------------------------------- +// Fallback-chain tests: named InfoType variants with no get_info arm +// --------------------------------------------------------------------------- +// +// SqlFileUsage/SqlQuotedIdentifierCase and the ten PowerBI capability info +// types below have no arm in default_get_info or trino_get_info's match: +// they only get a real value via TrinoBackend::get_info_raw, reached through +// the get_info_raw-first fallback in sql_get_info_w. See the note on +// ordering at info_type_default_response in +// stackable-odbc-core/src/ffi/info.rs. +// +// These need `handle.connection = Some(_)` to reach that fallback at all +// (info_type_default_response skips get_info_raw entirely when conn is +// None), but going through TrinoBackend::connect requires a live Trino +// server, because it validates the connection with a real query. Building +// a TrinoConnection directly and injecting it into the handle sidesteps +// that: ClientBuilder::build() only constructs a reqwest::Client +// synchronously (see TrinoBackend::connect in backend.rs, which performs no +// I/O until the separate validate_connection call), so this test needs no +// live server and is not `#[ignore]`d like the rest of this file. + +/// Allocates env + conn handles and injects a network-free `TrinoConnection` +/// directly into the connection handle, bypassing `TrinoBackend::connect` +/// (which requires a live server). This is enough to put `sql_get_info_w` on +/// the connected (`B::get_info` / `B::get_info_raw`) path: the fallback +/// chain under test here never touches the connection's fields. +unsafe fn alloc_conn_with_injected_trino_connection() -> (*mut c_void, *mut c_void) { + unsafe { + let mut env: *mut c_void = std::ptr::null_mut(); + assert_eq!( + ffi::handle::sql_alloc_handle::<TrinoBackend>( + HandleType::Env as i16, + std::ptr::null_mut(), + &mut env, + ), + SqlReturn::SUCCESS + ); + let mut conn: *mut c_void = std::ptr::null_mut(); + assert_eq!( + ffi::handle::sql_alloc_handle::<TrinoBackend>(HandleType::Dbc as i16, env, &mut conn), + SqlReturn::SUCCESS + ); + attach_connection::<TrinoBackend>(conn, disconnected_trino_conn()) + .expect("valid conn handle"); + (env, conn) + } +} + +/// Frees handles allocated by `alloc_conn_with_injected_trino_connection`. +/// +/// The connection is taken back out with `detach_connection` rather than closed +/// with `SQLDisconnect`: the spec has `SQLFreeHandle` refuse a connection handle +/// that still holds a connection (`HY010`), so something must remove it, and +/// this connection never opened a session for `TrinoBackend::disconnect` to +/// close. +unsafe fn cleanup_injected_conn(env: *mut c_void, conn: *mut c_void) { + unsafe { + let _ = detach_connection::<TrinoBackend>(conn); + let _ = ffi::handle::sql_free_handle::<TrinoBackend>(HandleType::Dbc as i16, conn); + let _ = ffi::handle::sql_free_handle::<TrinoBackend>(HandleType::Env as i16, env); + } +} + +/// Asserts `sql_get_info_w` returns exactly `expected` (a `U32`) for `info_type`. +unsafe fn assert_get_info_u32(conn: *mut c_void, info_type: InfoType, expected: u32) { + unsafe { + let mut value: u32 = 0xDEAD_BEEF; + let mut str_len: i16 = 0; + let ret = ffi::info::sql_get_info_w::<TrinoBackend>( + conn, + info_type as u16, + &mut value as *mut u32 as *mut c_void, + 4, + &mut str_len, + ); + assert_eq!(ret, SqlReturn::SUCCESS, "{info_type:?} must succeed"); + assert_eq!(str_len, 4, "{info_type:?} string_length_ptr"); + assert_eq!( + value, expected, + "{info_type:?} must come from get_info_raw, not the generic default" + ); + } +} + +/// Asserts `sql_get_info_w` returns exactly `expected` (a `U16`) for `info_type`. +unsafe fn assert_get_info_u16(conn: *mut c_void, info_type: InfoType, expected: u16) { + unsafe { + let mut value: u16 = 0xDEAD; + let mut str_len: i16 = 0; + let ret = ffi::info::sql_get_info_w::<TrinoBackend>( + conn, + info_type as u16, + &mut value as *mut u16 as *mut c_void, + 2, + &mut str_len, + ); + assert_eq!(ret, SqlReturn::SUCCESS, "{info_type:?} must succeed"); + assert_eq!(str_len, 2, "{info_type:?} string_length_ptr"); + assert_eq!( + value, expected, + "{info_type:?} must come from get_info_raw, not the generic default" + ); + } +} + +/// Asserts `sql_get_info_w` returns exactly `expected` (a `String`) for `info_type`. +unsafe fn assert_get_info_str(conn: *mut c_void, info_type: InfoType, expected: &str) { + unsafe { + let mut buf = [0u16; 128]; + let mut str_len: i16 = 0; + let ret = ffi::info::sql_get_info_w::<TrinoBackend>( + conn, + info_type as u16, + buf.as_mut_ptr() as *mut c_void, + 256, + &mut str_len, + ); + assert_eq!(ret, SqlReturn::SUCCESS, "{info_type:?} must succeed"); + let result = String::from_utf16_lossy(&buf[..(str_len / 2) as usize]); + assert_eq!( + result, expected, + "{info_type:?} must come from get_info_raw, not the generic default" + ); + } +} + +/// `SQL_KEYWORDS` through the real FFI path, which is the only place the +/// wiring is observable. +/// +/// `TrinoBackend::keywords` returns Trino's raw reserved words and +/// `stackable-odbc-core` subtracts `ODBC_RESERVED_KEYWORDS`, sorts and joins +/// them. A unit test in `backend/info.rs` can only redo that subtraction +/// itself, which proves the list is right but not that core asks this +/// backend for it. A core that never calls the hook answers every +/// backend with the empty string, and `SQLGetInfo` still returns `SUCCESS`, so +/// only a call through the real entry point can tell the two apart. +/// +/// There is no `odbc_sys::InfoType` variant for 89, so this goes through the +/// raw `u16` rather than `assert_get_info_str`. +#[test] +fn get_info_sql_keywords_reports_trinos_words_minus_odbcs() { + unsafe { + let (env, conn) = alloc_conn_with_injected_trino_connection(); + + // 22 words, ~230 characters: sized well clear of the answer so a + // truncation would show up as a short string rather than as SUCCESS. + let mut buf = [0xEEu16; 512]; + let mut str_len: i16 = -1; + let ret = ffi::info::sql_get_info_w::<TrinoBackend>( + conn, + stackable_odbc_core::types::SQL_KEYWORDS, + buf.as_mut_ptr() as *mut c_void, + (buf.len() * 2) as i16, + &mut str_len, + ); + assert_eq!(ret, SqlReturn::SUCCESS, "SQL_KEYWORDS must succeed"); + assert!( + str_len >= 0 && str_len % 2 == 0, + "SQL_KEYWORDS is a character string, so StringLength is an even \ + byte count; got {str_len}" + ); + let units = str_len as usize / 2; + let value = String::from_utf16_lossy(&buf[..units]); + assert_eq!(buf[units], 0, "SQL_KEYWORDS must be null-terminated"); + + assert_eq!( + value, + "AUTO,CUBE,CURRENT_CATALOG,CURRENT_PATH,CURRENT_ROLE,CURRENT_SCHEMA,\ + GROUPING,JSON_ARRAY,JSON_EXISTS,JSON_OBJECT,JSON_QUERY,JSON_TABLE,\ + JSON_VALUE,LISTAGG,LOCALTIME,LOCALTIMESTAMP,NORMALIZE,RECURSIVE,\ + ROLLUP,SKIP,UESCAPE,UNNEST", + "SQL_KEYWORDS must be Trino's reserved words minus ODBC's own" + ); + assert!( + !value.contains("SELECT"), + "SELECT is reserved by both, so the spec excludes it from this list" + ); + + let _ = ffi::handle::sql_free_handle::<TrinoBackend>(HandleType::Dbc as i16, conn); + let _ = ffi::handle::sql_free_handle::<TrinoBackend>(HandleType::Env as i16, env); + } +} + +/// Guards the get_info_raw-first ordering in `info_type_default_response` +/// (stackable-odbc-core/src/ffi/info.rs). Every info type asserted here has no arm in +/// `default_get_info` or `trino_get_info`'s match, so a passing `SUCCESS` +/// alone would prove nothing: reordering the fallback to try the numeric +/// defaults first would still return `SUCCESS`, just with the wrong value +/// (`U32(0)`/`0xFFFFFFFF` instead of the driver's real value). Asserting the +/// exact expected value is what makes this test fail on that regression. +/// +/// The version-gated bitmaps (`Sql92Predicates`, +/// `Sql92RelationalJoinOperators`) are asserted at their `server_major == 0` +/// values here, since `disconnected_trino_conn` leaves that field at 0 (a +/// failed version probe); see `sql92_predicates`/`sql92_join_operators`'s +/// own tests in `backend/info.rs` for the version-gated cases. +#[test] +fn get_info_named_but_unhandled_types_fall_back_to_get_info_raw() { + unsafe { + let (env, conn) = alloc_conn_with_injected_trino_connection(); + + // Trino-specific PowerBI/Power Query capability bitmaps, computed by + // TrinoBackend::get_info_raw. + assert_get_info_str(conn, InfoType::OuterJoins, "Y"); + assert_get_info_u32(conn, InfoType::NumericFunctions, TRINO_NUMERIC_FUNCTIONS); + assert_get_info_u32(conn, InfoType::StringFunctions, TRINO_STRING_FUNCTIONS); + assert_get_info_u32(conn, InfoType::SystemFunctions, TRINO_SYSTEM_FUNCTIONS); + assert_get_info_u32(conn, InfoType::TimedateFunctions, TRINO_TIMEDATE_FUNCTIONS); + assert_get_info_str(conn, InfoType::LikeEscapeClause, "Y"); + // Sql92Predicates and Sql92RelationalJoinOperators are version-gated + // (computed by sql92_predicates/sql92_join_operators for the injected + // server version), so they have no single named const to reference. + assert_get_info_u32(conn, InfoType::Sql92Predicates, 0x3E07); + assert_get_info_u32(conn, InfoType::Sql92RelationalJoinOperators, 0x17E); + assert_get_info_u32( + conn, + InfoType::Sql92ValueExpressions, + TRINO_SQL92_VALUE_EXPRESSIONS, + ); + assert_get_info_u32( + conn, + InfoType::AggregateFunctions, + TRINO_AGGREGATE_FUNCTIONS, + ); + + // Not Trino-specific: these fall through to stackable-odbc-core's + // common_get_info_raw after TrinoBackend::get_info_raw's own match + // misses, so Trino depends on that fallback ordering for them too. + assert_get_info_u16(conn, InfoType::SqlFileUsage, 0); + // `SQL_IC_LOWER`, because `common_get_info_raw` reads + // `TrinoBackend::quoted_identifier_case`, and a quoted identifier is + // case-insensitive in Trino and reported lower case by the system + // catalog. See that hook for the coordinator probes. + assert_get_info_u16(conn, InfoType::SqlQuotedIdentifierCase, SQL_IC_LOWER); + + cleanup_injected_conn(env, conn); + } +} + +// --------------------------------------------------------------------------- +// SQLGetInfoW info-type conformance test +// --------------------------------------------------------------------------- +// +// The failures this catches are all "nothing enumerated the spec": a value +// the Windows Driver Manager treats as an integer where the driver returns a +// string (or the reverse), and a conversion bitmap of 0, which makes the +// Windows DM block SQLGetData with HYC00. Line coverage cannot see any of +// them, because the code path producing the wrong answer runs constantly and +// only the info types a test happens to name are asserted on. +// +// These two tests iterate every `InfoType` odbc-sys +// compiles (derived from `info_type_from_raw`, not a hand-copied list; see +// `stackable_odbc_core::conformance`) through the real `sql_get_info_w` FFI entry +// point, against the real `TrinoBackend`. Both use the network-free +// connection injection (`alloc_conn_with_injected_trino_connection`) / +// unconnected allocation (`alloc_handles`) already established above, so +// (like `get_info_named_but_unhandled_types_fall_back_to_get_info_raw`) +// neither needs a live Trino server and neither is `#[ignore]`d. + +/// Property 1: every `InfoType`'s returned value has the shape the +/// SQLGetInfo spec declares for it (`stackable_odbc_core::types::expected_kind`), +/// whether `TrinoBackend` answers it itself (`trino_get_info`), falls +/// through to the shared `default_get_info`, or reaches the generic +/// DM-safe default in `info_type_default_response`. +#[test] +fn get_info_every_named_info_type_has_the_declared_shape_connected() { + unsafe { + let (env, conn) = alloc_conn_with_injected_trino_connection(); + + for info_type in all_info_types() { + let (ret, kind, _string_length) = + observe_info_value_kind::<TrinoBackend>(conn, info_type as u16); + // Not `== SUCCESS`: `observe_info_value_kind` probes the write + // shape with a non-null buffer it declares to be zero-length, and + // core reports that as total truncation (SQL_SUCCESS_WITH_INFO plus + // 01004) for a String-shaped info type. The assertion below is what + // this message always said it was. + assert_ne!( + ret, + SqlReturn::ERROR, + "{info_type:?}: SQLGetInfoW must not return SQL_ERROR" + ); + assert_eq!( + kind, + expected_kind(info_type), + "{info_type:?}: TrinoBackend returned shape {kind:?}, expected \ + {:?} per the SQLGetInfo spec", + expected_kind(info_type) + ); + } + + cleanup_injected_conn(env, conn); + } +} + +/// Property 1, pre-connect path: the Windows Driver Manager queries some +/// info types (e.g. `SQL_DRIVER_ODBC_VER`) before `SQLDriverConnectW`, which +/// routes through `TrinoBackend::get_info_pre_connect` instead of +/// `get_info`. Uses a plain unconnected handle (`alloc_handles`), not the +/// injected-connection helper, since there is no connection at all on this +/// path. +#[test] +fn get_info_every_named_info_type_has_the_declared_shape_pre_connect() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + + for info_type in all_info_types() { + let (ret, kind, _string_length) = + observe_info_value_kind::<TrinoBackend>(conn, info_type as u16); + // Not `== SUCCESS`: `observe_info_value_kind` probes the write + // shape with a non-null buffer it declares to be zero-length, and + // core reports that as total truncation (SQL_SUCCESS_WITH_INFO plus + // 01004) for a String-shaped info type. The assertion below is what + // this message always said it was. + assert_ne!( + ret, + SqlReturn::ERROR, + "{info_type:?}: SQLGetInfoW must not return SQL_ERROR pre-connect" + ); + assert_eq!( + kind, + expected_kind(info_type), + "{info_type:?}: TrinoBackend returned shape {kind:?} pre-connect, \ + expected {:?} per the SQLGetInfo spec", + expected_kind(info_type) + ); + } + + // stmt was never used to run a query; free directly rather than via + // the shared `cleanup`, which drains an in-flight result via fetch. + let _ = ffi::handle::sql_free_handle::<TrinoBackend>(HandleType::Stmt as i16, stmt); + let _ = ffi::handle::sql_free_handle::<TrinoBackend>(HandleType::Dbc as i16, conn); + let _ = ffi::handle::sql_free_handle::<TrinoBackend>(HandleType::Env as i16, env); + } +} + +/// Property 1c: the `SQLGetInfo` groups whose members constrain each other +/// agree, for this backend's real answers. +/// +/// The shape checks above police one info type at a time; this polices the +/// pairs. Core cannot check these at runtime, because `TrinoBackend::get_info` +/// runs first and is entitled to answer anything, so the invariants live in +/// `conformance` and each driver runs them against its own backend. +/// +/// Two of the groups are ones this driver overrides part of and could easily +/// desynchronise: `SQL_CATALOG_TERM` / `SQL_CATALOG_NAME_SEPARATOR` against +/// `SQL_CATALOG_NAME` (which core derives from +/// [`crate::backend::TrinoBackend::supports_catalogs`], answering `true`), and +/// `SQL_TXN_CAPABLE` against `SQL_TXN_ISOLATION_OPTION` and +/// `SQL_DEFAULT_TXN_ISOLATION`. Those three move together: `SQL_TC_DML` with +/// either isolation declaration left at `0` is the inconsistency this catches, +/// and it is the shape a driver lands in by declaring transactions without +/// declaring which isolation levels they run at. +#[test] +fn get_info_groups_that_constrain_each_other_agree() { + unsafe { + let (env, conn) = alloc_conn_with_injected_trino_connection(); + + let violations = + stackable_odbc_core::conformance::info_group_inconsistencies::<TrinoBackend>(conn); + assert!( + violations.is_empty(), + "SQLGetInfo answers contradict each other:\n {}", + violations.join("\n ") + ); + + cleanup_injected_conn(env, conn); + } +} + +/// Property 2: no genuine `SQL_CONVERT_*` code ever returns 0 through +/// `TrinoBackend`: per `AGENTS.md`, a `0` conversion bitmap is what makes +/// the Windows Driver Manager block `SQLGetData` with `HYC00`. +#[test] +fn get_info_no_genuine_convert_info_type_ever_returns_zero() { + unsafe { + let (env, conn) = alloc_conn_with_injected_trino_connection(); + + for info_type in genuine_convert_info_types() { + let (ret, value) = observe_u32_value::<TrinoBackend>(conn, info_type); + assert_eq!( + ret, + SqlReturn::SUCCESS, + "raw SQL_CONVERT_* info type {info_type} must not error" + ); + assert_ne!( + value, 0, + "raw SQL_CONVERT_* info type {info_type} returned 0: this is the \ + exact shape that makes the Windows Driver Manager block SQLGetData \ + with HYC00 (AGENTS.md)" + ); + } + + cleanup_injected_conn(env, conn); + } +} + +/// Helper: fetch column 1 as a WChar string after sql_fetch succeeds. +/// +/// Calls sql_get_data with CDataType::WChar and returns the result as a String. +/// Panics if sql_get_data does not return SUCCESS. +unsafe fn fetch_wchar(stmt: *mut c_void) -> String { + let mut buf = [0u16; 512]; + let mut ind: isize = 0; + let ret = unsafe { + ffi::fetch::sql_get_data::<TrinoBackend>( + stmt, + 1, + CDataType::WChar as i16, + buf.as_mut_ptr().cast(), + (buf.len() * 2) as isize, + &mut ind, + ) + }; + assert_eq!(ret, SqlReturn::SUCCESS, "sql_get_data failed"); + let code_units = (ind as usize) / 2; + String::from_utf16_lossy(&buf[..code_units]).to_string() +} + +/// Fetch the given 1-based column as a WChar string after `sql_fetch` succeeds. +/// +/// One-column generalisation of [`fetch_wchar`]. +unsafe fn get_wchar_col(stmt: *mut c_void, col: u16) -> String { + let mut buf = [0u16; 512]; + let mut ind: isize = 0; + let ret = unsafe { + ffi::fetch::sql_get_data::<TrinoBackend>( + stmt, + col, + CDataType::WChar as i16, + buf.as_mut_ptr().cast(), + (buf.len() * 2) as isize, + &mut ind, + ) + }; + assert_eq!(ret, SqlReturn::SUCCESS, "sql_get_data failed"); + if ind == SQL_NULL_DATA { + return String::new(); + } + let code_units = (ind as usize) / 2; + String::from_utf16_lossy(&buf[..code_units]).to_string() +} + +/// Fetch the given 1-based column as an i64 after `sql_fetch` succeeds. +/// +/// NULL (e.g. DECIMAL_DIGITS for a non-decimal column) is reported as 0, +/// matching the query path's `trino_ty_scale` default for non-decimal types. +unsafe fn get_i64_col(stmt: *mut c_void, col: u16) -> i64 { + let mut val: i64 = 0; + let mut ind: isize = 0; + let ret = unsafe { + ffi::fetch::sql_get_data::<TrinoBackend>( + stmt, + col, + CDataType::SBigInt as i16, + (&raw mut val).cast(), + 8, + &mut ind, + ) + }; + assert_eq!(ret, SqlReturn::SUCCESS, "sql_get_data failed"); + if ind == SQL_NULL_DATA { 0 } else { val } +} + +// --------------------------------------------------------------------------- +// Lifecycle test +// --------------------------------------------------------------------------- + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn connect_and_disconnect_lifecycle() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + assert_eq!(connect_trino(conn), SqlReturn::SUCCESS, "connect failed"); + assert_eq!( + exec_direct(stmt, "SELECT 1"), + SqlReturn::SUCCESS, + "exec_direct failed" + ); + let ret = stackable_odbc_core::ffi::fetch::sql_fetch::<TrinoBackend>(stmt); + assert_eq!(ret, SqlReturn::SUCCESS, "sql_fetch failed"); + cleanup(env, conn, stmt); + } +} + +// --------------------------------------------------------------------------- +// Tests moved from backend.rs +// --------------------------------------------------------------------------- + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn tables_returns_tpcds_tables() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + let catalog: Vec<u16> = "tpcds".encode_utf16().collect(); + let schema: Vec<u16> = "sf1".encode_utf16().collect(); + let ret = ffi::metadata::sql_tables_w::<TrinoBackend>( + stmt, + catalog.as_ptr(), + catalog.len() as i16, + schema.as_ptr(), + schema.len() as i16, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS, + "expected at least one table" + ); + cleanup_stmt(stmt); + } +} + +/// Trino sends VARBINARY as base64 text over the REST API. The driver decodes it +/// to `ColumnValue::Bytes`, so `SQLGetData(SQL_C_BINARY)` must yield the raw +/// payload, not the ASCII bytes of the base64 string ("3q2+7w=="). +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn varbinary_get_data_returns_raw_bytes() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + assert_eq!(exec_direct(stmt, "SELECT X'DEADBEEF'"), SqlReturn::SUCCESS); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + let mut buf = [0u8; 16]; + let mut ind: isize = 0; + let ret = ffi::fetch::sql_get_data::<TrinoBackend>( + stmt, + 1, + CDataType::Binary as i16, + buf.as_mut_ptr().cast(), + buf.len() as isize, + &mut ind, + ); + assert_eq!(ret, SqlReturn::SUCCESS, "SQLGetData(Binary) failed"); + assert_eq!(ind, 4, "expected 4 bytes of VARBINARY payload"); + assert_eq!( + &buf[..4], + &[0xDE, 0xAD, 0xBE, 0xEF], + "VARBINARY did not decode to raw bytes" + ); + cleanup_stmt(stmt); + } +} + +/// End-to-end proof that `TrinoBackend::escape_dialect()` is wired into the +/// execute path: `SQLExecDirect` is given raw ODBC escape syntax +/// (`{fn UCASE(...)}`, `{d '...'}`) that is not valid Trino SQL on its own, +/// and only succeeds because `sql_exec_direct_w` translates it first (see +/// `crate::escape_dialect`). +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn escape_fn_and_date_literal_translate_for_trino() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + assert_eq!( + exec_direct( + stmt, + "SELECT {fn UCASE('abc')}, CAST({d '2020-01-01'} AS VARCHAR)" + ), + SqlReturn::SUCCESS, + "exec_direct with {{fn}}/{{d}} escapes failed to translate" + ); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + assert_eq!( + get_wchar_col(stmt, 1), + "ABC", + "{{fn UCASE(...)}} not remapped to upper()" + ); + assert_eq!( + get_wchar_col(stmt, 2), + "2020-01-01", + "{{d '...'}} not rendered as Trino's DATE '...' literal" + ); + cleanup_stmt(stmt); + } +} + +/// Every `{fn ...}` escape the `SQL_*_FUNCTIONS` bitmaps advertise, executed +/// against a real coordinator. +/// +/// This is the check the bitmaps need. The unit tests assert that a rewrite +/// exists and what text it produces, and only the server can say whether that +/// text runs: an advertised escape can still fail there with +/// `FUNCTION_NOT_FOUND` or `COLUMN_NOT_FOUND`. +/// +/// Where the value is deterministic it is asserted; where it is not (`now()`, +/// `current_user`) executing without error is the whole point. `DAYOFWEEK` is +/// asserted precisely, because a rename alone would return a *plausible* +/// wrong answer: 2020-02-03 is a Monday, which is 2 in ODBC's numbering and 1 +/// in Trino's ISO one. +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn every_advertised_scalar_function_escape_runs_on_trino() { + // (escape, expected value as text, or None for "must merely run") + let cases: &[(&str, Option<&str>)] = &[ + // Rewritten: argument syntax. + ("{fn LOCATE('b', 'abc')}", Some("2")), + // Passed through: ODBC already spells POSITION Trino's way. + ("{fn POSITION('b' IN 'abc')}", Some("2")), + // Rewritten: bare keywords, the trailing () removed. + ("{fn CURDATE()}", None), + ("{fn CURTIME()}", None), + ("{fn CURRENT_DATE()}", None), + ("{fn CURRENT_TIME()}", None), + ("{fn CURRENT_TIMESTAMP()}", None), + ("{fn USERNAME()}", None), + // Rewritten: the interval keyword becomes a quoted unit. + ( + "{fn TIMESTAMPADD(SQL_TSI_DAY, 1, TIMESTAMP '2020-01-01 00:00:00')}", + Some("2020-01-02 00:00:00"), + ), + ( + "{fn TIMESTAMPDIFF(SQL_TSI_DAY, TIMESTAMP '2020-01-01 00:00:00', \ + TIMESTAMP '2020-01-03 00:00:00')}", + Some("2"), + ), + // Rewritten: ISO numbering converted to ODBC's. + ("{fn DAYOFWEEK(DATE '2020-02-03')}", Some("2")), + // Remapped: a plain rename. + ("{fn UCASE('a')}", Some("A")), + ("{fn LCASE('A')}", Some("a")), + ("{fn CHAR(65)}", Some("A")), + ("{fn IFNULL(NULL, 'x')}", Some("x")), + ("{fn DAYOFMONTH(DATE '2020-02-03')}", Some("3")), + ("{fn DAYOFYEAR(DATE '2020-02-03')}", Some("34")), + ("{fn LOG(1)}", Some("0E0")), + // Passed through: spelled identically in Trino. + ("{fn CONCAT('a', 'b')}", Some("ab")), + ("{fn SUBSTRING('abc', 2, 1)}", Some("b")), + ("{fn LENGTH('ab')}", Some("2")), + ("{fn LTRIM(' a')}", Some("a")), + ("{fn RTRIM('a ')}", Some("a")), + ("{fn REPLACE('a', 'a', 'b')}", Some("b")), + ("{fn SOUNDEX('Robert')}", Some("R163")), + ("{fn NOW()}", None), + ("{fn MONTH(DATE '2020-02-03')}", Some("2")), + ("{fn QUARTER(DATE '2020-02-03')}", Some("1")), + ("{fn WEEK(DATE '2020-02-03')}", Some("6")), + ("{fn YEAR(DATE '2020-02-03')}", Some("2020")), + ("{fn HOUR(TIMESTAMP '2020-02-03 04:05:06')}", Some("4")), + ("{fn MINUTE(TIMESTAMP '2020-02-03 04:05:06')}", Some("5")), + ("{fn SECOND(TIMESTAMP '2020-02-03 04:05:06')}", Some("6")), + ("{fn EXTRACT(YEAR FROM DATE '2020-02-03')}", Some("2020")), + ("{fn ABS(-1)}", Some("1")), + ("{fn CEILING(1.2)}", Some("2")), + ("{fn FLOOR(1.8)}", Some("1")), + ("{fn MOD(5, 2)}", Some("1")), + ("{fn POWER(2, 3)}", Some("8.0E0")), + ("{fn ROUND(1.5, 0)}", Some("2.0")), + ("{fn SIGN(-2)}", Some("-1")), + ("{fn SQRT(4)}", Some("2.0E0")), + // A zero digit count scales by nothing, so this is the bare + // single-argument `truncate` and keeps the literal's decimal type, + // where POWER and SQRT above are double and render in exponent form. + ("{fn TRUNCATE(1.9, 0)}", Some("1")), + ]; + + unsafe { + for (escape, expected) in cases { + let (_env, _conn, stmt) = alloc_stmt(); + let sql = format!("SELECT CAST(({escape}) AS VARCHAR)"); + assert_eq!( + exec_direct(stmt, &sql), + SqlReturn::SUCCESS, + "{escape} is advertised in a SQL_*_FUNCTIONS bitmap but did not \ + execute: the translation is missing or produces invalid Trino SQL" + ); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS, + "{escape} executed but returned no row" + ); + if let Some(want) = expected { + assert_eq!( + get_wchar_col(stmt, 1), + *want, + "{escape} returned the wrong value" + ); + } + cleanup_stmt(stmt); + } + } +} + +/// The `{fn CONVERT}` counterpart to +/// `every_advertised_scalar_function_escape_runs_on_trino`. +/// +/// That test walks the `SQL_*_FUNCTIONS` bitmaps, and `CONVERT` is not in any +/// of them: it is advertised through `SQL_CONVERT_FUNCTIONS` reporting +/// `SQL_FN_CVT_CAST` instead. Which is exactly how the escape stayed advertised +/// and untranslated: `SELECT {fn CONVERT('1', SQL_INTEGER)}` reached Trino as a +/// two-argument function call and failed with `COLUMN_NOT_FOUND` on +/// `sql_integer`, and no test walked the bitmap that promised it. +/// +/// Every ODBC type keyword with a mapping is exercised, because a client +/// reading the bitmap may send any of them, and only the server can say whether +/// the `CAST` this produces is one Trino accepts. +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn every_convert_escape_target_runs_on_trino() { + // (value expression, ODBC type keyword, expected text, or None for "must + // merely run") + let cases: &[(&str, &str, Option<&str>)] = &[ + ("'1'", "SQL_BIGINT", Some("1")), + ("'1'", "SQL_INTEGER", Some("1")), + ("'1'", "SQL_SMALLINT", Some("1")), + ("'1'", "SQL_TINYINT", Some("1")), + ("'1'", "SQL_DOUBLE", Some("1.0E0")), + ("'1'", "SQL_FLOAT", Some("1.0E0")), + ("'1'", "SQL_REAL", Some("1.0E0")), + ("'1'", "SQL_DECIMAL", Some("1")), + ("'1'", "SQL_NUMERIC", Some("1")), + ("'true'", "SQL_BIT", Some("true")), + // The truncation guard: a bare CHAR in Trino is CHAR(1), so a wrong + // mapping here returns "h" rather than failing loudly. + ("'hello world'", "SQL_CHAR", Some("hello world")), + ("'hello world'", "SQL_VARCHAR", Some("hello world")), + ("'hello world'", "SQL_LONGVARCHAR", Some("hello world")), + ("'hello world'", "SQL_WCHAR", Some("hello world")), + ("'hello world'", "SQL_WVARCHAR", Some("hello world")), + ("'hello world'", "SQL_WLONGVARCHAR", Some("hello world")), + // Trino rejects CAST(varbinary AS VARCHAR), so these are read back + // through to_hex instead of the shared wrapper below. 0x61 is 'a'. + ("'a'", "SQL_BINARY", Some("61")), + ("'a'", "SQL_VARBINARY", Some("61")), + ("'a'", "SQL_LONGVARBINARY", Some("61")), + ("'2020-02-03'", "SQL_DATE", Some("2020-02-03")), + ("'2020-02-03'", "SQL_TYPE_DATE", Some("2020-02-03")), + ("'04:05:06'", "SQL_TIME", None), + ("'04:05:06'", "SQL_TYPE_TIME", None), + ("'2020-02-03 04:05:06'", "SQL_TIMESTAMP", None), + ("'2020-02-03 04:05:06'", "SQL_TYPE_TIMESTAMP", None), + ( + "'12151fd2-7586-11e9-8f9e-2a86e4085a59'", + "SQL_GUID", + Some("12151fd2-7586-11e9-8f9e-2a86e4085a59"), + ), + ]; + + unsafe { + for (value, keyword, expected) in cases { + let (_env, _conn, stmt) = alloc_stmt(); + let escape = format!("{{fn CONVERT({value}, {keyword})}}"); + // The result has to come back as text to be compared, and Trino + // has no VARBINARY -> VARCHAR cast, so those read through to_hex. + let sql = if keyword.contains("BINARY") { + format!("SELECT to_hex({escape})") + } else { + format!("SELECT CAST(({escape}) AS VARCHAR)") + }; + assert_eq!( + exec_direct(stmt, &sql), + SqlReturn::SUCCESS, + "{escape} is advertised through SQL_CONVERT_FUNCTIONS but did \ + not execute: the translation is missing or produces invalid \ + Trino SQL" + ); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS, + "{escape} executed but returned no row" + ); + if let Some(want) = expected { + assert_eq!( + get_wchar_col(stmt, 1), + *want, + "{escape} returned the wrong value" + ); + } + cleanup_stmt(stmt); + } + } +} + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn date_columns_return_column_date_not_string() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + assert_eq!( + exec_direct( + stmt, + "SELECT d_date FROM tpcds.sf1.date_dim WHERE d_date IS NOT NULL LIMIT 1" + ), + SqlReturn::SUCCESS + ); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + // SQL_DATE_STRUCT layout: year (i16) + month (u16) + day (u16) = 6 bytes + let mut date_buf = [0u8; 6]; + let mut ind: isize = 0; + let ret = ffi::fetch::sql_get_data::<TrinoBackend>( + stmt, + 1, + CDataType::TypeDate as i16, + date_buf.as_mut_ptr().cast(), + date_buf.len() as isize, + &mut ind, + ); + assert_eq!(ret, SqlReturn::SUCCESS, "TypeDate conversion failed"); + cleanup_stmt(stmt); + } +} + +/// A `time(3)` value's milliseconds must survive `SQLGetData(SQL_C_WCHAR)` as +/// text, even though `SQL_TIME_STRUCT` (the target of `SQL_C_TYPE_TIME`) has +/// no field to hold them. `time(3)` is Trino's normal default precision for +/// `TIME` (unlike the ANSI SQL default of 0), so this is the common case, not +/// an edge case; the fraction must not be dropped before the C type of the +/// target is known. The literal below is used instead of the +/// `postgresql.public.types_test.col_time` column (a real `time(6)` column, +/// per `\d types_test` in the PostgreSQL container) because that table's +/// seed data (`test/postgres-init.sql`) only has whole-second values. +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn time_with_fraction_keeps_milliseconds_via_get_data_string() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + assert_eq!( + exec_direct(stmt, "SELECT TIME '13:30:15.123'"), + SqlReturn::SUCCESS + ); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + let mut buf = [0u16; 32]; + let mut ind: isize = 0; + let ret = ffi::fetch::sql_get_data::<TrinoBackend>( + stmt, + 1, + CDataType::WChar as i16, + buf.as_mut_ptr().cast(), + (buf.len() * 2) as isize, + &mut ind, + ); + assert_eq!(ret, SqlReturn::SUCCESS, "SQLGetData(WChar) failed"); + let char_count = (ind / 2) as usize; + let s = String::from_utf16_lossy(&buf[..char_count]); + assert_eq!( + s, "13:30:15.123", + "time(3) fraction did not survive as text" + ); + cleanup_stmt(stmt); + } +} + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn exec_direct_select_and_fetch() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + assert_eq!(exec_direct(stmt, "SELECT 1 AS n"), SqlReturn::SUCCESS); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + let mut val: i32 = 0; + let mut ind: isize = 0; + let ret = ffi::fetch::sql_get_data::<TrinoBackend>( + stmt, + 1, + CDataType::SLong as i16, + (&raw mut val).cast(), + 4, + &mut ind, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + assert_eq!(val, 1); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::NO_DATA + ); + cleanup_stmt(stmt); + } +} + +/// Bind a single i64 parameter on `stmt`. +unsafe fn bind_i64(stmt: *mut c_void, param: u16, val: &mut i64) -> SqlReturn { + unsafe { + ffi::params::sql_bind_parameter::<TrinoBackend>( + stmt, + param, + ParamType::Input as i16, + CDataType::SBigInt as i16, + SqlDataType::EXT_BIG_INT.0, + 19, + 0, + (val as *mut i64).cast(), + std::mem::size_of::<i64>() as isize, + std::ptr::null_mut(), + ) + } +} + +/// Fetch a single i64 from column 1 and assert there are no further rows. +unsafe fn fetch_one_i64(stmt: *mut c_void) -> i64 { + unsafe { + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS, + "expected a row" + ); + let mut val: i64 = 0; + let mut ind: isize = 0; + assert_eq!( + ffi::fetch::sql_get_data::<TrinoBackend>( + stmt, + 1, + CDataType::SBigInt as i16, + (&raw mut val).cast(), + 8, + &mut ind, + ), + SqlReturn::SUCCESS + ); + val + } +} + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn prepared_statement_binds_parameter() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + let sql = "SELECT CAST(? AS BIGINT) AS n"; + let wide: Vec<u16> = sql.encode_utf16().collect(); + assert_eq!( + ffi::execute::sql_prepare_w::<TrinoBackend>(stmt, wide.as_ptr(), wide.len() as i32), + SqlReturn::SUCCESS + ); + + let mut val: i64 = 4242; + assert_eq!(bind_i64(stmt, 1, &mut val), SqlReturn::SUCCESS); + assert_eq!( + ffi::execute::sql_execute::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + assert_eq!(fetch_one_i64(stmt), 4242, "bound parameter was not sent"); + + cleanup_stmt(stmt); + } +} + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn prepared_statement_re_executes_with_new_parameter() { + // The point of preparing is running the same statement with different + // values; the second execute must not fail or reuse the first value. + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + let sql = "SELECT CAST(? AS BIGINT) AS n"; + let wide: Vec<u16> = sql.encode_utf16().collect(); + assert_eq!( + ffi::execute::sql_prepare_w::<TrinoBackend>(stmt, wide.as_ptr(), wide.len() as i32), + SqlReturn::SUCCESS + ); + + for expected in [1i64, 2, 3] { + let mut val: i64 = expected; + assert_eq!(bind_i64(stmt, 1, &mut val), SqlReturn::SUCCESS); + assert_eq!( + ffi::execute::sql_execute::<TrinoBackend>(stmt), + SqlReturn::SUCCESS, + "execute failed for value {expected}" + ); + assert_eq!(fetch_one_i64(stmt), expected); + assert_eq!( + ffi::cursor::sql_close_cursor::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + } + + cleanup_stmt(stmt); + } +} + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn string_parameter_with_quotes_is_not_injected() { + // A payload that would break out of the literal must come back verbatim as + // data. If escaping were wrong this would be a syntax error or return the + // wrong row. + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + let sql = "SELECT CAST(? AS VARCHAR) AS s"; + let wide: Vec<u16> = sql.encode_utf16().collect(); + assert_eq!( + ffi::execute::sql_prepare_w::<TrinoBackend>(stmt, wide.as_ptr(), wide.len() as i32), + SqlReturn::SUCCESS + ); + + let payload = "'; DROP TABLE users; --"; + let mut buf: Vec<u8> = payload.as_bytes().to_vec(); + // The indicator is passed explicitly rather than left NULL. A NULL + // `StrLen_or_IndPtr` means "this buffer is null-terminated" per + // SQLBindParameter, so the driver scans for a NUL, while `to_vec()` + // produces exactly `payload.len()` bytes with no terminator. The two + // together read past the allocation into whatever the heap holds + // next, which appears here as the payload plus trailing garbage, and + // only when an earlier test has dirtied the allocator: run alone, + // this test passes either way. See + // `string_parameter_bound_as_nts_is_not_injected` for the + // null-terminated form of the same binding. + let mut ind_in: isize = buf.len() as isize; + assert_eq!( + ffi::params::sql_bind_parameter::<TrinoBackend>( + stmt, + 1, + ParamType::Input as i16, + CDataType::Char as i16, + SqlDataType::VARCHAR.0, + buf.len(), + 0, + buf.as_mut_ptr().cast(), + buf.len() as isize, + &mut ind_in, + ), + SqlReturn::SUCCESS + ); + assert_eq!( + ffi::execute::sql_execute::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + let mut out = [0u8; 64]; + let mut ind: isize = 0; + assert_eq!( + ffi::fetch::sql_get_data::<TrinoBackend>( + stmt, + 1, + CDataType::Char as i16, + out.as_mut_ptr().cast(), + out.len() as isize, + &mut ind, + ), + SqlReturn::SUCCESS + ); + let got = std::str::from_utf8(&out[..ind as usize]).expect("utf8"); + assert_eq!(got, payload, "payload was altered in transit"); + + cleanup_stmt(stmt); + } +} + +/// The same payload bound the other legal way: a null-terminated buffer with a +/// NULL `StrLen_or_IndPtr`. +/// +/// `SQLBindParameter` defines a NULL indicator as "the data is +/// null-terminated", so this is the path where the driver scans for the +/// terminator rather than being told the length. It was untested, which is why +/// the sibling test above could pass a buffer carrying no terminator down that +/// path and have the resulting over-read read as an injection failure. +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn string_parameter_bound_as_nts_is_not_injected() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + let sql = "SELECT CAST(? AS VARCHAR) AS s"; + let wide: Vec<u16> = sql.encode_utf16().collect(); + assert_eq!( + ffi::execute::sql_prepare_w::<TrinoBackend>(stmt, wide.as_ptr(), wide.len() as i32), + SqlReturn::SUCCESS + ); + + let payload = "'; DROP TABLE users; --"; + // The trailing NUL is the whole point: it is what makes a NULL + // indicator a legal binding. + let mut buf: Vec<u8> = payload.as_bytes().to_vec(); + buf.push(0); + assert_eq!( + ffi::params::sql_bind_parameter::<TrinoBackend>( + stmt, + 1, + ParamType::Input as i16, + CDataType::Char as i16, + SqlDataType::VARCHAR.0, + payload.len(), + 0, + buf.as_mut_ptr().cast(), + buf.len() as isize, + std::ptr::null_mut(), + ), + SqlReturn::SUCCESS + ); + assert_eq!( + ffi::execute::sql_execute::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + let mut out = [0u8; 64]; + let mut ind: isize = 0; + assert_eq!( + ffi::fetch::sql_get_data::<TrinoBackend>( + stmt, + 1, + CDataType::Char as i16, + out.as_mut_ptr().cast(), + out.len() as isize, + &mut ind, + ), + SqlReturn::SUCCESS + ); + let got = std::str::from_utf8(&out[..ind as usize]).expect("utf8"); + assert_eq!(got, payload, "payload was altered in transit"); + + cleanup_stmt(stmt); + } +} + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn columns_returns_tpcds_sf1_columns() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + let catalog: Vec<u16> = "tpcds".encode_utf16().collect(); + let schema: Vec<u16> = "sf1".encode_utf16().collect(); + let table: Vec<u16> = "customer".encode_utf16().collect(); + let ret = ffi::metadata::sql_columns_w::<TrinoBackend>( + stmt, + catalog.as_ptr(), + catalog.len() as i16, + schema.as_ptr(), + schema.len() as i16, + table.as_ptr(), + table.len() as i16, + std::ptr::null(), + 0, + ); + if ret != SqlReturn::SUCCESS { + // Read the diagnostic to understand the failure. + let mut state = [0u16; 6]; + let mut msg = [0u16; 512]; + let mut msg_len: i16 = 0; + let mut native: i32 = 0; + let _ = ffi::diag::sql_get_diag_rec_w::<TrinoBackend>( + HandleType::Stmt as i16, + stmt, + 1, + state.as_mut_ptr(), + &mut native, + msg.as_mut_ptr(), + msg.len() as i16, + &mut msg_len, + ); + let state_str = String::from_utf16_lossy(&state[..5]); + let msg_str = String::from_utf16_lossy(&msg[..msg_len as usize]); + panic!("SQLColumnsW returned {ret:?}: SQLSTATE={state_str} msg={msg_str}"); + } + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS, + "expected at least one column" + ); + cleanup_stmt(stmt); + } +} + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn tables_catalog_enumeration_mode() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + // catalog="%" + schema="" + table="" → ODBC catalog enumeration mode + let catalog: Vec<u16> = "%".encode_utf16().collect(); + let schema: Vec<u16> = "".encode_utf16().collect(); + let table: Vec<u16> = "".encode_utf16().collect(); + let ret = ffi::metadata::sql_tables_w::<TrinoBackend>( + stmt, + catalog.as_ptr(), + catalog.len() as i16, + schema.as_ptr(), + schema.len() as i16, + table.as_ptr(), + table.len() as i16, + std::ptr::null(), + 0, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS, + "expected at least one catalog row" + ); + cleanup_stmt(stmt); + } +} + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn tables_schema_enumeration_mode() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + // catalog="" + schema="%" + table="" → ODBC schema enumeration mode + let catalog: Vec<u16> = "".encode_utf16().collect(); + let schema: Vec<u16> = "%".encode_utf16().collect(); + let table: Vec<u16> = "".encode_utf16().collect(); + let ret = ffi::metadata::sql_tables_w::<TrinoBackend>( + stmt, + catalog.as_ptr(), + catalog.len() as i16, + schema.as_ptr(), + schema.len() as i16, + table.as_ptr(), + table.len() as i16, + std::ptr::null(), + 0, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS, + "expected at least one schema row" + ); + cleanup_stmt(stmt); + } +} + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn tables_table_type_enumeration_mode() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + // catalog="" + schema="" + table="" + table_type="%" → table type enumeration mode + let catalog: Vec<u16> = "".encode_utf16().collect(); + let schema: Vec<u16> = "".encode_utf16().collect(); + let table: Vec<u16> = "".encode_utf16().collect(); + let table_type: Vec<u16> = "%".encode_utf16().collect(); + let ret = ffi::metadata::sql_tables_w::<TrinoBackend>( + stmt, + catalog.as_ptr(), + catalog.len() as i16, + schema.as_ptr(), + schema.len() as i16, + table.as_ptr(), + table.len() as i16, + table_type.as_ptr(), + table_type.len() as i16, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + // TABLE and VIEW must be present + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS, + "expected TABLE row" + ); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS, + "expected VIEW row" + ); + cleanup_stmt(stmt); + } +} + +// --------------------------------------------------------------------------- +// New variant tests: exotic Trino types fetched as WChar strings +// --------------------------------------------------------------------------- + +// The variant tests below verify the full type-conversion chain: +// Trino REST response → ColumnValue variant → C WChar buffer +// At the FFI level, ColumnValue is not directly observable: sql_get_data +// has already marshalled it to a C string. Checking the WChar output +// is the correct way to assert "the correct variant is returned via +// sql_get_data" at this layer of the stack. + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn decimal_literal_returns_wchar_string() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + assert_eq!( + exec_direct(stmt, "SELECT CAST(123.456 AS DECIMAL(6,3)) AS v"), + SqlReturn::SUCCESS + ); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + let s = fetch_wchar(stmt); + assert!(s.contains("123.456"), "expected '123.456' in {s:?}"); + cleanup_stmt(stmt); + } +} + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn json_literal_returns_wchar_string() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + assert_eq!( + exec_direct(stmt, r#"SELECT JSON '{"key":"value"}' AS v"#), + SqlReturn::SUCCESS + ); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + let s = fetch_wchar(stmt); + assert!(s.contains("key"), "expected 'key' in {s:?}"); + assert!(s.contains("value"), "expected 'value' in {s:?}"); + cleanup_stmt(stmt); + } +} + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn interval_year_month_returns_wchar() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + assert_eq!( + exec_direct(stmt, "SELECT INTERVAL '3-7' YEAR TO MONTH AS v"), + SqlReturn::SUCCESS + ); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + let s = fetch_wchar(stmt); + assert!(s.contains('3'), "expected '3' in {s:?}"); + assert!(s.contains('7'), "expected '7' in {s:?}"); + cleanup_stmt(stmt); + } +} + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn interval_day_time_returns_wchar() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + assert_eq!( + exec_direct(stmt, "SELECT INTERVAL '2 03:04:05.678' DAY TO SECOND AS v"), + SqlReturn::SUCCESS + ); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + let s = fetch_wchar(stmt); + assert!(s.contains('2'), "expected '2' in {s:?}"); + cleanup_stmt(stmt); + } +} + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn timestamp_with_tz_returns_wchar() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + assert_eq!( + exec_direct(stmt, "SELECT TIMESTAMP '2024-03-15 10:30:00 +00:00' AS v"), + SqlReturn::SUCCESS + ); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + let s = fetch_wchar(stmt); + assert!(s.contains("2024"), "expected '2024' in {s:?}"); + cleanup_stmt(stmt); + } +} + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn timestamp_with_named_tz_returns_utc_via_get_data() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + // America/New_York in March 2025 is EDT (UTC-4). + // 20:21:22 EDT → 2025-03-11 00:21:22 UTC. + assert_eq!( + exec_direct( + stmt, + "SELECT TIMESTAMP '2025-03-10 20:21:22.123 America/New_York' AS v" + ), + SqlReturn::SUCCESS + ); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + + let mut buf = [0u8; std::mem::size_of::<odbc_sys::Timestamp>()]; + let mut ind: isize = 0; + let ret = ffi::fetch::sql_get_data::<TrinoBackend>( + stmt, + 1, + CDataType::TypeTimestamp as i16, + buf.as_mut_ptr().cast(), + std::mem::size_of::<odbc_sys::Timestamp>() as isize, + &mut ind, + ); + assert_eq!( + ret, + SqlReturn::SUCCESS, + "sql_get_data(TypeTimestamp) failed" + ); + + let ts = std::ptr::read(buf.as_ptr().cast::<odbc_sys::Timestamp>()); + assert_eq!(ts.year, 2025, "year"); + assert_eq!(ts.month, 3, "month"); + assert_eq!(ts.day, 11, "day (should roll forward from 10th)"); + assert_eq!(ts.hour, 0, "hour (20 EDT → 0 UTC)"); + assert_eq!(ts.minute, 21, "minute"); + assert_eq!(ts.second, 22, "second"); + assert_eq!(ts.fraction, 123_000_000, "fraction (nanoseconds)"); + + cleanup_stmt(stmt); + } +} + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn timestamp_with_utc_tz_returns_utc_via_get_data() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + assert_eq!( + exec_direct(stmt, "SELECT TIMESTAMP '2020-05-05 22:00:00.000 UTC' AS v"), + SqlReturn::SUCCESS + ); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + + let mut buf = [0u8; std::mem::size_of::<odbc_sys::Timestamp>()]; + let mut ind: isize = 0; + let ret = ffi::fetch::sql_get_data::<TrinoBackend>( + stmt, + 1, + CDataType::TypeTimestamp as i16, + buf.as_mut_ptr().cast(), + std::mem::size_of::<odbc_sys::Timestamp>() as isize, + &mut ind, + ); + assert_eq!( + ret, + SqlReturn::SUCCESS, + "sql_get_data(TypeTimestamp) failed" + ); + + let ts = std::ptr::read(buf.as_ptr().cast::<odbc_sys::Timestamp>()); + assert_eq!(ts.year, 2020); + assert_eq!(ts.month, 5); + assert_eq!(ts.day, 5); + assert_eq!(ts.hour, 22); + assert_eq!(ts.minute, 0); + assert_eq!(ts.second, 0); + assert_eq!(ts.fraction, 0); + + cleanup_stmt(stmt); + } +} + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn array_literal_returns_wchar() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + assert_eq!( + exec_direct(stmt, "SELECT ARRAY[1, 2, 3] AS v"), + SqlReturn::SUCCESS + ); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + let s = fetch_wchar(stmt); + assert!(s.contains('1'), "expected '1' in {s:?}"); + assert!(s.contains('2'), "expected '2' in {s:?}"); + assert!(s.contains('3'), "expected '3' in {s:?}"); + cleanup_stmt(stmt); + } +} + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn map_literal_returns_wchar() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + assert_eq!( + exec_direct(stmt, "SELECT MAP(ARRAY['a'], ARRAY[1]) AS v"), + SqlReturn::SUCCESS + ); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + let s = fetch_wchar(stmt); + assert!(s.contains('a'), "expected 'a' in {s:?}"); + assert!(s.contains('1'), "expected '1' in {s:?}"); + cleanup_stmt(stmt); + } +} + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn row_literal_returns_wchar() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + assert_eq!( + exec_direct(stmt, "SELECT ROW(1, 'hello', true) AS v"), + SqlReturn::SUCCESS + ); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + let s = fetch_wchar(stmt); + assert!(s.contains('1'), "expected '1' in {s:?}"); + assert!(s.contains("hello"), "expected 'hello' in {s:?}"); + cleanup_stmt(stmt); + } +} + +// --------------------------------------------------------------------------- +// P1: SQLGetData truncation +// --------------------------------------------------------------------------- + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn get_data_truncates_string_returns_success_with_info() { + // Verifies that reading a string column into a buffer that is too small + // returns SUCCESS_WITH_INFO (SQLSTATE 01004) and writes the truncated value. + // "hello world here" (16 chars); buffer holds 4 u16 slots (8 bytes) → + // capacity for 3 chars + null → truncated to "hel\0", ind = 32 bytes. + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + + assert_eq!( + exec_direct(stmt, "SELECT 'hello world here' AS v"), + SqlReturn::SUCCESS + ); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + + // 4 u16 slots = 8 bytes → capacity for 3 chars + null terminator. + let mut wbuf = [0u16; 4]; + let mut ind: isize = 0; + let ret = ffi::fetch::sql_get_data::<TrinoBackend>( + stmt, + 1, + CDataType::WChar as i16, + wbuf.as_mut_ptr().cast(), + 8, // bytes + &mut ind, + ); + assert_eq!(ret, SqlReturn::SUCCESS_WITH_INFO); + // ind reports the full byte count of the original string (no null). + assert_eq!(ind, 32); // 16 chars × 2 bytes + // Buffer contains "hel\0". + assert_eq!(String::from_utf16_lossy(&wbuf[..3]), "hel"); + assert_eq!(wbuf[3], 0u16); + + cleanup_stmt(stmt); + } +} + +// --------------------------------------------------------------------------- +// P1: Fetch after NO_DATA returns NO_DATA again (not ERROR) +// --------------------------------------------------------------------------- + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn fetch_after_no_data_returns_no_data_again() { + // After a result set is exhausted (SQLFetch returns NO_DATA), subsequent + // SQLFetch calls must also return NO_DATA, not ERROR or panic. + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + + assert_eq!(exec_direct(stmt, "SELECT 1 AS v"), SqlReturn::SUCCESS); + + // Fetch the single row. + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + // Cursor exhausted. + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::NO_DATA + ); + // A second call past the end must still return NO_DATA, not ERROR. + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::NO_DATA + ); + + cleanup_stmt(stmt); + } +} + +// --------------------------------------------------------------------------- +// P1: Statement handle is reusable after an error +// --------------------------------------------------------------------------- + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn exec_direct_reuse_after_error() { + // After a failed exec_direct (invalid SQL → SQL_ERROR), the same statement + // handle must accept a valid query and succeed. + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + + // Invalid SQL: must fail. + assert_eq!(exec_direct(stmt, "NOT VALID SQL AT ALL"), SqlReturn::ERROR); + + // Valid query on the same handle: must succeed. + assert_eq!(exec_direct(stmt, "SELECT 1 AS v"), SqlReturn::SUCCESS); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + let mut val: i32 = 0; + let mut ind: isize = 0; + assert_eq!( + ffi::fetch::sql_get_data::<TrinoBackend>( + stmt, + 1, + CDataType::SLong as i16, + (&raw mut val).cast(), + 4, + &mut ind, + ), + SqlReturn::SUCCESS + ); + assert_eq!(val, 1); + + cleanup_stmt(stmt); + } +} + +// --------------------------------------------------------------------------- +// P2: SQLColAttributeW: nullable, precision, octet_length via FFI +// --------------------------------------------------------------------------- + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn sql_col_attribute_w_returns_nullable() { + // SQL_DESC_NULLABLE (1008): verify the field is readable and returns a + // valid ODBC nullable value (0 = not nullable, 1 = nullable, 2 = unknown). + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + + assert_eq!( + exec_direct( + stmt, + "SELECT c_customer_sk, c_first_name FROM tpcds.sf1.customer LIMIT 1" + ), + SqlReturn::SUCCESS + ); + + for col in [1u16, 2u16] { + let mut num_attr: isize = 99; + let ret = ffi::metadata::sql_col_attribute_w::<TrinoBackend>( + stmt, + col, + Desc::Nullable as u16, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + &mut num_attr, + ); + assert_eq!(ret, SqlReturn::SUCCESS, "col {col}"); + assert!( + (0..=2).contains(&num_attr), + "col {col}: nullable must be 0, 1, or 2, got {num_attr}" + ); + } + + cleanup_stmt(stmt); + } +} + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn sql_col_attribute_w_returns_precision_for_integer() { + // SQL_DESC_PRECISION (1005): integer columns must return a positive precision. + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + + assert_eq!( + exec_direct(stmt, "SELECT c_birth_year FROM tpcds.sf1.customer LIMIT 1"), + SqlReturn::SUCCESS + ); + + let mut num_attr: isize = -1; + let ret = ffi::metadata::sql_col_attribute_w::<TrinoBackend>( + stmt, + 1, + Desc::Precision as u16, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + &mut num_attr, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + assert!( + num_attr >= 0, + "precision must be non-negative, got {num_attr}" + ); + + cleanup_stmt(stmt); + } +} + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn sql_col_attribute_w_returns_octet_length_for_integer() { + // SQL_DESC_OCTET_LENGTH (1013): integer columns must return a positive length. + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + + assert_eq!( + exec_direct(stmt, "SELECT c_birth_year FROM tpcds.sf1.customer LIMIT 1"), + SqlReturn::SUCCESS + ); + + let mut num_attr: isize = -1; + let ret = ffi::metadata::sql_col_attribute_w::<TrinoBackend>( + stmt, + 1, + Desc::OctetLength as u16, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + &mut num_attr, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + assert!( + num_attr > 0, + "octet_length must be positive, got {num_attr}" + ); + + cleanup_stmt(stmt); + } +} + +/// A `timestamp(6)` column's declared fractional-seconds scale must reach +/// three different, independently-meaningful descriptor fields with three +/// different correct values (a column's declared temporal scale must not be +/// ignored): +/// +/// - `SQL_DESC_LENGTH`/`COLUMN_SIZE`: the character length of the string +/// representation, `20 + s` = 26 (ODBC "Column Size" appendix). +/// - `SQL_DESC_PRECISION`: the fractional-seconds scale itself, 6 (per the +/// `SQLColAttribute` spec: "For data types SQL_TYPE_TIME, +/// SQL_TYPE_TIMESTAMP, ... its value is the applicable precision of the +/// fractional seconds component"). +/// +/// If `has_precision_param()` excluded TIME/TIMESTAMP, +/// `type_name_precision("timestamp(6)")` would fall back to `fixed_precision()`, +/// a constant derived from the undeclared-column default scale (3), and +/// both fields would report the values for scale 3 (23/3) rather than the +/// column's actual declared scale 6 (26/6). Treating the parenthesised +/// argument as `SQL_DESC_LENGTH` directly would instead push `SQL_DESC_LENGTH` +/// to `6` (the scale, not the column size); this test pins both fields +/// independently so neither mistake can pass silently. +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn timestamp_6_reports_correct_length_and_precision_via_sql_col_attribute() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + + assert_eq!( + exec_direct( + stmt, + "SELECT CAST(TIMESTAMP '2024-03-05 13:30:15.123456' AS TIMESTAMP(6))" + ), + SqlReturn::SUCCESS + ); + + let mut length: isize = -1; + assert_eq!( + ffi::metadata::sql_col_attribute_w::<TrinoBackend>( + stmt, + 1, + Desc::Length as u16, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + &mut length, + ), + SqlReturn::SUCCESS + ); + assert_eq!(length, 26, "SQL_DESC_LENGTH/COLUMN_SIZE must be 20 + 6"); + + let mut precision: isize = -1; + assert_eq!( + ffi::metadata::sql_col_attribute_w::<TrinoBackend>( + stmt, + 1, + Desc::Precision as u16, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + &mut precision, + ), + SqlReturn::SUCCESS + ); + assert_eq!( + precision, 6, + "SQL_DESC_PRECISION must be the fractional-seconds scale, not the column size" + ); + + cleanup_stmt(stmt); + } +} + +/// The `TIME` counterpart of the test above: `time(6)` must report +/// `SQL_DESC_LENGTH` = `9 + 6` = 15 and `SQL_DESC_PRECISION` = 6. +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn time_6_reports_correct_length_and_precision_via_sql_col_attribute() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + + assert_eq!( + exec_direct(stmt, "SELECT CAST(TIME '13:30:15.123456' AS TIME(6))"), + SqlReturn::SUCCESS + ); + + let mut length: isize = -1; + assert_eq!( + ffi::metadata::sql_col_attribute_w::<TrinoBackend>( + stmt, + 1, + Desc::Length as u16, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + &mut length, + ), + SqlReturn::SUCCESS + ); + assert_eq!(length, 15, "SQL_DESC_LENGTH/COLUMN_SIZE must be 9 + 6"); + + let mut precision: isize = -1; + assert_eq!( + ffi::metadata::sql_col_attribute_w::<TrinoBackend>( + stmt, + 1, + Desc::Precision as u16, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + &mut precision, + ), + SqlReturn::SUCCESS + ); + assert_eq!( + precision, 6, + "SQL_DESC_PRECISION must be the fractional-seconds scale, not the column size" + ); + + cleanup_stmt(stmt); + } +} + +// --------------------------------------------------------------------------- +// P2: SQLCloseCursor called twice returns 24000 on the second call +// --------------------------------------------------------------------------- + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn close_cursor_twice_returns_error() { + // The second SQLCloseCursor call must return ERROR (SQLSTATE 24000, invalid + // cursor state) because there is no open cursor after the first close. + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + + assert_eq!(exec_direct(stmt, "SELECT 1 AS v"), SqlReturn::SUCCESS); + + // First close: cursor is open, must succeed. + assert_eq!( + ffi::cursor::sql_close_cursor::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + // Second close: no cursor open, must return ERROR (24000). + assert_eq!( + ffi::cursor::sql_close_cursor::<TrinoBackend>(stmt), + SqlReturn::ERROR + ); + + cleanup_stmt(stmt); + } +} + +// --------------------------------------------------------------------------- +// P2: SQLNumResultCols after SQLPrepare but before SQLExecute +// --------------------------------------------------------------------------- + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn num_result_cols_after_prepare_before_execute() { + // After SQLPrepare (but before SQLExecute), SQLNumResultCols must return + // SUCCESS. The Trino backend returns count=0 because column metadata is + // only populated after execute. + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + + let sql = "SELECT c_customer_sk FROM tpcds.sf1.customer LIMIT 1"; + let wide: Vec<u16> = sql.encode_utf16().collect(); + let ret = + ffi::execute::sql_prepare_w::<TrinoBackend>(stmt, wide.as_ptr(), wide.len() as i32); + assert_eq!(ret, SqlReturn::SUCCESS); + + let mut count: i16 = 99; + let ret = ffi::cursor::sql_num_result_cols::<TrinoBackend>(stmt, &mut count); + assert_eq!(ret, SqlReturn::SUCCESS); + // Column metadata is populated only after execute. + assert_eq!(count, 0); + + cleanup_stmt(stmt); + } +} + +// --------------------------------------------------------------------------- +// P3: SQLGetDiagFieldW: field-by-field after an error +// --------------------------------------------------------------------------- + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn get_diag_field_number_after_error() { + // SQL_DIAG_NUMBER (2) on the header record (rec_number=0) reports the count + // of diagnostic records. After one error it must be 1. + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + + assert_eq!(exec_direct(stmt, "NOT VALID SQL"), SqlReturn::ERROR); + + let mut count: i32 = 0; + let ret = ffi::diag::sql_get_diag_field_w::<TrinoBackend>( + HandleType::Stmt as i16, + stmt, + 0, // header field: rec_number = 0 + HeaderDiagnosticIdentifier::Number as i16, + &mut count as *mut i32 as *mut c_void, + 0, + std::ptr::null_mut(), + ); + assert_eq!(ret, SqlReturn::SUCCESS); + assert_eq!(count, 1, "one diagnostic record after one error"); + + cleanup_stmt(stmt); + } +} + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn get_diag_field_sqlstate_after_error() { + // SQL_DIAG_SQLSTATE (4) on rec_number=1 returns the 5-character SQLSTATE. + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + + assert_eq!(exec_direct(stmt, "NOT VALID SQL"), SqlReturn::ERROR); + + // 6 u16 slots: 5 SQLSTATE chars + null terminator = 12 bytes. + let mut state_buf = [0u16; 6]; + let mut str_len: i16 = 0; + let ret = ffi::diag::sql_get_diag_field_w::<TrinoBackend>( + HandleType::Stmt as i16, + stmt, + 1, // first record + HeaderDiagnosticIdentifier::SqlState as i16, + state_buf.as_mut_ptr() as *mut c_void, + 12, // buffer_length in bytes (6 u16s) + &mut str_len, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + // SQLSTATE is always exactly 5 characters = 10 bytes; StringLengthPtr + // is spec'd in bytes for SQLGetDiagField. + assert_eq!(str_len, 10); + let state = String::from_utf16_lossy(&state_buf[..5]); + assert_eq!(state.len(), 5, "SQLSTATE must be 5 chars"); + + cleanup_stmt(stmt); + } +} + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn get_diag_field_native_error_after_error() { + // SQL_DIAG_NATIVE (5) returns the driver-specific native error code (i32). + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + + assert_eq!(exec_direct(stmt, "NOT VALID SQL"), SqlReturn::ERROR); + + let mut native: i32 = -999; + let mut str_len: i16 = 0; + let ret = ffi::diag::sql_get_diag_field_w::<TrinoBackend>( + HandleType::Stmt as i16, + stmt, + 1, // first record + HeaderDiagnosticIdentifier::Native as i16, + &mut native as *mut i32 as *mut c_void, + 0, + &mut str_len, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + assert_eq!(str_len, 4); // i32 = 4 bytes + let _ = native; + + cleanup_stmt(stmt); + } +} + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn get_diag_field_message_text_after_error() { + // SQL_DIAG_MESSAGE_TEXT (6) returns the diagnostic message string. + // After an invalid-SQL error the message must be non-empty. + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + + assert_eq!(exec_direct(stmt, "NOT VALID SQL"), SqlReturn::ERROR); + + let mut msg_buf = [0u16; 256]; + let mut str_len: i16 = 0; + let buffer_length = + i16::try_from(std::mem::size_of_val(&msg_buf)).expect("msg_buf byte size fits in i16"); + let ret = ffi::diag::sql_get_diag_field_w::<TrinoBackend>( + HandleType::Stmt as i16, + stmt, + 1, // first record + HeaderDiagnosticIdentifier::MessageText as i16, + msg_buf.as_mut_ptr() as *mut c_void, + buffer_length, + &mut str_len, + ); + // SUCCESS_WITH_INFO is also valid when the message is longer than the buffer. + assert!( + matches!(ret, SqlReturn::SUCCESS | SqlReturn::SUCCESS_WITH_INFO), + "expected SUCCESS or SUCCESS_WITH_INFO, got {ret:?}" + ); + assert!(str_len > 0, "diagnostic message must be non-empty"); + // str_len is a BYTE count (SQLGetDiagField spec); convert to UTF-16 + // code units and clamp to the buffer's element count before indexing: + // the untruncated byte count can exceed the buffer capacity. + let code_units = + (usize::try_from(str_len).expect("non-negative length") / 2).min(msg_buf.len()); + let msg = String::from_utf16_lossy(&msg_buf[..code_units]); + assert!(!msg.is_empty()); + + cleanup_stmt(stmt); + } +} + +// --------------------------------------------------------------------------- +// Empty result set: WHERE 1=0 must return SUCCESS with zero rows +// --------------------------------------------------------------------------- + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn empty_result_set_where_false() { + // A query that returns no rows (WHERE 1=0) must: + // - exec_direct → SUCCESS (not ERROR) + // - sql_num_result_cols → SUCCESS with count > 0 (columns are known) + // - first sql_fetch → NO_DATA (not ERROR) + // This is the response-to-DM path: the DM reads column count before + // fetching rows, so metadata must survive even when the row list is empty. + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + + assert_eq!( + exec_direct( + stmt, + "SELECT c_customer_sk FROM tpcds.sf1.customer WHERE 1 = 0" + ), + SqlReturn::SUCCESS, + "exec_direct must succeed for an empty result set" + ); + + let mut col_count: i16 = -1; + assert_eq!( + ffi::cursor::sql_num_result_cols::<TrinoBackend>(stmt, &mut col_count), + SqlReturn::SUCCESS, + "SQLNumResultCols must succeed" + ); + assert_eq!(col_count, 1, "one column even for empty result set"); + + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::NO_DATA, + "first fetch on empty result set must return NO_DATA" + ); + + cleanup_stmt(stmt); + } +} + +// --------------------------------------------------------------------------- +// P3: SQLGetEnvAttrW: ODBC version roundtrip (no Trino connection required) +// --------------------------------------------------------------------------- + +#[test] +fn get_env_attr_odbc_version_roundtrip() { + // Set SQL_ATTR_ODBC_VERSION (200) to SQL_OV_ODBC3 (3), then read it back. + // Per spec HY010, SQLSetEnvAttr must be called before any connection handle + // is allocated on the environment. We use a bare env handle here. + unsafe { + let mut env: *mut c_void = std::ptr::null_mut(); + assert_eq!( + ffi::handle::sql_alloc_handle::<TrinoBackend>( + HandleType::Env as i16, + std::ptr::null_mut(), + &mut env, + ), + SqlReturn::SUCCESS + ); + + // Set SQL_ATTR_ODBC_VERSION = SQL_OV_ODBC3 (3). + assert_eq!( + ffi::env::sql_set_env_attr::<TrinoBackend>( + env, + EnvironmentAttribute::OdbcVersion as i32, + AttrOdbcVersion::Odbc3 as usize as *mut c_void, + 0, + ), + SqlReturn::SUCCESS + ); + + // Read it back. + let mut version: i32 = 0; + let mut str_len: i32 = 0; + assert_eq!( + ffi::env::sql_get_env_attr::<TrinoBackend>( + env, + EnvironmentAttribute::OdbcVersion as i32, + &mut version as *mut i32 as *mut c_void, + 4, + &mut str_len, + ), + SqlReturn::SUCCESS + ); + assert_eq!(version, AttrOdbcVersion::Odbc3 as i32); + assert_eq!(str_len, 4); // sizeof(i32) + + let _ = ffi::handle::sql_free_handle::<TrinoBackend>(HandleType::Env as i16, env); + } +} + +// --------------------------------------------------------------------------- +// Array-fetch path (SQLBindCol + SQLFetch) +// --------------------------------------------------------------------------- +// +// pyodbc retrieves column data via SQLGetData after each fetch; turbodbc and +// other drivers that pre-allocate column buffers use SQLBindCol + SQLFetch +// instead. This test exercises the bound-column path so regressions in +// sql_bind_col or the write_column_value call inside sql_fetch are caught +// independently of the sql_get_data path. + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn bind_col_and_fetch_reads_bound_column_values() { + // Exercises SQL_ATTR_ROW_ARRAY_SIZE (27) and SQL_ATTR_ROWS_FETCHED_PTR (26) + // attribute setting (accepted without error) plus the full SQLBindCol → + // SQLFetch data path. + // + // NOTE: batch INSERT (SQL_ATTR_PARAMSET_SIZE) is not tested here because + // the tpcds catalog is read-only; the writable postgresql catalog covers + // the full bind-parameter path elsewhere in this suite. + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + + // Set SQL_ATTR_ROW_ARRAY_SIZE = 1. + assert_eq!( + ffi::stmt_attr::sql_set_stmt_attr_w::<TrinoBackend>( + stmt, + StatementAttribute::RowArraySize as i32, + std::ptr::without_provenance_mut(1usize), // 1 row per fetch + 0, + ), + SqlReturn::SUCCESS + ); + + // Set SQL_ATTR_ROWS_FETCHED_PTR to a usize variable. + let mut rows_fetched: usize = 0; + assert_eq!( + ffi::stmt_attr::sql_set_stmt_attr_w::<TrinoBackend>( + stmt, + StatementAttribute::RowsFetchedPtr as i32, + &mut rows_fetched as *mut usize as *mut c_void, + 0, + ), + SqlReturn::SUCCESS + ); + + // Execute a SELECT that returns exactly 3 integer rows. + assert_eq!( + exec_direct( + stmt, + "SELECT c FROM (VALUES (10), (20), (30)) AS t(c) ORDER BY c" + ), + SqlReturn::SUCCESS + ); + + // Bind column 1 to an i32 buffer via SQLBindCol. + // Trino returns VALUES integer literals as INTEGER (32-bit). + let mut val_buf: i32 = 0; + let mut val_ind: isize = 0; + assert_eq!( + ffi::bind::sql_bind_col::<TrinoBackend>( + stmt, + 1, // column 1 + CDataType::SLong as i16, + &mut val_buf as *mut i32 as *mut c_void, + std::mem::size_of::<i32>() as isize, + &mut val_ind, + ), + SqlReturn::SUCCESS + ); + + // Fetch each row and verify the bound buffer is populated. + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + assert_eq!(val_buf, 10); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + assert_eq!(val_buf, 20); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + assert_eq!(val_buf, 30); + + // Result set exhausted. + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::NO_DATA + ); + + cleanup_stmt(stmt); + } +} + +/// `SQL_ROW_SUCCESS`, the row-status value the spec fixes at 0. Core keeps its +/// own copy private to `ffi/fetch.rs`, and a test asserting a spec value names it +/// rather than writing the literal. +const SQL_ROW_SUCCESS: u16 = 0; + +/// `SQLGetFunctions` advertises `SQL_API_SQLEXTENDEDFETCH`, so this is the +/// evidence for that claim: the function has to fetch rows rather than fail. +/// +/// It reports through its own `RowCountPtr` and `RowStatusArray` arguments, which +/// the spec keeps separate from `SQL_ATTR_ROWS_FETCHED_PTR` and +/// `SQL_ATTR_ROW_STATUS_PTR`: that buffer "is used only by SQLExtendedFetch". +/// Asserting both arguments is what distinguishes a working implementation from +/// one that fetched a row and told the application nothing about it. +/// +/// The forward-only rejection is asserted alongside, because an advertised +/// function that accepts an orientation it cannot honour is worse than one that +/// refuses: `HY106` is the clause of that row carrying no `(DM)` marker, so it is +/// this driver's to report. +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn extended_fetch_reads_rows_and_reports_through_its_own_arguments() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + + assert_eq!( + exec_direct(stmt, "SELECT c FROM (VALUES (10), (20)) AS t(c) ORDER BY c"), + SqlReturn::SUCCESS + ); + + let mut val_buf: i32 = 0; + let mut val_ind: isize = 0; + assert_eq!( + ffi::bind::sql_bind_col::<TrinoBackend>( + stmt, + 1, + CDataType::SLong as i16, + &mut val_buf as *mut i32 as *mut c_void, + std::mem::size_of::<i32>() as isize, + &mut val_ind, + ), + SqlReturn::SUCCESS + ); + + let mut row_count: usize = 0; + let mut row_status: u16 = 0xFFFF; + assert_eq!( + ffi::fetch::sql_extended_fetch::<TrinoBackend>( + stmt, + odbc_sys::FetchOrientation::Next as u16, + 0, + &mut row_count, + &mut row_status, + ), + SqlReturn::SUCCESS + ); + assert_eq!(val_buf, 10, "the bound column must carry the first row"); + assert_eq!(row_count, 1, "RowCountPtr must report the rowset size"); + assert_eq!( + row_status, SQL_ROW_SUCCESS, + "RowStatusArray element 0 must report the row's status" + ); + + assert_eq!( + ffi::fetch::sql_extended_fetch::<TrinoBackend>( + stmt, + odbc_sys::FetchOrientation::Next as u16, + 0, + &mut row_count, + &mut row_status, + ), + SqlReturn::SUCCESS + ); + assert_eq!(val_buf, 20); + + // Exhausted. There is no row, so there is no status to report, but the + // count still has to say zero rather than keep the previous rowset's. + assert_eq!( + ffi::fetch::sql_extended_fetch::<TrinoBackend>( + stmt, + odbc_sys::FetchOrientation::Next as u16, + 0, + &mut row_count, + &mut row_status, + ), + SqlReturn::NO_DATA + ); + assert_eq!(row_count, 0, "an exhausted rowset holds no rows"); + + cleanup_stmt(stmt); + } +} + +/// Null out-params are legal: both arguments are optional, and an application +/// that wants neither must not be made to supply them. +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn extended_fetch_accepts_null_row_count_and_status_arguments() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + + assert_eq!(exec_direct(stmt, "SELECT 1"), SqlReturn::SUCCESS); + assert_eq!( + ffi::fetch::sql_extended_fetch::<TrinoBackend>( + stmt, + odbc_sys::FetchOrientation::Next as u16, + 0, + std::ptr::null_mut(), + std::ptr::null_mut(), + ), + SqlReturn::SUCCESS + ); + + cleanup_stmt(stmt); + } +} + +/// Every orientation but `SQL_FETCH_NEXT` is `HY106` on this driver's +/// forward-only cursor, including `SQL_FETCH_BOOKMARK`, which `odbc-sys` has no +/// variant for and which an application can nevertheless pass. +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn extended_fetch_refuses_every_orientation_but_next() { + unsafe { + for orientation in [ + odbc_sys::FetchOrientation::First as u16, + odbc_sys::FetchOrientation::Last as u16, + odbc_sys::FetchOrientation::Prior as u16, + odbc_sys::FetchOrientation::Absolute as u16, + odbc_sys::FetchOrientation::Relative as u16, + SQL_FETCH_BOOKMARK as u16, + ] { + let (_env, _conn, stmt) = alloc_stmt(); + assert_eq!(exec_direct(stmt, "SELECT 1"), SqlReturn::SUCCESS); + + let mut row_count: usize = 99; + assert_eq!( + ffi::fetch::sql_extended_fetch::<TrinoBackend>( + stmt, + orientation, + 0, + &mut row_count, + std::ptr::null_mut(), + ), + SqlReturn::ERROR, + "orientation {orientation} must be refused on a forward-only cursor" + ); + assert_eq!( + last_sqlstate(stmt), + "HY106", + "orientation {orientation} must report HY106" + ); + + cleanup_stmt(stmt); + } + } +} + +// --------------------------------------------------------------------------- +// Batch parameter path (SQLBindParameter + SQLPrepare + SQLExecute) +// --------------------------------------------------------------------------- +// +// The tpcds catalog is read-only, so DML runs against the writable PostgreSQL +// catalog. + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443 with the postgresql catalog; run ./integration-tests/setup.sh first"] +fn paramset_size_and_bound_insert_into_postgresql() { + // Exercises SQL_ATTR_PARAMSET_SIZE (22) attribute setting plus the full + // SQLBindParameter -> SQLPrepare -> SQLExecute DML path. + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + let table = "postgresql.public.h9_paramset_test"; + + // Each statement is closed before the next runs on the shared handle; + // otherwise SQLExecDirect returns 24000 (a cursor is already open). + + // Start clean in case a previous run left the table behind. + let _ = exec_direct(stmt, &format!("DROP TABLE IF EXISTS {table}")); + let _ = ffi::cursor::sql_close_cursor::<TrinoBackend>(stmt); + + assert_eq!( + exec_direct(stmt, &format!("CREATE TABLE {table} (id bigint)")), + SqlReturn::SUCCESS + ); + let _ = ffi::cursor::sql_close_cursor::<TrinoBackend>(stmt); + + // SQL_ATTR_PARAMSET_SIZE = 1: one parameter row per execute. + assert_eq!( + ffi::stmt_attr::sql_set_stmt_attr_w::<TrinoBackend>( + stmt, + StatementAttribute::ParamsetSize as i32, + std::ptr::without_provenance_mut(1usize), + 0, + ), + SqlReturn::SUCCESS + ); + + // Prepare and run a bound INSERT. + let sql = format!("INSERT INTO {table} VALUES (?)"); + let wide: Vec<u16> = sql.encode_utf16().collect(); + assert_eq!( + ffi::execute::sql_prepare_w::<TrinoBackend>(stmt, wide.as_ptr(), wide.len() as i32), + SqlReturn::SUCCESS + ); + let mut val: i64 = 42; + assert_eq!(bind_i64(stmt, 1, &mut val), SqlReturn::SUCCESS); + assert_eq!( + ffi::execute::sql_execute::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + let _ = ffi::cursor::sql_close_cursor::<TrinoBackend>(stmt); + + // The row landed. + assert_eq!( + exec_direct(stmt, &format!("SELECT COUNT(*) FROM {table} WHERE id = 42")), + SqlReturn::SUCCESS + ); + assert_eq!( + fetch_one_i64(stmt), + 1, + "bound INSERT did not persist the row" + ); + let _ = ffi::cursor::sql_close_cursor::<TrinoBackend>(stmt); + + // Clean up the table. + assert_eq!( + exec_direct(stmt, &format!("DROP TABLE {table}")), + SqlReturn::SUCCESS + ); + cleanup_stmt(stmt); + } +} + +// --------------------------------------------------------------------------- +// SQLPrimaryKeysW tests +// --------------------------------------------------------------------------- +// NOTE: Trino's information_schema does not include table_constraints or +// key_column_usage in any connector (PostgreSQL, tpcds, memory, etc.). +// These tests verify that the driver returns SQL_SUCCESS with an empty +// result set rather than SQL_ERROR. +// When Trino adds constraint metadata support, these tests should be +// updated to verify actual PK data. + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443 with the postgresql catalog; run ./integration-tests/setup.sh first"] +fn primary_keys_postgresql_returns_success() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + let catalog: Vec<u16> = "postgresql".encode_utf16().collect(); + let schema: Vec<u16> = "public".encode_utf16().collect(); + let table: Vec<u16> = "customers".encode_utf16().collect(); + let ret = ffi::metadata::sql_primary_keys_w::<TrinoBackend>( + stmt, + catalog.as_ptr(), + catalog.len() as i16, + schema.as_ptr(), + schema.len() as i16, + table.as_ptr(), + table.len() as i16, + ); + assert_eq!(ret, SqlReturn::SUCCESS, "sql_primary_keys_w should succeed"); + // Trino doesn't expose table_constraints: empty result expected + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::NO_DATA, + ); + cleanup_stmt(stmt); + } +} + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn primary_keys_no_constraints_returns_empty() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + let catalog: Vec<u16> = "tpcds".encode_utf16().collect(); + let schema: Vec<u16> = "sf1".encode_utf16().collect(); + let table: Vec<u16> = "customer".encode_utf16().collect(); + let ret = ffi::metadata::sql_primary_keys_w::<TrinoBackend>( + stmt, + catalog.as_ptr(), + catalog.len() as i16, + schema.as_ptr(), + schema.len() as i16, + table.as_ptr(), + table.len() as i16, + ); + assert_eq!(ret, SqlReturn::SUCCESS, "should succeed even with no PKs"); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::NO_DATA, + "tpcds has no PK constraints, so expect an empty result" + ); + cleanup_stmt(stmt); + } +} + +// --------------------------------------------------------------------------- +// SQLForeignKeysW tests +// --------------------------------------------------------------------------- +// NOTE: Same limitation as primary keys; Trino doesn't expose +// referential_constraints. Tests verify SQL_SUCCESS with empty results. + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443 with the postgresql catalog; run ./integration-tests/setup.sh first"] +fn foreign_keys_postgresql_returns_success() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + let catalog: Vec<u16> = "postgresql".encode_utf16().collect(); + let schema: Vec<u16> = "public".encode_utf16().collect(); + let fk_table: Vec<u16> = "orders".encode_utf16().collect(); + let ret = ffi::metadata::sql_foreign_keys_w::<TrinoBackend>( + stmt, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + catalog.as_ptr(), + catalog.len() as i16, + schema.as_ptr(), + schema.len() as i16, + fk_table.as_ptr(), + fk_table.len() as i16, + ); + assert_eq!(ret, SqlReturn::SUCCESS, "sql_foreign_keys_w should succeed"); + // Trino doesn't expose referential_constraints: empty result expected + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::NO_DATA, + ); + cleanup_stmt(stmt); + } +} + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn foreign_keys_no_constraints_returns_empty() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + let catalog: Vec<u16> = "tpcds".encode_utf16().collect(); + let schema: Vec<u16> = "sf1".encode_utf16().collect(); + let table: Vec<u16> = "customer".encode_utf16().collect(); + let ret = ffi::metadata::sql_foreign_keys_w::<TrinoBackend>( + stmt, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + std::ptr::null(), + 0, + catalog.as_ptr(), + catalog.len() as i16, + schema.as_ptr(), + schema.len() as i16, + table.as_ptr(), + table.len() as i16, + ); + assert_eq!(ret, SqlReturn::SUCCESS, "should succeed even with no FKs"); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::NO_DATA, + ); + cleanup_stmt(stmt); + } +} + +// --------------------------------------------------------------------------- +// SQLStatisticsW test +// --------------------------------------------------------------------------- + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn statistics_returns_empty_result_set() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + let catalog: Vec<u16> = "postgresql".encode_utf16().collect(); + let schema: Vec<u16> = "public".encode_utf16().collect(); + let table: Vec<u16> = "customers".encode_utf16().collect(); + let ret = ffi::metadata::sql_statistics_w::<TrinoBackend>( + stmt, + catalog.as_ptr(), + catalog.len() as i16, + schema.as_ptr(), + schema.len() as i16, + table.as_ptr(), + table.len() as i16, + SQL_INDEX_UNIQUE, + SQL_QUICK, + ); + assert_eq!(ret, SqlReturn::SUCCESS, "sql_statistics_w should succeed"); + + // Verify it has columns (13 per ODBC spec) by checking num_result_cols + let mut col_count: i16 = 0; + let ret = ffi::cursor::sql_num_result_cols::<TrinoBackend>(stmt, &mut col_count); + assert_eq!(ret, SqlReturn::SUCCESS); + assert_eq!( + col_count, 13, + "statistics result set should have 13 columns" + ); + + // Verify empty + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::NO_DATA, + "statistics should return empty result set" + ); + cleanup_stmt(stmt); + } +} + +// --------------------------------------------------------------------------- +// Query attribution +// +// `source` and `client_tags` are the two things a Trino operator uses to tell +// one client's traffic from another's and to route it to a resource group. +// Neither is observable through ODBC, so the assertion has to come from the +// server: `system.runtime.queries` records the source of every query. +// --------------------------------------------------------------------------- + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn queries_reach_trino_tagged_with_the_drivers_source() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + // The shared connection names no Source, so this is the default. + let sql = "SELECT source FROM system.runtime.queries \ + WHERE query_id = (SELECT max(query_id) FROM system.runtime.queries \ + WHERE query LIKE 'SELECT 41 + 1%')"; + let seed: Vec<u16> = "SELECT 41 + 1".encode_utf16().collect(); + assert_eq!( + ffi::execute::sql_exec_direct_w::<TrinoBackend>(stmt, seed.as_ptr(), seed.len() as i32), + SqlReturn::SUCCESS + ); + while ffi::fetch::sql_fetch::<TrinoBackend>(stmt) == SqlReturn::SUCCESS {} + assert_eq!( + ffi::cursor::sql_close_cursor::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + + let wide: Vec<u16> = sql.encode_utf16().collect(); + assert_eq!( + ffi::execute::sql_exec_direct_w::<TrinoBackend>(stmt, wide.as_ptr(), wide.len() as i32), + SqlReturn::SUCCESS, + "{}", + diag_message(stmt) + ); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS, + "no query found in system.runtime.queries" + ); + + let mut buf = [0u16; 128]; + let mut len: isize = 0; + assert_eq!( + ffi::fetch::sql_get_data::<TrinoBackend>( + stmt, + 1, + CDataType::WChar as i16, + buf.as_mut_ptr() as *mut c_void, + (buf.len() * 2) as isize, + &mut len, + ), + SqlReturn::SUCCESS + ); + let source = String::from_utf16_lossy(&buf[..(len as usize) / 2]); + // `env!` rather than a literal, so the assertion tracks the version + // the driver reports rather than needing a bump of its own. + assert_eq!( + source, + format!("stackable-odbc-trino/{}", env!("CARGO_PKG_VERSION")), + "Trino recorded the query under a source that does not name this driver and build" + ); + + cleanup_stmt(stmt); + } +} + +// --------------------------------------------------------------------------- +// SQLDescribeParam tests +// +// Core's fallback describes every parameter as VARCHAR(SQL_DEFAULT_PARAM_SIZE), +// which is what makes a client send a number as text. Trino can be asked: +// `DESCRIBE INPUT` on a prepared statement returns a type per parameter. +// --------------------------------------------------------------------------- + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn describe_param_reports_the_type_trino_infers_for_each_parameter() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + // Two parameters of different types, so a generic answer cannot pass + // by coincidence: the WHERE comparison makes the first a bigint and + // the second a char(20), which is c_first_name's declared type. + let sql = "SELECT c_customer_sk FROM tpcds.sf1.customer \ + WHERE c_customer_sk = ? AND c_first_name = ?"; + let wide: Vec<u16> = sql.encode_utf16().collect(); + assert_eq!( + ffi::execute::sql_prepare_w::<TrinoBackend>(stmt, wide.as_ptr(), wide.len() as i32), + SqlReturn::SUCCESS + ); + + let describe = |n: u16| -> (i16, usize, i16, i16) { + let (mut ty, mut size, mut digits, mut nullable) = (0i16, 0usize, 0i16, 0i16); + let ret = ffi::params::sql_describe_param::<TrinoBackend>( + stmt, + n, + &mut ty, + &mut size, + &mut digits, + &mut nullable, + ); + assert_eq!( + ret, + SqlReturn::SUCCESS, + "sql_describe_param({n}) failed: {}", + diag_message(stmt) + ); + (ty, size, digits, nullable) + }; + + let (ty1, _, _, _) = describe(1); + assert_eq!( + ty1, + SqlDataType::EXT_BIG_INT.0, + "parameter 1 compares against a bigint column" + ); + + let (ty2, size2, _, _) = describe(2); + assert_eq!( + ty2, + SqlDataType::EXT_W_CHAR.0, + "parameter 2 compares against a char(20) column" + ); + assert_eq!( + size2, 20, + "char(20) must carry its length for buffer sizing" + ); + + cleanup_stmt(stmt); + } +} + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn describe_param_re_describes_when_the_statement_changes() { + // The descriptors are cached on the connection, keyed by SQL text, because + // `Backend::describe_param` is called once per parameter and gets no + // statement handle. If that key were ignored, a second statement would be + // answered with the first one's types: a wrong specific type, which is + // the one outcome worse than no answer at all. + unsafe { + let describe_first_param_of = |sql: &str| -> i16 { + let (_env, _conn, stmt) = alloc_stmt(); + let wide: Vec<u16> = sql.encode_utf16().collect(); + assert_eq!( + ffi::execute::sql_prepare_w::<TrinoBackend>(stmt, wide.as_ptr(), wide.len() as i32), + SqlReturn::SUCCESS + ); + let (mut ty, mut size, mut digits, mut nullable) = (0i16, 0usize, 0i16, 0i16); + assert_eq!( + ffi::params::sql_describe_param::<TrinoBackend>( + stmt, + 1, + &mut ty, + &mut size, + &mut digits, + &mut nullable, + ), + SqlReturn::SUCCESS, + "{}", + diag_message(stmt) + ); + cleanup_stmt(stmt); + ty + }; + + let bigint_param = + describe_first_param_of("SELECT 1 FROM tpcds.sf1.customer WHERE c_customer_sk = ?"); + assert_eq!(bigint_param, SqlDataType::EXT_BIG_INT.0); + + let char_param = + describe_first_param_of("SELECT 1 FROM tpcds.sf1.customer WHERE c_first_name = ?"); + assert_eq!( + char_param, + SqlDataType::EXT_W_CHAR.0, + "the second statement was answered from the first one's cache entry" + ); + } +} + +// --------------------------------------------------------------------------- +// SQLTablePrivilegesW / SQLColumnPrivilegesW / SQLProceduresW / +// SQLProcedureColumnsW tests +// +// All four answer an empty result set against this test stack, but for two +// different reasons, and the distinction is what these tests protect. +// +// `SQLTablePrivileges` runs a real query: Trino models table privileges in +// `information_schema.table_privileges`, and it is empty here only because +// neither test catalog implements permission management (`GRANT` on either +// answers NOT_SUPPORTED, and a grant made directly in PostgreSQL is not +// visible through the `postgresql` catalog: Trino synthesises its own +// `information_schema`). So the assertion that matters is that the query is +// accepted and its column list is the one the driver expects; a rename or +// reordering in `information_schema.table_privileges` fails it. The row +// conversion has unit coverage in `backend::metadata`. +// +// The other three read nothing. Trino publishes no column-privilege or +// procedure metadata at all, so their empty result set is a fact about Trino, +// not about this stack's configuration. +// --------------------------------------------------------------------------- + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn table_privileges_queries_trino_and_returns_the_spec_column_count() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + let catalog: Vec<u16> = "tpcds".encode_utf16().collect(); + let schema: Vec<u16> = "sf1".encode_utf16().collect(); + let table: Vec<u16> = "call_center".encode_utf16().collect(); + let ret = ffi::metadata::sql_table_privileges_w::<TrinoBackend>( + stmt, + catalog.as_ptr(), + catalog.len() as i16, + schema.as_ptr(), + schema.len() as i16, + table.as_ptr(), + table.len() as i16, + ); + // A failure here is the interesting outcome: it means the query + // against information_schema.table_privileges was rejected. + assert_eq!( + ret, + SqlReturn::SUCCESS, + "sql_table_privileges_w should succeed: {}", + diag_message(stmt) + ); + + let mut col_count: i16 = 0; + let ret = ffi::cursor::sql_num_result_cols::<TrinoBackend>(stmt, &mut col_count); + assert_eq!(ret, SqlReturn::SUCCESS); + assert_eq!( + col_count, 7, + "table privileges result set should have 7 columns" + ); + + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::NO_DATA, + "neither test catalog implements permission management, so no rows" + ); + cleanup_stmt(stmt); + } +} + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn column_privileges_returns_empty_result_set() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + let catalog: Vec<u16> = "tpcds".encode_utf16().collect(); + let schema: Vec<u16> = "sf1".encode_utf16().collect(); + let table: Vec<u16> = "call_center".encode_utf16().collect(); + let column: Vec<u16> = "%".encode_utf16().collect(); + let ret = ffi::metadata::sql_column_privileges_w::<TrinoBackend>( + stmt, + catalog.as_ptr(), + catalog.len() as i16, + schema.as_ptr(), + schema.len() as i16, + table.as_ptr(), + table.len() as i16, + column.as_ptr(), + column.len() as i16, + ); + assert_eq!( + ret, + SqlReturn::SUCCESS, + "sql_column_privileges_w should succeed: {}", + diag_message(stmt) + ); + + let mut col_count: i16 = 0; + let ret = ffi::cursor::sql_num_result_cols::<TrinoBackend>(stmt, &mut col_count); + assert_eq!(ret, SqlReturn::SUCCESS); + assert_eq!( + col_count, 8, + "column privileges result set should have 8 columns" + ); + + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::NO_DATA, + "Trino grants on tables, not columns, so there is nothing to report" + ); + cleanup_stmt(stmt); + } +} + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn procedures_returns_empty_result_set() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + let catalog: Vec<u16> = "system".encode_utf16().collect(); + let schema: Vec<u16> = "runtime".encode_utf16().collect(); + let proc_name: Vec<u16> = "%".encode_utf16().collect(); + let ret = ffi::metadata::sql_procedures_w::<TrinoBackend>( + stmt, + catalog.as_ptr(), + catalog.len() as i16, + schema.as_ptr(), + schema.len() as i16, + proc_name.as_ptr(), + proc_name.len() as i16, + ); + assert_eq!( + ret, + SqlReturn::SUCCESS, + "sql_procedures_w should succeed: {}", + diag_message(stmt) + ); + + let mut col_count: i16 = 0; + let ret = ffi::cursor::sql_num_result_cols::<TrinoBackend>(stmt, &mut col_count); + assert_eq!(ret, SqlReturn::SUCCESS); + assert_eq!(col_count, 8, "procedures result set should have 8 columns"); + + // `system.runtime` really does hold callable procedures + // (`kill_query`), so this asserts the documented gap: Trino publishes + // no metadata naming them. + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::NO_DATA, + "Trino publishes no procedure metadata, even where procedures exist" + ); + cleanup_stmt(stmt); + } +} + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn procedure_columns_returns_empty_result_set() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + let catalog: Vec<u16> = "system".encode_utf16().collect(); + let schema: Vec<u16> = "runtime".encode_utf16().collect(); + let proc_name: Vec<u16> = "%".encode_utf16().collect(); + let column: Vec<u16> = "%".encode_utf16().collect(); + let ret = ffi::metadata::sql_procedure_columns_w::<TrinoBackend>( + stmt, + catalog.as_ptr(), + catalog.len() as i16, + schema.as_ptr(), + schema.len() as i16, + proc_name.as_ptr(), + proc_name.len() as i16, + column.as_ptr(), + column.len() as i16, + ); + assert_eq!( + ret, + SqlReturn::SUCCESS, + "sql_procedure_columns_w should succeed: {}", + diag_message(stmt) + ); + + let mut col_count: i16 = 0; + let ret = ffi::cursor::sql_num_result_cols::<TrinoBackend>(stmt, &mut col_count); + assert_eq!(ret, SqlReturn::SUCCESS); + assert_eq!( + col_count, 19, + "procedure columns result set should have 19 columns" + ); + + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::NO_DATA, + "Trino publishes no procedure metadata" + ); + cleanup_stmt(stmt); + } +} + +// --------------------------------------------------------------------------- +// SQLBulkOperations / SQLSetPos: HYC00 tests +// --------------------------------------------------------------------------- + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn bulk_operations_returns_hyc00() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + assert_eq!(exec_direct(stmt, "SELECT 1 AS n"), SqlReturn::SUCCESS); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + let ret = ffi::cursor::sql_bulk_operations::<TrinoBackend>( + stmt, + odbc_sys::BulkOperation::Add as i16, + ); + assert_eq!( + ret, + SqlReturn::ERROR, + "SQLBulkOperations should return ERROR" + ); + cleanup_stmt(stmt); + } +} + +// --------------------------------------------------------------------------- +// SQLDescribeColW / SQLColumnsW type-metadata agreement +// --------------------------------------------------------------------------- + +/// Describe every column of the current result set via `SQLDescribeColW`. +unsafe fn collect_describe_col(stmt: *mut c_void) -> Vec<(String, i16, usize, i16)> { + let mut count: i16 = 0; + unsafe { + assert_eq!( + ffi::cursor::sql_num_result_cols::<TrinoBackend>(stmt, &mut count), + SqlReturn::SUCCESS + ); + } + (1..=count) + .map(|col| { + let mut name = [0u16; 256]; + let mut name_len: i16 = 0; + let mut data_type: i16 = 0; + let mut col_size: usize = 0; + let mut decimal_digits: i16 = 0; + let mut nullable: i16 = 0; + unsafe { + assert_eq!( + ffi::metadata::sql_describe_col_w::<TrinoBackend>( + stmt, + u16::try_from(col).expect("column index fits u16"), + name.as_mut_ptr(), + i16::try_from(name.len()).expect("buffer fits i16"), + &mut name_len, + &mut data_type, + &mut col_size, + &mut decimal_digits, + &mut nullable, + ), + SqlReturn::SUCCESS + ); + } + let n = String::from_utf16_lossy(&name[..usize::try_from(name_len).unwrap_or(0)]); + (n, data_type, col_size, decimal_digits) + }) + .collect() +} + +/// The query path (`SQLDescribeColW`) and the catalog path (`SQLColumnsW`) +/// derive type, size and scale from the same native Trino type text, so the +/// columns of `postgresql.public.types_test` must agree between them. The +/// ways they can disagree are `char` (WVARCHAR against WCHAR), `timestamp` +/// (23 against 29) and `varchar(n)` (0 against n). +/// +/// Covers `VARCHAR`, `CHAR`, `DECIMAL`, integer and floating-point columns +/// plus `DATE`, `TIME`, `TIMESTAMP` and `BOOLEAN`. Those last four depend on +/// `SQLColumns`' `COLUMN_SIZE` gate (`backend/metadata.rs`) reporting +/// `type_name_precision`'s value for every type it can resolve one for. A +/// separately maintained "is_char || is_numeric" list leaves the four out, +/// and the catalog path then reports their `COLUMN_SIZE` as `NULL` whatever +/// the query path says. +/// +/// The catalog path needs the session's default catalog and schema switched +/// to `postgresql`/`public` first. `SQLColumnsW` reads +/// `information_schema.columns` unqualified, which Trino resolves against the +/// *session's* default catalog, and the shared connection defaults to +/// `Catalog=tpcds`: a `table_catalog='postgresql'` filter on that session +/// returns zero rows though the table exists. That is Trino's +/// information_schema scoping, not a defect. +/// +/// `USE` switches the shared connection and switches it back, rather than +/// opening a second connection. A second one means a second `reqwest` pool +/// against the same coordinator, which is the intermittent TCP socket +/// corruption this file's own docs warn about for `backend::tests`, and it +/// reproduces here. The query path works from any session, given a fully +/// qualified `SELECT`, so it runs before the switch. +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn describe_col_and_columns_agree_on_type_metadata() { + const CATALOG: &str = "postgresql"; + const SCHEMA: &str = "public"; + const TABLE: &str = "types_test"; + const COLUMNS: &str = "id, col_smallint, col_integer, col_bigint, col_real, \ + col_double, col_decimal, col_varchar, col_char, \ + col_boolean, col_date, col_time, col_timestamp"; + + unsafe { + // Query path. + let (_, _, stmt) = alloc_stmt(); + assert_eq!( + exec_direct( + stmt, + &format!("SELECT {COLUMNS} FROM {CATALOG}.{SCHEMA}.{TABLE} LIMIT 0") + ), + SqlReturn::SUCCESS + ); + let described = collect_describe_col(stmt); + cleanup_stmt(stmt); + assert!(!described.is_empty(), "expected columns to describe"); + + // Catalog path: SQLColumns queries `information_schema.columns` + // unqualified, which Trino resolves against the *session's* default + // catalog: the shared connection defaults to `Catalog=tpcds`, so a + // filter on `table_catalog='postgresql'` would return zero rows on + // that session even though the table exists. Switch the shared + // connection's session catalog/schema with `USE` rather than opening + // a second connection: this file's own docs warn that two + // independent reqwest connection pools hitting the same Trino + // coordinator cause intermittent TCP socket corruption, and that was + // reproducible here too. `USE` restores the original session + // afterwards so later tests on the shared connection are unaffected. + let (_, _, use_stmt) = alloc_stmt(); + assert_eq!( + exec_direct(use_stmt, &format!("USE {CATALOG}.{SCHEMA}")), + SqlReturn::SUCCESS, + "USE {CATALOG}.{SCHEMA} failed" + ); + cleanup_stmt(use_stmt); + + let (_, _, cat_stmt) = alloc_stmt(); + let cat: Vec<u16> = CATALOG.encode_utf16().collect(); + let sch: Vec<u16> = SCHEMA.encode_utf16().collect(); + let tbl: Vec<u16> = TABLE.encode_utf16().collect(); + assert_eq!( + ffi::metadata::sql_columns_w::<TrinoBackend>( + cat_stmt, + cat.as_ptr(), + i16::try_from(cat.len()).expect("fits i16"), + sch.as_ptr(), + i16::try_from(sch.len()).expect("fits i16"), + tbl.as_ptr(), + i16::try_from(tbl.len()).expect("fits i16"), + std::ptr::null(), + 0, + ), + SqlReturn::SUCCESS + ); + + let mut cataloged: Vec<(String, i16, usize, i16)> = Vec::new(); + while ffi::fetch::sql_fetch::<TrinoBackend>(cat_stmt) == SqlReturn::SUCCESS { + cataloged.push(( + get_wchar_col(cat_stmt, 4), + i16::try_from(get_i64_col(cat_stmt, 5)).expect("DATA_TYPE fits i16"), + usize::try_from(get_i64_col(cat_stmt, 7)).unwrap_or(0), + i16::try_from(get_i64_col(cat_stmt, 9)).unwrap_or(0), + )); + } + cleanup_stmt(cat_stmt); + + // Restore the shared connection's session catalog/schema so later + // tests that assume the original `Catalog=tpcds` connect-string + // default are unaffected. + let (_, _, restore_stmt) = alloc_stmt(); + assert_eq!( + exec_direct(restore_stmt, "USE tpcds.sf1"), + SqlReturn::SUCCESS, + "restoring USE tpcds.sf1 failed" + ); + cleanup_stmt(restore_stmt); + + assert!(!cataloged.is_empty(), "expected SQLColumns to return rows"); + for (name, sql_type, size, scale) in described { + let c = cataloged + .iter() + .find(|c| c.0 == name) + .unwrap_or_else(|| panic!("{name} missing from SQLColumns")); + assert_eq!(sql_type, c.1, "{name}: DATA_TYPE disagrees"); + assert_eq!(size, c.2, "{name}: COLUMN_SIZE disagrees"); + assert_eq!(scale, c.3, "{name}: DECIMAL_DIGITS disagrees"); + } + } +} + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn set_pos_returns_hyc00() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + assert_eq!(exec_direct(stmt, "SELECT 1 AS n"), SqlReturn::SUCCESS); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + let ret = + ffi::cursor::sql_set_pos::<TrinoBackend>(stmt, 1, SQL_POSITION, SQL_LOCK_NO_CHANGE); + assert_eq!(ret, SqlReturn::ERROR, "SQLSetPos should return ERROR"); + cleanup_stmt(stmt); + } +} + +// --------------------------------------------------------------------------- +// Column-size round-trip matrix +// +// Verifies that DISPLAY_SIZE (not OCTET_LENGTH) is the right field to size a +// text buffer from, across representative Trino types via ad hoc `SELECT` +// literals (no table/DDL needed, matching this file's existing +// `exec_direct_select_and_fetch` convention), plus the three cross-family +// priority cases. +// +// Omitted from the metadata-sized backbone below, with reasons: +// - BOOLEAN/TINYINT/SMALLINT: fixed-width types whose COLUMN_SIZE is an +// appendix constant, covered by `column_size.rs`'s own spec-table test. +// BIGINT and DOUBLE below cover the same numeric shape against Trino. +// - VARBINARY/JSON/UUID/INTERVAL/ARRAY: string-representable types whose +// rendering is covered elsewhere in this file +// (`varbinary_get_data_returns_raw_bytes` and friends), away from the +// temporal and DECIMAL sizing this matrix is about. VARBINARY cannot be +// given a bounded declared length at all, since Trino's VARBINARY carries +// no length parameter, so its DISPLAY_SIZE is the "unbounded" convention +// (i32::MAX * 2, see `is_binary_type` in col_attr.rs). That is not an +// allocatable buffer size: an application reads such a column with chunked +// `SQLGetData` calls rather than sizing one buffer from COLUMN_SIZE, which +// is what `varbinary_get_data_returns_raw_bytes` does. + +/// Mirrors `odbc_sys::Timestamp` (`SQL_TIMESTAMP_STRUCT`)'s field layout so +/// this test file can read a `SQL_C_TYPE_TIMESTAMP` buffer without adding +/// `odbc-sys` as a direct (non-dev) dependency of this crate. +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct RawTimestamp { + year: i16, + month: u16, + day: u16, + hour: u16, + minute: u16, + second: u16, + fraction: u32, +} + +/// Read `SQL_DESC_DISPLAY_SIZE` for one column via `SQLColAttributeW`. +unsafe fn column_display_size(stmt: *mut c_void, column_number: u16) -> usize { + let mut chars: isize = 0; + unsafe { + assert_eq!( + ffi::metadata::sql_col_attribute_w::<TrinoBackend>( + stmt, + column_number, + Desc::DisplaySize as u16, + std::ptr::null_mut(), + 0, + std::ptr::null_mut(), + &mut chars, + ), + SqlReturn::SUCCESS + ); + } + usize::try_from(chars).expect("DISPLAY_SIZE must not be negative") +} + +/// Fetch column `column_number` as `SQL_C_WCHAR`, using a buffer sized +/// exactly from `SQL_DESC_DISPLAY_SIZE` (plus one UTF-16 code unit of slack +/// for the null terminator, which `DISPLAY_SIZE` does not include per spec). +unsafe fn get_data_wchar_sized_from_metadata( + stmt: *mut c_void, + column_number: u16, +) -> (SqlReturn, String) { + let chars = unsafe { column_display_size(stmt, column_number) }; + let code_units = chars + 1; + let mut buf: Vec<u16> = vec![0u16; code_units]; + let mut ind: isize = 0; + let ret = unsafe { + ffi::fetch::sql_get_data::<TrinoBackend>( + stmt, + column_number, + CDataType::WChar as i16, + buf.as_mut_ptr().cast(), + (buf.len() * 2) as isize, + &mut ind, + ) + }; + let char_count = if ind > 0 { (ind / 2) as usize } else { 0 }; + ( + ret, + String::from_utf16_lossy(&buf[..char_count.min(buf.len())]), + ) +} + +/// Read the first diagnostic record's 5-character SQLSTATE off `stmt`. +unsafe fn last_sqlstate(stmt: *mut c_void) -> String { + let mut state = [0u16; 6]; + let mut native: i32 = 0; + let mut msg = [0u16; 256]; + let mut msg_len: i16 = 0; + unsafe { + assert_eq!( + ffi::diag::sql_get_diag_rec_w::<TrinoBackend>( + HandleType::Stmt as i16, + stmt, + 1, + state.as_mut_ptr(), + &mut native, + msg.as_mut_ptr(), + msg.len() as i16, + &mut msg_len, + ), + SqlReturn::SUCCESS, + "no diagnostic record was pushed" + ); + } + String::from_utf16_lossy(&state[..5]) +} + +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn metadata_sized_wchar_round_trip_covers_representative_types() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + // col_varchar comes from the real `types_test` table rather than a + // bare `CAST('...' AS VARCHAR)` literal, so that it exercises the + // normal path: a catalogued VARCHAR(200) column whose precision comes + // from `information_schema`. A computed VARCHAR expression has no + // catalog entry and therefore no declared length for + // `trino_ty_precision` to read, which under-reports DISPLAY_SIZE for + // that shape alone, separately from the temporal and DECIMAL sizing + // this test is about. + assert_eq!( + exec_direct( + stmt, + "SELECT \ + CAST(1234567890 AS BIGINT), \ + CAST(3.5 AS DOUBLE), \ + col_varchar, \ + DATE '2024-03-05', \ + TIME '13:30:15', \ + TIMESTAMP '2024-03-05 13:30:15', \ + CAST(123.45 AS DECIMAL(10,2)), \ + TIME '13:30:15.123', \ + TIMESTAMP '2024-03-05 13:30:15.123' \ + FROM postgresql.public.types_test WHERE id = 1" + ), + SqlReturn::SUCCESS + ); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + + // Columns 8/9: every temporal fixture above them has a zero + // fraction, which does not exercise fractional-second rendering. A + // `time(3)`/`timestamp(3)` value must not be padded to a fixed 9 + // nanosecond digits against a reported DISPLAY_SIZE of 12/23 (the + // ODBC "Column Size" appendix's `9 + s`/`20 + s` formula at `s` = 3). + // These two columns pin that: trailing zeros are trimmed, so the + // rendered text is exactly the 3 significant digits Trino sent, not 6 + // fabricated zeros appended to them. + let expectations: &[(u16, &str)] = &[ + (1, "1234567890"), + (2, "3.5"), + (3, "hello world"), + (4, "2024-03-05"), + (5, "13:30:15"), + (6, "2024-03-05 13:30:15"), + (7, "123.45"), + (8, "13:30:15.123"), + (9, "2024-03-05 13:30:15.123"), + ]; + + for &(col, expected) in expectations { + let (ret, text) = get_data_wchar_sized_from_metadata(stmt, col); + assert_eq!( + ret, + SqlReturn::SUCCESS, + "column {col}: DISPLAY_SIZE-sized buffer was not big enough \ + (metadata under-reported the size, or SUCCESS_WITH_INFO/ERROR \ + was otherwise returned)" + ); + assert_eq!(text, expected, "column {col}: unexpected text rendering"); + } + + cleanup_stmt(stmt); + } +} + +// --- Cross-family conversions --- + +/// A native Trino DECIMAL value (`ColumnValue::Decimal(String)`, +/// see `type_conversion.rs`'s `json_to_column_value`) read as `SQL_C_DOUBLE`. +/// DECIMAL arrives as text from Trino's JSON wire format and must go through +/// `write_column_value`'s numeric-pivot (`parse_numeric_text`) arm to reach +/// a `SQL_C_DOUBLE` buffer, rather than any native binary decimal +/// representation. +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn decimal_column_read_as_double() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + assert_eq!( + exec_direct(stmt, "SELECT CAST(123.45 AS DECIMAL(10,2))"), + SqlReturn::SUCCESS + ); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + + let mut buf: f64 = 0.0; + let mut ind: isize = 0; + let ret = ffi::fetch::sql_get_data::<TrinoBackend>( + stmt, + 1, + CDataType::Double as i16, + &mut buf as *mut f64 as *mut c_void, + std::mem::size_of::<f64>() as isize, + &mut ind, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + assert!((buf - 123.45).abs() < 1e-9, "got {buf}"); + + cleanup_stmt(stmt); + } +} + +/// The three IEEE specials must be readable as `SQL_C_DOUBLE` from both a +/// `DOUBLE` and a `REAL` column. +/// +/// Trino has no JSON literal for them and sends `"NaN"`, `"Infinity"` and +/// `"-Infinity"` as strings, which `trino_special_float` recognises. Without +/// it they reach the application as text and fail the C conversion with +/// `22018`, leaving the value unreadable as a number. Only a live coordinator +/// can confirm the wire encoding this depends on, which is why it is not left +/// to the unit tests over `json_to_column_value`. +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn ieee_special_floats_are_readable_as_c_double() { + /// Predicate on the `f64` read back, since the specials do not compare + /// equal to themselves and cannot be asserted with `assert_eq!`. + type Check = fn(f64) -> bool; + + // (Trino expression, predicate on the value read back) + let cases: &[(&str, Check)] = &[ + ("CAST(nan() AS DOUBLE)", |v| v.is_nan()), + ("CAST(infinity() AS DOUBLE)", |v| { + v.is_infinite() && v.is_sign_positive() + }), + ("CAST(-infinity() AS DOUBLE)", |v| { + v.is_infinite() && v.is_sign_negative() + }), + ("CAST(nan() AS REAL)", |v| v.is_nan()), + ("CAST(infinity() AS REAL)", |v| { + v.is_infinite() && v.is_sign_positive() + }), + ("CAST(-infinity() AS REAL)", |v| { + v.is_infinite() && v.is_sign_negative() + }), + ]; + + unsafe { + for (expr, ok) in cases { + let (_env, _conn, stmt) = alloc_stmt(); + assert_eq!( + exec_direct(stmt, &format!("SELECT {expr}")), + SqlReturn::SUCCESS, + "{expr} did not execute" + ); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS, + "{expr} returned no row" + ); + + let mut buf: f64 = 0.0; + let mut ind: isize = 0; + let ret = ffi::fetch::sql_get_data::<TrinoBackend>( + stmt, + 1, + CDataType::Double as i16, + &mut buf as *mut f64 as *mut c_void, + std::mem::size_of::<f64>() as isize, + &mut ind, + ); + assert_eq!( + ret, + SqlReturn::SUCCESS, + "{expr} could not be read as SQL_C_DOUBLE: this is the 22018 \ + that made IEEE specials unreadable" + ); + assert!(ok(buf), "{expr} read back as {buf}"); + + cleanup_stmt(stmt); + } + } +} + +/// A statement terminator must not fail the statement. +/// +/// Trino's REST API takes one statement per request and its grammar has no +/// terminator, so `SELECT 1;` is a `SYNTAX_ERROR` at the semicolon. ODBC tools +/// send one routinely (`isql` submits the line as typed), so this covers both +/// entry points that carry application SQL, including the prepared path where +/// the terminator survives parameter interpolation. +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn a_trailing_semicolon_does_not_fail_the_statement() { + unsafe { + for sql in ["SELECT 1 AS n;", "SELECT 1 AS n ; ", "SELECT 1 AS n;;"] { + let (_env, _conn, stmt) = alloc_stmt(); + assert_eq!( + exec_direct(stmt, sql), + SqlReturn::SUCCESS, + "{sql:?} did not execute" + ); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS, + "{sql:?} returned no row" + ); + assert_eq!( + get_wchar_col(stmt, 1), + "1", + "{sql:?} returned the wrong value" + ); + cleanup_stmt(stmt); + } + + // The prepared path: the terminator is still on the template when + // parameters are interpolated into it. + let (_env, _conn, stmt) = alloc_stmt(); + let sql = "SELECT CAST(? AS VARCHAR) AS s;"; + let wide: Vec<u16> = sql.encode_utf16().collect(); + assert_eq!( + ffi::execute::sql_prepare_w::<TrinoBackend>(stmt, wide.as_ptr(), wide.len() as i32), + SqlReturn::SUCCESS + ); + let mut buf: Vec<u8> = b"hi".to_vec(); + let mut ind_in: isize = buf.len() as isize; + assert_eq!( + ffi::params::sql_bind_parameter::<TrinoBackend>( + stmt, + 1, + ParamType::Input as i16, + CDataType::Char as i16, + SqlDataType::VARCHAR.0, + buf.len(), + 0, + buf.as_mut_ptr().cast(), + buf.len() as isize, + &mut ind_in, + ), + SqlReturn::SUCCESS + ); + assert_eq!( + ffi::execute::sql_execute::<TrinoBackend>(stmt), + SqlReturn::SUCCESS, + "a prepared statement with a terminator did not execute" + ); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + assert_eq!(get_wchar_col(stmt, 1), "hi"); + cleanup_stmt(stmt); + } +} + +/// A server-side error's diagnostic must carry the summary and the native +/// code, and must not carry Trino's `failure_info`. +/// +/// `QueryError`'s own `Display` renders the coordinator's Java stack, and core +/// walks the whole causal chain into the message, so a diagnostic carrying it +/// runs to thousands of characters: measured between 1,700 and 15,000 against +/// a live coordinator, `DIVISION_BY_ZERO` worst at ~168 frames. `QueryCause` +/// is what keeps the two apart. +/// +/// The bound below is loose on purpose. It asserts that no stack is in there, +/// not a particular length, and a returning stack would exceed it by orders +/// of magnitude. +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn server_error_diagnostics_carry_the_summary_not_the_java_stack() { + // (SQL, Trino error name, native error code) + let cases: &[(&str, &str, i32)] = &[ + ( + "SELECT nope FROM tpcds.sf1.customer", + "COLUMN_NOT_FOUND", + 47, + ), + ("SELECT 1/0", "DIVISION_BY_ZERO", 8), + ("SELECT CAST('abc' AS INTEGER)", "INVALID_CAST_ARGUMENT", 9), + ]; + const MAX_DIAGNOSTIC_CHARS: usize = 500; + + unsafe { + for (sql, error_name, want_native) in cases { + let (_env, _conn, stmt) = alloc_stmt(); + // Some of these are rejected at planning and some only once a page + // is fetched, so drive both before reading the diagnostic. + if exec_direct(stmt, sql) == SqlReturn::SUCCESS { + let _ = ffi::fetch::sql_fetch::<TrinoBackend>(stmt); + } + + let mut state = [0u16; 6]; + let mut msg = [0u16; 4096]; + let mut msg_len: i16 = 0; + let mut native: i32 = 0; + let ret = ffi::diag::sql_get_diag_rec_w::<TrinoBackend>( + HandleType::Stmt as i16, + stmt, + 1, + state.as_mut_ptr(), + &mut native, + msg.as_mut_ptr(), + // In characters for SQLGetDiagRec, unlike SQLGetDiagField. + msg.len() as i16, + &mut msg_len, + ); + assert_eq!( + ret, + SqlReturn::SUCCESS, + "{sql}: expected a diagnostic record" + ); + + let text = String::from_utf16_lossy(&msg[..msg_len as usize]); + assert!( + text.contains(error_name), + "{sql}: diagnostic does not name the error: {text}" + ); + assert_eq!(native, *want_native, "{sql}: wrong native error code"); + assert!( + !text.contains("io.trino") && !text.contains("java."), + "{sql}: Trino's failure_info reached the diagnostic: {text}" + ); + assert!( + text.chars().count() <= MAX_DIAGNOSTIC_CHARS, + "{sql}: diagnostic is {} chars, over the {MAX_DIAGNOSTIC_CHARS} \ + bound that stands in for 'carries no stack': {text}", + text.chars().count() + ); + + cleanup_stmt(stmt); + } + } +} + +/// A VARCHAR value holding digit text, read as `SQL_C_SBIGINT`, must succeed +/// (the ODBC conversion matrix requires CHAR/VARCHAR to convert to every C +/// type); the same shape holding non-numeric text must fail with the +/// specific SQLSTATE the spec defines (22018), not merely "some error". +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn numeric_looking_text_column_read_as_sbigint() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + + assert_eq!( + exec_direct(stmt, "SELECT CAST('12345' AS VARCHAR)"), + SqlReturn::SUCCESS + ); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + let mut buf: i64 = 0; + let mut ind: isize = 0; + let ret = ffi::fetch::sql_get_data::<TrinoBackend>( + stmt, + 1, + CDataType::SBigInt as i16, + &mut buf as *mut i64 as *mut c_void, + std::mem::size_of::<i64>() as isize, + &mut ind, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + assert_eq!(buf, 12345); + cleanup_stmt(stmt); + + let (_env, _conn, stmt) = alloc_stmt(); + assert_eq!( + exec_direct(stmt, "SELECT CAST('not a number' AS VARCHAR)"), + SqlReturn::SUCCESS + ); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + let mut buf2: i64 = 0; + let mut ind2: isize = 0; + let ret2 = ffi::fetch::sql_get_data::<TrinoBackend>( + stmt, + 1, + CDataType::SBigInt as i16, + &mut buf2 as *mut i64 as *mut c_void, + std::mem::size_of::<i64>() as isize, + &mut ind2, + ); + assert_eq!(ret2, SqlReturn::ERROR); + assert_eq!(last_sqlstate(stmt), "22018"); + cleanup_stmt(stmt); + } +} + +/// A VARCHAR value holding timestamp-shaped text (e.g. the result of +/// `CAST(... AS VARCHAR)` on a temporal expression, or any text column that +/// happens to look like a timestamp), read as `SQL_C_TYPE_TIMESTAMP`. This +/// is the Trino analogue of "a temporal column read as SQL_C_TYPE_TIMESTAMP +/// where the stored value is text": Trino's native TIMESTAMP columns are +/// already parsed to `ColumnValue::Timestamp` before `write_column_value` +/// runs (see `type_conversion.rs`), so only a VARCHAR-typed source reaches +/// `write_column_value`'s `(ColumnValue::String, CDataType::TypeTimestamp)` +/// arm end to end. +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn timestamp_shaped_text_column_read_as_type_timestamp() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + assert_eq!( + exec_direct( + stmt, + "SELECT CAST(TIMESTAMP '2024-03-05 13:30:15' AS VARCHAR)" + ), + SqlReturn::SUCCESS + ); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + + let mut buf = RawTimestamp { + year: 0, + month: 0, + day: 0, + hour: 0, + minute: 0, + second: 0, + fraction: 0, + }; + let mut ind: isize = 0; + let ret = ffi::fetch::sql_get_data::<TrinoBackend>( + stmt, + 1, + CDataType::TypeTimestamp as i16, + &mut buf as *mut RawTimestamp as *mut c_void, + std::mem::size_of::<RawTimestamp>() as isize, + &mut ind, + ); + assert_eq!(ret, SqlReturn::SUCCESS, "well-formed timestamp-shaped text"); + assert_eq!((buf.year, buf.month, buf.day), (2024, 3, 5)); + assert_eq!((buf.hour, buf.minute, buf.second), (13, 30, 15)); + cleanup_stmt(stmt); + + let (_env, _conn, stmt) = alloc_stmt(); + assert_eq!( + exec_direct(stmt, "SELECT CAST('not-a-timestamp' AS VARCHAR)"), + SqlReturn::SUCCESS + ); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + let mut buf2 = buf; + let mut ind2: isize = 0; + let ret2 = ffi::fetch::sql_get_data::<TrinoBackend>( + stmt, + 1, + CDataType::TypeTimestamp as i16, + &mut buf2 as *mut RawTimestamp as *mut c_void, + std::mem::size_of::<RawTimestamp>() as isize, + &mut ind2, + ); + assert_eq!(ret2, SqlReturn::ERROR); + assert_eq!(last_sqlstate(stmt), "22018"); + cleanup_stmt(stmt); + } +} + +// --------------------------------------------------------------------------- +// Statement and connection attributes +// --------------------------------------------------------------------------- +// +// Core owns every one of these paths, but they are only observable through a +// driver: `sql_set_stmt_attr_w::<TrinoBackend>` is what an application calls, +// and what it returns is what an application plans around. The tests here +// drive the real entry points with `TrinoBackend` as the type parameter, so a +// core change that alters the contract fails this crate's suite rather than +// being noticed by a BI tool. +// +// None of them need a live coordinator: `SQLSetStmtAttr` and `SQLGetStmtAttr` +// touch the handle's attribute map and nothing else, and the connection-level +// tests use the injected network-free `TrinoConnection`. + +/// `SQL_ATTR_ENLIST_IN_DTC` (1207), which `odbc_sys::ConnectionAttribute` does +/// not model. +const SQL_ATTR_ENLIST_IN_DTC: i32 = 1207; + +/// Reads one statement attribute back through `SQLGetStmtAttr`. +/// +/// The buffer is zeroed rather than poisoned, because core currently writes +/// only four bytes for the integer-valued attributes; see +/// [`statement_attribute_read_back_width_is_narrower_than_the_spec_declares`], +/// which owns that question. Every value these tests compare fits in 32 bits, +/// so this reads correctly both before and after that is fixed. +unsafe fn get_stmt_attr(stmt: *mut c_void, attribute: i32) -> (SqlReturn, usize) { + let mut value: usize = 0; + let mut string_length: i32 = 0; + let ret = unsafe { + ffi::stmt_attr::sql_get_stmt_attr_w::<TrinoBackend>( + stmt, + attribute, + &mut value as *mut usize as *mut c_void, + 0, + &mut string_length, + ) + }; + (ret, value) +} + +/// Sets one integer-valued statement attribute through `SQLSetStmtAttr`. +unsafe fn set_stmt_attr(stmt: *mut c_void, attribute: i32, value: usize) -> SqlReturn { + unsafe { + ffi::stmt_attr::sql_set_stmt_attr_w::<TrinoBackend>( + stmt, + attribute, + std::ptr::without_provenance_mut(value), + 0, + ) + } +} + +/// The first diagnostic record's SQLSTATE on any handle, or `""` when there is +/// none. Unlike [`last_sqlstate`] this does not assert one exists: the +/// attribute tests below check *both* that a warning is posted and that a +/// plain success posts nothing. +unsafe fn sqlstate_of(handle_type: HandleType, handle: *mut c_void) -> String { + let mut state = [0u16; 6]; + let mut native: i32 = 0; + let mut msg = [0u16; 256]; + let mut msg_len: i16 = 0; + let ret = unsafe { + ffi::diag::sql_get_diag_rec_w::<TrinoBackend>( + handle_type as i16, + handle, + 1, + state.as_mut_ptr(), + &mut native, + msg.as_mut_ptr(), + msg.len() as i16, + &mut msg_len, + ) + }; + if ret == SqlReturn::SUCCESS { + String::from_utf16_lossy(&state[..5]) + } else { + String::new() + } +} + +/// The spec's `01S02` row closes the set of statement attributes a driver may +/// substitute for, and core stores the value it will use for each rather than +/// the one asked for. That is what makes the row's parenthesis true +/// ("`SQLGetStmtAttr` can be called to determine the temporarily substituted +/// value."), and it is the half an application acts on: a tool that sets +/// `SQL_ATTR_MAX_ROWS = 100` and reads back `100` expects at most a hundred +/// rows, while this driver returns every one. +/// +/// `SQL_ATTR_CURSOR_SCROLLABLE` and `SQL_ATTR_PARAMSET_SIZE` are checked +/// alongside them although the spec's list names neither. Both are documented +/// deviations at their arms in core, and pinning them here stops the +/// deviation being undone by accident. +/// +/// `SQL_ATTR_QUERY_TIMEOUT` is **not** in this list. This driver answers +/// `Backend::set_query_timeout`, so a statement on a live connection accepts +/// the value instead of substituting `0`; see +/// `query_timeout_is_accepted_on_a_connected_statement`. It would pass here +/// anyway, because these handles are never connected and core substitutes +/// with no connection to offer the value to, and that is what makes keeping +/// it misleading: the assertion would hold for a reason unrelated to what it +/// claims. +#[test] +fn set_stmt_attr_substitutes_and_reports_the_value_it_will_use() { + // (attribute, requested value, the value core will report back) + let cases: &[(StatementAttribute, usize, usize, &str)] = &[ + ( + StatementAttribute::Concurrency, + 2, + 1, + "SQL_CONCUR_READ_ONLY", + ), + ( + StatementAttribute::CursorType, + 2, + 0, + "SQL_CURSOR_FORWARD_ONLY", + ), + (StatementAttribute::KeysetSize, 50, 0, "fully keyset-driven"), + (StatementAttribute::MaxLength, 4096, 0, "all available data"), + (StatementAttribute::MaxRows, 100, 0, "no row limit"), + (StatementAttribute::RowArraySize, 64, 1, "one-row rowset"), + ( + StatementAttribute::SimulateCursor, + 2, + 0, + "SQL_SC_NON_UNIQUE", + ), + // The two deviations from the spec's closed list. + ( + StatementAttribute::CursorScrollable, + 1, + 0, + "SQL_NONSCROLLABLE", + ), + ( + StatementAttribute::ParamsetSize, + 500, + 1, + "one parameter set", + ), + ]; + + unsafe { + for &(attr, requested, substituted, why) in cases { + let (env, conn, stmt) = alloc_handles(); + + assert_eq!( + set_stmt_attr(stmt, attr as i32, requested), + SqlReturn::SUCCESS_WITH_INFO, + "{attr:?} = {requested} must be reported as substituted ({why})" + ); + assert_eq!( + sqlstate_of(HandleType::Stmt, stmt), + "01S02", + "{attr:?}: the spec's SQLSTATE for a substituted attribute value" + ); + + let (ret, read_back) = get_stmt_attr(stmt, attr as i32); + assert_eq!(ret, SqlReturn::SUCCESS, "{attr:?} must be readable"); + assert_eq!( + read_back, substituted, + "{attr:?}: SQLGetStmtAttr must report the substituted value \ + ({substituted}, {why}), not the {requested} that was asked for" + ); + + let _ = ffi::handle::sql_free_handle::<TrinoBackend>(HandleType::Stmt as i16, stmt); + let _ = ffi::handle::sql_free_handle::<TrinoBackend>(HandleType::Dbc as i16, conn); + let _ = ffi::handle::sql_free_handle::<TrinoBackend>(HandleType::Env as i16, env); + } + } +} + +/// The value each of those attributes already holds is accepted plainly, +/// `SQL_SUCCESS`, no diagnostic. Without this the test above would still pass +/// if core substituted unconditionally, which would post a warning on every +/// tool that sets an attribute to the value the driver already uses. +#[test] +fn set_stmt_attr_accepts_the_value_it_already_uses_without_a_warning() { + let cases: &[(StatementAttribute, usize)] = &[ + (StatementAttribute::Concurrency, 1), + (StatementAttribute::CursorType, 0), + (StatementAttribute::KeysetSize, 0), + (StatementAttribute::MaxLength, 0), + (StatementAttribute::MaxRows, 0), + (StatementAttribute::RowArraySize, 1), + (StatementAttribute::SimulateCursor, 0), + (StatementAttribute::CursorScrollable, 0), + (StatementAttribute::ParamsetSize, 1), + ]; + + unsafe { + for &(attr, value) in cases { + let (env, conn, stmt) = alloc_handles(); + + assert_eq!( + set_stmt_attr(stmt, attr as i32, value), + SqlReturn::SUCCESS, + "{attr:?} = {value} is what the driver already does" + ); + assert_eq!( + sqlstate_of(HandleType::Stmt, stmt), + "", + "{attr:?} = {value} must post no diagnostic" + ); + + let _ = ffi::handle::sql_free_handle::<TrinoBackend>(HandleType::Stmt as i16, stmt); + let _ = ffi::handle::sql_free_handle::<TrinoBackend>(HandleType::Dbc as i16, conn); + let _ = ffi::handle::sql_free_handle::<TrinoBackend>(HandleType::Env as i16, env); + } + } +} + +/// `SQL_ATTR_QUERY_TIMEOUT` is accepted plainly on a connected statement, and +/// reads back the value the application asked for. +/// +/// This is the half that matters: core arms its timer only when +/// `Backend::set_query_timeout` answers `Ok`, and `SQLGetStmtAttr` reporting +/// the requested value rather than `0` is how an application learns its +/// deadline is really in force. The driver answers `QueryTimeout::CoreCancels`, +/// so both follow. +/// +/// A *connected* handle is required, and that is the whole point of using +/// `attach_connection` here: `offer_to_data_source` substitutes without +/// consulting the backend when it has no connection, so the plain +/// `alloc_handles` used by the neighbouring tests would exercise the fallback +/// and never reach `TrinoBackend::set_query_timeout` at all. +#[test] +fn query_timeout_is_accepted_on_a_connected_statement() { + unsafe { + let (env, conn) = alloc_conn_with_injected_trino_connection(); + let mut stmt: *mut c_void = std::ptr::null_mut(); + assert_eq!( + ffi::handle::sql_alloc_handle::<TrinoBackend>(HandleType::Stmt as i16, conn, &mut stmt), + SqlReturn::SUCCESS + ); + + assert_eq!( + set_stmt_attr(stmt, StatementAttribute::QueryTimeout as i32, 30), + SqlReturn::SUCCESS, + "this driver enforces SQL_ATTR_QUERY_TIMEOUT, so it must not be substituted" + ); + assert_eq!( + sqlstate_of(HandleType::Stmt, stmt), + "", + "an accepted attribute posts no diagnostic; 01S02 would tell the \ + application its deadline had been capped" + ); + + let (ret, read_back) = get_stmt_attr(stmt, StatementAttribute::QueryTimeout as i32); + assert_eq!(ret, SqlReturn::SUCCESS); + assert_eq!( + read_back, 30, + "SQLGetStmtAttr must report the timeout that was set, not the 0 core \ + substitutes for a backend that cannot enforce one" + ); + + let _ = ffi::handle::sql_free_handle::<TrinoBackend>(HandleType::Stmt as i16, stmt); + cleanup_injected_conn(env, conn); + } +} + +/// An attribute off the `01S02` list has no substitution to offer, so the +/// value is refused outright with `HYC00`, "optional feature not +/// implemented". The distinction matters to an application: `01S02` says +/// "I did something else", `HYC00` says "I did nothing", and reading a +/// substituted value back is only meaningful for the first. +#[test] +fn set_stmt_attr_reports_hyc00_for_a_value_it_cannot_substitute_for() { + // (attribute, an unhonourable value, what it would mean) + let cases: &[(StatementAttribute, usize, &str)] = &[ + (StatementAttribute::UseBookmarks, 2, "SQL_UB_VARIABLE"), + (StatementAttribute::RetrieveData, 0, "SQL_RD_OFF"), + (StatementAttribute::CursorSensitivity, 2, "SQL_SENSITIVE"), + (StatementAttribute::EnableAutoIpd, 1, "SQL_TRUE"), + (StatementAttribute::AsyncEnable, 1, "SQL_ASYNC_ENABLE_ON"), + ]; + + unsafe { + for &(attr, value, meaning) in cases { + let (env, conn, stmt) = alloc_handles(); + + assert_eq!( + set_stmt_attr(stmt, attr as i32, value), + SqlReturn::ERROR, + "{attr:?} = {meaning} is not implemented and must be refused" + ); + assert_eq!( + sqlstate_of(HandleType::Stmt, stmt), + "HYC00", + "{attr:?} = {meaning}: the spec's SQLSTATE for a valid attribute \ + whose value the driver does not support" + ); + + let _ = ffi::handle::sql_free_handle::<TrinoBackend>(HandleType::Stmt as i16, stmt); + let _ = ffi::handle::sql_free_handle::<TrinoBackend>(HandleType::Dbc as i16, conn); + let _ = ffi::handle::sql_free_handle::<TrinoBackend>(HandleType::Env as i16, env); + } + } +} + +/// Every statement attribute `SQLSetStmtAttr` accepts can be read back. +/// +/// A value stored but not readable is worse than one refused: the application +/// sets it, gets `SQL_SUCCESS`, and then gets `HYC00` asking what it is. The +/// pointer-valued attributes are the ones this covers that nothing else does: +/// a tool binding a row-status array reads the pointer back to confirm the +/// driver took it. +#[test] +fn every_statement_attribute_the_driver_accepts_is_readable() { + let mut sink: usize = 0; + let ptr = &mut sink as *mut usize as usize; + + // (attribute, a value the driver honours as-is) + let cases: &[(StatementAttribute, usize)] = &[ + (StatementAttribute::NoScan, 0), + (StatementAttribute::RowBindType, 0), + (StatementAttribute::ParamBindType, 0), + (StatementAttribute::MetadataId, 1), + (StatementAttribute::RowsFetchedPtr, ptr), + (StatementAttribute::RowStatusPtr, ptr), + (StatementAttribute::RowBindOffsetPtr, ptr), + (StatementAttribute::RowOperationPtr, ptr), + (StatementAttribute::ParamsProcessedPtr, ptr), + (StatementAttribute::ParamStatusPtr, ptr), + (StatementAttribute::ParamBindOffsetPtr, ptr), + (StatementAttribute::ParamOpterationPtr, ptr), + (StatementAttribute::FetchBookmarkPtr, 0), + ]; + + unsafe { + let (env, conn, stmt) = alloc_handles(); + + for &(attr, value) in cases { + assert_eq!( + set_stmt_attr(stmt, attr as i32, value), + SqlReturn::SUCCESS, + "{attr:?} = {value:#x} must be accepted" + ); + let (ret, read_back) = get_stmt_attr(stmt, attr as i32); + assert_eq!( + ret, + SqlReturn::SUCCESS, + "{attr:?} was accepted, so SQLGetStmtAttr must answer it" + ); + assert_eq!(read_back, value, "{attr:?} must read back as what was set"); + } + + let _ = ffi::handle::sql_free_handle::<TrinoBackend>(HandleType::Stmt as i16, stmt); + let _ = ffi::handle::sql_free_handle::<TrinoBackend>(HandleType::Dbc as i16, conn); + let _ = ffi::handle::sql_free_handle::<TrinoBackend>(HandleType::Env as i16, env); + } +} + +/// `SQL_ATTR_METADATA_ID` is one of exactly two attributes `SQLSetStmtAttr`'s +/// Comments allow an application to set at the connection level, and a +/// statement allocated afterwards must start from the connection's value. +/// +/// This is not a cosmetic read-back: `metadata_id_enabled` consults the +/// *statement's* map, and it is what decides whether the catalog functions +/// treat their arguments as identifiers (case-folded, wildcards escaped) or as +/// search patterns. An application taking the connection-level route must not +/// get `SQL_SUCCESS`, see its value echoed by `SQLGetConnectAttr`, and then get +/// pattern semantics with no diagnostic saying so. For this driver that +/// mismatch means `SQLColumns(table_name = "my_table")` matching `my7table` as +/// well, because `_` is a wildcard. +/// +/// The ODBC 2.x rule the connection-level route inherits makes this the +/// default for statements allocated *afterwards* only, so the statement that +/// already existed is asserted to be untouched in the same test. +#[test] +fn metadata_id_set_on_the_connection_reaches_statements_allocated_after_it() { + unsafe { + let (env, conn, before) = alloc_handles(); + + // A statement that predates the connection-level setting. + assert_eq!( + get_stmt_attr(before, StatementAttribute::MetadataId as i32).1, + 0 + ); + + assert_eq!( + ffi::connect_attr::sql_set_connect_attr_w::<TrinoBackend>( + conn, + odbc_sys::ConnectionAttribute::METADATA_ID.0, + std::ptr::without_provenance_mut(1usize), // SQL_TRUE + 0, + ), + SqlReturn::SUCCESS + ); + + let mut after: *mut c_void = std::ptr::null_mut(); + assert_eq!( + ffi::handle::sql_alloc_handle::<TrinoBackend>( + HandleType::Stmt as i16, + conn, + &mut after + ), + SqlReturn::SUCCESS + ); + assert_eq!( + get_stmt_attr(after, StatementAttribute::MetadataId as i32), + (SqlReturn::SUCCESS, 1), + "a statement allocated after SQLSetConnectAttr(SQL_ATTR_METADATA_ID, \ + SQL_TRUE) must inherit it" + ); + + assert_eq!( + get_stmt_attr(before, StatementAttribute::MetadataId as i32).1, + 0, + "a statement that already existed is untouched, per the ODBC 2.x \ + rule the connection-level route inherits" + ); + + // A later SQLSetStmtAttr still overrides the inherited value. + assert_eq!( + set_stmt_attr(after, StatementAttribute::MetadataId as i32, 0), + SqlReturn::SUCCESS + ); + assert_eq!( + get_stmt_attr(after, StatementAttribute::MetadataId as i32).1, + 0 + ); + + let _ = ffi::handle::sql_free_handle::<TrinoBackend>(HandleType::Stmt as i16, after); + let _ = ffi::handle::sql_free_handle::<TrinoBackend>(HandleType::Stmt as i16, before); + let _ = ffi::handle::sql_free_handle::<TrinoBackend>(HandleType::Dbc as i16, conn); + let _ = ffi::handle::sql_free_handle::<TrinoBackend>(HandleType::Env as i16, env); + } +} + +/// The two connection attributes whose state and support rules the +/// `SQLSetConnectAttr` page assigns to the driver rather than to the Driver +/// Manager. +/// +/// `SQL_ATTR_PACKET_SIZE` is stated directly ("if the application sets packet +/// size after a connection has already been made, the driver will return +/// SQLSTATE HY011"), and needs a connection to be open, which is what the +/// injected `TrinoConnection` supplies without a coordinator. It is accepted +/// before one, since a driver that refused it there would have no legal moment +/// to accept it at all. +/// +/// `SQL_ATTR_ENLIST_IN_DTC` and `SQL_ATTR_ASYNC_ENABLE = SQL_ASYNC_ENABLE_ON` +/// are `HYC00` at any time: this driver reports `SQL_AM_NONE` for +/// `SQL_ASYNC_MODE` and enlists in no distributed transaction, so accepting +/// either would leave an application believing in behaviour it does not get. +#[test] +fn set_connect_attr_enforces_the_rules_the_spec_assigns_to_the_driver() { + unsafe { + let (env, conn) = alloc_conn_with_injected_trino_connection(); + + assert_eq!( + ffi::connect_attr::sql_set_connect_attr_w::<TrinoBackend>( + conn, + odbc_sys::ConnectionAttribute::PACKET_SIZE.0, + std::ptr::without_provenance_mut(8192usize), + 0, + ), + SqlReturn::ERROR, + "SQL_ATTR_PACKET_SIZE after the connection is open" + ); + assert_eq!(sqlstate_of(HandleType::Dbc, conn), "HY011"); + + assert_eq!( + ffi::connect_attr::sql_set_connect_attr_w::<TrinoBackend>( + conn, + SQL_ATTR_ENLIST_IN_DTC, + std::ptr::without_provenance_mut(1usize), + 0, + ), + SqlReturn::ERROR, + "SQL_ATTR_ENLIST_IN_DTC" + ); + assert_eq!(sqlstate_of(HandleType::Dbc, conn), "HYC00"); + + assert_eq!( + ffi::connect_attr::sql_set_connect_attr_w::<TrinoBackend>( + conn, + odbc_sys::ConnectionAttribute::ASYNC_ENABLE.0, + std::ptr::without_provenance_mut(1usize), // SQL_ASYNC_ENABLE_ON + 0, + ), + SqlReturn::ERROR, + "SQL_ATTR_ASYNC_ENABLE = SQL_ASYNC_ENABLE_ON, with SQL_ASYNC_MODE = SQL_AM_NONE" + ); + assert_eq!(sqlstate_of(HandleType::Dbc, conn), "HYC00"); + + // SQL_ASYNC_ENABLE_OFF is the value the driver already uses. + assert_eq!( + ffi::connect_attr::sql_set_connect_attr_w::<TrinoBackend>( + conn, + odbc_sys::ConnectionAttribute::ASYNC_ENABLE.0, + std::ptr::without_provenance_mut(0usize), + 0, + ), + SqlReturn::SUCCESS + ); + + cleanup_injected_conn(env, conn); + } +} + +/// `SQL_ATTR_AUTOCOMMIT` round-trips through the exported entry points, and +/// `SQLEndTran` with nothing open succeeds. +/// +/// Offline on purpose: `set_autocommit` records the mode and issues nothing, +/// and `end_tran` reads the session's transaction id without touching the +/// network, so both halves of the contract are exercised with no coordinator. +/// The commit that reaches a coordinator is covered by the backend tests and by +/// `integration-tests/suites/test_transactions.py`. +/// +/// `SQLEndTran` returning `SQL_SUCCESS` here is the spec's own requirement: +/// "calling SQLEndTran with either SQL_COMMIT or SQL_ROLLBACK when no +/// transaction is active returns SQL_SUCCESS". Trino answers +/// `NOT_IN_TRANSACTION` to the same statement, so the driver must not send it. +#[test] +fn autocommit_round_trips_and_end_tran_with_nothing_open_succeeds() { + unsafe { + let (env, conn) = alloc_conn_with_injected_trino_connection(); + + for (value, name) in [ + (SQL_AUTOCOMMIT_OFF, "SQL_AUTOCOMMIT_OFF"), + (SQL_AUTOCOMMIT_ON, "SQL_AUTOCOMMIT_ON"), + ] { + assert_eq!( + ffi::connect_attr::sql_set_connect_attr_w::<TrinoBackend>( + conn, + odbc_sys::ConnectionAttribute::AUTOCOMMIT.0, + std::ptr::without_provenance_mut(value), + 0, + ), + SqlReturn::SUCCESS, + "setting {name}" + ); + assert_eq!(sqlstate_of(HandleType::Dbc, conn), ""); + + let mut read: u32 = u32::MAX; + assert_eq!( + ffi::connect_attr::sql_get_connect_attr_w::<TrinoBackend>( + conn, + odbc_sys::ConnectionAttribute::AUTOCOMMIT.0, + (&raw mut read).cast(), + 0, + std::ptr::null_mut(), + ), + SqlReturn::SUCCESS, + "reading back {name}" + ); + assert_eq!( + read as usize, value, + "{name} did not survive the round trip" + ); + } + + for (completion, name) in [ + (odbc_sys::CompletionType::Commit, "SQL_COMMIT"), + (odbc_sys::CompletionType::Rollback, "SQL_ROLLBACK"), + ] { + assert_eq!( + ffi::tran::sql_end_tran::<TrinoBackend>( + HandleType::Dbc as i16, + conn, + completion as i16, + ), + SqlReturn::SUCCESS, + "SQLEndTran({name}) with no transaction open" + ); + assert_eq!(sqlstate_of(HandleType::Dbc, conn), ""); + } + + cleanup_injected_conn(env, conn); + } +} + +/// `SQL_ATTR_PACKET_SIZE` before a connection exists is accepted, which is the +/// other half of the `HY011` rule above: the spec's restriction is on setting +/// it *after* connecting, so refusing it always would leave the attribute with +/// no legal moment. +#[test] +fn set_connect_attr_accepts_packet_size_before_connecting() { + unsafe { + let (env, conn, stmt) = alloc_handles(); + + assert_eq!( + ffi::connect_attr::sql_set_connect_attr_w::<TrinoBackend>( + conn, + odbc_sys::ConnectionAttribute::PACKET_SIZE.0, + std::ptr::without_provenance_mut(8192usize), + 0, + ), + SqlReturn::SUCCESS + ); + assert_eq!(sqlstate_of(HandleType::Dbc, conn), ""); + + let _ = ffi::handle::sql_free_handle::<TrinoBackend>(HandleType::Stmt as i16, stmt); + let _ = ffi::handle::sql_free_handle::<TrinoBackend>(HandleType::Dbc as i16, conn); + let _ = ffi::handle::sql_free_handle::<TrinoBackend>(HandleType::Env as i16, env); + } +} + +/// Every statement attribute is written at the width the spec declares. +/// +/// `SQLSetStmtAttr`'s page declares every non-pointer statement attribute it +/// lists as "An SQLULEN value": `SQL_ATTR_CONCURRENCY`, +/// `SQL_ATTR_CURSOR_TYPE`, `SQL_ATTR_NOSCAN`, `SQL_ATTR_METADATA_ID`, +/// `SQL_ATTR_QUERY_TIMEOUT`, `SQL_ATTR_MAX_ROWS`, `SQL_ATTR_ROW_ARRAY_SIZE` +/// and the rest. Not one is `SQLUINTEGER`, and `SQLULEN` is 64 bits on a +/// 64-bit platform. +/// +/// A short write is the same class of defect as a wrongly-shaped `SQLGetInfo` +/// answer, in the other direction. `SQLGetStmtAttr`'s `BufferLength` is +/// ignored for a non-string value, so an application writing +/// `SQLULEN v; SQLGetStmtAttr(stmt, SQL_ATTR_MAX_ROWS, &v, 0, NULL);` keeps +/// whatever was on its stack in the top four bytes of `v` and reads an +/// enormous row limit rather than the `0` core reported. The buffer below is +/// poisoned rather than zeroed for that reason: a zeroed one cannot tell a +/// correct write from a short one. +#[test] +fn statement_attributes_are_written_at_the_full_sqlulen_width() { + // The integer-valued attributes, each at a value whose top half is zero, + // so a short write leaves the poison visible. + let cases: &[(StatementAttribute, usize)] = &[ + (StatementAttribute::QueryTimeout, 0), + (StatementAttribute::MaxRows, 0), + (StatementAttribute::MaxLength, 0), + (StatementAttribute::KeysetSize, 0), + (StatementAttribute::RowArraySize, 1), + (StatementAttribute::ParamsetSize, 1), + (StatementAttribute::Concurrency, 1), + (StatementAttribute::CursorType, 0), + (StatementAttribute::NoScan, 0), + (StatementAttribute::RowBindType, 0), + (StatementAttribute::ParamBindType, 0), + (StatementAttribute::MetadataId, 0), + (StatementAttribute::CursorScrollable, 0), + (StatementAttribute::CursorSensitivity, 0), + (StatementAttribute::SimulateCursor, 0), + (StatementAttribute::RetrieveData, 1), + (StatementAttribute::UseBookmarks, 0), + (StatementAttribute::EnableAutoIpd, 0), + (StatementAttribute::AsyncEnable, 0), + ]; + + unsafe { + let (env, conn, stmt) = alloc_handles(); + + for &(attr, expected) in cases { + let mut value: usize = usize::MAX; + let mut string_length: i32 = 0; + assert_eq!( + ffi::stmt_attr::sql_get_stmt_attr_w::<TrinoBackend>( + stmt, + attr as i32, + &mut value as *mut usize as *mut c_void, + 0, + &mut string_length, + ), + SqlReturn::SUCCESS, + "{attr:?} must be readable" + ); + assert_eq!( + value, expected, + "{attr:?}: the poisoned top half survived, so the write was \ + narrower than the SQLULEN the spec declares" + ); + assert_eq!( + string_length, + std::mem::size_of::<usize>() as i32, + "{attr:?}: StringLength must be size_of::<SQLULEN>()" + ); + } + + // The pointer-valued attributes were always full width; asserted + // alongside so the two groups cannot drift apart. + let mut sink: usize = 0; + let ptr = &mut sink as *mut usize as usize; + assert_eq!( + set_stmt_attr(stmt, StatementAttribute::RowsFetchedPtr as i32, ptr), + SqlReturn::SUCCESS + ); + let mut back: usize = usize::MAX; + let mut ptr_len: i32 = 0; + assert_eq!( + ffi::stmt_attr::sql_get_stmt_attr_w::<TrinoBackend>( + stmt, + StatementAttribute::RowsFetchedPtr as i32, + &mut back as *mut usize as *mut c_void, + 0, + &mut ptr_len, + ), + SqlReturn::SUCCESS + ); + assert_eq!(back, ptr, "SQL_ATTR_ROWS_FETCHED_PTR round-trips whole"); + assert_eq!(ptr_len, std::mem::size_of::<usize>() as i32); + + let _ = ffi::handle::sql_free_handle::<TrinoBackend>(HandleType::Stmt as i16, stmt); + let _ = ffi::handle::sql_free_handle::<TrinoBackend>(HandleType::Dbc as i16, conn); + let _ = ffi::handle::sql_free_handle::<TrinoBackend>(HandleType::Env as i16, env); + } +} + +/// An execution reports its parameter set through +/// `SQL_ATTR_PARAMS_PROCESSED_PTR` and `SQL_ATTR_PARAM_STATUS_PTR`. +/// +/// The parameter-side counterpart of what `SQLFetch` already writes through +/// `SQL_ATTR_ROWS_FETCHED_PTR`. An application that binds a status array to +/// detect per-set errors and gets nothing written back reads its own initial +/// buffer contents, which is indistinguishable from every set having +/// succeeded. +/// +/// Driven against a live coordinator rather than a mock because the value of +/// the status element is decided by whether the *execution* succeeded, and a +/// real rejection by Trino is the only way to reach `SQL_PARAM_ERROR` through +/// the same path an application does. +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn execution_writes_the_processed_count_and_the_parameter_status() { + unsafe { + // --- A successful execution --- + let (_env, _conn, stmt) = alloc_stmt(); + + let mut processed: usize = usize::MAX; + let mut status: [u16; 4] = [0xBEEF; 4]; + assert_eq!( + set_stmt_attr( + stmt, + StatementAttribute::ParamsProcessedPtr as i32, + &mut processed as *mut usize as usize, + ), + SqlReturn::SUCCESS + ); + assert_eq!( + set_stmt_attr( + stmt, + StatementAttribute::ParamStatusPtr as i32, + status.as_mut_ptr() as usize, + ), + SqlReturn::SUCCESS + ); + + let mut value: i64 = 42; + assert_eq!(bind_i64(stmt, 1, &mut value), SqlReturn::SUCCESS); + assert_eq!( + exec_direct(stmt, "SELECT CAST(? AS bigint)"), + SqlReturn::SUCCESS, + "{}", + diag_message(stmt) + ); + + assert_eq!( + processed, 1, + "SQL_ATTR_PARAMSET_SIZE is pinned at 1, so exactly one set is processed" + ); + assert_eq!( + status[0], SQL_PARAM_SUCCESS, + "the first status element after a successful execution" + ); + assert_eq!( + status[1], 0xBEEF, + "only the sets actually processed are written; element 2 is untouched" + ); + assert_eq!(fetch_one_i64(stmt), 42); + cleanup_stmt(stmt); + + // --- A rejected execution --- + let (_env, _conn, stmt) = alloc_stmt(); + + let mut processed: usize = usize::MAX; + let mut status: [u16; 4] = [0xBEEF; 4]; + assert_eq!( + set_stmt_attr( + stmt, + StatementAttribute::ParamsProcessedPtr as i32, + &mut processed as *mut usize as usize, + ), + SqlReturn::SUCCESS + ); + assert_eq!( + set_stmt_attr( + stmt, + StatementAttribute::ParamStatusPtr as i32, + status.as_mut_ptr() as usize, + ), + SqlReturn::SUCCESS + ); + + let mut value: i64 = 1; + assert_eq!(bind_i64(stmt, 1, &mut value), SqlReturn::SUCCESS); + // Trino rejects the reference to a table that does not exist, so the + // failure comes from the coordinator rather than from parameter + // handling, which is the case an application binds a status array for. + assert_eq!( + exec_direct(stmt, "SELECT ? FROM does_not_exist_zzz"), + SqlReturn::ERROR + ); + assert_eq!( + processed, 1, + "the processed count includes error sets, per the spec's \ + \"including error sets\"" + ); + assert_eq!( + status[0], SQL_PARAM_ERROR, + "the status element for a set whose execution failed" + ); + cleanup_stmt(stmt); + } +} + +/// `SQL_ATTR_CURRENT_CATALOG` and `SQL_DATABASE_NAME` are one value under two +/// names, so they must agree. +/// +/// The spec says so directly: "in ODBC 3.x, the value returned for this +/// InfoType can also be returned by calling `SQLGetConnectAttr` with an +/// Attribute argument of `SQL_ATTR_CURRENT_CATALOG`". +/// +/// [`TrinoBackend::current_catalog`] is the single source both read, which is +/// why `info.rs` has an arm for neither. Two sources means a connection +/// opened against `tpcds` reporting `"tpcds"` under one name and `""` under +/// the other, since a handle-local attribute string has nothing to seed it. +#[test] +fn the_current_catalog_reads_the_same_under_both_of_its_names() { + unsafe { + for catalog in [Some("tpcds"), None] { + let expected = catalog.unwrap_or(""); + + let mut env: *mut c_void = std::ptr::null_mut(); + assert_eq!( + ffi::handle::sql_alloc_handle::<TrinoBackend>( + HandleType::Env as i16, + std::ptr::null_mut(), + &mut env, + ), + SqlReturn::SUCCESS + ); + let mut conn: *mut c_void = std::ptr::null_mut(); + assert_eq!( + ffi::handle::sql_alloc_handle::<TrinoBackend>( + HandleType::Dbc as i16, + env, + &mut conn + ), + SqlReturn::SUCCESS + ); + attach_connection::<TrinoBackend>(conn, disconnected_trino_conn_with_catalog(catalog)) + .expect("valid conn handle"); + + let mut buf = [0u16; 128]; + let mut len: i32 = 0; + assert_eq!( + ffi::connect_attr::sql_get_connect_attr_w::<TrinoBackend>( + conn, + odbc_sys::ConnectionAttribute::CURRENT_CATALOG.0, + buf.as_mut_ptr().cast(), + (buf.len() * 2) as i32, + &mut len, + ), + SqlReturn::SUCCESS + ); + let attr = String::from_utf16_lossy(&buf[..(len / 2).max(0) as usize]); + assert_eq!( + attr, expected, + "SQLGetConnectAttr(SQL_ATTR_CURRENT_CATALOG) for catalog {catalog:?}" + ); + + let (ret, info) = stackable_odbc_core::conformance::observe_string_value::<TrinoBackend>( + conn, + stackable_odbc_core::types::SQL_DATABASE_NAME, + ); + assert_eq!(ret, SqlReturn::SUCCESS); + assert_eq!( + info, attr, + "SQL_DATABASE_NAME and SQL_ATTR_CURRENT_CATALOG are the same \ + value under two names and must not disagree" + ); + + cleanup_injected_conn(env, conn); + } + } +} + +/// `SQLSetConnectAttr(SQL_ATTR_CURRENT_CATALOG)` reports `HYC00`, because this +/// driver cannot switch catalogs. +/// +/// Core's `set_current_catalog` default is left in place. Trino's only +/// catalog-switching statement is `USE`, whose grammar requires a schema +/// (`USE postgresql` is `NOT_FOUND`, parsed as a schema name), so honouring +/// the call would mean inventing a schema and silently moving the session's +/// unqualified name resolution into it. See the comment beside +/// `TrinoBackend::current_catalog` for the coordinator probes. +/// +/// An application that sets the attribute therefore gets `SQL_ERROR`. That is +/// the honest answer: succeeding would report a switch that did not happen. +#[test] +fn setting_the_current_catalog_is_refused_rather_than_silently_ignored() { + unsafe { + let (env, conn) = alloc_conn_with_injected_trino_connection(); + + let wide: Vec<u16> = "postgresql".encode_utf16().collect(); + assert_eq!( + ffi::connect_attr::sql_set_connect_attr_w::<TrinoBackend>( + conn, + odbc_sys::ConnectionAttribute::CURRENT_CATALOG.0, + wide.as_ptr() as *mut c_void, + (wide.len() * 2) as i32, + ), + SqlReturn::ERROR + ); + assert_eq!(sqlstate_of(HandleType::Dbc, conn), "HYC00"); + + // And nothing was stored: a refused switch must not move what the + // readers report, or the attribute would claim a catalog the session + // is not using. + let mut buf = [0u16; 128]; + let mut len: i32 = 0; + assert_eq!( + ffi::connect_attr::sql_get_connect_attr_w::<TrinoBackend>( + conn, + odbc_sys::ConnectionAttribute::CURRENT_CATALOG.0, + buf.as_mut_ptr().cast(), + (buf.len() * 2) as i32, + &mut len, + ), + SqlReturn::SUCCESS + ); + assert_eq!( + String::from_utf16_lossy(&buf[..(len / 2).max(0) as usize]), + "", + "the injected connection names no catalog, and the refused set \ + must not have changed that" + ); + + cleanup_injected_conn(env, conn); + } +} + +// --------------------------------------------------------------------------- +// Backend-reported fractional truncation (01S07) +// --------------------------------------------------------------------------- + +/// Trino's temporal types reach twelve fractional digits and the client +/// advertises `PARAMETRIC_DATETIME`, so `timestamp(12)` arrives with all twelve +/// while `ColumnValue::Timestamp` carries nine. The three that fall off are +/// dropped inside this driver's own conversion, which core cannot observe, so +/// the driver reports them through `StatementBackend::take_value_warning` and +/// the read answers `SQL_SUCCESS_WITH_INFO` with `01S07`. +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn timestamp_beyond_nanoseconds_reports_fractional_truncation() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + + assert_eq!( + exec_direct( + stmt, + "SELECT CAST(TIMESTAMP '2020-01-02 03:04:05.123456789012' AS timestamp(12)) AS v" + ), + SqlReturn::SUCCESS + ); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + + let mut wbuf = [0u16; 64]; + let mut ind: isize = 0; + let ret = ffi::fetch::sql_get_data::<TrinoBackend>( + stmt, + 1, + CDataType::WChar as i16, + wbuf.as_mut_ptr().cast(), + (wbuf.len() * 2) as isize, + &mut ind, + ); + assert_eq!( + ret, + SqlReturn::SUCCESS_WITH_INFO, + "the driver dropped three fractional digits, so the read is not a plain success" + ); + assert_eq!(sqlstate_of(HandleType::Stmt, stmt), "01S07"); + + // The value still arrives, at the precision the driver can carry: the + // warning is not an error channel. + let chars = (ind / 2).max(0) as usize; + let text = String::from_utf16_lossy(&wbuf[..chars.min(wbuf.len())]); + assert!( + text.contains("05.123456789"), + "expected nine fractional digits in {text:?}" + ); + + cleanup_stmt(stmt); + } +} + +/// The counterpart: a column declared `timestamp(12)` whose value has nothing +/// past the ninth digit loses nothing, and must not draw the diagnostic. The +/// warning is asked of the value, not of the column's declared scale. +#[test] +#[serial] +#[ignore = "requires Trino at localhost:8443; run ./integration-tests/setup.sh first"] +fn timestamp_within_nanoseconds_reports_no_warning() { + unsafe { + let (_env, _conn, stmt) = alloc_stmt(); + + assert_eq!( + exec_direct( + stmt, + "SELECT CAST(TIMESTAMP '2020-01-02 03:04:05.123456789000' AS timestamp(12)) AS v" + ), + SqlReturn::SUCCESS + ); + assert_eq!( + ffi::fetch::sql_fetch::<TrinoBackend>(stmt), + SqlReturn::SUCCESS + ); + + let mut wbuf = [0u16; 64]; + let mut ind: isize = 0; + assert_eq!( + ffi::fetch::sql_get_data::<TrinoBackend>( + stmt, + 1, + CDataType::WChar as i16, + wbuf.as_mut_ptr().cast(), + (wbuf.len() * 2) as isize, + &mut ind, + ), + SqlReturn::SUCCESS + ); + assert_eq!(sqlstate_of(HandleType::Stmt, stmt), ""); + + cleanup_stmt(stmt); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..ed412a1 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,227 @@ +//! ODBC driver for [Trino](https://trino.io), built on the generic +//! [`stackable_odbc_core`] framework. +//! +//! This crate compiles to a C dynamic library (`cdylib`) that an ODBC Driver +//! Manager (unixODBC on Linux, the built-in DM on Windows) loads at runtime, +//! rather than being used as a normal Rust dependency. All the ODBC C ABI +//! entry points are generated by [`stackable_odbc_core::forward_ffi!`] from +//! the [`TrinoBackend`] implementation; the Driver Manager translates ANSI +//! calls, so only the Unicode (`W`) functions are exported. +//! +//! The backend speaks to a Trino coordinator over its HTTP REST protocol via +//! `trino-rust-client`, wrapping an internal Tokio runtime because the +//! [`stackable_odbc_core::backend::Backend`] trait is synchronous. The +//! connection string is parsed in `backend::types::connect_params`, whose +//! `PARAM_*` constants are the authoritative key list. + +mod backend; +mod escape_dialect; +mod type_conversion; + +pub use backend::TrinoBackend; + +stackable_odbc_core::forward_ffi!(crate::backend::TrinoBackend); + +#[cfg(test)] +mod ffi_integration_tests; + +#[cfg(test)] +mod tests { + /// The Power Query connector's `[Version]` is the crate version. + /// + /// Power BI reads that attribute to decide whether an installed `.mez` is + /// newer than the one already present, and a release ships the `.mez` and + /// the driver binary together. Two different numbers would describe one + /// release, and a bug report quotes whichever artefact the reporter + /// happens to have. + /// + /// `release.toml` rewrites the connector as part of the version bump, in + /// the same commit. This asserts the result, so a hand-edit to either file + /// fails `cargo test` rather than surfacing in a release archive. + #[test] + fn connector_version_matches_the_crate() { + let source = include_str!("../connector/StackableTrinoODBC.pq"); + let declared = source + .lines() + .find_map(|line| { + let rest = line.trim().strip_prefix("[Version = \"")?; + rest.strip_suffix("\"]") + }) + .expect("connector/StackableTrinoODBC.pq must declare [Version = \"...\"]"); + + assert_eq!( + declared, + env!("CARGO_PKG_VERSION"), + "the connector's [Version] and the crate version name one release and must agree; \ + release.toml's pre-release-replacement for the .pq is what keeps them together" + ); + } + + /// Every connection-string keyword the parser accepts, from the `PARAM_` + /// constants themselves rather than a transcribed list. + fn connection_string_keys() -> Vec<&'static str> { + let parser = include_str!("backend/types/connect_params.rs"); + // `pub(crate) const PARAM_HOST: &str = "host";` + let mut keys: Vec<&str> = parser + .lines() + .filter_map(|line| { + let rest = line.trim().strip_prefix("pub(crate) const PARAM_")?; + let rest = rest.split_once(": &str = \"")?.1; + rest.strip_suffix("\";") + }) + .collect(); + keys.sort_unstable(); + keys + } + + /// The text of a block, from `opener` to its closing delimiter. + fn block_after<'a>(source: &'a str, opener: &str, closer: &str) -> &'a str { + let Some(start) = source.find(opener) else { + panic!("{opener} must appear in the connector"); + }; + let rest = &source[start + opener.len()..]; + let end = rest.find(closer).unwrap_or(rest.len()); + &rest[..end] + } + + /// The double-quoted strings in a block, in order. + fn quoted_names(block: &str) -> Vec<String> { + block + .split('"') + .skip(1) + .step_by(2) + .map(str::to_lowercase) + .collect() + } + + /// The identifiers introduced by `needle` in a block. + fn prefixed_names(block: &str, needle: &str) -> Vec<String> { + block + .match_indices(needle) + .filter_map(|(i, _)| { + let after = &block[i + needle.len()..]; + let cut = after.find(|c: char| !c.is_alphanumeric() && c != '_')?; + Some(after[..cut].to_lowercase()) + }) + .collect() + } + + /// The DSN dialog's field table names exactly the parser's keywords. + /// + /// `packaging/windows/configure-dsn.ps1` generates its layout, its read + /// path and its write path from one field table, so a keyword missing from + /// that table is a keyword no Windows user can set through the dialog, and + /// invisibly so, because the dialog still opens and still writes a working + /// data source without it. The reverse is worse: a keyword the table names + /// and the parser does not is written into the registry and silently + /// ignored at connect, which reads as the setting having no effect. + /// + /// Both directions are checked against the `PARAM_` constants themselves + /// rather than a transcribed list, so adding a connection-string key fails + /// here until the dialog offers it. + #[test] + fn dsn_keys_match_the_connection_string_parser() { + let dialog = include_str!("../packaging/windows/configure-dsn.ps1"); + let parser_keys = connection_string_keys(); + assert!( + parser_keys.len() > 30, + "expected the PARAM_ constants to parse; got {parser_keys:?}" + ); + + // `Key='host'` and `Alias='token'` in the field table. + let extract = |needle: &str| -> Vec<String> { + dialog + .match_indices(needle) + .filter_map(|(i, _)| { + let rest = &dialog[i + needle.len()..]; + rest.split_once('\'').map(|(v, _)| v.to_string()) + }) + .collect() + }; + let mut dialog_keys = extract("Key='"); + dialog_keys.extend(extract("Alias='")); + dialog_keys.sort(); + + // `User` and `Password` are core's own spec-defined keywords, read + // through `ConnectParams` rather than a `PARAM_` constant, so the + // dialog names two keys the parser file cannot declare. + const CORE_KEYWORDS: [&str; 2] = ["user", "password"]; + + let missing: Vec<&&str> = parser_keys + .iter() + .filter(|k| !dialog_keys.iter().any(|d| d == *k)) + .collect(); + assert!( + missing.is_empty(), + "connect_params.rs accepts {missing:?}, which configure-dsn.ps1's \ + field table does not offer; add an entry (or an Alias= on an \ + existing one) so Windows users can set it" + ); + + let unknown: Vec<&String> = dialog_keys + .iter() + .filter(|d| { + !parser_keys.iter().any(|k| k == *d) && !CORE_KEYWORDS.contains(&d.as_str()) + }) + .collect(); + assert!( + unknown.is_empty(), + "configure-dsn.ps1 offers {unknown:?}, which connect_params.rs does \ + not accept; the driver would ignore it at connect" + ); + } + + /// The connector's advanced options are keys the driver accepts, and the + /// list and the rendered type name the same ones. + /// + /// `Config_AdvancedOptions` is what the connection string is built from, + /// and `StackableTrinoODBC.OptionsType` is only what the Get Data dialog + /// renders. Nothing in Power Query relates them, so an option in the type + /// and not the list is a box a user can fill in that is then rejected as + /// unknown, and one in the list and not the type is reachable only by + /// hand-editing M. + /// + /// The `.pq` is not executed anywhere in this repo, so a name that matches + /// no connection-string key would otherwise surface as a report whose + /// setting silently did nothing. + #[test] + fn connector_options_are_connection_string_keys() { + let connector = include_str!("../connector/StackableTrinoODBC.pq"); + let parser_keys = connection_string_keys(); + + let listed = quoted_names(block_after(connector, "Config_AdvancedOptions = {", "};")); + assert!( + listed.len() > 15, + "expected Config_AdvancedOptions to parse; got {listed:?}" + ); + + let unknown: Vec<&String> = listed + .iter() + .filter(|o| !parser_keys.contains(&o.as_str())) + .collect(); + assert!( + unknown.is_empty(), + "the connector offers {unknown:?}, which connect_params.rs does not \ + accept; the driver would discard it at connect" + ); + + let rendered = prefixed_names( + block_after(connector, "StackableTrinoODBC.OptionsType = type [", "];"), + "optional ", + ); + + let in_type_only: Vec<&String> = rendered.iter().filter(|r| !listed.contains(r)).collect(); + assert!( + in_type_only.is_empty(), + "OptionsType renders {in_type_only:?}, which Config_AdvancedOptions \ + omits; the dialog would offer a field the connector then rejects" + ); + + let in_list_only: Vec<&String> = listed.iter().filter(|l| !rendered.contains(l)).collect(); + assert!( + in_list_only.is_empty(), + "Config_AdvancedOptions carries {in_list_only:?}, which OptionsType \ + does not render; it would be reachable only by hand-editing M" + ); + } +} diff --git a/src/type_conversion.rs b/src/type_conversion.rs new file mode 100644 index 0000000..4839274 --- /dev/null +++ b/src/type_conversion.rs @@ -0,0 +1,2884 @@ +//! Conversion of Trino's JSON-encoded row values into `stackable-odbc-core`'s +//! [`ColumnValue`], and of bound parameter values back into the JSON literals +//! Trino's REST protocol expects. Temporal types are handled via `chrono`. + +use chrono::Datelike as _; +use chrono::Timelike as _; +use serde_json::Value; +use stackable_odbc_core::types::{ + ColumnValue, Interval, NANOS_PER_DAY, NANOS_PER_HOUR, NANOS_PER_MINUTE, NANOS_PER_SECOND, + PRECISION_UNDETERMINABLE, SqlDataType, column_size, +}; +use trino_rust_client::{TrinoFloat, TrinoInt, TrinoTy}; + +/// This driver's declared maximum fractional-seconds precision for TIME, +/// TIME WITH TIME ZONE, TIMESTAMP, and TIMESTAMP WITH TIME ZONE. The single +/// source of truth for every `SQLGetTypeInfo` COLUMN_SIZE/MAXIMUM_SCALE value +/// for these four types (see `backend/info.rs`). Live-verified against Trino +/// 467: `CAST(current_time AS time(13))` errors ("Unknown type: time(13)"), +/// while `time(12)`/`timestamp(12)` (with or without time zone) succeed. +pub(crate) const MAX_FRACTIONAL_SECONDS_PRECISION: i16 = 12; + +/// Fractional-seconds scale assumed for TIME, TIME WITH TIME ZONE, TIMESTAMP +/// and TIMESTAMP WITH TIME ZONE when only a `TrinoTy` value is available, with +/// no `information_schema` type-name string to read a declared scale from. +/// Those four `TrinoTy` variants carry no precision parameter (a +/// `trino-rust-client` limitation; contrast `TrinoTy::Decimal(p, s)`, which +/// does), so there is no per-column scale to read here. +/// +/// 3 (milliseconds), not 0: that is Trino's default declared precision for a +/// column created without an explicit one, where the ANSI SQL default is 0. +/// `time_with_fraction_keeps_milliseconds_via_get_data_string` in +/// `ffi_integration_tests.rs` confirms it for TIME, and TIMESTAMP behaves the +/// same way. +/// +/// One constant covers both TIME and TIMESTAMP. Splitting it in two would gain +/// nothing, because TIME's fraction survives the text conversions exactly as +/// TIMESTAMP's does. +/// +/// A column declaring another scale is reported at 3 on this path. The +/// declared scale reaches the driver only in the type-name string, which +/// `type_name_scale` reads wherever the caller has one. +const DEFAULT_TEMPORAL_SCALE_WITHOUT_TYPE_NAME: i16 = 3; + +/// Trino built-in type names as an enum, eliminating hardcoded strings across +/// `type_conversion.rs` and `backend/info.rs`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum TrinoTypeName { + Boolean, + TinyInt, + SmallInt, + Integer, + BigInt, + Real, + Double, + Decimal, + Varchar, + Char, + Varbinary, + Date, + Time, + TimeWithTimeZone, + Timestamp, + TimestampWithTimeZone, + Json, + Uuid, + IntervalDayToSecond, + IntervalYearToMonth, +} + +impl TrinoTypeName { + /// Uppercase display name for ODBC type-info tables. + pub(crate) const fn name(&self) -> &'static str { + match self { + Self::Boolean => "BOOLEAN", + Self::TinyInt => "TINYINT", + Self::SmallInt => "SMALLINT", + Self::Integer => "INTEGER", + Self::BigInt => "BIGINT", + Self::Real => "REAL", + Self::Double => "DOUBLE", + Self::Decimal => "DECIMAL", + Self::Varchar => "VARCHAR", + Self::Char => "CHAR", + Self::Varbinary => "VARBINARY", + Self::Date => "DATE", + Self::Time => "TIME", + Self::TimeWithTimeZone => "TIME WITH TIME ZONE", + Self::Timestamp => "TIMESTAMP", + Self::TimestampWithTimeZone => "TIMESTAMP WITH TIME ZONE", + Self::Json => "JSON", + Self::Uuid => "UUID", + Self::IntervalDayToSecond => "INTERVAL DAY TO SECOND", + Self::IntervalYearToMonth => "INTERVAL YEAR TO MONTH", + } + } + + /// Parse from an `information_schema` type name string. + /// + /// Removes any parenthesised precision/scale argument (e.g. + /// `"varchar(255)"` → `"varchar"`) before matching. Unlike a naive + /// truncation at the first `(`, this preserves any suffix that follows + /// the argument: Trino serialises time-zone-aware types with the + /// argument in the middle, e.g. `"timestamp(3) with time zone"`, so + /// truncating at `(` would destroy the ` with time zone` suffix and + /// silently match the wrong (non-TZ) variant instead of failing to + /// parse. Returns `None` for unknown or compound types. + pub(crate) fn parse(name: &str) -> Option<Self> { + let base = strip_precision_param(name); + match base.as_str() { + "boolean" => Some(Self::Boolean), + "tinyint" => Some(Self::TinyInt), + "smallint" => Some(Self::SmallInt), + "integer" | "int" => Some(Self::Integer), + "bigint" => Some(Self::BigInt), + "real" => Some(Self::Real), + "double" | "double precision" => Some(Self::Double), + "decimal" | "numeric" => Some(Self::Decimal), + "varchar" | "character varying" | "string" => Some(Self::Varchar), + "char" => Some(Self::Char), + "varbinary" => Some(Self::Varbinary), + "date" => Some(Self::Date), + "time" => Some(Self::Time), + "time with time zone" => Some(Self::TimeWithTimeZone), + "timestamp" => Some(Self::Timestamp), + "timestamp with time zone" => Some(Self::TimestampWithTimeZone), + "json" => Some(Self::Json), + "uuid" => Some(Self::Uuid), + "interval day to second" => Some(Self::IntervalDayToSecond), + "interval year to month" => Some(Self::IntervalYearToMonth), + _ => None, + } + } + + /// The ODBC SQL data type for this Trino type, as returned by `SQLColumns`. + pub(crate) fn sql_type(&self) -> SqlDataType { + match self { + Self::Boolean => SqlDataType::EXT_BIT, + Self::TinyInt => SqlDataType::EXT_TINY_INT, + Self::SmallInt => SqlDataType::SMALLINT, + Self::Integer => SqlDataType::INTEGER, + Self::BigInt => SqlDataType::EXT_BIG_INT, + Self::Real => SqlDataType::REAL, + Self::Double => SqlDataType::DOUBLE, + Self::Decimal => SqlDataType::DECIMAL, + Self::Varchar => SqlDataType::EXT_W_VARCHAR, + // `information_schema` spells a fixed-length column `char(n)`, and + // `SQLColumns` reports it as EXT_W_CHAR. + // + // So does the query path: `backend::execute` prefers + // `TrinoTypeName::parse` over `trino_ty_to_sql_type` precisely so + // the two agree, and `char(n)` parses. `trino_ty_to_sql_type`'s own + // `TrinoTy::Char(_) => EXT_W_VARCHAR` arm is the fallback for a + // signature this parser cannot read, not the ordinary result-column + // route. + Self::Char => SqlDataType::EXT_W_CHAR, + Self::Varbinary => SqlDataType::EXT_LONG_VAR_BINARY, + Self::Date => SqlDataType::DATE, + Self::Time | Self::TimeWithTimeZone => SqlDataType::TIME, + Self::Timestamp | Self::TimestampWithTimeZone => SqlDataType::TIMESTAMP, + Self::Json | Self::Uuid => SqlDataType::EXT_W_VARCHAR, + // Same rationale as Json/Uuid: `odbc-sys` has no concrete + // SQL_INTERVAL_* `SqlDataType` (only the legacy verbose + // `EXT_TIME_OR_INTERVAL` code, which this driver does not use), + // and `trino_ty_to_sql_type` already renders both interval + // types as text for the same reason (see its own INTERVAL arm), + // so EXT_W_VARCHAR is the honest type here too. + Self::IntervalDayToSecond | Self::IntervalYearToMonth => SqlDataType::EXT_W_VARCHAR, + } + } + + /// Fixed column precision for non-parametric types. + /// + /// Returns `None` for types whose precision is encoded in the type string + /// (`Varchar(n)`, `Char(n)`, `Decimal(p,s)`). + pub(crate) fn fixed_precision(&self) -> Option<i32> { + match self { + Self::TinyInt => Some(column_size(SqlDataType::EXT_TINY_INT, 0, 0)), + Self::SmallInt => Some(column_size(SqlDataType::SMALLINT, 0, 0)), + Self::Integer => Some(column_size(SqlDataType::INTEGER, 0, 0)), + Self::BigInt => Some(column_size(SqlDataType::EXT_BIG_INT, 0, 0)), + Self::Real => Some(column_size(SqlDataType::REAL, 0, 0)), + Self::Double => Some(column_size(SqlDataType::DOUBLE, 0, 0)), + Self::Boolean => Some(column_size(SqlDataType::EXT_BIT, 0, 0)), + Self::Date => Some(column_size(SqlDataType::DATE, 0, 0)), + // HH:MM:SS.mmm = 12 chars (9 + scale 3, see + // DEFAULT_TEMPORAL_SCALE_WITHOUT_TYPE_NAME, Trino's actual + // default declared precision). TimeWithTimeZone is also 12, not + // HH:MM:SS.mmm+HH:MM (18): `parse_trino_time_with_tz` applies the + // offset and normalises to UTC (matching TIMESTAMP WITH TIME + // ZONE), so the offset never survives into SQL_TIME_STRUCT; + // only the fractional seconds do (preserved via `ColumnValue:: + // Time`'s `fraction` field, delivered through SQL_C_CHAR/WCHAR + // text conversions). Keep the two as distinct arms, matching + // `trino_ty_precision`: merging them makes one variant borrow the + // other's size, which the query path (`execute.rs`) then reports + // for a column that does not have it. + Self::Time => Some(column_size( + SqlDataType::TIME, + 0, + DEFAULT_TEMPORAL_SCALE_WITHOUT_TYPE_NAME, + )), + Self::TimeWithTimeZone => Some(column_size( + SqlDataType::TIME, + 0, + DEFAULT_TEMPORAL_SCALE_WITHOUT_TYPE_NAME, + )), + // YYYY-MM-DD HH:MM:SS.mmm = 23 chars (20 + scale 3, see + // DEFAULT_TEMPORAL_SCALE_WITHOUT_TYPE_NAME). TimestampWithTimeZone + // is also 23, not YYYY-MM-DD HH:MM:SS.mmm+HH:MM (29): + // `parse_trino_timestamp_tz` applies the offset and normalises to + // UTC, so it doesn't survive into SQL_TIMESTAMP_STRUCT (which has + // no zone field either); only YYYY-MM-DD HH:MM:SS.mmm is + // delivered. Kept as a distinct match arm (not merged with + // `Timestamp`) to match `trino_ty_precision`, for the same reason + // as `Time`/`TimeWithTimeZone` above. + Self::Timestamp => Some(column_size( + SqlDataType::TIMESTAMP, + 0, + DEFAULT_TEMPORAL_SCALE_WITHOUT_TYPE_NAME, + )), + Self::TimestampWithTimeZone => Some(column_size( + SqlDataType::TIMESTAMP, + 0, + DEFAULT_TEMPORAL_SCALE_WITHOUT_TYPE_NAME, + )), + Self::Varchar + | Self::Char + | Self::Decimal + | Self::Varbinary + | Self::Json + | Self::Uuid + | Self::IntervalDayToSecond + | Self::IntervalYearToMonth => None, + } + } + + /// Whether the type string carries a precision: `varchar(n)`, `char(n)` + /// or `decimal(p,s)`. + pub(crate) fn has_precision_param(&self) -> bool { + matches!(self, Self::Varchar | Self::Char | Self::Decimal) + } + + /// Whether the type string carries a scale, which only `decimal(p,s)` + /// does. + pub(crate) fn has_scale_param(&self) -> bool { + matches!(self, Self::Decimal) + } + + /// Whether this type's single parenthesised type-string argument is a + /// fractional-seconds *scale*, not a precision. + /// + /// `true` for `Time`, `TimeWithTimeZone`, `Timestamp` and + /// `TimestampWithTimeZone`, and a separate question from + /// [`Self::has_precision_param`] and [`Self::has_scale_param`]. + /// `decimal(p,s)`, `varchar(n)` and `char(n)` carry a precision as their + /// first argument, while Trino's temporal types spell a scale as their + /// only one: `timestamp(6)` declares 6 fractional-second digits, not a + /// precision of 6. + /// + /// Reading that argument as `COLUMN_SIZE`/`SQL_DESC_LENGTH` reports + /// `timestamp(6)` at `6` instead of `26`, which is `20 + s` per the ODBC + /// "Column Size" appendix. `type_name_precision` and `type_name_scale` + /// below are where the distinction is applied. + pub(crate) fn has_temporal_scale_param(&self) -> bool { + matches!( + self, + Self::Time | Self::TimeWithTimeZone | Self::Timestamp | Self::TimestampWithTimeZone + ) + } + + /// Every variant of this enum, for a completeness test + /// (`every_reportable_type_has_a_type_info_row` in `backend/info.rs`) + /// that must cover every native type name this driver explicitly + /// recognises, rather than a hand-copied list of declared type strings + /// that can silently omit a variant added later. + /// + /// `assert_all_variants_listed` below is an exhaustive `match` with no + /// wildcard arm, so adding a variant to `TrinoTypeName` without also + /// adding it here fails to compile. + #[cfg(test)] + pub(crate) const ALL_VARIANTS: &'static [TrinoTypeName] = &[ + Self::Boolean, + Self::TinyInt, + Self::SmallInt, + Self::Integer, + Self::BigInt, + Self::Real, + Self::Double, + Self::Decimal, + Self::Varchar, + Self::Char, + Self::Varbinary, + Self::Date, + Self::Time, + Self::TimeWithTimeZone, + Self::Timestamp, + Self::TimestampWithTimeZone, + Self::Json, + Self::Uuid, + Self::IntervalDayToSecond, + Self::IntervalYearToMonth, + ]; + + /// Compile-time proof that [`Self::ALL_VARIANTS`] is exhaustive: this + /// match has no wildcard arm, so it fails to compile the moment a new + /// variant is added to `TrinoTypeName` without being listed here (and, + /// per the doc comment above, in `ALL_VARIANTS`). + #[cfg(test)] + pub(crate) const fn assert_all_variants_listed(v: &Self) { + match v { + Self::Boolean + | Self::TinyInt + | Self::SmallInt + | Self::Integer + | Self::BigInt + | Self::Real + | Self::Double + | Self::Decimal + | Self::Varchar + | Self::Char + | Self::Varbinary + | Self::Date + | Self::Time + | Self::TimeWithTimeZone + | Self::Timestamp + | Self::TimestampWithTimeZone + | Self::Json + | Self::Uuid + | Self::IntervalDayToSecond + | Self::IntervalYearToMonth => {} + } + } +} + +/// Map a Trino column type to an ODBC SQL data type. +pub fn trino_ty_to_sql_type(column_type: &TrinoTy) -> SqlDataType { + match column_type { + TrinoTy::TrinoInt(TrinoInt::I64) => SqlDataType::EXT_BIG_INT, + TrinoTy::TrinoInt(TrinoInt::I32) => SqlDataType::INTEGER, + TrinoTy::TrinoInt(TrinoInt::I16) => SqlDataType::SMALLINT, + TrinoTy::TrinoInt(TrinoInt::I8) => SqlDataType::EXT_TINY_INT, + TrinoTy::TrinoFloat(TrinoFloat::F64) => SqlDataType::DOUBLE, + TrinoTy::TrinoFloat(TrinoFloat::F32) => SqlDataType::REAL, + TrinoTy::Boolean => SqlDataType::EXT_BIT, + // `Char(_)` lands on EXT_W_VARCHAR rather than EXT_W_CHAR, and only + // reaches an application when `TrinoTypeName::parse` could not read the + // column's own signature: every caller tries that first, and it answers + // EXT_W_CHAR for a `char(n)`. Widening is the safe direction for a type + // this driver could not identify. + TrinoTy::Varchar | TrinoTy::Char(_) => SqlDataType::EXT_W_VARCHAR, + TrinoTy::Date => SqlDataType::DATE, + TrinoTy::Time | TrinoTy::TimeWithTimeZone => SqlDataType::TIME, + TrinoTy::Timestamp | TrinoTy::TimestampWithTimeZone => SqlDataType::TIMESTAMP, + TrinoTy::Decimal(_, _) => SqlDataType::DECIMAL, + // String-representable types without a dedicated ODBC type + TrinoTy::Uuid | TrinoTy::Json | TrinoTy::IpAddress => SqlDataType::EXT_W_VARCHAR, + TrinoTy::IntervalYearToMonth | TrinoTy::IntervalDayToSecond => SqlDataType::EXT_W_VARCHAR, + // VARBINARY: Trino sends base64 text over the REST API; `json_to_column_value` + // decodes it to ColumnValue::Bytes. SQL_LONGVARBINARY (-4) is chosen to match + // what `SQLGetTypeInfo` (backend/info.rs) and the catalog path + // (`TrinoTypeName::Varbinary`) already report, so all three agree. + TrinoTy::VarBinary => SqlDataType::EXT_LONG_VAR_BINARY, + // Compound types: ODBC 3.x has no SQL type for them (SQL_ARRAY / SQL_ROW + // exist only in ODBC 4.0, which no Driver Manager implements). The values + // keep their structure as ColumnValue::Array / Map / Row and are rendered + // to text by `column_value_to_string` at write time, using Trino's own + // display form (`[1, 2]`, `{k=v}`, `(a, b)`) rather than JSON. + TrinoTy::Array(_) | TrinoTy::Map(_, _) | TrinoTy::Row(_) | TrinoTy::Tuple(_) => { + tracing::warn!(column_type = ?column_type, "compound Trino type mapped to SQL_WVARCHAR (rendered as text at write time)"); + SqlDataType::EXT_W_VARCHAR + } + TrinoTy::Option(inner) => trino_ty_to_sql_type(inner), + TrinoTy::Unknown => { + tracing::warn!("unknown Trino type mapped to SQL_WVARCHAR"); + SqlDataType::EXT_W_VARCHAR + } + } +} + +/// Narrow a [`column_size`] or [`TrinoTypeName::fixed_precision`] result to +/// the `u32` [`trino_ty_precision`] returns. +/// +/// No SQL type routed through here produces a negative or overflowing value, +/// so the fallback exists to keep the function panic-free rather than to rely +/// on that invariant silently. +fn precision_as_u32(n: i32) -> u32 { + u32::try_from(n).unwrap_or_else(|_| { + tracing::warn!( + value = n, + "column size formula produced a value outside u32 range" + ); + 0 + }) +} + +/// Map a Trino column type to a display precision (number of digits/chars). +pub fn trino_ty_precision(ty: &TrinoTy) -> u32 { + match ty { + TrinoTy::TrinoInt(TrinoInt::I8) => { + precision_as_u32(column_size(SqlDataType::EXT_TINY_INT, 0, 0)) + } + TrinoTy::TrinoInt(TrinoInt::I16) => { + precision_as_u32(column_size(SqlDataType::SMALLINT, 0, 0)) + } + TrinoTy::TrinoInt(TrinoInt::I32) => { + precision_as_u32(column_size(SqlDataType::INTEGER, 0, 0)) + } + TrinoTy::TrinoInt(TrinoInt::I64) => { + precision_as_u32(column_size(SqlDataType::EXT_BIG_INT, 0, 0)) + } + TrinoTy::TrinoFloat(TrinoFloat::F32) => { + precision_as_u32(column_size(SqlDataType::REAL, 0, 0)) + } + TrinoTy::TrinoFloat(TrinoFloat::F64) => { + precision_as_u32(column_size(SqlDataType::DOUBLE, 0, 0)) + } + // Char(n) is a character type: column_size passes precision straight + // through, so this is `n` itself, routed through the shared formula + // rather than cast directly for consistency with every other arm here. + TrinoTy::Char(n) => precision_as_u32(column_size( + SqlDataType::EXT_W_CHAR, + i32::try_from(*n).unwrap_or(i32::MAX), + 0, + )), + TrinoTy::Boolean => precision_as_u32(column_size(SqlDataType::EXT_BIT, 0, 0)), + TrinoTy::Date => precision_as_u32(column_size(SqlDataType::DATE, 0, 0)), + // HH:MM:SS.mmm (scale 3, see DEFAULT_TEMPORAL_SCALE_WITHOUT_TYPE_NAME). + TrinoTy::Time => precision_as_u32(column_size( + SqlDataType::TIME, + 0, + DEFAULT_TEMPORAL_SCALE_WITHOUT_TYPE_NAME, + )), + // Also HH:MM:SS.mmm, not HH:MM:SS.mmm+HH:MM: the offset is applied + // and the value normalised to UTC (see `parse_trino_time_with_tz`), + // so only the fractional seconds reach the application, through the + // text conversions. SQL_TIME_STRUCT has no fraction field of its own. + TrinoTy::TimeWithTimeZone => precision_as_u32(column_size( + SqlDataType::TIME, + 0, + DEFAULT_TEMPORAL_SCALE_WITHOUT_TYPE_NAME, + )), + // YYYY-MM-DD HH:MM:SS.mmm (scale 3, see + // DEFAULT_TEMPORAL_SCALE_WITHOUT_TYPE_NAME). + TrinoTy::Timestamp => precision_as_u32(column_size( + SqlDataType::TIMESTAMP, + 0, + DEFAULT_TEMPORAL_SCALE_WITHOUT_TYPE_NAME, + )), + // Also YYYY-MM-DD HH:MM:SS.mmm, not ...+HH:MM: the offset is applied + // and the value normalised to UTC (see `parse_trino_timestamp_tz`), + // so only YYYY-MM-DD HH:MM:SS.mmm reaches SQL_TIMESTAMP_STRUCT. + TrinoTy::TimestampWithTimeZone => precision_as_u32(column_size( + SqlDataType::TIMESTAMP, + 0, + DEFAULT_TEMPORAL_SCALE_WITHOUT_TYPE_NAME, + )), + TrinoTy::Decimal(p, _) => precision_as_u32(column_size( + SqlDataType::DECIMAL, + i32::try_from(*p).unwrap_or(i32::MAX), + 0, + )), + TrinoTy::Option(inner) => trino_ty_precision(inner), + // Every other type (Varchar, VarBinary, Uuid, Json, the two + // intervals, Array, Map, Row, Tuple, Unknown) is rendered as + // unbounded text by this driver, with no declared length to read. + // `backend/execute.rs` falls back to this function only for a column + // with no `information_schema` type-name string, such as a computed + // `SELECT CAST(x AS VARCHAR)` with no catalog entry. + // + // The ODBC "Column Size" and "Display Size" appendices cover exactly + // this in a footnote: "If the driver cannot determine the column or + // parameter length for a variable type, it returns SQL_NO_TOTAL." + // `PRECISION_UNDETERMINABLE` is core's sentinel for it, which the + // numeric `SQLColAttributeW` and `SQLDescribeCol` outputs recognise + // and substitute `SQL_NO_TOTAL` for (see `resolve_precision_isize` + // and `resolve_precision_ulen`). + // + // The two wrong answers here are 0 and `i32::MAX`. 0 under-reports a + // column that can hold arbitrarily long text and truncates + // `SQLGetData(SQL_C_WCHAR)` reads, which + // `metadata_sized_wchar_round_trip_covers_representative_types` in + // `ffi_integration_tests.rs` pins. 2147483647 is not an allocatable + // buffer size, and it carries a different meaning already: + // `SQLGetTypeInfo`'s VARCHAR, VARBINARY, JSON and INTERVAL rows + // (`backend/info.rs`) report that literal number for "unbounded but + // known", which must not be reinterpreted as "undeterminable". + _ => PRECISION_UNDETERMINABLE, + } +} + +/// Return the decimal scale for a Trino column type (0 for non-decimal types). +pub fn trino_ty_scale(ty: &TrinoTy) -> i16 { + match ty { + TrinoTy::Decimal(_, s) => *s as i16, + TrinoTy::Option(inner) => trino_ty_scale(inner), + _ => 0, + } +} + +/// Remove a parenthesised precision/scale argument from the middle of a type +/// string, preserving whatever comes before and after it, and lowercasing +/// the result for case-insensitive matching. +/// +/// `"timestamp(3) with time zone"` → `"timestamp with time zone"` +/// `"varchar(255)"` → `"varchar"` +/// `"timestamp with time zone"` (no argument) → `"timestamp with time zone"` +/// +/// Not a truncation at the first `(`: Trino's time-zone-aware types carry the +/// argument *before* a suffix (`" with time zone"`), so truncating there would +/// discard the suffix and let the caller misidentify the type. +fn strip_precision_param(name: &str) -> String { + let lower = name.trim().to_lowercase(); + let Some(start) = lower.find('(') else { + return lower; + }; + let Some(end) = lower[start..].find(')') else { + return lower; + }; + let end = start + end; + + let prefix = lower[..start].trim_end(); + let suffix = lower[end + 1..].trim(); + + if suffix.is_empty() { + prefix.to_string() + } else { + format!("{prefix} {suffix}") + } +} + +/// Extract the first numeric parameter from a type string like `"varchar(100)"` or `"decimal(10,2)"`. +fn parse_precision_param(name: &str) -> Option<i32> { + let start = name.find('(')?; + let end = name.rfind(')')?; + name[start + 1..end].split(',').next()?.trim().parse().ok() +} + +/// Extract the second numeric parameter from a type string like `"decimal(10,2)"`. +fn parse_scale_param(name: &str) -> Option<i32> { + let start = name.find('(')?; + let end = name.rfind(')')?; + let mut parts = name[start + 1..end].split(','); + parts.next()?; + parts.next()?.trim().parse().ok() +} + +/// Parse the fractional-seconds *scale* from a temporal type-name string's +/// sole parenthesised argument (`"timestamp(6)"` -> 6, +/// `"time(6) with time zone"` -> 6), defaulting to +/// [`DEFAULT_TEMPORAL_SCALE_WITHOUT_TYPE_NAME`] when there is no argument at +/// all (a bare `"timestamp"`) or it does not fit `i16`. +/// +/// It reuses [`parse_precision_param`]'s parenthesis extraction, since the +/// argument sits in the same textual position, and keeps its own name because +/// the quantity is different: for `Time`, `TimeWithTimeZone`, `Timestamp` and +/// `TimestampWithTimeZone` that argument is a *scale*, never a precision. See +/// [`TrinoTypeName::has_temporal_scale_param`] for what conflating the two +/// reports. +fn temporal_scale_param(name: &str) -> i16 { + parse_precision_param(name) + .and_then(|s| i16::try_from(s).ok()) + .unwrap_or(DEFAULT_TEMPORAL_SCALE_WITHOUT_TYPE_NAME) +} + +/// Extract ODBC column precision (`COLUMN_SIZE`/`SQL_DESC_LENGTH`) from a +/// Trino type name string (as returned by `information_schema.columns.data_type`). +/// +/// For parametric types (`varchar(n)`, `char(n)`, `decimal(p,s)`), the value is +/// read from the type string. For fixed-size types, the canonical precision is +/// returned directly. Returns `None` for types with no meaningful precision. +/// +/// For `Time`/`TimeWithTimeZone`/`Timestamp`/`TimestampWithTimeZone`, the +/// declared fractional-seconds scale is parsed from the type string (falling +/// back to [`DEFAULT_TEMPORAL_SCALE_WITHOUT_TYPE_NAME`] only when no +/// parenthesised argument is present) and fed through [`column_size`], the +/// ODBC "Column Size" appendix's character-length formula (`9 + s` for TIME, +/// `20 + s` for TIMESTAMP), rather than returned as-is. The parenthesised +/// argument for these types *is* the scale, not a precision (see +/// [`TrinoTypeName::has_temporal_scale_param`]); do not return it directly +/// here, as that collapses `SQL_DESC_LENGTH`/`COLUMN_SIZE` +/// for `timestamp(6)` to `6` instead of the correct `26`. +pub fn type_name_precision(name: &str) -> Option<i32> { + let ty = TrinoTypeName::parse(name)?; + if ty.has_precision_param() { + parse_precision_param(name) + } else if ty.has_temporal_scale_param() { + let scale = temporal_scale_param(name); + Some(column_size(ty.sql_type(), 0, scale)) + } else { + ty.fixed_precision() + } +} + +/// Extract ODBC decimal digits (`SQL_DESC_PRECISION` for datetime types, +/// `SQL_DESC_SCALE` for `DECIMAL`/`NUMERIC`) from a Trino type name string. +/// +/// Returns `Some(scale)` for `decimal`/`numeric` types with an explicit scale +/// parameter, and for `Time`/`TimeWithTimeZone`/`Timestamp`/ +/// `TimestampWithTimeZone` the declared fractional-seconds scale parsed from +/// the type string (see [`type_name_precision`]'s doc comment for why this is +/// a distinct quantity from that function's `COLUMN_SIZE`/`SQL_DESC_LENGTH` +/// result, even though both are ultimately derived from the same +/// parenthesised argument). `None` for every other type. +pub fn type_name_scale(name: &str) -> Option<i32> { + let ty = TrinoTypeName::parse(name)?; + if ty.has_scale_param() { + parse_scale_param(name) + } else if ty.has_temporal_scale_param() { + Some(i32::from(temporal_scale_param(name))) + } else { + None + } +} + +/// Map a Trino type name string (from `information_schema.columns.data_type`) +/// to an ODBC `SqlDataType`. Strips parametric suffixes like `varchar(100)` or +/// `timestamp(3)` before matching. Unknown types fall back to `EXT_W_VARCHAR`. +pub fn trino_type_name_to_sql_type(name: &str) -> SqlDataType { + TrinoTypeName::parse(name) + .map(|ty| ty.sql_type()) + .unwrap_or(SqlDataType::EXT_W_VARCHAR) +} + +/// Parse a Trino date string `"YYYY-MM-DD"` into a [`ColumnValue::Date`]. +/// +/// Returns `None` if the string is malformed. +fn parse_trino_date(s: &str) -> Option<ColumnValue> { + let (year, month, day) = parse_ymd(s)?; + Some(ColumnValue::Date { year, month, day }) +} + +/// Split a Trino `YYYY-MM-DD` date into its three fields. +/// +/// Shared by [`parse_trino_date`] and [`parse_trino_timestamp`], which have to +/// agree: both read back values this driver itself emitted, through the same +/// `year4` renderer in `backend::params`, so a year one accepts and the other +/// rejects is a round trip that works for `DATE` and silently degrades a +/// `TIMESTAMP` to text. +/// +/// Trino renders a year before 1 CE with a leading `-`, which is the same +/// character that separates the fields, so the sign is taken off before the +/// split rather than left for `splitn` to read as an empty year. +/// +/// A year that does not fit `SQL_DATE_STRUCT`'s signed 16-bit field (Trino goes +/// to 5881580, and renders those with a leading `+`) returns `None`, and the +/// caller keeps the value as text: truncating it would report a different year +/// as though it were the real one. +fn parse_ymd(s: &str) -> Option<(i16, u16, u16)> { + let (negative, rest) = match s.strip_prefix('-') { + Some(rest) => (true, rest), + None => (false, s), + }; + let mut parts = rest.splitn(3, '-'); + let magnitude: i16 = parts.next()?.parse().ok()?; + // `checked_neg`, because `i16::MIN` has no positive counterpart and a bare + // `-` on it would wrap back to itself. + let year = if negative { + magnitude.checked_neg()? + } else { + magnitude + }; + let month: u16 = parts.next()?.parse().ok()?; + let day: u16 = parts.next()?.parse().ok()?; + Some((year, month, day)) +} + +/// Convert Trino's decimal fractional-seconds text (up to 12 digits, i.e. +/// picoseconds) into nanoseconds, the unit `ColumnValue::Time`/`Timestamp` +/// both use. Left-aligned zero-padding/truncation to 9 digits. The sole +/// helper for this: `parse_trino_time`/`parse_trino_time_with_tz`/ +/// `parse_trino_timestamp` all call it rather than each repeating the same +/// padding logic. +fn parse_fraction_nanos(frac: &str) -> u32 { + let padded = format!("{frac:0<9}"); + // `padded.get(..9)` rather than `padded[..9]`: `frac` is expected to be + // ASCII digits from Trino's own wire format, but if malformed input ever + // contains a multi-byte character, byte index 9 may not land on a UTF-8 + // char boundary, and indexing (unlike `get`) panics rather than + // returning `None`: the workspace denies `panic` outside tests, so + // this must not be able to. Falling back to 0 matches what an + // unparseable numeric fragment already does below. + padded.get(..9).and_then(|s| s.parse().ok()).unwrap_or(0) +} + +/// Whether converting `val` under `ty` discards fractional-seconds digits. +/// +/// Trino's temporal types reach twelve fractional digits and the client +/// advertises `PARAMETRIC_DATETIME`, so a `timestamp(12)` column arrives on the +/// wire with all twelve, while `ColumnValue::Timestamp::fraction` counts +/// nanoseconds and [`parse_fraction_nanos`] keeps nine. The three that fall off +/// are lost inside this driver's own conversion, before a `ColumnValue` exists, +/// which is precisely the loss core cannot see and +/// `StatementBackend::take_value_warning` exists to report as `01S07`. +/// +/// Asked of the wire text rather than of the column's declared scale, so that a +/// `timestamp(12)` whose value happens to end in zeros does not draw a +/// diagnostic for precision nothing actually dropped. +pub(crate) fn discards_fractional_seconds(val: &Value, ty: &TrinoTy) -> bool { + match ty { + TrinoTy::Time + | TrinoTy::TimeWithTimeZone + | TrinoTy::Timestamp + | TrinoTy::TimestampWithTimeZone => { + matches!(val, Value::String(s) if fraction_exceeds_nanoseconds(s)) + } + // The composite types are walked because their elements go through the + // same parsers: a `ROW(t timestamp(12))` truncates exactly as a bare + // `timestamp(12)` column does, and the warning belongs to the column the + // application reads. + TrinoTy::Option(inner) => discards_fractional_seconds(val, inner), + TrinoTy::Array(inner) => matches!(val, Value::Array(items) + if items.iter().any(|v| discards_fractional_seconds(v, inner))), + TrinoTy::Map(key_ty, val_ty) => matches!(val, Value::Object(map) + if map.iter().any(|(k, v)| { + discards_fractional_seconds(&Value::String(k.clone()), key_ty) + || discards_fractional_seconds(v, val_ty) + })), + TrinoTy::Row(fields) => matches!(val, Value::Array(items) + if items.iter().zip(fields.iter()) + .any(|(v, (_name, ty))| discards_fractional_seconds(v, ty))), + TrinoTy::Tuple(fields) => matches!(val, Value::Array(items) + if items.iter().zip(fields.iter()) + .any(|(v, ty)| discards_fractional_seconds(v, ty))), + _ => false, + } +} + +/// Whether a Trino temporal literal's fractional-seconds fragment carries a +/// non-zero digit past the ninth, the last one nanoseconds can hold. +/// +/// The fragment is the digit run after the first `.`, which locates it in all +/// four renderings: a bare time or timestamp ends there, a named zone follows a +/// space, and a numeric offset is punctuated with `:` rather than `.`. +fn fraction_exceeds_nanoseconds(text: &str) -> bool { + let Some((_, rest)) = text.split_once('.') else { + return false; + }; + rest.chars() + .take_while(char::is_ascii_digit) + .skip(9) + .any(|c| c != '0') +} + +/// Parse a Trino time string `"HH:MM:SS[.fraction][ TZ]"` into a [`ColumnValue::Time`]. +/// +/// The timezone suffix is discarded (a bare `TIME` has no offset semantics +/// beyond what `parse_trino_time_with_tz` applies). The fractional-seconds +/// part is converted to nanoseconds and kept: `SQL_TIME_STRUCT` cannot carry +/// it, but the string rendering used for `SQL_C_CHAR`/`SQL_C_WCHAR` targets +/// can, so dropping it here would lose it before the driver even knows which +/// C type the caller wants. +/// Returns `None` if the string is malformed. +fn parse_trino_time(s: &str) -> Option<ColumnValue> { + // Strip optional timezone suffix: "13:14:15.123 UTC" → "13:14:15.123" + let s = s.split_whitespace().next()?; + let mut parts = s.splitn(3, ':'); + let hour: u16 = parts.next()?.parse().ok()?; + let minute: u16 = parts.next()?.parse().ok()?; + let sec_part = parts.next()?; + let mut sf = sec_part.splitn(2, '.'); + let second: u16 = sf.next()?.parse().ok()?; + let fraction = sf.next().map(parse_fraction_nanos).unwrap_or(0); + Some(ColumnValue::Time { + hour, + minute, + second, + fraction, + }) +} + +/// Parse `TIME WITH TIME ZONE` and normalise to UTC. +/// +/// `SQL_TIME_STRUCT` has no timezone field, so the offset cannot be carried. +/// Applying it and returning UTC matches what `TIMESTAMP WITH TIME ZONE` +/// already does; do not discard the offset silently, or the two "with time +/// zone" types behave inconsistently. +/// +/// Trino renders the zone two ways: a space-separated name (`"13:14:15.000 +/// UTC"`) or a glued numeric offset (`"13:14:15+02:00"`, `"13:14:15-05:30"`). +/// Both are handled. A named zone other than UTC is date-dependent (DST) and +/// a bare TIME has no date to resolve it against, so it is treated as UTC +/// (offset 0) with a `tracing::warn!` rather than silently guessing; this +/// only affects the rare case of a non-UTC named zone, since Trino's own +/// numeric-offset rendering is the common form. +/// Returns `None` if the string is malformed. +fn parse_trino_time_with_tz(s: &str) -> Option<ColumnValue> { + let t = s.trim(); + + // A space-separated named zone, e.g. "13:14:15.000 UTC". + if let Some((time_part, zone)) = t.rsplit_once(' ') { + let offset_minutes = if zone.eq_ignore_ascii_case("UTC") { + 0 + } else { + // A named zone's offset is date-dependent and a TIME has no date. + // Treat it as UTC and say so rather than silently guessing. + tracing::warn!( + zone = %zone, + "TIME WITH TIME ZONE carries a named zone; offset cannot be \ + resolved without a date, treating as UTC" + ); + 0 + }; + return shift_time(time_part, offset_minutes); + } + + // A glued numeric offset, e.g. "13:14:15+02:00" or "13:14:15-05:30". + // The time-of-day portion (HH:MM:SS[.f]) is only ever digits, colons and + // a dot, never '+' or '-', so the *rightmost* '+'/'-' in + // the string is unambiguously the offset sign. + let sign_pos = t.rfind(['+', '-'])?; + let (time_part, offset_part) = t.split_at(sign_pos); + let sign = if offset_part.starts_with('-') { -1 } else { 1 }; + let offset_body = &offset_part[1..]; + let mut op = offset_body.splitn(2, ':'); + let oh: i32 = op.next()?.parse().ok()?; + let om: i32 = op.next().unwrap_or("0").parse().ok()?; + shift_time(time_part, sign * (oh * 60 + om)) +} + +/// Shift `HH:MM:SS[.f]` by `offset_minutes`, wrapping within the day. +/// +/// A `TIME` has no date to carry an overflow into, so the result wraps +/// within `[0, 24h)`. `rem_euclid` (not `%`) is used because a negative +/// `total` (the offset exceeds the time-of-day, e.g. `"01:00:00+02:00"`) +/// must wrap forward to the previous day's minutes-past-midnight, not +/// produce a negative remainder. +/// +/// The offset is always a whole number of minutes, so it cannot shift the +/// fractional-seconds part: that is carried through unchanged, the same way +/// `parse_trino_time` keeps it. +fn shift_time(time_part: &str, offset_minutes: i32) -> Option<ColumnValue> { + let mut parts = time_part.trim().splitn(3, ':'); + let hour: i32 = parts.next()?.parse().ok()?; + let minute: i32 = parts.next()?.parse().ok()?; + let sec_part = parts.next()?; + let mut sf = sec_part.splitn(2, '.'); + let second: i32 = sf.next()?.parse().ok()?; + let fraction = sf.next().map(parse_fraction_nanos).unwrap_or(0); + + let total = hour * 60 + minute - offset_minutes; + let wrapped = total.rem_euclid(24 * 60); + + Some(ColumnValue::Time { + hour: u16::try_from(wrapped / 60).ok()?, + minute: u16::try_from(wrapped % 60).ok()?, + second: u16::try_from(second).ok()?, + fraction, + }) +} + +/// Decode Trino's base64 VARBINARY payload into [`ColumnValue::Bytes`]. +/// +/// Returns `None` if the string is not valid standard-alphabet base64. Callers +/// do not log the raw payload, since binary column contents may be sensitive. +fn base64_decode(s: &str) -> Option<ColumnValue> { + base64::Engine::decode(&base64::engine::general_purpose::STANDARD, s) + .ok() + .map(ColumnValue::Bytes) +} + +/// Parse a Trino timestamp string `"YYYY-MM-DD HH:MM:SS[.fraction][ TZ]"` into a +/// [`ColumnValue::Timestamp`]. +/// +/// The fractional-seconds part is converted to nanoseconds stored in +/// `fraction` via [`parse_fraction_nanos`]. The optional timezone suffix is +/// discarded (Trino normalises timestamps to UTC before serialising them +/// when the column type includes a time zone). +/// +/// Returns `None` if the string is malformed. +fn parse_trino_timestamp(s: &str) -> Option<ColumnValue> { + // Split on the first space to separate date from time+tz. + let (date_str, rest) = s.split_once(' ')?; + // Strip optional timezone: take only the first token. + let time_str = rest.split_whitespace().next()?; + + // Through `parse_ymd` rather than a local `splitn`, so a year before 1 CE + // reads here exactly as it does in `parse_trino_date`. `backend::params` + // renders a bound `SQL_TIMESTAMP_STRUCT` with the same `year4` it uses for + // a `SQL_DATE_STRUCT`, so a bare split left `-0001-01-01 00:00:00` with an + // empty first field and degraded the whole value to text. + let (year, month, day) = parse_ymd(date_str)?; + + let mut tp = time_str.splitn(3, ':'); + let hour: u16 = tp.next()?.parse().ok()?; + let minute: u16 = tp.next()?.parse().ok()?; + let sec_frac = tp.next()?; + let mut sf = sec_frac.splitn(2, '.'); + let second: u16 = sf.next()?.parse().ok()?; + let fraction = sf.next().map(parse_fraction_nanos).unwrap_or(0); + + Some(ColumnValue::Timestamp { + year, + month, + day, + hour, + minute, + second, + fraction, + }) +} + +/// Parse a Trino INTERVAL YEAR TO MONTH string "Y-M" into years and months. +/// +/// The sign prefixes the whole interval, not just the year component: Trino +/// serialises a negative interval as `"-Y-M"`. Parsing the leading `-` once +/// (rather than relying on `i32::parse` to see it on the first token) and +/// applying it to both fields keeps the two in agreement: a split +/// representation must not let one field be negative while the other is +/// positive. +fn parse_interval_year_month(s: &str) -> Option<ColumnValue> { + let t = s.trim(); + let (negative, body) = match t.strip_prefix('-') { + Some(rest) => (true, rest), + None => (false, t), + }; + + let mut parts = body.splitn(2, '-'); + let years: i32 = parts.next()?.trim().parse().ok()?; + let months: i32 = parts.next()?.trim().parse().ok()?; + + let (years, months) = if negative { + (years.checked_neg()?, months.checked_neg()?) + } else { + (years, months) + }; + + Some(ColumnValue::IntervalYearMonth { + years, + months, + // Trino has one year-month interval type and it carries both fields, so + // the precision is always the two-field form. The narrower + // `Interval::Year` and `Interval::Month` have no Trino column type to + // come from. + precision: Interval::YearToMonth, + }) +} + +/// Parse Trino's `INTERVAL DAY TO SECOND` text, e.g. `"-2 03:04:05.678"`. +/// +/// The sign prefixes the whole interval, not just the day component. +fn parse_interval_day_time(s: &str) -> Option<ColumnValue> { + let t = s.trim(); + let (negative, body) = match t.strip_prefix('-') { + Some(rest) => (true, rest), + None => (false, t), + }; + + let mut outer = body.splitn(2, ' '); + let days: i64 = outer.next()?.trim().parse().ok()?; + let time_part = outer.next()?.trim(); + + let mut tp = time_part.splitn(3, ':'); + let h: i64 = tp.next()?.parse().ok()?; + let m: i64 = tp.next()?.parse().ok()?; + let sec_part = tp.next()?; + let (sec_text, frac_text) = match sec_part.split_once('.') { + Some((sec, frac)) => (sec, frac), + None => (sec_part, ""), + }; + let sec: i64 = sec_text.parse().ok()?; + // `ColumnValue::IntervalDayTime` counts nanoseconds, so the fraction is kept + // whole. Trino renders this type with three fractional digits, its own + // storage being a millisecond count, but `parse_fraction_nanos` reads + // whatever arrives on the same "pad right, then take nine" rule the temporal + // parsers use, so a shorter fragment like "5" is read as 500ms rather than + // 5ns and a longer one does not have to be special-cased here. + let frac_nanos = i128::from(parse_fraction_nanos(frac_text)); + + let magnitude = i128::from(days) + .checked_mul(NANOS_PER_DAY)? + .checked_add(i128::from(h) * NANOS_PER_HOUR)? + .checked_add(i128::from(m) * NANOS_PER_MINUTE)? + .checked_add(i128::from(sec) * NANOS_PER_SECOND)? + .checked_add(frac_nanos)?; + + Some(ColumnValue::IntervalDayTime { + total_nanoseconds: if negative { -magnitude } else { magnitude }, + // Trino has one day-time interval type and it spans all four fields, so + // the precision is always the widest form. + precision: Interval::DayToSecond, + }) +} + +/// Parse a Trino TIMESTAMP WITH TIME ZONE string and convert to UTC. +/// +/// Trino REST API format: `"YYYY-MM-DD HH:MM:SS.fraction TIMEZONE"` where +/// TIMEZONE is either a numeric offset (`+05:30`, `-08:00`) or a named IANA +/// zone (`UTC`, `America/New_York`, `Europe/Berlin`). +/// +/// Returns `ColumnValue::Timestamp` with UTC-converted fields, matching the +/// official Trino ODBC driver's behaviour. The timezone information is consumed +/// during conversion: `SQL_TIMESTAMP_STRUCT` has no timezone field. +fn parse_trino_timestamp_tz(s: &str) -> Option<ColumnValue> { + let last_space = s.rfind(' ')?; + let datetime_part = &s[..last_space]; + let tz_part = s[last_space + 1..].trim(); + + let base = parse_trino_timestamp(datetime_part)?; + let (year, month, day, hour, minute, second, fraction) = match base { + ColumnValue::Timestamp { + year, + month, + day, + hour, + minute, + second, + fraction, + } => (year, month, day, hour, minute, second, fraction), + _ => return None, + }; + + use chrono::{NaiveDate, NaiveDateTime, NaiveTime, TimeZone as _}; + use chrono_tz::Tz; + + let ndt = NaiveDate::from_ymd_opt(year.into(), month.into(), day.into()).and_then(|d| { + NaiveTime::from_hms_nano_opt(hour.into(), minute.into(), second.into(), fraction) + .map(|t| NaiveDateTime::new(d, t)) + })?; + + // Numeric offsets (+05:30, -08:00): subtract the offset directly to get + // UTC. Named zones (UTC, America/New_York, CET): resolve via chrono-tz + // which handles DST rules: the same named zone maps to different UTC + // offsets depending on the date. + let utc_ndt = if let Some(rest) = tz_part.strip_prefix('+') { + ndt - parse_numeric_offset(1, rest)? + } else if let Some(rest) = tz_part.strip_prefix('-') { + ndt - parse_numeric_offset(-1, rest)? + } else { + let tz: Tz = tz_part.parse().ok().or_else(|| { + tracing::warn!( + "parse_trino_timestamp_tz: unrecognised timezone {:?}", + tz_part + ); + None + })?; + // earliest() picks the first valid mapping when a local time is + // ambiguous (DST fall-back). Returns None for gap times (spring-forward). + tz.from_local_datetime(&ndt) + .earliest() + .or_else(|| { + tracing::warn!( + "parse_trino_timestamp_tz: ambiguous or invalid local time {:?} in zone {:?}", + ndt, + tz_part + ); + None + })? + .with_timezone(&chrono::Utc) + .naive_utc() + }; + + // Fraction (sub-second nanoseconds) is preserved as-is: UTC conversion + // only shifts hours/minutes/seconds, never sub-second precision. + Some(ColumnValue::Timestamp { + year: i16::try_from(utc_ndt.date().year()).ok()?, + month: utc_ndt.date().month() as u16, + day: utc_ndt.date().day() as u16, + hour: utc_ndt.time().hour() as u16, + minute: utc_ndt.time().minute() as u16, + second: utc_ndt.time().second() as u16, + fraction, + }) +} + +/// Parse a numeric timezone offset string `"HH:MM"` or `"HH"` into a +/// `chrono::TimeDelta`, applying the given sign (1 or -1). +fn parse_numeric_offset(sign: i32, hhmm: &str) -> Option<chrono::TimeDelta> { + let mut parts = hhmm.splitn(2, ':'); + let h: i64 = parts.next()?.parse().ok()?; + let m: i64 = parts.next().and_then(|s| s.parse().ok()).unwrap_or(0); + let total_seconds = i64::from(sign) * (h * 3600 + m * 60); + chrono::TimeDelta::try_seconds(total_seconds) +} + +/// The `f64` Trino means by a string-valued float, or `None` for any other +/// string. +/// +/// JSON has no literal for the IEEE specials, so Trino sends them as strings: +/// a `DOUBLE` or `REAL` column carrying one arrives as `"NaN"`, `"Infinity"` or +/// `"-Infinity"` rather than as a number. Read off the wire, not from the +/// documentation. See `ieee_specials_are_read_as_floats_not_strings`. +/// +/// The spellings are matched exactly rather than case-insensitively: these are +/// the three Trino emits, and accepting looser forms would mean silently +/// turning some other data source's text into a number. +fn trino_special_float(s: &str) -> Option<f64> { + match s { + "NaN" => Some(f64::NAN), + "Infinity" => Some(f64::INFINITY), + "-Infinity" => Some(f64::NEG_INFINITY), + _ => None, + } +} + +/// The text of a JSON value, without re-encoding a string as JSON. +/// +/// `Value::to_string()` renders `Value::String("abc")` as `"\"abc\""`, quote +/// characters and all. Every fallback arm in [`json_to_column_value`] hands its +/// result to the application as data, so a value that failed to convert must +/// arrive as the text Trino sent, not as its JSON encoding. +fn json_as_text(val: &Value) -> String { + match val { + Value::String(s) => s.clone(), + other => other.to_string(), + } +} + +/// Convert a JSON value from Trino to an ODBC ColumnValue, guided by the column type. +pub fn json_to_column_value(val: Value, ty: &TrinoTy) -> ColumnValue { + if val.is_null() { + return ColumnValue::Null; + } + match ty { + TrinoTy::TrinoInt(TrinoInt::I64) => val + .as_i64() + .or_else(|| val.as_str().and_then(|s| s.parse().ok())) + .map(ColumnValue::I64) + .unwrap_or_else(|| ColumnValue::String(json_as_text(&val))), + TrinoTy::TrinoInt(TrinoInt::I32) => match val.as_i64() { + Some(n) => i32::try_from(n).map(ColumnValue::I32).unwrap_or_else(|_| { + tracing::warn!( + value = %val, + declared_type = ?ty, + "value does not fit the declared INTEGER column; returning it as text" + ); + ColumnValue::String(json_as_text(&val)) + }), + None => ColumnValue::String(json_as_text(&val)), + }, + TrinoTy::TrinoInt(TrinoInt::I16) => match val.as_i64() { + Some(n) => i16::try_from(n).map(ColumnValue::I16).unwrap_or_else(|_| { + tracing::warn!( + value = %val, + declared_type = ?ty, + "value does not fit the declared SMALLINT column; returning it as text" + ); + ColumnValue::String(json_as_text(&val)) + }), + None => ColumnValue::String(json_as_text(&val)), + }, + TrinoTy::TrinoInt(TrinoInt::I8) => match val.as_i64() { + Some(n) => i8::try_from(n).map(ColumnValue::I8).unwrap_or_else(|_| { + tracing::warn!( + value = %val, + declared_type = ?ty, + "value does not fit the declared TINYINT column; returning it as text" + ); + ColumnValue::String(json_as_text(&val)) + }), + None => ColumnValue::String(json_as_text(&val)), + }, + TrinoTy::TrinoFloat(TrinoFloat::F64) => val + .as_f64() + .or_else(|| val.as_str().and_then(trino_special_float)) + .map(ColumnValue::F64) + .unwrap_or_else(|| ColumnValue::String(json_as_text(&val))), + TrinoTy::TrinoFloat(TrinoFloat::F32) => val + .as_f64() + .or_else(|| val.as_str().and_then(trino_special_float)) + .map(|f| { + let n = f as f32; + if n.is_finite() || !f.is_finite() { + ColumnValue::F32(n) + } else { + tracing::warn!( + value = %f, + declared_type = ?ty, + "value overflows the declared REAL column; returning it as text" + ); + ColumnValue::String(json_as_text(&val)) + } + }) + .unwrap_or_else(|| ColumnValue::String(json_as_text(&val))), + TrinoTy::Boolean => val + .as_bool() + .map(ColumnValue::Bool) + .unwrap_or_else(|| ColumnValue::String(json_as_text(&val))), + // Date / time types: Trino serialises these as ISO-8601 strings. + TrinoTy::Date => { + if let Value::String(ref s) = val { + parse_trino_date(s).unwrap_or_else(|| { + tracing::warn!(raw = s, "failed to parse Trino DATE string"); + ColumnValue::String(s.clone()) + }) + } else { + ColumnValue::String(val.to_string()) + } + } + TrinoTy::Time => { + if let Value::String(ref s) = val { + parse_trino_time(s).unwrap_or_else(|| { + tracing::warn!(raw = s, "failed to parse Trino TIME string"); + ColumnValue::String(s.clone()) + }) + } else { + ColumnValue::String(val.to_string()) + } + } + // TIME WITH TIME ZONE: parse and normalise to UTC, mirroring + // TIMESTAMP WITH TIME ZONE. See `parse_trino_time_with_tz` for why + // this must not share `parse_trino_time`, which silently discards + // the offset instead of applying it. + TrinoTy::TimeWithTimeZone => { + if let Value::String(ref s) = val { + parse_trino_time_with_tz(s).unwrap_or_else(|| { + tracing::warn!(raw = s, "failed to parse Trino TIME WITH TIME ZONE string"); + ColumnValue::String(s.clone()) + }) + } else { + ColumnValue::String(val.to_string()) + } + } + // TIMESTAMP (no TZ): parse date/time fields directly, no UTC conversion. + TrinoTy::Timestamp => { + if let Value::String(ref s) = val { + parse_trino_timestamp(s).unwrap_or_else(|| { + tracing::warn!(raw = s, "failed to parse Trino TIMESTAMP string"); + ColumnValue::String(s.clone()) + }) + } else { + ColumnValue::String(val.to_string()) + } + } + // TIMESTAMP WITH TIME ZONE: parse and convert to UTC via chrono-tz. + // Trino sends named zones (UTC, America/New_York, CET) or numeric + // offsets (+05:30, -08:00); the column type in the REST API metadata + // determines which parser is called, not the string content. + TrinoTy::TimestampWithTimeZone => { + if let Value::String(ref s) = val { + parse_trino_timestamp_tz(s).unwrap_or_else(|| { + tracing::warn!( + raw = s, + "failed to parse Trino TIMESTAMP WITH TIME ZONE string" + ); + ColumnValue::String(s.clone()) + }) + } else { + ColumnValue::String(val.to_string()) + } + } + TrinoTy::Decimal(_, _) => match val { + Value::String(s) => ColumnValue::Decimal(s), + other => ColumnValue::Decimal(other.to_string()), + }, + TrinoTy::Json => match val { + Value::String(s) => ColumnValue::Json(s), + other => ColumnValue::Json(other.to_string()), + }, + TrinoTy::IntervalYearToMonth => { + if let Value::String(ref s) = val { + parse_interval_year_month(s).unwrap_or_else(|| { + tracing::warn!(raw = s, "failed to parse Trino INTERVAL YEAR TO MONTH"); + ColumnValue::String(s.clone()) + }) + } else { + ColumnValue::String(val.to_string()) + } + } + TrinoTy::IntervalDayToSecond => { + if let Value::String(ref s) = val { + parse_interval_day_time(s).unwrap_or_else(|| { + tracing::warn!(raw = s, "failed to parse Trino INTERVAL DAY TO SECOND"); + ColumnValue::String(s.clone()) + }) + } else { + ColumnValue::String(val.to_string()) + } + } + // VARBINARY arrives as a base64-encoded string in the REST API payload. + TrinoTy::VarBinary => { + if let Value::String(ref s) = val { + base64_decode(s).unwrap_or_else(|| { + tracing::warn!("failed to base64-decode Trino VARBINARY"); + ColumnValue::String(s.clone()) + }) + } else { + ColumnValue::String(val.to_string()) + } + } + TrinoTy::Array(inner_ty) => { + if let Value::Array(items) = val { + let vals = items + .into_iter() + .map(|v| json_to_column_value(v, inner_ty)) + .collect(); + ColumnValue::Array(vals) + } else { + ColumnValue::String(val.to_string()) + } + } + TrinoTy::Map(key_ty, val_ty) => { + if let Value::Object(map) = val { + let pairs = map + .into_iter() + .map(|(k, v)| { + let key_col = json_to_column_value(Value::String(k), key_ty); + let val_col = json_to_column_value(v, val_ty); + (key_col, val_col) + }) + .collect(); + ColumnValue::Map(pairs) + } else { + ColumnValue::String(val.to_string()) + } + } + TrinoTy::Row(fields) => { + if let Value::Array(items) = val { + let vals = items + .into_iter() + .zip(fields.iter()) + .map(|(v, (_name, ty))| json_to_column_value(v, ty)) + .collect(); + ColumnValue::Row(vals) + } else { + ColumnValue::String(val.to_string()) + } + } + TrinoTy::Tuple(fields) => { + if let Value::Array(items) = val { + let vals = items + .into_iter() + .zip(fields.iter()) + .map(|(v, ty)| json_to_column_value(v, ty)) + .collect(); + ColumnValue::Row(vals) + } else { + ColumnValue::String(val.to_string()) + } + } + // Nullable wrapper: delegate to the inner type (null already handled above) + TrinoTy::Option(inner) => json_to_column_value(val, inner), + _ => match val { + Value::String(s) => ColumnValue::String(s), + other => ColumnValue::String(other.to_string()), + }, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn bigint_maps_to_ext_big_int() { + assert_eq!( + trino_ty_to_sql_type(&TrinoTy::TrinoInt(TrinoInt::I64)), + SqlDataType::EXT_BIG_INT + ); + } + + #[test] + fn integer_maps_to_integer() { + assert_eq!( + trino_ty_to_sql_type(&TrinoTy::TrinoInt(TrinoInt::I32)), + SqlDataType::INTEGER + ); + } + + #[test] + fn varchar_maps_to_ext_w_varchar() { + assert_eq!( + trino_ty_to_sql_type(&TrinoTy::Varchar), + SqlDataType::EXT_W_VARCHAR + ); + } + + #[test] + fn varbinary_maps_to_ext_long_var_binary() { + assert_eq!( + trino_ty_to_sql_type(&TrinoTy::VarBinary), + SqlDataType::EXT_LONG_VAR_BINARY + ); + } + + #[test] + fn varbinary_decodes_base64_to_bytes() { + // base64("\xDE\xAD\xBE\xEF") == "3q2+7w==" + let val = Value::String("3q2+7w==".to_string()); + assert_eq!( + json_to_column_value(val, &TrinoTy::VarBinary), + ColumnValue::Bytes(vec![0xDE, 0xAD, 0xBE, 0xEF]) + ); + } + + #[test] + fn varbinary_empty_decodes_to_empty_bytes() { + let val = Value::String(String::new()); + assert_eq!( + json_to_column_value(val, &TrinoTy::VarBinary), + ColumnValue::Bytes(Vec::new()) + ); + } + + #[test] + fn varbinary_invalid_base64_falls_back_to_string() { + let val = Value::String("not!valid!base64".to_string()); + assert_eq!( + json_to_column_value(val, &TrinoTy::VarBinary), + ColumnValue::String("not!valid!base64".to_string()) + ); + } + + #[test] + fn varbinary_null_maps_to_null() { + assert_eq!( + json_to_column_value(Value::Null, &TrinoTy::VarBinary), + ColumnValue::Null + ); + } + + #[test] + fn boolean_maps_to_ext_bit() { + assert_eq!( + trino_ty_to_sql_type(&TrinoTy::Boolean), + SqlDataType::EXT_BIT + ); + } + + #[test] + fn bigint_precision_is_19() { + assert_eq!(trino_ty_precision(&TrinoTy::TrinoInt(TrinoInt::I64)), 19); + } + + #[test] + fn boolean_precision_is_1() { + assert_eq!(trino_ty_precision(&TrinoTy::Boolean), 1); + } + + #[test] + fn varchar_precision_is_undeterminable_not_zero() { + // A precision of 0 here would under-report the length of a column + // that can legitimately hold arbitrarily long text, producing a + // zero-sized SQL_DESC_DISPLAY_SIZE for a computed VARCHAR expression + // with no catalog entry. `i32::MAX` is also wrong: not an allocatable + // buffer size, and it collides with the different "unbounded but + // known" convention `SQLGetTypeInfo` uses. See the fallback arm's doc + // comment in `trino_ty_precision`. + assert_eq!( + trino_ty_precision(&TrinoTy::Varchar), + PRECISION_UNDETERMINABLE + ); + } + + #[test] + fn unknown_precision_is_undeterminable_not_zero() { + assert_eq!( + trino_ty_precision(&TrinoTy::Unknown), + PRECISION_UNDETERMINABLE + ); + } + + #[test] + fn json_null_returns_column_null() { + assert_eq!( + json_to_column_value(Value::Null, &TrinoTy::Varchar), + ColumnValue::Null + ); + } + + #[test] + fn json_string_returns_column_string() { + assert_eq!( + json_to_column_value(Value::String("hello".into()), &TrinoTy::Varchar), + ColumnValue::String("hello".into()) + ); + } + + #[test] + fn json_number_bigint_returns_i64() { + assert_eq!( + json_to_column_value(serde_json::json!(42), &TrinoTy::TrinoInt(TrinoInt::I64)), + ColumnValue::I64(42) + ); + } + + // --- json_to_column_value: checked narrowing --- + + #[test] + fn out_of_range_integer_for_declared_type_is_an_error_not_a_wrap() { + // Server declared INTEGER but sent a value that does not fit i32. + let val = json_to_column_value( + serde_json::json!(4_294_967_296i64), + &TrinoTy::TrinoInt(TrinoInt::I32), + ); + // Out-of-range falls back to the text representation, matching what + // the I64 arm already does for a non-integer JSON value. + assert_eq!(val, ColumnValue::String("4294967296".to_string())); + } + + #[test] + fn in_range_integer_still_converts() { + let val = json_to_column_value(serde_json::json!(42i64), &TrinoTy::TrinoInt(TrinoInt::I32)); + assert_eq!(val, ColumnValue::I32(42)); + } + + #[test] + fn out_of_range_i16_falls_back_to_text() { + let val = json_to_column_value( + serde_json::json!(70_000i64), + &TrinoTy::TrinoInt(TrinoInt::I16), + ); + assert_eq!(val, ColumnValue::String("70000".to_string())); + } + + #[test] + fn out_of_range_i8_falls_back_to_text() { + let val = json_to_column_value(serde_json::json!(200i64), &TrinoTy::TrinoInt(TrinoInt::I8)); + assert_eq!(val, ColumnValue::String("200".to_string())); + } + + #[test] + fn negative_out_of_range_integer_falls_back_to_text() { + let val = json_to_column_value( + serde_json::json!(-2_147_483_649i64), + &TrinoTy::TrinoInt(TrinoInt::I32), + ); + assert_eq!(val, ColumnValue::String("-2147483649".to_string())); + } + + #[test] + fn out_of_range_float_for_real_is_not_infinity() { + let val = json_to_column_value( + serde_json::json!(1e300f64), + &TrinoTy::TrinoFloat(TrinoFloat::F32), + ); + // Not "1e300": serde_json renders the exponent with an explicit sign + // ("1e+300"), which is what `json_as_text` produces for a non-string + // value. + assert_eq!(val, ColumnValue::String("1e+300".to_string())); + } + + /// Trino encodes the three IEEE specials as JSON *strings*, not numbers + /// (`["NaN", "Infinity", "-Infinity"]`, confirmed off the wire), because + /// JSON has no literal for them. Without an arm for a string-valued float + /// column they fall through to `ColumnValue::String`, and core then + /// refuses `String -> Double` with `22018`, leaving them unreadable. + #[test] + fn ieee_specials_are_read_as_floats_not_strings() { + for (raw, want) in [ + ("NaN", f64::NAN), + ("Infinity", f64::INFINITY), + ("-Infinity", f64::NEG_INFINITY), + ] { + let val = json_to_column_value( + serde_json::json!(raw), + &TrinoTy::TrinoFloat(TrinoFloat::F64), + ); + match val { + ColumnValue::F64(got) => assert!( + (got.is_nan() && want.is_nan()) || got == want, + "DOUBLE {raw:?} became {got}, expected {want}" + ), + other => panic!("DOUBLE {raw:?} did not convert to a float: {other:?}"), + } + } + } + + /// The same three, for `REAL`. The F32 arm reads them through the same + /// `as_f64()`, which returns `None` for a string, so both arms need the + /// string case. + #[test] + fn ieee_specials_are_read_as_floats_for_real_too() { + for (raw, want) in [ + ("NaN", f32::NAN), + ("Infinity", f32::INFINITY), + ("-Infinity", f32::NEG_INFINITY), + ] { + let val = json_to_column_value( + serde_json::json!(raw), + &TrinoTy::TrinoFloat(TrinoFloat::F32), + ); + match val { + ColumnValue::F32(got) => assert!( + (got.is_nan() && want.is_nan()) || got == want, + "REAL {raw:?} became {got}, expected {want}" + ), + other => panic!("REAL {raw:?} did not convert to a float: {other:?}"), + } + } + } + + /// A string Trino sends for a float column that is *not* one of the three + /// specials falls back to text, as the text itself rather than as its JSON + /// encoding. + /// + /// The fallback goes through `json_as_text` for that reason. + /// `Value::to_string()` on a `Value::String` re-adds the quote characters + /// and would yield `"\"abc\""`, giving an application reading the column as + /// text two literal quote marks it never sent. + #[test] + fn unparseable_float_string_falls_back_without_json_quotes() { + let val = json_to_column_value( + serde_json::json!("abc"), + &TrinoTy::TrinoFloat(TrinoFloat::F64), + ); + assert_eq!(val, ColumnValue::String("abc".to_string())); + } + + #[test] + fn in_range_float_for_real_still_converts() { + let val = json_to_column_value( + serde_json::json!(3.5f64), + &TrinoTy::TrinoFloat(TrinoFloat::F32), + ); + assert_eq!(val, ColumnValue::F32(3.5)); + } + + // Note: there is no test for "source f64 already infinite" (e.g. Trino + // sending a value that parses to f64::INFINITY) because serde_json::Value + // cannot represent a non-finite number at all: `Value::from(f64::INFINITY)` + // collapses to `Value::Null` (confirmed via `Number::from_f64` returning + // `None`), and parsing the literal `"1e400"` errors with "number out of + // range" rather than producing an infinite f64. The `!f.is_finite()` + // guard in the F32 arm below is therefore defensive/unreachable through + // this entry point, but is kept since it costs nothing + // and documents the intent (an already-infinite source is not the + // overflow the guard targets). + + #[test] + fn json_bool_returns_column_bool() { + assert_eq!( + json_to_column_value(Value::Bool(true), &TrinoTy::Boolean), + ColumnValue::Bool(true) + ); + } + + #[test] + fn type_name_varchar_maps_to_wvarchar() { + assert_eq!( + trino_type_name_to_sql_type("varchar"), + SqlDataType::EXT_W_VARCHAR + ); + } + + #[test] + fn type_name_integer_maps_to_integer() { + assert_eq!(trino_type_name_to_sql_type("integer"), SqlDataType::INTEGER); + } + + #[test] + fn type_name_bigint_maps_to_bigint() { + assert_eq!( + trino_type_name_to_sql_type("bigint"), + SqlDataType::EXT_BIG_INT + ); + } + + #[test] + fn type_name_boolean_maps_to_bit() { + assert_eq!(trino_type_name_to_sql_type("boolean"), SqlDataType::EXT_BIT); + } + + #[test] + fn type_name_double_maps_to_double() { + assert_eq!(trino_type_name_to_sql_type("double"), SqlDataType::DOUBLE); + } + + #[test] + fn type_name_varchar_with_param_maps_to_wvarchar() { + // Verifies parametric suffix stripping: "varchar(255)" → "varchar" + assert_eq!( + trino_type_name_to_sql_type("varchar(255)"), + SqlDataType::EXT_W_VARCHAR + ); + } + + #[test] + fn type_name_decimal_maps_to_decimal() { + assert_eq!( + trino_type_name_to_sql_type("decimal(10,2)"), + SqlDataType::DECIMAL + ); + } + + #[test] + fn type_name_date_maps_to_date() { + assert_eq!(trino_type_name_to_sql_type("date"), SqlDataType::DATE); + } + + #[test] + fn type_name_timestamp_maps_to_timestamp() { + assert_eq!( + trino_type_name_to_sql_type("timestamp(3)"), + SqlDataType::TIMESTAMP + ); + } + + #[test] + fn type_name_unknown_maps_to_wvarchar() { + assert_eq!( + trino_type_name_to_sql_type("row(x integer, y varchar)"), + SqlDataType::EXT_W_VARCHAR + ); + } + + #[test] + fn type_name_path_recovers_varchar_length() { + // The query path must agree with the catalog path, which already + // parses the length out of the type-name string. + assert_eq!(type_name_precision("varchar(50)"), Some(50)); + assert_eq!(type_name_precision("decimal(10,2)"), Some(10)); + assert_eq!(type_name_scale("decimal(10,2)"), Some(2)); + } + + // --- TrinoTypeName::parse: precision-argument-in-the-middle --- + // + // `TrinoTypeName::parse` must not truncate at the first `(`: that would + // destroy the ` with time zone` suffix on `"timestamp(3) with time + // zone"` / `"time(3) with time zone"` and cause them to silently parse + // as the plain (non-TZ) variant instead of failing to parse, making + // the query path (`execute.rs`) report the wrong precision because its + // `unwrap_or_else` fallback to `trino_ty_precision` never fires. + + #[test] + fn parse_timestamp_without_param() { + assert!(matches!( + TrinoTypeName::parse("timestamp"), + Some(TrinoTypeName::Timestamp) + )); + } + + #[test] + fn parse_timestamp_with_param() { + assert!(matches!( + TrinoTypeName::parse("timestamp(3)"), + Some(TrinoTypeName::Timestamp) + )); + } + + #[test] + fn parse_timestamp_with_time_zone_no_param() { + assert!(matches!( + TrinoTypeName::parse("timestamp with time zone"), + Some(TrinoTypeName::TimestampWithTimeZone) + )); + } + + #[test] + fn parse_timestamp_with_time_zone_and_param() { + assert!(matches!( + TrinoTypeName::parse("timestamp(3) with time zone"), + Some(TrinoTypeName::TimestampWithTimeZone) + )); + } + + #[test] + fn parse_time_without_param() { + assert!(matches!( + TrinoTypeName::parse("time"), + Some(TrinoTypeName::Time) + )); + } + + #[test] + fn parse_time_with_param() { + assert!(matches!( + TrinoTypeName::parse("time(3)"), + Some(TrinoTypeName::Time) + )); + } + + #[test] + fn parse_time_with_time_zone_no_param() { + assert!(matches!( + TrinoTypeName::parse("time with time zone"), + Some(TrinoTypeName::TimeWithTimeZone) + )); + } + + #[test] + fn parse_time_with_time_zone_and_param() { + assert!(matches!( + TrinoTypeName::parse("time(3) with time zone"), + Some(TrinoTypeName::TimeWithTimeZone) + )); + } + + #[test] + fn parse_varchar_with_param_unchanged() { + assert!(matches!( + TrinoTypeName::parse("varchar"), + Some(TrinoTypeName::Varchar) + )); + assert!(matches!( + TrinoTypeName::parse("varchar(50)"), + Some(TrinoTypeName::Varchar) + )); + } + + #[test] + fn parse_char_with_param_unchanged() { + assert!(matches!( + TrinoTypeName::parse("char(10)"), + Some(TrinoTypeName::Char) + )); + } + + #[test] + fn parse_decimal_with_param_unchanged() { + assert!(matches!( + TrinoTypeName::parse("decimal(10,2)"), + Some(TrinoTypeName::Decimal) + )); + } + + // --- TrinoTypeName::parse: INTERVAL types --- + // + // `trino_type_info` (backend/info.rs) advertises "INTERVAL DAY TO SECOND" + // and "INTERVAL YEAR TO MONTH" rows, so `TrinoTypeName::parse` needs a + // variant for each. Without one, `trino_bare_type_name` falls through to + // the EXT_W_VARCHAR/"VARCHAR" fallback and no interval column can be + // reported under the name its own SQLGetTypeInfo row advertises. This + // matches how `Json` and `Uuid` are handled, for the same reason. + + #[test] + fn parse_interval_day_to_second() { + assert!(matches!( + TrinoTypeName::parse("interval day to second"), + Some(TrinoTypeName::IntervalDayToSecond) + )); + } + + #[test] + fn parse_interval_year_to_month() { + assert!(matches!( + TrinoTypeName::parse("interval year to month"), + Some(TrinoTypeName::IntervalYearToMonth) + )); + } + + #[test] + fn parse_varchar_param_still_recovers_length() { + // Confirms the argument-in-the-middle handling does not break the + // `type_name_precision` / `type_name_scale` extraction for types + // whose argument sits at the end of the string. + assert_eq!(type_name_precision("varchar(50)"), Some(50)); + assert_eq!(type_name_precision("char(10)"), Some(10)); + assert_eq!(type_name_precision("decimal(10,2)"), Some(10)); + assert_eq!(type_name_scale("decimal(10,2)"), Some(2)); + } + + /// `TIMESTAMP WITH TIME ZONE` and plain `TIMESTAMP` report the same + /// precision (23, `YYYY-MM-DD HH:MM:SS.mmm`) because the offset is applied + /// and discarded rather than carried in the string (see + /// `parse_trino_timestamp_tz`), so this test does not distinguish a + /// mis-parse (`TrinoTypeName::parse` silently matching the wrong non-TZ + /// variant for parenthesised time-zone types) from the correct value. + /// That distinction is covered separately by + /// `parse_timestamp_with_time_zone_and_param`, which asserts + /// `TrinoTypeName::parse` resolves to the `TimestampWithTimeZone` + /// variant, not `Timestamp`. What this test pins is + /// `type_name_precision`'s output for this input string, which is what + /// `execute.rs` reports: + /// `type_name_precision(&native_name).unwrap_or_else(|| trino_ty_precision(&ty))`. + #[test] + fn query_path_timestamp_with_time_zone_precision_is_23() { + assert_eq!(type_name_precision("timestamp(3) with time zone"), Some(23)); + } + + /// `TIME WITH TIME ZONE` and plain `TIME` report the same precision + /// (12, `HH:MM:SS.mmm`, 9 + `DEFAULT_TEMPORAL_SCALE_WITHOUT_TYPE_NAME`) + /// because the offset is applied and discarded rather than carried in the + /// string (see `parse_trino_time_with_tz`), so this test does not + /// distinguish a mis-parse from the correct value, the same as + /// `..._timestamp_..._23` above; that + /// distinction is covered separately by + /// `parse_time_with_time_zone_and_param`, which asserts `TrinoTypeName::parse` + /// resolves to the `TimeWithTimeZone` variant, not `Time`. What this test + /// pins is `type_name_precision`'s output for this input string. + #[test] + fn query_path_time_with_time_zone_precision_is_12() { + assert_eq!(type_name_precision("time(3) with time zone"), Some(12)); + } + + // --- type_name_precision/type_name_scale: declared temporal scale --- + // + // The tests above (`..._precision_is_23`/`..._is_12`) use scale 3, which + // happens to equal `DEFAULT_TEMPORAL_SCALE_WITHOUT_TYPE_NAME`, so they + // cannot distinguish "the declared scale was read from the type string" + // from "the fallback default was used and happened to match". These + // tests use scale 6 specifically to rule that out. + + #[test] + fn timestamp_6_reports_column_size_26_not_the_bare_scale() { + // 20 + 6 = 26 (ODBC "Column Size" appendix formula), not `6`: + // treating the parenthesised argument as if it were the column size + // directly reports the bare scale, which is wrong. + assert_eq!(type_name_precision("timestamp(6)"), Some(26)); + } + + #[test] + fn timestamp_6_reports_decimal_digits_6() { + // SQL_DESC_PRECISION/decimal digits for a datetime type is the + // fractional-seconds scale itself, not the column size (the + // companion quantity `type_name_precision` above must NOT collapse + // into). + assert_eq!(type_name_scale("timestamp(6)"), Some(6)); + } + + #[test] + fn timestamp_with_time_zone_6_reports_column_size_26() { + assert_eq!(type_name_precision("timestamp(6) with time zone"), Some(26)); + assert_eq!(type_name_scale("timestamp(6) with time zone"), Some(6)); + } + + #[test] + fn time_6_reports_column_size_15_not_the_bare_scale() { + // 9 + 6 = 15, not `6`. + assert_eq!(type_name_precision("time(6)"), Some(15)); + assert_eq!(type_name_scale("time(6)"), Some(6)); + } + + #[test] + fn time_with_time_zone_6_reports_column_size_15() { + assert_eq!(type_name_precision("time(6) with time zone"), Some(15)); + assert_eq!(type_name_scale("time(6) with time zone"), Some(6)); + } + + #[test] + fn timestamp_0_reports_column_size_19_and_scale_0() { + // scale 0 is a real, explicit declaration (no fractional seconds at + // all), distinct from "no argument present at all" below; both must + // still produce the correct formula output (19 = 20 + 0, not 20). + assert_eq!(type_name_precision("timestamp(0)"), Some(19)); + assert_eq!(type_name_scale("timestamp(0)"), Some(0)); + } + + #[test] + fn bare_timestamp_with_no_type_name_argument_falls_back_to_the_default_scale() { + // No parenthesised argument at all (e.g. a bare "timestamp" string): + // `DEFAULT_TEMPORAL_SCALE_WITHOUT_TYPE_NAME` (3) is the only + // reasonable fallback here, matching `TrinoTypeName::fixed_precision`'s + // behaviour for the no-type-name-string case. + assert_eq!(type_name_precision("timestamp"), Some(23)); + assert_eq!(type_name_scale("timestamp"), Some(3)); + } + + // --- trino_ty_to_sql_type: new type coverage --- + + #[test] + fn decimal_maps_to_decimal() { + assert_eq!( + trino_ty_to_sql_type(&TrinoTy::Decimal(10, 2)), + SqlDataType::DECIMAL + ); + } + + #[test] + fn time_maps_to_time() { + assert_eq!(trino_ty_to_sql_type(&TrinoTy::Time), SqlDataType::TIME); + } + + #[test] + fn time_with_tz_maps_to_time() { + assert_eq!( + trino_ty_to_sql_type(&TrinoTy::TimeWithTimeZone), + SqlDataType::TIME + ); + } + + #[test] + fn timestamp_with_tz_maps_to_timestamp() { + assert_eq!( + trino_ty_to_sql_type(&TrinoTy::TimestampWithTimeZone), + SqlDataType::TIMESTAMP + ); + } + + #[test] + fn uuid_maps_to_wvarchar() { + assert_eq!( + trino_ty_to_sql_type(&TrinoTy::Uuid), + SqlDataType::EXT_W_VARCHAR + ); + } + + #[test] + fn json_maps_to_wvarchar() { + assert_eq!( + trino_ty_to_sql_type(&TrinoTy::Json), + SqlDataType::EXT_W_VARCHAR + ); + } + + #[test] + fn array_maps_to_wvarchar() { + assert_eq!( + trino_ty_to_sql_type(&TrinoTy::Array(Box::new(TrinoTy::TrinoInt(TrinoInt::I64)))), + SqlDataType::EXT_W_VARCHAR + ); + } + + #[test] + fn option_delegates_to_inner_type() { + assert_eq!( + trino_ty_to_sql_type(&TrinoTy::Option(Box::new(TrinoTy::TrinoInt(TrinoInt::I64)))), + SqlDataType::EXT_BIG_INT + ); + } + + // --- trino_ty_precision: new type coverage --- + + #[test] + fn decimal_precision_extracted() { + assert_eq!(trino_ty_precision(&TrinoTy::Decimal(10, 2)), 10); + } + + #[test] + fn time_precision_is_12() { + // 9 + 3 (DEFAULT_TEMPORAL_SCALE_WITHOUT_TYPE_NAME, Trino's actual + // default declared precision) = 12, HH:MM:SS.mmm. + assert_eq!(trino_ty_precision(&TrinoTy::Time), 12); + } + + #[test] + fn time_with_tz_precision_is_12() { + // Not 18 (HH:MM:SS.mmm+HH:MM): the offset is applied and the value + // normalised to UTC, so only the fractional seconds are delivered. + assert_eq!(trino_ty_precision(&TrinoTy::TimeWithTimeZone), 12); + } + + #[test] + fn date_precision_is_10() { + // YYYY-MM-DD = 10 chars + assert_eq!(trino_ty_precision(&TrinoTy::Date), 10); + } + + #[test] + fn timestamp_precision_is_23() { + // YYYY-MM-DD HH:MM:SS.mmm = 23 chars (default millisecond precision) + assert_eq!(trino_ty_precision(&TrinoTy::Timestamp), 23); + } + + #[test] + fn timestamp_with_tz_precision_is_23() { + // Not 29 (YYYY-MM-DD HH:MM:SS.mmm+HH:MM): the offset is applied and + // the value normalised to UTC, so only YYYY-MM-DD HH:MM:SS.mmm + // survives into SQL_TIMESTAMP_STRUCT. + assert_eq!(trino_ty_precision(&TrinoTy::TimestampWithTimeZone), 23); + } + + #[test] + fn option_precision_delegates_to_inner() { + assert_eq!( + trino_ty_precision(&TrinoTy::Option(Box::new(TrinoTy::TrinoInt(TrinoInt::I64)))), + 19 + ); + } + + // --- trino_ty_scale --- + + #[test] + fn decimal_scale_extracted() { + assert_eq!(trino_ty_scale(&TrinoTy::Decimal(10, 3)), 3); + } + + #[test] + fn integer_scale_is_zero() { + assert_eq!(trino_ty_scale(&TrinoTy::TrinoInt(TrinoInt::I64)), 0); + } + + #[test] + fn option_decimal_scale_delegates_to_inner() { + assert_eq!( + trino_ty_scale(&TrinoTy::Option(Box::new(TrinoTy::Decimal(10, 4)))), + 4 + ); + } + + // --- json_to_column_value: Option unwrapping --- + + #[test] + fn option_i64_non_null_converts_as_i64() { + assert_eq!( + json_to_column_value( + serde_json::json!(99), + &TrinoTy::Option(Box::new(TrinoTy::TrinoInt(TrinoInt::I64))) + ), + ColumnValue::I64(99) + ); + } + + #[test] + fn option_i64_null_returns_null() { + assert_eq!( + json_to_column_value( + Value::Null, + &TrinoTy::Option(Box::new(TrinoTy::TrinoInt(TrinoInt::I64))) + ), + ColumnValue::Null + ); + } + + // --- date / time / timestamp parsing --- + + #[test] + fn date_string_parses_to_column_date() { + assert_eq!( + json_to_column_value(Value::String("1998-01-14".into()), &TrinoTy::Date), + ColumnValue::Date { + year: 1998, + month: 1, + day: 14 + } + ); + } + + /// Trino renders a year before 1 CE with a leading `-`, which splits the + /// same way as the field separators. A parser that does not strip the sign + /// first drops the whole value to the string fallback, and + /// `SQLGetData(SQL_C_TYPE_DATE)` then fails on a column the driver + /// described as `SQL_TYPE_DATE`. + /// + /// This driver produces such a date itself, from a bound `SQL_DATE_STRUCT` + /// with a negative year, so it has to be able to read one back. + #[test] + fn a_date_before_1_ce_parses_to_column_date() { + assert_eq!( + json_to_column_value(Value::String("-0001-01-01".into()), &TrinoTy::Date), + ColumnValue::Date { + year: -1, + month: 1, + day: 1 + } + ); + } + + /// The same argument as `a_date_before_1_ce_parses_to_column_date`, for the + /// type that had the local `splitn` the shared parser now replaces. + /// `backend::params` renders a bound `SQL_TIMESTAMP_STRUCT` through the same + /// `year4` as a `SQL_DATE_STRUCT`, so the two must read the same years. + #[test] + fn a_timestamp_before_1_ce_parses_to_column_timestamp() { + assert_eq!( + json_to_column_value( + Value::String("-0001-01-01 12:34:56.789".into()), + &TrinoTy::Timestamp + ), + ColumnValue::Timestamp { + year: -1, + month: 1, + day: 1, + hour: 12, + minute: 34, + second: 56, + fraction: 789_000_000, + } + ); + } + + /// The two parsers agree on every year either can meet, which is the + /// property that made the timestamp defect invisible: `DATE` round-tripped, + /// so the shared renderer looked correct. + #[test] + fn dates_and_timestamps_read_the_same_years() { + for year in ["-4713", "-0001", "0000", "0001", "1970", "9999"] { + let date = json_to_column_value(Value::String(format!("{year}-06-15")), &TrinoTy::Date); + let timestamp = json_to_column_value( + Value::String(format!("{year}-06-15 00:00:00")), + &TrinoTy::Timestamp, + ); + let ColumnValue::Date { year: d, .. } = date else { + panic!("{year} did not parse as a DATE: {date:?}"); + }; + let ColumnValue::Timestamp { year: t, .. } = timestamp else { + panic!("{year} parsed as a DATE but not as a TIMESTAMP: {timestamp:?}"); + }; + assert_eq!(d, t, "the two parsers disagree about year {year}"); + } + } + + #[test] + fn year_zero_parses_to_column_date() { + assert_eq!( + json_to_column_value(Value::String("0000-01-01".into()), &TrinoTy::Date), + ColumnValue::Date { + year: 0, + month: 1, + day: 1 + } + ); + } + + /// A year beyond `SQL_DATE_STRUCT`'s signed 16-bit field cannot be carried + /// as a date at all, so it keeps the documented string fallback rather + /// than being truncated into a different year. + #[test] + fn a_year_beyond_the_date_struct_falls_back_to_text() { + assert!(matches!( + json_to_column_value(Value::String("+99999-01-01".into()), &TrinoTy::Date), + ColumnValue::String(_) + )); + } + + #[test] + fn time_string_parses_to_column_time() { + assert_eq!( + json_to_column_value(Value::String("13:14:15".into()), &TrinoTy::Time), + ColumnValue::Time { + hour: 13, + minute: 14, + second: 15, + fraction: 0, + } + ); + } + + #[test] + fn time_with_fractional_seconds_parses_correctly() { + // The fraction is kept, not discarded: SQL_TIME_STRUCT cannot carry + // it, but the SQL_C_CHAR/SQL_C_WCHAR string rendering can. + assert_eq!( + json_to_column_value(Value::String("09:05:03.336".into()), &TrinoTy::Time), + ColumnValue::Time { + hour: 9, + minute: 5, + second: 3, + fraction: 336_000_000, + } + ); + } + + #[test] + fn time_with_timezone_parses_correctly() { + assert_eq!( + json_to_column_value( + Value::String("13:14:15.000 UTC".into()), + &TrinoTy::TimeWithTimeZone + ), + ColumnValue::Time { + hour: 13, + minute: 14, + second: 15, + fraction: 0, + } + ); + } + + #[test] + fn time_with_timezone_normalises_to_utc() { + // The two "with time zone" types must agree: TIMESTAMP WITH TIME + // ZONE converts to UTC, so discarding the offset here rather than + // applying it would make TIME WITH TIME ZONE contradict it. + let val = parse_trino_time_with_tz("13:14:15+02:00").expect("parses"); + assert_eq!( + val, + ColumnValue::Time { + hour: 11, + minute: 14, + second: 15, + fraction: 0, + } + ); + } + + #[test] + fn time_with_negative_offset_normalises_to_utc() { + let val = parse_trino_time_with_tz("13:14:15-05:30").expect("parses"); + assert_eq!( + val, + ColumnValue::Time { + hour: 18, + minute: 44, + second: 15, + fraction: 0, + } + ); + } + + #[test] + fn time_with_offset_wraps_across_midnight() { + let val = parse_trino_time_with_tz("01:00:00+02:00").expect("parses"); + assert_eq!( + val, + ColumnValue::Time { + hour: 23, + minute: 0, + second: 0, + fraction: 0, + } + ); + } + + #[test] + fn time_with_utc_offset_is_unchanged() { + let val = parse_trino_time_with_tz("13:14:15.000 UTC").expect("parses"); + assert_eq!( + val, + ColumnValue::Time { + hour: 13, + minute: 14, + second: 15, + fraction: 0, + } + ); + } + + #[test] + fn time_with_timezone_keeps_fraction_through_offset_shift() { + // The offset shift only touches whole minutes, so a fractional-seconds + // part must survive `shift_time` unchanged. + let val = parse_trino_time_with_tz("13:14:15.123456+02:00").expect("parses"); + assert_eq!( + val, + ColumnValue::Time { + hour: 11, + minute: 14, + second: 15, + fraction: 123_456_000, + } + ); + } + + #[test] + fn timestamp_string_parses_to_column_timestamp() { + assert_eq!( + json_to_column_value( + Value::String("1998-01-14 13:14:15".into()), + &TrinoTy::Timestamp + ), + ColumnValue::Timestamp { + year: 1998, + month: 1, + day: 14, + hour: 13, + minute: 14, + second: 15, + fraction: 0 + } + ); + } + + #[test] + fn timestamp_with_millis_converts_fraction_to_nanoseconds() { + assert_eq!( + json_to_column_value( + Value::String("1998-01-14 13:14:15.123".into()), + &TrinoTy::Timestamp + ), + ColumnValue::Timestamp { + year: 1998, + month: 1, + day: 14, + hour: 13, + minute: 14, + second: 15, + fraction: 123_000_000 + } + ); + } + + /// UTC is a no-op conversion: fields should pass through unchanged. + #[test] + fn timestamp_with_named_timezone_converts_to_utc() { + let val = json_to_column_value( + Value::String("1998-01-14 13:14:15.000 UTC".into()), + &TrinoTy::TimestampWithTimeZone, + ); + assert_eq!( + val, + ColumnValue::Timestamp { + year: 1998, + month: 1, + day: 14, + hour: 13, + minute: 14, + second: 15, + fraction: 0, + }, + ); + } + + #[test] + fn date_null_returns_null() { + assert_eq!( + json_to_column_value(Value::Null, &TrinoTy::Date), + ColumnValue::Null + ); + } + + #[test] + fn decimal_maps_to_decimal_variant() { + use serde_json::json; + let val = json_to_column_value(json!("123.456"), &TrinoTy::Decimal(6, 3)); + assert_eq!(val, ColumnValue::Decimal("123.456".to_string())); + } + + #[test] + fn json_ty_maps_to_json_variant() { + use serde_json::json; + let val = json_to_column_value(json!(r#"{"a":1}"#), &TrinoTy::Json); + assert_eq!(val, ColumnValue::Json(r#"{"a":1}"#.to_string())); + } + + #[test] + fn interval_year_month_parses_correctly() { + use serde_json::json; + let val = json_to_column_value(json!("3-7"), &TrinoTy::IntervalYearToMonth); + assert_eq!( + val, + ColumnValue::IntervalYearMonth { + years: 3, + months: 7, + precision: Interval::YearToMonth, + } + ); + } + + #[test] + fn interval_day_time_parses_correctly() { + use serde_json::json; + let val = json_to_column_value(json!("2 03:04:05.678"), &TrinoTy::IntervalDayToSecond); + assert_eq!( + val, + ColumnValue::IntervalDayTime { + total_nanoseconds: 2 * NANOS_PER_DAY + + 3 * NANOS_PER_HOUR + + 4 * NANOS_PER_MINUTE + + 5 * NANOS_PER_SECOND + + 678_000_000, + precision: Interval::DayToSecond, + } + ); + } + + #[test] + fn negative_interval_day_time_is_fully_negative() { + let val = parse_interval_day_time("-2 03:04:05.678").expect("parses"); + // -(2 days + 3h4m5.678s) = -183_845_678 ms in nanoseconds. + assert_eq!( + val, + ColumnValue::IntervalDayTime { + total_nanoseconds: -183_845_678_000_000, + precision: Interval::DayToSecond, + } + ); + } + + #[test] + fn negative_zero_day_interval_keeps_its_sign() { + // "-0 03:04:05" must keep its sign: parsing the sign only off `days` + // loses it entirely, because "-0".parse::<i64>() is 0. + let val = parse_interval_day_time("-0 03:04:05").expect("parses"); + assert_eq!( + val, + ColumnValue::IntervalDayTime { + total_nanoseconds: -11_045_000_000_000, + precision: Interval::DayToSecond, + } + ); + } + + #[test] + fn positive_interval_day_time_is_unchanged() { + let val = parse_interval_day_time("2 03:04:05.678").expect("parses"); + assert_eq!( + val, + ColumnValue::IntervalDayTime { + total_nanoseconds: 183_845_678_000_000, + precision: Interval::DayToSecond, + } + ); + } + + /// A fraction finer than Trino's own millisecond rendering survives now that + /// the variant counts nanoseconds: the parser no longer truncates at three + /// digits. + #[test] + fn interval_day_time_keeps_sub_millisecond_digits() { + let val = parse_interval_day_time("0 00:00:01.234567").expect("parses"); + assert_eq!( + val, + ColumnValue::IntervalDayTime { + total_nanoseconds: NANOS_PER_SECOND + 234_567_000, + precision: Interval::DayToSecond, + } + ); + } + + /// Numeric offset +05:30: 10:30 local = 05:00 UTC (subtract 5h30m). + #[test] + fn timestamp_with_tz_numeric_offset_converts_to_utc() { + let val = json_to_column_value( + Value::String("2024-03-15 10:30:00.000 +05:30".into()), + &TrinoTy::TimestampWithTimeZone, + ); + assert_eq!( + val, + ColumnValue::Timestamp { + year: 2024, + month: 3, + day: 15, + hour: 5, + minute: 0, + second: 0, + fraction: 0, + }, + ); + } + + #[test] + fn timestamp_tz_named_utc_converts_to_utc_timestamp() { + let val = json_to_column_value( + Value::String("2020-05-05 22:00:00.000 UTC".into()), + &TrinoTy::TimestampWithTimeZone, + ); + assert_eq!( + val, + ColumnValue::Timestamp { + year: 2020, + month: 5, + day: 5, + hour: 22, + minute: 0, + second: 0, + fraction: 0, + }, + ); + } + + #[test] + fn timestamp_tz_named_zone_converts_to_utc() { + // America/New_York in March 2025 is EDT (UTC-4). + // 20:21:22 EDT = 2025-03-11 00:21:22 UTC (date rolls forward). + let val = json_to_column_value( + Value::String("2025-03-10 20:21:22.123 America/New_York".into()), + &TrinoTy::TimestampWithTimeZone, + ); + assert_eq!( + val, + ColumnValue::Timestamp { + year: 2025, + month: 3, + day: 11, + hour: 0, + minute: 21, + second: 22, + fraction: 123_000_000, + }, + ); + } + + #[test] + fn timestamp_tz_numeric_offset_converts_to_utc() { + // +05:30 means wall clock is 5h30m ahead of UTC. + // 10:30:00 +05:30 = 05:00:00 UTC (same day). + let val = json_to_column_value( + Value::String("2024-03-15 10:30:00.000 +05:30".into()), + &TrinoTy::TimestampWithTimeZone, + ); + assert_eq!( + val, + ColumnValue::Timestamp { + year: 2024, + month: 3, + day: 15, + hour: 5, + minute: 0, + second: 0, + fraction: 0, + }, + ); + } + + #[test] + fn timestamp_tz_negative_offset_converts_to_utc() { + // -08:00: 16:00:00 PST = 2024-12-16 00:00:00 UTC (date rolls forward). + let val = json_to_column_value( + Value::String("2024-12-15 16:00:00.000 -08:00".into()), + &TrinoTy::TimestampWithTimeZone, + ); + assert_eq!( + val, + ColumnValue::Timestamp { + year: 2024, + month: 12, + day: 16, + hour: 0, + minute: 0, + second: 0, + fraction: 0, + }, + ); + } + + #[test] + fn timestamp_tz_dst_winter_converts_correctly() { + // America/New_York in December is EST (UTC-5). + // 23:00:00 EST = 2025-01-02 04:00:00 UTC. + let val = json_to_column_value( + Value::String("2025-01-01 23:00:00.000 America/New_York".into()), + &TrinoTy::TimestampWithTimeZone, + ); + assert_eq!( + val, + ColumnValue::Timestamp { + year: 2025, + month: 1, + day: 2, + hour: 4, + minute: 0, + second: 0, + fraction: 0, + }, + ); + } + + /// Trino can send POSIX abbreviations like CET; chrono-tz resolves these. + /// CET is UTC+1 (no DST variant; CEST is the summer equivalent). + #[test] + fn timestamp_tz_posix_abbreviation_cet_converts_to_utc() { + // CET (Central European Time) = UTC+1. + // 15:00:00 CET = 14:00:00 UTC. + let val = json_to_column_value( + Value::String("2025-01-15 15:00:00.000 CET".into()), + &TrinoTy::TimestampWithTimeZone, + ); + assert_eq!( + val, + ColumnValue::Timestamp { + year: 2025, + month: 1, + day: 15, + hour: 14, + minute: 0, + second: 0, + fraction: 0, + }, + ); + } + + /// Full IANA name Europe/Berlin, same offset as CET in winter (UTC+1), + /// but Europe/Berlin also covers CEST (UTC+2) in summer. This test uses + /// a winter date so the expected result matches CET. + #[test] + fn timestamp_tz_europe_berlin_winter_converts_to_utc() { + let val = json_to_column_value( + Value::String("2025-01-15 15:00:00.000 Europe/Berlin".into()), + &TrinoTy::TimestampWithTimeZone, + ); + assert_eq!( + val, + ColumnValue::Timestamp { + year: 2025, + month: 1, + day: 15, + hour: 14, + minute: 0, + second: 0, + fraction: 0, + }, + ); + } + + /// Europe/Berlin in summer is CEST (UTC+2): verify DST shifts correctly. + #[test] + fn timestamp_tz_europe_berlin_summer_converts_to_utc() { + // 2025-07-15 is in CEST (UTC+2). + // 15:00:00 CEST = 13:00:00 UTC. + let val = json_to_column_value( + Value::String("2025-07-15 15:00:00.000 Europe/Berlin".into()), + &TrinoTy::TimestampWithTimeZone, + ); + assert_eq!( + val, + ColumnValue::Timestamp { + year: 2025, + month: 7, + day: 15, + hour: 13, + minute: 0, + second: 0, + fraction: 0, + }, + ); + } + + /// Hour-only numeric offset without minutes (+05 instead of +05:00). + /// parse_numeric_offset defaults the minutes component to 0. + #[test] + fn timestamp_tz_hour_only_numeric_offset() { + // +05 = +05:00. 10:00:00 +05:00 = 05:00:00 UTC. + let val = json_to_column_value( + Value::String("2024-06-01 10:00:00.000 +05".into()), + &TrinoTy::TimestampWithTimeZone, + ); + assert_eq!( + val, + ColumnValue::Timestamp { + year: 2024, + month: 6, + day: 1, + hour: 5, + minute: 0, + second: 0, + fraction: 0, + }, + ); + } + + /// Zero offset (+00:00) is equivalent to UTC. + #[test] + fn timestamp_tz_zero_numeric_offset() { + let val = json_to_column_value( + Value::String("2024-06-01 10:00:00.000 +00:00".into()), + &TrinoTy::TimestampWithTimeZone, + ); + assert_eq!( + val, + ColumnValue::Timestamp { + year: 2024, + month: 6, + day: 1, + hour: 10, + minute: 0, + second: 0, + fraction: 0, + }, + ); + } + + /// Plain TIMESTAMP (no TZ) is a separate code path, via + /// `TrinoTy::Timestamp`, and no UTC conversion applies to it. + #[test] + fn timestamp_no_tz_is_unaffected_by_tz_changes() { + let val = json_to_column_value( + Value::String("2025-06-15 09:30:45.678".into()), + &TrinoTy::Timestamp, + ); + assert_eq!( + val, + ColumnValue::Timestamp { + year: 2025, + month: 6, + day: 15, + hour: 9, + minute: 30, + second: 45, + fraction: 678_000_000, + }, + ); + } + + /// Fraction (sub-second nanoseconds) must survive UTC conversion unchanged. + #[test] + fn timestamp_tz_preserves_fraction_through_conversion() { + // 10:30:00.999 +05:30 = 05:00:00.999 UTC; fraction stays 999ms. + let val = json_to_column_value( + Value::String("2024-03-15 10:30:00.999 +05:30".into()), + &TrinoTy::TimestampWithTimeZone, + ); + assert_eq!( + val, + ColumnValue::Timestamp { + year: 2024, + month: 3, + day: 15, + hour: 5, + minute: 0, + second: 0, + fraction: 999_000_000, + }, + ); + } + + // --- discards_fractional_seconds --- + + #[test] + fn twelve_digit_timestamp_is_reported_as_truncating() { + use serde_json::json; + assert!(discards_fractional_seconds( + &json!("2020-01-02 03:04:05.123456789012"), + &TrinoTy::Timestamp + )); + } + + /// Trailing zeros past the ninth digit are not a loss: nothing the value + /// carried was dropped, so a `timestamp(12)` column full of them must stay + /// silent. + #[test] + fn zeros_past_the_ninth_digit_are_not_a_loss() { + use serde_json::json; + assert!(!discards_fractional_seconds( + &json!("2020-01-02 03:04:05.123456789000"), + &TrinoTy::Timestamp + )); + } + + #[test] + fn nine_or_fewer_digits_are_not_a_loss() { + use serde_json::json; + assert!(!discards_fractional_seconds( + &json!("03:04:05.123456789"), + &TrinoTy::Time + )); + assert!(!discards_fractional_seconds( + &json!("03:04:05"), + &TrinoTy::Time + )); + } + + /// A named zone follows a space and a numeric offset is punctuated with + /// `:`, so neither is mistaken for fractional digits. + #[test] + fn a_zone_suffix_does_not_confuse_the_fraction() { + use serde_json::json; + assert!(discards_fractional_seconds( + &json!("2020-01-02 03:04:05.123456789012 Europe/Berlin"), + &TrinoTy::TimestampWithTimeZone + )); + assert!(!discards_fractional_seconds( + &json!("03:04:05.123+02:00"), + &TrinoTy::TimeWithTimeZone + )); + } + + /// A non-temporal column is never asked about, whatever its text looks + /// like: a `decimal` with twelve digits after the point loses nothing here. + #[test] + fn a_non_temporal_column_never_truncates_a_fraction() { + use serde_json::json; + assert!(!discards_fractional_seconds( + &json!("5.123456789012"), + &TrinoTy::Varchar + )); + } + + /// The composite types are walked, because their elements go through the + /// same parsers. + #[test] + fn a_nested_timestamp_is_reported_through_its_container() { + use serde_json::json; + assert!(discards_fractional_seconds( + &json!(["2020-01-02 03:04:05.000000000001"]), + &TrinoTy::Array(Box::new(TrinoTy::Timestamp)) + )); + assert!(!discards_fractional_seconds( + &json!(["2020-01-02 03:04:05.000000000"]), + &TrinoTy::Array(Box::new(TrinoTy::Timestamp)) + )); + } + + /// A nullable timestamp is the common shape for a projected column, and the + /// wrapper must not hide the loss. + #[test] + fn an_optional_timestamp_is_unwrapped() { + use serde_json::json; + assert!(discards_fractional_seconds( + &json!("2020-01-02 03:04:05.123456789012"), + &TrinoTy::Option(Box::new(TrinoTy::Timestamp)) + )); + } +} + +#[cfg(test)] +mod proptests { + use super::*; + use proptest::prelude::*; + + proptest! { + // The type-name parsers must never panic on any input, however + // malformed: a panic would cross the FFI boundary. + #[test] + fn type_name_parsers_never_panic(s in ".*") { + let _ = type_name_precision(&s); + let _ = type_name_scale(&s); + let _ = trino_type_name_to_sql_type(&s); + } + + // A well-formed `varchar(n)` reports its declared length as the precision. + #[test] + fn varchar_precision_round_trips(n in 1i32..1_000_000) { + prop_assert_eq!(type_name_precision(&format!("varchar({n})")), Some(n)); + } + + // `decimal(p,s)` reports p as the precision and s as the scale. + #[test] + fn decimal_precision_and_scale_round_trip(p in 1i32..=38, s in 0i32..=38) { + let decl = format!("decimal({p},{s})"); + prop_assert_eq!(type_name_precision(&decl), Some(p)); + prop_assert_eq!(type_name_scale(&decl), Some(s)); + } + } +}