fix(query): support editing distinct results

This commit is contained in:
t8y2 committed 2026-07-10 13:19:28 +08:00
1 parent a9428e3c73
commit cb6350bef6
5 files changed
+119 -71

No files matched your search

+65 -54
View File
@@ -24,6 +24,8 @@ pub struct EditableQueryInfo {
pub multi_source: 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)]
@@ -146,10 +148,18 @@ pub fn analyze_editable_query_editability(sql: &str) -> QueryEditability {
return not_editable(QueryEditabilityReason::NoTable);
};
let select_body = normalized["SELECT".len()..from_index].trim();
if starts_with_keyword(select_body, "DISTINCT") {
return not_editable(QueryEditabilityReason::Aggregation);
}
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 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());
@@ -172,13 +182,13 @@ pub fn analyze_editable_query_editability(sql: &str) -> QueryEditability {
let source = sources[0].clone();
let select_star = sources.len() == 1 && is_select_star(select_body, source.alias.as_deref());
if sources.len() > 1 && select_body_contains_star_projection(select_body) {
return not_editable(QueryEditabilityReason::ComplexSource);
}
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,
@@ -193,7 +203,10 @@ pub fn analyze_editable_query_editability(sql: &str) -> QueryEditability {
sources: None,
editable_source_key: None,
multi_source: false,
allow_insert_delete: None,
// 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_delete: distinct.then_some(false),
distinct,
};
if sources.len() > 1 {
analysis.sources = Some(sources.into_iter().map(EditableQuerySource::from).collect());
@@ -491,52 +504,6 @@ fn is_external_from_source(body: &str) -> bool {
is_single_quoted_source_with_optional_alias(trimmed) || starts_with_table_function(trimmed)
}
fn select_body_contains_star_projection(body: &str) -> bool {
split_top_level_commas(body).iter().any(|item| {
let trimmed = item.trim();
if trimmed == "*" {
return true;
}
let Some((_, suffix)) = trimmed.split_once('.') else {
return false;
};
suffix.trim() == "*"
})
}
fn split_top_level_commas(body: &str) -> Vec<String> {
let mut items = 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 = 0.max(depth - 1),
',' if depth == 0 => {
items.push(current);
current = String::new();
continue;
}
_ => {}
}
current.push(ch);
}
if !current.is_empty() {
items.push(current);
}
items
}
fn table_source_terminator_at(text: &str, pos: usize) -> bool {
let pos = skip_whitespace(text, pos);
if pos >= text.len() || text[pos..].starts_with(',') {
@@ -855,6 +822,50 @@ mod tests {
);
}
#[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_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 u.* from users u left join orders o on o.user_id = u.id",
);
assert!(result.editable);
let analysis = result.analysis.unwrap();
assert!(analysis.distinct);
assert!(analysis.multi_source);
assert_eq!(analysis.allow_insert_delete, Some(false));
assert_eq!(analysis.columns, vec![star_column(Some("u"), Some("u:0"), "u.*")]);
}
#[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(