Contents

Using Oracle db26ai from Rust with the oracle crate

Part 1: Environment setup and queries

Over the years, we have witnessed computer programs become more ambitious in their use of data, making it more useful to us. Thus, the programs of the 1950s to the 1980s were mostly focused on computing, on solving mathematical or algorithmic problems that were hard to solve manually due to the complexity of the calculations. Then, from the 80s to the 2000s, with database software that could pull its weight, the focus shifted to storing, managing, and exploiting data through analytical queries. Dealing with customers, orders, products, invoices, and any other type of structured data became accessible to any application. The data could be handled reliably, ensuring integrity, consistency, security, and availability. And performant when used properly, without worrying about low-level details.

There was no way back. Companies learned the value of holding, processing, enhancing, and exploiting data for their own benefit and for their customers. Databases were augmented with the concept of services, exposed internally and externally, and later further enhanced via mash-ups.

At the end of the 2010s, AI became something that could be used to work with both structured and unstructured data, extracting knowledge from them and providing it to answer questions, rather than queries, to its users.

In this article, I will use the oracle crate to access and modify data from Rust code. This crate was created by Kubo Takehiro in 2017 and has been maintained by him and contributors since then. It is based on ODPI-C, an open-source library that simplifies access to the Oracle Call Interface (OCI), which provides a lower-level, high-performance interface.

Having access to structured data in your Rust code is the first step to creating useful services and writing an AI application, should you want to do so. So, this is something that you want to know how to do as a Rust developer. I will try to explain the most common cases and some less common ones in this article and its second part.

I plan to make no assumptions about your development environment, so I will share how to set it up using some "real" data from the database we can use for the examples.

Let's get cracking!

Install the driver and SQLcl

An Oracle DB instance

The whole purpose of this article is to interact with an Oracle database, to query, create, update, and modify it, so we will need to have access to an instance. I recommend using a container because we will add a schema and some data. If you spin up an instance of the Oracle AI Database 26ai Free Container, it will start in a clean state, and you can discard the data after using all the code in this article. I have already explained how to run Oracle AI db26ai in a container in this other article, in case you haven't done so yet.

Required dynamic libraries

In this article, I will use the oracle crate, which is based on ODPI-C. That means that the ODPI-C dynamic libraries must be installed and available in your system. In case you don't have them yet, they are available in a package that can be downloaded in two versions: basic and basic light. They both contain all the drivers, but the latter only includes the English error messages and support for some character sets. I'll use the former, but feel free to do otherwise if you don't need the extra data.

Double-click on the dmg file that you have downloaded from the link above, or use the following command with the right version identifier.

  hdiutil mount instantclient-basic-macos.arm64-<version id>.dmg

Then, install its contents from the command line.

  cd /Volumes/instantclient-basic-macos.arm64-<version id>/
  sh ./install_ic.sh

However, the script will fail in Tahoe (macOS 26) and show a pop-up because macOS cannot assess the safety of this code. Open the System settings, and select Privacy & Security from the left pane. Scroll down and allow this script to run. Execute the script again, and it will create the directory ~/Downloads/instantclient_23_3 and copy its contents there. I moved its contents to ~/bin/oracle because I don't want the libraries I use to be in my downloads directory, and I added that directory to the dynamic linker path so they can be used by the crate.

  export DYLD_LIBRARY_PATH=~/bin/oracle

Oracle DB command-line interface

It is also convenient to have a tool to talk to our Oracle DB instance. SQL*Plus has been the traditional go-to Oracle client for a very long time, and you can download it from the same place and install it using the same process that I used for the dynamic libraries. But I'd recommend you use SQLcl instead, which is newer, easier to use, and offers additional features, like code completion, which may be useful here. I will install it using Homebrew (brew install sqlcl) to simplify the process, but you can refer to the official documentation and to Jeff's video for an intro to the tool.

Prepare the database

Now that I have our container up and running and the client and libraries installed, I'd like to connect to the database and add some data. Oracle provides sample schemas, but they aren't installed in the container by default. We will download them from this GitHub repo, uncompress the zip file and connect to the database from there.

  unzip db-sample-schemas-23.3.zip
  cd db-sample-schemas-23.3/customer_orders
  sql sys@localhost:1521 as sysdba

Set up for vector search

Once I have connected to the database with the correct password, I can use the opportunity to alter the CDB's vector pool size, as I will need it for the second part of this article.

  ALTER SYSTEM SET vector_memory_size=512M scope=spfile;

This change has been introduced to the server parameters file and requires restarting the database so it gets read and applied. In this case, I had called my container db-free, and I restart it from the command line.

  docker container restart db-free

Let's get some data

