Skip to content

chore: extract the Trino ODBC driver from the stackable-odbc-rs workspace - #1

Merged
maltesander merged 158 commits into
mainfrom
scaffolding
Aug 4, 2026
Merged

chore: extract the Trino ODBC driver from the stackable-odbc-rs workspace#1
maltesander merged 158 commits into
mainfrom
scaffolding

Conversation

@maltesander

Copy link
Copy Markdown
Member

Description

Extracts stackable-odbc-trino from the three-crate stackable-odbc-rs
workspace (core + trino + sqlite) into this standalone, releasable repository,
and adapts it to the newer stackable-odbc-core API.

SQLite follows later, separately.

Layout

Flat single-crate, mirroring stackable-odbc-core's scaffolding branch — no
[workspace] table, no crates/ nesting. test/trino/ flattens to test/,
since there is no sibling suite here to disambiguate from.

Cargo.toml  src/  benches/  connector/  packaging/  test/  windows/  release/

Moved across: the driver sources, the Criterion benchmark, the Power Query
connector, the release packaging scripts, the Docker integration suite and the
Windows VM harness. Left behind deliberately: fuzz/ (both targets exercise
core APIs), the other two crates, and the workspace tables.

Reviewer notes

CI will be red, by design. stackable-odbc-core is still private, so it is
a path dependency on a sibling checkout and does not resolve on a runner.
cargo publish is blocked for the same reason. The workflows are written for
the published world, so the single TODO in Cargo.toml — swapping the path
dep for a version dep — is the only change needed to make both work.

Version resets to 0.0.1 and the manifest gains the metadata crates.io
requires: description, repository, readme, keywords, categories,
rust-version. authors and license were workspace-inherited and are now
explicit.

Adapting to core's newer API

Core gained a breaking change since this driver last built against it:

  • default_get_info and common_get_info_raw are now generic over the backend
  • SQL_CURSOR_COMMIT_BEHAVIOR is derived from Backend::cursor_commit_behavior
    rather than hard-coded. This driver reports SQL_TC_NONE, so core's
    CursorBehavior::Preserve default is correct and the stale part was the
    snapshot expectation — SQL_CB_PRESERVE (2), not SQL_CB_DELETE (0).

Fixes found during the move

These were latent or newly broken by the shallower paths, not pre-existing bugs
carried over:

  • PROJECT_DIR overshoottest/setup.sh, test/run-tests.sh and
    test/windows_test.py computed $SCRIPT_DIR/../.., correct at test/trino/
    but one level above the repo root at test/. Would have built in the wrong
    directory and looked for the DLL outside the repository.
  • Undocumented connection-string keyswindows/WINDOWS.md omitted
    AccessToken/Token and QueryTimeout/LoginTimeout, all four of which
    connect_params.rs has always accepted.
  • Dead cargo-deny allowances — the ring and OpenSSL licence
    allowances were carried over on the assumption trino-rust-client pulls
    ring. It does not: rustls 0.23 resolves to aws-lc-rs, and ring is absent
    from the tree entirely. Removed and re-verified against all three targets.
    What remains genuinely trino-specific versus core's config is the
    x86_64-pc-windows-gnu triple and the RUSTSEC-2024-0436 ignore for the
    unmaintained transitive paste.
  • Misleading transaction comments — the code justified SQL_TC_NONE with
    "Trino has no explicit transactions". Trino does support transactions over
    the X-Trino-Transaction-Id headers; it is this driver that does not
    implement them. Reported capability unchanged, reasoning corrected.
  • Workspace-era references — 64 across the Rust sources (#[ignore]
    reasons, doc comments, cargo -p / --workspace invocations), plus the
    SQLite half of WINDOWS.md.

Tooling

  • Linters: core's extended pre-commit set (check-yaml, check-toml,
    check-merge-conflict, mixed-line-ending, cargo-sort), plus shellcheck
    re-added — this repo ships nine shell scripts where core had none.
  • CI: build.yaml (unit tests + Windows cross-compile), pr_pre-commit,
    security_audit, and release.yaml triggering on v*. No Miri or fuzz —
    Miri cannot execute the OpenSSL and aws-lc-rs code trino-rust-client links
    in, and both fuzz targets belong to core.
  • Release: cargo-release (release.toml + release/release.sh) cuts a
    signed v<version> tag, which fires release.yaml to build both binaries and
    publish the archives.
  • Docs: README.md for a standalone driver repo, a driver-focused
    self-contained AGENTS.md, CLAUDE.md, and a Keep a Changelog CHANGELOG.md.

The Trino integration suite is not run in CI — the Docker Trino + Postgres
stack exceeds a standard runner. The existing TODO(@maltesander) is carried
into build.yaml, and the suite is documented as a pre-release local step.

Verification

All run locally against this branch:

Check Result
cargo test 225 passed, 0 failed
cargo clippy --all-targets -- -D warnings pass
cargo fmt --check, cargo sort, cargo deny check pass
cargo doc --no-deps, cargo bench --no-run pass
pre-commit run --all-files 15/15 hooks pass
actionlint 0 errors in 4 workflows
Linux/Windows release builds 76 ODBC symbols + ConfigDSNW exported
End-to-end packaging 3 archives; release.yaml's sanity check passes
cargo-release dry run all replacements fire; branch guard holds; no mutation

Integration suites, both green:

Suite Result
Docker/Trino, pyodbc × 4 configs 100 passed, 0 failed
Rust FFI integration 53 passed, 0 failed
Backend (isolated, as AGENTS.md requires) 7 passed, 0 failed
Windows VM via the Windows DM, × 4 configs 100 passed, 0 failed

Follow-ups

  • Swap the path dep for a version dep once core is published; that also unblocks
    CI and cargo publish.
  • Implement transaction support once the trino-rust-client transaction fix
    ships. TrinoBackend::end_tran lists every site that needs revisiting.

maltesander and others added 11 commits July 26, 2026 16:24
Extracted from stackable-odbc-rs, which held core, trino and sqlite in
one workspace. The manifest is de-inherited from that workspace and gains
the metadata crates.io requires. Version resets to 0.0.1.

Does not compile yet: stackable-odbc-core is ahead of the copy this
driver last built against. Adapted in the next commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
default_get_info and common_get_info_raw are now generic over the
backend. SQL_CURSOR_COMMIT_BEHAVIOR is now derived from
Backend::cursor_commit_behavior, which defaults to SQL_CB_PRESERVE (2);
this driver reports SQL_TC_NONE, so the default is correct and the
snapshot expectation was the stale part.

Also corrects two comments that justified SQL_TC_NONE with "Trino has no
explicit transactions". Trino does support transactions over the
X-Trino-Transaction-Id headers; it is this driver that does not implement
them. The reported capability is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The pre-commit configuration is stackable-odbc-core's extended set --
check-yaml, check-toml, check-merge-conflict, mixed-line-ending and
cargo-sort -- with shellcheck re-added for the nine shell scripts this
repository ships, and cargo test in place of cargo test --workspace.

deny.toml keeps stackable-odbc-rs's Windows target triple, since this
repository ships a DLL, and its RUSTSEC-2024-0436 ignore for the
unmaintained paste crate that trino-rust-client pulls in. Its ring and
OpenSSL licence allowances are dropped: rustls 0.23 resolves to
aws-lc-rs, so ring is absent from the tree entirely and no
OpenSSL-licensed crate is present. Verified by removing them and
re-running cargo deny check against all three targets.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Paths collapse now that the crate is the repository root: CRATE_DIR and
REPO_ROOT become one variable, and the dist directory moves from
crates/stackable-odbc-trino/packaging/dist to packaging/dist. The -p
package selector goes with the workspace it selected from.

The support link pointed at stackable-odbc-rs and now points here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
build.sh resolves paths from its own location, so it needed no edits; the
README's four nested crates/stackable-odbc-trino/connector/ paths did.

The .pq file's [Version = "1.0.0"] is the Power BI connector's own
version, which Power BI reads, not a Cargo version -- deliberately not
reset to 0.0.1.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
test/trino/ flattens to test/: there is no sibling suite here to
disambiguate from. Three consequences of the shallower path:

  - the test/trino/ usage strings and comments become test/
  - PROJECT_DIR was $SCRIPT_DIR/../.., which now overshoots the repository
    root. setup.sh and run-tests.sh would have built in the wrong
    directory, and windows_test.py would have looked for
    windows/openssl_legacy.cnf and the built DLL outside the repository.
  - cargo's -p selector goes with the workspace it selected from

Generated artefacts are left behind rather than copied: setup.sh
regenerates them, and the old odbc.ini held an absolute path into
stackable-odbc-rs. .gitignore also learns about Python bytecode, which
the five test scripts produce and nothing previously ignored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
WINDOWS.md documented both drivers; the SQLite half goes, along with its
build lines, driver and DSN registration, registry cleanup and the
SQLite-specific parameter table.

Two things are corrected rather than merely copied:

  - The PowerShell smoke test created and dropped a table, which assumes
    a writable catalog. Trino's DDL support depends on the connector
    backing the catalog, so it now selects from an inline VALUES list --
    self-contained, as the original intended to be.
  - The connection-string table omitted AccessToken/Token and
    QueryTimeout/LoginTimeout. Both are accepted by connect_params.rs.

