fix(elasticsearch): complete mapping fields in Query DSL field slots
This commit is contained in:
1 parent
dd84e6c06a
commit
c0d6965b5f
5 files changed
+628
-58
No files matched your search
@@ -88,7 +88,15 @@ import { driverProfileHasCompletionCandidates } from "@/lib/database/driverProfi
|
||||
import { sqlCompletionContextFromSemantic, sqlSemanticSelectStarIsOnlyProjection, sqlSemanticSelectStarQualifierSql, sqlSemanticSelectStarTableSources } from "@/lib/sql/semantic/completion";
|
||||
import { buildSqlSemanticModel } from "@/lib/sql/semantic/model";
|
||||
import { mergeSqlSemanticReferenceAnalysis, resolveSqlSemanticNavigationTarget } from "@/lib/sql/semantic/references";
|
||||
import { buildElasticsearchCompletionItemsFromContext, getElasticsearchCompletionContext, getElasticsearchCompletionResultValidFor, shouldAutoOpenElasticsearchCompletion, type ElasticsearchCompletionItem } from "@/lib/elasticsearch/elasticsearchCompletion";
|
||||
import {
|
||||
buildElasticsearchCompletionItemsFromContext,
|
||||
elasticsearchCompletionNeedsFields,
|
||||
getElasticsearchCompletionContext,
|
||||
getElasticsearchCompletionResultValidFor,
|
||||
shouldAutoOpenElasticsearchCompletion,
|
||||
type ElasticsearchCompletionField,
|
||||
type ElasticsearchCompletionItem,
|
||||
} from "@/lib/elasticsearch/elasticsearchCompletion";
|
||||
import { buildMongoCompletionItemsFromContext, getMongoCompletionContext, getMongoCompletionResultValidFor, mongoCompletionNeedsCollections, mongoCompletionNeedsFields, shouldAutoOpenMongoCompletion, type MongoCompletionItem } from "@/lib/mongo/mongoCompletion";
|
||||
import {
|
||||
buildSqlServerUseDatabaseCompletionItems,
|
||||
@@ -4526,9 +4534,11 @@ function shouldApplyCompletionAsSnippet(item: QueryCompletionItem): boolean {
|
||||
function completionOptionForItem(item: QueryCompletionItem | BatchColumnSelectionActionItem) {
|
||||
const filterText = "filterText" in item && typeof item.filterText === "string" ? item.filterText : undefined;
|
||||
const labelPresentation = completionLabelPresentation(item.label, filterText);
|
||||
const sortText = "sortText" in item && typeof item.sortText === "string" ? item.sortText : labelPresentation.sortText;
|
||||
if (isBatchColumnSelectionAction(item)) {
|
||||
return {
|
||||
...labelPresentation,
|
||||
...(sortText ? { sortText } : {}),
|
||||
dbxBatchColumnSelectionAction: { sessionKey: item.sessionKey },
|
||||
type: item.type,
|
||||
detail: item.detail,
|
||||
@@ -4545,6 +4555,7 @@ function completionOptionForItem(item: QueryCompletionItem | BatchColumnSelectio
|
||||
if (shouldApplyCompletionAsSnippet(item) && item.apply) {
|
||||
const completion = codeMirrorSnippetCompletion(item.apply, {
|
||||
...labelPresentation,
|
||||
...(sortText ? { sortText } : {}),
|
||||
type: item.type,
|
||||
detail: item.detail,
|
||||
info: item.info,
|
||||
@@ -4578,6 +4589,7 @@ function completionOptionForItem(item: QueryCompletionItem | BatchColumnSelectio
|
||||
}
|
||||
return cacheBatchColumnSelectionOption(batchColumnSelection, {
|
||||
...labelPresentation,
|
||||
...(sortText ? { sortText } : {}),
|
||||
...(batchColumnSelection ? { dbxBatchColumnSelection: batchColumnSelection } : {}),
|
||||
type: item.type,
|
||||
detail: item.detail,
|
||||
@@ -4612,6 +4624,7 @@ async function provideElasticsearchCompletions(currentState: import("@codemirror
|
||||
|
||||
const completionContext = getElasticsearchCompletionContext(fullDoc, position);
|
||||
let indices: string[] = [];
|
||||
let fields: ElasticsearchCompletionField[] = [];
|
||||
if (props.database != null && completionContext.mode === "path") {
|
||||
try {
|
||||
indices = await connectionStore.listElasticsearchCompletionIndices(props.connectionId, props.database);
|
||||
@@ -4619,9 +4632,16 @@ async function provideElasticsearchCompletions(currentState: import("@codemirror
|
||||
indices = [];
|
||||
}
|
||||
}
|
||||
if (elasticsearchCompletionNeedsFields(completionContext) && completionContext.index) {
|
||||
try {
|
||||
fields = await connectionStore.listElasticsearchCompletionFields(props.connectionId, completionContext.index);
|
||||
} catch {
|
||||
fields = [];
|
||||
}
|
||||
}
|
||||
if (epoch !== completionEpoch) return null;
|
||||
|
||||
const items = buildElasticsearchCompletionItemsFromContext(completionContext, { indices });
|
||||
const items = buildElasticsearchCompletionItemsFromContext(completionContext, { indices, fields });
|
||||
return buildCompletionResult(items, completionContext.from, getElasticsearchCompletionResultValidFor());
|
||||
}
|
||||
|
||||
|
||||
@@ -4,11 +4,12 @@ export type ElasticsearchJsonSlot = "key" | "value";
|
||||
|
||||
export interface ElasticsearchCompletionItem {
|
||||
label: string;
|
||||
type: "keyword" | "property" | "text" | "snippet";
|
||||
type: "keyword" | "property" | "text" | "snippet" | "column";
|
||||
detail?: string;
|
||||
info?: string;
|
||||
apply?: string;
|
||||
filterText?: string;
|
||||
sortText?: string;
|
||||
// Set when `apply` carries snippet fields so the editor expands it instead of
|
||||
// inserting the raw `${}` markers.
|
||||
applyAsSnippet?: boolean;
|
||||
@@ -17,6 +18,11 @@ export interface ElasticsearchCompletionItem {
|
||||
boost: number;
|
||||
}
|
||||
|
||||
export interface ElasticsearchCompletionField {
|
||||
name: string;
|
||||
dataType?: string;
|
||||
}
|
||||
|
||||
export interface ElasticsearchCompletionContext {
|
||||
mode: ElasticsearchCompletionMode;
|
||||
prefix: string;
|
||||
@@ -26,12 +32,15 @@ export interface ElasticsearchCompletionContext {
|
||||
segmentIndex?: number;
|
||||
replaceClosingQuote?: '"';
|
||||
jsonSlot?: ElasticsearchJsonSlot;
|
||||
jsonFieldSlot?: boolean;
|
||||
hasKeySeparator?: boolean;
|
||||
jsonIndent?: string;
|
||||
index?: string;
|
||||
}
|
||||
|
||||
export interface ElasticsearchCompletionInput {
|
||||
indices?: string[];
|
||||
fields?: ElasticsearchCompletionField[];
|
||||
}
|
||||
|
||||
const HTTP_METHODS = ["GET", "POST", "PUT", "DELETE", "HEAD"] as const;
|
||||
@@ -76,6 +85,20 @@ const JSON_KEYWORDS = ["query", "bool", "must", "should", "must_not", "filter",
|
||||
const JSON_ARRAY_KEYS = new Set(["must", "should", "must_not", "filter", "sort", "_source", "fields"]);
|
||||
const JSON_SCALAR_KEYS = new Set(["size", "from", "track_total_hits"]);
|
||||
|
||||
// Query-clause objects whose keys are mapping field names (`"term": { "status": ... }`).
|
||||
const FIELD_NAME_OBJECT_KEYS = new Set(["term", "terms", "match", "match_phrase", "match_phrase_prefix", "match_bool_prefix", "range", "wildcard", "prefix", "fuzzy", "regexp", "span_term"]);
|
||||
|
||||
// `terms`/`range` are also aggregation types whose keys are `field`/`ranges`, not mapping names.
|
||||
const AGGREGATION_TYPE_FIELD_OBJECT_KEYS = new Set(["terms", "range"]);
|
||||
const AGGREGATION_KEYS = new Set(["aggs", "aggregations"]);
|
||||
const QUERY_CLAUSE_KEYS = new Set(["query", "bool", "must", "should", "filter", "must_not", "nested", "constant_score"]);
|
||||
|
||||
// Arrays whose values (or object-element keys) are mapping field names.
|
||||
const FIELD_NAME_ARRAY_KEYS = new Set(["sort", "_source", "fields"]);
|
||||
|
||||
// Object keys whose JSON value is a mapping field name (`"exists": { "field": "status" }`).
|
||||
const FIELD_VALUE_KEYS = new Set(["field", "path"]);
|
||||
|
||||
// Value scaffold inserted after a completed key so the matching brackets come
|
||||
// along with it, with the cursor parked where the value goes.
|
||||
function jsonKeyValueTemplate(key: string): string {
|
||||
@@ -170,14 +193,17 @@ export function getElasticsearchCompletionContext(text: string, cursor: number):
|
||||
if (lineStart > 0 || safeCursor > firstLineLimit || looksLikeJsonBody(text, safeCursor)) {
|
||||
const jsonPrefix = readJsonPrefix(text, safeCursor);
|
||||
const replaceClosingQuote = jsonPrefix.prefix.startsWith('"') && text[safeCursor] === '"' ? ('"' as const) : undefined;
|
||||
const inspection = inspectJsonBody(text, jsonPrefix.from);
|
||||
return {
|
||||
mode: "json",
|
||||
prefix: jsonPrefix.prefix,
|
||||
from: jsonPrefix.from,
|
||||
replaceClosingQuote,
|
||||
jsonSlot: readJsonSlot(text, jsonPrefix.from),
|
||||
jsonSlot: inspection.slot,
|
||||
jsonFieldSlot: isJsonFieldNameSlot(inspection),
|
||||
hasKeySeparator: hasJsonKeySeparator(text, safeCursor),
|
||||
jsonIndent: beforeCursorOnLine.match(/^\s*/)?.[0] ?? "",
|
||||
index: elasticsearchRequestIndex(text, safeCursor),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -192,20 +218,81 @@ export function buildElasticsearchCompletionItems(text: string, cursor: number,
|
||||
|
||||
export function buildElasticsearchCompletionItemsFromContext(context: ElasticsearchCompletionContext, input: ElasticsearchCompletionInput = {}): ElasticsearchCompletionItem[] {
|
||||
if (context.mode === "method") return methodItems(context.prefix);
|
||||
if (context.mode === "json") return jsonItems(context);
|
||||
if (context.mode === "json") return jsonItems(context, input.fields ?? []);
|
||||
return pathItems(context, input.indices ?? []);
|
||||
}
|
||||
|
||||
export function elasticsearchCompletionNeedsFields(context: ElasticsearchCompletionContext): boolean {
|
||||
return context.mode === "json" && context.jsonFieldSlot === true;
|
||||
}
|
||||
|
||||
const MAX_MAPPING_FIELDS = 500;
|
||||
|
||||
/** Flatten `GET /{index}/_mapping` into completion fields. Iterative on purpose: a recursive walk is the wrong model for this JSON. */
|
||||
export function flattenElasticsearchMappingFields(mapping: unknown): ElasticsearchCompletionField[] {
|
||||
const properties = elasticsearchMappingProperties(mapping);
|
||||
if (!properties) return [];
|
||||
|
||||
const fields: ElasticsearchCompletionField[] = [];
|
||||
const seenNames = new Set<string>();
|
||||
const seenObjects = new Set<Record<string, unknown>>();
|
||||
const stack: Array<{ prefix: string; properties: Record<string, unknown> }> = [{ prefix: "", properties }];
|
||||
|
||||
while (stack.length > 0 && fields.length < MAX_MAPPING_FIELDS) {
|
||||
const current = stack.pop();
|
||||
if (!current || seenObjects.has(current.properties)) continue;
|
||||
seenObjects.add(current.properties);
|
||||
for (const [name, definition] of Object.entries(current.properties)) {
|
||||
if (fields.length >= MAX_MAPPING_FIELDS) break;
|
||||
if (!isJsonObject(definition)) continue;
|
||||
const fieldName = current.prefix ? `${current.prefix}.${name}` : name;
|
||||
const fieldType = typeof definition.type === "string" ? definition.type : isJsonObject(definition.properties) ? "object" : undefined;
|
||||
if (fieldType && !seenNames.has(fieldName)) {
|
||||
seenNames.add(fieldName);
|
||||
fields.push({ name: fieldName, dataType: fieldType });
|
||||
}
|
||||
// Only mapping subtrees: `properties` (object/nested) and `fields` (multi-fields).
|
||||
// Never walk `index_options`, `path`, analyzer settings, etc.
|
||||
if (isJsonObject(definition.properties)) stack.push({ prefix: fieldName, properties: definition.properties });
|
||||
if (isJsonObject(definition.fields)) stack.push({ prefix: fieldName, properties: definition.fields });
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function isJsonObject(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function elasticsearchMappingProperties(mapping: unknown): Record<string, unknown> | undefined {
|
||||
if (!isJsonObject(mapping)) return undefined;
|
||||
const collected: Record<string, unknown> = {};
|
||||
const consider = (node: unknown) => {
|
||||
if (!isJsonObject(node)) return;
|
||||
const mappings = isJsonObject(node.mappings) ? node.mappings : node;
|
||||
if (isJsonObject(mappings.properties)) {
|
||||
Object.assign(collected, mappings.properties);
|
||||
return;
|
||||
}
|
||||
for (const typed of Object.values(mappings)) {
|
||||
if (isJsonObject(typed) && isJsonObject(typed.properties)) Object.assign(collected, typed.properties);
|
||||
}
|
||||
};
|
||||
if (isJsonObject(mapping.mappings)) consider(mapping);
|
||||
else for (const value of Object.values(mapping)) consider(value);
|
||||
return Object.keys(collected).length > 0 ? collected : undefined;
|
||||
}
|
||||
|
||||
export function shouldAutoOpenElasticsearchCompletion(text: string, cursor: number): boolean {
|
||||
const previousChar = text[cursor - 1];
|
||||
if (!previousChar) return false;
|
||||
if (/[{,}\]\n\r]/.test(previousChar)) return false;
|
||||
if (/[\w/_."]/.test(previousChar)) return true;
|
||||
if (/[\w/_.".-]/.test(previousChar)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export function getElasticsearchCompletionResultValidFor(): RegExp {
|
||||
return /[\w/_."]*$/;
|
||||
return /[\w/_."-]*$/;
|
||||
}
|
||||
|
||||
function methodItems(prefix: string): ElasticsearchCompletionItem[] {
|
||||
@@ -274,7 +361,8 @@ function indexEndpointItems(prefix: string): ElasticsearchCompletionItem[] {
|
||||
}));
|
||||
}
|
||||
|
||||
function jsonItems(context: ElasticsearchCompletionContext): ElasticsearchCompletionItem[] {
|
||||
function jsonItems(context: ElasticsearchCompletionContext, fields: ElasticsearchCompletionField[]): ElasticsearchCompletionItem[] {
|
||||
if (context.jsonFieldSlot) return fieldItems(context, fields);
|
||||
const quoted = context.prefix.startsWith('"');
|
||||
const normalizedPrefix = context.prefix.replace(/^"/, "");
|
||||
// Only an object key without its own `:` yet may pull the value scaffold in;
|
||||
@@ -306,6 +394,32 @@ function jsonItems(context: ElasticsearchCompletionContext): ElasticsearchComple
|
||||
return dedupeAndSort([...snippetItems, ...keyItems]);
|
||||
}
|
||||
|
||||
function fieldItems(context: ElasticsearchCompletionContext, fields: ElasticsearchCompletionField[]): ElasticsearchCompletionItem[] {
|
||||
const quoted = context.prefix.startsWith('"');
|
||||
const normalizedPrefix = context.prefix.replace(/^"/, "");
|
||||
return fields
|
||||
.filter((field) => matchesElasticsearchFieldPrefix(field.name, normalizedPrefix))
|
||||
.map((field) => {
|
||||
const depth = field.name.split(".").length;
|
||||
return {
|
||||
label: `"${field.name}"`,
|
||||
type: "column" as const,
|
||||
detail: field.dataType || "field",
|
||||
apply: `"${field.name}"`,
|
||||
// Quoted labels cannot prefix-match an unquoted typed token (`ci` vs `"city"`),
|
||||
// so expose the bare field name as filterText for CodeMirror's matcher.
|
||||
filterText: quoted ? `"${field.name}"` : field.name,
|
||||
replaceClosingQuote: context.replaceClosingQuote,
|
||||
// Shallower paths first. Default editor sort uses localeCompare on labels,
|
||||
// which puts `"nested_comments"` *after* its children; sortText avoids that.
|
||||
sortText: `${String(depth).padStart(2, "0")}\t${field.name}`,
|
||||
boost: 200 - depth * 20,
|
||||
};
|
||||
})
|
||||
.sort((left, right) => right.boost - left.boost || (left.sortText ?? left.label).localeCompare(right.sortText ?? right.label))
|
||||
.slice(0, 100);
|
||||
}
|
||||
|
||||
function indentJsonSnippet(snippet: string, baseIndent: string): string {
|
||||
if (!baseIndent) return snippet;
|
||||
return snippet.replace(/\n/g, `\n${baseIndent}`);
|
||||
@@ -328,25 +442,169 @@ function hasOpenJsonContainer(text: string, cursor: number): boolean {
|
||||
return stack.length > 0;
|
||||
}
|
||||
|
||||
// Tracks open brackets so an array element or a value after `:` is never
|
||||
// mistaken for an object key.
|
||||
function readJsonSlot(text: string, from: number): ElasticsearchJsonSlot {
|
||||
const stack: string[] = [];
|
||||
interface JsonFrame {
|
||||
kind: "{" | "[";
|
||||
key: string | null;
|
||||
}
|
||||
|
||||
interface JsonInspection {
|
||||
slot: ElasticsearchJsonSlot;
|
||||
parentKey: string | null;
|
||||
parentKind: "{" | "[" | null;
|
||||
nearestNamedKey: string | null;
|
||||
currentKey: string | null;
|
||||
ancestorKeys: string[];
|
||||
}
|
||||
|
||||
// Walks the current REST request body while keeping object keys, so a field
|
||||
// name inside `"term": { | }` is not treated the same as a DSL key inside `"bool"`.
|
||||
function inspectJsonBody(text: string, from: number): JsonInspection {
|
||||
const before = text.slice(0, from);
|
||||
const requestLine = /[\s\S]*(?:^|\n)[ \t]*(?:GET|POST|PUT|DELETE|HEAD)[ \t][^\n]*/i.exec(before);
|
||||
const bodyStart = requestLine?.[0].length ?? 0;
|
||||
const stack: JsonFrame[] = [];
|
||||
let pendingKey: string | null = null;
|
||||
let last = "";
|
||||
for (const char of readRequestBody(text, from)) {
|
||||
if (/\s/.test(char)) continue;
|
||||
if (char === "{" || char === "[") stack.push(char);
|
||||
else if (char === "}" || char === "]") stack.pop();
|
||||
let index = bodyStart;
|
||||
|
||||
while (index < from) {
|
||||
const char = before[index] ?? "";
|
||||
if (char === "#" || (char === "/" && before[index + 1] === "/")) {
|
||||
const newline = before.indexOf("\n", index);
|
||||
index = newline < 0 ? from : newline;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === '"') {
|
||||
const string = readJsonString(before, index, from);
|
||||
if (string.end < 0) break;
|
||||
index = string.end;
|
||||
let lookahead = index;
|
||||
while (lookahead < from && (before[lookahead] === " " || before[lookahead] === "\t")) lookahead++;
|
||||
if (before[lookahead] === ":") {
|
||||
pendingKey = string.value;
|
||||
continue;
|
||||
}
|
||||
last = '"';
|
||||
continue;
|
||||
}
|
||||
|
||||
if (/\s/.test(char)) {
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "{" || char === "[") {
|
||||
stack.push({ kind: char, key: pendingKey });
|
||||
pendingKey = null;
|
||||
last = char;
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === "}" || char === "]") {
|
||||
stack.pop();
|
||||
pendingKey = null;
|
||||
last = char;
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (char === ",") {
|
||||
pendingKey = null;
|
||||
last = char;
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
|
||||
last = char;
|
||||
index++;
|
||||
}
|
||||
|
||||
const top = stack[stack.length - 1];
|
||||
const slot: ElasticsearchJsonSlot = top?.kind === "{" && (last === "{" || last === ",") ? "key" : "value";
|
||||
let nearestNamedKey: string | null = null;
|
||||
for (let depth = stack.length - 1; depth >= 0; depth--) {
|
||||
const key = stack[depth]?.key;
|
||||
if (key) {
|
||||
nearestNamedKey = key;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
slot,
|
||||
parentKey: top?.key ?? null,
|
||||
parentKind: top?.kind ?? null,
|
||||
nearestNamedKey,
|
||||
currentKey: slot === "value" ? pendingKey : null,
|
||||
ancestorKeys: stack.map((frame) => frame.key).filter((key): key is string => !!key),
|
||||
};
|
||||
}
|
||||
|
||||
function readJsonString(text: string, start: number, limit: number): { end: number; value: string } {
|
||||
let index = start + 1;
|
||||
let escaped = false;
|
||||
let value = "";
|
||||
while (index < limit) {
|
||||
const char = text[index] ?? "";
|
||||
if (escaped) {
|
||||
value += char;
|
||||
escaped = false;
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
if (char === "\\") {
|
||||
escaped = true;
|
||||
index++;
|
||||
continue;
|
||||
}
|
||||
if (char === '"') return { end: index + 1, value };
|
||||
value += char;
|
||||
index++;
|
||||
}
|
||||
return { end: -1, value };
|
||||
}
|
||||
|
||||
function isJsonFieldNameSlot(inspection: JsonInspection): boolean {
|
||||
if (inspection.slot === "key") {
|
||||
if (inspection.parentKey && FIELD_NAME_OBJECT_KEYS.has(inspection.parentKey)) {
|
||||
return !isAggregationTypeClause(inspection);
|
||||
}
|
||||
// Object-form sort (`"sort": { "createdAt": "desc" }`) uses field names as keys.
|
||||
// Array-form sort objects (`"sort": [{ "createdAt": ... }]`) have no key of their
|
||||
// own; `_source`/`fields` objects do not (`includes`/`field`/`format`).
|
||||
if (inspection.parentKey === "sort") return true;
|
||||
return inspection.parentKind === "{" && !inspection.parentKey && inspection.nearestNamedKey === "sort";
|
||||
}
|
||||
if (inspection.currentKey && FIELD_VALUE_KEYS.has(inspection.currentKey)) return true;
|
||||
return inspection.parentKind === "[" && !!inspection.parentKey && FIELD_NAME_ARRAY_KEYS.has(inspection.parentKey);
|
||||
}
|
||||
|
||||
function isAggregationTypeClause(inspection: JsonInspection): boolean {
|
||||
if (!inspection.parentKey || !AGGREGATION_TYPE_FIELD_OBJECT_KEYS.has(inspection.parentKey)) return false;
|
||||
if (!inspection.ancestorKeys.some((key) => AGGREGATION_KEYS.has(key))) return false;
|
||||
return !inspection.ancestorKeys.some((key) => QUERY_CLAUSE_KEYS.has(key));
|
||||
}
|
||||
|
||||
function elasticsearchRequestIndex(text: string, cursor: number): string | undefined {
|
||||
const match = /[\s\S]*(?:^|\n)[ \t]*(?:GET|POST|PUT|DELETE|HEAD)[ \t]+(\S+)/i.exec(text.slice(0, cursor));
|
||||
const path = match?.[1];
|
||||
if (!path) return undefined;
|
||||
const first = (path.split("?")[0] ?? "").split("/").filter(Boolean)[0];
|
||||
if (!first || first.startsWith("_")) return undefined;
|
||||
try {
|
||||
return decodeURIComponent(first);
|
||||
} catch {
|
||||
return first;
|
||||
}
|
||||
return stack[stack.length - 1] === "{" && (last === "{" || last === ",") ? "key" : "value";
|
||||
}
|
||||
|
||||
function hasJsonKeySeparator(text: string, cursor: number): boolean {
|
||||
let index = cursor;
|
||||
// The caret may sit inside the key, so step over the rest of the token and
|
||||
// its closing quote before looking for the separator.
|
||||
while (index < text.length && /[\w_]/.test(text[index] ?? "")) index++;
|
||||
while (index < text.length && /[\w_.-]/.test(text[index] ?? "")) index++;
|
||||
if (text[index] === '"') index++;
|
||||
while (text[index] === " " || text[index] === "\t") index++;
|
||||
return text[index] === ":";
|
||||
@@ -359,7 +617,7 @@ function looksLikeJsonBody(text: string, cursor: number): boolean {
|
||||
|
||||
function readJsonPrefix(text: string, cursor: number): { prefix: string; from: number } {
|
||||
let from = cursor;
|
||||
while (from > 0 && /[\w_"]/.test(text[from - 1] ?? "")) from--;
|
||||
while (from > 0 && /[\w_.\-"]/.test(text[from - 1] ?? "")) from--;
|
||||
return { prefix: text.slice(from, cursor), from };
|
||||
}
|
||||
|
||||
@@ -391,6 +649,15 @@ function matchesFuzzyPrefix(value: string, prefix: string): boolean {
|
||||
return !normalizedPrefix || normalizedValue.includes(normalizedPrefix);
|
||||
}
|
||||
|
||||
// Kibana Console: flatten mapping to dotted paths, then prefix-filter the full name.
|
||||
// `"ne"` matches `nested_comments` and `nested_comments.user`; `"nested_comments."` keeps only children.
|
||||
// Do not use substring match (`rf_hotness` must not appear for `"ne"`).
|
||||
function matchesElasticsearchFieldPrefix(fieldName: string, prefix: string): boolean {
|
||||
const name = fieldName.toLowerCase();
|
||||
const typed = prefix.toLowerCase();
|
||||
return !typed || name.startsWith(typed);
|
||||
}
|
||||
|
||||
function dedupeAndSort(items: ElasticsearchCompletionItem[]): ElasticsearchCompletionItem[] {
|
||||
const seen = new Set<string>();
|
||||
const deduped: ElasticsearchCompletionItem[] = [];
|
||||
|
||||
@@ -138,6 +138,7 @@ import { useSavedSqlStore } from "@/stores/savedSqlStore";
|
||||
import { decorateDatabaseSavedSqlTreeNodes, indexSavedSqlFilesByDatabase, stripDatabaseSavedSqlTreeNodes, withDatabaseSavedSqlRoot } from "@/lib/savedSql/savedSqlDatabaseTree";
|
||||
import { encodeSqlServerLinkedSchema, parseSqlServerLinkedSchema } from "@/lib/database/sqlServerLinkedServers";
|
||||
import { inferMongoCompletionFields, type MongoCompletionField } from "@/lib/mongo/mongoCompletion";
|
||||
import { flattenElasticsearchMappingFields, type ElasticsearchCompletionField } from "@/lib/elasticsearch/elasticsearchCompletion";
|
||||
import { isMongoLegacyDriverProfile } from "@/lib/mongo/mongoCapabilities";
|
||||
import { mongoCollectionKindFromNode, toMongoCollectionKind, visibleMongoCollections } from "@/lib/sidebar/mongoCollectionMutation";
|
||||
import { completionSchemasFromTree, completionTablesFromTree } from "@/lib/metadata/completionTreeIndex";
|
||||
@@ -486,6 +487,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
const primaryVisibleObjectNames = ref<Record<string, string[]>>({});
|
||||
const sqlServerCompletionContextCache = ref<Record<string, SqlServerCompletionContext>>({});
|
||||
const elasticsearchCompletionIndicesCache = ref<Record<string, string[]>>({});
|
||||
const elasticsearchCompletionFieldsCache = ref<Record<string, ElasticsearchCompletionField[]>>({});
|
||||
const redisCompletionKeysCache = ref<Record<string, string[]>>({});
|
||||
const redisCommandDocsCache = ref<Record<string, RedisCommandDocumentation[]>>({});
|
||||
const redisCommandDocsCacheGeneration = new Map<string, number>();
|
||||
@@ -3511,6 +3513,9 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
for (const key of Object.keys(elasticsearchCompletionIndicesCache.value)) {
|
||||
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete elasticsearchCompletionIndicesCache.value[key];
|
||||
}
|
||||
for (const key of Object.keys(elasticsearchCompletionFieldsCache.value)) {
|
||||
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete elasticsearchCompletionFieldsCache.value[key];
|
||||
}
|
||||
for (const key of Object.keys(redisCompletionKeysCache.value)) {
|
||||
if (key === exactCacheKey || key.startsWith(cachePrefix)) delete redisCompletionKeysCache.value[key];
|
||||
}
|
||||
@@ -7975,6 +7980,21 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
return elasticsearchCompletionIndicesCache.value[cacheKey];
|
||||
}
|
||||
|
||||
async function listElasticsearchCompletionFields(connectionId: string, index: string): Promise<ElasticsearchCompletionField[]> {
|
||||
if (!index) return [];
|
||||
const cacheKey = `${connectionId}:${index}`;
|
||||
const cached = elasticsearchCompletionFieldsCache.value[cacheKey];
|
||||
if (cached) return cached;
|
||||
return withCompletionInFlight(`${cacheKey}:es-fields`, async () => {
|
||||
await ensureConnected(connectionId);
|
||||
const mapping = await api.elasticsearchGetIndexMetadata(connectionId, index, "mapping");
|
||||
const fields = flattenElasticsearchMappingFields(mapping);
|
||||
elasticsearchCompletionFieldsCache.value[cacheKey] = fields;
|
||||
evictOldestCacheEntries(elasticsearchCompletionFieldsCache.value, COMPLETION_CACHE_MAX);
|
||||
return fields;
|
||||
});
|
||||
}
|
||||
|
||||
// Upper bound on cached key names per db, to keep completion memory bounded
|
||||
// (Redis can hold far more keys than we ever want resident for autocomplete).
|
||||
const REDIS_COMPLETION_KEYS_MAX = 1000;
|
||||
@@ -9388,6 +9408,7 @@ export const useConnectionStore = defineStore("connection", () => {
|
||||
refreshCompletionSchemas,
|
||||
refreshCompletionDatabases,
|
||||
listElasticsearchCompletionIndices,
|
||||
listElasticsearchCompletionFields,
|
||||
listRedisCompletionKeys,
|
||||
listRedisCompletionCommandDocs,
|
||||
listMongoCompletionCollections,
|
||||
|
||||
Reference in new issue
Block a user