diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..3c6b39c --- /dev/null +++ b/.gitattributes @@ -0,0 +1,16 @@ +# Check every text file out with LF endings on every platform. +# +# Windows git defaults to `core.autocrlf=true`, and GitHub's `windows-latest` +# runners ship that default — so without this line `actions/checkout` writes CRLF +# into the working tree even though the repository stores LF. That is not a +# cosmetic difference: `the_set_of_group_lock_acquisition_sites_is_closed` +# (`src/handles/registry.rs`) reads the source tree and splits each file on a +# pattern containing a newline, and CRLF made that pattern miss, so the audit +# counted the test modules and failed on Windows alone. The test normalises line +# endings itself now; this stops the next reader of a file from having to. +# +# `text=auto` still lets git detect and leave binary content alone, so the +# wildcard is safe for anything added later. Every file currently tracked is +# text — no images, and no `.bat`/`.cmd`/`.ps1`, which are the ones that would +# want CRLF kept. +* text=auto eol=lf diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..d9c3669 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,106 @@ +name: Bug report +description: Something in stackable-odbc-core behaves differently from the ODBC spec, or from its own documentation. +labels: ["bug"] +body: + - type: markdown + attributes: + value: | + `stackable-odbc-core` is a **library**, not a loadable ODBC driver — it + is the database-independent half. A concrete driver crate implements + the `Backend` and `StatementBackend` traits and exports the C ABI. + + If the wrong behaviour is in how a particular data source is queried, + it probably belongs in that driver's repository. If you are not sure + which side it falls on, file it here and say so. + + - type: textarea + id: what-happened + attributes: + label: What happened + description: What the driver did, and what you expected instead. + validations: + required: true + + - type: textarea + id: call-sequence + attributes: + label: ODBC call sequence + description: > + The calls that lead to it, in order, with the arguments that matter. + Most bugs in this crate are about a specific function under a specific + prior state, so the sequence is usually more useful than a stack trace. + placeholder: | + SQLAllocHandle(SQL_HANDLE_STMT, ...) + SQLExecDirectW(stmt, L"SELECT ...", SQL_NTS) -> SQL_SUCCESS + SQLFetch(stmt) -> SQL_ERROR, expected SQL_SUCCESS + validations: + required: true + + - type: input + id: sqlstate + attributes: + label: SQLSTATE and message + description: > + From `SQLGetDiagRecW`, if the call returned `SQL_ERROR` or + `SQL_SUCCESS_WITH_INFO`. A wrong SQLSTATE is itself a bug worth + reporting, even when the call otherwise does the right thing. + placeholder: "07009 — Column number 3 out of range (have 5 columns)" + + - type: input + id: spec-reference + attributes: + label: Spec reference + description: > + Link to the function's page on Microsoft Learn, and quote the row or + sentence you think is not being honoured. Optional, but it is what + makes a report immediately actionable — and pay attention to whether + the row carries a `(DM)` marker, which means the Driver Manager owes + it rather than the driver. + placeholder: "https://learn.microsoft.com/en-us/sql/odbc/reference/syntax/sqlfetch-function" + + - type: input + id: version + attributes: + label: Version or commit + description: The `stackable-odbc-core` version, or the commit if building from source. + validations: + required: true + + - type: input + id: driver + attributes: + label: Driver crate + description: Which driver is built on core here, and at what version. + + - type: dropdown + id: driver-manager + attributes: + label: Driver Manager + description: > + The Windows Driver Manager is considerably stricter than unixODBC, and + several behaviours differ between them. + options: + - unixODBC + - Windows Driver Manager + - None (calling the exported entry points directly) + - Other / not sure + validations: + required: true + + - type: input + id: platform + attributes: + label: Platform + description: OS and architecture, and the application if one is involved. + placeholder: "Ubuntu 24.04 x86_64, pyodbc 5.1 / isql" + + - type: textarea + id: logs + attributes: + label: Driver log + description: > + Set `ODBC_LOG_LEVEL=trace` and `ODBC_LOG_FILE=/path/to/log`, reproduce, + and attach the relevant part. **Check it before posting** — a + connection string may appear in it. Passwords are redacted, but other + connection parameters are not. + render: text diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..70b1b26 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,11 @@ +blank_issues_enabled: true +contact_links: + - name: Question or discussion + url: https://github.com/orgs/stackabletech/discussions + about: For usage questions and anything that is not yet a specific defect. + - name: Community chat + url: https://discord.gg/7kZ3BNnCAF + about: Stackable's Discord, for a faster back-and-forth. + - name: Report a security vulnerability + url: https://github.com/stackabletech/stackable-odbc-core/security/advisories/new + about: Please report vulnerabilities privately rather than in an issue. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..59da597 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,62 @@ +name: Feature request +description: An ODBC function, attribute or info type that core does not implement yet, or a change to the Backend trait. +labels: ["enhancement"] +body: + - type: markdown + attributes: + value: | + Most additions here are driven by the ODBC spec rather than invented: + a function that is stubbed, an attribute that is accepted and ignored, + an info type answered with a default. Saying which one it is, and + quoting the spec, usually settles the design. + + - type: textarea + id: what + attributes: + label: What is missing + description: What you need core to do that it does not do today. + validations: + required: true + + - type: textarea + id: use-case + attributes: + label: What it unblocks + description: > + The application or driver that needs it, and what it does instead + today. A concrete blocked case is the strongest argument. + validations: + required: true + + - type: input + id: spec-reference + attributes: + label: Spec reference + description: > + The relevant page on Microsoft Learn, if there is one. For an info + type or attribute, quote its description — the stated *purpose* often + decides the design, and has more than once here. + placeholder: "https://learn.microsoft.com/en-us/sql/odbc/reference/syntax/sqlgetinfo-function" + + - type: dropdown + id: layer + attributes: + label: Which side should own it + description: > + Core holds no database-specific code. A capability only the data source + can answer belongs on a `Backend` method, so the compiler asks the + driver author rather than core guessing — a value core invents is wrong + for some real driver, and wrong silently. + options: + - Core owns it entirely (same answer for every driver) + - A new or changed Backend trait method (each driver answers) + - Not sure + validations: + required: true + + - type: checkboxes + id: breaking + attributes: + label: Compatibility + options: + - label: This would change an existing public API or trait method (a breaking change for driver crates) diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..adcbb15 --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,30 @@ +--- +# Every `uses:` in this repository is pinned to a full commit SHA, which is only +# safe if something keeps those pins current. Without this file they freeze +# permanently, and Cargo.lock drifts toward advisories cargo-deny will start +# failing on with no PR available to fix them. +version: 2 +updates: + - package-ecosystem: github-actions + directory: / + schedule: + interval: weekly + groups: + actions: + patterns: ["*"] + + - package-ecosystem: cargo + directory: / + schedule: + interval: weekly + groups: + cargo: + patterns: ["*"] + update-types: ["minor", "patch"] + + # fuzz/ is a separate Cargo workspace (libFuzzer needs nightly), so the root + # ecosystem entry above does not cover it. + - package-ecosystem: cargo + directory: /fuzz + schedule: + interval: monthly diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..51081c6 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,47 @@ + + + +## What this changes + + + +## Spec basis + + + +## Checklist + +- [ ] `pre-commit run --all-files` passes — this is the single source of truth for what must pass. +- [ ] `CHANGELOG.md` has an entry under `## [Unreleased]`, if this is user-facing. Any change to a public type, trait method or exported FFI contract counts, since driver crates consume them. +- [ ] Doc comments on any FFI function touched list every SQLSTATE from its spec diagnostics table, saying for each whether the driver returns it or why not. +- [ ] New tests were checked by breaking the line they cover and watching them fail. A test that cannot fail reports coverage that does not exist. + +### If it applies + +- [ ] Miri, for anything touching raw pointers: `MIRIFLAGS="-Zmiri-disable-isolation" cargo +nightly miri test -p stackable-odbc-core --lib -- --skip proptest` +- [ ] loom, for anything touching handle locking: `RUSTFLAGS="--cfg loom" cargo test --lib loom_tests` +- [ ] Breaking changes for driver crates are called out above, so the drivers can be updated alongside. + +## Notes for the reviewer + + diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..7e214e0 --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,350 @@ +--- +name: Build and Test + +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" + +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 here + # rather than in its own workflow 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-latest + 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 + version: ubuntu-latest + + - 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 (${{ matrix.os }}) + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + # One platform failing should not hide the others: the point of the + # matrix is to learn which platforms are broken, not just that one is. + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + 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). Windows needs nothing: odbc32 + # and odbccp32 ship with the platform SDK on the runner image. + - name: Install host dependencies (Linux) + if: runner.os == 'Linux' + uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0 + with: + packages: unixodbc-dev + version: ubuntu-latest + + - 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: clippy + + - name: Setup Rust Cache + uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 + + # --locked so CI tests the dependency versions Cargo.lock pins and that + # cargo publish would use, and so a Cargo.toml change without a matching + # lockfile update fails here instead of drifting. + - name: Run unit tests + run: cargo test --locked + + # The pre-commit job runs this same command, but only on Linux — so the + # denied lints (unwrap_used / unwrap_in_result / panic, from Cargo.toml) + # had never applied to any `#[cfg(windows)]` code: `ffi/setup.rs` and + # ConfigDSNW, which is also the least-reviewed code in the crate. Guarded + # to Windows because Linux is already covered and running it twice would + # only slow the matrix down. Same invocation as the pre-commit hook, so + # the two platforms are held to one standard. + - name: Clippy (Windows-only code paths) + if: runner.os == 'Windows' + run: cargo clippy --locked --all-targets -- -D warnings + + # `test-support` is gated `#[cfg(any(test, feature = "test-support"))]`, so + # the job above compiles that module via `cfg(test)` no matter what the + # feature is set to. The configuration a *driver* consumes is the other + # one — feature on, `cfg(test)` off — and nothing built it. `cargo check` + # rather than `cargo test`: the point is that the module compiles outside + # `cfg(test)`, and there are no tests to run in that configuration. + - name: Check the test-support feature as a driver consumes it + run: cargo check --locked --features test-support + + # The benchmark is its own crate (see bench/Cargo.toml), so nothing in + # the root build touches it and bench rot could otherwise merge unnoticed. + # harness = false means it is never run as a test either. Compile it; do + # not run it. + - name: Compile benchmarks + run: cargo build --benches + working-directory: bench + + miri: + name: Miri (undefined behaviour + leaks) + runs-on: ubuntu-latest + timeout-minutes: 30 + needs: [unit-tests] + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + # Miri requires a nightly toolchain; it cannot run on the pinned stable + # version used everywhere else in this workflow. + - name: Install nightly toolchain with Miri + uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # nightly + with: + toolchain: nightly + components: miri + + - name: Setup Rust Cache + uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 + with: + key: miri + + # stackable-odbc-core is pure Rust and holds all the raw-pointer + # marshalling, so it is where the undefined-behaviour risk lives. + # + # Proptests are skipped because they take hours under Miri; they run on + # stable in the unit-tests job. + # + # -Zmiri-disable-isolation is needed for the clock/filesystem access the + # test harness performs. Leak reporting is left ON: it is what catches + # handle and descriptor allocations that a teardown path forgets to free. + # `+nightly` is required: rust-toolchain.toml pins 1.95.0, and a bare + # `cargo miri` respects that file regardless of which toolchain was + # installed above. + - name: Run Miri + env: + MIRIFLAGS: -Zmiri-disable-isolation + run: cargo +nightly miri test --locked -p stackable-odbc-core --lib -- --skip proptest + + loom: + name: loom + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: [unit-tests] + steps: + # odbc-sys links against libodbc/libodbcinst, so linking the test binary + # needs the same host dependency as the other jobs. + - name: Install host dependencies + uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0 + with: + packages: unixodbc-dev + version: ubuntu-latest + + - 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 }} + + - name: Setup Rust Cache + uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 + with: + key: loom + + # `--cfg loom` swaps every lock in `src/sync.rs` for loom's instrumented + # equivalent, so this is a full rebuild of the crate and cannot share a + # cache with the normal test job. The models live in + # `src/handles/registry.rs` as `#[cfg(all(test, loom))]`; the `loom_tests` + # filter is required, not cosmetic, because every other unit test in the + # crate still calls the process-wide registry outside a `loom::model`, + # which panics once `Registry::new` resolves to loom's `RwLock`. + - run: RUSTFLAGS="--cfg loom" cargo test --lib loom_tests + env: + LOOM_MAX_PREEMPTIONS: "3" + + fuzz: + name: Fuzz (ASAN smoke) + runs-on: ubuntu-latest + timeout-minutes: 20 + needs: [unit-tests] + steps: + # The fuzz binaries link stackable-odbc-core, which links libodbc. + - name: Install host dependencies + uses: awalsh128/cache-apt-pkgs-action@acb598e5ddbc6f68a970c5da0688d2f3a9f04d05 # v1.6.0 + with: + packages: unixodbc-dev + version: ubuntu-latest + + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + # cargo-fuzz builds with libFuzzer + AddressSanitizer, which require a + # nightly toolchain. The fuzz crate is its own Cargo workspace so the + # pinned stable root build never touches it. + - name: Install nightly toolchain + uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b # nightly + with: + toolchain: nightly + + # fuzz/ declares its own [workspace], so its build artifacts land in + # fuzz/target, not the root target/. Without this the cache stores an + # empty directory and every run rebuilds nightly + ASAN from scratch. + - name: Setup Rust Cache + uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 + with: + key: fuzz + workspaces: "fuzz -> target" + + - name: Install cargo-fuzz + uses: taiki-e/install-action@97a5807a604e12de3a13b52d868ebecaeeea757c # v2.75.4 + with: + tool: cargo-fuzz + + # A short smoke run per target: long enough to shake out an ASAN overrun + # in the pointer-marshalling paths, short enough for per-PR CI. + # + # --target is pinned to the gnu triple explicitly: newer cargo-fuzz + # defaults to x86_64-unknown-linux-musl, whose statically linked libc is + # incompatible with AddressSanitizer ("sanitizer is incompatible with + # statically linked libc"). gnu uses a dynamic libc and ships with the + # nightly toolchain. + - name: Fuzz utf16 + run: cargo +nightly fuzz run utf16 --target x86_64-unknown-linux-gnu -- -max_total_time=30 + - name: Fuzz column_value + run: cargo +nightly fuzz run column_value --target x86_64-unknown-linux-gnu -- -max_total_time=30 + + # Verifies the crate can actually be packaged, without publishing anything. + # + # `cargo package` is not covered by `cargo build`: it applies `exclude`, + # re-resolves the result as a standalone crate and compiles it from the + # tarball. That is what catches a source file excluded by accident, a + # declared-but-unpackaged target, or a path dependency with no version -- + # each of which only shows up at publish time otherwise. + # + # `--locked` so a manifest change without a lockfile update fails here. + # Warnings are promoted to failures: the packaging step is short enough that + # a standing warning would be read as normal and hide the next one. + package: + name: Package (publish dry run) + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + + - name: Install host dependencies + run: sudo apt-get update && sudo apt-get install -y unixodbc-dev + + - name: Setup Rust Cache + uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 + with: + key: package + + # The log goes to RUNNER_TEMP, not the workspace: `cargo package` refuses + # to run against a dirty tree, so a log file written next to Cargo.toml + # is itself the uncommitted change that fails the step. + - name: Build the package and compile it from the tarball + run: | + set -o pipefail + cargo package --locked 2>&1 | tee "${RUNNER_TEMP}/package.log" + if grep -q '^warning' "${RUNNER_TEMP}/package.log"; then + echo "::error::cargo package emitted warnings; see above" + exit 1 + fi + + # The published tarball must carry the licence and the notice, and must + # not carry the toolchain pin, shipped, it pins consumers to this + # crate's Rust version. + - name: Check the tarball's contents + run: | + list=$(cargo package --locked --list) + for required in LICENSE NOTICE README.md CHANGELOG.md; do + grep -qx "$required" <<<"$list" || { + echo "::error::$required missing from the package"; exit 1; } + done + for forbidden in rust-toolchain.toml AGENTS.md CLAUDE.md; do + if grep -qx "$forbidden" <<<"$list"; then + echo "::error::$forbidden must not be published"; exit 1 + fi + done + echo "$list" + + # Single required check for branch protection rules. + finished: + name: Finished Build and Test + if: always() + needs: + - pre-commit + - unit-tests + - miri + - loom + - fuzz + - package + runs-on: ubuntu-latest + 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 went unenforced. + - 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/security_audit.yaml b/.github/workflows/security_audit.yaml new file mode 100644 index 0000000..070c7de --- /dev/null +++ b/.github/workflows/security_audit.yaml @@ -0,0 +1,29 @@ +--- +name: Daily Security Audit + +on: + schedule: + # Run every day at 04:15 UTC: https://crontab.guru/#15_4_*_*_* + - cron: '15 4 * * *' + workflow_dispatch: + +# rustsec/audit-check reports findings by opening a GitHub issue, and records a +# check run. With contents:read alone it cannot do either, so a new advisory +# would fail silently and the job's green status would mean nothing. +permissions: + contents: read + issues: write + checks: write + +jobs: + audit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + token: ${{ secrets.GITHUB_TOKEN }} + + - 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..2099c8a --- /dev/null +++ b/.gitignore @@ -0,0 +1,15 @@ +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/ 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..b731cdd --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,87 @@ +--- +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: 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$ + + # Broken intra-doc links are warnings, not errors, so they reach docs.rs + # silently — and docs.rs is this crate's shop window, since a driver + # author reads the Backend trait docs before writing any code. -D warnings + # promotes them. Runs in well under a second because the dependency graph + # is already built by the hooks above. + # + # `--all-features` is load-bearing, not tidiness. `conformance` and + # `test_support` are gated on `#[cfg(any(test, feature = "test-support"))]`, + # and rustdoc documents the lib target *without* `cfg(test)` — so without + # this flag neither module is documented and neither is checked. The + # cargo-clippy hook above does not have the gap, because `--all-targets` + # builds the test target and that sets `cfg(test)`. Cargo.toml points + # docs.rs at `features = ["test-support"]`, so those are exactly the + # modules whose broken links ship to the shop window. One did: the + # conformance module's link to `info_group_inconsistencies` rendered as + # broken text while every local and CI run reported clean. + - id: cargo-doc + name: cargo-doc + language: system + entry: env RUSTDOCFLAGS=-Dwarnings cargo doc --locked --no-deps --all-features + 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..98ecfca --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,1467 @@ +# Agent Guide + +Implementation details for anyone working on `stackable-odbc-core`, human or AI. + +`stackable-odbc-core` is the database-independent framework that concrete ODBC +driver crates build on. It contains **zero** database-specific code: a driver +implements the `Backend` and `StatementBackend` traits and calls the +`forward_ffi!` macro to export the C ABI. This guide describes the framework +itself and, where relevant, how a downstream driver crate consumes it. + +For building, testing and the commit gate, see +[CONTRIBUTING.md](CONTRIBUTING.md). This file is the reference behind those +commands, not a substitute for them. + +## Quick Reference + +| Topic | When to Read | +|-------|-------------| +| [Architecture](#architecture) | Understanding call flow, crate layout, or the key design decisions | +| [Conventions](#conventions) | Writing any code here: logging, named constants, casts, the changelog | +| [Adding a new ODBC function](#adding-a-new-odbc-function) | Implementing or moving a function from stubs | +| [Adding a new driver](#adding-a-new-driver) | Creating a backend crate on top of core, and every capability it must declare | +| [Descriptors](#descriptors) | Touching a binding, a descriptor field, the `HY021` consistency check, or a statement attribute that is a header field | +| [Concurrency: the lock discipline](#concurrency-the-lock-discipline) | Understanding the per-connection lock, `HandleScope`, `SQLCancel`'s exemption, or loom | +| [Testing](#testing) | Writing tests, or running Miri, loom, fuzz or the benchmarks | +| [odbc-sys usage](#odbc-sys-usage) | Using ODBC types, enums or constants | +| [Converting raw values](#converting-raw-values-to-strongly-typed-enums) | Handling raw integers from the C ABI | + +## Architecture + +Core exposes a generic `Backend` trait, and each driver crate generates manual +forwarding stubs from it. That shape was chosen over proc-macros (premature) +and a `dyn` trait (poor ergonomics for complex trait hierarchies). + +### How a function call flows + +Example: `SQLDriverConnectW` (a fully implemented function), for a driver whose +backend type is `XyzBackend`: + +```text +ODBC Application (e.g. isql) + -> SQLDriverConnectW(...) # C ABI entry point generated by forward_ffi! in the driver's lib.rs + -> ffi::connect::sql_driver_connect_w::(...) # generic impl in stackable-odbc-core + -> panic_safe::(...) # locks the target's group, builds a HandleScope, catches panics + -> scope.get::>() # validates the token against the handle registry, returns typed &mut + -> handle.diagnostics.clear() # spec: clear diagnostics at start of each call + -> validation checks # 08002, HY090 per the ODBC spec + -> utf16_to_string(...) # convert UTF-16 input to Rust String + -> merge_dsn_params(...) # parse "Key=Value;..."; if DSN= is present, resolve its keys from odbc.ini (explicit values win) and re-parse + -> params.set_prompter(prompter_for::(completion)) # B::prompter(), unless DriverCompletion is SQL_DRIVER_NOPROMPT + -> B::connect(¶ms) # Backend trait method (database-specific); runs under the connection's group lock, like every other Backend method + -> handle.connection = Some(conn) # store result in handle + -> apply_pending_autocommit::(..) # apply a SQL_ATTR_AUTOCOMMIT set before connect; tears the connection down on failure + -> write_utf16(...) # echo connection string to output buffer +``` + +### Key design decisions + +- **Two traits**: `Backend` (creates connections and statements) and + `StatementBackend` (iterates results). Split so that lifecycle is separate + from cursor operations. +- **Handle registry**: an application-facing `SQLHANDLE` is an opaque token + packing a slot index and a generation counter, not an address. A driver-owned + table holds `{ generation, kind, addr, group, parent, cancel }`, and + `HandleScope::get()` validates a token with a bounds check plus a + generation and kind compare, **without dereferencing the pointer the + application passed**. Freeing bumps the slot's generation, so every + outstanding token for that slot is permanently rejected, which also closes + the recycled-address double-free. This is the primary safety mechanism at the + FFI boundary. Nothing may treat a `SQLHANDLE` as an address, or validate one + by reading through it. See "Concurrency: the lock discipline" for `group`, + `parent` and `cancel`. +- **`panic_safe`**: wraps every FFI function except the two below. It locks the + target handle's group, builds the `HandleScope` the closure operates through, + and uses `AssertUnwindSafe` plus `catch_unwind`. On error it pushes to the + handle's diagnostic queue and returns the appropriate `SqlReturn`. + + **The two exceptions use `panic_safe_unlocked`, because neither has a handle + to work through.** `SQLCancel` must not touch the diagnostic state + `panic_safe` clears and pushes, per the spec's carve-out for cancelling a + call running on another thread. `ConfigDSNW` is handed no ODBC handle at all: + its arguments are a window handle, a request code and two strings, so there + is no token to lock a group by and no queue to push to. It is still an + `extern "system"` boundary, and an unwind across it lands in the ODBC + Administrator. + + **Every `extern "system"` export needs one of the two.** Neither is optional + for a new entry point. "It takes no handle" is a reason to reach for + `panic_safe_unlocked`, not a reason to skip the guard. + + **`SQLCopyDesc` needs two guards, because it takes two lock phases rather + than one** (see "Descriptors", "The explicit-descriptor rulings", for why). + Phase one holds only the *source*'s group, through `HandleScope::with_group`, + which is a plain lock-then-call with no `catch_unwind` of its own, so it is + guarded by `panic::catch_panic_as_error`. That converts a panic into the same + `OdbcError` shape a non-panicking phase-one failure (`HY007`) returns. Phase + two is an ordinary `panic_safe` on the target, and it posts that error to the + target's queue, where the whole call's diagnostics belong. +- **W-only for string-bearing functions**: every ODBC function that takes or + returns a string is exported only in its Wide (`W`-suffix) form, and the + Driver Manager translates an ANSI application's calls into those. Functions + with no strings in their signature, such as `SQLAllocHandle`, `SQLFetch` and + `SQLBindCol`, have one spelling and are exported unsuffixed. + `CORE_EXPORTED_FUNCTIONS` in `src/function_id.rs` is the authoritative list, + and a guard test pins every entry to a symbol that exists. +- **No async in the trait**: `Backend` is synchronous. A driver wrapping an + async client library bridges to it internally, for example with a + current-thread tokio runtime plus `block_on`. + +### Crate layout + +Generic framework. Zero database-specific code. + +| Module | What it does | +|--------|-------------| +| `backend.rs` | `Backend` + `StatementBackend` trait definitions; `common_get_info_raw` and `default_get_info` shared helpers | +| `types/mod.rs` | `odbc-sys` re-exports, `InfoValue` enum, submodule declarations | +| `types/constants.rs` | All `SQL_*` named constants (spec-defined values not in `odbc-sys`) | +| `types/conversions.rs` | `*_from_raw()` conversion functions for all ODBC ABI types | +| `types/sql_state.rs` | `SqlState`: the five-character ODBC diagnostic code, and its factory methods | +| `types/value.rs` | `ColumnValue`, `FetchResult`, `Nullable`, `TypeInfoRow`, `ColumnDescriptor` | +| `types/result_cols.rs` | `TablesResultCol`, `ColumnsResultCol`, `PrimaryKeysResultCol`, `ForeignKeysResultCol`, and `CatalogResultColumnWidths` (the per-backend widths those result sets declare) | +| `types/connect_params.rs` | `ConnectParams`: the ODBC connection string parser | +| `types/col_attr.rs` | `ColAttrValue` and column attribute logic for `SQLColAttributeW` | +| `types/cursor_behavior.rs` | `CursorBehavior`: the `SQL_CB_*` cursor behaviour `SQLEndTran` applies, declared by the backend and reported by `SQLGetInfoW` | +| `types/query_timeout.rs` | `QueryTimeout`: which side enforces `SQL_ATTR_QUERY_TIMEOUT`, declared by `Backend::set_query_timeout` | +| `types/column_size.rs` | Shared ODBC column-size formulas (`catalog_column_size`/`column_size`); keeps declared vs maximum precision distinct | +| `types/info_type_shape.rs` | The `SQLGetInfo` spec's per-`InfoType` return-value shape, transcribed for the conformance test | +| `types/version.rs` | Parsed data-source version numbers, for a backend gating capabilities on server version | +| `types/odbc_version.rs` | `DeclaredOdbcVersion`: the version an application declared through `SQL_ATTR_ODBC_VERSION`, and what it changes | +| `types/catalog_queries.rs` | The ten sealed `XxxQuery` argument objects the catalog hooks take | +| `types/diagnostics_table.rs` | Every function's spec Diagnostics table transcribed, plus the guards that check the doc comments against it | +| `types/redacted.rs` | `Redacted`: a `Debug` wrapper that prints `*****` for sensitive fields (e.g. passwords) | +| `column_value.rs` | `write_column_value()`: core data marshalling for `SQLGetData` (NULL, truncation, type coercion). Also owns the spec's "SQL to C: Year-Month Intervals" and "SQL to C: Day-Time Intervals" tables, transcribed: the C interval targets, footnote [b]'s exact-numeric row, and the character and binary rows those two pages word differently from every other source | +| `param_convert.rs` | `text_to_sql_type()`, the reverse direction: converts `SQL_C_CHAR`/`SQL_C_WCHAR` parameter text to the SQL type `SQLBindParameter` declared. The spec's "C to SQL: Character" table, transcribed. Also owns the size checks all three C-to-SQL tables share (`DecimalLiteral`, `check_declared_char_size`, `check_declared_decimal_size`, `check_declared_binary_size`) | +| `binary_convert.rs` | The spec's "C to SQL: Binary" table, transcribed. `SQL_C_BINARY` to the targets whose byte layout ODBC defines; refuses the rest at bind with `07006` | +| `numeric_convert.rs` | The spec's "C to SQL: Numeric" table, transcribed. Every numeric C type to any of its six target rows, including the interval row and footnote [b]'s optional `01S07`. `numeric_pairing_is_supported` is `SQLBindParameter`'s gate | +| `prompt.rs` | `Prompter`: the trait a driver implements to present a login URL to the user during a connect. Definition only: core ships no implementation and gains no dependency | +| `setup.rs` | `ConfigRequest`, `InstallerError` and `config_request_from_raw`: the driver-facing half of the ODBC installer's `ConfigDSN` entry point, which a driver reaches through `Backend::configure_dsn`. Nothing here is `#[cfg(windows)]`; only the `ConfigDSNW` export in `ffi/setup.rs` is | +| `query_timer.rs` | `QueryTimer`, core-side `SQL_ATTR_QUERY_TIMEOUT` enforcement: a timer thread that calls `Backend::cancel` on expiry and relabels the resulting failure `HYT00` | +| `cancel.rs` | `CancelState`: a backend's cancel token plus core's `timed_out` flag, and the one implementation of "a cancelled call reports `HY008`" | +| `synthetic.rs` | `SyntheticStatement`: in-memory result set for `SQLGetTypeInfo` and catalog functions | +| `catalog_sort.rs` | Sorts a catalog result set into its spec-mandated order; NULL placement from `Backend::null_collation` | +| `catalog_ident.rs` | `SQL_ATTR_METADATA_ID` identifier normalisation and the `SQLTables` `TableType` value-list parser | +| `types/catalog_rows.rs` | The ten typed catalog row structs a `Backend` returns (`TableRow`, `ColumnRow`, `PrimaryKeyRow`, `ForeignKeyRow`, `StatisticsRow`, `SpecialColumnRow`, `ProcedureRow`, `ProcedureColumnRow`, `ColumnPrivilegeRow`, `TablePrivilegeRow`), and their spec-order conversion to `ColumnValue`s | +| `conformance.rs` | Shared support for the `SQLGetInfoW` info-type conformance test (return shape + Driver-Manager-safe value), reused by core and by driver test suites | +| `escape.rs` | ODBC escape-sequence translation (`{fn}`, `{d/t/ts}`, `{oj}`, `{escape}`); a shared scanner with a per-backend `EscapeDialect` | +| `errors.rs` | `OdbcError` with SQLSTATE mapping and `SqlReturn` conversion | +| `descriptor.rs` | `DescriptorRecord`, `DescriptorRole`, the per-role field tables (`field_access`, which decides `HY091` for every identifier naming a real field; one naming none is refused earlier by `ffi::desc::field_from_raw`), the header-field mapping, and the `HY021` consistency check. No FFI, no handles | +| `diagnostics.rs` | Per-handle diagnostic queue (`SQLGetDiagRecW` reads from here) | +| `handles/mod.rs` | `EnvironmentHandle`, `ConnectionHandle`, `StatementHandle`, `Descriptor`, `HandleHeader`, `GetDataCursor`, `DataAtExecState`, and alloc/free (`pub(crate)`) | +| `handles/registry.rs` | The live-handle table (`Registry`, `Slot`), per-connection `GroupLock`s, cancel tokens, and the loom models (`#[cfg(all(test, loom))] mod loom_tests`) | +| `handles/scope.rs` | `HandleScope`: the only way to reach a handle's contents; token validation without dereferencing the application's pointer | +| `sync.rs` | The one import path for every lock in the crate; aliases to `loom`'s primitives under `#[cfg(all(loom, test))]`, `std::sync` otherwise | +| `utf16.rs` | `utf16_to_string`, `write_utf16` (ODBC uses UTF-16LE) | +| `panic.rs` | `panic_safe` (locks the target's group, builds a `HandleScope`, catches panics), `panic_safe_unlocked` (`SQLCancel`'s lock-free sibling), and `catch_panic_as_error` (`SQLCopyDesc` phase one's panic-to-`OdbcError` guard) | +| `logging.rs` | `init_logging()` via tracing, configured by `ODBC_LOG_LEVEL` / `ODBC_LOG_FILE` | +| `function_id.rs` | `FunctionId` enum + `function_id_from_raw()` for `SQL_API_*` constants | +| `test_support.rs` | `test-support`-feature-gated hooks a driver's test suite uses to put a connection into a handle without `SQLDriverConnectW` | +| `ffi/handle.rs` | `sql_alloc_handle`, `sql_free_handle`, `sql_free_stmt` | +| `ffi/env.rs` | `sql_set_env_attr`, `sql_get_env_attr` | +| `ffi/connect.rs` | `sql_driver_connect_w`, `sql_browse_connect_w`, `sql_connect_w`, `sql_disconnect`, `sql_native_sql_w`; `merge_dsn_params` (DSN resolution) | +| `ffi/connect_attr.rs` | `sql_set_connect_attr_w`, `sql_get_connect_attr_w` | +| `ffi/diag.rs` | `sql_get_diag_rec_w`, `sql_get_diag_field_w` | +| `ffi/cursor.rs` | `sql_num_result_cols`, `sql_row_count`, `sql_more_results`, `sql_close_cursor`, `sql_cancel`, `sql_get_cursor_name_w`, `sql_set_cursor_name_w`, `sql_bulk_operations`, `sql_set_pos` | +| `ffi/execute.rs` | `sql_exec_direct_w`, `sql_prepare_w`, `sql_execute` | +| `ffi/fetch.rs` | `sql_fetch`, `sql_fetch_scroll`, `sql_extended_fetch`, `sql_get_data` | +| `ffi/metadata.rs` | `sql_describe_col_w`, `sql_col_attribute_w`, `sql_tables_w`, `sql_columns_w`, `sql_primary_keys_w`, `sql_foreign_keys_w`, `sql_statistics_w`, `sql_special_columns_w`, `sql_procedures_w`, `sql_procedure_columns_w`, `sql_column_privileges_w`, `sql_table_privileges_w` | +| `ffi/params.rs` | `sql_bind_parameter`, `sql_num_params`, `sql_describe_param`, `sql_put_data`, `sql_param_data` | +| `ffi/bind.rs` | `sql_bind_col` | +| `ffi/desc.rs` | `sql_get_desc_field_w`, `sql_set_desc_field_w`, `sql_get_desc_rec_w`, `sql_set_desc_rec`, `sql_copy_desc`; argument marshalling over `descriptor.rs`'s tables | +| `ffi/stmt_attr.rs` | `sql_set_stmt_attr_w`, `sql_get_stmt_attr_w` | +| `ffi/info.rs` | `sql_get_info_w`, `sql_get_type_info`, `sql_get_functions` | +| `ffi/tran.rs` | `sql_end_tran` | +| `ffi/setup.rs` | `config_dsn_w` (ODBC installer entry point) | +| `ffi/mod.rs` | `ffi` submodule declarations | +| `forward_ffi.rs` | `forward_ffi!` macro: generates the C ABI entry points for a backend (the `SQL*` functions, plus `ConfigDSNW` on Windows) | +| `test_utils.rs` | Shared test infrastructure (`MockBackend` and the purpose-built mocks listed under Testing) | + +### What a driver crate contains + +A driver built on core is typically laid out like this: + +| File | What it does | +|------|-------------| +| `backend.rs` | Struct definitions (`XyzBackend`, `XyzConnection`, `XyzStatement`), `connect`, `disconnect`, `end_tran`, the thin `impl Backend` delegation layer, and the central error-mapping function | +| `backend/execute.rs` | `exec_direct`, `prepare`, `execute`; `impl StatementBackend for XyzStatement` | +| `backend/metadata.rs` | `tables`, `columns`, `primary_keys`, `foreign_keys`; private query helpers | +| `backend/info.rs` | `get_info`, `get_info_pre_connect`, `get_info_raw`, `get_functions`, `get_type_info` | +| `backend/params.rs` | `bind_parameter`, `num_params`, `describe_param` (if the backend supports server-side parameters) | +| `backend/types/connect_params.rs` | Driver-specific connection parameters parsed from the ODBC connection string | +| `lib.rs` | Invokes `stackable_odbc_core::forward_ffi!(crate::backend::XyzBackend)`, which generates all the C ABI entry points | +| `type_conversion.rs` | Converts backend-native column values to `ColumnValue` | +| `escape_dialect.rs` | The backend's `EscapeDialect` for core's escape-sequence translator (identifier quoting, `{fn}` name mapping) | +| `ffi_integration_tests.rs` | FFI-level integration tests that call the C ABI entry points directly | + +## Conventions + +- Edition 2024, resolver 3, Rust 1.95.0 +- `snafu` for errors (the `unwrap_used`, `unwrap_in_result` and `panic` clippy + lints are denied outside tests) +- `tracing` for logging (not `println!` or `log`) +- `#[repr(C)]` on all handle structs, for a defined, non-reordered layout on a + type that is heap-allocated via `Box::into_raw` and later reclaimed via + `Box::from_raw` at that same raw address. Handle validation never dereferences + these structs at all: it is a slot index and generation compare against the + registry, so no field's offset, including `HandleHeader`'s, is load-bearing. +- `extern "system"` on all FFI exports (resolves to the correct ABI on both + Windows and Linux) +- `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. Miri is the exception, because it + interprets rather than links and so needs no system libraries. +- **`#[cfg(windows)]` code is compilable from Linux, and should be compiled + before it is pushed.** A plain `cargo check` does not look at it at all, so + `ffi/setup.rs` and `ConfigDSNW` can be edited into a state that builds and + tests clean locally and fails on the Windows runner: + + ```bash + rustup target add x86_64-pc-windows-msvc # once + cargo clippy --target x86_64-pc-windows-msvc --all-targets -- -D warnings + ``` + + This links nothing and needs no Windows host, because `raw-dylib` resolves + `odbccp32` at link time and a `check`/`clippy` run never reaches it. It is not + a substitute for *running* the code, which only a Windows host with a Driver + Manager can do. It closes the compile-and-lint half, which is where the + regressions are. + +- **`bench/` and `fuzz/` are separate Cargo workspaces, so nothing at the repo + root compiles them.** Not `cargo test`, not `cargo clippy --all-targets`, and + not a single `pre-commit` hook. `bench/benches/handle_lookup.rs` contains a + full `impl Backend`, so **any change to the `Backend` or `StatementBackend` + trait breaks it silently**: every local check passes and CI's "Compile + benchmarks" step fails. After touching either trait: + + ```bash + (cd bench && cargo build --benches) + (cd fuzz && cargo +nightly build --target x86_64-unknown-linux-gnu) + ``` + + `pre-commit run --all-files` covers everything *in the root workspace*, and + these two directories are outside it by design (see the Benchmarks and Fuzzing + sections for why). A detached workspace is invisible to exactly the checks you + would expect to catch it. + +### 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 (public API, behaviour, spec-compliance fixes) gets an entry under the +`## [Unreleased]` heading in the appropriate `Added` / `Changed` / `Fixed` / +`Removed` group. Core is a published library consumed by driver crates, so +treat any change to a public type, trait method, or exported FFI contract as +user-facing. + +### Logging in FFI functions + +Every `pub unsafe fn` in `ffi/` must follow this structure: + +```rust +// 1. If no parsing: single debug! at entry +tracing::debug!("SQLFunctionW(handle={:?}, param={})", handle, param); + +// OR if parsing anything (raw integers to enums, or UTF-16 pointers to +// strings): +// 1a. TRACE: raw inputs before parse +tracing::trace!("SQLFunctionW(handle={:?}, raw={})", handle, raw_int); +// 1b. DEBUG: parsed/typed values after +tracing::debug!("SQLFunctionW: attr={:?}", parsed_attr); +// 1b. ...and for a function taking string arguments, name every one of them: +tracing::debug!( + "SQLFunctionW(handle={:?}, catalog={:?}, schema={:?}, table={:?})", + handle, catalog, schema, table, +); + +// 2. WARN: intentional spec deviations (silent accepts, ignored features) +tracing::warn!("SQLFunctionW: accepting unrecognized X (DM compatibility)"); + +// 3. DEBUG: return value, always; requires the +// `let ret = unsafe { panic_safe(...) };` pattern +tracing::debug!("SQLFunctionW -> {:?}", ret); +``` + +Rules: no passwords or connection string content; `error!` only for validation +failures expressed via `OdbcError` (avoid double-logging); stubs use a single +`debug!` entry, no exit log. + +**A string argument counts as parsed input.** The entry log knows only the +handle, so a function that logs just that shows *which* call happened and not +what it asked for, which is precisely what you need when a client's metadata +query comes back empty. Every catalog function therefore logs its +`parse_filter_param` results. This is the rule most easily missed when a stub +becomes a real implementation, because the stub's single `debug!` looks like it +already complies. + +### Named constants + +ODBC attribute values, function IDs, and bitmap constants must use named `const` +definitions. Never write raw integer literals for ODBC-spec-defined values. Name +them after the ODBC spec name (for example `SQL_AUTOCOMMIT_ON`, +`SQL_CUR_USE_DRIVER`, `SQL2_FREE_CONNECT`). + +**This applies to tests too.** Test code is where raw literals creep back in +most easily, usually with the spec name relegated to a trailing comment. A +comment is not a constant: + +```rust +// BAD: the value is unchecked and the name is only a comment +sql_bind_parameter::(stmt, 1, 1 /* SQL_PARAM_INPUT */, ..., -5 /* SQL_BIGINT */, ...); + +// GOOD: the compiler validates both +sql_bind_parameter::(stmt, 1, ParamType::Input as i16, ..., SqlDataType::EXT_BIG_INT.0, ...); +``` + +Prefer the `odbc-sys` type over defining a new constant when one exists, because +most spec values are already modelled: + +| Value | Use | +|-------|-----| +| `SQL_PARAM_INPUT`, `SQL_PARAM_OUTPUT`, … | `ParamType::Input as i16` | +| `SQL_BIGINT`, `SQL_VARCHAR`, `SQL_INTEGER`, … | `SqlDataType::EXT_BIG_INT.0` (note the `.0`) | +| `SQL_C_SBIGINT`, `SQL_C_WCHAR`, … | `CDataType::SBigInt as i16` | +| `SQL_ATTR_*` | `StatementAttribute::*` / `ConnectionAttribute::*` | +| `SQL_HANDLE_*` | `HandleType::*` | + +All are re-exported from `stackable_odbc_core::types`. Only define a new `const` +in `types/constants.rs` when `odbc-sys` genuinely lacks the value. Ordinals that +are not spec constants (a parameter number, a column index) are fine as +literals. + +### Type cast safety + +Use `T::try_from(x)` over bare `as T` when truncation is possible. For ODBC +output parameters typed `*mut i16` (column counts, parameter counts), use +`i16::try_from(n).unwrap_or_else(|_| { tracing::warn!(...); i16::MAX })`. + +## Adding a new ODBC function + +1. **Read the function's spec page.** Every function has one at + `https://learn.microsoft.com/en-us/sql/odbc/reference/syntax/-function?view=sql-server-ver17` + (for example `sqlallochandle-function`). +2. **Implement every check and constraint** from the spec: + - All parameter validation (null checks, valid handle types, valid attribute + values) + - All required error returns and SQLSTATEs listed in the spec's "Diagnostics" + table + - All state transition rules (for example "cannot call X before Y") + - Setting output parameters to defined values on error (for example + `*OutputHandlePtr = SQL_NULL_HANDLE`) + - If a spec requirement cannot be implemented, leave a `// TODO(spec):` + comment explaining why, and flag it to the user +3. **Reference the spec URL** in the doc comment for the generic function in + `src/ffi/`: + + ```rust + /// Generic implementation of SQLAllocHandle. + /// + /// Spec: + ``` + + The doc comment's SQLSTATE list is checked against the spec's own Diagnostics + table by `every_doc_comment_matches_the_spec_diagnostics_table` + (`src/types/diagnostics_table.rs`), so a new function needs its table + transcribed there before it will build. That module's docs give the four + verdict phrasings the guard recognises, and state the one thing it does not + check: whether the *reason* a row is not returned is true. + +4. **Implement the generic function** in `src/ffi/`, in the appropriate module. +5. **Add a `Backend` (or `StatementBackend`) trait method** if the function + needs database-specific logic. Prefer a defaulted method so existing drivers + keep compiling. +6. **Each driver implements the new trait method** in its own backend. +7. **Add one entry to the `forward_ffi!` macro** in `src/forward_ffi.rs`, so all + drivers pick it up automatically. + +## Adding a new driver + +1. Create a new crate that depends on `stackable-odbc-core`. +2. Implement `Backend` + `StatementBackend` for your backend type. +3. In `lib.rs`, invoke + `stackable_odbc_core::forward_ffi!(crate::backend::YourBackend);`. No + `ffi.rs` is needed. + +### Backend error mapping + +Core never talks to a database; it only defines the trait boundary. A driver +must route **every** error from its client library through a single central +mapping function, never hand-building an `OdbcError` at the call site. That +function is the one place that decides the SQLSTATE, so bypassing it silently +degrades specific codes to `HY000`. + +Hand-built errors are correct only for *internal* invariant violations that +never came from the client (a `get_data` before `fetch`, a missing runtime +handle, a poisoned mutex), and for connection-setup failures where the call-site +context is more useful than a mapped variant. + +### 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"), which is the code the diagnostics tables of +`SQLExecute`, `SQLFetch`, `SQLGetInfo` and the rest actually list. A driver +whose `connect` performs no network I/O only ever sees post-connection +failures, so it maps them to `08S01`. A driver that opens a real connection in +`connect` is where `08001` legitimately originates. + +### A SQLSTATE only the data source can determine + +Some states are the driver's to return by the spec's `(DM)` rules, yet core +cannot produce them, because the fact they assert lives at the data source. +`3D000` ("invalid catalog name") is the clearest case. `SQLSetConnectAttr`'s row +carries no `(DM)` marker, but only the data source knows which catalogs exist, +and the attribute's description has the driver *send* something to find out +("the driver sends a **USE** *database* statement"). Core's part is threefold: +name the state (`SqlState::invalid_catalog_name`), call the hook, and propagate +what it returns unchanged. A backend that maps "no such catalog" to a generic +`HY000` is the only reason an application would not see `3D000`. + +- **A "not returned by this driver" doc line is a claim about the whole path, + not about core's own code.** Core can be propagating a state whose only source + is the backend, so reading core's own code answers the wrong question. +- **A pending connection attribute moves the SQLSTATE to a different function.** + `SQL_ATTR_CURRENT_CATALOG` and `SQL_ATTR_ACCESS_MODE` are settable either side + of a connection, and the spec says interoperable applications set them + *before*. Core therefore applies them during `SQLDriverConnectW`, so a hook + failure surfaces there, carrying a state that function's own diagnostics table + may not list (`3D000`, or `HYC00` from an unimplemented hook). Propagate it + rather than degrading it: a connection that failed because the catalog does + not exist should say so. + +### Prompting the user: core decides whether, the driver decides how + +A driver needing interactive authentication, an OAuth 2.0 external flow for +instance, implements `prompt::Prompter` and returns it from the defaulted +`Backend::prompter`. It reads it back inside its own `connect`, from +`ConnectParams::prompter()`, and **never** by calling `Backend::prompter` +directly: that method is ungated and says what the driver *could* do, not what +this call is allowed to do. + +The gate is `SQLDriverConnect`'s *DriverCompletion*, and it lives in exactly one +function, `prompter_for` in `ffi/connect.rs`. + +- **A withheld prompter is `None`, not an error.** Under `SQL_DRIVER_NOPROMPT` + the backend is simply handed nothing to call, so the spec's "do not prompt" + cannot be forgotten at a call site. A backend that finds `None` and needs a + prompt fails the connect the way the spec's own `SQL_DRIVER_NOPROMPT` clause + says: "otherwise, the driver returns SQL_ERROR." +- **`SQLConnect` and `SQLBrowseConnect` have no such argument, and absence + permits prompting.** `SQLConnect` is the DSN path (`isql` and Excel), so those + are the likeliest interactive callers of the whole driver. Reading the missing + argument as `SQL_DRIVER_NOPROMPT` would lock DSN connections out of + interactive authentication, and no spec text asks for it. +- **An unrecognised value is accepted.** `HY110` carries `(DM)` on *both* of its + clauses, so core adds no check, and the fallback is the most permissive + treatment rather than a driver-side error borrowed from a Driver-Manager row. + +Core ships no `Prompter` implementation and must not gain a dependency for one: +every implementation it could offer needs a platform (a browser, a window +system) that the database-independent half of a driver has no business +choosing. A Windows dialog implementation would belong next to +`SQLDriverConnectW`, but it needs its own design and its own dependency. The +trait is shaped so it can arrive later without changing the backend-facing API. + +### The catalog functions: core owns the result set + +The ten catalog `Backend` methods (`tables`, `columns`, `primary_keys`, +`foreign_keys`, `statistics`, `special_columns`, `procedures`, +`procedure_columns`, `column_privileges`, `table_privileges`) return **typed +row structs** (`TableRow`, `ColumnRow`, …), not a `Self::Statement`. Five +consequences for a driver author: + +- **Return the rows in any order.** Core sorts each result set into the order + its spec page mandates (`SQLTables` by `TABLE_TYPE, TABLE_CAT, TABLE_SCHEM, + TABLE_NAME`, and so on), with NULL placement from `Backend::null_collation`. + A driver needs no `ORDER BY` for ODBC compliance, so one added purely for it + can be deleted. +- **Core owns the column layout.** A backend fills named fields, so it cannot + get column order or count wrong, and a column added to a spec result set is a + core-only change. That is what `#[non_exhaustive]` on all ten row types buys. + It also rules out a struct expression outside core, including + `..Default::default()`, which Rust rejects cross-crate with `E0639`. Each type + therefore carries one consuming setter per column, generated from the same + field list by the `catalog_rows!` macro: + + ```rust + let row = TableRow::default() + .catalog(catalog) // Option column takes a bare String + .name(name) + .table_type("TABLE"); // String column takes a &str + ``` + + Setters take `impl Into` and are named after their field, so adding a + column adds a setter and breaks nothing. There is deliberately no positional + `new(...)`: the widest row types run to well over a dozen columns, so an + argument list would reintroduce the ordering mistake named fields exist to + prevent. +- **The `SQL_ALL_*` enumerations never reach these methods.** Core serves + `SQL_ALL_CATALOGS`, `SQL_ALL_SCHEMAS` and `SQL_ALL_TABLE_TYPES` from + `Backend::catalogs`, `Backend::schemas` and `Backend::table_types`, building + the all-but-one-column-NULL rows itself. The first two are only called when + `supports_catalogs`/`supports_schemas` already returned `true`. +- **`SQL_ATTR_METADATA_ID` is core's job.** When it is `SQL_TRUE`, core has + already stripped delimiters, case-folded per `identifier_case` and escaped + `%`/`_` per `search_pattern_escape` before calling the backend, so these + methods always see ordinary pattern values. `SQLTables`' `TableType` is the + one exemption in the family, because the spec makes it a value list under both + settings. Core parses it, and `tables` reads it back from + `query.table_types()` rather than as a raw string. +- **The arguments arrive as a typed query object.** Each hook takes a single + `&XxxQuery<'_>` (`TablesQuery`, `ForeignKeysQuery`, and so on) instead of five + to eight positional arguments, read through accessors: + + ```rust + fn tables( + conn: &Self::Connection, + cancel: &Self::CancelToken, + query: &TablesQuery<'_>, + ) -> Result, Self::Error> { + let _ = (query.catalog(), query.schema(), query.table(), query.table_types()); + todo!() + } + ``` + + These are sealed exactly as the row types are, so an argument added to a + catalog hook is a source-compatible change for every driver. They also name + the arguments: `SQLForeignKeys` takes six consecutive `Option<&str>`, where + swapping a primary-key argument for its foreign-key counterpart compiles + without complaint and `query.pk_table()` beside `query.fk_table()` cannot. + + Eight are built from `Default` plus `with_*` setters. The other two take the + arguments that have no honest default through `new` instead: + `StatisticsQuery::new(unique_only)`, because `false` means `SQL_INDEX_ALL` + rather than "unspecified", and + `SpecialColumnsQuery::new(identifier_type, scope, nullable)`, because no + `Scope` or `IdentifierType` value is a defensible default and core does not + invent one. + +The last four (`procedures`, `procedure_columns`, `column_privileges`, +`table_privileges`) are **defaulted to `Ok(Vec::new())`**, not to +`NotImplemented` like `primary_keys` and its neighbours. A data source with no +stored procedures, or no privilege metadata, genuinely has none to report, so an +empty result set is the honest answer where an error would not be. Override one +to report real rows. + +Their `HY009` handling is **not** uniform, and the difference is deliberate. +All four return it for the spec's `SQL_ATTR_METADATA_ID` + null-`CatalogName` + +catalogs-supported clause, which every one of the four pages states without a +`(DM)` marker. Only `SQLColumnPrivileges` additionally rejects a null +`TableName` unconditionally, because it is the only one of the four whose page +carries that sentence unmarked. `SQLTablePrivileges`, `SQLProcedures` and +`SQLProcedureColumns` must **not** check it. This mirrors the split among the +first six, where `SQLStatistics` and `SQLSpecialColumns` check a null +`TableName` and `SQLPrimaryKeys` and `SQLForeignKeys` do not. Tests pin both +directions; do not "fix" any of it into consistency. + +### Capability methods are required, not defaulted + +Most of `Backend` is defaulted, so a driver implements only what it needs. +These deliberately are not: + +| Method | States | +|--------|--------| +| `supports_catalogs` | whether the data source has ODBC catalogs | +| `supports_schemas` | whether it has ODBC schemas | +| `alter_table_support` | the `SQL_ALTER_TABLE` `SQL_AT_*` bitmask | +| `outer_join_capabilities` | the `SQL_OJ_CAPABILITIES` `SQL_OJ_*` bitmask | +| `default_txn_isolation` | `SQL_DEFAULT_TXN_ISOLATION` (`0` = no transactions) | +| `txn_isolation_options` | `SQL_TXN_ISOLATION_OPTION` (`0` = no transactions) | +| `group_by` | `SQL_GROUP_BY` (`0` = `GROUP BY` not supported) | +| `null_collation` | `SQL_NULL_COLLATION` (`0` = `SQL_NC_HIGH`) | +| `correlation_name` | `SQL_CORRELATION_NAME` (`0` = `SQL_CN_NONE`) | +| `non_nullable_columns` | `SQL_NON_NULLABLE_COLUMNS` (`0` = `SQL_NNC_NULL`) | +| `expressions_in_order_by` | `SQL_EXPRESSIONS_IN_ORDERBY` | +| `identifier_case` | `SQL_IDENTIFIER_CASE` (`SQL_IC_*`); `0` is not a legal value | +| `quoted_identifier_case` | `SQL_QUOTED_IDENTIFIER_CASE` (`SQL_IC_*`); independent of the unquoted rule | +| `txn_capable` | `SQL_TXN_CAPABLE` (`SQL_TC_*`); `0` = `SQL_TC_NONE`, contradicting any declared isolation level | +| `integrity` | `SQL_INTEGRITY`: whether the *data source* has the Integrity Enhancement Facility | +| `multiple_active_txn` | `SQL_MULTIPLE_ACTIVE_TXN`: whether two transactions can be live at once | +| `special_characters` | `SQL_SPECIAL_CHARACTERS`; an empty list is an answer, as with `keywords` | +| `accessible_procedures` | `SQL_ACCESSIBLE_PROCEDURES`, the counterpart of `accessible_tables` | +| `driver_name` / `driver_version` | `SQL_DRIVER_NAME` / `SQL_DRIVER_VER`; answered before a connection exists | +| `dbms_name` / `dbms_version` | `SQL_DBMS_NAME` / `SQL_DBMS_VER`: what this connection reached | +| `sql_conformance` | `SQL_SQL_CONFORMANCE` (`SQL_SC_*`) | +| `timedate_add_intervals` | `SQL_TIMEDATE_ADD_INTERVALS` (`SQL_FN_TSI_*`) | +| `timedate_diff_intervals` | `SQL_TIMEDATE_DIFF_INTERVALS` (`SQL_FN_TSI_*`) | +| `subqueries` | `SQL_SUBQUERIES` (`SQL_SQ_*`) | +| `column_alias` | `SQL_COLUMN_ALIAS` | +| `concat_null_behavior` | `SQL_CONCAT_NULL_BEHAVIOR` (`0` = `SQL_CB_NULL`) | +| `union_support` | `SQL_UNION` (`SQL_U_*`) | +| `convert_functions` | `SQL_CONVERT_FUNCTIONS` (`SQL_FN_CVT_*`) | +| `order_by_columns_in_select` | `SQL_ORDER_BY_COLUMNS_IN_SELECT` | +| `accessible_tables` | `SQL_ACCESSIBLE_TABLES` | +| `data_source_read_only` | `SQL_DATA_SOURCE_READ_ONLY` | +| `search_pattern_escape` | `SQL_SEARCH_PATTERN_ESCAPE` | +| `keywords` | the data source's own reserved words, *before* ODBC's are subtracted (`SQL_KEYWORDS`) | +| `table_types` | the data source's table types, for `SQLTables`' `SQL_ALL_TABLE_TYPES` enumeration | + +Each states a **capability**, so any default core invents is a claim the backend +author never made. It is also wrong silently: the author never sees the +question, and the application never sees anything but a confident answer. The +compiler asks instead. + +#### Attributes that reduce load at the data source are never emulated + +`SQL_ATTR_QUERY_TIMEOUT`, `SQL_ATTR_MAX_ROWS` and `SQL_ATTR_MAX_LENGTH` share +one shape, in `offer_to_data_source` (`ffi/stmt_attr.rs`): offer the value to a +defaulted `Backend` hook, store it if the backend accepts, substitute the +spec's default with `01S02` if the hook is unimplemented, and propagate any +*other* error as-is. Core emulates none of them, and the spec is explicit about +why for two of the three: "a driver should not emulate SQL_ATTR_MAX_ROWS +behavior", and `SQL_ATTR_MAX_LENGTH` "should be supported only when the data +source (as opposed to the driver) ... can implement it". Each row states the +purpose that makes emulation pointless, "this attribute is intended to reduce +network traffic". Counting rows or bytes in the driver, after they have crossed +the wire, achieves nothing the application asked for. + +`SQL_ATTR_QUERY_TIMEOUT` is the one with a core-side fallback, and it is opt-in +rather than automatic: `Backend::set_query_timeout` returns a `QueryTimeout`, +and only `CoreCancels` arms core's timer. Core cannot infer that. Every +statement-producing `Backend` method is synchronous and blocks the calling +thread, so `Backend::cancel` is the only lever, and whether a backend wired it +up is not observable from Rust. + +**The timer is armed at `SQLFetch` too, not only at the statement-producing +calls.** `SQL_ATTR_QUERY_TIMEOUT` bounds *returning the result set*, and a data +source is free to answer with column metadata long before it has computed a row, +so an execute-only timer can expire on nothing and bound nothing. `SQLFetch` and +`SQLFetchScroll` both carry `HYT00` with **no `(DM)` marker**, naming this +attribute directly. + +`SQLGetData` is the boundary, and the spec draws it: its diagnostics table +carries `HYT01` and **no `HYT00` row at all**, so it is deliberately unarmed. +The bound-column reads that run *inside* `SQLFetch` are a different thing and do +fall under that call's deadline. `SQLFetchScroll` needs no site of its own, +because every orientation but `SQL_FETCH_NEXT` is rejected with `HY106` and that +one delegates to `sql_fetch`. Before arming a further site, check the function's +own table for an `HYT00` row. + +Before adding a fourth attribute of this kind, check the spec row for a stated +*purpose*. If the purpose is to reduce work at the data source, the answer is a +hook plus the `01S02` fallback, not an implementation in core. + +#### Deciding whether a new info type belongs here + +The test is one question: **is zero "unknown", or is zero an answer?** + +- Zero means *unknown or no limit* → shared default in `default_get_info`. + `SQL_MAX_ROW_SIZE`, `SQL_MAX_INDEX_SIZE`, `SQL_MAX_STATEMENT_LEN` and the + `SQL_MAX_COLUMNS_IN_*` group are all of this kind: the spec explicitly + defines `0` as "no specified limit or the limit is unknown", so a shared `0` + asserts nothing. +- Zero is a *substantive claim* → required `Backend` method. Every enum in the + table above has this shape. `SQL_NULL_COLLATION`'s zero is `SQL_NC_HIGH`, + `SQL_CORRELATION_NAME`'s is `SQL_CN_NONE` and `SQL_NON_NULLABLE_COLUMNS`'s is + `SQL_NNC_NULL`, each a specific, falsifiable statement about the data source + that core has no way to know. + +Two corollaries worth checking when adding an info type: + +- **A Y/N string has no valid empty value.** The shape-aware fallback in + `info_type_default_response` gives an unhandled `String`-shaped info type + `""`, which is the right *shape* but is not in any Y/N value list. Such a + type needs either a shared `"N"` arm in `default_get_info` or a hook. +- **An empty *list* is an answer too.** `SQL_KEYWORDS` reads as an empty + string just like an unhandled `String`-shaped type, but it means "this data + source reserves nothing beyond ODBC", which applications act on when deciding + what to quote. It is a `Backend::keywords` hook for that reason; core owns + only the spec's subtraction of `ODBC_RESERVED_KEYWORDS`, which is the same for + every backend. +- **Watch for info types that constrain each other.** `SQL_SQL_CONFORMANCE` + fixes the value of `SQL_GROUP_BY`, `SQL_CORRELATION_NAME`, + `SQL_NON_NULLABLE_COLUMNS`, `SQL_CONCAT_NULL_BEHAVIOR`, `SQL_SUBQUERIES` + and `SQL_COLUMN_ALIAS`, because the spec names what an entry-level driver + returns for each of those six. `SQL_TIMEDATE_FUNCTIONS` claiming + `SQL_FN_TD_TIMESTAMPADD` obliges `SQL_TIMEDATE_ADD_INTERVALS` to be non-zero, + and `SQL_CATALOG_NAME` drives the whole catalog group. Core supplying one side + of such a pair while the backend supplies the other is how it ends up + contradicting itself. +- **Prefer deriving over adding a hook when the fact is already declared.** + `SQL_IDENTIFIER_QUOTE_CHAR` comes from `EscapeDialect::identifier_quotes` + and `SQL_CURSOR_COMMIT_BEHAVIOR` from `Backend::cursor_commit_behavior`, + because a second way to state the same fact is a second way to state it + *differently*. Check whether an existing hook already answers the question + before adding one. + +#### The rule is enforced by a test, not by review + +`default_get_info_answers_are_backend_derived_or_declared_core_facts` +(`src/backend.rs`) asks one question of every info type: **does the answer move +when the backend does?** It evaluates `default_get_info` for two mock backends +that share no capability declaration. An info type answering identically for +both is one core decided, so it must appear in that test's `CORE_FACTS` list +with the reason core is entitled to decide it. Three reasons qualify: a fact +about core's own implementation (its fetch really is forward-only, its `Backend` +trait really is synchronous), a limit where the spec defines `0` as "no limit or +unknown", or driver-level identity with no per-backend answer. + +Adding a hard-coded claim to `default_get_info` therefore fails a test that +names the info type. If you cannot write a `CORE_FACTS` reason that is about +*core* rather than about the data source, the value belongs on a `Backend` +method. + +`supports_catalogs` and `supports_schemas` between them drive seven info types +(`SQL_CATALOG_NAME`, `SQL_CATALOG_TERM`, `SQL_CATALOG_NAME_SEPARATOR`, +`SQL_CATALOG_LOCATION`, `SQL_CATALOG_USAGE`, `SQL_SCHEMA_TERM`, +`SQL_SCHEMA_USAGE`), which the `SQLGetInfo` spec defines in terms of that one +fact. Note the asymmetry: core answers the whole group when the answer is *no*, +because the spec mandates the empty string or zero. When the answer is *yes*, it +returns `None` for `SQL_CATALOG_LOCATION`, `SQL_CATALOG_USAGE` and +`SQL_SCHEMA_USAGE` rather than inventing a value, so a driver with catalogs +answers those three itself. + +`Backend::set_txn_isolation` stays defaulted, and the default is only correct +for a data source with exactly one isolation level. A backend declaring more +than one bit in `txn_isolation_options` **must** override it, or +`SQLSetConnectAttr(SQL_ATTR_TXN_ISOLATION)` reports `NotImplemented` rather +than accepting a level it cannot apply. + +### Windows Driver Manager compatibility checklist + +The Windows DM is much stricter than unixODBC. These items are **required** for +a driver to work on Windows, because omitting any one can cause silent crashes, +`IM001` errors, or blocked `SQLGetData` calls: + +- **The pre-connect info group is core's job, not a checklist item.** The + Windows DM queries `SQL_DRIVER_ODBC_VER` (77) *before* `SQLDriverConnectW`, + and on `SQL_ERROR` treats the driver as ODBC 2.x and blocks 3.x features like + `SQL_C_SBIGINT`. Core answers the whole group without a connection: + `SQL_DRIVER_NAME` and `SQL_DRIVER_VER` from the required + `Backend::driver_name` and `Backend::driver_version`, and + `SQL_DRIVER_ODBC_VER`, `SQL_ASYNC_DBC_FUNCTIONS` and + `SQL_MAX_CONCURRENT_ACTIVITIES` from facts about itself. Declaring the two + hooks is all a driver does. Overriding `get_info_pre_connect` is only for a + *further* info type it can answer before connecting, which is rare. + +- **`get_functions`**: List **every** exported FFI function, not just + query-related ones, and **nothing core does not export**. The Windows DM uses + the 3.x bitmap (`func_id=999`) to build its dispatch table, so a missing entry + (`SetEnvAttr`, `GetStmtAttr`, `BindCol`) gives it a null function pointer to + call. Build the list from `CORE_EXPORTED_FUNCTIONS` and it cannot drift in + either direction. + + The 2.x array (`func_id=0`) is a **different question with a different + answer**. It asks "can an ODBC 2.x application call this", so it reports the + deprecated functions as supported even though core exports almost none of + them, because the Driver Manager's mapping is what makes that true. + `stackable-odbc-core` derives those entries from their 3.x counterparts + automatically. An entry there naming a `FunctionId` absent from + `CORE_EXPORTED_FUNCTIONS` is correct and deliberate, not an oversight; + psqlODBC ships the same combination (`pfExists[SQL_API_SQLERROR] = TRUE` + beside a commented-out `;;SQLError` in its `.def`). + +- **`get_type_info`**: Include **both** ANSI and Unicode type variants. + pyodbc queries `SQLGetTypeInfo(SQL_VARCHAR=12)` and + `SQLGetTypeInfo(SQL_CHAR=1)`. If only `SQL_WVARCHAR` (-9) and `SQL_WCHAR` + (-8) are returned, pyodbc cannot perform type conversions and `SQLGetData` + fails for numeric types. + +- **`SQL_GETDATA_EXTENSIONS`**: Report exactly what the shared + `stackable-odbc-core` fetch/bind implementation supports; do not reflexively + return `0x0F`. `SQL_GD_ANY_COLUMN | SQL_GD_ANY_ORDER | SQL_GD_BOUND` (`0x0B`) + is correct for a forward-only driver, because `sql_get_data` + (`src/ffi/fetch.rs`) never checks column order or binding state, so any + column, in any order, bound or not, can be read via `SQLGetData`. + `SQL_GD_BLOCK` must **not** be included unless the driver implements block + cursors: `SQLSetStmtAttrW` (`src/ffi/stmt_attr.rs`) rejects any + `SQL_ATTR_ROW_ARRAY_SIZE` other than 1 (substituting 1 back with `01S02`), so + a driver that inherits that behaviour can never produce a multi-row rowset for + `SQL_GD_BLOCK` to describe. + +- **Unknown `SQLGetInfoW` info types**: `stackable-odbc-core` returns `U32(0)` + for unknown info types, because returning `SQL_ERROR` corrupts the DM's + internal state. For the genuine per-source-type `SQL_CONVERT_*` info types it + returns `0xFFFFFFFF` ("all conversions supported"), because returning 0 causes + the DM to block `SQLGetData` with `HYC00`. + + That set is **53–71, 122–126 and 173**, not the contiguous 48–73 the numbering + suggests. The gap matters: 48 is `SQL_CONVERT_FUNCTIONS`, a bitmask of whether + `CAST`/`CONVERT` syntax is supported at all, and 49–52 are the + numeric/string/system/timedate scalar-function bitmaps. Answering "all + supported" for those claims scalar functions the backend may not have, which + is how a BI tool comes to emit `{fn SOUNDEX(x)}` against a data source that + rejects it. `info_type_default_response` classifies them individually against + `sqlext.h` for exactly this reason. + +#### A 3.x driver does not export the deprecated 2.x functions + +Appendix G, "Mapping Deprecated Functions": a 3.x driver "does not have to +implement the ODBC 2.x functions", and the mapping "is triggered when the driver +is an ODBC 3.x driver and **the driver does not support the function that is +being mapped**." + +So exporting one does not *add* a capability, it **removes the Driver +Manager's**, which is usually better informed. unixODBC's `SQLSetScrollOptions` +mapping checks the requested concurrency against the driver's own `SQLGetInfo` +answers before setting anything, where a driver-side export is a bare +`SQL_ERROR` that replaces all of it. `SQLError`'s mapping routes to +`SQLGetDiagRec`, which core implements properly, where an export would answer +`SQL_NO_DATA` and leave an ODBC 2.x application with no diagnostics at all. + +Core therefore exports no function whose ODBC 2.x call the Driver Manager maps, +and psqlODBC comments out every one of them in its `.def`. Two exports sit near +that line: + +- **`SQLFreeStmt` is an ODBC 3.x function in its own right.** Appendix G covers + only its deprecated `SQL_DROP` option, which the Windows DM passes through + rather than mapping, so core exports the function. +- **`SQLExtendedFetch` is deprecated but unmapped.** Appendix G's table does not + list it, so exporting it displaces no Driver Manager capability, and an ODBC + 2.x application reaches a real implementation only if the driver provides one. + +Before implementing any deprecated entry point, check +`CORE_UNEXPORTED_FUNCTIONS`: each entry records which 3.x function the DM maps +it to. "We export it, so we should make it work" is backwards whenever the +Driver Manager already maps the function. + +## Descriptors + +A statement owns four descriptors, the ARD, APD, IRD and IPD, and ODBC makes +them the *definition* of a binding rather than a copy of one. `SQLBindCol`'s +page: "when `SQLBindCol` is called, the driver sets fields in the ARD." So +there is one storage, not a binding map beside a descriptor: + +| Descriptor | Reached by | Records | What they are | +|---|---|---|---| +| ARD | `desc_of(stmt, Ard)` | `DescriptorRecord` | what `SQLBindCol` set | +| APD | `desc_of(stmt, Apd)` | `DescriptorRecord` | `SQLBindParameter`'s C-side buffer | +| IPD | `desc_of(stmt, Ipd)` | `DescriptorRecord` | `SQLBindParameter`'s declared SQL type | +| IRD | `desc_of(stmt, Ird)` | none stored | computed from `ColumnDescriptor` on read | + +Each is its own registered allocation rather than a field of the statement, and +the two application descriptors may be replaced by one the application +allocated; see "Reaching a descriptor" below. + +`Descriptor` carries a `role: DescriptorRole` rather than a type parameter, +because ODBC has one record shape and four *readings* of it. `SQLSetDescField` +accepts any field identifier against any descriptor and decides validity from +the role. + +- **`SQLBindParameter` writes two descriptors.** The C-side fields are an APD + record and the declared type is an IPD record, under the same key, removed + together. One record spanning both is what makes `SQLSetDescField` + unimplementable. Readers take `ParamRecord<'_>`, a borrowed view of both + halves, from `ParamRecords::get`. +- **The IRD is a computed view, never stored state.** `SQLGetDescField` and + `SQLGetDescRec` on the IRD delegate to `col_attr::get_column_attribute`, + which is also `SQLColAttributeW`'s implementation. The two are spellings of + one question, and answering them from two places is how they come to differ. + A read before the statement has produced column metadata is `HY007`; the + spec: "Until the IRD has been populated, any attempt to gain access to a + field of an IRD will return an error." A write is `HY016`, except the two + header fields that row exempts by name. +- **A binding is a non-null `SQL_DESC_DATA_PTR`, not a present key**, but that + answers "is there a data buffer", not "is there a binding". A record exists + as soon as any one field is set, so key presence answers neither question, + and every site needing the first calls `DescriptorRecord::is_bound`. The + second has *two* pointers in it: the spec lets `SQLBindCol` unbind a column's + data buffer while keeping its length/indicator buffer ("An application can + unbind the data buffer for a column but still have a length/indicator buffer + bound for the column"). So `collect_bindings` admits a record carrying either + pointer, and skips only a record carrying neither. The mature drivers split + on this, MySQL Connector/ODBC keeping such a record and psqlODBC clearing the + whole binding, and core follows the spec sentence, which is unconditional. + The visible half of getting this wrong is the *indicator*: + `write_column_value` declines to write through a null target but writes the + length indicator unconditionally, which is exactly what makes the + indicator-only binding work and exactly what makes a stray record visible. +- **`set_concise_type` is the only writer of the type trio.** Setting + `SQL_DESC_CONCISE_TYPE` also sets `SQL_DESC_TYPE` and + `SQL_DESC_DATETIME_INTERVAL_CODE`, and the subcode is **not** the concise + type: `SQL_TYPE_DATE` is 91 while `SQL_CODE_DATE` is 1. `col_attr` holds both + mappings (`verbose_type` and `datetime_interval_subcode`) so the descriptor + and `SQLColAttribute` cannot disagree about one column. +- **Eight statement attributes are descriptor header fields**, per + `SQLSetStmtAttr`'s own mapping table, which says setting one sets the other. + `HeaderOwner::of` names them, and `HandleScope::attr_get`/`attr_set` is the + only way to reach an attribute's storage. `descriptor::header_attribute` is + the same table read in the other direction, for `SQLGetDescField`. The four + IRD- and IPD-side pairs (`SQL_ATTR_ROW_STATUS_PTR`, + `SQL_ATTR_ROWS_FETCHED_PTR`, `SQL_ATTR_PARAM_STATUS_PTR`, + `SQL_ATTR_PARAMS_PROCESSED_PTR`) live on `stmt.attrs`, and `attr_get` routes + them there so no caller needs to know. + **The storage is keyed by the `SQL_DESC_*` field, not by the attribute**, + because the mapping is not one-to-one: `SQL_DESC_ARRAY_SIZE` is + `SQL_ATTR_ROW_ARRAY_SIZE` on an ARD and `SQL_ATTR_PARAMSET_SIZE` on an APD. + One explicit descriptor may also be the ARD of one statement and the APD of + another, so two keys for one field would be two values for one field. +- **`odbc-sys` misspells one of the eight.** `SQL_ATTR_PARAM_OPERATION_PTR` is + `StatementAttribute::ParamOpterationPtr`, transposed letters, upstream. A + grep for the correct spelling finds nothing and reads as "core does not + implement it", which is false. + +### The consistency check runs at all four sites + +`descriptor::consistency_check` returns `HY021`, and `SQLSetDescRec`'s own +"Consistency Checks" section says when it runs: "This check is always performed +when **SQLBindParameter** or **SQLBindCol** is called or when **SQLSetDescRec** +is called for an APD, ARD, or IPD", plus `SQLSetDescField` when it sets +`SQL_DESC_DATA_PTR`. + +**`SQLBindCol` and `SQLBindParameter` both run the check, so either can return +`HY021`.** Each function's doc comment lists all five of the spec's clauses and +states which core reduces and why. + +**Clause 5 is checked.** It reads "if `SQL_DESC_CONCISE_TYPE` is an interval +type, `SQL_DESC_DATETIME_INTERVAL_PRECISION` is a valid interval leading +precision". The *C to SQL: Numeric* table's interval row reads that field, so +core enforces it: a leading precision is a digit count and cannot be negative, +while zero passes and means the application declared none. Reading zero as +"unspecified" rather than as a literal limit is the same reading +`check_declared_decimal_size` gives a zero `ColumnSize`, and the conversion +relies on it. The clauses about interval *seconds* precision remain reduced. + +A value core cannot honour must be refused identically through both doors: +`SQL_DESC_ARRAY_SIZE` set through `SQLSetDescField` routes through the same +`01S02` substitution `SQLSetStmtAttr(SQL_ATTR_ROW_ARRAY_SIZE)` applies, because +they are one value. + +### Reaching a descriptor + +**Every descriptor is its own registered allocation.** A statement holds four +tokens, not four `Box` fields, plus two `Option` overrides for the +application descriptors. An explicit descriptor is parented to the +**connection** and an implicit one to its statement. All of them join the +connection's lock group, the one every statement on it already shares, so a +descriptor adds no lock and no ordering rule. + +- **`Descriptor` has a `HasKind` impl.** A token names exactly one descriptor, + and the struct at that address carries its own `role`, so `HandleScope::get` + needs nothing the registry cannot check. +- **`HandleScope::stmt_with_desc` is sound**, on the same footing as + `stmt_with_parent`: the statement holds opaque tokens the compiler cannot + follow, and a `Descriptor` holds no back-pointer, so neither is reachable + from the other. `stmt_with_parent_and_params` is the three-way form of the + same argument, for the calls that need a statement, its connection and both + parameter descriptors at once. +- **`Drop` does not reclaim them.** `free_statement_allocation` frees the four + explicitly, `SQLFreeHandle` frees an explicit one, and `SQLDisconnect` frees + any left on the connection. Miri's leak check is what enforces all three. + +`HandleScope::desc_of` is the single door onto descriptor storage: it applies +the override, so no call site can read the implicit descriptor while the +application believes its own is in use. A site that already resolved the +statement should copy `descriptor_token(role)` out and use +`HandleScope::descriptor` instead, because going through `desc_of` there +resolves the statement a second time, which `handle_lookup` measures. + +A `Descriptor` is never reached by casting an address, only through the +registry, as every other handle kind is. + +### The explicit-descriptor rulings, and why + +Four questions a future reader would otherwise relitigate: + +- **`HY024` is core's; `HY017` is not.** `SQLSetStmtAttr`'s `HY024` row states + the cross-connection descriptor case verbatim and closes with the general + rule that makes it core's: "For all other connection and statement + attributes, the driver must verify the value specified in *ValuePtr*". + `HY017` is `(DM)` on *both* of its clauses, so core adds neither check. The + second clause's "other than the handle originally allocated" implies the + original *is* allowed, so it is accepted. The check core makes compares the + parent **chain**, so a descriptor of this connection and one of this + connection's statements both pass. +- **`SQLFreeHandle` answers `HY000` on the ownership branch, never `HY017`.** + It routes by parentage rather than by alloc type: this function allocated the + descriptors whose parent is a connection and only those. Retiring a + statement's own slot would leave that statement pointing at nothing. The + refusal is ownership, not a spec check, and borrows no `(DM)` code to say so; + the same function already answers `HY000` for an unimplemented handle type, + whose table lists no `HYC00` either. A token that is not a descriptor at all + is `SQL_INVALID_HANDLE`, which is a different question. +- **`SQLCopyDesc` never holds two group locks.** The spec permits a copy across + connections and even across environments, so source and target may be in two + groups. Phase one takes the source's group through `HandleScope::with_group` + and materialises an owned `DescriptorSnapshot`; that function's return type + carries no guard, so the release before phase two is structural rather than + remembered. Phase two is an ordinary `panic_safe` on the target, which is + where every diagnostic belongs, including the `HY007` phase one decided. + `opposite_direction_copies_cannot_deadlock` models it, and + `the_set_of_group_lock_acquisition_sites_is_closed` records the site. +- **A shared descriptor means shared bindings.** Two statements pointed at one + explicit ARD have one binding set between them, so `SQLFreeStmt(SQL_UNBIND)` + on either clears both. That is spec-correct, since the spec makes the + descriptor *be* the binding, and it has a test rather than a workaround. + +Two smaller ones. `SQL_DESC_ALLOC_TYPE` follows the allocation, and is the one +field `SQLCopyDesc` never copies. `DescriptorSnapshot` carries neither it nor +the *source's* role, because the consistency check runs under the **target's** +role, and a snapshot that remembered where it came from would invite a check +against the wrong one. + +### What descriptors support + +All five descriptor functions are implemented and reported by `SQLGetFunctions`: +`SQLGetDescFieldW`, `SQLSetDescFieldW`, `SQLGetDescRecW`, `SQLSetDescRec` and +`SQLCopyDesc`. `SQLAllocHandle(SQL_HANDLE_DESC)` and +`SQLFreeHandle(SQL_HANDLE_DESC)` work, an application descriptor can be swapped +in through `SQL_ATTR_APP_ROW_DESC` / `SQL_ATTR_APP_PARAM_DESC`, and one +descriptor may be shared across statements on a connection. + +`DescriptorRole` has a fifth variant, `App`, for an explicitly allocated +descriptor whose role is not yet known. The spec: "it is not known whether an +explicitly allocated application descriptor is an APD or ARD until execute +time". `field_access(App, f)` is defined as the ARD's cell, and +`the_ard_and_apd_field_tables_agree_everywhere` is what makes that a derived +fact rather than a fourth hand transcription. + +**`SQL_OIC_CORE` is satisfied.** Core-level conformance requires allocating and +freeing all handle types and manipulating descriptor fields through all five +functions, which is what the above closes. + +Still out of scope, deliberately: bookmark records (record 0), and automatic +population of the IPD. `SQL_ATTR_AUTO_IPD` stays `SQL_FALSE`, so the five +footnote-[1] fields stay `Undefined` on the IPD. + +## Concurrency: the lock discipline + +Handle contents are internally synchronised, per connection, not left to the +Driver Manager. `SQLAllocHandle`'s Comments section requires this: "Drivers +must therefore support safe, multithread access to this information." The +mechanism: + +- **A lock group is per connection, shared with every statement and descriptor + allocated on it.** One acquisition therefore covers a call that touches a + statement and its parent connection, so there is no ordering between the two + to get wrong. Groups are `GroupLock` (`src/handles/registry.rs`), and which + group a token belongs to is derived from the registry rather than stored on + the handle. +- **`HandleScope` is the only way to reach a handle's contents.** `panic_safe` + (`src/panic.rs`) locks the target's group before constructing the scope and + ties the scope's lifetime to that lock. So "the group lock is held" is a fact + the borrow checker enforces rather than a rule a comment states. Every other + caller of `HandleScope::new` does the same, and `handles/scope.rs`'s module + doc is the authoritative list of them. +- **A `Backend` method must never re-enter a `SQLxxx` entry point on the same + connection.** Every `Backend` method, including `connect`, runs while + `panic_safe` holds that connection's group lock, and the lock is not + reentrant. Calling back in, directly or through an application callback, + deadlocks the calling thread with no diagnostic and no `SqlReturn`, because + the thread never returns far enough to produce either. `Backend::cancel` is + the one exception, covered below. +- **The one lock-ordering rule is environment before connection**, and + `SQLEndTran(SQL_HANDLE_ENV)` is its only site: it holds the environment's + group while walking that environment's connections via + `HandleScope::with_child_group`. Do not acquire a connection's group first + and then reach for its environment's; nothing else in the crate nests two + groups at all. +- **`SQLCancel` is deliberately exempt from taking the group lock.** It may run + on a thread other than the one executing on the target statement. Taking that + statement's group lock unconditionally would make cancelling a query wait for + the query it was asked to cancel. Instead it clones the statement's + cancel token out of the registry, then attempts the group with `try_lock`. On + the branch where another thread holds the group, cancel signals the backend's + `CancelToken` and returns, touching no handle state and posting no + diagnostic, per the spec's carve-out for a function running on another + thread. + + A `CancelToken` carries the crate's one bounded exception to "core never + touches a backend's state concurrently", so it has two obligations. It must + be built eagerly, with the connection's real parameters in hand, at first use + rather than lazily inside `cancel`; see `Backend::cancel_token`'s doc comment + for the MariaDB ODBC-401 failure this rule prevents. And if it aliases the + connection rather than standing alone, it must keep its target alive through + an `Arc`, because core clones the token out before doing anything else and it + must survive a concurrent `SQLDisconnect`. + + Two consequences follow from running the cross-thread branch lock-free. A + `SQLGetDiagRecW` or `SQLGetDiagFieldW` immediately after such a cancel + **blocks** until the cancelled call has unwound through the backend, because + both take the connection's group and reading the diagnostic queue while + another thread pushes to it is undefined behaviour. `SQLCancel` itself still + returns promptly; the wait moves to whichever call reads diagnostics next. + Separately, `try_lock` cannot tell "a sibling statement on this connection is + busy" from "my own statement is busy". Either pushes `SQLCancel` onto the + cross-thread branch, so a merely idle statement's data-at-execution state is + occasionally left uncleared where it strictly could have been cleared, which + is harmless and explicitly spec-legal. +- **A cancelled call reports `HY008`, and the token is minted per execution.** + `Backend::cancel` signals and `Backend::is_cancelled` observes, and they are a + pair. A backend implementing the first and not the second still cancels the + work, but the application sees whatever SQLSTATE the driver's error mapping + produced instead of "operation canceled". Core asks `is_cancelled` **only + after a backend call returned an error**, because the spec permits a + cancelled execution to finish anyway ("it is possible for the execution to + succeed and return SQL_SUCCESS while the cancel is also successful"), so `Ok` + is never reclassified. The single implementation is `crate::cancel`. + + `mint_cancel_token` builds a **new** token at every statement-producing call, + and the cursor-consuming calls read that execution's token rather than + minting one. One token per statement would leave a cancelled statement + permanently unusable, because `Backend::cancel` marks the token and the next + execution would reuse it. The spec requires the opposite: "After the + statement has been canceled, the application can call SQLExecute or + SQLExecDirect again." Keeping a stale token as a guard buys nothing either, + because "a call to SQLCancel when no processing is being done on the + statement ... has is [sic] no effect at all." +- **`SQLCancel` is not the only cross-thread caller of `Backend::cancel`.** + `src/query_timer.rs` enforces `SQL_ATTR_QUERY_TIMEOUT` for a backend that + answered `QueryTimeout::CoreCancels`, and it does so the only way a + synchronous trait allows: a timer thread calls `Backend::cancel` while the + calling thread is still blocked inside the backend. It holds **no lock**, the + same footing as `SQLCancel`'s cross-thread branch, so the rule that a + `cancel` implementation must never block on this connection's lock covers it + unchanged. It clones the token out of the registry for the same reason too, + because the token has to survive a statement freed while a timer is still + armed. + + A timed-out call reports **`HYT00`**, not `HY008`. Both arrive through a + signalled token, so the ordering in `QueryTimer::check` is load-bearing: the + cancel pass runs first and would label it `HY008`, and the timeout pass runs + second so the more specific state wins. An application that set a deadline is + waiting to tell "my deadline passed" from "another thread cancelled me". + + **The attribution outlives the call that made it**, and has to. A deadline + that expires as the backend call is returning cancels the token and leaves + the call successful, so the failure it causes surfaces on a *later* call + whose own timer never fired. `cancel::CancelState` is therefore what the + registry stores: the backend's token plus core's `timed_out` flag, in one + allocation. The flag is minted per execution and survives a freed statement + exactly as the token does. `QueryTimer::reclassify` reads it, which confines + `HYT00` to the entry points that hold a timer. + + `cancel::reclassify_cancelled` deliberately does not read it. The entry + points that reach it without a timer, `SQLGetData`, `SQLDescribeParam`, + `SQLDescribeCol` and `SQLColAttribute`, have no `HYT00` row between them, so + those keep `HY008` on a timed-out cursor. Do not shorten that to "the + timer-holding entry points are exactly the ones with an `HYT00` row": + `SQLParamData` holds a timer and relabels, and its own table has no such row. + It inherits one from the sentence after its table ("it can return any + SQLSTATE that can be returned by the function called to execute the + statement"), which is the same grant its doc comment records other inherited + states under. Check a function's table *and* its surrounding prose before + deciding either way. +- **Every lock in the crate is imported from `src/sync.rs`**, never directly + from `std::sync`, so that building a test with `--cfg loom` swaps every one + of them for loom's instrumented equivalent. A lock imported around that + module would be invisible to loom and silently opt its code out of the + interleaving proof. + + **Two exceptions, each stated at its site and in `sync.rs`.** + `query_timer.rs` takes its `Condvar` and `Mutex` from `std::sync`, because + loom's `Condvar` cannot model a timeout at all; `logging.rs` takes its + `Mutex` from `std::sync`, because `tracing_subscriber` implements + `MakeWriter` for that type specifically. `src/sync.rs`'s own comment carries + the detail. The rule being enforced is "no lock silently opts itself out", so + a stated exception does not break it and a quiet one would. Before adding a + third, check whether loom can model the primitive at all: if it can, import + it from `sync.rs`. + +**Loom models** the primitives this discipline is built from, `Registry`, +`GroupLock`, and the crate's own nested-lock path +(`HandleScope::with_child_group_in`), in `src/handles/registry.rs`'s +`#[cfg(all(test, loom))] mod loom_tests`. It does not model the FFI entry +points above them, since `registry()` panics outside an active `loom::model` +and cannot be called from inside one either. loom replays the same closure many +times to explore interleavings, while a `static` only runs its initializer +once. + +**`with_child_group` has an `_in` variant taking a `&Registry` so a model can +call the real function.** A model that can only reach a function by +re-implementing what it does proves a property of the test rather than of the +crate. Before accepting "the model cannot reach this", check whether a +`&Registry` parameter is all that stands in the way. + +Run them with: + +```bash +RUSTFLAGS="--cfg loom" cargo test --lib loom_tests +``` + +The `loom_tests` filter is required: every other unit test in the crate also +compiles under `--cfg loom` once it is set, and calls the process-wide +registry outside a model, which panics as soon as `Registry::new` resolves to +loom's `RwLock`. If a model runs long, lower `LOOM_MAX_PREEMPTIONS` before +simplifying the model itself, because a smaller bound still proves more than no +model. CI sets it in `.github/workflows/build.yaml`. + +## Testing + +### Unit tests + +Run `cargo test`. It must produce zero warnings. + +- **Mocks live in `test_utils.rs`.** `MockBackend` is the shared default: + connect and disconnect succeed, everything else returns `NotImplemented`. + Purpose-built mocks cover the paths it cannot reach, among them + `MockAltBackend`, `MockNoCatalogBackend`, `MockTypeInfoBackend`, + `MockFunctionsBackend`, `MockFailingCloseBackend` and the + `mock_isolation_backend!` / `mock_txn_backend!` families. Grep `struct Mock` + and `mock_` in that file for the current set. +- **`MockAltBackend` declares a different value for every capability method**, + so a guard test can watch an answer move with the backend rather than passing + against a constant. +- **A mock returning an empty slice makes a loop run zero times**, so + `MockTypeInfoBackend` and `MockFunctionsBackend` declare real rows and a real + function list rather than letting a test pass vacuously. +- **Array fetch and batch parameter paths** (`SQL_ATTR_ROW_ARRAY_SIZE`, + `SQL_ATTR_ROWS_FETCHED_PTR`, `SQL_ATTR_PARAMSET_SIZE`) are covered by direct + C ABI calls with pre-allocated column and parameter buffers, which Rust + handles cleanly without any external dependencies. +- **A driver crate tests its own `Backend` impl**, and adds FFI-level + integration tests that call the generated C ABI entry points. Those live in + the driver's repository, not here. + +### Miri (undefined behaviour and leaks) + +`stackable-odbc-core` is checked by Miri on every PR (the `miri` job in +`.github/workflows/build.yaml`). Run it locally the same way: + +```bash +rustup +nightly component add miri +MIRIFLAGS="-Zmiri-disable-isolation" \ + cargo +nightly miri test -p stackable-odbc-core --lib -- --skip proptest +``` + +Runtimes move with every commit, so take them from the run rather than from +here. The CI job budgets 30 minutes and has come close to spending it, so when +a run slows down put `-Z unstable-options --report-time` after the `--`: the +per-test breakdown names the test responsible. + +- **Nightly only.** Miri cannot run on the pinned stable toolchain. +- **Pure Rust.** All the raw-pointer marshalling lives in + `stackable-odbc-core`, so it is where the undefined-behaviour risk lives and + where Miri earns its keep. +- **Proptests are skipped**, because they take hours under Miri. They run on + stable instead. +- **A test whose cost is algorithmic rather than memory-safety-related gets + `#[cfg_attr(miri, ignore = "…")]`.** Miri's slowdown turns a large input into + minutes or hours, against a CI budget of 30. The precedent is + `escape::tests::pathological_nesting_returns_an_error_rather_than_killing_the_process`, + and skipping it loses nothing: `src/escape.rs` contains no `unsafe` for Miri + to check, and the neighbouring `MAX_ESCAPE_DEPTH ± 1` tests cover the limit + on both recursion paths. Before adding a big-input test, ask whether the code + under it is `unsafe` at all; if not, Miri is not the tool that should be + paying for it. + + **A big input does not have to look big.** The guards in + `types/diagnostics_table.rs` take no parameters and read no files at runtime, + so nothing about them reads as expensive, yet each scans the FFI source that + module `include_str!`s and Miri interprets that byte by byte. Together they + are the largest single cost in the run, in a module containing no `unsafe` + whatsoever. Watch for `include_str!`, a full-`u16`-space scan, or any other + input baked in at compile time rather than passed in, because the native + runtime will not warn you: Miri multiplies the ratio, not the absolute time. + Every guard there carries `#[cfg_attr(miri, ignore = …)]`, and a further + scanner added to that module needs one too. +- **Leak reporting is deliberately left on.** It is what catches a handle or + descriptor allocation that a teardown path forgets to free. A test that + allocates handles must free them, or the job goes red. +- **A run after any source change rebuilds the crate under Miri first**, and + that rebuild dominates. Budget for it before assuming a run has hung. +- **`-Zmiri-disable-isolation` is required, not optional.** + `column_value::current_utc_date` reads the wall clock, which the + `SQL_TYPE_TIME` to `SQL_C_TYPE_TIMESTAMP` conversion needs ("the date fields + of the timestamp structure are set to the current date"). Without the flag + Miri refuses `SystemTime::now` as an unsupported operation and the test + aborts rather than failing an assertion, which reads like a Miri bug rather + than a missing flag. + +### Alignment: what Miri does and does not catch + +Every access through an application-supplied pointer must be `read_unaligned` / +`write_unaligned`, a byte-wise copy, or an element-wise loop. ODBC applications +using row-wise binding pass pointers at arbitrary offsets into a packed buffer, +so alignment is never guaranteed. Four operations carry an alignment +requirement, and all four have been the source of real bugs here: + +| Operation | Requirement | +|-----------|-------------| +| `*ptr = v` / `*ptr`, including `*(p as *mut T) = v` | aligned for `T` | +| `slice::from_raw_parts(_mut)` | aligned for the element type; **UB on construction, before anything is read** | +| `ptr::copy_nonoverlapping` | *both* pointers aligned for `T`; cast to `*mut u8` to avoid it | +| `&*(p as *const T)` | aligned for `T` | + +`u8` pointers are exempt, because `u8` has alignment 1. + +**Grep for the operation, not for `*ptr`.** A deref of a cast +(`*(diag_info as *mut i32) = v`) and a multi-line `unsafe` block both evade the +obvious pattern, and `from_raw_parts` looks nothing like a deref at all. + +**A misaligned access is only *sometimes* observable on x86-64.** With +`debug-assertions` on, four of the five shapes abort the process: the standard +library's precondition check fires and raises a **non-unwinding** panic, which +`panic_safe`'s `catch_unwind` cannot contain. + +| Shape | Detected by `cargo test` (debug) | +|---|---| +| `*ptr = v` / `*ptr`, including through a cast | yes, abort | +| `slice::from_raw_parts(_mut)` | yes, on construction | +| `ptr::copy_nonoverlapping` | yes | +| `&*(p as *const T)` | yes | +| **`ptr::write` / `ptr::read`** | **no, silently succeeds** | + +`ptr::write` is precisely the aligned sibling of the `write_unaligned` this +crate uses everywhere, so **the one regression the misalignment tests exist to +catch is the one a debug build does not catch**. Catching it needs +`-Zmiri-symbolic-alignment-check`. With `debug-assertions` off, nothing in the +table is detected at all, and in release it usually just works, until it does +not. + +**`-Zmiri-symbolic-alignment-check` is a manual tool, deliberately not in CI.** +Plain Miri checks alignment against the concrete address the allocator +returned, so a test can pass by luck: offsetting `+1` into a `Vec` is not +reliably misaligned, because a byte allocation has alignment 1 and may already +start on an odd address. The symbolic check ignores the concrete address and +catches the class regardless. It is slow enough not to be worth a per-PR job, +so run it by hand when touching pointer marshalling: + +```bash +MIRIFLAGS="-Zmiri-disable-isolation -Zmiri-symbolic-alignment-check" \ + cargo +nightly miri test -p stackable-odbc-core --lib -- --skip proptest +``` + +To write a test that is misaligned on every platform, offset one byte into an +allocation of the *target* type, not into a byte buffer: + +```rust +let mut arena = vec![0u16; 16]; +let ptr = unsafe { arena.as_mut_ptr().cast::().add(1) }.cast::(); +``` + +### Fuzzing + +`fuzz/` holds [cargo-fuzz](https://github.com/rust-fuzz/cargo-fuzz) targets for +the memory-marshalling hot paths, `write_column_value` and `utf16`. Each +allocates its output buffer at exactly the caller-declared length, so +AddressSanitizer catches any overrun that clippy cannot see. It is its own +Cargo workspace, because libFuzzer needs nightly, so the root build ignores it. +A short smoke run of both targets runs on every PR (the `fuzz` job in +`build.yaml`). + +```bash +cargo install cargo-fuzz +cargo +nightly fuzz run utf16 +cargo +nightly fuzz run column_value +``` + +See [`fuzz/README.md`](fuzz/README.md) for what is and is not worth fuzzing. + +### Benchmarks + +Core has three Criterion benchmarks, all in `bench/`: + +```bash +cd bench && cargo bench +``` + +| Benchmark | What it drives | What it cannot see | +|---|---|---| +| `fetch_throughput` | `SyntheticStatement::fetch()` / `get_data()` directly, in memory, with no backend | the FFI layer at all: no `panic_safe`, no registry lookup, no descriptor, no `write_column_value` | +| `handle_lookup` | the FFI entry points with no result set open, so `HandleScope::get` in isolation | binding, fetch and data marshalling, because `BenchBackend::exec_direct` is never called | +| `ffi_fetch` | the FFI entry points against a real result set, with `panic_safe`, the registry, the ARD and `write_column_value` on the measured path | nothing the other two reach; a connection is installed through the `test-support` feature's `attach_connection` | + +- **`fetch_throughput` measures `SyntheticStatement`'s own row-cloning and + `ColumnValue` construction cost**, not the marshalling into an application's + buffer, which is `ffi_fetch`'s job. `BENCH_ROWS` overrides the row count. +- **`handle_lookup` has two shapes**, because the error path is not the success + path scaled: `get` (one `HandleScope::get`, then trivial work) and + `get_then_push_diagnostic`, where `panic_safe` also has to find the handle + again. +- **`ffi_fetch` has two groups.** `ffi_fetch_bound` binds three columns with + `SQLBindCol` (one `i64`, one 1 KiB string, one 1 KiB bytes) and loops + `SQLFetch` over `BENCH_ROWS` rows. `ffi_get_data_chunked` fetches one row and + then drains a 64 KiB string column through a 512-byte `SQLGetData` buffer + until `SQL_NO_DATA`, exercising the `GetDataCursor` chunking loop that a + bound column never reaches. + +Pick the one that can actually see what you changed. A registry or locking +change is invisible to `fetch_throughput`, a `ColumnValue` conversion inside +`SyntheticStatement` is invisible to `handle_lookup`, and anything in +`write_column_value`, the ARD or the `SQLGetData` chunking cursor is invisible +to both. If nothing covers it, add a fourth rather than quote a number from the +wrong one. + +`bench/` is a detached Cargo workspace so that `criterion` stays out of core's +dependency graph and `cargo package` does not warn about a `[[bench]]` target +excluded from the published crate. The directory is singular because cargo +auto-discovers `benches/` as a target directory and then insists on validating +any manifest inside it, which fails packaging. + +## odbc-sys usage + +`odbc-sys` is a minimal `-sys` crate for ODBC type definitions. It has no +convenience methods by design (see +[PR #47](https://github.com/pacman82/odbc-sys/pull/47) for the rationale), so +`stackable-odbc-core` is the driver-side convenience layer on top of it. + +- **Always use `odbc-sys` types** where they exist: `HandleType`, `SqlReturn`, + `CDataType`, `SqlDataType`, `InfoType`, `Desc`, `FreeStmtOption`, + `AttrOdbcVersion`, `EnvironmentAttribute`, `Len`, `Pointer`, `WChar`, and so + on. For primitive parameters where odbc-sys 0.29 removed the type aliases + (the old `SmallInt`, for instance), use the Rust primitives directly (`i16`, + `u16`, `i32`). +- **Never redefine** enums, structs, or constants that `odbc-sys` already + provides. Check `odbc-sys` before defining a new constant or enum, and use + what is there. +- **Add driver-side extensions** in `stackable-odbc-core`. Orphan rules prevent + `impl TryFrom for odbc_sys::HandleType`, so use standalone conversion + functions such as `fn handle_type_from_raw(v: i16) -> Option`. +- **Keep our own types** only for things `odbc-sys` does not have: + `ConnectParams`, `ColumnValue`, `ColumnDescriptor`, `FetchResult`, + `InfoValue`, `SqlState`, `DiagnosticQueue`, `TypeInfoRow`, `OdbcError`. +- **ODBC function IDs** (`SQL_API_*` values) are not in `odbc-sys`. They live in + `src/function_id.rs` as the `FunctionId` enum, sourced from + `/usr/include/sql.h` and `sqlext.h`. Always use `FunctionId::ExecDirect` and + its siblings, never raw numeric IDs, and convert with + `function_id_from_raw(u16) -> Option`. + +## Converting raw values to strongly typed enums + +Raw integers from the ODBC C ABI must be converted to strongly typed Rust enums +**as early as possible**: at the FFI boundary, before any logic runs. + +```rust +// GOOD: fallible conversion, handles unknown values gracefully +let field = desc_from_raw(field_identifier).ok_or_else(|| { + OdbcError::general( + format!("Unknown descriptor field: {field_identifier}"), + SqlState::optional_feature_not_implemented(), + ) +})?; +tracing::debug!("SQLColAttributeW(col={}, field={:?})", col, field); + +// BAD: transmute on arbitrary u16 is UB if the value isn't a valid enum variant +let field: Desc = std::mem::transmute(field_identifier); // DON'T DO THIS + +// BAD: passing raw u16 through multiple layers before converting +fn do_work(field_id: u16) { ... } // loses type safety and readable logging +``` + +Available conversion functions (all in `src/types/conversions.rs` unless noted): + +- `handle_type_from_raw(i16) -> Option` +- `desc_from_raw(u16) -> Option` +- `info_type_from_raw(u16) -> Option` +- `c_data_type_from_raw(i16) -> Option` +- `param_type_from_raw(i16) -> Option` +- `environment_attribute_from_raw(i32) -> Option` +- `attr_odbc_version_from_raw(i32) -> Option` +- `free_stmt_option_from_raw(u16) -> Option` +- `statement_attribute_from_raw(i32) -> Option` +- `completion_type_from_raw(i16) -> Option` +- `fetch_orientation_from_raw(i16) -> Option` +- `identifier_type_from_raw(u16) -> Option` +- `nullable_from_raw(u16) -> Option` +- `scope_from_raw(u16) -> Option` +- `bulk_operation_from_raw(i16) -> Option` +- `interval_from_raw(i16) -> Option` +- `declared_odbc_version_from_raw(i32) -> Option` +- `driver_connect_option_from_raw(u16) -> Option` +- `function_id_from_raw(u16) -> Option` (in `function_id.rs`) + +If `odbc-sys` adds a new enum that we need to convert from raw values, add an +`xxx_from_raw` function following the same pattern. Do not use `transmute`. + +`SQLSetPos`'s `Operation` and `LockType` are the one documented exception. An +`odbc-sys` type is usable here only if the raw ABI value can be recovered from +it. `odbc_sys::Operation` and `odbc_sys::Lock` are newtype structs over a +**private** `i16`, with no accessor, no `From`, and no `#[repr]` enum to cast +through. A converted value can therefore be compared against their associated +constants and used for nothing else, and no caller or test can name a valid +input. Those two validate against `SQL_POSITION` and `SQL_LOCK_*` in +`types/constants.rs`, which is the exception that block's comment records. +Before adding a conversion, check that the target type can round-trip. If it +cannot, a named constant is the correct answer rather than a worse conversion. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..32ff2dc --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,139 @@ +# 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 crate offers rather than what +changed. + +### Added + +**The framework.** The database-independent half of an ODBC 3.80 driver, at the +`SQL_OIC_CORE` conformance level, on Linux and Windows. Implement the `Backend` +and `StatementBackend` traits and invoke `forward_ffi!` once, and the crate +generates every C entry point the standard requires. Core carries no +database-specific code, so a driver never has to fork or patch it. + +**A handle is a ticket, not an address.** A `SQLHANDLE` is a slot index plus a +generation counter, looked up in a driver-owned table rather than dereferenced. +Freeing bumps that slot's generation, so every token still naming it stops +matching, and use after free becomes a clean `SQL_INVALID_HANDLE` rather than +memory corruption. + +**Two threads can share one connection.** Each connection owns a lock group that +its statements and descriptors join, so a call touching a statement and its +connection takes a single lock and no ordering rule is left to get wrong. +`SQLCancel` takes no lock at all, because cancelling a slow query must not wait +for the query it is cancelling. + +**Connections and transactions.** The three connect functions parse the +connection string, resolve a `DSN=` key against `odbc.ini` with explicit values +winning, and hand `Backend::connect` a typed `ConnectParams`. Attributes set +before connecting reach that call, because the spec calls setting them early the +interoperable choice. `SQLEndTran` commits or rolls back and applies the cursor +behaviour the backend declares. A driver needing an interactive login implements +`prompt::Prompter`, and core decides whether prompting is permitted from +`SQLDriverConnect`'s DriverCompletion argument. + +**Statements and result sets.** Direct and prepared execution, row counts, +multiple result sets, cursor names, and `SQLFetch` over bound columns or +`SQLGetData` chunked through a buffer of any size. The ODBC escape sequences +`{fn}`, `{d}`, `{t}`, `{ts}`, `{oj}` and `{escape}` are translated by a shared +scanner driven by a per-backend `EscapeDialect`, which can rewrite a whole +scalar-function call rather than only its name, so a function whose argument +syntax differs from ODBC's stays translatable. `SQLDescribeParam` answers from +`Backend::describe_param` where a backend can ask the data source, so a client +sizing its buffers from the answer is not left guessing at `VARCHAR`. + +**Value conversion is done for you.** `SQLBindParameter` records the C-side +buffer in the APD and the declared SQL type in the IPD, and core converts +between them with all three of the spec's C-to-SQL tables, character, binary +and numeric. The other direction covers the C types the standard defines, and +reports truncation as `01004`, an out-of-range value as `22003` and dropped +precision as `01S07`. Every access through an application pointer is unaligned, +because row-wise binding hands out pointers at arbitrary offsets into a packed +buffer. + +**Catalog metadata.** The ten catalog functions are implemented with core owning +the result set: a backend returns typed row structs with named fields, and core +puts the columns in spec order, sorts the rows as each page mandates and places +NULLs per `Backend::null_collation`. `SQL_ATTR_METADATA_ID` normalisation and +the `SQL_ALL_*` enumerations are core's too, so a driver writes no code for +either and cannot get a column order wrong. + +**Descriptors.** All five descriptor functions work over the four descriptors a +statement owns, an application can install its own through +`SQL_ATTR_APP_ROW_DESC` or `SQL_ATTR_APP_PARAM_DESC`, and one descriptor can be +shared across statements on a connection. ODBC makes a descriptor *be* the +binding, so a binding assembled through `SQLSetDescField` fetches exactly like +one made with `SQLBindCol`. + +**Diagnostics.** Every handle carries its own queue, read through +`SQLGetDiagRec` and `SQLGetDiagField`, and a backend error keeps its native +error code and its causal chain rather than a flattened message string. Each FFI +function's doc comment lists every SQLSTATE in its spec diagnostics table with a +verdict, and guard tests check those lists against the transcribed tables, so a +missing or invented state fails the build. + +**Cancellation and query timeouts.** `SQLCancel` on another thread signals the +backend's cancel token and returns without waiting, and a call stopped that way +reports `HY008` rather than whatever the driver's error mapping produced. A +fresh token is minted per execution, so a cancelled statement is usable again. +`SQL_ATTR_QUERY_TIMEOUT` goes to the data source first, and a backend that can +only be cancelled gets a core-side timer reporting `HYT00`, armed at `SQLFetch` +as well as at execution, because a data source may answer with column metadata +long before it has a row. + +**Capability reporting.** `SQLGetInfo` answers from required `Backend` methods +wherever the answer is a claim about the data source, and from core only where +the fact is core's own or the spec defines zero as "unknown". A guard test +evaluates the defaults against two backends sharing no declaration and fails on +any answer that does not move, so a hard-coded claim about somebody else's +database cannot slip in. + +**Windows is a first-class target.** The info group its Driver Manager queries +before `SQLDriverConnect` is answered without a connection, the function bitmap +is built from the exported-function list so the two cannot drift apart, and an +unknown info type gets a Driver-Manager-safe value rather than the `SQL_ERROR` +that corrupts its internal state. `ConfigDSNW` is exported for the ODBC +Administrator, with a hook so a driver's own dialog supplies the keywords. + +**A conformance harness drivers can run.** The `test-support` feature exposes +the `conformance` module, so a driver's own test suite can drive core's shared +`SQLGetInfo` checks against its real backend and catch an info type whose value +contradicts another one it declared. + +**Checked by more than unit tests.** Miri runs the pointer marshalling in an +interpreter that detects undefined behaviour and leaked handles, loom explores +the thread interleavings a test run happens not to produce, and cargo-fuzz +throws random input at the buffer copying under AddressSanitizer. Every +`extern "system"` entry point catches unwinds, because a panic crossing the C +boundary is itself undefined behaviour. + +### Known limitations + +- Results are read front to back only (`SQL_SO_FORWARD_ONLY`). `SQLFetchScroll` + takes `SQL_FETCH_NEXT` and rejects every other orientation with `HY106`. +- No block cursors and no parameter arrays. `SQL_ATTR_ROW_ARRAY_SIZE` and + `SQL_ATTR_PARAMSET_SIZE` are fixed at 1, and asking for more returns 1 with an + `01S02` warning, so `SQL_GD_BLOCK` is never reported. +- No bookmarks and no positioned updates: `SQLSetPos` and `SQLBulkOperations` + validate their arguments and then report `HYC00`. +- `SQL_ATTR_AUTO_IPD` is `SQL_FALSE`, so parameter metadata is never populated + automatically. Bind the parameter or set the IPD fields yourself. +- No async. `Backend` is synchronous, so a driver over an async client library + bridges internally, for example with a current-thread tokio runtime. +- The deprecated ODBC 2.x functions are not exported, because the Driver Manager + already maps them onto the modern ones and usually does it better. + `SQLExtendedFetch` is the exception, since it is not mapped. +- `SQL_ATTR_MAX_ROWS` and `SQL_ATTR_MAX_LENGTH` are offered to the data source + and otherwise capped to 0 with `01S02`, never emulated, because counting rows + in the driver after they have crossed the wire saves nothing. +- Core ships no `Prompter` implementation, because any one it could offer needs + a browser or a window system a database-independent crate cannot choose. + +[Unreleased]: https://github.com/stackabletech/stackable-odbc-core/commits/HEAD diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b97e75c --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,60 @@ +# Project Rules + +Read and follow @AGENTS.md. It holds the architecture, the patterns and the +procedures. + +## Non-Negotiable Rules + +- **ODBC spec compliance is mandatory.** Read the spec page for every function + you implement, modify or audit. Never claim a SQLSTATE is missing or wrong + without checking the actual spec diagnostics table first. Pay attention to + **(DM)** annotations: those SQLSTATEs are returned by the Driver Manager + rather than the driver, so do not add driver-side checks for them. Every FFI + function's doc comment must list all SQLSTATEs from the spec diagnostics + table, and for each one note whether the driver returns it or why it does not, + for example "(driver-manager-handled; not returned here)". When you touch a + function, verify its doc comment is complete and accurate against the spec. + See AGENTS.md "Adding a new ODBC function" for the full checklist. +- **When the spec is silent or ambiguous, check a mature driver before + deciding.** Some pages state a behaviour without a diagnostics table, describe + a Driver-Manager fallback without saying what the driver should do, or leave a + `(DM)` marker off exactly one clause of a row. Read what the established + drivers do. psqlODBC, MySQL Connector/ODBC, FreeTDS and unixODBC's own Driver + Manager are open source, and the commercial ones (Amazon Redshift, Simba, + DataDirect) publish supported-function tables. Prefer their source over their + documentation, and prefer both over inference from the spec's silence. Record + what you found and cite it in the doc comment, so the next reader inherits the + evidence rather than the conclusion. **If the drivers disagree with each + other, or with the spec, ask rather than picking one.** +- **Use `odbc-sys` types.** Never redefine an enum, struct or constant it + already provides. +- **Convert raw integers to typed enums at the FFI boundary**, using the + `xxx_from_raw()` functions. Never `transmute`. +- **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 file size before reading (`stat -c%s file`). Files + over 50 KB must be sliced rather than read whole. +2. **Navigate definitions with ctags.** Run `ctags -R .` once to build a tags + index, then `grep "^SymbolName" tags` for the exact file and line of any + function, struct or trait, with no file reading at all. +3. **Locate with Grep.** Find patterns, keywords or usages before reading. Use + `-C` for context lines. +4. **Extract with Read (offset + limit).** Once you know the line range, read + only that slice. +5. **Structured data.** Use `jq` for JSON and `yq` for YAML. Never read raw + markup whole. +6. **Filesystem survey.** Use `tree -L 2 -I '.git|target|node_modules'` rather + than a recursive `ls`. +7. **Verify edits with diff.** After editing, run `git diff -u` to confirm the + change instead of re-reading the file. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..bb0659a --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,149 @@ +# Contributing + +Thanks for considering a contribution. Reports of a spec rule this crate gets +wrong, a SQLSTATE it should return and does not, or an application that will not +work against a driver built on it 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 driver built on this crate, and which + application. 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 `odbc-sys` links against +them. You do not need a DSN or a running Driver Manager to build or to run the +tests. + +```bash +sudo apt-get install unixodbc-dev # Debian/Ubuntu +``` + +```bash +git clone https://github.com/stackabletech/stackable-odbc-core +cd stackable-odbc-core +cargo build +``` + +The toolchain version is pinned in `rust-toolchain.toml`, so rustup fetches the +right one on the first build. + +This crate is a library. To see it working end to end you need a driver on top +of it, and +[stackable-odbc-sqlite](https://github.com/stackabletech/stackable-odbc-sqlite) +is the smallest one. + +### Windows code, from Linux + +`#[cfg(windows)]` code compiles from Linux, and should be compiled before it is +pushed. A plain `cargo check` does not look at it at all, so `ffi/setup.rs` and +`ConfigDSNW` can reach a state that builds and tests clean locally and fails on +the Windows runner. + +```bash +rustup target add x86_64-pc-windows-msvc # once +cargo clippy --target x86_64-pc-windows-msvc --all-targets -- -D warnings +``` + +This links nothing and needs no Windows host, because `raw-dylib` resolves +`odbccp32` at link time and a `clippy` run never reaches it. It is not a +substitute for running the code, which needs a Windows host with a Driver +Manager, but it closes the compile-and-lint half where the regressions actually +happen. + +## Testing + +```bash +cargo test # unit and FFI tests +cargo clippy --all-targets -- -D warnings +``` + +`cargo test` must produce zero warnings. + +All the raw-pointer marshalling lives in this crate, so three further tools run +on every pull request and are worth running by hand when you touch that code. + +```bash +# undefined behaviour and leaked handles +MIRIFLAGS="-Zmiri-disable-isolation" \ + cargo +nightly miri test -p stackable-odbc-core --lib -- --skip proptest + +# every thread interleaving of the locking code, not just the one that occurred +RUSTFLAGS="--cfg loom" cargo test --lib loom_tests + +# random input at the buffer-copying paths, under AddressSanitizer +cargo install cargo-fuzz +cargo +nightly fuzz run utf16 +cargo +nightly fuzz run column_value +``` + +`bench/` and `fuzz/` are **separate Cargo workspaces**, so nothing at the repo +root compiles them. Not `cargo test`, not `cargo clippy --all-targets`, and not +a single `pre-commit` hook. `bench/benches/handle_lookup.rs` contains a full +`impl Backend`, so any change to the `Backend` or `StatementBackend` trait +breaks it while every local check still passes and CI fails. After touching +either trait: + +```bash +(cd bench && cargo build --benches) +(cd fuzz && cargo +nightly build --target x86_64-unknown-linux-gnu) +``` + +## Before you commit + +```bash +pre-commit run --all-files +``` + +That is the gate, and 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`, markdownlint and a +secret scan. + +Two of those are not cargo built-ins, so install them once: + +```bash +cargo install cargo-deny cargo-sort +``` + +A change usually needs one more thing: **a changelog entry**, under +`## [Unreleased]` in [`CHANGELOG.md`](CHANGELOG.md), if a driver author or an +ODBC application can observe the difference. Because this crate is a library +that driver crates build on, treat any change to a public type, a trait method, +or an exported FFI contract as observable. + +## Where things live + +[`AGENTS.md`](AGENTS.md) is the working reference: the architecture, how a call +flows through the layers, the descriptor and catalog rules, the lock discipline, +and the spec evidence behind each design decision. Read the section covering +whatever you are about to change. It is written for AI coding agents and human +contributors alike. + +Four rules are worth stating here, because they are the ones a reasonable-looking +change breaks most easily. + +- **Read the ODBC spec page for every function you touch.** Every FFI function's + doc comment lists each SQLSTATE from that function's spec diagnostics table + and says whether this crate returns it, or why not. A guard test in + `src/types/diagnostics_table.rs` checks the doc comments against a + transcription of those tables, so an incomplete one fails the build. +- **Watch the `(DM)` annotations.** A SQLSTATE marked that way is the Driver + Manager's to return, not the driver's, so adding a driver-side check for one + is wrong even though it looks like extra safety. +- **Convert raw integers to typed enums at the FFI boundary**, using the + `xxx_from_raw` functions in `src/types/conversions.rs`. Never `transmute`: an + arbitrary integer is not necessarily a valid enum variant, and transmuting one + is undefined behaviour. +- **Import every lock from `src/sync.rs`**, never from `std::sync` directly. That + is what lets a `--cfg loom` build swap them for loom's instrumented versions. + A lock imported around that module is invisible to loom and silently opts its + code out of the interleaving proof. + +## 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..716f2ce --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,739 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[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 = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +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 = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[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 = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "generator" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b3b854b0e584ead1a33f18b2fcad7cf7be18b3875c78816b753639aa501513ae" +dependencies = [ + "cc", + "cfg-if", + "libc", + "log", + "rustversion", + "windows-link", + "windows-result", +] + +[[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", + "libc", + "r-efi 6.0.0", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[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 = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "log" +version = "0.4.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad" + +[[package]] +name = "loom" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "419e0dc8046cb947daa77eb95ae174acfbddb7673b4151f56d1eed8e93fbfaca" +dependencies = [ + "cfg-if", + "generator", + "scoped-tls", + "tracing", + "tracing-subscriber", +] + +[[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 = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "num-conv" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" + +[[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 = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[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", + "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 = "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", +] + +[[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", +] + +[[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_xorshift" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "513962919efc330f829edb2535844d1b912b0fbe2ca165d613e4e8788bb05a5a" +dependencies = [ + "rand_core", +] + +[[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 = "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", +] + +[[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 = "scoped-tls" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1cf6437eb19a8f4a6cc0f7dca544973b0b78843adbfeb3683d1a94a0024a294" + +[[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 = "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 = "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 = "stackable-odbc-core" +version = "0.0.1" +dependencies = [ + "loom", + "odbc-sys", + "proptest", + "snafu", + "tracing", + "tracing-appender", + "tracing-subscriber", +] + +[[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 = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[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.54" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e1d5e639ff6bab73cb6885cc7e7b1de96c3f32c68ec55f3952614bec1092244" +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 = "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 = "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 = "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 = "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 = "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-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[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", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..0b2806b --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,98 @@ +[package] +name = "stackable-odbc-core" +version = "0.0.1" +edition = "2024" +rust-version = "1.95.0" +authors = ["Stackable GmbH "] +license = "Apache-2.0" + +# Publishing is switched off at the manifest, not only in `release.toml`. +# `cargo publish` refuses outright while this is false, so neither a stray +# `cargo release --execute` nor a hand-run publish can push this crate to +# crates.io by accident. +publish = false +repository = "https://github.com/stackabletech/stackable-odbc-core" +homepage = "https://github.com/stackabletech/stackable-odbc-core" +documentation = "https://docs.rs/stackable-odbc-core" +description = "Database-independent framework for building ODBC drivers in Rust. Implement two traits, get a conformant ODBC 3.80 driver." +readme = "README.md" +keywords = ["odbc", "database", "driver", "ffi", "sql"] +categories = ["database", "external-ffi-bindings", "api-bindings"] + +# What does NOT go in the published tarball. +# +# `rust-toolchain.toml` is the important one: shipped, it pins *consumers* to +# 1.95.0, because rustup honours the file it finds in a vendored or unpacked +# dependency. That is a known docs.rs and vendoring failure mode, and the pin is +# a contributor convenience with no business travelling with the crate. +exclude = [ + "rust-toolchain.toml", + ".github/", + "release/", + "release.toml", + "fuzz/", + "bench/", + "AGENTS.md", + "CLAUDE.md", + "clippy.toml", + "deny.toml", + "rustfmt.toml", + ".pre-commit-config.yaml", + ".markdownlint.yaml", +] + +# docs.rs builds for a single target by default, which would leave `ConfigDSNW` +# and the seven other `#[cfg(windows)]` items undocumented, exactly the parts +# a Windows driver author needs. Build the docs on Windows, and turn on the +# `test-support` feature so the `conformance` module a driver's test suite uses +# is documented too. +[package.metadata.docs.rs] +default-target = "x86_64-pc-windows-msvc" +targets = ["x86_64-pc-windows-msvc", "x86_64-unknown-linux-gnu"] +features = ["test-support"] + +[features] +# Test support for driver crates: the `conformance` module, which drives +# `SQLGetInfoW` through the real C ABI to check an info type's return shape. +# +# Default-off because it is test code. Compiled unconditionally it lands in +# every driver's production binary, and it reaches an `unreachable!()` through a +# public `unsafe fn` taking a caller-supplied `u16` — a panic path a shipped +# driver has no reason to carry. Driver test suites enable it under +# `[dev-dependencies]`. +test-support = [] + +[dependencies] +odbc-sys = "0.31" +snafu = "0.9" +tracing = "0.1" +tracing-appender = "0.2" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[dev-dependencies] +proptest = "1" +# For `tests/logging.rs`, which installs a competing global subscriber before +# calling `init_logging`. That has to run in its own process, so it cannot be a +# unit test, and an integration test reaches only the public API plus these. +tracing-subscriber = "0.3" + +# Exhaustive interleaving checker for the handle lock discipline. The models +# themselves live in `src/handles/registry.rs`'s +# `#[cfg(all(test, loom))] mod loom_tests`, and `src/sync.rs`'s type aliases +# are gated the same way, so `loom` is only ever resolved inside a test build +# with `--cfg loom` set. That keeps it a dev-dependency: it never appears in a +# downstream consumer's resolved dependency graph. +[target.'cfg(loom)'.dev-dependencies] +loom = "0.7" + +[lints.rust] +# `loom` is a custom cfg (see `src/sync.rs`), set via `RUSTFLAGS="--cfg loom"` +# rather than a Cargo feature, so rustc does not know its name is legitimate +# without this declaration. +unexpected_cfgs = { level = "warn", check-cfg = ["cfg(loom)"] } + +[lints.clippy] +expect_used = "deny" +unwrap_in_result = "deny" +unwrap_used = "deny" +panic = "deny" diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..282125b --- /dev/null +++ b/NOTICE @@ -0,0 +1,16 @@ +stackable-odbc-core +Copyright 2026 Stackable GmbH + +This product includes software developed at Stackable GmbH +(https://stackable.tech/). + +Licensed under the Apache License, Version 2.0 (the "License"); you may not use +this file except in compliance with the License. You may obtain a copy of the +License in the LICENSE file distributed alongside this notice, or at: + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software distributed +under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR +CONDITIONS OF ANY KIND, either express or implied. See the License for the +specific language governing permissions and limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..d6fa712 --- /dev/null +++ b/README.md @@ -0,0 +1,239 @@ + + +

+ Stackable Logo +

+ +

Stackable ODBC Core

+ +

The database-independent half of an ODBC driver, in Rust.

+ +[![Build and Test](https://github.com/stackabletech/stackable-odbc-core/actions/workflows/build.yaml/badge.svg)](https://github.com/stackabletech/stackable-odbc-core/actions/workflows/build.yaml) +[![Security Audit](https://github.com/stackabletech/stackable-odbc-core/actions/workflows/security_audit.yaml/badge.svg)](https://github.com/stackabletech/stackable-odbc-core/actions/workflows/security_audit.yaml) +[![PRs Welcome](https://img.shields.io/badge/PRs-welcome-green.svg)](CONTRIBUTING.md) +[![Apache License 2.0](https://img.shields.io/badge/license-Apache--2.0-green)](./LICENSE) +[![ODBC 3.80 Core](https://img.shields.io/badge/ODBC-3.80%20Core-blue)](#conformance) +[![Platforms](https://img.shields.io/badge/platforms-Linux%20%7C%20Windows-blue)](#conformance) + +[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? + +ODBC is the standard way desktop tools talk to a database. Excel, Tableau, +Power BI and Python's pyodbc all speak it. Each database needs its own *driver*, +a shared library the tool loads that translates those standard calls into +whatever the database actually speaks. + +Writing one is a large job, and most of it has nothing to do with your database. +The driver has to hand out and validate handles, convert every string to and +from UTF-16, report errors in the exact format the standard demands, copy values +into buffers the application supplied, and not crash when the application lies +about how big those buffers are. + +`stackable-odbc-core` is that shared part, written once. What you supply is the +part that really is about your database: how to connect and authenticate, how +to run a query and read rows back, how your database's types map onto ODBC's, +and how to answer the catalog questions. For a networked database that is a +client library in its own right, and writing one is not trivial. It is just +not ODBC work. One macro then generates the C entry points the standard +requires. + +This is a library rather than a driver you can load on its own. A working driver +is this crate plus a backend, and +[stackable-odbc-sqlite](https://github.com/stackabletech/stackable-odbc-sqlite) +is the smallest complete example of one. + +## What you get + +- **A database backend is two traits and one macro.** Implement `Backend` and + `StatementBackend`, then call `forward_ffi!`. The compiler names everything + still missing, so there is no list to work through by hand. Core holds no + database-specific code, so a driver never forks or patches it. + +- **A handle is a ticket number, not a memory address.** ODBC hands the + application a `SQLHANDLE` that refers to a connection or a running query. The + obvious implementation is a raw pointer, and then an application that frees a + handle twice, or uses one after freeing it, corrupts the driver's memory. + That is undefined behaviour, so the program may crash, or may quietly return a + wrong answer. + + Here a handle is a slot number plus a counter. The driver looks it up in its + own table and never follows the pointer the application passed. Freeing bumps + that slot's counter, so every ticket still referring to it stops matching. + Use-after-free and double-free become a clean "invalid handle" error rather + than memory corruption. + +- **Two threads can share one connection safely.** The standard requires it, + because "drivers must therefore support safe, multithread access to this + information", and many drivers leave it to the Driver Manager instead. Each + connection here has one lock, shared with every query started on it, so a call + touching both a query and its connection takes a single lock. That leaves no + lock ordering to get wrong, which is the usual way a driver deadlocks. + `SQLCancel` takes no lock at all, because cancelling a slow query must not + wait for the query it is cancelling. + +- **The query timeout covers waiting for rows, not just sending the query.** An + application sets `SQL_ATTR_QUERY_TIMEOUT` to say "give up after N seconds". + Most drivers run that clock only while the query is being submitted, but a + database can answer with the column names immediately and then take much + longer to produce the first row. A timer covering only submission bounds + nothing, so this one runs during `SQLFetch` as well. + +- **Core builds the catalog answers.** For "what tables exist?" and its + relatives, the standard dictates the exact columns, their order, and how the + rows are sorted. A backend returns ordinary Rust structs with named fields, + and core puts the columns in order, sorts the rows and normalises identifier + case. You cannot get the column order or count wrong because you never write + them, and a column added to one of those result sets is a change in core + alone. + +- **Value conversion is already done.** When an application supplies a parameter + as text and asks for it to be treated as a number, the standard has three + large tables saying exactly what each conversion does, down to which warning + to raise when precision is lost. All three are implemented: character, binary + and numeric, including the interval rows and the optional `01S07` warning for + fractional seconds that were rounded away. + +- **Windows is a first-class target.** Its Driver Manager is stricter than + unixODBC and it fails quietly, so missing one requirement stops a feature + working with no error to explain why. The known traps are handled: answering + the version query it makes before connecting, reporting the complete function + list it uses to build its dispatch table, and not exporting the deprecated + ODBC 2.x functions, because exporting one replaces the Driver Manager's own + better implementation with yours. + +- **Checked by more than unit tests.** Three tools cover what ordinary tests + cannot. Miri runs the code in an interpreter that detects undefined behaviour + and leaked handles, loom re-runs the locking code under every thread + interleaving rather than the one that happened to occur, and cargo-fuzz throws + random input at the buffer-copying code under AddressSanitizer. All three run + on every pull request, alongside the unit tests on Linux and Windows. + +## Writing a driver + +```bash +cargo new --lib stackable-odbc-xyz +cargo add stackable-odbc-core +``` + +Implement `Backend` and `StatementBackend` for your database, then generate the +C ABI in `lib.rs`: + +```rust,ignore +stackable_odbc_core::forward_ffi!(crate::backend::XyzBackend); +``` + +That one line expands to every exported `SQL*` entry point, plus `ConfigDSNW` on +Windows, each forwarding to the generic implementation in this crate. + +`Backend` has four associated types and a body of required methods, but most of +them are one-line capability declarations such as `supports_catalogs`, +`identifier_case` and `sql_conformance`, each answering a single question about +your database. They are required rather than defaulted on purpose: any default +core supplied would be a claim about your database that nobody ever checked, and +a wrong one is invisible, because the driver would confidently tell applications +something untrue and nothing would complain. `StatementBackend` is the opposite, +with one associated type and no required methods, so you override only what your +backend supports. + +In practice you do not look the list up. Write the four associated types, run +`cargo check`, and the compiler names what is still missing. + +Two traits and one macro bound the surface, not the effort. A backend for a +real database is a real client. Authentication, sessions, type mapping, catalog +queries and error mapping are all yours, and in both existing drivers that adds +up to a substantial crate. What core takes off your hands is the ODBC half: the +handle table, the UTF-16, the diagnostics format, the buffer copying and the +conversion tables. That half is identical for every database, and it is the +half where a mistake corrupts memory rather than returning a wrong answer. + +[AGENTS.md](https://github.com/stackabletech/stackable-odbc-core/blob/main/AGENTS.md) +has the full walkthrough: how a call flows through the layers, what each +capability method means, the catalog and descriptor rules, and the Windows +Driver Manager checklist. + +## Conformance + +This implements ODBC 3.80 at the `SQL_OIC_CORE` level, the base of the +standard's three interface-conformance levels and the one an application may +assume of any driver. All four handle types can be allocated and freed, and all +five descriptor functions work. Descriptors are the standard's own way of +describing a bound column or parameter, and one can be shared between queries on +a connection. + +This is a Unicode driver: every function that takes or returns a string is +exported only in its wide (`W`-suffixed) form e.g. `SQLConnectW`. The +Driver Manager translates for ANSI applications, so they keep working and the +driver never carries a second set of entry points. Functions with no strings +in their signature, such as `SQLFetch`, have one spelling and are exported +unsuffixed. + +`CORE_EXPORTED_FUNCTIONS` in `src/function_id.rs` is the authoritative list of +what is exported, and a guard test pins every entry to a symbol that exists. The +deprecated ODBC 2.x functions are left out, because the Driver Manager already +emulates them on top of the modern ones and usually does it better than a driver +would, so exporting your own version switches that off rather than adding +anything. `SQLExtendedFetch` is the exception the Driver Manager does not map, +so core exports it. + +## Limits + +Each of these is reported to the application as unsupported rather than quietly +ignored, so a tool can react instead of trusting a wrong answer. + +- **Results are read front to back only** (`SQL_SO_FORWARD_ONLY`), so there is + no jumping to a row and no going backwards. `SQLFetchScroll` accepts + `SQL_FETCH_NEXT` and rejects every other direction with `HY106`. +- **One row at a time.** There are no block cursors, so + `SQL_ATTR_ROW_ARRAY_SIZE` is fixed at 1. Asking for more returns 1 with an + `01S02` warning, and `SQL_GD_BLOCK` is never reported. +- **No bookmarks**, which are saved row positions an application can return to + later, and no automatic population of parameter metadata, so + `SQL_ATTR_AUTO_IPD` stays `SQL_FALSE`. +- **No async.** Every call runs to completion before returning: + `SQL_ASYNC_MODE` is reported as `SQL_AM_NONE`, and turning on + `SQL_ATTR_ASYNC_ENABLE` is refused rather than ignored. This is about the + calling thread, not the shape of the results. Rows still arrive one + `SQLFetch` at a time, the query timeout still bounds a slow query, and + `SQLCancel` still interrupts one. `Backend` is synchronous too, so a driver + built on an async client library bridges to it internally, for example with + a current-thread tokio runtime and `block_on`. + +## Drivers built on this crate + +Each driver is a separate crate supplying only its `Backend` and +`StatementBackend` implementation. + +- [stackable-odbc-trino](https://github.com/stackabletech/stackable-odbc-trino), + an ODBC driver for [Trino](https://trino.io/). +- [stackable-odbc-sqlite](https://github.com/stackabletech/stackable-odbc-sqlite), + a SQLite driver, used as a worked example and as the test driver for the + framework itself. + +## Resources + +- [ODBC API reference](https://learn.microsoft.com/en-us/sql/odbc/reference/syntax/odbc-api-reference?view=sql-server-ver16), + the authoritative specification. It is the most detailed source and still not + an easy read. +- [Header files](https://github.com/microsoft/ODBC-Specification/blob/master/Windows/inc/sql.h) + for the unreleased ODBC 4 standard, mostly valid for the older ones too. +- [odbc-sys](https://github.com/pacman82/odbc-sys), the ODBC type definitions + this crate builds on. + +## 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-core/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. See [LICENSE](./LICENSE) and [NOTICE](./NOTICE). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..b961d0d --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,40 @@ +# 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 crate version, the platform and Driver Manager in use, the +driver built on this crate where relevant, and the steps needed to reproduce the +issue. This crate holds the raw-pointer marshalling for every driver built on +it, so a reproducer that shows a bad buffer length, a stale handle or a +misaligned pointer reaching an ODBC entry point is especially useful. + +## 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 crate is below 1.0, fixes are not backported to earlier releases: +upgrade to the current release to receive them. + +A driver built on this crate links it statically, so a fix here reaches +applications only once that driver is rebuilt and re-released against the fixed +version. + +## 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/bench/.gitignore b/bench/.gitignore new file mode 100644 index 0000000..560e4dd --- /dev/null +++ b/bench/.gitignore @@ -0,0 +1,5 @@ +target +# Not committed, matching fuzz/. The benchmark is a development tool, not a +# shipped artefact, and pinning its dependency graph would mean a second +# lockfile to keep current for no reproducibility anyone depends on. +Cargo.lock diff --git a/bench/Cargo.toml b/bench/Cargo.toml new file mode 100644 index 0000000..4b2cfc1 --- /dev/null +++ b/bench/Cargo.toml @@ -0,0 +1,40 @@ +[package] +name = "stackable-odbc-core-bench" +version = "0.0.1" +publish = false +edition = "2024" +rust-version = "1.95.0" + +# Own workspace, like `fuzz/`. Two reasons, and the second is the important one: +# +# 1. It keeps `criterion` and its tree out of core's own dependency resolution. +# 2. It means core's manifest declares no `[[bench]]` target at all. A benchmark +# that is excluded from the published package while still being declared as a +# target makes `cargo package` warn on every publish, and the only ways to +# silence that are to ship a benchmark nobody consuming the crate can run, or +# to leave a permanent warning in the publish output where it would mask the +# next real one. +[workspace] + +[dependencies] +# test-support: `ffi_fetch`'s two benchmarks install a connection through +# `attach_connection` instead of parsing a real connection string, the same +# shortcut a driver's own test suite uses to reach the connected path without +# a data source. +stackable-odbc-core = { path = "..", features = ["test-support"] } +odbc-sys = "0.31" + +[dev-dependencies] +criterion = { version = "0.8", features = ["html_reports"] } + +[[bench]] +name = "fetch_throughput" +harness = false + +[[bench]] +name = "handle_lookup" +harness = false + +[[bench]] +name = "ffi_fetch" +harness = false diff --git a/bench/benches/fetch_throughput.rs b/bench/benches/fetch_throughput.rs new file mode 100644 index 0000000..2a3f5fe --- /dev/null +++ b/bench/benches/fetch_throughput.rs @@ -0,0 +1,295 @@ +//! Benchmarks for the result-set hot path: fetch + get_data. +//! +//! Two workload shapes: +//! * Shape A — mixed columns (50% i64, 40% short string, 10% decimal-as-string). +//! Defaults to BENCH_ROWS=100_000 × BENCH_COLS=20. +//! * Shape B — 5 columns × wide strings. +//! Defaults to BENCH_WIDE_ROWS=10_000 × BENCH_WIDE_STR_LEN=1024. +//! +//! Three scenarios (where supported by the harness): +//! * `late_binding` — SQLFetch + per-cell SQLGetData. Default ODBC pattern. +//! * `bound_columns` — SQLBindCol + SQLFetch writes directly to client buffers. +//! Not exercised here; only the FFI-end-to-end driver benches cover it. +//! * `repeat_get_data` — SQLGetData called BENCH_REPEAT_GET_DATA times per cell. +//! Surfaces per-call clone cost. +//! +//! Run with: +//! cargo bench -p stackable-odbc-core +//! BENCH_ROWS=1000000 cargo bench -p stackable-odbc-core # huge-data manual run + +use criterion::{Criterion, criterion_group, criterion_main}; +use odbc_sys::{CDataType, SqlDataType}; +use stackable_odbc_core::backend::StatementBackend; +use stackable_odbc_core::synthetic::SyntheticStatement; +use stackable_odbc_core::types::{ColumnDescriptor, ColumnValue, FetchResult, Nullable}; +use std::hint::black_box; +use std::time::Duration; + +#[derive(Clone, Copy)] +struct BenchConfig { + rows: usize, + cols: usize, + wide_rows: usize, + wide_str_len: usize, + repeat_get_data: usize, +} + +fn env_or(name: &str, default: T) -> T { + std::env::var(name) + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(default) +} + +fn bench_config() -> BenchConfig { + BenchConfig { + rows: env_or("BENCH_ROWS", 100_000), + cols: env_or("BENCH_COLS", 20), + wide_rows: env_or("BENCH_WIDE_ROWS", 10_000), + wide_str_len: env_or("BENCH_WIDE_STR_LEN", 1024), + repeat_get_data: env_or("BENCH_REPEAT_GET_DATA", 3), + } +} + +/// Apply the large-run sample-size heuristic: fewer samples when each iteration +/// is expected to take seconds (rows > 250k). +fn configure_for_size(c: Criterion, rows: usize) -> Criterion { + if rows > 250_000 { + c.sample_size(20) + .measurement_time(Duration::from_secs(30)) + .warm_up_time(Duration::from_secs(3)) + } else { + c + } +} + +fn string_rows(n_rows: usize, n_cols: usize, s_len: usize) -> Vec> { + let s = "x".repeat(s_len); + (0..n_rows) + .map(|_| { + (0..n_cols) + .map(|_| ColumnValue::String(s.clone())) + .collect() + }) + .collect() +} + +fn int_rows(n_rows: usize, n_cols: usize) -> Vec> { + (0..n_rows) + .map(|row| { + (0..n_cols) + .map(|col| ColumnValue::I64(row as i64 * n_cols as i64 + col as i64)) + .collect() + }) + .collect() +} + +/// Compute the (i64, string, decimal) column split for Shape A based on `n_cols`. +/// Ratio is 50/40/10, with any rounding remainder going to i64. +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) +} + +/// Shape A: mixed columns at 50% i64, 40% short string (32 chars), 10% decimal-as-string. +fn shape_a_rows(n_rows: usize, n_cols: usize) -> Vec> { + let (n_i, n_s, n_d) = shape_a_split(n_cols); + let short = "x".repeat(32); + let decimal = String::from("12345678.90"); + (0..n_rows) + .map(|row| { + let mut cells = Vec::with_capacity(n_cols); + for col in 0..n_i { + cells.push(ColumnValue::I64(row as i64 * n_cols as i64 + col as i64)); + } + for _ in 0..n_s { + cells.push(ColumnValue::String(short.clone())); + } + for _ in 0..n_d { + cells.push(ColumnValue::String(decimal.clone())); + } + cells + }) + .collect() +} + +/// Shape B: wide-string rows — 5 columns × `s_len` characters each. +fn shape_b_rows(n_rows: usize, s_len: usize) -> Vec> { + let s = "x".repeat(s_len); + (0..n_rows) + .map(|_| (0..5).map(|_| ColumnValue::String(s.clone())).collect()) + .collect() +} + +fn columns(n: usize) -> Vec { + (0..n) + .map(|i| { + ColumnDescriptor::new(format!("col{i}"), SqlDataType::EXT_W_VARCHAR) + .with_precision_scale(255, 0) + .with_nullable(Nullable::SqlNullable) + }) + .collect() +} + +/// Drain a SyntheticStatement by calling fetch+get_data on every column. +/// Returns the total number of values read so the caller can pass it through `black_box`. +fn drain(stmt: &mut SyntheticStatement, n_cols: usize) -> usize { + let mut count = 0usize; + while let FetchResult::Row = stmt.fetch().expect("fetch") { + for col in 1..=(n_cols as u16) { + stmt.get_data(col, CDataType::Default).expect("get_data"); + count += 1; + } + } + count +} + +fn drain_repeat(stmt: &mut SyntheticStatement, n_cols: usize, repeats: usize) -> usize { + let mut count = 0usize; + while let FetchResult::Row = stmt.fetch().expect("fetch") { + for col in 1..=(n_cols as u16) { + for _ in 0..repeats { + stmt.get_data(col, CDataType::Default).expect("get_data"); + count += 1; + } + } + } + count +} + +fn bench_get_data_string(c: &mut Criterion) { + use criterion::{BatchSize, Throughput}; + + let n_rows = 1_000; + let n_cols = 10; + let cols = columns(n_cols); + let rows = string_rows(n_rows, n_cols, 64); + + let mut group = c.benchmark_group("get_data_string"); + group.throughput(Throughput::Elements((n_rows * n_cols) as u64)); + group.bench_function("1000x10_len64", |b| { + b.iter_batched( + || SyntheticStatement::new(cols.clone(), rows.clone()), + |mut stmt| { + black_box(drain(&mut stmt, n_cols)); + }, + BatchSize::SmallInput, + ); + }); + group.finish(); +} + +fn bench_get_data_int(c: &mut Criterion) { + use criterion::{BatchSize, Throughput}; + + let n_rows = 1_000; + let n_cols = 10; + let cols = columns(n_cols); + let rows = int_rows(n_rows, n_cols); + + let mut group = c.benchmark_group("get_data_i64"); + group.throughput(Throughput::Elements((n_rows * n_cols) as u64)); + group.bench_function("1000x10", |b| { + b.iter_batched( + || SyntheticStatement::new(cols.clone(), rows.clone()), + |mut stmt| { + black_box(drain(&mut stmt, n_cols)); + }, + BatchSize::SmallInput, + ); + }); + group.finish(); +} + +fn bench_shape_a(c: &mut Criterion) { + use criterion::{BatchSize, BenchmarkId, Throughput}; + + let cfg = bench_config(); + let cols = columns(cfg.cols); + let rows = shape_a_rows(cfg.rows, cfg.cols); + let label = format!("{}x{}", cfg.rows, cfg.cols); + + let mut group = c.benchmark_group("shape_a"); + group.throughput(Throughput::Elements((cfg.rows * cfg.cols) as u64)); + + group.bench_function(BenchmarkId::new("late_binding", &label), |b| { + b.iter_batched( + || SyntheticStatement::new(cols.clone(), rows.clone()), + |mut stmt| { + black_box(drain(&mut stmt, cfg.cols)); + }, + BatchSize::LargeInput, + ); + }); + + group.bench_function( + BenchmarkId::new(format!("repeat_get_data_x{}", cfg.repeat_get_data), &label), + |b| { + b.iter_batched( + || SyntheticStatement::new(cols.clone(), rows.clone()), + |mut stmt| { + black_box(drain_repeat(&mut stmt, cfg.cols, cfg.repeat_get_data)); + }, + BatchSize::LargeInput, + ); + }, + ); + + group.finish(); +} + +fn bench_shape_b(c: &mut Criterion) { + use criterion::{BatchSize, BenchmarkId, Throughput}; + + let cfg = bench_config(); + let n_cols = 5; + let cols = columns(n_cols); + let rows = shape_b_rows(cfg.wide_rows, cfg.wide_str_len); + let label = format!("{}x5_len{}", cfg.wide_rows, cfg.wide_str_len); + + let mut group = c.benchmark_group("shape_b"); + group.throughput(Throughput::Elements((cfg.wide_rows * n_cols) as u64)); + + group.bench_function(BenchmarkId::new("late_binding", &label), |b| { + b.iter_batched( + || SyntheticStatement::new(cols.clone(), rows.clone()), + |mut stmt| { + black_box(drain(&mut stmt, n_cols)); + }, + BatchSize::LargeInput, + ); + }); + + group.bench_function( + BenchmarkId::new(format!("repeat_get_data_x{}", cfg.repeat_get_data), &label), + |b| { + b.iter_batched( + || SyntheticStatement::new(cols.clone(), rows.clone()), + |mut stmt| { + black_box(drain_repeat(&mut stmt, n_cols, cfg.repeat_get_data)); + }, + BatchSize::LargeInput, + ); + }, + ); + + group.finish(); +} + +fn benches() -> Criterion { + configure_for_size(Criterion::default(), bench_config().rows) +} + +criterion_group! { + name = benches_group; + config = benches(); + targets = + bench_get_data_string, + bench_get_data_int, + bench_shape_a, + bench_shape_b +} +criterion_main!(benches_group); diff --git a/bench/benches/ffi_fetch.rs b/bench/benches/ffi_fetch.rs new file mode 100644 index 0000000..e352234 --- /dev/null +++ b/bench/benches/ffi_fetch.rs @@ -0,0 +1,725 @@ +//! FFI-level fetch benchmarks: `SQLBindCol` + `SQLFetch`, and chunked `SQLGetData`. +//! +//! `fetch_throughput` drives `SyntheticStatement::fetch()`/`get_data()` directly +//! and never enters the FFI layer at all -- no `panic_safe`, no handle +//! registry lookup, no descriptor, no `write_column_value`. `handle_lookup` +//! goes through the FFI entry points but never reaches a result set: its +//! `BenchBackend::exec_direct`/`prepare` are never even called by either of its +//! two benchmarked functions. This file closes the gap between them: both +//! groups run the real `SQLBindCol`/`SQLFetch`/`SQLGetData` C ABI entry points +//! (`stackable_odbc_core::ffi::bind::sql_bind_col`, +//! `stackable_odbc_core::ffi::fetch::sql_fetch`, +//! `stackable_odbc_core::ffi::fetch::sql_get_data`) against a connected +//! statement handle, so `panic_safe`, the handle registry, the ARD and +//! `write_column_value` are all on the measured path. +//! +//! The connection is installed with the `test-support` feature's +//! [`attach_connection`], the same shortcut a driver's own test suite uses to +//! reach the connected path without a real data source -- `Backend::connect` +//! is never called by this benchmark, only `Backend::exec_direct`. +//! +//! Two groups: +//! +//! - `ffi_fetch_bound` -- `SQLBindCol` three columns (one `i64`, one 1 KiB +//! `SQL_C_CHAR` string, one 1 KiB `SQL_C_BINARY` blob), then loop `SQLFetch` +//! over `BENCH_ROWS` rows to `SQL_NO_DATA`. Every row's string and bytes +//! value is freshly allocated inside `BoundColumnsStatement::get_data`, +//! which is what a backend that does not cache its whole result set in +//! memory actually does -- `fetch_throughput`'s `SyntheticStatement` clones +//! out of a `Vec>` it built once, so it cannot see that +//! per-row allocation cost, or the ARD lookup, bind-offset arithmetic and +//! `write_column_value` call that turn each `ColumnValue` into bytes in the +//! application's buffer. +//! - `ffi_get_data_chunked` -- one row, one 64 KiB string column, read back +//! with `SQLFetch` then repeated `SQLGetData` calls through a 512-byte +//! buffer until `SQL_NO_DATA`. Exercises the `GetDataCursor` chunking loop +//! in `sql_get_data` (`cursor.delivered`/`cursor.done`) that +//! `ffi_fetch_bound`'s bound-column path never reaches at all, since a bound +//! column is written by `SQLFetch` in one call regardless of size. +//! +//! `BENCH_ROWS` overrides `ffi_fetch_bound`'s row count (default 100,000), +//! matching `fetch_throughput`'s env var so the two can be compared at the +//! same N. `ffi_get_data_chunked`'s row count (1) and column width (64 KiB) +//! are fixed, since the group's variable is chunk count, not throughput. +//! +//! Run with: +//! (cd bench && cargo bench --bench ffi_fetch) +//! (cd bench && cargo bench --bench ffi_fetch -- --test) # smoke test only +//! +//! ## Baseline (recorded 2026-07-31, `BENCH_ROWS=5000`, warm build, this host) +//! +//! ```text +//! ffi_fetch_bound/5000_rows time: [1.5347 ms 1.5542 ms 1.5795 ms] +//! thrpt: [3.1655 Melem/s 3.2171 Melem/s 3.2579 Melem/s] +//! ffi_get_data_chunked/64KiB_over_512B_chunks time: [235.41 us 242.26 us 248.92 us] +//! thrpt: [251.08 MiB/s 257.99 MiB/s 265.50 MiB/s] +//! ``` +//! +//! Re-measure rather than trusting these numbers on a different host or after +//! a source change that touches the fetch path -- see `AGENTS.md`'s Miri +//! section for why a stale recorded figure is worse than none. + +use std::borrow::Cow; +use std::ffi::c_void; +use std::hint::black_box; +use std::time::Duration; + +use criterion::{BatchSize, Criterion, Throughput, criterion_group, criterion_main}; +use stackable_odbc_core::backend::{Backend, StatementBackend}; +use stackable_odbc_core::errors::OdbcError; +use stackable_odbc_core::function_id::FunctionId; +use stackable_odbc_core::odbc_sys::{FreeStmtOption, HandleType}; +use stackable_odbc_core::test_support::{attach_connection, detach_connection}; +use stackable_odbc_core::types::{ + CDataType, ColumnValue, ConnectParams, ExecuteOutcome, FetchResult, InfoType, InfoValue, + SQL_NTS, SqlReturn, SqlState, TypeInfoRow, +}; + +fn env_or(name: &str, default: T) -> T { + std::env::var(name) + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(default) +} + +fn bench_rows() -> u64 { + env_or("BENCH_ROWS", 100_000) +} + +/// Width of the string and bytes columns `ffi_fetch_bound` binds. +const ONE_KIB: usize = 1024; + +/// Width of the string column `ffi_get_data_chunked` reads back in parts. +const CHUNKED_STRING_LEN: usize = 64 * 1024; + +/// The `SQLGetData` target buffer size for `ffi_get_data_chunked` -- small +/// enough against a 64 KiB value that draining it takes many calls. +const CHUNK_BUFFER_LEN: usize = 512; + +/// How many `SQLGetData` calls draining [`CHUNKED_STRING_LEN`] bytes through a +/// [`CHUNK_BUFFER_LEN`]-byte `SQL_C_CHAR` buffer takes: `write_char` +/// (`src/column_value.rs`) reserves the buffer's last byte for the null +/// terminator, so each call before the last delivers `CHUNK_BUFFER_LEN - 1` +/// bytes. +const EXPECTED_CHUNKS: u64 = + ((CHUNKED_STRING_LEN + CHUNK_BUFFER_LEN - 2) / (CHUNK_BUFFER_LEN - 1)) as u64; + +/// The exact SQL text `ffi_fetch_bound`'s setup sends. `BenchBackend` matches +/// on it to decide which shape of statement to hand back, since +/// `Backend::exec_direct` has no other per-call argument that could carry that +/// choice -- this is a benchmark's own fixed request, not data an application +/// chose, so matching on it is safe. +const BOUND_SQL: &str = "SELECT id, payload_text, payload_bytes FROM bound_bench"; + +/// The exact SQL text `ffi_get_data_chunked`'s setup sends. See [`BOUND_SQL`]. +const CHUNKED_SQL: &str = "SELECT wide_text FROM chunked_bench"; + +// --------------------------------------------------------------------------- +// Statement shapes +// --------------------------------------------------------------------------- + +/// `BENCH_ROWS` rows of `(i64, 1 KiB string, 1 KiB bytes)`, for +/// `ffi_fetch_bound`. +/// +/// `get_data` allocates a fresh `String`/`Vec` on every call rather than +/// cloning out of a pre-built row list, because that allocation is exactly +/// the cost `fetch_throughput`'s in-memory `SyntheticStatement` cannot show. +struct BoundColumnsStatement { + rows_left: u64, +} + +impl BoundColumnsStatement { + fn new(rows: u64) -> Self { + Self { rows_left: rows } + } +} + +impl StatementBackend for BoundColumnsStatement { + type Error = OdbcError; + + fn column_count(&self) -> i16 { + 3 + } + + fn fetch(&mut self) -> Result { + if self.rows_left == 0 { + return Ok(FetchResult::NoData); + } + self.rows_left -= 1; + Ok(FetchResult::Row) + } + + fn get_data( + &mut self, + col: u16, + _target_type: CDataType, + ) -> Result, Self::Error> { + match col { + 1 => Ok(Cow::Owned(ColumnValue::I64(self.rows_left as i64))), + 2 => Ok(Cow::Owned(ColumnValue::String("x".repeat(ONE_KIB)))), + 3 => Ok(Cow::Owned(ColumnValue::Bytes(vec![0xABu8; ONE_KIB]))), + other => Err(OdbcError::general( + format!("BoundColumnsStatement has no column {other}"), + SqlState::invalid_descriptor_index(), + )), + } + } +} + +/// One row, one 64 KiB string column, for `ffi_get_data_chunked`. +/// +/// `get_data` returns the whole string on every call rather than a slice +/// starting at some offset -- exactly like `MockLongDataStatement` in core's +/// own test suite (`src/test_utils.rs`) -- because chunking across several +/// `SQLGetData` calls is `write_column_value_at`'s job, not the backend's; a +/// backend that pre-sliced would only be testing itself. +struct ChunkedStatement { + rows_left: u8, +} + +impl ChunkedStatement { + fn new() -> Self { + Self { rows_left: 1 } + } +} + +impl StatementBackend for ChunkedStatement { + type Error = OdbcError; + + fn column_count(&self) -> i16 { + 1 + } + + fn fetch(&mut self) -> Result { + if self.rows_left == 0 { + return Ok(FetchResult::NoData); + } + self.rows_left -= 1; + Ok(FetchResult::Row) + } + + fn get_data( + &mut self, + col: u16, + _target_type: CDataType, + ) -> Result, Self::Error> { + match col { + 1 => Ok(Cow::Owned(ColumnValue::String( + "y".repeat(CHUNKED_STRING_LEN), + ))), + other => Err(OdbcError::general( + format!("ChunkedStatement has no column {other}"), + SqlState::invalid_descriptor_index(), + )), + } + } +} + +/// The one `Backend::Statement` type both benchmarked SQL texts produce. +enum BenchStatement { + Bound(BoundColumnsStatement), + Chunked(ChunkedStatement), +} + +impl StatementBackend for BenchStatement { + type Error = OdbcError; + + fn column_count(&self) -> i16 { + match self { + Self::Bound(s) => s.column_count(), + Self::Chunked(s) => s.column_count(), + } + } + + fn fetch(&mut self) -> Result { + match self { + Self::Bound(s) => s.fetch(), + Self::Chunked(s) => s.fetch(), + } + } + + fn get_data( + &mut self, + col: u16, + target_type: CDataType, + ) -> Result, Self::Error> { + match self { + Self::Bound(s) => s.get_data(col, target_type), + Self::Chunked(s) => s.get_data(col, target_type), + } + } +} + +struct BenchConnection; + +/// The backend both benchmarks in this file drive. +/// +/// `connect` is never called: `attach_connection` (behind the `test-support` +/// feature) puts a `BenchConnection` into the handle directly, so the +/// benchmark never has to invent a connection string. +struct BenchBackend; + +impl Backend for BenchBackend { + type Connection = BenchConnection; + type Statement = BenchStatement; + type Error = OdbcError; + type CancelToken = (); + + fn connect(_: &ConnectParams) -> Result { + Ok(BenchConnection) + } + fn disconnect(_: &mut Self::Connection) -> Result<(), Self::Error> { + Ok(()) + } + fn cancel_token(_: &Self::Connection) -> Self::CancelToken {} + fn get_functions() -> Cow<'static, [FunctionId]> { + Cow::Borrowed(&[]) + } + fn get_type_info(_: &Self::Connection) -> Cow<'static, [TypeInfoRow]> { + Cow::Borrowed(&[]) + } + fn table_types(_: &Self::Connection) -> Vec> { + Vec::new() + } + fn get_info(_: &Self::Connection, _: InfoType) -> Result { + Err(OdbcError::NotImplemented { + feature: "get_info".into(), + }) + } + fn exec_direct( + _: &Self::Connection, + _: &Self::CancelToken, + sql: &str, + ) -> Result { + match sql { + BOUND_SQL => Ok(BenchStatement::Bound(BoundColumnsStatement::new( + bench_rows(), + ))), + CHUNKED_SQL => Ok(BenchStatement::Chunked(ChunkedStatement::new())), + other => Err(OdbcError::NotImplemented { + feature: format!("BenchBackend received unexpected SQL: {other}"), + }), + } + } + fn prepare( + conn: &Self::Connection, + cancel: &Self::CancelToken, + sql: &str, + ) -> Result { + Self::exec_direct(conn, cancel, sql) + } + fn execute( + _: &Self::Connection, + _: &Self::CancelToken, + _: &mut Self::Statement, + _: &[ColumnValue], + ) -> Result { + Ok(ExecuteOutcome::default()) + } + fn tables( + _: &Self::Connection, + _: &Self::CancelToken, + _: &stackable_odbc_core::types::TablesQuery<'_>, + ) -> Result, Self::Error> { + Ok(Vec::new()) + } + fn columns( + _: &Self::Connection, + _: &Self::CancelToken, + _: &stackable_odbc_core::types::ColumnsQuery<'_>, + ) -> Result, Self::Error> { + Ok(Vec::new()) + } + + fn supports_catalogs(_: &Self::Connection) -> bool { + false + } + fn supports_schemas(_: &Self::Connection) -> bool { + false + } + fn alter_table_support(_: &Self::Connection) -> u32 { + 0 + } + fn outer_join_capabilities(_: &Self::Connection) -> u32 { + 0 + } + fn group_by(_: &Self::Connection) -> u16 { + 0 + } + fn null_collation(_: &Self::Connection) -> u16 { + 0 + } + fn identifier_case(_: &Self::Connection) -> u16 { + stackable_odbc_core::types::SQL_IC_SENSITIVE + } + fn quoted_identifier_case(_: &Self::Connection) -> u16 { + stackable_odbc_core::types::SQL_IC_SENSITIVE + } + fn txn_capable(_: &Self::Connection) -> u16 { + 0 + } + fn integrity(_: &Self::Connection) -> bool { + false + } + fn multiple_active_txn(_: &Self::Connection) -> bool { + false + } + fn special_characters(_: &Self::Connection) -> Cow<'static, str> { + Cow::Borrowed("") + } + fn accessible_procedures(_: &Self::Connection) -> bool { + false + } + fn driver_name() -> Cow<'static, str> { + Cow::Borrowed("bench") + } + fn driver_version() -> Cow<'static, str> { + Cow::Borrowed("00.00.0000") + } + fn dbms_name(_: &Self::Connection) -> Cow<'static, str> { + Cow::Borrowed("bench") + } + fn dbms_version(_: &Self::Connection) -> Cow<'static, str> { + Cow::Borrowed("00.00.0000") + } + fn correlation_name(_: &Self::Connection) -> u16 { + 0 + } + fn non_nullable_columns(_: &Self::Connection) -> u16 { + 0 + } + fn expressions_in_order_by(_: &Self::Connection) -> bool { + false + } + fn sql_conformance(_: &Self::Connection) -> u32 { + 0 + } + fn subqueries(_: &Self::Connection) -> u32 { + 0 + } + fn column_alias(_: &Self::Connection) -> bool { + false + } + fn concat_null_behavior(_: &Self::Connection) -> u16 { + 0 + } + fn union_support(_: &Self::Connection) -> u32 { + 0 + } + fn convert_functions(_: &Self::Connection) -> u32 { + 0 + } + fn order_by_columns_in_select(_: &Self::Connection) -> bool { + false + } + fn accessible_tables(_: &Self::Connection) -> bool { + false + } + fn data_source_read_only(_: &Self::Connection) -> bool { + false + } + fn search_pattern_escape(_: &Self::Connection) -> Cow<'static, str> { + Cow::Borrowed("") + } + fn keywords(_: &Self::Connection) -> Cow<'static, [Cow<'static, str>]> { + Cow::Borrowed(&[]) + } + fn timedate_add_intervals(_: &Self::Connection) -> u32 { + 0 + } + fn timedate_diff_intervals(_: &Self::Connection) -> u32 { + 0 + } + fn default_txn_isolation(_: &Self::Connection) -> u32 { + 0 + } + fn txn_isolation_options(_: &Self::Connection) -> u32 { + 0 + } +} + +// --------------------------------------------------------------------------- +// Handle setup, shared by both groups +// --------------------------------------------------------------------------- + +/// Allocate env -> connection -> statement, and attach a `BenchConnection` to +/// the connection handle through the `test-support` feature's +/// `attach_connection` -- the same shortcut a driver's own test suite uses to +/// reach the connected path without a real data source. +fn alloc_and_connect() -> (*mut c_void, *mut c_void, *mut c_void) { + use stackable_odbc_core::ffi::handle::sql_alloc_handle; + unsafe { + let mut env: *mut c_void = std::ptr::null_mut(); + assert_eq!( + sql_alloc_handle::( + HandleType::Env as i16, + std::ptr::null_mut(), + &mut env + ), + SqlReturn::SUCCESS, + ); + let mut conn: *mut c_void = std::ptr::null_mut(); + assert_eq!( + sql_alloc_handle::(HandleType::Dbc as i16, env, &mut conn), + SqlReturn::SUCCESS, + ); + attach_connection::(conn, BenchConnection).expect("attach_connection"); + let mut stmt: *mut c_void = std::ptr::null_mut(); + assert_eq!( + sql_alloc_handle::(HandleType::Stmt as i16, conn, &mut stmt), + SqlReturn::SUCCESS, + ); + (env, conn, stmt) + } +} + +/// Detach the connection and free all three handles, mirroring the teardown +/// order `SQLDisconnect`/`SQLFreeHandle` would use. +fn free_all(env: *mut c_void, conn: *mut c_void, stmt: *mut c_void) { + use stackable_odbc_core::ffi::handle::sql_free_handle; + unsafe { + assert_eq!( + sql_free_handle::(HandleType::Stmt as i16, stmt), + SqlReturn::SUCCESS, + ); + // SQLFreeHandle(SQL_HANDLE_DBC) refuses a connection still holding a + // connection (HY010); detach_connection takes it back out without + // calling Backend::disconnect, which is what an offline benchmark + // (no real data source to disconnect from) wants. + detach_connection::(conn).expect("detach_connection"); + assert_eq!( + sql_free_handle::(HandleType::Dbc as i16, conn), + SqlReturn::SUCCESS, + ); + assert_eq!( + sql_free_handle::(HandleType::Env as i16, env), + SqlReturn::SUCCESS, + ); + } +} + +fn utf16_nts(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +/// Reset `stmt` for another execution of `sql` and run it, asserting success. +/// +/// `SQLFreeStmt(SQL_CLOSE)` rather than `SQLCloseCursor`: the spec makes the +/// former a no-op when no cursor is open, so it works both for the very first +/// execution (no cursor yet) and every re-execution after (`ffi/handle.rs`'s +/// `sql_free_stmt`, `FreeStmtOption::Close`), where `SQLCloseCursor` would +/// return `24000` on the first call. +fn reexecute(stmt: *mut c_void, sql: &str) { + use stackable_odbc_core::ffi::execute::sql_exec_direct_w; + use stackable_odbc_core::ffi::handle::sql_free_stmt; + let text = utf16_nts(sql); + unsafe { + assert_eq!( + sql_free_stmt::(stmt, FreeStmtOption::Close as u16), + SqlReturn::SUCCESS, + ); + assert_eq!( + sql_exec_direct_w::(stmt, text.as_ptr(), SQL_NTS), + SqlReturn::SUCCESS, + ); + } +} + +// --------------------------------------------------------------------------- +// ffi_fetch_bound +// --------------------------------------------------------------------------- + +/// `SQLBindCol` three columns, then loop `SQLFetch` over `BENCH_ROWS` rows. +/// +/// Bound once, outside the measured loop: ARD bindings are their own +/// descriptor storage, independent of the backend statement, so they survive +/// `SQLFreeStmt(SQL_CLOSE)` and re-`SQLExecDirectW` across iterations. Only +/// `reexecute` (producing a fresh `BENCH_ROWS`-row statement) and the +/// `SQLFetch` loop are inside `iter_batched`'s timed routine. +fn ffi_fetch_bound(c: &mut Criterion) { + use stackable_odbc_core::ffi::bind::sql_bind_col; + use stackable_odbc_core::ffi::fetch::sql_fetch; + + let rows = bench_rows(); + let (env, conn, stmt) = alloc_and_connect(); + + let mut id_buf: i64 = 0; + let mut id_ind: isize = 0; + let mut text_buf = [0u8; ONE_KIB + 1]; // +1: SQL_C_CHAR null terminator + let mut text_ind: isize = 0; + let mut bytes_buf = [0u8; ONE_KIB]; + let mut bytes_ind: isize = 0; + + unsafe { + assert_eq!( + sql_bind_col::( + stmt, + 1, + CDataType::SBigInt as i16, + std::ptr::from_mut(&mut id_buf).cast::(), + isize::try_from(std::mem::size_of::()).expect("size_of:: fits isize"), + std::ptr::from_mut(&mut id_ind), + ), + SqlReturn::SUCCESS, + ); + assert_eq!( + sql_bind_col::( + stmt, + 2, + CDataType::Char as i16, + text_buf.as_mut_ptr().cast::(), + isize::try_from(text_buf.len()).expect("text_buf.len() fits isize"), + std::ptr::from_mut(&mut text_ind), + ), + SqlReturn::SUCCESS, + ); + assert_eq!( + sql_bind_col::( + stmt, + 3, + CDataType::Binary as i16, + bytes_buf.as_mut_ptr().cast::(), + isize::try_from(bytes_buf.len()).expect("bytes_buf.len() fits isize"), + std::ptr::from_mut(&mut bytes_ind), + ), + SqlReturn::SUCCESS, + ); + } + + let mut group = c.benchmark_group("ffi_fetch_bound"); + group.throughput(Throughput::Elements(rows)); + group.bench_function(format!("{rows}_rows"), |b| { + b.iter_batched( + || reexecute(stmt, BOUND_SQL), + |()| unsafe { + let mut count = 0u64; + loop { + match sql_fetch::(stmt) { + SqlReturn::SUCCESS => { + count += 1; + black_box(id_buf); + black_box(&text_buf); + black_box(&bytes_buf); + } + SqlReturn::NO_DATA => break, + other => panic!("sql_fetch returned {other:?}"), + } + } + // Catches the PerIteration/batching mistake documented below + // even if it recurs in a different shape: a routine that + // silently fetched zero or partial rows would otherwise still + // report a (meaningless) time. + assert_eq!(count, rows, "did not fetch exactly BENCH_ROWS rows"); + black_box(count); + }, + // PerIteration, not LargeInput/SmallInput: those batch several + // setup() calls ahead of the timed routine() calls they belong + // to, and setup here mutates the one shared `stmt` handle rather + // than returning an independent value per call. Under batching, + // only the last setup() in a batch actually leaves a fresh + // BENCH_ROWS-row statement behind, so every routine() after the + // first in that batch fetches against an already-exhausted one + // and returns SQL_NO_DATA immediately -- silently, since nothing + // here asserts the loop actually ran BENCH_ROWS times. + // PerIteration forces exactly one setup() before each timed + // routine(), which is the only ordering this shared-handle + // design is correct under. + BatchSize::PerIteration, + ); + }); + group.finish(); + + free_all(env, conn, stmt); +} + +// --------------------------------------------------------------------------- +// ffi_get_data_chunked +// --------------------------------------------------------------------------- + +/// `SQLFetch` the one row, then drain its 64 KiB string column through a +/// 512-byte `SQLGetData` buffer until `SQL_NO_DATA`. +/// +/// No `SQLBindCol` here: this group is late binding by construction, so every +/// byte crosses through `sql_get_data`'s `GetDataCursor` chunking loop +/// (`cursor.delivered` / `cursor.done`), which `ffi_fetch_bound`'s bound +/// columns never touch at all. +fn ffi_get_data_chunked(c: &mut Criterion) { + use stackable_odbc_core::ffi::fetch::{sql_fetch, sql_get_data}; + + let (env, conn, stmt) = alloc_and_connect(); + + let mut group = c.benchmark_group("ffi_get_data_chunked"); + group.throughput(Throughput::Bytes(CHUNKED_STRING_LEN as u64)); + group.bench_function(format!("64KiB_over_{CHUNK_BUFFER_LEN}B_chunks"), |b| { + b.iter_batched( + || reexecute(stmt, CHUNKED_SQL), + |()| unsafe { + assert_eq!( + sql_fetch::(stmt), + SqlReturn::SUCCESS, + "sql_fetch" + ); + let mut buf = [0u8; CHUNK_BUFFER_LEN]; + let mut ind: isize = 0; + let mut chunks = 0u64; + loop { + let ret = sql_get_data::( + stmt, + 1, + CDataType::Char as i16, + buf.as_mut_ptr().cast::(), + isize::try_from(buf.len()).expect("buf.len() fits isize"), + std::ptr::from_mut(&mut ind), + ); + match ret { + SqlReturn::SUCCESS | SqlReturn::SUCCESS_WITH_INFO => { + chunks += 1; + black_box(&buf); + } + SqlReturn::NO_DATA => break, + other => panic!("sql_get_data returned {other:?}"), + } + } + // Same reasoning as ffi_fetch_bound's row-count assert: + // a routine that silently drained zero or a partial value + // would otherwise still report a (meaningless) time. + assert_eq!( + chunks, EXPECTED_CHUNKS, + "did not drain the full 64 KiB value" + ); + black_box(chunks); + }, + // PerIteration: see the comment on the same choice in + // ffi_fetch_bound. This group's routine is fast enough that + // SmallInput's batching is exactly where the bug that choice + // avoids was first caught -- reexecute's shared `stmt` handle + // got reset once per batch instead of once per iteration, so + // every fetch after the batch's first returned SQL_NO_DATA. + BatchSize::PerIteration, + ); + }); + group.finish(); + + free_all(env, conn, stmt); +} + +fn benches_config() -> Criterion { + // Mirrors fetch_throughput's configure_for_size heuristic: a row count + // large enough to make one iteration take seconds gets fewer samples and + // a longer measurement window, so the whole run stays bounded. + if bench_rows() > 20_000 { + Criterion::default() + .sample_size(20) + .measurement_time(Duration::from_secs(15)) + .warm_up_time(Duration::from_secs(2)) + } else { + Criterion::default() + } +} + +criterion_group! { + name = benches; + config = benches_config(); + targets = ffi_fetch_bound, ffi_get_data_chunked +} +criterion_main!(benches); diff --git a/bench/benches/handle_lookup.rs b/bench/benches/handle_lookup.rs new file mode 100644 index 0000000..fa442fa --- /dev/null +++ b/bench/benches/handle_lookup.rs @@ -0,0 +1,278 @@ +//! Registry-lookup cost on the FFI entry path. +//! +//! The sibling `fetch_throughput` benchmark drives `SyntheticStatement` +//! directly and never touches the handle registry, so it cannot see this at +//! all. Every `SQLxxx` entry point in the crate reaches its handle through +//! `HandleScope::get`, which is the one place that cost is paid, and paid on +//! every call whatever the function goes on to do. +//! +//! Two shapes, because the error path is not the success path scaled: +//! +//! - `get` — one `scope.get`, then trivial work. `SQLFreeStmt(SQL_UNBIND)` +//! clears a binding vector that is already empty, so essentially all of what +//! is measured is the lookup. +//! - `get_then_push_diagnostic` — the error path, where `panic_safe` also has +//! to find the handle again to post a diagnostic against it. +//! `SQLNumResultCols` on a statement that was never executed is the cheapest +//! way to reach it. + +use std::borrow::Cow; +use std::ffi::c_void; + +use criterion::{Criterion, criterion_group, criterion_main}; +use stackable_odbc_core::backend::Backend; +use stackable_odbc_core::errors::OdbcError; +use stackable_odbc_core::function_id::FunctionId; +use stackable_odbc_core::types::{ConnectParams, InfoValue, InfoType, SqlReturn, TypeInfoRow}; +use stackable_odbc_core::odbc_sys::{FreeStmtOption, HandleType}; + +/// The smallest backend the trait allows. Nothing here is ever called: the +/// benchmarked entry points resolve a handle and return, without reaching a +/// connection. +struct BenchBackend; + +struct BenchConnection; +struct BenchStatement; + +impl stackable_odbc_core::backend::StatementBackend for BenchStatement { + type Error = OdbcError; +} + +impl Backend for BenchBackend { + type Connection = BenchConnection; + type Statement = BenchStatement; + type Error = OdbcError; + type CancelToken = (); + + fn connect(_: &ConnectParams) -> Result { + Ok(BenchConnection) + } + fn disconnect(_: &mut Self::Connection) -> Result<(), Self::Error> { + Ok(()) + } + fn get_functions() -> Cow<'static, [FunctionId]> { + Cow::Borrowed(&[]) + } + fn get_type_info(_: &Self::Connection) -> Cow<'static, [TypeInfoRow]> { + Cow::Borrowed(&[]) + } + fn table_types(_: &Self::Connection) -> Vec> { + Vec::new() + } + fn cancel_token(_: &Self::Connection) -> Self::CancelToken {} + fn get_info(_: &Self::Connection, _: InfoType) -> Result { + Err(OdbcError::NotImplemented { + feature: "get_info".into(), + }) + } + fn exec_direct( + _: &Self::Connection, + _: &Self::CancelToken, + _: &str, + ) -> Result { + Ok(BenchStatement) + } + fn prepare( + _: &Self::Connection, + _: &Self::CancelToken, + _: &str, + ) -> Result { + Ok(BenchStatement) + } + fn execute( + _: &Self::Connection, + _: &Self::CancelToken, + _: &mut Self::Statement, + _: &[stackable_odbc_core::types::ColumnValue], + ) -> Result { + Ok(stackable_odbc_core::types::ExecuteOutcome::default()) + } + fn tables( + _: &Self::Connection, + _: &Self::CancelToken, + _: &stackable_odbc_core::types::TablesQuery<'_>, + ) -> Result, Self::Error> { + Ok(Vec::new()) + } + fn columns( + _: &Self::Connection, + _: &Self::CancelToken, + _: &stackable_odbc_core::types::ColumnsQuery<'_>, + ) -> Result, Self::Error> { + Ok(Vec::new()) + } + + fn supports_catalogs(_: &Self::Connection) -> bool { + false + } + fn supports_schemas(_: &Self::Connection) -> bool { + false + } + fn alter_table_support(_: &Self::Connection) -> u32 { + 0 + } + fn outer_join_capabilities(_: &Self::Connection) -> u32 { + 0 + } + fn group_by(_: &Self::Connection) -> u16 { + 0 + } + fn null_collation(_: &Self::Connection) -> u16 { + 0 + } + fn identifier_case(_: &Self::Connection) -> u16 { + stackable_odbc_core::types::SQL_IC_SENSITIVE + } + fn quoted_identifier_case(_: &Self::Connection) -> u16 { + stackable_odbc_core::types::SQL_IC_SENSITIVE + } + fn txn_capable(_: &Self::Connection) -> u16 { + 0 + } + fn integrity(_: &Self::Connection) -> bool { + false + } + fn multiple_active_txn(_: &Self::Connection) -> bool { + false + } + fn special_characters(_: &Self::Connection) -> Cow<'static, str> { + Cow::Borrowed("") + } + fn accessible_procedures(_: &Self::Connection) -> bool { + false + } + fn driver_name() -> Cow<'static, str> { + Cow::Borrowed("bench") + } + fn driver_version() -> Cow<'static, str> { + Cow::Borrowed("00.00.0000") + } + fn dbms_name(_: &Self::Connection) -> Cow<'static, str> { + Cow::Borrowed("bench") + } + fn dbms_version(_: &Self::Connection) -> Cow<'static, str> { + Cow::Borrowed("00.00.0000") + } + fn correlation_name(_: &Self::Connection) -> u16 { + 0 + } + fn non_nullable_columns(_: &Self::Connection) -> u16 { + 0 + } + fn expressions_in_order_by(_: &Self::Connection) -> bool { + false + } + fn sql_conformance(_: &Self::Connection) -> u32 { + 0 + } + fn subqueries(_: &Self::Connection) -> u32 { + 0 + } + fn column_alias(_: &Self::Connection) -> bool { + false + } + fn concat_null_behavior(_: &Self::Connection) -> u16 { + 0 + } + fn union_support(_: &Self::Connection) -> u32 { + 0 + } + fn convert_functions(_: &Self::Connection) -> u32 { + 0 + } + fn order_by_columns_in_select(_: &Self::Connection) -> bool { + false + } + fn accessible_tables(_: &Self::Connection) -> bool { + false + } + fn data_source_read_only(_: &Self::Connection) -> bool { + false + } + fn search_pattern_escape(_: &Self::Connection) -> Cow<'static, str> { + Cow::Borrowed("") + } + fn keywords(_: &Self::Connection) -> Cow<'static, [Cow<'static, str>]> { + Cow::Borrowed(&[]) + } + fn timedate_add_intervals(_: &Self::Connection) -> u32 { + 0 + } + fn timedate_diff_intervals(_: &Self::Connection) -> u32 { + 0 + } + fn default_txn_isolation(_: &Self::Connection) -> u32 { + 0 + } + fn txn_isolation_options(_: &Self::Connection) -> u32 { + 0 + } +} + +/// Allocate env → connection → statement, all unconnected. +/// +/// No connection is attached: neither benchmarked entry point reaches one, and +/// leaving it off keeps the measurement to the registry. +fn alloc() -> (*mut c_void, *mut c_void, *mut c_void) { + use stackable_odbc_core::ffi::handle::sql_alloc_handle; + unsafe { + let mut env: *mut c_void = std::ptr::null_mut(); + assert_eq!( + sql_alloc_handle::(HandleType::Env as i16, std::ptr::null_mut(), &mut env), + SqlReturn::SUCCESS, + ); + let mut conn: *mut c_void = std::ptr::null_mut(); + assert_eq!( + sql_alloc_handle::(HandleType::Dbc as i16, env, &mut conn), + SqlReturn::SUCCESS, + ); + let mut stmt: *mut c_void = std::ptr::null_mut(); + assert_eq!( + sql_alloc_handle::(HandleType::Stmt as i16, conn, &mut stmt), + SqlReturn::SUCCESS, + ); + (env, conn, stmt) + } +} + +fn free(env: *mut c_void, conn: *mut c_void, stmt: *mut c_void) { + use stackable_odbc_core::ffi::handle::sql_free_handle; + unsafe { + let _ = sql_free_handle::(HandleType::Stmt as i16, stmt); + let _ = sql_free_handle::(HandleType::Dbc as i16, conn); + let _ = sql_free_handle::(HandleType::Env as i16, env); + } +} + +fn bench_get(c: &mut Criterion) { + let (env, conn, stmt) = alloc(); + let mut group = c.benchmark_group("handle_scope"); + + // One `scope.get`, then a no-op: the bindings vector is already empty. + group.bench_function("get", |b| { + b.iter(|| unsafe { + stackable_odbc_core::ffi::handle::sql_free_stmt::( + std::hint::black_box(stmt), + FreeStmtOption::Unbind as u16, + ) + }); + }); + + // The error path: `scope.get`, then `panic_safe` finding the handle again + // to post the diagnostic against it. + group.bench_function("get_then_push_diagnostic", |b| { + b.iter(|| unsafe { + let mut count: i16 = 0; + stackable_odbc_core::ffi::cursor::sql_num_result_cols::( + std::hint::black_box(stmt), + &mut count, + ) + }); + }); + + group.finish(); + free(env, conn, stmt); +} + +criterion_group!(benches, bench_get); +criterion_main!(benches); diff --git a/clippy.toml b/clippy.toml new file mode 100644 index 0000000..906adcc --- /dev/null +++ b/clippy.toml @@ -0,0 +1,8 @@ +allow-unwrap-in-tests = true +allow-panic-in-tests = true +allow-expect-in-tests = true + +disallowed-methods = [ + { path = "std::time::SystemTime::now", reason = "core is a deterministic marshalling library, and its unit tests, proptests and fuzz targets rely on that. Exactly one wall-clock read is legitimate: `column_value::current_utc_date`, which SQL_TYPE_TIME -> SQL_C_TYPE_TIMESTAMP requires ('the date fields of the timestamp structure are set to the current date'). Each read also makes -Zmiri-disable-isolation mandatory, without which Miri aborts rather than failing an assertion. If the spec forces another one, add #[allow(clippy::disallowed_methods)] with a comment naming the rule that forces it" }, + { path = "std::time::Instant::now", reason = "a duration measurement in a marshalling path is nondeterminism with no caller. To measure per-phase cost set ODBC_PROFILING=1 and read the tracing spans logging.rs already emits, or measure in the driver crate. Note that core is no longer entirely timing-free: query_timer.rs enforces SQL_ATTR_QUERY_TIMEOUT, but it expresses its deadline as a Condvar::wait_timeout rather than by reading a clock, which is what keeps this ban intact. Prefer that shape; if the spec forces an actual clock read, add #[allow(clippy::disallowed_methods)] with a comment naming the rule that forces it" }, +] diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..ab16832 --- /dev/null +++ b/deny.toml @@ -0,0 +1,46 @@ +# Cargo deny configuration for stackable-odbc-core +# Based on operator-rs conventions. +# Run: cargo deny check + +# Windows is listed because the crate has #[cfg(windows)] code (ConfigDSNW) and +# README claims it as a first-class target; without it, any Windows-only +# dependency is never license- or advisory-scanned. macOS is deliberately +# absent, matching the CI matrix and the platforms the crate claims. +[graph] +targets = [ + { triple = "x86_64-unknown-linux-gnu" }, + { triple = "aarch64-unknown-linux-gnu" }, + { triple = "x86_64-pc-windows-msvc" }, +] + +# Stated explicitly rather than relying on cargo-deny's defaults, which have +# changed across major versions. +[advisories] +yanked = "deny" +unmaintained = "all" +ignore = [] + +[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" diff --git a/fuzz/.gitignore b/fuzz/.gitignore new file mode 100644 index 0000000..5c404b9 --- /dev/null +++ b/fuzz/.gitignore @@ -0,0 +1,5 @@ +target +corpus +artifacts +coverage +Cargo.lock diff --git a/fuzz/Cargo.toml b/fuzz/Cargo.toml new file mode 100644 index 0000000..fbee2c8 --- /dev/null +++ b/fuzz/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "stackable-odbc-core-fuzz" +version = "0.0.1" +publish = false +edition = "2021" + +[package.metadata] +cargo-fuzz = true + +# Own workspace: keeps `cargo fuzz` from resolving against the parent workspace +# (which pins a stable toolchain; libFuzzer needs nightly). +[workspace] + +[[bin]] +name = "utf16" +path = "fuzz_targets/utf16.rs" +test = false +doc = false +bench = false + +[[bin]] +name = "column_value" +path = "fuzz_targets/column_value.rs" +test = false +doc = false +bench = false + +[dependencies] +arbitrary = { version = "1", features = ["derive"] } +libfuzzer-sys = "0.4" +stackable-odbc-core = { path = ".." } diff --git a/fuzz/README.md b/fuzz/README.md new file mode 100644 index 0000000..f2515f1 --- /dev/null +++ b/fuzz/README.md @@ -0,0 +1,56 @@ +# Fuzz targets + +These targets cover the `unsafe` pointer-marshalling paths in +`stackable-odbc-core`, which is where AddressSanitizer catches what clippy +cannot see. Each one allocates its output buffer at exactly the size a correct +application would, so any write past the end is a reported error rather than a +silent overrun into neighbouring memory. + +That size is the `BufferLength` argument for a variable-length C target, and the +C type's own size for a fixed-length one, which ignores `BufferLength` +altogether. + +- `utf16` covers `utf16_to_string` and `write_utf16`. +- `column_value` covers `write_column_value` across every marshallable value + variant and C target type, which is the full coercion matrix. + +The pure-safe parsers, `translate_escapes`, `ConnectParams::parse` and the +drivers' own type-name parsers, contain no `unsafe`, so AddressSanitizer adds +nothing over property tests. They are covered by +[`proptest`](https://docs.rs/proptest) suites next to the code, which run on +stable under an ordinary `cargo test` and assert both never-panics and +round-trip invariants. + +## Running + +[cargo-fuzz](https://github.com/rust-fuzz/cargo-fuzz) needs nightly, because +libFuzzer does. + +```bash +cargo install cargo-fuzz +cargo +nightly fuzz run utf16 +cargo +nightly fuzz run column_value +``` + +If cargo-fuzz fails with "sanitizer is incompatible with statically linked +libc", it picked a musl target. Pin the gnu triple explicitly, which is what CI +does: + +```bash +cargo +nightly fuzz run utf16 --target x86_64-unknown-linux-gnu +``` + +`cargo fuzz run` runs until it finds a crash or you stop it. To bound a run, +pass a libFuzzer flag after `--`: + +```bash +cargo +nightly fuzz run column_value -- -max_total_time=60 # stop after 60s +cargo +nightly fuzz run column_value -- -runs=1000000 # or a run count +``` + +## Workspace + +This is its own Cargo workspace, so `cargo build` in the repository root does +not touch it and no `pre-commit` hook compiles it. Anything that changes the +`Backend` or `StatementBackend` trait can break it while every root check still +passes, so build it by hand after such a change. diff --git a/fuzz/fuzz_targets/column_value.rs b/fuzz/fuzz_targets/column_value.rs new file mode 100644 index 0000000..e580b70 --- /dev/null +++ b/fuzz/fuzz_targets/column_value.rs @@ -0,0 +1,213 @@ +#![no_main] + +use arbitrary::Arbitrary; +use libfuzzer_sys::fuzz_target; +use stackable_odbc_core::column_value::{NumericTarget, write_column_value}; +use stackable_odbc_core::types::{CDataType, ColumnValue}; +use std::ffi::c_void; + +// Fuzzes the full write_column_value coercion matrix — every marshallable +// ColumnValue variant against every interesting C target type — the pointer +// marshalling and narrowing/formatting code where a buffer overrun would live. +// The output buffer is allocated at exactly the declared length, so +// AddressSanitizer reports any write past it. Nested container variants +// (Array/Map/Row) are omitted: they are not marshalled by SQLGetData. +#[derive(Arbitrary, Debug)] +enum FuzzValue { + Null, + String(String), + I8(i8), + I16(i16), + I32(i32), + I64(i64), + F32(f32), + F64(f64), + Bool(bool), + Date { year: i16, month: u16, day: u16 }, + Time { hour: u16, minute: u16, second: u16, fraction: u32 }, + Timestamp { year: i16, month: u16, day: u16, hour: u16, minute: u16, second: u16, fraction: u32 }, + Bytes(Vec), + Guid([u8; 16]), + Decimal(String), + TimestampTz { year: i16, month: u16, day: u16, hour: u16, minute: u16, second: u16, fraction: u32, timezone_offset_minutes: i16 }, + Json(String), + // `precision` is a fuzzed index rather than an `odbc_sys::Interval`, which + // is not `Arbitrary`; `interval_precision` maps it onto all thirteen. + IntervalYearMonth { years: i32, months: i32, precision: u8 }, + IntervalDayTime { total_nanoseconds: i128, precision: u8 }, +} + +/// Map a fuzzed byte onto one of the thirteen interval precisions. +/// +/// All thirteen are reachable, so the fuzzer explores every field span rather +/// than only the one a fixed choice would pin. +fn interval_precision(raw: u8) -> stackable_odbc_core::types::Interval { + use stackable_odbc_core::types::Interval::*; + match raw % 13 { + 0 => Year, + 1 => Month, + 2 => Day, + 3 => Hour, + 4 => Minute, + 5 => Second, + 6 => YearToMonth, + 7 => DayToHour, + 8 => DayToMinute, + 9 => DayToSecond, + 10 => HourToMinute, + 11 => HourToSecond, + _ => MinuteToSecond, + } +} + +impl From for ColumnValue { + fn from(v: FuzzValue) -> ColumnValue { + match v { + FuzzValue::Null => ColumnValue::Null, + FuzzValue::String(s) => ColumnValue::String(s), + FuzzValue::I8(n) => ColumnValue::I8(n), + FuzzValue::I16(n) => ColumnValue::I16(n), + FuzzValue::I32(n) => ColumnValue::I32(n), + FuzzValue::I64(n) => ColumnValue::I64(n), + FuzzValue::F32(n) => ColumnValue::F32(n), + FuzzValue::F64(n) => ColumnValue::F64(n), + FuzzValue::Bool(b) => ColumnValue::Bool(b), + FuzzValue::Date { year, month, day } => ColumnValue::Date { year, month, day }, + FuzzValue::Time { hour, minute, second, fraction } => { + ColumnValue::Time { hour, minute, second, fraction } + } + FuzzValue::Timestamp { year, month, day, hour, minute, second, fraction } => { + ColumnValue::Timestamp { year, month, day, hour, minute, second, fraction } + } + FuzzValue::Bytes(b) => ColumnValue::Bytes(b), + FuzzValue::Guid(g) => ColumnValue::Guid(g), + FuzzValue::Decimal(s) => ColumnValue::Decimal(s), + FuzzValue::TimestampTz { + year, month, day, hour, minute, second, fraction, timezone_offset_minutes, + } => ColumnValue::TimestampTz { + year, month, day, hour, minute, second, fraction, timezone_offset_minutes, + }, + FuzzValue::Json(s) => ColumnValue::Json(s), + FuzzValue::IntervalYearMonth { years, months, precision } => { + ColumnValue::IntervalYearMonth { + years, + months, + precision: interval_precision(precision), + } + } + FuzzValue::IntervalDayTime { total_nanoseconds, precision } => { + ColumnValue::IntervalDayTime { + total_nanoseconds, + precision: interval_precision(precision), + } + } + } + } +} + +// The interesting C target types SQLGetData can be asked to produce. +const TARGETS: &[CDataType] = &[ + CDataType::WChar, + CDataType::Char, + CDataType::SLong, + CDataType::ULong, + CDataType::SShort, + CDataType::UShort, + CDataType::STinyInt, + CDataType::UTinyInt, + CDataType::SBigInt, + CDataType::UBigInt, + CDataType::Float, + CDataType::Double, + CDataType::Bit, + CDataType::Binary, + CDataType::Guid, + CDataType::TypeDate, + CDataType::TypeTime, + CDataType::TypeTimestamp, + CDataType::Numeric, + CDataType::Default, +]; + +#[derive(Arbitrary, Debug)] +struct Input { + value: FuzzValue, + target: u8, + buf_len: u8, + // The ARD's SQL_DESC_PRECISION and SQL_DESC_SCALE, which only SQL_C_NUMERIC + // reads. Fuzzed rather than fixed: they drive a digit-string rescale and a + // u128 range check inside `to_numeric_struct`, so they are live input to + // the newest marshalling path rather than a constant to pass through. + // `i16` in full, including the negative values a real application can set, + // because rejecting those is part of what is under test. + precision: i16, + scale: i16, +} + +fuzz_target!(|input: Input| { + let value: ColumnValue = input.value.into(); + let target = TARGETS[input.target as usize % TARGETS.len()]; + let buf_len = input.buf_len as isize; + + // Allocation size and the `BufferLength` argument are decoupled, exactly as + // in a real ODBC application: + // + // - Variable-length C targets (CHAR/WCHAR/BINARY) honour `BufferLength`, so + // an exact-size buffer lets ASAN catch a write past it (a buffer overrun). + // - Fixed-length C targets ignore `BufferLength` per the ODBC spec — an app + // supplies a variable of the C type's size and may pass any `BufferLength` + // (0 is common and legal), so buffer size is independent of the argument. + // Give those a buffer sized to the C type; writing `sizeof(T)` into a + // smaller one would be the app's contract violation, not a driver bug. + // 256 bytes covers every fixed ODBC C type (SQL_C_NUMERIC and the interval + // structs, the largest, are well under it), so a write past it would be a + // genuine defect. + // - SQL_C_DEFAULT is neither, and giving it the blanket 256 bytes is what + // hid a real heap overflow from this fuzzer: the *driver* picks the C + // type there, and it picks from the runtime `ColumnValue` variant rather + // than from the `sql_type` the application sized its buffer against, so + // `BufferLength` is the only bound that exists. A positive `BufferLength` + // must therefore be honoured and gets an exact-size allocation. Zero is + // the documented exemption — it is how an application says "not + // applicable" for a fixed C type, so it carries no size information — and + // gets the fixed-type allocation instead. + let variable_len = matches!( + target, + CDataType::WChar | CDataType::Char | CDataType::Binary + ); + let bounded_by_buf_len = variable_len || (target == CDataType::Default && buf_len > 0); + let alloc = if bounded_by_buf_len { + input.buf_len as usize + } else { + 256 + }; + // Deliberately misaligned, not incidentally aligned. `vec![0u8; alloc]` + // handed over `buf.as_mut_ptr()` directly, which the allocator returns + // suitably aligned for anything this writes, so no run ever exercised the + // unaligned path and the `write_unaligned` calls were taken on trust. An + // arena of the widest-*alignment* target type (8 bytes: `i64`, `f64`, + // `SQLLEN`) offset by one byte is misaligned for every target on every + // platform, and one extra element keeps `alloc` bytes writable past the + // offset. + let mut arena = vec![0u64; alloc / 8 + 2]; + let mut ind_arena = [0isize; 2]; + // SAFETY: both offsets stay inside their own allocation, and every write + // through them is an unaligned write. + unsafe { + let buf = arena.as_mut_ptr().cast::().add(1); + let ind = ind_arena.as_mut_ptr().cast::().add(1).cast::(); + debug_assert!(!ind.is_aligned(), "the point of the offset"); + let _ = write_column_value( + &value, + target, + buf as *mut c_void, + buf_len, + ind, + NumericTarget { + interval_leading_precision: 0, + precision: input.precision, + scale: input.scale, + }, + ); + } +}); diff --git a/fuzz/fuzz_targets/utf16.rs b/fuzz/fuzz_targets/utf16.rs new file mode 100644 index 0000000..0f1f1c4 --- /dev/null +++ b/fuzz/fuzz_targets/utf16.rs @@ -0,0 +1,44 @@ +#![no_main] + +use libfuzzer_sys::fuzz_target; +use stackable_odbc_core::utf16::{utf16_to_string, write_utf16}; + +// Fuzzes the UTF-16 marshalling helpers. Every output buffer is allocated at +// exactly the length passed to the function, so AddressSanitizer flags any read +// or write past the caller's buffer — the buffer-overrun defect class that +// clippy and Miri-less unit tests cannot see. +fuzz_target!(|data: &[u8]| { + // Interpret the input as little-endian UTF-16 code units. + let units: Vec = data + .chunks_exact(2) + .map(|c| u16::from_le_bytes([c[0], c[1]])) + .collect(); + + // Explicit-length reading: any code units are valid; the length is exact. + if !units.is_empty() { + unsafe { + let _ = utf16_to_string(units.as_ptr(), units.len() as i32); + } + } + + // SQL_NTS reading requires a null-terminated buffer (utf16_to_string's + // documented safety contract, which the Driver Manager upholds in practice). + // Append a terminator so the scan stays in bounds; feeding an unterminated + // buffer with SQL_NTS would violate the contract, not find a real bug. + let mut nts = units.clone(); + nts.push(0); + unsafe { + let _ = utf16_to_string(nts.as_ptr(), -3); + } + + // Writing side: a spread of output buffer sizes, each allocated to its exact + // length so an off-by-one overrun is caught. + let s = String::from_utf16_lossy(&units); + for &buf_len in &[0i16, 1, 2, 3, 5, 16] { + let mut buf = vec![0u16; buf_len.max(0) as usize]; + let mut len_out: i16 = 0; + unsafe { + let _ = write_utf16(&s, buf.as_mut_ptr(), buf_len, &mut len_out); + } + } +}); diff --git a/release.toml b/release.toml new file mode 100644 index 0000000..6690e3c --- /dev/null +++ b/release.toml @@ -0,0 +1,61 @@ +# cargo-release configuration for stackable-odbc-core. +# +# 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, commits, tags and pushes. +# Publishing is a separate, manual step for now. + +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-core/compare/v[0-9]+\\.[0-9]+\\.[0-9]+\\.\\.\\.HEAD" +replace = "[Unreleased]: https://github.com/stackabletech/stackable-odbc-core/compare/v{{version}}...HEAD\n[{{version}}]: https://github.com/stackabletech/stackable-odbc-core/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-core/commits/HEAD" +replace = "[Unreleased]: https://github.com/stackabletech/stackable-odbc-core/compare/v{{version}}...HEAD\n[{{version}}]: https://github.com/stackabletech/stackable-odbc-core/releases/tag/v{{version}}" +min = 0 diff --git a/release/release.sh b/release/release.sh new file mode 100755 index 0000000..8a52266 --- /dev/null +++ b/release/release.sh @@ -0,0 +1,23 @@ +#!/usr/bin/env bash +# release.sh — 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) and for the +# `main`-only branch restriction. +set -euo pipefail + +if [[ $# -lt 1 ]]; then + echo "usage: release.sh [--execute]" >&2 + exit 2 +fi + +BUMP="$1" +shift + +exec cargo release "$BUMP" "$@" 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..efc491b --- /dev/null +++ b/src/backend.rs @@ -0,0 +1,3445 @@ +//! The [`Backend`] and [`StatementBackend`] traits every driver implements, +//! plus the shared `SQLGetInfo` helpers (`common_get_info_raw`, +//! `default_get_info`). + +use std::borrow::Cow; + +use odbc_sys::CDataType; + +use crate::errors::OdbcError; +use crate::types::{ + CatalogResultColumnWidths, ColumnDescriptor, ColumnPrivilegeRow, ColumnRow, ColumnValue, + ConnectParams, ExecuteOutcome, FetchResult, ForeignKeyRow, InfoValue, PrimaryKeyRow, + ProcedureColumnRow, ProcedureRow, SpecialColumnRow, StatisticsRow, TablePrivilegeRow, TableRow, + TypeInfoRow, ValueWarning, +}; + +/// Core abstraction for database-specific logic. Everything in +/// stackable-odbc-core is generic over `B: Backend`. +/// +/// `Sized` is implicit, since all traits require it by default. It is listed +/// for symmetry with `StatementBackend` and to make the full contract visible +/// in one place. +/// +/// # Re-entrancy +/// +/// Every method here runs while core holds the target connection's group +/// lock (see `HandleScope` and `panic_safe` in `src/panic.rs`), including +/// [`Backend::connect`], which runs under the lock the freshly allocated +/// connection handle already has, even though nothing yet depends on it. That +/// lock is not reentrant. A method implementation that calls back into any +/// `SQLxxx` entry point on the same connection, directly or indirectly through +/// a callback into application code that does so, deadlocks the calling thread +/// permanently: there is no diagnostic and no `SqlReturn`, because the thread +/// never returns from the lock acquisition to produce either. +/// [`Backend::cancel`] is the one method exempt from this, and its own doc +/// comment explains why. +pub trait Backend: Sized + Send + Sync + 'static { + /// The backend's live connection, whatever it needs to hold: a socket, a + /// client-library handle, a file descriptor, a runtime plus a channel. + /// + /// Core stores one inside a `ConnectionHandle` and hands it back to every + /// method by reference; it never inspects it. `Send + Sync` because an + /// application may use one connection from several threads: core's own + /// per-connection group lock serialises calls into a `Backend` method, + /// but does not confine the connection to the thread that created it. + type Connection: Send + Sync; + + /// The backend's executed or prepared statement, from which rows are read. + /// + /// Produced by `exec_direct`, `prepare` and the catalog methods, and driven + /// through [`StatementBackend`]. + type Statement: StatementBackend; + + /// The one error type every [`Backend`] method returns. + /// + /// Both conversion directions are required, and they do different jobs: + /// + /// - `Into` lets core turn a backend failure into a diagnostic + /// record. This is what the generic FFI entry points call. + /// - `From` lets a *defaulted* method body in this trait + /// construct an error and still name `Self::Error`. Without it, no + /// default could report `NotImplemented`. + /// + /// `std::error::Error` is what makes the causal chain usable: core attaches + /// a backend error as [`OdbcError::with_source`] and walks `source()` when + /// building the diagnostic message. It also lets core log an error it would + /// otherwise have to swallow, such as a `disconnect` that fails while + /// unwinding a half-open connection. + /// + /// `Send + Sync + 'static` matches the handles the error travels inside; + /// a `#[derive(Debug, Snafu)]` error type satisfies all of this already. + type Error: Into + From + std::error::Error + Send + Sync + 'static; + + /// Establishes a new connection using the given [`ConnectParams`]. + /// + /// Called by `SQLDriverConnectW` / `SQLConnectW`. Returns the backend-specific + /// connection handle on success. + fn connect(params: &ConnectParams) -> Result; + + /// Connection-string keywords whose values must never be logged. + /// + /// The backend owns its connection-string vocabulary, so it is the only + /// party that can name its own secrets: core sees `WalletLocation`, + /// `OAuthAssertion` or `KeyStorePin` as ordinary keywords and has no way to + /// know better. Every `ConnectParams` the generic FFI entry points build is + /// told this list, so declaring a keyword here is all a driver has to do. + /// + /// Matched case-insensitively against the whole keyword name. Aliases must + /// be listed individually. + /// + /// Defaulted to empty rather than required, because core keeps a substring + /// heuristic (`password`, `pwd`, `secret`, `token`, `apikey`, …) in force + /// underneath: a backend that declares nothing is still covered for the + /// common shapes, so the default understates rather than leaks. Declaring a + /// keyword here only ever adds redaction; it can never un-redact one the + /// heuristic already catches. + /// + /// `Cow<'static, [Cow<'static, str>]>`, not `&'static [&'static str]`: this + /// method takes no connection, so nothing about it varies at runtime, but + /// every other list-returning method on this trait uses the same shape for + /// a data source whose answer does vary, so this one matches for uniformity + /// of the trait's vocabulary rather than because it needs to. + fn sensitive_connect_keywords() -> Cow<'static, [Cow<'static, str>]> { + Cow::Borrowed(&[]) + } + + /// The driver's interactive prompter, if it has one. + /// + /// Core calls this while connecting and gates the result on + /// `SQLDriverConnect`'s *DriverCompletion*: under `SQL_DRIVER_NOPROMPT` the + /// backend receives `None` from + /// [`ConnectParams::prompter`](crate::types::ConnectParams::prompter) and + /// has nothing to call, so the spec's "do not prompt" cannot be forgotten + /// at a call site. `SQLConnect` and `SQLBrowseConnect` have no such + /// argument and always permit it. + /// + /// Read it from `ConnectParams` inside [`Backend::connect`], never by + /// calling this method directly, which is ungated and says only what the + /// driver *could* do, not what this call is allowed to do. + /// + /// Static, like [`Backend::connect`] and + /// [`Backend::sensitive_connect_keywords`]: there is no connection yet when + /// core asks. A driver whose prompter varies per connection string can + /// return one implementation that reads the difference at + /// [`Prompter::present_url`](crate::prompt::Prompter::present_url) time. + /// + /// Defaulted, so a driver with no interactive authentication never has to + /// think about it. + fn prompter() -> Option> { + None + } + + /// Present the driver's DSN setup UI and return the data source's final + /// keywords. + /// + /// Called by `ConfigDSN`, the entry point the Windows ODBC Administrator's + /// **Add…** and **Configure…** buttons reach. Core does everything else: + /// validating *fRequest*, rejecting a `DRIVER=` keyword, calling + /// `SQLValidDSN`, and writing the data source through `SQLWriteDSNToIni` and + /// `SQLWritePrivateProfileString`. This hook supplies the one thing that + /// varies per backend: which keywords the data source needs, and how to ask + /// the user for them. + /// + /// # Arguments + /// + /// - `hwnd_parent` is the ODBC Administrator's window, to parent a dialog + /// on. It is passed through untouched and never dereferenced by core, so + /// it is `*mut c_void` on every platform; a driver's own `#[cfg(windows)]` + /// is where it becomes an `HWND`. It may be null, which the spec defines + /// as "the function will not display any dialog boxes". + /// - `request` is which of the three operations was asked for. **All three + /// reach this hook**, [`Remove`](crate::setup::ConfigRequest::Remove) + /// included, so a driver can confirm a removal or clean up something that + /// does not live in `ODBC.INI`, such as a cached token or a keytab. + /// - `attributes` is the keyword-value list the Driver Manager supplied. For + /// `Config` and `Remove` core has already merged in the data source's + /// existing keywords, read from `ODBC.INI`, so the map is complete and the + /// driver never has to touch `odbcinst`. The spec requires that merge + /// rather than merely permitting it: "for information not in + /// *lpszAttributes*, it uses information from the system information." + /// Supplied attributes win over stored ones. For `Add` there is no merge: + /// `SQLWriteDSNToIni` removes the old section before creating the new one, + /// so prefilling would resurrect exactly the keywords the caller meant to + /// drop. On **Add…** the map is typically empty: the dialog is what + /// produces the `DSN` keyword, which is why core calls this hook *before* + /// it looks for one. + /// + /// # Returns + /// + /// - `Ok(Some(attrs))`: proceed, writing `attrs`. Core uses the + /// **returned** map's `DSN` value for every request, `Remove` included. + /// + /// **If `attributes` carried a `DSN` keyword, the returned map must carry + /// the same one.** 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 this rather than trusting the hook, because the + /// failure it prevents is destructive: a hook altering `DSN=` on a + /// `Remove` would delete a data source the user never named. A mismatch + /// fails the call. When no `DSN` keyword was supplied (the **Add…** case) + /// the hook may return any name, since there is nothing it could have + /// changed. + /// - `Ok(None)`: the user cancelled. `ConfigDSN` returns FALSE and posts + /// **no** installer error, because nothing failed. + /// - `Err(e)`: the hook could not complete. Core posts `e.code` and + /// `e.message` with `SQLPostInstallerError`. + /// [`SetupError::request_failed`](crate::setup::SetupError::request_failed) + /// is the usual constructor. + /// + /// # Errors + /// + /// Whatever the driver's setup UI could not do: no display, no permission + /// to open a window, a value the driver itself rejected. + /// + /// # Panics + /// + /// A panic here is caught: `ConfigDSN` runs inside `panic_safe_unlocked` and + /// reports `ODBC_ERROR_REQUEST_FAILED`. Returning `Err` is still better, + /// since it carries a message. + /// + /// Defaulted to the identity function, so a driver with no setup dialog + /// never has to think about it and keeps core's headless behaviour. + fn configure_dsn( + hwnd_parent: *mut std::ffi::c_void, + request: crate::setup::ConfigRequest, + attributes: std::collections::HashMap, + ) -> Result>, crate::setup::SetupError> { + let _ = request; + if !hwnd_parent.is_null() { + // AGENTS.md: an ignored feature gets a `warn!`. The spec attaches + // real behaviour to this argument ("If it matches an existing name + // and hwndParent is not null, ConfigDSN prompts the user to + // overwrite the existing name"), and a driver that has not + // overridden this hook has no dialog to prompt with, so the prompt + // becomes an unconditional overwrite. Only a caller that passed + // non-null is affected: "The function will not display any dialog + // boxes if the handle is null" is exactly what a null caller gets. + tracing::warn!( + "ConfigDSN: hwndParent is non-null but this driver has not \ + overridden Backend::configure_dsn, so it ships no setup dialog \ + and proceeds headlessly. An existing data source of the same \ + name is overwritten without prompting." + ); + } + Ok(Some(attributes)) + } + + /// Closes an existing connection and releases associated resources. + /// + /// Called by `SQLDisconnect`. + fn disconnect(conn: &mut Self::Connection) -> Result<(), Self::Error>; + + /// Executes a SQL statement directly without preparation. + /// + /// Called by `SQLExecDirectW`. Returns a statement that can be used to iterate results + /// via [`StatementBackend`]. `cancel` is this statement's token; record whatever + /// `Backend::cancel` will need to identify this work. + fn exec_direct( + conn: &Self::Connection, + cancel: &Self::CancelToken, + sql: &str, + ) -> Result; + + /// Prepares a SQL statement for later execution. + /// + /// Called by `SQLPrepareW`. Returns a prepared statement object (`Self::Statement`) + /// that can be executed via [`Backend::execute`]. `cancel` is this statement's token; + /// record whatever `Backend::cancel` will need to identify this work. + fn prepare( + conn: &Self::Connection, + cancel: &Self::CancelToken, + sql: &str, + ) -> Result; + + /// Executes a previously prepared statement with the given parameter values. + /// + /// Called by `SQLExecute`. `params` contains one [`ColumnValue`] per bound + /// parameter, in bind order (the assembled *input* values). + /// + /// Returns an [`ExecuteOutcome`]. Backends without output-parameter support + /// return `Ok(ExecuteOutcome::default())` (the common case). A backend that + /// produces `SQL_PARAM_OUTPUT` / `SQL_PARAM_INPUT_OUTPUT` values populates + /// [`ExecuteOutcome::output_params`]; `stackable-odbc-core` then writes each value back + /// into the application's bound parameter buffer, the symmetric counterpart + /// of the `params` input above. `cancel` is this statement's token; record + /// whatever `Backend::cancel` will need to identify this work. + fn execute( + conn: &Self::Connection, + cancel: &Self::CancelToken, + stmt: &mut Self::Statement, + params: &[ColumnValue], + ) -> Result; + + /// Switch the connection between autocommit and manual-commit mode. + /// + /// Called by `SQLSetConnectAttr(SQL_ATTR_AUTOCOMMIT)`. In manual-commit + /// mode the backend must hold changes until [`Backend::end_tran`] commits + /// or rolls them back. + /// + /// The default implementation reports `HYC00` for manual-commit mode, + /// which is correct for a backend that reports `SQL_TC_NONE` for + /// `SQL_TXN_CAPABLE`. A backend that advertises transaction support **must** + /// override this: accepting the attribute without honouring it would let + /// an application believe a rollback is available when it is not. + fn set_autocommit(_conn: &Self::Connection, enabled: bool) -> Result<(), Self::Error> { + if enabled { + // Autocommit is the default mode; nothing to do. + Ok(()) + } else { + Err(OdbcError::NotImplemented { + feature: "SQL_ATTR_AUTOCOMMIT=SQL_AUTOCOMMIT_OFF (manual-commit mode)".into(), + } + .into()) + } + } + + /// Returns driver or data source information for the given `InfoType`. + /// + /// Called by `SQLGetInfoW`. See [`default_get_info`] for values that are shared across + /// all drivers; backends should delegate to it before handling driver-specific types. + fn get_info( + conn: &Self::Connection, + info_type: crate::types::InfoType, + ) -> Result; + + /// The catalog the session is currently using, if the data source has a + /// current catalog at all. + /// + /// Read by `SQLGetConnectAttr(SQL_ATTR_CURRENT_CATALOG)` and, because the + /// spec makes them one value, by `SQLGetInfo(SQL_DATABASE_NAME)`. An + /// application's own `SQLSetConnectAttr` value takes precedence, since that + /// is what it asked for; this answers when it has set nothing. + /// + /// Defaults to `None`, meaning "this data source has no current catalog to + /// report": the empty string, which is what both readers then produce. + /// + /// A backend that returns `Some` should also implement + /// [`Backend::set_current_catalog`], or an application can read the catalog + /// and not change it. + fn current_catalog(_conn: &Self::Connection) -> Option> { + None + } + + /// Switch the session to `catalog`. + /// + /// Called by `SQLSetConnectAttr(SQL_ATTR_CURRENT_CATALOG)`. The spec's + /// example is a driver sending `USE database`; for a single-tier driver it + /// may be changing a directory. + /// + /// The default reports `HYC00`, which is the honest answer for a backend + /// that cannot switch catalogs: storing the value and returning + /// `SQL_SUCCESS` would tell an application its unqualified names now + /// resolve somewhere they do not. Core stores the value only once this + /// returns `Ok`. + /// + /// **Map "no such catalog" to `3D000`**, via + /// [`crate::types::SqlState::invalid_catalog_name`]. + /// `SQLSetConnectAttr`'s `3D000` row ("the *Attribute* argument was + /// SQL_CURRENT_CATALOG, and the specified catalog name was invalid") + /// carries no `(DM)` marker, so the driver owes it, and this method is the + /// only place it can come from: core has no way to know which catalogs a + /// data source has. Core propagates whatever this returns unchanged, so a + /// backend that reports a bad catalog name as a generic `HY000` is the only + /// reason an application would not see `3D000`. + /// + /// Note where the error surfaces. The spec lists this attribute as settable + /// either side of a connection and notes that interoperable applications set + /// it *before* one, in which case core applies it during `SQLDriverConnectW` + /// / `SQLConnectW` / `SQLBrowseConnectW` and a failure here fails the + /// connect, so `3D000` arrives from a function whose own diagnostics table + /// does not list it. Degrading it would tell the application its connection + /// failed for some unrelated reason. + fn set_current_catalog(_conn: &Self::Connection, _catalog: &str) -> Result<(), Self::Error> { + Err(OdbcError::NotImplemented { + feature: "SQL_ATTR_CURRENT_CATALOG".into(), + } + .into()) + } + + /// Ask the data source to return at most `rows` rows from a result set. + /// + /// Called by `SQLSetStmtAttr(SQL_ATTR_MAX_ROWS)`. `rows` is the spec's + /// `SQLULEN` verbatim; it is never `0`, because `0` means "return all rows" + /// and core handles that without asking. + /// + /// **Core deliberately does not emulate this, and neither should a driver.** + /// The spec says so outright: "a driver should not emulate + /// SQL_ATTR_MAX_ROWS behavior for `SQLFetch` or `SQLFetchScroll` (if result + /// set size limitations cannot be implemented at the data source) if it + /// cannot guarantee that SQL_ATTR_MAX_ROWS will be implemented properly." + /// The reason is in the attribute's own purpose, "this attribute is + /// intended to reduce network traffic", which counting rows on the client + /// and discarding the rest cannot achieve. Implement this only where the + /// data source can genuinely cap the result set (a `LIMIT`, a fetch size, a + /// server-side row cap). + /// + /// Note the spec's scope: it "applies to all result sets on the + /// *Statement*, including those returned by catalog functions", and + /// "conceptually, it is applied when the result set is created". + /// + /// Three answers, as for [`Backend::set_query_timeout`]: + /// + /// - `Ok(())`: the cap is in force. Core stores the value, so + /// `SQLGetStmtAttr` reports back what the application asked for. + /// - `Err(NotImplemented)`, the default: core substitutes `0` and posts + /// `01S02`, which the spec's closed `01S02` list names this attribute + /// for, so the application learns it got no cap rather than believing in + /// one that will never apply. + /// - Any other `Err`: a real failure, reported as-is rather than turned + /// into a substitution. + fn set_max_rows(_conn: &Self::Connection, _rows: usize) -> Result<(), Self::Error> { + Err(OdbcError::NotImplemented { + feature: "SQL_ATTR_MAX_ROWS".into(), + } + .into()) + } + + /// Ask the data source to return at most `bytes` from a character or binary + /// column. + /// + /// Called by `SQLSetStmtAttr(SQL_ATTR_MAX_LENGTH)`. `bytes` is the spec's + /// `SQLULEN` verbatim; it is never `0`, which means "return all available + /// data". + /// + /// **Core deliberately does not emulate this either.** The spec restricts + /// it to the data source in as many words: "this attribute is intended to + /// reduce network traffic and should be supported only when the data source + /// (as opposed to the driver) in a multiple-tier driver can implement it", + /// and it warns applications off using it as a truncation mechanism at + /// all: "this mechanism should not be used by applications to truncate + /// data; to truncate data received, an application should specify the + /// maximum buffer length in the *BufferLength* argument in `SQLBindCol` or + /// `SQLGetData`". Truncating in core would move bytes over the wire and + /// then throw them away, which is the opposite of the point. + /// + /// Truncation performed by the data source under this attribute returns + /// `SQL_SUCCESS`, not `01004`: "if *ValuePtr* is less than the length of + /// the available data, `SQLFetch` or `SQLGetData` truncates the data and + /// returns SQL_SUCCESS." + /// + /// Same three answers as [`Backend::set_max_rows`]. + fn set_max_length(_conn: &Self::Connection, _bytes: usize) -> Result<(), Self::Error> { + Err(OdbcError::NotImplemented { + feature: "SQL_ATTR_MAX_LENGTH".into(), + } + .into()) + } + + /// Tell the data source whether this connection needs to support updates. + /// + /// Called by `SQLSetConnectAttr(SQL_ATTR_ACCESS_MODE)` with `true` for + /// `SQL_MODE_READ_ONLY` and `false` for `SQL_MODE_READ_WRITE` (the + /// default). A value set before connecting is applied at connect, since the + /// spec lists this attribute as settable either side of one. + /// + /// **The default is `Ok(())`, accepting and ignoring, and that is + /// spec-compliant here**, which is what makes this hook different from + /// [`Backend::set_current_catalog`] and [`Backend::set_autocommit`], whose + /// defaults refuse. The spec says so directly: read-only "is used by the + /// driver or data source as an indicator that the connection is not + /// required to support SQL statements that cause updates to occur ... **the + /// driver is not required to prevent such statements from being submitted + /// to the data source**", and "the behavior of the driver and data source + /// when asked to process SQL statements that are not read-only during a + /// read-only connection is implementation-defined". + /// + /// So this is a *hint*, not a guarantee, and an application must not treat + /// `SQL_MODE_READ_ONLY` as a safety interlock. Storing it without telling + /// the data source therefore misleads nobody about correctness; it only + /// forgoes the optimisation the spec offers: "this mode can be used to + /// optimize locking strategies, transaction management, or other areas as + /// appropriate to the driver or data source." + /// + /// Override it where the data source has a real read-only session mode + /// worth entering. Returning an error is reasonable for a backend that + /// cannot enter one *and* judges that silently ignoring the request would + /// mislead its users; core reports whatever SQLSTATE the error maps to. + fn set_access_mode(_conn: &Self::Connection, _read_only: bool) -> Result<(), Self::Error> { + Ok(()) + } + + /// Whether the connection to the data source has been lost. + /// + /// Read by `SQLGetConnectAttr(SQL_ATTR_CONNECTION_DEAD)`, which the spec + /// makes read-only: "Both SQL_ATTR_AUTO_IPD and SQL_ATTR_CONNECTION_DEAD + /// connection attributes can be returned by a call to `SQLGetConnectAttr` + /// but cannot be set by a call to `SQLSetConnectAttr`." `true` becomes + /// `SQL_CD_TRUE`, `false` becomes `SQL_CD_FALSE`. + /// + /// **This is what a connection pool reads before handing a connection + /// out.** Answering `false` for a connection whose socket closed an hour + /// ago is how a pool serves a dead connection to the next caller, which + /// then fails on its first query for no reason the application can see. + /// + /// **Answer from state you already have; do not make a round trip.** The + /// spec's own note on this function is that "a driver can improve + /// performance by minimizing the number of times that information is sent + /// or requested from the server", and a pool may call this on every + /// checkout. A flag the error-mapping function sets when it sees a + /// connection-level failure, or whatever liveness the client library + /// already tracks, is the intended source. + /// + /// The default is `false`, which is correct for a backend that cannot tell: + /// `SQL_CD_TRUE` asserts the connection *has been lost*, and a backend with + /// no liveness signal has not observed that. Note the asymmetry: `false` + /// means "not known to be dead", not "known to be alive". + fn connection_dead(_conn: &Self::Connection) -> bool { + false + } + + /// Ask the data source to stop a statement that runs longer than + /// `seconds`. + /// + /// Called by `SQLSetStmtAttr(SQL_ATTR_QUERY_TIMEOUT)`. `seconds` is the + /// spec's `SQLULEN` verbatim, hence `usize`; it is never `0`, because `0` + /// means "no timeout" and core handles that without asking. + /// + /// **This is the server-side half of the timeout.** A data source that can + /// enforce a deadline itself does it far better than core can: it knows + /// when the query actually started, it stops the work rather than merely + /// abandoning it, and it needs no [`Backend::cancel`] implementation. + /// Implement this in preference to relying on core's timer. + /// + /// Four answers, and the difference is load-bearing: + /// + /// - [`Ok(DataSource)`](crate::types::QueryTimeout::DataSource): the data source enforces the + /// deadline itself. Core arms no timer and stores the value, so + /// `SQLGetStmtAttr` reports back what the application asked for. + /// - [`Ok(CoreCancels)`](crate::types::QueryTimeout::CoreCancels): the backend cannot set a + /// server-side deadline but *can* be cancelled, so core arms a timer that + /// calls [`Backend::cancel`] when the deadline passes. Returning this + /// asserts that `cancel` really cancels; see + /// [`QueryTimeout::CoreCancels`](crate::types::QueryTimeout::CoreCancels). + /// - `Err(NotImplemented)`, the default: this backend can do neither. Core + /// substitutes `0` and posts `01S02`, which is the spec's own answer for + /// `SQL_ATTR_QUERY_TIMEOUT` ("the statement attributes that can be changed + /// are: ... SQL_ATTR_QUERY_TIMEOUT"), so `SQLGetStmtAttr` reports `0` and + /// the application learns it got no timeout rather than believing in one + /// that will never fire. + /// - Any other `Err`: a real failure talking to the data source, reported + /// as-is. It is *not* turned into a substitution: an application told + /// `01S02` concludes "this driver caps my timeout", which is a different + /// thing from "the connection is broken". + /// + /// The spec's own use of `01S02` here is clamping: "if the specified + /// timeout exceeds the maximum timeout in the data source or is smaller + /// than the minimum timeout, `SQLSetStmtAttr` substitutes that value and + /// returns SQLSTATE 01S02". A backend that clamps should therefore *not* + /// return an error: return `Ok` and core stores what was asked for. + /// Reporting the clamped value instead needs core to know it, which this + /// signature does not carry; see the note on the `SQL_ATTR_QUERY_TIMEOUT` + /// arm in `ffi/stmt_attr.rs`. + /// + /// **Scope caveat.** `SQL_ATTR_QUERY_TIMEOUT` is a *statement* attribute + /// but this hook receives only the connection, so a backend that applies it + /// session-wide gives every statement on that connection the most recently + /// set value. Core cannot fix that without changing the signature of every + /// method taking a `cancel:` argument, which would break every existing + /// driver. A backend that can scope a deadline per statement should carry + /// the value on its own connection state and apply it at execution time. + fn set_query_timeout( + _conn: &Self::Connection, + _seconds: usize, + ) -> Result { + Err(OdbcError::NotImplemented { + feature: "SQL_ATTR_QUERY_TIMEOUT".into(), + } + .into()) + } + + /// Return driver-level info that does not require an active connection. + /// + /// The Windows Driver Manager calls `SQLGetInfoW` for types like + /// `SQL_DRIVER_ODBC_VER` *before* the connection is established, and reads + /// the answer to decide whether the driver is ODBC 3.x, so a wrong or + /// missing one costs the connection its 3.x features. + /// + /// **That group needs no override.** `NotImplemented` falls through to + /// [`default_get_info`], which answers `SQL_DRIVER_NAME` and + /// `SQL_DRIVER_VER` from [`Backend::driver_name`] and + /// [`Backend::driver_version`], and `SQL_DRIVER_ODBC_VER`, + /// `SQL_ASYNC_DBC_FUNCTIONS` and `SQL_MAX_CONCURRENT_ACTIVITIES` from what + /// core knows about itself. Those are what the Driver Manager needs, and + /// the two hooks behind them are required declarations rather than a + /// checklist item. + /// + /// Override it for a *further* info type a backend can answer before + /// connecting, which is rare: most of `SQLGetInfo` describes the data + /// source, and there is no data source yet. + fn get_info_pre_connect(_info_type: crate::types::InfoType) -> Result { + Err(OdbcError::NotImplemented { + feature: "get_info_pre_connect".into(), + } + .into()) + } + + /// Handle an info type by its raw `u16` value, before the typed `InfoType` + /// dispatch in [`Backend::get_info`] / [`default_get_info`] runs. + /// + /// Return `Some(Ok(value))` to respond, `Some(Err(e))` to report an error, + /// or `None` (the default) to fall through to the Driver-Manager-safe + /// default in `info_type_default_response` (`stackable-odbc-core/src/ffi/info.rs`). + /// + /// This is the *only* place two different kinds of info type get a value: + /// - Info types genuinely absent from `odbc_sys::InfoType` (e.g. + /// `SQL_CURSOR_ROLLBACK_BEHAVIOR`) have no `InfoType` variant to match on + /// anywhere else. + /// - Info types that **are** real `InfoType` variants (e.g. + /// `SQL_AGGREGATE_FUNCTIONS`, `SQL_FILE_USAGE`) but have no arm in + /// [`default_get_info`] or in the backend's own typed `get_info` still + /// need a value; those reach here as raw `u16`s after the typed call + /// returns `NotImplemented`. See `info_type_default_response`'s + /// "load-bearing ordering" doc for why this must be checked before the + /// generic numeric-range defaults. + /// + /// Backends should match their own driver-specific info types first, + /// then delegate to [`common_get_info_raw`] as the fallback (`_ =>` + /// arm) for the small set of values that are identical across every + /// driver. That way a driver's own answer always wins over the shared + /// default for any info type both would otherwise handle. + fn get_info_raw( + _conn: &Self::Connection, + _info_type: u16, + ) -> Option> { + None + } + + /// Returns the list of ODBC functions supported by this driver. + /// + /// Called by `SQLGetFunctions`. The returned slice must contain one + /// [`FunctionId`](crate::function_id::FunctionId) entry + /// per exported FFI function. `stackable-odbc-core` maps 3.x IDs to their 2.x equivalents + /// automatically for the legacy function array. + /// + /// This method takes no connection, so nothing about it varies at + /// runtime; it returns `Cow<'static, [FunctionId]>` rather than + /// `&'static [FunctionId]` for uniformity with the trait's other + /// list-returning methods, not because it needs to be computed. + fn get_functions() -> Cow<'static, [crate::function_id::FunctionId]>; + + /// Returns type information rows describing the SQL types supported by this driver. + /// + /// Called by `SQLGetTypeInfoW`. The returned rows should include both ANSI and Unicode + /// type variants so that ODBC applications can match on `SQL_VARCHAR` as well as + /// `SQL_WVARCHAR`. + /// + /// Takes a connection because the type list can genuinely differ by data + /// source (a server-version probe gating a type's availability, say), so + /// the answer is computed rather than a `'static` borrow, hence + /// `Cow<'static, [TypeInfoRow]>` rather than `&'static [TypeInfoRow]`. + fn get_type_info(conn: &Self::Connection) -> Cow<'static, [TypeInfoRow]>; + + /// Returns the rows describing tables matching the given filter criteria. + /// + /// Called by `SQLTablesW`. All filter parameters are optional; `None` means + /// no filter on that dimension. `cancel` is this statement's token; record + /// whatever `Backend::cancel` will need to identify this work. + /// + /// **Return the rows in any order.** Core sorts them into the spec's order + /// (TABLE_TYPE, TABLE_CAT, TABLE_SCHEM, TABLE_NAME) and builds the result + /// set, so a backend does not need an ORDER BY for correctness. + /// + /// `query.table_types()` is the parsed `TableType` value list: core has + /// already split it on commas and stripped the optional single quotes, so a + /// backend never parses it. An empty slice means no table-type filter. + /// + /// The arguments arrive as a [`TablesQuery`](crate::types::TablesQuery) so + /// that core can add one later without breaking every driver, and so that + /// the run of same-typed filters cannot be transposed at a call site. + fn tables( + conn: &Self::Connection, + cancel: &Self::CancelToken, + query: &crate::types::TablesQuery<'_>, + ) -> Result, Self::Error>; + + /// The table types this data source has, for `SQLTables`' + /// `SQL_ALL_TABLE_TYPES` enumeration, e.g. `["TABLE", "VIEW"]`. + /// + /// Required, not defaulted, for the reason in AGENTS.md: an empty list is + /// an *answer* ("this data source has no table types"), not "unknown", and + /// there is no `supports_*` method to derive it from the way + /// `SQL_ALL_CATALOGS` derives from [`Backend::supports_catalogs`]. Any + /// value core invented here would be a claim about a data source it knows + /// nothing about. + /// + /// Values should be upper case: the spec has applications specify table + /// types in upper case and the driver map them to whatever the data source + /// needs. + /// + /// Takes no `cancel` token because it is a static declaration rather than + /// a query, unlike [`Backend::catalogs`] and [`Backend::schemas`]. + fn table_types(conn: &Self::Connection) -> Vec>; + + /// The catalog names on this data source, for `SQLTables`' + /// `SQL_ALL_CATALOGS` enumeration. + /// + /// Runtime data rather than a static capability, so this takes `cancel` + /// like every other backend call. Core only calls it when + /// [`Backend::supports_catalogs`] already returned `true`, since a backend + /// that says it has no catalogs gets an empty result set without being + /// asked. So a backend that claims catalogs and leaves this defaulted is a + /// driver bug, which core reports as `HYC00` rather than silently returning + /// nothing. + /// + /// **Return the names in any order.** Core sorts the result set. + fn catalogs( + _conn: &Self::Connection, + _cancel: &Self::CancelToken, + ) -> Result, Self::Error> { + Err(OdbcError::NotImplemented { + feature: "catalogs".into(), + } + .into()) + } + + /// The schema names on this data source, for `SQLTables`' + /// `SQL_ALL_SCHEMAS` enumeration. See [`Backend::catalogs`]; this is gated + /// on [`Backend::supports_schemas`] the same way. + fn schemas( + _conn: &Self::Connection, + _cancel: &Self::CancelToken, + ) -> Result, Self::Error> { + Err(OdbcError::NotImplemented { + feature: "schemas".into(), + } + .into()) + } + + /// Returns the rows describing columns matching the given filter criteria. + /// + /// Called by `SQLColumnsW`. All filter parameters are optional; `None` means no filter + /// on that dimension. `cancel` is this statement's token; record whatever + /// `Backend::cancel` will need to identify this work. + /// + /// **Return the rows in any order.** Core sorts them into the spec's order + /// (TABLE_CAT, TABLE_SCHEM, TABLE_NAME, ORDINAL_POSITION) and builds the + /// result set, so a backend does not need an ORDER BY for correctness. + /// The arguments arrive as a [`ColumnsQuery`](crate::types::ColumnsQuery), + /// for the same reason [`Backend::tables`] takes a `TablesQuery`. + fn columns( + conn: &Self::Connection, + cancel: &Self::CancelToken, + query: &crate::types::ColumnsQuery<'_>, + ) -> Result, Self::Error>; + + /// Return the primary key columns for the given table. + /// + /// Called by `SQLPrimaryKeysW`. Backends that do not support this can leave the + /// default implementation which returns `NotImplemented`. `cancel` is this + /// statement's token; record whatever `Backend::cancel` will need to identify + /// this work. + /// + /// **Return the rows in any order.** Core sorts them into the spec's order + /// (TABLE_CAT, TABLE_SCHEM, TABLE_NAME, KEY_SEQ) and builds the result set, + /// so a backend does not need an ORDER BY for correctness. + fn primary_keys( + _conn: &Self::Connection, + _cancel: &Self::CancelToken, + _query: &crate::types::PrimaryKeysQuery<'_>, + ) -> Result, Self::Error> { + Err(OdbcError::NotImplemented { + feature: "primary_keys".into(), + } + .into()) + } + + /// Return foreign key relationships. + /// + /// Called by `SQLForeignKeysW`. Either `query.pk_table()` or + /// `query.fk_table()` (or both) may be supplied. + /// Backends that do not support this can leave the default implementation which returns + /// `NotImplemented`. `cancel` is this statement's token; record whatever + /// `Backend::cancel` will need to identify this work. + /// + /// **Return the rows in any order.** Core sorts them into the spec's order + /// and builds the result set, so a backend does not need an ORDER BY for + /// correctness. Which of the two orders the spec defines applies is + /// decided by core from the arguments: FKTABLE_CAT, FKTABLE_SCHEM, + /// FKTABLE_NAME, KEY_SEQ when `query.pk_table()` was supplied, and + /// PKTABLE_CAT, PKTABLE_SCHEM, PKTABLE_NAME, KEY_SEQ otherwise. + /// + /// The two identifier trios arrive as a + /// [`ForeignKeysQuery`](crate::types::ForeignKeysQuery) rather than six + /// positional `Option<&str>`, where crossing a PK argument with its FK + /// counterpart compiled without complaint. + fn foreign_keys( + _conn: &Self::Connection, + _cancel: &Self::CancelToken, + _query: &crate::types::ForeignKeysQuery<'_>, + ) -> Result, Self::Error> { + Err(OdbcError::NotImplemented { + feature: "foreign_keys".into(), + } + .into()) + } + + /// Return index statistics for a single table. + /// + /// Called by `SQLStatisticsW`. `query.unique_only()` reflects + /// `SQL_INDEX_UNIQUE` (true) vs `SQL_INDEX_ALL` (false). Backends that do not expose index + /// metadata leave the default; the FFI layer then returns a spec-legitimate + /// empty result set (a table with no indexes is a valid empty response). + /// `cancel` is this statement's token; record whatever `Backend::cancel` + /// will need to identify this work. + /// + /// **Return the rows in any order.** Core sorts them into the spec's order + /// (NON_UNIQUE, TYPE, INDEX_QUALIFIER, INDEX_NAME, ORDINAL_POSITION) and + /// builds the result set, so a backend does not need an ORDER BY for + /// correctness. + fn statistics( + _conn: &Self::Connection, + _cancel: &Self::CancelToken, + _query: &crate::types::StatisticsQuery<'_>, + ) -> Result, Self::Error> { + Err(OdbcError::NotImplemented { + feature: "statistics".into(), + } + .into()) + } + + /// Return the optimal row-identifier (`SQL_BEST_ROWID`) or row-version + /// (`SQL_ROWVER`) columns for a single table. + /// + /// Called by `SQLSpecialColumnsW`. The default returns `NotImplemented`, + /// which the FFI layer converts to an empty result set, the spec's defined + /// response when no such columns exist. `cancel` is this statement's + /// token; record whatever `Backend::cancel` will need to identify this + /// work. + /// + /// **Return the rows in any order.** Core sorts them into the spec's order + /// (SCOPE) and builds the result set, so a backend does not need an ORDER + /// BY for correctness. + fn special_columns( + _conn: &Self::Connection, + _cancel: &Self::CancelToken, + _query: &crate::types::SpecialColumnsQuery<'_>, + ) -> Result, Self::Error> { + Err(OdbcError::NotImplemented { + feature: "special_columns".into(), + } + .into()) + } + + /// Return the stored procedures matching the given filter criteria. + /// + /// Called by `SQLProceduresW`. All filter parameters are optional; `None` + /// means no filter on that dimension. `cancel` is this statement's token; + /// record whatever `Backend::cancel` will need to identify this work. + /// + /// The default returns no rows rather than `NotImplemented`, unlike + /// [`Backend::primary_keys`] and its neighbours: a data source with no + /// stored procedures has none to report, which is a legitimate answer, and + /// this function returned an empty result set for every driver before the + /// hook existed. Erroring instead would regress a working call. + /// + /// **Return the rows in any order.** Core sorts them into the spec's order + /// (PROCEDURE_CAT, PROCEDURE_SCHEM, PROCEDURE_NAME) and builds the result + /// set, so a backend does not need an ORDER BY for correctness. + /// + /// `SQL_ATTR_METADATA_ID` has already been applied: when it is `SQL_TRUE` + /// core has stripped delimiters, case-folded and escaped each argument, so + /// these are always ordinary pattern values here. + fn procedures( + _conn: &Self::Connection, + _cancel: &Self::CancelToken, + _query: &crate::types::ProceduresQuery<'_>, + ) -> Result, Self::Error> { + Ok(Vec::new()) + } + + /// Return the parameters and result-set columns of the matching stored + /// procedures. + /// + /// Called by `SQLProcedureColumnsW`. Defaulted to no rows for the same + /// reason as [`Backend::procedures`], and `SQL_ATTR_METADATA_ID` has + /// likewise already been applied to all four arguments. + /// + /// **Return the rows in any order.** Core sorts them into the spec's order + /// (PROCEDURE_CAT, PROCEDURE_SCHEM, PROCEDURE_NAME, COLUMN_TYPE) and + /// builds the result set, so a backend does not need an ORDER BY for + /// correctness. + fn procedure_columns( + _conn: &Self::Connection, + _cancel: &Self::CancelToken, + _query: &crate::types::ProcedureColumnsQuery<'_>, + ) -> Result, Self::Error> { + Ok(Vec::new()) + } + + /// Return the column-level privileges on a single table. + /// + /// Called by `SQLColumnPrivilegesW`. Defaulted to no rows for the same + /// reason as [`Backend::procedures`], and `SQL_ATTR_METADATA_ID` has + /// likewise already been applied to all four arguments. + /// + /// **Return the rows in any order.** Core sorts them into the spec's order + /// (TABLE_CAT, TABLE_SCHEM, TABLE_NAME, COLUMN_NAME, PRIVILEGE) and builds + /// the result set, so a backend does not need an ORDER BY for correctness. + fn column_privileges( + _conn: &Self::Connection, + _cancel: &Self::CancelToken, + _query: &crate::types::ColumnPrivilegesQuery<'_>, + ) -> Result, Self::Error> { + Ok(Vec::new()) + } + + /// Return the table-level privileges on the matching tables. + /// + /// Called by `SQLTablePrivilegesW`. Defaulted to no rows for the same + /// reason as [`Backend::procedures`], and `SQL_ATTR_METADATA_ID` has + /// likewise already been applied to all three arguments. + /// + /// **Return the rows in any order.** Core sorts them into the spec's order + /// (TABLE_CAT, TABLE_SCHEM, TABLE_NAME, PRIVILEGE, GRANTEE) and builds the + /// result set, so a backend does not need an ORDER BY for correctness. + /// Note that PRIVILEGE outranks GRANTEE, unlike `SQLColumnPrivileges`. + fn table_privileges( + _conn: &Self::Connection, + _cancel: &Self::CancelToken, + _query: &crate::types::TablePrivilegesQuery<'_>, + ) -> Result, Self::Error> { + Ok(Vec::new()) + } + + /// Everything needed to cancel work on a statement from a thread holding + /// no lock on the connection, while another thread may be executing on it + /// concurrently. + /// + /// Two shapes are legitimate, and the choice is the backend's: + /// + /// - **Standalone.** The token carries its own channel and shares nothing + /// with the connection. libpq's `PGcancel` (a snapshot of the backend + /// PID and secret key, used over a fresh socket) and MySQL's second + /// connection issuing `KILL QUERY` are both this shape. Prefer it. + /// - **Aliasing.** The token holds the connection itself, sound only where + /// the backend's own client library documents concurrent use. + /// `sqlite3_interrupt` is the example: SQLite guarantees it is safe to + /// call from a thread other than the one running the query. + /// + /// An aliasing token **must** keep its target alive through an `Arc`, + /// never a raw handle: core clones the token out before doing anything + /// else, so it has to survive a concurrent `SQLDisconnect`. SQLite's own + /// documentation states the requirement directly: "it is not safe to + /// call this routine with a database connection that is closed or might + /// close before `sqlite3_interrupt()` returns". The same reasoning applies + /// to any aliasing token, not only SQLite's. + /// + /// A backend that cannot cancel anything uses `()`. + /// + /// `'static` is required, not incidental: core stores the token as + /// `Arc` in the registry so the registry itself + /// can stay non-generic over `Backend`, and `Any` is implemented only for + /// `'static` types. + type CancelToken: Send + Sync + 'static; + + /// Build the cancel token for a connection. + /// + /// Core calls this immediately before the statement's first backend call + /// site (`exec_direct`, `prepare`, a catalog function, ...), not at + /// `SQLAllocHandle(SQL_HANDLE_STMT)`. A statement can be allocated on a + /// connection that is not yet open (`SQLAllocHandle`'s 08003 for that + /// case is Driver-Manager-owned, so core never checks it), which leaves + /// no `&Self::Connection` to build a token from until a + /// statement-producing call actually supplies one. Resolution can precede + /// a fallible step (parameter collection, say) that then fails before any + /// backend call happens; the token still gets built in that case, just + /// unused, which is harmless, since it is cheap and the statement may yet + /// make a real call later. + /// + /// A **new token is minted at every statement-producing call**, including + /// a second `SQLExecute` on the same handle; the statement's stored token + /// is replaced each time, and the cursor-consuming calls (`SQLFetch`, + /// `SQLGetData`, ...) read that execution's token rather than minting one. + /// Reusing one token across executions would leave a cancelled statement + /// permanently unusable, since `Backend::cancel` marks the token and the + /// next execution would find it marked. The spec requires the opposite: + /// "After the statement has been canceled, the application can call + /// SQLExecute or SQLExecDirect again." + /// + /// The consequence a backend author should plan for is that a `SQLCancel` + /// which cloned an *earlier* execution's token signals only that + /// execution. That is the spec's own outcome for a cancel arriving when + /// the work it named is already over: "a call to SQLCancel when no + /// processing is being done on the statement ... has is \[sic\] no effect + /// at all." + /// + /// Build the token with the connection's parameters in hand here; do not + /// defer the real assembly to `cancel`. MariaDB's ODBC-401 is the failure + /// this rule exists to prevent: its cancel channel was assembled lazily, by + /// which time the original connection's TLS settings were out of reach, so + /// cancelling an encrypted connection failed silently. + /// + /// A backend whose cancellation needs a value not known until execution + /// (e.g. a query id) returns an empty shared slot here, such as an + /// `Arc>>`, and fills it from whichever + /// statement-producing method actually runs the work. A catalog method like + /// `tables` is a real query with its own id on some backends, not only + /// `exec_direct`/`prepare`/`execute`, and every one of them receives the + /// same token. + fn cancel_token(conn: &Self::Connection) -> Self::CancelToken; + + /// Cancel whatever the token refers to. + /// + /// Called by `SQLCancel`, possibly while another thread is executing on + /// the same statement. It receives neither a connection nor a statement, + /// and that is what keeps this call off the *guarded state*: there is no + /// `&mut Self::Statement` or `&Self::Connection` reachable through it, so a + /// backend cannot reach into a handle another thread may be mutating. + /// + /// That is **not** the same as "core never holds the connection's lock + /// while calling this". Whether it does depends on which of `SQLCancel`'s + /// two cases applies: + /// + /// - **Another thread holds the connection** (the cross-thread cancel the + /// spec singles out): core calls this holding **no lock at all**. + /// - **The connection is idle** (a data-at-execution cancel, or "no + /// processing in progress"): core calls this **while holding the + /// connection's group lock**, because it needs that same scope + /// afterward to post the resulting diagnostic. + /// + /// A `cancel` implementation must therefore never block on anything that + /// itself waits for this connection's lock, such as a call back into + /// another `SQLxxx` entry point on the same connection, or the idle-path + /// case deadlocks the calling thread. Blocking on something + /// outside the connection (a network round-trip to the data source + /// asking it to cancel a query, say) is fine on both paths; it just means + /// `SQLCancel` itself does not return until that finishes. + /// + /// Any state a backend needs to clear after a cancellation (e.g. + /// streaming/pagination state) has to live in the token itself, or behind + /// synchronisation the backend owns. + /// + /// The default returns `NotImplemented`, which `SQLCancel` treats as + /// success: there was nothing to cancel. + fn cancel(_token: &Self::CancelToken) -> Result<(), Self::Error> { + Err(OdbcError::NotImplemented { + feature: "cancel".into(), + } + .into()) + } + + /// Whether this token has been signalled by [`Backend::cancel`]. + /// + /// Core calls this **only after a backend call returned an error**, to + /// decide whether that error was a cancellation. When it answers `true`, + /// core discards the backend's SQLSTATE and reports `HY008` instead, per + /// the spec's "If the original function is canceled, it returns SQL_ERROR + /// and SQLSTATE HY008 (Operation canceled)." + /// + /// Core never asks on the success path, and a backend must not expect it + /// to. The spec explicitly allows a cancelled execution to finish anyway: + /// "it is possible for the execution to succeed and return SQL_SUCCESS + /// while the cancel is also successful." A successful call therefore stays + /// successful whatever this would have answered. + /// + /// **Implement this whenever you implement [`Backend::cancel`].** The two + /// are a pair: `cancel` signals, this observes. A backend that signals but + /// cannot observe still cancels the work; it just reports the resulting + /// failure with whatever SQLSTATE its own error mapping produced, usually + /// `HY000`, which tells the application nothing about why it failed. + /// + /// The default is `false`, which is the correct answer for a backend that + /// leaves `cancel` defaulted and therefore has no cancellation at all. + /// + /// Called with no lock held on the cross-thread path and with the + /// connection's group lock held on the idle path, exactly as + /// [`Backend::cancel`] is, so the same "never block on anything that waits + /// for this connection's lock" rule applies. + fn is_cancelled(_token: &Self::CancelToken) -> bool { + false + } + + /// Commit or roll back the current transaction on a connection. + /// + /// Called by `SQLEndTran`. If `commit` is `true`, commit; otherwise roll back. + /// The default implementation returns `NotImplemented`; backends that support + /// explicit transactions should override this. + fn end_tran(_conn: &Self::Connection, _commit: bool) -> Result<(), Self::Error> { + Err(OdbcError::NotImplemented { + feature: "end_tran".into(), + } + .into()) + } + + /// What `SQLEndTran(SQL_COMMIT)` does to the open cursors on a connection. + /// + /// This value is authoritative in two places at once: `sql_end_tran` + /// applies it to the connection's statements, and `SQLGetInfoW` reports it + /// for `SQL_CURSOR_COMMIT_BEHAVIOR`. Overriding this method therefore + /// changes both together, which is the point: the value a driver reports + /// and the value it applies have to be the same one, or it advertises a + /// behaviour it does not implement. + /// + /// The default is [`crate::types::CursorBehavior::Preserve`]: the least destructive + /// value, and the one both reference drivers report for commit: + /// psqlODBC's `info.c` answers `SQL_CURSOR_COMMIT_BEHAVIOR` with + /// `SQL_CB_PRESERVE`, and MySQL Connector/ODBC's `driver/info.cc` answers + /// `SQL_CB_PRESERVE` for commit and rollback in one shared arm. A backend whose + /// data source drops cursors on commit **must** override this. + /// + /// # Reporting path + /// + /// [`default_get_info`] derives `SQL_CURSOR_COMMIT_BEHAVIOR` from this + /// hook, and so does core's own DM-safe fallback + /// (`info_type_default_response` in `src/ffi/info.rs`), so a backend that + /// answers the info type *nowhere* still reports the declared value. The + /// one remaining way to bypass the hook is to answer + /// `SQL_CURSOR_COMMIT_BEHAVIOR` outright: from the backend's own + /// typed `get_info` match, or from [`Backend::get_info_raw`], which is + /// consulted before the fallback. A backend that does either must keep the + /// reported value and this hook in sync itself. + /// + /// # `SQL_CB_CLOSE` requires `close_cursor` + /// + /// Under [`crate::types::CursorBehavior::Close`], `sql_end_tran` closes each + /// statement's cursor through [`StatementBackend::close_cursor`] and leaves + /// the statement itself prepared (the transition table's footnote `[2]`). + /// `close_cursor` defaults to a no-op, so a backend declaring `Close` + /// **must** implement it or no cursor is actually closed. + /// [`crate::types::CursorBehavior::Delete`] needs no such implementation: + /// core drops the backend statement outright. + fn cursor_commit_behavior() -> crate::types::CursorBehavior { + crate::types::CursorBehavior::Preserve + } + + /// What `SQLEndTran(SQL_ROLLBACK)` does to the open cursors on a connection. + /// + /// Separate from [`Backend::cursor_commit_behavior`] because the two + /// legitimately differ: psqlODBC reports `SQL_CB_PRESERVE` for commit but + /// `SQL_CB_CLOSE` for rollback when `use_declarefetch` is enabled. + /// + /// Reported for `SQL_CURSOR_ROLLBACK_BEHAVIOR` by [`common_get_info_raw`] + /// and, for a backend that answers the info type nowhere, by core's own + /// DM-safe fallback; see [`Backend::cursor_commit_behavior`] for the rest + /// of the contract, including the requirement that a backend declaring + /// [`crate::types::CursorBehavior::Close`] implement + /// [`StatementBackend::close_cursor`]. + fn cursor_rollback_behavior() -> crate::types::CursorBehavior { + crate::types::CursorBehavior::Preserve + } + + /// Returns the connection string attribute names required by this driver. + /// + /// Used by `SQLBrowseConnectW` to determine which attributes are still + /// missing and must be supplied by the application. Keys should be + /// lowercase to match `ConnectParams` storage convention. + /// + /// The default returns an empty slice (all attributes are optional). + /// + /// `Cow<'static, [Cow<'static, str>]>`, not `&'static [&'static str]`: this + /// method takes no connection, so nothing about it varies at runtime, but + /// it matches the shape of the trait's other list-returning methods for + /// uniformity of vocabulary rather than because it needs to. + fn browse_connect_attrs() -> Cow<'static, [Cow<'static, str>]> { + Cow::Borrowed(&[]) + } + + /// The escape-translation dialect for this backend (`{fn}` name map, + /// identifier quotes, date-literal rendering). Called by the generic + /// `SQLExecDirect`/`SQLPrepare`/`SQLNativeSql` translation. The default is a + /// neutral ANSI dialect. + fn escape_dialect(_conn: &Self::Connection) -> crate::escape::EscapeDialect { + crate::escape::EscapeDialect::ansi_default() + } + + /// Describes the 1-based `parameter_number`-th parameter marker of `sql`, + /// for `SQLDescribeParam`. + /// + /// `sql` is the prepared statement text as the backend received it (after + /// escape translation). `Ok(None)` means "this backend cannot tell", which + /// is the default and which leaves core reporting a generic + /// `VARCHAR(SQL_DEFAULT_PARAM_SIZE)`: usable, but wrong for any parameter + /// that is not a string, which is what leads a client that sizes its buffers + /// from this to send a number as text and get a type error back from the + /// data source. + /// + /// Override this wherever the data source can be asked. A backend that can + /// describe *some* parameters should answer `Ok(None)` for the rest rather + /// than guess: core's fallback is at least a documented, uniform guess, + /// whereas a wrong specific type is indistinguishable from a real answer. + /// + /// Note that `SQLGetInfo(SQL_DESCRIBE_PARAMETER)` reports `"Y"` either way. + /// That is not a claim about accuracy: the spec defines it as whether the + /// driver supports the *call*, and core always answers it. + fn describe_param( + _conn: &Self::Connection, + _sql: &str, + _parameter_number: u16, + ) -> Result, Self::Error> { + Ok(None) + } + + /// The data-source-dependent widths of this driver's catalog result-set + /// columns, and the SQL type its character columns report. + /// + /// Every catalog result set the driver can produce derives from this one + /// value: `SQLTables`, `SQLColumns`, `SQLPrimaryKeys`, `SQLForeignKeys`, + /// `SQLStatistics`, `SQLSpecialColumns`, `SQLProcedures`, + /// `SQLProcedureColumns`, `SQLColumnPrivileges`, `SQLTablePrivileges` and + /// `SQLGetTypeInfo`, so they cannot describe the same column two ways. + /// + /// The default suits a data source with no identifier length limit. A + /// driver for a source that *does* impose one, PostgreSQL's 63-character + /// `NAMEDATALEN - 1` say, overrides this, and both its catalog result + /// sets and its `SQL_MAX_*_NAME_LEN` answers follow from the one override. + /// + /// Takes no connection, unlike the capability methods below. + /// `SQLProcedures`, `SQLProcedureColumns`, `SQLColumnPrivileges` and + /// `SQLTablePrivileges` return an empty result set without resolving one, + /// and requiring a connection here would put a connection lookup, and an + /// error path, into paths that have neither. A driver whose identifier + /// limit varies by server version reports the widest form here and narrows + /// the `SQL_MAX_*_NAME_LEN` answers through the per-connection hooks. + fn catalog_result_column_widths() -> CatalogResultColumnWidths { + CatalogResultColumnWidths::default() + } + + // ------------------------------------------------------------------ + // Capability declarations + // + // Each takes `&Self::Connection`, because `SQLGetInfo` is a + // *per-connection* call: what a data source can do is a property of the + // connection, not of the driver binary. A backend whose answer depends on + // the server version reads it from the connection here; one whose answer is + // fixed ignores the argument. Modelling these as associated functions would + // make a driver pick one answer for every server it ever talks to. + // + // Exactly three declarations take no connection, for two different + // reasons. + // + // `cursor_commit_behavior` and `cursor_rollback_behavior`, because + // `SQLGetInfo` must answer `SQL_CURSOR_COMMIT_BEHAVIOR` and + // `SQL_CURSOR_ROLLBACK_BEHAVIOR` *before* a connection exists: the Windows + // Driver Manager queries info types ahead of `SQLDriverConnectW`, and + // falling through to the shape default there answers `SQL_CB_DELETE`, + // which is the exact claim these two hooks exist to stop core from + // inventing. + // + // `catalog_result_column_widths`, for an unrelated reason: it is not an + // `SQLGetInfo` answer at all. Four catalog functions (`SQLProcedures`, + // `SQLProcedureColumns`, `SQLColumnPrivileges`, `SQLTablePrivileges`) + // return an empty result set without resolving a connection, and requiring + // one here would put a connection lookup, and an error path, into paths + // that have neither. + // + // The rule all three share is the general one: a declaration consumed on a + // path that has no connection cannot require one. Every other capability + // here is consumed only once a connection exists, so it takes one. + // ------------------------------------------------------------------ + + /// Whether this data source exposes ODBC **catalogs**. + /// + /// The `SQLGetInfo` spec defines a whole group of info types in terms of + /// this one fact, and mandates an empty string or zero for every one of + /// them when the answer is no: + /// + /// | Info type | Value when `false` | + /// |---|---| + /// | `SQL_CATALOG_NAME` | `"N"` | + /// | `SQL_CATALOG_TERM` | `""` | + /// | `SQL_CATALOG_NAME_SEPARATOR` | `""` | + /// | `SQL_CATALOG_LOCATION` | `0` | + /// | `SQL_CATALOG_USAGE` | `0` | + /// + /// [`default_get_info`] derives all five from this method, so a backend + /// cannot report `SQL_CATALOG_NAME = "N"` and name its catalogs in the + /// same breath. + /// + /// **Required** rather than defaulted: a defaulted `true` would silently + /// reproduce that contradiction in the next catalog-less backend, and the + /// fact is one every backend author already knows. + /// + /// Note that when the answer is `true`, `SQL_CATALOG_LOCATION` and + /// `SQL_CATALOG_USAGE` become genuinely data-source-specific; core returns + /// `None` for them rather than inventing a value, so a backend with + /// catalogs answers those two itself. + fn supports_catalogs(conn: &Self::Connection) -> bool; + + /// Whether this data source exposes ODBC **schemas**. + /// + /// The schema half of [`Backend::supports_catalogs`]: the spec mandates + /// `SQL_SCHEMA_TERM = ""` and `SQL_SCHEMA_USAGE = 0` when the answer is + /// no, and both are derived from this method by [`default_get_info`]. + /// When the answer is `true`, `SQL_SCHEMA_USAGE` is data-source-specific + /// and left to the backend. + /// + /// There is no `SQL_SCHEMA_NAME` info type; the spec directs applications + /// to `SQL_CATALOG_NAME` for both questions, so this hook is the only + /// place the schema fact is stated. + fn supports_schemas(conn: &Self::Connection) -> bool; + + /// The `SQL_ALTER_TABLE` (86) capability bitmask: an OR of the + /// [`SQL_AT_*`](crate::types::SQL_AT_ADD_COLUMN_SINGLE) constants. + /// + /// Required rather than defaulted for the reason a capability bitmap + /// always should be: `0` means "this data source cannot `ALTER TABLE` at + /// all", which is a claim, not an absence of one. A backend author is + /// unlikely to notice a capability they never wrote code for, so the + /// compiler asks instead. Return `0` only if that is genuinely true. + fn alter_table_support(conn: &Self::Connection) -> u32; + + /// The `SQL_OJ_CAPABILITIES` (115) bitmask: an OR of the + /// [`SQL_OJ_*`](crate::types::SQL_OJ_LEFT) constants. + /// + /// Required for the same reason as [`Backend::alter_table_support`]: `0` + /// asserts that the data source supports no outer joins whatsoever. + fn outer_join_capabilities(conn: &Self::Connection) -> u32; + + /// The `SQL_GROUP_BY` (88) relationship between the columns in a + /// `GROUP BY` clause and the non-aggregated columns in the select list, + /// one of the [`SQL_GB_*`](crate::types::SQL_GB_NO_RELATION) values. + /// + /// Required because every value here is a claim, `0` + /// (`SQL_GB_NOT_SUPPORTED`, "GROUP BY is not supported") included. There is + /// none core could pick that would merely be permissive: even + /// `SQL_GB_NO_RELATION` is one the spec says an entry-level driver does not + /// return: "a SQL-92 Entry level-conformant driver will always return the + /// SQL_GB_GROUP_BY_EQUALS_SELECT option as supported." + fn group_by(conn: &Self::Connection) -> u16; + + /// The `SQL_NULL_COLLATION` (85) position of NULLs in a sorted result set, + /// one of the [`SQL_NC_*`](crate::types::SQL_NC_END) values. + /// + /// Required because `0` is [`SQL_NC_HIGH`](crate::types::SQL_NC_HIGH), a + /// substantive answer ("NULLs sort high, depending on ASC/DESC") rather + /// than an absence of one, so a shape-derived default would silently make + /// that claim for every backend. + fn null_collation(conn: &Self::Connection) -> u16; + + /// How the data source treats *unquoted* identifiers: + /// `SQL_IDENTIFIER_CASE` (28), one of the + /// [`SQL_IC_*`](crate::types::SQL_IC_UPPER) values. + /// + /// Required because the shape-aware default cannot produce a legal answer + /// here: the spec defines `SQL_IC_UPPER` (1), `SQL_IC_LOWER` (2), + /// `SQL_IC_SENSITIVE` (3) and `SQL_IC_MIXED` (4), and `0` is none of them. + /// Unlike the capability methods where zero is a substantive claim, there + /// is no value core could pick that is merely *understated*. Every choice + /// is a different assertion about how the data source folds identifiers, + /// and an application uses it to decide how to quote generated SQL. + /// + /// Distinct from [`Backend::quoted_identifier_case`], which describes + /// *quoted* identifiers. + fn identifier_case(conn: &Self::Connection) -> u16; + + /// How the data source treats *quoted* identifiers: + /// `SQL_QUOTED_IDENTIFIER_CASE` (93), one of the + /// [`SQL_IC_*`](crate::types::SQL_IC_UPPER) values. + /// + /// The counterpart of [`Backend::identifier_case`] and required for the + /// same reason: `0` is not one of the four legal values, so there is no + /// understated answer to fall back on. Every choice is a different + /// assertion about what the system catalog stores, and the two are + /// genuinely independent: a data source that upper-cases unquoted + /// identifiers commonly stores quoted ones verbatim. + /// + /// Not derivable from `identifier_case`: in the MySQL family the answer + /// depends on server configuration rather than on the unquoted rule. + fn quoted_identifier_case(conn: &Self::Connection) -> u16; + + /// The `SQL_TXN_CAPABLE` (46) level: what the data source allows inside a + /// transaction, as one of the [`SQL_TC_*`](crate::types::SQL_TC_ALL) + /// values. + /// + /// Required because `0` is + /// [`SQL_TC_NONE`](crate::types::SQL_TC_NONE), "transactions not + /// supported", the strongest possible denial, and the one a shape-aware + /// default would make on a backend's behalf. A backend that declares an + /// isolation level in [`Backend::txn_isolation_options`] and implements + /// [`Backend::end_tran`] would then be reported as having no transactions + /// at all, which is the self-contradiction the constrained-info-type rule + /// in AGENTS.md exists to prevent. A test pins the pair: `SQL_TC_NONE` if + /// and only if `txn_isolation_options` is `0`. + /// + /// Return `u16`, not `u32`: the info type is `SQLUSMALLINT`, and the + /// `SQL_TC_*` constants are typed `u32` for use in bitmask expressions. + fn txn_capable(conn: &Self::Connection) -> u16; + + /// Whether the data source supports the Integrity Enhancement Facility: + /// `SQL_INTEGRITY` (73), reported as `"Y"` or `"N"`. + /// + /// The IEF is referential-integrity DDL: `FOREIGN KEY`, `CHECK` and the + /// rest. The spec's wording is "`"Y"` if **the data source** supports the + /// Integrity Enhancement Facility", so this is a statement about the data + /// source and not about the driver, which is why core cannot answer it + /// from anything it knows about itself. + fn integrity(conn: &Self::Connection) -> bool; + + /// Whether more than one transaction can be active at a time: + /// `SQL_MULTIPLE_ACTIVE_TXN` (37), reported as `"Y"` or `"N"`. + /// + /// The spec: "`"Y"` if the driver supports more than one active + /// transaction at the same time, `"N"` if only one transaction can be + /// active at any time." A driver whose connections are independent answers + /// `"Y"`, and `"N"` is a real restriction an application plans around, so + /// neither is a safe default. + fn multiple_active_txn(conn: &Self::Connection) -> bool; + + /// The characters other than `a`–`z`, `A`–`Z`, `0`–`9` and `_` that may + /// appear in an identifier without delimiting it: + /// `SQL_SPECIAL_CHARACTERS` (94). + /// + /// Required for the same reason as [`Backend::keywords`]: an empty list is + /// an answer, not an absence. An application reads this to decide when an + /// identifier must be quoted, so `""` asserts that nothing beyond the + /// alphanumerics and underscore is legal unquoted, a claim about the data + /// source that core has no way to make. + fn special_characters(conn: &Self::Connection) -> Cow<'static, str>; + + /// Whether the connected user can execute every procedure `SQLProcedures` + /// returns: `SQL_ACCESSIBLE_PROCEDURES` (20), reported as `"Y"` or `"N"`. + /// + /// The counterpart of [`Backend::accessible_tables`], and required for the + /// same reason: `"Y"` is a guarantee about the connected principal's + /// privileges that only the backend can make. + fn accessible_procedures(conn: &Self::Connection) -> bool; + + /// The driver's own name: `SQL_DRIVER_NAME` (6). + /// + /// Takes no connection: the Windows Driver Manager asks for driver + /// identity *before* `SQLDriverConnectW`, and an answer that needed a + /// connection could not be given then. + /// + /// Required because core has no name to give and the empty string is not a + /// usable one. With this and [`Backend::driver_version`] declared, core + /// answers the whole pre-connect identity group itself, so a driver never + /// has to override [`Backend::get_info_pre_connect`] for the Driver + /// Manager's benefit. + fn driver_name() -> Cow<'static, str>; + + /// The driver's own version: `SQL_DRIVER_VER` (7), in the spec's + /// `##.##.####` form, which [`crate::types::format_odbc_version`] produces. + /// + /// Takes no connection, for the reason given on [`Backend::driver_name`]. + fn driver_version() -> Cow<'static, str>; + + /// The name of the DBMS this connection reached: `SQL_DBMS_NAME` (17). + /// + /// Required because only the backend knows what it connected to, and the + /// empty string a shape-aware default would produce is what tools display + /// and log when they identify a data source. + fn dbms_name(conn: &Self::Connection) -> Cow<'static, str>; + + /// The version of the DBMS this connection reached: `SQL_DBMS_VER` (18), + /// in the spec's `##.##.####` form followed by any vendor-defined suffix. + /// + /// Per-connection rather than per-driver: it is the server's version, not + /// the driver's, and a backend that gates capabilities on it already has + /// the value (see [`crate::types::format_odbc_version`]). + fn dbms_version(conn: &Self::Connection) -> Cow<'static, str>; + + /// The `SQL_CORRELATION_NAME` (74) support level: one of the + /// [`SQL_CN_*`](crate::types::SQL_CN_ANY) values. + /// + /// Required because `0` is [`SQL_CN_NONE`](crate::types::SQL_CN_NONE), + /// "correlation names are not supported". The spec also ties this to + /// [`Backend::sql_conformance`]: "a SQL-92 Entry level-conformant driver + /// will always return SQL_CN_ANY." + fn correlation_name(conn: &Self::Connection) -> u16; + + /// The `SQL_NON_NULLABLE_COLUMNS` (75) answer to whether the data source + /// supports `NOT NULL`: [`SQL_NNC_NULL`](crate::types::SQL_NNC_NULL) or + /// [`SQL_NNC_NON_NULL`](crate::types::SQL_NNC_NON_NULL). + /// + /// Required because `0` is `SQL_NNC_NULL`, "all columns must be nullable". + /// The spec ties this to [`Backend::sql_conformance`] too: "a SQL-92 Entry + /// level-conformant driver will return SQL_NNC_NON_NULL." + fn non_nullable_columns(conn: &Self::Connection) -> u16; + + /// Whether the data source supports expressions (not just column names) in + /// an `ORDER BY` list: `SQL_EXPRESSIONS_IN_ORDERBY` (27), reported as + /// `"Y"` or `"N"`. + /// + /// Required rather than defaulted because it is a capability an + /// application acts on: a tool deciding whether to push `ORDER BY + /// lower(name)` down to the data source reads this, and both a wrong `"N"` + /// and the `""` a shape-derived default would produce read as "no". + fn expressions_in_order_by(conn: &Self::Connection) -> bool; + + /// The `SQL_SQL_CONFORMANCE` (118) level: one of the + /// [`SQL_SC_*`](crate::types::SQL_SC_SQL92_ENTRY) values. + /// + /// Required because core cannot know it, and hard-coding it would make core + /// contradict itself: claiming `SQL_SC_SQL92_ENTRY` while separately + /// supplying `SQL_GROUP_BY`, `SQL_CORRELATION_NAME` and + /// `SQL_NON_NULLABLE_COLUMNS` values the spec says an entry-level driver + /// never returns. Declaring a level here is a promise about those three + /// hooks. + fn sql_conformance(conn: &Self::Connection) -> u32; + + /// The `SQL_SUBQUERIES` (95) bitmask: which predicates accept a subquery, + /// as an OR of the [`SQL_SQ_*`](crate::types::SQL_SQ_EXISTS) constants. + /// + /// Constrained by [`Backend::sql_conformance`]: "a SQL-92 Entry + /// level-conformant driver will always return a bitmask with all of these + /// bits set." Hard-coding that in core would tell a backend declaring no + /// conformance level at all that it supports correlated subqueries, the + /// claim a BI tool acts on when it decides to push one down. + fn subqueries(conn: &Self::Connection) -> u32; + + /// Whether the data source supports column aliases (`SELECT x AS y`): + /// `SQL_COLUMN_ALIAS` (87), reported as `"Y"` or `"N"`. + /// + /// Constrained by [`Backend::sql_conformance`]: "a SQL-92 Entry + /// level-conformant driver will always return 'Y'." + fn column_alias(conn: &Self::Connection) -> bool; + + /// How the data source concatenates a NULL character column with a + /// non-NULL one: `SQL_CONCAT_NULL_BEHAVIOR` (22), either + /// [`SQL_CB_NULL`](crate::types::SQL_CB_NULL) or + /// [`SQL_CB_NON_NULL`](crate::types::SQL_CB_NON_NULL). + /// + /// Required because `0` is `SQL_CB_NULL`, a substantive answer. Also + /// constrained by [`Backend::sql_conformance`]: "a SQL-92 Entry + /// level-conformant driver will always return SQL_CB_NULL." + fn concat_null_behavior(conn: &Self::Connection) -> u16; + + /// The `SQL_UNION` (96) bitmask: which of `UNION` and `UNION ALL` the data + /// source supports, as an OR of + /// [`SQL_U_UNION`](crate::types::SQL_U_UNION) and + /// [`SQL_U_UNION_ALL`](crate::types::SQL_U_UNION_ALL). + fn union_support(conn: &Self::Connection) -> u32; + + /// The `SQL_CONVERT_FUNCTIONS` (48) bitmask: which of the ODBC conversion + /// functions the driver supports, as an OR of + /// [`SQL_FN_CVT_CAST`](crate::types::SQL_FN_CVT_CAST) and + /// [`SQL_FN_CVT_CONVERT`](crate::types::SQL_FN_CVT_CONVERT). + /// + /// Note this is about `CAST` / `CONVERT` themselves. Which *type pairs* + /// each can convert between is the separate `SQL_CONVERT_*` family, which + /// a backend answers through [`Backend::get_info_raw`]. + fn convert_functions(conn: &Self::Connection) -> u32; + + /// Whether a column named in `ORDER BY` must also appear in the select + /// list: `SQL_ORDER_BY_COLUMNS_IN_SELECT` (90), reported as `"Y"` or + /// `"N"`. + /// + /// `false` is the *permissive* answer, so it is a claim rather than an + /// absence of one: it tells an application it may order by a column it did + /// not select. + fn order_by_columns_in_select(conn: &Self::Connection) -> bool; + + /// Whether the connected user is guaranteed `SELECT` on **every** table + /// `SQLTables` returns: `SQL_ACCESSIBLE_TABLES` (19). + /// + /// `true` is a guarantee core cannot make on a backend's behalf, and one + /// that depends on the connected principal rather than the driver. Return + /// `false` unless the data source genuinely filters its catalog by + /// privilege. + fn accessible_tables(conn: &Self::Connection) -> bool; + + /// Whether the data source is read-only: `SQL_DATA_SOURCE_READ_ONLY` + /// (25), reported as `"Y"` or `"N"`. + fn data_source_read_only(conn: &Self::Connection) -> bool; + + /// The `SQL_SEARCH_PATTERN_ESCAPE` (14) character: what escapes `%` and + /// `_` in the pattern arguments of the catalog functions, so they match + /// literally. + /// + /// Applies only to catalog-function patterns, not to the `LIKE` predicate + /// (that is `SQL_LIKE_ESCAPE_CLAUSE`). Return `""` if the data source has + /// no escape character, which is the spec's answer for that case, and one + /// core cannot distinguish from a backend that simply never set it. + /// + /// Takes a connection because the escape character can genuinely differ by + /// data source, so the answer is computed rather than a `'static` borrow, + /// hence `Cow<'static, str>` rather than `&'static str`. + fn search_pattern_escape(conn: &Self::Connection) -> Cow<'static, str>; + + /// The data source's own reserved words, unfiltered. + /// + /// Core reports `SQL_KEYWORDS` (89) from this, after removing everything + /// ODBC already reserves: the spec defines the info type as the data + /// source's keywords *excluding* its own ("This list does not contain + /// keywords specific to ODBC or keywords used by both the data source and + /// ODBC"), so a backend states the raw fact and core applies the rule once + /// against [`ODBC_RESERVED_KEYWORDS`](crate::types::ODBC_RESERVED_KEYWORDS). + /// + /// Required, not defaulted: an empty list is the claim that this data + /// source reserves nothing beyond ODBC, which applications act on when + /// deciding what to quote. + /// + /// Return the raw names in any order and in any case; core filters + /// case-insensitively, sorts, and joins. Note that it does so on **every** + /// call rather than caching: a `static` cannot be generic over `B`, and + /// `SQLGetInfo(SQL_KEYWORDS)` is not a hot path. A backend whose list is + /// expensive to produce, read out of a linked library say, should cache + /// behind its own `OnceLock` and return the cached slice from here. + /// + /// Takes a connection because the keyword list can genuinely differ by + /// data source, so the answer is computed rather than a `'static` borrow, + /// hence `Cow<'static, [Cow<'static, str>]>` rather than + /// `&'static [&'static str]`; the inner `Cow` lets each keyword itself be + /// owned, not only the list. + fn keywords(conn: &Self::Connection) -> Cow<'static, [Cow<'static, str>]>; + + /// The `SQL_TIMEDATE_ADD_INTERVALS` (109) bitmask: the interval units the + /// `TIMESTAMPADD` scalar function accepts, as an OR of the + /// [`SQL_FN_TSI_*`](crate::types::SQL_FN_TSI_SECOND) constants. + /// + /// Coupled to `SQL_TIMEDATE_FUNCTIONS`: a backend claiming + /// `SQL_FN_TD_TIMESTAMPADD` there and `0` here would be saying the function + /// exists but accepts no units. Required so that contradiction cannot be + /// inherited silently. Return `0` only if `TIMESTAMPADD` is genuinely not + /// supported. + fn timedate_add_intervals(conn: &Self::Connection) -> u32; + + /// The `SQL_TIMEDATE_DIFF_INTERVALS` (110) bitmask: the interval units the + /// `TIMESTAMPDIFF` scalar function accepts. + /// + /// Separate from [`Backend::timedate_add_intervals`] because a data source + /// may accept different units for each; see that method for the coupling + /// to `SQL_TIMEDATE_FUNCTIONS`. + fn timedate_diff_intervals(conn: &Self::Connection) -> u32; + + /// The `SQL_DEFAULT_TXN_ISOLATION` (26) level this data source runs at + /// when the application has not set one: a single + /// [`SQL_TXN_*`](crate::types::SQL_TXN_SERIALIZABLE) constant, or `0` if + /// the data source does not support transactions. + /// + /// This is also what `SQLGetConnectAttr(SQL_ATTR_TXN_ISOLATION)` reports + /// on a connection where the application has not set the attribute. Both + /// answers come from here so they cannot disagree. Answering the connection + /// attribute from a constant instead would let a connection report one + /// isolation level through `SQLGetConnectAttr` and another through + /// `SQLGetInfo`, with nothing in either path to reconcile them. + fn default_txn_isolation(conn: &Self::Connection) -> u32; + + /// The `SQL_TXN_ISOLATION_OPTION` (72) bitmask: every isolation level this + /// data source can actually run at, as an OR of the + /// [`SQL_TXN_*`](crate::types::SQL_TXN_SERIALIZABLE) constants. `0` if the + /// data source does not support transactions. + /// + /// `SQLSetConnectAttr(SQL_ATTR_TXN_ISOLATION)` validates against this and + /// rejects anything outside it with `HY024`, so a level reported here is a + /// promise the backend must be able to keep; see + /// [`Backend::set_txn_isolation`], which a backend declaring more than one + /// level is required to implement. + /// + /// Must include [`Backend::default_txn_isolation`] whenever that is + /// non-zero. + fn txn_isolation_options(conn: &Self::Connection) -> u32; + + /// Apply an isolation level to an open connection. + /// + /// Called by `SQLSetConnectAttr(SQL_ATTR_TXN_ISOLATION)` after `level` has + /// been validated against [`Backend::txn_isolation_options`], and by + /// `SQLDriverConnect`/`SQLConnect` for a level the application set before + /// connecting. `level` is always exactly one `SQL_TXN_*` bit. + /// + /// The default handles the common case of a data source with exactly one + /// isolation level: there is nothing to switch to, so applying the only + /// supported level succeeds without the backend writing any code. A + /// backend that declares **more than one** level in + /// `txn_isolation_options` must override this, or the default reports + /// `NotImplemented` rather than accepting a level it would then silently + /// fail to apply. + /// + /// A backend with **no** transactions (`txn_isolation_options` of `0`, + /// matching `SQL_TC_NONE`) never reaches this method at all: validation + /// rejects every level before the call, because no level can be inside an + /// empty set. The `NotImplemented` branch below is therefore unreachable + /// for such a backend, and it needs no implementation. + fn set_txn_isolation(conn: &Self::Connection, level: u32) -> Result<(), Self::Error> { + if Self::txn_isolation_options(conn) == level { + // The only level this data source has; it is already in effect. + Ok(()) + } else { + Err(OdbcError::NotImplemented { + feature: "set_txn_isolation".into(), + } + .into()) + } + } +} + +/// Separate trait for statement/cursor operations. +/// +/// All methods have default implementations that return `NotImplemented` errors, +/// allowing backends to implement only the methods they support. Override methods +/// as you implement real functionality. +pub trait StatementBackend: Send + Sync { + /// The one error type every [`StatementBackend`] method returns. + /// + /// Same bounds and same reasoning as [`Backend::Error`]. A driver is free to + /// use one type for both traits, since nothing here requires them to differ. + /// + /// This exists so the fetch path keeps its causal chain. `fetch` and + /// `get_data` are the hottest error path in the crate, and returning + /// `OdbcError` directly would make a driver flatten its own error into a + /// string at every call, which is what `Backend::Error` exists to stop + /// everywhere else. + type Error: Into + From + std::error::Error + Send + Sync + 'static; + + /// Advances the cursor to the next row. + /// + /// Called by `SQLFetch`. Returns [`FetchResult::Row`] if a row is available, + /// [`FetchResult::NoData`] when the result set is exhausted. + fn fetch(&mut self) -> Result { + Err(OdbcError::NotImplemented { + feature: "fetch".into(), + } + .into()) + } + + /// Retrieves the value of column `col` (1-based) from the current row. + /// + /// Called by `SQLGetData`. The value is converted to `target_type` as requested by + /// the application. + /// + /// Returns a [`Cow`] so that backends which cache rows in memory can hand + /// back a borrow (`Cow::Borrowed`) without cloning, while backends that + /// need to construct a value on the fly can still return `Cow::Owned`. + fn get_data( + &mut self, + _col: u16, + _target_type: CDataType, + ) -> Result, Self::Error> { + Err(OdbcError::NotImplemented { + feature: "get_data".into(), + } + .into()) + } + + /// A warning raised while producing the value [`Self::get_data`] just + /// returned, taken so it is reported once. + /// + /// Core calls this immediately after **every** `get_data`, on both paths + /// that read a value: `SQLGetData`, and `SQLFetch`'s bound-column loop. + /// A returned warning is posted to the statement's diagnostic queue and the + /// call reports `SQL_SUCCESS_WITH_INFO`. + /// + /// # When to use it + /// + /// Only for precision a backend dropped in its *own* conversion, before a + /// [`ColumnValue`] existed, which is precisely what core cannot see. A + /// backend delivering nine fractional digits for a `timestamp(12)` column + /// returns [`ValueWarning::FractionalTruncation`] here and caps its declared + /// `decimal_digits` to match. + /// + /// **This is not an error channel.** Returning a warning does not make the + /// call fail; a condition that should fail belongs in `get_data`'s `Err` + /// arm. And core raises `01S07` itself where *it* drops precision, so a + /// backend must not also report a loss core is going to detect, which + /// would post the record twice. + /// + /// # Taken, not read + /// + /// The name is `take_` because core calls it once per value and expects the + /// warning to be cleared: a backend that returns the same warning forever + /// attaches it to every subsequent column of every subsequent row. + /// + /// Returns `Option` rather than a collection because `get_data` is the + /// hottest path in the crate, and a `Vec` would allocate on every column of + /// every row to carry nothing in the overwhelming case. + fn take_value_warning(&mut self) -> Option { + None + } + + /// Returns the number of columns in the result set. + /// + /// Called by `SQLNumResultCols`. Returns 0 if no result set is active. + /// + /// `i16` because that is what the ABI is: `SQLNumResultCols` writes through + /// a `SQLSMALLINT *`. `u16` would let a backend name a count the driver + /// cannot report, and push a fallible narrowing into core for a value the + /// backend already knew was out of range. + fn column_count(&self) -> i16 { + 0 + } + + /// Returns metadata for column `col` (1-based). + /// + /// Called by `SQLDescribeColW`. + /// + /// Returns a [`Cow`] for the same reason + /// [`StatementBackend::get_data`] does: a backend holding its column + /// descriptors in memory, which most do since the result set's shape is + /// known once, can hand back a borrow instead of cloning a + /// `ColumnDescriptor` and its two `String`s on every call. `SQLColAttribute` + /// calls this once per column per attribute, so an application walking the + /// metadata of a wide result set would otherwise pay for a clone each + /// time. + /// + /// **Core range-checks `col` against [`StatementBackend::column_count`] + /// before calling**, so an out-of-range column never reaches here and an + /// error returned from this method is understood as a genuine failure. Its + /// SQLSTATE is what the application sees: `08S01` for a link failure, + /// `HY008` if the statement's cancel token reports signalled, `HY000` + /// otherwise. So route it through the driver's central error-mapping + /// function rather than hand-building an `OdbcError` here. Core reports + /// what this method returns and does not rewrite it into `07009` "column + /// number out of range". + fn describe_col( + &self, + _col: u16, + ) -> Result, Self::Error> { + Err(OdbcError::NotImplemented { + feature: "describe_col".into(), + } + .into()) + } + + /// Returns the number of rows affected by the last DML statement. + /// + /// Called by `SQLRowCount`. Returns `None` if not applicable (e.g. for SELECT + /// statements or when no statement has been executed). + /// + /// `i64` because `SQLRowCount` writes through a `SQLLEN *`, which is signed + /// and 64-bit on a 64-bit build. The signedness is load-bearing: the spec + /// defines `SQL_NO_TOTAL` (`-1`) for "the driver cannot determine the count", + /// which is a different answer from `None`'s "not applicable to this + /// statement", and `usize` could express neither. + fn row_count(&self) -> Option { + None + } + + /// Closes the open cursor, discarding any unread rows, and leaves the + /// statement handle valid and re-executable. + /// + /// Three callers, all of which mean "this cursor is closing now": + /// + /// - `SQLCloseCursor`, the most obvious place an application closes one. + /// - `SQLFreeStmt(SQL_CLOSE)`, which the spec makes equivalent to it bar + /// the `24000`. Both then discard the backend statement, so a failure + /// here is reported and the discard happens anyway, so the application is + /// not left holding a cursor it cannot clear. + /// - `SQLEndTran`, for a backend declaring + /// [`crate::types::CursorBehavior::Close`] from + /// [`Backend::cursor_commit_behavior`] / + /// [`Backend::cursor_rollback_behavior`]. + /// + /// **The statement is dropped after this returns** in the first two cases, + /// so an implementation must be safe to follow with `Drop`. `SQLEndTran` + /// is the exception and keeps it alive: footnote \[2\] of the transition + /// table leaves a prepared-but-unexecuted statement unchanged under + /// `SQL_CB_CLOSE`, which is why a backend declaring `Close` **must** + /// override this: there, this method is the only thing that closes the + /// cursor at all. + /// + /// Fallible because for a networked data source this is a round trip that + /// can fail: it is where a driver tells the server to drop a partially-read + /// result set. That is also why core calls it rather than relying on + /// `Drop`, which cannot report anything and may run without the runtime an + /// async-bridged driver needs. The SQLSTATE the application sees is + /// whatever the driver's central error mapping produced. + /// + /// The default is `Ok(())`, which is correct only for a backend whose + /// cursors need no teardown. See [`Backend::cursor_commit_behavior`]. + fn close_cursor(&mut self) -> Result<(), Self::Error> { + Ok(()) + } +} + +/// Default values for `InfoType` variants that are **identical** across all drivers. +/// +/// Backends should call this at the end of their `get_info` match, before the +/// `_ =>` arm, so they do not duplicate the arms it already answers. Returns +/// `None` for anything driver-specific. +/// +/// Generic over the calling backend so that the answers can be derived from its +/// own declarations; call it as `default_get_info::(info_type)`. +/// +/// The catalog column widths come from [`Backend::catalog_result_column_widths`] +/// rather than from a parameter. They are already a `Backend` declaration, and +/// taking them separately would let a caller hand over widths that disagree with +/// the ones the same backend reports everywhere else, which is exactly what +/// the `SQL_MAX_*_NAME_LEN` group is derived from. +pub fn default_get_info( + conn: Option<&B::Connection>, + info_type: crate::types::InfoType, +) -> Option { + use crate::types::{ + InfoType, InfoValue, SQL_AM_NONE, SQL_ASYNC_DBC_NOT_CAPABLE, + SQL_ASYNC_NOTIFICATION_NOT_CAPABLE, SQL_CA1_NEXT, SQL_CA2_READ_ONLY_CONCURRENCY, + SQL_DRIVER_ODBC_VER_STRING, SQL_GD_ANY_COLUMN, SQL_GD_ANY_ORDER, SQL_GD_BOUND, + SQL_MAX_CURSOR_NAME_LEN, SQL_OIC_CORE, SQL_PARC_NO_BATCH, SQL_PAS_NO_SELECT, + SQL_SO_FORWARD_ONLY, SQL_UNSPECIFIED, + }; + match info_type { + // --- String types identical in all drivers --- + InfoType::DriverOdbcVer => Some(InfoValue::String(SQL_DRIVER_ODBC_VER_STRING.into())), + InfoType::SearchPatternEscape => { + Some(InfoValue::String(B::search_pattern_escape(conn?).into())) + } + // Derived from the escape dialect, which already carries this fact and + // is what the escape translator itself consults. Hard-coding `"` here + // would let a backend quote identifiers one way and tell the + // application another. The spec's "if the data source does not + // support quoted identifiers, a blank is returned" is the + // empty-dialect case. + InfoType::IdentifierQuoteChar => Some(InfoValue::String( + B::escape_dialect(conn?) + .identifier_quotes + .first() + .map(|(open, _)| open.to_string()) + .unwrap_or_default(), + )), + // --- Catalog / schema group: all derived from the two backend hooks --- + // The spec defines each of these in terms of whether the data source + // has catalogs (resp. schemas) at all, and mandates the empty string + // or zero when it does not. See `Backend::supports_catalogs`. + InfoType::CatalogTerm => Some(InfoValue::String( + if B::supports_catalogs(conn?) { + "catalog" + } else { + "" + } + .into(), + )), + InfoType::SchemaTerm => Some(InfoValue::String( + if B::supports_schemas(conn?) { + "schema" + } else { + "" + } + .into(), + )), + InfoType::CatalogNameSeparator => Some(InfoValue::String( + if B::supports_catalogs(conn?) { "." } else { "" }.into(), + )), + InfoType::CatalogName => Some(InfoValue::String( + if B::supports_catalogs(conn?) { + "Y" + } else { + "N" + } + .into(), + )), + // Only the spec-mandated zero is asserted here. Once catalogs or + // schemas exist, the position of the catalog in a qualified name and + // the statements catalogs/schemas may appear in are genuinely + // per-data-source, so core falls through and lets the backend answer + // rather than overstating a capability it cannot know. + InfoType::CatalogLocation if !B::supports_catalogs(conn?) => Some(InfoValue::U16(0)), + InfoType::CatalogUsage if !B::supports_catalogs(conn?) => Some(InfoValue::U32(0)), + InfoType::SchemaUsage if !B::supports_schemas(conn?) => Some(InfoValue::U32(0)), + // Each of these was core asserting an entry-level SQL-92 answer while + // the conformance level itself is the backend's to declare, so a + // backend claiming no level was still told it had all of them. + InfoType::ColumnAlias => Some(InfoValue::String( + if B::column_alias(conn?) { "Y" } else { "N" }.into(), + )), + InfoType::Subqueries => Some(InfoValue::U32(B::subqueries(conn?))), + InfoType::ConcatNullBehavior => Some(InfoValue::U16(B::concat_null_behavior(conn?))), + InfoType::OrderByColumnsInSelect => Some(InfoValue::String( + if B::order_by_columns_in_select(conn?) { + "Y" + } else { + "N" + } + .into(), + )), + InfoType::UnionStatement => Some(InfoValue::U32(B::union_support(conn?))), + InfoType::DataSourceName => Some(InfoValue::String(String::new())), + InfoType::ServerName => Some(InfoValue::String(String::new())), + InfoType::UserName => Some(InfoValue::String(String::new())), + InfoType::DataSourceReadOnly => Some(InfoValue::String( + if B::data_source_read_only(conn?) { + "Y" + } else { + "N" + } + .into(), + )), + InfoType::AccessibleTables => Some(InfoValue::String( + if B::accessible_tables(conn?) { + "Y" + } else { + "N" + } + .into(), + )), + InfoType::AccessibleProcedures => Some(InfoValue::String( + if B::accessible_procedures(conn?) { + "Y" + } else { + "N" + } + .into(), + )), + InfoType::Integrity => Some(InfoValue::String( + if B::integrity(conn?) { "Y" } else { "N" }.into(), + )), + InfoType::SpecialCharacters => Some(InfoValue::String(B::special_characters(conn?).into())), + // Identity. `SQL_DRIVER_NAME` and `SQL_DRIVER_VER` describe the driver + // rather than the data source, so they answer without a connection, + // which is what the Windows Driver Manager asks for before one exists. + InfoType::DriverName => Some(InfoValue::String(B::driver_name().into())), + InfoType::DriverVer => Some(InfoValue::String(B::driver_version().into())), + InfoType::DbmsName => Some(InfoValue::String(B::dbms_name(conn?).into())), + InfoType::DbmsVer => Some(InfoValue::String(B::dbms_version(conn?).into())), + // Zero here is `SQL_TC_NONE`, "transactions not supported", which + // contradicts any backend declaring an isolation level. See + // `Backend::txn_capable`. + InfoType::TransactionCapable => Some(InfoValue::U16(B::txn_capable(conn?))), + InfoType::XopenCliYear => Some(InfoValue::String("1995".into())), + InfoType::CollationSeq => Some(InfoValue::String(String::new())), + InfoType::DescribeParameter => Some(InfoValue::String("Y".into())), + // Spec-declared "Y"/"N" strings, which need an arm each: the + // shape-aware fallback would give them `""`, the right shape but not + // a value in any of their value lists. + InfoType::MultResultSets => Some(InfoValue::String("N".into())), + InfoType::MaxRowSizeIncludesLong => Some(InfoValue::String("N".into())), + InfoType::NeedLongDataLen => Some(InfoValue::String("N".into())), + // A capability, not a shared default: `""` and a wrong "N" both read as + // "no" to a tool deciding whether to push an expression into ORDER BY. + InfoType::ExpressionsInOrderBy => Some(InfoValue::String( + if B::expressions_in_order_by(conn?) { + "Y" + } else { + "N" + } + .into(), + )), + // --- U16 types identical in all drivers --- + // Enum-valued info types where zero is a substantive answer + // (SQL_GB_NOT_SUPPORTED, SQL_NC_HIGH, SQL_CN_NONE, SQL_NNC_NULL), not + // "unknown", so the backend has to state them rather than inherit a + // claim it never made. See the `Backend` docs for each. + InfoType::GroupBy => Some(InfoValue::U16(B::group_by(conn?))), + InfoType::NullCollation => Some(InfoValue::U16(B::null_collation(conn?))), + InfoType::CorrelationName => Some(InfoValue::U16(B::correlation_name(conn?))), + InfoType::NonNullableColumns => Some(InfoValue::U16(B::non_nullable_columns(conn?))), + InfoType::MaxDriverConnections => Some(InfoValue::U16(0)), + InfoType::MaxConcurrentActivities => Some(InfoValue::U16(0)), + // Derived from the backend hook so the value reported here and the + // behaviour `sql_end_tran` applies cannot disagree. + InfoType::CursorCommitBehaviour => { + Some(InfoValue::U16(B::cursor_commit_behavior().as_u16())) + } + InfoType::MaxColumnNameLen => Some(InfoValue::U16( + B::catalog_result_column_widths().identifier_len, + )), + // Not `widths.identifier_len`, because a cursor name is an + // ODBC-level convention the application invents, not a data-source + // identifier the backend's catalog stores. See + // `SQL_MAX_CURSOR_NAME_LEN`'s doc comment for the full rationale. + InfoType::MaxCursorNameLen => Some(InfoValue::U16(SQL_MAX_CURSOR_NAME_LEN)), + InfoType::MaxSchemaNameLen => Some(InfoValue::U16( + B::catalog_result_column_widths().identifier_len, + )), + InfoType::MaxCatalogNameLen => Some(InfoValue::U16( + B::catalog_result_column_widths().identifier_len, + )), + InfoType::MaxTableNameLen => Some(InfoValue::U16( + B::catalog_result_column_widths().identifier_len, + )), + InfoType::MaxColumnsInGroupBy => Some(InfoValue::U16(0)), + InfoType::MaxColumnsInIndex => Some(InfoValue::U16(0)), + InfoType::MaxColumnsInOrderBy => Some(InfoValue::U16(0)), + InfoType::MaxColumnsInSelect => Some(InfoValue::U16(0)), + InfoType::MaxColumnsInTable => Some(InfoValue::U16(0)), + InfoType::MaxTablesInSelect => Some(InfoValue::U16(0)), + InfoType::MaxUserNameLen => Some(InfoValue::U16(0)), + InfoType::ActiveEnvironments => Some(InfoValue::U16(0)), + // SQL_CURSOR_SENSITIVITY is `An SQLUINTEGER value` per the SQLGetInfo + // spec, not SQLUSMALLINT, as the info-type conformance test finds + // (`stackable-odbc-core::conformance`), which enumerates every InfoType's + // declared shape rather than relying on a hand-picked subset. `U16` + // here would hand a numeric type expecting 4 bytes only 2, leaving + // the upper 2 bytes as whatever the caller's buffer already held. + // `SQL_UNSPECIFIED`, not `SQL_INSENSITIVE`. Insensitivity is a promise + // that no other cursor's changes become visible, and core's fetch + // streams rows from the backend as the application asks for them, so + // it makes no such promise about rows it has not read yet. The spec puts + // the two on either side of a conformance line ("a SQL-92 Entry + // level-conformant driver will always return the SQL_UNSPECIFIED + // option as supported. A SQL-92 Full level-conformant driver will + // always return the SQL_INSENSITIVE option"), but this describes core's + // cursor rather than the backend's SQL grammar, so it does not follow + // `Backend::sql_conformance`. `SQLGetStmtAttr(SQL_ATTR_CURSOR_ + // SENSITIVITY)` reports the same value. + InfoType::CursorSensitivity => Some(InfoValue::U32(u32::from(SQL_UNSPECIFIED))), + InfoType::MaxIdentifierLen => Some(InfoValue::U16( + B::catalog_result_column_widths().identifier_len, + )), + // --- U32 types identical in all drivers --- + InfoType::ScrollOptions => Some(InfoValue::U32(SQL_SO_FORWARD_ONLY)), + InfoType::ConvertFunctions => Some(InfoValue::U32(B::convert_functions(conn?))), + // Capability bitmaps, not limits: a `0` here is the claim "this data + // source cannot do this at all", so the backend has to state it. + InfoType::AlterTable => Some(InfoValue::U32(B::alter_table_support(conn?))), + InfoType::OuterJoinCapabilities => Some(InfoValue::U32(B::outer_join_capabilities(conn?))), + // Limits, where the spec defines 0 as "no specified limit or the limit + // is unknown", which is correct as a shared default, unlike the two + // above. + InfoType::MaxIndexSize => Some(InfoValue::U32(0)), + InfoType::MaxRowSize => Some(InfoValue::U32(0)), + InfoType::MaxStatementLen => Some(InfoValue::U32(0)), + // Derived from the backend hooks so that SQL_DEFAULT_TXN_ISOLATION and + // SQLGetConnectAttr(SQL_ATTR_TXN_ISOLATION) cannot report two + // different levels for the same connection. + InfoType::DefaultTxnIsolation => Some(InfoValue::U32(B::default_txn_isolation(conn?))), + InfoType::TransactionIsolationProtocol => { + Some(InfoValue::U32(B::txn_isolation_options(conn?))) + } + // Core cannot know the conformance level, and hard-coding it would + // contradict its own SQL_GROUP_BY / SQL_CORRELATION_NAME / + // SQL_NON_NULLABLE_COLUMNS answers. + InfoType::SqlConformance => Some(InfoValue::U32(B::sql_conformance(conn?))), + // The units TIMESTAMPADD / TIMESTAMPDIFF accept. Defaulting these to 0 + // while the backend claims SQL_FN_TD_TIMESTAMPADD in + // SQL_TIMEDATE_FUNCTIONS is self-contradictory. + InfoType::TimedateAddIntervals => Some(InfoValue::U32(B::timedate_add_intervals(conn?))), + InfoType::TimedateDiffIntervals => Some(InfoValue::U32(B::timedate_diff_intervals(conn?))), + InfoType::IdentifierCase => Some(InfoValue::U16(B::identifier_case(conn?))), + InfoType::OdbcInterfaceConformance => Some(InfoValue::U32(SQL_OIC_CORE)), + InfoType::AsyncMode => Some(InfoValue::U32(SQL_AM_NONE)), + InfoType::AsyncDbcFunctions => Some(InfoValue::U32(SQL_ASYNC_DBC_NOT_CAPABLE)), + + // --- Facts about core's own implementation --- + // + // `SQLGetData` here can read any column, in any order, bound or not: + // `sql_get_data` checks neither column order nor binding state. + // `SQL_GD_BLOCK` is absent on purpose: it describes reading a row + // from a block cursor, and `sql_set_stmt_attr_w` substitutes 1 back for + // any `SQL_ATTR_ROW_ARRAY_SIZE`, so no multi-row rowset can exist. + InfoType::GetDataExtensions => Some(InfoValue::U32( + SQL_GD_ANY_COLUMN | SQL_GD_ANY_ORDER | SQL_GD_BOUND, + )), + // `escape.rs` implements the `{escape}` sequence, and its translation + // is what an application is asking about here. + InfoType::LikeEscapeClause => Some(InfoValue::String("Y".into())), + // Core executes one parameter set and one statement per execute: + // `sql_set_stmt_attr_w` refuses any `SQL_ATTR_PARAMSET_SIZE` other + // than 1, so no batch or parameter-array rowset can be produced. + InfoType::BatchSupport => Some(InfoValue::U32(0)), + InfoType::BatchRowCount => Some(InfoValue::U32(0)), + InfoType::ParamArrayRowCounts => Some(InfoValue::U32(SQL_PARC_NO_BATCH)), + InfoType::ParamArraySelects => Some(InfoValue::U32(SQL_PAS_NO_SELECT)), + // The `Backend` trait is synchronous, so there is no asynchronous + // execution to describe and nothing to notify about. + InfoType::MaxAsyncConcurrentStatements => Some(InfoValue::U32(0)), + InfoType::AsyncNotification => Some(InfoValue::U32(SQL_ASYNC_NOTIFICATION_NOT_CAPABLE)), + // Core owns no connection pool. + InfoType::DriverAwarePoolingSupported => Some(InfoValue::U32(0)), + // Whether the data source supports outer joins at all is already stated + // by `outer_join_capabilities`; deriving it here keeps the two from + // contradicting each other. + InfoType::OuterJoins => Some(InfoValue::String( + if B::outer_join_capabilities(conn?) != 0 { + "Y" + } else { + "N" + } + .into(), + )), + // --- Cursor attributes (zero except the forward-only pair, which is + // the only cursor core has) --- + InfoType::DynamicCursorAttributes1 => Some(InfoValue::U32(0)), + InfoType::DynamicCursorAttributes2 => Some(InfoValue::U32(0)), + InfoType::ForwardOnlyCursorAttributes1 => Some(InfoValue::U32(SQL_CA1_NEXT)), + // `SQL_CA2_READ_ONLY_CONCURRENCY` asserts exactly what + // `SQLSetStmtAttr` does with `SQL_ATTR_CONCURRENCY`: the attribute "can + // be SQL_CONCUR_READ_ONLY" for this cursor. `SQL_CONCUR_READ_ONLY` is + // the one value that arm accepts unchanged, since every other value is + // substituted back to it with `01S02`, so reporting `0` here would + // claim core supports no concurrency at all for the only cursor it has, + // and contradict the attribute it just accepted. The remaining bits in + // this bitmask stay clear: they describe updatable cursors, row-count + // exactness and positioned-statement simulation, none of which core + // does. + InfoType::ForwardOnlyCursorAttributes2 => { + Some(InfoValue::U32(SQL_CA2_READ_ONLY_CONCURRENCY)) + } + InfoType::KeysetCursorAttributes1 => Some(InfoValue::U32(0)), + InfoType::KeysetCursorAttributes2 => Some(InfoValue::U32(0)), + InfoType::StaticCursorAttributes1 => Some(InfoValue::U32(0)), + InfoType::StaticCursorAttributes2 => Some(InfoValue::U32(0)), + _ => None, + } +} + +/// The `SQL_KEYWORDS` (89) value for `B`: [`Backend::keywords`] minus +/// everything ODBC itself reserves, sorted, comma-separated with no spaces. +/// +/// The subtraction is what the spec defines the info type to be ("This list +/// does not contain keywords specific to ODBC or keywords used by both the +/// data source and ODBC"), so it lives here once rather than in every backend. +/// Comparison is ASCII-case-insensitive: the reserved list is upper-case and a +/// data source that spells its keywords in lower case still shares them. +/// +/// Sorting is not required by the spec, but makes the value stable for +/// anything that diffs or caches it, whatever order the backend enumerates in. +/// +/// Recomputed on every call. A `static` cache cannot be generic over `B`, and +/// this is a linear scan of a short list against a fixed one on a path +/// `SQLGetInfo` reaches at most a handful of times per connection; a backend +/// with an expensive list caches on its own side (see [`Backend::keywords`]). +fn data_source_specific_keywords(conn: &B::Connection) -> String { + let keywords = B::keywords(conn); + let mut names: Vec<&str> = keywords + .iter() + .map(Cow::as_ref) + .filter(|name| { + !crate::types::ODBC_RESERVED_KEYWORDS + .iter() + .any(|reserved| reserved.eq_ignore_ascii_case(name)) + }) + .collect(); + names.sort_unstable(); + names.join(",") +} + +/// Returns a value for the few info types that must be dispatched through +/// [`Backend::get_info_raw`] (rather than the typed `InfoType` path; see +/// that method's doc for why) but are **identical** across all drivers. +/// +/// Only `SQL_CURSOR_ROLLBACK_BEHAVIOR` is genuinely absent from +/// `odbc_sys::InfoType`; `SQL_FILE_USAGE` and `SQL_QUOTED_IDENTIFIER_CASE` are +/// real `InfoType` variants (`SqlFileUsage`, `SqlQuotedIdentifierCase`) that +/// simply have no arm in [`default_get_info`], so they still need a raw-`u16` +/// answer here. +/// +/// Backends should call this from `get_info_raw` before checking driver-specific values. +/// Returns `None` if the info type is not handled here. +/// +/// Generic over the calling backend so that `SQL_CURSOR_ROLLBACK_BEHAVIOR` can be +/// derived from [`Backend::cursor_rollback_behavior`]; call it as +/// `common_get_info_raw::(info_type)`. +pub fn common_get_info_raw( + conn: Option<&B::Connection>, + info_type: u16, +) -> Option { + use crate::types::{ + InfoValue, SQL_CURSOR_ROLLBACK_BEHAVIOR, SQL_FILE_USAGE, SQL_KEYWORDS, + SQL_MULTIPLE_ACTIVE_TXN, SQL_PROCEDURE_TERM, SQL_PROCEDURES, SQL_QUOTED_IDENTIFIER_CASE, + SQL_ROW_UPDATES, SQL_TABLE_TERM, + }; + match info_type { + SQL_FILE_USAGE => Some(InfoValue::U16(0)), + // See the matching arm in `default_get_info`. + SQL_CURSOR_ROLLBACK_BEHAVIOR => { + Some(InfoValue::U16(B::cursor_rollback_behavior().as_u16())) + } + // The quoted counterpart of `SQL_IDENTIFIER_CASE`, and like it a + // four-valued statement about the system catalog with no legal zero. + SQL_QUOTED_IDENTIFIER_CASE => Some(InfoValue::U16(B::quoted_identifier_case(conn?))), + // Both are spec-defined "Y"/"N" character strings with no + // `odbc_sys::InfoType` variant, so this raw path is the only place + // they can be answered. Without these arms they reach the + // unnamed-raw default `U32(0)` and an application reading them into a + // character buffer gets four bytes of binary zero. + // + // "N" for both: core drives a forward-only cursor with no positioned + // updates, and exports no procedure-invocation support of its own. A + // backend that has either answers it before delegating here. + SQL_ROW_UPDATES => Some(InfoValue::String("N".into())), + SQL_PROCEDURES => Some(InfoValue::String("N".into())), + // Whether two transactions can be live at once is a property of the + // driver's connections, which only the backend knows. + SQL_MULTIPLE_ACTIVE_TXN => Some(InfoValue::String( + if B::multiple_active_txn(conn?) { + "Y" + } else { + "N" + } + .into(), + )), + // The data source's own reserved words, minus everything ODBC already + // reserves. That subtraction is what the spec defines for this info + // type, applied once here rather than in each backend. The list is a + // capability, so it comes from `Backend::keywords`; core only owns the + // rule. + SQL_KEYWORDS => Some(InfoValue::String(data_source_specific_keywords::(conn?))), + // `SQL_DATABASE_NAME` is deliberately absent: the spec equates it with + // `SQLGetConnectAttr(SQL_ATTR_CURRENT_CATALOG)`, which lives on the + // connection *handle* rather than on `B::Connection`, so `sql_get_info_w` + // answers it from there. Answering `""` here as well would give the + // same info type two sources and let them disagree. + // + // Empty is a valid value for `SQL_PROCEDURE_TERM`: given + // `SQL_PROCEDURES` above answers "N", there are no procedures to have a + // vendor term for. Every data source has tables, so `SQL_TABLE_TERM` + // gets the generic term rather than "". + SQL_PROCEDURE_TERM => Some(InfoValue::String(String::new())), + SQL_TABLE_TERM => Some(InfoValue::String("table".into())), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::{ + MockBackend, MockConnection, MockNoCatalogBackend, MockTxnConnection, + MockTxnDeleteCloseBackend, + }; + use crate::types::{ + DEFAULT_IDENTIFIER_LEN, InfoType, InfoValue, SQL_AM_NONE, SQL_ASYNC_DBC_NOT_CAPABLE, + SQL_AT_ADD_COLUMN_SINGLE, SQL_AT_DROP_COLUMN_RESTRICT, SQL_CA1_NEXT, + SQL_CA2_READ_ONLY_CONCURRENCY, SQL_CB_PRESERVE, SQL_CN_ANY, SQL_DRIVER_ODBC_VER_STRING, + SQL_FN_CVT_CAST, SQL_FN_TSI_DAY, SQL_FN_TSI_SECOND, SQL_FN_TSI_YEAR, + SQL_GB_GROUP_BY_EQUALS_SELECT, SQL_MAX_CURSOR_NAME_LEN, SQL_NC_END, SQL_NNC_NON_NULL, + SQL_OIC_CORE, SQL_OJ_LEFT, SQL_OJ_NESTED, SQL_SC_SQL92_ENTRY, SQL_SO_FORWARD_ONLY, + SQL_SQ_COMPARISON, SQL_SQ_CORRELATED_SUBQUERIES, SQL_SQ_EXISTS, SQL_SQ_IN, + SQL_SQ_QUANTIFIED, SQL_TC_ALL, SQL_TXN_SERIALIZABLE, SQL_U_UNION, SQL_U_UNION_ALL, + SQL_UNSPECIFIED, + }; + + /// The default hook is the identity function, so a driver that has not + /// overridden it behaves exactly as core did before the hook existed. + /// + /// This is what makes the change non-breaking: every existing driver keeps + /// both its compilation and its behaviour. + #[test] + fn the_default_configure_dsn_returns_the_attributes_unchanged() { + use crate::setup::ConfigRequest; + use std::collections::HashMap; + + let mut attrs = HashMap::new(); + attrs.insert("DSN".to_string(), "MyDSN".to_string()); + attrs.insert("Host".to_string(), "example.com".to_string()); + + for request in [ + ConfigRequest::Add, + ConfigRequest::Config, + ConfigRequest::Remove, + ] { + let out = MockBackend::configure_dsn(std::ptr::null_mut(), request, attrs.clone()) + .expect("the default hook never fails"); + assert_eq!( + out, + Some(attrs.clone()), + "the default hook must pass {request:?}'s attributes through untouched" + ); + } + } + + /// A non-null `hwndParent` must be warned about, and the warning belongs to + /// the *default* implementation rather than to core's call site: core + /// forwards the handle, so core is not the thing ignoring it. A driver that + /// overrides the hook must emit nothing. + /// + /// Source-audited because the crate has no log-capture harness for unit + /// tests; the precedent is `a_non_null_parent_window_is_warned_about` in + /// `ffi/setup.rs`. + #[test] + fn the_default_configure_dsn_warns_only_about_a_non_null_parent_window() { + let source = include_str!("backend.rs"); + let start = source + .find("fn configure_dsn(") + .expect("Backend declares configure_dsn"); + let body = &source[start..]; + let end = body + .find("\n }") + .expect("configure_dsn's default body has a closing brace"); + let body = &body[..end]; + + assert!( + body.contains("tracing::warn!"), + "the default configure_dsn must warn that it ships no setup dialog" + ); + assert!( + body.contains("if !hwnd_parent.is_null()"), + "a null hwndParent is fully conforming ('The function will not \ + display any dialog boxes if the handle is null') and must not be \ + warned about" + ); + } + + enum Expected { + Str(&'static str), + U16(u16), + U32(u32), + } + + #[rustfmt::skip] + const EXPECTED: &[(InfoType, Expected)] = &[ + // --- String values --- + (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::DataSourceName, Expected::Str("")), + (InfoType::ServerName, Expected::Str("")), + (InfoType::UserName, Expected::Str("")), + (InfoType::DataSourceReadOnly, Expected::Str("N")), + // "Y" guarantees the user has SELECT on every table SQLTables returns. + // Core cannot make that promise for a backend, and it depends on the + // connected principal, so the mock declares the honest "N". + (InfoType::AccessibleTables, Expected::Str("N")), + // Backend-stated capabilities; MockBackend declares each of these. + (InfoType::AccessibleProcedures, Expected::Str("Y")), + (InfoType::Integrity, Expected::Str("Y")), + (InfoType::SpecialCharacters, Expected::Str("$#")), + // Identity. The two driver-level ones answer without a connection, + // which is what the Windows Driver Manager asks for before one exists. + (InfoType::DriverName, Expected::Str("Mock ODBC Driver")), + (InfoType::DriverVer, Expected::Str("01.00.0000")), + (InfoType::DbmsName, Expected::Str("MockDB")), + (InfoType::DbmsVer, Expected::Str("01.02.0003")), + (InfoType::XopenCliYear, Expected::Str("1995")), + (InfoType::CollationSeq, Expected::Str("")), + (InfoType::DescribeParameter, Expected::Str("Y")), + // Y/N strings, which need arms of their own: the shape default's "" is + // not in any of their value lists. + (InfoType::MultResultSets, Expected::Str("N")), + (InfoType::MaxRowSizeIncludesLong, Expected::Str("N")), + (InfoType::NeedLongDataLen, Expected::Str("N")), + // Backend-stated capability; MockBackend declares true. + (InfoType::ExpressionsInOrderBy, Expected::Str("Y")), + // --- U16 values --- + // Enum values where 0 is a real answer, so they come from the backend. + // Non-`SQL_TC_NONE`, as MockBackend declares an isolation level. + (InfoType::TransactionCapable, Expected::U16(SQL_TC_ALL as u16)), + (InfoType::GroupBy, Expected::U16(SQL_GB_GROUP_BY_EQUALS_SELECT)), + (InfoType::NullCollation, Expected::U16(SQL_NC_END)), + (InfoType::CorrelationName, Expected::U16(SQL_CN_ANY)), + (InfoType::NonNullableColumns, Expected::U16(SQL_NNC_NON_NULL)), + (InfoType::MaxDriverConnections, Expected::U16(0)), + (InfoType::MaxConcurrentActivities, Expected::U16(0)), + (InfoType::ConcatNullBehavior, Expected::U16(0)), + (InfoType::CursorCommitBehaviour, Expected::U16(SQL_CB_PRESERVE)), + (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::MaxColumnsInGroupBy, Expected::U16(0)), + (InfoType::MaxColumnsInIndex, Expected::U16(0)), + (InfoType::MaxColumnsInOrderBy, Expected::U16(0)), + (InfoType::MaxColumnsInSelect, Expected::U16(0)), + (InfoType::MaxColumnsInTable, Expected::U16(0)), + (InfoType::MaxTablesInSelect, Expected::U16(0)), + (InfoType::MaxUserNameLen, Expected::U16(0)), + (InfoType::ActiveEnvironments, Expected::U16(0)), + (InfoType::MaxIdentifierLen, Expected::U16(DEFAULT_IDENTIFIER_LEN)), + // --- U32 values --- + // CursorSensitivity is SQLUINTEGER per spec, not SQLUSMALLINT; see + // the matching comment on its arm in `default_get_info`. + (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)), + (InfoType::ScrollOptions, Expected::U32(SQL_SO_FORWARD_ONLY)), + (InfoType::ConvertFunctions, Expected::U32(SQL_FN_CVT_CAST)), + // Capability bitmaps: MockBackend's declared values, not a shared 0. + (InfoType::AlterTable, Expected::U32(SQL_AT_ADD_COLUMN_SINGLE | SQL_AT_DROP_COLUMN_RESTRICT)), + (InfoType::OuterJoinCapabilities, Expected::U32(SQL_OJ_LEFT | SQL_OJ_NESTED)), + // Limits, where the spec defines 0 as "no limit or unknown". + (InfoType::MaxIndexSize, Expected::U32(0)), + (InfoType::MaxRowSize, Expected::U32(0)), + (InfoType::MaxStatementLen, Expected::U32(0)), + (InfoType::DefaultTxnIsolation, Expected::U32(SQL_TXN_SERIALIZABLE)), + (InfoType::TransactionIsolationProtocol, Expected::U32(SQL_TXN_SERIALIZABLE)), + (InfoType::SqlConformance, Expected::U32(SQL_SC_SQL92_ENTRY)), + (InfoType::TimedateAddIntervals, Expected::U32(SQL_FN_TSI_SECOND | SQL_FN_TSI_DAY)), + (InfoType::TimedateDiffIntervals, Expected::U32(SQL_FN_TSI_SECOND | SQL_FN_TSI_YEAR)), + (InfoType::OdbcInterfaceConformance, Expected::U32(SQL_OIC_CORE)), + (InfoType::AsyncMode, Expected::U32(SQL_AM_NONE)), + (InfoType::AsyncDbcFunctions, Expected::U32(SQL_ASYNC_DBC_NOT_CAPABLE)), + (InfoType::DynamicCursorAttributes1, Expected::U32(0)), + (InfoType::DynamicCursorAttributes2, Expected::U32(0)), + (InfoType::ForwardOnlyCursorAttributes1, Expected::U32(SQL_CA1_NEXT)), + (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)), + ]; + + #[test] + fn default_get_info_snapshot() { + for (info_type, expected) in EXPECTED { + let actual = default_get_info::(Some(&MockConnection), *info_type) + .unwrap_or_else(|| panic!("default_get_info returned None for {info_type:?}")); + 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:?}"), + } + } + } + + #[test] + fn cursor_behavior_hooks_default_to_preserve() { + use crate::test_utils::MockBackend; + use crate::types::CursorBehavior; + + assert_eq!( + MockBackend::cursor_commit_behavior(), + CursorBehavior::Preserve + ); + assert_eq!( + MockBackend::cursor_rollback_behavior(), + CursorBehavior::Preserve + ); + } + + #[test] + fn advertised_cursor_behavior_tracks_the_backend_hooks() { + use crate::types::{SQL_CB_CLOSE, SQL_CB_DELETE, SQL_CURSOR_ROLLBACK_BEHAVIOR}; + + assert_eq!( + default_get_info::( + Some(&MockTxnConnection { + end_tran_fails: false + }), + InfoType::CursorCommitBehaviour + ), + Some(InfoValue::U16(SQL_CB_DELETE)), + "SQL_CURSOR_COMMIT_BEHAVIOR ignored Backend::cursor_commit_behavior" + ); + assert_eq!( + common_get_info_raw::( + Some(&MockTxnConnection { + end_tran_fails: false + }), + SQL_CURSOR_ROLLBACK_BEHAVIOR + ), + Some(InfoValue::U16(SQL_CB_CLOSE)), + "SQL_CURSOR_ROLLBACK_BEHAVIOR ignored Backend::cursor_rollback_behavior" + ); + } + + #[test] + fn advertised_cursor_behavior_defaults_to_preserve() { + use crate::test_utils::MockBackend; + use crate::types::{SQL_CB_PRESERVE, SQL_CURSOR_ROLLBACK_BEHAVIOR}; + + assert_eq!( + default_get_info::(Some(&MockConnection), InfoType::CursorCommitBehaviour), + Some(InfoValue::U16(SQL_CB_PRESERVE)) + ); + assert_eq!( + common_get_info_raw::(Some(&MockConnection), SQL_CURSOR_ROLLBACK_BEHAVIOR), + Some(InfoValue::U16(SQL_CB_PRESERVE)) + ); + } + + /// `SQL_CATALOG_TERM`, `SQL_CATALOG_NAME_SEPARATOR`, `SQL_CATALOG_NAME`, + /// `SQL_CATALOG_LOCATION` and `SQL_CATALOG_USAGE` are all defined by the + /// `SQLGetInfo` spec in terms of one fact, whether the data source has + /// catalogs at all, so a backend that says it has none must not be handed + /// a name for them. Answering "catalog" and "." unconditionally would let a + /// driver report `SQL_CATALOG_NAME = "N"` and name its catalogs in the same + /// breath. + #[test] + fn catalog_less_backend_reports_the_spec_mandated_empty_catalog_group() { + use crate::test_utils::MockNoCatalogBackend; + assert_eq!( + default_get_info::(Some(&MockConnection), InfoType::CatalogTerm), + Some(InfoValue::String(String::new())), + "SQL_CATALOG_TERM must be empty when the data source has no catalogs" + ); + assert_eq!( + default_get_info::( + Some(&MockConnection), + InfoType::CatalogNameSeparator + ), + Some(InfoValue::String(String::new())), + "SQL_CATALOG_NAME_SEPARATOR must be empty when the data source has no catalogs" + ); + assert_eq!( + default_get_info::(Some(&MockConnection), InfoType::CatalogName), + Some(InfoValue::String("N".into())), + "SQL_CATALOG_NAME must be \"N\" when the data source has no catalogs" + ); + assert_eq!( + default_get_info::( + Some(&MockConnection), + InfoType::CatalogLocation + ), + Some(InfoValue::U16(0)), + "SQL_CATALOG_LOCATION must be 0 when the data source has no catalogs" + ); + assert_eq!( + default_get_info::(Some(&MockConnection), InfoType::CatalogUsage), + Some(InfoValue::U32(0)), + "SQL_CATALOG_USAGE must be 0 when the data source has no catalogs" + ); + } + + /// The schema half of the same rule: `SQL_SCHEMA_TERM` and + /// `SQL_SCHEMA_USAGE` are defined in terms of whether schemas exist. + #[test] + fn schema_less_backend_reports_the_spec_mandated_empty_schema_group() { + use crate::test_utils::MockNoCatalogBackend; + assert_eq!( + default_get_info::(Some(&MockConnection), InfoType::SchemaTerm), + Some(InfoValue::String(String::new())), + "SQL_SCHEMA_TERM must be empty when the data source has no schemas" + ); + assert_eq!( + default_get_info::(Some(&MockConnection), InfoType::SchemaUsage), + Some(InfoValue::U32(0)), + "SQL_SCHEMA_USAGE must be 0 when the data source has no schemas" + ); + } + + /// A backend that *does* have catalogs and schemas gets the SQL-92 Full + /// level terms the spec names. The sibling test above pins the empty group + /// for a backend without them; this one is its other half, so that + /// suppressing the group for one backend cannot blank it for both. + #[test] + fn catalog_supporting_backend_keeps_the_sql92_full_terms() { + for (info_type, expected) in [ + (InfoType::CatalogTerm, "catalog"), + (InfoType::SchemaTerm, "schema"), + (InfoType::CatalogNameSeparator, "."), + (InfoType::CatalogName, "Y"), + ] { + assert_eq!( + default_get_info::(Some(&MockConnection), info_type), + Some(InfoValue::String(expected.into())), + "{info_type:?} changed for a catalog-supporting backend" + ); + } + } + + /// Where the spec only mandates the *zero*, core must not invent the + /// non-zero. `SQL_CATALOG_LOCATION` (start vs end), `SQL_CATALOG_USAGE` + /// and `SQL_SCHEMA_USAGE` are genuinely per-data-source once catalogs or + /// schemas exist, so core returns `None` and leaves them to the backend + /// rather than overstating a capability it cannot know. + #[test] + fn catalog_supporting_backend_leaves_location_and_usage_to_the_backend() { + for info_type in [ + InfoType::CatalogLocation, + InfoType::CatalogUsage, + InfoType::SchemaUsage, + ] { + assert_eq!( + default_get_info::(Some(&MockConnection), info_type), + None, + "{info_type:?} must be left to the backend when catalogs/schemas exist" + ); + } + } + + /// `SQL_ALTER_TABLE` and `SQL_OJ_CAPABILITIES` are capability bitmaps, + /// where a defaulted `0` claims "this data source cannot do this at all". + /// Both come from required `Backend` methods, so a backend author states + /// the fact rather than inheriting a silent understatement. + #[test] + fn alter_table_and_outer_join_capabilities_come_from_the_backend() { + assert_eq!( + default_get_info::(Some(&MockConnection), InfoType::AlterTable), + Some(InfoValue::U32(MockBackend::alter_table_support( + &MockConnection + ))), + "SQL_ALTER_TABLE ignored Backend::alter_table_support" + ); + assert_eq!( + default_get_info::(Some(&MockConnection), InfoType::OuterJoinCapabilities), + Some(InfoValue::U32(MockBackend::outer_join_capabilities( + &MockConnection + ))), + "SQL_OJ_CAPABILITIES ignored Backend::outer_join_capabilities" + ); + // A non-zero declaration is what proves the value is read rather than + // hard-coded: the previous implementation returned 0 for both. + assert_ne!(MockBackend::alter_table_support(&MockConnection), 0); + assert_ne!(MockBackend::outer_join_capabilities(&MockConnection), 0); + } + + /// `SQL_ROW_UPDATES` (11) and `SQL_PROCEDURES` (21) are spec-defined + /// `"Y"`/`"N"` character strings with no `odbc_sys::InfoType` variant, so + /// they can only be answered through the raw-`u16` path. Without an arm + /// there they fall to the unnamed-raw default `U32(0)`, and an application + /// passing a character buffer gets four bytes of binary zero with + /// `StringLength = 4`. + #[test] + fn row_updates_and_procedures_are_yn_strings_not_u32() { + use crate::types::{SQL_PROCEDURES, SQL_ROW_UPDATES}; + + for info_type in [SQL_ROW_UPDATES, SQL_PROCEDURES] { + let value = common_get_info_raw::(Some(&MockConnection), info_type) + .unwrap_or_else(|| panic!("common_get_info_raw returned None for {info_type}")); + assert!( + matches!(&value, InfoValue::String(s) if s == "N"), + "info type {info_type} must be a Y/N string, got {value:?}" + ); + } + } + + /// An info type the spec declares as a `"Y"`/`"N"` string needs an arm of + /// its own: the shape-aware fallback gives it `""`, which is the right + /// *shape* but not a value in its value list. + /// `SQL_EXPRESSIONS_IN_ORDERBY` is not here: it is a capability a backend + /// states (see [`Backend::expressions_in_order_by`]), not a shared default. + #[test] + fn yn_info_types_default_to_a_value_in_their_value_list() { + for info_type in [ + InfoType::MultResultSets, + InfoType::MaxRowSizeIncludesLong, + InfoType::NeedLongDataLen, + ] { + assert_eq!( + default_get_info::(Some(&MockConnection), info_type), + Some(InfoValue::String("N".into())), + "{info_type:?} must be \"Y\" or \"N\", never the empty string" + ); + } + } + + /// The C5 failure mode in its purest form: for these four info types zero + /// is a *substantive answer*, not "unknown", so the shape default handed + /// out a real spec claim (`SQL_NC_HIGH`, `SQL_CN_NONE`, `SQL_NNC_NULL`, + /// `SQL_GB_NOT_SUPPORTED`) that no backend ever made. They are now stated + /// by the backend. + #[test] + fn enum_valued_info_types_come_from_the_backend() { + for (info_type, actual) in [ + ( + InfoType::NullCollation, + MockBackend::null_collation(&MockConnection), + ), + ( + InfoType::CorrelationName, + MockBackend::correlation_name(&MockConnection), + ), + ( + InfoType::NonNullableColumns, + MockBackend::non_nullable_columns(&MockConnection), + ), + (InfoType::GroupBy, MockBackend::group_by(&MockConnection)), + ] { + assert_eq!( + default_get_info::(Some(&MockConnection), info_type), + Some(InfoValue::U16(actual)), + "{info_type:?} ignored its Backend hook" + ); + } + } + + /// Core hard-coded `SQL_SQL_CONFORMANCE = SQL_SC_SQL92_ENTRY` while + /// separately supplying `SQL_GROUP_BY`, `SQL_CORRELATION_NAME` and + /// `SQL_NON_NULLABLE_COLUMNS` values the spec says an entry-level driver + /// never returns. Every backend inherited that contradiction; the + /// conformance claim is now the backend's too. + /// + /// Asserted across two backends declaring *different* levels, because a + /// single backend cannot distinguish "core read the hook" from "core still + /// hard-codes the value this backend happens to declare". + #[test] + fn sql_conformance_comes_from_the_backend() { + assert_eq!( + default_get_info::(Some(&MockConnection), InfoType::SqlConformance), + Some(InfoValue::U32(SQL_SC_SQL92_ENTRY)), + "SQL_SQL_CONFORMANCE ignored Backend::sql_conformance" + ); + assert_eq!( + default_get_info::( + Some(&MockConnection), + InfoType::SqlConformance + ), + Some(InfoValue::U32(0)), + "SQL_SQL_CONFORMANCE is still pinned to SQL_SC_SQL92_ENTRY" + ); + assert_ne!( + MockBackend::sql_conformance(&MockConnection), + MockNoCatalogBackend::sql_conformance(&MockConnection), + "the two mocks must declare different levels or this test proves nothing" + ); + } + + /// The contradiction item 4 is about: core claimed `SQL_SC_SQL92_ENTRY` + /// while separately supplying `SQL_GROUP_BY`, `SQL_CORRELATION_NAME` and + /// `SQL_NON_NULLABLE_COLUMNS` values the spec says an entry-level driver + /// never returns. Now that all four come from the same backend, a backend + /// declaring entry level reports the three values the spec names for it. + #[test] + fn entry_level_conformance_no_longer_contradicts_the_other_info_types() { + use crate::types::{SQL_CN_ANY, SQL_GB_GROUP_BY_EQUALS_SELECT, SQL_NNC_NON_NULL}; + assert_eq!( + default_get_info::(Some(&MockConnection), InfoType::SqlConformance), + Some(InfoValue::U32(SQL_SC_SQL92_ENTRY)), + ); + for (info_type, expected, spec) in [ + ( + InfoType::CorrelationName, + SQL_CN_ANY, + "will always return SQL_CN_ANY", + ), + ( + InfoType::NonNullableColumns, + SQL_NNC_NON_NULL, + "will return SQL_NNC_NON_NULL", + ), + ( + InfoType::GroupBy, + SQL_GB_GROUP_BY_EQUALS_SELECT, + "will always return the SQL_GB_GROUP_BY_EQUALS_SELECT option", + ), + ] { + assert_eq!( + default_get_info::(Some(&MockConnection), info_type), + Some(InfoValue::U16(expected)), + "an entry-level-conformant driver {spec} for {info_type:?}" + ); + } + } + + /// `SQL_EXPRESSIONS_IN_ORDERBY` is a capability, and `""` reads as "no" to + /// a tool deciding whether to push an expression into `ORDER BY`. + #[test] + fn expressions_in_order_by_comes_from_the_backend() { + assert_eq!( + default_get_info::(Some(&MockConnection), InfoType::ExpressionsInOrderBy), + Some(InfoValue::String("Y".into())), + "SQL_EXPRESSIONS_IN_ORDERBY ignored Backend::expressions_in_order_by" + ); + assert_eq!( + default_get_info::( + Some(&MockConnection), + InfoType::ExpressionsInOrderBy + ), + Some(InfoValue::String("N".into())), + "a backend declaring no ORDER BY expressions must report \"N\", not \"\"" + ); + } + + /// The interval bitmaps are the units `TIMESTAMPADD` / `TIMESTAMPDIFF` + /// accept. Defaulting them to 0 while a backend freely claims + /// `SQL_FN_TD_TIMESTAMPADD` in `SQL_TIMEDATE_FUNCTIONS` is + /// self-contradictory, so the backend states both. They are separate hooks + /// because a data source can legitimately support different units for each. + #[test] + fn timedate_interval_bitmaps_come_from_the_backend() { + assert_eq!( + default_get_info::(Some(&MockConnection), InfoType::TimedateAddIntervals), + Some(InfoValue::U32(MockBackend::timedate_add_intervals( + &MockConnection + ))), + "SQL_TIMEDATE_ADD_INTERVALS ignored Backend::timedate_add_intervals" + ); + assert_eq!( + default_get_info::(Some(&MockConnection), InfoType::TimedateDiffIntervals), + Some(InfoValue::U32(MockBackend::timedate_diff_intervals( + &MockConnection + ))), + "SQL_TIMEDATE_DIFF_INTERVALS ignored Backend::timedate_diff_intervals" + ); + assert_ne!( + MockBackend::timedate_add_intervals(&MockConnection), + MockBackend::timedate_diff_intervals(&MockConnection), + "the mock must declare different units for each, or one hook could \ + serve both and the test would not notice" + ); + } + + /// `SQL_DEFAULT_TXN_ISOLATION` and `SQL_TXN_ISOLATION_OPTION` are + /// derived from the same two hooks that + /// `SQLGetConnectAttr(SQL_ATTR_TXN_ISOLATION)` reads, so the two cannot + /// disagree on one connection. + #[test] + fn txn_isolation_info_types_come_from_the_backend() { + use crate::types::SQL_TXN_SERIALIZABLE; + assert_eq!( + default_get_info::(Some(&MockConnection), InfoType::DefaultTxnIsolation), + Some(InfoValue::U32(SQL_TXN_SERIALIZABLE)), + "SQL_DEFAULT_TXN_ISOLATION ignored Backend::default_txn_isolation" + ); + assert_eq!( + default_get_info::( + Some(&MockConnection), + InfoType::TransactionIsolationProtocol + ), + Some(InfoValue::U32(SQL_TXN_SERIALIZABLE)), + "SQL_TXN_ISOLATION_OPTION ignored Backend::txn_isolation_options" + ); + } + + /// Info types `default_get_info` answers identically for **every** backend, + /// each with the reason core is entitled to decide it. + /// + /// The entries fall into three kinds, and nothing else belongs here: + /// + /// - **Facts about core's own implementation.** Core's fetch really is + /// forward-only and the `Backend` trait really is synchronous, so these + /// are not claims about the data source at all. A hook would be worse: + /// it would let a backend contradict what core actually does. + /// - **Limits where the spec defines `0` as "no limit or unknown".** + /// Asserting nothing. + /// - **Driver-level identity** that has no per-backend answer. + /// + /// Anything else belongs on a `Backend` method instead, meaning any value + /// that is a falsifiable statement about the *data source*. See AGENTS.md, + /// "Deciding whether a new info type belongs here". + #[rustfmt::skip] + const CORE_FACTS: &[(InfoType, &str)] = &[ + // --- Facts about core's own implementation --- + ( + InfoType::GetDataExtensions, + "sql_get_data checks neither column order nor binding state, and no block cursor can exist", + ), + ( + InfoType::LikeEscapeClause, + "escape.rs implements the {escape} sequence core is being asked about", + ), + ( + InfoType::BatchSupport, + "core executes one statement per execute", + ), + ( + InfoType::BatchRowCount, + "core executes one statement per execute", + ), + ( + InfoType::ParamArrayRowCounts, + "sql_set_stmt_attr_w refuses any SQL_ATTR_PARAMSET_SIZE but 1", + ), + ( + InfoType::ParamArraySelects, + "sql_set_stmt_attr_w refuses any SQL_ATTR_PARAMSET_SIZE but 1", + ), + ( + InfoType::MaxAsyncConcurrentStatements, + "the Backend trait is synchronous", + ), + ( + InfoType::AsyncNotification, + "the Backend trait is synchronous", + ), + ( + InfoType::DriverAwarePoolingSupported, + "core owns no connection pool", + ), + (InfoType::ScrollOptions, "core's fetch is forward-only"), + ( + InfoType::CursorSensitivity, + "core's fetch streams rows and promises nothing about another cursor's changes", + ), + ( + InfoType::ForwardOnlyCursorAttributes1, + "the fetch operations core implements", + ), + ( + InfoType::ForwardOnlyCursorAttributes2, + "SQL_CONCUR_READ_ONLY is the one concurrency SQLSetStmtAttr accepts", + ), + ( + InfoType::DynamicCursorAttributes1, + "core has no dynamic cursor", + ), + ( + InfoType::DynamicCursorAttributes2, + "core has no dynamic cursor", + ), + ( + InfoType::KeysetCursorAttributes1, + "core has no keyset cursor", + ), + ( + InfoType::KeysetCursorAttributes2, + "core has no keyset cursor", + ), + ( + InfoType::StaticCursorAttributes1, + "core has no static cursor", + ), + ( + InfoType::StaticCursorAttributes2, + "core has no static cursor", + ), + (InfoType::AsyncMode, "the Backend trait is synchronous"), + ( + InfoType::AsyncDbcFunctions, + "the Backend trait is synchronous", + ), + ( + InfoType::MultResultSets, + "sql_more_results always returns SQL_NO_DATA", + ), + ( + InfoType::NeedLongDataLen, + "core's data-at-execution path never needs the length up front", + ), + ( + InfoType::OdbcInterfaceConformance, + "describes the FFI surface core exports", + ), + ( + InfoType::DriverOdbcVer, + "describes the FFI surface core exports", + ), + ( + InfoType::XopenCliYear, + "driver-level identity, not a data-source property", + ), + ( + InfoType::MaxCursorNameLen, + "a cursor name is an ODBC-level convention core owns", + ), + ( + InfoType::DescribeParameter, + "core's SQLDescribeParam always answers, generically", + ), + ( + InfoType::MaxRowSizeIncludesLong, + "follows from SQL_MAX_ROW_SIZE being 'unknown'", + ), + // --- Limits: the spec defines 0 as "no limit or unknown" --- + (InfoType::MaxDriverConnections, "0 = no limit"), + (InfoType::MaxConcurrentActivities, "0 = no limit"), + (InfoType::ActiveEnvironments, "0 = no limit"), + (InfoType::MaxColumnsInGroupBy, "0 = no limit or unknown"), + (InfoType::MaxColumnsInIndex, "0 = no limit or unknown"), + (InfoType::MaxColumnsInOrderBy, "0 = no limit or unknown"), + (InfoType::MaxColumnsInSelect, "0 = no limit or unknown"), + (InfoType::MaxColumnsInTable, "0 = no limit or unknown"), + (InfoType::MaxTablesInSelect, "0 = no limit or unknown"), + (InfoType::MaxUserNameLen, "0 = no limit or unknown"), + (InfoType::MaxIndexSize, "0 = no limit or unknown"), + (InfoType::MaxRowSize, "0 = no limit or unknown"), + (InfoType::MaxStatementLen, "0 = no limit or unknown"), + // --- No per-backend answer to give --- + ( + InfoType::DataSourceName, + "the DM supplies the DSN; core has none", + ), + ( + InfoType::ServerName, + "carried in the connection string, not known here", + ), + ( + InfoType::UserName, + "carried in the connection string, not known here", + ), + (InfoType::CollationSeq, "unknown, and the spec allows empty"), + ]; + + /// Info types with no arm in [`default_get_info`], where reaching the + /// shape-aware default in `info_type_default_response` is the intended + /// outcome rather than an oversight. + /// + /// Two kinds qualify, and neither is "core decided the data source's + /// answer": + /// + /// - **Driver identity.** Core has no name or version to give; only the + /// driver does, and the Windows DM path documented in AGENTS.md requires + /// it to supply them through `get_info_pre_connect`. + /// - **Capability bitmaps a backend must claim for itself.** `0` reads as + /// "supports none of these", which is the only honest answer core can + /// give for a backend that has said nothing. Overstating here is what + /// makes a BI tool push down SQL the data source then rejects. Several + /// are also genuinely *per-connection* (a server version can gate them), + /// which a static capability method could not express; those belong in + /// `Backend::get_info`, which runs first and takes a connection. + #[rustfmt::skip] + const SHAPE_DEFAULT_IS_THE_ANSWER: &[(InfoType, &str)] = &[ + // --- Driver and data source identity --- + + // --- Claims about the data source, understated rather than invented --- + (InfoType::SqlFileUsage, "whether the driver is single-tier and file-based is a driver fact"), + (InfoType::SqlQuotedIdentifierCase, "answered by common_get_info_raw for backends that delegate to it"), + + // --- Scalar-function bitmaps: claiming one core cannot honour makes a + // BI tool emit a function the data source rejects --- + (InfoType::NumericFunctions, "the backend owns its scalar function set"), + (InfoType::StringFunctions, "the backend owns its scalar function set"), + (InfoType::SystemFunctions, "the backend owns its scalar function set"), + (InfoType::TimedateFunctions, "the backend owns its scalar function set"), + (InfoType::AggregateFunctions, "the backend owns its aggregate function set"), + + // --- SQL-92 grammar bitmaps. Several are per-connection in practice + // (Trino gates MATCH/UNIQUE on the coordinator version), so they + // belong in Backend::get_info, which takes a connection --- + (InfoType::Sql92Predicates, "per-connection in at least one driver; belongs in get_info"), + (InfoType::Sql92RelationalJoinOperators, "per-connection in at least one driver; belongs in get_info"), + (InfoType::Sql92DatetimeFunctions, "the backend owns its SQL-92 grammar support"), + (InfoType::Sql92NumericValueFunctions, "the backend owns its SQL-92 grammar support"), + (InfoType::Sql92StringFunctions, "the backend owns its SQL-92 grammar support"), + (InfoType::Sql92ValueExpressions, "the backend owns its SQL-92 grammar support"), + (InfoType::Sql92RowValueConstructor, "the backend owns its SQL-92 grammar support"), + (InfoType::Sql92ForeignKeyDeleteRule, "referential actions are a data source fact"), + (InfoType::Sql92ForeignKeyUpdateRule, "referential actions are a data source fact"), + (InfoType::Sql92Grant, "the backend owns its privilege model"), + (InfoType::Sql92Revoke, "the backend owns its privilege model"), + ]; + + /// Classifies every info type `default_get_info` answers by asking one + /// question: **does the answer move when the backend does?** + /// + /// Two backends that share no capability declaration are compared. An info + /// type answering the same for both is one *core* decided, and must appear + /// in [`CORE_FACTS`] with the reason core is entitled to decide it. One + /// that differs is backend-derived and needs no entry. + /// + /// This is what keeps the backend/`SQLGetInfo` split from drifting. + /// Hard-coding a claim about the data source into `default_get_info` fails + /// a test naming the info type rather than surviving review, which is how + /// `SQL_GROUP_BY`, `SQL_CORRELATION_NAME` and `SQL_SUBQUERIES` come to be + /// decided in the wrong place. + #[test] + fn default_get_info_answers_are_backend_derived_or_declared_core_facts() { + use crate::test_utils::MockAltBackend; + + // Each backend's own widths, exactly as `sql_get_info_w` calls this. + // Passing one shared value would make the `SQL_MAX_*_NAME_LEN` group + // look core-decided when it is derived from + // `Backend::catalog_result_column_widths`. + let mine_widths = MockBackend::catalog_result_column_widths(); + let alt_widths = MockAltBackend::catalog_result_column_widths(); + assert_ne!( + mine_widths.identifier_len, alt_widths.identifier_len, + "the two mocks must declare different identifier widths" + ); + let mut undeclared = Vec::new(); + let mut stale = Vec::new(); + let mut unanswered = Vec::new(); + + for info_type in crate::conformance::all_info_types() { + let mine = default_get_info::(Some(&MockConnection), info_type); + let theirs = default_get_info::(Some(&MockConnection), info_type); + let declared = CORE_FACTS.iter().find(|(t, _)| *t == info_type); + + match (mine.is_some() && mine == theirs, declared) { + // Core decides it, and said why. Fine. + (true, Some(_)) => {} + // Core decides it, and did not say why. + (true, None) => undeclared.push(info_type), + // Backend-derived (or unanswered) but still listed as a core + // fact, so the entry outlived the value it described. + (false, Some(_)) => stale.push(info_type), + (false, None) => { + // Neither backend answers it at all. `default_get_info` + // returns `None`, so `sql_get_info_w` falls through to the + // shape-aware default (`0` or `""`), which for many info + // types is a substantive claim about the data source that + // core is in no position to make. Only the comparison above + // sees arms that *exist*, so without this a type with no arm + // bypasses the whole "capability must be declared" design. + if mine.is_none() && theirs.is_none() { + unanswered.push(info_type); + } + } + } + } + + assert!( + undeclared.is_empty(), + "these info types answer the same for two backends with nothing in \ + common, so core is deciding them. Either derive each from a \ + `Backend` method, or add it to CORE_FACTS with the reason core is \ + entitled to decide it: {undeclared:?}" + ); + let undeclared_gap: Vec<_> = unanswered + .iter() + .filter(|t| !SHAPE_DEFAULT_IS_THE_ANSWER.iter().any(|(d, _)| d == *t)) + .collect(); + assert!( + undeclared_gap.is_empty(), + "these info types have no arm at all, so they reach the shape-aware \ + default (`0` / `\"\"`) with nothing naming that as the intended \ + answer. Either give each an arm, derived from a `Backend` method \ + if it is a claim about the data source, or list it in \ + SHAPE_DEFAULT_IS_THE_ANSWER with the reason the default is \ + correct: {undeclared_gap:?}" + ); + let stale_gap: Vec<_> = SHAPE_DEFAULT_IS_THE_ANSWER + .iter() + .map(|(t, _)| *t) + .filter(|t| !unanswered.contains(t)) + .collect(); + assert!( + stale_gap.is_empty(), + "these are listed in SHAPE_DEFAULT_IS_THE_ANSWER but have an arm; \ + drop the stale entries: {stale_gap:?}" + ); + assert!( + stale.is_empty(), + "these are listed in CORE_FACTS but are not answered identically \ + for every backend; drop the stale entries: {stale:?}" + ); + } + + /// Info types [`common_get_info_raw`] answers identically for **every** + /// backend, each with the reason core is entitled to decide it. The + /// raw-path sibling of [`CORE_FACTS`], and held to the same standard. + #[rustfmt::skip] + const RAW_PATH_CORE_FACTS: &[(u16, &str)] = &[ + ( + crate::types::SQL_ROW_UPDATES, + "describes a keyset-driven or mixed cursor's row-version detection, and core drives neither", + ), + ( + crate::types::SQL_PROCEDURES, + "the spec's conjunction requires the driver to support ODBC procedure-invocation syntax, and escape.rs rejects {call} with HYC00", + ), + ( + crate::types::SQL_PROCEDURE_TERM, + "the spec mandates the empty string while SQL_PROCEDURES is \"N\"", + ), + ( + crate::types::SQL_TABLE_TERM, + "ODBC's generic word for the one object every data source has; a vendor that calls it something else overrides the info type in get_info", + ), + ( + crate::types::SQL_FILE_USAGE, + "core builds a driver that is not single-tier and file-based", + ), + ]; + + /// Info types [`common_get_info_raw`] answers identically for every backend + /// where that answer is a claim about the *data source*, which core is not + /// in a position to make. + /// + /// Every entry is outstanding work, not a justification: each needs either + /// a `Backend` method to state it or a derivation from one that already + /// exists. The list exists so it can only shrink: a new hard-coded + /// data-source claim on this path fails the test rather than joining them + /// silently. + #[rustfmt::skip] + const RAW_PATH_GAPS: &[(u16, &str)] = &[ + ]; + + /// The raw-path sibling of + /// [`default_get_info_answers_are_backend_derived_or_declared_core_facts`], + /// asking the same question of the same two mocks: **does the answer move + /// when the backend does?** + /// + /// `common_get_info_raw` is the only place several info types are ever + /// answered. `SQL_CURSOR_ROLLBACK_BEHAVIOR` has no `odbc_sys::InfoType` + /// variant at all, and `SQL_TABLE_TERM`, `SQL_PROCEDURES` and their + /// neighbours have one but no arm in `default_get_info`. Without this test + /// that path is unpoliced, and a hard-coded claim about the data source can + /// live there indefinitely. + /// + /// The whole `u16` range is scanned rather than a hand-picked set: what is + /// answered here is exactly what the function's `match` decides, and a list + /// maintained beside it would be a second statement of the same fact. + /// + /// That scan is 131 072 evaluations, which costs nothing on stable but is + /// a large share of the Miri job, since Miri interprets every one of them. + /// `common_get_info_raw` contains no `unsafe`, so Miri has nothing to check + /// here and would pay purely to re-run a classification stable already + /// made. Same reasoning as + /// `escape::tests::pathological_nesting_returns_an_error_rather_than_killing_the_process`. + #[cfg_attr( + miri, + ignore = "131k-evaluation scan; the function under test has no unsafe for Miri to check" + )] + #[test] + fn common_get_info_raw_answers_are_backend_derived_or_declared() { + use crate::test_utils::MockAltBackend; + + let mut undeclared = Vec::new(); + let mut stale = Vec::new(); + + for raw in 0..=u16::MAX { + let mine = common_get_info_raw::(Some(&MockConnection), raw); + let theirs = common_get_info_raw::(Some(&MockConnection), raw); + let declared = RAW_PATH_CORE_FACTS.iter().any(|(t, _)| *t == raw) + || RAW_PATH_GAPS.iter().any(|(t, _)| *t == raw); + + match (mine.is_some() && mine == theirs, declared) { + // Core decides it, and said why, or said it is a known gap. + (true, true) => {} + // Core decides it, and neither is recorded. + (true, false) => undeclared.push(raw), + // Backend-derived (or unanswered) but still listed, so the + // entry outlived the value it described. + (false, true) => stale.push(raw), + (false, false) => {} + } + } + + assert!( + undeclared.is_empty(), + "these raw info types answer the same for two backends with nothing \ + in common, so core is deciding them. Either derive each from a \ + `Backend` method, or record it in RAW_PATH_CORE_FACTS with the \ + reason core is entitled to decide it, or in RAW_PATH_GAPS if it is \ + a claim about the data source that still needs a hook: {undeclared:?}" + ); + assert!( + stale.is_empty(), + "these are recorded in RAW_PATH_CORE_FACTS or RAW_PATH_GAPS but are \ + not answered identically for every backend; drop the stale \ + entries: {stale:?}" + ); + } + + /// `SQL_TXN_CAPABLE` and `SQL_TXN_ISOLATION_OPTION` constrain each other: + /// `SQL_TC_NONE` says the data source has no transactions, and a non-zero + /// isolation bitmask says it has at least one level to run them at. A + /// backend that declares both is making one claim, and this is the pairing + /// the AGENTS.md "info types that constrain each other" rule is about. + /// + /// Asserted over both classification mocks, which between them cover the + /// two combinations that matter: `SQL_TC_ALL` with `SQL_TXN_SERIALIZABLE` + /// and `SQL_TC_DML` with `SQL_TXN_READ_UNCOMMITTED`. + #[test] + fn txn_capable_and_the_isolation_options_agree_about_transactions() { + use crate::test_utils::MockAltBackend; + + fn check(conn: &B::Connection, name: &str) { + let capable = B::txn_capable(conn); + let options = B::txn_isolation_options(conn); + assert_eq!( + capable == crate::types::SQL_TC_NONE as u16, + options == 0, + "{name} reports SQL_TXN_CAPABLE = {capable} and \ + SQL_TXN_ISOLATION_OPTION = {options:#x}; a data source with no \ + transactions has no isolation levels, and one with levels is \ + not SQL_TC_NONE" + ); + } + + check::(&MockConnection, "MockBackend"); + check::(&MockConnection, "MockAltBackend"); + } + + /// The Windows Driver Manager queries driver identity *before* + /// `SQLDriverConnectW`, so these answers cannot depend on a connection. + /// With [`Backend::driver_name`] and [`Backend::driver_version`] declared, + /// core answers them itself and a driver needs no + /// [`Backend::get_info_pre_connect`] override to satisfy the Driver + /// Manager. + #[test] + fn driver_identity_answers_without_a_connection() { + assert_eq!( + default_get_info::(None, InfoType::DriverName), + Some(InfoValue::String("Mock ODBC Driver".into())), + ); + assert_eq!( + default_get_info::(None, InfoType::DriverVer), + Some(InfoValue::String("01.00.0000".into())), + ); + // The rest of the group the Windows DM asks for, for the same reason. + assert_eq!( + default_get_info::(None, InfoType::DriverOdbcVer), + Some(InfoValue::String(SQL_DRIVER_ODBC_VER_STRING.into())), + ); + assert_eq!( + default_get_info::(None, InfoType::AsyncDbcFunctions), + Some(InfoValue::U32(0)), + ); + assert_eq!( + default_get_info::(None, InfoType::MaxConcurrentActivities), + Some(InfoValue::U16(0)), + ); + // The data-source half of identity is not answerable without one. + assert_eq!( + default_get_info::(None, InfoType::DbmsName), + None, + ); + } + + /// The classification above is only as strong as the two mocks differing. + /// A hook added to `MockBackend` and copied verbatim into `MockAltBackend` + /// would silently turn a backend-derived info type into a "core fact" + /// without anyone noticing. + #[test] + fn the_two_classification_mocks_share_no_capability_declaration() { + use crate::test_utils::MockAltBackend; + + macro_rules! differs { + ($($hook:ident),+ $(,)?) => {$( + assert_ne!( + MockBackend::$hook(&MockConnection), + MockAltBackend::$hook(&MockConnection), + concat!( + "MockBackend and MockAltBackend declare the same ", + stringify!($hook), + ", which weakens the classification test", + ) + ); + )+}; + } + differs!( + supports_catalogs, + supports_schemas, + alter_table_support, + outer_join_capabilities, + default_txn_isolation, + txn_isolation_options, + group_by, + null_collation, + identifier_case, + correlation_name, + non_nullable_columns, + expressions_in_order_by, + sql_conformance, + timedate_add_intervals, + timedate_diff_intervals, + subqueries, + column_alias, + concat_null_behavior, + union_support, + convert_functions, + order_by_columns_in_select, + accessible_tables, + data_source_read_only, + search_pattern_escape, + keywords, + quoted_identifier_case, + txn_capable, + integrity, + multiple_active_txn, + special_characters, + accessible_procedures, + dbms_name, + dbms_version, + ); + // Asserted separately for the same reason as the cursor-behaviour pair + // below: driver identity answers before a connection exists, because + // that is when the Windows Driver Manager asks for it. + assert_ne!(MockBackend::driver_name(), MockAltBackend::driver_name()); + assert_ne!( + MockBackend::driver_version(), + MockAltBackend::driver_version(), + ); + // Asserted separately: these three do not take a connection, because + // `SQLGetInfo` has to answer the two cursor-behaviour info types before + // one exists. + assert_ne!( + MockBackend::cursor_commit_behavior(), + MockAltBackend::cursor_commit_behavior(), + ); + assert_ne!( + MockBackend::cursor_rollback_behavior(), + MockAltBackend::cursor_rollback_behavior(), + ); + assert_ne!( + MockBackend::escape_dialect(&MockConnection).identifier_quotes, + MockAltBackend::escape_dialect(&MockConnection).identifier_quotes, + ); + } + + /// The five identifier-length info types follow the backend's declared + /// catalog widths, not a baked-in 128, so a driver cannot report 63 in its + /// catalog result sets and 128 here, which would be two different answers + /// about one limit. + #[test] + fn max_name_len_info_types_follow_the_backends_declared_widths() { + use crate::test_utils::MockAltBackend; + // `MockAltBackend` declares 63; the shared default is 128. + assert_eq!( + MockAltBackend::catalog_result_column_widths().identifier_len, + 63 + ); + assert_eq!( + CatalogResultColumnWidths::default().identifier_len, + 128, + "if the shared default were also 63 this test could not tell them apart" + ); + for info_type in [ + InfoType::MaxColumnNameLen, + InfoType::MaxSchemaNameLen, + InfoType::MaxCatalogNameLen, + InfoType::MaxTableNameLen, + InfoType::MaxIdentifierLen, + ] { + assert_eq!( + default_get_info::(Some(&MockConnection), info_type), + Some(InfoValue::U16(63)), + "{info_type:?} ignored the backend's declared identifier_len" + ); + } + } +} diff --git a/src/binary_convert.rs b/src/binary_convert.rs new file mode 100644 index 0000000..caa0eb4 --- /dev/null +++ b/src/binary_convert.rs @@ -0,0 +1,601 @@ +//! Conversion of `SQL_C_BINARY` parameter data to the SQL type the application +//! declared at `SQLBindParameter`. +//! +//! This module is the [C to SQL: Binary] table, transcribed, and the sibling of +//! [`crate::param_convert`], which is the [C to SQL: Character] one. It exists +//! for the same reason: [`crate::backend::Backend::execute`] receives only +//! `&[ColumnValue]`, so the declared `ParameterType` never reaches the backend. +//! If core does not honour it, nobody does, and a parameter bound `SQL_C_BINARY` +//! + `SQL_INTEGER` arrives at the data source as raw bytes. +//! +//! # Byte order is native, and that is a ruling +//! +//! Native is chosen on evidence rather than derived. AWS's Redshift +//! ODBC driver v2.2.0 reads these targets with a plain `memcpy` into the C type, +//! with no swapping. Its changelog calls that work "fully ODBC-compliant" and +//! "Added missing SQL_C_BINARY conversion support for all SQL types". A +//! round-trip test per target pins the expectation, so a big-endian port fails +//! loudly rather than silently swapping. +//! +//! Core is stricter than that driver in one place: it tests `==` where AWS tests +//! `>=`, so a five-byte value bound `SQL_INTEGER` is rejected rather than +//! silently truncated to its first four bytes. The spec row says "=". +//! +//! # What is refused, and why +//! +//! - **`SQL_DECIMAL` / `SQL_NUMERIC`.** Row 3 compares against "SQL data +//! length", which [Converting Data from C to SQL Data Types] defines as the +//! bytes required to store the value *at the data source*. A decimal has no +//! fixed width and core cannot know the data source's. Reading the bytes as a +//! `SQL_NUMERIC_STRUCT` would be an invented convention whose failure mode is +//! the one this module exists to remove: 19 bytes meaning something else +//! decoded into a plausible, wrong decimal with no diagnostic. +//! - **Every character target.** Rows 1 and 2 need an encoding, and ODBC +//! specifies none for these bytes. Row 1's test is plain byte length rather +//! than doubled, unlike the character table's binary row, which explicitly +//! halves, so the conversion is a byte pass-through into the data source's +//! own encoding, not a hex expansion. Core does not know that encoding. +//! Guessing UTF-8 would make acceptance depend on the value's contents, so +//! the same bind would succeed or fail with the data. +//! +//! Both refusals are `07006`, and both are raised by `SQLBindParameter` rather +//! than at execute time; see [`binary_target_is_supported`]. +//! +//! [C to SQL: Character]: https://learn.microsoft.com/en-us/sql/odbc/reference/appendixes/c-to-sql-character +//! [C to SQL: Binary]: https://learn.microsoft.com/en-us/sql/odbc/reference/appendixes/c-to-sql-binary +//! [Converting Data from C to SQL Data Types]: https://learn.microsoft.com/en-us/sql/odbc/reference/appendixes/converting-data-from-c-to-sql-data-types + +use odbc_sys::SqlDataType; + +use crate::{ + errors::OdbcError, + param_convert::check_declared_binary_size, + types::{ColumnValue, SqlState, ULen}, +}; + +/// Whether the declared SQL type is one of the three binary types, whose +/// `ColumnSize` is a byte length. +/// +/// Moved here from [`crate::param_convert`] with the rest of this table; +/// `check_declared_binary_size` stayed there, because it also serves the +/// character table's own binary row. +pub(crate) fn is_binary_sql_type(sql_type: SqlDataType) -> bool { + sql_type == SqlDataType::EXT_BINARY + || sql_type == SqlDataType::EXT_VAR_BINARY + || sql_type == SqlDataType::EXT_LONG_VAR_BINARY +} + +/// The exact byte count row 3 requires for a target, or `None` if this table +/// does not convert `SQL_C_BINARY` to it. +/// +/// Every width is a `size_of`, never a literal: these are C struct and scalar +/// widths, so the compiler is the only correct source for them. +/// +/// The ODBC 2.0 datetime spellings are grouped with their 3.x counterparts +/// exactly as [`crate::param_convert`] and [`crate::types::col_attr`] group +/// them. Note that `odbc_sys` names 9 `DATETIME`, after the *verbose* +/// `SQL_DATETIME`, but `ParameterType` is a **concise** type where 9 is +/// `SQL_DATE`, so it belongs with date. +fn fixed_width(sql_type: SqlDataType) -> Option { + use std::mem::size_of; + + if sql_type == SqlDataType::EXT_TINY_INT { + return Some(size_of::()); + } + if sql_type == SqlDataType::SMALLINT { + return Some(size_of::()); + } + if sql_type == SqlDataType::INTEGER { + return Some(size_of::()); + } + if sql_type == SqlDataType::EXT_BIG_INT { + return Some(size_of::()); + } + if sql_type == SqlDataType::REAL { + return Some(size_of::()); + } + if sql_type == SqlDataType::FLOAT || sql_type == SqlDataType::DOUBLE { + return Some(size_of::()); + } + if sql_type == SqlDataType::EXT_BIT { + return Some(size_of::()); + } + if sql_type == SqlDataType::DATE || sql_type == SqlDataType::DATETIME { + return Some(size_of::()); + } + if sql_type == SqlDataType::TIME || sql_type == SqlDataType::EXT_TIME_OR_INTERVAL { + return Some(size_of::()); + } + if sql_type == SqlDataType::TIMESTAMP || sql_type == SqlDataType::EXT_TIMESTAMP { + return Some(size_of::()); + } + None +} + +/// Whether core converts `SQL_C_BINARY` to this target at all. +/// +/// `SQLBindParameter` calls this and refuses the pairing with 07006 when it is +/// false. Bind time rather than execute time, because the pairing is fixed at +/// bind, needs no backend metadata, and never depends on the data. So the +/// application fails before running its query, and the `SQLPutData` path is +/// covered by the same single check. +/// +/// Anything this admits, [`binary_to_sql_type`] handles; a test pins both +/// directions. +pub(crate) fn binary_target_is_supported(sql_type: SqlDataType) -> bool { + is_binary_sql_type(sql_type) || fixed_width(sql_type).is_some() +} + +/// "Byte length of data <> SQL data length": row 3's only failure outcome. +fn wrong_width(actual: usize, expected: usize, sql_type: SqlDataType) -> OdbcError { + OdbcError::general( + format!( + "A SQL_C_BINARY parameter for {sql_type:?} needs exactly {expected} bytes, not {actual}" + ), + SqlState::numeric_value_out_of_range(), + ) +} + +/// A target this table does not convert `SQL_C_BINARY` to. +/// +/// `pub(crate)` so `SQLBindParameter`'s refusal and this module's own carry the +/// same message; an application should not be able to tell the two apart. +pub(crate) fn unsupported_target(sql_type: SqlDataType) -> OdbcError { + OdbcError::general( + format!("SQL_C_BINARY cannot be converted to {sql_type:?}"), + SqlState::restricted_data_type_attribute_violation(), + ) +} + +/// Copy exactly `N` bytes out of a slice the caller has already length-checked. +/// +/// `unwrap_or` states a value the branch cannot reach rather than panicking in +/// an FFI call: every caller is guarded by the `bytes.len() != width` test. +fn fixed(bytes: &[u8]) -> [u8; N] { + bytes.try_into().unwrap_or([0u8; N]) +} + +/// Convert `SQL_C_BINARY` parameter data to the declared SQL type. +/// +/// `bytes` is the value read out of the parameter buffer, or accumulated by +/// `SQLPutData`; `sql_type` is `SQLBindParameter`'s `ParameterType` and +/// `col_size` its `ColumnSize`. +/// +/// Spec: +pub(crate) fn binary_to_sql_type( + bytes: &[u8], + sql_type: SqlDataType, + col_size: ULen, +) -> Result { + tracing::trace!( + "binary_to_sql_type: {} bytes declared as {:?}", + bytes.len(), + sql_type + ); + + // Row 4: binary to binary needs no conversion, only the declared-size test. + if is_binary_sql_type(sql_type) { + check_declared_binary_size(bytes.len(), col_size)?; + return Ok(ColumnValue::Bytes(bytes.to_vec())); + } + + let Some(width) = fixed_width(sql_type) else { + return Err(unsupported_target(sql_type)); + }; + if bytes.len() != width { + return Err(wrong_width(bytes.len(), width, sql_type)); + } + + if sql_type == SqlDataType::EXT_TINY_INT { + return Ok(ColumnValue::I8(i8::from_ne_bytes(fixed(bytes)))); + } + if sql_type == SqlDataType::SMALLINT { + return Ok(ColumnValue::I16(i16::from_ne_bytes(fixed(bytes)))); + } + if sql_type == SqlDataType::INTEGER { + return Ok(ColumnValue::I32(i32::from_ne_bytes(fixed(bytes)))); + } + if sql_type == SqlDataType::EXT_BIG_INT { + return Ok(ColumnValue::I64(i64::from_ne_bytes(fixed(bytes)))); + } + if sql_type == SqlDataType::REAL { + return Ok(ColumnValue::F32(f32::from_ne_bytes(fixed(bytes)))); + } + if sql_type == SqlDataType::FLOAT || sql_type == SqlDataType::DOUBLE { + return Ok(ColumnValue::F64(f64::from_ne_bytes(fixed(bytes)))); + } + if sql_type == SqlDataType::EXT_BIT { + // Row 3 states a width test and no value test for SQL_BIT, so any + // non-zero byte is true. `first` rather than `[0]`: the width check + // above already guarantees one byte, and indexing would be a panic + // path in an FFI call. + return Ok(ColumnValue::Bool(bytes.first().copied().unwrap_or(0) != 0)); + } + + // The temporal targets are the C structs the C Data Types appendix + // specifies field by field. SAFETY for all three reads: the width check + // above guarantees `bytes` is exactly `size_of` the struct, and + // `read_unaligned` imposes no alignment requirement on the `u8` pointer it + // reads through, which matters because a bound parameter buffer inside a + // packed row-wise structure has no guaranteed alignment. + if sql_type == SqlDataType::DATE || sql_type == SqlDataType::DATETIME { + let d = unsafe { std::ptr::read_unaligned(bytes.as_ptr().cast::()) }; + return Ok(ColumnValue::Date { + year: d.year, + month: d.month, + day: d.day, + }); + } + if sql_type == SqlDataType::TIME || sql_type == SqlDataType::EXT_TIME_OR_INTERVAL { + // SQL_TIME_STRUCT carries no fractional seconds; report 0, as + // `read_param_value` does for the SQL_C_TYPE_TIME buffer. + let t = unsafe { std::ptr::read_unaligned(bytes.as_ptr().cast::()) }; + return Ok(ColumnValue::Time { + hour: t.hour, + minute: t.minute, + second: t.second, + fraction: 0, + }); + } + if sql_type == SqlDataType::TIMESTAMP || sql_type == SqlDataType::EXT_TIMESTAMP { + let ts = unsafe { std::ptr::read_unaligned(bytes.as_ptr().cast::()) }; + return Ok(ColumnValue::Timestamp { + year: ts.year, + month: ts.month, + day: ts.day, + hour: ts.hour, + minute: ts.minute, + second: ts.second, + fraction: ts.fraction, + }); + } + + // `fixed_width` returned a width for a type no branch above handles, which + // means the two lists have drifted. A test pins them together. + Err(unsupported_target(sql_type)) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Convert with no declared column size, which only the binary-family + /// targets consult. + fn convert(bytes: &[u8], sql_type: SqlDataType) -> Result { + binary_to_sql_type(bytes, sql_type, 0) + } + + fn state_of(result: Result) -> String { + result + .expect_err("conversion should have failed") + .sqlstate() + .as_str() + .to_owned() + } + + // round trips: the native byte-order ruling, pinned ------------------- + + #[test] + fn a_tinyint_target_decodes_one_native_byte() { + assert_eq!( + convert(&(-42_i8).to_ne_bytes(), SqlDataType::EXT_TINY_INT).expect("one byte"), + ColumnValue::I8(-42) + ); + } + + #[test] + fn a_smallint_target_decodes_two_native_bytes() { + assert_eq!( + convert(&(-12_345_i16).to_ne_bytes(), SqlDataType::SMALLINT).expect("two bytes"), + ColumnValue::I16(-12_345) + ); + } + + #[test] + fn an_integer_target_decodes_four_native_bytes() { + assert_eq!( + convert(&(-123_456_i32).to_ne_bytes(), SqlDataType::INTEGER).expect("four bytes"), + ColumnValue::I32(-123_456) + ); + } + + #[test] + fn a_bigint_target_decodes_eight_native_bytes() { + assert_eq!( + convert( + &(-9_000_000_000_i64).to_ne_bytes(), + SqlDataType::EXT_BIG_INT + ) + .expect("eight bytes"), + ColumnValue::I64(-9_000_000_000) + ); + } + + #[test] + fn a_real_target_decodes_four_native_bytes() { + assert_eq!( + convert(&1.5_f32.to_ne_bytes(), SqlDataType::REAL).expect("four bytes"), + ColumnValue::F32(1.5) + ); + } + + #[test] + fn a_double_target_decodes_eight_native_bytes() { + assert_eq!( + convert(&(-2.25_f64).to_ne_bytes(), SqlDataType::DOUBLE).expect("eight bytes"), + ColumnValue::F64(-2.25) + ); + assert_eq!( + convert(&(-2.25_f64).to_ne_bytes(), SqlDataType::FLOAT).expect("eight bytes"), + ColumnValue::F64(-2.25) + ); + } + + /// Row 3 states a width test and no value test for `SQL_BIT`, unlike the + /// character table's `SQL_BIT` row, which states three. So any non-zero + /// byte is true; 2 is not an error here. + #[test] + fn a_bit_target_reads_one_byte_with_no_value_test() { + assert_eq!( + convert(&[0], SqlDataType::EXT_BIT).expect("one byte"), + ColumnValue::Bool(false) + ); + assert_eq!( + convert(&[1], SqlDataType::EXT_BIT).expect("one byte"), + ColumnValue::Bool(true) + ); + assert_eq!( + convert(&[2], SqlDataType::EXT_BIT).expect("one byte"), + ColumnValue::Bool(true) + ); + } + + // the width test: "=" and not ">=" ----------------------------------- + + #[test] + fn a_value_narrower_than_the_target_is_22003() { + assert_eq!(state_of(convert(&[0, 0, 0], SqlDataType::INTEGER)), "22003"); + } + + /// AWS's driver accepts this and takes the first four bytes. The spec row + /// says "Byte length of data = SQL data length", so core rejects it. + #[test] + fn a_value_wider_than_the_target_is_22003_not_truncated() { + assert_eq!( + state_of(convert(&[0, 0, 0, 0, 0], SqlDataType::INTEGER)), + "22003" + ); + } + + #[test] + fn an_empty_value_is_22003_rather_than_a_zero() { + assert_eq!(state_of(convert(&[], SqlDataType::EXT_TINY_INT)), "22003"); + } + + // refusals ----------------------------------------------------------- + + #[test] + fn a_decimal_target_is_07006() { + assert_eq!(state_of(convert(&[0; 19], SqlDataType::DECIMAL)), "07006"); + assert_eq!(state_of(convert(&[0; 19], SqlDataType::NUMERIC)), "07006"); + } + + #[test] + fn a_character_target_is_07006() { + assert_eq!(state_of(convert(b"hello", SqlDataType::VARCHAR)), "07006"); + assert_eq!(state_of(convert(b"hello", SqlDataType::CHAR)), "07006"); + assert_eq!( + state_of(convert(b"hello", SqlDataType::EXT_W_VARCHAR)), + "07006" + ); + } + + #[test] + fn an_unrecognised_target_is_07006() { + assert_eq!(state_of(convert(&[0], SqlDataType(4242))), "07006"); + } + + // the binary family: unchanged behaviour, now owned here ------------- + + #[test] + fn a_binary_target_passes_the_bytes_through_at_any_width() { + assert_eq!( + convert(&[1, 2, 3], SqlDataType::EXT_VAR_BINARY).expect("no declared size"), + ColumnValue::Bytes(vec![1, 2, 3]) + ); + } + + #[test] + fn a_binary_target_over_the_declared_size_is_22001() { + assert_eq!( + binary_to_sql_type(&[1, 2, 3], SqlDataType::EXT_VAR_BINARY, 2) + .expect_err("three bytes exceed VARBINARY(2)") + .sqlstate() + .as_str(), + "22001" + ); + } + + // temporal targets --------------------------------------------------- + + /// The bytes are a `SQL_DATE_STRUCT`, whose fields the C Data Types + /// appendix specifies. Built here from the struct so the test states the + /// layout rather than assuming it. + #[test] + fn a_date_target_decodes_a_sql_date_struct() { + let d = odbc_sys::Date { + year: 2026, + month: 7, + day: 29, + }; + let bytes = unsafe { + std::slice::from_raw_parts( + std::ptr::addr_of!(d).cast::(), + std::mem::size_of::(), + ) + }; + assert_eq!( + convert(bytes, SqlDataType::DATE).expect("six bytes"), + ColumnValue::Date { + year: 2026, + month: 7, + day: 29 + } + ); + } + + #[test] + fn a_time_target_decodes_a_sql_time_struct_with_zero_fraction() { + let t = odbc_sys::Time { + hour: 13, + minute: 45, + second: 6, + }; + let bytes = unsafe { + std::slice::from_raw_parts( + std::ptr::addr_of!(t).cast::(), + std::mem::size_of::(), + ) + }; + let expected = ColumnValue::Time { + hour: 13, + minute: 45, + second: 6, + fraction: 0, + }; + assert_eq!( + convert(bytes, SqlDataType::TIME).expect("six bytes"), + expected + ); + assert_eq!( + convert(bytes, SqlDataType::EXT_TIME_OR_INTERVAL).expect("six bytes"), + expected + ); + } + + #[test] + fn a_timestamp_target_decodes_a_sql_timestamp_struct() { + let ts = odbc_sys::Timestamp { + year: 2026, + month: 7, + day: 29, + hour: 13, + minute: 45, + second: 6, + fraction: 123_000_000, + }; + let bytes = unsafe { + std::slice::from_raw_parts( + std::ptr::addr_of!(ts).cast::(), + std::mem::size_of::(), + ) + }; + let expected = ColumnValue::Timestamp { + year: 2026, + month: 7, + day: 29, + hour: 13, + minute: 45, + second: 6, + fraction: 123_000_000, + }; + assert_eq!( + convert(bytes, SqlDataType::TIMESTAMP).expect("sixteen bytes"), + expected + ); + assert_eq!( + convert(bytes, SqlDataType::EXT_TIMESTAMP).expect("sixteen bytes"), + expected + ); + } + + /// `DATETIME` is 9, which is both the 3.x *verbose* datetime identifier and + /// the ODBC 2.0 *concise* `SQL_DATE`. `ParameterType` is a concise type, so + /// 9 is a date. AWS's Redshift ODBC driver reads it the same way: its + /// parameter conversion opens that branch `case SQL_TYPE_DATE: case + /// SQL_DATE:`. `param_convert` and `col_attr` agree. + #[test] + fn the_2x_date_spelling_is_a_date_not_a_timestamp() { + assert_eq!( + fixed_width(SqlDataType::DATETIME), + Some(std::mem::size_of::()) + ); + + let d = odbc_sys::Date { + year: 2026, + month: 7, + day: 29, + }; + let bytes = unsafe { + std::slice::from_raw_parts( + std::ptr::addr_of!(d).cast::(), + std::mem::size_of::(), + ) + }; + assert_eq!( + convert(bytes, SqlDataType::DATETIME).expect("six bytes"), + ColumnValue::Date { + year: 2026, + month: 7, + day: 29 + } + ); + } + + #[test] + fn a_temporal_target_at_the_wrong_width_is_22003() { + assert_eq!(state_of(convert(&[0; 5], SqlDataType::DATE)), "22003"); + assert_eq!(state_of(convert(&[0; 7], SqlDataType::DATE)), "22003"); + assert_eq!(state_of(convert(&[0; 15], SqlDataType::TIMESTAMP)), "22003"); + } + + // the bind-time gate agrees with the converter ------------------------ + + /// The two must not drift: anything the gate admits, the converter handles. + #[test] + fn every_supported_target_converts_at_its_own_width() { + for sql_type in [ + SqlDataType::EXT_TINY_INT, + SqlDataType::SMALLINT, + SqlDataType::INTEGER, + SqlDataType::EXT_BIG_INT, + SqlDataType::REAL, + SqlDataType::FLOAT, + SqlDataType::DOUBLE, + SqlDataType::EXT_BIT, + SqlDataType::DATE, + SqlDataType::TIME, + SqlDataType::EXT_TIME_OR_INTERVAL, + SqlDataType::TIMESTAMP, + SqlDataType::DATETIME, + SqlDataType::EXT_TIMESTAMP, + ] { + assert!( + binary_target_is_supported(sql_type), + "gate rejects {sql_type:?}" + ); + let width = fixed_width(sql_type).expect("supported target has a width"); + let bytes = vec![0u8; width]; + assert!( + binary_to_sql_type(&bytes, sql_type, 0).is_ok(), + "converter rejects {sql_type:?} at width {width}" + ); + } + } + + #[test] + fn the_gate_rejects_what_the_converter_refuses() { + for sql_type in [ + SqlDataType::DECIMAL, + SqlDataType::NUMERIC, + SqlDataType::VARCHAR, + SqlDataType::EXT_W_VARCHAR, + SqlDataType(4242), + ] { + assert!( + !binary_target_is_supported(sql_type), + "gate admits {sql_type:?}" + ); + } + } +} diff --git a/src/cancel.rs b/src/cancel.rs new file mode 100644 index 0000000..01a2cfe --- /dev/null +++ b/src/cancel.rs @@ -0,0 +1,256 @@ +//! Turning a cancelled backend failure into the spec's `HY008`. +//! +//! `SQLCancel` signals the backend's token; the in-flight call then fails with +//! whatever its client library reported, which carries no hint that a +//! cancellation caused it. This module is the one place that asks the token and +//! relabels such a failure, so the answer cannot drift between the backend call +//! sites in `ffi/`. + +use std::sync::atomic::{AtomicBool, Ordering}; + +use crate::backend::Backend; +use crate::errors::OdbcError; +use crate::types::SqlState; + +/// A backend's cancel token, plus core's record of *what* signalled it. +/// +/// `Backend::CancelToken` answers "was this cancelled"; it cannot answer "by +/// whom", because a backend has one `cancel` method and both callers use it: +/// `SQLCancel` on another thread, and the query timer in +/// [`crate::query_timer`]. The two are different events with different +/// SQLSTATEs (`HY008` against `HYT00`), so core records the second one here. +/// +/// # Why the flag lives inside the token's own allocation +/// +/// This is what `mint_cancel_token` stores in the registry, so the flag has +/// exactly the token's lifetime and no other: +/// +/// - **It is minted per execution**, with the token, so a deadline that expired +/// on one execution cannot relabel a failure on the next. That is the same +/// property `mint_cancel_token` exists to give the token itself, inherited +/// rather than restated. +/// - **It survives the statement**, with the token, because both are one +/// allocation behind the `Arc` that `Registry::cancel_of` clones out. The +/// timer thread already holds that clone, so it needs no registry access, no +/// lock, and cannot touch a statement that another thread has freed. +/// - **It needs no lock**, being one `AtomicBool`. Nothing here goes through +/// `crate::sync`, which is the crate's import path for *locks*; the timer +/// thread holds none, exactly as `SQLCancel`'s cross-thread branch holds +/// none. +/// +/// A flag on the statement handle fails the second point, because the timer +/// thread must never reach handle state. A second field on `Registry::Slot` +/// satisfies the first two only by keeping in step, by hand, two things that +/// one allocation keeps in step by construction. +pub(crate) struct CancelState { + token: T, + /// Set by [`crate::query_timer::QueryTimer`]'s thread when a core-enforced + /// `SQL_ATTR_QUERY_TIMEOUT` expired and it cancelled `token`. + timed_out: AtomicBool, +} + +impl CancelState { + pub(crate) fn new(token: T) -> Self { + Self { + token, + timed_out: AtomicBool::new(false), + } + } + + pub(crate) fn token(&self) -> &T { + &self.token + } + + /// Record that a core-side deadline is why this token was signalled. + /// + /// Called before `Backend::cancel`, so any thread that can observe the + /// cancellation can already observe its cause. + pub(crate) fn mark_timed_out(&self) { + self.timed_out.store(true, Ordering::SeqCst); + } + + pub(crate) fn timed_out(&self) -> bool { + self.timed_out.load(Ordering::SeqCst) + } +} + +/// Relabel a failed backend call as `HY008` when its cancel token was signalled. +/// +/// Spec, `SQLCancel`: "If the original function is canceled, it returns +/// SQL_ERROR and SQLSTATE HY008 (Operation canceled)." +/// +/// **Only the error half is examined.** The spec allows a cancelled execution +/// to complete anyway ("it is possible for the execution to succeed and return +/// SQL_SUCCESS while the cancel is also successful"), so `Ok` is returned +/// untouched no matter what the token says. This relabels an error core already +/// has; it never manufactures one. +/// +/// The backend's own error is dropped rather than chained, because it describes +/// the *symptom* of a cancellation (a closed socket, an aborted query) and not +/// the cause. Its SQLSTATE is what the application would otherwise see, and it +/// is exactly what the spec says must not be reported here. +/// +/// **This function does not consult [`CancelState::timed_out`].** Relabelling a +/// timer-signalled cancellation `HYT00` happens in +/// [`crate::query_timer::QueryTimer::reclassify`], which only the entry points +/// that hold a `QueryTimer` reach. The four that reach *this* function with no +/// timer are `SQLGetData`, `SQLDescribeParam`, `SQLDescribeCol` and +/// `SQLColAttribute`, and **none of the four has an `HYT00` row**: each has +/// `HYT01`, the connection timeout, which is a different state. So a +/// `SQLGetData` failing on a timed-out cursor keeps `HY008`, which its table +/// does list; moving the check here would hand all four a SQLSTATE their spec +/// pages do not allow. That is also why `SQLGetData` carries no timer. +/// +/// The rule is *not* "a timer-holding entry point always has an `HYT00` row", +/// and `SQLParamData` is the exception to check before restating it that way. +/// It holds a timer and relabels (`ffi::params::sql_param_data`), yet its own +/// diagnostics table has no `HYT00` row either. It is entitled to one anyway, +/// by the sentence its page carries after the table: "If **SQLParamData** is +/// called while sending data for a parameter in a SQL statement, it can return +/// any SQLSTATE that can be returned by the function called to execute the +/// statement (**SQLExecute** or **SQLExecDirect**)", and both of those do list +/// `HYT00`. That function's own doc comment documents the whole inherited set +/// on the same grounds. +pub(crate) fn reclassify_cancelled>( + result: Result, + cancel: &B::CancelToken, +) -> Result { + match result { + Ok(value) => Ok(value), + Err(e) => { + if B::is_cancelled(cancel) { + tracing::debug!("backend call failed with its token signalled; reporting HY008"); + Err(OdbcError::general( + "Operation canceled", + SqlState::operation_canceled(), + )) + } else { + Err(e.into()) + } + } + } +} + +/// [`reclassify_cancelled`] for a caller that may not have a token. +/// +/// The cursor-consuming entry points, `SQLFetch`, `SQLGetData` and their +/// neighbours, read their token from the registry rather than minting one, and +/// `None` there means no backend call has run on this statement yet. Nothing +/// could have been cancelled in that case, so the error passes through with its +/// own SQLSTATE. +pub(crate) fn reclassify_cancelled_opt>( + result: Result, + cancel: Option<&B::CancelToken>, +) -> Result { + match cancel { + Some(cancel) => reclassify_cancelled::(result, cancel), + None => result.map_err(Into::into), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::test_utils::{MockBackend, MockCancelToken, MockError}; + use std::sync::atomic::Ordering; + + #[test] + fn an_error_becomes_hy008_when_the_token_is_signalled() { + let token = MockCancelToken::default(); + token.cancelled.store(true, Ordering::SeqCst); + let err = reclassify_cancelled::(Err(MockError), &token) + .expect_err("the input was an error"); + assert_eq!(err.sqlstate().as_str(), "HY008"); + } + + #[test] + fn an_error_keeps_its_own_state_when_the_token_is_not_signalled() { + let token = MockCancelToken::default(); + let err = reclassify_cancelled::(Err(MockError), &token) + .expect_err("the input was an error"); + assert_ne!( + err.sqlstate().as_str(), + "HY008", + "an uncancelled failure must keep the backend's own SQLSTATE" + ); + } + + /// The shape every FFI entry point will use, exercised once here against a + /// real `Backend` rather than a hand-set flag: the backend signals its own + /// token mid-call and then fails, which is what core sees when another + /// thread cancelled it. Pins `MockCancelAwareBackend`'s switches too, so a + /// broken mock fails here rather than in twenty entry-point tests. + #[test] + fn a_backend_that_cancels_itself_mid_call_produces_hy008() { + use crate::backend::Backend; + use crate::test_utils::{MockCancelAwareBackend, MockConnection}; + + MockCancelAwareBackend::fail_next_execution(); + MockCancelAwareBackend::cancel_before_returning(); + + let token = MockCancelAwareBackend::cancel_token(&MockConnection); + let result = MockCancelAwareBackend::exec_direct(&MockConnection, &token, "SELECT 1"); + assert!(result.is_err(), "the mock was told to fail"); + assert!( + MockCancelAwareBackend::is_cancelled(&token), + "the mock was told to cancel itself before returning" + ); + + let err = reclassify_cancelled::(result, &token) + .expect_err("the input was an error"); + assert_eq!(err.sqlstate().as_str(), "HY008"); + } + + /// The same mock without the cancel switch: a plain failure keeps its own + /// state. Guards against the switches leaking between calls, which would + /// make every entry-point test pass for the wrong reason. + #[test] + fn the_same_backend_failing_without_a_cancel_keeps_its_own_state() { + use crate::backend::Backend; + use crate::test_utils::{MockCancelAwareBackend, MockConnection}; + + MockCancelAwareBackend::fail_next_execution(); + + let token = MockCancelAwareBackend::cancel_token(&MockConnection); + let result = MockCancelAwareBackend::exec_direct(&MockConnection, &token, "SELECT 1"); + assert!(result.is_err()); + assert!(!MockCancelAwareBackend::is_cancelled(&token)); + + let err = reclassify_cancelled::(result, &token) + .expect_err("the input was an error"); + assert_ne!(err.sqlstate().as_str(), "HY008"); + } + + /// The cursor half of the same shape, for the entry points that consume a + /// cursor rather than produce one. `fetch`'s failure is reclassified + /// against the token the *producing* execution minted, which is what + /// `handles::current_cancel_token` hands them. + #[test] + fn a_cancelled_fetch_produces_hy008() { + use crate::backend::{Backend, StatementBackend}; + use crate::test_utils::{MockCancelAwareBackend, MockConnection}; + + let token = MockCancelAwareBackend::cancel_token(&MockConnection); + let mut stmt = MockCancelAwareBackend::exec_direct(&MockConnection, &token, "SELECT 1") + .expect("the mock was not told to fail"); + + MockCancelAwareBackend::fail_next_fetch(); + MockCancelAwareBackend::cancel(&token).expect("mock cancel succeeds"); + + let err = reclassify_cancelled::(stmt.fetch(), &token) + .expect_err("the fetch was told to fail"); + assert_eq!(err.sqlstate().as_str(), "HY008"); + } + + #[test] + fn success_stays_successful_even_when_the_token_is_signalled() { + // Spec, SQLCancel: "it is possible for the execution to succeed and + // return SQL_SUCCESS while the cancel is also successful." Turning a + // successful call into HY008 would contradict that outright. + let token = MockCancelToken::default(); + token.cancelled.store(true, Ordering::SeqCst); + let ok = reclassify_cancelled::(Ok(7), &token) + .expect("a successful call must stay successful"); + assert_eq!(ok, 7); + } +} diff --git a/src/catalog_ident.rs b/src/catalog_ident.rs new file mode 100644 index 0000000..91a0dca --- /dev/null +++ b/src/catalog_ident.rs @@ -0,0 +1,257 @@ +//! `SQL_ATTR_METADATA_ID` argument normalisation, and the `SQLTables` +//! `TableType` value-list parser. +//! +//! When `SQL_ATTR_METADATA_ID` is `SQL_TRUE` the spec reclassifies most +//! catalog string arguments from pattern values to identifiers. Core resolves +//! that here rather than passing a flag down to the backend: it already knows +//! the data source's identifier case, quote characters and pattern-escape +//! character, and normalising to an ordinary pattern that matches exactly one +//! name means a backend needs no code for the feature at all. +//! +//! # The spec contradicts itself about quoting, and core follows its first half +//! +//! *Identifier Arguments* states the rule core implements: "If a string in an +//! identifier argument is quoted, the driver removes leading and trailing blanks +//! and treats literally the string within the quotation marks. If the string is +//! not quoted, the driver removes trailing blanks and folds the string to +//! uppercase." +//! +//! Two paragraphs later the same page says the opposite: identifiers "must not +//! be quoted when passed as catalog function arguments, because quote characters +//! passed to catalog functions are interpreted literally", with a worked example +//! in which `SQLTables` given `"\"Accounts Payable\""` looks for a table whose +//! name *includes* the quotation marks, "which is probably not what was +//! intended". +//! +//! The two implementing drivers are on the side of the second paragraph, or +//! silent: psqlODBC does not implement `SQL_ATTR_METADATA_ID` at all, and MySQL +//! Connector/ODBC implements it as a case-insensitive comparison with no +//! delimiter handling (see [`strip_delimiters`] for the citations). So core +//! recognising a delimiter pair is a deviation from both of them, taken from the +//! page's first paragraph. +//! +//! Core keeps that reading because [`normalise_identifier`] +//! is the only reason a `SQL_IC_UPPER` data source can be asked about a +//! mixed-case name at all, and the fourth paragraph of the same page depends on +//! quoting being significant ("Quoted identifiers are used to distinguish a true +//! column name from a pseudo-column of the same name"). Reversing it would make +//! `SQL_ATTR_METADATA_ID` unable to express a case-sensitive name, which is what +//! an application sets it for. + +use crate::types::{SQL_IC_LOWER, SQL_IC_UPPER}; + +/// Turn an identifier-valued catalog argument into a literal pattern. +/// +/// Delimiters are stripped first, and a delimited identifier is **not** folded, +/// because delimiting is how an application says its case is significant. +/// +/// `quotes` comes from `EscapeDialect::identifier_quotes` and `escape` from +/// `Backend::search_pattern_escape`, so every input is a fact the backend +/// already declares. +pub(crate) fn normalise_identifier( + value: &str, + identifier_case: u16, + quotes: &[(char, char)], + escape: &str, +) -> String { + let (unwrapped, was_delimited) = strip_delimiters(value, quotes); + + let folded = if was_delimited { + unwrapped + } else { + match identifier_case { + c if c == SQL_IC_UPPER => unwrapped.to_uppercase(), + c if c == SQL_IC_LOWER => unwrapped.to_lowercase(), + // SQL_IC_SENSITIVE and SQL_IC_MIXED both store the identifier as + // written, so there is nothing to fold. + _ => unwrapped, + } + }; + + escape_pattern_metacharacters(&folded, escape) +} + +/// Remove a matching open/close delimiter pair, reporting whether one was +/// found. +/// +/// Requires at least two characters: a lone `"` is not a pair, and stripping +/// first-and-last unconditionally would turn it into an empty string. +/// +/// # A doubled delimiter inside the identifier is not collapsed +/// +/// `"a""b"` yields `a""b`, not `a"b`, so a table actually named `a"b` is not +/// found by that spelling. **Nothing corroborates a doubling convention here**: +/// +/// - *Quoted Identifiers* defines a quoted identifier as SQL-92's delimited +/// identifier but says nothing about escaping a delimiter inside one. +/// *Identifier Arguments*, the page that governs `SQL_ATTR_METADATA_ID`, +/// does not mention it either. +/// - psqlODBC does not implement `SQL_ATTR_METADATA_ID` at all: it is absent +/// from `options.c`, whose `default` arm answers "Unknown statement option", +/// so the attribute cannot even be set. +/// - MySQL Connector/ODBC threads the flag through to +/// `add_name_condition_oa_id`/`add_name_condition_pv_id` (`driver/catalog.cc`) +/// and uses it for one thing only: comparing with `=` instead of `= BINARY`, +/// which is how it makes the match case-insensitive. It strips no delimiters, +/// recognises none, and carries a `/* Need also code to remove trailing +/// blanks */` TODO for another clause of the same paragraph. +/// +/// So neither driver reaches the question, because neither strips delimiters in +/// the first place. That is a larger divergence than the doubling itself, and +/// it is recorded in the module docs above rather than settled here. +fn strip_delimiters(value: &str, quotes: &[(char, char)]) -> (String, bool) { + let mut chars = value.chars(); + let (Some(first), Some(last)) = (chars.next(), chars.next_back()) else { + // Zero or one character: no room for a pair. + return (value.to_string(), false); + }; + if quotes + .iter() + .any(|&(open, close)| open == first && close == last) + { + (chars.as_str().to_string(), true) + } else { + (value.to_string(), false) + } +} + +/// Escape `%` and `_` so the value matches literally. +/// +/// The escape character is escaped too, and in the same pass, because a +/// separate earlier or later step would either miss the escapes this pass +/// inserts or double them. +fn escape_pattern_metacharacters(value: &str, escape: &str) -> String { + // An empty escape string means the data source has no escape character + // (`Backend::search_pattern_escape` may legitimately return one), so + // there is nothing to escape with. + let Some(esc) = escape.chars().next() else { + return value.to_string(); + }; + let mut out = String::with_capacity(value.len()); + for ch in value.chars() { + if ch == esc || ch == '%' || ch == '_' { + out.push(esc); + } + out.push(ch); + } + out +} + +/// Split `SQLTables`' `TableType` value list. +/// +/// Spec: "a list of comma-separated values for the types of interest; each +/// value can be enclosed in single quotation marks (') or unquoted". +/// +/// `METADATA_ID` never applies here, because the spec is explicit that +/// `TableType` "is a value list argument, regardless of the setting of +/// SQL_ATTR_METADATA_ID". +pub(crate) fn parse_table_type_list(value: &str) -> Vec { + value + .split(',') + .map(|part| part.trim().trim_matches('\'').trim().to_string()) + .filter(|s| !s.is_empty()) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{SQL_IC_LOWER, SQL_IC_MIXED, SQL_IC_SENSITIVE, SQL_IC_UPPER}; + + const QUOTES: &[(char, char)] = &[('"', '"')]; + + #[test] + fn an_undelimited_identifier_is_case_folded() { + // SQL_IC_UPPER means the data source stores unquoted identifiers in + // upper case, so that is what the application's "orders" must match. + assert_eq!( + normalise_identifier("orders", SQL_IC_UPPER, QUOTES, "\\"), + "ORDERS" + ); + assert_eq!( + normalise_identifier("Orders", SQL_IC_LOWER, QUOTES, "\\"), + "orders" + ); + } + + #[test] + fn a_delimited_identifier_is_unwrapped_but_not_folded() { + // Delimiting is exactly how an application says "do not fold this". + // Folding it anyway would make "MixedCase" unfindable. + assert_eq!( + normalise_identifier("\"MixedCase\"", SQL_IC_UPPER, QUOTES, "\\"), + "MixedCase" + ); + } + + #[test] + fn case_preserving_sources_fold_nothing() { + // SQL_IC_SENSITIVE and SQL_IC_MIXED both store the identifier as + // written, so there is nothing to fold. + assert_eq!( + normalise_identifier("Orders", SQL_IC_SENSITIVE, QUOTES, "\\"), + "Orders" + ); + assert_eq!( + normalise_identifier("Orders", SQL_IC_MIXED, QUOTES, "\\"), + "Orders" + ); + } + + #[test] + fn pattern_metacharacters_are_escaped() { + // Under METADATA_ID the value is an identifier, so % and _ are + // literal. Without escaping, a table actually named "a_b" would also + // match "axb", because the backend still matches with LIKE. + assert_eq!( + normalise_identifier("a_b%c", SQL_IC_SENSITIVE, QUOTES, "\\"), + "a\\_b\\%c" + ); + } + + #[test] + fn the_escape_character_itself_is_escaped() { + // Otherwise a literal backslash would escape whatever followed it. + assert_eq!( + normalise_identifier("a\\b", SQL_IC_SENSITIVE, QUOTES, "\\"), + "a\\\\b" + ); + } + + #[test] + fn an_empty_escape_string_disables_escaping() { + // `Backend::search_pattern_escape` may legitimately be empty when the + // data source has no escape character; emitting a stray prefix would + // corrupt the value. + assert_eq!( + normalise_identifier("a_b", SQL_IC_SENSITIVE, QUOTES, ""), + "a_b" + ); + } + + #[test] + fn a_lone_quote_character_is_not_treated_as_a_delimiter() { + // A single `"` is not an open/close pair. Stripping first and last + // unconditionally would turn it into an empty string. + assert_eq!( + normalise_identifier("\"", SQL_IC_SENSITIVE, QUOTES, ""), + "\"" + ); + } + + #[test] + fn table_type_list_splits_and_unquotes() { + // Spec, SQLTables TableType: "a list of comma-separated values ... + // each value can be enclosed in single quotation marks (') or + // unquoted, for example, 'TABLE', 'VIEW' or TABLE, VIEW." + assert_eq!( + parse_table_type_list("'TABLE', 'VIEW'"), + vec!["TABLE".to_string(), "VIEW".to_string()] + ); + assert_eq!( + parse_table_type_list("TABLE, VIEW"), + vec!["TABLE".to_string(), "VIEW".to_string()] + ); + assert_eq!(parse_table_type_list(""), Vec::::new()); + } +} diff --git a/src/catalog_sort.rs b/src/catalog_sort.rs new file mode 100644 index 0000000..72bfde7 --- /dev/null +++ b/src/catalog_sort.rs @@ -0,0 +1,149 @@ +//! Sorting for catalog result sets. +//! +//! Each catalog function's spec page states the order its result set must be +//! in. Core sorts rather than trusting a backend to, because core now holds +//! the rows and a backend that forgets an `ORDER BY` is silently +//! non-compliant in a way no test of its own would catch. + +use crate::types::{ColumnValue, SQL_NC_LOW}; +use std::cmp::Ordering; + +/// Order two values of the same catalog column. +/// +/// NULL placement comes from the data source's `SQL_NULL_COLLATION`, not from +/// a choice made here: `SQLGetInfo` already reports that value to the +/// application, and a sorter that disagreed with it would make the driver +/// contradict itself. +fn compare_values(a: &ColumnValue, b: &ColumnValue, null_collation: u16) -> Ordering { + let nulls_first = null_collation == SQL_NC_LOW; + match (a, b) { + (ColumnValue::Null, ColumnValue::Null) => Ordering::Equal, + (ColumnValue::Null, _) => { + if nulls_first { + Ordering::Less + } else { + Ordering::Greater + } + } + (_, ColumnValue::Null) => { + if nulls_first { + Ordering::Greater + } else { + Ordering::Less + } + } + (ColumnValue::String(x), ColumnValue::String(y)) => x.cmp(y), + // Integer columns (KEY_SEQ, ORDINAL_POSITION, SCOPE, TYPE, + // NON_UNIQUE) must compare numerically: as text, 10 sorts before 2, + // which would corrupt the column order of any table with more than + // nine columns. + (ColumnValue::I16(x), ColumnValue::I16(y)) => x.cmp(y), + (ColumnValue::I32(x), ColumnValue::I32(y)) => x.cmp(y), + (ColumnValue::I64(x), ColumnValue::I64(y)) => x.cmp(y), + // Mixed or non-comparable variants: treat as equal so the stable sort + // preserves the backend's order rather than inventing one. A catalog + // column holds one type across all rows, so this is unreachable in + // practice; it exists so the comparator is total. + _ => Ordering::Equal, + } +} + +/// Sort `rows` in place by `keys`, which are zero-based column indices, most +/// significant first. +/// +/// `sort_by` is stable, which matters: rows equal on every spec key keep the +/// order the backend returned them in. +pub(crate) fn sort_rows(rows: &mut [Vec], keys: &[usize], null_collation: u16) { + rows.sort_by(|a, b| { + for &k in keys { + match (a.get(k), b.get(k)) { + (Some(x), Some(y)) => { + let ord = compare_values(x, y, null_collation); + if ord != Ordering::Equal { + return ord; + } + } + // A row too short for a declared key cannot be ordered + // against; leave the pair alone rather than guess. + _ => return Ordering::Equal, + } + } + Ordering::Equal + }); +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{SQL_NC_HIGH, SQL_NC_LOW}; + + fn s(v: &str) -> ColumnValue { + ColumnValue::String(v.into()) + } + + #[test] + fn sorts_by_each_key_in_order() { + // Spec, SQLTables: "ordered by TABLE_TYPE, TABLE_CAT, TABLE_SCHEM, + // and TABLE_NAME", so TABLE_TYPE (index 3) dominates TABLE_NAME + // (index 2), which is the whole point of a multi-key sort. + let mut rows = vec![ + vec![s("c"), s("s"), s("z_table"), s("TABLE")], + vec![s("c"), s("s"), s("a_table"), s("VIEW")], + vec![s("c"), s("s"), s("m_table"), s("TABLE")], + ]; + sort_rows(&mut rows, &[3, 0, 1, 2], SQL_NC_HIGH); + let names: Vec<&ColumnValue> = rows.iter().map(|r| &r[2]).collect(); + assert_eq!(names, vec![&s("m_table"), &s("z_table"), &s("a_table")]); + } + + #[test] + fn nulls_sort_high_or_low_per_null_collation() { + // SQL_NULL_COLLATION says where NULLs go for this data source. Core + // must not pick: SQLGetInfo already told the application the answer, + // and the two must not contradict each other. + let mk = || vec![vec![s("b")], vec![ColumnValue::Null], vec![s("a")]]; + + let mut high = mk(); + sort_rows(&mut high, &[0], SQL_NC_HIGH); + assert_eq!(high[2][0], ColumnValue::Null, "NC_HIGH sorts NULLs last"); + + let mut low = mk(); + sort_rows(&mut low, &[0], SQL_NC_LOW); + assert_eq!(low[0][0], ColumnValue::Null, "NC_LOW sorts NULLs first"); + } + + #[test] + fn numeric_keys_compare_numerically_not_lexically() { + // KEY_SEQ and ORDINAL_POSITION are integer columns. Comparing them as + // text puts 10 before 2, which silently corrupts the column order of + // every table with more than nine columns. + let mut rows = vec![ + vec![ColumnValue::I32(10)], + vec![ColumnValue::I32(2)], + vec![ColumnValue::I32(1)], + ]; + sort_rows(&mut rows, &[0], SQL_NC_HIGH); + assert_eq!( + rows, + vec![ + vec![ColumnValue::I32(1)], + vec![ColumnValue::I32(2)], + vec![ColumnValue::I32(10)], + ] + ); + } + + #[test] + fn the_sort_is_stable_for_rows_equal_on_every_key() { + // A backend may return meaningful order within a key group; the sort + // must not scramble it. + let mut rows = vec![ + vec![s("k"), s("first")], + vec![s("k"), s("second")], + vec![s("k"), s("third")], + ]; + sort_rows(&mut rows, &[0], SQL_NC_HIGH); + let tail: Vec<&ColumnValue> = rows.iter().map(|r| &r[1]).collect(); + assert_eq!(tail, vec![&s("first"), &s("second"), &s("third")]); + } +} diff --git a/src/column_value.rs b/src/column_value.rs new file mode 100644 index 0000000..fcc7c11 --- /dev/null +++ b/src/column_value.rs @@ -0,0 +1,9316 @@ +//! `write_column_value` marshals a [`crate::types::ColumnValue`] into an +//! application buffer for `SQLGetData` (NULL, truncation, type coercion). +//! +//! # The interval tables +//! +//! Two of the SQL-to-C pages are transcribed here in full rather than in a +//! module of their own, because they share every writer the rest of this file +//! already has: [SQL to C: Year-Month Intervals] and [SQL to C: Day-Time +//! Intervals]. `write_interval` is their first row, the C interval targets; +//! `write_interval_as_exact_numeric` is footnote \[b\]'s exact-numeric row; +//! and their character and binary rows are carve-outs in +//! `check_whole_digits_fit` and the `SQL_C_BINARY` arm, both of which differ +//! from the corresponding row of every other source. +//! +//! The two pages disagree with each other in one cell, "interval precision was +//! not a single field" against an exact numeric, which the year-month page +//! calls `22015` and the day-time page `07006`. Each is answered with its own +//! page's state; see `write_interval_as_exact_numeric`. +//! +//! [SQL to C: Year-Month Intervals]: https://learn.microsoft.com/en-us/sql/odbc/reference/appendixes/sql-to-c-year-month-intervals +//! [SQL to C: Day-Time Intervals]: https://learn.microsoft.com/en-us/sql/odbc/reference/appendixes/sql-to-c-day-time-intervals + +use std::ffi::{c_int, c_void}; + +use odbc_sys::{ + Date, DaySecond, Interval, IntervalStruct, IntervalUnion, NULL_DATA, Time, Timestamp, YearMonth, +}; + +use crate::errors::OdbcError; +use crate::param_convert::{DecimalLiteral, parse_numeric_literal}; +use crate::types::{ + CDataType, ColumnValue, NANOS_PER_DAY, NANOS_PER_HOUR, NANOS_PER_MINUTE, NANOS_PER_SECOND, + SqlReturn, SqlState, +}; + +// --------------------------------------------------------------------------- +// Core marshalling function +// --------------------------------------------------------------------------- + +/// Write a [`ColumnValue`] into a caller-provided C buffer. +/// +/// This is the core data marshalling for `SQLGetData`. Handles NULL values, +/// type conversion, truncation detection, and length/indicator reporting. +/// +/// # Arguments +/// - `value`: The column value to write +/// - `target_type`: The ODBC C data type the caller wants +/// - `target_ptr`: Pointer to the caller's buffer (may be null for length-only queries) +/// - `buf_len`: Buffer size in bytes +/// - `len_ind_ptr`: Output pointer for actual data length (bytes) or NULL_DATA (-1) +/// - `numeric`: the ARD's precision and scale, read only by `SQL_C_NUMERIC` +/// +/// # Returns +/// - `SqlReturn::SUCCESS` if the value was written completely +/// - `SqlReturn::SUCCESS_WITH_INFO` if the value was truncated (SQLSTATE 01004) +/// - `SqlReturn::ERROR` on invalid conversion +/// +/// # Safety +/// `target_ptr` and `len_ind_ptr` must be valid writable pointers (or null where documented). +pub unsafe fn write_column_value( + value: &ColumnValue, + target_type: CDataType, + target_ptr: *mut c_void, + buf_len: isize, + len_ind_ptr: *mut isize, + numeric: NumericTarget, +) -> Result { + unsafe { + write_column_value_at( + value, + target_type, + target_ptr, + buf_len, + len_ind_ptr, + 0, + numeric, + ) + } + .map(|w| w.ret) +} + +/// The ARD's `SQL_DESC_PRECISION` and `SQL_DESC_SCALE` for the column being +/// written, which only `SQL_C_NUMERIC` reads. +/// +/// The *SQL to C: Numeric* page is explicit that an application controls a +/// `SQL_NUMERIC_STRUCT`'s precision and scale through the descriptor: +/// "**SQLSetDescField** is required to perform manual binding with +/// SQL_C_NUMERIC values". So the conversion cannot be done from the +/// [`ColumnValue`] alone. +/// +/// A struct rather than two `i16` arguments, for the reason +/// `SQLForeignKeys`' argument list is a standing warning about in AGENTS.md: +/// two adjacent same-typed parameters can be crossed at a call site and still +/// compile, and crossing these two produces a struct describing a different +/// number. +/// +/// [`NumericTarget::UNSPECIFIED`] is what every caller but the bound-column +/// loop and `SQLGetData` passes, and what an ARD record that was never given +/// these fields yields. Zero is not a legal `SQL_NUMERIC_STRUCT` precision, so +/// it reads as "the application did not say" and the conversion derives both +/// from the value itself. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct NumericTarget { + /// `SQL_DESC_PRECISION`. `0` means unspecified. + pub precision: i16, + /// `SQL_DESC_SCALE`. + pub scale: i16, + /// `SQL_DESC_DATETIME_INTERVAL_PRECISION`, the *leading field precision* of + /// an interval target. `0` means unspecified. + /// + /// Read only by the interval C targets, where it is half of the two + /// interval tables' "leading precision of target is not big enough to hold + /// data from source" row; the other half is whether the leading field fits + /// its `SQLUINTEGER` at all. Zero reads as "the application did not say", + /// the same convention `precision` above uses and the same one + /// `numeric_convert::interval_from_exact` already applies to this field in + /// the C-to-SQL direction, so the two directions agree about an undeclared + /// precision. + /// + /// It rides here, in a struct named for the numeric targets, because this + /// and `precision` are read from the same ARD record at the same moment and + /// a third argument of the same type is what the doc comment above warns + /// against. + pub interval_leading_precision: i32, +} + +impl NumericTarget { + /// The application declared none of these fields; derive what is needed + /// from the value. + pub const UNSPECIFIED: Self = Self { + precision: 0, + scale: 0, + interval_leading_precision: 0, + }; +} + +/// What one marshalling call delivered, for `SQLGetData`'s chunking loop. +/// +/// Only `SQLGetData` needs this; the bound-column and `SQLParamData` paths call +/// [`write_column_value`], which discards it. +/// +/// The fields are crate-private and the type is `#[non_exhaustive]`, so a field +/// added here is a source-compatible change for every driver. Read a field back +/// through its accessor. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +#[non_exhaustive] +pub struct ChunkWrite { + pub(crate) ret: SqlReturn, + pub(crate) delivered: usize, + pub(crate) chunkable: bool, +} + +/// Field accessors for [`ChunkWrite`]. +/// +/// The fields themselves are crate-private: this type is `#[non_exhaustive]`, +/// and public fields would have made that advisory. Reading goes through these +/// instead. +impl ChunkWrite { + /// The value to return from the FFI function. + /// + /// No `#[must_use]`: [`SqlReturn`] already carries one, and clippy's + /// `double_must_use` rejects the pair. + pub fn ret(&self) -> SqlReturn { + self.ret + } + + /// Units delivered by this call: UTF-16 code units for `SQL_C_WCHAR`, + /// bytes for `SQL_C_CHAR` and `SQL_C_BINARY`, `0` for a fixed-width target. + /// The caller adds this to its running offset. + #[must_use] + pub fn delivered(&self) -> usize { + self.delivered + } + + /// Whether this target type can be read in parts at all. + /// + /// `false` for every fixed-width target, which the spec forbids chunking: + /// "SQLGetData cannot be used to return fixed-length data in parts. If + /// SQLGetData is called more than one time in a row for a column containing + /// fixed-length data, it returns SQL_NO_DATA for all calls after the first." + #[must_use] + pub fn chunkable(&self) -> bool { + self.chunkable + } +} + +/// [`write_column_value`], resuming `offset` units into the value. +/// +/// Character and binary targets are the only ones that can be read in parts, and +/// all three of them funnel through `write_wchar` / `write_char` / +/// `write_binary`, so the chunking is handled in one place here rather than +/// spread across the fixed-width arms below, none of which can chunk. +/// +/// # Safety +/// Same contract as [`write_column_value`]. +pub unsafe fn write_column_value_at( + value: &ColumnValue, + target_type: CDataType, + target_ptr: *mut c_void, + buf_len: isize, + len_ind_ptr: *mut isize, + offset: usize, + numeric: NumericTarget, +) -> Result { + unsafe { + write_fixed_or_chunked( + value, + target_type, + target_ptr, + buf_len, + len_ind_ptr, + offset, + numeric, + ) + } +} + +/// A chunkable value, converted once, for a `SQLGetData` read to drain in parts. +/// +/// # Why this exists +/// +/// `SQLGetData` is called repeatedly for one column, each call delivering the +/// next part. Asking the backend for the value and converting it again on every +/// call would drain an N-byte column through a K-byte buffer at O(N²/K): 128 +/// materialisations of 64 KiB to deliver 64 KiB through the 512-byte buffer a +/// driver manager may pick. The chunk size is the application's own buffer, so +/// nothing it could do would avoid the amplification. +/// +/// The variant records the shape the target C type needs, and the C type it was +/// built for is stored beside it, because an application may legally change +/// target type between parts, and that invalidates the conversion rather than +/// only the offset. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum CachedChunkSource { + /// UTF-16 code units, for `SQL_C_WCHAR`. + Utf16(Vec), + /// Bytes: UTF-8 for `SQL_C_CHAR`, the value's own bytes for `SQL_C_BINARY`. + /// Which writer applies is decided by the stored C type, since the character + /// one reserves a null terminator and the binary one does not. + Bytes(Vec), +} + +/// The chunk source for a value whose string or byte form **is** the value, or +/// `None` for every other combination. +/// +/// Narrow on purpose. `ColumnValue::String` and `ColumnValue::Bytes` are the +/// variants a data source makes long enough to chunk (a LOB), and they are the +/// two whose conversion is a borrow rather than a rendering. Everything else +/// keeps the uncached path unchanged, which matters for one reason beyond +/// caution: [`check_whole_digits_fit`] must be re-evaluated per call, because it +/// reads `buf_len`, and it applies only to numeric sources. Returning `None` +/// here for those keeps that check where it was. +pub(crate) fn cacheable_chunk_source( + value: &ColumnValue, + target_type: CDataType, +) -> Option { + match (value, target_type) { + (ColumnValue::String(s), CDataType::WChar) => { + let mut wide = Vec::with_capacity(s.len()); + wide.extend(s.encode_utf16()); + Some(CachedChunkSource::Utf16(wide)) + } + (ColumnValue::String(s), CDataType::Char) => { + Some(CachedChunkSource::Bytes(s.as_bytes().to_vec())) + } + (ColumnValue::Bytes(b), CDataType::Binary) => Some(CachedChunkSource::Bytes(b.clone())), + _ => None, + } +} + +/// Write one chunk from an already-converted source. +/// +/// The same three writers the uncached path uses, entered past their conversion +/// step, so the chunking contract is one implementation rather than two: the +/// indicator reporting bytes *remaining*, the terminator, and the +/// `SUCCESS_WITH_INFO` that marks "more to come". +/// +/// # Safety +/// +/// Same as [`write_column_value_at`]: `target_ptr` must be null or writable for +/// `buf_len` bytes, and `len_ind_ptr` null or a writable `isize`. +pub(crate) unsafe fn write_cached_chunk( + source: &CachedChunkSource, + target_type: CDataType, + target_ptr: *mut c_void, + buf_len: isize, + len_ind_ptr: *mut isize, + offset: usize, +) -> Result { + let (ret, delivered) = unsafe { + match (source, target_type) { + (CachedChunkSource::Utf16(units), _) => { + write_wchar_units(units, target_ptr, buf_len, len_ind_ptr, offset)? + } + (CachedChunkSource::Bytes(bytes), CDataType::Binary) => { + write_binary(bytes, target_ptr, buf_len, len_ind_ptr, offset)? + } + (CachedChunkSource::Bytes(bytes), _) => { + write_char_bytes(bytes, target_ptr, buf_len, len_ind_ptr, offset)? + } + } + }; + Ok(ChunkWrite { + ret, + delivered, + chunkable: true, + }) +} + +/// A non-chunkable outcome: the whole value in one call. +fn whole(ret: SqlReturn) -> ChunkWrite { + ChunkWrite { + ret, + delivered: 0, + chunkable: false, + } +} + +unsafe fn write_fixed_or_chunked( + value: &ColumnValue, + target_type: CDataType, + target_ptr: *mut c_void, + buf_len: isize, + len_ind_ptr: *mut isize, + offset: usize, + numeric: NumericTarget, +) -> Result { + // NULL handling + if matches!(value, ColumnValue::Null) { + if !len_ind_ptr.is_null() { + unsafe { std::ptr::write_unaligned(len_ind_ptr, NULL_DATA) }; + } + return Ok(whole(SqlReturn::SUCCESS)); + } + + // Default type: infer the natural C type from the ColumnValue variant + if target_type == CDataType::Default { + let inferred = match value { + ColumnValue::String(_) => CDataType::WChar, + ColumnValue::I8(_) => CDataType::STinyInt, + ColumnValue::I16(_) => CDataType::SShort, + ColumnValue::I32(_) => CDataType::SLong, + ColumnValue::I64(_) => CDataType::SBigInt, + ColumnValue::F32(_) => CDataType::Float, + ColumnValue::F64(_) => CDataType::Double, + ColumnValue::Bool(_) => CDataType::Bit, + ColumnValue::Date { .. } => CDataType::TypeDate, + ColumnValue::Time { .. } => CDataType::TypeTime, + ColumnValue::Timestamp { .. } => CDataType::TypeTimestamp, + // No SQL_C_TYPE_TIMESTAMP_TZ in ODBC, so map to TypeTimestamp + // (the offset is dropped). + ColumnValue::TimestampTz { .. } => CDataType::TypeTimestamp, + ColumnValue::Bytes(_) => CDataType::Binary, + // The spec's *Default C Data Types* table pairs `SQL_GUID` with + // `SQL_C_GUID`, not with `SQL_C_BINARY`. The distinction is not + // cosmetic even though both write sixteen bytes: `SQL_C_GUID` + // reassembles the first three groups as integers, so the + // `SQL_C_BINARY` reading byte-swaps them on every little-endian + // machine. An application that binds `SQL_C_DEFAULT` on a GUID + // column allocates a `SQLGUID` and reads `d1` from it, so the + // wrong choice here is a silently wrong value under `SQL_SUCCESS` + // rather than a diagnosable error. + ColumnValue::Guid(_) => CDataType::Guid, + // ColumnValue::Null is handled by the early return above and never + // reaches this match; it falls into the catch-all harmlessly. + // New complex variants: default to string serialization via WChar. + // The (_, CDataType::WChar) arm will call column_value_to_string. + _ => CDataType::WChar, + }; + + // For an explicitly named fixed C type the spec has the driver ignore + // BufferLength, because naming the type is itself a statement of the + // buffer's size. SQL_C_DEFAULT inverts that: the driver chooses, and it + // chooses from the runtime `ColumnValue` variant rather than from the + // `sql_type` that `SQLDescribeCol` reported and the application sized + // its buffer from. Nothing cross-checks those two, so a backend + // yielding a wider variant than it described would otherwise write past + // the application's buffer: 16 bytes of `Timestamp` into the four an + // application allocated for a declared `SQL_INTEGER`. + // + // A positive `buf_len` is the only evidence of the real buffer size + // core has here, so honour it. Zero is exempt: it is the idiomatic way + // to say "not applicable" for a fixed C type, so it carries no size + // information and cannot be used as a bound. Variable-length targets + // are not checked because `write_wchar` / `write_char` / `write_binary` + // already bound themselves by `buf_len`. + if let Some(needed) = default_target_width(inferred) + && buf_len > 0 + && buf_len < needed as isize + { + return Err(OdbcError::general( + format!( + "SQL_C_DEFAULT for {value:?} selects {inferred:?}, which needs {needed} bytes, \ + but the application supplied a {buf_len}-byte buffer" + ), + SqlState::restricted_data_type_attribute_violation(), + )); + } + + return unsafe { + write_fixed_or_chunked( + value, + inferred, + target_ptr, + buf_len, + len_ind_ptr, + offset, + numeric, + ) + }; + } + + // The three chunkable targets are handled here, ahead of the coercion match, + // because every character and binary conversion below funnelled into these + // same three writers, differing only in how they produce the string or + // byte form. Resuming at `offset` therefore belongs in one place rather + // than in each arm, and no fixed-width arm can chunk at all. + // + // For `ColumnValue::String` the string form *is* the value, which is why + // this borrows instead of going through `column_value_to_string` (that + // returns `s.clone()` for the `String` variant, so the two agree). + match target_type { + CDataType::WChar | CDataType::Char => { + let owned; + let s: &str = match value { + // Both variants hold the string form already, so this borrows + // instead of going through `column_value_to_string`, which + // returns `s.clone()` for each of them, so the two agree. That + // clone is a full copy of the value on every call, and for + // `Decimal` that is every numeric column an application binds + // as character data. + ColumnValue::String(s) | ColumnValue::Decimal(s) => s, + _ => { + owned = column_value_to_string(value); + &owned + } + }; + check_whole_digits_fit(value, s, target_type, target_ptr, buf_len)?; + let (ret, delivered) = unsafe { + if target_type == CDataType::WChar { + write_wchar(s, target_ptr, buf_len, len_ind_ptr, offset)? + } else { + write_char(s, target_ptr, buf_len, len_ind_ptr, offset)? + } + }; + return Ok(ChunkWrite { + ret, + delivered, + chunkable: true, + }); + } + CDataType::Binary => { + let bytes = column_value_to_binary(value); + // Both interval tables' `SQL_C_BINARY` row is a two-way split with + // no truncating outcome at all: "Byte length of data <= + // *BufferLength*" writes the data, and "> *BufferLength*" is + // *Undefined* with `22003`. That is unlike every other source, + // whose binary row truncates with `01004`, and unlike them an + // interval has no meaningful prefix: half a `SQL_INTERVAL_STRUCT` + // is not a shorter interval. Checked before the write so nothing + // is delivered, which is what "Undefined" requires. + if is_interval_source(value) && !target_ptr.is_null() && buf_len < bytes.len() as isize + { + return Err(OdbcError::general( + format!( + "{value:?} needs {} bytes as SQL_C_BINARY, but the application supplied \ + a {buf_len}-byte buffer", + bytes.len() + ), + SqlState::numeric_value_out_of_range(), + )); + } + let (ret, delivered) = + unsafe { write_binary(&bytes, target_ptr, buf_len, len_ind_ptr, offset)? }; + return Ok(ChunkWrite { + ret, + delivered, + chunkable: true, + }); + } + // `SQL_C_NUMERIC` sits here, beside the other targets that need the + // value's *text* rather than a numeric pivot, and ahead of the coercion + // match for the same reason they are: it reads the rendered decimal. + // Not chunkable: `SQL_NUMERIC_STRUCT` is fixed-width. + // + // An interval source is diverted before it gets here, because the two + // interval tables give `SQL_C_NUMERIC` a row of their own with a + // different failure set from the numeric table's. + CDataType::Numeric + if !matches!( + value, + ColumnValue::IntervalYearMonth { .. } | ColumnValue::IntervalDayTime { .. } + ) => + { + return unsafe { write_numeric(value, target_ptr, len_ind_ptr, numeric) }.map(whole); + } + // The interval C targets, both tables' first row. `SQL_C_INTERVAL_*` + // appears in no other conversion table, so a non-interval source asked + // for one falls through to the terminal `07006`. + CDataType::IntervalYear + | CDataType::IntervalMonth + | CDataType::IntervalDay + | CDataType::IntervalHour + | CDataType::IntervalMinute + | CDataType::IntervalSecond + | CDataType::IntervalYearToMonth + | CDataType::IntervalDayToHour + | CDataType::IntervalDayToMinute + | CDataType::IntervalDayToSecond + | CDataType::IntervalHourToMinute + | CDataType::IntervalHourToSecond + | CDataType::IntervalMinuteToSecond => { + if let Some(target) = interval_of_c_type(target_type) { + return unsafe { write_interval(value, target, target_ptr, len_ind_ptr, numeric) } + .map(whole); + } + } + // The *SQL to C: GUID* table's own row, and the only row that table + // gives for this C type: test "None", data written, indicator 16, no + // SQLSTATE. There is no failure case. + // + // Only `ColumnValue::Guid` reaches it. `SQL_C_GUID` appears in exactly + // one conversion table, whose single source type is `SQL_GUID`; the + // *SQL to C: Character* table has no `SQL_C_GUID` row, so a character + // column read as `SQL_C_GUID` is not a defined conversion and falls + // through to the `07006` the overview page prescribes for "an + // identifier for an ODBC C data type not shown in the table for a given + // ODBC SQL data type". Adding a text-parsing arm here with a `22018` + // for a bad parse would be inventing a cell the spec does not have. + CDataType::Guid => { + if let ColumnValue::Guid(bytes) = value { + // `SQLGUID`'s first three groups are integers whose textual + // form is the big-endian reading of the bytes, the same order + // `column_value_to_string` renders, where `data[0]` is the + // leading digit pair. Reading them natively would byte-swap the + // GUID on every little-endian machine, silently. + let out = odbc_sys::Guid { + d1: u32::from_be_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]), + d2: u16::from_be_bytes([bytes[4], bytes[5]]), + d3: u16::from_be_bytes([bytes[6], bytes[7]]), + d4: [ + bytes[8], bytes[9], bytes[10], bytes[11], bytes[12], bytes[13], bytes[14], + bytes[15], + ], + }; + return unsafe { write_fixed(target_ptr, len_ind_ptr, out) }.map(whole); + } + } + _ => {} + } + + // Type coercion: if the value doesn't match the requested type, convert + // through a string representation for string targets. + // + // SAFETY: All unsafe helper calls below operate on the same raw pointers + // passed by the caller, whose validity is guaranteed by the function's + // safety contract. + let fixed = match (value, target_type) { + // --- String to datetime C types --- + // Required by the ODBC conversion matrix: SQL_CHAR / SQL_VARCHAR + // convert to every C type. Backends whose data source has no native + // date type deliver datetimes as character data. + // + // Each of the three C structs accepts more than its own literal form, + // and the SQL to C: Character rows say which and at what cost. The + // cascades below are the same ones `param_convert`'s `to_date`, + // `to_time` and `to_timestamp` run in the C-to-SQL direction, over the + // same two parsers; only the SQLSTATE conventions differ, which is why + // they are not one function. + + // "Data value is a valid date-value" / "a valid timestamp-value; time + // portion is zero": data written, no SQLSTATE. "a valid + // timestamp-value; time portion is nonzero": truncated data written + // with 01S07, footnote [c]: "The time portion of the timestamp-value is + // truncated." Anything else is the row's 22018 with nothing written, or + // this module's 22007, which `parse_sql_timestamp`'s `?` propagates + // for a literal it recognises whose field is out of range. + // + // One branch covers all three: `parse_sql_timestamp` reads a bare + // `yyyy-mm-dd` as that date at midnight, so a date-value arrives here + // with a zero time portion and takes the clean path by construction. + (ColumnValue::String(s), CDataType::TypeDate) => { + let ts = parse_sql_timestamp(s)?; + let d = Date { + year: ts.year, + month: ts.month, + day: ts.day, + }; + unsafe { + let _ = write_fixed(target_ptr, len_ind_ptr, d)?; + } + // "Nonzero" is any of the four time fields, the fraction included. + if (ts.hour, ts.minute, ts.second, ts.fraction) != (0, 0, 0, 0) { + return Err(OdbcError::FractionalTruncation); + } + Ok(SqlReturn::SUCCESS) + } + + // "Data value is a valid time-value and the fractional seconds value is + // 0" / "a valid timestamp-value or a valid time-value; fractional + // seconds portion is zero": data written, no SQLSTATE, footnote [d]: + // "The date portion of the timestamp-value is ignored", so a discarded + // date is not a truncation and reports nothing. "a valid + // timestamp-value; fractional seconds portion is nonzero": truncated + // data written with 01S07, footnote [e]. Anything else is 22018. + (ColumnValue::String(s), CDataType::TypeTime) => { + let (t, fraction) = match parse_sql_time(s) { + Ok(parsed) => parsed, + // Load-bearing, not an optimisation: falling through on *every* + // error would hand "25:00:00" to `parse_sql_timestamp`, which + // does not recognise it as a datetime at all and answers 22018, + // discarding the 22007 this module gives a recognised literal + // with an out-of-range field. Deleting it fails + // `hour_that_overflows_u16_returns_22007_not_22018`, and only + // that test. The newer + // `timestamp_text_with_out_of_range_minute_to_time_stays_22007` + // takes the fall-through and gets its 22007 from the timestamp + // parser, so it stays green either way. + Err(e) if !is_wrong_literal_shape(&e) => return Err(e), + Err(_) => { + let ts = parse_sql_timestamp(s)?; + ( + Time { + hour: ts.hour, + minute: ts.minute, + second: ts.second, + }, + ts.fraction, + ) + } + }; + unsafe { + let _ = write_fixed(target_ptr, len_ind_ptr, t)?; + } + if fraction != 0 { + return Err(OdbcError::FractionalTruncation); + } + Ok(SqlReturn::SUCCESS) + } + + // "Data value is a valid timestamp-value or a valid time-value; + // fractional seconds portion not truncated" / "a valid date-value", + // footnote [f]: "The time fields of the timestamp structure are set to + // zero": both are what `parse_sql_timestamp` already produces. "a + // valid time-value", footnote [g]: "The date fields of the timestamp + // structure are set to the current date", which is the branch below. + // Anything else is 22018 with nothing written. + // + // Footnote [g] speaks only of the date fields, and the row above it + // makes a time-value's fractional seconds something that can be + // "truncated", so the literal's fraction is carried into the target's + // own fraction field rather than zeroed. That is the opposite of the + // typed `ColumnValue::Time` arm below, whose row (SQL to C: Time) says + // in as many words that the fraction is set to zero. Two source types, + // two tables, two answers. + // + // Known limitation, recorded rather than fixed: the "fractional seconds + // portion truncated" row's 01S07 is not reported. `parse_time_fields` + // truncates a literal carrying more than nine fractional digits to + // nanoseconds silently, on this path and on the timestamp-value path + // alike, so the two cannot disagree and the data still arrives; only + // the warning is missing. This is a ruling rather than an open + // intention: reporting it means deciding what a driver should do when + // the two paths disagree about precision, which is a larger question + // than the warning itself. + (ColumnValue::String(s), CDataType::TypeTimestamp) => { + let ts = match parse_sql_timestamp(s) { + Ok(ts) => ts, + // A local invariant, *not* load-bearing, unlike its counterpart + // in the arm above: no known input both fails + // `parse_sql_timestamp` with 22007 and parses as a time-value, + // and where neither form parses the terminal arm below already + // returns this same `e`. It states "only a 'this is not a + // timestamp at all' failure may try the time-value form" so a + // later change to either parser cannot quietly break it. + Err(e) if !is_wrong_literal_shape(&e) => return Err(e), + Err(e) => match parse_sql_time(s) { + Ok((t, fraction)) => { + let (year, month, day) = current_utc_date(); + Timestamp { + year, + month, + day, + hour: t.hour, + minute: t.minute, + second: t.second, + fraction, + } + } + // Neither form parsed. A 22007 from the time parser names a + // real defect in the text and is kept over the timestamp + // parser's blanket 22018. + Err(time_err) if !is_wrong_literal_shape(&time_err) => return Err(time_err), + Err(_) => return Err(e), + }, + }; + unsafe { write_fixed(target_ptr, len_ind_ptr, ts) } + } + + // --- Numeric coercion: any numeric source → any numeric C target --- + // ODBC requires drivers to support conversions between compatible numeric C types. + // Applications (e.g. LibreOffice Base) routinely request SQL_C_SLONG for columns + // that happen to hold i16 values, so all cross-type numeric casts must work. + // + // The pivot (column_value_as_numeric) maps every ColumnValue to Int(i64), Float(f64) + // or, for text bound for an integer target, exact decimal digits, none of which + // loses precision on the way in. write_numeric_pivot then narrows to the requested + // C type at the last possible moment. + // + // column_value_as_numeric uses an exhaustive match (no wildcard), so adding a new + // ColumnValue variant causes a compile error there, forcing an explicit decision. + // The two interval tables' exact-numeric row, ahead of the general + // numeric arm because its failure set is not the numeric table's. + // + // The target list is footnote [b]'s own and stops short of the numeric + // arm's: `SQL_C_FLOAT`, `SQL_C_DOUBLE` and `SQL_C_BIT` are absent from + // both interval pages, so an interval asked for one is not a defined + // conversion and falls to the terminal `07006` rather than being + // approximated. + ( + ColumnValue::IntervalYearMonth { .. } | ColumnValue::IntervalDayTime { .. }, + CDataType::STinyInt + | CDataType::UTinyInt + | CDataType::SShort + | CDataType::UShort + | CDataType::SLong + | CDataType::ULong + | CDataType::SBigInt + | CDataType::UBigInt + | CDataType::Numeric, + ) => unsafe { + write_interval_as_exact_numeric(value, target_type, target_ptr, len_ind_ptr, numeric) + }, + ( + _, + CDataType::STinyInt + | CDataType::SShort + | CDataType::SLong + | CDataType::SBigInt + | CDataType::UTinyInt + | CDataType::UShort + | CDataType::ULong + | CDataType::UBigInt + | CDataType::Float + | CDataType::Double + | CDataType::Bit, + ) => match column_value_as_numeric(value, target_type) { + Ok(pivot) => unsafe { + write_numeric_pivot(pivot, target_type, target_ptr, len_ind_ptr) + }, + // Text that should have been numeric but was not parseable. + Err(NumericPivotError::NotNumericLiteral) => Err(OdbcError::general( + format!("Invalid character value for cast: {value:?}"), + SqlState::invalid_character_value_for_cast(), + )), + // Numeric text of a magnitude no C target holds. Nothing is written + // and the length indicator is left alone, which is what the two + // "Undefined" cells of that row require. + Err(NumericPivotError::OutOfRange) => Err(OdbcError::general( + format!( + "Numeric value out of range: {value:?} exceeds the range of {target_type:?}" + ), + SqlState::numeric_value_out_of_range(), + )), + // The column value's type has no defined conversion to the + // requested C type (e.g. a Bytes/Guid/structured value asked + // to become a numeric target). Spec 07006: "The data value of + // a column in the result set could not be converted to the + // data type specified by the TargetType argument." + Err(NumericPivotError::NotNumericType) => Err(OdbcError::general( + format!("Unsupported conversion from {value:?} to {target_type:?}"), + SqlState::restricted_data_type_attribute_violation(), + )), + }, + + // --- Date --- + (ColumnValue::Date { year, month, day }, CDataType::TypeDate) => { + let ds = Date { + year: *year, + month: *month, + day: *day, + }; + unsafe { write_fixed(target_ptr, len_ind_ptr, ds) } + } + + // SQL_TYPE_DATE -> SQL_C_TYPE_TIMESTAMP. Legal per the spec's SQL-to-C + // table: "The driver sets the time fields of the timestamp structure to + // zero." No SQLSTATE, because nothing is lost. + (ColumnValue::Date { year, month, day }, CDataType::TypeTimestamp) => { + let ts = Timestamp { + year: *year, + month: *month, + day: *day, + hour: 0, + minute: 0, + second: 0, + fraction: 0, + }; + unsafe { write_fixed(target_ptr, len_ind_ptr, ts) } + } + + // --- Time --- + // SQL_TIME_STRUCT has no fraction field: write the whole-second parts, + // then report 01S07 if a non-zero fraction had to be dropped to fit. + ( + ColumnValue::Time { + hour, + minute, + second, + fraction, + }, + CDataType::TypeTime, + ) => { + let ts = Time { + hour: *hour, + minute: *minute, + second: *second, + }; + unsafe { + let _ = write_fixed(target_ptr, len_ind_ptr, ts)?; + } + if *fraction != 0 { + return Err(OdbcError::FractionalTruncation); + } + Ok(SqlReturn::SUCCESS) + } + + // SQL_TYPE_TIME -> SQL_C_TYPE_TIMESTAMP. Legal per the spec's SQL-to-C + // table: "The date fields of the timestamp structure are set to the + // current date, and the fractional seconds field of the timestamp + // structure is set to zero." + // + // The spec lists no SQLSTATE for this row, so a dropped fraction is not + // reported here, unlike the SQL_C_TYPE_TIME row above, where the target + // has nowhere to put one. Here the target *has* a fraction field and the + // spec still says to zero it, which makes it a defined part of the + // conversion rather than a truncation. + ( + ColumnValue::Time { + hour, + minute, + second, + .. + }, + CDataType::TypeTimestamp, + ) => { + let (year, month, day) = current_utc_date(); + let ts = Timestamp { + year, + month, + day, + hour: *hour, + minute: *minute, + second: *second, + fraction: 0, + }; + unsafe { write_fixed(target_ptr, len_ind_ptr, ts) } + } + + // --- Timestamp --- + ( + ColumnValue::Timestamp { + year, + month, + day, + hour, + minute, + second, + fraction, + }, + CDataType::TypeTimestamp, + ) => { + let ts = Timestamp { + year: *year, + month: *month, + day: *day, + hour: *hour, + minute: *minute, + second: *second, + fraction: *fraction, + }; + unsafe { write_fixed(target_ptr, len_ind_ptr, ts) } + } + + // SQL_TYPE_TIMESTAMP -> SQL_C_TYPE_DATE. Legal per the spec's SQL-to-C + // table, which splits on the time portion: zero is `n/a`, non-zero is + // `01S07` with "The time portion of the timestamp is truncated." + ( + ColumnValue::Timestamp { + year, + month, + day, + hour, + minute, + second, + fraction, + }, + CDataType::TypeDate, + ) => { + let ds = Date { + year: *year, + month: *month, + day: *day, + }; + unsafe { + let _ = write_fixed(target_ptr, len_ind_ptr, ds)?; + } + if *hour != 0 || *minute != 0 || *second != 0 || *fraction != 0 { + return Err(OdbcError::FractionalTruncation); + } + Ok(SqlReturn::SUCCESS) + } + + // SQL_TYPE_TIMESTAMP -> SQL_C_TYPE_TIME. Legal per the spec's SQL-to-C + // table: "The date portion of the timestamp is ignored", and the split + // is on the *fractional seconds* alone: a discarded date is not a + // truncation, so only a non-zero fraction reports `01S07`. + ( + ColumnValue::Timestamp { + hour, + minute, + second, + fraction, + .. + }, + CDataType::TypeTime, + ) => { + let ts = Time { + hour: *hour, + minute: *minute, + second: *second, + }; + unsafe { + let _ = write_fixed(target_ptr, len_ind_ptr, ts)?; + } + if *fraction != 0 { + return Err(OdbcError::FractionalTruncation); + } + Ok(SqlReturn::SUCCESS) + } + + // --- TimestampTz → TypeTimestamp --- + // SQL_TIMESTAMP_STRUCT has no timezone field, so the offset is discarded. + // A backend that normalizes to UTC before reaching this point returns + // ColumnValue::Timestamp instead, so this arm is a safety net for any + // backend that produces TimestampTz directly. + ( + ColumnValue::TimestampTz { + year, + month, + day, + hour, + minute, + second, + fraction, + .. + }, + CDataType::TypeTimestamp, + ) => { + let ts = Timestamp { + year: *year, + month: *month, + day: *day, + hour: *hour, + minute: *minute, + second: *second, + fraction: *fraction, + }; + unsafe { write_fixed(target_ptr, len_ind_ptr, ts) } + } + + // TimestampTz narrowed to SQL_C_TYPE_DATE / SQL_C_TYPE_TIME, by analogy + // with the `Timestamp` arms above rather than from the SQL-to-C table, + // which has no row for a zoned timestamp. Supporting only + // SQL_C_TYPE_TIMESTAMP for `TimestampTz` while `Timestamp` supports all + // three would leave the same hole this arm family was added to close: an + // application asking a zoned column for a plain date would get 07006 + // where an unzoned one succeeds. The offset is discarded, as it already + // is for SQL_C_TYPE_TIMESTAMP. + ( + ColumnValue::TimestampTz { + year, + month, + day, + hour, + minute, + second, + fraction, + .. + }, + CDataType::TypeDate, + ) => { + let ds = Date { + year: *year, + month: *month, + day: *day, + }; + unsafe { + let _ = write_fixed(target_ptr, len_ind_ptr, ds)?; + } + if *hour != 0 || *minute != 0 || *second != 0 || *fraction != 0 { + return Err(OdbcError::FractionalTruncation); + } + Ok(SqlReturn::SUCCESS) + } + ( + ColumnValue::TimestampTz { + hour, + minute, + second, + fraction, + .. + }, + CDataType::TypeTime, + ) => { + let ts = Time { + hour: *hour, + minute: *minute, + second: *second, + }; + unsafe { + let _ = write_fixed(target_ptr, len_ind_ptr, ts)?; + } + if *fraction != 0 { + return Err(OdbcError::FractionalTruncation); + } + Ok(SqlReturn::SUCCESS) + } + + // The `Binary`, `WChar` and `Char` catch-alls sit ahead of this match, + // where the chunking offset is applied, so every target reaching here + // is fixed-width. + + // Unsupported conversion. Spec 07006: "The data value of a column in + // the result set could not be converted to the data type specified + // by the TargetType argument." + _ => Err(OdbcError::general( + format!( + "Unsupported conversion from {:?} to {:?}", + value, target_type + ), + SqlState::restricted_data_type_attribute_violation(), + )), + }; + + fixed.map(whole) +} + +// --------------------------------------------------------------------------- +// Helper: today's date, for SQL_TYPE_TIME -> SQL_C_TYPE_TIMESTAMP +// --------------------------------------------------------------------------- + +/// Convert a count of days since 1970-01-01 to a proleptic Gregorian date. +/// +/// Howard Hinnant's `civil_from_days`, which is exact for the whole range this +/// can produce and needs no calendar dependency. Kept separate from +/// [`current_utc_date`] so the arithmetic can be tested against known dates +/// without a clock in the way. +fn civil_from_days(days: i64) -> (i64, u16, u16) { + // Shift the epoch to 0000-03-01, which puts the leap day at the end of the + // 400-year era and makes the month arithmetic below branch-free. + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; // [0, 146_096] + let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365; // [0, 399] + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] + let mp = (5 * doy + 2) / 153; // [0, 11], March = 0 + let day = (doy - (153 * mp + 2) / 5 + 1) as u16; // [1, 31] + let month = if mp < 10 { mp + 3 } else { mp - 9 } as u16; // [1, 12] + let year = yoe as i64 + era * 400 + i64::from(month <= 2); + (year, month, day) +} + +/// Today's date in UTC. +/// +/// The `SQL_TYPE_TIME` -> `SQL_C_TYPE_TIMESTAMP` conversion requires it: "The +/// date fields of the timestamp structure are set to the current date, and the +/// fractional seconds field of the timestamp structure is set to zero." +/// +/// UTC, not local time. The spec says "the current date" without saying whose, +/// and the standard library offers no timezone database, so local time is not +/// implementable here without a dependency. UTC is at least well defined and +/// the same for every driver built on this crate. +/// +/// This is the only wall-clock read in the crate, and it makes +/// [`write_column_value`] impure for exactly one `(value, target_type)` pair. +/// `clippy.toml` disallows `SystemTime::now` so that a second one has to be +/// argued for rather than appearing by accident. +/// +/// A clock set before 1970 is reported truthfully rather than clamped: +/// `duration_since` fails in that case, but the error carries the distance +/// backwards, so the date is still recoverable. There is no SQLSTATE for "no +/// clock" and the conversion owes a date either way, so the only alternative +/// would be to substitute one, and a wrong date presented as correct is worse +/// than an unusual one. +// The single sanctioned wall-clock read in the crate. `clippy.toml` disallows +// `SystemTime::now` so that a second one has to be argued for rather than +// appearing by accident; this one is forced by the spec sentence quoted above, +// which cannot be satisfied from the column value alone. +#[allow( + clippy::disallowed_methods, + reason = "SQL_TYPE_TIME -> SQL_C_TYPE_TIMESTAMP is specified as using the current date" +)] +pub(crate) fn current_utc_date() -> (i16, u16, u16) { + // `try_from` rather than `as`: a clock far enough out to exceed i64 seconds + // is nonsense either way, but wrapping it into a negative would turn a date + // in the far future into one in the distant past. + let secs = match std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH) { + Ok(since_epoch) => i64::try_from(since_epoch.as_secs()).unwrap_or(i64::MAX), + // Before 1970. The error carries how far back, so negate it rather than + // discarding it and claiming 1970-01-01. + Err(before_epoch) => -i64::try_from(before_epoch.duration().as_secs()).unwrap_or(i64::MAX), + }; + let (year, month, day) = civil_from_days(secs.div_euclid(86_400)); + // `SQL_TIMESTAMP_STRUCT::year` is an i16, so a year outside it cannot be + // represented at all; saturate rather than wrap into a plausible-looking + // one. Unreachable for any clock that is merely wrong rather than absurd. + (i16::try_from(year).unwrap_or(i16::MAX), month, day) +} + +// --------------------------------------------------------------------------- +// Helper: write a fixed-size value to a raw pointer +// --------------------------------------------------------------------------- + +/// How many bytes `write_fixed` will write for a C type that +/// [`write_column_value`]'s `SQL_C_DEFAULT` inference can select, or `None` for +/// the variable-length targets, which bound themselves by `buf_len`. +/// +/// Covers only the types that inference can produce. A wider match would invite +/// the impression that this is a general size table for `CDataType`, which it is +/// not: it exists solely to bound the one path where the driver, not the +/// application, picks the C type. +fn default_target_width(c_type: CDataType) -> Option { + Some(match c_type { + CDataType::Bit | CDataType::STinyInt => 1, + CDataType::SShort => 2, + CDataType::SLong | CDataType::Float => 4, + CDataType::SBigInt | CDataType::Double => 8, + CDataType::TypeDate => size_of::(), + CDataType::TypeTime => size_of::