[Search] SQL search strategy (#127859)

This commit is contained in:
Anton Dosov 2022-03-24 14:37:11 +01:00 committed by GitHub
parent 78bae9b241
commit 0421f868ea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 948 additions and 0 deletions

View file

@ -16,12 +16,17 @@ import { SearchExamplePage, ExampleLink } from './common/example_page';
import { SearchExamplesApp } from './search/app';
import { SearchSessionsExampleApp } from './search_sessions/app';
import { RedirectAppLinks } from '../../../src/plugins/kibana_react/public';
import { SqlSearchExampleApp } from './sql_search/app';
const LINKS: ExampleLink[] = [
{
path: '/search',
title: 'Search',
},
{
path: '/sql-search',
title: 'SQL Search',
},
{
path: '/search-sessions',
title: 'Search Sessions',
@ -51,12 +56,16 @@ export const renderApp = (
/>
</Route>
<Route path={LINKS[1].path}>
<SqlSearchExampleApp notifications={notifications} data={data} />
</Route>
<Route path={LINKS[2].path}>
<SearchSessionsExampleApp
navigation={navigation}
notifications={notifications}
data={data}
/>
</Route>
<Route path="/" exact={true}>
<Redirect to={LINKS[0].path} />
</Route>

View file

@ -0,0 +1,164 @@
/*
* 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 React, { useState } from 'react';
import {
EuiCodeBlock,
EuiFlexGroup,
EuiFlexItem,
EuiForm,
EuiPageBody,
EuiPageContent,
EuiPageContentBody,
EuiPageHeader,
EuiPanel,
EuiSuperUpdateButton,
EuiText,
EuiTextArea,
EuiTitle,
} from '@elastic/eui';
import { CoreStart } from '../../../../src/core/public';
import {
DataPublicPluginStart,
IKibanaSearchResponse,
isCompleteResponse,
isErrorResponse,
} from '../../../../src/plugins/data/public';
import {
SQL_SEARCH_STRATEGY,
SqlSearchStrategyRequest,
SqlSearchStrategyResponse,
} from '../../../../src/plugins/data/common';
interface SearchExamplesAppDeps {
notifications: CoreStart['notifications'];
data: DataPublicPluginStart;
}
export const SqlSearchExampleApp = ({ notifications, data }: SearchExamplesAppDeps) => {
const [sqlQuery, setSqlQuery] = useState<string>('');
const [request, setRequest] = useState<Record<string, any>>({});
const [isLoading, setIsLoading] = useState<boolean>(false);
const [rawResponse, setRawResponse] = useState<Record<string, any>>({});
function setResponse(response: IKibanaSearchResponse) {
setRawResponse(response.rawResponse);
}
const doSearch = async () => {
const req: SqlSearchStrategyRequest = {
params: {
query: sqlQuery,
},
};
// Submit the search request using the `data.search` service.
setRequest(req.params!);
setIsLoading(true);
data.search
.search<SqlSearchStrategyRequest, SqlSearchStrategyResponse>(req, {
strategy: SQL_SEARCH_STRATEGY,
})
.subscribe({
next: (res) => {
if (isCompleteResponse(res)) {
setIsLoading(false);
setResponse(res);
} else if (isErrorResponse(res)) {
setIsLoading(false);
setResponse(res);
notifications.toasts.addDanger('An error has occurred');
}
},
error: (e) => {
setIsLoading(false);
data.search.showError(e);
},
});
};
return (
<EuiPageBody>
<EuiPageHeader>
<EuiTitle size="l">
<h1>SQL search example</h1>
</EuiTitle>
</EuiPageHeader>
<EuiPageContent>
<EuiPageContentBody>
<EuiForm>
<EuiFlexGroup>
<EuiFlexItem grow>
<EuiTextArea
placeholder="SELECT * FROM library ORDER BY page_count DESC"
aria-label="SQL query to run"
value={sqlQuery}
onChange={(e) => setSqlQuery(e.target.value)}
fullWidth
data-test-subj="sqlQueryInput"
/>
</EuiFlexItem>
<EuiFlexItem grow={false}>
<EuiSuperUpdateButton
isLoading={isLoading}
isDisabled={sqlQuery.length === 0}
onClick={doSearch}
fill={true}
data-test-subj="querySubmitButton"
/>
</EuiFlexItem>
</EuiFlexGroup>
</EuiForm>
<EuiFlexGroup gutterSize="l">
<EuiFlexItem grow style={{ minWidth: 0 }}>
<EuiPanel grow>
<EuiText>
<h3>Request</h3>
</EuiText>
<EuiCodeBlock
language="json"
fontSize="s"
paddingSize="s"
overflowHeight={720}
isCopyable
data-test-subj="requestCodeBlock"
isVirtualized
>
{JSON.stringify(request, null, 2)}
</EuiCodeBlock>
</EuiPanel>
</EuiFlexItem>
<EuiFlexItem grow style={{ minWidth: 0 }}>
<EuiPanel grow>
<EuiText>
<h3>Response</h3>
</EuiText>
<EuiCodeBlock
language="json"
fontSize="s"
paddingSize="s"
isCopyable
data-test-subj="responseCodeBlock"
overflowHeight={720}
isVirtualized
>
{JSON.stringify(rawResponse, null, 2)}
</EuiCodeBlock>
</EuiPanel>
</EuiFlexItem>
</EuiFlexGroup>
</EuiPageContentBody>
</EuiPageContent>
</EuiPageBody>
);
};

View file

@ -17,3 +17,4 @@ export * from './poll_search';
export * from './strategies/es_search';
export * from './strategies/eql_search';
export * from './strategies/ese_search';
export * from './strategies/sql_search';

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
export * from './types';

View file

@ -0,0 +1,23 @@
/*
* 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 type {
SqlGetAsyncRequest,
SqlQueryRequest,
SqlQueryResponse,
} from '@elastic/elasticsearch/lib/api/types';
import { IKibanaSearchRequest, IKibanaSearchResponse } from '../../types';
export const SQL_SEARCH_STRATEGY = 'sql';
export type SqlRequestParams =
| Omit<SqlQueryRequest, 'keep_alive' | 'keep_on_completion'>
| Omit<SqlGetAsyncRequest, 'id' | 'keep_alive' | 'keep_on_completion'>;
export type SqlSearchStrategyRequest = IKibanaSearchRequest<SqlRequestParams>;
export type SqlSearchStrategyResponse = IKibanaSearchResponse<SqlQueryResponse>;

View file

@ -10,3 +10,4 @@ The `search` plugin includes:
- ES_SEARCH_STRATEGY - hitting regular es `_search` endpoint using query DSL
- (default) ESE_SEARCH_STRATEGY (Enhanced ES) - hitting `_async_search` endpoint and works with search sessions
- EQL_SEARCH_STRATEGY
- SQL_SEARCH_STRATEGY

View file

@ -77,6 +77,7 @@ import {
eqlRawResponse,
ENHANCED_ES_SEARCH_STRATEGY,
EQL_SEARCH_STRATEGY,
SQL_SEARCH_STRATEGY,
} from '../../common/search';
import { getEsaggs, getEsdsl, getEql } from './expressions';
import {
@ -93,6 +94,7 @@ import { enhancedEsSearchStrategyProvider } from './strategies/ese_search';
import { eqlSearchStrategyProvider } from './strategies/eql_search';
import { NoSearchIdInSessionError } from './errors/no_search_id_in_session';
import { CachedUiSettingsClient } from './services';
import { sqlSearchStrategyProvider } from './strategies/sql_search';
type StrategyMap = Record<string, ISearchStrategy<any, any>>;
@ -176,6 +178,7 @@ export class SearchService implements Plugin<ISearchSetup, ISearchStart> {
);
this.registerSearchStrategy(EQL_SEARCH_STRATEGY, eqlSearchStrategyProvider(this.logger));
this.registerSearchStrategy(SQL_SEARCH_STRATEGY, sqlSearchStrategyProvider(this.logger));
registerBsearchRoute(
bfetch,

View file

@ -0,0 +1,9 @@
/*
* 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.
*/
export { sqlSearchStrategyProvider } from './sql_search_strategy';

View file

@ -0,0 +1,110 @@
/*
* 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 { getDefaultAsyncSubmitParams, getDefaultAsyncGetParams } from './request_utils';
import moment from 'moment';
import { SearchSessionsConfigSchema } from '../../../../config';
const getMockSearchSessionsConfig = ({
enabled = true,
defaultExpiration = moment.duration(7, 'd'),
} = {}) =>
({
enabled,
defaultExpiration,
} as SearchSessionsConfigSchema);
describe('request utils', () => {
describe('getDefaultAsyncSubmitParams', () => {
test('Uses `keep_alive` from default params if no `sessionId` is provided', async () => {
const mockConfig = getMockSearchSessionsConfig({
defaultExpiration: moment.duration(3, 'd'),
});
const params = getDefaultAsyncSubmitParams(mockConfig, {});
expect(params).toHaveProperty('keep_alive', '1m');
});
test('Uses `keep_alive` from config if enabled', async () => {
const mockConfig = getMockSearchSessionsConfig({
defaultExpiration: moment.duration(3, 'd'),
});
const params = getDefaultAsyncSubmitParams(mockConfig, {
sessionId: 'foo',
});
expect(params).toHaveProperty('keep_alive', '259200000ms');
});
test('Uses `keepAlive` of `1m` if disabled', async () => {
const mockConfig = getMockSearchSessionsConfig({
defaultExpiration: moment.duration(3, 'd'),
enabled: false,
});
const params = getDefaultAsyncSubmitParams(mockConfig, {
sessionId: 'foo',
});
expect(params).toHaveProperty('keep_alive', '1m');
});
test('Uses `keep_on_completion` if enabled', async () => {
const mockConfig = getMockSearchSessionsConfig({});
const params = getDefaultAsyncSubmitParams(mockConfig, {
sessionId: 'foo',
});
expect(params).toHaveProperty('keep_on_completion', true);
});
test('Does not use `keep_on_completion` if disabled', async () => {
const mockConfig = getMockSearchSessionsConfig({
defaultExpiration: moment.duration(3, 'd'),
enabled: false,
});
const params = getDefaultAsyncSubmitParams(mockConfig, {
sessionId: 'foo',
});
expect(params).toHaveProperty('keep_on_completion', false);
});
});
describe('getDefaultAsyncGetParams', () => {
test('Uses `wait_for_completion_timeout`', async () => {
const mockConfig = getMockSearchSessionsConfig({
defaultExpiration: moment.duration(3, 'd'),
enabled: true,
});
const params = getDefaultAsyncGetParams(mockConfig, {});
expect(params).toHaveProperty('wait_for_completion_timeout');
});
test('Uses `keep_alive` if `sessionId` is not provided', async () => {
const mockConfig = getMockSearchSessionsConfig({
defaultExpiration: moment.duration(3, 'd'),
enabled: true,
});
const params = getDefaultAsyncGetParams(mockConfig, {});
expect(params).toHaveProperty('keep_alive', '1m');
});
test('Has no `keep_alive` if `sessionId` is provided', async () => {
const mockConfig = getMockSearchSessionsConfig({
defaultExpiration: moment.duration(3, 'd'),
enabled: true,
});
const params = getDefaultAsyncGetParams(mockConfig, { sessionId: 'foo' });
expect(params).not.toHaveProperty('keep_alive');
});
test('Uses `keep_alive` if `sessionId` is provided but sessions disabled', async () => {
const mockConfig = getMockSearchSessionsConfig({
defaultExpiration: moment.duration(3, 'd'),
enabled: false,
});
const params = getDefaultAsyncGetParams(mockConfig, { sessionId: 'foo' });
expect(params).toHaveProperty('keep_alive', '1m');
});
});
});

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 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 { SqlGetAsyncRequest, SqlQueryRequest } from '@elastic/elasticsearch/lib/api/types';
import { ISearchOptions } from '../../../../common';
import { SearchSessionsConfigSchema } from '../../../../config';
/**
@internal
*/
export function getDefaultAsyncSubmitParams(
searchSessionsConfig: SearchSessionsConfigSchema | null,
options: ISearchOptions
): Pick<SqlQueryRequest, 'keep_alive' | 'wait_for_completion_timeout' | 'keep_on_completion'> {
const useSearchSessions = searchSessionsConfig?.enabled && !!options.sessionId;
const keepAlive = useSearchSessions
? `${searchSessionsConfig!.defaultExpiration.asMilliseconds()}ms`
: '1m';
return {
// Wait up to 100ms for the response to return
wait_for_completion_timeout: '100ms',
// If search sessions are used, store and get an async ID even for short running requests.
keep_on_completion: useSearchSessions,
// The initial keepalive is as defined in defaultExpiration if search sessions are used or 1m otherwise.
keep_alive: keepAlive,
};
}
/**
@internal
*/
export function getDefaultAsyncGetParams(
searchSessionsConfig: SearchSessionsConfigSchema | null,
options: ISearchOptions
): Pick<SqlGetAsyncRequest, 'keep_alive' | 'wait_for_completion_timeout'> {
const useSearchSessions = searchSessionsConfig?.enabled && !!options.sessionId;
return {
// Wait up to 100ms for the response to return
wait_for_completion_timeout: '100ms',
...(useSearchSessions
? // Don't change the expiration of search requests that are tracked in a search session
undefined
: {
// We still need to do polling for searches not within the context of a search session or when search session disabled
keep_alive: '1m',
}),
};
}

View file

@ -0,0 +1,26 @@
/*
* 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 { SqlQueryResponse } from '@elastic/elasticsearch/lib/api/typesWithBodyKey';
import { SqlSearchStrategyResponse } from '../../../../common';
/**
* Get the Kibana representation of an async search response
*/
export function toAsyncKibanaSearchResponse(
response: SqlQueryResponse,
warning?: string
): SqlSearchStrategyResponse {
return {
id: response.id,
rawResponse: response,
isPartial: response.is_partial,
isRunning: response.is_running,
...(warning ? { warning } : {}),
};
}

View file

@ -0,0 +1,264 @@
/*
* 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 { KbnServerError } from '../../../../../kibana_utils/server';
import { errors } from '@elastic/elasticsearch';
import * as indexNotFoundException from '../../../../common/search/test_data/index_not_found_exception.json';
import { SearchStrategyDependencies } from '../../types';
import { sqlSearchStrategyProvider } from './sql_search_strategy';
import { createSearchSessionsClientMock } from '../../mocks';
import { SqlSearchStrategyRequest } from '../../../../common';
const mockSqlResponse = {
body: {
id: 'foo',
is_partial: false,
is_running: false,
rows: [],
},
};
describe('SQL search strategy', () => {
const mockSqlGetAsync = jest.fn();
const mockSqlQuery = jest.fn();
const mockSqlDelete = jest.fn();
const mockLogger: any = {
debug: () => {},
};
const mockDeps = {
esClient: {
asCurrentUser: {
sql: {
getAsync: mockSqlGetAsync,
query: mockSqlQuery,
deleteAsync: mockSqlDelete,
},
},
},
searchSessionsClient: createSearchSessionsClientMock(),
} as unknown as SearchStrategyDependencies;
beforeEach(() => {
mockSqlGetAsync.mockClear();
mockSqlQuery.mockClear();
mockSqlDelete.mockClear();
});
it('returns a strategy with `search and `cancel`, `extend`', async () => {
const esSearch = await sqlSearchStrategyProvider(mockLogger);
expect(typeof esSearch.search).toBe('function');
expect(typeof esSearch.cancel).toBe('function');
expect(typeof esSearch.extend).toBe('function');
});
describe('search', () => {
describe('no sessionId', () => {
it('makes a POST request with params when no ID provided', async () => {
mockSqlQuery.mockResolvedValueOnce(mockSqlResponse);
const params: SqlSearchStrategyRequest['params'] = {
query:
'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC',
};
const esSearch = await sqlSearchStrategyProvider(mockLogger);
await esSearch.search({ params }, {}, mockDeps).toPromise();
expect(mockSqlQuery).toBeCalled();
const request = mockSqlQuery.mock.calls[0][0];
expect(request.query).toEqual(params.query);
expect(request).toHaveProperty('format', 'json');
expect(request).toHaveProperty('keep_alive', '1m');
expect(request).toHaveProperty('wait_for_completion_timeout');
});
it('makes a GET request to async search with ID', async () => {
mockSqlGetAsync.mockResolvedValueOnce(mockSqlResponse);
const params: SqlSearchStrategyRequest['params'] = {
query:
'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC',
};
const esSearch = await sqlSearchStrategyProvider(mockLogger);
await esSearch.search({ id: 'foo', params }, {}, mockDeps).toPromise();
expect(mockSqlGetAsync).toBeCalled();
const request = mockSqlGetAsync.mock.calls[0][0];
expect(request.id).toEqual('foo');
expect(request).toHaveProperty('wait_for_completion_timeout');
expect(request).toHaveProperty('keep_alive', '1m');
expect(request).toHaveProperty('format', 'json');
});
});
// skip until full search session support https://github.com/elastic/kibana/issues/127880
describe.skip('with sessionId', () => {
it('makes a POST request with params (long keepalive)', async () => {
mockSqlQuery.mockResolvedValueOnce(mockSqlResponse);
const params: SqlSearchStrategyRequest['params'] = {
query:
'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC',
};
const esSearch = await sqlSearchStrategyProvider(mockLogger);
await esSearch.search({ params }, { sessionId: '1' }, mockDeps).toPromise();
expect(mockSqlQuery).toBeCalled();
const request = mockSqlQuery.mock.calls[0][0];
expect(request.query).toEqual(params.query);
expect(request).toHaveProperty('keep_alive', '604800000ms');
});
it('makes a GET request to async search without keepalive', async () => {
mockSqlGetAsync.mockResolvedValueOnce(mockSqlResponse);
const params: SqlSearchStrategyRequest['params'] = {
query:
'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC',
};
const esSearch = await sqlSearchStrategyProvider(mockLogger);
await esSearch.search({ id: 'foo', params }, { sessionId: '1' }, mockDeps).toPromise();
expect(mockSqlGetAsync).toBeCalled();
const request = mockSqlGetAsync.mock.calls[0][0];
expect(request.id).toEqual('foo');
expect(request).toHaveProperty('wait_for_completion_timeout');
expect(request).not.toHaveProperty('keep_alive');
});
});
describe('with sessionId (until SQL ignores session Id)', () => {
it('makes a POST request with params (long keepalive)', async () => {
mockSqlQuery.mockResolvedValueOnce(mockSqlResponse);
const params: SqlSearchStrategyRequest['params'] = {
query:
'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC',
};
const esSearch = await sqlSearchStrategyProvider(mockLogger);
await esSearch.search({ params }, { sessionId: '1' }, mockDeps).toPromise();
expect(mockSqlQuery).toBeCalled();
const request = mockSqlQuery.mock.calls[0][0];
expect(request.query).toEqual(params.query);
expect(request).toHaveProperty('wait_for_completion_timeout');
expect(request).toHaveProperty('keep_alive', '1m');
});
it('makes a GET request to async search with keepalive', async () => {
mockSqlGetAsync.mockResolvedValueOnce(mockSqlResponse);
const params: SqlSearchStrategyRequest['params'] = {
query:
'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC',
};
const esSearch = await sqlSearchStrategyProvider(mockLogger);
await esSearch.search({ id: 'foo', params }, { sessionId: '1' }, mockDeps).toPromise();
expect(mockSqlGetAsync).toBeCalled();
const request = mockSqlGetAsync.mock.calls[0][0];
expect(request.id).toEqual('foo');
expect(request).toHaveProperty('wait_for_completion_timeout');
expect(request).toHaveProperty('keep_alive', '1m');
});
});
it('throws normalized error if ResponseError is thrown', async () => {
const errResponse = new errors.ResponseError({
body: indexNotFoundException,
statusCode: 404,
headers: {},
warnings: [],
meta: {} as any,
});
mockSqlQuery.mockRejectedValue(errResponse);
const params: SqlSearchStrategyRequest['params'] = {
query:
'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC',
};
const esSearch = await sqlSearchStrategyProvider(mockLogger);
let err: KbnServerError | undefined;
try {
await esSearch.search({ params }, {}, mockDeps).toPromise();
} catch (e) {
err = e;
}
expect(mockSqlQuery).toBeCalled();
expect(err).toBeInstanceOf(KbnServerError);
expect(err?.statusCode).toBe(404);
expect(err?.message).toBe(errResponse.message);
expect(err?.errBody).toBe(indexNotFoundException);
});
it('throws normalized error if Error is thrown', async () => {
const errResponse = new Error('not good');
mockSqlQuery.mockRejectedValue(errResponse);
const params: SqlSearchStrategyRequest['params'] = {
query:
'SELECT customer_first_name FROM kibana_sample_data_ecommerce ORDER BY order_date DESC',
};
const esSearch = await sqlSearchStrategyProvider(mockLogger);
let err: KbnServerError | undefined;
try {
await esSearch.search({ params }, {}, mockDeps).toPromise();
} catch (e) {
err = e;
}
expect(mockSqlQuery).toBeCalled();
expect(err).toBeInstanceOf(KbnServerError);
expect(err?.statusCode).toBe(500);
expect(err?.message).toBe(errResponse.message);
expect(err?.errBody).toBe(undefined);
});
});
describe('cancel', () => {
it('makes a DELETE request to async search with the provided ID', async () => {
mockSqlDelete.mockResolvedValueOnce(200);
const id = 'some_id';
const esSearch = await sqlSearchStrategyProvider(mockLogger);
await esSearch.cancel!(id, {}, mockDeps);
expect(mockSqlDelete).toBeCalled();
const request = mockSqlDelete.mock.calls[0][0];
expect(request).toEqual({ id });
});
});
describe('extend', () => {
it('makes a GET request to async search with the provided ID and keepAlive', async () => {
mockSqlGetAsync.mockResolvedValueOnce(mockSqlResponse);
const id = 'some_other_id';
const keepAlive = '1d';
const esSearch = await sqlSearchStrategyProvider(mockLogger);
await esSearch.extend!(id, keepAlive, {}, mockDeps);
expect(mockSqlGetAsync).toBeCalled();
const request = mockSqlGetAsync.mock.calls[0][0];
expect(request).toEqual({ id, keep_alive: keepAlive });
});
});
});

View file

@ -0,0 +1,138 @@
/*
* 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 type { IScopedClusterClient, Logger } from 'kibana/server';
import { catchError, tap } from 'rxjs/operators';
import { SqlGetAsyncRequest, SqlQueryRequest } from '@elastic/elasticsearch/lib/api/types';
import type { ISearchStrategy, SearchStrategyDependencies } from '../../types';
import type {
IAsyncSearchOptions,
SqlSearchStrategyRequest,
SqlSearchStrategyResponse,
} from '../../../../common';
import { pollSearch } from '../../../../common';
import { getDefaultAsyncGetParams, getDefaultAsyncSubmitParams } from './request_utils';
import { toAsyncKibanaSearchResponse } from './response_utils';
import { getKbnServerError } from '../../../../../kibana_utils/server';
export const sqlSearchStrategyProvider = (
logger: Logger,
useInternalUser: boolean = false
): ISearchStrategy<SqlSearchStrategyRequest, SqlSearchStrategyResponse> => {
async function cancelAsyncSearch(id: string, esClient: IScopedClusterClient) {
try {
const client = useInternalUser ? esClient.asInternalUser : esClient.asCurrentUser;
await client.sql.deleteAsync({ id });
} catch (e) {
throw getKbnServerError(e);
}
}
function asyncSearch(
{ id, ...request }: SqlSearchStrategyRequest,
options: IAsyncSearchOptions,
{ esClient }: SearchStrategyDependencies
) {
const client = useInternalUser ? esClient.asInternalUser : esClient.asCurrentUser;
// disable search sessions until session task manager supports SQL
// https://github.com/elastic/kibana/issues/127880
// const sessionConfig = searchSessionsClient.getConfig();
const sessionConfig = null;
const search = async () => {
if (id) {
const params: SqlGetAsyncRequest = {
format: request.params?.format ?? 'json',
...getDefaultAsyncGetParams(sessionConfig, options),
id,
};
const { body, headers } = await client.sql.getAsync(params, {
signal: options.abortSignal,
meta: true,
});
return toAsyncKibanaSearchResponse(body, headers?.warning);
} else {
const params: SqlQueryRequest = {
format: request.params?.format ?? 'json',
...getDefaultAsyncSubmitParams(sessionConfig, options),
...request.params,
};
const { headers, body } = await client.sql.query(params, {
signal: options.abortSignal,
meta: true,
});
return toAsyncKibanaSearchResponse(body, headers?.warning);
}
};
const cancel = async () => {
if (id) {
await cancelAsyncSearch(id, esClient);
}
};
return pollSearch(search, cancel, options).pipe(
tap((response) => (id = response.id)),
catchError((e) => {
throw getKbnServerError(e);
})
);
}
return {
/**
* @param request
* @param options
* @param deps `SearchStrategyDependencies`
* @returns `Observable<IEsSearchResponse<any>>`
* @throws `KbnServerError`
*/
search: (request, options: IAsyncSearchOptions, deps) => {
logger.debug(`sql search: search request=${JSON.stringify(request)}`);
return asyncSearch(request, options, deps);
},
/**
* @param id async search ID to cancel, as returned from _async_search API
* @param options
* @param deps `SearchStrategyDependencies`
* @returns `Promise<void>`
* @throws `KbnServerError`
*/
cancel: async (id, options, { esClient }) => {
logger.debug(`sql search: cancel async_search_id=${id}`);
await cancelAsyncSearch(id, esClient);
},
/**
*
* @param id async search ID to extend, as returned from _async_search API
* @param keepAlive
* @param options
* @param deps `SearchStrategyDependencies`
* @returns `Promise<void>`
* @throws `KbnServerError`
*/
extend: async (id, keepAlive, options, { esClient }) => {
logger.debug(`sql search: extend async_search_id=${id} keep_alive=${keepAlive}`);
try {
const client = useInternalUser ? esClient.asInternalUser : esClient.asCurrentUser;
await client.sql.getAsync({
id,
keep_alive: keepAlive,
});
} catch (e) {
throw getKbnServerError(e);
}
},
};
};

View file

@ -11,6 +11,7 @@ import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ loadTestFile }: FtrProviderContext) {
describe('search', () => {
loadTestFile(require.resolve('./search'));
loadTestFile(require.resolve('./sql_search'));
loadTestFile(require.resolve('./bsearch'));
});
}

