mirror of
https://github.com/elastic/kibana.git
synced 2025-04-23 09:19:04 -04:00
[ES|QL] Separate EVAL
autocomplete routine (#212996)
## Summary Part of https://github.com/elastic/kibana/issues/195418 Gives `EVAL` autocomplete logic its own home 🏡 ### Expression suggestions function This PR also introduces a semi-generic function for generating suggestions within an expression. This is so that the logic can be shared between `EVAL` and `WHERE`. It also gets us closer to supporting filtering in `STATS` (https://github.com/elastic/kibana/issues/195363). To make this happen, I took stock of where we have differences in our expression suggestions between `WHERE` and `EVAL`. In some cases, the differences seemed important. In other cases, I felt ok removing them. #### EVAL | Behavior | Plan | |--------------------------------------------------------------------------|------| | Suggests pipe and comma after complete column names (`column/` or `column /`)| get rid of it because an expression consisting of just a single column name is essentially useless in `EVAL` | | Doesn't suggest fields after an assignment | get rid of it. why act any different than an expression not assigned an alias? | | Suggests assignment operator after new column name (`newColumn /`) | keep it | | Suggests assignment snippet for empty expression | keep it | | Suggests time literal completions after literal number in assignment (`newColumn = 1 /`) | remove it. it doesn't feel that useful and removing it makes it easier to have a generic expression suggestions function. It will still be around in functions and operators (e.g. `1 day + 2 /`). | | Supports multiple expressions | keep it | #### WHERE | Behavior | Plan | |--------------------------------------------------------------------------|------| | Suggests pipe after complete boolean expression (`foo AND bar /`) | keep it, but outside of the expression suggestion function | | Suggests boolean operators to make a boolean expression (`timestamp > "2002" AND doubleField /`) | keep it... maybe we're being too smart but we can always remove it later | ### Other changes - the suggestions for `CASE(foo != /)` used to differ based on the trigger kind. This seemed inadvertent so I removed the difference. - we now add spaces after fields that are inserted in expressions. E.g. `WHERE foo + <insert field><space>`. I'm not sure if this is best or not... ### 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> Co-authored-by: Elastic Machine <elasticmachine@users.noreply.github.com>
This commit is contained in:
parent
e84f6de3f6
commit
74c31fbc86
16 changed files with 751 additions and 1101 deletions
|
@ -15,6 +15,8 @@ import {
|
|||
getLiteralsByType,
|
||||
PartialSuggestionWithText,
|
||||
getDateLiteralsByFieldType,
|
||||
AssertSuggestionsFn,
|
||||
fields,
|
||||
} from './helpers';
|
||||
import { ESQL_COMMON_NUMERIC_TYPES } from '../../shared/esql_types';
|
||||
import { scalarFunctionDefinitions } from '../../definitions/generated/scalar_functions';
|
||||
|
@ -45,37 +47,124 @@ const getTypesFromParamDefs = (paramDefs: FunctionParameter[]): SupportedDataTyp
|
|||
|
||||
describe('autocomplete.suggest', () => {
|
||||
describe('eval', () => {
|
||||
test('suggestions', async () => {
|
||||
const { assertSuggestions } = await setup();
|
||||
let assertSuggestions: AssertSuggestionsFn;
|
||||
|
||||
beforeEach(async () => {
|
||||
const setupResult = await setup();
|
||||
assertSuggestions = setupResult.assertSuggestions;
|
||||
});
|
||||
|
||||
test('empty expression', async () => {
|
||||
await assertSuggestions('from a | eval /', [
|
||||
'var0 = ',
|
||||
...getFieldNamesByType('any'),
|
||||
...getFieldNamesByType('any').map((v) => `${v} `),
|
||||
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
|
||||
]);
|
||||
|
||||
await assertSuggestions('from a | eval doubleField/', [
|
||||
'doubleField, ',
|
||||
'doubleField | ',
|
||||
'var0 = ',
|
||||
await assertSuggestions('from a | eval col0 = /', [
|
||||
...getFieldNamesByType('any').map((v) => `${v} `),
|
||||
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
|
||||
]);
|
||||
|
||||
await assertSuggestions('from a | eval col0 = 1, /', [
|
||||
'var0 = ',
|
||||
...getFieldNamesByType('any').map((v) => `${v} `),
|
||||
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
|
||||
]);
|
||||
|
||||
await assertSuggestions('from a | eval col0 = 1, col1 = /', [
|
||||
...getFieldNamesByType('any').map((v) => `${v} `),
|
||||
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
|
||||
]);
|
||||
|
||||
// Re-enable with https://github.com/elastic/kibana/issues/210639
|
||||
// await assertSuggestions('from a | eval a=doubleField, /', [
|
||||
// 'var0 = ',
|
||||
// ...getFieldNamesByType('any').map((v) => `${v} `),
|
||||
// 'a',
|
||||
// ...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
|
||||
// ]);
|
||||
|
||||
await assertSuggestions(
|
||||
'from a | stats avg(doubleField) by keywordField | eval /',
|
||||
[
|
||||
'var0 = ',
|
||||
'`avg(doubleField)` ',
|
||||
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
|
||||
],
|
||||
{
|
||||
triggerCharacter: ' ',
|
||||
// make aware EVAL of the previous STATS command
|
||||
callbacks: createCustomCallbackMocks(
|
||||
[{ name: 'avg(doubleField)', type: 'double' }],
|
||||
undefined,
|
||||
undefined
|
||||
),
|
||||
}
|
||||
);
|
||||
await assertSuggestions(
|
||||
'from a | eval abs(doubleField) + 1 | eval /',
|
||||
[
|
||||
'var0 = ',
|
||||
...getFieldNamesByType('any').map((v) => `${v} `),
|
||||
'`abs(doubleField) + 1` ',
|
||||
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
|
||||
],
|
||||
{
|
||||
triggerCharacter: ' ',
|
||||
callbacks: createCustomCallbackMocks(
|
||||
[...fields, { name: 'abs(doubleField) + 1', type: 'double' }],
|
||||
undefined,
|
||||
undefined
|
||||
),
|
||||
}
|
||||
);
|
||||
await assertSuggestions(
|
||||
'from a | stats avg(doubleField) by keywordField | eval /',
|
||||
[
|
||||
'var0 = ',
|
||||
'`avg(doubleField)` ',
|
||||
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
|
||||
],
|
||||
{
|
||||
triggerCharacter: ' ',
|
||||
callbacks: createCustomCallbackMocks(
|
||||
[{ name: 'avg(doubleField)', type: 'double' }],
|
||||
undefined,
|
||||
undefined
|
||||
),
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('after column', async () => {
|
||||
await assertSuggestions('from a | eval doubleField /', [
|
||||
...getFunctionSignaturesByReturnType('eval', 'any', { operators: true, skipAssign: true }, [
|
||||
'double',
|
||||
]),
|
||||
',',
|
||||
'| ',
|
||||
]);
|
||||
});
|
||||
|
||||
test('after NOT', async () => {
|
||||
await assertSuggestions('from index | EVAL keywordField not /', [
|
||||
'LIKE $0',
|
||||
'RLIKE $0',
|
||||
'IN $0',
|
||||
]);
|
||||
|
||||
await assertSuggestions('from index | EVAL keywordField NOT /', [
|
||||
'LIKE $0',
|
||||
'RLIKE $0',
|
||||
'IN $0',
|
||||
]);
|
||||
|
||||
await assertSuggestions('from index | EVAL not /', [
|
||||
...getFieldNamesByType('boolean').map((v) => `${v} `),
|
||||
...getFunctionSignaturesByReturnType('eval', 'boolean', { scalar: true }),
|
||||
]);
|
||||
});
|
||||
|
||||
test('with lists', async () => {
|
||||
await assertSuggestions('from index | EVAL doubleField in /', ['( $0 )']);
|
||||
await assertSuggestions(
|
||||
'from index | EVAL doubleField in (/)',
|
||||
|
@ -86,26 +175,28 @@ describe('autocomplete.suggest', () => {
|
|||
{ triggerCharacter: '(' }
|
||||
);
|
||||
await assertSuggestions('from index | EVAL doubleField not in /', ['( $0 )']);
|
||||
await assertSuggestions('from index | EVAL not /', [
|
||||
...getFieldNamesByType('boolean'),
|
||||
...getFunctionSignaturesByReturnType('eval', 'boolean', { scalar: true }),
|
||||
]);
|
||||
});
|
||||
|
||||
test('after assignment', async () => {
|
||||
await assertSuggestions(
|
||||
'from a | eval a=/',
|
||||
[...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true })],
|
||||
[
|
||||
...getFieldNamesByType('any').map((v) => `${v} `),
|
||||
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
|
||||
],
|
||||
{ triggerCharacter: '=' }
|
||||
);
|
||||
await assertSuggestions(
|
||||
'from a | eval a=abs(doubleField), b= /',
|
||||
[...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true })],
|
||||
[
|
||||
...getFieldNamesByType('any').map((v) => `${v} `),
|
||||
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
|
||||
],
|
||||
{ triggerCharacter: '=' }
|
||||
);
|
||||
await assertSuggestions('from a | eval a=doubleField, /', [
|
||||
'var0 = ',
|
||||
...getFieldNamesByType('any'),
|
||||
'a',
|
||||
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
|
||||
]);
|
||||
});
|
||||
|
||||
test('in and around functions', async () => {
|
||||
await assertSuggestions(
|
||||
'from a | eval a=round(/)',
|
||||
[
|
||||
|
@ -144,7 +235,7 @@ describe('autocomplete.suggest', () => {
|
|||
{ triggerCharacter: '(' }
|
||||
);
|
||||
await assertSuggestions('from a | eval a=round(doubleField) /', [
|
||||
',',
|
||||
', ',
|
||||
'| ',
|
||||
...getFunctionSignaturesByReturnType('eval', 'any', { operators: true, skipAssign: true }, [
|
||||
'double',
|
||||
|
@ -184,8 +275,9 @@ describe('autocomplete.suggest', () => {
|
|||
);
|
||||
await assertSuggestions('from a | eval a=round(doubleField),/', [
|
||||
'var0 = ',
|
||||
...getFieldNamesByType('any'),
|
||||
'a',
|
||||
...getFieldNamesByType('any').map((v) => `${v} `),
|
||||
// Re-enable with https://github.com/elastic/kibana/issues/210639
|
||||
// 'a',
|
||||
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
|
||||
]);
|
||||
await assertSuggestions('from a | eval a=round(doubleField) + /', [
|
||||
|
@ -212,64 +304,6 @@ describe('autocomplete.suggest', () => {
|
|||
scalar: true,
|
||||
}),
|
||||
]);
|
||||
await assertSuggestions(
|
||||
'from a | stats avg(doubleField) by keywordField | eval /',
|
||||
[
|
||||
'var0 = ',
|
||||
'`avg(doubleField)`',
|
||||
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
|
||||
],
|
||||
{
|
||||
triggerCharacter: ' ',
|
||||
// make aware EVAL of the previous STATS command
|
||||
callbacks: createCustomCallbackMocks([], undefined, undefined),
|
||||
}
|
||||
);
|
||||
await assertSuggestions(
|
||||
'from a | eval abs(doubleField) + 1 | eval /',
|
||||
[
|
||||
'var0 = ',
|
||||
...getFieldNamesByType('any'),
|
||||
'`abs(doubleField) + 1`',
|
||||
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
|
||||
],
|
||||
{ triggerCharacter: ' ' }
|
||||
);
|
||||
await assertSuggestions(
|
||||
'from a | stats avg(doubleField) by keywordField | eval /',
|
||||
[
|
||||
'var0 = ',
|
||||
'`avg(doubleField)`',
|
||||
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
|
||||
],
|
||||
{
|
||||
triggerCharacter: ' ',
|
||||
callbacks: createCustomCallbackMocks(
|
||||
[{ name: 'avg_doubleField_', type: 'double' }],
|
||||
undefined,
|
||||
undefined
|
||||
),
|
||||
}
|
||||
// make aware EVAL of the previous STATS command with the buggy field name from expression
|
||||
);
|
||||
await assertSuggestions(
|
||||
'from a | stats avg(doubleField), avg(kubernetes.something.something) by keywordField | eval /',
|
||||
[
|
||||
'var0 = ',
|
||||
'`avg(doubleField)`',
|
||||
'`avg(kubernetes.something.something)`',
|
||||
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
|
||||
],
|
||||
{
|
||||
triggerCharacter: ' ',
|
||||
// make aware EVAL of the previous STATS command with the buggy field name from expression
|
||||
callbacks: createCustomCallbackMocks(
|
||||
[{ name: 'avg_doubleField_', type: 'double' }],
|
||||
undefined,
|
||||
undefined
|
||||
),
|
||||
}
|
||||
);
|
||||
|
||||
await assertSuggestions(
|
||||
'from a | eval a=round(doubleField), b=round(/)',
|
||||
|
@ -335,11 +369,9 @@ describe('autocomplete.suggest', () => {
|
|||
],
|
||||
{ triggerCharacter: ' ' }
|
||||
);
|
||||
// test deep function nesting suggestions (and check that the same function is not suggested)
|
||||
// round(round(
|
||||
// round(round(round(
|
||||
// etc...
|
||||
});
|
||||
|
||||
test('deep function nesting', async () => {
|
||||
for (const nesting of [1, 2, 3, 4]) {
|
||||
await assertSuggestions(
|
||||
`from a | eval a=${Array(nesting).fill('round(/').join('')}`,
|
||||
|
@ -356,16 +388,18 @@ describe('autocomplete.suggest', () => {
|
|||
{ triggerCharacter: '(' }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test('discards query after cursor', async () => {
|
||||
const absParameterTypes = ['double', 'integer', 'long', 'unsigned_long'] as const;
|
||||
|
||||
// Smoke testing for suggestions in previous position than the end of the statement
|
||||
await assertSuggestions('from a | eval var0 = abs(doubleField) / | eval abs(var0)', [
|
||||
',',
|
||||
'| ',
|
||||
...getFunctionSignaturesByReturnType('eval', 'any', { operators: true, skipAssign: true }, [
|
||||
'double',
|
||||
]),
|
||||
', ',
|
||||
'| ',
|
||||
]);
|
||||
await assertSuggestions('from a | eval var0 = abs(b/) | eval abs(var0)', [
|
||||
...getFieldNamesByType(absParameterTypes),
|
||||
|
@ -398,9 +432,6 @@ describe('autocomplete.suggest', () => {
|
|||
) {
|
||||
test(`${fn.name}`, async () => {
|
||||
const testedCases = new Set<string>();
|
||||
|
||||
const { assertSuggestions } = await setup();
|
||||
|
||||
for (const signature of fn.signatures) {
|
||||
// @ts-expect-error Partial type
|
||||
const enrichedArgs: Array<
|
||||
|
@ -517,7 +548,6 @@ describe('autocomplete.suggest', () => {
|
|||
// but currently, our autocomplete only suggests the literal suggestions
|
||||
if (['date_extract', 'date_diff'].includes(fn.name)) {
|
||||
test(`${fn.name}`, async () => {
|
||||
const { assertSuggestions } = await setup();
|
||||
const firstParam = fn.signatures[0].params[0];
|
||||
const suggestedConstants = firstParam?.literalSuggestions || firstParam?.acceptedValues;
|
||||
const requiresMoreArgs = true;
|
||||
|
@ -538,7 +568,6 @@ describe('autocomplete.suggest', () => {
|
|||
});
|
||||
|
||||
test('date math', async () => {
|
||||
const { assertSuggestions } = await setup();
|
||||
const dateSuggestions = timeUnitsToSuggest.map(({ name }) => name);
|
||||
|
||||
// Eval bucket is not a valid expression
|
||||
|
@ -546,12 +575,10 @@ describe('autocomplete.suggest', () => {
|
|||
triggerCharacter: ' ',
|
||||
});
|
||||
|
||||
// If a literal number is detected then suggest also date period keywords
|
||||
await assertSuggestions(
|
||||
'from a | eval a = 1 /',
|
||||
[
|
||||
...dateSuggestions,
|
||||
',',
|
||||
', ',
|
||||
'| ',
|
||||
...getFunctionSignaturesByReturnType(
|
||||
'eval',
|
||||
|
@ -562,7 +589,14 @@ describe('autocomplete.suggest', () => {
|
|||
],
|
||||
{ triggerCharacter: ' ' }
|
||||
);
|
||||
await assertSuggestions('from a | eval a = 1 year /', [',', '| ', 'IS NOT NULL', 'IS NULL']);
|
||||
await assertSuggestions('from a | eval a = 1 year /', [
|
||||
', ',
|
||||
'| ',
|
||||
'+ $0',
|
||||
'- $0',
|
||||
'IS NOT NULL',
|
||||
'IS NULL',
|
||||
]);
|
||||
await assertSuggestions(
|
||||
'from a | eval var0=date_trunc(/)',
|
||||
[
|
||||
|
@ -579,108 +613,5 @@ describe('autocomplete.suggest', () => {
|
|||
{ triggerCharacter: ' ' }
|
||||
);
|
||||
});
|
||||
|
||||
test('case', async () => {
|
||||
const { assertSuggestions } = await setup();
|
||||
const comparisonOperators = ['==', '!=', '>', '<', '>=', '<=']
|
||||
.map((op) => `${op}`)
|
||||
.concat(',');
|
||||
|
||||
// case( / ) suggest any field/eval function in this position as first argument
|
||||
|
||||
const allSuggestions = [
|
||||
// With extra space after field name to open suggestions
|
||||
...getFieldNamesByType('any').map((field) => `${field} `),
|
||||
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }, undefined, ['case']),
|
||||
];
|
||||
await assertSuggestions('from a | eval case(/)', allSuggestions, {
|
||||
triggerCharacter: ' ',
|
||||
});
|
||||
await assertSuggestions('from a | eval case(/)', allSuggestions);
|
||||
|
||||
// case( field /) suggest comparison operators at this point to converge to a boolean
|
||||
await assertSuggestions('from a | eval case( textField /)', comparisonOperators, {
|
||||
triggerCharacter: ' ',
|
||||
});
|
||||
await assertSuggestions('from a | eval case( doubleField /)', comparisonOperators, {
|
||||
triggerCharacter: ' ',
|
||||
});
|
||||
await assertSuggestions('from a | eval case( booleanField /)', comparisonOperators, {
|
||||
triggerCharacter: ' ',
|
||||
});
|
||||
|
||||
// case( field > /) suggest field/function of the same type of the right hand side to complete the boolean expression
|
||||
await assertSuggestions(
|
||||
'from a | eval case( keywordField != /)',
|
||||
[
|
||||
// Notice no extra space after field name
|
||||
...getFieldNamesByType(['keyword', 'text', 'boolean']).map((field) => `${field}`),
|
||||
...getFunctionSignaturesByReturnType(
|
||||
'eval',
|
||||
['keyword', 'text', 'boolean'],
|
||||
{ scalar: true },
|
||||
undefined,
|
||||
[]
|
||||
),
|
||||
],
|
||||
{
|
||||
triggerCharacter: ' ',
|
||||
}
|
||||
);
|
||||
|
||||
const expectedNumericSuggestions = [
|
||||
// Notice no extra space after field name
|
||||
...getFieldNamesByType(ESQL_COMMON_NUMERIC_TYPES).map((field) => `${field}`),
|
||||
...getFunctionSignaturesByReturnType(
|
||||
'eval',
|
||||
ESQL_COMMON_NUMERIC_TYPES,
|
||||
{ scalar: true },
|
||||
undefined,
|
||||
[]
|
||||
),
|
||||
];
|
||||
await assertSuggestions(
|
||||
'from a | eval case( integerField != /)',
|
||||
expectedNumericSuggestions,
|
||||
{
|
||||
triggerCharacter: ' ',
|
||||
}
|
||||
);
|
||||
await assertSuggestions('from a | eval case( integerField != /)', [
|
||||
// Notice no extra space after field name
|
||||
...getFieldNamesByType('any').map((field) => `${field}`),
|
||||
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }, undefined, []),
|
||||
]);
|
||||
|
||||
// case( field > 0, >) suggests fields like normal
|
||||
await assertSuggestions(
|
||||
'from a | eval case( integerField != doubleField, /)',
|
||||
[
|
||||
// With extra space after field name to open suggestions
|
||||
...getFieldNamesByType('any').map((field) => `${field}`),
|
||||
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }, undefined, [
|
||||
'case',
|
||||
]),
|
||||
],
|
||||
{
|
||||
triggerCharacter: ' ',
|
||||
}
|
||||
);
|
||||
|
||||
// case( multiple conditions ) suggests fields like normal
|
||||
await assertSuggestions(
|
||||
'from a | eval case(integerField < 0, "negative", integerField > 0, "positive", /)',
|
||||
[
|
||||
// With extra space after field name to open suggestions
|
||||
...getFieldNamesByType('any').map((field) => `${field} `),
|
||||
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }, undefined, [
|
||||
'case',
|
||||
]),
|
||||
],
|
||||
{
|
||||
triggerCharacter: ' ',
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
|
@ -257,7 +257,6 @@ describe('WHERE <expression>', () => {
|
|||
'IS NULL',
|
||||
'NOT',
|
||||
'OR $0',
|
||||
'| ',
|
||||
]);
|
||||
|
||||
await assertSuggestions('from index | WHERE keywordField IS NOT /', [
|
||||
|
@ -269,7 +268,6 @@ describe('WHERE <expression>', () => {
|
|||
'IS NULL',
|
||||
'NOT',
|
||||
'OR $0',
|
||||
'| ',
|
||||
]);
|
||||
});
|
||||
|
||||
|
|
|
@ -0,0 +1,120 @@
|
|||
/*
|
||||
* 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_COMMON_NUMERIC_TYPES } from '../../shared/esql_types';
|
||||
import {
|
||||
AssertSuggestionsFn,
|
||||
getFieldNamesByType,
|
||||
getFunctionSignaturesByReturnType,
|
||||
setup,
|
||||
} from './helpers';
|
||||
|
||||
describe('case', () => {
|
||||
let assertSuggestions: AssertSuggestionsFn;
|
||||
|
||||
beforeEach(async () => {
|
||||
const setupResult = await setup();
|
||||
assertSuggestions = setupResult.assertSuggestions;
|
||||
});
|
||||
|
||||
const comparisonOperators = ['==', '!=', '>', '<', '>=', '<='].map((op) => `${op}`).concat(',');
|
||||
|
||||
// case( / ) suggest any field/eval function in this position as first argument
|
||||
|
||||
const allSuggestions = [
|
||||
// With extra space after field name to open suggestions
|
||||
...getFieldNamesByType('any').map((field) => `${field} `),
|
||||
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }, undefined, ['case']),
|
||||
];
|
||||
|
||||
test('first position', async () => {
|
||||
await assertSuggestions('from a | eval case(/)', allSuggestions, {
|
||||
triggerCharacter: ' ',
|
||||
});
|
||||
await assertSuggestions('from a | eval case(/)', allSuggestions);
|
||||
});
|
||||
|
||||
test('suggests comparison operators after initial column', async () => {
|
||||
// case( field /) suggest comparison operators at this point to converge to a boolean
|
||||
await assertSuggestions('from a | eval case( textField /)', comparisonOperators, {
|
||||
triggerCharacter: ' ',
|
||||
});
|
||||
await assertSuggestions('from a | eval case( doubleField /)', comparisonOperators, {
|
||||
triggerCharacter: ' ',
|
||||
});
|
||||
await assertSuggestions('from a | eval case( booleanField /)', comparisonOperators, {
|
||||
triggerCharacter: ' ',
|
||||
});
|
||||
});
|
||||
|
||||
test('after comparison operator', async () => {
|
||||
// case( field > /) suggest field/function of the same type of the right hand side to complete the boolean expression
|
||||
await assertSuggestions(
|
||||
'from a | eval case( keywordField != /)',
|
||||
[
|
||||
// Notice no extra space after field name
|
||||
...getFieldNamesByType(['keyword', 'text', 'boolean']).map((field) => `${field}`),
|
||||
...getFunctionSignaturesByReturnType(
|
||||
'eval',
|
||||
['keyword', 'text', 'boolean'],
|
||||
{ scalar: true },
|
||||
undefined,
|
||||
[]
|
||||
),
|
||||
],
|
||||
{
|
||||
triggerCharacter: ' ',
|
||||
}
|
||||
);
|
||||
|
||||
const expectedNumericSuggestions = [
|
||||
// Notice no extra space after field name
|
||||
...getFieldNamesByType(ESQL_COMMON_NUMERIC_TYPES).map((field) => `${field}`),
|
||||
...getFunctionSignaturesByReturnType(
|
||||
'eval',
|
||||
ESQL_COMMON_NUMERIC_TYPES,
|
||||
{ scalar: true },
|
||||
undefined,
|
||||
[]
|
||||
),
|
||||
];
|
||||
await assertSuggestions('from a | eval case( integerField != /)', expectedNumericSuggestions, {
|
||||
triggerCharacter: ' ',
|
||||
});
|
||||
await assertSuggestions('from a | eval case( integerField != /)', expectedNumericSuggestions);
|
||||
});
|
||||
|
||||
test('suggestions for second position', async () => {
|
||||
// case( field > 0, >) suggests fields like normal
|
||||
await assertSuggestions(
|
||||
'from a | eval case( integerField != doubleField, /)',
|
||||
[
|
||||
// With extra space after field name to open suggestions
|
||||
...getFieldNamesByType('any').map((field) => `${field}`),
|
||||
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }, undefined, ['case']),
|
||||
],
|
||||
{
|
||||
triggerCharacter: ' ',
|
||||
}
|
||||
);
|
||||
|
||||
// case( multiple conditions ) suggests fields like normal
|
||||
await assertSuggestions(
|
||||
'from a | eval case(integerField < 0, "negative", integerField > 0, "positive", /)',
|
||||
[
|
||||
// With extra space after field name to open suggestions
|
||||
...getFieldNamesByType('any').map((field) => `${field} `),
|
||||
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }, undefined, ['case']),
|
||||
],
|
||||
{
|
||||
triggerCharacter: ' ',
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
|
@ -321,6 +321,12 @@ export interface SuggestOptions {
|
|||
callbacks?: ESQLCallbacks;
|
||||
}
|
||||
|
||||
export type AssertSuggestionsFn = (
|
||||
query: string,
|
||||
expected: Array<string | PartialSuggestionWithText>,
|
||||
opts?: SuggestOptions
|
||||
) => Promise<void>;
|
||||
|
||||
export const setup = async (caret = '/') => {
|
||||
if (caret.length !== 1) {
|
||||
throw new Error('Caret must be a single character');
|
||||
|
|
|
@ -327,11 +327,12 @@ describe('autocomplete', () => {
|
|||
// EVAL argument
|
||||
testSuggestions('FROM index1 | EVAL b/', [
|
||||
'var0 = ',
|
||||
...getFieldNamesByType('any'),
|
||||
...getFieldNamesByType('any').map((name) => `${name} `),
|
||||
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
|
||||
]);
|
||||
|
||||
testSuggestions('FROM index1 | EVAL var0 = f/', [
|
||||
...getFieldNamesByType('any').map((name) => `${name} `),
|
||||
...getFunctionSignaturesByReturnType('eval', 'any', { scalar: true }),
|
||||
]);
|
||||
|
||||
|
@ -1008,10 +1009,6 @@ describe('autocomplete', () => {
|
|||
'AND $0',
|
||||
'NOT',
|
||||
'OR $0',
|
||||
// pipe doesn't make sense here, but Monaco will filter it out.
|
||||
// see https://github.com/elastic/kibana/issues/199401 for an explanation
|
||||
// of why this happens
|
||||
'| ',
|
||||
]);
|
||||
testSuggestions('FROM a | WHERE doubleField IS N/', [
|
||||
{ text: 'IS NOT NULL', rangeToReplace: { start: 28, end: 32 } },
|
||||
|
@ -1022,10 +1019,6 @@ describe('autocomplete', () => {
|
|||
'AND $0',
|
||||
'NOT',
|
||||
'OR $0',
|
||||
// pipe doesn't make sense here, but Monaco will filter it out.
|
||||
// see https://github.com/elastic/kibana/issues/199401 for an explanation
|
||||
// of why this happens
|
||||
'| ',
|
||||
]);
|
||||
testSuggestions('FROM a | EVAL doubleField IS NOT N/', [
|
||||
{ text: 'IS NOT NULL', rangeToReplace: { start: 27, end: 35 } },
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { uniq, uniqBy } from 'lodash';
|
||||
import { uniq } from 'lodash';
|
||||
import {
|
||||
type AstProviderFn,
|
||||
type ESQLAstItem,
|
||||
|
@ -17,56 +17,41 @@ import {
|
|||
type ESQLSingleAstItem,
|
||||
} from '@kbn/esql-ast';
|
||||
import type { ESQLControlVariable } from '@kbn/esql-types';
|
||||
import { ESQL_NUMBER_TYPES, isNumericType } from '../shared/esql_types';
|
||||
import { isNumericType } from '../shared/esql_types';
|
||||
import type { EditorContext, ItemKind, SuggestionRawDefinition, GetColumnsByTypeFn } from './types';
|
||||
import {
|
||||
getColumnForASTNode,
|
||||
getCommandDefinition,
|
||||
getFunctionDefinition,
|
||||
isAssignment,
|
||||
isAssignmentComplete,
|
||||
isColumnItem,
|
||||
isFunctionItem,
|
||||
isIncompleteItem,
|
||||
isLiteralItem,
|
||||
isOptionItem,
|
||||
isRestartingExpression,
|
||||
isSourceCommand,
|
||||
isTimeIntervalItem,
|
||||
getAllFunctions,
|
||||
isSingleItem,
|
||||
getColumnExists,
|
||||
findPreviousWord,
|
||||
correctQuerySyntax,
|
||||
getColumnByName,
|
||||
getAllCommands,
|
||||
getExpressionType,
|
||||
} from '../shared/helpers';
|
||||
import { collectVariables, excludeVariablesFromCurrentCommand } from '../shared/variables';
|
||||
import type { ESQLRealField, ESQLVariable, ReferenceMaps } from '../validation/types';
|
||||
import type { ESQLRealField, ESQLVariable } from '../validation/types';
|
||||
import {
|
||||
allStarConstant,
|
||||
commaCompleteItem,
|
||||
getAssignmentDefinitionCompletitionItem,
|
||||
getCommandAutocompleteDefinitions,
|
||||
pipeCompleteItem,
|
||||
} from './complete_items';
|
||||
import {
|
||||
buildPoliciesDefinitions,
|
||||
getNewVariableSuggestion,
|
||||
getFunctionSuggestions,
|
||||
getCompatibleLiterals,
|
||||
buildConstantsDefinitions,
|
||||
buildOptionDefinition,
|
||||
buildValueDefinitions,
|
||||
getDateLiterals,
|
||||
buildFieldsDefinitionsWithMetadata,
|
||||
TRIGGER_SUGGESTION_COMMAND,
|
||||
getOperatorSuggestions,
|
||||
getSuggestionsAfterNot,
|
||||
} from './factories';
|
||||
import { EDITOR_MARKER, FULL_TEXT_SEARCH_FUNCTIONS } from '../shared/constants';
|
||||
import { getAstContext, removeMarkerArgFromArgsList } from '../shared/context';
|
||||
import { getAstContext } from '../shared/context';
|
||||
import {
|
||||
buildQueryUntilPreviousCommand,
|
||||
getFieldsByTypeHelper,
|
||||
|
@ -80,16 +65,13 @@ import {
|
|||
getSourcesFromCommands,
|
||||
isAggFunctionUsedAlready,
|
||||
getValidSignaturesAndTypesToSuggestNext,
|
||||
handleFragment,
|
||||
getFieldsOrFunctionsSuggestions,
|
||||
pushItUpInTheList,
|
||||
extractTypeFromASTArg,
|
||||
getSuggestionsToRightOfOperatorExpression,
|
||||
checkFunctionInvocationComplete,
|
||||
} from './helper';
|
||||
import {
|
||||
FunctionParameter,
|
||||
isParameterType,
|
||||
FunctionDefinitionTypes,
|
||||
GetPolicyMetadataFn,
|
||||
} from '../definitions/types';
|
||||
|
@ -99,14 +81,6 @@ import { getRecommendedQueriesSuggestions } from './recommended_queries/suggesti
|
|||
type GetFieldsMapFn = () => Promise<Map<string, ESQLRealField>>;
|
||||
type GetPoliciesFn = () => Promise<SuggestionRawDefinition[]>;
|
||||
|
||||
function getFinalSuggestions({ comma }: { comma?: boolean } = { comma: true }) {
|
||||
const finalSuggestions = [pipeCompleteItem];
|
||||
if (comma) {
|
||||
finalSuggestions.push(commaCompleteItem);
|
||||
}
|
||||
return finalSuggestions;
|
||||
}
|
||||
|
||||
export async function suggest(
|
||||
fullText: string,
|
||||
offset: number,
|
||||
|
@ -255,77 +229,14 @@ function findNewVariable(variables: Map<string, ESQLVariable[]>) {
|
|||
return name;
|
||||
}
|
||||
|
||||
function workoutBuiltinOptions(
|
||||
nodeArg: ESQLAstItem,
|
||||
references: Pick<ReferenceMaps, 'fields' | 'variables'>
|
||||
): { ignored?: string[] } {
|
||||
// skip assign operator if it's a function or an existing field to avoid promoting shadowing
|
||||
return {
|
||||
ignored: Boolean(!isColumnItem(nodeArg) || getColumnForASTNode(nodeArg, references))
|
||||
? ['=']
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function areCurrentArgsValid(
|
||||
command: ESQLCommand,
|
||||
node: ESQLAstItem,
|
||||
references: Pick<ReferenceMaps, 'fields' | 'variables'>
|
||||
) {
|
||||
// unfortunately here we need to bake some command-specific logic
|
||||
if (command.name === 'eval') {
|
||||
if (node) {
|
||||
if (isFunctionItem(node)) {
|
||||
if (isAssignment(node)) {
|
||||
return isAssignmentComplete(node);
|
||||
} else {
|
||||
return checkFunctionInvocationComplete(node, (expression) =>
|
||||
getExpressionType(expression, references.fields, references.variables)
|
||||
).complete;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if (command.name === 'rename') {
|
||||
if (node) {
|
||||
if (isColumnItem(node)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
function extractArgMeta(
|
||||
commandOrOption: ESQLCommand | ESQLCommandOption,
|
||||
node: ESQLSingleAstItem | undefined
|
||||
) {
|
||||
let argIndex = commandOrOption.args.length;
|
||||
const prevIndex = Math.max(argIndex - 1, 0);
|
||||
const lastArg = removeMarkerArgFromArgsList(commandOrOption)!.args[prevIndex];
|
||||
if (isIncompleteItem(lastArg)) {
|
||||
argIndex = prevIndex;
|
||||
}
|
||||
|
||||
// if a node is not specified use the lastArg
|
||||
// mind to give priority to node as lastArg might be a function root
|
||||
// => "a > b and c == d" gets translated into and( gt(a, b) , eq(c, d) ) => hence "and" is lastArg
|
||||
const nodeArg = node || lastArg;
|
||||
|
||||
return { argIndex, prevIndex, lastArg, nodeArg };
|
||||
}
|
||||
|
||||
async function getSuggestionsWithinCommandExpression(
|
||||
innerText: string,
|
||||
commands: ESQLCommand[],
|
||||
{
|
||||
command,
|
||||
option,
|
||||
node,
|
||||
}: {
|
||||
astContext: {
|
||||
command: ESQLCommand;
|
||||
option: ESQLCommandOption | undefined;
|
||||
node: ESQLSingleAstItem | undefined;
|
||||
node?: ESQLAstItem;
|
||||
option?: ESQLCommandOption;
|
||||
containingFunction?: ESQLFunction;
|
||||
},
|
||||
getSources: () => Promise<ESQLSourceResult[]>,
|
||||
getColumnsByType: GetColumnsByTypeFn,
|
||||
|
@ -337,533 +248,67 @@ async function getSuggestionsWithinCommandExpression(
|
|||
callbacks?: ESQLCallbacks,
|
||||
supportsControls?: boolean
|
||||
) {
|
||||
const commandDef = getCommandDefinition(command.name);
|
||||
const commandDef = getCommandDefinition(astContext.command.name);
|
||||
|
||||
// collect all fields + variables to suggest
|
||||
const fieldsMap: Map<string, ESQLRealField> = await getFieldsMap();
|
||||
const anyVariables = collectVariables(commands, fieldsMap, innerText);
|
||||
|
||||
const references = { fields: fieldsMap, variables: anyVariables };
|
||||
if (commandDef.suggest) {
|
||||
// The new path.
|
||||
return commandDef.suggest({
|
||||
innerText,
|
||||
command,
|
||||
getColumnsByType,
|
||||
getAllColumnNames: () => Array.from(fieldsMap.keys()),
|
||||
columnExists: (col: string) => Boolean(getColumnByName(col, references)),
|
||||
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,
|
||||
definition: commandDef,
|
||||
getSources,
|
||||
getRecommendedQueriesSuggestions: (prefix) =>
|
||||
getRecommendedQueriesSuggestions(getColumnsByType, prefix),
|
||||
getSourcesFromQuery: (type) => getSourcesFromCommands(commands, type),
|
||||
previousCommands: commands,
|
||||
callbacks,
|
||||
getVariables,
|
||||
supportsControls,
|
||||
getPolicies,
|
||||
getPolicyMetadata,
|
||||
});
|
||||
} else {
|
||||
// The deprecated path.
|
||||
return getExpressionSuggestionsByType(
|
||||
innerText,
|
||||
commands,
|
||||
{ command, option, node },
|
||||
getSources,
|
||||
getColumnsByType,
|
||||
getFieldsMap,
|
||||
getPolicies
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated — this generic logic will be replaced with the command-specific suggest functions
|
||||
* from each command definition.
|
||||
*/
|
||||
async function getExpressionSuggestionsByType(
|
||||
innerText: string,
|
||||
commands: ESQLCommand[],
|
||||
{
|
||||
command,
|
||||
option,
|
||||
node,
|
||||
}: {
|
||||
command: ESQLCommand;
|
||||
option: ESQLCommandOption | undefined;
|
||||
node: ESQLSingleAstItem | undefined;
|
||||
},
|
||||
getSources: () => Promise<ESQLSourceResult[]>,
|
||||
getFieldsByType: GetColumnsByTypeFn,
|
||||
getFieldsMap: GetFieldsMapFn,
|
||||
getPolicies: GetPoliciesFn
|
||||
) {
|
||||
const commandDef = getCommandDefinition(command.name);
|
||||
const { argIndex, prevIndex, lastArg, nodeArg } = extractArgMeta(command, node);
|
||||
|
||||
// collect all fields + variables to suggest
|
||||
const fieldsMap: Map<string, ESQLRealField> = await getFieldsMap();
|
||||
const anyVariables = collectVariables(commands, fieldsMap, innerText);
|
||||
|
||||
const references = { fields: fieldsMap, variables: anyVariables };
|
||||
if (!commandDef.signature || !commandDef.options) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// A new expression is considered either
|
||||
// * just after a command name => i.e. ... | STATS <here>
|
||||
// * or after a comma => i.e. STATS fieldA, <here>
|
||||
const isNewExpression =
|
||||
isRestartingExpression(innerText) ||
|
||||
(argIndex === 0 && (!isFunctionItem(nodeArg) || !nodeArg?.args.length));
|
||||
|
||||
// the not function is a special operator that can be used in different ways,
|
||||
// and not all these are mapped within the AST data structure: in particular
|
||||
// <COMMAND> <field> NOT <here>
|
||||
// is an incomplete statement and it results in a missing AST node, so we need to detect
|
||||
// from the query string itself
|
||||
const endsWithNot =
|
||||
/ not$/i.test(innerText.trimEnd()) &&
|
||||
!command.args.some((arg) => isFunctionItem(arg) && arg.name === 'not');
|
||||
|
||||
// early exit in case of a missing function
|
||||
if (isFunctionItem(lastArg) && !getFunctionDefinition(lastArg.name)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Are options already declared? This is useful to suggest only new ones
|
||||
const optionsAlreadyDeclared = (
|
||||
command.args.filter((arg) => isOptionItem(arg)) as ESQLCommandOption[]
|
||||
).map(({ name }) => ({
|
||||
name,
|
||||
index: commandDef.options!.findIndex(({ name: defName }) => defName === name),
|
||||
}));
|
||||
const optionsAvailable = commandDef.options.filter(({ name }, index) => {
|
||||
const optArg = optionsAlreadyDeclared.find(({ name: optionName }) => optionName === name);
|
||||
return (!optArg && !optionsAlreadyDeclared.length) || (optArg && index > optArg.index);
|
||||
});
|
||||
// get the next definition for the given command
|
||||
let argDef = commandDef.signature.params[argIndex];
|
||||
// tune it for the variadic case
|
||||
if (!argDef) {
|
||||
// this is the case of a comma argument
|
||||
if (commandDef.signature.multipleParams) {
|
||||
if (isNewExpression || (isAssignment(lastArg) && !isAssignmentComplete(lastArg))) {
|
||||
// i.e. ... | <COMMAND> a, <here>
|
||||
// i.e. ... | <COMMAND> a = ..., b = <here>
|
||||
argDef = commandDef.signature.params[0];
|
||||
}
|
||||
}
|
||||
|
||||
// this is the case where there's an argument, but it's of the wrong type
|
||||
// i.e. ... | WHERE numberField <here> (WHERE wants a boolean expression!)
|
||||
// i.e. ... | STATS numberfield <here> (STATS wants a function expression!)
|
||||
if (!isNewExpression && nodeArg && !Array.isArray(nodeArg)) {
|
||||
const prevArg = commandDef.signature.params[prevIndex];
|
||||
// in some cases we do not want to go back as the command only accepts a literal
|
||||
// i.e. LIMIT 5 <suggest> -> that's it, so no argDef should be assigned
|
||||
|
||||
// make an exception for STATS (STATS is the only command who accept a function type as arg)
|
||||
if (
|
||||
prevArg &&
|
||||
(prevArg.type === 'function' || (!Array.isArray(nodeArg) && prevArg.type !== nodeArg.type))
|
||||
) {
|
||||
if (!isLiteralItem(nodeArg) || !prevArg.constantOnly) {
|
||||
argDef = prevArg;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const previousWord = findPreviousWord(innerText);
|
||||
// enrich with assignment has some special rules who are handled somewhere else
|
||||
const canHaveAssignments =
|
||||
['eval', 'stats', 'row'].includes(command.name) &&
|
||||
!comparisonFunctions.map((fn) => fn.name).includes(previousWord);
|
||||
|
||||
const suggestions: SuggestionRawDefinition[] = [];
|
||||
|
||||
// When user types and accepts autocomplete suggestion, and cursor is placed at the end of a valid field
|
||||
// we should not show irrelevant functions that might have words matching
|
||||
const columnWithActiveCursor = commands.find(
|
||||
(c) =>
|
||||
c.name === command.name &&
|
||||
command.name === 'eval' &&
|
||||
c.args.some((arg) => isColumnItem(arg) && arg.name.includes(EDITOR_MARKER))
|
||||
);
|
||||
|
||||
const shouldShowFunctions = !columnWithActiveCursor;
|
||||
|
||||
// in this flow there's a clear plan here from argument definitions so try to follow it
|
||||
if (argDef) {
|
||||
if (argDef.type === 'column' || argDef.type === 'any' || argDef.type === 'function') {
|
||||
if (isNewExpression && canHaveAssignments) {
|
||||
if (endsWithNot) {
|
||||
// i.e.
|
||||
// ... | ROW field NOT <suggest>
|
||||
// ... | EVAL field NOT <suggest>
|
||||
// there's not way to know the type of the field here, so suggest anything
|
||||
suggestions.push(...getSuggestionsAfterNot());
|
||||
} else {
|
||||
// i.e.
|
||||
// ... | ROW <suggest>
|
||||
// ... | STATS <suggest>
|
||||
// ... | STATS ..., <suggest>
|
||||
// ... | EVAL <suggest>
|
||||
// ... | EVAL ..., <suggest>
|
||||
suggestions.push(getNewVariableSuggestion(findNewVariable(anyVariables)));
|
||||
}
|
||||
}
|
||||
}
|
||||
// Suggest fields or variables
|
||||
if (argDef.type === 'column' || argDef.type === 'any') {
|
||||
if ((!nodeArg || isNewExpression) && !endsWithNot) {
|
||||
const fieldSuggestions = await getFieldsOrFunctionsSuggestions(
|
||||
argDef.innerTypes || ['any'],
|
||||
command.name,
|
||||
option?.name,
|
||||
getFieldsByType,
|
||||
{
|
||||
// TODO instead of relying on canHaveAssignments and other command name checks
|
||||
// we should have a more generic way to determine if a command can have functions.
|
||||
// I think it comes down to the definition of 'column' since 'any' should always
|
||||
// include functions.
|
||||
functions: canHaveAssignments || command.name === 'sort',
|
||||
fields: !argDef.constantOnly,
|
||||
variables: anyVariables,
|
||||
literals: argDef.constantOnly,
|
||||
},
|
||||
{
|
||||
ignoreColumns: isNewExpression
|
||||
? command.args.filter(isColumnItem).map(({ name }) => name)
|
||||
: [],
|
||||
}
|
||||
);
|
||||
|
||||
const fieldFragmentSuggestions = await handleFragment(
|
||||
innerText,
|
||||
(fragment) => Boolean(getColumnByName(fragment, references)),
|
||||
(_fragment: string, rangeToReplace?: { start: number; end: number }) => {
|
||||
// COMMAND fie<suggest>
|
||||
return fieldSuggestions.map((suggestion) => {
|
||||
// if there is already a command, we don't want to override it
|
||||
if (suggestion.command) return suggestion;
|
||||
return {
|
||||
...suggestion,
|
||||
text: suggestion.text + (['grok', 'dissect'].includes(command.name) ? ' ' : ''),
|
||||
command: TRIGGER_SUGGESTION_COMMAND,
|
||||
rangeToReplace,
|
||||
};
|
||||
});
|
||||
},
|
||||
(fragment: string, rangeToReplace: { start: number; end: number }) => {
|
||||
// COMMAND field<suggest>
|
||||
if (['grok', 'dissect'].includes(command.name)) {
|
||||
return fieldSuggestions.map((suggestion) => {
|
||||
// if there is already a command, we don't want to override it
|
||||
if (suggestion.command) return suggestion;
|
||||
return {
|
||||
...suggestion,
|
||||
text: suggestion.text + ' ',
|
||||
command: TRIGGER_SUGGESTION_COMMAND,
|
||||
rangeToReplace,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
const finalSuggestions = [{ ...pipeCompleteItem, text: ' | ' }];
|
||||
if (fieldSuggestions.length > 1)
|
||||
// when we fix the editor marker, this should probably be checked against 0 instead of 1
|
||||
// this is because the last field in the AST is currently getting removed (because it contains
|
||||
// the editor marker) so it is not included in the ignored list which is used to filter out
|
||||
// existing fields above.
|
||||
finalSuggestions.push({ ...commaCompleteItem, text: ', ' });
|
||||
|
||||
return finalSuggestions.map<SuggestionRawDefinition>((s) => ({
|
||||
...s,
|
||||
filterText: fragment,
|
||||
text: fragment + s.text,
|
||||
command: TRIGGER_SUGGESTION_COMMAND,
|
||||
rangeToReplace,
|
||||
}));
|
||||
}
|
||||
);
|
||||
|
||||
suggestions.push(...fieldFragmentSuggestions);
|
||||
}
|
||||
}
|
||||
if (argDef.type === 'function' || argDef.type === 'any') {
|
||||
if (isColumnItem(nodeArg)) {
|
||||
// ... | STATS a <suggest>
|
||||
// ... | EVAL a <suggest>
|
||||
const nodeArgType = extractTypeFromASTArg(nodeArg, references);
|
||||
if (isParameterType(nodeArgType)) {
|
||||
suggestions.push(
|
||||
...getOperatorSuggestions({
|
||||
command: command.name,
|
||||
leftParamType: nodeArgType,
|
||||
ignored: workoutBuiltinOptions(nodeArg, references).ignored,
|
||||
})
|
||||
);
|
||||
} else {
|
||||
suggestions.push(getAssignmentDefinitionCompletitionItem());
|
||||
}
|
||||
}
|
||||
if (
|
||||
(isNewExpression && !endsWithNot) ||
|
||||
(isAssignment(nodeArg) && !isAssignmentComplete(nodeArg))
|
||||
) {
|
||||
// ... | STATS a = <suggest>
|
||||
// ... | EVAL a = <suggest>
|
||||
// ... | STATS a = ..., <suggest>
|
||||
// ... | EVAL a = ..., <suggest>
|
||||
// ... | STATS a = ..., b = <suggest>
|
||||
// ... | EVAL a = ..., b = <suggest>
|
||||
suggestions.push(
|
||||
...(await getFieldsOrFunctionsSuggestions(
|
||||
['any'],
|
||||
command.name,
|
||||
option?.name,
|
||||
getFieldsByType,
|
||||
{
|
||||
functions: shouldShowFunctions,
|
||||
fields: false,
|
||||
variables: nodeArg ? undefined : anyVariables,
|
||||
literals: argDef.constantOnly,
|
||||
}
|
||||
))
|
||||
);
|
||||
if (['show', 'meta'].includes(command.name)) {
|
||||
suggestions.push(...getOperatorSuggestions({ command: command.name }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (argDef.type === 'any') {
|
||||
// ... | EVAL var = field <suggest>
|
||||
// ... | EVAL var = fn(field) <suggest>
|
||||
// make sure we're still in the same assignment context and there's no comma (newExpression ensures that)
|
||||
if (!isNewExpression) {
|
||||
if (isAssignment(nodeArg) && isAssignmentComplete(nodeArg)) {
|
||||
const [rightArg] = nodeArg.args[1] as [ESQLSingleAstItem];
|
||||
const nodeArgType = extractTypeFromASTArg(rightArg, references);
|
||||
suggestions.push(
|
||||
...getOperatorSuggestions({
|
||||
command: command.name,
|
||||
leftParamType: isParameterType(nodeArgType) ? nodeArgType : 'any',
|
||||
ignored: workoutBuiltinOptions(nodeArg, references).ignored,
|
||||
})
|
||||
);
|
||||
if (isNumericType(nodeArgType) && isLiteralItem(rightArg)) {
|
||||
// ... EVAL var = 1 <suggest>
|
||||
suggestions.push(...getCompatibleLiterals(['time_literal_unit']));
|
||||
}
|
||||
if (isFunctionItem(rightArg)) {
|
||||
if (rightArg.args.some(isTimeIntervalItem)) {
|
||||
const lastFnArg = rightArg.args[rightArg.args.length - 1];
|
||||
const lastFnArgType = extractTypeFromASTArg(lastFnArg, references);
|
||||
if (isNumericType(lastFnArgType) && isLiteralItem(lastFnArg))
|
||||
// ... EVAL var = 1 year + 2 <suggest>
|
||||
suggestions.push(...getCompatibleLiterals(['time_literal_unit']));
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (isFunctionItem(nodeArg)) {
|
||||
if (nodeArg.name === 'not') {
|
||||
suggestions.push(
|
||||
...(await getFieldsOrFunctionsSuggestions(
|
||||
['boolean'],
|
||||
command.name,
|
||||
option?.name,
|
||||
getFieldsByType,
|
||||
{
|
||||
functions: true,
|
||||
fields: true,
|
||||
variables: anyVariables,
|
||||
}
|
||||
))
|
||||
);
|
||||
} else {
|
||||
suggestions.push(
|
||||
...(await getSuggestionsToRightOfOperatorExpression({
|
||||
queryText: innerText,
|
||||
commandName: command.name,
|
||||
optionName: option?.name,
|
||||
rootOperator: nodeArg,
|
||||
getExpressionType: (expression) =>
|
||||
getExpressionType(expression, references.fields, references.variables),
|
||||
getColumnsByType: getFieldsByType,
|
||||
}))
|
||||
);
|
||||
if (nodeArg.args.some(isTimeIntervalItem)) {
|
||||
const lastFnArg = nodeArg.args[nodeArg.args.length - 1];
|
||||
const lastFnArgType = extractTypeFromASTArg(lastFnArg, references);
|
||||
if (isNumericType(lastFnArgType) && isLiteralItem(lastFnArg))
|
||||
// ... EVAL var = 1 year + 2 <suggest>
|
||||
suggestions.push(...getCompatibleLiterals(['time_literal_unit']));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// if the definition includes a list of constants, suggest them
|
||||
if (argDef.values) {
|
||||
// ... | <COMMAND> ... <suggest enums>
|
||||
suggestions.push(
|
||||
...buildConstantsDefinitions(argDef.values, undefined, undefined, {
|
||||
advanceCursorAndOpenSuggestions: true,
|
||||
})
|
||||
);
|
||||
}
|
||||
// If the type is specified try to dig deeper in the definition to suggest the best candidate
|
||||
if (
|
||||
['string', 'text', 'keyword', 'boolean', ...ESQL_NUMBER_TYPES].includes(argDef.type) &&
|
||||
!argDef.values
|
||||
) {
|
||||
// it can be just literal values (i.e. "string")
|
||||
if (argDef.constantOnly) {
|
||||
// ... | <COMMAND> ... <suggest>
|
||||
suggestions.push(...getCompatibleLiterals([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) {
|
||||
if (endsWithNot) {
|
||||
// i.e.
|
||||
// ... | WHERE field NOT <suggest>
|
||||
// there's not way to know the type of the field here, so suggest anything
|
||||
suggestions.push(...getSuggestionsAfterNot());
|
||||
} else {
|
||||
// ... | <COMMAND> <suggest>
|
||||
// In this case start suggesting something not strictly based on type
|
||||
suggestions.push(
|
||||
...(await getFieldsByType('any', [], { advanceCursor: true, openSuggestions: true })),
|
||||
...(await getFieldsOrFunctionsSuggestions(
|
||||
['any'],
|
||||
command.name,
|
||||
option?.name,
|
||||
getFieldsByType,
|
||||
{
|
||||
functions: true,
|
||||
fields: false,
|
||||
variables: anyVariables,
|
||||
}
|
||||
))
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// if something is already present, leverage its type to suggest something in context
|
||||
const nodeArgType = extractTypeFromASTArg(nodeArg, references);
|
||||
// These cases can happen here, so need to identify each and provide the right suggestion
|
||||
// i.e. ... | <COMMAND> field <suggest>
|
||||
// i.e. ... | <COMMAND> field + <suggest>
|
||||
// i.e. ... | <COMMAND> field >= <suggest>
|
||||
// i.e. ... | <COMMAND> field > 0 <suggest>
|
||||
// i.e. ... | <COMMAND> field + otherN <suggest>
|
||||
// "FROM a | WHERE doubleField IS NOT N"
|
||||
if (nodeArgType) {
|
||||
if (isFunctionItem(nodeArg)) {
|
||||
if (nodeArg.name === 'not') {
|
||||
suggestions.push(
|
||||
...(await getFieldsOrFunctionsSuggestions(
|
||||
['boolean'],
|
||||
command.name,
|
||||
option?.name,
|
||||
getFieldsByType,
|
||||
{
|
||||
functions: true,
|
||||
fields: true,
|
||||
variables: anyVariables,
|
||||
}
|
||||
))
|
||||
);
|
||||
} else {
|
||||
suggestions.push(
|
||||
...(await getSuggestionsToRightOfOperatorExpression({
|
||||
queryText: innerText,
|
||||
commandName: command.name,
|
||||
optionName: option?.name,
|
||||
rootOperator: nodeArg,
|
||||
getExpressionType: (expression) =>
|
||||
getExpressionType(expression, references.fields, references.variables),
|
||||
getColumnsByType: getFieldsByType,
|
||||
}))
|
||||
);
|
||||
}
|
||||
} else if (isParameterType(nodeArgType)) {
|
||||
// i.e. ... | <COMMAND> field <suggest>
|
||||
suggestions.push(
|
||||
...getOperatorSuggestions({
|
||||
command: command.name,
|
||||
leftParamType: nodeArgType,
|
||||
ignored: workoutBuiltinOptions(nodeArg, references).ignored,
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const nonOptionArgs = command.args.filter(
|
||||
(arg) => !isOptionItem(arg) && !Array.isArray(arg) && !arg.incomplete
|
||||
);
|
||||
// Perform some checks on mandatory arguments
|
||||
const mandatoryArgsAlreadyPresent =
|
||||
(commandDef.signature.multipleParams && nonOptionArgs.length > 1) ||
|
||||
nonOptionArgs.length >=
|
||||
commandDef.signature.params.filter(({ optional }) => !optional).length ||
|
||||
argDef?.type === 'function';
|
||||
|
||||
// check if declared args are fully valid for the given command
|
||||
const currentArgsAreValidForCommand = areCurrentArgsValid(command, nodeArg, references);
|
||||
|
||||
// latest suggestions: options and final ones
|
||||
// For now, we don't suggest for expressions within any function besides CASE
|
||||
// e.g. CASE(field != /)
|
||||
//
|
||||
// So, it is handled as a special branch...
|
||||
if (
|
||||
(!isNewExpression && mandatoryArgsAlreadyPresent && currentArgsAreValidForCommand) ||
|
||||
optionsAlreadyDeclared.length
|
||||
astContext.containingFunction?.name === 'case' &&
|
||||
!Array.isArray(astContext.node) &&
|
||||
astContext.node?.type === 'function' &&
|
||||
astContext.node?.subtype === 'binary-expression'
|
||||
) {
|
||||
// suggest some command options
|
||||
if (optionsAvailable.length) {
|
||||
suggestions.push(
|
||||
...optionsAvailable.map((opt) => buildOptionDefinition(opt, command.name === 'dissect'))
|
||||
);
|
||||
}
|
||||
|
||||
if (!optionsAvailable.length || optionsAvailable.every(({ optional }) => optional)) {
|
||||
const shouldPushItDown = command.name === 'eval' && !command.args.some(isFunctionItem);
|
||||
// now suggest pipe or comma
|
||||
const finalSuggestions = getFinalSuggestions({
|
||||
comma:
|
||||
commandDef.signature.multipleParams &&
|
||||
optionsAvailable.length === commandDef.options.length,
|
||||
}).map(({ sortText, ...rest }) => ({
|
||||
...rest,
|
||||
sortText: shouldPushItDown ? `Z${sortText}` : sortText,
|
||||
}));
|
||||
suggestions.push(...finalSuggestions);
|
||||
}
|
||||
return await getSuggestionsToRightOfOperatorExpression({
|
||||
queryText: innerText,
|
||||
commandName: astContext.command.name,
|
||||
optionName: astContext.option?.name,
|
||||
rootOperator: astContext.node,
|
||||
getExpressionType: (expression) =>
|
||||
getExpressionType(expression, references.fields, references.variables),
|
||||
getColumnsByType,
|
||||
});
|
||||
}
|
||||
// Due to some logic overlapping functions can be repeated
|
||||
// so dedupe here based on text string (it can differ from name)
|
||||
return uniqBy(suggestions, (suggestion) => suggestion.text);
|
||||
|
||||
return commandDef.suggest({
|
||||
innerText,
|
||||
command: astContext.command,
|
||||
getColumnsByType,
|
||||
getAllColumnNames: () => Array.from(fieldsMap.keys()),
|
||||
columnExists: (col: string) => Boolean(getColumnByName(col, references)),
|
||||
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,
|
||||
definition: commandDef,
|
||||
getSources,
|
||||
getRecommendedQueriesSuggestions: (prefix) =>
|
||||
getRecommendedQueriesSuggestions(getColumnsByType, prefix),
|
||||
getSourcesFromQuery: (type) => getSourcesFromCommands(commands, type),
|
||||
previousCommands: commands,
|
||||
callbacks,
|
||||
getVariables,
|
||||
supportsControls,
|
||||
getPolicies,
|
||||
getPolicyMetadata,
|
||||
});
|
||||
}
|
||||
|
||||
const addCommaIf = (condition: boolean, text: string) => (condition ? `${text},` : text);
|
||||
|
|
|
@ -0,0 +1,69 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import type { ESQLSingleAstItem } from '@kbn/esql-ast';
|
||||
import { isMarkerNode } from '../../../shared/context';
|
||||
import { isAssignment, isColumnItem } from '../../../..';
|
||||
import { CommandSuggestParams } from '../../../definitions/types';
|
||||
import type { SuggestionRawDefinition } from '../../types';
|
||||
import { getNewVariableSuggestion } from '../../factories';
|
||||
import { commaCompleteItem, pipeCompleteItem } from '../../complete_items';
|
||||
import { buildPartialMatcher, getExpressionPosition, suggestForExpression } from '../../helper';
|
||||
|
||||
const isNullMatcher = buildPartialMatcher('is nul');
|
||||
const isNotNullMatcher = buildPartialMatcher('is not nul');
|
||||
|
||||
export async function suggest(
|
||||
params: CommandSuggestParams<'eval'>
|
||||
): Promise<SuggestionRawDefinition[]> {
|
||||
let expressionRoot = /,\s*$/.test(params.innerText)
|
||||
? undefined
|
||||
: (params.command.args[params.command.args.length - 1] as ESQLSingleAstItem | undefined);
|
||||
|
||||
let insideAssignment = false;
|
||||
if (expressionRoot && isAssignment(expressionRoot)) {
|
||||
// EVAL foo = <use this as the expression root>
|
||||
expressionRoot = (expressionRoot.args[1] as ESQLSingleAstItem[])[0] as ESQLSingleAstItem;
|
||||
insideAssignment = true;
|
||||
|
||||
if (isMarkerNode(expressionRoot)) {
|
||||
expressionRoot = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
const suggestions = await suggestForExpression({
|
||||
...params,
|
||||
expressionRoot,
|
||||
commandName: 'eval',
|
||||
});
|
||||
|
||||
const positionInExpression = getExpressionPosition(params.innerText, expressionRoot);
|
||||
if (positionInExpression === 'empty_expression' && !insideAssignment) {
|
||||
suggestions.push(getNewVariableSuggestion(params.getSuggestedVariableName()));
|
||||
}
|
||||
|
||||
const isExpressionComplete =
|
||||
params.getExpressionType(expressionRoot) !== 'unknown' &&
|
||||
// see https://github.com/elastic/kibana/issues/199401
|
||||
// for the reason we need this string check.
|
||||
!(isNullMatcher.test(params.innerText) || isNotNullMatcher.test(params.innerText));
|
||||
|
||||
if (
|
||||
// don't suggest finishing characters if incomplete expression
|
||||
isExpressionComplete &&
|
||||
// don't suggest finishing characters if the expression is a column
|
||||
// because "EVAL columnName" is a useless expression
|
||||
expressionRoot &&
|
||||
!isColumnItem(expressionRoot)
|
||||
) {
|
||||
suggestions.push(pipeCompleteItem, { ...commaCompleteItem, text: ', ' });
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
|
@ -12,8 +12,8 @@ import { ESQLCommand, mutate, LeafPrinter } from '@kbn/esql-ast';
|
|||
import type { ESQLAstJoinCommand } from '@kbn/esql-ast';
|
||||
import type { ESQLCallbacks } from '../../../shared/types';
|
||||
import {
|
||||
CommandBaseDefinition,
|
||||
CommandDefinition,
|
||||
CommandSuggestFunction,
|
||||
CommandSuggestParams,
|
||||
CommandTypeDefinition,
|
||||
} from '../../../definitions/types';
|
||||
|
@ -96,7 +96,7 @@ const suggestFields = async (
|
|||
return [...intersection, ...union];
|
||||
};
|
||||
|
||||
export const suggest: CommandBaseDefinition<'join'>['suggest'] = async ({
|
||||
export const suggest: CommandSuggestFunction<'join'> = async ({
|
||||
innerText,
|
||||
command,
|
||||
getColumnsByType,
|
||||
|
|
|
@ -7,214 +7,37 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { Walker, type ESQLSingleAstItem, type ESQLFunction } from '@kbn/esql-ast';
|
||||
import { logicalOperators } from '../../../definitions/all_operators';
|
||||
import { CommandSuggestParams, isParameterType } from '../../../definitions/types';
|
||||
import { isFunctionItem } from '../../../shared/helpers';
|
||||
import { type ESQLSingleAstItem } from '@kbn/esql-ast';
|
||||
import { CommandSuggestParams } from '../../../definitions/types';
|
||||
import type { SuggestionRawDefinition } from '../../types';
|
||||
import {
|
||||
getFunctionSuggestions,
|
||||
getOperatorSuggestion,
|
||||
getOperatorSuggestions,
|
||||
getSuggestionsAfterNot,
|
||||
} from '../../factories';
|
||||
import { getOverlapRange, getSuggestionsToRightOfOperatorExpression } from '../../helper';
|
||||
import { getPosition } from './util';
|
||||
import { pipeCompleteItem } from '../../complete_items';
|
||||
import {
|
||||
EDITOR_MARKER,
|
||||
UNSUPPORTED_COMMANDS_BEFORE_MATCH,
|
||||
UNSUPPORTED_COMMANDS_BEFORE_QSTR,
|
||||
} from '../../../shared/constants';
|
||||
import { buildPartialMatcher, suggestForExpression } from '../../helper';
|
||||
|
||||
export async function suggest({
|
||||
innerText,
|
||||
command,
|
||||
getColumnsByType,
|
||||
getExpressionType,
|
||||
previousCommands,
|
||||
}: CommandSuggestParams<'where'>): Promise<SuggestionRawDefinition[]> {
|
||||
const suggestions: SuggestionRawDefinition[] = [];
|
||||
const isNullMatcher = buildPartialMatcher('is nul');
|
||||
const isNotNullMatcher = buildPartialMatcher('is not nul');
|
||||
|
||||
/**
|
||||
* The logic for WHERE suggestions is basically the logic for expression suggestions.
|
||||
* I assume we will eventually extract much of this to be a shared function among WHERE and EVAL
|
||||
* and anywhere else the user can enter a generic expression.
|
||||
*/
|
||||
const expressionRoot = command.args[0] as ESQLSingleAstItem | undefined;
|
||||
export async function suggest(
|
||||
params: CommandSuggestParams<'where'>
|
||||
): Promise<SuggestionRawDefinition[]> {
|
||||
const expressionRoot = params.command.args[0] as ESQLSingleAstItem | undefined;
|
||||
const suggestions = await suggestForExpression({
|
||||
...params,
|
||||
expressionRoot,
|
||||
commandName: 'where',
|
||||
});
|
||||
|
||||
const position = getPosition(innerText, command);
|
||||
switch (position) {
|
||||
/**
|
||||
* After a column name
|
||||
*/
|
||||
case 'after_column':
|
||||
const columnType = getExpressionType(expressionRoot);
|
||||
const isExpressionComplete =
|
||||
expressionRoot &&
|
||||
params.getExpressionType(expressionRoot) === 'boolean' &&
|
||||
// see https://github.com/elastic/kibana/issues/199401
|
||||
// for the reason we need this string check.
|
||||
!(isNullMatcher.test(params.innerText) || isNotNullMatcher.test(params.innerText));
|
||||
|
||||
if (!isParameterType(columnType)) {
|
||||
break;
|
||||
}
|
||||
|
||||
suggestions.push(
|
||||
...getOperatorSuggestions({
|
||||
command: 'where',
|
||||
leftParamType: columnType,
|
||||
// no assignments allowed in WHERE
|
||||
ignored: ['='],
|
||||
})
|
||||
);
|
||||
break;
|
||||
|
||||
/**
|
||||
* After a complete (non-operator) function call
|
||||
*/
|
||||
case 'after_function':
|
||||
const returnType = getExpressionType(expressionRoot);
|
||||
|
||||
if (!isParameterType(returnType)) {
|
||||
break;
|
||||
}
|
||||
|
||||
suggestions.push(
|
||||
...getOperatorSuggestions({
|
||||
command: 'where',
|
||||
leftParamType: returnType,
|
||||
ignored: ['='],
|
||||
})
|
||||
);
|
||||
|
||||
break;
|
||||
|
||||
/**
|
||||
* After a NOT keyword
|
||||
*
|
||||
* the NOT function is a special operator that can be used in different ways,
|
||||
* and not all these are mapped within the AST data structure: in particular
|
||||
* <COMMAND> <field> NOT <here>
|
||||
* is an incomplete statement and it results in a missing AST node, so we need to detect
|
||||
* from the query string itself
|
||||
*
|
||||
* (this comment was copied but seems to still apply)
|
||||
*/
|
||||
case 'after_not':
|
||||
if (expressionRoot && isFunctionItem(expressionRoot) && expressionRoot.name === 'not') {
|
||||
suggestions.push(
|
||||
...getFunctionSuggestions({ command: 'where', returnTypes: ['boolean'] }),
|
||||
...(await getColumnsByType('boolean', [], { advanceCursor: true, openSuggestions: true }))
|
||||
);
|
||||
} else {
|
||||
suggestions.push(...getSuggestionsAfterNot());
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
/**
|
||||
* After an operator (e.g. AND, OR, IS NULL, +, etc.)
|
||||
*/
|
||||
case 'after_operator':
|
||||
if (!expressionRoot) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!isFunctionItem(expressionRoot) || expressionRoot.subtype === 'variadic-call') {
|
||||
// this is already guaranteed in the getPosition function, but TypeScript doesn't know
|
||||
break;
|
||||
}
|
||||
|
||||
let rightmostOperator = expressionRoot;
|
||||
// get rightmost function
|
||||
const walker = new Walker({
|
||||
visitFunction: (fn: ESQLFunction) => {
|
||||
if (fn.location.min > rightmostOperator.location.min && fn.subtype !== 'variadic-call')
|
||||
rightmostOperator = fn;
|
||||
},
|
||||
});
|
||||
walker.walkFunction(expressionRoot);
|
||||
|
||||
// See https://github.com/elastic/kibana/issues/199401 for an explanation of
|
||||
// why this check has to be so convoluted
|
||||
if (rightmostOperator.text.toLowerCase().trim().endsWith('null')) {
|
||||
suggestions.push(...logicalOperators.map(getOperatorSuggestion));
|
||||
break;
|
||||
}
|
||||
|
||||
suggestions.push(
|
||||
...(await getSuggestionsToRightOfOperatorExpression({
|
||||
queryText: innerText,
|
||||
commandName: 'where',
|
||||
rootOperator: rightmostOperator,
|
||||
preferredExpressionType: 'boolean',
|
||||
getExpressionType,
|
||||
getColumnsByType,
|
||||
}))
|
||||
);
|
||||
|
||||
break;
|
||||
|
||||
case 'empty_expression':
|
||||
// Don't suggest MATCH, QSTR or KQL after unsupported commands
|
||||
const priorCommands = previousCommands?.map((a) => a.name) ?? [];
|
||||
const ignored = [];
|
||||
if (priorCommands.some((c) => UNSUPPORTED_COMMANDS_BEFORE_MATCH.has(c))) {
|
||||
ignored.push('match');
|
||||
}
|
||||
if (priorCommands.some((c) => UNSUPPORTED_COMMANDS_BEFORE_QSTR.has(c))) {
|
||||
ignored.push('kql', 'qstr');
|
||||
}
|
||||
const last = previousCommands?.[previousCommands.length - 1];
|
||||
let columnSuggestions: SuggestionRawDefinition[] = [];
|
||||
if (!last?.text?.endsWith(`:${EDITOR_MARKER}`)) {
|
||||
columnSuggestions = await getColumnsByType('any', [], {
|
||||
advanceCursor: true,
|
||||
openSuggestions: true,
|
||||
});
|
||||
}
|
||||
suggestions.push(
|
||||
...columnSuggestions,
|
||||
...getFunctionSuggestions({ command: 'where', ignored })
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
// Is this a complete expression of the right type?
|
||||
// Is this a complete boolean expression?
|
||||
// If so, we can call it done and suggest a pipe
|
||||
if (getExpressionType(expressionRoot) === 'boolean') {
|
||||
if (isExpressionComplete) {
|
||||
suggestions.push(pipeCompleteItem);
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach replacement ranges if there's a prefix.
|
||||
*
|
||||
* Can't rely on Monaco because
|
||||
* - it counts "." as a word separator
|
||||
* - it doesn't handle multi-word completions (like "is null")
|
||||
*
|
||||
* TODO - think about how to generalize this.
|
||||
*/
|
||||
const hasNonWhitespacePrefix = !/\s/.test(innerText[innerText.length - 1]);
|
||||
if (hasNonWhitespacePrefix) {
|
||||
// get index of first char of final word
|
||||
const lastWhitespaceIndex = innerText.search(/\S(?=\S*$)/);
|
||||
suggestions.forEach((s) => {
|
||||
if (['IS NULL', 'IS NOT NULL'].includes(s.text)) {
|
||||
// this suggestion has spaces in it (e.g. "IS NOT NULL")
|
||||
// so we need to see if there's an overlap
|
||||
const overlap = getOverlapRange(innerText, s.text);
|
||||
if (overlap.start < overlap.end) {
|
||||
// there's an overlap so use that
|
||||
s.rangeToReplace = overlap;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// no overlap, so just replace from the last whitespace
|
||||
s.rangeToReplace = {
|
||||
start: lastWhitespaceIndex + 1,
|
||||
end: innerText.length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
|
|
@ -1,52 +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 { ESQLCommand, ESQLSingleAstItem } from '@kbn/esql-ast';
|
||||
import { isColumnItem, isFunctionItem } from '../../../shared/helpers';
|
||||
|
||||
export type CaretPosition =
|
||||
| 'after_column'
|
||||
| 'after_function'
|
||||
| 'after_not'
|
||||
| 'after_operator'
|
||||
| 'empty_expression';
|
||||
|
||||
export const getPosition = (innerText: string, command: ESQLCommand): CaretPosition => {
|
||||
const expressionRoot = command.args[0] as ESQLSingleAstItem | undefined;
|
||||
|
||||
const endsWithNot = / not$/i.test(innerText.trimEnd());
|
||||
if (
|
||||
endsWithNot &&
|
||||
!(
|
||||
expressionRoot &&
|
||||
isFunctionItem(expressionRoot) &&
|
||||
// See https://github.com/elastic/kibana/issues/199401
|
||||
// for more information on this check...
|
||||
['is null', 'is not null'].includes(expressionRoot.name)
|
||||
)
|
||||
) {
|
||||
return 'after_not';
|
||||
}
|
||||
|
||||
if (expressionRoot) {
|
||||
if (isColumnItem(expressionRoot)) {
|
||||
return 'after_column';
|
||||
}
|
||||
|
||||
if (isFunctionItem(expressionRoot) && expressionRoot.subtype === 'variadic-call') {
|
||||
return 'after_function';
|
||||
}
|
||||
|
||||
if (isFunctionItem(expressionRoot) && expressionRoot.subtype !== 'variadic-call') {
|
||||
return 'after_operator';
|
||||
}
|
||||
}
|
||||
|
||||
return 'empty_expression';
|
||||
};
|
|
@ -7,7 +7,7 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { getOverlapRange } from './helper';
|
||||
import { buildPartialMatcher, getOverlapRange } from './helper';
|
||||
|
||||
describe('getOverlapRange', () => {
|
||||
it('should return the overlap range', () => {
|
||||
|
@ -22,3 +22,18 @@ describe('getOverlapRange', () => {
|
|||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildPartialMatcher', () => {
|
||||
it('should build a partial matcher', () => {
|
||||
const str = 'is NoT nulL';
|
||||
const matcher = buildPartialMatcher(str);
|
||||
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
expect(matcher.test(str.slice(0, i + 1))).toEqual(true);
|
||||
}
|
||||
|
||||
expect(matcher.test('not')).toEqual(false);
|
||||
expect(matcher.test('is null')).toEqual(false);
|
||||
expect(matcher.test('is not nullz')).toEqual(false);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -14,6 +14,8 @@ import {
|
|||
type ESQLFunction,
|
||||
type ESQLLiteral,
|
||||
type ESQLSource,
|
||||
ESQLSingleAstItem,
|
||||
Walker,
|
||||
} from '@kbn/esql-ast';
|
||||
import { uniqBy } from 'lodash';
|
||||
import { ESQLVariableType } from '@kbn/esql-types';
|
||||
|
@ -24,6 +26,7 @@ import {
|
|||
type SupportedDataType,
|
||||
isReturnType,
|
||||
FunctionDefinitionTypes,
|
||||
CommandSuggestParams,
|
||||
} from '../definitions/types';
|
||||
import {
|
||||
findFinalWord,
|
||||
|
@ -45,11 +48,18 @@ import {
|
|||
getCompatibleLiterals,
|
||||
getDateLiterals,
|
||||
getOperatorSuggestions,
|
||||
getSuggestionsAfterNot,
|
||||
getOperatorSuggestion,
|
||||
} from './factories';
|
||||
import { EDITOR_MARKER } from '../shared/constants';
|
||||
import {
|
||||
EDITOR_MARKER,
|
||||
UNSUPPORTED_COMMANDS_BEFORE_MATCH,
|
||||
UNSUPPORTED_COMMANDS_BEFORE_QSTR,
|
||||
} from '../shared/constants';
|
||||
import { ESQLRealField, ESQLVariable, ReferenceMaps } from '../validation/types';
|
||||
import { listCompleteItem } from './complete_items';
|
||||
import { removeMarkerArgFromArgsList } from '../shared/context';
|
||||
import { logicalOperators } from '../definitions/all_operators';
|
||||
|
||||
function extractFunctionArgs(args: ESQLAstItem[]): ESQLFunction[] {
|
||||
return args.flatMap((arg) => (isAssignment(arg) ? arg.args[1] : arg)).filter(isFunctionItem);
|
||||
|
@ -532,7 +542,8 @@ export function checkFunctionInvocationComplete(
|
|||
const hasCorrectTypes = fnDefinition.signatures.some((def) => {
|
||||
return func.args.every((a, index) => {
|
||||
return (
|
||||
(fnDefinition.name.endsWith('null') && def.params[index].type === 'any') ||
|
||||
fnDefinition.name.endsWith('null') ||
|
||||
def.params[index].type === 'any' ||
|
||||
def.params[index].type === getExpressionType(a)
|
||||
);
|
||||
});
|
||||
|
@ -680,3 +691,271 @@ export async function getSuggestionsToRightOfOperatorExpression({
|
|||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* The position of the cursor within an expression.
|
||||
*/
|
||||
type ExpressionPosition =
|
||||
| 'after_column'
|
||||
| 'after_function'
|
||||
| 'after_not'
|
||||
| 'after_operator'
|
||||
| 'after_literal'
|
||||
| 'empty_expression';
|
||||
|
||||
/**
|
||||
* Determines the position of the cursor within an expression.
|
||||
* @param innerText
|
||||
* @param expressionRoot
|
||||
* @returns
|
||||
*/
|
||||
export const getExpressionPosition = (
|
||||
innerText: string,
|
||||
expressionRoot: ESQLSingleAstItem | undefined
|
||||
): ExpressionPosition => {
|
||||
const endsWithNot = / not$/i.test(innerText.trimEnd());
|
||||
if (
|
||||
endsWithNot &&
|
||||
!(
|
||||
expressionRoot &&
|
||||
isFunctionItem(expressionRoot) &&
|
||||
// See https://github.com/elastic/kibana/issues/199401
|
||||
// for more information on this check...
|
||||
['is null', 'is not null'].includes(expressionRoot.name)
|
||||
)
|
||||
) {
|
||||
return 'after_not';
|
||||
}
|
||||
|
||||
if (expressionRoot) {
|
||||
if (isColumnItem(expressionRoot)) {
|
||||
return 'after_column';
|
||||
}
|
||||
|
||||
if (isFunctionItem(expressionRoot) && expressionRoot.subtype === 'variadic-call') {
|
||||
return 'after_function';
|
||||
}
|
||||
|
||||
if (isFunctionItem(expressionRoot) && expressionRoot.subtype !== 'variadic-call') {
|
||||
return 'after_operator';
|
||||
}
|
||||
|
||||
if (isLiteralItem(expressionRoot) || isTimeIntervalItem(expressionRoot)) {
|
||||
return 'after_literal';
|
||||
}
|
||||
}
|
||||
|
||||
return 'empty_expression';
|
||||
};
|
||||
|
||||
/**
|
||||
* Creates suggestion within an expression.
|
||||
*
|
||||
* TODO — should this function know about the command context
|
||||
* or would we prefer a set of generic configuration options?
|
||||
*
|
||||
* @param param0
|
||||
* @returns
|
||||
*/
|
||||
export async function suggestForExpression({
|
||||
expressionRoot,
|
||||
innerText,
|
||||
getExpressionType,
|
||||
getColumnsByType,
|
||||
previousCommands,
|
||||
commandName,
|
||||
}: {
|
||||
expressionRoot: ESQLSingleAstItem | undefined;
|
||||
commandName: string;
|
||||
} & Pick<
|
||||
CommandSuggestParams<string>,
|
||||
'innerText' | 'getExpressionType' | 'getColumnsByType' | 'previousCommands'
|
||||
>): Promise<SuggestionRawDefinition[]> {
|
||||
const suggestions: SuggestionRawDefinition[] = [];
|
||||
|
||||
const position = getExpressionPosition(innerText, expressionRoot);
|
||||
switch (position) {
|
||||
/**
|
||||
* After a literal, column, or complete (non-operator) function call
|
||||
*/
|
||||
case 'after_literal':
|
||||
case 'after_column':
|
||||
case 'after_function':
|
||||
const expressionType = getExpressionType(expressionRoot);
|
||||
|
||||
if (!isParameterType(expressionType)) {
|
||||
break;
|
||||
}
|
||||
|
||||
suggestions.push(
|
||||
...getOperatorSuggestions({
|
||||
command: commandName,
|
||||
leftParamType: expressionType,
|
||||
ignored: ['='],
|
||||
})
|
||||
);
|
||||
|
||||
break;
|
||||
|
||||
/**
|
||||
* After a NOT keyword
|
||||
*
|
||||
* the NOT function is a special operator that can be used in different ways,
|
||||
* and not all these are mapped within the AST data structure: in particular
|
||||
* <COMMAND> <field> NOT <here>
|
||||
* is an incomplete statement and it results in a missing AST node, so we need to detect
|
||||
* from the query string itself
|
||||
*
|
||||
* (this comment was copied but seems to still apply)
|
||||
*/
|
||||
case 'after_not':
|
||||
if (expressionRoot && isFunctionItem(expressionRoot) && expressionRoot.name === 'not') {
|
||||
suggestions.push(
|
||||
...getFunctionSuggestions({ command: commandName, returnTypes: ['boolean'] }),
|
||||
...(await getColumnsByType('boolean', [], { advanceCursor: true, openSuggestions: true }))
|
||||
);
|
||||
} else {
|
||||
suggestions.push(...getSuggestionsAfterNot());
|
||||
}
|
||||
|
||||
break;
|
||||
|
||||
/**
|
||||
* After an operator (e.g. AND, OR, IS NULL, +, etc.)
|
||||
*/
|
||||
case 'after_operator':
|
||||
if (!expressionRoot) {
|
||||
break;
|
||||
}
|
||||
|
||||
if (!isFunctionItem(expressionRoot) || expressionRoot.subtype === 'variadic-call') {
|
||||
// this is already guaranteed in the getPosition function, but TypeScript doesn't know
|
||||
break;
|
||||
}
|
||||
|
||||
let rightmostOperator = expressionRoot;
|
||||
// get rightmost function
|
||||
const walker = new Walker({
|
||||
visitFunction: (fn: ESQLFunction) => {
|
||||
if (fn.location.min > rightmostOperator.location.min && fn.subtype !== 'variadic-call')
|
||||
rightmostOperator = fn;
|
||||
},
|
||||
});
|
||||
walker.walkFunction(expressionRoot);
|
||||
|
||||
// See https://github.com/elastic/kibana/issues/199401 for an explanation of
|
||||
// why this check has to be so convoluted
|
||||
if (rightmostOperator.text.toLowerCase().trim().endsWith('null')) {
|
||||
suggestions.push(...logicalOperators.map(getOperatorSuggestion));
|
||||
break;
|
||||
}
|
||||
|
||||
suggestions.push(
|
||||
...(await getSuggestionsToRightOfOperatorExpression({
|
||||
queryText: innerText,
|
||||
commandName,
|
||||
rootOperator: rightmostOperator,
|
||||
preferredExpressionType: commandName === 'where' ? 'boolean' : undefined,
|
||||
getExpressionType,
|
||||
getColumnsByType,
|
||||
}))
|
||||
);
|
||||
|
||||
break;
|
||||
|
||||
case 'empty_expression':
|
||||
// Don't suggest MATCH, QSTR or KQL after unsupported commands
|
||||
const priorCommands = previousCommands?.map((a) => a.name) ?? [];
|
||||
const ignored = [];
|
||||
if (priorCommands.some((c) => UNSUPPORTED_COMMANDS_BEFORE_MATCH.has(c))) {
|
||||
ignored.push('match');
|
||||
}
|
||||
if (priorCommands.some((c) => UNSUPPORTED_COMMANDS_BEFORE_QSTR.has(c))) {
|
||||
ignored.push('kql', 'qstr');
|
||||
}
|
||||
const last = previousCommands?.[previousCommands.length - 1];
|
||||
let columnSuggestions: SuggestionRawDefinition[] = [];
|
||||
if (!last?.text?.endsWith(`:${EDITOR_MARKER}`)) {
|
||||
columnSuggestions = await getColumnsByType('any', [], {
|
||||
advanceCursor: true,
|
||||
openSuggestions: true,
|
||||
});
|
||||
}
|
||||
suggestions.push(
|
||||
...pushItUpInTheList(columnSuggestions, true),
|
||||
...getFunctionSuggestions({ command: commandName, ignored })
|
||||
);
|
||||
|
||||
break;
|
||||
}
|
||||
|
||||
/**
|
||||
* Attach replacement ranges if there's a prefix.
|
||||
*
|
||||
* Can't rely on Monaco because
|
||||
* - it counts "." as a word separator
|
||||
* - it doesn't handle multi-word completions (like "is null")
|
||||
*
|
||||
* TODO - think about how to generalize this — issue: https://github.com/elastic/kibana/issues/209905
|
||||
*/
|
||||
const hasNonWhitespacePrefix = !/\s/.test(innerText[innerText.length - 1]);
|
||||
if (hasNonWhitespacePrefix) {
|
||||
// get index of first char of final word
|
||||
const lastWhitespaceIndex = innerText.search(/\S(?=\S*$)/);
|
||||
suggestions.forEach((s) => {
|
||||
if (['IS NULL', 'IS NOT NULL'].includes(s.text)) {
|
||||
// this suggestion has spaces in it (e.g. "IS NOT NULL")
|
||||
// so we need to see if there's an overlap
|
||||
const overlap = getOverlapRange(innerText, s.text);
|
||||
if (overlap.start < overlap.end) {
|
||||
// there's an overlap so use that
|
||||
s.rangeToReplace = overlap;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// no overlap, so just replace from the last whitespace
|
||||
s.rangeToReplace = {
|
||||
start: lastWhitespaceIndex + 1,
|
||||
end: innerText.length,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a regex that matches partial strings starting
|
||||
* from the beginning of the string.
|
||||
*
|
||||
* Example:
|
||||
* "is null" -> /^i(?:s(?:\s+(?:n(?:u(?:l(?:l)?)?)?)?)?)?$/i
|
||||
*/
|
||||
export function buildPartialMatcher(str: string) {
|
||||
// Split the string into characters
|
||||
const chars = str.split('');
|
||||
|
||||
// Initialize the regex pattern
|
||||
let pattern = '';
|
||||
|
||||
// Iterate through the characters and build the pattern
|
||||
chars.forEach((char, index) => {
|
||||
if (char === ' ') {
|
||||
pattern += '\\s+';
|
||||
} else {
|
||||
pattern += char;
|
||||
}
|
||||
if (index < chars.length - 1) {
|
||||
pattern += '(?:';
|
||||
}
|
||||
});
|
||||
|
||||
// Close the non-capturing groups
|
||||
for (let i = 0; i < chars.length - 1; i++) {
|
||||
pattern += ')?';
|
||||
}
|
||||
|
||||
// Return the final regex pattern
|
||||
return new RegExp(pattern + '$', 'i');
|
||||
}
|
||||
|
|
|
@ -30,21 +30,22 @@ import { ENRICH_MODES } from './settings';
|
|||
import { type CommandDefinition } from './types';
|
||||
import { checkAggExistence, checkFunctionContent } from './commands_helpers';
|
||||
|
||||
import { suggest as suggestForSort } from '../autocomplete/commands/sort';
|
||||
import { suggest as suggestForKeep } from '../autocomplete/commands/keep';
|
||||
import { suggest as suggestForDrop } from '../autocomplete/commands/drop';
|
||||
import { suggest as suggestForStats } from '../autocomplete/commands/stats';
|
||||
import { suggest as suggestForWhere } from '../autocomplete/commands/where';
|
||||
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';
|
||||
import { suggest as suggestForDrop } from '../autocomplete/commands/drop';
|
||||
import { suggest as suggestForEnrich } from '../autocomplete/commands/enrich';
|
||||
import { suggest as suggestForRename } from '../autocomplete/commands/rename';
|
||||
import { suggest as suggestForEval } from '../autocomplete/commands/eval';
|
||||
import { suggest as suggestForFrom } from '../autocomplete/commands/from';
|
||||
import { suggest as suggestForGrok } from '../autocomplete/commands/grok';
|
||||
import { suggest as suggestForJoin } from '../autocomplete/commands/join';
|
||||
import { suggest as suggestForKeep } from '../autocomplete/commands/keep';
|
||||
import { suggest as suggestForLimit } from '../autocomplete/commands/limit';
|
||||
import { suggest as suggestForMvExpand } from '../autocomplete/commands/mv_expand';
|
||||
import { suggest as suggestForRename } from '../autocomplete/commands/rename';
|
||||
import { suggest as suggestForRow } from '../autocomplete/commands/row';
|
||||
import { suggest as suggestForShow } from '../autocomplete/commands/show';
|
||||
import { suggest as suggestForSort } from '../autocomplete/commands/sort';
|
||||
import { suggest as suggestForStats } from '../autocomplete/commands/stats';
|
||||
import { suggest as suggestForWhere } from '../autocomplete/commands/where';
|
||||
|
||||
const statsValidator = (command: ESQLCommand) => {
|
||||
const messages: ESQLMessage[] = [];
|
||||
|
@ -207,6 +208,7 @@ export const commandDefinitions: Array<CommandDefinition<any>> = [
|
|||
{ name: 'expression', type: 'function', optional: true },
|
||||
],
|
||||
},
|
||||
suggest: () => [],
|
||||
},
|
||||
{
|
||||
name: 'stats',
|
||||
|
@ -243,6 +245,7 @@ export const commandDefinitions: Array<CommandDefinition<any>> = [
|
|||
modes: [],
|
||||
// Reusing the same validation logic as stats command
|
||||
validate: statsValidator,
|
||||
suggest: () => [],
|
||||
},
|
||||
|
||||
{
|
||||
|
@ -263,6 +266,7 @@ export const commandDefinitions: Array<CommandDefinition<any>> = [
|
|||
},
|
||||
options: [],
|
||||
modes: [],
|
||||
suggest: suggestForEval,
|
||||
},
|
||||
{
|
||||
name: 'rename',
|
||||
|
@ -476,6 +480,7 @@ export const commandDefinitions: Array<CommandDefinition<any>> = [
|
|||
params: [],
|
||||
multipleParams: false,
|
||||
},
|
||||
suggest: () => [],
|
||||
},
|
||||
{
|
||||
name: 'join',
|
||||
|
|
|
@ -280,6 +280,9 @@ export type CommandSuggestFunction<CommandName extends string> = (
|
|||
params: CommandSuggestParams<CommandName>
|
||||
) => Promise<SuggestionRawDefinition[]> | SuggestionRawDefinition[];
|
||||
|
||||
/**
|
||||
* @deprecated — use CommandDefinition instead
|
||||
*/
|
||||
export interface CommandBaseDefinition<CommandName extends string> {
|
||||
name: CommandName;
|
||||
|
||||
|
@ -298,7 +301,6 @@ export interface CommandBaseDefinition<CommandName extends string> {
|
|||
* Whether to show or hide in autocomplete suggestion list
|
||||
*/
|
||||
hidden?: boolean;
|
||||
suggest?: CommandSuggestFunction<CommandName>;
|
||||
/** @deprecated this property will disappear in the future */
|
||||
signature: {
|
||||
multipleParams: boolean;
|
||||
|
@ -322,6 +324,9 @@ export interface CommandTypeDefinition {
|
|||
description?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* @deprecated options are going away
|
||||
*/
|
||||
export interface CommandOptionsDefinition<CommandName extends string = string>
|
||||
extends CommandBaseDefinition<CommandName> {
|
||||
wrapped?: string[];
|
||||
|
@ -345,6 +350,7 @@ export interface CommandDefinition<CommandName extends string>
|
|||
extends CommandBaseDefinition<CommandName> {
|
||||
examples: string[];
|
||||
validate?: (option: ESQLCommand) => ESQLMessage[];
|
||||
suggest: CommandSuggestFunction<CommandName>;
|
||||
/** @deprecated this property will disappear in the future */
|
||||
modes: CommandModeDefinition[];
|
||||
/** @deprecated this property will disappear in the future */
|
||||
|
|
|
@ -121,8 +121,19 @@ function findAstPosition(ast: ESQLAst, offset: number) {
|
|||
if (!command) {
|
||||
return { command: undefined, node: undefined };
|
||||
}
|
||||
|
||||
const containingFunction = Walker.findAll(
|
||||
command,
|
||||
(node) =>
|
||||
node.type === 'function' &&
|
||||
node.subtype === 'variadic-call' &&
|
||||
node.location?.min <= offset &&
|
||||
node.location?.max >= offset
|
||||
).pop() as ESQLFunction | undefined;
|
||||
|
||||
return {
|
||||
command: removeMarkerArgFromArgsList(command)!,
|
||||
containingFunction: removeMarkerArgFromArgsList(containingFunction),
|
||||
option: removeMarkerArgFromArgsList(findOption(command.args, offset)),
|
||||
node: removeMarkerArgFromArgsList(cleanMarkerNode(findNode(command.args, offset))),
|
||||
};
|
||||
|
@ -162,16 +173,16 @@ export function getAstContext(queryString: string, ast: ESQLAst, offset: number)
|
|||
};
|
||||
}
|
||||
|
||||
const { command, option, node } = findAstPosition(ast, offset);
|
||||
const { command, option, node, containingFunction } = findAstPosition(ast, offset);
|
||||
if (node) {
|
||||
if (node.type === 'literal' && node.literalType === 'keyword') {
|
||||
// command ... "<here>"
|
||||
return { type: 'value' as const, command, node, option };
|
||||
return { type: 'value' as const, command, node, option, containingFunction };
|
||||
}
|
||||
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 };
|
||||
return { type: 'list' as const, command, node, option, containingFunction };
|
||||
}
|
||||
if (
|
||||
isNotEnrichClauseAssigment(node, command) &&
|
||||
|
@ -182,19 +193,20 @@ 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 };
|
||||
return { type: 'function' as const, command, node, option, containingFunction };
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!command || (queryString.length <= offset && pipePrecedesCurrentWord(queryString))) {
|
||||
// // ... | <here>
|
||||
return { type: 'newCommand' as const, command: undefined, node, option };
|
||||
return { type: 'newCommand' as const, command: undefined, node, option, containingFunction };
|
||||
}
|
||||
|
||||
// command a ... <here> OR command a = ... <here>
|
||||
return {
|
||||
type: 'expression' as const,
|
||||
command,
|
||||
containingFunction,
|
||||
option,
|
||||
node,
|
||||
};
|
||||
|
|
|
@ -111,7 +111,7 @@ export function isIncompleteItem(arg: ESQLAstItem): boolean {
|
|||
return !arg || (!Array.isArray(arg) && arg.incomplete);
|
||||
}
|
||||
|
||||
export function isMathFunction(query: string, offset: number) {
|
||||
function isMathFunction(query: string) {
|
||||
const queryTrimmed = query.trimEnd();
|
||||
// try to get the full operation token (e.g. "+", "in", "like", etc...) but it requires the token
|
||||
// to be spaced out from a field/function (e.g. "field + ") so it is subject to issues
|
||||
|
@ -764,8 +764,8 @@ export function correctQuerySyntax(_query: string, context: EditorContext) {
|
|||
(context.triggerCharacter && charThatNeedMarkers.includes(context.triggerCharacter)) ||
|
||||
// monaco.editor.CompletionTriggerKind['Invoke'] === 0
|
||||
(context.triggerKind === 0 && unclosedRoundBracketCount === 0) ||
|
||||
(context.triggerCharacter === ' ' && isMathFunction(query, query.length)) ||
|
||||
isComma(query.trimEnd()[query.trimEnd().length - 1])
|
||||
isMathFunction(query) ||
|
||||
/,\s+$/.test(query)
|
||||
) {
|
||||
query += EDITOR_MARKER;
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue