Web-Console: add more side column functions to query view (#8283)

* small fixes

* run jest -u

* add trim

* add to groupby

* save

* add functionality to column tree menu

* remove history dialog

* rename file

* fixes

* add new values to time-menu

* add query dialog

* add more functions to collumn-tree menu

* add menu functions

* remove test

* update tests, fix extra 0s, clear in between time filters

* fixes

* add deffered

* save

* add alias's

* change menu style

* fix substring

* small fixes

* add auto run to run button

* move divider to a reasonable position

* fix auto expland

* run jest -u

* fix auto run

* prevent aggregate cooumn changes

* fix group by bug

* add quertAst to state

* ui fixes

* prevent filter on non existant ast

* add auto run to shows

* use handle run

* handle *

* add alias and tslint fix
This commit is contained in:
mcbrewster 2019-08-15 01:22:25 -07:00 committed by Clint Wylie
parent ff26fcb159
commit 8924d285dc
30 changed files with 1714 additions and 202 deletions

View File

@ -4395,9 +4395,9 @@
"integrity": "sha512-0sYnfUHHMoajaud/i5BHKA12bUxiWEHJ9rxGqVEppFxsEcxef0TZQ5J59lU+UniEBcz/sG5fTESRyS7cOm3tSQ==" "integrity": "sha512-0sYnfUHHMoajaud/i5BHKA12bUxiWEHJ9rxGqVEppFxsEcxef0TZQ5J59lU+UniEBcz/sG5fTESRyS7cOm3tSQ=="
}, },
"druid-query-toolkit": { "druid-query-toolkit": {
"version": "0.3.15", "version": "0.3.20",
"resolved": "https://registry.npmjs.org/druid-query-toolkit/-/druid-query-toolkit-0.3.15.tgz", "resolved": "https://registry.npmjs.org/druid-query-toolkit/-/druid-query-toolkit-0.3.20.tgz",
"integrity": "sha512-q7uKfUdBItjOyNF1PlWF/rAhOim1uAjI085fsoKIBDZ2o5O4XRjaCKqXtW49Ovv92ks/22zLoYWNdU51i4PB/w==", "integrity": "sha512-jrGNu+o/nD+uhbxAMLXEQrSWNEylCRmkiuFDJSPCMz7cjMNArsdIgyBQHPezNAeTDpReelAt59xJ7pvqXwPIvw==",
"requires": { "requires": {
"tslib": "^1.10.0" "tslib": "^1.10.0"
} }

View File

@ -61,7 +61,7 @@
"d3": "^5.9.7", "d3": "^5.9.7",
"d3-array": "^2.2.0", "d3-array": "^2.2.0",
"druid-console": "^0.0.2", "druid-console": "^0.0.2",
"druid-query-toolkit": "^0.3.15", "druid-query-toolkit": "^0.3.20",
"file-saver": "^2.0.2", "file-saver": "^2.0.2",
"has-own-prop": "^2.0.0", "has-own-prop": "^2.0.0",
"hjson": "^3.1.2", "hjson": "^3.1.2",

View File

@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`deferred matches snapshot 1`] = `<div />`;

View File

@ -0,0 +1,31 @@
/*
* 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 { render } from '@testing-library/react';
import React from 'react';
import { Deferred } from './deferred';
describe('deferred', () => {
it('matches snapshot', () => {
const deferred = <Deferred content={() => <div />} />;
const { container } = render(deferred);
expect(container.firstChild).toMatchSnapshot();
});
});

View File

@ -0,0 +1,36 @@
/*
* 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 React from 'react';
export interface DeferredProps {
content: () => JSX.Element;
}
export interface DeferredState {}
export class Deferred extends React.PureComponent<DeferredProps, DeferredState> {
constructor(props: DeferredProps, context: any) {
super(props, context);
}
render(): JSX.Element {
const { content } = this.props;
return content();
}
}

View File

@ -0,0 +1,111 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`query plan dialog matches snapshot 1`] = `
<div
class="bp3-portal"
>
<div
class="bp3-overlay bp3-overlay-open bp3-overlay-scroll-container"
>
<div
class="bp3-overlay-backdrop bp3-overlay-appear bp3-overlay-appear-active"
tabindex="0"
/>
<div
class="bp3-dialog-container bp3-overlay-content bp3-overlay-appear bp3-overlay-appear-active"
tabindex="0"
>
<div
class="bp3-dialog query-history-dialog"
>
<div
class="bp3-dialog-header"
>
<h4
class="bp3-heading"
>
Query history
</h4>
<button
aria-label="Close"
class="bp3-button bp3-minimal bp3-dialog-close-button"
type="button"
>
<span
class="bp3-icon bp3-icon-small-cross"
icon="small-cross"
>
<svg
data-icon="small-cross"
height="20"
viewBox="0 0 20 20"
width="20"
>
<desc>
small-cross
</desc>
<path
d="M11.41 10l3.29-3.29c.19-.18.3-.43.3-.71a1.003 1.003 0 00-1.71-.71L10 8.59l-3.29-3.3a1.003 1.003 0 00-1.42 1.42L8.59 10 5.3 13.29c-.19.18-.3.43-.3.71a1.003 1.003 0 001.71.71l3.29-3.3 3.29 3.29c.18.19.43.3.71.3a1.003 1.003 0 00.71-1.71L11.41 10z"
fill-rule="evenodd"
/>
</svg>
</span>
</button>
</div>
<div
class="bp3-dialog-body"
>
<div
class="bp3-tabs bp3-vertical tab-area"
>
<div
class="bp3-tab-list"
role="tablist"
>
<div
class="bp3-tab-indicator-wrapper"
style="display: none;"
>
<div
class="bp3-tab-indicator"
/>
</div>
<div
class="bp3-flex-expander"
/>
</div>
</div>
</div>
<div
class="bp3-dialog-footer"
>
<div
class="bp3-dialog-footer-actions"
>
<button
class="bp3-button"
type="button"
>
<span
class="bp3-button-text"
>
Close
</span>
</button>
<button
class="bp3-button bp3-intent-primary"
type="button"
>
<span
class="bp3-button-text"
>
Open
</span>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
`;

View File

@ -0,0 +1,33 @@
/*
* 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-history-dialog {
&.bp3-dialog {
width: 900px;
}
.panel {
width: 100%;
}
.text-area {
width: 100%;
height: 500px;
resize: none;
}
}

View File

@ -0,0 +1,91 @@
/*
* 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, Intent, Tab, Tabs, TextArea } from '@blueprintjs/core';
import React from 'react';
import './query-history-dialog.scss';
export interface QueryRecord {
version: string;
queryString: string;
}
export interface QueryHistoryDialogProps {
setQueryString: (queryString: string) => void;
onClose: () => void;
queryRecords: QueryRecord[];
}
export interface QueryHistoryDialogState {
activeTab: number;
}
export class QueryHistoryDialog extends React.PureComponent<
QueryHistoryDialogProps,
QueryHistoryDialogState
> {
constructor(props: QueryHistoryDialogProps) {
super(props);
this.state = {
activeTab: 0,
};
}
render(): JSX.Element {
const { onClose, queryRecords, setQueryString } = this.props;
const { activeTab } = this.state;
const versions = queryRecords.map((record, index) => (
<Tab
id={index}
key={index}
title={record.version}
panel={<TextArea readOnly value={record.queryString} className={'text-area'} />}
panelClassName={'panel'}
/>
));
return (
<Dialog className="query-history-dialog" isOpen onClose={onClose} title="Query history">
<div className={Classes.DIALOG_BODY}>
<Tabs
animate
renderActiveTabPanelOnly
vertical
className={'tab-area'}
selectedTabId={activeTab}
onChange={(tab: number) => this.setState({ activeTab: tab })}
>
{versions}
<Tabs.Expander />
</Tabs>
</div>
<div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button text="Close" onClick={onClose} />
<Button
text="Open"
intent={Intent.PRIMARY}
onClick={() => setQueryString(queryRecords[activeTab].queryString)}
/>
</div>
</div>
</Dialog>
);
}
}

View File

@ -0,0 +1,32 @@
/*
* 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 { render } from '@testing-library/react';
import React from 'react';
import { QueryHistoryDialog } from './query-history-dialog';
describe('query plan dialog', () => {
it('matches snapshot', () => {
const queryPlanDialog = (
<QueryHistoryDialog setQueryString={() => null} queryRecords={[]} onClose={() => {}} />
);
render(queryPlanDialog);
expect(document.body.lastChild).toMatchSnapshot();
});
});

View File

@ -75,6 +75,16 @@ exports[`query plan dialog matches snapshot 1`] = `
Close Close
</span> </span>
</button> </button>
<button
class="bp3-button bp3-intent-primary"
type="button"
>
<span
class="bp3-button-text"
>
Open
</span>
</button>
</div> </div>
</div> </div>
</div> </div>

View File

@ -24,7 +24,12 @@ import { QueryPlanDialog } from './query-plan-dialog';
describe('query plan dialog', () => { describe('query plan dialog', () => {
it('matches snapshot', () => { it('matches snapshot', () => {
const queryPlanDialog = ( const queryPlanDialog = (
<QueryPlanDialog explainResult={'test'} explainError={undefined} onClose={() => {}} /> <QueryPlanDialog
setQueryString={() => null}
explainResult={'test'}
explainError={undefined}
onClose={() => {}}
/>
); );
render(queryPlanDialog); render(queryPlanDialog);
expect(document.body.lastChild).toMatchSnapshot(); expect(document.body.lastChild).toMatchSnapshot();

View File

@ -16,7 +16,15 @@
* limitations under the License. * limitations under the License.
*/ */
import { Button, Classes, Dialog, FormGroup, InputGroup, TextArea } from '@blueprintjs/core'; import {
Button,
Classes,
Dialog,
FormGroup,
InputGroup,
Intent,
TextArea,
} from '@blueprintjs/core';
import React from 'react'; import React from 'react';
import { BasicQueryExplanation, SemiJoinQueryExplanation } from '../../utils'; import { BasicQueryExplanation, SemiJoinQueryExplanation } from '../../utils';
@ -27,6 +35,7 @@ export interface QueryPlanDialogProps {
explainResult?: BasicQueryExplanation | SemiJoinQueryExplanation | string; explainResult?: BasicQueryExplanation | SemiJoinQueryExplanation | string;
explainError?: string; explainError?: string;
onClose: () => void; onClose: () => void;
setQueryString: (queryString: string) => void;
} }
export interface QueryPlanDialogState {} export interface QueryPlanDialogState {}
@ -40,8 +49,10 @@ export class QueryPlanDialog extends React.PureComponent<
this.state = {}; this.state = {};
} }
private queryString: string = '';
render(): JSX.Element { render(): JSX.Element {
const { explainResult, explainError, onClose } = this.props; const { explainResult, explainError, onClose, setQueryString } = this.props;
let content: JSX.Element; let content: JSX.Element;
@ -60,17 +71,15 @@ export class QueryPlanDialog extends React.PureComponent<
); );
} }
this.queryString = JSON.stringify(
(explainResult as BasicQueryExplanation).query[0],
undefined,
2,
);
content = ( content = (
<div className="one-query"> <div className="one-query">
<FormGroup label="Query"> <FormGroup label="Query">
<TextArea <TextArea readOnly value={this.queryString} />
readOnly
value={JSON.stringify(
(explainResult as BasicQueryExplanation).query[0],
undefined,
2,
)}
/>
</FormGroup> </FormGroup>
{signature} {signature}
</div> </div>
@ -136,6 +145,11 @@ export class QueryPlanDialog extends React.PureComponent<
<div className={Classes.DIALOG_FOOTER}> <div className={Classes.DIALOG_FOOTER}>
<div className={Classes.DIALOG_FOOTER_ACTIONS}> <div className={Classes.DIALOG_FOOTER_ACTIONS}>
<Button text="Close" onClick={onClose} /> <Button text="Close" onClick={onClose} />
<Button
text="Open"
intent={Intent.PRIMARY}
onClick={() => setQueryString(this.queryString)}
/>
</div> </div>
</div> </div>
</Dialog> </Dialog>

View File

@ -34,6 +34,8 @@ export const LocalStorageKeys = {
SERVERS_REFRESH_RATE: 'servers-refresh-rate' as 'servers-refresh-rate', SERVERS_REFRESH_RATE: 'servers-refresh-rate' as 'servers-refresh-rate',
SUPERVISORS_REFRESH_RATE: 'supervisors-refresh-rate' as 'supervisors-refresh-rate', SUPERVISORS_REFRESH_RATE: 'supervisors-refresh-rate' as 'supervisors-refresh-rate',
LOOKUPS_REFRESH_RATE: 'lookups-refresh-rate' as 'lookups-refresh-rate', LOOKUPS_REFRESH_RATE: 'lookups-refresh-rate' as 'lookups-refresh-rate',
QUERY_HISTORY: 'query-history' as 'query-history',
AUTO_RUN: 'auto-run' as 'auto-run',
}; };
export type LocalStorageKeys = typeof LocalStorageKeys[keyof typeof LocalStorageKeys]; export type LocalStorageKeys = typeof LocalStorageKeys[keyof typeof LocalStorageKeys];

View File

@ -5,8 +5,16 @@ exports[`sql view matches snapshot 1`] = `
className="query-view app-view" className="query-view app-view"
> >
<ColumnTree <ColumnTree
addAggregateColumn={[Function]}
addFunctionToGroupBy={[Function]}
addToGroupBy={[Function]}
clear={[Function]}
columnMetadataLoading={true} columnMetadataLoading={true}
defaultSchema="druid"
filterByRow={[Function]}
hasGroupBy={[Function]}
onQueryStringChange={[Function]} onQueryStringChange={[Function]}
queryAst={[Function]}
/> />
<t <t
customClassName="" customClassName=""
@ -32,17 +40,19 @@ exports[`sql view matches snapshot 1`] = `
className="control-bar" className="control-bar"
> >
<HotkeysTarget(RunButton) <HotkeysTarget(RunButton)
autoRun={true}
onEditContext={[Function]} onEditContext={[Function]}
onExplain={[Function]} onExplain={[Function]}
onHistory={[Function]}
onQueryContextChange={[Function]} onQueryContextChange={[Function]}
onRun={[Function]} onRun={[Function]}
queryContext={Object {}} queryContext={Object {}}
runeMode={false} runeMode={false}
setAutoRun={[Function]}
/> />
</div> </div>
</div> </div>
<QueryOutput <QueryOutput
disabled={true}
loading={false} loading={false}
runeMode={false} runeMode={false}
sqlExcludeColumn={[Function]} sqlExcludeColumn={[Function]}

View File

@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`number menu matches snapshot 1`] = `null`;

