mirror of https://github.com/apache/druid.git
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:
parent
0fa122ecf2
commit
a66291a9cd
|
@ -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 {
|
||||||
|
|
|
@ -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>;
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -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>;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
||||||
|
}
|
||||||
|
|
|
@ -38,3 +38,4 @@
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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>;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in New Issue