The virtio-win ISO is copied across but stays untracked under the
target/ rule; start.yaml re-fetches it from a pinned, checksummed URL.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
build.yaml is adapted from stackable-odbc-core's, minus the Miri and fuzz
jobs: Miri cannot execute the OpenSSL and aws-lc-rs code
trino-rust-client links in, and both fuzz targets exercise core APIs. It
gains the Windows cross-compile job from stackable-odbc-rs, narrowed to
this one DLL.

release.yaml triggers on v* rather than trino-v*, which is the tag
cargo-release produces, reads the root Cargo.toml, and drops the
workspace-era -p selector and nested packaging paths. Its commented-out
integration-test block is gone; that TODO now lives in build.yaml.

Note that CI cannot pass until stackable-odbc-core is published: the path
dependency does not resolve on a runner. This is deliberate -- the
workflows are written for the published world, so swapping the path dep
for a version dep is the only change needed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AGENTS.md is driver-focused and self-contained: an agent working here
cannot read stackable-odbc-core's copy. The framework-internal sections
core owns -- adding an ODBC function, forward_ffi internals, Miri,
fuzzing -- are cross-linked rather than duplicated, to avoid drift.

Content is verified against source rather than carried over on trust: the
isql DSN names against test/setup.sh, and every connection-string key
against connect_params.rs. That last check is what surfaced the
AccessToken/Token and QueryTimeout/LoginTimeout keys missing from the
Windows documentation, now recorded in the changelog.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Mirrors stackable-odbc-core's, except for what gets version-bumped: this
crate is a loadable driver rather than a Rust dependency, so there is no
dep snippet to rewrite and the version strings kept current are the
packaging examples instead.

The v{version} tag it produces is what release.yaml triggers on.

Validated with a dry run: all four replacements fire, the changelog
footer's first-release rule emits the tag link exactly once (the
compare-form rule correctly no-ops via min = 0), the pre-release
pre-commit hook passes, and the allow-branch guard refuses to release
from a branch other than main.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The repository-wide sweep caught what the per-directory sweeps missed:
the Rust sources still carried 56 test/trino/ paths in #[ignore] reasons
and doc comments, seven cargo -p / --workspace invocations that no longer
have a workspace to select from, and one crates/stackable-odbc-trino/
path in escape_dialect.rs.

test/windows_test.py's HTTP_PORT comment said it avoided "SQLite's 8080".
The port is unchanged and still correct -- 8080 is Trino's port here --
but the reason it gives now matches the repository it lives in.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@maltesander
maltesander requested review from adwk67 and lfrancke July 26, 2026 15:24
@maltesander maltesander self-assigned this Jul 26, 2026
maltesander and others added 16 commits July 26, 2026 20:05
stackable-odbc-core made fourteen capability facts required `Backend`
methods, on the rule that a value whose zero is an answer rather than
"unknown" must come from the backend. Implementing them corrected five
values this driver had been reporting wrongly, each confirmed against a
live Trino 467 coordinator rather than the documentation:

- SQL_NULL_COLLATION was SQL_NC_HIGH, meaning NULL position follows
  ASC/DESC. Trino orders NULLS LAST in both directions, which is
  SQL_NC_END.

- SQL_GROUP_BY was SQL_GB_NO_RELATION, which tells an application it may
  put arbitrary non-grouped columns in the select list. Trino rejects
  that with EXPRESSION_NOT_AGGREGATE while allowing GROUP BY columns
  absent from the select list -- SQL_GB_GROUP_BY_CONTAINS_SELECT.

- SQL_DEFAULT_TXN_ISOLATION was SQL_TXN_READ_COMMITTED while
  SQL_TXN_CAPABLE reported SQL_TC_NONE and SQL_TXN_ISOLATION_OPTION
  reported 0, so it named a level absent from its own supported set. The
  spec's value for a data source without transactions is 0.

- SQL_ALTER_TABLE and SQL_SQL_CONFORMANCE were core's defaults: 0, which
  claims Trino cannot ALTER TABLE at all, and SQL_SC_SQL92_ENTRY, which
  Trino does not reach -- its CREATE TABLE rejects PRIMARY KEY, UNIQUE,
  CHECK and REFERENCES, and entry level requires both referential
  integrity and COMMIT/ROLLBACK.

SQL_CORRELATION_NAME, SQL_NON_NULLABLE_COLUMNS and
SQL_EXPRESSIONS_IN_ORDERBY had been reaching applications as
SQL_CN_NONE, SQL_NNC_NULL and the empty string, none of which Trino
warrants and the last of which is not one of that info type's two
values.

The SQL_CATALOG_NAME, SQL_NULL_COLLATION, SQL_OJ_CAPABILITIES,
SQL_DEFAULT_TXN_ISOLATION and SQL_TXN_ISOLATION_OPTION arms are removed
from trino_get_info. Core derives all five from a hook, and an arm here
would answer SQLGetInfo while the hook still drove SQLGetConnectAttr and
the HY024 validation in sql_set_connect_attr -- the two could then
report different things for one connection.

timedate_add_intervals and timedate_diff_intervals stay 0. The spec
defines them as the intervals TIMESTAMPADD/TIMESTAMPDIFF accept, and
those escapes do not yet survive translation; the doc comment records
the value to use once they do.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The `SQL_*_FUNCTIONS` bitmaps are defined by the spec in terms of the
ODBC escape -- "an application can determine which string functions are
supported by a driver by calling SQLGetInfo with an information type of
SQL_STRING_FUNCTIONS", and what it emits next is `{fn NAME(...)}`. This
driver read them instead as "does Trino have an equivalent", and
advertised twelve functions whose escape a client could not actually
use. Each was checked against a live coordinator:

    {fn LOCATE('b','ab')}                 FUNCTION_NOT_FOUND: 'locate'
    {fn POSITION('b','ab')}               FUNCTION_NOT_FOUND: 'position'
    {fn CURDATE()}                        FUNCTION_NOT_FOUND: 'curdate'
    {fn CURTIME()}                        FUNCTION_NOT_FOUND: 'curtime'
    {fn CURRENT_DATE()}                   SYNTAX_ERROR
    {fn USERNAME()}                       FUNCTION_NOT_FOUND: 'username'
    {fn DBNAME()}                         FUNCTION_NOT_FOUND: 'dbname'
    {fn DAYOFWEEK(d)}                     FUNCTION_NOT_FOUND: 'dayofweek'
    {fn TIMESTAMPADD(SQL_TSI_DAY, 1, t)}  COLUMN_NOT_FOUND: 'sql_tsi_day'

Trino can do all of them, but only through `position(sub IN str)`, the
bare `current_date`/`current_user`/`current_catalog` keywords without
parentheses, or `date_add('day', 1, t)` with a quoted unit. None is a
rename, and `EscapeDialect::remap_scalar_fn` only swaps the identifier
in front of the parentheses -- it never sees the arguments. Restoring a
bit therefore means teaching core's translator to rewrite the whole
call, which is why the bits are dropped rather than the dialect
extended here.

`DAYOFWEEK` is dropped for a second reason: renaming it to Trino's
`day_of_week()` would succeed and return an ISO-numbered day (1 =
Monday) where ODBC specifies 1 = Sunday -- a silently wrong answer
rather than a loud failure.

The three snapshot tests over these bitmaps only restated the constants
they guarded, so they could not have caught the overclaim; they are
joined by untranslatable_escapes_are_never_advertised, which asserts
both that each name's bit is clear and that the dialect has no remap arm
for it. Verified by re-adding SQL_FN_STR_LOCATE and confirming the test
fails.

Also answers SQL_MULT_RESULT_SETS, SQL_NEED_LONG_DATA_LEN and
SQL_MAX_ROW_SIZE_INCLUDES_LONG with "N". All three are Y/N strings with
no arm in core's default_get_info, so they were reaching applications as
the empty string, which is not one of the two values the spec defines
for any of them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…re capabilities

Two rounds of core changes land here.

--- The escape rewrites ---

EscapeDialect::rewrite_scalar_fn receives a whole {fn NAME(args)} call, so
the twelve functions this driver had to stop advertising can be translated
rather than dropped. Each is verified end to end against a live coordinator
by every_advertised_scalar_function_escape_runs_on_trino, which executes
all 40 advertised escapes -- the check the bitmaps actually need, since the
unit tests can only say what text a rewrite produces, not whether it runs:

  {fn LOCATE('b', 'ab')}                  position('b' IN 'ab')
  {fn CURDATE()} and the CUR*/CURRENT_*   the bare keyword, () removed
  {fn USERNAME()} / {fn DBNAME()}         current_user / current_catalog
  {fn TIMESTAMPADD(SQL_TSI_DAY, 1, t)}    date_add('day', 1, t)
  {fn DAYOFWEEK(d)}                       ((day_of_week(d) % 7) + 1)

DAYOFWEEK is the one where the rewrite earns its keep. Trino has
day_of_week(), so a rename would have succeeded -- and returned an
ISO-numbered day where ODBC specifies 1 = Sunday. The conversion is asserted
against a known Monday rather than trusted.

POSITION needed nothing at all: ODBC spells it POSITION(exp IN exp), which
is already Trino's syntax. It was dropped for a calling convention it never
had. LOCATE comes back as SQL_FN_STR_LOCATE_2, the two-argument flag, since
ODBC's third argument is a start offset and strpos()'s is an occurrence
index.