View File

@ -0,0 +1,39 @@
/*
* 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 { render } from '@testing-library/react';
import React from 'react';
import { NumberMenuItems } from './number-menu-items';
describe('number menu', () => {
it('matches snapshot', () => {
const numberMenu = (
<NumberMenuItems
addFunctionToGroupBy={() => null}
addToGroupBy={() => null}
addAggregateColumn={() => null}
filterByRow={() => null}
columnName={'text'}
/>
);
const { container } = render(numberMenu);
expect(container.firstChild).toMatchSnapshot();
});
});

View File

@ -0,0 +1,141 @@
/*
* 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 { MenuItem } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
import { Alias, FilterClause, SqlQuery, StringType } from 'druid-query-toolkit';
import { aliasFactory } from 'druid-query-toolkit/build/ast/sql-query/helpers';
import React from 'react';
import { RowFilter } from '../../../query-view';
export interface NumberMenuItemsProps {
addFunctionToGroupBy: (
functionName: string,
spacing: string[],
argumentsArray: (StringType | number)[],
run: boolean,
alias: Alias,
) => void;
addToGroupBy: (columnName: string, run: boolean) => void;
addAggregateColumn: (
columnName: string,
functionName: string,
run: boolean,
alias?: Alias,
distinct?: boolean,
filter?: FilterClause,
) => void;
filterByRow: (filters: RowFilter[], preferablyRun: boolean) => void;
queryAst?: SqlQuery;
columnName: string;
}
export class NumberMenuItems extends React.PureComponent<NumberMenuItemsProps> {
constructor(props: NumberMenuItemsProps, context: any) {
super(props, context);
}
renderFilterMenu(): JSX.Element {
const { columnName, filterByRow } = this.props;
return (
<MenuItem icon={IconNames.FILTER} text={`Filter`}>
<MenuItem
text={`"${columnName}" > 100`}
onClick={() => filterByRow([{ row: 100, header: columnName, operator: '>' }], false)}
/>
<MenuItem
text={`"${columnName}" <= 100`}
onClick={() => filterByRow([{ row: 100, header: columnName, operator: '<=' }], false)}
/>
</MenuItem>
);
}
renderGroupByMenu(): JSX.Element {
const { columnName, addFunctionToGroupBy, addToGroupBy } = this.props;
return (
<MenuItem icon={IconNames.GROUP_OBJECTS} text={`Group by`}>
<MenuItem text={`"${columnName}"`} onClick={() => addToGroupBy(columnName, true)} />
<MenuItem
text={`TRUNCATE("${columnName}", 1) AS "${columnName}_truncated"`}
onClick={() =>
addFunctionToGroupBy(
'TRUNCATE',
[' '],
[
new StringType({
spacing: [],
chars: columnName,
quote: '"',
}),
1,
],
true,
aliasFactory(`${columnName}_truncated`),
)
}
/>
</MenuItem>
);
}
renderAggregateMenu(): JSX.Element {
const { columnName, addAggregateColumn } = this.props;
return (
<MenuItem icon={IconNames.FUNCTION} text={`Aggregate`}>
<MenuItem
text={`SUM(${columnName}) AS "sum_${columnName}"`}
onClick={() =>
addAggregateColumn(columnName, 'SUM', true, aliasFactory(`sum_${columnName}`))
}
/>
<MenuItem
text={`MAX(${columnName}) AS "max_${columnName}"`}
onClick={() =>
addAggregateColumn(columnName, 'MAX', true, aliasFactory(`max_${columnName}`))
}
/>
<MenuItem
text={`MIN(${columnName}) AS "min_${columnName}"`}
onClick={() =>
addAggregateColumn(columnName, 'MIN', true, aliasFactory(`min_${columnName}`))
}
/>
</MenuItem>
);
}
render(): JSX.Element {
const { queryAst } = this.props;
let hasGroupBy;
if (queryAst) {
hasGroupBy = queryAst.groupByClause;
}
return (
<>
{queryAst && this.renderFilterMenu()}
{hasGroupBy && this.renderGroupByMenu()}
{hasGroupBy && this.renderAggregateMenu()}
</>
);
}
}

View File

@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`string menu matches snapshot 1`] = `null`;

View File

@ -0,0 +1,39 @@
/*
* 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 { render } from '@testing-library/react';
import React from 'react';
import { StringMenuItems } from './string-menu-items';
describe('string menu', () => {
it('matches snapshot', () => {
const stringMenu = (
<StringMenuItems
addFunctionToGroupBy={() => null}
addToGroupBy={() => null}
addAggregateColumn={() => null}
filterByRow={() => null}
columnName={'text'}
/>
);
const { container } = render(stringMenu);
expect(container.firstChild).toMatchSnapshot();
});
});

View File

@ -0,0 +1,161 @@
/*
* 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 { MenuItem } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
import {
Alias,
ComparisonExpression,
ComparisonExpressionRhs,
FilterClause,
RefExpression,
refExpressionFactory,
SqlQuery,
StringType,
WhereClause,
} from 'druid-query-toolkit';
import { aliasFactory, stringFactory } from 'druid-query-toolkit/build/ast/sql-query/helpers';
import React from 'react';
import { RowFilter } from '../../../query-view';
export interface StringMenuItemsProps {
addFunctionToGroupBy: (
functionName: string,
spacing: string[],
argumentsArray: (StringType | number)[],
run: boolean,
alias: Alias,
) => void;
addToGroupBy: (columnName: string, run: boolean) => void;
addAggregateColumn: (
columnName: string | RefExpression,
functionName: string,
run: boolean,
alias?: Alias,
distinct?: boolean,
filter?: FilterClause,
) => void;
filterByRow: (filters: RowFilter[], preferablyRun: boolean) => void;
queryAst?: SqlQuery;
columnName: string;
}
export class StringMenuItems extends React.PureComponent<StringMenuItemsProps> {
constructor(props: StringMenuItemsProps, context: any) {
super(props, context);
}
renderFilterMenu(): JSX.Element {
const { columnName, filterByRow } = this.props;
return (
<MenuItem icon={IconNames.FILTER} text={`Filter`}>
<MenuItem
text={`"${columnName}" = 'xxx'`}
onClick={() => filterByRow([{ row: 'xxx', header: columnName, operator: '=' }], false)}
/>
<MenuItem
text={`"${columnName}" LIKE '%xxx%'`}
onClick={() => filterByRow([{ row: 'xxx', header: columnName, operator: 'LIKE' }], false)}
/>
</MenuItem>
);
}
renderGroupByMenu(): JSX.Element {
const { columnName, addFunctionToGroupBy, addToGroupBy } = this.props;
return (
<MenuItem icon={IconNames.GROUP_OBJECTS} text={`Group by`}>
<MenuItem text={`"${columnName}"`} onClick={() => addToGroupBy(columnName, true)} />
<MenuItem
text={`SUBSTRING("${columnName}", 1, 2) AS "${columnName}_substring"`}
onClick={() =>
addFunctionToGroupBy(
'SUBSTRING',
[' ', ' '],
[stringFactory(columnName, `"`), 1, 2],
true,
aliasFactory(`${columnName}_substring`),
)
}
/>
</MenuItem>
);
}
renderAggregateMenu(): JSX.Element {
const { columnName, addAggregateColumn } = this.props;
return (
<MenuItem icon={IconNames.FUNCTION} text={`Aggregate`}>
<MenuItem
text={`COUNT(DISTINCT "${columnName}") AS "dist_${columnName}"`}
onClick={() =>
addAggregateColumn(columnName, 'COUNT', true, aliasFactory(`dist_${columnName}`), true)
}
/>
<MenuItem
text={`COUNT(*) FILTER (WHERE "${columnName}" = 'xxx') AS ${columnName}_filtered_count `}
onClick={() =>
addAggregateColumn(
refExpressionFactory('*'),
'COUNT',
false,
aliasFactory(`${columnName}_filtered_count`),
false,
new FilterClause({
keyword: 'FILTER',
spacing: [' '],
ex: new WhereClause({
keyword: 'WHERE',
spacing: [' '],
filter: new ComparisonExpression({
parens: [],
ex: stringFactory(columnName, '"'),
rhs: new ComparisonExpressionRhs({
parens: [],
op: '=',
rhs: stringFactory('xxx', `'`),
spacing: [' ', ' '],
}),
}),
}),
}),
)
}
/>
</MenuItem>
);
}
render(): JSX.Element {
const { queryAst } = this.props;
let hasGroupBy;
if (queryAst) {
hasGroupBy = queryAst.groupByClause;
}
return (
<>
{queryAst && this.renderFilterMenu()}
{hasGroupBy && this.renderGroupByMenu()}
{hasGroupBy && this.renderAggregateMenu()}
</>
);
}
}

View File

@ -0,0 +1,3 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`time menu matches snapshot 1`] = `null`;

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.
*/
import { render } from '@testing-library/react';
import React from 'react';
import { TimeMenuItems } from './time-menu-items';
describe('time menu', () => {
it('matches snapshot', () => {
const timeMenu = (
<TimeMenuItems
clear={() => null}
addFunctionToGroupBy={() => null}
addToGroupBy={() => null}
addAggregateColumn={() => null}
filterByRow={() => null}
columnName={'text'}
/>
);
const { container } = render(timeMenu);
expect(container.firstChild).toMatchSnapshot();
});
});

