From 4cbedae84376232339a164106fda44b89002cf1b Mon Sep 17 00:00:00 2001 From: Aasim Khan Date: Mon, 6 Oct 2025 22:31:24 -0700 Subject: [PATCH 01/11] Fixing summary perf --- src/constants/locConstants.ts | 36 +++++-- src/controllers/queryRunner.ts | 144 ++++++++++----------------- src/models/contracts/queryExecute.ts | 82 +++++++++++++++ src/queryResult/utils.ts | 79 --------------- 4 files changed, 164 insertions(+), 177 deletions(-) diff --git a/src/constants/locConstants.ts b/src/constants/locConstants.ts index d10173feef..8682946b34 100644 --- a/src/constants/locConstants.ts +++ b/src/constants/locConstants.ts @@ -1016,6 +1016,29 @@ export class QueryResult { }), ].join(os.EOL); }; + public static nonNumericSelectionSummaryTooltip = ( + count: number, + distinctCount: number, + nullCount: number, + ) => { + return [ + l10n.t({ + message: "Count: {0}", + args: [count], + comment: ["{0} is the count"], + }), + l10n.t({ + message: "Distinct Count: {0}", + args: [distinctCount], + comment: ["{0} is the distinct count"], + }), + l10n.t({ + message: "Null Count: {0}", + args: [nullCount], + comment: ["{0} is the null count"], + }), + ].join(os.EOL); + }; public static summaryFetchConfirmation = (numRows: number) => l10n.t({ message: "{0} rows selected, click to load summary", @@ -1023,16 +1046,11 @@ export class QueryResult { comment: ["{0} is the number of rows to fetch summary statistics for"], }); public static clickToFetchSummary = l10n.t("Click to load summary"); - public static summaryLoadingProgress = (currentRow: number, totalRows: number) => { - const percentage = Math.floor((currentRow / totalRows) * 100); + public static summaryLoadingProgress = (totalRows: number) => { return l10n.t({ - message: `Loading summary {0}/{1} ({2}%) (Click to cancel)`, - args: [currentRow, totalRows, percentage], - comment: [ - "{0} is the current row", - "{1} is the total number of rows", - "{2} is the percentage of rows loaded", - ], + message: `Loading summary for {0} rows (Click to cancel)`, + args: [totalRows], + comment: ["{0} is the total number of rows"], }); }; public static clickToCancelLoadingSummary = l10n.t("Click to cancel loading summary"); diff --git a/src/controllers/queryRunner.ts b/src/controllers/queryRunner.ts index 2383327363..59e64722fe 100644 --- a/src/controllers/queryRunner.ts +++ b/src/controllers/queryRunner.ts @@ -30,6 +30,9 @@ import { ExecutionPlanOptions, QueryConnectionUriChangeRequest, QueryConnectionUriChangeParams, + GridSelectionSummaryRequest, + TableSelectionRange, + CancelGridSelectionSummaryNotification, } from "../models/contracts/queryExecute"; import { QueryDisposeParams, QueryDisposeRequest } from "../models/contracts/queryDispose"; import { @@ -52,10 +55,7 @@ import { Deferred } from "../protocol"; import { sendActionEvent } from "../telemetry/telemetry"; import { TelemetryActions, TelemetryViews } from "../sharedInterfaces/telemetry"; import { SelectionSummary } from "../sharedInterfaces/queryResult"; -import { - calculateSelectionSummaryFromData, - getInMemoryGridDataProcessingThreshold, -} from "../queryResult/utils"; +import { getInMemoryGridDataProcessingThreshold } from "../queryResult/utils"; export interface IResultSet { columns: string[]; @@ -1069,7 +1069,7 @@ export default class QueryRunner { private _requestID: string; private _cancelConfirmation: Deferred; public async generateSelectionSummaryData( - selection: ISlickRange[], + selections: ISlickRange[], batchId: number, resultId: number, showThresholdWarning: boolean = true, @@ -1094,7 +1094,7 @@ export default class QueryRunner { await proceed.promise; }; - const showProgress = (progress: number, cancelConfirmation: Deferred) => { + const showProgress = (cancelConfirmation: Deferred) => { this.fireSummaryChangedEvent(this._requestID, { command: { title: Constants.cmdHandleSummaryOperation, @@ -1102,7 +1102,7 @@ export default class QueryRunner { arguments: [this.uri], }, continue: cancelConfirmation, - text: `$(arrow-circle-down) ${LocalizedConstants.QueryResult.summaryLoadingProgress(progress, totalRows)}`, + text: `$(loading~spin) ${LocalizedConstants.QueryResult.summaryLoadingProgress(totalRows)}`, tooltip: LocalizedConstants.QueryResult.clickToCancelLoadingSummary, uri: this.uri, }); @@ -1115,9 +1115,9 @@ export default class QueryRunner { this._cancelConfirmation = undefined; // Keep copy order deterministic - selection.sort((a, b) => a.fromRow - b.fromRow); + selections.sort((a, b) => a.fromRow - b.fromRow); let totalRows = 0; - for (let range of selection) { + for (let range of selections) { totalRows += range.toRow - range.fromRow + 1; } @@ -1132,7 +1132,7 @@ export default class QueryRunner { // Reset and allow user to start a new summary operation this._cancelConfirmation = undefined; await waitForUserToProceed(requestId, totalRows); - await this.generateSelectionSummaryData(selection, batchId, resultId, false); + await this.generateSelectionSummaryData(selections, batchId, resultId, false); }; this._cancelConfirmation = new Deferred(); @@ -1140,113 +1140,79 @@ export default class QueryRunner { let isCanceled = false; // Set up cancellation handling cancel.promise - .then(() => (isCanceled = true)) + .then(async () => { + isCanceled = true; + await this._client.sendNotification(CancelGridSelectionSummaryNotification.type, { + ownerUri: this.uri, + }); + await sendCancelSummaryEvent(); + }) .catch(() => { /* noop */ }); - showProgress(0, cancel); - - const rowIdToSelectionMap = new Map(); - const rowIdToRowMap = new Map(); - - // Calculate batch threshold for processing rows in smaller chunks - const batchThreshold = Math.min(1000, threshold / 5); - let processedRows = 0; + showProgress(cancel); try { - // Process each selection range with batching - for (const range of selection) { - if (isCanceled) { - await sendCancelSummaryEvent(); - return; - } - - // Split large ranges into smaller batches - for ( - let startRow = range.fromRow; - startRow <= range.toRow; - startRow += batchThreshold - ) { - if (isCanceled) { - await sendCancelSummaryEvent(); - return; - } - - const endRow = Math.min(startRow + batchThreshold - 1, range.toRow); - const batchSize = endRow - startRow + 1; - - // Fetch the batch - const result = await this.getRows(startRow, batchSize, batchId, resultId); - - // Create a sub-range for this batch - const batchRange: ISlickRange = { - fromRow: startRow, - toRow: endRow, - fromCell: range.fromCell, - toCell: range.toCell, - }; - - this.getRowMappings( - result.resultSubset.rows, - batchRange, - rowIdToSelectionMap, - rowIdToRowMap, - ); - - processedRows += batchSize; - - if (isCanceled) { - await sendCancelSummaryEvent(); - return; - } - - showProgress(processedRows, cancel); - } - } + // Convert ISlickRange[] to TableSelectionRange[] + const simpleSelections: TableSelectionRange[] = selections.map((range) => ({ + fromRow: range.fromRow, + toRow: range.toRow, + fromColumn: range.fromCell, + toColumn: range.toCell, + })); + + const result = await this._client.sendRequest(GridSelectionSummaryRequest.type, { + ownerUri: this.uri, + batchIndex: batchId, + resultSetIndex: resultId, + rowsStartIndex: 0, + rowsCount: 0, + selections: simpleSelections, + }); if (isCanceled) { await sendCancelSummaryEvent(); return; } - // Calculate final summary - const selectionSummary = calculateSelectionSummaryFromData( - rowIdToRowMap, - rowIdToSelectionMap, - ); - let text = ""; let tooltip = ""; // the selection is numeric - if (selectionSummary.average) { + if (result.average !== undefined && result.average !== null) { + const average = result.average.toFixed(2); text = LocalizedConstants.QueryResult.numericSelectionSummary( - selectionSummary.average, - selectionSummary.count, - selectionSummary.sum, + average, + result.count, + result.sum, ); tooltip = LocalizedConstants.QueryResult.numericSelectionSummaryTooltip( - selectionSummary.average, - selectionSummary.count, - selectionSummary.distinctCount, - selectionSummary.max, - selectionSummary.min, - selectionSummary.nullCount, - selectionSummary.sum, + average, + result.count, + result.distinctCount, + result.max ?? 0, + result.min ?? 0, + result.nullCount, + result.sum, ); } else { text = LocalizedConstants.QueryResult.nonNumericSelectionSummary( - selectionSummary.count, - selectionSummary.distinctCount, - selectionSummary.nullCount, + result.count, + result.distinctCount, + result.nullCount, + ); + tooltip = LocalizedConstants.QueryResult.nonNumericSelectionSummaryTooltip( + result.count, + result.distinctCount, + result.nullCount, ); tooltip = text; } // Resolve the cancel confirmation to clean up if (!isCanceled) { - cancel.resolve(); + cancel.reject(); } this.fireSummaryChangedEvent(requestId, { diff --git a/src/models/contracts/queryExecute.ts b/src/models/contracts/queryExecute.ts index b86feec651..06b6c2489f 100644 --- a/src/models/contracts/queryExecute.ts +++ b/src/models/contracts/queryExecute.ts @@ -199,3 +199,85 @@ export class QueryConnectionUriChangeParams { newOwnerUri: string; originalOwnerUri: string; } + +// ------------------------------- < Table Selection Range > ------------------------------------ +export interface TableSelectionRange { + fromRow: number; + toRow: number; + fromColumn: number; + toColumn: number; +} + +// ------------------------------- < Grid Selection Summary Request > ------------------------------------ +export namespace GridSelectionSummaryRequest { + export const type = new RequestType< + GridSelectionSummaryRequestParams, + GridSelectionSummaryResponse, + void, + void + >("query/selectionsummary"); +} + +export namespace CancelGridSelectionSummaryNotification { + export const type = new NotificationType< + { + ownerUri: string; + }, + void + >("query/cancelSelectionSummary"); +} + +export class GridSelectionSummaryRequestParams { + ownerUri: string; + batchIndex: number; + resultSetIndex: number; + rowsStartIndex: number; + rowsCount: number; + selections: TableSelectionRange[]; +} + +export class GridSelectionSummaryResponse { + count: number; + average?: number; + sum: number; + min?: number; + max?: number; + distinctCount: number; + nullCount: number; +} + +// ------------------------------- < Copy Results 2 Request > ------------------------------------ +export enum CopyType { + Text = 0, + TextWithHeaders = 1, + JSON = 2, + CSV = 3, + INSERT = 4, + IN = 5, +} + +export namespace CopyResults2Request { + export const type = new RequestType< + CopyResults2RequestParams, + CopyResults2RequestResult, + void, + void + >("query/copy2"); +} + +export class CopyResults2RequestParams { + ownerUri: string; + batchIndex: number; + resultSetIndex: number; + rowsStartIndex: number; + rowsCount: number; + copyType: CopyType; + includeHeaders: boolean; + selections: TableSelectionRange[]; +} + +export class CopyResults2RequestResult {} + +export namespace CancelCopy2Notification { + export const type = new NotificationType("query/cancelCopy2"); +} diff --git a/src/queryResult/utils.ts b/src/queryResult/utils.ts index 5afd465115..73878f7f39 100644 --- a/src/queryResult/utils.ts +++ b/src/queryResult/utils.ts @@ -518,85 +518,6 @@ export async function selectionSummaryHelper( return summary; } -/** - * Calculate selection summary statistics from database row data - * @param rowIdToRowMap Map of row IDs to database cell value arrays - * @param rowIdToSelectionMap Map of row IDs to selection ranges - * @returns SelectionSummaryStats Summary statistics - */ -export function calculateSelectionSummaryFromData( - rowIdToRowMap: Map, - rowIdToSelectionMap: Map, -): qr.SelectionSummaryStats { - const summary: qr.SelectionSummaryStats = { - count: 0, - average: "", - sum: 0, - min: Infinity, - max: -Infinity, - removeSelectionStats: false, - distinctCount: 0, - nullCount: 0, - }; - - if (rowIdToRowMap.size === 0) { - // No data; normalize min/max to 0 to match prior behavior - summary.min = 0; - summary.max = 0; - return summary; - } - - const distinct = new Set(); - let numericCount = 0; - - const isFiniteNumber = (v: string): boolean => { - const n = Number(v); - return Number.isFinite(n); - }; - - for (const [rowId, row] of rowIdToRowMap) { - const rowSelections = rowIdToSelectionMap.get(rowId) ?? []; - for (const sel of rowSelections) { - const start = Math.max(0, sel.fromCell); - const end = Math.min(row.length - 1, sel.toCell); - for (let c = start; c <= end; c++) { - const cell = row[c]; - summary.count++; - - if (cell?.isNull) { - summary.nullCount++; - continue; - } - - const display = cell?.displayValue ?? ""; - distinct.add(display); - - if (isFiniteNumber(display)) { - const n = Number(display); - numericCount++; - summary.sum += n; - if (n < summary.min) summary.min = n; - if (n > summary.max) summary.max = n; - } else { - // There is at least one non-numeric (non-null) value - summary.removeSelectionStats = false; - } - } - } - } - - summary.distinctCount = distinct.size; - - // Only compute average when we actually saw numeric cells and round to 2 decimal places - summary.average = numericCount > 0 ? (summary.sum / numericCount).toFixed(2) : ""; - - // Normalize min/max if there were no numeric values - if (!Number.isFinite(summary.min)) summary.min = 0; - if (!Number.isFinite(summary.max)) summary.max = 0; - - return summary; -} - export function getInMemoryGridDataProcessingThreshold(): number { return ( vscode.workspace From 3bb47e7bba8bd2546c80169680857a507b476d6e Mon Sep 17 00:00:00 2001 From: Aasim Khan Date: Mon, 6 Oct 2025 22:33:23 -0700 Subject: [PATCH 02/11] Pushing loc files --- localization/l10n/bundle.l10n.json | 10 +++------- localization/xliff/vscode-mssql.xlf | 8 +++----- 2 files changed, 6 insertions(+), 12 deletions(-) diff --git a/localization/l10n/bundle.l10n.json b/localization/l10n/bundle.l10n.json index 1a275bdee7..f5ecfb8f38 100644 --- a/localization/l10n/bundle.l10n.json +++ b/localization/l10n/bundle.l10n.json @@ -1279,13 +1279,9 @@ "comment": ["{0} is the number of rows to fetch summary statistics for"] }, "Click to load summary": "Click to load summary", - "Loading summary {0}/{1} ({2}%) (Click to cancel)/{0} is the current row{1} is the total number of rows{2} is the percentage of rows loaded": { - "message": "Loading summary {0}/{1} ({2}%) (Click to cancel)", - "comment": [ - "{0} is the current row", - "{1} is the total number of rows", - "{2} is the percentage of rows loaded" - ] + "Loading summary for {0} rows (Click to cancel)/{0} is the total number of rows": { + "message": "Loading summary for {0} rows (Click to cancel)", + "comment": ["{0} is the total number of rows"] }, "Click to cancel loading summary": "Click to cancel loading summary", "Summary loading canceled": "Summary loading canceled", diff --git a/localization/xliff/vscode-mssql.xlf b/localization/xliff/vscode-mssql.xlf index 90a71534e2..f87e5807ee 100644 --- a/localization/xliff/vscode-mssql.xlf +++ b/localization/xliff/vscode-mssql.xlf @@ -1918,11 +1918,9 @@ Loading results... - - Loading summary {0}/{1} ({2}%) (Click to cancel) - {0} is the current row -{1} is the total number of rows -{2} is the percentage of rows loaded + + Loading summary for {0} rows (Click to cancel) + {0} is the total number of rows Loading tenants... From 29f12a1d3430b0574ef5e6052b43059c8cf3d9a3 Mon Sep 17 00:00:00 2001 From: Aasim Khan Date: Mon, 6 Oct 2025 23:10:23 -0700 Subject: [PATCH 03/11] remove unused contracts --- src/models/contracts/queryExecute.ts | 36 ---------------------------- 1 file changed, 36 deletions(-) diff --git a/src/models/contracts/queryExecute.ts b/src/models/contracts/queryExecute.ts index 06b6c2489f..41016fb735 100644 --- a/src/models/contracts/queryExecute.ts +++ b/src/models/contracts/queryExecute.ts @@ -245,39 +245,3 @@ export class GridSelectionSummaryResponse { distinctCount: number; nullCount: number; } - -// ------------------------------- < Copy Results 2 Request > ------------------------------------ -export enum CopyType { - Text = 0, - TextWithHeaders = 1, - JSON = 2, - CSV = 3, - INSERT = 4, - IN = 5, -} - -export namespace CopyResults2Request { - export const type = new RequestType< - CopyResults2RequestParams, - CopyResults2RequestResult, - void, - void - >("query/copy2"); -} - -export class CopyResults2RequestParams { - ownerUri: string; - batchIndex: number; - resultSetIndex: number; - rowsStartIndex: number; - rowsCount: number; - copyType: CopyType; - includeHeaders: boolean; - selections: TableSelectionRange[]; -} - -export class CopyResults2RequestResult {} - -export namespace CancelCopy2Notification { - export const type = new NotificationType("query/cancelCopy2"); -} From 820352409f94aa1cff5bbd4891956e374f48e530 Mon Sep 17 00:00:00 2001 From: Aasim Khan Date: Wed, 8 Oct 2025 13:03:08 -0700 Subject: [PATCH 04/11] getting actual selection from filtered/sorted columns --- .../pages/QueryResult/queryResultPane.tsx | 4 +- .../plugins/cellSelectionModel.plugin.ts | 6 +- .../table/plugins/headerFilter.plugin.ts | 2 + .../pages/QueryResult/table/table.ts | 7 ++ .../pages/QueryResult/table/tableDataView.ts | 7 -- .../pages/QueryResult/table/utils.ts | 89 +++++++++++++++++++ 6 files changed, 105 insertions(+), 10 deletions(-) diff --git a/src/reactviews/pages/QueryResult/queryResultPane.tsx b/src/reactviews/pages/QueryResult/queryResultPane.tsx index 24fe03503b..87bfb71876 100644 --- a/src/reactviews/pages/QueryResult/queryResultPane.tsx +++ b/src/reactviews/pages/QueryResult/queryResultPane.tsx @@ -39,6 +39,7 @@ import { QueryResultCommandsContext } from "./queryResultStateProvider"; import { useQueryResultSelector } from "./queryResultSelector"; import { ExecuteCommandRequest } from "../../../sharedInterfaces/webview"; import { ExecutionPlanGraph } from "../../../sharedInterfaces/executionPlan"; +import { SLICKGRID_ROW_ID_PROP } from "./table/utils"; const useStyles = makeStyles({ root: { @@ -337,7 +338,7 @@ export const QueryResultPane = () => { let r = response as qr.ResultSetSubset; var columnLength = resultSetSummaries[batchId][resultId]?.columnInfo?.length; - return r.rows.map((r) => { + return r.rows.map((r, rowOffset) => { let dataWithSchema: { [key: string]: any; } = {}; @@ -354,6 +355,7 @@ export const QueryResultPane = () => { isNull: cell.isNull, invariantCultureDisplayValue: displayValue, }; + dataWithSchema[SLICKGRID_ROW_ID_PROP] = offset + rowOffset; } return dataWithSchema; }); diff --git a/src/reactviews/pages/QueryResult/table/plugins/cellSelectionModel.plugin.ts b/src/reactviews/pages/QueryResult/table/plugins/cellSelectionModel.plugin.ts index 0b466192fa..cdb05e5f19 100644 --- a/src/reactviews/pages/QueryResult/table/plugins/cellSelectionModel.plugin.ts +++ b/src/reactviews/pages/QueryResult/table/plugins/cellSelectionModel.plugin.ts @@ -17,6 +17,7 @@ import { mixin } from "../objects"; import { tokens } from "@fluentui/react-components"; import { Keys } from "../../../../common/keys"; import { QueryResultReactProvider } from "../../queryResultStateProvider"; +import { convertDisplayedSelectionToActual } from "../utils"; export interface ICellSelectionModelOptions { cellRangeSelector?: any; @@ -684,7 +685,7 @@ export class CellSelectionModel e.stopPropagation(); } - private async updateSummaryText(ranges?: Slick.Range[]): Promise { + public async updateSummaryText(ranges?: Slick.Range[]): Promise { if (!ranges) { ranges = this.getSelectedRanges(); } @@ -694,8 +695,9 @@ export class CellSelectionModel toRow: range.toRow, toCell: range.toCell - 1, // adjust for number column })); + const actualRanges = convertDisplayedSelectionToActual(this.grid, simplifiedRanges); await this.context.extensionRpc.sendNotification(SetSelectionSummaryRequest.type, { - selection: simplifiedRanges, + selection: actualRanges, uri: this.uri, batchId: this.resultSetSummary.batchId, resultId: this.resultSetSummary.id, diff --git a/src/reactviews/pages/QueryResult/table/plugins/headerFilter.plugin.ts b/src/reactviews/pages/QueryResult/table/plugins/headerFilter.plugin.ts index d329f3abd0..ac5f43f678 100644 --- a/src/reactviews/pages/QueryResult/table/plugins/headerFilter.plugin.ts +++ b/src/reactviews/pages/QueryResult/table/plugins/headerFilter.plugin.ts @@ -41,6 +41,7 @@ export class HeaderFilter { grid: Slick.Grid; column: FilterableColumn; }>(); + public onSortChanged = new Slick.Event(); public onCommand = new Slick.Event>(); public enabled: boolean = true; @@ -215,6 +216,7 @@ export class HeaderFilter { } await this.updateState(columnFilterState, this.columnDef.id!); this.grid.onHeaderClick.notify(); + this.onSortChanged.notify(sortState!); }); } diff --git a/src/reactviews/pages/QueryResult/table/table.ts b/src/reactviews/pages/QueryResult/table/table.ts index 93e8628d96..e9fa0d755a 100644 --- a/src/reactviews/pages/QueryResult/table/table.ts +++ b/src/reactviews/pages/QueryResult/table/table.ts @@ -139,6 +139,13 @@ export class Table implements IThemable { this.styleElement = DOM.createStyleSheet(this._container); this._grid = new Slick.Grid(this._tableContainer, this._data, [], newOptions); this.headerFilter = new HeaderFilter(this.uri, themeKind, this.context, gridId); + this.headerFilter.onFilterApplied.subscribe(async () => { + this.selectionModel.setSelectedRanges([]); + await this.selectionModel.updateSummaryText(); + }); + this.headerFilter.onSortChanged.subscribe(async () => { + await this.selectionModel.updateSummaryText(); + }); this.registerPlugin(this.headerFilter); this.registerPlugin( new ContextMenu( diff --git a/src/reactviews/pages/QueryResult/table/tableDataView.ts b/src/reactviews/pages/QueryResult/table/tableDataView.ts index e17eaa0636..aefec58503 100644 --- a/src/reactviews/pages/QueryResult/table/tableDataView.ts +++ b/src/reactviews/pages/QueryResult/table/tableDataView.ts @@ -164,7 +164,6 @@ export class TableDataView implements IDisposableData if (this._data.length === this._allData.length) { await this.clearFilter(); } else { - console.log("filterstatechange"); // this._onFilterStateChange.fire(); } } @@ -202,7 +201,6 @@ export class TableDataView implements IDisposableData column: args.sortCol!, sortDirection: args.sortAsc ? "sort-asc" : "sort-desc", }; - console.log(args); // this._onSortComplete.fire(args); } @@ -262,7 +260,6 @@ export class TableDataView implements IDisposableData } else { this._data.push(...inputArray); } - console.log(this.getLength()); // this._onRowCountChange.fire(this.getLength()); } @@ -271,7 +268,6 @@ export class TableDataView implements IDisposableData if (this._filterEnabled) { this._allData = new Array(); } - console.log(this.getLength()); // this._onRowCountChange.fire(this.getLength()); } @@ -281,7 +277,6 @@ export class TableDataView implements IDisposableData } this._findArray = new Array(); this._findIndex = 0; - console.log(this._findArray.length); // this._onFindCountChange.fire(this._findArray.length); if (exp) { return new Promise(() => { @@ -302,7 +297,6 @@ export class TableDataView implements IDisposableData const pos = result[j]; const index = { col: pos, row: i }; this._findArray!.push(index); - console.log(this._findArray!.length); // this._onFindCountChange.fire(this._findArray!.length); if (maxMatches > 0 && this._findArray!.length === maxMatches) { breakout = true; @@ -320,7 +314,6 @@ export class TableDataView implements IDisposableData clearFind() { this._findArray = new Array(); this._findIndex = 0; - console.log(this._findArray.length); // this._onFindCountChange.fire(this._findArray.length); } diff --git a/src/reactviews/pages/QueryResult/table/utils.ts b/src/reactviews/pages/QueryResult/table/utils.ts index 58abfc82bd..a61036bed6 100644 --- a/src/reactviews/pages/QueryResult/table/utils.ts +++ b/src/reactviews/pages/QueryResult/table/utils.ts @@ -4,6 +4,95 @@ *--------------------------------------------------------------------------------------------*/ import { ISlickRange } from "../../../../sharedInterfaces/queryResult"; +import { FilterableColumn } from "./interfaces"; + +export const SLICKGRID_ROW_ID_PROP = "_mssqlRowId"; + +function hasSortOrFilterApplied(grid: Slick.Grid): boolean { + const sortedColumns = grid.getSortColumns(); + + const columns = grid.getColumns() as FilterableColumn[]; + + return columns.some((column) => { + if (!column) { + return false; + } + + const isFiltered = column?.filterValues?.length ?? 0 > 0; + const isSorted = sortedColumns?.some( + (sort) => sort.columnId === column.id && sort.sortAsc !== undefined, + ); + + return isFiltered || isSorted; + }); +} + +function getActualRowIndex(grid: Slick.Grid, displayRow: number): number | undefined { + const item = grid.getDataItem(displayRow) as Record; + if (!item) { + return undefined; + } + return item[SLICKGRID_ROW_ID_PROP] as number; +} + +export function convertDisplayedSelectionToActual( + grid: Slick.Grid, + selections: ISlickRange[], +): ISlickRange[] { + if (selections.length === 0) { + return selections; + } + const actualSelections: ISlickRange[] = []; + const shouldMapRows = hasSortOrFilterApplied(grid); + + if (!shouldMapRows) { + return selections; + } + + for (const selection of selections) { + const actualRows = new Set(); + + for (let displayRow = selection.fromRow; displayRow <= selection.toRow; displayRow++) { + const actualRow = getActualRowIndex(grid, displayRow); + actualRows.add(actualRow ?? displayRow); + } + + const orderedRows = Array.from(actualRows.values()).sort((a, b) => a - b); + if (orderedRows.length === 0) { + continue; + } + + let rangeStart = orderedRows[0]; + let previous = orderedRows[0]; + + for (let i = 1; i < orderedRows.length; i++) { + const current = orderedRows[i]; + if (current <= previous + 1) { + previous = current; + continue; + } + + actualSelections.push({ + fromCell: selection.fromCell, + toCell: selection.toCell, + fromRow: rangeStart, + toRow: previous, + }); + + rangeStart = current; + previous = current; + } + + actualSelections.push({ + fromCell: selection.fromCell, + toCell: selection.toCell, + fromRow: rangeStart, + toRow: previous, + }); + } + + return actualSelections; +} export interface RowRange { start: number; From 8075e3ace4ee01a8256177f7b13fe5a83cb541f9 Mon Sep 17 00:00:00 2001 From: Aasim Khan Date: Thu, 9 Oct 2025 15:07:29 -0700 Subject: [PATCH 05/11] removing copying code from frontend and using STS api. --- src/constants/constants.ts | 2 +- src/constants/locConstants.ts | 8 + src/controllers/queryRunner.ts | 854 +---------- src/models/contracts/queryExecute.ts | 37 + src/models/sqlOutputContentProvider.ts | 21 +- src/queryResult/utils.ts | 45 +- .../table/plugins/contextMenu.plugin.ts | 120 +- .../table/plugins/copyKeybind.plugin.ts | 55 +- .../pages/QueryResult/table/table.ts | 20 +- src/sharedInterfaces/queryResult.ts | 20 +- test/unit/queryRunner.test.ts | 1347 +---------------- 11 files changed, 189 insertions(+), 2340 deletions(-) diff --git a/src/constants/constants.ts b/src/constants/constants.ts index a3ac04f5e3..a79408998d 100644 --- a/src/constants/constants.ts +++ b/src/constants/constants.ts @@ -199,7 +199,7 @@ export const copilotShowSchemaToolName = "mssql_show_schema"; export const copilotGetConnectionDetailsToolName = "mssql_get_connection_details"; // Configuration Constants -export const copyIncludeHeaders = "copyIncludeHeaders"; +export const copyIncludeHeaders = "mssql.copyIncludeHeaders"; export const configLogDebugInfo = "logDebugInfo"; export const configMyConnections = "connections"; export const configSaveAsCsv = "saveAsCsv"; diff --git a/src/constants/locConstants.ts b/src/constants/locConstants.ts index 8682946b34..7b8e08d194 100644 --- a/src/constants/locConstants.ts +++ b/src/constants/locConstants.ts @@ -584,6 +584,8 @@ export let executionPlan = l10n.t("Execution Plan"); export let executionPlanFileFilter = l10n.t("SQL Plan Files"); export let scriptCopiedToClipboard = l10n.t("Script copied to clipboard"); export let copied = l10n.t("Copied"); +export let copyingResults = l10n.t("Copying results..."); +export let resultsCopiedToClipboard = l10n.t("Results copied to clipboard"); export let openQueryResultsInTabByDefaultPrompt = l10n.t( "Do you want to always display query results in a new tab instead of the query pane?", @@ -1039,6 +1041,12 @@ export class QueryResult { }), ].join(os.EOL); }; + public static copyError = (error: string) => + l10n.t({ + message: "An error occurred while copying results: {0}", + args: [error], + comment: ["{0} is the error message"], + }); public static summaryFetchConfirmation = (numRows: number) => l10n.t({ message: "{0} rows selected, click to load summary", diff --git a/src/controllers/queryRunner.ts b/src/controllers/queryRunner.ts index 59e64722fe..3f7e3fb3ab 100644 --- a/src/controllers/queryRunner.ts +++ b/src/controllers/queryRunner.ts @@ -26,13 +26,16 @@ import { QueryExecuteOptionsRequest, QueryExecutionOptionsParams, QueryExecutionOptions, - DbCellValue, ExecutionPlanOptions, QueryConnectionUriChangeRequest, QueryConnectionUriChangeParams, GridSelectionSummaryRequest, TableSelectionRange, CancelGridSelectionSummaryNotification, + CopyResults2Request, + CopyResults2RequestParams, + CopyType, + CancelCopy2Notification, } from "../models/contracts/queryExecute"; import { QueryDisposeParams, QueryDisposeRequest } from "../models/contracts/queryDispose"; import { @@ -643,156 +646,76 @@ export default class QueryRunner { resultId: number, includeHeaders?: boolean, ): Promise { - let copyString = ""; - - if (this.shouldIncludeHeaders(includeHeaders)) { - copyString = this.addHeadersToCopyString(copyString, batchId, resultId, selection); - } - // sort the selections by row to maintain copy order - selection.sort((a, b) => a.fromRow - b.fromRow); - - // create a mapping of rows to selections - let rowIdToSelectionMap = new Map(); - let rowIdToRowMap = new Map(); - - // create a mapping of the ranges to get promises - let tasks = selection.map((range) => { - return async () => { - const result = await this.getRows( - range.fromRow, - range.toRow - range.fromRow + 1, - batchId, - resultId, - ); - this.getRowMappings( - result.resultSubset.rows, - range, - rowIdToSelectionMap, - rowIdToRowMap, - ); - }; - }); - - // get all the rows - let p = tasks[0](); - for (let i = 1; i < tasks.length; i++) { - p = p.then(tasks[i]); - } - await p; - - copyString = this.constructCopyString(copyString, rowIdToRowMap, rowIdToSelectionMap); - - await this.writeStringToClipboard(copyString); - } - - public async exportCellsToClipboard( - data: DbCellValue[][], - batchId: number, - resultId: number, - selection: ISlickRange[], - headersFlag, - ) { - let copyString = ""; - if (headersFlag) { - copyString = this.addHeadersToCopyString(copyString, batchId, resultId, selection); - } - - // create a mapping of rows to selections - let rowIdToSelectionMap = new Map(); - let rowIdToRowMap = new Map(); - - // create a mapping of the ranges to get promises - let tasks = selection.map((range) => { - return async () => { - const result = data; - this.getRowMappings(result, range, rowIdToSelectionMap, rowIdToRowMap); - }; + await this.copyResults2(selection, batchId, resultId, CopyType.Text, { + includeHeaders: includeHeaders ?? false, }); - let p = tasks[0](); - for (let i = 1; i < tasks.length; i++) { - p = p.then(tasks[i]); - } - await p; - - copyString = this.constructCopyString(copyString, rowIdToRowMap, rowIdToSelectionMap); - - await this.writeStringToClipboard(copyString); } /** - * Construct the row mappings, which contain the row data and selection data and are used to construct the copy string - * @param data - * @param range - * @param rowIdToSelectionMap - * @param rowIdToRowMap + * Copy the result range using the query/copy2 contract */ - private getRowMappings( - data: DbCellValue[][], - range: ISlickRange, - rowIdToSelectionMap, - rowIdToRowMap, - ) { - let count = 0; - for (let row of data) { - let rowNumber = count + range.fromRow; - if (rowIdToSelectionMap.has(rowNumber)) { - let rowSelection = rowIdToSelectionMap.get(rowNumber); - rowSelection.push(range); - } else { - rowIdToSelectionMap.set(rowNumber, [range]); - } - rowIdToRowMap.set(rowNumber, row); - count += 1; - } - } - - private constructCopyString( - copyString: string, - rowIdToRowMap: Map, - rowIdToSelectionMap: Map, - ) { - // Go through all rows and get selections for them - let allRowIds = rowIdToRowMap.keys(); - const endColumns = this.getSelectionEndColumns(rowIdToRowMap, rowIdToSelectionMap); - const firstColumn = endColumns[0]; - const lastColumn = endColumns[1]; - for (let rowId of allRowIds) { - let row = rowIdToRowMap.get(rowId); - const rowSelections = rowIdToSelectionMap.get(rowId); - - // sort selections by column to go from left to right - rowSelections.sort((a, b) => { - return a.fromCell < b.fromCell ? -1 : a.fromCell > b.fromCell ? 1 : 0; - }); - - for (let i = 0; i < rowSelections.length; i++) { - let rowSelection = rowSelections[i]; - - // Add tabs starting from the first column of the selection - for (let j = firstColumn; j < rowSelection.fromCell; j++) { - copyString += "\t"; - } - let cellObjects = row.slice(rowSelection.fromCell, rowSelection.toCell + 1); - - // Remove newlines if requested - let cells = this.shouldRemoveNewLines() - ? cellObjects.map((x) => this.removeNewLines(x.displayValue)) - : cellObjects.map((x) => x.displayValue); - copyString += cells.join("\t"); - - // Add tabs until the end column of the selection - for (let k = rowSelection.toCell; k < lastColumn; k++) { - copyString += "\t"; - } - } - copyString += editorEol; - } - - // Remove the last extra new line - if (copyString.length > 1) { - copyString = copyString.substring(0, copyString.length - editorEol.length); - } - return copyString; + private async copyResults2( + selection: ISlickRange[], + batchId: number, + resultId: number, + copyType: CopyType, + options?: { + includeHeaders?: boolean; + delimiter?: string; + lineSeparator?: string; + textIdentifier?: string; + encoding?: string; + }, + ): Promise { + await vscode.window.withProgress( + { + location: vscode.ProgressLocation.Notification, + title: LocalizedConstants.copyingResults, + cancellable: true, + }, + async (_progress, token) => { + return new Promise(async (resolve, reject) => { + try { + token.onCancellationRequested(async () => { + await this._client.sendNotification(CancelCopy2Notification.type); + vscode.window.showInformationMessage("Copying results cancelled"); + resolve(); + }); + + const selections: TableSelectionRange[] = selection.map((range) => ({ + fromRow: range.fromRow, + toRow: range.toRow, + fromColumn: range.fromCell, + toColumn: range.toCell, + })); + + const params: CopyResults2RequestParams = { + ownerUri: this.uri, + batchIndex: batchId, + resultSetIndex: resultId, + copyType, + includeHeaders: options?.includeHeaders ?? false, + selections, + delimiter: options?.delimiter, + lineSeparator: options?.lineSeparator ?? editorEol, + textIdentifier: options?.textIdentifier, + encoding: options?.encoding, + }; + + await this._client.sendRequest(CopyResults2Request.type, params); + vscode.window.showInformationMessage( + LocalizedConstants.resultsCopiedToClipboard, + ); + resolve(); + } catch (error) { + vscode.window.showErrorMessage( + LocalizedConstants.QueryResult.copyError(getErrorMessage(error)), + ); + reject(error); + } + }); + }, + ); } /** @@ -855,71 +778,23 @@ export default class QueryRunner { selection: ISlickRange[], batchId: number, resultId: number, - includeHeaders?: boolean, ): Promise { - // Get CSV configuration const config = this._vscodeWrapper.getConfiguration(Constants.extensionConfigSectionName); const csvConfig = config[Constants.configSaveAsCsv] || {}; const delimiter = csvConfig.delimiter || ","; const textIdentifier = csvConfig.textIdentifier || '"'; - const lineSeperator = csvConfig.lineSeperator || editorEol; - - let copyString = ""; + const lineSeparator = csvConfig.lineSeperator || editorEol; + const encoding = csvConfig.encoding; + const includeHeaders = csvConfig.includeHeaders; - if (this.shouldIncludeHeaders(includeHeaders)) { - copyString = this.addHeadersToCsvString( - copyString, - batchId, - resultId, - selection, - delimiter, - textIdentifier, - ); - } - - // sort the selections by row to maintain copy order - selection.sort((a, b) => a.fromRow - b.fromRow); - - // create a mapping of rows to selections - let rowIdToSelectionMap = new Map(); - let rowIdToRowMap = new Map(); - - // create a mapping of the ranges to get promises - let tasks = selection.map((range) => { - return async () => { - const result = await this.getRows( - range.fromRow, - range.toRow - range.fromRow + 1, - batchId, - resultId, - ); - this.getRowMappings( - result.resultSubset.rows, - range, - rowIdToSelectionMap, - rowIdToRowMap, - ); - }; - }); - - // get all the rows - let p = tasks[0](); - for (let i = 1; i < tasks.length; i++) { - p = p.then(tasks[i]); - } - await p; - - copyString = this.constructCsvString( - copyString, - rowIdToRowMap, - rowIdToSelectionMap, + await this.copyResults2(selection, batchId, resultId, CopyType.CSV, { + includeHeaders: includeHeaders, delimiter, textIdentifier, - lineSeperator, - ); - - await this.writeStringToClipboard(copyString); + lineSeparator, + encoding, + }); } /** @@ -933,48 +808,10 @@ export default class QueryRunner { selection: ISlickRange[], batchId: number, resultId: number, - includeHeaders?: boolean, ): Promise { - // sort the selections by row to maintain copy order - selection.sort((a, b) => a.fromRow - b.fromRow); - - // create a mapping of rows to selections - let rowIdToSelectionMap = new Map(); - let rowIdToRowMap = new Map(); - - // create a mapping of the ranges to get promises - let tasks = selection.map((range) => { - return async () => { - const result = await this.getRows( - range.fromRow, - range.toRow - range.fromRow + 1, - batchId, - resultId, - ); - this.getRowMappings( - result.resultSubset.rows, - range, - rowIdToSelectionMap, - rowIdToRowMap, - ); - }; + await this.copyResults2(selection, batchId, resultId, CopyType.JSON, { + includeHeaders: true, }); - - // get all the rows - let p = tasks[0](); - for (let i = 1; i < tasks.length; i++) { - p = p.then(tasks[i]); - } - await p; - - const jsonString = this.constructJsonString( - rowIdToRowMap, - rowIdToSelectionMap, - batchId, - resultId, - ); - - await this.writeStringToClipboard(jsonString); } public async copyResultsAsInClause( @@ -982,41 +819,7 @@ export default class QueryRunner { batchId: number, resultId: number, ): Promise { - // sort the selections by row to maintain copy order - selection.sort((a, b) => a.fromRow - b.fromRow); - - // create a mapping of rows to selections - let rowIdToSelectionMap = new Map(); - let rowIdToRowMap = new Map(); - - // create a mapping of the ranges to get promises - let tasks = selection.map((range) => { - return async () => { - const result = await this.getRows( - range.fromRow, - range.toRow - range.fromRow + 1, - batchId, - resultId, - ); - this.getRowMappings( - result.resultSubset.rows, - range, - rowIdToSelectionMap, - rowIdToRowMap, - ); - }; - }); - - // get all the rows - let p = tasks[0](); - for (let i = 1; i < tasks.length; i++) { - p = p.then(tasks[i]); - } - await p; - - const inClauseString = this.constructInClauseString(rowIdToRowMap, rowIdToSelectionMap); - - await this.writeStringToClipboard(inClauseString); + await this.copyResults2(selection, batchId, resultId, CopyType.IN); } public async copyResultsAsInsertInto( @@ -1024,46 +827,9 @@ export default class QueryRunner { batchId: number, resultId: number, ): Promise { - // sort the selections by row to maintain copy order - selection.sort((a, b) => a.fromRow - b.fromRow); - - // create a mapping of rows to selections - let rowIdToSelectionMap = new Map(); - let rowIdToRowMap = new Map(); - - // create a mapping of the ranges to get promises - let tasks = selection.map((range) => { - return async () => { - const result = await this.getRows( - range.fromRow, - range.toRow - range.fromRow + 1, - batchId, - resultId, - ); - this.getRowMappings( - result.resultSubset.rows, - range, - rowIdToSelectionMap, - rowIdToRowMap, - ); - }; + await this.copyResults2(selection, batchId, resultId, CopyType.INSERT, { + includeHeaders: true, }); - - // get all the rows - let p = tasks[0](); - for (let i = 1; i < tasks.length; i++) { - p = p.then(tasks[i]); - } - await p; - - const insertIntoString = this.constructInsertIntoString( - rowIdToRowMap, - rowIdToSelectionMap, - batchId, - resultId, - ); - - await this.writeStringToClipboard(insertIntoString); } private _requestID: string; @@ -1259,39 +1025,6 @@ export default class QueryRunner { return true; } - private shouldIncludeHeaders(includeHeaders: boolean): boolean { - if (includeHeaders !== undefined) { - // Respect the value explicity passed into the method - return includeHeaders; - } - // else get config option from vscode config - let config = this._vscodeWrapper.getConfiguration( - Constants.extensionConfigSectionName, - this.uri, - ); - includeHeaders = config.get(Constants.copyIncludeHeaders); - return !!includeHeaders; - } - - private shouldRemoveNewLines(): boolean { - // get config copyRemoveNewLine option from vscode config - let config = this._vscodeWrapper.getConfiguration( - Constants.extensionConfigSectionName, - this.uri, - ); - let removeNewLines: boolean = config.get(Constants.configCopyRemoveNewLine); - return removeNewLines; - } - - private removeNewLines(inputString: string): string { - // This regex removes all newlines in all OS types - // Windows(CRLF): \r\n - // Linux(LF)/Modern MacOS: \n - // Old MacOs: \r - let outputString: string = inputString.replace(/(\r\n|\n|\r)/gm, ""); - return outputString; - } - private sendBatchTimeMessage(batchId: number, executionTime: string): void { // get config copyRemoveNewLine option from vscode config let config = this._vscodeWrapper.getConfiguration( @@ -1310,30 +1043,6 @@ export default class QueryRunner { } } - /** - * Gets the first and last column of a selection: [first, last] - */ - private getSelectionEndColumns( - rowIdToRowMap: Map, - rowIdToSelectionMap: Map, - ): number[] { - let allRowIds = rowIdToRowMap.keys(); - let firstColumn = -1; - let lastColumn = -1; - for (let rowId of allRowIds) { - const rowSelections = rowIdToSelectionMap.get(rowId); - for (let i = 0; i < rowSelections.length; i++) { - if (firstColumn === -1 || rowSelections[i].fromCell < firstColumn) { - firstColumn = rowSelections[i].fromCell; - } - if (lastColumn === -1 || rowSelections[i].toCell > lastColumn) { - lastColumn = rowSelections[i].toCell; - } - } - } - return [firstColumn, lastColumn]; - } - /** * Sets a selection range in the editor for this query * @param selection The selection range to select @@ -1409,42 +1118,6 @@ export default class QueryRunner { * @param textIdentifier * @returns */ - private addHeadersToCsvString( - copyString: string, - batchId: number, - resultId: number, - selection: ISlickRange[], - delimiter: string, - textIdentifier: string, - ): string { - // add the column headers - let firstCol: number; - let lastCol: number; - for (let range of selection) { - if (firstCol === undefined || range.fromCell < firstCol) { - firstCol = range.fromCell; - } - if (lastCol === undefined || range.toCell > lastCol) { - lastCol = range.toCell; - } - } - let columnRange: ISlickRange = { - fromCell: firstCol, - toCell: lastCol, - fromRow: undefined, - toRow: undefined, - }; - let columnHeaders = this.getColumnHeaders(batchId, resultId, columnRange); - - // Format headers with proper CSV escaping - const escapedHeaders = columnHeaders.map((header) => - this.escapeCsvValue(header, textIdentifier), - ); - copyString += escapedHeaders.join(delimiter); - copyString += editorEol; - return copyString; - } - /** * Construct CSV string from row data * @param copyString @@ -1455,69 +1128,6 @@ export default class QueryRunner { * @param lineSeperator * @returns */ - private constructCsvString( - copyString: string, - rowIdToRowMap: Map, - rowIdToSelectionMap: Map, - delimiter: string, - textIdentifier: string, - lineSeperator: string, - ): string { - // Go through all rows and get selections for them - let allRowIds = Array.from(rowIdToRowMap.keys()).sort((a, b) => a - b); - const endColumns = this.getSelectionEndColumns(rowIdToRowMap, rowIdToSelectionMap); - const firstColumn = endColumns[0]; - const lastColumn = endColumns[1]; - - for (let rowId of allRowIds) { - let row = rowIdToRowMap.get(rowId); - const rowSelections = rowIdToSelectionMap.get(rowId); - - // sort selections by column to go from left to right - rowSelections.sort((a, b) => { - return a.fromCell < b.fromCell ? -1 : a.fromCell > b.fromCell ? 1 : 0; - }); - - let rowValues: string[] = []; - - for (let i = 0; i < rowSelections.length; i++) { - let rowSelection = rowSelections[i]; - - // Add empty values for gaps before this selection - while (rowValues.length < rowSelection.fromCell - firstColumn) { - rowValues.push(""); - } - - let cellObjects = row.slice(rowSelection.fromCell, rowSelection.toCell + 1); - let cells = cellObjects.map((x) => { - // For null values, use empty string instead of the displayValue (which contains "null") - let displayValue = x.isNull - ? "" - : this.shouldRemoveNewLines() - ? this.removeNewLines(x.displayValue) - : x.displayValue; - return this.escapeCsvValue(displayValue, textIdentifier); - }); - - rowValues.push(...cells); - } - - // Add empty values for gaps after the last selection - while (rowValues.length < lastColumn - firstColumn + 1) { - rowValues.push(""); - } - - copyString += rowValues.join(delimiter); - copyString += lineSeperator; - } - - // Remove the last extra line separator - if (copyString.length > lineSeperator.length) { - copyString = copyString.substring(0, copyString.length - lineSeperator.length); - } - return copyString; - } - /** * Construct JSON string from row data * @param rowIdToRowMap @@ -1527,306 +1137,6 @@ export default class QueryRunner { * @param includeHeaders * @returns */ - private constructJsonString( - rowIdToRowMap: Map, - rowIdToSelectionMap: Map, - batchId: number, - resultId: number, - ): string { - // Get column headers for property names - let allRowIds = Array.from(rowIdToRowMap.keys()).sort((a, b) => a - b); - if (allRowIds.length === 0) { - return "[]"; - } - - const endColumns = this.getSelectionEndColumns(rowIdToRowMap, rowIdToSelectionMap); - const firstColumn = endColumns[0]; - const lastColumn = endColumns[1]; - - let columnRange: ISlickRange = { - fromCell: firstColumn, - toCell: lastColumn, - fromRow: undefined, - toRow: undefined, - }; - let columnHeaders = this.getColumnHeaders(batchId, resultId, columnRange); - - let jsonArray: any[] = []; - - for (let rowId of allRowIds) { - let row = rowIdToRowMap.get(rowId); - const rowSelections = rowIdToSelectionMap.get(rowId); - - // sort selections by column to go from left to right - rowSelections.sort((a, b) => { - return a.fromCell < b.fromCell ? -1 : a.fromCell > b.fromCell ? 1 : 0; - }); - - let jsonObject: any = {}; - let columnIndex = 0; - - for (let i = 0; i < rowSelections.length; i++) { - let rowSelection = rowSelections[i]; - - // Add null values for gaps before this selection - while (columnIndex < rowSelection.fromCell - firstColumn) { - jsonObject[columnHeaders[columnIndex]] = null; - columnIndex++; - } - - let cellObjects = row.slice(rowSelection.fromCell, rowSelection.toCell + 1); - for (let cellObject of cellObjects) { - let value: any; - if (cellObject.isNull) { - // For null values, use proper JSON null instead of parsing displayValue - value = null; - } else { - let displayValue = this.shouldRemoveNewLines() - ? this.removeNewLines(cellObject.displayValue) - : cellObject.displayValue; - - // Try to parse numeric and boolean values - value = this.parseJsonValue(displayValue); - } - jsonObject[columnHeaders[columnIndex]] = value; - columnIndex++; - } - } - - // Add null values for gaps after the last selection - while (columnIndex < columnHeaders.length) { - jsonObject[columnHeaders[columnIndex]] = null; - columnIndex++; - } - - jsonArray.push(jsonObject); - } - - return JSON.stringify(jsonArray, null, 2); - } - - private constructInClauseString( - rowIdToRowMap: Map, - rowIdToSelectionMap: Map, - ): string { - let allRowIds = Array.from(rowIdToRowMap.keys()).sort((a, b) => a - b); - if (allRowIds.length === 0) { - return "IN ()"; - } - - let values: string[] = []; - - for (let rowId of allRowIds) { - let row = rowIdToRowMap.get(rowId); - const rowSelections = rowIdToSelectionMap.get(rowId); - - // sort selections by column to go from left to right - rowSelections.sort((a, b) => { - return a.fromCell < b.fromCell ? -1 : a.fromCell > b.fromCell ? 1 : 0; - }); - - for (let i = 0; i < rowSelections.length; i++) { - let rowSelection = rowSelections[i]; - let cellObjects = row.slice(rowSelection.fromCell, rowSelection.toCell + 1); - - for (let cellObject of cellObjects) { - if (cellObject.isNull) { - values.push("NULL"); - } else { - let displayValue = this.shouldRemoveNewLines() - ? this.removeNewLines(cellObject.displayValue) - : cellObject.displayValue; - - // Check if the value is numeric - if ( - !isNaN(Number(displayValue)) && - isFinite(Number(displayValue)) && - displayValue.trim() !== "" - ) { - values.push(displayValue); - } else { - // Escape single quotes and wrap in single quotes for string values - let escapedValue = displayValue.replace(/'/g, "''"); - values.push(`'${escapedValue}'`); - } - } - } - } - } - - return `IN${editorEol}(${editorEol}${values.map((value) => ` ${value}`).join(`,${editorEol}`)}${editorEol})`; - } - - private constructInsertIntoString( - rowIdToRowMap: Map, - rowIdToSelectionMap: Map, - batchId: number, - resultId: number, - ): string { - let allRowIds = Array.from(rowIdToRowMap.keys()).sort((a, b) => a - b); - if (allRowIds.length === 0) { - return `INSERT INTO [TableName] VALUES${editorEol}();`; - } - - // Get column information for headers - const endColumns = this.getSelectionEndColumns(rowIdToRowMap, rowIdToSelectionMap); - const firstColumn = endColumns[0]; - const lastColumn = endColumns[1]; - - let columnRange: ISlickRange = { - fromCell: firstColumn, - toCell: lastColumn, - fromRow: undefined, - toRow: undefined, - }; - let columnHeaders = this.getColumnHeaders(batchId, resultId, columnRange); - - // Build column list for INSERT INTO - const columnList = columnHeaders.map((header) => `[${header}]`).join(", "); - - // Process all rows and extract their values - let allRowValues: string[][] = []; - - for (let rowId of allRowIds) { - let row = rowIdToRowMap.get(rowId); - const rowSelections = rowIdToSelectionMap.get(rowId); - - // sort selections by column to go from left to right - rowSelections.sort((a, b) => { - return a.fromCell < b.fromCell ? -1 : a.fromCell > b.fromCell ? 1 : 0; - }); - - let values: string[] = []; - let columnIndex = 0; - - for (let i = 0; i < rowSelections.length; i++) { - let rowSelection = rowSelections[i]; - - // Add NULL values for gaps before this selection - while (columnIndex < rowSelection.fromCell - firstColumn) { - values.push("NULL"); - columnIndex++; - } - - let cellObjects = row.slice(rowSelection.fromCell, rowSelection.toCell + 1); - - for (let cellObject of cellObjects) { - if (cellObject.isNull) { - values.push("NULL"); - } else { - let displayValue = this.shouldRemoveNewLines() - ? this.removeNewLines(cellObject.displayValue) - : cellObject.displayValue; - - // Check if the value is numeric - if ( - !isNaN(Number(displayValue)) && - isFinite(Number(displayValue)) && - displayValue.trim() !== "" - ) { - values.push(displayValue); - } else { - // Escape single quotes and wrap in single quotes for string values - let escapedValue = displayValue.replace(/'/g, "''"); - values.push(`'${escapedValue}'`); - } - } - columnIndex++; - } - } - - // Add NULL values for gaps after the last selection - while (columnIndex < columnHeaders.length) { - values.push("NULL"); - columnIndex++; - } - - allRowValues.push(values); - } - - // SQL Server limit: 1000 rows per INSERT VALUES statement - const maxRowsPerInsert = 1000; - - if (allRowValues.length <= maxRowsPerInsert) { - // Use single INSERT INTO ... VALUES statement - const valueRows = allRowValues.map((values) => ` (${values.join(", ")})`); - return `INSERT INTO [TableName] (${columnList})${editorEol}VALUES${editorEol}${valueRows.join(`,${editorEol}`)};`; - } else { - // Break into individual INSERT statements - const insertStatements: string[] = []; - - for (let i = 0; i < allRowValues.length; i += maxRowsPerInsert) { - const chunk = allRowValues.slice(i, i + maxRowsPerInsert); - const valueRows = chunk.map((values) => ` (${values.join(", ")})`); - insertStatements.push( - `INSERT INTO [TableName] (${columnList})${editorEol}VALUES${editorEol}${valueRows.join(`,${editorEol}`)};`, - ); - } - - return insertStatements.join(`${editorEol}${editorEol}`); - } - } - - /** - * Escape a value for CSV format - * @param value - * @param textIdentifier - * @returns - */ - private escapeCsvValue(value: string, textIdentifier: string): string { - if (value === null || value === undefined) { - return ""; - } - - let stringValue = String(value); - - // Check if the value contains delimiter, newlines, or text identifier - if ( - stringValue.includes(",") || - stringValue.includes("\n") || - stringValue.includes("\r") || - stringValue.includes(textIdentifier) - ) { - // Escape text identifier by doubling it - stringValue = stringValue.replace( - new RegExp(textIdentifier, "g"), - textIdentifier + textIdentifier, - ); - - // Wrap in text identifier - return textIdentifier + stringValue + textIdentifier; - } - - return stringValue; - } - - /** - * Parse a string value to appropriate JSON type - * @param value - * @returns - */ - private parseJsonValue(value: string): any { - if (value === null || value === undefined || value === "") { - return null; - } - - // Try to parse as boolean - if (value.toLowerCase() === "true") { - return true; - } - if (value.toLowerCase() === "false") { - return false; - } - - // Try to parse as number - if (!isNaN(Number(value)) && value.trim() !== "") { - return Number(value); - } - - // Return as string - return value; - } - /** * Vscode core expects uri.fsPath for resourcePath context value. * https://github.com/microsoft/vscode/blob/bb5a3c607b14787009f8e9fadb720beee596133c/src/vs/workbench/common/contextkeys.ts#L275 diff --git a/src/models/contracts/queryExecute.ts b/src/models/contracts/queryExecute.ts index 41016fb735..78a693a0bf 100644 --- a/src/models/contracts/queryExecute.ts +++ b/src/models/contracts/queryExecute.ts @@ -245,3 +245,40 @@ export class GridSelectionSummaryResponse { distinctCount: number; nullCount: number; } + +// ------------------------------- < Copy Results 2 Request > ------------------------------------ +export enum CopyType { + Text = 0, + JSON = 1, + CSV = 2, + INSERT = 3, + IN = 4, +} + +export namespace CopyResults2Request { + export const type = new RequestType< + CopyResults2RequestParams, + CopyResults2RequestResult, + void, + void + >("query/copy2"); +} + +export class CopyResults2RequestParams { + ownerUri: string; + batchIndex: number; + resultSetIndex: number; + copyType: CopyType; + includeHeaders: boolean; + selections: TableSelectionRange[]; + delimiter?: string; + lineSeparator?: string; + textIdentifier?: string; + encoding?: string; +} + +export class CopyResults2RequestResult {} + +export namespace CancelCopy2Notification { + export const type = new NotificationType("query/cancelCopy2"); +} diff --git a/src/models/sqlOutputContentProvider.ts b/src/models/sqlOutputContentProvider.ts index dc40598f77..8bcca15297 100644 --- a/src/models/sqlOutputContentProvider.ts +++ b/src/models/sqlOutputContentProvider.ts @@ -11,7 +11,7 @@ import QueryRunner from "../controllers/queryRunner"; import ResultsSerializer from "../models/resultsSerializer"; import StatusView from "../views/statusView"; import VscodeWrapper from "./../controllers/vscodeWrapper"; -import { ISelectionData, ISlickRange } from "./interfaces"; +import { ISelectionData } from "./interfaces"; import { Deferred } from "../protocol"; import { ExecutionPlanOptions, ResultSetSubset, ResultSetSummary } from "./contracts/queryExecute"; import { sendActionEvent } from "../telemetry/telemetry"; @@ -183,29 +183,15 @@ export class SqlOutputContentProvider { .queryRunner.copyResults(selection, batchId, resultId, includeHeaders); } - public sendToClipboard( - uri: string, - data: qr.DbCellValue[][], - batchId: number, - resultId: number, - selection: ISlickRange[], - headersFlag: boolean, - ): void { - void this._queryResultsMap - .get(uri) - .queryRunner.exportCellsToClipboard(data, batchId, resultId, selection, headersFlag); - } - public copyAsCsvRequestHandler( uri: string, batchId: number, resultId: number, selection: Interfaces.ISlickRange[], - includeHeaders?: boolean, ): void { void this._queryResultsMap .get(uri) - .queryRunner.copyResultsAsCsv(selection, batchId, resultId, includeHeaders); + .queryRunner.copyResultsAsCsv(selection, batchId, resultId); } public copyAsJsonRequestHandler( @@ -213,11 +199,10 @@ export class SqlOutputContentProvider { batchId: number, resultId: number, selection: Interfaces.ISlickRange[], - includeHeaders?: boolean, ): void { void this._queryResultsMap .get(uri) - .queryRunner.copyResultsAsJson(selection, batchId, resultId, includeHeaders); + .queryRunner.copyResultsAsJson(selection, batchId, resultId); } public copyAsInClauseRequestHandler( diff --git a/src/queryResult/utils.ts b/src/queryResult/utils.ts index 73878f7f39..c28a07650d 100644 --- a/src/queryResult/utils.ts +++ b/src/queryResult/utils.ts @@ -115,22 +115,6 @@ export function registerCommonRequestHandlers( ); }); - webviewController.onRequest(qr.SendToClipboardRequest.type, async (message) => { - sendActionEvent(TelemetryViews.QueryResult, TelemetryActions.CopyResults, { - correlationId: correlationId, - }); - return webviewViewController - .getSqlOutputContentProvider() - .sendToClipboard( - message.uri, - message.data, - message.batchId, - message.resultId, - message.selection, - message.headersFlag, - ); - }); - webviewController.onRequest(qr.CopySelectionRequest.type, async (message) => { sendActionEvent(TelemetryViews.QueryResult, TelemetryActions.CopyResults, { correlationId: correlationId, @@ -142,26 +126,10 @@ export function registerCommonRequestHandlers( message.batchId, message.resultId, message.selection, - false, + shouldIncludeHeaders(message.includeHeaders), ); }); - webviewController.onRequest(qr.CopyWithHeadersRequest.type, async (message) => { - sendActionEvent(TelemetryViews.QueryResult, TelemetryActions.CopyResultsHeaders, { - correlationId: correlationId, - format: undefined, - selection: undefined, - origin: undefined, - }); - return await webviewViewController.getSqlOutputContentProvider().copyRequestHandler( - message.uri, - message.batchId, - message.resultId, - message.selection, - true, //copy headers flag - ); - }); - webviewController.onRequest(qr.CopyHeadersRequest.type, async (message) => { sendActionEvent(TelemetryViews.QueryResult, TelemetryActions.CopyHeaders, { correlationId: correlationId, @@ -188,7 +156,6 @@ export function registerCommonRequestHandlers( message.batchId, message.resultId, message.selection, - message.includeHeaders, ); }); @@ -204,7 +171,6 @@ export function registerCommonRequestHandlers( message.batchId, message.resultId, message.selection, - message.includeHeaders, ); }); @@ -525,3 +491,12 @@ export function getInMemoryGridDataProcessingThreshold(): number { .get(Constants.configInMemoryDataProcessingThreshold) ?? 5000 ); } + +export function shouldIncludeHeaders(includeHeaders: boolean): boolean { + if (includeHeaders !== undefined) { + // Respect the value explicity passed into the method + return includeHeaders; + } + // else get config option from vscode config + return vscode.workspace.getConfiguration().get(Constants.copyIncludeHeaders); +} diff --git a/src/reactviews/pages/QueryResult/table/plugins/contextMenu.plugin.ts b/src/reactviews/pages/QueryResult/table/plugins/contextMenu.plugin.ts index a476bf5d0a..c74b21773b 100644 --- a/src/reactviews/pages/QueryResult/table/plugins/contextMenu.plugin.ts +++ b/src/reactviews/pages/QueryResult/table/plugins/contextMenu.plugin.ts @@ -10,16 +10,16 @@ import { CopyAsInsertIntoRequest, CopyHeadersRequest, CopySelectionRequest, - CopyWithHeadersRequest, - DbCellValue, GridContextMenuAction, ResultSetSummary, - SendToClipboardRequest, } from "../../../../../sharedInterfaces/queryResult"; import { QueryResultReactProvider } from "../../queryResultStateProvider"; -import { IDisposableDataProvider } from "../dataProvider"; import { HybridDataProvider } from "../hybridDataProvider"; -import { selectEntireGrid, selectionToRange, tryCombineSelectionsForResults } from "../utils"; +import { + convertDisplayedSelectionToActual, + selectEntireGrid, + tryCombineSelectionsForResults, +} from "../utils"; export class ContextMenu { private grid!: Slick.Grid; @@ -29,7 +29,6 @@ export class ContextMenu { private uri: string, private resultSetSummary: ResultSetSummary, private queryResultContext: QueryResultReactProvider, - private dataProvider: IDisposableDataProvider, ) { this.uri = uri; this.resultSetSummary = resultSetSummary; @@ -83,6 +82,8 @@ export class ContextMenu { selection = selectEntireGrid(this.grid); } + const convertedSelection = convertDisplayedSelectionToActual(this.grid, selection); + switch (action) { case GridContextMenuAction.SelectAll: this.queryResultContext.log("Select All action triggered"); @@ -94,91 +95,23 @@ export class ContextMenu { break; case GridContextMenuAction.CopySelection: this.queryResultContext.log("Copy action triggered"); - if (this.dataProvider.isDataInMemory) { - this.queryResultContext.log( - "Sorted/filtered grid detected, fetching data from data provider", - ); - let range = selectionToRange(selection[0]); - let data = await this.dataProvider.getRangeAsync(range.start, range.length); - const dataArray = data.map((map) => { - const maxKey = Math.max(...Array.from(Object.keys(map)).map(Number)); // Get the maximum key - return Array.from( - { length: maxKey + 1 }, - (_, index) => - ({ - rowId: index, - displayValue: map[index].displayValue || null, - }) as DbCellValue, - ); - }); - await this.queryResultContext.extensionRpc.sendRequest( - SendToClipboardRequest.type, - { - uri: this.uri, - data: dataArray, - batchId: this.resultSetSummary.batchId, - resultId: this.resultSetSummary.id, - selection: selection, - headersFlag: false, - }, - ); - } else { - await this.queryResultContext.extensionRpc.sendRequest( - CopySelectionRequest.type, - { - uri: this.uri, - batchId: this.resultSetSummary.batchId, - resultId: this.resultSetSummary.id, - selection: selection, - }, - ); - } - + await this.queryResultContext.extensionRpc.sendRequest(CopySelectionRequest.type, { + uri: this.uri, + batchId: this.resultSetSummary.batchId, + resultId: this.resultSetSummary.id, + selection: convertedSelection, + includeHeaders: false, + }); break; case GridContextMenuAction.CopyWithHeaders: this.queryResultContext.log("Copy with headers action triggered"); - - if (this.dataProvider.isDataInMemory) { - this.queryResultContext.log( - "Sorted/filtered grid detected, fetching data from data provider", - ); - - let range = selectionToRange(selection[0]); - let data = await this.dataProvider.getRangeAsync(range.start, range.length); - const dataArray = data.map((map) => { - const maxKey = Math.max(...Array.from(Object.keys(map)).map(Number)); // Get the maximum key - return Array.from( - { length: maxKey + 1 }, - (_, index) => - ({ - rowId: index, - displayValue: map[index].displayValue || null, - }) as DbCellValue, - ); - }); - await this.queryResultContext.extensionRpc.sendRequest( - SendToClipboardRequest.type, - { - uri: this.uri, - data: dataArray, - batchId: this.resultSetSummary.batchId, - resultId: this.resultSetSummary.id, - selection: selection, - headersFlag: true, - }, - ); - } else { - await this.queryResultContext.extensionRpc.sendRequest( - CopyWithHeadersRequest.type, - { - uri: this.uri, - batchId: this.resultSetSummary.batchId, - resultId: this.resultSetSummary.id, - selection: selection, - }, - ); - } - + await this.queryResultContext.extensionRpc.sendRequest(CopySelectionRequest.type, { + uri: this.uri, + batchId: this.resultSetSummary.batchId, + resultId: this.resultSetSummary.id, + selection: convertedSelection, + includeHeaders: true, + }); break; case GridContextMenuAction.CopyHeaders: this.queryResultContext.log("Copy Headers action triggered"); @@ -186,7 +119,7 @@ export class ContextMenu { uri: this.uri, batchId: this.resultSetSummary.batchId, resultId: this.resultSetSummary.id, - selection: selection, + selection: convertedSelection, }); break; case GridContextMenuAction.CopyAsCsv: @@ -195,8 +128,7 @@ export class ContextMenu { uri: this.uri, batchId: this.resultSetSummary.batchId, resultId: this.resultSetSummary.id, - selection: selection, - includeHeaders: true, // Default to including headers for CSV + selection: convertedSelection, }); break; case GridContextMenuAction.CopyAsJson: @@ -205,7 +137,7 @@ export class ContextMenu { uri: this.uri, batchId: this.resultSetSummary.batchId, resultId: this.resultSetSummary.id, - selection: selection, + selection: convertedSelection, includeHeaders: true, // Default to including headers for JSON }); break; @@ -215,7 +147,7 @@ export class ContextMenu { uri: this.uri, batchId: this.resultSetSummary.batchId, resultId: this.resultSetSummary.id, - selection: selection, + selection: convertedSelection, }); break; case GridContextMenuAction.CopyAsInsertInto: @@ -226,7 +158,7 @@ export class ContextMenu { uri: this.uri, batchId: this.resultSetSummary.batchId, resultId: this.resultSetSummary.id, - selection: selection, + selection: convertedSelection, }, ); break; diff --git a/src/reactviews/pages/QueryResult/table/plugins/copyKeybind.plugin.ts b/src/reactviews/pages/QueryResult/table/plugins/copyKeybind.plugin.ts index 3905c6029c..a070503459 100644 --- a/src/reactviews/pages/QueryResult/table/plugins/copyKeybind.plugin.ts +++ b/src/reactviews/pages/QueryResult/table/plugins/copyKeybind.plugin.ts @@ -6,13 +6,14 @@ import { KeyboardEvent } from "react"; import { ResultSetSummary, - DbCellValue, - SendToClipboardRequest, CopySelectionRequest, } from "../../../../../sharedInterfaces/queryResult"; -import { selectEntireGrid, selectionToRange, tryCombineSelectionsForResults } from "../utils"; +import { + convertDisplayedSelectionToActual, + selectEntireGrid, + tryCombineSelectionsForResults, +} from "../utils"; import { Keys } from "../../../../common/keys"; -import { IDisposableDataProvider } from "../dataProvider"; import { QueryResultReactProvider } from "../../queryResultStateProvider"; import { GetPlatformRequest } from "../../../../../sharedInterfaces/webview"; @@ -29,7 +30,6 @@ export class CopyKeybind implements Slick.Plugin { uri: string, resultSetSummary: ResultSetSummary, private _qrContext: QueryResultReactProvider, - private dataProvider: IDisposableDataProvider, ) { this.uri = uri; this.resultSetSummary = resultSetSummary; @@ -72,44 +72,21 @@ export class CopyKeybind implements Slick.Plugin { uri: string, resultSetSummary: ResultSetSummary, ) { - let selectedRanges = grid.getSelectionModel().getSelectedRanges(); - let selection = tryCombineSelectionsForResults(selectedRanges); + const selectedRanges = grid.getSelectionModel().getSelectedRanges(); + let selection = tryCombineSelectionsForResults(selectedRanges) ?? []; - // If no selection exists, create a selection for the entire grid if (!selection || selection.length === 0) { selection = selectEntireGrid(grid); } - if (this.dataProvider.isDataInMemory) { - let range = selectionToRange(selection[0]); - let data = await this.dataProvider.getRangeAsync(range.start, range.length); - const dataArray = data.map((map) => { - const maxKey = Math.max(...Array.from(Object.keys(map)).map(Number)); // Get the maximum key - return Array.from( - { length: maxKey + 1 }, - (_, index) => - ({ - rowId: index, - displayValue: map[index].displayValue || null, - isNull: map[index].isNull || false, - }) as DbCellValue, - ); - }); - await this._qrContext.extensionRpc.sendRequest(SendToClipboardRequest.type, { - uri: uri, - data: dataArray, - batchId: resultSetSummary.batchId, - resultId: resultSetSummary.id, - selection: selection, - headersFlag: false, // Assuming headers are not needed for in-memory data - }); - } else { - await this._qrContext.extensionRpc.sendRequest(CopySelectionRequest.type, { - uri: uri, - batchId: resultSetSummary.batchId, - resultId: resultSetSummary.id, - selection: selection, - }); - } + const convertedSelection = convertDisplayedSelectionToActual(grid, selection); + + await this._qrContext.extensionRpc.sendRequest(CopySelectionRequest.type, { + uri: uri, + batchId: resultSetSummary.batchId, + resultId: resultSetSummary.id, + selection: convertedSelection, + includeHeaders: undefined, // Keeping it undefined so that it can be determined by user settings + }); } } diff --git a/src/reactviews/pages/QueryResult/table/table.ts b/src/reactviews/pages/QueryResult/table/table.ts index db0163086b..28798401f9 100644 --- a/src/reactviews/pages/QueryResult/table/table.ts +++ b/src/reactviews/pages/QueryResult/table/table.ts @@ -69,7 +69,7 @@ export class Table implements IThemable { private context: QueryResultReactProvider, private linkHandler: (fileContent: string, fileType: string) => void, private gridId: string, - private configuration: ITableConfiguration, + configuration: ITableConfiguration, options?: Slick.GridOptions, gridParentRef?: React.RefObject, autoSizeColumns: boolean = false, @@ -148,22 +148,8 @@ export class Table implements IThemable { this.styleElement = DOM.createStyleSheet(this._container); this._grid = new Slick.Grid(this._tableContainer, this._data, [], newOptions); this.registerPlugin(this.headerFilter); - this.registerPlugin( - new ContextMenu( - this.uri, - this.resultSetSummary, - this.context, - this.configuration.dataProvider as IDisposableDataProvider, - ), - ); - this.registerPlugin( - new CopyKeybind( - this.uri, - this.resultSetSummary, - this.context, - this.configuration.dataProvider as IDisposableDataProvider, - ), - ); + this.registerPlugin(new ContextMenu(this.uri, this.resultSetSummary, this.context)); + this.registerPlugin(new CopyKeybind(this.uri, this.resultSetSummary, this.context)); this._autoColumnSizePlugin = new AutoColumnSize( { diff --git a/src/sharedInterfaces/queryResult.ts b/src/sharedInterfaces/queryResult.ts index 9978f6a7f3..07f190325f 100644 --- a/src/sharedInterfaces/queryResult.ts +++ b/src/sharedInterfaces/queryResult.ts @@ -253,23 +253,13 @@ export interface CopySelectionRequestParams { batchId: number; resultId: number; selection: ISlickRange[]; + includeHeaders?: boolean; } + export namespace CopySelectionRequest { export const type = new RequestType("copySelection"); } -export interface SendToClipboardParams { - uri: string; - data: DbCellValue[][]; - batchId: number; - resultId: number; - selection: ISlickRange[]; - headersFlag?: boolean; -} -export namespace SendToClipboardRequest { - export const type = new RequestType("sendToClipboard"); -} - export interface CopyHeadersParams { uri: string; batchId: number; @@ -280,17 +270,11 @@ export namespace CopyHeadersRequest { export const type = new RequestType("copyHeaders"); } -export interface CopyWithHeadersParams extends CopyHeadersParams {} -export namespace CopyWithHeadersRequest { - export const type = new RequestType("copyWithHeaders"); -} - export interface CopyAsCsvRequest { uri: string; batchId: number; resultId: number; selection: ISlickRange[]; - includeHeaders: boolean; } export namespace CopyAsCsvRequest { diff --git a/test/unit/queryRunner.test.ts b/test/unit/queryRunner.test.ts index 1add052b1a..2bd698ae9f 100644 --- a/test/unit/queryRunner.test.ts +++ b/test/unit/queryRunner.test.ts @@ -14,7 +14,6 @@ import { QueryExecuteCompleteNotificationResult, QueryExecuteBatchNotificationParams, QueryExecuteResultSetCompleteNotificationParams, - ResultSetSummary, QueryExecuteSubsetResult, } from "../../src/models/contracts/queryExecute"; import VscodeWrapper from "../../src/controllers/vscodeWrapper"; @@ -22,11 +21,10 @@ import StatusView from "../../src/views/statusView"; import * as Constants from "../../src/constants/constants"; import * as QueryExecuteContracts from "../../src/models/contracts/queryExecute"; import * as QueryDisposeContracts from "../../src/models/contracts/queryDispose"; -import { ISlickRange, ISelectionData } from "../../src/models/interfaces"; +import { ISelectionData } from "../../src/models/interfaces"; import * as stubs from "./stubs"; import * as vscode from "vscode"; import { expect } from "chai"; -import * as os from "os"; // CONSTANTS ////////////////////////////////////////////////////////////////////////////////////// const standardUri = "uri"; @@ -611,1349 +609,6 @@ suite("Query Runner tests", () => { return config; }); } - - suite("Copy Tests", () => { - // ------ Common inputs and setup for copy tests ------- - const testuri = "test"; - let testresult: QueryExecuteSubsetResult = { - resultSubset: { - rowCount: 5, - rows: [ - [ - { isNull: false, displayValue: "1" }, - { isNull: false, displayValue: "2" }, - ], - [ - { isNull: false, displayValue: "3" }, - { isNull: false, displayValue: "4" }, - ], - [ - { isNull: false, displayValue: "5" }, - { isNull: false, displayValue: "6" }, - ], - [ - { isNull: false, displayValue: "7" }, - { isNull: false, displayValue: "8" }, - ], - [ - { isNull: false, displayValue: "9" }, - { isNull: false, displayValue: "10 ∞" }, - ], - ], - }, - }; - process.env["LANG"] = "C"; - - let testRange: ISlickRange[] = [{ fromCell: 0, fromRow: 0, toCell: 1, toRow: 4 }]; - - let result: QueryExecuteCompleteNotificationResult = { - ownerUri: testuri, - batchSummaries: [ - { - hasError: false, - id: 0, - selection: { - startLine: 0, - endLine: 0, - startColumn: 3, - endColumn: 3, - }, - resultSetSummaries: [ - { - id: 0, - rowCount: 5, - columnInfo: [{ columnName: "Col1" }, { columnName: "Col2" }], - }, - ], - executionElapsed: undefined, - executionStart: new Date().toISOString(), - executionEnd: new Date().toISOString(), - }, - ], - }; - - setup(() => { - testSqlToolsServerClient - .setup((x) => x.sendRequest(TypeMoq.It.isAny(), TypeMoq.It.isAny())) - .callback(() => { - // testing - }) - .returns(() => { - return Promise.resolve(testresult); - }); - testStatusView.setup((x) => x.executingQuery(TypeMoq.It.isAnyString())); - testStatusView.setup((x) => x.executedQuery(TypeMoq.It.isAnyString())); - testVscodeWrapper.setup((x) => x.logToOutputChannel(TypeMoq.It.isAnyString())); - testVscodeWrapper - .setup((x) => x.clipboardWriteText(TypeMoq.It.isAnyString())) - .callback(() => { - // testing - }) - .returns(() => { - return Promise.resolve(); - }); - }); - - // ------ Copy tests ------- - test("Correctly copy pastes a selection", (done) => { - let configResult: { [key: string]: any } = {}; - configResult[Constants.copyIncludeHeaders] = false; - setupWorkspaceConfig(configResult); - - let queryRunner = new QueryRunner( - testuri, - testuri, - testStatusView.object, - testSqlToolsServerClient.object, - testQueryNotificationHandler.object, - testVscodeWrapper.object, - ); - queryRunner.uri = testuri; - void queryRunner.copyResults(testRange, 0, 0).then(() => { - testVscodeWrapper.verify( - (x) => x.clipboardWriteText(TypeMoq.It.isAnyString()), - TypeMoq.Times.once(), - ); - done(); - }); - }); - - test("Copies selection with column headers set in user config", () => { - // Set column headers in the user config settings - let configResult: { [key: string]: any } = {}; - configResult[Constants.copyIncludeHeaders] = true; - setupWorkspaceConfig(configResult); - - let queryRunner = new QueryRunner( - testuri, - testuri, - testStatusView.object, - testSqlToolsServerClient.object, - testQueryNotificationHandler.object, - testVscodeWrapper.object, - ); - queryRunner.uri = testuri; - // Call handleResult to ensure column header info is seeded - queryRunner.handleQueryComplete(result); - return queryRunner.copyResults(testRange, 0, 0).then(() => { - testVscodeWrapper.verify( - (x) => x.clipboardWriteText(TypeMoq.It.isAnyString()), - TypeMoq.Times.once(), - ); - }); - }); - - test("Copies selection with headers when true passed as parameter", () => { - // Do not set column config in user settings - let configResult: { [key: string]: any } = {}; - configResult[Constants.copyIncludeHeaders] = false; - setupWorkspaceConfig(configResult); - - let queryRunner = new QueryRunner( - testuri, - testuri, - testStatusView.object, - testSqlToolsServerClient.object, - testQueryNotificationHandler.object, - testVscodeWrapper.object, - ); - queryRunner.uri = testuri; - // Call handleResult to ensure column header info is seeded - queryRunner.handleQueryComplete(result); - - // call copyResults with additional parameter indicating to include headers - return queryRunner.copyResults(testRange, 0, 0, true).then(() => { - testVscodeWrapper.verify( - (x) => x.clipboardWriteText(TypeMoq.It.isAnyString()), - TypeMoq.Times.once(), - ); - }); - }); - - test("Copies selection without headers when false passed as parameter", () => { - // Set column config in user settings - let configResult: { [key: string]: any } = {}; - configResult[Constants.copyIncludeHeaders] = true; - setupWorkspaceConfig(configResult); - - let queryRunner = new QueryRunner( - testuri, - testuri, - testStatusView.object, - testSqlToolsServerClient.object, - testQueryNotificationHandler.object, - testVscodeWrapper.object, - ); - queryRunner.uri = testuri; - // Call handleResult to ensure column header info is seeded - queryRunner.handleQueryComplete(result); - - // call copyResults with additional parameter indicating to not include headers - return queryRunner.copyResults(testRange, 0, 0, false).then(() => { - testVscodeWrapper.verify( - (x) => x.clipboardWriteText(TypeMoq.It.isAnyString()), - TypeMoq.Times.once(), - ); - }); - }); - - test("SetEditorSelection uses an existing editor if it is visible", (done) => { - let queryUri = "test_uri"; - let queryColumn = 2; - let queryRunner = new QueryRunner( - queryUri, - queryUri, - testStatusView.object, - testSqlToolsServerClient.object, - testQueryNotificationHandler.object, - testVscodeWrapper.object, - ); - let editor: vscode.TextEditor = { - document: { - uri: queryUri, - }, - viewColumn: queryColumn, - selection: undefined, - } as any; - - testVscodeWrapper.setup((x) => x.textDocuments).returns(() => [editor.document]); - testVscodeWrapper.setup((x) => x.activeTextEditor).returns(() => editor); - testVscodeWrapper - .setup((x) => x.openTextDocument(TypeMoq.It.isAny())) - .returns(() => Promise.resolve(editor.document)); - testVscodeWrapper - .setup((x) => x.showTextDocument(editor.document, TypeMoq.It.isAny())) - .returns(() => Promise.resolve(editor)); - - // If I try to set a selection for the existing editor - let selection: ISelectionData = { - startColumn: 0, - startLine: 0, - endColumn: 1, - endLine: 1, - }; - queryRunner.setEditorSelection(selection).then( - () => { - try { - // Then showTextDocument gets called with the existing editor's column - testVscodeWrapper.verify( - (x) => x.showTextDocument(editor.document, TypeMoq.It.isAny()), - TypeMoq.Times.once(), - ); - done(); - } catch (err) { - done(err); - } - }, - (err) => done(err), - ); - }); - - test("SetEditorSelection uses column 1 by default", (done) => { - let queryUri = "test_uri"; - let queryRunner = new QueryRunner( - queryUri, - queryUri, - testStatusView.object, - testSqlToolsServerClient.object, - testQueryNotificationHandler.object, - testVscodeWrapper.object, - ); - let editor: vscode.TextEditor = { - document: { - uri: queryUri, - }, - viewColumn: undefined, - selection: undefined, - } as any; - - testVscodeWrapper.setup((x) => x.textDocuments).returns(() => [editor.document]); - testVscodeWrapper.setup((x) => x.visibleEditors).returns(() => []); - testVscodeWrapper - .setup((x) => x.openTextDocument(TypeMoq.It.isAny())) - .returns(() => Promise.resolve(editor.document)); - testVscodeWrapper - .setup((x) => x.showTextDocument(editor.document, TypeMoq.It.isAny())) - .returns(() => Promise.resolve(editor)); - - // If I try to set a selection for an editor that is not currently visible - queryRunner - .setEditorSelection({ - startColumn: 0, - startLine: 0, - endColumn: 1, - endLine: 1, - }) - .then( - () => { - try { - // Then showTextDocument gets called with the default first column - testVscodeWrapper.verify( - (x) => x.showTextDocument(editor.document, TypeMoq.It.isAny()), - TypeMoq.Times.once(), - ); - done(); - } catch (err) { - done(err); - } - }, - (err) => done(err), - ); - }); - }); - - suite("Copy Tests with multiple selections", () => { - // ------ Common inputs and setup for copy tests ------- - let mockConfig: TypeMoq.IMock; - const testuri = "test"; - let testresult: QueryExecuteSubsetResult = { - resultSubset: { - rowCount: 5, - rows: [ - [ - { isNull: false, displayValue: "1" }, - { isNull: false, displayValue: "2" }, - { isNull: false, displayValue: "3" }, - ], - [ - { isNull: false, displayValue: "4" }, - { isNull: false, displayValue: "5" }, - { isNull: false, displayValue: "6" }, - ], - [ - { isNull: false, displayValue: "7" }, - { isNull: false, displayValue: "8" }, - { isNull: false, displayValue: "9" }, - ], - [ - { isNull: false, displayValue: "10" }, - { isNull: false, displayValue: "11" }, - { isNull: false, displayValue: "12" }, - ], - [ - { isNull: false, displayValue: "13" }, - { isNull: false, displayValue: "14" }, - { isNull: false, displayValue: "15" }, - ], - ], - }, - }; - process.env["LANG"] = "C"; - - let testRange: ISlickRange[] = [ - { fromCell: 0, fromRow: 0, toCell: 1, toRow: 2 }, - { fromCell: 1, fromRow: 1, toCell: 2, toRow: 4 }, - ]; - - let result: QueryExecuteCompleteNotificationResult = { - ownerUri: testuri, - batchSummaries: [ - { - hasError: false, - id: 0, - selection: { - startLine: 0, - endLine: 0, - startColumn: 3, - endColumn: 3, - }, - resultSetSummaries: [ - { - id: 0, - rowCount: 5, - columnInfo: [ - { columnName: "Col1" }, - { columnName: "Col2" }, - { columnName: "Col3" }, - ], - }, - ], - executionElapsed: undefined, - executionStart: new Date().toISOString(), - executionEnd: new Date().toISOString(), - }, - ], - }; - - setup(() => { - testStatusView.setup((x) => x.executingQuery(TypeMoq.It.isAnyString())); - testStatusView.setup((x) => x.executedQuery(TypeMoq.It.isAnyString())); - testVscodeWrapper.setup((x) => x.logToOutputChannel(TypeMoq.It.isAnyString())); - }); - - function setupMockConfig(): void { - mockConfig = TypeMoq.Mock.ofType(); - mockConfig.setup((c) => c.get(TypeMoq.It.isAnyString())).returns(() => false); - testVscodeWrapper - .setup((x) => x.getConfiguration(TypeMoq.It.isAny(), TypeMoq.It.isAny())) - .returns(() => mockConfig.object); - } - - // ------ Copy tests with multiple selections ------- - test("Correctly copy pastes a selection", async () => { - testSqlToolsServerClient - .setup((x) => x.sendRequest(TypeMoq.It.isAny(), TypeMoq.It.isAny())) - .returns(() => { - return Promise.resolve(testresult); - }); - setupMockConfig(); - let queryRunner = new QueryRunner( - testuri, - testuri, - testStatusView.object, - testSqlToolsServerClient.object, - testQueryNotificationHandler.object, - testVscodeWrapper.object, - ); - queryRunner.uri = testuri; - await queryRunner.copyResults(testRange, 0, 0); - // Two selections - mockConfig.verify( - (c) => c.get(Constants.configCopyRemoveNewLine), - TypeMoq.Times.atLeast(2), - ); - // Once for new lines and once for headers - testVscodeWrapper.verify( - (v) => v.getConfiguration(TypeMoq.It.isAny(), TypeMoq.It.isAny()), - TypeMoq.Times.atLeast(2), - ); - mockConfig.verify((c) => c.get(Constants.copyIncludeHeaders), TypeMoq.Times.once()); - testVscodeWrapper.verify( - (x) => x.clipboardWriteText(TypeMoq.It.isAnyString()), - TypeMoq.Times.once(), - ); - }); - - test("Copies selection with column headers set in user config", async () => { - testSqlToolsServerClient - .setup((x) => x.sendRequest(TypeMoq.It.isAny(), TypeMoq.It.isAny())) - .returns(() => { - return Promise.resolve(testresult); - }); - setupMockConfig(); - // Set column headers in the user config settings - let queryRunner = new QueryRunner( - testuri, - testuri, - testStatusView.object, - testSqlToolsServerClient.object, - testQueryNotificationHandler.object, - testVscodeWrapper.object, - ); - queryRunner.uri = testuri; - // Call handleResult to ensure column header info is seeded - queryRunner.handleQueryComplete(result); - await queryRunner.copyResults(testRange, 0, 0); - mockConfig.verify((c) => c.get(Constants.copyIncludeHeaders), TypeMoq.Times.once()); - // Two selections - mockConfig.verify( - (c) => c.get(Constants.configCopyRemoveNewLine), - TypeMoq.Times.atLeast(2), - ); - testVscodeWrapper.verify( - (v) => v.getConfiguration(TypeMoq.It.isAny(), TypeMoq.It.isAny()), - TypeMoq.Times.atLeast(2), - ); - testVscodeWrapper.verify( - (x) => x.clipboardWriteText(TypeMoq.It.isAnyString()), - TypeMoq.Times.once(), - ); - }); - - test("Copies selection with headers when true passed as parameter", async () => { - testSqlToolsServerClient - .setup((x) => x.sendRequest(TypeMoq.It.isAny(), TypeMoq.It.isAny())) - .returns(() => { - return Promise.resolve(testresult); - }); - setupMockConfig(); - // Do not set column config in user settings - let queryRunner = new QueryRunner( - testuri, - testuri, - testStatusView.object, - testSqlToolsServerClient.object, - testQueryNotificationHandler.object, - testVscodeWrapper.object, - ); - queryRunner.uri = testuri; - // Call handleResult to ensure column header info is seeded - queryRunner.handleQueryComplete(result); - - // call copyResults with additional parameter indicating to include headers - await queryRunner.copyResults(testRange, 0, 0, true); - testVscodeWrapper.verify( - (x) => x.getConfiguration(TypeMoq.It.isAny(), TypeMoq.It.isAny()), - TypeMoq.Times.atLeastOnce(), - ); - mockConfig.verify( - (c) => c.get(Constants.configCopyRemoveNewLine), - TypeMoq.Times.atLeast(2), - ); - mockConfig.verify((c) => c.get(Constants.copyIncludeHeaders), TypeMoq.Times.never()); - testVscodeWrapper.verify( - (x) => x.clipboardWriteText(TypeMoq.It.isAnyString()), - TypeMoq.Times.once(), - ); - }); - - test("Copies selection without headers when false passed as parameter", async () => { - testSqlToolsServerClient - .setup((x) => x.sendRequest(TypeMoq.It.isAny(), TypeMoq.It.isAny())) - .returns(() => { - return Promise.resolve(testresult); - }); - setupMockConfig(); - // Set column config in user settings - let queryRunner = new QueryRunner( - testuri, - testuri, - testStatusView.object, - testSqlToolsServerClient.object, - testQueryNotificationHandler.object, - testVscodeWrapper.object, - ); - queryRunner.uri = testuri; - // Call handleResult to ensure column header info is seeded - queryRunner.handleQueryComplete(result); - - // call copyResults with additional parameter indicating to not include headers - await queryRunner.copyResults(testRange, 0, 0, false); - testVscodeWrapper.verify( - (x) => x.getConfiguration(TypeMoq.It.isAny(), TypeMoq.It.isAny()), - TypeMoq.Times.atLeastOnce(), - ); - mockConfig.verify( - (c) => c.get(Constants.configCopyRemoveNewLine), - TypeMoq.Times.atLeast(2), - ); - mockConfig.verify((c) => c.get(Constants.copyIncludeHeaders), TypeMoq.Times.never()); - testVscodeWrapper.verify( - (x) => x.clipboardWriteText(TypeMoq.It.isAnyString()), - TypeMoq.Times.once(), - ); - }); - - test("Copies selection as CSV with headers", async () => { - testSqlToolsServerClient - .setup((x) => x.sendRequest(TypeMoq.It.isAny(), TypeMoq.It.isAny())) - .returns(() => { - return Promise.resolve(testresult); - }); - setupMockConfig(); - let configResult: { [key: string]: any } = {}; - configResult[Constants.configSaveAsCsv] = { - delimiter: ",", - textIdentifier: '"', - lineSeperator: "\n", - }; - - let queryRunner = new QueryRunner( - testuri, - testuri, - testStatusView.object, - testSqlToolsServerClient.object, - testQueryNotificationHandler.object, - testVscodeWrapper.object, - ); - queryRunner.uri = testuri; - queryRunner.handleQueryComplete(result); - - await queryRunner.copyResultsAsCsv(testRange, 0, 0, true); - testVscodeWrapper.verify( - (x) => x.clipboardWriteText(TypeMoq.It.isAnyString()), - TypeMoq.Times.once(), - ); - }); - - test("Copies selection as CSV with null values", async () => { - let configResult: { [key: string]: any } = {}; - configResult[Constants.configSaveAsCsv] = { - delimiter: ",", - textIdentifier: '"', - lineSeperator: "\n", - }; - - // Create test data with null values for CSV export - let testResultWithNulls: QueryExecuteSubsetResult = { - resultSubset: { - rowCount: 3, - rows: [ - [ - { isNull: false, displayValue: "1" }, - { isNull: true, displayValue: "null" }, - { isNull: false, displayValue: "3" }, - ], - [ - { isNull: true, displayValue: "null" }, - { isNull: false, displayValue: "5" }, - { isNull: true, displayValue: "null" }, - ], - [ - { isNull: false, displayValue: "7" }, - { isNull: false, displayValue: "8" }, - { isNull: false, displayValue: "9" }, - ], - ], - }, - }; - - let testRangeWithNulls: ISlickRange[] = [ - { fromCell: 0, fromRow: 0, toCell: 2, toRow: 2 }, - ]; - - // Setup testSqlToolsServerClient to return null data - testSqlToolsServerClient - .setup((x) => x.sendRequest(TypeMoq.It.isAny(), TypeMoq.It.isAny())) - .returns(() => Promise.resolve(testResultWithNulls)); - - // Setup configuration mock - let mockConfig = TypeMoq.Mock.ofType(); - mockConfig - .setup((c) => c.get(TypeMoq.It.isAnyString())) - .returns((key: string) => { - return configResult[key] || false; - }); - testVscodeWrapper - .setup((x) => x.getConfiguration(TypeMoq.It.isAny(), TypeMoq.It.isAny())) - .returns(() => mockConfig.object); - testVscodeWrapper - .setup((x) => x.getConfiguration(TypeMoq.It.isAny())) - .returns(() => mockConfig.object); - - // Capture the CSV content - let capturedCsvContent: string = ""; - testVscodeWrapper - .setup((x) => x.clipboardWriteText(TypeMoq.It.isAnyString())) - .callback((text: string) => { - capturedCsvContent = text; - }) - .returns(() => Promise.resolve()); - - let queryRunner = new QueryRunner( - testuri, - testuri, - testStatusView.object, - testSqlToolsServerClient.object, - testQueryNotificationHandler.object, - testVscodeWrapper.object, - ); - queryRunner.uri = testuri; - queryRunner.handleQueryComplete(result); - - await queryRunner.copyResultsAsCsv(testRangeWithNulls, 0, 0, true); - - testVscodeWrapper.verify( - (x) => x.clipboardWriteText(TypeMoq.It.isAnyString()), - TypeMoq.Times.once(), - ); - - // Verify that null values are exported as empty strings, not "null" - console.log("Captured CSV content:", JSON.stringify(capturedCsvContent)); - assert.ok( - capturedCsvContent.includes("1,,3"), - "First row should have empty value for null cell. Actual content: " + - capturedCsvContent, - ); - assert.ok( - capturedCsvContent.includes(",5,"), - "Second row should have empty values for null cells", - ); - assert.ok( - !capturedCsvContent.includes(",null,"), - "CSV should not contain literal 'null' strings", - ); - }); - - test("Copies selection as JSON with headers", async () => { - testSqlToolsServerClient - .setup((x) => x.sendRequest(TypeMoq.It.isAny(), TypeMoq.It.isAny())) - .returns(() => { - return Promise.resolve(testresult); - }); - setupMockConfig(); - let queryRunner = new QueryRunner( - testuri, - testuri, - testStatusView.object, - testSqlToolsServerClient.object, - testQueryNotificationHandler.object, - testVscodeWrapper.object, - ); - queryRunner.uri = testuri; - queryRunner.handleQueryComplete(result); - - await queryRunner.copyResultsAsJson(testRange, 0, 0, true); - testVscodeWrapper.verify( - (x) => x.clipboardWriteText(TypeMoq.It.isAnyString()), - TypeMoq.Times.once(), - ); - }); - - test("Copies selection as JSON with null values", async () => { - setupMockConfig(); - - // Create test data with null values - let testResultWithNulls: QueryExecuteSubsetResult = { - resultSubset: { - rowCount: 2, - rows: [ - [ - { isNull: false, displayValue: "1" }, - { isNull: true, displayValue: "null" }, - { isNull: false, displayValue: "test" }, - ], - [ - { isNull: true, displayValue: "null" }, - { isNull: false, displayValue: "42" }, - { isNull: true, displayValue: "null" }, - ], - ], - }, - }; - - let resultWithNulls: QueryExecuteCompleteNotificationResult = { - ownerUri: testuri, - batchSummaries: [ - { - hasError: false, - id: 0, - selection: { - startLine: 0, - endLine: 0, - startColumn: 3, - endColumn: 3, - }, - resultSetSummaries: [ - { - id: 0, - rowCount: 2, - columnInfo: [ - { columnName: "Col1" }, - { columnName: "Col2" }, - { columnName: "Col3" }, - ], - }, - ], - executionElapsed: undefined, - executionStart: new Date().toISOString(), - executionEnd: new Date().toISOString(), - }, - ], - }; - - let testRangeWithNulls: ISlickRange[] = [ - { fromCell: 0, fromRow: 0, toCell: 2, toRow: 1 }, - ]; - - // Setup mock to capture the actual JSON content - let capturedJsonContent: string = ""; - testVscodeWrapper - .setup((x) => x.clipboardWriteText(TypeMoq.It.isAnyString())) - .callback((text: string) => { - capturedJsonContent = text; - }) - .returns(() => Promise.resolve()); - - testSqlToolsServerClient - .setup((x) => x.sendRequest(TypeMoq.It.isAny(), TypeMoq.It.isAny())) - .returns(() => Promise.resolve(testResultWithNulls)); - - let queryRunner = new QueryRunner( - testuri, - testuri, - testStatusView.object, - testSqlToolsServerClient.object, - testQueryNotificationHandler.object, - testVscodeWrapper.object, - ); - queryRunner.uri = testuri; - queryRunner.handleQueryComplete(resultWithNulls); - - await queryRunner.copyResultsAsJson(testRangeWithNulls, 0, 0, true); - - testVscodeWrapper.verify( - (x) => x.clipboardWriteText(TypeMoq.It.isAnyString()), - TypeMoq.Times.once(), - ); - - // Verify that null values are exported as proper JSON null, not "null" strings - let jsonData; - try { - jsonData = JSON.parse(capturedJsonContent); - } catch (e) { - assert.fail( - `Generated JSON is invalid: ${e.message}. Content: ${capturedJsonContent}`, - ); - } - - assert.equal(jsonData.length, 2, "Should have 2 rows"); - - // First row: {Col1: 1, Col2: null, Col3: "test"} - assert.equal(jsonData[0].Col1, 1, "First row Col1 should be number 1"); - assert.strictEqual(jsonData[0].Col2, null, "First row Col2 should be null"); - assert.equal(jsonData[0].Col3, "test", "First row Col3 should be 'test'"); - - // Second row: {Col1: null, Col2: 42, Col3: null} - assert.strictEqual(jsonData[1].Col1, null, "Second row Col1 should be null"); - assert.equal(jsonData[1].Col2, 42, "Second row Col2 should be number 42"); - assert.strictEqual(jsonData[1].Col3, null, "Second row Col3 should be null"); - - // Ensure JSON string doesn't contain literal "null" strings - assert.ok( - !capturedJsonContent.includes('"null"'), - "JSON should not contain literal 'null' strings", - ); - }); - }); - - suite("copyResultsAsInClause", () => { - const testuri = "uri"; - let testSqlToolsServerClient: TypeMoq.IMock; - let testVscodeWrapper: TypeMoq.IMock; - let testStatusView: TypeMoq.IMock; - let testQueryNotificationHandler: TypeMoq.IMock; - - setup(() => { - testSqlToolsServerClient = TypeMoq.Mock.ofType( - SqlToolsServerClient, - TypeMoq.MockBehavior.Loose, - ); - testQueryNotificationHandler = TypeMoq.Mock.ofType( - QueryNotificationHandler, - TypeMoq.MockBehavior.Loose, - ); - testVscodeWrapper = TypeMoq.Mock.ofType(VscodeWrapper, TypeMoq.MockBehavior.Loose); - testStatusView = TypeMoq.Mock.ofType(StatusView, TypeMoq.MockBehavior.Loose); - }); - - function setupMockConfig() { - // Use the shared workspace configuration stub so config.get(...) works - const configItems: { [key: string]: any } = { - [Constants.copyIncludeHeaders]: true, - [Constants.configCopyRemoveNewLine]: false, - }; - const config = stubs.createWorkspaceConfiguration(configItems); - testVscodeWrapper - .setup((x) => - x.getConfiguration(TypeMoq.It.isAnyString(), TypeMoq.It.isAnyString()), - ) - .returns(() => config); - } - - test("Copies selection as IN clause with string values", async () => { - setupMockConfig(); - - let testResult: QueryExecuteSubsetResult = { - resultSubset: { - rowCount: 2, - rows: [ - [{ isNull: false, displayValue: "USA" }], - [{ isNull: false, displayValue: "Canada" }], - ], - }, - }; - - let result: QueryExecuteCompleteNotificationResult = { - ownerUri: testuri, - batchSummaries: [ - { - hasError: false, - id: 0, - selection: { - startLine: 0, - endLine: 0, - startColumn: 3, - endColumn: 3, - }, - resultSetSummaries: [ - { - id: 0, - rowCount: 2, - columnInfo: [{ columnName: "Country" }], - }, - ], - executionElapsed: undefined, - executionStart: new Date().toISOString(), - executionEnd: new Date().toISOString(), - }, - ], - }; - - let testRange: ISlickRange[] = [{ fromCell: 0, fromRow: 0, toCell: 0, toRow: 1 }]; - - let capturedClipboardContent: string = ""; - testVscodeWrapper - .setup((x) => x.clipboardWriteText(TypeMoq.It.isAnyString())) - .callback((text: string) => { - capturedClipboardContent = text; - }) - .returns(() => Promise.resolve()); - - testSqlToolsServerClient - .setup((x) => x.sendRequest(TypeMoq.It.isAny(), TypeMoq.It.isAny())) - .returns(() => Promise.resolve(testResult)); - - let queryRunner = new QueryRunner( - testuri, - testuri, - testStatusView.object, - testSqlToolsServerClient.object, - testQueryNotificationHandler.object, - testVscodeWrapper.object, - ); - queryRunner.uri = testuri; - queryRunner.handleQueryComplete(result); - - await queryRunner.copyResultsAsInClause(testRange, 0, 0); - - testVscodeWrapper.verify( - (x) => x.clipboardWriteText(TypeMoq.It.isAnyString()), - TypeMoq.Times.once(), - ); - - // Verify the IN clause format - const expectedInClause = `IN${os.EOL}(${os.EOL} 'USA',${os.EOL} 'Canada'${os.EOL})`; - assert.equal(capturedClipboardContent, expectedInClause); - }); - - test("Copies selection as IN clause with numeric values", async () => { - setupMockConfig(); - - let testResult: QueryExecuteSubsetResult = { - resultSubset: { - rowCount: 3, - rows: [ - [{ isNull: false, displayValue: "247199264" }], - [{ isNull: false, displayValue: "247199265" }], - [{ isNull: false, displayValue: "247199266" }], - ], - }, - }; - - let result: QueryExecuteCompleteNotificationResult = { - ownerUri: testuri, - batchSummaries: [ - { - hasError: false, - id: 0, - selection: { - startLine: 0, - endLine: 0, - startColumn: 3, - endColumn: 3, - }, - resultSetSummaries: [ - { - id: 0, - rowCount: 3, - columnInfo: [{ columnName: "ID" }], - }, - ], - executionElapsed: undefined, - executionStart: new Date().toISOString(), - executionEnd: new Date().toISOString(), - }, - ], - }; - - let testRange: ISlickRange[] = [{ fromCell: 0, fromRow: 0, toCell: 0, toRow: 2 }]; - - let capturedClipboardContent: string = ""; - testVscodeWrapper - .setup((x) => x.clipboardWriteText(TypeMoq.It.isAnyString())) - .callback((text: string) => { - capturedClipboardContent = text; - }) - .returns(() => Promise.resolve()); - - testSqlToolsServerClient - .setup((x) => x.sendRequest(TypeMoq.It.isAny(), TypeMoq.It.isAny())) - .returns(() => Promise.resolve(testResult)); - - let queryRunner = new QueryRunner( - testuri, - testuri, - testStatusView.object, - testSqlToolsServerClient.object, - testQueryNotificationHandler.object, - testVscodeWrapper.object, - ); - queryRunner.uri = testuri; - queryRunner.handleQueryComplete(result); - - await queryRunner.copyResultsAsInClause(testRange, 0, 0); - - // Verify the IN clause format with numeric values (no quotes) - const expectedInClause = `IN${os.EOL}(${os.EOL} 247199264,${os.EOL} 247199265,${os.EOL} 247199266${os.EOL})`; - assert.equal(capturedClipboardContent, expectedInClause); - }); - - test("Copies selection as IN clause with NULL values", async () => { - setupMockConfig(); - - let testResult: QueryExecuteSubsetResult = { - resultSubset: { - rowCount: 2, - rows: [ - [{ isNull: false, displayValue: "Valid" }], - [{ isNull: true, displayValue: "null" }], - ], - }, - }; - - let result: QueryExecuteCompleteNotificationResult = { - ownerUri: testuri, - batchSummaries: [ - { - hasError: false, - id: 0, - selection: { - startLine: 0, - endLine: 0, - startColumn: 3, - endColumn: 3, - }, - resultSetSummaries: [ - { - id: 0, - rowCount: 2, - columnInfo: [{ columnName: "Value" }], - }, - ], - executionElapsed: undefined, - executionStart: new Date().toISOString(), - executionEnd: new Date().toISOString(), - }, - ], - }; - - let testRange: ISlickRange[] = [{ fromCell: 0, fromRow: 0, toCell: 0, toRow: 1 }]; - - let capturedClipboardContent: string = ""; - testVscodeWrapper - .setup((x) => x.clipboardWriteText(TypeMoq.It.isAnyString())) - .callback((text: string) => { - capturedClipboardContent = text; - }) - .returns(() => Promise.resolve()); - - testSqlToolsServerClient - .setup((x) => x.sendRequest(TypeMoq.It.isAny(), TypeMoq.It.isAny())) - .returns(() => Promise.resolve(testResult)); - - let queryRunner = new QueryRunner( - testuri, - testuri, - testStatusView.object, - testSqlToolsServerClient.object, - testQueryNotificationHandler.object, - testVscodeWrapper.object, - ); - queryRunner.uri = testuri; - queryRunner.handleQueryComplete(result); - - await queryRunner.copyResultsAsInClause(testRange, 0, 0); - - // Verify the IN clause format with NULL values - const expectedInClause = `IN${os.EOL}(${os.EOL} 'Valid',${os.EOL} NULL${os.EOL})`; - assert.equal(capturedClipboardContent, expectedInClause); - }); - }); - - suite("copyResultsAsInsertInto", () => { - const testuri = "uri"; - let testSqlToolsServerClient: TypeMoq.IMock; - let testVscodeWrapper: TypeMoq.IMock; - let testStatusView: TypeMoq.IMock; - let testQueryNotificationHandler: TypeMoq.IMock; - - setup(() => { - testSqlToolsServerClient = TypeMoq.Mock.ofType( - SqlToolsServerClient, - TypeMoq.MockBehavior.Loose, - ); - testQueryNotificationHandler = TypeMoq.Mock.ofType( - QueryNotificationHandler, - TypeMoq.MockBehavior.Loose, - ); - testVscodeWrapper = TypeMoq.Mock.ofType(VscodeWrapper, TypeMoq.MockBehavior.Loose); - testStatusView = TypeMoq.Mock.ofType(StatusView, TypeMoq.MockBehavior.Loose); - }); - - function setupMockConfig() { - // Use the shared workspace configuration stub so config.get(...) works - const configItems: { [key: string]: any } = { - [Constants.copyIncludeHeaders]: true, - [Constants.configCopyRemoveNewLine]: false, - }; - const config = stubs.createWorkspaceConfiguration(configItems); - testVscodeWrapper - .setup((x) => - x.getConfiguration(TypeMoq.It.isAnyString(), TypeMoq.It.isAnyString()), - ) - .returns(() => config); - } - - test("Copies selection as INSERT INTO statement with small dataset", async () => { - setupMockConfig(); - - let testResult: QueryExecuteSubsetResult = { - resultSubset: { - rowCount: 2, - rows: [ - [ - { isNull: false, displayValue: "1" }, - { isNull: false, displayValue: "John" }, - { isNull: false, displayValue: "Doe" }, - ], - [ - { isNull: false, displayValue: "2" }, - { isNull: false, displayValue: "Jane" }, - { isNull: false, displayValue: "Smith" }, - ], - ], - }, - }; - - let result: QueryExecuteCompleteNotificationResult = { - ownerUri: testuri, - batchSummaries: [ - { - hasError: false, - id: 0, - selection: { - startLine: 0, - endLine: 0, - startColumn: 3, - endColumn: 3, - }, - resultSetSummaries: [ - { - id: 0, - rowCount: 2, - columnInfo: [ - { columnName: "ID" }, - { columnName: "FirstName" }, - { columnName: "LastName" }, - ], - }, - ], - executionElapsed: undefined, - executionStart: new Date().toISOString(), - executionEnd: new Date().toISOString(), - }, - ], - }; - - let testRange: ISlickRange[] = [{ fromCell: 0, fromRow: 0, toCell: 2, toRow: 1 }]; - - let capturedClipboardContent: string = ""; - testVscodeWrapper - .setup((x) => x.clipboardWriteText(TypeMoq.It.isAnyString())) - .callback((text: string) => { - capturedClipboardContent = text; - }) - .returns(() => Promise.resolve()); - - testSqlToolsServerClient - .setup((x) => x.sendRequest(TypeMoq.It.isAny(), TypeMoq.It.isAny())) - .returns(() => Promise.resolve(testResult)); - - let queryRunner = new QueryRunner( - testuri, - testuri, - testStatusView.object, - testSqlToolsServerClient.object, - testQueryNotificationHandler.object, - testVscodeWrapper.object, - ); - queryRunner.uri = testuri; - queryRunner.handleQueryComplete(result); - - await queryRunner.copyResultsAsInsertInto(testRange, 0, 0); - - // Verify the INSERT INTO format - const expectedInsert = `INSERT INTO [TableName] ([ID], [FirstName], [LastName])${os.EOL}VALUES${os.EOL} (1, 'John', 'Doe'),${os.EOL} (2, 'Jane', 'Smith');`; - assert.equal(capturedClipboardContent, expectedInsert); - }); - - test("Copies selection as INSERT INTO with NULL and mixed data types", async () => { - setupMockConfig(); - - let testResult: QueryExecuteSubsetResult = { - resultSubset: { - rowCount: 2, - rows: [ - [ - { isNull: false, displayValue: "100" }, - { isNull: true, displayValue: "null" }, - { isNull: false, displayValue: "Active" }, - ], - [ - { isNull: false, displayValue: "200" }, - { isNull: false, displayValue: "2023-01-01" }, - { isNull: true, displayValue: "null" }, - ], - ], - }, - }; - - let result: QueryExecuteCompleteNotificationResult = { - ownerUri: testuri, - batchSummaries: [ - { - hasError: false, - id: 0, - selection: { - startLine: 0, - endLine: 0, - startColumn: 3, - endColumn: 3, - }, - resultSetSummaries: [ - { - id: 0, - rowCount: 2, - columnInfo: [ - { columnName: "Amount" }, - { columnName: "CreatedDate" }, - { columnName: "Status" }, - ], - }, - ], - executionElapsed: undefined, - executionStart: new Date().toISOString(), - executionEnd: new Date().toISOString(), - }, - ], - }; - - let testRange: ISlickRange[] = [{ fromCell: 0, fromRow: 0, toCell: 2, toRow: 1 }]; - - let capturedClipboardContent: string = ""; - testVscodeWrapper - .setup((x) => x.clipboardWriteText(TypeMoq.It.isAnyString())) - .callback((text: string) => { - capturedClipboardContent = text; - }) - .returns(() => Promise.resolve()); - - testSqlToolsServerClient - .setup((x) => x.sendRequest(TypeMoq.It.isAny(), TypeMoq.It.isAny())) - .returns(() => Promise.resolve(testResult)); - - let queryRunner = new QueryRunner( - testuri, - testuri, - testStatusView.object, - testSqlToolsServerClient.object, - testQueryNotificationHandler.object, - testVscodeWrapper.object, - ); - queryRunner.uri = testuri; - queryRunner.handleQueryComplete(result); - - await queryRunner.copyResultsAsInsertInto(testRange, 0, 0); - - // Verify the INSERT INTO format with NULL values and mixed data types - const expectedInsert = `INSERT INTO [TableName] ([Amount], [CreatedDate], [Status])${os.EOL}VALUES${os.EOL} (100, NULL, 'Active'),${os.EOL} (200, '2023-01-01', NULL);`; - assert.equal(capturedClipboardContent, expectedInsert); - }); - - test("Handles large dataset exceeding 1000 rows (chunking)", async () => { - setupMockConfig(); - - // Create test data with more than 1000 rows to test chunking - const rowCount = 1500; - const rows: any[] = []; - for (let i = 1; i <= rowCount; i++) { - rows.push([ - { isNull: false, displayValue: i.toString() }, - { isNull: false, displayValue: `Name${i}` }, - ]); - } - - let testResult: QueryExecuteSubsetResult = { - resultSubset: { - rowCount: rowCount, - rows: rows, - }, - }; - - let result: QueryExecuteCompleteNotificationResult = { - ownerUri: testuri, - batchSummaries: [ - { - hasError: false, - id: 0, - selection: { - startLine: 0, - endLine: 0, - startColumn: 3, - endColumn: 3, - }, - resultSetSummaries: [ - { - id: 0, - rowCount: rowCount, - columnInfo: [{ columnName: "ID" }, { columnName: "Name" }], - }, - ], - executionElapsed: undefined, - executionStart: new Date().toISOString(), - executionEnd: new Date().toISOString(), - }, - ], - }; - - let testRange: ISlickRange[] = [ - { fromCell: 0, fromRow: 0, toCell: 1, toRow: rowCount - 1 }, - ]; - - let capturedClipboardContent: string = ""; - testVscodeWrapper - .setup((x) => x.clipboardWriteText(TypeMoq.It.isAnyString())) - .callback((text: string) => { - capturedClipboardContent = text; - }) - .returns(() => Promise.resolve()); - - testSqlToolsServerClient - .setup((x) => x.sendRequest(TypeMoq.It.isAny(), TypeMoq.It.isAny())) - .returns(() => Promise.resolve(testResult)); - - let queryRunner = new QueryRunner( - testuri, - testuri, - testStatusView.object, - testSqlToolsServerClient.object, - testQueryNotificationHandler.object, - testVscodeWrapper.object, - ); - queryRunner.uri = testuri; - queryRunner.handleQueryComplete(result); - - await queryRunner.copyResultsAsInsertInto(testRange, 0, 0); - - // Verify that multiple INSERT statements were generated (chunking occurred) - const statementCount = (capturedClipboardContent.match(/INSERT INTO/g) || []).length; - assert.equal(statementCount, 2, "Should generate 2 INSERT statements for 1500 rows"); - - // Verify that statements are separated by double newlines - assert.ok( - capturedClipboardContent.includes(`${os.EOL}${os.EOL}INSERT INTO`), - "Statements should be separated by double newlines", - ); - - // Verify first statement contains exactly 1000 rows (plus header) - const firstStatement = capturedClipboardContent.split( - `${os.EOL}${os.EOL}INSERT INTO`, - )[0]; - const firstStatementRowCount = (firstStatement.match(/\(/g) || []).length - 1; // Subtract 1 for column list parentheses - assert.equal(firstStatementRowCount, 1000, "First statement should contain 1000 rows"); - }); - }); }); /** From e8f93105812c423558a36c881c5e291940c74aeb Mon Sep 17 00:00:00 2001 From: Aasim Khan Date: Thu, 9 Oct 2025 15:07:44 -0700 Subject: [PATCH 06/11] loc files --- localization/l10n/bundle.l10n.json | 6 ++++++ localization/xliff/vscode-mssql.xlf | 10 ++++++++++ 2 files changed, 16 insertions(+) diff --git a/localization/l10n/bundle.l10n.json b/localization/l10n/bundle.l10n.json index 188dac355f..2d1de9226d 100644 --- a/localization/l10n/bundle.l10n.json +++ b/localization/l10n/bundle.l10n.json @@ -1068,6 +1068,8 @@ "SQL Plan Files": "SQL Plan Files", "Script copied to clipboard": "Script copied to clipboard", "Copied": "Copied", + "Copying results...": "Copying results...", + "Results copied to clipboard": "Results copied to clipboard", "Do you want to always display query results in a new tab instead of the query pane?": "Do you want to always display query results in a new tab instead of the query pane?", "Always show in new tab": "Always show in new tab", "Keep in query pane": "Keep in query pane", @@ -1280,6 +1282,10 @@ "message": "Sum: {0}", "comment": ["{0} is the sum"] }, + "An error occurred while copying results: {0}/{0} is the error message": { + "message": "An error occurred while copying results: {0}", + "comment": ["{0} is the error message"] + }, "{0} rows selected, click to load summary/{0} is the number of rows to fetch summary statistics for": { "message": "{0} rows selected, click to load summary", "comment": ["{0} is the number of rows to fetch summary statistics for"] diff --git a/localization/xliff/vscode-mssql.xlf b/localization/xliff/vscode-mssql.xlf index 7802df10bd..72ef8643a8 100644 --- a/localization/xliff/vscode-mssql.xlf +++ b/localization/xliff/vscode-mssql.xlf @@ -156,6 +156,10 @@ An error occurred refreshing nodes. See the MSSQL output channel for more details. + + An error occurred while copying results: {0} + {0} is the error message + An error occurred while processing your request. @@ -842,6 +846,9 @@ Copy with Headers + + Copying results... + Cost @@ -2570,6 +2577,9 @@ Results ({0}) {0} is the number of results + + Results copied to clipboard + Retry From 4584027bf6f7c546f6973e9458b324e6f213339c Mon Sep 17 00:00:00 2001 From: Aasim Khan Date: Thu, 9 Oct 2025 15:21:33 -0700 Subject: [PATCH 07/11] Adding large copy confirmation --- src/constants/locConstants.ts | 8 +++++++- src/controllers/queryRunner.ts | 31 +++++++++++++++++++++++++------ 2 files changed, 32 insertions(+), 7 deletions(-) diff --git a/src/constants/locConstants.ts b/src/constants/locConstants.ts index 7b8e08d194..1cedd4cc6e 100644 --- a/src/constants/locConstants.ts +++ b/src/constants/locConstants.ts @@ -1077,7 +1077,13 @@ export class QueryResult { args: [error], comment: ["{0} is the error message"], }); - public static; + public static largeCopyConfirmation = (numRows: number) => + l10n.t({ + message: + "{0} rows selected. Are you sure you want to copy all of these rows to the clipboard?", + args: [numRows], + comment: ["{0} is the number of rows to copy"], + }); } export class LocalContainers { diff --git a/src/controllers/queryRunner.ts b/src/controllers/queryRunner.ts index 3f7e3fb3ab..d1931808f6 100644 --- a/src/controllers/queryRunner.ts +++ b/src/controllers/queryRunner.ts @@ -667,6 +667,20 @@ export default class QueryRunner { encoding?: string; }, ): Promise { + const totalSelectedRows = this.getTotalSelectedRows(selection); + const inMemoryThreshold = getInMemoryGridDataProcessingThreshold(); + if (totalSelectedRows > inMemoryThreshold) { + const confirmation = await vscode.window.showWarningMessage( + LocalizedConstants.QueryResult.largeCopyConfirmation(totalSelectedRows), + { modal: false }, + LocalizedConstants.msgYes, + LocalizedConstants.msgNo, + ); + if (confirmation !== LocalizedConstants.msgYes) { + return; + } + } + await vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, @@ -880,12 +894,7 @@ export default class QueryRunner { this._cancelConfirmation?.resolve(); this._cancelConfirmation = undefined; - // Keep copy order deterministic - selections.sort((a, b) => a.fromRow - b.fromRow); - let totalRows = 0; - for (let range of selections) { - totalRows += range.toRow - range.fromRow + 1; - } + const totalRows = this.getTotalSelectedRows(selections); const threshold = getInMemoryGridDataProcessingThreshold(); @@ -1169,4 +1178,14 @@ export default class QueryRunner { QueryRunner._runningQueries, ); } + + private getTotalSelectedRows(selections: ISlickRange[]): number { + // Keep copy order deterministic + selections.sort((a, b) => a.fromRow - b.fromRow); + let totalRows = 0; + for (let range of selections) { + totalRows += range.toRow - range.fromRow + 1; + } + return totalRows; + } } From 38976648040a0fe7e1c1fa66568e099c78231446 Mon Sep 17 00:00:00 2001 From: Aasim Khan Date: Thu, 9 Oct 2025 15:21:51 -0700 Subject: [PATCH 08/11] Fixing stuff --- localization/l10n/bundle.l10n.json | 4 ++++ localization/xliff/vscode-mssql.xlf | 4 ++++ 2 files changed, 8 insertions(+) diff --git a/localization/l10n/bundle.l10n.json b/localization/l10n/bundle.l10n.json index 2d1de9226d..f0216874ec 100644 --- a/localization/l10n/bundle.l10n.json +++ b/localization/l10n/bundle.l10n.json @@ -1307,6 +1307,10 @@ "message": "An error occurred while retrieving rows: {0}", "comment": ["{0} is the error message"] }, + "{0} rows selected. Are you sure you want to copy all of these rows to the clipboard?/{0} is the number of rows to copy": { + "message": "{0} rows selected. Are you sure you want to copy all of these rows to the clipboard?", + "comment": ["{0} is the number of rows to copy"] + }, "{0} stopped successfully./{0} stopped successfully.": { "message": "{0} stopped successfully.", "comment": ["{0} stopped successfully."] diff --git a/localization/xliff/vscode-mssql.xlf b/localization/xliff/vscode-mssql.xlf index 72ef8643a8..c38fb5dc6e 100644 --- a/localization/xliff/vscode-mssql.xlf +++ b/localization/xliff/vscode-mssql.xlf @@ -3528,6 +3528,10 @@ {0} rows selected, click to load summary {0} is the number of rows to fetch summary statistics for + + {0} rows selected. Are you sure you want to copy all of these rows to the clipboard? + {0} is the number of rows to copy + {0} selected {0} is the number of selected rows From 5ba50cc8c6ebe175f3471d98375b2efb467eaef8 Mon Sep 17 00:00:00 2001 From: Aasim Khan Date: Thu, 9 Oct 2025 18:08:38 -0700 Subject: [PATCH 09/11] remove conf --- src/constants/locConstants.ts | 7 ------- src/controllers/queryRunner.ts | 14 -------------- 2 files changed, 21 deletions(-) diff --git a/src/constants/locConstants.ts b/src/constants/locConstants.ts index 1cedd4cc6e..af7b3eec40 100644 --- a/src/constants/locConstants.ts +++ b/src/constants/locConstants.ts @@ -1077,13 +1077,6 @@ export class QueryResult { args: [error], comment: ["{0} is the error message"], }); - public static largeCopyConfirmation = (numRows: number) => - l10n.t({ - message: - "{0} rows selected. Are you sure you want to copy all of these rows to the clipboard?", - args: [numRows], - comment: ["{0} is the number of rows to copy"], - }); } export class LocalContainers { diff --git a/src/controllers/queryRunner.ts b/src/controllers/queryRunner.ts index d1931808f6..b1193728f0 100644 --- a/src/controllers/queryRunner.ts +++ b/src/controllers/queryRunner.ts @@ -667,20 +667,6 @@ export default class QueryRunner { encoding?: string; }, ): Promise { - const totalSelectedRows = this.getTotalSelectedRows(selection); - const inMemoryThreshold = getInMemoryGridDataProcessingThreshold(); - if (totalSelectedRows > inMemoryThreshold) { - const confirmation = await vscode.window.showWarningMessage( - LocalizedConstants.QueryResult.largeCopyConfirmation(totalSelectedRows), - { modal: false }, - LocalizedConstants.msgYes, - LocalizedConstants.msgNo, - ); - if (confirmation !== LocalizedConstants.msgYes) { - return; - } - } - await vscode.window.withProgress( { location: vscode.ProgressLocation.Notification, From 059b25586949eaab46783d07ae7cd5756a00da3d Mon Sep 17 00:00:00 2001 From: Aasim Khan Date: Thu, 9 Oct 2025 18:51:05 -0700 Subject: [PATCH 10/11] loc --- localization/l10n/bundle.l10n.json | 4 ---- localization/xliff/vscode-mssql.xlf | 4 ---- 2 files changed, 8 deletions(-) diff --git a/localization/l10n/bundle.l10n.json b/localization/l10n/bundle.l10n.json index f0216874ec..2d1de9226d 100644 --- a/localization/l10n/bundle.l10n.json +++ b/localization/l10n/bundle.l10n.json @@ -1307,10 +1307,6 @@ "message": "An error occurred while retrieving rows: {0}", "comment": ["{0} is the error message"] }, - "{0} rows selected. Are you sure you want to copy all of these rows to the clipboard?/{0} is the number of rows to copy": { - "message": "{0} rows selected. Are you sure you want to copy all of these rows to the clipboard?", - "comment": ["{0} is the number of rows to copy"] - }, "{0} stopped successfully./{0} stopped successfully.": { "message": "{0} stopped successfully.", "comment": ["{0} stopped successfully."] diff --git a/localization/xliff/vscode-mssql.xlf b/localization/xliff/vscode-mssql.xlf index c38fb5dc6e..72ef8643a8 100644 --- a/localization/xliff/vscode-mssql.xlf +++ b/localization/xliff/vscode-mssql.xlf @@ -3528,10 +3528,6 @@ {0} rows selected, click to load summary {0} is the number of rows to fetch summary statistics for - - {0} rows selected. Are you sure you want to copy all of these rows to the clipboard? - {0} is the number of rows to copy - {0} selected {0} is the number of selected rows From f1c22d9568b048c7c46db8231482858dad8884dc Mon Sep 17 00:00:00 2001 From: Aasim Khan Date: Thu, 9 Oct 2025 23:59:16 -0700 Subject: [PATCH 11/11] updating to latest sts --- src/configurations/config.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/configurations/config.ts b/src/configurations/config.ts index 2e278b95c5..d09713a8fe 100644 --- a/src/configurations/config.ts +++ b/src/configurations/config.ts @@ -7,7 +7,7 @@ export const config = { service: { downloadUrl: "https://github.com/Microsoft/sqltoolsservice/releases/download/{#version#}/microsoft.sqltools.servicelayer-{#fileName#}", - version: "5.0.20250910.2", + version: "5.0.20251009.1", downloadFileNames: { Windows_86: "win-x86-net8.0.zip", Windows_64: "win-x64-net8.0.zip",