Update QueryView to use latest DruidQueryToolkit (#10201)

* Update to latest DruidQueryToolkit

* add THEN keyword

* do not crash on invalid JSON
This commit is contained in:
Vadim Ogievetsky 2020-07-23 22:45:01 -07:00 committed by GitHub
parent e363b1cd20
commit 6d8799f2df
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
27 changed files with 999 additions and 1167 deletions

View File

@ -4707,7 +4707,7 @@ license_category: binary
module: web-console
license_name: Apache License version 2.0
copyright: Imply Data
version: 0.6.1
version: 0.8.4
---

View File

@ -28,6 +28,9 @@ exports.SQL_KEYWORDS = [
'FROM',
'WHERE',
'GROUP BY',
'CUBE',
'ROLLUP',
'GROUPING SETS',
'HAVING',
'ORDER BY',
'ASC',
@ -48,6 +51,7 @@ exports.SQL_EXPRESSION_PARTS = [
'END',
'ELSE',
'WHEN',
'THEN',
'CASE',
'OR',
'AND',
@ -56,7 +60,9 @@ exports.SQL_EXPRESSION_PARTS = [
'IS',
'TO',
'BETWEEN',
'SYMMETRIC',
'LIKE',
'SIMILAR',
'ESCAPE',
'BOTH',
'LEADING',

View File

@ -4243,9 +4243,9 @@
}
},
"druid-query-toolkit": {
"version": "0.6.1",
"resolved": "https://registry.npmjs.org/druid-query-toolkit/-/druid-query-toolkit-0.6.1.tgz",
"integrity": "sha512-ykrWD9AbDQEvE55x8ST1kyiiGHSN8zhp/Lqe7z43/l7XG9QD7AQwfBzOn+HATXFynrOQN/3z3Cis70EzdDjc1g==",
"version": "0.8.4",
"resolved": "https://registry.npmjs.org/druid-query-toolkit/-/druid-query-toolkit-0.8.4.tgz",
"integrity": "sha512-d0/OJDh6xNxlmqu874v1K2yGH0DD5ZXhRGR8iRFNDRUUZzTLSIdOTyaulHxCQ8j1bpfhB6VN+XTWzd0V6AgqbQ==",
"requires": {
"tslib": "^1.10.0"
}

View File

@ -68,7 +68,7 @@
"d3-axis": "^1.0.12",
"d3-scale": "^3.2.0",
"d3-selection": "^1.4.0",
"druid-query-toolkit": "^0.6.1",
"druid-query-toolkit": "^0.8.4",
"file-saver": "^2.0.2",
"has-own-prop": "^2.0.0",
"hjson": "^3.2.1",

View File

@ -23,6 +23,10 @@ const fs = require('fs-extra');
const readfile = '../docs/querying/sql.md';
const writefile = 'lib/sql-docs.js';
function unwrapMarkdownLinks(str) {
return str.replace(/\[([^\]]+)\]\([^)]+\)/g, (_, s) => s);
}
const readDoc = async () => {
const data = await fs.readFile(readfile, 'utf-8');
const lines = data.split('\n');
@ -35,7 +39,7 @@ const readDoc = async () => {
functionDocs.push({
name: functionMatch[1],
arguments: functionMatch[2],
description: functionMatch[3],
description: unwrapMarkdownLinks(functionMatch[3]),
});
}
@ -43,7 +47,9 @@ const readDoc = async () => {
if (dataTypeMatch) {
dataTypeDocs.push({
name: dataTypeMatch[1],
description: dataTypeMatch[4] || `Druid runtime type: ${dataTypeMatch[2]}`,
description: unwrapMarkdownLinks(
dataTypeMatch[4] || `Druid runtime type: ${dataTypeMatch[2]}`,
),
});
}
}

View File

@ -309,10 +309,6 @@ export function downloadFile(text: string, type: string, filename: string): void
FileSaver.saveAs(blob, filename);
}
export function escapeSqlIdentifier(identifier: string): string {
return `"${identifier.replace(/"/g, '""')}"`;
}
export function copyAndAlert(copyString: string, alertMessage: string): void {
copy(copyString, { format: 'text/plain' });
AppToaster.show({

View File

@ -20,4 +20,5 @@ export * from './general';
export * from './druid-query';
export * from './query-manager';
export * from './query-state';
export * from './query-cursor';
export * from './local-storage-keys';

View File

@ -0,0 +1,64 @@
/*
* 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 { SqlBase, SqlLiteral, SqlQuery } from 'druid-query-toolkit';
export const EMPTY_LITERAL = SqlLiteral.create('');
const CRAZY_STRING = '$.X.@.X.$';
const DOT_DOT_DOT_LITERAL = SqlLiteral.create('...');
export function prettyPrintSql(b: SqlBase): string {
return b
.walk(b => {
if (b === EMPTY_LITERAL) {
return DOT_DOT_DOT_LITERAL;
}
return b;
})
.prettyTrim(50)
.toString();
}
export interface RowColumn {
row: number;
column: number;
}
export function findEmptyLiteralPosition(query: SqlQuery): RowColumn | undefined {
const subQueryString = query
.walk(b => {
if (b === EMPTY_LITERAL) {
return SqlLiteral.create(CRAZY_STRING);
}
return b;
})
.toString();
const crazyIndex = subQueryString.indexOf(CRAZY_STRING);
if (crazyIndex < 0) return;
const prefix = subQueryString.substr(0, crazyIndex);
const lines = prefix.split(/\n/g);
const row = lines.length - 1;
const lastLine = lines[row];
return {
row,
column: lastLine.length,
};
}

View File

@ -20,6 +20,7 @@ import { FormGroup, InputGroup, Intent, MenuItem, Switch } from '@blueprintjs/co
import { IconNames } from '@blueprintjs/icons';
import axios from 'axios';
import classNames from 'classnames';
import { SqlQuery, SqlRef } from 'druid-query-toolkit';
import React from 'react';
import ReactTable, { Filter } from 'react-table';
@ -41,7 +42,6 @@ import { AppToaster } from '../../singletons/toaster';
import {
addFilter,
countBy,
escapeSqlIdentifier,
formatBytes,
formatNumber,
getDruidErrorMessage,
@ -598,7 +598,7 @@ GROUP BY 1`;
{
icon: IconNames.APPLICATION,
title: 'Query with SQL',
onAction: () => goToQuery(`SELECT * FROM ${escapeSqlIdentifier(datasource)}`),
onAction: () => goToQuery(SqlQuery.create(SqlRef.table(datasource)).toString()),
},
{
icon: IconNames.GANTT_CHART,

View File

@ -8,7 +8,7 @@ exports[`sql view matches snapshot 1`] = `
columnMetadataLoading={true}
defaultSchema="druid"
getParsedQuery={[Function]}
onQueryStringChange={[Function]}
onQueryChange={[Function]}
/>
<t
customClassName=""
@ -90,7 +90,7 @@ exports[`sql view matches snapshot with query 1`] = `
columnMetadataLoading={true}
defaultSchema="druid"
getParsedQuery={[Function]}
onQueryStringChange={[Function]}
onQueryChange={[Function]}
/>
<t
customClassName=""

View File

@ -17,7 +17,7 @@
*/
import { render } from '@testing-library/react';
import { parseSqlQuery } from 'druid-query-toolkit';
import { SqlQuery } from 'druid-query-toolkit';
import React from 'react';
import { NumberMenuItems } from './number-menu-items';
@ -29,7 +29,7 @@ describe('number menu', () => {
schema="schema"
table="table"
columnName={'added'}
parsedQuery={parseSqlQuery(`SELECT channel, count(*) as cnt FROM wikipedia GROUP BY 1`)}
parsedQuery={SqlQuery.parse(`SELECT channel, count(*) as cnt FROM wikipedia GROUP BY 1`)}
onQueryChange={() => {}}
/>
);
@ -44,7 +44,7 @@ describe('number menu', () => {
schema="schema"
table="table"
columnName={'added'}
parsedQuery={parseSqlQuery(`SELECT added, count(*) as cnt FROM wikipedia GROUP BY 1`)}
parsedQuery={SqlQuery.parse(`SELECT added, count(*) as cnt FROM wikipedia GROUP BY 1`)}
onQueryChange={() => {}}
/>
);

View File

@ -18,84 +18,55 @@
import { MenuItem } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
import {
SqlAliasRef,
SqlFunction,
SqlLiteral,
SqlMulti,
SqlQuery,
SqlRef,
} from 'druid-query-toolkit';
import { SqlExpression, SqlFunction, SqlLiteral, SqlQuery, SqlRef } from 'druid-query-toolkit';
import React from 'react';
import { getCurrentColumns } from '../../column-tree';
import { prettyPrintSql } from '../../../../../utils';
const NINE_THOUSAND = SqlLiteral.create(9000);
export interface NumberMenuItemsProps {
table: string;
schema: string;
columnName: string;
parsedQuery: SqlQuery;
onQueryChange: (queryString: SqlQuery, run?: boolean) => void;
onQueryChange: (query: SqlQuery, run?: boolean) => void;
}
export const NumberMenuItems = React.memo(function NumberMenuItems(props: NumberMenuItemsProps) {
function renderFilterMenu(): JSX.Element {
const { columnName, parsedQuery, onQueryChange } = props;
const ref = SqlRef.column(columnName);
function filterMenuItem(clause: SqlExpression) {
return (
<MenuItem
text={prettyPrintSql(clause)}
onClick={() => {
onQueryChange(parsedQuery.addToWhere(clause));
}}
/>
);
}
return (
<MenuItem icon={IconNames.FILTER} text={`Filter`}>
<MenuItem
text={`"${columnName}" > 100`}
onClick={() => {
onQueryChange(
parsedQuery.addWhereFilter(
SqlRef.fromStringWithDoubleQuotes(columnName),
'>',
SqlLiteral.fromInput(100),
),
);
}}
/>
<MenuItem
text={`"${columnName}" <= 100`}
onClick={() => {
onQueryChange(
parsedQuery.addWhereFilter(
SqlRef.fromStringWithDoubleQuotes(columnName),
'<=',
SqlLiteral.fromInput(100),
),
);
}}
/>
{filterMenuItem(ref.greaterThan(NINE_THOUSAND))}
{filterMenuItem(ref.lessThanOrEqual(NINE_THOUSAND))}
</MenuItem>
);
}
function renderRemoveFilter(): JSX.Element | undefined {
const { columnName, parsedQuery, onQueryChange } = props;
if (!parsedQuery.getCurrentFilters().includes(columnName)) return;
if (!parsedQuery.getEffectiveWhereExpression().containsColumn(columnName)) return;
return (
<MenuItem
icon={IconNames.FILTER_REMOVE}
text={`Remove filter`}
onClick={() => {
onQueryChange(parsedQuery.removeFilter(columnName), true);
}}
/>
);
}
function renderRemoveGroupBy(): JSX.Element | undefined {
const { columnName, parsedQuery, onQueryChange } = props;
if (!parsedQuery.hasGroupByColumn(columnName)) return;
return (
<MenuItem
icon={IconNames.UNGROUP_OBJECTS}
text={'Remove group by'}
onClick={() => {
onQueryChange(parsedQuery.removeFromGroupBy(columnName), true);
onQueryChange(parsedQuery.removeColumnFromWhere(columnName), true);
}}
/>
);
@ -103,155 +74,78 @@ export const NumberMenuItems = React.memo(function NumberMenuItems(props: Number
function renderGroupByMenu(): JSX.Element | undefined {
const { columnName, parsedQuery, onQueryChange } = props;
if (!parsedQuery.groupByExpression) return;
if (!parsedQuery.hasGroupBy()) return;
const ref = SqlRef.column(columnName);
function groupByMenuItem(ex: SqlExpression, alias?: string) {
return (
<MenuItem
text={prettyPrintSql(ex)}
onClick={() => {
onQueryChange(parsedQuery.addToGroupBy(ex.as(alias)), true);
}}
/>
);
}
return (
<MenuItem icon={IconNames.GROUP_OBJECTS} text={`Group by`}>
<MenuItem
text={`"${columnName}"`}
onClick={() => {
onQueryChange(
parsedQuery.addToGroupBy(SqlRef.fromStringWithDoubleQuotes(columnName)),
true,
);
}}
/>
<MenuItem
text={`TRUNC("${columnName}", -1) AS "${columnName}_trunc"`}
onClick={() => {
onQueryChange(
parsedQuery.addToGroupBy(
SqlAliasRef.sqlAliasFactory(
SqlFunction.sqlFunctionFactory('TRUNC', [
SqlRef.fromStringWithDoubleQuotes(columnName),
SqlLiteral.fromInput(-1),
]),
`${columnName}_truncated`,
),
),
true,
);
}}
/>
{groupByMenuItem(ref)}
{groupByMenuItem(
SqlFunction.simple('TRUNC', [ref, SqlLiteral.create(-1)]),
`${columnName}_truncated`,
)}
</MenuItem>
);
}
function renderRemoveGroupBy(): JSX.Element | undefined {
const { columnName, parsedQuery, onQueryChange } = props;
const selectIndex = parsedQuery.getSelectIndexForColumn(columnName);
if (!parsedQuery.isGroupedSelectIndex(selectIndex)) return;
return (
<MenuItem
icon={IconNames.UNGROUP_OBJECTS}
text={'Remove group by'}
onClick={() => {
onQueryChange(parsedQuery.removeSelectIndex(selectIndex), true);
}}
/>
);
}
function renderAggregateMenu(): JSX.Element | undefined {
const { columnName, parsedQuery, onQueryChange } = props;
if (!parsedQuery.groupByExpression) return;
if (!parsedQuery.hasGroupBy()) return;
const ref = SqlRef.column(columnName);
function aggregateMenuItem(ex: SqlExpression, alias: string) {
return (
<MenuItem
text={prettyPrintSql(ex)}
onClick={() => {
onQueryChange(parsedQuery.addSelectExpression(ex.as(alias)), true);
}}
/>
);
}
return (
<MenuItem icon={IconNames.FUNCTION} text={`Aggregate`}>
<MenuItem
text={`SUM(${columnName}) AS "sum_${columnName}"`}
onClick={() => {
onQueryChange(
parsedQuery.addAggregateColumn(
[SqlRef.fromString(columnName)],
'SUM',
`sum_${columnName}`,
),
true,
);
}}
/>
<MenuItem
text={`MAX(${columnName}) AS "max_${columnName}"`}
onClick={() => {
onQueryChange(
parsedQuery.addAggregateColumn(
[SqlRef.fromString(columnName)],
'MAX',
`max_${columnName}`,
),
true,
);
}}
/>
<MenuItem
text={`MIN(${columnName}) AS "min_${columnName}"`}
onClick={() => {
onQueryChange(
parsedQuery.addAggregateColumn(
[SqlRef.fromString(columnName)],
'MIN',
`min_${columnName}`,
),
true,
);
}}
/>
{aggregateMenuItem(SqlFunction.simple('SUM', [ref]), `sum_${columnName}`)}
{aggregateMenuItem(SqlFunction.simple('MIN', [ref]), `min_${columnName}`)}
{aggregateMenuItem(SqlFunction.simple('MAX', [ref]), `max_${columnName}`)}
{aggregateMenuItem(SqlFunction.simple('AVG', [ref]), `avg_${columnName}`)}
{aggregateMenuItem(
SqlFunction.simple('APPROX_QUANTILE', [ref, SqlLiteral.create(0.98)]),
`p98_${columnName}`,
)}
{aggregateMenuItem(SqlFunction.simple('LATEST', [ref]), `latest_${columnName}`)}
</MenuItem>
);
}
function renderJoinMenu(): JSX.Element | undefined {
const { schema, table, columnName, parsedQuery, onQueryChange } = props;
if (schema !== 'lookup' || !parsedQuery) return;
const { originalTableColumn, lookupColumn } = getCurrentColumns(parsedQuery, table);
return (
<>
<MenuItem
icon={IconNames.JOIN_TABLE}
text={parsedQuery.joinTable ? `Replace join` : `Join`}
>
<MenuItem
icon={IconNames.LEFT_JOIN}
text={`Left join`}
onClick={() => {
onQueryChange(
parsedQuery.addJoin(
'LEFT',
SqlRef.fromString(table, schema).upgrade(),
SqlMulti.sqlMultiFactory('=', [
SqlRef.fromString(columnName, table, 'lookup'),
SqlRef.fromString(
lookupColumn === columnName ? originalTableColumn : 'XXX',
parsedQuery.getTableName(),
),
]),
),
false,
);
}}
/>
<MenuItem
icon={IconNames.INNER_JOIN}
text={`Inner join`}
onClick={() => {
onQueryChange(
parsedQuery.addJoin(
'INNER',
SqlRef.fromString(table, schema).upgrade(),
SqlMulti.sqlMultiFactory('=', [
SqlRef.fromString(columnName, table, 'lookup'),
SqlRef.fromString(
lookupColumn === columnName ? originalTableColumn : 'XXX',
parsedQuery.getTableName(),
),
]),
),
false,
);
}}
/>
</MenuItem>
{parsedQuery.onExpression &&
parsedQuery.onExpression instanceof SqlMulti &&
parsedQuery.onExpression.containsColumn(columnName) && (
<MenuItem
icon={IconNames.EXCHANGE}
text={`Remove join`}
onClick={() => onQueryChange(parsedQuery.removeJoin())}
/>
)}
</>
);
}
return (
<>
{renderFilterMenu()}
@ -259,7 +153,6 @@ export const NumberMenuItems = React.memo(function NumberMenuItems(props: Number
{renderGroupByMenu()}
{renderRemoveGroupBy()}
{renderAggregateMenu()}
{renderJoinMenu()}
</>
);
});

View File

@ -17,7 +17,7 @@
*/
import { render } from '@testing-library/react';
import { parseSqlQuery } from 'druid-query-toolkit';
import { SqlQuery } from 'druid-query-toolkit';
import React from 'react';
import { StringMenuItems } from './string-menu-items';
@ -29,7 +29,7 @@ describe('string menu', () => {
table={'table'}
schema={'schema'}
columnName={'cityName'}
parsedQuery={parseSqlQuery(`SELECT channel, count(*) as cnt FROM wikipedia GROUP BY 1`)}
parsedQuery={SqlQuery.parse(`SELECT channel, count(*) as cnt FROM wikipedia GROUP BY 1`)}
onQueryChange={() => {}}
/>
);
@ -44,7 +44,7 @@ describe('string menu', () => {
table={'table'}
schema={'schema'}
columnName={'channel'}
parsedQuery={parseSqlQuery(`SELECT channel, count(*) as cnt FROM wikipedia GROUP BY 1`)}
parsedQuery={SqlQuery.parse(`SELECT channel, count(*) as cnt FROM wikipedia GROUP BY 1`)}
onQueryChange={() => {}}
/>
);

View File

@ -19,57 +19,62 @@
import { MenuItem } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
import {
SqlAliasRef,
SqlExpression,
SqlFunction,
SqlJoinPart,
SqlLiteral,
SqlMulti,
SqlQuery,
SqlRef,
} from 'druid-query-toolkit';
import React from 'react';
import { getCurrentColumns } from '../../column-tree';
import { EMPTY_LITERAL, prettyPrintSql } from '../../../../../utils';
import { getJoinColumns } from '../../column-tree';
export interface StringMenuItemsProps {
schema: string;
table: string;
columnName: string;
parsedQuery: SqlQuery;
onQueryChange: (queryString: SqlQuery, run?: boolean) => void;
onQueryChange: (query: SqlQuery, run?: boolean) => void;
}
export const StringMenuItems = React.memo(function StringMenuItems(props: StringMenuItemsProps) {
function renderFilterMenu(): JSX.Element | undefined {
const { columnName, parsedQuery, onQueryChange } = props;
const ref = SqlRef.column(columnName);
function filterMenuItem(clause: SqlExpression, run = true) {
return (
<MenuItem
text={prettyPrintSql(clause)}
onClick={() => {
onQueryChange(parsedQuery.addToWhere(clause), run);
}}
/>
);
}
return (
<MenuItem icon={IconNames.FILTER} text={`Filter`}>
<MenuItem
text={`"${columnName}" = 'xxx'`}
onClick={() => {
onQueryChange(parsedQuery.addWhereFilter(columnName, '=', 'xxx'), false);
}}
/>
<MenuItem
text={`"${columnName}" LIKE '%xxx%'`}
onClick={() => {
onQueryChange(parsedQuery.addWhereFilter(columnName, 'LIKE', '%xxx%'), false);
}}
/>
{filterMenuItem(ref.isNotNull())}
{filterMenuItem(ref.equal(EMPTY_LITERAL), false)}
{filterMenuItem(ref.like(EMPTY_LITERAL), false)}
{filterMenuItem(SqlFunction.simple('REGEXP_LIKE', [ref, EMPTY_LITERAL]), false)}
</MenuItem>
);
}
function renderRemoveFilter(): JSX.Element | undefined {
const { columnName, parsedQuery, onQueryChange } = props;
if (!parsedQuery.getCurrentFilters().includes(columnName)) return;
if (!parsedQuery.getEffectiveWhereExpression().containsColumn(columnName)) return;
return (
<MenuItem
icon={IconNames.FILTER_REMOVE}
text={`Remove filter`}
onClick={() => {
onQueryChange(parsedQuery.removeFilter(columnName), true);
onQueryChange(parsedQuery.removeColumnFromWhere(columnName), true);
}}
/>
);
@ -77,13 +82,15 @@ export const StringMenuItems = React.memo(function StringMenuItems(props: String
function renderRemoveGroupBy(): JSX.Element | undefined {
const { columnName, parsedQuery, onQueryChange } = props;
if (!parsedQuery.hasGroupByColumn(columnName)) return;
const selectIndex = parsedQuery.getSelectIndexForColumn(columnName);
if (!parsedQuery.isGroupedSelectIndex(selectIndex)) return;
return (
<MenuItem
icon={IconNames.UNGROUP_OBJECTS}
text={'Remove group by'}
onClick={() => {
onQueryChange(parsedQuery.removeFromGroupBy(columnName), true);
onQueryChange(parsedQuery.removeSelectIndex(selectIndex), true);
}}
/>
);
@ -91,78 +98,69 @@ export const StringMenuItems = React.memo(function StringMenuItems(props: String
function renderGroupByMenu(): JSX.Element | undefined {
const { columnName, parsedQuery, onQueryChange } = props;
if (!parsedQuery.groupByExpression) return;
if (!parsedQuery.hasGroupBy()) return;
function groupByMenuItem(ex: SqlExpression, alias?: string) {
return (
<MenuItem
text={prettyPrintSql(ex)}
onClick={() => {
onQueryChange(parsedQuery.addToGroupBy(alias ? ex.as(alias) : ex), true);
}}
/>
);
}
return (
<MenuItem icon={IconNames.GROUP_OBJECTS} text={`Group by`}>
<MenuItem
text={`"${columnName}"`}
onClick={() => {
onQueryChange(
parsedQuery.addToGroupBy(SqlRef.fromStringWithDoubleQuotes(columnName)),
true,
);
}}
/>
<MenuItem
text={`SUBSTRING("${columnName}", 1, 2) AS "${columnName}_substring"`}
onClick={() => {
onQueryChange(
parsedQuery.addToGroupBy(
SqlAliasRef.sqlAliasFactory(
SqlFunction.sqlFunctionFactory('SUBSTRING', [
SqlRef.fromStringWithDoubleQuotes(columnName),
SqlLiteral.fromInput(1),
SqlLiteral.fromInput(2),
]),
`${columnName}_substring`,
),
),
true,
);
}}
/>
{groupByMenuItem(SqlRef.column(columnName))}
{groupByMenuItem(
SqlFunction.simple('SUBSTRING', [
SqlRef.column(columnName),
SqlLiteral.create(1),
SqlLiteral.create(2),
]),
`${columnName}_substring`,
)}
{groupByMenuItem(
SqlFunction.simple('REGEXP_EXTRACT', [
SqlRef.column(columnName),
SqlLiteral.create('(\\d+)'),
]),
`${columnName}_extract`,
)}
</MenuItem>
);
}
function renderAggregateMenu(): JSX.Element | undefined {
const { columnName, parsedQuery, onQueryChange } = props;
if (!parsedQuery.groupByExpression) return;
if (!parsedQuery.hasGroupBy()) return;
const ref = SqlRef.column(columnName);
function aggregateMenuItem(ex: SqlExpression, alias: string, run = true) {
return (
<MenuItem
text={prettyPrintSql(ex)}
onClick={() => {
onQueryChange(parsedQuery.addSelectExpression(ex.as(alias)), run);
}}
/>
);
}
return (
<MenuItem icon={IconNames.FUNCTION} text={`Aggregate`}>
<MenuItem
text={`COUNT(DISTINCT "${columnName}") AS "dist_${columnName}"`}
onClick={() =>
onQueryChange(
parsedQuery.addAggregateColumn(
[SqlRef.fromStringWithDoubleQuotes(columnName)],
'COUNT',
`dist_${columnName}`,
undefined,
'DISTINCT',
),
true,
)
}
/>
<MenuItem
text={`COUNT(*) FILTER (WHERE "${columnName}" = 'xxx') AS ${columnName}_filtered_count `}
onClick={() => {
onQueryChange(
parsedQuery.addAggregateColumn(
[SqlRef.fromString('*')],
'COUNT',
`${columnName}_filtered_count`,
SqlMulti.sqlMultiFactory('=', [
SqlRef.fromStringWithDoubleQuotes(columnName),
SqlLiteral.fromInput('xxx'),
]),
),
);
}}
/>
{aggregateMenuItem(SqlFunction.decorated('COUNT', 'DISTINCT', [ref]), `dist_${columnName}`)}
{aggregateMenuItem(
SqlFunction.simple('COUNT', [SqlRef.STAR], ref.equal(EMPTY_LITERAL)),
`filtered_dist_${columnName}`,
false,
)}
{aggregateMenuItem(
SqlFunction.simple('LATEST', [ref, SqlLiteral.create(100)]),
`latest_${columnName}`,
)}
</MenuItem>
);
}
@ -171,65 +169,67 @@ export const StringMenuItems = React.memo(function StringMenuItems(props: String
const { schema, table, columnName, parsedQuery, onQueryChange } = props;
if (schema !== 'lookup' || !parsedQuery) return;
const { originalTableColumn, lookupColumn } = getCurrentColumns(parsedQuery, table);
const { originalTableColumn, lookupColumn } = getJoinColumns(parsedQuery, table);
return (
<>
<MenuItem icon={IconNames.JOIN_TABLE} text={parsedQuery.hasJoin() ? `Replace join` : `Join`}>
<MenuItem
icon={IconNames.JOIN_TABLE}
text={parsedQuery.joinTable ? `Replace join` : `Join`}
>
<MenuItem
icon={IconNames.LEFT_JOIN}
text={`Left join`}
onClick={() => {
onQueryChange(
parsedQuery.addJoin(
icon={IconNames.LEFT_JOIN}
text={`Left join`}
onClick={() => {
onQueryChange(
parsedQuery.addJoin(
SqlJoinPart.create(
'LEFT',
SqlRef.fromString(table, schema).upgrade(),
SqlMulti.sqlMultiFactory('=', [
SqlRef.fromString(columnName, table, 'lookup'),
SqlRef.fromString(
SqlRef.column(table, schema).upgrade(),
SqlRef.column(columnName, table, 'lookup').equal(
SqlRef.column(
lookupColumn === columnName ? originalTableColumn : 'XXX',
parsedQuery.getTableName(),
parsedQuery.getFirstTableName(),
),
]),
),
),
false,
);
}}
/>
<MenuItem
icon={IconNames.INNER_JOIN}
text={`Inner join`}
onClick={() => {
onQueryChange(
parsedQuery.addJoin(
),
false,
);
}}
/>
<MenuItem
icon={IconNames.INNER_JOIN}
text={`Inner join`}
onClick={() => {
onQueryChange(
parsedQuery.addJoin(
SqlJoinPart.create(
'INNER',
SqlRef.fromString(table, schema).upgrade(),
SqlMulti.sqlMultiFactory('=', [
SqlRef.fromString(columnName, table, 'lookup'),
SqlRef.fromString(
SqlRef.column(table, schema).upgrade(),
SqlRef.column(columnName, table, 'lookup').equal(
SqlRef.column(
lookupColumn === columnName ? originalTableColumn : 'XXX',
parsedQuery.getTableName(),
parsedQuery.getFirstTableName(),
),
]),
),
),
false,
);
}}
/>
</MenuItem>
{parsedQuery.onExpression &&
parsedQuery.onExpression instanceof SqlMulti &&
parsedQuery.onExpression.containsColumn(columnName) && (
<MenuItem
icon={IconNames.EXCHANGE}
text={`Remove join`}
onClick={() => onQueryChange(parsedQuery.removeJoin())}
/>
)}
</>
),
false,
);
}}
/>
</MenuItem>
);
}
function renderRemoveJoin(): JSX.Element | undefined {
const { schema, parsedQuery, onQueryChange } = props;
if (schema !== 'lookup' || !parsedQuery) return;
if (!parsedQuery.hasJoin()) return;
return (
<MenuItem
icon={IconNames.EXCHANGE}
text={`Remove join`}
onClick={() => onQueryChange(parsedQuery.removeAllJoins())}
/>
);
}
@ -241,6 +241,7 @@ export const StringMenuItems = React.memo(function StringMenuItems(props: String
{renderRemoveGroupBy()}
{renderAggregateMenu()}
{renderJoinMenu()}
{renderRemoveJoin()}
</>
);
});

View File

@ -17,7 +17,7 @@
*/
import { render } from '@testing-library/react';
import { parseSqlQuery } from 'druid-query-toolkit';
import { SqlQuery } from 'druid-query-toolkit';
import React from 'react';
import { TimeMenuItems } from './time-menu-items';
@ -29,7 +29,7 @@ describe('time menu', () => {
table={'table'}
schema={'schema'}
columnName={'__time'}
parsedQuery={parseSqlQuery(`SELECT channel, count(*) as cnt FROM wikipedia GROUP BY 1`)}
parsedQuery={SqlQuery.parse(`SELECT channel, count(*) as cnt FROM wikipedia GROUP BY 1`)}
onQueryChange={() => {}}
/>
);
@ -44,7 +44,7 @@ describe('time menu', () => {
table={'table'}
schema={'schema'}
columnName={'__time'}
parsedQuery={parseSqlQuery(`SELECT __time, count(*) as cnt FROM wikipedia GROUP BY 1`)}
parsedQuery={SqlQuery.parse(`SELECT __time, count(*) as cnt FROM wikipedia GROUP BY 1`)}
onQueryChange={() => {}}
/>
);

View File

@ -18,30 +18,45 @@
import { MenuDivider, MenuItem } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
import {
SqlAliasRef,
SqlFunction,
SqlInterval,
SqlLiteral,
SqlMulti,
SqlQuery,
SqlRef,
SqlTimestamp,
} from 'druid-query-toolkit';
import { SqlExpression, SqlFunction, SqlLiteral, SqlQuery, SqlRef } from 'druid-query-toolkit';
import React from 'react';
import { getCurrentColumns } from '../../column-tree';
import { prettyPrintSql } from '../../../../../utils';
function dateToTimestamp(date: Date): SqlTimestamp {
return SqlTimestamp.sqlTimestampFactory(
date
.toISOString()
.split('.')[0]
.split('T')
.join(' '),
);
const LATEST_HOUR: SqlExpression = SqlExpression.parse(
`? >= CURRENT_TIMESTAMP - INTERVAL '1' HOUR`,
);
const LATEST_DAY: SqlExpression = SqlExpression.parse(`? >= CURRENT_TIMESTAMP - INTERVAL '1' DAY`);
const LATEST_WEEK: SqlExpression = SqlExpression.parse(
`? >= CURRENT_TIMESTAMP - INTERVAL '1' WEEK`,
);
const LATEST_MONTH: SqlExpression = SqlExpression.parse(
`? >= CURRENT_TIMESTAMP - INTERVAL '1' MONTH`,
);
const LATEST_YEAR: SqlExpression = SqlExpression.parse(
`? >= CURRENT_TIMESTAMP - INTERVAL '1' YEAR`,
);
const BETWEEN: SqlExpression = SqlExpression.parse(`(? <= ? AND ? < ?)`);
// ------------------------------------
function fillWithColumn(b: SqlExpression, columnName: string): SqlExpression {
return b.fillPlaceholders([SqlRef.column(columnName)]) as SqlExpression;
}
function fillWithColumnStartEnd(columnName: string, start: Date, end: Date): SqlExpression {
const ref = SqlRef.column(columnName);
return BETWEEN.fillPlaceholders([
SqlLiteral.create(start),
ref,
ref,
SqlLiteral.create(end),
]) as SqlExpression;
}
// ------------------------------------
function floorHour(dt: Date): Date {
dt = new Date(dt.valueOf());
dt.setUTCMinutes(0, 0, 0);
@ -97,205 +112,67 @@ export interface TimeMenuItemsProps {
schema: string;
columnName: string;
parsedQuery: SqlQuery;
onQueryChange: (queryString: SqlQuery, run?: boolean) => void;
onQueryChange: (query: SqlQuery, run?: boolean) => void;
}
export const TimeMenuItems = React.memo(function TimeMenuItems(props: TimeMenuItemsProps) {
function renderFilterMenu(): JSX.Element | undefined {
const { columnName, parsedQuery, onQueryChange } = props;
const now = new Date();
function filterMenuItem(label: string, clause: SqlExpression) {
return (
<MenuItem
text={label}
onClick={() => {
onQueryChange(parsedQuery.removeColumnFromWhere(columnName).addToWhere(clause), true);
}}
/>
);
}
const now = new Date();
const hourStart = floorHour(now);
const dayStart = floorDay(now);
const monthStart = floorMonth(now);
const yearStart = floorYear(now);
return (
<MenuItem icon={IconNames.FILTER} text={`Filter`}>
<MenuItem
text={`Latest hour`}
onClick={() => {
onQueryChange(
parsedQuery
.removeFilter(columnName)
.addWhereFilter(
columnName,
'>=',
SqlMulti.sqlMultiFactory('-', [
SqlRef.fromString('CURRENT_TIMESTAMP'),
SqlInterval.sqlIntervalFactory('HOUR', 1),
]),
),
true,
);
}}
/>
<MenuItem
text={`Latest day`}
onClick={() => {
onQueryChange(
parsedQuery
.removeFilter(columnName)
.addWhereFilter(
columnName,
'>=',
SqlMulti.sqlMultiFactory('-', [
SqlRef.fromString('CURRENT_TIMESTAMP'),
SqlInterval.sqlIntervalFactory('Day', 1),
]),
),
true,
);
}}
/>
<MenuItem
text={`Latest week`}
onClick={() => {
onQueryChange(
parsedQuery
.removeFilter(columnName)
.addWhereFilter(
columnName,
'>=',
SqlMulti.sqlMultiFactory('-', [
SqlRef.fromString('CURRENT_TIMESTAMP'),
SqlInterval.sqlIntervalFactory('Day', 7),
]),
),
true,
);
}}
/>
<MenuItem
text={`Latest month`}
onClick={() => {
onQueryChange(
parsedQuery
.removeFilter(columnName)
.addWhereFilter(
columnName,
'>=',
SqlMulti.sqlMultiFactory('-', [
SqlRef.fromString('CURRENT_TIMESTAMP'),
SqlInterval.sqlIntervalFactory('MONTH', 1),
]),
),
true,
);
}}
/>
<MenuItem
text={`Latest year`}
onClick={() => {
onQueryChange(
parsedQuery
.removeFilter(columnName)
.addWhereFilter(
columnName,
'>=',
SqlMulti.sqlMultiFactory('-', [
SqlRef.fromString('CURRENT_TIMESTAMP'),
SqlInterval.sqlIntervalFactory('YEAR', 1),
]),
),
true,
);
}}
/>
{filterMenuItem(`Latest hour`, fillWithColumn(LATEST_HOUR, columnName))}
{filterMenuItem(`Latest day`, fillWithColumn(LATEST_DAY, columnName))}
{filterMenuItem(`Latest week`, fillWithColumn(LATEST_WEEK, columnName))}
{filterMenuItem(`Latest month`, fillWithColumn(LATEST_MONTH, columnName))}
{filterMenuItem(`Latest year`, fillWithColumn(LATEST_YEAR, columnName))}
<MenuDivider />
<MenuItem
text={`Current hour`}
onClick={() => {
const hourStart = floorHour(now);
onQueryChange(
parsedQuery
.removeFilter(columnName)
.addWhereFilter(
SqlRef.fromStringWithDoubleQuotes(columnName),
'>=',
dateToTimestamp(hourStart),
)
.addWhereFilter(
SqlRef.fromStringWithDoubleQuotes(columnName),
'<',
dateToTimestamp(nextHour(hourStart)),
),
true,
);
}}
/>
<MenuItem
text={`Current day`}
onClick={() => {
const dayStart = floorDay(now);
onQueryChange(
parsedQuery
.removeFilter(columnName)
.addWhereFilter(
SqlRef.fromStringWithDoubleQuotes(columnName),
'>=',
dateToTimestamp(dayStart),
)
.addWhereFilter(
SqlRef.fromStringWithDoubleQuotes(columnName),
'<',
dateToTimestamp(nextDay(dayStart)),
),
true,
);
}}
/>
<MenuItem
text={`Current month`}
onClick={() => {
const monthStart = floorMonth(now);
onQueryChange(
parsedQuery
.removeFilter(columnName)
.addWhereFilter(
SqlRef.fromStringWithDoubleQuotes(columnName),
'>=',
dateToTimestamp(monthStart),
)
.addWhereFilter(
SqlRef.fromStringWithDoubleQuotes(columnName),
'<',
dateToTimestamp(nextMonth(monthStart)),
),
true,
);
}}
/>
<MenuItem
text={`Current year`}
onClick={() => {
const yearStart = floorYear(now);
onQueryChange(
parsedQuery
.removeFilter(columnName)
.addWhereFilter(
SqlRef.fromStringWithDoubleQuotes(columnName),
'<=',
dateToTimestamp(yearStart),
)
.addWhereFilter(
dateToTimestamp(yearStart),
'<',
dateToTimestamp(nextYear(yearStart)),
),
true,
);
}}
/>
{filterMenuItem(
`Current hour`,
fillWithColumnStartEnd(columnName, hourStart, nextHour(hourStart)),
)}
{filterMenuItem(
`Current day`,
fillWithColumnStartEnd(columnName, dayStart, nextDay(dayStart)),
)}
{filterMenuItem(
`Current month`,
fillWithColumnStartEnd(columnName, monthStart, nextMonth(monthStart)),
)}
{filterMenuItem(
`Current year`,
fillWithColumnStartEnd(columnName, yearStart, nextYear(yearStart)),
)}
</MenuItem>
);
}
function renderRemoveFilter(): JSX.Element | undefined {
const { columnName, parsedQuery, onQueryChange } = props;
if (!parsedQuery.getCurrentFilters().includes(columnName)) return;
if (!parsedQuery.getEffectiveWhereExpression().containsColumn(columnName)) return;
return (
<MenuItem
icon={IconNames.FILTER_REMOVE}
text={`Remove filter`}
onClick={() => {
onQueryChange(parsedQuery.removeFilter(columnName), true);
onQueryChange(parsedQuery.removeColumnFromWhere(columnName), true);
}}
/>
);
@ -303,13 +180,15 @@ export const TimeMenuItems = React.memo(function TimeMenuItems(props: TimeMenuIt
function renderRemoveGroupBy(): JSX.Element | undefined {
const { columnName, parsedQuery, onQueryChange } = props;
if (!parsedQuery.hasGroupByColumn(columnName)) return;
const selectIndex = parsedQuery.getSelectIndexForColumn(columnName);
if (!parsedQuery.isGroupedSelectIndex(selectIndex)) return;
return (
<MenuItem
icon={IconNames.UNGROUP_OBJECTS}
text={'Remove group by'}
onClick={() => {
onQueryChange(parsedQuery.removeFromGroupBy(columnName), true);
onQueryChange(parsedQuery.removeSelectIndex(selectIndex), true);
}}
/>
);
@ -317,167 +196,83 @@ export const TimeMenuItems = React.memo(function TimeMenuItems(props: TimeMenuIt
function renderGroupByMenu(): JSX.Element | undefined {
const { columnName, parsedQuery, onQueryChange } = props;
if (!parsedQuery.groupByExpression) return;
if (!parsedQuery.hasGroupBy()) return;
const ref = SqlRef.column(columnName);
function groupByMenuItem(ex: SqlExpression, alias: string) {
return (
<MenuItem
text={prettyPrintSql(ex)}
onClick={() => {
onQueryChange(parsedQuery.addToGroupBy(ex.as(alias)), true);
}}
/>
);
}
return (
<MenuItem icon={IconNames.GROUP_OBJECTS} text={`Group by`}>
<MenuItem
text={`TIME_FLOOR("${columnName}", 'PT1H') AS "${columnName}_time_floor"`}
onClick={() => {
onQueryChange(
parsedQuery.addToGroupBy(
SqlAliasRef.sqlAliasFactory(
SqlFunction.sqlFunctionFactory('TIME_FLOOR', [
SqlRef.fromString(columnName),
SqlLiteral.fromInput('PT1h'),
]),
`${columnName}_time_floor`,
),
),
true,
);
}}
/>
<MenuItem
text={`TIME_FLOOR("${columnName}", 'P1D') AS "${columnName}_time_floor"`}
onClick={() => {
onQueryChange(
parsedQuery.addToGroupBy(
SqlAliasRef.sqlAliasFactory(
SqlFunction.sqlFunctionFactory('TIME_FLOOR', [
SqlRef.fromString(columnName),
SqlLiteral.fromInput('P1D'),
]),
`${columnName}_time_floor`,
),
),
true,
);
}}
/>
<MenuItem
text={`TIME_FLOOR("${columnName}", 'P7D') AS "${columnName}_time_floor"`}
onClick={() => {
onQueryChange(
parsedQuery.addToGroupBy(
SqlAliasRef.sqlAliasFactory(
SqlFunction.sqlFunctionFactory('TIME_FLOOR', [
SqlRef.fromString(columnName),
SqlLiteral.fromInput('P7D'),
]),
`${columnName}_time_floor`,
),
),
true,
);
}}
/>
{groupByMenuItem(
SqlFunction.simple('TIME_FLOOR', [ref, SqlLiteral.create('PT1H')]),
`${columnName}_by_hour`,
)}
{groupByMenuItem(
SqlFunction.simple('TIME_FLOOR', [ref, SqlLiteral.create('P1D')]),
`${columnName}_by_day`,
)}
{groupByMenuItem(
SqlFunction.simple('TIME_FLOOR', [ref, SqlLiteral.create('P1M')]),
`${columnName}_by_month`,
)}
{groupByMenuItem(
SqlFunction.simple('TIME_FLOOR', [ref, SqlLiteral.create('P1Y')]),
`${columnName}_by_year`,
)}
<MenuDivider />
{groupByMenuItem(
SqlFunction.simple('TIME_EXTRACT', [ref, SqlLiteral.create('HOUR')]),
`hour_of_${columnName}`,
)}
{groupByMenuItem(
SqlFunction.simple('TIME_EXTRACT', [ref, SqlLiteral.create('DAY')]),
`day_of_${columnName}`,
)}
{groupByMenuItem(
SqlFunction.simple('TIME_EXTRACT', [ref, SqlLiteral.create('MONTH')]),
`month_of_${columnName}`,
)}
{groupByMenuItem(
SqlFunction.simple('TIME_EXTRACT', [ref, SqlLiteral.create('YEAR')]),
`year_of_${columnName}`,
)}
</MenuItem>
);
}
function renderAggregateMenu(): JSX.Element | undefined {
const { columnName, parsedQuery, onQueryChange } = props;
if (!parsedQuery.groupByExpression) return;
if (!parsedQuery.hasGroupBy()) return;
const ref = SqlRef.column(columnName);
function aggregateMenuItem(ex: SqlExpression, alias: string) {
return (
<MenuItem
text={prettyPrintSql(ex)}
onClick={() => {
onQueryChange(parsedQuery.addSelectExpression(ex.as(alias)), true);
}}
/>
);
}
return (
<MenuItem icon={IconNames.FUNCTION} text={`Aggregate`}>
<MenuItem
text={`MAX("${columnName}") AS "max_${columnName}"`}
onClick={() => {
onQueryChange(
parsedQuery.addAggregateColumn(
[SqlRef.fromStringWithDoubleQuotes(columnName)],
'MAX',
`max_${columnName}`,
),
true,
);
}}
/>
<MenuItem
text={`MIN("${columnName}") AS "min_${columnName}"`}
onClick={() => {
onQueryChange(
parsedQuery.addAggregateColumn(
[SqlRef.fromStringWithDoubleQuotes(columnName)],
'MIN',
`min_${columnName}`,
),
true,
);
}}
/>
{aggregateMenuItem(SqlFunction.simple('MAX', [ref]), `max_${columnName}`)}
{aggregateMenuItem(SqlFunction.simple('MIN', [ref]), `min_${columnName}`)}
</MenuItem>
);
}
function renderJoinMenu(): JSX.Element | undefined {
const { schema, table, columnName, parsedQuery, onQueryChange } = props;
if (schema !== 'lookup' || !parsedQuery) return;
const { originalTableColumn, lookupColumn } = getCurrentColumns(parsedQuery, table);
return (
<>
<MenuItem
icon={IconNames.JOIN_TABLE}
text={parsedQuery.joinTable ? `Replace join` : `Join`}
>
<MenuItem
icon={IconNames.LEFT_JOIN}
text={`Left join`}
onClick={() => {
onQueryChange(
parsedQuery.addJoin(
'LEFT',
SqlRef.fromString(table, schema).upgrade(),
SqlMulti.sqlMultiFactory('=', [
SqlRef.fromString(columnName, table, 'lookup'),
SqlRef.fromString(
lookupColumn === columnName ? originalTableColumn : 'XXX',
parsedQuery.getTableName(),
),
]),
),
false,
);
}}
/>
<MenuItem
icon={IconNames.INNER_JOIN}
text={`Inner join`}
onClick={() => {
onQueryChange(
parsedQuery.addJoin(
'INNER',
SqlRef.fromString(table, schema).upgrade(),
SqlMulti.sqlMultiFactory('=', [
SqlRef.fromString(columnName, table, 'lookup'),
SqlRef.fromString(
lookupColumn === columnName ? originalTableColumn : 'XXX',
parsedQuery.getTableName(),
),
]),
),
false,
);
}}
/>
</MenuItem>
{parsedQuery.onExpression &&
parsedQuery.onExpression instanceof SqlMulti &&
parsedQuery.onExpression.containsColumn(columnName) && (
<MenuItem
icon={IconNames.EXCHANGE}
text={`Remove join`}
onClick={() => onQueryChange(parsedQuery.removeJoin())}
/>
)}
</>
);
}
return (
<>
{renderFilterMenu()}
@ -485,7 +280,6 @@ export const TimeMenuItems = React.memo(function TimeMenuItems(props: TimeMenuIt
{renderGroupByMenu()}
{renderRemoveGroupBy()}
{renderAggregateMenu()}
{renderJoinMenu()}
</>
);
});

View File

@ -17,7 +17,7 @@
*/
import { render } from '@testing-library/react';
import { parseSqlQuery } from 'druid-query-toolkit';
import { SqlQuery } from 'druid-query-toolkit';
import React from 'react';
import { ColumnMetadata } from '../../../utils/column-metadata';
@ -29,7 +29,7 @@ describe('column tree', () => {
const columnTree = (
<ColumnTree
getParsedQuery={() => {
return parseSqlQuery(`SELECT channel, count(*) as cnt FROM wikipedia GROUP BY 1`);
return SqlQuery.parse(`SELECT channel, count(*) as cnt FROM wikipedia GROUP BY 1`);
}}
defaultSchema="druid"
defaultTable="wikipedia"
@ -62,7 +62,7 @@ describe('column tree', () => {
},
] as ColumnMetadata[]
}
onQueryStringChange={() => {}}
onQueryChange={() => {}}
/>
);

View File

@ -27,93 +27,96 @@ import {
Tree,
} from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
import { SqlMulti, SqlQuery, SqlRef } from 'druid-query-toolkit';
import {
SqlAlias,
SqlComparison,
SqlExpression,
SqlFunction,
SqlJoinPart,
SqlQuery,
SqlRef,
} 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 { copyAndAlert, groupBy, prettyPrintSql } from '../../../utils';
import { ColumnMetadata } from '../../../utils/column-metadata';
import { NumberMenuItems, StringMenuItems, TimeMenuItems } from './column-tree-menu';
import './column-tree.scss';
function handleTableClick(
tableSchema: string,
nodeData: ITreeNode,
onQueryStringChange: (queryString: string, run: boolean) => void,
): void {
let columns: string[];
if (nodeData.childNodes) {
columns = nodeData.childNodes.map(child => escapeSqlIdentifier(String(child.label)));
} else {
columns = ['*'];
}
if (tableSchema === 'druid') {
onQueryStringChange(
`SELECT ${columns.join(', ')}
FROM ${escapeSqlIdentifier(String(nodeData.label))}
WHERE "__time" >= CURRENT_TIMESTAMP - INTERVAL '1' DAY`,
true,
);
} else {
onQueryStringChange(
`SELECT ${columns.join(', ')}
FROM ${tableSchema}.${nodeData.label}`,
true,
);
}
const LAST_DAY = SqlExpression.parse(`__time >= CURRENT_TIMESTAMP - INTERVAL '1' DAY`);
const COUNT_STAR = SqlFunction.COUNT_STAR.as('Count');
const STRING_QUERY = SqlQuery.parse(`SELECT
?
FROM ?
GROUP BY 1
ORDER BY 2 DESC`);
const TIME_QUERY = SqlQuery.parse(`SELECT
TIME_FLOOR(?, 'PT1H') AS "Time"
FROM ?
GROUP BY 1
ORDER BY 1 ASC`);
interface HandleColumnClickOptions {
columnSchema: string;
columnTable: string;
columnName: string;
columnType: string;
parsedQuery: SqlQuery | undefined;
onQueryChange: (query: SqlQuery, run: boolean) => void;
}
function handleColumnClick(
columnSchema: string,
columnTable: string,
nodeData: ITreeNode,
onQueryStringChange: (queryString: string, run: boolean) => void,
): void {
function handleColumnClick(options: HandleColumnClickOptions): void {
const { columnSchema, columnTable, columnName, columnType, parsedQuery, onQueryChange } = options;
let query: SqlQuery;
const columnRef = SqlRef.column(columnName);
if (columnSchema === 'druid') {
if (nodeData.icon === IconNames.TIME) {
onQueryStringChange(
`SELECT
TIME_FLOOR(${escapeSqlIdentifier(String(nodeData.label))}, 'PT1H') AS "Time",
COUNT(*) AS "Count"
FROM ${escapeSqlIdentifier(columnTable)}
WHERE "__time" >= CURRENT_TIMESTAMP - INTERVAL '1' DAY
GROUP BY 1
ORDER BY "Time" ASC`,
true,
);
if (columnType === 'TIMESTAMP') {
query = TIME_QUERY.fillPlaceholders([columnRef, SqlRef.table(columnTable)]) as SqlQuery;
} else {
onQueryStringChange(
`SELECT
"${nodeData.label}",
COUNT(*) AS "Count"
FROM ${escapeSqlIdentifier(columnTable)}
WHERE "__time" >= CURRENT_TIMESTAMP - INTERVAL '1' DAY
GROUP BY 1
ORDER BY "Count" DESC`,
true,
);
query = STRING_QUERY.fillPlaceholders([columnRef, SqlRef.table(columnTable)]) as SqlQuery;
}
} else {
onQueryStringChange(
`SELECT
${escapeSqlIdentifier(String(nodeData.label))},
COUNT(*) AS "Count"
FROM ${columnSchema}.${columnTable}
GROUP BY 1
ORDER BY "Count" DESC`,
true,
);
query = STRING_QUERY.fillPlaceholders([
columnRef,
SqlRef.table(columnTable, columnSchema),
]) as SqlQuery;
}
let where: SqlExpression | undefined;
let aggregates: SqlAlias[] = [];
if (parsedQuery && parsedQuery.getFirstTableName() === columnTable) {
where = parsedQuery.getWhereExpression();
aggregates = parsedQuery.getAggregateSelectExpressions();
} else if (columnSchema === 'druid') {
where = LAST_DAY;
}
if (!aggregates.length) {
aggregates.push(COUNT_STAR);
}
let newSelectExpressions = query.selectExpressions;
for (const aggregate of aggregates) {
newSelectExpressions = newSelectExpressions.addLast(aggregate);
}
onQueryChange(
query.changeSelectExpressions(newSelectExpressions).changeWhereExpression(where),
true,
);
}
export interface ColumnTreeProps {
columnMetadataLoading: boolean;
columnMetadata?: readonly ColumnMetadata[];
getParsedQuery: () => SqlQuery | undefined;
onQueryStringChange: (queryString: string | SqlQuery, run?: boolean) => void;
onQueryChange: (query: SqlQuery, run?: boolean) => void;
defaultSchema?: string;
defaultTable?: string;
}
@ -125,48 +128,44 @@ export interface ColumnTreeState {
selectedTreeIndex: number;
}
export function getCurrentColumns(parsedQuery: SqlQuery, table: string) {
let lookupColumn;
let originalTableColumn;
if (
parsedQuery.joinTable &&
parsedQuery.joinTable.table === table &&
parsedQuery.onExpression &&
parsedQuery.onExpression instanceof SqlMulti
) {
parsedQuery.onExpression.arguments.map(argument => {
if (argument instanceof SqlRef) {
if (argument.namespace === 'lookup') {
lookupColumn = argument.column;
} else {
originalTableColumn = argument.column;
}
export function getJoinColumns(parsedQuery: SqlQuery, _table: string) {
let lookupColumn: string | undefined;
let originalTableColumn: string | undefined;
if (parsedQuery.fromClause && parsedQuery.fromClause.joinParts) {
const firstOnExpression = parsedQuery.fromClause.joinParts.first().onExpression;
if (firstOnExpression instanceof SqlComparison && firstOnExpression.op === '=') {
const { lhs, rhs } = firstOnExpression;
if (lhs instanceof SqlRef && lhs.namespace === 'lookup') {
lookupColumn = lhs.column;
}
});
if (rhs instanceof SqlRef) {
originalTableColumn = rhs.column;
}
}
}
return {
lookupColumn: lookupColumn || 'XXX',
lookupColumn: lookupColumn || 'k',
originalTableColumn: originalTableColumn || 'XXX',
};
}
export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeState> {
static getDerivedStateFromProps(props: ColumnTreeProps, state: ColumnTreeState) {
const { columnMetadata, defaultSchema, defaultTable } = props;
const { columnMetadata, defaultSchema, defaultTable, onQueryChange } = props;
if (columnMetadata && columnMetadata !== state.prevColumnMetadata) {
const columnTree = groupBy(
columnMetadata,
r => r.TABLE_SCHEMA,
(metadata, schema): ITreeNode => ({
id: schema,
label: schema,
(metadata, schemaName): ITreeNode => ({
id: schemaName,
label: schemaName,
childNodes: groupBy(
metadata,
r => r.TABLE_NAME,
(metadata, table): ITreeNode => ({
id: table,
(metadata, tableName): ITreeNode => ({
id: tableName,
icon: IconNames.TH,
label: (
<Popover
@ -176,70 +175,77 @@ export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeS
<Deferred
content={() => {
const parsedQuery = props.getParsedQuery();
const tableRef = SqlRef.table(tableName).as();
const prettyTableRef = prettyPrintSql(tableRef);
return (
<Menu>
<MenuItem
icon={IconNames.FULLSCREEN}
text={`SELECT ... FROM ${table}`}
text={`SELECT ... FROM ${tableName}`}
onClick={() => {
handleTableClick(
schema,
{
id: table,
icon: IconNames.TH,
label: table,
childNodes: metadata.map(columnData => ({
id: columnData.COLUMN_NAME,
icon: ColumnTree.dataTypeToIcon(columnData.DATA_TYPE),
label: columnData.COLUMN_NAME,
})),
},
props.onQueryStringChange,
const tableRef = SqlRef.table(
tableName,
schemaName === 'druid' ? undefined : schemaName,
);
let where: SqlExpression | undefined;
if (parsedQuery && parsedQuery.getFirstTableName() === tableName) {
where = parsedQuery.getWhereExpression();
} else if (schemaName === 'druid') {
where = LAST_DAY;
}
onQueryChange(
SqlQuery.create(tableRef)
.changeSelectExpressions(
metadata.map(child => SqlRef.column(child.COLUMN_NAME).as()),
)
.changeWhereExpression(where),
true,
);
}}
/>
<MenuItem
icon={IconNames.CLIPBOARD}
text={`Copy: ${table}`}
onClick={() => {
copyAndAlert(table, `${table} query copied to clipboard`);
}}
/>
{parsedQuery && (
{parsedQuery && parsedQuery.getFirstTableName() !== tableName && (
<MenuItem
icon={IconNames.EXCHANGE}
text={`Replace FROM with: ${table}`}
text={`Replace FROM with: ${prettyTableRef}`}
onClick={() => {
props.onQueryStringChange(parsedQuery.replaceFrom(table), true);
onQueryChange(
parsedQuery.changeFromExpressions([tableRef]),
true,
);
}}
/>
)}
{parsedQuery && schema === 'lookup' && (
{parsedQuery && schemaName === 'lookup' && (
<MenuItem
popoverProps={{ openOnTargetFocus: false }}
icon={IconNames.JOIN_TABLE}
text={parsedQuery.joinTable ? `Replace join` : `Join`}
text={parsedQuery.hasJoin() ? `Replace join` : `Join`}
>
<MenuItem
icon={IconNames.LEFT_JOIN}
text={`Left join`}
onClick={() => {
const { lookupColumn, originalTableColumn } = getCurrentColumns(
const { lookupColumn, originalTableColumn } = getJoinColumns(
parsedQuery,
table,
tableName,
);
props.onQueryStringChange(
parsedQuery.addJoin(
'LEFT',
SqlRef.fromString(table, schema).upgrade(),
SqlMulti.sqlMultiFactory('=', [
SqlRef.fromString(lookupColumn, table, 'lookup'),
SqlRef.fromString(
originalTableColumn,
parsedQuery.getTableName(),
onQueryChange(
parsedQuery
.removeAllJoins()
.addJoin(
SqlJoinPart.create(
'LEFT',
SqlRef.column(tableName, schemaName).upgrade(),
SqlRef.column(lookupColumn, tableName, 'lookup').equal(
SqlRef.column(
originalTableColumn,
parsedQuery.getFirstTableName(),
),
),
),
]),
),
),
false,
);
}}
@ -248,21 +254,22 @@ export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeS
icon={IconNames.INNER_JOIN}
text={`Inner join`}
onClick={() => {
const { lookupColumn, originalTableColumn } = getCurrentColumns(
const { lookupColumn, originalTableColumn } = getJoinColumns(
parsedQuery,
table,
tableName,
);
props.onQueryStringChange(
onQueryChange(
parsedQuery.addJoin(
'INNER',
SqlRef.fromString(table, schema).upgrade(),
SqlMulti.sqlMultiFactory('=', [
SqlRef.fromString(lookupColumn, table, 'lookup'),
SqlRef.fromString(
originalTableColumn,
parsedQuery.getTableName(),
SqlJoinPart.create(
'INNER',
SqlRef.column(tableName, schemaName).upgrade(),
SqlRef.column(lookupColumn, tableName, 'lookup').equal(
SqlRef.column(
originalTableColumn,
parsedQuery.getFirstTableName(),
),
),
]),
),
),
false,
);
@ -271,23 +278,42 @@ export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeS
</MenuItem>
)}
{parsedQuery &&
parsedQuery.joinTable &&
parsedQuery.joinTable.table === table && (
parsedQuery.hasJoin() &&
parsedQuery.getJoins()[0].table.toString() === tableName && (
<MenuItem
icon={IconNames.EXCHANGE}
text={`Remove join`}
onClick={() => onQueryChange(parsedQuery.removeAllJoins())}
/>
)}
{parsedQuery &&
parsedQuery.hasGroupBy() &&
parsedQuery.getFirstTableName() === tableName && (
<MenuItem
icon={IconNames.FUNCTION}
text={`Aggregate COUNT(*)`}
onClick={() =>
props.onQueryStringChange(parsedQuery.removeJoin())
onQueryChange(parsedQuery.addSelectExpression(COUNT_STAR), true)
}
/>
)}
<MenuItem
icon={IconNames.CLIPBOARD}
text={`Copy: ${prettyTableRef}`}
onClick={() => {
copyAndAlert(
tableRef.toString(),
`${prettyTableRef} query copied to clipboard`,
);
}}
/>
</Menu>
);
}}
/>
}
>
<div>{table}</div>
<div>{tableName}</div>
</Popover>
),
childNodes: metadata
@ -311,45 +337,43 @@ export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeS
icon={IconNames.FULLSCREEN}
text={`Show: ${columnData.COLUMN_NAME}`}
onClick={() => {
handleColumnClick(
schema,
table,
{
id: columnData.COLUMN_NAME,
icon: ColumnTree.dataTypeToIcon(columnData.DATA_TYPE),
label: columnData.COLUMN_NAME,
},
props.onQueryStringChange,
);
handleColumnClick({
columnSchema: schemaName,
columnTable: tableName,
columnName: columnData.COLUMN_NAME,
columnType: columnData.DATA_TYPE,
parsedQuery,
onQueryChange: onQueryChange,
});
}}
/>
{parsedQuery &&
(columnData.DATA_TYPE === 'BIGINT' ||
columnData.DATA_TYPE === 'FLOAT') && (
<NumberMenuItems
table={table}
schema={schema}
table={tableName}
schema={schemaName}
columnName={columnData.COLUMN_NAME}
parsedQuery={parsedQuery}
onQueryChange={props.onQueryStringChange}
onQueryChange={onQueryChange}
/>
)}
{parsedQuery && columnData.DATA_TYPE === 'VARCHAR' && (
<StringMenuItems
table={table}
schema={schema}
table={tableName}
schema={schemaName}
columnName={columnData.COLUMN_NAME}
parsedQuery={parsedQuery}
onQueryChange={props.onQueryStringChange}
onQueryChange={onQueryChange}
/>
)}
{parsedQuery && columnData.DATA_TYPE === 'TIMESTAMP' && (
<TimeMenuItems
table={table}
schema={schema}
table={tableName}
schema={schemaName}
columnName={columnData.COLUMN_NAME}
parsedQuery={parsedQuery}
onQueryChange={props.onQueryStringChange}
onQueryChange={onQueryChange}
/>
)}
<MenuItem

View File

@ -17,7 +17,7 @@ exports[`query extra info matches snapshot 1`] = `
class=""
tabindex="0"
>
999+ results in 8.00s
0 results in 8.00s
</span>
</span>
</span>

View File

@ -17,6 +17,7 @@
*/
import { render } from '@testing-library/react';
import { QueryResult } from 'druid-query-toolkit';
import React from 'react';
import { QueryExtraInfo } from './query-extra-info';
@ -25,13 +26,9 @@ describe('query extra info', () => {
it('matches snapshot', () => {
const queryExtraInfo = (
<QueryExtraInfo
queryExtraInfo={{
queryId: 'e3ee781b-c0b6-4385-9d99-a8a1994bebac',
startTime: new Date('1986-04-26T01:23:40+03:00'),
endTime: new Date('1986-04-26T01:23:48+03:00'),
numResults: 1000,
wrapQueryLimit: 1000,
}}
queryResult={QueryResult.BLANK.attachQueryId(
'e3ee781b-c0b6-4385-9d99-a8a1994bebac',
).changeQueryDuration(8000)}
onDownload={() => {}}
/>
);

View File

@ -28,6 +28,7 @@ import {
} from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
import copy from 'copy-to-clipboard';
import { QueryResult } from 'druid-query-toolkit';
import React from 'react';
import { AppToaster } from '../../../singletons/toaster';
@ -35,25 +36,16 @@ import { pluralIfNeeded } from '../../../utils';
import './query-extra-info.scss';
export interface QueryExtraInfoData {
queryId?: string;
sqlQueryId?: string;
startTime: Date;
endTime: Date;
numResults: number;
wrapQueryLimit: number | undefined;
}
export interface QueryExtraInfoProps {
queryExtraInfo: QueryExtraInfoData;
queryResult: QueryResult;
onDownload: (filename: string, format: string) => void;
}
export const QueryExtraInfo = React.memo(function QueryExtraInfo(props: QueryExtraInfoProps) {
const { queryExtraInfo, onDownload } = props;
const { queryResult, onDownload } = props;
function handleQueryInfoClick() {
const id = queryExtraInfo.queryId || queryExtraInfo.sqlQueryId;
const id = queryResult.queryId || queryResult.sqlQueryId;
if (!id) return;
copy(id, { format: 'text/plain' });
@ -64,7 +56,7 @@ export const QueryExtraInfo = React.memo(function QueryExtraInfo(props: QueryExt
}
function handleDownload(format: string) {
const id = queryExtraInfo.queryId || queryExtraInfo.sqlQueryId;
const id = queryResult.queryId || queryResult.sqlQueryId;
if (!id) return;
onDownload(`query-${id}.${format}`, format);
@ -79,40 +71,38 @@ export const QueryExtraInfo = React.memo(function QueryExtraInfo(props: QueryExt
</Menu>
);
const wrapQueryLimit = queryResult.getSqlOuterLimit();
let resultCount: string;
if (
queryExtraInfo.wrapQueryLimit &&
queryExtraInfo.numResults === queryExtraInfo.wrapQueryLimit
) {
resultCount = `${queryExtraInfo.numResults - 1}+ results`;
if (wrapQueryLimit && queryResult.getNumResults() === wrapQueryLimit) {
resultCount = `${queryResult.getNumResults() - 1}+ results`;
} else {
resultCount = pluralIfNeeded(queryExtraInfo.numResults, 'result');
resultCount = pluralIfNeeded(queryResult.getNumResults(), 'result');
}
const elapsed = queryExtraInfo.endTime.valueOf() - queryExtraInfo.startTime.valueOf();
let tooltipContent: JSX.Element | undefined;
if (queryExtraInfo.queryId) {
if (queryResult.queryId) {
tooltipContent = (
<>
Query ID: <strong>{queryExtraInfo.queryId}</strong> (click to copy)
Query ID: <strong>{queryResult.queryId}</strong> (click to copy)
</>
);
} else if (queryExtraInfo.sqlQueryId) {
} else if (queryResult.sqlQueryId) {
tooltipContent = (
<>
SQL query ID: <strong>{queryExtraInfo.sqlQueryId}</strong> (click to copy)
SQL query ID: <strong>{queryResult.sqlQueryId}</strong> (click to copy)
</>
);
}
return (
<div className="query-extra-info">
<div className="query-info" onClick={handleQueryInfoClick}>
<Tooltip content={tooltipContent} hoverOpenDelay={500}>
{`${resultCount} in ${(elapsed / 1000).toFixed(2)}s`}
</Tooltip>
</div>
{typeof queryResult.queryDuration !== 'undefined' && (
<div className="query-info" onClick={handleQueryInfoClick}>
<Tooltip content={tooltipContent} hoverOpenDelay={500}>
{`${resultCount} in ${(queryResult.queryDuration / 1000).toFixed(2)}s`}
</Tooltip>
</div>
)}
<Popover className="download-button" content={downloadMenu} position={Position.BOTTOM_RIGHT}>
<Button icon={IconNames.DOWNLOAD} minimal />
</Popover>

View File

@ -17,7 +17,7 @@
*/
import { IResizeEntry, ResizeSensor } from '@blueprintjs/core';
import ace from 'brace';
import ace, { Editor } from 'brace';
import escape from 'lodash.escape';
import React from 'react';
import AceEditor from 'react-ace';
@ -29,7 +29,7 @@ import {
SQL_KEYWORDS,
} from '../../../../lib/keywords';
import { SQL_DATA_TYPES, SQL_FUNCTIONS } from '../../../../lib/sql-docs';
import { uniq } from '../../../utils';
import { RowColumn, uniq } from '../../../utils';
import { ColumnMetadata } from '../../../utils/column-metadata';
import './query-input.scss';
@ -56,6 +56,8 @@ export interface QueryInputState {
}
export class QueryInput extends React.PureComponent<QueryInputProps, QueryInputState> {
private aceEditor: Editor | undefined;
static replaceDefaultAutoCompleter(): void {
if (!langTools) return;
@ -209,6 +211,13 @@ export class QueryInput extends React.PureComponent<QueryInputProps, QueryInputS
onQueryStringChange(value);
};
public goToRowColumn(rowColumn: RowColumn) {
const { aceEditor } = this;
if (!aceEditor) return;
aceEditor.getSelection().moveCursorTo(rowColumn.row, rowColumn.column);
aceEditor.focus(); // Grab the focus also
}
render(): JSX.Element {
const { queryString, runeMode } = this.props;
const { editorHeight } = this.state;
@ -240,6 +249,9 @@ export class QueryInput extends React.PureComponent<QueryInputProps, QueryInputS
}}
style={{}}
placeholder="SELECT * FROM ..."
onLoad={(editor: any) => {
this.aceEditor = editor;
}}
/>
</div>
</ResizeSensor>

View File

@ -38,6 +38,25 @@ exports[`query output matches snapshot 1`] = `
class=""
>
language
<span
class="bp3-icon bp3-icon-filter"
icon="filter"
>
<svg
data-icon="filter"
height="14"
viewBox="0 0 16 16"
width="14"
>
<desc>
filter
</desc>
<path
d="M13.99.99h-12a1.003 1.003 0 00-.71 1.71l4.71 4.71V14a1.003 1.003 0 001.71.71l2-2c.18-.18.29-.43.29-.71V7.41L14.7 2.7a1.003 1.003 0 00-.71-1.71z"
fill-rule="evenodd"
/>
</svg>
</span>
</div>
</span>
</span>
@ -65,6 +84,25 @@ exports[`query output matches snapshot 1`] = `
class=""
>
Count
<span
class="bp3-icon bp3-icon-filter"
icon="filter"
>
<svg
data-icon="filter"
height="14"
viewBox="0 0 16 16"
width="14"
>
<desc>
filter
</desc>
<path
d="M13.99.99h-12a1.003 1.003 0 00-.71 1.71l4.71 4.71V14a1.003 1.003 0 001.71.71l2-2c.18-.18.29-.43.29-.71V7.41L14.7 2.7a1.003 1.003 0 00-.71-1.71z"
fill-rule="evenodd"
/>
</svg>
</span>
</div>
</span>
</span>

View File

@ -39,12 +39,18 @@
&.aggregate-header {
background: rgb(75, 122, 148);
}
.asc {
box-shadow: inset 0 3px 0 0 rgba(255, 255, 255, 0.6);
}
.desc {
box-shadow: inset 0 -3px 0 0 rgba(255, 255, 255, 0.6);
}
.bp3-icon {
margin-left: 3px;
}
}
.rt-td {
cursor: pointer;

View File

@ -17,14 +17,14 @@
*/
import { render } from '@testing-library/react';
import { parseSqlQuery } from 'druid-query-toolkit';
import { QueryResult, SqlQuery } from 'druid-query-toolkit';
import React from 'react';
import { QueryOutput } from './query-output';
describe('query output', () => {
it('matches snapshot', () => {
const parsedQuery = parseSqlQuery(`SELECT
const parsedQuery = SqlQuery.parse(`SELECT
"language",
COUNT(*) AS "Count", COUNT(DISTINCT "language") AS "dist_language", COUNT(*) FILTER (WHERE "language"= 'xxx') AS "language_filtered_count"
FROM "github"
@ -38,17 +38,18 @@ ORDER BY "Count" DESC`);
runeMode={false}
loading={false}
error="lol"
queryResult={{
header: ['language', 'Count', 'dist_language', 'language_filtered_count'],
rows: [
queryResult={QueryResult.fromRawResult(
[
['language', 'Count', 'dist_language', 'language_filtered_count'],
['', 6881, 1, 0],
['JavaScript', 166, 1, 0],
['Python', 62, 1, 0],
['HTML', 46, 1, 0],
[],
],
}}
parsedQuery={parsedQuery}
false,
true,
).attachQuery({}, parsedQuery)}
onQueryChange={() => null}
/>
);

View File

@ -16,116 +16,163 @@
* limitations under the License.
*/
import { Menu, MenuItem, Popover } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
import { HeaderRows, SqlQuery } from 'druid-query-toolkit';
import { basicIdentifierEscape, basicLiteralEscape } from 'druid-query-toolkit/build/sql/helpers';
import { Icon, Menu, MenuItem, Popover } from '@blueprintjs/core';
import { IconName, IconNames } from '@blueprintjs/icons';
import {
QueryResult,
SqlExpression,
SqlLiteral,
SqlQuery,
SqlRef,
trimString,
} from 'druid-query-toolkit';
import React, { useState } from 'react';
import ReactTable from 'react-table';
import { TableCell } from '../../../components';
import { ShowValueDialog } from '../../../dialogs/show-value-dialog/show-value-dialog';
import { copyAndAlert } from '../../../utils';
import { copyAndAlert, prettyPrintSql } from '../../../utils';
import { BasicAction, basicActionsToMenu } from '../../../utils/basic-action';
import './query-output.scss';
function trimValue(str: any): string {
str = String(str);
if (str.length < 102) return str;
return str.substr(0, 100) + '...';
function isComparable(x: unknown): boolean {
return x !== null && x !== '' && !isNaN(Number(x));
}
export interface QueryOutputProps {
loading: boolean;
queryResult?: HeaderRows;
parsedQuery?: SqlQuery;
queryResult?: QueryResult;
onQueryChange: (query: SqlQuery, run?: boolean) => void;
error?: string;
runeMode: boolean;
}
export const QueryOutput = React.memo(function QueryOutput(props: QueryOutputProps) {
const { queryResult, parsedQuery, loading, error } = props;
const { queryResult, loading, error } = props;
const parsedQuery = queryResult ? queryResult.sqlQuery : undefined;
const [showValue, setShowValue] = useState();
function getHeaderMenu(header: string) {
const { parsedQuery, onQueryChange, runeMode } = props;
function hasFilterOnHeader(header: string, headerIndex: number): boolean {
if (!parsedQuery || !parsedQuery.isRealOutputColumnAtSelectIndex(headerIndex)) return false;
return (
parsedQuery.getEffectiveWhereExpression().containsColumn(header) ||
parsedQuery.getEffectiveHavingExpression().containsColumn(header)
);
}
function getHeaderMenu(header: string, headerIndex: number) {
const { onQueryChange, runeMode } = props;
const ref = SqlRef.column(header);
const prettyRef = prettyPrintSql(ref);
if (parsedQuery) {
const sorted = parsedQuery.getSorted();
const orderByExpression = parsedQuery.isValidSelectIndex(headerIndex)
? SqlLiteral.index(headerIndex)
: SqlRef.column(header);
const descOrderBy = orderByExpression.toOrderByPart('DESC');
const ascOrderBy = orderByExpression.toOrderByPart('ASC');
const orderBy = parsedQuery.getOrderByForSelectIndex(headerIndex);
const basicActions: BasicAction[] = [];
if (sorted) {
sorted.map(sorted => {
if (sorted.id === header) {
basicActions.push({
icon: sorted.desc ? IconNames.SORT_ASC : IconNames.SORT_DESC,
title: `Order by: ${trimValue(header)} ${sorted.desc ? 'ASC' : 'DESC'}`,
onAction: () => {
onQueryChange(parsedQuery.orderBy(header, sorted.desc ? 'ASC' : 'DESC'), true);
},
});
}
if (orderBy) {
const reverseOrderBy = orderBy.reverseDirection();
const reverseOrderByDirection = reverseOrderBy.getEffectiveDirection();
basicActions.push({
icon: reverseOrderByDirection === 'ASC' ? IconNames.SORT_ASC : IconNames.SORT_DESC,
title: `Order ${reverseOrderByDirection === 'ASC' ? 'ascending' : 'descending'}`,
onAction: () => {
onQueryChange(parsedQuery.changeOrderByExpressions([reverseOrderBy]), true);
},
});
}
if (!basicActions.length) {
} else {
basicActions.push(
{
icon: IconNames.SORT_DESC,
title: `Order by: ${trimValue(header)} DESC`,
title: `Order descending`,
onAction: () => {
onQueryChange(parsedQuery.orderBy(header, 'DESC'), true);
onQueryChange(parsedQuery.changeOrderByExpressions([descOrderBy]), true);
},
},
{
icon: IconNames.SORT_ASC,
title: `Order by: ${trimValue(header)} ASC`,
title: `Order ascending`,
onAction: () => {
onQueryChange(parsedQuery.orderBy(header, 'ASC'), true);
onQueryChange(parsedQuery.changeOrderByExpressions([ascOrderBy]), true);
},
},
);
}
if (parsedQuery.isRealOutputColumnAtSelectIndex(headerIndex)) {
const whereExpression = parsedQuery.getWhereExpression();
if (whereExpression && whereExpression.containsColumn(header)) {
basicActions.push({
icon: IconNames.FILTER_REMOVE,
title: `Remove from WHERE clause`,
onAction: () => {
onQueryChange(
parsedQuery.changeWhereExpression(whereExpression.removeColumnFromAnd(header)),
true,
);
},
});
}
const havingExpression = parsedQuery.getHavingExpression();
if (havingExpression && havingExpression.containsColumn(header)) {
basicActions.push({
icon: IconNames.FILTER_REMOVE,
title: `Remove from HAVING clause`,
onAction: () => {
onQueryChange(
parsedQuery.changeHavingExpression(havingExpression.removeColumnFromAnd(header)),
true,
);
},
});
}
}
basicActions.push({
icon: IconNames.CROSS,
title: `Remove: ${trimValue(header)}`,
title: `Remove column`,
onAction: () => {
onQueryChange(parsedQuery.remove(header), true);
onQueryChange(parsedQuery.removeOutputColumn(header), true);
},
});
return basicActionsToMenu(basicActions);
} else {
const orderByExpression = SqlRef.column(header);
const descOrderBy = orderByExpression.toOrderByPart('DESC');
const ascOrderBy = orderByExpression.toOrderByPart('ASC');
const descOrderByPretty = prettyPrintSql(descOrderBy);
const ascOrderByPretty = prettyPrintSql(descOrderBy);
return (
<Menu>
<MenuItem
icon={IconNames.CLIPBOARD}
text={`Copy: ${trimValue(header)}`}
text={`Copy: ${prettyRef}`}
onClick={() => {
copyAndAlert(header, `${header}' copied to clipboard`);
copyAndAlert(String(ref), `${prettyRef}' copied to clipboard`);
}}
/>
{!runeMode && (
<>
<MenuItem
icon={IconNames.CLIPBOARD}
text={`Copy: ORDER BY ${basicIdentifierEscape(header)} ASC`}
text={`Copy: ${descOrderByPretty}`}
onClick={() =>
copyAndAlert(
`ORDER BY ${basicIdentifierEscape(header)} ASC`,
`ORDER BY ${basicIdentifierEscape(header)} ASC' copied to clipboard`,
)
copyAndAlert(descOrderBy.toString(), `'${descOrderByPretty}' copied to clipboard`)
}
/>
<MenuItem
icon={IconNames.CLIPBOARD}
text={`Copy: 'ORDER BY ${basicIdentifierEscape(header)} DESC'`}
text={`Copy: ${ascOrderByPretty}`}
onClick={() =>
copyAndAlert(
`ORDER BY ${basicIdentifierEscape(header)} DESC`,
`ORDER BY ${basicIdentifierEscape(header)} DESC' copied to clipboard`,
)
copyAndAlert(ascOrderBy.toString(), `'${ascOrderByPretty}' copied to clipboard`)
}
/>
</>
@ -135,8 +182,37 @@ export const QueryOutput = React.memo(function QueryOutput(props: QueryOutputPro
}
}
function getCellMenu(header: string, value: any) {
const { parsedQuery, onQueryChange, runeMode } = props;
function filterOnMenuItem(icon: IconName, clause: SqlExpression, having: boolean) {
const { onQueryChange } = props;
if (!parsedQuery) return;
return (
<MenuItem
icon={icon}
text={`${having ? 'Having' : 'Filter on'}: ${prettyPrintSql(clause)}`}
onClick={() => {
onQueryChange(
having ? parsedQuery.addToHaving(clause) : parsedQuery.addToWhere(clause),
true,
);
}}
/>
);
}
function clipboardMenuItem(clause: SqlExpression) {
const prettyLabel = prettyPrintSql(clause);
return (
<MenuItem
icon={IconNames.CLIPBOARD}
text={`Copy: ${prettyLabel}`}
onClick={() => copyAndAlert(clause.toString(), `${prettyLabel} copied to clipboard`)}
/>
);
}
function getCellMenu(header: string, headerIndex: number, value: any) {
const { runeMode } = props;
const showFullValueMenuItem =
typeof value === 'string' ? (
@ -151,117 +227,75 @@ export const QueryOutput = React.memo(function QueryOutput(props: QueryOutputPro
undefined
);
const val = SqlLiteral.create(value);
if (parsedQuery) {
return (
<Menu>
<MenuItem
icon={IconNames.FILTER_KEEP}
text={`Filter by: ${trimValue(header)} = ${trimValue(value)}`}
onClick={() => {
onQueryChange(parsedQuery.addWhereFilter(header, '=', value), true);
}}
/>
<MenuItem
icon={IconNames.FILTER_REMOVE}
text={`Filter by: ${trimValue(header)} != ${trimValue(value)}`}
onClick={() => {
onQueryChange(parsedQuery.addWhereFilter(header, '!=', value), true);
}}
/>
{!isNaN(Number(value)) && (
<>
<MenuItem
icon={IconNames.FILTER_KEEP}
text={`Filter by: ${trimValue(header)} >= ${trimValue(value)}`}
onClick={() => {
onQueryChange(parsedQuery.addWhereFilter(header, '>=', value), true);
}}
/>
<MenuItem
icon={IconNames.FILTER_KEEP}
text={`Filter by: ${trimValue(header)} <= ${trimValue(value)}`}
onClick={() => {
onQueryChange(parsedQuery.addWhereFilter(header, '<=', value), true);
}}
/>
</>
)}
{showFullValueMenuItem}
</Menu>
);
} else {
return (
<Menu>
<MenuItem
icon={IconNames.CLIPBOARD}
text={`Copy: ${trimValue(value)}`}
onClick={() => copyAndAlert(value, `${value} copied to clipboard`)}
/>
{!runeMode && (
<>
<MenuItem
icon={IconNames.CLIPBOARD}
text={`Copy: ${basicIdentifierEscape(header)} = ${basicLiteralEscape(value)}`}
onClick={() =>
copyAndAlert(
`${basicIdentifierEscape(header)} = ${basicLiteralEscape(value)}`,
`${basicIdentifierEscape(header)} = ${basicLiteralEscape(
value,
)} copied to clipboard`,
)
}
/>
<MenuItem
icon={IconNames.CLIPBOARD}
text={`Copy: ${basicIdentifierEscape(header)} != ${basicLiteralEscape(value)}`}
onClick={() =>
copyAndAlert(
`${basicIdentifierEscape(header)} != ${basicLiteralEscape(value)}`,
`${basicIdentifierEscape(header)} != ${basicLiteralEscape(
value,
)} copied to clipboard`,
)
}
/>
</>
)}
{showFullValueMenuItem}
</Menu>
);
}
}
const selectValue = parsedQuery.getSelectExpressionForIndex(headerIndex);
if (selectValue) {
const outputName = selectValue.getOutputName();
const having = parsedQuery.isAggregateSelectIndex(headerIndex);
let ex: SqlExpression;
if (having && outputName) {
ex = SqlRef.column(outputName);
} else {
ex = selectValue.expression as SqlExpression;
}
function getHeaderClassName(header: string) {
const { parsedQuery } = props;
if (!parsedQuery) return;
const className = [];
const sorted = parsedQuery.getSorted();
const aggregateColumns = parsedQuery.getAggregateColumns();
if (sorted) {
const sortedColumnNames = sorted.map(column => column.id);
if (sortedColumnNames.includes(header)) {
className.push(sorted[sortedColumnNames.indexOf(header)].desc ? '-sort-desc' : '-sort-asc');
return (
<Menu>
{isComparable(value) && (
<>
{filterOnMenuItem(IconNames.FILTER_KEEP, ex.greaterThanOrEqual(val), having)}
{filterOnMenuItem(IconNames.FILTER_KEEP, ex.lessThanOrEqual(val), having)}
</>
)}
{filterOnMenuItem(IconNames.FILTER_KEEP, ex.equal(val), having)}
{filterOnMenuItem(IconNames.FILTER_REMOVE, ex.unequal(val), having)}
{showFullValueMenuItem}
</Menu>
);
}
}
if (aggregateColumns && aggregateColumns.includes(header)) {
const ref = SqlRef.column(header);
const trimmedValue = trimString(String(value), 50);
return (
<Menu>
<MenuItem
icon={IconNames.CLIPBOARD}
text={`Copy: ${trimmedValue}`}
onClick={() => copyAndAlert(value, `${trimmedValue} copied to clipboard`)}
/>
{!runeMode && (
<>
{clipboardMenuItem(ref.equal(val))}
{clipboardMenuItem(ref.unequal(val))}
</>
)}
{showFullValueMenuItem}
</Menu>
);
}
function getHeaderClassName(header: string) {
if (!parsedQuery) return;
const className = [];
const orderBy = parsedQuery.getOrderByForOutputColumn(header);
if (orderBy) {
className.push(orderBy.getEffectiveDirection() === 'DESC' ? '-sort-desc' : '-sort-asc');
}
if (parsedQuery.isAggregateOutputColumn(header)) {
className.push('aggregate-header');
}
return className.join(' ');
}
let aggregateColumns: string[] | undefined;
if (parsedQuery) {
aggregateColumns = parsedQuery.getAggregateColumns();
}
return (
<div className="query-output">
<ReactTable
data={queryResult ? queryResult.rows : []}
data={queryResult ? (queryResult.rows as any[][]) : []}
loading={loading}
noDataText={
!loading && queryResult && !queryResult.rows.length
@ -269,12 +303,16 @@ export const QueryOutput = React.memo(function QueryOutput(props: QueryOutputPro
: error || ''
}
sortable={false}
columns={(queryResult ? queryResult.header : []).map((h: any, i) => {
columns={(queryResult ? queryResult.header : []).map((column, i) => {
const h = column.name;
return {
Header: () => {
return (
<Popover className={'clickable-cell'} content={getHeaderMenu(h)}>
<div>{h}</div>
<Popover className={'clickable-cell'} content={getHeaderMenu(h, i)}>
<div>
{h}
{hasFilterOnHeader(h, i) && <Icon icon={IconNames.FILTER} iconSize={14} />}
</div>
</Popover>
);
},
@ -284,14 +322,16 @@ export const QueryOutput = React.memo(function QueryOutput(props: QueryOutputPro
const value = row.value;
return (
<div>
<Popover content={getCellMenu(h, value)}>
<Popover content={getCellMenu(h, i, value)}>
<TableCell value={value} unlimited />
</Popover>
</div>
);
},
className:
aggregateColumns && aggregateColumns.includes(h) ? 'aggregate-column' : undefined,
parsedQuery && parsedQuery.isAggregateOutputColumn(h)
? 'aggregate-column'
: undefined,
};
})}
/>

View File

@ -19,17 +19,10 @@
import { Intent, Switch, Tooltip } from '@blueprintjs/core';
import axios from 'axios';
import classNames from 'classnames';
import {
HeaderRows,
isFirstRowHeader,
normalizeQueryResult,
parseSqlQuery,
shouldIncludeTimestamp,
SqlQuery,
} from 'druid-query-toolkit';
import { QueryResult, QueryRunner, SqlQuery } from 'druid-query-toolkit';
import Hjson from 'hjson';
import memoizeOne from 'memoize-one';
import React from 'react';
import React, { RefObject } from 'react';
import SplitterLayout from 'react-splitter-layout';
import { QueryPlanDialog } from '../../dialogs';
@ -39,6 +32,7 @@ import { AppToaster } from '../../singletons/toaster';
import {
BasicQueryExplanation,
downloadFile,
findEmptyLiteralPosition,
getDruidErrorMessage,
localStorageGet,
localStorageGetJson,
@ -55,7 +49,7 @@ import { isEmptyContext, QueryContext } from '../../utils/query-context';
import { QueryRecord, QueryRecordUtil } from '../../utils/query-history';
import { ColumnTree } from './column-tree/column-tree';
import { QueryExtraInfo, QueryExtraInfoData } from './query-extra-info/query-extra-info';
import { QueryExtraInfo } from './query-extra-info/query-extra-info';
import { QueryInput } from './query-input/query-input';
import { QueryOutput } from './query-output/query-output';
import { RunButton } from './run-button/run-button';
@ -64,7 +58,7 @@ import './query-view.scss';
const parser = memoizeOne((sql: string): SqlQuery | undefined => {
try {
return parseSqlQuery(sql);
return SqlQuery.parse(sql);
} catch {
return;
}
@ -94,7 +88,7 @@ export interface QueryViewState {
columnMetadataError?: string;
loading: boolean;
result?: QueryResult;
queryResult?: QueryResult;
error?: string;
explainDialogOpen: boolean;
@ -110,12 +104,6 @@ export interface QueryViewState {
queryHistory: readonly QueryRecord[];
}
interface QueryResult {
queryResult: HeaderRows;
queryExtraInfo: QueryExtraInfoData;
parsedQuery?: SqlQuery;
}
export class QueryView extends React.PureComponent<QueryViewProps, QueryViewState> {
static trimSemicolon(query: string): string {
// Trims out a trailing semicolon while preserving space (https://bit.ly/1n1yfkJ)
@ -166,16 +154,20 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
}
private metadataQueryManager: QueryManager<null, ColumnMetadata[]>;
private sqlQueryManager: QueryManager<QueryWithContext, QueryResult>;
private queryManager: QueryManager<QueryWithContext, QueryResult>;
private explainQueryManager: QueryManager<
QueryWithContext,
BasicQueryExplanation | SemiJoinQueryExplanation | string
>;
private queryInputRef: RefObject<QueryInput>;
constructor(props: QueryViewProps, context: any) {
super(props, context);
const { mandatoryQueryContext } = props;
this.queryInputRef = React.createRef();
const queryString = props.initQuery || localStorageGet(LocalStorageKeys.QUERY_KEY) || '';
const parsedQuery = queryString ? parser(queryString) : undefined;
@ -228,86 +220,33 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
},
});
this.sqlQueryManager = new QueryManager({
const queryRunner = new QueryRunner((payload, isSql) => {
return axios.post(`/druid/v2${isSql ? '/sql' : ''}`, payload);
});
this.queryManager = new QueryManager({
processQuery: async (queryWithContext: QueryWithContext): Promise<QueryResult> => {
const { queryString, queryContext, wrapQueryLimit } = queryWithContext;
let parsedQuery: SqlQuery | undefined;
let jsonQuery: any;
const query = QueryView.isJsonLike(queryString) ? Hjson.parse(queryString) : queryString;
let context: Record<string, any> | undefined;
if (!isEmptyContext(queryContext) || wrapQueryLimit || mandatoryQueryContext) {
context = Object.assign({}, queryContext, mandatoryQueryContext || {});
if (typeof wrapQueryLimit !== 'undefined') {
context.sqlOuterLimit = wrapQueryLimit;
}
}
try {
parsedQuery = parser(queryString);
} catch {}
if (!(parsedQuery instanceof SqlQuery)) {
parsedQuery = undefined;
return await queryRunner.runQuery(query, context);
} catch (e) {
throw new Error(getDruidErrorMessage(e));
}
if (QueryView.isJsonLike(queryString)) {
jsonQuery = Hjson.parse(queryString);
} else {
jsonQuery = {
query: queryString,
resultFormat: 'array',
header: true,
};
}
if (!isEmptyContext(queryContext) || wrapQueryLimit || mandatoryQueryContext) {
jsonQuery.context = Object.assign(
{},
jsonQuery.context || {},
queryContext,
mandatoryQueryContext || {},
);
jsonQuery.context.sqlOuterLimit = wrapQueryLimit;
}
let rawQueryResult: unknown;
let queryId: string | undefined;
let sqlQueryId: string | undefined;
const startTime = new Date();
let endTime: Date;
if (!jsonQuery.queryType && typeof jsonQuery.query === 'string') {
try {
const sqlResultResp = await axios.post('/druid/v2/sql', jsonQuery);
endTime = new Date();
rawQueryResult = sqlResultResp.data;
sqlQueryId = sqlResultResp.headers['x-druid-sql-query-id'];
} catch (e) {
throw new Error(getDruidErrorMessage(e));
}
} else {
try {
const runeResultResp = await axios.post('/druid/v2', jsonQuery);
endTime = new Date();
rawQueryResult = runeResultResp.data;
queryId = runeResultResp.headers['x-druid-query-id'];
} catch (e) {
throw new Error(getDruidErrorMessage(e));
}
}
const queryResult = normalizeQueryResult(
rawQueryResult,
shouldIncludeTimestamp(jsonQuery),
isFirstRowHeader(jsonQuery),
);
return {
queryResult,
queryExtraInfo: {
queryId,
sqlQueryId,
startTime,
endTime,
numResults: queryResult.rows.length,
wrapQueryLimit,
},
parsedQuery,
};
},
onStateChange: ({ result, loading, error }) => {
this.setState({
result,
queryResult: result,
loading,
error,
});
@ -323,9 +262,15 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
resultFormat: 'object',
};
if (!isEmptyContext(queryContext) || wrapQueryLimit) {
explainPayload.context = Object.assign({}, queryContext || {});
explainPayload.context.sqlOuterLimit = wrapQueryLimit;
if (!isEmptyContext(queryContext) || wrapQueryLimit || mandatoryQueryContext) {
explainPayload.context = Object.assign(
{},
queryContext || {},
mandatoryQueryContext || {},
);
if (typeof wrapQueryLimit !== 'undefined') {
explainPayload.context.sqlOuterLimit = wrapQueryLimit;
}
}
const result = await queryDruidSql(explainPayload);
@ -347,27 +292,36 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
componentWillUnmount(): void {
this.metadataQueryManager.terminate();
this.sqlQueryManager.terminate();
this.queryManager.terminate();
this.explainQueryManager.terminate();
}
prettyPrintJson(): void {
this.setState(prevState => ({
queryString: JSON.stringify(Hjson.parse(prevState.queryString), null, 2),
}));
this.setState(prevState => {
let parsed: any;
try {
parsed = Hjson.parse(prevState.queryString);
} catch {
return null;
}
return {
queryString: JSON.stringify(parsed, null, 2),
};
});
}
handleDownload = (filename: string, format: string) => {
const { result } = this.state;
if (!result) return;
const { queryResult } = result;
const { queryResult } = this.state;
if (!queryResult) return;
let lines: string[] = [];
let separator: string = '';
if (format === 'csv' || format === 'tsv') {
separator = format === 'csv' ? ',' : '\t';
lines.push(queryResult.header.map(str => QueryView.formatStr(str, format)).join(separator));
lines.push(
queryResult.header.map(column => QueryView.formatStr(column.name, format)).join(separator),
);
lines = lines.concat(
queryResult.rows.map(r => r.map(cell => QueryView.formatStr(cell, format)).join(separator)),
);
@ -378,7 +332,7 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
for (let k = 0; k < r.length; k++) {
const newName = queryResult.header[k];
if (newName) {
outputObject[newName] = r[k];
outputObject[newName.name] = r[k];
}
}
return JSON.stringify(outputObject);
@ -473,15 +427,15 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
}
renderMainArea() {
const { queryString, queryContext, loading, result, error, columnMetadata } = this.state;
const { queryString, queryContext, loading, queryResult, error, columnMetadata } = this.state;
const emptyQuery = QueryView.isEmptyQuery(queryString);
let currentSchema: string | undefined;
let currentTable: string | undefined;
if (result && result.parsedQuery) {
currentSchema = result.parsedQuery.getSchema();
currentTable = result.parsedQuery.getTableName();
if (queryResult && queryResult.sqlQuery) {
currentSchema = queryResult.sqlQuery.getFirstSchema();
currentTable = queryResult.sqlQuery.getFirstTableName();
} else if (localStorageGet(LocalStorageKeys.QUERY_KEY)) {
const defaultQueryString = localStorageGet(LocalStorageKeys.QUERY_KEY);
@ -490,8 +444,8 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
: undefined;
if (defaultQueryAst) {
currentSchema = defaultQueryAst.getSchema();
currentTable = defaultQueryAst.getTableName();
currentSchema = defaultQueryAst.getFirstSchema();
currentTable = defaultQueryAst.getFirstTableName();
}
}
@ -509,6 +463,7 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
>
<div className="control-pane">
<QueryInput
ref={this.queryInputRef}
currentSchema={currentSchema ? currentSchema : 'druid'}
currentTable={currentTable}
queryString={queryString}
@ -530,11 +485,8 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
/>
{this.renderAutoRunSwitch()}
{this.renderWrapQueryLimitSelector()}
{result && (
<QueryExtraInfo
queryExtraInfo={result.queryExtraInfo}
onDownload={this.handleDownload}
/>
{queryResult && (
<QueryExtraInfo queryResult={queryResult} onDownload={this.handleDownload} />
)}
</div>
</div>
@ -542,19 +494,30 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
runeMode={runeMode}
loading={loading}
error={error}
queryResult={result ? result.queryResult : undefined}
parsedQuery={result ? result.parsedQuery : undefined}
onQueryChange={this.handleQueryStringChange}
queryResult={queryResult}
onQueryChange={this.handleQueryChange}
/>
</SplitterLayout>
);
}
private handleQueryStringChange = (
queryString: string | SqlQuery,
preferablyRun?: boolean,
): void => {
if (queryString instanceof SqlQuery) queryString = queryString.toString();
private handleQueryChange = (query: SqlQuery, preferablyRun?: boolean): void => {
this.handleQueryStringChange(query.toString(), preferablyRun);
// Possibly move the cursor of the QueryInput to the empty literal position
const emptyLiteralPosition = findEmptyLiteralPosition(query);
if (emptyLiteralPosition) {
// Introduce a delay to let the new text appear
setTimeout(() => {
const currentQueryInput = this.queryInputRef.current;
if (currentQueryInput) {
currentQueryInput.goToRowColumn(emptyLiteralPosition);
}
}, 10);
}
};
private handleQueryStringChange = (queryString: string, preferablyRun?: boolean): void => {
this.setState({ queryString, parsedQuery: parser(queryString) }, () => {
const { autoRun } = this.state;
if (preferablyRun && autoRun) this.handleRun();
@ -589,7 +552,7 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
localStorageSetJson(LocalStorageKeys.QUERY_CONTEXT, queryContext);
this.setState({ queryHistory: newQueryHistory });
this.sqlQueryManager.runQuery({ queryString, queryContext, wrapQueryLimit });
this.queryManager.runQuery({ queryString, queryContext, wrapQueryLimit });
};
private handleExplain = () => {
@ -614,8 +577,8 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
let defaultSchema;
let defaultTable;
if (parsedQuery instanceof SqlQuery) {
defaultSchema = parsedQuery.getSchema();
defaultTable = parsedQuery.getTableName();
defaultSchema = parsedQuery.getFirstSchema();
defaultTable = parsedQuery.getFirstTableName();
}
return (
@ -627,7 +590,7 @@ export class QueryView extends React.PureComponent<QueryViewProps, QueryViewStat
getParsedQuery={this.getParsedQuery}
columnMetadataLoading={columnMetadataLoading}
columnMetadata={columnMetadata}
onQueryStringChange={this.handleQueryStringChange}
onQueryChange={this.handleQueryChange}
defaultSchema={defaultSchema ? defaultSchema : 'druid'}
defaultTable={defaultTable}
/>