Skip to content

Commit 55b0c04

Browse files
authored
Adding handling for shift + arrow keys to select data and fixing result grid summary (#20200)
1 parent 47db111 commit 55b0c04

File tree

11 files changed

+447
-131
lines changed

11 files changed

+447
-131
lines changed

localization/l10n/bundle.l10n.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1486,6 +1486,12 @@
14861486
"{0} is the average, {1} is the count, {2} is the distinct count, {3} is the max, {4} is the min, {5} is the null count, {6} is the sum"
14871487
]
14881488
},
1489+
"You have selected data across {0} rows, it might take a while to load the data and calculate the summary, do you want to continue?/{0} is the number of rows to fetch summary statistics for": {
1490+
"message": "You have selected data across {0} rows, it might take a while to load the data and calculate the summary, do you want to continue?",
1491+
"comment": [
1492+
"{0} is the number of rows to fetch summary statistics for"
1493+
]
1494+
},
14891495
"An error occurred while retrieving rows: {0}/{0} is the error message": {
14901496
"message": "An error occurred while retrieving rows: {0}",
14911497
"comment": [

localization/xliff/vscode-mssql.xlf

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3221,6 +3221,10 @@
32213221
<trans-unit id="++CODE++867d2664362bce58e979ee3de5b436d12ab50da730929367290fd6b9043b2a90">
32223222
<source xml:lang="en">You are not connected to any database.</source>
32233223
</trans-unit>
3224+
<trans-unit id="++CODE++9b358aeeb5265d934737d601151f0982e0b833eaa6abb54c40be62ed494882b8">
3225+
<source xml:lang="en">You have selected data across {0} rows, it might take a while to load the data and calculate the summary, do you want to continue?</source>
3226+
<note>{0} is the number of rows to fetch summary statistics for</note>
3227+
</trans-unit>
32243228
<trans-unit id="++CODE++437f6eb2a46a074059cbbd924d82e7b72146b3bbb13c028906a45bbffdf69e3f">
32253229
<source xml:lang="en">You must be signed into Azure in order to browse SQL databases.</source>
32263230
</trans-unit>

src/constants/locConstants.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -949,6 +949,13 @@ export class QueryResult {
949949
"{0} is the average, {1} is the count, {2} is the distinct count, {3} is the max, {4} is the min, {5} is the null count, {6} is the sum",
950950
],
951951
});
952+
public static summaryFetchConfirmation = (numRows: number) =>
953+
l10n.t({
954+
message:
955+
"You have selected data across {0} rows, it might take a while to load the data and calculate the summary, do you want to continue?",
956+
args: [numRows],
957+
comment: ["{0} is the number of rows to fetch summary statistics for"],
958+
});
952959
public static getRowsError = (error: string) =>
953960
l10n.t({
954961
message: "An error occurred while retrieving rows: {0}",

src/controllers/queryRunner.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,8 @@ import * as os from "os";
5151
import { Deferred } from "../protocol";
5252
import { sendActionEvent } from "../telemetry/telemetry";
5353
import { TelemetryActions, TelemetryViews } from "../sharedInterfaces/telemetry";
54+
import { SelectionSummaryStats } from "../sharedInterfaces/queryResult";
55+
import { calculateSelectionSummaryFromData } from "../queryResult/utils";
5456

5557
export interface IResultSet {
5658
columns: string[];
@@ -1053,6 +1055,55 @@ export default class QueryRunner {
10531055
await this.writeStringToClipboard(insertIntoString);
10541056
}
10551057

1058+
public async generateSelectionSummaryData(
1059+
selection: ISlickRange[],
1060+
batchId: number,
1061+
resultId: number,
1062+
): Promise<SelectionSummaryStats> {
1063+
// Keep copy order deterministic
1064+
selection.sort((a, b) => a.fromRow - b.fromRow);
1065+
1066+
let totalRows = 0;
1067+
for (let range of selection) {
1068+
totalRows += range.toRow - range.fromRow + 1;
1069+
}
1070+
1071+
const summaryFetchThreshold =
1072+
vscode.workspace
1073+
.getConfiguration()
1074+
.get<number>(Constants.configInMemoryDataProcessingThreshold) ?? 5000;
1075+
1076+
if (totalRows > summaryFetchThreshold) {
1077+
let confirm = await vscode.window.showInformationMessage(
1078+
LocalizedConstants.QueryResult.summaryFetchConfirmation(totalRows),
1079+
{ modal: false },
1080+
LocalizedConstants.msgYes,
1081+
);
1082+
if (confirm !== LocalizedConstants.msgYes) {
1083+
return;
1084+
}
1085+
}
1086+
1087+
const rowIdToSelectionMap = new Map<number, ISlickRange[]>();
1088+
const rowIdToRowMap = new Map<number, DbCellValue[]>();
1089+
1090+
// Fetch all ranges in parallel; fill the maps as results come in
1091+
await Promise.all(
1092+
selection.map(async (range) => {
1093+
const count = range.toRow - range.fromRow + 1;
1094+
const result = await this.getRows(range.fromRow, count, batchId, resultId);
1095+
this.getRowMappings(
1096+
result.resultSubset.rows,
1097+
range,
1098+
rowIdToSelectionMap,
1099+
rowIdToRowMap,
1100+
);
1101+
}),
1102+
);
1103+
1104+
return calculateSelectionSummaryFromData(rowIdToRowMap, rowIdToSelectionMap);
1105+
}
1106+
10561107
public async toggleSqlCmd(): Promise<boolean> {
10571108
const queryExecuteOptions: QueryExecutionOptions = { options: {} };
10581109
queryExecuteOptions.options["isSqlCmdMode"] = !this.isSqlCmd;

src/models/sqlOutputContentProvider.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -242,6 +242,17 @@ export class SqlOutputContentProvider {
242242
.queryRunner.copyResultsAsInsertInto(selection, batchId, resultId);
243243
}
244244

245+
public generateSelectionSummaryData(
246+
uri: string,
247+
batchId: number,
248+
resultId: number,
249+
selection: Interfaces.ISlickRange[],
250+
): Promise<qr.SelectionSummaryStats> {
251+
return this._queryResultsMap
252+
.get(uri)
253+
.queryRunner.generateSelectionSummaryData(selection, batchId, resultId);
254+
}
255+
245256
public editorSelectionRequestHandler(uri: string, selection: ISelectionData): void {
246257
void this._queryResultsMap.get(uri).queryRunner.setEditorSelection(selection);
247258
}

src/queryResult/utils.ts

Lines changed: 203 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import { QueryResultWebviewController } from "./queryResultWebViewController";
2020
import store, { SubKeys } from "./singletonStore";
2121
import { JsonFormattingEditProvider } from "../utils/jsonFormatter";
2222
import * as LocalizedConstants from "../constants/locConstants";
23+
import { generateGuid } from "../models/utils";
2324

2425
export const MAX_VIEW_COLUMN = 9;
2526

@@ -312,13 +313,34 @@ export function registerCommonRequestHandlers(
312313
return store.get(message.uri, SubKeys.PaneScrollPosition) ?? { scrollTop: 0 };
313314
});
314315

315-
webviewController.onRequest(qr.SetSelectionSummaryRequest.type, async (message) => {
316+
let lastSummaryRequestId = "";
317+
webviewController.onNotification(qr.SetSelectionSummaryRequest.type, async (message) => {
318+
let currentId = generateGuid();
319+
lastSummaryRequestId = currentId;
320+
321+
// Fetch all the data needed for the summary
322+
const summaryData = await webviewViewController
323+
.getSqlOutputContentProvider()
324+
.generateSelectionSummaryData(
325+
message.uri,
326+
message.batchId,
327+
message.resultId,
328+
message.selection,
329+
);
330+
316331
const controller =
317332
webviewController instanceof QueryResultWebviewPanelController
318333
? webviewController.getQueryResultWebviewViewController()
319334
: webviewController;
320335

321-
controller.updateSelectionSummaryStatusItem(message.summary);
336+
/**
337+
* Only update the summary if this is the latest request. This ensures that if multiple
338+
* requests are made in quick succession, we don't overwrite the summary with stale data.
339+
*/
340+
if (lastSummaryRequestId !== currentId) {
341+
return;
342+
}
343+
controller.updateSelectionSummaryStatusItem(summaryData);
322344
});
323345

324346
webviewController.registerReducer("setResultTab", async (state, payload) => {
@@ -414,3 +436,182 @@ export function countResultSets(
414436
}
415437
return count;
416438
}
439+
440+
/**
441+
* Calculate selection summary statistics for grid selections
442+
* @param selections Array of grid selection ranges
443+
* @param grid Mock grid object with getCellNode method
444+
* @param isSelection Whether this is an actual selection (true) or clearing stats (false)
445+
* @returns Promise<SelectionSummaryStats> Summary statistics
446+
*/
447+
export async function selectionSummaryHelper(
448+
selections: qr.ISlickRange[],
449+
grid: {
450+
getCellNode: (row: number, col: number) => HTMLElement | undefined;
451+
getColumns: () => any[];
452+
},
453+
isSelection: boolean,
454+
): Promise<qr.SelectionSummaryStats> {
455+
const summary: qr.SelectionSummaryStats = {
456+
count: -1,
457+
average: "",
458+
sum: 0,
459+
min: 0,
460+
max: 0,
461+
removeSelectionStats: !isSelection,
462+
distinctCount: -1,
463+
nullCount: -1,
464+
};
465+
466+
if (!isSelection) {
467+
return summary;
468+
}
469+
470+
const columns = grid.getColumns();
471+
if (!columns || columns.length === 0) {
472+
return summary;
473+
}
474+
475+
if (!selections || selections.length === 0) {
476+
return summary;
477+
}
478+
479+
// Reset values for actual calculation
480+
summary.count = 0;
481+
summary.distinctCount = 0;
482+
summary.nullCount = 0;
483+
summary.min = Infinity;
484+
summary.max = -Infinity;
485+
summary.removeSelectionStats = false;
486+
487+
const distinct = new Set<string>();
488+
let numericCount = 0;
489+
490+
const isFiniteNumber = (v: string): boolean => {
491+
const n = Number(v);
492+
return Number.isFinite(n);
493+
};
494+
495+
for (const selection of selections) {
496+
for (let row = selection.fromRow; row <= selection.toRow; row++) {
497+
for (let col = selection.fromCell; col <= selection.toCell; col++) {
498+
const cell = grid.getCellNode(row, col);
499+
if (!cell) {
500+
continue;
501+
}
502+
503+
summary.count++;
504+
const cellText = cell.innerText;
505+
506+
if (cellText === "NULL" || cellText === null || cellText === undefined) {
507+
summary.nullCount++;
508+
continue;
509+
}
510+
511+
distinct.add(cellText);
512+
513+
if (isFiniteNumber(cellText)) {
514+
const n = Number(cellText);
515+
numericCount++;
516+
summary.sum += n;
517+
if (n < summary.min) summary.min = n;
518+
if (n > summary.max) summary.max = n;
519+
}
520+
}
521+
}
522+
}
523+
524+
summary.distinctCount = distinct.size;
525+
526+
// Only compute average when we actually saw numeric cells
527+
if (numericCount > 0) {
528+
summary.average = (summary.sum / numericCount).toFixed(3);
529+
} else {
530+
summary.average = "";
531+
}
532+
533+
// Normalize min/max if there were no numeric values
534+
if (!Number.isFinite(summary.min)) summary.min = 0;
535+
if (!Number.isFinite(summary.max)) summary.max = 0;
536+
537+
return summary;
538+
}
539+
540+
/**
541+
* Calculate selection summary statistics from database row data
542+
* @param rowIdToRowMap Map of row IDs to database cell value arrays
543+
* @param rowIdToSelectionMap Map of row IDs to selection ranges
544+
* @returns SelectionSummaryStats Summary statistics
545+
*/
546+
export function calculateSelectionSummaryFromData(
547+
rowIdToRowMap: Map<number, qr.DbCellValue[]>,
548+
rowIdToSelectionMap: Map<number, qr.ISlickRange[]>,
549+
): qr.SelectionSummaryStats {
550+
const summary: qr.SelectionSummaryStats = {
551+
count: 0,
552+
average: "",
553+
sum: 0,
554+
min: Infinity,
555+
max: -Infinity,
556+
removeSelectionStats: false,
557+
distinctCount: 0,
558+
nullCount: 0,
559+
};
560+
561+
if (rowIdToRowMap.size === 0) {
562+
// No data; normalize min/max to 0 to match prior behavior
563+
summary.min = 0;
564+
summary.max = 0;
565+
return summary;
566+
}
567+
568+
const distinct = new Set<string>();
569+
let numericCount = 0;
570+
571+
const isFiniteNumber = (v: string): boolean => {
572+
const n = Number(v);
573+
return Number.isFinite(n);
574+
};
575+
576+
for (const [rowId, row] of rowIdToRowMap) {
577+
const rowSelections = rowIdToSelectionMap.get(rowId) ?? [];
578+
for (const sel of rowSelections) {
579+
const start = Math.max(0, sel.fromCell);
580+
const end = Math.min(row.length - 1, sel.toCell);
581+
for (let c = start; c <= end; c++) {
582+
const cell = row[c];
583+
summary.count++;
584+
585+
if (cell?.isNull) {
586+
summary.nullCount++;
587+
continue;
588+
}
589+
590+
const display = cell?.displayValue ?? "";
591+
distinct.add(display);
592+
593+
if (isFiniteNumber(display)) {
594+
const n = Number(display);
595+
numericCount++;
596+
summary.sum += n;
597+
if (n < summary.min) summary.min = n;
598+
if (n > summary.max) summary.max = n;
599+
} else {
600+
// There is at least one non-numeric (non-null) value
601+
summary.removeSelectionStats = false;
602+
}
603+
}
604+
}
605+
}
606+
607+
summary.distinctCount = distinct.size;
608+
609+
// Only compute average when we actually saw numeric cells and round to 2 decimal places
610+
summary.average = numericCount > 0 ? (summary.sum / numericCount).toFixed(2) : "";
611+
612+
// Normalize min/max if there were no numeric values
613+
if (!Number.isFinite(summary.min)) summary.min = 0;
614+
if (!Number.isFinite(summary.max)) summary.max = 0;
615+
616+
return summary;
617+
}

src/reactviews/common/keys.ts

Lines changed: 16 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,16 @@
1-
/*---------------------------------------------------------------------------------------------
2-
* Copyright (c) Microsoft Corporation. All rights reserved.
3-
* Licensed under the MIT License. See License.txt in the project root for license information.
4-
*--------------------------------------------------------------------------------------------*/
5-
6-
export enum Keys {
7-
ArrowDown = "ArrowDown",
8-
ArrowUp = "ArrowUp",
9-
Enter = "Enter",
10-
Escape = "Escape",
11-
Space = " ",
12-
c = "c",
13-
a = "a",
14-
}
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
* Licensed under the MIT License. See License.txt in the project root for license information.
4+
*--------------------------------------------------------------------------------------------*/
5+
6+
export enum Keys {
7+
Enter = "Enter",
8+
Escape = "Escape",
9+
ArrowLeft = "ArrowLeft",
10+
ArrowRight = "ArrowRight",
11+
ArrowUp = "ArrowUp",
12+
ArrowDown = "ArrowDown",
13+
Space = " ",
14+
c = "c",
15+
a = "a",
16+
}

0 commit comments

Comments
 (0)