Implementation details for anyone working on stackable-odbc-core, human or AI.
stackable-odbc-core is the database-independent framework that concrete ODBC
driver crates build on. It contains zero database-specific code: a driver
implements the Backend and StatementBackend traits and calls the
forward_ffi! macro to export the C ABI. This guide describes the framework
itself and, where relevant, how a downstream driver crate consumes it.
For building, testing and the commit gate, see CONTRIBUTING.md. This file is the reference behind those commands, not a substitute for them.
| Topic | When to Read |
|---|---|
| Architecture | Understanding call flow, crate layout, or the key design decisions |
| Conventions | Writing any code here: logging, named constants, casts, the changelog |
| Adding a new ODBC function | Implementing or moving a function from stubs |
| Adding a new driver | Creating a backend crate on top of core, and every capability it must declare |
| Descriptors | Touching a binding, a descriptor field, the HY021 consistency check, or a statement attribute that is a header field |
| Concurrency: the lock discipline | Understanding the per-connection lock, HandleScope, SQLCancel's exemption, or loom |
| Testing | Writing tests, or running Miri, loom, fuzz or the benchmarks |
| odbc-sys usage | Using ODBC types, enums or constants |
| Converting raw values | Handling raw integers from the C ABI |
Core exposes a generic Backend trait, and each driver crate generates manual
forwarding stubs from it. That shape was chosen over proc-macros (premature)
and a dyn trait (poor ergonomics for complex trait hierarchies).
Example: SQLDriverConnectW (a fully implemented function), for a driver whose
backend type is XyzBackend:
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::<XyzBackend>(...) # generic impl in stackable-odbc-core
-> panic_safe::<B, _>(...) # locks the target's group, builds a HandleScope, catches panics
-> scope.get::<ConnectionHandle<B>>() # validates the token against the handle registry, returns typed &mut
-> handle.diagnostics.clear() # spec: clear diagnostics at start of each call
-> validation checks # 08002, HY090 per the ODBC spec
-> utf16_to_string(...) # convert UTF-16 input to Rust String
-> merge_dsn_params(...) # parse "Key=Value;..."; if DSN= is present, resolve its keys from odbc.ini (explicit values win) and re-parse
-> params.set_prompter(prompter_for::<B>(completion)) # B::prompter(), unless DriverCompletion is SQL_DRIVER_NOPROMPT
-> B::connect(¶ms) # Backend trait method (database-specific); runs under the connection's group lock, like every other Backend method
-> handle.connection = Some(conn) # store result in handle
-> apply_pending_autocommit::<B>(..) # apply a SQL_ATTR_AUTOCOMMIT set before connect; tears the connection down on failure
-> write_utf16(...) # echo connection string to output buffer
-
Two traits:
Backend(creates connections and statements) andStatementBackend(iterates results). Split so that lifecycle is separate from cursor operations. -
Handle registry: an application-facing
SQLHANDLEis an opaque token packing a slot index and a generation counter, not an address. A driver-owned table holds{ generation, kind, addr, group, parent, cancel }, andHandleScope::get<T>()validates a token with a bounds check plus a generation and kind compare, without dereferencing the pointer the application passed. Freeing bumps the slot's generation, so every outstanding token for that slot is permanently rejected, which also closes the recycled-address double-free. This is the primary safety mechanism at the FFI boundary. Nothing may treat aSQLHANDLEas an address, or validate one by reading through it. See "Concurrency: the lock discipline" forgroup,parentandcancel. -
panic_safe: wraps every FFI function except the two below. It locks the target handle's group, builds theHandleScopethe closure operates through, and usesAssertUnwindSafepluscatch_unwind. On error it pushes to the handle's diagnostic queue and returns the appropriateSqlReturn.The two exceptions use
panic_safe_unlocked, because neither has a handle to work through.SQLCancelmust not touch the diagnostic statepanic_safeclears and pushes, per the spec's carve-out for cancelling a call running on another thread.ConfigDSNWis handed no ODBC handle at all: its arguments are a window handle, a request code and two strings, so there is no token to lock a group by and no queue to push to. It is still anextern "system"boundary, and an unwind across it lands in the ODBC Administrator.Every
extern "system"export needs one of the two. Neither is optional for a new entry point. "It takes no handle" is a reason to reach forpanic_safe_unlocked, not a reason to skip the guard.SQLCopyDescneeds two guards, because it takes two lock phases rather than one (see "Descriptors", "The explicit-descriptor rulings", for why). Phase one holds only the source's group, throughHandleScope::with_group, which is a plain lock-then-call with nocatch_unwindof its own, so it is guarded bypanic::catch_panic_as_error. That converts a panic into the sameOdbcErrorshape a non-panicking phase-one failure (HY007) returns. Phase two is an ordinarypanic_safeon the target, and it posts that error to the target's queue, where the whole call's diagnostics belong. -
W-only for string-bearing functions: every ODBC function that takes or returns a string is exported only in its Wide (
W-suffix) form, and the Driver Manager translates an ANSI application's calls into those. Functions with no strings in their signature, such asSQLAllocHandle,SQLFetchandSQLBindCol, have one spelling and are exported unsuffixed.CORE_EXPORTED_FUNCTIONSinsrc/function_id.rsis the authoritative list, and a guard test pins every entry to a symbol that exists. -
No async in the trait:
Backendis synchronous. A driver wrapping an async client library bridges to it internally, for example with a current-thread tokio runtime plusblock_on.
Generic framework. Zero database-specific code.
| Module | What it does |
|---|---|
backend.rs |
Backend + StatementBackend trait definitions; common_get_info_raw and default_get_info shared helpers |
types/mod.rs |
odbc-sys re-exports, InfoValue enum, submodule declarations |
types/constants.rs |
All SQL_* named constants (spec-defined values not in odbc-sys) |
types/conversions.rs |
*_from_raw() conversion functions for all ODBC ABI types |
types/sql_state.rs |
SqlState: the five-character ODBC diagnostic code, and its factory methods |
types/value.rs |
ColumnValue, FetchResult, Nullable, TypeInfoRow, ColumnDescriptor |
types/result_cols.rs |
TablesResultCol, ColumnsResultCol, PrimaryKeysResultCol, ForeignKeysResultCol, and CatalogResultColumnWidths (the per-backend widths those result sets declare) |
types/connect_params.rs |
ConnectParams: the ODBC connection string parser |
types/col_attr.rs |
ColAttrValue and column attribute logic for SQLColAttributeW |
types/cursor_behavior.rs |
CursorBehavior: the SQL_CB_* cursor behaviour SQLEndTran applies, declared by the backend and reported by SQLGetInfoW |
types/query_timeout.rs |
QueryTimeout: which side enforces SQL_ATTR_QUERY_TIMEOUT, declared by Backend::set_query_timeout |
types/column_size.rs |
Shared ODBC column-size formulas (catalog_column_size/column_size); keeps declared vs maximum precision distinct |
types/info_type_shape.rs |
The SQLGetInfo spec's per-InfoType return-value shape, transcribed for the conformance test |
types/version.rs |
Parsed data-source version numbers, for a backend gating capabilities on server version |
types/odbc_version.rs |
DeclaredOdbcVersion: the version an application declared through SQL_ATTR_ODBC_VERSION, and what it changes |
types/catalog_queries.rs |
The ten sealed XxxQuery argument objects the catalog hooks take |
types/diagnostics_table.rs |
Every function's spec Diagnostics table transcribed, plus the guards that check the doc comments against it |
types/redacted.rs |
Redacted<T>: a Debug wrapper that prints ***** for sensitive fields (e.g. passwords) |
column_value.rs |
write_column_value(): core data marshalling for SQLGetData (NULL, truncation, type coercion). Also owns the spec's "SQL to C: Year-Month Intervals" and "SQL to C: Day-Time Intervals" tables, transcribed: the C interval targets, footnote [b]'s exact-numeric row, and the character and binary rows those two pages word differently from every other source |
param_convert.rs |
text_to_sql_type(), the reverse direction: converts SQL_C_CHAR/SQL_C_WCHAR parameter text to the SQL type SQLBindParameter declared. The spec's "C to SQL: Character" table, transcribed. Also owns the size checks all three C-to-SQL tables share (DecimalLiteral, check_declared_char_size, check_declared_decimal_size, check_declared_binary_size) |
binary_convert.rs |
The spec's "C to SQL: Binary" table, transcribed. SQL_C_BINARY to the targets whose byte layout ODBC defines; refuses the rest at bind with 07006 |
numeric_convert.rs |
The spec's "C to SQL: Numeric" table, transcribed. Every numeric C type to any of its six target rows, including the interval row and footnote [b]'s optional 01S07. numeric_pairing_is_supported is SQLBindParameter's gate |
prompt.rs |
Prompter: the trait a driver implements to present a login URL to the user during a connect. Definition only: core ships no implementation and gains no dependency |
setup.rs |
ConfigRequest, InstallerError and config_request_from_raw: the driver-facing half of the ODBC installer's ConfigDSN entry point, which a driver reaches through Backend::configure_dsn. Nothing here is #[cfg(windows)]; only the ConfigDSNW export in ffi/setup.rs is |
query_timer.rs |
QueryTimer, core-side SQL_ATTR_QUERY_TIMEOUT enforcement: a timer thread that calls Backend::cancel on expiry and relabels the resulting failure HYT00 |
cancel.rs |
CancelState: a backend's cancel token plus core's timed_out flag, and the one implementation of "a cancelled call reports HY008" |
synthetic.rs |
SyntheticStatement: in-memory result set for SQLGetTypeInfo and catalog functions |
catalog_sort.rs |
Sorts a catalog result set into its spec-mandated order; NULL placement from Backend::null_collation |
catalog_ident.rs |
SQL_ATTR_METADATA_ID identifier normalisation and the SQLTables TableType value-list parser |
types/catalog_rows.rs |
The ten typed catalog row structs a Backend returns (TableRow, ColumnRow, PrimaryKeyRow, ForeignKeyRow, StatisticsRow, SpecialColumnRow, ProcedureRow, ProcedureColumnRow, ColumnPrivilegeRow, TablePrivilegeRow), and their spec-order conversion to ColumnValues |
conformance.rs |
Shared support for the SQLGetInfoW info-type conformance test (return shape + Driver-Manager-safe value), reused by core and by driver test suites |
escape.rs |
ODBC escape-sequence translation ({fn}, {d/t/ts}, {oj}, {escape}); a shared scanner with a per-backend EscapeDialect |
errors.rs |
OdbcError with SQLSTATE mapping and SqlReturn conversion |
descriptor.rs |
DescriptorRecord, DescriptorRole, the per-role field tables (field_access, which decides HY091 for every identifier naming a real field; one naming none is refused earlier by ffi::desc::field_from_raw), the header-field mapping, and the HY021 consistency check. No FFI, no handles |
diagnostics.rs |
Per-handle diagnostic queue (SQLGetDiagRecW reads from here) |
handles/mod.rs |
EnvironmentHandle<B>, ConnectionHandle<B>, StatementHandle<B>, Descriptor, HandleHeader, GetDataCursor, DataAtExecState, and alloc/free (pub(crate)) |
handles/registry.rs |
The live-handle table (Registry, Slot), per-connection GroupLocks, cancel tokens, and the loom models (#[cfg(all(test, loom))] mod loom_tests) |
handles/scope.rs |
HandleScope: the only way to reach a handle's contents; token validation without dereferencing the application's pointer |
sync.rs |
The one import path for every lock in the crate; aliases to loom's primitives under #[cfg(all(loom, test))], std::sync otherwise |
utf16.rs |
utf16_to_string, write_utf16 (ODBC uses UTF-16LE) |
panic.rs |
panic_safe (locks the target's group, builds a HandleScope, catches panics), panic_safe_unlocked (SQLCancel's lock-free sibling), and catch_panic_as_error (SQLCopyDesc phase one's panic-to-OdbcError guard) |
logging.rs |
init_logging() via tracing, configured by ODBC_LOG_LEVEL / ODBC_LOG_FILE |
function_id.rs |
FunctionId enum + function_id_from_raw() for SQL_API_* constants |
test_support.rs |
test-support-feature-gated hooks a driver's test suite uses to put a connection into a handle without SQLDriverConnectW |
ffi/handle.rs |
sql_alloc_handle<B>, sql_free_handle<B>, sql_free_stmt<B> |
ffi/env.rs |
sql_set_env_attr<B>, sql_get_env_attr<B> |
ffi/connect.rs |
sql_driver_connect_w<B>, sql_browse_connect_w<B>, sql_connect_w<B>, sql_disconnect<B>, sql_native_sql_w<B>; merge_dsn_params (DSN resolution) |
ffi/connect_attr.rs |
sql_set_connect_attr_w<B>, sql_get_connect_attr_w<B> |
ffi/diag.rs |
sql_get_diag_rec_w<B>, sql_get_diag_field_w<B> |
ffi/cursor.rs |
sql_num_result_cols<B>, sql_row_count<B>, sql_more_results<B>, sql_close_cursor<B>, sql_cancel<B>, sql_get_cursor_name_w<B>, sql_set_cursor_name_w<B>, sql_bulk_operations<B>, sql_set_pos<B> |
ffi/execute.rs |
sql_exec_direct_w<B>, sql_prepare_w<B>, sql_execute<B> |
ffi/fetch.rs |
sql_fetch<B>, sql_fetch_scroll<B>, sql_extended_fetch<B>, sql_get_data<B> |
ffi/metadata.rs |
sql_describe_col_w<B>, sql_col_attribute_w<B>, sql_tables_w<B>, sql_columns_w<B>, sql_primary_keys_w<B>, sql_foreign_keys_w<B>, sql_statistics_w<B>, sql_special_columns_w<B>, sql_procedures_w<B>, sql_procedure_columns_w<B>, sql_column_privileges_w<B>, sql_table_privileges_w<B> |
ffi/params.rs |
sql_bind_parameter<B>, sql_num_params<B>, sql_describe_param<B>, sql_put_data<B>, sql_param_data<B> |
ffi/bind.rs |
sql_bind_col<B> |
ffi/desc.rs |
sql_get_desc_field_w<B>, sql_set_desc_field_w<B>, sql_get_desc_rec_w<B>, sql_set_desc_rec<B>, sql_copy_desc<B>; argument marshalling over descriptor.rs's tables |
ffi/stmt_attr.rs |
sql_set_stmt_attr_w<B>, sql_get_stmt_attr_w<B> |
ffi/info.rs |
sql_get_info_w<B>, sql_get_type_info<B>, sql_get_functions<B> |
ffi/tran.rs |
sql_end_tran<B> |
ffi/setup.rs |
config_dsn_w (ODBC installer entry point) |
ffi/mod.rs |
ffi submodule declarations |
forward_ffi.rs |
forward_ffi! macro: generates the C ABI entry points for a backend (the SQL* functions, plus ConfigDSNW on Windows) |
test_utils.rs |
Shared test infrastructure (MockBackend and the purpose-built mocks listed under Testing) |
A driver built on core is typically laid out like this:
| File | What it does |
|---|---|
backend.rs |
Struct definitions (XyzBackend, XyzConnection, XyzStatement), connect, disconnect, end_tran, the thin impl Backend delegation layer, and the central error-mapping function |
backend/execute.rs |
exec_direct, prepare, execute; impl StatementBackend for XyzStatement |
backend/metadata.rs |
tables, columns, primary_keys, foreign_keys; private query helpers |
backend/info.rs |
get_info, get_info_pre_connect, get_info_raw, get_functions, get_type_info |
backend/params.rs |
bind_parameter, num_params, describe_param (if the backend supports server-side parameters) |
backend/types/connect_params.rs |
Driver-specific connection parameters parsed from the ODBC connection string |
lib.rs |
Invokes stackable_odbc_core::forward_ffi!(crate::backend::XyzBackend), which generates all the C ABI entry points |
type_conversion.rs |
Converts backend-native column values to ColumnValue |
escape_dialect.rs |
The backend's EscapeDialect for core's escape-sequence translator (identifier quoting, {fn} name mapping) |
ffi_integration_tests.rs |
FFI-level integration tests that call the C ABI entry points directly |
-
Edition 2024, resolver 3, Rust 1.95.0
-
snafufor errors (theunwrap_used,unwrap_in_resultandpanicclippy lints are denied outside tests) -
tracingfor logging (notprintln!orlog) -
#[repr(C)]on all handle structs, for a defined, non-reordered layout on a type that is heap-allocated viaBox::into_rawand later reclaimed viaBox::from_rawat that same raw address. Handle validation never dereferences these structs at all: it is a slot index and generation compare against the registry, so no field's offset, includingHandleHeader's, is load-bearing. -
extern "system"on all FFI exports (resolves to the correct ABI on both Windows and Linux) -
odbc-syslinks againstlibodbc/libodbcinst, so building or testing needs the unixODBC dev libraries installed (unixodbc-devon Debian/Ubuntu). No DSN or running Driver Manager is required. Miri is the exception, because it interprets rather than links and so needs no system libraries. -
#[cfg(windows)]code is compilable from Linux, and should be compiled before it is pushed. A plaincargo checkdoes not look at it at all, soffi/setup.rsandConfigDSNWcan be edited into a state that builds and tests clean locally and fails on the Windows runner:rustup target add x86_64-pc-windows-msvc # once cargo clippy --target x86_64-pc-windows-msvc --all-targets -- -D warningsThis links nothing and needs no Windows host, because
raw-dylibresolvesodbccp32at link time and acheck/clippyrun never reaches it. It is not a substitute for running the code, which only a Windows host with a Driver Manager can do. It closes the compile-and-lint half, which is where the regressions are. -
bench/andfuzz/are separate Cargo workspaces, so nothing at the repo root compiles them. Notcargo test, notcargo clippy --all-targets, and not a singlepre-commithook.bench/benches/handle_lookup.rscontains a fullimpl Backend, so any change to theBackendorStatementBackendtrait breaks it silently: every local check passes and CI's "Compile benchmarks" step fails. After touching either trait:(cd bench && cargo build --benches) (cd fuzz && cargo +nightly build --target x86_64-unknown-linux-gnu)
pre-commit run --all-filescovers everything in the root workspace, and these two directories are outside it by design (see the Benchmarks and Fuzzing sections for why). A detached workspace is invisible to exactly the checks you would expect to catch it.
This project keeps a Keep a Changelog
CHANGELOG.md and follows
Semantic Versioning. Every user-facing
change (public API, behaviour, spec-compliance fixes) gets an entry under the
## [Unreleased] heading in the appropriate Added / Changed / Fixed /
Removed group. Core is a published library consumed by driver crates, so
treat any change to a public type, trait method, or exported FFI contract as
user-facing.
Every pub unsafe fn in ffi/ must follow this structure:
// 1. If no parsing: single debug! at entry
tracing::debug!("SQLFunctionW(handle={:?}, param={})", handle, param);
// OR if parsing anything (raw integers to enums, or UTF-16 pointers to
// strings):
// 1a. TRACE: raw inputs before parse
tracing::trace!("SQLFunctionW(handle={:?}, raw={})", handle, raw_int);
// 1b. DEBUG: parsed/typed values after
tracing::debug!("SQLFunctionW: attr={:?}", parsed_attr);
// 1b. ...and for a function taking string arguments, name every one of them:
tracing::debug!(
"SQLFunctionW(handle={:?}, catalog={:?}, schema={:?}, table={:?})",
handle, catalog, schema, table,
);
// 2. WARN: intentional spec deviations (silent accepts, ignored features)
tracing::warn!("SQLFunctionW: accepting unrecognized X (DM compatibility)");
// 3. DEBUG: return value, always; requires the
// `let ret = unsafe { panic_safe(...) };` pattern
tracing::debug!("SQLFunctionW -> {:?}", ret);Rules: no passwords or connection string content; error! only for validation
failures expressed via OdbcError (avoid double-logging); stubs use a single
debug! entry, no exit log.
A string argument counts as parsed input. The entry log knows only the
handle, so a function that logs just that shows which call happened and not
what it asked for, which is precisely what you need when a client's metadata
query comes back empty. Every catalog function therefore logs its
parse_filter_param results. This is the rule most easily missed when a stub
becomes a real implementation, because the stub's single debug! looks like it
already complies.
ODBC attribute values, function IDs, and bitmap constants must use named const
definitions. Never write raw integer literals for ODBC-spec-defined values. Name
them after the ODBC spec name (for example SQL_AUTOCOMMIT_ON,
SQL_CUR_USE_DRIVER, SQL2_FREE_CONNECT).
This applies to tests too. Test code is where raw literals creep back in most easily, usually with the spec name relegated to a trailing comment. A comment is not a constant:
// BAD: the value is unchecked and the name is only a comment
sql_bind_parameter::<B>(stmt, 1, 1 /* SQL_PARAM_INPUT */, ..., -5 /* SQL_BIGINT */, ...);
// GOOD: the compiler validates both
sql_bind_parameter::<B>(stmt, 1, ParamType::Input as i16, ..., SqlDataType::EXT_BIG_INT.0, ...);Prefer the odbc-sys type over defining a new constant when one exists, because
most spec values are already modelled:
| Value | Use |
|---|---|
SQL_PARAM_INPUT, SQL_PARAM_OUTPUT, … |
ParamType::Input as i16 |
SQL_BIGINT, SQL_VARCHAR, SQL_INTEGER, … |
SqlDataType::EXT_BIG_INT.0 (note the .0) |
SQL_C_SBIGINT, SQL_C_WCHAR, … |
CDataType::SBigInt as i16 |
SQL_ATTR_* |
StatementAttribute::* / ConnectionAttribute::* |
SQL_HANDLE_* |
HandleType::* |
All are re-exported from stackable_odbc_core::types. Only define a new const
in types/constants.rs when odbc-sys genuinely lacks the value. Ordinals that
are not spec constants (a parameter number, a column index) are fine as
literals.
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 }).
-
Read the function's spec page. Every function has one at
https://learn.microsoft.com/en-us/sql/odbc/reference/syntax/<function-name>-function?view=sql-server-ver17(for examplesqlallochandle-function). -
Implement every check and constraint from the spec:
- All parameter validation (null checks, valid handle types, valid attribute values)
- All required error returns and SQLSTATEs listed in the spec's "Diagnostics" table
- All state transition rules (for example "cannot call X before Y")
- Setting output parameters to defined values on error (for example
*OutputHandlePtr = SQL_NULL_HANDLE) - If a spec requirement cannot be implemented, leave a
// TODO(spec):comment explaining why, and flag it to the user
-
Reference the spec URL in the doc comment for the generic function in
src/ffi/:/// Generic implementation of SQLAllocHandle. /// /// Spec: <https://learn.microsoft.com/en-us/sql/odbc/reference/syntax/sqlallochandle-function>
The doc comment's SQLSTATE list is checked against the spec's own Diagnostics table by
every_doc_comment_matches_the_spec_diagnostics_table(src/types/diagnostics_table.rs), so a new function needs its table transcribed there before it will build. That module's docs give the four verdict phrasings the guard recognises, and state the one thing it does not check: whether the reason a row is not returned is true. -
Implement the generic function in
src/ffi/, in the appropriate module. -
Add a
Backend(orStatementBackend) trait method if the function needs database-specific logic. Prefer a defaulted method so existing drivers keep compiling. -
Each driver implements the new trait method in its own backend.
-
Add one entry to the
forward_ffi!macro insrc/forward_ffi.rs, so all drivers pick it up automatically.
- Create a new crate that depends on
stackable-odbc-core. - Implement
Backend+StatementBackendfor your backend type. - In
lib.rs, invokestackable_odbc_core::forward_ffi!(crate::backend::YourBackend);. Noffi.rsis needed.
Core never talks to a database; it only defines the trait boundary. A driver
must route every error from its client library through a single central
mapping function, never hand-building an OdbcError at the call site. That
function is the one place that decides the SQLSTATE, so bypassing it silently
degrades specific codes to HY000.
Hand-built errors are correct only for internal invariant violations that
never came from the client (a get_data before fetch, a missing runtime
handle, a poisoned mutex), and for connection-setup failures where the call-site
context is more useful than a mapped variant.
08001 ("client unable to establish connection") is only valid from the
connection functions. Once a connection exists, a failing link is 08S01
("communication link failure"), which is the code the diagnostics tables of
SQLExecute, SQLFetch, SQLGetInfo and the rest actually list. A driver
whose connect performs no network I/O only ever sees post-connection
failures, so it maps them to 08S01. A driver that opens a real connection in
connect is where 08001 legitimately originates.
Some states are the driver's to return by the spec's (DM) rules, yet core
cannot produce them, because the fact they assert lives at the data source.
3D000 ("invalid catalog name") is the clearest case. SQLSetConnectAttr's row
carries no (DM) marker, but only the data source knows which catalogs exist,
and the attribute's description has the driver send something to find out
("the driver sends a USE database statement"). Core's part is threefold:
name the state (SqlState::invalid_catalog_name), call the hook, and propagate
what it returns unchanged. A backend that maps "no such catalog" to a generic
HY000 is the only reason an application would not see 3D000.
- A "not returned by this driver" doc line is a claim about the whole path, not about core's own code. Core can be propagating a state whose only source is the backend, so reading core's own code answers the wrong question.
- A pending connection attribute moves the SQLSTATE to a different function.
SQL_ATTR_CURRENT_CATALOGandSQL_ATTR_ACCESS_MODEare settable either side of a connection, and the spec says interoperable applications set them before. Core therefore applies them duringSQLDriverConnectW, so a hook failure surfaces there, carrying a state that function's own diagnostics table may not list (3D000, orHYC00from an unimplemented hook). Propagate it rather than degrading it: a connection that failed because the catalog does not exist should say so.
A driver needing interactive authentication, an OAuth 2.0 external flow for
instance, implements prompt::Prompter and returns it from the defaulted
Backend::prompter. It reads it back inside its own connect, from
ConnectParams::prompter(), and never by calling Backend::prompter
directly: that method is ungated and says what the driver could do, not what
this call is allowed to do.
The gate is SQLDriverConnect's DriverCompletion, and it lives in exactly one
function, prompter_for in ffi/connect.rs.
- A withheld prompter is
None, not an error. UnderSQL_DRIVER_NOPROMPTthe backend is simply handed nothing to call, so the spec's "do not prompt" cannot be forgotten at a call site. A backend that findsNoneand needs a prompt fails the connect the way the spec's ownSQL_DRIVER_NOPROMPTclause says: "otherwise, the driver returns SQL_ERROR." SQLConnectandSQLBrowseConnecthave no such argument, and absence permits prompting.SQLConnectis the DSN path (isqland Excel), so those are the likeliest interactive callers of the whole driver. Reading the missing argument asSQL_DRIVER_NOPROMPTwould lock DSN connections out of interactive authentication, and no spec text asks for it.- An unrecognised value is accepted.
HY110carries(DM)on both of its clauses, so core adds no check, and the fallback is the most permissive treatment rather than a driver-side error borrowed from a Driver-Manager row.
Core ships no Prompter implementation and must not gain a dependency for one:
every implementation it could offer needs a platform (a browser, a window
system) that the database-independent half of a driver has no business
choosing. A Windows dialog implementation would belong next to
SQLDriverConnectW, but it needs its own design and its own dependency. The
trait is shaped so it can arrive later without changing the backend-facing API.
The ten catalog Backend methods (tables, columns, primary_keys,
foreign_keys, statistics, special_columns, procedures,
procedure_columns, column_privileges, table_privileges) return typed
row structs (TableRow, ColumnRow, …), not a Self::Statement. Five
consequences for a driver author:
-
Return the rows in any order. Core sorts each result set into the order its spec page mandates (
SQLTablesbyTABLE_TYPE, TABLE_CAT, TABLE_SCHEM, TABLE_NAME, and so on), with NULL placement fromBackend::null_collation. A driver needs noORDER BYfor ODBC compliance, so one added purely for it can be deleted. -
Core owns the column layout. A backend fills named fields, so it cannot get column order or count wrong, and a column added to a spec result set is a core-only change. That is what
#[non_exhaustive]on all ten row types buys. It also rules out a struct expression outside core, including..Default::default(), which Rust rejects cross-crate withE0639. Each type therefore carries one consuming setter per column, generated from the same field list by thecatalog_rows!macro:let row = TableRow::default() .catalog(catalog) // Option<String> column takes a bare String .name(name) .table_type("TABLE"); // String column takes a &str
Setters take
impl Into<T>and are named after their field, so adding a column adds a setter and breaks nothing. There is deliberately no positionalnew(...): the widest row types run to well over a dozen columns, so an argument list would reintroduce the ordering mistake named fields exist to prevent. -
The
SQL_ALL_*enumerations never reach these methods. Core servesSQL_ALL_CATALOGS,SQL_ALL_SCHEMASandSQL_ALL_TABLE_TYPESfromBackend::catalogs,Backend::schemasandBackend::table_types, building the all-but-one-column-NULL rows itself. The first two are only called whensupports_catalogs/supports_schemasalready returnedtrue. -
SQL_ATTR_METADATA_IDis core's job. When it isSQL_TRUE, core has already stripped delimiters, case-folded peridentifier_caseand escaped%/_persearch_pattern_escapebefore calling the backend, so these methods always see ordinary pattern values.SQLTables'TableTypeis the one exemption in the family, because the spec makes it a value list under both settings. Core parses it, andtablesreads it back fromquery.table_types()rather than as a raw string. -
The arguments arrive as a typed query object. Each hook takes a single
&XxxQuery<'_>(TablesQuery,ForeignKeysQuery, and so on) instead of five to eight positional arguments, read through accessors:fn tables( conn: &Self::Connection, cancel: &Self::CancelToken, query: &TablesQuery<'_>, ) -> Result<Vec<TableRow>, Self::Error> { let _ = (query.catalog(), query.schema(), query.table(), query.table_types()); todo!() }
These are sealed exactly as the row types are, so an argument added to a catalog hook is a source-compatible change for every driver. They also name the arguments:
SQLForeignKeystakes six consecutiveOption<&str>, where swapping a primary-key argument for its foreign-key counterpart compiles without complaint andquery.pk_table()besidequery.fk_table()cannot.Eight are built from
Defaultpluswith_*setters. The other two take the arguments that have no honest default throughnewinstead:StatisticsQuery::new(unique_only), becausefalsemeansSQL_INDEX_ALLrather than "unspecified", andSpecialColumnsQuery::new(identifier_type, scope, nullable), because noScopeorIdentifierTypevalue is a defensible default and core does not invent one.
The last four (procedures, procedure_columns, column_privileges,
table_privileges) are defaulted to Ok(Vec::new()), not to
NotImplemented like primary_keys and its neighbours. A data source with no
stored procedures, or no privilege metadata, genuinely has none to report, so an
empty result set is the honest answer where an error would not be. Override one
to report real rows.
Their HY009 handling is not uniform, and the difference is deliberate.
All four return it for the spec's SQL_ATTR_METADATA_ID + null-CatalogName +
catalogs-supported clause, which every one of the four pages states without a
(DM) marker. Only SQLColumnPrivileges additionally rejects a null
TableName unconditionally, because it is the only one of the four whose page
carries that sentence unmarked. SQLTablePrivileges, SQLProcedures and
SQLProcedureColumns must not check it. This mirrors the split among the
first six, where SQLStatistics and SQLSpecialColumns check a null
TableName and SQLPrimaryKeys and SQLForeignKeys do not. Tests pin both
directions; do not "fix" any of it into consistency.
Most of Backend is defaulted, so a driver implements only what it needs.
These deliberately are not:
| Method | States |
|---|---|
supports_catalogs |
whether the data source has ODBC catalogs |
supports_schemas |
whether it has ODBC schemas |
alter_table_support |
the SQL_ALTER_TABLE SQL_AT_* bitmask |
outer_join_capabilities |
the SQL_OJ_CAPABILITIES SQL_OJ_* bitmask |
default_txn_isolation |
SQL_DEFAULT_TXN_ISOLATION (0 = no transactions) |
txn_isolation_options |
SQL_TXN_ISOLATION_OPTION (0 = no transactions) |
group_by |
SQL_GROUP_BY (0 = GROUP BY not supported) |
null_collation |
SQL_NULL_COLLATION (0 = SQL_NC_HIGH) |
correlation_name |
SQL_CORRELATION_NAME (0 = SQL_CN_NONE) |
non_nullable_columns |
SQL_NON_NULLABLE_COLUMNS (0 = SQL_NNC_NULL) |
expressions_in_order_by |
SQL_EXPRESSIONS_IN_ORDERBY |
identifier_case |
SQL_IDENTIFIER_CASE (SQL_IC_*); 0 is not a legal value |
quoted_identifier_case |
SQL_QUOTED_IDENTIFIER_CASE (SQL_IC_*); independent of the unquoted rule |
txn_capable |
SQL_TXN_CAPABLE (SQL_TC_*); 0 = SQL_TC_NONE, contradicting any declared isolation level |
integrity |
SQL_INTEGRITY: whether the data source has the Integrity Enhancement Facility |
multiple_active_txn |
SQL_MULTIPLE_ACTIVE_TXN: whether two transactions can be live at once |
special_characters |
SQL_SPECIAL_CHARACTERS; an empty list is an answer, as with keywords |
accessible_procedures |
SQL_ACCESSIBLE_PROCEDURES, the counterpart of accessible_tables |
driver_name / driver_version |
SQL_DRIVER_NAME / SQL_DRIVER_VER; answered before a connection exists |
dbms_name / dbms_version |
SQL_DBMS_NAME / SQL_DBMS_VER: what this connection reached |
sql_conformance |
SQL_SQL_CONFORMANCE (SQL_SC_*) |
timedate_add_intervals |
SQL_TIMEDATE_ADD_INTERVALS (SQL_FN_TSI_*) |
timedate_diff_intervals |
SQL_TIMEDATE_DIFF_INTERVALS (SQL_FN_TSI_*) |
subqueries |
SQL_SUBQUERIES (SQL_SQ_*) |
column_alias |
SQL_COLUMN_ALIAS |
concat_null_behavior |
SQL_CONCAT_NULL_BEHAVIOR (0 = SQL_CB_NULL) |
union_support |
SQL_UNION (SQL_U_*) |
convert_functions |
SQL_CONVERT_FUNCTIONS (SQL_FN_CVT_*) |
order_by_columns_in_select |
SQL_ORDER_BY_COLUMNS_IN_SELECT |
accessible_tables |
SQL_ACCESSIBLE_TABLES |
data_source_read_only |
SQL_DATA_SOURCE_READ_ONLY |
search_pattern_escape |
SQL_SEARCH_PATTERN_ESCAPE |
keywords |
the data source's own reserved words, before ODBC's are subtracted (SQL_KEYWORDS) |
table_types |
the data source's table types, for SQLTables' SQL_ALL_TABLE_TYPES enumeration |
Each states a capability, so any default core invents is a claim the backend author never made. It is also wrong silently: the author never sees the question, and the application never sees anything but a confident answer. The compiler asks instead.
SQL_ATTR_QUERY_TIMEOUT, SQL_ATTR_MAX_ROWS and SQL_ATTR_MAX_LENGTH share
one shape, in offer_to_data_source (ffi/stmt_attr.rs): offer the value to a
defaulted Backend hook, store it if the backend accepts, substitute the
spec's default with 01S02 if the hook is unimplemented, and propagate any
other error as-is. Core emulates none of them, and the spec is explicit about
why for two of the three: "a driver should not emulate SQL_ATTR_MAX_ROWS
behavior", and SQL_ATTR_MAX_LENGTH "should be supported only when the data
source (as opposed to the driver) ... can implement it". Each row states the
purpose that makes emulation pointless, "this attribute is intended to reduce
network traffic". Counting rows or bytes in the driver, after they have crossed
the wire, achieves nothing the application asked for.
SQL_ATTR_QUERY_TIMEOUT is the one with a core-side fallback, and it is opt-in
rather than automatic: Backend::set_query_timeout returns a QueryTimeout,
and only CoreCancels arms core's timer. Core cannot infer that. Every
statement-producing Backend method is synchronous and blocks the calling
thread, so Backend::cancel is the only lever, and whether a backend wired it
up is not observable from Rust.
The timer is armed at SQLFetch too, not only at the statement-producing
calls. SQL_ATTR_QUERY_TIMEOUT bounds returning the result set, and a data
source is free to answer with column metadata long before it has computed a row,
so an execute-only timer can expire on nothing and bound nothing. SQLFetch and
SQLFetchScroll both carry HYT00 with no (DM) marker, naming this
attribute directly.
SQLGetData is the boundary, and the spec draws it: its diagnostics table
carries HYT01 and no HYT00 row at all, so it is deliberately unarmed.
The bound-column reads that run inside SQLFetch are a different thing and do
fall under that call's deadline. SQLFetchScroll needs no site of its own,
because every orientation but SQL_FETCH_NEXT is rejected with HY106 and that
one delegates to sql_fetch. Before arming a further site, check the function's
own table for an HYT00 row.
Before adding a fourth attribute of this kind, check the spec row for a stated
purpose. If the purpose is to reduce work at the data source, the answer is a
hook plus the 01S02 fallback, not an implementation in core.
The test is one question: is zero "unknown", or is zero an answer?
- Zero means unknown or no limit → shared default in
default_get_info.SQL_MAX_ROW_SIZE,SQL_MAX_INDEX_SIZE,SQL_MAX_STATEMENT_LENand theSQL_MAX_COLUMNS_IN_*group are all of this kind: the spec explicitly defines0as "no specified limit or the limit is unknown", so a shared0asserts nothing. - Zero is a substantive claim → required
Backendmethod. Every enum in the table above has this shape.SQL_NULL_COLLATION's zero isSQL_NC_HIGH,SQL_CORRELATION_NAME's isSQL_CN_NONEandSQL_NON_NULLABLE_COLUMNS's isSQL_NNC_NULL, each a specific, falsifiable statement about the data source that core has no way to know.
Two corollaries worth checking when adding an info type:
- A Y/N string has no valid empty value. The shape-aware fallback in
info_type_default_responsegives an unhandledString-shaped info type"", which is the right shape but is not in any Y/N value list. Such a type needs either a shared"N"arm indefault_get_infoor a hook. - An empty list is an answer too.
SQL_KEYWORDSreads as an empty string just like an unhandledString-shaped type, but it means "this data source reserves nothing beyond ODBC", which applications act on when deciding what to quote. It is aBackend::keywordshook for that reason; core owns only the spec's subtraction ofODBC_RESERVED_KEYWORDS, which is the same for every backend. - Watch for info types that constrain each other.
SQL_SQL_CONFORMANCEfixes the value ofSQL_GROUP_BY,SQL_CORRELATION_NAME,SQL_NON_NULLABLE_COLUMNS,SQL_CONCAT_NULL_BEHAVIOR,SQL_SUBQUERIESandSQL_COLUMN_ALIAS, because the spec names what an entry-level driver returns for each of those six.SQL_TIMEDATE_FUNCTIONSclaimingSQL_FN_TD_TIMESTAMPADDobligesSQL_TIMEDATE_ADD_INTERVALSto be non-zero, andSQL_CATALOG_NAMEdrives the whole catalog group. Core supplying one side of such a pair while the backend supplies the other is how it ends up contradicting itself. - Prefer deriving over adding a hook when the fact is already declared.
SQL_IDENTIFIER_QUOTE_CHARcomes fromEscapeDialect::identifier_quotesandSQL_CURSOR_COMMIT_BEHAVIORfromBackend::cursor_commit_behavior, because a second way to state the same fact is a second way to state it differently. Check whether an existing hook already answers the question before adding one.
default_get_info_answers_are_backend_derived_or_declared_core_facts
(src/backend.rs) asks one question of every info type: does the answer move
when the backend does? It evaluates default_get_info for two mock backends
that share no capability declaration. An info type answering identically for
both is one core decided, so it must appear in that test's CORE_FACTS list
with the reason core is entitled to decide it. Three reasons qualify: a fact
about core's own implementation (its fetch really is forward-only, its Backend
trait really is synchronous), a limit where the spec defines 0 as "no limit or
unknown", or driver-level identity with no per-backend answer.
Adding a hard-coded claim to default_get_info therefore fails a test that
names the info type. If you cannot write a CORE_FACTS reason that is about
core rather than about the data source, the value belongs on a Backend
method.
supports_catalogs and supports_schemas between them drive seven info types
(SQL_CATALOG_NAME, SQL_CATALOG_TERM, SQL_CATALOG_NAME_SEPARATOR,
SQL_CATALOG_LOCATION, SQL_CATALOG_USAGE, SQL_SCHEMA_TERM,
SQL_SCHEMA_USAGE), which the SQLGetInfo spec defines in terms of that one
fact. Note the asymmetry: core answers the whole group when the answer is no,
because the spec mandates the empty string or zero. When the answer is yes, it
returns None for SQL_CATALOG_LOCATION, SQL_CATALOG_USAGE and
SQL_SCHEMA_USAGE rather than inventing a value, so a driver with catalogs
answers those three itself.
Backend::set_txn_isolation stays defaulted, and the default is only correct
for a data source with exactly one isolation level. A backend declaring more
than one bit in txn_isolation_options must override it, or
SQLSetConnectAttr(SQL_ATTR_TXN_ISOLATION) reports NotImplemented rather
than accepting a level it cannot apply.
The Windows DM is much stricter than unixODBC. These items are required for
a driver to work on Windows, because omitting any one can cause silent crashes,
IM001 errors, or blocked SQLGetData calls:
-
The pre-connect info group is core's job, not a checklist item. The Windows DM queries
SQL_DRIVER_ODBC_VER(77) beforeSQLDriverConnectW, and onSQL_ERRORtreats the driver as ODBC 2.x and blocks 3.x features likeSQL_C_SBIGINT. Core answers the whole group without a connection:SQL_DRIVER_NAMEandSQL_DRIVER_VERfrom the requiredBackend::driver_nameandBackend::driver_version, andSQL_DRIVER_ODBC_VER,SQL_ASYNC_DBC_FUNCTIONSandSQL_MAX_CONCURRENT_ACTIVITIESfrom facts about itself. Declaring the two hooks is all a driver does. Overridingget_info_pre_connectis only for a further info type it can answer before connecting, which is rare. -
get_functions: List every exported FFI function, not just query-related ones, and nothing core does not export. The Windows DM uses the 3.x bitmap (func_id=999) to build its dispatch table, so a missing entry (SetEnvAttr,GetStmtAttr,BindCol) gives it a null function pointer to call. Build the list fromCORE_EXPORTED_FUNCTIONSand it cannot drift in either direction.The 2.x array (
func_id=0) is a different question with a different answer. It asks "can an ODBC 2.x application call this", so it reports the deprecated functions as supported even though core exports almost none of them, because the Driver Manager's mapping is what makes that true.stackable-odbc-corederives those entries from their 3.x counterparts automatically. An entry there naming aFunctionIdabsent fromCORE_EXPORTED_FUNCTIONSis correct and deliberate, not an oversight; psqlODBC ships the same combination (pfExists[SQL_API_SQLERROR] = TRUEbeside a commented-out;;SQLErrorin its.def). -
get_type_info: Include both ANSI and Unicode type variants. pyodbc queriesSQLGetTypeInfo(SQL_VARCHAR=12)andSQLGetTypeInfo(SQL_CHAR=1). If onlySQL_WVARCHAR(-9) andSQL_WCHAR(-8) are returned, pyodbc cannot perform type conversions andSQLGetDatafails for numeric types. -
SQL_GETDATA_EXTENSIONS: Report exactly what the sharedstackable-odbc-corefetch/bind implementation supports; do not reflexively return0x0F.SQL_GD_ANY_COLUMN | SQL_GD_ANY_ORDER | SQL_GD_BOUND(0x0B) is correct for a forward-only driver, becausesql_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 viaSQLGetData.SQL_GD_BLOCKmust not be included unless the driver implements block cursors:SQLSetStmtAttrW(src/ffi/stmt_attr.rs) rejects anySQL_ATTR_ROW_ARRAY_SIZEother than 1 (substituting 1 back with01S02), so a driver that inherits that behaviour can never produce a multi-row rowset forSQL_GD_BLOCKto describe. -
Unknown
SQLGetInfoWinfo types:stackable-odbc-corereturnsU32(0)for unknown info types, because returningSQL_ERRORcorrupts the DM's internal state. For the genuine per-source-typeSQL_CONVERT_*info types it returns0xFFFFFFFF("all conversions supported"), because returning 0 causes the DM to blockSQLGetDatawithHYC00.That set is 53–71, 122–126 and 173, not the contiguous 48–73 the numbering suggests. The gap matters: 48 is
SQL_CONVERT_FUNCTIONS, a bitmask of whetherCAST/CONVERTsyntax is supported at all, and 49–52 are the numeric/string/system/timedate scalar-function bitmaps. Answering "all supported" for those claims scalar functions the backend may not have, which is how a BI tool comes to emit{fn SOUNDEX(x)}against a data source that rejects it.info_type_default_responseclassifies them individually againstsqlext.hfor exactly this reason.
Appendix G, "Mapping Deprecated Functions": a 3.x driver "does not have to implement the ODBC 2.x functions", and the mapping "is triggered when the driver is an ODBC 3.x driver and the driver does not support the function that is being mapped."
So exporting one does not add a capability, it removes the Driver
Manager's, which is usually better informed. unixODBC's SQLSetScrollOptions
mapping checks the requested concurrency against the driver's own SQLGetInfo
answers before setting anything, where a driver-side export is a bare
SQL_ERROR that replaces all of it. SQLError's mapping routes to
SQLGetDiagRec, which core implements properly, where an export would answer
SQL_NO_DATA and leave an ODBC 2.x application with no diagnostics at all.
Core therefore exports no function whose ODBC 2.x call the Driver Manager maps,
and psqlODBC comments out every one of them in its .def. Two exports sit near
that line:
SQLFreeStmtis an ODBC 3.x function in its own right. Appendix G covers only its deprecatedSQL_DROPoption, which the Windows DM passes through rather than mapping, so core exports the function.SQLExtendedFetchis deprecated but unmapped. Appendix G's table does not list it, so exporting it displaces no Driver Manager capability, and an ODBC 2.x application reaches a real implementation only if the driver provides one.
Before implementing any deprecated entry point, check
CORE_UNEXPORTED_FUNCTIONS: each entry records which 3.x function the DM maps
it to. "We export it, so we should make it work" is backwards whenever the
Driver Manager already maps the function.
A statement owns four descriptors, the ARD, APD, IRD and IPD, and ODBC makes
them the definition of a binding rather than a copy of one. SQLBindCol's
page: "when SQLBindCol is called, the driver sets fields in the ARD." So
there is one storage, not a binding map beside a descriptor:
| Descriptor | Reached by | Records | What they are |
|---|---|---|---|
| ARD | desc_of(stmt, Ard) |
DescriptorRecord |
what SQLBindCol set |
| APD | desc_of(stmt, Apd) |
DescriptorRecord |
SQLBindParameter's C-side buffer |
| IPD | desc_of(stmt, Ipd) |
DescriptorRecord |
SQLBindParameter's declared SQL type |
| IRD | desc_of(stmt, Ird) |
none stored | computed from ColumnDescriptor on read |
Each is its own registered allocation rather than a field of the statement, and the two application descriptors may be replaced by one the application allocated; see "Reaching a descriptor" below.
Descriptor carries a role: DescriptorRole rather than a type parameter,
because ODBC has one record shape and four readings of it. SQLSetDescField
accepts any field identifier against any descriptor and decides validity from
the role.
SQLBindParameterwrites two descriptors. The C-side fields are an APD record and the declared type is an IPD record, under the same key, removed together. One record spanning both is what makesSQLSetDescFieldunimplementable. Readers takeParamRecord<'_>, a borrowed view of both halves, fromParamRecords::get.- The IRD is a computed view, never stored state.
SQLGetDescFieldandSQLGetDescRecon the IRD delegate tocol_attr::get_column_attribute, which is alsoSQLColAttributeW's implementation. The two are spellings of one question, and answering them from two places is how they come to differ. A read before the statement has produced column metadata isHY007; the spec: "Until the IRD has been populated, any attempt to gain access to a field of an IRD will return an error." A write isHY016, except the two header fields that row exempts by name. - A binding is a non-null
SQL_DESC_DATA_PTR, not a present key, but that answers "is there a data buffer", not "is there a binding". A record exists as soon as any one field is set, so key presence answers neither question, and every site needing the first callsDescriptorRecord::is_bound. The second has two pointers in it: the spec letsSQLBindColunbind a column's data buffer while keeping its length/indicator buffer ("An application can unbind the data buffer for a column but still have a length/indicator buffer bound for the column"). Socollect_bindingsadmits a record carrying either pointer, and skips only a record carrying neither. The mature drivers split on this, MySQL Connector/ODBC keeping such a record and psqlODBC clearing the whole binding, and core follows the spec sentence, which is unconditional. The visible half of getting this wrong is the indicator:write_column_valuedeclines to write through a null target but writes the length indicator unconditionally, which is exactly what makes the indicator-only binding work and exactly what makes a stray record visible. set_concise_typeis the only writer of the type trio. SettingSQL_DESC_CONCISE_TYPEalso setsSQL_DESC_TYPEandSQL_DESC_DATETIME_INTERVAL_CODE, and the subcode is not the concise type:SQL_TYPE_DATEis 91 whileSQL_CODE_DATEis 1.col_attrholds both mappings (verbose_typeanddatetime_interval_subcode) so the descriptor andSQLColAttributecannot disagree about one column.- Eight statement attributes are descriptor header fields, per
SQLSetStmtAttr's own mapping table, which says setting one sets the other.HeaderOwner::ofnames them, andHandleScope::attr_get/attr_setis the only way to reach an attribute's storage.descriptor::header_attributeis the same table read in the other direction, forSQLGetDescField. The four IRD- and IPD-side pairs (SQL_ATTR_ROW_STATUS_PTR,SQL_ATTR_ROWS_FETCHED_PTR,SQL_ATTR_PARAM_STATUS_PTR,SQL_ATTR_PARAMS_PROCESSED_PTR) live onstmt.attrs, andattr_getroutes them there so no caller needs to know. The storage is keyed by theSQL_DESC_*field, not by the attribute, because the mapping is not one-to-one:SQL_DESC_ARRAY_SIZEisSQL_ATTR_ROW_ARRAY_SIZEon an ARD andSQL_ATTR_PARAMSET_SIZEon an APD. One explicit descriptor may also be the ARD of one statement and the APD of another, so two keys for one field would be two values for one field. odbc-sysmisspells one of the eight.SQL_ATTR_PARAM_OPERATION_PTRisStatementAttribute::ParamOpterationPtr, transposed letters, upstream. A grep for the correct spelling finds nothing and reads as "core does not implement it", which is false.
descriptor::consistency_check returns HY021, and SQLSetDescRec's own
"Consistency Checks" section says when it runs: "This check is always performed
when SQLBindParameter or SQLBindCol is called or when SQLSetDescRec
is called for an APD, ARD, or IPD", plus SQLSetDescField when it sets
SQL_DESC_DATA_PTR.
SQLBindCol and SQLBindParameter both run the check, so either can return
HY021. Each function's doc comment lists all five of the spec's clauses and
states which core reduces and why.
Clause 5 is checked. It reads "if SQL_DESC_CONCISE_TYPE is an interval
type, SQL_DESC_DATETIME_INTERVAL_PRECISION is a valid interval leading
precision". The C to SQL: Numeric table's interval row reads that field, so
core enforces it: a leading precision is a digit count and cannot be negative,
while zero passes and means the application declared none. Reading zero as
"unspecified" rather than as a literal limit is the same reading
check_declared_decimal_size gives a zero ColumnSize, and the conversion
relies on it. The clauses about interval seconds precision remain reduced.
A value core cannot honour must be refused identically through both doors:
SQL_DESC_ARRAY_SIZE set through SQLSetDescField routes through the same
01S02 substitution SQLSetStmtAttr(SQL_ATTR_ROW_ARRAY_SIZE) applies, because
they are one value.
Every descriptor is its own registered allocation. A statement holds four
tokens, not four Box<Descriptor> fields, plus two Option overrides for the
application descriptors. An explicit descriptor is parented to the
connection and an implicit one to its statement. All of them join the
connection's lock group, the one every statement on it already shares, so a
descriptor adds no lock and no ordering rule.
Descriptorhas aHasKindimpl. A token names exactly one descriptor, and the struct at that address carries its ownrole, soHandleScope::getneeds nothing the registry cannot check.HandleScope::stmt_with_descis sound, on the same footing asstmt_with_parent: the statement holds opaque tokens the compiler cannot follow, and aDescriptorholds no back-pointer, so neither is reachable from the other.stmt_with_parent_and_paramsis the three-way form of the same argument, for the calls that need a statement, its connection and both parameter descriptors at once.Dropdoes not reclaim them.free_statement_allocationfrees the four explicitly,SQLFreeHandlefrees an explicit one, andSQLDisconnectfrees any left on the connection. Miri's leak check is what enforces all three.
HandleScope::desc_of is the single door onto descriptor storage: it applies
the override, so no call site can read the implicit descriptor while the
application believes its own is in use. A site that already resolved the
statement should copy descriptor_token(role) out and use
HandleScope::descriptor instead, because going through desc_of there
resolves the statement a second time, which handle_lookup measures.
A Descriptor is never reached by casting an address, only through the
registry, as every other handle kind is.
Four questions a future reader would otherwise relitigate:
HY024is core's;HY017is not.SQLSetStmtAttr'sHY024row states the cross-connection descriptor case verbatim and closes with the general rule that makes it core's: "For all other connection and statement attributes, the driver must verify the value specified in ValuePtr".HY017is(DM)on both of its clauses, so core adds neither check. The second clause's "other than the handle originally allocated" implies the original is allowed, so it is accepted. The check core makes compares the parent chain, so a descriptor of this connection and one of this connection's statements both pass.SQLFreeHandleanswersHY000on the ownership branch, neverHY017. It routes by parentage rather than by alloc type: this function allocated the descriptors whose parent is a connection and only those. Retiring a statement's own slot would leave that statement pointing at nothing. The refusal is ownership, not a spec check, and borrows no(DM)code to say so; the same function already answersHY000for an unimplemented handle type, whose table lists noHYC00either. A token that is not a descriptor at all isSQL_INVALID_HANDLE, which is a different question.SQLCopyDescnever holds two group locks. The spec permits a copy across connections and even across environments, so source and target may be in two groups. Phase one takes the source's group throughHandleScope::with_groupand materialises an ownedDescriptorSnapshot; that function's return type carries no guard, so the release before phase two is structural rather than remembered. Phase two is an ordinarypanic_safeon the target, which is where every diagnostic belongs, including theHY007phase one decided.opposite_direction_copies_cannot_deadlockmodels it, andthe_set_of_group_lock_acquisition_sites_is_closedrecords the site.- A shared descriptor means shared bindings. Two statements pointed at one
explicit ARD have one binding set between them, so
SQLFreeStmt(SQL_UNBIND)on either clears both. That is spec-correct, since the spec makes the descriptor be the binding, and it has a test rather than a workaround.
Two smaller ones. SQL_DESC_ALLOC_TYPE follows the allocation, and is the one
field SQLCopyDesc never copies. DescriptorSnapshot carries neither it nor
the source's role, because the consistency check runs under the target's
role, and a snapshot that remembered where it came from would invite a check
against the wrong one.
All five descriptor functions are implemented and reported by SQLGetFunctions:
SQLGetDescFieldW, SQLSetDescFieldW, SQLGetDescRecW, SQLSetDescRec and
SQLCopyDesc. SQLAllocHandle(SQL_HANDLE_DESC) and
SQLFreeHandle(SQL_HANDLE_DESC) work, an application descriptor can be swapped
in through SQL_ATTR_APP_ROW_DESC / SQL_ATTR_APP_PARAM_DESC, and one
descriptor may be shared across statements on a connection.
DescriptorRole has a fifth variant, App, for an explicitly allocated
descriptor whose role is not yet known. The spec: "it is not known whether an
explicitly allocated application descriptor is an APD or ARD until execute
time". field_access(App, f) is defined as the ARD's cell, and
the_ard_and_apd_field_tables_agree_everywhere is what makes that a derived
fact rather than a fourth hand transcription.
SQL_OIC_CORE is satisfied. Core-level conformance requires allocating and
freeing all handle types and manipulating descriptor fields through all five
functions, which is what the above closes.
Still out of scope, deliberately: bookmark records (record 0), and automatic
population of the IPD. SQL_ATTR_AUTO_IPD stays SQL_FALSE, so the five
footnote-[1] fields stay Undefined on the IPD.
Handle contents are internally synchronised, per connection, not left to the
Driver Manager. SQLAllocHandle's Comments section requires this: "Drivers
must therefore support safe, multithread access to this information." The
mechanism:
-
A lock group is per connection, shared with every statement and descriptor allocated on it. One acquisition therefore covers a call that touches a statement and its parent connection, so there is no ordering between the two to get wrong. Groups are
GroupLock(src/handles/registry.rs), and which group a token belongs to is derived from the registry rather than stored on the handle. -
HandleScopeis the only way to reach a handle's contents.panic_safe(src/panic.rs) locks the target's group before constructing the scope and ties the scope's lifetime to that lock. So "the group lock is held" is a fact the borrow checker enforces rather than a rule a comment states. Every other caller ofHandleScope::newdoes the same, andhandles/scope.rs's module doc is the authoritative list of them. -
A
Backendmethod must never re-enter aSQLxxxentry point on the same connection. EveryBackendmethod, includingconnect, runs whilepanic_safeholds that connection's group lock, and the lock is not reentrant. Calling back in, directly or through an application callback, deadlocks the calling thread with no diagnostic and noSqlReturn, because the thread never returns far enough to produce either.Backend::cancelis the one exception, covered below. -
The one lock-ordering rule is environment before connection, and
SQLEndTran(SQL_HANDLE_ENV)is its only site: it holds the environment's group while walking that environment's connections viaHandleScope::with_child_group. Do not acquire a connection's group first and then reach for its environment's; nothing else in the crate nests two groups at all. -
SQLCancelis deliberately exempt from taking the group lock. It may run on a thread other than the one executing on the target statement. Taking that statement's group lock unconditionally would make cancelling a query wait for the query it was asked to cancel. Instead it clones the statement's cancel token out of the registry, then attempts the group withtry_lock. On the branch where another thread holds the group, cancel signals the backend'sCancelTokenand returns, touching no handle state and posting no diagnostic, per the spec's carve-out for a function running on another thread.A
CancelTokencarries the crate's one bounded exception to "core never touches a backend's state concurrently", so it has two obligations. It must be built eagerly, with the connection's real parameters in hand, at first use rather than lazily insidecancel; seeBackend::cancel_token's doc comment for the MariaDB ODBC-401 failure this rule prevents. And if it aliases the connection rather than standing alone, it must keep its target alive through anArc, because core clones the token out before doing anything else and it must survive a concurrentSQLDisconnect.Two consequences follow from running the cross-thread branch lock-free. A
SQLGetDiagRecWorSQLGetDiagFieldWimmediately after such a cancel blocks until the cancelled call has unwound through the backend, because both take the connection's group and reading the diagnostic queue while another thread pushes to it is undefined behaviour.SQLCancelitself still returns promptly; the wait moves to whichever call reads diagnostics next. Separately,try_lockcannot tell "a sibling statement on this connection is busy" from "my own statement is busy". Either pushesSQLCancelonto the cross-thread branch, so a merely idle statement's data-at-execution state is occasionally left uncleared where it strictly could have been cleared, which is harmless and explicitly spec-legal. -
A cancelled call reports
HY008, and the token is minted per execution.Backend::cancelsignals andBackend::is_cancelledobserves, and they are a pair. A backend implementing the first and not the second still cancels the work, but the application sees whatever SQLSTATE the driver's error mapping produced instead of "operation canceled". Core asksis_cancelledonly after a backend call returned an error, because the spec permits a cancelled execution to finish anyway ("it is possible for the execution to succeed and return SQL_SUCCESS while the cancel is also successful"), soOkis never reclassified. The single implementation iscrate::cancel.mint_cancel_tokenbuilds a new token at every statement-producing call, and the cursor-consuming calls read that execution's token rather than minting one. One token per statement would leave a cancelled statement permanently unusable, becauseBackend::cancelmarks the token and the next execution would reuse it. The spec requires the opposite: "After the statement has been canceled, the application can call SQLExecute or SQLExecDirect again." Keeping a stale token as a guard buys nothing either, because "a call to SQLCancel when no processing is being done on the statement ... has is [sic] no effect at all." -
SQLCancelis not the only cross-thread caller ofBackend::cancel.src/query_timer.rsenforcesSQL_ATTR_QUERY_TIMEOUTfor a backend that answeredQueryTimeout::CoreCancels, and it does so the only way a synchronous trait allows: a timer thread callsBackend::cancelwhile the calling thread is still blocked inside the backend. It holds no lock, the same footing asSQLCancel's cross-thread branch, so the rule that acancelimplementation must never block on this connection's lock covers it unchanged. It clones the token out of the registry for the same reason too, because the token has to survive a statement freed while a timer is still armed.A timed-out call reports
HYT00, notHY008. Both arrive through a signalled token, so the ordering inQueryTimer::checkis load-bearing: the cancel pass runs first and would label itHY008, and the timeout pass runs second so the more specific state wins. An application that set a deadline is waiting to tell "my deadline passed" from "another thread cancelled me".The attribution outlives the call that made it, and has to. A deadline that expires as the backend call is returning cancels the token and leaves the call successful, so the failure it causes surfaces on a later call whose own timer never fired.
cancel::CancelStateis therefore what the registry stores: the backend's token plus core'stimed_outflag, in one allocation. The flag is minted per execution and survives a freed statement exactly as the token does.QueryTimer::reclassifyreads it, which confinesHYT00to the entry points that hold a timer.cancel::reclassify_cancelleddeliberately does not read it. The entry points that reach it without a timer,SQLGetData,SQLDescribeParam,SQLDescribeColandSQLColAttribute, have noHYT00row between them, so those keepHY008on a timed-out cursor. Do not shorten that to "the timer-holding entry points are exactly the ones with anHYT00row":SQLParamDataholds a timer and relabels, and its own table has no such row. It inherits one from the sentence after its table ("it can return any SQLSTATE that can be returned by the function called to execute the statement"), which is the same grant its doc comment records other inherited states under. Check a function's table and its surrounding prose before deciding either way. -
Every lock in the crate is imported from
src/sync.rs, never directly fromstd::sync, so that building a test with--cfg loomswaps every one of them for loom's instrumented equivalent. A lock imported around that module would be invisible to loom and silently opt its code out of the interleaving proof.Two exceptions, each stated at its site and in
sync.rs.query_timer.rstakes itsCondvarandMutexfromstd::sync, because loom'sCondvarcannot model a timeout at all;logging.rstakes itsMutexfromstd::sync, becausetracing_subscriberimplementsMakeWriterfor that type specifically.src/sync.rs's own comment carries the detail. The rule being enforced is "no lock silently opts itself out", so a stated exception does not break it and a quiet one would. Before adding a third, check whether loom can model the primitive at all: if it can, import it fromsync.rs.
Loom models the primitives this discipline is built from, Registry,
GroupLock, and the crate's own nested-lock path
(HandleScope::with_child_group_in), in src/handles/registry.rs's
#[cfg(all(test, loom))] mod loom_tests. It does not model the FFI entry
points above them, since registry() panics outside an active loom::model
and cannot be called from inside one either. loom replays the same closure many
times to explore interleavings, while a static only runs its initializer
once.
with_child_group has an _in variant taking a &Registry so a model can
call the real function. A model that can only reach a function by
re-implementing what it does proves a property of the test rather than of the
crate. Before accepting "the model cannot reach this", check whether a
&Registry parameter is all that stands in the way.
Run them with:
RUSTFLAGS="--cfg loom" cargo test --lib loom_testsThe loom_tests filter is required: every other unit test in the crate also
compiles under --cfg loom once it is set, and calls the process-wide
registry outside a model, which panics as soon as Registry::new resolves to
loom's RwLock. If a model runs long, lower LOOM_MAX_PREEMPTIONS before
simplifying the model itself, because a smaller bound still proves more than no
model. CI sets it in .github/workflows/build.yaml.
Run cargo test. It must produce zero warnings.
- Mocks live in
test_utils.rs.MockBackendis the shared default: connect and disconnect succeed, everything else returnsNotImplemented. Purpose-built mocks cover the paths it cannot reach, among themMockAltBackend,MockNoCatalogBackend,MockTypeInfoBackend,MockFunctionsBackend,MockFailingCloseBackendand themock_isolation_backend!/mock_txn_backend!families. Grepstruct Mockandmock_in that file for the current set. MockAltBackenddeclares a different value for every capability method, so a guard test can watch an answer move with the backend rather than passing against a constant.- A mock returning an empty slice makes a loop run zero times, so
MockTypeInfoBackendandMockFunctionsBackenddeclare real rows and a real function list rather than letting a test pass vacuously. - Array fetch and batch parameter paths (
SQL_ATTR_ROW_ARRAY_SIZE,SQL_ATTR_ROWS_FETCHED_PTR,SQL_ATTR_PARAMSET_SIZE) are covered by direct C ABI calls with pre-allocated column and parameter buffers, which Rust handles cleanly without any external dependencies. - A driver crate tests its own
Backendimpl, and adds FFI-level integration tests that call the generated C ABI entry points. Those live in the driver's repository, not here.
stackable-odbc-core is checked by Miri on every PR (the miri job in
.github/workflows/build.yaml). Run it locally the same way:
rustup +nightly component add miri
MIRIFLAGS="-Zmiri-disable-isolation" \
cargo +nightly miri test -p stackable-odbc-core --lib -- --skip proptestRuntimes move with every commit, so take them from the run rather than from
here. The CI job budgets 30 minutes and has come close to spending it, so when
a run slows down put -Z unstable-options --report-time after the --: the
per-test breakdown names the test responsible.
-
Nightly only. Miri cannot run on the pinned stable toolchain.
-
Pure Rust. All the raw-pointer marshalling lives in
stackable-odbc-core, so it is where the undefined-behaviour risk lives and where Miri earns its keep. -
Proptests are skipped, because they take hours under Miri. They run on stable instead.
-
A test whose cost is algorithmic rather than memory-safety-related gets
#[cfg_attr(miri, ignore = "…")]. Miri's slowdown turns a large input into minutes or hours, against a CI budget of 30. The precedent isescape::tests::pathological_nesting_returns_an_error_rather_than_killing_the_process, and skipping it loses nothing:src/escape.rscontains nounsafefor Miri to check, and the neighbouringMAX_ESCAPE_DEPTH ± 1tests cover the limit on both recursion paths. Before adding a big-input test, ask whether the code under it isunsafeat all; if not, Miri is not the tool that should be paying for it.A big input does not have to look big. The guards in
types/diagnostics_table.rstake no parameters and read no files at runtime, so nothing about them reads as expensive, yet each scans the FFI source that moduleinclude_str!s and Miri interprets that byte by byte. Together they are the largest single cost in the run, in a module containing nounsafewhatsoever. Watch forinclude_str!, a full-u16-space scan, or any other input baked in at compile time rather than passed in, because the native runtime will not warn you: Miri multiplies the ratio, not the absolute time. Every guard there carries#[cfg_attr(miri, ignore = …)], and a further scanner added to that module needs one too. -
Leak reporting is deliberately left on. It is what catches a handle or descriptor allocation that a teardown path forgets to free. A test that allocates handles must free them, or the job goes red.
-
A run after any source change rebuilds the crate under Miri first, and that rebuild dominates. Budget for it before assuming a run has hung.
-
-Zmiri-disable-isolationis required, not optional.column_value::current_utc_datereads the wall clock, which theSQL_TYPE_TIMEtoSQL_C_TYPE_TIMESTAMPconversion needs ("the date fields of the timestamp structure are set to the current date"). Without the flag Miri refusesSystemTime::nowas an unsupported operation and the test aborts rather than failing an assertion, which reads like a Miri bug rather than a missing flag.
Every access through an application-supplied pointer must be read_unaligned /
write_unaligned, a byte-wise copy, or an element-wise loop. ODBC applications
using row-wise binding pass pointers at arbitrary offsets into a packed buffer,
so alignment is never guaranteed. Four operations carry an alignment
requirement, and all four have been the source of real bugs here:
| Operation | Requirement |
|---|---|
*ptr = v / *ptr, including *(p as *mut T) = v |
aligned for T |
slice::from_raw_parts(_mut) |
aligned for the element type; UB on construction, before anything is read |
ptr::copy_nonoverlapping |
both pointers aligned for T; cast to *mut u8 to avoid it |
&*(p as *const T) |
aligned for T |
u8 pointers are exempt, because u8 has alignment 1.
Grep for the operation, not for *ptr. A deref of a cast
(*(diag_info as *mut i32) = v) and a multi-line unsafe block both evade the
obvious pattern, and from_raw_parts looks nothing like a deref at all.
A misaligned access is only sometimes observable on x86-64. With
debug-assertions on, four of the five shapes abort the process: the standard
library's precondition check fires and raises a non-unwinding panic, which
panic_safe's catch_unwind cannot contain.
| Shape | Detected by cargo test (debug) |
|---|---|
*ptr = v / *ptr, including through a cast |
yes, abort |
slice::from_raw_parts(_mut) |
yes, on construction |
ptr::copy_nonoverlapping |
yes |
&*(p as *const T) |
yes |
ptr::write / ptr::read |
no, silently succeeds |
ptr::write is precisely the aligned sibling of the write_unaligned this
crate uses everywhere, so the one regression the misalignment tests exist to
catch is the one a debug build does not catch. Catching it needs
-Zmiri-symbolic-alignment-check. With debug-assertions off, nothing in the
table is detected at all, and in release it usually just works, until it does
not.
-Zmiri-symbolic-alignment-check is a manual tool, deliberately not in CI.
Plain Miri checks alignment against the concrete address the allocator
returned, so a test can pass by luck: offsetting +1 into a Vec<u8> is not
reliably misaligned, because a byte allocation has alignment 1 and may already
start on an odd address. The symbolic check ignores the concrete address and
catches the class regardless. It is slow enough not to be worth a per-PR job,
so run it by hand when touching pointer marshalling:
MIRIFLAGS="-Zmiri-disable-isolation -Zmiri-symbolic-alignment-check" \
cargo +nightly miri test -p stackable-odbc-core --lib -- --skip proptestTo write a test that is misaligned on every platform, offset one byte into an allocation of the target type, not into a byte buffer:
let mut arena = vec![0u16; 16];
let ptr = unsafe { arena.as_mut_ptr().cast::<u8>().add(1) }.cast::<u16>();fuzz/ holds cargo-fuzz targets for
the raw-pointer paths: write_column_value, utf16 and
ffi::setup::parse_attributes_w. The first two allocate their output buffer at
exactly the caller-declared length, so AddressSanitizer catches any overrun that
clippy cannot see; the third walks a Driver-Manager pointer looking for a
terminator. It is its own Cargo workspace, because libFuzzer needs nightly, so
the root build ignores it. A short smoke run of every target runs on every PR
(the fuzz job in build.yaml).
cargo install cargo-fuzz
cargo +nightly fuzz run utf16
cargo +nightly fuzz run column_value
cargo +nightly fuzz run parse_attributesA fuzz target is for unsafe code. Where the code is safe Rust the worst
outcome is a panic or a wrong answer, and a proptest suite next to the code
finds both on stable, in far less CPU time, on every cargo test rather than in
a 30-second smoke run. escape, types::connect_params, param_convert,
numeric_convert and types::conversions are covered that way, with an oracle
rather than only a never-panics assertion wherever one exists.
Reaching a pub(crate) item from fuzz/, which is a separate crate, goes
through a wrapper gated on the default-off test-support feature.
parse_attributes_summary_w is the example to copy.
See fuzz/README.md for what is and is not worth fuzzing, and
for why a fuzz target must terminate the buffer it hands to a parser whose
contract says it is terminated.
Core has three Criterion benchmarks, all in bench/:
cd bench && cargo bench| Benchmark | What it drives | What it cannot see |
|---|---|---|
fetch_throughput |
SyntheticStatement::fetch() / get_data() directly, in memory, with no backend |
the FFI layer at all: no panic_safe, no registry lookup, no descriptor, no write_column_value |
handle_lookup |
the FFI entry points with no result set open, so HandleScope::get in isolation |
binding, fetch and data marshalling, because BenchBackend::exec_direct is never called |
ffi_fetch |
the FFI entry points against a real result set, with panic_safe, the registry, the ARD and write_column_value on the measured path |
nothing the other two reach; a connection is installed through the test-support feature's attach_connection |
fetch_throughputmeasuresSyntheticStatement's own row-cloning andColumnValueconstruction cost, not the marshalling into an application's buffer, which isffi_fetch's job.BENCH_ROWSoverrides the row count.handle_lookuphas two shapes, because the error path is not the success path scaled:get(oneHandleScope::get, then trivial work) andget_then_push_diagnostic, wherepanic_safealso has to find the handle again.ffi_fetchhas two groups.ffi_fetch_boundbinds three columns withSQLBindCol(onei64, one 1 KiB string, one 1 KiB bytes) and loopsSQLFetchoverBENCH_ROWSrows.ffi_get_data_chunkedfetches one row and then drains a 64 KiB string column through a 512-byteSQLGetDatabuffer untilSQL_NO_DATA, exercising theGetDataCursorchunking loop that a bound column never reaches.
Pick the one that can actually see what you changed. A registry or locking
change is invisible to fetch_throughput, a ColumnValue conversion inside
SyntheticStatement is invisible to handle_lookup, and anything in
write_column_value, the ARD or the SQLGetData chunking cursor is invisible
to both. If nothing covers it, add a fourth rather than quote a number from the
wrong one.
bench/ is a detached Cargo workspace so that criterion stays out of core's
dependency graph and cargo package does not warn about a [[bench]] target
excluded from the published crate. The directory is singular because cargo
auto-discovers benches/ as a target directory and then insists on validating
any manifest inside it, which fails packaging.
odbc-sys is a minimal -sys crate for ODBC type definitions. It has no
convenience methods by design (see
PR #47 for the rationale), so
stackable-odbc-core is the driver-side convenience layer on top of it.
- Always use
odbc-systypes where they exist:HandleType,SqlReturn,CDataType,SqlDataType,InfoType,Desc,FreeStmtOption,AttrOdbcVersion,EnvironmentAttribute,Len,Pointer,WChar, and so on. For primitive parameters where odbc-sys 0.29 removed the type aliases (the oldSmallInt, for instance), use the Rust primitives directly (i16,u16,i32). - Never redefine enums, structs, or constants that
odbc-sysalready provides. Checkodbc-sysbefore defining a new constant or enum, and use what is there. - Add driver-side extensions in
stackable-odbc-core. Orphan rules preventimpl TryFrom<i16> for odbc_sys::HandleType, so use standalone conversion functions such asfn handle_type_from_raw(v: i16) -> Option<HandleType>. - Keep our own types only for things
odbc-sysdoes not have:ConnectParams,ColumnValue,ColumnDescriptor,FetchResult,InfoValue,SqlState,DiagnosticQueue,TypeInfoRow,OdbcError. - ODBC function IDs (
SQL_API_*values) are not inodbc-sys. They live insrc/function_id.rsas theFunctionIdenum, sourced from/usr/include/sql.handsqlext.h. Always useFunctionId::ExecDirectand its siblings, never raw numeric IDs, and convert withfunction_id_from_raw(u16) -> Option<FunctionId>.
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.
// 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 loggingAvailable conversion functions (all in src/types/conversions.rs unless noted):
handle_type_from_raw(i16) -> Option<HandleType>desc_from_raw(u16) -> Option<Desc>info_type_from_raw(u16) -> Option<InfoType>c_data_type_from_raw(i16) -> Option<CDataType>param_type_from_raw(i16) -> Option<ParamType>environment_attribute_from_raw(i32) -> Option<EnvironmentAttribute>attr_odbc_version_from_raw(i32) -> Option<AttrOdbcVersion>free_stmt_option_from_raw(u16) -> Option<FreeStmtOption>statement_attribute_from_raw(i32) -> Option<StatementAttribute>completion_type_from_raw(i16) -> Option<CompletionType>fetch_orientation_from_raw(i16) -> Option<FetchOrientation>identifier_type_from_raw(u16) -> Option<IdentifierType>nullable_from_raw(u16) -> Option<Nullable>scope_from_raw(u16) -> Option<Scope>bulk_operation_from_raw(i16) -> Option<BulkOperation>interval_from_raw(i16) -> Option<Interval>declared_odbc_version_from_raw(i32) -> Option<DeclaredOdbcVersion>driver_connect_option_from_raw(u16) -> Option<DriverConnectOption>function_id_from_raw(u16) -> Option<FunctionId>(infunction_id.rs)
If odbc-sys adds a new enum that we need to convert from raw values, add an
xxx_from_raw function following the same pattern. Do not use transmute.
SQLSetPos's Operation and LockType are the one documented exception. An
odbc-sys type is usable here only if the raw ABI value can be recovered from
it. odbc_sys::Operation and odbc_sys::Lock are newtype structs over a
private i16, with no accessor, no From, and no #[repr] enum to cast
through. A converted value can therefore be compared against their associated
constants and used for nothing else, and no caller or test can name a valid
input. Those two validate against SQL_POSITION and SQL_LOCK_* in
types/constants.rs, which is the exception that block's comment records.
Before adding a conversion, check that the target type can round-trip. If it
cannot, a named constant is the correct answer rather than a worse conversion.