mirror of
https://github.com/elastic/kibana.git
synced 2025-04-25 10:23:14 -04:00
## Summary Closes https://github.com/elastic/kibana/issues/172649 This PR extracts the ES|QL service like "validation", "autocomplete", "code_action" (quick fixes) and some other services into a separate package `@kbn/esql-validation-autocomplete` which has no tie with Monaco editor anymore. All the AST and grammar logic has been encapsulated into a single `@kbn/esql-ast` package who contains the `getAst` function together with the `parser` and `lexer`. ## Validation enhancements The validation API has been enhanced to gracefully degrade whenever callbacks are not provided, via the `ignoreOnMissingCallback` option. Tests are included as well for this scenario. ### Example plugin An example plugin app has been developed to show to to use the validation API <img width="500" alt="Screenshot 2024-03-22 at 17 45 38" src="ebc172d3
-ee61-4f3a-9e42-dcb9b15c7e69"> The app starts with a missing callback: <img width="855" alt="Screenshot 2024-03-22 at 17 36 22" src="c9b81370
-8a10-487d-b22f-2359e1365a54"> Toggling the ignore option will hide the index error due to the lack of `getSources` callback: <img width="816" alt="Screenshot 2024-03-22 at 17 28 17" src="931f4e5f
-3ad6-46f7-97ca-63d7bb66646b"> Warnings are shown as well when detected: <img width="807" alt="Screenshot 2024-03-22 at 17 35 56" src="5e3e0537
-cba2-475f-946b-0302867384ca"> #### Code snippet The example app produces a copyable code snippet of the current configuration of the validator:  ### Checklist - [x] [Documentation](https://www.elastic.co/guide/en/kibana/master/development-documentation.html) was added for features that require explanation or tutorials - [x] [Unit or functional tests](https://www.elastic.co/guide/en/kibana/master/development-tests.html) were updated or added to match the most common scenarios --------- Co-authored-by: kibanamachine <42973632+kibanamachine@users.noreply.github.com>
260 lines
8.4 KiB
TypeScript
260 lines
8.4 KiB
TypeScript
/*
|
|
* 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 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 or the Server
|
|
* Side Public License, v 1.
|
|
*/
|
|
|
|
import Fs from 'fs';
|
|
import Path from 'path';
|
|
import expect from '@kbn/expect';
|
|
import { MappingProperty } from '@elastic/elasticsearch/lib/api/types';
|
|
import { REPO_ROOT } from '@kbn/repo-info';
|
|
import uniqBy from 'lodash/uniqBy';
|
|
import { groupBy, mapValues } from 'lodash';
|
|
import { FtrProviderContext } from '../../ftr_provider_context';
|
|
|
|
function getConfigPath() {
|
|
return Path.resolve(
|
|
REPO_ROOT,
|
|
'packages',
|
|
'kbn-esql-validation-autocomplete',
|
|
'src',
|
|
'validation'
|
|
);
|
|
}
|
|
|
|
function getSetupPath() {
|
|
return Path.resolve(getConfigPath(), 'esql_validation_meta_tests.json');
|
|
}
|
|
|
|
function getMissmatchedPath() {
|
|
return Path.resolve(getConfigPath(), 'esql_validation_missmatches.json');
|
|
}
|
|
|
|
function readSetupFromESQLPackage() {
|
|
const esqlPackagePath = getSetupPath();
|
|
const json = Fs.readFileSync(esqlPackagePath, 'utf8');
|
|
const esqlPackage = JSON.parse(json);
|
|
return esqlPackage;
|
|
}
|
|
|
|
function createIndexRequest(
|
|
index: string,
|
|
fields: Array<{ name: string; type: string }>,
|
|
stringType: 'text' | 'keyword',
|
|
numberType: 'integer' | 'double' | 'long' | 'unsigned_long'
|
|
) {
|
|
return {
|
|
index,
|
|
mappings: {
|
|
properties: fields.reduce(
|
|
(memo: Record<string, MappingProperty>, { name, type }: { name: string; type: string }) => {
|
|
let esType = type;
|
|
if (type === 'string') {
|
|
esType = stringType;
|
|
}
|
|
if (type === 'number') {
|
|
esType = numberType;
|
|
}
|
|
if (type === 'cartesian_point') {
|
|
esType = 'point';
|
|
}
|
|
if (type === 'unsupported') {
|
|
esType = 'integer_range';
|
|
}
|
|
memo[name] = { type: esType } as MappingProperty;
|
|
return memo;
|
|
},
|
|
{}
|
|
),
|
|
},
|
|
};
|
|
}
|
|
|
|
interface JSONConfig {
|
|
testCases: Array<{ query: string; error: string[] }>;
|
|
indexes: string[];
|
|
policies: Array<{
|
|
name: string;
|
|
sourceIndices: string[];
|
|
matchField: string;
|
|
enrichFields: string[];
|
|
}>;
|
|
unsupported_field: Array<{ name: string; type: string }>;
|
|
fields: Array<{ name: string; type: string }>;
|
|
enrichFields: Array<{ name: string; type: string }>;
|
|
}
|
|
|
|
export interface EsqlResultColumn {
|
|
name: string;
|
|
type: string;
|
|
}
|
|
|
|
export type EsqlResultRow = Array<string | null>;
|
|
|
|
export interface EsqlTable {
|
|
columns: EsqlResultColumn[];
|
|
values: EsqlResultRow[];
|
|
}
|
|
|
|
function parseConfig(config: JSONConfig) {
|
|
return {
|
|
queryToErrors: config.testCases,
|
|
indexes: config.indexes,
|
|
policies: config.policies.map(({ name }: { name: string }) => name),
|
|
};
|
|
}
|
|
|
|
export default function ({ getService }: FtrProviderContext) {
|
|
const es = getService('es');
|
|
const log = getService('log');
|
|
|
|
// Send raw ES|QL query directly to ES endpoint bypassing Kibana
|
|
// as we do not need more overhead here
|
|
async function sendESQLQuery(query: string): Promise<{
|
|
resp: EsqlTable | undefined;
|
|
error: { message: string } | undefined;
|
|
}> {
|
|
try {
|
|
const resp = await es.transport.request<EsqlTable>({
|
|
method: 'POST',
|
|
path: '/_query',
|
|
body: {
|
|
query,
|
|
},
|
|
});
|
|
return { resp, error: undefined };
|
|
} catch (e) {
|
|
return { resp: undefined, error: { message: e.meta.body.error.root_cause[0].reason } };
|
|
}
|
|
}
|
|
|
|
describe('error messages', () => {
|
|
const config = readSetupFromESQLPackage();
|
|
const { queryToErrors, indexes, policies } = parseConfig(config);
|
|
const missmatches: Array<{ query: string; error: string }> = [];
|
|
// Swap these for DEBUG/further investigation on ES bugs
|
|
const stringVariants = ['text', 'keyword'] as const;
|
|
const numberVariants = ['integer', 'long', 'double', 'long'] as const;
|
|
|
|
async function cleanup() {
|
|
// clean it up all indexes and policies
|
|
log.info(`cleaning up all indexes: ${indexes.join(', ')}`);
|
|
await es.indices.delete({ index: indexes, ignore_unavailable: true }, { ignore: [404] });
|
|
await es.indices.delete(
|
|
{ index: config.policies[0].sourceIndices[0], ignore_unavailable: true },
|
|
{ ignore: [404] }
|
|
);
|
|
for (const policy of policies) {
|
|
log.info(`deleting policy "${policy}"...`);
|
|
await es.enrich.deletePolicy({ name: policy }, { ignore: [404] });
|
|
}
|
|
}
|
|
|
|
after(async () => {
|
|
if (missmatches.length) {
|
|
const distinctMissmatches = uniqBy(
|
|
missmatches,
|
|
(missmatch) => missmatch.query + missmatch.error
|
|
);
|
|
const missmatchesGrouped = mapValues(
|
|
groupBy(distinctMissmatches, (missmatch) => missmatch.error),
|
|
(list) => list.map(({ query }) => query)
|
|
);
|
|
log.info(`writing ${Object.keys(missmatchesGrouped).length} missmatches to file...`);
|
|
Fs.writeFileSync(getMissmatchedPath(), JSON.stringify(missmatchesGrouped, null, 2));
|
|
}
|
|
});
|
|
|
|
for (const stringFieldType of stringVariants) {
|
|
for (const numberFieldType of numberVariants) {
|
|
describe(`Using string field type: ${stringFieldType} and number field type: ${numberFieldType}`, () => {
|
|
before(async () => {
|
|
await cleanup();
|
|
|
|
log.info(`creating ${indexes.length} indexes...`);
|
|
|
|
for (const index of indexes) {
|
|
// setup all indexes, mappings and policies here
|
|
log.info(`creating a index "${index}" with mapping...`);
|
|
await es.indices.create(
|
|
createIndexRequest(
|
|
index,
|
|
/unsupported/.test(index) ? config.unsupported_field : config.fields,
|
|
stringFieldType,
|
|
numberFieldType
|
|
),
|
|
{ ignore: [409] }
|
|
);
|
|
}
|
|
|
|
for (const { sourceIndices, matchField } of config.policies.slice(0, 1)) {
|
|
const enrichFields = [{ name: matchField, type: 'string' }].concat(
|
|
config.enrichFields
|
|
);
|
|
log.info(`creating a index "${sourceIndices[0]}" for policy with mapping...`);
|
|
await es.indices.create(
|
|
createIndexRequest(
|
|
sourceIndices[0],
|
|
enrichFields,
|
|
stringFieldType,
|
|
numberFieldType
|
|
),
|
|
{
|
|
ignore: [409],
|
|
}
|
|
);
|
|
}
|
|
|
|
log.info(`creating ${policies.length} policies...`);
|
|
for (const { name, sourceIndices, matchField, enrichFields } of config.policies) {
|
|
log.info(`creating a policy "${name}"...`);
|
|
await es.enrich.putPolicy(
|
|
{
|
|
name,
|
|
body: {
|
|
match: {
|
|
indices: sourceIndices,
|
|
match_field: matchField,
|
|
enrich_fields: enrichFields,
|
|
},
|
|
},
|
|
},
|
|
{ ignore: [409] }
|
|
);
|
|
log.info(`executing policy "${name}"...`);
|
|
await es.enrich.executePolicy({ name });
|
|
}
|
|
});
|
|
|
|
after(async () => {
|
|
await cleanup();
|
|
});
|
|
|
|
it(`Checking error messages`, async () => {
|
|
for (const { query, error } of queryToErrors) {
|
|
const jsonBody = await sendESQLQuery(query);
|
|
|
|
const clientSideHasError = Boolean(error.length);
|
|
const serverSideHasError = Boolean(jsonBody.error);
|
|
|
|
if (clientSideHasError !== serverSideHasError) {
|
|
if (clientSideHasError) {
|
|
// in this case it's a problem, so fail the test
|
|
expect().fail(`Client side errored but ES server did not: ${query}`);
|
|
}
|
|
if (serverSideHasError) {
|
|
// in this case client side validator can improve, but it's not hard failure
|
|
// rather log it as it can be a useful to investigate a bug on the ES implementation side for some type combination
|
|
missmatches.push({ query, error: jsonBody.error!.message });
|
|
}
|
|
}
|
|
}
|
|
});
|
|
});
|
|
}
|
|
}
|
|
});
|
|
}
|