Adds toggle button to switch between estimated and actual execution plans (#19629)

* Creates toggle button to switch between estimate and actual query plans

* Renames ID for the toggleActualExecutionPlanModeAction class

* Renames button back to explain

* Creating actual execution plans resembles SSMS

* Adds CTRL/CMD + L shortcut to display estimated execution plans

* Alphabetically organizes telemetry actions

* Adds telemetry when the setting for actual execution plan toggle is used

* Resolves build errors

* Fixes broken unit tests.

* Code review changes

* Removes unnecessary null-coalescing operator.

* Creates placeholder icons for actual execution plans enabled

* Code review changes

* Shortens label names

* Telemetry moved to toggle button

* Telemetry review changes

* Clarifies misleading label
This commit is contained in:
Lewis Sanchez
2022-06-09 16:07:12 -07:00
committed by GitHub
parent b1d8e43569
commit 20d2256709
12 changed files with 159 additions and 17 deletions

View File

@@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><style>.cls-1{fill:#fff;}</style></defs><title>query_plan_inverse_16x16</title><path class="cls-1" d="M13.06,10.49H15v5H10.22v-5h1.89v-2H3.59v2H5.48v5H.75v-5h1.9v-3H7.38v-2H5.48v-5h4.74v5H8.33v2h4.74Zm-8.53,4v-3H1.69v3Zm1.9-13v3H9.27v-3Zm7.58,13v-3H11.17v3Z"/></svg>

After

Width:  |  Height:  |  Size: 365 B

View File

@@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><title>query_plan_16x16</title><path d="M13.16,10.59h1.9v5H10.32v-5h1.89v-2H3.68v2H5.58v5H.84v-5h1.9v-3H7.47v-2H5.58v-5h4.74v5H8.42v2h4.74Zm-8.53,4v-3H1.79v3Zm1.9-13v3H9.37v-3Zm7.58,13v-3H11.26v3Z"/></svg>

After

Width:  |  Height:  |  Size: 298 B

View File

@@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><defs><style>.cls-1{fill:#fff;}</style></defs><title>query_plan_inverse_16x16</title><path class="cls-1" d="M13.06,10.49H15v5H10.22v-5h1.89v-2H3.59v2H5.48v5H.75v-5h1.9v-3H7.38v-2H5.48v-5h4.74v5H8.33v2h4.74Zm-8.53,4v-3H1.69v3Zm1.9-13v3H9.27v-3Zm7.58,13v-3H11.17v3Z"/></svg>

After

Width:  |  Height:  |  Size: 365 B

View File

@@ -0,0 +1 @@
<svg id="Layer_1" data-name="Layer 1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><title>query_plan_16x16</title><path d="M13.16,10.59h1.9v5H10.32v-5h1.89v-2H3.68v2H5.58v5H.84v-5h1.9v-3H7.47v-2H5.58v-5h4.74v5H8.42v2h4.74Zm-8.53,4v-3H1.79v3Zm1.9-13v3H9.37v-3Zm7.58,13v-3H11.26v3Z"/></svg>

After

Width:  |  Height:  |  Size: 298 B

View File

@@ -65,6 +65,14 @@
background-image: url('query-plan.svg');
}
.vs .codicon.disabledActualExecutionPlan {
background-image: url('disabled-actual-execution-plan.svg');
}
.vs .codicon.enabledActualExecutionPlan {
background-image: url('enabled-actual-execution-plan.svg');
}
.vs-dark .codicon.estimatedQueryPlan,
.hc-black .codicon.estimatedQueryPlan,
.vs-dark .codicon.actualQueryPlan,
@@ -72,6 +80,16 @@
background-image: url('query-plan-inverse.svg');
}
.vs-dark .codicon.codicon.disabledActualExecutionPlan,
.hc-black .codicon.codicon.disabledActualExecutionPlan {
background-image: url('disabled-actual-execution-plan-inverse.svg');
}
.vs-dark .codicon.codicon.enabledActualExecutionPlan,
.hc-black .codicon.codicon.enabledActualExecutionPlan {
background-image: url('enabled-actual-execution-plan-inverse.svg');
}
.vs .codicon.createInsight {
background-image: url('create_insight.svg');
}

View File

@@ -55,12 +55,16 @@ export const enum TelemetryError {
}
export const enum TelemetryAction {
adsCommandExecuted = 'adsCommandExecuted',
AddExecutionPlan = 'AddExecutionPlan',
AddServerGroup = 'AddServerGroup',
adsCommandExecuted = 'adsCommandExecuted',
BackupCreated = 'BackupCreated',
ConnectToServer = 'ConnectToServer',
CustomZoom = 'CustomZoom',
BackupCreated = 'BackupCreated',
CancelQuery = 'CancelQuery',
ChartCreated = 'ChartCreated',
Click = 'Click',
CompareExecutionPlan = 'CompareExecutionPlan',
DashboardNavigated = 'DashboardNavigated',
DatabaseConnected = 'DatabaseConnected',
DatabaseDisconnected = 'DatabaseDisconnected',
@@ -71,10 +75,6 @@ export const enum TelemetryAction {
DeleteAgentProxy = 'DeleteAgentProxy',
DeleteConnection = 'DeleteConnection',
DeleteServerGroup = 'DeleteServerGroup',
CancelQuery = 'CancelQuery',
ChartCreated = 'ChartCreated',
Click = 'Click',
CompareExecutionPlan = 'CompareExecutionPlan',
FindNode = 'FindNode',
FirewallRuleRequested = 'FirewallRuleCreated',
GenerateScript = 'GenerateScript',
@@ -97,13 +97,14 @@ export const enum TelemetryAction {
RunQuery = 'RunQuery',
RunQueryStatement = 'RunQueryStatement',
RunQueryString = 'RunQueryString',
SearchCompleted = 'SearchCompleted',
SearchStarted = 'SearchStarted',
ShowChart = 'ShowChart',
StopAgentJob = 'StopAgentJob',
ToggleActualExecutionPlan = 'ToggleActualExecutionPlan',
ViewExecutionPlanComparisonProperties = 'ViewExecutionPlanComparisonProperties',
ViewTopOperations = 'ViewTopOperations',
WizardPagesNavigation = 'WizardPagesNavigation',
SearchStarted = 'SearchStarted',
SearchCompleted = 'SearchCompleted',
ZoomIn = 'ZoomIn',
ZoomOut = 'ZoomOut',
ZoomToFit = 'ZoomToFIt'

View File

@@ -41,6 +41,7 @@ export interface IQueryEditorStateChange {
executingChange?: boolean;
connectingChange?: boolean;
sqlCmdModeChanged?: boolean;
actualExecutionPlanModeChanged?: boolean;
}
export class QueryEditorState extends Disposable {
@@ -49,6 +50,7 @@ export class QueryEditorState extends Disposable {
private _resultsVisible = false;
private _executing = false;
private _connecting = false;
private _isActualExecutionPlanMode = false;
private _onChange = this._register(new Emitter<IQueryEditorStateChange>());
public onChange = this._onChange.event;
@@ -108,12 +110,24 @@ export class QueryEditorState extends Disposable {
return this._isSqlCmdMode;
}
public set isActualExecutionPlanMode(val: boolean) {
if (val !== this._isActualExecutionPlanMode) {
this._isActualExecutionPlanMode = val;
this._onChange.fire({ actualExecutionPlanModeChanged: true });
}
}
public get isActualExecutionPlanMode() {
return this._isActualExecutionPlanMode;
}
public setState(newState: QueryEditorState): void {
this.connected = newState.connected;
this.connecting = newState.connecting;
this.resultsVisible = newState.resultsVisible;
this.executing = newState.executing;
this.isSqlCmdMode = newState.isSqlCmdMode;
this.isActualExecutionPlanMode = newState.isActualExecutionPlanMode;
}
}

View File

@@ -206,6 +206,27 @@ export class CopyQueryWithResultsKeyboardAction extends Action {
}
}
export class EstimatedExecutionPlanKeyboardAction extends Action {
public static ID = 'estimatedExecutionPlanKeyboardAction';
public static LABEL = nls.localize('estimatedExecutionPlanKeyboardAction', "Display Estimated Execution Plan");
constructor(
id: string,
label: string,
@IEditorService private _editorService: IEditorService
) {
super(id, label);
this.enabled = true;
}
public override async run(): Promise<void> {
const editor = this._editorService.activeEditorPane;
if (editor instanceof QueryEditor) {
editor.input.runQuery(editor.getSelection(), { displayEstimatedQueryPlan: true });
}
}
}
export class RunCurrentQueryWithActualPlanKeyboardAction extends Action {
public static ID = 'runCurrentQueryWithActualPlanKeyboardAction';
public static LABEL = nls.localize('runCurrentQueryWithActualPlanKeyboardAction', "Run Current Query with Actual Plan");

View File

@@ -19,7 +19,7 @@ import { QueryResultsInput } from 'sql/workbench/common/editor/query/queryResult
import * as queryContext from 'sql/workbench/contrib/query/common/queryContext';
import {
RunQueryKeyboardAction, RunCurrentQueryKeyboardAction, CancelQueryKeyboardAction, RefreshIntellisenseKeyboardAction, ToggleQueryResultsKeyboardAction,
RunQueryShortcutAction, RunCurrentQueryWithActualPlanKeyboardAction, CopyQueryWithResultsKeyboardAction, FocusOnCurrentQueryKeyboardAction, ParseSyntaxAction, ToggleFocusBetweenQueryEditorAndResultsAction
RunQueryShortcutAction, RunCurrentQueryWithActualPlanKeyboardAction, CopyQueryWithResultsKeyboardAction, FocusOnCurrentQueryKeyboardAction, ParseSyntaxAction, ToggleFocusBetweenQueryEditorAndResultsAction, EstimatedExecutionPlanKeyboardAction
} from 'sql/workbench/contrib/query/browser/keyboardQueryActions';
import * as gridActions from 'sql/workbench/contrib/editData/browser/gridActions';
import * as gridCommands from 'sql/workbench/contrib/editData/browser/gridCommands';
@@ -135,6 +135,16 @@ actionRegistry.registerWorkbenchAction(
RunCurrentQueryKeyboardAction.LABEL
);
actionRegistry.registerWorkbenchAction(
SyncActionDescriptor.create(
EstimatedExecutionPlanKeyboardAction,
EstimatedExecutionPlanKeyboardAction.ID,
EstimatedExecutionPlanKeyboardAction.LABEL,
{ primary: KeyMod.CtrlCmd | KeyCode.KEY_L }
),
EstimatedExecutionPlanKeyboardAction.LABEL
);
actionRegistry.registerWorkbenchAction(
SyncActionDescriptor.create(
RunCurrentQueryWithActualPlanKeyboardAction,

View File

@@ -5,6 +5,7 @@
import 'vs/css!./media/queryActions';
import * as nls from 'vs/nls';
import * as TelemetryKeys from 'sql/platform/telemetry/common/telemetryKeys';
import { Action, IAction, IActionRunner } from 'vs/base/common/actions';
import { IDisposable, Disposable } from 'vs/base/common/lifecycle';
import { IContextViewService } from 'vs/platform/contextview/browser/contextView';
@@ -46,6 +47,7 @@ import { getErrorMessage, onUnexpectedError } from 'vs/base/common/errors';
import { IActionViewItem } from 'vs/base/browser/ui/actionbar/actionbar';
import { gen3Version, sqlDataWarehouse } from 'sql/platform/connection/common/constants';
import { Dropdown } from 'sql/base/browser/ui/editableDropdown/browser/dropdown';
import { IAdsTelemetryService } from 'sql/platform/telemetry/common/telemetry';
/**
* Action class that query-based Actions will extend. This base class automatically handles activating and
@@ -242,9 +244,15 @@ export class RunQueryAction extends QueryTaskbarAction {
if (runCurrentStatement && selection && this.isCursorPosition(selection)) {
editor.input.runQueryStatement(selection);
} else {
// get the selection again this time with trimming
selection = editor.getSelection();
editor.input.runQuery(selection);
if (editor.input.state.isActualExecutionPlanMode) {
selection = editor.getSelection();
editor.input.runQuery(selection, { displayActualQueryPlan: true });
}
else {
// get the selection again this time with trimming
selection = editor.getSelection();
editor.input.runQuery(selection);
}
}
return true;
}
@@ -300,7 +308,7 @@ export class EstimatedQueryPlanAction extends QueryTaskbarAction {
@IConnectionManagementService connectionManagementService: IConnectionManagementService
) {
super(connectionManagementService, editor, EstimatedQueryPlanAction.ID, EstimatedQueryPlanAction.EnabledClass);
this.label = nls.localize('estimatedQueryPlan', "Explain");
this.label = nls.localize('estimatedQueryPlan', "Estimated Plan");
}
public override async run(): Promise<void> {
@@ -323,13 +331,57 @@ export class EstimatedQueryPlanAction extends QueryTaskbarAction {
}
if (this.isConnected(editor)) {
editor.input.runQuery(editor.getSelection(), {
displayEstimatedQueryPlan: true
});
editor.input.runQuery(editor.getSelection(), { displayEstimatedQueryPlan: true });
}
}
}
/**
* Action class that toggles the actual execution plan mode for the editor
*/
export class ToggleActualExecutionPlanModeAction extends QueryTaskbarAction {
public static EnabledClass = 'enabledActualExecutionPlan';
public static ID = 'toggleActualExecutionPlanModeAction';
private _enableActualPlanLabel = nls.localize('enableActualPlanLabel', "Include Actual Plan");
private _disableActualPlanLabel = nls.localize('disableActualPlanLabel', "Exclude Actual Plan");
constructor(
editor: QueryEditor,
private _isActualPlanMode: boolean,
@IQueryManagementService protected readonly queryManagementService: IQueryManagementService,
@IConfigurationService protected readonly configurationService: IConfigurationService,
@IConnectionManagementService connectionManagementService: IConnectionManagementService,
@IAdsTelemetryService private readonly telemetryService: IAdsTelemetryService
) {
super(connectionManagementService, editor, ToggleActualExecutionPlanModeAction.ID, ToggleActualExecutionPlanModeAction.EnabledClass);
this.updateLabel();
}
public get isActualExecutionPlanMode(): boolean {
return this._isActualPlanMode;
}
public set isActualExecutionPlanMode(value: boolean) {
this._isActualPlanMode = value;
this.updateLabel();
}
private updateLabel(): void {
// show option to disable actual plan mode if already enabled
this.label = this.isActualExecutionPlanMode ? this._disableActualPlanLabel : this._enableActualPlanLabel;
}
public override async run(): Promise<void> {
const toActualPlanState = !this.isActualExecutionPlanMode;
this.editor.input.state.isActualExecutionPlanMode = toActualPlanState;
this.telemetryService.createActionEvent(TelemetryKeys.TelemetryView.ExecutionPlan, TelemetryKeys.TelemetryAction.Click, 'ToggleActualExecutionPlan')
.withAdditionalProperties({ actualExecutionPlanMode: this.isActualExecutionPlanMode })
.send();
}
}
export class ActualQueryPlanAction extends QueryTaskbarAction {
public static EnabledClass = 'actualQueryPlan';
public static ID = 'actualQueryPlanAction';
@@ -522,6 +574,7 @@ export class ToggleSqlCmdModeAction extends QueryTaskbarAction {
private _enablesqlcmdLabel = nls.localize('enablesqlcmdLabel', "Enable SQLCMD");
private _disablesqlcmdLabel = nls.localize('disablesqlcmdLabel', "Disable SQLCMD");
constructor(
editor: QueryEditor,
private _isSqlCmdMode: boolean,

View File

@@ -96,6 +96,7 @@ export class QueryEditor extends EditorPane {
private _actualQueryPlanAction: actions.ActualQueryPlanAction;
private _listDatabasesActionItem: actions.ListDatabasesActionItem;
private _toggleSqlcmdMode: actions.ToggleSqlCmdModeAction;
private _toggleActualExecutionPlanMode: actions.ToggleActualExecutionPlanModeAction;
private _exportAsNotebookAction: actions.ExportAsNotebookAction;
constructor(
@@ -205,6 +206,7 @@ export class QueryEditor extends EditorPane {
this._estimatedQueryPlanAction = this.instantiationService.createInstance(actions.EstimatedQueryPlanAction, this);
this._actualQueryPlanAction = this.instantiationService.createInstance(actions.ActualQueryPlanAction, this);
this._toggleSqlcmdMode = this.instantiationService.createInstance(actions.ToggleSqlCmdModeAction, this, false);
this._toggleActualExecutionPlanMode = this.instantiationService.createInstance(actions.ToggleActualExecutionPlanModeAction, this, false);
this._exportAsNotebookAction = this.instantiationService.createInstance(actions.ExportAsNotebookAction, this);
this.setTaskbarContent();
this._register(this.configurationService.onDidChangeConfiguration(e => {
@@ -241,6 +243,10 @@ export class QueryEditor extends EditorPane {
this._toggleSqlcmdMode.isSqlCmdMode = this.input.state.isSqlCmdMode;
}
if (stateChangeEvent.actualExecutionPlanModeChanged) {
this._toggleActualExecutionPlanMode.isActualExecutionPlanMode = this.input.state.isActualExecutionPlanMode;
}
if (stateChangeEvent.connectingChange) {
this._runQueryAction.enabled = !this.input.state.connecting;
this._estimatedQueryPlanAction.enabled = !this.input.state.connecting;
@@ -322,6 +328,7 @@ export class QueryEditor extends EditorPane {
content.push(
{ element: Taskbar.createTaskbarSeparator() },
{ action: this._estimatedQueryPlanAction },
{ action: this._toggleActualExecutionPlanMode },
{ action: this._toggleSqlcmdMode },
{ action: this._exportAsNotebookAction }
);
@@ -367,7 +374,7 @@ export class QueryEditor extends EditorPane {
this.inputDisposables.clear();
this.inputDisposables.add(this.input.state.onChange(c => this.updateState(c)));
this.updateState({ connectingChange: true, connectedChange: true, executingChange: true, resultsVisibleChange: true, sqlCmdModeChanged: true });
this.updateState({ connectingChange: true, connectedChange: true, executingChange: true, resultsVisibleChange: true, sqlCmdModeChanged: true, actualExecutionPlanModeChanged: true });
const editorViewState = this.loadTextEditorViewState(this.input.resource);

View File

@@ -34,6 +34,7 @@ import { IUntitledTextEditorService } from 'vs/workbench/services/untitled/commo
import { TestStorageService } from 'vs/workbench/test/common/workbenchTestServices';
import { IRange } from 'vs/editor/common/core/range';
import { ServerInfo } from 'azdata';
import { QueryEditorState } from 'sql/workbench/common/editor/query/queryEditorInput';
suite('SQL QueryAction Tests', () => {
@@ -41,6 +42,7 @@ suite('SQL QueryAction Tests', () => {
let editor: TypeMoq.Mock<QueryEditor>;
let calledRunQueryOnInput: boolean = undefined;
let testQueryInput: TypeMoq.Mock<UntitledQueryEditorInput>;
let testQueryInputState: TypeMoq.Mock<QueryEditorState>;
let configurationService: TypeMoq.Mock<TestConfigurationService>;
let queryModelService: TypeMoq.Mock<TestQueryModelService>;
let connectionManagementService: TypeMoq.Mock<TestConnectionManagementService>;
@@ -76,9 +78,13 @@ suite('SQL QueryAction Tests', () => {
const service = accessor.untitledTextEditorService;
let fileInput = workbenchinstantiationService.createInstance(UntitledTextEditorInput, service.create({ associatedResource: URI.parse('file://testUri') }));
// Setup a reusable mock QueryInput
testQueryInputState = TypeMoq.Mock.ofType(QueryEditorState, TypeMoq.MockBehavior.Strict);
testQueryInputState.setup(x => x.isActualExecutionPlanMode).returns(() => false);
testQueryInput = TypeMoq.Mock.ofType(UntitledQueryEditorInput, TypeMoq.MockBehavior.Strict, undefined, fileInput, undefined, connectionManagementService.object, queryModelService.object, configurationService.object);
testQueryInput.setup(x => x.uri).returns(() => testUri);
testQueryInput.setup(x => x.runQuery(undefined)).callback(() => { calledRunQueryOnInput = true; });
testQueryInput.setup(x => x.state).returns(() => testQueryInputState.object);
});
test('setClass sets child CSS class correctly', () => {
@@ -184,11 +190,15 @@ suite('SQL QueryAction Tests', () => {
let fileInput = workbenchinstantiationService.createInstance(UntitledTextEditorInput, service.create({ associatedResource: URI.parse('file://testUri') }));
// ... Mock "isSelectionEmpty" in QueryEditor
let queryInputState = TypeMoq.Mock.ofType(QueryEditorState, TypeMoq.MockBehavior.Loose);
queryInputState.setup(x => x.isActualExecutionPlanMode).returns(() => false);
let queryInput = TypeMoq.Mock.ofType(UntitledQueryEditorInput, TypeMoq.MockBehavior.Strict, undefined, fileInput, undefined, connectionManagementService.object, queryModelService.object, configurationService.object);
queryInput.setup(x => x.uri).returns(() => testUri);
queryInput.setup(x => x.runQuery(undefined)).callback(() => {
countCalledRunQuery++;
});
queryInput.setup(x => x.state).returns(() => queryInputState.object);
const contextkeyservice = new MockContextKeyService();
// Setup a reusable mock QueryEditor
@@ -234,12 +244,16 @@ suite('SQL QueryAction Tests', () => {
const service = accessor.untitledTextEditorService;
let fileInput = workbenchinstantiationService.createInstance(UntitledTextEditorInput, service.create({ associatedResource: URI.parse('file://testUri') }));
let queryInputState = TypeMoq.Mock.ofType(QueryEditorState, TypeMoq.MockBehavior.Loose);
queryInputState.setup(x => x.isActualExecutionPlanMode).returns(() => false);
let queryInput = TypeMoq.Mock.ofType(UntitledQueryEditorInput, TypeMoq.MockBehavior.Loose, undefined, fileInput, undefined, connectionManagementService.object, queryModelService.object, configurationService.object);
queryInput.setup(x => x.uri).returns(() => testUri);
queryInput.setup(x => x.runQuery(TypeMoq.It.isAny())).callback((selection: IRange) => {
runQuerySelection = selection;
countCalledRunQuery++;
});
queryInput.setup(x => x.state).returns(() => queryInputState.object);
queryInput.setup(x => x.runQuery(undefined)).callback((selection: IRange) => {
runQuerySelection = selection;
countCalledRunQuery++;