| /* |
| * 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 { Code, Intent, Switch, Tooltip } from '@blueprintjs/core'; |
| import axios from 'axios'; |
| import classNames from 'classnames'; |
| import { QueryResult, QueryRunner, SqlQuery } from 'druid-query-toolkit'; |
| import Hjson from 'hjson'; |
| import memoizeOne from 'memoize-one'; |
| import React, { RefObject } from 'react'; |
| import SplitterLayout from 'react-splitter-layout'; |
| |
| import { Loader } from '../../components'; |
| import { QueryPlanDialog } from '../../dialogs'; |
| import { EditContextDialog } from '../../dialogs/edit-context-dialog/edit-context-dialog'; |
| import { QueryHistoryDialog } from '../../dialogs/query-history-dialog/query-history-dialog'; |
| import { AppToaster } from '../../singletons/toaster'; |
| import { |
| BasicQueryExplanation, |
| ColumnMetadata, |
| downloadFile, |
| DruidError, |
| findEmptyLiteralPosition, |
| getDruidErrorMessage, |
| localStorageGet, |
| localStorageGetJson, |
| LocalStorageKeys, |
| localStorageSet, |
| localStorageSetJson, |
| parseQueryPlan, |
| queryDruidSql, |
| QueryManager, |
| QueryState, |
| RowColumn, |
| SemiJoinQueryExplanation, |
| } from '../../utils'; |
| import { isEmptyContext, QueryContext } from '../../utils/query-context'; |
| import { QueryRecord, QueryRecordUtil } from '../../utils/query-history'; |
| |
| import { ColumnTree } from './column-tree/column-tree'; |
| import { |
| LIVE_QUERY_MODES, |
| LiveQueryMode, |
| LiveQueryModeSelector, |
| } from './live-query-mode-selector/live-query-mode-selector'; |
| import { QueryError } from './query-error/query-error'; |
| 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'; |
| |
| import './query-view.scss'; |
| |
| const parser = memoizeOne((sql: string): SqlQuery | undefined => { |
| try { |
| return SqlQuery.parse(sql); |
| } catch { |
| return; |
| } |
| }); |
| |
| interface QueryWithContext { |
| queryString: string; |
| queryContext: QueryContext; |
| wrapQueryLimit: number | undefined; |
| } |
| |
| export interface QueryViewProps { |
| initQuery: string | undefined; |
| defaultQueryContext?: Record<string, any>; |
| mandatoryQueryContext?: Record<string, any>; |
| } |
| |
| export interface QueryViewState { |
| queryString: string; |
| parsedQuery?: SqlQuery; |
| queryContext: QueryContext; |
| wrapQueryLimit: number | undefined; |
| liveQueryMode: LiveQueryMode; |
| |
| columnMetadataState: QueryState<readonly ColumnMetadata[]>; |
| |
| queryResultState: QueryState<QueryResult, DruidError>; |
| |
| explainDialogOpen: boolean; |
| explainResultState: QueryState<BasicQueryExplanation | SemiJoinQueryExplanation | string>; |
| |
| defaultSchema?: string; |
| defaultTable?: string; |
| |
| editContextDialogOpen: boolean; |
| historyDialogOpen: boolean; |
| queryHistory: readonly QueryRecord[]; |
| } |
| |
| 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) |
| return query.replace(/;+((?:\s*--[^\n]*)?\s*)$/, '$1'); |
| } |
| |
| static isEmptyQuery(query: string): boolean { |
| return query.trim() === ''; |
| } |
| |
| static isExplainQuery(query: string): boolean { |
| return /EXPLAIN\sPLAN\sFOR/i.test(query); |
| } |
| |
| static wrapInExplainIfNeeded(query: string): string { |
| query = QueryView.trimSemicolon(query); |
| if (QueryView.isExplainQuery(query)) return query; |
| return `EXPLAIN PLAN FOR (${query}\n)`; |
| } |
| |
| static isJsonLike(queryString: string): boolean { |
| return queryString.trim().startsWith('{'); |
| } |
| |
| static validRune(queryString: string): boolean { |
| try { |
| Hjson.parse(queryString); |
| return true; |
| } catch { |
| return false; |
| } |
| } |
| |
| static formatStr(s: string | number, format: 'csv' | 'tsv') { |
| if (format === 'csv') { |
| // remove line break, single quote => double quote, handle ',' |
| return `"${String(s) |
| .replace(/(?:\r\n|\r|\n)/g, ' ') |
| .replace(/"/g, '""')}"`; |
| } else { |
| // tsv |
| // remove line break, single quote => double quote, \t => '' |
| return String(s) |
| .replace(/(?:\r\n|\r|\n)/g, ' ') |
| .replace(/\t/g, '') |
| .replace(/"/g, '""'); |
| } |
| } |
| |
| private metadataQueryManager: QueryManager<null, ColumnMetadata[]>; |
| 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; |
| |
| const queryContext = |
| localStorageGetJson(LocalStorageKeys.QUERY_CONTEXT) || props.defaultQueryContext || {}; |
| |
| const possibleQueryHistory = localStorageGetJson(LocalStorageKeys.QUERY_HISTORY); |
| const queryHistory = Array.isArray(possibleQueryHistory) ? possibleQueryHistory : []; |
| |
| const possibleLiveQueryMode = localStorageGetJson(LocalStorageKeys.LIVE_QUERY_MODE); |
| const liveQueryMode = LIVE_QUERY_MODES.includes(possibleLiveQueryMode) |
| ? possibleLiveQueryMode |
| : 'auto'; |
| |
| this.state = { |
| queryString, |
| parsedQuery, |
| queryContext, |
| wrapQueryLimit: 100, |
| liveQueryMode, |
| |
| columnMetadataState: QueryState.INIT, |
| |
| queryResultState: QueryState.INIT, |
| |
| explainDialogOpen: false, |
| explainResultState: QueryState.INIT, |
| |
| editContextDialogOpen: false, |
| historyDialogOpen: false, |
| queryHistory, |
| }; |
| |
| this.metadataQueryManager = new QueryManager({ |
| processQuery: async () => { |
| return await queryDruidSql<ColumnMetadata>({ |
| query: `SELECT TABLE_SCHEMA, TABLE_NAME, COLUMN_NAME, DATA_TYPE FROM INFORMATION_SCHEMA.COLUMNS`, |
| }); |
| }, |
| onStateChange: columnMetadataState => { |
| if (columnMetadataState.error) { |
| AppToaster.show({ |
| message: 'Could not load SQL metadata', |
| intent: Intent.DANGER, |
| }); |
| } |
| this.setState({ |
| columnMetadataState, |
| }); |
| }, |
| }); |
| |
| const queryRunner = new QueryRunner((payload, isSql, cancelToken) => { |
| return axios.post(`/druid/v2${isSql ? '/sql' : ''}`, payload, { cancelToken }); |
| }); |
| |
| this.queryManager = new QueryManager({ |
| processQuery: async ( |
| queryWithContext: QueryWithContext, |
| cancelToken, |
| ): Promise<QueryResult> => { |
| const { queryString, queryContext, wrapQueryLimit } = queryWithContext; |
| |
| 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 { |
| return await queryRunner.runQuery({ |
| query, |
| extraQueryContext: context, |
| cancelToken, |
| }); |
| } catch (e) { |
| throw new DruidError(e); |
| } |
| }, |
| onStateChange: queryResultState => { |
| this.setState({ |
| queryResultState, |
| }); |
| }, |
| }); |
| |
| this.explainQueryManager = new QueryManager({ |
| processQuery: async (queryWithContext: QueryWithContext) => { |
| const { queryString, queryContext, wrapQueryLimit } = queryWithContext; |
| |
| let context: Record<string, any> | undefined; |
| if (!isEmptyContext(queryContext) || wrapQueryLimit || mandatoryQueryContext) { |
| context = Object.assign({}, queryContext, mandatoryQueryContext || {}); |
| if (typeof wrapQueryLimit !== 'undefined') { |
| context.sqlOuterLimit = wrapQueryLimit; |
| } |
| } |
| |
| let result: QueryResult | undefined; |
| try { |
| result = await queryRunner.runQuery({ |
| query: QueryView.wrapInExplainIfNeeded(queryString), |
| extraQueryContext: context, |
| }); |
| } catch (e) { |
| throw new Error(getDruidErrorMessage(e)); |
| } |
| |
| return parseQueryPlan(result!.rows[0][0]); |
| }, |
| onStateChange: explainResultState => { |
| this.setState({ |
| explainResultState, |
| }); |
| }, |
| }); |
| } |
| |
| componentDidMount(): void { |
| const { liveQueryMode, queryString } = this.state; |
| |
| this.metadataQueryManager.runQuery(null); |
| |
| if (liveQueryMode !== 'off' && queryString) { |
| this.handleRun(); |
| } |
| } |
| |
| componentWillUnmount(): void { |
| this.metadataQueryManager.terminate(); |
| this.queryManager.terminate(); |
| this.explainQueryManager.terminate(); |
| } |
| |
| prettyPrintJson(): void { |
| 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 { queryResultState } = this.state; |
| const queryResult = queryResultState.data; |
| if (!queryResult) return; |
| |
| let lines: string[] = []; |
| let separator: string = ''; |
| |
| if (format === 'csv' || format === 'tsv') { |
| separator = format === 'csv' ? ',' : '\t'; |
| 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)), |
| ); |
| } else { |
| // json |
| lines = queryResult.rows.map(r => { |
| const outputObject: Record<string, any> = {}; |
| for (let k = 0; k < r.length; k++) { |
| const newName = queryResult.header[k]; |
| if (newName) { |
| outputObject[newName.name] = r[k]; |
| } |
| } |
| return JSON.stringify(outputObject); |
| }); |
| } |
| |
| const lineBreak = '\n'; |
| downloadFile(lines.join(lineBreak), format, filename); |
| }; |
| |
| renderExplainDialog() { |
| const { explainDialogOpen, explainResultState } = this.state; |
| if (explainResultState.loading || !explainDialogOpen) return; |
| |
| return ( |
| <QueryPlanDialog |
| explainResult={explainResultState.data} |
| explainError={explainResultState.error} |
| setQueryString={this.handleQueryStringChange} |
| onClose={() => this.setState({ explainDialogOpen: false })} |
| /> |
| ); |
| } |
| |
| renderHistoryDialog() { |
| const { historyDialogOpen, queryHistory } = this.state; |
| if (!historyDialogOpen) return; |
| |
| return ( |
| <QueryHistoryDialog |
| queryRecords={queryHistory} |
| setQueryString={(queryString, queryContext) => { |
| this.handleQueryContextChange(queryContext); |
| this.handleQueryStringChange(queryString); |
| }} |
| onClose={() => this.setState({ historyDialogOpen: false })} |
| /> |
| ); |
| } |
| |
| renderEditContextDialog() { |
| const { editContextDialogOpen, queryContext } = this.state; |
| if (!editContextDialogOpen) return; |
| |
| return ( |
| <EditContextDialog |
| onQueryContextChange={this.handleQueryContextChange} |
| onClose={() => { |
| this.setState({ editContextDialogOpen: false }); |
| }} |
| queryContext={queryContext} |
| /> |
| ); |
| } |
| |
| renderLiveQueryModeSelector() { |
| const { liveQueryMode, queryString } = this.state; |
| if (QueryView.isJsonLike(queryString)) return; |
| |
| return ( |
| <LiveQueryModeSelector |
| liveQueryMode={liveQueryMode} |
| onLiveQueryModeChange={this.handleLiveQueryModeChange} |
| autoLiveQueryModeShouldRun={this.autoLiveQueryModeShouldRun()} |
| /> |
| ); |
| } |
| |
| renderWrapQueryLimitSelector() { |
| const { wrapQueryLimit, queryString } = this.state; |
| if (QueryView.isJsonLike(queryString)) return; |
| |
| return ( |
| <Tooltip |
| content="Automatically wrap the query with a limit to protect against queries with very large result sets." |
| hoverOpenDelay={800} |
| > |
| <Switch |
| className="smart-query-limit" |
| checked={Boolean(wrapQueryLimit)} |
| label="Smart query limit" |
| onChange={() => this.handleWrapQueryLimitChange(wrapQueryLimit ? undefined : 100)} |
| /> |
| </Tooltip> |
| ); |
| } |
| |
| renderMainArea() { |
| const { queryString, queryContext, queryResultState, columnMetadataState } = this.state; |
| const emptyQuery = QueryView.isEmptyQuery(queryString); |
| const queryResult = queryResultState.data; |
| |
| let currentSchema: string | undefined; |
| let currentTable: string | undefined; |
| |
| if (queryResult && queryResult.sqlQuery) { |
| currentSchema = queryResult.sqlQuery.getFirstSchema(); |
| currentTable = queryResult.sqlQuery.getFirstTableName(); |
| } else if (localStorageGet(LocalStorageKeys.QUERY_KEY)) { |
| const defaultQueryString = localStorageGet(LocalStorageKeys.QUERY_KEY); |
| |
| const defaultQueryAst: SqlQuery | undefined = defaultQueryString |
| ? parser(defaultQueryString) |
| : undefined; |
| |
| if (defaultQueryAst) { |
| currentSchema = defaultQueryAst.getFirstSchema(); |
| currentTable = defaultQueryAst.getFirstTableName(); |
| } |
| } |
| |
| const runeMode = QueryView.isJsonLike(queryString); |
| return ( |
| <SplitterLayout |
| vertical |
| percentage |
| secondaryInitialSize={ |
| Number(localStorageGet(LocalStorageKeys.QUERY_VIEW_PANE_SIZE) as string) || 60 |
| } |
| primaryMinSize={30} |
| secondaryMinSize={30} |
| onSecondaryPaneSizeChange={this.handleSecondaryPaneSizeChange} |
| > |
| <div className="control-pane"> |
| <QueryInput |
| ref={this.queryInputRef} |
| currentSchema={currentSchema ? currentSchema : 'druid'} |
| currentTable={currentTable} |
| queryString={queryString} |
| onQueryStringChange={this.handleQueryStringChange} |
| runeMode={runeMode} |
| columnMetadata={columnMetadataState.data} |
| /> |
| <div className="control-bar"> |
| <RunButton |
| onEditContext={() => this.setState({ editContextDialogOpen: true })} |
| runeMode={runeMode} |
| queryContext={queryContext} |
| onQueryContextChange={this.handleQueryContextChange} |
| onRun={emptyQuery ? undefined : this.handleRun} |
| onExplain={emptyQuery ? undefined : this.handleExplain} |
| onHistory={() => this.setState({ historyDialogOpen: true })} |
| onPrettier={() => this.prettyPrintJson()} |
| loading={queryResultState.loading} |
| /> |
| {this.renderWrapQueryLimitSelector()} |
| {this.renderLiveQueryModeSelector()} |
| {queryResult && ( |
| <QueryExtraInfo queryResult={queryResult} onDownload={this.handleDownload} /> |
| )} |
| </div> |
| </div> |
| <div className="output-pane"> |
| {queryResult && ( |
| <QueryOutput |
| runeMode={runeMode} |
| queryResult={queryResult} |
| onQueryChange={this.handleQueryChange} |
| /> |
| )} |
| {queryResultState.error && ( |
| <QueryError |
| error={queryResultState.error} |
| moveCursorTo={position => { |
| this.moveToPosition(position); |
| }} |
| /> |
| )} |
| {queryResultState.loading && ( |
| <Loader |
| cancelText="Cancel query" |
| onCancel={() => { |
| this.queryManager.cancelCurrent(); |
| }} |
| /> |
| )} |
| {queryResultState.isInit() && ( |
| <div className="init-state"> |
| <p> |
| Enter a query and click <Code>Run</Code> |
| </p> |
| </div> |
| )} |
| </div> |
| </SplitterLayout> |
| ); |
| } |
| |
| private moveToPosition(position: RowColumn) { |
| const currentQueryInput = this.queryInputRef.current; |
| if (!currentQueryInput) return; |
| currentQueryInput.goToPosition(position); |
| } |
| |
| 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(() => { |
| this.moveToPosition(emptyLiteralPosition); |
| }, 10); |
| } |
| }; |
| |
| private handleQueryStringChange = (queryString: string, preferablyRun?: boolean): void => { |
| const parsedQuery = parser(queryString); |
| const newSate = { queryString, parsedQuery }; |
| this.setState(newSate, preferablyRun ? this.handleRunIfLive : undefined); |
| }; |
| |
| private handleQueryContextChange = (queryContext: QueryContext) => { |
| this.setState({ queryContext }); |
| }; |
| |
| private handleLiveQueryModeChange = (liveQueryMode: LiveQueryMode) => { |
| this.setState({ liveQueryMode }); |
| localStorageSetJson(LocalStorageKeys.LIVE_QUERY_MODE, liveQueryMode); |
| }; |
| |
| private handleWrapQueryLimitChange = (wrapQueryLimit: number | undefined) => { |
| this.setState({ wrapQueryLimit }); |
| }; |
| |
| private handleRun = () => { |
| const { queryString, queryContext, wrapQueryLimit, queryHistory } = this.state; |
| if (QueryView.isJsonLike(queryString) && !QueryView.validRune(queryString)) return; |
| |
| const newQueryHistory = QueryRecordUtil.addQueryToHistory( |
| queryHistory, |
| queryString, |
| queryContext, |
| ); |
| |
| localStorageSetJson(LocalStorageKeys.QUERY_HISTORY, newQueryHistory); |
| localStorageSet(LocalStorageKeys.QUERY_KEY, queryString); |
| localStorageSetJson(LocalStorageKeys.QUERY_CONTEXT, queryContext); |
| |
| this.setState({ queryHistory: newQueryHistory }); |
| this.queryManager.runQuery({ queryString, queryContext, wrapQueryLimit }); |
| }; |
| |
| private autoLiveQueryModeShouldRun() { |
| const { queryResultState } = this.state; |
| return ( |
| !queryResultState.data || |
| !queryResultState.data.queryDuration || |
| queryResultState.data.queryDuration < 10000 |
| ); |
| } |
| |
| private handleRunIfLive = () => { |
| const { liveQueryMode } = this.state; |
| if (liveQueryMode === 'off') return; |
| if (liveQueryMode === 'auto' && !this.autoLiveQueryModeShouldRun()) return; |
| this.handleRun(); |
| }; |
| |
| private handleExplain = () => { |
| const { queryString, queryContext, wrapQueryLimit } = this.state; |
| |
| this.setState({ explainDialogOpen: true }); |
| this.explainQueryManager.runQuery({ |
| queryString, |
| queryContext, |
| wrapQueryLimit, |
| }); |
| }; |
| |
| private handleSecondaryPaneSizeChange = (secondaryPaneSize: number) => { |
| localStorageSet(LocalStorageKeys.QUERY_VIEW_PANE_SIZE, String(secondaryPaneSize)); |
| }; |
| |
| private getParsedQuery = () => { |
| const { parsedQuery } = this.state; |
| return parsedQuery; |
| }; |
| |
| render(): JSX.Element { |
| const { columnMetadataState, parsedQuery } = this.state; |
| |
| let defaultSchema; |
| let defaultTable; |
| if (parsedQuery instanceof SqlQuery) { |
| defaultSchema = parsedQuery.getFirstSchema(); |
| defaultTable = parsedQuery.getFirstTableName(); |
| } |
| |
| return ( |
| <div |
| className={classNames('query-view app-view', { |
| 'hide-column-tree': columnMetadataState.isError(), |
| })} |
| > |
| {!columnMetadataState.isError() && ( |
| <ColumnTree |
| getParsedQuery={this.getParsedQuery} |
| columnMetadataLoading={columnMetadataState.loading} |
| columnMetadata={columnMetadataState.data} |
| onQueryChange={this.handleQueryChange} |
| defaultSchema={defaultSchema ? defaultSchema : 'druid'} |
| defaultTable={defaultTable} |
| /> |
| )} |
| {this.renderMainArea()} |
| {this.renderExplainDialog()} |
| {this.renderHistoryDialog()} |
| {this.renderEditContextDialog()} |
| </div> |
| ); |
| } |
| } |