split_args is the one piece of parsing this crate does that core declined
to, because only the dialect knows each function's arity. It is
literal-aware for the same reason core's translator is: a comma inside a
string, a quoted identifier, a comment or a nested call is not a separator.
Core's own worst case, {fn LOCATE(',', x)}, is a test case.

SQL_TIMEDATE_ADD_INTERVALS and SQL_TIMEDATE_DIFF_INTERVALS follow from
TIMESTAMPADD/TIMESTAMPDIFF working, and are held to the units the dialect
can rewrite. SQL_FN_TSI_FRAC_SECOND stays out: ODBC defines it as
billionths of a second and Trino's finest unit is millisecond.

--- The nine new capability hooks ---

Core's default_get_info_answers_are_backend_derived_or_declared_core_facts
turned nine more values into required methods. Each was measured:

- accessible_tables is now "N", changed from the "Y" core used to supply.
  "Y" guarantees the connected user has SELECT on every table SQLTables
  returns, which depends on the deployment's access control -- Trino filters
  information_schema only when one is configured.
- concat_null_behavior is SQL_CB_NULL: concat('a', NULL) is NULL.
- subqueries claims every SQL_SQ_* bit, each executed.
- convert_functions is CAST only; CONVERT('1', INTEGER) does not resolve.
- The rest -- column_alias, union_support, order_by_columns_in_select,
  data_source_read_only, search_pattern_escape -- keep the values the driver
  already reported, now stated rather than inherited.

Also answers SQL_DATABASE_NAME with the connection's catalog, which core
cannot know: common_get_info_raw takes no connection, and its shared empty
string is only right for a backend that cannot name its current database.
TrinoConnection gains the catalog for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Core's `Backend::keywords` hook replaces the shared empty string, which
claimed this data source reserves nothing beyond ODBC's own list. It
reserves 22 words ODBC does not -- UNNEST, LISTAGG, ROLLUP, the JSON_*
family, both LOCAL* forms and the rest -- and applications read
SQL_KEYWORDS to decide which identifiers need quoting, so the empty list
could leave a generated identifier unquoted where it collides. Verified
against a live coordinator: `SELECT 1 AS unnest` is a syntax error and
`SELECT 1 AS "unnest"` is not.

The list is static, unlike the SQLite driver's, which reads its keywords
out of the linked library through sqlite3_keyword_name. Trino has no
equivalent: system.jdbc -- the schema backing the JDBC driver's
DatabaseMetaData, and so the one place such a list would live -- has no
keywords table, nor does system.metadata, and this driver speaks HTTP so
there is no library to ask. Trino's own JDBC driver hardcodes
getSQLKeywords() for the same reason. The hook returns the raw 83 reserved
words and core subtracts ODBC's 234, which is where the spec puts the rule:
"this list does not contain keywords specific to ODBC or keywords used by
both the data source and ODBC".

Deliberately not gated on server_major, unlike SQL_SQL92_PREDICATES and
SQL_SQL92_RELATIONAL_JOIN_OPERATORS. The safe direction is inverted here:
over-reporting a keyword only makes an application quote an identifier it
need not have, while under-reporting leaves a genuinely reserved word
unquoted and the statement fails to parse. So it tracks the newest list.
The drift that costs is small and additive -- of twelve words sampled
against a live 467, eleven were already reserved and only AUTO was newer.

The FFI test asserts the value through sql_get_info_w rather than only
recomputing the subtraction in a unit test. Redoing the subtraction proves
the list is right but not that core is asking this backend for it: before
the hook existed core answered every backend with "" and SQLGetInfo still
returned SUCCESS.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…onestly

The .mez overrode six SQLGetInfo values, and a Power Query override wins
over SQLGetInfoW silently -- it cannot be corrected by fixing the driver.
Two of them contradicted what the coordinator will actually accept.

SQL_SQL92_PREDICATES was forced to 0x3FFF and
SQL_SQL92_RELATIONAL_JOIN_OPERATORS to 0x3FF, every bit set. The driver
gates both on the coordinator's version, because MATCH and UNIQUE arrived
in Trino 482, OVERLAPS in 483 and CORRESPONDING in 475; the flat values
re-asserted all of them on every server, including the 467 in
test/docker-compose.yml. 0x3FF additionally claimed NATURAL JOIN, which a
live 467 rejects with "NOT_SUPPORTED: Natural join not supported", and
UNION JOIN, which has no production in Trino's grammar at any version.
Power BI folding any of those emits SQL the coordinator refuses.

Nothing Power Query actually generates is lost by removing them:
comparison, IN, LIKE, BETWEEN, IS NULL, EXISTS and the four join types are
all in the driver's ungated set.

SQL_AGGREGATE_FUNCTIONS, SQL_SQL92_VALUE_EXPRESSIONS and
SQL_IDENTIFIER_QUOTE_CHAR were overridden to exactly the values the driver
already reports. Harmless today, duplicated state tomorrow.

GroupByCapabilities is not a documented SqlCapabilities field. The GROUP BY
relationship belongs in SQL_GROUP_BY, which the driver now answers with
SQL_GB_GROUP_BY_CONTAINS_SELECT -- the same value that was sitting in the
wrong record. Its comment claimed "Trino supports GROUP BY without
restrictions", which is wrong either way: a non-aggregated select column
absent from GROUP BY fails with EXPRESSION_NOT_AGGREGATE.

SQL_SQL_CONFORMANCE stays overridden to SQL_SC_SQL92_FULL. The driver's own
0 and this 8 are both right for their audience: to an ODBC application the
info type is a conformance claim Trino does not meet, while to Power Query
it is the knob that unlocks SQL generation, and Microsoft's guidance is
explicit -- "most drivers will want to report a SQL_SC_SQL92_FULL
compliance level, and override specific SQL generation behavior using the
SQLGetInfo and SQLGetFunctions properties". SupportsDerivedTable also
defaults to false below that level, and derived tables are required for
many DirectQuery scenarios. The stale comment claiming the driver reports
"SQL_SC_MINIMUM (1)" is replaced with that reasoning.

Not verified in Power BI: this repo has no way to drive it. Every claim
about what Trino accepts was checked against a live 467 coordinator.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Still needed, and load-bearing: 56 `#[serial]` attributes across
`ffi_integration_tests.rs` plus the backend tests. The FFI tests share one
ODBC connection through a `OnceLock` and the backend tests open their own
`TrinoConnection`, so without serialisation two independent reqwest
connection pools hit the same coordinator and intermittently corrupt a TCP
socket -- the reason AGENTS.md tells you not to run the two suites together.

No source changes: 4.0.1 needed no API adjustment here.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Core gives every `Backend` and `StatementBackend` method one associated
error type, requires `identifier_case`, and makes the descriptor and
type-info structs non-exhaustive with builders. Adapt to all of it.

`TrinoError` gains an `Odbc` variant and `From<OdbcError>`, which is what
lets a defaulted trait body construct an error and still name
`Self::Error`. It wraps rather than flattens, and the reverse direction
unwraps it, so the SQLSTATE core chose survives the round trip instead of
degrading to HY000. The hand-built internal-invariant errors keep
building an `OdbcError` and converting: that preserves their exact
message and SQLSTATE, including the 24000 on an abandoned result set,
which no `TrinoError` variant expresses.

`SQLDescribeCol` reports SQL_NULLABLE_UNKNOWN rather than SQL_NULLABLE.
Trino's REST protocol describes a result column with a name and a type
and nothing else, so neither definite answer has a basis, and the spec
defines the third value for that case.

`column_count` narrows to i16 here, where the real number is known,
rather than leaving core to clamp a value it cannot interpret.
`describe_col` hands back a borrow, which `SQLColAttribute` reads once
per column per attribute. `close_cursor` reports a failed page drain
after completing teardown, so a dirty pooled socket reaches the
application without stranding the statement.

The FFI tests move onto `test_support::attach_connection`, core's
supported replacement for reaching into the private `handles` module,
and detach before freeing because `SQLFreeHandle` refuses a connection
handle that still holds a connection.

Drop the `odbc-sys` dev-dependency in favour of core's re-export. The
only use is `Timestamp`, read back out of a buffer core wrote, and
nothing pinned the two declarations to one version -- two versions of a
`#[repr(C)]` struct are two different layouts.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`SQLGetDiagRec` reports the native error through `NativeErrorPtr`, and
Trino's error taxonomy is the only thing that can meaningfully go there.
Every failure reported `0` with the client error flattened into a
message string, which is the one form neither the code nor the causal
chain survives.

`map_trino_error` gains a `Query` variant for everything it does not
classify into a specific SQLSTATE, keeping the client error as the cause
and lifting `QueryError::error_code` into the native error. The
classified variants are untouched on purpose: `validate_connection`
matches on them to keep an auth failure at 28000 rather than
reclassifying it as 08001, so collapsing them would have moved that
silently.

Two limits are documented rather than worked around. PERMISSION_DENIED
never arrives with a code, because the client turns it into
`Error::Forbidden` and drops the code on the way; and a transport
failure has no Trino code at all, so both report `0` -- the spec's
value for "no native code".

