diff --git a/localization/l10n/bundle.l10n.json b/localization/l10n/bundle.l10n.json
index eea22efa50..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,18 +1282,18 @@
"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"]
},
"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 1ab9665cb8..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
@@ -1921,11 +1928,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...
@@ -2572,6 +2577,9 @@
Results ({0})
{0} is the number of results
+
+ Results copied to clipboard
+
Retry
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",
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 d10173feef..af7b3eec40 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?",
@@ -1016,6 +1018,35 @@ 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 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",
@@ -1023,16 +1054,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");
@@ -1051,7 +1077,6 @@ export class QueryResult {
args: [error],
comment: ["{0} is the error message"],
});
- public static;
}
export class LocalContainers {
diff --git a/src/controllers/queryRunner.ts b/src/controllers/queryRunner.ts
index 2383327363..b1193728f0 100644
--- a/src/controllers/queryRunner.ts
+++ b/src/controllers/queryRunner.ts
@@ -26,10 +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 {
@@ -52,10 +58,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[];
@@ -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,52 +827,15 @@ 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;
private _cancelConfirmation: Deferred;
public async generateSelectionSummaryData(
- selection: ISlickRange[],
+ selections: ISlickRange[],
batchId: number,
resultId: number,
showThresholdWarning: boolean = true,
@@ -1094,7 +860,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 +868,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,
});
@@ -1114,12 +880,7 @@ export default class QueryRunner {
this._cancelConfirmation?.resolve();
this._cancelConfirmation = undefined;
- // Keep copy order deterministic
- selection.sort((a, b) => a.fromRow - b.fromRow);
- let totalRows = 0;
- for (let range of selection) {
- totalRows += range.toRow - range.fromRow + 1;
- }
+ const totalRows = this.getTotalSelectedRows(selections);
const threshold = getInMemoryGridDataProcessingThreshold();
@@ -1132,7 +893,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 +901,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, {
@@ -1293,39 +1020,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(
@@ -1344,30 +1038,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
@@ -1443,42 +1113,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
@@ -1489,69 +1123,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
@@ -1561,306 +1132,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
@@ -1893,4 +1164,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;
+ }
}
diff --git a/src/models/contracts/queryExecute.ts b/src/models/contracts/queryExecute.ts
index b86feec651..78a693a0bf 100644
--- a/src/models/contracts/queryExecute.ts
+++ b/src/models/contracts/queryExecute.ts
@@ -199,3 +199,86 @@ 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,
+ 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 5afd465115..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,
);
});
@@ -518,85 +484,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
@@ -604,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/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 6ca767699a..970475e8a2 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";
import { HeaderMenu } from "./headerFilter.plugin";
export interface ICellSelectionModelOptions {
@@ -699,7 +700,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();
}
@@ -709,8 +710,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/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/plugins/headerFilter.plugin.ts b/src/reactviews/pages/QueryResult/table/plugins/headerFilter.plugin.ts
index f5175f4041..5f372767e8 100644
--- a/src/reactviews/pages/QueryResult/table/plugins/headerFilter.plugin.ts
+++ b/src/reactviews/pages/QueryResult/table/plugins/headerFilter.plugin.ts
@@ -38,6 +38,7 @@ export class HeaderMenu {
grid: Slick.Grid;
column: FilterableColumn;
}>();
+ public onSortChanged = new Slick.Event();
public onCommand = new Slick.Event>();
public enabled: boolean = true;
@@ -305,6 +306,8 @@ export class HeaderMenu {
if (headerNode) {
this.updateSortIndicator(headerNode, command);
}
+
+ this.onSortChanged.notify(command);
}
private getHeaderNode(columnId: string): HTMLElement | null {
diff --git a/src/reactviews/pages/QueryResult/table/table.ts b/src/reactviews/pages/QueryResult/table/table.ts
index 9c9a0fa9c3..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,
@@ -77,6 +77,13 @@ export class Table implements IThemable {
) {
this.linkHandler = linkHandler;
this.headerFilter = new HeaderMenu(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.selectionModel = new CellSelectionModel(
{
hasRowSelector: true,
@@ -141,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/reactviews/pages/QueryResult/table/tableDataView.ts b/src/reactviews/pages/QueryResult/table/tableDataView.ts
index e2926c0ad8..5f7707aa8c 100644
--- a/src/reactviews/pages/QueryResult/table/tableDataView.ts
+++ b/src/reactviews/pages/QueryResult/table/tableDataView.ts
@@ -165,7 +165,6 @@ export class TableDataView implements IDisposableData
if (this._data.length === this._allData.length) {
await this.clearFilter();
} else {
- console.log("filterstatechange");
// this._onFilterStateChange.fire();
}
}
@@ -203,7 +202,6 @@ export class TableDataView implements IDisposableData
column: args.sortCol!,
sortDirection: args.sortAsc ? SortProperties.ASC : SortProperties.DESC,
};
- console.log(args);
// this._onSortComplete.fire(args);
}
@@ -263,7 +261,6 @@ export class TableDataView implements IDisposableData
} else {
this._data.push(...inputArray);
}
- console.log(this.getLength());
// this._onRowCountChange.fire(this.getLength());
}
@@ -272,7 +269,6 @@ export class TableDataView implements IDisposableData
if (this._filterEnabled) {
this._allData = new Array();
}
- console.log(this.getLength());
// this._onRowCountChange.fire(this.getLength());
}
@@ -282,7 +278,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(() => {
@@ -303,7 +298,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;
@@ -321,7 +315,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;
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");
- });
- });
});
/**