fix(oracle): prove built-in function queries read-only

This commit is contained in:
Abeautifulsnow
2026-10-14 14:07:20 +08:00
committed by GitHub
parent 15bf5c5844
commit c5d4e936d5
3 changed files with 625 additions and 130 deletions
+118 -4
View File
@@ -6083,9 +6083,11 @@ fn mysql_error_is_syntax_error(error: &mysql_async::Error) -> bool {
/// manual-transaction UX (#7122 Oracle, #9018 MySQL/PostgreSQL, ... GaussDB).
/// The user-facing classification SQL is split with the same dialect-aware
/// splitter as the execution SQL and paired by count/position; any mismatch is
/// fail-closed (no markers). Oracle/OceanBase-Oracle keep the lexical
/// classifier, the PostgreSQL family (MySQL is separate) use the strict
/// `sql_risk` proof; every other dialect is unproven.
/// fail-closed (no markers). Oracle/OceanBase-Oracle reach the same AST proof as
/// the PostgreSQL family through `is_oracle_proven_read_only_statement`, which
/// keeps the Oracle-specific lexical guard rails (see `sql_risk`); MySQL and the
/// PostgreSQL family use the strict `sql_risk` proof directly; every other
/// dialect is unproven.
fn classify_manual_transaction_statements(
database_type: Option<DatabaseType>,
execution_statement_count: usize,
@@ -12168,11 +12170,26 @@ for line in sys.stdin:
assert!(classify_manual_transaction_statements(Some(DatabaseType::Mysql), 1, None).is_empty());
assert!(classify_manual_transaction_statements(Some(DatabaseType::Doris), 1, Some("SELECT 1")).is_empty());
assert!(classify_manual_transaction_statements(None, 1, Some("SELECT 1")).is_empty());
// Oracle keeps its lexical classifier and its pairing behavior.
// Oracle/OceanBase-Oracle share the AST proof and keep the pairing
// behavior; built-in functions are now provable (#11788) while the
// Oracle guard rails (dblink, sequence pseudocolumns, set operators)
// still fail the proof.
assert_eq!(
classify_manual_transaction_statements(Some(DatabaseType::Oracle), 1, Some("SELECT * FROM EMP")),
vec![true]
);
assert_eq!(
classify_manual_transaction_statements(Some(DatabaseType::Oracle), 1, Some("SELECT LENGTH(1) FROM dual")),
vec![true]
);
assert_eq!(
classify_manual_transaction_statements(
Some(DatabaseType::OceanbaseOracle),
2,
Some("SELECT COUNT(*) FROM EMP; SELECT * FROM EMP@LINK")
),
vec![true, false]
);
assert_eq!(
classify_manual_transaction_statements(Some(DatabaseType::OceanbaseOracle), 1, Some("DELETE FROM EMP")),
vec![false]
@@ -12477,6 +12494,103 @@ for line in sys.stdin:
let _ = std::fs::remove_dir_all(dir);
}
/// #11788: the Oracle manual-transaction marker must follow the user's SQL
/// through the real execution path — `execute_in_manual_transaction_with_options`
/// → statement/classification pairing → per-statement marker — because that
/// marker is what the frontend aggregates into `tab.txnPossiblyDirty` and the
/// toolbar's Commit/Rollback visibility. The fake agent echoes each statement,
/// so this needs no live database. It does NOT cover the Java driver or a real
/// Oracle server, and it does not render the toolbar; the two-way real-Oracle
/// acceptance run is still outstanding.
#[tokio::test]
async fn oracle_manual_transaction_marks_only_proven_read_only_statements() {
async fn markers(state: &AppState, txn_session_id: &str, sql: &str) -> Vec<bool> {
execute_in_manual_transaction_with_options(
state,
txn_session_id,
sql,
"ORCL",
None,
ManualTransactionExecutionOptions { classification_sql: Some(sql.to_string()), ..Default::default() },
)
.await
.expect("statement executes through the manual transaction session")
.into_iter()
.map(|result| result.manual_transaction_proven_read_only)
.collect()
}
let (state, txn_session_id, dir) = manual_transaction_test_state(DatabaseType::Oracle).await;
// Proven reads: the toolbar must stay clean, including the built-in
// functions and non-call parentheses the old blanket `(` rule rejected.
for sql in [
"SELECT LENGTH(1) FROM dual",
"SELECT COUNT(*) FROM EMP",
"SELECT UPPER(name) FROM EMP",
"SELECT NVL(name, 'x') FROM EMP",
"SELECT * FROM EMP WHERE id IN (1, 2, 3)",
"SELECT CURRENT_TIME FROM dual",
] {
assert_eq!(markers(&state, &txn_session_id, sql).await, vec![true], "expected proven: {sql}");
}
// Unproven statements must still dirty the session.
for sql in [
"SELECT my_udf(id) FROM EMP",
"SELECT DBMS_LOCK.SLEEP(1) FROM dual",
"SELECT * FROM EMP@LINK",
"SELECT seq.NEXTVAL FROM dual",
"SELECT * FROM EMP FOR UPDATE",
"UPDATE EMP SET name = 'x'",
] {
assert_eq!(markers(&state, &txn_session_id, sql).await, vec![false], "expected unproven: {sql}");
}
// A mixed script is classified per statement and paired by position, so
// one unproven statement keeps the session dirty.
assert_eq!(
markers(&state, &txn_session_id, "SELECT COUNT(*) FROM EMP; SELECT * FROM EMP@LINK").await,
vec![true, false]
);
// The marker is the frontend contract: present in JSON only when proven.
let proven = execute_in_manual_transaction_with_options(
&state,
&txn_session_id,
"SELECT UPPER(name) FROM EMP",
"ORCL",
None,
ManualTransactionExecutionOptions {
classification_sql: Some("SELECT UPPER(name) FROM EMP".to_string()),
..Default::default()
},
)
.await
.expect("proven statement executes");
assert_eq!(
serde_json::to_value(&proven[0]).unwrap()["manual_transaction_proven_read_only"],
serde_json::json!(true)
);
let unproven = execute_in_manual_transaction_with_options(
&state,
&txn_session_id,
"SELECT my_udf(id) FROM EMP",
"ORCL",
None,
ManualTransactionExecutionOptions {
classification_sql: Some("SELECT my_udf(id) FROM EMP".to_string()),
..Default::default()
},
)
.await
.expect("unproven statement executes");
assert!(serde_json::to_value(&unproven[0]).unwrap().get("manual_transaction_proven_read_only").is_none());
assert!(rollback_manual_transaction(&state, &txn_session_id).await.is_ok());
let _ = std::fs::remove_dir_all(dir);
}
#[test]
fn agent_close_query_session_params_include_session() {
let params = agent_close_query_session_params("session-1");
+144 -117
View File
@@ -847,7 +847,7 @@ pub fn has_dialect_specific_write(sql: &str, database_type: DatabaseType) -> boo
fn has_dialect_specific_write_statement(sql: &str, database_type: DatabaseType) -> bool {
if is_mysql_compatible_database(database_type) {
let (cleaned, has_executable_comment) = strip_sql_comments_and_literals_with_metadata(sql, true);
let (cleaned, has_executable_comment) = strip_sql_comments_and_literals_with_metadata(sql, true, true);
return has_executable_comment
|| contains_keyword_sequence(&cleaned, "INTO", "OUTFILE")
|| contains_keyword_sequence(&cleaned, "INTO", "DUMPFILE");
@@ -892,7 +892,7 @@ fn is_write_sql_statement(
) -> bool {
// 1. Strip comments and string literals
let (cleaned, has_mysql_executable_comment) =
strip_sql_comments_and_literals_with_metadata(sql, detect_mysql_executable_comments);
strip_sql_comments_and_literals_with_metadata(sql, detect_mysql_executable_comments, true);
// MySQL/MariaDB executable comments may contain arbitrary SQL, including
// writes that are not represented by the outer statement (for example,
// INTO OUTFILE inside a SELECT). Treat them as writes rather than
@@ -1008,7 +1008,10 @@ fn is_safe_read_pragma(upper_stripped: &str) -> bool {
false
}
fn starts_with_keyword(upper: &str, keyword: &str) -> bool {
/// True when `upper` starts with `keyword` followed by a non-alphanumeric byte
/// (or nothing). `upper` must already be ASCII-uppercased. Shared with the
/// Oracle manual-transaction proof in [`crate::sql_risk`].
pub(crate) fn starts_with_keyword(upper: &str, keyword: &str) -> bool {
upper.starts_with(keyword)
&& (upper.len() == keyword.len() || !upper.as_bytes()[keyword.len()].is_ascii_alphanumeric())
}
@@ -1054,94 +1057,31 @@ pub fn check_read_only(sql: &str, connection_name: &str, database_type: Database
Ok(())
}
/// Oracle-only, fail-closed classifier for manual-transaction UX. Returns true
/// only for an ordinary top-level read that DBX can prove harmless: a single
/// top-level `SELECT` (not `WITH`) that after Oracle-aware comment/literal
/// stripping contains no row-locking, transaction-control, DDL/DCL, `CALL`/
/// `EXEC`, PL/SQL block, `EXECUTE IMMEDIATE`, database-link, sequence
/// pseudocolumn (`NEXTVAL`/`CURRVAL`), function-invocation, or subquery
/// syntax. Any lexical ambiguity is **not** proven read-only; the classifier
/// deliberately whitelists no functions because user/package function side
/// effects cannot be proven and would create an unbounded compatibility
/// surface.
/// Oracle-only, fail-closed classifier for manual-transaction UX: true only for
/// a top-level read DBX can prove harmless.
///
/// This is the public Oracle entry point; the rule set lives in
/// [`crate::sql_risk::prove_read_only_for_database`] (see
/// `prove_oracle_read_only_statement` there) and combines the Oracle lexical
/// guard rails — no `WITH`, no row locking, no transaction control, no DDL/DCL,
/// no `CALL`/`EXEC`, no PL/SQL block, no `EXECUTE IMMEDIATE`, no database link
/// (`@`), no sequence pseudocolumn (`NEXTVAL`/`CURRVAL`), no `SELECT ... INTO` —
/// with an `OracleDialect` AST proof that every function call is on the reviewed
/// built-in whitelist.
///
/// Before #11788 the guard rails included "reject any `(`", which rejected every
/// ordinary query that used a built-in function (`SELECT LENGTH(1) FROM dual`)
/// and could not tell a call from a datatype argument
/// (`CAST(a AS VARCHAR2(10))`) or an `IN (1, 2)` list.
pub fn is_oracle_proven_read_only_statement(sql: &str) -> bool {
let cleaned = strip_sql_comments_and_literals(sql);
let trimmed = cleaned.trim_start();
if trimmed.is_empty() {
return false;
}
let upper = trimmed.to_uppercase();
// Single top-level SELECT only (WITH is excluded because data-modifying
// CTEs can hide writes in a row-returning path).
if !starts_with_keyword(&upper, "SELECT") {
return false;
}
let lower = cleaned.to_lowercase();
// Function invocation or subquery syntax: any '(' after literal stripping.
// This deliberately excludes SELECT upper(name), COUNT(*), package
// functions, and subqueries DBX cannot prove harmless.
if lower.contains('(') {
return false;
}
// Database link: '@' cannot appear in an unquoted Oracle identifier, so any
// remaining '@' after literal stripping is a remote-object reference.
if lower.contains('@') {
return false;
}
// Word-boundary forbidden keywords. These are Oracle keywords/reserved
// words, so they cannot be legitimate unquoted identifiers inside a simple
// read; a quoted occurrence is already stripped to spaces above.
const FORBIDDEN_WORDS: &[&str] = &[
// row locking
"for",
"update",
"lock",
// transaction control
"begin",
"commit",
"rollback",
"savepoint",
// DDL/DCL
"create",
"alter",
"drop",
"truncate",
"rename",
"grant",
"revoke",
"comment",
"purge",
"audit",
"noaudit",
"analyze",
"flashback",
"merge",
"insert",
"delete",
// set operators (a UNION is not a single top-level SELECT)
"union",
"minus",
"intersect",
// stored procedures / dynamic SQL / PL/SQL blocks
"call",
"exec",
"execute",
"declare",
// sequence pseudocolumns
"nextval",
"currval",
// SELECT ... INTO is PL/SQL-only in Oracle
"into",
];
!FORBIDDEN_WORDS.iter().any(|word| contains_word(&lower, word))
crate::sql_risk::prove_read_only_for_database(sql, DatabaseType::Oracle)
== crate::sql_risk::ReadProof::ProvenReadOnly
}
fn contains_word(source: &str, word: &str) -> bool {
/// Match a whole word (`word` bounded by non-identifier bytes) in a
/// pre-lowercased, comment/literal-stripped source. Shared with the Oracle
/// manual-transaction proof in [`crate::sql_risk`].
pub(crate) fn contains_word(source: &str, word: &str) -> bool {
let bytes = source.as_bytes();
let word_bytes = word.as_bytes();
if word_bytes.is_empty() || bytes.len() < word_bytes.len() {
@@ -1222,10 +1162,28 @@ pub fn strip_sql_comments(sql: &str) -> String {
}
pub fn strip_sql_comments_and_literals(sql: &str) -> String {
strip_sql_comments_and_literals_with_metadata(sql, false).0
strip_sql_comments_and_literals_with_metadata(sql, false, true).0
}
fn strip_sql_comments_and_literals_with_metadata(sql: &str, detect_mysql_executable_comments: bool) -> (String, bool) {
/// [`strip_sql_comments_and_literals`] with `#` handled as a **code character**
/// instead of a line-comment start.
///
/// `#` only starts a comment in the MySQL family; Oracle (and OceanBase-Oracle)
/// allow `#` inside unquoted identifiers, so stripping from `#` to end of line
/// hides real code from the safety scanners. `sql::SqlDialectProfile::
/// supports_hash_line_comments` models the same distinction for statement
/// splitting. Only the Oracle manual-transaction proof opts in here; every other
/// caller keeps the MySQL-conservative default, so no other dialect's
/// classification changes.
pub(crate) fn strip_sql_comments_and_literals_keeping_hash(sql: &str) -> String {
strip_sql_comments_and_literals_with_metadata(sql, false, false).0
}
fn strip_sql_comments_and_literals_with_metadata(
sql: &str,
detect_mysql_executable_comments: bool,
hash_line_comments: bool,
) -> (String, bool) {
let mut output = String::with_capacity(sql.len());
let mut chars = sql.chars().peekable();
let mut in_line_comment = false;
@@ -1323,7 +1281,10 @@ fn strip_sql_comments_and_literals_with_metadata(sql: &str, detect_mysql_executa
in_line_comment = true;
continue;
}
if ch == '#' {
// `#` starts a line comment only in the MySQL family; elsewhere it is a
// code character (Oracle allows it inside identifiers), so callers that
// pass `hash_line_comments = false` must keep the text visible.
if hash_line_comments && ch == '#' {
in_line_comment = true;
continue;
}
@@ -1535,19 +1496,24 @@ mod tests {
#[test]
fn oracle_proven_read_only_accepts_simple_top_level_selects() {
assert!(is_oracle_proven_read_only_statement("SELECT * FROM table"));
assert!(is_oracle_proven_read_only_statement("select id, name from users"));
assert!(is_oracle_proven_read_only_statement("SELECT * FROM users WHERE id = 1"));
assert!(is_oracle_proven_read_only_statement("SELECT * FROM users ORDER BY id"));
assert!(is_oracle_proven_read_only_statement("SELECT * FROM users FETCH FIRST 10 ROWS ONLY"));
assert!(is_oracle_proven_read_only_statement("SELECT 1"));
assert!(is_oracle_proven_read_only_statement("SELECT ROWNUM FROM dual"));
assert!(is_oracle_proven_read_only_statement("SELECT * FROM users"));
// Comment/literal stripping hides keywords that are not part of the SQL.
assert!(is_oracle_proven_read_only_statement("-- comment\nSELECT * FROM users"));
assert!(is_oracle_proven_read_only_statement("SELECT 'update' FROM dual"));
assert!(is_oracle_proven_read_only_statement("SELECT \"select\" FROM t"));
assert!(is_oracle_proven_read_only_statement("SELECT * FROM user_update"));
for sql in [
"SELECT * FROM users",
"select id, name from users",
"SELECT * FROM users WHERE id = 1",
"SELECT * FROM users ORDER BY id",
"SELECT * FROM users FETCH FIRST 10 ROWS ONLY",
"SELECT * FROM users ORDER BY id OFFSET 5 ROWS FETCH NEXT 10 ROWS ONLY",
"SELECT 1",
"SELECT ROWNUM FROM dual",
"SELECT * FROM users;",
// Comment/literal stripping hides keywords that are not part of the SQL.
"-- comment\nSELECT * FROM users",
"SELECT 'update' FROM dual",
"SELECT \"select\" FROM t",
"SELECT * FROM user_update",
] {
assert!(is_oracle_proven_read_only_statement(sql), "expected proven: {sql}");
}
}
#[test]
@@ -1596,28 +1562,89 @@ mod tests {
}
}
/// #11788: the old blanket "reject any `(`" rule rejected every ordinary
/// read that used a built-in function. Whitelisted calls, non-call
/// parentheses (`IN` lists, grouping, datatype arguments) and subqueries are
/// proven now; the AST walk is what tells a call from a datatype argument.
#[test]
fn oracle_proven_read_only_rejects_functions_and_subqueries() {
fn oracle_proven_read_only_accepts_whitelisted_functions_and_parenthesised_reads() {
for sql in [
"SELECT upper(name) FROM users",
"SELECT count(*) FROM users",
"SELECT pkg.fn() FROM dual",
"SELECT * FROM users WHERE id IN (SELECT id FROM orders)",
"SELECT * FROM (SELECT * FROM users)",
"SELECT LENGTH(1) FROM dual",
"SELECT COUNT(*) FROM users",
"SELECT UPPER(name) FROM users",
"SELECT NVL(name, 'x') FROM users",
"SELECT TO_CHAR(SYSDATE, 'YYYY-MM-DD') FROM dual",
"SELECT SUBSTR(name, 1, 3) || DECODE(a, 1, 'x', 'y') FROM users",
"SELECT MIN(a), MAX(a), AVG(a), SUM(a) FROM t",
"SELECT COUNT(*) FROM t GROUP BY a HAVING COUNT(*) > 1",
"SELECT * FROM t WHERE id IN (1, 2, 3)",
"SELECT (a + b) * c FROM t",
"SELECT * FROM t WHERE (a = 1 OR b = 2)",
"SELECT CAST(a AS VARCHAR2(10)) FROM t",
"SELECT CAST(a AS NUMBER(10,2)) FROM t",
"SELECT * FROM (SELECT * FROM users)",
"SELECT * FROM users WHERE id IN (SELECT id FROM orders)",
// Read-only keyword forms sqlparser models as function calls must
// stay proven, or the fix would regress them.
"SELECT CURRENT_DATE FROM dual",
"SELECT CURRENT_TIME FROM dual",
"SELECT CURRENT_TIMESTAMP FROM dual",
"SELECT SYSTIMESTAMP, USER, LEVEL FROM dual",
"SELECT LEVEL, a FROM t START WITH id = 1 CONNECT BY PRIOR id = pid",
"SELECT /*+ FULL(t) */ COUNT(*) FROM t WHERE a = :1",
] {
assert!(is_oracle_proven_read_only_statement(sql), "expected proven: {sql}");
}
}
#[test]
fn oracle_proven_read_only_rejects_unknown_functions_and_qualified_calls() {
for sql in [
// Not on the reviewed built-in whitelist.
"SELECT my_udf(a) FROM t",
"SELECT my_pkg.fn(a) FROM t",
"SELECT DBMS_LOCK.SLEEP(1) FROM dual",
"SELECT SYS_CONTEXT('USERENV', 'SESSION_USER') FROM dual",
"SELECT SYS_GUID() FROM dual",
"SELECT XMLAGG(x) FROM t",
"SELECT LISTAGG(a, ',') WITHIN GROUP (ORDER BY a) FROM t",
// Window and table functions stay Unproven by the visitor.
"SELECT ROW_NUMBER() OVER (ORDER BY a) FROM t",
"SELECT * FROM TABLE(my_func())",
] {
assert!(!is_oracle_proven_read_only_statement(sql), "expected rejected: {sql}");
}
}
#[test]
fn oracle_proven_read_only_rejects_empty_and_ambiguous_input() {
assert!(!is_oracle_proven_read_only_statement(""));
assert!(!is_oracle_proven_read_only_statement(" "));
assert!(!is_oracle_proven_read_only_statement("-- only a comment"));
assert!(!is_oracle_proven_read_only_statement("SELECT * FROM t WHERE a IN (1, 2)"));
assert!(!is_oracle_proven_read_only_statement("SELECT * FROM t WHERE b = 'x' AND c IN (3)"));
assert!(!is_oracle_proven_read_only_statement("SELECT 1 UNION SELECT 2"));
fn oracle_proven_read_only_rejects_empty_and_multi_statement_input() {
for sql in [
"",
" ",
"-- only a comment",
"SELECT 1 UNION SELECT 2",
"SELECT 1; SELECT 2",
"SELECT a FROM t; SELECT b FROM u",
// Parse failure is judged by the lexical guard rails alone. Call-shaped
// syntax stays Unproven because no AST can vouch for the call.
"SELECT * FROM t AS OF TIMESTAMP TO_TIMESTAMP('2020-01-01', 'YYYY-MM-DD')",
] {
assert!(!is_oracle_proven_read_only_statement(sql), "expected rejected: {sql}");
}
}
/// Oracle-only syntax sqlparser cannot parse keeps the verdict it had before
/// #11788 (no `(`, no `@`, no forbidden word) instead of losing the bit: the
/// pre-change classifier never parsed the statement either.
#[test]
fn oracle_proven_read_only_keeps_pre_change_verdict_for_unparseable_oracle_syntax() {
for sql in [
"SELECT * FROM t AS OF TIMESTAMP SYSTIMESTAMP - INTERVAL '1' HOUR",
"SELECT * FROM t AS OF SCN 12345",
"SELECT * FROM t VERSIONS BETWEEN TIMESTAMP SYSTIMESTAMP - INTERVAL '1' HOUR AND SYSTIMESTAMP",
] {
assert!(is_oracle_proven_read_only_statement(sql), "expected proven: {sql}");
}
}
#[test]
+363 -9
View File
@@ -6,7 +6,8 @@ use sqlparser::ast::{
Statement, TableFactor, UnaryOperator, Value, Visit, Visitor,
};
use sqlparser::dialect::{
ClickHouseDialect, DuckDbDialect, GenericDialect, MsSqlDialect, MySqlDialect, PostgreSqlDialect, SQLiteDialect,
ClickHouseDialect, DuckDbDialect, GenericDialect, MsSqlDialect, MySqlDialect, OracleDialect, PostgreSqlDialect,
SQLiteDialect,
};
use sqlparser::parser::Parser;
use sqlparser::tokenizer::{Token, Tokenizer};
@@ -1132,22 +1133,232 @@ const POSTGRES_PROOF_SAFE_FUNCTIONS: &[&str] = &[
"version",
];
/// Pure built-in functions accepted by the Oracle proof. Same fail-closed
/// contract as `MYSQL_PROOF_SAFE_FUNCTIONS`: a call to anything not listed is
/// Unproven. Only ever grows by review.
///
/// `current_date`, `current_time`, `current_timestamp`, `localtime` and
/// `localtimestamp` are listed even though Oracle writes them without
/// parentheses: sqlparser routes exactly these five keywords through
/// `parse_time_functions` (`parser/mod.rs`), so they arrive as function calls.
/// Omitting one would reject statements the previous lexical classifier still
/// proved (`SELECT CURRENT_TIME FROM dual`). `sysdate`/`systimestamp`/`rownum`/
/// `level`/`user` parse as ordinary expressions and need no entry — as do
/// `current_user`/`session_user`, which sqlparser only models as calls for
/// PostgreSQL/Generic.
///
/// Deliberately absent: package or schema-qualified calls (`dbms_*`, `utl_*`,
/// `pkg.fn`), sequence pseudocolumns, `sys_context`, `sys_guid`, `xmlagg` and
/// ordered-set aggregates (`listagg ... WITHIN GROUP`) — each stays Unproven
/// until reviewed.
const ORACLE_PROOF_SAFE_FUNCTIONS: &[&str] = &[
"abs",
"add_months",
"ascii",
"avg",
"bitand",
"ceil",
"chr",
"coalesce",
"concat",
"count",
"current_date",
"current_time",
"current_timestamp",
"decode",
"exp",
"floor",
"greatest",
"initcap",
"instr",
"last_day",
"least",
"length",
"lengthb",
"ln",
"localtime",
"localtimestamp",
"log",
"lower",
"lpad",
"ltrim",
"max",
"min",
"mod",
"months_between",
"next_day",
"nullif",
"nvl",
"nvl2",
"power",
"remainder",
"replace",
"round",
"rpad",
"rtrim",
"sign",
"sqrt",
"stddev",
"substr",
"substrb",
"sum",
"to_char",
"to_date",
"to_number",
"to_timestamp",
"translate",
"trim",
"trunc",
"upper",
"variance",
];
/// Prove that a single SQL statement is, by DBX's strict heuristic, an
/// ordinary read. Only MySQL and the PostgreSQL family (PostgreSQL, GaussDB,
/// openGauss) take part in this proof; every other database type is Unproven so
/// its manual-transaction toolbar keeps the legacy behavior. Oracle keeps its
/// own lexical classifier (`is_oracle_proven_read_only_statement`) — do not
/// reroute it here.
/// ordinary read. MySQL, the PostgreSQL family (PostgreSQL, GaussDB, openGauss)
/// and Oracle/OceanBase-Oracle take part in this proof; every other database
/// type is Unproven so its manual-transaction toolbar keeps the legacy behavior.
///
/// Oracle used to keep a purely lexical classifier in `query_execution_sql`
/// that rejected *any* `(` after comment/literal stripping, which is why
/// `SELECT LENGTH(1) FROM dual` showed commit/rollback buttons (#11788). It now
/// shares this proof's mechanism, keeping the Oracle-specific lexical guard
/// rails in `prove_oracle_read_only_statement` — see the comment there for why
/// those guards are load-bearing and cannot be replaced by the AST walk.
pub fn prove_read_only_for_database(sql: &str, database_type: DatabaseType) -> ReadProof {
match database_type {
DatabaseType::Mysql => prove_read_only_statement(sql, "mysql", MYSQL_PROOF_SAFE_FUNCTIONS),
DatabaseType::Postgres | DatabaseType::Gaussdb | DatabaseType::OpenGauss => {
prove_read_only_statement(sql, "postgres", POSTGRES_PROOF_SAFE_FUNCTIONS)
}
DatabaseType::Oracle | DatabaseType::OceanbaseOracle => prove_oracle_read_only_statement(sql),
_ => ReadProof::Unproven,
}
}
/// Oracle keywords/reserved words that make a statement unprovable. These are
/// Oracle keywords, so they cannot be legitimate unquoted identifiers inside a
/// simple read; a quoted occurrence is already stripped to spaces first.
///
/// Most entries are redundant now that the statement is parsed (a write parses
/// as a non-`Query` statement and `query_is_write_capable` sees locking clauses
/// and `SELECT ... INTO`), but they are kept verbatim: dropping one would widen
/// what DBX calls a proven read, which is a deliberate decision per keyword, not
/// a side effect of moving to the AST.
const ORACLE_PROOF_FORBIDDEN_WORDS: &[&str] = &[
// row locking
"for",
"update",
"lock",
// transaction control
"begin",
"commit",
"rollback",
"savepoint",
// DDL/DCL
"create",
"alter",
"drop",
"truncate",
"rename",
"grant",
"revoke",
"comment",
"purge",
"audit",
"noaudit",
"analyze",
"flashback",
"merge",
"insert",
"delete",
// set operators (a UNION is not a single top-level SELECT)
"union",
"minus",
"intersect",
// stored procedures / dynamic SQL / PL/SQL blocks
"call",
"exec",
"execute",
"declare",
// sequence pseudocolumns
"nextval",
"currval",
// SELECT ... INTO is PL/SQL-only in Oracle
"into",
];
/// Oracle has no keyword fallback either: the lexical guard rails below must
/// hold *and* the statement must parse as exactly one `Statement::Query` under
/// `OracleDialect`, with no disallowed function call. A statement that does not
/// parse at all is judged by the guard rails alone (see the parse-failure branch).
///
/// The guard rails are load-bearing, not belt-and-braces: sqlparser happily
/// parses `SELECT * FROM t@dblink` and `SELECT seq.NEXTVAL FROM dual` as plain
/// queries, so without them a remote read and a sequence bump would be stamped
/// as proven read-only.
///
/// The dialect is resolved here rather than through `resolve_dialect`: the
/// shared resolver also feeds `classify_sql_risk_for_database`,
/// `is_dangerous_sql_for_database` and the MCP `USE` check, and changing Oracle's
/// dialect there would move Oracle's risk tiers — out of scope for a
/// toolbar-metadata fix. `OracleDialect` (not `GenericDialect`, today's fallback)
/// is what parses `(+)` outer joins and the rest of the Oracle corpus.
fn prove_oracle_read_only_statement(sql: &str) -> ReadProof {
use crate::query_execution_sql::{
contains_word, starts_with_keyword, strip_sql_comments_and_literals_keeping_hash,
};
// Oracle's own comment rules: `#` is an identifier character, not a comment
// start (mysql-only). With the shared `#`-blind stripper,
// `SELECT a#b FROM t@dblink` lost everything after the `#` and was stamped
// proven — the AST cannot catch it, because `t@dblink` parses as an ordinary
// table reference.
let cleaned = strip_sql_comments_and_literals_keeping_hash(sql);
let trimmed = cleaned.trim_start();
if trimmed.is_empty() {
return ReadProof::Unproven;
}
// Single top-level SELECT only (WITH is excluded because data-modifying CTEs
// can hide writes in a row-returning path).
if !starts_with_keyword(&trimmed.to_uppercase(), "SELECT") {
return ReadProof::Unproven;
}
// Multi-statement text is Unproven on both paths below, so the parse-failure
// fallback cannot prove a whole script from its first statement. The count
// comes from the dialect-aware splitter, not from
// `has_extra_statement_after_semicolon`: the latter's lexer treats `#` as a
// line comment (the MySQL rule) while Oracle allows `#` inside identifiers,
// so a `#` would hide a following statement from it. The splitter models that
// per dialect (`sql::SqlDialectProfile::supports_hash_line_comments`).
if crate::sql::split_sql_statements_for_database(sql, DatabaseType::Oracle).len() != 1 {
return ReadProof::Unproven;
}
let lower = cleaned.to_lowercase();
// Database link: '@' cannot appear in an unquoted Oracle identifier, so any
// remaining '@' after literal stripping is a remote-object reference.
if lower.contains('@') {
return ReadProof::Unproven;
}
if ORACLE_PROOF_FORBIDDEN_WORDS.iter().any(|word| contains_word(&lower, word)) {
return ReadProof::Unproven;
}
let Ok(statements) = Parser::parse_sql(&OracleDialect {}, sql) else {
// Oracle-only syntax the parser cannot handle (`AS OF TIMESTAMP` /
// `AS OF SCN` flashback reads, `VERSIONS BETWEEN`) must not lose a
// capability it had before #11788. With no AST there is no function list
// to inspect, so fall back to the pre-#11788 rule: proven only while
// nothing call-shaped is visible at all. This never proves more than the
// old classifier did, and it is the only reason a statement without an
// AST can still be proven.
return if lower.contains('(') { ReadProof::Unproven } else { ReadProof::ProvenReadOnly };
};
let [statement] = statements.as_slice() else {
return ReadProof::Unproven;
};
prove_statement(statement, ORACLE_PROOF_SAFE_FUNCTIONS)
}
fn prove_read_only_statement(sql: &str, dialect: &str, allowed_functions: &[&str]) -> ReadProof {
let database_type = if dialect == "mysql" { DatabaseType::Mysql } else { DatabaseType::Postgres };
// Lexical rejections that must not depend on parser support: dialect-specific
@@ -1397,13 +1608,156 @@ mod tests {
#[test]
fn proof_only_applies_to_enabled_dialects() {
// Oracle keeps its own lexical classifier; the gate in query.rs must
// never route it through the generic proof.
assert_eq!(prove_read_only_for_database("SELECT 1", DatabaseType::Oracle), ReadProof::Unproven);
// Oracle/OceanBase-Oracle share this proof since #11788; their
// Oracle-specific guard rails live in `prove_oracle_read_only_statement`.
assert_eq!(prove_read_only_for_database("SELECT 1", DatabaseType::Oracle), ReadProof::ProvenReadOnly);
assert_eq!(
prove_read_only_for_database("SELECT LENGTH(1) FROM dual", DatabaseType::OceanbaseOracle),
ReadProof::ProvenReadOnly
);
assert_eq!(
prove_read_only_for_database("SELECT * FROM t@dblink", DatabaseType::OceanbaseOracle),
ReadProof::Unproven
);
// Family members without manual-transaction UI stay unproven in v1.
assert_eq!(prove_read_only_for_database("SELECT 1", DatabaseType::Doris), ReadProof::Unproven);
}
/// The Oracle guard rails are not belt-and-braces: each statement below
/// parses as a single ordinary `Query` under `OracleDialect`, so the AST
/// walk alone would call it ProvenReadOnly. Removing a guard rail (the `@`
/// scan, the word-boundary keyword scan) silently widens what DBX calls a
/// proven read, which is why they are asserted here rather than only through
/// the public classifier.
#[test]
fn oracle_proof_keeps_the_guard_rails_the_ast_cannot_see() {
for sql in [
// Remote objects: '@' cannot appear in an unquoted Oracle identifier.
"SELECT * FROM t@dblink",
"SELECT * FROM DUAL@dblink",
// Sequence pseudocolumns: parsed as a compound identifier, not a call.
"SELECT seq.NEXTVAL FROM dual",
"SELECT seq.CURRVAL FROM dual",
// Retained restrictions that the AST itself would accept.
"WITH cte AS (SELECT 1 AS a FROM dual) SELECT * FROM cte",
"SELECT 1 FROM dual UNION SELECT 2 FROM dual",
"SELECT 1 FROM dual MINUS SELECT 2 FROM dual",
// A write hidden behind a row-returning shape.
"SELECT * FROM t FOR UPDATE",
"SELECT COUNT(*) INTO v FROM t",
"SELECT 1; SELECT 2",
] {
assert_eq!(
prove_read_only_for_database(sql, DatabaseType::Oracle),
ReadProof::Unproven,
"expected unproven: {sql}"
);
}
}
#[test]
fn oracle_proof_accepts_reviewed_builtins_only() {
for sql in [
"SELECT LENGTH(1) FROM dual",
"SELECT COUNT(*) FROM users",
"SELECT UPPER(name) FROM users",
"SELECT NVL(name, 'x') FROM users",
"SELECT TO_CHAR(SYSDATE, 'YYYY-MM-DD') FROM dual",
"SELECT * FROM t WHERE id IN (1, 2, 3)",
"SELECT * FROM t WHERE (a = 1 OR b = 2)",
"SELECT CAST(a AS VARCHAR2(10)) FROM t",
"SELECT * FROM (SELECT * FROM users)",
// No-paren forms that must not regress: SYSDATE/ROWNUM/LEVEL parse as
// ordinary expressions, while sqlparser models these five as calls.
"SELECT SYSDATE, ROWNUM, USER FROM dual",
"SELECT CURRENT_DATE FROM dual",
"SELECT CURRENT_TIME FROM dual",
"SELECT CURRENT_TIMESTAMP FROM dual",
"SELECT LOCALTIME FROM dual",
"SELECT LOCALTIMESTAMP FROM dual",
// Precision form: sqlparser models it as a call too.
"SELECT CURRENT_TIME(3) FROM dual",
// Parser-invisible Oracle syntax keeps the pre-#11788 verdict via the
// parse-failure fallback (it contains no call-shaped syntax).
"SELECT * FROM t AS OF TIMESTAMP SYSTIMESTAMP - INTERVAL '1' HOUR",
"SELECT * FROM t VERSIONS BETWEEN TIMESTAMP SYSTIMESTAMP - INTERVAL '1' HOUR AND SYSTIMESTAMP",
] {
for database_type in [DatabaseType::Oracle, DatabaseType::OceanbaseOracle] {
assert_eq!(
prove_read_only_for_database(sql, database_type),
ReadProof::ProvenReadOnly,
"expected proven for {database_type:?}: {sql}"
);
}
}
for sql in [
"SELECT my_udf(a) FROM t",
"SELECT my_pkg.fn(a) FROM t",
"SELECT SYS_CONTEXT('USERENV', 'SESSION_USER') FROM dual",
"SELECT SYS_GUID() FROM dual",
"SELECT LISTAGG(a, ',') WITHIN GROUP (ORDER BY a) FROM t",
"SELECT ROW_NUMBER() OVER (ORDER BY a) FROM t",
"SELECT * FROM TABLE(my_func())",
] {
assert_eq!(
prove_read_only_for_database(sql, DatabaseType::Oracle),
ReadProof::Unproven,
"expected unproven: {sql}"
);
}
}
/// `#` is inside Oracle identifiers, so a statement hidden behind one must not
/// become provable. Two rails are needed: the shared stripper used to treat
/// `#` as a line comment (mysql-only rule) and hide the rest of the line, so
/// the proof strips with `strip_sql_comments_and_literals_keeping_hash`, and
/// the multi-statement rail uses the dialect-aware splitter. Without the first
/// one `SELECT a#b FROM t@dblink` was proven (the AST cannot catch a database
/// link — `t@dblink` parses as an ordinary table reference); without the
/// second, `SELECT a#b; DELETE FROM t` was proven. The pre-#11788 classifier
/// proved all of these.
#[test]
fn oracle_proof_rejects_statements_hidden_behind_a_hash_identifier() {
for sql in [
// `@` database link hidden behind `#`
"SELECT a#b FROM t@link",
"SELECT a#b FROM dual@dblink",
// multi-statement, where the dialect-aware splitter is the rail
"SELECT a#b; EXECUTE IMMEDIATE 'DROP TABLE t'",
"SELECT a#b; DELETE FROM t",
"SELECT 1 #; COMMIT",
"SELECT 'x' # y; DELETE FROM t",
"SELECT a#b WHERE 1 = 1; BEGIN NULL; END;",
"SELECT a#b; BEGIN DELETE FROM t; END;",
// the AST covers these even with the `#` text visible
"SELECT a#b FROM t WHERE x = 1 FOR UPDATE",
"SELECT a#b INTO v FROM t",
// Deliberate conservative outcome: with `#` visible, an identifier
// whose `#`-suffix spells a forbidden word no longer matches as an
// identifier boundary, so the statement is Unproven rather than
// proven. Safe direction; the statement is unusual either way.
"SELECT x#for FROM t",
] {
assert_eq!(
prove_read_only_for_database(sql, DatabaseType::Oracle),
ReadProof::Unproven,
"expected unproven: {sql}"
);
}
// Single statements with `#` in an identifier stay readable, exactly as
// the splitter sees them.
assert_eq!(
prove_read_only_for_database("SELECT a#b FROM t WHERE c = 1", DatabaseType::Oracle),
ReadProof::ProvenReadOnly
);
assert_eq!(prove_read_only_for_database("SELECT * FROM t#1", DatabaseType::Oracle), ReadProof::ProvenReadOnly);
assert_eq!(
prove_read_only_for_database("SELECT COUNT(*) FROM emp#1", DatabaseType::Oracle),
ReadProof::ProvenReadOnly
);
}
#[test]
fn classify_select_statements() {
assert_eq!(classify_sql_risk("SELECT * FROM users", "postgres").unwrap(), SqlRisk::ReadOnly);