From b3e537cd2a9db563b6665ed9420e4c54f7d26472 Mon Sep 17 00:00:00 2001 From: Malte Sander Date: Fri, 24 Jul 2026 20:13:47 +0200 Subject: [PATCH 001/386] chore: scaffold standalone stackable-odbc-core repository Extract the database-independent ODBC framework from the stackable-odbc-rs monorepo into its own releasable crate. - Move the core src/ tree, the Criterion bench, and the two cargo-fuzz targets. - De-workspace Cargo.toml into a plain [package] with inline clippy lints; add crates.io metadata (description, repository, readme, keywords, categories, rust-version) and reset the version to 0.0.1. - Decouple dependencies: only snafu, tracing, odbc-sys, tracing-subscriber and tracing-appender remain; no driver crates leak in. - Point the fuzz crate's path dependency at the repo root (..). - Trim deny.toml (drop the Trino-only advisory ignore, the ring clarify, and the Windows target) and .gitignore (drop driver-only entries). - Add cargo-sort plus check-yaml/check-toml/check-merge-conflict/mixed-line-ending to pre-commit; drop shellcheck. - Reduce CI to unit-tests + Miri + a cargo-fuzz smoke job; drop the SQLite, Windows and driver jobs. - Make the docs core-specific: partition AGENTS.md to be driver-agnostic, rewrite README.md, add a Keep a Changelog CHANGELOG.md. Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/workflows/build.yaml | 132 + .github/workflows/pr_pre-commit.yaml | 39 + .github/workflows/security_audit.yaml | 24 + .gitignore | 12 + .markdownlint.yaml | 28 + .pre-commit-config.yaml | 64 + AGENTS.md | 410 +++ CHANGELOG.md | 16 + CLAUDE.md | 16 + Cargo.lock | 1134 ++++++++ Cargo.toml | 32 + README.md | 86 + benches/fetch_throughput.rs | 298 +++ clippy.toml | 2 + deny.toml | 37 + fuzz/.gitignore | 5 + fuzz/Cargo.toml | 31 + fuzz/README.md | 38 + fuzz/fuzz_targets/column_value.rs | 141 + fuzz/fuzz_targets/utf16.rs | 44 + rust-toolchain.toml | 3 + rustfmt.toml | 6 + src/backend.rs | 629 +++++ src/column_value.rs | 3404 +++++++++++++++++++++++++ src/conformance.rs | 361 +++ src/diagnostics.rs | 97 + src/errors.rs | 149 ++ src/escape.rs | 481 ++++ src/ffi/bind.rs | 249 ++ src/ffi/connect.rs | 1462 +++++++++++ src/ffi/connect_attr.rs | 875 +++++++ src/ffi/cursor.rs | 1345 ++++++++++ src/ffi/diag.rs | 1093 ++++++++ src/ffi/env.rs | 482 ++++ src/ffi/execute.rs | 784 ++++++ src/ffi/fetch.rs | 558 ++++ src/ffi/handle.rs | 649 +++++ src/ffi/info.rs | 1006 ++++++++ src/ffi/metadata.rs | 2300 +++++++++++++++++ src/ffi/mod.rs | 19 + src/ffi/params.rs | 2008 +++++++++++++++ src/ffi/setup.rs | 261 ++ src/ffi/stmt_attr.rs | 944 +++++++ src/ffi/tran.rs | 269 ++ src/forward_ffi.rs | 1161 +++++++++ src/function_id.rs | 157 ++ src/handles.rs | 775 ++++++ src/lib.rs | 72 + src/logging.rs | 78 + src/panic.rs | 178 ++ src/synthetic.rs | 175 ++ src/test_utils.rs | 104 + src/types/col_attr.rs | 1409 ++++++++++ src/types/column_size.rs | 340 +++ src/types/connect_params.rs | 411 +++ src/types/constants.rs | 888 +++++++ src/types/conversions.rs | 1009 ++++++++ src/types/info_type_shape.rs | 239 ++ src/types/mod.rs | 86 + src/types/redacted.rs | 14 + src/types/result_cols.rs | 699 +++++ src/types/sql_state.rs | 373 +++ src/types/value.rs | 372 +++ src/types/version.rs | 164 ++ src/utf16.rs | 231 ++ 65 files changed, 30958 insertions(+) create mode 100644 .github/workflows/build.yaml create mode 100644 .github/workflows/pr_pre-commit.yaml create mode 100644 .github/workflows/security_audit.yaml create mode 100644 .gitignore create mode 100644 .markdownlint.yaml create mode 100644 .pre-commit-config.yaml create mode 100644 AGENTS.md create mode 100644 CHANGELOG.md create mode 100644 CLAUDE.md create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100644 README.md create mode 100644 benches/fetch_throughput.rs create mode 100644 clippy.toml create mode 100644 deny.toml create mode 100644 fuzz/.gitignore create mode 100644 fuzz/Cargo.toml create mode 100644 fuzz/README.md create mode 100644 fuzz/fuzz_targets/column_value.rs create mode 100644 fuzz/fuzz_targets/utf16.rs create mode 100644 rust-toolchain.toml create mode 100644 rustfmt.toml create mode 100644 src/backend.rs create mode 100644 src/column_value.rs create mode 100644 src/conformance.rs create mode 100644 src/diagnostics.rs create mode 100644 src/errors.rs create mode 100644 src/escape.rs create mode 100644 src/ffi/bind.rs create mode 100644 src/ffi/connect.rs create mode 100644 src/ffi/connect_attr.rs create mode 100644 src/ffi/cursor.rs create mode 100644 src/ffi/diag.rs create mode 100644 src/ffi/env.rs create mode 100644 src/ffi/execute.rs create mode 100644 src/ffi/fetch.rs create mode 100644 src/ffi/handle.rs create mode 100644 src/ffi/info.rs create mode 100644 src/ffi/metadata.rs create mode 100644 src/ffi/mod.rs create mode 100644 src/ffi/params.rs create mode 100644 src/ffi/setup.rs create mode 100644 src/ffi/stmt_attr.rs create mode 100644 src/ffi/tran.rs create mode 100644 src/forward_ffi.rs create mode 100644 src/function_id.rs create mode 100644 src/handles.rs create mode 100644 src/lib.rs create mode 100644 src/logging.rs create mode 100644 src/panic.rs create mode 100644 src/synthetic.rs create mode 100644 src/test_utils.rs create mode 100644 src/types/col_attr.rs create mode 100644 src/types/column_size.rs create mode 100644 src/types/connect_params.rs create mode 100644 src/types/constants.rs create mode 100644 src/types/conversions.rs create mode 100644 src/types/info_type_shape.rs create mode 100644 src/types/mod.rs create mode 100644 src/types/redacted.rs create mode 100644 src/types/result_cols.rs create mode 100644 src/types/sql_state.rs create mode 100644 src/types/value.rs create mode 100644 src/types/version.rs create mode 100644 src/utf16.rs diff --git a/.github/workflows/build.yaml b/.github/workflows/build.yaml new file mode 100644 index 0000000..5f4680d --- /dev/null +++ b/.github/workflows/build.yaml @@ -0,0 +1,132 @@ +--- +name: Build and Test + +permissions: + contents: read + +on: + push: + branches: + - main + pull_request: + merge_group: + +env: + CARGO_TERM_COLOR: always + RUST_TOOLCHAIN_VERSION: "1.95.0" + +jobs: + unit-tests: + name: Unit Tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install Rust ${{ env.RUST_TOOLCHAIN_VERSION }} toolchain + uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b + with: + toolchain: ${{ env.RUST_TOOLCHAIN_VERSION }} + + - name: Setup Rust Cache + uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 + + - name: Run unit tests + run: cargo test + + miri: + name: Miri (undefined behaviour + leaks) + runs-on: ubuntu-latest + needs: [unit-tests] + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + token: ${{ secrets.GITHUB_TOKEN }} + + # 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 + 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 -p stackable-odbc-core --lib -- --skip proptest + + fuzz: + name: Fuzz (ASAN smoke) + runs-on: ubuntu-latest + needs: [unit-tests] + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + token: ${{ secrets.GITHUB_TOKEN }} + + # 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 + with: + toolchain: nightly + + - name: Setup Rust Cache + uses: Swatinem/rust-cache@98c8021b550208e191a6a3145459bfc9fb29c4c0 # v2.8.0 + with: + key: fuzz + + - 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. + - name: Fuzz utf16 + run: cargo +nightly fuzz run utf16 -- -max_total_time=30 + - name: Fuzz column_value + run: cargo +nightly fuzz run column_value -- -max_total_time=30 + + # Single required check for branch protection rules. + finished: + name: Finished Build and Test + if: always() + needs: + - unit-tests + - miri + - fuzz + runs-on: ubuntu-latest + steps: + - name: Check job results + run: | + if [[ "${{ needs.unit-tests.result }}" != "success" ]] || + [[ "${{ needs.miri.result }}" != "success" ]] || + [[ "${{ needs.fuzz.result }}" != "success" ]]; then + echo "One or more jobs failed" + exit 1 + fi + echo "All jobs passed" diff --git a/.github/workflows/pr_pre-commit.yaml b/.github/workflows/pr_pre-commit.yaml new file mode 100644 index 0000000..2603c8d --- /dev/null +++ b/.github/workflows/pr_pre-commit.yaml @@ -0,0 +1,39 @@ +--- +name: pre-commit + +on: + pull_request: + merge_group: + +env: + CARGO_TERM_COLOR: always + RUST_TOOLCHAIN_VERSION: "1.95.0" + +jobs: + pre-commit: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 + with: + persist-credentials: false + token: ${{ secrets.GITHUB_TOKEN }} + + - name: Install Rust ${{ env.RUST_TOOLCHAIN_VERSION }} toolchain + uses: dtolnay/rust-toolchain@b3b07ba8b418998c39fb20f53e8b695cdcc8de1b + 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 diff --git a/.github/workflows/security_audit.yaml b/.github/workflows/security_audit.yaml new file mode 100644 index 0000000..3f04918 --- /dev/null +++ b/.github/workflows/security_audit.yaml @@ -0,0 +1,24 @@ +--- +name: Daily Security Audit + +on: + schedule: + # Run every day at 04:15 UTC: https://crontab.guru/#15_4_*_*_* + - cron: '15 4 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + audit: + runs-on: ubuntu-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..5a61c79 --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +debug/ +target/ +**/*.rs.bk +.worktrees/ + +.idea/ +*.iws +*.iml +.vscode/ + +# 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..ccedb18 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,64 @@ +--- +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 + 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 --all-targets -- -D warnings + stages: [pre-commit, pre-merge-commit] + pass_filenames: false + files: \.rs$ + + - 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 check + stages: [pre-commit, pre-merge-commit] + pass_filenames: false + files: Cargo\.(toml|lock)|deny\.toml diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..57f63bd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,410 @@ +# Agent Guide + +Implementation details for AI agents working on `stackable-odbc-core`. + +`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 therefore describes the +framework itself and, where relevant, how a downstream driver crate consumes it. + +## Quick Reference + +| Topic | When to Read | +|-------|-------------| +| [Adding a new ODBC function](#adding-a-new-odbc-function) | Implementing or moving a function from stubs | +| [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 | +| [Adding a new driver](#adding-a-new-driver) | Creating a new backend crate on top of core | +| [Testing](#testing) | Writing tests, or running Miri / fuzz | +| [Architecture](#architecture) | Understanding call flow or crate layout | + +## 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 (required for tag-based validation) +- `extern "system"` on all FFI exports (resolves to correct ABI on both Windows and Linux) + +### 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. Because core is a published +library consumed by driver crates, 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 raw integers to enums: +// 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); + +// 2. WARN: intentional spec deviations (silent accepts, ignored features) +tracing::warn!("SQLFunctionW: accepting unrecognized X (DM compatibility)"); + +// 3. DEBUG: return value — always; requires 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. + +### 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 (e.g., `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 — 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 })`. + +### Backend error mapping + +Core never talks to a database; it only defines the trait boundary. A driver, +however, must route **every** error from its client library through a single +central mapping function — never hand-build an `OdbcError` at the call site. That +function is the one place that decides the SQLSTATE; bypassing it silently +degrades specific codes to `HY000`. + +Hand-built errors are correct only for *internal* invariant violations that +never came from the client (e.g. "get_data called 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") — that is the code the diagnostics tables of +`SQLExecute`, `SQLFetch`, `SQLGetInfo` and the rest actually list. A driver +whose `connect` performs no network I/O will only ever see post-connection +failures, and should map them to `08S01`; a driver that opens a real connection +in `connect` is where `08001` legitimately originates. + +## Adding a new ODBC function + +1. **Read the ODBC spec first.** Every function has a spec page at `https://learn.microsoft.com/en-us/sql/odbc/reference/syntax/-function?view=sql-server-ver17` (e.g. `sqlallochandle-function`). Read it in detail before writing any code. +2. **Implement every check and constraint** from the spec. This includes: + - 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 (e.g. "cannot call X before Y") + - Setting output parameters to defined values on error (e.g. `*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: + ``` + +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` — all drivers pick it up automatically. + +## odbc-sys usage + +`odbc-sys` is a minimal `-sys` crate for ODBC type definitions. It deliberately has no convenience methods (see [PR #47](https://github.com/pacman82/odbc-sys/pull/47) for rationale). `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`, etc. For primitive parameters where odbc-sys 0.29 removed the type aliases (e.g. the old `SmallInt`), use the Rust primitives directly (`i16`, `u16`, `i32`). +- **Never redefine** enums, structs, or constants that `odbc-sys` already provides. Before defining a new constant or enum, check `odbc-sys` first. If it's there, use it. +- **Add driver-side extensions** in `stackable-odbc-core` -- since orphan rules prevent `impl TryFrom for odbc_sys::HandleType`, use standalone conversion functions like `fn handle_type_from_raw(v: i16) -> Option` +- **Keep our own types** only for things `odbc-sys` doesn't 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` etc. -- never raw numeric IDs. 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` +- `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 a `xxx_from_raw` function following the same pattern. Do not use `transmute`. + +## 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` needed. + +### Windows Driver Manager compatibility checklist + +The Windows DM is much stricter than unixODBC. These items are **required** for +a driver to work on Windows — omitting any one can cause silent crashes, +`IM001` errors, or blocked `SQLGetData` calls: + +- **`get_info_pre_connect`**: Override this in your `Backend` impl. The Windows DM + queries `SQL_DRIVER_ODBC_VER` (77) *before* `SQLDriverConnectW`. If it gets + `SQL_ERROR`, the DM treats the driver as ODBC 2.x and blocks 3.x features + like `SQL_C_SBIGINT`. At minimum, return `DriverOdbcVer`, `DriverName`, + `DriverVer`, `AsyncDbcFunctions`, and `MaxConcurrentActivities`. Delegate to + the same handler as the connected path so the two never drift. + +- **`get_functions`**: List **every** exported FFI function, not just + query-related ones. The Windows DM uses the 3.x bitmap (`func_id=999`) to + build its dispatch table. Missing entries (e.g. `SetEnvAttr`, `GetStmtAttr`, + `BindCol`) cause NULL function pointer crashes. The 2.x array (`func_id=0`) + also needs correct entries — `stackable-odbc-core` maps 3.x IDs to their deprecated + 2.x equivalents automatically, but only for IDs present in the list. + +- **`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: `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 (returning `SQL_ERROR` corrupts the DM's internal state). + For `SQL_CONVERT_*` info types (48–73) specifically, it returns `0xFFFFFFFF` + ("all conversions supported") — returning 0 causes the DM to block + `SQLGetData` with `HYC00`. + +## Testing + +### 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 +``` + +Takes about 35 seconds. Notes: + +- **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 Miri earns its keep. +- **Proptests are skipped** — they take hours under Miri. They run on stable. +- **Leak reporting is deliberately left on.** It is what catches a handle or + descriptor allocation that a teardown path forgets to free. If you add a + test that allocates handles, it must free them or the job goes red. +- Writes through application-supplied pointers must use `write_unaligned` / + byte-wise copies. ODBC applications using row-wise binding pass pointers at + arbitrary offsets into a packed buffer, so alignment is never guaranteed. + +### 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 (libFuzzer needs nightly), so the root build ignores it. A short smoke +run of both targets also 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. + +### Unit tests + +- `stackable-odbc-core` tests use a shared `MockBackend` from `test_utils.rs` + (connect/disconnect succeed, everything else returns `NotImplemented`), plus + `MockFailBackend` for error paths. +- Run `cargo test` — must produce zero warnings. +- **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/parameter buffers, which Rust handles + cleanly without any external dependencies. +- A driver crate tests its `Backend` impl directly and adds FFI-level + integration tests that call the generated C ABI entry points; those live in + the driver's repository, not here. + +### Benchmarks + +Core has a Criterion fetch-throughput benchmark in `benches/fetch_throughput.rs` +(in-memory, no backend): + +```bash +cargo bench +``` + +`BENCH_ROWS` overrides the row count. + +## Architecture + +The project uses a generic `Backend` trait in `stackable-odbc-core` with manual +forwarding stubs generated in each driver crate. This was chosen over +proc-macros (premature) and `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() wrapper # catches panics, manages diagnostics + -> as_handle_ref::() # validates handle tag, returns typed &mut + -> handle.diagnostics.clear() # spec: clear diagnostics at start of each call + -> validation checks # 08002, HY090 -- per 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 + -> B::connect(¶ms) # Backend trait method (database-specific) + -> 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/statements) and `StatementBackend` (iterates results). Split to separate lifecycle from cursor operations. +- **Handle tags**: Every handle has a `#[repr(C)] HandleHeader { tag: u32 }` as its first field. `as_handle_ref()` checks the tag before casting raw pointers. This is the primary safety mechanism at the FFI boundary. +- **`panic_safe`**: Wraps every FFI function. Uses `AssertUnwindSafe` + `catch_unwind`. On error, pushes to the handle's diagnostic queue and returns the appropriate `SqlReturn`. +- **W-only exports**: Only Unicode (W-suffix) ODBC functions are exported. The Driver Manager translates ANSI calls automatically. +- **No async in the trait**: `Backend` is synchronous. A driver that wraps an async client library is expected to bridge to it internally (e.g. a current-thread tokio runtime + `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` — five-character ODBC diagnostic code and factory methods | +| `types/value.rs` | `ColumnValue`, `FetchResult`, `Nullable`, `TypeInfoRow`, `ColumnDescriptor` | +| `types/result_cols.rs` | `TablesResultCol`, `ColumnsResultCol`, `PrimaryKeysResultCol`, `ForeignKeysResultCol` | +| `types/connect_params.rs` | `ConnectParams` — ODBC connection string parser | +| `types/col_attr.rs` | `ColAttrValue` and column attribute logic for `SQLColAttributeW` | +| `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/redacted.rs` | `Redacted` — `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) | +| `synthetic.rs` | `SyntheticStatement` — in-memory result set for `SQLGetTypeInfo` and catalog functions | +| `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 | +| `diagnostics.rs` | Per-handle diagnostic queue (`SQLGetDiagRecW` reads from here) | +| `handles.rs` | `EnvironmentHandle`, `ConnectionHandle`, `StatementHandle`, alloc/free, tag validation | +| `utf16.rs` | `utf16_to_string`, `write_utf16` (ODBC uses UTF-16LE) | +| `panic.rs` | `panic_safe` wrapper | +| `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 | +| `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_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/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 73 C ABI entry points for a backend | +| `test_utils.rs` | Shared test infrastructure (`MockBackend`, `MockFailBackend`) | + +### 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)` — generates all 73 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 | diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..c38a7f1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,16 @@ +# 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] + +### Added + +- Initial extraction of `stackable-odbc-core` into its own repository. + Provides the database-independent ODBC framework: + protocol logic, handle allocation and tag validation, UTF-16 marshalling, + diagnostics, panic safety, and the generic implementations of the ODBC FFI + entry points, exported for a concrete backend via the `forward_ffi!` macro. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..d6f2cf3 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,16 @@ +# Project Rules + +Read and follow @AGENTS.md — it contains architecture, patterns, and 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, not the driver; do not add driver-side checks for DM-only codes. Every FFI function's doc comment must list all SQLSTATEs from the spec diagnostics table — for each one, note whether the driver returns it or why not (e.g., "(driver-manager-handled; not returned here)"). When touching 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. +- **Use `odbc-sys` types** — never redefine enums, structs, or constants it already provides. +- **Convert raw integers to typed enums at the FFI boundary** — use `xxx_from_raw()` functions, never `transmute`. +- **Run `pre-commit run --all-files`** before every commit. This 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. diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..f87fde9 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1134 @@ +# 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 = "alloca" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5a7d05ea6aea7e9e64d25b9156ba2fee3fdd659e34e41063cd2fc7cd020d7f4" +dependencies = [ + "cc", +] + +[[package]] +name = "anes" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "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 = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "cast" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" + +[[package]] +name = "cc" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5add81bb678e6cb321aff7fa0dc7689ad82b112dbc032cea19f91d6b8e3582b9" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "ciborium" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42e69ffd6f0917f5c029256a24d0161db17cea3997d185db0d35926308770f0e" +dependencies = [ + "ciborium-io", + "ciborium-ll", + "serde", +] + +[[package]] +name = "ciborium-io" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05afea1e0a06c9be33d539b876f1ce3692f4afea2cb41f740e7743225ed1c757" + +[[package]] +name = "ciborium-ll" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57663b653d948a338bfb3eeba9bb2fd5fcfaecb9e199e87e1eda4d9e8b240fd9" +dependencies = [ + "ciborium-io", + "half", +] + +[[package]] +name = "clap" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d91e0c145792ef73a6ad36d27c75ac09f1832222a3c209689d90f534685ee5b7" +dependencies = [ + "clap_builder", +] + +[[package]] +name = "clap_builder" +version = "4.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f09628afdcc538b57f3c6341e9c8e9970f18e4a481690a64974d7023bd33548b" +dependencies = [ + "anstyle", + "clap_lex", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "criterion" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "950046b2aa2492f9a536f5f4f9a3de7b9e2476e575e05bd6c333371add4d98f3" +dependencies = [ + "alloca", + "anes", + "cast", + "ciborium", + "clap", + "criterion-plot", + "itertools", + "num-traits", + "oorandom", + "page_size", + "plotters", + "rayon", + "regex", + "serde", + "serde_json", + "tinytemplate", + "walkdir", +] + +[[package]] +name = "criterion-plot" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8d80a2f4f5b554395e47b5d8305bc3d27813bacb73493eb1001e8f76dae29ea" +dependencies = [ + "cast", + "itertools", +] + +[[package]] +name = "crossbeam-channel" +version = "0.5.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-deque" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-epoch" +version = "0.9.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +dependencies = [ + "crossbeam-utils", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61803da095bee82a81bb1a452ecc25d3b2f1416d1897eb86430c6159ef717c17" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "deranged" +version = "0.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[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 = "futures-core" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" + +[[package]] +name = "futures-task" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" + +[[package]] +name = "futures-util" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + +[[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 = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "itertools" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53b44bfcdb3f8d5837a46dae1ca9660a837176eee74a28b229bc626816589102" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "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 = "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 = "oorandom" +version = "11.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6790f58c7ff633d8771f42965289203411a5e5c68388703c06e14f24770b41e" + +[[package]] +name = "page_size" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30d5b2194ed13191c1999ae0704b7839fb18384fa22e49b57eeaa97d79ce40da" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "plotters" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aeb6f403d7a4911efb1e33402027fc44f29b5bf6def3effcc22d7bb75f2b747" +dependencies = [ + "num-traits", + "plotters-backend", + "plotters-svg", + "wasm-bindgen", + "web-sys", +] + +[[package]] +name = "plotters-backend" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df42e13c12958a16b3f7f4386b9ab1f3e7933914ecea48da7139435263a4172a" + +[[package]] +name = "plotters-svg" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51bae2ac328883f7acdfea3d66a7c35751187f870bc81f94563733a154d7a670" +dependencies = [ + "plotters-backend", +] + +[[package]] +name = "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 = "rayon" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" +dependencies = [ + "either", + "rayon-core", +] + +[[package]] +name = "rayon-core" +version = "1.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" +dependencies = [ + "crossbeam-deque", + "crossbeam-utils", +] + +[[package]] +name = "regex" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fcfdb36bda0c880c5931cdc7a2bcdc8ba4556847b9d912bca70bc94708711ad" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "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 = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.3", +] + +[[package]] +name = "serde_json" +version = "1.0.151" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "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 = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" + +[[package]] +name = "snafu" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e45cb604038abb7b926b679887b3226d8d0f23874b66623625a0454be425a4b7" +dependencies = [ + "snafu-derive", +] + +[[package]] +name = "snafu-derive" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "287f59010008f0d7cf5e3b03196d666c1acc46c8d3e9cf34c28a1a7157601e72" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "stackable-odbc-core" +version = "0.0.1" +dependencies = [ + "criterion", + "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 = "tinytemplate" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be4d6b5f19ff7664e8c98d03e2139cb510db9b0a60b55f8e8709b689d939b6bc" +dependencies = [ + "serde", + "serde_json", +] + +[[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 = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[package]] +name = "wasip2" +version = "1.0.4+wasi-0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" +dependencies = [ + "wit-bindgen", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b067c0c11094aef6b7a801c1e34a26affafdf3d051dba08456b868789aaf9a4" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "167ce5e579f6bcf889c4f7175a8a5a585de84e8ff93976ce393efa5f2837aab1" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3997c7839262f4ef12cf90b818d6340c18e80f263f1a94bf157d0ec4420380e" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.119", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.126" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1b4cb0cc549fcf58d7dfc081778139b3d283a081644e833e84682ad71cea24" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "web-sys" +version = "0.3.103" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8622dcb61c0bcc9fffa6938bed81210af2da9a7e4a1a834b2e37a59b6dfb6141" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[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", +] + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..16cf88c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "stackable-odbc-core" +version = "0.0.1" +edition = "2024" +rust-version = "1.95.0" +authors = ["Stackable GmbH "] +license = "Apache-2.0" +description = "Database-independent framework for building ODBC drivers in Rust: protocol logic, handle management, UTF-16 marshalling, diagnostics, and the generic ODBC FFI entry points." +repository = "https://github.com/stackabletech/stackable-odbc-core" +readme = "README.md" +keywords = ["odbc", "database", "driver", "ffi", "sql"] +categories = ["database", "external-ffi-bindings", "api-bindings"] + +[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] +criterion = { version = "0.8", features = ["html_reports"] } +proptest = "1" + +[lints.clippy] +unwrap_in_result = "deny" +unwrap_used = "deny" +panic = "deny" + +[[bench]] +name = "fetch_throughput" +harness = false diff --git a/README.md b/README.md new file mode 100644 index 0000000..56e8d59 --- /dev/null +++ b/README.md @@ -0,0 +1,86 @@ +# stackable-odbc-core + +The database-independent half of an ODBC driver. `stackable-odbc-core` provides ODBC +protocol logic, handle allocation and tag validation, UTF-16 marshalling, +diagnostics, panic safety, and the generic implementations of the ODBC FFI +entry points. A concrete driver crate implements the `Backend` and +`StatementBackend` traits, then calls the `forward_ffi!` macro to export all 73 +C ABI entry points. + +This is a library crate, not a loadable ODBC driver on its own. Drivers built on +it are published as separate crates, for example `stackable-odbc-trino` (an ODBC +driver for [Trino](https://trino.io/)) and `stackable-odbc-sqlite` (a SQLite +driver used for development and testing). + +For architecture, the call-flow walkthrough, and the spec-compliance rules, see +[AGENTS.md](AGENTS.md). + +## Creating a new driver + +Adding a new database backend requires three steps: + +1. **Create the crate** and add `stackable-odbc-core` as a dependency: + + ```toml + [dependencies] + stackable-odbc-core = "0.0.1" + ``` + +2. **Implement the `Backend` trait** in `backend.rs`: + + ```rust + use stackable_odbc_core::backend::{Backend, StatementBackend}; + + pub struct XyzBackend; + + impl Backend for XyzBackend { + // implement connect, disconnect, etc. + } + ``` + +3. **Generate the FFI entry points** in `lib.rs` using the `forward_ffi!` macro: + + ```rust + stackable_odbc_core::forward_ffi!(crate::backend::XyzBackend); + ``` + + This single line expands to 73 `#[unsafe(no_mangle)] pub unsafe extern + "system"` C ABI entry points (72 `SQL*` functions plus `ConfigDSNW`), each + forwarding to the corresponding generic implementation in `stackable-odbc-core`. + +When adding a new ODBC function to the framework later, add one entry to +`src/forward_ffi.rs` and every driver automatically exports it. See +[AGENTS.md](AGENTS.md#adding-a-new-odbc-function) for the full checklist. + +## Testing + +```bash +cargo test # unit tests (pure Rust, no Driver Manager needed) +cargo bench # Criterion fetch-throughput benchmark +``` + +`stackable-odbc-core` holds all the raw-pointer marshalling, so it is checked by +Miri (undefined behaviour + leaks) and cargo-fuzz (AddressSanitizer) on every PR: + +```bash +MIRIFLAGS="-Zmiri-disable-isolation" \ + cargo +nightly miri test -p stackable-odbc-core --lib -- --skip proptest + +cargo install cargo-fuzz +cargo +nightly fuzz run utf16 +cargo +nightly fuzz run column_value +``` + +See [AGENTS.md](AGENTS.md#testing) and [`fuzz/README.md`](fuzz/README.md) for details. + +## Resources + +- [ODBC API / Documentation](https://learn.microsoft.com/en-us/sql/odbc/reference/syntax/odbc-api-reference?view=sql-server-ver16): + the authoritative reference; the most detailed, still not easy to read. +- [Header files](https://github.com/microsoft/ODBC-Specification/blob/master/Windows/inc/sql.h): + for the unreleased ODBC 4 standard, but mostly valid for older ones too. +- [odbc-sys](https://github.com/pacman82/odbc-sys): ODBC definitions in Rust. + +## License + +Apache-2.0 diff --git a/benches/fetch_throughput.rs b/benches/fetch_throughput.rs new file mode 100644 index 0000000..47fc145 --- /dev/null +++ b/benches/fetch_throughput.rs @@ -0,0 +1,298 @@ +//! 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}; +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 { + name: format!("col{i}"), + type_name: String::new(), + sql_type: SqlDataType::EXT_W_VARCHAR, + precision: 255, + scale: 0, + nullable: true, + }) + .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/clippy.toml b/clippy.toml new file mode 100644 index 0000000..f69b4a6 --- /dev/null +++ b/clippy.toml @@ -0,0 +1,2 @@ +allow-unwrap-in-tests = true +allow-panic-in-tests = true diff --git a/deny.toml b/deny.toml new file mode 100644 index 0000000..59c4557 --- /dev/null +++ b/deny.toml @@ -0,0 +1,37 @@ +# Cargo deny configuration for stackable-odbc-core +# Based on operator-rs conventions. +# Run: cargo deny check + +[graph] +targets = [ + { triple = "x86_64-unknown-linux-gnu" }, + { triple = "aarch64-unknown-linux-gnu" }, +] + +[advisories] +yanked = "deny" + +[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..2b40224 --- /dev/null +++ b/fuzz/README.md @@ -0,0 +1,38 @@ +# Fuzz targets + +Fuzz targets for `stackable-odbc-core`'s **`unsafe` pointer-marshalling** hot paths, where +AddressSanitizer earns its keep. Each allocates its output buffer at exactly the +size a correct application would (the `BufferLength` argument for variable-length +C targets; the C type's own size for fixed-length ones, which ignore +`BufferLength`), so ASAN reports any write past it — the A1 bug class, invisible +to clippy. + +- `utf16` — `utf16_to_string` / `write_utf16` +- `column_value` — `write_column_value` across every marshallable value variant + and C target type (the full coercion matrix) + +The pure-safe parsers (`translate_escapes`, `ConnectParams::parse`, the drivers' +type-name parsers) contain no `unsafe`, so ASAN adds nothing over property tests. +They are covered by [`proptest`](https://docs.rs/proptest) suites co-located with +the code, which run on stable in the normal `cargo test` (never-panics plus +round-trip invariants). + +Run with [cargo-fuzz](https://github.com/rust-fuzz/cargo-fuzz) (nightly + +libFuzzer): + +```bash +cargo install cargo-fuzz +cargo +nightly fuzz run utf16 +cargo +nightly fuzz run column_value +``` + +`cargo fuzz run` runs **indefinitely** 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 +``` + +This crate is its own Cargo workspace, so `cargo build` in the repository root +does not touch it (libFuzzer needs a nightly toolchain). diff --git a/fuzz/fuzz_targets/column_value.rs b/fuzz/fuzz_targets/column_value.rs new file mode 100644 index 0000000..0974d72 --- /dev/null +++ b/fuzz/fuzz_targets/column_value.rs @@ -0,0 +1,141 @@ +#![no_main] + +use arbitrary::Arbitrary; +use libfuzzer_sys::fuzz_target; +use stackable_odbc_core::column_value::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), + IntervalYearMonth { years: i32, months: i32 }, + IntervalDayTime { total_milliseconds: i64 }, +} + +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 } => { + ColumnValue::IntervalYearMonth { years, months } + } + FuzzValue::IntervalDayTime { total_milliseconds } => { + ColumnValue::IntervalDayTime { total_milliseconds } + } + } + } +} + +// 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, +} + +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. + let variable_len = matches!( + target, + CDataType::WChar | CDataType::Char | CDataType::Binary + ); + let alloc = if variable_len { input.buf_len as usize } else { 256 }; + let mut buf = vec![0u8; alloc]; + let mut ind: isize = 0; + unsafe { + let _ = write_column_value( + &value, + target, + buf.as_mut_ptr() as *mut c_void, + buf_len, + &mut ind, + ); + } +}); 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/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..10af678 --- /dev/null +++ b/src/backend.rs @@ -0,0 +1,629 @@ +//! The [`Backend`] and [`StatementBackend`] traits every driver implements, +//! plus the shared `SQLGetInfo` helpers (`common_get_info_raw`, +//! `default_get_info`). + +use odbc_sys::CDataType; + +use crate::errors::OdbcError; +use crate::types::{ + CatalogResultColumnWidths, ColumnDescriptor, ColumnValue, ConnectParams, ExecuteOutcome, + FetchResult, IdentifierType, InfoValue, Nullable, Scope, TypeInfoRow, +}; + +/// Core abstraction for database-specific logic. +/// Everything in stackable-odbc-core is generic over B: Backend. +/// `Sized` is implicit (all traits require it by default), listed for symmetry with `StatementBackend` and to make the full contract visible in one place. +pub trait Backend: Sized + Send + Sync + 'static { + type Connection: Send + Sync; + type Statement: StatementBackend; + type Error: Into; + + /// 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; + + /// Closes an existing connection and releases associated resources. + /// + /// Called by `SQLDisconnectW`. + 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`]. + fn exec_direct(conn: &Self::Connection, 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`]. + fn prepare(conn: &Self::Connection, sql: &str) -> Result; + + /// Executes a previously prepared statement with the given parameter values. + /// + /// Called by `SQLExecuteW`. `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. + fn execute( + conn: &Self::Connection, + 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<(), OdbcError> { + 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(), + }) + } + } + + /// 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; + + /// 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. + /// Backends should override this to handle those pre-connect info types. + /// The default returns `NotImplemented`. + fn get_info_pre_connect(_info_type: crate::types::InfoType) -> Result { + Err(OdbcError::NotImplemented { + feature: "get_info_pre_connect".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 `SQLGetFunctionsW`. 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. + fn get_functions() -> &'static [crate::function_id::FunctionId]; + + /// Returns static type information rows describing the SQL types supported by this driver. + /// + /// Called by `SQLGetTypeInfoW`. The returned slice should include both ANSI and Unicode + /// type variants so that ODBC applications can match on `SQL_VARCHAR` as well as + /// `SQL_WVARCHAR`. + fn get_type_info() -> &'static [TypeInfoRow]; + + /// Returns a result set describing tables matching the given filter criteria. + /// + /// Called by `SQLTablesW`. All filter parameters are optional; `None` means no filter + /// on that dimension. + fn tables( + conn: &Self::Connection, + catalog: Option<&str>, + schema: Option<&str>, + table: Option<&str>, + table_type: Option<&str>, + ) -> Result; + + /// Returns a result set describing columns matching the given filter criteria. + /// + /// Called by `SQLColumnsW`. All filter parameters are optional; `None` means no filter + /// on that dimension. + fn columns( + conn: &Self::Connection, + catalog: Option<&str>, + schema: Option<&str>, + table: Option<&str>, + column: Option<&str>, + ) -> Result; + + /// 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`. + fn primary_keys( + _conn: &Self::Connection, + _catalog: Option<&str>, + _schema: Option<&str>, + _table: Option<&str>, + ) -> Result { + Err(OdbcError::NotImplemented { + feature: "primary_keys".into(), + }) + } + + /// Return foreign key relationships. + /// + /// Called by `SQLForeignKeysW`. Either `pk_table` or `fk_table` (or both) may be supplied. + /// Backends that do not support this can leave the default implementation which returns + /// `NotImplemented`. + fn foreign_keys( + _conn: &Self::Connection, + _pk_catalog: Option<&str>, + _pk_schema: Option<&str>, + _pk_table: Option<&str>, + _fk_catalog: Option<&str>, + _fk_schema: Option<&str>, + _fk_table: Option<&str>, + ) -> Result { + Err(OdbcError::NotImplemented { + feature: "foreign_keys".into(), + }) + } + + /// Return index statistics for a single table. + /// + /// Called by `SQLStatisticsW`. `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). + fn statistics( + _conn: &Self::Connection, + _catalog: Option<&str>, + _schema: Option<&str>, + _table: Option<&str>, + _unique_only: bool, + ) -> Result { + Err(OdbcError::NotImplemented { + feature: "statistics".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. + fn special_columns( + _conn: &Self::Connection, + _identifier_type: IdentifierType, + _catalog: Option<&str>, + _schema: Option<&str>, + _table: Option<&str>, + _scope: Scope, + _nullable: Nullable, + ) -> Result { + Err(OdbcError::NotImplemented { + feature: "special_columns".into(), + }) + } + + /// Cancels an in-progress statement. + /// + /// Called by `SQLCancelW`. Takes `&mut` because implementations must clear + /// streaming state (e.g. `next_uri`) after a server-side cancel to prevent + /// `close_cursor`/`Drop` from trying to drain a cancelled query, which + /// would fail and leave the connection pool's TCP socket dirty. + /// + /// Returns `OdbcError` directly (not `Self::Error`) to allow a default + /// implementation. The default returns `NotImplemented`. + fn cancel(_stmt: &mut Self::Statement) -> Result<(), OdbcError> { + Err(OdbcError::NotImplemented { + feature: "cancel".into(), + }) + } + + /// 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<(), OdbcError> { + Err(OdbcError::NotImplemented { + feature: "end_tran".into(), + }) + } + + /// 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). + fn browse_connect_attrs() -> &'static [&'static str] { + &[] + } + + /// 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() -> crate::escape::EscapeDialect { + crate::escape::EscapeDialect::ansi_default() + } + + /// 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. + fn catalog_result_column_widths() -> CatalogResultColumnWidths { + CatalogResultColumnWidths::default() + } +} + +/// 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 { + /// Advances the cursor to the next row. + /// + /// Called by `SQLFetchW`. 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(), + }) + } + + /// Retrieves the value of column `col` (1-based) from the current row. + /// + /// Called by `SQLGetDataW`. The value is converted to `target_type` as requested by + /// the application. + /// + /// Returns a [`Cow`](std::borrow::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, OdbcError> { + Err(OdbcError::NotImplemented { + feature: "get_data".into(), + }) + } + + /// Returns the number of columns in the result set. + /// + /// Called by `SQLNumResultColsW`. Returns 0 if no result set is active. + fn column_count(&self) -> u16 { + 0 + } + + /// Returns metadata for column `col` (1-based). + /// + /// Called by `SQLDescribeColW`. + fn describe_col(&self, _col: u16) -> Result { + Err(OdbcError::NotImplemented { + feature: "describe_col".into(), + }) + } + + /// Returns the number of rows affected by the last DML statement. + /// + /// Called by `SQLRowCountW`. Returns `None` if not applicable (e.g. for SELECT + /// statements or when no statement has been executed). + fn row_count(&self) -> Option { + None + } + + /// Closes the cursor and discards any pending results. + /// + /// Called by `SQLCloseCursorW`. The statement handle remains valid and may be + /// re-executed. + fn close_cursor(&mut self) {} +} + +/// 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, +/// to avoid duplicating these ~60 arms. Returns `None` for anything driver-specific. +pub fn default_get_info( + info_type: crate::types::InfoType, + widths: &CatalogResultColumnWidths, +) -> Option { + use crate::types::{ + InfoType, InfoValue, SQL_AM_NONE, SQL_CA1_NEXT, SQL_DRIVER_ODBC_VER_STRING, + SQL_FN_CVT_CAST, SQL_GB_NO_RELATION, SQL_INSENSITIVE, SQL_MAX_CURSOR_NAME_LEN, + SQL_OIC_CORE, 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_U_UNION, + SQL_U_UNION_ALL, + }; + 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("\\".into())), + InfoType::IdentifierQuoteChar => Some(InfoValue::String("\"".into())), + InfoType::CatalogTerm => Some(InfoValue::String("catalog".into())), + InfoType::SchemaTerm => Some(InfoValue::String("schema".into())), + InfoType::CatalogNameSeparator => Some(InfoValue::String(".".into())), + InfoType::ColumnAlias => Some(InfoValue::String("Y".into())), + InfoType::OrderByColumnsInSelect => Some(InfoValue::String("N".into())), + InfoType::Subqueries => Some(InfoValue::U32( + SQL_SQ_COMPARISON + | SQL_SQ_EXISTS + | SQL_SQ_IN + | SQL_SQ_QUANTIFIED + | SQL_SQ_CORRELATED_SUBQUERIES, + )), + InfoType::UnionStatement => Some(InfoValue::U32(SQL_U_UNION | SQL_U_UNION_ALL)), + 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("N".into())), + InfoType::AccessibleTables => Some(InfoValue::String("Y".into())), + InfoType::AccessibleProcedures => Some(InfoValue::String("N".into())), + InfoType::Integrity => Some(InfoValue::String("N".into())), + InfoType::SpecialCharacters => Some(InfoValue::String(String::new())), + InfoType::XopenCliYear => Some(InfoValue::String("1995".into())), + InfoType::CollationSeq => Some(InfoValue::String(String::new())), + InfoType::DescribeParameter => Some(InfoValue::String("Y".into())), + // --- U16 types identical in all drivers --- + InfoType::GroupBy => Some(InfoValue::U16(SQL_GB_NO_RELATION)), + InfoType::MaxDriverConnections => Some(InfoValue::U16(0)), + InfoType::MaxConcurrentActivities => Some(InfoValue::U16(0)), + InfoType::ConcatNullBehavior => Some(InfoValue::U16(0)), + InfoType::CursorCommitBehaviour => Some(InfoValue::U16(0)), + InfoType::MaxColumnNameLen => Some(InfoValue::U16(widths.identifier_len)), + // Deliberately not `widths.identifier_len` -- 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(widths.identifier_len)), + InfoType::MaxCatalogNameLen => Some(InfoValue::U16(widths.identifier_len)), + InfoType::MaxTableNameLen => Some(InfoValue::U16(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 -- found by the info-type conformance test + // (`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. + InfoType::CursorSensitivity => Some(InfoValue::U32(u32::from(SQL_INSENSITIVE))), + InfoType::MaxIdentifierLen => Some(InfoValue::U16(widths.identifier_len)), + // --- U32 types identical in all drivers --- + InfoType::ScrollOptions => Some(InfoValue::U32(SQL_SO_FORWARD_ONLY)), + InfoType::ConvertFunctions => Some(InfoValue::U32(SQL_FN_CVT_CAST)), + InfoType::AlterTable => Some(InfoValue::U32(0)), + InfoType::MaxIndexSize => Some(InfoValue::U32(0)), + InfoType::MaxRowSize => Some(InfoValue::U32(0)), + InfoType::MaxStatementLen => Some(InfoValue::U32(0)), + InfoType::OuterJoinCapabilities => Some(InfoValue::U32(0)), + InfoType::SqlConformance => Some(InfoValue::U32(SQL_SC_SQL92_ENTRY)), + InfoType::OdbcInterfaceConformance => Some(InfoValue::U32(SQL_OIC_CORE)), + InfoType::AsyncMode => Some(InfoValue::U32(SQL_AM_NONE)), + InfoType::AsyncDbcFunctions => Some(InfoValue::U32(0)), + // --- Cursor attributes (all zero except ForwardOnly1) --- + InfoType::DynamicCursorAttributes1 => Some(InfoValue::U32(0)), + InfoType::DynamicCursorAttributes2 => Some(InfoValue::U32(0)), + InfoType::ForwardOnlyCursorAttributes1 => Some(InfoValue::U32(SQL_CA1_NEXT)), + InfoType::ForwardOnlyCursorAttributes2 => Some(InfoValue::U32(0)), + 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, + } +} + +/// 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. +pub fn common_get_info_raw(info_type: u16) -> Option { + use crate::types::{ + InfoValue, SQL_CURSOR_ROLLBACK_BEHAVIOR, SQL_FILE_USAGE, SQL_IC_SENSITIVE, + SQL_QUOTED_IDENTIFIER_CASE, + }; + match info_type { + SQL_FILE_USAGE => Some(InfoValue::U16(0)), + SQL_CURSOR_ROLLBACK_BEHAVIOR => Some(InfoValue::U16(0)), + SQL_QUOTED_IDENTIFIER_CASE => Some(InfoValue::U16(SQL_IC_SENSITIVE)), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{ + DEFAULT_IDENTIFIER_LEN, InfoType, InfoValue, SQL_AM_NONE, SQL_CA1_NEXT, + SQL_DRIVER_ODBC_VER_STRING, SQL_FN_CVT_CAST, SQL_GB_NO_RELATION, SQL_INSENSITIVE, + SQL_MAX_CURSOR_NAME_LEN, SQL_OIC_CORE, 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_U_UNION, SQL_U_UNION_ALL, + }; + + 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")), + (InfoType::AccessibleTables, Expected::Str("Y")), + (InfoType::AccessibleProcedures, Expected::Str("N")), + (InfoType::Integrity, Expected::Str("N")), + (InfoType::SpecialCharacters, Expected::Str("")), + (InfoType::XopenCliYear, Expected::Str("1995")), + (InfoType::CollationSeq, Expected::Str("")), + (InfoType::DescribeParameter, Expected::Str("Y")), + // --- U16 values --- + (InfoType::GroupBy, Expected::U16(SQL_GB_NO_RELATION)), + (InfoType::MaxDriverConnections, Expected::U16(0)), + (InfoType::MaxConcurrentActivities, Expected::U16(0)), + (InfoType::ConcatNullBehavior, Expected::U16(0)), + (InfoType::CursorCommitBehaviour, Expected::U16(0)), + (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_INSENSITIVE 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)), + (InfoType::AlterTable, Expected::U32(0)), + (InfoType::MaxIndexSize, Expected::U32(0)), + (InfoType::MaxRowSize, Expected::U32(0)), + (InfoType::MaxStatementLen, Expected::U32(0)), + (InfoType::OuterJoinCapabilities, Expected::U32(0)), + (InfoType::SqlConformance, Expected::U32(SQL_SC_SQL92_ENTRY)), + (InfoType::OdbcInterfaceConformance, Expected::U32(SQL_OIC_CORE)), + (InfoType::AsyncMode, Expected::U32(SQL_AM_NONE)), + (InfoType::AsyncDbcFunctions, Expected::U32(0)), + (InfoType::DynamicCursorAttributes1, Expected::U32(0)), + (InfoType::DynamicCursorAttributes2, Expected::U32(0)), + (InfoType::ForwardOnlyCursorAttributes1, Expected::U32(SQL_CA1_NEXT)), + (InfoType::ForwardOnlyCursorAttributes2, Expected::U32(0)), + (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(*info_type, &CatalogResultColumnWidths::default()) + .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:?}"), + } + } + } + + /// The five identifier-length info types must follow the supplied widths, + /// not a baked-in 128. Before this was plumbed, a driver could report 63 + /// in its catalog result sets and 128 here, telling an application two + /// different things about the same limit. + #[test] + fn max_name_len_info_types_follow_the_supplied_widths() { + let widths = CatalogResultColumnWidths { + identifier_len: 63, + ..CatalogResultColumnWidths::default() + }; + for info_type in [ + InfoType::MaxColumnNameLen, + InfoType::MaxSchemaNameLen, + InfoType::MaxCatalogNameLen, + InfoType::MaxTableNameLen, + InfoType::MaxIdentifierLen, + ] { + assert_eq!( + default_get_info(info_type, &widths), + Some(InfoValue::U16(63)), + "{info_type:?} ignored the supplied identifier_len" + ); + } + } +} diff --git a/src/column_value.rs b/src/column_value.rs new file mode 100644 index 0000000..83b1f6d --- /dev/null +++ b/src/column_value.rs @@ -0,0 +1,3404 @@ +//! `write_column_value` marshals a [`crate::types::ColumnValue`] into an +//! application buffer for `SQLGetData` (NULL, truncation, type coercion). + +use std::ffi::c_void; + +use odbc_sys::{Date, NULL_DATA, Time, Timestamp}; + +use crate::errors::OdbcError; +use crate::types::{CDataType, ColumnValue, 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) +/// +/// # 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, +) -> 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(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 — map to TypeTimestamp (offset is dropped). + ColumnValue::TimestampTz { .. } => CDataType::TypeTimestamp, + ColumnValue::Bytes(_) => CDataType::Binary, + ColumnValue::Guid(_) => CDataType::Binary, + // 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, + }; + return unsafe { write_column_value(value, inferred, target_ptr, buf_len, len_ind_ptr) }; + } + + // 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. + match (value, target_type) { + // --- String to WChar (UTF-16) --- + (ColumnValue::String(s), CDataType::WChar) => unsafe { + write_wchar(s, target_ptr, buf_len, len_ind_ptr) + }, + + // --- String to Char (UTF-8) --- + (ColumnValue::String(s), CDataType::Char) => unsafe { + write_char(s, target_ptr, buf_len, len_ind_ptr) + }, + + // --- 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. + (ColumnValue::String(s), CDataType::TypeDate) => { + let d = parse_sql_date(s)?; + unsafe { write_fixed(target_ptr, len_ind_ptr, d) } + } + (ColumnValue::String(s), CDataType::TypeTime) => { + let (t, fraction) = parse_sql_time(s)?; + unsafe { + let _ = write_fixed(target_ptr, len_ind_ptr, t)?; + } + if fraction != 0 { + return Err(OdbcError::FractionalTruncation); + } + Ok(SqlReturn::SUCCESS) + } + (ColumnValue::String(s), CDataType::TypeTimestamp) => { + let ts = parse_sql_timestamp(s)?; + 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 either Int(i64) or + // Float(f64) without intermediate precision loss. 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. + ( + _, + 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) { + Some(pivot) => unsafe { + write_numeric_pivot(pivot, target_type, target_ptr, len_ind_ptr) + }, + None => Err(match value { + // Text that should have been numeric but was not parseable. + ColumnValue::String(_) | ColumnValue::Decimal(_) => OdbcError::general( + format!("Invalid character value for cast: {value:?}"), + SqlState::invalid_character_value_for_cast(), + ), + // 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." + _ => 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) } + } + + // --- 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) + } + + // --- 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) } + } + + // --- 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) } + } + + // --- Coercion: any value → Binary --- + // Mirrors the WChar/Char catch-alls: a single arm backed by column_value_to_binary + // so that adding a new ColumnValue variant never requires a new Binary arm here. + (_, CDataType::Binary) => unsafe { + let bytes = column_value_to_binary(value); + write_binary(&bytes, target_ptr, buf_len, len_ind_ptr) + }, + + // --- Coercion: any value → WChar --- + (_, CDataType::WChar) => { + let s = column_value_to_string(value); + unsafe { write_wchar(&s, target_ptr, buf_len, len_ind_ptr) } + } + + // --- Coercion: numeric/bool to Char --- + (_, CDataType::Char) => { + let s = column_value_to_string(value); + unsafe { write_char(&s, target_ptr, buf_len, len_ind_ptr) } + } + + // 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(), + )), + } +} + +// --------------------------------------------------------------------------- +// Helper: write a fixed-size value to a raw pointer +// --------------------------------------------------------------------------- + +unsafe fn write_fixed( + target_ptr: *mut c_void, + len_ind_ptr: *mut isize, + value: T, +) -> Result { + if !target_ptr.is_null() { + unsafe { std::ptr::write_unaligned(target_ptr.cast::(), value) }; + } + if !len_ind_ptr.is_null() { + unsafe { std::ptr::write_unaligned(len_ind_ptr, std::mem::size_of::() as isize) }; + } + Ok(SqlReturn::SUCCESS) +} + +// --------------------------------------------------------------------------- +// Helper: parse ODBC datetime literals from character data +// --------------------------------------------------------------------------- +// +// The ODBC conversion matrix requires SQL_CHAR / SQL_VARCHAR to convert to the +// datetime C types. Accepted forms are the ODBC literal formats, which is what +// backends are expected to emit: +// +// date yyyy-mm-dd +// time hh:mm[:ss[.f...]] +// timestamp yyyy-mm-dd[ T]hh:mm[:ss[.f...]] +// +// Unparseable text is 22018; text that parses but carries an out-of-range field +// is 22007. Both codes are scoped by the spec to a character column source +// (see the SQLGetData diagnostics table), which is exactly the case handled +// in this module -- stackable-odbc-core has no numeric datetime encodings left to +// decode (see the Backend-side decoding note on write_column_value above). + +fn cast_error(s: &str) -> OdbcError { + OdbcError::general( + format!("Invalid character value for cast: {s:?}"), + SqlState::invalid_character_value_for_cast(), + ) +} + +fn invalid_datetime_format(s: &str) -> OdbcError { + OdbcError::general( + format!("Invalid datetime format: {s:?}"), + SqlState::invalid_datetime_format(), + ) +} + +/// Map a numeric-field [`std::num::ParseIntError`] to the right SQLSTATE. +/// +/// A string that fails to parse purely because it does not fit the target +/// integer type (`PosOverflow` / `NegOverflow`, e.g. year `"99999"` or hour +/// `"700000"`) is syntactically valid but out of range: 22007. Anything else +/// (empty, non-digit characters, ...) is a syntax problem: 22018. +fn field_parse_error(s: &str, e: std::num::ParseIntError) -> OdbcError { + use std::num::IntErrorKind; + match e.kind() { + IntErrorKind::PosOverflow | IntErrorKind::NegOverflow => invalid_datetime_format(s), + _ => cast_error(s), + } +} + +/// Parse `yyyy-mm-dd` into its three numeric fields. +fn parse_date_fields(s: &str) -> Result<(i16, u16, u16), OdbcError> { + let mut parts = s.split('-'); + let (y, m, d) = match (parts.next(), parts.next(), parts.next(), parts.next()) { + (Some(y), Some(m), Some(d), None) => (y, m, d), + _ => return Err(cast_error(s)), + }; + let year: i16 = y.parse().map_err(|e| field_parse_error(s, e))?; + let month: u16 = m.parse().map_err(|e| field_parse_error(s, e))?; + let day: u16 = d.parse().map_err(|e| field_parse_error(s, e))?; + if !(1..=12).contains(&month) || !(1..=31).contains(&day) { + return Err(invalid_datetime_format(s)); + } + Ok((year, month, day)) +} + +/// Parse `hh:mm[:ss[.f...]]` into hour, minute, second and nanoseconds. +fn parse_time_fields(s: &str) -> Result<(u16, u16, u16, u32), OdbcError> { + let mut parts = s.split(':'); + let (h, m) = match (parts.next(), parts.next()) { + (Some(h), Some(m)) => (h, m), + _ => return Err(cast_error(s)), + }; + let sec_part = parts.next().unwrap_or("0"); + if parts.next().is_some() { + return Err(cast_error(s)); + } + + let hour: u16 = h.parse().map_err(|e| field_parse_error(s, e))?; + let minute: u16 = m.parse().map_err(|e| field_parse_error(s, e))?; + + // Distinguish "no dot at all" (None -> fraction 0) from "a dot with + // nothing after it" (Some((_, "")) -> malformed, e.g. "10:30:15."). + let (sec_text, frac_text) = match sec_part.split_once('.') { + Some((sec, frac)) => (sec, Some(frac)), + None => (sec_part, None), + }; + let second: u16 = sec_text.parse().map_err(|e| field_parse_error(s, e))?; + + // Both SQL_TIMESTAMP_STRUCT.fraction and ColumnValue::Time's fraction are + // nanoseconds. Pad or truncate to 9 digits. + let fraction: u32 = match frac_text { + None => 0, + Some(frac_text) => { + if frac_text.is_empty() || !frac_text.bytes().all(|b| b.is_ascii_digit()) { + return Err(cast_error(s)); + } + let mut digits = frac_text.to_string(); + digits.truncate(9); + while digits.len() < 9 { + digits.push('0'); + } + digits.parse().map_err(|_| cast_error(s))? + } + }; + + // 60 is permitted for a leap second. + if hour > 23 || minute > 59 || second > 60 { + return Err(invalid_datetime_format(s)); + } + Ok((hour, minute, second, fraction)) +} + +fn parse_sql_date(s: &str) -> Result { + let (year, month, day) = parse_date_fields(s.trim())?; + Ok(Date { year, month, day }) +} + +/// Parse ODBC time literal text into a [`Time`] struct plus the fractional +/// seconds (nanoseconds) that `SQL_TIME_STRUCT` cannot carry. Callers writing +/// to `SQL_C_TYPE_TIME` must check the returned fraction themselves and report +/// 01S07 if it is non-zero -- this function only parses, it does not decide +/// whether the drop is acceptable for the caller's target type. +fn parse_sql_time(s: &str) -> Result<(Time, u32), OdbcError> { + let (hour, minute, second, fraction) = parse_time_fields(s.trim())?; + Ok(( + Time { + hour, + minute, + second, + }, + fraction, + )) +} + +fn parse_sql_timestamp(s: &str) -> Result { + let t = s.trim(); + // Accept either the ODBC space separator or the ISO 8601 'T'. + let (date_part, time_part) = match t.split_once([' ', 'T']) { + Some((d, rest)) => (d, rest.trim()), + // A bare date is a valid timestamp at midnight. + None => (t, ""), + }; + let (year, month, day) = parse_date_fields(date_part)?; + let (hour, minute, second, fraction) = if time_part.is_empty() { + (0, 0, 0, 0) + } else { + parse_time_fields(time_part)? + }; + Ok(Timestamp { + year, + month, + day, + hour, + minute, + second, + fraction, + }) +} + +// --------------------------------------------------------------------------- +// Helper: write UTF-16 string +// --------------------------------------------------------------------------- + +unsafe fn write_wchar( + s: &str, + target_ptr: *mut c_void, + buf_len: isize, + len_ind_ptr: *mut isize, +) -> Result { + // Pre-size to UTF-8 byte length, which is always >= UTF-16 code unit count. + let mut wide = Vec::with_capacity(s.len()); + wide.extend(s.encode_utf16()); + let total_bytes = (wide.len() * 2) as isize; + + // Always report the total byte length needed. + if !len_ind_ptr.is_null() { + unsafe { std::ptr::write_unaligned(len_ind_ptr, total_bytes) }; + } + + if target_ptr.is_null() || buf_len <= 0 { + return Ok(SqlReturn::SUCCESS); + } + + // The null terminator is one UTF-16 code unit, so a buffer of fewer than + // two bytes cannot hold it. Writing one anyway would overrun the caller's + // buffer. Spec: "If the data buffer supplied is too small to hold the + // null-termination character, SQLGetData returns SQL_SUCCESS_WITH_INFO + // and SQLSTATE 01004." + if buf_len < 2 { + return Ok(SqlReturn::SUCCESS_WITH_INFO); + } + + let out_ptr = target_ptr.cast::(); + // buf_len is in bytes; capacity in u16 code units (reserve one for null terminator) + let capacity_units = ((buf_len as usize) / 2).saturating_sub(1); + let copy_count = wide.len().min(capacity_units); + + unsafe { + let out_bytes = out_ptr.cast::(); + std::ptr::copy_nonoverlapping(wide.as_ptr().cast::(), out_bytes, copy_count * 2); + // null terminator + std::ptr::write_unaligned(out_bytes.add(copy_count * 2).cast::(), 0u16); + } + + if copy_count < wide.len() { + Ok(SqlReturn::SUCCESS_WITH_INFO) + } else { + Ok(SqlReturn::SUCCESS) + } +} + +// --------------------------------------------------------------------------- +// Helper: write UTF-8 string +// --------------------------------------------------------------------------- + +unsafe fn write_char( + s: &str, + target_ptr: *mut c_void, + buf_len: isize, + len_ind_ptr: *mut isize, +) -> Result { + let bytes = s.as_bytes(); + let total_bytes = bytes.len() as isize; + + if !len_ind_ptr.is_null() { + unsafe { std::ptr::write_unaligned(len_ind_ptr, total_bytes) }; + } + + if target_ptr.is_null() || buf_len <= 0 { + return Ok(SqlReturn::SUCCESS); + } + + let out_ptr = target_ptr.cast::(); + let capacity = (buf_len as usize).saturating_sub(1); // reserve one for null terminator + let copy_count = bytes.len().min(capacity); + + unsafe { + std::ptr::copy_nonoverlapping(bytes.as_ptr(), out_ptr, copy_count); + *out_ptr.add(copy_count) = 0u8; // null terminator + } + + if copy_count < bytes.len() { + Ok(SqlReturn::SUCCESS_WITH_INFO) + } else { + Ok(SqlReturn::SUCCESS) + } +} + +// --------------------------------------------------------------------------- +// Helper: write raw binary bytes +// --------------------------------------------------------------------------- + +unsafe fn write_binary( + data: &[u8], + target_ptr: *mut c_void, + buf_len: isize, + len_ind_ptr: *mut isize, +) -> Result { + let total_bytes = data.len() as isize; + + if !len_ind_ptr.is_null() { + unsafe { std::ptr::write_unaligned(len_ind_ptr, total_bytes) }; + } + + if target_ptr.is_null() || buf_len <= 0 { + return Ok(SqlReturn::SUCCESS); + } + + let out_ptr = target_ptr.cast::(); + let copy_count = data.len().min(buf_len as usize); + + unsafe { + std::ptr::copy_nonoverlapping(data.as_ptr(), out_ptr, copy_count); + } + + if copy_count < data.len() { + Ok(SqlReturn::SUCCESS_WITH_INFO) + } else { + Ok(SqlReturn::SUCCESS) + } +} + +// --------------------------------------------------------------------------- +// Numeric pivot: intermediate representation for numeric cross-type coercion +// --------------------------------------------------------------------------- + +/// Intermediate representation for numeric cross-type coercion. +/// +/// Keeping integers as `i64` and floats as `f64` avoids any intermediate precision +/// loss: the final narrowing cast (`i64 as i8`, `i64 as f64`, etc.) happens once, +/// at write time, only when the requested C target type requires it. +enum NumericPivot { + Int(i64), + Float(f64), +} + +/// Parse numeric text into a [`NumericPivot`]. +/// +/// Integer is attempted first so that values beyond `f64`'s 53-bit exact range +/// (an exact numeric such as `DECIMAL(19,0)`) reach `SQL_C_SBIGINT` intact. +/// Falls back to `f64` for anything with a fractional part or exponent. +fn parse_numeric_text(s: &str) -> Option { + let t = s.trim(); + if let Ok(i) = t.parse::() { + return Some(NumericPivot::Int(i)); + } + t.parse::().ok().map(NumericPivot::Float) +} + +/// Map a [`ColumnValue`] to a [`NumericPivot`], or `None` if the variant is not numeric. +/// +/// This match is intentionally exhaustive (no wildcard) so that adding a new +/// `ColumnValue` variant causes a compile error here, forcing an explicit decision +/// about whether the new type is numeric. +fn column_value_as_numeric(value: &ColumnValue) -> Option { + match value { + ColumnValue::I8(v) => Some(NumericPivot::Int(i64::from(*v))), + ColumnValue::I16(v) => Some(NumericPivot::Int(i64::from(*v))), + ColumnValue::I32(v) => Some(NumericPivot::Int(i64::from(*v))), + ColumnValue::I64(v) => Some(NumericPivot::Int(*v)), + ColumnValue::F32(v) => Some(NumericPivot::Float(f64::from(*v))), + ColumnValue::F64(v) => Some(NumericPivot::Float(*v)), + ColumnValue::Bool(v) => Some(NumericPivot::Int(*v as i64)), + ColumnValue::Decimal(s) | ColumnValue::String(s) => parse_numeric_text(s), + // Non-numeric variants: explicitly listed so the compiler flags any new variant + ColumnValue::Null + | ColumnValue::Date { .. } + | ColumnValue::Time { .. } + | ColumnValue::Timestamp { .. } + | ColumnValue::Bytes(_) + | ColumnValue::Guid(_) + | ColumnValue::TimestampTz { .. } + | ColumnValue::Json(_) + | ColumnValue::Array(_) + | ColumnValue::Map(_) + | ColumnValue::Row(_) + | ColumnValue::IntervalYearMonth { .. } + | ColumnValue::IntervalDayTime { .. } => None, + } +} + +/// Write a numeric pivot value into a C buffer for the given target type. +/// +/// Handles all signed and unsigned integer C types, `SQL_C_FLOAT`, `SQL_C_DOUBLE`, +/// and `SQL_C_BIT`. Returns `SQL_ERROR` with SQLSTATE `22003` (numeric value out of +/// range) when the value does not fit the target type, and `SQL_SUCCESS_WITH_INFO` +/// with SQLSTATE `01S07` (fractional truncation) when an `i64` or an `f64` is +/// narrowed to `f32` with precision loss. Any `CDataType` not covered by the +/// numeric arms returns `SQL_ERROR` with SQLSTATE `HY003` (invalid application +/// buffer type). +unsafe fn write_numeric_pivot( + pivot: NumericPivot, + target_type: CDataType, + target_ptr: *mut c_void, + len_ind_ptr: *mut isize, +) -> Result { + match (pivot, target_type) { + // --- Int pivot → signed integer targets --- + (NumericPivot::Int(v), CDataType::STinyInt) => { + let n = i8::try_from(v).map_err(|_| { + OdbcError::general( + format!("Numeric value out of range: {v}"), + SqlState::numeric_value_out_of_range(), + ) + })?; + unsafe { write_fixed(target_ptr, len_ind_ptr, n) } + } + (NumericPivot::Int(v), CDataType::SShort) => { + let n = i16::try_from(v).map_err(|_| { + OdbcError::general( + format!("Numeric value out of range: {v}"), + SqlState::numeric_value_out_of_range(), + ) + })?; + unsafe { write_fixed(target_ptr, len_ind_ptr, n) } + } + (NumericPivot::Int(v), CDataType::SLong) => { + let n = i32::try_from(v).map_err(|_| { + OdbcError::general( + format!("Numeric value out of range: {v}"), + SqlState::numeric_value_out_of_range(), + ) + })?; + unsafe { write_fixed(target_ptr, len_ind_ptr, n) } + } + (NumericPivot::Int(v), CDataType::SBigInt) => unsafe { + write_fixed(target_ptr, len_ind_ptr, v) + }, + // --- Int pivot → unsigned integer targets --- + (NumericPivot::Int(v), CDataType::UTinyInt) => { + let n = u8::try_from(v).map_err(|_| { + OdbcError::general( + format!("Numeric value out of range: {v}"), + SqlState::numeric_value_out_of_range(), + ) + })?; + unsafe { write_fixed(target_ptr, len_ind_ptr, n) } + } + (NumericPivot::Int(v), CDataType::UShort) => { + let n = u16::try_from(v).map_err(|_| { + OdbcError::general( + format!("Numeric value out of range: {v}"), + SqlState::numeric_value_out_of_range(), + ) + })?; + unsafe { write_fixed(target_ptr, len_ind_ptr, n) } + } + (NumericPivot::Int(v), CDataType::ULong) => { + let n = u32::try_from(v).map_err(|_| { + OdbcError::general( + format!("Numeric value out of range: {v}"), + SqlState::numeric_value_out_of_range(), + ) + })?; + unsafe { write_fixed(target_ptr, len_ind_ptr, n) } + } + (NumericPivot::Int(v), CDataType::UBigInt) => { + let n = u64::try_from(v).map_err(|_| { + OdbcError::general( + format!("Numeric value out of range: {v}"), + SqlState::numeric_value_out_of_range(), + ) + })?; + unsafe { write_fixed(target_ptr, len_ind_ptr, n) } + } + // --- Int pivot → float targets --- + // i64 → f32: values with |v| > 2^24 lose precision; return 01S07 after writing. + (NumericPivot::Int(v), CDataType::Float) => { + let f = v as f32; + unsafe { + let _ = write_fixed(target_ptr, len_ind_ptr, f)?; + }; + if f as i64 != v { + return Err(OdbcError::FractionalTruncation); + } + Ok(SqlReturn::SUCCESS) + } + (NumericPivot::Int(v), CDataType::Double) => unsafe { + write_fixed(target_ptr, len_ind_ptr, v as f64) + }, + (NumericPivot::Int(v), CDataType::Bit) => unsafe { + write_fixed(target_ptr, len_ind_ptr, u8::from(v != 0)) + }, + // --- Float pivot → signed integer targets --- + (NumericPivot::Float(v), CDataType::STinyInt) => { + if !v.is_finite() || v < i8::MIN as f64 || v > i8::MAX as f64 { + return Err(OdbcError::general( + format!("Numeric value out of range: {v}"), + SqlState::numeric_value_out_of_range(), + )); + } + unsafe { write_fixed(target_ptr, len_ind_ptr, v as i8) } + } + (NumericPivot::Float(v), CDataType::SShort) => { + if !v.is_finite() || v < i16::MIN as f64 || v > i16::MAX as f64 { + return Err(OdbcError::general( + format!("Numeric value out of range: {v}"), + SqlState::numeric_value_out_of_range(), + )); + } + unsafe { write_fixed(target_ptr, len_ind_ptr, v as i16) } + } + (NumericPivot::Float(v), CDataType::SLong) => { + if !v.is_finite() || v < i32::MIN as f64 || v > i32::MAX as f64 { + return Err(OdbcError::general( + format!("Numeric value out of range: {v}"), + SqlState::numeric_value_out_of_range(), + )); + } + unsafe { write_fixed(target_ptr, len_ind_ptr, v as i32) } + } + (NumericPivot::Float(v), CDataType::SBigInt) => { + // i64::MAX is not representable in f64: `i64::MAX as f64` rounds up + // to 2^63. Compare against 2^63 exclusively so that value is + // rejected rather than saturated. + const I64_MAX_EXCLUSIVE: f64 = 9_223_372_036_854_775_808.0; // 2^63 + const I64_MIN_INCLUSIVE: f64 = -9_223_372_036_854_775_808.0; // -2^63 + if !v.is_finite() || !(I64_MIN_INCLUSIVE..I64_MAX_EXCLUSIVE).contains(&v) { + return Err(OdbcError::general( + format!("Numeric value out of range: {v}"), + SqlState::numeric_value_out_of_range(), + )); + } + unsafe { write_fixed(target_ptr, len_ind_ptr, v as i64) } + } + // --- Float pivot → unsigned integer targets --- + (NumericPivot::Float(v), CDataType::UTinyInt) => { + if !v.is_finite() || v < 0.0 || v > u8::MAX as f64 { + return Err(OdbcError::general( + format!("Numeric value out of range: {v}"), + SqlState::numeric_value_out_of_range(), + )); + } + unsafe { write_fixed(target_ptr, len_ind_ptr, v as u8) } + } + (NumericPivot::Float(v), CDataType::UShort) => { + if !v.is_finite() || v < 0.0 || v > u16::MAX as f64 { + return Err(OdbcError::general( + format!("Numeric value out of range: {v}"), + SqlState::numeric_value_out_of_range(), + )); + } + unsafe { write_fixed(target_ptr, len_ind_ptr, v as u16) } + } + (NumericPivot::Float(v), CDataType::ULong) => { + if !v.is_finite() || v < 0.0 || v > u32::MAX as f64 { + return Err(OdbcError::general( + format!("Numeric value out of range: {v}"), + SqlState::numeric_value_out_of_range(), + )); + } + unsafe { write_fixed(target_ptr, len_ind_ptr, v as u32) } + } + (NumericPivot::Float(v), CDataType::UBigInt) => { + // u64::MAX is not representable in f64: `u64::MAX as f64` rounds up + // to 2^64. + const U64_MAX_EXCLUSIVE: f64 = 18_446_744_073_709_551_616.0; // 2^64 + if !v.is_finite() || !(0.0..U64_MAX_EXCLUSIVE).contains(&v) { + return Err(OdbcError::general( + format!("Numeric value out of range: {v}"), + SqlState::numeric_value_out_of_range(), + )); + } + unsafe { write_fixed(target_ptr, len_ind_ptr, v as u64) } + } + // --- Float pivot → float targets --- + // f64 → f32: narrowing loses precision for most values, and overflows + // to ±inf beyond f32::MAX. Write the value, then report 01S07 when the + // round trip is not exact, matching the Int → Float arm above. + (NumericPivot::Float(v), CDataType::Float) => { + let f = v as f32; + unsafe { + let _ = write_fixed(target_ptr, len_ind_ptr, f)?; + }; + if f64::from(f) != v { + return Err(OdbcError::FractionalTruncation); + } + Ok(SqlReturn::SUCCESS) + } + (NumericPivot::Float(v), CDataType::Double) => unsafe { + write_fixed(target_ptr, len_ind_ptr, v) + }, + // --- Float pivot → Bit --- + // NaN is not a valid bit value; return 22003 rather than silently mapping to 1. + (NumericPivot::Float(v), CDataType::Bit) => { + if v.is_nan() { + return Err(OdbcError::general( + "Numeric value out of range: NaN cannot be converted to SQL_C_BIT", + SqlState::numeric_value_out_of_range(), + )); + } + unsafe { write_fixed(target_ptr, len_ind_ptr, u8::from(v != 0.0)) } + } + (_, _) => Err(OdbcError::general( + format!("Unsupported numeric target type: {target_type:?}"), + SqlState::invalid_application_buffer_type(), + )), + } +} + +// --------------------------------------------------------------------------- +// Helper: convert ColumnValue to string for coercion +// --------------------------------------------------------------------------- + +fn column_value_to_string(value: &ColumnValue) -> String { + match value { + ColumnValue::Null => String::new(), + ColumnValue::String(s) => s.clone(), + ColumnValue::I8(v) => v.to_string(), + ColumnValue::I16(v) => v.to_string(), + ColumnValue::I32(v) => v.to_string(), + ColumnValue::I64(v) => v.to_string(), + ColumnValue::F32(v) => v.to_string(), + ColumnValue::F64(v) => v.to_string(), + ColumnValue::Bool(v) => { + if *v { + "1".to_string() + } else { + "0".to_string() + } + } + ColumnValue::Date { year, month, day } => { + format!("{year:04}-{month:02}-{day:02}") + } + ColumnValue::Time { + hour, + minute, + second, + fraction, + } => { + if *fraction == 0 { + format!("{hour:02}:{minute:02}:{second:02}") + } else { + // Trailing zeros are stripped here: a `time(3)` value's + // fraction is stored padded out to nanoseconds, and rendering + // all 9 digits would print fabricated trailing zeros for + // every genuine digit received, misrepresenting the source + // column's actual precision. Trimming recovers exactly the + // digits that were received, since the padding this function + // strips back off is the same zero-padding the parser added + // on the way in. `Timestamp` below does the same, for the + // same reason. + format!( + "{hour:02}:{minute:02}:{second:02}.{}", + format!("{fraction:09}").trim_end_matches('0') + ) + } + } + ColumnValue::Timestamp { + year, + month, + day, + hour, + minute, + second, + fraction, + } => { + if *fraction == 0 { + format!("{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02}") + } else { + // See the `Time` arm above: trailing zeros are stripped so a + // `timestamp(3)` value renders 3 fractional digits, not the + // 9-digit nanosecond storage padded out with 6 fabricated + // zeros. Emitting all 9 would render 29 characters against a + // reported DISPLAY_SIZE of 23 (see the ODBC "Column Size"/ + // "Display Size" appendices' `20 + s` formula, `s` = declared + // fractional-seconds scale). + format!( + "{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02}.{}", + format!("{fraction:09}").trim_end_matches('0') + ) + } + } + ColumnValue::Bytes(data) => { + // Hex-encode + data.iter().map(|b| format!("{b:02X}")).collect() + } + ColumnValue::Guid(data) => { + // UUID format: 8-4-4-4-12 + format!( + "{:02X}{:02X}{:02X}{:02X}-{:02X}{:02X}-{:02X}{:02X}-{:02X}{:02X}-{:02X}{:02X}{:02X}{:02X}{:02X}{:02X}", + data[0], + data[1], + data[2], + data[3], + data[4], + data[5], + data[6], + data[7], + data[8], + data[9], + data[10], + data[11], + data[12], + data[13], + data[14], + data[15], + ) + } + ColumnValue::Decimal(s) => s.clone(), + ColumnValue::TimestampTz { + year, + month, + day, + hour, + minute, + second, + fraction, + timezone_offset_minutes, + } => { + let sign = if *timezone_offset_minutes < 0 { + '-' + } else { + '+' + }; + let abs_offset = timezone_offset_minutes.unsigned_abs(); + let tz_h = abs_offset / 60; + let tz_m = abs_offset % 60; + if *fraction == 0 { + format!( + "{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02}{sign}{tz_h:02}:{tz_m:02}" + ) + } else { + format!( + "{year:04}-{month:02}-{day:02} {hour:02}:{minute:02}:{second:02}.{fraction:09}{sign}{tz_h:02}:{tz_m:02}" + ) + } + } + ColumnValue::Json(s) => s.clone(), + ColumnValue::Array(items) => { + let parts: Vec = items.iter().map(column_value_to_string).collect(); + format!("[{}]", parts.join(", ")) + } + ColumnValue::Map(pairs) => { + let parts: Vec = pairs + .iter() + .map(|(k, v)| { + format!( + "{}={}", + column_value_to_string(k), + column_value_to_string(v) + ) + }) + .collect(); + format!("{{{}}}", parts.join(", ")) + } + ColumnValue::Row(fields) => { + let parts: Vec = fields.iter().map(column_value_to_string).collect(); + format!("({})", parts.join(", ")) + } + ColumnValue::IntervalYearMonth { years, months } => { + // Both fields carry the same sign (see the parser that produces + // this variant), so either is a valid sign source; render the + // sign once up front rather than letting each field print its + // own, which would otherwise yield something like "-1--6". + let negative = *years < 0 || *months < 0; + let sign = if negative { "-" } else { "" }; + format!("{sign}{}-{}", years.unsigned_abs(), months.unsigned_abs()) + } + ColumnValue::IntervalDayTime { total_milliseconds } => { + let negative = *total_milliseconds < 0; + let sign = if negative { "-" } else { "" }; + let total_ms = total_milliseconds.unsigned_abs(); + let ms = total_ms % 1000; + let total_s = total_ms / 1000; + let s = total_s % 60; + let total_m = total_s / 60; + let m = total_m % 60; + let total_h = total_m / 60; + let h = total_h % 24; + let days = total_h / 24; + format!("{sign}{days} {h:02}:{m:02}:{s:02}.{ms:03}") + } + } +} + +// --------------------------------------------------------------------------- +// Helper: convert ColumnValue to raw bytes for SQL_C_BINARY targets +// --------------------------------------------------------------------------- + +/// Convert a [`ColumnValue`] to raw bytes for writing into a `SQL_C_BINARY` buffer. +/// +/// Mirrors [`column_value_to_string`]: a single function that handles all variants, +/// so the `(_, CDataType::Binary)` catch-all in [`write_column_value`] never needs +/// per-variant arms. +/// +/// Numeric types are written as little-endian machine bytes (ODBC permits any +/// representation for numeric→binary; LE matches what most clients expect and what +/// `struct.unpack` in Python decodes by default with `<`). +/// String types are returned as UTF-8 bytes. +/// Structured types fall back to their string representation as UTF-8 bytes. +fn column_value_to_binary(value: &ColumnValue) -> Vec { + match value { + // Raw byte types: return as-is + ColumnValue::Bytes(b) => b.clone(), + ColumnValue::Guid(data) => data.to_vec(), + // Integer types: little-endian machine representation + ColumnValue::I8(v) => v.to_le_bytes().to_vec(), + ColumnValue::I16(v) => v.to_le_bytes().to_vec(), + ColumnValue::I32(v) => v.to_le_bytes().to_vec(), + ColumnValue::I64(v) => v.to_le_bytes().to_vec(), + // Float types: little-endian IEEE 754 representation + ColumnValue::F32(v) => v.to_le_bytes().to_vec(), + ColumnValue::F64(v) => v.to_le_bytes().to_vec(), + // Bool: single byte (0 or 1) + ColumnValue::Bool(v) => vec![*v as u8], + // Null is handled before the match in write_column_value and never reaches here. + // Strings and all structured types: fall back to UTF-8 string representation. + _ => column_value_to_string(value).into_bytes(), + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn null_value_writes_null_indicator() { + let mut ind: isize = 0; + let ret = unsafe { + write_column_value( + &ColumnValue::Null, + CDataType::SLong, + std::ptr::null_mut(), + 0, + &mut ind, + ) + }; + assert_eq!(ret.unwrap(), SqlReturn::SUCCESS); + assert_eq!(ind, -1); // NULL_DATA + } + + #[test] + fn i32_value_to_slong_buffer() { + let mut buf: i32 = 0; + let mut ind: isize = 0; + let ret = unsafe { + write_column_value( + &ColumnValue::I32(42), + CDataType::SLong, + &mut buf as *mut i32 as *mut c_void, + 4, + &mut ind, + ) + }; + assert_eq!(ret.unwrap(), SqlReturn::SUCCESS); + assert_eq!(buf, 42); + assert_eq!(ind, 4); + } + + #[test] + fn i64_value_to_sbigint_buffer() { + let mut buf: i64 = 0; + let mut ind: isize = 0; + let ret = unsafe { + write_column_value( + &ColumnValue::I64(123_456_789), + CDataType::SBigInt, + &mut buf as *mut i64 as *mut c_void, + 8, + &mut ind, + ) + }; + assert_eq!(ret.unwrap(), SqlReturn::SUCCESS); + assert_eq!(buf, 123_456_789); + assert_eq!(ind, 8); + } + + #[test] + fn i8_value_to_stinyint_buffer() { + let mut buf: i8 = 0; + let mut ind: isize = 0; + let ret = unsafe { + write_column_value( + &ColumnValue::I8(-42), + CDataType::STinyInt, + &mut buf as *mut i8 as *mut c_void, + 1, + &mut ind, + ) + }; + assert_eq!(ret.unwrap(), SqlReturn::SUCCESS); + assert_eq!(buf, -42); + assert_eq!(ind, 1); + } + + #[test] + fn i16_value_to_sshort_buffer() { + let mut buf: i16 = 0; + let mut ind: isize = 0; + let ret = unsafe { + write_column_value( + &ColumnValue::I16(1234), + CDataType::SShort, + &mut buf as *mut i16 as *mut c_void, + 2, + &mut ind, + ) + }; + assert_eq!(ret.unwrap(), SqlReturn::SUCCESS); + assert_eq!(buf, 1234); + assert_eq!(ind, 2); + } + + #[test] + fn f32_value_to_float_buffer() { + let mut buf: f32 = 0.0; + let mut ind: isize = 0; + let ret = unsafe { + write_column_value( + &ColumnValue::F32(2.5), + CDataType::Float, + &mut buf as *mut f32 as *mut c_void, + 4, + &mut ind, + ) + }; + assert_eq!(ret.unwrap(), SqlReturn::SUCCESS); + assert!((buf - 2.5).abs() < f32::EPSILON); + assert_eq!(ind, 4); + } + + #[test] + fn f64_value_to_double_buffer() { + let mut buf: f64 = 0.0; + let mut ind: isize = 0; + let ret = unsafe { + write_column_value( + &ColumnValue::F64(std::f64::consts::PI), + CDataType::Double, + &mut buf as *mut f64 as *mut c_void, + 8, + &mut ind, + ) + }; + assert_eq!(ret.unwrap(), SqlReturn::SUCCESS); + assert!((buf - std::f64::consts::PI).abs() < f64::EPSILON); + assert_eq!(ind, 8); + } + + #[test] + fn bool_value_to_bit_buffer() { + let mut buf: u8 = 0; + let mut ind: isize = 0; + let ret = unsafe { + write_column_value( + &ColumnValue::Bool(true), + CDataType::Bit, + &mut buf as *mut u8 as *mut c_void, + 1, + &mut ind, + ) + }; + assert_eq!(ret.unwrap(), SqlReturn::SUCCESS); + assert_eq!(buf, 1); + assert_eq!(ind, 1); + } + + #[test] + fn bool_false_value_to_bit_buffer() { + let mut buf: u8 = 1; + let mut ind: isize = 0; + let ret = unsafe { + write_column_value( + &ColumnValue::Bool(false), + CDataType::Bit, + &mut buf as *mut u8 as *mut c_void, + 1, + &mut ind, + ) + }; + assert_eq!(ret.unwrap(), SqlReturn::SUCCESS); + assert_eq!(buf, 0); + assert_eq!(ind, 1); + } + + #[test] + fn string_value_to_wchar_buffer() { + let mut buf = [0u16; 20]; + let mut ind: isize = 0; + let ret = unsafe { + write_column_value( + &ColumnValue::String("hello".into()), + CDataType::WChar, + buf.as_mut_ptr() as *mut c_void, + 40, + &mut ind, + ) + }; + assert_eq!(ret.unwrap(), SqlReturn::SUCCESS); + assert_eq!(ind, 10); // 5 chars * 2 bytes + let s = String::from_utf16_lossy(&buf[..5]); + assert_eq!(s, "hello"); + } + + #[test] + fn string_truncation_returns_success_with_info() { + let mut buf = [0u16; 4]; // room for 3 chars + null + let mut ind: isize = 0; + let ret = unsafe { + write_column_value( + &ColumnValue::String("hello".into()), + CDataType::WChar, + buf.as_mut_ptr() as *mut c_void, + 8, // 4 u16 slots = 8 bytes + &mut ind, + ) + }; + assert_eq!(ret.unwrap(), SqlReturn::SUCCESS_WITH_INFO); + assert_eq!(ind, 10); // reports full size needed + } + + #[test] + fn wchar_buffer_too_small_for_null_terminator_writes_nothing() { + // A 1-byte buffer cannot hold the 2-byte UTF-16 null terminator. + // Spec: "If the data buffer supplied is too small to hold the + // null-termination character, SQLGetData returns SQL_SUCCESS_WITH_INFO + // and SQLSTATE 01004." + let mut buf = [0xAAu8; 4]; + let mut ind: isize = 0; + let ret = unsafe { + write_column_value( + &ColumnValue::String("hello".into()), + CDataType::WChar, + buf.as_mut_ptr() as *mut c_void, + 1, // only one byte is available + &mut ind, + ) + }; + assert_eq!(ret.unwrap(), SqlReturn::SUCCESS_WITH_INFO); + assert_eq!(ind, 10); // the full size needed is still reported + assert_eq!( + buf, [0xAA; 4], + "wrote past the end of the caller's 1-byte buffer" + ); + } + + #[test] + fn wchar_zero_length_buffer_reports_size_and_writes_nothing() { + // buf_len == 0 is a length query: report the byte count, write nothing. + let mut buf = [0xAAu8; 4]; + let mut ind: isize = 0; + let ret = unsafe { + write_column_value( + &ColumnValue::String("hello".into()), + CDataType::WChar, + buf.as_mut_ptr() as *mut c_void, + 0, + &mut ind, + ) + }; + assert_eq!(ret.unwrap(), SqlReturn::SUCCESS); + assert_eq!(ind, 10); // 5 chars * 2 bytes, still reported + assert_eq!(buf, [0xAA; 4], "wrote into a zero-length buffer"); + } + + #[test] + fn wchar_buffer_holding_only_the_null_terminator_is_written() { + // Two bytes is exactly one UTF-16 code unit: room for the terminator + // and nothing else. This pins the boundary of the guard above. + let mut buf = [0xAAu8; 4]; + let mut ind: isize = 0; + let ret = unsafe { + write_column_value( + &ColumnValue::String("hello".into()), + CDataType::WChar, + buf.as_mut_ptr() as *mut c_void, + 2, + &mut ind, + ) + }; + assert_eq!(ret.unwrap(), SqlReturn::SUCCESS_WITH_INFO); + assert_eq!(ind, 10); + assert_eq!(&buf[..2], &[0x00, 0x00], "null terminator not written"); + assert_eq!(&buf[2..], &[0xAA, 0xAA], "wrote past the 2-byte buffer"); + } + + #[test] + fn default_type_infers_correctly() { + let mut buf: i32 = 0; + let mut ind: isize = 0; + let ret = unsafe { + write_column_value( + &ColumnValue::I32(99), + CDataType::Default, + &mut buf as *mut i32 as *mut c_void, + 4, + &mut ind, + ) + }; + assert_eq!(ret.unwrap(), SqlReturn::SUCCESS); + assert_eq!(buf, 99); + } + + #[test] + fn string_to_char_buffer_utf8() { + let mut buf = [0u8; 20]; + let mut ind: isize = 0; + let ret = unsafe { + write_column_value( + &ColumnValue::String("hello".into()), + CDataType::Char, + buf.as_mut_ptr() as *mut c_void, + 20, + &mut ind, + ) + }; + assert_eq!(ret.unwrap(), SqlReturn::SUCCESS); + assert_eq!(ind, 5); // 5 UTF-8 bytes + assert_eq!(&buf[..5], b"hello"); + assert_eq!(buf[5], 0); // null terminator + } + + #[test] + fn string_to_char_truncation() { + let mut buf = [0u8; 4]; // room for 3 chars + null + let mut ind: isize = 0; + let ret = unsafe { + write_column_value( + &ColumnValue::String("hello".into()), + CDataType::Char, + buf.as_mut_ptr() as *mut c_void, + 4, + &mut ind, + ) + }; + assert_eq!(ret.unwrap(), SqlReturn::SUCCESS_WITH_INFO); + assert_eq!(ind, 5); // reports full size + assert_eq!(&buf[..3], b"hel"); + assert_eq!(buf[3], 0); // null terminator + } + + #[test] + fn bytes_to_binary_buffer() { + let mut buf = [0u8; 10]; + let mut ind: isize = 0; + let ret = unsafe { + write_column_value( + &ColumnValue::Bytes(vec![0xDE, 0xAD, 0xBE, 0xEF]), + CDataType::Binary, + buf.as_mut_ptr() as *mut c_void, + 10, + &mut ind, + ) + }; + assert_eq!(ret.unwrap(), SqlReturn::SUCCESS); + assert_eq!(ind, 4); + assert_eq!(&buf[..4], &[0xDE, 0xAD, 0xBE, 0xEF]); + } + + #[test] + fn bytes_truncation() { + let mut buf = [0u8; 2]; + let mut ind: isize = 0; + let ret = unsafe { + write_column_value( + &ColumnValue::Bytes(vec![0xDE, 0xAD, 0xBE, 0xEF]), + CDataType::Binary, + buf.as_mut_ptr() as *mut c_void, + 2, + &mut ind, + ) + }; + assert_eq!(ret.unwrap(), SqlReturn::SUCCESS_WITH_INFO); + assert_eq!(ind, 4); // reports full size + assert_eq!(&buf[..2], &[0xDE, 0xAD]); + } + + #[test] + fn date_value_to_type_date() { + let mut buf = [0u8; std::mem::size_of::()]; + let mut ind: isize = 0; + let ret = unsafe { + write_column_value( + &ColumnValue::Date { + year: 2025, + month: 6, + day: 15, + }, + CDataType::TypeDate, + buf.as_mut_ptr() as *mut c_void, + std::mem::size_of::() as isize, + &mut ind, + ) + }; + assert_eq!(ret.unwrap(), SqlReturn::SUCCESS); + assert_eq!(ind, std::mem::size_of::() as isize); + let ds = unsafe { std::ptr::read_unaligned(buf.as_ptr().cast::()) }; + assert_eq!(ds.year, 2025); + assert_eq!(ds.month, 6); + assert_eq!(ds.day, 15); + } + + #[test] + fn time_value_to_type_time() { + let mut buf = [0u8; std::mem::size_of::