Introduction
Sibyl is an OCI-based interface (a.k.a. a driver) between Rust applications and Oracle databases. It supports both sync (blocking) and async (nonblocking) API.
Example
Assuming an HR sample schema is installed, the following example program would report median salaries for each country in the specified region.
Blocking Mode Version
fn main() -> sibyl::Result<()> {
let oracle = sibyl::env()?;
let dbname = std::env::var("DBNAME").expect("database name");
let dbuser = std::env::var("DBUSER").expect("user name");
let dbpass = std::env::var("DBPASS").expect("password");
let session = oracle.connect(&dbname, &dbuser, &dbpass)?;
let stmt = session.prepare("
SELECT c.country_name, Median(e.salary)
FROM hr.employees e
JOIN hr.departments d ON d.department_id = e.department_id
JOIN hr.locations l ON l.location_id = d.location_id
JOIN hr.countries c ON c.country_id = l.country_id
JOIN hr.regions r ON r.region_id = c.region_id
WHERE r.region_name = :REGION_NAME
GROUP BY c.country_name
")?;
let rows = stmt.query("Europe")?;
while let Some(row) = rows.next()? {
let country_name : &str = row.get(0)?;
let median_salary : u16 = row.get(1)?;
println!("{:25}: {:>5}", country_name, median_salary);
}
Ok(())
}
When executed it prints:
Germany : 10000
United Kingdom : 8800
Nonblocking (async) Mode Version
fn main() -> sibyl::Result<()> {
sibyl::block_on(async {
let oracle = sibyl::env()?;
let dbname = std::env::var("DBNAME").expect("database name");
let dbuser = std::env::var("DBUSER").expect("user name");
let dbpass = std::env::var("DBPASS").expect("password");
let session = oracle.connect(&dbname, &dbuser, &dbpass).await?;
let stmt = session.prepare("
SELECT c.country_name, Median(e.salary)
FROM hr.employees e
JOIN hr.departments d ON d.department_id = e.department_id
JOIN hr.locations l ON l.location_id = d.location_id
JOIN hr.countries c ON c.country_id = l.country_id
JOIN hr.regions r ON r.region_id = c.region_id
WHERE r.region_name = :REGION_NAME
GROUP BY c.country_name
").await?;
let rows = stmt.query("Europe").await?;
while let Some(row) = rows.next().await? {
let country_name : &str = row.get(0)?;
let median_salary : u16 = row.get(1)?;
println!("{:25}: {:>5}", country_name, median_salary);
}
Ok(())
})
}
Note the only difference between this and the blocking mode program is that
async
method calls need to beawait
ed. Otherwise theasync
version of the program is a verbatim copy of the non-async one.
Prerequisites
Sibyl needs an installed Oracle client in order to link either OCI.DLL
on Windows or libclntsh.so
on Linux. The minimal supported client is 12.2 as Sibyl uses some API functions that are not available in earlier clients.
Note that while supporting 12.1 and older clients is definitely feasible, it was not a priority.
Using Sibyl In A Project
Sibyl has 2 features - blocking
and nonblocking
. They are exclusive and one must be explicitly selected as neither is the default. Thus, when Sibyl is used as a dependency, it might be included as:
[dependencies]
sibyl = { version = "0.6", features = ["blocking"] }
A nonblocking
mode also needs to know which async runtime/executor it is allowed to use to spawn async tasks. The async runtime selection is also controlled by a set of exclusive features. For now, Sibyl supports tokio
, actix
, async-std
, and async-global
. One of these must be specified with the nonblocking
feature. For example:
[dependencies]
sibyl = { version = "0.6", features = ["nonblocking", "tokio"] }
Building
The cargo build needs to know where the OCI client library is. You can provide that information via environment variable OCI_LIB_DIR
on Windows or LIBRARY_PATH
on Linux. On Linux, depending on which Oracle client is installed and how it was installed, the LD_LIBRARY_PATH
might also be needed. LIBRARY_PATH
(and LD_LIBRARY_PATH
) would include the path to the directory with libclntsh.so
. For example, you might build Sibyl's examples as:
export LIBRARY_PATH=/opt/instantclient_19_24
export LD_LIBRARY_PATH=/opt/instantclient_19_24
cargo build --examples --features=blocking
On Windows the process is similar if the target environment is gnu
. The OCI_LIB_DIR
would point to the directory with oci.dll
:
set OCI_LIB_DIR=%ORACLE_HOME%\bin
cargo build --examples --features=blocking
Note that for
gnu
targets the build script will try to locateOCI.DLL
by searching it in the currentPATH
if theOCI_LIB_DIR
is not specified.
However, for msvc
environment the OCI_LIB_DIR
must exist and point to the directory with oci.lib
. The build will fail if OCI_LIB_DIR
is not set. For example, you might build those examples as:
set OCI_LIB_DIR=%ORACLE_HOME%\oci\lib\msvc
cargo build --examples --features=blocking
Because of the above requirement, that the OCI_LIB_DIR
must be set for msvc
targets, it also must be specified for the rust-analyzer
. For example, in VS Code this can be done in .vscode\settings.json
:
"rust-analyzer.server.extraEnv": { "OCI_LIB_DIR": "C:\\Path\\To\\Oracle\\instantclient\\sdk\\lib\\msvc" }
Optional Features
Sibyl provides one optional opt-in feature - unsafe-direct-binds
.
By default Sibyl creates shadow buffers for arguments that are bound to IN
parameter placeholders. With unsafe-direct-binds
Sibyl instead binds arguments directly. This, of course, is somewhat more performant and conserves memory. However, unsafe-direct-binds
makes it possible to violate Rust's immutability of references when a reference is mistakenly bound to the OUT
or INOUT
placeholder.
Example
let stmt = session.prepare("
SELECT country_name
FROM hr.countries
WHERE country_id = :COUNTRY_ID
")?;
let row = stmt.query_single("UK")?.unwrap();
let name : String = row.get(0)?;
// note that the `name` is not declared mutable
let stmt = session.prepare("
BEGIN
SELECT country_name
INTO :COUNTRY_NAME
FROM hr.countries
WHERE country_id = :COUNTRY_ID
AND country_name != :COUNTRY_NAME;
END;
")?;
stmt.execute((
("COUNTRY_ID", "NL"),
("COUNTRY_NAME", &name),
// `:COUNTRY_NAME` is INOUT but `name` is bound only for reading
))?;
println!("country_name={name}");
Default (Safe) Binding
#[cfg(not(feature="unsafe-direct-binds"))]
// `name` has not changed despite the binding mistake
assert_eq!(name, "United Kingdom");
The OCI actually did change the value as values bound to OUT placeholders are always changed. However, that has happened in the shadow buffer that Sibyl created to bind the value, thus actual value in Rust was not affected.
Unsafe Direct Binding
The binding mistake allows unacceptable mutation of the bound value:
#[cfg(feature="unsafe-direct-binds")]
assert_eq!(name, "Netherlandsdom");
Note also that because the string was bound via a (read-only) reference Sibyl used read-only binding for it and thus the code that sets the String
length to match the loaded value was not executed. As the result the new name still has the last 3 characters from the original name.
Example Project
Let's say we need to write a program to generate a report for the demo HR schema. This report shall list median salaries for each country of the specified region. The name of the region and the connection parameters shall be specified as environment variables (maybe because our program will be executed in a container).
Project Setup
cargo new median-salary
Then edit Cargo.toml
and include Sibyl as a dependency:
[dependencies]
sibyl = { version="0.6", features=["blocking"] }
We will be writing a single threaded program, thus we will use Sibyl's blocking
API.
Implement the Solution
fn main() -> sibyl::Result<()> {
let oracle = sibyl::env()?;
let dbname = std::env::var("DBNAME").expect("database name");
let dbuser = std::env::var("DBUSER").expect("user name");
let dbpass = std::env::var("DBPASS").expect("password");
let region = std::env::var("REGION").expect("HR region");
let session = oracle.connect(&dbname, &dbuser, &dbpass)?;
let stmt = session.prepare("
SELECT c.country_name, Median(e.salary)
FROM hr.employees e
JOIN hr.departments d ON d.department_id = e.department_id
JOIN hr.locations l ON l.location_id = d.location_id
JOIN hr.countries c ON c.country_id = l.country_id
JOIN hr.regions r ON r.region_id = c.region_id
WHERE r.region_name = :REGION_NAME
GROUP BY c.country_name
")?;
let rows = stmt.query(®ion)?;
while let Some(row) = rows.next()? {
let country_name : &str = row.get(0)?;
let median_salary : u16 = row.get(1)?;
println!("{:25}: {:>5}", country_name, median_salary);
}
Ok(())
}
Build and Run
cargo build
And then run it:
DBNAME=localhost/orcl DBUSER=sibyl DBPASS=Or4cl3 REGION=Americas cargo run
⚠ Note that depending on the Oracle client installed you might also need to adjust
PATH
on Windows or specifyLD_LIBRARY_PATH
on Linux:
LD_LIBRARY_PATH=/opt/instantclient_19_24 DBNAME=localhost/orcl DBUSER=sibyl DBPASS=Or4cl3 REGION=Americas cargo run
Expected Output
Canada : 9500
United States of America : 3250
Environment
An OCI environment handle must be created before any other OCI function can be called. While there can be many environments - for example, they might be configured to have different languages and territories - usually one is sufficient. Sibyl initializes it to be the most compatible with Rust requirements - thread-safe using UTF8 (AL32UTF8) character encoding. That single environment handle can be created in main
and then passed around:
fn main() {
let oracle = sibyl::env().expect("Oracle OCI environment");
// ...
}
Note however that some functions will need a direct reference to this handle, so instead of passing it around some applications might prefer to create it statically:
use sibyl::Environment;
use lazy_static::lazy_static;
lazy_static!{
pub static ref ORACLE : Environment = sibyl::env().expect("Oracle OCI environment");
}
Then later one would be able to create, for example, a current timestamp as:
use sibyl::TimestampTZ;
let current_timestamp = TimestampTZ::from_systimestamp(&ORACLE)?;
Connections
Depending on the application and its needs there are several ways to obtain a database sesssion:
- An application might use
Environment::connect
method to connect to a database and start a new user session. This is the most relevant way to get session for a single threaded application. Though, multithreaded applications might, in some cases, do the same. - A multithreaded or a multitasking (async) application might create a session pool and then make each thread (or task) "borrow" a session from that pool for limited time. The caveat here is that those sessions are indistinguishable and thus must be "stateless".
- A blocking mode multithreaded application might create a connection pool and make each thread establish their own sessions that would use pooled connections when they need to communicate with the database. As these sessions are not shared, they can be "stateful".
Connection Per Thread
fn main() -> sibyl::Result<()> {
let oracle = sibyl::env()?;
let dbname = std::env::var("DBNAME").expect("database name");
let dbuser = std::env::var("DBUSER").expect("user name");
let dbpass = std::env::var("DBPASS").expect("password");
let session = oracle.connect(&dbname, &dbuser, &dbpass)?;
// ...
Ok(())
}
Where dbname
can be any name that is acceptable to Oracle clients - from local TNS name to EZConnect identifier to a connect descriptor.
Or with multiple threads:
use std::{env, thread, sync::Arc};
use sibyl::*;
fn main() -> Result<()> {
let oracle = sibyl::env()?;
let oracle = Arc::new(oracle);
let mut workers = Vec::new();
for _i in 0..10 {
let oracle = oracle.clone();
let handle = thread::spawn(move || -> Result<()> {
let dbname = env::var("DBNAME").expect("database name");
let dbuser = env::var("DBUSER").expect("user name");
let dbpass = env::var("DBPASS").expect("password");
let session = oracle.connect(&dbname, &dbuser, &dbpass)?;
// ...
Ok(())
}
workers.push(handle);
}
for handle in workers {
let _ = handle.join();
}
Ok(())
}
Session Pool
Session pool can be used in both blocking
and nonblocking
mode applications.
Blocking Mode Pool
use std::{env, thread, sync::Arc};
use once_cell::sync::OnceCell;
use sibyl::*;
fn main() -> sibyl::Result<()> {
static ORACLE : OnceCell<Environment> = OnceCell::new();
let oracle = ORACLE.get_or_try_init(|| {
Environment::new()
})?;
let dbname = env::var("DBNAME").expect("database name");
let dbuser = env::var("DBUSER").expect("user name");
let dbpass = env::var("DBPASS").expect("password");
// All sessions will be authenticated with the provided user name and password.
let pool = oracle.create_session_pool(&dbname, &dbuser, &dbpass, 0, 1, 10)?;
// This pool has 0 available session at this time, will create 1 session at a
// time when they are needed (session is requested and there are no available
// sessions in the pool), up to the maximum of 10 sessions.
let pool = Arc::new(pool);
let mut workers = Vec::new();
for _i in 0..100 {
let pool = pool.clone();
let handle = thread::spawn(move || -> Result<()> {
let session = pool.get_session()?;
// ...
Ok(())
}
workers.push(handle);
}
for handle in workers {
let _ = handle.join();
}
Ok(())
}
Nonblocking Mode Pool
use std::{env, thread, sync::Arc};
use once_cell::sync::OnceCell;
use sibyl::*;
#[tokio::main]
async fn main() -> sibyl::Result<()> {
static ORACLE : OnceCell<Environment> = OnceCell::new();
let oracle = ORACLE.get_or_try_init(|| {
Environment::new()
})?;
let dbname = env::var("DBNAME").expect("database name");
let dbuser = env::var("DBUSER").expect("user name");
let dbpass = env::var("DBPASS").expect("password");
let pool = oracle.create_session_pool(&dbname, &dbuser, &dbpass, 0, 1, 10).await?;
let pool = Arc::new(pool);
let mut workers = Vec::new();
for _i in 0..100 {
let pool = pool.clone();
let handle = tokio::task::spawn(async move {
let session = pool.get_session().await?;
// ...
Ok::<_,Error>(())
}
workers.push(handle);
}
for handle in workers {
let _ = handle.await;
}
Ok(())
}
Connection Pool
Connection pool can only be used in blocking
mode applications.
use std::{env, thread, sync::Arc};
use once_cell::sync::OnceCell;
use sibyl::*;
fn main() -> sibyl::Result<()> {
static ORACLE : OnceCell<Environment> = OnceCell::new();
let oracle = ORACLE.get_or_try_init(|| {
Environment::new()
})?;
let dbname = env::var("DBNAME").expect("database name");
let dbuser = env::var("DBUSER").expect("user name");
let dbpass = env::var("DBPASS").expect("password");
// Connection pool needs to establish an internal session with the database.
// `dbuser` and `dbpass` here are for that session.
let pool = oracle.create_connection_pool(&dbname, &dbuser, &dbpass, 0, 1, 4)?;
// This pool has 0 available connections at this time, will create 1 connection
// at a time when they are needed (session needs a connection to run and there
// are no available connections in the pool), up to the maximum of 4 connections.
let pool = Arc::new(pool);
let mut workers = Vec::new();
for i in 0..10 {
let pool = pool.clone();
let handle = thread::spawn(move || -> Result<()> {
let dbuser = env::var(format!("DBUSER{}",i)).expect("user name");
let dbpass = env::var(format!("DBPASS{}",i)).expect("password");
// Here `dbuser` and `dbpass` are used to create a new database session.
// While these sessions share pooled connections, they are entirely
// "owned" by the threads that created them and as such might use
// different users for authentication.
let session = pool.get_session(&dbuser, &dbpass)?;
// ...
Ok(())
}
workers.push(handle);
}
for handle in workers {
let _ = handle.join();
}
Ok(())
}
SQL Statement Execution
All SQL or PL/SQL statements must be prepared before they can be executed:
let stmt = session.prepare("
SELECT employee_id, last_name, first_name
FROM hr.employees
WHERE manager_id = :id
ORDER BY employee_id
")?;
A prepared statement can be executed either with the query
, query_single
or execute
methods:
query
is used forSELECT
statements. In fact, Sibyl will complain if you try toquery
any other statement.query_single
is a variant ofquery
that returns a single row. It's a convenience method that allows skipping boilerplate of extracting only one row from a result set when it is known upfront that only one row (or none) is expected.execute
is used for all other, non-SELECT, DML and DDL.
execute
returns the number of rows affected by the statement.
query
returns what is colloquially called a "streaming iterator" which is typically iterated using while
. For example (continuing the SELECT example from above):
let mut employees = HashMap::new();
let stmt = session.prepare("
SELECT employee_id, last_name, first_name
FROM hr.employees
WHERE manager_id = :id
ORDER BY employee_id
")?;
let rows = stmt.query(103)?;
while let Some( row ) = rows.next()? {
let employee_id : u32 = row.get(0)?;
let last_name : &str = row.get(1)?;
let first_name : Option<&str> = row.get(2)?;
let name = first_name.map_or(last_name.to_string(),
|first_name| format!("{}, {}", last_name, first_name)
);
employees.insert(employee_id, name);
}
There are a few notable points of interest in the last example:
- Sibyl uses 0-based column indexing in a projection.
LAST_NAME
andFIRST_NAME
are retrieved as&str
. This is fast as they borrow directly from the respective column buffers. However, those values will only be valid during the lifetime of the row. If the value needs to continue to exist beyond the lifetime of a row, it should be retrieved as aString
.
Note that while Sibyl expects 0-based indexes to reference projection columns, it also accepts column names. Thus, the row processing loop of the previous example can be written as:
while let Some( row ) = rows.next()? {
let employee_id : u32 = row.get("EMPLOYEE_ID")?;
let last_name : &str = row.get("LAST_NAME")?;
let first_name : Option<&str> = row.get("FIRST_NAME")?;
let name = first_name.map_or(last_name.to_string(),
|first_name| format!("{}, {}", last_name, first_name)
);
employees.insert(employee_id, name);
}
Note that all examples use all upper case column and parameter names. This is not really necessary as Sibyl treat them as case-insensitive. However, using all upper case gives Sibyl a chance to locate a column (or a parameter placeholder) without converting the name to upper case first (to match the Oracle reported names), thus avoiding temporary string allocation and upper case conversion. Of course, you can always maintain an enum
for a select list, thus using indexes, which are the speediest way to get to the data anyway.
enum Col { EmployeeId, LastName, FirstName }
while let Some( row ) = rows.next()? {
let employee_id : u32 = row.get(Col::EmployeeId as usize)?;
let last_name : &str = row.get(Col::LastName as usize)?;
let first_name : Option<&str> = row.get(Col::FirstName as usize)?;
// ...
}
Or to be extra fancy:
#[derive(Clone,Copy)]
enum Col { EmployeeId, LastName, FirstName }
impl sibyl::Position for Col {
fn index(&self) -> Option<usize> { Some(*self as _) }
}
impl std::fmt::Display for Col {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
static COLS : [&str;3] = ["EMPLOYEE_ID", "LAST_NAME", "FIRST_NAME"];
let i = *self as usize;
f.write_str(COLS[i])
}
}
while let Some( row ) = rows.next()? {
let employee_id : u32 = row.get(Col::EmployeeId)?;
let last_name : &str = row.get(Col::LastName)?;
let first_name : Option<&str> = row.get(Col::FirstName)?;
// ...
}
Of course, that's a lot of boilerplate, which would benefit from a derive
macro. Maybe we'll get to that eventually :-)
Statement Arguments
query
, query_single
and execute
take a tuple of arguments (or a single argument). The latter can be specified as positional arguments or as name-value tuples. For example, to execute the above SELECT we can call query
using a positional argument as:
let row = stmt.query_single(103)?;
or bind a value to :id
by name as:
let row = stmt.query_single((":ID", 103))?;
The leading colon in name part of the name-value argument tuple is optional. Depending on your preferences and/or tooling you might specify parameter placeholder name to bind argument value to without a colon:
let row = stmt.query_single(("ID", 103))?;
Note one caveat - until [min_specialization][1] is stabilized Sibyl has no way of distinguishing whether a 2-item tuple is used to pass a single named argument or 2 positional arguments. For the moment you must use a 3-item tuple with a unit type as the last item when you are passing 2 positional arguments. The unit type is skipped, so effectively only first 2 arguments are used. For example:
let stmt = session.prepare("
SELECT department_id, manager_id
FROM hr.departments
WHERE department_name = :DEPARTMENT_NAME
AND location_id = :LOCATION_ID
")?;
let rows = stmt.query(( "Administration", 1700, () ))?;
In most cases which binding style to use is a matter of convenience and/or personal preferences. However, in some cases named arguments would be preferable and less ambiguous. For example, statement might change during development and thus force the change in argument positions. Also SQL and PL/SQL statements have different interpretation of a parameter position. SQL statements create positions for every parameter but allow a single argument to be used for the primary parameter and all its duplicates. PL/SQL on the other hand creates positions for unique parameter names and this might make positioning arguments correctly a bit awkward when there is more than one "duplicate" name in a statement.
For example, the following (contrived) INSERT
would need its arguments to be bound differently depending on whether it is defined as a standalone SQL or as a (part of a) PL/SQL:
let stmt = session.prepare("
INSERT INTO hr.locations
(location_id, state_province, city, postal_code, street_address)
VALUES
(:id, :na, :na, :code, :na)
")?;
stmt.execute( (3333, "N/A", (), "00000", ()) )?;
// :NA'a first pos __^---^
// while ___________________^^____and____^^
// are its duplicate positions
The duplicate position can be skipped using ()
as in the example. However, when it is a part of PL/SQL:
let stmt = session.prepare("
BEGIN
INSERT INTO hr.locations
(location_id, state_province, city, postal_code, street_address)
VALUES
(:id, :na, :na, :code, :na);
END;
")?;
stmt.execute( ( 3333, "N/A", "00000" ) )?;
Only 3 position are possible as there are only 3 unique names.
execute
also allows execution of statements with OUT (or INOUT) parameters. For example:
let stmt = session.prepare("
INSERT INTO hr.departments
( department_id, department_name, manager_id, location_id )
VALUES ( hr.departments_seq.nextval, :department_name, :manager_id, :location_id )
RETURNING department_id
INTO :department_id
")?;
let mut department_id: u32 = 0;
let num_inserted = stmt.execute(
(
(":DEPARTMENT_NAME", "Security" ),
(":MANAGER_ID", "" ),
(":LOCATION_ID", 1700 ),
(":DEPARTMENT_ID", &mut department_id ),
)
)?;
Using Slices as Statement Arguments
Sibyl accepts a collection of values (as a slice) as a single positional or a named argument. The primary target of this feature is to make passing arguments for IN-list parameters easier. However, as Sibyl simply unrolls a slice into consecutive arguments, this feature can also be "abused" :-) to pass multiple consecutive arguments of the same type when it is convenient.
Example
let stmt = session.prepare("
SELECT first_name, last_name, department_name, hire_date
FROM hr.employees e
JOIN hr.departments d
ON d.department_id = e.department_id
WHERE d.department_name IN (:departments, :2, :3, :4, :5)
AND d.department_id IN (
SELECT department_id
FROM hr.employees
GROUP BY department_id
HAVING Count(*) >= :min_employees )
AND hire_date BETWEEN :hire_range AND :8
ORDER BY hire_date, department_name, last_name, first_name
")?;
let date_from = Date::from_string("July 1, 2006", "MONTH DD, YYYY", &session)?;
let date_thru = Date::from_string("December 31, 2006", "MONTH DD, YYYY", &session)?;
let rows = stmt.query(
(
(":DEPARTMENTS", ["Marketing", "Purchasing", "Human Resources", "Shipping", "IT"].as_slice()),
(":MIN_EMPLOYEES", 5),
(":HIRE_RANGE", [date_from, date_thru].as_slice()),
)
)?;
NULLs as Statement Arguments
There are several, sometimes complementary, ways to pass nulls into and receive them from statements.
Empty Strings Are Nulls
This is idiosyncratic Oracle's way to treat empty strings as nulls.
Example
let stmt = session.prepare("
SELECT Nvl(:VAL,'nil') FROM dual
")?;
if let Some(row) = stmt.query_single("")? {
let val: Option<&str> = row.get(0)?;
assert!(val.is_some());
assert_eq!(val.unwrap(), "nil");
}
When used as an OUT or an INOUT arguments a String
or a Varchar
does not even need to be wrapped into Option
as its length by itself indicates whether Oracle sees it as null or returned its value as null.
Example
let stmt = session.prepare("
BEGIN
IF :VAL IS NULL THEN
:VAL := 'nil';
ELSE
:VAL := NULL;
END IF;
END;
")?;
// allocate space for future output
let mut val = String::with_capacity(4);
stmt.execute(&mut val)?;
assert!(!stmt.is_null("VAL")?);
assert_eq!(val, "nil");
stmt.execute(&mut val)?;
assert!(stmt.is_null("VAL")?);
assert_eq!(val, "");
Using Option
to Represent Null
Option
allows the maximum flexibility in representing input and output null values.
Example
let stmt = session.prepare("
BEGIN
IF :VAL IS NULL THEN
:VAL := 42;
ELSE
:VAL := NULL;
END IF;
END;
")?;
let mut val : Option<i32> = None;
stmt.execute(&mut val)?;
assert_eq!(val, Some(42));
stmt.execute(&mut val)?;
assert!(val.is_none());
Unfortunately, there are a few cases, specifically with INOUT parameters, when an Option
cannot represent arguments that are null values as inputs and concrete values on output. To support these Sibyl offers Nvl
type.
Using Nvl
to Represent Null
Nvl
wraps a value that would provide a storage for an output value, but it binds it to the parameter placeholder as null.
Example
let stmt = session.prepare("
BEGIN
IF :VAL IS NULL THEN
:VAL := Utl_Raw.Cast_To_Raw('Hello, World!');
END IF;
END;
")?;
let mut buf = [0; 16];
let mut val = Nvl::new(buf.as_mut_slice());
stmt.execute(&mut val)?;
assert!(!stmt.is_null("VAL")?);
assert!(val.as_ref().is_some());
assert_eq!(
val.as_ref().unwrap(),
&[0x48u8, 0x65, 0x6C, 0x6C, 0x6F, 0x2C, 0x20, 0x57, 0x6F, 0x72, 0x6C, 0x64, 0x21, 0x00, 0x00, 0x00]
// note the "trailing" initial zeroes are not overwritten by output --------------^^^^--^^^^--^^^^
);
let output_len = stmt.len_of("VAL")?;
let output = &val.as_ref().unwrap()[0..output_len];
assert_eq!(output, &[0x48u8, 0x65, 0x6C, 0x6C, 0x6F, 0x2C, 0x20, 0x57, 0x6F, 0x72, 0x6C, 0x64, 0x21]);
Similarly INOUT Interval
must be wrapped in Nvl
if the input value is null while output is expected to be an actual interval.
Dynamic SQL
In cases when the code for an SQL statement is being constructed at runtime as a string, it might be difficult to express the SQL arguments as a tuple. The following approaches allow construction of SQL arguments dynamically with the SQL code and passing them to Sibyl for SQL execution.
Slice of Same Type Arguments
Quite often, when the SQL is built dynamically, argument values are provided out of the process. This way they most likely will arrive as strings. These strings can be collected into a vector and then passed to Sibyl as a slice of strings.
For the sake of clarity the example below does not show the actual process of building the SQL and the vector of arguments. One would have to feel in the gaps.
// Assume that this was assembled dynamically from bits and pieces
let sql = String::from("
SELECT first_name, last_name, department_name, hire_date
FROM hr.employees e
JOIN hr.departments d
ON d.department_id = e.department_id
WHERE d.department_name IN (:department_name, :dn2, :dn3, :dn4, :dn5)
AND d.department_id IN (
SELECT department_id
FROM hr.employees
GROUP BY department_id
HAVING Count(*) >= :min_employees )
AND hire_date BETWEEN To_Date(:from_date,'MONTH DD, YYYY')
AND To_Date(:thru_date,'MONTH DD, YYYY')
ORDER BY hire_date
");
// Assume that values for them arrived from elsewhere, and were collected
// while the above SQL was being constructed
let mut args = Vec::new();
args.push("Marketing".to_string());
args.push("Purchasing".to_string());
args.push("Human Resources".to_string());
args.push("Shipping".to_string());
args.push("IT".to_string());
args.push("5".to_string());
args.push("October 1, 2006".to_string());
args.push("December 31, 2006".to_string());
let stmt = session.prepare(&sql)?;
let row = stmt.query_single(args.as_slice())?.expect("single row result");
let first_name: &str = row.get(0)?;
let last_name : &str = row.get(1)?;
let dept_name : &str = row.get(2)?;
let hire_date : Date = row.get(3)?;
Note that the example above uses only IN parameters. When some of them are OUT or INOUT the slice passed to Sibyl must be mutable, i.e. extracted via
as_mut_slice()
, and the strings at appropriate positions sized, i.e. created viawith_capacity()
, to accommodate the maximum length of the expected output.
While this woks, it has a certain, albeit minor, limitation - the SQL code has to expect only arguments of the same type and apply appropriate explicit conversion where appropriate. Sometimes this makes SQL a bit cluttered and somewhat more difficult to read.
Vec
of Any Type
More strictly - a vector of any type that implements Sibyl's ToSql
trait.
let sql = String::from("
SELECT first_name, last_name, department_name, hire_date
FROM hr.employees e
JOIN hr.departments d
ON d.department_id = e.department_id
WHERE d.department_name IN (:department_name, :dn2, :dn3, :dn4, :dn5)
AND d.department_id IN (
SELECT department_id
FROM hr.employees
GROUP BY department_id
HAVING Count(*) >= :min_employees )
AND hire_date BETWEEN :from_date AND :thru_date
ORDER BY hire_date
");
// Assume that the arguments where preparsed into the appropriate types
let mut dept1 = "Marketing";
let mut dept2 = "Purchasing";
let mut dept3 = "Human Resources";
let mut dept4 = "Shipping";
let mut dept5 = "IT";
let mut num_emp = 5;
let mut date_from = Date::from_string("October 1, 2006", "MONTH DD, YYYY", &session)?;
let mut date_thru = Date::from_string("December 31, 2006", "MONTH DD, YYYY", &session)?;
let stmt = session.prepare(&sql)?;
let mut args = Vec::<&mut dyn ToSql>::new();
args.push(&mut dept1); // :department_name
args.push(&mut dept2); // :dn2
args.push(&mut dept3); // :dn3
args.push(&mut dept4); // :dn4
args.push(&mut dept5); // :dn5
args.push(&mut num_emp as &mut dyn ToSql); // :min_employees
args.push(&mut date_from); // :from_date
args.push(&mut date_thru); // :thru_date
let row = stmt.query_single(args)?.expect("single row result");
let first_name: &str = row.get(0)?;
let last_name : &str = row.get(1)?;
let dept_name : &str = row.get(2)?;
let hire_date : Date = row.get(3)?;
While this allows working with arguments that have appropriate types, it has a limitation on its own - in order to be able to accept arguments for OUT or INOUT parameters this method requires all arguments to be mutable. In a way it treats all of them as INOUT even when that is not actually needed.
Character Sets
When a database character set is anything other than AL32UTF8, UTF8, or US7ASCII, the reported by Oracle character column data size most likely will be smaller than what is required to store the retrieved text encoded as UTF-8 in Rust. To address this issue Sibyl applies a database character set to UTF-8 worst case conversion factor when it allocates memory for column buffers. By default this factor is 1, which works with AL32UTF8, UTF8, and US7ASCII.
In cases when conversion of the database character set to UTF-8 requires more bytes for each character, in the worst case, than is used by the original encoding this conversion factor should be provided to the application via environment variable ORACLE_UTF8_CONV_FACTOR
. The factor is an unsigned integer. However, the most likely values for it would be 2, 3, or 4.
Example
A Thai Ko Kai "ก" character in TH8TISASCII encoding is stored as 0xA1
. However, it is encoded as 0xE0 0xB8 0x81
in UTF-8. Thus, an application that is connected to the database that uses TH8TISASCII character set needs to use a conversion factor 3. To run such an application one would need to set ORACLE_UTF8_CONV_FACTOR
before executing it:
export ORACLE_UTF8_CONV_FACTOR=3
Oracle Data Types
Sibyl provides API to access several Oracle native data types.
VARCHAR
fn main() -> sibyl::Result<()> {
use sibyl::Varchar;
let env = sibyl::env()?;
let txt = Varchar::from("Hello, World!", &env)?;
assert_eq!(txt.as_str(), "Hello, World!");
}
Number
use sibyl::Number;
let oracle = sibyl::env()?;
let pi = Number::pi(&oracle);
let two = Number::from_int(2, &oracle);
let two_pi = pi.mul(&two)?;
let h = Number::from_string("6.62607004E-34", "9D999999999EEEE", &oracle)?;
let hbar = h.div(&two_pi)?;
assert_eq!(
hbar.to_string("TME")?,
"1.05457180013911265115394106872506677375E-34"
);
RAW
fn main() -> sibyl::Result<()> {
use sibyl::Raw;
let env = sibyl::env()?;
let raw = Raw::from_bytes(&[1u8,2,3,4,5], &env)?;
assert_eq!(raw.as_bytes(), &[1u8,2,3,4,5]);
}
Date
use sibyl::Date;
let mar28_1996 = Date::from_string("28-MAR-1996", "DD-MON-YYYY", &oracle)?;
let next_monday = mar28_1996.next_week_day("MONDAY")?;
assert_eq!(next_monday.to_string("DL")?, "Monday, April 01, 1996");
Timestamp
There are 3 types of timestamps:
Timestamp
which is equivalent to Oracle TIMESTAMP data type,TimestampTZ
- TIMESTAMP WITH TIME ZONE, andTimestampLTZ
- TIMESTAMP WITH LOCAL TIME ZONE
use sibyl::TimestampTZ;
let ts = oracle::TimestampTZ::from_string(
"July 20, 1969 8:18:04.16 pm UTC",
"MONTH DD, YYYY HH:MI:SS.FF PM TZR",
&oracle
)?;
assert_eq!(
ts.to_string("YYYY-MM-DD HH24:MI:SS.FF TZR", 3)?,
"1969-07-20 20:18:04.160 UTC"
);
Note that if you are getting
ORA-01805
when timestamp with time zone is used, then most likely your local client and the server it is connected to are using different versions of the time zone file. This stackoverflow answer should help you in setting up your local client with the correct time zone file.
Interval
There are 2 types of intervals:
IntervalYM
which is eqivalent to Oracle's INTERVAL YEAR TO MONTH,IntervalDS
- INTERVAL DAY TO SECOND
use sibyl::{ TimestampTZ, IntervalDS };
let launch = TimestampTZ::with_date_and_time(1969, 7, 16, 13, 32, 0, 0, "UTC", &oracle)?;
let landing = TimestampTZ::with_date_and_time(1969, 7, 24, 16, 50, 35, 0, "UTC", &oracle)?;
let duration : IntervalDS = landing.subtract(&launch)?;
assert_eq!(duration.to_string(1,3)?, "+8 03:18:35.000");
Row ID
Oracle ROWID can be selected and retrieved explicitly into an instance of the RowID
. However, one interesting case is SELECT FOR UPDATE queries where Oracle returns ROWIDs implicitly. Those can be retrieved using Row::rowid
method.
let stmt = session.prepare("
SELECT manager_id
FROM hr.employees
WHERE employee_id = :id
FOR UPDATE
")?;
let row = stmt.query_single(107)?;
let rowid = row.rowid()?;
let manager_id: u32 = row.get(0)?;
assert_eq!(manager_id, 103);
let stmt = session.prepare("
UPDATE hr.employees
SET manager_id = :manager_id
WHERE rowid = :row_id
")?;
let num_updated = stmt.execute((
( ":MANAGER_ID", 102 ),
( ":ROW_ID", &rowid ),
))?;
assert_eq!(num_updated, 1);
Cursor
Cursors can be returned explicitly:
let stmt = session.prepare("
BEGIN
OPEN :emp FOR
SELECT department_name, first_name, last_name, salary
FROM hr.employees e
JOIN hr.departments d
ON d.department_id = e.department_id;
END;
")?;
let mut cursor = Cursor::new(&stmt)?;
stmt.execute(&mut cursor)?;
let rows = cursor.rows()?;
// ...
Or, beginning with Oracle 12.1, implicitly:
let stmt = session.prepare("
DECLARE
emp SYS_REFCURSOR;
BEGIN
OPEN emp FOR
SELECT department_name, first_name, last_name, salary
FROM hr.employees e
JOIN hr.departments d
ON d.department_id = e.department_id;
;
DBMS_SQL.RETURN_RESULT(emp);
END;
")?;
stmt.execute(())?;
if let Some( cursor ) = stmt.next_result()? {
let rows = cursor.rows()?;
// ...
}
CLOBs, BLOBs, BFILEs
Let's assume a table was created:
CREATE TABLE lob_example (
id NUMBER GENERATED ALWAYS AS IDENTITY,
bin BLOB
);
We can then create and write data into that LOB as:
// ... create OCI environment, connect to the database, etc.
let file = BFile::new(&session)?;
file.set_file_name("MEDIA_DIR", "mousepad_comp_ad.pdf")?;
let file_len = file.len()?;
file.open_file()?;
let mut data = Vec::new();
let num_read = file.read(0, file_len, &mut data)?;
file.close_file()?;
// ... or do not close now as it will be closed
// automatically when `file` goes out of scope
// Insert new BLOB and lock its row
let stmt = session.prepare("
DECLARE
row_id ROWID;
BEGIN
INSERT INTO lob_example (bin) VALUES (Empty_Blob()) RETURNING rowid INTO row_id;
SELECT bin INTO :NEW_BLOB FROM lob_example WHERE rowid = row_id FOR UPDATE;
END;
")?;
let mut lob = BLOB::new(&session)?;
stmt.execute(&mut lob)?;
lob.open()?;
let num_bytes_written = lob.write(0, &data)?;
lob.close()?;
session.commit()?;
And then later it could be read as:
let id: usize = 1234; // assume it was retrieved from somewhere...
let stmt = session.prepare("SELECT bin FROM lob_example WHERE id = :ID")?;
let row = stmt.query_single(&id)?;
if let Some(row) = row {
if let Some(lob) = row.get(0)? {
let data = read_blob(lob)?;
// ...
}
}
// Where `read_blob` could be this:
fn read_blob(lob: BLOB<'_>) -> Result<Vec<u8>> {
let mut data = Vec::new();
let lob_len = lob.len()?;
let offset = 0;
lob.read(offset, lob_len, &mut data)?;
Ok(data)
}
Known Issues with Some Clients
12.2 client does not support OCI_ATTR_SPOOL_MAX_USE_SESSION
and thus SessionPool
's session_max_use_count
and set_session_max_use_count
will fail on it with ORA-24315: illegal attribute type
.
19.15 and all later clients do not return current schema (via Session::current_schema
) until it is explicitly set via Session::set_current_schema
. Session::current_schema
works as expected in 19.13 client.
21c clients (at least with 19.3 database) is strangely picky about names of parameter placeholders for LOB columns. For example, if a table was created with the following LOB column:
CREATE TABLE table_with_lob (
id NUMBER GENERATED ALWAYS AS IDENTITY,
txt CLOB
);
and if an SQL parameter name is the same as the LOB column name (as in this example):
let stmt = session.prepare("
INSERT INTO table_with_lob (txt) VALUES (:TXT) RETURNING id INTO :ID
")?;
Then 21c clients will fail executing this SQL with ORA-03120: two-task conversion routine: integer overflow
. Renaming the parameter placeholder resolves this:
let stmt = session.prepare("
INSERT INTO table_with_lob (txt) VALUES (:NEW_TXT) RETURNING id INTO :ID
")?;
21c clients also do not "like" some specific parameter names like :NAME
which makes it fail with the same ORA-03120
.
Note that 12.2 through 19.18 clients (as far as Sibyl's tests showed) do not exhibit this issue.
21c clients (at least when connected to the 19.3 database) cannot read LOBs piece-wize - something bad happens while it reads the very last byte of the last piece and the execution is aborted with SIGSEGV. Notably, they have no problem reading the second to last piece even if it has all the bytes but the very last one. Subsequently, an attempt to read the last one-byte piece gets aborted with memory violation.
All tested clients behave erratically in nonblocking
mode when they execute piece-wize LOB operations. Therefore, in nonblocking
mode Sibyl does not support LOBs piece-wise reading and writing.
Limitations
At this time Sibyl provides only the most commonly needed means to interface with the Oracle database. Some of the missing features are:
- Array interface for multi-row operations
- User defined data types
- PL/SQL collections and tables
- Objects
- JSON data
- LDAP and proxy authentications
- Global transactions
- High Availability
- Continuous query and publish-subscribe notifications
- Advanced queuing
- Shards
- Direct path load
Some of these features might be added in the upcoming releases if the need arises or if they are explicitly requested. Some, however, will never be implemented. The latter category includes those that are incompatible with nonblocking execution.
Testing
Some of Sibyl's tests connect to the database and expect certain objects to exist in it and certain privileges granted:
- At least the HR demo schema should be installed.
- While there is no need to install other demo schemas at least
MEDIA_DIR
should be created (see$ORACLE_HOME/demo/schema/mk_dir.sql
) and point to the directory with demo files. The latter can be found inproduct_media
in the db-sample-schemas.zip. - Some of the LOB tests need text files with the the expected content. Those can be found in
etc/media
and copied intoMEDIA_DIR
. - A test user should be created. That user needs access to the
HR
schema and to theMEDIA_DIR
directory. Seeetc/create_sandbox.sql
for an example of how it can be accomplished. - The test user needs
SELECT
access toV$SESSION
as some tests use it for validation.
GRANT SELECT ON V_$SESSION TO sibyl;
- Tests that connect to the database use environment variables -
DBNAME
,DBUSER
andDBPASS
- to identify the database, user and password respectively. These variables should be set before executingcargo test
.