blob: 4bab7e7bfb0522940b86db7281fe0b0b62549d3e [file] [log] [blame]
/*
* Licensed to the Apache Software Foundation (ASF) under one
* or more contributor license agreements. See the NOTICE file
* distributed with this work for additional information
* regarding copyright ownership. The ASF licenses this file
* to you under the Apache License, Version 2.0 (the
* "License"); you may not use this file except in compliance
* with the License. You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import {
Button,
Classes,
Dialog,
FormGroup,
InputGroup,
Intent,
Tab,
Tabs,
} from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
import * as JSONBig from 'json-bigint-native';
import type { JSX } from 'react';
import React from 'react';
import AceEditor from 'react-ace';
import { Loader } from '../../../components';
import type { DruidEngine, QueryContext, QueryWithContext } from '../../../druid-models';
import { isEmptyContext } from '../../../druid-models';
import { useQueryManager } from '../../../hooks';
import { Api } from '../../../singletons';
import type { QueryExplanation } from '../../../utils';
import {
deepGet,
formatSignature,
getDruidErrorMessage,
nonEmptyArray,
queryDruidSql,
} from '../../../utils';
import './explain-dialog.scss';
function isExplainQuery(query: string): boolean {
return /^\s*EXPLAIN\sPLAN\sFOR/im.test(query);
}
function wrapInExplainIfNeeded(query: string): string {
if (isExplainQuery(query)) return query;
return `EXPLAIN PLAN FOR ${query}`;
}
export interface QueryContextEngine extends QueryWithContext {
engine: DruidEngine;
}
export interface ExplainDialogProps {
queryWithContext: QueryContextEngine;
mandatoryQueryContext?: Record<string, any>;
onClose: () => void;
openQueryLabel: string | undefined;
onOpenQuery: (queryString: string) => void;
}
export const ExplainDialog = React.memo(function ExplainDialog(props: ExplainDialogProps) {
const { queryWithContext, onClose, openQueryLabel, onOpenQuery, mandatoryQueryContext } = props;
const [explainState] = useQueryManager<QueryContextEngine, QueryExplanation[] | string>({
processQuery: async queryWithContext => {
const { engine, queryString, queryContext, wrapQueryLimit } = queryWithContext;
let context: QueryContext | undefined;
if (!isEmptyContext(queryContext) || wrapQueryLimit || mandatoryQueryContext) {
context = {
...queryContext,
...(mandatoryQueryContext || {}),
useNativeQueryExplain: true,
};
if (typeof wrapQueryLimit !== 'undefined') {
context.sqlOuterLimit = wrapQueryLimit + 1;
}
}
const payload: any = {
query: wrapInExplainIfNeeded(queryString),
context,
};
let result: any[];
try {
result =
engine === 'sql-msq-task'
? (await Api.instance.post(`/druid/v2/sql/task`, payload)).data
: await queryDruidSql(payload);
} catch (e) {
throw new Error(getDruidErrorMessage(e));
}
const plan = deepGet(result, '0.PLAN');
if (typeof plan !== 'string') {
throw new Error(`unexpected result from ${engine} API`);
}
try {
return JSONBig.parse(plan);
} catch {
return plan;
}
},
initQuery: queryWithContext,
});
let content: JSX.Element;
const { loading, error: explainError, data: explainResult } = explainState;
function renderQueryExplanation(queryExplanation: QueryExplanation) {
const queryString = JSONBig.stringify(queryExplanation.query, undefined, 2);
return (
<div className="query-explanation">
<FormGroup className="query-group">
<AceEditor
mode="hjson"
theme="solarized_dark"
className="query-string"
name="ace-editor"
fontSize={12}
width="100%"
height="100%"
showGutter
showPrintMargin={false}
value={queryString}
readOnly
/>
</FormGroup>
<FormGroup className="signature-group" label="Signature">
<InputGroup defaultValue={formatSignature(queryExplanation)} readOnly />
</FormGroup>
{openQueryLabel && (
<Button
className="open-query"
text={openQueryLabel}
rightIcon={IconNames.ARROW_TOP_RIGHT}
intent={Intent.PRIMARY}
minimal
onClick={() => {
onOpenQuery(queryString);
onClose();
}}
/>
)}
</div>
);
}
if (loading) {
content = <Loader />;
} else if (explainError) {
content = <div>{explainError.message}</div>;
} else if (!explainResult) {
content = <div />;
} else if (nonEmptyArray(explainResult)) {
if (explainResult.length === 1) {
content = renderQueryExplanation(explainResult[0]);
} else {
content = (
<Tabs animate renderActiveTabPanelOnly vertical>
{explainResult.map((queryExplanation, i) => (
<Tab
id={i}
key={i}
title={`Query ${i + 1}`}
panel={renderQueryExplanation(queryExplanation)}
/>
))}
<Tabs.Expander />
</Tabs>
);
}
} else {
content = <div className="generic-result">{String(explainResult)}</div>;
}
return (
<Dialog className="explain-dialog" isOpen onClose={onClose} title="Query plan">
<div className={Classes.DIALOG_BODY}>{content}</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button text="Close" onClick={onClose} />
</div>
</div>
</Dialog>
);
});