mirror of https://github.com/apache/druid.git
Web console: misc fixes to the Explore view (#17213)
* make record table able to hide column * stickyness * refactor query log * fix measure drag * start nested column dialog * nested expand * fix filtering on Measures * use output name * fix scrolling * select all / none * use ARRAY_CONCAT_AGG * no need to limit if aggregating * remove magic number * better search * update arg list * add, don't replace
This commit is contained in:
parent
e5d027ee1c
commit
715ae5ece0
|
@ -58,6 +58,7 @@ export const LocalStorageKeys = {
|
||||||
SQL_DATA_LOADER_CONTENT: 'sql-data-loader-content' as const,
|
SQL_DATA_LOADER_CONTENT: 'sql-data-loader-content' as const,
|
||||||
|
|
||||||
EXPLORE_STATE: 'explore-state' as const,
|
EXPLORE_STATE: 'explore-state' as const,
|
||||||
|
EXPLORE_STICKY: 'explore-sticky' as const,
|
||||||
};
|
};
|
||||||
export type LocalStorageKeys = (typeof LocalStorageKeys)[keyof typeof LocalStorageKeys];
|
export type LocalStorageKeys = (typeof LocalStorageKeys)[keyof typeof LocalStorageKeys];
|
||||||
|
|
||||||
|
|
|
@ -35,6 +35,7 @@ export function changePage(pagination: Pagination, page: number): Pagination {
|
||||||
export interface ColumnHint {
|
export interface ColumnHint {
|
||||||
displayName?: string;
|
displayName?: string;
|
||||||
group?: string;
|
group?: string;
|
||||||
|
hidden?: boolean;
|
||||||
expressionForWhere?: SqlExpression;
|
expressionForWhere?: SqlExpression;
|
||||||
formatter?: (x: any) => string;
|
formatter?: (x: any) => string;
|
||||||
}
|
}
|
||||||
|
|
|
@ -194,7 +194,7 @@ export const ControlPane = function ControlPane(props: ControlPaneProps) {
|
||||||
};
|
};
|
||||||
return {
|
return {
|
||||||
element: (
|
element: (
|
||||||
<NamedExpressionsInput
|
<NamedExpressionsInput<ExpressionMeta>
|
||||||
allowReordering
|
allowReordering
|
||||||
values={effectiveValue ? [effectiveValue] : []}
|
values={effectiveValue ? [effectiveValue] : []}
|
||||||
onValuesChange={vs => onValueChange(vs[0])}
|
onValuesChange={vs => onValueChange(vs[0])}
|
||||||
|
@ -223,7 +223,7 @@ export const ControlPane = function ControlPane(props: ControlPaneProps) {
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
element: (
|
element: (
|
||||||
<NamedExpressionsInput
|
<NamedExpressionsInput<ExpressionMeta>
|
||||||
allowReordering
|
allowReordering
|
||||||
values={effectiveValue as ExpressionMeta[]}
|
values={effectiveValue as ExpressionMeta[]}
|
||||||
onValuesChange={onValueChange}
|
onValuesChange={onValueChange}
|
||||||
|
@ -266,7 +266,7 @@ export const ControlPane = function ControlPane(props: ControlPaneProps) {
|
||||||
case 'measure': {
|
case 'measure': {
|
||||||
return {
|
return {
|
||||||
element: (
|
element: (
|
||||||
<NamedExpressionsInput
|
<NamedExpressionsInput<Measure>
|
||||||
values={effectiveValue ? [effectiveValue] : []}
|
values={effectiveValue ? [effectiveValue] : []}
|
||||||
onValuesChange={vs => onValueChange(vs[0])}
|
onValuesChange={vs => onValueChange(vs[0])}
|
||||||
singleton
|
singleton
|
||||||
|
@ -284,9 +284,11 @@ export const ControlPane = function ControlPane(props: ControlPaneProps) {
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
onDropColumn: column => {
|
onDropColumn: column => {
|
||||||
const measures = Measure.getPossibleMeasuresForColumn(column);
|
const candidateMeasures = Measure.getPossibleMeasuresForColumn(column).filter(
|
||||||
if (!measures.length) return;
|
p => !effectiveValue || effectiveValue.name !== p.name,
|
||||||
onValueChange(measures[0]);
|
);
|
||||||
|
if (!candidateMeasures.length) return;
|
||||||
|
onValueChange(candidateMeasures[0]);
|
||||||
},
|
},
|
||||||
onDropMeasure: onValueChange,
|
onDropMeasure: onValueChange,
|
||||||
};
|
};
|
||||||
|
@ -313,11 +315,11 @@ export const ControlPane = function ControlPane(props: ControlPaneProps) {
|
||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
onDropColumn: column => {
|
onDropColumn: column => {
|
||||||
const measures = Measure.getPossibleMeasuresForColumn(column).filter(
|
const candidateMeasures = Measure.getPossibleMeasuresForColumn(column).filter(
|
||||||
p => !effectiveValue.some((v: ExpressionMeta) => v.name === p.name),
|
p => !effectiveValue.some((v: Measure) => v.name === p.name),
|
||||||
);
|
);
|
||||||
if (!measures.length) return;
|
if (!candidateMeasures.length) return;
|
||||||
onValueChange(effectiveValue.concat(measures[0]));
|
onValueChange(effectiveValue.concat(candidateMeasures[0]));
|
||||||
},
|
},
|
||||||
onDropMeasure: measure => {
|
onDropMeasure: measure => {
|
||||||
onValueChange(effectiveValue.concat(measure));
|
onValueChange(effectiveValue.concat(measure));
|
||||||
|
|
|
@ -61,6 +61,7 @@ export const NamedExpressionsInput = function NamedExpressionsInput<
|
||||||
|
|
||||||
const onDragOver = useCallback(
|
const onDragOver = useCallback(
|
||||||
(e: React.DragEvent, i: number) => {
|
(e: React.DragEvent, i: number) => {
|
||||||
|
if (dragIndex === -1) return;
|
||||||
const targetRect = e.currentTarget.getBoundingClientRect();
|
const targetRect = e.currentTarget.getBoundingClientRect();
|
||||||
const before = e.clientX - targetRect.left <= targetRect.width / 2;
|
const before = e.clientX - targetRect.left <= targetRect.width / 2;
|
||||||
setDropBefore(before);
|
setDropBefore(before);
|
||||||
|
|
|
@ -52,6 +52,7 @@ export const ContainsFilterControl = React.memo(function ContainsFilterControl(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
.changeOrderByExpression(F.count().toOrderByExpression('DESC'))
|
.changeOrderByExpression(F.count().toOrderByExpression('DESC'))
|
||||||
|
.changeLimitValue(101)
|
||||||
.toString(),
|
.toString(),
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- exclude 'makePattern' from deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- exclude 'makePattern' from deps
|
||||||
[querySource.query, filter, column, contains, negated],
|
[querySource.query, filter, column, contains, negated],
|
||||||
|
|
|
@ -58,6 +58,7 @@ export const RegexpFilterControl = React.memo(function RegexpFilterControl(
|
||||||
SqlExpression.and(filter, regexp ? filterPatternToExpression(filterPattern) : undefined),
|
SqlExpression.and(filter, regexp ? filterPatternToExpression(filterPattern) : undefined),
|
||||||
)
|
)
|
||||||
.changeOrderByExpression(F.count().toOrderByExpression('DESC'))
|
.changeOrderByExpression(F.count().toOrderByExpression('DESC'))
|
||||||
|
.changeLimitValue(101)
|
||||||
.toString(),
|
.toString(),
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- exclude 'makePattern' from deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps -- exclude 'makePattern' from deps
|
||||||
[querySource.query, filter, column, regexp, negated],
|
[querySource.query, filter, column, regexp, negated],
|
||||||
|
|
|
@ -16,14 +16,15 @@
|
||||||
* limitations under the License.
|
* limitations under the License.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import { FormGroup, InputGroup, Menu, MenuItem } from '@blueprintjs/core';
|
import { FormGroup, Menu, MenuItem } from '@blueprintjs/core';
|
||||||
import { IconNames } from '@blueprintjs/icons';
|
import { IconNames } from '@blueprintjs/icons';
|
||||||
import type { QueryResult, SqlQuery, ValuesFilterPattern } from '@druid-toolkit/query';
|
import type { QueryResult, ValuesFilterPattern } from '@druid-toolkit/query';
|
||||||
import { C, F, L, SqlExpression, SqlLiteral } from '@druid-toolkit/query';
|
import { C, F, SqlExpression, SqlQuery } from '@druid-toolkit/query';
|
||||||
import React, { useMemo, useState } from 'react';
|
import React, { useMemo, useState } from 'react';
|
||||||
|
|
||||||
|
import { ClearableInput } from '../../../../../../components';
|
||||||
import { useQueryManager } from '../../../../../../hooks';
|
import { useQueryManager } from '../../../../../../hooks';
|
||||||
import { caseInsensitiveContains } from '../../../../../../utils';
|
import { caseInsensitiveContains, filterMap } from '../../../../../../utils';
|
||||||
import type { QuerySource } from '../../../../models';
|
import type { QuerySource } from '../../../../models';
|
||||||
import { toggle } from '../../../../utils';
|
import { toggle } from '../../../../utils';
|
||||||
import { ColumnValue } from '../../column-value/column-value';
|
import { ColumnValue } from '../../column-value/column-value';
|
||||||
|
@ -46,21 +47,21 @@ export const ValuesFilterControl = React.memo(function ValuesFilterControl(
|
||||||
const [initValues] = useState(selectedValues);
|
const [initValues] = useState(selectedValues);
|
||||||
const [searchString, setSearchString] = useState('');
|
const [searchString, setSearchString] = useState('');
|
||||||
|
|
||||||
const valuesQuery = useMemo(() => {
|
const valuesQuery = useMemo(
|
||||||
const columnRef = C(column);
|
() =>
|
||||||
const queryParts: string[] = [`SELECT ${columnRef.as('c')}`, `FROM (${querySource.query})`];
|
SqlQuery.from(querySource.query)
|
||||||
|
.addSelect(C(column).as('c'), { addToGroupBy: 'end' })
|
||||||
const filterEx = SqlExpression.and(
|
.changeWhereExpression(
|
||||||
|
SqlExpression.and(
|
||||||
filter,
|
filter,
|
||||||
searchString ? F('ICONTAINS_STRING', columnRef, L(searchString)) : undefined,
|
searchString ? F('ICONTAINS_STRING', C(column), searchString) : undefined,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
.changeOrderByExpression(F.count().toOrderByExpression('DESC'))
|
||||||
|
.changeLimitValue(101)
|
||||||
|
.toString(),
|
||||||
|
[querySource.query, filter, column, searchString],
|
||||||
);
|
);
|
||||||
if (!(filterEx instanceof SqlLiteral)) {
|
|
||||||
queryParts.push(`WHERE ${filterEx}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
queryParts.push(`GROUP BY 1 ORDER BY COUNT(*) DESC LIMIT 101`);
|
|
||||||
return queryParts.join('\n');
|
|
||||||
}, [querySource.query, filter, column, searchString]);
|
|
||||||
|
|
||||||
const [valuesState] = useQueryManager<string, any[]>({
|
const [valuesState] = useQueryManager<string, any[]>({
|
||||||
query: valuesQuery,
|
query: valuesQuery,
|
||||||
|
@ -77,23 +78,17 @@ export const ValuesFilterControl = React.memo(function ValuesFilterControl(
|
||||||
if (values) {
|
if (values) {
|
||||||
valuesToShow = valuesToShow.concat(values.filter(v => !initValues.includes(v)));
|
valuesToShow = valuesToShow.concat(values.filter(v => !initValues.includes(v)));
|
||||||
}
|
}
|
||||||
if (searchString) {
|
|
||||||
valuesToShow = valuesToShow.filter(v => caseInsensitiveContains(v, searchString));
|
|
||||||
}
|
|
||||||
|
|
||||||
const showSearch = querySource.columns.find(c => c.name === column)?.sqlType !== 'BOOLEAN';
|
const showSearch = querySource.columns.find(c => c.name === column)?.sqlType !== 'BOOLEAN';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<FormGroup className="values-filter-control">
|
<FormGroup className="values-filter-control">
|
||||||
{showSearch && (
|
{showSearch && (
|
||||||
<InputGroup
|
<ClearableInput value={searchString} onChange={setSearchString} placeholder="Search" />
|
||||||
value={searchString}
|
|
||||||
onChange={e => setSearchString(e.target.value)}
|
|
||||||
placeholder="Search"
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
<Menu className="value-list">
|
<Menu className="value-list">
|
||||||
{valuesToShow.map((v, i) => (
|
{filterMap(valuesToShow, (v, i) => {
|
||||||
|
if (!caseInsensitiveContains(v, searchString)) return;
|
||||||
|
return (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
key={i}
|
key={i}
|
||||||
icon={
|
icon={
|
||||||
|
@ -112,7 +107,8 @@ export const ValuesFilterControl = React.memo(function ValuesFilterControl(
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
{valuesState.loading && <MenuItem icon={IconNames.BLANK} text="Loading..." disabled />}
|
{valuesState.loading && <MenuItem icon={IconNames.BLANK} text="Loading..." disabled />}
|
||||||
</Menu>
|
</Menu>
|
||||||
</FormGroup>
|
</FormGroup>
|
||||||
|
|
|
@ -428,6 +428,7 @@ export const GenericOutputTable = React.memo(function GenericOutputTable(
|
||||||
columns={columnNester(
|
columns={columnNester(
|
||||||
queryResult.header.map((column, i) => {
|
queryResult.header.map((column, i) => {
|
||||||
const h = column.name;
|
const h = column.name;
|
||||||
|
const hint = columnHints?.get(h);
|
||||||
const icon = showTypeIcons ? columnToIcon(column) : undefined;
|
const icon = showTypeIcons ? columnToIcon(column) : undefined;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
@ -446,9 +447,10 @@ export const GenericOutputTable = React.memo(function GenericOutputTable(
|
||||||
},
|
},
|
||||||
headerClassName: getHeaderClassName(h),
|
headerClassName: getHeaderClassName(h),
|
||||||
accessor: String(i),
|
accessor: String(i),
|
||||||
|
show: !hint?.hidden,
|
||||||
Cell(row) {
|
Cell(row) {
|
||||||
const value = row.value;
|
const value = row.value;
|
||||||
const formatter = columnHints?.get(h)?.formatter || formatNumber;
|
const formatter = hint?.formatter || formatNumber;
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Popover content={<Deferred content={() => getCellMenu(column, i, value)} />}>
|
<Popover content={<Deferred content={() => getCellMenu(column, i, value)} />}>
|
||||||
|
|
|
@ -48,9 +48,8 @@ export const ColumnDialog = React.memo(function ColumnDialog(props: ColumnDialog
|
||||||
if (!expression) return;
|
if (!expression) return;
|
||||||
return SqlQuery.from(QuerySource.stripToBaseSource(querySource.query))
|
return SqlQuery.from(QuerySource.stripToBaseSource(querySource.query))
|
||||||
.addSelect(F.cast(expression, 'VARCHAR').as('v'), { addToGroupBy: 'end' })
|
.addSelect(F.cast(expression, 'VARCHAR').as('v'), { addToGroupBy: 'end' })
|
||||||
.applyIf(
|
.applyIf(querySource.hasBaseTimeColumn(), q =>
|
||||||
querySource.baseColumns.find(column => column.isTimeColumn()),
|
q.addWhere(sql`MAX_DATA_TIME() - INTERVAL '14' DAY <= __time`),
|
||||||
q => q.addWhere(sql`MAX_DATA_TIME() - INTERVAL '14' DAY <= __time`),
|
|
||||||
)
|
)
|
||||||
.changeLimitValue(100)
|
.changeLimitValue(100)
|
||||||
.toString();
|
.toString();
|
||||||
|
@ -151,7 +150,7 @@ export const ColumnDialog = React.memo(function ColumnDialog(props: ColumnDialog
|
||||||
} else {
|
} else {
|
||||||
onApply(
|
onApply(
|
||||||
querySource.changeColumn(initExpressionName, newExpression),
|
querySource.changeColumn(initExpressionName, newExpression),
|
||||||
new Map([[initExpression.getOutputName()!, newExpression.getOutputName()!]]),
|
new Map([[initExpressionName, newExpression.getOutputName()!]]),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
|
|
@ -57,9 +57,8 @@ export const MeasureDialog = React.memo(function MeasureDialog(props: MeasureDia
|
||||||
.changeWithParts([SqlWithPart.simple('t', QuerySource.stripToBaseSource(querySource.query))])
|
.changeWithParts([SqlWithPart.simple('t', QuerySource.stripToBaseSource(querySource.query))])
|
||||||
.addSelect(L('Overall').as('label'))
|
.addSelect(L('Overall').as('label'))
|
||||||
.addSelect(expression.as('value'))
|
.addSelect(expression.as('value'))
|
||||||
.applyIf(
|
.applyIf(querySource.hasBaseTimeColumn(), q =>
|
||||||
querySource.baseColumns.find(column => column.isTimeColumn()),
|
q.addWhere(sql`MAX_DATA_TIME() - INTERVAL '14' DAY <= __time`),
|
||||||
q => q.addWhere(sql`MAX_DATA_TIME() - INTERVAL '14' DAY <= __time`),
|
|
||||||
)
|
)
|
||||||
.toString();
|
.toString();
|
||||||
}, [querySource.query, formula]);
|
}, [querySource.query, formula]);
|
||||||
|
|
|
@ -0,0 +1,44 @@
|
||||||
|
/*
|
||||||
|
* 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 '../../../../../variables';
|
||||||
|
|
||||||
|
.nested-column-dialog {
|
||||||
|
&.#{$bp-ns}-dialog {
|
||||||
|
width: 50vw;
|
||||||
|
min-height: 540px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.#{$bp-ns}-dialog-body {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.path-selector {
|
||||||
|
flex: 1;
|
||||||
|
padding: 5px 0;
|
||||||
|
height: 400px;
|
||||||
|
overflow: auto;
|
||||||
|
border-left: 1px solid rgba(15, 19, 32, 0.4);
|
||||||
|
border-right: 1px solid rgba(15, 19, 32, 0.4);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.#{$bp-ns}-dialog-footer {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,176 @@
|
||||||
|
/*
|
||||||
|
* Licensed to the Apache Software Foundation (ASF) under one
|
||||||
|
* or more contributor license agreements. See the NOTICE file
|
||||||
|
* distributed with this work for additional information
|
||||||
|
* regarding copyright ownership. The ASF licenses this file
|
||||||
|
* to you under the Apache License, Version 2.0 (the
|
||||||
|
* "License"); you may not use this file except in compliance
|
||||||
|
* with the License. You may obtain a copy of the License at
|
||||||
|
*
|
||||||
|
* http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
*
|
||||||
|
* Unless required by applicable law or agreed to in writing, software
|
||||||
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
* See the License for the specific language governing permissions and
|
||||||
|
* limitations under the License.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
ButtonGroup,
|
||||||
|
Classes,
|
||||||
|
Dialog,
|
||||||
|
FormGroup,
|
||||||
|
InputGroup,
|
||||||
|
Intent,
|
||||||
|
Menu,
|
||||||
|
Tag,
|
||||||
|
} from '@blueprintjs/core';
|
||||||
|
import type { SqlExpression } from '@druid-toolkit/query';
|
||||||
|
import { type QueryResult, F, sql, SqlFunction, SqlQuery } from '@druid-toolkit/query';
|
||||||
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
|
import { ClearableInput, Loader, MenuCheckbox } from '../../../../../components';
|
||||||
|
import { useQueryManager } from '../../../../../hooks';
|
||||||
|
import { caseInsensitiveContains, filterMap, pluralIfNeeded } from '../../../../../utils';
|
||||||
|
import { ExpressionMeta, QuerySource } from '../../../models';
|
||||||
|
import { toggle } from '../../../utils';
|
||||||
|
|
||||||
|
import './nested-column-dialog.scss';
|
||||||
|
|
||||||
|
const ARRAY_CONCAT_AGG_SIZE = 10000;
|
||||||
|
|
||||||
|
export interface NestedColumnDialogProps {
|
||||||
|
nestedColumn: SqlExpression;
|
||||||
|
onApply(newQuery: SqlQuery): void;
|
||||||
|
querySource: QuerySource;
|
||||||
|
runSqlQuery(query: string | SqlQuery): Promise<QueryResult>;
|
||||||
|
onClose(): void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const NestedColumnDialog = React.memo(function NestedColumnDialog(
|
||||||
|
props: NestedColumnDialogProps,
|
||||||
|
) {
|
||||||
|
const { nestedColumn, onApply, querySource, runSqlQuery, onClose } = props;
|
||||||
|
const [searchString, setSearchString] = useState('');
|
||||||
|
const [selectedPaths, setSelectedPaths] = useState<string[]>([]);
|
||||||
|
const [namingScheme, setNamingScheme] = useState(`${nestedColumn.getFirstColumnName()}[%]`);
|
||||||
|
|
||||||
|
const [pathsState] = useQueryManager({
|
||||||
|
query: nestedColumn,
|
||||||
|
processQuery: async nestedColumn => {
|
||||||
|
const query = SqlQuery.from(QuerySource.stripToBaseSource(querySource.query))
|
||||||
|
.addSelect(
|
||||||
|
SqlFunction.decorated('ARRAY_CONCAT_AGG', 'DISTINCT', [
|
||||||
|
F('JSON_PATHS', nestedColumn),
|
||||||
|
ARRAY_CONCAT_AGG_SIZE,
|
||||||
|
]),
|
||||||
|
)
|
||||||
|
.applyIf(querySource.hasBaseTimeColumn(), q =>
|
||||||
|
q.addWhere(sql`MAX_DATA_TIME() - INTERVAL '14' DAY <= __time`),
|
||||||
|
);
|
||||||
|
|
||||||
|
const pathResult = await runSqlQuery(query);
|
||||||
|
|
||||||
|
const paths = pathResult.rows[0]?.[0];
|
||||||
|
if (!Array.isArray(paths)) throw new Error('Could not get paths');
|
||||||
|
|
||||||
|
return paths;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const paths = pathsState.data;
|
||||||
|
return (
|
||||||
|
<Dialog className="nested-column-dialog" isOpen onClose={onClose} title="Expand nested column">
|
||||||
|
<div className={Classes.DIALOG_BODY}>
|
||||||
|
<p>
|
||||||
|
Replace <Tag minimal>{String(nestedColumn.getOutputName())}</Tag> with path expansions for
|
||||||
|
the selected paths.
|
||||||
|
</p>
|
||||||
|
{pathsState.isLoading() && <Loader />}
|
||||||
|
{pathsState.getErrorMessage()}
|
||||||
|
{paths && (
|
||||||
|
<FormGroup>
|
||||||
|
<ClearableInput value={searchString} onChange={setSearchString} placeholder="Search" />
|
||||||
|
<Menu className="path-selector">
|
||||||
|
{filterMap(paths, (path, i) => {
|
||||||
|
if (!caseInsensitiveContains(path, searchString)) return;
|
||||||
|
return (
|
||||||
|
<MenuCheckbox
|
||||||
|
key={i}
|
||||||
|
checked={selectedPaths.includes(path)}
|
||||||
|
onChange={() => setSelectedPaths(toggle(selectedPaths, path))}
|
||||||
|
text={path}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Menu>
|
||||||
|
<ButtonGroup fill>
|
||||||
|
<Button
|
||||||
|
text="Select all"
|
||||||
|
onClick={() =>
|
||||||
|
// Select all paths matching the shown search string
|
||||||
|
setSelectedPaths(
|
||||||
|
paths.filter(path => caseInsensitiveContains(path, searchString)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
text="Select none"
|
||||||
|
onClick={() =>
|
||||||
|
// Remove from selection all the paths matching the search string
|
||||||
|
setSelectedPaths(
|
||||||
|
selectedPaths.filter(path => !caseInsensitiveContains(path, searchString)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</ButtonGroup>
|
||||||
|
</FormGroup>
|
||||||
|
)}
|
||||||
|
<FormGroup label="Nameing scheme">
|
||||||
|
<InputGroup
|
||||||
|
value={namingScheme}
|
||||||
|
onChange={e => {
|
||||||
|
setNamingScheme(e.target.value.slice(0, ExpressionMeta.MAX_NAME_LENGTH));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</FormGroup>
|
||||||
|
</div>
|
||||||
|
<div className={Classes.DIALOG_FOOTER}>
|
||||||
|
<div className={Classes.DIALOG_FOOTER_ACTIONS}>
|
||||||
|
<div className="edit-column-dialog-buttons">
|
||||||
|
<Button text="Cancel" onClick={onClose} />
|
||||||
|
<Button
|
||||||
|
text={
|
||||||
|
selectedPaths.length
|
||||||
|
? `Add ${pluralIfNeeded(selectedPaths.length, 'column')}`
|
||||||
|
: 'Select path'
|
||||||
|
}
|
||||||
|
disabled={!selectedPaths.length}
|
||||||
|
intent={Intent.PRIMARY}
|
||||||
|
onClick={() => {
|
||||||
|
const effectiveNamingScheme = namingScheme.includes('%')
|
||||||
|
? namingScheme
|
||||||
|
: namingScheme + '[%]';
|
||||||
|
onApply(
|
||||||
|
querySource.addColumnAfter(
|
||||||
|
nestedColumn.getOutputName()!,
|
||||||
|
...selectedPaths.map(path =>
|
||||||
|
F('JSON_VALUE', nestedColumn, path).as(
|
||||||
|
querySource.getAvailableName(
|
||||||
|
effectiveNamingScheme.replaceAll('%', path.replace(/^\$\./, '')),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
onClose();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
});
|
|
@ -40,6 +40,7 @@ import type { Rename } from '../../utils';
|
||||||
|
|
||||||
import { ColumnDialog } from './column-dialog/column-dialog';
|
import { ColumnDialog } from './column-dialog/column-dialog';
|
||||||
import { MeasureDialog } from './measure-dialog/measure-dialog';
|
import { MeasureDialog } from './measure-dialog/measure-dialog';
|
||||||
|
import { NestedColumnDialog } from './nested-column-dialog/nested-column-dialog';
|
||||||
|
|
||||||
import './resource-pane.scss';
|
import './resource-pane.scss';
|
||||||
|
|
||||||
|
@ -67,6 +68,9 @@ export const ResourcePane = function ResourcePane(props: ResourcePaneProps) {
|
||||||
const [columnSearch, setColumnSearch] = useState('');
|
const [columnSearch, setColumnSearch] = useState('');
|
||||||
|
|
||||||
const [columnEditorOpenOn, setColumnEditorOpenOn] = useState<ColumnEditorOpenOn | undefined>();
|
const [columnEditorOpenOn, setColumnEditorOpenOn] = useState<ColumnEditorOpenOn | undefined>();
|
||||||
|
const [nestedColumnEditorOpenOn, setNestedColumnEditorOpenOn] = useState<
|
||||||
|
SqlExpression | undefined
|
||||||
|
>();
|
||||||
const [measureEditorOpenOn, setMeasureEditorOpenOn] = useState<MeasureEditorOpenOn | undefined>();
|
const [measureEditorOpenOn, setMeasureEditorOpenOn] = useState<MeasureEditorOpenOn | undefined>();
|
||||||
|
|
||||||
function applyUtil(nameTransform: (columnName: string) => string) {
|
function applyUtil(nameTransform: (columnName: string) => string) {
|
||||||
|
@ -112,6 +116,7 @@ export const ResourcePane = function ResourcePane(props: ResourcePaneProps) {
|
||||||
<div className="column-resource-list">
|
<div className="column-resource-list">
|
||||||
{filterMap(querySource.columns, (column, i) => {
|
{filterMap(querySource.columns, (column, i) => {
|
||||||
const columnName = column.name;
|
const columnName = column.name;
|
||||||
|
const isNestedColumn = column.nativeType === 'COMPLEX<json>';
|
||||||
if (!caseInsensitiveContains(columnName, columnSearch)) return;
|
if (!caseInsensitiveContains(columnName, columnSearch)) return;
|
||||||
return (
|
return (
|
||||||
<Popover
|
<Popover
|
||||||
|
@ -120,6 +125,18 @@ export const ResourcePane = function ResourcePane(props: ResourcePaneProps) {
|
||||||
position="right"
|
position="right"
|
||||||
content={
|
content={
|
||||||
<Menu>
|
<Menu>
|
||||||
|
{isNestedColumn ? (
|
||||||
|
<MenuItem
|
||||||
|
icon={IconNames.EXPAND_ALL}
|
||||||
|
text="Expand nested column"
|
||||||
|
onClick={() =>
|
||||||
|
setNestedColumnEditorOpenOn(
|
||||||
|
querySource.getSourceExpressionForColumn(columnName),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
{onFilter && (
|
{onFilter && (
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={IconNames.FILTER}
|
icon={IconNames.FILTER}
|
||||||
|
@ -133,6 +150,8 @@ export const ResourcePane = function ResourcePane(props: ResourcePaneProps) {
|
||||||
onClick={() => onShowColumn(column)}
|
onClick={() => onShowColumn(column)}
|
||||||
/>
|
/>
|
||||||
<MenuDivider />
|
<MenuDivider />
|
||||||
|
</>
|
||||||
|
)}
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={IconNames.EDIT}
|
icon={IconNames.EDIT}
|
||||||
text="Edit"
|
text="Edit"
|
||||||
|
@ -165,7 +184,7 @@ export const ResourcePane = function ResourcePane(props: ResourcePaneProps) {
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={Classes.MENU_ITEM}
|
className={Classes.MENU_ITEM}
|
||||||
draggable
|
draggable={!isNestedColumn}
|
||||||
onDragStart={e => {
|
onDragStart={e => {
|
||||||
e.dataTransfer.effectAllowed = 'all';
|
e.dataTransfer.effectAllowed = 'all';
|
||||||
DragHelper.dragColumn = column;
|
DragHelper.dragColumn = column;
|
||||||
|
@ -268,6 +287,15 @@ export const ResourcePane = function ResourcePane(props: ResourcePaneProps) {
|
||||||
onClose={() => setColumnEditorOpenOn(undefined)}
|
onClose={() => setColumnEditorOpenOn(undefined)}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
{nestedColumnEditorOpenOn && (
|
||||||
|
<NestedColumnDialog
|
||||||
|
nestedColumn={nestedColumnEditorOpenOn}
|
||||||
|
onApply={newQuery => onQueryChange(newQuery, undefined)}
|
||||||
|
querySource={querySource}
|
||||||
|
runSqlQuery={runSqlQuery}
|
||||||
|
onClose={() => setNestedColumnEditorOpenOn(undefined)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
{measureEditorOpenOn && (
|
{measureEditorOpenOn && (
|
||||||
<MeasureDialog
|
<MeasureDialog
|
||||||
initMeasure={measureEditorOpenOn.measure}
|
initMeasure={measureEditorOpenOn.measure}
|
||||||
|
|
|
@ -30,7 +30,15 @@ import { useStore } from 'zustand';
|
||||||
import { ShowValueDialog } from '../../dialogs/show-value-dialog/show-value-dialog';
|
import { ShowValueDialog } from '../../dialogs/show-value-dialog/show-value-dialog';
|
||||||
import { useHashAndLocalStorageHybridState, useQueryManager } from '../../hooks';
|
import { useHashAndLocalStorageHybridState, useQueryManager } from '../../hooks';
|
||||||
import { Api, AppToaster } from '../../singletons';
|
import { Api, AppToaster } from '../../singletons';
|
||||||
import { DruidError, LocalStorageKeys, queryDruidSql } from '../../utils';
|
import {
|
||||||
|
DruidError,
|
||||||
|
isEmpty,
|
||||||
|
localStorageGetJson,
|
||||||
|
LocalStorageKeys,
|
||||||
|
localStorageSetJson,
|
||||||
|
mapRecord,
|
||||||
|
queryDruidSql,
|
||||||
|
} from '../../utils';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
ControlPane,
|
ControlPane,
|
||||||
|
@ -50,29 +58,14 @@ import { QuerySource } from './models';
|
||||||
import { ModuleRepository } from './module-repository/module-repository';
|
import { ModuleRepository } from './module-repository/module-repository';
|
||||||
import { rewriteAggregate, rewriteMaxDataTime } from './query-macros';
|
import { rewriteAggregate, rewriteMaxDataTime } from './query-macros';
|
||||||
import type { Rename } from './utils';
|
import type { Rename } from './utils';
|
||||||
import { adjustTransferValue, normalizeType } from './utils';
|
import { adjustTransferValue, normalizeType, QueryLog } from './utils';
|
||||||
|
|
||||||
import './explore-view.scss';
|
import './explore-view.scss';
|
||||||
|
|
||||||
// ---------------------------------------
|
const QUERY_LOG = new QueryLog();
|
||||||
|
|
||||||
interface QueryHistoryEntry {
|
function getStickyParameterValuesForModule(moduleId: string): ParameterValues {
|
||||||
time: Date;
|
return localStorageGetJson(LocalStorageKeys.EXPLORE_STICKY)?.[moduleId] || {};
|
||||||
sqlQuery: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const MAX_PAST_QUERIES = 10;
|
|
||||||
const QUERY_HISTORY: QueryHistoryEntry[] = [];
|
|
||||||
|
|
||||||
function addQueryToHistory(sqlQuery: string): void {
|
|
||||||
QUERY_HISTORY.unshift({ time: new Date(), sqlQuery });
|
|
||||||
while (QUERY_HISTORY.length > MAX_PAST_QUERIES) QUERY_HISTORY.pop();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFormattedQueryHistory(): string {
|
|
||||||
return QUERY_HISTORY.map(
|
|
||||||
({ time, sqlQuery }) => `At ${time.toISOString()} ran query:\n\n${sqlQuery}`,
|
|
||||||
).join('\n\n-----------------------------------------------------\n\n');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------
|
// ---------------------------------------
|
||||||
|
@ -81,7 +74,7 @@ const queryRunner = new QueryRunner({
|
||||||
inflateDateStrategy: 'fromSqlTypes',
|
inflateDateStrategy: 'fromSqlTypes',
|
||||||
executor: async (sqlQueryPayload, isSql, cancelToken) => {
|
executor: async (sqlQueryPayload, isSql, cancelToken) => {
|
||||||
if (!isSql) throw new Error('should never get here');
|
if (!isSql) throw new Error('should never get here');
|
||||||
addQueryToHistory(sqlQueryPayload.query);
|
QUERY_LOG.addQuery(sqlQueryPayload.query);
|
||||||
return Api.instance.post('/druid/v2/sql', sqlQueryPayload, { cancelToken });
|
return Api.instance.post('/druid/v2/sql', sqlQueryPayload, { cancelToken });
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
@ -90,6 +83,9 @@ async function runSqlQuery(query: string | SqlQuery): Promise<QueryResult> {
|
||||||
try {
|
try {
|
||||||
return await queryRunner.runQuery({
|
return await queryRunner.runQuery({
|
||||||
query,
|
query,
|
||||||
|
defaultQueryContext: {
|
||||||
|
sqlStringifyArrays: false,
|
||||||
|
},
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw new DruidError(e);
|
throw new DruidError(e);
|
||||||
|
@ -193,10 +189,25 @@ export const ExploreView = React.memo(function ExploreView() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function resetParameterValues() {
|
function resetParameterValues() {
|
||||||
setParameterValues({});
|
setParameterValues(getStickyParameterValuesForModule(moduleId));
|
||||||
}
|
}
|
||||||
|
|
||||||
function updateParameterValues(newParameterValues: ParameterValues) {
|
function updateParameterValues(newParameterValues: ParameterValues) {
|
||||||
|
// Evaluate sticky-ness
|
||||||
|
if (module) {
|
||||||
|
const currentExploreSticky = localStorageGetJson(LocalStorageKeys.EXPLORE_STICKY) || {};
|
||||||
|
const currentModuleSticky = currentExploreSticky[moduleId] || {};
|
||||||
|
const newModuleSticky = {
|
||||||
|
...currentModuleSticky,
|
||||||
|
...mapRecord(newParameterValues, (v, k) => (module.parameters[k]?.sticky ? v : undefined)),
|
||||||
|
};
|
||||||
|
|
||||||
|
localStorageSetJson(LocalStorageKeys.EXPLORE_STICKY, {
|
||||||
|
...currentExploreSticky,
|
||||||
|
[moduleId]: isEmpty(newModuleSticky) ? undefined : newModuleSticky,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
setParameterValues({ ...parameterValues, ...newParameterValues });
|
setParameterValues({ ...parameterValues, ...newParameterValues });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -311,7 +322,8 @@ export const ExploreView = React.memo(function ExploreView() {
|
||||||
]}
|
]}
|
||||||
selectedModuleId={moduleId}
|
selectedModuleId={moduleId}
|
||||||
onSelectedModuleIdChange={newModuleId => {
|
onSelectedModuleIdChange={newModuleId => {
|
||||||
const newParameterValues: ParameterValues = {};
|
const newParameterValues = getStickyParameterValuesForModule(newModuleId);
|
||||||
|
|
||||||
const oldModule = ModuleRepository.getModule(moduleId);
|
const oldModule = ModuleRepository.getModule(moduleId);
|
||||||
const newModule = ModuleRepository.getModule(newModuleId);
|
const newModule = ModuleRepository.getModule(newModuleId);
|
||||||
if (oldModule && newModule) {
|
if (oldModule && newModule) {
|
||||||
|
@ -349,9 +361,9 @@ export const ExploreView = React.memo(function ExploreView() {
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={IconNames.DUPLICATE}
|
icon={IconNames.DUPLICATE}
|
||||||
text="Copy last query"
|
text="Copy last query"
|
||||||
disabled={!QUERY_HISTORY.length}
|
disabled={!QUERY_LOG.length()}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
copy(QUERY_HISTORY[0]?.sqlQuery, { format: 'text/plain' });
|
copy(QUERY_LOG.getLastQuery()!, { format: 'text/plain' });
|
||||||
AppToaster.show({
|
AppToaster.show({
|
||||||
message: `Copied query to clipboard`,
|
message: `Copied query to clipboard`,
|
||||||
intent: Intent.SUCCESS,
|
intent: Intent.SUCCESS,
|
||||||
|
@ -360,9 +372,9 @@ export const ExploreView = React.memo(function ExploreView() {
|
||||||
/>
|
/>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
icon={IconNames.HISTORY}
|
icon={IconNames.HISTORY}
|
||||||
text="Show query history"
|
text="Show query log"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setShownText(getFormattedQueryHistory());
|
setShownText(QUERY_LOG.getFormatted());
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<MenuItem
|
<MenuItem
|
||||||
|
|
|
@ -90,7 +90,7 @@ export class Measure extends ExpressionMeta {
|
||||||
}
|
}
|
||||||
|
|
||||||
switch (column.nativeType) {
|
switch (column.nativeType) {
|
||||||
case 'BIGINT':
|
case 'LONG':
|
||||||
case 'FLOAT':
|
case 'FLOAT':
|
||||||
case 'DOUBLE':
|
case 'DOUBLE':
|
||||||
return [
|
return [
|
||||||
|
@ -103,16 +103,16 @@ export class Measure extends ExpressionMeta {
|
||||||
new Measure({
|
new Measure({
|
||||||
expression: F.min(C(column.name)),
|
expression: F.min(C(column.name)),
|
||||||
}),
|
}),
|
||||||
new Measure({
|
|
||||||
expression: SqlFunction.countDistinct(C(column.name)),
|
|
||||||
}),
|
|
||||||
new Measure({
|
new Measure({
|
||||||
as: `P98 ${column.name}`,
|
as: `P98 ${column.name}`,
|
||||||
expression: F('APPROX_QUANTILE_DS', C(column.name), 0.98),
|
expression: F('APPROX_QUANTILE_DS', C(column.name), 0.98),
|
||||||
}),
|
}),
|
||||||
|
new Measure({
|
||||||
|
expression: SqlFunction.countDistinct(C(column.name)),
|
||||||
|
}),
|
||||||
];
|
];
|
||||||
|
|
||||||
case 'VARCHAR':
|
case 'STRING':
|
||||||
case 'COMPLEX':
|
case 'COMPLEX':
|
||||||
case 'COMPLEX<hyperUnique>':
|
case 'COMPLEX<hyperUnique>':
|
||||||
return [
|
return [
|
||||||
|
|
|
@ -85,6 +85,7 @@ export type TypedParameterDefinition<Type extends keyof ParameterTypes> = TypedE
|
||||||
| ParameterTypes[Type]
|
| ParameterTypes[Type]
|
||||||
| ((querySource: QuerySource) => ParameterTypes[Type] | undefined);
|
| ((querySource: QuerySource) => ParameterTypes[Type] | undefined);
|
||||||
|
|
||||||
|
sticky?: boolean;
|
||||||
required?: ModuleFunctor<boolean>;
|
required?: ModuleFunctor<boolean>;
|
||||||
description?: ModuleFunctor<string>;
|
description?: ModuleFunctor<string>;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
|
|
@ -95,7 +95,9 @@ export class QuerySource {
|
||||||
let effectiveColumns = columns;
|
let effectiveColumns = columns;
|
||||||
if (query.getSelectExpressionsArray().some(ex => ex instanceof SqlStar)) {
|
if (query.getSelectExpressionsArray().some(ex => ex instanceof SqlStar)) {
|
||||||
// The query has a star so carefully pick the columns that make sense
|
// The query has a star so carefully pick the columns that make sense
|
||||||
effectiveColumns = columns.filter(c => c.sqlType !== 'OTHER');
|
effectiveColumns = columns.filter(
|
||||||
|
c => c.sqlType !== 'OTHER' || c.nativeType === 'COMPLEX<json>',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
let measures = Measure.extractQueryMeasures(query);
|
let measures = Measure.extractQueryMeasures(query);
|
||||||
|
@ -179,6 +181,10 @@ export class QuerySource {
|
||||||
return this.measures.some(m => m.name === name);
|
return this.measures.some(m => m.name === name);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public hasBaseTimeColumn(): boolean {
|
||||||
|
return this.baseColumns.some(column => column.isTimeColumn());
|
||||||
|
}
|
||||||
|
|
||||||
public getSourceExpressionForColumn(outputName: string): SqlExpression {
|
public getSourceExpressionForColumn(outputName: string): SqlExpression {
|
||||||
const selectExpressionsArray = this.query.getSelectExpressionsArray();
|
const selectExpressionsArray = this.query.getSelectExpressionsArray();
|
||||||
|
|
||||||
|
@ -224,12 +230,12 @@ export class QuerySource {
|
||||||
return noStarQuery.addSelect(newExpression);
|
return noStarQuery.addSelect(newExpression);
|
||||||
}
|
}
|
||||||
|
|
||||||
public addColumnAfter(neighborName: string, newExpression: SqlExpression): SqlQuery {
|
public addColumnAfter(neighborName: string, ...newExpressions: SqlExpression[]): SqlQuery {
|
||||||
const noStarQuery = QuerySource.materializeStarIfNeeded(this.query, this.columns);
|
const noStarQuery = QuerySource.materializeStarIfNeeded(this.query, this.columns);
|
||||||
return noStarQuery.changeSelectExpressions(
|
return noStarQuery.changeSelectExpressions(
|
||||||
noStarQuery
|
noStarQuery
|
||||||
.getSelectExpressionsArray()
|
.getSelectExpressionsArray()
|
||||||
.flatMap(ex => (ex.getOutputName() === neighborName ? [ex, newExpression] : ex)),
|
.flatMap(ex => (ex.getOutputName() === neighborName ? [ex, ...newExpressions] : ex)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -105,6 +105,7 @@ ModuleRepository.registerModule<GroupingTableParameterValues>({
|
||||||
count: `Show '<count> values'`,
|
count: `Show '<count> values'`,
|
||||||
},
|
},
|
||||||
defaultValue: 'null',
|
defaultValue: 'null',
|
||||||
|
sticky: true,
|
||||||
visible: ({ parameterValues }) => Boolean((parameterValues.showColumns || []).length),
|
visible: ({ parameterValues }) => Boolean((parameterValues.showColumns || []).length),
|
||||||
},
|
},
|
||||||
pivotColumn: {
|
pivotColumn: {
|
||||||
|
|
|
@ -21,10 +21,9 @@ import React, { useMemo } from 'react';
|
||||||
|
|
||||||
import { Loader } from '../../../components';
|
import { Loader } from '../../../components';
|
||||||
import { useQueryManager } from '../../../hooks';
|
import { useQueryManager } from '../../../hooks';
|
||||||
import {
|
import type { ColumnHint } from '../../../utils';
|
||||||
calculateInitPageSize,
|
import { filterMap } from '../../../utils';
|
||||||
GenericOutputTable,
|
import { calculateInitPageSize, GenericOutputTable } from '../components';
|
||||||
} from '../components/generic-output-table/generic-output-table';
|
|
||||||
import { ModuleRepository } from '../module-repository/module-repository';
|
import { ModuleRepository } from '../module-repository/module-repository';
|
||||||
|
|
||||||
import './record-table-module.scss';
|
import './record-table-module.scss';
|
||||||
|
@ -33,6 +32,7 @@ interface RecordTableParameterValues {
|
||||||
maxRows: number;
|
maxRows: number;
|
||||||
ascending: boolean;
|
ascending: boolean;
|
||||||
showTypeIcons: boolean;
|
showTypeIcons: boolean;
|
||||||
|
hideNullColumns: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
ModuleRepository.registerModule<RecordTableParameterValues>({
|
ModuleRepository.registerModule<RecordTableParameterValues>({
|
||||||
|
@ -50,10 +50,18 @@ ModuleRepository.registerModule<RecordTableParameterValues>({
|
||||||
ascending: {
|
ascending: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
defaultValue: false,
|
defaultValue: false,
|
||||||
|
sticky: true,
|
||||||
},
|
},
|
||||||
showTypeIcons: {
|
showTypeIcons: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
|
sticky: true,
|
||||||
|
},
|
||||||
|
hideNullColumns: {
|
||||||
|
type: 'boolean',
|
||||||
|
label: 'Hide all null columns',
|
||||||
|
defaultValue: false,
|
||||||
|
sticky: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
component: function RecordTableModule(props) {
|
component: function RecordTableModule(props) {
|
||||||
|
@ -77,6 +85,18 @@ ModuleRepository.registerModule<RecordTableParameterValues>({
|
||||||
});
|
});
|
||||||
|
|
||||||
const resultData = resultState.getSomeData();
|
const resultData = resultState.getSomeData();
|
||||||
|
|
||||||
|
let columnHints: Map<string, ColumnHint> | undefined;
|
||||||
|
if (parameterValues.hideNullColumns && resultData) {
|
||||||
|
columnHints = new Map<string, ColumnHint>(
|
||||||
|
filterMap(resultData.header, (column, i) =>
|
||||||
|
resultData.getColumnByIndex(i)?.every(v => v == null)
|
||||||
|
? [column.name, { hidden: true }]
|
||||||
|
: undefined,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="record-table-module module">
|
<div className="record-table-module module">
|
||||||
{resultState.error ? (
|
{resultState.error ? (
|
||||||
|
@ -84,6 +104,7 @@ ModuleRepository.registerModule<RecordTableParameterValues>({
|
||||||
) : resultData ? (
|
) : resultData ? (
|
||||||
<GenericOutputTable
|
<GenericOutputTable
|
||||||
queryResult={resultData}
|
queryResult={resultData}
|
||||||
|
columnHints={columnHints}
|
||||||
showTypeIcons={parameterValues.showTypeIcons}
|
showTypeIcons={parameterValues.showTypeIcons}
|
||||||
onWhereChange={setWhere}
|
onWhereChange={setWhere}
|
||||||
initPageSize={calculateInitPageSize(stage.height)}
|
initPageSize={calculateInitPageSize(stage.height)}
|
||||||
|
|
|
@ -114,8 +114,9 @@ ModuleRepository.registerModule<TimeChartParameterValues>({
|
||||||
},
|
},
|
||||||
snappyHighlight: {
|
snappyHighlight: {
|
||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
label: 'Snap highlight to nearest dates',
|
label: 'Snap highlight to granularity',
|
||||||
defaultValue: true,
|
defaultValue: true,
|
||||||
|
sticky: true,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
component: function TimeChartModule(props) {
|
component: function TimeChartModule(props) {
|
||||||
|
|
|
@ -20,6 +20,7 @@ import { C, SqlFunction, SqlQuery } from '@druid-toolkit/query';
|
||||||
|
|
||||||
import { filterMap, uniq } from '../../../utils';
|
import { filterMap, uniq } from '../../../utils';
|
||||||
import { Measure } from '../models';
|
import { Measure } from '../models';
|
||||||
|
import { KNOWN_AGGREGATIONS } from '../utils';
|
||||||
|
|
||||||
export function rewriteAggregate(query: SqlQuery, measures: Measure[]): SqlQuery {
|
export function rewriteAggregate(query: SqlQuery, measures: Measure[]): SqlQuery {
|
||||||
const usedMeasures: Map<string, boolean> = new Map();
|
const usedMeasures: Map<string, boolean> = new Map();
|
||||||
|
@ -35,7 +36,14 @@ export function rewriteAggregate(query: SqlQuery, measures: Measure[]): SqlQuery
|
||||||
if (!measure) throw new Error(`${Measure.AGGREGATE} of unknown measure '${measureName}'`);
|
if (!measure) throw new Error(`${Measure.AGGREGATE} of unknown measure '${measureName}'`);
|
||||||
|
|
||||||
usedMeasures.set(measureName, true);
|
usedMeasures.set(measureName, true);
|
||||||
return measure.expression;
|
|
||||||
|
let measureExpression = measure.expression;
|
||||||
|
const filter = ex.getWhereExpression();
|
||||||
|
if (filter) {
|
||||||
|
measureExpression = measureExpression.addFilterToAggregations(filter, KNOWN_AGGREGATIONS);
|
||||||
|
}
|
||||||
|
|
||||||
|
return measureExpression;
|
||||||
}
|
}
|
||||||
|
|
||||||
// If we encounter a (the) query with the measure definitions, and we have used those measures then expand out all the columns within them
|
// If we encounter a (the) query with the measure definitions, and we have used those measures then expand out all the columns within them
|
||||||
|
|
|
@ -21,8 +21,10 @@ export * from './duration';
|
||||||
export * from './filter-pattern-helpers';
|
export * from './filter-pattern-helpers';
|
||||||
export * from './general';
|
export * from './general';
|
||||||
export * from './get-auto-granularity';
|
export * from './get-auto-granularity';
|
||||||
|
export * from './known-aggregations';
|
||||||
export * from './max-time-for-table';
|
export * from './max-time-for-table';
|
||||||
export * from './misc';
|
export * from './misc';
|
||||||
|
export * from './query-log';
|
||||||
export * from './snap-to-granularity';
|
export * from './snap-to-granularity';
|
||||||
export * from './table-query';
|
export * from './table-query';
|
||||||
export * from './time-manipulation';
|
export * from './time-manipulation';
|
||||||
|
|
|
@ -0,0 +1,59 @@
|
||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export const KNOWN_AGGREGATIONS = [
|
||||||
|
'COUNT',
|
||||||
|
'SUM',
|
||||||
|
'MIN',
|
||||||
|
'MAX',
|
||||||
|
'AVG',
|
||||||
|
'APPROX_COUNT_DISTINCT',
|
||||||
|
'APPROX_COUNT_DISTINCT_BUILTIN',
|
||||||
|
'APPROX_QUANTILE',
|
||||||
|
'APPROX_QUANTILE_FIXED_BUCKETS',
|
||||||
|
'BLOOM_FILTER',
|
||||||
|
'VAR_POP',
|
||||||
|
'VAR_SAMP',
|
||||||
|
'VARIANCE',
|
||||||
|
'STDDEV_POP',
|
||||||
|
'STDDEV_SAMP',
|
||||||
|
'STDDEV',
|
||||||
|
'EARLIEST',
|
||||||
|
'EARLIEST_BY',
|
||||||
|
'LATEST',
|
||||||
|
'LATEST_BY',
|
||||||
|
'ANY_VALUE',
|
||||||
|
'GROUPING',
|
||||||
|
'ARRAY_AGG',
|
||||||
|
'ARRAY_AGG',
|
||||||
|
'ARRAY_CONCAT_AGG',
|
||||||
|
'ARRAY_CONCAT_AGG',
|
||||||
|
'STRING_AGG',
|
||||||
|
'LISTAGG',
|
||||||
|
'BIT_AND',
|
||||||
|
'BIT_OR',
|
||||||
|
'BIT_XOR',
|
||||||
|
'APPROX_COUNT_DISTINCT_DS_THETA',
|
||||||
|
'DS_THETA',
|
||||||
|
'APPROX_QUANTILE_DS',
|
||||||
|
'DS_QUANTILES_SKETCH',
|
||||||
|
'DS_TUPLE_DOUBLES',
|
||||||
|
'DS_TUPLE_DOUBLES',
|
||||||
|
'TDIGEST_QUANTILE',
|
||||||
|
'TDIGEST_GENERATE_SKETCH',
|
||||||
|
];
|
|
@ -0,0 +1,48 @@
|
||||||
|
/*
|
||||||
|
* 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.
|
||||||
|
*/
|
||||||
|
|
||||||
|
interface QueryLogEntry {
|
||||||
|
time: Date;
|
||||||
|
sqlQuery: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_QUERIES_TO_LOG = 10;
|
||||||
|
|
||||||
|
export class QueryLog {
|
||||||
|
private readonly queryLog: QueryLogEntry[] = [];
|
||||||
|
|
||||||
|
public length(): number {
|
||||||
|
return this.queryLog.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
public addQuery(sqlQuery: string): void {
|
||||||
|
const { queryLog } = this;
|
||||||
|
queryLog.unshift({ time: new Date(), sqlQuery });
|
||||||
|
while (queryLog.length > MAX_QUERIES_TO_LOG) queryLog.pop();
|
||||||
|
}
|
||||||
|
|
||||||
|
public getLastQuery(): string | undefined {
|
||||||
|
return this.queryLog[0]?.sqlQuery;
|
||||||
|
}
|
||||||
|
|
||||||
|
public getFormatted(): string {
|
||||||
|
return this.queryLog
|
||||||
|
.map(({ time, sqlQuery }) => `At ${time.toISOString()} ran query:\n\n${sqlQuery}`)
|
||||||
|
.join('\n\n-----------------------------------------------------\n\n');
|
||||||
|
}
|
||||||
|
}
|
|
@ -37,6 +37,7 @@ import { Measure } from '../models';
|
||||||
|
|
||||||
import { formatDuration } from './duration';
|
import { formatDuration } from './duration';
|
||||||
import { addTableScope } from './general';
|
import { addTableScope } from './general';
|
||||||
|
import { KNOWN_AGGREGATIONS } from './known-aggregations';
|
||||||
import type { Compare } from './time-manipulation';
|
import type { Compare } from './time-manipulation';
|
||||||
import { computeWhereForCompares } from './time-manipulation';
|
import { computeWhereForCompares } from './time-manipulation';
|
||||||
|
|
||||||
|
@ -48,35 +49,6 @@ export type CompareType = 'value' | 'delta' | 'absDelta' | 'percent' | 'absPerce
|
||||||
|
|
||||||
export type RestrictTop = 'always' | 'never';
|
export type RestrictTop = 'always' | 'never';
|
||||||
|
|
||||||
const KNOWN_AGGREGATIONS = [
|
|
||||||
'COUNT',
|
|
||||||
'SUM',
|
|
||||||
'MIN',
|
|
||||||
'MAX',
|
|
||||||
'AVG',
|
|
||||||
'APPROX_COUNT_DISTINCT',
|
|
||||||
'APPROX_COUNT_DISTINCT_DS_HLL',
|
|
||||||
'APPROX_COUNT_DISTINCT_DS_THETA',
|
|
||||||
'DS_HLL',
|
|
||||||
'DS_THETA',
|
|
||||||
'APPROX_QUANTILE',
|
|
||||||
'APPROX_QUANTILE_DS',
|
|
||||||
'APPROX_QUANTILE_FIXED_BUCKETS',
|
|
||||||
'DS_QUANTILES_SKETCH',
|
|
||||||
'BLOOM_FILTER',
|
|
||||||
'TDIGEST_QUANTILE',
|
|
||||||
'TDIGEST_GENERATE_SKETCH',
|
|
||||||
'VAR_POP',
|
|
||||||
'VAR_SAMP',
|
|
||||||
'VARIANCE',
|
|
||||||
'STDDEV_POP',
|
|
||||||
'STDDEV_SAMP',
|
|
||||||
'STDDEV',
|
|
||||||
'EARLIEST',
|
|
||||||
'LATEST',
|
|
||||||
'ANY_VALUE',
|
|
||||||
];
|
|
||||||
|
|
||||||
const DRUID_DEFAULT_TOTAL_SUB_QUERY_LIMIT = 100000;
|
const DRUID_DEFAULT_TOTAL_SUB_QUERY_LIMIT = 100000;
|
||||||
|
|
||||||
const COMMON_NAME = 'common';
|
const COMMON_NAME = 'common';
|
||||||
|
|
Loading…
Reference in New Issue