`sensitive_connect_keywords` declares the two bearer-token spellings.
Core's substring heuristic already covers them, so nothing that leaked
before stops leaking; it states this driver's own vocabulary instead of
depending on another crate's pattern list to keep matching it.

`get_functions` stays an explicit list rather than becoming
`CORE_EXPORTED_FUNCTIONS`: core exporting a symbol says the entry point
exists, not that this driver implements the function behind it, and 16
of the 69 are ODBC 2.x functions superseded in 3.x or descriptor-field
functions with no handle to work on. Both directions are now pinned by
tests, so neither the claim nor the withholding can drift unnoticed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
pre-commit ran in its own workflow, and `finished` -- the single check
branch protection requires -- did not list it in `needs`. Formatting,
clippy and cargo-deny could therefore all fail while the required check
reported success. Fold the job into this workflow, because `needs:`
cannot cross workflows, and add it to `needs`.

`finished` now derives its verdict from `join(needs.*.result, ' ')`
instead of comparing two job names by hand, so a job added to `needs`
and forgotten in the script cannot be silently non-blocking. That is the
same shape as the bug above.

Also: a concurrency group that supersedes in-flight PR runs but never
cancels a merge_group run, since a cancelled one reports failure and
evicts the PR from the queue; `timeout-minutes` on every job; `--locked`
on the cargo invocations in CI and in the hooks, so a Cargo.toml change
without a matching lockfile update fails here rather than drifting; and
the redundant `token:` on checkout dropped, GITHUB_TOKEN being its
default.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AGENTS.md told a contributor to write
`.map_err(|e| OdbcError::from(map_trino_error(e)))?` in functions
returning `OdbcError`. Both traits give every method one associated
error type, so no such function exists and the idiom is just
`.map_err(map_trino_error)?`. Record what replaces it: when a hand-built
`OdbcError` is still right (a SQLSTATE no `TrinoError` variant carries),
and why `map_trino_error`'s classified arms cannot be collapsed --
`validate_connection` matches on two of them.

Also document the things the compiler enforces but does not explain: the
non-exhaustive types from core and their builders, why `TypeInfoRow`'s
are `const`, that `odbc-sys` comes from core's re-export rather than a
dependency of this crate, and that `test_support` is how a test puts a
connection into a handle now that `handles` is private -- including the
HY010 teardown that catches out the obvious spelling.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`actions/checkout` defaults its `token` to `secrets.GITHUB_TOKEN`, so
passing it explicitly states the default and reads as though some
elevated credential were in play. The `token` on `rustsec/audit-check`
stays: that one is the action's own input, which it needs to post
advisory annotations onto the run.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…hheld

`get_functions` was a hand-written list, with the sixteen functions it
omitted recorded only as a set pinned inside a test and justified by one
prose comment covering all of them. Split it into
`TRINO_ADVERTISED_FUNCTIONS` and `TRINO_WITHHELD_FUNCTIONS`, the latter
carrying a reason per entry, and assert that the two partition
`CORE_EXPORTED_FUNCTIONS` exactly.

The advertised set is unchanged: the same 53 functions, so
`SQLGetFunctions` answers exactly as before.

It stays opt-in rather than becoming `CORE_EXPORTED_FUNCTIONS` minus the
withheld set, because the two directions fail differently. Deriving it
means a function core exports later is advertised without anyone
deciding it works, and the Windows Driver Manager builds its dispatch
table from that answer. Listing it means such a function is merely not
advertised -- and now lands in neither list, so the partition test names
it and someone has to classify it.

Mirrors core's own `CORE_EXPORTED_FUNCTIONS` /
`CORE_UNEXPORTED_FUNCTIONS` pair and its
`every_function_id_is_declared_exported_or_not`, one level up.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adapts to five breaking changes in stackable-odbc-core. Four are mechanical;
the cancel token is not.

Backend::cancel took &mut Self::Statement, which SQLCancel cannot produce
from a thread holding no lock on the connection while another thread executes
on the same statement -- the case the ODBC spec singles out. It now receives a
TrinoCancelToken built once per statement from the connection, carrying the
client, the runtime and a shared CancelState.

That state is load-bearing beyond naming the query. The old cancel also
cleared next_uri, because polling get_next after a server-side cancel fails
and leaves the pooled TCP socket carrying residual bytes, which surfaces later
as an unrelated query failing. With no statement to mutate, cancel publishes a
flag instead and fetch/close_cursor read it. exec_direct fills the query-id
slot as soon as the coordinator accepts the query rather than after the
metadata-polling loop, so a query cancelled while still queued is now
reachable -- it was not before. begin_query clears the flag as well as setting
the id, since core never replaces a statement's token across a re-execute.

The six catalog functions take the token but record nothing in it: four do no
I/O, and tables/columns page inside Client::get_all, which never surfaces a
query id. They were not cancellable before either.

The 25 required capability declarations, plus get_type_info and
escape_dialect, take &Self::Connection. This driver reads it from none of them
-- every value is a fact about Trino-the-engine or about this driver's own SQL
generation -- but it is what a version-gated declaration would use, which is
why TrinoConnection::server_major exists.

Pre-connect, core skips every declaration needing a connection and substitutes
its own benign default, so 22 of this driver's documented SQLGetInfo answers
no longer appear before SQLDriverConnectW. None becomes SQL_ERROR; the
connected answers are unchanged. get_info_snapshot now asserts the connected
path, which is what that table has always been about.

TypeInfoRow's four string-setting builders take impl Into<Cow<'static, str>>,
and Into cannot run in a const context, so TRINO_TYPE_INFO moves from a static
to info::trino_type_info() behind a OnceLock. TRINO_RESERVED_KEYWORDS stays a
plain &[&str] so the list remains readable, with reserved_keywords() lifting
it into Cows once.

Adds cancel_from_another_thread_while_fetching, covering the scenario the
whole token design exists for and the one place it could plausibly deadlock
rather than cancel: two threads inside block_on on the same current-thread
runtime. It needs a live Trino and has not yet been run.

Lifts disconnected_trino_conn out of ffi_integration_tests into backend.rs;
the unit tests need a connection now too, and it was about to be duplicated.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Three changes, landing together because core moved mid-task and the tree does
not compile without all of them.

1. A SQLFetch interrupted by a concurrent SQLCancel now reports HY008.

Running the cross-thread cancel test against a live coordinator for the first
time found a real gap. The cancel flag is set by the cancelling thread only
after its DELETE returns, by which point Trino may already have failed the
in-flight page request -- so fetch's flag check, which sits at the top of the
loop, never saw it, and the request surfaced as a raw USER_CANCELED under
HY000.

map_trino_error now recognises Trino's USER_CANCELED code and yields
TrinoError::OperationCancelled, which carries HY008. The spec gives that state
to a function interrupted by SQLCancel from another thread and does not mark
the clause (DM), so it is the driver's to report. Reading the server's verdict
rather than the local flag needs no cross-thread ordering and also catches a
query killed by something else, e.g. CALL system.runtime.kill_query.

end_page_fetch keeps that case off abandon_result_set: a cancellation the
application asked for leaves a finished result set, not the undefined cursor
position 24000 describes.

Core has no named constructor for HY008 because it documents HY008 as never
returned by a driver ("not applicable; the Backend trait is synchronous").
Cross-thread SQLCancel made that false, so this builds it with SqlState::new.
Worth raising against core.

2. string_parameter_with_quotes_is_not_injected was reading out of bounds.

It bound payload.as_bytes().to_vec() -- exactly 23 bytes, no terminator --
while passing a NULL StrLen_or_IndPtr, which SQLBindParameter defines as "the
data is null-terminated". Core therefore scanned for a NUL past the end of the
allocation and returned the payload plus whatever the heap held next, which
read as "payload was altered in transit". It passed alone and failed after 53
other tests had dirtied the allocator, which is why the handoff recorded one
sighting and dismissed it as an artefact; it reproduced 5/5.

The binding now passes an explicit indicator. The NULL-indicator form was
untested for strings, which is what let the bad binding look like a driver
bug, so string_parameter_bound_as_nts_is_not_injected covers it with a
properly terminated buffer.

3. Adaptation to core's sealing and constant export.

EscapeDialect, ColumnDescriptor and TypeInfoRow have pub(crate) fields and
accessors, so 51 field reads become calls. TypeInfoRow::type_name returns &str
rather than Cow, dropping the as_ref() the previous commit needed. SQL_ADD and
SQL_DIAG_MESSAGE_TEXT are gone from types, restated as
BulkOperation::Add as i16 and HeaderDiagnosticIdentifier::MessageText as i16,
which is what core's own note at the removal site directs.

Backend::describe_param is additive and defaulted, and this driver keeps the
default: Trino has no wire-level parameter binding, so it cannot describe a
parameter without parsing the SQL, and core's documented uniform fallback beats
a specific guess indistinguishable from a real answer.

Verified against a live coordinator: 244 offline, 8 backend, 55 FFI, with the
FFI suite run three times and the cancel tests eight.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
SQL_CONVERT_FUNCTIONS has always reported SQL_FN_CVT_CAST, which advertises
the ODBC conversion escape, but escape_dialect.rs had no CONVERT arm. The
target type reached Trino as a bare identifier in an argument position, so
SELECT {fn CONVERT('1', SQL_INTEGER)} failed with COLUMN_NOT_FOUND on
sql_integer. Same class as the twelve scalar functions fixed earlier: a client
reading the bitmap is entitled to send this.

