diff --git a/web-console/package-lock.json b/web-console/package-lock.json
index fdac73370ad..5c25e00ce13 100644
--- a/web-console/package-lock.json
+++ b/web-console/package-lock.json
@@ -4395,9 +4395,9 @@
"integrity": "sha512-0sYnfUHHMoajaud/i5BHKA12bUxiWEHJ9rxGqVEppFxsEcxef0TZQ5J59lU+UniEBcz/sG5fTESRyS7cOm3tSQ=="
},
"druid-query-toolkit": {
- "version": "0.3.15",
- "resolved": "https://registry.npmjs.org/druid-query-toolkit/-/druid-query-toolkit-0.3.15.tgz",
- "integrity": "sha512-q7uKfUdBItjOyNF1PlWF/rAhOim1uAjI085fsoKIBDZ2o5O4XRjaCKqXtW49Ovv92ks/22zLoYWNdU51i4PB/w==",
+ "version": "0.3.20",
+ "resolved": "https://registry.npmjs.org/druid-query-toolkit/-/druid-query-toolkit-0.3.20.tgz",
+ "integrity": "sha512-jrGNu+o/nD+uhbxAMLXEQrSWNEylCRmkiuFDJSPCMz7cjMNArsdIgyBQHPezNAeTDpReelAt59xJ7pvqXwPIvw==",
"requires": {
"tslib": "^1.10.0"
}
diff --git a/web-console/package.json b/web-console/package.json
index 14ef194d07b..8f3b214cea8 100644
--- a/web-console/package.json
+++ b/web-console/package.json
@@ -61,7 +61,7 @@
"d3": "^5.9.7",
"d3-array": "^2.2.0",
"druid-console": "^0.0.2",
- "druid-query-toolkit": "^0.3.15",
+ "druid-query-toolkit": "^0.3.20",
"file-saver": "^2.0.2",
"has-own-prop": "^2.0.0",
"hjson": "^3.1.2",
diff --git a/web-console/src/components/deferred/__snapshots__/deferred.spec.tsx.snap b/web-console/src/components/deferred/__snapshots__/deferred.spec.tsx.snap
new file mode 100644
index 00000000000..60bc7894080
--- /dev/null
+++ b/web-console/src/components/deferred/__snapshots__/deferred.spec.tsx.snap
@@ -0,0 +1,3 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`deferred matches snapshot 1`] = `
`;
diff --git a/web-console/src/components/deferred/deferred.spec.tsx b/web-console/src/components/deferred/deferred.spec.tsx
new file mode 100644
index 00000000000..8125fafbcfc
--- /dev/null
+++ b/web-console/src/components/deferred/deferred.spec.tsx
@@ -0,0 +1,31 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { render } from '@testing-library/react';
+import React from 'react';
+
+import { Deferred } from './deferred';
+
+describe('deferred', () => {
+ it('matches snapshot', () => {
+ const deferred =
} />;
+
+ const { container } = render(deferred);
+ expect(container.firstChild).toMatchSnapshot();
+ });
+});
diff --git a/web-console/src/components/deferred/deferred.tsx b/web-console/src/components/deferred/deferred.tsx
new file mode 100644
index 00000000000..6194979193d
--- /dev/null
+++ b/web-console/src/components/deferred/deferred.tsx
@@ -0,0 +1,36 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import React from 'react';
+
+export interface DeferredProps {
+ content: () => JSX.Element;
+}
+
+export interface DeferredState {}
+
+export class Deferred extends React.PureComponent {
+ constructor(props: DeferredProps, context: any) {
+ super(props, context);
+ }
+
+ render(): JSX.Element {
+ const { content } = this.props;
+ return content();
+ }
+}
diff --git a/web-console/src/dialogs/query-history-dialog/__snapshots__/query-plan-history.spec.tsx.snap b/web-console/src/dialogs/query-history-dialog/__snapshots__/query-plan-history.spec.tsx.snap
new file mode 100644
index 00000000000..d7efc740e7f
--- /dev/null
+++ b/web-console/src/dialogs/query-history-dialog/__snapshots__/query-plan-history.spec.tsx.snap
@@ -0,0 +1,111 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`query plan dialog matches snapshot 1`] = `
+
+`;
diff --git a/web-console/src/dialogs/query-history-dialog/query-history-dialog.scss b/web-console/src/dialogs/query-history-dialog/query-history-dialog.scss
new file mode 100644
index 00000000000..34bf4b79b01
--- /dev/null
+++ b/web-console/src/dialogs/query-history-dialog/query-history-dialog.scss
@@ -0,0 +1,33 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+.query-history-dialog {
+ &.bp3-dialog {
+ width: 900px;
+ }
+
+ .panel {
+ width: 100%;
+ }
+
+ .text-area {
+ width: 100%;
+ height: 500px;
+ resize: none;
+ }
+}
diff --git a/web-console/src/dialogs/query-history-dialog/query-history-dialog.tsx b/web-console/src/dialogs/query-history-dialog/query-history-dialog.tsx
new file mode 100644
index 00000000000..c17dbc60486
--- /dev/null
+++ b/web-console/src/dialogs/query-history-dialog/query-history-dialog.tsx
@@ -0,0 +1,91 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Button, Classes, Dialog, Intent, Tab, Tabs, TextArea } from '@blueprintjs/core';
+import React from 'react';
+
+import './query-history-dialog.scss';
+
+export interface QueryRecord {
+ version: string;
+ queryString: string;
+}
+export interface QueryHistoryDialogProps {
+ setQueryString: (queryString: string) => void;
+ onClose: () => void;
+ queryRecords: QueryRecord[];
+}
+
+export interface QueryHistoryDialogState {
+ activeTab: number;
+}
+
+export class QueryHistoryDialog extends React.PureComponent<
+ QueryHistoryDialogProps,
+ QueryHistoryDialogState
+> {
+ constructor(props: QueryHistoryDialogProps) {
+ super(props);
+ this.state = {
+ activeTab: 0,
+ };
+ }
+
+ render(): JSX.Element {
+ const { onClose, queryRecords, setQueryString } = this.props;
+ const { activeTab } = this.state;
+
+ const versions = queryRecords.map((record, index) => (
+ }
+ panelClassName={'panel'}
+ />
+ ));
+
+ return (
+
+
+ this.setState({ activeTab: tab })}
+ >
+ {versions}
+
+
+
+
+
+
+ setQueryString(queryRecords[activeTab].queryString)}
+ />
+
+
+
+ );
+ }
+}
diff --git a/web-console/src/dialogs/query-history-dialog/query-plan-history.spec.tsx b/web-console/src/dialogs/query-history-dialog/query-plan-history.spec.tsx
new file mode 100644
index 00000000000..d164c3e0171
--- /dev/null
+++ b/web-console/src/dialogs/query-history-dialog/query-plan-history.spec.tsx
@@ -0,0 +1,32 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { render } from '@testing-library/react';
+import React from 'react';
+
+import { QueryHistoryDialog } from './query-history-dialog';
+
+describe('query plan dialog', () => {
+ it('matches snapshot', () => {
+ const queryPlanDialog = (
+ null} queryRecords={[]} onClose={() => {}} />
+ );
+ render(queryPlanDialog);
+ expect(document.body.lastChild).toMatchSnapshot();
+ });
+});
diff --git a/web-console/src/dialogs/query-plan-dialog/__snapshots__/query-plan-dialog.spec.tsx.snap b/web-console/src/dialogs/query-plan-dialog/__snapshots__/query-plan-dialog.spec.tsx.snap
index abd814e44b1..e91d4b8c7ca 100644
--- a/web-console/src/dialogs/query-plan-dialog/__snapshots__/query-plan-dialog.spec.tsx.snap
+++ b/web-console/src/dialogs/query-plan-dialog/__snapshots__/query-plan-dialog.spec.tsx.snap
@@ -75,6 +75,16 @@ exports[`query plan dialog matches snapshot 1`] = `
Close
+
+
+ Open
+
+
diff --git a/web-console/src/dialogs/query-plan-dialog/query-plan-dialog.spec.tsx b/web-console/src/dialogs/query-plan-dialog/query-plan-dialog.spec.tsx
index 465436d5a04..de7bc91a204 100644
--- a/web-console/src/dialogs/query-plan-dialog/query-plan-dialog.spec.tsx
+++ b/web-console/src/dialogs/query-plan-dialog/query-plan-dialog.spec.tsx
@@ -24,7 +24,12 @@ import { QueryPlanDialog } from './query-plan-dialog';
describe('query plan dialog', () => {
it('matches snapshot', () => {
const queryPlanDialog = (
- {}} />
+ null}
+ explainResult={'test'}
+ explainError={undefined}
+ onClose={() => {}}
+ />
);
render(queryPlanDialog);
expect(document.body.lastChild).toMatchSnapshot();
diff --git a/web-console/src/dialogs/query-plan-dialog/query-plan-dialog.tsx b/web-console/src/dialogs/query-plan-dialog/query-plan-dialog.tsx
index 94bcdf3caa5..000cb9a2d8e 100644
--- a/web-console/src/dialogs/query-plan-dialog/query-plan-dialog.tsx
+++ b/web-console/src/dialogs/query-plan-dialog/query-plan-dialog.tsx
@@ -16,7 +16,15 @@
* limitations under the License.
*/
-import { Button, Classes, Dialog, FormGroup, InputGroup, TextArea } from '@blueprintjs/core';
+import {
+ Button,
+ Classes,
+ Dialog,
+ FormGroup,
+ InputGroup,
+ Intent,
+ TextArea,
+} from '@blueprintjs/core';
import React from 'react';
import { BasicQueryExplanation, SemiJoinQueryExplanation } from '../../utils';
@@ -27,6 +35,7 @@ export interface QueryPlanDialogProps {
explainResult?: BasicQueryExplanation | SemiJoinQueryExplanation | string;
explainError?: string;
onClose: () => void;
+ setQueryString: (queryString: string) => void;
}
export interface QueryPlanDialogState {}
@@ -40,8 +49,10 @@ export class QueryPlanDialog extends React.PureComponent<
this.state = {};
}
+ private queryString: string = '';
+
render(): JSX.Element {
- const { explainResult, explainError, onClose } = this.props;
+ const { explainResult, explainError, onClose, setQueryString } = this.props;
let content: JSX.Element;
@@ -60,17 +71,15 @@ export class QueryPlanDialog extends React.PureComponent<
);
}
+ this.queryString = JSON.stringify(
+ (explainResult as BasicQueryExplanation).query[0],
+ undefined,
+ 2,
+ );
content = (
-
+
{signature}
@@ -136,6 +145,11 @@ export class QueryPlanDialog extends React.PureComponent<
+ setQueryString(this.queryString)}
+ />
diff --git a/web-console/src/utils/local-storage-keys.tsx b/web-console/src/utils/local-storage-keys.tsx
index 0dec7d50aa2..4f116df44aa 100644
--- a/web-console/src/utils/local-storage-keys.tsx
+++ b/web-console/src/utils/local-storage-keys.tsx
@@ -34,6 +34,8 @@ export const LocalStorageKeys = {
SERVERS_REFRESH_RATE: 'servers-refresh-rate' as 'servers-refresh-rate',
SUPERVISORS_REFRESH_RATE: 'supervisors-refresh-rate' as 'supervisors-refresh-rate',
LOOKUPS_REFRESH_RATE: 'lookups-refresh-rate' as 'lookups-refresh-rate',
+ QUERY_HISTORY: 'query-history' as 'query-history',
+ AUTO_RUN: 'auto-run' as 'auto-run',
};
export type LocalStorageKeys = typeof LocalStorageKeys[keyof typeof LocalStorageKeys];
diff --git a/web-console/src/views/query-view/__snapshots__/query-view.spec.tsx.snap b/web-console/src/views/query-view/__snapshots__/query-view.spec.tsx.snap
index 4bc32b5b7d9..d5139b40d73 100644
--- a/web-console/src/views/query-view/__snapshots__/query-view.spec.tsx.snap
+++ b/web-console/src/views/query-view/__snapshots__/query-view.spec.tsx.snap
@@ -5,8 +5,16 @@ exports[`sql view matches snapshot 1`] = `
className="query-view app-view"
>
{
+ it('matches snapshot', () => {
+ const numberMenu = (
+ null}
+ addToGroupBy={() => null}
+ addAggregateColumn={() => null}
+ filterByRow={() => null}
+ columnName={'text'}
+ />
+ );
+
+ const { container } = render(numberMenu);
+ expect(container.firstChild).toMatchSnapshot();
+ });
+});
diff --git a/web-console/src/views/query-view/column-tree/column-tree-menu/number-menu-items/number-menu-items.tsx b/web-console/src/views/query-view/column-tree/column-tree-menu/number-menu-items/number-menu-items.tsx
new file mode 100644
index 00000000000..5c679107b6d
--- /dev/null
+++ b/web-console/src/views/query-view/column-tree/column-tree-menu/number-menu-items/number-menu-items.tsx
@@ -0,0 +1,141 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { MenuItem } from '@blueprintjs/core';
+import { IconNames } from '@blueprintjs/icons';
+import { Alias, FilterClause, SqlQuery, StringType } from 'druid-query-toolkit';
+import { aliasFactory } from 'druid-query-toolkit/build/ast/sql-query/helpers';
+import React from 'react';
+
+import { RowFilter } from '../../../query-view';
+
+export interface NumberMenuItemsProps {
+ addFunctionToGroupBy: (
+ functionName: string,
+ spacing: string[],
+ argumentsArray: (StringType | number)[],
+ run: boolean,
+ alias: Alias,
+ ) => void;
+ addToGroupBy: (columnName: string, run: boolean) => void;
+ addAggregateColumn: (
+ columnName: string,
+ functionName: string,
+ run: boolean,
+ alias?: Alias,
+ distinct?: boolean,
+ filter?: FilterClause,
+ ) => void;
+ filterByRow: (filters: RowFilter[], preferablyRun: boolean) => void;
+ queryAst?: SqlQuery;
+ columnName: string;
+}
+
+export class NumberMenuItems extends React.PureComponent {
+ constructor(props: NumberMenuItemsProps, context: any) {
+ super(props, context);
+ }
+
+ renderFilterMenu(): JSX.Element {
+ const { columnName, filterByRow } = this.props;
+
+ return (
+
+ 100`}
+ onClick={() => filterByRow([{ row: 100, header: columnName, operator: '>' }], false)}
+ />
+ filterByRow([{ row: 100, header: columnName, operator: '<=' }], false)}
+ />
+
+ );
+ }
+
+ renderGroupByMenu(): JSX.Element {
+ const { columnName, addFunctionToGroupBy, addToGroupBy } = this.props;
+
+ return (
+
+ addToGroupBy(columnName, true)} />
+
+ addFunctionToGroupBy(
+ 'TRUNCATE',
+ [' '],
+ [
+ new StringType({
+ spacing: [],
+ chars: columnName,
+ quote: '"',
+ }),
+ 1,
+ ],
+ true,
+ aliasFactory(`${columnName}_truncated`),
+ )
+ }
+ />
+
+ );
+ }
+
+ renderAggregateMenu(): JSX.Element {
+ const { columnName, addAggregateColumn } = this.props;
+ return (
+
+
+ addAggregateColumn(columnName, 'SUM', true, aliasFactory(`sum_${columnName}`))
+ }
+ />
+
+ addAggregateColumn(columnName, 'MAX', true, aliasFactory(`max_${columnName}`))
+ }
+ />
+
+ addAggregateColumn(columnName, 'MIN', true, aliasFactory(`min_${columnName}`))
+ }
+ />
+
+ );
+ }
+
+ render(): JSX.Element {
+ const { queryAst } = this.props;
+ let hasGroupBy;
+ if (queryAst) {
+ hasGroupBy = queryAst.groupByClause;
+ }
+
+ return (
+ <>
+ {queryAst && this.renderFilterMenu()}
+ {hasGroupBy && this.renderGroupByMenu()}
+ {hasGroupBy && this.renderAggregateMenu()}
+ >
+ );
+ }
+}
diff --git a/web-console/src/views/query-view/column-tree/column-tree-menu/string-menu-items/__snapshots__/string-menu-items.spec.tsx.snap b/web-console/src/views/query-view/column-tree/column-tree-menu/string-menu-items/__snapshots__/string-menu-items.spec.tsx.snap
new file mode 100644
index 00000000000..dbd66e4ad16
--- /dev/null
+++ b/web-console/src/views/query-view/column-tree/column-tree-menu/string-menu-items/__snapshots__/string-menu-items.spec.tsx.snap
@@ -0,0 +1,3 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`string menu matches snapshot 1`] = `null`;
diff --git a/web-console/src/views/query-view/column-tree/column-tree-menu/string-menu-items/string-menu-items.spec.tsx b/web-console/src/views/query-view/column-tree/column-tree-menu/string-menu-items/string-menu-items.spec.tsx
new file mode 100644
index 00000000000..75734357c87
--- /dev/null
+++ b/web-console/src/views/query-view/column-tree/column-tree-menu/string-menu-items/string-menu-items.spec.tsx
@@ -0,0 +1,39 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { render } from '@testing-library/react';
+import React from 'react';
+
+import { StringMenuItems } from './string-menu-items';
+
+describe('string menu', () => {
+ it('matches snapshot', () => {
+ const stringMenu = (
+ null}
+ addToGroupBy={() => null}
+ addAggregateColumn={() => null}
+ filterByRow={() => null}
+ columnName={'text'}
+ />
+ );
+
+ const { container } = render(stringMenu);
+ expect(container.firstChild).toMatchSnapshot();
+ });
+});
diff --git a/web-console/src/views/query-view/column-tree/column-tree-menu/string-menu-items/string-menu-items.tsx b/web-console/src/views/query-view/column-tree/column-tree-menu/string-menu-items/string-menu-items.tsx
new file mode 100644
index 00000000000..79dcc4936ea
--- /dev/null
+++ b/web-console/src/views/query-view/column-tree/column-tree-menu/string-menu-items/string-menu-items.tsx
@@ -0,0 +1,161 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { MenuItem } from '@blueprintjs/core';
+import { IconNames } from '@blueprintjs/icons';
+import {
+ Alias,
+ ComparisonExpression,
+ ComparisonExpressionRhs,
+ FilterClause,
+ RefExpression,
+ refExpressionFactory,
+ SqlQuery,
+ StringType,
+ WhereClause,
+} from 'druid-query-toolkit';
+import { aliasFactory, stringFactory } from 'druid-query-toolkit/build/ast/sql-query/helpers';
+import React from 'react';
+
+import { RowFilter } from '../../../query-view';
+
+export interface StringMenuItemsProps {
+ addFunctionToGroupBy: (
+ functionName: string,
+ spacing: string[],
+ argumentsArray: (StringType | number)[],
+ run: boolean,
+ alias: Alias,
+ ) => void;
+ addToGroupBy: (columnName: string, run: boolean) => void;
+ addAggregateColumn: (
+ columnName: string | RefExpression,
+ functionName: string,
+ run: boolean,
+ alias?: Alias,
+ distinct?: boolean,
+ filter?: FilterClause,
+ ) => void;
+ filterByRow: (filters: RowFilter[], preferablyRun: boolean) => void;
+ queryAst?: SqlQuery;
+ columnName: string;
+}
+
+export class StringMenuItems extends React.PureComponent {
+ constructor(props: StringMenuItemsProps, context: any) {
+ super(props, context);
+ }
+
+ renderFilterMenu(): JSX.Element {
+ const { columnName, filterByRow } = this.props;
+
+ return (
+
+ filterByRow([{ row: 'xxx', header: columnName, operator: '=' }], false)}
+ />
+ filterByRow([{ row: 'xxx', header: columnName, operator: 'LIKE' }], false)}
+ />
+
+ );
+ }
+
+ renderGroupByMenu(): JSX.Element {
+ const { columnName, addFunctionToGroupBy, addToGroupBy } = this.props;
+
+ return (
+
+ addToGroupBy(columnName, true)} />
+
+ addFunctionToGroupBy(
+ 'SUBSTRING',
+ [' ', ' '],
+ [stringFactory(columnName, `"`), 1, 2],
+ true,
+ aliasFactory(`${columnName}_substring`),
+ )
+ }
+ />
+
+ );
+ }
+
+ renderAggregateMenu(): JSX.Element {
+ const { columnName, addAggregateColumn } = this.props;
+ return (
+
+
+ addAggregateColumn(columnName, 'COUNT', true, aliasFactory(`dist_${columnName}`), true)
+ }
+ />
+
+ addAggregateColumn(
+ refExpressionFactory('*'),
+ 'COUNT',
+ false,
+ aliasFactory(`${columnName}_filtered_count`),
+ false,
+ new FilterClause({
+ keyword: 'FILTER',
+ spacing: [' '],
+ ex: new WhereClause({
+ keyword: 'WHERE',
+ spacing: [' '],
+ filter: new ComparisonExpression({
+ parens: [],
+ ex: stringFactory(columnName, '"'),
+ rhs: new ComparisonExpressionRhs({
+ parens: [],
+ op: '=',
+ rhs: stringFactory('xxx', `'`),
+ spacing: [' ', ' '],
+ }),
+ }),
+ }),
+ }),
+ )
+ }
+ />
+
+ );
+ }
+
+ render(): JSX.Element {
+ const { queryAst } = this.props;
+ let hasGroupBy;
+ if (queryAst) {
+ hasGroupBy = queryAst.groupByClause;
+ }
+ return (
+ <>
+ {queryAst && this.renderFilterMenu()}
+ {hasGroupBy && this.renderGroupByMenu()}
+ {hasGroupBy && this.renderAggregateMenu()}
+ >
+ );
+ }
+}
diff --git a/web-console/src/views/query-view/column-tree/column-tree-menu/time-menu-items/__snapshots__/time-menu-items.spec.tsx.snap b/web-console/src/views/query-view/column-tree/column-tree-menu/time-menu-items/__snapshots__/time-menu-items.spec.tsx.snap
new file mode 100644
index 00000000000..0e6d23ec2ae
--- /dev/null
+++ b/web-console/src/views/query-view/column-tree/column-tree-menu/time-menu-items/__snapshots__/time-menu-items.spec.tsx.snap
@@ -0,0 +1,3 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`time menu matches snapshot 1`] = `null`;
diff --git a/web-console/src/views/query-view/column-tree/column-tree-menu/time-menu-items/time-menu-items.spec.tsx b/web-console/src/views/query-view/column-tree/column-tree-menu/time-menu-items/time-menu-items.spec.tsx
new file mode 100644
index 00000000000..398ecc0cf93
--- /dev/null
+++ b/web-console/src/views/query-view/column-tree/column-tree-menu/time-menu-items/time-menu-items.spec.tsx
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { render } from '@testing-library/react';
+import React from 'react';
+
+import { TimeMenuItems } from './time-menu-items';
+
+describe('time menu', () => {
+ it('matches snapshot', () => {
+ const timeMenu = (
+ null}
+ addFunctionToGroupBy={() => null}
+ addToGroupBy={() => null}
+ addAggregateColumn={() => null}
+ filterByRow={() => null}
+ columnName={'text'}
+ />
+ );
+
+ const { container } = render(timeMenu);
+ expect(container.firstChild).toMatchSnapshot();
+ });
+});
diff --git a/web-console/src/views/query-view/column-tree/column-tree-menu/time-menu-items/time-menu-items.tsx b/web-console/src/views/query-view/column-tree/column-tree-menu/time-menu-items/time-menu-items.tsx
new file mode 100644
index 00000000000..3400ae3896c
--- /dev/null
+++ b/web-console/src/views/query-view/column-tree/column-tree-menu/time-menu-items/time-menu-items.tsx
@@ -0,0 +1,380 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one
+ * or more contributor license agreements. See the NOTICE file
+ * distributed with this work for additional information
+ * regarding copyright ownership. The ASF licenses this file
+ * to you under the Apache License, Version 2.0 (the
+ * "License"); you may not use this file except in compliance
+ * with the License. You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { MenuDivider, MenuItem } from '@blueprintjs/core';
+import { IconNames } from '@blueprintjs/icons';
+import {
+ AdditiveExpression,
+ Alias,
+ FilterClause,
+ SqlQuery,
+ StringType,
+ timestampFactory,
+} from 'druid-query-toolkit';
+import {
+ aliasFactory,
+ intervalFactory,
+ refExpressionFactory,
+ stringFactory,
+} from 'druid-query-toolkit/build/ast/sql-query/helpers';
+import React from 'react';
+
+import { RowFilter } from '../../../query-view';
+
+export interface TimeMenuItemsProps {
+ addFunctionToGroupBy: (
+ functionName: string,
+ spacing: string[],
+ argumentsArray: (StringType | number)[],
+ run: boolean,
+ alias: Alias,
+ ) => void;
+ addToGroupBy: (columnName: string, run: boolean) => void;
+ addAggregateColumn: (
+ columnName: string,
+ functionName: string,
+ run: boolean,
+ alias?: Alias,
+ distinct?: boolean,
+ filter?: FilterClause,
+ ) => void;
+ filterByRow: (filters: RowFilter[], preferablyRun: boolean) => void;
+ queryAst?: SqlQuery;
+ columnName: string;
+ clear: () => void;
+}
+
+export class TimeMenuItems extends React.PureComponent {
+ constructor(props: TimeMenuItemsProps, context: any) {
+ super(props, context);
+ }
+
+ formatTime(timePart: number): string {
+ if (timePart % 10 > 0) {
+ return String(timePart);
+ } else return '0' + String(timePart);
+ }
+
+ getNextMonth(month: number, year: number): { month: string; year: number } {
+ if (month === 12) {
+ return { month: '01', year: year + 1 };
+ }
+ return { month: this.formatTime(month + 1), year: year };
+ }
+
+ getNextDay(
+ day: number,
+ month: number,
+ year: number,
+ ): { day: string; month: string; year: number } {
+ if (
+ month === 1 ||
+ month === 3 ||
+ month === 5 ||
+ month === 7 ||
+ month === 8 ||
+ month === 10 ||
+ month === 12
+ ) {
+ if (day === 31) {
+ const next = this.getNextMonth(month, year);
+ return { day: '01', month: next.month, year: next.year };
+ }
+ } else if (month === 4 || month === 6 || month === 9 || month === 11) {
+ if (day === 30) {
+ const next = this.getNextMonth(month, year);
+ return { day: '01', month: next.month, year: next.year };
+ }
+ } else if (month === 2) {
+ if ((day === 29 && year % 4 === 0) || (day === 28 && year % 4)) {
+ const next = this.getNextMonth(month, year);
+ return { day: '01', month: next.month, year: next.year };
+ }
+ }
+ return { day: this.formatTime(day + 1), month: this.formatTime(month), year: year };
+ }
+
+ getNextHour(
+ hour: number,
+ day: number,
+ month: number,
+ year: number,
+ ): { hour: string; day: string; month: string; year: number } {
+ if (hour === 23) {
+ const next = this.getNextDay(day, month, year);
+ return { hour: '00', day: next.day, month: next.month, year: next.year };
+ }
+ return {
+ hour: this.formatTime(hour + 1),
+ day: this.formatTime(day),
+ month: this.formatTime(month),
+ year: year,
+ };
+ }
+
+ renderFilterMenu(): JSX.Element {
+ const { columnName, filterByRow, clear } = this.props;
+ const date = new Date();
+ const year = date.getFullYear();
+ const month = date.getMonth();
+ const day = date.getDay();
+ const hour = date.getHours();
+
+ return (
+
+ {
+ const additiveExpression = new AdditiveExpression({
+ parens: [],
+ op: ['-'],
+ ex: [refExpressionFactory('CURRENT_TIMESTAMP'), intervalFactory('HOUR', '1')],
+ spacing: [' ', ' '],
+ });
+ clear();
+ filterByRow([{ row: additiveExpression, header: columnName, operator: '>=' }], true);
+ }}
+ />
+ {
+ const additiveExpression = new AdditiveExpression({
+ parens: [],
+ op: ['-'],
+ ex: [refExpressionFactory('CURRENT_TIMESTAMP'), intervalFactory('DAY', '1')],
+ spacing: [' ', ' '],
+ });
+ clear();
+ filterByRow([{ row: additiveExpression, header: columnName, operator: '>=' }], true);
+ }}
+ />
+ {
+ const additiveExpression = new AdditiveExpression({
+ parens: [],
+ op: ['-'],
+ ex: [refExpressionFactory('CURRENT_TIMESTAMP'), intervalFactory('DAY', '7')],
+ spacing: [' ', ' '],
+ });
+ clear();
+ filterByRow([{ row: additiveExpression, header: columnName, operator: '>=' }], true);
+ }}
+ />
+ {
+ const additiveExpression = new AdditiveExpression({
+ parens: [],
+ op: ['-'],
+ ex: [refExpressionFactory('CURRENT_TIMESTAMP'), intervalFactory('MONTH', '1')],
+ spacing: [' ', ' '],
+ });
+ clear();
+ filterByRow([{ row: additiveExpression, header: columnName, operator: '>=' }], true);
+ }}
+ />
+ {
+ const additiveExpression = new AdditiveExpression({
+ parens: [],
+ op: ['-'],
+ ex: [refExpressionFactory('CURRENT_TIMESTAMP'), intervalFactory('YEAR', '1')],
+ spacing: [' ', ' '],
+ });
+ clear();
+ filterByRow([{ row: additiveExpression, header: columnName, operator: '>=' }], true);
+ }}
+ />
+
+ {
+ const next = this.getNextHour(hour, day, month, year);
+ clear();
+ filterByRow(
+ [
+ {
+ row: stringFactory(columnName, `"`),
+ header: timestampFactory(
+ `${year}-${month}-${day} ${this.formatTime(hour)}:00:00`,
+ ),
+ operator: '<=',
+ },
+ {
+ row: timestampFactory(
+ `${next.year}-${next.month}-${next.day} ${next.hour}:00:00`,
+ ),
+ header: columnName,
+ operator: '<',
+ },
+ ],
+ true,
+ );
+ }}
+ />
+ {
+ const next = this.getNextDay(day, month, year);
+ clear();
+ filterByRow(
+ [
+ {
+ row: stringFactory(columnName, `"`),
+ header: timestampFactory(`${year}-${month}-${day} 00:00:00`),
+ operator: '<=',
+ },
+ {
+ row: timestampFactory(`${next.year}-${next.month}-${next.day} 00:00:00`),
+ header: columnName,
+ operator: '<',
+ },
+ ],
+ true,
+ );
+ }}
+ />
+ {
+ const next = this.getNextMonth(month, year);
+ clear();
+ filterByRow(
+ [
+ {
+ row: stringFactory(columnName, `"`),
+ header: timestampFactory(`${year}-${month}-01 00:00:00`),
+ operator: '<=',
+ },
+ {
+ row: timestampFactory(`${next.year}-${next.month}-01 00:00:00`),
+ header: columnName,
+ operator: '<',
+ },
+ ],
+ true,
+ );
+ }}
+ />
+ {
+ clear();
+ filterByRow(
+ [
+ {
+ row: stringFactory(columnName, `"`),
+ header: timestampFactory(`${year}-01-01 00:00:00`),
+ operator: '<=',
+ },
+ {
+ row: timestampFactory(`${Number(year) + 1}-01-01 00:00:00`),
+ header: columnName,
+ operator: '<',
+ },
+ ],
+ true,
+ );
+ }}
+ />
+
+ );
+ }
+
+ renderGroupByMenu(): JSX.Element {
+ const { columnName, addFunctionToGroupBy } = this.props;
+
+ return (
+
+
+ addFunctionToGroupBy(
+ 'TIME_FLOOR',
+ [' '],
+ [stringFactory(columnName, `"`), stringFactory('PT1H', `'`)],
+ true,
+ aliasFactory(`${columnName}_time_floor`),
+ )
+ }
+ />
+
+ addFunctionToGroupBy(
+ 'TIME_FLOOR',
+ [' '],
+ [stringFactory(columnName, `"`), stringFactory('P1D', `'`)],
+ true,
+ aliasFactory(`${columnName}_time_floor`),
+ )
+ }
+ />
+
+ addFunctionToGroupBy(
+ 'TIME_FLOOR',
+ [' '],
+ [stringFactory(columnName, `"`), stringFactory('P7D', `'`)],
+ true,
+ aliasFactory(`${columnName}_time_floor`),
+ )
+ }
+ />
+
+ );
+ }
+
+ renderAggregateMenu(): JSX.Element {
+ const { columnName, addAggregateColumn } = this.props;
+ return (
+
+
+ addAggregateColumn(columnName, 'MAX', true, aliasFactory(`max_${columnName}`))
+ }
+ />
+
+ addAggregateColumn(columnName, 'MIN', true, aliasFactory(`min_${columnName}`))
+ }
+ />
+
+ );
+ }
+
+ render(): JSX.Element {
+ const { queryAst } = this.props;
+ let hasGroupBy;
+ if (queryAst) {
+ hasGroupBy = queryAst.groupByClause;
+ }
+ return (
+ <>
+ {queryAst && this.renderFilterMenu()}
+ {hasGroupBy && this.renderGroupByMenu()}
+ {hasGroupBy && this.renderAggregateMenu()}
+ >
+ );
+ }
+}
diff --git a/web-console/src/views/query-view/column-tree/column-tree.spec.tsx b/web-console/src/views/query-view/column-tree/column-tree.spec.tsx
index e8ca98d6874..d210eb2af04 100644
--- a/web-console/src/views/query-view/column-tree/column-tree.spec.tsx
+++ b/web-console/src/views/query-view/column-tree/column-tree.spec.tsx
@@ -27,6 +27,13 @@ describe('column tree', () => {
it('matches snapshot', () => {
const columnTree = (
undefined}
+ hasGroupBy={() => false}
+ clear={() => null}
+ addFunctionToGroupBy={() => null}
+ filterByRow={() => null}
+ addAggregateColumn={() => null}
+ addToGroupBy={() => null}
columnMetadataLoading={false}
columnMetadata={
[
diff --git a/web-console/src/views/query-view/column-tree/column-tree.tsx b/web-console/src/views/query-view/column-tree/column-tree.tsx
index ea62f2080c3..0a491b0f1b5 100644
--- a/web-console/src/views/query-view/column-tree/column-tree.tsx
+++ b/web-console/src/views/query-view/column-tree/column-tree.tsx
@@ -16,14 +16,29 @@
* limitations under the License.
*/
-import { HTMLSelect, IconName, ITreeNode, Menu, MenuItem, Position, Tree } from '@blueprintjs/core';
-import { Popover } from '@blueprintjs/core/lib/cjs';
+import {
+ HTMLSelect,
+ IconName,
+ ITreeNode,
+ Menu,
+ MenuItem,
+ Popover,
+ Position,
+ Tree,
+} from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
+import { Alias, FilterClause, RefExpression, SqlQuery, StringType } from 'druid-query-toolkit';
import React, { ChangeEvent } from 'react';
import { Loader } from '../../../components';
+import { Deferred } from '../../../components/deferred/deferred';
import { copyAndAlert, escapeSqlIdentifier, groupBy } from '../../../utils';
import { ColumnMetadata } from '../../../utils/column-metadata';
+import { RowFilter } from '../query-view';
+
+import { NumberMenuItems } from './column-tree-menu/number-menu-items/number-menu-items';
+import { StringMenuItems } from './column-tree-menu/string-menu-items/string-menu-items';
+import { TimeMenuItems } from './column-tree-menu/time-menu-items/time-menu-items';
import './column-tree.scss';
@@ -88,10 +103,31 @@ export interface ColumnTreeProps {
onQueryStringChange: (queryString: string) => void;
defaultSchema?: string;
defaultTable?: string;
+ addFunctionToGroupBy: (
+ functionName: string,
+ spacing: string[],
+ argumentsArray: (StringType | number)[],
+ run: boolean,
+ alias: Alias,
+ ) => void;
+ addToGroupBy: (columnName: string, run: boolean) => void;
+ addAggregateColumn: (
+ columnName: string | RefExpression,
+ functionName: string,
+ run: boolean,
+ alias?: Alias,
+ distinct?: boolean,
+ filter?: FilterClause,
+ ) => void;
+ filterByRow: (filters: RowFilter[], preferablyRun: boolean) => void;
+ hasGroupBy: () => boolean;
+ queryAst: () => SqlQuery | undefined;
+ clear: () => void;
}
export interface ColumnTreeState {
prevColumnMetadata?: ColumnMetadata[];
+ prevGroupByStatus?: boolean;
columnTree?: ITreeNode[];
selectedTreeIndex: number;
expandedNode: number;
@@ -100,7 +136,6 @@ export interface ColumnTreeState {
export class ColumnTree extends React.PureComponent {
static getDerivedStateFromProps(props: ColumnTreeProps, state: ColumnTreeState) {
const { columnMetadata, defaultSchema, defaultTable } = props;
-
if (columnMetadata && columnMetadata !== state.prevColumnMetadata) {
const columnTree = groupBy(
columnMetadata,
@@ -122,7 +157,7 @@ export class ColumnTree extends React.PureComponent
{
handleTableClick(
schema,
@@ -160,35 +195,72 @@ export class ColumnTree extends React.PureComponent
- {
- handleColumnClick(
- schema,
- table,
- {
- id: columnData.COLUMN_NAME,
- icon: ColumnTree.dataTypeToIcon(columnData.DATA_TYPE),
- label: columnData.COLUMN_NAME,
- },
- props.onQueryStringChange,
- );
- }}
- />
- {
- copyAndAlert(
- columnData.COLUMN_NAME,
- `${columnData.COLUMN_NAME} query copied to clipboard`,
- );
- }}
- />
-
+ (
+
+ {
+ handleColumnClick(
+ schema,
+ table,
+ {
+ id: columnData.COLUMN_NAME,
+ icon: ColumnTree.dataTypeToIcon(columnData.DATA_TYPE),
+ label: columnData.COLUMN_NAME,
+ },
+ props.onQueryStringChange,
+ );
+ }}
+ />
+ {columnData.DATA_TYPE === 'BIGINT' && (
+
+ )}
+ {columnData.DATA_TYPE === 'VARCHAR' && (
+
+ )}
+ {columnData.DATA_TYPE === 'TIMESTAMP' && (
+
+ )}
+ {
+ copyAndAlert(
+ columnData.COLUMN_NAME,
+ `${columnData.COLUMN_NAME} query copied to clipboard`,
+ );
+ }}
+ />
+
+ )}
+ />
}
>
{columnData.COLUMN_NAME}
@@ -203,29 +275,28 @@ export class ColumnTree extends React.PureComponent {
+ return x.id === defaultSchema;
+ });
}
+
if (selectedTreeIndex > -1) {
const treeNodes = columnTree[selectedTreeIndex].childNodes;
if (treeNodes) {
if (defaultTable) {
- expandedNode = treeNodes
- .map(node => {
- return node.id;
- })
- .indexOf(defaultTable);
+ expandedNode = treeNodes.findIndex(node => {
+ return node.id === defaultTable;
+ });
}
}
}
+
return {
prevColumnMetadata: columnMetadata,
columnTree,
selectedTreeIndex,
expandedNode,
+ prevGroupByStatus: props.hasGroupBy,
};
}
return null;
diff --git a/web-console/src/views/query-view/query-output/query-output.spec.tsx b/web-console/src/views/query-view/query-output/query-output.spec.tsx
index e56b1d1d052..3cfb424e512 100644
--- a/web-console/src/views/query-view/query-output/query-output.spec.tsx
+++ b/web-console/src/views/query-view/query-output/query-output.spec.tsx
@@ -29,7 +29,6 @@ describe('query output', () => {
sqlOrderBy={() => null}
sqlFilterRow={() => null}
sqlExcludeColumn={() => null}
- disabled={false}
loading={false}
error="lol"
/>
diff --git a/web-console/src/views/query-view/query-output/query-output.tsx b/web-console/src/views/query-view/query-output/query-output.tsx
index 9a3f4ea142a..ad89e4e1aab 100644
--- a/web-console/src/views/query-view/query-output/query-output.tsx
+++ b/web-console/src/views/query-view/query-output/query-output.tsx
@@ -18,7 +18,7 @@
import { Menu, MenuItem, Popover } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
-import { HeaderRows } from 'druid-query-toolkit';
+import { HeaderRows, SqlQuery } from 'druid-query-toolkit';
import {
basicIdentifierEscape,
basicLiteralEscape,
@@ -28,34 +28,40 @@ import ReactTable from 'react-table';
import { copyAndAlert } from '../../../utils';
import { BasicAction, basicActionsToMenu } from '../../../utils/basic-action';
+import { RowFilter } from '../query-view';
import './query-output.scss';
export interface QueryOutputProps {
- aggregateColumns?: string[];
- disabled: boolean;
loading: boolean;
- sqlFilterRow: (row: string, header: string, operator: '=' | '!=') => void;
- sqlExcludeColumn: (header: string) => void;
- sqlOrderBy: (header: string, direction: 'ASC' | 'DESC') => void;
- sorted?: { id: string; desc: boolean }[];
- result?: HeaderRows;
+ sqlFilterRow: (filters: RowFilter[], run: boolean) => void;
+ sqlExcludeColumn: (header: string, run: boolean) => void;
+ sqlOrderBy: (header: string, direction: 'ASC' | 'DESC', run: boolean) => void;
+ queryResult?: HeaderRows;
+ parsedQuery?: SqlQuery;
error?: string;
runeMode: boolean;
}
export class QueryOutput extends React.PureComponent {
render(): JSX.Element {
- const { result, loading, error } = this.props;
+ const { queryResult, parsedQuery, loading, error } = this.props;
+
+ let aggregateColumns: string[] | undefined;
+ if (parsedQuery) {
+ aggregateColumns = parsedQuery.getAggregateColumns();
+ }
return (
{
+ columns={(queryResult ? queryResult.header : []).map((h: any, i) => {
return {
Header: () => {
return (
@@ -80,11 +86,8 @@ export class QueryOutput extends React.PureComponent {
}
return value;
},
- className: this.props.aggregateColumns
- ? this.props.aggregateColumns.indexOf(h) > -1
- ? 'aggregate-column'
- : undefined
- : undefined,
+ className:
+ aggregateColumns && aggregateColumns.includes(h) ? 'aggregate-column' : undefined,
};
})}
/>
@@ -92,9 +95,45 @@ export class QueryOutput extends React.PureComponent {
);
}
getHeaderActions(h: string) {
- const { disabled, sqlExcludeColumn, sqlOrderBy, runeMode } = this.props;
+ const { parsedQuery, sqlExcludeColumn, sqlOrderBy, runeMode } = this.props;
+
let actionsMenu;
- if (disabled) {
+ if (parsedQuery) {
+ const sorted = parsedQuery.getSorted();
+
+ const basicActions: BasicAction[] = [];
+ if (sorted) {
+ sorted.map(sorted => {
+ if (sorted.id === h) {
+ basicActions.push({
+ icon: sorted.desc ? IconNames.SORT_ASC : IconNames.SORT_DESC,
+ title: `Order by: ${h} ${sorted.desc ? 'ASC' : 'DESC'}`,
+ onAction: () => sqlOrderBy(h, sorted.desc ? 'ASC' : 'DESC', true),
+ });
+ }
+ });
+ }
+ if (!basicActions.length) {
+ basicActions.push(
+ {
+ icon: IconNames.SORT_ASC,
+ title: `Order by: ${h} ASC`,
+ onAction: () => sqlOrderBy(h, 'ASC', true),
+ },
+ {
+ icon: IconNames.SORT_DESC,
+ title: `Order by: ${h} DESC`,
+ onAction: () => sqlOrderBy(h, 'DESC', true),
+ },
+ );
+ }
+ basicActions.push({
+ icon: IconNames.CROSS,
+ title: `Remove: ${h}`,
+ onAction: () => sqlExcludeColumn(h, true),
+ });
+ actionsMenu = basicActionsToMenu(basicActions);
+ } else {
actionsMenu = (
{
)}
);
- } else {
- const { sorted } = this.props;
- const basicActions: BasicAction[] = [];
- if (sorted) {
- sorted.map(sorted => {
- if (sorted.id === h) {
- basicActions.push({
- icon: sorted.desc ? IconNames.SORT_ASC : IconNames.SORT_DESC,
- title: `Order by: ${h} ${sorted.desc ? 'ASC' : 'DESC'}`,
- onAction: () => sqlOrderBy(h, sorted.desc ? 'ASC' : 'DESC'),
- });
- }
- });
- }
- if (!basicActions.length) {
- basicActions.push(
- {
- icon: IconNames.SORT_ASC,
- title: `Order by: ${h} ASC`,
- onAction: () => sqlOrderBy(h, 'ASC'),
- },
- {
- icon: IconNames.SORT_DESC,
- title: `Order by: ${h} DESC`,
- onAction: () => sqlOrderBy(h, 'DESC'),
- },
- );
- }
- basicActions.push({
- icon: IconNames.CROSS,
- title: `Remove: ${h}`,
- onAction: () => sqlExcludeColumn(h),
- });
- actionsMenu = basicActionsToMenu(basicActions);
}
return actionsMenu ? actionsMenu : undefined;
}
getRowActions(row: string, header: string) {
- const { disabled, sqlFilterRow, runeMode } = this.props;
+ const { parsedQuery, sqlFilterRow, runeMode } = this.props;
+
let actionsMenu;
- if (disabled) {
+ if (parsedQuery) {
+ actionsMenu = basicActionsToMenu([
+ {
+ icon: IconNames.FILTER_KEEP,
+ title: `Filter by: ${header} = ${row}`,
+ onAction: () => sqlFilterRow([{ row, header, operator: '=' }], true),
+ },
+ {
+ icon: IconNames.FILTER_REMOVE,
+ title: `Filter by: ${header} != ${row}`,
+ onAction: () => sqlFilterRow([{ row, header, operator: '!=' }], true),
+ },
+ ]);
+ } else {
actionsMenu = (
{
)}
);
- } else {
- actionsMenu = basicActionsToMenu([
- {
- icon: IconNames.FILTER_KEEP,
- title: `Filter by: ${header} = ${row}`,
- onAction: () => sqlFilterRow(row, header, '='),
- },
- {
- icon: IconNames.FILTER_REMOVE,
- title: `Filter by: ${header} != ${row}`,
- onAction: () => sqlFilterRow(row, header, '!='),
- },
- ]);
}
return actionsMenu ? actionsMenu : undefined;
}
getHeaderClassName(h: string) {
- const { sorted, aggregateColumns } = this.props;
+ const { parsedQuery } = this.props;
+
const className = [];
- className.push(
- sorted
- ? sorted.map(sorted => {
+ if (parsedQuery) {
+ const sorted = parsedQuery.getSorted();
+ if (sorted) {
+ className.push(
+ sorted.map(sorted => {
if (sorted.id === h) {
return sorted.desc ? '-sort-desc' : '-sort-asc';
}
return '';
- })[0]
- : undefined,
- );
- if (aggregateColumns) {
- if (aggregateColumns.includes(h)) {
+ })[0],
+ );
+ }
+
+ const aggregateColumns = parsedQuery.getAggregateColumns();
+ if (aggregateColumns && aggregateColumns.includes(h)) {
className.push('aggregate-header');
}
}
diff --git a/web-console/src/views/query-view/query-view.tsx b/web-console/src/views/query-view/query-view.tsx
index 68aa550424e..06949942cdd 100644
--- a/web-console/src/views/query-view/query-view.tsx
+++ b/web-console/src/views/query-view/query-view.tsx
@@ -20,20 +20,31 @@ import { Intent } from '@blueprintjs/core';
import axios from 'axios';
import classNames from 'classnames';
import {
+ AdditiveExpression,
+ Alias,
+ FilterClause,
HeaderRows,
isFirstRowHeader,
normalizeQueryResult,
+ RefExpression,
shouldIncludeTimestamp,
sqlParserFactory,
SqlQuery,
+ StringType,
+ Timestamp,
} from 'druid-query-toolkit';
import Hjson from 'hjson';
+import memoizeOne from 'memoize-one';
import React from 'react';
import SplitterLayout from 'react-splitter-layout';
import { SQL_FUNCTIONS, SyntaxDescription } from '../../../lib/sql-function-doc';
import { QueryPlanDialog } from '../../dialogs';
import { EditContextDialog } from '../../dialogs/edit-context-dialog/edit-context-dialog';
+import {
+ QueryHistoryDialog,
+ QueryRecord,
+} from '../../dialogs/query-history-dialog/query-history-dialog';
import { AppToaster } from '../../singletons/toaster';
import {
BasicQueryExplanation,
@@ -58,12 +69,20 @@ import { RunButton } from './run-button/run-button';
import './query-view.scss';
-const parser = sqlParserFactory(
+const parserRaw = sqlParserFactory(
SQL_FUNCTIONS.map((sql_function: SyntaxDescription) => {
return sql_function.syntax.substr(0, sql_function.syntax.indexOf('('));
}),
);
+const parser = memoizeOne((sql: string) => {
+ try {
+ return parserRaw(sql);
+ } catch {
+ return;
+ }
+});
+
interface QueryWithContext {
queryString: string;
queryContext: QueryContext;
@@ -74,8 +93,15 @@ export interface QueryViewProps {
initQuery: string | undefined;
}
+export interface RowFilter {
+ row: string | number | AdditiveExpression | Timestamp | StringType;
+ header: string | Timestamp | StringType;
+ operator: '!=' | '=' | '>' | '<' | 'like' | '>=' | '<=' | 'LIKE';
+}
+
export interface QueryViewState {
queryString: string;
+ queryAst: SqlQuery;
queryContext: QueryContext;
columnMetadataLoading: boolean;
@@ -83,8 +109,7 @@ export interface QueryViewState {
columnMetadataError?: string;
loading: boolean;
- result?: HeaderRows;
- queryExtraInfo?: QueryExtraInfoData;
+ result?: QueryResult;
error?: string;
explainDialogOpen: boolean;
@@ -94,9 +119,12 @@ export interface QueryViewState {
defaultSchema?: string;
defaultTable?: string;
- ast?: SqlQuery;
editContextDialogOpen: boolean;
+ historyDialogOpen: boolean;
+ queryHistory: QueryRecord[];
+
+ autoRun: boolean;
}
interface QueryResult {
@@ -149,8 +177,18 @@ export class QueryView extends React.PureComponent {
return await queryDruidSql({
@@ -199,7 +240,6 @@ export class QueryView extends React.PureComponent {
this.setState({
- result: result ? result.queryResult : undefined,
- queryExtraInfo: result ? result.queryExtraInfo : undefined,
+ result,
loading,
error,
- ast: result ? result.parsedQuery : undefined,
});
},
});
@@ -300,6 +337,28 @@ export class QueryView extends React.PureComponent {
const { result } = this.state;
if (!result) return;
+ const { queryResult } = result;
+
let lines: string[] = [];
let separator: string = '';
if (format === 'csv' || format === 'tsv') {
separator = format === 'csv' ? ',' : '\t';
- lines.push(result.header.map(str => QueryView.formatStr(str, format)).join(separator));
+ lines.push(queryResult.header.map(str => QueryView.formatStr(str, format)).join(separator));
lines = lines.concat(
- result.rows.map(r => r.map(cell => QueryView.formatStr(cell, format)).join(separator)),
+ queryResult.rows.map(r => r.map(cell => QueryView.formatStr(cell, format)).join(separator)),
);
} else {
// json
- lines = result.rows.map(r => {
+ lines = queryResult.rows.map(r => {
const outputObject: Record = {};
for (let k = 0; k < r.length; k++) {
- const newName = result.header[k];
+ const newName = queryResult.header[k];
if (newName) {
outputObject[newName] = r[k];
}
@@ -347,6 +408,24 @@ export class QueryView extends React.PureComponent this.setState({ explainDialogOpen: false })}
+ setQueryString={(queryString: string) =>
+ this.setState({ queryString, explainDialogOpen: false, queryAst: parser(queryString) })
+ }
+ />
+ );
+ }
+
+ renderHistoryDialog() {
+ const { historyDialogOpen, queryHistory } = this.state;
+ if (!historyDialogOpen) return;
+
+ return (
+
+ this.setState({ queryString, queryAst: parser(queryString), historyDialogOpen: false })
+ }
+ onClose={() => this.setState({ historyDialogOpen: false })}
/>
);
}
@@ -372,13 +451,11 @@ export class QueryView extends React.PureComponent
this.setAutoRun(autoRun)}
onEditContext={() => this.setState({ editContextDialogOpen: true })}
runeMode={runeMode}
queryContext={queryContext}
onQueryContextChange={this.handleQueryContextChange}
onRun={this.handleRun}
onExplain={this.handleExplain}
+ onHistory={() => this.setState({ historyDialogOpen: true })}
/>
- {queryExtraInfo && (
-
+ {result && (
+
)}
);
}
- private sqlOrderBy = (header: string, direction: 'ASC' | 'DESC'): void => {
- let { ast } = this.state;
- if (!ast) return;
- ast = ast.orderBy(header, direction);
+ private addFunctionToGroupBy = (
+ functionName: string,
+ spacing: string[],
+ argumentsArray: (StringType | number)[],
+ preferablyRun: boolean,
+ alias: Alias,
+ ): void => {
+ const { autoRun, queryAst } = this.state;
+ if (!queryAst) return;
+ const groupedAst = queryAst.addFunctionToGroupBy(functionName, spacing, argumentsArray, alias);
+ const queryString = groupedAst.toString();
this.setState({
- queryString: ast.toString(),
+ queryString,
+ queryAst: parser(queryString),
});
- this.handleRun(true, ast.toString());
+ if (autoRun && preferablyRun) {
+ this.handleRun(true, queryString);
+ }
};
- private sqlExcludeColumn = (header: string): void => {
- let { ast } = this.state;
- if (!ast) return;
- ast = ast.excludeColumn(header);
+ private addToGroupBy = (columnName: string, preferablyRun: boolean): void => {
+ const { autoRun, queryAst } = this.state;
+ if (!queryAst) return;
+ const groupedAst = queryAst.addToGroupBy(columnName);
+ const queryString = groupedAst.toString();
this.setState({
- queryString: ast.toString(),
+ queryString,
+ queryAst: parser(queryString),
});
- this.handleRun(true, ast.toString());
+ if (autoRun && preferablyRun) {
+ this.handleRun(true, queryString);
+ }
};
- private sqlFilterRow = (row: string, header: string, operator: '!=' | '='): void => {
- let { ast } = this.state;
- if (!ast) return;
- ast = ast.filterRow(header, row, operator);
+ private addAggregateColumn = (
+ columnName: string | RefExpression,
+ functionName: string,
+ preferablyRun: boolean,
+ alias?: Alias,
+ distinct?: boolean,
+ filter?: FilterClause,
+ ): void => {
+ const { autoRun, queryAst } = this.state;
+ if (!queryAst) return;
+ const modifiedAst = queryAst.addAggregateColumn(
+ columnName,
+ functionName,
+ alias,
+ distinct,
+ filter,
+ );
+ const queryString = modifiedAst.toString();
this.setState({
- queryString: ast.toString(),
+ queryString,
+ queryAst: parser(queryString),
});
- this.handleRun(true, ast.toString());
+ if (autoRun && preferablyRun) {
+ this.handleRun(true, queryString);
+ }
+ };
+
+ private sqlOrderBy = (
+ header: string,
+ direction: 'ASC' | 'DESC',
+ preferablyRun: boolean,
+ ): void => {
+ const { autoRun, queryAst } = this.state;
+ if (!queryAst) return;
+ const modifiedAst = queryAst.orderBy(header, direction);
+ const queryString = modifiedAst.toString();
+ this.setState({
+ queryString,
+ queryAst: parser(queryString),
+ });
+ if (autoRun && preferablyRun) {
+ this.handleRun(true, queryString);
+ }
+ };
+
+ private sqlExcludeColumn = (header: string, preferablyRun: boolean): void => {
+ const { autoRun, queryAst } = this.state;
+ if (!queryAst) return;
+ const modifiedAst = queryAst.excludeColumn(header);
+ const queryString = modifiedAst.toString();
+ this.setState({
+ queryString,
+ queryAst: parser(queryString),
+ });
+ if (autoRun && preferablyRun) {
+ this.handleRun(true, queryString);
+ }
+ };
+
+ private sqlFilterRow = (filters: RowFilter[], preferablyRun: boolean): void => {
+ const { autoRun, queryAst } = this.state;
+ if (!queryAst) return;
+
+ let modifiedAst: SqlQuery = queryAst;
+ if (queryAst) {
+ for (const filter of filters) {
+ modifiedAst = modifiedAst.filterRow(filter.header, filter.row, filter.operator);
+ }
+ }
+ const queryString = modifiedAst.toString();
+ this.setState({
+ queryString,
+ queryAst: parser(queryString),
+ });
+ if (autoRun && preferablyRun) {
+ this.handleRun(true, queryString);
+ }
+ };
+
+ private sqlClearWhere = (): void => {
+ const { queryAst } = this.state;
+
+ if (!queryAst) return;
+ if (queryAst.whereClause) {
+ queryAst.whereClause = undefined;
+ }
};
private handleQueryStringChange = (queryString: string): void => {
- this.setState({ queryString });
+ this.setState({ queryString, queryAst: parser(queryString) });
};
private handleQueryContextChange = (queryContext: QueryContext) => {
this.setState({ queryContext });
};
+ private setAutoRun = (autoRun: boolean) => {
+ this.setState({ autoRun });
+ localStorageSet(LocalStorageKeys.AUTO_RUN, String(autoRun));
+ };
+
private handleRun = (wrapQuery: boolean, customQueryString?: string) => {
- const { queryString, queryContext } = this.state;
+ const { queryString, queryContext, queryHistory } = this.state;
if (!customQueryString) {
customQueryString = queryString;
}
+
+ while (queryHistory.length > 9) {
+ queryHistory.pop();
+ }
+ queryHistory.unshift({
+ version: `${new Date().toISOString()}`,
+ queryString: customQueryString,
+ });
+ let queryHistoryString;
+ try {
+ queryHistoryString = JSON.stringify(queryHistory);
+ } catch {}
+ if (queryHistoryString) {
+ localStorageSet(LocalStorageKeys.QUERY_HISTORY, queryHistoryString);
+ }
+
if (QueryView.isJsonLike(customQueryString) && !QueryView.validRune(customQueryString)) return;
localStorageSet(LocalStorageKeys.QUERY_KEY, customQueryString);
@@ -486,32 +680,47 @@ export class QueryView extends React.PureComponent {
+ const { queryString, queryAst } = this.state;
+ const ast = queryAst;
let tempAst: SqlQuery | undefined;
if (!ast) {
- try {
- tempAst = parser(queryString);
- } catch {}
+ tempAst = parser(queryString);
}
- let defaultSchema;
+
+ let hasGroupBy = false;
if (ast && ast instanceof SqlQuery) {
- defaultSchema = ast.getSchema();
+ hasGroupBy = !!ast.groupByClause;
} else if (tempAst && tempAst instanceof SqlQuery) {
- defaultSchema = tempAst.getSchema();
+ hasGroupBy = !!tempAst.groupByClause;
+ }
+ return hasGroupBy;
+ };
+
+ private getQueryAst = () => {
+ const { queryAst } = this.state;
+ return queryAst;
+ };
+
+ private onQueryStringChange = (queryString: string) => {
+ const { autoRun } = this.state;
+
+ this.handleQueryStringChange(queryString);
+ if (autoRun) {
+ this.handleRun(true, queryString);
+ }
+ };
+
+ render(): JSX.Element {
+ const { columnMetadata, columnMetadataLoading, columnMetadataError, queryAst } = this.state;
+
+ let defaultSchema;
+ if (queryAst && queryAst instanceof SqlQuery) {
+ defaultSchema = queryAst.getSchema();
}
let defaultTable;
- if (ast && ast instanceof SqlQuery) {
- defaultTable = ast.getTableName();
- } else if (tempAst && tempAst instanceof SqlQuery) {
- defaultTable = tempAst.getTableName();
+ if (queryAst && queryAst instanceof SqlQuery) {
+ defaultTable = queryAst.getTableName();
}
return (
@@ -520,15 +729,23 @@ export class QueryView extends React.PureComponent
{!columnMetadataError && (
)}
{this.renderMainArea()}
{this.renderExplainDialog()}
+ {this.renderHistoryDialog()}
{this.renderEditContextDialog()}
);
diff --git a/web-console/src/views/query-view/run-button/run-button.spec.tsx b/web-console/src/views/query-view/run-button/run-button.spec.tsx
index 1560f7a31b0..d61e5f77587 100644
--- a/web-console/src/views/query-view/run-button/run-button.spec.tsx
+++ b/web-console/src/views/query-view/run-button/run-button.spec.tsx
@@ -25,6 +25,9 @@ describe('run button', () => {
it('matches snapshot', () => {
const runButton = (
null}
+ onHistory={() => null}
onEditContext={() => null}
runeMode={false}
queryContext={{}}
diff --git a/web-console/src/views/query-view/run-button/run-button.tsx b/web-console/src/views/query-view/run-button/run-button.tsx
index 037f4b3acea..276288ba3fb 100644
--- a/web-console/src/views/query-view/run-button/run-button.tsx
+++ b/web-console/src/views/query-view/run-button/run-button.tsx
@@ -45,11 +45,14 @@ import { DRUID_DOCS_RUNE, DRUID_DOCS_SQL } from '../../../variables';
export interface RunButtonProps {
runeMode: boolean;
+ autoRun: boolean;
queryContext: QueryContext;
onQueryContextChange: (newQueryContext: QueryContext) => void;
onRun: (wrapQuery: boolean) => void;
onExplain: () => void;
onEditContext: () => void;
+ onHistory: () => void;
+ setAutoRun: (autoRun: boolean) => void;
}
interface RunButtonState {
@@ -86,7 +89,16 @@ export class RunButton extends React.PureComponent
+
this.setState({ wrapQuery: !wrapQuery })}
/>
+ setAutoRun(!autoRun)}
+ />