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 {
|
||||
margin:10px;
|
||||
.sql-control-popover {
|
||||
padding:10px;
|
||||
min-width: 120px;
|
||||
|
||||
.bp3-form-group {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
button {
|
||||
span {
|
||||
position: relative;
|
||||
left: -7px;
|
||||
}
|
||||
padding-right: 35px;
|
||||
}
|
||||
}
|
||||
|
||||
.ace_tooltip {
|
||||
|
|
|
@ -16,7 +16,7 @@
|
|||
* 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 axios from "axios";
|
||||
import * as ace from 'brace';
|
||||
|
@ -39,6 +39,7 @@ const langTools = ace.acequire('ace/ext/language_tools');
|
|||
export interface SqlControlProps extends React.Props<any> {
|
||||
initSql: string | null;
|
||||
onRun: (query: string) => void;
|
||||
onExplain: (query: string) => void;
|
||||
}
|
||||
|
||||
export interface SqlControlState {
|
||||
|
@ -165,19 +166,28 @@ export class SqlControl extends React.Component<SqlControlProps, SqlControlState
|
|||
}
|
||||
|
||||
render() {
|
||||
const { onRun } = this.props;
|
||||
const { onRun, onExplain } = this.props;
|
||||
const { query, autoCompleteOn } = this.state;
|
||||
|
||||
const isRune = query.trim().startsWith('{');
|
||||
|
||||
const autoCompletePopover = <div className={"auto-complete-checkbox"}>
|
||||
const SqlControlPopover = <Popover position={Position.BOTTOM_LEFT}>
|
||||
<Button minimal icon={IconNames.MORE}/>
|
||||
<div className={"sql-control-popover"}>
|
||||
<Checkbox
|
||||
checked={isRune ? false : autoCompleteOn}
|
||||
disabled={isRune}
|
||||
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
|
||||
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)}>
|
||||
{isRune ? 'Rune' : 'Run'}
|
||||
</Button>
|
||||
<Popover position={Position.BOTTOM_LEFT} content={autoCompletePopover}>
|
||||
<Button minimal icon={IconNames.MORE}/>
|
||||
</Popover>
|
||||
{!isRune ? SqlControlPopover : null}
|
||||
</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;
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -16,20 +16,20 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import axios from 'axios';
|
||||
import * as classNames from 'classnames';
|
||||
import * as Hjson from "hjson";
|
||||
import * as React from 'react';
|
||||
import ReactTable from "react-table";
|
||||
|
||||
import { SqlControl } from '../components/sql-control';
|
||||
import { QueryPlanDialog } from "../dialogs/query-plan-dialog";
|
||||
import {
|
||||
BasicQueryExplanation,
|
||||
decodeRune,
|
||||
HeaderRows,
|
||||
localStorageGet, LocalStorageKeys,
|
||||
localStorageSet,
|
||||
localStorageSet, parseQueryPlan,
|
||||
queryDruidRune,
|
||||
queryDruidSql, QueryManager
|
||||
queryDruidSql, QueryManager, SemiJoinQueryExplanation
|
||||
} from '../utils';
|
||||
|
||||
import "./sql-view.scss";
|
||||
|
@ -42,18 +42,27 @@ export interface SqlViewState {
|
|||
loading: boolean;
|
||||
result: HeaderRows | null;
|
||||
error: string | null;
|
||||
explainDialogOpen: boolean;
|
||||
explainResult: BasicQueryExplanation | SemiJoinQueryExplanation | string | null;
|
||||
loadingExplain: boolean;
|
||||
explainError: Error | null;
|
||||
}
|
||||
|
||||
export class SqlView extends React.Component<SqlViewProps, SqlViewState> {
|
||||
|
||||
private sqlQueryManager: QueryManager<string, HeaderRows>;
|
||||
private explainQueryManager: QueryManager<string, any>;
|
||||
|
||||
constructor(props: SqlViewProps, context: any) {
|
||||
super(props, context);
|
||||
this.state = {
|
||||
loading: false,
|
||||
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 {
|
||||
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() {
|
||||
|
@ -116,8 +166,10 @@ export class SqlView extends React.Component<SqlViewProps, SqlViewState> {
|
|||
localStorageSet(LocalStorageKeys.QUERY_KEY, q);
|
||||
this.sqlQueryManager.runQuery(q);
|
||||
}}
|
||||
onExplain={(q: string) => this.getExplain(q)}
|
||||
/>
|
||||
{this.renderResultTable()}
|
||||
{this.renderExplainDialog()}
|
||||
</div>;
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Reference in New Issue