diff --git a/webapp/packages/core-app/public/icons/icons.svg b/webapp/packages/core-app/public/icons/icons.svg index 912e069d91..fe610185db 100644 --- a/webapp/packages/core-app/public/icons/icons.svg +++ b/webapp/packages/core-app/public/icons/icons.svg @@ -209,13 +209,6 @@ - - - - - - - diff --git a/webapp/packages/plugin-codemirror/src/theme/_base-code-editor.scss b/webapp/packages/plugin-codemirror/src/theme/_base-code-editor.scss index d225a6ddd6..57f9e95242 100644 --- a/webapp/packages/plugin-codemirror/src/theme/_base-code-editor.scss +++ b/webapp/packages/plugin-codemirror/src/theme/_base-code-editor.scss @@ -22,7 +22,7 @@ @include mdc-theme-prop(color, on-surface, false); &:hover:before { - content: ''; + content: ""; position: absolute; left: 0; width: 100%; @@ -30,7 +30,7 @@ z-index: -1; opacity: 0.14; @include mdc-theme-prop(background-color, primary, false); - } + } } .material .CodeMirror-hint-active { @include mdc-theme-prop(background-color, primary, false); @@ -89,7 +89,7 @@ .CodeMirror :global(pre).CodeMirror-line, .CodeMirror :global(pre).CodeMirror-line-like { - padding: 0 8px; + padding: 0 16px; } .cm-s-material .CodeMirror-line::selection, @@ -196,5 +196,19 @@ .cm-s-material .active-query { background-color: $active-query; } + + .cm-s-material .running-query:before { + content: ""; + position: absolute; + left: 2px; + top: 50%; + transform: translateY(-50%); + height: 0; + width: 0; + border-top: 6px solid transparent; + border-bottom: 6px solid transparent; + border-left: 6px solid; + @include mdc-theme-prop(border-left-color, primary, false); + } } } diff --git a/webapp/packages/plugin-data-viewer/src/ContainerDataSource.ts b/webapp/packages/plugin-data-viewer/src/ContainerDataSource.ts index 8f428b42c7..328d91931a 100644 --- a/webapp/packages/plugin-data-viewer/src/ContainerDataSource.ts +++ b/webapp/packages/plugin-data-viewer/src/ContainerDataSource.ts @@ -41,7 +41,8 @@ export class ContainerDataSource extends DatabaseDataSource { diff --git a/webapp/packages/plugin-data-viewer/src/DataViewerTableService.ts b/webapp/packages/plugin-data-viewer/src/DataViewerTableService.ts index adec298996..26565863fa 100644 --- a/webapp/packages/plugin-data-viewer/src/DataViewerTableService.ts +++ b/webapp/packages/plugin-data-viewer/src/DataViewerTableService.ts @@ -11,10 +11,12 @@ import { injectable } from '@cloudbeaver/core-di'; import { NotificationService } from '@cloudbeaver/core-events'; import { GraphQLService } from '@cloudbeaver/core-sdk'; -import { ContainerDataSource } from './ContainerDataSource'; +import { ContainerDataSource, IDataContainerOptions } from './ContainerDataSource'; +import { DatabaseDataModel } from './DatabaseDataModel/DatabaseDataModel'; import type { IDatabaseDataModel } from './DatabaseDataModel/IDatabaseDataModel'; import { DatabaseDataAccessMode } from './DatabaseDataModel/IDatabaseDataSource'; -import { DataModelWrapper } from './TableViewer/DataModelWrapper'; +import type { IDatabaseResultSet } from './DatabaseDataModel/IDatabaseResultSet'; +import { getDefaultRowsCount } from './getDefaultRowsCount'; import { TableViewerStorageService } from './TableViewer/TableViewerStorageService'; @injectable() @@ -37,16 +39,17 @@ export class DataViewerTableService { async removeTableModel(tableId: string): Promise { const model = this.tableViewerStorageService.get(tableId); - if (model instanceof DataModelWrapper) { + + if (model) { await model.dispose(); + this.tableViewerStorageService.remove(tableId); } - this.tableViewerStorageService.remove(tableId); } async create( connectionId: string, containerNodePath = '' - ): Promise { + ): Promise> { const connectionInfo = await this.connectionInfoResource.load(connectionId); const source = new ContainerDataSource( this.graphQLService, @@ -54,17 +57,17 @@ export class DataViewerTableService { this.connectionExecutionContextService ); - const dataModel = this.tableViewerStorageService.create( - source - .setOptions({ - connectionId, - containerNodePath, - constraints: [], - whereFilter: '', - }) - .setSupportedDataFormats(connectionInfo.supportedDataFormats) - ) - .setCountGain() + source + .setOptions({ + connectionId, + containerNodePath, + constraints: [], + whereFilter: '', + }) + .setSupportedDataFormats(connectionInfo.supportedDataFormats); + + const dataModel = this.tableViewerStorageService.add(new DatabaseDataModel(source)) + .setCountGain(getDefaultRowsCount()) .setSlice(0) .setAccess(connectionInfo.readOnly ? DatabaseDataAccessMode.Readonly : DatabaseDataAccessMode.Default); diff --git a/webapp/packages/plugin-data-viewer/src/TableViewer/DataModelWrapper.ts b/webapp/packages/plugin-data-viewer/src/TableViewer/DataModelWrapper.ts deleted file mode 100644 index 7d9386e78f..0000000000 --- a/webapp/packages/plugin-data-viewer/src/TableViewer/DataModelWrapper.ts +++ /dev/null @@ -1,81 +0,0 @@ -/* - * 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 { DatabaseDataModel } from '../DatabaseDataModel/DatabaseDataModel'; -import type { IDatabaseDataOptions } from '../DatabaseDataModel/IDatabaseDataOptions'; -import type { IDatabaseDataResult } from '../DatabaseDataModel/IDatabaseDataResult'; -import type { DatabaseDataAccessMode, IDatabaseDataSource } from '../DatabaseDataModel/IDatabaseDataSource'; - -const fetchingSettings = { - fetchMin: 100, - fetchMax: 5000, - fetchDefault: 200, -}; - -export class DataModelWrapper extends DatabaseDataModel { - constructor( - source: IDatabaseDataSource - ) { - super(source); - - this.countGain = this.getDefaultRowsCount(); - } - - isLoading(): boolean { - return this.source.isLoading(); - } - - async reload(): Promise { - this.setSlice(0, this.countGain); - await this.requestData(); - } - - async refresh(): Promise { - await this.requestData(); - } - - setCountGain(count?: number): this { - const realCount = this.getDefaultRowsCount(count); - this.countGain = realCount; - return this; - } - - setAccess(access: DatabaseDataAccessMode): this { - this.source.setAccess(access); - return this; - } - - setOptions(options: any): this { - this.source.setOptions(options); - return this; - } - - async requestData(): Promise { - await this.source.requestData(); - } - - async requestDataPortion(offset: number, count: number): Promise { - if (!this.isDataAvailable(offset, count)) { - this.source.setSlice(offset, count); - await this.source.requestData(); - } - } - - async dispose(): Promise { - await this.source.dispose(); - } - - private getDefaultRowsCount(count?: number) { - return count !== undefined - ? Math.max( - fetchingSettings.fetchMin, - Math.min(count, fetchingSettings.fetchMax) - ) - : fetchingSettings.fetchDefault; - } -} diff --git a/webapp/packages/plugin-data-viewer/src/TableViewer/TableStatistics.tsx b/webapp/packages/plugin-data-viewer/src/TableViewer/TableStatistics.tsx index 9646a89ca6..8121906d6a 100644 --- a/webapp/packages/plugin-data-viewer/src/TableViewer/TableStatistics.tsx +++ b/webapp/packages/plugin-data-viewer/src/TableViewer/TableStatistics.tsx @@ -7,17 +7,16 @@ */ import { observer } from 'mobx-react-lite'; -import type { PropsWithChildren } from 'react'; import styled, { css } from 'reshadow'; import { useTranslate } from '@cloudbeaver/core-localization'; import type { IDatabaseDataModel } from '../DatabaseDataModel/IDatabaseDataModel'; -type TableGridProps = PropsWithChildren<{ +interface Props { model: IDatabaseDataModel; resultIndex: number; -}>; +} const styles = css` statistics { @@ -30,10 +29,10 @@ const styles = css` } `; -export const TableStatistics = observer(function TableStatistics({ +export const TableStatistics: React.FC = observer(function TableStatistics({ model, resultIndex, -}: TableGridProps) { +}) { const translate = useTranslate(); const source = model.source; const result = model.getResult(resultIndex); diff --git a/webapp/packages/plugin-data-viewer/src/TableViewer/TableViewerStorageService.ts b/webapp/packages/plugin-data-viewer/src/TableViewer/TableViewerStorageService.ts index 12bd106d28..6063a0c430 100644 --- a/webapp/packages/plugin-data-viewer/src/TableViewer/TableViewerStorageService.ts +++ b/webapp/packages/plugin-data-viewer/src/TableViewer/TableViewerStorageService.ts @@ -12,8 +12,6 @@ import { injectable } from '@cloudbeaver/core-di'; import type { IDatabaseDataModel } from '../DatabaseDataModel/IDatabaseDataModel'; import type { IDatabaseDataResult } from '../DatabaseDataModel/IDatabaseDataResult'; -import type { IDatabaseDataSource } from '../DatabaseDataModel/IDatabaseDataSource'; -import { DataModelWrapper } from './DataModelWrapper'; @injectable() export class TableViewerStorageService { @@ -25,7 +23,7 @@ export class TableViewerStorageService { constructor() { makeObservable(this, { - tableModelMap: observable, + tableModelMap: observable.shallow, values: computed, }); } @@ -38,20 +36,11 @@ export class TableViewerStorageService { return this.tableModelMap.get(tableId) as any; } - /** - * @deprecated Use add method instead - */ - create( - source: IDatabaseDataSource - ): DataModelWrapper { - return this.add(new DataModelWrapper(source)) as DataModelWrapper; - } - add( model: IDatabaseDataModel ): IDatabaseDataModel { this.tableModelMap.set(model.id, model); - return this.tableModelMap.get(model.id)!; + return model; } remove(id: string): void { diff --git a/webapp/packages/plugin-data-viewer/src/getDefaultRowsCount.ts b/webapp/packages/plugin-data-viewer/src/getDefaultRowsCount.ts new file mode 100644 index 0000000000..126ffb1c50 --- /dev/null +++ b/webapp/packages/plugin-data-viewer/src/getDefaultRowsCount.ts @@ -0,0 +1,22 @@ +/* + * 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. + */ + +const fetchingSettings = { + fetchMin: 100, + fetchMax: 5000, + fetchDefault: 200, +}; + +export function getDefaultRowsCount(count?: number): number { + return count !== undefined + ? Math.max( + fetchingSettings.fetchMin, + Math.min(count, fetchingSettings.fetchMax) + ) + : fetchingSettings.fetchDefault; +} diff --git a/webapp/packages/plugin-data-viewer/src/index.ts b/webapp/packages/plugin-data-viewer/src/index.ts index eec9fd5e7e..64c0663264 100644 --- a/webapp/packages/plugin-data-viewer/src/index.ts +++ b/webapp/packages/plugin-data-viewer/src/index.ts @@ -41,7 +41,6 @@ export * from './TableViewer/IDataTableActions'; export * from './TableViewer/IDataPresentationActions'; export * from './TableViewer/TableViewer'; export * from './TableViewer/TableFooter/TableFooterMenu/TableFooterMenuService'; -export * from './TableViewer/DataModelWrapper'; export * from './TableViewer/TableDataModel/TableColumn'; export * from './TableViewer/TableDataModel/TableRow'; @@ -49,5 +48,6 @@ export * from './TableViewer/TableDataModel/EditedRow'; export * from './ContainerDataSource'; export * from './DataPresentationService'; +export * from './getDefaultRowsCount'; export * from './useDataModel'; export * from './ValuePanelPresentation/BooleanValue/isBooleanValuePresentationAvaliable'; diff --git a/webapp/packages/plugin-sql-editor/public/icons/sql_exec.svg b/webapp/packages/plugin-sql-editor/public/icons/sql_exec.svg new file mode 100644 index 0000000000..6d0500b0b3 --- /dev/null +++ b/webapp/packages/plugin-sql-editor/public/icons/sql_exec.svg @@ -0,0 +1,3 @@ + + + diff --git a/webapp/packages/plugin-sql-editor/public/icons/sql_exec_new.svg b/webapp/packages/plugin-sql-editor/public/icons/sql_exec_new.svg new file mode 100644 index 0000000000..8b784c1099 --- /dev/null +++ b/webapp/packages/plugin-sql-editor/public/icons/sql_exec_new.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/webapp/packages/plugin-sql-editor/public/icons/sql_execution_plan.svg b/webapp/packages/plugin-sql-editor/public/icons/sql_execution_plan.svg new file mode 100644 index 0000000000..442c9599ae --- /dev/null +++ b/webapp/packages/plugin-sql-editor/public/icons/sql_execution_plan.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/webapp/packages/plugin-sql-editor/public/icons/sql_script_exec.svg b/webapp/packages/plugin-sql-editor/public/icons/sql_script_exec.svg new file mode 100644 index 0000000000..31a4a23f66 --- /dev/null +++ b/webapp/packages/plugin-sql-editor/public/icons/sql_script_exec.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/webapp/packages/plugin-sql-editor/src/ISqlEditorTabState.ts b/webapp/packages/plugin-sql-editor/src/ISqlEditorTabState.ts index f48e269022..2aaf87eb94 100644 --- a/webapp/packages/plugin-sql-editor/src/ISqlEditorTabState.ts +++ b/webapp/packages/plugin-sql-editor/src/ISqlEditorTabState.ts @@ -16,6 +16,11 @@ export interface IResultTab { indexInResultSet: number; } +export interface IStatisticsTab { + tabId: string; + order: number; +} + export interface IResultGroup { groupId: string; modelId: string; @@ -46,5 +51,6 @@ export interface ISqlEditorTabState { tabs: ISqlEditorResultTab[]; resultGroups: IResultGroup[]; resultTabs: IResultTab[]; + statisticsTabs: IStatisticsTab[]; executionPlanTabs: IExecutionPlanTab[]; } diff --git a/webapp/packages/plugin-sql-editor/src/QueryDataSource.ts b/webapp/packages/plugin-sql-editor/src/QueryDataSource.ts index 52240245c5..04713f4b7d 100644 --- a/webapp/packages/plugin-sql-editor/src/QueryDataSource.ts +++ b/webapp/packages/plugin-sql-editor/src/QueryDataSource.ts @@ -41,7 +41,9 @@ export class QueryDataSource extends DatabaseDataSource { diff --git a/webapp/packages/plugin-sql-editor/src/SqlEditor/SqlEditor.tsx b/webapp/packages/plugin-sql-editor/src/SqlEditor/SqlEditor.tsx index ecf0217717..938b53a2dd 100644 --- a/webapp/packages/plugin-sql-editor/src/SqlEditor/SqlEditor.tsx +++ b/webapp/packages/plugin-sql-editor/src/SqlEditor/SqlEditor.tsx @@ -10,7 +10,7 @@ import { observer } from 'mobx-react-lite'; import { useEffect, useRef } from 'react'; import styled, { css } from 'reshadow'; -import { Icon, StaticImage, useTab as useBaseTab } from '@cloudbeaver/core-blocks'; +import { StaticImage, useTab as useBaseTab } from '@cloudbeaver/core-blocks'; import { useController } from '@cloudbeaver/core-di'; import { useTranslate } from '@cloudbeaver/core-localization'; import { composes, useStyles } from '@cloudbeaver/core-theming'; @@ -51,10 +51,10 @@ const styles = composes( cursor: pointer; } - StaticImage, Icon { - padding: 4px; - height: 24px; - width: 24px; + StaticImage { + padding: 8px; + height: 20px; + width: 20px; cursor: pointer; } @@ -94,31 +94,40 @@ export const SqlEditor: React.FC = observer(function SqlEditor( disabled={controller.isActionsDisabled} title={translate('sql_editor_sql_execution_button_tooltip')} onMouseDown={preventFocus} - onClick={controller.handleExecute} + onClick={controller.executeQuery} > - + + {controller.dialect?.supportsExplainExecutionPlan && ( )} { this.handleExecute(); }, + 'Ctrl-Enter': () => { this.executeQuery(); }, // Execute sql script in new tab - 'Ctrl-\\': () => { this.handleExecuteNewTab(); }, - 'Shift-Ctrl-Enter': () => { this.handleExecuteNewTab(); }, - 'Shift-Ctrl-E': () => { this.handleExecutionPlan(); }, + 'Ctrl-\\': () => { this.executeQueryNewTab(); }, + 'Shift-Ctrl-Enter': () => { this.executeQueryNewTab(); }, + 'Shift-Ctrl-E': () => { this.showExecutionPlan(); }, // Autocomplete 'Ctrl-Space': () => { this.showHint(false); }, // classic for windows, linux @@ -99,6 +104,7 @@ export class SqlEditorController implements IInitializableController { editorDidMount: this.handleEditorConfigure.bind(this), }; + private executingScript: boolean; private cursor: Position | null; private tab!: ITab; private editor?: Editor; @@ -113,12 +119,15 @@ export class SqlEditorController implements IInitializableController { this.getHandleAutocomplete = this.getHandleAutocomplete.bind(this); this.getHandleAutocomplete = throttleAsync(this.getHandleAutocomplete, 1000 / 3); this.cursor = null; + this.executingScript = false; - makeObservable(this, { + makeObservable(this, { dialect: computed, isActionsDisabled: computed, value: computed, cursor: observable, + executingScript: observable, + readonly: computed, }); } @@ -131,7 +140,7 @@ export class SqlEditorController implements IInitializableController { }); } - handleExecute = async (): Promise => { + executeQuery = async (): Promise => { if (this.isActionsDisabled) { return; } @@ -142,7 +151,7 @@ export class SqlEditorController implements IInitializableController { ); }; - handleExecuteNewTab = async (): Promise => { + executeQueryNewTab = async (): Promise => { if (this.isActionsDisabled) { return; } @@ -153,16 +162,44 @@ export class SqlEditorController implements IInitializableController { ); }; - handleExecutionPlan = async (): Promise => { + showExecutionPlan = async (): Promise => { if (this.isActionsDisabled || !this.dialect?.supportsExplainExecutionPlan) { return; } - this.sqlExecutionPlanService.executeExecutionPlan( + await this.sqlExecutionPlanService.executeExecutionPlan( this.tab.handlerState, this.getSubQuery().query, ); }; + executeScript = async (): Promise => { + if (this.isActionsDisabled) { + return; + } + + try { + this.executingScript = true; + const queries = this.getQueryList(); + + await this.sqlQueryService.executeQueries( + this.tab.handlerState, + queries.map(query => query.query), + { + onQueryExecutionStart: (query, index) => { + const subQuery = queries[index]; + this.highlightExecutingLine(subQuery.begin, true); + }, + onQueryExecuted: (query, index) => { + const subQuery = queries[index]; + this.highlightExecutingLine(subQuery.begin, false); + }, + } + ); + } finally { + this.executingScript = false; + } + }; + private async showHint(activeSuggest: boolean) { if (!this.editor) { return; @@ -277,6 +314,49 @@ export class SqlEditorController implements IInitializableController { return hints; } + private getQueryList(): ISubQuery[] { + const delimiters: string[] = []; + + if (this.dialect?.scriptDelimiter) { + delimiters.push(this.dialect.scriptDelimiter); + } + + const lines: ISubQuery[] = []; + + let begin = 0; + let subQuery = ''; + + this.editor?.eachLine(handle => { + subQuery += handle.text; + const trimmed = handle.text.trim(); + + if (this.isLineEndedWithDelimiter(delimiters, trimmed)) { + const lineNumber = this.editor?.getLineNumber(handle) || 0; + + let subQueryTrimmed = subQuery.trim(); + + if (this.dialect?.scriptDelimiter && subQueryTrimmed.endsWith(this.dialect?.scriptDelimiter)) { + subQueryTrimmed = subQueryTrimmed + .slice(0, subQueryTrimmed.length - this.dialect.scriptDelimiter.length) + .trim(); + } + + if (subQueryTrimmed.length > 0) { + lines.push({ + query: subQueryTrimmed, + begin, + end: lineNumber - (trimmed.length === 0 ? 1 : 0), + }); + } + + subQuery = ''; + begin = lineNumber + 1; + } + }); + + return lines; + } + private handleEditorConfigure(editor: Editor) { this.editor = editor; @@ -322,17 +402,44 @@ export class SqlEditorController implements IInitializableController { private highlightActiveQuery() { this.editor?.eachLine(line => { - this.editor?.removeLineClass(line, 'background', 'active-query'); + this.highlightActiveLine(line, false); }); - if (this.isActionsDisabled) { + if (!this.dialect) { return; } const query = this.getSubQuery(); - for (let line = query.begin; line <= query.end; line++) { - this.editor?.addLineClass(line, 'background', 'active-query'); + this.highlightActiveLine(query.begin, query.end, true); + } + + private highlightActiveLine(from: LineHandle, state: boolean): void + private highlightActiveLine(from: number, to: number, state: boolean): void + private highlightActiveLine(from: LineHandle | number, to: number | boolean, state?: boolean): void { + if (typeof from === 'object') { + if (state) { + this.editor?.addLineClass(from, 'background', 'active-query'); + } else { + this.editor?.removeLineClass(from, 'background', 'active-query'); + } + return; + } + + for (let line = from; line <= to; line++) { + if (state) { + this.editor?.addLineClass(line, 'background', 'active-query'); + } else { + this.editor?.removeLineClass(line, 'background', 'active-query'); + } + } + } + + private highlightExecutingLine(line: number, state: boolean): void { + if (state) { + this.editor?.addLineClass(line, 'background', 'running-query'); + } else { + this.editor?.removeLineClass(line, 'background', 'running-query'); } } @@ -382,6 +489,10 @@ export class SqlEditorController implements IInitializableController { } private handleQueryChange(editor: Editor, data: EditorChange, query: string) { + if (this.readonly) { + (data as any).cancel(); + return; + } this.tab.handlerState.query = query; } } diff --git a/webapp/packages/plugin-sql-editor/src/SqlEditorTabService.ts b/webapp/packages/plugin-sql-editor/src/SqlEditorTabService.ts index 9607f5a425..6436c6bb2e 100644 --- a/webapp/packages/plugin-sql-editor/src/SqlEditorTabService.ts +++ b/webapp/packages/plugin-sql-editor/src/SqlEditorTabService.ts @@ -95,6 +95,7 @@ export class SqlEditorTabService extends Bootstrap { resultGroups: [], resultTabs: [], executionPlanTabs: [], + statisticsTabs: [], }, }; } @@ -140,6 +141,7 @@ export class SqlEditorTabService extends Bootstrap { || !Array.isArray(tab.handlerState.executionPlanTabs) || !Array.isArray(tab.handlerState.resultGroups) || !Array.isArray(tab.handlerState.resultTabs) + || !Array.isArray(tab.handlerState.statisticsTabs) ) { return false; } @@ -152,11 +154,13 @@ export class SqlEditorTabService extends Bootstrap { } } + // clean old results tab.handlerState.currentTabId = ''; - tab.handlerState.tabs = []; // clean old results - tab.handlerState.resultGroups = []; // clean old results - tab.handlerState.resultTabs = []; // clean old results - tab.handlerState.executionPlanTabs = []; // clean old results + tab.handlerState.tabs = []; + tab.handlerState.resultGroups = []; + tab.handlerState.resultTabs = []; + tab.handlerState.executionPlanTabs = []; + tab.handlerState.statisticsTabs = []; return true; } diff --git a/webapp/packages/plugin-sql-editor/src/SqlResultTabs/SqlQueryResultService.ts b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/SqlQueryResultService.ts index fc3bc0571a..a1894acea5 100644 --- a/webapp/packages/plugin-sql-editor/src/SqlResultTabs/SqlQueryResultService.ts +++ b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/SqlQueryResultService.ts @@ -8,9 +8,10 @@ import { injectable } from '@cloudbeaver/core-di'; import { uuid } from '@cloudbeaver/core-utils'; -import { DataModelWrapper, TableViewerStorageService } from '@cloudbeaver/plugin-data-viewer'; +import { IDatabaseDataModel, IDatabaseResultSet, TableViewerStorageService } from '@cloudbeaver/plugin-data-viewer'; -import type { IResultGroup, ISqlEditorTabState } from '../ISqlEditorTabState'; +import type { IResultGroup, ISqlEditorTabState, IStatisticsTab } from '../ISqlEditorTabState'; +import type { IDataQueryOptions } from '../QueryDataSource'; @injectable() export class SqlQueryResultService { @@ -41,8 +42,9 @@ export class SqlQueryResultService { updateGroupTabs( editorState: ISqlEditorTabState, - model: DataModelWrapper, - groupId: string + model: IDatabaseDataModel, + groupId: string, + selectFirstResult?: boolean ): void { const group = this.getGroup(editorState, groupId); @@ -66,7 +68,9 @@ export class SqlQueryResultService { editorState.resultTabs = editorState.resultTabs .filter(resultTab => !tabsToRemove.includes(resultTab.tabId)); - this.selectFirstResult(editorState, groupId); + if (selectFirstResult) { + this.selectFirstResult(editorState, groupId); + } } createGroup( @@ -89,13 +93,45 @@ export class SqlQueryResultService { return tabState.resultGroups.find(group => group.groupId === groupId)!; } + createStatisticsTab(tabState: ISqlEditorTabState): IStatisticsTab { + const nameOrder = Math.max(1, ...tabState.statisticsTabs.map(group => group.order + 1)); + const order = Math.max(0, ...tabState.tabs.map(tab => tab.order + 1)); + const id = uuid(); + + tabState.statisticsTabs.push({ + tabId: id, + order: nameOrder, + }); + + tabState.tabs.push({ + id, + name: `Statistics - ${nameOrder}`, + icon: 'table-icon', + order, + }); + + return tabState.statisticsTabs.find(tab => tab.tabId === id)!; + } + + removeStatisticsTab(tabState: ISqlEditorTabState, tabId: string): void { + tabState.tabs = tabState.tabs.filter(tab => tab.id !== tabId); + tabState.statisticsTabs = tabState.statisticsTabs.filter(tab => tab.tabId !== tabId); + } + removeGroup(tabState: ISqlEditorTabState, groupId: string): void { + const group = this.getGroup(tabState, groupId); + + if (!group) { + return; + } + const tabsToRemove = tabState.resultTabs.filter(tab => tab.groupId === groupId).map(tab => tab.tabId); tabState.tabs = tabState.tabs.filter(tab => !tabsToRemove.includes(tab.id)); tabState.resultGroups = tabState.resultGroups.filter(group => group.groupId !== groupId); tabState.resultTabs = tabState.resultTabs.filter(tab => tab.groupId !== groupId); tabState.currentTabId = tabState.tabs[0]?.id; + this.tableViewerStorageService.remove(group.modelId); } removeResultTab(state: ISqlEditorTabState, tabId: string): void { @@ -123,35 +159,31 @@ export class SqlQueryResultService { } } - private selectFirstResult(editorState: ISqlEditorTabState, groupId: string, openNew = false) { - const currentTab = editorState.tabs.find(tab => tab.id === editorState.currentTabId); - + private selectFirstResult(editorState: ISqlEditorTabState, groupId: string) { const mainTab = editorState.resultTabs.filter( resultTab => resultTab.groupId === groupId ) .sort((a, b) => a.indexInResultSet - b.indexInResultSet); - if (mainTab.length && (!currentTab || openNew)) { - editorState.currentTabId = mainTab[0].tabId; - } + editorState.currentTabId = mainTab[0].tabId; } private createTabsForGroup( state: ISqlEditorTabState, group: IResultGroup, - model: DataModelWrapper + model: IDatabaseDataModel ) { - this.updateResultTab(model, state, group, 0); + this.updateResultTab(state, group, model, 0); for (let i = 1; i < model.source.results.length; i++) { - this.updateResultTab(model, state, group, i); + this.updateResultTab(state, group, model, i); } } private updateResultTab( - model: DataModelWrapper, state: ISqlEditorTabState, group: IResultGroup, + model: IDatabaseDataModel, indexInResultSet: number, ) { const resultTab = state.resultTabs.find( diff --git a/webapp/packages/plugin-sql-editor/src/SqlResultTabs/SqlQueryService.ts b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/SqlQueryService.ts index 665b91d9fe..98583c6c80 100644 --- a/webapp/packages/plugin-sql-editor/src/SqlResultTabs/SqlQueryService.ts +++ b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/SqlQueryService.ts @@ -6,18 +6,35 @@ * you may not use this file except in compliance with the License. */ +import { makeObservable, observable } from 'mobx'; + import { ConnectionExecutionContextService, ConnectionInfoResource } from '@cloudbeaver/core-connections'; import { injectable } from '@cloudbeaver/core-di'; import { NotificationService } from '@cloudbeaver/core-events'; import { GraphQLService } from '@cloudbeaver/core-sdk'; -import { DatabaseDataAccessMode, DataModelWrapper, TableViewerStorageService } from '@cloudbeaver/plugin-data-viewer'; +import { DatabaseDataAccessMode, DatabaseDataModel, getDefaultRowsCount, IDatabaseDataModel, IDatabaseResultSet, TableViewerStorageService } from '@cloudbeaver/plugin-data-viewer'; -import type { ISqlEditorTabState } from '../ISqlEditorTabState'; -import { QueryDataSource } from '../QueryDataSource'; +import type { IResultGroup, ISqlEditorTabState } from '../ISqlEditorTabState'; +import { IDataQueryOptions, QueryDataSource } from '../QueryDataSource'; import { SqlQueryResultService } from './SqlQueryResultService'; +interface IQueryExecutionOptions { + onQueryExecutionStart?: (query: string, index: number) => void; + onQueryExecuted?: (query: string, index: number) => void; +} + +export interface IQueryExecutionStatistics { + queries: number; + executedQueries: number; + currentQuery: string | null; + updatedRows: number; + executeTime: number; +} + @injectable() export class SqlQueryService { + private statisticsMap: Map; + constructor( private tableViewerStorageService: TableViewerStorageService, private graphQLService: GraphQLService, @@ -25,7 +42,17 @@ export class SqlQueryService { private connectionInfoResource: ConnectionInfoResource, private connectionExecutionContextService: ConnectionExecutionContextService, private sqlQueryResultService: SqlQueryResultService - ) { } + ) { + this.statisticsMap = new Map(); + + makeObservable(this, { + statisticsMap: observable, + }); + } + + getStatistics(tabId: string): IQueryExecutionStatistics | undefined { + return this.statisticsMap.get(tabId); + } async executeEditorQuery( editorState: ISqlEditorTabState, @@ -41,7 +68,7 @@ export class SqlQueryService { } let source: QueryDataSource; - let model: DataModelWrapper; + let model: IDatabaseDataModel; let isNewTabCreated = false; const connectionInfo = await this.connectionInfoResource.load(contextInfo.connectionId); @@ -49,43 +76,39 @@ export class SqlQueryService { if (inNewTab || !tabGroup) { source = new QueryDataSource(this.graphQLService, this.notificationService); - - model = this.tableViewerStorageService.create(source) - .setCountGain() - .setSlice(0); - + model = this.tableViewerStorageService.add(new DatabaseDataModel(source)); tabGroup = this.sqlQueryResultService.createGroup(editorState, model.id, query); isNewTabCreated = true; } else { - tabGroup.query = query; model = this.tableViewerStorageService.get(tabGroup.modelId)!; - source = model.source as any as QueryDataSource; + source = model.source as QueryDataSource; + tabGroup.query = query; } model - .setAccess(connectionInfo.readOnly ? DatabaseDataAccessMode.Readonly : DatabaseDataAccessMode.Default); - - source.setOptions({ - query: query, - connectionId: contextInfo.connectionId, - constraints: [], - whereFilter: '', - }) + .setAccess(connectionInfo.readOnly ? DatabaseDataAccessMode.Readonly : DatabaseDataAccessMode.Default) + .setOptions({ + query: query, + connectionId: contextInfo.connectionId, + constraints: [], + whereFilter: '', + }) + .source .setExecutionContext(executionContext) .setSupportedDataFormats(connectionInfo.supportedDataFormats); - this.sqlQueryResultService.updateGroupTabs(editorState, model, tabGroup.groupId); + this.sqlQueryResultService.updateGroupTabs(editorState, model, tabGroup.groupId, true); try { await model - .setCountGain() + .setCountGain(getDefaultRowsCount()) .setSlice(0) .requestData(); this.sqlQueryResultService.updateGroupTabs(editorState, model, tabGroup.groupId); } catch (exception) { - // remove first panel if execution was cancelled + // remove group if execution was cancelled if (source.currentTask?.cancelled && isNewTabCreated) { this.sqlQueryResultService.removeGroup(editorState, tabGroup.groupId); const message = `Query execution has been canceled${status ? `: ${status}` : ''}`; @@ -93,4 +116,100 @@ export class SqlQueryService { } } } + + async executeQueries( + editorState: ISqlEditorTabState, + queries: string[], + options?: IQueryExecutionOptions + ): Promise { + const contextInfo = editorState.executionContext; + const executionContext = contextInfo && this.connectionExecutionContextService.get(contextInfo.baseId); + + if (!contextInfo || !executionContext) { + console.error('executeEditorQuery executionContext is not provided'); + return; + } + + let source: QueryDataSource; + let model: IDatabaseDataModel; + + const connectionInfo = await this.connectionInfoResource.load(contextInfo.connectionId); + let tabGroup: IResultGroup | undefined; + + const statisticsTab = this.sqlQueryResultService.createStatisticsTab(editorState); + + this.statisticsMap.set(statisticsTab.tabId, { + queries: queries.length, + currentQuery: null, + executedQueries: 0, + executeTime: 0, + updatedRows: 0, + }); + + editorState.currentTabId = statisticsTab.tabId; + + const statistics = this.getStatistics(statisticsTab.tabId)!; + + for (let i = 0; i < queries.length; i++) { + const query = queries[i]; + + statistics.currentQuery = query; + options?.onQueryExecutionStart?.(query, i); + + if (!tabGroup) { + source = new QueryDataSource(this.graphQLService, this.notificationService); + model = this.tableViewerStorageService.add(new DatabaseDataModel(source)); + tabGroup = this.sqlQueryResultService.createGroup(editorState, model.id, query); + } else { + tabGroup.query = query; + model = this.tableViewerStorageService.get(tabGroup.modelId)!; + source = model.source as QueryDataSource; + } + + model + .setAccess(connectionInfo.readOnly ? DatabaseDataAccessMode.Readonly : DatabaseDataAccessMode.Default) + .setOptions({ + query: query, + connectionId: contextInfo.connectionId, + constraints: [], + whereFilter: '', + }) + .source + .setExecutionContext(executionContext) + .setSupportedDataFormats(connectionInfo.supportedDataFormats); + + this.sqlQueryResultService.updateGroupTabs(editorState, model, tabGroup.groupId); + + try { + await model + .setCountGain(getDefaultRowsCount()) + .setSlice(0) + .requestData(); + + statistics.executedQueries++; + statistics.executeTime += source.requestInfo.requestDuration; + for (const result of source.results) { + statistics.updatedRows += result.updateRowCount; + } + + this.sqlQueryResultService.updateGroupTabs(editorState, model, tabGroup.groupId); + + if (source.results.some(result => result.data)) { + tabGroup = undefined; + } else if (i === queries.length - 1) { + this.sqlQueryResultService.removeGroup(editorState, tabGroup.groupId); + } + } catch (exception) { + break; + } finally { + options?.onQueryExecuted?.(query, i); + } + } + statistics.currentQuery = null; + } + + removeStatisticsTab(state: ISqlEditorTabState, tabId: string): void { + this.sqlQueryResultService.removeStatisticsTab(state, tabId); + this.statisticsMap.delete(tabId); + } } diff --git a/webapp/packages/plugin-sql-editor/src/SqlResultTabs/SqlResultPanel.tsx b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/SqlResultPanel.tsx index 830e9eabab..0834a93a85 100644 --- a/webapp/packages/plugin-sql-editor/src/SqlResultTabs/SqlResultPanel.tsx +++ b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/SqlResultPanel.tsx @@ -14,6 +14,7 @@ import type { ITab } from '@cloudbeaver/core-app'; import type { ISqlEditorTabState } from '../ISqlEditorTabState'; import { SqlExecutionPlanPanel } from './ExecutionPlan/SqlExecutionPlanPanel'; import { SqlResultSetPanel } from './SqlResultSetPanel'; +import { SqlScriptStatisticsPanel } from './SqlScriptStatisticsPanel'; const style = css` result-panel { @@ -51,5 +52,15 @@ export const SqlResultPanel = observer(function SqlResultPanel({ tab, id }: SqlR ); } + const statisticsTab = tab.handlerState.statisticsTabs.find(tab => tab.tabId === id); + + if (statisticsTab) { + return styled(style)( + + + + ); + } + return null; }); diff --git a/webapp/packages/plugin-sql-editor/src/SqlResultTabs/SqlResultTabsService.ts b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/SqlResultTabsService.ts index 4fb651708f..ffef3b7540 100644 --- a/webapp/packages/plugin-sql-editor/src/SqlResultTabs/SqlResultTabsService.ts +++ b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/SqlResultTabsService.ts @@ -11,10 +11,12 @@ import { injectable } from '@cloudbeaver/core-di'; import type { ISqlEditorTabState } from '../ISqlEditorTabState'; import { SqlExecutionPlanService } from './ExecutionPlan/SqlExecutionPlanService'; import { SqlQueryResultService } from './SqlQueryResultService'; +import { SqlQueryService } from './SqlQueryService'; @injectable() export class SqlResultTabsService { constructor( + private sqlQueryService: SqlQueryService, private sqlQueryResultService: SqlQueryResultService, private sqlExecutionPlanService: SqlExecutionPlanService, ) { } @@ -26,6 +28,7 @@ export class SqlResultTabsService { state.tabs.splice(state.tabs.indexOf(tab), 1); } + this.sqlQueryService.removeStatisticsTab(state, tabId); this.sqlQueryResultService.removeResultTab(state, tabId); this.sqlExecutionPlanService.removeExecutionPlanTab(state, tabId); diff --git a/webapp/packages/plugin-sql-editor/src/SqlResultTabs/SqlScriptStatisticsPanel.tsx b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/SqlScriptStatisticsPanel.tsx new file mode 100644 index 0000000000..834cf049b8 --- /dev/null +++ b/webapp/packages/plugin-sql-editor/src/SqlResultTabs/SqlScriptStatisticsPanel.tsx @@ -0,0 +1,67 @@ +/* + * 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 styled, { css } from 'reshadow'; + +import { Loader, TextPlaceholder } from '@cloudbeaver/core-blocks'; +import { useService } from '@cloudbeaver/core-di'; +import { useTranslate } from '@cloudbeaver/core-localization'; + +import type { IStatisticsTab } from '../ISqlEditorTabState'; +import { SqlQueryService } from './SqlQueryService'; + +interface IProps { + tab: IStatisticsTab; +} + +const styles = css` + statistics { + composes: theme-typography--caption from global; + flex: 1; + overflow: auto; + box-sizing: border-box; + white-space: pre-wrap; + padding: 16px; + } + box { + display: flex; + align-items: center; + } + icon { + width: 16px; + height: 16px; + margin-right: 8px; + } +`; + +export const SqlScriptStatisticsPanel: React.FC = observer(function SqlScriptStatisticsPanel({ + tab, +}) { + const sqlQueryService = useService(SqlQueryService); + const statistics = sqlQueryService.getStatistics(tab.tabId); + const translate = useTranslate(); + + if (!statistics) { + return {translate('sql_editor_sql_statistics_unavailable')}; + } + + return styled(styles)( + + {translate('sql_editor_sql_execution_queries')} {statistics.executedQueries} / {statistics.queries}
+ {translate('data_viewer_statistics_duration')} {statistics.executeTime} ms
+ {translate('data_viewer_statistics_updated_rows')} {statistics.updatedRows}
+ {statistics.currentQuery && ( + <> +

{translate('sql_editor_sql_execution_executing')}

+
{statistics.currentQuery}
+ + )} +
+ ); +}); diff --git a/webapp/packages/plugin-sql-editor/src/locales/en.ts b/webapp/packages/plugin-sql-editor/src/locales/en.ts index 14b3a990cb..dfa6e9fa27 100644 --- a/webapp/packages/plugin-sql-editor/src/locales/en.ts +++ b/webapp/packages/plugin-sql-editor/src/locales/en.ts @@ -3,6 +3,10 @@ export default [ ['sql_editor_execution_plan_button_tooltip', 'Explain execution plan (Shift + Ctrl + E)'], ['sql_editor_sql_execution_button_tooltip', 'Execute SQL Statement (Ctrl + Enter)'], ['sql_editor_sql_execution_new_tab_button_tooltip', 'Execute SQL Statement in new tab (Ctrl + \\)(Shift + Ctrl + Enter)'], + ['sql_editor_sql_execution_script_button_tooltip', 'Execute SQL Script (Alt + X)'], + ['sql_editor_sql_statistics_unavailable', 'Statistics is not available'], + ['sql_editor_sql_execution_queries', 'Queries:'], + ['sql_editor_sql_execution_executing', 'Executing...'], ['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'], diff --git a/webapp/packages/plugin-sql-editor/src/locales/ru.ts b/webapp/packages/plugin-sql-editor/src/locales/ru.ts index acfcf11a36..5821026d9f 100644 --- a/webapp/packages/plugin-sql-editor/src/locales/ru.ts +++ b/webapp/packages/plugin-sql-editor/src/locales/ru.ts @@ -3,6 +3,10 @@ export default [ ['sql_editor_execution_plan_button_tooltip', 'Посмотреть информацию о плане выполнения запроса (Shift + Ctrl + E)'], ['sql_editor_sql_execution_button_tooltip', 'Выполнить SQL Выражение (Ctrl + Enter)'], ['sql_editor_sql_execution_new_tab_button_tooltip', 'Выполнить SQL Выражение в новой вкладке (Ctrl + \\)(Shift + Ctrl + Enter)'], + ['sql_editor_sql_execution_script_button_tooltip', 'Исполнить SQL Скрипт (Alt + X)'], + ['sql_editor_sql_statistics_unavailable', 'Статистика не доступна'], + ['sql_editor_sql_execution_queries', 'Запросы:'], + ['sql_editor_sql_execution_executing', 'Выполняется...'], ['sql_execution_plan_placeholder', 'План выполнения пуст'], ['sql_execution_plan_properties_panel_placeholder', 'Информация не доступна'], ['sql_execution_plan_properties_panel_name', 'Название'],