fix(sql): keep queries editable when literals contain semicolons

This commit is contained in:
onenewcode
2026-09-14 14:29:11 +08:00
committed by GitHub
parent e561bd156f
commit 7cbd1418b3
3 changed files with 129 additions and 5 deletions
+32 -2
View File
@@ -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;
+61 -2
View File
@@ -129,7 +129,7 @@ pub fn analyze_editable_query(sql: &str) -> Option<EditableQueryInfo> {
}
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<char> = 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<usize> {
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 [
+36 -1
View File
@@ -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",