Files
dbx/crates/dbx-sql-core/src/query_execution_sql.rs
T

3342 lines
136 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
use serde::{Deserialize, Serialize};
use sqlparser::dialect::{GenericDialect, MsSqlDialect, PostgreSqlDialect};
use sqlparser::tokenizer::{Token, Tokenizer};
use crate::models::connection::DatabaseType;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum ExplainFormat {
Json,
Standard,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExplainSqlOptions {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub database_type: Option<DatabaseType>,
/// MySQL supports both a structured JSON plan and the traditional tabular plan.
/// Omitted formats retain the existing JSON behavior.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub format: Option<ExplainFormat>,
/// PostgreSQL and SQL Server only: run the statement and report measured
/// rows/timings. Every other engine ignores the flag.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub analyze: Option<bool>,
pub sql: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct ExplainSqlBuildResult {
pub ok: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub sql: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub reason: Option<String>,
}
/// Payload encoding of an estimated plan produced by [`build_explain_sql`].
///
/// Callers that hand a raw plan to a consumer which cannot inspect the SQL
/// (the plugin Host API) need to know how to decode it. Keep this in sync with
/// the generated statements in [`build_explain_sql`];
/// `estimated_plan_strategy_matches_capability_sql_format_and_acquisition` guards the pairing.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum EstimatedPlanFormat {
/// A JSON document returned as a single cell (PostgreSQL, MySQL, OceanBase Oracle).
Json,
/// A `ShowPlanXML` document returned as text (SQL Server `SET SHOWPLAN_XML`).
Xml,
/// A human-readable plan listing (Dameng, Doris, QuestDB, Oracle).
Text,
}
impl EstimatedPlanFormat {
pub const fn as_str(self) -> &'static str {
match self {
Self::Json => "json",
Self::Xml => "xml",
Self::Text => "text",
}
}
}
/// The execution contract used to acquire an estimated plan.
///
/// This describes the acquisition path only; connection and driver execution
/// remain owned by `dbx-core`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum EstimatedPlanAcquisition {
/// Execute the SQL generated by `build_explain_sql`.
GeneratedSql,
/// Ask the connected driver's existing explain facility for a plan.
DriverNative,
/// Capture SHOWPLAN on a dedicated SQL Server client session.
SqlServerShowPlanSession,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EstimatedPlanSqlGeneration {
Postgres,
Mysql,
Plain,
Xugu,
Oracle,
Db2,
SqlServer,
Json,
}
/// One complete Estimated Plan contract for a database dialect.
///
/// Capability, SQL generation, payload encoding and Plugin Host acquisition
/// all derive from this mapping.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct EstimatedPlanStrategy {
sql_generation: EstimatedPlanSqlGeneration,
acquisition: EstimatedPlanAcquisition,
format: EstimatedPlanFormat,
}
impl EstimatedPlanStrategy {
pub const fn acquisition(self) -> EstimatedPlanAcquisition {
self.acquisition
}
pub const fn format(self) -> EstimatedPlanFormat {
self.format
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct DroppedFilePreviewSqlOptions {
pub path: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub limit: Option<usize>,
}
pub fn build_explain_sql(options: ExplainSqlOptions) -> ExplainSqlBuildResult {
let Some(strategy) = estimated_plan_strategy(options.database_type) else {
return explain_err("unsupported");
};
build_explain_sql_with_strategy(options, strategy)
}
/// Builds the only validated GaussDB(DWS) Estimated Plan form. This does not
/// make `DatabaseType::Gaussdb` generally supported: callers must first verify
/// the explicit DWS connection profile and supported server version.
pub fn build_gaussdb_dws_explain_sql(options: ExplainSqlOptions) -> ExplainSqlBuildResult {
if options.database_type != Some(DatabaseType::Gaussdb) {
return explain_err("unsupported");
}
if options.analyze == Some(true) {
return explain_err("unsafe");
}
build_explain_sql_with_strategy(options, gaussdb_dws_estimated_plan_strategy())
}
fn build_explain_sql_with_strategy(
options: ExplainSqlOptions,
strategy: EstimatedPlanStrategy,
) -> ExplainSqlBuildResult {
let source = strip_trailing_semicolons(options.sql.trim());
if source.is_empty() {
return explain_err("empty");
}
let safety_source =
if options.database_type == Some(DatabaseType::Db2) { options.sql.as_str() } else { source.as_str() };
if !is_safe_explain_sql_for_database(safety_source, options.database_type) {
return explain_err("unsafe");
}
if options.database_type == Some(DatabaseType::Xugu) {
// Xugu's documented SELECT forms; do not inherit PG-only TABLE/VALUES
// shortcuts or permit DML/Autotrace in the first visual-plan implementation.
let leading = strip_sql_comments(&source).trim_start().to_lowercase();
if !["select", "with"].iter().any(|keyword| starts_with_explain_keyword_boundary(&leading, keyword)) {
return explain_err("unsafe");
}
}
if options.analyze == Some(true)
&& options.database_type.is_some_and(|database_type| {
matches!(database_type, DatabaseType::Postgres | DatabaseType::SqlServer)
&& is_write_sql_for_database(&source, database_type)
})
{
return explain_err("unsafe");
}
if options.database_type == Some(DatabaseType::SqlServer) && crate::sql::split_sql_batches(&source).len() != 1 {
return explain_err("unsafe");
}
let sql = match strategy.sql_generation {
// ANALYZE executes the statement; the safety gate above has already
// limited the source to the existing read-only subset.
EstimatedPlanSqlGeneration::Postgres if options.analyze == Some(true) => {
format!("EXPLAIN (ANALYZE, FORMAT JSON) {source}")
}
EstimatedPlanSqlGeneration::Postgres => format!("EXPLAIN (FORMAT JSON) {source}"),
EstimatedPlanSqlGeneration::Plain => format!("EXPLAIN {source}"),
EstimatedPlanSqlGeneration::Xugu => format!("EXPLAIN VERBOSE {source}"),
EstimatedPlanSqlGeneration::Oracle | EstimatedPlanSqlGeneration::Db2 => format!("EXPLAIN PLAN FOR {source}"),
// STATISTICS XML returns the same ShowPlanXML document plus per-operator
// runtime counters, at the price of actually running the statement.
EstimatedPlanSqlGeneration::SqlServer if options.analyze == Some(true) => {
format!("SET STATISTICS XML ON;\nGO\n{source}\nGO\nSET STATISTICS XML OFF;")
}
EstimatedPlanSqlGeneration::SqlServer => {
format!("SET SHOWPLAN_XML ON;\nGO\n{source}\nGO\nSET SHOWPLAN_XML OFF;")
}
EstimatedPlanSqlGeneration::Mysql if options.format == Some(ExplainFormat::Standard) => {
// MySQL 8.0.32+ may otherwise inherit TREE or JSON from the session-level explain_format.
format!("EXPLAIN FORMAT=TRADITIONAL {source}")
}
EstimatedPlanSqlGeneration::Mysql | EstimatedPlanSqlGeneration::Json => {
format!("EXPLAIN FORMAT=JSON {source}")
}
};
ExplainSqlBuildResult { ok: true, sql: Some(sql), reason: None }
}
pub fn build_dropped_file_preview_sql(options: DroppedFilePreviewSqlOptions) -> Option<String> {
let lower = options.path.to_lowercase();
let escaped = options.path.replace('\'', "''");
let limit = options.limit.unwrap_or(1000).max(1);
if lower.ends_with(".parquet") {
return Some(format!("SELECT * FROM read_parquet('{escaped}') LIMIT {limit}"));
}
if lower.ends_with(".csv") {
return Some(format!("SELECT * FROM read_csv('{escaped}') LIMIT {limit}"));
}
if lower.ends_with(".tsv") {
return Some(format!("SELECT * FROM read_csv('{escaped}', delim='\\t') LIMIT {limit}"));
}
if lower.ends_with(".json") {
return Some(format!("SELECT * FROM read_json('{escaped}') LIMIT {limit}"));
}
None
}
/// The estimated-plan contract for a database, if one is currently supported.
pub fn estimated_plan_strategy(database_type: Option<DatabaseType>) -> Option<EstimatedPlanStrategy> {
use EstimatedPlanAcquisition::{DriverNative, GeneratedSql, SqlServerShowPlanSession};
use EstimatedPlanFormat::{Json, Text, Xml};
use EstimatedPlanSqlGeneration::{Db2, Mysql, Oracle, Plain, Postgres, SqlServer, Xugu};
let strategy = match database_type? {
DatabaseType::Db2 => EstimatedPlanStrategy { sql_generation: Db2, acquisition: DriverNative, format: Json },
DatabaseType::Mysql => EstimatedPlanStrategy { sql_generation: Mysql, acquisition: GeneratedSql, format: Json },
DatabaseType::Doris => EstimatedPlanStrategy { sql_generation: Plain, acquisition: GeneratedSql, format: Text },
DatabaseType::Xugu => EstimatedPlanStrategy { sql_generation: Xugu, acquisition: GeneratedSql, format: Text },
DatabaseType::Postgres => {
EstimatedPlanStrategy { sql_generation: Postgres, acquisition: GeneratedSql, format: Json }
}
// openGauss documents EXPLAIN (FORMAT TEXT|XML|JSON|YAML) but JSON output
// additionally depends on the session's explain_perf_mode; plain text avoids
// assuming that GUC, so the plan is generated as plain `EXPLAIN <statement>`
// text (#13055).
DatabaseType::OpenGauss => {
EstimatedPlanStrategy { sql_generation: Plain, acquisition: GeneratedSql, format: Text }
}
DatabaseType::Questdb => {
EstimatedPlanStrategy { sql_generation: Plain, acquisition: GeneratedSql, format: Text }
}
DatabaseType::Dameng => {
EstimatedPlanStrategy { sql_generation: Plain, acquisition: DriverNative, format: Text }
}
DatabaseType::Oracle => {
EstimatedPlanStrategy { sql_generation: Oracle, acquisition: DriverNative, format: Text }
}
DatabaseType::OceanbaseOracle => EstimatedPlanStrategy {
sql_generation: EstimatedPlanSqlGeneration::Json,
acquisition: GeneratedSql,
format: Json,
},
DatabaseType::SqlServer => {
EstimatedPlanStrategy { sql_generation: SqlServer, acquisition: SqlServerShowPlanSession, format: Xml }
}
// The former MongoDB SQL-generation arm was unreachable behind the old
// capability gate; it remains unsupported and has no Estimated Plan strategy.
_ => return None,
};
Some(strategy)
}
/// The version-gated strategy used only for an explicitly identified and
/// validated Huawei DWS connection. Generic GaussDB remains unsupported.
pub const fn gaussdb_dws_estimated_plan_strategy() -> EstimatedPlanStrategy {
EstimatedPlanStrategy {
sql_generation: EstimatedPlanSqlGeneration::Postgres,
acquisition: EstimatedPlanAcquisition::GeneratedSql,
format: EstimatedPlanFormat::Json,
}
}
/// Payload encoding defined by the estimated-plan strategy for this dialect.
/// Keep the historical JSON fallback for unsupported/unspecified dialects;
/// callers must check capability separately before using it.
pub fn estimated_plan_format(database_type: Option<DatabaseType>) -> EstimatedPlanFormat {
estimated_plan_strategy(database_type).map_or(EstimatedPlanFormat::Json, EstimatedPlanStrategy::format)
}
pub fn supports_explain_plan(database_type: Option<DatabaseType>) -> bool {
estimated_plan_strategy(database_type).is_some()
}
pub fn is_safe_explain_sql(sql: &str) -> bool {
is_safe_explain_sql_for_database(sql, None)
}
pub fn is_safe_explain_sql_for_database(sql: &str, database_type: Option<DatabaseType>) -> bool {
if database_type == Some(DatabaseType::Db2) {
return is_safe_db2_explain_sql(sql);
}
let source = strip_trailing_semicolons(sql.trim());
if source.is_empty() || has_extra_statement_after_semicolon(&source) {
return false;
}
if database_type == Some(DatabaseType::Oracle) && is_safe_oracle_explain_dml_source(&source) {
return true;
}
is_safe_explain_source(&source) && !contains_dangerous_sql_keyword(&source)
}
/// Use the existing lexer so nested DB2 comments are removed before every
/// safety check; preserve literal escaping while normalizing only for validation.
fn is_safe_db2_explain_sql(sql: &str) -> bool {
let Ok(tokens) = Tokenizer::new(&GenericDialect {}, sql).with_unescape(false).tokenize() else {
return false;
};
let mut tokens: Vec<_> = tokens.into_iter().filter(|token| !matches!(token, Token::Whitespace(_))).collect();
if tokens.last() == Some(&Token::SemiColon) {
tokens.pop();
}
if tokens.iter().any(|token| matches!(token, Token::SemiColon)) {
return false;
}
let Some(Token::Word(word)) = tokens.first() else {
return false;
};
if word.quote_style.is_some() || !["SELECT", "WITH", "VALUES"].contains(&word.value.to_ascii_uppercase().as_str()) {
return false;
}
let normalized = tokens.iter().map(ToString::to_string).collect::<Vec<_>>().join(" ");
!contains_dangerous_sql_keyword(&normalized)
}
/// Returns true for databases that support SQL query execution (execute_query / get_sample_data).
/// Non-SQL databases (Redis, MongoDB, Elasticsearch, InfluxDB, VictoriaMetrics, Neo4j, etcd) are excluded.
pub fn supports_sql_query(database_type: DatabaseType) -> bool {
!matches!(
database_type,
DatabaseType::Redis
| DatabaseType::MongoDb
| DatabaseType::Elasticsearch
| DatabaseType::Easysearch
| DatabaseType::Solr
| DatabaseType::CouchDb
| DatabaseType::Qdrant
| DatabaseType::Milvus
| DatabaseType::Weaviate
| DatabaseType::ChromaDb
| DatabaseType::InfluxDb
| DatabaseType::InfluxDb3
| DatabaseType::VictoriaMetrics
| DatabaseType::Neo4j
| DatabaseType::Nebula
| DatabaseType::Etcd
)
}
pub fn is_safe_dameng_autotrace_sql(sql: &str) -> bool {
let source = strip_trailing_semicolons(sql.trim());
if source.is_empty() || has_extra_statement_after_semicolon(&source) {
return false;
}
is_safe_explain_source(&source) && !contains_dangerous_sql_keyword(&source)
}
fn explain_err(reason: &str) -> ExplainSqlBuildResult {
ExplainSqlBuildResult { ok: false, sql: None, reason: Some(reason.to_string()) }
}
fn strip_trailing_semicolons(sql: &str) -> String {
sql.trim_end().trim_end_matches(';').trim_end().to_string()
}
fn is_safe_explain_source(sql: &str) -> bool {
let source = strip_sql_comments(sql).trim_start().to_lowercase();
["select", "with", "table", "values"].iter().any(|keyword| starts_with_explain_keyword_boundary(&source, keyword))
}
fn starts_with_explain_keyword_boundary(source: &str, keyword: &str) -> bool {
let Some(remainder) = source.strip_prefix(keyword) else {
return false;
};
match remainder.chars().next() {
None => true,
Some(character) if character.is_ascii_alphanumeric() || matches!(character, '_' | '$') => false,
// The safety gate permits ASCII whitespace and punctuation after the
// leading keyword, but fails closed for non-ASCII continuation chars.
Some(character) if character.is_ascii() => true,
Some(_) => false,
}
}
fn is_safe_oracle_explain_dml_source(sql: &str) -> bool {
let source = strip_sql_comments_and_literals(sql);
let source = source.trim_start().to_ascii_uppercase();
["INSERT", "UPDATE", "DELETE", "MERGE"].iter().any(|keyword| starts_with_keyword(&source, keyword))
}
/// Write verbs that mark a read-leading statement as a write, for example a
/// data-modifying CTE (`WITH x AS (DELETE FROM t RETURNING *) SELECT * FROM x`).
const DANGEROUS_SQL_KEYWORDS: &[&str] =
&["drop", "delete", "truncate", "alter", "update", "merge", "replace", "insert", "create"];
/// Write verbs that are also scalar function names, so `REPLACE(col, 'a', 'b')`
/// and `INSERT(str, pos, len, new)` are ordinary parts of a read-only query.
///
/// Only these two verbs are exempted when they are called with parentheses.
/// `update` stays dangerous even then: MySQL accepts a parenthesized table
/// reference (`UPDATE (t) SET c = 1`, verified on MySQL 8.4), while
/// `REPLACE (`/`INSERT (` are rejected there as statements, so a parenthesized
/// call can never hide a write for the exempted verbs.
const WRITE_VERBS_WITH_SCALAR_FUNCTION_FORMS: &[&str] = &["replace", "insert"];
pub fn contains_dangerous_sql_keyword(sql: &str) -> bool {
let source = strip_sql_comments_and_literals(sql).to_lowercase();
DANGEROUS_SQL_KEYWORDS.iter().any(|keyword| contains_dangerous_sql_verb(&source, keyword))
}
/// Match a write verb as a whole word. A verb called with parentheses is a
/// scalar function for [`WRITE_VERBS_WITH_SCALAR_FUNCTION_FORMS`] (`SELECT
/// REPLACE(name, 'a', 'b') FROM users` must stay read-only), including when the
/// dialect allows whitespace before the parenthesis.
fn contains_dangerous_sql_verb(source: &str, keyword: &str) -> bool {
let bytes = source.as_bytes();
let keyword_bytes = keyword.as_bytes();
if keyword_bytes.is_empty() || bytes.len() < keyword_bytes.len() {
return false;
}
let allows_function_form = WRITE_VERBS_WITH_SCALAR_FUNCTION_FORMS.contains(&keyword);
for idx in 0..=bytes.len() - keyword_bytes.len() {
if &bytes[idx..idx + keyword_bytes.len()] != keyword_bytes {
continue;
}
if is_identifier_byte(idx.checked_sub(1).and_then(|i| bytes.get(i)).copied()) {
continue;
}
let rest = &bytes[idx + keyword_bytes.len()..];
match rest.first().copied() {
None => return true,
Some(b'(') if allows_function_form => continue,
Some(byte) if is_identifier_byte(Some(byte)) => continue,
Some(byte) => {
if allows_function_form && byte.is_ascii_whitespace() && starts_with_parenthesis(rest) {
continue;
}
return true;
}
}
}
false
}
/// True when the first non-whitespace byte is `(` (SQL Server style `REPLACE (a, b, c)`).
fn starts_with_parenthesis(rest: &[u8]) -> bool {
let offset = rest.iter().take_while(|byte| byte.is_ascii_whitespace()).count();
rest.get(offset) == Some(&b'(')
}
/// Keywords that start a read-only SQL statement.
/// Note: FROM is a DuckDB-specific read keyword supporting SELECT-less FROM syntax
/// (e.g. `FROM table SELECT *`). In other databases, a statement starting with FROM
/// is invalid and would be rejected by the database itself, so allowing it poses no risk.
///
/// PRAGMA is intentionally NOT in this list because some PRAGMA statements modify
/// database or session state (e.g. SQLite `PRAGMA journal_mode=WAL`). Instead,
/// read-only PRAGMA forms are handled separately in `is_safe_read_pragma`.
const READ_SQL_KEYWORDS: &[&str] = &["SELECT", "WITH", "SHOW", "DESCRIBE", "DESC", "EXPLAIN", "FROM"];
/// PRAGMA names that are known to be safe read-only queries in SQLite/DuckDB.
/// Only the function-call form `PRAGMA name(args)` matching these names is allowed.
/// Any PRAGMA with assignment (`PRAGMA name = value`) or not in this list is blocked.
const SAFE_READ_PRAGMA_NAMES: &[&str] = &[
"TABLE_INFO",
"TABLE_XINFO",
"INDEX_LIST",
"INDEX_INFO",
"FOREIGN_KEY_LIST",
"DATABASE_LIST",
"COMPILE_OPTIONS",
"DATA_VERSION",
];
/// Returns true if the SQL statement is a write operation (not a pure read).
///
/// Callers that know the connection database type should use
/// [`is_write_sql_for_database`] so executable comments are interpreted using
/// the correct dialect. This untyped helper deliberately remains conservative
/// for executable comments.
pub fn is_write_sql(sql: &str) -> bool {
is_write_sql_with_database_type(sql, None)
}
/// Returns true if the SQL statement is a write operation for a database
/// dialect. In addition to ordinary write statements, this recognizes MySQL
/// executable comments and file exports, plus PostgreSQL-family/SQL Server
/// `SELECT ... INTO` table creation.
pub fn is_write_sql_for_database(sql: &str, database_type: DatabaseType) -> bool {
if database_type == DatabaseType::Neo4j {
return !crate::cypher_read_only::is_proven_read_only_cypher(sql);
}
// VictoriaMetrics execution is hard-wired to the read-only query API; MetricsQL
// does not use SQL verbs and must not be rejected by SQL write classification.
if database_type == DatabaseType::VictoriaMetrics {
return false;
}
if database_type == DatabaseType::DynamoDb {
if let Some(is_write) = classify_dynamodb_statement_write(sql) {
return is_write;
}
}
if database_type == DatabaseType::Salesforce {
// SOQL has no write verbs — every write arrives as a DBX pseudo-command —
// so the keyword scan below would misread a string literal
// (`Subject = 'Delete request'`) or a custom field (`Update__c`) as a
// write and lock a read-only session out of an ordinary SELECT.
return classify_salesforce_statement_risk(sql) != SalesforceStatementRisk::Read;
}
if let Some(risk) = classify_search_engine_query_risk(sql, database_type) {
return risk != SearchEngineQueryRisk::ReadOnly;
}
is_write_sql_with_database_type(sql, Some(database_type))
}
fn classify_dynamodb_statement_write(source: &str) -> Option<bool> {
let header = source.lines().find(|line| !line.trim().is_empty())?.trim().to_ascii_uppercase();
match header.as_str() {
"DBX DYNAMODB SCAN" | "DBX DYNAMODB QUERY / SCAN" => Some(false),
"DBX DYNAMODB INSERT ITEM" | "DBX DYNAMODB PUT ITEM" | "DBX DYNAMODB DELETE ITEM" => Some(true),
_ if header.starts_with("DBX DYNAMODB") => Some(true),
_ => None,
}
}
/// How a Salesforce statement must be treated by write and risk classification.
///
/// `SfClient::execute_query` accepts exactly two shapes: a SOQL `SELECT`, which
/// the REST query endpoint runs read-only, and a `DBX SALESFORCE <…>`
/// pseudo-command carrying a JSON body. Anything else is rejected by Salesforce
/// anyway, so it is classified as an opaque write and fails closed rather than
/// being guessed at by the SQL keyword scan.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SalesforceStatementRisk {
/// SOQL `SELECT` — always a read, whatever its literals and field names say.
Read,
/// Single-record DML naming its object and, for update/delete, its record Id.
ScopedWrite,
/// Unscoped or unknown DML, unrecognized pseudo-commands, non-SOQL text.
OpaqueWrite,
}
pub fn classify_salesforce_statement_risk(source: &str) -> SalesforceStatementRisk {
use SalesforceStatementRisk::{OpaqueWrite, Read, ScopedWrite};
let header = source.lines().find(|line| !line.trim().is_empty()).unwrap_or("").trim();
if header.to_ascii_uppercase().starts_with("DBX SALESFORCE") {
return if salesforce_dml_is_scoped(source) { ScopedWrite } else { OpaqueWrite };
}
// Comments are stripped before the verb check, but the pseudo-command branch
// above deliberately reads the raw text: a `--` inside a JSON field value
// must not be mistaken for a line comment and truncate the payload.
let first_word = strip_sql_comments(source)
.split(|character: char| !character.is_alphanumeric() && character != '_')
.find(|token| !token.is_empty())
.unwrap_or("")
.to_ascii_uppercase();
if first_word == "SELECT" {
Read
} else {
OpaqueWrite
}
}
/// Whether a `DBX SALESFORCE DML` body targets exactly one identified record:
/// an insert needs only its object, while an update or delete without an Id has
/// nothing bounding it and stays opaque.
fn salesforce_dml_is_scoped(source: &str) -> bool {
// The header line never contains a brace, so the JSON body starts at the first `{`.
let Some(start) = source.find('{') else { return false };
let Ok(body) = serde_json::from_str::<serde_json::Value>(&source[start..]) else {
return false;
};
let names_object =
body.get("object").and_then(|value| value.as_str()).is_some_and(|object| !object.trim().is_empty());
if !names_object {
return false;
}
match body.get("op").and_then(|value| value.as_str()).map(str::to_ascii_lowercase).as_deref() {
Some("insert") => true,
Some("update") | Some("delete") => {
body.get("id").and_then(|value| value.as_str()).is_some_and(|id| !id.trim().is_empty())
}
_ => false,
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SearchEngineQueryRisk {
ReadOnly,
Write,
Dangerous,
}
pub fn classify_search_engine_query_risk(source: &str, database_type: DatabaseType) -> Option<SearchEngineQueryRisk> {
if database_type == DatabaseType::CouchDb {
return classify_couchdb_query_risk(source);
}
if database_type == DatabaseType::Solr {
return classify_solr_query_risk(source);
}
if !matches!(database_type, DatabaseType::Elasticsearch | DatabaseType::Easysearch) {
return None;
}
let source = strip_leading_search_engine_comments(source);
let request_line = source.lines().next()?.trim();
let mut parts = request_line.split_whitespace();
let method = parts.next()?.to_ascii_uppercase();
let path = parts.next()?;
let path = path.split('?').next().unwrap_or(path).trim_end_matches('/');
let segments = path.split('/').filter(|segment| !segment.is_empty()).collect::<Vec<_>>();
let has_segment = |candidate: &str| segments.iter().any(|segment| segment.eq_ignore_ascii_case(candidate));
let has_document_id = |candidate: &str| {
segments
.iter()
.position(|segment| segment.eq_ignore_ascii_case(candidate))
.is_some_and(|index| segments.get(index + 1).is_some_and(|value| !value.is_empty()))
};
match method.as_str() {
"GET" | "HEAD" | "OPTIONS" => Some(SearchEngineQueryRisk::ReadOnly),
"POST"
if [
"_search",
"_count",
"_sql",
"_msearch",
"_field_caps",
"_terms_enum",
"_validate",
"_explain",
"_rank_eval",
"_search_shards",
]
.iter()
.any(|endpoint| has_segment(endpoint)) =>
{
Some(SearchEngineQueryRisk::ReadOnly)
}
"POST" if ["_doc", "_create", "_update", "_bulk"].iter().any(|endpoint| has_segment(endpoint)) => {
Some(SearchEngineQueryRisk::Write)
}
"PUT" if has_document_id("_doc") || has_document_id("_create") => Some(SearchEngineQueryRisk::Write),
"DELETE" if has_document_id("_doc") => Some(SearchEngineQueryRisk::Write),
"POST" | "PUT" | "PATCH" | "DELETE" => Some(SearchEngineQueryRisk::Dangerous),
_ => None,
}
}
fn classify_couchdb_query_risk(source: &str) -> Option<SearchEngineQueryRisk> {
let source = strip_leading_search_engine_comments(source);
let request_line = source.lines().next()?.trim();
let mut parts = request_line.split_whitespace();
let method = parts.next()?.to_ascii_uppercase();
let raw_path = parts.next()?;
let path = raw_path.split('?').next().unwrap_or(raw_path).trim_end_matches('/');
let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect();
match method.as_str() {
"GET" | "HEAD" | "OPTIONS" => Some(SearchEngineQueryRisk::ReadOnly),
"POST"
if segments.last().is_some_and(|s| {
s.eq_ignore_ascii_case("_find")
|| s.eq_ignore_ascii_case("_explain")
|| s.eq_ignore_ascii_case("_all_docs")
|| s.eq_ignore_ascii_case("_bulk_get")
}) =>
{
Some(SearchEngineQueryRisk::ReadOnly)
}
// 视图查询(POST /{db}/_design/{doc}/_view/{name} 带 keys 等参数)只读。
"POST"
if segments
.windows(3)
.any(|w| w[0].eq_ignore_ascii_case("_design") && w[2].eq_ignore_ascii_case("_view")) =>
{
Some(SearchEngineQueryRisk::ReadOnly)
}
"DELETE" if segments.len() <= 1 => Some(SearchEngineQueryRisk::Dangerous),
"DELETE" => Some(SearchEngineQueryRisk::Write),
"PUT" if segments.len() <= 1 => Some(SearchEngineQueryRisk::Dangerous),
"PUT" | "POST" => Some(SearchEngineQueryRisk::Write),
_ => None,
}
}
/// Solr REST 写请求集中在 `/{core}/update`(含 JSON/XML/CSV update handlers);
/// 只读 POST handler 名单之外的写路径(`/admin/*`、`/schema`、`/config`、
/// collection/core 管理)一律按 Dangerous 处理。
fn classify_solr_query_risk(source: &str) -> Option<SearchEngineQueryRisk> {
const READ_ONLY_POST_ENDPOINTS: &[&str] = &[
"select",
"query",
"get",
"export",
"terms",
"suggest",
"spell",
"mlt",
"sql",
"graph",
"clustering",
"tvrh",
"luke",
"elevate",
"browse",
"debug",
];
let source = strip_leading_search_engine_comments(source);
let request_line = source.lines().next()?.trim();
let mut parts = request_line.split_whitespace();
let method = parts.next()?.to_ascii_uppercase();
let raw_path = parts.next()?;
let path = raw_path.split('?').next().unwrap_or(raw_path).trim_end_matches('/');
let mut segments: Vec<&str> = path.split('/').filter(|segment| !segment.is_empty()).collect();
if segments.first().is_some_and(|segment| segment.eq_ignore_ascii_case("solr")) {
segments.remove(0);
}
let endpoint = segments.last().copied().unwrap_or("");
// update handler 的变体(/update/json、/update/json/docs、/update/csv)在
// update 段之后还有子路径,所以不能只检查末段。
let is_update_path = segments.iter().any(|segment| segment.starts_with("update") || *segment == "commit");
match method.as_str() {
// Solr 的 update handler 也响应 GET(commit/optimize/stream.body 都能
// 改数据),所以 update 路径对任何方法都算写,不能只放行 POST。
_ if is_update_path => Some(SearchEngineQueryRisk::Write),
"GET" | "HEAD" | "OPTIONS" => {
if solr_admin_request_is_mutation(raw_path, &segments)
|| solr_replication_request_is_mutation(raw_path, &segments)
{
Some(SearchEngineQueryRisk::Dangerous)
} else {
Some(SearchEngineQueryRisk::ReadOnly)
}
}
"POST" if READ_ONLY_POST_ENDPOINTS.contains(&endpoint) => Some(SearchEngineQueryRisk::ReadOnly),
"POST" | "PUT" | "PATCH" | "DELETE" => Some(SearchEngineQueryRisk::Dangerous),
_ => None,
}
}
/// CoreAdmin/Collections 管理端点接受 GET 触发的变更动作
///(`GET /admin/cores?action=CREATE&name=x` 会真实建 core),只有显式只读的
/// action 才能算 ReadOnly;其余带 action 的一律按 Dangerous 处理。
fn solr_admin_request_is_mutation(raw_path: &str, segments: &[&str]) -> bool {
let is_admin_target = segments.windows(2).any(|pair| {
pair[0].eq_ignore_ascii_case("admin")
&& matches!(pair[1].to_ascii_lowercase().as_str(), "cores" | "collections")
});
if !is_admin_target {
return false;
}
let Some(query) = raw_path.split('?').nth(1) else { return false };
let action = query.split('&').find_map(|param| {
param.split_once('=').filter(|(key, _)| key.eq_ignore_ascii_case("action")).map(|(_, value)| value)
});
match action {
None => false,
Some(action) => !matches!(action.to_ascii_uppercase().as_str(), "STATUS" | "REQUESTSTATUS" | "LIST"),
}
}
/// ReplicationHandler 也接受 GET 触发的变更
///(`GET /{core}/replication?command=disablereplication` 会真实停用复制),
/// 只有显式只读的 command 才算 ReadOnly;不带 command 时 handler 默认返回
/// 详情,按只读处理。
fn solr_replication_request_is_mutation(raw_path: &str, segments: &[&str]) -> bool {
if !segments.last().is_some_and(|segment| segment.eq_ignore_ascii_case("replication")) {
return false;
}
const READ_ONLY_COMMANDS: &[&str] = &[
"details",
"restorestatus",
"filelist",
"filecontent",
"filedownload",
"filemtime",
"indexversion",
"showversion",
];
let Some(query) = raw_path.split('?').nth(1) else {
return false;
};
let command = query.split('&').find_map(|param| {
param.split_once('=').filter(|(key, _)| key.eq_ignore_ascii_case("command")).map(|(_, value)| value)
});
match command {
None => false,
Some(command) => !READ_ONLY_COMMANDS.iter().any(|readonly| readonly.eq_ignore_ascii_case(command)),
}
}
fn strip_leading_search_engine_comments(input: &str) -> &str {
let mut rest = input;
loop {
rest = rest.trim_start();
if let Some(comment) = rest.strip_prefix('#').or_else(|| rest.strip_prefix("//")) {
rest = comment.split_once('\n').map_or("", |(_, remaining)| remaining);
continue;
}
if let Some(comment) = rest.strip_prefix("/*") {
rest = comment.split_once("*/").map_or("", |(_, remaining)| remaining);
continue;
}
return rest.trim();
}
}
fn is_write_sql_with_database_type(sql: &str, database_type: Option<DatabaseType>) -> bool {
if database_type == Some(DatabaseType::SqlServer) && is_sqlserver_plan_capture_set(sql) {
return false;
}
if database_type.is_some_and(|database_type| has_dialect_specific_write(sql, database_type)) {
return true;
}
// The untyped helper remains conservative for MySQL executable comments.
// Typed callers handle those comments in has_dialect_specific_write above.
let detect_mysql_executable_comments = database_type.is_none();
let detect_select_into = database_type.is_none();
let statements = match database_type {
Some(database_type) => crate::sql::split_sql_statements_for_database(sql, database_type),
None => crate::sql::split_sql_statements(sql),
};
let allow_mysql_checksum = database_type == Some(DatabaseType::Mysql);
statements.iter().any(|statement| {
is_write_sql_statement(statement, detect_mysql_executable_comments, detect_select_into, allow_mysql_checksum)
})
}
/// The explain flows toggle plan capture with a standalone SET statement:
/// `SHOWPLAN_XML` for the estimated plan, `STATISTICS XML` for the actual one.
fn is_sqlserver_plan_capture_set(sql: &str) -> bool {
let normalized = strip_sql_comments(sql)
.split_whitespace()
.map(|part| part.trim_end_matches(';').to_ascii_uppercase())
.collect::<Vec<_>>();
let tokens = normalized.iter().map(String::as_str).collect::<Vec<_>>();
matches!(tokens.as_slice(), ["SET", "SHOWPLAN_XML", "ON" | "OFF"] | ["SET", "STATISTICS", "XML", "ON" | "OFF"])
}
fn is_mysql_compatible_database(database_type: DatabaseType) -> bool {
matches!(
database_type,
DatabaseType::Mysql
| DatabaseType::Doris
| DatabaseType::StarRocks
| DatabaseType::ManticoreSearch
| DatabaseType::Goldendb
)
}
fn is_postgresql_family_database(database_type: DatabaseType) -> bool {
matches!(
database_type,
DatabaseType::Postgres
| DatabaseType::Redshift
| DatabaseType::Gaussdb
| DatabaseType::OpenGauss
| DatabaseType::Kingbase
| DatabaseType::Highgo
| DatabaseType::Uxdb
| DatabaseType::Vastbase
| DatabaseType::Gbase8c
| DatabaseType::Kwdb
)
}
/// Detects write-capable syntax that otherwise looks like a read query and is
/// interpreted differently depending on the database dialect.
pub fn has_dialect_specific_write(sql: &str, database_type: DatabaseType) -> bool {
let statements = crate::sql::split_sql_statements_for_database(sql, database_type);
statements.iter().any(|statement| has_dialect_specific_write_statement(statement, database_type))
}
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, true);
return has_executable_comment
|| contains_keyword_sequence(&cleaned, "INTO", "OUTFILE")
|| contains_keyword_sequence(&cleaned, "INTO", "DUMPFILE");
}
if is_postgresql_family_database(database_type) {
contains_unquoted_keyword(sql, &PostgreSqlDialect {}, "INTO")
} else {
match database_type {
DatabaseType::SqlServer => contains_unquoted_keyword(sql, &MsSqlDialect {}, "INTO"),
_ => false,
}
}
}
fn contains_keyword_sequence(sql: &str, first: &str, second: &str) -> bool {
let words = sql.split(|ch: char| !ch.is_ascii_alphanumeric() && ch != '_').filter(|word| !word.is_empty());
let mut previous_matches = false;
for word in words {
if previous_matches && word.eq_ignore_ascii_case(second) {
return true;
}
previous_matches = word.eq_ignore_ascii_case(first);
}
false
}
pub fn contains_unquoted_keyword(sql: &str, dialect: &dyn sqlparser::dialect::Dialect, keyword: &str) -> bool {
Tokenizer::new(dialect, sql).tokenize().is_ok_and(|tokens| {
tokens.into_iter().any(|token| {
matches!(token, Token::Word(word) if word.quote_style.is_none() && word.value.eq_ignore_ascii_case(keyword))
})
})
}
fn is_write_sql_statement(
sql: &str,
detect_mysql_executable_comments: bool,
detect_select_into: bool,
allow_mysql_checksum: bool,
) -> 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, 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
// attempting to parse every supported MySQL dialect extension here.
if has_mysql_executable_comment {
return true;
}
let trimmed = cleaned.trim_start();
if trimmed.is_empty() {
return false;
}
let upper = trimmed.to_uppercase();
// 2. Check if first keyword is a read keyword
let starts_with_read = READ_SQL_KEYWORDS.iter().any(|kw| {
upper.starts_with(kw) && (upper.len() == kw.len() || !upper.as_bytes()[kw.len()].is_ascii_alphanumeric())
}) || (allow_mysql_checksum && starts_with_keyword(&upper, "CHECKSUM TABLE"));
// 3. Special handling for PRAGMA: only allow safe read-only forms
if !starts_with_read && starts_with_keyword(&upper, "PRAGMA") {
return !is_safe_read_pragma(&upper);
}
// SHOW CREATE returns object metadata; CREATE is part of its read-only syntax.
if starts_with_show_create(&upper) {
return false;
}
// Untyped callers stay conservative. Typed callers handle SELECT ... INTO
// only for database families where the syntax performs a write.
if detect_select_into && starts_with_keyword(&upper, "SELECT") && select_contains_top_level_into(&upper) {
return true;
}
// A statement is a write if it doesn't start with a read keyword,
// or if it contains embedded dangerous keywords (e.g. CTE-wrapped writes like WITH ... AS (DELETE FROM ...))
!starts_with_read || contains_dangerous_sql_keyword(sql)
}
fn starts_with_show_create(upper: &str) -> bool {
let Some(after_show) = upper.strip_prefix("SHOW") else {
return false;
};
if !after_show.is_empty() && after_show.as_bytes()[0].is_ascii_alphanumeric() {
return false;
}
starts_with_keyword(after_show.trim_start(), "CREATE")
}
fn select_contains_top_level_into(upper: &str) -> bool {
let mut token = String::new();
let mut depth = 0usize;
let mut saw_select = false;
for ch in upper.chars().chain(std::iter::once(' ')) {
if ch.is_ascii_alphanumeric() || ch == '_' {
token.push(ch);
continue;
}
if !token.is_empty() {
if depth == 0 {
if token == "SELECT" {
saw_select = true;
} else if saw_select && token == "INTO" {
return true;
}
}
token.clear();
}
if ch == '(' {
depth += 1;
} else if ch == ')' {
depth = depth.saturating_sub(1);
}
}
false
}
/// Check if a PRAGMA statement is a safe read-only form.
/// Allows: PRAGMA table_info(...), PRAGMA index_list(...), etc.
/// Blocks: PRAGMA name = value, PRAGMA name(value), or unknown PRAGMA names.
fn is_safe_read_pragma(upper_stripped: &str) -> bool {
// Skip "PRAGMA" keyword to get the rest
let rest = upper_stripped.strip_prefix("PRAGMA").unwrap_or("").trim_start();
if rest.is_empty() {
return false;
}
// Extract the pragma name (first word)
let name_end = rest.find(|c: char| !c.is_ascii_alphanumeric() && c != '_').unwrap_or(rest.len());
let pragma_name = &rest[..name_end];
// Check if it's in the safe list
if !SAFE_READ_PRAGMA_NAMES.contains(&pragma_name) {
return false;
}
// Check the form after the name: must be function-call style "(...)" or end of statement
let after_name = rest[name_end..].trim_start();
if after_name.is_empty() {
// PRAGMA table_info (no args) — safe
return true;
}
if after_name.starts_with('(') {
// PRAGMA table_info(users) — safe read form
return true;
}
// PRAGMA table_info = something or other unsafe form — blocked
false
}
/// 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())
}
/// Whether one MySQL statement is an explicit transaction opener that a
/// tab-scoped auto-commit session may keep alive across executions: a bare
/// `BEGIN` / `BEGIN WORK` / `START TRANSACTION [modifiers]`.
///
/// `COMMIT` / `ROLLBACK` close a transaction instead of opening one, and
/// compound statements (`BEGIN ... END`, only valid inside stored programs)
/// never open a transaction at the top level, so neither is matched. Anything
/// ambiguous — extra statements in the same text, other `BEGIN` continuations —
/// is reported as "not an opener" so the caller falls back to the historical
/// cleanup instead of keeping a transaction open by accident.
pub fn mysql_statement_opens_explicit_transaction(sql: &str) -> bool {
let cleaned = strip_sql_comments(sql);
let mut parts = cleaned.split(';');
let statement = parts.next().unwrap_or_default().trim();
if statement.is_empty() || parts.any(|part| !part.trim().is_empty()) {
return false;
}
let upper = statement.to_ascii_uppercase();
let mut tokens = upper.split_whitespace();
match tokens.next() {
Some("BEGIN") => matches!(tokens.next(), None | Some("WORK")),
// Modifiers (`READ ONLY`, `READ WRITE`, `WITH CONSISTENT SNAPSHOT`) are
// optional and are verified by the server; a malformed one fails the
// statement, so no transaction is left open for this execution.
Some("START") => tokens.next() == Some("TRANSACTION"),
_ => false,
}
}
/// Check whether a SQL statement is allowed under read-only mode.
/// Returns Err with a descriptive message if the statement is a write operation.
pub fn check_read_only(sql: &str, connection_name: &str, database_type: DatabaseType) -> Result<(), String> {
if is_write_sql_for_database(sql, database_type) {
return Err(format!(
"Read-only mode: connection '{}' has read-only protection enabled. Write operation (including stored procedure calls) blocked.",
connection_name
));
}
Ok(())
}
/// 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 {
crate::sql_risk::prove_read_only_for_database(sql, DatabaseType::Oracle)
== crate::sql_risk::ReadProof::ProvenReadOnly
}
/// 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() {
return false;
}
for idx in 0..=bytes.len() - word_bytes.len() {
if &bytes[idx..idx + word_bytes.len()] != word_bytes {
continue;
}
let before = idx.checked_sub(1).and_then(|i| bytes.get(i)).copied();
let after = bytes.get(idx + word_bytes.len()).copied();
if !is_identifier_byte(before) && !is_identifier_byte(after) {
return true;
}
}
false
}
fn is_identifier_byte(byte: Option<u8>) -> bool {
byte.is_some_and(|b| b.is_ascii_alphanumeric() || b == b'_')
}
fn has_extra_statement_after_semicolon(sql: &str) -> bool {
let stripped = strip_sql_comments_and_literals(sql);
stripped.split(';').skip(1).any(|part| !part.trim().is_empty())
}
pub fn strip_sql_comments(sql: &str) -> String {
let mut output = String::with_capacity(sql.len());
let mut chars = sql.chars().peekable();
let mut in_line_comment = false;
let mut in_block_comment = false;
while let Some(ch) = chars.next() {
if in_line_comment {
if ch == '\n' {
in_line_comment = false;
output.push(' ');
}
continue;
}
if in_block_comment {
if ch == '*' && chars.peek() == Some(&'/') {
chars.next();
in_block_comment = false;
output.push(' ');
}
continue;
}
if ch == '-' && chars.peek() == Some(&'-') {
chars.next();
in_line_comment = true;
continue;
}
if ch == '#' {
in_line_comment = true;
continue;
}
if ch == '/' && chars.peek() == Some(&'*') {
chars.next();
if let Some(body) = read_mysql_executable_comment_body(&mut chars) {
output.push(' ');
output.push_str(&strip_sql_comments(&body));
output.push(' ');
} else {
in_block_comment = true;
}
continue;
}
output.push(ch);
}
output
}
pub fn strip_sql_comments_and_literals(sql: &str) -> String {
strip_sql_comments_and_literals_with_metadata(sql, false, true).0
}
/// [`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;
let mut in_block_comment = false;
let mut in_single_quote = false;
let mut in_double_quote = false;
let mut in_backtick_quote = false;
// The closing `$$` / `$tag$` of the dollar-quoted literal being skipped.
let mut in_dollar_quote: Option<String> = None;
let mut has_mysql_executable_comment = false;
// Last character emitted from code position, used to keep a `$` that is
// still part of an identifier (`foo$tag$bar`) from opening a literal.
let mut previous_code_char: Option<char> = None;
while let Some(ch) = chars.next() {
if in_line_comment {
if ch == '\n' {
in_line_comment = false;
output.push(' ');
}
continue;
}
if in_block_comment {
if ch == '*' && chars.peek() == Some(&'/') {
chars.next();
in_block_comment = false;
output.push(' ');
}
continue;
}
if in_single_quote {
if ch == '\'' {
if chars.peek() == Some(&'\'') {
chars.next();
} else {
in_single_quote = false;
}
}
output.push(' ');
continue;
}
if in_double_quote {
if ch == '"' {
if chars.peek() == Some(&'"') {
chars.next();
} else {
in_double_quote = false;
}
}
output.push(' ');
continue;
}
if in_backtick_quote {
if ch == '`' {
if chars.peek() == Some(&'`') {
chars.next();
} else {
in_backtick_quote = false;
}
}
output.push(' ');
continue;
}
if let Some(delimiter) = &in_dollar_quote {
if ch == '$' {
let mut probe = chars.clone();
let mut matched = true;
for expected in delimiter.chars().skip(1) {
match probe.next() {
Some(actual) if actual == expected => {}
_ => {
matched = false;
break;
}
}
}
if matched {
for _ in delimiter.chars().skip(1) {
chars.next();
}
in_dollar_quote = None;
}
}
output.push(' ');
continue;
}
if ch == '-' && chars.peek() == Some(&'-') {
chars.next();
in_line_comment = true;
continue;
}
// `#` 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;
}
if ch == '/' && chars.peek() == Some(&'*') {
chars.next();
if detect_mysql_executable_comments && is_mysql_executable_comment_start(&chars) {
has_mysql_executable_comment = true;
}
in_block_comment = true;
continue;
}
if matches!(ch, 'q' | 'Q' | 'n' | 'N') && !previous_code_char.is_some_and(is_oracle_identifier_char) {
if let Some(length) = oracle_alternative_quote_len(ch, &chars) {
for _ in 0..length {
chars.next();
}
output.push_str(&" ".repeat(length));
previous_code_char = Some(' ');
continue;
}
}
if ch == '\'' {
in_single_quote = true;
output.push(' ');
previous_code_char = Some(' ');
continue;
}
if ch == '"' {
in_double_quote = true;
output.push(' ');
previous_code_char = Some(' ');
continue;
}
if ch == '`' {
in_backtick_quote = true;
output.push(' ');
previous_code_char = Some(' ');
continue;
}
if ch == '$' && !previous_code_char.is_some_and(is_identifier_char) {
if let Some(delimiter) = dollar_quote_delimiter(&chars) {
for _ in delimiter.chars().skip(1) {
chars.next();
}
in_dollar_quote = Some(delimiter);
output.push(' ');
previous_code_char = Some(' ');
continue;
}
}
output.push(ch);
previous_code_char = Some(ch);
}
(output, has_mysql_executable_comment)
}
/// `/*! ... */` is executable in MySQL, while `/*M! ... */` (optionally
/// followed by a version number) is executable in MariaDB.
fn is_mysql_executable_comment_start(chars: &std::iter::Peekable<std::str::Chars<'_>>) -> bool {
let mut marker = chars.clone();
match marker.next() {
Some('!') => true,
Some('M') => marker.next() == Some('!'),
_ => false,
}
}
fn is_identifier_char(character: char) -> bool {
character.is_ascii_alphanumeric() || character == '_' || character == '$'
}
/// Oracle identifiers may also contain `#` (and `$` above), so an introducer
/// directly after one of those characters does not start its own token.
fn is_oracle_identifier_char(character: char) -> bool {
character.is_alphanumeric() || character == '_' || character == '$' || character == '#'
}
/// Length of an Oracle alternative-quoted literal — `q'#...#'`, `q'[...]'`,
/// `nq'{...}'` — where the delimiter may contain the quote character
/// unchanged. The literal ends at the first `closer + '`; Oracle has no escape
/// or nesting inside these literals. `ch` is the already-read `q`/`Q`/`n`/`N`
/// introducer and the returned length includes it. An unterminated literal
/// returns `None` so the text stays visible to the keyword scan and malformed
/// input still fails closed, matching the dollar-quote policy here. Mirrors
/// `readOracleAlternativeQuoteEnd` in `apps/desktop/src/lib/sql/sqlRisk.ts`.
fn oracle_alternative_quote_len(ch: char, chars: &std::iter::Peekable<std::str::Chars<'_>>) -> Option<usize> {
let mut probe = chars.clone();
let mut length = 1usize;
if matches!(ch, 'n' | 'N') {
match probe.next() {
Some('q' | 'Q') => length += 1,
_ => return None,
}
}
match probe.next() {
Some('\'') => length += 1,
_ => return None,
}
let opener = probe.next()?;
length += 1;
// A whitespace opener means `q '...'` — an identifier then an ordinary
// string, not an alternative-quoted literal.
if opener.is_whitespace() {
return None;
}
let closer = match opener {
'[' => ']',
'{' => '}',
'(' => ')',
'<' => '>',
other => other,
};
loop {
let scan = probe.next()?;
length += 1;
if scan == closer && probe.peek() == Some(&'\'') {
probe.next();
return Some(length + 1);
}
}
}
/// Read a PostgreSQL dollar-quote delimiter — `$$` or `$tag$` — from the
/// characters that follow an opening `$`, and return it only when the literal is
/// actually closed later in the statement.
///
/// The tag rules mirror PostgreSQL's own lexer (empty, or an identifier that
/// does not start with a digit) and sqlparser's dollar-quote tokenizer, so the
/// keyword scan agrees with the AST classifier in `sql_risk` instead of reading
/// the body of a literal as SQL. Requiring a matching closing delimiter keeps an
/// unterminated `$$` — which the database rejects anyway — visible to the
/// keyword scan, so malformed input still fails closed.
fn dollar_quote_delimiter(chars: &std::iter::Peekable<std::str::Chars<'_>>) -> Option<String> {
let mut probe = chars.clone();
let delimiter = if probe.peek() == Some(&'$') {
"$$".to_string()
} else {
let mut tag = String::new();
while probe.peek().is_some_and(|ch| ch.is_ascii_alphanumeric() || *ch == '_') {
tag.push(*probe.peek()?);
probe.next();
}
if tag.is_empty() || tag.starts_with(|ch: char| ch.is_ascii_digit()) || probe.peek() != Some(&'$') {
return None;
}
format!("${tag}$")
};
for _ in delimiter.chars().skip(1) {
probe.next();
}
dollar_quote_is_closed(&mut probe, &delimiter).then_some(delimiter)
}
/// True when `delimiter` occurs again in `chars`, which is positioned just after
/// an opening delimiter.
fn dollar_quote_is_closed(chars: &mut std::iter::Peekable<std::str::Chars<'_>>, delimiter: &str) -> bool {
let delimiter: Vec<char> = delimiter.chars().collect();
let mut window: Vec<char> = Vec::with_capacity(delimiter.len());
for character in chars.by_ref() {
window.push(character);
if window.len() > delimiter.len() {
window.remove(0);
}
if window == delimiter {
return true;
}
}
false
}
fn read_mysql_executable_comment_body<I>(chars: &mut std::iter::Peekable<I>) -> Option<String>
where
I: Iterator<Item = char>,
{
let marker = chars.peek().copied()?;
if marker == '!' {
chars.next();
} else if marker == 'M' {
chars.next();
if chars.peek() != Some(&'!') {
return None;
}
chars.next();
} else {
return None;
}
let mut body = String::new();
let mut skipping_version = true;
while let Some(ch) = chars.next() {
if ch == '*' && chars.peek() == Some(&'/') {
chars.next();
return Some(body);
}
if skipping_version && (ch.is_ascii_digit() || ch.is_whitespace()) {
continue;
}
skipping_version = false;
body.push(ch);
}
Some(body)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn oracle_proven_read_only_accepts_simple_top_level_selects() {
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]
fn oracle_proven_read_only_rejects_writes_and_ddl() {
for sql in [
"UPDATE users SET name = 'x'",
"INSERT INTO users VALUES (1)",
"DELETE FROM users",
"MERGE INTO target USING source ON (a = b) WHEN MATCHED THEN UPDATE",
"CREATE TABLE t (id INT)",
"ALTER TABLE t ADD COLUMN c INT",
"DROP TABLE t",
"TRUNCATE TABLE t",
"GRANT SELECT ON t TO x",
"REVOKE SELECT ON t FROM x",
] {
assert!(!is_oracle_proven_read_only_statement(sql), "expected rejected: {sql}");
}
}
#[test]
fn oracle_proven_read_only_rejects_cte_procedure_and_plsql() {
for sql in [
"WITH cte AS (SELECT 1 FROM dual) SELECT * FROM cte",
"CALL my_proc()",
"EXEC my_proc",
"EXECUTE IMMEDIATE 'DROP TABLE t'",
"BEGIN UPDATE t SET x = 1; END;",
"DECLARE x INT; BEGIN NULL; END;",
] {
assert!(!is_oracle_proven_read_only_statement(sql), "expected rejected: {sql}");
}
}
#[test]
fn oracle_proven_read_only_rejects_locking_and_sequence_pseudocolumns() {
for sql in [
"SELECT * FROM t FOR UPDATE",
"LOCK TABLE t IN EXCLUSIVE MODE",
"SELECT seq.NEXTVAL FROM dual",
"SELECT seq.CURRVAL FROM dual",
"SELECT * FROM t@dblink",
"SELECT * FROM t WHERE id = 1 FOR UPDATE",
] {
assert!(!is_oracle_proven_read_only_statement(sql), "expected rejected: {sql}");
}
}
/// #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_accepts_whitelisted_functions_and_parenthesised_reads() {
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 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_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]
fn db2_explain_uses_native_json_plan_acquisition() {
let db_type = Some(DatabaseType::Db2);
assert!(supports_explain_plan(db_type));
let strategy = estimated_plan_strategy(db_type).unwrap();
assert_eq!(strategy.acquisition(), EstimatedPlanAcquisition::DriverNative);
assert_eq!(strategy.format(), EstimatedPlanFormat::Json);
for sql in [
"SELECT * FROM \"APP\".\"ORDERS\"",
"WITH q AS (SELECT 1 FROM SYSIBM.SYSDUMMY1) SELECT * FROM q",
"VALUES 1",
"/* plan */ select * FROM APP.ORDERS",
"-- plan\nwith q AS (SELECT 1 FROM SYSIBM.SYSDUMMY1) SELECT * FROM q",
"/* plan */ values (1)",
] {
let result = build_explain_sql(ExplainSqlOptions {
database_type: db_type,
format: None,
analyze: Some(true),
sql: format!("{sql};"),
});
assert!(result.ok, "{result:?}");
assert_eq!(result.sql, Some(format!("EXPLAIN PLAN FOR {sql}")));
}
}
#[test]
fn db2_explain_handles_nested_comments_without_hiding_writes() {
for sql in [
"/* outer /* inner */ trailing */ SELECT 1 FROM SYSIBM.SYSDUMMY1",
"/* outer /* inner */ DELETE */ VALUES ('a;''b')",
"WITH q AS (/* outer /* inner */ UPDATE */ SELECT 1 FROM SYSIBM.SYSDUMMY1) SELECT * FROM q",
] {
assert!(is_safe_explain_sql_for_database(sql, Some(DatabaseType::Db2)), "{sql}");
assert!(
build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::Db2),
format: None,
analyze: None,
sql: sql.into()
})
.ok,
"{sql}"
);
}
for sql in [
"/* outer /* inner */ trailing */ DELETE FROM APP.ORDERS",
"/* outer /* inner */ trailing */ SELECT 1; DELETE FROM APP.ORDERS",
"/* outer /* inner */ trailing */ TABLE APP.ORDERS",
"WITH q AS (DELETE FROM APP.ORDERS) SELECT * FROM q",
"SELECT 1 /* outer /* inner */",
"SELECT 1;;",
] {
assert!(!is_safe_explain_sql_for_database(sql, Some(DatabaseType::Db2)), "{sql}");
assert!(
!build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::Db2),
format: None,
analyze: None,
sql: sql.into()
})
.ok,
"{sql}"
);
}
}
#[test]
fn db2_explain_rejects_table_shortcuts_in_all_preflight_paths() {
for sql in ["TABLE APP.ORDERS", "table \"APP\".\"ORDERS\";", "/* plan */ TABLE APP.ORDERS"] {
assert!(!is_safe_explain_sql_for_database(sql, Some(DatabaseType::Db2)), "{sql}");
let result = build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::Db2),
format: None,
analyze: None,
sql: sql.into(),
});
assert!(!result.ok, "{sql}: {result:?}");
assert_eq!(result.reason.as_deref(), Some("unsafe"));
assert!(is_safe_explain_sql(sql), "generic TABLE support must remain: {sql}");
assert!(
build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::Postgres),
format: None,
analyze: None,
sql: sql.into(),
})
.ok,
"PostgreSQL TABLE support must remain: {sql}"
);
}
}
#[test]
fn db2_explain_rejects_empty_write_and_multiple_statement_sources() {
for (sql, reason) in [
("", "empty"),
("DELETE FROM t", "unsafe"),
("UPDATE t SET n=1", "unsafe"),
("SELECT 1; DROP TABLE t", "unsafe"),
("SELECT 1; SELECT 2", "unsafe"),
] {
let result = build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::Db2),
format: None,
analyze: None,
sql: sql.into(),
});
assert!(!result.ok);
assert_eq!(result.reason.as_deref(), Some(reason));
}
}
#[test]
fn builds_xugu_verbose_text_explain_without_executing_the_query() {
for sql in [
"SELECT * FROM \"APP_TEST\".\"ORDERS\" WHERE id = 1",
"WITH q AS (SELECT 1 FROM DUAL) SELECT * FROM q",
"/* plan */ SELECT 1 FROM DUAL",
"SELECT 'DELETE; DROP TABLE x' FROM DUAL",
] {
for analyze in [None, Some(false), Some(true)] {
let result = build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::Xugu),
format: Some(ExplainFormat::Json),
analyze,
sql: format!(" {sql}; "),
});
assert_eq!(result.sql.as_deref(), Some(format!("EXPLAIN VERBOSE {sql}").as_str()));
assert!(result.ok);
}
}
assert_eq!(estimated_plan_format(Some(DatabaseType::Xugu)), EstimatedPlanFormat::Text);
assert_eq!(
estimated_plan_strategy(Some(DatabaseType::Xugu)).unwrap().acquisition(),
EstimatedPlanAcquisition::GeneratedSql
);
}
#[test]
fn xugu_explain_rejects_writes_multiple_statements_and_unsupported_forms() {
for sql in [
"DELETE FROM t",
"UPDATE t SET a = 1",
"INSERT INTO t VALUES (1)",
"MERGE INTO t USING s ON t.id = s.id WHEN MATCHED THEN UPDATE SET t.a = 1",
"SELECT 1; DROP TABLE t",
"WITH q AS (DELETE FROM t RETURNING *) SELECT * FROM q",
"WITH FUNCTION f RETURN INT IS BEGIN RETURN 1; END; SELECT f() FROM DUAL",
"EXPLAIN SELECT 1",
"TABLE t",
"VALUES (1)",
"SELECTED",
"",
] {
let result = build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::Xugu),
format: None,
analyze: None,
sql: sql.to_string(),
});
assert!(!result.ok, "unsafe Xugu explain source: {sql}");
assert!(result.sql.is_none());
}
}
#[test]
fn builds_postgres_json_explain_sql() {
let result = build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::Postgres),
format: None,
analyze: None,
sql: " select * from users where id = 1; ".to_string(),
});
assert_eq!(
result,
ExplainSqlBuildResult {
ok: true,
sql: Some("EXPLAIN (FORMAT JSON) select * from users where id = 1".to_string()),
reason: None,
}
);
}
#[test]
fn builds_postgres_explain_sql_for_keyword_boundaries() {
for sql in [
"SELECT* FROM users",
"SELECT\t* FROM users",
"SELECT(1)",
"VALUES(1)",
"SELECT\r\n*\r\nFROM users",
"TABLE users",
] {
assert_eq!(
build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::Postgres),
format: None,
analyze: None,
sql: sql.to_string(),
}),
ExplainSqlBuildResult { ok: true, sql: Some(format!("EXPLAIN (FORMAT JSON) {sql}")), reason: None },
"expected safe EXPLAIN source: {sql:?}"
);
}
}
#[test]
fn rejects_explain_keyword_identifier_prefixes() {
for sql in ["SELECTED", "SELECT_foo", "SELECT$foo", "SELECT1", "VALUES_foo", "VALUES1"] {
assert_eq!(
build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::Postgres),
format: None,
analyze: None,
sql: sql.to_string(),
}),
ExplainSqlBuildResult { ok: false, sql: None, reason: Some("unsafe".to_string()) },
"expected unsafe EXPLAIN source: {sql:?}"
);
}
}
#[test]
fn builds_postgres_analyze_explain_sql() {
let result = build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::Postgres),
format: None,
analyze: Some(true),
sql: " select * from users where id = 1; ".to_string(),
});
assert_eq!(
result,
ExplainSqlBuildResult {
ok: true,
sql: Some("EXPLAIN (ANALYZE, FORMAT JSON) select * from users where id = 1".to_string()),
reason: None,
}
);
// analyze: Some(false) must behave exactly like the omitted flag.
assert_eq!(
build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::Postgres),
format: None,
analyze: Some(false),
sql: "SELECT 1".to_string(),
})
.sql,
Some("EXPLAIN (FORMAT JSON) SELECT 1".to_string())
);
}
#[test]
fn builds_sqlserver_actual_plan_explain_sql() {
let result = build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::SqlServer),
format: None,
analyze: Some(true),
sql: " select * from dbo.orders where id = 1; ".to_string(),
});
assert_eq!(
result,
ExplainSqlBuildResult {
ok: true,
sql: Some(
"SET STATISTICS XML ON;\nGO\nselect * from dbo.orders where id = 1\nGO\nSET STATISTICS XML OFF;"
.to_string()
),
reason: None,
}
);
// analyze: Some(false) must keep the estimated SHOWPLAN plan.
assert_eq!(
build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::SqlServer),
format: None,
analyze: Some(false),
sql: "SELECT 1".to_string(),
})
.sql,
Some("SET SHOWPLAN_XML ON;\nGO\nSELECT 1\nGO\nSET SHOWPLAN_XML OFF;".to_string())
);
}
#[test]
fn refuses_sqlserver_analyze_on_non_select_sources() {
// STATISTICS XML runs the statement, so the safety gate stays load-bearing.
for sql in [
"delete from dbo.orders",
"update dbo.orders set name = 'x'",
"SELECT * INTO dbo.orders_copy FROM dbo.orders",
"SELECT 1\nGO\nDROP TABLE dbo.orders",
] {
assert_eq!(
build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::SqlServer),
format: None,
analyze: Some(true),
sql: sql.to_string(),
}),
ExplainSqlBuildResult { ok: false, sql: None, reason: Some("unsafe".to_string()) },
"{sql}"
);
}
}
#[test]
fn ignores_analyze_for_every_engine_but_postgres_and_sqlserver() {
for (db_type, expected) in [
(DatabaseType::MongoDb, "EXPLAIN (FORMAT JSON) SELECT 1"),
(DatabaseType::Mysql, "EXPLAIN FORMAT=JSON SELECT 1"),
(DatabaseType::Doris, "EXPLAIN SELECT 1"),
(DatabaseType::Questdb, "EXPLAIN SELECT 1"),
(DatabaseType::Dameng, "EXPLAIN SELECT 1"),
(DatabaseType::Oracle, "EXPLAIN PLAN FOR SELECT 1"),
(DatabaseType::OceanbaseOracle, "EXPLAIN FORMAT=JSON SELECT 1"),
] {
let analyzed = build_explain_sql(ExplainSqlOptions {
database_type: Some(db_type),
format: None,
analyze: Some(true),
sql: "SELECT 1".to_string(),
});
let plain = build_explain_sql(ExplainSqlOptions {
database_type: Some(db_type),
format: None,
analyze: None,
sql: "SELECT 1".to_string(),
});
assert_eq!(analyzed, plain, "{db_type:?} must ignore the analyze flag");
// MongoDb is rejected by the strategy capability gate; its historical
// SQL-generation arm remains unreachable.
if db_type != DatabaseType::MongoDb {
assert_eq!(analyzed.sql, Some(expected.to_string()), "{db_type:?}");
} else {
assert_eq!(analyzed.reason, Some("unsupported".to_string()));
}
}
}
#[test]
fn refuses_analyze_on_non_select_sources() {
for sql in ["delete from users", "update users set name = 'x'", "SELECT 1; DROP TABLE users"] {
assert_eq!(
build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::Postgres),
format: None,
analyze: Some(true),
sql: sql.to_string(),
}),
ExplainSqlBuildResult { ok: false, sql: None, reason: Some("unsafe".to_string()) },
"{sql}"
);
}
}
#[test]
fn builds_dameng_explain_sql() {
let result = build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::Dameng),
format: None,
analyze: None,
sql: "SELECT * FROM t1 WHERE id = 1".to_string(),
});
assert_eq!(
result,
ExplainSqlBuildResult {
ok: true,
sql: Some("EXPLAIN SELECT * FROM t1 WHERE id = 1".to_string()),
reason: None,
}
);
}
#[test]
fn builds_doris_explain_sql_without_mysql_json_format() {
assert!(supports_explain_plan(Some(DatabaseType::Doris)));
assert_eq!(
build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::Doris),
format: None,
analyze: None,
sql: "SELECT * FROM users;".to_string(),
}),
ExplainSqlBuildResult { ok: true, sql: Some("EXPLAIN SELECT * FROM users".to_string()), reason: None }
);
for sql in ["DELETE FROM users", "SELECT * FROM users; DROP TABLE users"] {
assert_eq!(
build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::Doris),
format: None,
analyze: None,
sql: sql.to_string(),
}),
ExplainSqlBuildResult { ok: false, sql: None, reason: Some("unsafe".to_string()) },
"{sql}"
);
}
}
#[test]
fn builds_oracle_explain_plan_sql() {
let result = build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::Oracle),
format: None,
analyze: None,
sql: "WITH rows AS (SELECT 1 AS id FROM dual) SELECT * FROM rows;".to_string(),
});
assert_eq!(
result,
ExplainSqlBuildResult {
ok: true,
sql: Some("EXPLAIN PLAN FOR WITH rows AS (SELECT 1 AS id FROM dual) SELECT * FROM rows".to_string()),
reason: None,
}
);
}
#[test]
fn builds_oracle_explain_plan_for_update() {
let result = build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::Oracle),
format: None,
analyze: None,
sql: "UPDATE AA_PAY_VOUCHER_TEMP t SET t.AGENCY_ID = '1';".to_string(),
});
assert_eq!(
result,
ExplainSqlBuildResult {
ok: true,
sql: Some("EXPLAIN PLAN FOR UPDATE AA_PAY_VOUCHER_TEMP t SET t.AGENCY_ID = '1'".to_string()),
reason: None,
}
);
}
#[test]
fn allows_oracle_explain_plan_for_dml_only() {
for (sql, expected) in [
("INSERT INTO audit_log (id) VALUES (1)", "EXPLAIN PLAN FOR INSERT INTO audit_log (id) VALUES (1)"),
("DELETE FROM audit_log WHERE id = 1", "EXPLAIN PLAN FOR DELETE FROM audit_log WHERE id = 1"),
(
"MERGE INTO target t USING source s ON (t.id = s.id) WHEN MATCHED THEN UPDATE SET t.name = s.name",
"EXPLAIN PLAN FOR MERGE INTO target t USING source s ON (t.id = s.id) WHEN MATCHED THEN UPDATE SET t.name = s.name",
),
] {
assert_eq!(
build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::Oracle),
format: None,
analyze: None,
sql: sql.to_string(),
})
.sql,
Some(expected.to_string()),
"{sql}"
);
}
for sql in [
"DROP TABLE audit_log",
"ALTER TABLE audit_log ADD created_at DATE",
"BEGIN DELETE FROM audit_log; END;",
"UPDATE audit_log SET id = 2; DROP TABLE audit_log",
] {
assert_eq!(
build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::Oracle),
format: None,
analyze: None,
sql: sql.to_string(),
}),
ExplainSqlBuildResult { ok: false, sql: None, reason: Some("unsafe".to_string()) },
"{sql}"
);
}
}
#[test]
fn gaussdb_dws_strategy_is_explicit_and_keeps_the_generic_dialect_closed() {
use EstimatedPlanAcquisition::GeneratedSql;
use EstimatedPlanFormat::Json;
let strategy = gaussdb_dws_estimated_plan_strategy();
assert_eq!(strategy.acquisition(), GeneratedSql);
assert_eq!(strategy.format(), Json);
assert!(estimated_plan_strategy(Some(DatabaseType::Gaussdb)).is_none());
assert!(!supports_explain_plan(Some(DatabaseType::Gaussdb)));
let result = build_gaussdb_dws_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::Gaussdb),
format: None,
analyze: None,
sql: "SELECT 1".to_string(),
});
assert_eq!(result.sql.as_deref(), Some("EXPLAIN (FORMAT JSON) SELECT 1"));
assert!(result.ok);
for options in [
ExplainSqlOptions {
database_type: Some(DatabaseType::Gaussdb),
format: None,
analyze: Some(true),
sql: "SELECT 1".to_string(),
},
ExplainSqlOptions {
database_type: Some(DatabaseType::Gaussdb),
format: None,
analyze: None,
sql: "DELETE FROM users".to_string(),
},
] {
assert!(!build_gaussdb_dws_explain_sql(options).ok);
}
assert_eq!(
build_gaussdb_dws_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::Postgres),
format: None,
analyze: None,
sql: "SELECT 1".to_string(),
})
.reason
.as_deref(),
Some("unsupported")
);
}
#[test]
fn estimated_plan_strategy_matches_capability_sql_format_and_acquisition() {
use EstimatedPlanAcquisition::{DriverNative, GeneratedSql, SqlServerShowPlanSession};
use EstimatedPlanFormat::{Json, Text, Xml};
let cases = [
(DatabaseType::Mysql, "EXPLAIN FORMAT=JSON SELECT 1", Json, GeneratedSql),
(DatabaseType::Doris, "EXPLAIN SELECT 1", Text, GeneratedSql),
(DatabaseType::Xugu, "EXPLAIN VERBOSE SELECT 1", Text, GeneratedSql),
(DatabaseType::Postgres, "EXPLAIN (FORMAT JSON) SELECT 1", Json, GeneratedSql),
(DatabaseType::OpenGauss, "EXPLAIN SELECT 1", Text, GeneratedSql),
(DatabaseType::Questdb, "EXPLAIN SELECT 1", Text, GeneratedSql),
(DatabaseType::Dameng, "EXPLAIN SELECT 1", Text, DriverNative),
(DatabaseType::Oracle, "EXPLAIN PLAN FOR SELECT 1", Text, DriverNative),
(DatabaseType::Db2, "EXPLAIN PLAN FOR SELECT 1", Json, DriverNative),
(DatabaseType::OceanbaseOracle, "EXPLAIN FORMAT=JSON SELECT 1", Json, GeneratedSql),
(
DatabaseType::SqlServer,
"SET SHOWPLAN_XML ON;\nGO\nSELECT 1\nGO\nSET SHOWPLAN_XML OFF;",
Xml,
SqlServerShowPlanSession,
),
];
for (database_type, expected_sql, expected_format, expected_acquisition) in cases {
let strategy = estimated_plan_strategy(Some(database_type))
.unwrap_or_else(|| panic!("{database_type:?} must have a complete Estimated Plan strategy"));
assert!(supports_explain_plan(Some(database_type)), "{database_type:?}");
assert_eq!(strategy.acquisition(), expected_acquisition, "{database_type:?}");
assert_eq!(strategy.format(), expected_format, "{database_type:?}");
assert_eq!(estimated_plan_format(Some(database_type)), expected_format, "{database_type:?}");
let result = build_explain_sql(ExplainSqlOptions {
database_type: Some(database_type),
format: None,
analyze: None,
sql: "SELECT 1".to_string(),
});
assert!(result.ok, "{database_type:?}: {:?}", result.reason);
assert_eq!(result.sql.as_deref(), Some(expected_sql), "{database_type:?}");
}
for database_type in [
None,
Some(DatabaseType::Redis),
Some(DatabaseType::MongoDb),
Some(DatabaseType::Gaussdb),
Some(DatabaseType::StarRocks),
] {
assert!(estimated_plan_strategy(database_type).is_none(), "{database_type:?}");
assert!(!supports_explain_plan(database_type), "{database_type:?}");
assert_eq!(
build_explain_sql(ExplainSqlOptions {
database_type,
format: None,
analyze: None,
sql: "SELECT 1".to_string(),
})
.reason
.as_deref(),
Some("unsupported"),
"{database_type:?}"
);
}
// Preserve the legacy fallback for callers that ask for a format without
// first checking support; it does not enable plan capability.
assert_eq!(estimated_plan_format(None), Json);
assert_eq!(estimated_plan_format(Some(DatabaseType::Redis)), Json);
}
#[test]
fn builds_sqlserver_showplan_xml_batches() {
let result = build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::SqlServer),
format: None,
analyze: None,
sql: "WITH rows AS (SELECT 1 AS id) SELECT * FROM rows;".to_string(),
});
assert_eq!(
result,
ExplainSqlBuildResult {
ok: true,
sql: Some(
"SET SHOWPLAN_XML ON;\nGO\nWITH rows AS (SELECT 1 AS id) SELECT * FROM rows\nGO\nSET SHOWPLAN_XML OFF;"
.to_string()
),
reason: None,
}
);
assert_eq!(
build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::SqlServer),
format: None,
analyze: None,
sql: "SELECT 1\nGO\nSELECT 2".to_string(),
}),
ExplainSqlBuildResult { ok: false, sql: None, reason: Some("unsafe".to_string()) }
);
assert!(
build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::SqlServer),
format: None,
analyze: None,
sql: "SELECT 'first line\nGO\nlast line' AS text".to_string(),
})
.ok
);
}
#[test]
fn validates_dameng_autotrace_sql_safety() {
assert!(is_safe_dameng_autotrace_sql("SELECT * FROM t WHERE name = 'delete';"));
assert!(is_safe_dameng_autotrace_sql("/* comment */ WITH q AS (SELECT 1) SELECT * FROM q"));
assert!(!is_safe_dameng_autotrace_sql("SELECT * FROM t; DELETE FROM t"));
assert!(!is_safe_dameng_autotrace_sql("UPDATE t SET name = 'x'"));
assert!(!is_safe_dameng_autotrace_sql("SELECT * FROM t; /* hidden */ DROP TABLE t"));
assert!(!is_safe_dameng_autotrace_sql(""));
}
#[test]
fn builds_mysql_json_explain_and_rejects_unsafe_sql() {
assert_eq!(
build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::Mysql),
format: None,
analyze: None,
sql: "SELECT * FROM users;".to_string(),
}),
ExplainSqlBuildResult {
ok: true,
sql: Some("EXPLAIN FORMAT=JSON SELECT * FROM users".to_string()),
reason: None,
}
);
assert_eq!(
build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::Mysql),
format: None,
analyze: None,
sql: "delete from users".to_string(),
}),
ExplainSqlBuildResult { ok: false, sql: None, reason: Some("unsafe".to_string()) }
);
assert_eq!(
build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::Mysql),
format: None,
analyze: None,
sql: "SELECT * FROM users; DELETE FROM users".to_string(),
}),
ExplainSqlBuildResult { ok: false, sql: None, reason: Some("unsafe".to_string()) }
);
for sql in [
"DROP TABLE users;",
"DELETE FROM users;",
"INSERT INTO users (id) VALUES (1);",
"UPDATE users SET name = 'x';",
"SELECT 1; DROP TABLE users;",
"CREATE INDEX idx ON users (id);",
] {
assert_eq!(
build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::Mysql),
format: None,
analyze: None,
sql: sql.to_string(),
}),
ExplainSqlBuildResult { ok: false, sql: None, reason: Some("unsafe".to_string()) },
"{sql} must remain blocked by the explain safety gate"
);
}
assert_eq!(
build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::Mysql),
format: Some(ExplainFormat::Standard),
analyze: None,
sql: "SELECT * FROM users;".to_string(),
}),
ExplainSqlBuildResult {
ok: true,
sql: Some("EXPLAIN FORMAT=TRADITIONAL SELECT * FROM users".to_string()),
reason: None,
}
);
}
#[test]
fn builds_nonexecuting_oceanbase_oracle_json_explain() {
let result = build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::OceanbaseOracle),
format: Some(ExplainFormat::Json),
analyze: Some(true),
sql: "SELECT * FROM events;".to_string(),
});
assert_eq!(result.sql.as_deref(), Some("EXPLAIN FORMAT=JSON SELECT * FROM events"));
assert!(result.ok);
let unsafe_result = build_explain_sql(ExplainSqlOptions {
database_type: Some(DatabaseType::OceanbaseOracle),
format: None,
analyze: None,
sql: "DELETE FROM events".to_string(),
});
assert_eq!(unsafe_result.reason.as_deref(), Some("unsafe"));
}
#[test]
fn builds_dropped_file_preview_sql() {
assert_eq!(
build_dropped_file_preview_sql(DroppedFilePreviewSqlOptions {
path: "/tmp/O'Hara.csv".to_string(),
limit: Some(25),
}),
Some("SELECT * FROM read_csv('/tmp/O''Hara.csv') LIMIT 25".to_string())
);
assert_eq!(
build_dropped_file_preview_sql(DroppedFilePreviewSqlOptions {
path: "/tmp/data.tsv".to_string(),
limit: None,
}),
Some("SELECT * FROM read_csv('/tmp/data.tsv', delim='\\t') LIMIT 1000".to_string())
);
assert_eq!(
build_dropped_file_preview_sql(DroppedFilePreviewSqlOptions {
path: "/tmp/data.txt".to_string(),
limit: None,
}),
None
);
}
#[test]
fn strip_sql_comments_and_literals_dollar_quoted() {
// #9866: the body of a dollar-quoted literal is data, not SQL.
assert_eq!(strip_sql_comments_and_literals("SELECT $$hello$$ FROM t"), "SELECT FROM t");
assert_eq!(strip_sql_comments_and_literals("SELECT $tag$hello$tag$ FROM t"), "SELECT FROM t");
// A literal body may contain quotes, comments and its own `$` without ending early.
assert_eq!(
strip_sql_comments_and_literals("SELECT $f$ a 'b' /* c */ $$ $f$ FROM t"),
"SELECT FROM t"
);
// `$1` is a placeholder, not a literal opener.
assert_eq!(strip_sql_comments_and_literals("SELECT $1, $2 FROM t"), "SELECT $1, $2 FROM t");
// An unterminated `$$` is not a literal, so the text stays visible to the keyword scan.
assert_eq!(strip_sql_comments_and_literals("SELECT $$hello FROM t"), "SELECT $$hello FROM t");
// `$` continues an identifier, so `foo$tag$bar` is one word and not a literal.
assert_eq!(strip_sql_comments_and_literals("SELECT foo$tag$bar FROM t"), "SELECT foo$tag$bar FROM t");
}
#[test]
fn contains_dangerous_sql_keyword_ignores_dollar_quoted_literals() {
assert!(!contains_dangerous_sql_keyword("SELECT $$INSERT INTO users VALUES (1)$$ FROM t"));
assert!(!contains_dangerous_sql_keyword("SELECT $body$DROP TABLE users$body$ FROM t"));
assert!(!contains_dangerous_sql_keyword("SELECT $$it''s a DELETE$$ FROM t"));
// Nested dollar quotes in a tagged literal must not close it early.
assert!(!contains_dangerous_sql_keyword("SELECT $outer$$DELETE FROM t$$$outer$ FROM t"));
// An unterminated literal is malformed input and still fails closed.
assert!(contains_dangerous_sql_keyword("SELECT $$DELETE FROM users"));
}
#[test]
fn is_write_sql_dollar_quoted_literals_stay_read_only() {
for database_type in [DatabaseType::Postgres, DatabaseType::Mysql, DatabaseType::SqlServer] {
assert!(
!is_write_sql_for_database("SELECT $$INSERT INTO t VALUES(1)$$ FROM t", database_type),
"dollar-quoted literal must stay read-only for {database_type:?}"
);
}
assert!(!is_write_sql("SELECT $$DROP TABLE users$$ FROM t"));
// `SELECT ... INTO` still creates a table, so the write must survive.
assert!(is_write_sql_for_database("SELECT $$x$$ INTO backup FROM t", DatabaseType::Postgres));
assert!(is_write_sql_for_database("SELECT $$x$$ INTO backup FROM t", DatabaseType::SqlServer));
// A write after a literal is unaffected.
assert!(is_write_sql_for_database("SELECT $$x$$ FROM t; DELETE FROM t", DatabaseType::Postgres));
}
#[test]
fn strip_sql_comments_and_literals_basic() {
assert_eq!(strip_sql_comments_and_literals("SELECT 1"), "SELECT 1");
assert_eq!(strip_sql_comments_and_literals("SELECT 'hello'"), "SELECT ");
assert_eq!(strip_sql_comments_and_literals("SELECT \"hello\""), "SELECT ");
assert_eq!(strip_sql_comments_and_literals("-- comment\nSELECT 1"), " SELECT 1");
assert_eq!(strip_sql_comments_and_literals("/* block */ SELECT 1"), " SELECT 1");
assert_eq!(strip_sql_comments_and_literals("# comment\nSELECT 1"), " SELECT 1");
}
#[test]
fn strip_sql_comments_and_literals_oracle_alternative_quoted() {
// The quote inside q'...' is literal data, so the rest of the statement
// stays visible to the keyword scan instead of being swallowed by the
// mis-paired ordinary string.
let visible = strip_sql_comments_and_literals("SELECT q'[O'Reilly]' FROM t; DELETE FROM users;");
assert!(visible.contains("DELETE FROM users"), "{visible}");
let visible = strip_sql_comments_and_literals("SELECT nq'{O'Reilly}' FROM t; DELETE FROM users;");
assert!(visible.contains("DELETE FROM users"), "{visible}");
// SQL-looking text inside the literal stays literal data.
let hidden = strip_sql_comments_and_literals("SELECT q'[it's; DELETE FROM users;]' FROM t;");
assert!(!hidden.contains("DELETE"), "{hidden}");
// The introducer must start its own token: `aq` is one identifier, so
// the following literal is ordinary.
let visible = strip_sql_comments_and_literals("SELECT aq'[x]' FROM t; DELETE FROM users;");
assert!(visible.contains("DELETE FROM users"), "{visible}");
// An unterminated q-literal is malformed input and stays visible, so
// the keyword scan fails closed.
let visible = strip_sql_comments_and_literals("SELECT q'[O'Reilly FROM t; DELETE FROM users;");
assert!(visible.contains("DELETE FROM users"), "{visible}");
}
#[test]
fn oracle_proven_read_only_statement_alternative_quoted() {
// The mis-paired quotes previously reduced this to `SELECT q`, proving
// the statement read-only even though real code follows the literal.
assert!(!is_oracle_proven_read_only_statement("SELECT q'[O'Reilly]' FROM users; DELETE FROM users;"));
assert!(is_oracle_proven_read_only_statement("SELECT * FROM users WHERE name = q'[O''Reilly]'"));
// SQL-looking text inside the literal is data, not a forbidden keyword.
assert!(is_oracle_proven_read_only_statement("SELECT * FROM users WHERE note = q'[drop table]'"));
}
#[test]
fn strip_sql_comments_and_literals_nested() {
// String literals containing comments should be stripped
assert_eq!(strip_sql_comments_and_literals("SELECT '/* not a comment */'"), "SELECT ");
// Comments containing string delimiters should be stripped
assert_eq!(strip_sql_comments_and_literals("/* 'not a string' */ SELECT 1"), " SELECT 1");
}
#[test]
fn contains_dangerous_sql_keyword_detects_writes() {
assert!(contains_dangerous_sql_keyword("DROP TABLE users"));
assert!(contains_dangerous_sql_keyword("DELETE FROM users"));
assert!(contains_dangerous_sql_keyword("TRUNCATE TABLE users"));
assert!(contains_dangerous_sql_keyword("ALTER TABLE users ADD COLUMN age INT"));
assert!(contains_dangerous_sql_keyword("UPDATE users SET name = 'x'"));
assert!(contains_dangerous_sql_keyword("MERGE INTO target USING source"));
assert!(contains_dangerous_sql_keyword("REPLACE INTO users VALUES (1)"));
assert!(contains_dangerous_sql_keyword("INSERT INTO users VALUES (1)"));
assert!(contains_dangerous_sql_keyword("CREATE TABLE users (id INT)"));
}
#[test]
fn contains_dangerous_sql_keyword_ignores_substrings() {
// "updateable" contains "update" as substring but is a different word
assert!(!contains_dangerous_sql_keyword("SELECT * FROM updateable_view"));
// "dropped" contains "drop" as substring
assert!(!contains_dangerous_sql_keyword("SELECT dropped FROM t"));
// "inserted" contains "insert"
assert!(!contains_dangerous_sql_keyword("SELECT inserted FROM t"));
}
#[test]
fn contains_dangerous_sql_keyword_ignores_scalar_functions_named_like_writes() {
// #9978: REPLACE()/INSERT() are scalar functions, not statements.
assert!(!contains_dangerous_sql_keyword("SELECT REPLACE(name, 'a', 'b') FROM users"));
assert!(!contains_dangerous_sql_keyword("SELECT REPLACE (name, 'a', 'b') FROM users"));
assert!(!contains_dangerous_sql_keyword("SELECT INSERT('Quadratic', 3, 4, 'What')"));
assert!(!contains_dangerous_sql_keyword("SELECT dropped, inserted FROM t"));
assert!(contains_dangerous_sql_keyword("REPLACE INTO users VALUES (1)"));
assert!(contains_dangerous_sql_keyword("REPLACE users SET name = 'x'"));
assert!(contains_dangerous_sql_keyword("INSERT INTO users VALUES (1)"));
// `UPDATE (t)` is a real write: MySQL accepts a parenthesized table reference.
assert!(contains_dangerous_sql_keyword("UPDATE (users) SET name = 'x'"));
}
#[test]
fn is_write_sql_allows_mysql_json_table_query_with_replace_function() {
// #9978: a JSON_TABLE query using REPLACE() must not be treated as a write,
// otherwise read-only connections ask for write permission before running it.
let sql = "with tmp as (\n select '张三,李四,王五' as `names`\n)\nSELECT jt.name\nFROM tmp t\nJOIN JSON_TABLE(\n CONCAT('[\"',REPLACE(t.`names`,',','\",\"'),'\"]'),\n '$[*]' COLUMNS(name VARCHAR(50) PATH '$')\n) jt";
assert!(!is_write_sql_for_database(sql, DatabaseType::Mysql), "JSON_TABLE + REPLACE() must stay read-only");
assert!(!is_write_sql_for_database("SELECT REPLACE(name, 'a', 'b') FROM users", DatabaseType::Mysql));
assert!(!is_write_sql_for_database("SELECT INSERT('Quadratic', 3, 4, 'What')", DatabaseType::Mysql));
assert!(!is_write_sql_for_database("SELECT REPLACE (name, 'a', 'b') FROM users", DatabaseType::SqlServer));
assert!(!is_write_sql_for_database("SELECT replace(name, 'a', 'b') FROM users", DatabaseType::Postgres));
assert!(!is_write_sql(sql));
}
#[test]
fn is_write_sql_still_blocks_writes_using_function_like_verbs() {
assert!(is_write_sql_for_database("REPLACE INTO users VALUES (1)", DatabaseType::Mysql));
assert!(is_write_sql_for_database("REPLACE users SET name = 'x'", DatabaseType::Mysql));
assert!(is_write_sql_for_database("INSERT INTO users VALUES (1)", DatabaseType::Mysql));
// MySQL accepts a parenthesized table reference, so `UPDATE (` is a write.
assert!(is_write_sql_for_database("UPDATE (users) SET name = 'x'", DatabaseType::Mysql));
assert!(is_write_sql_for_database(
"WITH x AS (SELECT 1 AS n) UPDATE (users) SET name = 'x'",
DatabaseType::Mysql
));
// Data-modifying CTEs keep being detected.
assert!(is_write_sql_for_database(
"WITH x AS (INSERT INTO users VALUES (1) RETURNING *) SELECT * FROM x",
DatabaseType::Postgres
));
assert!(is_write_sql_for_database(
"WITH x AS (DELETE FROM users RETURNING *) SELECT * FROM x",
DatabaseType::Postgres
));
}
#[test]
fn contains_dangerous_sql_keyword_ignores_in_string_literals() {
assert!(!contains_dangerous_sql_keyword("SELECT 'DROP TABLE users' FROM t"));
assert!(!contains_dangerous_sql_keyword("SELECT 'delete' FROM t"));
assert!(!contains_dangerous_sql_keyword("SELECT \"CREATE TABLE\" FROM t"));
}
#[test]
fn contains_dangerous_sql_keyword_ignores_backtick_identifiers() {
for keyword in ["drop", "delete", "truncate", "alter", "update", "merge", "replace", "insert", "create"] {
let sql = format!("SELECT 1 AS `{keyword}`");
assert!(!contains_dangerous_sql_keyword(&sql), "expected safe SQL: {sql}");
}
assert!(!contains_dangerous_sql_keyword("SELECT 1 AS `before``delete`"));
}
#[test]
fn is_write_sql_detects_simple_writes() {
assert!(is_write_sql("INSERT INTO users VALUES (1)"));
assert!(is_write_sql("UPDATE users SET name = 'x'"));
assert!(is_write_sql("DELETE FROM users"));
assert!(is_write_sql("DROP TABLE users"));
assert!(is_write_sql("CREATE TABLE users (id INT)"));
assert!(is_write_sql("ALTER TABLE users ADD COLUMN age INT"));
assert!(is_write_sql("TRUNCATE TABLE users"));
assert!(is_write_sql("EXPLAIN ANALYZE DELETE FROM users"));
assert!(is_write_sql("SELECT * INTO backup_users FROM users"));
assert!(is_write_sql("SELECT * FROM users INTO OUTFILE '/tmp/users.csv'"));
assert!(is_write_sql("COPY users FROM '/tmp/users.csv'"));
assert!(is_write_sql("/*! DELETE FROM users */"));
assert!(is_write_sql(
"MERGE INTO target USING source ON t.id = s.id WHEN MATCHED THEN UPDATE SET t.name = s.name"
));
assert!(is_write_sql("REPLACE INTO users VALUES (1)"));
}
#[test]
fn is_write_sql_allows_reads() {
assert!(!is_write_sql("SELECT * FROM users"));
assert!(!is_write_sql("SELECT id, name FROM users WHERE active = true"));
assert!(!is_write_sql("WITH cte AS (SELECT 1) SELECT * FROM cte"));
assert!(!is_write_sql("SHOW TABLES"));
assert!(!is_write_sql("DESCRIBE users"));
assert!(!is_write_sql("DESC users"));
assert!(!is_write_sql("EXPLAIN SELECT * FROM users"));
assert!(!is_write_sql("PRAGMA table_info(users)"));
assert!(!is_write_sql("FROM users SELECT *"));
}
#[test]
fn is_write_sql_allows_backtick_identifiers_with_dangerous_keywords() {
for keyword in ["drop", "delete", "truncate", "alter", "update", "merge", "replace", "insert", "create"] {
let sql = format!("SELECT 1 AS `{keyword}`");
assert!(!is_write_sql(&sql), "expected read-only SQL: {sql}");
}
assert!(!is_write_sql("SHOW COLUMNS FROM `delete`"));
assert!(!is_write_sql("DESC `delete`"));
assert!(!is_write_sql("SELECT 1 AS `before``delete`"));
assert!(!is_write_sql("SELECT 1 AS `semi;delete`; SELECT 2"));
}
#[test]
fn is_write_sql_still_blocks_writes_with_backtick_identifiers() {
assert!(is_write_sql("DELETE FROM `users`"));
assert!(is_write_sql("DROP TABLE `users`"));
assert!(is_write_sql("UPDATE `users` SET name = 'Ada'"));
}
#[test]
fn is_write_sql_allows_show_create_statements() {
for sql in [
"SHOW CREATE TABLE users",
"show create view active_users",
"SHOW CREATE PROCEDURE refresh_users",
"SHOW CREATE FUNCTION user_count",
" /* metadata */\nSHOW\nCREATE TABLE users;",
"SHOW CREATE TABLE users; SELECT ';' AS separator",
"SHOW CREATE TABLE users /* ; DELETE FROM users */",
] {
assert!(!is_write_sql(sql), "expected read-only SQL: {sql}");
}
}
#[test]
fn is_write_sql_blocks_writes_after_show_create() {
for write_sql in [
"DROP TABLE users",
"DELETE FROM users",
"TRUNCATE TABLE users",
"ALTER TABLE users ADD COLUMN active BOOLEAN",
"UPDATE users SET active = true",
"MERGE INTO users USING source ON users.id = source.id WHEN MATCHED THEN UPDATE SET active = true",
"REPLACE INTO users VALUES (1)",
"INSERT INTO users VALUES (1)",
"CREATE TABLE audit (id INT)",
] {
let sql = format!("SHOW CREATE TABLE users; {write_sql}");
assert!(is_write_sql(&sql), "expected write SQL: {sql}");
}
}
#[test]
fn is_write_sql_ignores_leading_whitespace_and_comments() {
assert!(!is_write_sql(" /* comment */ SELECT * FROM users"));
assert!(!is_write_sql("-- comment\nSELECT * FROM users"));
assert!(is_write_sql(" /* comment */ INSERT INTO users VALUES (1)"));
}
#[test]
fn is_write_sql_blocks_mysql_and_mariadb_executable_comments() {
for sql in [
"SELECT 3156 /*! INTO OUTFILE '/var/lib/mysql-files/dbx_ro_probe.txt' */",
"SELECT 3156 /*!50000 INTO OUTFILE '/var/lib/mysql-files/dbx_ro_probe.txt' */",
"SELECT 3156 /*M! INTO OUTFILE '/var/lib/mysql-files/dbx_ro_probe.txt' */",
"SELECT 3156 /*M!100100 INTO OUTFILE '/var/lib/mysql-files/dbx_ro_probe.txt' */",
] {
assert!(is_write_sql_for_database(sql, DatabaseType::Mysql), "expected write SQL: {sql}");
}
assert!(!is_write_sql_for_database(
"SELECT 3156 /* INTO OUTFILE '/var/lib/mysql-files/dbx_ro_probe.txt' */",
DatabaseType::Mysql
));
assert!(!is_write_sql_for_database(
"SELECT '/*!50000 INTO OUTFILE \'/tmp/probe\' */' AS note",
DatabaseType::Mysql
));
}
#[test]
fn is_write_sql_treats_mysql_executable_comment_syntax_as_plain_for_other_dialects() {
for database_type in [DatabaseType::Postgres, DatabaseType::Sqlite] {
for sql in [
"SELECT 3156 /*!50000 INTO OUTFILE '/var/lib/mysql-files/dbx_ro_probe.txt' */",
"SELECT 3156 /*M!100100 INTO OUTFILE '/var/lib/mysql-files/dbx_ro_probe.txt' */",
"SELECT 3156 /* ordinary block comment */",
] {
assert!(
!is_write_sql_for_database(sql, database_type),
"expected read-only SQL for {database_type:?}: {sql}"
);
}
}
}
#[test]
fn is_write_sql_blocks_dialect_specific_select_into_writes() {
for sql in [
"SELECT 3156 INTO OUTFILE '/var/lib/mysql-files/dbx_ro_probe.txt'",
"SELECT 3156 INTO DUMPFILE '/var/lib/mysql-files/dbx_ro_probe.bin'",
"WITH probe AS (SELECT 3156) SELECT * FROM probe INTO OUTFILE '/var/lib/mysql-files/dbx_ro_probe.txt'",
] {
assert!(is_write_sql_for_database(sql, DatabaseType::Mysql), "expected write SQL: {sql}");
}
for database_type in [
DatabaseType::Postgres,
DatabaseType::Redshift,
DatabaseType::Gaussdb,
DatabaseType::OpenGauss,
DatabaseType::Kingbase,
DatabaseType::Highgo,
DatabaseType::Vastbase,
DatabaseType::Kwdb,
] {
for sql in [
"SELECT * INTO copied_users FROM users",
"WITH active AS (SELECT * FROM users WHERE active) SELECT * INTO active_users FROM active",
] {
assert!(
is_write_sql_for_database(sql, database_type),
"expected write SQL for {database_type:?}: {sql}"
);
}
}
for sql in [
"SELECT * INTO dbo.copied_users FROM dbo.users",
"WITH active AS (SELECT * FROM users WHERE active = 1) SELECT * INTO #active_users FROM active",
] {
assert!(is_write_sql_for_database(sql, DatabaseType::SqlServer), "expected write SQL: {sql}");
}
}
#[test]
fn is_write_sql_does_not_globally_block_into() {
for sql in [
"SELECT 3156 INTO @probe",
"SELECT 'INTO OUTFILE /tmp/probe' AS note",
"SELECT 3156 /* INTO OUTFILE '/tmp/probe' */",
"SELECT 1 AS `into`, 2 AS `outfile`",
] {
assert!(!is_write_sql_for_database(sql, DatabaseType::Mysql), "expected read-only SQL: {sql}");
}
for sql in
["SELECT 'INTO copied_users' AS note", "SELECT $$INTO copied_users$$ AS note", "SELECT 1 AS \"into\""]
{
assert!(!is_write_sql_for_database(sql, DatabaseType::Postgres), "expected read-only SQL: {sql}");
}
assert!(!is_write_sql_for_database("SELECT 1 AS [into]", DatabaseType::SqlServer));
assert!(!is_write_sql_for_database("SELECT 1 INTO unsupported", DatabaseType::Sqlite));
}
#[test]
fn is_write_sql_cte_with_nested_write() {
// CTE starting with WITH but containing a write operation inside
assert!(is_write_sql("WITH deleted AS (DELETE FROM users RETURNING id) SELECT * FROM deleted"));
assert!(is_write_sql("WITH updated AS (UPDATE users SET name = 'x' RETURNING id) SELECT * FROM updated"));
assert!(is_write_sql("WITH inserted AS (INSERT INTO users VALUES (1) RETURNING id) SELECT * FROM inserted"));
// Pure read CTE should be allowed
assert!(!is_write_sql("WITH cte AS (SELECT * FROM users) SELECT * FROM cte"));
}
#[test]
fn is_write_sql_case_insensitive() {
assert!(!is_write_sql("select * from users"));
assert!(!is_write_sql("Select * From users"));
assert!(is_write_sql("insert into users values (1)"));
assert!(is_write_sql("Insert Into users Values (1)"));
assert!(is_write_sql("update users set name = 'x'"));
}
#[test]
fn is_write_sql_edge_cases() {
assert!(!is_write_sql("")); // empty string -> not a write
assert!(!is_write_sql(" ")); // whitespace only -> not a write
assert!(is_write_sql("COMMIT")); // not a recognized read keyword
assert!(is_write_sql("ROLLBACK")); // not a recognized read keyword
assert!(is_write_sql("BEGIN")); // not a recognized read keyword
assert!(is_write_sql("GRANT SELECT ON users TO admin"));
assert!(is_write_sql("REVOKE SELECT ON users FROM admin"));
}
#[test]
fn is_write_sql_blocks_stored_procedure_calls() {
// CALL and EXEC don't start with a read keyword, so they are treated as writes
assert!(is_write_sql("CALL my_procedure(1, 2)"));
assert!(is_write_sql("call my_procedure()"));
assert!(is_write_sql("EXEC sp_update_stats"));
assert!(is_write_sql("EXECUTE sp_rename 'old', 'new'"));
assert!(is_write_sql("execute my_func()"));
}
#[test]
fn is_write_sql_allows_safe_read_pragmas() {
// Read-only PRAGMA forms (function-call style with known safe names) are allowed
assert!(!is_write_sql("PRAGMA table_info(users)"));
assert!(!is_write_sql("PRAGMA table_xinfo(users)"));
assert!(!is_write_sql("PRAGMA index_list(users)"));
assert!(!is_write_sql("PRAGMA index_info(idx_name)"));
assert!(!is_write_sql("PRAGMA foreign_key_list(users)"));
assert!(!is_write_sql("PRAGMA database_list"));
assert!(!is_write_sql("PRAGMA compile_options"));
assert!(!is_write_sql("PRAGMA data_version"));
assert!(!is_write_sql("pragma table_info(users)"));
}
#[test]
fn is_write_sql_blocks_unsafe_pragmas() {
// Assignment forms are always blocked
assert!(is_write_sql("PRAGMA journal_mode = WAL"));
assert!(is_write_sql("PRAGMA synchronous = OFF"));
assert!(is_write_sql("PRAGMA foreign_keys = ON"));
assert!(is_write_sql("PRAGMA cache_size = -2000"));
assert!(is_write_sql("PRAGMA user_version = 123"));
// Unknown PRAGMA names are blocked
assert!(is_write_sql("PRAGMA writable_schema = ON"));
assert!(is_write_sql("PRAGMA locking_mode = EXCLUSIVE"));
assert!(is_write_sql("PRAGMA temp_store = MEMORY"));
assert!(is_write_sql("PRAGMA some_unknown_pragma"));
}
#[test]
fn is_write_sql_string_literal_hides_keywords() {
// The dangerous keyword is inside a string literal, so it should NOT be detected
assert!(!is_write_sql("SELECT 'DROP TABLE users' AS hint FROM t"));
assert!(!is_write_sql("SELECT * FROM t WHERE name = 'delete'"));
}
#[test]
fn check_read_only_success_and_error() {
assert_eq!(check_read_only("SELECT * FROM users", "prod-db", DatabaseType::Mysql), Ok(()));
assert_eq!(check_read_only("WITH cte AS (SELECT 1) SELECT * FROM cte", "prod-db", DatabaseType::Mysql), Ok(()));
assert_eq!(check_read_only("SHOW CREATE TABLE users", "prod-db", DatabaseType::Mysql), Ok(()));
assert_eq!(check_read_only("SELECT 1 AS `delete`", "prod-db", DatabaseType::Mysql), Ok(()));
let err = check_read_only("DELETE FROM users", "prod-db", DatabaseType::Mysql);
assert!(err.is_err());
assert_eq!(
err.unwrap_err(),
"Read-only mode: connection 'prod-db' has read-only protection enabled. Write operation (including stored procedure calls) blocked."
);
let err2 = check_read_only("UPDATE users SET name = 'x'", "reporting-db", DatabaseType::Mysql);
assert!(err2.is_err());
assert!(err2.unwrap_err().contains("reporting-db"));
let show_create_err =
check_read_only("SHOW CREATE TABLE users; DELETE FROM users", "prod-db", DatabaseType::Mysql);
assert!(show_create_err.is_err());
assert!(show_create_err.unwrap_err().contains("Write operation"));
}
#[test]
fn check_read_only_allows_mysql_checksum_only_for_mysql() {
assert_eq!(check_read_only("CHECKSUM TABLE `issue6693_repro`", "mysql", DatabaseType::Mysql), Ok(()));
assert!(check_read_only("CHECKSUM TABLE users", "postgres", DatabaseType::Postgres).is_err());
assert!(check_read_only("CHECKSUM TABLE users", "sqlite", DatabaseType::Sqlite).is_err());
assert!(check_read_only("CHECKSUM TABLE users; DROP TABLE users", "mysql", DatabaseType::Mysql).is_err());
}
#[test]
fn check_read_only_allows_only_sqlserver_plan_capture_session_switches() {
for sql in [
"SET SHOWPLAN_XML ON;",
"-- explain\nSET SHOWPLAN_XML OFF",
"SET STATISTICS XML ON;",
"-- actual plan\nSET STATISTICS XML OFF",
] {
assert_eq!(check_read_only(sql, "readonly", DatabaseType::SqlServer), Ok(()));
}
assert!(check_read_only("SET SHOWPLAN_ALL ON", "readonly", DatabaseType::SqlServer).is_err());
assert!(check_read_only("SET STATISTICS IO ON", "readonly", DatabaseType::SqlServer).is_err());
assert!(check_read_only("SET SHOWPLAN_XML ON; SELECT 1", "readonly", DatabaseType::SqlServer).is_err());
assert!(check_read_only("SET SHOWPLAN_XML OFF; DROP TABLE users", "readonly", DatabaseType::SqlServer).is_err());
assert!(
check_read_only("SET STATISTICS XML OFF; DROP TABLE users", "readonly", DatabaseType::SqlServer).is_err()
);
}
#[test]
fn check_read_only_only_treats_executable_comments_as_writes_for_mysql_compatible_connections() {
let mysql_executable_comment = "SELECT 3156 /*!50000 INTO OUTFILE '/var/lib/mysql-files/dbx_ro_probe.txt' */";
let mariadb_executable_comment =
"SELECT 3156 /*M!100100 INTO OUTFILE '/var/lib/mysql-files/dbx_ro_probe.txt' */";
assert!(check_read_only(mysql_executable_comment, "mysql", DatabaseType::Mysql).is_err());
assert!(check_read_only(mariadb_executable_comment, "mariadb", DatabaseType::Mysql).is_err());
for database_type in [DatabaseType::Postgres, DatabaseType::Sqlite] {
assert_eq!(check_read_only(mysql_executable_comment, "readonly", database_type), Ok(()));
assert_eq!(check_read_only(mariadb_executable_comment, "readonly", database_type), Ok(()));
}
}
#[test]
fn check_read_only_blocks_dialect_specific_select_into_writes() {
for (sql, database_type) in [
("SELECT 3156 INTO OUTFILE '/var/lib/mysql-files/dbx_ro_probe.txt'", DatabaseType::Mysql),
("SELECT * INTO copied_users FROM users", DatabaseType::Postgres),
("SELECT * INTO copied_users FROM users", DatabaseType::Redshift),
("SELECT * INTO copied_users FROM users", DatabaseType::Gaussdb),
("SELECT * INTO copied_users FROM users", DatabaseType::OpenGauss),
("SELECT * INTO copied_users FROM users", DatabaseType::Kingbase),
("SELECT * INTO copied_users FROM users", DatabaseType::Highgo),
("SELECT * INTO copied_users FROM users", DatabaseType::Vastbase),
("SELECT * INTO copied_users FROM users", DatabaseType::Kwdb),
("SELECT * INTO #copied_users FROM users", DatabaseType::SqlServer),
] {
assert!(check_read_only(sql, "readonly", database_type).is_err(), "expected blocked SQL: {sql}");
}
}
#[test]
fn strip_sql_comments_basic() {
assert_eq!(strip_sql_comments("SELECT 1"), "SELECT 1");
assert_eq!(strip_sql_comments("-- comment\nSELECT 1"), " SELECT 1");
assert_eq!(strip_sql_comments("/* block */ SELECT 1"), " SELECT 1");
assert_eq!(strip_sql_comments("# comment\nSELECT 1"), " SELECT 1");
}
#[test]
fn strip_sql_comments_preserves_strings() {
// strip_sql_comments does NOT handle string delimiters, so it strips
// comments even inside string literals
assert_eq!(strip_sql_comments("SELECT 'hello /* not a comment */'"), "SELECT 'hello '");
}
#[test]
fn classifies_dynamodb_generated_statements_for_read_only_protection() {
assert!(!is_write_sql_for_database(
"DBX DYNAMODB SCAN\ntable: \"orders\"\nlimit: 1000",
DatabaseType::DynamoDb
));
assert!(!is_write_sql_for_database(
"DBX DYNAMODB QUERY / SCAN\ntable: \"orders\"\nfilter:\n{\"status\":\"SHIPPED\"}",
DatabaseType::DynamoDb
));
for operation in ["INSERT ITEM", "PUT ITEM", "DELETE ITEM", "UNKNOWN"] {
assert!(is_write_sql_for_database(
&format!("DBX DYNAMODB {operation}\ntable: \"orders\""),
DatabaseType::DynamoDb
));
}
}
#[test]
fn classifies_dynamodb_partiql_reads_and_writes_for_read_only_protection() {
assert!(!is_write_sql_for_database(
"SELECT * FROM \"items\" WHERE \"status\"='PENDING'",
DatabaseType::DynamoDb
));
assert!(!is_write_sql_for_database("-- comment\nSELECT 'UPDATE' FROM \"items\"", DatabaseType::DynamoDb));
for statement in [
"INSERT INTO \"items\" VALUE {'pk':'x'}",
"UPDATE \"items\" SET flag=true WHERE pk='x'",
"DELETE FROM \"items\" WHERE pk='x'",
"SELECT * FROM \"items\"; DELETE FROM \"items\" WHERE pk='x'",
] {
assert!(is_write_sql_for_database(statement, DatabaseType::DynamoDb), "{statement}");
}
}
#[test]
fn classifies_search_engine_rest_writes() {
assert!(!is_write_sql_for_database("GET /_cluster/health", DatabaseType::Easysearch));
assert!(!is_write_sql_for_database(
"POST /products/_search\n{\"query\":{\"match_all\":{}}}",
DatabaseType::Easysearch
));
assert!(is_write_sql_for_database(
"PUT /products/_doc/1?refresh=true\n{\"name\":\"Notebook\"}",
DatabaseType::Easysearch
));
}
#[test]
fn classifies_solr_rest_requests() {
// Read-only requests: select/query/get and other read handlers.
assert!(!is_write_sql_for_database("GET /mycore/select?q=*:*&rows=20", DatabaseType::Solr));
assert!(!is_write_sql_for_database(
"POST /mycore/query\n{\"query\":\"name:foo\",\"limit\":10}",
DatabaseType::Solr
));
assert!(!is_write_sql_for_database("GET /mycore/schema/fields", DatabaseType::Solr));
// Document writes hit the update handlers, including the `/update/...`
// variants whose last path segment is not literally `update`.
assert!(is_write_sql_for_database(
"POST /mycore/update\n{\"add\":{\"doc\":{\"id\":\"1\"}}}",
DatabaseType::Solr
));
assert!(is_write_sql_for_database("POST /mycore/update/json/docs\n{\"id\":\"1\"}", DatabaseType::Solr));
assert!(is_write_sql_for_database("GET /mycore/update?commit=true", DatabaseType::Solr));
// CoreAdmin actions mutate over GET too; only the read whitelist stays safe.
assert_eq!(
classify_search_engine_query_risk("GET /admin/cores?action=CREATE&name=x", DatabaseType::Solr),
Some(SearchEngineQueryRisk::Dangerous)
);
assert_eq!(
classify_search_engine_query_risk("GET /admin/cores?action=STATUS", DatabaseType::Solr),
Some(SearchEngineQueryRisk::ReadOnly)
);
assert_eq!(
classify_search_engine_query_risk("GET /admin/info/system", DatabaseType::Solr),
Some(SearchEngineQueryRisk::ReadOnly)
);
// Replication commands mutate over GET too; only the read whitelist stays safe.
assert_eq!(
classify_search_engine_query_risk("GET /mycore/replication?command=details", DatabaseType::Solr),
Some(SearchEngineQueryRisk::ReadOnly)
);
assert_eq!(
classify_search_engine_query_risk("GET /mycore/replication?command=restorestatus", DatabaseType::Solr),
Some(SearchEngineQueryRisk::ReadOnly)
);
assert_eq!(
classify_search_engine_query_risk("GET /mycore/replication?command=filelist", DatabaseType::Solr),
Some(SearchEngineQueryRisk::ReadOnly)
);
assert_eq!(
classify_search_engine_query_risk("GET /mycore/replication", DatabaseType::Solr),
Some(SearchEngineQueryRisk::ReadOnly)
);
assert_eq!(
classify_search_engine_query_risk("GET /mycore/replication?command=disablereplication", DatabaseType::Solr),
Some(SearchEngineQueryRisk::Dangerous)
);
assert_eq!(
classify_search_engine_query_risk("GET /mycore/replication?command=enablereplication", DatabaseType::Solr),
Some(SearchEngineQueryRisk::Dangerous)
);
assert_eq!(
classify_search_engine_query_risk("GET /mycore/replication?command=fetchindex", DatabaseType::Solr),
Some(SearchEngineQueryRisk::Dangerous)
);
// Core/schema/admin operations are dangerous.
assert_eq!(
classify_search_engine_query_risk("POST /admin/cores?action=CREATE&name=x", DatabaseType::Solr),
Some(SearchEngineQueryRisk::Dangerous)
);
assert_eq!(
classify_search_engine_query_risk("GET /_all_dbs", DatabaseType::CouchDb),
Some(SearchEngineQueryRisk::ReadOnly)
);
assert_eq!(
classify_search_engine_query_risk("POST /mydb/_find\n{\"selector\":{}}", DatabaseType::CouchDb),
Some(SearchEngineQueryRisk::ReadOnly)
);
assert_eq!(
classify_search_engine_query_risk(
"POST /mydb/_bulk_get\n{\"docs\":[{\"id\":\"a\"}]}",
DatabaseType::CouchDb
),
Some(SearchEngineQueryRisk::ReadOnly)
);
assert_eq!(
classify_search_engine_query_risk(
"POST /mydb/_design/users/_view/by_name\n{\"keys\":[\"a\"]}",
DatabaseType::CouchDb
),
Some(SearchEngineQueryRisk::ReadOnly)
);
assert_eq!(
classify_search_engine_query_risk("POST /mydb/_design/users/_info", DatabaseType::CouchDb),
Some(SearchEngineQueryRisk::Write)
);
assert_eq!(
classify_search_engine_query_risk("POST /mydb\n{\"name\":\"doc\"}", DatabaseType::CouchDb),
Some(SearchEngineQueryRisk::Write)
);
assert_eq!(
classify_search_engine_query_risk("PUT /mydb/doc1\n{\"name\":\"doc\"}", DatabaseType::CouchDb),
Some(SearchEngineQueryRisk::Write)
);
assert_eq!(
classify_search_engine_query_risk("PUT /mydb", DatabaseType::CouchDb),
Some(SearchEngineQueryRisk::Dangerous)
);
assert_eq!(
classify_search_engine_query_risk("DELETE /mydb", DatabaseType::CouchDb),
Some(SearchEngineQueryRisk::Dangerous)
);
assert_eq!(
classify_search_engine_query_risk("DELETE /mydb/doc1", DatabaseType::CouchDb),
Some(SearchEngineQueryRisk::Write)
);
assert_eq!(
classify_search_engine_query_risk(
"POST /mycore/schema\n{\"add-field\":{\"name\":\"x\",\"type\":\"string\"}}",
DatabaseType::Solr
),
Some(SearchEngineQueryRisk::Dangerous)
);
// Non-Solr types never take this classifier.
assert!(classify_search_engine_query_risk("POST /mycore/update", DatabaseType::Postgres).is_none());
}
#[test]
fn excludes_victoriametrics_from_sql_query_paths() {
assert!(!supports_sql_query(DatabaseType::VictoriaMetrics));
assert!(supports_sql_query(DatabaseType::Postgres));
assert_eq!(
check_read_only(
r#"{__name__="dbx_issue_5352_temperature_celsius"}[5m]"#,
"VictoriaMetrics",
DatabaseType::VictoriaMetrics,
),
Ok(())
);
}
#[test]
fn classify_salesforce_statement_risk_scoped_dml() {
let update = "DBX SALESFORCE DML\n{\"op\":\"update\",\"object\":\"Account\",\"id\":\"001x\",\"fields\":{\"Name\":\"Acme\"}}";
assert_eq!(classify_salesforce_statement_risk(update), SalesforceStatementRisk::ScopedWrite);
let delete = "DBX SALESFORCE DML\n{\"op\":\"delete\",\"object\":\"Account\",\"id\":\"001x\"}";
assert_eq!(classify_salesforce_statement_risk(delete), SalesforceStatementRisk::ScopedWrite);
let insert = "DBX SALESFORCE DML\n{\"op\":\"insert\",\"object\":\"Lead\",\"fields\":{\"Company\":\"Acme\"}}";
assert_eq!(classify_salesforce_statement_risk(insert), SalesforceStatementRisk::ScopedWrite);
}
#[test]
fn classify_salesforce_statement_risk_unscoped_dml_fails_closed() {
// No Id: nothing bounds the write, so it is not a safe write.
assert_eq!(
classify_salesforce_statement_risk(
"DBX SALESFORCE DML\n{\"op\":\"update\",\"object\":\"Account\",\"fields\":{}}"
),
SalesforceStatementRisk::OpaqueWrite
);
// Unknown op / unknown header / unparseable body all fail closed.
assert_eq!(
classify_salesforce_statement_risk(
"DBX SALESFORCE DML\n{\"op\":\"upsert\",\"object\":\"Account\",\"id\":\"001x\"}"
),
SalesforceStatementRisk::OpaqueWrite
);
assert_eq!(
classify_salesforce_statement_risk("DBX SALESFORCE FUTURE\n{}"),
SalesforceStatementRisk::OpaqueWrite
);
assert_eq!(
classify_salesforce_statement_risk("DBX SALESFORCE DML\n{not json"),
SalesforceStatementRisk::OpaqueWrite
);
}
#[test]
fn classify_salesforce_statement_risk_dml_body_survives_comment_lookalikes() {
// A `--` inside a field value must not be stripped as a line comment:
// the pseudo-command branch reads the raw text, so the body still parses
// and the write stays scoped.
let dml = "DBX SALESFORCE DML\n{\"op\":\"update\",\"object\":\"Account\",\"id\":\"001x\",\"fields\":{\"Description\":\"a--b\"}}";
assert_eq!(classify_salesforce_statement_risk(dml), SalesforceStatementRisk::ScopedWrite);
}
#[test]
fn classify_salesforce_statement_risk_soql_is_read() {
assert_eq!(classify_salesforce_statement_risk("SELECT Id FROM Account"), SalesforceStatementRisk::Read);
assert_eq!(classify_salesforce_statement_risk(" select Name from Lead "), SalesforceStatementRisk::Read);
// SOQL forms sqlparser cannot parse must still read as reads.
assert_eq!(
classify_salesforce_statement_risk("SELECT FIELDS(ALL) FROM Account LIMIT 200"),
SalesforceStatementRisk::Read
);
assert_eq!(
classify_salesforce_statement_risk("SELECT Id FROM Opportunity WHERE CloseDate = LAST_N_DAYS:7"),
SalesforceStatementRisk::Read
);
assert_eq!(
classify_salesforce_statement_risk("SELECT Id, (SELECT Id FROM Contacts) FROM Account"),
SalesforceStatementRisk::Read
);
assert_eq!(
classify_salesforce_statement_risk("/* report */ SELECT Id FROM Account"),
SalesforceStatementRisk::Read
);
}
#[test]
fn classify_salesforce_statement_risk_non_soql_is_opaque_write() {
// The driver only accepts SELECT and DBX pseudo-commands; anything else
// is a client error, not something to classify as a harmless read.
assert_eq!(classify_salesforce_statement_risk("DELETE FROM Account"), SalesforceStatementRisk::OpaqueWrite);
assert_eq!(
classify_salesforce_statement_risk("FIND {Acme} IN ALL FIELDS"),
SalesforceStatementRisk::OpaqueWrite
);
assert_eq!(classify_salesforce_statement_risk(""), SalesforceStatementRisk::OpaqueWrite);
}
#[test]
fn is_write_sql_for_database_salesforce_soql_is_read() {
assert!(!is_write_sql_for_database("SELECT Id FROM Account", DatabaseType::Salesforce));
}
#[test]
fn is_write_sql_for_database_salesforce_literals_are_not_writes() {
// The generic keyword scan would see DELETE/UPDATE here; SOQL cannot write.
assert!(!is_write_sql_for_database(
"SELECT Id FROM Case WHERE Subject = 'Delete request'",
DatabaseType::Salesforce
));
assert!(!is_write_sql_for_database("SELECT Update__c FROM Lead", DatabaseType::Salesforce));
}
#[test]
fn is_write_sql_for_database_salesforce_dml_is_write() {
let dml = "DBX SALESFORCE DML\n{\"op\":\"delete\",\"object\":\"Account\",\"id\":\"x\"}";
assert!(is_write_sql_for_database(dml, DatabaseType::Salesforce));
}
#[test]
fn mysql_explicit_transaction_openers_are_recognized() {
for sql in [
"BEGIN",
"begin",
"BEGIN;",
" BEGIN ",
"BEGIN WORK",
"BEGIN WORK;",
"-- open a transaction\nBEGIN",
"/* keep */ START TRANSACTION",
"START TRANSACTION",
"start transaction;",
"START TRANSACTION READ ONLY",
"START TRANSACTION READ WRITE",
"START TRANSACTION WITH CONSISTENT SNAPSHOT",
] {
assert!(mysql_statement_opens_explicit_transaction(sql), "expected opener: {sql}");
}
}
#[test]
fn mysql_non_openers_are_not_treated_as_explicit_transactions() {
for sql in [
"",
" ",
";",
"SELECT 1",
"COMMIT",
"ROLLBACK",
"BEGIN;\nUPDATE t SET a = 1;",
"BEGIN\nDECLARE x INT;\nEND",
"BEGIN END",
"START REPLICA",
"SET autocommit = 0",
"SELECT 'BEGIN' FROM t",
] {
assert!(!mysql_statement_opens_explicit_transaction(sql), "expected non-opener: {sql}");
}
}
}