mirror of
https://github.com/elastic/kibana.git
synced 2025-04-24 01:38:56 -04:00
[Search] SQL search strategy (#127859)
This commit is contained in:
parent
78bae9b241
commit
0421f868ea
18 changed files with 948 additions and 0 deletions
|
@ -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>
|
||||
|
|
164
examples/search_examples/public/sql_search/app.tsx
Normal file
164
examples/search_examples/public/sql_search/app.tsx
Normal 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>
|
||||
);
|
||||
};
|
|
@ -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';
|
||||
|
|
|
@ -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';
|
|
@ -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>;
|
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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';
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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',
|
||||
}),
|
||||
};
|
||||
}
|
|
@ -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 } : {}),
|
||||
};
|
||||
}
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
};
|
|
@ -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'));
|
||||
});
|
||||
}
|
||||
|
|
90
test/api_integration/apis/search/sql_search.ts
Normal file
90
test/api_integration/apis/search/sql_search.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
|
@ -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(
|
||||
|
|
|
@ -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'));
|
||||
});
|
||||
}
|
||||
|
|
42
x-pack/test/examples/search_examples/sql_search_example.ts
Normal file
42
x-pack/test/examples/search_examples/sql_search_example.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
Loading…
Add table
Add a link
Reference in a new issue