mirror of
https://github.com/t8y2/dbx.git
synced 2026-10-02 02:34:42 +08:00
fix(sql): keep queries editable when literals contain semicolons
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user