Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[Backport 2.16] feat: support PPL in vega visualization #7404

Merged
merged 1 commit into from
Jul 23, 2024
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
2 changes: 2 additions & 0 deletions changelogs/fragments/7285.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
feat:
- Support PPL in vega visualization ([#7285](https://github.com/opensearch-project/OpenSearch-Dashboards/pull/7285))
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,7 @@ export function getSearchParamsFromRequest(
}

return {
index: searchRequest.index.title || searchRequest.index,
index: searchRequest.index?.title || searchRequest.index,
body: searchRequest.body,
...searchParams,
};
Expand Down
10 changes: 8 additions & 2 deletions src/plugins/data/server/search/routes/search.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -94,7 +94,10 @@ describe('Search service', () => {
await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse);

expect(mockDataStart.search.search).toBeCalled();
expect(mockDataStart.search.search.mock.calls[0][1]).toStrictEqual(mockBody);
expect(mockDataStart.search.search.mock.calls[0][1]).toStrictEqual({
...mockBody,
rawRequest: mockRequest,
});
expect(mockResponse.ok).toBeCalled();
expect(mockResponse.ok.mock.calls[0][0]).toEqual({
body: response,
Expand Down Expand Up @@ -125,7 +128,10 @@ describe('Search service', () => {
await handler((mockContext as unknown) as RequestHandlerContext, mockRequest, mockResponse);

expect(mockDataStart.search.search).toBeCalled();
expect(mockDataStart.search.search.mock.calls[0][1]).toStrictEqual(mockBody);
expect(mockDataStart.search.search.mock.calls[0][1]).toStrictEqual({
...mockBody,
rawRequest: mockRequest,
});
expect(mockResponse.customError).toBeCalled();
const error: any = mockResponse.customError.mock.calls[0][0];
expect(error.body.message).toBe('oh no');
Expand Down
2 changes: 1 addition & 1 deletion src/plugins/data/server/search/routes/search.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ export function registerSearchRoute(
try {
const { withLongNumeralsSupport, ...response } = await selfStart.search.search(
context,
{ ...searchRequest, id },
{ ...searchRequest, id, rawRequest: request },
{
abortSignal,
strategy,
Expand Down
4 changes: 3 additions & 1 deletion src/plugins/data/server/search/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,9 @@ export interface ISearchSetup {
}

export interface ISearchStart<
SearchStrategyRequest extends IOpenSearchDashboardsSearchRequest = IOpenSearchSearchRequest,
SearchStrategyRequest extends IOpenSearchDashboardsSearchRequest = IOpenSearchSearchRequest & {
rawRequest?: OpenSearchDashboardsRequest;
},
SearchStrategyResponse extends IOpenSearchDashboardsSearchResponse = IOpenSearchSearchResponse
> {
aggs: AggsStart;
Expand Down
1 change: 1 addition & 0 deletions src/plugins/query_enhancements/common/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ export const BASE_API = '/api/enhancements';

export const SEARCH_STRATEGY = {
PPL: 'ppl',
PPL_RAW: 'pplraw',
SQL: 'sql',
SQL_ASYNC: 'sqlasync',
};
Expand Down
4 changes: 4 additions & 0 deletions src/plugins/query_enhancements/server/plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ import {
QueryEnhancementsPluginSetup,
QueryEnhancementsPluginSetupDependencies,
QueryEnhancementsPluginStart,
QueryEnhancementsPluginStartDependencies,
} from './types';
import { OpenSearchObservabilityPlugin, OpenSearchPPLPlugin } from './utils';
import { pplRawSearchStrategyProvider } from './search/ppl_raw_search_strategy';

export class QueryEnhancementsPlugin
implements Plugin<QueryEnhancementsPluginSetup, QueryEnhancementsPluginStart> {
Expand All @@ -51,6 +53,7 @@ export class QueryEnhancementsPlugin
}

const pplSearchStrategy = pplSearchStrategyProvider(this.config$, this.logger, client);
const pplRawSearchStrategy = pplRawSearchStrategyProvider(this.config$, this.logger, client);
const sqlSearchStrategy = sqlSearchStrategyProvider(this.config$, this.logger, client);
const sqlAsyncSearchStrategy = sqlAsyncSearchStrategyProvider(
this.config$,
Expand All @@ -59,6 +62,7 @@ export class QueryEnhancementsPlugin
);

data.search.registerSearchStrategy(SEARCH_STRATEGY.PPL, pplSearchStrategy);
data.search.registerSearchStrategy(SEARCH_STRATEGY.PPL_RAW, pplRawSearchStrategy);
data.search.registerSearchStrategy(SEARCH_STRATEGY.SQL, sqlSearchStrategy);
data.search.registerSearchStrategy(SEARCH_STRATEGY.SQL_ASYNC, sqlAsyncSearchStrategy);

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import { SharedGlobalConfig, Logger, ILegacyClusterClient } from 'opensearch-dashboards/server';
import { Observable } from 'rxjs';
import { ISearchStrategy, SearchUsage } from '../../../data/server';
import { shimSchemaRow } from '../utils';

export const pplRawSearchStrategyProvider = (
config$: Observable<SharedGlobalConfig>,
logger: Logger,
client: ILegacyClusterClient,
usage?: SearchUsage
): ISearchStrategy => {
return {
search: async (context, request: any, options) => {
const runSearch = request.dataSourceId
? context.dataSource.opensearch.legacy.getClient(request.dataSourceId).callAPI
: client.asScoped(request.rawRequest).callAsCurrentUser;

try {
const rawResponse: any = await runSearch('ppl.pplQuery', { body: request.params.body });
const data = shimSchemaRow(rawResponse);
rawResponse.jsonData = data.jsonData;

return {
rawResponse,
};
} catch (e) {
logger.error(`pplRawSearchStrategy: ${e.message}`);
if (usage) usage.trackError();
throw e;
}
},
};
};
49 changes: 49 additions & 0 deletions src/plugins/vis_type_vega/public/data_model/ppl_parser.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import { PPLQueryParser } from './ppl_parser';

test('it should throw error if with invalid url object', () => {
const searchApiMock = {
search: jest.fn(() => ({
toPromise: jest.fn(() => Promise.resolve({})),
})),
};
const parser = new PPLQueryParser(searchApiMock);
expect(() => parser.parseUrl({}, {})).toThrowError();
expect(() => parser.parseUrl({}, { body: {} })).toThrowError();
expect(() => parser.parseUrl({}, { body: { query: {} } })).toThrowError();
});

test('it should parse url object', () => {
const searchApiMock = {
search: jest.fn(() => ({
toPromise: jest.fn(() => Promise.resolve({})),
})),
};
const parser = new PPLQueryParser(searchApiMock);
const result = parser.parseUrl({}, { body: { query: 'source=test_index' } });
expect(result.dataObject).toEqual({});
expect(result.url).toEqual({ body: { query: 'source=test_index' } });
});

it('should populate data to request', async () => {
const searchApiMock = {
search: jest.fn(() => ({
toPromise: jest.fn(() =>
Promise.resolve([{ name: 'request name', rawResponse: { jsonData: [{ id: 'id1' }] } }])
),
})),
};
const parser = new PPLQueryParser(searchApiMock);
const request = {
url: { body: { query: 'source=test_index' } },
dataObject: {
name: 'request name',
},
};
await parser.populateData([request]);
expect(request.dataObject.values).toEqual([{ id: 'id1' }]);
});
57 changes: 57 additions & 0 deletions src/plugins/vis_type_vega/public/data_model/ppl_parser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
/*
* Copyright OpenSearch Contributors
* SPDX-License-Identifier: Apache-2.0
*/

import { i18n } from '@osd/i18n';
import { Data, UrlObject, PPLQueryRequest } from './types';
import { SearchAPI } from './search_api';

const getRequestName = (request: PPLQueryRequest, index: number) =>
request.dataObject.name ||
i18n.translate('visTypeVega.opensearchQueryParser.unnamedRequest', {
defaultMessage: 'Unnamed request #{index}',
values: { index },
});

export class PPLQueryParser {
searchAPI: SearchAPI;

constructor(searchAPI: SearchAPI) {
this.searchAPI = searchAPI;
}

parseUrl(dataObject: Data, url: UrlObject) {
// data.url.body.query must be defined
if (!url.body || !url.body.query || typeof url.body.query !== 'string') {
throw new Error(
i18n.translate('visTypeVega.pplQueryParser.dataUrl.PPL.queryCannotBeEmpty', {
defaultMessage: '{dataUrlParam} must have query specified',
values: {
dataUrlParam: '"data.url"',
},
})
);
}

return { dataObject, url };
}

async populateData(requests: PPLQueryRequest[]) {
const searchRequests = requests.map((r, index) => ({
...r.url,
name: getRequestName(r, index),
}));

const data$ = await this.searchAPI.search(searchRequests, { strategy: 'pplraw' });
const results = await data$.toPromise();
results.forEach((data, index) => {
const requestObject = requests.find((item) => getRequestName(item, index) === data.name);

if (requestObject) {
requestObject.dataObject.url = requestObject.url;
requestObject.dataObject.values = (data.rawResponse as any).jsonData;
}
});
}
}
63 changes: 56 additions & 7 deletions src/plugins/vis_type_vega/public/data_model/search_api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@
* under the License.
*/

import { i18n } from '@osd/i18n';
import { combineLatest } from 'rxjs';
import { map, tap } from 'rxjs/operators';
import { CoreStart, IUiSettingsClient } from 'opensearch-dashboards/public';
Expand All @@ -38,10 +39,21 @@
SearchRequest,
DataPublicPluginStart,
IOpenSearchSearchResponse,
IOpenSearchSearchRequest,
} from '../../../data/public';
import { search as dataPluginSearch } from '../../../data/public';
import { VegaInspectorAdapters } from '../vega_inspector';
import { RequestResponder } from '../../../inspector/public';
import { RequestResponder, RequestStatistics } from '../../../inspector/public';

interface RawPPLStrategySearchResponse {
rawResponse: {
datarows: any[];
jsonData: any[];
schema: any[];
size: number;
total: number;
};
}

export interface SearchAPIDependencies {
uiSettings: IUiSettingsClient;
Expand All @@ -58,7 +70,7 @@
public readonly inspectorAdapters?: VegaInspectorAdapters
) {}

async search(searchRequests: SearchRequest[]) {
async search(searchRequests: SearchRequest[], options?: { strategy?: string }) {
const { search } = this.dependencies.search;
const requestResponders: any = {};

Expand Down Expand Up @@ -87,7 +99,13 @@
? { params, dataSourceId }
: { params };

return search(searchApiParams, { abortSignal: this.abortSignal }).pipe(
return search<
IOpenSearchSearchRequest,
IOpenSearchSearchResponse | RawPPLStrategySearchResponse
>(searchApiParams, {
abortSignal: this.abortSignal,
strategy: options?.strategy,
}).pipe(
tap((data) => this.inspectSearchResult(data, requestResponders[requestId])),
map((data) => ({
name: requestId,
Expand Down Expand Up @@ -135,14 +153,45 @@
}
}

private getPPLRawResponseInspectorStats(response: RawPPLStrategySearchResponse['rawResponse']) {
const stats: RequestStatistics = {};
stats.hitsTotal = {

Check warning on line 158 in src/plugins/vis_type_vega/public/data_model/search_api.ts

View check run for this annotation

Codecov / codecov/patch

src/plugins/vis_type_vega/public/data_model/search_api.ts#L157-L158

Added lines #L157 - L158 were not covered by tests
label: i18n.translate('data.search.searchSource.hitsTotalLabel', {
defaultMessage: 'Hits (total)',
}),
value: `${response.total}`,
description: i18n.translate('data.search.searchSource.hitsTotalDescription', {
defaultMessage: 'The number of documents that match the query.',
}),
};

stats.hits = {

Check warning on line 168 in src/plugins/vis_type_vega/public/data_model/search_api.ts

View check run for this annotation

Codecov / codecov/patch

src/plugins/vis_type_vega/public/data_model/search_api.ts#L168

Added line #L168 was not covered by tests
label: i18n.translate('data.search.searchSource.hitsLabel', {
defaultMessage: 'Hits',
}),
value: `${response.size}`,
description: i18n.translate('data.search.searchSource.hitsDescription', {
defaultMessage: 'The number of documents returned by the query.',
}),
};
return stats;

Check warning on line 177 in src/plugins/vis_type_vega/public/data_model/search_api.ts

View check run for this annotation

Codecov / codecov/patch

src/plugins/vis_type_vega/public/data_model/search_api.ts#L177

Added line #L177 was not covered by tests
}

private inspectSearchResult(
response: IOpenSearchSearchResponse,
response: IOpenSearchSearchResponse | RawPPLStrategySearchResponse,
requestResponder: RequestResponder
) {
if (requestResponder) {
requestResponder
.stats(dataPluginSearch.getResponseInspectorStats(response.rawResponse))
.ok({ json: response.rawResponse });
// inspect ppl response
if ('jsonData' in response.rawResponse) {
requestResponder

Check warning on line 187 in src/plugins/vis_type_vega/public/data_model/search_api.ts

View check run for this annotation

Codecov / codecov/patch

src/plugins/vis_type_vega/public/data_model/search_api.ts#L187

Added line #L187 was not covered by tests
.stats(this.getPPLRawResponseInspectorStats(response.rawResponse))
.ok({ json: response.rawResponse });
} else {
requestResponder

Check warning on line 191 in src/plugins/vis_type_vega/public/data_model/search_api.ts

View check run for this annotation

Codecov / codecov/patch

src/plugins/vis_type_vega/public/data_model/search_api.ts#L191

Added line #L191 was not covered by tests
.stats(dataPluginSearch.getResponseInspectorStats(response.rawResponse))
.ok({ json: response.rawResponse });
}
}
}
}
3 changes: 2 additions & 1 deletion src/plugins/vis_type_vega/public/data_model/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ import { UrlParser } from './url_parser';

interface Body {
aggs?: SearchParams['body']['aggs'];
query?: Query;
query?: Query | string;
timeout?: string;
}

Expand Down Expand Up @@ -214,6 +214,7 @@ export type OpenSearchQueryRequest = Requests;
export type EmsQueryRequest = Requests & {
obj: UrlObject;
};
export type PPLQueryRequest = Requests;

export interface ContextVarsObject {
[index: string]: any;
Expand Down
4 changes: 4 additions & 0 deletions src/plugins/vis_type_vega/public/data_model/vega_parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ import { euiThemeVars } from '@osd/ui-shared-deps/theme';
import { i18n } from '@osd/i18n';
// @ts-ignore
import { Signal } from 'vega';

import { HttpSetup } from 'opensearch-dashboards/public';
import { vega, vegaLite } from '../lib/vega';
import { OpenSearchQueryParser } from './opensearch_query_parser';
import { Utils } from './utils';
Expand All @@ -59,6 +61,7 @@ import {
ControlsDirection,
OpenSearchDashboards,
} from './types';
import { PPLQueryParser } from './ppl_parser';

// Set default single color to match other OpenSearch Dashboards visualizations
const defaultColor: string = euiPaletteColorBlind()[0];
Expand Down Expand Up @@ -653,6 +656,7 @@ The URL is an identifier only. OpenSearch Dashboards and your browser will never
opensearch: new OpenSearchQueryParser(this.timeCache, this.searchAPI, this.filters, onWarn),
emsfile: new EmsFileParser(serviceSettings),
url: new UrlParser(onWarn),
ppl: new PPLQueryParser(this.searchAPI),
};
}
const pending: PendingType = {};
Expand Down
4 changes: 2 additions & 2 deletions src/plugins/visualizations/public/legacy/build_pipeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -390,8 +390,8 @@ export const buildVislibDimensions = async (

export const buildPipeline = async (vis: Vis, params: BuildPipelineParams) => {
const { indexPattern, searchSource } = vis.data;
const query = searchSource!.getField('query');
const filters = searchSource!.getField('filter');
const query = searchSource?.getField('query');
const filters = searchSource?.getField('filter');
const { uiState, title } = vis;

// context
Expand Down
Loading