diff --git a/apps/desktop/src/lib/sql/sqlAnalysis.ts b/apps/desktop/src/lib/sql/sqlAnalysis.ts index c51e34795b..677bf74e3f 100644 --- a/apps/desktop/src/lib/sql/sqlAnalysis.ts +++ b/apps/desktop/src/lib/sql/sqlAnalysis.ts @@ -232,7 +232,7 @@ export function analyzeEditableQueryEditability(sql: string): QueryEditability { if (hasTopLevelKeyword(normalized, ["UNION", "INTERSECT", "EXCEPT", "MINUS"])) { return { editable: false, reason: "set-operation" }; } - if (normalized.includes(";")) return { editable: false, reason: "complex-source" }; + if (hasTopLevelSemicolon(normalized)) return { editable: false, reason: "complex-source" }; const fromIndex = findTopLevelKeyword(normalized, "FROM", 0); if (fromIndex < 0) return { editable: false, reason: "no-table" }; @@ -313,7 +313,7 @@ export function analyzeSelectStructureForDisplay(sql: string): EditableQueryInfo if (/^\s*WITH\b/i.test(normalized)) return null; if (!/^SELECT\b/i.test(normalized)) return null; if (hasTopLevelKeyword(normalized, ["UNION", "INTERSECT", "EXCEPT", "MINUS"])) return null; - if (normalized.includes(";")) return null; + if (hasTopLevelSemicolon(normalized)) return null; const fromIndex = findTopLevelKeyword(normalized, "FROM", 0); if (fromIndex < 0) return null; @@ -761,6 +761,36 @@ function hasTopLevelKeyword(sql: string, keywords: string[]): boolean { return keywords.some((keyword) => findTopLevelKeyword(sql, keyword, 0) >= 0); } +function hasTopLevelSemicolon(sql: string): boolean { + let depth = 0; + let quote: string | null = null; + for (let i = 0; i < sql.length; i++) { + const ch = sql[i]; + if (quote) { + if (ch === quote || (quote === "]" && ch === "]")) quote = null; + continue; + } + if (ch === "'" || ch === '"' || ch === "`") { + quote = ch; + continue; + } + if (ch === "[") { + quote = "]"; + continue; + } + if (ch === "(") { + depth++; + continue; + } + if (ch === ")") { + depth = Math.max(0, depth - 1); + continue; + } + if (depth === 0 && ch === ";") return true; + } + return false; +} + function firstTopLevelKeywordIndex(sql: string, keywords: string[], start: number): number { const indexes = keywords.map((keyword) => findTopLevelKeyword(sql, keyword, start)).filter((index) => index >= 0); return indexes.length ? Math.min(...indexes) : -1; diff --git a/crates/dbx-core/src/sql_editability.rs b/crates/dbx-core/src/sql_editability.rs index 7b65077744..da2f05c593 100644 --- a/crates/dbx-core/src/sql_editability.rs +++ b/crates/dbx-core/src/sql_editability.rs @@ -129,7 +129,7 @@ pub fn analyze_editable_query(sql: &str) -> Option { } pub fn analyze_editable_query_editability(sql: &str) -> QueryEditability { - let normalized = strip_sql_comments(sql).trim_end_matches(';').trim().to_string(); + let normalized = strip_sql_comments(sql).trim().trim_end_matches(';').trim().to_string(); if normalized.is_empty() { return not_editable(QueryEditabilityReason::NotSelect); } @@ -142,7 +142,7 @@ pub fn analyze_editable_query_editability(sql: &str) -> QueryEditability { if has_top_level_keyword(&normalized, &["UNION", "INTERSECT", "EXCEPT", "MINUS"]) { return not_editable(QueryEditabilityReason::SetOperation); } - if normalized.contains(';') { + if has_top_level_semicolon(&normalized) { return not_editable(QueryEditabilityReason::ComplexSource); } @@ -982,6 +982,28 @@ 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 = 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 { keywords.iter().filter_map(|keyword| find_top_level_keyword(sql, keyword, start)).min() } @@ -1151,6 +1173,43 @@ mod tests { } } + #[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 [ diff --git a/packages/app-tests/sqlAnalysis.test.ts b/packages/app-tests/sqlAnalysis.test.ts index d1b6e06a33..0feca9a8f9 100644 --- a/packages/app-tests/sqlAnalysis.test.ts +++ b/packages/app-tests/sqlAnalysis.test.ts @@ -1,6 +1,6 @@ import { strict as assert } from "node:assert"; import { test } from "vitest"; -import { allEditableColumnsWriteable, allPrimaryKeysPresent, analyzeEditableQuery, analyzeEditableQueryEditability, isBinaryType, queryEditabilityMessageKey, resolveMetadataColumnName, sourceColumnsForResult } from "../../apps/desktop/src/lib/sql/sqlAnalysis.ts"; +import { allEditableColumnsWriteable, allPrimaryKeysPresent, analyzeEditableQuery, analyzeEditableQueryEditability, analyzeSelectStructureForDisplay, isBinaryType, queryEditabilityMessageKey, resolveMetadataColumnName, sourceColumnsForResult } from "../../apps/desktop/src/lib/sql/sqlAnalysis.ts"; test("recognizes a simple single-table SELECT as editable", () => { const result = analyzeEditableQueryEditability("select id, name from public.users where active = true order by id"); @@ -53,6 +53,41 @@ test("ignores MINUS in strings, comments, and nested queries", () => { } }); +test("ignores semicolons inside literals, identifiers, and comments", () => { + for (const sql of [ + "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' ", + '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", + ]) { + const result = analyzeEditableQueryEditability(sql); + + assert.equal(result.editable, true, sql); + } + + const issue = analyzeEditableQueryEditability("SELECT c.* FROM CONTAINER c WHERE c.CONTAINERNAME = '00390360;081111';"); + assert.equal(issue.editable, true); + assert.equal(issue.analysis.tableName, "CONTAINER"); + assert.equal(issue.analysis.tableAlias, "c"); + assert.equal(issue.analysis.selectStar, true); + + const display = analyzeSelectStructureForDisplay("SELECT c.* FROM CONTAINER c WHERE c.CONTAINERNAME = '00390360;081111';"); + assert.ok(display); + assert.equal(display.tableName, "CONTAINER"); + assert.equal(display.tableAlias, "c"); +}); + +test("treats top-level semicolons as a complex source", () => { + for (const sql of ["SELECT * FROM users; SELECT * FROM orders", "SELECT * FROM users WHERE name = 'a;b'; SELECT 1"]) { + assert.deepEqual(analyzeEditableQueryEditability(sql), { editable: false, reason: "complex-source" }, sql); + assert.equal(analyzeSelectStructureForDisplay(sql), null, sql); + } +}); + test("recognizes Oracle FOR UPDATE variants without treating FOR as an alias", () => { for (const sql of [ "SELECT * FROM employees FOR UPDATE",