mirror of https://github.com/apache/druid.git
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:
parent
e363b1cd20
commit
6d8799f2df
|
@ -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
|
||||
|
||||
---
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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"
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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]}`,
|
||||
),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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({
|
||||
|
|
|
@ -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';
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
}
|
|
@ -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,
|
||||
|
|
|
@ -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=""
|
||||
|
|
|
@ -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={() => {}}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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()}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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={() => {}}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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()}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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={() => {}}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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()}
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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={() => {}}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -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,
|
||||
};
|
||||
})}
|
||||
/>
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
|
|
Loading…
Reference in New Issue