rewrite_scalar_fn is the hook that can express it -- the fix is not a rename
but a change of shape, from a two-argument call to a CAST -- and
trino_convert_target maps all 26 ODBC type keywords that have a Trino
equivalent.

Two mappings were measured against a live coordinator rather than read off the
documentation, and both would have been wrong the obvious way:

- SQL_CHAR maps to VARCHAR, not CHAR. A bare CHAR in Trino is CHAR(1), so
  CAST('hello world' AS CHAR) returns "h". The escape carries no length to
  give CHAR(n) instead, so mapping it to CHAR would have silently truncated
  every conversion to one character -- worse than not translating at all.
  convert_to_char_does_not_map_to_trinos_truncating_char pins this.
- SQL_FLOAT maps to DOUBLE. ODBC's FLOAT is double precision; Trino's REAL is
  the single-precision type, which is SQL_REAL.

The SQL_INTERVAL_* keywords are left unmapped: no bare CAST reaches Trino's
interval types, so declining leaves the call on the fallback path rather than
casting to something the application did not ask for. A warn! names the
keyword when that happens, per the degraded-behaviour rule in AGENTS.md.

every_convert_escape_target_runs_on_trino covers the live side. It is a
separate test from every_advertised_scalar_function_escape_runs_on_trino
because CONVERT is advertised through SQL_CONVERT_FUNCTIONS, not through the
SQL_*_FUNCTIONS bitmaps that test walks -- which is precisely how the escape
stayed advertised and untranslated with a green suite. The binary targets read
back through to_hex, since Trino has no VARBINARY -> VARCHAR cast, which also
makes them assert a value instead of merely running.

Verified end to end through unixODBC: isql now answers
SELECT {fn CONVERT('1', SQL_INTEGER)} AS n with 1.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
… text

JSON has no literal for NaN or the infinities, so Trino sends them as strings:
a DOUBLE or REAL column carrying one arrives as "NaN", "Infinity" or
"-Infinity". Confirmed off the wire rather than from the documentation -- a
direct /v1/statement call returns [["NaN", "Infinity", "-Infinity", "NaN"]].

json_to_column_value had no arm for a string-valued float column, so the value
fell through as ColumnValue::String and core refused String -> Double with
22018: the three values were simply unreadable as numbers. Both float arms now
try trino_special_float when as_f64 declines, which covers REAL as well as
DOUBLE -- both reached the fallback through the same as_f64.

The spellings are matched exactly, not case-insensitively. These are the three
Trino emits, and looser matching would silently turn some other data source's
text into a number.

The quoting the task brief flagged as a second, possibly wider problem turns
out to be exactly that. Every fallback in this function rendered its value with
Value::to_string(), which re-encodes a string as JSON, so any value that failed
to convert reached the application carrying two quote characters it never sent
-- "NaN" was merely the reachable instance. json_as_text yields the text Trino
actually sent. The twelve remaining to_string() calls are in `else` branches
where the value is provably not a string, so they already produce the same
thing and are left alone.

Verified through unixODBC: isql now answers CAST(nan() AS DOUBLE) with NaN
rather than "NaN", and ieee_special_floats_are_readable_as_c_double reads all
six DOUBLE/REAL cases as SQL_C_DOUBLE.

One consequence worth recording rather than fixing here: read as text, an
infinity now renders as "inf", which is Rust's Display. Trino and Java's
Double.toString both spell it "Infinity". That conversion is core's F64 -> text
path, not this driver's, and no ODBC text form is specified, so it is a
consistency question for core rather than part of this fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
maltesander and others added 28 commits August 2, 2026 21:23
AGENTS.md gains a section for the version resource, recording the three
decisions that are easy to undo by accident: every value coming from
cargo's environment so it cannot disagree with the package, release.toml
deliberately having no rule because CARGO_PKG_VERSION already tracks the
bump, and the gate being CARGO_CFG_TARGET_OS rather than cfg!(windows),
which is false on the Linux host that cross-compiles the shipped DLL.

It also records where the Administrator's buttons are tested, and why
Cancel and Remove are asserted rather than photographed.

WINDOWS.md gains the command, and says what the screenshots are for.

The CHANGELOG entry states what an application sees: the driver lists
with its version and Stackable GmbH instead of "Not marked" twice.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
System.Data.Odbc calls SQLDriverConnectW with SQL_DRIVER_NOPROMPT, the
same as pyodbc, so a connection made from the dialog's Test button may
never show a login URL. Measured through the Windows Driver Manager
against a live Keycloak, testing an ExternalAuthentication data source
returns

  [28000] ExternalAuthentication needs to show a login URL, and this
          connection was made with SQL_DRIVER_NOPROMPT; supply
          AccessToken instead

which is correct, and a good diagnostic, and still arrives under a
"Connection failed" heading that reads as the settings being wrong. They
are not: the same data source connects and opens a browser from any
application that permits prompting.

The button now checks the keyword before connecting and reports that the
login cannot be driven from here, as information rather than a failure,
naming the reason and what to do instead.

Testing it for real would mean replacing System.Data.Odbc with a direct
SQLDriverConnectW at SQL_DRIVER_COMPLETE, which the script could do --
it already P/Invokes odbccp32 for ConfigDSN. That also means reading the
result columns through raw ODBC in PowerShell, so it is left until an
OAuth user asks for it, and AGENTS.md records the option.

Verified on the VM: the OAuth data source now reports "Cannot be tested
here", and dsn_dialog_test.py's ordinary password path still passes
every check, Test connection included.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Core is its own repository, so depend on it as one. A path dependency on
`../stackable-odbc-core` required every contributor to clone a second
repository by hand and could not resolve on a CI runner at all.

`Cargo.lock` pins the resolved commit, so the build stays reproducible even
though the dependency tracks a branch. `deny.toml` allows the repository by
name, leaving `unknown-git = "deny"` to reject any other git source.

To build against a local core checkout, use a `[patch]` in a gitignored
`.cargo/config.toml` instead of editing `Cargo.toml`, so the override cannot be
committed. Note that cargo rewrites core's `Cargo.lock` entry to a path while
that patch is active.

`packaging/test-sbom.sh` expected two path-sourced components. There is now
one, the root package, and the assertion states the invariant it was really
protecting: a path-sourced component other than the root package means a
developer's local override reached a release artifact. A new check requires
core's purl to carry a resolved 40 character commit rather than a branch name.

`cargo publish` stays blocked until core reaches crates.io, which is what the
remaining TODO tracks.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The documentation was accurate but uniformly machine shaped, and much of it
narrated a history no reader has: nothing has been released, so a before and
after contrast describes a state nobody saw.

Prose rules applied throughout: no em dashes, present tense describing present
state, fact before reason, no "deliberately" or "load-bearing" or "worth
knowing", and prose matched to its reader. SQLSTATEs and spec citations belong
in AGENTS.md and in code comments, not in README.md. The measured facts are the
value of this documentation and every one is kept; only the prose around them
is compressed.

CHANGELOG.md drops 974 lines of pre-release diff history for a short entry
describing what the driver offers, plus its known limitations.

README.md loses the internal reasoning and gains compatibility, troubleshooting
and support sections. Building and testing move to a new CONTRIBUTING.md, which
GitHub also links from the issue and pull request UI. AGENTS.md puts the
architecture first and splits a 768 line "Conventions" section that was mostly
design rationale. connector/README.md keeps the install path and moves 113
lines of maintainer notes to connector/DEVELOPING.md. packaging/README.md ships
inside both release archives, so installation now precedes the build tooling.

Nine factual errors found and fixed along the way:

- integration-tests/README.md documented a --force-recreate flag that makes
  setup.sh exit 2
- WINDOWS.md expected ODBC_ERROR_INVALID_KEYWORD_VALUE, which predates the DSN
  dialog and contradicted the same file
- connector/build.sh printed the wrong Get Data entry name
- the CI TODO named a hive compose profile that does not exist
- test_integration.py claimed SQLNumParams is unimplemented; it is implemented
  in core
- UID and PWD are accepted but were documented nowhere
- PARAM_EXTERNAL_AUTHENTICATION claimed User is required, where the code, the
  tests and AGENTS.md all make it optional
- test_c_abi.py claimed the query timeout needs Threading = 2, where AGENTS.md
  records the opposite as measured
- metadata.rs claimed both test catalogs return no table privileges; there are
  three, and hive returns rows

Three doc comments were attached to the wrong item, which is why
map_trino_error and cancel appeared to be undocumented.

connector/StackableTrinoODBC80.png is removed. Power Query defines two icon
groups, 16/20/24/32 and 32/40/48/64, and every connector in Microsoft's
DataConnectors samples ships exactly those seven sizes. Nothing referenced the
80, and the Driver Manager never sees these icons: they are read from inside
the .mez and the driver is listed from the VERSIONINFO resource instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
release.yaml called build-archives.sh but built with plain cargo, so the first
tag push would have died in packaging: sbom.sh refuses a binary carrying no
.dep-v0 section, and syft was not on the runner at all.

