fix(grid): keep computed query columns individually read-only
Closes #6132
This commit is contained in:
1 parent
7a21e7e5f9
commit
37f64dbdf4
4 files changed
+383
-23
No files matched your search
@@ -339,7 +339,11 @@ fn parse_star_select_column(column: &str, sources: &[FromSource]) -> Option<Edit
|
||||
}
|
||||
|
||||
fn parse_computed_select_column(column: &str) -> Option<EditableQueryColumn> {
|
||||
let alias = parse_expression_alias(column)?;
|
||||
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,
|
||||
@@ -384,23 +388,167 @@ fn parse_expression_alias(column: &str) -> Option<ExpressionAlias> {
|
||||
return Some(ExpressionAlias { expression, result_name: alias.value });
|
||||
}
|
||||
|
||||
for (index, _) in trimmed_end.match_indices('\'') {
|
||||
if !previous_char(trimmed_end, index).is_some_and(char::is_whitespace) {
|
||||
continue;
|
||||
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 });
|
||||
}
|
||||
}
|
||||
let alias = read_select_alias(&trimmed_end[index..])?;
|
||||
if alias.end != trimmed_end.len() - index {
|
||||
continue;
|
||||
}
|
||||
let expression = trimmed_end[..index].trim().to_string();
|
||||
if expression.is_empty() {
|
||||
return None;
|
||||
}
|
||||
return Some(ExpressionAlias { expression, 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() {
|
||||
@@ -1184,6 +1332,68 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[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 [
|
||||
|
||||
Reference in new issue
Block a user