Skip to content

Commit

Permalink
query result selection summary improvement (both perf and usability) (#…
Browse files Browse the repository at this point in the history
  • Loading branch information
alanrenmsft committed Jun 13, 2023
1 parent e58f3ff commit d983355
Show file tree
Hide file tree
Showing 10 changed files with 255 additions and 111 deletions.
66 changes: 66 additions & 0 deletions src/sql/base/common/gridRange.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
*--------------------------------------------------------------------------------------------*/

import { IGridPosition, GridPosition } from 'sql/base/common/gridPosition';
import { IRange } from 'vs/base/common/range';
import { isNumber } from 'vs/base/common/types';

/**
Expand Down Expand Up @@ -358,4 +359,69 @@ export class GridRange {
public static spansMultipleLines(range: IGridRange): boolean {
return range.endRow > range.startRow;
}

/**
* Create an instance of IGridRange from Slick.Range.
*/
public static fromSlickRange(range: Slick.Range): IGridRange {
return {
startRow: range.fromRow,
endRow: range.toRow,
startColumn: range.fromCell,
endColumn: range.toCell
};
}

/**
* Create a list IGridRange from a list of Slick.Range.
*/
public static fromSlickRanges(ranges: Slick.Range[]): IGridRange[] {
return ranges.map(r => GridRange.fromSlickRange(r));
}

/**
* Merge the ranges by row or column and return merged ranges
* @param ranges the ranges to be merged
* @param mergeRows whether to merge the rows or columns.
*/
private static mergeRanges(ranges: IGridRange[], mergeRows: boolean): IRange[] {
let sourceRanges: IRange[] = ranges.map(r => {
if (mergeRows) {
return { start: r.startRow, end: r.endRow };
} else {
return { start: r.startColumn, end: r.endColumn };
}
});
const mergedRanges: IRange[] = [];
sourceRanges = sourceRanges.sort((s1, s2) => { return s1.start - s2.start; });
sourceRanges.forEach(range => {
let merged = false;
for (let i = 0; i < mergedRanges.length; i++) {
const mergedRange = mergedRanges[i];
if (range.start <= mergedRange.end) {
mergedRange.end = Math.max(range.end, mergedRange.end);
merged = true;
break;
}
}
if (!merged) {
mergedRanges.push(range);
}
});
return mergedRanges;
}

/**
* Gets the unique row ranges.
*/
public static getUniqueRows(ranges: IGridRange[]): IRange[] {
return GridRange.mergeRanges(ranges, true);
}

/**
* Gets the unique column ranges.
*/
public static getUniqueColumns(ranges: IGridRange[]): IRange[] {
return GridRange.mergeRanges(ranges, false);
}
}
1 change: 1 addition & 0 deletions src/sql/platform/query/common/query.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface IQueryEditorConfiguration {
readonly openAfterSave: boolean;
readonly showActionBar: boolean;
readonly preferProvidersCopyHandler: boolean;
readonly promptForLargeRowSelection: boolean;
},
readonly messages: {
readonly showBatchTime: boolean;
Expand Down
114 changes: 98 additions & 16 deletions src/sql/workbench/contrib/query/browser/gridPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,11 +33,11 @@ import { ActionBar, ActionsOrientation } from 'vs/base/browser/ui/actionbar/acti
import { isInDOM, Dimension } from 'vs/base/browser/dom';
import { IInstantiationService } from 'vs/platform/instantiation/common/instantiation';
import { IEditorService } from 'vs/workbench/services/editor/common/editorService';
import { IAction, Separator } from 'vs/base/common/actions';
import { IAction, Separator, toAction } from 'vs/base/common/actions';
import { ILogService } from 'vs/platform/log/common/log';
import { localize } from 'vs/nls';
import { IGridDataProvider } from 'sql/workbench/services/query/common/gridDataProvider';
import { CancellationToken } from 'vs/base/common/cancellation';
import { CancellationToken, CancellationTokenSource } from 'vs/base/common/cancellation';
import { GridPanelState, GridTableState } from 'sql/workbench/common/editor/query/gridTableState';
import { IUntitledTextEditorService } from 'vs/workbench/services/untitled/common/untitledTextEditorService';
import { SaveFormat } from 'sql/workbench/services/query/common/resultSerializer';
Expand All @@ -48,7 +48,7 @@ import { Orientation } from 'vs/base/browser/ui/splitview/splitview';
import { IQueryModelService } from 'sql/workbench/services/query/common/queryModel';
import { FilterButtonWidth, HeaderFilter } from 'sql/base/browser/ui/table/plugins/headerFilter.plugin';
import { HybridDataProvider } from 'sql/base/browser/ui/table/hybridDataProvider';
import { INotificationService } from 'vs/platform/notification/common/notification';
import { INotificationHandle, INotificationService, Severity } from 'vs/platform/notification/common/notification';
import { alert, status } from 'vs/base/browser/ui/aria/aria';
import { IExecutionPlanService } from 'sql/workbench/services/executionPlan/common/interfaces';
import { ExecutionPlanInput } from 'sql/workbench/contrib/executionPlan/browser/executionPlanInput';
Expand All @@ -58,6 +58,8 @@ import { IAccessibilityService } from 'vs/platform/accessibility/common/accessib
import { IQuickInputService } from 'vs/platform/quickinput/common/quickInput';
import { queryEditorNullBackground } from 'sql/platform/theme/common/colorRegistry';
import { IComponentContextService } from 'sql/workbench/services/componentContext/browser/componentContextService';
import { GridRange } from 'sql/base/common/gridRange';
import { onUnexpectedError } from 'vs/base/common/errors';

const ROW_HEIGHT = 29;
const HEADER_HEIGHT = 26;
Expand Down Expand Up @@ -373,6 +375,7 @@ export abstract class GridTableBase<T> extends Disposable implements IView, IQue
private filterPlugin: HeaderFilter<T>;
private isDisposed: boolean = false;
private gridConfig: IResultGridConfiguration;
private selectionChangeHandlerTokenSource: CancellationTokenSource | undefined;

private columns: Slick.Column<T>[];

Expand Down Expand Up @@ -664,7 +667,7 @@ export abstract class GridTableBase<T> extends Disposable implements IView, IQue
if (this.state) {
this.state.selection = this.selectionModel.getSelectedRanges();
}
await this.notifyTableSelectionChanged();
await this.handleTableSelectionChange();
});

