/*--------------------------------------------------------------------------------------------- * Copyright (c) Microsoft Corporation. All rights reserved. * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; import * as azdata from 'azdata'; import * as nls from 'vscode-nls'; const localize = nls.loadMessageBundle(); interface QueryCompletionHandler { ownerUri: string; handler: (results: azdata.BatchSummary[]) => void } interface QueryMessageHandler { ownerUri: string; handler: (results: azdata.QueryExecuteMessageParams) => void } export class SqlNotebookController implements vscode.Disposable { private readonly _cellUriScheme = 'vscode-notebook-cell'; private readonly _connectionLabel = (serverName: string) => localize('notebookConnection', 'Connected to: {0}', serverName); private readonly _disconnectedLabel = localize('notebookDisconnected', 'Disconnected'); private readonly _disposables = new Array(); private readonly _controller: vscode.NotebookController; private readonly _connectionsMap = new Map(); private readonly _executionOrderMap = new Map(); private readonly _queryProvider: azdata.QueryProvider; private readonly _connProvider: azdata.ConnectionProvider; private readonly _connectionLabelItem: vscode.StatusBarItem; private _queryCompleteHandler: QueryCompletionHandler | undefined; private _queryMessageHandler: QueryMessageHandler | undefined; private _activeCellUri: string; constructor() { this._controller = vscode.notebooks.createNotebookController('sql-controller-id', 'jupyter-notebook', 'SQL'); this._controller.supportedLanguages = ['sql']; this._controller.supportsExecutionOrder = true; this._controller.executeHandler = this.execute.bind(this) as (cells: vscode.NotebookCell[], notebook: vscode.NotebookDocument, controller: vscode.NotebookController) => void | Thenable; const sqlProvider = 'MSSQL'; this._queryProvider = azdata.dataprotocol.getProvider(sqlProvider, azdata.DataProviderType.QueryProvider); this._queryProvider.registerOnQueryComplete(result => this.handleQueryComplete(result)); this._queryProvider.registerOnMessage(message => this.handleQueryMessage(message)); this._connProvider = azdata.dataprotocol.getProvider(sqlProvider, azdata.DataProviderType.ConnectionProvider); const commandName = 'mssql.changeNotebookConnection'; let changeConnectionCommand = vscode.commands.registerCommand(commandName, async () => await this.changeConnection()); this._disposables.push(changeConnectionCommand); this._connectionLabelItem = vscode.window.createStatusBarItem(vscode.StatusBarAlignment.Right); this._connectionLabelItem.text = this._disconnectedLabel; this._connectionLabelItem.tooltip = localize('changeNotebookConnection', 'Change SQL Notebook Connection'); this._connectionLabelItem.command = commandName; this._disposables.push(this._connectionLabelItem); // Show connection status if there's a notebook already open when ADS starts if (vscode.window.activeTextEditor?.document.notebook) { this._connectionLabelItem.show(); } let editorChangedEvent = vscode.window.onDidChangeActiveTextEditor(async editor => await this.handleActiveEditorChanged(editor)); this._disposables.push(editorChangedEvent); let docClosedEvent = vscode.workspace.onDidCloseTextDocument(document => this.handleDocumentClosed(document)); this._disposables.push(docClosedEvent); } private handleQueryComplete(result: azdata.QueryExecuteCompleteNotificationResult): void { if (this._queryCompleteHandler && this._queryCompleteHandler.ownerUri === result.ownerUri) { // Check if handler is undefined separately in case the result URI is also undefined this._queryCompleteHandler.handler(result.batchSummaries); } } private handleQueryMessage(message: azdata.QueryExecuteMessageParams): void { if (this._queryMessageHandler && this._queryMessageHandler.ownerUri === message.ownerUri) { // Check if handler is undefined separately in case the result URI is also undefined this._queryMessageHandler.handler(message); } } private async handleActiveEditorChanged(editor: vscode.TextEditor | undefined): Promise { let notebook = editor?.document.notebook; if (!notebook) { // Hide status bar item if the current editor isn't a notebook this._connectionLabelItem.hide(); } else { let connection = this._connectionsMap.get(notebook.uri); if (connection) { this._connectionLabelItem.text = this._connectionLabel(connection.options['server']); // If this editor is for a cell, then update the connection for it this.updateCellConnection(notebook.uri, connection); } else { this._connectionLabelItem.text = this._disconnectedLabel; } this._connectionLabelItem.show(); } } public getConnectionProfile(connection: azdata.connection.Connection): azdata.IConnectionProfile { let connectionProfile: azdata.IConnectionProfile = { connectionName: connection.options.connectionName as string, serverName: connection.options.server as string, databaseName: connection.options.database as string, userName: connection.options.user as string, password: connection.options.password as string, authenticationType: connection.options.authenticationType as string, savePassword: connection.options.savePassword as boolean, groupFullName: undefined, groupId: undefined, providerName: connection.providerName, saveProfile: false, id: connection.connectionId, options: connection.options }; return connectionProfile; } private handleDocumentClosed(editor: vscode.TextDocument): void { // Have to check isClosed here since this event is also emitted on doc language changes if (editor.notebook && editor.isClosed) { // Remove the connection & execution associations if the doc is closed, but don't close the connection since it might be re-used elsewhere this._connectionsMap.delete(editor.notebook.uri); this._executionOrderMap.delete(editor.notebook.uri); } } private updateCellConnection(notebookUri: vscode.Uri, connection: azdata.connection.Connection): void { let docUri = vscode.window.activeTextEditor?.document.uri; if (docUri && docUri.scheme === this._cellUriScheme && docUri.path === notebookUri.path) { if (this._activeCellUri) { this._connProvider.disconnect(this._activeCellUri).then(() => undefined, error => console.log(error)); } this._activeCellUri = docUri.toString(); // Delay connecting in case user is clicking between cells a lot setTimeout(() => { if (this._activeCellUri === docUri!.toString()) { let profile = this.getConnectionProfile(connection); this._connProvider.connect(docUri!.toString(), profile).then( connected => { if (!connected) { console.log(`Failed to update cell connection for cell: ${docUri!.toString()}`); } }, error => { console.log(error); }); } }, 200); } } private async changeConnection(notebook?: vscode.NotebookDocument): Promise { let connection: azdata.connection.Connection | undefined; let notebookUri = notebook?.uri ?? vscode.window.activeTextEditor?.document.notebook?.uri; if (notebookUri) { connection = await azdata.connection.openConnectionDialog(['MSSQL']); if (connection) { this._connectionsMap.set(notebookUri, connection); this._connectionLabelItem.text = this._connectionLabel(connection.options['server']); // Connect current notebook cell, if there is one this.updateCellConnection(notebookUri, connection); } else { this._connectionLabelItem.text = this._disconnectedLabel; } this._connectionLabelItem.show(); } return connection; } private async execute(cells: vscode.NotebookCell[], notebook: vscode.NotebookDocument, controller: vscode.NotebookController): Promise { if (this._queryCompleteHandler) { throw new Error(localize('queryInProgressError', 'Another query is currently in progress. Please wait for that query to complete before running these cells.')); } let connection = this._connectionsMap.get(notebook.uri); if (!connection) { connection = await this.changeConnection(notebook); } let executionOrder = this._executionOrderMap.get(notebook.uri) ?? 0; for (let cell of cells) { await this.doExecution(cell, connection, ++executionOrder); } this._executionOrderMap.set(notebook.uri, executionOrder); } private async doExecution(cell: vscode.NotebookCell, connection: azdata.connection.Connection | undefined, executionOrder: number): Promise { const execution = this._controller.createNotebookCellExecution(cell); execution.executionOrder = executionOrder; execution.start(Date.now()); await execution.clearOutput(); if (!connection) { await execution.appendOutput([ new vscode.NotebookCellOutput([ vscode.NotebookCellOutputItem.text(localize('noConnectionError', 'No connection provided.')) ]) ]); execution.end(false, Date.now()); return; } let cancelHandler: vscode.Disposable | undefined; try { const ownerUri = await azdata.connection.getUriForConnection(connection.connectionId); await this._queryProvider.runQueryString(ownerUri, cell.document.getText()); cancelHandler = execution.token.onCancellationRequested(async () => await this._queryProvider.cancelQuery(ownerUri)); let queryComplete = new Promise(resolve => { let queryCompleteHandler = async (batchSummaries: azdata.BatchSummary[]) => { let tableHtmlEntries: string[] = []; for (let batchSummary of batchSummaries) { if (execution.token.isCancellationRequested) { break; } for (let resultSummary of batchSummary.resultSetSummaries || []) { if (execution.token.isCancellationRequested) { break; } if (resultSummary.rowCount > 0) { // Add column headers let tableHtml = ` `; for (let column of resultSummary.columnInfo) { tableHtml += ``; } tableHtml += ''; // Add rows and cells let subsetResult = await this._queryProvider.getQueryRows({ ownerUri: ownerUri, batchIndex: batchSummary.id, resultSetIndex: resultSummary.id, rowsStartIndex: 0, rowsCount: resultSummary.rowCount }); tableHtml += ''; for (let row of subsetResult.resultSubset.rows) { tableHtml += ''; for (let cell of row) { tableHtml += ``; } tableHtml += ''; } tableHtml += '
${htmlEscape(column.columnName)}
${htmlEscape(cell.displayValue)}
'; tableHtmlEntries.push(tableHtml); } } } if (execution.token.isCancellationRequested) { await execution.appendOutput([ new vscode.NotebookCellOutput([ vscode.NotebookCellOutputItem.text(localize('cellExecutionCancelled', 'Cell execution cancelled.')) ]) ]); execution.end(false, Date.now()); } else { await execution.appendOutput([ new vscode.NotebookCellOutput([ vscode.NotebookCellOutputItem.text(tableHtmlEntries.join('

'), 'text/html') ]) ]); execution.end(true, Date.now()); } resolve(); }; this._queryCompleteHandler = { ownerUri: ownerUri, handler: queryCompleteHandler }; }); this._queryMessageHandler = { ownerUri: ownerUri, handler: async message => { await execution.appendOutput([ new vscode.NotebookCellOutput([ vscode.NotebookCellOutputItem.text(message.message.message) ]) ]); } }; await queryComplete; } catch (error) { await execution.appendOutput([ new vscode.NotebookCellOutput([ vscode.NotebookCellOutputItem.error(error) ]) ]); execution.end(false, Date.now()); } finally { cancelHandler?.dispose(); this._queryCompleteHandler = undefined; this._queryMessageHandler = undefined; } } dispose() { this._disposables.forEach(d => d.dispose()); } } function htmlEscape(html: string): string { return html.replace(/[<|>|&|"]/g, function (match) { switch (match) { case '<': return '<'; case '>': return '>'; case '&': return '&'; case '"': return '"'; case '\'': return '''; default: return match; } }); }