Web console: Save query context also (#8395)

* tidy up menus

* fix query output API

* save context also

* pull out auto run into a switch

* better copy

* add group_id

* support FLOAT also

* use built in time logic

* fix trunc direction

* add skipCache

* add manifest url

* remove depricated props
This commit is contained in:
Vadim Ogievetsky 2019-08-25 16:56:27 -07:00 committed by Fangjin Yang
parent 496dfa3b15
commit 9254dc8b80
43 changed files with 925 additions and 863 deletions

View File

@ -16,4 +16,7 @@
* limitations under the License. * limitations under the License.
*/ */
window.consoleConfig = { /* future configs may go here */ }; window.consoleConfig = {
"exampleManifestsUrl": "https://druid.apache.org/data/example-manifests.tsv"
/* future configs may go here */
};

View File

@ -4428,9 +4428,9 @@
"integrity": "sha512-0sYnfUHHMoajaud/i5BHKA12bUxiWEHJ9rxGqVEppFxsEcxef0TZQ5J59lU+UniEBcz/sG5fTESRyS7cOm3tSQ==" "integrity": "sha512-0sYnfUHHMoajaud/i5BHKA12bUxiWEHJ9rxGqVEppFxsEcxef0TZQ5J59lU+UniEBcz/sG5fTESRyS7cOm3tSQ=="
}, },
"druid-query-toolkit": { "druid-query-toolkit": {
"version": "0.3.24", "version": "0.3.26",
"resolved": "https://registry.npmjs.org/druid-query-toolkit/-/druid-query-toolkit-0.3.24.tgz", "resolved": "https://registry.npmjs.org/druid-query-toolkit/-/druid-query-toolkit-0.3.26.tgz",
"integrity": "sha512-kFvEXAjjNuJYpeRsAzzO/cJ2rr4nHBGTSCAA4UPxyt4pKNZE/OUap7IQbsdnxYmhkHgfjUBGcFteufaVHSn7SA==", "integrity": "sha512-j9HcwHCx2YnFSefYc1oJDw8rPq5zSB0tpGkaMp2GkO9syKbdncKfUPugZ613c5XIOBe+j5Hqh/luqh4sLacHGQ==",
"requires": { "requires": {
"tslib": "^1.10.0" "tslib": "^1.10.0"
} }

View File

@ -61,7 +61,7 @@
"d3": "^5.10.1", "d3": "^5.10.1",
"d3-array": "^2.3.1", "d3-array": "^2.3.1",
"druid-console": "0.0.2", "druid-console": "0.0.2",
"druid-query-toolkit": "^0.3.24", "druid-query-toolkit": "^0.3.26",
"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

@ -35,10 +35,6 @@ export class ActionCell extends React.PureComponent<ActionCellProps> {
static COLUMN_LABEL = 'Actions'; static COLUMN_LABEL = 'Actions';
static COLUMN_WIDTH = 70; static COLUMN_WIDTH = 70;
constructor(props: ActionCellProps, context: any) {
super(props, context);
}
render(): JSX.Element { render(): JSX.Element {
const { onDetail, actions } = this.props; const { onDetail, actions } = this.props;
const actionsMenu = actions ? basicActionsToMenu(actions) : null; const actionsMenu = actions ? basicActionsToMenu(actions) : null;

View File

@ -25,10 +25,6 @@ export interface DeferredProps {
export interface DeferredState {} export interface DeferredState {}
export class Deferred extends React.PureComponent<DeferredProps, DeferredState> { export class Deferred extends React.PureComponent<DeferredProps, DeferredState> {
constructor(props: DeferredProps, context: any) {
super(props, context);
}
render(): JSX.Element { render(): JSX.Element {
const { content } = this.props; const { content } = this.props;
return content(); return content();

View File

@ -15,6 +15,7 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
import { IconNames } from '@blueprintjs/icons'; import { IconNames } from '@blueprintjs/icons';
import React from 'react'; import React from 'react';
@ -27,10 +28,6 @@ export interface RefreshButtonProps {
} }
export class RefreshButton extends React.PureComponent<RefreshButtonProps> { export class RefreshButton extends React.PureComponent<RefreshButtonProps> {
constructor(props: RefreshButtonProps, context: any) {
super(props, context);
}
render(): JSX.Element { render(): JSX.Element {
const { onRefresh, localStorageKey } = this.props; const { onRefresh, localStorageKey } = this.props;
const intervals = [ const intervals = [

View File

@ -27,7 +27,7 @@ import { Loader } from '../loader/loader';
import './segment-timeline.scss'; import './segment-timeline.scss';
interface SegmentTimelineProps extends React.Props<any> { interface SegmentTimelineProps {
chartHeight: number; chartHeight: number;
chartWidth: number; chartWidth: number;
} }
@ -71,9 +71,7 @@ export interface BarChartMargin {
} }
export class SegmentTimeline extends React.Component<SegmentTimelineProps, SegmentTimelineState> { export class SegmentTimeline extends React.Component<SegmentTimelineProps, SegmentTimelineState> {
private dataQueryManager: QueryManager<null, any>; static COLORS = [
private datasourceQueryManager: QueryManager<null, any>;
private colors = [
'#b33040', '#b33040',
'#d25c4d', '#d25c4d',
'#f2b447', '#f2b447',
@ -91,6 +89,13 @@ export class SegmentTimeline extends React.Component<SegmentTimelineProps, Segme
'#915412', '#915412',
'#87606c', '#87606c',
]; ];
static getColor(index: number): string {
return SegmentTimeline.COLORS[index % SegmentTimeline.COLORS.length];
}
private dataQueryManager: QueryManager<null, any>;
private datasourceQueryManager: QueryManager<null, any>;
private chartMargin = { top: 20, right: 10, bottom: 20, left: 10 }; private chartMargin = { top: 20, right: 10, bottom: 20, left: 10 };
constructor(props: SegmentTimelineProps) { constructor(props: SegmentTimelineProps) {
@ -249,7 +254,7 @@ export class SegmentTimeline extends React.Component<SegmentTimelineProps, Segme
y: d[datasource] === undefined ? 0 : d[datasource], y: d[datasource] === undefined ? 0 : d[datasource],
y0, y0,
datasource, datasource,
color: this.colors[i], color: SegmentTimeline.getColor(i),
}; };
y0 += d[datasource] === undefined ? 0 : d[datasource]; y0 += d[datasource] === undefined ? 0 : d[datasource];
return barUnitData; return barUnitData;
@ -279,7 +284,7 @@ export class SegmentTimeline extends React.Component<SegmentTimelineProps, Segme
x: d.day, x: d.day,
y, y,
datasource, datasource,
color: this.colors[i], color: SegmentTimeline.getColor(i),
}; };
}); });
if (!dataResult.every((d: any) => d.y === 0)) { if (!dataResult.every((d: any) => d.y === 0)) {

View File

@ -31,10 +31,6 @@ export interface ShowValueProps {
} }
export class ShowValue extends React.PureComponent<ShowValueProps> { export class ShowValue extends React.PureComponent<ShowValueProps> {
constructor(props: ShowValueProps, context: any) {
super(props, context);
}
render(): JSX.Element { render(): JSX.Element {
const { endpoint, downloadFilename, jsonValue } = this.props; const { endpoint, downloadFilename, jsonValue } = this.props;
return ( return (

View File

@ -15,6 +15,7 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
import { Button, Callout, Classes, Dialog, Intent, TextArea } from '@blueprintjs/core'; import { Button, Callout, Classes, Dialog, Intent, TextArea } from '@blueprintjs/core';
import Hjson from 'hjson'; import Hjson from 'hjson';
import React from 'react'; import React from 'react';
@ -42,6 +43,7 @@ export class EditContextDialog extends React.PureComponent<
constructor(props: EditContextDialogProps) { constructor(props: EditContextDialogProps) {
super(props); super(props);
this.state = { this.state = {
queryContext: props.queryContext,
queryContextString: Object.keys(props.queryContext).length queryContextString: Object.keys(props.queryContext).length
? JSON.stringify(props.queryContext, undefined, 2) ? JSON.stringify(props.queryContext, undefined, 2)
: '{\n\n}', : '{\n\n}',
@ -72,10 +74,12 @@ export class EditContextDialog extends React.PureComponent<
}; };
private handleSave = () => { private handleSave = () => {
const { onQueryContextChange } = this.props; const { onQueryContextChange, onClose } = this.props;
const { queryContext } = this.state; const { queryContext } = this.state;
if (!queryContext) return; if (!queryContext) return;
onQueryContextChange(queryContext); onQueryContextChange(queryContext);
onClose();
}; };
render(): JSX.Element { render(): JSX.Element {
@ -94,9 +98,9 @@ export class EditContextDialog extends React.PureComponent<
<div className={'edit-context-dialog-buttons'}> <div className={'edit-context-dialog-buttons'}>
<Button text={'Close'} onClick={onClose} /> <Button text={'Close'} onClick={onClose} />
<Button <Button
disabled={Boolean(error)}
text={'Save'} text={'Save'}
intent={Intent.PRIMARY} intent={Intent.PRIMARY}
disabled={Boolean(error)}
onClick={this.handleSave} onClick={this.handleSave}
/> />
</div> </div>

View File

@ -15,6 +15,7 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
export * from './about-dialog/about-dialog'; export * from './about-dialog/about-dialog';
export * from './async-action-dialog/async-action-dialog'; export * from './async-action-dialog/async-action-dialog';
export * from './compaction-dialog/compaction-dialog'; export * from './compaction-dialog/compaction-dialog';

View File

@ -26,9 +26,10 @@ import './query-history-dialog.scss';
export interface QueryRecord { export interface QueryRecord {
version: string; version: string;
queryString: string; queryString: string;
queryContext?: Record<string, any>;
} }
export interface QueryHistoryDialogProps { export interface QueryHistoryDialogProps {
setQueryString: (queryString: string) => void; setQueryString: (queryString: string, queryContext: Record<string, any>) => void;
onClose: () => void; onClose: () => void;
queryRecords: readonly QueryRecord[]; queryRecords: readonly QueryRecord[];
} }
@ -51,9 +52,14 @@ export class QueryHistoryDialog extends React.PureComponent<
static addQueryToHistory( static addQueryToHistory(
queryHistory: readonly QueryRecord[], queryHistory: readonly QueryRecord[],
queryString: string, queryString: string,
queryContext: Record<string, any>,
): readonly QueryRecord[] { ): readonly QueryRecord[] {
// Do not add to history if already the same as the last element // Do not add to history if already the same as the last element in query and context
if (queryHistory.length && queryHistory[0].queryString === queryString) { if (
queryHistory.length &&
queryHistory[0].queryString === queryString &&
JSON.stringify(queryHistory[0].queryContext) === JSON.stringify(queryContext)
) {
return queryHistory; return queryHistory;
} }
@ -61,7 +67,8 @@ export class QueryHistoryDialog extends React.PureComponent<
{ {
version: QueryHistoryDialog.getHistoryVersion(), version: QueryHistoryDialog.getHistoryVersion(),
queryString, queryString,
}, queryContext,
} as QueryRecord,
] ]
.concat(queryHistory) .concat(queryHistory)
.slice(0, 10); .slice(0, 10);
@ -77,8 +84,9 @@ export class QueryHistoryDialog extends React.PureComponent<
private handleSelect = () => { private handleSelect = () => {
const { queryRecords, setQueryString, onClose } = this.props; const { queryRecords, setQueryString, onClose } = this.props;
const { activeTab } = this.state; const { activeTab } = this.state;
const queryRecord = queryRecords[activeTab];
setQueryString(queryRecords[activeTab].queryString); setQueryString(queryRecord.queryString, queryRecord.queryContext || {});
onClose(); onClose();
}; };

View File

@ -55,7 +55,9 @@ exports[`query plan dialog matches snapshot 1`] = `
<div <div
class="bp3-dialog-body" class="bp3-dialog-body"
> >
<div> <div
class="generic-result"
>
test test
</div> </div>
</div> </div>
@ -75,16 +77,6 @@ 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

@ -36,4 +36,8 @@
height: 25vh !important; height: 25vh !important;
} }
} }
.generic-result {
overflow: scroll;
}
} }

View File

@ -38,23 +38,16 @@ export interface QueryPlanDialogProps {
setQueryString: (queryString: string) => void; setQueryString: (queryString: string) => void;
} }
export interface QueryPlanDialogState {} export class QueryPlanDialog extends React.PureComponent<QueryPlanDialogProps> {
export class QueryPlanDialog extends React.PureComponent<
QueryPlanDialogProps,
QueryPlanDialogState
> {
constructor(props: QueryPlanDialogProps) { constructor(props: QueryPlanDialogProps) {
super(props); super(props);
this.state = {};
} }
private queryString: string = '';
render(): JSX.Element { render(): JSX.Element {
const { explainResult, explainError, onClose, setQueryString } = this.props; const { explainResult, explainError, onClose, setQueryString } = this.props;
let content: JSX.Element; let content: JSX.Element;
let queryString: string | undefined;
if (explainError) { if (explainError) {
content = <div>{explainError}</div>; content = <div>{explainError}</div>;
@ -71,15 +64,11 @@ export class QueryPlanDialog extends React.PureComponent<
); );
} }
this.queryString = JSON.stringify( queryString = JSON.stringify((explainResult as BasicQueryExplanation).query[0], undefined, 2);
(explainResult as BasicQueryExplanation).query[0],
undefined,
2,
);
content = ( content = (
<div className="one-query"> <div className="one-query">
<FormGroup label="Query"> <FormGroup label="Query">
<TextArea readOnly value={this.queryString} /> <TextArea readOnly value={queryString} />
</FormGroup> </FormGroup>
{signature} {signature}
</div> </div>
@ -136,7 +125,7 @@ export class QueryPlanDialog extends React.PureComponent<
</div> </div>
); );
} else { } else {
content = <div>{explainResult}</div>; content = <div className="generic-result">{explainResult}</div>;
} }
return ( return (
@ -145,14 +134,16 @@ 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} />
{queryString && (
<Button <Button
text="Open" text="Open query"
intent={Intent.PRIMARY} intent={Intent.PRIMARY}
onClick={() => { onClick={() => {
setQueryString(this.queryString); if (queryString) setQueryString(queryString);
onClose(); onClose();
}} }}
/> />
)}
</div> </div>
</div> </div>
</Dialog> </Dialog>

View File

@ -15,6 +15,7 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
import { Button, Classes, Dialog, Intent, TextArea } from '@blueprintjs/core'; import { Button, Classes, Dialog, Intent, TextArea } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons'; import { IconNames } from '@blueprintjs/icons';
import copy from 'copy-to-clipboard'; import copy from 'copy-to-clipboard';

View File

@ -26,6 +26,7 @@ export const LocalStorageKeys = {
SERVER_TABLE_COLUMN_SELECTION: 'historical-table-column-selection' as 'historical-table-column-selection', SERVER_TABLE_COLUMN_SELECTION: 'historical-table-column-selection' as 'historical-table-column-selection',
LOOKUP_TABLE_COLUMN_SELECTION: 'lookup-table-column-selection' as 'lookup-table-column-selection', LOOKUP_TABLE_COLUMN_SELECTION: 'lookup-table-column-selection' as 'lookup-table-column-selection',
QUERY_KEY: 'druid-console-query' as 'druid-console-query', QUERY_KEY: 'druid-console-query' as 'druid-console-query',
QUERY_CONTEXT: 'query-context' as 'query-context',
TASKS_VIEW_PANE_SIZE: 'tasks-view-pane-size' as 'tasks-view-pane-size', TASKS_VIEW_PANE_SIZE: 'tasks-view-pane-size' as 'tasks-view-pane-size',
QUERY_VIEW_PANE_SIZE: 'query-view-pane-size' as 'query-view-pane-size', QUERY_VIEW_PANE_SIZE: 'query-view-pane-size' as 'query-view-pane-size',
TASKS_REFRESH_RATE: 'task-refresh-rate' as 'task-refresh-rate', TASKS_REFRESH_RATE: 'task-refresh-rate' as 'task-refresh-rate',
@ -46,7 +47,21 @@ export function localStorageSet(key: LocalStorageKeys, value: string): void {
localStorage.setItem(key, value); localStorage.setItem(key, value);
} }
export function localStorageSetJson(key: LocalStorageKeys, value: any): void {
localStorageSet(key, JSON.stringify(value));
}
export function localStorageGet(key: LocalStorageKeys): string | undefined { export function localStorageGet(key: LocalStorageKeys): string | undefined {
if (typeof localStorage === 'undefined') return; if (typeof localStorage === 'undefined') return;
return localStorage.getItem(key) || undefined; return localStorage.getItem(key) || undefined;
} }
export function localStorageGetJson(key: LocalStorageKeys): any {
const value = localStorageGet(key);
if (!value) return;
try {
return JSON.parse(value);
} catch {
return;
}
}

View File

@ -23,6 +23,7 @@ export interface QueryContext {
populateCache?: boolean | undefined; populateCache?: boolean | undefined;
useApproximateCountDistinct?: boolean | undefined; useApproximateCountDistinct?: boolean | undefined;
useApproximateTopN?: boolean | undefined; useApproximateTopN?: boolean | undefined;
[key: string]: any;
} }
export function isEmptyContext(context: QueryContext): boolean { export function isEmptyContext(context: QueryContext): boolean {

View File

@ -555,7 +555,7 @@ export async function sampleForExampleManifests(
}, },
}, },
}, },
samplerConfig: { numRows: 50, timeoutMs: 10000 }, samplerConfig: { numRows: 50, timeoutMs: 10000, skipCache: true },
}; };
const exampleData = await postToSampler(sampleSpec, 'example-manifest'); const exampleData = await postToSampler(sampleSpec, 'example-manifest');

View File

@ -15,6 +15,7 @@
* See the License for the specific language governing permissions and * See the License for the specific language governing permissions and
* limitations under the License. * limitations under the License.
*/ */
export * from './datasource-view/datasource-view'; export * from './datasource-view/datasource-view';
export * from './home-view/home-view'; export * from './home-view/home-view';
export * from './load-data-view/load-data-view'; export * from './load-data-view/load-data-view';

View File

@ -1044,8 +1044,9 @@ export class LoadDataView extends React.PureComponent<LoadDataViewProps, LoadDat
</FormGroup> </FormGroup>
)} )}
<Button <Button
text={inlineMode ? 'Register' : 'Preview'} text={inlineMode ? 'Register data' : 'Preview'}
disabled={isBlank} disabled={isBlank}
intent={inputQueryState.data ? undefined : Intent.PRIMARY}
onClick={() => this.queryForConnect()} onClick={() => this.queryForConnect()}
/> />
</div> </div>

View File

@ -5,16 +5,10 @@ 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" defaultSchema="druid"
filterByRow={[Function]} getParsedQuery={[Function]}
onQueryStringChange={[Function]} onQueryStringChange={[Function]}
queryAst={[Function]}
replaceFrom={[Function]}
/> />
<t <t
customClassName="" customClassName=""
@ -41,8 +35,6 @@ exports[`sql view matches snapshot 1`] = `
className="control-bar" className="control-bar"
> >
<HotkeysTarget(RunButton) <HotkeysTarget(RunButton)
autoRun={true}
onAutoRunChange={[Function]}
onEditContext={[Function]} onEditContext={[Function]}
onExplain={[Function]} onExplain={[Function]}
onHistory={[Function]} onHistory={[Function]}
@ -52,7 +44,20 @@ exports[`sql view matches snapshot 1`] = `
runeMode={false} runeMode={false}
/> />
<Blueprint3.Tooltip <Blueprint3.Tooltip
content="Automatically wrap the query with a limit to protect against queries with very large result sets" content="Automatically run queries when modified via helper action menus."
hoverCloseDelay={0}
hoverOpenDelay={800}
transitionDuration={100}
>
<Blueprint3.Switch
checked={true}
className="auto-run"
label="Auto run"
onChange={[Function]}
/>
</Blueprint3.Tooltip>
<Blueprint3.Tooltip
content="Automatically wrap the query with a limit to protect against queries with very large result sets."
hoverCloseDelay={0} hoverCloseDelay={0}
hoverOpenDelay={800} hoverOpenDelay={800}
transitionDuration={100} transitionDuration={100}
@ -68,10 +73,8 @@ exports[`sql view matches snapshot 1`] = `
</div> </div>
<QueryOutput <QueryOutput
loading={false} loading={false}
onQueryChange={[Function]}
runeMode={false} runeMode={false}
sqlExcludeColumn={[Function]}
sqlFilterRow={[Function]}
sqlOrderBy={[Function]}
/> />
</t> </t>
</div> </div>

View File

@ -49,13 +49,13 @@ exports[`column tree matches snapshot 1`] = `
class="bp3-tree-node-list bp3-tree-root" class="bp3-tree-node-list bp3-tree-root"
> >
<li <li
class="bp3-tree-node" class="bp3-tree-node bp3-tree-node-expanded"
> >
<div <div
class="bp3-tree-node-content bp3-tree-node-content-0" class="bp3-tree-node-content bp3-tree-node-content-0"
> >
<span <span
class="bp3-icon bp3-icon-chevron-right bp3-tree-node-caret bp3-tree-node-caret-closed" class="bp3-icon bp3-icon-chevron-right bp3-tree-node-caret bp3-tree-node-caret-open"
icon="chevron-right" icon="chevron-right"
> >
<svg <svg
@ -104,7 +104,177 @@ exports[`column tree matches snapshot 1`] = `
<div <div
class="" class=""
> >
deletion-tutorial wikipedia
</div>
</span>
</span>
</span>
</div>
<div
class="bp3-collapse"
style="height: auto; overflow-y: visible; transition: none;"
>
<div
aria-hidden="false"
class="bp3-collapse-body"
style="transform: translateY(0); transition: none;"
>
<ul
class="bp3-tree-node-list"
>
<li
class="bp3-tree-node"
>
<div
class="bp3-tree-node-content bp3-tree-node-content-1"
>
<span
class="bp3-tree-node-caret-none"
/>
<span
class="bp3-icon bp3-icon-time bp3-tree-node-icon"
icon="time"
>
<svg
data-icon="time"
height="16"
viewBox="0 0 16 16"
width="16"
>
<desc>
time
</desc>
<path
d="M8 0C3.58 0 0 3.58 0 8s3.58 8 8 8 8-3.58 8-8-3.58-8-8-8zm0 14c-3.31 0-6-2.69-6-6s2.69-6 6-6 6 2.69 6 6-2.69 6-6 6zm1-6.41V4c0-.55-.45-1-1-1s-1 .45-1 1v4c0 .28.11.53.29.71l2 2a1.003 1.003 0 001.42-1.42L9 7.59z"
fill-rule="evenodd"
/>
</svg>
</span>
<span
class="bp3-tree-node-label"
>
<span
class="bp3-popover-wrapper"
>
<span
class="bp3-popover-target bp3-popover-open"
>
<div
class=""
>
__time
</div>
</span>
</span>
</span>
</div>
<div
class="bp3-collapse"
>
<div
aria-hidden="false"
class="bp3-collapse-body"
style="transform: translateY(-0px);"
/>
</div>
</li>
<li
class="bp3-tree-node"
>
<div
class="bp3-tree-node-content bp3-tree-node-content-1"
>
<span
class="bp3-tree-node-caret-none"
/>
<span
class="bp3-icon bp3-icon-numerical bp3-tree-node-icon"
icon="numerical"
>
<svg
data-icon="numerical"
height="16"
viewBox="0 0 16 16"
width="16"
>
<desc>
numerical
</desc>
<path
d="M2.79 4.61c-.13.17-.29.3-.48.41-.18.11-.39.18-.62.23-.23.04-.46.07-.71.07v1.03h1.74V12h1.26V4h-.94c-.04.23-.12.44-.25.61zm4.37 5.31c.18-.14.37-.28.58-.42l.63-.45c.21-.16.41-.33.61-.51s.37-.38.52-.59c.15-.21.28-.45.37-.7.09-.25.13-.54.13-.85 0-.25-.04-.52-.12-.8-.07-.29-.2-.55-.39-.79a2.18 2.18 0 00-.73-.6c-.29-.15-.66-.23-1.11-.23-.41 0-.77.08-1.08.23-.31.16-.58.37-.79.64-.22.27-.38.59-.49.96-.11.37-.16.77-.16 1.2h1.19c.01-.27.03-.53.08-.77.04-.24.11-.45.21-.62.09-.18.22-.32.38-.42.16-.1.35-.15.59-.15.26 0 .47.05.63.14.15.09.28.21.37.35.09.14.15.29.18.45.03.16.05.31.05.45-.01.31-.08.58-.22.82-.14.23-.32.45-.53.65-.22.21-.46.39-.71.57-.26.18-.51.36-.75.54-.5.36-.89.78-1.17 1.27-.28.49-.43 1.06-.44 1.71h5v-1.15H6.43c.05-.17.14-.33.27-.49.13-.15.29-.29.46-.44zm8.5-1.56c-.23-.35-.54-.57-.95-.65v-.02c.34-.13.6-.34.76-.63.16-.29.24-.63.24-1.02 0-.34-.06-.64-.19-.9s-.3-.47-.51-.64c-.21-.17-.45-.3-.72-.38-.27-.09-.54-.13-.82-.13-.36 0-.68.07-.96.2-.28.13-.53.32-.72.55-.2.23-.36.51-.47.83-.11.32-.18.66-.19 1.04h1.15c-.01-.2.01-.39.06-.58.05-.19.12-.36.22-.51.1-.15.22-.27.37-.36.15-.09.32-.13.53-.13.32 0 .59.1.79.3.21.2.31.46.31.79 0 .23-.05.43-.14.59-.09.16-.21.29-.35.38-.15.09-.32.16-.51.19-.19.04-.38.05-.57.04v.93c.23-.01.45 0 .67.02.22.02.42.08.59.17.18.09.32.23.43.4.11.18.16.41.16.71 0 .44-.13.78-.39 1.02s-.58.36-.97.36c-.45 0-.79-.16-1.02-.47-.23-.31-.33-.7-.32-1.17H11c.01.4.06.77.17 1.1.11.33.26.61.47.85.21.23.46.42.77.54.31.13.67.19 1.08.19.34 0 .66-.05.96-.16.3-.11.57-.27.8-.47.23-.2.41-.45.55-.74.13-.27.2-.6.2-.97 0-.5-.11-.92-.34-1.27z"
fill-rule="evenodd"
/>
</svg>
</span>
<span
class="bp3-tree-node-label"
>
<span
class="bp3-popover-wrapper"
>
<span
class="bp3-popover-target bp3-popover-open"
>
<div
class=""
>
added
</div>
</span>
</span>
</span>
</div>
<div
class="bp3-collapse"
>
<div
aria-hidden="false"
class="bp3-collapse-body"
style="transform: translateY(-0px);"
/>
</div>
</li>
<li
class="bp3-tree-node"
>
<div
class="bp3-tree-node-content bp3-tree-node-content-1"
>
<span
class="bp3-tree-node-caret-none"
/>
<span
class="bp3-icon bp3-icon-numerical bp3-tree-node-icon"
icon="numerical"
>
<svg
data-icon="numerical"
height="16"
viewBox="0 0 16 16"
width="16"
>
<desc>
numerical
</desc>
<path
d="M2.79 4.61c-.13.17-.29.3-.48.41-.18.11-.39.18-.62.23-.23.04-.46.07-.71.07v1.03h1.74V12h1.26V4h-.94c-.04.23-.12.44-.25.61zm4.37 5.31c.18-.14.37-.28.58-.42l.63-.45c.21-.16.41-.33.61-.51s.37-.38.52-.59c.15-.21.28-.45.37-.7.09-.25.13-.54.13-.85 0-.25-.04-.52-.12-.8-.07-.29-.2-.55-.39-.79a2.18 2.18 0 00-.73-.6c-.29-.15-.66-.23-1.11-.23-.41 0-.77.08-1.08.23-.31.16-.58.37-.79.64-.22.27-.38.59-.49.96-.11.37-.16.77-.16 1.2h1.19c.01-.27.03-.53.08-.77.04-.24.11-.45.21-.62.09-.18.22-.32.38-.42.16-.1.35-.15.59-.15.26 0 .47.05.63.14.15.09.28.21.37.35.09.14.15.29.18.45.03.16.05.31.05.45-.01.31-.08.58-.22.82-.14.23-.32.45-.53.65-.22.21-.46.39-.71.57-.26.18-.51.36-.75.54-.5.36-.89.78-1.17 1.27-.28.49-.43 1.06-.44 1.71h5v-1.15H6.43c.05-.17.14-.33.27-.49.13-.15.29-.29.46-.44zm8.5-1.56c-.23-.35-.54-.57-.95-.65v-.02c.34-.13.6-.34.76-.63.16-.29.24-.63.24-1.02 0-.34-.06-.64-.19-.9s-.3-.47-.51-.64c-.21-.17-.45-.3-.72-.38-.27-.09-.54-.13-.82-.13-.36 0-.68.07-.96.2-.28.13-.53.32-.72.55-.2.23-.36.51-.47.83-.11.32-.18.66-.19 1.04h1.15c-.01-.2.01-.39.06-.58.05-.19.12-.36.22-.51.1-.15.22-.27.37-.36.15-.09.32-.13.53-.13.32 0 .59.1.79.3.21.2.31.46.31.79 0 .23-.05.43-.14.59-.09.16-.21.29-.35.38-.15.09-.32.16-.51.19-.19.04-.38.05-.57.04v.93c.23-.01.45 0 .67.02.22.02.42.08.59.17.18.09.32.23.43.4.11.18.16.41.16.71 0 .44-.13.78-.39 1.02s-.58.36-.97.36c-.45 0-.79-.16-1.02-.47-.23-.31-.33-.7-.32-1.17H11c.01.4.06.77.17 1.1.11.33.26.61.47.85.21.23.46.42.77.54.31.13.67.19 1.08.19.34 0 .66-.05.96-.16.3-.11.57-.27.8-.47.23-.2.41-.45.55-.74.13-.27.2-.6.2-.97 0-.5-.11-.92-.34-1.27z"
fill-rule="evenodd"
/>
</svg>
</span>
<span
class="bp3-tree-node-label"
>
<span
class="bp3-popover-wrapper"
>
<span
class="bp3-popover-target bp3-popover-open"
>
<div
class=""
>
addedBy10
</div> </div>
</span> </span>
</span> </span>
@ -123,5 +293,9 @@ exports[`column tree matches snapshot 1`] = `
</ul> </ul>
</div> </div>
</div> </div>
</li>
</ul>
</div>
</div>
</div> </div>
`; `;

View File

@ -0,0 +1,21 @@
/*
* 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.
*/
export * from './number-menu-items/number-menu-items';
export * from './string-menu-items/string-menu-items';
export * from './time-menu-items/time-menu-items';

View File

@ -28,14 +28,9 @@ describe('number menu', () => {
it('matches snapshot', () => { it('matches snapshot', () => {
const numberMenu = ( const numberMenu = (
<NumberMenuItems <NumberMenuItems
hasFilter
clear={() => null}
addFunctionToGroupBy={() => null}
addToGroupBy={() => null}
addAggregateColumn={() => null}
filterByRow={() => null}
columnName={'added'} columnName={'added'}
queryAst={parser(`SELECT channel, count(*) as cnt FROM wikipedia GROUP BY 1`)} parsedQuery={parser(`SELECT channel, count(*) as cnt FROM wikipedia GROUP BY 1`)}
onQueryChange={() => {}}
/> />
); );

View File

@ -18,82 +18,71 @@
import { MenuItem } from '@blueprintjs/core'; import { MenuItem } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons'; import { IconNames } from '@blueprintjs/icons';
import { Alias, FilterClause, SqlQuery, StringType } from 'druid-query-toolkit'; import { SqlQuery, StringType } from 'druid-query-toolkit';
import { aliasFactory } from 'druid-query-toolkit/build/ast/sql-query/helpers'; import { aliasFactory } from 'druid-query-toolkit/build/ast/sql-query/helpers';
import React from 'react'; import React from 'react';
import { RowFilter } from '../../../query-view';
export interface NumberMenuItemsProps { 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; columnName: string;
clear: (column: string, preferablyRun: boolean) => void; parsedQuery: SqlQuery;
hasFilter: boolean; onQueryChange: (queryString: SqlQuery, run?: boolean) => void;
} }
export class NumberMenuItems extends React.PureComponent<NumberMenuItemsProps> { export class NumberMenuItems extends React.PureComponent<NumberMenuItemsProps> {
constructor(props: NumberMenuItemsProps, context: any) {
super(props, context);
}
renderFilterMenu(): JSX.Element { renderFilterMenu(): JSX.Element {
const { columnName, filterByRow } = this.props; const { columnName, parsedQuery, onQueryChange } = this.props;
return ( return (
<MenuItem icon={IconNames.FILTER} text={`Filter`}> <MenuItem icon={IconNames.FILTER} text={`Filter`}>
<MenuItem <MenuItem
text={`"${columnName}" > 100`} text={`"${columnName}" > 100`}
onClick={() => filterByRow([{ row: 100, header: columnName, operator: '>' }], false)} onClick={() => {
onQueryChange(parsedQuery.filterRow(columnName, 100, '>'));
}}
/> />
<MenuItem <MenuItem
text={`"${columnName}" <= 100`} text={`"${columnName}" <= 100`}
onClick={() => filterByRow([{ row: 100, header: columnName, operator: '<=' }], false)} onClick={() => {
onQueryChange(parsedQuery.filterRow(columnName, 100, '<='));
}}
/> />
</MenuItem> </MenuItem>
); );
} }
renderRemoveFilter() { renderRemoveFilter(): JSX.Element | undefined {
const { columnName, clear } = this.props; const { columnName, parsedQuery, onQueryChange } = this.props;
if (!parsedQuery.hasFilterForColumn(columnName)) return;
return ( return (
<MenuItem <MenuItem
icon={IconNames.FILTER_REMOVE} icon={IconNames.FILTER_REMOVE}
text={`Remove filter`} text={`Remove filter`}
onClick={() => { onClick={() => {
clear(columnName, true); onQueryChange(parsedQuery.removeFilter(columnName), true);
}} }}
/> />
); );
} }
renderGroupByMenu(): JSX.Element { renderGroupByMenu(): JSX.Element | undefined {
const { columnName, addFunctionToGroupBy, addToGroupBy } = this.props; const { columnName, parsedQuery, onQueryChange } = this.props;
if (!parsedQuery.groupByClause) return;
return ( return (
<MenuItem icon={IconNames.GROUP_OBJECTS} text={`Group by`}> <MenuItem icon={IconNames.GROUP_OBJECTS} text={`Group by`}>
<MenuItem text={`"${columnName}"`} onClick={() => addToGroupBy(columnName, true)} />
<MenuItem <MenuItem
text={`TRUNCATE("${columnName}", 1) AS "${columnName}_truncated"`} text={`"${columnName}"`}
onClick={() => onClick={() => {
addFunctionToGroupBy( onQueryChange(parsedQuery.addToGroupBy(columnName), true);
'TRUNCATE', }}
/>
<MenuItem
text={`TRUNC("${columnName}", -1) AS "${columnName}_trunc"`}
onClick={() => {
onQueryChange(
parsedQuery.addFunctionToGroupBy(
'TRUNC',
[' '], [' '],
[ [
new StringType({ new StringType({
@ -101,56 +90,62 @@ export class NumberMenuItems extends React.PureComponent<NumberMenuItemsProps> {
chars: columnName, chars: columnName,
quote: '"', quote: '"',
}), }),
1, -1,
], ],
true,
aliasFactory(`${columnName}_truncated`), aliasFactory(`${columnName}_truncated`),
) ),
} true,
);
}}
/> />
</MenuItem> </MenuItem>
); );
} }
renderAggregateMenu(): JSX.Element { renderAggregateMenu(): JSX.Element | undefined {
const { columnName, addAggregateColumn } = this.props; const { columnName, parsedQuery, onQueryChange } = this.props;
if (!parsedQuery.groupByClause) return;
return ( return (
<MenuItem icon={IconNames.FUNCTION} text={`Aggregate`}> <MenuItem icon={IconNames.FUNCTION} text={`Aggregate`}>
<MenuItem <MenuItem
text={`SUM(${columnName}) AS "sum_${columnName}"`} text={`SUM(${columnName}) AS "sum_${columnName}"`}
onClick={() => onClick={() => {
addAggregateColumn(columnName, 'SUM', true, aliasFactory(`sum_${columnName}`)) onQueryChange(
} parsedQuery.addAggregateColumn(columnName, 'SUM', aliasFactory(`sum_${columnName}`)),
true,
);
}}
/> />
<MenuItem <MenuItem
text={`MAX(${columnName}) AS "max_${columnName}"`} text={`MAX(${columnName}) AS "max_${columnName}"`}
onClick={() => onClick={() => {
addAggregateColumn(columnName, 'MAX', true, aliasFactory(`max_${columnName}`)) onQueryChange(
} parsedQuery.addAggregateColumn(columnName, 'MAX', aliasFactory(`max_${columnName}`)),
true,
);
}}
/> />
<MenuItem <MenuItem
text={`MIN(${columnName}) AS "min_${columnName}"`} text={`MIN(${columnName}) AS "min_${columnName}"`}
onClick={() => onClick={() => {
addAggregateColumn(columnName, 'MIN', true, aliasFactory(`min_${columnName}`)) onQueryChange(
} parsedQuery.addAggregateColumn(columnName, 'MIN', aliasFactory(`min_${columnName}`)),
true,
);
}}
/> />
</MenuItem> </MenuItem>
); );
} }
render(): JSX.Element { render(): JSX.Element {
const { queryAst, hasFilter } = this.props;
let hasGroupBy;
if (queryAst) {
hasGroupBy = queryAst.groupByClause;
}
return ( return (
<> <>
{queryAst && this.renderFilterMenu()} {this.renderFilterMenu()}
{hasFilter && this.renderRemoveFilter()} {this.renderRemoveFilter()}
{hasGroupBy && this.renderGroupByMenu()} {this.renderGroupByMenu()}
{hasGroupBy && this.renderAggregateMenu()} {this.renderAggregateMenu()}
</> </>
); );
} }

View File

@ -28,14 +28,9 @@ describe('string menu', () => {
it('matches snapshot', () => { it('matches snapshot', () => {
const stringMenu = ( const stringMenu = (
<StringMenuItems <StringMenuItems
hasFilter
clear={() => null}
addFunctionToGroupBy={() => null}
addToGroupBy={() => null}
addAggregateColumn={() => null}
filterByRow={() => null}
columnName={'channel'} columnName={'channel'}
queryAst={parser(`SELECT channel, count(*) as cnt FROM wikipedia GROUP BY 1`)} parsedQuery={parser(`SELECT channel, count(*) as cnt FROM wikipedia GROUP BY 1`)}
onQueryChange={() => {}}
/> />
); );

View File

@ -19,121 +19,116 @@
import { MenuItem } from '@blueprintjs/core'; import { MenuItem } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons'; import { IconNames } from '@blueprintjs/icons';
import { import {
Alias,
ComparisonExpression, ComparisonExpression,
ComparisonExpressionRhs, ComparisonExpressionRhs,
FilterClause, FilterClause,
RefExpression,
refExpressionFactory, refExpressionFactory,
SqlQuery, SqlQuery,
StringType,
WhereClause, WhereClause,
} from 'druid-query-toolkit'; } from 'druid-query-toolkit';
import { aliasFactory, stringFactory } from 'druid-query-toolkit/build/ast/sql-query/helpers'; import { aliasFactory, stringFactory } from 'druid-query-toolkit/build/ast/sql-query/helpers';
import React from 'react'; import React from 'react';
import { RowFilter } from '../../../query-view';
export interface StringMenuItemsProps { 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; columnName: string;
clear: (column: string, preferablyRun: boolean) => void; parsedQuery: SqlQuery;
hasFilter: boolean; onQueryChange: (queryString: SqlQuery, run?: boolean) => void;
} }
export class StringMenuItems extends React.PureComponent<StringMenuItemsProps> { export class StringMenuItems extends React.PureComponent<StringMenuItemsProps> {
constructor(props: StringMenuItemsProps, context: any) { renderFilterMenu(): JSX.Element | undefined {
super(props, context); const { columnName, parsedQuery, onQueryChange } = this.props;
}
renderFilterMenu(): JSX.Element {
const { columnName, filterByRow } = this.props;
return ( return (
<MenuItem icon={IconNames.FILTER} text={`Filter`}> <MenuItem icon={IconNames.FILTER} text={`Filter`}>
<MenuItem <MenuItem
text={`"${columnName}" = 'xxx'`} text={`"${columnName}" = 'xxx'`}
onClick={() => filterByRow([{ row: 'xxx', header: columnName, operator: '=' }], false)} onClick={() => {
onQueryChange(parsedQuery.filterRow(columnName, 'xxx', '='), false);
}}
/> />
<MenuItem <MenuItem
text={`"${columnName}" LIKE '%xxx%'`} text={`"${columnName}" LIKE '%xxx%'`}
onClick={() => onClick={() => {
filterByRow([{ row: '%xxx%', header: columnName, operator: 'LIKE' }], false) onQueryChange(parsedQuery.filterRow(columnName, '%xxx%', 'LIKE'), false);
} }}
/> />
</MenuItem> </MenuItem>
); );
} }
renderRemoveFilter() { renderRemoveFilter(): JSX.Element | undefined {
const { columnName, clear } = this.props; const { columnName, parsedQuery, onQueryChange } = this.props;
if (!parsedQuery.hasFilterForColumn(columnName)) return;
return ( return (
<MenuItem <MenuItem
icon={IconNames.FILTER_REMOVE} icon={IconNames.FILTER_REMOVE}
text={`Remove filter`} text={`Remove filter`}
onClick={() => { onClick={() => {
clear(columnName, true); onQueryChange(parsedQuery.removeFilter(columnName), true);
}} }}
/> />
); );
} }
renderGroupByMenu(): JSX.Element { renderGroupByMenu(): JSX.Element | undefined {
const { columnName, addFunctionToGroupBy, addToGroupBy } = this.props; const { columnName, parsedQuery, onQueryChange } = this.props;
if (!parsedQuery.hasGroupBy()) return;
return ( return (
<MenuItem icon={IconNames.GROUP_OBJECTS} text={`Group by`}> <MenuItem icon={IconNames.GROUP_OBJECTS} text={`Group by`}>
<MenuItem text={`"${columnName}"`} onClick={() => addToGroupBy(columnName, true)} /> <MenuItem
text={`"${columnName}"`}
onClick={() => {
onQueryChange(parsedQuery.addToGroupBy(columnName), true);
}}
/>
<MenuItem <MenuItem
text={`SUBSTRING("${columnName}", 1, 2) AS "${columnName}_substring"`} text={`SUBSTRING("${columnName}", 1, 2) AS "${columnName}_substring"`}
onClick={() => onClick={() => {
addFunctionToGroupBy( onQueryChange(
parsedQuery.addFunctionToGroupBy(
'SUBSTRING', 'SUBSTRING',
[' ', ' '], [' ', ' '],
[stringFactory(columnName, `"`), 1, 2], [stringFactory(columnName, `"`), 1, 2],
true,
aliasFactory(`${columnName}_substring`), aliasFactory(`${columnName}_substring`),
) ),
} true,
);
}}
/> />
</MenuItem> </MenuItem>
); );
} }
renderAggregateMenu(): JSX.Element { renderAggregateMenu(): JSX.Element | undefined {
const { columnName, addAggregateColumn } = this.props; const { columnName, parsedQuery, onQueryChange } = this.props;
if (!parsedQuery.hasGroupBy()) return;
return ( return (
<MenuItem icon={IconNames.FUNCTION} text={`Aggregate`}> <MenuItem icon={IconNames.FUNCTION} text={`Aggregate`}>
<MenuItem <MenuItem
text={`COUNT(DISTINCT "${columnName}") AS "dist_${columnName}"`} text={`COUNT(DISTINCT "${columnName}") AS "dist_${columnName}"`}
onClick={() => onClick={() =>
addAggregateColumn(columnName, 'COUNT', true, aliasFactory(`dist_${columnName}`), true) onQueryChange(
parsedQuery.addAggregateColumn(
columnName,
'COUNT',
aliasFactory(`dist_${columnName}`),
),
true,
)
} }
/> />
<MenuItem <MenuItem
text={`COUNT(*) FILTER (WHERE "${columnName}" = 'xxx') AS ${columnName}_filtered_count `} text={`COUNT(*) FILTER (WHERE "${columnName}" = 'xxx') AS ${columnName}_filtered_count `}
onClick={() => onClick={() => {
addAggregateColumn( onQueryChange(
parsedQuery.addAggregateColumn(
refExpressionFactory('*'), refExpressionFactory('*'),
'COUNT', 'COUNT',
false,
aliasFactory(`${columnName}_filtered_count`), aliasFactory(`${columnName}_filtered_count`),
false, false,
new FilterClause({ new FilterClause({
@ -154,25 +149,21 @@ export class StringMenuItems extends React.PureComponent<StringMenuItemsProps> {
}), }),
}), }),
}), }),
) ),
} );
}}
/> />
</MenuItem> </MenuItem>
); );
} }
render(): JSX.Element { render(): JSX.Element {
const { queryAst, hasFilter } = this.props;
let hasGroupBy;
if (queryAst) {
hasGroupBy = queryAst.groupByClause;
}
return ( return (
<> <>
{queryAst && this.renderFilterMenu()} {this.renderFilterMenu()}
{hasFilter && this.renderRemoveFilter()} {this.renderRemoveFilter()}
{hasGroupBy && this.renderGroupByMenu()} {this.renderGroupByMenu()}
{hasGroupBy && this.renderAggregateMenu()} {this.renderAggregateMenu()}
</> </>
); );
} }

View File

@ -28,14 +28,9 @@ describe('time menu', () => {
it('matches snapshot', () => { it('matches snapshot', () => {
const timeMenu = ( const timeMenu = (
<TimeMenuItems <TimeMenuItems
hasFilter
clear={() => null}
addFunctionToGroupBy={() => null}
addToGroupBy={() => null}
addAggregateColumn={() => null}
filterByRow={() => null}
columnName={'__time'} columnName={'__time'}
queryAst={parser(`SELECT channel, count(*) as cnt FROM wikipedia GROUP BY 1`)} parsedQuery={parser(`SELECT channel, count(*) as cnt FROM wikipedia GROUP BY 1`)}
onQueryChange={() => {}}
/> />
); );

View File

@ -18,14 +18,7 @@
import { MenuDivider, MenuItem } from '@blueprintjs/core'; import { MenuDivider, MenuItem } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons'; import { IconNames } from '@blueprintjs/icons';
import { import { AdditiveExpression, SqlQuery, Timestamp, timestampFactory } from 'druid-query-toolkit';
AdditiveExpression,
Alias,
FilterClause,
SqlQuery,
StringType,
timestampFactory,
} from 'druid-query-toolkit';
import { import {
aliasFactory, aliasFactory,
intervalFactory, intervalFactory,
@ -34,107 +27,76 @@ import {
} from 'druid-query-toolkit/build/ast/sql-query/helpers'; } from 'druid-query-toolkit/build/ast/sql-query/helpers';
import React from 'react'; import React from 'react';
import { RowFilter } from '../../../query-view';
export interface TimeMenuItemsProps { 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; columnName: string;
clear: (column: string, preferablyRun: boolean) => void; parsedQuery: SqlQuery;
hasFilter: boolean; onQueryChange: (queryString: SqlQuery, run?: boolean) => void;
} }
export class TimeMenuItems extends React.PureComponent<TimeMenuItemsProps> { export class TimeMenuItems extends React.PureComponent<TimeMenuItemsProps> {
constructor(props: TimeMenuItemsProps, context: any) { static dateToTimestamp(date: Date): Timestamp {
super(props, context); return timestampFactory(
date
.toISOString()
.split('.')[0]
.split('T')
.join(' '),
);
} }
formatTime(timePart: number): string { static floorHour(dt: Date): Date {
if (timePart % 10 > 0) { dt = new Date(dt.valueOf());
return String(timePart); dt.setUTCMinutes(0, 0, 0);
} else return '0' + String(timePart); return dt;
} }
getNextMonth(month: number, year: number): { month: string; year: number } { static nextHour(dt: Date): Date {
if (month === 12) { dt = new Date(dt.valueOf());
return { month: '01', year: year + 1 }; dt.setUTCHours(dt.getUTCHours() + 1);
} return dt;
return { month: this.formatTime(month + 1), year: year };
} }
getNextDay( static floorDay(dt: Date): Date {
day: number, dt = new Date(dt.valueOf());
month: number, dt.setUTCHours(0, 0, 0, 0);
year: number, return dt;
): { 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( static nextDay(dt: Date): Date {
hour: number, dt = new Date(dt.valueOf());
day: number, dt.setUTCDate(dt.getUTCDate() + 1);
month: number, return dt;
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 { static floorMonth(dt: Date): Date {
const { columnName, filterByRow, clear } = this.props; dt = new Date(dt.valueOf());
const date = new Date(); dt.setUTCHours(0, 0, 0, 0);
const year = date.getFullYear(); dt.setUTCDate(1);
const month = date.getMonth(); return dt;
const day = date.getDay(); }
const hour = date.getHours();
static nextMonth(dt: Date): Date {
dt = new Date(dt.valueOf());
dt.setUTCMonth(dt.getUTCMonth() + 1);
return dt;
}
static floorYear(dt: Date): Date {
dt = new Date(dt.valueOf());
dt.setUTCHours(0, 0, 0, 0);
dt.setUTCMonth(0, 1);
return dt;
}
static nextYear(dt: Date): Date {
dt = new Date(dt.valueOf());
dt.setUTCFullYear(dt.getUTCFullYear() + 1);
return dt;
}
renderFilterMenu(): JSX.Element | undefined {
const { columnName, parsedQuery, onQueryChange } = this.props;
const now = new Date();
return ( return (
<MenuItem icon={IconNames.FILTER} text={`Filter`}> <MenuItem icon={IconNames.FILTER} text={`Filter`}>
@ -147,8 +109,10 @@ export class TimeMenuItems extends React.PureComponent<TimeMenuItemsProps> {
ex: [refExpressionFactory('CURRENT_TIMESTAMP'), intervalFactory('HOUR', '1')], ex: [refExpressionFactory('CURRENT_TIMESTAMP'), intervalFactory('HOUR', '1')],
spacing: [' ', ' '], spacing: [' ', ' '],
}); });
clear(columnName, false); onQueryChange(
filterByRow([{ row: additiveExpression, header: columnName, operator: '>=' }], true); parsedQuery.removeFilter(columnName).filterRow(columnName, additiveExpression, '>='),
true,
);
}} }}
/> />
<MenuItem <MenuItem
@ -160,8 +124,10 @@ export class TimeMenuItems extends React.PureComponent<TimeMenuItemsProps> {
ex: [refExpressionFactory('CURRENT_TIMESTAMP'), intervalFactory('DAY', '1')], ex: [refExpressionFactory('CURRENT_TIMESTAMP'), intervalFactory('DAY', '1')],
spacing: [' ', ' '], spacing: [' ', ' '],
}); });
clear(columnName, false); onQueryChange(
filterByRow([{ row: additiveExpression, header: columnName, operator: '>=' }], true); parsedQuery.removeFilter(columnName).filterRow(columnName, additiveExpression, '>='),
true,
);
}} }}
/> />
<MenuItem <MenuItem
@ -173,8 +139,10 @@ export class TimeMenuItems extends React.PureComponent<TimeMenuItemsProps> {
ex: [refExpressionFactory('CURRENT_TIMESTAMP'), intervalFactory('DAY', '7')], ex: [refExpressionFactory('CURRENT_TIMESTAMP'), intervalFactory('DAY', '7')],
spacing: [' ', ' '], spacing: [' ', ' '],
}); });
clear(columnName, false); onQueryChange(
filterByRow([{ row: additiveExpression, header: columnName, operator: '>=' }], true); parsedQuery.removeFilter(columnName).filterRow(columnName, additiveExpression, '>='),
true,
);
}} }}
/> />
<MenuItem <MenuItem
@ -186,8 +154,10 @@ export class TimeMenuItems extends React.PureComponent<TimeMenuItemsProps> {
ex: [refExpressionFactory('CURRENT_TIMESTAMP'), intervalFactory('MONTH', '1')], ex: [refExpressionFactory('CURRENT_TIMESTAMP'), intervalFactory('MONTH', '1')],
spacing: [' ', ' '], spacing: [' ', ' '],
}); });
clear(columnName, false); onQueryChange(
filterByRow([{ row: additiveExpression, header: columnName, operator: '>=' }], true); parsedQuery.removeFilter(columnName).filterRow(columnName, additiveExpression, '>='),
true,
);
}} }}
/> />
<MenuItem <MenuItem
@ -199,33 +169,30 @@ export class TimeMenuItems extends React.PureComponent<TimeMenuItemsProps> {
ex: [refExpressionFactory('CURRENT_TIMESTAMP'), intervalFactory('YEAR', '1')], ex: [refExpressionFactory('CURRENT_TIMESTAMP'), intervalFactory('YEAR', '1')],
spacing: [' ', ' '], spacing: [' ', ' '],
}); });
clear(columnName, false); onQueryChange(
filterByRow([{ row: additiveExpression, header: columnName, operator: '>=' }], true); parsedQuery.removeFilter(columnName).filterRow(columnName, additiveExpression, '>='),
true,
);
}} }}
/> />
<MenuDivider /> <MenuDivider />
<MenuItem <MenuItem
text={`Current hour`} text={`Current hour`}
onClick={() => { onClick={() => {
const next = this.getNextHour(hour, day, month, year); const hourStart = TimeMenuItems.floorHour(now);
clear(columnName, false); onQueryChange(
filterByRow( parsedQuery
[ .removeFilter(columnName)
{ .filterRow(
row: stringFactory(columnName, `"`), TimeMenuItems.dateToTimestamp(hourStart),
header: timestampFactory( stringFactory(columnName, `"`),
`${year}-${month}-${day} ${this.formatTime(hour)}:00:00`, '<=',
)
.filterRow(
columnName,
TimeMenuItems.dateToTimestamp(TimeMenuItems.nextHour(hourStart)),
'<',
), ),
operator: '<=',
},
{
row: timestampFactory(
`${next.year}-${next.month}-${next.day} ${next.hour}:00:00`,
),
header: columnName,
operator: '<',
},
],
true, true,
); );
}} }}
@ -233,21 +200,20 @@ export class TimeMenuItems extends React.PureComponent<TimeMenuItemsProps> {
<MenuItem <MenuItem
text={`Current day`} text={`Current day`}
onClick={() => { onClick={() => {
const next = this.getNextDay(day, month, year); const dayStart = TimeMenuItems.floorDay(now);
clear(columnName, false); onQueryChange(
filterByRow( parsedQuery
[ .removeFilter(columnName)
{ .filterRow(
row: stringFactory(columnName, `"`), TimeMenuItems.dateToTimestamp(dayStart),
header: timestampFactory(`${year}-${month}-${day} 00:00:00`), stringFactory(columnName, `"`),
operator: '<=', '<=',
}, )
{ .filterRow(
row: timestampFactory(`${next.year}-${next.month}-${next.day} 00:00:00`), columnName,
header: columnName, TimeMenuItems.dateToTimestamp(TimeMenuItems.nextDay(dayStart)),
operator: '<', '<',
}, ),
],
true, true,
); );
}} }}
@ -255,21 +221,20 @@ export class TimeMenuItems extends React.PureComponent<TimeMenuItemsProps> {
<MenuItem <MenuItem
text={`Current month`} text={`Current month`}
onClick={() => { onClick={() => {
const next = this.getNextMonth(month, year); const monthStart = TimeMenuItems.floorMonth(now);
clear(columnName, false); onQueryChange(
filterByRow( parsedQuery
[ .removeFilter(columnName)
{ .filterRow(
row: stringFactory(columnName, `"`), TimeMenuItems.dateToTimestamp(monthStart),
header: timestampFactory(`${year}-${month}-01 00:00:00`), stringFactory(columnName, `"`),
operator: '<=', '<=',
}, )
{ .filterRow(
row: timestampFactory(`${next.year}-${next.month}-01 00:00:00`), columnName,
header: columnName, TimeMenuItems.dateToTimestamp(TimeMenuItems.nextMonth(monthStart)),
operator: '<', '<',
}, ),
],
true, true,
); );
}} }}
@ -277,20 +242,20 @@ export class TimeMenuItems extends React.PureComponent<TimeMenuItemsProps> {
<MenuItem <MenuItem
text={`Current year`} text={`Current year`}
onClick={() => { onClick={() => {
clear(columnName, false); const yearStart = TimeMenuItems.floorYear(now);
filterByRow( onQueryChange(
[ parsedQuery
{ .removeFilter(columnName)
row: stringFactory(columnName, `"`), .filterRow(
header: timestampFactory(`${year}-01-01 00:00:00`), TimeMenuItems.dateToTimestamp(yearStart),
operator: '<=', stringFactory(columnName, `"`),
}, '<=',
{ )
row: timestampFactory(`${Number(year) + 1}-01-01 00:00:00`), .filterRow(
header: columnName, columnName,
operator: '<', TimeMenuItems.dateToTimestamp(TimeMenuItems.nextYear(yearStart)),
}, '<',
], ),
true, true,
); );
}} }}
@ -299,96 +264,108 @@ export class TimeMenuItems extends React.PureComponent<TimeMenuItemsProps> {
); );
} }
renderRemoveFilter() { renderRemoveFilter(): JSX.Element | undefined {
const { columnName, clear } = this.props; const { columnName, parsedQuery, onQueryChange } = this.props;
if (!parsedQuery.hasFilterForColumn(columnName)) return;
return ( return (
<MenuItem <MenuItem
icon={IconNames.FILTER_REMOVE} icon={IconNames.FILTER_REMOVE}
text={`Remove filter`} text={`Remove filter`}
onClick={() => { onClick={() => {
clear(columnName, true); onQueryChange(parsedQuery.removeFilter(columnName), true);
}} }}
/> />
); );
} }
renderGroupByMenu(): JSX.Element { renderGroupByMenu(): JSX.Element | undefined {
const { columnName, addFunctionToGroupBy } = this.props; const { columnName, parsedQuery, onQueryChange } = this.props;
if (!parsedQuery.hasGroupBy()) return;
return ( return (
<MenuItem icon={IconNames.GROUP_OBJECTS} text={`Group by`}> <MenuItem icon={IconNames.GROUP_OBJECTS} text={`Group by`}>
<MenuItem <MenuItem
text={`TIME_FLOOR("${columnName}", 'PT1H') AS "${columnName}_time_floor"`} text={`TIME_FLOOR("${columnName}", 'PT1H') AS "${columnName}_time_floor"`}
onClick={() => onClick={() => {
addFunctionToGroupBy( onQueryChange(
parsedQuery.addFunctionToGroupBy(
'TIME_FLOOR', 'TIME_FLOOR',
[' '], [' '],
[stringFactory(columnName, `"`), stringFactory('PT1H', `'`)], [stringFactory(columnName, `"`), stringFactory('PT1H', `'`)],
true,
aliasFactory(`${columnName}_time_floor`), aliasFactory(`${columnName}_time_floor`),
) ),
} true,
);
}}
/> />
<MenuItem <MenuItem
text={`TIME_FLOOR("${columnName}", 'P1D') AS "${columnName}_time_floor"`} text={`TIME_FLOOR("${columnName}", 'P1D') AS "${columnName}_time_floor"`}
onClick={() => onClick={() => {
addFunctionToGroupBy( onQueryChange(
parsedQuery.addFunctionToGroupBy(
'TIME_FLOOR', 'TIME_FLOOR',
[' '], [' '],
[stringFactory(columnName, `"`), stringFactory('P1D', `'`)], [stringFactory(columnName, `"`), stringFactory('P1D', `'`)],
true,
aliasFactory(`${columnName}_time_floor`), aliasFactory(`${columnName}_time_floor`),
) ),
} true,
);
}}
/> />
<MenuItem <MenuItem
text={`TIME_FLOOR("${columnName}", 'P7D') AS "${columnName}_time_floor"`} text={`TIME_FLOOR("${columnName}", 'P7D') AS "${columnName}_time_floor"`}
onClick={() => onClick={() => {
addFunctionToGroupBy( onQueryChange(
parsedQuery.addFunctionToGroupBy(
'TIME_FLOOR', 'TIME_FLOOR',
[' '], [' '],
[stringFactory(columnName, `"`), stringFactory('P7D', `'`)], [stringFactory(columnName, `"`), stringFactory('P7D', `'`)],
true,
aliasFactory(`${columnName}_time_floor`), aliasFactory(`${columnName}_time_floor`),
) ),
} true,
);
}}
/> />
</MenuItem> </MenuItem>
); );
} }
renderAggregateMenu(): JSX.Element { renderAggregateMenu(): JSX.Element | undefined {
const { columnName, addAggregateColumn } = this.props; const { columnName, parsedQuery, onQueryChange } = this.props;
if (!parsedQuery.hasGroupBy()) return;
return ( return (
<MenuItem icon={IconNames.FUNCTION} text={`Aggregate`}> <MenuItem icon={IconNames.FUNCTION} text={`Aggregate`}>
<MenuItem <MenuItem
text={`MAX("${columnName}") AS "max_${columnName}"`} text={`MAX("${columnName}") AS "max_${columnName}"`}
onClick={() => onClick={() => {
addAggregateColumn(columnName, 'MAX', true, aliasFactory(`max_${columnName}`)) onQueryChange(
} parsedQuery.addAggregateColumn(columnName, 'MAX', aliasFactory(`max_${columnName}`)),
true,
);
}}
/> />
<MenuItem <MenuItem
text={`MIN("${columnName}") AS "min_${columnName}"`} text={`MIN("${columnName}") AS "min_${columnName}"`}
onClick={() => onClick={() => {
addAggregateColumn(columnName, 'MIN', true, aliasFactory(`min_${columnName}`)) onQueryChange(
} parsedQuery.addAggregateColumn(columnName, 'MIN', aliasFactory(`min_${columnName}`)),
true,
);
}}
/> />
</MenuItem> </MenuItem>
); );
} }
render(): JSX.Element { render(): JSX.Element {
const { queryAst, hasFilter } = this.props;
let hasGroupBy;
if (queryAst) {
hasGroupBy = queryAst.groupByClause;
}
return ( return (
<> <>
{queryAst && this.renderFilterMenu()} {this.renderFilterMenu()}
{hasFilter && this.renderRemoveFilter()} {this.renderRemoveFilter()}
{hasGroupBy && this.renderGroupByMenu()} {this.renderGroupByMenu()}
{hasGroupBy && this.renderAggregateMenu()} {this.renderAggregateMenu()}
</> </>
); );
} }

View File

@ -17,6 +17,7 @@
*/ */
import { render } from '@testing-library/react'; import { render } from '@testing-library/react';
import { sqlParserFactory } from 'druid-query-toolkit';
import React from 'react'; import React from 'react';
import { ColumnMetadata } from '../../../utils/column-metadata'; import { ColumnMetadata } from '../../../utils/column-metadata';
@ -24,30 +25,37 @@ import { ColumnMetadata } from '../../../utils/column-metadata';
import { ColumnTree } from './column-tree'; import { ColumnTree } from './column-tree';
describe('column tree', () => { describe('column tree', () => {
const parser = sqlParserFactory(['COUNT']);
it('matches snapshot', () => { it('matches snapshot', () => {
const columnTree = ( const columnTree = (
<ColumnTree <ColumnTree
queryAst={() => undefined} getParsedQuery={() => {
clear={() => null} return parser(`SELECT channel, count(*) as cnt FROM wikipedia GROUP BY 1`);
addFunctionToGroupBy={() => null} }}
filterByRow={() => null} defaultSchema="druid"
addAggregateColumn={() => null} defaultTable="wikipedia"
addToGroupBy={() => null}
columnMetadataLoading={false} columnMetadataLoading={false}
columnMetadata={ columnMetadata={
[ [
{ {
TABLE_SCHEMA: 'druid', TABLE_SCHEMA: 'druid',
TABLE_NAME: 'deletion-tutorial', TABLE_NAME: 'wikipedia',
COLUMN_NAME: '__time', COLUMN_NAME: '__time',
DATA_TYPE: 'TIMESTAMP', DATA_TYPE: 'TIMESTAMP',
}, },
{ {
TABLE_SCHEMA: 'druid', TABLE_SCHEMA: 'druid',
TABLE_NAME: 'deletion-tutorial', TABLE_NAME: 'wikipedia',
COLUMN_NAME: 'added', COLUMN_NAME: 'added',
DATA_TYPE: 'BIGINT', DATA_TYPE: 'BIGINT',
}, },
{
TABLE_SCHEMA: 'druid',
TABLE_NAME: 'wikipedia',
COLUMN_NAME: 'addedBy10',
DATA_TYPE: 'FLOAT',
},
{ {
TABLE_SCHEMA: 'sys', TABLE_SCHEMA: 'sys',
TABLE_NAME: 'tasks', TABLE_NAME: 'tasks',
@ -57,7 +65,6 @@ describe('column tree', () => {
] as ColumnMetadata[] ] as ColumnMetadata[]
} }
onQueryStringChange={() => {}} onQueryStringChange={() => {}}
replaceFrom={() => null}
/> />
); );

View File

@ -27,26 +27,15 @@ import {
Tree, Tree,
} from '@blueprintjs/core'; } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons'; import { IconNames } from '@blueprintjs/icons';
import { import { refExpressionFactory, SqlQuery, stringFactory } from 'druid-query-toolkit';
Alias,
FilterClause,
RefExpression,
refExpressionFactory,
SqlQuery,
stringFactory,
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 { 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 { NumberMenuItems, StringMenuItems, TimeMenuItems } from './column-tree-menu';
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';
@ -123,29 +112,10 @@ ORDER BY "Count" DESC`,
export interface ColumnTreeProps { export interface ColumnTreeProps {
columnMetadataLoading: boolean; columnMetadataLoading: boolean;
columnMetadata?: readonly ColumnMetadata[]; columnMetadata?: readonly ColumnMetadata[];
onQueryStringChange: (queryString: string, run: boolean) => void; getParsedQuery: () => SqlQuery | undefined;
onQueryStringChange: (queryString: string | SqlQuery, run?: boolean) => 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;
replaceFrom: (table: RefExpression, preferablyRun: boolean) => void;
queryAst: () => SqlQuery | undefined;
clear: (column: string, preferablyRun: boolean) => void;
} }
export interface ColumnTreeState { export interface ColumnTreeState {
@ -169,7 +139,7 @@ export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeS
childNodes: groupBy( childNodes: groupBy(
metadata, metadata,
r => r.TABLE_NAME, r => r.TABLE_NAME,
(metadata, table) => ({ (metadata, table): ITreeNode => ({
id: table, id: table,
icon: IconNames.TH, icon: IconNames.TH,
label: ( label: (
@ -177,6 +147,10 @@ export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeS
boundary={'window'} boundary={'window'}
position={Position.RIGHT} position={Position.RIGHT}
content={ content={
<Deferred
content={() => {
const parsedQuery = props.getParsedQuery();
return (
<Menu> <Menu>
<MenuItem <MenuItem
icon={IconNames.FULLSCREEN} icon={IconNames.FULLSCREEN}
@ -205,31 +179,32 @@ export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeS
copyAndAlert(table, `${table} query copied to clipboard`); copyAndAlert(table, `${table} query copied to clipboard`);
}} }}
/> />
<Deferred {parsedQuery && (
content={() => (
<>
{props.queryAst() && (
<MenuItem <MenuItem
icon={IconNames.EXCHANGE} icon={IconNames.EXCHANGE}
text={`Replace FROM with: ${table}`} text={`Replace FROM with: ${table}`}
onClick={() => { onClick={() => {
props.replaceFrom( props.onQueryStringChange(
parsedQuery.replaceFrom(
refExpressionFactory(stringFactory(table, `"`)), refExpressionFactory(stringFactory(table, `"`)),
),
true, true,
); );
}} }}
/> />
)} )}
</>
)}
/>
</Menu> </Menu>
);
}}
/>
} }
> >
<div>{table}</div> <div>{table}</div>
</Popover> </Popover>
), ),
childNodes: metadata.map(columnData => ({ childNodes: metadata
.map(
(columnData): ITreeNode => ({
id: columnData.COLUMN_NAME, id: columnData.COLUMN_NAME,
icon: ColumnTree.dataTypeToIcon(columnData.DATA_TYPE), icon: ColumnTree.dataTypeToIcon(columnData.DATA_TYPE),
label: ( label: (
@ -241,11 +216,7 @@ export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeS
content={ content={
<Deferred <Deferred
content={() => { content={() => {
const queryAst = props.queryAst(); const parsedQuery = props.getParsedQuery();
const hasFilter = queryAst
? queryAst.getCurrentFilters().includes(columnData.COLUMN_NAME)
: false;
return ( return (
<Menu> <Menu>
<MenuItem <MenuItem
@ -264,40 +235,27 @@ export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeS
); );
}} }}
/> />
{columnData.DATA_TYPE === 'BIGINT' && ( {parsedQuery &&
(columnData.DATA_TYPE === 'BIGINT' ||
columnData.DATA_TYPE === 'FLOAT') && (
<NumberMenuItems <NumberMenuItems
addFunctionToGroupBy={props.addFunctionToGroupBy}
addToGroupBy={props.addToGroupBy}
addAggregateColumn={props.addAggregateColumn}
filterByRow={props.filterByRow}
columnName={columnData.COLUMN_NAME} columnName={columnData.COLUMN_NAME}
queryAst={props.queryAst()} parsedQuery={parsedQuery}
clear={props.clear} onQueryChange={props.onQueryStringChange}
hasFilter={hasFilter}
/> />
)} )}
{columnData.DATA_TYPE === 'VARCHAR' && ( {parsedQuery && columnData.DATA_TYPE === 'VARCHAR' && (
<StringMenuItems <StringMenuItems
addFunctionToGroupBy={props.addFunctionToGroupBy}
addToGroupBy={props.addToGroupBy}
addAggregateColumn={props.addAggregateColumn}
filterByRow={props.filterByRow}
columnName={columnData.COLUMN_NAME} columnName={columnData.COLUMN_NAME}
queryAst={props.queryAst()} parsedQuery={parsedQuery}
clear={props.clear} onQueryChange={props.onQueryStringChange}
hasFilter={hasFilter}
/> />
)} )}
{columnData.DATA_TYPE === 'TIMESTAMP' && ( {parsedQuery && columnData.DATA_TYPE === 'TIMESTAMP' && (
<TimeMenuItems <TimeMenuItems
clear={props.clear}
addFunctionToGroupBy={props.addFunctionToGroupBy}
addToGroupBy={props.addToGroupBy}
addAggregateColumn={props.addAggregateColumn}
filterByRow={props.filterByRow}
columnName={columnData.COLUMN_NAME} columnName={columnData.COLUMN_NAME}
queryAst={props.queryAst()} parsedQuery={parsedQuery}
hasFilter={hasFilter} onQueryChange={props.onQueryStringChange}
/> />
)} )}
<MenuItem <MenuItem
@ -319,7 +277,13 @@ export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeS
<div>{columnData.COLUMN_NAME}</div> <div>{columnData.COLUMN_NAME}</div>
</Popover> </Popover>
), ),
})), }),
)
.sort((a, b) =>
String(a.id)
.toLowerCase()
.localeCompare(String(b.id).toLowerCase()),
),
}), }),
), ),
}), }),
@ -370,6 +334,7 @@ export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeS
case 'VARCHAR': case 'VARCHAR':
return IconNames.FONT; return IconNames.FONT;
case 'BIGINT': case 'BIGINT':
case 'FLOAT':
return IconNames.NUMERICAL; return IconNames.NUMERICAL;
default: default:
return IconNames.HELP; return IconNames.HELP;

View File

@ -24,14 +24,7 @@ import { QueryOutput } from './query-output';
describe('query output', () => { describe('query output', () => {
it('matches snapshot', () => { it('matches snapshot', () => {
const queryOutput = ( const queryOutput = (
<QueryOutput <QueryOutput runeMode={false} onQueryChange={() => {}} loading={false} error="lol" />
runeMode={false}
sqlOrderBy={() => null}
sqlFilterRow={() => null}
sqlExcludeColumn={() => null}
loading={false}
error="lol"
/>
); );
const { container } = render(queryOutput); const { container } = render(queryOutput);

View File

@ -28,17 +28,14 @@ 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 {
loading: boolean; loading: boolean;
sqlFilterRow: (filters: RowFilter[], run: boolean) => void;
sqlExcludeColumn: (header: string, run: boolean) => void;
sqlOrderBy: (header: string, direction: 'ASC' | 'DESC', run: boolean) => void;
queryResult?: HeaderRows; queryResult?: HeaderRows;
parsedQuery?: SqlQuery; parsedQuery?: SqlQuery;
onQueryChange: (query: SqlQuery, run?: boolean) => void;
error?: string; error?: string;
runeMode: boolean; runeMode: boolean;
} }
@ -97,7 +94,7 @@ export class QueryOutput extends React.PureComponent<QueryOutputProps> {
); );
} }
getHeaderActions(h: string) { getHeaderActions(h: string) {
const { parsedQuery, sqlExcludeColumn, sqlOrderBy, runeMode } = this.props; const { parsedQuery, onQueryChange, runeMode } = this.props;
let actionsMenu; let actionsMenu;
if (parsedQuery) { if (parsedQuery) {
@ -110,7 +107,9 @@ export class QueryOutput extends React.PureComponent<QueryOutputProps> {
basicActions.push({ basicActions.push({
icon: sorted.desc ? IconNames.SORT_ASC : IconNames.SORT_DESC, icon: sorted.desc ? IconNames.SORT_ASC : IconNames.SORT_DESC,
title: `Order by: ${h} ${sorted.desc ? 'ASC' : 'DESC'}`, title: `Order by: ${h} ${sorted.desc ? 'ASC' : 'DESC'}`,
onAction: () => sqlOrderBy(h, sorted.desc ? 'ASC' : 'DESC', true), onAction: () => {
onQueryChange(parsedQuery.orderBy(h, sorted.desc ? 'ASC' : 'DESC'), true);
},
}); });
} }
}); });
@ -120,19 +119,25 @@ export class QueryOutput extends React.PureComponent<QueryOutputProps> {
{ {
icon: IconNames.SORT_ASC, icon: IconNames.SORT_ASC,
title: `Order by: ${h} ASC`, title: `Order by: ${h} ASC`,
onAction: () => sqlOrderBy(h, 'ASC', true), onAction: () => {
onQueryChange(parsedQuery.orderBy(h, 'ASC'), true);
},
}, },
{ {
icon: IconNames.SORT_DESC, icon: IconNames.SORT_DESC,
title: `Order by: ${h} DESC`, title: `Order by: ${h} DESC`,
onAction: () => sqlOrderBy(h, 'DESC', true), onAction: () => {
onQueryChange(parsedQuery.orderBy(h, 'DESC'), true);
},
}, },
); );
} }
basicActions.push({ basicActions.push({
icon: IconNames.CROSS, icon: IconNames.CROSS,
title: `Remove: ${h}`, title: `Remove: ${h}`,
onAction: () => sqlExcludeColumn(h, true), onAction: () => {
onQueryChange(parsedQuery.excludeColumn(h), true);
},
}); });
actionsMenu = basicActionsToMenu(basicActions); actionsMenu = basicActionsToMenu(basicActions);
} else { } else {
@ -176,7 +181,7 @@ export class QueryOutput extends React.PureComponent<QueryOutputProps> {
} }
getRowActions(row: string, header: string) { getRowActions(row: string, header: string) {
const { parsedQuery, sqlFilterRow, runeMode } = this.props; const { parsedQuery, onQueryChange, runeMode } = this.props;
let actionsMenu; let actionsMenu;
if (parsedQuery) { if (parsedQuery) {
@ -185,24 +190,32 @@ export class QueryOutput extends React.PureComponent<QueryOutputProps> {
<MenuItem <MenuItem
icon={IconNames.FILTER_KEEP} icon={IconNames.FILTER_KEEP}
text={`Filter by: ${header} = ${row}`} text={`Filter by: ${header} = ${row}`}
onClick={() => sqlFilterRow([{ row, header, operator: '=' }], true)} onClick={() => {
onQueryChange(parsedQuery.filterRow(header, row, '='), true);
}}
/> />
<MenuItem <MenuItem
icon={IconNames.FILTER_REMOVE} icon={IconNames.FILTER_REMOVE}
text={`Filter by: ${header} != ${row}`} text={`Filter by: ${header} != ${row}`}
onClick={() => sqlFilterRow([{ row, header, operator: '!=' }], true)} onClick={() => {
onQueryChange(parsedQuery.filterRow(header, row, '!='), true);
}}
/> />
{!isNaN(Number(row)) && ( {!isNaN(Number(row)) && (
<> <>
<MenuItem <MenuItem
icon={IconNames.FILTER_KEEP} icon={IconNames.FILTER_KEEP}
text={`Filter by: ${header} > ${row}`} text={`Filter by: ${header} >= ${row}`}
onClick={() => sqlFilterRow([{ row, header, operator: '>' }], true)} onClick={() => {
onQueryChange(parsedQuery.filterRow(header, row, '>='), true);
}}
/> />
<MenuItem <MenuItem
icon={IconNames.FILTER_KEEP} icon={IconNames.FILTER_KEEP}
text={`Filter by: ${header} <= ${row}`} text={`Filter by: ${header} <= ${row}`}
onClick={() => sqlFilterRow([{ row, header, operator: '<=' }], true)} onClick={() => {
onQueryChange(parsedQuery.filterRow(header, row, '<='), true);
}}
/> />
</> </>
)} )}

View File

@ -63,6 +63,7 @@ $nav-width: 250px;
margin-right: 15px; margin-right: 15px;
} }
.auto-run,
.smart-query-limit { .smart-query-limit {
display: inline-block; display: inline-block;
margin-bottom: 8px; margin-bottom: 8px;

View File

@ -20,18 +20,12 @@ import { Intent, Switch, Tooltip } 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 memoizeOne from 'memoize-one';
@ -51,8 +45,10 @@ import {
downloadFile, downloadFile,
getDruidErrorMessage, getDruidErrorMessage,
localStorageGet, localStorageGet,
localStorageGetJson,
LocalStorageKeys, LocalStorageKeys,
localStorageSet, localStorageSet,
localStorageSetJson,
parseQueryPlan, parseQueryPlan,
queryDruidSql, queryDruidSql,
QueryManager, QueryManager,
@ -89,15 +85,9 @@ 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; parsedQuery: SqlQuery;
queryContext: QueryContext; queryContext: QueryContext;
wrapQueryLimit: number | undefined; wrapQueryLimit: number | undefined;
autoRun: boolean; autoRun: boolean;
@ -196,32 +186,20 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
super(props, context); super(props, context);
const queryString = props.initQuery || localStorageGet(LocalStorageKeys.QUERY_KEY) || ''; const queryString = props.initQuery || localStorageGet(LocalStorageKeys.QUERY_KEY) || '';
const queryAst = queryString ? parser(queryString) : undefined; const parsedQuery = queryString ? parser(queryString) : undefined;
const localStorageQueryHistory = localStorageGet(LocalStorageKeys.QUERY_HISTORY); const queryContext = localStorageGetJson(LocalStorageKeys.QUERY_CONTEXT) || {};
let queryHistory = [];
if (localStorageQueryHistory) {
let possibleQueryHistory: unknown;
try {
possibleQueryHistory = JSON.parse(localStorageQueryHistory);
} catch {}
if (Array.isArray(possibleQueryHistory)) queryHistory = possibleQueryHistory;
}
const localStorageAutoRun = localStorageGet(LocalStorageKeys.AUTO_RUN); const possibleQueryHistory = localStorageGetJson(LocalStorageKeys.QUERY_HISTORY);
let autoRun = true; const queryHistory = Array.isArray(possibleQueryHistory) ? possibleQueryHistory : [];
if (localStorageAutoRun) {
let possibleAutoRun: unknown; const possibleAutoRun = localStorageGetJson(LocalStorageKeys.AUTO_RUN);
try { const autoRun = typeof possibleAutoRun === 'boolean' ? possibleAutoRun : true;
possibleAutoRun = JSON.parse(localStorageAutoRun);
} catch {}
if (typeof possibleAutoRun === 'boolean') autoRun = possibleAutoRun;
}
this.state = { this.state = {
queryString, queryString,
queryAst, parsedQuery,
queryContext: {}, queryContext,
wrapQueryLimit: 100, wrapQueryLimit: 100,
autoRun, autoRun,
@ -429,7 +407,10 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
return ( return (
<QueryHistoryDialog <QueryHistoryDialog
queryRecords={queryHistory} queryRecords={queryHistory}
setQueryString={this.handleQueryStringChange} setQueryString={(queryString, queryContext) => {
this.handleQueryContextChange(queryContext);
this.handleQueryStringChange(queryString);
}}
onClose={() => this.setState({ historyDialogOpen: false })} onClose={() => this.setState({ historyDialogOpen: false })}
/> />
); );
@ -441,22 +422,41 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
return ( return (
<EditContextDialog <EditContextDialog
onQueryContextChange={(queryContext: QueryContext) => onQueryContextChange={this.handleQueryContextChange}
this.setState({ queryContext, editContextDialogOpen: false }) onClose={() => {
} this.setState({ editContextDialogOpen: false });
onClose={() => this.setState({ editContextDialogOpen: false })} }}
queryContext={queryContext} queryContext={queryContext}
/> />
); );
} }
renderAutoRunSwitch() {
const { autoRun, queryString } = this.state;
if (QueryView.isJsonLike(queryString)) return;
return (
<Tooltip
content="Automatically run queries when modified via helper action menus."
hoverOpenDelay={800}
>
<Switch
className="auto-run"
checked={autoRun}
label="Auto run"
onChange={() => this.handleAutoRunChange(!autoRun)}
/>
</Tooltip>
);
}
renderWrapQueryLimitSelector() { renderWrapQueryLimitSelector() {
const { wrapQueryLimit, queryString } = this.state; const { wrapQueryLimit, queryString } = this.state;
if (QueryView.isJsonLike(queryString)) return; if (QueryView.isJsonLike(queryString)) return;
return ( return (
<Tooltip <Tooltip
content="Automatically wrap the query with a limit to protect against queries with very large result sets" content="Automatically wrap the query with a limit to protect against queries with very large result sets."
hoverOpenDelay={800} hoverOpenDelay={800}
> >
<Switch <Switch
@ -470,15 +470,7 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
} }
renderMainArea() { renderMainArea() {
const { const { queryString, queryContext, loading, result, error, columnMetadata } = this.state;
queryString,
queryContext,
loading,
result,
error,
columnMetadata,
autoRun,
} = this.state;
const emptyQuery = QueryView.isEmptyQuery(queryString); const emptyQuery = QueryView.isEmptyQuery(queryString);
let currentSchema; let currentSchema;
@ -519,8 +511,6 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
/> />
<div className="control-bar"> <div className="control-bar">
<RunButton <RunButton
autoRun={autoRun}
onAutoRunChange={this.handleAutoRunChange}
onEditContext={() => this.setState({ editContextDialogOpen: true })} onEditContext={() => this.setState({ editContextDialogOpen: true })}
runeMode={runeMode} runeMode={runeMode}
queryContext={queryContext} queryContext={queryContext}
@ -529,6 +519,7 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
onExplain={emptyQuery ? undefined : this.handleExplain} onExplain={emptyQuery ? undefined : this.handleExplain}
onHistory={() => this.setState({ historyDialogOpen: true })} onHistory={() => this.setState({ historyDialogOpen: true })}
/> />
{this.renderAutoRunSwitch()}
{this.renderWrapQueryLimitSelector()} {this.renderWrapQueryLimitSelector()}
{result && ( {result && (
<QueryExtraInfo <QueryExtraInfo
@ -539,110 +530,23 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
</div> </div>
</div> </div>
<QueryOutput <QueryOutput
sqlExcludeColumn={this.sqlExcludeColumn}
sqlFilterRow={this.sqlFilterRow}
sqlOrderBy={this.sqlOrderBy}
runeMode={runeMode} runeMode={runeMode}
loading={loading} loading={loading}
error={error}
queryResult={result ? result.queryResult : undefined} queryResult={result ? result.queryResult : undefined}
parsedQuery={result ? result.parsedQuery : undefined} parsedQuery={result ? result.parsedQuery : undefined}
error={error} onQueryChange={this.handleQueryStringChange}
/> />
</SplitterLayout> </SplitterLayout>
); );
} }
private addFunctionToGroupBy = ( private handleQueryStringChange = (
functionName: string, queryString: string | SqlQuery,
spacing: string[], preferablyRun?: boolean,
argumentsArray: (StringType | number)[],
preferablyRun: boolean,
alias: Alias,
): void => { ): void => {
const { queryAst } = this.state; if (queryString instanceof SqlQuery) queryString = queryString.toString();
if (!queryAst) return; this.setState({ queryString, parsedQuery: parser(queryString) }, () => {
const modifiedAst = queryAst.addFunctionToGroupBy(functionName, spacing, argumentsArray, alias);
this.handleQueryStringChange(modifiedAst.toString(), preferablyRun);
};
private addToGroupBy = (columnName: string, preferablyRun: boolean): void => {
const { queryAst } = this.state;
if (!queryAst) return;
const modifiedAst = queryAst.addToGroupBy(columnName);
this.handleQueryStringChange(modifiedAst.toString(), preferablyRun);
};
private replaceFrom = (table: RefExpression, preferablyRun: boolean): void => {
const { queryAst } = this.state;
if (!queryAst) return;
const modifiedAst = queryAst.replaceFrom(table);
this.handleQueryStringChange(modifiedAst.toString(), preferablyRun);
};
private addAggregateColumn = (
columnName: string | RefExpression,
functionName: string,
preferablyRun: boolean,
alias?: Alias,
distinct?: boolean,
filter?: FilterClause,
): void => {
const { queryAst } = this.state;
if (!queryAst) return;
const modifiedAst = queryAst.addAggregateColumn(
columnName,
functionName,
alias,
distinct,
filter,
);
this.handleQueryStringChange(modifiedAst.toString(), preferablyRun);
};
private sqlOrderBy = (
header: string,
direction: 'ASC' | 'DESC',
preferablyRun: boolean,
): void => {
const { queryAst } = this.state;
if (!queryAst) return;
const modifiedAst = queryAst.orderBy(header, direction);
this.handleQueryStringChange(modifiedAst.toString(), preferablyRun);
};
private sqlExcludeColumn = (header: string, preferablyRun: boolean): void => {
const { queryAst } = this.state;
if (!queryAst) return;
const modifiedAst = queryAst.excludeColumn(header);
this.handleQueryStringChange(modifiedAst.toString(), preferablyRun);
};
private sqlFilterRow = (filters: RowFilter[], preferablyRun: boolean): void => {
const { queryAst } = this.state;
if (!queryAst) return;
let modifiedAst: SqlQuery = queryAst;
for (const filter of filters) {
modifiedAst = modifiedAst.filterRow(filter.header, filter.row, filter.operator);
}
this.handleQueryStringChange(modifiedAst.toString(), preferablyRun);
};
private sqlClearWhere = (column: string, preferablyRun: boolean): void => {
const { queryAst } = this.state;
if (!queryAst) return;
this.handleQueryStringChange(queryAst.removeFilter(column).toString(), preferablyRun);
};
private handleQueryStringChange = (queryString: string, preferablyRun?: boolean): void => {
this.setState({ queryString, queryAst: parser(queryString) }, () => {
const { autoRun } = this.state; const { autoRun } = this.state;
if (preferablyRun && autoRun) this.handleRun(); if (preferablyRun && autoRun) this.handleRun();
}); });
@ -654,7 +558,7 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
private handleAutoRunChange = (autoRun: boolean) => { private handleAutoRunChange = (autoRun: boolean) => {
this.setState({ autoRun }); this.setState({ autoRun });
localStorageSet(LocalStorageKeys.AUTO_RUN, String(autoRun)); localStorageSetJson(LocalStorageKeys.AUTO_RUN, autoRun);
}; };
private handleWrapQueryLimitChange = (wrapQueryLimit: number | undefined) => { private handleWrapQueryLimitChange = (wrapQueryLimit: number | undefined) => {
@ -665,10 +569,15 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
const { queryString, queryContext, wrapQueryLimit, queryHistory } = this.state; const { queryString, queryContext, wrapQueryLimit, queryHistory } = this.state;
if (QueryView.isJsonLike(queryString) && !QueryView.validRune(queryString)) return; if (QueryView.isJsonLike(queryString) && !QueryView.validRune(queryString)) return;
const newQueryHistory = QueryHistoryDialog.addQueryToHistory(queryHistory, queryString); const newQueryHistory = QueryHistoryDialog.addQueryToHistory(
queryHistory,
queryString,
queryContext,
);
localStorageSet(LocalStorageKeys.QUERY_HISTORY, JSON.stringify(newQueryHistory)); localStorageSetJson(LocalStorageKeys.QUERY_HISTORY, newQueryHistory);
localStorageSet(LocalStorageKeys.QUERY_KEY, queryString); localStorageSet(LocalStorageKeys.QUERY_KEY, queryString);
localStorageSetJson(LocalStorageKeys.QUERY_CONTEXT, queryContext);
this.setState({ queryHistory: newQueryHistory }); this.setState({ queryHistory: newQueryHistory });
this.sqlQueryManager.runQuery({ queryString, queryContext, wrapQueryLimit }); this.sqlQueryManager.runQuery({ queryString, queryContext, wrapQueryLimit });
@ -685,19 +594,19 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
localStorageSet(LocalStorageKeys.QUERY_VIEW_PANE_SIZE, String(secondaryPaneSize)); localStorageSet(LocalStorageKeys.QUERY_VIEW_PANE_SIZE, String(secondaryPaneSize));
}; };
private getQueryAst = () => { private getParsedQuery = () => {
const { queryAst } = this.state; const { parsedQuery } = this.state;
return queryAst; return parsedQuery;
}; };
render(): JSX.Element { render(): JSX.Element {
const { columnMetadata, columnMetadataLoading, columnMetadataError, queryAst } = this.state; const { columnMetadata, columnMetadataLoading, columnMetadataError, parsedQuery } = this.state;
let defaultSchema; let defaultSchema;
let defaultTable; let defaultTable;
if (queryAst instanceof SqlQuery) { if (parsedQuery instanceof SqlQuery) {
defaultSchema = queryAst.getSchema(); defaultSchema = parsedQuery.getSchema();
defaultTable = queryAst.getTableName(); defaultTable = parsedQuery.getTableName();
} }
return ( return (
@ -706,18 +615,12 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
> >
{!columnMetadataError && ( {!columnMetadataError && (
<ColumnTree <ColumnTree
clear={this.sqlClearWhere} getParsedQuery={this.getParsedQuery}
filterByRow={this.sqlFilterRow}
addFunctionToGroupBy={this.addFunctionToGroupBy}
addAggregateColumn={this.addAggregateColumn}
addToGroupBy={this.addToGroupBy}
queryAst={this.getQueryAst}
columnMetadataLoading={columnMetadataLoading} columnMetadataLoading={columnMetadataLoading}
columnMetadata={columnMetadata} columnMetadata={columnMetadata}
onQueryStringChange={this.handleQueryStringChange} onQueryStringChange={this.handleQueryStringChange}
defaultSchema={defaultSchema ? defaultSchema : 'druid'} defaultSchema={defaultSchema ? defaultSchema : 'druid'}
defaultTable={defaultTable} defaultTable={defaultTable}
replaceFrom={this.replaceFrom}
/> />
)} )}
{this.renderMainArea()} {this.renderMainArea()}

View File

@ -25,12 +25,10 @@ describe('run button', () => {
it('matches snapshot', () => { it('matches snapshot', () => {
const runButton = ( const runButton = (
<RunButton <RunButton
autoRun
onAutoRunChange={() => {}}
onHistory={() => {}} onHistory={() => {}}
onEditContext={() => {}} onEditContext={() => {}}
runeMode={false} runeMode={false}
queryContext={{}} queryContext={{ f: 3 }}
onQueryContextChange={() => {}} onQueryContextChange={() => {}}
onRun={() => {}} onRun={() => {}}
onExplain={() => {}} onExplain={() => {}}

View File

@ -33,6 +33,7 @@ import { IconNames } from '@blueprintjs/icons';
import React from 'react'; import React from 'react';
import { MenuCheckbox } from '../../../components'; import { MenuCheckbox } from '../../../components';
import { pluralIfNeeded } from '../../../utils';
import { import {
getUseApproximateCountDistinct, getUseApproximateCountDistinct,
getUseApproximateTopN, getUseApproximateTopN,
@ -46,8 +47,6 @@ import { DRUID_DOCS_RUNE, DRUID_DOCS_SQL } from '../../../variables';
export interface RunButtonProps { export interface RunButtonProps {
runeMode: boolean; runeMode: boolean;
autoRun: boolean;
onAutoRunChange: (autoRun: boolean) => void;
queryContext: QueryContext; queryContext: QueryContext;
onQueryContextChange: (newQueryContext: QueryContext) => void; onQueryContextChange: (newQueryContext: QueryContext) => void;
onRun: (() => void) | undefined; onRun: (() => void) | undefined;
@ -58,10 +57,6 @@ export interface RunButtonProps {
@HotkeysTarget @HotkeysTarget
export class RunButton extends React.PureComponent<RunButtonProps> { export class RunButton extends React.PureComponent<RunButtonProps> {
constructor(props: RunButtonProps, context: any) {
super(props, context);
}
public renderHotkeys() { public renderHotkeys() {
return ( return (
<Hotkeys> <Hotkeys>
@ -90,31 +85,27 @@ export class RunButton extends React.PureComponent<RunButtonProps> {
onQueryContextChange, onQueryContextChange,
onEditContext, onEditContext,
onHistory, onHistory,
autoRun,
onAutoRunChange,
} = this.props; } = this.props;
const useCache = getUseCache(queryContext); const useCache = getUseCache(queryContext);
const useApproximateCountDistinct = getUseApproximateCountDistinct(queryContext); const useApproximateCountDistinct = getUseApproximateCountDistinct(queryContext);
const useApproximateTopN = getUseApproximateTopN(queryContext); const useApproximateTopN = getUseApproximateTopN(queryContext);
const numContextKeys = Object.keys(queryContext).length;
return ( return (
<Menu> <Menu>
<MenuItem <MenuItem
icon={IconNames.HELP} icon={IconNames.HELP}
text="Query docs" text={runeMode ? 'Native query documentation' : 'DruidSQL documentation'}
href={runeMode ? DRUID_DOCS_RUNE : DRUID_DOCS_SQL} href={runeMode ? DRUID_DOCS_RUNE : DRUID_DOCS_SQL}
target="_blank" target="_blank"
/> />
<MenuItem icon={IconNames.HISTORY} text="Query history" onClick={onHistory} />
{!runeMode && ( {!runeMode && (
<> <>
{onExplain && <MenuItem icon={IconNames.CLEAN} text="Explain" onClick={onExplain} />} {onExplain && (
<MenuItem icon={IconNames.HISTORY} text="History" onClick={onHistory} /> <MenuItem icon={IconNames.CLEAN} text="Explain SQL query" onClick={onExplain} />
<MenuCheckbox )}
checked={autoRun}
label="Auto run queries"
onChange={() => onAutoRunChange(!autoRun)}
/>
<MenuCheckbox <MenuCheckbox
checked={useApproximateCountDistinct} checked={useApproximateCountDistinct}
label="Use approximate COUNT(DISTINCT)" label="Use approximate COUNT(DISTINCT)"
@ -141,7 +132,12 @@ export class RunButton extends React.PureComponent<RunButtonProps> {
}} }}
/> />
{!runeMode && ( {!runeMode && (
<MenuItem icon={IconNames.PROPERTIES} text="Edit context" onClick={onEditContext} /> <MenuItem
icon={IconNames.PROPERTIES}
text="Edit context"
onClick={onEditContext}
labelElement={numContextKeys ? pluralIfNeeded(numContextKeys, 'key') : undefined}
/>
)} )}
</Menu> </Menu>
); );
@ -166,7 +162,7 @@ export class RunButton extends React.PureComponent<RunButtonProps> {
<Button icon={IconNames.CARET_RIGHT} text={runButtonText} disabled /> <Button icon={IconNames.CARET_RIGHT} text={runButtonText} disabled />
)} )}
<Popover position={Position.BOTTOM_LEFT} content={this.renderExtraMenu()}> <Popover position={Position.BOTTOM_LEFT} content={this.renderExtraMenu()}>
<Button icon={IconNames.MORE} intent={Intent.PRIMARY} /> <Button icon={IconNames.MORE} intent={onRun ? Intent.PRIMARY : undefined} />
</Popover> </Popover>
</ButtonGroup> </ButtonGroup>
); );

View File

@ -344,6 +344,12 @@ exports[`tasks view matches snapshot 1`] = `
> >
None None
</Blueprint3.Button> </Blueprint3.Button>
<Blueprint3.Button
active={false}
onClick={[Function]}
>
Group ID
</Blueprint3.Button>
<Blueprint3.Button <Blueprint3.Button
active={false} active={false}
onClick={[Function]} onClick={[Function]}
@ -455,6 +461,7 @@ exports[`tasks view matches snapshot 1`] = `
columns={ columns={
Array [ Array [
"Task ID", "Task ID",
"Group ID",
"Type", "Type",
"Datasource", "Datasource",
"Location", "Location",
@ -532,6 +539,13 @@ exports[`tasks view matches snapshot 1`] = `
"show": true, "show": true,
"width": 300, "width": 300,
}, },
Object {
"Aggregated": [Function],
"Header": "Group ID",
"accessor": "group_id",
"show": true,
"width": 300,
},
Object { Object {
"Cell": [Function], "Cell": [Function],
"Header": "Type", "Header": "Type",

View File

@ -68,6 +68,7 @@ const supervisorTableColumns: string[] = [
]; ];
const taskTableColumns: string[] = [ const taskTableColumns: string[] = [
'Task ID', 'Task ID',
'Group ID',
'Type', 'Type',
'Datasource', 'Datasource',
'Location', 'Location',
@ -109,7 +110,7 @@ export interface TasksViewState {
taskFilter: Filter[]; taskFilter: Filter[];
supervisorFilter: Filter[]; supervisorFilter: Filter[];
groupTasksBy?: 'type' | 'datasource' | 'status'; groupTasksBy?: 'group_id' | 'type' | 'datasource' | 'status';
killTaskId?: string; killTaskId?: string;
@ -191,7 +192,7 @@ export class TasksView extends React.PureComponent<TasksViewProps, TasksViewStat
}; };
static TASK_SQL = `SELECT static TASK_SQL = `SELECT
"task_id", "type", "datasource", "created_time", "location", "duration", "error_msg", "task_id", "group_id", "type", "datasource", "created_time", "location", "duration", "error_msg",
CASE WHEN "status" = 'RUNNING' THEN "runner_status" ELSE "status" END AS "status", CASE WHEN "status" = 'RUNNING' THEN "runner_status" ELSE "status" END AS "status",
( (
CASE WHEN "status" = 'RUNNING' THEN CASE WHEN "status" = 'RUNNING' THEN
@ -744,6 +745,13 @@ ORDER BY "rank" DESC, "created_time" DESC`;
Aggregated: () => '', Aggregated: () => '',
show: hiddenTaskColumns.exists('Task ID'), show: hiddenTaskColumns.exists('Task ID'),
}, },
{
Header: 'Group ID',
accessor: 'group_id',
width: 300,
Aggregated: () => '',
show: hiddenTaskColumns.exists('Group ID'),
},
{ {
Header: 'Type', Header: 'Type',
accessor: 'type', accessor: 'type',
@ -1125,6 +1133,12 @@ ORDER BY "rank" DESC, "created_time" DESC`;
> >
None None
</Button> </Button>
<Button
active={groupTasksBy === 'group_id'}
onClick={() => this.setState({ groupTasksBy: 'group_id' })}
>
Group ID
</Button>
<Button <Button
active={groupTasksBy === 'type'} active={groupTasksBy === 'type'}
onClick={() => this.setState({ groupTasksBy: 'type' })} onClick={() => this.setState({ groupTasksBy: 'type' })}

View File

@ -24,7 +24,7 @@ import { BarUnitData } from '../components/segment-timeline/segment-timeline';
import { BarUnit } from './bar-unit'; import { BarUnit } from './bar-unit';
import { HoveredBarInfo } from './stacked-bar-chart'; import { HoveredBarInfo } from './stacked-bar-chart';
interface BarGroupProps extends React.Props<any> { interface BarGroupProps {
dataToRender: BarUnitData[]; dataToRender: BarUnitData[];
changeActiveDatasource: (e: string) => void; changeActiveDatasource: (e: string) => void;
formatTick: (e: number) => string; formatTick: (e: number) => string;

View File

@ -20,7 +20,7 @@ import React from 'react';
import './bar-unit.scss'; import './bar-unit.scss';
interface BarChartUnitProps extends React.Props<any> { interface BarChartUnitProps {
x: number | undefined; x: number | undefined;
y: number; y: number;
width: number; width: number;

View File

@ -19,7 +19,7 @@
import * as d3 from 'd3'; import * as d3 from 'd3';
import React from 'react'; import React from 'react';
interface ChartAxisProps extends React.Props<any> { interface ChartAxisProps {
transform: string; transform: string;
scale: any; scale: any;
className?: string; className?: string;

View File

@ -27,7 +27,7 @@ import { ChartAxis } from './chart-axis';
import './stacked-bar-chart.scss'; import './stacked-bar-chart.scss';
interface StackedBarChartProps extends React.Props<any> { interface StackedBarChartProps {
svgWidth: number; svgWidth: number;
svgHeight: number; svgHeight: number;
margin: BarChartMargin; margin: BarChartMargin;