fix(editability): allow digit-leading unquoted table names
This commit is contained in:
5 files changed
+179
-11
No files matched your search
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user