[ES|QL] Walker improvements (#224582)

## Summary

- Implements traversal of `source` node children (string literals), like
`a` and `b` in `FROM a:b`. Before `a` and `b` would not be traversed.
- Implements traversal of `order` nodes, like `field DESC` in `FROM a |
SORT field DESC`. Before the `field DESC` would be skipped.
- Adds tests, which verify that all nodes in the query are traversed by
the `Walker`, see `walker_all_nodes.test.ts`.


### Checklist

- [x] [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
This commit is contained in:
Vadim Kibana 2025-06-24 19:28:34 +02:00 committed by GitHub
parent 979d0ce0f2
commit f433e7aa97
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 1243 additions and 843 deletions

View file

@ -0,0 +1,49 @@
/*
* 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".
*/
export const smallest = `FROM a`;
export const sortCommandFromDocs = `FROM employees
| KEEP first_name, last_name, height
| SORT height`;
export const large = `
// This is a comment, not a "string"
FROM index, metrics:index, "another_index", """index""", metrics-metrics-metrics METADATA _id, _index
/* This is a multiline
comment */
// | FORK (WHERE ?param.test == "asdf" | LIMIT 123) (LIMIT 123)
| EVAL kb = bytes / 1024 * -1.23e456 + ?param <= 3, a = 5 WEEKS, foo = "baasdfr", ? <= ?asdf
| WHERE process.name == "curl.exe /* asdf */" AND ?42 == 123 OR ?
| WHERE event_duration > /* very big number */ 5000000
| WHERE message LIKE "Connected*"
| KEEP kb, destination.address, date, ip, email, num, avg.avg.avg
// The ten is
// very sensible number
| LIMIT 10
| STATS bytes = (SUM(destination.bytes, true))::INTEGER
| SORT asdf
| SORT @timestamp DESC, @timestamp ASC
| SORT kb, date ASC NULLS FIRST, ip DESC NULLS LAST
| DROP date, ip, \`AVG(FALSE, null, { "this": "is", "map": 123 })\`
| RENAME field AS another_field, another_field AS field
| RENAME unique_queries AS \`Unique Queries\`
/**
* Description, not "string"
* @description This is a description
* @color #0077ff
*/
| DISSECT field """%{date} - %{msg} - %{ip}"""
| GROK dns.question.name "asdf"
| ENRICH languages_policy ON a WITH name = language_name, more
| MV_EXPAND column
| INLINESTATS count = COUNT(ROUND(AVG(
MV_AVG(department.salary_change)), 10))
BY languages
| LOOKUP JOIN join_index ON x.foo`;

View file

@ -26,7 +26,11 @@ import type {
import { BinaryExpressionGroup } from './constants';
export const isProperNode = (node: unknown): node is ESQLProperNode =>
!!node && typeof node === 'object' && !Array.isArray(node);
!!node &&
typeof node === 'object' &&
!Array.isArray(node) &&
typeof (node as ESQLProperNode).type === 'string' &&
!!(node as ESQLProperNode).type;
export const isFunctionExpression = (node: unknown): node is ESQLFunction =>
isProperNode(node) && node.type === 'function';

View file

@ -116,4 +116,37 @@ describe('aborting traversal', () => {
expect(commands).toStrictEqual(['from']);
});
test('can abort traversal in the middle of source component', () => {
const { ast } = EsqlQuery.fromSrc('FROM a:b, c::d');
const components: string[] = [];
Walker.walk(ast, {
visitLiteral: (node, parent, walker) => {
components.push(node.value as string);
if (components.length === 1) {
walker.abort();
}
},
});
expect(components).toStrictEqual(['a']);
});
test('can abort traversal in the middle of source component (backward)', () => {
const { ast } = EsqlQuery.fromSrc('FROM a:b, c::d');
const components: string[] = [];
Walker.walk(ast, {
visitLiteral: (node, parent, walker) => {
components.push(node.value as string);
if (components.length === 3) {
walker.abort();
}
},
order: 'backward',
});
expect(components).toStrictEqual(['d', 'c', 'b']);
});
});

View file

@ -0,0 +1,121 @@
/*
* 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 { EsqlQuery } from '../../query';
import * as fixtures from '../../__tests__/fixtures';
import { Walker } from '../walker';
import { ESQLAstExpression, ESQLProperNode } from '../../types';
import { isProperNode } from '../../ast/helpers';
interface JsonWalkerOptions {
visitObject?: (node: Record<string, unknown>) => void;
visitArray?: (node: unknown[]) => void;
visitString?: (node: string) => void;
visitNumber?: (node: number) => void;
visitBigint?: (node: bigint) => void;
visitBoolean?: (node: boolean) => void;
visitNull?: () => void;
visitUndefined?: () => void;
}
const walkJson = (json: unknown, options: JsonWalkerOptions = {}) => {
switch (typeof json) {
case 'string': {
options.visitString?.(json);
break;
}
case 'number': {
options.visitNumber?.(json);
break;
}
case 'bigint': {
options.visitBigint?.(json as bigint);
break;
}
case 'boolean': {
options.visitBoolean?.(json);
break;
}
case 'undefined': {
options.visitUndefined?.();
break;
}
case 'object': {
if (!json) {
options.visitNull?.();
} else if (Array.isArray(json)) {
options.visitArray?.(json);
const length = json.length;
for (let i = 0; i < length; i++) {
walkJson(json[i], options);
}
} else {
options.visitObject?.(json as Record<string, unknown>);
const values = Object.values(json as Record<string, unknown>);
const length = values.length;
for (let i = 0; i < length; i++) {
const value = values[i];
walkJson(value, options);
}
}
}
}
};
const assertAllNodesAreVisited = (query: string) => {
const { ast, errors } = EsqlQuery.fromSrc(query);
expect(errors).toStrictEqual([]);
const allNodes = new Set<ESQLProperNode>();
const allExpressionNodes = new Set<ESQLAstExpression>();
const allWalkerAnyNodes = new Set<ESQLProperNode>();
const allWalkerExpressionNodes = new Set<ESQLAstExpression>();
walkJson(ast, {
visitObject: (node) => {
if (isProperNode(node)) {
allNodes.add(node);
if (node.type !== 'command') {
allExpressionNodes.add(node);
}
}
},
});
Walker.walk(ast, {
visitAny: (node) => {
allWalkerAnyNodes.add(node);
},
visitSingleAstItem: (node) => {
allWalkerExpressionNodes.add(node);
},
});
expect(allWalkerAnyNodes).toStrictEqual(allNodes);
expect(allWalkerAnyNodes.size).toBe(allNodes.size);
expect(allWalkerExpressionNodes).toStrictEqual(allExpressionNodes);
expect(allWalkerExpressionNodes.size).toBe(allExpressionNodes.size);
};
describe('Walker walks all nodes', () => {
test('small query', () => {
assertAllNodesAreVisited(fixtures.smallest);
});
test('sample SORT command from docs', () => {
assertAllNodesAreVisited(fixtures.sortCommandFromDocs);
});
test('large query', () => {
assertAllNodesAreVisited(fixtures.large);
});
});

View file

@ -239,4 +239,50 @@ describe('traversal order', () => {
expect(numbers.map((n) => n.name)).toStrictEqual(['limit', 'from']);
});
});
describe('source components', () => {
test('in "forward" order', () => {
const { ast } = EsqlQuery.fromSrc('FROM a:b');
const numbers = Walker.matchAll(
ast,
{ type: 'literal' },
{ order: 'forward' }
) as ESQLLiteral[];
expect(numbers.map((n) => n.value)).toStrictEqual(['a', 'b']);
});
test('in "forward" order (selector)', () => {
const { ast } = EsqlQuery.fromSrc('FROM a::b');
const numbers = Walker.matchAll(
ast,
{ type: 'literal' },
{ order: 'forward' }
) as ESQLLiteral[];
expect(numbers.map((n) => n.value)).toStrictEqual(['a', 'b']);
});
test('in "backward" order', () => {
const { ast } = EsqlQuery.fromSrc('FROM a:b');
const numbers = Walker.matchAll(
ast,
{ type: 'literal' },
{ order: 'backward' }
) as ESQLLiteral[];
expect(numbers.map((n) => n.value)).toStrictEqual(['b', 'a']);
});
test('in "backward" order (selector)', () => {
const { ast } = EsqlQuery.fromSrc('FROM a::b');
const numbers = Walker.matchAll(
ast,
{ type: 'literal' },
{ order: 'backward' }
) as ESQLLiteral[];
expect(numbers.map((n) => n.value)).toStrictEqual(['b', 'a']);
});
});
});

View file

@ -43,6 +43,11 @@ export interface WalkerOptions {
parent: types.ESQLProperNode | undefined,
walker: WalkerVisitorApi
) => void;
visitOrder?: (
node: types.ESQLOrderExpression,
parent: types.ESQLProperNode | undefined,
walker: WalkerVisitorApi
) => void;
visitLiteral?: (
node: types.ESQLLiteral,
parent: types.ESQLProperNode | undefined,
@ -516,6 +521,26 @@ export class Walker {
this.walkList(node.values, node);
}
public walkSource(node: types.ESQLSource, parent: types.ESQLProperNode | undefined): void {
const { options } = this;
(options.visitSource ?? options.visitAny)?.(node, parent, this);
const children: types.ESQLStringLiteral[] = [];
if (node.prefix) {
children.push(node.prefix);
}
if (node.index) {
children.push(node.index);
}
if (node.selector) {
children.push(node.selector);
}
this.walkList(children, node);
}
public walkColumn(node: types.ESQLColumn, parent: types.ESQLProperNode | undefined): void {
const { options } = this;
const { args } = node;
@ -527,6 +552,17 @@ export class Walker {
}
}
public walkOrder(
node: types.ESQLOrderExpression,
parent: types.ESQLProperNode | undefined
): void {
const { options } = this;
(options.visitOrder ?? options.visitAny)?.(node, parent, this);
this.walkList(node.args, node);
}
public walkInlineCast(
node: types.ESQLInlineCast,
parent: types.ESQLProperNode | undefined
@ -607,13 +643,17 @@ export class Walker {
break;
}
case 'source': {
(options.visitSource ?? options.visitAny)?.(node, parent, this);
this.walkSource(node, parent);
break;
}
case 'column': {
this.walkColumn(node, parent);
break;
}
case 'order': {
this.walkOrder(node, parent);
break;
}
case 'literal': {
(options.visitLiteral ?? options.visitAny)?.(node, parent, this);
break;

View file

@ -167,7 +167,7 @@ describe('autocomplete.suggest', () => {
);
});
test('lookup join', async () => {
test('lookup join after command name', async () => {
await assertSuggestions('FROM a | FORK (LOOKUP JOIN /)', [
'join_index ',
'join_index_with_alias ',
@ -175,8 +175,9 @@ describe('autocomplete.suggest', () => {
'join_index_alias_1 $0',
'join_index_alias_2 $0',
]);
const suggestions = await suggest('FROM a | FORK (LOOKUP JOIN join_index ON /)');
const labels = suggestions.map((s) => s.text.trim()).sort();
});
test('lookup join after ON keyword', async () => {
const expected = getFieldNamesByType('any')
.sort()
.map((field) => field.trim());
@ -184,9 +185,8 @@ describe('autocomplete.suggest', () => {
for (const { name } of lookupIndexFields) {
expected.push(name.trim());
}
expected.sort();
expect(labels).toEqual(expected);
await assertSuggestions('FROM a | FORK (LOOKUP JOIN join_index ON /)', expected);
});
test('enrich', async () => {

View file

@ -122,6 +122,12 @@ function findAstPosition(ast: ESQLAst, offset: number) {
let node: ESQLSingleAstItem | undefined;
Walker.walk(command, {
visitSource: (_node, parent, walker) => {
if (_node.location.max >= offset && _node.text !== EDITOR_MARKER) {
node = _node as ESQLSingleAstItem;
walker.abort();
}
},
visitAny: (_node) => {
if (
_node.type === 'function' &&

View file

@ -0,0 +1,85 @@
/*
* 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 { ESQLFieldWithMetadata } from '@kbn/esql-validation-autocomplete';
import { FieldType } from '@kbn/esql-validation-autocomplete/src/definitions/types';
import { monaco } from '../../../../../monaco_imports';
import { HoverMonacoModel } from '../hover';
const types: FieldType[] = ['keyword', 'double', 'date', 'boolean', 'ip'];
const fields: Array<ESQLFieldWithMetadata & { suggestedAs?: string }> = [
...types.map((type) => ({
name: `${type}Field`,
type,
})),
{ name: 'any#Char$Field', type: 'double', suggestedAs: '`any#Char$Field`' },
{ name: 'kubernetes.something.something', type: 'double' },
];
const indexes = (
[] as Array<{ name: string; hidden: boolean; suggestedAs: string | undefined }>
).concat(
['a', 'index', 'otherIndex', '.secretIndex', 'my-index'].map((name) => ({
name,
hidden: name.startsWith('.'),
suggestedAs: undefined,
})),
['my-index[quoted]', 'my-index$', 'my_index{}'].map((name) => ({
name,
hidden: false,
suggestedAs: `\`${name}\``,
}))
);
export const policies = [
{
name: 'policy',
sourceIndices: ['enrichIndex1'],
matchField: 'otherStringField',
enrichFields: ['otherField', 'yetAnotherField', 'yet-special-field'],
suggestedAs: undefined,
},
...['my-policy[quoted]', 'my-policy$', 'my_policy{}'].map((name) => ({
name,
sourceIndices: ['enrichIndex1'],
matchField: 'otherStringField',
enrichFields: ['otherField', 'yetAnotherField', 'yet-special-field'],
suggestedAs: `\`${name}\``,
})),
];
function createCustomCallbackMocks() {
return {
getFieldsFor: jest.fn(async () => fields),
getSources: jest.fn(async () => indexes),
getPolicies: jest.fn(async () => policies),
};
}
function createModelAndPosition(text: string, string: string) {
return {
model: {
getValue: () => text,
} as HoverMonacoModel,
position: { lineNumber: 1, column: text.lastIndexOf(string) + 1 } as monaco.Position,
};
}
export const setupTestbed = (statement: string, triggerString: string) => {
const { model, position } = createModelAndPosition(statement, triggerString);
const callbacks = createCustomCallbackMocks();
const testbed = {
model,
position,
callbacks,
};
return testbed;
};

View file

@ -0,0 +1,111 @@
/*
* 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 { getFunctionDefinition, getFunctionSignatures } from '@kbn/esql-validation-autocomplete';
import { modeDescription } from '@kbn/esql-validation-autocomplete/src/autocomplete/commands/enrich/util';
import { ENRICH_MODES } from '@kbn/esql-validation-autocomplete/src/definitions/commands_helpers';
import { getHoverItem } from '../hover';
import { policies, setupTestbed } from './fixtures';
const assertGetHoverItem = async (statement: string, triggerString: string, expected: string[]) => {
const kit = setupTestbed(statement, triggerString);
const { contents } = await getHoverItem(kit.model, kit.position, kit.callbacks);
const result = contents.map(({ value }) => value).sort();
expect(result).toEqual(expected.sort());
};
describe('getHoverItem()', () => {
describe('policies', () => {
test('returns enrich policy list on hover', async () => {
function createPolicyContent(
policyName: string,
customPolicies: Array<(typeof policies)[number]> = policies
) {
const policyHit = customPolicies.find((p) => p.name === policyName);
if (!policyHit) {
return [];
}
return [
`**Indexes**: ${policyHit.sourceIndices.join(', ')}`,
`**Matching field**: ${policyHit.matchField}`,
`**Fields**: ${policyHit.enrichFields.join(', ')}`,
];
}
await assertGetHoverItem(
`from a | enrich policy on b with var0 = stringField`,
'policy',
createPolicyContent('policy')
);
await assertGetHoverItem(`from a | enrich policy`, 'policy', createPolicyContent('policy'));
await assertGetHoverItem(
`from a | enrich policy on b `,
'policy',
createPolicyContent('policy')
);
await assertGetHoverItem(`from a | enrich policy on b `, 'non-policy', []);
});
describe('ccq mode', () => {
for (const mode of ENRICH_MODES) {
test(mode.name, async () => {
await assertGetHoverItem(`from a | enrich _${mode.name}:policy`, `_${mode.name}`, [
modeDescription,
`**${mode.name}**: ${mode.description}`,
]);
});
}
});
});
describe('functions', () => {
function createFunctionContent(fn: string) {
const fnDefinition = getFunctionDefinition(fn);
if (!fnDefinition) {
return [];
}
return [getFunctionSignatures(fnDefinition)[0].declaration, fnDefinition.description];
}
test('function name', async () => {
await assertGetHoverItem(
`from a | eval round(numberField)`,
'round',
createFunctionContent('round')
);
await assertGetHoverItem(
`from a | eval nonExistentFn(numberField)`,
'nonExistentFn',
createFunctionContent('nonExistentFn')
);
await assertGetHoverItem(
`from a | eval round(numberField)`,
'round',
createFunctionContent('round')
);
await assertGetHoverItem(
`from a | eval nonExistentFn(numberField)`,
'nonExistentFn',
createFunctionContent('nonExistentFn')
);
});
test('nested function name', async () => {
await assertGetHoverItem(`from a | stats avg(round(numberField))`, 'round', [
'**Acceptable types**: **double** | **integer** | **long**',
...createFunctionContent('round'),
]);
await assertGetHoverItem(`from a | stats avg(nonExistentFn(numberField))`, 'nonExistentFn', [
'**Acceptable types**: **double** | **integer** | **long**',
...createFunctionContent('nonExistentFn'),
]);
});
});
});

View file

@ -1,200 +0,0 @@
/*
* 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 {
ESQLFieldWithMetadata,
getFunctionDefinition,
getFunctionSignatures,
} from '@kbn/esql-validation-autocomplete';
import { modeDescription } from '@kbn/esql-validation-autocomplete/src/autocomplete/commands/enrich/util';
import { ENRICH_MODES } from '@kbn/esql-validation-autocomplete/src/definitions/commands_helpers';
import { FieldType } from '@kbn/esql-validation-autocomplete/src/definitions/types';
import { monaco } from '../../../../monaco_imports';
import { getHoverItem } from './hover';
const types: FieldType[] = ['keyword', 'double', 'date', 'boolean', 'ip'];
const fields: Array<ESQLFieldWithMetadata & { suggestedAs?: string }> = [
...types.map((type) => ({
name: `${type}Field`,
type,
})),
{ name: 'any#Char$Field', type: 'double', suggestedAs: '`any#Char$Field`' },
{ name: 'kubernetes.something.something', type: 'double' },
];
const indexes = (
[] as Array<{ name: string; hidden: boolean; suggestedAs: string | undefined }>
).concat(
['a', 'index', 'otherIndex', '.secretIndex', 'my-index'].map((name) => ({
name,
hidden: name.startsWith('.'),
suggestedAs: undefined,
})),
['my-index[quoted]', 'my-index$', 'my_index{}'].map((name) => ({
name,
hidden: false,
suggestedAs: `\`${name}\``,
}))
);
const policies = [
{
name: 'policy',
sourceIndices: ['enrichIndex1'],
matchField: 'otherStringField',
enrichFields: ['otherField', 'yetAnotherField', 'yet-special-field'],
suggestedAs: undefined,
},
...['my-policy[quoted]', 'my-policy$', 'my_policy{}'].map((name) => ({
name,
sourceIndices: ['enrichIndex1'],
matchField: 'otherStringField',
enrichFields: ['otherField', 'yetAnotherField', 'yet-special-field'],
suggestedAs: `\`${name}\``,
})),
];
function createCustomCallbackMocks(
customFields: ESQLFieldWithMetadata[] | undefined,
customSources: Array<{ name: string; hidden: boolean }> | undefined,
customPolicies:
| Array<{
name: string;
sourceIndices: string[];
matchField: string;
enrichFields: string[];
}>
| undefined
) {
const finalFields = customFields || fields;
const finalSources = customSources || indexes;
const finalPolicies = customPolicies || policies;
return {
getFieldsFor: jest.fn(async () => finalFields),
getSources: jest.fn(async () => finalSources),
getPolicies: jest.fn(async () => finalPolicies),
};
}
function createModelAndPosition(text: string, string: string) {
return {
model: {
getValue: () => text,
getLineCount: () => text.split('\n').length,
getLineMaxColumn: (lineNumber: number) => text.split('\n')[lineNumber - 1].length,
} as unknown as monaco.editor.ITextModel,
// bumo the column by one as the internal logic has a -1 offset when converting frmo monaco
position: { lineNumber: 1, column: text.lastIndexOf(string) + 1 } as monaco.Position,
};
}
describe('hover', () => {
type TestArgs = [
string,
string,
(n: string) => string[],
Parameters<typeof createCustomCallbackMocks>?
];
const testHoverFn = (
statement: string,
triggerString: string,
contentFn: (name: string) => string[],
customCallbacksArgs: Parameters<typeof createCustomCallbackMocks> = [
undefined,
undefined,
undefined,
],
{ only, skip }: { only?: boolean; skip?: boolean } = {}
) => {
const { model, position } = createModelAndPosition(statement, triggerString);
const testFn = only ? test.only : skip ? test.skip : test;
const expected = contentFn(triggerString);
testFn(
`${statement} (hover: "${triggerString}" @ ${position.column} - ${
position.column + triggerString.length
})=> ["${expected.join('","')}"]`,
async () => {
const callbackMocks = createCustomCallbackMocks(...customCallbacksArgs);
const { contents } = await getHoverItem(model, position, callbackMocks);
expect(contents.map(({ value }) => value)).toEqual(expected);
}
);
};
// Enrich the function to work with .only and .skip as regular test function
const testHover = Object.assign(testHoverFn, {
skip: (...args: TestArgs) => {
const paddingArgs = [[undefined, undefined, undefined]].slice(args.length - 1);
return testHoverFn(...((args.length > 1 ? [...args, ...paddingArgs] : args) as TestArgs), {
skip: true,
});
},
only: (...args: TestArgs) => {
const paddingArgs = [[undefined, undefined, undefined]].slice(args.length - 1);
return testHoverFn(...((args.length > 1 ? [...args, ...paddingArgs] : args) as TestArgs), {
only: true,
});
},
});
describe('policies', () => {
function createPolicyContent(
policyName: string,
customPolicies: Array<(typeof policies)[number]> = policies
) {
const policyHit = customPolicies.find((p) => p.name === policyName);
if (!policyHit) {
return [];
}
return [
`**Indexes**: ${policyHit.sourceIndices.join(', ')}`,
`**Matching field**: ${policyHit.matchField}`,
`**Fields**: ${policyHit.enrichFields.join(', ')}`,
];
}
testHover(`from a | enrich policy on b with var0 = stringField`, 'policy', createPolicyContent);
testHover(`from a | enrich policy`, 'policy', createPolicyContent);
testHover(`from a | enrich policy on b `, 'policy', createPolicyContent);
testHover(`from a | enrich policy on b `, 'non-policy', createPolicyContent);
describe('ccq mode', () => {
for (const mode of ENRICH_MODES) {
testHover(`from a | enrich _${mode.name}:policy`, `_${mode.name}`, () => [
modeDescription,
`**${mode.name}**: ${mode.description}`,
]);
}
});
});
describe('functions', () => {
function createFunctionContent(fn: string) {
const fnDefinition = getFunctionDefinition(fn);
if (!fnDefinition) {
return [];
}
return [getFunctionSignatures(fnDefinition)[0].declaration, fnDefinition.description];
}
testHover(`from a | eval round(numberField)`, 'round', createFunctionContent);
testHover(`from a | eval nonExistentFn(numberField)`, 'nonExistentFn', createFunctionContent);
testHover(`from a | stats avg(round(numberField))`, 'round', () => {
return [
'**Acceptable types**: **double** | **integer** | **long**',
...createFunctionContent('round'),
];
});
testHover(`from a | stats avg(round(numberField))`, 'avg', createFunctionContent);
testHover(`from a | stats avg(nonExistentFn(numberField))`, 'nonExistentFn', () => [
'**Acceptable types**: **double** | **integer** | **long**',
...createFunctionContent('nonExistentFn'),
]);
testHover(`from a | where round(numberField) > 0`, 'round', createFunctionContent);
});
});

View file

@ -44,10 +44,17 @@ const ACCEPTABLE_TYPES_HOVER = i18n.translate('monaco.esql.hover.acceptableTypes
defaultMessage: 'Acceptable types',
});
export type HoverMonacoModel = Pick<monaco.editor.ITextModel, 'getValue'>;
/**
* @todo Monaco dependencies are not necesasry here: (1) replace {@link HoverMonacoModel}
* by some generic `getText(): string` method; (2) replace {@link monaco.Position} by
* `offset: number`.
*/
export async function getHoverItem(
model: monaco.editor.ITextModel,
model: HoverMonacoModel,
position: monaco.Position,
resourceRetriever?: ESQLCallbacks
callbacks?: ESQLCallbacks
) {
const fullText = model.getValue();
const offset = monacoPositionToOffset(fullText, position);
@ -69,6 +76,12 @@ export async function getHoverItem(
containingFunction = fn as ESQLFunction<'variadic-call'>;
}
},
visitSource: (source, parent, walker) => {
if (within(offset, source.location)) {
node = source;
walker.abort();
}
},
visitSingleAstItem: (_node) => {
// ignore identifiers because we don't want to choose them as the node type
// instead of the function node (functions can have an "operator" child which is
@ -87,7 +100,7 @@ export async function getHoverItem(
return hoverContent;
}
const variables = resourceRetriever?.getVariables?.();
const variables = callbacks?.getVariables?.();
const variablesContent = getVariablesHoverContent(node, variables);
if (variablesContent.length) {
@ -100,7 +113,7 @@ export async function getHoverItem(
root,
fullText,
offset,
resourceRetriever
callbacks
);
hoverContent.contents.push(...argHints);
}
@ -120,7 +133,7 @@ export async function getHoverItem(
if (node.type === 'source' && node.sourceType === 'policy') {
const source = node as ESQLSource;
const { getPolicyMetadata } = getPolicyHelper(resourceRetriever);
const { getPolicyMetadata } = getPolicyHelper(callbacks);
const policyMetadata = await getPolicyMetadata(node.name);
if (policyMetadata) {
hoverContent.contents.push(