In order to run the script that creates the database, I will use SQLcl from the command line, connecting again to the database as SYS, but this time targeting the pluggable database instead of the container one.

  sql sys@localhost:1521/FREEPDB1 as sysdba @co_install.sql

When prompted, I will use "co" as the password for the "CO" user. Obviously, this is NOT a secure choice, but this is just a test in a container that will be discarded afterwards. The tablespace for CO will be USERS (default value), and I have safely assumed that the schema can be overwritten because this is a new container.

The script will create the tables, add the data, and exit from SQLcl. I want to connect again, this time with the CO user.

  sql co/co@localhost:1521/FREEPDB1

And make a query to verify that everything went well.

  SELECT product_id, product_name, unit_price FROM products WHERE product_name LIKE 'Girl''s Coat%';

Which should reply with a single product.

   PRODUCT_ID PRODUCT_NAME             UNIT_PRICE
_____________ _____________________ _____________
           15 Girl's Coat (Blue)            13.09

Feel free to familiarize yourself with the schema by using desc <table_name> or info <table_name> before moving on to writing some Rust code.

Enhance user CO

I will finish the database setup by configuring the user CO to work with vector search, because I will be using these changes in the second part of this article. I need to make these changes as SYSDBA, so we can start a new session as the SYS user and with the SYSDBA privilege.

  sql sys@localhost:1521/FREEPDB1 as sysdba

This is a good opportunity to verify that the change I made before has been applied.

  SHOW PARAMETER vector_memory_size;

Then, I issue the following commands that add some capabilities to the CO user.

  GRANT "DB_DEVELOPER_ROLE" TO "CO";
  ALTER USER "CO" DEFAULT ROLE ALL;
  ALTER USER "CO" QUOTA UNLIMITED ON USERS;
  EXIT;

That's it! Let's start coding!

First "contact" with the Oracle DB instance

Before we can tell the database what to do with the data, we need to connect to it and exchange messages. I will start by creating a new project.

  cargo new oracle-test
  cd oracle-test

I will also add the crates oracle, which we are testing, and anyhow to simplify error management.

   cargo add oracle
   cargo add anyhow

I will start by establishing a connection to the database and pinging it. Connection provides several methods for configuring its parameters. The ping is totally unnecessary because the connection has just been established, but it is an easy way to issue our first command.

  use oracle::Connection;
  fn main() -> Result<(), anyhow::Error> {
      println!("Oratest");

      let connection = Connection::connect("co", "co", "localhost:1521/FREEPDB1")?;

      connection.ping()?;
      println!("Connected to the database.");

      Ok(())
  }

This should compile and run. But if you get any "DPI Error"1, double-check your DYLD_LIBRARY_PATH before you move on.

It would be much better to send a real statement to the database and receive a reply. Let's try that.

  let row = connection.query_row("SELECT 'Hello Oracle!' FROM dual", &[])?;
  let greeting: String = row.get(0)?;
  println!("{greeting}");

I don't know about you, but this makes me feel like Amy Adams in "Arrival", when she can finally talk to the aliens. 🎉

Querying data from Rust

Direct queries

The Connection instance provides simple methods for executing queries directly. The simplest one is query(), which takes a string containing the SQL select statement and and a reference to an array with the arguments of the query, if any. Let's go step by step.

Multi-row query

I will start by using a select statement without any bind variables. The query() method, if successful, returns a ResultSet containing Row instances. ResultSet implements Iterator, so it can be used in a for expression to loop through the results or use its methods to restrict the number of items shown, among other things. Then I can get the values of each column of a Row using its get() method. It is important to specify the types for the variables that will receive the values, so the get() method knows what to do.

  const MAX_ROWS: usize = 20;

  let sql_customer_list = "SELECT * FROM co.customers";
  println!("Getting {MAX_ROWS} customers:");
  for row in connection
      .query(sql_customer_list, &[])?
      .take(MAX_ROWS)
  {
      let row = row?;
      let id: u32 = row.get(0)?;
      let email: String = row.get(1)?;
      let fullname: String = row.get(2)?;

      println!("{id}: {fullname} - {email}");
  }

Run this version, and you should get a listing with MAX_ROWS customers. Easy-peasy!

Single row query

If you have written a query that returns a single row, you can use another method of Connection. That is the same method that I used to get a greeting from the database in the previous section.

For example, if I want to retrieve the data of a product with a given PRODUCT_ID, I can use the simpler form shown below. The query, if successful, returns a single row, and this time I preferred to use the get_as() method and a tuple-destructuring assignment to access the column values. Notice that the type for the price value is an Option<f32>, because items in this column might be NULL.

  let sql_product_with_id = "SELECT product_id, product_name, unit_price \
                             FROM products \
                             WHERE product_id = :1";
  let row = connection.query_row(sql_product_with_id, &[&15])?;
  let (id, name, price) = row.get_as::<(i32, String, Option<f32>)>()?;
  println!("\nProduct 15:");
  println!("{id}: {name} - {}", price.unwrap_or(0.0));

