[8.x] [ES|QL] Separate GROK and DISSECT autocomplete routines (#211101) (#211320)

# Backport

This will backport the following commits from `main` to `8.x`:
- [[ES|QL] Separate `GROK` and `DISSECT`
autocomplete routines
(#211101)](https://github.com/elastic/kibana/pull/211101)

<!--- Backport version: 9.6.4 -->

### Questions ?
Please refer to the [Backport tool
documentation](https://github.com/sorenlouv/backport)

<!--BACKPORT [{"author":{"name":"Drew
Tate","email":"drew.tate@elastic.co"},"sourceCommit":{"committedDate":"2025-02-14T14:44:46Z","message":"[ES|QL]
Separate `GROK` and `DISSECT` autocomplete routines (#211101)\n\n##
Summary\n\nPart of
https://github.com/elastic/kibana/issues/195418\n\nGives `GROK` and
`DISSECT` autocomplete logic its own home 🏡\n\n### Checklist\n\n- [x]
[Unit or
functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere
updated or added to match the most common scenarios\n\n### Identify
risks\n\n- [ ] As with any refactor, there's a possibility this will
introduce a\nregression in the behavior of commands. However, all
automated tests are\npassing and I have tested the behavior manually and
can detect
no\nregression.","sha":"1223926450736fba5f67d17279ccf650d8b3ff2a","branchLabelMapping":{"^v9.1.0$":"main","^v8.19.0$":"8.x","^v(\\d+).(\\d+).\\d+$":"$1.$2"}},"sourcePullRequest":{"labels":["release_note:skip","Feature:ES|QL","Team:ESQL","backport:version","v9.1.0","v8.19.0"],"title":"[ES|QL]
Separate `GROK` and `DISSECT` autocomplete
routines","number":211101,"url":"https://github.com/elastic/kibana/pull/211101","mergeCommit":{"message":"[ES|QL]
Separate `GROK` and `DISSECT` autocomplete routines (#211101)\n\n##
Summary\n\nPart of
https://github.com/elastic/kibana/issues/195418\n\nGives `GROK` and
`DISSECT` autocomplete logic its own home 🏡\n\n### Checklist\n\n- [x]
[Unit or
functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere
updated or added to match the most common scenarios\n\n### Identify
risks\n\n- [ ] As with any refactor, there's a possibility this will
introduce a\nregression in the behavior of commands. However, all
automated tests are\npassing and I have tested the behavior manually and
can detect
no\nregression.","sha":"1223926450736fba5f67d17279ccf650d8b3ff2a"}},"sourceBranch":"main","suggestedTargetBranches":["8.x"],"targetPullRequestStates":[{"branch":"main","label":"v9.1.0","branchLabelMappingKey":"^v9.1.0$","isSourceBranch":true,"state":"MERGED","url":"https://github.com/elastic/kibana/pull/211101","number":211101,"mergeCommit":{"message":"[ES|QL]
Separate `GROK` and `DISSECT` autocomplete routines (#211101)\n\n##
Summary\n\nPart of
https://github.com/elastic/kibana/issues/195418\n\nGives `GROK` and
`DISSECT` autocomplete logic its own home 🏡\n\n### Checklist\n\n- [x]
[Unit or
functional\ntests](https://www.elastic.co/guide/en/kibana/master/development-tests.html)\nwere
updated or added to match the most common scenarios\n\n### Identify
risks\n\n- [ ] As with any refactor, there's a possibility this will
introduce a\nregression in the behavior of commands. However, all
automated tests are\npassing and I have tested the behavior manually and
can detect
no\nregression.","sha":"1223926450736fba5f67d17279ccf650d8b3ff2a"}},{"branch":"8.x","label":"v8.19.0","branchLabelMappingKey":"^v8.19.0$","isSourceBranch":false,"state":"NOT_CREATED"}]}]
BACKPORT-->
This commit is contained in:
Drew Tate 2025-02-15 00:58:04 -07:00 committed by GitHub
parent 855d9fd55d
commit c2797a3208
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 261 additions and 126 deletions

View file

@ -0,0 +1,71 @@
/*
* 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 { ESQL_STRING_TYPES } from '../../shared/esql_types';
import { attachTriggerCommand, getFieldNamesByType, setup } from './helpers';
describe('autocomplete.suggest', () => {
describe('DISSECT', () => {
it('suggests fields after DISSECT', async () => {
const { assertSuggestions } = await setup();
await assertSuggestions(
'from a | DISSECT /',
getFieldNamesByType(ESQL_STRING_TYPES).map((name) => `${name} `)
);
await assertSuggestions(
'from a | DISSECT /',
getFieldNamesByType(ESQL_STRING_TYPES).map((name) => `${name} `),
{ triggerCharacter: ' ' }
);
await assertSuggestions(
'from a | DISSECT key/',
getFieldNamesByType(ESQL_STRING_TYPES).map((name) => `${name} `)
);
await assertSuggestions(
'from a | DISSECT keywordField/',
['keywordField ', 'textField '].map(attachTriggerCommand)
);
});
const constantPattern = '"%{firstWord}" ';
it('suggests a pattern after a field name', async () => {
const { assertSuggestions } = await setup();
await assertSuggestions('from a | DISSECT keywordField /', [constantPattern]);
});
it('suggests an append separator or pipe after a pattern', async () => {
const { assertSuggestions } = await setup();
assertSuggestions(
`from a | DISSECT keywordField ${constantPattern} /`,
['APPEND_SEPARATOR = ', '| '].map(attachTriggerCommand),
{ triggerCharacter: ' ' }
);
assertSuggestions(
`from a | DISSECT keywordField ${constantPattern} /`,
['APPEND_SEPARATOR = ', '| '].map(attachTriggerCommand)
);
});
it('suggests append separators', async () => {
const { assertSuggestions } = await setup();
await assertSuggestions(
`from a | DISSECT keywordField ${constantPattern} append_separator = /`,
['":" ', '";" '].map(attachTriggerCommand)
);
});
it('suggests a pipe after an append separator', async () => {
const { assertSuggestions } = await setup();
await assertSuggestions(
`from a | DISSECT keywordField ${constantPattern} append_separator = ":" /`,
['| ']
);
});
});
});

View file

@ -0,0 +1,42 @@
/*
* 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 { ESQL_STRING_TYPES } from '../../shared/esql_types';
import { attachTriggerCommand, getFieldNamesByType, setup } from './helpers';
describe('autocomplete.suggest', () => {
describe('GROK', () => {
it('suggests fields after GROK', async () => {
const { assertSuggestions } = await setup();
await assertSuggestions(
'from a | grok /',
getFieldNamesByType(ESQL_STRING_TYPES).map((name) => `${name} `)
);
await assertSuggestions(
'from a | grok key/',
getFieldNamesByType(ESQL_STRING_TYPES).map((name) => `${name} `)
);
await assertSuggestions(
'from a | grok keywordField/',
['keywordField ', 'textField '].map(attachTriggerCommand)
);
});
const constantPattern = '"%{WORD:firstWord}"';
it('suggests a pattern after a field name', async () => {
const { assertSuggestions } = await setup();
await assertSuggestions('from a | grok keywordField /', [constantPattern + ' ']);
});
it('suggests a pipe after a pattern', async () => {
const { assertSuggestions } = await setup();
await assertSuggestions(`from a | grok keywordField ${constantPattern} /`, ['| ']);
});
});
});

View file

@ -113,88 +113,6 @@ describe('autocomplete', () => {
testSuggestions('from a metadata _id | eval var0 = a | /', commands);
});
describe('show', () => {
testSuggestions('show /', ['INFO']);
for (const fn of ['info']) {
testSuggestions(`show ${fn} /`, ['| ']);
}
});
describe('grok', () => {
const constantPattern = '"%{WORD:firstWord}"';
const subExpressions = [
'',
`grok keywordField |`,
`grok keywordField ${constantPattern} |`,
`dissect keywordField ${constantPattern} append_separator = ":" |`,
`dissect keywordField ${constantPattern} |`,
];
for (const subExpression of subExpressions) {
testSuggestions(
`from a | ${subExpression} grok /`,
getFieldNamesByType(ESQL_STRING_TYPES).map((name) => `${name} `)
);
testSuggestions(`from a | ${subExpression} grok keywordField /`, [constantPattern], ' ');
testSuggestions(`from a | ${subExpression} grok keywordField ${constantPattern} /`, ['| ']);
}
testSuggestions(
'from a | grok /',
getFieldNamesByType(ESQL_STRING_TYPES).map((name) => `${name} `)
);
testSuggestions(
'from a | grok key/',
getFieldNamesByType(ESQL_STRING_TYPES).map((name) => `${name} `)
);
testSuggestions(
'from a | grok keywordField/',
['keywordField ', 'textField '].map(attachTriggerCommand)
);
});
describe('dissect', () => {
const constantPattern = '"%{firstWord}"';
const subExpressions = [
'',
`dissect keywordField |`,
`dissect keywordField ${constantPattern} |`,
`dissect keywordField ${constantPattern} append_separator = ":" |`,
];
for (const subExpression of subExpressions) {
testSuggestions(
`from a | ${subExpression} dissect /`,
getFieldNamesByType(ESQL_STRING_TYPES).map((name) => `${name} `)
);
testSuggestions(`from a | ${subExpression} dissect keywordField /`, [constantPattern], ' ');
testSuggestions(
`from a | ${subExpression} dissect keywordField ${constantPattern} /`,
['APPEND_SEPARATOR = $0', '| '],
' '
);
testSuggestions(
`from a | ${subExpression} dissect keywordField ${constantPattern} append_separator = /`,
['":"', '";"']
);
testSuggestions(
`from a | ${subExpression} dissect keywordField ${constantPattern} append_separator = ":" /`,
['| ']
);
}
testSuggestions(
'from a | dissect /',
getFieldNamesByType(ESQL_STRING_TYPES).map((name) => `${name} `)
);
testSuggestions(
'from a | dissect key/',
getFieldNamesByType(ESQL_STRING_TYPES).map((name) => `${name} `)
);
testSuggestions(
'from a | dissect keywordField/',
['keywordField ', 'textField '].map(attachTriggerCommand)
);
});
describe('limit', () => {
testSuggestions('from a | limit /', ['10 ', '100 ', '1000 ']);
testSuggestions('from a | limit 4 /', ['| ']);

View file

@ -52,12 +52,10 @@ import { collectVariables, excludeVariablesFromCurrentCommand } from '../shared/
import type { ESQLPolicy, ESQLRealField, ESQLVariable, ReferenceMaps } from '../validation/types';
import {
allStarConstant,
colonCompleteItem,
commaCompleteItem,
getAssignmentDefinitionCompletitionItem,
getCommandAutocompleteDefinitions,
pipeCompleteItem,
semiColonCompleteItem,
} from './complete_items';
import {
buildFieldsDefinitions,
@ -210,6 +208,7 @@ export async function suggest(
if (
astContext.type === 'expression' ||
(astContext.type === 'option' && astContext.command?.name === 'join') ||
(astContext.type === 'option' && astContext.command?.name === 'dissect') ||
(astContext.type === 'option' && astContext.command?.name === 'from')
) {
return getSuggestionsWithinCommandExpression(
@ -285,7 +284,7 @@ export function getFieldsByTypeRetriever(
const supportsControls = resourceRetriever?.canSuggestVariables?.() ?? false;
return {
getFieldsByType: async (
expectedType: string | string[] = 'any',
expectedType: Readonly<string> | Readonly<string[]> = 'any',
ignored: string[] = [],
options
) => {
@ -791,7 +790,7 @@ async function getExpressionSuggestionsByType(
// it can be just literal values (i.e. "string")
if (argDef.constantOnly) {
// ... | <COMMAND> ... <suggest>
suggestions.push(...getCompatibleLiterals(command.name, [argDef.type], [argDef.name]));
suggestions.push(...getCompatibleLiterals(command.name, [argDef.type]));
} else {
// or it can be anything else as long as it is of the right type and the end (i.e. column or function)
if (!nodeArg) {
@ -1078,7 +1077,6 @@ async function getFunctionArgsSuggestions(
...getCompatibleLiterals(
command.name,
getTypesFromParamDefs(constantOnlyParamDefs) as string[],
undefined,
{
addComma: shouldAddComma,
advanceCursorAndOpenSuggestions: hasMoreMandatoryArgs,
@ -1151,7 +1149,7 @@ async function getFunctionArgsSuggestions(
if (isLiteralItem(arg) && isNumericType(arg.literalType)) {
// ... | EVAL fn(2 <suggest>)
suggestions.push(
...getCompatibleLiterals(command.name, ['time_literal_unit'], undefined, {
...getCompatibleLiterals(command.name, ['time_literal_unit'], {
addComma: shouldAddComma,
advanceCursorAndOpenSuggestions: hasMoreMandatoryArgs,
})
@ -1403,15 +1401,6 @@ async function getOptionArgsSuggestions(
}
}
if (command.name === 'dissect') {
if (
option.args.filter((arg) => !(isSingleItem(arg) && arg.type === 'unknown')).length < 1 &&
optionDef
) {
suggestions.push(colonCompleteItem, semiColonCompleteItem);
}
}
if (optionDef) {
if (!suggestions.length) {
const argDefIndex = optionDef.signature.multipleParams

View file

@ -0,0 +1,77 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { EDITOR_MARKER } from '../../../shared/constants';
import { isSingleItem } from '../../../..';
import { ESQL_STRING_TYPES } from '../../../shared/esql_types';
import { CommandSuggestParams } from '../../../definitions/types';
import type { SuggestionRawDefinition } from '../../types';
import { TRIGGER_SUGGESTION_COMMAND, buildConstantsDefinitions } from '../../factories';
import { colonCompleteItem, pipeCompleteItem, semiColonCompleteItem } from '../../complete_items';
export async function suggest({
command,
innerText,
getColumnsByType,
}: CommandSuggestParams<'dissect'>): Promise<SuggestionRawDefinition[]> {
const commandArgs = command.args.filter(
(arg) => isSingleItem(arg) && arg.text !== EDITOR_MARKER && arg.text !== ''
);
// DISSECT field /
if (commandArgs.length === 1) {
return buildConstantsDefinitions(
['"%{firstWord}"'],
i18n.translate('kbn-esql-validation-autocomplete.esql.autocomplete.aPatternString', {
defaultMessage: 'A pattern string',
}),
undefined,
{
advanceCursorAndOpenSuggestions: true,
}
);
}
// DISSECT field pattern /
else if (commandArgs.length === 2) {
return [
{ ...pipeCompleteItem, command: TRIGGER_SUGGESTION_COMMAND },
appendSeparatorCompletionItem,
];
}
// DISSECT field APPEND_SEPARATOR = /
else if (/append_separator\s*=\s*$/i.test(innerText)) {
return [colonCompleteItem, semiColonCompleteItem];
}
// DISSECT field APPEND_SEPARATOR = ":" /
else if (commandArgs.some((arg) => isSingleItem(arg) && arg.type === 'option')) {
return [{ ...pipeCompleteItem, command: TRIGGER_SUGGESTION_COMMAND }];
}
// DISSECT /
const fieldSuggestions = await getColumnsByType(ESQL_STRING_TYPES);
return fieldSuggestions.map((sug) => ({
...sug,
text: `${sug.text} `,
command: TRIGGER_SUGGESTION_COMMAND,
}));
}
const appendSeparatorCompletionItem: SuggestionRawDefinition = {
command: TRIGGER_SUGGESTION_COMMAND,
detail: i18n.translate('kbn-esql-validation-autocomplete.esql.definitions.appendSeparatorDoc', {
defaultMessage:
'The character(s) that separate the appended fields. Default to empty string ("").',
}),
kind: 'Reference',
label: 'APPEND_SEPARATOR',
sortText: '1',
text: 'APPEND_SEPARATOR = ',
};

View file

@ -0,0 +1,47 @@
/*
* 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 { i18n } from '@kbn/i18n';
import { CommandSuggestParams } from '../../../definitions/types';
import type { SuggestionRawDefinition } from '../../types';
import { ESQL_STRING_TYPES } from '../../../shared/esql_types';
import { TRIGGER_SUGGESTION_COMMAND, buildConstantsDefinitions } from '../../factories';
import { pipeCompleteItem } from '../../complete_items';
export async function suggest({
command,
getColumnsByType,
}: CommandSuggestParams<'grok'>): Promise<SuggestionRawDefinition[]> {
// GROK field /
if (command.args.length === 1) {
return buildConstantsDefinitions(
['"%{WORD:firstWord}"'],
i18n.translate('kbn-esql-validation-autocomplete.esql.autocomplete.aPatternString', {
defaultMessage: 'A pattern string',
}),
undefined,
{
advanceCursorAndOpenSuggestions: true,
}
);
}
// GROK field pattern /
else if (command.args.length === 2) {
return [{ ...pipeCompleteItem, command: TRIGGER_SUGGESTION_COMMAND }];
}
// GROK /
const fieldSuggestions = await getColumnsByType(ESQL_STRING_TYPES);
return fieldSuggestions.map((sug) => ({
...sug,
text: `${sug.text} `,
command: TRIGGER_SUGGESTION_COMMAND,
}));
}

View file

@ -20,7 +20,7 @@ export async function suggest({
if (/INFO\s+$/i.test(innerText)) {
return [{ ...pipeCompleteItem, command: TRIGGER_SUGGESTION_COMMAND }];
}
// SHOW INSOF /
// SHOW LOLZ /
else if (/SHOW\s+\S+\s+$/i.test(innerText)) {
return [];
}

View file

@ -79,14 +79,21 @@ export const getCommandAutocompleteDefinitions = (
function buildCharCompleteItem(
label: string,
detail: string,
{ sortText, quoted }: { sortText?: string; quoted: boolean } = { quoted: false }
{
sortText,
quoted,
advanceCursorAndOpenSuggestions,
}: { sortText?: string; quoted: boolean; advanceCursorAndOpenSuggestions?: boolean } = {
quoted: false,
}
): SuggestionRawDefinition {
return {
label,
text: quoted ? `"${label}"` : label,
text: (quoted ? `"${label}"` : label) + (advanceCursorAndOpenSuggestions ? ' ' : ''),
kind: 'Keyword',
detail,
sortText,
command: advanceCursorAndOpenSuggestions ? TRIGGER_SUGGESTION_COMMAND : undefined,
};
}
export const pipeCompleteItem: SuggestionRawDefinition = {
@ -113,14 +120,14 @@ export const colonCompleteItem = buildCharCompleteItem(
i18n.translate('kbn-esql-validation-autocomplete.esql.autocomplete.colonDoc', {
defaultMessage: 'Colon (:)',
}),
{ sortText: 'A', quoted: true }
{ sortText: 'A', quoted: true, advanceCursorAndOpenSuggestions: true }
);
export const semiColonCompleteItem = buildCharCompleteItem(
';',
i18n.translate('kbn-esql-validation-autocomplete.esql.autocomplete.semiColonDoc', {
defaultMessage: 'Semi colon (;)',
}),
{ sortText: 'A', quoted: true }
{ sortText: 'A', quoted: true, advanceCursorAndOpenSuggestions: true }
);
export const listCompleteItem: SuggestionRawDefinition = {

View file

@ -459,7 +459,6 @@ export function getUnitDuration(unit: number = 1) {
export function getCompatibleLiterals(
commandName: string,
types: string[],
names?: string[],
options?: {
advanceCursorAndOpenSuggestions?: boolean;
addComma?: boolean;
@ -505,25 +504,6 @@ export function getCompatibleLiterals(
)
); // i.e. year, month, ...
}
if (types.includes('string')) {
if (names) {
const index = types.indexOf('string');
if (/pattern/.test(names[index])) {
suggestions.push(
...buildConstantsDefinitions(
[commandName === 'grok' ? '"%{WORD:firstWord}"' : '"%{firstWord}"'],
i18n.translate('kbn-esql-validation-autocomplete.esql.autocomplete.aPatternString', {
defaultMessage: 'A pattern string',
}),
undefined,
options
)
);
} else {
suggestions.push(...buildConstantsDefinitions(['string'], '', undefined, options));
}
}
}
return suggestions;
}

View file

@ -83,7 +83,7 @@ export interface EditorContext {
}
export type GetColumnsByTypeFn = (
type: string | string[],
type: Readonly<string> | Readonly<string[]>,
ignored?: string[],
options?: {
advanceCursor?: boolean;

View file

@ -46,6 +46,8 @@ import { suggest as suggestForJoin } from '../autocomplete/commands/join';
import { suggest as suggestForFrom } from '../autocomplete/commands/from';
import { suggest as suggestForRow } from '../autocomplete/commands/row';
import { suggest as suggestForShow } from '../autocomplete/commands/show';
import { suggest as suggestForGrok } from '../autocomplete/commands/grok';
import { suggest as suggestForDissect } from '../autocomplete/commands/dissect';
const statsValidator = (command: ESQLCommand) => {
const messages: ESQLMessage[] = [];
@ -454,7 +456,7 @@ export const commandDefinitions: Array<CommandDefinition<any>> = [
defaultMessage:
'Extracts multiple string values from a single string input, based on a pattern',
}),
examples: ['… | dissect a "%{b} %{c}"'],
examples: ['… | DISSECT a "%{b} %{c}" APPEND_SEPARATOR = ":"'],
options: [appendSeparatorOption],
modes: [],
signature: {
@ -464,6 +466,7 @@ export const commandDefinitions: Array<CommandDefinition<any>> = [
{ name: 'pattern', type: 'string', constantOnly: true },
],
},
suggest: suggestForDissect,
},
{
name: 'grok',
@ -471,7 +474,7 @@ export const commandDefinitions: Array<CommandDefinition<any>> = [
defaultMessage:
'Extracts multiple string values from a single string input, based on a pattern',
}),
examples: ['… | grok a "%{IP:b} %{NUMBER:c}"'],
examples: ['… | GROK a "%{IP:b} %{NUMBER:c}"'],
options: [],
modes: [],
signature: {
@ -481,6 +484,7 @@ export const commandDefinitions: Array<CommandDefinition<any>> = [
{ name: 'pattern', type: 'string', constantOnly: true },
],
},
suggest: suggestForGrok,
},
{
name: 'mv_expand',

View file

@ -48,7 +48,7 @@ export function getFieldsByTypeHelper(queryText: string, resourceRetriever?: ESQ
return {
getFieldsByType: async (
expectedType: string | string[] = 'any',
expectedType: Readonly<string> | Readonly<string[]> = 'any',
ignored: string[] = []
): Promise<ESQLRealField[]> => {
const types = Array.isArray(expectedType) ? expectedType : [expectedType];