feat(elasticsearch): add SQL-model execution with mapping-aware completion

- Route SELECT * FROM <index> through /_search for simple browses; aggregates/projections stay on /_sql
- Drive completion columns from /_mapping (flattened dotted fields, multi-fields)
- Switch editor completion to SQL when the active statement starts with SELECT/WITH on ES
- Skip generic SQL semantic diagnostics for ES (hyphenated wildcard indices caused 500s)
- Wire ES into server-side pagination plan; derive completion context from line blocks
- Fix result-grid pagination exceeding in-memory result sets (allRowsLoaded)
This commit is contained in:
Yong Studt
2026-06-08 17:53:05 +08:00
committed by GitHub
parent 465ed195cf
commit 2628fb8374
11 changed files with 749 additions and 26 deletions
@@ -29,6 +29,7 @@ import {
getSqlFunctionSignatureHelp,
getSqlCompletionContext,
getSqlCompletionResultValidFor,
isSqlLikeCompletionStatement,
shouldAutoOpenSqlCompletion,
extractCteDefinitions,
} from "@/lib/sqlCompletion";
@@ -739,7 +740,13 @@ async function refreshSemanticDiagnostics() {
setSemanticDiagnostics([]);
return;
}
if (!shouldRunSqlSemanticDiagnostics(sql, currentView.state.selection.main.head)) {
if (props.databaseType === "elasticsearch") {
setSemanticDiagnostics([]);
return;
}
if (
!shouldRunSqlSemanticDiagnostics(sql, currentView.state.selection.main.head, { databaseType: props.databaseType })
) {
scheduleSemanticDiagnostics(1200);
return;
}
@@ -933,15 +940,17 @@ async function provideSqlCompletions(
explicit: boolean,
) {
if (!props.connectionId) return null;
const fullDoc = currentState.doc.toString();
if (props.databaseType === "elasticsearch") {
return provideElasticsearchCompletions(currentState, position, explicit);
if (!isSqlLikeCompletionStatement(fullDoc, position)) {
return provideElasticsearchCompletions(currentState, position, explicit);
}
}
const hasDatabase = props.database != null;
const epoch = ++completionEpoch;
try {
const fullDoc = currentState.doc.toString();
if (!explicit && !shouldAutoOpenSqlCompletion(fullDoc, position)) return null;
const completionContext = getSqlCompletionContext(fullDoc, position);
+35 -3
View File
@@ -1857,10 +1857,30 @@ const showTruncationWarning = computed(
() => props.result.truncated === true && typeof props.pageLimit !== "number" && props.result.has_more !== true,
);
const isResultsContext = computed(() => props.context === "results");
const displayedTotalRowCount = computed(() => props.totalRowCount ?? manualTotalRowCount.value);
// affected_rows reported by the backend can be larger than the rows we
// actually have in memory — e.g. ES auto-pages SELECT * on a big index and
// reports the index's true match count. Surface that in the status bar so
// the user sees the real total, but do NOT use it to unlock pagination:
// we don't have those rows, so letting the user page into them would just
// show blank screens.
const inferredBackendTotalRowCount = computed(() => {
const affected = props.result.affected_rows;
if (typeof affected !== "number" || !Number.isFinite(affected)) return undefined;
if (affected <= props.result.rows.length) return undefined;
return affected;
});
const serverKnownTotalRowCount = computed(() => props.totalRowCount ?? manualTotalRowCount.value);
const displayedTotalRowCount = computed(() => serverKnownTotalRowCount.value ?? inferredBackendTotalRowCount.value);
// Only a server-confirmed total drives pagination — an inferred total means
// rows exist that we never fetched, so navigation must stay inside rows.length.
const hasKnownTotalRowCount = computed(
() => typeof displayedTotalRowCount.value === "number" && displayedTotalRowCount.value >= 0,
() => typeof serverKnownTotalRowCount.value === "number" && serverKnownTotalRowCount.value >= 0,
);
// When context=results and the caller hasn't configured server-side
// pagination (no pageLimit), the backend handed us every row up-front and
// rowCount IS the total. Without this hint, the "page is full → assume more"
// fallback in canGoNextDataGridPage lets the user keep clicking next forever.
const allRowsLoaded = computed(() => isResultsContext.value && props.pageLimit === undefined);
const canGoNextPage = computed(() => {
return canGoNextDataGridPage({
hasMore: props.result.has_more,
@@ -1869,10 +1889,13 @@ const canGoNextPage = computed(() => {
pageOffset: props.pageOffset,
currentPage: currentPage.value,
totalRowCount: hasKnownTotalRowCount.value ? displayedTotalRowCount.value : undefined,
allRowsLoaded: allRowsLoaded.value,
});
});
const canJumpLastPage = computed(
() => canGoNextPage.value && (hasKnownTotalRowCount.value || !!props.tableMeta || !!props.countSql),
() =>
canGoNextPage.value &&
(hasKnownTotalRowCount.value || allRowsLoaded.value || !!props.tableMeta || !!props.countSql),
);
const totalRowCountBusy = computed(() => props.totalRowCountLoading === true || manualTotalRowCountLoading.value);
const canCalculateTotalRowCount = computed(
@@ -2015,6 +2038,15 @@ async function lastPage() {
emit("paginate", (lastPageNum - 1) * pageSize.value, pageSize.value, currentWhereInput(), currentOrderBy());
return;
}
if (allRowsLoaded.value) {
const total = props.result.rows.length;
if (total <= 0) return;
const lastPageNum = Math.ceil(total / pageSize.value);
if (lastPageNum <= currentPage.value) return;
currentPage.value = lastPageNum;
resetGridVerticalScroll(true);
return;
}
if (!props.connectionId) return;
const countTarget = await buildCurrentCountTarget();
const sql = countTarget?.sql;
+13 -4
View File
@@ -5,20 +5,29 @@ export interface CanGoNextDataGridPageOptions {
pageOffset?: number;
currentPage?: number;
totalRowCount?: number;
// True when every result row is already in memory (SQL editor result with no
// server-side pagination). rowCount IS the authoritative total in that case,
// so a full final page must not appear as "more available".
allRowsLoaded?: boolean;
}
export function canGoNextDataGridPage(options: CanGoNextDataGridPageOptions): boolean {
if (options.hasMore === true) return true;
const pageSize = Math.max(1, options.pageSize);
const currentOffset =
typeof options.pageOffset === "number" && options.pageOffset >= 0
? options.pageOffset
: Math.max(0, (options.currentPage ?? 1) - 1) * pageSize;
const totalRowCount = options.totalRowCount;
if (typeof totalRowCount === "number" && Number.isFinite(totalRowCount) && totalRowCount >= 0) {
const currentOffset =
typeof options.pageOffset === "number" && options.pageOffset >= 0
? options.pageOffset
: Math.max(0, (options.currentPage ?? 1) - 1) * pageSize;
return currentOffset + pageSize < totalRowCount;
}
if (options.allRowsLoaded === true) {
return currentOffset + pageSize < options.rowCount;
}
return options.rowCount >= pageSize;
}
+87 -7
View File
@@ -1109,7 +1109,71 @@ export function shouldAutoOpenSqlCompletion(sql: string, cursor: number): boolea
) {
return true;
}
return /[\w$.]/.test(previousChar);
return /[\w$.@]/.test(previousChar);
}
export function isSqlLikeCompletionStatement(sql: string, cursor: number): boolean {
const statement = extractStatementAt(sql, cursor).trimStart();
if (/^(select|with)\b/i.test(statement)) return true;
return currentLineBlockStartsSql(sql, cursor);
}
function currentLineBlockStartsSql(sql: string, cursor: number): boolean {
return currentSqlLikeLineBlockSpan(sql, cursor) != null;
}
function currentSqlLikeLineBlockSpan(sql: string, cursor: number): { start: number; end: number } | null {
const safeCursor = Math.max(0, Math.min(cursor, sql.length));
const beforeCursor = sql.slice(0, safeCursor);
const lines = beforeCursor.split(/\r?\n/);
let start: number | null = null;
let offset = 0;
for (const line of lines) {
const trimmed = line.trimStart();
if (trimmed) {
const indentation = line.length - trimmed.length;
if (/^(select|with)\b/i.test(trimmed)) start = offset + indentation;
if (/^(get|post|put|delete|patch|head)\s+\//i.test(trimmed)) start = null;
}
offset += line.length + 1;
}
if (start == null) return null;
let end = sql.length;
let inSingleQuote = false;
let inDoubleQuote = false;
for (let index = start; index < sql.length; index += 1) {
const ch = sql[index];
if (ch === "'" && !inDoubleQuote) inSingleQuote = !inSingleQuote;
else if (ch === '"' && !inSingleQuote) inDoubleQuote = !inDoubleQuote;
else if (ch === ";" && !inSingleQuote && !inDoubleQuote && index >= safeCursor) {
end = index;
break;
}
}
const blockEnd = currentLineBlockEnd(sql, safeCursor, start);
if (blockEnd != null) end = Math.min(end, blockEnd);
return { start, end };
}
function currentLineBlockEnd(sql: string, cursor: number, start: number): number | null {
let lineStart = sql.lastIndexOf("\n", cursor - 1) + 1;
while (lineStart < sql.length) {
const lineEnd = sql.indexOf("\n", lineStart);
const boundedLineEnd = lineEnd >= 0 ? lineEnd : sql.length;
const line = sql.slice(lineStart, boundedLineEnd);
const trimmed = line.trimStart();
if (lineStart > start && (!trimmed || /^(get|post|put|delete|patch|head)\s+\//i.test(trimmed))) {
return lineStart;
}
if (lineEnd < 0) break;
lineStart = lineEnd + 1;
}
return null;
}
export function getSqlCompletionResultValidFor(sql: string, cursor: number): RegExp | undefined {
@@ -1144,6 +1208,9 @@ export function getSqlFunctionSignatureHelp(sql: string, cursor: number): SqlFun
* Respects semicolons and string literals.
*/
function extractStatementStart(sql: string, cursor: number): number {
const lineBlock = currentSqlLikeLineBlockSpan(sql, cursor);
if (lineBlock) return lineBlock.start;
let start = 0;
let inSingleQuote = false;
let inDoubleQuote = false;
@@ -1166,6 +1233,9 @@ function extractStatementStart(sql: string, cursor: number): number {
* Respects semicolons and string literals.
*/
function extractStatementAt(sql: string, cursor: number): string {
const lineBlock = currentSqlLikeLineBlockSpan(sql, cursor);
if (lineBlock) return sql.slice(lineBlock.start, lineBlock.end).trim();
const start = extractStatementStart(sql, cursor);
let end = sql.length;
let inSingleQuote = false;
@@ -1364,11 +1434,11 @@ function parseTrailingIdentifierPart(input: string, endExclusive: number): { sta
}
let start = end;
while (start >= 0 && /[A-Za-z0-9_$]/.test(input[start] ?? "")) start -= 1;
while (start >= 0 && /[A-Za-z0-9_$@]/.test(input[start] ?? "")) start -= 1;
start += 1;
if (start >= endExclusive) return null;
const raw = input.slice(start, endExclusive);
if (!/^[A-Za-z_][\w$]*$/.test(raw)) return null;
if (!/^[@A-Za-z_][\w$@]*$/.test(raw)) return null;
return { start, raw };
}
@@ -1599,21 +1669,31 @@ function extractReferencedTables(sql: string): SqlCompletionReferencedTable[] {
]);
const pattern =
/\b(?:from|join|update|into|apply)\s+((?:"[^"]+"|`[^`]+`|[A-Za-z_][\w$]*)(?:\.(?:"[^"]+"|`[^`]+`|[A-Za-z_][\w$]*))?)(?:\s+(?:as\s+)?([A-Za-z_][\w$]*))?/gi;
/\b(?:from|join|update|into|apply)\s+((?:"[^"]+"|`[^`]+`|[^\s,;()]+)(?:\.(?:"[^"]+"|`[^`]+`|[^\s,;()]+))?)(?:\s+(?:as\s+)?([A-Za-z_][\w$]*))?/gi;
const referenced: SqlCompletionReferencedTable[] = [];
for (const match of sql.matchAll(pattern)) {
const rawName = match[1];
const alias = match[2];
const [first, second] = splitQualifiedName(rawName);
if (!first) continue;
// Filter out SQL keywords that accidentally matched as aliases
const cleanAlias = alias && !ALIAS_BLACKLIST.has(alias.toLowerCase()) ? alias : undefined;
if (isElasticsearchStyleIndexName(rawName)) {
referenced.push({ name: unquoteIdentifier(rawName), alias: cleanAlias });
continue;
}
const [first, second] = splitQualifiedName(rawName);
if (!first) continue;
const table = second ? { schema: first, name: second, alias: cleanAlias } : { name: first, alias: cleanAlias };
referenced.push(table);
}
return referenced;
}
function isElasticsearchStyleIndexName(name: string | undefined): name is string {
if (!name) return false;
if ((name.startsWith('"') && name.endsWith('"')) || (name.startsWith("`") && name.endsWith("`"))) return false;
return /[-*]/.test(name);
}
function extractSelectAliases(sql: string): string[] {
const selectList = extractSelectList(sql);
if (!selectList) return [];
@@ -2292,7 +2372,7 @@ function buildColumnApply(
context: SqlCompletionContext,
dialect?: "mysql" | "postgres" | "sqlserver",
): string {
if (context.qualifier || !column.displayLabel.includes(".")) {
if (context.qualifier || column.displayLabel === column.name || !column.displayLabel.includes(".")) {
return quoteSqlIdentifier(column.name, dialect);
}
return `${quoteSqlIdentifier(column.table, dialect)}.${quoteSqlIdentifier(column.name, dialect)}`;
+13 -2
View File
@@ -1,6 +1,12 @@
import type { SqlCompletionColumn, SqlCompletionTable } from "@/lib/sqlCompletion";
import { getSqlCompletionContext } from "@/lib/sqlCompletion";
import type { SqlColumnReference, SqlReferenceAnalysis, SqlTableReference, SqlTextSpan } from "@/types/database";
import type {
DatabaseType,
SqlColumnReference,
SqlReferenceAnalysis,
SqlTableReference,
SqlTextSpan,
} from "@/types/database";
export interface SqlSemanticDiagnostic {
span: SqlTextSpan;
@@ -93,7 +99,12 @@ export function areSqlSemanticDiagnosticsEqual(
});
}
export function shouldRunSqlSemanticDiagnostics(sql: string, cursor: number): boolean {
export function shouldRunSqlSemanticDiagnostics(
sql: string,
cursor: number,
options: { databaseType?: DatabaseType } = {},
): boolean {
if (options.databaseType === "elasticsearch") return false;
const context = getSqlCompletionContext(sql, cursor);
if (context.suggestTables || context.exclusiveTableSuggestions || context.exclusiveColumnSuggestions) return false;
if (context.qualifier) return false;
+14
View File
@@ -1270,6 +1270,20 @@ export const useQueryStore = defineStore("query", () => {
current.resultTotalRowCount = undefined;
}
current.resultTotalRowCountLoading = current.mode === "query" && !!current.result && !!countSql;
// Server-side pagination without a countSql: the backend (currently
// the Elasticsearch driver) already reports the true match total via
// affected_rows. Use it directly so the result-grid can compute the
// page count without issuing a separate COUNT query.
if (
current.result &&
current.mode === "query" &&
typeof pageLimit === "number" &&
!countSql &&
typeof current.result.affected_rows === "number"
) {
current.resultTotalRowCount = current.result.affected_rows;
current.resultTotalRowCountLoading = false;
}
touchResult(current);
if (current.mode === "query" && current.result) {
countQueryTotalRowsInBackground({