View file

@ -0,0 +1,90 @@
/*
* 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 expect from '@kbn/expect';
import { FtrProviderContext } from '../../ftr_provider_context';
export default function ({ getService }: FtrProviderContext) {
const supertest = getService('supertest');
const esArchiver = getService('esArchiver');
const sqlQuery = `SELECT index, bytes FROM "logstash-*" ORDER BY "@timestamp" DESC`;
describe('SQL search', () => {
before(async () => {
await esArchiver.emptyKibanaIndex();
await esArchiver.loadIfNeeded('test/functional/fixtures/es_archiver/logstash_functional');
});
after(async () => {
await esArchiver.unload('test/functional/fixtures/es_archiver/logstash_functional');
});
describe('post', () => {
it('should return 200 when correctly formatted searches are provided', async () => {
const resp = await supertest
.post(`/internal/search/sql`)
.send({
params: {
query: sqlQuery,
},
})
.expect(200);
expect(resp.body).to.have.property('id');
expect(resp.body).to.have.property('isPartial');
expect(resp.body).to.have.property('isRunning');
expect(resp.body).to.have.property('rawResponse');
});
it('should fetch search results by id', async () => {
const resp1 = await supertest
.post(`/internal/search/sql`)
.send({
params: {
query: sqlQuery,
keep_on_completion: true, // force keeping the results even if completes early
},
})
.expect(200);
const id = resp1.body.id;
const resp2 = await supertest.post(`/internal/search/sql/${id}`).send({});
expect(resp2.status).to.be(200);
expect(resp2.body.id).to.be(id);
expect(resp2.body).to.have.property('isPartial');
expect(resp2.body).to.have.property('isRunning');
expect(resp2.body).to.have.property('rawResponse');
});
});
describe('delete', () => {
it('should delete search', async () => {
const resp1 = await supertest
.post(`/internal/search/sql`)
.send({
params: {
query: sqlQuery,
keep_on_completion: true, // force keeping the results even if completes early
},
})
.expect(200);
const id = resp1.body.id;
// confirm it was saved
await supertest.post(`/internal/search/sql/${id}`).send({}).expect(200);
// delete it
await supertest.delete(`/internal/search/sql/${id}`).send().expect(200);
// check it was deleted
await supertest.post(`/internal/search/sql/${id}`).send({}).expect(404);
});
});
});
}

View file

@ -17,6 +17,7 @@ export async function getSearchStatus(
asyncId: string
): Promise<Pick<SearchSessionRequestInfo, 'status' | 'error'>> {
// TODO: Handle strategies other than the default one
// https://github.com/elastic/kibana/issues/127880
try {
// @ts-expect-error start_time_in_millis: EpochMillis is string | number
const apiResponse: TransportResult<AsyncSearchStatusResponse> = await client.asyncSearch.status(

View file

@ -33,5 +33,6 @@ export default function ({ getService, loadTestFile }: PluginFunctionalProviderC
loadTestFile(require.resolve('./search_example'));
loadTestFile(require.resolve('./search_sessions_cache'));
loadTestFile(require.resolve('./partial_results_example'));
loadTestFile(require.resolve('./sql_search_example'));
});
}

View file

@ -0,0 +1,42 @@
/*
* 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; you may not use this file except in compliance with the Elastic License
* 2.0.
*/
import expect from '@kbn/expect';
import { FtrProviderContext } from '../../functional/ftr_provider_context';
// eslint-disable-next-line import/no-default-export
export default function ({ getService, getPageObjects }: FtrProviderContext) {
const testSubjects = getService('testSubjects');
const PageObjects = getPageObjects(['common']);
const toasts = getService('toasts');
describe('SQL search example', () => {
const appId = 'searchExamples';
before(async function () {
await PageObjects.common.navigateToApp(appId, { insertTimestamp: false });
await testSubjects.click('/sql-search');
});
it('should search', async () => {
const sqlQuery = `SELECT index, bytes FROM "logstash-*" ORDER BY "@timestamp" DESC`;
await (await testSubjects.find('sqlQueryInput')).type(sqlQuery);
await testSubjects.click(`querySubmitButton`);
await testSubjects.stringExistsInCodeBlockOrFail(
'requestCodeBlock',
JSON.stringify(sqlQuery)
);
await testSubjects.stringExistsInCodeBlockOrFail(
'responseCodeBlock',
`"logstash-2015.09.22"`
);
expect(await toasts.getToastCount()).to.be(0);
});
});
}