blob: 78df2159d289be16755d8441b5b58f742a7c1dc0 [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 { Icon, Menu, MenuItem } from '@blueprintjs/core';
import { IconName, IconNames } from '@blueprintjs/icons';
import { Popover2 } from '@blueprintjs/popover2';
import classNames from 'classnames';
import {
QueryResult,
SqlExpression,
SqlLiteral,
SqlQuery,
SqlRef,
trimString,
} from 'druid-query-toolkit';
import React, { useEffect, useState } from 'react';
import ReactTable from 'react-table';
import { BracedText, TableCell } from '../../../components';
import { ShowValueDialog } from '../../../dialogs/show-value-dialog/show-value-dialog';
import {
copyAndAlert,
deepSet,
filterMap,
oneOf,
prettyPrintSql,
stringifyValue,
} from '../../../utils';
import { BasicAction, basicActionsToMenu } from '../../../utils/basic-action';
import { ColumnRenameInput } from './column-rename-input/column-rename-input';
import './query-output.scss';
function isComparable(x: unknown): boolean {
return x !== null && x !== '' && !isNaN(Number(x));
}
interface Pagination {
page: number;
pageSize: number;
}
function changePage(pagination: Pagination, page: number): Pagination {
return deepSet(pagination, 'page', page);
}
function getNumericColumnBraces(
queryResult: QueryResult,
pagination: Pagination,
): Record<number, string[]> {
const numericColumnBraces: Record<number, string[]> = {};
const index = pagination.page * pagination.pageSize;
const rows = queryResult.rows.slice(index, index + pagination.pageSize);
if (rows.length) {
const numColumns = queryResult.header.length;
for (let c = 0; c < numColumns; c++) {
const brace = filterMap(rows, row =>
oneOf(typeof row[c], 'number', 'bigint') ? String(row[c]) : undefined,
);
if (rows.length === brace.length) {
numericColumnBraces[c] = brace;
}
}
}
return numericColumnBraces;
}
export interface QueryOutputProps {
queryResult: QueryResult;
onQueryChange: (query: SqlQuery, run?: boolean) => void;
onLoadMore: () => void;
runeMode: boolean;
}
export const QueryOutput = React.memo(function QueryOutput(props: QueryOutputProps) {
const { queryResult, onQueryChange, onLoadMore, runeMode } = props;
const parsedQuery = queryResult.sqlQuery;
const [pagination, setPagination] = useState<Pagination>({ page: 0, pageSize: 20 });
const [showValue, setShowValue] = useState<string>();
const [renamingColumn, setRenamingColumn] = useState<number>(-1);
// Reset page to 0 if number of results changes
useEffect(() => {
if (pagination.page) {
setPagination(changePage(pagination, 0));
}
}, [queryResult.rows.length]);
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 ref = SqlRef.column(header);
const prettyRef = prettyPrintSql(ref);
if (parsedQuery) {
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 (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);
},
});
} else {
basicActions.push(
{
icon: IconNames.SORT_DESC,
title: `Order descending`,
onAction: () => {
onQueryChange(parsedQuery.changeOrderByExpressions([descOrderBy]), true);
},
},
{
icon: IconNames.SORT_ASC,
title: `Order ascending`,
onAction: () => {
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,
);
},
});
}
}
if (!parsedQuery.hasStarInSelect()) {
basicActions.push({
icon: IconNames.EDIT,
title: `Rename column`,
onAction: () => {
setRenamingColumn(headerIndex);
},
});
}
basicActions.push({
icon: IconNames.CROSS,
title: `Remove column`,
onAction: () => {
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: ${prettyRef}`}
onClick={() => {
copyAndAlert(String(ref), `${prettyRef}' copied to clipboard`);
}}
/>
{!runeMode && (
<>
<MenuItem
icon={IconNames.CLIPBOARD}
text={`Copy: ${descOrderByPretty}`}
onClick={() =>
copyAndAlert(descOrderBy.toString(), `'${descOrderByPretty}' copied to clipboard`)
}
/>
<MenuItem
icon={IconNames.CLIPBOARD}
text={`Copy: ${ascOrderByPretty}`}
onClick={() =>
copyAndAlert(ascOrderBy.toString(), `'${ascOrderByPretty}' copied to clipboard`)
}
/>
</>
)}
</Menu>
);
}
}
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.addHaving(clause) : parsedQuery.addWhere(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: unknown) {
const { runeMode } = props;
const val = SqlLiteral.maybe(value);
const showFullValueMenuItem = (
<MenuItem
icon={IconNames.EYE_OPEN}
text="Show full value"
onClick={() => {
setShowValue(stringifyValue(value));
}}
/>
);
if (parsedQuery) {
let ex: SqlExpression | undefined;
let having = false;
const selectValue = parsedQuery.getSelectExpressionForIndex(headerIndex);
if (selectValue) {
const outputName = selectValue.getOutputName();
having = parsedQuery.isAggregateSelectIndex(headerIndex);
if (having && outputName) {
ex = SqlRef.column(outputName);
} else {
ex = selectValue.getUnderlyingExpression();
}
} else if (parsedQuery.hasStarInSelect()) {
ex = SqlRef.column(header);
}
return (
<Menu>
{ex && val && (
<>
{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>
);
} else {
const ref = SqlRef.column(header);
const stringValue = stringifyValue(value);
const trimmedValue = trimString(stringValue, 50);
return (
<Menu>
<MenuItem
icon={IconNames.CLIPBOARD}
text={`Copy: ${trimmedValue}`}
onClick={() => copyAndAlert(stringValue, `${trimmedValue} copied to clipboard`)}
/>
{!runeMode && val && (
<>
{clipboardMenuItem(ref.equal(val))}
{clipboardMenuItem(ref.unequal(val))}
</>
)}
{showFullValueMenuItem}
</Menu>
);
}
}
function getHeaderClassName(header: string, i: number) {
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');
}
if (i === renamingColumn) {
className.push('renaming');
}
return className.join(' ');
}
function renameColumnTo(renameTo: string | undefined) {
setRenamingColumn(-1);
if (renameTo && parsedQuery) {
if (parsedQuery.hasStarInSelect()) return;
const selectExpression = parsedQuery.getSelectExpressionForIndex(renamingColumn);
if (!selectExpression) return;
onQueryChange(parsedQuery.changeSelect(renamingColumn, selectExpression.as(renameTo)), true);
}
}
const outerLimit = queryResult.getSqlOuterLimit();
const hasMoreResults = queryResult.rows.length === outerLimit;
function changePagination(pagination: Pagination) {
if (
hasMoreResults &&
Math.floor(queryResult.rows.length / pagination.pageSize) === pagination.page // on the last page
) {
onLoadMore();
}
setPagination(pagination);
}
const numericColumnBraces = getNumericColumnBraces(queryResult, pagination);
return (
<div className={classNames('query-output', { 'more-results': hasMoreResults })}>
<ReactTable
data={queryResult.rows as any[][]}
noDataText={queryResult.rows.length ? '' : 'Query returned no data'}
page={pagination.page}
pageSize={pagination.pageSize}
onPageChange={page => changePagination(changePage(pagination, page))}
onPageSizeChange={(pageSize, page) => changePagination({ page, pageSize })}
sortable={false}
ofText={hasMoreResults ? '' : 'of'}
columns={queryResult.header.map((column, i) => {
const h = column.name;
return {
Header:
i === renamingColumn && parsedQuery
? () => <ColumnRenameInput initialName={h} onDone={renameColumnTo} />
: () => {
return (
<Popover2 className="clickable-cell" content={getHeaderMenu(h, i)}>
<div>
{h}
{hasFilterOnHeader(h, i) && (
<Icon icon={IconNames.FILTER} iconSize={14} />
)}
</div>
</Popover2>
);
},
headerClassName: getHeaderClassName(h, i),
accessor: String(i),
Cell: function QueryOutputTableCell(row) {
const value = row.value;
return (
<div>
<Popover2 content={getCellMenu(h, i, value)}>
{numericColumnBraces[i] ? (
<BracedText
text={String(value)}
braces={numericColumnBraces[i]}
padFractionalPart
/>
) : (
<TableCell value={value} unlimited />
)}
</Popover2>
</div>
);
},
className:
parsedQuery && parsedQuery.isAggregateOutputColumn(h)
? 'aggregate-column'
: undefined,
};
})}
/>
{showValue && <ShowValueDialog onClose={() => setShowValue(undefined)} str={showValue} />}
</div>
);
});