SQL explain in web console (#7402)

* Add explain for SQL query

* Terminate explain query manager

* Add signature for semi-joined queries

* Extract components out

* Hide more button in rune mode

* Add types for query explanation parser

* Add type for query-plan-dialog
This commit is contained in:
Qi Shu 2019-04-05 12:44:51 -07:00 committed by Clint Wylie
parent 0fa122ecf2
commit a66291a9cd
7 changed files with 339 additions and 20 deletions

View File

@ -37,9 +37,21 @@
} }
.auto-complete-checkbox { .sql-control-popover {
margin:10px; padding:10px;
min-width: 120px; min-width: 120px;
.bp3-form-group {
margin-bottom: 0;
}
button {
span {
position: relative;
left: -7px;
}
padding-right: 35px;
}
} }
.ace_tooltip { .ace_tooltip {

View File

@ -16,7 +16,7 @@
* limitations under the License. * limitations under the License.
*/ */
import { Button, Checkbox, Classes, Intent, Popover, Position } from "@blueprintjs/core"; import { Button, Checkbox, Classes, FormGroup, Intent, Menu, Popover, Position } from "@blueprintjs/core";
import { IconNames } from "@blueprintjs/icons"; import { IconNames } from "@blueprintjs/icons";
import axios from "axios"; import axios from "axios";
import * as ace from 'brace'; import * as ace from 'brace';
@ -39,6 +39,7 @@ const langTools = ace.acequire('ace/ext/language_tools');
export interface SqlControlProps extends React.Props<any> { export interface SqlControlProps extends React.Props<any> {
initSql: string | null; initSql: string | null;
onRun: (query: string) => void; onRun: (query: string) => void;
onExplain: (query: string) => void;
} }
export interface SqlControlState { export interface SqlControlState {
@ -165,19 +166,28 @@ export class SqlControl extends React.Component<SqlControlProps, SqlControlState
} }
render() { render() {
const { onRun } = this.props; const { onRun, onExplain } = this.props;
const { query, autoCompleteOn } = this.state; const { query, autoCompleteOn } = this.state;
const isRune = query.trim().startsWith('{'); const isRune = query.trim().startsWith('{');
const autoCompletePopover = <div className={"auto-complete-checkbox"}> const SqlControlPopover = <Popover position={Position.BOTTOM_LEFT}>
<Checkbox <Button minimal icon={IconNames.MORE}/>
checked={isRune ? false : autoCompleteOn} <div className={"sql-control-popover"}>
disabled={isRune} <Checkbox
label={"Auto complete"} checked={isRune ? false : autoCompleteOn}
onChange={() => this.setState({autoCompleteOn: !autoCompleteOn})} label={"Auto complete"}
/> onChange={() => this.setState({autoCompleteOn: !autoCompleteOn})}
</div>; />
<Button
icon={IconNames.CLEAN}
className={Classes.POPOVER_DISMISS}
text={"Explain"}
onClick={() => onExplain(query)}
minimal
/>
</div>
</Popover>;
// Set the key in the AceEditor to force a rebind and prevent an error that happens otherwise // Set the key in the AceEditor to force a rebind and prevent an error that happens otherwise
return <div className="sql-control"> return <div className="sql-control">
@ -207,9 +217,7 @@ export class SqlControl extends React.Component<SqlControlProps, SqlControlState
<Button rightIcon={IconNames.CARET_RIGHT} onClick={() => onRun(query)}> <Button rightIcon={IconNames.CARET_RIGHT} onClick={() => onRun(query)}>
{isRune ? 'Rune' : 'Run'} {isRune ? 'Rune' : 'Run'}
</Button> </Button>
<Popover position={Position.BOTTOM_LEFT} content={autoCompletePopover}> {!isRune ? SqlControlPopover : null}
<Button minimal icon={IconNames.MORE}/>
</Popover>
</div> </div>
</div>; </div>;
} }

View File

@ -0,0 +1,40 @@
/*
* 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.
*/
.query-plan-dialog {
&.bp3-dialog {
width: 600px;
}
textarea {
width: 100%;
}
.one-query {
textarea {
height: 50vh !important;
}
}
.two-queries {
textarea {
height: 25vh !important;
}
}
}

View File

@ -0,0 +1,139 @@
/*
* 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, TextArea } from "@blueprintjs/core";
import * as React from "react";
import { BasicQueryExplanation, SemiJoinQueryExplanation } from "../utils";
import "./query-plan-dialog.scss";
export interface QueryPlanDialogProps extends React.Props<any> {
explainResult: BasicQueryExplanation | SemiJoinQueryExplanation | string | null;
explainError: Error | null;
onClose: () => void;
}
export interface QueryPlanDialogState {
}
export class QueryPlanDialog extends React.Component<QueryPlanDialogProps, QueryPlanDialogState> {
constructor(props: QueryPlanDialogProps) {
super(props);
this.state = {};
}
render() {
const { explainResult, explainError, onClose } = this.props;
let content: JSX.Element;
if (explainError) {
content = <div>{explainError.message}</div>;
} else if (explainResult == null) {
content = <div/>;
} else if ((explainResult as BasicQueryExplanation).query) {
let signature: JSX.Element | null = null;
if ((explainResult as BasicQueryExplanation).signature) {
const signatureContent = (explainResult as BasicQueryExplanation).signature || "";
signature = <FormGroup
label={"Signature"}
>
<InputGroup defaultValue={signatureContent} readOnly/>
</FormGroup>;
}
content = <div className={"one-query"}>
<FormGroup
label={"Query"}
>
<TextArea
readOnly
value={JSON.stringify((explainResult as BasicQueryExplanation).query[0], undefined, 2)}
/>
</FormGroup>
{signature}
</div>;
} else if ((explainResult as SemiJoinQueryExplanation).mainQuery && (explainResult as SemiJoinQueryExplanation).subQueryRight) {
let mainSignature: JSX.Element | null = null;
let subSignature: JSX.Element | null = null;
if ((explainResult as SemiJoinQueryExplanation).mainQuery.signature) {
const signatureContent = (explainResult as SemiJoinQueryExplanation).mainQuery.signature || "";
mainSignature = <FormGroup
label={"Signature"}
>
<InputGroup defaultValue={signatureContent} readOnly/>
</FormGroup>;
}
if ((explainResult as SemiJoinQueryExplanation).subQueryRight.signature) {
const signatureContent = (explainResult as SemiJoinQueryExplanation).subQueryRight.signature || "";
subSignature = <FormGroup
label={"Signature"}
>
<InputGroup defaultValue={signatureContent} readOnly/>
</FormGroup>;
}
content = <div className={"two-queries"}>
<FormGroup
label={"Main query"}
>
<TextArea
readOnly
value={JSON.stringify((explainResult as SemiJoinQueryExplanation).mainQuery.query, undefined, 2)}
/>
</FormGroup>
{mainSignature}
<FormGroup
label={"Sub query"}
>
<TextArea
readOnly
value={JSON.stringify((explainResult as SemiJoinQueryExplanation).subQueryRight.query, undefined, 2)}
/>
</FormGroup>
{subSignature}
</div>;
} else {
content = <div>{explainResult}</div>;
}
return <Dialog
className={'query-plan-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>;
}
}

View File

@ -43,3 +43,70 @@ export async function queryDruidSql(sqlQuery: Record<string, any>): Promise<any[
} }
return sqlResultResp.data; return sqlResultResp.data;
} }
export interface BasicQueryExplanation {
query: any;
signature: string | null;
}
export interface SemiJoinQueryExplanation {
mainQuery: BasicQueryExplanation;
subQueryRight: BasicQueryExplanation;
}
function parseQueryPlanResult(queryPlanResult: string): BasicQueryExplanation {
if (!queryPlanResult) {
return {
query: null,
signature: null
};
}
const queryAndSignature = queryPlanResult.split(', signature=');
const queryValue = new RegExp(/query=(.+)/).exec(queryAndSignature[0]);
const signatureValue = queryAndSignature[1];
let parsedQuery: any;
if (queryValue && queryValue[1]) {
try {
parsedQuery = JSON.parse(queryValue[1]);
} catch (e) {}
}
return {
query: parsedQuery || queryPlanResult,
signature: signatureValue || null
};
}
export function parseQueryPlan(raw: string): BasicQueryExplanation | SemiJoinQueryExplanation | string {
let plan: string = raw;
plan = plan.replace(/\n/g, '');
if (plan.includes('DruidOuterQueryRel(')) {
return plan; // don't know how to parse this
}
let queryArgs: string;
const queryRelFnStart = 'DruidQueryRel(';
const semiJoinFnStart = 'DruidSemiJoin(';
if (plan.startsWith(queryRelFnStart)) {
queryArgs = plan.substring(queryRelFnStart.length, plan.length - 1);
} else if (plan.startsWith(semiJoinFnStart)) {
queryArgs = plan.substring(semiJoinFnStart.length, plan.length - 1);
const leftExpressionsArgs = ', leftExpressions=';
const keysArgumentIdx = queryArgs.indexOf(leftExpressionsArgs);
if (keysArgumentIdx !== -1) {
return {
mainQuery: parseQueryPlanResult(queryArgs.substring(0, keysArgumentIdx)),
subQueryRight: parseQueryPlan(queryArgs.substring(queryArgs.indexOf(queryRelFnStart)))
} as SemiJoinQueryExplanation;
}
} else {
return plan;
}
return parseQueryPlanResult(queryArgs);
}

View File

@ -38,3 +38,4 @@
flex: 1; flex: 1;
} }
} }

View File

@ -16,20 +16,20 @@
* limitations under the License. * limitations under the License.
*/ */
import axios from 'axios';
import * as classNames from 'classnames';
import * as Hjson from "hjson"; import * as Hjson from "hjson";
import * as React from 'react'; import * as React from 'react';
import ReactTable from "react-table"; import ReactTable from "react-table";
import { SqlControl } from '../components/sql-control'; import { SqlControl } from '../components/sql-control';
import { QueryPlanDialog } from "../dialogs/query-plan-dialog";
import { import {
BasicQueryExplanation,
decodeRune, decodeRune,
HeaderRows, HeaderRows,
localStorageGet, LocalStorageKeys, localStorageGet, LocalStorageKeys,
localStorageSet, localStorageSet, parseQueryPlan,
queryDruidRune, queryDruidRune,
queryDruidSql, QueryManager queryDruidSql, QueryManager, SemiJoinQueryExplanation
} from '../utils'; } from '../utils';
import "./sql-view.scss"; import "./sql-view.scss";
@ -42,18 +42,27 @@ export interface SqlViewState {
loading: boolean; loading: boolean;
result: HeaderRows | null; result: HeaderRows | null;
error: string | null; error: string | null;
explainDialogOpen: boolean;
explainResult: BasicQueryExplanation | SemiJoinQueryExplanation | string | null;
loadingExplain: boolean;
explainError: Error | null;
} }
export class SqlView extends React.Component<SqlViewProps, SqlViewState> { export class SqlView extends React.Component<SqlViewProps, SqlViewState> {
private sqlQueryManager: QueryManager<string, HeaderRows>; private sqlQueryManager: QueryManager<string, HeaderRows>;
private explainQueryManager: QueryManager<string, any>;
constructor(props: SqlViewProps, context: any) { constructor(props: SqlViewProps, context: any) {
super(props, context); super(props, context);
this.state = { this.state = {
loading: false, loading: false,
result: null, result: null,
error: null error: null,
explainDialogOpen: false,
loadingExplain: false,
explainResult: null,
explainError: null
}; };
} }
@ -86,10 +95,51 @@ export class SqlView extends React.Component<SqlViewProps, SqlViewState> {
}); });
} }
}); });
this.explainQueryManager = new QueryManager({
processQuery: async (query: string) => {
const explainQuery = `explain plan for ${query}`;
const result = await queryDruidSql({
query: explainQuery,
resultFormat: "object"
});
const data: BasicQueryExplanation | SemiJoinQueryExplanation | string = parseQueryPlan(result[0]["PLAN"]);
return data;
},
onStateChange: ({ result, loading, error }) => {
this.setState({
explainResult: result,
loadingExplain: loading,
explainError: error !== null ? new Error(error) : null
});
}
});
} }
componentWillUnmount(): void { componentWillUnmount(): void {
this.sqlQueryManager.terminate(); this.sqlQueryManager.terminate();
this.explainQueryManager.terminate();
}
getExplain = (q: string) => {
this.setState({
explainDialogOpen: true,
loadingExplain: true,
explainError: null
});
this.explainQueryManager.runQuery(q);
}
renderExplainDialog() {
const {explainDialogOpen, explainResult, loadingExplain, explainError} = this.state;
if (!loadingExplain && explainDialogOpen) {
return <QueryPlanDialog
explainResult={explainResult}
explainError={explainError}
onClose={() => this.setState({explainDialogOpen: false})}
/>;
}
return null;
} }
renderResultTable() { renderResultTable() {
@ -116,8 +166,10 @@ export class SqlView extends React.Component<SqlViewProps, SqlViewState> {
localStorageSet(LocalStorageKeys.QUERY_KEY, q); localStorageSet(LocalStorageKeys.QUERY_KEY, q);
this.sqlQueryManager.runQuery(q); this.sqlQueryManager.runQuery(q);
}} }}
onExplain={(q: string) => this.getExplain(q)}
/> />
{this.renderResultTable()} {this.renderResultTable()}
{this.renderExplainDialog()}
</div>; </div>;
} }
} }