diff --git a/apps/desktop/src/lib/__tests__/sql/digitLeadingIdentifier.spec.ts b/apps/desktop/src/lib/__tests__/sql/digitLeadingIdentifier.spec.ts new file mode 100644 index 0000000000..ce977c140c --- /dev/null +++ b/apps/desktop/src/lib/__tests__/sql/digitLeadingIdentifier.spec.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from "vitest"; +import { analyzeEditableQueryEditability } from "@/lib/sql/sqlAnalysis"; +import { extractIdentifierAt, extractQualifiedIdentifierAt } from "@/lib/sql/sqlNavigation"; + +/** + * MySQL/MariaDB allow an unquoted identifier to start with a digit as long as it is not a pure + * number: `01_tablename` is a table name, `123` / `1e3` stay numeric literals. Before this the + * grid could not map the result back to a base table (read-only) and hover/Ctrl+click found no + * identifier at all. Regression coverage for #9992. + */ +describe("digit-leading identifiers (#9992)", () => { + it("treats a digit-leading table name as editable", () => { + const result = analyzeEditableQueryEditability("select * from 01_tablename"); + + expect(result.editable).toBe(true); + if (!result.editable) return; + expect(result.analysis.tableName).toBe("01_tablename"); + expect(result.analysis.tableNameQuoted).toBe(false); + expect(result.analysis.selectStar).toBe(true); + }); + + it("keeps schema-qualified digit-leading table names editable", () => { + const result = analyzeEditableQueryEditability("select id, name from app.01_tablename"); + + expect(result.editable).toBe(true); + if (!result.editable) return; + expect(result.analysis.schema).toBe("app"); + expect(result.analysis.tableName).toBe("01_tablename"); + expect(result.analysis.columns.map((column) => column.sourceName)).toEqual(["id", "name"]); + }); + + it("still treats a pure number as a literal instead of a source", () => { + expect(analyzeEditableQueryEditability("select * from 123").editable).toBe(false); + expect(analyzeEditableQueryEditability("select * from 1e3").editable).toBe(false); + }); + + it("extracts digit-leading identifiers for hover and Ctrl+click", () => { + const sql = "select * from 01_tablename"; + + expect(extractIdentifierAt(sql, sql.indexOf("01_tablename"))).toBe("01_tablename"); + expect(extractQualifiedIdentifierAt(sql, sql.indexOf("01_tablename"))?.parts).toEqual([{ value: "01_tablename", quoted: false }]); + }); + + it("does not extract numeric literals as identifiers", () => { + const sql = "select 123, 1e3 from t"; + + expect(extractIdentifierAt(sql, sql.indexOf("123"))).toBeNull(); + expect(extractIdentifierAt(sql, sql.indexOf("1e3"))).toBeNull(); + }); +}); diff --git a/apps/desktop/src/lib/__tests__/sql/sqlNavigation.spec.ts b/apps/desktop/src/lib/__tests__/sql/sqlNavigation.spec.ts index 82e04e3ff6..83781c86b1 100644 --- a/apps/desktop/src/lib/__tests__/sql/sqlNavigation.spec.ts +++ b/apps/desktop/src/lib/__tests__/sql/sqlNavigation.spec.ts @@ -355,11 +355,11 @@ describe("标识符提取的字符边界", () => { expect(extractIdentifierAt(sql, sql.indexOf("$col"))).toBe("$col"); }); - it("数字开头的片段不被当作标识符(只识别其后的字母段)", () => { - const sql = "select 1abc from t"; + it("数字开头的标识符按 MySQL 规则识别,纯数字仍是字面量", () => { + const sql = "select 1abc, 123 from t"; - expect(extractIdentifierAt(sql, sql.indexOf("1abc"))).toBeNull(); - expect(extractIdentifierAt(sql, sql.indexOf("abc"))).toBe("abc"); + expect(extractIdentifierAt(sql, sql.indexOf("1abc"))).toBe("1abc"); + expect(extractIdentifierAt(sql, sql.indexOf("123"))).toBeNull(); }); it("引号包裹的中文标识符去掉引号并标记 quoted", () => { diff --git a/apps/desktop/src/lib/sql/sqlAnalysis.ts b/apps/desktop/src/lib/sql/sqlAnalysis.ts index 5cbf4873e2..126b189709 100644 --- a/apps/desktop/src/lib/sql/sqlAnalysis.ts +++ b/apps/desktop/src/lib/sql/sqlAnalysis.ts @@ -748,7 +748,28 @@ function readIdentifier(text: string, start: number): { value: string; quoted: b return null; } 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; + if (match) return { value: match[0], quoted: false, end: pos + match[0].length }; + // MySQL/MariaDB also allow an unquoted identifier to begin with a digit as long as it is not a + // pure number, so `select * from 01_tablename` maps back to a base table while `123` / `1e3` + // stay literals (#9992). + const digitMatch = text.slice(pos).match(/^[0-9][\p{ID_Continue}$]*/u); + if (digitMatch && !isNumberShapedToken(digitMatch[0])) { + return { value: digitMatch[0], quoted: false, end: pos + digitMatch[0].length }; + } + return null; +} + +/** + * Mirrors `sqlNavigation.isNumberShapedToken`: a digit-leading token is only an identifier when + * it is not a number (`123`, `1e3`, `0x1f`, `0b101`). See #9992. + */ +function isNumberShapedToken(value: string): boolean { + if (!/^[0-9]/.test(value)) return false; + const rest = value.slice(1).toLowerCase(); + if (rest === "") return true; + if (rest.startsWith("x")) return /^x[0-9a-f]+$/.test(rest); + if (rest.startsWith("b")) return /^b[01]+$/.test(rest); + return /^[0-9e]+$/.test(rest); } function skipWhitespace(text: string, pos: number): number { diff --git a/apps/desktop/src/lib/sql/sqlNavigation.ts b/apps/desktop/src/lib/sql/sqlNavigation.ts index 923efa8423..d5078187f0 100644 --- a/apps/desktop/src/lib/sql/sqlNavigation.ts +++ b/apps/desktop/src/lib/sql/sqlNavigation.ts @@ -315,6 +315,21 @@ function isIdentifierStartChar(char: string | undefined): boolean { return !!char && IDENTIFIER_START_CHAR.test(char); } +/** + * MySQL/MariaDB allow an unquoted identifier to begin with a digit as long as it is not a pure + * number, so `01_tablename` is a table name while `123` / `1e3` / `0x1f` stay numeric literals + * (#9992). Other dialects reject the statement at parse time, so accepting the token here cannot + * resolve an identifier the server would refuse to run. + */ +function isNumberShapedToken(value: string): boolean { + if (!/^[0-9]/.test(value)) return false; + const rest = value.slice(1).toLowerCase(); + if (rest === "") return true; + if (rest.startsWith("x")) return /^x[0-9a-f]+$/.test(rest); + if (rest.startsWith("b")) return /^b[01]+$/.test(rest); + return /^[0-9e]+$/.test(rest); +} + function isIdentifierChar(char: string | undefined): boolean { return !!char && IDENTIFIER_PART_CHAR.test(char); } @@ -341,10 +356,14 @@ function readQuotedPart(text: string, start: number): IdentifierPart | null { } function readUnquotedPart(text: string, start: number): IdentifierPart | null { - if (!isIdentifierStartChar(text[start])) return null; + const first = text[start]; + const startsWithDigit = !!first && /^[0-9]$/.test(first); + if (!isIdentifierStartChar(first) && !startsWithDigit) return null; let end = start + 1; while (end < text.length && isIdentifierChar(text[end])) end += 1; - return { value: text.slice(start, end), start, end, quoted: false }; + const value = text.slice(start, end); + if (startsWithDigit && isNumberShapedToken(value)) return null; + return { value, start, end, quoted: false }; } function readIdentifierPart(text: string, start: number): IdentifierPart | null { diff --git a/crates/dbx-sql/src/sql_editability.rs b/crates/dbx-sql/src/sql_editability.rs index d384a29cd3..6de30e7acd 100644 --- a/crates/dbx-sql/src/sql_editability.rs +++ b/crates/dbx-sql/src/sql_editability.rs @@ -924,17 +924,27 @@ fn read_identifier(text: &str, start: usize) -> Option { return None; } - if !is_identifier_start(first) { + // MySQL/MariaDB let an unquoted identifier start with a digit as long as it is not a pure + // number, so `01_tablename` is a table name while `123` / `1e3` stay literals (#9992). + // Every other dialect that reaches this lenient analyzer rejects a leading digit at parse + // time, so accepting the token here cannot mis-bind a statement the server would run. + if !is_identifier_start(first) && !first.is_ascii_digit() { return None; } let mut end = pos + first.len_utf8(); + let mut value_end = text.len(); for (offset, ch) in text[end..].char_indices() { if !is_identifier_char(ch) { - return Some(Identifier { value: text[pos..end + offset].to_string(), quoted: false, end: end + offset }); + value_end = end + offset; + break; } } - end = text.len(); - Some(Identifier { value: text[pos..end].to_string(), quoted: false, end }) + end = value_end; + let value = &text[pos..end]; + if first.is_ascii_digit() && is_number_shaped(value) { + return None; + } + Some(Identifier { value: value.to_string(), quoted: false, end }) } fn skip_whitespace(text: &str, pos: usize) -> usize { @@ -1076,6 +1086,25 @@ fn is_identifier_start(ch: char) -> bool { ch == '_' || unicode_ident::is_xid_start(ch) } +/// Numeric literals keep their meaning even though MySQL allows an unquoted identifier to begin +/// with a digit: `123`, `1e3`, `0x1f` and `0b11` are numbers, `01_tablename` is not (#9992). +fn is_number_shaped(value: &str) -> bool { + let lower = value.to_ascii_lowercase(); + let Some(rest) = lower.strip_prefix(|ch: char| ch.is_ascii_digit()) else { + return false; + }; + if rest.is_empty() { + return true; + } + if let Some(hex) = rest.strip_prefix('x') { + return !hex.is_empty() && hex.chars().all(|ch| ch.is_ascii_hexdigit()); + } + if let Some(bits) = rest.strip_prefix('b') { + return !bits.is_empty() && bits.chars().all(|ch| ch == '0' || ch == '1'); + } + rest.chars().all(|ch| ch.is_ascii_digit() || matches!(ch, 'e' | '+' | '-')) +} + fn is_identifier_char(ch: char) -> bool { ch == '$' || unicode_ident::is_xid_continue(ch) } @@ -1084,6 +1113,55 @@ fn is_identifier_char(ch: char) -> bool { mod tests { use super::*; + #[test] + fn recognizes_digit_leading_identifiers_per_issue_9992() { + // MySQL/MariaDB allow an unquoted identifier that starts with a digit as long as it is + // not a pure number, so `01_tablename` must resolve like any other table name. + assert_eq!(read_identifier("01_tablename", 0).map(|ident| ident.value), Some("01_tablename".to_string())); + assert_eq!( + read_identifier("`01_tablename`", 0).map(|ident| (ident.value, ident.quoted)), + Some(("01_tablename".to_string(), true)) + ); + // Number-shaped tokens stay literals instead of becoming identifiers. + assert!(read_identifier("123", 0).is_none()); + assert!(read_identifier("1e3", 0).is_none()); + assert!(read_identifier("0x1f", 0).is_none()); + assert!(is_number_shaped("123")); + assert!(is_number_shaped("1e3")); + assert!(is_number_shaped("0x1f")); + assert!(is_number_shaped("0b101")); + assert!(!is_number_shaped("01_tablename")); + assert!(!is_number_shaped("1abc")); + assert!(!is_number_shaped("t1")); + + let result = analyze_editable_query_editability("select * from 01_tablename"); + assert!(result.editable, "{:?}", result.reason); + let analysis = result.analysis.expect("analysis"); + assert_eq!(analysis.table_name, "01_tablename"); + assert!(!analysis.table_name_quoted); + assert!(analysis.select_star); + + let qualified = analyze_editable_query_editability("select id, name from app.01_tablename"); + assert!(qualified.editable, "{:?}", qualified.reason); + let qualified_analysis = qualified.analysis.expect("analysis"); + assert_eq!(qualified_analysis.schema.as_deref(), Some("app")); + assert_eq!(qualified_analysis.table_name, "01_tablename"); + assert_eq!( + qualified_analysis.columns, + vec![ + column(Some("id"), false, None, None, "id", "id"), + column(Some("name"), false, None, None, "name", "name"), + ] + ); + + // A pure numeric projection stays an expression, not a column reference. + let literal = analyze_editable_query_editability("select 123 from 01_tablename"); + let literal_analysis = literal.analysis.expect("analysis"); + assert_eq!(literal_analysis.columns.len(), 1); + assert_eq!(literal_analysis.columns[0].source_name, None); + assert_eq!(literal_analysis.columns[0].expression, "123"); + } + #[test] fn recognizes_simple_single_table_select_as_editable() { let result =