[ES|QL] Follow ups on variables (#213040)

## Summary

This PR:

- add an extra information when hovering over a variable
<img width="479" alt="image"
src="https://github.com/user-attachments/assets/331f7faf-89e9-468d-9887-9d58a2f66ff7"
/>

- passes the variables on the fields retrieval endpoint in the editor to
get the fields correctly when there are variables in the query


### Checklist
- [ ] [Unit or functional
tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)
were updated or added to match the most common scenarios

---------

Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
Stratoula Kalafateli 2025-03-10 14:45:27 +01:00 committed by GitHub
parent 250a473e8d
commit 90a345b21a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
18 changed files with 187 additions and 44 deletions

View file

@ -406,7 +406,16 @@ export const ESQLEditor = memo(function ESQLEditor({
const { cache: esqlFieldsCache, memoizedFieldsFromESQL } = useMemo(() => {
// need to store the timing of the first request so we can atomically clear the cache per query
const fn = memoize(
(...args: [{ esql: string }, ExpressionsStart, TimeRange, AbortController?]) => ({
(
...args: [
{ esql: string },
ExpressionsStart,
TimeRange,
AbortController?,
string?,
ESQLControlVariable[]?
]
) => ({
timestamp: Date.now(),
result: fetchFieldsFromESQL(...args),
}),
@ -446,7 +455,9 @@ export const ESQLEditor = memo(function ESQLEditor({
esqlQuery,
expressions,
timeRange,
abortController
abortController,
undefined,
esqlVariables
).result;
const columns: ESQLRealField[] =
table?.columns.map((c) => {
@ -478,8 +489,8 @@ export const ESQLEditor = memo(function ESQLEditor({
},
// @ts-expect-error To prevent circular type import, type defined here is partial of full client
getFieldsMetadata: fieldsMetadata?.getClient(),
getVariablesByType: (type: ESQLVariableType) => {
return variablesService?.esqlVariables.filter((variable) => variable.type === type);
getVariables: () => {
return variablesService?.esqlVariables;
},
canSuggestVariables: () => {
return variablesService?.areSuggestionsEnabled ?? false;
@ -489,6 +500,7 @@ export const ESQLEditor = memo(function ESQLEditor({
return callbacks;
}, [
fieldsMetadata,
esqlVariables,
kibana.services?.esql?.getJoinIndicesAutocomplete,
dataSourcesCache,
query.esql,

View file

@ -11,6 +11,7 @@ import { pluck } from 'rxjs';
import { lastValueFrom } from 'rxjs';
import { Query, AggregateQuery, TimeRange } from '@kbn/es-query';
import type { ExpressionsStart } from '@kbn/expressions-plugin/public';
import type { ESQLControlVariable } from '@kbn/esql-types';
import type { Datatable } from '@kbn/expressions-plugin/public';
import { textBasedQueryStateToAstWithValidation } from '@kbn/data-plugin/common';
@ -26,7 +27,8 @@ export function fetchFieldsFromESQL(
expressions: ExpressionsStart,
time?: TimeRange,
abortController?: AbortController,
timeFieldName?: string
timeFieldName?: string,
esqlVariables?: ESQLControlVariable[]
) {
return textBasedQueryStateToAstWithValidation({
query,
@ -38,6 +40,7 @@ export function fetchFieldsFromESQL(
const executionContract = expressions.execute(ast, null, {
searchContext: {
timeRange: time,
esqlVariables,
},
});

View file

@ -60,7 +60,7 @@ export {
ESQLErrorListener,
} from './src/parser';
export { Walker, type WalkerOptions, walk } from './src/walker';
export { Walker, type WalkerOptions, walk, type WalkerAstNode } from './src/walker';
export * as synth from './src/synth';
export {

View file

@ -7,4 +7,4 @@
* License v3.0 only", or the "Server Side Public License, v 1".
*/
export { Walker, type WalkerOptions, walk } from './walker';
export { Walker, type WalkerOptions, walk, type WalkerAstNode } from './walker';

View file

@ -378,7 +378,7 @@ describe('autocomplete.suggest', () => {
const suggestions = await suggest('FROM a | STATS /', {
callbacks: {
canSuggestVariables: () => true,
getVariablesByType: () => [],
getVariables: () => [],
getColumnsFor: () => Promise.resolve([{ name: 'clientip', type: 'ip' }]),
},
});
@ -399,7 +399,7 @@ describe('autocomplete.suggest', () => {
const suggestions = await suggest('FROM a | STATS var0 = /', {
callbacks: {
canSuggestVariables: () => true,
getVariablesByType: () => [
getVariables: () => [
{
key: 'function',
value: 'avg',
@ -426,7 +426,7 @@ describe('autocomplete.suggest', () => {
const suggestions = await suggest('FROM a | STATS BY /', {
callbacks: {
canSuggestVariables: () => true,
getVariablesByType: () => [],
getVariables: () => [],
getColumnsFor: () => Promise.resolve([{ name: 'clientip', type: 'ip' }]),
},
});
@ -447,7 +447,7 @@ describe('autocomplete.suggest', () => {
const suggestions = await suggest('FROM a | STATS BY /', {
callbacks: {
canSuggestVariables: () => true,
getVariablesByType: () => [
getVariables: () => [
{
key: 'field',
value: 'clientip',
@ -474,7 +474,7 @@ describe('autocomplete.suggest', () => {
const suggestions = await suggest('FROM a | STATS BY BUCKET(@timestamp, /)', {
callbacks: {
canSuggestVariables: () => true,
getVariablesByType: () => [
getVariables: () => [
{
key: 'interval',
value: '1 hour',

View file

@ -399,7 +399,7 @@ describe('WHERE <expression>', () => {
const suggestions = await suggest('FROM a | WHERE agent.name == /', {
callbacks: {
canSuggestVariables: () => true,
getVariablesByType: () => [],
getVariables: () => [],
getColumnsFor: () => Promise.resolve([{ name: 'agent.name', type: 'keyword' }]),
},
});
@ -421,7 +421,7 @@ describe('WHERE <expression>', () => {
const suggestions = await suggest('FROM a | WHERE agent.name == /', {
callbacks: {
canSuggestVariables: () => true,
getVariablesByType: () => [
getVariables: () => [
{
key: 'value',
value: 'java',

View file

@ -16,7 +16,7 @@ import {
type ESQLFunction,
type ESQLSingleAstItem,
} from '@kbn/esql-ast';
import { ESQLVariableType, type ESQLControlVariable } from '@kbn/esql-types';
import type { ESQLControlVariable } from '@kbn/esql-types';
import { ESQL_NUMBER_TYPES, isNumericType } from '../shared/esql_types';
import type { EditorContext, ItemKind, SuggestionRawDefinition, GetColumnsByTypeFn } from './types';
import {
@ -138,7 +138,7 @@ export async function suggest(
resourceRetriever
);
const supportsControls = resourceRetriever?.canSuggestVariables?.() ?? false;
const getVariablesByType = resourceRetriever?.getVariablesByType;
const getVariables = resourceRetriever?.getVariables;
const getSources = getSourcesHelper(resourceRetriever);
const { getPolicies, getPolicyMetadata } = getPolicyRetriever(resourceRetriever);
@ -188,7 +188,7 @@ export async function suggest(
getFieldsMap,
getPolicies,
getPolicyMetadata,
getVariablesByType,
getVariables,
resourceRetriever?.getPreferences,
resourceRetriever,
supportsControls
@ -217,7 +217,7 @@ export async function suggest(
getFieldsMap,
fullText,
offset,
getVariablesByType,
getVariables,
supportsControls
);
}
@ -239,7 +239,7 @@ export function getFieldsByTypeRetriever(
resourceRetriever?: ESQLCallbacks
): { getFieldsByType: GetColumnsByTypeFn; getFieldsMap: GetFieldsMapFn } {
const helpers = getFieldsByTypeHelper(queryString, resourceRetriever);
const getVariablesByType = resourceRetriever?.getVariablesByType;
const getVariables = resourceRetriever?.getVariables;
const supportsControls = resourceRetriever?.canSuggestVariables?.() ?? false;
return {
getFieldsByType: async (
@ -252,7 +252,7 @@ export function getFieldsByTypeRetriever(
supportsControls,
};
const fields = await helpers.getFieldsByType(expectedType, ignored);
return buildFieldsDefinitionsWithMetadata(fields, updatedOptions, getVariablesByType);
return buildFieldsDefinitionsWithMetadata(fields, updatedOptions, getVariables);
},
getFieldsMap: helpers.getFieldsMap,
};
@ -355,7 +355,7 @@ async function getSuggestionsWithinCommandExpression(
getFieldsMap: GetFieldsMapFn,
getPolicies: GetPoliciesFn,
getPolicyMetadata: GetPolicyMetadataFn,
getVariablesByType?: (type: ESQLVariableType) => ESQLControlVariable[] | undefined,
getVariables?: () => ESQLControlVariable[] | undefined,
getPreferences?: () => Promise<{ histogramBarTarget: number } | undefined>,
callbacks?: ESQLCallbacks,
supportsControls?: boolean
@ -396,7 +396,7 @@ async function getSuggestionsWithinCommandExpression(
getSourcesFromQuery: (type) => getSourcesFromCommands(commands, type),
previousCommands: commands,
callbacks,
getVariablesByType,
getVariables,
supportsControls,
getPolicies,
getPolicyMetadata,
@ -907,7 +907,7 @@ async function getFunctionArgsSuggestions(
getFieldsMap: GetFieldsMapFn,
fullText: string,
offset: number,
getVariablesByType?: (type: ESQLVariableType) => ESQLControlVariable[] | undefined,
getVariables?: () => ESQLControlVariable[] | undefined,
supportsControls?: boolean
): Promise<SuggestionRawDefinition[]> {
const fnDefinition = getFunctionDefinition(node.name);
@ -1038,7 +1038,7 @@ async function getFunctionArgsSuggestions(
advanceCursorAndOpenSuggestions: hasMoreMandatoryArgs,
supportsControls,
},
getVariablesByType
getVariables
)
);

View file

@ -59,11 +59,11 @@ const suggestFields = async (
]);
const supportsControls = callbacks?.canSuggestVariables?.() ?? false;
const getVariablesByType = callbacks?.getVariablesByType;
const getVariables = callbacks?.getVariables;
const joinFields = buildFieldsDefinitionsWithMetadata(
lookupIndexFields!,
{ supportsControls },
getVariablesByType
getVariables
);
const intersection = suggestionIntersection(joinFields, sourceFields);

View file

@ -25,7 +25,7 @@ export async function suggest({
getColumnsByType,
getSuggestedVariableName,
getPreferences,
getVariablesByType,
getVariables,
supportsControls,
}: CommandSuggestParams<'stats'>): Promise<SuggestionRawDefinition[]> {
const pos = getPosition(innerText, command);
@ -37,7 +37,7 @@ export async function suggest({
const controlSuggestions = getControlSuggestionIfSupported(
Boolean(supportsControls),
ESQLVariableType.FUNCTIONS,
getVariablesByType
getVariables
);
switch (pos) {

View file

@ -209,7 +209,7 @@ export const buildFieldsDefinitionsWithMetadata = (
variableType?: ESQLVariableType;
supportsControls?: boolean;
},
getVariablesByType?: (type: ESQLVariableType) => ESQLControlVariable[] | undefined
getVariables?: () => ESQLControlVariable[] | undefined
): SuggestionRawDefinition[] => {
const fieldsSuggestions = fields.map((field) => {
const titleCaseType = field.type.charAt(0).toUpperCase() + field.type.slice(1);
@ -230,7 +230,7 @@ export const buildFieldsDefinitionsWithMetadata = (
const suggestions = [...fieldsSuggestions];
if (options?.supportsControls) {
const variableType = options?.variableType ?? ESQLVariableType.FIELDS;
const variables = getVariablesByType?.(variableType) ?? [];
const variables = getVariables?.()?.filter((variable) => variable.type === variableType) ?? [];
const controlSuggestions = fields.length
? getControlSuggestion(
@ -418,7 +418,7 @@ export function getCompatibleLiterals(
addComma?: boolean;
supportsControls?: boolean;
},
getVariablesByType?: (type: ESQLVariableType) => ESQLControlVariable[] | undefined
getVariables?: () => ESQLControlVariable[] | undefined
) {
const suggestions: SuggestionRawDefinition[] = [];
if (types.some(isNumericType)) {
@ -436,7 +436,9 @@ export function getCompatibleLiterals(
...buildConstantsDefinitions(getUnitDuration(1), undefined, undefined, options),
];
if (options?.supportsControls) {
const variables = getVariablesByType?.(ESQLVariableType.TIME_LITERAL) ?? [];
const variables =
getVariables?.()?.filter((variable) => variable.type === ESQLVariableType.TIME_LITERAL) ??
[];
timeLiteralSuggestions.push(
...getControlSuggestion(
ESQLVariableType.TIME_LITERAL,
@ -520,13 +522,13 @@ export function getDateLiterals(options?: {
export function getControlSuggestionIfSupported(
supportsControls: boolean,
type: ESQLVariableType,
getVariablesByType?: (type: ESQLVariableType) => ESQLControlVariable[] | undefined
getVariables?: () => ESQLControlVariable[] | undefined
) {
if (!supportsControls) {
return [];
}
const variableType = type;
const variables = getVariablesByType?.(variableType) ?? [];
const variables = getVariables?.()?.filter((variable) => variable.type === variableType) ?? [];
const controlSuggestion = getControlSuggestion(
variableType,
variables?.map((v) => `?${v.key}`)

View file

@ -14,7 +14,7 @@ import type {
ESQLMessage,
ESQLSource,
} from '@kbn/esql-ast';
import { ESQLControlVariable, ESQLVariableType } from '@kbn/esql-types';
import { ESQLControlVariable } from '@kbn/esql-types';
import { GetColumnsByTypeFn, SuggestionRawDefinition } from '../autocomplete/types';
import type { ESQLPolicy } from '../validation/types';
import { ESQLCallbacks, ESQLSourceResult } from '../shared/types';
@ -272,7 +272,7 @@ export interface CommandSuggestParams<CommandName extends string> {
*/
previousCommands?: ESQLCommand[];
callbacks?: ESQLCallbacks;
getVariablesByType?: (type: ESQLVariableType) => ESQLControlVariable[] | undefined;
getVariables?: () => ESQLControlVariable[] | undefined;
supportsControls?: boolean;
}

View file

@ -6,7 +6,7 @@
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { ESQLVariableType, type ESQLControlVariable } from '@kbn/esql-types';
import type { ESQLControlVariable } from '@kbn/esql-types';
import type { ESQLRealField, JoinIndexAutocompleteItem } from '../validation/types';
/** @internal **/
@ -46,7 +46,7 @@ export interface ESQLCallbacks {
>;
getPreferences?: () => Promise<{ histogramBarTarget: number }>;
getFieldsMetadata?: Promise<PartialFieldsMetadataClient>;
getVariablesByType?: (type: ESQLVariableType) => ESQLControlVariable[] | undefined;
getVariables?: () => ESQLControlVariable[] | undefined;
canSuggestVariables?: () => boolean;
getJoinIndices?: () => Promise<{ indices: JoinIndexAutocompleteItem[] }>;
}

View file

@ -1733,7 +1733,7 @@ describe('validation logic', () => {
getColumnsFor: /Unknown column|Argument of|it is unsupported or not indexed/,
getPreferences: /Unknown/,
getFieldsMetadata: /Unknown/,
getVariablesByType: /Unknown/,
getVariables: /Unknown/,
canSuggestVariables: /Unknown/,
};
return excludedCallback.map((callback) => (contentByCallback as any)[callback]) || [];

View file

@ -1352,7 +1352,7 @@ export const ignoreErrorsMap: Record<keyof ESQLCallbacks, ErrorTypes[]> = {
getPolicies: ['unknownPolicy'],
getPreferences: [],
getFieldsMetadata: [],
getVariablesByType: [],
getVariables: [],
canSuggestVariables: [],
getJoinIndices: [],
};

View file

@ -0,0 +1,86 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import type { WalkerAstNode } from '@kbn/esql-ast';
import { ESQLVariableType } from '@kbn/esql-types';
import { getVariablesHoverContent } from './helpers';
describe('getVariablesHoverContent', () => {
test('should return empty array if no variables are used in the query', async () => {
const node = {
type: 'source',
cluster: '',
index: 'logst*',
name: 'logst*',
sourceType: 'index',
location: {
min: 5,
max: 10,
},
incomplete: false,
text: 'logst*',
} as WalkerAstNode;
const variables = [
{
key: 'var',
value: 'value',
type: ESQLVariableType.VALUES,
},
];
expect(getVariablesHoverContent(node, variables)).toEqual([]);
});
test('should return empty array if no variables are given', () => {
const node = {
type: 'source',
cluster: '',
index: 'logst*',
name: 'logst*',
sourceType: 'index',
location: {
min: 5,
max: 10,
},
incomplete: false,
text: 'logst*',
} as WalkerAstNode;
expect(getVariablesHoverContent(node)).toEqual([]);
});
test('should return the variable content if user is hovering over a variable', () => {
const node = {
value: 'field',
location: {
min: 96,
max: 101,
},
text: '?field',
incomplete: false,
name: '',
type: 'literal',
literalType: 'param',
paramType: 'named',
} as WalkerAstNode;
const variables = [
{
key: 'field',
value: 'agent',
type: ESQLVariableType.FIELDS,
},
];
expect(getVariablesHoverContent(node, variables)).toEqual([
{
value: '**field**: agent',
},
]);
});
});

View file

@ -0,0 +1,30 @@
/*
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
* or more contributor license agreements. Licensed under the "Elastic License
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
* Public License v 1"; you may not use this file except in compliance with, at
* your election, the "Elastic License 2.0", the "GNU Affero General Public
* License v3.0 only", or the "Server Side Public License, v 1".
*/
import { Walker, type WalkerAstNode } from '@kbn/esql-ast';
import type { ESQLControlVariable } from '@kbn/esql-types';
export const getVariablesHoverContent = (
node?: WalkerAstNode,
variables?: ESQLControlVariable[]
) => {
const usedVariablesInNode = node ? Walker.params(node).map((v) => v.text.replace('?', '')) : [];
const usedVariables = variables?.filter((v) => usedVariablesInNode.includes(v.key));
const hoverContents: Array<{ value: string }> = [];
if (usedVariables?.length) {
usedVariables.forEach((variable) => {
hoverContents.push({
value: `**${variable.key}**: ${variable.value}`,
});
});
}
return hoverContents;
};

View file

@ -36,6 +36,7 @@ import {
import { isESQLFunction, isESQLNamedParamLiteral } from '@kbn/esql-ast/src/types';
import { monacoPositionToOffset } from '../shared/utils';
import { monaco } from '../../../../monaco_imports';
import { getVariablesHoverContent } from './helpers';
const ACCEPTABLE_TYPES_HOVER = i18n.translate('monaco.esql.hover.acceptableTypes', {
defaultMessage: 'Acceptable types',
@ -148,12 +149,19 @@ export async function getHoverItem(
const { ast } = await astProvider(fullText);
const astContext = getAstContext(fullText, ast, offset);
const { getPolicyMetadata } = getPolicyHelper(resourceRetriever);
let hoverContent: monaco.languages.Hover = {
const variables = resourceRetriever?.getVariables?.();
const variablesContent = getVariablesHoverContent(astContext.node, variables);
const hoverContent: monaco.languages.Hover = {
contents: [],
};
if (variablesContent.length) {
hoverContent.contents.push(...variablesContent);
}
const hoverItemsForFunction = await getHoverItemForFunction(
model,
position,
@ -162,7 +170,8 @@ export async function getHoverItem(
resourceRetriever
);
if (hoverItemsForFunction) {
hoverContent = hoverItemsForFunction;
hoverContent.contents.push(...hoverItemsForFunction.contents);
hoverContent.range = hoverItemsForFunction.range;
}
if (['newCommand', 'list'].includes(astContext.type)) {

View file

@ -9,7 +9,8 @@
"@kbn/i18n",
"@kbn/repo-info",
"@kbn/esql-ast",
"@kbn/esql-validation-autocomplete"
"@kbn/esql-validation-autocomplete",
"@kbn/esql-types"
],
"exclude": ["target/**/*"]
}