Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions src/plugins/data/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,21 @@ export const configSchema = schema.object({
}),
valueSuggestions: schema.object({
enabled: schema.boolean({ defaultValue: true }),
method: schema.oneOf([schema.literal('terms_enum'), schema.literal('terms_agg')], {
defaultValue: 'terms_enum',
}),
tiers: schema.arrayOf(
schema.oneOf([
schema.literal('data_content'),
schema.literal('data_hot'),
schema.literal('data_warm'),
schema.literal('data_cold'),
schema.literal('data_frozen'),
]),
{
defaultValue: ['data_hot', 'data_warm', 'data_content', 'data_cold'],
}
),
terminateAfter: schema.duration({ defaultValue: 100000 }),
timeout: schema.duration({ defaultValue: 1000 }),
}),
Expand Down
89 changes: 89 additions & 0 deletions src/plugins/data/server/autocomplete/terms_agg.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/*
* 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 { coreMock } from '../../../../core/server/mocks';
import { ElasticsearchClient, SavedObjectsClientContract } from 'kibana/server';
import { ConfigSchema } from '../../config';
import type { DeeplyMockedKeys } from '@kbn/utility-types/jest';
import type { ApiResponse } from '@elastic/elasticsearch';
import { termsAggSuggestions } from './terms_agg';
import { SearchResponse } from 'elasticsearch';
import { duration } from 'moment';

let savedObjectsClientMock: jest.Mocked<SavedObjectsClientContract>;
let esClientMock: DeeplyMockedKeys<ElasticsearchClient>;
const configMock = ({
autocomplete: {
valueSuggestions: { timeout: duration(4513), terminateAfter: duration(98430) },
},
} as unknown) as ConfigSchema;
const mockResponse = {
body: {
aggregations: {
suggestions: {
buckets: [{ key: 'whoa' }, { key: 'amazing' }],
},
},
},
} as ApiResponse<SearchResponse<any>>;

describe('terms agg suggestions', () => {
beforeEach(() => {
const requestHandlerContext = coreMock.createRequestHandlerContext();
savedObjectsClientMock = requestHandlerContext.savedObjects.client;
esClientMock = requestHandlerContext.elasticsearch.client.asCurrentUser;
esClientMock.search.mockResolvedValue(mockResponse);
});

it('calls the _search API with a terms agg with the given args', async () => {
const result = await termsAggSuggestions(
configMock,
savedObjectsClientMock,
esClientMock,
'index',
'fieldName',
'query',
[],
{ name: 'field_name', type: 'string' }
);

const [[args]] = esClientMock.search.mock.calls;

expect(args).toMatchInlineSnapshot(`
Object {
"body": Object {
"aggs": Object {
"suggestions": Object {
"terms": Object {
"execution_hint": "map",
"field": "field_name",
"include": "query.*",
"shard_size": 10,
},
},
},
"query": Object {
"bool": Object {
"filter": Array [],
},
},
"size": 0,
"terminate_after": 98430,
"timeout": "4513ms",
},
"index": "index",
}
`);
expect(result).toMatchInlineSnapshot(`
Array [
"whoa",
"amazing",
]
`);
});
});
106 changes: 106 additions & 0 deletions src/plugins/data/server/autocomplete/terms_agg.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
/*
* 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 { get, map } from 'lodash';
import { ElasticsearchClient, SavedObjectsClientContract } from 'kibana/server';
import { estypes } from '@elastic/elasticsearch';
import { ConfigSchema } from '../../config';
import { IFieldType } from '../../common';
import { findIndexPatternById, getFieldByName } from '../index_patterns';
import { shimAbortSignal } from '../search';

export async function termsAggSuggestions(
config: ConfigSchema,
savedObjectsClient: SavedObjectsClientContract,
esClient: ElasticsearchClient,
index: string,
fieldName: string,
query: string,
filters?: estypes.QueryDslQueryContainer[],
field?: IFieldType,
abortSignal?: AbortSignal
) {
const autocompleteSearchOptions = {
timeout: `${config.autocomplete.valueSuggestions.timeout.asMilliseconds()}ms`,
terminate_after: config.autocomplete.valueSuggestions.terminateAfter.asMilliseconds(),
};

if (!field?.name && !field?.type) {
const indexPattern = await findIndexPatternById(savedObjectsClient, index);

field = indexPattern && getFieldByName(fieldName, indexPattern);
}

const body = await getBody(autocompleteSearchOptions, field ?? fieldName, query, filters);

const promise = esClient.search({ index, body });
const result = await shimAbortSignal(promise, abortSignal);

const buckets =
get(result.body, 'aggregations.suggestions.buckets') ||
get(result.body, 'aggregations.nestedSuggestions.suggestions.buckets');

return map(buckets ?? [], 'key');
}

async function getBody(
// eslint-disable-next-line @typescript-eslint/naming-convention
{ timeout, terminate_after }: Record<string, any>,
field: IFieldType | string,
query: string,
filters: estypes.QueryDslQueryContainer[] = []
) {
const isFieldObject = (f: any): f is IFieldType => Boolean(f && f.name);

// https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-regexp-query.html#_standard_operators
const getEscapedQuery = (q: string = '') =>
q.replace(/[.?+*|{}[\]()"\\#@&<>~]/g, (match) => `\\${match}`);

// Helps ensure that the regex is not evaluated eagerly against the terms dictionary
const executionHint = 'map' as const;

// We don't care about the accuracy of the counts, just the content of the terms, so this reduces
// the amount of information that needs to be transmitted to the coordinating node
const shardSize = 10;
const body = {
size: 0,
timeout,
terminate_after,
query: {
bool: {
filter: filters,
},
},
aggs: {
suggestions: {
terms: {
field: isFieldObject(field) ? field.name : field,
include: `${getEscapedQuery(query)}.*`,
execution_hint: executionHint,
shard_size: shardSize,
},
},
},
};

if (isFieldObject(field) && field.subType && field.subType.nested) {
return {
...body,
aggs: {
nestedSuggestions: {
nested: {
path: field.subType.nested.path,
},
aggs: body.aggs,
},
},
};
}

return body;
}
74 changes: 74 additions & 0 deletions src/plugins/data/server/autocomplete/terms_enum.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/*
* 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 { termsEnumSuggestions } from './terms_enum';
import { coreMock } from '../../../../core/server/mocks';
import { ElasticsearchClient, SavedObjectsClientContract } from 'kibana/server';
import { ConfigSchema } from '../../config';
import type { DeeplyMockedKeys } from '@kbn/utility-types/jest';
import type { ApiResponse } from '@elastic/elasticsearch';

let savedObjectsClientMock: jest.Mocked<SavedObjectsClientContract>;
let esClientMock: DeeplyMockedKeys<ElasticsearchClient>;
const configMock = {
autocomplete: { valueSuggestions: { tiers: ['data_hot', 'data_warm', 'data_content'] } },
} as ConfigSchema;
const mockResponse = {
body: { terms: ['whoa', 'amazing'] },
};

describe('_terms_enum suggestions', () => {
beforeEach(() => {
const requestHandlerContext = coreMock.createRequestHandlerContext();
savedObjectsClientMock = requestHandlerContext.savedObjects.client;
esClientMock = requestHandlerContext.elasticsearch.client.asCurrentUser;
esClientMock.transport.request.mockResolvedValue((mockResponse as unknown) as ApiResponse);
});

it('calls the _terms_enum API with the field, query, filters, and config tiers', async () => {
const result = await termsEnumSuggestions(
configMock,
savedObjectsClientMock,
esClientMock,
'index',
'fieldName',
'query',
[],
{ name: 'field_name', type: 'string' }
);

const [[args]] = esClientMock.transport.request.mock.calls;

expect(args).toMatchInlineSnapshot(`
Object {
"body": Object {
"field": "field_name",
"index_filter": Object {
"bool": Object {
"must": Array [
Object {
"terms": Object {
"_tier": Array [
"data_hot",
"data_warm",
"data_content",
],
},
},
],
},
},
"string": "query",
},
"method": "POST",
"path": "/index/_terms_enum",
}
`);
expect(result).toEqual(mockResponse.body.terms);
});
});
62 changes: 62 additions & 0 deletions src/plugins/data/server/autocomplete/terms_enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
/*
* 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 { ElasticsearchClient, SavedObjectsClientContract } from 'kibana/server';
import { estypes } from '@elastic/elasticsearch';
import { IFieldType } from '../../common';
import { findIndexPatternById, getFieldByName } from '../index_patterns';
import { shimAbortSignal } from '../search';
import { getKbnServerError } from '../../../kibana_utils/server';
import { ConfigSchema } from '../../config';

export async function termsEnumSuggestions(
config: ConfigSchema,
savedObjectsClient: SavedObjectsClientContract,
esClient: ElasticsearchClient,
index: string,
fieldName: string,
query: string,
filters?: estypes.QueryDslQueryContainer[],
field?: IFieldType,
abortSignal?: AbortSignal
) {
const { tiers } = config.autocomplete.valueSuggestions;
if (!field?.name && !field?.type) {
const indexPattern = await findIndexPatternById(savedObjectsClient, index);
field = indexPattern && getFieldByName(fieldName, indexPattern);
}

try {
const promise = esClient.transport.request({
method: 'POST',
path: encodeURI(`/${index}/_terms_enum`),
body: {
field: field?.name ?? field,
string: query,
index_filter: {
bool: {
must: [
...(filters ?? []),
{
terms: {
_tier: tiers,
},
},
],
},
},
},
});

const result = await shimAbortSignal(promise, abortSignal);

return result.body.terms;
} catch (e) {
throw getKbnServerError(e);
}
}
Loading