[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:

![Kapture 2025-01-09 at 19 02
17](https://github.com/user-attachments/assets/5ecaddb7-d8c1-4768-a22d-82d2adc521ce)

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:
Vadim Kibana 2025-01-15 15:14:19 +01:00 committed by GitHub
parent e7f0771be7
commit 571ee960ad
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
42 changed files with 1256 additions and 118 deletions

View file

@ -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(() => {

View file

@ -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;
}

View file

@ -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/**/*",

View file

@ -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 {

View file

@ -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:
*

View file

@ -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 })),
};
}

View file

@ -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([',', '|']);
});
});
});
});

View file

@ -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);
});
});

View file

@ -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 })),
};
}

View file

@ -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', () => {

View file

@ -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.

View file

@ -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;
};

View file

@ -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;
}

View file

@ -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',
]);
});
});

View file

@ -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];
};

View file

@ -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,

View file

@ -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 }

View file

@ -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';

View file

@ -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';

View file

@ -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',

View file

@ -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,
},
];

View file

@ -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[];

View file

@ -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 {

View file

@ -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';
}

View file

@ -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';

View file

@ -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);
});
});

View file

@ -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', []);
});
});
});
});
});
};

View file

@ -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);

View file

@ -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() {

View file

@ -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": []
},
{

View file

@ -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;

View file

@ -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) {

View file

@ -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);

View file

@ -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';

View file

@ -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();

View file

@ -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,
});
};

View file

@ -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() {}

View 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');
});

View 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;
};
};

View file

@ -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;

View file

@ -32,6 +32,9 @@ class LocalStorageMock {
const storage = new LocalStorageMock({}) as unknown as Storage;
setKibanaServices(
{
getJoinIndicesAutocomplete: async () => ({ indices: [] }),
},
coreMock.createStart(),
dataViewPluginMocks.createStartContract(),
expressionsPluginMock.createStartContract(),