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

1980 lines
76 KiB
Rust

use serde::{Deserialize, Serialize};
use sqlparser::dialect::{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_format_matches_generated_sql` 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",
}
}
}
#[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 {
if !supports_explain_plan(options.database_type) {
return explain_err("unsupported");
}
let source = strip_trailing_semicolons(options.sql.trim());
if source.is_empty() {
return explain_err("empty");
}
if !is_safe_explain_sql_for_database(&source, options.database_type) {
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 options.database_type {
// ANALYZE executes the statement; is_safe_explain_sql has already limited
// the source to SELECT/WITH/TABLE/VALUES. MongoDb shares the plain arm only.
Some(DatabaseType::Postgres) if options.analyze == Some(true) => {
format!("EXPLAIN (ANALYZE, FORMAT JSON) {source}")
}
Some(DatabaseType::Postgres | DatabaseType::MongoDb) => {
format!("EXPLAIN (FORMAT JSON) {source}")
}
Some(DatabaseType::Dameng | DatabaseType::Questdb) => {
format!("EXPLAIN {source}")
}
Some(DatabaseType::Doris) => format!("EXPLAIN {source}"),
Some(DatabaseType::Oracle) => 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.
Some(DatabaseType::SqlServer) if options.analyze == Some(true) => {
format!("SET STATISTICS XML ON;\nGO\n{source}\nGO\nSET STATISTICS XML OFF;")
}
Some(DatabaseType::SqlServer) => {
format!("SET SHOWPLAN_XML ON;\nGO\n{source}\nGO\nSET SHOWPLAN_XML OFF;")
}
Some(DatabaseType::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}")
}
_ => 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
}
/// How a consumer should decode the estimated plan `build_explain_sql` asks
/// the server for. Dameng and Oracle plans reach DBX through the driver's
/// native plan text rather than a generated `EXPLAIN` statement, and every
/// agent plan is text.
pub fn estimated_plan_format(database_type: Option<DatabaseType>) -> EstimatedPlanFormat {
match database_type {
Some(DatabaseType::SqlServer) => EstimatedPlanFormat::Xml,
Some(DatabaseType::Dameng | DatabaseType::Questdb | DatabaseType::Doris | DatabaseType::Oracle) => {
EstimatedPlanFormat::Text
}
_ => EstimatedPlanFormat::Json,
}
}
pub fn supports_explain_plan(database_type: Option<DatabaseType>) -> bool {
matches!(
database_type,
Some(
DatabaseType::Mysql
| DatabaseType::Doris
| DatabaseType::Postgres
| DatabaseType::Questdb
| DatabaseType::Dameng
| DatabaseType::Oracle
| DatabaseType::OceanbaseOracle
| DatabaseType::SqlServer
)
)
}
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 {
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)
}
/// 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::Qdrant
| DatabaseType::Milvus
| DatabaseType::Weaviate
| DatabaseType::ChromaDb
| DatabaseType::InfluxDb
| DatabaseType::InfluxDb3
| DatabaseType::VictoriaMetrics
| DatabaseType::Neo4j
| 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| {
source == *keyword || source.starts_with(&format!("{keyword} ")) || source.starts_with(&format!("{keyword}\n"))
})
}
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))
}
pub fn contains_dangerous_sql_keyword(sql: &str) -> bool {
let source = strip_sql_comments_and_literals(sql).to_lowercase();
["drop", "delete", "truncate", "alter", "update", "merge", "replace", "insert", "create"]
.iter()
.any(|keyword| contains_word(&source, keyword))
}
/// 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 {
// 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 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,
}
}
#[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 !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 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::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);
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);
// 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
}
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())
}
/// 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. Returns true
/// only for an ordinary top-level read that DBX can prove harmless: a single
/// top-level `SELECT` (not `WITH`) that after Oracle-aware comment/literal
/// stripping contains no row-locking, transaction-control, DDL/DCL, `CALL`/
/// `EXEC`, PL/SQL block, `EXECUTE IMMEDIATE`, database-link, sequence
/// pseudocolumn (`NEXTVAL`/`CURRVAL`), function-invocation, or subquery
/// syntax. Any lexical ambiguity is **not** proven read-only; the classifier
/// deliberately whitelists no functions because user/package function side
/// effects cannot be proven and would create an unbounded compatibility
/// surface.
pub fn is_oracle_proven_read_only_statement(sql: &str) -> bool {
let cleaned = strip_sql_comments_and_literals(sql);
let trimmed = cleaned.trim_start();
if trimmed.is_empty() {
return false;
}
let upper = trimmed.to_uppercase();
// Single top-level SELECT only (WITH is excluded because data-modifying
// CTEs can hide writes in a row-returning path).
if !starts_with_keyword(&upper, "SELECT") {
return false;
}
let lower = cleaned.to_lowercase();
// Function invocation or subquery syntax: any '(' after literal stripping.
// This deliberately excludes SELECT upper(name), COUNT(*), package
// functions, and subqueries DBX cannot prove harmless.
if lower.contains('(') {
return false;
}
// Database link: '@' cannot appear in an unquoted Oracle identifier, so any
// remaining '@' after literal stripping is a remote-object reference.
if lower.contains('@') {
return false;
}
// Word-boundary forbidden keywords. These are Oracle keywords/reserved
// words, so they cannot be legitimate unquoted identifiers inside a simple
// read; a quoted occurrence is already stripped to spaces above.
const FORBIDDEN_WORDS: &[&str] = &[
// row locking
"for",
"update",
"lock",
// transaction control
"begin",
"commit",
"rollback",
"savepoint",
// DDL/DCL
"create",
"alter",
"drop",
"truncate",
"rename",
"grant",
"revoke",
"comment",
"purge",
"audit",
"noaudit",
"analyze",
"flashback",
"merge",
"insert",
"delete",
// set operators (a UNION is not a single top-level SELECT)
"union",
"minus",
"intersect",
// stored procedures / dynamic SQL / PL/SQL blocks
"call",
"exec",
"execute",
"declare",
// sequence pseudocolumns
"nextval",
"currval",
// SELECT ... INTO is PL/SQL-only in Oracle
"into",
];
!FORBIDDEN_WORDS.iter().any(|word| contains_word(&lower, word))
}
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).0
}
fn strip_sql_comments_and_literals_with_metadata(sql: &str, detect_mysql_executable_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;
let mut has_mysql_executable_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 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 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 detect_mysql_executable_comments && is_mysql_executable_comment_start(&chars) {
has_mysql_executable_comment = true;
}
in_block_comment = true;
continue;
}
if ch == '\'' {
in_single_quote = true;
output.push(' ');
continue;
}
if ch == '"' {
in_double_quote = true;
output.push(' ');
continue;
}
if ch == '`' {
in_backtick_quote = true;
output.push(' ');
continue;
}
output.push(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 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() {
assert!(is_oracle_proven_read_only_statement("SELECT * FROM table"));
assert!(is_oracle_proven_read_only_statement("select id, name from users"));
assert!(is_oracle_proven_read_only_statement("SELECT * FROM users WHERE id = 1"));
assert!(is_oracle_proven_read_only_statement("SELECT * FROM users ORDER BY id"));
assert!(is_oracle_proven_read_only_statement("SELECT * FROM users FETCH FIRST 10 ROWS ONLY"));
assert!(is_oracle_proven_read_only_statement("SELECT 1"));
assert!(is_oracle_proven_read_only_statement("SELECT ROWNUM FROM dual"));
assert!(is_oracle_proven_read_only_statement("SELECT * FROM users"));
// Comment/literal stripping hides keywords that are not part of the SQL.
assert!(is_oracle_proven_read_only_statement("-- comment\nSELECT * FROM users"));
assert!(is_oracle_proven_read_only_statement("SELECT 'update' FROM dual"));
assert!(is_oracle_proven_read_only_statement("SELECT \"select\" FROM t"));
assert!(is_oracle_proven_read_only_statement("SELECT * FROM user_update"));
}
#[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}");
}
}
#[test]
fn oracle_proven_read_only_rejects_functions_and_subqueries() {
for sql in [
"SELECT upper(name) FROM users",
"SELECT count(*) FROM users",
"SELECT pkg.fn() FROM dual",
"SELECT * FROM users WHERE id IN (SELECT id FROM orders)",
"SELECT * FROM (SELECT * FROM users)",
"SELECT NVL(name, 'x') FROM users",
] {
assert!(!is_oracle_proven_read_only_statement(sql), "expected rejected: {sql}");
}
}
#[test]
fn oracle_proven_read_only_rejects_empty_and_ambiguous_input() {
assert!(!is_oracle_proven_read_only_statement(""));
assert!(!is_oracle_proven_read_only_statement(" "));
assert!(!is_oracle_proven_read_only_statement("-- only a comment"));
assert!(!is_oracle_proven_read_only_statement("SELECT * FROM t WHERE a IN (1, 2)"));
assert!(!is_oracle_proven_read_only_statement("SELECT * FROM t WHERE b = 'x' AND c IN (3)"));
assert!(!is_oracle_proven_read_only_statement("SELECT 1 UNION SELECT 2"));
}
#[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_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::Dameng, "EXPLAIN SELECT 1"),
(DatabaseType::Questdb, "EXPLAIN SELECT 1"),
(DatabaseType::Oracle, "EXPLAIN PLAN FOR 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 earlier by supports_explain_plan, so it never
// reaches the match arm it nominally shares with Postgres.
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 estimated_plan_format_matches_generated_sql() {
// Every dialect `supports_explain_plan` advertises must declare a payload
// format that the statement `build_explain_sql` generates really produces.
for database_type in
DatabaseType::ALL.iter().copied().filter(|database_type| supports_explain_plan(Some(*database_type)))
{
let explain_sql = build_explain_sql(ExplainSqlOptions {
database_type: Some(database_type),
format: None,
analyze: None,
sql: "SELECT 1".to_string(),
})
.sql
.expect("a supported dialect always builds EXPLAIN SQL");
match estimated_plan_format(Some(database_type)) {
EstimatedPlanFormat::Json => assert!(
explain_sql.contains("FORMAT JSON") || explain_sql.contains("FORMAT=JSON"),
"{database_type:?} declares a JSON plan but generated '{explain_sql}'"
),
EstimatedPlanFormat::Xml => assert!(
explain_sql.contains("SHOWPLAN_XML"),
"{database_type:?} declares an XML plan but generated '{explain_sql}'"
),
EstimatedPlanFormat::Text => assert!(
explain_sql.starts_with("EXPLAIN ") && !explain_sql.contains("FORMAT"),
"{database_type:?} declares a text plan but generated '{explain_sql}'"
),
}
}
assert_eq!(estimated_plan_format(None), EstimatedPlanFormat::Json);
assert_eq!(estimated_plan_format(Some(DatabaseType::Dameng)), EstimatedPlanFormat::Text);
assert_eq!(estimated_plan_format(Some(DatabaseType::Questdb)), EstimatedPlanFormat::Text);
assert_eq!(estimated_plan_format(Some(DatabaseType::Doris)), EstimatedPlanFormat::Text);
assert_eq!(estimated_plan_format(Some(DatabaseType::Oracle)), EstimatedPlanFormat::Text);
assert_eq!(estimated_plan_format(Some(DatabaseType::SqlServer)), EstimatedPlanFormat::Xml);
assert_eq!(estimated_plan_format(Some(DatabaseType::Mysql)), EstimatedPlanFormat::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()) }
);
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_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_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_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_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 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(())
);
}
}