Both tools are now installed at pinned versions, named once in the workflow's
env, and both targets build with `cargo auditable build --locked --release`.
--locked matters here beyond the usual reason: the SBOM describes what was
linked, so an unlocked build would produce an accurate document about an
unintended dependency set.

The six SBOMs and sha256sums.txt were being generated and thrown away; they are
release assets now. actions/attest-build-provenance signs where the archives
came from and actions/attest-sbom binds each artifact to its CycloneDX
document, which is what the job-level id-token and attestations permissions are
for. The top level drops to contents: read, leaving publish-release as the only
writer. Code signing stays a TODO: attestation proves where a binary was built,
not who vouches for it.

On the build side, windows-cross-compile becomes release-artifacts, which also
builds the Linux release .so and runs sbom.sh --check-native on both. That
fragment is hand-maintained, so nothing else keeps it true, and a pull request
is where a dependency change can still be reverted cheaply.

Runner images are pinned across all four workflows. windows-latest resolves to
Windows Server 2025, while the driver's Windows behaviour was measured on a
2022 VM, so the suite now runs on windows-2022. The label cannot be lifted into
a variable: runs-on accepts no env context, and vars would move the pin out of
the repository.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A refused port, a certificate signed by an authority the client does not trust
and a host that does not resolve all set reqwest's is_connect(), so one arm of
map_trino_error classifies all three and the message is the only thing that
separates them. It separated nothing: reqwest::Error's Display names only its
own layer, so every one of them read "unable to reach Trino server: error
sending request for url (...)", and CommunicationLinkFailure carries a String
rather than a source, so core's Diagnostics had no chain left to walk. The
information was discarded at the format!, not lost upstream.

flatten_causes walks the chain into the message first. A refused port now ends
in "tcp connect error: Connection refused (os error 111)", measured, and a
rejected certificate carries whatever rustls says instead of vanishing.
Segments already present are dropped, since the layers quote each other.

This came from a report that a TLS misconfiguration and a dead server are
indistinguishable in the Windows installer UI, which is true of anything
holding only the diagnostic record. Two of the three cases stay
indistinguishable and that is not fixable here: Jetty serves a different
certificate for an SNI it cannot match, so connecting by IP fails as an unknown
issuer rather than a name mismatch.

The certificate wording is unverified against a live coordinator; only the
refused-connection path is measured. The flattening itself is asserted on a
synthetic chain, so it holds whatever rustls decides to say.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two more sites stringified a reqwest error with its shallow Display, the same
way the connect arm did.

`connect`'s client build is the one that matters. reqwest::ClientBuilder::build
reports a TLS trust store it cannot assemble as a bare "builder error", the
client wraps that as Error::HttpError, and the reason a certificate was refused
sits one source() down. Someone with a bad Certificate= therefore read "failed
to build Trino client: http error, reason: builder error", which says less than
the message that prompted this work.

The timeout arm beside the connect arm has the same shape. Its detail is
smaller, "operation timed out" against reqwest's "error sending request for
url", but it separates a timeout waiting for a connection from one waiting for
a coordinator that took the request and is still thinking.

Its test builds a genuine reqwest timeout from a loopback listener that accepts
and then answers nothing, rather than a synthetic error: a refused port yields a
connect error instead, and no coordinator is needed to make a request outlive
its own timeout. It costs under a second and reaches no network.

The client build has no test. Both errors that reach it need crafted TLS
material, since a certificate that fails to parse is rejected earlier by
Ssl::read_pem, and the flattening itself is already covered.

Arms that carry a source are untouched and need nothing: QueryCause::Transport
holds the client error whole, so core walks that chain already.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Verified against the compose stack rather than reasoned about. Each failure now
ends in its own sentence, and the table names all four.

One of them corrects what was written when the fix landed. The claim was that
the two certificate cases collapse into one, on the grounds that an unmatched
SNI reaches Trino's internal certificate rather than a mismatched name. The
first half is right and the conclusion is not: reaching a different certificate
is not the same failure as reaching an untrusted one, and rustls says so,
rejecting the internal certificate as a CA presented as an end entity while an
anchor that signed nothing is UnknownIssuer.

What does stay indistinguishable is a Certificate= file that is not a
certificate. It reads UnknownIssuer like an untrusted anchor, because reqwest
defers parsing to the handshake instead of failing in Ssl::read_pem.

The TLS suite passes unchanged against the live stack, 19 checks and 1 note.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
run-tests.sh decided the stack was up from the presence of generated/stack.env.
That directory survives a teardown, so after any earlier session the file was
there and setup.sh was skipped with nothing running. The file header already
claimed the behaviour this now implements.

The failure mode is worse than a red run. Every check needing the coordinator
fails with "unable to reach Trino server", but the checks asserting a refusal
pass, because a refusal is what they assert and a dead port supplies one. The
TLS suite reported 7 passed, 12 failed against an empty Docker, and two of those
passes were the ones verifying that a bad trust anchor is rejected.

Both conditions are now required, since neither implies the other: stack.env is
what the suites read, and a running coordinator is what they talk to. The check
reuses lib.sh's service_running rather than adding one.

Verified both branches. With stack.env present and nothing running it prints
"Stack configured but not running", calls setup.sh and the TLS suite passes
19/0; with the stack up it skips setup and passes 19/0 again.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…elds

Two defects in the options record introduced by 495fc4d, either of
which made the connector unusable.

 asked M to construct a type from
a type, so the section failed to compile and Power BI registered
nothing -- the connector was absent from Get Data and any saved query
reported that StackableTrinoODBC.Contents was not recognised. Naming a
type-valued section member needs no  prefix.

The dialog supplies every declared field, null for those left blank,
and Text.From(null) is null rather than , so every unset option
reached Odbc.DataSource as a null-valued connection-string property
and it refused the connection. Blank fields are now removed before the
string is built; the unknown-key check still runs on the full supplied
record so a misspelled option still errors.
The Windows runner ran test_integration.py and nothing else, while the
Linux runner grew to eleven entries. Neither file said that was a
decision rather than an oversight, and adding a suite meant editing one
of them.

suites/registry.py is now the only list. scripts/run-tests.sh reads it
through --bash and windows/windows_test.py imports it, so a suite is
added in one place. Each entry states its script, profile, how it takes
its configuration, whether it needs pyodbc, and whether it runs on
Windows; a suite that does not carries the reason, which the runner
prints as a SKIP.

The command stays with each runner. The two invoke Python differently,
and the four-configuration connect matrix is not the same on both: Linux
crosses DSN names out of the generated odbc.ini, Windows crosses a
registry-registered DSN and connects by address for the unverified cases
so that no SNI is sent.

What kept the other suites off Windows was stack.env alone: the host's
names the driver's .so, a localhost the VM is not, and certificate paths
under the user's home directory. windows_test.py writes the VM its own,
and mirrors the repository layout under C:\odbc_test_trino so that
test_folding_contract.py's ../../connector/... and Stack's
../generated/stack.env resolve there as they do here. Stack gains
DRIVER_NAME, because the Windows Driver Manager loads by registered name
while the ctypes suites want the DLL's path.

Windows now runs eight of the ten suites rather than one. test_harness.py
is excluded as platform-independent Python, test_oauth.py until it has an
odbc32.dll branch and an issuer the VM resolves.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Picks up trino-rust-client 98b44b01, where a root certificate replaces
the platform's roots instead of joining them. TlsVerify=full previously
left the machine's CA store in the trust store alongside Certificate, so
a chain that store trusted was accepted even though the connection had
named a different anchor. Pinning a private CA therefore still accepted
whatever any public CA issued for the same host.

The Windows suite is what found it: the VM carries the test CA in
Cert:\LocalMachine\Root for the OAuth2 browser login, and test_tls.py's
"full refuses a trust anchor that did not sign the chain" passed on Linux
and failed there. It passes on both now.

README says what Certificate means, in the parameter table and in the TLS
troubleshooting section: it names the anchor, so the machine's CA store
stops applying to that connection.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`parse_trino_date` strips a leading `-` before splitting, with a comment
saying the driver has to be able to read back what it emits.
`parse_trino_timestamp` did a bare `splitn(3, '-')`, so the first field of
`-0001-01-01 00:00:00` was empty, the parse failed, and the value degraded
to text.

The driver emits exactly that form: `backend::params` renders a bound
`SQL_TIMESTAMP_STRUCT` through the same `year4` it uses for a
`SQL_DATE_STRUCT`. So a bound BC timestamp did not round-trip, while a
bound BC date did, which is what kept it invisible.

Both now go through one `parse_ymd`, so the two cannot disagree again, and
`dates_and_timestamps_read_the_same_years` asserts that directly. The sign
is applied with `checked_neg`, because `i16::MIN` has no positive
counterpart.

Also corrects the `TrinoTypeName::Char` comment, which claimed the
`TrinoTy::Char -> EXT_W_VARCHAR` mapping was the query path. `execute.rs`
prefers `TrinoTypeName::parse`, which answers `EXT_W_CHAR` for a
`char(n)`; the `TrinoTy` arm is the fallback for a signature the parser
cannot read.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…seed

ODBC's `RAND` takes an optional *seed* and returns a float in `[0, 1)`.
Trino's `rand(n)` takes a *bound* and returns an integer in `[0, n)`, so
`{fn RAND(5)}` passed through verbatim answered a different type over a
different range, and no error revealed it.

