[ES|QL] Separate ENRICH autocomplete routine (#211657)

## Summary

Part of https://github.com/elastic/kibana/issues/195418

Gives `ENRICH` autocomplete logic its own home 🏡

### 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

### Identify risks

- [ ] As with any refactor, there's a possibility this will introduce a
regression in the behavior of commands. However, all automated tests are
passing and I have tested the behavior manually and can detect no
regression.

---------

Co-authored-by: Stratoula Kalafateli <efstratia.kalafateli@elastic.co>
This commit is contained in:
Drew Tate 2025-03-03 13:24:23 -07:00 committed by GitHub
parent d2913395af
commit f2a91732d8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
13 changed files with 549 additions and 376 deletions

View file

@ -0,0 +1,142 @@
/*
* 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 { camelCase } from 'lodash';
import { getFieldNamesByType, getPolicyFields, policies, setup } from './helpers';
describe('autocomplete.suggest', () => {
describe('ENRICH', () => {
const modes = ['any', 'coordinator', 'remote'];
const expectedPolicyNameSuggestions = policies
.map(({ name, suggestedAs }) => suggestedAs || name)
.map((name) => `${name} `);
let assertSuggestions: Awaited<ReturnType<typeof setup>>['assertSuggestions'];
beforeEach(async () => {
const setupResult = await setup();
assertSuggestions = setupResult.assertSuggestions;
});
it('suggests policy names', async () => {
await assertSuggestions(`from a | enrich /`, expectedPolicyNameSuggestions);
await assertSuggestions(`from a | enrich po/`, expectedPolicyNameSuggestions);
});
test('modes', async () => {
await assertSuggestions(
`from a | enrich _/`,
modes.map((mode) => `_${mode}:$0`),
{ triggerCharacter: '_' }
);
await assertSuggestions('from a | enrich _any: /', []);
for (const mode of modes) {
await assertSuggestions(`from a | enrich _${mode}:/`, expectedPolicyNameSuggestions, {
triggerCharacter: ':',
});
await assertSuggestions(
`from a | enrich _${mode.toUpperCase()}:/`,
expectedPolicyNameSuggestions,
{ triggerCharacter: ':' }
);
await assertSuggestions(
`from a | enrich _${camelCase(mode)}:/`,
expectedPolicyNameSuggestions,
{ triggerCharacter: ':' }
);
}
});
it('suggests ON and WITH after policy name', async () => {
await assertSuggestions(`from a | enrich policy /`, ['ON ', 'WITH ', '| ']);
await assertSuggestions(`from a | enrich policy O/`, ['ON ', 'WITH ', '| ']);
});
it('suggests fields after ON', async () => {
await assertSuggestions(
`from a | enrich policy on /`,
getFieldNamesByType('any').map((v) => `${v} `)
);
await assertSuggestions(
`from a | enrich policy on fi/`,
getFieldNamesByType('any').map((v) => `${v} `)
);
});
describe('WITH', () => {
it('suggests WITH after ON <field>', async () => {
await assertSuggestions(`from a | enrich policy on field /`, ['WITH ', '| ']);
});
it('suggests fields for new WITH clauses', async () => {
await assertSuggestions(`from a | enrich policy on field with /`, [
'var0 = ',
...getPolicyFields('policy').map((name) => ({
text: name,
// Makes sure the suggestion menu isn't opened when a field is accepted
command: undefined,
})),
]);
await assertSuggestions(`from a | enrich policy on field with fi/`, [
'var0 = ',
...getPolicyFields('policy'),
]);
await assertSuggestions(`from a | enrich policy on b with var0 = otherField, /`, [
'var1 = ',
...getPolicyFields('policy'),
]);
await assertSuggestions(`from a | enrich policy on b with var0 = otherField, fi/`, [
'var1 = ',
...getPolicyFields('policy'),
]);
});
test('waits to suggest fields until space', async () => {
await assertSuggestions(`from a | enrich policy on b with var0 = otherField,/`, []);
await assertSuggestions(`from a | enrich policy on b with/`, []);
});
test('after first word', async () => {
// not a recognized column name
await assertSuggestions(`from a | enrich policy on b with var0 /`, ['= $0']);
// recognized column name
await assertSuggestions(`from a | enrich policy on b with otherField /`, [',', '| ']);
});
test('suggests enrich fields after open assignment', async () => {
await assertSuggestions(`from a | enrich policy on b with var0 = /`, [
...getPolicyFields('policy'),
]);
await assertSuggestions(`from a | enrich policy on b with var0 = fi/`, [
...getPolicyFields('policy'),
]);
await assertSuggestions(`from a | enrich policy on b with var0 = otherField, var1 = /`, [
...getPolicyFields('policy'),
]);
});
test('after complete clause', async () => {
// works with escaped field names
await assertSuggestions(`from a | enrich policy on b with var0 = \`otherField\` /`, [
',',
'| ',
]);
await assertSuggestions(`from a | enrich policy on b with var0=otherField /`, [',', '| ']);
await assertSuggestions(`from a | enrich policy on b with otherField /`, [',', '| ']);
});
test('after user-defined column name', async () => {
await assertSuggestions(`from a | enrich policy on b with var0 = otherField, var1 /`, [
'= $0',
]);
});
});
});
});

View file

@ -12,7 +12,6 @@ import { scalarFunctionDefinitions } from '../definitions/generated/scalar_funct
import { timeUnitsToSuggest } from '../definitions/literals';
import { commandDefinitions as unmodifiedCommandDefinitions } from '../definitions/commands';
import { getSafeInsertText, TIME_SYSTEM_PARAMS, TRIGGER_SUGGESTION_COMMAND } from './factories';
import { camelCase } from 'lodash';
import { getAstAndSyntaxErrors } from '@kbn/esql-ast';
import {
policies,
@ -206,84 +205,6 @@ describe('autocomplete', () => {
});
}
describe('enrich', () => {
const modes = ['any', 'coordinator', 'remote'];
const expectedPolicyNameSuggestions = policies
.map(({ name, suggestedAs }) => suggestedAs || name)
.map((name) => `${name} `);
for (const prevCommand of [
'',
// '| enrich other-policy ',
// '| enrich other-policy on b ',
// '| enrich other-policy with c ',
]) {
testSuggestions(`from a ${prevCommand}| enrich /`, expectedPolicyNameSuggestions);
testSuggestions(
`from a ${prevCommand}| enrich _/`,
modes.map((mode) => `_${mode}:$0`),
'_'
);
for (const mode of modes) {
testSuggestions(
`from a ${prevCommand}| enrich _${mode}:/`,
expectedPolicyNameSuggestions,
':'
);
testSuggestions(
`from a ${prevCommand}| enrich _${mode.toUpperCase()}:/`,
expectedPolicyNameSuggestions,
':'
);
testSuggestions(
`from a ${prevCommand}| enrich _${camelCase(mode)}:/`,
expectedPolicyNameSuggestions,
':'
);
}
testSuggestions(`from a ${prevCommand}| enrich policy /`, ['ON $0', 'WITH $0', '| ']);
testSuggestions(
`from a ${prevCommand}| enrich policy on /`,
getFieldNamesByType('any').map((v) => `${v} `)
);
testSuggestions(`from a ${prevCommand}| enrich policy on b /`, ['WITH $0', '| ']);
testSuggestions(
`from a ${prevCommand}| enrich policy on b with /`,
['var0 = ', ...getPolicyFields('policy')],
' '
);
testSuggestions(`from a ${prevCommand}| enrich policy on b with var0 /`, ['= $0', ',', '| ']);
testSuggestions(`from a ${prevCommand}| enrich policy on b with var0 = /`, [
...getPolicyFields('policy'),
]);
testSuggestions(`from a ${prevCommand}| enrich policy on b with var0 = keywordField /`, [
',',
'| ',
]);
testSuggestions(`from a ${prevCommand}| enrich policy on b with var0 = keywordField, /`, [
'var1 = ',
...getPolicyFields('policy'),
]);
testSuggestions(
`from a ${prevCommand}| enrich policy on b with var0 = keywordField, var1 /`,
['= $0', ',', '| ']
);
testSuggestions(
`from a ${prevCommand}| enrich policy on b with var0 = keywordField, var1 = /`,
[...getPolicyFields('policy')]
);
testSuggestions(
`from a ${prevCommand}| enrich policy with /`,
['var0 = ', ...getPolicyFields('policy')],
' '
);
testSuggestions(`from a ${prevCommand}| enrich policy with keywordField /`, [
'= $0',
',',
'| ',
]);
}
});
// @TODO: get updated eval block from main
describe('values suggestions', () => {
testSuggestions('FROM "i/"', []);
@ -449,7 +370,7 @@ describe('autocomplete', () => {
);
// ENRICH policy ON
testSuggestions('FROM index1 | ENRICH policy O/', ['ON $0', 'WITH $0', '| ']);
testSuggestions('FROM index1 | ENRICH policy O/', ['ON ', 'WITH ', '| ']);
// ENRICH policy ON field
testSuggestions(
@ -816,10 +737,7 @@ describe('autocomplete', () => {
.map(attachTriggerCommand)
.map((s) => ({ ...s, rangeToReplace: { start: 17, end: 20 } }))
);
testSuggestions(
'FROM a | ENRICH policy /',
['ON $0', 'WITH $0', '| '].map(attachTriggerCommand)
);
testSuggestions('FROM a | ENRICH policy /', ['ON ', 'WITH ', '| '].map(attachTriggerCommand));
testSuggestions(
'FROM a | ENRICH policy ON /',
@ -829,12 +747,12 @@ describe('autocomplete', () => {
);
testSuggestions(
'FROM a | ENRICH policy ON @timestamp /',
['WITH $0', '| '].map(attachTriggerCommand)
['WITH ', '| '].map(attachTriggerCommand)
);
// nothing fancy with this field list
testSuggestions('FROM a | ENRICH policy ON @timestamp WITH /', [
'var0 = ',
...getPolicyFields('policy').map((name) => ({ text: name, command: undefined })),
...getPolicyFields('policy'),
]);
describe('replacement range', () => {
testSuggestions('FROM a | ENRICH policy ON @timestamp WITH othe/', [

View file

@ -24,7 +24,6 @@ import {
getCommandDefinition,
getCommandOption,
getFunctionDefinition,
getLastNonWhitespaceChar,
isAssignment,
isAssignmentComplete,
isColumnItem,
@ -34,23 +33,19 @@ import {
isOptionItem,
isRestartingExpression,
isSourceCommand,
isSettingItem,
isSourceItem,
isTimeIntervalItem,
getAllFunctions,
isSingleItem,
nonNullable,
getColumnExists,
findPreviousWord,
noCaseCompare,
correctQuerySyntax,
getColumnByName,
findFinalWord,
getAllCommands,
getExpressionType,
} from '../shared/helpers';
import { collectVariables, excludeVariablesFromCurrentCommand } from '../shared/variables';
import type { ESQLPolicy, ESQLRealField, ESQLVariable, ReferenceMaps } from '../validation/types';
import type { ESQLRealField, ESQLVariable, ReferenceMaps } from '../validation/types';
import {
allStarConstant,
commaCompleteItem,
@ -59,17 +54,13 @@ import {
pipeCompleteItem,
} from './complete_items';
import {
buildFieldsDefinitions,
buildPoliciesDefinitions,
getNewVariableSuggestion,
buildNoPoliciesAvailableDefinition,
getFunctionSuggestions,
buildMatchingFieldsDefinition,
getCompatibleLiterals,
buildConstantsDefinitions,
buildVariablesDefinitions,
buildOptionDefinition,
buildSettingDefinitions,
buildValueDefinitions,
getDateLiterals,
buildFieldsDefinitionsWithMetadata,
@ -99,37 +90,17 @@ import {
getSuggestionsToRightOfOperatorExpression,
checkFunctionInvocationComplete,
} from './helper';
import { FunctionParameter, isParameterType, FunctionDefinitionTypes } from '../definitions/types';
import {
FunctionParameter,
isParameterType,
FunctionDefinitionTypes,
GetPolicyMetadataFn,
} from '../definitions/types';
import { comparisonFunctions } from '../definitions/all_operators';
import { getRecommendedQueriesSuggestions } from './recommended_queries/suggestions';
type GetFieldsMapFn = () => Promise<Map<string, ESQLRealField>>;
type GetPoliciesFn = () => Promise<SuggestionRawDefinition[]>;
type GetPolicyMetadataFn = (name: string) => Promise<ESQLPolicy | undefined>;
function hasSameArgBothSides(assignFn: ESQLFunction) {
if (assignFn.name === '=' && isColumnItem(assignFn.args[0]) && assignFn.args[1]) {
const assignValue = assignFn.args[1];
if (Array.isArray(assignValue) && isColumnItem(assignValue[0])) {
return assignFn.args[0].name === assignValue[0].name;
}
}
}
function appendEnrichFields(
fieldsMap: Map<string, ESQLRealField>,
policyMetadata: ESQLPolicy | undefined
) {
if (!policyMetadata) {
return fieldsMap;
}
// @TODO: improve this
const newMap: Map<string, ESQLRealField> = new Map(fieldsMap);
for (const field of policyMetadata.enrichFields) {
newMap.set(field, { name: field, type: 'double' });
}
return newMap;
}
function getFinalSuggestions({ comma }: { comma?: boolean } = { comma: true }) {
const finalSuggestions = [pipeCompleteItem];
@ -205,7 +176,8 @@ export async function suggest(
astContext.type === 'expression' ||
(astContext.type === 'option' && astContext.command?.name === 'join') ||
(astContext.type === 'option' && astContext.command?.name === 'dissect') ||
(astContext.type === 'option' && astContext.command?.name === 'from')
(astContext.type === 'option' && astContext.command?.name === 'from') ||
(astContext.type === 'option' && astContext.command?.name === 'enrich')
) {
return getSuggestionsWithinCommandExpression(
innerText,
@ -215,22 +187,13 @@ export async function suggest(
getFieldsByType,
getFieldsMap,
getPolicies,
getPolicyMetadata,
getVariablesByType,
resourceRetriever?.getPreferences,
resourceRetriever,
supportsControls
);
}
if (astContext.type === 'setting') {
return getSettingArgsSuggestions(
innerText,
ast,
astContext,
getFieldsByType,
getFieldsMap,
getPolicyMetadata
);
}
if (astContext.type === 'option') {
// need this wrap/unwrap thing to make TS happy
const { option, ...rest } = astContext;
@ -391,6 +354,7 @@ async function getSuggestionsWithinCommandExpression(
getColumnsByType: GetColumnsByTypeFn,
getFieldsMap: GetFieldsMapFn,
getPolicies: GetPoliciesFn,
getPolicyMetadata: GetPolicyMetadataFn,
getVariablesByType?: (type: ESQLVariableType) => ESQLControlVariable[] | undefined,
getPreferences?: () => Promise<{ histogramBarTarget: number } | undefined>,
callbacks?: ESQLCallbacks,
@ -409,8 +373,19 @@ async function getSuggestionsWithinCommandExpression(
innerText,
command,
getColumnsByType,
getAllColumnNames: () => Array.from(fieldsMap.keys()),
columnExists: (col: string) => Boolean(getColumnByName(col, references)),
getSuggestedVariableName: () => findNewVariable(anyVariables),
getSuggestedVariableName: (extraFieldNames?: string[]) => {
if (!extraFieldNames?.length) {
return findNewVariable(anyVariables);
}
const augmentedFieldsMap = new Map(fieldsMap);
extraFieldNames.forEach((name) => {
augmentedFieldsMap.set(name, { name, type: 'double' });
});
return findNewVariable(collectVariables(commands, augmentedFieldsMap, innerText));
},
getExpressionType: (expression: ESQLAstItem | undefined) =>
getExpressionType(expression, references.fields, references.variables),
getPreferences,
@ -423,6 +398,8 @@ async function getSuggestionsWithinCommandExpression(
callbacks,
getVariablesByType,
supportsControls,
getPolicies,
getPolicyMetadata,
});
} else {
// The deprecated path.
@ -866,29 +843,10 @@ async function getExpressionSuggestionsByType(
}
}
}
if (argDef.type === 'source') {
if (argDef.innerTypes?.includes('policy')) {
// ... | ENRICH <suggest>
const policies = await getPolicies();
const lastWord = findFinalWord(innerText);
if (lastWord !== '') {
policies.forEach((suggestion) => {
suggestions.push({
...suggestion,
rangeToReplace: {
start: innerText.length - lastWord.length + 1,
end: innerText.length + 1,
},
});
});
}
suggestions.push(...(policies.length ? policies : [buildNoPoliciesAvailableDefinition()]));
}
}
}
const nonOptionArgs = command.args.filter(
(arg) => !isOptionItem(arg) && !isSettingItem(arg) && !Array.isArray(arg) && !arg.incomplete
(arg) => !isOptionItem(arg) && !Array.isArray(arg) && !arg.incomplete
);
// Perform some checks on mandatory arguments
const mandatoryArgsAlreadyPresent =
@ -1234,35 +1192,6 @@ async function getListArgsSuggestions(
return suggestions;
}
async function getSettingArgsSuggestions(
innerText: string,
commands: ESQLCommand[],
{
command,
node,
}: {
command: ESQLCommand;
node: ESQLSingleAstItem | undefined;
},
getFieldsByType: GetColumnsByTypeFn,
getFieldsMaps: GetFieldsMapFn,
getPolicyMetadata: GetPolicyMetadataFn
) {
const suggestions = [];
const settingDefs = getCommandDefinition(command.name).modes || [];
if (settingDefs.length) {
const lastChar = getLastNonWhitespaceChar(innerText);
const matchingSettingDefs = settingDefs.filter(({ prefix }) => lastChar === prefix);
if (matchingSettingDefs.length) {
// COMMAND _<here>
suggestions.push(...matchingSettingDefs.flatMap(buildSettingDefinitions));
}
}
return suggestions;
}
/**
* @deprecated this will disappear when https://github.com/elastic/kibana/issues/195418 is complete
* because "options" will be handled in imperative command-specific routines instead of being independent.
@ -1294,105 +1223,6 @@ async function getOptionArgsSuggestions(
const fieldsMap = await getFieldsMaps();
const anyVariables = collectVariables(commands, fieldsMap, innerText);
if (command.name === 'enrich') {
if (option.name === 'on') {
// if it's a new expression, suggest fields to match on
if (
isNewExpression ||
noCaseCompare(findPreviousWord(innerText), 'ON') ||
(option && isAssignment(option.args[0]) && !option.args[1])
) {
const policyName = isSourceItem(command.args[0]) ? command.args[0].name : undefined;
if (policyName) {
const policyMetadata = await getPolicyMetadata(policyName);
if (policyMetadata) {
suggestions.push(
...buildMatchingFieldsDefinition(
policyMetadata.matchField,
Array.from(fieldsMap.keys())
)
);
}
}
} else {
// propose the with option
suggestions.push(
buildOptionDefinition(getCommandOption('with')!),
...getFinalSuggestions({
comma: false,
})
);
}
}
if (option.name === 'with') {
const policyName = isSourceItem(command.args[0]) ? command.args[0].name : undefined;
if (policyName) {
const policyMetadata = await getPolicyMetadata(policyName);
const anyEnhancedVariables = collectVariables(
commands,
appendEnrichFields(fieldsMap, policyMetadata),
innerText
);
if (isNewExpression || noCaseCompare(findPreviousWord(innerText), 'WITH')) {
suggestions.push(getNewVariableSuggestion(findNewVariable(anyEnhancedVariables)));
}
// make sure to remove the marker arg from the assign fn
const assignFn = isAssignment(lastArg)
? (removeMarkerArgFromArgsList(lastArg) as ESQLFunction)
: undefined;
if (policyMetadata) {
if (isNewExpression || (assignFn && !isAssignmentComplete(assignFn))) {
// ... | ENRICH ... WITH a =
// ... | ENRICH ... WITH b
const fieldSuggestions = buildFieldsDefinitions(policyMetadata.enrichFields);
// in this case, we don't want to open the suggestions menu when the field is accepted
// because we're keeping the suggestions simple here for now. Could always revisit.
fieldSuggestions.forEach((s) => (s.command = undefined));
// attach the replacement range if needed
const lastWord = findFinalWord(innerText);
if (lastWord) {
// ENRICH ... WITH a <suggest>
const rangeToReplace = {
start: innerText.length - lastWord.length + 1,
end: innerText.length + 1,
};
fieldSuggestions.forEach((s) => (s.rangeToReplace = rangeToReplace));
}
suggestions.push(...fieldSuggestions);
}
}
if (
assignFn &&
hasSameArgBothSides(assignFn) &&
!isNewExpression &&
!isIncompleteItem(assignFn)
) {
// ... | ENRICH ... WITH a
// effectively only assign will apper
suggestions.push(
...pushItUpInTheList(getOperatorSuggestions({ command: command.name }), true)
);
}
if (
assignFn &&
(isAssignmentComplete(assignFn) || hasSameArgBothSides(assignFn)) &&
!isNewExpression
) {
suggestions.push(
...getFinalSuggestions({
comma: true,
})
);
}
}
}
}
if (command.name === 'rename') {
if (option.args.length < 2) {
suggestions.push(...buildVariablesDefinitions([findNewVariable(anyVariables)]));

View file

@ -0,0 +1,165 @@
/*
* 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 { ESQLSource } from '@kbn/esql-ast';
import {
findFinalWord,
findPreviousWord,
isSingleItem,
unescapeColumnName,
} from '../../../shared/helpers';
import { CommandSuggestParams } from '../../../definitions/types';
import type { SuggestionRawDefinition } from '../../types';
import {
Position,
buildMatchingFieldsDefinition,
getPosition,
modeSuggestions,
noPoliciesAvailableSuggestion,
onSuggestion,
withSuggestion,
} from './util';
import { commaCompleteItem, pipeCompleteItem } from '../../complete_items';
import {
TRIGGER_SUGGESTION_COMMAND,
buildFieldsDefinitions,
getNewVariableSuggestion,
getOperatorSuggestions,
} from '../../factories';
export async function suggest({
innerText,
command,
getPolicies,
getPolicyMetadata,
getAllColumnNames,
getSuggestedVariableName,
}: CommandSuggestParams<'enrich'>): Promise<SuggestionRawDefinition[]> {
const pos = getPosition(innerText, command);
const policyName = (
command.args.find((arg) => isSingleItem(arg) && arg.type === 'source') as ESQLSource | undefined
)?.name;
const getFieldSuggestionsForWithClause = async () => {
if (!policyName) {
return [];
}
const policyMetadata = await getPolicyMetadata(policyName);
if (!policyMetadata) {
return [];
}
const fieldSuggestions = buildFieldsDefinitions(policyMetadata.enrichFields, false);
const lastWord = findFinalWord(innerText);
if (lastWord) {
// ENRICH ... WITH a <suggest>
const rangeToReplace = {
start: innerText.length - lastWord.length + 1,
end: innerText.length + 1,
};
fieldSuggestions.forEach((s) => {
s.rangeToReplace = rangeToReplace;
});
}
return fieldSuggestions;
};
switch (pos) {
case Position.MODE:
return modeSuggestions;
case Position.POLICY: {
const policies = await getPolicies();
const lastWord = findFinalWord(innerText);
if (lastWord !== '') {
policies.forEach((policySuggestion) => {
policySuggestion.rangeToReplace = {
start: innerText.length - lastWord.length + 1,
end: innerText.length + 1,
};
});
}
return policies.length ? policies : [noPoliciesAvailableSuggestion];
}
case Position.AFTER_POLICY:
return [onSuggestion, withSuggestion, pipeCompleteItem];
case Position.MATCH_FIELD: {
if (!policyName) {
return [];
}
const policyMetadata = await getPolicyMetadata(policyName);
if (!policyMetadata) {
return [];
}
return buildMatchingFieldsDefinition(policyMetadata.matchField, getAllColumnNames());
}
case Position.AFTER_ON_CLAUSE:
return [withSuggestion, pipeCompleteItem];
case Position.WITH_NEW_CLAUSE: {
if (!policyName) {
return [];
}
const policyMetadata = await getPolicyMetadata(policyName);
if (!policyMetadata) {
return [];
}
const suggestions: SuggestionRawDefinition[] = [];
suggestions.push(
getNewVariableSuggestion(getSuggestedVariableName(policyMetadata.enrichFields))
);
suggestions.push(...(await getFieldSuggestionsForWithClause()));
return suggestions;
}
case Position.WITH_AFTER_FIRST_WORD: {
if (!policyName) {
return [];
}
const policyMetadata = await getPolicyMetadata(policyName);
if (!policyMetadata) {
return [];
}
const word = findPreviousWord(innerText);
if (policyMetadata.enrichFields.includes(unescapeColumnName(word))) {
// complete field name
return [pipeCompleteItem, { ...commaCompleteItem, command: TRIGGER_SUGGESTION_COMMAND }];
} else {
// not recognized as a field name, assume new user-defined column name
return getOperatorSuggestions({ command: 'enrich' });
}
}
case Position.WITH_AFTER_ASSIGNMENT: {
const suggestions: SuggestionRawDefinition[] = [];
suggestions.push(...(await getFieldSuggestionsForWithClause()));
return suggestions;
}
case Position.WITH_AFTER_COMPLETE_CLAUSE: {
return [pipeCompleteItem, { ...commaCompleteItem, command: TRIGGER_SUGGESTION_COMMAND }];
}
default:
return [];
}
}

View file

@ -0,0 +1,148 @@
/*
* 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 { ESQLCommand } from '@kbn/esql-ast';
import { i18n } from '@kbn/i18n';
import { isSingleItem } from '../../../..';
import { ENRICH_MODES } from '../../../definitions/settings';
import { SuggestionRawDefinition } from '../../types';
import { TRIGGER_SUGGESTION_COMMAND, getSafeInsertText } from '../../factories';
export enum Position {
MODE = 'mode',
POLICY = 'policy',
AFTER_POLICY = 'after_policy',
MATCH_FIELD = 'match_field',
AFTER_ON_CLAUSE = 'after_on_clause',
WITH_NEW_CLAUSE = 'with_new_clause',
WITH_AFTER_FIRST_WORD = 'with_after_first_word',
WITH_AFTER_ASSIGNMENT = 'with_after_assignment',
WITH_AFTER_COMPLETE_CLAUSE = 'with_after_complete_clause',
}
export const getPosition = (
innerText: string,
command: ESQLCommand<'enrich'>
): Position | undefined => {
if (command.args.length < 2) {
if (innerText.match(/_[^:\s]*$/)) {
return Position.MODE;
}
if (innerText.match(/(:|ENRICH\s+)\S*$/i)) {
return Position.POLICY;
}
if (innerText.match(/:\s+$/)) {
return undefined;
}
if (innerText.match(/\s+\S*$/)) {
return Position.AFTER_POLICY;
}
}
const lastArg = command.args[command.args.length - 1];
if (isSingleItem(lastArg) && lastArg.name === 'on') {
if (innerText.match(/on\s+\S*$/i)) {
return Position.MATCH_FIELD;
}
if (innerText.match(/on\s+\S+\s+$/i)) {
return Position.AFTER_ON_CLAUSE;
}
}
if (isSingleItem(lastArg) && lastArg.name === 'with') {
if (innerText.match(/[,|with]\s+\S*$/i)) {
return Position.WITH_NEW_CLAUSE;
}
if (innerText.match(/[,|with]\s+\S+\s*=\s*\S+\s+$/i)) {
return Position.WITH_AFTER_COMPLETE_CLAUSE;
}
if (innerText.match(/[,|with]\s+\S+\s+$/i)) {
return Position.WITH_AFTER_FIRST_WORD;
}
if (innerText.match(/=\s+[^,\s]*$/i)) {
return Position.WITH_AFTER_ASSIGNMENT;
}
}
};
export const noPoliciesAvailableSuggestion: SuggestionRawDefinition = {
label: i18n.translate('kbn-esql-validation-autocomplete.esql.autocomplete.noPoliciesLabel', {
defaultMessage: 'No available policy',
}),
text: '',
kind: 'Issue',
detail: i18n.translate(
'kbn-esql-validation-autocomplete.esql.autocomplete.noPoliciesLabelsFound',
{
defaultMessage: 'Click to create',
}
),
sortText: 'D',
command: {
id: 'esql.policies.create',
title: i18n.translate('kbn-esql-validation-autocomplete.esql.autocomplete.createNewPolicy', {
defaultMessage: 'Click to create',
}),
},
};
export const modeSuggestions: SuggestionRawDefinition[] = ENRICH_MODES.values.map(
({ name, description }) => ({
label: `${ENRICH_MODES.prefix || ''}${name}`,
text: `${ENRICH_MODES.prefix || ''}${name}:$0`,
asSnippet: true,
kind: 'Reference',
detail: `${ENRICH_MODES.description} - ${description}`,
sortText: 'D',
command: TRIGGER_SUGGESTION_COMMAND,
})
);
export const onSuggestion: SuggestionRawDefinition = {
label: 'ON',
text: 'ON ',
kind: 'Reference',
detail: i18n.translate('kbn-esql-validation-autocomplete.esql.definitions.onDoc', {
defaultMessage: 'On',
}),
sortText: '1',
command: TRIGGER_SUGGESTION_COMMAND,
};
export const withSuggestion: SuggestionRawDefinition = {
label: 'WITH',
text: 'WITH ',
kind: 'Reference',
detail: i18n.translate('kbn-esql-validation-autocomplete.esql.definitions.withDoc', {
defaultMessage: 'With',
}),
sortText: '1',
command: TRIGGER_SUGGESTION_COMMAND,
};
export const buildMatchingFieldsDefinition = (
matchingField: string,
fields: string[]
): SuggestionRawDefinition[] =>
fields.map((label) => ({
label,
text: getSafeInsertText(label) + ' ',
kind: 'Variable',
detail: i18n.translate(
'kbn-esql-validation-autocomplete.esql.autocomplete.matchingFieldDefinition',
{
defaultMessage: `Use to match on {matchingField} on the policy`,
values: {
matchingField,
},
}
),
sortText: 'D',
command: TRIGGER_SUGGESTION_COMMAND,
}));

View file

@ -102,7 +102,6 @@ export const suggest: CommandBaseDefinition<'join'>['suggest'] = async ({
getColumnsByType,
definition,
callbacks,
previousCommands,
}: CommandSuggestParams<'join'>): Promise<SuggestionRawDefinition[]> => {
let commandText: string = innerText;

View file

@ -19,7 +19,6 @@ import { timeUnitsToSuggest } from '../definitions/literals';
import {
FunctionDefinition,
CommandOptionsDefinition,
CommandModeDefinition,
FunctionParameterType,
FunctionDefinitionTypes,
} from '../definitions/types';
@ -245,7 +244,10 @@ export const buildFieldsDefinitionsWithMetadata = (
return [...suggestions];
};
export const buildFieldsDefinitions = (fields: string[]): SuggestionRawDefinition[] => {
export const buildFieldsDefinitions = (
fields: string[],
openSuggestions = true
): SuggestionRawDefinition[] => {
return fields.map((label) => ({
label,
text: getSafeInsertText(label),
@ -254,7 +256,7 @@ export const buildFieldsDefinitions = (fields: string[]): SuggestionRawDefinitio
defaultMessage: `Field specified by the input table`,
}),
sortText: 'D',
command: TRIGGER_SUGGESTION_COMMAND,
command: openSuggestions ? TRIGGER_SUGGESTION_COMMAND : undefined,
}));
};
export const buildVariablesDefinitions = (variables: string[]): SuggestionRawDefinition[] =>
@ -365,27 +367,7 @@ export const buildPoliciesDefinitions = (
command: TRIGGER_SUGGESTION_COMMAND,
}));
export const buildMatchingFieldsDefinition = (
matchingField: string,
fields: string[]
): SuggestionRawDefinition[] =>
fields.map((label) => ({
label,
text: getSafeInsertText(label) + ' ',
kind: 'Variable',
detail: i18n.translate(
'kbn-esql-validation-autocomplete.esql.autocomplete.matchingFieldDefinition',
{
defaultMessage: `Use to match on {matchingField} on the policy`,
values: {
matchingField,
},
}
),
sortText: 'D',
command: TRIGGER_SUGGESTION_COMMAND,
}));
/** @deprecated — options will be removed */
export const buildOptionDefinition = (
option: CommandOptionsDefinition,
isAssignType: boolean = false
@ -407,42 +389,6 @@ export const buildOptionDefinition = (
return completeItem;
};
export const buildSettingDefinitions = (
setting: CommandModeDefinition
): SuggestionRawDefinition[] => {
// for now there's just a single setting with one argument
return setting.values.map(({ name, description }) => ({
label: `${setting.prefix || ''}${name}`,
text: `${setting.prefix || ''}${name}:$0`,
asSnippet: true,
kind: 'Reference',
detail: description ? `${setting.description} - ${description}` : setting.description,
sortText: 'D',
command: TRIGGER_SUGGESTION_COMMAND,
}));
};
export const buildNoPoliciesAvailableDefinition = (): SuggestionRawDefinition => ({
label: i18n.translate('kbn-esql-validation-autocomplete.esql.autocomplete.noPoliciesLabel', {
defaultMessage: 'No available policy',
}),
text: '',
kind: 'Issue',
detail: i18n.translate(
'kbn-esql-validation-autocomplete.esql.autocomplete.noPoliciesLabelsFound',
{
defaultMessage: 'Click to create',
}
),
sortText: 'D',
command: {
id: 'esql.policies.create',
title: i18n.translate('kbn-esql-validation-autocomplete.esql.autocomplete.createNewPolicy', {
defaultMessage: 'Click to create',
}),
},
});
export function getUnitDuration(unit: number = 1) {
const filteredTimeLiteral = timeUnitsToSuggest.filter(({ name }) => {
const result = /s$/.test(name);

View file

@ -27,7 +27,6 @@ import {
isFunctionOperatorParam,
isLiteralItem,
} from '../shared/helpers';
import { ENRICH_MODES } from './settings';
import {
appendSeparatorOption,
asOption,
@ -36,6 +35,8 @@ import {
onOption,
withOption,
} from './options';
import { ENRICH_MODES } from './settings';
import { type CommandDefinition, FunctionDefinitionTypes } from './types';
import { suggest as suggestForSort } from '../autocomplete/commands/sort';
import { suggest as suggestForKeep } from '../autocomplete/commands/keep';
@ -48,6 +49,7 @@ 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';
import { suggest as suggestForEnrich } from '../autocomplete/commands/enrich';
const statsValidator = (command: ESQLCommand) => {
const messages: ESQLMessage[] = [];
@ -521,14 +523,15 @@ export const commandDefinitions: Array<CommandDefinition<any>> = [
multipleParams: false,
params: [{ name: 'policyName', type: 'source', innerTypes: ['policy'] }],
},
suggest: suggestForEnrich,
},
{
name: 'hidden_command',
description: 'A test fixture to test hidden-ness',
hidden: true,
examples: [],
modes: [],
options: [],
modes: [],
signature: {
params: [],
multipleParams: false,

View file

@ -13,6 +13,7 @@ import { isLiteralItem, isColumnItem, isInlineCastItem } from '../shared/helpers
import { getMessageFromId } from '../validation/errors';
import type { CommandOptionsDefinition } from './types';
/** @deprecated — options are going away */
export const byOption: CommandOptionsDefinition = {
name: 'by',
description: i18n.translate('kbn-esql-validation-autocomplete.esql.definitions.byDoc', {
@ -25,6 +26,7 @@ export const byOption: CommandOptionsDefinition = {
optional: true,
};
/** @deprecated — options are going away */
export const metadataOption: CommandOptionsDefinition = {
name: 'metadata',
description: i18n.translate('kbn-esql-validation-autocomplete.esql.definitions.metadataDoc', {
@ -60,6 +62,7 @@ export const metadataOption: CommandOptionsDefinition = {
},
};
/** @deprecated — options are going away */
export const asOption: CommandOptionsDefinition = {
name: 'as',
description: i18n.translate('kbn-esql-validation-autocomplete.esql.definitions.asDoc', {
@ -75,6 +78,7 @@ export const asOption: CommandOptionsDefinition = {
optional: false,
};
/** @deprecated — options are going away */
export const onOption: CommandOptionsDefinition = {
name: 'on',
description: i18n.translate('kbn-esql-validation-autocomplete.esql.definitions.onDoc', {
@ -87,6 +91,7 @@ export const onOption: CommandOptionsDefinition = {
optional: true,
};
/** @deprecated — options are going away */
export const withOption: CommandOptionsDefinition = {
name: 'with',
description: i18n.translate('kbn-esql-validation-autocomplete.esql.definitions.withDoc', {
@ -99,6 +104,7 @@ export const withOption: CommandOptionsDefinition = {
optional: true,
};
/** @deprecated — options are going away */
export const appendSeparatorOption: CommandOptionsDefinition = {
name: 'append_separator',
description: i18n.translate(

View file

@ -6,7 +6,6 @@
* 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 { ESQLVariableType, ESQLControlVariable } from '@kbn/esql-types';
import type {
ESQLAstItem,
ESQLCommand,
@ -15,8 +14,10 @@ import type {
ESQLMessage,
ESQLSource,
} from '@kbn/esql-ast';
import { ESQLControlVariable, ESQLVariableType } from '@kbn/esql-types';
import { GetColumnsByTypeFn, SuggestionRawDefinition } from '../autocomplete/types';
import type { ESQLCallbacks, ESQLSourceResult } from '../shared/types';
import type { ESQLPolicy } from '../validation/types';
import { ESQLCallbacks, ESQLSourceResult } from '../shared/types';
/**
* All supported field types in ES|QL. This is all the types
@ -192,6 +193,8 @@ export interface FunctionDefinition {
customParametersSnippet?: string;
}
export type GetPolicyMetadataFn = (name: string) => Promise<ESQLPolicy | undefined>;
export interface CommandSuggestParams<CommandName extends string> {
/**
* The text of the query to the left of the cursor.
@ -202,10 +205,14 @@ export interface CommandSuggestParams<CommandName extends string> {
*/
command: ESQLCommand<CommandName>;
/**
* Get a list of columns by type. This includes fields from any sources as well as
* variables defined in the query.
* Get suggestions for columns by type. This includes fields from any sources as well as
* user-defined columns in the query.
*/
getColumnsByType: GetColumnsByTypeFn;
/**
* Gets the names of all columns
*/
getAllColumnNames: () => string[];
/**
* Check for the existence of a column by name.
* @param column
@ -214,9 +221,13 @@ export interface CommandSuggestParams<CommandName extends string> {
columnExists: (column: string) => boolean;
/**
* Gets the name that should be used for the next variable.
*
* @param extraFieldNames names that should be recognized as columns
* but that won't be found in the current table from Elasticsearch. This is currently only
* used to recognize enrichment fields from a policy in the ENRICH command.
* @returns
*/
getSuggestedVariableName: () => string;
getSuggestedVariableName: (extraFieldNames?: string[]) => string;
/**
* Examine the AST to determine the type of an expression.
* @param expression
@ -237,6 +248,14 @@ export interface CommandSuggestParams<CommandName extends string> {
* @returns
*/
getSources: () => Promise<ESQLSourceResult[]>;
/**
* Fetch suggestions for all available policies
*/
getPolicies: () => Promise<SuggestionRawDefinition[]>;
/**
* Get metadata for a policy by name
*/
getPolicyMetadata: GetPolicyMetadataFn;
/**
* Inspect the AST and returns the sources that are used in the query.
* @param type

View file

@ -18,14 +18,12 @@ import {
Walker,
isIdentifier,
} from '@kbn/esql-ast';
import { ENRICH_MODES } from '../definitions/settings';
import { FunctionDefinitionTypes } from '../definitions/types';
import { EDITOR_MARKER } from './constants';
import {
isOptionItem,
isColumnItem,
isSourceItem,
isSettingItem,
pipePrecedesCurrentWord,
getFunctionDefinition,
} from './helpers';
@ -66,10 +64,6 @@ function findOption(nodes: ESQLAstItem[], offset: number): ESQLCommandOption | u
return findCommandSubType(nodes, offset, isOptionItem);
}
function findSetting(nodes: ESQLAstItem[], offset: number): ESQLCommandMode | undefined {
return findCommandSubType(nodes, offset, isSettingItem);
}
function findCommandSubType<T extends ESQLCommandMode | ESQLCommandOption>(
nodes: ESQLAstItem[],
offset: number,
@ -131,7 +125,6 @@ function findAstPosition(ast: ESQLAst, offset: number) {
command: removeMarkerArgFromArgsList(command)!,
option: removeMarkerArgFromArgsList(findOption(command.args, offset)),
node: removeMarkerArgFromArgsList(cleanMarkerNode(findNode(command.args, offset))),
setting: removeMarkerArgFromArgsList(findSetting(command.args, offset)),
};
}
@ -171,16 +164,16 @@ export function getAstContext(queryString: string, ast: ESQLAst, offset: number)
};
}
const { command, option, setting, node } = findAstPosition(ast, offset);
const { command, option, node } = findAstPosition(ast, offset);
if (node) {
if (node.type === 'literal' && node.literalType === 'keyword') {
// command ... "<here>"
return { type: 'value' as const, command, node, option, setting };
return { type: 'value' as const, command, node, option };
}
if (node.type === 'function') {
if (['in', 'not_in'].includes(node.name) && Array.isArray(node.args[1])) {
// command ... a in ( <here> )
return { type: 'list' as const, command, node, option, setting };
return { type: 'list' as const, command, node, option };
}
if (
isNotEnrichClauseAssigment(node, command) &&
@ -191,24 +184,19 @@ export function getAstContext(queryString: string, ast: ESQLAst, offset: number)
!(isOperator(node) && command.name !== 'stats')
) {
// command ... fn( <here> )
return { type: 'function' as const, command, node, option, setting };
return { type: 'function' as const, command, node, option };
}
}
// for now it's only an enrich thing
if (node.type === 'source' && node.text === ENRICH_MODES.prefix) {
// command _<here>
return { type: 'setting' as const, command, node, option, setting };
}
}
if (!command || (queryString.length <= offset && pipePrecedesCurrentWord(queryString))) {
// // ... | <here>
return { type: 'newCommand' as const, command: undefined, node, option, setting };
return { type: 'newCommand' as const, command: undefined, node, option };
}
// TODO — remove this option branch once https://github.com/elastic/kibana/issues/195418 is complete
if (command && isOptionItem(command.args[command.args.length - 1]) && command.name !== 'stats') {
if (option) {
return { type: 'option' as const, command, node, option, setting };
return { type: 'option' as const, command, node, option };
}
}
@ -218,6 +206,5 @@ export function getAstContext(queryString: string, ast: ESQLAst, offset: number)
command,
option,
node,
setting,
};
}

View file

@ -11,7 +11,6 @@ import {
Walker,
type ESQLAstItem,
type ESQLColumn,
type ESQLCommandMode,
type ESQLCommandOption,
type ESQLFunction,
type ESQLLiteral,
@ -20,6 +19,7 @@ import {
type ESQLTimeInterval,
} from '@kbn/esql-ast';
import {
ESQLCommandMode,
ESQLIdentifier,
ESQLInlineCast,
ESQLParamLiteral,
@ -66,6 +66,7 @@ export function isSingleItem(arg: ESQLAstItem): arg is ESQLSingleAstItem {
return arg && !Array.isArray(arg);
}
/** @deprecated — a "setting" is a concept we will be getting rid of soon */
export function isSettingItem(arg: ESQLAstItem): arg is ESQLCommandMode {
return isSingleItem(arg) && arg.type === 'mode';
}
@ -268,6 +269,18 @@ export function getColumnForASTNode(
return getColumnByName(formatted, { fields, variables });
}
/**
* Take a column name like "`my``column`"" and return "my`column"
*/
export function unescapeColumnName(columnName: string) {
// TODO this doesn't cover all escaping scenarios... the best thing to do would be
// to use the AST column node parts array, but in some cases the AST node isn't available.
if (columnName.startsWith(SINGLE_BACKTICK) && columnName.endsWith(SINGLE_BACKTICK)) {
return columnName.slice(1, -1).replace(DOUBLE_TICKS_REGEX, SINGLE_BACKTICK);
}
return columnName;
}
/**
* This function returns the variable or field matching a column
*/
@ -275,12 +288,8 @@ export function getColumnByName(
columnName: string,
{ fields, variables }: Pick<ReferenceMaps, 'fields' | 'variables'>
): ESQLRealField | ESQLVariable | undefined {
// TODO this doesn't cover all escaping scenarios... the best thing to do would be
// to use the AST column node parts array, but in some cases the AST node isn't available.
if (columnName.startsWith(SINGLE_BACKTICK) && columnName.endsWith(SINGLE_BACKTICK)) {
columnName = columnName.slice(1, -1).replace(DOUBLE_TICKS_REGEX, SINGLE_BACKTICK);
}
return fields.get(columnName) || variables.get(columnName)?.[0];
const unescaped = unescapeColumnName(columnName);
return fields.get(unescaped) || variables.get(unescaped)?.[0];
}
const ARRAY_REGEXP = /\[\]$/;

View file

@ -710,6 +710,7 @@ function validateFunction({
return uniqBy(messages, ({ location }) => `${location.min}-${location.max}`);
}
/** @deprecated — "command settings" will be removed soon */
function validateSetting(
setting: ESQLCommandMode,
settingDef: CommandModeDefinition | undefined,