View File

@ -0,0 +1,380 @@
/*
* 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 { MenuDivider, MenuItem } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
import {
AdditiveExpression,
Alias,
FilterClause,
SqlQuery,
StringType,
timestampFactory,
} from 'druid-query-toolkit';
import {
aliasFactory,
intervalFactory,
refExpressionFactory,
stringFactory,
} from 'druid-query-toolkit/build/ast/sql-query/helpers';
import React from 'react';
import { RowFilter } from '../../../query-view';
export interface TimeMenuItemsProps {
addFunctionToGroupBy: (
functionName: string,
spacing: string[],
argumentsArray: (StringType | number)[],
run: boolean,
alias: Alias,
) => void;
addToGroupBy: (columnName: string, run: boolean) => void;
addAggregateColumn: (
columnName: string,
functionName: string,
run: boolean,
alias?: Alias,
distinct?: boolean,
filter?: FilterClause,
) => void;
filterByRow: (filters: RowFilter[], preferablyRun: boolean) => void;
queryAst?: SqlQuery;
columnName: string;
clear: () => void;
}
export class TimeMenuItems extends React.PureComponent<TimeMenuItemsProps> {
constructor(props: TimeMenuItemsProps, context: any) {
super(props, context);
}
formatTime(timePart: number): string {
if (timePart % 10 > 0) {
return String(timePart);
} else return '0' + String(timePart);
}
getNextMonth(month: number, year: number): { month: string; year: number } {
if (month === 12) {
return { month: '01', year: year + 1 };
}
return { month: this.formatTime(month + 1), year: year };
}
getNextDay(
day: number,
month: number,
year: number,
): { day: string; month: string; year: number } {
if (
month === 1 ||
month === 3 ||
month === 5 ||
month === 7 ||
month === 8 ||
month === 10 ||
month === 12
) {
if (day === 31) {
const next = this.getNextMonth(month, year);
return { day: '01', month: next.month, year: next.year };
}
} else if (month === 4 || month === 6 || month === 9 || month === 11) {
if (day === 30) {
const next = this.getNextMonth(month, year);
return { day: '01', month: next.month, year: next.year };
}
} else if (month === 2) {
if ((day === 29 && year % 4 === 0) || (day === 28 && year % 4)) {
const next = this.getNextMonth(month, year);
return { day: '01', month: next.month, year: next.year };
}
}
return { day: this.formatTime(day + 1), month: this.formatTime(month), year: year };
}
getNextHour(
hour: number,
day: number,
month: number,
year: number,
): { hour: string; day: string; month: string; year: number } {
if (hour === 23) {
const next = this.getNextDay(day, month, year);
return { hour: '00', day: next.day, month: next.month, year: next.year };
}
return {
hour: this.formatTime(hour + 1),
day: this.formatTime(day),
month: this.formatTime(month),
year: year,
};
}
renderFilterMenu(): JSX.Element {
const { columnName, filterByRow, clear } = this.props;
const date = new Date();
const year = date.getFullYear();
const month = date.getMonth();
const day = date.getDay();
const hour = date.getHours();
return (
<MenuItem icon={IconNames.FILTER} text={`Filter`}>
<MenuItem
text={`Latest hour`}
onClick={() => {
const additiveExpression = new AdditiveExpression({
parens: [],
op: ['-'],
ex: [refExpressionFactory('CURRENT_TIMESTAMP'), intervalFactory('HOUR', '1')],
spacing: [' ', ' '],
});
clear();
filterByRow([{ row: additiveExpression, header: columnName, operator: '>=' }], true);
}}
/>
<MenuItem
text={`Latest day`}
onClick={() => {
const additiveExpression = new AdditiveExpression({
parens: [],
op: ['-'],
ex: [refExpressionFactory('CURRENT_TIMESTAMP'), intervalFactory('DAY', '1')],
spacing: [' ', ' '],
});
clear();
filterByRow([{ row: additiveExpression, header: columnName, operator: '>=' }], true);
}}
/>
<MenuItem
text={`Latest week`}
onClick={() => {
const additiveExpression = new AdditiveExpression({
parens: [],
op: ['-'],
ex: [refExpressionFactory('CURRENT_TIMESTAMP'), intervalFactory('DAY', '7')],
spacing: [' ', ' '],
});
clear();
filterByRow([{ row: additiveExpression, header: columnName, operator: '>=' }], true);
}}
/>
<MenuItem
text={`Latest month`}
onClick={() => {
const additiveExpression = new AdditiveExpression({
parens: [],
op: ['-'],
ex: [refExpressionFactory('CURRENT_TIMESTAMP'), intervalFactory('MONTH', '1')],
spacing: [' ', ' '],
});
clear();
filterByRow([{ row: additiveExpression, header: columnName, operator: '>=' }], true);
}}
/>
<MenuItem
text={`Latest year`}
onClick={() => {
const additiveExpression = new AdditiveExpression({
parens: [],
op: ['-'],
ex: [refExpressionFactory('CURRENT_TIMESTAMP'), intervalFactory('YEAR', '1')],
spacing: [' ', ' '],
});
clear();
filterByRow([{ row: additiveExpression, header: columnName, operator: '>=' }], true);
}}
/>
<MenuDivider />
<MenuItem
text={`Current hour`}
onClick={() => {
const next = this.getNextHour(hour, day, month, year);
clear();
filterByRow(
[
{
row: stringFactory(columnName, `"`),
header: timestampFactory(
`${year}-${month}-${day} ${this.formatTime(hour)}:00:00`,
),
operator: '<=',
},
{
row: timestampFactory(
`${next.year}-${next.month}-${next.day} ${next.hour}:00:00`,
),
header: columnName,
operator: '<',
},
],
true,
);
}}
/>
<MenuItem
text={`Current day`}
onClick={() => {
const next = this.getNextDay(day, month, year);
clear();
filterByRow(
[
{
row: stringFactory(columnName, `"`),
header: timestampFactory(`${year}-${month}-${day} 00:00:00`),
operator: '<=',
},
{
row: timestampFactory(`${next.year}-${next.month}-${next.day} 00:00:00`),
header: columnName,
operator: '<',
},
],
true,
);
}}
/>
<MenuItem
text={`Current month`}
onClick={() => {
const next = this.getNextMonth(month, year);
clear();
filterByRow(
[
{
row: stringFactory(columnName, `"`),
header: timestampFactory(`${year}-${month}-01 00:00:00`),
operator: '<=',
},
{
row: timestampFactory(`${next.year}-${next.month}-01 00:00:00`),
header: columnName,
operator: '<',
},
],
true,
);
}}
/>
<MenuItem
text={`Current year`}
onClick={() => {
clear();
filterByRow(
[
{
row: stringFactory(columnName, `"`),
header: timestampFactory(`${year}-01-01 00:00:00`),
operator: '<=',
},
{
row: timestampFactory(`${Number(year) + 1}-01-01 00:00:00`),
header: columnName,
operator: '<',
},
],
true,
);
}}
/>
</MenuItem>
);
}
renderGroupByMenu(): JSX.Element {
const { columnName, addFunctionToGroupBy } = this.props;
return (
<MenuItem icon={IconNames.GROUP_OBJECTS} text={`Group by`}>
<MenuItem
text={`TIME_FLOOR("${columnName}", 'PT1H') AS "${columnName}_time_floor"`}
onClick={() =>
addFunctionToGroupBy(
'TIME_FLOOR',
[' '],
[stringFactory(columnName, `"`), stringFactory('PT1H', `'`)],
true,
aliasFactory(`${columnName}_time_floor`),
)
}
/>
<MenuItem
text={`TIME_FLOOR("${columnName}", 'P1D') AS "${columnName}_time_floor"`}
onClick={() =>
addFunctionToGroupBy(
'TIME_FLOOR',
[' '],
[stringFactory(columnName, `"`), stringFactory('P1D', `'`)],
true,
aliasFactory(`${columnName}_time_floor`),
)
}
/>
<MenuItem
text={`TIME_FLOOR("${columnName}", 'P7D') AS "${columnName}_time_floor"`}
onClick={() =>
addFunctionToGroupBy(
'TIME_FLOOR',
[' '],
[stringFactory(columnName, `"`), stringFactory('P7D', `'`)],
true,
aliasFactory(`${columnName}_time_floor`),
)
}
/>
</MenuItem>
);
}
renderAggregateMenu(): JSX.Element {
const { columnName, addAggregateColumn } = this.props;
return (
<MenuItem icon={IconNames.FUNCTION} text={`Aggregate`}>
<MenuItem
text={`MAX("${columnName}") AS "max_${columnName}"`}
onClick={() =>
addAggregateColumn(columnName, 'MAX', true, aliasFactory(`max_${columnName}`))
}
/>
<MenuItem
text={`MIN("${columnName}") AS "min_${columnName}"`}
onClick={() =>
addAggregateColumn(columnName, 'MIN', true, aliasFactory(`min_${columnName}`))
}
/>
</MenuItem>
);
}
render(): JSX.Element {
const { queryAst } = this.props;
let hasGroupBy;
if (queryAst) {
hasGroupBy = queryAst.groupByClause;
}
return (
<>
{queryAst && this.renderFilterMenu()}
{hasGroupBy && this.renderGroupByMenu()}
{hasGroupBy && this.renderAggregateMenu()}
</>
);
}
}

View File

@ -27,6 +27,13 @@ describe('column tree', () => {
it('matches snapshot', () => { it('matches snapshot', () => {
const columnTree = ( const columnTree = (
<ColumnTree <ColumnTree
queryAst={() => undefined}
hasGroupBy={() => false}
clear={() => null}
addFunctionToGroupBy={() => null}
filterByRow={() => null}
addAggregateColumn={() => null}
addToGroupBy={() => null}
columnMetadataLoading={false} columnMetadataLoading={false}
columnMetadata={ columnMetadata={
[ [

View File

@ -16,14 +16,29 @@
* limitations under the License. * limitations under the License.
*/ */
import { HTMLSelect, IconName, ITreeNode, Menu, MenuItem, Position, Tree } from '@blueprintjs/core'; import {
import { Popover } from '@blueprintjs/core/lib/cjs'; HTMLSelect,
IconName,
ITreeNode,
Menu,
MenuItem,
Popover,
Position,
Tree,
} from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons'; import { IconNames } from '@blueprintjs/icons';
import { Alias, FilterClause, RefExpression, SqlQuery, StringType } from 'druid-query-toolkit';
import React, { ChangeEvent } from 'react'; import React, { ChangeEvent } from 'react';
import { Loader } from '../../../components'; import { Loader } from '../../../components';
import { Deferred } from '../../../components/deferred/deferred';
import { copyAndAlert, escapeSqlIdentifier, groupBy } from '../../../utils'; import { copyAndAlert, escapeSqlIdentifier, groupBy } from '../../../utils';
import { ColumnMetadata } from '../../../utils/column-metadata'; import { ColumnMetadata } from '../../../utils/column-metadata';
import { RowFilter } from '../query-view';
import { NumberMenuItems } from './column-tree-menu/number-menu-items/number-menu-items';
import { StringMenuItems } from './column-tree-menu/string-menu-items/string-menu-items';
import { TimeMenuItems } from './column-tree-menu/time-menu-items/time-menu-items';
import './column-tree.scss'; import './column-tree.scss';
@ -88,10 +103,31 @@ export interface ColumnTreeProps {
onQueryStringChange: (queryString: string) => void; onQueryStringChange: (queryString: string) => void;
defaultSchema?: string; defaultSchema?: string;
defaultTable?: string; defaultTable?: string;
addFunctionToGroupBy: (
functionName: string,
spacing: string[],
argumentsArray: (StringType | number)[],
run: boolean,
alias: Alias,
) => void;
addToGroupBy: (columnName: string, run: boolean) => void;
addAggregateColumn: (
columnName: string | RefExpression,
functionName: string,
run: boolean,
alias?: Alias,
distinct?: boolean,
filter?: FilterClause,
) => void;
filterByRow: (filters: RowFilter[], preferablyRun: boolean) => void;
hasGroupBy: () => boolean;
queryAst: () => SqlQuery | undefined;
clear: () => void;
} }
export interface ColumnTreeState { export interface ColumnTreeState {
prevColumnMetadata?: ColumnMetadata[]; prevColumnMetadata?: ColumnMetadata[];
prevGroupByStatus?: boolean;
columnTree?: ITreeNode[]; columnTree?: ITreeNode[];
selectedTreeIndex: number; selectedTreeIndex: number;
expandedNode: number; expandedNode: number;
@ -100,7 +136,6 @@ export interface ColumnTreeState {
export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeState> { export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeState> {
static getDerivedStateFromProps(props: ColumnTreeProps, state: ColumnTreeState) { static getDerivedStateFromProps(props: ColumnTreeProps, state: ColumnTreeState) {
const { columnMetadata, defaultSchema, defaultTable } = props; const { columnMetadata, defaultSchema, defaultTable } = props;
if (columnMetadata && columnMetadata !== state.prevColumnMetadata) { if (columnMetadata && columnMetadata !== state.prevColumnMetadata) {
const columnTree = groupBy( const columnTree = groupBy(
columnMetadata, columnMetadata,
@ -122,7 +157,7 @@ export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeS
<Menu> <Menu>
<MenuItem <MenuItem
icon={IconNames.FULLSCREEN} icon={IconNames.FULLSCREEN}
text={`Show: ${table}`} text={`Select ... from ${table}`}
onClick={() => { onClick={() => {
handleTableClick( handleTableClick(
schema, schema,
@ -160,35 +195,72 @@ export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeS
<Popover <Popover
boundary={'window'} boundary={'window'}
position={Position.RIGHT} position={Position.RIGHT}
autoFocus={false}
targetClassName={'bp3-popover-open'}
content={ content={
<Menu> <Deferred
<MenuItem content={() => (
icon={IconNames.FULLSCREEN} <Menu>
text={`Show: ${columnData.COLUMN_NAME}`} <MenuItem
onClick={() => { icon={IconNames.FULLSCREEN}
handleColumnClick( text={`Show: ${columnData.COLUMN_NAME}`}
schema, onClick={() => {
table, handleColumnClick(
{ schema,
id: columnData.COLUMN_NAME, table,
icon: ColumnTree.dataTypeToIcon(columnData.DATA_TYPE), {
label: columnData.COLUMN_NAME, id: columnData.COLUMN_NAME,
}, icon: ColumnTree.dataTypeToIcon(columnData.DATA_TYPE),
props.onQueryStringChange, label: columnData.COLUMN_NAME,
); },
}} props.onQueryStringChange,
/> );
<MenuItem }}
icon={IconNames.CLIPBOARD} />
text={`Copy: ${columnData.COLUMN_NAME}`} {columnData.DATA_TYPE === 'BIGINT' && (
onClick={() => { <NumberMenuItems
copyAndAlert( addFunctionToGroupBy={props.addFunctionToGroupBy}
columnData.COLUMN_NAME, addToGroupBy={props.addToGroupBy}
`${columnData.COLUMN_NAME} query copied to clipboard`, addAggregateColumn={props.addAggregateColumn}
); filterByRow={props.filterByRow}
}} columnName={columnData.COLUMN_NAME}
/> queryAst={props.queryAst()}
</Menu> />
)}
{columnData.DATA_TYPE === 'VARCHAR' && (
<StringMenuItems
addFunctionToGroupBy={props.addFunctionToGroupBy}
addToGroupBy={props.addToGroupBy}
addAggregateColumn={props.addAggregateColumn}
filterByRow={props.filterByRow}
columnName={columnData.COLUMN_NAME}
queryAst={props.queryAst()}
/>
)}
{columnData.DATA_TYPE === 'TIMESTAMP' && (
<TimeMenuItems
clear={props.clear}
addFunctionToGroupBy={props.addFunctionToGroupBy}
addToGroupBy={props.addToGroupBy}
addAggregateColumn={props.addAggregateColumn}
filterByRow={props.filterByRow}
columnName={columnData.COLUMN_NAME}
queryAst={props.queryAst()}
/>
)}
<MenuItem
icon={IconNames.CLIPBOARD}
text={`Copy: ${columnData.COLUMN_NAME}`}
onClick={() => {
copyAndAlert(
columnData.COLUMN_NAME,
`${columnData.COLUMN_NAME} query copied to clipboard`,
);
}}
/>
</Menu>
)}
/>
} }
> >
<div>{columnData.COLUMN_NAME}</div> <div>{columnData.COLUMN_NAME}</div>
@ -203,29 +275,28 @@ export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeS
let selectedTreeIndex = -1; let selectedTreeIndex = -1;
let expandedNode = -1; let expandedNode = -1;
if (defaultSchema && columnTree) { if (defaultSchema && columnTree) {
selectedTreeIndex = columnTree selectedTreeIndex = columnTree.findIndex(x => {
.map(function(x) { return x.id === defaultSchema;
return x.id; });
})
.indexOf(defaultSchema);
} }
if (selectedTreeIndex > -1) { if (selectedTreeIndex > -1) {
const treeNodes = columnTree[selectedTreeIndex].childNodes; const treeNodes = columnTree[selectedTreeIndex].childNodes;
if (treeNodes) { if (treeNodes) {
if (defaultTable) { if (defaultTable) {
expandedNode = treeNodes expandedNode = treeNodes.findIndex(node => {
.map(node => { return node.id === defaultTable;
return node.id; });
})
.indexOf(defaultTable);
} }
} }
} }
return { return {
prevColumnMetadata: columnMetadata, prevColumnMetadata: columnMetadata,
columnTree, columnTree,
selectedTreeIndex, selectedTreeIndex,
expandedNode, expandedNode,
prevGroupByStatus: props.hasGroupBy,
}; };
} }
return null; return null;

View File

@ -29,7 +29,6 @@ describe('query output', () => {
sqlOrderBy={() => null} sqlOrderBy={() => null}
sqlFilterRow={() => null} sqlFilterRow={() => null}
sqlExcludeColumn={() => null} sqlExcludeColumn={() => null}
disabled={false}
loading={false} loading={false}
error="lol" error="lol"
/> />

View File

@ -18,7 +18,7 @@
import { Menu, MenuItem, Popover } from '@blueprintjs/core'; import { Menu, MenuItem, Popover } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons'; import { IconNames } from '@blueprintjs/icons';
import { HeaderRows } from 'druid-query-toolkit'; import { HeaderRows, SqlQuery } from 'druid-query-toolkit';
import { import {
basicIdentifierEscape, basicIdentifierEscape,
basicLiteralEscape, basicLiteralEscape,
@ -28,34 +28,40 @@ import ReactTable from 'react-table';
import { copyAndAlert } from '../../../utils'; import { copyAndAlert } from '../../../utils';
import { BasicAction, basicActionsToMenu } from '../../../utils/basic-action'; import { BasicAction, basicActionsToMenu } from '../../../utils/basic-action';
import { RowFilter } from '../query-view';
import './query-output.scss'; import './query-output.scss';
export interface QueryOutputProps { export interface QueryOutputProps {
aggregateColumns?: string[];
disabled: boolean;
loading: boolean; loading: boolean;
sqlFilterRow: (row: string, header: string, operator: '=' | '!=') => void; sqlFilterRow: (filters: RowFilter[], run: boolean) => void;
sqlExcludeColumn: (header: string) => void; sqlExcludeColumn: (header: string, run: boolean) => void;
sqlOrderBy: (header: string, direction: 'ASC' | 'DESC') => void; sqlOrderBy: (header: string, direction: 'ASC' | 'DESC', run: boolean) => void;
sorted?: { id: string; desc: boolean }[]; queryResult?: HeaderRows;
result?: HeaderRows; parsedQuery?: SqlQuery;
error?: string; error?: string;
runeMode: boolean; runeMode: boolean;
} }
export class QueryOutput extends React.PureComponent<QueryOutputProps> { export class QueryOutput extends React.PureComponent<QueryOutputProps> {
render(): JSX.Element { render(): JSX.Element {
const { result, loading, error } = this.props; const { queryResult, parsedQuery, loading, error } = this.props;
let aggregateColumns: string[] | undefined;
if (parsedQuery) {
aggregateColumns = parsedQuery.getAggregateColumns();
}
return ( return (
<div className="query-output"> <div className="query-output">
<ReactTable <ReactTable
data={result ? result.rows : []} data={queryResult ? queryResult.rows : []}
loading={loading} loading={loading}
noDataText={!loading && result && !result.rows.length ? 'No results' : error || ''} noDataText={
!loading && queryResult && !queryResult.rows.length ? 'No queryResults' : error || ''
}
sortable={false} sortable={false}
columns={(result ? result.header : []).map((h: any, i) => { columns={(queryResult ? queryResult.header : []).map((h: any, i) => {
return { return {
Header: () => { Header: () => {
return ( return (
@ -80,11 +86,8 @@ export class QueryOutput extends React.PureComponent<QueryOutputProps> {
} }
return value; return value;
}, },
className: this.props.aggregateColumns className:
? this.props.aggregateColumns.indexOf(h) > -1 aggregateColumns && aggregateColumns.includes(h) ? 'aggregate-column' : undefined,
? 'aggregate-column'
: undefined
: undefined,
}; };
})} })}
/> />
@ -92,9 +95,45 @@ export class QueryOutput extends React.PureComponent<QueryOutputProps> {
); );
} }
getHeaderActions(h: string) { getHeaderActions(h: string) {
const { disabled, sqlExcludeColumn, sqlOrderBy, runeMode } = this.props; const { parsedQuery, sqlExcludeColumn, sqlOrderBy, runeMode } = this.props;
let actionsMenu; let actionsMenu;
if (disabled) { if (parsedQuery) {
const sorted = parsedQuery.getSorted();
const basicActions: BasicAction[] = [];
if (sorted) {
sorted.map(sorted => {
if (sorted.id === h) {
basicActions.push({
icon: sorted.desc ? IconNames.SORT_ASC : IconNames.SORT_DESC,
title: `Order by: ${h} ${sorted.desc ? 'ASC' : 'DESC'}`,
onAction: () => sqlOrderBy(h, sorted.desc ? 'ASC' : 'DESC', true),
});
}
});
}
if (!basicActions.length) {
basicActions.push(
{
icon: IconNames.SORT_ASC,
title: `Order by: ${h} ASC`,
onAction: () => sqlOrderBy(h, 'ASC', true),
},
{
icon: IconNames.SORT_DESC,
title: `Order by: ${h} DESC`,
onAction: () => sqlOrderBy(h, 'DESC', true),
},
);
}
basicActions.push({
icon: IconNames.CROSS,
title: `Remove: ${h}`,
onAction: () => sqlExcludeColumn(h, true),
});
actionsMenu = basicActionsToMenu(basicActions);
} else {
actionsMenu = ( actionsMenu = (
<Menu> <Menu>
<MenuItem <MenuItem
@ -130,48 +169,28 @@ export class QueryOutput extends React.PureComponent<QueryOutputProps> {
)} )}
</Menu> </Menu>
); );
} else {
const { sorted } = this.props;
const basicActions: BasicAction[] = [];
if (sorted) {
sorted.map(sorted => {
if (sorted.id === h) {
basicActions.push({
icon: sorted.desc ? IconNames.SORT_ASC : IconNames.SORT_DESC,
title: `Order by: ${h} ${sorted.desc ? 'ASC' : 'DESC'}`,
onAction: () => sqlOrderBy(h, sorted.desc ? 'ASC' : 'DESC'),
});
}
});
}
if (!basicActions.length) {
basicActions.push(
{
icon: IconNames.SORT_ASC,
title: `Order by: ${h} ASC`,
onAction: () => sqlOrderBy(h, 'ASC'),
},
{
icon: IconNames.SORT_DESC,
title: `Order by: ${h} DESC`,
onAction: () => sqlOrderBy(h, 'DESC'),
},
);
}
basicActions.push({
icon: IconNames.CROSS,
title: `Remove: ${h}`,
onAction: () => sqlExcludeColumn(h),
});
actionsMenu = basicActionsToMenu(basicActions);
} }
return actionsMenu ? actionsMenu : undefined; return actionsMenu ? actionsMenu : undefined;
} }
getRowActions(row: string, header: string) { getRowActions(row: string, header: string) {
const { disabled, sqlFilterRow, runeMode } = this.props; const { parsedQuery, sqlFilterRow, runeMode } = this.props;
let actionsMenu; let actionsMenu;
if (disabled) { if (parsedQuery) {
actionsMenu = basicActionsToMenu([
{
icon: IconNames.FILTER_KEEP,
title: `Filter by: ${header} = ${row}`,
onAction: () => sqlFilterRow([{ row, header, operator: '=' }], true),
},
{
icon: IconNames.FILTER_REMOVE,
title: `Filter by: ${header} != ${row}`,
onAction: () => sqlFilterRow([{ row, header, operator: '!=' }], true),
},
]);
} else {
actionsMenu = ( actionsMenu = (
<Menu> <Menu>
<MenuItem <MenuItem
@ -209,38 +228,29 @@ export class QueryOutput extends React.PureComponent<QueryOutputProps> {
)} )}
</Menu> </Menu>
); );
} else {
actionsMenu = basicActionsToMenu([
{
icon: IconNames.FILTER_KEEP,
title: `Filter by: ${header} = ${row}`,
onAction: () => sqlFilterRow(row, header, '='),
},
{
icon: IconNames.FILTER_REMOVE,
title: `Filter by: ${header} != ${row}`,
onAction: () => sqlFilterRow(row, header, '!='),
},
]);
} }
return actionsMenu ? actionsMenu : undefined; return actionsMenu ? actionsMenu : undefined;
} }
getHeaderClassName(h: string) { getHeaderClassName(h: string) {
const { sorted, aggregateColumns } = this.props; const { parsedQuery } = this.props;
const className = []; const className = [];
className.push( if (parsedQuery) {
sorted const sorted = parsedQuery.getSorted();
? sorted.map(sorted => { if (sorted) {
className.push(
sorted.map(sorted => {
if (sorted.id === h) { if (sorted.id === h) {
return sorted.desc ? '-sort-desc' : '-sort-asc'; return sorted.desc ? '-sort-desc' : '-sort-asc';
} }
return ''; return '';
})[0] })[0],
: undefined, );
); }
if (aggregateColumns) {
if (aggregateColumns.includes(h)) { const aggregateColumns = parsedQuery.getAggregateColumns();
if (aggregateColumns && aggregateColumns.includes(h)) {
className.push('aggregate-header'); className.push('aggregate-header');
} }
} }

View File

@ -20,20 +20,31 @@ import { Intent } from '@blueprintjs/core';
import axios from 'axios'; import axios from 'axios';
import classNames from 'classnames'; import classNames from 'classnames';
import { import {
AdditiveExpression,
Alias,
FilterClause,
HeaderRows, HeaderRows,
isFirstRowHeader, isFirstRowHeader,
normalizeQueryResult, normalizeQueryResult,
RefExpression,
shouldIncludeTimestamp, shouldIncludeTimestamp,
sqlParserFactory, sqlParserFactory,
SqlQuery, SqlQuery,
StringType,
Timestamp,
} from 'druid-query-toolkit'; } from 'druid-query-toolkit';
import Hjson from 'hjson'; import Hjson from 'hjson';
import memoizeOne from 'memoize-one';
import React from 'react'; import React from 'react';
import SplitterLayout from 'react-splitter-layout'; import SplitterLayout from 'react-splitter-layout';
import { SQL_FUNCTIONS, SyntaxDescription } from '../../../lib/sql-function-doc'; import { SQL_FUNCTIONS, SyntaxDescription } from '../../../lib/sql-function-doc';
import { QueryPlanDialog } from '../../dialogs'; import { QueryPlanDialog } from '../../dialogs';
import { EditContextDialog } from '../../dialogs/edit-context-dialog/edit-context-dialog'; import { EditContextDialog } from '../../dialogs/edit-context-dialog/edit-context-dialog';
import {
QueryHistoryDialog,
QueryRecord,
} from '../../dialogs/query-history-dialog/query-history-dialog';
import { AppToaster } from '../../singletons/toaster'; import { AppToaster } from '../../singletons/toaster';
import { import {
BasicQueryExplanation, BasicQueryExplanation,
@ -58,12 +69,20 @@ import { RunButton } from './run-button/run-button';
import './query-view.scss'; import './query-view.scss';
const parser = sqlParserFactory( const parserRaw = sqlParserFactory(
SQL_FUNCTIONS.map((sql_function: SyntaxDescription) => { SQL_FUNCTIONS.map((sql_function: SyntaxDescription) => {
return sql_function.syntax.substr(0, sql_function.syntax.indexOf('(')); return sql_function.syntax.substr(0, sql_function.syntax.indexOf('('));
}), }),
); );
const parser = memoizeOne((sql: string) => {
try {
return parserRaw(sql);
} catch {
return;
}
});
interface QueryWithContext { interface QueryWithContext {
queryString: string; queryString: string;
queryContext: QueryContext; queryContext: QueryContext;
@ -74,8 +93,15 @@ export interface QueryViewProps {
initQuery: string | undefined; initQuery: string | undefined;
} }
export interface RowFilter {
row: string | number | AdditiveExpression | Timestamp | StringType;
header: string | Timestamp | StringType;
operator: '!=' | '=' | '>' | '<' | 'like' | '>=' | '<=' | 'LIKE';
}
export interface QueryViewState { export interface QueryViewState {
queryString: string; queryString: string;
queryAst: SqlQuery;
queryContext: QueryContext; queryContext: QueryContext;
columnMetadataLoading: boolean; columnMetadataLoading: boolean;
@ -83,8 +109,7 @@ export interface QueryViewState {
columnMetadataError?: string; columnMetadataError?: string;
loading: boolean; loading: boolean;
result?: HeaderRows; result?: QueryResult;
queryExtraInfo?: QueryExtraInfoData;
error?: string; error?: string;
explainDialogOpen: boolean; explainDialogOpen: boolean;
@ -94,9 +119,12 @@ export interface QueryViewState {
defaultSchema?: string; defaultSchema?: string;
defaultTable?: string; defaultTable?: string;
ast?: SqlQuery;
editContextDialogOpen: boolean; editContextDialogOpen: boolean;
historyDialogOpen: boolean;
queryHistory: QueryRecord[];
autoRun: boolean;
} }
interface QueryResult { interface QueryResult {
@ -149,8 +177,18 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
constructor(props: QueryViewProps, context: any) { constructor(props: QueryViewProps, context: any) {
super(props, context); super(props, context);
let queryString: string | undefined;
if (props.initQuery) {
queryString = props.initQuery;
} else if (localStorageGet(LocalStorageKeys.QUERY_KEY)) {
queryString = localStorageGet(LocalStorageKeys.QUERY_KEY);
}
const queryAst = queryString ? parser(queryString) : undefined;
this.state = { this.state = {
queryString: props.initQuery || localStorageGet(LocalStorageKeys.QUERY_KEY) || '', queryString: queryString ? queryString : '',
queryAst,
queryContext: {}, queryContext: {},
columnMetadataLoading: false, columnMetadataLoading: false,
@ -161,8 +199,11 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
loadingExplain: false, loadingExplain: false,
editContextDialogOpen: false, editContextDialogOpen: false,
}; historyDialogOpen: false,
queryHistory: [],
autoRun: true,
};
this.metadataQueryManager = new QueryManager({ this.metadataQueryManager = new QueryManager({
processQuery: async () => { processQuery: async () => {
return await queryDruidSql<ColumnMetadata>({ return await queryDruidSql<ColumnMetadata>({
@ -199,7 +240,6 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
if (!(ast instanceof SqlQuery)) { if (!(ast instanceof SqlQuery)) {
ast = undefined; ast = undefined;
} }
if (QueryView.isJsonLike(queryString)) { if (QueryView.isJsonLike(queryString)) {
jsonQuery = Hjson.parse(queryString); jsonQuery = Hjson.parse(queryString);
} else { } else {
@ -250,7 +290,6 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
shouldIncludeTimestamp(jsonQuery), shouldIncludeTimestamp(jsonQuery),
isFirstRowHeader(jsonQuery), isFirstRowHeader(jsonQuery),
); );
return { return {
queryResult, queryResult,
queryExtraInfo: { queryExtraInfo: {
@ -266,11 +305,9 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
}, },
onStateChange: ({ result, loading, error }) => { onStateChange: ({ result, loading, error }) => {
this.setState({ this.setState({
result: result ? result.queryResult : undefined, result,
queryExtraInfo: result ? result.queryExtraInfo : undefined,
loading, loading,
error, error,
ast: result ? result.parsedQuery : undefined,
}); });
}, },
}); });
@ -300,6 +337,28 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
componentDidMount(): void { componentDidMount(): void {
this.metadataQueryManager.runQuery(null); this.metadataQueryManager.runQuery(null);
const localStorageQueryHistoy = localStorageGet(LocalStorageKeys.QUERY_HISTORY);
let queryHistory;
if (localStorageQueryHistoy) {
try {
queryHistory = JSON.parse(localStorageQueryHistoy);
} catch {}
if (queryHistory) {
this.setState({ queryHistory });
}
}
const localStorageAutoRun = localStorageGet(LocalStorageKeys.AUTO_RUN);
let autoRun;
if (localStorageAutoRun) {
try {
autoRun = JSON.parse(localStorageAutoRun);
} catch {}
if (typeof autoRun === 'boolean') {
this.setState({ autoRun });
}
}
} }
componentWillUnmount(): void { componentWillUnmount(): void {
@ -311,21 +370,23 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
handleDownload = (filename: string, format: string) => { handleDownload = (filename: string, format: string) => {
const { result } = this.state; const { result } = this.state;
if (!result) return; if (!result) return;
const { queryResult } = result;
let lines: string[] = []; let lines: string[] = [];
let separator: string = ''; let separator: string = '';
if (format === 'csv' || format === 'tsv') { if (format === 'csv' || format === 'tsv') {
separator = format === 'csv' ? ',' : '\t'; separator = format === 'csv' ? ',' : '\t';
lines.push(result.header.map(str => QueryView.formatStr(str, format)).join(separator)); lines.push(queryResult.header.map(str => QueryView.formatStr(str, format)).join(separator));
lines = lines.concat( lines = lines.concat(
result.rows.map(r => r.map(cell => QueryView.formatStr(cell, format)).join(separator)), queryResult.rows.map(r => r.map(cell => QueryView.formatStr(cell, format)).join(separator)),
); );
} else { } else {
// json // json
lines = result.rows.map(r => { lines = queryResult.rows.map(r => {
const outputObject: Record<string, any> = {}; const outputObject: Record<string, any> = {};
for (let k = 0; k < r.length; k++) { for (let k = 0; k < r.length; k++) {
const newName = result.header[k]; const newName = queryResult.header[k];
if (newName) { if (newName) {
outputObject[newName] = r[k]; outputObject[newName] = r[k];
} }
@ -347,6 +408,24 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
explainResult={explainResult} explainResult={explainResult}
explainError={explainError} explainError={explainError}
onClose={() => this.setState({ explainDialogOpen: false })} onClose={() => this.setState({ explainDialogOpen: false })}
setQueryString={(queryString: string) =>
this.setState({ queryString, explainDialogOpen: false, queryAst: parser(queryString) })
}
/>
);
}
renderHistoryDialog() {
const { historyDialogOpen, queryHistory } = this.state;
if (!historyDialogOpen) return;
return (
<QueryHistoryDialog
queryRecords={queryHistory}
setQueryString={queryString =>
this.setState({ queryString, queryAst: parser(queryString), historyDialogOpen: false })
}
onClose={() => this.setState({ historyDialogOpen: false })}
/> />
); );
} }
@ -372,13 +451,11 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
queryContext, queryContext,
loading, loading,
result, result,
queryExtraInfo,
error, error,
columnMetadata, columnMetadata,
ast, autoRun,
} = this.state; } = this.state;
const runeMode = QueryView.isJsonLike(queryString); const runeMode = QueryView.isJsonLike(queryString);
return ( return (
<SplitterLayout <SplitterLayout
vertical vertical
@ -399,77 +476,194 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
/> />
<div className="control-bar"> <div className="control-bar">
<RunButton <RunButton
autoRun={autoRun}
setAutoRun={(autoRun: boolean) => this.setAutoRun(autoRun)}
onEditContext={() => this.setState({ editContextDialogOpen: true })} onEditContext={() => this.setState({ editContextDialogOpen: true })}
runeMode={runeMode} runeMode={runeMode}
queryContext={queryContext} queryContext={queryContext}
onQueryContextChange={this.handleQueryContextChange} onQueryContextChange={this.handleQueryContextChange}
onRun={this.handleRun} onRun={this.handleRun}
onExplain={this.handleExplain} onExplain={this.handleExplain}
onHistory={() => this.setState({ historyDialogOpen: true })}
/> />
{queryExtraInfo && ( {result && (
<QueryExtraInfo queryExtraInfo={queryExtraInfo} onDownload={this.handleDownload} /> <QueryExtraInfo
queryExtraInfo={result.queryExtraInfo}
onDownload={this.handleDownload}
/>
)} )}
</div> </div>
</div> </div>
<QueryOutput <QueryOutput
aggregateColumns={ast ? ast.getAggregateColumns() : undefined}
disabled={!ast}
sorted={ast ? ast.getSorted() : undefined}
sqlExcludeColumn={this.sqlExcludeColumn} sqlExcludeColumn={this.sqlExcludeColumn}
sqlFilterRow={this.sqlFilterRow} sqlFilterRow={this.sqlFilterRow}
sqlOrderBy={this.sqlOrderBy} sqlOrderBy={this.sqlOrderBy}
runeMode={runeMode} runeMode={runeMode}
loading={loading} loading={loading}
result={result} queryResult={result ? result.queryResult : undefined}
parsedQuery={result ? result.parsedQuery : undefined}
error={error} error={error}
/> />
</SplitterLayout> </SplitterLayout>
); );
} }
private sqlOrderBy = (header: string, direction: 'ASC' | 'DESC'): void => { private addFunctionToGroupBy = (
let { ast } = this.state; functionName: string,
if (!ast) return; spacing: string[],
ast = ast.orderBy(header, direction); argumentsArray: (StringType | number)[],
preferablyRun: boolean,
alias: Alias,
): void => {
const { autoRun, queryAst } = this.state;
if (!queryAst) return;
const groupedAst = queryAst.addFunctionToGroupBy(functionName, spacing, argumentsArray, alias);
const queryString = groupedAst.toString();
this.setState({ this.setState({
queryString: ast.toString(), queryString,
queryAst: parser(queryString),
}); });
this.handleRun(true, ast.toString()); if (autoRun && preferablyRun) {
this.handleRun(true, queryString);
}
}; };
private sqlExcludeColumn = (header: string): void => { private addToGroupBy = (columnName: string, preferablyRun: boolean): void => {
let { ast } = this.state; const { autoRun, queryAst } = this.state;
if (!ast) return; if (!queryAst) return;
ast = ast.excludeColumn(header); const groupedAst = queryAst.addToGroupBy(columnName);
const queryString = groupedAst.toString();
this.setState({ this.setState({
queryString: ast.toString(), queryString,
queryAst: parser(queryString),
}); });
this.handleRun(true, ast.toString()); if (autoRun && preferablyRun) {
this.handleRun(true, queryString);
}
}; };
private sqlFilterRow = (row: string, header: string, operator: '!=' | '='): void => { private addAggregateColumn = (
let { ast } = this.state; columnName: string | RefExpression,
if (!ast) return; functionName: string,
ast = ast.filterRow(header, row, operator); preferablyRun: boolean,
alias?: Alias,
distinct?: boolean,
filter?: FilterClause,
): void => {
const { autoRun, queryAst } = this.state;
if (!queryAst) return;
const modifiedAst = queryAst.addAggregateColumn(
columnName,
functionName,
alias,
distinct,
filter,
);
const queryString = modifiedAst.toString();
this.setState({ this.setState({
queryString: ast.toString(), queryString,
queryAst: parser(queryString),
}); });
this.handleRun(true, ast.toString()); if (autoRun && preferablyRun) {
this.handleRun(true, queryString);
}
};
private sqlOrderBy = (
header: string,
direction: 'ASC' | 'DESC',
preferablyRun: boolean,
): void => {
const { autoRun, queryAst } = this.state;
if (!queryAst) return;
const modifiedAst = queryAst.orderBy(header, direction);
const queryString = modifiedAst.toString();
this.setState({
queryString,
queryAst: parser(queryString),
});
if (autoRun && preferablyRun) {
this.handleRun(true, queryString);
}
};
private sqlExcludeColumn = (header: string, preferablyRun: boolean): void => {
const { autoRun, queryAst } = this.state;
if (!queryAst) return;
const modifiedAst = queryAst.excludeColumn(header);
const queryString = modifiedAst.toString();
this.setState({
queryString,
queryAst: parser(queryString),
});
if (autoRun && preferablyRun) {
this.handleRun(true, queryString);
}
};
private sqlFilterRow = (filters: RowFilter[], preferablyRun: boolean): void => {
const { autoRun, queryAst } = this.state;
if (!queryAst) return;
let modifiedAst: SqlQuery = queryAst;
if (queryAst) {
for (const filter of filters) {
modifiedAst = modifiedAst.filterRow(filter.header, filter.row, filter.operator);
}
}
const queryString = modifiedAst.toString();
this.setState({
queryString,
queryAst: parser(queryString),
});
if (autoRun && preferablyRun) {
this.handleRun(true, queryString);
}
};
private sqlClearWhere = (): void => {
const { queryAst } = this.state;
if (!queryAst) return;
if (queryAst.whereClause) {
queryAst.whereClause = undefined;
}
}; };
private handleQueryStringChange = (queryString: string): void => { private handleQueryStringChange = (queryString: string): void => {
this.setState({ queryString }); this.setState({ queryString, queryAst: parser(queryString) });
}; };
private handleQueryContextChange = (queryContext: QueryContext) => { private handleQueryContextChange = (queryContext: QueryContext) => {
this.setState({ queryContext }); this.setState({ queryContext });
}; };
private setAutoRun = (autoRun: boolean) => {
this.setState({ autoRun });
localStorageSet(LocalStorageKeys.AUTO_RUN, String(autoRun));
};
private handleRun = (wrapQuery: boolean, customQueryString?: string) => { private handleRun = (wrapQuery: boolean, customQueryString?: string) => {
const { queryString, queryContext } = this.state; const { queryString, queryContext, queryHistory } = this.state;
if (!customQueryString) { if (!customQueryString) {
customQueryString = queryString; customQueryString = queryString;
} }
while (queryHistory.length > 9) {
queryHistory.pop();
}
queryHistory.unshift({
version: `${new Date().toISOString()}`,
queryString: customQueryString,
});
let queryHistoryString;
try {
queryHistoryString = JSON.stringify(queryHistory);
} catch {}
if (queryHistoryString) {
localStorageSet(LocalStorageKeys.QUERY_HISTORY, queryHistoryString);
}
if (QueryView.isJsonLike(customQueryString) && !QueryView.validRune(customQueryString)) return; if (QueryView.isJsonLike(customQueryString) && !QueryView.validRune(customQueryString)) return;
localStorageSet(LocalStorageKeys.QUERY_KEY, customQueryString); localStorageSet(LocalStorageKeys.QUERY_KEY, customQueryString);
@ -486,32 +680,47 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
localStorageSet(LocalStorageKeys.QUERY_VIEW_PANE_SIZE, String(secondaryPaneSize)); localStorageSet(LocalStorageKeys.QUERY_VIEW_PANE_SIZE, String(secondaryPaneSize));
}; };
render(): JSX.Element { private getGroupBySetting = () => {
const { const { queryString, queryAst } = this.state;
columnMetadata, const ast = queryAst;
columnMetadataLoading,
columnMetadataError,
ast,
queryString,
} = this.state;
let tempAst: SqlQuery | undefined; let tempAst: SqlQuery | undefined;
if (!ast) { if (!ast) {
try { tempAst = parser(queryString);
tempAst = parser(queryString);
} catch {}
} }
let defaultSchema;
let hasGroupBy = false;
if (ast && ast instanceof SqlQuery) { if (ast && ast instanceof SqlQuery) {
defaultSchema = ast.getSchema(); hasGroupBy = !!ast.groupByClause;
} else if (tempAst && tempAst instanceof SqlQuery) { } else if (tempAst && tempAst instanceof SqlQuery) {
defaultSchema = tempAst.getSchema(); hasGroupBy = !!tempAst.groupByClause;
}
return hasGroupBy;
};
private getQueryAst = () => {
const { queryAst } = this.state;
return queryAst;
};
private onQueryStringChange = (queryString: string) => {
const { autoRun } = this.state;
this.handleQueryStringChange(queryString);
if (autoRun) {
this.handleRun(true, queryString);
}
};
render(): JSX.Element {
const { columnMetadata, columnMetadataLoading, columnMetadataError, queryAst } = this.state;
let defaultSchema;
if (queryAst && queryAst instanceof SqlQuery) {
defaultSchema = queryAst.getSchema();
} }
let defaultTable; let defaultTable;
if (ast && ast instanceof SqlQuery) { if (queryAst && queryAst instanceof SqlQuery) {
defaultTable = ast.getTableName(); defaultTable = queryAst.getTableName();
} else if (tempAst && tempAst instanceof SqlQuery) {
defaultTable = tempAst.getTableName();
} }
return ( return (
@ -520,15 +729,23 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
> >
{!columnMetadataError && ( {!columnMetadataError && (
<ColumnTree <ColumnTree
clear={this.sqlClearWhere}
filterByRow={this.sqlFilterRow}
addFunctionToGroupBy={this.addFunctionToGroupBy}
addAggregateColumn={this.addAggregateColumn}
addToGroupBy={this.addToGroupBy}
hasGroupBy={this.getGroupBySetting}
queryAst={this.getQueryAst}
columnMetadataLoading={columnMetadataLoading} columnMetadataLoading={columnMetadataLoading}
columnMetadata={columnMetadata} columnMetadata={columnMetadata}
onQueryStringChange={this.handleQueryStringChange} onQueryStringChange={this.onQueryStringChange}
defaultSchema={defaultSchema} defaultSchema={defaultSchema ? defaultSchema : 'druid'}
defaultTable={defaultTable} defaultTable={defaultTable}
/> />
)} )}
{this.renderMainArea()} {this.renderMainArea()}
{this.renderExplainDialog()} {this.renderExplainDialog()}
{this.renderHistoryDialog()}
{this.renderEditContextDialog()} {this.renderEditContextDialog()}
</div> </div>
); );

View File

@ -25,6 +25,9 @@ describe('run button', () => {
it('matches snapshot', () => { it('matches snapshot', () => {
const runButton = ( const runButton = (
<RunButton <RunButton
autoRun
setAutoRun={() => null}
onHistory={() => null}
onEditContext={() => null} onEditContext={() => null}
runeMode={false} runeMode={false}
queryContext={{}} queryContext={{}}

View File

@ -45,11 +45,14 @@ import { DRUID_DOCS_RUNE, DRUID_DOCS_SQL } from '../../../variables';
export interface RunButtonProps { export interface RunButtonProps {
runeMode: boolean; runeMode: boolean;
autoRun: boolean;
queryContext: QueryContext; queryContext: QueryContext;
onQueryContextChange: (newQueryContext: QueryContext) => void; onQueryContextChange: (newQueryContext: QueryContext) => void;
onRun: (wrapQuery: boolean) => void; onRun: (wrapQuery: boolean) => void;
onExplain: () => void; onExplain: () => void;
onEditContext: () => void; onEditContext: () => void;
onHistory: () => void;
setAutoRun: (autoRun: boolean) => void;
} }
interface RunButtonState { interface RunButtonState {
@ -86,7 +89,16 @@ export class RunButton extends React.PureComponent<RunButtonProps, RunButtonStat
}; };
renderExtraMenu() { renderExtraMenu() {
const { runeMode, onExplain, queryContext, onQueryContextChange, onEditContext } = this.props; const {
runeMode,
onExplain,
queryContext,
onQueryContextChange,
onEditContext,
onHistory,
setAutoRun,
autoRun,
} = this.props;
const { wrapQuery } = this.state; const { wrapQuery } = this.state;
const useCache = getUseCache(queryContext); const useCache = getUseCache(queryContext);
@ -104,11 +116,17 @@ export class RunButton extends React.PureComponent<RunButtonProps, RunButtonStat
{!runeMode && ( {!runeMode && (
<> <>
<MenuItem icon={IconNames.CLEAN} text="Explain" onClick={onExplain} /> <MenuItem icon={IconNames.CLEAN} text="Explain" onClick={onExplain} />
<MenuItem icon={IconNames.HISTORY} text="History" onClick={onHistory} />
<MenuCheckbox <MenuCheckbox
checked={wrapQuery} checked={wrapQuery}
label="Wrap query with limit" label="Wrap query with limit"
onChange={() => this.setState({ wrapQuery: !wrapQuery })} onChange={() => this.setState({ wrapQuery: !wrapQuery })}
/> />
<MenuCheckbox
checked={autoRun}
label="Auto run queries"
onChange={() => setAutoRun(!autoRun)}
/>
<MenuCheckbox <MenuCheckbox
checked={useApproximateCountDistinct} checked={useApproximateCountDistinct}
label="Use approximate COUNT(DISTINCT)" label="Use approximate COUNT(DISTINCT)"