Trino has no seeded generator to rewrite the seed onto, so the seed is
dropped and a bare `random()` emitted. That keeps ODBC's type and range
and loses only reproducibility, which is the better of the two failures.
The zero-argument form still passes through untouched.

Also documents `SQL_FN_STR_SOUNDEX`, which was the one name in
`TRINO_STRING_FUNCTIONS` with no justification beside it. Trino gained
`soundex()` in 356 (April 2021, trinodb/trino#4022), so unlike
`CORRESPONDING` (475), `MATCH` and `UNIQUE` (482) and `OVERLAPS` (483) it
needs no `server_major` gate: no coordinator this driver supports predates
it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`TransactionState::aborted` is meant to say "the transaction open now has
been aborted". It was set whenever autocommit was off, whether or not one
was open, and only `ended()` cleared it, which runs from `end_tran` and
returns early when nothing is open.

Manual-commit mode is not the same as having a transaction: the mode is
set by `SQLSetConnectAttr` and a transaction opens at the first statement
that needs one, so there is a window with the mode on and nothing begun. A
failing catalog lookup or `DESCRIBE INPUT` in that window set the flag, and
the next transaction was born aborted: its statements all succeeded and
`SQLEndTran(SQL_COMMIT)` rolled them back with 25S03.

`begun()` now clears it wherever a transaction opens, and `end_tran`
clears it on the path where none was, so the flag is unobservable outside
the life of a transaction.

Three further fixes in the same area:

- `set_autocommit` records the mode even when the commit it makes first
  fails. `end_tran` has already moved the epoch, cleared the flag and, in
  the case that fails most often, sent the rollback, so an early return
  left the connection in manual-commit mode with nothing open while the
  application had been told the switch failed.

- Trino carries the transaction id in a session header, so the driver's
  own `information_schema` queries and its `PREPARE` / `DESCRIBE INPUT`
  round trip join whatever the application has open, and a failure in
  either aborts it. Both used to substitute a success -- an empty result
  set, and core's uniform `VARCHAR` guess -- which reported success over a
  transaction they had just killed. Both substitutions are now conditional
  on no transaction being open. Suppressing the abort instead was rejected:
  Trino really did abort it.

- `describe_param` strips the trailing statement terminator, as
  `exec_direct` already did. Trino's grammar has none, so `PREPARE x FROM
  SELECT ? ;` is a syntax error and a statement an application could
  prepare and run was one `SQLDescribeParam` could not describe.

Two connection-string keys are tightened to match the policy the rest of
the parser already follows:

- `Certificate` and `ClientCertificate` are refused with `Protocol=http`.
  `connect` applies the TLS group only when the transport is secure, so
  neither file was opened and an unreadable or wrong-CA path went
  unreported, while the operator believed the connection was verified
  against it. `TlsVerify` and `SSLVerification` are deliberately tolerated:
  they are equally inert but harmlessly so, and `configure-dsn.ps1` writes
  one into every data source it produces, so refusing them would refuse
  plaintext data sources the driver's own dialog wrote. The dialog mirrors
  the rule so it is reported while the fields are on screen.

- `QueryTimeout` refuses a value it cannot parse instead of logging a
  `warn!` no application sees and silently using 30s, which is what
  `MaxAttempts` and `ExternalAuthenticationTimeout` already do. Blank is
  unset rather than invalid, for the ODBC-standard `LoginTimeout` spelling
  that a DSN editor may write empty, and `0` stays legal as the spec's
  "no timeout".

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`StackableTrinoODBCImpl` fell back to `User=trino` for every credential
kind but UsernamePassword. `ExternalAuthentication` is offered in
`Config_AdvancedOptions`, and `connect_params.rs` documents why a name
must not be invented there: Trino reads a `User` that disagrees with the
token's identity as an impersonation request and refuses it. So the only
Power BI route to OAuth 2.0 failed every time, with the exact error the
driver goes out of its way to avoid. An absent user is now left absent.

Three values were wrong in ways nothing would report:

- The `Roles` sample was `hive:ROLE{admin}`. `selected_role` renders
  Trino's `ROLE{...}` wire form itself, so the sample double-wrapped, and
  the braces collide with connection-string syntax besides.

- The TIMESTAMP and TIME `Constant` entries rendered through
  `.sssssss`. In a .NET custom format string `s` is the second and `f` is
  the fractional second, so those two of the twelve folding entries built
  a literal with no fraction in it. `test_folding_contract.py` now
  translates each temporal format and sends the rendered literal to Trino,
  rejecting a run of `s` where a fraction belongs.

- The conformance-override comment justified itself with "the driver
  reports SQL_TC_NONE where Entry level requires COMMIT/ROLLBACK". It
  reports `SQL_TC_DML`, and `sql_conformance` says the opposite: the
  COMMIT/ROLLBACK requirement is met and the constraint grammar is the
  whole argument. Stale from before transactions landed, and load-bearing,
  since it justifies the one override that silently outranks SQLGetInfo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three checks reported success regardless of the outcome:

- `install.bat` graded `odbcconf.exe /A {INSTALLDRIVER ...}` on its exit
  code. odbcconf exits 0 whether or not the action succeeded, so a failed
  registration printed "installed successfully" and the driver was then
  simply absent from the ODBC Administrator with no explanation, which is
  the first symptom README.md's troubleshooting section covers. It now
  reads the registry: the driver's own key, and its entry in the `ODBC
  Drivers` listing the Drivers tab is populated from. `windows_test.py`
  works around the same unreliability by force-writing those values after
  its own odbcconf call, which is what prompted the check.

- `uninstall.bat` deleted only the DLL, while `install.bat` places two
  files and refuses to run without both. It now removes `configure-dsn.ps1`
  and the directories it created, when empty.

- build.yaml's "Verify DLL exports" piped a symbol count into `xargs
  echo`, and the step was graded on `xargs`. A DLL exporting nothing at all
  passed. It now asserts 23 named entry points, `ConfigDSNW` among them,
  plus a floor of 55; the Linux .so has 60 and the DLL 61.

The Windows VM harness never ran the shipped installers -- it registers
the driver its own way -- so `check_installers` now stages the four files
the release zip puts side by side, runs install.bat and uninstall.bat, and
reads the state each left. It runs before `register_driver`, because the
uninstaller deregisters the driver every later suite depends on.

release.yaml's archive check now asserts the Linux tarball carries no
`.mez`, rather than only looking for files it expects to find. README.md
spent this branch claiming it did; a check that only detects absences
cannot catch a promise about a file that was never there. `configure-dsn.ps1`
is added to the Windows list, since install.bat hard-requires it.

`build-archives.sh` clears `packaging/dist` before assembling. The
checksum step globs the whole directory, so a local re-run at a different
$VERSION checksummed both versions into one manifest. CI never saw it;
the person following packaging/README.md does.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three suites, for three surfaces that had no live coverage at all.

**Escape sequences.** `info.rs` states the rule the capability bitmaps
follow: a bit is set only when the escape becomes Trino SQL that runs.
Nothing checked that against a coordinator. The Rust side compares a
bitmap against a list of names and the rewriter's output against expected
strings, so an argument order, a unit spelling or a value conversion could
be wrong in both places and agree with itself. `test_escapes.py` parses
the bitmaps out of the driver's own source and executes one `{fn ...}` per
bit, asserting the value wherever the dialect transforms rather than
renames: `DAYOFWEEK` renumbering Sunday to 1, `TIMESTAMPDIFF` counting
b - a, `LOG` being `ln`, `LOCATE`'s argument order, and `RAND` dropping its
seed. It also covers every advertised `SQL_TSI_*` unit in both directions,
all 26 `{fn CONVERT}` targets, and the `{d}` `{t}` `{ts}` literals. A bit
added without a probe fails the suite rather than shipping unchecked.
114 assertions.

**Session keys.** Seventeen connection-string keys were parsed, unit-tested
and never once shown to reach Trino. `test_session_keys.py` covers each by
the strongest assertion Trino allows: observed for `TimeZone`, `Path`,
`Source`, `Locale`, `SessionProperties` and `Roles`; refused-on-a-bad-value
for `ResourceEstimates` and `ExtraHeaders`, where a rejection proves the
good value travelled too; and accepted, recorded as a NOTE rather than a
PASS, for the seven Trino exposes nothing for. `AccessToken` and the proxy
keys are explicit SKIPs with reasons.

**SQLDescribeParam.** ctypes, because pyodbc exposes it not at all. Covers
the `PREPARE` / `DESCRIBE INPUT` / `DEALLOCATE` round trip, the
per-connection cache, the trailing terminator, and both failure paths --
falling back outside a transaction and reporting inside one. Each type
probe also asserts the answer is *not* core's uniform `VARCHAR` fallback,
which is what the round trip exists to avoid: with the terminator strip
reverted, the semicolon probe returns `SQL_VARCHAR(4000)` instead of
`SQL_BIGINT`, so it is a real regression guard.

`test_sql_surface.py` gains batched parameters, which the changelog claims
and nothing covered: every set inserted, values kept with their own row,
and quotes and NULLs escaped per set.

integration-tests/README.md records that `Protocol=http` stays uncovered
by decision rather than oversight.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
README.md said "Both archives contain StackableTrinoODBC.mez".
`build-archives.sh` puts it in the Windows zip and publishes it standalone;
the Linux tarball has never carried it, and packaging/README.md and
AGENTS.md both say so correctly. The top-level README was the outlier, and
it is the one a Linux user reads before going looking for a file that is
not there. It now states where the connector ships and that Power BI
Desktop is Windows-only.

CONTRIBUTING.md called pre-commit "the single source of truth for what
must pass" and then listed five of its sixteen hooks. `cargo doc` with
warnings denied, `cargo sort` and `cargo deny` are the three most likely to
surprise a first contributor, and two of them need installing, which
nothing said.

The `Certificate`, `ClientCertificate` and `QueryTimeout` rows in the
connection-option table are updated for the validation those keys now
carry.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four advertised scalar functions did not do what the ODBC appendix
specifies, and each failed quietly rather than visibly.

TRUNCATE was not merely inexact, it did not resolve. Trino declares the
two-argument `truncate` over `decimal` alone, so `{fn TRUNCATE(x, 1)}`
over a double or real column fails FUNCTION_NOT_FOUND, while ODBC's
`numeric_exp` covers SQL_FLOAT, SQL_REAL and SQL_DOUBLE. The escape now
scales into the single-argument form, which Trino does define over every
numeric type. The scale factor is an integer literal rather than
`power(10, d)`, and that choice is what keeps ODBC's "returns values of
the same data type as the input parameters": Trino promotes
`decimal * bigint` to decimal and `real * bigint` to real, where a
double-valued factor drags both to double. A digit count that cannot be
folded, a column or a parameter marker, and any `|d| > 18`, still take
the `power` path and widen, which remains better than not resolving.

LENGTH, LTRIM and RTRIM all turn on ODBC's word "blanks", which means the
space character alone. Trino reads all three as whitespace-wide, so
`length()` counted trailing spaces the spec excludes and the one-argument
trims ate tabs and newlines the spec keeps. Measured against a
coordinator, `length(CAST('ab' AS char(5)))` answered 5 where ODBC asks
for 2, and `length('abc   ')` answered 6 where it asks for 3, so the gap
was never confined to padded char(n). Each now goes through the
two-argument trim with an explicit space.

The suites gain cases that tell the readings apart: the trims carry a tab
that has to survive alongside spaces that have to go, LENGTH carries
trailing blanks, and TRUNCATE asserts `typeof` across decimal, double and
real at three digit counts, because the value alone cannot distinguish an
integer scale factor from a double one.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…ings

An audit of all 54 advertised scalar functions against the ODBC
appendices left three names where the driver's answer is a decision
rather than a match, and none of them said so.

ATAN2 is the one place this driver knowingly departs from the appendix
text. That text reads `ATAN2(float_exp1, float_exp2)` as "the arctangent
of the x and y coordinates ... respectively", putting x first, where
Trino's `atan2(y, x)` puts y first. No implementation honours the
sentence: PostgreSQL, MySQL, Oracle, C, Java, Python and SQL Server's own
ATN2 all take (y, x), and ATN2's documented example evaluates
`ATN2(129.44, 35.175643)` to 1.30545, which is atan(129.44/35.175643).
Microsoft's engine therefore contradicts Microsoft's appendix. psqlodbc,
which remaps LOG, LENGTH and DAYOFWEEK exactly as this module does,
carries ATAN2 only as a commented-out `built_in`. Swapping would make
this the single driver in the ecosystem answering the complementary
angle, so the call passes through and the reasoning now sits next to the
arm it deliberately does not have, with unit tests asserting both hooks
decline it.

ROUND looked like the same trap as TRUNCATE, since ODBC mandates
rounding to the left of the point for a negative digit count and Trino's
math reference documents that for `truncate` alone. Measured, `round`
accepts one too, over double, real, decimal and bigint, and returns the
input's type. Scaling it the way TRUNCATE is scaled would break that
type preservation for no gain, so it stays unrewritten and the test now
pins the negative case the docs omit.

WEEK is ISO-numbered, and the existing note called the divergence "off by
one". At a year boundary it is a whole year: `week(DATE '2021-01-01')` is
53 where a convention starting week 1 on January 1 answers 1. ODBC fixes
only the 1-53 range, which ISO numbering stays inside, and never names a
convention, so the behaviour stands and the note now says what it does.

The ATAN2 suite case moves off `(0, 1)`, whose two readings differ by an
answer of 0 that many wrong paths produce, onto `(1, 2)`, where they
differ by 0.64.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The interval variants of `ColumnValue` changed shape: `IntervalDayTime` counts
signed `i128` nanoseconds rather than `i64` milliseconds, and both variants now
carry the `Interval` precision the spec's two *SQL to C: Intervals* tables turn
on. Trino has exactly one type per family and each spans its family's full
field range, so the precision is `YearToMonth` and `DayToSecond` and nothing
narrower has a Trino column type to come from.

The day-time parser no longer truncates at three fractional digits. It did so
because the variant held milliseconds, under a comment claiming ODBC's
`SQL_INTERVAL_DAY_TO_SECOND` could carry no more; `SQL_INTERVAL_STRUCT`'s
`fraction` field counts billionths of a second, so that was never true. Trino
renders this type with three digits, its own storage being a millisecond count,
so nothing observable changes today, and `parse_fraction_nanos` now reads the
fragment on the same rule the temporal parsers use rather than a second
padding rule that could drift from it.

One rendering does change, in core rather than here: an `INTERVAL YEAR TO
MONTH` reads as `3-07` where it read as `3-7`, core having moved to the spec's
*Interval Literals* rule of an unpadded leading field and two-digit trailing
fields. Day-time is unchanged.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Trino's temporal types reach twelve fractional digits and the client always
advertises `PARAMETRIC_DATETIME`, so a `timestamp(12)` column arrives on the
wire with all twelve:

    columns: [('t12', 'timestamp(12)'), ('ti12', 'time(12)')]
    data   : [['2020-01-02 03:04:05.123456789012', '03:04:05.123456789012']]

`ColumnValue::Timestamp::fraction` counts nanoseconds, so `parse_fraction_nanos`
keeps nine of them, while `type_name_scale` reports the column's scale as the
declared twelve. The read answered `SQL_SUCCESS` with three digits silently
gone. The loss happens inside this driver's own conversion, before a
`ColumnValue` exists, which is exactly what core cannot see and what
`StatementBackend::take_value_warning` was added for.

Reported per value, not per column. Asking the declared scale instead would
put `01S07` on every row of a `timestamp(12)` column, including the values
whose discarded digits were zeros and which therefore lost nothing; core's own
`01S07` is value-precise for the same reason. So `convert_rows` records the
cells whose wire text carried a non-zero digit past the ninth, that being the
only point the text still exists, `get_data` arms the warning from the record
and `take_value_warning` hands it back.

The record is a set rather than a flag per cell because only a `time` or
`timestamp` column declared beyond nine digits can put anything in it: for
every other result set it stays empty, allocating nothing, and `get_data` pays
one length check per value rather than a hash. The composite types are walked,
since a `timestamp(12)` inside a `ROW` reaches the same parser.

Both halves are pinned against a live coordinator: a value with digits past
the ninth answers `SQL_SUCCESS_WITH_INFO` with `01S07` and still delivers its
nine, and one padded with zeros answers a plain `SQL_SUCCESS` with an empty
diagnostic queue.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The repository is public now, and its `scaffolding` branch is gone: the
remote has only `main`, at the commit `v0.1.0` points to. The branch
dependency therefore no longer resolves at all.

A tag rather than `main`, so which core a build takes is stated in
Cargo.toml and moving to a newer one is a reviewable edit, instead of
depending on where a branch happened to point when the runner cloned it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…Cargo.toml

Four defects, all of which would have surfaced on the first release.

`cargo release` aborted. A pre-release-replacement searched
packaging/README.md for a literal `stackable-odbc-trino-<n.n.n>-linux-x64
.tar.gz`, but that file names artefacts with a `<version>` placeholder
because it documents a naming scheme rather than one release, so the rule
matched nothing and `exactly = 1` failed the run. Both README rules are
gone: the build command now takes its version from Cargo.toml, so the file
holds no release version to rewrite.

build-archives.sh trusted $VERSION and never compared it to the crate
version, so it would name an archive 0.1.0 around a DLL whose VERSIONINFO
resource said 0.0.1. The crate version is now the default, since it is the
only value that can be correct, and a $VERSION that disagrees is refused.
release.yaml still passes one explicitly, from a tag verify-version has
already checked, and that agreeing value is now verified rather than
assumed.

test-sbom.sh hardcoded the trino-rust-client commit and went stale when the
fork was bumped, reporting a defect that was only a stale expectation. Both
purl checks derive the expected value from Cargo.lock, which still asserts
the real property because the commit comes from the lockfile rather than
from the SBOM under test.

`--locked` was in release.yaml alone. Every local instruction and script
omitted it, so a local build could resolve a different dependency set than
the released one while test-sbom.sh asserts an exact component count. The
flag, and the pinned syft and cargo-auditable versions, now match CI.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@maltesander
maltesander merged commit 5641c2d into main Aug 4, 2026
5 checks passed
@maltesander
maltesander deleted the scaffolding branch August 4, 2026 13:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants