diff --git a/src/platform/packages/private/kbn-esql-editor/src/esql_editor.tsx b/src/platform/packages/private/kbn-esql-editor/src/esql_editor.tsx index 995af0088ae44..b4aea90dfc3eb 100644 --- a/src/platform/packages/private/kbn-esql-editor/src/esql_editor.tsx +++ b/src/platform/packages/private/kbn-esql-editor/src/esql_editor.tsx @@ -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(() => { diff --git a/src/platform/packages/private/kbn-esql-editor/src/types.ts b/src/platform/packages/private/kbn-esql-editor/src/types.ts index d1cd894ebfd37..177dc2b398873 100644 --- a/src/platform/packages/private/kbn-esql-editor/src/types.ts +++ b/src/platform/packages/private/kbn-esql-editor/src/types.ts @@ -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; +} + export interface ESQLEditorDeps { core: CoreStart; dataViews: DataViewsPublicPluginStart; @@ -79,4 +93,5 @@ export interface ESQLEditorDeps { indexManagementApiService?: IndexManagementPluginSetup['apiService']; fieldsMetadata?: FieldsMetadataPublicStart; usageCollection?: UsageCollectionStart; + esql?: EsqlPluginStartBase; } diff --git a/src/platform/packages/private/kbn-esql-editor/tsconfig.json b/src/platform/packages/private/kbn-esql-editor/tsconfig.json index e29b2d78e3897..96c05ba88d600 100644 --- a/src/platform/packages/private/kbn-esql-editor/tsconfig.json +++ b/src/platform/packages/private/kbn-esql-editor/tsconfig.json @@ -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/**/*", diff --git a/src/platform/packages/shared/kbn-esql-ast/index.ts b/src/platform/packages/shared/kbn-esql-ast/index.ts index d7254f9de51ba..882cbfa0c28c4 100644 --- a/src/platform/packages/shared/kbn-esql-ast/index.ts +++ b/src/platform/packages/shared/kbn-esql-ast/index.ts @@ -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 { diff --git a/src/platform/packages/shared/kbn-esql-ast/src/ast/helpers.ts b/src/platform/packages/shared/kbn-esql-ast/src/ast/helpers.ts index 2520b6ffabdb2..55a21513a2b75 100644 --- a/src/platform/packages/shared/kbn-esql-ast/src/ast/helpers.ts +++ b/src/platform/packages/shared/kbn-esql-ast/src/ast/helpers.ts @@ -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: * diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/__tests__/helpers.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/__tests__/helpers.ts index 02d2c062ccca7..ca0db5866dfbe 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/__tests__/helpers.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/__tests__/helpers.ts @@ -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 })), }; } diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.join.test.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.join.test.ts new file mode 100644 index 0000000000000..16b0745435184 --- /dev/null +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.command.join.test.ts @@ -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(' JOIN [ AS ] ON [, [, ...]]', () => { + describe(' 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('... ...', () => { + 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 ', () => { + 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([',', '|']); + }); + }); + }); +}); diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.suggest.test.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.suggest.test.ts index c7bf9079f9155..1cf55c6c71296 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.suggest.test.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/autocomplete.suggest.test.ts @@ -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); }); }); diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/helpers.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/helpers.ts index c49b05985c86a..0d62b5040c034 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/helpers.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/__tests__/helpers.ts @@ -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 })), }; } diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts index f8d72fecf229a..1e714091eeefe 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.test.ts @@ -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', () => { diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts index e0b5d8c3ffcee..5e4140e407c9a 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/autocomplete.ts @@ -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. diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/commands/join/index.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/commands/join/index.ts new file mode 100644 index 0000000000000..70314bea364f3 --- /dev/null +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/commands/join/index.ts @@ -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 +): 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 => { + 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); + 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 = /(?,)?(?\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; +}; diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/commands/join/types.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/commands/join/types.ts new file mode 100644 index 0000000000000..055840219c302 --- /dev/null +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/commands/join/types.ts @@ -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. + * + * ``` + * JOIN [ AS ] ON + * | || || | | || | | | + * | || || | | || | | | + * | || || | | || | | 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. + * + * ``` + * JOIN [ AS ] ON [, [, ...]] + * | || || | | || | | || | | | + * | || || | | || | | || | | | + * | || || | | || | | || | | 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 `` 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; +} diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/commands/join/util.test.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/commands/join/util.test.ts new file mode 100644 index 0000000000000..a19fffd8e1fb3 --- /dev/null +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/commands/join/util.test.ts @@ -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 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', + ]); + }); +}); diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/commands/join/util.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/commands/join/util.ts new file mode 100644 index 0000000000000..eae48cef75b4b --- /dev/null +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/commands/join/util.ts @@ -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 = + /^(?\w+((?\s+((?(JOIN|JOI|JO|J)((?\s+((?\S+((?\s+(?(AS|A))?(?\s+(((?\S+)?(?\s+)?)?))?((?(ON|O)((?\s+(?[^\s])?)?))?))?))?))?))?))?))?/i; + +const positions: Array = [ + '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 `` + * 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]; +}; diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/complete_items.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/complete_items.ts index 0c448d4814f96..493a070b46497 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/complete_items.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/complete_items.ts @@ -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> -): 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, diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/factories.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/factories.ts index d9813bb0e91a1..030c02ad1b81a 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/factories.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/factories.ts @@ -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 -): 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 } diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts index f359b928f3c36..2fa1ce943cd33 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/autocomplete/helper.ts @@ -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'; diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/code_actions/actions.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/code_actions/actions.ts index 7c9d5d7ae8ba2..5c4840cf6493b 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/code_actions/actions.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/code_actions/actions.ts @@ -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'; diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/definitions/builtin.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/definitions/builtin.ts index 3f5040efbcb10..0460b708456a6 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/definitions/builtin.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/definitions/builtin.ts @@ -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', diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/definitions/commands.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/definitions/commands.ts index 950dac5e2d50b..693be70beefb6 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/definitions/commands.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/definitions/commands.ts @@ -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> = [ 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 + // '… | JOIN index ON index.field = index2.field', + // '… | JOIN index AS alias ON index.field = index2.field', + // '… | 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, + }, ]; diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/definitions/types.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/definitions/types.ts index ce649acec5b44..6a9442faca1fa 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/definitions/types.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/definitions/types.ts @@ -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 { 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 { getSuggestedVariableName: () => string, getExpressionType: (expression: ESQLAstItem | undefined) => SupportedDataType | 'unknown', getPreferences?: () => Promise<{ histogramBarTarget: number } | undefined>, - fullTextAst?: ESQLAst + fullTextAst?: ESQLAst, + definition?: CommandDefinition, + callbacks?: ESQLCallbacks ) => Promise; /** @deprecated this property will disappear in the future */ signature: { @@ -207,6 +216,11 @@ export interface CommandBaseDefinition { }; } +export interface CommandTypeDefinition { + name: string; + description?: string; +} + export interface CommandOptionsDefinition extends CommandBaseDefinition { wrapped?: string[]; diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/shared/context.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/shared/context.ts index cc7c36abf64f7..4632c49070faa 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/shared/context.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/shared/context.ts @@ -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 { diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/shared/helpers.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/shared/helpers.ts index 2c864a487026c..92ac10cb1c456 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/shared/helpers.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/shared/helpers.ts @@ -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'; } diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/shared/types.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/shared/types.ts index 1caa2c480864e..5ff9285517b07 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/shared/types.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/shared/types.ts @@ -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 = (ctx?: Options) => Result[] | Promise; @@ -46,6 +46,7 @@ export interface ESQLCallbacks { >; getPreferences?: () => Promise<{ histogramBarTarget: number }>; getFieldsMetadata?: Promise; + getJoinIndices?: () => Promise<{ indices: JoinIndexAutocompleteItem[] }>; } export type ReasonTypes = 'missingCommand' | 'unsupportedFunction' | 'unknownFunction'; diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/__tests__/callbacks.test.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/__tests__/callbacks.test.ts index 61c0455fa1b0d..0461036492b94 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/__tests__/callbacks.test.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/__tests__/callbacks.test.ts @@ -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); }); }); diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/__tests__/test_suites/validation.command.join.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/__tests__/test_suites/validation.command.join.ts new file mode 100644 index 0000000000000..7d3b6b5e2bae2 --- /dev/null +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/__tests__/test_suites/validation.command.join.ts @@ -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(' JOIN [ AS ] ON [, [, ...]]', () => { + describe('... [ AS ]', () => { + 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', []); + }); + }); + }); + }); + }); +}; diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/__tests__/validation.command.join.test.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/__tests__/validation.command.join.test.ts new file mode 100644 index 0000000000000..f23d34062ec4a --- /dev/null +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/__tests__/validation.command.join.test.ts @@ -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); diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/errors.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/errors.ts index abf4db6e7fe69..5951df2d3c2f4 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/errors.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/errors.ts @@ -443,6 +443,18 @@ function getMessageAndTypeFromId({ } ), }; + 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() { diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/esql_validation_meta_tests.json b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/esql_validation_meta_tests.json index 2e6b4a085656f..16b89dcc8306d 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/esql_validation_meta_tests.json +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/esql_validation_meta_tests.json @@ -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": [] }, { diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/types.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/types.ts index 2beffbfa26425..ef529d9398637 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/types.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/types.ts @@ -42,6 +42,13 @@ export interface ReferenceMaps { fields: Map; policies: Map; 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; diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/validation.test.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/validation.test.ts index f1256622fe7f8..7da2d74a29fc6 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/validation.test.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/validation.test.ts @@ -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) { diff --git a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/validation.ts b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/validation.ts index ae8ab41da157e..9125f480ee4e7 100644 --- a/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/validation.ts +++ b/src/platform/packages/shared/kbn-esql-validation-autocomplete/src/validation/validation.ts @@ -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: + * + * JOIN ON + * JOIN index [ = alias ] ON [, [, ...]] + */ +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 = { 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); diff --git a/src/platform/plugins/shared/esql/public/types.ts b/src/platform/plugins/shared/esql/common/index.ts similarity index 77% rename from src/platform/plugins/shared/esql/public/types.ts rename to src/platform/plugins/shared/esql/common/index.ts index fbbc549ee4436..9b6ee5bbc44a7 100644 --- a/src/platform/plugins/shared/esql/public/types.ts +++ b/src/platform/plugins/shared/esql/common/index.ts @@ -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; -} +export type * from './types'; diff --git a/src/platform/plugins/shared/esql/server/services/types.ts b/src/platform/plugins/shared/esql/common/types.ts similarity index 100% rename from src/platform/plugins/shared/esql/server/services/types.ts rename to src/platform/plugins/shared/esql/common/types.ts diff --git a/src/platform/plugins/shared/esql/public/index.ts b/src/platform/plugins/shared/esql/public/index.ts index 8101796e28b4e..90473418e24b5 100644 --- a/src/platform/plugins/shared/esql/public/index.ts +++ b/src/platform/plugins/shared/esql/public/index.ts @@ -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(); diff --git a/src/platform/plugins/shared/esql/public/kibana_services.ts b/src/platform/plugins/shared/esql/public/kibana_services.ts index 3ada58d7c2aec..8c348cf4d287c 100644 --- a/src/platform/plugins/shared/esql/public/kibana_services.ts +++ b/src/platform/plugins/shared/esql/public/kibana_services.ts @@ -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(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, }); }; diff --git a/src/platform/plugins/shared/esql/public/plugin.ts b/src/platform/plugins/shared/esql/public/plugin.ts index 99199d21c1ef8..a196c4c974e2d 100755 --- a/src/platform/plugins/shared/esql/public/plugin.ts +++ b/src/platform/plugins/shared/esql/public/plugin.ts @@ -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; } -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( + '/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() {} diff --git a/src/platform/plugins/shared/esql/public/util/cache.test.ts b/src/platform/plugins/shared/esql/public/util/cache.test.ts new file mode 100644 index 0000000000000..423519fb8de17 --- /dev/null +++ b/src/platform/plugins/shared/esql/public/util/cache.test.ts @@ -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'); +}); diff --git a/src/platform/plugins/shared/esql/public/util/cache.ts b/src/platform/plugins/shared/esql/public/util/cache.ts new file mode 100644 index 0000000000000..10d1ae2d84f49 --- /dev/null +++ b/src/platform/plugins/shared/esql/public/util/cache.ts @@ -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 = ( + fn: () => Promise, + maxCacheDuration: number = 1000 * 60 * 5, + refreshAfter: number = 1000 * 15, + now: () => number = Date.now +) => { + let lastCallTime = 0; + let value: Promise | 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; + }; +}; diff --git a/src/platform/plugins/shared/esql/server/services/esql_service.ts b/src/platform/plugins/shared/esql/server/services/esql_service.ts index 2861d7859ae41..bf705b937edb4 100644 --- a/src/platform/plugins/shared/esql/server/services/esql_service.ts +++ b/src/platform/plugins/shared/esql/server/services/esql_service.ts @@ -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; diff --git a/x-pack/solutions/observability/plugins/investigate_app/.storybook/mock_kibana_services.ts b/x-pack/solutions/observability/plugins/investigate_app/.storybook/mock_kibana_services.ts index 1a8a07bf7a360..e6a6589ca660f 100644 --- a/x-pack/solutions/observability/plugins/investigate_app/.storybook/mock_kibana_services.ts +++ b/x-pack/solutions/observability/plugins/investigate_app/.storybook/mock_kibana_services.ts @@ -32,6 +32,9 @@ class LocalStorageMock { const storage = new LocalStorageMock({}) as unknown as Storage; setKibanaServices( + { + getJoinIndicesAutocomplete: async () => ({ indices: [] }), + }, coreMock.createStart(), dataViewPluginMocks.createStartContract(), expressionsPluginMock.createStartContract(),