Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ All notable changes to LowQuery are documented here. The format follows [Keep a

## [Unreleased]

### Fixed
- Oracle: running a query that returns a CLOB or BLOB again in the same session could fail with ORA-01002 (fetch out of sequence) ([#17](https://github.com/LowBloat/LowQuery/issues/17)). It happened when the server parsed the query again, which on Oracle 23ai often came by the third run of a query such as `dbms_metadata.get_ddl`, and on every run after one that had failed, until reconnecting. Both causes were in the Oracle driver LowQuery ships and are fixed there; Show DDL no longer needs to bypass the statement cache.

## [0.5.1] - 2026-10-22

### Security
Expand Down
136 changes: 136 additions & 0 deletions src-tauri/src/drivers/integration_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -415,6 +415,142 @@ async fn oracle_wide_rows_with_repeated_values() {
}
}

/// Queries returning CLOBs run again and again in one session, through the
/// driver's statement cache. LOBs are fetched as text, which takes a define
/// round trip on the first run. ORA-01002 (fetch out of sequence) followed
/// when that run failed, and when the server parsed and described the query
/// again on a later run.
#[tokio::test]
#[ignore]
async fn oracle_clob_queries_run_repeatedly() {
let Some((profile, pw)) = ora_profile() else {
return;
};
let s = Session::open(&profile, pw.as_deref())
.await
.expect("connection");
let schema = profile.user.to_uppercase();
let _ = s.execute(&req("drop table lq_clob purge", 0)).await;
s.execute(&req(
"create table lq_clob (id number primary key, doc clob)",
0,
))
.await
.unwrap();
// Values longer than 4000 characters, which no VARCHAR2 define can hold.
s.execute(&req(
"insert into lq_clob
select level, to_clob(rpad('a', 4000, 'a')) || rpad('b', 4000, 'b') || level
from dual connect by level <= 3",
0,
))
.await
.unwrap();
s.execute(&req("commit", 0)).await.unwrap();

let long_doc = |id: usize| format!("{}{}{id}", "a".repeat(4000), "b".repeat(4000));
type Check = Box<dyn Fn(&[Vec<Option<String>>])>;
let queries: [(&str, Check); 4] = [
(
"select dbms_metadata.get_ddl('TABLE', 'LQ_CLOB') from dual",
Box::new(|rows| {
assert_eq!(rows.len(), 1);
let ddl = rows[0][0].as_deref().unwrap_or_default();
assert!(
ddl.contains("CREATE TABLE") && ddl.contains("LQ_CLOB"),
"{ddl}"
);
}),
),
(
"select to_clob('LowQuery ' || level) from dual connect by level <= 3",
Box::new(|rows| {
let got: Vec<_> = rows.iter().map(|r| r[0].clone()).collect();
let want: Vec<_> = (1..=3).map(|i| Some(format!("LowQuery {i}"))).collect();
assert_eq!(got, want);
}),
),
(
"select id, doc from lq_clob order by id",
Box::new(move |rows| {
assert_eq!(rows.len(), 3);
for (i, row) in rows.iter().enumerate() {
assert_eq!(row[0].as_deref(), Some((i + 1).to_string().as_str()));
assert_eq!(row[1].as_deref(), Some(long_doc(i + 1).as_str()));
}
}),
),
// The first row only: the cursor is left open with rows to fetch.
(
"select doc from lq_clob order by id",
Box::new(move |rows| {
assert_eq!(rows.len(), 1);
assert_eq!(rows[0][0].as_deref(), Some(long_doc(1).as_str()));
}),
),
];
// New statistics invalidate the cursors on LQ_CLOB, as statistics
// feedback does on its own: the next run parses the query again and the
// server describes its columns again, as CLOB.
let invalidate =
"begin dbms_stats.gather_table_stats(user, 'LQ_CLOB', no_invalidate => false); end;";
for (n, (sql, check)) in queries.iter().enumerate() {
let limit = if n == 3 { 1 } else { 100 };
for run in 1..=5 {
if run == 3 {
s.execute(&req(invalidate, 0)).await.unwrap();
}
let r = s
.execute(&req(sql, limit))
.await
.unwrap_or_else(|e| panic!("{sql}: run {run}: {e}"));
check(&r.rows);
}
}
// A query that fails while its LOB column is defined (ORA-01476 here),
// then succeeds once the data changes.
let _ = s.execute(&req("drop table lq_clob_flag purge", 0)).await;
s.execute(&req("create table lq_clob_flag (n number)", 0))
.await
.unwrap();
s.execute(&req("insert into lq_clob_flag values (0)", 0))
.await
.unwrap();
s.execute(&req("commit", 0)).await.unwrap();
let flagged = "select to_clob('n=' || (select 1 / n from lq_clob_flag)) from dual";
for run in 1..=2 {
let err = s.execute(&req(flagged, 100)).await.unwrap_err();
assert!(err.to_string().contains("ORA-01476"), "run {run}: {err}");
}
s.execute(&req("update lq_clob_flag set n = 1", 0))
.await
.unwrap();
s.execute(&req("commit", 0)).await.unwrap();
for _ in 3..=5 {
assert_eq!(one_row(&s, flagged).await[0].as_deref(), Some("n=1"));
}
s.execute(&req("drop table lq_clob_flag purge", 0))
.await
.unwrap();
// Show DDL reads DBMS_METADATA with binds, five times in a row; the
// table has no comments, so one of its queries fails every time.
for run in 1..=5 {
let ddl = s
.object_ddl(&schema, "LQ_CLOB", "table")
.await
.unwrap_or_else(|e| panic!("Show DDL run {run}: {e}"));
assert!(ddl.contains("LQ_CLOB"), "{ddl}");
}
// The session is still usable afterwards.
assert_eq!(
one_row(&s, "select count(*) from lq_clob").await[0].as_deref(),
Some("3")
);
s.execute(&req("drop table lq_clob purge", 0))
.await
.unwrap();
}

#[tokio::test]
#[ignore]
async fn oracle_connects_through_tns_alias() {
Expand Down
5 changes: 1 addition & 4 deletions src-tauri/src/drivers/oracle.rs
Original file line number Diff line number Diff line change
Expand Up @@ -580,9 +580,7 @@ impl OraSession {
}
}

/// First column of every row, as text. The statement stays out of the
/// driver's cache: on 23ai, running a cached DBMS_METADATA query a third time
/// in a session fails with ORA-01002 (fetch out of sequence).
/// First column of every row, as text.
fn first_texts(conn: &Connection, sql: &str, params: &[String]) -> AppResult<Vec<String>> {
let binds: Vec<&dyn oracledb::ToDbValue> = params
.iter()
Expand All @@ -591,7 +589,6 @@ fn first_texts(conn: &Connection, sql: &str, params: &[String]) -> AppResult<Vec
let cursor = conn
.statement(sql)
.map_err(map_error)?
.exclude_from_cache()
.build()
.map_err(map_error)?
.query(&binds)
Expand Down
28 changes: 25 additions & 3 deletions src-tauri/vendor/oracledb/LOWQUERY.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# Patched oracledb

This is `oracledb` 26.0.0-beta.4 from crates.io (Oracle's Rust thin driver,
UPL-1.0 OR Apache-2.0) with two fixes; everything else is unchanged.
UPL-1.0 OR Apache-2.0) with four fixes; everything else is unchanged.

1. `src/messages/execute.rs`: the execute response now knows the number of
columns of a statement that was already described. When a query runs again
Expand All @@ -13,7 +13,29 @@ UPL-1.0 OR Apache-2.0) with two fixes; everything else is unchanged.
2. `src/response/mod.rs`: a bit vector is cleared after the row it belongs to,
as python-oracledb does, so a later row without one is not read with stale
duplicate flags.
3. `src/client/mod.rs`, `src/statement/cache.rs`: a query with LOB columns
is executed, then sent again to define those columns (as text, or as LOB
locators). When either step failed, for example with an error raised while
fetching the first rows, the statement went back to the statement cache
still marked as needing a define. Its next execution then only sent the
define and fetched from a cursor that was never executed, failing with
ORA-01002 (fetch out of sequence), and so did every later execution of the
same SQL in the session. The cursor of such a statement is now closed and
the next execution parses it again, as python-oracledb does after an
error.
4. `src/metadata.rs`, `src/statement/mod.rs`: when a query that ran before is
parsed again by the server (statistics feedback, new statistics, other
invalidations), the server describes its columns again on the next
execution, LOB columns as CLOB/BLOB, although the cursor keeps its define
and returns them as LONG/LONG RAW along with the rows. The driver took that
description as a new LOB column and sent a define after the rows had been
fetched, failing with ORA-01002 (fetch out of sequence). On Oracle 23ai
this showed on about the third run of a cached `DBMS_METADATA` query. Like
python-oracledb's `_adjust_metadata`, a column described as CLOB/BLOB
whose previous type on the same cursor was a string/LONG (or RAW/LONG
RAW) keeps the LONG type and needs no define.

`drivers::integration_tests::oracle_wide_rows_with_repeated_values` covers
both on Oracle 21c and 23ai. Remove the `[patch.crates-io]` entry in
`Cargo.toml` and this folder once an upstream release includes the fixes.
the first two and `oracle_clob_queries_run_repeatedly` the last two, on
Oracle 21c and 23ai. Remove the `[patch.crates-io]` entry in `Cargo.toml` and this
folder once an upstream release includes the fixes.
14 changes: 12 additions & 2 deletions src-tauri/vendor/oracledb/src/client/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -743,11 +743,21 @@ impl Client {
} else {
let mut message =
ExecuteMessage::new(statement, params, parse_only);
let mut response = self.process_message(&mut message)?;
response.finalize_rows(client_ref, statement.out_metadata());
let result = self.process_message(&mut message);
// LowQuery patch: when the execution fails before or during the
// define of LOB columns, the cursor is left in an unknown state
// (not executed, or without its define); it is closed so the next
// execution parses the statement again. Otherwise that execution
// would perform a define only, then fetch from a cursor that was
// never executed (ORA-01002: fetch out of sequence).
if statement.requires_define() {
if result.is_err() {
self.statement_cache.clear_cursor(statement);
}
statement.clear_requires_define();
}
let mut response = result?;
response.finalize_rows(client_ref, statement.out_metadata());
Ok(response)
}
}
Expand Down
36 changes: 36 additions & 0 deletions src-tauri/vendor/oracledb/src/metadata.rs
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,42 @@ impl Metadata {
}
}

/// LowQuery patch, from python-oracledb's `_adjust_metadata`: when a
/// query runs again and the server describes it again (after parsing it
/// anew), a CLOB or BLOB column whose cursor already has a define as
/// string/bytes is still returned as LONG/LONG RAW, so it gets the same
/// metadata as `define_metadata` and needs no new define.
pub(crate) fn adjust_for_previous(&mut self, previous: &Metadata) {
let previous_type = previous.db_type.ora_type_num;
match self.db_type.ora_type_num {
constants::ORA_TYPE_NUM_CLOB
if matches!(
previous_type,
constants::ORA_TYPE_NUM_CHAR
| constants::ORA_TYPE_NUM_LONG
| constants::ORA_TYPE_NUM_VARCHAR
) =>
{
self.db_type = DbType::from_ora_type_and_csfrm(
constants::ORA_TYPE_NUM_LONG,
previous.db_type.csfrm,
);
self.max_size = 0;
}
constants::ORA_TYPE_NUM_BLOB
if matches!(
previous_type,
constants::ORA_TYPE_NUM_RAW
| constants::ORA_TYPE_NUM_LONG_RAW
) =>
{
self.db_type = crate::DB_TYPE_LONG_RAW;
self.max_size = 0;
}
_ => {}
}
}

/// Returns a new structure suitable for defining CLOB/BLOB as
/// string/bytes.
pub(crate) fn define_metadata(&self) -> Metadata {
Expand Down
7 changes: 7 additions & 0 deletions src-tauri/vendor/oracledb/src/statement/cache.rs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,13 @@ impl StatementCache {
!self.empty_slots.is_empty() || !self.free_used_slots.is_empty()
}

/// Forgets the database cursor of a statement and closes it on the next
/// round trip (LowQuery patch).
pub(crate) fn clear_cursor(&mut self, info: &mut CachedStatement) {
self.close_cursor(info);
info.clear_cursor();
}

/// Returns whether the statement cache has cursors that need to be closed.
pub(crate) fn has_cursors_to_close(&self) -> bool {
!self.cursors_to_close.is_empty()
Expand Down
12 changes: 10 additions & 2 deletions src-tauri/vendor/oracledb/src/statement/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -374,9 +374,17 @@ impl CachedStatement {
if num_columns > 0 {
resp.read_u8()?;
}
self.out_metadata.clear();
for _ in 0..num_columns {
// LowQuery patch: on a cursor that already ran, the columns described
// before tell whether it has a define for its LOB columns.
let mut previous = std::mem::take(&mut self.out_metadata);
if !self.has_cursor() {
previous.clear();
}
for i in 0..num_columns {
let mut metadata = Metadata::from_response(resp, client)?;
if let Some(previous) = previous.get(i as usize) {
metadata.adjust_for_previous(previous);
}
if metadata.requires_define() {
self.requires_define = true;
self.no_prefetch = true;
Expand Down
Loading