Execute this version and notice that we get the same product as we did with SQLcl. Ain't this a wonderful world?

Reusing queries

It is quite common in an application to repeatedly perform a set of operations. This can be achieved by either running direct queries or by preparing statements and reusing them to save time and effort.

Reusing the prepared statement

The oracle crate uses the statement() method to produce a StatementBuilder, which starts with the provided SQL and can be used to tune the number of rows that can and will be fetched and to ask whether to reuse or ignore statements from the cache.

Once the statement is built, I can execute it as many times as needed using the query() method or its variants. And each time, I can use a different variant.

  {
      println!("\nAGAIN Getting {MAX_ROWS} customers:");
      let mut stmt = connection
          .statement(sql_customer_list)
          .tag("customer list")
          .build()?;
      for row in stmt.query_as::<(u32, String, String)>(&[])?.take(MAX_ROWS) {
          if let Ok((id, email, fullname)) = row {
              println!("{id}: {fullname} - {email}");
          }
      }
      println!("\nAND AGAIN Getting {MAX_ROWS} customers:");
      for row in stmt.query(&[])?.take(MAX_ROWS) {
          let row = row?;
          let (id, email, fullname) = row.get_as::<(u32, String, String)>()?;
          println!("{id}: {fullname} - {email}");
      }
  }

In the previous snippet, I have scoped both queries using curly braces to end the lifetime of the Statement, because it is only backed in the cache when it is dropped. I could have achieved the same result using stmt.close(). The other important thing is to add a tag when we prepare the statement, so it can be retrieved later from the cache. Getting the statement back is done with the same statement() method of the Connection, but in this case, we don't need to provide any SQL, as shown in the snippet below. However, passing both the SQL and the tag works for both the first time the SQL is executed statement and all the subsequent executions.

  println!("\n...AND AGAIN Getting {MAX_ROWS} customers:");
  let mut stmt = connection.statement("").tag("customer list").build()?;
  query_mr(&mut stmt, MAX_ROWS)?;

As you can see, there are several options for processing the query results, but I am going to stick with the first one I used and put it in a function to simplify reuse.

  fn query_mr(stmt: &mut Statement, n_rows: usize) -> Result<(), oracle::Error> {
    for row in stmt.query(&[])?.take(n_rows) {
        let row = row?;
        let id: u32 = row.get(0)?;
        let email: String = row.get(1)?;
        let fullname: String = row.get(2)?;

        println!("{id}: {fullname} - {email}");
    }

    Ok(())
  }

Running the code, you will get MAX_ROWS customers three times using the same statement and different query methods. Cool!

Using bind-by-name variables

Some of those interactions with the data that we repeat over and over are slightly different from one another. When I want to fetch the product with a given PRODUCT_ID, as I did in the section about the single row query, the SQL statement was the same, but I could replace the binding variable with a different value.

This is what I have to do when we want to repeat a similar interaction with different parameters. I will reuse the statement and pass the values to execute the query. But we can do better and give meaningful names to each binding variable. That way, we can provide pairs (name, value) in any order, making our query code easier to read and understand.

For example, I might want to retrieve information about the status of any orders processed by a given STORE_ID for a given CUSTOMER_ID. I could achieve this with the following snippet.

  let sql_refunded_store_customer = "SELECT order_id, order_tms \
                                     FROM co.orders \
                                     WHERE order_status = 'REFUNDED' AND store_id = :store AND customer_id = :customer";
  println!("\nCustomer orders refunded in store 1 for customer 99");
  let mut stmt = connection
      .statement(sql_refunded_store_customer)
      .tag("refunds")
      .build()?;
  for row in stmt.query_named(&[("customer", &99), ("store", &1)])? {
      let row = row?;
      let (order_id, timestamp) = row.get_as::<(u32, Timestamp)>()?;
      println!("{order_id} - {timestamp}");
  }

Run it and get a single row that matches the criteria, although there could have been more. May the data be with you!

SUMMARY

In this first article on using Oracle AI db26ai from Rust with the oracle crate, I have started by explaining how to set up an environment from scratch to test your code with an instance of the database. I have spun up the Oracle AI db26ai with development data, making the examples reproducible in your development environment.

I have also explored using actual Rust code, several ways to perform queries using the crate, extract the data from each row, and reuse your work even with different parameters. You can find the code of this article and other related ones in this repo.

In the second part of this article, I will cover using DML, DDL, privileged connections, and even vector search. Don't miss that part!

Stay curious. Hack your code. See you next time!

Footnotes


1

No, it isn't complaining about the dots per inch of your monitor. 😁