blob: 9339adf5a2f1a7ae67c3e5f13b3bd11b22fb43d1 [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 './modules';
import { Button, Intent, Menu, MenuDivider, MenuItem } from '@blueprintjs/core';
import { IconNames } from '@blueprintjs/icons';
import type { Timezone } from 'chronoshift';
import classNames from 'classnames';
import copy from 'copy-to-clipboard';
import type { Column, QueryResult, SqlExpression } from 'druid-query-toolkit';
import { QueryRunner, SqlLiteral, SqlQuery } from 'druid-query-toolkit';
import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Loader, SplitterLayout } from '../../components';
import { ShowValueDialog } from '../../dialogs/show-value-dialog/show-value-dialog';
import type { Capabilities } from '../../helpers';
import { useHashAndLocalStorageHybridState, useQueryManager } from '../../hooks';
import { Api, AppToaster } from '../../singletons';
import { DruidError, LocalStorageKeys, queryDruidSql } from '../../utils';
import {
DroppableContainer,
FilterPane,
HelperTable,
ModulePane,
ResourcePane,
SourcePane,
SourceQueryPane,
} from './components';
import { ExploreHeaderBar } from './components/explore-header-ber/explore-header-bar';
import type { Measure, ModuleState } from './models';
import { ExploreState, ExpressionMeta, QuerySource } from './models';
import { rewriteAggregate, rewriteMaxDataTime } from './query-macros';
import type { Rename } from './utils';
import { QueryLog } from './utils';
import './explore-view.scss';
const QUERY_LOG = new QueryLog();
// ---------------------------------------
const queryRunner = new QueryRunner({
inflateDateStrategy: 'fromSqlTypes',
executor: async ({ payload, isSql, signal }) => {
if (!isSql) throw new Error('should never get here');
QUERY_LOG.addQuery(payload.query);
return Api.instance.post('/druid/v2/sql', payload, { signal });
},
});
async function runSqlQuery(
query: string | SqlQuery,
timezone: Timezone | undefined,
signal?: AbortSignal,
): Promise<QueryResult> {
try {
return await queryRunner.runQuery({
query,
defaultQueryContext: {
sqlStringifyArrays: false,
},
extraQueryContext: timezone ? { sqlTimeZone: timezone.toString() } : undefined,
signal,
});
} catch (e) {
throw new DruidError(e);
}
}
async function introspectSource(source: string, signal?: AbortSignal): Promise<QuerySource> {
const query = SqlQuery.parse(source);
const introspectResult = await runSqlQuery(
QuerySource.makeLimitZeroIntrospectionQuery(query),
undefined,
signal,
);
signal?.throwIfAborted();
const baseIntrospectResult = QuerySource.isSingleStarQuery(query)
? introspectResult
: await runSqlQuery(
QuerySource.makeLimitZeroIntrospectionQuery(QuerySource.stripToBaseSource(query)),
undefined,
signal,
);
return QuerySource.fromIntrospectResult(
query,
baseIntrospectResult.header,
introspectResult.header,
);
}
export interface ExploreViewProps {
capabilities: Capabilities;
}
export const ExploreView = React.memo(function ExploreView({ capabilities }: ExploreViewProps) {
const [shownText, setShownText] = useState<string | undefined>();
const filterPane = useRef<{ filterOn(column: Column): void }>();
const containerRef = useRef<HTMLDivElement | null>(null);
const [exploreState, setExploreState] = useHashAndLocalStorageHybridState<ExploreState>(
'#explore/v/',
LocalStorageKeys.EXPLORE_STATE,
ExploreState.DEFAULT_STATE,
s => ExploreState.fromJS(s),
);
// -------------------------------------------------------
// If no table selected, change to first table if possible
async function initializeWithFirstTable() {
const tables = await queryDruidSql<{ TABLE_NAME: string }>({
query: `SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = 'TABLE' LIMIT 1`,
});
const firstTableName = tables[0].TABLE_NAME;
if (firstTableName) {
setExploreState(exploreState.initToTable(firstTableName));
}
}
useEffect(() => {
if (exploreState.isInitState()) {
void initializeWithFirstTable();
}
});
// -------------------------------------------------------
const { parsedSource } = exploreState;
const [querySourceState] = useQueryManager<string, QuerySource>({
query: parsedSource ? String(parsedSource) : undefined,
processQuery: introspectSource,
});
// -------------------------------------------------------
// If we have a TIMESTAMP column and no filter, then add a filter
useEffect(() => {
const columns = querySourceState.data?.columns;
if (!columns) return;
const newExploreState = exploreState.addInitTimeFilterIfNeeded(columns);
if (exploreState !== newExploreState) {
setExploreState(newExploreState);
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [querySourceState.data]);
// -------------------------------------------------------
const effectiveExploreState = useMemo(
() =>
querySourceState.data
? exploreState.restrictToQuerySource(querySourceState.data)
: exploreState,
[exploreState, querySourceState.data],
);
const { source, parseError, where, showSourceQuery, hideResources, hideHelpers } =
effectiveExploreState;
const timezone = effectiveExploreState.getEffectiveTimezone();
function setModuleState(index: number, moduleState: ModuleState) {
setExploreState(effectiveExploreState.changeModuleState(index, moduleState));
}
function setSource(source: SqlQuery | string, rename?: Rename) {
setExploreState(effectiveExploreState.changeSource(source, rename));
}
function setTable(tableName: string) {
setExploreState(effectiveExploreState.changeToTable(tableName));
}
function setWhere(where: SqlExpression) {
setExploreState(effectiveExploreState.change({ where }));
}
function onShowColumn(column: Column) {
setExploreState(effectiveExploreState.applyShowColumn(column, undefined));
}
function onShowMeasure(measure: Measure) {
setExploreState(effectiveExploreState.applyShowMeasure(measure, undefined));
}
function onShowSourceQuery() {
setExploreState(effectiveExploreState.change({ showSourceQuery: true }));
}
const querySource = querySourceState.getSomeData();
const runSqlPlusQuery = useMemo(() => {
return async (
query: string | SqlQuery | { query: string | SqlQuery; timezone?: Timezone },
signal?: AbortSignal,
) => {
if (!querySource) throw new Error('no querySource');
let parsedQuery: SqlQuery;
let queryTimezone: Timezone;
if (typeof query === 'string' || query instanceof SqlQuery) {
parsedQuery = SqlQuery.parse(query);
queryTimezone = timezone;
} else if (query.query) {
parsedQuery = SqlQuery.parse(query.query);
queryTimezone = query.timezone || timezone;
} else {
throw new TypeError('invalid arguments');
}
const { query: rewrittenQuery, maxTime } = await rewriteMaxDataTime(
rewriteAggregate(parsedQuery, querySource.measures),
);
const results = await runSqlQuery(rewrittenQuery, queryTimezone, signal);
return results
.attachQuery({ query: '' }, parsedQuery)
.changeResultContext({ ...results.resultContext, maxTime });
};
}, [querySource, timezone]);
const selectedLayout = effectiveExploreState.getLayout();
return (
<div className="explore-view">
<ExploreHeaderBar
capabilities={capabilities}
timezone={effectiveExploreState.timezone}
onChangeTimezone={timezone =>
setExploreState(effectiveExploreState.changeTimezone(timezone))
}
moreMenu={
<Menu>
<MenuItem
icon={IconNames.DUPLICATE}
text="Copy last query"
disabled={!QUERY_LOG.length()}
onClick={() => {
copy(QUERY_LOG.getLastQuery()!, { format: 'text/plain' });
AppToaster.show({
message: `Copied query to clipboard`,
intent: Intent.SUCCESS,
});
}}
/>
<MenuItem
icon={IconNames.HISTORY}
text="Show query log"
onClick={() => {
setShownText(QUERY_LOG.getFormatted());
}}
/>
<MenuDivider />
<MenuItem
icon={IconNames.RESET}
text="Reset view state"
intent={Intent.DANGER}
onClick={() => {
localStorage.removeItem(LocalStorageKeys.EXPLORE_STATE);
location.hash = '#explore';
location.reload();
}}
/>
</Menu>
}
layout={selectedLayout}
onChangeLayout={layout => setExploreState(effectiveExploreState.change({ layout }))}
onShowHideSidePanel={alt => {
if (alt) {
setExploreState(effectiveExploreState.change({ hideResources: !hideResources }));
} else {
setExploreState(effectiveExploreState.change({ hideHelpers: !hideHelpers }));
}
}}
/>
<div className="explore-main">
<SplitterLayout
className="source-query-module-splitter"
vertical
primaryIndex={1}
secondaryInitialSize={200}
secondaryMinSize={150}
primaryMinSize={400}
splitterSize={8}
>
{showSourceQuery && (
<SourceQueryPane
source={source}
onSourceChange={setSource}
onClose={() =>
setExploreState(effectiveExploreState.change({ showSourceQuery: false }))
}
/>
)}
{parseError && (
<div className="source-error">
<p>{parseError}</p>
{source === '' && (
<p>
<SourcePane
selectedSource={undefined}
onSelectTable={setTable}
disabled={Boolean(querySource && querySourceState.loading)}
/>
</p>
)}
{!showSourceQuery && (
<p>
<Button text="Show source query" onClick={onShowSourceQuery} />
</p>
)}
</div>
)}
{parsedSource && (
<div className="filter-explore-wrapper">
<div className="filter-pane-container">
{!showSourceQuery && (
<div className="source-pane-container">
<SourcePane
selectedSource={parsedSource}
onSelectTable={setTable}
onShowSourceQuery={onShowSourceQuery}
fill
minimal
disabled={Boolean(querySource && querySourceState.loading)}
/>
</div>
)}
<FilterPane
ref={filterPane}
querySource={querySource}
extraFilter={SqlLiteral.TRUE}
timezone={timezone}
filter={where}
onFilterChange={setWhere}
runSqlQuery={runSqlPlusQuery}
onAddToSourceQueryAsColumn={expression => {
if (!querySource) return;
setExploreState(
effectiveExploreState.changeSource(
querySource.addColumn(
querySource.transformExpressionToBaseColumns(expression),
),
undefined,
),
);
}}
onMoveToSourceQueryAsClause={(expression, changeWhere) => {
if (!querySource) return;
setExploreState(
effectiveExploreState
.change({ where: changeWhere })
.changeSource(
querySource.addWhereClause(
querySource.transformExpressionToBaseColumns(expression),
),
undefined,
),
);
}}
/>
</div>
<SplitterLayout
className="resource-explore-splitter"
primaryIndex={1}
secondaryInitialSize={250}
secondaryMinSize={250}
secondaryMaxSize={500}
splitterSize={8}
>
{!hideResources && (
<div className="resource-pane-cnt">
{!querySource && querySourceState.loading && 'Loading...'}
{querySource && (
<ResourcePane
querySource={querySource}
onQueryChange={setSource}
where={where}
onFilter={c => {
filterPane.current?.filterOn(c);
}}
runSqlQuery={runSqlPlusQuery}
onShowColumn={onShowColumn}
onShowMeasure={onShowMeasure}
/>
)}
</div>
)}
<SplitterLayout
className="module-helpers-splitter"
secondaryInitialSize={250}
secondaryMinSize={250}
splitterSize={8}
>
{querySourceState.error ? (
<div className="query-source-error">{querySourceState.getErrorMessage()}</div>
) : querySource ? (
<div
className={classNames('modules-pane', `layout-${selectedLayout}`)}
ref={containerRef}
>
{effectiveExploreState.getModuleStatesToShow().map((moduleState, i) =>
moduleState ? (
<ModulePane
key={i}
className={`m${i}`}
moduleState={moduleState}
setModuleState={moduleState => setModuleState(i, moduleState)}
onDelete={() => setExploreState(effectiveExploreState.removeModule(i))}
querySource={querySource}
timezone={timezone}
where={where}
setWhere={setWhere}
runSqlQuery={runSqlPlusQuery}
onAddToSourceQueryAsColumn={expression => {
if (!querySource) return;
setExploreState(
effectiveExploreState.changeSource(
querySource.addColumn(
querySource.transformExpressionToBaseColumns(expression),
),
undefined,
),
);
}}
onAddToSourceQueryAsMeasure={measure => {
if (!querySource) return;
setExploreState(
effectiveExploreState.changeSource(
querySource.addMeasure(
measure.changeExpression(
querySource.transformExpressionToBaseColumns(
measure.expression,
),
),
),
undefined,
),
);
}}
/>
) : (
<DroppableContainer
key={i}
className={`no-module-placeholder m${i}`}
onDropColumn={column =>
setExploreState(effectiveExploreState.applyShowColumn(column, i))
}
onDropMeasure={measure =>
setExploreState(effectiveExploreState.applyShowMeasure(measure, i))
}
>
<span>Drag and drop a column or measure here</span>
</DroppableContainer>
),
)}
</div>
) : querySourceState.loading ? (
<Loader
className="query-source-loader"
loadingText="Introspecting query source"
/>
) : (
'should never get here'
)}
{!hideHelpers && (
<DroppableContainer
className="helper-bar"
onDropColumn={c =>
setExploreState(
effectiveExploreState.addHelper(ExpressionMeta.fromColumn(c)),
)
}
>
{querySource && effectiveExploreState.helpers.length > 0 && (
<div className="helper-tables">
{effectiveExploreState.helpers.map((ex, i) => (
<HelperTable
key={i}
querySource={querySource}
where={where}
setWhere={setWhere}
expression={ex}
runSqlQuery={runSqlPlusQuery}
onDelete={() =>
setExploreState(effectiveExploreState.removeHelper(i))
}
/>
))}
</div>
)}
{!effectiveExploreState.helpers.length && (
<div className="no-helper-message"> Drag columns here to see helpers</div>
)}
</DroppableContainer>
)}
</SplitterLayout>
</SplitterLayout>
</div>
)}
</SplitterLayout>
</div>
{shownText && (
<ShowValueDialog
title="Query history"
str={shownText}
onClose={() => {
setShownText(undefined);
}}
/>
)}
</div>
);
});