From a5eb6fd1fa95c2e9c310da39d3e1aee9eb8e73bf Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Mon, 27 Jul 2026 21:16:52 +0800 Subject: [PATCH] fix(oracle): classify minus queries as set operations --- apps/desktop/src/lib/sql/sqlAnalysis.ts | 2 +- crates/dbx-core/src/sql_editability.rs | 28 ++++++++++++++++++++++++- packages/app-tests/sqlAnalysis.test.ts | 17 +++++++++++++++ 3 files changed, 45 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/lib/sql/sqlAnalysis.ts b/apps/desktop/src/lib/sql/sqlAnalysis.ts index 04949503e..9c9dac986 100644 --- a/apps/desktop/src/lib/sql/sqlAnalysis.ts +++ b/apps/desktop/src/lib/sql/sqlAnalysis.ts @@ -112,7 +112,7 @@ export function analyzeEditableQueryEditability(sql: string): QueryEditability { if (!normalized) return { editable: false, reason: "not-select" }; if (/^\s*WITH\b/i.test(normalized)) return { editable: false, reason: "cte" }; if (!/^SELECT\b/i.test(normalized)) return { editable: false, reason: "not-select" }; - if (hasTopLevelKeyword(normalized, ["UNION", "INTERSECT", "EXCEPT"])) { + if (hasTopLevelKeyword(normalized, ["UNION", "INTERSECT", "EXCEPT", "MINUS"])) { return { editable: false, reason: "set-operation" }; } if (normalized.includes(";")) return { editable: false, reason: "complex-source" }; diff --git a/crates/dbx-core/src/sql_editability.rs b/crates/dbx-core/src/sql_editability.rs index 4cffe7f09..7e62852bf 100644 --- a/crates/dbx-core/src/sql_editability.rs +++ b/crates/dbx-core/src/sql_editability.rs @@ -137,7 +137,7 @@ pub fn analyze_editable_query_editability(sql: &str) -> QueryEditability { if !starts_with_keyword(&normalized, "SELECT") { return not_editable(QueryEditabilityReason::NotSelect); } - if has_top_level_keyword(&normalized, &["UNION", "INTERSECT", "EXCEPT"]) { + if has_top_level_keyword(&normalized, &["UNION", "INTERSECT", "EXCEPT", "MINUS"]) { return not_editable(QueryEditabilityReason::SetOperation); } if normalized.contains(';') { @@ -917,6 +917,32 @@ mod tests { 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 recognizes_oracle_for_update_clauses_without_treating_for_as_an_alias() { for sql in [ diff --git a/packages/app-tests/sqlAnalysis.test.ts b/packages/app-tests/sqlAnalysis.test.ts index 44d78a7c0..4eef2d0bf 100644 --- a/packages/app-tests/sqlAnalysis.test.ts +++ b/packages/app-tests/sqlAnalysis.test.ts @@ -36,6 +36,23 @@ test("keeps the legacy analyzer API for editable SELECT queries", () => { assert.deepEqual(analysis.columns, []); }); +test("recognizes top-level SQL set operations as read-only", () => { + for (const operator of ["UNION", "INTERSECT", "EXCEPT", "MINUS"]) { + const sql = `SELECT id FROM users ${operator} SELECT id FROM archived_users`; + + assert.deepEqual(analyzeEditableQueryEditability(sql), { editable: false, reason: "set-operation" }, operator); + } +}); + +test("ignores MINUS in strings, comments, and nested queries", () => { + for (const sql of ["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)"]) { + const result = analyzeEditableQueryEditability(sql); + + assert.equal(result.editable, true, sql); + assert.equal(result.analysis.tableName, "users", sql); + } +}); + test("recognizes Oracle FOR UPDATE variants without treating FOR as an alias", () => { for (const sql of [ "SELECT * FROM employees FOR UPDATE",