Web console: updated the explain dialog to use new explain output (#12009)

This is the UI followup to the work done in #11908

Updated the Explain dialog to use the new output format.
This commit is contained in:
Vadim Ogievetsky 2021-12-01 10:48:11 -08:00 committed by GitHub
parent ffa553593f
commit 1f95a42bb8
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 532 additions and 208 deletions

View File

@ -18,13 +18,7 @@
import { sane } from 'druid-query-toolkit/build/test-utils';
import {
DruidError,
getDruidErrorMessage,
parseHtmlError,
parseQueryPlan,
trimSemicolon,
} from './druid-query';
import { DruidError, getDruidErrorMessage, parseHtmlError, trimSemicolon } from './druid-query';
describe('DruidQuery', () => {
describe('DruidError.parsePosition', () => {
@ -227,10 +221,6 @@ describe('DruidQuery', () => {
it('parseHtmlError', () => {
expect(getDruidErrorMessage({})).toMatchInlineSnapshot(`undefined`);
});
it('parseQueryPlan', () => {
expect(parseQueryPlan('start')).toMatchInlineSnapshot(`"start"`);
});
});
describe('.trimSemicolon', () => {

View File

@ -17,6 +17,7 @@
*/
import axios, { AxiosResponse } from 'axios';
import { SqlRef } from 'druid-query-toolkit';
import { Api } from '../singletons';
@ -296,73 +297,15 @@ export async function queryDruidSql<T = any>(sqlQueryPayload: Record<string, any
return sqlResultResp.data;
}
export interface BasicQueryExplanation {
export interface QueryExplanation {
query: any;
signature: string | null;
signature: { name: string; type: string }[];
}
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);
export function formatSignature(queryExplanation: QueryExplanation): string {
return queryExplanation.signature
.map(({ name, type }) => `${SqlRef.column(name)}::${type}`)
.join(', ');
}
export function trimSemicolon(query: string): string {

View File

@ -86,7 +86,175 @@ exports[`ExplainDialog matches snapshot on loading 1`] = `
</Blueprint3.Dialog>
`;
exports[`ExplainDialog matches snapshot on some data 1`] = `
exports[`ExplainDialog matches snapshot on some data (many queries) 1`] = `
<Blueprint3.Dialog
canOutsideClickClose={true}
className="explain-dialog"
isOpen={true}
onClose={[Function]}
title="Query plan"
>
<div
className="bp3-dialog-body"
>
<Blueprint3.Tabs
animate={true}
large={false}
renderActiveTabPanelOnly={true}
vertical={true}
>
<Blueprint3.Tab
disabled={false}
id={0}
key="0"
panel={
<div
className="query-explanation"
>
<Blueprint3.FormGroup
className="query-group"
label="Query"
>
<Blueprint3.TextArea
readOnly={true}
value="{
\\"queryType\\": \\"scan\\",
\\"dataSource\\": {
\\"type\\": \\"table\\",
\\"name\\": \\"wikipedia\\"
},
\\"intervals\\": {
\\"type\\": \\"intervals\\",
\\"intervals\\": [
\\"-146136543-09-08T08:23:32.096Z/146140482-04-24T15:36:27.903Z\\"
]
},
\\"virtualColumns\\": [],
\\"resultFormat\\": \\"compactedList\\",
\\"batchSize\\": 20480,
\\"limit\\": 101,
\\"filter\\": null,
\\"columns\\": [
\\"channel\\"
],
\\"legacy\\": false,
\\"context\\": {},
\\"descending\\": false,
\\"granularity\\": {
\\"type\\": \\"all\\"
}
}"
/>
</Blueprint3.FormGroup>
<Blueprint3.FormGroup
className="signature-group"
label="Signature"
>
<Blueprint3.InputGroup
defaultValue="channel::STRING"
readOnly={true}
/>
</Blueprint3.FormGroup>
<Blueprint3.Button
className="open-query"
intent="primary"
minimal={true}
onClick={[Function]}
rightIcon="arrow-top-right"
text="Open query"
/>
</div>
}
title="Query 1"
/>
<Blueprint3.Tab
disabled={false}
id={1}
key="1"
panel={
<div
className="query-explanation"
>
<Blueprint3.FormGroup
className="query-group"
label="Query"
>
<Blueprint3.TextArea
readOnly={true}
value="{
\\"queryType\\": \\"scan\\",
\\"dataSource\\": {
\\"type\\": \\"table\\",
\\"name\\": \\"wikipedia\\"
},
\\"intervals\\": {
\\"type\\": \\"intervals\\",
\\"intervals\\": [
\\"-146136543-09-08T08:23:32.096Z/146140482-04-24T15:36:27.903Z\\"
]
},
\\"virtualColumns\\": [],
\\"resultFormat\\": \\"compactedList\\",
\\"batchSize\\": 20480,
\\"limit\\": 101,
\\"filter\\": {
\\"type\\": \\"selector\\",
\\"dimension\\": \\"channel\\",
\\"value\\": \\"#en.wikipedia\\",
\\"extractionFn\\": null
},
\\"columns\\": [
\\"channel\\"
],
\\"legacy\\": false,
\\"context\\": {},
\\"descending\\": false,
\\"granularity\\": {
\\"type\\": \\"all\\"
}
}"
/>
</Blueprint3.FormGroup>
<Blueprint3.FormGroup
className="signature-group"
label="Signature"
>
<Blueprint3.InputGroup
defaultValue="channel::STRING"
readOnly={true}
/>
</Blueprint3.FormGroup>
<Blueprint3.Button
className="open-query"
intent="primary"
minimal={true}
onClick={[Function]}
rightIcon="arrow-top-right"
text="Open query"
/>
</div>
}
title="Query 2"
/>
<Expander />
</Blueprint3.Tabs>
</div>
<div
className="bp3-dialog-footer"
>
<div
className="bp3-dialog-footer-actions"
>
<Blueprint3.Button
onClick={[Function]}
text="Close"
/>
</div>
</div>
</Blueprint3.Dialog>
`;
exports[`ExplainDialog matches snapshot on some data (one query) 1`] = `
<Blueprint3.Dialog
canOutsideClickClose={true}
className="explain-dialog"
@ -98,9 +266,10 @@ exports[`ExplainDialog matches snapshot on some data 1`] = `
className="bp3-dialog-body"
>
<div
className="one-query"
className="query-explanation"
>
<Blueprint3.FormGroup
className="query-group"
label="Query"
>
<Blueprint3.TextArea
@ -108,19 +277,71 @@ exports[`ExplainDialog matches snapshot on some data 1`] = `
value="{
\\"queryType\\": \\"topN\\",
\\"dataSource\\": {
\\"type\\": \\"table\\",
\\"name\\": \\"kttm-multi-day\\"
\\"type\\": \\"join\\",
\\"left\\": {
\\"type\\": \\"table\\",
\\"name\\": \\"wikipedia\\"
},
\\"right\\": {
\\"type\\": \\"query\\",
\\"query\\": {
\\"queryType\\": \\"groupBy\\",
\\"dataSource\\": {
\\"type\\": \\"table\\",
\\"name\\": \\"wikipedia\\"
},
\\"intervals\\": {
\\"type\\": \\"intervals\\",
\\"intervals\\": [
\\"-146136543-09-08T08:23:32.096Z/146140482-04-24T15:36:27.903Z\\"
]
},
\\"virtualColumns\\": [],
\\"filter\\": {
\\"type\\": \\"selector\\",
\\"dimension\\": \\"channel\\",
\\"value\\": \\"#en.wikipedia\\",
\\"extractionFn\\": null
},
\\"granularity\\": {
\\"type\\": \\"all\\"
},
\\"dimensions\\": [
{
\\"type\\": \\"default\\",
\\"dimension\\": \\"channel\\",
\\"outputName\\": \\"d0\\",
\\"outputType\\": \\"STRING\\"
}
],
\\"aggregations\\": [],
\\"postAggregations\\": [],
\\"having\\": null,
\\"limitSpec\\": {
\\"type\\": \\"NoopLimitSpec\\"
},
\\"context\\": {},
\\"descending\\": false
}
},
\\"rightPrefix\\": \\"j0.\\",
\\"condition\\": \\"(\\\\\\"channel\\\\\\" == \\\\\\"j0.d0\\\\\\")\\",
\\"joinType\\": \\"LEFT\\",
\\"leftFilter\\": null
},
\\"virtualColumns\\": [],
\\"dimension\\": {
\\"type\\": \\"default\\",
\\"dimension\\": \\"browser\\",
\\"dimension\\": \\"channel\\",
\\"outputName\\": \\"d0\\",
\\"outputType\\": \\"STRING\\"
},
\\"metric\\": {
\\"type\\": \\"numeric\\",
\\"metric\\": \\"a0\\"
\\"type\\": \\"dimension\\",
\\"previousStop\\": null,
\\"ordering\\": {
\\"type\\": \\"lexicographic\\"
}
},
\\"threshold\\": 101,
\\"intervals\\": {
@ -140,22 +361,28 @@ exports[`ExplainDialog matches snapshot on some data 1`] = `
}
],
\\"postAggregations\\": [],
\\"context\\": {
\\"sqlOuterLimit\\": 101,
\\"sqlQueryId\\": \\"5905fe8d-9a91-41e0-8f3a-d7a8ac21dce6\\"
},
\\"context\\": {},
\\"descending\\": false
}"
/>
</Blueprint3.FormGroup>
<Blueprint3.FormGroup
className="signature-group"
label="Signature"
>
<Blueprint3.InputGroup
defaultValue="[{d0:STRING, a0:LONG}]"
defaultValue="d0::STRING, a0::LONG"
readOnly={true}
/>
</Blueprint3.FormGroup>
<Blueprint3.Button
className="open-query"
intent="primary"
minimal={true}
onClick={[Function]}
rightIcon="arrow-top-right"
text="Open query"
/>
</div>
</div>
<div
@ -168,11 +395,6 @@ exports[`ExplainDialog matches snapshot on some data 1`] = `
onClick={[Function]}
text="Close"
/>
<Blueprint3.Button
intent="primary"
onClick={[Function]}
text="Open query"
/>
</div>
</div>
</Blueprint3.Dialog>

View File

@ -18,30 +18,54 @@
.explain-dialog {
&.bp3-dialog {
width: 600px;
width: 800px;
height: 90vh;
}
.bp3-dialog-body {
min-height: 70vh;
}
textarea {
width: 100%;
}
.one-query {
textarea {
height: 50vh !important;
.bp3-tabs {
width: 100%;
height: 100%;
}
}
.two-queries {
textarea {
height: 25vh !important;
.bp3-tab-panel {
flex: 1;
}
}
.generic-result {
overflow: scroll;
.query-explanation {
position: relative;
display: flex;
flex-direction: column;
height: 100%;
textarea {
width: 100%;
height: 100%;
resize: none;
}
.query-group {
flex: 1;
.bp3-form-content {
flex: 1;
}
}
.signature-group {
margin-bottom: 0;
}
.open-query {
position: absolute;
top: 30px;
right: 6px;
}
}
.generic-result {
overflow: scroll;
white-space: pre-wrap;
}
}
}

View File

@ -19,17 +19,11 @@
import { shallow } from 'enzyme';
import React from 'react';
import {
BasicQueryExplanation,
parseQueryPlan,
QueryState,
SemiJoinQueryExplanation,
} from '../../../utils';
import { QueryExplanation, QueryState } from '../../../utils';
import { ExplainDialog } from './explain-dialog';
let explainState: QueryState<BasicQueryExplanation | SemiJoinQueryExplanation | string> =
QueryState.INIT;
let explainState: QueryState<QueryExplanation[] | string> = QueryState.INIT;
jest.mock('../../../hooks', () => {
return {
@ -64,11 +58,184 @@ describe('ExplainDialog', () => {
expect(shallow(makeExplainDialog())).toMatchSnapshot();
});
it('matches snapshot on some data', () => {
it('matches snapshot on some data (one query)', () => {
explainState = new QueryState({
data: parseQueryPlan(
`DruidQueryRel(query=[{"queryType":"topN","dataSource":{"type":"table","name":"kttm-multi-day"},"virtualColumns":[],"dimension":{"type":"default","dimension":"browser","outputName":"d0","outputType":"STRING"},"metric":{"type":"numeric","metric":"a0"},"threshold":101,"intervals":{"type":"intervals","intervals":["-146136543-09-08T08:23:32.096Z/146140482-04-24T15:36:27.903Z"]},"filter":null,"granularity":{"type":"all"},"aggregations":[{"type":"count","name":"a0"}],"postAggregations":[],"context":{"sqlOuterLimit":101,"sqlQueryId":"5905fe8d-9a91-41e0-8f3a-d7a8ac21dce6"},"descending":false}], signature=[{d0:STRING, a0:LONG}])`,
),
data: [
{
query: {
queryType: 'topN',
dataSource: {
type: 'join',
left: {
type: 'table',
name: 'wikipedia',
},
right: {
type: 'query',
query: {
queryType: 'groupBy',
dataSource: {
type: 'table',
name: 'wikipedia',
},
intervals: {
type: 'intervals',
intervals: ['-146136543-09-08T08:23:32.096Z/146140482-04-24T15:36:27.903Z'],
},
virtualColumns: [],
filter: {
type: 'selector',
dimension: 'channel',
value: '#en.wikipedia',
extractionFn: null,
},
granularity: {
type: 'all',
},
dimensions: [
{
type: 'default',
dimension: 'channel',
outputName: 'd0',
outputType: 'STRING',
},
],
aggregations: [],
postAggregations: [],
having: null,
limitSpec: {
type: 'NoopLimitSpec',
},
context: {},
descending: false,
},
},
rightPrefix: 'j0.',
condition: '("channel" == "j0.d0")',
joinType: 'LEFT',
leftFilter: null,
},
virtualColumns: [],
dimension: {
type: 'default',
dimension: 'channel',
outputName: 'd0',
outputType: 'STRING',
},
metric: {
type: 'dimension',
previousStop: null,
ordering: {
type: 'lexicographic',
},
},
threshold: 101,
intervals: {
type: 'intervals',
intervals: ['-146136543-09-08T08:23:32.096Z/146140482-04-24T15:36:27.903Z'],
},
filter: null,
granularity: {
type: 'all',
},
aggregations: [
{
type: 'count',
name: 'a0',
},
],
postAggregations: [],
context: {},
descending: false,
},
signature: [
{
name: 'd0',
type: 'STRING',
},
{
name: 'a0',
type: 'LONG',
},
],
},
],
});
expect(shallow(makeExplainDialog())).toMatchSnapshot();
});
it('matches snapshot on some data (many queries)', () => {
explainState = new QueryState({
data: [
{
query: {
queryType: 'scan',
dataSource: {
type: 'table',
name: 'wikipedia',
},
intervals: {
type: 'intervals',
intervals: ['-146136543-09-08T08:23:32.096Z/146140482-04-24T15:36:27.903Z'],
},
virtualColumns: [],
resultFormat: 'compactedList',
batchSize: 20480,
limit: 101,
filter: null,
columns: ['channel'],
legacy: false,
context: {},
descending: false,
granularity: {
type: 'all',
},
},
signature: [
{
name: 'channel',
type: 'STRING',
},
],
},
{
query: {
queryType: 'scan',
dataSource: {
type: 'table',
name: 'wikipedia',
},
intervals: {
type: 'intervals',
intervals: ['-146136543-09-08T08:23:32.096Z/146140482-04-24T15:36:27.903Z'],
},
virtualColumns: [],
resultFormat: 'compactedList',
batchSize: 20480,
limit: 101,
filter: {
type: 'selector',
dimension: 'channel',
value: '#en.wikipedia',
extractionFn: null,
},
columns: ['channel'],
legacy: false,
context: {},
descending: false,
granularity: {
type: 'all',
},
},
signature: [
{
name: 'channel',
type: 'STRING',
},
],
},
],
});
expect(shallow(makeExplainDialog())).toMatchSnapshot();

View File

@ -23,20 +23,22 @@ import {
FormGroup,
InputGroup,
Intent,
Tab,
Tabs,
TextArea,
} from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
import * as JSONBig from 'json-bigint-native';
import React from 'react';
import { Loader } from '../../../components';
import { useQueryManager } from '../../../hooks';
import {
BasicQueryExplanation,
formatSignature,
getDruidErrorMessage,
parseQueryPlan,
queryDruidSql,
QueryExplanation,
QueryWithContext,
SemiJoinQueryExplanation,
trimSemicolon,
} from '../../../utils';
import { isEmptyContext } from '../../../utils/query-context';
@ -63,16 +65,17 @@ export interface ExplainDialogProps {
export const ExplainDialog = React.memo(function ExplainDialog(props: ExplainDialogProps) {
const { queryWithContext, onClose, setQueryString, mandatoryQueryContext } = props;
const [explainState] = useQueryManager<
QueryWithContext,
BasicQueryExplanation | SemiJoinQueryExplanation | string
>({
const [explainState] = useQueryManager<QueryWithContext, QueryExplanation[] | string>({
processQuery: async (queryWithContext: QueryWithContext) => {
const { queryString, queryContext, wrapQueryLimit } = queryWithContext;
let context: Record<string, any> | undefined;
if (!isEmptyContext(queryContext) || wrapQueryLimit || mandatoryQueryContext) {
context = { ...queryContext, ...(mandatoryQueryContext || {}) };
context = {
...queryContext,
...(mandatoryQueryContext || {}),
useNativeQueryExplain: true,
};
if (typeof wrapQueryLimit !== 'undefined') {
context.sqlOuterLimit = wrapQueryLimit + 1;
}
@ -88,88 +91,73 @@ export const ExplainDialog = React.memo(function ExplainDialog(props: ExplainDia
throw new Error(getDruidErrorMessage(e));
}
return parseQueryPlan(result[0]['PLAN']);
const plan = result[0]['PLAN'];
if (typeof plan !== 'string') {
throw new Error(`unexpected result from server`);
}
try {
return JSONBig.parse(plan);
} catch {
return plan;
}
},
initQuery: queryWithContext,
});
let content: JSX.Element;
let queryString: string | undefined;
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" label="Query">
<TextArea readOnly value={queryString} />
</FormGroup>
<FormGroup className="signature-group" label="Signature">
<InputGroup defaultValue={formatSignature(queryExplanation)} readOnly />
</FormGroup>
<Button
className="open-query"
text="Open query"
rightIcon={IconNames.ARROW_TOP_RIGHT}
intent={Intent.PRIMARY}
minimal
onClick={() => {
setQueryString(queryString);
onClose();
}}
/>
</div>
);
}
if (loading) {
content = <Loader />;
} else if (explainError) {
content = <div>{explainError.message}</div>;
} else if (!explainResult) {
content = <div />;
} else if ((explainResult as BasicQueryExplanation).query) {
queryString = JSONBig.stringify(
(explainResult as BasicQueryExplanation).query[0],
undefined,
2,
);
content = (
<div className="one-query">
<FormGroup label="Query">
<TextArea readOnly value={queryString} />
</FormGroup>
{(explainResult as BasicQueryExplanation).signature && (
<FormGroup label="Signature">
<InputGroup
defaultValue={(explainResult as BasicQueryExplanation).signature || ''}
readOnly
} else if (Array.isArray(explainResult) && explainResult.length) {
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)}
/>
</FormGroup>
)}
</div>
);
} else if (
(explainResult as SemiJoinQueryExplanation).mainQuery &&
(explainResult as SemiJoinQueryExplanation).subQueryRight
) {
content = (
<div className="two-queries">
<FormGroup label="Main query">
<TextArea
readOnly
value={JSONBig.stringify(
(explainResult as SemiJoinQueryExplanation).mainQuery.query,
undefined,
2,
)}
/>
</FormGroup>
{(explainResult as SemiJoinQueryExplanation).mainQuery.signature && (
<FormGroup label="Signature">
<InputGroup
defaultValue={(explainResult as SemiJoinQueryExplanation).mainQuery.signature || ''}
readOnly
/>
</FormGroup>
)}
<FormGroup label="Sub query">
<TextArea
readOnly
value={JSONBig.stringify(
(explainResult as SemiJoinQueryExplanation).subQueryRight.query,
undefined,
2,
)}
/>
</FormGroup>
{(explainResult as SemiJoinQueryExplanation).subQueryRight.signature && (
<FormGroup label="Signature">
<InputGroup
defaultValue={
(explainResult as SemiJoinQueryExplanation).subQueryRight.signature || ''
}
readOnly
/>
</FormGroup>
)}
</div>
);
))}
<Tabs.Expander />
</Tabs>
);
}
} else {
content = <div className="generic-result">{explainResult}</div>;
}
@ -180,16 +168,6 @@ export const ExplainDialog = React.memo(function ExplainDialog(props: ExplainDia
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button text="Close" onClick={onClose} />
{queryString && (
<Button
text="Open query"
intent={Intent.PRIMARY}
onClick={() => {
if (queryString) setQueryString(queryString);
onClose();
}}
/>
)}
</div>
</div>
</Dialog>