From 4b522f5c1a66e47a621636a161cc2f80e36aeb5b Mon Sep 17 00:00:00 2001 From: t8y2 <1156263951@qq.com> Date: Thu, 30 Jul 2026 12:32:23 +0800 Subject: [PATCH] fix(grid): support Unicode aliases in editable queries --- Cargo.lock | 1 + apps/desktop/src/lib/sql/sqlAnalysis.ts | 8 ++--- crates/dbx-core/Cargo.toml | 1 + crates/dbx-core/src/sql_editability.rs | 39 +++++++++++++++++++++++-- packages/app-tests/sqlAnalysis.test.ts | 22 ++++++++++++++ 5 files changed, 64 insertions(+), 7 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 5b2a7f7954..938b4e9b41 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1747,6 +1747,7 @@ dependencies = [ "tokio-util", "tracing", "tracing-subscriber", + "unicode-ident", "unicode-width", "utoipa", "uuid", diff --git a/apps/desktop/src/lib/sql/sqlAnalysis.ts b/apps/desktop/src/lib/sql/sqlAnalysis.ts index 9c9dac986b..1510b1923e 100644 --- a/apps/desktop/src/lib/sql/sqlAnalysis.ts +++ b/apps/desktop/src/lib/sql/sqlAnalysis.ts @@ -246,7 +246,7 @@ function parseStarSelectColumn(col: string, sources?: EditableQuerySource[]): Ed expression: col, }; } - const starMatch = col.match(/^((?:[A-Za-z_][\w$]*|"[^"]+"|`[^`]+`|\[[^\]]+\]))\s*\.\s*\*$/); + const starMatch = col.match(/^((?:[\p{ID_Start}_][\p{ID_Continue}$]*|"[^"]+"|`[^`]+`|\[[^\]]+\]))\s*\.\s*\*$/u); if (!starMatch) return null; const qualifier = readIdentifier(starMatch[1]!, 0); if (!qualifier) return null; @@ -272,7 +272,7 @@ function parseComputedSelectColumn(col: string): EditableQueryColumn | null { } function parseExpressionAlias(col: string): { expression: string; resultName: string } | null { - const asMatch = col.match(/\bAS\s+((?:"[^"]+")|(?:`[^`]+`)|(?:\[[^\]]+\])|(?:[A-Za-z_][\w$]*))\s*$/i); + 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; @@ -426,7 +426,7 @@ function readIdentifier(text: string, start: number): { value: string; quoted: b } return null; } - const match = text.slice(pos).match(/^[A-Za-z_][\w$]*/); + const match = text.slice(pos).match(/^[\p{ID_Start}_][\p{ID_Continue}$]*/u); return match ? { value: match[0], quoted: false, end: pos + match[0].length } : null; } @@ -484,7 +484,7 @@ function findTopLevelKeyword(sql: string, keyword: string, start: number): numbe } function isIdentifierChar(ch: string): boolean { - return /[\w$]/.test(ch); + return /[\p{ID_Continue}$]/u.test(ch); } function escapeRegExp(value: string): string { diff --git a/crates/dbx-core/Cargo.toml b/crates/dbx-core/Cargo.toml index aaa7117fc2..84a0afa3d6 100644 --- a/crates/dbx-core/Cargo.toml +++ b/crates/dbx-core/Cargo.toml @@ -17,6 +17,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", features = ["arbitrary_precision", "preserve_order"] } utoipa = { version = "5.5", optional = true } unicode-width = "0.2" +unicode-ident = "1" serde_yaml_ng = "0.10" json5 = "0.4" regex = "1" diff --git a/crates/dbx-core/src/sql_editability.rs b/crates/dbx-core/src/sql_editability.rs index 7e62852bfd..1a263f6f39 100644 --- a/crates/dbx-core/src/sql_editability.rs +++ b/crates/dbx-core/src/sql_editability.rs @@ -720,12 +720,12 @@ fn read_identifier(text: &str, start: usize) -> Option { return None; } - if !(first.is_ascii_alphabetic() || first == '_') { + if !is_identifier_start(first) { return None; } let mut end = pos + first.len_utf8(); for (offset, ch) in text[end..].char_indices() { - if !(ch.is_ascii_alphanumeric() || ch == '_' || ch == '$') { + if !is_identifier_char(ch) { return Some(Identifier { value: text[pos..end + offset].to_string(), quoted: false, end: end + offset }); } } @@ -846,8 +846,12 @@ fn previous_char(text: &str, index: usize) -> Option { text[..index].chars().next_back() } +fn is_identifier_start(ch: char) -> bool { + ch == '_' || unicode_ident::is_xid_start(ch) +} + fn is_identifier_char(ch: char) -> bool { - ch.is_ascii_alphanumeric() || ch == '_' || ch == '$' + ch == '$' || unicode_ident::is_xid_continue(ch) } #[cfg(test)] @@ -1142,6 +1146,35 @@ mod tests { ); } + #[test] + fn accepts_unquoted_unicode_aliases_in_mysql_and_sql_server_queries() { + for sql in [ + "SELECT Guid, FDeleted AS 禁用, IsAuditing AS 审核 FROM xy.dbo.GL_CUSTOM WHERE TJBH=\n24049", + "SELECT Guid, FDeleted AS 禁用, IsAuditing AS 审核 FROM xy.GL_CUSTOM WHERE TJBH=24049", + ] { + let result = analyze_editable_query_editability(sql); + + assert!(result.editable, "{sql}"); + assert_eq!( + result.analysis.unwrap().columns, + vec![ + column(Some("Guid"), false, None, None, "Guid", "Guid"), + column(Some("FDeleted"), false, None, None, "禁用", "FDeleted"), + column(Some("IsAuditing"), false, None, None, "审核", "IsAuditing"), + ] + ); + } + + let computed = analyze_editable_query_editability( + "SELECT Guid, FDeleted AS 禁用, CASE WHEN IsAuditing = 1 THEN 1 ELSE 0 END AS 审核状态 FROM xy.dbo.GL_CUSTOM", + ); + assert!(computed.editable); + assert_eq!( + computed.analysis.unwrap().columns[2], + column(None, false, None, None, "审核状态", "CASE WHEN IsAuditing = 1 THEN 1 ELSE 0 END",) + ); + } + #[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 4eef2d0bfd..642e6d69b9 100644 --- a/packages/app-tests/sqlAnalysis.test.ts +++ b/packages/app-tests/sqlAnalysis.test.ts @@ -202,6 +202,28 @@ test("accepts aliased primary key source columns for row identity", () => { assert.equal(allPrimaryKeysPresent(["id"], ["id", "name"], analyzeEditableQuery("select id, name from users")!), true); }); +test("accepts unquoted Unicode aliases in editable MySQL and SQL Server queries", () => { + for (const sql of ["SELECT Guid, FDeleted AS 禁用, IsAuditing AS 审核 FROM xy.dbo.GL_CUSTOM WHERE TJBH=\n24049", "SELECT Guid, FDeleted AS 禁用, IsAuditing AS 审核 FROM xy.GL_CUSTOM WHERE TJBH=24049"]) { + const result = analyzeEditableQueryEditability(sql); + + assert.equal(result.editable, true, sql); + assert.deepEqual(result.analysis.columns, [ + { sourceName: "Guid", sourceNameQuoted: false, resultName: "Guid", expression: "Guid" }, + { sourceName: "FDeleted", sourceNameQuoted: false, resultName: "禁用", expression: "FDeleted" }, + { sourceName: "IsAuditing", sourceNameQuoted: false, resultName: "审核", expression: "IsAuditing" }, + ]); + } + + const computed = analyzeEditableQueryEditability("SELECT Guid, FDeleted AS 禁用, CASE WHEN IsAuditing = 1 THEN 1 ELSE 0 END AS 审核状态 FROM xy.dbo.GL_CUSTOM"); + assert.equal(computed.editable, true); + assert.deepEqual(computed.analysis.columns[2], { + sourceName: undefined, + sourceNameQuoted: false, + resultName: "审核状态", + expression: "CASE WHEN IsAuditing = 1 THEN 1 ELSE 0 END", + }); +}); + test("resolves metadata columns with dialect and quote aware identifier rules", () => { const postgresColumns = ["id", "ID", "name"]; assert.equal(resolveMetadataColumnName("postgres", "ID", false, postgresColumns), "id");