this.table.grid.onScroll.subscribe((e, data) => {
Expand Down Expand Up @@ -763,31 +766,110 @@ export abstract class GridTableBase<T> extends Disposable implements IView, IQue
this._state = val;
}

private async getRowData(start: number, length: number): Promise<ICellValue[][]> {
private async getRowData(start: number, length: number, cancellationToken?: CancellationToken, onProgressCallback?: (availableRows: number) => void): Promise<ICellValue[][]> {
let subset;
if (this.dataProvider.isDataInMemory) {
// handle the scenario when the data is sorted/filtered,
// we need to use the data that is being displayed
const data = await this.dataProvider.getRangeAsync(start, length);
subset = data.map(item => Object.keys(item).map(key => item[key]));
} else {
subset = (await this.gridDataProvider.getRowData(start, length)).rows;
subset = (await this.gridDataProvider.getRowData(start, length, cancellationToken, onProgressCallback)).rows;
}
return subset;
}

private async notifyTableSelectionChanged() {
const selectedCells = [];
for (const range of this.state.selection) {
const subset = await this.getRowData(range.fromRow, range.toRow - range.fromRow + 1);
subset.forEach(row => {
// start with range.fromCell -1 because we have row number column which is not available in the actual data
for (let i = range.fromCell - 1; i < range.toCell; i++) {
selectedCells.push(row[i]);
private async handleTableSelectionChange(): Promise<void> {
if (this.selectionChangeHandlerTokenSource) {
this.selectionChangeHandlerTokenSource.cancel();
}
this.selectionChangeHandlerTokenSource = new CancellationTokenSource();
await this.notifyTableSelectionChanged(this.selectionChangeHandlerTokenSource);
}

private async notifyTableSelectionChanged(cancellationTokenSource: CancellationTokenSource): Promise<void> {
const gridRanges = GridRange.fromSlickRanges(this.state.selection ?? []);
const rowRanges = GridRange.getUniqueRows(gridRanges);
const columnRanges = GridRange.getUniqueColumns(gridRanges);
const rowCount = rowRanges.map(range => range.end - range.start + 1).reduce((p, c) => p + c);
const runAction = async (proceed: boolean) => {
const selectedCells = [];
if (proceed && !cancellationTokenSource.token.isCancellationRequested) {
let notificationHandle: INotificationHandle = undefined;
const timeout = setTimeout(() => {
notificationHandle = this.notificationService.notify({
message: localize('resultsGrid.loadingData', "Loading selected rows for calculation..."),
severity: Severity.Info,
progress: {
infinite: true
},
actions: {
primary: [
toAction({
id: 'cancelLoadingCells',
label: localize('resultsGrid.cancel', "Cancel"),
run: () => {
cancellationTokenSource.cancel();
notificationHandle.close();
}
})]
}
});
}, 1000);
this.queryModelService.notifyCellSelectionChanged([]);
let rowsInProcessedRanges = 0;
for (const range of rowRanges) {
if (cancellationTokenSource.token.isCancellationRequested) {
break;
}
const rows = await this.getRowData(range.start, range.end - range.start + 1, cancellationTokenSource.token, (availableRows: number) => {
notificationHandle?.updateMessage(localize('resultsGrid.loadingDataWithProgress', "Loading selected rows for calculation ({0}/{1})...", rowsInProcessedRanges + availableRows, rowCount));
});
rows.forEach((row, rowIndex) => {
columnRanges.forEach(cr => {
for (let i = cr.start; i <= cr.end; i++) {
if (this.state.selection.some(selection => selection.contains(rowIndex + range.start, i))) {
// need to reduce the column index by 1 because we have row number column which is not available in the actual data
selectedCells.push(row[i - 1]);
}
}
});
});
rowsInProcessedRanges += range.end - range.start + 1;
}
});
clearTimeout(timeout);
notificationHandle?.close();
}
cancellationTokenSource.dispose();
if (!cancellationTokenSource.token.isCancellationRequested) {
this.queryModelService.notifyCellSelectionChanged(selectedCells);
}
};
const showPromptConfigValue = this.configurationService.getValue<IQueryEditorConfiguration>('queryEditor').results.promptForLargeRowSelection;
if (this.options.inMemoryDataCountThreshold && rowCount > this.options.inMemoryDataCountThreshold && showPromptConfigValue) {
this.notificationService.prompt(Severity.Warning, localize('resultsGrid.largeRowSelectionPrompt.', 'You have selected {0} rows, it might take a while to load the data and calculate the summary, do you want to continue?', rowCount), [
{
label: localize('resultsGrid.confirmLargeRowSelection', "Yes"),
run: async () => {
await runAction(true);
}
}, {
label: localize('resultsGrid.cancelLargeRowSelection', "Cancel"),
run: async () => {
await runAction(false);
}
}, {
label: localize('resultsGrid.donotShowLargeRowSelectionPromptAgain', "Don't show again"),
run: async () => {
this.configurationService.updateValue('queryEditor.results.promptForLargeRowSelection', false).catch(e => onUnexpectedError(e));
await runAction(true);
},
isSecondary: true
}
]);
} else {
await runAction(true);
}
this.queryModelService.notifyCellSelectionChanged(selectedCells);
}

private async onTableClick(event: ITableMouseEvent) {
Expand Down
5 changes: 5 additions & 0 deletions src/sql/workbench/contrib/query/browser/query.contribution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -424,6 +424,11 @@ const queryEditorConfiguration: IConfigurationNode = {
'description': localize('queryEditor.results.showActionBar', "Whether to show the action bar in the query results view"),
'default': true
},
'queryEditor.results.promptForLargeRowSelection': {
'type': 'boolean',
'default': true,
'description': localize('queryEditor.results.promptForLargeRowSelection', "When cells are selected in the results grid, ADS will calculate the summary for them, This setting controls whether to show the a confirmation when the number of rows selected is larger than the value specified in the 'inMemoryDataProcessingThreshold' setting. The default value is true.")
},
'queryEditor.messages.showBatchTime': {
'type': 'boolean',
'description': localize('queryEditor.messages.showBatchTime', "Should execution time be shown for individual batches"),
Expand Down
6 changes: 4 additions & 2 deletions src/sql/workbench/contrib/query/browser/statusBarItems.ts
Original file line number Diff line number Diff line change
Expand Up @@ -316,10 +316,12 @@ export class QueryResultSelectionSummaryStatusBarContribution extends Disposable
const nullCount = selectedCells.filter(cell => cell.isNull).length;
let summaryText, tooltipText;
if (numericValues.length >= 2) {
const sum = numericValues.reduce((previous, current, idx, array) => previous + current);
const sum = numericValues.reduce((previous, current) => previous + current);
const min = numericValues.reduce((previous, current) => Math.min(previous, current));
const max = numericValues.reduce((previous, current) => Math.max(previous, current));
summaryText = localize('status.query.summaryText', "Average: {0} Count: {1} Sum: {2}", Number((sum / numericValues.length).toFixed(3)), selectedCells.length, sum);
tooltipText = localize('status.query.summaryTooltip', "Average: {0} Count: {1} Distinct Count: {2} Max: {3} Min: {4} Null Count: {5} Sum: {6}",
Number((sum / numericValues.length).toFixed(3)), selectedCells.length, distinctValues.size, Math.max(...numericValues), Math.min(...numericValues), nullCount, sum);
Number((sum / numericValues.length).toFixed(3)), selectedCells.length, distinctValues.size, max, min, nullCount, sum);
} else {
summaryText = summaryText = localize('status.query.summaryTextNonNumeric', "Count: {0} Distinct Count: {1} Null Count: {2}", selectedCells.length, distinctValues.size, nullCount);
}
Expand Down

0 comments on commit d983355

Please sign in to comment.