diff --git a/src/sql/base/browser/ui/table/formatters.ts b/src/sql/base/browser/ui/table/formatters.ts index 07638f9160..44d9286c63 100644 --- a/src/sql/base/browser/ui/table/formatters.ts +++ b/src/sql/base/browser/ui/table/formatters.ts @@ -111,6 +111,17 @@ export function textFormatter(row: number | undefined, cell: any | undefined, va valueToDisplay = escape(valueToDisplay.length > 250 ? valueToDisplay.slice(0, 250) + '...' : valueToDisplay); titleValue = valueToDisplay; } + else if (value && value.title) { + if (value.title) { + valueToDisplay = value.title; + + if (value.style) { + cellStyle = value.style; + } + } + valueToDisplay = escape(valueToDisplay.length > 250 ? valueToDisplay.slice(0, 250) + '...' : valueToDisplay); + titleValue = valueToDisplay; + } return `${valueToDisplay}`; } @@ -118,7 +129,7 @@ export function textFormatter(row: number | undefined, cell: any | undefined, va export function iconCssFormatter(row: number | undefined, cell: any | undefined, value: any, columnDef: any | undefined, dataContext: any | undefined): string { if (isCssIconCellValue(value)) { - return `
`; + return `
`; } return textFormatter(row, cell, value, columnDef, dataContext); } diff --git a/src/sql/base/common/numbers.ts b/src/sql/base/common/numbers.ts new file mode 100644 index 0000000000..4607bf2319 --- /dev/null +++ b/src/sql/base/common/numbers.ts @@ -0,0 +1,8 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export function isNumber(text: string): boolean { + return !isNaN(parseInt(text)) && !isNaN(parseFloat(text)); +} diff --git a/src/sql/workbench/contrib/executionPlan/browser/executionPlanComparisonPropertiesView.ts b/src/sql/workbench/contrib/executionPlan/browser/executionPlanComparisonPropertiesView.ts index 9799751c0c..7343bfdb61 100644 --- a/src/sql/workbench/contrib/executionPlan/browser/executionPlanComparisonPropertiesView.ts +++ b/src/sql/workbench/contrib/executionPlan/browser/executionPlanComparisonPropertiesView.ts @@ -5,18 +5,20 @@ import { ExecutionPlanPropertiesViewBase, PropertiesSortType } from 'sql/workbench/contrib/executionPlan/browser/executionPlanPropertiesViewBase'; import { IThemeService } from 'vs/platform/theme/common/themeService'; +import { isNumber } from 'sql/base/common/numbers'; import * as azdata from 'azdata'; import { localize } from 'vs/nls'; -import { textFormatter } from 'sql/base/browser/ui/table/formatters'; +import { iconCssFormatter, textFormatter } from 'sql/base/browser/ui/table/formatters'; import { isString } from 'vs/base/common/types'; import { removeLineBreaks } from 'sql/base/common/strings'; import * as DOM from 'vs/base/browser/dom'; import { InternalExecutionPlanElement } from 'sql/workbench/contrib/executionPlan/browser/azdataGraphView'; -import { executionPlanComparisonPropertiesDifferent, executionPlanComparisonPropertiesUpArrow, executionPlanComparisonPropertiesDownArrow } from 'sql/workbench/contrib/executionPlan/browser/constants'; +import { executionPlanComparisonPropertiesDifferent } from 'sql/workbench/contrib/executionPlan/browser/constants'; import * as sqlExtHostType from 'sql/workbench/api/common/sqlExtHostTypes'; -import { TextWithIconColumn } from 'sql/base/browser/ui/table/plugins/textWithIconColumn'; import { IContextMenuService, IContextViewService } from 'vs/platform/contextview/browser/contextView'; import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation'; +import { ITextResourcePropertiesService } from 'vs/editor/common/services/textResourceConfigurationService'; +import { Codicon } from 'vs/base/common/codicons'; export enum ExecutionPlanCompareOrientation { Horizontal = 'horizontal', @@ -39,6 +41,26 @@ function getRightOperationLabel(target: string): string { return localize('nodePropertyViewRightOperation', 'Right operation: {0}', target); } +function getTopPlanIsGreaterThanBottomPlanSummaryTextTemplate(rowName: string): string { + return localize('nodePropertyViewTopPlanGreaterThanBottomPlan', '{0} is greater for the top plan than it is for the bottom plan.', rowName); +} + +function getBottomPlanIsGreaterThanTopPlanSummaryTextTemplate(rowName: string): string { + return localize('nodePropertyViewBottomPlanGreaterThanTopPlan', '{0} is greater for the bottom plan than it is for the top plan.', rowName); +} + +function getLeftPlanIsGreaterThanRightPlanSummaryTextTemplate(rowName: string): string { + return localize('nodePropertyViewLeftPlanGreaterThanRightPlan', '{0} is greater for the left plan than it is for the right plan.', rowName); +} + +function getRightPlanIsGreaterThanLeftPlanSummaryTextTemplate(rowName: string): string { + return localize('nodePropertyViewRightPlanGreaterThanLeftPlan', '{0} is greater for the right plan than it is for the left plan.', rowName); +} + +const notEqualTitle = localize('nodePropertyViewNameNotEqualTitle', 'Not equal to'); +const lessThanTitle = localize('nodePropertyViewNameLessThanTitle', 'Less than'); +const greaterThanTitle = localize('nodePropertyViewNameGreaterThanTitle', 'Greater than'); +const equivalentPropertiesRowHeader = localize('nodePropertyViewNameEquivalentPropertiesRowHeader', 'Equivalent Properties'); const topTitleColumnHeader = localize('nodePropertyViewNameValueColumnTopHeader', "Value (Top Plan)"); const leftTitleColumnHeader = localize('nodePropertyViewNameValueColumnLeftHeader', "Value (Left Plan)"); const rightTitleColumnHeader = localize('nodePropertyViewNameValueColumnRightHeader', "Value (Right Plan)"); @@ -46,6 +68,7 @@ const bottomTitleColumnHeader = localize('nodePropertyViewNameValueColumnBottomH export class ExecutionPlanComparisonPropertiesView extends ExecutionPlanPropertiesViewBase { private _model: ExecutionPlanComparisonPropertiesViewModel; + private _summaryTextContainer: HTMLElement; private _primaryContainer: HTMLElement; private _secondaryContainer: HTMLElement; private _orientation: ExecutionPlanCompareOrientation = ExecutionPlanCompareOrientation.Horizontal; @@ -57,19 +80,34 @@ export class ExecutionPlanComparisonPropertiesView extends ExecutionPlanProperti @IThemeService themeService: IThemeService, @IInstantiationService instantiationService: IInstantiationService, @IContextMenuService contextMenuService: IContextMenuService, - @IContextViewService contextViewService: IContextViewService + @IContextViewService contextViewService: IContextViewService, + @ITextResourcePropertiesService private readonly textResourcePropertiesService: ITextResourcePropertiesService ) { super(parentContainer, themeService, instantiationService, contextMenuService, contextViewService); this._model = {}; this._parentContainer.style.display = 'none'; const header = DOM.$('.compare-operation-name'); + + this._summaryTextContainer = DOM.$('.compare-operation-summary-text'); + this.setSummary(this._summaryTextContainer); + this._primaryContainer = DOM.$('.compare-operation-name-text'); header.appendChild(this._primaryContainer); + this._secondaryContainer = DOM.$('.compare-operation-name-text'); header.appendChild(this._secondaryContainer); + this.setHeader(header); } + private setSummaryElement(summary: string[]): void { + const EOL = this.textResourcePropertiesService.getEOL(undefined); + let summaryText = summary.join(EOL); + let summaryContainerText = localize('executionPlanSummaryForExpensiveOperators', "Summary: {0}{1}", EOL, summaryText); + this._summaryTextContainer.innerText = summaryContainerText; + this._summaryTextContainer.title = summaryContainerText; + } + public setPrimaryElement(e: InternalExecutionPlanElement): void { this._model.primaryElement = e; if ((e).name) { @@ -134,7 +172,60 @@ export class ExecutionPlanComparisonPropertiesView extends ExecutionPlanProperti if (this._model.secondaryElement?.properties) { secondaryProps = this._model.secondaryElement.properties; } - this.populateTable(columns, this.convertPropertiesToTableRows(primaryProps, secondaryProps)); + + const tableRows = this.convertPropertiesToTableRows(primaryProps, secondaryProps); + this.setSummaryElement(this.getExpensivePropertySummary(tableRows)); + this.populateTable(columns, tableRows); + } + + /** + * This method returns an array of strings that that will make up the properties summary. The properties summary + * will appear above the properties table when execution plans are being compared. + * Each segment of that summary is in the following generic format: + * + * is greater for the top plan than it is for the bottom plan. + * is greater for the bottom plan than it is for the top plan. + * + * @param tableRows The table rows that will appear in the properties table. + * @returns The string array containing the segments of the summary. + */ + private getExpensivePropertySummary(tableRows: { [key: string]: string }[]): string[] { + let summary: string[] = []; + + tableRows.forEach(row => { + const rowName = row.name['text']; + if (row.primary && row.secondary) { + const primaryText = row.primary['text'].split(' '); + const secondaryTitle = row.secondary['title'].split(' '); + + if (primaryText.length === secondaryTitle.length && primaryText.length <= 2 && secondaryTitle.length <= 2) { + const MAX_PROPERTY_SUMMARY_LENGTH = 3; + + for (let i = 0; i < primaryText.length && summary.length < MAX_PROPERTY_SUMMARY_LENGTH; ++i) { + if (isNumber(primaryText[i]) && isNumber(secondaryTitle[i])) { + const primaryValue = Number(primaryText); + const secondaryValue = Number(secondaryTitle); + + let summaryText: string; + if (primaryValue > secondaryValue) { + summaryText = this._orientation === ExecutionPlanCompareOrientation.Horizontal + ? getTopPlanIsGreaterThanBottomPlanSummaryTextTemplate(rowName) + : getLeftPlanIsGreaterThanRightPlanSummaryTextTemplate(rowName); + } + else { + summaryText = this._orientation === ExecutionPlanCompareOrientation.Horizontal + ? getBottomPlanIsGreaterThanTopPlanSummaryTextTemplate(rowName) + : getRightPlanIsGreaterThanLeftPlanSummaryTextTemplate(rowName); + } + + summary.push(summaryText); + } + } + } + } + }); + + return summary; } private getPropertyTableColumns() { @@ -159,13 +250,23 @@ export class ExecutionPlanComparisonPropertiesView extends ExecutionPlanProperti }); } if (this._model.secondaryElement) { - columns.push(new TextWithIconColumn({ + columns.push({ + id: 'comparison', + name: '', + field: 'icon', + width: 40, + headerCssClass: 'prop-table-header', + formatter: iconCssFormatter + }); + + columns.push({ id: 'value2', name: getPropertyViewNameValueColumnBottomHeaderForOrientation(this._orientation), field: 'secondary', width: 150, headerCssClass: 'prop-table-header', - }).definition); + formatter: textFormatter + }); } return columns; } @@ -188,16 +289,69 @@ export class ExecutionPlanComparisonPropertiesView extends ExecutionPlanProperti return new Map([...props.entries()].sort((a, b) => { if (!a[1]?.displayOrder && !b[1]?.displayOrder) { return 0; - } else if (!a[1]?.displayOrder) { + } + else if (!a[1]?.displayOrder) { return -1; - } else if (!b[1]?.displayOrder) { + } + else if (!b[1]?.displayOrder) { return 1; - } else { + } + else { return a[1].displayOrder - b[1].displayOrder; } })); } + /** + * This method will sort properties by having those with different values appear at the top, + * and similar values appearing at the bottom of the table. + * + * An example of this sort of sorting looks like this: + * + * Name Value (Top plan) Value (Bottom Plan) + * ------------------------------------------------------------------------- + * Compile Time 38 37 + * CompileCpu 38 37 + * CompileMemory 5816 6424 + * Estimated Number of Rows 1000 1000 + * Optimization Level FULL FULL + * RetrievedFromCache false false + * + * @param props Map of properties that will be organized. + * @returns A new map with different values appearing at the top and similar values appearing at the bottom. + */ + public sortPropertiesByDisplayValueEquivalency(props: Map, sortProperties: (props: Map) => Map): Map { + let unequalProperties: Map = new Map(); + let equalProperties: Map = new Map(); + + [...props.entries()].forEach(prop => { + const [rowKey, rowEntry] = prop; + const primaryProp = rowEntry.primaryProp; + const secondaryProp = rowEntry.secondaryProp; + + if (primaryProp?.displayValue.localeCompare(secondaryProp?.displayValue) === 0) { + equalProperties.set(rowKey, rowEntry); + } + else { + unequalProperties.set(rowKey, rowEntry); + } + }); + + unequalProperties = sortProperties(unequalProperties); + equalProperties = sortProperties(equalProperties); + + let map: Map = new Map(); + unequalProperties.forEach((v, k) => { + map.set(k, v); + }); + + equalProperties.forEach((v, k) => { + map.set(k, v); + }); + + return map; + } + public sortPropertiesReverseAlphabetically(props: Map): Map { return new Map([...props.entries()].sort((a, b) => { if (!a[1]?.displayOrder && !b[1]?.displayOrder) { @@ -244,13 +398,13 @@ export class ExecutionPlanComparisonPropertiesView extends ExecutionPlanProperti switch (this.sortType) { case PropertiesSortType.DisplayOrder: - propertiesMap = this.sortPropertiesByImportance(propertiesMap); + propertiesMap = this.sortPropertiesByDisplayValueEquivalency(propertiesMap, this.sortPropertiesByImportance); break; case PropertiesSortType.Alphabetical: - propertiesMap = this.sortPropertiesAlphabetically(propertiesMap); + propertiesMap = this.sortPropertiesByDisplayValueEquivalency(propertiesMap, this.sortPropertiesAlphabetically); break; case PropertiesSortType.ReverseAlphabetical: - propertiesMap = this.sortPropertiesReverseAlphabetically(propertiesMap); + propertiesMap = this.sortPropertiesByDisplayValueEquivalency(propertiesMap, this.sortPropertiesReverseAlphabetically); break; } @@ -263,37 +417,56 @@ export class ExecutionPlanComparisonPropertiesView extends ExecutionPlanProperti const primaryProp = v.primaryProp; const secondaryProp = v.secondaryProp; let diffIconClass = ''; + if (primaryProp && secondaryProp) { row['displayOrder'] = v.primaryProp.displayOrder; + + let diffIcon = new Object() as DiffIcon; if (v.primaryProp.displayValue !== v.secondaryProp.displayValue) { switch (v.primaryProp.dataType) { case sqlExtHostType.executionPlan.ExecutionPlanGraphElementPropertyDataType.Boolean: - diffIconClass = executionPlanComparisonPropertiesDifferent; + diffIcon.iconClass = executionPlanComparisonPropertiesDifferent; + diffIcon.title = notEqualTitle; break; case sqlExtHostType.executionPlan.ExecutionPlanGraphElementPropertyDataType.Number: - diffIconClass = (parseFloat(v.primaryProp.displayValue) > parseFloat(v.secondaryProp.displayValue)) ? executionPlanComparisonPropertiesDownArrow : executionPlanComparisonPropertiesUpArrow; + diffIcon = (parseFloat(v.primaryProp.displayValue) > parseFloat(v.secondaryProp.displayValue)) + ? { iconClass: Codicon.chevronRight.classNames, title: greaterThanTitle } + : { iconClass: Codicon.chevronLeft.classNames, title: lessThanTitle }; + break; case sqlExtHostType.executionPlan.ExecutionPlanGraphElementPropertyDataType.String: - diffIconClass = executionPlanComparisonPropertiesDifferent; + diffIcon.iconClass = executionPlanComparisonPropertiesDifferent; + diffIcon.title = notEqualTitle; break; default: - diffIconClass = executionPlanComparisonPropertiesDifferent; + diffIcon.iconClass = executionPlanComparisonPropertiesDifferent; + diffIcon.title = notEqualTitle; break; } } + row['primary'] = { text: removeLineBreaks(v.primaryProp.displayValue, ' ') }; + + row['icon'] = { + iconCssClass: diffIcon.iconClass, + title: diffIcon.title + }; + row['secondary'] = { - iconCssClass: diffIconClass, title: removeLineBreaks(v.secondaryProp.displayValue, ' ') }; + if ((primaryProp && !isString(primaryProp.value)) || (secondaryProp && !isString(secondaryProp.value))) { row['name'].iconCssClass += ` parent-row-styling`; row['primary'].iconCssClass += ` parent-row-styling`; + row['icon'].iconCssClass += 'parent-row-styling'; row['secondary'].iconCssClass += ` parent-row-styling`; } + rows.push(row); + const topPropValue = isString(primaryProp.value) ? undefined : primaryProp.value; const bottomPropValue = isString(secondaryProp.value) ? undefined : secondaryProp.value; row['treeGridChildren'] = this.convertPropertiesToTableRows(topPropValue, bottomPropValue); @@ -324,7 +497,28 @@ export class ExecutionPlanComparisonPropertiesView extends ExecutionPlanProperti } }); - return rows; + + let formattedRows: { [key: string]: string }[] = []; + let equalRows: { [key: string]: string }[] = []; + for (const [_, row] of Object.entries(rows)) { + if (row.primary && row.secondary && row.primary['text'] === row.secondary['title']) { + equalRows.push(row); + } + else { + formattedRows.push(row); + } + } + + if (equalRows.length > 0) { + let equalRow = {}; + equalRow['name'] = equivalentPropertiesRowHeader; + equalRow['expanded'] = false; + equalRow['treeGridChildren'] = equalRows; + + formattedRows.push(equalRow); + } + + return formattedRows; } set orientation(value: ExecutionPlanCompareOrientation) { @@ -365,3 +559,8 @@ interface TablePropertiesMapEntry { displayOrder: number, name: string } + +interface DiffIcon { + iconClass: string; + title: string; +} diff --git a/src/sql/workbench/contrib/executionPlan/browser/executionPlanPropertiesViewBase.ts b/src/sql/workbench/contrib/executionPlan/browser/executionPlanPropertiesViewBase.ts index 7570e5063b..0e8a1adb5b 100644 --- a/src/sql/workbench/contrib/executionPlan/browser/executionPlanPropertiesViewBase.ts +++ b/src/sql/workbench/contrib/executionPlan/browser/executionPlanPropertiesViewBase.ts @@ -43,6 +43,9 @@ export abstract class ExecutionPlanPropertiesViewBase extends Disposable impleme // Header container private _headerContainer: HTMLElement; + // Summary container + private _summaryContainer: HTMLElement; + // Properties actions private _headerActionsContainer!: HTMLElement; private _headerActions: ActionBar; @@ -110,6 +113,9 @@ export abstract class ExecutionPlanPropertiesViewBase extends Disposable impleme this._headerContainer = DOM.$('.header'); propertiesContent.appendChild(this._headerContainer); + this._summaryContainer = DOM.$('.summary'); + propertiesContent.appendChild(this._summaryContainer); + this._searchAndActionBarContainer = DOM.$('.search-action-bar-container'); propertiesContent.appendChild(this._searchAndActionBarContainer); @@ -222,6 +228,10 @@ export abstract class ExecutionPlanPropertiesViewBase extends Disposable impleme this._headerContainer.appendChild(c); } + public setSummary(c: HTMLElement): void { + this._summaryContainer.appendChild(c); + } + public set tableHeight(value: number) { if (this.tableHeight !== value) { this._tableHeight = value; @@ -264,6 +274,7 @@ export abstract class ExecutionPlanPropertiesViewBase extends Disposable impleme private resizeTable(): void { const spaceOccupied = (this._titleBarContainer.getBoundingClientRect().height + this._headerContainer.getBoundingClientRect().height + + this._summaryContainer.getBoundingClientRect().height + this._headerActionsContainer.getBoundingClientRect().height); this.tableHeight = (this._parentContainer.getBoundingClientRect().height - spaceOccupied - 15); diff --git a/src/sql/workbench/contrib/executionPlan/browser/media/executionPlan.css b/src/sql/workbench/contrib/executionPlan/browser/media/executionPlan.css index 25c0feef7d..cce859a877 100644 --- a/src/sql/workbench/contrib/executionPlan/browser/media/executionPlan.css +++ b/src/sql/workbench/contrib/executionPlan/browser/media/executionPlan.css @@ -597,7 +597,7 @@ However we always want it to be the width of the container it is resizing. background-position: center; background-repeat: no-repeat; width: 16px; - height: auto; + height: 16px; } .vs-dark .eps-container .ep-properties-different, @@ -607,9 +607,18 @@ However we always want it to be the width of the container it is resizing. background-position: center; background-repeat: no-repeat; width: 16px; - height: auto; + height: 16px; } +.eps-container .comparison-editor .plan-comparison-container .properties .properties-content .table-container .table .monaco-table .ui-widget .slick-viewport .grid-canvas .ui-widget-content.slick-row .slick-cell .grid-cell-value-container.icon.codicon.slick-icon-cell-content.codicon-chevron-left, +.eps-container .comparison-editor .plan-comparison-container .properties .properties-content .table-container .table .monaco-table .ui-widget .slick-viewport .grid-canvas .ui-widget-content.slick-row .slick-cell .grid-cell-value-container.icon.codicon.slick-icon-cell-content.codicon-chevron-right { + background-size: 16px 16px; + background-position: center; + background-repeat: no-repeat; + width: 16px; + height: 16px; + margin-left: -28px; +} .eps-container .ep-properties-down-arrow { background-image: url(../images/actionIcons/downArrow.svg); @@ -617,7 +626,7 @@ However we always want it to be the width of the container it is resizing. background-position: center; background-repeat: no-repeat; width: 16px; - height: auto; + height: 16px; } .vs-dark .eps-container .ep-properties-down-arrow, @@ -627,7 +636,7 @@ However we always want it to be the width of the container it is resizing. background-position: center; background-repeat: no-repeat; width: 16px; - height: auto; + height: 16px; } .eps-container .ep-properties-up-arrow { @@ -636,7 +645,7 @@ However we always want it to be the width of the container it is resizing. background-position: center; background-repeat: no-repeat; width: 16px; - height: auto; + height: 16px; } .vs-dark .eps-container .ep-properties-up-arrow, @@ -646,7 +655,7 @@ However we always want it to be the width of the container it is resizing. background-position: center; background-repeat: no-repeat; width: 16px; - height: auto; + height: 16px; } .eps-container .ep-top-operations {