mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[ES|QL] JOIN
command autocomplete and validation (#205762)
## Summary Part of https://github.com/elastic/kibana/issues/200858 Main goal of this PR is to introduce initial autocomplete for the `JOIN` command:  In this PR: - Adds `JOIN` command and `AS` function definition - Adds `JOIN` command validation - Adds `JOIN` command autocomplete - New command suggestion, including command type - Command suggestion on partial command entry - Suggests lookup indices - Fetches them from the server and caches - Also suggests lookup index aliases - Suggests `ON` keyword - Suggests join condition fields - Suggests `,` or `|` after join condition fields - Autocomplete behaviour that could be improve in followup - After index suggestion selection, the "ON" suggestion does not appear automatically, user needs to enter space ` `. - When suggesting `ON <condition>` fields, compute lookup index and source index field intersection and show only those. - Only `LOOKUP JOIN` is exposed. `LEFT JOIN` and `RIGTH JOIN` are defined in code, but commented out. - The aliasing using `AS` operator will validate, but autocomplete does not actively suggest it to the user. --- ### Testing To test, you can create lookup indices in dev console using the following queries: ``` PUT /lookup_index { "settings": { "index.mode": "lookup" }, "mappings": { "properties": { "currency": { "type": "keyword" } } } } PUT /lookup_index_with_alias { "settings": { "index.mode": "lookup" }, "aliases": { "lookup_index2_alias1": {}, "lookup_index2_alias2": {} } } ``` Add some sample data: ``` POST /lookup_index/_doc { "currency": "EUR", "continenet": "Europe", "name": "Euro" } POST /lookup_index/_doc { "currency": "USD", "continenet": "North America", "name": "US Dollar" } POST /lookup_index/_doc { "currency": "USD", "continenet": "North America", "name": "Canadian Dollar" } ``` Add `kibana_sample_data_ecommerce` sample data and execute a query: ``` FROM kibana_sample_data_ecommerce | LOOKUP JOIN lookup_index ON currency ``` --- ### Checklist Check the PR satisfies following conditions. Reviewers should verify this PR satisfies this list as well. - [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 --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
This commit is contained in:
parent
e7f0771be7
commit
571ee960ad
42 changed files with 1256 additions and 118 deletions
|
@ -382,6 +382,7 @@ export const ESQLEditor = memo(function ESQLEditor({
|
|||
},
|
||||
// @ts-expect-error To prevent circular type import, type defined here is partial of full client
|
||||
getFieldsMetadata: fieldsMetadata?.getClient(),
|
||||
getJoinIndices: kibana.services?.esql?.getJoinIndicesAutocomplete,
|
||||
};
|
||||
return callbacks;
|
||||
}, [
|
||||
|
@ -397,6 +398,7 @@ export const ESQLEditor = memo(function ESQLEditor({
|
|||
indexManagementApiService,
|
||||
histogramBarTarget,
|
||||
fieldsMetadata,
|
||||
kibana.services?.esql?.getJoinIndicesAutocomplete,
|
||||
]);
|
||||
|
||||
const queryRunButtonProperties = useMemo(() => {
|
||||
|
|
|
@ -71,6 +71,20 @@ export interface ESQLEditorProps {
|
|||
disableAutoFocus?: boolean;
|
||||
}
|
||||
|
||||
export interface JoinIndicesAutocompleteResult {
|
||||
indices: JoinIndexAutocompleteItem[];
|
||||
}
|
||||
|
||||
export interface JoinIndexAutocompleteItem {
|
||||
name: string;
|
||||
mode: 'lookup' | string;
|
||||
aliases: string[];
|
||||
}
|
||||
|
||||
export interface EsqlPluginStartBase {
|
||||
getJoinIndicesAutocomplete: () => Promise<JoinIndicesAutocompleteResult>;
|
||||
}
|
||||
|
||||
export interface ESQLEditorDeps {
|
||||
core: CoreStart;
|
||||
dataViews: DataViewsPublicPluginStart;
|
||||
|
@ -79,4 +93,5 @@ export interface ESQLEditorDeps {
|
|||
indexManagementApiService?: IndexManagementPluginSetup['apiService'];
|
||||
fieldsMetadata?: FieldsMetadataPublicStart;
|
||||
usageCollection?: UsageCollectionStart;
|
||||
esql?: EsqlPluginStartBase;
|
||||
}
|
||||
|
|
|
@ -32,7 +32,7 @@
|
|||
"@kbn/usage-collection-plugin",
|
||||
"@kbn/content-management-favorites-common",
|
||||
"@kbn/kibana-utils-plugin",
|
||||
"@kbn/shared-ux-table-persist",
|
||||
"@kbn/shared-ux-table-persist"
|
||||
],
|
||||
"exclude": [
|
||||
"target/**/*",
|
||||
|
|
|
@ -30,6 +30,18 @@ export type {
|
|||
ESQLAstNode,
|
||||
} from './src/types';
|
||||
|
||||
export {
|
||||
isBinaryExpression,
|
||||
isColumn,
|
||||
isDoubleLiteral,
|
||||
isFunctionExpression,
|
||||
isIdentifier,
|
||||
isIntegerLiteral,
|
||||
isLiteral,
|
||||
isParamLiteral,
|
||||
isProperNode,
|
||||
} from './src/ast/helpers';
|
||||
|
||||
export { Builder, type AstNodeParserFields, type AstNodeTemplate } from './src/builder';
|
||||
|
||||
export {
|
||||
|
|
|
@ -12,6 +12,7 @@ import type {
|
|||
ESQLBinaryExpression,
|
||||
ESQLColumn,
|
||||
ESQLFunction,
|
||||
ESQLIdentifier,
|
||||
ESQLIntegerLiteral,
|
||||
ESQLLiteral,
|
||||
ESQLParamLiteral,
|
||||
|
@ -55,6 +56,9 @@ export const isParamLiteral = (node: unknown): node is ESQLParamLiteral =>
|
|||
export const isColumn = (node: unknown): node is ESQLColumn =>
|
||||
isProperNode(node) && node.type === 'column';
|
||||
|
||||
export const isIdentifier = (node: unknown): node is ESQLIdentifier =>
|
||||
isProperNode(node) && node.type === 'identifier';
|
||||
|
||||
/**
|
||||
* Returns the group of a binary expression:
|
||||
*
|
||||
|
|
|
@ -8,8 +8,9 @@
|
|||
*/
|
||||
|
||||
import { camelCase } from 'lodash';
|
||||
import { ESQLRealField } from '../validation/types';
|
||||
import { ESQLRealField, JoinIndexAutocompleteItem } from '../validation/types';
|
||||
import { fieldTypes } from '../definitions/types';
|
||||
import { ESQLCallbacks } from '../shared/types';
|
||||
|
||||
export const fields: ESQLRealField[] = [
|
||||
...fieldTypes.map((type) => ({ name: `${camelCase(type)}Field`, type })),
|
||||
|
@ -52,9 +53,22 @@ export const policies = [
|
|||
},
|
||||
];
|
||||
|
||||
export function getCallbackMocks() {
|
||||
export const joinIndices: JoinIndexAutocompleteItem[] = [
|
||||
{
|
||||
name: 'join_index',
|
||||
mode: 'lookup',
|
||||
aliases: [],
|
||||
},
|
||||
{
|
||||
name: 'join_index_with_alias',
|
||||
mode: 'lookup',
|
||||
aliases: ['join_index_alias_1', 'join_index_alias_2'],
|
||||
},
|
||||
];
|
||||
|
||||
export function getCallbackMocks(): ESQLCallbacks {
|
||||
return {
|
||||
getColumnsFor: jest.fn(async ({ query }) => {
|
||||
getColumnsFor: jest.fn(async ({ query } = {}) => {
|
||||
if (/enrich/.test(query)) {
|
||||
return enrichFields;
|
||||
}
|
||||
|
@ -75,5 +89,6 @@ export function getCallbackMocks() {
|
|||
}))
|
||||
),
|
||||
getPolicies: jest.fn(async () => policies),
|
||||
getJoinIndices: jest.fn(async () => ({ indices: joinIndices })),
|
||||
};
|
||||
}
|
||||
|
|
|
@ -0,0 +1,147 @@
|
|||
/*
|
||||
* 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 { setup, getFieldNamesByType } from './helpers';
|
||||
|
||||
describe('autocomplete.suggest', () => {
|
||||
describe('<type> JOIN <index> [ AS <alias> ] ON <condition> [, <condition> [, ...]]', () => {
|
||||
describe('<type> JOIN ...', () => {
|
||||
test('suggests join commands', async () => {
|
||||
const { suggest } = await setup();
|
||||
|
||||
const suggestions = await suggest('FROM index | /');
|
||||
const filtered = suggestions
|
||||
.filter((s) => s.label.includes('JOIN'))
|
||||
.map((s) => [s.label, s.text, s.detail]);
|
||||
|
||||
expect(filtered.map((s) => s[0])).toEqual(['LOOKUP JOIN']);
|
||||
|
||||
// TODO: Uncomment when other join types are implemented
|
||||
// expect(filtered.map((s) => s[0])).toEqual(['LEFT JOIN', 'RIGHT JOIN', 'LOOKUP JOIN']);
|
||||
});
|
||||
|
||||
test('can infer full command name based on the unique command type', async () => {
|
||||
const { suggest } = await setup();
|
||||
|
||||
const suggestions = await suggest('FROM index | LOOKU/');
|
||||
const filtered = suggestions.filter((s) => s.label.toUpperCase() === 'LOOKUP JOIN');
|
||||
|
||||
expect(filtered[0].label).toBe('LOOKUP JOIN');
|
||||
});
|
||||
|
||||
test('suggests command on first character', async () => {
|
||||
const { suggest } = await setup();
|
||||
|
||||
const suggestions = await suggest('FROM index | LOOKUP J/');
|
||||
const filtered = suggestions.filter((s) => s.label.toUpperCase() === 'LOOKUP JOIN');
|
||||
|
||||
expect(filtered[0].label).toBe('LOOKUP JOIN');
|
||||
});
|
||||
|
||||
test('returns command description, correct type, and suggestion continuation', async () => {
|
||||
const { suggest } = await setup();
|
||||
|
||||
const suggestions = await suggest('FROM index | LOOKUP J/');
|
||||
|
||||
expect(suggestions[0]).toMatchObject({
|
||||
label: 'LOOKUP JOIN',
|
||||
text: 'LOOKUP JOIN $0',
|
||||
detail: 'Join with a "lookup" mode index',
|
||||
kind: 'Keyword',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('... <index> ...', () => {
|
||||
test('can suggest lookup indices (and aliases)', async () => {
|
||||
const { suggest } = await setup();
|
||||
|
||||
const suggestions = await suggest('FROM index | LEFT JOIN /');
|
||||
const labels = suggestions.map((s) => s.label);
|
||||
|
||||
expect(labels).toEqual([
|
||||
'join_index',
|
||||
'join_index_with_alias',
|
||||
'join_index_alias_1',
|
||||
'join_index_alias_2',
|
||||
]);
|
||||
});
|
||||
|
||||
test('discriminates between indices and aliases', async () => {
|
||||
const { suggest } = await setup();
|
||||
|
||||
const suggestions = await suggest('FROM index | LEFT JOIN /');
|
||||
const indices: string[] = suggestions
|
||||
.filter((s) => s.detail === 'Index')
|
||||
.map((s) => s.label)
|
||||
.sort();
|
||||
const aliases: string[] = suggestions
|
||||
.filter((s) => s.detail === 'Alias')
|
||||
.map((s) => s.label)
|
||||
.sort();
|
||||
|
||||
expect(indices).toEqual(['join_index', 'join_index_with_alias']);
|
||||
expect(aliases).toEqual(['join_index_alias_1', 'join_index_alias_2']);
|
||||
});
|
||||
});
|
||||
|
||||
describe('... ON <condition>', () => {
|
||||
test('shows "ON" keyword suggestion', async () => {
|
||||
const { suggest } = await setup();
|
||||
|
||||
const suggestions = await suggest('FROM index | LOOKUP JOIN join_index /');
|
||||
const labels = suggestions.map((s) => s.label);
|
||||
|
||||
expect(labels).toEqual(['ON']);
|
||||
});
|
||||
|
||||
test('suggests fields after ON keyword', async () => {
|
||||
const { suggest } = await setup();
|
||||
|
||||
const suggestions = await suggest('FROM index | LOOKUP JOIN join_index ON /');
|
||||
const labels = suggestions.map((s) => s.text).sort();
|
||||
const expected = getFieldNamesByType('any')
|
||||
.sort()
|
||||
.map((field) => field + ' ');
|
||||
|
||||
expect(labels).toEqual(expected);
|
||||
});
|
||||
|
||||
test('more field suggestions after comma', async () => {
|
||||
const { suggest } = await setup();
|
||||
|
||||
const suggestions = await suggest('FROM index | LOOKUP JOIN join_index ON stringField, /');
|
||||
const labels = suggestions.map((s) => s.text).sort();
|
||||
const expected = getFieldNamesByType('any')
|
||||
.sort()
|
||||
.map((field) => field + ' ');
|
||||
|
||||
expect(labels).toEqual(expected);
|
||||
});
|
||||
|
||||
test('suggests pipe and comma after a field', async () => {
|
||||
const { suggest } = await setup();
|
||||
|
||||
const suggestions = await suggest('FROM index | LOOKUP JOIN join_index ON stringField /');
|
||||
const labels = suggestions.map((s) => s.label).sort();
|
||||
|
||||
expect(labels).toEqual([',', '|']);
|
||||
});
|
||||
|
||||
test('suggests pipe and comma after a field (no space)', async () => {
|
||||
const { suggest } = await setup();
|
||||
|
||||
const suggestions = await suggest('FROM index | LOOKUP JOIN join_index ON stringField/');
|
||||
const labels = suggestions.map((s) => s.label).sort();
|
||||
|
||||
expect(labels).toEqual([',', '|']);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
|
@ -42,6 +42,6 @@ describe('autocomplete.suggest', () => {
|
|||
await suggest('sHoW ?');
|
||||
await suggest('row ? |');
|
||||
|
||||
expect(callbacks.getColumnsFor.mock.calls.length).toBe(0);
|
||||
expect((callbacks.getColumnsFor as any).mock.calls.length).toBe(0);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -27,6 +27,7 @@ import {
|
|||
FunctionReturnType,
|
||||
SupportedDataType,
|
||||
} from '../../definitions/types';
|
||||
import { joinIndices } from '../../__tests__/helpers';
|
||||
|
||||
export interface Integration {
|
||||
name: string;
|
||||
|
@ -281,6 +282,7 @@ export function createCustomCallbackMocks(
|
|||
getColumnsFor: jest.fn(async () => finalColumnsSinceLastCommand),
|
||||
getSources: jest.fn(async () => finalSources),
|
||||
getPolicies: jest.fn(async () => finalPolicies),
|
||||
getJoinIndices: jest.fn(async () => ({ indices: joinIndices })),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
@ -91,30 +91,26 @@ describe('autocomplete', () => {
|
|||
...sourceCommands.map((name) => name.toUpperCase() + ' $0'),
|
||||
...recommendedQuerySuggestions.map((q) => q.queryString),
|
||||
]);
|
||||
testSuggestions(
|
||||
'from a | /',
|
||||
commandDefinitions
|
||||
.filter(({ name }) => !sourceCommands.includes(name))
|
||||
.map(({ name }) => name.toUpperCase() + ' $0')
|
||||
);
|
||||
testSuggestions(
|
||||
'from a metadata _id | /',
|
||||
commandDefinitions
|
||||
.filter(({ name }) => !sourceCommands.includes(name))
|
||||
.map(({ name }) => name.toUpperCase() + ' $0')
|
||||
);
|
||||
testSuggestions(
|
||||
'from a | eval var0 = a | /',
|
||||
commandDefinitions
|
||||
.filter(({ name }) => !sourceCommands.includes(name))
|
||||
.map(({ name }) => name.toUpperCase() + ' $0')
|
||||
);
|
||||
testSuggestions(
|
||||
'from a metadata _id | eval var0 = a | /',
|
||||
commandDefinitions
|
||||
.filter(({ name }) => !sourceCommands.includes(name))
|
||||
.map(({ name }) => name.toUpperCase() + ' $0')
|
||||
);
|
||||
const commands = commandDefinitions
|
||||
.filter(({ name }) => !sourceCommands.includes(name))
|
||||
.map(({ name, types }) => {
|
||||
if (types && types.length) {
|
||||
const cmds: string[] = [];
|
||||
for (const type of types) {
|
||||
const cmd = type.name.toUpperCase() + ' ' + name.toUpperCase() + ' $0';
|
||||
cmds.push(cmd);
|
||||
}
|
||||
return cmds;
|
||||
} else {
|
||||
return name.toUpperCase() + ' $0';
|
||||
}
|
||||
})
|
||||
.flat();
|
||||
|
||||
testSuggestions('from a | /', commands);
|
||||
testSuggestions('from a metadata _id | /', commands);
|
||||
testSuggestions('from a | eval var0 = a | /', commands);
|
||||
testSuggestions('from a metadata _id | eval var0 = a | /', commands);
|
||||
});
|
||||
|
||||
describe('show', () => {
|
||||
|
@ -440,13 +436,24 @@ describe('autocomplete', () => {
|
|||
...recommendedQuerySuggestions.map((q) => q.queryString),
|
||||
]);
|
||||
|
||||
const commands = commandDefinitions
|
||||
.filter(({ name }) => !sourceCommands.includes(name))
|
||||
.map(({ name, types }) => {
|
||||
if (types && types.length) {
|
||||
const cmds: string[] = [];
|
||||
for (const type of types) {
|
||||
const cmd = type.name.toUpperCase() + ' ' + name.toUpperCase() + ' $0';
|
||||
cmds.push(cmd);
|
||||
}
|
||||
return cmds;
|
||||
} else {
|
||||
return name.toUpperCase() + ' $0';
|
||||
}
|
||||
})
|
||||
.flat();
|
||||
|
||||
// pipe command
|
||||
testSuggestions(
|
||||
'FROM k | E/',
|
||||
commandDefinitions
|
||||
.filter(({ name }) => !sourceCommands.includes(name))
|
||||
.map(({ name }) => name.toUpperCase() + ' $0')
|
||||
);
|
||||
testSuggestions('FROM k | E/', commands);
|
||||
|
||||
describe('function arguments', () => {
|
||||
// function argument
|
||||
|
@ -650,13 +657,26 @@ describe('autocomplete', () => {
|
|||
...recommendedQuerySuggestions.map((q) => q.queryString),
|
||||
]);
|
||||
|
||||
const commands = commandDefinitions
|
||||
.filter(({ name }) => !sourceCommands.includes(name))
|
||||
.map(({ name, types }) => {
|
||||
if (types && types.length) {
|
||||
const cmds: string[] = [];
|
||||
for (const type of types) {
|
||||
const cmd = type.name.toUpperCase() + ' ' + name.toUpperCase() + ' $0';
|
||||
cmds.push(cmd);
|
||||
}
|
||||
return cmds;
|
||||
} else {
|
||||
return name.toUpperCase() + ' $0';
|
||||
}
|
||||
})
|
||||
.flat();
|
||||
|
||||
// Pipe command
|
||||
testSuggestions(
|
||||
'FROM a | E/',
|
||||
commandDefinitions
|
||||
.filter(({ name }) => !sourceCommands.includes(name))
|
||||
.map(({ name }) => attachTriggerCommand(name.toUpperCase() + ' $0'))
|
||||
.map(attachAsSnippet) // TODO consider making this check more fundamental
|
||||
commands.map((name) => attachTriggerCommand(name)).map(attachAsSnippet) // TODO consider making this check more fundamental
|
||||
);
|
||||
|
||||
describe('function arguments', () => {
|
||||
|
|
|
@ -209,7 +209,10 @@ export async function suggest(
|
|||
return suggestions.filter((def) => !isSourceCommand(def));
|
||||
}
|
||||
|
||||
if (astContext.type === 'expression') {
|
||||
if (
|
||||
astContext.type === 'expression' ||
|
||||
(astContext.type === 'option' && astContext.command?.name === 'join')
|
||||
) {
|
||||
return getSuggestionsWithinCommandExpression(
|
||||
innerText,
|
||||
ast,
|
||||
|
@ -220,7 +223,8 @@ export async function suggest(
|
|||
getPolicies,
|
||||
getPolicyMetadata,
|
||||
resourceRetriever?.getPreferences,
|
||||
fullAst
|
||||
fullAst,
|
||||
resourceRetriever
|
||||
);
|
||||
}
|
||||
if (astContext.type === 'setting') {
|
||||
|
@ -399,7 +403,8 @@ async function getSuggestionsWithinCommandExpression(
|
|||
getPolicies: GetPoliciesFn,
|
||||
getPolicyMetadata: GetPolicyMetadataFn,
|
||||
getPreferences?: () => Promise<{ histogramBarTarget: number } | undefined>,
|
||||
fullAst?: ESQLAst
|
||||
fullAst?: ESQLAst,
|
||||
callbacks?: ESQLCallbacks
|
||||
) {
|
||||
const commandDef = getCommandDefinition(command.name);
|
||||
|
||||
|
@ -419,7 +424,9 @@ async function getSuggestionsWithinCommandExpression(
|
|||
(expression: ESQLAstItem | undefined) =>
|
||||
getExpressionType(expression, references.fields, references.variables),
|
||||
getPreferences,
|
||||
fullAst
|
||||
fullAst,
|
||||
commandDef,
|
||||
callbacks
|
||||
);
|
||||
} else {
|
||||
// The deprecated path.
|
||||
|
|
|
@ -0,0 +1,145 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { type ESQLAstItem, ESQLAst } from '@kbn/esql-ast';
|
||||
import { ESQLCommand } from '@kbn/esql-ast/src/types';
|
||||
import type { ESQLCallbacks } from '../../../shared/types';
|
||||
import {
|
||||
CommandBaseDefinition,
|
||||
CommandDefinition,
|
||||
CommandTypeDefinition,
|
||||
type SupportedDataType,
|
||||
} from '../../../definitions/types';
|
||||
import { getPosition, joinIndicesToSuggestions } from './util';
|
||||
import { TRIGGER_SUGGESTION_COMMAND } from '../../factories';
|
||||
import type { GetColumnsByTypeFn, SuggestionRawDefinition } from '../../types';
|
||||
import { commaCompleteItem, pipeCompleteItem } from '../../complete_items';
|
||||
|
||||
const getFullCommandMnemonics = (
|
||||
definition: CommandDefinition<string>
|
||||
): Array<[mnemonic: string, description: string]> => {
|
||||
const types: CommandTypeDefinition[] = definition.types ?? [];
|
||||
|
||||
if (!types.length) {
|
||||
return [[definition.name, definition.description]];
|
||||
}
|
||||
|
||||
return types.map((type) => [
|
||||
`${type.name.toUpperCase()} ${definition.name.toUpperCase()}`,
|
||||
type.description ?? definition.description,
|
||||
]);
|
||||
};
|
||||
|
||||
export const suggest: CommandBaseDefinition<'join'>['suggest'] = async (
|
||||
innerText: string,
|
||||
command: ESQLCommand<'join'>,
|
||||
getColumnsByType: GetColumnsByTypeFn,
|
||||
columnExists: (column: string) => boolean,
|
||||
getSuggestedVariableName: () => string,
|
||||
getExpressionType: (expression: ESQLAstItem | undefined) => SupportedDataType | 'unknown',
|
||||
getPreferences?: () => Promise<{ histogramBarTarget: number } | undefined>,
|
||||
fullTextAst?: ESQLAst,
|
||||
definition?: CommandDefinition<'join'>,
|
||||
callbacks?: ESQLCallbacks
|
||||
): Promise<SuggestionRawDefinition[]> => {
|
||||
let commandText: string = innerText;
|
||||
|
||||
if (command.location) {
|
||||
commandText = innerText.slice(command.location.min);
|
||||
}
|
||||
|
||||
const position = getPosition(commandText, command);
|
||||
|
||||
switch (position.pos) {
|
||||
case 'type':
|
||||
case 'after_type':
|
||||
case 'mnemonic': {
|
||||
const allMnemonics = getFullCommandMnemonics(definition! as CommandDefinition<string>);
|
||||
const filteredMnemonics = allMnemonics.filter(([mnemonic]) =>
|
||||
mnemonic.startsWith(commandText.toUpperCase())
|
||||
);
|
||||
|
||||
if (!filteredMnemonics.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return filteredMnemonics.map(
|
||||
([mnemonic, description], i) =>
|
||||
({
|
||||
label: mnemonic,
|
||||
text: mnemonic + ' $0',
|
||||
detail: description,
|
||||
kind: 'Keyword',
|
||||
sortText: `${i}-MNEMONIC`,
|
||||
command: TRIGGER_SUGGESTION_COMMAND,
|
||||
} as SuggestionRawDefinition)
|
||||
);
|
||||
}
|
||||
|
||||
case 'after_mnemonic':
|
||||
case 'index': {
|
||||
const joinIndices = await callbacks?.getJoinIndices?.();
|
||||
|
||||
if (!joinIndices) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return joinIndicesToSuggestions(joinIndices.indices);
|
||||
}
|
||||
|
||||
case 'after_index': {
|
||||
const suggestion: SuggestionRawDefinition = {
|
||||
label: 'ON',
|
||||
text: 'ON ',
|
||||
detail: i18n.translate(
|
||||
'kbn-esql-validation-autocomplete.esql.autocomplete.join.onKeyword',
|
||||
{
|
||||
defaultMessage: 'Specify JOIN field conditions',
|
||||
}
|
||||
),
|
||||
kind: 'Keyword',
|
||||
sortText: '0-ON',
|
||||
command: TRIGGER_SUGGESTION_COMMAND,
|
||||
};
|
||||
|
||||
return [suggestion];
|
||||
}
|
||||
|
||||
case 'after_on': {
|
||||
const fields = await getColumnsByType(['any'], [], {
|
||||
advanceCursor: true,
|
||||
openSuggestions: true,
|
||||
});
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
case 'condition': {
|
||||
const endingWhitespaceRegex = /(?<comma>,)?(?<whitespace>\s{0,99})$/;
|
||||
const match = commandText.match(endingWhitespaceRegex);
|
||||
const commaIsLastToken = !!match?.groups?.comma;
|
||||
|
||||
if (commaIsLastToken) {
|
||||
const fields = await getColumnsByType(['any'], [], {
|
||||
advanceCursor: true,
|
||||
openSuggestions: true,
|
||||
});
|
||||
|
||||
return fields;
|
||||
}
|
||||
|
||||
return [pipeCompleteItem, commaCompleteItem];
|
||||
}
|
||||
}
|
||||
|
||||
const suggestions: SuggestionRawDefinition[] = [];
|
||||
|
||||
return suggestions;
|
||||
};
|
|
@ -0,0 +1,94 @@
|
|||
/*
|
||||
* 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 { ESQLAstExpression } from '@kbn/esql-ast/src/types';
|
||||
|
||||
/**
|
||||
* Position of the caret in the JOIN command, which can be easily matched with
|
||||
* with basic parsing. Can be matched with a regular expression. Does not
|
||||
* include the `condition` and `after_condition` positions.
|
||||
*
|
||||
* ```
|
||||
* <type> JOIN <index> [ AS <alias> ] ON <conditions>
|
||||
* | || || | | || | | |
|
||||
* | || || | | || | | |
|
||||
* | || || | | || | | after_on
|
||||
* | || || | | || | on
|
||||
* | || || | | || after_alias
|
||||
* | || || | | |alias
|
||||
* | || || | | after_as
|
||||
* | || || | as
|
||||
* | || || after_index
|
||||
* | || |index
|
||||
* | || after_mnemonic
|
||||
* | |mnemonic
|
||||
* | after_type
|
||||
* type
|
||||
* ```
|
||||
*/
|
||||
export type JoinStaticPosition =
|
||||
| 'none'
|
||||
| 'type'
|
||||
| 'after_type'
|
||||
| 'mnemonic'
|
||||
| 'after_mnemonic'
|
||||
| 'index'
|
||||
| 'after_index'
|
||||
| 'as'
|
||||
| 'after_as'
|
||||
| 'alias'
|
||||
| 'after_alias'
|
||||
| 'on'
|
||||
| 'after_on';
|
||||
|
||||
/**
|
||||
* Position of the caret in the JOIN command. Includes the `condition` and
|
||||
* `after_condition` positions, which need to involve the main parser to be
|
||||
* determined correctly.
|
||||
*
|
||||
* ```
|
||||
* <type> JOIN <index> [ AS <alias> ] ON <condition> [, <condition> [, ...]]
|
||||
* | || || | | || | | || | | |
|
||||
* | || || | | || | | || | | |
|
||||
* | || || | | || | | || | | after_condition
|
||||
* | || || | | || | | || | condition
|
||||
* | || || | | || | | || after_condition
|
||||
* | || || | | || | | |condition
|
||||
* | || || | | || | | after_on
|
||||
* | || || | | || | on
|
||||
* | || || | | || after_alias
|
||||
* | || || | | |alias
|
||||
* | || || | | after_as
|
||||
* | || || | as
|
||||
* | || || after_index
|
||||
* | || |index
|
||||
* | || after_mnemonic
|
||||
* | |mnemonic
|
||||
* | after_type
|
||||
* type
|
||||
* ```
|
||||
*/
|
||||
export type JoinPosition = JoinStaticPosition | 'condition' | 'after_condition';
|
||||
|
||||
/**
|
||||
* Details about the position of the caret in the JOIN command.
|
||||
*/
|
||||
export interface JoinCommandPosition {
|
||||
pos: JoinPosition;
|
||||
|
||||
/** The `<type>` of the JOIN command. */
|
||||
type: string;
|
||||
|
||||
/**
|
||||
* If position is `condition` or `after_condition`, this property holds the
|
||||
* condition expression AST node after which or inside of which the caret is
|
||||
* located.
|
||||
*/
|
||||
condition?: ESQLAstExpression;
|
||||
}
|
|
@ -0,0 +1,70 @@
|
|||
/*
|
||||
* 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 { joinIndices } from '../../../__tests__/helpers';
|
||||
import { getPosition, joinIndicesToSuggestions } from './util';
|
||||
|
||||
describe('getPosition()', () => {
|
||||
test('returns correct position on complete modifier matches', () => {
|
||||
expect(getPosition('L', {} as any).pos).toBe('type');
|
||||
expect(getPosition('LE', {} as any).pos).toBe('type');
|
||||
expect(getPosition('LEFT', {} as any).pos).toBe('type');
|
||||
expect(getPosition('LEFT ', {} as any).pos).toBe('after_type');
|
||||
expect(getPosition('LEFT ', {} as any).pos).toBe('after_type');
|
||||
expect(getPosition('LEFT J', {} as any).pos).toBe('mnemonic');
|
||||
expect(getPosition('LEFT JO', {} as any).pos).toBe('mnemonic');
|
||||
expect(getPosition('LEFT JOI', {} as any).pos).toBe('mnemonic');
|
||||
expect(getPosition('LEFT JOIN', {} as any).pos).toBe('mnemonic');
|
||||
expect(getPosition('LEFT JOIN ', {} as any).pos).toBe('after_mnemonic');
|
||||
expect(getPosition('LEFT JOIN ', {} as any).pos).toBe('after_mnemonic');
|
||||
expect(getPosition('LEFT JOIN i', {} as any).pos).toBe('index');
|
||||
expect(getPosition('LEFT JOIN i2', {} as any).pos).toBe('index');
|
||||
expect(getPosition('LEFT JOIN ind', {} as any).pos).toBe('index');
|
||||
expect(getPosition('LEFT JOIN index', {} as any).pos).toBe('index');
|
||||
expect(getPosition('LEFT JOIN index ', {} as any).pos).toBe('after_index');
|
||||
expect(getPosition('LEFT JOIN index ', {} as any).pos).toBe('after_index');
|
||||
expect(getPosition('LEFT JOIN index A', {} as any).pos).toBe('as');
|
||||
expect(getPosition('LEFT JOIN index As', {} as any).pos).toBe('as');
|
||||
expect(getPosition('LEFT JOIN index AS', {} as any).pos).toBe('as');
|
||||
expect(getPosition('LEFT JOIN index AS ', {} as any).pos).toBe('after_as');
|
||||
expect(getPosition('LEFT JOIN index AS ', {} as any).pos).toBe('after_as');
|
||||
expect(getPosition('LEFT JOIN index AS a', {} as any).pos).toBe('alias');
|
||||
expect(getPosition('LEFT JOIN index AS al2', {} as any).pos).toBe('alias');
|
||||
expect(getPosition('LEFT JOIN index AS alias', {} as any).pos).toBe('alias');
|
||||
expect(getPosition('LEFT JOIN index AS alias ', {} as any).pos).toBe('after_alias');
|
||||
expect(getPosition('LEFT JOIN index AS alias ', {} as any).pos).toBe('after_alias');
|
||||
expect(getPosition('LEFT JOIN index AS alias O', {} as any).pos).toBe('on');
|
||||
expect(getPosition('LEFT JOIN index AS alias On', {} as any).pos).toBe('on');
|
||||
expect(getPosition('LEFT JOIN index AS alias ON', {} as any).pos).toBe('on');
|
||||
expect(getPosition('LEFT JOIN index AS alias ON ', {} as any).pos).toBe('after_on');
|
||||
expect(getPosition('LEFT JOIN index AS alias ON ', {} as any).pos).toBe('after_on');
|
||||
expect(getPosition('LEFT JOIN index AS alias ON a', {} as any).pos).toBe('condition');
|
||||
});
|
||||
|
||||
test('returns correct position, when no <alias> part specified', () => {
|
||||
expect(getPosition('LEFT JOIN index O', {} as any).pos).toBe('on');
|
||||
expect(getPosition('LEFT JOIN index ON', {} as any).pos).toBe('on');
|
||||
expect(getPosition('LEFT JOIN index ON ', {} as any).pos).toBe('after_on');
|
||||
expect(getPosition('LEFT JOIN index ON ', {} as any).pos).toBe('after_on');
|
||||
});
|
||||
});
|
||||
|
||||
describe('joinIndicesToSuggestions()', () => {
|
||||
test('converts join indices to suggestions', () => {
|
||||
const suggestions = joinIndicesToSuggestions(joinIndices);
|
||||
const labels = suggestions.map((s) => s.label);
|
||||
|
||||
expect(labels).toEqual([
|
||||
'join_index',
|
||||
'join_index_with_alias',
|
||||
'join_index_alias_1',
|
||||
'join_index_alias_2',
|
||||
]);
|
||||
});
|
||||
});
|
|
@ -0,0 +1,107 @@
|
|||
/*
|
||||
* Copyright Elasticsearch B.V. and/or licensed to Elasticsearch B.V. under one
|
||||
* or more contributor license agreements. Licensed under the "Elastic License
|
||||
* 2.0", the "GNU Affero General Public License v3.0 only", and the "Server Side
|
||||
* Public License v 1"; you may not use this file except in compliance with, at
|
||||
* your election, the "Elastic License 2.0", the "GNU Affero General Public
|
||||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { ESQLCommand } from '@kbn/esql-ast';
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { JoinCommandPosition, JoinPosition, JoinStaticPosition } from './types';
|
||||
import type { JoinIndexAutocompleteItem } from '../../../validation/types';
|
||||
import { SuggestionRawDefinition } from '../../types';
|
||||
|
||||
const REGEX =
|
||||
/^(?<type>\w+((?<after_type>\s+((?<mnemonic>(JOIN|JOI|JO|J)((?<after_mnemonic>\s+((?<index>\S+((?<after_index>\s+(?<as>(AS|A))?(?<after_as>\s+(((?<alias>\S+)?(?<after_alias>\s+)?)?))?((?<on>(ON|O)((?<after_on>\s+(?<cond>[^\s])?)?))?))?))?))?))?))?))?/i;
|
||||
|
||||
const positions: Array<JoinStaticPosition | 'cond'> = [
|
||||
'cond',
|
||||
'after_on',
|
||||
'on',
|
||||
'after_alias',
|
||||
'alias',
|
||||
'after_as',
|
||||
'as',
|
||||
'after_index',
|
||||
'index',
|
||||
'after_mnemonic',
|
||||
'mnemonic',
|
||||
'after_type',
|
||||
'type',
|
||||
];
|
||||
|
||||
/**
|
||||
* Returns the static position, or `cond` if the caret is in the `<conditions>`
|
||||
* part of the command, in which case further parsing is needed.
|
||||
*/
|
||||
const getStaticPosition = (text: string): JoinStaticPosition | 'cond' => {
|
||||
const match = text.match(REGEX);
|
||||
|
||||
if (!match || !match.groups) {
|
||||
return 'none';
|
||||
}
|
||||
|
||||
let pos: JoinStaticPosition | 'cond' = 'cond';
|
||||
|
||||
for (const position of positions) {
|
||||
if (match.groups[position]) {
|
||||
pos = position;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return pos;
|
||||
};
|
||||
|
||||
export const getPosition = (text: string, command: ESQLCommand): JoinCommandPosition => {
|
||||
const pos0: JoinStaticPosition | 'cond' = getStaticPosition(text);
|
||||
const pos: JoinPosition = pos0 === 'cond' ? 'condition' : pos0;
|
||||
|
||||
return {
|
||||
pos,
|
||||
type: '',
|
||||
};
|
||||
};
|
||||
|
||||
export const joinIndicesToSuggestions = (
|
||||
indices: JoinIndexAutocompleteItem[]
|
||||
): SuggestionRawDefinition[] => {
|
||||
const mainSuggestions: SuggestionRawDefinition[] = [];
|
||||
const aliasSuggestions: SuggestionRawDefinition[] = [];
|
||||
|
||||
for (const index of indices) {
|
||||
mainSuggestions.push({
|
||||
label: index.name,
|
||||
text: index.name + ' ',
|
||||
kind: 'Issue',
|
||||
detail: i18n.translate(
|
||||
'kbn-esql-validation-autocomplete.esql.autocomplete.join.indexType.index',
|
||||
{
|
||||
defaultMessage: 'Index',
|
||||
}
|
||||
),
|
||||
sortText: '0-INDEX-' + index.name,
|
||||
});
|
||||
|
||||
if (index.aliases) {
|
||||
for (const alias of index.aliases) {
|
||||
aliasSuggestions.push({
|
||||
label: alias,
|
||||
text: alias + ' $0',
|
||||
kind: 'Issue',
|
||||
detail: i18n.translate(
|
||||
'kbn-esql-validation-autocomplete.esql.autocomplete.join.indexType.alias',
|
||||
{
|
||||
defaultMessage: 'Alias',
|
||||
}
|
||||
),
|
||||
sortText: '1-ALIAS-' + alias,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return [...mainSuggestions, ...aliasSuggestions];
|
||||
};
|
|
@ -10,12 +10,11 @@
|
|||
import { i18n } from '@kbn/i18n';
|
||||
import type { ItemKind, SuggestionRawDefinition } from './types';
|
||||
import { builtinFunctions } from '../definitions/builtin';
|
||||
import {
|
||||
getOperatorSuggestion,
|
||||
getSuggestionCommandDefinition,
|
||||
TRIGGER_SUGGESTION_COMMAND,
|
||||
} from './factories';
|
||||
import { CommandDefinition } from '../definitions/types';
|
||||
import { getOperatorSuggestion, TRIGGER_SUGGESTION_COMMAND } from './factories';
|
||||
import { CommandDefinition, CommandTypeDefinition } from '../definitions/types';
|
||||
import { getCommandDefinition } from '../shared/helpers';
|
||||
import { getCommandSignature } from '../definitions/helpers';
|
||||
import { buildDocumentation } from './documentation_util';
|
||||
|
||||
export function getAssignmentDefinitionCompletitionItem() {
|
||||
const assignFn = builtinFunctions.find(({ name }) => name === '=')!;
|
||||
|
@ -24,8 +23,47 @@ export function getAssignmentDefinitionCompletitionItem() {
|
|||
|
||||
export const getCommandAutocompleteDefinitions = (
|
||||
commands: Array<CommandDefinition<string>>
|
||||
): SuggestionRawDefinition[] =>
|
||||
commands.filter(({ hidden }) => !hidden).map(getSuggestionCommandDefinition);
|
||||
): SuggestionRawDefinition[] => {
|
||||
const suggestions: SuggestionRawDefinition[] = [];
|
||||
|
||||
for (const command of commands) {
|
||||
if (command.hidden) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const commandDefinition = getCommandDefinition(command.name);
|
||||
const commandSignature = getCommandSignature(commandDefinition);
|
||||
const label = commandDefinition.name.toUpperCase();
|
||||
const text = commandDefinition.signature.params.length
|
||||
? `${commandDefinition.name.toUpperCase()} $0`
|
||||
: commandDefinition.name.toUpperCase();
|
||||
const types: CommandTypeDefinition[] = command.types ?? [
|
||||
{
|
||||
name: '',
|
||||
description: '',
|
||||
},
|
||||
];
|
||||
|
||||
for (const type of types) {
|
||||
const suggestion: SuggestionRawDefinition = {
|
||||
label: type.name ? `${type.name.toLocaleUpperCase()} ${label}` : label,
|
||||
text: type.name ? `${type.name.toLocaleUpperCase()} ${text}` : text,
|
||||
asSnippet: true,
|
||||
kind: 'Method',
|
||||
detail: type.description || commandDefinition.description,
|
||||
documentation: {
|
||||
value: buildDocumentation(commandSignature.declaration, commandSignature.examples),
|
||||
},
|
||||
sortText: 'A-' + label + '-' + type.name,
|
||||
command: TRIGGER_SUGGESTION_COMMAND,
|
||||
};
|
||||
|
||||
suggestions.push(suggestion);
|
||||
}
|
||||
}
|
||||
|
||||
return suggestions;
|
||||
};
|
||||
|
||||
function buildCharCompleteItem(
|
||||
label: string,
|
||||
|
|
|
@ -13,17 +13,16 @@ import { SuggestionRawDefinition } from './types';
|
|||
import { groupingFunctionDefinitions } from '../definitions/grouping';
|
||||
import { aggregationFunctionDefinitions } from '../definitions/generated/aggregation_functions';
|
||||
import { scalarFunctionDefinitions } from '../definitions/generated/scalar_functions';
|
||||
import { getFunctionSignatures, getCommandSignature } from '../definitions/helpers';
|
||||
import { getFunctionSignatures } from '../definitions/helpers';
|
||||
import { timeUnitsToSuggest } from '../definitions/literals';
|
||||
import {
|
||||
FunctionDefinition,
|
||||
CommandDefinition,
|
||||
CommandOptionsDefinition,
|
||||
CommandModeDefinition,
|
||||
FunctionParameterType,
|
||||
} from '../definitions/types';
|
||||
import { shouldBeQuotedSource, getCommandDefinition, shouldBeQuotedText } from '../shared/helpers';
|
||||
import { buildDocumentation, buildFunctionDocumentation } from './documentation_util';
|
||||
import { shouldBeQuotedSource, shouldBeQuotedText } from '../shared/helpers';
|
||||
import { buildFunctionDocumentation } from './documentation_util';
|
||||
import { DOUBLE_BACKTICK, SINGLE_TICK_REGEX } from '../shared/constants';
|
||||
import { ESQLRealField } from '../validation/types';
|
||||
import { isNumericType } from '../shared/esql_types';
|
||||
|
@ -193,27 +192,6 @@ export const getSuggestionsAfterNot = (): SuggestionRawDefinition[] => {
|
|||
.map(getOperatorSuggestion);
|
||||
};
|
||||
|
||||
export function getSuggestionCommandDefinition(
|
||||
command: CommandDefinition<string>
|
||||
): SuggestionRawDefinition {
|
||||
const commandDefinition = getCommandDefinition(command.name);
|
||||
const commandSignature = getCommandSignature(commandDefinition);
|
||||
return {
|
||||
label: commandDefinition.name.toUpperCase(),
|
||||
text: commandDefinition.signature.params.length
|
||||
? `${commandDefinition.name.toUpperCase()} $0`
|
||||
: commandDefinition.name.toUpperCase(),
|
||||
asSnippet: true,
|
||||
kind: 'Method',
|
||||
detail: commandDefinition.description,
|
||||
documentation: {
|
||||
value: buildDocumentation(commandSignature.declaration, commandSignature.examples),
|
||||
},
|
||||
sortText: 'A',
|
||||
command: TRIGGER_SUGGESTION_COMMAND,
|
||||
};
|
||||
}
|
||||
|
||||
export const buildFieldsDefinitionsWithMetadata = (
|
||||
fields: ESQLRealField[],
|
||||
options?: { advanceCursor?: boolean; openSuggestions?: boolean; addComma?: boolean }
|
||||
|
|
|
@ -7,12 +7,13 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import type {
|
||||
ESQLAstItem,
|
||||
ESQLCommand,
|
||||
ESQLFunction,
|
||||
ESQLLiteral,
|
||||
ESQLSource,
|
||||
import {
|
||||
isIdentifier,
|
||||
type ESQLAstItem,
|
||||
type ESQLCommand,
|
||||
type ESQLFunction,
|
||||
type ESQLLiteral,
|
||||
type ESQLSource,
|
||||
} from '@kbn/esql-ast';
|
||||
import { uniqBy } from 'lodash';
|
||||
import {
|
||||
|
@ -30,7 +31,6 @@ import {
|
|||
isAssignment,
|
||||
isColumnItem,
|
||||
isFunctionItem,
|
||||
isIdentifier,
|
||||
isLiteralItem,
|
||||
isTimeIntervalItem,
|
||||
} from '../shared/helpers';
|
||||
|
|
|
@ -9,7 +9,13 @@
|
|||
|
||||
import { i18n } from '@kbn/i18n';
|
||||
import { distance } from 'fastest-levenshtein';
|
||||
import type { AstProviderFn, ESQLAst, EditorError, ESQLMessage } from '@kbn/esql-ast';
|
||||
import {
|
||||
type AstProviderFn,
|
||||
type ESQLAst,
|
||||
type EditorError,
|
||||
type ESQLMessage,
|
||||
isIdentifier,
|
||||
} from '@kbn/esql-ast';
|
||||
import { uniqBy } from 'lodash';
|
||||
import {
|
||||
getFieldsByTypeHelper,
|
||||
|
@ -20,7 +26,6 @@ import {
|
|||
getAllFunctions,
|
||||
getCommandDefinition,
|
||||
isColumnItem,
|
||||
isIdentifier,
|
||||
isSourceItem,
|
||||
shouldBeQuotedText,
|
||||
} from '../shared/helpers';
|
||||
|
|
|
@ -627,6 +627,24 @@ const otherDefinitions: FunctionDefinition[] = [
|
|||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
type: 'builtin' as const,
|
||||
name: 'as',
|
||||
description: i18n.translate('kbn-esql-validation-autocomplete.esql.definition.asDoc', {
|
||||
defaultMessage: 'Rename as (AS)',
|
||||
}),
|
||||
supportedCommands: ['rename', 'join'],
|
||||
supportedOptions: [],
|
||||
signatures: [
|
||||
{
|
||||
params: [
|
||||
{ name: 'oldName', type: 'any' },
|
||||
{ name: 'newName', type: 'any' },
|
||||
],
|
||||
returnType: 'unknown',
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
// TODO — this shouldn't be a function or an operator...
|
||||
name: 'info',
|
||||
|
|
|
@ -38,6 +38,7 @@ 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';
|
||||
|
||||
const statsValidator = (command: ESQLCommand) => {
|
||||
const messages: ESQLMessage[] = [];
|
||||
|
@ -491,4 +492,56 @@ export const commandDefinitions: Array<CommandDefinition<any>> = [
|
|||
multipleParams: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'join',
|
||||
types: [
|
||||
// TODO: uncomment, when in the future LEFT JOIN and RIGHT JOIN are supported.
|
||||
// {
|
||||
// name: 'left',
|
||||
// description: i18n.translate(
|
||||
// 'kbn-esql-validation-autocomplete.esql.definitions.joinLeftDoc',
|
||||
// {
|
||||
// defaultMessage:
|
||||
// 'Join index with another index, keep only matching documents from the right index',
|
||||
// }
|
||||
// ),
|
||||
// },
|
||||
// {
|
||||
// name: 'right',
|
||||
// description: i18n.translate(
|
||||
// 'kbn-esql-validation-autocomplete.esql.definitions.joinRightDoc',
|
||||
// {
|
||||
// defaultMessage:
|
||||
// 'Join index with another index, keep only matching documents from the left index',
|
||||
// }
|
||||
// ),
|
||||
// },
|
||||
{
|
||||
name: 'lookup',
|
||||
description: i18n.translate(
|
||||
'kbn-esql-validation-autocomplete.esql.definitions.joinLookupDoc',
|
||||
{
|
||||
defaultMessage: 'Join with a "lookup" mode index',
|
||||
}
|
||||
),
|
||||
},
|
||||
],
|
||||
description: i18n.translate('kbn-esql-validation-autocomplete.esql.definitions.joinDoc', {
|
||||
defaultMessage: 'Join table with another table.',
|
||||
}),
|
||||
examples: [
|
||||
'… | LOOKUP JOIN lookup_index ON join_field',
|
||||
// TODO: Uncomment when other join types are implemented
|
||||
// '… | <LEFT | RIGHT | LOOKUP> JOIN index ON index.field = index2.field',
|
||||
// '… | <LEFT | RIGHT | LOOKUP> JOIN index AS alias ON index.field = index2.field',
|
||||
// '… | <LEFT | RIGHT | LOOKUP> JOIN index AS alias ON index.field = index2.field, index.field2 = index2.field2',
|
||||
],
|
||||
options: [],
|
||||
modes: [],
|
||||
signature: {
|
||||
multipleParams: false,
|
||||
params: [{ name: 'index', type: 'source', wildcards: true }],
|
||||
},
|
||||
suggest: suggestForJoin,
|
||||
},
|
||||
];
|
||||
|
|
|
@ -16,6 +16,7 @@ import type {
|
|||
ESQLMessage,
|
||||
} from '@kbn/esql-ast';
|
||||
import { GetColumnsByTypeFn, SuggestionRawDefinition } from '../autocomplete/types';
|
||||
import type { ESQLCallbacks } from '../shared/types';
|
||||
|
||||
/**
|
||||
* All supported field types in ES|QL. This is all the types
|
||||
|
@ -173,6 +174,12 @@ export interface FunctionDefinition {
|
|||
|
||||
export interface CommandBaseDefinition<CommandName extends string> {
|
||||
name: CommandName;
|
||||
|
||||
/**
|
||||
* Command name prefix, such as "LEFT" or "RIGHT" for JOIN command.
|
||||
*/
|
||||
types?: CommandTypeDefinition[];
|
||||
|
||||
alias?: string;
|
||||
description: string;
|
||||
/**
|
||||
|
@ -187,7 +194,9 @@ export interface CommandBaseDefinition<CommandName extends string> {
|
|||
getSuggestedVariableName: () => string,
|
||||
getExpressionType: (expression: ESQLAstItem | undefined) => SupportedDataType | 'unknown',
|
||||
getPreferences?: () => Promise<{ histogramBarTarget: number } | undefined>,
|
||||
fullTextAst?: ESQLAst
|
||||
fullTextAst?: ESQLAst,
|
||||
definition?: CommandDefinition<CommandName>,
|
||||
callbacks?: ESQLCallbacks
|
||||
) => Promise<SuggestionRawDefinition[]>;
|
||||
/** @deprecated this property will disappear in the future */
|
||||
signature: {
|
||||
|
@ -207,6 +216,11 @@ export interface CommandBaseDefinition<CommandName extends string> {
|
|||
};
|
||||
}
|
||||
|
||||
export interface CommandTypeDefinition {
|
||||
name: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface CommandOptionsDefinition<CommandName extends string = string>
|
||||
extends CommandBaseDefinition<CommandName> {
|
||||
wrapped?: string[];
|
||||
|
|
|
@ -16,6 +16,7 @@ import {
|
|||
type ESQLCommandOption,
|
||||
type ESQLCommandMode,
|
||||
Walker,
|
||||
isIdentifier,
|
||||
} from '@kbn/esql-ast';
|
||||
import { ENRICH_MODES } from '../definitions/settings';
|
||||
import { EDITOR_MARKER } from './constants';
|
||||
|
@ -26,7 +27,6 @@ import {
|
|||
isSettingItem,
|
||||
pipePrecedesCurrentWord,
|
||||
getFunctionDefinition,
|
||||
isIdentifier,
|
||||
} from './helpers';
|
||||
|
||||
function findNode(nodes: ESQLAstItem[], offset: number): ESQLSingleAstItem | undefined {
|
||||
|
|
|
@ -84,10 +84,6 @@ export function isColumnItem(arg: ESQLAstItem): arg is ESQLColumn {
|
|||
return isSingleItem(arg) && arg.type === 'column';
|
||||
}
|
||||
|
||||
export function isIdentifier(arg: ESQLAstItem): arg is ESQLIdentifier {
|
||||
return isSingleItem(arg) && arg.type === 'identifier';
|
||||
}
|
||||
|
||||
export function isLiteralItem(arg: ESQLAstItem): arg is ESQLLiteral {
|
||||
return isSingleItem(arg) && arg.type === 'literal';
|
||||
}
|
||||
|
|
|
@ -7,7 +7,7 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import type { ESQLRealField } from '../validation/types';
|
||||
import type { ESQLRealField, JoinIndexAutocompleteItem } from '../validation/types';
|
||||
|
||||
/** @internal **/
|
||||
type CallbackFn<Options = {}, Result = string> = (ctx?: Options) => Result[] | Promise<Result[]>;
|
||||
|
@ -46,6 +46,7 @@ export interface ESQLCallbacks {
|
|||
>;
|
||||
getPreferences?: () => Promise<{ histogramBarTarget: number }>;
|
||||
getFieldsMetadata?: Promise<PartialFieldsMetadataClient>;
|
||||
getJoinIndices?: () => Promise<{ indices: JoinIndexAutocompleteItem[] }>;
|
||||
}
|
||||
|
||||
export type ReasonTypes = 'missingCommand' | 'unsupportedFunction' | 'unknownFunction';
|
||||
|
|
|
@ -19,7 +19,7 @@ describe('FROM', () => {
|
|||
await validate('SHOW');
|
||||
await validate('ROW \t');
|
||||
|
||||
expect(callbacks.getColumnsFor.mock.calls.length).toBe(0);
|
||||
expect((callbacks.getColumnsFor as any).mock.calls.length).toBe(0);
|
||||
});
|
||||
|
||||
test('loads fields with FROM source when commands after pipe present', async () => {
|
||||
|
@ -27,6 +27,6 @@ describe('FROM', () => {
|
|||
|
||||
await validate('FROM kibana_ecommerce METADATA _id | eval');
|
||||
|
||||
expect(callbacks.getColumnsFor.mock.calls.length).toBe(1);
|
||||
expect((callbacks.getColumnsFor as any).mock.calls.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* 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 * as helpers from '../helpers';
|
||||
|
||||
export const validationJoinCommandTestSuite = (setup: helpers.Setup) => {
|
||||
describe('validation', () => {
|
||||
describe('command', () => {
|
||||
describe('<LEFT | RIGHT | LOOKUP> JOIN <index> [ AS <alias> ] ON <condition> [, <condition> [, ...]]', () => {
|
||||
describe('... <index> [ AS <alias> ]', () => {
|
||||
test('validates the most basic query', async () => {
|
||||
const { expectErrors } = await setup();
|
||||
|
||||
await expectErrors('FROM index | LEFT JOIN join_index ON stringField', []);
|
||||
});
|
||||
|
||||
test('raises error, when index is not suitable for JOIN command', async () => {
|
||||
const { expectErrors } = await setup();
|
||||
|
||||
await expectErrors('FROM index | LEFT JOIN index ON stringField', [
|
||||
'[index] index is not a valid JOIN index. Please use a "lookup" mode index JOIN commands.',
|
||||
]);
|
||||
await expectErrors('FROM index | LEFT JOIN non_existing_index_123 ON stringField', [
|
||||
'[non_existing_index_123] index is not a valid JOIN index. Please use a "lookup" mode index JOIN commands.',
|
||||
]);
|
||||
});
|
||||
|
||||
test('allows lookup index', async () => {
|
||||
const { expectErrors } = await setup();
|
||||
|
||||
await expectErrors('FROM index | LEFT JOIN join_index ON stringField', []);
|
||||
await expectErrors('FROM index | LEFT JOIN join_index_with_alias ON stringField', []);
|
||||
});
|
||||
|
||||
test('allows lookup index alias', async () => {
|
||||
const { expectErrors } = await setup();
|
||||
|
||||
await expectErrors('FROM index | LEFT JOIN join_index_alias_1 ON stringField', []);
|
||||
await expectErrors('FROM index | LEFT JOIN join_index_alias_2 ON stringField', []);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
};
|
|
@ -0,0 +1,13 @@
|
|||
/*
|
||||
* 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 * as helpers from './helpers';
|
||||
import { validationJoinCommandTestSuite } from './test_suites/validation.command.join';
|
||||
|
||||
validationJoinCommandTestSuite(helpers.setup);
|
|
@ -443,6 +443,18 @@ function getMessageAndTypeFromId<K extends ErrorTypes>({
|
|||
}
|
||||
),
|
||||
};
|
||||
case 'invalidJoinIndex':
|
||||
return {
|
||||
message: i18n.translate(
|
||||
'kbn-esql-validation-autocomplete.esql.validation.invalidJoinIndex',
|
||||
{
|
||||
defaultMessage:
|
||||
'[{identifier}] index is not a valid JOIN index.' +
|
||||
' Please use a "lookup" mode index JOIN commands.',
|
||||
values: { identifier: out.identifier },
|
||||
}
|
||||
),
|
||||
};
|
||||
}
|
||||
return { message: '' };
|
||||
}
|
||||
|
@ -533,6 +545,11 @@ export const errors = {
|
|||
errors.byId('aggInAggFunction', fn.location, {
|
||||
nestedAgg: fn.name,
|
||||
}),
|
||||
|
||||
invalidJoinIndex: (identifier: ESQLIdentifier): ESQLMessage =>
|
||||
errors.byId('invalidJoinIndex', identifier.location, {
|
||||
identifier: identifier.name,
|
||||
}),
|
||||
};
|
||||
|
||||
export function getUnknownTypeLabel() {
|
||||
|
|
|
@ -2204,7 +2204,9 @@
|
|||
},
|
||||
{
|
||||
"query": "ROW a=1::LONG | LOOKUP JOIN t ON a",
|
||||
"error": [],
|
||||
"error": [
|
||||
"[t] index is not a valid JOIN index. Please use a \"lookup\" mode index JOIN commands."
|
||||
],
|
||||
"warning": []
|
||||
},
|
||||
{
|
||||
|
|
|
@ -42,6 +42,13 @@ export interface ReferenceMaps {
|
|||
fields: Map<string, ESQLRealField>;
|
||||
policies: Map<string, ESQLPolicy>;
|
||||
query: string;
|
||||
joinIndices: JoinIndexAutocompleteItem[];
|
||||
}
|
||||
|
||||
export interface JoinIndexAutocompleteItem {
|
||||
name: string;
|
||||
mode: 'lookup' | string;
|
||||
aliases: string[];
|
||||
}
|
||||
|
||||
export interface ValidationErrors {
|
||||
|
@ -204,6 +211,10 @@ export interface ValidationErrors {
|
|||
message: string;
|
||||
type: { fn: string };
|
||||
};
|
||||
invalidJoinIndex: {
|
||||
message: string;
|
||||
type: { identifier: string };
|
||||
};
|
||||
}
|
||||
|
||||
export type ErrorTypes = keyof ValidationErrors;
|
||||
|
|
|
@ -505,8 +505,10 @@ describe('validation logic', () => {
|
|||
testErrorsAndWarnings('from index | limit 4', []);
|
||||
});
|
||||
|
||||
describe('lookup', () => {
|
||||
testErrorsAndWarnings('ROW a=1::LONG | LOOKUP JOIN t ON a', []);
|
||||
describe('join', () => {
|
||||
testErrorsAndWarnings('ROW a=1::LONG | LOOKUP JOIN t ON a', [
|
||||
'[t] index is not a valid JOIN index. Please use a "lookup" mode index JOIN commands.',
|
||||
]);
|
||||
});
|
||||
|
||||
describe('keep', () => {
|
||||
|
@ -1729,7 +1731,7 @@ describe('validation logic', () => {
|
|||
getPreferences: /Unknown/,
|
||||
getFieldsMetadata: /Unknown/,
|
||||
};
|
||||
return excludedCallback.map((callback) => contentByCallback[callback]) || [];
|
||||
return excludedCallback.map((callback) => (contentByCallback as any)[callback]) || [];
|
||||
}
|
||||
|
||||
function getPartialCallbackMocks(exclude?: string) {
|
||||
|
|
|
@ -21,8 +21,15 @@ import {
|
|||
ESQLMessage,
|
||||
ESQLSource,
|
||||
walk,
|
||||
isBinaryExpression,
|
||||
isIdentifier,
|
||||
} from '@kbn/esql-ast';
|
||||
import type { ESQLAstField, ESQLIdentifier } from '@kbn/esql-ast/src/types';
|
||||
import type {
|
||||
ESQLAstField,
|
||||
ESQLAstJoinCommand,
|
||||
ESQLIdentifier,
|
||||
ESQLProperNode,
|
||||
} from '@kbn/esql-ast/src/types';
|
||||
import {
|
||||
CommandModeDefinition,
|
||||
CommandOptionsDefinition,
|
||||
|
@ -55,7 +62,6 @@ import {
|
|||
getQuotedColumnName,
|
||||
isInlineCastItem,
|
||||
getSignaturesWithMatchingArity,
|
||||
isIdentifier,
|
||||
isFunctionOperatorParam,
|
||||
isMaybeAggFunction,
|
||||
isParametrized,
|
||||
|
@ -1104,6 +1110,72 @@ const validateMetricsCommand = (
|
|||
return messages;
|
||||
};
|
||||
|
||||
/**
|
||||
* Validates the JOIN command:
|
||||
*
|
||||
* <LEFT | RIGHT | LOOKUP> JOIN <target> ON <conditions>
|
||||
* <LEFT | RIGHT | LOOKUP> JOIN index [ = alias ] ON <condition> [, <condition> [, ...]]
|
||||
*/
|
||||
const validateJoinCommand = (
|
||||
command: ESQLAstJoinCommand,
|
||||
references: ReferenceMaps
|
||||
): ESQLMessage[] => {
|
||||
const messages: ESQLMessage[] = [];
|
||||
const { commandType, args } = command;
|
||||
const { joinIndices } = references;
|
||||
|
||||
if (!['left', 'right', 'lookup'].includes(commandType)) {
|
||||
return [errors.unexpected(command.location, 'JOIN command type')];
|
||||
}
|
||||
|
||||
const target = args[0] as ESQLProperNode;
|
||||
let index: ESQLIdentifier;
|
||||
let alias: ESQLIdentifier | undefined;
|
||||
|
||||
if (isBinaryExpression(target)) {
|
||||
if (target.name === 'as') {
|
||||
alias = target.args[1] as ESQLIdentifier;
|
||||
index = target.args[0] as ESQLIdentifier;
|
||||
|
||||
if (!isIdentifier(index) || !isIdentifier(alias)) {
|
||||
return [errors.unexpected(target.location)];
|
||||
}
|
||||
} else {
|
||||
return [errors.unexpected(target.location)];
|
||||
}
|
||||
} else if (isIdentifier(target)) {
|
||||
index = target as ESQLIdentifier;
|
||||
} else {
|
||||
return [errors.unexpected(target.location)];
|
||||
}
|
||||
|
||||
let isIndexFound = false;
|
||||
for (const { name, aliases } of joinIndices) {
|
||||
if (index.name === name) {
|
||||
isIndexFound = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (aliases) {
|
||||
for (const aliasName of aliases) {
|
||||
if (index.name === aliasName) {
|
||||
isIndexFound = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (!isIndexFound) {
|
||||
const error = errors.invalidJoinIndex(index);
|
||||
messages.push(error);
|
||||
|
||||
return messages;
|
||||
}
|
||||
|
||||
return messages;
|
||||
};
|
||||
|
||||
function validateCommand(
|
||||
command: ESQLCommand,
|
||||
references: ReferenceMaps,
|
||||
|
@ -1131,6 +1203,12 @@ function validateCommand(
|
|||
messages.push(...validateMetricsCommand(metrics, references));
|
||||
break;
|
||||
}
|
||||
case 'join': {
|
||||
const join = command as ESQLAstJoinCommand;
|
||||
const joinCommandErrors = validateJoinCommand(join, references);
|
||||
messages.push(...joinCommandErrors);
|
||||
break;
|
||||
}
|
||||
default: {
|
||||
// Now validate arguments
|
||||
for (const commandArg of command.args) {
|
||||
|
@ -1256,6 +1334,7 @@ export const ignoreErrorsMap: Record<keyof ESQLCallbacks, ErrorTypes[]> = {
|
|||
getPolicies: ['unknownPolicy'],
|
||||
getPreferences: [],
|
||||
getFieldsMetadata: [],
|
||||
getJoinIndices: [],
|
||||
};
|
||||
|
||||
/**
|
||||
|
@ -1325,13 +1404,15 @@ async function validateAst(
|
|||
|
||||
const { ast } = parsingResult;
|
||||
|
||||
const [sources, availableFields, availablePolicies] = await Promise.all([
|
||||
const [sources, availableFields, availablePolicies, joinIndices] = await Promise.all([
|
||||
// retrieve the list of available sources
|
||||
retrieveSources(ast, callbacks),
|
||||
// retrieve available fields (if a source command has been defined)
|
||||
retrieveFields(queryString, ast, callbacks),
|
||||
// retrieve available policies (if an enrich command has been defined)
|
||||
retrievePolicies(ast, callbacks),
|
||||
// retrieve indices for join command
|
||||
callbacks?.getJoinIndices?.(),
|
||||
]);
|
||||
|
||||
if (availablePolicies.size) {
|
||||
|
@ -1366,6 +1447,7 @@ async function validateAst(
|
|||
policies: availablePolicies,
|
||||
variables,
|
||||
query: queryString,
|
||||
joinIndices: joinIndices?.indices || [],
|
||||
};
|
||||
const commandMessages = validateCommand(command, references, ast, index);
|
||||
messages.push(...commandMessages);
|
||||
|
|
|
@ -7,8 +7,4 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { ESQLEditorProps } from '@kbn/esql-editor';
|
||||
|
||||
export interface EsqlPluginStart {
|
||||
Editor: React.ComponentType<ESQLEditorProps>;
|
||||
}
|
||||
export type * from './types';
|
|
@ -7,10 +7,11 @@
|
|||
* License v3.0 only", or the "Server Side Public License, v 1".
|
||||
*/
|
||||
|
||||
import { EsqlPlugin } from './plugin';
|
||||
export type { ESQLEditorProps } from '@kbn/esql-editor';
|
||||
export type { EsqlPluginStart } from './types';
|
||||
import { EsqlPlugin, type EsqlPluginStart } from './plugin';
|
||||
|
||||
export { ESQLLangEditor } from './create_editor';
|
||||
export type { ESQLEditorProps } from '@kbn/esql-editor';
|
||||
export type { EsqlPluginStart };
|
||||
|
||||
export function plugin() {
|
||||
return new EsqlPlugin();
|
||||
|
|
|
@ -15,6 +15,7 @@ import type { Storage } from '@kbn/kibana-utils-plugin/public';
|
|||
import type { IndexManagementPluginSetup } from '@kbn/index-management-shared-types';
|
||||
import type { FieldsMetadataPublicStart } from '@kbn/fields-metadata-plugin/public';
|
||||
import type { UsageCollectionStart } from '@kbn/usage-collection-plugin/public';
|
||||
import type { EsqlPluginStart } from './plugin';
|
||||
|
||||
export let core: CoreStart;
|
||||
|
||||
|
@ -26,6 +27,7 @@ interface ServiceDeps {
|
|||
indexManagementApiService?: IndexManagementPluginSetup['apiService'];
|
||||
fieldsMetadata?: FieldsMetadataPublicStart;
|
||||
usageCollection?: UsageCollectionStart;
|
||||
esql: EsqlPluginStart;
|
||||
}
|
||||
|
||||
const servicesReady$ = new BehaviorSubject<ServiceDeps | undefined>(undefined);
|
||||
|
@ -42,6 +44,7 @@ export const untilPluginStartServicesReady = () => {
|
|||
};
|
||||
|
||||
export const setKibanaServices = (
|
||||
esql: EsqlPluginStart,
|
||||
kibanaCore: CoreStart,
|
||||
dataViews: DataViewsPublicPluginStart,
|
||||
expressions: ExpressionsStart,
|
||||
|
@ -59,5 +62,6 @@ export const setKibanaServices = (
|
|||
indexManagementApiService: indexManagement?.apiService,
|
||||
fieldsMetadata,
|
||||
usageCollection,
|
||||
esql,
|
||||
});
|
||||
};
|
||||
|
|
|
@ -22,8 +22,15 @@ import {
|
|||
UPDATE_ESQL_QUERY_TRIGGER,
|
||||
} from './triggers';
|
||||
import { setKibanaServices } from './kibana_services';
|
||||
import { JoinIndicesAutocompleteResult } from '../common';
|
||||
import { cacheNonParametrizedAsyncFunction } from './util/cache';
|
||||
|
||||
interface EsqlPluginStart {
|
||||
interface EsqlPluginSetupDependencies {
|
||||
indexManagement: IndexManagementPluginSetup;
|
||||
uiActions: UiActionsSetup;
|
||||
}
|
||||
|
||||
interface EsqlPluginStartDependencies {
|
||||
dataViews: DataViewsPublicPluginStart;
|
||||
expressions: ExpressionsStart;
|
||||
uiActions: UiActionsStart;
|
||||
|
@ -32,15 +39,14 @@ interface EsqlPluginStart {
|
|||
usageCollection?: UsageCollectionStart;
|
||||
}
|
||||
|
||||
interface EsqlPluginSetup {
|
||||
indexManagement: IndexManagementPluginSetup;
|
||||
uiActions: UiActionsSetup;
|
||||
export interface EsqlPluginStart {
|
||||
getJoinIndicesAutocomplete: () => Promise<JoinIndicesAutocompleteResult>;
|
||||
}
|
||||
|
||||
export class EsqlPlugin implements Plugin<{}, void> {
|
||||
export class EsqlPlugin implements Plugin<{}, EsqlPluginStart> {
|
||||
private indexManagement?: IndexManagementPluginSetup;
|
||||
|
||||
public setup(_: CoreSetup, { indexManagement, uiActions }: EsqlPluginSetup) {
|
||||
public setup(_: CoreSetup, { indexManagement, uiActions }: EsqlPluginSetupDependencies) {
|
||||
this.indexManagement = indexManagement;
|
||||
|
||||
uiActions.registerTrigger(updateESQLQueryTrigger);
|
||||
|
@ -50,12 +56,38 @@ export class EsqlPlugin implements Plugin<{}, void> {
|
|||
|
||||
public start(
|
||||
core: CoreStart,
|
||||
{ dataViews, expressions, data, uiActions, fieldsMetadata, usageCollection }: EsqlPluginStart
|
||||
): void {
|
||||
{
|
||||
dataViews,
|
||||
expressions,
|
||||
data,
|
||||
uiActions,
|
||||
fieldsMetadata,
|
||||
usageCollection,
|
||||
}: EsqlPluginStartDependencies
|
||||
): EsqlPluginStart {
|
||||
const storage = new Storage(localStorage);
|
||||
const appendESQLAction = new UpdateESQLQueryAction(data);
|
||||
|
||||
uiActions.addTriggerAction(UPDATE_ESQL_QUERY_TRIGGER, appendESQLAction);
|
||||
|
||||
const getJoinIndicesAutocomplete = cacheNonParametrizedAsyncFunction(
|
||||
async () => {
|
||||
const result = await core.http.get<JoinIndicesAutocompleteResult>(
|
||||
'/internal/esql/autocomplete/join/indices'
|
||||
);
|
||||
|
||||
return result;
|
||||
},
|
||||
1000 * 60 * 5, // Keep the value in cache for 5 minutes
|
||||
1000 * 15 // Refresh the cache in the background only if 15 seconds passed since the last call
|
||||
);
|
||||
|
||||
const start = {
|
||||
getJoinIndicesAutocomplete,
|
||||
};
|
||||
|
||||
setKibanaServices(
|
||||
start,
|
||||
core,
|
||||
dataViews,
|
||||
expressions,
|
||||
|
@ -64,6 +96,8 @@ export class EsqlPlugin implements Plugin<{}, void> {
|
|||
fieldsMetadata,
|
||||
usageCollection
|
||||
);
|
||||
|
||||
return start;
|
||||
}
|
||||
|
||||
public stop() {}
|
||||
|
|
123
src/platform/plugins/shared/esql/public/util/cache.test.ts
Normal file
123
src/platform/plugins/shared/esql/public/util/cache.test.ts
Normal file
|
@ -0,0 +1,123 @@
|
|||
/*
|
||||
* 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 { cacheNonParametrizedAsyncFunction } from './cache';
|
||||
|
||||
it('returns the value returned by the original function', async () => {
|
||||
const fn = jest.fn().mockResolvedValue('value');
|
||||
const cached = cacheNonParametrizedAsyncFunction(fn);
|
||||
const value = await cached();
|
||||
|
||||
expect(value).toBe('value');
|
||||
});
|
||||
|
||||
it('immediate consecutive calls do not call the original function', async () => {
|
||||
const fn = jest.fn().mockResolvedValue('value');
|
||||
const cached = cacheNonParametrizedAsyncFunction(fn);
|
||||
const value1 = await cached();
|
||||
|
||||
expect(fn.mock.calls.length).toBe(1);
|
||||
|
||||
const value2 = await cached();
|
||||
|
||||
expect(fn.mock.calls.length).toBe(1);
|
||||
|
||||
const value3 = await cached();
|
||||
|
||||
expect(fn.mock.calls.length).toBe(1);
|
||||
|
||||
expect(value1).toBe('value');
|
||||
expect(value2).toBe('value');
|
||||
expect(value3).toBe('value');
|
||||
});
|
||||
|
||||
it('immediate consecutive synchronous calls do not call the original function', async () => {
|
||||
const fn = jest.fn().mockResolvedValue('value');
|
||||
const cached = cacheNonParametrizedAsyncFunction(fn);
|
||||
const value1 = cached();
|
||||
const value2 = cached();
|
||||
const value3 = cached();
|
||||
|
||||
expect(fn.mock.calls.length).toBe(1);
|
||||
expect(await value1).toBe('value');
|
||||
expect(await value2).toBe('value');
|
||||
expect(await value3).toBe('value');
|
||||
expect(fn.mock.calls.length).toBe(1);
|
||||
});
|
||||
|
||||
it('does not call original function if cached value is fresh enough', async () => {
|
||||
let time = 1;
|
||||
let value = 'value1';
|
||||
const now = jest.fn(() => time);
|
||||
const fn = jest.fn(async () => value);
|
||||
const cached = cacheNonParametrizedAsyncFunction(fn, 100, 20, now);
|
||||
|
||||
const value1 = await cached();
|
||||
|
||||
expect(fn.mock.calls.length).toBe(1);
|
||||
expect(value1).toBe('value1');
|
||||
|
||||
time = 10;
|
||||
value = 'value2';
|
||||
|
||||
const value2 = await cached();
|
||||
|
||||
expect(fn.mock.calls.length).toBe(1);
|
||||
expect(value2).toBe('value1');
|
||||
});
|
||||
|
||||
it('immediately returns cached value, but calls original function when sufficient time passed', async () => {
|
||||
let time = 1;
|
||||
let value = 'value1';
|
||||
const now = jest.fn(() => time);
|
||||
const fn = jest.fn(async () => value);
|
||||
const cached = cacheNonParametrizedAsyncFunction(fn, 100, 20, now);
|
||||
|
||||
const value1 = await cached();
|
||||
|
||||
expect(fn.mock.calls.length).toBe(1);
|
||||
expect(value1).toBe('value1');
|
||||
|
||||
time = 30;
|
||||
value = 'value2';
|
||||
|
||||
const value2 = await cached();
|
||||
|
||||
expect(fn.mock.calls.length).toBe(2);
|
||||
expect(value2).toBe('value1');
|
||||
|
||||
time = 50;
|
||||
value = 'value3';
|
||||
|
||||
const value3 = await cached();
|
||||
|
||||
expect(fn.mock.calls.length).toBe(2);
|
||||
expect(value3).toBe('value2');
|
||||
});
|
||||
|
||||
it('blocks and refreshes the value when cache expires', async () => {
|
||||
let time = 1;
|
||||
let value = 'value1';
|
||||
const now = jest.fn(() => time);
|
||||
const fn = jest.fn(async () => value);
|
||||
const cached = cacheNonParametrizedAsyncFunction(fn, 100, 20, now);
|
||||
|
||||
const value1 = await cached();
|
||||
|
||||
expect(fn.mock.calls.length).toBe(1);
|
||||
expect(value1).toBe('value1');
|
||||
|
||||
time = 130;
|
||||
value = 'value2';
|
||||
|
||||
const value2 = await cached();
|
||||
|
||||
expect(fn.mock.calls.length).toBe(2);
|
||||
expect(value2).toBe('value2');
|
||||
});
|
56
src/platform/plugins/shared/esql/public/util/cache.ts
Normal file
56
src/platform/plugins/shared/esql/public/util/cache.ts
Normal file
|
@ -0,0 +1,56 @@
|
|||
/*
|
||||
* 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".
|
||||
*/
|
||||
|
||||
/**
|
||||
* Given a non-parametrized async function, returns a function which caches the
|
||||
* result of that function. When a cached value is available, it returns
|
||||
* immediately that value and refreshes the cache in the background. When the
|
||||
* cached value is too old, it is discarded and the function is called again.
|
||||
*
|
||||
* @param fn Function to call to get the value.
|
||||
* @param maxCacheDuration For how long to keep a value in the cache,
|
||||
* in milliseconds. Defaults to 5 minutes.
|
||||
* @param refreshAfter Minimum time between cache refreshes, in milliseconds.
|
||||
* Defaults to 15 seconds.
|
||||
* @param now Function which returns the current time in milliseconds, defaults to `Date.now`.
|
||||
* @returns A function which returns the cached value.
|
||||
*/
|
||||
export const cacheNonParametrizedAsyncFunction = <T>(
|
||||
fn: () => Promise<T>,
|
||||
maxCacheDuration: number = 1000 * 60 * 5,
|
||||
refreshAfter: number = 1000 * 15,
|
||||
now: () => number = Date.now
|
||||
) => {
|
||||
let lastCallTime = 0;
|
||||
let value: Promise<T> | undefined;
|
||||
|
||||
return () => {
|
||||
const time = now();
|
||||
|
||||
if (time - lastCallTime > maxCacheDuration) {
|
||||
value = undefined;
|
||||
}
|
||||
|
||||
if (!value) {
|
||||
lastCallTime = time;
|
||||
value = fn();
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
if (time - lastCallTime > refreshAfter) {
|
||||
lastCallTime = time;
|
||||
Promise.resolve().then(() => {
|
||||
value = fn();
|
||||
});
|
||||
}
|
||||
|
||||
return value;
|
||||
};
|
||||
};
|
|
@ -8,7 +8,7 @@
|
|||
*/
|
||||
|
||||
import type { ElasticsearchClient } from '@kbn/core/server';
|
||||
import type { JoinIndexAutocompleteItem, JoinIndicesAutocompleteResult } from './types';
|
||||
import type { JoinIndexAutocompleteItem, JoinIndicesAutocompleteResult } from '../../common';
|
||||
|
||||
export interface EsqlServiceOptions {
|
||||
client: ElasticsearchClient;
|
||||
|
|
|
@ -32,6 +32,9 @@ class LocalStorageMock {
|
|||
const storage = new LocalStorageMock({}) as unknown as Storage;
|
||||
|
||||
setKibanaServices(
|
||||
{
|
||||
getJoinIndicesAutocomplete: async () => ({ indices: [] }),
|
||||
},
|
||||
coreMock.createStart(),
|
||||
dataViewPluginMocks.createStartContract(),
|
||||
expressionsPluginMock.createStartContract(),
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue