Skip to content

mversic/rs-odbc

Repository files navigation

RS-ODBC

License: Apache 2.0 Build Crates.io

Rust implementation of the ODBC API that looks and feels like ODBC but is safe

Description

Main design goal of this crate is that the exposed API should look as close as possible to the original ODBC API while providing type safety wherever possible. This crate prevents most of the safety issues inherent to C code and moves most of the application errors to compile time. ODBC state transitions FSM is implemented inside Rust's type system so that many of the invalid handle errors are prevented as compile errors.

Why this crate

1. Known API

If you have already worked with the ODBC API you will feel at home using this crate, i.e. you don't have to learn yet another API. With this crate you are getting a well known, highly used and standardized API. The level of abstraction over the original ODBC API is minimal so that you can use the original ODBC documentation. Translating existing ODBC applications or examples from C to Rust is very straightforward.

2. Safe API

For most applications you will never have to resort to using raw pointers. Crates which expose custom high-level API wrappers around ODBC will most likely force you to fall back to using the raw API when you are required to use ODBC features that are not expressible through the API they provide. This will introduce unnecessary safety risks for your application unless those crates are built on top of this crate.

3. Complete API

This crate is designed to be fully ODBC compliant so there should be no low-level ODBC feature that cannot be expressed through this crate. However, it is possible that a particular feature may not have been implemented yet. If you notice that a feature is missing, you are encouraged to open an issue requiring the feature.

Installation

To be able to use this library you have to have ODBC Driver Manager installed and configured on your host OS. This library dynamically links against the odbc32.dll on Windows or against libodbc.so(unixODBC) on Linux and OS-X. To enable static linking of native libraries use the cargo static feature.

Cargo features

static

Enables static linking of native libraries. If static linking is enabled user must define RS_ODBC_LINK_SEARCH environment variable which contains path to static libraries this crate will link against. For unixODBC, user should provide both libodbc.a and libltdl.a under this path. Static linking is not supported for Windows.

API differences

  1. ODBC functions are implemented as methods or associated functions on handles. Therefore, providing handle identifier(e.g. SQL_HANDLE_STMT) as an argument becomes unnecessary
C ODBC eample Rust ODBC example
SQLHSTMT hstmt = SQL_NULL_HSTMT;
int ret1 = SQLAllocHandle(
    SQL_HANDLE_STMT,
    hdbc,
    &hstmt
);
if (SQL_SUCCEEDED(ret1)) {
    int ret2 = SQLCancelHandle(
        SQL_HANDLE_STMT,
        hstmt
    );
}
let (hstmt, ret1) = SQLHSTMT::SQLAllocHandle(&hdbc);
if SQL_SUCCEEDED(ret1) {
    let hstmt = hstmt.unwrap();
    let ret2 = hstmt.SQLCancelHandle();
}
  1. Most of the ODBC handle methods return SQLRETURN as per standard, but some will return a tuple (Result<<succ_handle_type>, <err_handle_type>>, SQLRETURN)(e.g. SQLDriverConnect). Returning handles makes it possible to implement the ODBC state transition FSM inside the Rust's type system

  2. ODBC functions which take pointer and it's length take reference to a slice instead. Slice references prevent the possibility of the application writer to write/read past the end of the allocation unit.

C ODBC eample Rust ODBC example
SQLCHAR catalog_name[] = "rs_odbc";
int ret = SQLSetConnAttr(
    hdbc,
    SQL_ATTR_CURRENT_CATALOG,
    catalog_name,
    SQL_NTS,
);
let catalog_name = "rs_odbc";
let ret = hdbc.SQLSetConnAttr(
    SQL_ATTR_CURRENT_CATALOG,
    catalog_name.as_ref()
);
  1. ODBC version is defined at the point when environment handle is allocated. Usually, this should be the first step in your ODBC application but in Rust it is handled by the type system
