feat(editor): 支持 SELECT * 展开为字段列表

* ✨ feat(editor): 支持将 SELECT * 展开为字段列表

- 新增右键菜单及 `Mod+Shift+X` 快捷键,可展开 `*` 和 `别名.*`
- 基于语义分析识别单表来源、别名及原始引用符,并按数据库方言处理字段名转义
- 优先读取本地或远程表字段元数据,单表纯星号查询可回退使用执行结果字段
- 增加字段加载失败提示及多语言文案
- 补充星号展开、结果匹配和快捷键注册测试

* fix(editor): selectStar 回退加词边界检查并记录加载失败

---------

Co-authored-by: t8y2 <t8y2@users.noreply.github.com>
Co-authored-by: skyler <1156263951@qq.com>
This commit is contained in:
authored and GitHub committed 2026-08-06 00:55:36 +08:00
1 parent fa6540b739
commit 5c281b1653
18 files changed
+473 -17

No files matched your search

@@ -1,5 +1,7 @@
import { describe, expect, it } from "vitest";
import { buildSqlCompletionItems, getSqlCompletionContext, shouldAutoOpenSqlCompletion } from "@/lib/sql/sqlCompletion";
import { buildSelectStarExpansion, buildSqlCompletionItems, getSqlCompletionContext, selectStarResultColumnsMatch, shouldAutoOpenSqlCompletion } from "@/lib/sql/sqlCompletion";
import { sqlCompletionContextFromSemantic } from "@/lib/sql/semantic/completion";
import { buildSqlSemanticModel } from "@/lib/sql/semantic/model";
describe("sqlCompletion keyword snippets", () => {
it("auto-opens and suggests SELECT when typing sel", () => {
@@ -14,6 +16,118 @@ describe("sqlCompletion keyword snippets", () => {
});
});
describe("SELECT star expansion", () => {
it("reuses completion column ordering for an unqualified star", () => {
const sql = "SELECT * FROM apis";
const context = getSqlCompletionContext(sql, "SELECT *".length);
expect(
buildSelectStarExpansion(
context,
new Map([
[
"apis",
[
{ name: "id", table: "apis" },
{ name: "created_at", table: "apis" },
{ name: "method", table: "apis" },
],
],
]),
),
).toBe("id, created_at, method");
});
it("preserves an alias while replacing only the star", () => {
const sql = "SELECT ap.* FROM apis AS ap";
const cursor = "SELECT ap.*".length;
const context = sqlCompletionContextFromSemantic(buildSqlSemanticModel(sql, cursor), getSqlCompletionContext(sql, cursor));
expect(
buildSelectStarExpansion(
context,
new Map([
[
"apis",
[
{ name: "id", table: "apis" },
{ name: "created_at", table: "apis" },
],
],
]),
),
).toBe("id, ap.created_at");
});
it.each([
["postgres", "postgres", '"Order Alias"', '"created at"'],
["mysql", "mysql", "`Order Alias`", "`created at`"],
["sqlserver", "sqlserver", "[Order Alias]", "[created at]"],
["oracle", "mysql", '"Order Alias"', '"created at"'],
] as const)("preserves a quoted %s alias for every expanded column", (databaseType, dialect, qualifierSql, quotedColumn) => {
const sql = `SELECT ${qualifierSql}.* FROM orders AS ${qualifierSql}`;
const cursor = sql.indexOf("*") + 1;
const context = sqlCompletionContextFromSemantic(buildSqlSemanticModel(sql, cursor, { databaseType, dialect }), getSqlCompletionContext(sql, cursor, { databaseType, dialect }));
expect(
buildSelectStarExpansion(
context,
new Map([
[
"orders",
[
{ name: "id", table: "orders" },
{ name: "created at", table: "orders" },
],
],
]),
dialect,
qualifierSql,
databaseType,
),
).toBe(`id, ${qualifierSql}.${quotedColumn}`);
});
it("expands an unqualified star from result columns when the table has an alias", () => {
const sql = "select *\nfrom apis as ap\nlimit 100;";
const cursor = "select *".length;
const context = sqlCompletionContextFromSemantic(buildSqlSemanticModel(sql, cursor), getSqlCompletionContext(sql, cursor));
expect(
buildSelectStarExpansion(
context,
new Map([
[
"apis",
[
{ name: "id", table: "apis" },
{ name: "created_at", table: "apis" },
{ name: "updated_at", table: "apis" },
{ name: "deleted_at", table: "apis" },
{ name: "method", table: "apis" },
],
],
]),
),
).toBe("id, created_at, updated_at, deleted_at, method");
});
it("accepts result columns only when their source still contains the target star", () => {
const currentSql = "select * from apis;\nselect * from users;";
const sourceStatement = "select * from users";
const sourceFrom = currentSql.lastIndexOf("select");
const targetFrom = currentSql.lastIndexOf("*");
expect(selectStarResultColumnsMatch({ currentSql, targetFrom, targetTo: targetFrom + 1, statementSql: sourceStatement, sourceStatement, sourceFrom, sourceTo: sourceFrom + sourceStatement.length })).toBe(true);
expect(selectStarResultColumnsMatch({ currentSql, targetFrom: currentSql.indexOf("*"), targetTo: currentSql.indexOf("*") + 1, statementSql: "select * from apis", sourceStatement, sourceFrom, sourceTo: sourceFrom + sourceStatement.length })).toBe(false);
});
it("rejects stale and incomplete result source metadata", () => {
expect(selectStarResultColumnsMatch({ currentSql: "select * from users", targetFrom: 7, targetTo: 8, statementSql: "select * from users", sourceStatement: "select * from apis" })).toBe(false);
expect(selectStarResultColumnsMatch({ currentSql: "select * from users", targetFrom: 7, targetTo: 8, statementSql: "select * from users", sourceStatement: "select * from users", sourceFrom: 0 })).toBe(false);
});
});
describe("sqlCompletion database functions", () => {
it("suggests ClickHouse functions with canonical casing and preferred placeholders", () => {
const sql = "SELECT tostart";