Mehdi Akiki
Published on

An Empty Oracle String Became None in Rust

Authors
  • Mehdi Akiki avatar
    Name
    Mehdi Akiki
    Twitter

Article · Through the layers

Consider a settings form that lets someone clear an optional label. The Rust request contains Some(""). After the write and read, it contains None. The screen now treats the label as never provided.

That can happen without a broken Rust decoder. Oracle currently treats a zero-length character value as null. If the application needs to distinguish “cleared” from “never supplied,” an optional text column alone is the wrong place to keep that distinction.

For this example form, start with a small check: send a null, an empty string, a space, and a name through the same connection and look at the returned optional value. Do that before adding a normalization helper that hides which case arrived.

Four inputs, three returned values

For the VARCHAR2 expression below, expect these results:

Bound inputRust value after reading
No character valueNone
Empty stringNone
One ordinary spaceSome(" ")
The text AnaSome("Ana")

The empty input and null input both return None. A space does not become null merely because a UI might consider it blank. If the product trims spaces, that is another application rule, and it needs its own examples.

Oracle's null reference documents the current zero-length character behavior and explicitly cautions against assuming it will remain unchanged in every future release. Treat this as a contract to check against the deployed database, not an eternal equivalence between every Rust string and every Oracle type.

The example pins the Rust oracle crate at 0.6.3. Each query sends a bound input to Oracle and reads the optional result. Keeping all four pairs in the loop makes it easy to see which input failed after an upgrade.

fn main() -> AnyResult<()> {
    let conn = oracle_connection()?;
    for (input, expected) in [
        (None, None),
        (Some(""), None),
        (Some(" "), Some(" ")),
        (Some("Ana"), Some("Ana")),
    ] {
        let row = conn.query_row("SELECT CAST(:1 AS VARCHAR2(20)) FROM dual", &[&input])?;
        let actual: Option<String> = row.get(0)?;
        assert_eq!(actual.as_deref(), expected);
        println!("input={input:?}, returned={actual:?}");
    }
    Ok(())
}

These are independently listed expected pairs. In particular, the null and empty inputs are separate cases even though the expected output is the same. If a driver or server change alters that result, the failing pair tells us which part of the contract changed.

The query reads an expression through dual; it does not create a table or write a user's settings. That keeps the first check focused on binding and decoding. Add a table round trip afterward if storage, triggers, or a package can change the value in the real application.

Decide what clear means before saving the form

There are at least three operations a partial update can express. Keep the existing label. Clear the existing label. Set it to a supplied value. Those operations should stay distinct until the application has decided which database write to make.

A missing request field might mean keep the old value, while an explicit null means clear it. Another API might use an operation field instead. Choose one contract and check it at the request boundary. A single optional string that has already lost the difference between missing and null cannot recover that intention later.

If the business also needs to remember that a user actively cleared a label, store that event or state separately. For example, a label state can record that it was cleared while the optional text remains null. The database should not have to infer that history from a character value that no longer contains it.

Avoid using a single space as a secret substitute for an empty string. The form may trim it, an export may preserve it, and another client may display a surprising blank. A sentinel only helps when every consumer understands the same explicit contract. An ordinary whitespace character rarely provides that agreement by itself.

Follow null through the query as well as the decoder

A predicate such as label = :1 does not become a null test when the bound value is null. Oracle evaluates that comparison as unknown, so a WHERE clause does not select the row. To find rows without a label, use label IS NULL. To find a particular label, bind it in the equality query. The null comparison rules explain the difference.

Keep those two fixed queries visible in the Rust query selection. Bind the supplied label in the equality branch instead of inserting it into the SQL text. The null branch has no label value to bind.

For reads, Option<String> says that the column can be null. It does not say why the column is null. A decoder cannot distinguish an unknown value, a deliberate clear, and a legacy empty value unless the schema preserved more information.

The same caution applies to exports. Converting every None into an empty CSV field may be the export format's intended rule. It is still a conversion. Document it where the export is built instead of changing the database adapter's result for every caller.

This example covers a short VARCHAR2 expression. It does not establish the behavior of CHAR padding, national character types, binary fields, JSON values, or empty LOBs. Pick the actual column type before applying the four-case table elsewhere.

Keep the compatibility check small enough to retain

Keep this probe when upgrading the Rust driver or Oracle Client. It has four inputs and changes no application data. Record the exact database and Client versions with its result. The shared helper requires ORACLE_EXPECT_VERSION and Oracle Client 18.1 or later because it sets a timeout for each database round trip. A different Client or server version deserves the same four checks.

If the fixture passes but the form still changes a value, follow the value through the request decoder, any trimming step, the package or trigger, and the response serializer. The probe checks the database expression, while those other steps can still trim, replace or omit the value.

For the form, write down what keep, clear, and set do to an existing label. Check each operation through the real write path and read the committed result from another connection. That gives the next developer a clearer answer than “Oracle sometimes returns None.”