mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-24 05:30:07 +00:00
1762 lines
62 KiB
Rust
1762 lines
62 KiB
Rust
use serde::{Deserialize, Serialize};
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct EditableQueryInfo {
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub catalog: Option<String>,
|
|
#[serde(default, skip_serializing_if = "is_false")]
|
|
pub catalog_quoted: bool,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub schema: Option<String>,
|
|
pub schema_quoted: bool,
|
|
pub table_name: String,
|
|
pub table_name_quoted: bool,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub table_alias: Option<String>,
|
|
pub select_star: bool,
|
|
pub columns: Vec<EditableQueryColumn>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub sources: Option<Vec<EditableQuerySource>>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub editable_source_key: Option<String>,
|
|
#[serde(default, skip_serializing_if = "is_false")]
|
|
pub multi_source: bool,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub allow_insert: Option<bool>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub allow_insert_delete: Option<bool>,
|
|
#[serde(default, skip_serializing_if = "is_false")]
|
|
pub distinct: bool,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct EditableQueryColumn {
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub source_name: Option<String>,
|
|
pub source_name_quoted: bool,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub source_qualifier: Option<String>,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub source_key: Option<String>,
|
|
#[serde(default, skip_serializing_if = "is_false")]
|
|
pub star: bool,
|
|
pub result_name: String,
|
|
pub expression: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct EditableQuerySource {
|
|
pub key: String,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub catalog: Option<String>,
|
|
#[serde(default, skip_serializing_if = "is_false")]
|
|
pub catalog_quoted: bool,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub schema: Option<String>,
|
|
pub schema_quoted: bool,
|
|
pub table_name: String,
|
|
pub table_name_quoted: bool,
|
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
|
pub alias: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "kebab-case")]
|
|
pub enum QueryEditabilityReason {
|
|
NotSelect,
|
|
Cte,
|
|
SetOperation,
|
|
Aggregation,
|
|
ExternalSource,
|
|
ComplexSource,
|
|
ComputedColumns,
|
|
NoTable,
|
|
NoPrimaryKey,
|
|
PrimaryKeyNotReturned,
|
|
AliasedColumns,
|
|
MetadataUnavailable,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
|
#[serde(rename_all = "camelCase")]
|
|
pub struct QueryEditability {
|
|
pub editable: bool,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub analysis: Option<EditableQueryInfo>,
|
|
#[serde(skip_serializing_if = "Option::is_none")]
|
|
pub reason: Option<QueryEditabilityReason>,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct FromSource {
|
|
key: String,
|
|
catalog: Option<String>,
|
|
catalog_quoted: bool,
|
|
schema: Option<String>,
|
|
schema_quoted: bool,
|
|
table_name: String,
|
|
table_name_quoted: bool,
|
|
alias: Option<String>,
|
|
}
|
|
|
|
fn is_false(value: &bool) -> bool {
|
|
!*value
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct QualifiedIdentifier {
|
|
parts: Vec<Identifier>,
|
|
end: usize,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct Identifier {
|
|
value: String,
|
|
quoted: bool,
|
|
end: usize,
|
|
}
|
|
|
|
pub fn analyze_editable_query(sql: &str) -> Option<EditableQueryInfo> {
|
|
let result = analyze_editable_query_editability(sql);
|
|
if result.editable {
|
|
result.analysis
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
pub fn analyze_editable_query_editability(sql: &str) -> QueryEditability {
|
|
let normalized = strip_sql_comments(sql).trim().trim_end_matches(';').trim().to_string();
|
|
if normalized.is_empty() {
|
|
return not_editable(QueryEditabilityReason::NotSelect);
|
|
}
|
|
if starts_with_keyword(&normalized, "WITH") {
|
|
return not_editable(QueryEditabilityReason::Cte);
|
|
}
|
|
if !starts_with_keyword(&normalized, "SELECT") {
|
|
return not_editable(QueryEditabilityReason::NotSelect);
|
|
}
|
|
if has_top_level_keyword(&normalized, &["UNION", "INTERSECT", "EXCEPT", "MINUS"]) {
|
|
return not_editable(QueryEditabilityReason::SetOperation);
|
|
}
|
|
if has_top_level_semicolon(&normalized) {
|
|
return not_editable(QueryEditabilityReason::ComplexSource);
|
|
}
|
|
|
|
let Some(from_index) = find_top_level_keyword(&normalized, "FROM", 0) else {
|
|
return not_editable(QueryEditabilityReason::NoTable);
|
|
};
|
|
|
|
let raw_select_body = normalized["SELECT".len()..from_index].trim();
|
|
let (select_body, distinct) = if starts_with_keyword(raw_select_body, "DISTINCT") {
|
|
let body = raw_select_body["DISTINCT".len()..].trim_start();
|
|
// DISTINCT ON has database-specific row-selection semantics and cannot be
|
|
// treated as a plain projection whose rows map directly to base records.
|
|
if starts_with_keyword(body, "ON") {
|
|
return not_editable(QueryEditabilityReason::Aggregation);
|
|
}
|
|
(body, true)
|
|
} else {
|
|
(raw_select_body, false)
|
|
};
|
|
let select_body = strip_sql_server_top_clause(select_body);
|
|
|
|
let group_index = find_top_level_keyword(&normalized, "GROUP", from_index + "FROM".len());
|
|
let having_index = find_top_level_keyword(&normalized, "HAVING", from_index + "FROM".len());
|
|
if group_index.is_some() || having_index.is_some() {
|
|
return not_editable(QueryEditabilityReason::Aggregation);
|
|
}
|
|
|
|
let from_body_start = from_index + "FROM".len();
|
|
let for_index = find_top_level_keyword(&normalized, "FOR", from_body_start);
|
|
// Row-locking FOR clauses change concurrency behavior, not the base-row
|
|
// mapping. Output/read-only FOR modes must stay non-editable.
|
|
if for_index.is_some_and(|index| !is_row_lock_clause(&normalized[index..])) {
|
|
return not_editable(QueryEditabilityReason::ComplexSource);
|
|
}
|
|
let from_end = first_top_level_keyword_index(
|
|
&normalized,
|
|
&["WHERE", "ORDER", "LIMIT", "OFFSET", "FETCH", "FOR"],
|
|
from_body_start,
|
|
)
|
|
.unwrap_or(normalized.len());
|
|
let from_body = normalized[from_body_start..from_end].trim();
|
|
if is_external_from_source(from_body) {
|
|
return not_editable(QueryEditabilityReason::ExternalSource);
|
|
}
|
|
let sources = parse_from_sources(from_body);
|
|
if sources.is_empty() {
|
|
return not_editable(QueryEditabilityReason::ComplexSource);
|
|
}
|
|
let source = sources[0].clone();
|
|
|
|
let select_star = sources.len() == 1 && is_select_star(select_body, source.alias.as_deref());
|
|
let columns = if select_star { Vec::new() } else { parse_select_columns(select_body, &sources) };
|
|
if !select_star && columns.is_empty() {
|
|
return not_editable(QueryEditabilityReason::ComputedColumns);
|
|
}
|
|
if sources.len() > 1 && columns.iter().any(|column| column.star && column.source_key.is_none()) {
|
|
return not_editable(QueryEditabilityReason::ComplexSource);
|
|
}
|
|
|
|
let mut analysis = EditableQueryInfo {
|
|
catalog: source.catalog,
|
|
catalog_quoted: source.catalog_quoted,
|
|
schema: source.schema,
|
|
schema_quoted: source.schema_quoted,
|
|
table_name: source.table_name,
|
|
table_name_quoted: source.table_name_quoted,
|
|
table_alias: source.alias,
|
|
select_star,
|
|
columns,
|
|
sources: None,
|
|
editable_source_key: None,
|
|
multi_source: false,
|
|
// Updating an identified base row is safe, but insert/delete semantics are
|
|
// ambiguous when the displayed set is de-duplicated by the query.
|
|
allow_insert: distinct.then_some(false),
|
|
allow_insert_delete: distinct.then_some(false),
|
|
distinct,
|
|
};
|
|
if sources.len() > 1 {
|
|
analysis.sources = Some(sources.into_iter().map(EditableQuerySource::from).collect());
|
|
analysis.multi_source = true;
|
|
analysis.allow_insert_delete = Some(false);
|
|
}
|
|
|
|
QueryEditability { editable: true, analysis: Some(analysis), reason: None }
|
|
}
|
|
|
|
fn not_editable(reason: QueryEditabilityReason) -> QueryEditability {
|
|
QueryEditability { editable: false, analysis: None, reason: Some(reason) }
|
|
}
|
|
|
|
fn parse_select_columns(body: &str, sources: &[FromSource]) -> Vec<EditableQueryColumn> {
|
|
let mut columns = Vec::new();
|
|
let mut depth = 0i32;
|
|
let mut current = String::new();
|
|
let mut quote: Option<char> = None;
|
|
|
|
for ch in body.chars() {
|
|
if let Some(close) = quote {
|
|
current.push(ch);
|
|
if ch == close {
|
|
quote = None;
|
|
}
|
|
continue;
|
|
}
|
|
|
|
match ch {
|
|
'\'' | '"' | '`' => quote = Some(ch),
|
|
'[' => quote = Some(']'),
|
|
'(' => depth += 1,
|
|
')' => depth -= 1,
|
|
',' if depth == 0 => {
|
|
let Some(column) = parse_select_column(current.trim(), sources) else {
|
|
return Vec::new();
|
|
};
|
|
columns.push(column);
|
|
current.clear();
|
|
continue;
|
|
}
|
|
_ => {}
|
|
}
|
|
current.push(ch);
|
|
}
|
|
|
|
if !current.trim().is_empty() {
|
|
let Some(column) = parse_select_column(current.trim(), sources) else {
|
|
return Vec::new();
|
|
};
|
|
columns.push(column);
|
|
}
|
|
|
|
columns
|
|
}
|
|
|
|
fn parse_select_column(column: &str, sources: &[FromSource]) -> Option<EditableQueryColumn> {
|
|
if let Some(star) = parse_star_select_column(column, sources) {
|
|
return Some(star);
|
|
}
|
|
let Some(source) = parse_qualified_identifier(column) else {
|
|
return parse_computed_select_column(column);
|
|
};
|
|
let rest = &column[source.end..];
|
|
let Some(alias) = parse_column_alias(rest) else {
|
|
return parse_computed_select_column(column);
|
|
};
|
|
let source_name_part = source.parts.last()?;
|
|
let source_name = source_name_part.value.clone();
|
|
let qualifier = if source.parts.len() >= 2 {
|
|
source.parts.get(source.parts.len() - 2).map(|part| part.value.clone())
|
|
} else {
|
|
None
|
|
};
|
|
let source_key = qualifier.as_deref().and_then(|qualifier| source_key_for_qualifier(sources, qualifier));
|
|
Some(EditableQueryColumn {
|
|
source_name: Some(source_name.clone()),
|
|
source_name_quoted: source_name_part.quoted,
|
|
source_qualifier: qualifier,
|
|
source_key,
|
|
star: false,
|
|
result_name: alias.unwrap_or(source_name),
|
|
expression: column[..source.end].trim().to_string(),
|
|
})
|
|
}
|
|
|
|
fn parse_star_select_column(column: &str, sources: &[FromSource]) -> Option<EditableQueryColumn> {
|
|
let trimmed = column.trim();
|
|
if trimmed == "*" {
|
|
// An unqualified `*` is unambiguous when the query has exactly one
|
|
// source, so it can bind to that source like a qualified `alias.*`
|
|
// would. Without this, the star projection carries no source_key and
|
|
// the ordinal column-comment resolver (apps/desktop TS side) cannot
|
|
// expand it against the table's columns, misaligning every result
|
|
// column from that point on — e.g. `SELECT *, amount FROM orders`
|
|
// loses all result-column comments even though the table has no
|
|
// ambiguity to resolve.
|
|
let source_key = match sources {
|
|
[only] => Some(only.key.clone()),
|
|
_ => None,
|
|
};
|
|
return Some(EditableQueryColumn {
|
|
source_name: None,
|
|
source_name_quoted: false,
|
|
source_qualifier: None,
|
|
source_key,
|
|
star: true,
|
|
result_name: "*".to_string(),
|
|
expression: trimmed.to_string(),
|
|
});
|
|
}
|
|
let qualifier = read_identifier(trimmed, 0)?;
|
|
let mut pos = skip_whitespace(trimmed, qualifier.end);
|
|
if !trimmed[pos..].starts_with('.') {
|
|
return None;
|
|
}
|
|
pos = skip_whitespace(trimmed, pos + 1);
|
|
if trimmed[pos..].trim() != "*" {
|
|
return None;
|
|
}
|
|
let source_key = source_key_for_qualifier(sources, &qualifier.value);
|
|
Some(EditableQueryColumn {
|
|
source_name: None,
|
|
source_name_quoted: false,
|
|
source_qualifier: Some(qualifier.value),
|
|
source_key,
|
|
star: true,
|
|
result_name: "*".to_string(),
|
|
expression: trimmed.to_string(),
|
|
})
|
|
}
|
|
|
|
fn parse_computed_select_column(column: &str) -> Option<EditableQueryColumn> {
|
|
let alias = parse_expression_alias(column).or_else(|| {
|
|
let expression = column.trim();
|
|
looks_like_computed_expression(expression)
|
|
.then(|| ExpressionAlias { expression: expression.to_string(), result_name: expression.to_string() })
|
|
})?;
|
|
Some(EditableQueryColumn {
|
|
source_name: None,
|
|
source_name_quoted: false,
|
|
source_qualifier: None,
|
|
source_key: None,
|
|
star: false,
|
|
result_name: alias.result_name,
|
|
expression: alias.expression,
|
|
})
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct ExpressionAlias {
|
|
expression: String,
|
|
result_name: String,
|
|
}
|
|
|
|
fn parse_expression_alias(column: &str) -> Option<ExpressionAlias> {
|
|
let trimmed_end = column.trim_end();
|
|
for (index, _) in trimmed_end.match_indices(['A', 'a']) {
|
|
let candidate = &trimmed_end[index..];
|
|
if !candidate.get(..2).is_some_and(|prefix| prefix.eq_ignore_ascii_case("AS")) {
|
|
continue;
|
|
}
|
|
let before = if index == 0 { "" } else { &trimmed_end[..index] };
|
|
if before.chars().last().is_some_and(is_identifier_char) {
|
|
continue;
|
|
}
|
|
let after_as = &candidate[2..];
|
|
if !after_as.chars().next().is_some_and(char::is_whitespace) {
|
|
continue;
|
|
}
|
|
let alias_text = after_as.trim();
|
|
let alias = read_select_alias(alias_text)?;
|
|
if alias.end != alias_text.len() {
|
|
continue;
|
|
}
|
|
let expression = trimmed_end[..index].trim().to_string();
|
|
if expression.is_empty() {
|
|
return None;
|
|
}
|
|
return Some(ExpressionAlias { expression, result_name: alias.value });
|
|
}
|
|
|
|
if let Some(index) = last_top_level_whitespace_start(trimmed_end) {
|
|
let alias_text = trimmed_end[index..].trim();
|
|
if let Some(alias) = read_select_alias(alias_text) {
|
|
let expression = trimmed_end[..index].trim();
|
|
// `a + b` has no alias: the prefix `a +` is incomplete, so the
|
|
// whole projection keeps the server's expression label.
|
|
if alias.end == alias_text.len()
|
|
&& (alias.quoted || !is_reserved_implicit_alias(&alias.value))
|
|
&& looks_like_computed_expression(expression)
|
|
{
|
|
return Some(ExpressionAlias { expression: expression.to_string(), result_name: alias.value });
|
|
}
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn last_top_level_whitespace_start(text: &str) -> Option<usize> {
|
|
let mut last = None;
|
|
let mut depth = 0i32;
|
|
let mut quote: Option<char> = None;
|
|
let mut in_whitespace = false;
|
|
|
|
for (index, ch) in text.char_indices() {
|
|
if let Some(close) = quote {
|
|
if ch == close {
|
|
quote = None;
|
|
}
|
|
continue;
|
|
}
|
|
match ch {
|
|
'\'' | '"' | '`' => quote = Some(ch),
|
|
'[' => quote = Some(']'),
|
|
'(' => depth += 1,
|
|
')' => depth -= 1,
|
|
_ => {}
|
|
}
|
|
let top_level_whitespace = depth == 0 && ch.is_whitespace();
|
|
if top_level_whitespace && !in_whitespace {
|
|
last = Some(index);
|
|
}
|
|
in_whitespace = top_level_whitespace;
|
|
}
|
|
last
|
|
}
|
|
|
|
fn looks_like_computed_expression(expression: &str) -> bool {
|
|
let expression = expression.trim();
|
|
if expression.is_empty() || !has_balanced_expression_delimiters(expression) {
|
|
return false;
|
|
}
|
|
if parse_qualified_identifier(expression).is_some_and(|identifier| identifier.end == expression.len()) {
|
|
return false;
|
|
}
|
|
if expression.chars().next_back().is_some_and(|ch| {
|
|
matches!(ch, '+' | '-' | '*' | '/' | '%' | '=' | '<' | '>' | '!' | '|' | '&' | '^' | ',' | '.' | ':')
|
|
}) {
|
|
return false;
|
|
}
|
|
let final_word = expression.split_whitespace().next_back().unwrap_or_default().to_ascii_uppercase();
|
|
if matches!(
|
|
final_word.as_str(),
|
|
"AND"
|
|
| "OR"
|
|
| "XOR"
|
|
| "NOT"
|
|
| "LIKE"
|
|
| "ILIKE"
|
|
| "IS"
|
|
| "IN"
|
|
| "BETWEEN"
|
|
| "WHEN"
|
|
| "THEN"
|
|
| "ELSE"
|
|
| "AS"
|
|
| "COLLATE"
|
|
| "AT"
|
|
| "DIV"
|
|
| "MOD"
|
|
| "REGEXP"
|
|
| "RLIKE"
|
|
) {
|
|
return false;
|
|
}
|
|
|
|
let Some(source) = parse_qualified_identifier(expression) else {
|
|
return true;
|
|
};
|
|
if source.end == expression.len() {
|
|
return false;
|
|
}
|
|
let rest = expression[source.end..].trim_start();
|
|
matches!(
|
|
rest.chars().next(),
|
|
Some('(' | '+' | '-' | '*' | '/' | '%' | '=' | '<' | '>' | '!' | '|' | '&' | '^' | ':' | '?')
|
|
) || ["IS", "IN", "LIKE", "ILIKE", "BETWEEN", "COLLATE", "AT", "DIV", "MOD", "REGEXP", "RLIKE"]
|
|
.iter()
|
|
.any(|keyword| starts_with_keyword(rest, keyword))
|
|
|| starts_with_keyword(expression, "CASE")
|
|
|| starts_with_keyword(expression, "NOT")
|
|
}
|
|
|
|
fn has_balanced_expression_delimiters(expression: &str) -> bool {
|
|
let mut depth = 0i32;
|
|
let mut quote: Option<char> = None;
|
|
for ch in expression.chars() {
|
|
if let Some(close) = quote {
|
|
if ch == close {
|
|
quote = None;
|
|
}
|
|
continue;
|
|
}
|
|
match ch {
|
|
'\'' | '"' | '`' => quote = Some(ch),
|
|
'[' => quote = Some(']'),
|
|
'(' => depth += 1,
|
|
')' => {
|
|
depth -= 1;
|
|
if depth < 0 {
|
|
return false;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
depth == 0 && quote.is_none()
|
|
}
|
|
|
|
fn is_reserved_implicit_alias(alias: &str) -> bool {
|
|
matches!(
|
|
alias.to_ascii_uppercase().as_str(),
|
|
"ALL"
|
|
| "AND"
|
|
| "AS"
|
|
| "ASC"
|
|
| "BETWEEN"
|
|
| "CASE"
|
|
| "COLLATE"
|
|
| "DESC"
|
|
| "DISTINCT"
|
|
| "ELSE"
|
|
| "END"
|
|
| "FALSE"
|
|
| "FROM"
|
|
| "IN"
|
|
| "IS"
|
|
| "LIKE"
|
|
| "LIMIT"
|
|
| "NOT"
|
|
| "NULL"
|
|
| "OFFSET"
|
|
| "OR"
|
|
| "ORDER"
|
|
| "THEN"
|
|
| "TRUE"
|
|
| "WHEN"
|
|
| "WHERE"
|
|
| "XOR"
|
|
)
|
|
}
|
|
|
|
fn parse_column_alias(rest: &str) -> Option<Option<String>> {
|
|
let trimmed = rest.trim();
|
|
if trimmed.is_empty() {
|
|
return Some(None);
|
|
}
|
|
let alias_text = strip_leading_as(trimmed).unwrap_or(trimmed).trim();
|
|
let alias = read_select_alias(alias_text)?;
|
|
if alias.end != alias_text.len() {
|
|
return None;
|
|
}
|
|
Some(Some(alias.value))
|
|
}
|
|
|
|
fn read_select_alias(text: &str) -> Option<Identifier> {
|
|
let pos = skip_whitespace(text, 0);
|
|
if !text[pos..].starts_with('\'') {
|
|
return read_identifier(text, pos);
|
|
}
|
|
|
|
let mut value = String::new();
|
|
let mut chars = text[pos + 1..].char_indices().peekable();
|
|
while let Some((offset, ch)) = chars.next() {
|
|
if ch != '\'' {
|
|
value.push(ch);
|
|
continue;
|
|
}
|
|
if chars.peek().is_some_and(|(_, next)| *next == '\'') {
|
|
chars.next();
|
|
value.push('\'');
|
|
continue;
|
|
}
|
|
return Some(Identifier { value, quoted: true, end: pos + 1 + offset + ch.len_utf8() });
|
|
}
|
|
None
|
|
}
|
|
|
|
fn strip_leading_as(text: &str) -> Option<&str> {
|
|
let prefix = text.get(..2)?;
|
|
if !prefix.eq_ignore_ascii_case("AS") {
|
|
return None;
|
|
}
|
|
let rest = &text[2..];
|
|
if rest.chars().next().is_some_and(char::is_whitespace) {
|
|
Some(rest)
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
fn strip_sql_server_top_clause(body: &str) -> &str {
|
|
let trimmed = body.trim_start();
|
|
if !starts_with_keyword(trimmed, "TOP") {
|
|
return trimmed;
|
|
}
|
|
|
|
let mut pos = skip_whitespace(trimmed, "TOP".len());
|
|
if trimmed[pos..].starts_with('(') {
|
|
let mut depth = 0i32;
|
|
let mut quote: Option<char> = None;
|
|
let mut end = None;
|
|
for (offset, ch) in trimmed[pos..].char_indices() {
|
|
if let Some(close) = quote {
|
|
if ch == close {
|
|
quote = None;
|
|
}
|
|
continue;
|
|
}
|
|
match ch {
|
|
'\'' | '"' | '`' => quote = Some(ch),
|
|
'[' => quote = Some(']'),
|
|
'(' => depth += 1,
|
|
')' => {
|
|
depth -= 1;
|
|
if depth == 0 {
|
|
end = Some(pos + offset + ch.len_utf8());
|
|
break;
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
let Some(end) = end else {
|
|
return trimmed;
|
|
};
|
|
pos = end;
|
|
} else {
|
|
let number_end = trimmed[pos..]
|
|
.char_indices()
|
|
.take_while(|(_, ch)| ch.is_ascii_digit())
|
|
.last()
|
|
.map(|(offset, ch)| pos + offset + ch.len_utf8());
|
|
let Some(end) = number_end else {
|
|
return trimmed;
|
|
};
|
|
pos = end;
|
|
}
|
|
|
|
pos = skip_whitespace(trimmed, pos);
|
|
if starts_with_keyword_at(trimmed, pos, "PERCENT") {
|
|
pos = skip_whitespace(trimmed, pos + "PERCENT".len());
|
|
}
|
|
if starts_with_keyword_at(trimmed, pos, "WITH") {
|
|
let ties_pos = skip_whitespace(trimmed, pos + "WITH".len());
|
|
if starts_with_keyword_at(trimmed, ties_pos, "TIES") {
|
|
pos = skip_whitespace(trimmed, ties_pos + "TIES".len());
|
|
}
|
|
}
|
|
let remaining = trimmed[pos..].trim_start();
|
|
if remaining.is_empty() {
|
|
trimmed
|
|
} else {
|
|
remaining
|
|
}
|
|
}
|
|
|
|
fn is_select_star(body: &str, alias: Option<&str>) -> bool {
|
|
let trimmed = body.trim();
|
|
if trimmed == "*" {
|
|
return true;
|
|
}
|
|
let Some(alias) = alias else {
|
|
return false;
|
|
};
|
|
let Some((prefix, suffix)) = trimmed.split_once('.') else {
|
|
return false;
|
|
};
|
|
prefix.trim().eq_ignore_ascii_case(alias) && suffix.trim() == "*"
|
|
}
|
|
|
|
fn parse_from_sources(body: &str) -> Vec<FromSource> {
|
|
if body.is_empty() {
|
|
return Vec::new();
|
|
}
|
|
|
|
let mut sources = Vec::new();
|
|
let Some(first) = parse_table_source_at(body, 0, sources.len()) else {
|
|
return Vec::new();
|
|
};
|
|
sources.push(first.0);
|
|
let mut pos = first.1;
|
|
|
|
while pos < body.len() {
|
|
pos = skip_whitespace(body, pos);
|
|
if pos >= body.len() {
|
|
break;
|
|
}
|
|
if body[pos..].starts_with(',') {
|
|
let Some(next) = parse_table_source_at(body, pos + 1, sources.len()) else {
|
|
return Vec::new();
|
|
};
|
|
sources.push(next.0);
|
|
pos = next.1;
|
|
continue;
|
|
}
|
|
let Some(join_index) = find_top_level_keyword(body, "JOIN", pos) else {
|
|
break;
|
|
};
|
|
let Some(next) = parse_table_source_at(body, join_index + "JOIN".len(), sources.len()) else {
|
|
return Vec::new();
|
|
};
|
|
sources.push(next.0);
|
|
pos = next.1;
|
|
}
|
|
|
|
sources
|
|
}
|
|
|
|
fn parse_table_source_at(text: &str, start: usize, index: usize) -> Option<(FromSource, usize)> {
|
|
let pos = skip_whitespace(text, start);
|
|
if text[pos..].starts_with('\'') || text[pos..].starts_with('(') {
|
|
return None;
|
|
}
|
|
let ident = parse_qualified_identifier(&text[pos..])?;
|
|
if ident.parts.is_empty() || ident.parts.len() > 3 {
|
|
return None;
|
|
}
|
|
let mut end = pos + ident.end;
|
|
let tail_pos = skip_whitespace(text, end);
|
|
if text[tail_pos..].starts_with('(') {
|
|
return None;
|
|
}
|
|
let alias = if starts_with_keyword_at(text, tail_pos, "AS") {
|
|
let alias_ident = read_identifier(text, tail_pos + 2)?;
|
|
end = alias_ident.end;
|
|
Some(alias_ident.value)
|
|
} else if table_source_terminator_at(text, tail_pos) {
|
|
None
|
|
} else {
|
|
let alias_ident = read_identifier(text, tail_pos)?;
|
|
end = alias_ident.end;
|
|
Some(alias_ident.value)
|
|
};
|
|
|
|
let table = ident.parts.last()?;
|
|
let table_name = table.value.clone();
|
|
let table_name_quoted = table.quoted;
|
|
let (schema, schema_quoted) = if ident.parts.len() >= 2 {
|
|
let schema = &ident.parts[ident.parts.len() - 2];
|
|
(Some(schema.value.clone()), schema.quoted)
|
|
} else {
|
|
(None, false)
|
|
};
|
|
let (catalog, catalog_quoted) = if ident.parts.len() == 3 {
|
|
let catalog = &ident.parts[0];
|
|
(Some(catalog.value.clone()), catalog.quoted)
|
|
} else {
|
|
(None, false)
|
|
};
|
|
let key = format!("{}:{}", alias.as_deref().unwrap_or(&table_name), index);
|
|
Some((
|
|
FromSource { key, catalog, catalog_quoted, schema, schema_quoted, table_name, table_name_quoted, alias },
|
|
end,
|
|
))
|
|
}
|
|
|
|
fn is_external_from_source(body: &str) -> bool {
|
|
let trimmed = body.trim();
|
|
is_single_quoted_source_with_optional_alias(trimmed) || starts_with_table_function(trimmed)
|
|
}
|
|
|
|
fn table_source_terminator_at(text: &str, pos: usize) -> bool {
|
|
let pos = skip_whitespace(text, pos);
|
|
if pos >= text.len() || text[pos..].starts_with(',') {
|
|
return true;
|
|
}
|
|
["ON", "USING", "JOIN", "LEFT", "RIGHT", "INNER", "FULL", "CROSS", "OUTER", "NATURAL"]
|
|
.iter()
|
|
.any(|keyword| starts_with_keyword_at(text, pos, keyword))
|
|
}
|
|
|
|
fn is_row_lock_clause(text: &str) -> bool {
|
|
if !starts_with_keyword(text, "FOR") {
|
|
return false;
|
|
}
|
|
let mode_start = skip_whitespace(text, "FOR".len());
|
|
if starts_with_keyword_at(text, mode_start, "UPDATE") || starts_with_keyword_at(text, mode_start, "SHARE") {
|
|
return true;
|
|
}
|
|
if starts_with_keyword_at(text, mode_start, "NO") {
|
|
let key_start = skip_whitespace(text, mode_start + "NO".len());
|
|
let update_start = skip_whitespace(text, key_start + "KEY".len());
|
|
return starts_with_keyword_at(text, key_start, "KEY") && starts_with_keyword_at(text, update_start, "UPDATE");
|
|
}
|
|
if starts_with_keyword_at(text, mode_start, "KEY") {
|
|
let share_start = skip_whitespace(text, mode_start + "KEY".len());
|
|
return starts_with_keyword_at(text, share_start, "SHARE");
|
|
}
|
|
false
|
|
}
|
|
|
|
fn starts_with_keyword_at(text: &str, pos: usize, keyword: &str) -> bool {
|
|
let start = skip_whitespace(text, pos);
|
|
let Some(candidate) = text.get(start..start + keyword.len()) else {
|
|
return false;
|
|
};
|
|
if !candidate.eq_ignore_ascii_case(keyword) {
|
|
return false;
|
|
}
|
|
let before = previous_char(text, start);
|
|
let after = text[start + keyword.len()..].chars().next();
|
|
!before.is_some_and(is_identifier_char) && !after.is_some_and(is_identifier_char)
|
|
}
|
|
|
|
fn source_key_for_qualifier(sources: &[FromSource], qualifier: &str) -> Option<String> {
|
|
let matches = sources
|
|
.iter()
|
|
.filter(|source| {
|
|
source.alias.as_deref().is_some_and(|alias| alias.eq_ignore_ascii_case(qualifier))
|
|
|| source.table_name.eq_ignore_ascii_case(qualifier)
|
|
})
|
|
.collect::<Vec<_>>();
|
|
if matches.len() == 1 {
|
|
Some(matches[0].key.clone())
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
impl From<FromSource> for EditableQuerySource {
|
|
fn from(source: FromSource) -> Self {
|
|
Self {
|
|
key: source.key,
|
|
catalog: source.catalog,
|
|
catalog_quoted: source.catalog_quoted,
|
|
schema: source.schema,
|
|
schema_quoted: source.schema_quoted,
|
|
table_name: source.table_name,
|
|
table_name_quoted: source.table_name_quoted,
|
|
alias: source.alias,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn is_single_quoted_source_with_optional_alias(text: &str) -> bool {
|
|
let mut chars = text.char_indices().peekable();
|
|
if chars.next().map(|(_, ch)| ch) != Some('\'') {
|
|
return false;
|
|
}
|
|
while let Some((idx, ch)) = chars.next() {
|
|
if ch == '\'' {
|
|
if chars.peek().is_some_and(|(_, next)| *next == '\'') {
|
|
chars.next();
|
|
continue;
|
|
}
|
|
let tail = text[idx + ch.len_utf8()..].trim();
|
|
if tail.is_empty() {
|
|
return true;
|
|
}
|
|
let alias_text = strip_leading_as(tail).unwrap_or(tail).trim();
|
|
return read_identifier(alias_text, 0).is_some_and(|alias| alias.end == alias_text.len());
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
fn starts_with_table_function(text: &str) -> bool {
|
|
let Some(ident) = read_identifier(text, 0) else {
|
|
return false;
|
|
};
|
|
text[ident.end..].trim_start().starts_with('(')
|
|
}
|
|
|
|
fn parse_qualified_identifier(text: &str) -> Option<QualifiedIdentifier> {
|
|
let mut parts = Vec::new();
|
|
let mut pos = 0usize;
|
|
while pos < text.len() {
|
|
pos = skip_whitespace(text, pos);
|
|
let Some(ident) = read_identifier(text, pos) else {
|
|
break;
|
|
};
|
|
let ident_end = ident.end;
|
|
parts.push(ident);
|
|
pos = skip_whitespace(text, ident_end);
|
|
if !text[pos..].starts_with('.') {
|
|
break;
|
|
}
|
|
pos += 1;
|
|
}
|
|
if parts.is_empty() {
|
|
return None;
|
|
}
|
|
Some(QualifiedIdentifier { parts, end: pos })
|
|
}
|
|
|
|
fn read_identifier(text: &str, start: usize) -> Option<Identifier> {
|
|
let pos = skip_whitespace(text, start);
|
|
let mut chars = text[pos..].char_indices();
|
|
let (_, first) = chars.next()?;
|
|
if matches!(first, '"' | '`' | '[') {
|
|
let close = if first == '[' { ']' } else { first };
|
|
let mut value = String::new();
|
|
for (offset, ch) in chars {
|
|
if ch == close {
|
|
return Some(Identifier { value, quoted: true, end: pos + offset + ch.len_utf8() });
|
|
}
|
|
value.push(ch);
|
|
}
|
|
return None;
|
|
}
|
|
|
|
// MySQL/MariaDB let an unquoted identifier start with a digit as long as it is not a pure
|
|
// number, so `01_tablename` is a table name while `123` / `1e3` stay literals (#9992).
|
|
// Every other dialect that reaches this lenient analyzer rejects a leading digit at parse
|
|
// time, so accepting the token here cannot mis-bind a statement the server would run.
|
|
if !is_identifier_start(first) && !first.is_ascii_digit() {
|
|
return None;
|
|
}
|
|
let mut end = pos + first.len_utf8();
|
|
let mut value_end = text.len();
|
|
for (offset, ch) in text[end..].char_indices() {
|
|
if !is_identifier_char(ch) {
|
|
value_end = end + offset;
|
|
break;
|
|
}
|
|
}
|
|
end = value_end;
|
|
let value = &text[pos..end];
|
|
if first.is_ascii_digit() && is_number_shaped(value) {
|
|
return None;
|
|
}
|
|
Some(Identifier { value: value.to_string(), quoted: false, end })
|
|
}
|
|
|
|
fn skip_whitespace(text: &str, pos: usize) -> usize {
|
|
let mut current = pos;
|
|
for (offset, ch) in text[pos..].char_indices() {
|
|
if !ch.is_whitespace() {
|
|
return pos + offset;
|
|
}
|
|
current = pos + offset + ch.len_utf8();
|
|
}
|
|
current
|
|
}
|
|
|
|
fn strip_sql_comments(sql: &str) -> String {
|
|
let mut result = String::new();
|
|
let mut chars = sql.chars().peekable();
|
|
while let Some(ch) = chars.next() {
|
|
if ch == '-' && chars.peek() == Some(&'-') {
|
|
chars.next();
|
|
for next in chars.by_ref() {
|
|
if next == '\n' {
|
|
result.push('\n');
|
|
break;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
if ch == '/' && chars.peek() == Some(&'*') {
|
|
chars.next();
|
|
let mut previous = '\0';
|
|
for next in chars.by_ref() {
|
|
if previous == '*' && next == '/' {
|
|
break;
|
|
}
|
|
previous = next;
|
|
}
|
|
continue;
|
|
}
|
|
result.push(ch);
|
|
}
|
|
result
|
|
}
|
|
|
|
fn has_top_level_keyword(sql: &str, keywords: &[&str]) -> bool {
|
|
keywords.iter().any(|keyword| find_top_level_keyword(sql, keyword, 0).is_some())
|
|
}
|
|
|
|
fn has_top_level_semicolon(sql: &str) -> bool {
|
|
let mut depth = 0i32;
|
|
let mut quote: Option<char> = None;
|
|
for ch in sql.chars() {
|
|
if let Some(close) = quote {
|
|
if ch == close {
|
|
quote = None;
|
|
}
|
|
continue;
|
|
}
|
|
match ch {
|
|
'\'' | '"' | '`' => quote = Some(ch),
|
|
'[' => quote = Some(']'),
|
|
'(' => depth += 1,
|
|
')' => depth = 0.max(depth - 1),
|
|
';' if depth == 0 => return true,
|
|
_ => {}
|
|
}
|
|
}
|
|
false
|
|
}
|
|
|
|
fn first_top_level_keyword_index(sql: &str, keywords: &[&str], start: usize) -> Option<usize> {
|
|
keywords.iter().filter_map(|keyword| find_top_level_keyword(sql, keyword, start)).min()
|
|
}
|
|
|
|
fn find_top_level_keyword(sql: &str, keyword: &str, start: usize) -> Option<usize> {
|
|
let mut depth = 0i32;
|
|
let mut quote: Option<char> = None;
|
|
let upper_keyword = keyword.to_ascii_uppercase();
|
|
|
|
for (index, ch) in sql.char_indices().filter(|(index, _)| *index >= start) {
|
|
if let Some(close) = quote {
|
|
if ch == close {
|
|
quote = None;
|
|
}
|
|
continue;
|
|
}
|
|
match ch {
|
|
'\'' | '"' | '`' => {
|
|
quote = Some(ch);
|
|
continue;
|
|
}
|
|
'[' => {
|
|
quote = Some(']');
|
|
continue;
|
|
}
|
|
'(' => {
|
|
depth += 1;
|
|
continue;
|
|
}
|
|
')' => {
|
|
depth = 0.max(depth - 1);
|
|
continue;
|
|
}
|
|
_ => {}
|
|
}
|
|
if depth != 0 {
|
|
continue;
|
|
}
|
|
let Some(candidate) = sql.get(index..index + keyword.len()) else {
|
|
continue;
|
|
};
|
|
if candidate.to_ascii_uppercase() != upper_keyword {
|
|
continue;
|
|
}
|
|
let before = previous_char(sql, index);
|
|
let after = sql[index + keyword.len()..].chars().next();
|
|
if !before.is_some_and(is_identifier_char) && !after.is_some_and(is_identifier_char) {
|
|
return Some(index);
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn starts_with_keyword(sql: &str, keyword: &str) -> bool {
|
|
let trimmed = sql.trim_start();
|
|
let Some(candidate) = trimmed.get(..keyword.len()) else {
|
|
return false;
|
|
};
|
|
if !candidate.eq_ignore_ascii_case(keyword) {
|
|
return false;
|
|
}
|
|
!trimmed[keyword.len()..].chars().next().is_some_and(is_identifier_char)
|
|
}
|
|
|
|
fn previous_char(text: &str, index: usize) -> Option<char> {
|
|
text[..index].chars().next_back()
|
|
}
|
|
|
|
fn is_identifier_start(ch: char) -> bool {
|
|
ch == '_' || unicode_ident::is_xid_start(ch)
|
|
}
|
|
|
|
/// Numeric literals keep their meaning even though MySQL allows an unquoted identifier to begin
|
|
/// with a digit: `123`, `1e3`, `0x1f` and `0b11` are numbers, `01_tablename` is not (#9992).
|
|
fn is_number_shaped(value: &str) -> bool {
|
|
let lower = value.to_ascii_lowercase();
|
|
let Some(rest) = lower.strip_prefix(|ch: char| ch.is_ascii_digit()) else {
|
|
return false;
|
|
};
|
|
if rest.is_empty() {
|
|
return true;
|
|
}
|
|
if let Some(hex) = rest.strip_prefix('x') {
|
|
return !hex.is_empty() && hex.chars().all(|ch| ch.is_ascii_hexdigit());
|
|
}
|
|
if let Some(bits) = rest.strip_prefix('b') {
|
|
return !bits.is_empty() && bits.chars().all(|ch| ch == '0' || ch == '1');
|
|
}
|
|
rest.chars().all(|ch| ch.is_ascii_digit() || matches!(ch, 'e' | '+' | '-'))
|
|
}
|
|
|
|
fn is_identifier_char(ch: char) -> bool {
|
|
ch == '$' || unicode_ident::is_xid_continue(ch)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn recognizes_digit_leading_identifiers_per_issue_9992() {
|
|
// MySQL/MariaDB allow an unquoted identifier that starts with a digit as long as it is
|
|
// not a pure number, so `01_tablename` must resolve like any other table name.
|
|
assert_eq!(read_identifier("01_tablename", 0).map(|ident| ident.value), Some("01_tablename".to_string()));
|
|
assert_eq!(
|
|
read_identifier("`01_tablename`", 0).map(|ident| (ident.value, ident.quoted)),
|
|
Some(("01_tablename".to_string(), true))
|
|
);
|
|
// Number-shaped tokens stay literals instead of becoming identifiers.
|
|
assert!(read_identifier("123", 0).is_none());
|
|
assert!(read_identifier("1e3", 0).is_none());
|
|
assert!(read_identifier("0x1f", 0).is_none());
|
|
assert!(is_number_shaped("123"));
|
|
assert!(is_number_shaped("1e3"));
|
|
assert!(is_number_shaped("0x1f"));
|
|
assert!(is_number_shaped("0b101"));
|
|
assert!(!is_number_shaped("01_tablename"));
|
|
assert!(!is_number_shaped("1abc"));
|
|
assert!(!is_number_shaped("t1"));
|
|
|
|
let result = analyze_editable_query_editability("select * from 01_tablename");
|
|
assert!(result.editable, "{:?}", result.reason);
|
|
let analysis = result.analysis.expect("analysis");
|
|
assert_eq!(analysis.table_name, "01_tablename");
|
|
assert!(!analysis.table_name_quoted);
|
|
assert!(analysis.select_star);
|
|
|
|
let qualified = analyze_editable_query_editability("select id, name from app.01_tablename");
|
|
assert!(qualified.editable, "{:?}", qualified.reason);
|
|
let qualified_analysis = qualified.analysis.expect("analysis");
|
|
assert_eq!(qualified_analysis.schema.as_deref(), Some("app"));
|
|
assert_eq!(qualified_analysis.table_name, "01_tablename");
|
|
assert_eq!(
|
|
qualified_analysis.columns,
|
|
vec![
|
|
column(Some("id"), false, None, None, "id", "id"),
|
|
column(Some("name"), false, None, None, "name", "name"),
|
|
]
|
|
);
|
|
|
|
// A pure numeric projection stays an expression, not a column reference.
|
|
let literal = analyze_editable_query_editability("select 123 from 01_tablename");
|
|
let literal_analysis = literal.analysis.expect("analysis");
|
|
assert_eq!(literal_analysis.columns.len(), 1);
|
|
assert_eq!(literal_analysis.columns[0].source_name, None);
|
|
assert_eq!(literal_analysis.columns[0].expression, "123");
|
|
}
|
|
|
|
#[test]
|
|
fn recognizes_simple_single_table_select_as_editable() {
|
|
let result =
|
|
analyze_editable_query_editability("select id, name from public.users where active = true order by id");
|
|
|
|
assert!(result.editable);
|
|
assert_eq!(result.reason, None);
|
|
let analysis = result.analysis.unwrap();
|
|
assert_eq!(analysis.schema.as_deref(), Some("public"));
|
|
assert_eq!(analysis.table_name, "users");
|
|
assert_eq!(
|
|
analysis.columns,
|
|
vec![
|
|
column(Some("id"), false, None, None, "id", "id"),
|
|
column(Some("name"), false, None, None, "name", "name"),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recognizes_oracle_num_column_and_alias_as_editable() {
|
|
for sql in ["select id, num from users", "select id, amount as num from users"] {
|
|
let result = analyze_editable_query_editability(sql);
|
|
assert!(result.editable, "{sql}");
|
|
assert_eq!(result.reason, None, "{sql}");
|
|
let analysis = result.analysis.unwrap();
|
|
assert_eq!(analysis.table_name, "users", "{sql}");
|
|
assert_eq!(analysis.columns.last().unwrap().result_name, "num", "{sql}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn recognizes_sql_server_top_selects_as_editable() {
|
|
for sql in [
|
|
"SELECT TOP 2 name, note FROM dbo.users ORDER BY name",
|
|
"SELECT TOP(2) name, note FROM dbo.users ORDER BY name",
|
|
"SELECT TOP (2) name, note FROM dbo.users ORDER BY name",
|
|
"SELECT TOP 10 PERCENT name, note FROM dbo.users ORDER BY name",
|
|
"SELECT TOP (2) WITH TIES name, note FROM dbo.users ORDER BY name",
|
|
] {
|
|
let result = analyze_editable_query_editability(sql);
|
|
assert!(result.editable, "{sql}: {:?}", result.reason);
|
|
let analysis = result.analysis.unwrap();
|
|
assert_eq!(analysis.table_name, "users");
|
|
assert_eq!(analysis.columns[0].source_name.as_deref(), Some("name"));
|
|
assert_eq!(analysis.columns[1].source_name.as_deref(), Some("note"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn recognizes_quoted_table_names_and_aliases() {
|
|
let result =
|
|
analyze_editable_query_editability(r#"SELECT u."id", u."full name" FROM "app schema"."user table" AS u"#);
|
|
|
|
let analysis = result.analysis.unwrap();
|
|
assert_eq!(analysis.schema.as_deref(), Some("app schema"));
|
|
assert_eq!(analysis.table_name, "user table");
|
|
assert_eq!(analysis.table_alias.as_deref(), Some("u"));
|
|
assert_eq!(
|
|
analysis.columns,
|
|
vec![
|
|
column(Some("id"), true, Some("u"), Some("u:0"), "id", r#"u."id""#),
|
|
column(Some("full name"), true, Some("u"), Some("u:0"), "full name", r#"u."full name""#),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn keeps_select_star_empty_columns() {
|
|
let analysis = analyze_editable_query("select * from users").unwrap();
|
|
assert_eq!(analysis.table_name, "users");
|
|
assert!(analysis.select_star);
|
|
assert!(analysis.columns.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn recognizes_top_level_sql_set_operations_as_read_only() {
|
|
for operator in ["UNION", "INTERSECT", "EXCEPT", "MINUS"] {
|
|
let sql = format!("SELECT id FROM users {operator} SELECT id FROM archived_users");
|
|
let result = analyze_editable_query_editability(&sql);
|
|
|
|
assert!(!result.editable, "{operator}");
|
|
assert_eq!(result.reason, Some(QueryEditabilityReason::SetOperation), "{operator}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ignores_minus_in_strings_comments_and_nested_queries() {
|
|
for sql in [
|
|
"SELECT id, 'MINUS' AS operation FROM users",
|
|
"SELECT id FROM users -- MINUS\nWHERE active = 1",
|
|
"SELECT id FROM users /* MINUS */ WHERE active = 1",
|
|
"SELECT * FROM users WHERE id IN (SELECT id FROM archived_users MINUS SELECT id FROM blocked_users)",
|
|
] {
|
|
let result = analyze_editable_query_editability(sql);
|
|
|
|
assert!(result.editable, "{sql}: {:?}", result.reason);
|
|
assert_eq!(result.analysis.unwrap().table_name, "users", "{sql}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ignores_semicolons_inside_literals_identifiers_and_comments() {
|
|
for sql in [
|
|
"SELECT c.* FROM CONTAINER c WHERE c.CONTAINERNAME = '00390360;081111'",
|
|
"SELECT c.* FROM CONTAINER c WHERE c.CONTAINERNAME = '00390360;081111';",
|
|
"SELECT * FROM users WHERE name = 'a;b' ",
|
|
r#"SELECT * FROM "weird;name""#,
|
|
"SELECT * FROM `weird;name`",
|
|
"SELECT * FROM [weird;name]",
|
|
"SELECT * FROM users -- keep; going\nWHERE active = 1",
|
|
"SELECT * FROM users /* keep; going */ WHERE active = 1",
|
|
] {
|
|
let result = analyze_editable_query_editability(sql);
|
|
|
|
assert!(result.editable, "{sql}: {:?}", result.reason);
|
|
}
|
|
|
|
let issue = analyze_editable_query_editability(
|
|
"SELECT c.* FROM CONTAINER c WHERE c.CONTAINERNAME = '00390360;081111';",
|
|
);
|
|
assert!(issue.editable);
|
|
let analysis = issue.analysis.unwrap();
|
|
assert_eq!(analysis.table_name, "CONTAINER");
|
|
assert_eq!(analysis.table_alias.as_deref(), Some("c"));
|
|
assert!(analysis.select_star);
|
|
}
|
|
|
|
#[test]
|
|
fn treats_top_level_semicolons_as_complex_source() {
|
|
for sql in ["SELECT * FROM users; SELECT * FROM orders", "SELECT * FROM users WHERE name = 'a;b'; SELECT 1"] {
|
|
let result = analyze_editable_query_editability(sql);
|
|
|
|
assert!(!result.editable, "{sql}");
|
|
assert_eq!(result.reason, Some(QueryEditabilityReason::ComplexSource), "{sql}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn recognizes_oracle_for_update_clauses_without_treating_for_as_an_alias() {
|
|
for sql in [
|
|
"SELECT * FROM employees FOR UPDATE",
|
|
"SELECT * FROM employees FOR UPDATE NOWAIT",
|
|
"SELECT * FROM employees FOR UPDATE SKIP LOCKED",
|
|
"SELECT * FROM employees FOR UPDATE OF salary, department_id WAIT 5",
|
|
"SELECT * FROM employees WHERE department_id = 10 ORDER BY employee_id FOR UPDATE OF salary NOWAIT",
|
|
] {
|
|
let result = analyze_editable_query_editability(sql);
|
|
|
|
assert!(result.editable, "{sql}");
|
|
let analysis = result.analysis.unwrap();
|
|
assert_eq!(analysis.table_name, "employees", "{sql}");
|
|
assert_eq!(analysis.table_alias, None, "{sql}");
|
|
assert!(analysis.select_star, "{sql}");
|
|
}
|
|
|
|
let result = analyze_editable_query_editability(
|
|
"SELECT e.employee_id, e.salary FROM employees e FOR UPDATE OF e.salary SKIP LOCKED",
|
|
);
|
|
assert!(result.editable);
|
|
assert_eq!(result.analysis.unwrap().table_alias.as_deref(), Some("e"));
|
|
}
|
|
|
|
#[test]
|
|
fn recognizes_postgres_row_lock_clauses_without_treating_for_as_an_alias() {
|
|
for sql in [
|
|
"SELECT * FROM jobs FOR SHARE",
|
|
"SELECT * FROM jobs FOR NO KEY UPDATE SKIP LOCKED",
|
|
"SELECT * FROM jobs FOR KEY SHARE NOWAIT",
|
|
] {
|
|
let result = analyze_editable_query_editability(sql);
|
|
|
|
assert!(result.editable, "{sql}");
|
|
let analysis = result.analysis.unwrap();
|
|
assert_eq!(analysis.table_name, "jobs", "{sql}");
|
|
assert_eq!(analysis.table_alias, None, "{sql}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn keeps_explicit_read_only_and_output_for_clauses_non_editable() {
|
|
for sql in [
|
|
"SELECT * FROM employees FOR READ ONLY",
|
|
"SELECT * FROM employees FOR FETCH ONLY",
|
|
"SELECT * FROM employees FOR JSON",
|
|
] {
|
|
let result = analyze_editable_query_editability(sql);
|
|
|
|
assert!(!result.editable, "{sql}");
|
|
assert_eq!(result.reason, Some(QueryEditabilityReason::ComplexSource), "{sql}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ignores_where_subquery_sources_for_editable_target() {
|
|
let result = analyze_editable_query_editability(
|
|
"SELECT t.* FROM app.platform_cars t WHERE t.customer_no IN (SELECT c.customer_no FROM app.customers c WHERE c.enabled = 1)",
|
|
);
|
|
|
|
assert!(result.editable);
|
|
let analysis = result.analysis.unwrap();
|
|
assert_eq!(analysis.schema.as_deref(), Some("app"));
|
|
assert_eq!(analysis.table_name, "platform_cars");
|
|
assert_eq!(analysis.table_alias.as_deref(), Some("t"));
|
|
assert!(analysis.select_star);
|
|
assert!(!analysis.multi_source);
|
|
assert_eq!(analysis.allow_insert_delete, None);
|
|
}
|
|
|
|
#[test]
|
|
fn maps_single_table_explicit_column_with_alias_star() {
|
|
let result = analyze_editable_query_editability(
|
|
"select t.create_date, t.* from tt_kd_material_container_sap t where t.order_no = 'KD2607071336' order by t.create_date desc",
|
|
);
|
|
|
|
assert!(result.editable);
|
|
let analysis = result.analysis.unwrap();
|
|
assert_eq!(analysis.table_name, "tt_kd_material_container_sap");
|
|
assert_eq!(analysis.table_alias.as_deref(), Some("t"));
|
|
assert_eq!(
|
|
analysis.columns,
|
|
vec![
|
|
column(Some("create_date"), false, Some("t"), Some("t:0"), "create_date", "t.create_date"),
|
|
star_column(Some("t"), Some("t:0"), "t.*"),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn maps_single_table_bare_star_mixed_with_explicit_column() {
|
|
// Regression for #8015: a bare, unqualified `*` mixed with another
|
|
// projected column is unambiguous when the query has exactly one
|
|
// source, so it must bind to that source (source_key) the same way
|
|
// a qualified `t.*` does. Previously it carried no source_key,
|
|
// which made the ordinal column-comment resolver drop the star
|
|
// expansion entirely and lose comments for the whole result set.
|
|
let result = analyze_editable_query_editability("SELECT *, amount FROM orders");
|
|
|
|
assert!(result.editable);
|
|
let analysis = result.analysis.unwrap();
|
|
assert_eq!(analysis.table_name, "orders");
|
|
assert_eq!(
|
|
analysis.columns,
|
|
vec![
|
|
star_column(None, Some("orders:0"), "*"),
|
|
column(Some("amount"), false, None, None, "amount", "amount"),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn maps_single_aliased_table_bare_trailing_star_after_qualified_columns() {
|
|
// Matches the issue's own repro shape: qualified explicit columns
|
|
// followed by a trailing bare `*`, single aliased source.
|
|
let result = analyze_editable_query_editability(
|
|
"SELECT fl.sqlicenseno, fl.sqtypetext, * FROM flow_licenseapprove AS fl",
|
|
);
|
|
|
|
assert!(result.editable);
|
|
let analysis = result.analysis.unwrap();
|
|
assert_eq!(analysis.table_alias.as_deref(), Some("fl"));
|
|
assert_eq!(
|
|
analysis.columns,
|
|
vec![
|
|
column(Some("sqlicenseno"), false, Some("fl"), Some("fl:0"), "sqlicenseno", "fl.sqlicenseno"),
|
|
column(Some("sqtypetext"), false, Some("fl"), Some("fl:0"), "sqtypetext", "fl.sqtypetext"),
|
|
star_column(None, Some("fl:0"), "*"),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn recognizes_distinct_single_table_projection_as_update_only() {
|
|
let result = analyze_editable_query_editability("select distinct id, name from users");
|
|
|
|
assert!(result.editable);
|
|
let analysis = result.analysis.unwrap();
|
|
assert!(analysis.distinct);
|
|
assert_eq!(analysis.allow_insert, Some(false));
|
|
assert_eq!(analysis.allow_insert_delete, Some(false));
|
|
assert_eq!(analysis.columns.len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn recognizes_distinct_qualified_star_from_join() {
|
|
let result = analyze_editable_query_editability(
|
|
"select distinct t4.*
|
|
from TASK_CHECK_BASE t1
|
|
left join TASK_FORMULATE_EXECUTE t20 on t1.EXECUTE_UID = t20.EXECUTE_UID
|
|
left join TASK_FORMULATE_BASE t40 on t20.FORMULATE_UID = t40.FORMULATE_UID
|
|
left join TASK_EXECUTE_FORM t30 on t20.EXECUTE_UID = t30.EXECUTE_UID
|
|
left join TASK_CHECK_ORG t2 on t1.TASK_ID = t2.TASK_ID
|
|
left join TASK_CHECK_ORG_INNER_DEPT t3 on t2.TASK_ORG_ID = t3.TASK_ORG_ID
|
|
left join TASK_CHECK_ENT t4 on t1.TASK_ID = t4.TASK_ID
|
|
left join TASK_EXECUTE_OBJ_ENT t44 on t1.EXECUTE_UID = t44.EXECUTE_UID
|
|
left join TASK_CHECK_DEPT_RESULT t5 on t4.TASK_ID = t5.TASK_ID and t4.TASK_ENT_ID = t5.TASK_ENT_ID
|
|
left join TASK_EXECUTE_PERSON_AGENT t6 on t1.EXECUTE_UID = t6.EXECUTE_UID",
|
|
);
|
|
|
|
assert!(result.editable);
|
|
let analysis = result.analysis.unwrap();
|
|
assert!(analysis.distinct);
|
|
assert!(analysis.multi_source);
|
|
assert_eq!(analysis.allow_insert, Some(false));
|
|
assert_eq!(analysis.allow_insert_delete, Some(false));
|
|
assert_eq!(analysis.sources.as_ref().map(Vec::len), Some(10));
|
|
assert_eq!(analysis.columns, vec![star_column(Some("t4"), Some("t4:6"), "t4.*")]);
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_unqualified_star_from_join() {
|
|
let result =
|
|
analyze_editable_query_editability("select distinct * from users u join orders o on o.user_id = u.id");
|
|
|
|
assert!(!result.editable);
|
|
assert_eq!(result.reason, Some(QueryEditabilityReason::ComplexSource));
|
|
}
|
|
|
|
#[test]
|
|
fn rejects_distinct_on_projection() {
|
|
let result = analyze_editable_query_editability(
|
|
"select distinct on (user_id) id, user_id from orders order by user_id, id desc",
|
|
);
|
|
|
|
assert!(!result.editable);
|
|
assert_eq!(result.reason, Some(QueryEditabilityReason::Aggregation));
|
|
}
|
|
|
|
#[test]
|
|
fn maps_joined_query_source_columns() {
|
|
let result = analyze_editable_query_editability(
|
|
"select u.id as user_id, u.name, o.total from users u join orders o on o.user_id = u.id",
|
|
);
|
|
|
|
assert!(result.editable);
|
|
let analysis = result.analysis.unwrap();
|
|
assert!(analysis.multi_source);
|
|
assert_eq!(analysis.allow_insert_delete, Some(false));
|
|
assert_eq!(
|
|
analysis
|
|
.sources
|
|
.unwrap()
|
|
.iter()
|
|
.map(|source| (&source.key, &source.table_name, source.alias.as_deref()))
|
|
.collect::<Vec<_>>(),
|
|
vec![
|
|
(&"u:0".to_string(), &"users".to_string(), Some("u")),
|
|
(&"o:1".to_string(), &"orders".to_string(), Some("o")),
|
|
]
|
|
);
|
|
assert_eq!(
|
|
analysis.columns,
|
|
vec![
|
|
column(Some("id"), false, Some("u"), Some("u:0"), "user_id", "u.id"),
|
|
column(Some("name"), false, Some("u"), Some("u:0"), "name", "u.name"),
|
|
column(Some("total"), false, Some("o"), Some("o:1"), "total", "o.total"),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn reports_external_file_scan_as_external_source() {
|
|
let result = analyze_editable_query_editability("SELECT * FROM '/tmp/duckdb_excel_extension_test.xlsx'");
|
|
|
|
assert!(!result.editable);
|
|
assert_eq!(result.reason, Some(QueryEditabilityReason::ExternalSource));
|
|
}
|
|
|
|
#[test]
|
|
fn reports_grouped_query_as_aggregation() {
|
|
let result = analyze_editable_query_editability("select id, count(*) as total from users group by id");
|
|
|
|
assert!(!result.editable);
|
|
assert_eq!(result.reason, Some(QueryEditabilityReason::Aggregation));
|
|
}
|
|
|
|
#[test]
|
|
fn keeps_single_table_expression_columns() {
|
|
let result = analyze_editable_query_editability(
|
|
"select iso3, year, country_name, ihli / gdp_pc as score from ihli_data",
|
|
);
|
|
|
|
assert_eq!(
|
|
result.analysis.unwrap().columns,
|
|
vec![
|
|
column(Some("iso3"), false, None, None, "iso3", "iso3"),
|
|
column(Some("year"), false, None, None, "year", "year"),
|
|
column(Some("country_name"), false, None, None, "country_name", "country_name"),
|
|
column(None, false, None, None, "score", "ihli / gdp_pc"),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn keeps_mysql_json_expressions_read_only_with_or_without_aliases() {
|
|
for (sql, expected) in [
|
|
(
|
|
"select id, status, extra->>'$.mode' mode, extra->>'$.template' tmpl from jobs",
|
|
vec![
|
|
column(Some("id"), false, None, None, "id", "id"),
|
|
column(Some("status"), false, None, None, "status", "status"),
|
|
column(None, false, None, None, "mode", "extra->>'$.mode'"),
|
|
column(None, false, None, None, "tmpl", "extra->>'$.template'"),
|
|
],
|
|
),
|
|
(
|
|
"select id, status, extra->>'$.mode', extra->>'$.template' from jobs",
|
|
vec![
|
|
column(Some("id"), false, None, None, "id", "id"),
|
|
column(Some("status"), false, None, None, "status", "status"),
|
|
column(None, false, None, None, "extra->>'$.mode'", "extra->>'$.mode'"),
|
|
column(None, false, None, None, "extra->>'$.template'", "extra->>'$.template'"),
|
|
],
|
|
),
|
|
] {
|
|
let result = analyze_editable_query_editability(sql);
|
|
|
|
assert!(result.editable, "{sql}: {:?}", result.reason);
|
|
assert_eq!(result.analysis.unwrap().columns, expected, "{sql}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn keeps_computed_aliases_separate_from_writeable_source_columns() {
|
|
let result = analyze_editable_query_editability(
|
|
"select id, status as state, lower(name) normalized, count_value + 1 from jobs",
|
|
);
|
|
|
|
assert!(result.editable);
|
|
assert_eq!(
|
|
result.analysis.unwrap().columns,
|
|
vec![
|
|
column(Some("id"), false, None, None, "id", "id"),
|
|
column(Some("status"), false, None, None, "state", "status"),
|
|
column(None, false, None, None, "normalized", "lower(name)"),
|
|
column(None, false, None, None, "count_value + 1", "count_value + 1"),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn keeps_unaliased_computed_result_labels_without_binding_them() {
|
|
let result = analyze_editable_query_editability("select id, id + status, lower(status) from jobs");
|
|
|
|
assert!(result.editable);
|
|
assert_eq!(
|
|
result.analysis.unwrap().columns,
|
|
vec![
|
|
column(Some("id"), false, None, None, "id", "id"),
|
|
column(None, false, None, None, "id + status", "id + status"),
|
|
column(None, false, None, None, "lower(status)", "lower(status)"),
|
|
]
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn accepts_unquoted_unicode_aliases_in_mysql_and_sql_server_queries() {
|
|
for sql in [
|
|
"SELECT Guid, FDeleted AS 禁用, IsAuditing AS 审核 FROM xy.dbo.GL_CUSTOM WHERE TJBH=\n24049",
|
|
"SELECT Guid, FDeleted AS 禁用, IsAuditing AS 审核 FROM xy.GL_CUSTOM WHERE TJBH=24049",
|
|
] {
|
|
let result = analyze_editable_query_editability(sql);
|
|
|
|
assert!(result.editable, "{sql}");
|
|
assert_eq!(
|
|
result.analysis.unwrap().columns,
|
|
vec![
|
|
column(Some("Guid"), false, None, None, "Guid", "Guid"),
|
|
column(Some("FDeleted"), false, None, None, "禁用", "FDeleted"),
|
|
column(Some("IsAuditing"), false, None, None, "审核", "IsAuditing"),
|
|
]
|
|
);
|
|
}
|
|
|
|
let computed = analyze_editable_query_editability(
|
|
"SELECT Guid, FDeleted AS 禁用, CASE WHEN IsAuditing = 1 THEN 1 ELSE 0 END AS 审核状态 FROM xy.dbo.GL_CUSTOM",
|
|
);
|
|
assert!(computed.editable);
|
|
assert_eq!(
|
|
computed.analysis.unwrap().columns[2],
|
|
column(None, false, None, None, "审核状态", "CASE WHEN IsAuditing = 1 THEN 1 ELSE 0 END",)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn accepts_mysql_string_quoted_column_aliases() {
|
|
for (sql, result_name) in [
|
|
("SELECT id, report_type '计划类型' FROM biz_work_log", "计划类型"),
|
|
("SELECT id, report_type AS '计划类型' FROM biz_work_log", "计划类型"),
|
|
("SELECT id, report_type '计划''类型' FROM biz_work_log", "计划'类型"),
|
|
("SELECT id, report_type AS '计划''类型' FROM biz_work_log", "计划'类型"),
|
|
] {
|
|
let result = analyze_editable_query_editability(sql);
|
|
|
|
assert!(result.editable, "{sql}: {:?}", result.reason);
|
|
assert_eq!(
|
|
result.analysis.unwrap().columns,
|
|
vec![
|
|
column(Some("id"), false, None, None, "id", "id"),
|
|
column(Some("report_type"), false, None, None, result_name, "report_type"),
|
|
],
|
|
"{sql}"
|
|
);
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn keeps_string_literals_computed_and_rejects_malformed_string_aliases() {
|
|
for sql in [
|
|
"SELECT id, report_type '计划类型 FROM biz_work_log",
|
|
"SELECT id, report_type '计划类型' trailing FROM biz_work_log",
|
|
] {
|
|
let result = analyze_editable_query_editability(sql);
|
|
assert!(!result.editable, "{sql}");
|
|
}
|
|
|
|
for sql in
|
|
["SELECT id, 'constant' '固定值' FROM biz_work_log", "SELECT id, 'constant' AS '固定值' FROM biz_work_log"]
|
|
{
|
|
let computed = analyze_editable_query_editability(sql);
|
|
assert!(computed.editable, "{sql}");
|
|
assert_eq!(
|
|
computed.analysis.unwrap().columns[1],
|
|
column(None, false, None, None, "固定值", "'constant'"),
|
|
"{sql}"
|
|
);
|
|
}
|
|
|
|
for sql in [
|
|
"SELECT id, report_type AS 计划类型 FROM biz_work_log",
|
|
"SELECT id, report_type AS `计划类型` FROM biz_work_log",
|
|
"SELECT id, report_type AS \"计划类型\" FROM biz_work_log",
|
|
] {
|
|
assert!(analyze_editable_query_editability(sql).editable, "{sql}");
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn serializes_reason_values_like_frontend_union() {
|
|
let json = serde_json::to_value(not_editable(QueryEditabilityReason::SetOperation)).unwrap();
|
|
|
|
assert_eq!(json, serde_json::json!({ "editable": false, "reason": "set-operation" }));
|
|
}
|
|
|
|
fn column(
|
|
source_name: Option<&str>,
|
|
source_name_quoted: bool,
|
|
source_qualifier: Option<&str>,
|
|
source_key: Option<&str>,
|
|
result_name: &str,
|
|
expression: &str,
|
|
) -> EditableQueryColumn {
|
|
EditableQueryColumn {
|
|
source_name: source_name.map(str::to_string),
|
|
source_name_quoted,
|
|
source_qualifier: source_qualifier.map(str::to_string),
|
|
source_key: source_key.map(str::to_string),
|
|
star: false,
|
|
result_name: result_name.to_string(),
|
|
expression: expression.to_string(),
|
|
}
|
|
}
|
|
|
|
fn star_column(source_qualifier: Option<&str>, source_key: Option<&str>, expression: &str) -> EditableQueryColumn {
|
|
EditableQueryColumn {
|
|
source_name: None,
|
|
source_name_quoted: false,
|
|
source_qualifier: source_qualifier.map(str::to_string),
|
|
source_key: source_key.map(str::to_string),
|
|
star: true,
|
|
result_name: "*".to_string(),
|
|
expression: expression.to_string(),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod joined_predicate_regression {
|
|
use super::analyze_editable_query_editability;
|
|
|
|
#[test]
|
|
fn parenthesized_join_predicate_preserves_both_sources() {
|
|
let result = analyze_editable_query_editability("SELECT u.id,u.nickname,p.paper_id,p.paper_name FROM users u JOIN papers p ON (u.id=p.user_id) WHERE u.id=1");
|
|
assert!(result.editable);
|
|
assert_eq!(result.analysis.unwrap().sources.unwrap().len(), 2);
|
|
}
|
|
|
|
#[test]
|
|
fn joined_derived_and_function_sources_remain_read_only() {
|
|
for sql in [
|
|
"SELECT u.id,p.id FROM users u JOIN (SELECT id FROM papers) p ON u.id=p.id",
|
|
"SELECT u.id,p.id FROM users u JOIN generate_series(1,2) p ON u.id=p.id",
|
|
] {
|
|
assert!(!analyze_editable_query_editability(sql).editable, "{sql}");
|
|
}
|
|
}
|
|
}
|