C ODBC eample Rust ODBC example
SQLHENV henv = SQL_NULL_HENV;
int ret1 = SQLAllocHandle(
    SQL_HANDLE_ENV,
    &SQL_NULL_HANDLE,
    &henv
);
if (SQL_SUCCEEDED(ret1)) {
    int ret2 = SQLSetEnvAttr(
        henv,
        SQL_ATTR_ODBC_VERSION,
        SQL_OV_ODBC3_80,
        0
    );
}
let (env, ret1) = SQLHENV::SQLAllocHandle(&SQL_NULL_HANDLE);
if (SQL_SUCCEEDED(ret1)) {
    let env: SQLHENV<SQL_OV_ODBC3_80> = env.unwrap();
}
  1. Disconnecting and freeing handles is done automatically at the end of scope

Uninitialized variables

When using ODBC functions(such as SQLGetEnvAttr) that take mutable references which are written to, but are never read from by the driver or the DM, it is unnecessary to initialize those variables since they will be initialized during the call to the ODBC function in question. To circumvent the unnecessary initialization, many of the ODBC functions exposed through Rust allow for the usage of both initialized and uninitialized variables (via MaybeUninit).

use core::mem::MaybeUninit;
use rs_odbc::api::Allocate;
use rs_odbc::env::{self, SQL_ATTR_CONNECTION_POOLING, SQL_OV_ODBC3_80};
use rs_odbc::handle::{SQLHENV, SQL_NULL_HANDLE};

fn main() {
  let (env, _) = SQLHENV::SQLAllocHandle(&SQL_NULL_HANDLE);
  let env: SQLHENV<SQL_OV_ODBC3_80> = env.unwrap();

  let mut value = env::SQL_CP_ONE_PER_HENV;     // Initialized to default value
  let _ = env.SQLGetEnvAttr(SQL_ATTR_CONNECTION_POOLING, Some(&mut value), None);

  // Confirm value was modified by the driver
  assert_ne!(env::SQL_CP_ONE_PER_HENV, value);

  let mut value = MaybeUninit::uninit();        // Variable is uninitialized
  let _ = env.SQLGetEnvAttr(SQL_ATTR_CONNECTION_POOLING, Some(&mut value), None);

  // Value initialized by the driver
  match unsafe { value.assume_init() } {
      env::SQL_CP_ONE_PER_DRIVER => println!("SQL_CP_ONE_PER_DRIVER"),
      env::SQL_CP_ONE_PER_HENV => println!("SQL_CP_ONE_PER_HENV"),
      env::SQL_CP_DRIVER_AWARE => println!("SQL_CP_DRIVER_AWARE"),
      env::SQL_CP_OFF => println!("SQL_CP_OFF"),

      _ => panic!("Driver returned unknown value"),
  }
}

The use of uninitialized variables is highly discouraged because their use is usually a micro optimization that will have no measurable effect on the performance of your code and introduce a potential for unexpected UB if not careful(such as partially initialized variables). If some ODBC function is only able to receive uninitialized arguments, users are encouraged to use MaybeUninit::new or MaybeUninit::zeroed to minimize the risk of UB.

Thread safety

All handles are Send, however, at the moment, only SQLHENV is Sync since sharing references to other handles across threads is considered to be an anti-pattern. Obviously, to cancel a function running on a connection or statement handle on another thread one must be able to share a handle reference across threads. Since the operation of canceling is defined by the ODBC standard to always be a thread safe operation, for this specific scenario, from your original handle, you can derive a handle that implements the Sync trait such as WeakSQLHSTMT or RefSQLHSTMT. Handles prefixed with Ref are allocated from a reference to your original handle, while ones that are prefixed Weak are allocated from your original handle wrapped in an Arc.

// TODO: Add code example

If there is a use-case where you would like to be able to share handles other than SQLHENV among threads, please open an issue describing your use-case.

Unsafe API

There are cases where it's not possible to ensure safety through the type system. In these rare cases you can allocate UnsafeSQLHSTMT and UnsafeSQLHDESC which implement additional unsafe API which makes some of the statement functions unsafe

// TODO:

Testing

Integration tests use dockerized environment which has database and ODBC driver already set up.

Testing environment can be set up with docker-compose up -d
Tests are executed with docker exec -t rs-odbc sh -lc 'cargo test'

  • use RUSTFLAGS=-Awarnings to silence compiler warnings which make compile tests fail