blob: 1d99735ce7b6f3805d01428ea853ec25028eddf4 [file] [log] [blame]
/*
* 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 { HTMLSelect, ITreeNode, Menu, MenuItem, Popover, Position, Tree } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
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 { ColumnMetadata, copyAndAlert, groupBy, oneOf, prettyPrintSql } from '../../../utils';
import { dataTypeToIcon } from '../query-utils';
import { NumberMenuItems, StringMenuItems, TimeMenuItems } from './column-tree-menu';
import './column-tree.scss';
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(options: HandleColumnClickOptions): void {
const { columnSchema, columnTable, columnName, columnType, parsedQuery, onQueryChange } = options;
let query: SqlQuery;
const columnRef = SqlRef.column(columnName);
if (columnSchema === 'druid') {
if (columnType === 'TIMESTAMP') {
query = TIME_QUERY.fillPlaceholders([columnRef, SqlRef.table(columnTable)]) as SqlQuery;
} else {
query = STRING_QUERY.fillPlaceholders([columnRef, SqlRef.table(columnTable)]) as SqlQuery;
}
} else {
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.append(aggregate);
}
onQueryChange(
query.changeSelectExpressions(newSelectExpressions).changeWhereExpression(where),
true,
);
}
export interface ColumnTreeProps {
columnMetadataLoading: boolean;
columnMetadata?: readonly ColumnMetadata[];
getParsedQuery: () => SqlQuery | undefined;
onQueryChange: (query: SqlQuery, run?: boolean) => void;
defaultSchema?: string;
defaultTable?: string;
}
export interface ColumnTreeState {
prevColumnMetadata?: readonly ColumnMetadata[];
columnTree?: ITreeNode[];
currentSchemaSubtree?: ITreeNode[];
selectedTreeIndex: number;
}
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 || 'k',
originalTableColumn: originalTableColumn || 'XXX',
};
}
export class ColumnTree extends React.PureComponent<ColumnTreeProps, ColumnTreeState> {
static getDerivedStateFromProps(props: ColumnTreeProps, state: ColumnTreeState) {
const { columnMetadata, defaultSchema, defaultTable, onQueryChange } = props;
if (columnMetadata && columnMetadata !== state.prevColumnMetadata) {
const columnTree = groupBy(
columnMetadata,
r => r.TABLE_SCHEMA,
(metadata, schemaName): ITreeNode => ({
id: schemaName,
label: schemaName,
childNodes: groupBy(
metadata,
r => r.TABLE_NAME,
(metadata, tableName): ITreeNode => ({
id: tableName,
icon: IconNames.TH,
label: (
<Popover
boundary={'window'}
position={Position.RIGHT}
content={
<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 ${tableName}`}
onClick={() => {
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,
);
}}
/>
{parsedQuery && parsedQuery.getFirstTableName() !== tableName && (
<MenuItem
icon={IconNames.EXCHANGE}
text={`Replace FROM with: ${prettyTableRef}`}
onClick={() => {
onQueryChange(
parsedQuery.changeFromExpressions([tableRef]),
true,
);
}}
/>
)}
{parsedQuery && schemaName === 'lookup' && (
<MenuItem
popoverProps={{ openOnTargetFocus: false }}
icon={IconNames.JOIN_TABLE}
text={parsedQuery.hasJoin() ? `Replace join` : `Join`}
>
<MenuItem
icon={IconNames.LEFT_JOIN}
text={`Left join`}
onClick={() => {
const { lookupColumn, originalTableColumn } = getJoinColumns(
parsedQuery,
tableName,
);
onQueryChange(
parsedQuery
.removeAllJoins()
.addJoin(
SqlJoinPart.create(
'LEFT',
SqlRef.column(tableName, schemaName).upgrade(),
SqlRef.column(lookupColumn, tableName, 'lookup').equal(
SqlRef.column(
originalTableColumn,
parsedQuery.getFirstTableName(),
),
),
),
),
false,
);
}}
/>
<MenuItem
icon={IconNames.INNER_JOIN}
text={`Inner join`}
onClick={() => {
const { lookupColumn, originalTableColumn } = getJoinColumns(
parsedQuery,
tableName,
);
onQueryChange(
parsedQuery.addJoin(
SqlJoinPart.create(
'INNER',
SqlRef.column(tableName, schemaName).upgrade(),
SqlRef.column(lookupColumn, tableName, 'lookup').equal(
SqlRef.column(
originalTableColumn,
parsedQuery.getFirstTableName(),
),
),
),
),
false,
);
}}
/>
</MenuItem>
)}
{parsedQuery &&
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={() =>
onQueryChange(parsedQuery.addSelectExpression(COUNT_STAR), true)
}
/>
)}
<MenuItem
icon={IconNames.CLIPBOARD}
text={`Copy: ${prettyTableRef}`}
onClick={() => {
copyAndAlert(
tableRef.toString(),
`${prettyTableRef} query copied to clipboard`,
);
}}
/>
</Menu>
);
}}
/>
}
>
{tableName}
</Popover>
),
childNodes: metadata
.map(
(columnData): ITreeNode => ({
id: columnData.COLUMN_NAME,
icon: dataTypeToIcon(columnData.DATA_TYPE),
label: (
<Popover
boundary={'window'}
position={Position.RIGHT}
autoFocus={false}
targetClassName={'bp3-popover-open'}
content={
<Deferred
content={() => {
const parsedQuery = props.getParsedQuery();
return (
<Menu>
<MenuItem
icon={IconNames.FULLSCREEN}
text={`Show: ${columnData.COLUMN_NAME}`}
onClick={() => {
handleColumnClick({
columnSchema: schemaName,
columnTable: tableName,
columnName: columnData.COLUMN_NAME,
columnType: columnData.DATA_TYPE,
parsedQuery,
onQueryChange: onQueryChange,
});
}}
/>
{parsedQuery &&
oneOf(columnData.DATA_TYPE, 'BIGINT', 'FLOAT', 'DOUBLE') && (
<NumberMenuItems
table={tableName}
schema={schemaName}
columnName={columnData.COLUMN_NAME}
parsedQuery={parsedQuery}
onQueryChange={onQueryChange}
/>
)}
{parsedQuery && columnData.DATA_TYPE === 'VARCHAR' && (
<StringMenuItems
table={tableName}
schema={schemaName}
columnName={columnData.COLUMN_NAME}
parsedQuery={parsedQuery}
onQueryChange={onQueryChange}
/>
)}
{parsedQuery && columnData.DATA_TYPE === 'TIMESTAMP' && (
<TimeMenuItems
table={tableName}
schema={schemaName}
columnName={columnData.COLUMN_NAME}
parsedQuery={parsedQuery}
onQueryChange={onQueryChange}
/>
)}
<MenuItem
icon={IconNames.CLIPBOARD}
text={`Copy: ${columnData.COLUMN_NAME}`}
onClick={() => {
copyAndAlert(
columnData.COLUMN_NAME,
`${columnData.COLUMN_NAME} query copied to clipboard`,
);
}}
/>
</Menu>
);
}}
/>
}
>
{columnData.COLUMN_NAME}
</Popover>
),
}),
)
.sort((a, b) =>
String(a.id).toLowerCase().localeCompare(String(b.id).toLowerCase()),
),
}),
),
}),
);
let selectedTreeIndex = -1;
let expandedNode = -1;
if (defaultSchema && columnTree) {
selectedTreeIndex = columnTree.findIndex(x => {
return x.id === defaultSchema;
});
}
if (selectedTreeIndex > -1) {
const treeNodes = columnTree[selectedTreeIndex].childNodes;
if (treeNodes) {
if (defaultTable) {
expandedNode = treeNodes.findIndex(node => {
return node.id === defaultTable;
});
}
}
}
if (!columnTree) return null;
const currentSchemaSubtree =
columnTree[selectedTreeIndex > -1 ? selectedTreeIndex : 0].childNodes;
if (!currentSchemaSubtree) return null;
if (expandedNode > -1) {
currentSchemaSubtree[expandedNode].isExpanded = true;
}
return {
prevColumnMetadata: columnMetadata,
columnTree,
selectedTreeIndex,
currentSchemaSubtree,
};
}
return null;
}
constructor(props: ColumnTreeProps, context: any) {
super(props, context);
this.state = {
selectedTreeIndex: -1,
};
}
renderSchemaSelector() {
const { columnTree, selectedTreeIndex } = this.state;
if (!columnTree) return null;
return (
<HTMLSelect
className="schema-selector"
value={selectedTreeIndex > -1 ? selectedTreeIndex : undefined}
onChange={this.handleSchemaSelectorChange}
fill
minimal
large
>
{columnTree.map((treeNode, i) => (
<option key={i} value={i}>
{treeNode.label}
</option>
))}
</HTMLSelect>
);
}
private handleSchemaSelectorChange = (e: ChangeEvent<HTMLSelectElement>): void => {
const { columnTree } = this.state;
const selectedTreeIndex = Number(e.target.value);
if (!columnTree) return;
const currentSchemaSubtree =
columnTree[selectedTreeIndex > -1 ? selectedTreeIndex : 0].childNodes;
this.setState({
selectedTreeIndex: Number(e.target.value),
currentSchemaSubtree: currentSchemaSubtree,
});
};
private handleNodeCollapse = (nodeData: ITreeNode) => {
nodeData.isExpanded = false;
this.bounceState();
};
private handleNodeExpand = (nodeData: ITreeNode) => {
nodeData.isExpanded = true;
this.bounceState();
};
bounceState() {
const { columnTree } = this.state;
if (!columnTree) return;
this.setState(prevState => ({
columnTree: prevState.columnTree ? prevState.columnTree.slice() : undefined,
}));
}
render(): JSX.Element | null {
const { columnMetadataLoading } = this.props;
const { currentSchemaSubtree } = this.state;
if (columnMetadataLoading) {
return (
<div className="column-tree">
<Loader />
</div>
);
}
if (!currentSchemaSubtree) return null;
return (
<div className="column-tree">
{this.renderSchemaSelector()}
<div className="tree-container">
<Tree
contents={currentSchemaSubtree}
onNodeCollapse={this.handleNodeCollapse}
onNodeExpand={this.handleNodeExpand}
/>
</div>
</div>
);
}
}