fix(oracle): classify minus queries as set operations

This commit is contained in:
t8y2 2026-07-27 21:16:52 +08:00
parent 0be9bcef12
commit a5eb6fd1fa
No known key found for this signature in database
3 changed files with 45 additions and 2 deletions

View File

@ -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" };

View File

@ -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 [

View File

@ -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",