mirror of
https://github.com/elastic/kibana.git
synced 2025-06-27 18:51:07 -04:00
[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:
parent
979d0ce0f2
commit
f433e7aa97
13 changed files with 1243 additions and 843 deletions
|
@ -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`;
|
|
@ -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';
|
||||
|
|
File diff suppressed because it is too large
Load diff
|
@ -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']);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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 () => {
|
||||
|
|
|
@ -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' &&
|
||||
|
|
|
@ -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;
|
||||
};
|
|
@ -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'),
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
});
|
||||
});
|
|
@ -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(
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue