From 3acaf6f16ae16994316684dc6632b39a5d1d7be6 Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Thu, 13 Aug 2026 03:40:45 +0000 Subject: [PATCH] fix(grid): support MySQL string-quoted editable aliases Closes #6059 --- apps/desktop/src/lib/sql/sqlAnalysis.ts | 35 +++++++-- crates/dbx-core/src/sql_editability.rs | 95 ++++++++++++++++++++++++- packages/app-tests/sqlAnalysis.test.ts | 41 +++++++++++ 3 files changed, 163 insertions(+), 8 deletions(-) diff --git a/apps/desktop/src/lib/sql/sqlAnalysis.ts b/apps/desktop/src/lib/sql/sqlAnalysis.ts index 1510b1923e..1bba6bfbae 100644 --- a/apps/desktop/src/lib/sql/sqlAnalysis.ts +++ b/apps/desktop/src/lib/sql/sqlAnalysis.ts @@ -272,11 +272,14 @@ function parseComputedSelectColumn(col: string): EditableQueryColumn | null { } function parseExpressionAlias(col: string): { expression: string; resultName: string } | null { - const asMatch = col.match(/\bAS\s+((?:"[^"]+")|(?:`[^`]+`)|(?:\[[^\]]+\])|(?:[\p{ID_Start}_][\p{ID_Continue}$]*))\s*$/iu); - if (asMatch?.index === undefined) return null; - const alias = readIdentifier(asMatch[1], 0); - if (!alias || alias.end !== asMatch[1].length) return null; - const expression = col.slice(0, asMatch.index).trim(); + const asMatch = col.match(/\bAS\s+((?:"[^"]+")|(?:`[^`]+`)|(?:\[[^\]]+\])|(?:'(?:''|[^'])*')|(?:[\p{ID_Start}_][\p{ID_Continue}$]*))\s*$/iu); + const implicitStringMatch = asMatch ? undefined : col.match(/\s+('(?:''|[^'])*')\s*$/u); + const aliasText = asMatch?.[1] ?? implicitStringMatch?.[1]; + const expressionEnd = asMatch?.index ?? implicitStringMatch?.index; + if (!aliasText || expressionEnd === undefined) return null; + const alias = readSelectAlias(aliasText); + if (!alias || alias.end !== aliasText.length) return null; + const expression = col.slice(0, expressionEnd).trim(); return expression ? { expression, resultName: alias.value } : null; } @@ -285,11 +288,31 @@ function parseColumnAlias(rest: string): string | undefined | null { if (!trimmed) return undefined; const asMatch = trimmed.match(/^AS\s+/i); const aliasText = asMatch ? trimmed.slice(asMatch[0].length).trim() : trimmed; - const alias = readIdentifier(aliasText, 0); + const alias = readSelectAlias(aliasText); if (!alias || alias.end !== aliasText.length) return null; return alias.value; } +function readSelectAlias(text: string): { value: string; quoted: boolean; end: number } | null { + const pos = skipWhitespace(text, 0); + if (text[pos] !== "'") return readIdentifier(text, pos); + + let value = ""; + for (let i = pos + 1; i < text.length; i++) { + if (text[i] !== "'") { + value += text[i]; + continue; + } + if (text[i + 1] === "'") { + value += "'"; + i++; + continue; + } + return { value, quoted: true, end: i + 1 }; + } + return null; +} + function isSelectStar(body: string, alias: string | undefined): boolean { const trimmed = body.trim(); if (trimmed === "*") return true; diff --git a/crates/dbx-core/src/sql_editability.rs b/crates/dbx-core/src/sql_editability.rs index 1a263f6f39..086ac74e86 100644 --- a/crates/dbx-core/src/sql_editability.rs +++ b/crates/dbx-core/src/sql_editability.rs @@ -373,7 +373,7 @@ fn parse_expression_alias(column: &str) -> Option { continue; } let alias_text = after_as.trim(); - let alias = read_identifier(alias_text, 0)?; + let alias = read_select_alias(alias_text)?; if alias.end != alias_text.len() { continue; } @@ -383,6 +383,21 @@ fn parse_expression_alias(column: &str) -> Option { } return Some(ExpressionAlias { expression, result_name: alias.value }); } + + for (index, _) in trimmed_end.match_indices('\'') { + if !previous_char(trimmed_end, index).is_some_and(char::is_whitespace) { + continue; + } + let alias = read_select_alias(&trimmed_end[index..])?; + if alias.end != trimmed_end.len() - index { + continue; + } + let expression = trimmed_end[..index].trim().to_string(); + if expression.is_empty() { + return None; + } + return Some(ExpressionAlias { expression, result_name: alias.value }); + } None } @@ -392,13 +407,36 @@ fn parse_column_alias(rest: &str) -> Option> { return Some(None); } let alias_text = strip_leading_as(trimmed).unwrap_or(trimmed).trim(); - let alias = read_identifier(alias_text, 0)?; + let alias = read_select_alias(alias_text)?; if alias.end != alias_text.len() { return None; } Some(Some(alias.value)) } +fn read_select_alias(text: &str) -> Option { + let pos = skip_whitespace(text, 0); + if !text[pos..].starts_with('\'') { + return read_identifier(text, pos); + } + + let mut value = String::new(); + let mut chars = text[pos + 1..].char_indices().peekable(); + while let Some((offset, ch)) = chars.next() { + if ch != '\'' { + value.push(ch); + continue; + } + if chars.peek().is_some_and(|(_, next)| *next == '\'') { + chars.next(); + value.push('\''); + continue; + } + return Some(Identifier { value, quoted: true, end: pos + 1 + offset + ch.len_utf8() }); + } + None +} + fn strip_leading_as(text: &str) -> Option<&str> { let prefix = text.get(..2)?; if !prefix.eq_ignore_ascii_case("AS") { @@ -1175,6 +1213,59 @@ mod tests { ); } + #[test] + fn accepts_mysql_string_quoted_column_aliases() { + for (sql, result_name) in [ + ("SELECT id, report_type '计划类型' FROM biz_work_log", "计划类型"), + ("SELECT id, report_type AS '计划类型' FROM biz_work_log", "计划类型"), + ("SELECT id, report_type '计划''类型' FROM biz_work_log", "计划'类型"), + ("SELECT id, report_type AS '计划''类型' FROM biz_work_log", "计划'类型"), + ] { + let result = analyze_editable_query_editability(sql); + + assert!(result.editable, "{sql}: {:?}", result.reason); + assert_eq!( + result.analysis.unwrap().columns, + vec![ + column(Some("id"), false, None, None, "id", "id"), + column(Some("report_type"), false, None, None, result_name, "report_type"), + ], + "{sql}" + ); + } + } + + #[test] + fn keeps_string_literals_computed_and_rejects_malformed_string_aliases() { + for sql in [ + "SELECT id, report_type '计划类型 FROM biz_work_log", + "SELECT id, report_type '计划类型' trailing FROM biz_work_log", + ] { + let result = analyze_editable_query_editability(sql); + assert!(!result.editable, "{sql}"); + } + + for sql in + ["SELECT id, 'constant' '固定值' FROM biz_work_log", "SELECT id, 'constant' AS '固定值' FROM biz_work_log"] + { + let computed = analyze_editable_query_editability(sql); + assert!(computed.editable, "{sql}"); + assert_eq!( + computed.analysis.unwrap().columns[1], + column(None, false, None, None, "固定值", "'constant'"), + "{sql}" + ); + } + + for sql in [ + "SELECT id, report_type AS 计划类型 FROM biz_work_log", + "SELECT id, report_type AS `计划类型` FROM biz_work_log", + "SELECT id, report_type AS \"计划类型\" FROM biz_work_log", + ] { + assert!(analyze_editable_query_editability(sql).editable, "{sql}"); + } + } + #[test] fn serializes_reason_values_like_frontend_union() { let json = serde_json::to_value(not_editable(QueryEditabilityReason::SetOperation)).unwrap(); diff --git a/packages/app-tests/sqlAnalysis.test.ts b/packages/app-tests/sqlAnalysis.test.ts index 642e6d69b9..68803b8680 100644 --- a/packages/app-tests/sqlAnalysis.test.ts +++ b/packages/app-tests/sqlAnalysis.test.ts @@ -224,6 +224,47 @@ test("accepts unquoted Unicode aliases in editable MySQL and SQL Server queries" }); }); +test("accepts MySQL string-quoted column aliases", () => { + for (const [sql, resultName] of [ + ["SELECT id, report_type '计划类型' FROM biz_work_log", "计划类型"], + ["SELECT id, report_type AS '计划类型' FROM biz_work_log", "计划类型"], + ["SELECT id, report_type '计划''类型' FROM biz_work_log", "计划'类型"], + ["SELECT id, report_type AS '计划''类型' FROM biz_work_log", "计划'类型"], + ] as const) { + const result = analyzeEditableQueryEditability(sql); + + assert.equal(result.editable, true, sql); + assert.deepEqual(result.analysis.columns, [ + { sourceName: "id", sourceNameQuoted: false, resultName: "id", expression: "id" }, + { sourceName: "report_type", sourceNameQuoted: false, resultName, expression: "report_type" }, + ]); + assert.deepEqual(sourceColumnsForResult(result.analysis, ["id", resultName]), ["id", "report_type"], sql); + assert.equal(allPrimaryKeysPresent(["id"], ["id", resultName], result.analysis), true, sql); + assert.equal(allEditableColumnsWriteable(result.analysis, ["id", resultName]), true, sql); + } +}); + +test("keeps string literals computed and rejects malformed string aliases", () => { + for (const sql of ["SELECT id, report_type '计划类型 FROM biz_work_log", "SELECT id, report_type '计划类型' trailing FROM biz_work_log"]) { + assert.equal(analyzeEditableQueryEditability(sql).editable, false, sql); + } + + for (const sql of ["SELECT id, 'constant' '固定值' FROM biz_work_log", "SELECT id, 'constant' AS '固定值' FROM biz_work_log"]) { + const computed = analyzeEditableQueryEditability(sql); + assert.equal(computed.editable, true, sql); + assert.deepEqual(computed.analysis.columns[1], { + sourceName: undefined, + sourceNameQuoted: false, + resultName: "固定值", + expression: "'constant'", + }); + } + + for (const sql of ["SELECT id, report_type AS 计划类型 FROM biz_work_log", "SELECT id, report_type AS `计划类型` FROM biz_work_log", 'SELECT id, report_type AS "计划类型" FROM biz_work_log']) { + assert.equal(analyzeEditableQueryEditability(sql).editable, true, sql); + } +}); + test("resolves metadata columns with dialect and quote aware identifier rules", () => { const postgresColumns = ["id", "ID", "name"]; assert.equal(resolveMetadataColumnName("postgres", "ID", false, postgresColumns), "id");