mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[ES|QL] Make getActions
more resilient to lack of callbacks (#180260)
## Summary Now `getActions` provides some more fixes in case of lack of callabacks, like with unquoted fields. The feature is still experimental and applies only to unquoted fields (disabling the existence check on quoted fields). ### Checklist - [x] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios
This commit is contained in:
parent
069d814fe4
commit
c2adb13ee9
6 changed files with 476 additions and 164 deletions
|
@ -25,8 +25,8 @@ export class ESQLErrorListener extends ErrorListener<any> {
|
|||
const textMessage = `SyntaxError: ${message}`;
|
||||
|
||||
const tokenPosition = getPosition(offendingSymbol);
|
||||
const startColumn = tokenPosition?.min + 1 || column;
|
||||
const endColumn = tokenPosition?.max + 1 || column + 1;
|
||||
const startColumn = offendingSymbol && tokenPosition ? tokenPosition.min + 1 : column + 1;
|
||||
const endColumn = offendingSymbol && tokenPosition ? tokenPosition.max + 1 : column + 2;
|
||||
|
||||
this.errors.push({
|
||||
startLineNumber: line,
|
||||
|
|
|
@ -116,6 +116,7 @@ const {title, edits} = await getActions(
|
|||
queryString,
|
||||
errors,
|
||||
getAstAndSyntaxErrors,
|
||||
undefined,
|
||||
myCallbacks
|
||||
);
|
||||
|
||||
|
@ -124,6 +125,33 @@ const {title, edits} = await getActions(
|
|||
console.log({ title, edits });
|
||||
```
|
||||
|
||||
Like with validation also `getActions` can 'relax' its internal checks when no callbacks, either all or specific ones, are passed.
|
||||
|
||||
```js
|
||||
import { getAstAndSyntaxErrors } from '@kbn/esql-ast';
|
||||
import { validateQuery, getActions } from '@kbn/esql-validation-autocomplete';
|
||||
|
||||
const queryString = "from index2 | keep unquoted-field"
|
||||
|
||||
const myCallbacks = {
|
||||
getSources: async () => [{name: 'index', hidden: false}],
|
||||
...
|
||||
};
|
||||
const { errors, warnings } = await validateQuery(queryString, getAstAndSyntaxErrors, undefined, myCallbacks);
|
||||
|
||||
const {title, edits} = await getActions(
|
||||
queryString,
|
||||
errors,
|
||||
getAstAndSyntaxErrors,
|
||||
{ relaxOnMissingCallbacks: true },
|
||||
myCallbacks
|
||||
);
|
||||
|
||||
console.log(edits[0].text); // => `unquoted-field`
|
||||
```
|
||||
|
||||
**Note**: this behaviour is still experimental, and applied for few error types, like the unquoted fields case.
|
||||
|
||||
### getAstContext
|
||||
|
||||
This is an important function in order to build more features on top of the existing one.
|
||||
|
|
|
@ -10,6 +10,7 @@ import { getActions } from './actions';
|
|||
import { validateQuery } from '../validation/validation';
|
||||
import { getAllFunctions } from '../shared/helpers';
|
||||
import { getAstAndSyntaxErrors } from '@kbn/esql-ast';
|
||||
import { CodeActionOptions } from './types';
|
||||
|
||||
function getCallbackMocks() {
|
||||
return {
|
||||
|
@ -60,6 +61,8 @@ function getCallbackMocks() {
|
|||
};
|
||||
}
|
||||
|
||||
export type Simplify<T> = { [KeyType in keyof T]: T[KeyType] } & {};
|
||||
|
||||
/**
|
||||
* There are different wats to test the code here: one is a direct unit test of the feature, another is
|
||||
* an integration test passing from the query statement validation. The latter is more realistic, but
|
||||
|
@ -68,27 +71,41 @@ function getCallbackMocks() {
|
|||
function testQuickFixesFn(
|
||||
statement: string,
|
||||
expectedFixes: string[] = [],
|
||||
options: { equalityCheck?: 'include' | 'equal' } = {},
|
||||
options: Simplify<{ equalityCheck?: 'include' | 'equal' } & CodeActionOptions> = {},
|
||||
{ only, skip }: { only?: boolean; skip?: boolean } = {}
|
||||
) {
|
||||
const testFn = only ? it.only : skip ? it.skip : it;
|
||||
testFn(`${statement} => ["${expectedFixes.join('","')}"]`, async () => {
|
||||
const callbackMocks = getCallbackMocks();
|
||||
const { errors } = await validateQuery(
|
||||
statement,
|
||||
getAstAndSyntaxErrors,
|
||||
undefined,
|
||||
callbackMocks
|
||||
);
|
||||
testFn(
|
||||
`${statement} => ["${expectedFixes.join('","')}"]${
|
||||
options.relaxOnMissingCallbacks != null
|
||||
? ` (Relaxed = ${options.relaxOnMissingCallbacks})`
|
||||
: ''
|
||||
} `,
|
||||
async () => {
|
||||
const callbackMocks = getCallbackMocks();
|
||||
const { errors } = await validateQuery(
|
||||
statement,
|
||||
getAstAndSyntaxErrors,
|
||||
undefined,
|
||||
callbackMocks
|
||||
);
|
||||
const { equalityCheck, ...fnOptions } = options || {};
|
||||
|
||||
const actions = await getActions(statement, errors, getAstAndSyntaxErrors, callbackMocks);
|
||||
const edits = actions.map(({ edits: actionEdits }) => actionEdits[0].text);
|
||||
expect(edits).toEqual(
|
||||
!options || !options.equalityCheck || options.equalityCheck === 'equal'
|
||||
? expectedFixes
|
||||
: expect.arrayContaining(expectedFixes)
|
||||
);
|
||||
});
|
||||
const actions = await getActions(
|
||||
statement,
|
||||
errors,
|
||||
getAstAndSyntaxErrors,
|
||||
fnOptions,
|
||||
callbackMocks
|
||||
);
|
||||
const edits = actions.map(({ edits: actionEdits }) => actionEdits[0].text);
|
||||
expect(edits).toEqual(
|
||||
!equalityCheck || equalityCheck === 'equal'
|
||||
? expectedFixes
|
||||
: expect.arrayContaining(expectedFixes)
|
||||
);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
type TestArgs = [string, string[], { equalityCheck?: 'include' | 'equal' }?];
|
||||
|
@ -111,84 +128,128 @@ const testQuickFixes = Object.assign(testQuickFixesFn, {
|
|||
|
||||
describe('quick fixes logic', () => {
|
||||
describe('fixing index spellchecks', () => {
|
||||
// No error, no quick action
|
||||
testQuickFixes('FROM index', []);
|
||||
testQuickFixes('FROM index2', ['index']);
|
||||
testQuickFixes('FROM myindex', ['index', 'my-index']);
|
||||
// wildcards
|
||||
testQuickFixes('FROM index*', []);
|
||||
testQuickFixes('FROM ind*', []);
|
||||
testQuickFixes('FROM end*', ['ind*']);
|
||||
testQuickFixes('FROM endex*', ['index']);
|
||||
// Too far for the levenstein distance and should not fix with a hidden index
|
||||
testQuickFixes('FROM secretIndex', []);
|
||||
testQuickFixes('FROM secretIndex2', []);
|
||||
for (const options of [
|
||||
undefined,
|
||||
{ relaxOnMissingCallbacks: false },
|
||||
{ relaxOnMissingCallbacks: false },
|
||||
]) {
|
||||
// No error, no quick action
|
||||
testQuickFixes('FROM index', [], options);
|
||||
testQuickFixes('FROM index2', ['index'], options);
|
||||
testQuickFixes('FROM myindex', ['index', 'my-index'], options);
|
||||
// wildcards
|
||||
testQuickFixes('FROM index*', [], options);
|
||||
testQuickFixes('FROM ind*', [], options);
|
||||
testQuickFixes('FROM end*', ['ind*']);
|
||||
testQuickFixes('FROM endex*', ['index'], options);
|
||||
// Too far for the levenstein distance and should not fix with a hidden index
|
||||
testQuickFixes('FROM secretIndex', [], options);
|
||||
testQuickFixes('FROM secretIndex2', [], options);
|
||||
}
|
||||
});
|
||||
|
||||
describe('fixing fields spellchecks', () => {
|
||||
for (const command of ['KEEP', 'DROP', 'EVAL']) {
|
||||
testQuickFixes(`FROM index | ${command} stringField`, []);
|
||||
// strongField => stringField
|
||||
testQuickFixes(`FROM index | ${command} strongField`, ['stringField']);
|
||||
testQuickFixes(`FROM index | ${command} numberField, strongField`, ['stringField']);
|
||||
}
|
||||
testQuickFixes(`FROM index | EVAL round(strongField)`, ['stringField']);
|
||||
testQuickFixes(`FROM index | EVAL var0 = round(strongField)`, ['stringField']);
|
||||
testQuickFixes(`FROM index | WHERE round(strongField) > 0`, ['stringField']);
|
||||
testQuickFixes(`FROM index | WHERE 0 < round(strongField)`, ['stringField']);
|
||||
testQuickFixes(`FROM index | RENAME strongField as newField`, ['stringField']);
|
||||
// This levarage the knowledge of the enrich policy fields to suggest the right field
|
||||
testQuickFixes(`FROM index | ENRICH policy | KEEP yetAnotherField2`, ['yetAnotherField']);
|
||||
testQuickFixes(`FROM index | ENRICH policy ON strongField`, ['stringField']);
|
||||
testQuickFixes(`FROM index | ENRICH policy ON stringField WITH yetAnotherField2`, [
|
||||
'yetAnotherField',
|
||||
]);
|
||||
|
||||
describe('metafields spellchecks', () => {
|
||||
for (const isWrapped of [true, false]) {
|
||||
function setWrapping(text: string) {
|
||||
return isWrapped ? `[${text}]` : text;
|
||||
}
|
||||
testQuickFixes(`FROM index ${setWrapping('metadata _i_ndex')}`, ['_index']);
|
||||
testQuickFixes(`FROM index ${setWrapping('metadata _id, _i_ndex')}`, ['_index']);
|
||||
testQuickFixes(`FROM index ${setWrapping('METADATA _id, _i_ndex')}`, ['_index']);
|
||||
for (const options of [
|
||||
undefined,
|
||||
{ relaxOnMissingCallbacks: false },
|
||||
{ relaxOnMissingCallbacks: false },
|
||||
]) {
|
||||
for (const command of ['KEEP', 'DROP', 'EVAL']) {
|
||||
testQuickFixes(`FROM index | ${command} stringField`, [], options);
|
||||
// strongField => stringField
|
||||
testQuickFixes(`FROM index | ${command} strongField`, ['stringField'], options);
|
||||
testQuickFixes(
|
||||
`FROM index | ${command} numberField, strongField`,
|
||||
['stringField'],
|
||||
options
|
||||
);
|
||||
}
|
||||
});
|
||||
testQuickFixes(`FROM index | EVAL round(strongField)`, ['stringField'], options);
|
||||
testQuickFixes(`FROM index | EVAL var0 = round(strongField)`, ['stringField'], options);
|
||||
testQuickFixes(`FROM index | WHERE round(strongField) > 0`, ['stringField'], options);
|
||||
testQuickFixes(`FROM index | WHERE 0 < round(strongField)`, ['stringField'], options);
|
||||
testQuickFixes(`FROM index | RENAME strongField as newField`, ['stringField'], options);
|
||||
// This levarage the knowledge of the enrich policy fields to suggest the right field
|
||||
testQuickFixes(
|
||||
`FROM index | ENRICH policy | KEEP yetAnotherField2`,
|
||||
['yetAnotherField'],
|
||||
options
|
||||
);
|
||||
testQuickFixes(`FROM index | ENRICH policy ON strongField`, ['stringField'], options);
|
||||
testQuickFixes(
|
||||
`FROM index | ENRICH policy ON stringField WITH yetAnotherField2`,
|
||||
['yetAnotherField'],
|
||||
options
|
||||
);
|
||||
|
||||
describe('metafields spellchecks', () => {
|
||||
for (const isWrapped of [true, false]) {
|
||||
function setWrapping(text: string) {
|
||||
return isWrapped ? `[${text}]` : text;
|
||||
}
|
||||
testQuickFixes(`FROM index ${setWrapping('metadata _i_ndex')}`, ['_index'], options);
|
||||
testQuickFixes(`FROM index ${setWrapping('metadata _id, _i_ndex')}`, ['_index'], options);
|
||||
testQuickFixes(`FROM index ${setWrapping('METADATA _id, _i_ndex')}`, ['_index'], options);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('fixing meta fields spellchecks', () => {
|
||||
for (const command of ['KEEP', 'DROP', 'EVAL']) {
|
||||
testQuickFixes(`FROM index | ${command} stringField`, []);
|
||||
// strongField => stringField
|
||||
testQuickFixes(`FROM index | ${command} strongField`, ['stringField']);
|
||||
testQuickFixes(`FROM index | ${command} numberField, strongField`, ['stringField']);
|
||||
for (const options of [
|
||||
undefined,
|
||||
{ relaxOnMissingCallbacks: false },
|
||||
{ relaxOnMissingCallbacks: false },
|
||||
]) {
|
||||
for (const command of ['KEEP', 'DROP', 'EVAL']) {
|
||||
testQuickFixes(`FROM index | ${command} stringField`, [], options);
|
||||
// strongField => stringField
|
||||
testQuickFixes(`FROM index | ${command} strongField`, ['stringField'], options);
|
||||
testQuickFixes(
|
||||
`FROM index | ${command} numberField, strongField`,
|
||||
['stringField'],
|
||||
options
|
||||
);
|
||||
}
|
||||
testQuickFixes(`FROM index | EVAL round(strongField)`, ['stringField'], options);
|
||||
testQuickFixes(`FROM index | EVAL var0 = round(strongField)`, ['stringField'], options);
|
||||
testQuickFixes(`FROM index | WHERE round(strongField) > 0`, ['stringField'], options);
|
||||
testQuickFixes(`FROM index | WHERE 0 < round(strongField)`, ['stringField'], options);
|
||||
testQuickFixes(`FROM index | RENAME strongField as newField`, ['stringField'], options);
|
||||
// This levarage the knowledge of the enrich policy fields to suggest the right field
|
||||
testQuickFixes(
|
||||
`FROM index | ENRICH policy | KEEP yetAnotherField2`,
|
||||
['yetAnotherField'],
|
||||
options
|
||||
);
|
||||
testQuickFixes(`FROM index | ENRICH policy ON strongField`, ['stringField'], options);
|
||||
testQuickFixes(
|
||||
`FROM index | ENRICH policy ON stringField WITH yetAnotherField2`,
|
||||
['yetAnotherField'],
|
||||
options
|
||||
);
|
||||
}
|
||||
testQuickFixes(`FROM index | EVAL round(strongField)`, ['stringField']);
|
||||
testQuickFixes(`FROM index | EVAL var0 = round(strongField)`, ['stringField']);
|
||||
testQuickFixes(`FROM index | WHERE round(strongField) > 0`, ['stringField']);
|
||||
testQuickFixes(`FROM index | WHERE 0 < round(strongField)`, ['stringField']);
|
||||
testQuickFixes(`FROM index | RENAME strongField as newField`, ['stringField']);
|
||||
// This levarage the knowledge of the enrich policy fields to suggest the right field
|
||||
testQuickFixes(`FROM index | ENRICH policy | KEEP yetAnotherField2`, ['yetAnotherField']);
|
||||
testQuickFixes(`FROM index | ENRICH policy ON strongField`, ['stringField']);
|
||||
testQuickFixes(`FROM index | ENRICH policy ON stringField WITH yetAnotherField2`, [
|
||||
'yetAnotherField',
|
||||
]);
|
||||
});
|
||||
|
||||
describe('fixing policies spellchecks', () => {
|
||||
testQuickFixes(`FROM index | ENRICH poli`, ['policy']);
|
||||
testQuickFixes(`FROM index | ENRICH mypolicy`, ['policy']);
|
||||
testQuickFixes(`FROM index | ENRICH policy[`, ['policy', 'policy[]']);
|
||||
for (const options of [
|
||||
undefined,
|
||||
{ relaxOnMissingCallbacks: false },
|
||||
{ relaxOnMissingCallbacks: false },
|
||||
]) {
|
||||
testQuickFixes(`FROM index | ENRICH poli`, ['policy'], options);
|
||||
testQuickFixes(`FROM index | ENRICH mypolicy`, ['policy'], options);
|
||||
testQuickFixes(`FROM index | ENRICH policy[`, ['policy', 'policy[]'], options);
|
||||
|
||||
describe('modes', () => {
|
||||
testQuickFixes(`FROM index | ENRICH _ann:policy`, ['_any']);
|
||||
const modes = ['_any', '_coordinator', '_remote'];
|
||||
for (const mode of modes) {
|
||||
testQuickFixes(`FROM index | ENRICH ${mode.replace('_', '@')}:policy`, [mode]);
|
||||
}
|
||||
testQuickFixes(`FROM index | ENRICH unknown:policy`, modes);
|
||||
});
|
||||
describe('modes', () => {
|
||||
testQuickFixes(`FROM index | ENRICH _ann:policy`, ['_any'], options);
|
||||
const modes = ['_any', '_coordinator', '_remote'];
|
||||
for (const mode of modes) {
|
||||
testQuickFixes(`FROM index | ENRICH ${mode.replace('_', '@')}:policy`, [mode], options);
|
||||
}
|
||||
testQuickFixes(`FROM index | ENRICH unknown:policy`, modes, options);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('fixing function spellchecks', () => {
|
||||
|
@ -197,68 +258,147 @@ describe('quick fixes logic', () => {
|
|||
}
|
||||
// it should be strange enough to make the function invalid
|
||||
const BROKEN_PREFIX = 'Q';
|
||||
for (const fn of getAllFunctions({ type: 'eval' })) {
|
||||
// add an A to the function name to make it invalid
|
||||
testQuickFixes(
|
||||
`FROM index | EVAL ${BROKEN_PREFIX}${fn.name}()`,
|
||||
[fn.name].map(toFunctionSignature),
|
||||
{ equalityCheck: 'include' }
|
||||
);
|
||||
testQuickFixes(
|
||||
`FROM index | EVAL var0 = ${BROKEN_PREFIX}${fn.name}()`,
|
||||
[fn.name].map(toFunctionSignature),
|
||||
{ equalityCheck: 'include' }
|
||||
);
|
||||
testQuickFixes(
|
||||
`FROM index | STATS avg(${BROKEN_PREFIX}${fn.name}())`,
|
||||
[fn.name].map(toFunctionSignature),
|
||||
{ equalityCheck: 'include' }
|
||||
);
|
||||
testQuickFixes(
|
||||
`FROM index | STATS avg(numberField) BY ${BROKEN_PREFIX}${fn.name}()`,
|
||||
[fn.name].map(toFunctionSignature),
|
||||
{ equalityCheck: 'include' }
|
||||
);
|
||||
testQuickFixes(
|
||||
`FROM index | STATS avg(numberField) BY var0 = ${BROKEN_PREFIX}${fn.name}()`,
|
||||
[fn.name].map(toFunctionSignature),
|
||||
{ equalityCheck: 'include' }
|
||||
);
|
||||
for (const options of [
|
||||
undefined,
|
||||
{ relaxOnMissingCallbacks: false },
|
||||
{ relaxOnMissingCallbacks: false },
|
||||
]) {
|
||||
for (const fn of getAllFunctions({ type: 'eval' })) {
|
||||
// add an A to the function name to make it invalid
|
||||
testQuickFixes(
|
||||
`FROM index | EVAL ${BROKEN_PREFIX}${fn.name}()`,
|
||||
[fn.name].map(toFunctionSignature),
|
||||
{ equalityCheck: 'include', ...options }
|
||||
);
|
||||
testQuickFixes(
|
||||
`FROM index | EVAL var0 = ${BROKEN_PREFIX}${fn.name}()`,
|
||||
[fn.name].map(toFunctionSignature),
|
||||
{ equalityCheck: 'include', ...options }
|
||||
);
|
||||
testQuickFixes(
|
||||
`FROM index | STATS avg(${BROKEN_PREFIX}${fn.name}())`,
|
||||
[fn.name].map(toFunctionSignature),
|
||||
{ equalityCheck: 'include', ...options }
|
||||
);
|
||||
testQuickFixes(
|
||||
`FROM index | STATS avg(numberField) BY ${BROKEN_PREFIX}${fn.name}()`,
|
||||
[fn.name].map(toFunctionSignature),
|
||||
{ equalityCheck: 'include', ...options }
|
||||
);
|
||||
testQuickFixes(
|
||||
`FROM index | STATS avg(numberField) BY var0 = ${BROKEN_PREFIX}${fn.name}()`,
|
||||
[fn.name].map(toFunctionSignature),
|
||||
{ equalityCheck: 'include', ...options }
|
||||
);
|
||||
}
|
||||
for (const fn of getAllFunctions({ type: 'agg' })) {
|
||||
// add an A to the function name to make it invalid
|
||||
testQuickFixes(
|
||||
`FROM index | STATS ${BROKEN_PREFIX}${fn.name}()`,
|
||||
[fn.name].map(toFunctionSignature),
|
||||
{ equalityCheck: 'include', ...options }
|
||||
);
|
||||
testQuickFixes(
|
||||
`FROM index | STATS var0 = ${BROKEN_PREFIX}${fn.name}()`,
|
||||
[fn.name].map(toFunctionSignature),
|
||||
{ equalityCheck: 'include', ...options }
|
||||
);
|
||||
}
|
||||
// it should preserve the arguments
|
||||
testQuickFixes(`FROM index | EVAL rAund(numberField)`, ['round(numberField)'], {
|
||||
equalityCheck: 'include',
|
||||
...options,
|
||||
});
|
||||
testQuickFixes(`FROM index | STATS AVVG(numberField)`, ['avg(numberField)'], {
|
||||
equalityCheck: 'include',
|
||||
...options,
|
||||
});
|
||||
}
|
||||
for (const fn of getAllFunctions({ type: 'agg' })) {
|
||||
// add an A to the function name to make it invalid
|
||||
testQuickFixes(
|
||||
`FROM index | STATS ${BROKEN_PREFIX}${fn.name}()`,
|
||||
[fn.name].map(toFunctionSignature),
|
||||
{ equalityCheck: 'include' }
|
||||
);
|
||||
testQuickFixes(
|
||||
`FROM index | STATS var0 = ${BROKEN_PREFIX}${fn.name}()`,
|
||||
[fn.name].map(toFunctionSignature),
|
||||
{ equalityCheck: 'include' }
|
||||
);
|
||||
}
|
||||
// it should preserve the arguments
|
||||
testQuickFixes(`FROM index | EVAL rAund(numberField)`, ['round(numberField)'], {
|
||||
equalityCheck: 'include',
|
||||
});
|
||||
testQuickFixes(`FROM index | STATS AVVG(numberField)`, ['avg(numberField)'], {
|
||||
equalityCheck: 'include',
|
||||
});
|
||||
});
|
||||
|
||||
describe('fixing wrong quotes', () => {
|
||||
testQuickFixes(`FROM index | WHERE stringField like 'asda'`, ['"asda"']);
|
||||
testQuickFixes(`FROM index | WHERE stringField not like 'asda'`, ['"asda"']);
|
||||
for (const options of [
|
||||
undefined,
|
||||
{ relaxOnMissingCallbacks: false },
|
||||
{ relaxOnMissingCallbacks: false },
|
||||
]) {
|
||||
testQuickFixes(`FROM index | WHERE stringField like 'asda'`, ['"asda"'], options);
|
||||
testQuickFixes(`FROM index | WHERE stringField not like 'asda'`, ['"asda"'], options);
|
||||
}
|
||||
});
|
||||
|
||||
describe('fixing unquoted field names', () => {
|
||||
testQuickFixes('FROM index | DROP any#Char$Field', ['`any#Char$Field`']);
|
||||
testQuickFixes('FROM index | DROP numberField, any#Char$Field', ['`any#Char$Field`']);
|
||||
for (const options of [
|
||||
undefined,
|
||||
{ relaxOnMissingCallbacks: false },
|
||||
{ relaxOnMissingCallbacks: false },
|
||||
]) {
|
||||
testQuickFixes('FROM index | DROP any#Char$Field', ['`any#Char$Field`'], options);
|
||||
testQuickFixes(
|
||||
'FROM index | DROP numberField, any#Char$Field',
|
||||
['`any#Char$Field`'],
|
||||
options
|
||||
);
|
||||
}
|
||||
describe('with no callbacks', () => {
|
||||
describe('with no relaxed option', () => {
|
||||
it('return no result without callbacks and relaxed option', async () => {
|
||||
const statement = `FROM index | DROP any#Char$Field`;
|
||||
const { errors } = await validateQuery(statement, getAstAndSyntaxErrors);
|
||||
const edits = await getActions(statement, errors, getAstAndSyntaxErrors);
|
||||
expect(edits.length).toBe(0);
|
||||
});
|
||||
|
||||
it('return no result without specific callback and relaxed option', async () => {
|
||||
const callbackMocks = getCallbackMocks();
|
||||
const statement = `FROM index | DROP any#Char$Field`;
|
||||
const { errors } = await validateQuery(statement, getAstAndSyntaxErrors, undefined, {
|
||||
...callbackMocks,
|
||||
getFieldsFor: undefined,
|
||||
});
|
||||
const edits = await getActions(statement, errors, getAstAndSyntaxErrors, undefined, {
|
||||
...callbackMocks,
|
||||
getFieldsFor: undefined,
|
||||
});
|
||||
expect(edits.length).toBe(0);
|
||||
});
|
||||
});
|
||||
describe('with relaxed option', () => {
|
||||
it('return a result without callbacks and relaxed option', async () => {
|
||||
const statement = `FROM index | DROP any#Char$Field`;
|
||||
const { errors } = await validateQuery(statement, getAstAndSyntaxErrors);
|
||||
const actions = await getActions(statement, errors, getAstAndSyntaxErrors, {
|
||||
relaxOnMissingCallbacks: true,
|
||||
});
|
||||
const edits = actions.map(({ edits: actionEdits }) => actionEdits[0].text);
|
||||
expect(edits).toEqual(['`any#Char$Field`']);
|
||||
});
|
||||
|
||||
it('return a result without specific callback and relaxed option', async () => {
|
||||
const callbackMocks = getCallbackMocks();
|
||||
const statement = `FROM index | DROP any#Char$Field`;
|
||||
const { errors } = await validateQuery(statement, getAstAndSyntaxErrors, undefined, {
|
||||
...callbackMocks,
|
||||
getFieldsFor: undefined,
|
||||
});
|
||||
const actions = await getActions(
|
||||
statement,
|
||||
errors,
|
||||
getAstAndSyntaxErrors,
|
||||
{
|
||||
relaxOnMissingCallbacks: true,
|
||||
},
|
||||
{ ...callbackMocks, getFieldsFor: undefined }
|
||||
);
|
||||
const edits = actions.map(({ edits: actionEdits }) => actionEdits[0].text);
|
||||
expect(edits).toEqual(['`any#Char$Field`']);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('callbacks', () => {
|
||||
it('should not crash if callback functions are not passed', async () => {
|
||||
it('should not crash if specific callback functions are not passed', async () => {
|
||||
const callbackMocks = getCallbackMocks();
|
||||
const statement = `from a | eval b = a | enrich policy | dissect stringField "%{firstWord}"`;
|
||||
const { errors } = await validateQuery(
|
||||
|
@ -268,7 +408,7 @@ describe('quick fixes logic', () => {
|
|||
callbackMocks
|
||||
);
|
||||
try {
|
||||
await getActions(statement, errors, getAstAndSyntaxErrors, {
|
||||
await getActions(statement, errors, getAstAndSyntaxErrors, undefined, {
|
||||
getFieldsFor: undefined,
|
||||
getSources: undefined,
|
||||
getPolicies: undefined,
|
||||
|
@ -279,6 +419,33 @@ describe('quick fixes logic', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('should not crash if specific callback functions are not passed with relaxed option', async () => {
|
||||
const callbackMocks = getCallbackMocks();
|
||||
const statement = `from a | eval b = a | enrich policy | dissect stringField "%{firstWord}"`;
|
||||
const { errors } = await validateQuery(
|
||||
statement,
|
||||
getAstAndSyntaxErrors,
|
||||
undefined,
|
||||
callbackMocks
|
||||
);
|
||||
try {
|
||||
await getActions(
|
||||
statement,
|
||||
errors,
|
||||
getAstAndSyntaxErrors,
|
||||
{ relaxOnMissingCallbacks: true },
|
||||
{
|
||||
getFieldsFor: undefined,
|
||||
getSources: undefined,
|
||||
getPolicies: undefined,
|
||||
getMetaFields: undefined,
|
||||
}
|
||||
);
|
||||
} catch {
|
||||
fail('Should not throw');
|
||||
}
|
||||
});
|
||||
|
||||
it('should not crash no callbacks are passed', async () => {
|
||||
const callbackMocks = getCallbackMocks();
|
||||
const statement = `from a | eval b = a | enrich policy | dissect stringField "%{firstWord}"`;
|
||||
|
@ -289,7 +456,25 @@ describe('quick fixes logic', () => {
|
|||
callbackMocks
|
||||
);
|
||||
try {
|
||||
await getActions(statement, errors, getAstAndSyntaxErrors, undefined);
|
||||
await getActions(statement, errors, getAstAndSyntaxErrors);
|
||||
} catch {
|
||||
fail('Should not throw');
|
||||
}
|
||||
});
|
||||
|
||||
it('should not crash no callbacks are passed with relaxed option', async () => {
|
||||
const callbackMocks = getCallbackMocks();
|
||||
const statement = `from a | eval b = a | enrich policy | dissect stringField "%{firstWord}"`;
|
||||
const { errors } = await validateQuery(
|
||||
statement,
|
||||
getAstAndSyntaxErrors,
|
||||
undefined,
|
||||
callbackMocks
|
||||
);
|
||||
try {
|
||||
await getActions(statement, errors, getAstAndSyntaxErrors, {
|
||||
relaxOnMissingCallbacks: true,
|
||||
});
|
||||
} catch {
|
||||
fail('Should not throw');
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import levenshtein from 'js-levenshtein';
|
||||
import type { AstProviderFn, ESQLAst, ESQLCommand, EditorError, ESQLMessage } from '@kbn/esql-ast';
|
||||
import { uniqBy } from 'lodash';
|
||||
import {
|
||||
getFieldsByTypeHelper,
|
||||
getPolicyHelper,
|
||||
|
@ -16,13 +17,15 @@ import {
|
|||
import {
|
||||
getAllFunctions,
|
||||
getCommandDefinition,
|
||||
isColumnItem,
|
||||
isSourceItem,
|
||||
shouldBeQuotedText,
|
||||
} from '../shared/helpers';
|
||||
import { ESQLCallbacks } from '../shared/types';
|
||||
import { buildQueryForFieldsFromSource } from '../validation/helpers';
|
||||
import { DOUBLE_BACKTICK, SINGLE_TICK_REGEX } from '../shared/constants';
|
||||
import type { CodeAction, Callbacks } from './types';
|
||||
import type { CodeAction, Callbacks, CodeActionOptions } from './types';
|
||||
import { getAstContext } from '../shared/context';
|
||||
import { wrapAsEditorMessage } from './utils';
|
||||
|
||||
function getFieldsByTypeRetriever(queryString: string, resourceRetriever?: ESQLCallbacks) {
|
||||
|
@ -114,9 +117,13 @@ async function getSpellingActionForColumns(
|
|||
error: EditorError,
|
||||
queryString: string,
|
||||
ast: ESQLAst,
|
||||
{ getFieldsByType, getPolicies, getPolicyFields }: Callbacks
|
||||
options: CodeActionOptions,
|
||||
{ getFieldsByType, getPolicies, getPolicyFields }: Partial<Callbacks>
|
||||
) {
|
||||
const errorText = queryString.substring(error.startColumn - 1, error.endColumn - 1);
|
||||
if (!getFieldsByType || !getPolicyFields) {
|
||||
return [];
|
||||
}
|
||||
// @TODO add variables support
|
||||
const possibleFields = await getSpellingPossibilities(async () => {
|
||||
const availableFields = await getFieldsByType('any');
|
||||
|
@ -133,11 +140,32 @@ async function getSpellingActionForColumns(
|
|||
return wrapIntoSpellingChangeAction(error, possibleFields);
|
||||
}
|
||||
|
||||
function extractUnquotedFieldText(
|
||||
query: string,
|
||||
errorType: string,
|
||||
ast: ESQLAst,
|
||||
possibleStart: number,
|
||||
end: number
|
||||
) {
|
||||
if (errorType === 'syntaxError') {
|
||||
// scope it down to column items for now
|
||||
const { node } = getAstContext(query, ast, possibleStart - 1);
|
||||
if (node && isColumnItem(node)) {
|
||||
return {
|
||||
start: node.location.min + 1,
|
||||
name: query.substring(node.location.min, end).trimEnd(),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { start: possibleStart + 1, name: query.substring(possibleStart, end).trimEnd() };
|
||||
}
|
||||
|
||||
async function getQuotableActionForColumns(
|
||||
error: EditorError,
|
||||
queryString: string,
|
||||
ast: ESQLAst,
|
||||
{ getFieldsByType }: Callbacks
|
||||
options: CodeActionOptions,
|
||||
{ getFieldsByType }: Partial<Callbacks>
|
||||
): Promise<CodeAction[]> {
|
||||
const commandEndIndex = ast.find((command) => command.location.max > error.endColumn)?.location
|
||||
.max;
|
||||
|
@ -159,14 +187,20 @@ async function getQuotableActionForColumns(
|
|||
error.endColumn - 1,
|
||||
error.endColumn + stopIndex
|
||||
);
|
||||
const errorText = queryString
|
||||
.substring(error.startColumn - 1, error.endColumn + possibleUnquotedText.length)
|
||||
.trimEnd();
|
||||
const { start, name: errorText } = extractUnquotedFieldText(
|
||||
queryString,
|
||||
error.code || 'syntaxError',
|
||||
ast,
|
||||
error.startColumn - 1,
|
||||
error.endColumn + possibleUnquotedText.length
|
||||
);
|
||||
const actions: CodeAction[] = [];
|
||||
if (shouldBeQuotedText(errorText)) {
|
||||
const availableFields = new Set(await getFieldsByType('any'));
|
||||
const solution = `\`${errorText.replace(SINGLE_TICK_REGEX, DOUBLE_BACKTICK)}\``;
|
||||
if (availableFields.has(errorText) || availableFields.has(solution)) {
|
||||
if (!getFieldsByType) {
|
||||
if (!options.relaxOnMissingCallbacks) {
|
||||
return [];
|
||||
}
|
||||
actions.push(
|
||||
createAction(
|
||||
i18n.translate('kbn-esql-validation-autocomplete.esql.quickfix.replaceWithSolution', {
|
||||
|
@ -176,9 +210,25 @@ async function getQuotableActionForColumns(
|
|||
},
|
||||
}),
|
||||
solution,
|
||||
{ ...error, endColumn: error.startColumn + errorText.length } // override the location
|
||||
{ ...error, startColumn: start, endColumn: start + errorText.length } // override the location
|
||||
)
|
||||
);
|
||||
} else {
|
||||
const availableFields = new Set(await getFieldsByType('any'));
|
||||
if (availableFields.has(errorText) || availableFields.has(solution)) {
|
||||
actions.push(
|
||||
createAction(
|
||||
i18n.translate('kbn-esql-validation-autocomplete.esql.quickfix.replaceWithSolution', {
|
||||
defaultMessage: 'Did you mean {solution} ?',
|
||||
values: {
|
||||
solution,
|
||||
},
|
||||
}),
|
||||
solution,
|
||||
{ ...error, startColumn: start, endColumn: start + errorText.length } // override the location
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return actions;
|
||||
|
@ -188,8 +238,12 @@ async function getSpellingActionForIndex(
|
|||
error: EditorError,
|
||||
queryString: string,
|
||||
ast: ESQLAst,
|
||||
{ getSources }: Callbacks
|
||||
options: CodeActionOptions,
|
||||
{ getSources }: Partial<Callbacks>
|
||||
) {
|
||||
if (!getSources) {
|
||||
return [];
|
||||
}
|
||||
const errorText = queryString.substring(error.startColumn - 1, error.endColumn - 1);
|
||||
const possibleSources = await getSpellingPossibilities(async () => {
|
||||
// Handle fuzzy names via truncation to test levenstein distance
|
||||
|
@ -208,8 +262,12 @@ async function getSpellingActionForPolicies(
|
|||
error: EditorError,
|
||||
queryString: string,
|
||||
ast: ESQLAst,
|
||||
{ getPolicies }: Callbacks
|
||||
options: CodeActionOptions,
|
||||
{ getPolicies }: Partial<Callbacks>
|
||||
) {
|
||||
if (!getPolicies) {
|
||||
return [];
|
||||
}
|
||||
const errorText = queryString.substring(error.startColumn - 1, error.endColumn - 1);
|
||||
const possiblePolicies = await getSpellingPossibilities(getPolicies, errorText);
|
||||
return wrapIntoSpellingChangeAction(error, possiblePolicies);
|
||||
|
@ -245,8 +303,12 @@ async function getSpellingActionForMetadata(
|
|||
error: EditorError,
|
||||
queryString: string,
|
||||
ast: ESQLAst,
|
||||
{ getMetaFields }: Callbacks
|
||||
options: CodeActionOptions,
|
||||
{ getMetaFields }: Partial<Callbacks>
|
||||
) {
|
||||
if (!getMetaFields) {
|
||||
return [];
|
||||
}
|
||||
const errorText = queryString.substring(error.startColumn - 1, error.endColumn - 1);
|
||||
const possibleMetafields = await getSpellingPossibilities(getMetaFields, errorText);
|
||||
return wrapIntoSpellingChangeAction(error, possibleMetafields);
|
||||
|
@ -256,7 +318,8 @@ async function getSpellingActionForEnrichMode(
|
|||
error: EditorError,
|
||||
queryString: string,
|
||||
ast: ESQLAst,
|
||||
_callbacks: Callbacks
|
||||
options: CodeActionOptions,
|
||||
_callbacks: Partial<Callbacks>
|
||||
) {
|
||||
const errorText = queryString.substring(error.startColumn - 1, error.endColumn - 1);
|
||||
const commandContext =
|
||||
|
@ -300,17 +363,27 @@ function extractQuotedText(rawText: string, error: EditorError) {
|
|||
return rawText.substring(error.startColumn - 2, error.endColumn);
|
||||
}
|
||||
|
||||
function inferCodeFromError(error: EditorError & { owner?: string }, rawText: string) {
|
||||
function inferCodeFromError(
|
||||
error: EditorError & { owner?: string },
|
||||
ast: ESQLAst,
|
||||
rawText: string
|
||||
) {
|
||||
if (error.message.endsWith('expecting QUOTED_STRING')) {
|
||||
const value = extractQuotedText(rawText, error);
|
||||
return /^'(.)*'$/.test(value) ? 'wrongQuotes' : undefined;
|
||||
}
|
||||
if (error.message.startsWith('SyntaxError: token recognition error at:')) {
|
||||
// scope it down to column items for now
|
||||
const { node } = getAstContext(rawText, ast, error.startColumn - 2);
|
||||
return node && isColumnItem(node) ? 'quotableFields' : undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getActions(
|
||||
innerText: string,
|
||||
markers: Array<ESQLMessage | EditorError>,
|
||||
astProvider: AstProviderFn,
|
||||
options: CodeActionOptions = {},
|
||||
resourceRetriever?: ESQLCallbacks
|
||||
): Promise<CodeAction[]> {
|
||||
const actions: CodeAction[] = [];
|
||||
|
@ -327,28 +400,46 @@ export async function getActions(
|
|||
const getMetaFields = getMetaFieldsRetriever(innerText, ast, resourceRetriever);
|
||||
|
||||
const callbacks = {
|
||||
getFieldsByType,
|
||||
getSources,
|
||||
getPolicies,
|
||||
getPolicyFields,
|
||||
getMetaFields,
|
||||
getFieldsByType: resourceRetriever?.getFieldsFor ? getFieldsByType : undefined,
|
||||
getSources: resourceRetriever?.getSources ? getSources : undefined,
|
||||
getPolicies: resourceRetriever?.getPolicies ? getPolicies : undefined,
|
||||
getPolicyFields: resourceRetriever?.getPolicies ? getPolicyFields : undefined,
|
||||
getMetaFields: resourceRetriever?.getMetaFields ? getMetaFields : undefined,
|
||||
};
|
||||
|
||||
// Markers are sent only on hover and are limited to the hovered area
|
||||
// so unless there are multiple error/markers for the same area, there's just one
|
||||
// in some cases, like syntax + semantic errors (i.e. unquoted fields eval field-1 ), there might be more than one
|
||||
for (const error of editorMarkers) {
|
||||
const code = error.code ?? inferCodeFromError(error, innerText);
|
||||
const code = error.code ?? inferCodeFromError(error, ast, innerText);
|
||||
switch (code) {
|
||||
case 'unknownColumn':
|
||||
case 'unknownColumn': {
|
||||
const [columnsSpellChanges, columnsQuotedChanges] = await Promise.all([
|
||||
getSpellingActionForColumns(error, innerText, ast, callbacks),
|
||||
getQuotableActionForColumns(error, innerText, ast, callbacks),
|
||||
getSpellingActionForColumns(error, innerText, ast, options, callbacks),
|
||||
getQuotableActionForColumns(error, innerText, ast, options, callbacks),
|
||||
]);
|
||||
actions.push(...(columnsQuotedChanges.length ? columnsQuotedChanges : columnsSpellChanges));
|
||||
break;
|
||||
}
|
||||
case 'quotableFields': {
|
||||
const columnsQuotedChanges = await getQuotableActionForColumns(
|
||||
error,
|
||||
innerText,
|
||||
ast,
|
||||
options,
|
||||
callbacks
|
||||
);
|
||||
actions.push(...columnsQuotedChanges);
|
||||
break;
|
||||
}
|
||||
case 'unknownIndex':
|
||||
const indexSpellChanges = await getSpellingActionForIndex(error, innerText, ast, callbacks);
|
||||
const indexSpellChanges = await getSpellingActionForIndex(
|
||||
error,
|
||||
innerText,
|
||||
ast,
|
||||
options,
|
||||
callbacks
|
||||
);
|
||||
actions.push(...indexSpellChanges);
|
||||
break;
|
||||
case 'unknownPolicy':
|
||||
|
@ -356,6 +447,7 @@ export async function getActions(
|
|||
error,
|
||||
innerText,
|
||||
ast,
|
||||
options,
|
||||
callbacks
|
||||
);
|
||||
actions.push(...policySpellChanges);
|
||||
|
@ -369,6 +461,7 @@ export async function getActions(
|
|||
error,
|
||||
innerText,
|
||||
ast,
|
||||
options,
|
||||
callbacks
|
||||
);
|
||||
actions.push(...metadataSpellChanges);
|
||||
|
@ -396,6 +489,7 @@ export async function getActions(
|
|||
error,
|
||||
innerText,
|
||||
ast,
|
||||
options,
|
||||
callbacks
|
||||
);
|
||||
actions.push(...enrichModeSpellChanges);
|
||||
|
@ -404,5 +498,5 @@ export async function getActions(
|
|||
break;
|
||||
}
|
||||
}
|
||||
return actions;
|
||||
return uniqBy(actions, ({ edits }) => edits[0].text);
|
||||
}
|
||||
|
|
|
@ -31,3 +31,7 @@ export interface CodeAction {
|
|||
text: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface CodeActionOptions {
|
||||
relaxOnMissingCallbacks?: boolean;
|
||||
}
|
||||
|
|
|
@ -89,6 +89,7 @@ export class ESQLAstAdapter {
|
|||
model.getValue(),
|
||||
context.markers as EditorError[],
|
||||
getAstFn,
|
||||
undefined,
|
||||
this.callbacks
|
||||
);
|
||||
return codeActions;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue