diff --git a/webapp/packages/core-app/public/icons/icons.svg b/webapp/packages/core-app/public/icons/icons.svg index 43c5864989..e70ea3855a 100644 --- a/webapp/packages/core-app/public/icons/icons.svg +++ b/webapp/packages/core-app/public/icons/icons.svg @@ -183,7 +183,7 @@ - + @@ -192,4 +192,18 @@ + + + + + + + + + + + + + + diff --git a/webapp/packages/core-sdk/src/queries/grid/asyncSqlExplainExecutionPlan.gql b/webapp/packages/core-sdk/src/queries/grid/asyncSqlExplainExecutionPlan.gql new file mode 100644 index 0000000000..1c7ea949f8 --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/grid/asyncSqlExplainExecutionPlan.gql @@ -0,0 +1,24 @@ +mutation asyncSqlExplainExecutionPlan( + $connectionId: ID! + $contextId: ID! + $query: String! + $configuration: Object! +) { + taskInfo: asyncSqlExplainExecutionPlan( + connectionId: $connectionId + contextId: $contextId + query: $query + configuration: $configuration + ) { + id + name + running + status + error { + message + errorCode + stackTrace + } + taskResult + } +} diff --git a/webapp/packages/core-sdk/src/queries/grid/getSqlExecutionPlanResult.gql b/webapp/packages/core-sdk/src/queries/grid/getSqlExecutionPlanResult.gql new file mode 100644 index 0000000000..80d522a914 --- /dev/null +++ b/webapp/packages/core-sdk/src/queries/grid/getSqlExecutionPlanResult.gql @@ -0,0 +1,28 @@ +mutation getSqlExecutionPlanResult( + $taskId: ID! +) { + result: asyncSqlExplainExecutionPlanResult( + taskId: $taskId + ) { + query + nodes { + id + parentId + kind + name + type + condition + description + properties { + id + category + dataType + description + displayName + features + value + order + } + } + } +} diff --git a/webapp/packages/core-sdk/src/sdk.ts b/webapp/packages/core-sdk/src/sdk.ts index 48581c9f42..515598eb00 100644 --- a/webapp/packages/core-sdk/src/sdk.ts +++ b/webapp/packages/core-sdk/src/sdk.ts @@ -806,7 +806,7 @@ export interface SqlCompletionProposal { replacementString: Scalars['String']; replacementOffset: Scalars['Int']; replacementLength: Scalars['Int']; - cursorPosition: Scalars['Int']; + cursorPosition?: Maybe; icon?: Maybe; nodePath?: Maybe; } @@ -1488,6 +1488,20 @@ export interface AsyncSqlExecuteQueryMutation { ); } +export type AsyncSqlExplainExecutionPlanMutationVariables = Exact<{ + connectionId: Scalars['ID']; + contextId: Scalars['ID']; + query: Scalars['String']; + configuration: Scalars['Object']; +}>; + +export interface AsyncSqlExplainExecutionPlanMutation { + taskInfo: ( + Pick + & { error?: Maybe> } + ); +} + export type GetSqlExecuteTaskResultsMutationVariables = Exact<{ taskId: Scalars['ID']; }>; @@ -1508,6 +1522,20 @@ export interface GetSqlExecuteTaskResultsMutation { ); } +export type GetSqlExecutionPlanResultMutationVariables = Exact<{ + taskId: Scalars['ID']; +}>; + +export interface GetSqlExecutionPlanResultMutation { + result: ( + Pick + & { nodes: Array<( + Pick + & { properties: Array> } + )>; } + ); +} + export type UpdateResultsDataMutationVariables = Exact<{ connectionId: Scalars['ID']; contextId: Scalars['ID']; @@ -2383,6 +2411,27 @@ export const AsyncSqlExecuteQueryDocument = ` } } `; +export const AsyncSqlExplainExecutionPlanDocument = ` + mutation asyncSqlExplainExecutionPlan($connectionId: ID!, $contextId: ID!, $query: String!, $configuration: Object!) { + taskInfo: asyncSqlExplainExecutionPlan( + connectionId: $connectionId + contextId: $contextId + query: $query + configuration: $configuration + ) { + id + name + running + status + error { + message + errorCode + stackTrace + } + taskResult + } +} + `; export const GetSqlExecuteTaskResultsDocument = ` mutation getSqlExecuteTaskResults($taskId: ID!) { result: asyncSqlExecuteResults(taskId: $taskId) { @@ -2423,6 +2472,32 @@ export const GetSqlExecuteTaskResultsDocument = ` } } `; +export const GetSqlExecutionPlanResultDocument = ` + mutation getSqlExecutionPlanResult($taskId: ID!) { + result: asyncSqlExplainExecutionPlanResult(taskId: $taskId) { + query + nodes { + id + parentId + kind + name + type + condition + description + properties { + id + category + dataType + description + displayName + features + value + order + } + } + } +} + `; export const UpdateResultsDataDocument = ` mutation updateResultsData($connectionId: ID!, $contextId: ID!, $resultsId: ID!, $sourceRowValues: [Object]!, $values: Object) { result: updateResultsData( @@ -2833,9 +2908,15 @@ export function getSdk(client: GraphQLClient, withWrapper: SdkFunctionWrapper = asyncSqlExecuteQuery(variables: AsyncSqlExecuteQueryMutationVariables): Promise { return withWrapper(() => client.request(AsyncSqlExecuteQueryDocument, variables)); }, + asyncSqlExplainExecutionPlan(variables: AsyncSqlExplainExecutionPlanMutationVariables): Promise { + return withWrapper(() => client.request(AsyncSqlExplainExecutionPlanDocument, variables)); + }, getSqlExecuteTaskResults(variables: GetSqlExecuteTaskResultsMutationVariables): Promise { return withWrapper(() => client.request(GetSqlExecuteTaskResultsDocument, variables)); }, + getSqlExecutionPlanResult(variables: GetSqlExecutionPlanResultMutationVariables): Promise { + return withWrapper(() => client.request(GetSqlExecutionPlanResultDocument, variables)); + }, updateResultsData(variables: UpdateResultsDataMutationVariables): Promise { return withWrapper(() => client.request(UpdateResultsDataDocument, variables)); }, diff --git a/webapp/packages/plugin-sql-editor/src/ISqlEditorTabState.ts b/webapp/packages/plugin-sql-editor/src/ISqlEditorTabState.ts index e642523d65..467e88fe17 100644 --- a/webapp/packages/plugin-sql-editor/src/ISqlEditorTabState.ts +++ b/webapp/packages/plugin-sql-editor/src/ISqlEditorTabState.ts @@ -26,6 +26,7 @@ export interface IResultGroup { modelId: string; sqlQueryParams: ISqlQueryParams; order: number; + nameOrder: number; } export interface ISqlEditorResultTab { @@ -37,8 +38,9 @@ export interface ISqlEditorResultTab { export interface IExecutionPlanTab { tabId: string; - query: string; executionContext: IDatabaseExecutionContext; + query: string; + order: number; options?: Record; } @@ -50,5 +52,5 @@ export interface ISqlEditorTabState { tabs: ISqlEditorResultTab[]; resultGroups: IResultGroup[]; resultTabs: IResultTab[]; - executionPlanTabs: IExecutionPlanTab[]; // TODO: ex-plan store addition info for tab + executionPlanTabs: IExecutionPlanTab[]; } diff --git a/webapp/packages/plugin-sql-editor/src/SqlEditor/SqlEditor.tsx b/webapp/packages/plugin-sql-editor/src/SqlEditor/SqlEditor.tsx index 2e3d8469ac..c4f8b54a55 100644 --- a/webapp/packages/plugin-sql-editor/src/SqlEditor/SqlEditor.tsx +++ b/webapp/packages/plugin-sql-editor/src/SqlEditor/SqlEditor.tsx @@ -11,8 +11,9 @@ import { PropsWithChildren, useEffect, useRef } from 'react'; import styled, { css } from 'reshadow'; import { useTab } from '@cloudbeaver/core-app'; -import { StaticImage, useTab as useBaseTab } from '@cloudbeaver/core-blocks'; +import { Icon, StaticImage, useTab as useBaseTab } from '@cloudbeaver/core-blocks'; import { useController } from '@cloudbeaver/core-di'; +import { useTranslate } from '@cloudbeaver/core-localization'; import type { SQLCodeEditorController } from './SQLCodeEditor/SQLCodeEditorController'; import { SQLCodeEditorLoader } from './SQLCodeEditor/SQLCodeEditorLoader'; @@ -44,7 +45,7 @@ const styles = css` background: none; } - StaticImage { + StaticImage, Icon { padding: 4px; height: 24px; width: 24px; @@ -57,6 +58,7 @@ const styles = css` `; export const SqlEditor = observer(function SqlEditor({ tabId, className }: SqlEditorProps) { + const translate = useTranslate(); const tab = useTab(tabId); const editor = useRef(null); const baseTab = useBaseTab(tabId); @@ -79,8 +81,8 @@ export const SqlEditor = observer(function SqlEditor({ tabId, className }: SqlEd } return styled(styles)( - - + + + => { + if (this.isActionsDisabled) { + return; + } this.sqlQueryService.executeEditorQuery( this.sqlResultTabsService.getTabExecutionContext(this.tab.id), this.tab.handlerState, @@ -46,6 +50,9 @@ export class SqlEditorController implements IInitializableController { }; handleExecuteNewTab = async (): Promise => { + if (this.isActionsDisabled) { + return; + } this.sqlQueryService.executeEditorQuery( this.sqlResultTabsService.getTabExecutionContext(this.tab.id), this.tab.handlerState, @@ -54,7 +61,16 @@ export class SqlEditorController implements IInitializableController { ); }; - // TODO: ex-plan add action for execution plan + handleExecutionPlan = async (): Promise => { + if (this.isActionsDisabled) { + return; + } + this.sqlExecutionPlanService.executeExecutionPlan( + this.sqlResultTabsService.getTabExecutionContext(this.tab.id), + this.tab.handlerState, + await this.getExecutingQuery(), + ); + }; readonly options: EditorConfiguration = { theme: 'material', @@ -71,6 +87,7 @@ export class SqlEditorController implements IInitializableController { // Execute sql script in new tab 'Ctrl-\\': () => { this.handleExecuteNewTab(); }, 'Shift-Ctrl-Enter': () => { this.handleExecuteNewTab(); }, + 'Shift-Ctrl-E': () => { this.handleExecutionPlan(); }, // Autocomplete 'Ctrl-Space': this.showHint.bind(this), // classic for windows, linux @@ -96,7 +113,8 @@ export class SqlEditorController implements IInitializableController { private sqlResultTabsService: SqlResultTabsService, private sqlQueryService: SqlQueryService, private sqlDialectInfoService: SqlDialectInfoService, - private sqlEditorService: SqlEditorService + private sqlEditorService: SqlEditorService, + private sqlExecutionPlanService: SqlExecutionPlanService, ) { makeObservable(this, { dialect: computed, diff --git a/webapp/packages/plugin-sql-editor/src/SqlEditorTabService.ts b/webapp/packages/plugin-sql-editor/src/SqlEditorTabService.ts index 929357cd99..d98183c547 100644 --- a/webapp/packages/plugin-sql-editor/src/SqlEditorTabService.ts +++ b/webapp/packages/plugin-sql-editor/src/SqlEditorTabService.ts @@ -246,13 +246,13 @@ export class SqlEditorTabService extends Bootstrap { } } - private async handleTabClose(tab: ITab) { - this.tabExecutionState.delete(tab.id); - if (tab.handlerState.executionContext) { - await this.sqlEditorService.destroySqlContext(tab.handlerState.executionContext); + private async handleTabClose(editorTab: ITab) { + this.tabExecutionState.delete(editorTab.id); + if (editorTab.handlerState.executionContext) { + await this.sqlEditorService.destroySqlContext(editorTab.handlerState.executionContext); } - for (const resultTab of tab.handlerState.resultTabs) { - await this.sqlResultTabsService.removeResultTab(tab.handlerState, resultTab.tabId); + for (const tab of editorTab.handlerState.tabs) { + await this.sqlResultTabsService.removeResultTab(editorTab.handlerState, tab.id); } } } diff --git a/webapp/packages/plugin-sql-editor/src/SqlResultTabs/ExecutionPlan/ExecutionPlanTree.tsx b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/ExecutionPlan/ExecutionPlanTree.tsx new file mode 100644 index 0000000000..0fa206ac4e --- /dev/null +++ b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/ExecutionPlan/ExecutionPlanTree.tsx @@ -0,0 +1,64 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2021 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { useContext } from 'react'; +import styled, { css } from 'reshadow'; + +import { Table, TableHeader, TableColumnHeader, TableBody } from '@cloudbeaver/core-blocks'; +import { composes, useStyles } from '@cloudbeaver/core-theming'; + +import { ExecutionPlanTreeContext } from './ExecutionPlanTreeContext'; +import { NestedNode } from './NestedNode'; + +const styles = composes( + css` + TableHeader { + composes: theme-background-surface from global; + } + `, + css` + TableHeader { + position: sticky; + top: 0; + z-index: 1; + } + ` +); + +interface Props { + className?: string; +} + +export const ExecutionPlanTree: React.FC = function ExecutionPlanTree({ className }) { + const treeContext = useContext(ExecutionPlanTreeContext); + + if (!treeContext) { + throw new Error('Tree context must be provided'); + } + + return styled(useStyles(styles))( + + + {treeContext.columns.map(property => ( + + {property.displayName} + + ))} + + + {treeContext.nodes.map(node => ( + + ))} + +
+ ); +}; diff --git a/webapp/packages/plugin-sql-editor/src/SqlResultTabs/ExecutionPlan/ExecutionPlanTreeBlock.tsx b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/ExecutionPlan/ExecutionPlanTreeBlock.tsx new file mode 100644 index 0000000000..281754417c --- /dev/null +++ b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/ExecutionPlan/ExecutionPlanTreeBlock.tsx @@ -0,0 +1,64 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2021 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import styled, { css } from 'reshadow'; + +import { Split, Pane, ResizerControls, splitStyles } from '@cloudbeaver/core-blocks'; +import { TextareaNew } from '@cloudbeaver/core-blocks'; +import { composes, useStyles } from '@cloudbeaver/core-theming'; + +import { ExecutionPlanTree } from './ExecutionPlanTree'; + +const styles = composes( + css` + Pane { + composes: theme-background-surface theme-text-on-surface from global; + } + `, + css` + Split { + height: 100%; + flex-direction: column; + } + ResizerControls { + width: 100%; + height: 2px; + } + Pane:last-child { + flex: 0 0 30%; + } + ` +); + +interface Props { + query: string; + className?: string; +} + +export const ExecutionPlanTreeBlock: React.FC = function ExecutionPlanTreeBlock({ className, query }) { + const style = useStyles(styles, splitStyles); + + return styled(style)( + + + + + + + + + + ); +}; diff --git a/webapp/packages/plugin-sql-editor/src/SqlResultTabs/ExecutionPlan/ExecutionPlanTreeContext.ts b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/ExecutionPlan/ExecutionPlanTreeContext.ts new file mode 100644 index 0000000000..e08c9f73bd --- /dev/null +++ b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/ExecutionPlan/ExecutionPlanTreeContext.ts @@ -0,0 +1,25 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2021 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { createContext } from 'react'; + +import type { ObjectPropertyInfo, SqlExecutionPlanNode } from '@cloudbeaver/core-sdk'; + +export interface IExecutionPlanNode extends SqlExecutionPlanNode { + children: IExecutionPlanNode[]; +} + +export interface IExecutionPlanTreeContext { + selectedNodes: Map; + readonly columns: ObjectPropertyInfo[]; + readonly nodes: IExecutionPlanNode[]; + readonly selectedNode: SqlExecutionPlanNode | undefined; + selectNode: (nodeId: string) => void; +} + +export const ExecutionPlanTreeContext = createContext(null); diff --git a/webapp/packages/plugin-sql-editor/src/SqlResultTabs/ExecutionPlan/Expand.tsx b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/ExecutionPlan/Expand.tsx new file mode 100644 index 0000000000..c9fe4856ae --- /dev/null +++ b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/ExecutionPlan/Expand.tsx @@ -0,0 +1,48 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2021 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import styled, { css, use } from 'reshadow'; + +import { Icon } from '@cloudbeaver/core-blocks'; +import { composes, useStyles } from '@cloudbeaver/core-theming'; + +interface Props { + expanded: boolean; + onClick: (event: React.MouseEvent) => void; + className?: string; +} + +const styles = composes( + css` + Icon { + composes: theme-text-on-surface from global; + } +`, + css` + Icon { + cursor: pointer; + opacity: 0.5; + transform: rotate(-90deg); + transition: transform 0.15s ease-in-out; + &[|expanded] { + transform: rotate(0deg); + } + } + `); + +export const Expand: React.FC = function Expand({ expanded, onClick, className }) { + return styled(useStyles(styles))( + + ); +}; diff --git a/webapp/packages/plugin-sql-editor/src/SqlResultTabs/ExecutionPlan/NestedNode.tsx b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/ExecutionPlan/NestedNode.tsx new file mode 100644 index 0000000000..a4c5c2243a --- /dev/null +++ b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/ExecutionPlan/NestedNode.tsx @@ -0,0 +1,92 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2021 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { useCallback, useContext, useState } from 'react'; +import styled, { css } from 'reshadow'; + +import { TableItem, TableColumnValue } from '@cloudbeaver/core-blocks'; +import { useStyles } from '@cloudbeaver/core-theming'; + +import { ExecutionPlanTreeContext, IExecutionPlanNode } from './ExecutionPlanTreeContext'; +import { Expand } from './Expand'; +import { getPropertyValue } from './getPropertyValue'; + +const styles = css` + TableColumnValue { + white-space: pre; + cursor: pointer; + } + expand-container { + width: 24px; + height: 24px; + margin-right: 4px; + display: flex; + } + control { + display: flex; + align-items: center; + } +`; + +interface Props { + node: IExecutionPlanNode; + depth: number; + className?: string; +} + +export const NestedNode: React.FC = function NestedNode({ node, depth, className }) { + const style = useStyles(styles); + const treeContext = useContext(ExecutionPlanTreeContext); + const [expanded, setExpanded] = useState(true); + + if (!treeContext) { + throw new Error('Tree context must be provided'); + } + + const expand = useCallback((event: React.MouseEvent) => { + event.stopPropagation(); + setExpanded(prev => !prev); + }, []); + + const hasChildren = node.children.length > 0; + + return styled(style)( + <> + + {treeContext.columns.map((column, idx) => { + const property = node.properties.find(property => property.id === column.id); + const value = property ? getPropertyValue(property) : ''; + return ( + + + {idx === 0 && ( + <> + {`${'\t'.repeat(depth)}`} + + {hasChildren && ( + + )} + + + )} + {value} + + + ); + })} + + {expanded && node.children.map(child => ( + + ))} + + ); +}; diff --git a/webapp/packages/plugin-sql-editor/src/SqlResultTabs/ExecutionPlan/PropertiesPanel.tsx b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/ExecutionPlan/PropertiesPanel.tsx new file mode 100644 index 0000000000..63566f3434 --- /dev/null +++ b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/ExecutionPlan/PropertiesPanel.tsx @@ -0,0 +1,151 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2021 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { computed } from 'mobx'; +import { observer } from 'mobx-react-lite'; +import { useMemo, useState } from 'react'; +import styled, { css } from 'reshadow'; + +import { + Table, TableHeader, TableColumnHeader, TableBody, + TableItem, + TableColumnValue, + TextPlaceholder, +} from '@cloudbeaver/core-blocks'; +import { useTranslate } from '@cloudbeaver/core-localization'; +import type { ObjectPropertyInfo } from '@cloudbeaver/core-sdk'; +import { composes, useStyles } from '@cloudbeaver/core-theming'; + +import { Expand } from './Expand'; +import { getPropertyValue } from './getPropertyValue'; +import { isVisibleProperty } from './useExecutionPlanTreeState'; + +const styles = composes( + css` + TableColumnHeader { + composes: theme-background-surface from global; + } + `, + css` + TableColumnHeader { + position: sticky; + top: 0; + z-index: 1; + } + TableColumnValue { + white-space: pre; + } + control { + display: flex; + align-items: center; + } + expand-container { + width: 24px; + height: 24px; + margin-right: 4px; + cursor: pointer; + display: flex; + } + ` +); + +interface Props { + properties: ObjectPropertyInfo[]; + className?: string; +} + +export const PropertiesPanel: React.FC = observer(function PropertiesPanel({ properties, className }) { + const style = useStyles(styles); + const translate = useTranslate(); + const [generalExpanded, setGeneralExpanded] = useState(true); + const [detailsExpanded, setDetailsExpanded] = useState(true); + + const { general, details } = useMemo(() => computed(() => { + const general: ObjectPropertyInfo[] = []; + const details: ObjectPropertyInfo[] = []; + + for (const property of properties) { + if (isVisibleProperty(property)) { + general.push(property); + } else { + details.push(property); + } + } + + return { general, details }; + }), [properties]).get(); + + if (!general.length && !details.length) { + return {translate('sql_execution_plan_properties_panel_placeholder')}; + } + + return styled(style)( + + + + {translate('sql_execution_plan_properties_panel_name')} + + + {translate('sql_execution_plan_properties_panel_value')} + + + + + + + + setGeneralExpanded(!generalExpanded)} /> + + {translate('sql_execution_plan_properties_panel_general')} + + + + + {generalExpanded && general.map(property => { + const name = property.displayName; + const value = getPropertyValue(property); + return ( + + + {'\t\t' + name} + + + {value} + + + ); + })} + + + + + setDetailsExpanded(!detailsExpanded)} /> + + {translate('sql_execution_plan_properties_panel_details')} + + + + + {detailsExpanded && details.map(property => { + const name = property.displayName; + const value = getPropertyValue(property); + return ( + + + {'\t\t' + name} + + + {value} + + + ); + })} + +
+ ); +}); diff --git a/webapp/packages/plugin-sql-editor/src/SqlResultTabs/ExecutionPlan/SQLExecutionPlanProcess.ts b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/ExecutionPlan/SQLExecutionPlanProcess.ts new file mode 100644 index 0000000000..14f853053b --- /dev/null +++ b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/ExecutionPlan/SQLExecutionPlanProcess.ts @@ -0,0 +1,157 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2021 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import type { NotificationService } from '@cloudbeaver/core-events'; +import { + AsyncTaskInfo, GraphQLService, ServerInternalError, SqlExecutionPlan +} from '@cloudbeaver/core-sdk'; +import { + CancellablePromise, cancellableTimeout, Deferred, EDeferredState +} from '@cloudbeaver/core-utils'; +import type { IDatabaseExecutionContext } from '@cloudbeaver/plugin-data-viewer'; + +const DELAY_BETWEEN_TRIES = 1000; + +export class SQLExecutionPlanProcess extends Deferred { + private taskId?: string; + private timeout?: CancellablePromise; + private isCancelConfirmed = false; // true when server successfully executed cancelQueryAsync + + constructor( + private graphQLService: GraphQLService, + private notificationService: NotificationService + ) { + super(); + } + + async start( + query: string, + context: IDatabaseExecutionContext, + ): Promise { + // start async task + try { + const taskInfo = await this.explainExecutionPlanAsync(query, context); + await this.applyResult(taskInfo); + this.taskId = taskInfo.id; + if (this.getState() === EDeferredState.CANCELLING) { + await this.cancelAsync(this.taskId); + } + } catch (e) { + this.onError(e); + return; + } + + if (this.isFinished) { + return; + } + + // check async task status until execution finished + while (this.isInProgress) { + if (this.getState() === EDeferredState.CANCELLING) { + await this.cancelAsync(this.taskId); + } + // run the first check immediately because usually the query execution is fast + try { + const taskInfo = await this.getExecutionPlanStatusAsync(this.taskId); + await this.applyResult(taskInfo); + if (this.isFinished) { + return; + } + } catch (e) { + this.notificationService.logException(e, 'Failed to check async task status'); + } + + try { + this.timeout = cancellableTimeout(DELAY_BETWEEN_TRIES); + await this.timeout; + } catch { + } + } + } + + /** + * this method just mark process as cancelling + * to avoid racing conditions the server request will be executed in sinchronious manner in start method + */ + async cancel(): Promise { + if (this.getState() !== EDeferredState.PENDING) { + return; + } + this.toCancelling(); + if (this.timeout) { + this.timeout.cancel(); + } + } + + private async cancelAsync(taskId: string) { + if (this.isCancelConfirmed) { + return; + } + try { + await this.cancelQueryAsync(taskId); + this.isCancelConfirmed = true; + } catch (e) { + if (this.getState() === EDeferredState.CANCELLING) { + this.toPending(); + this.notificationService.logException(e, 'Failed to cancel async task'); + } + } + } + + private async explainExecutionPlanAsync( + query: string, + context: IDatabaseExecutionContext, + ): Promise { + const { taskInfo } = await this.graphQLService.sdk.asyncSqlExplainExecutionPlan({ + connectionId: context.connectionId, + contextId: context.contextId, + query, + configuration: {}, + }); + return taskInfo; + } + + private async getExecutionPlanStatusAsync(taskId: string): Promise { + const { taskInfo } = await this.graphQLService.sdk.getAsyncTaskInfo({ taskId, removeOnFinish: false }); + return taskInfo; + } + + private async cancelQueryAsync(taskId: string): Promise { + await this.graphQLService.sdk.asyncTaskCancel({ taskId }); + } + + private async applyResult(taskInfo: AsyncTaskInfo) { + // task is running + if (taskInfo.running) { + return; + } + // task failed to execute + if (taskInfo.error) { + const serverError = new ServerInternalError(taskInfo.error); + this.onError(serverError, taskInfo.status); + return; + } + try { + // task execution successful + const { result } = await this.graphQLService.sdk.getSqlExecutionPlanResult({ taskId: taskInfo.id }); + this.toResolved({ nodes: result.nodes, query: result.query }); + await this.graphQLService.sdk.getAsyncTaskInfo({ taskId: taskInfo.id, removeOnFinish: true }); + } catch (exception) { + this.onError(new Error('Tasks execution returns no result'), exception); + } + } + + private onError(error: Error, status?: string) { + // if task failed to execute during cancelling - it means it was cancelled successfully + if (this.getState() === EDeferredState.CANCELLING) { + this.toCancelled(error); + } else { + this.toRejected(error); + } + } +} diff --git a/webapp/packages/plugin-sql-editor/src/SqlResultTabs/ExecutionPlan/SqlExecutionPlanPanel.tsx b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/ExecutionPlan/SqlExecutionPlanPanel.tsx new file mode 100644 index 0000000000..f5e2a20247 --- /dev/null +++ b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/ExecutionPlan/SqlExecutionPlanPanel.tsx @@ -0,0 +1,94 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2021 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { observer } from 'mobx-react-lite'; +import { useCallback } from 'react'; +import styled, { css } from 'reshadow'; + +import { Split, Pane, ResizerControls, splitStyles, TextPlaceholder } from '@cloudbeaver/core-blocks'; +import { Loader } from '@cloudbeaver/core-blocks'; +import { useService } from '@cloudbeaver/core-di'; +import { useTranslate } from '@cloudbeaver/core-localization'; +import { composes, useStyles } from '@cloudbeaver/core-theming'; +import { EDeferredState } from '@cloudbeaver/core-utils'; + +import type { IExecutionPlanTab } from '../../ISqlEditorTabState'; +import { ExecutionPlanTreeBlock } from './ExecutionPlanTreeBlock'; +import { ExecutionPlanTreeContext } from './ExecutionPlanTreeContext'; +import { PropertiesPanel } from './PropertiesPanel'; +import { SqlExecutionPlanService } from './SqlExecutionPlanService'; +import { useExecutionPlanTreeState } from './useExecutionPlanTreeState'; + +const styles = composes( + css` + Pane { + composes: theme-background-surface theme-text-on-surface from global; + } + `, + css` + Split { + height: 100%; + } + ` +); + +interface Props { + executionPlanTab: IExecutionPlanTab; +} + +export const SqlExecutionPlanPanel: React.FC = observer(function SqlExecutionPlanPanel({ + executionPlanTab, +}) { + const style = useStyles(styles, splitStyles); + const translate = useTranslate(); + const tabId = executionPlanTab.tabId; + const sqlExecutionPlanService = useService(SqlExecutionPlanService); + const executionPlan = sqlExecutionPlanService.results.get(tabId); + const process = sqlExecutionPlanService.processes.get(tabId); + + const executionPlanState = useExecutionPlanTreeState(executionPlan?.nodes || []); + const loading = process?.isInProgress || !executionPlan; + const canCancel = process?.getState() === EDeferredState.PENDING ?? false; + + const cancelTask = useCallback(() => { + if (process) { + process.cancel(); + } + }, [process]); + + if (loading) { + return ( + + ); + } + + if (!executionPlanState.nodes.length || !executionPlanState.columns.length) { + return {translate('sql_execution_plan_placeholder')}; + } + + return styled(style)( + + + + + + {executionPlanState.selectedNode && ( + <> + + + + + + )} + + + ); +}); diff --git a/webapp/packages/plugin-sql-editor/src/SqlResultTabs/ExecutionPlan/SqlExecutionPlanService.ts b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/ExecutionPlan/SqlExecutionPlanService.ts new file mode 100644 index 0000000000..00ba9ab91e --- /dev/null +++ b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/ExecutionPlan/SqlExecutionPlanService.ts @@ -0,0 +1,141 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2021 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { makeObservable, observable } from 'mobx'; + +import { injectable } from '@cloudbeaver/core-di'; +import { NotificationService } from '@cloudbeaver/core-events'; +import { GraphQLService, SqlExecutionPlan } from '@cloudbeaver/core-sdk'; +import { EDeferredState, uuid } from '@cloudbeaver/core-utils'; + +import type { ISqlEditorTabState } from '../../ISqlEditorTabState'; +import { SqlDialectInfoService } from '../../SqlDialectInfoService'; +import type { SqlExecutionState } from '../../SqlExecutionState'; +import { SQLExecutionPlanProcess } from './SQLExecutionPlanProcess'; + +@injectable() +export class SqlExecutionPlanService { + results: Map; + processes: Map; + + constructor( + private sqlDialectInfoService: SqlDialectInfoService, + private graphQLService: GraphQLService, + private notificationService: NotificationService, + ) { + makeObservable(this, { + results: observable, + processes: observable, + }); + this.results = new Map(); + this.processes = new Map(); + } + + async executeExecutionPlan( + executionState: SqlExecutionState, + editorState: ISqlEditorTabState, + query: string, + ): Promise { + if (!editorState.executionContext) { + console.error('executeEditorQuery executionContext is not provided'); + return; + } + + const tabId = this.createExecutionPlanTab(editorState, query); + const subQuery = await this.getSubQuery(editorState.executionContext.connectionId, query); + const task = new SQLExecutionPlanProcess(this.graphQLService, this.notificationService); + + try { + this.processes.set(tabId, task); + executionState.setExecutionTask(task); + editorState.currentTabId = tabId; + + await task.start( + subQuery, + { + connectionId: editorState.executionContext.connectionId, + contextId: editorState.executionContext.contextId, + } + ); + + const executionPlan = await task.promise; + this.results.set(tabId, executionPlan); + } catch (exception) { + const message = task.getState() === EDeferredState.CANCELLED ? 'Execution plan process has been canceled' : undefined; + this.notificationService.logException(exception, 'Execution plan Error', message); + this.removeTab(editorState, tabId); + } + } + + async getSubQuery(connectionId: string, query: string): Promise { + const dialectInfo = await this.sqlDialectInfoService.loadSqlDialectInfo(connectionId); + + if (dialectInfo?.scriptDelimiter && query.endsWith(dialectInfo?.scriptDelimiter)) { + return query.slice(0, query.length - dialectInfo.scriptDelimiter.length); + } + + return query; + } + + private removeTab(state: ISqlEditorTabState, tabId: string) { + const tab = state.tabs.find(tab => tab.id === tabId); + if (tab) { + state.tabs.splice(state.tabs.indexOf(tab), 1); + } + this.removeExecutionPlanTab(state, tabId); + + if (state.tabs.length > 0) { + state.currentTabId = state.tabs[0].id; + } else { + state.currentTabId = ''; + } + } + + removeExecutionPlanTab(state: ISqlEditorTabState, tabId: string): void { + const executionPlanTab = state.executionPlanTabs.find(executionPlanTab => executionPlanTab.tabId === tabId); + + if (executionPlanTab) { + state.executionPlanTabs.splice(state.executionPlanTabs.indexOf(executionPlanTab), 1); + } + + this.results.delete(tabId); + this.processes.delete(tabId); + } + + private createExecutionPlanTab( + state: ISqlEditorTabState, + query: string, + ) { + if (!state.executionContext) { + throw new Error('ExecutionContext is not provided'); + } + + const id = uuid(); + const order = Math.max(0, ...state.tabs.map(tab => tab.order + 1)); + const nameOrder = Math.max(1, ...state.executionPlanTabs.map(tab => tab.order + 1)); + + state.executionPlanTabs.push({ + tabId: id, + executionContext: { + connectionId: state.executionContext.connectionId, + contextId: state.executionContext.contextId, + }, + query, + order: nameOrder, + }); + + state.tabs.push({ + id, + name: `Execution plan - ${nameOrder}`, + icon: 'execution-plan-tab', + order, + }); + + return id; + } +} diff --git a/webapp/packages/plugin-sql-editor/src/SqlResultTabs/ExecutionPlan/getPropertyValue.ts b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/ExecutionPlan/getPropertyValue.ts new file mode 100644 index 0000000000..9ebf1101ea --- /dev/null +++ b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/ExecutionPlan/getPropertyValue.ts @@ -0,0 +1,23 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2021 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import type { ObjectPropertyInfo } from '@cloudbeaver/core-sdk'; + +export function getPropertyValue(property: ObjectPropertyInfo): string { + const value = property.value; + + if (!value) { + return ''; + } + + if (value !== null && typeof value === 'object') { + return value.displayValue || value.fullName || ''; + } + + return value; +} diff --git a/webapp/packages/plugin-sql-editor/src/SqlResultTabs/ExecutionPlan/useExecutionPlanTreeState.ts b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/ExecutionPlan/useExecutionPlanTreeState.ts new file mode 100644 index 0000000000..c054310fd3 --- /dev/null +++ b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/ExecutionPlan/useExecutionPlanTreeState.ts @@ -0,0 +1,78 @@ +/* + * CloudBeaver - Cloud Database Manager + * Copyright (C) 2020-2021 DBeaver Corp and others + * + * Licensed under the Apache License, Version 2.0. + * you may not use this file except in compliance with the License. + */ + +import { computed, observable } from 'mobx'; + +import { useObjectRef } from '@cloudbeaver/core-blocks'; +import type { ObjectPropertyInfo, SqlExecutionPlanNode } from '@cloudbeaver/core-sdk'; + +import type { IExecutionPlanNode, IExecutionPlanTreeContext } from './ExecutionPlanTreeContext'; + +export function isVisibleProperty(property: ObjectPropertyInfo): boolean { + return property.features.includes('viewable'); +} + +interface IPrivateExecutionPlanTreeState extends IExecutionPlanTreeContext { + list: SqlExecutionPlanNode[]; +} + +export function useExecutionPlanTreeState(list: SqlExecutionPlanNode[]): IExecutionPlanTreeContext { + return useObjectRef({ + list, + selectedNodes: new Map(), + get columns() { + const columns: ObjectPropertyInfo[] = []; + + for (const node of this.list) { + for (const property of node.properties) { + if (property.id && isVisibleProperty(property) && !columns.find(column => column.id === property.id)) { + columns.push(property); + } + } + } + + return columns; + }, + get nodes() { + const map: Map = new Map(); + + const tree: IExecutionPlanNode[] = this.list + .map((node, idx) => { + map.set(node.id, idx); + return ({ ...node, children: [] }); + }); + + const nodes: IExecutionPlanNode[] = []; + + for (const node of tree) { + if (node.parentId) { + const parent = map.get(node.parentId)!; + tree[parent].children.push(node); + } else { + nodes.push(node); + } + } + + return nodes; + }, + get selectedNode(): SqlExecutionPlanNode | undefined { + const nodeId = Array.from(this.selectedNodes.keys())[0]; + return this.list.find(node => node.id === nodeId); + }, + selectNode(nodeId: string) { + this.selectedNodes.clear(); + this.selectedNodes.set(nodeId, true); + }, + }, { list }, { + selectedNodes: observable, + list: observable, + columns: computed, + nodes: computed, + selectedNode: computed, + }, ['selectNode']); +} diff --git a/webapp/packages/plugin-sql-editor/src/SqlResultTabs/SqlQueryService.ts b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/SqlQueryService.ts index 2550cb9f5c..7bfdd3058c 100644 --- a/webapp/packages/plugin-sql-editor/src/SqlResultTabs/SqlQueryService.ts +++ b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/SqlQueryService.ts @@ -131,6 +131,7 @@ export class SqlQueryService { tabState: ISqlEditorTabState, modelId: string ): IResultGroup { + const nameOrder = Math.max(1, ...tabState.resultGroups.map(group => group.nameOrder + 1)); const order = Math.max(0, ...tabState.tabs.map(tab => tab.order + 1)); const groupId = uuid(); @@ -138,6 +139,7 @@ export class SqlQueryService { groupId, modelId, order, + nameOrder, sqlQueryParams: params, }); @@ -208,7 +210,7 @@ export class SqlQueryService { const tab = state.tabs.find(tab => tab.id === resultTab.tabId); if (tab) { - tab.name = this.getTabNameForOrder(group.order, indexInResultSet, model.source.results.length); + tab.name = this.getTabNameForOrder(group.nameOrder, indexInResultSet, model.source.results.length); } } } @@ -229,13 +231,13 @@ export class SqlQueryService { state.tabs.push({ id, - name: this.getTabNameForOrder(group.order, indexInResultSet, results), + name: this.getTabNameForOrder(group.nameOrder, indexInResultSet, results), icon: '/icons/grid.png', order: group.order, }); } private getTabNameForOrder(order: number, indexInResultSet: number, results: number) { - return `Result - ${order + 1}` + (results > 1 ? ` (${indexInResultSet + 1})` : ''); + return `Result - ${order}` + (results > 1 ? ` (${indexInResultSet + 1})` : ''); } } diff --git a/webapp/packages/plugin-sql-editor/src/SqlResultTabs/SqlResultPanel.tsx b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/SqlResultPanel.tsx index a720ed0b00..a10a29f6e0 100644 --- a/webapp/packages/plugin-sql-editor/src/SqlResultTabs/SqlResultPanel.tsx +++ b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/SqlResultPanel.tsx @@ -12,6 +12,7 @@ import styled, { css } from 'reshadow'; import type { ITab } from '@cloudbeaver/core-app'; import type { ISqlEditorTabState } from '../ISqlEditorTabState'; +import { SqlExecutionPlanPanel } from './ExecutionPlan/SqlExecutionPlanPanel'; import { SqlResultSetPanel } from './SqlResultSetPanel'; const style = css` @@ -44,7 +45,7 @@ export const SqlResultPanel = observer(function SqlResultPanel({ tab, id }: SqlR if (executionPlanTab) { return styled(style)( - {/* TODO: ex-plan add panel */} + ); } diff --git a/webapp/packages/plugin-sql-editor/src/SqlResultTabs/SqlResultTabs.tsx b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/SqlResultTabs.tsx index 0638adf8c8..4f2d200f44 100644 --- a/webapp/packages/plugin-sql-editor/src/SqlResultTabs/SqlResultTabs.tsx +++ b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/SqlResultTabs.tsx @@ -28,12 +28,16 @@ const styles = composes( Tab { composes: theme-ripple theme-background-surface theme-text-text-primary-on-light from global; } + TabIcon { + composes: theme-text-surface from global; + } tabs { composes: theme-background-background theme-text-text-primary-on-light from global; } `, css` wrapper { + overflow: auto; display: flex; flex: 1; height: 100%; @@ -70,12 +74,12 @@ export const SqlResultTabs = observer(function SqlDataResult({ tab }: SqlDataRes }) ), [tab] - ); + ).get(); const handleOpen = ({ tabId }: ITabData) => navigatorService.openEditorResult(tab.id, tabId); const handleClose = ({ tabId }: ITabData) => navigatorService.closeEditorResult(tab.id, tabId); - if (!tab.handlerState.resultGroups.length) { + if (!tab.handlerState.tabs.length) { return {translate('sql_editor_placeholder')}; } @@ -85,7 +89,7 @@ export const SqlResultTabs = observer(function SqlDataResult({ tab }: SqlDataRes ( + tabs={orderedTabs.map(result => ( {result.name} @@ -93,7 +97,7 @@ export const SqlResultTabs = observer(function SqlDataResult({ tab }: SqlDataRes ))} style={[styles]} > - {orderedTabs.get().map(result => ( + {orderedTabs.map(result => ( diff --git a/webapp/packages/plugin-sql-editor/src/SqlResultTabs/SqlResultTabsService.ts b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/SqlResultTabsService.ts index 5b4252372d..5067db8fa9 100644 --- a/webapp/packages/plugin-sql-editor/src/SqlResultTabs/SqlResultTabsService.ts +++ b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/SqlResultTabsService.ts @@ -11,6 +11,7 @@ import { MetadataMap } from '@cloudbeaver/core-utils'; import type { ISqlEditorTabState } from '../ISqlEditorTabState'; import { SqlExecutionState } from '../SqlExecutionState'; +import { SqlExecutionPlanService } from './ExecutionPlan/SqlExecutionPlanService'; import { SqlQueryService } from './SqlQueryService'; @injectable() @@ -19,6 +20,7 @@ export class SqlResultTabsService { constructor( private sqlQueryService: SqlQueryService, + private sqlExecutionPlanService: SqlExecutionPlanService ) { this.tabExecutionContext = new MetadataMap(() => new SqlExecutionState()); } @@ -35,7 +37,7 @@ export class SqlResultTabsService { } this.sqlQueryService.removeResultTab(state, tabId); - // TODO: ex-plan add removeResultTab for ex-plan + this.sqlExecutionPlanService.removeExecutionPlanTab(state, tabId); if (state.currentTabId === tabId) { if (state.tabs.length > 0) { diff --git a/webapp/packages/plugin-sql-editor/src/locales/en.ts b/webapp/packages/plugin-sql-editor/src/locales/en.ts index adcf1920df..18b7eb420d 100644 --- a/webapp/packages/plugin-sql-editor/src/locales/en.ts +++ b/webapp/packages/plugin-sql-editor/src/locales/en.ts @@ -1,3 +1,10 @@ export default [ ['sql_editor_placeholder', 'Execute query with Ctrl+Enter to see results'], + ['sql_editor_execution_plan_button_tooltip', 'Explain execution plan (Shift + Ctrl + E)'], + ['sql_execution_plan_placeholder', 'Execution plan is empty'], + ['sql_execution_plan_properties_panel_placeholder', 'There are no items to show'], + ['sql_execution_plan_properties_panel_name', 'Name'], + ['sql_execution_plan_properties_panel_value', 'Value'], + ['sql_execution_plan_properties_panel_general', 'General'], + ['sql_execution_plan_properties_panel_details', 'Details'], ]; diff --git a/webapp/packages/plugin-sql-editor/src/locales/ru.ts b/webapp/packages/plugin-sql-editor/src/locales/ru.ts index 4bed43b963..9d5ae60a9b 100644 --- a/webapp/packages/plugin-sql-editor/src/locales/ru.ts +++ b/webapp/packages/plugin-sql-editor/src/locales/ru.ts @@ -1,3 +1,10 @@ export default [ ['sql_editor_placeholder', 'Нажмите Ctrl+Enter, чтобы выполнить запрос и увидеть результат'], + ['sql_editor_execution_plan_button_tooltip', 'Посмотреть информацию о плане выполнения запроса (Shift + Ctrl + E)'], + ['sql_execution_plan_placeholder', 'План выполнения пуст'], + ['sql_execution_plan_properties_panel_placeholder', 'Информация не доступна'], + ['sql_execution_plan_properties_panel_name', 'Название'], + ['sql_execution_plan_properties_panel_value', 'Значение'], + ['sql_execution_plan_properties_panel_general', 'Общие'], + ['sql_execution_plan_properties_panel_details', 'Дополнительные'], ]; diff --git a/webapp/packages/plugin-sql-editor/src/manifest.ts b/webapp/packages/plugin-sql-editor/src/manifest.ts index 21075adbaf..afbc9e5f61 100644 --- a/webapp/packages/plugin-sql-editor/src/manifest.ts +++ b/webapp/packages/plugin-sql-editor/src/manifest.ts @@ -14,6 +14,7 @@ import { SqlEditorBootstrap } from './SqlEditorBootstrap'; import { SqlEditorNavigatorService } from './SqlEditorNavigatorService'; import { SqlEditorService } from './SqlEditorService'; import { SqlEditorTabService } from './SqlEditorTabService'; +import { SqlExecutionPlanService } from './SqlResultTabs/ExecutionPlan/SqlExecutionPlanService'; import { SqlQueryService } from './SqlResultTabs/SqlQueryService'; import { SqlResultTabsService } from './SqlResultTabs/SqlResultTabsService'; @@ -27,6 +28,7 @@ export const sqlEditorPluginManifest: PluginManifest = { SqlDialectInfoService, SqlEditorTabService, SqlQueryService, + SqlExecutionPlanService, SqlResultTabsService, SqlEditorService, SqlEditorNavigatorService,