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:
18 files changed
+473
-17
No files matched your search
@@ -1,6 +1,6 @@
|
||||
import type { SqlCompletionColumn, SqlCompletionContext, SqlCompletionReferencedTable } from "@/lib/sql/sqlCompletion";
|
||||
import { SQL_SEMANTIC_DIALECTS } from "@/lib/sql/semantic/dialect";
|
||||
import type { SqlSemanticModel, SqlSemanticRowSource } from "@/lib/sql/semantic/types";
|
||||
import type { SqlSemanticModel, SqlSemanticRowSource, SqlSemanticToken } from "@/lib/sql/semantic/types";
|
||||
|
||||
export type SqlSemanticCompletionScopeKind = "keyword" | "table" | "schema" | "catalog" | "routine" | "columns" | "local";
|
||||
|
||||
@@ -13,6 +13,61 @@ export interface SqlSemanticCompletionScope {
|
||||
fallbackReason?: string;
|
||||
}
|
||||
|
||||
function isSemanticIdentifier(token: SqlSemanticToken | undefined): boolean {
|
||||
return token?.kind === "word" || token?.kind === "quoted_identifier";
|
||||
}
|
||||
|
||||
function selectStarToken(model: SqlSemanticModel): SqlSemanticToken | undefined {
|
||||
const range = model.cursorIntent.replacementRange;
|
||||
if (model.cursorIntent.kind !== "star") return undefined;
|
||||
return model.tokens.find((token) => token.text === "*" && token.span.start === range.start && token.span.end === range.end);
|
||||
}
|
||||
|
||||
export function sqlSemanticSelectStarTableSource(model: SqlSemanticModel): SqlSemanticRowSource | undefined {
|
||||
if (model.cursorIntent.kind !== "star") return undefined;
|
||||
const source = model.cursorIntent.targetSourceId ? model.rowSources.find((candidate) => candidate.id === model.cursorIntent.targetSourceId) : model.rowSources.length === 1 ? model.rowSources[0] : undefined;
|
||||
return source?.kind === "table" ? source : undefined;
|
||||
}
|
||||
|
||||
export function sqlSemanticSelectStarQualifierSql(model: SqlSemanticModel): string | undefined {
|
||||
const star = selectStarToken(model);
|
||||
if (!star) return undefined;
|
||||
const starIndex = model.tokens.indexOf(star);
|
||||
let index = starIndex - 1;
|
||||
if (model.tokens[index]?.text !== ".") return undefined;
|
||||
const qualifierEnd = model.tokens[index]!.span.start;
|
||||
index -= 1;
|
||||
if (!isSemanticIdentifier(model.tokens[index])) return undefined;
|
||||
let qualifierStart = model.tokens[index]!.span.start;
|
||||
while (index >= 2 && model.tokens[index - 1]?.text === "." && isSemanticIdentifier(model.tokens[index - 2])) {
|
||||
index -= 2;
|
||||
qualifierStart = model.tokens[index]!.span.start;
|
||||
}
|
||||
return model.sql.slice(qualifierStart, qualifierEnd).trim() || undefined;
|
||||
}
|
||||
|
||||
export function sqlSemanticSelectStarIsOnlyProjection(model: SqlSemanticModel): boolean {
|
||||
const star = selectStarToken(model);
|
||||
if (!star) return false;
|
||||
let selectIndex = -1;
|
||||
for (let index = model.tokens.length - 1; index >= 0; index -= 1) {
|
||||
const token = model.tokens[index];
|
||||
if (!token || token.span.end > star.span.start || token.depth !== star.depth) continue;
|
||||
if (token.kind === "word" && token.normalized === "select") {
|
||||
selectIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (selectIndex < 0) return false;
|
||||
const fromIndex = model.tokens.findIndex((token, index) => index > selectIndex && token.span.start >= star.span.end && token.depth === star.depth && token.kind === "word" && token.normalized === "from");
|
||||
const projectionEnd = fromIndex >= 0 ? model.tokens[fromIndex]!.span.start : model.statement.span.end;
|
||||
const projectionTokens = model.tokens.filter((token) => token.span.start >= model.tokens[selectIndex]!.span.end && token.span.end <= projectionEnd && token.kind !== "comment");
|
||||
if (projectionTokens[0]?.kind === "word" && (projectionTokens[0].normalized === "all" || projectionTokens[0].normalized === "distinct")) projectionTokens.shift();
|
||||
if (projectionTokens.pop() !== star) return false;
|
||||
if (projectionTokens.length === 0) return true;
|
||||
return projectionTokens.length % 2 === 0 && projectionTokens.every((token, index) => (index % 2 === 0 ? isSemanticIdentifier(token) : token.text === "."));
|
||||
}
|
||||
|
||||
export function sqlSemanticReferencedTables(model: SqlSemanticModel): SqlCompletionReferencedTable[] {
|
||||
return model.rowSources
|
||||
.filter((source) => source.kind !== "unknown")
|
||||
|
||||
@@ -675,7 +675,8 @@ function sourceForQualifier(sources: readonly SqlSemanticRowSource[], qualifierP
|
||||
|
||||
function starQualifierParts(before: readonly SqlSemanticToken[], starIndex: number, dialect: SqlSemanticDialectAdapter): string[] {
|
||||
let index = starIndex - 1;
|
||||
if (before[index]?.text === ".") index -= 1;
|
||||
if (before[index]?.text !== ".") return [];
|
||||
index -= 1;
|
||||
const qualifierParts: string[] = [];
|
||||
while (index >= 0) {
|
||||
const identifier = before[index];
|
||||
|
||||
@@ -11,6 +11,7 @@ import type { SqlSemanticBuildOptions, SqlSemanticSpan } from "@/lib/sql/semanti
|
||||
import { DEFAULT_SQL_SNIPPETS, MANTICORESEARCH_SQL_SNIPPETS, resolveSqlSnippetBodyForDatabase } from "@/lib/sql/sqlSnippetTemplates";
|
||||
import { requiresPostgresIdentifierQuote } from "@/lib/sql/sqlIdentifier";
|
||||
import { containsHan, orderedSubsequenceSpan, pinyinFirstLetters } from "@/lib/common/pinyin";
|
||||
import { quoteTableIdentifier } from "@/lib/table/tableSelectSql";
|
||||
|
||||
export { DEFAULT_SQL_SNIPPETS, resolveSqlSnippetBodyForDatabase } from "@/lib/sql/sqlSnippetTemplates";
|
||||
|
||||
@@ -2861,6 +2862,14 @@ export function quoteSqlIdentifier(identifier: string, dialect?: "mysql" | "post
|
||||
|
||||
const POSTGRES_IDENTIFIER_KEYWORDS = new Set(SQL_KEYWORDS.map((keyword) => keyword.toLowerCase()));
|
||||
|
||||
function quoteSelectStarColumnIdentifier(identifier: string, dialect?: "mysql" | "postgres" | "sqlserver", databaseType?: DatabaseType): string {
|
||||
if (!requiresPostgresIdentifierQuote(identifier, POSTGRES_IDENTIFIER_KEYWORDS)) return identifier;
|
||||
if (databaseType) return quoteTableIdentifier(databaseType, identifier);
|
||||
if (dialect === "mysql") return `\`${identifier.replaceAll("`", "``")}\``;
|
||||
if (dialect === "sqlserver") return `[${identifier.replaceAll("]", "]]")}]`;
|
||||
return quoteSqlIdentifier(identifier, dialect);
|
||||
}
|
||||
|
||||
function buildTableItems(
|
||||
context: Pick<SqlCompletionContext, "prefix" | "qualifier">,
|
||||
tables: SqlCompletionTable[],
|
||||
@@ -3167,16 +3176,38 @@ function buildPreferredKeywordItems(prefix: string, keywords: string[], keywordC
|
||||
}));
|
||||
}
|
||||
|
||||
function buildStarExpansionItem(context: SqlCompletionContext, columnsByTable: Map<string, SqlCompletionColumn[]>, t?: SqlCompletionTranslations, dialect?: "mysql" | "postgres" | "sqlserver"): SqlCompletionItem | null {
|
||||
function selectStarExpansionColumns(context: SqlCompletionContext, columnsByTable: Map<string, SqlCompletionColumn[]>): SqlCompletionColumn[] {
|
||||
const columns = context.qualifier ? referencedTablesForSelectAllColumns(context).flatMap((ref) => columnsForSelectAllReferencedTable(ref, columnsByTable)) : [...columnsByTable.values()].flat();
|
||||
const uniqueColumns = uniqueColumnsByName(columns);
|
||||
if (uniqueColumns.length === 0) return null;
|
||||
return uniqueColumnsByName(columns);
|
||||
}
|
||||
|
||||
export function selectStarResultColumnsMatch(options: { currentSql: string; targetFrom: number; targetTo: number; statementSql: string; sourceStatement?: string; sourceFrom?: number; sourceTo?: number }): boolean {
|
||||
if (!options.sourceStatement) return false;
|
||||
const hasSourceFrom = typeof options.sourceFrom === "number";
|
||||
const hasSourceTo = typeof options.sourceTo === "number";
|
||||
if (hasSourceFrom !== hasSourceTo) return false;
|
||||
if (!hasSourceFrom || !hasSourceTo) return options.statementSql === options.sourceStatement;
|
||||
// 词边界检查:已执行语句可能是当前内容的真前缀(如 `FROM users` → `FROM users_backup`),
|
||||
// 此时 slice 仍与 sourceStatement 相等,会用旧表列回退到新表。要求 sourceTo 落在标识符边界。
|
||||
const sourceToAtBoundary = !/[\w$]/.test(options.currentSql[options.sourceTo!] ?? "");
|
||||
return options.targetFrom >= options.sourceFrom! && options.targetTo <= options.sourceTo! && sourceToAtBoundary && options.currentSql.slice(options.sourceFrom, options.sourceTo) === options.sourceStatement;
|
||||
}
|
||||
|
||||
export function buildSelectStarExpansion(context: SqlCompletionContext, columnsByTable: Map<string, SqlCompletionColumn[]>, dialect?: "mysql" | "postgres" | "sqlserver", qualifierSql = context.qualifier, databaseType?: DatabaseType): string | null {
|
||||
const columns = selectStarExpansionColumns(context, columnsByTable);
|
||||
if (columns.length === 0) return null;
|
||||
// `alias.*` replaces only the `*`, so the first column must continue the already typed `alias.`.
|
||||
const expansion = context.qualifier ? buildSelectAllColumnExpansion(uniqueColumns, context.qualifier, true, dialect) : uniqueColumns.map((column) => quoteSqlIdentifier(column.name, dialect)).join(", ");
|
||||
return qualifierSql ? buildSelectAllColumnExpansion(columns, qualifierSql, true, dialect, databaseType) : columns.map((column) => quoteSelectStarColumnIdentifier(column.name, dialect, databaseType)).join(", ");
|
||||
}
|
||||
|
||||
function buildStarExpansionItem(context: SqlCompletionContext, columnsByTable: Map<string, SqlCompletionColumn[]>, t?: SqlCompletionTranslations, dialect?: "mysql" | "postgres" | "sqlserver"): SqlCompletionItem | null {
|
||||
const expansion = buildSelectStarExpansion(context, columnsByTable, dialect);
|
||||
if (!expansion) return null;
|
||||
const columnCount = selectStarExpansionColumns(context, columnsByTable).length;
|
||||
return {
|
||||
label: "* → columns",
|
||||
type: "snippet" as const,
|
||||
detail: `${(t?.starExpansionColumns ?? "{count} columns").replace("{count}", String(uniqueColumns.length))}: ${expansion.length > 60 ? expansion.slice(0, 57) + "..." : expansion}`,
|
||||
detail: `${(t?.starExpansionColumns ?? "{count} columns").replace("{count}", String(columnCount))}: ${expansion.length > 60 ? expansion.slice(0, 57) + "..." : expansion}`,
|
||||
apply: expansion,
|
||||
boost: 1900,
|
||||
};
|
||||
@@ -3248,10 +3279,10 @@ function referencedTablesForSelectAllColumns(context: SqlCompletionContext): Sql
|
||||
return context.referencedTables.filter((table) => referencedTableMatchesColumnQualifier(table, qualifier, qualifierLower, qualifiedTarget));
|
||||
}
|
||||
|
||||
function buildSelectAllColumnExpansion(columns: SqlCompletionColumn[], qualifier: string | undefined, qualifierAlreadyTyped: boolean, dialect?: "mysql" | "postgres" | "sqlserver"): string {
|
||||
function buildSelectAllColumnExpansion(columns: SqlCompletionColumn[], qualifier: string | undefined, qualifierAlreadyTyped: boolean, dialect?: "mysql" | "postgres" | "sqlserver", databaseType?: DatabaseType): string {
|
||||
return columns
|
||||
.map((column, index) => {
|
||||
const columnName = quoteSqlIdentifier(column.name, dialect);
|
||||
const columnName = quoteSelectStarColumnIdentifier(column.name, dialect, databaseType);
|
||||
if (!qualifier || (qualifierAlreadyTyped && index === 0)) return columnName;
|
||||
return `${qualifier}.${columnName}`;
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user