diff --git a/extensions/mssql/package.json b/extensions/mssql/package.json index c6634a00e7..7c55aada4f 100644 --- a/extensions/mssql/package.json +++ b/extensions/mssql/package.json @@ -18,10 +18,15 @@ "update-grammar": "node ../../build/npm/update-grammar.js Microsoft/vscode-mssql syntaxes/SQL.plist ./syntaxes/sql.tmLanguage.json" }, "dependencies": { + "clipboardy": "^1.2.3", "dataprotocol-client": "github:Microsoft/sqlops-dataprotocolclient#0.2.15", "opener": "^1.4.3", "service-downloader": "github:anthonydresser/service-downloader#0.1.5", - "vscode-extension-telemetry": "^0.0.15" + "stream-meter": "^1.0.4", + "uri-js": "^4.2.2", + "vscode-extension-telemetry": "^0.0.15", + "vscode-nls": "2.0.2", + "webhdfs": "^1.1.1" }, "devDependencies": { }, diff --git a/extensions/mssql/src/apiWrapper.ts b/extensions/mssql/src/apiWrapper.ts new file mode 100644 index 0000000000..1e91915904 --- /dev/null +++ b/extensions/mssql/src/apiWrapper.ts @@ -0,0 +1,93 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import * as vscode from 'vscode'; +import * as sqlops from 'sqlops'; + +/** + * Wrapper class to act as a facade over VSCode and Data APIs and allow us to test / mock callbacks into + * this API from our code + * + * @export + * @class ApiWrapper + */ +export class ApiWrapper { + // Data APIs + public registerConnectionProvider(provider: sqlops.ConnectionProvider): vscode.Disposable { + return sqlops.dataprotocol.registerConnectionProvider(provider); + } + + public registerObjectExplorerNodeProvider(provider: sqlops.ObjectExplorerNodeProvider): vscode.Disposable { + return sqlops.dataprotocol.registerObjectExplorerNodeProvider(provider); + } + + public registerTaskServicesProvider(provider: sqlops.TaskServicesProvider): vscode.Disposable { + return sqlops.dataprotocol.registerTaskServicesProvider(provider); + } + + public registerFileBrowserProvider(provider: sqlops.FileBrowserProvider): vscode.Disposable { + return sqlops.dataprotocol.registerFileBrowserProvider(provider); + } + + public registerTaskHandler(taskId: string, handler: (profile: sqlops.IConnectionProfile) => void): void { + sqlops.tasks.registerTask(taskId, handler); + } + + // VSCode APIs + + public executeCommand(command: string, ...rest: any[]): Thenable { + return vscode.commands.executeCommand(command, ...rest); + } + + public registerCommand(command: string, callback: (...args: any[]) => any, thisArg?: any): vscode.Disposable { + return vscode.commands.registerCommand(command, callback, thisArg); + } + + public showOpenDialog(options: vscode.OpenDialogOptions): Thenable { + return vscode.window.showOpenDialog(options); + } + + public showSaveDialog(options: vscode.SaveDialogOptions): Thenable { + return vscode.window.showSaveDialog(options); + } + + public openTextDocument(uri: vscode.Uri): Thenable; + public openTextDocument(options: { language?: string; content?: string; }): Thenable; + public openTextDocument(uriOrOptions): Thenable { + return vscode.workspace.openTextDocument(uriOrOptions); + } + + public showTextDocument(document: vscode.TextDocument, column?: vscode.ViewColumn, preserveFocus?: boolean, preview?: boolean): Thenable { + let options: vscode.TextDocumentShowOptions = { + viewColumn: column, + preserveFocus: preserveFocus, + preview: preview + }; + return vscode.window.showTextDocument(document, options); + } + + public showErrorMessage(message: string, ...items: string[]): Thenable { + return vscode.window.showErrorMessage(message, ...items); + } + + public showWarningMessage(message: string, ...items: string[]): Thenable { + return vscode.window.showWarningMessage(message, ...items); + } + + public showInformationMessage(message: string, ...items: string[]): Thenable { + return vscode.window.showInformationMessage(message, ...items); + } + + public createStatusBarItem(alignment?: vscode.StatusBarAlignment, priority?: number): vscode.StatusBarItem { + return vscode.window.createStatusBarItem(alignment, priority); + } + + public get workspaceFolders(): vscode.WorkspaceFolder[] { + return vscode.workspace.workspaceFolders; + } + +} diff --git a/extensions/mssql/src/appContext.ts b/extensions/mssql/src/appContext.ts new file mode 100644 index 0000000000..eaedd182ed --- /dev/null +++ b/extensions/mssql/src/appContext.ts @@ -0,0 +1,28 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import * as vscode from 'vscode'; +import { ApiWrapper } from './apiWrapper'; + +/** + * Global context for the application + */ +export class AppContext { + + private serviceMap: Map = new Map(); + constructor(public readonly extensionContext: vscode.ExtensionContext, public readonly apiWrapper: ApiWrapper) { + this.apiWrapper = apiWrapper || new ApiWrapper(); + } + + public getService(serviceName: string): T { + return this.serviceMap.get(serviceName) as T; + } + + public registerService(serviceName: string, service: T): void { + this.serviceMap.set(serviceName, service); + } +} diff --git a/extensions/mssql/src/constants.ts b/extensions/mssql/src/constants.ts index 1dc58ff589..5baec591b4 100644 --- a/extensions/mssql/src/constants.ts +++ b/extensions/mssql/src/constants.ts @@ -10,3 +10,51 @@ export const serviceCrashMessage = 'SQL Tools Service component exited unexpecte export const serviceCrashButton = 'View Known Issues'; export const serviceCrashLink = 'https://github.com/Microsoft/vscode-mssql/wiki/SqlToolsService-Known-Issues'; export const extensionConfigSectionName = 'mssql'; + +// DATA PROTOCOL VALUES /////////////////////////////////////////////////////////// +export const mssqlClusterProviderName = 'mssqlCluster'; +export const hadoopKnoxEndpointName = 'Knox'; +export const protocolVersion = '1.0'; +export const hostPropName = 'host'; +export const userPropName = 'user'; +export const knoxPortPropName = 'knoxport'; +export const passwordPropName = 'password'; +export const groupIdPropName = 'groupId'; +export const defaultKnoxPort = '30443'; +export const groupIdName = 'groupId'; +export const sqlProviderName = 'MSSQL'; +export const dataService = 'Data Services'; + +export const hdfsHost = 'host'; +export const hdfsUser = 'user'; +export const UNTITLED_SCHEMA = 'untitled'; + +export const hadoopConnectionTimeoutSeconds = 15; +export const hdfsRootPath = '/'; + +export const clusterEndpointsProperty = 'clusterEndpoints'; +export const isBigDataClusterProperty = 'isBigDataCluster'; + +// SERVICE NAMES ////////////////////////////////////////////////////////// +export const ObjectExplorerService = 'objectexplorer'; +export const objectExplorerPrefix: string = 'objectexplorer://'; +export const ViewType = 'view'; + +export enum BuiltInCommands { + SetContext = 'setContext' +} + +export enum CommandContext { + WizardServiceEnabled = 'wizardservice:enabled' +} + +export enum HdfsItems { + Connection = 'hdfs:connection', + Folder = 'hdfs:folder', + File = 'hdfs:file', + Message = 'hdfs:message' +} + +export enum HdfsItemsSubType { + Spark = 'hdfs:spark' +} \ No newline at end of file diff --git a/extensions/mssql/src/localizedConstants.ts b/extensions/mssql/src/localizedConstants.ts new file mode 100644 index 0000000000..48ac14d1d3 --- /dev/null +++ b/extensions/mssql/src/localizedConstants.ts @@ -0,0 +1,13 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import * as nls from 'vscode-nls'; +const localize = nls.loadMessageBundle(); + +// HDFS Constants ////////////////////////////////////////////////////////// +export const msgMissingNodeContext = localize('msgMissingNodeContext', 'Node Command called without any node passed'); +export const msgTimeout = localize('connectionTimeout', 'connection timed out. Host name or port may be incorrect'); diff --git a/extensions/mssql/src/main.ts b/extensions/mssql/src/main.ts index 2588febfcc..a376dc5968 100644 --- a/extensions/mssql/src/main.ts +++ b/extensions/mssql/src/main.ts @@ -5,6 +5,7 @@ 'use strict'; import * as vscode from 'vscode'; +import * as sqlops from 'sqlops'; import * as path from 'path'; import { SqlOpsDataClient, ClientOptions } from 'dataprotocol-client'; import { IConfig, ServerProvider, Events } from 'service-downloader'; @@ -17,6 +18,9 @@ import { AzureResourceProvider } from './resourceProvider/resourceProvider'; import * as Utils from './utils'; import { Telemetry, LanguageClientErrorHandler } from './telemetry'; import { TelemetryFeature, AgentServicesFeature, DacFxServicesFeature } from './features'; +import { AppContext } from './appContext'; +import { ApiWrapper } from './apiWrapper'; +import { MssqlObjectExplorerNodeProvider } from './objectExplorerNodeProvider/objectExplorerNodeProvider'; const baseConfig = require('./config.json'); const outputChannel = vscode.window.createOutputChannel(Constants.serviceName); @@ -85,6 +89,8 @@ export async function activate(context: vscode.ExtensionContext) { languageClient.start(); credentialsStore.start(); resourceProvider.start(); + let nodeProvider = new MssqlObjectExplorerNodeProvider(new AppContext(context, new ApiWrapper())); + sqlops.dataprotocol.registerObjectExplorerNodeProvider(nodeProvider); }, e => { Telemetry.sendTelemetryEvent('ServiceInitializingFailed'); vscode.window.showErrorMessage('Failed to start Sql tools service'); diff --git a/extensions/mssql/src/objectExplorerNodeProvider/cancelableStream.ts b/extensions/mssql/src/objectExplorerNodeProvider/cancelableStream.ts new file mode 100644 index 0000000000..f1ef49a591 --- /dev/null +++ b/extensions/mssql/src/objectExplorerNodeProvider/cancelableStream.ts @@ -0,0 +1,27 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import { Transform } from 'stream'; +import * as vscode from 'vscode'; +import * as nls from 'vscode-nls'; + +const localize = nls.loadMessageBundle(); + +export class CancelableStream extends Transform { + constructor(private cancelationToken: vscode.CancellationTokenSource) { + super(); + } + + public _transform(chunk: any, encoding: string, callback: Function): void { + if (this.cancelationToken && this.cancelationToken.token.isCancellationRequested) { + callback(new Error(localize('streamCanceled', 'Stream operation canceled by the user'))); + } else { + this.push(chunk); + callback(); + } + } +} diff --git a/extensions/mssql/src/objectExplorerNodeProvider/command.ts b/extensions/mssql/src/objectExplorerNodeProvider/command.ts new file mode 100644 index 0000000000..1e131df137 --- /dev/null +++ b/extensions/mssql/src/objectExplorerNodeProvider/command.ts @@ -0,0 +1,176 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; +import * as vscode from 'vscode'; +import * as sqlops from 'sqlops'; +import * as nls from 'vscode-nls'; +const localize = nls.loadMessageBundle(); + +import { ApiWrapper } from '../apiWrapper'; +import { TreeNode } from './treeNodes'; +import { QuestionTypes, IPrompter, IQuestion } from '../prompts/question'; +import * as utils from '../utils'; +import * as constants from '../constants'; +import { AppContext } from '../appContext'; + +export interface ICommandContextParsingOptions { + editor: boolean; + uri: boolean; +} + +export interface ICommandBaseContext { + command: string; + editor?: vscode.TextEditor; + uri?: vscode.Uri; +} + +export interface ICommandUnknownContext extends ICommandBaseContext { + type: 'unknown'; +} + +export interface ICommandUriContext extends ICommandBaseContext { + type: 'uri'; +} + +export interface ICommandViewContext extends ICommandBaseContext { + type: 'view'; + node: TreeNode; +} + +export interface ICommandObjectExplorerContext extends ICommandBaseContext { + type: 'objectexplorer'; + explorerContext: sqlops.ObjectExplorerContext; +} + +export type CommandContext = ICommandObjectExplorerContext | ICommandViewContext | ICommandUriContext | ICommandUnknownContext; + +function isTextEditor(editor: any): editor is vscode.TextEditor { + if (editor === undefined) { return false; } + + return editor.id !== undefined && ((editor as vscode.TextEditor).edit !== undefined || (editor as vscode.TextEditor).document !== undefined); +} + +export abstract class Command extends vscode.Disposable { + + + protected readonly contextParsingOptions: ICommandContextParsingOptions = { editor: false, uri: false }; + + private disposable: vscode.Disposable; + + constructor(command: string | string[], protected appContext: AppContext) { + super(() => this.dispose()); + + if (typeof command === 'string') { + this.disposable = this.apiWrapper.registerCommand(command, (...args: any[]) => this._execute(command, ...args), this); + + return; + } + + const subscriptions = command.map(cmd => this.apiWrapper.registerCommand(cmd, (...args: any[]) => this._execute(cmd, ...args), this)); + this.disposable = vscode.Disposable.from(...subscriptions); + } + + dispose(): void { + this.disposable && this.disposable.dispose(); + } + + protected get apiWrapper(): ApiWrapper { + return this.appContext.apiWrapper; + } + + protected async preExecute(...args: any[]): Promise { + return this.execute(...args); + } + + abstract execute(...args: any[]): any; + + protected _execute(command: string, ...args: any[]): any { + // TODO consider using Telemetry.trackEvent(command); + + const [context, rest] = Command.parseContext(command, this.contextParsingOptions, ...args); + return this.preExecute(context, ...rest); + } + + private static parseContext(command: string, options: ICommandContextParsingOptions, ...args: any[]): [CommandContext, any[]] { + let editor: vscode.TextEditor | undefined = undefined; + + let firstArg = args[0]; + if (options.editor && (firstArg === undefined || isTextEditor(firstArg))) { + editor = firstArg; + args = args.slice(1); + firstArg = args[0]; + } + + if (options.uri && (firstArg === undefined || firstArg instanceof vscode.Uri)) { + const [uri, ...rest] = args as [vscode.Uri, any]; + return [{ command: command, type: 'uri', editor: editor, uri: uri }, rest]; + } + + if (firstArg instanceof TreeNode) { + const [node, ...rest] = args as [TreeNode, any]; + return [{ command: command, type: constants.ViewType, node: node }, rest]; + } + + if (firstArg && utils.isObjectExplorerContext(firstArg)) { + const [explorerContext, ...rest] = args as [sqlops.ObjectExplorerContext, any]; + return [{ command: command, type: constants.ObjectExplorerService, explorerContext: explorerContext }, rest]; + } + + return [{ command: command, type: 'unknown', editor: editor }, args]; + } +} + +export abstract class ProgressCommand extends Command { + static progressId = 0; + constructor(private command: string, protected prompter: IPrompter, appContext: AppContext) { + super(command, appContext); + } + + protected async executeWithProgress( + execution: (cancelToken: vscode.CancellationTokenSource) => Promise, + label: string, + isCancelable: boolean = false, + onCanceled?: () => void + ): Promise { + let disposables: vscode.Disposable[] = []; + const tokenSource = new vscode.CancellationTokenSource(); + const statusBarItem = this.apiWrapper.createStatusBarItem(vscode.StatusBarAlignment.Left); + disposables.push(vscode.Disposable.from(statusBarItem)); + statusBarItem.text = localize('progress', '$(sync~spin) {0}...', label); + if (isCancelable) { + const cancelCommandId = `cancelProgress${ProgressCommand.progressId++}`; + disposables.push(this.apiWrapper.registerCommand(cancelCommandId, async () => { + if (await this.confirmCancel()) { + tokenSource.cancel(); + } + })); + statusBarItem.tooltip = localize('cancelTooltip', 'Cancel'); + statusBarItem.command = cancelCommandId; + } + statusBarItem.show(); + + try { + await execution(tokenSource); + } catch (error) { + if (isCancelable && onCanceled && tokenSource.token.isCancellationRequested) { + // The error can be assumed to be due to cancelation occurring. Do the callback + onCanceled(); + } else { + throw error; + } + } finally { + disposables.forEach(d => d.dispose()); + } + } + + private async confirmCancel(): Promise { + return await this.prompter.promptSingle({ + type: QuestionTypes.confirm, + message: localize('cancel', 'Cancel operation?'), + default: true + }); + } +} diff --git a/extensions/mssql/src/objectExplorerNodeProvider/connection.ts b/extensions/mssql/src/objectExplorerNodeProvider/connection.ts new file mode 100644 index 0000000000..5c5f4991cb --- /dev/null +++ b/extensions/mssql/src/objectExplorerNodeProvider/connection.ts @@ -0,0 +1,222 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import * as sqlops from 'sqlops'; +import * as UUID from 'vscode-languageclient/lib/utils/uuid'; +import * as nls from 'vscode-nls'; +const localize = nls.loadMessageBundle(); + +import * as constants from '../constants'; +import * as LocalizedConstants from '../localizedConstants'; +import * as utils from '../utils'; +import { IFileSource, HdfsFileSource, IHdfsOptions, IRequestParams, FileSourceFactory } from './fileSources'; + +function appendIfExists(uri: string, propName: string, propValue: string): string { + if (propValue) { + uri = `${uri};${propName}=${propValue}`; + } + return uri; +} + +interface IValidationResult { + isValid: boolean; + errors: string; +} + +export class Connection { + private _host: string; + private _knoxPort: string; + + constructor(private connectionInfo: sqlops.ConnectionInfo, private connectionUri?: string, private _connectionId?: string) { + if (!this.connectionInfo) { + throw new Error(localize('connectionInfoMissing', 'connectionInfo is required')); + } + + if (!this._connectionId) { + this._connectionId = UUID.generateUuid(); + } + } + + public get uri(): string { + return this.connectionUri; + } + + public saveUriWithPrefix(prefix: string): string { + let uri = `${prefix}${this.host}`; + uri = appendIfExists(uri, constants.knoxPortPropName, this.knoxport); + uri = appendIfExists(uri, constants.userPropName, this.user); + uri = appendIfExists(uri, constants.groupIdPropName, this.connectionInfo.options[constants.groupIdPropName]); + this.connectionUri = uri; + return this.connectionUri; + } + + public async tryConnect(factory?: FileSourceFactory): Promise { + let fileSource = this.createHdfsFileSource(factory, { + timeout: this.connecttimeout + }); + let summary: sqlops.ConnectionInfoSummary = undefined; + try { + await fileSource.enumerateFiles(constants.hdfsRootPath); + summary = { + ownerUri: this.connectionUri, + connectionId: this.connectionId, + connectionSummary: { + serverName: this.host, + databaseName: undefined, + userName: this.user + }, + errorMessage: undefined, + errorNumber: undefined, + messages: undefined, + serverInfo: this.getEmptyServerInfo() + }; + } catch (error) { + summary = { + ownerUri: this.connectionUri, + connectionId: undefined, + connectionSummary: undefined, + errorMessage: this.getConnectError(error), + errorNumber: undefined, + messages: undefined, + serverInfo: undefined + }; + } + return summary; + } + + private getConnectError(error: string | Error): string { + let errorMsg = utils.getErrorMessage(error); + if (errorMsg.indexOf('ETIMEDOUT') > -1) { + errorMsg = LocalizedConstants.msgTimeout; + } else if (errorMsg.indexOf('ENOTFOUND') > -1) { + errorMsg = LocalizedConstants.msgTimeout; + } + return localize('connectError', 'Connection failed with error: {0}', errorMsg); + } + + private getEmptyServerInfo(): sqlops.ServerInfo { + let info: sqlops.ServerInfo = { + serverMajorVersion: 0, + serverMinorVersion: 0, + serverReleaseVersion: 0, + engineEditionId: 0, + serverVersion: '', + serverLevel: '', + serverEdition: '', + isCloud: false, + azureVersion: 0, + osVersion: '', + options: { isBigDataCluster: false, clusterEndpoints: []} + }; + return info; + } + + public get connectionId(): string { + return this._connectionId; + } + + public get host(): string { + if (!this._host) { + this.ensureHostAndPort(); + } + return this._host; + } + + /** + * Sets host and port values, using any ',' or ':' delimited port in the hostname in + * preference to the built in port. + */ + private ensureHostAndPort(): void { + this._host = this.connectionInfo.options[constants.hostPropName]; + this._knoxPort = Connection.getKnoxPortOrDefault(this.connectionInfo); + // determine whether the host has either a ',' or ':' in it + this.setHostAndPort(','); + this.setHostAndPort(':'); + } + + // set port and host correctly after we've identified that a delimiter exists in the host name + private setHostAndPort(delimeter: string): void { + let originalHost = this._host; + let index = originalHost.indexOf(delimeter); + if (index > -1) { + this._host = originalHost.slice(0, index); + this._knoxPort = originalHost.slice(index + 1); + } + } + + public get user(): string { + return this.connectionInfo.options[constants.userPropName]; + } + + public get password(): string { + return this.connectionInfo.options[constants.passwordPropName]; + } + + public get knoxport(): string { + if (!this._knoxPort) { + this.ensureHostAndPort(); + } + return this._knoxPort; + } + + private static getKnoxPortOrDefault(connInfo: sqlops.ConnectionInfo): string { + let port = connInfo.options[constants.knoxPortPropName]; + if (!port) { + port = constants.defaultKnoxPort; + } + return port; + } + + public get connecttimeout(): number { + let timeoutSeconds: number = this.connectionInfo.options['connecttimeout']; + if (!timeoutSeconds) { + timeoutSeconds = constants.hadoopConnectionTimeoutSeconds; + } + // connect timeout is in milliseconds + return timeoutSeconds * 1000; + } + + public get sslverification(): string { + return this.connectionInfo.options['sslverification']; + } + + public get groupId(): string { + return this.connectionInfo.options[constants.groupIdName]; + } + + public isMatch(connectionInfo: sqlops.ConnectionInfo): boolean { + if (!connectionInfo) { + return false; + } + let otherConnection = new Connection(connectionInfo); + return otherConnection.groupId === this.groupId + && otherConnection.host === this.host + && otherConnection.knoxport === this.knoxport + && otherConnection.user === this.user; + } + + public createHdfsFileSource(factory?: FileSourceFactory, additionalRequestParams?: IRequestParams): IFileSource { + factory = factory || FileSourceFactory.instance; + let options: IHdfsOptions = { + protocol: 'https', + host: this.host, + port: this.knoxport, + user: this.user, + path: 'gateway/default/webhdfs/v1', + requestParams: { + auth: { + user: this.user, + pass: this.password + } + } + }; + if (additionalRequestParams) { + options.requestParams = Object.assign(options.requestParams, additionalRequestParams); + } + return factory.createHdfsFileSource(options); + } +} diff --git a/extensions/mssql/src/objectExplorerNodeProvider/fileSources.ts b/extensions/mssql/src/objectExplorerNodeProvider/fileSources.ts new file mode 100644 index 0000000000..0c12732d7c --- /dev/null +++ b/extensions/mssql/src/objectExplorerNodeProvider/fileSources.ts @@ -0,0 +1,371 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; +import * as fspath from 'path'; +import * as webhdfs from 'webhdfs'; +import * as fs from 'fs'; +import * as meter from 'stream-meter'; +import * as bytes from 'bytes'; +import * as https from 'https'; +import * as readline from 'readline'; +import * as os from 'os'; + +import * as constants from '../constants'; +import * as utils from '../utils'; + +export function joinHdfsPath(parent: string, child: string): string { + if (parent === constants.hdfsRootPath) { + return `/${child}`; + } + return `${parent}/${child}`; +} + +export interface IFile { + path: string; + isDirectory: boolean; +} + +export class File implements IFile { + constructor(public path: string, public isDirectory: boolean) { + + } + + public static createPath(path: string, fileName: string): string { + return joinHdfsPath(path, fileName); + } + + public static createChild(parent: IFile, fileName: string, isDirectory: boolean): IFile { + return new File(File.createPath(parent.path, fileName), isDirectory); + } + + public static createFile(parent: IFile, fileName: string): File { + return File.createChild(parent, fileName, false); + } + + public static createDirectory(parent: IFile, fileName: string): IFile { + return File.createChild(parent, fileName, true); + } + + public static getBasename(file: IFile): string { + return fspath.basename(file.path); + } +} + +export interface IFileSource { + + enumerateFiles(path: string): Promise; + mkdir(dirName: string, remoteBasePath: string): Promise; + createReadStream(path: string): fs.ReadStream; + readFile(path: string, maxBytes?: number): Promise; + readFileLines(path: string, maxLines: number): Promise; + writeFile(localFile: IFile, remoteDir: string): Promise; + delete(path: string, recursive?: boolean): Promise; + exists(path: string): Promise; +} + +export interface IHttpAuthentication { + user: string; + pass: string; +} +export interface IHdfsOptions { + host?: string; + port?: string; + protocol?: string; + user?: string; + path?: string; + requestParams?: IRequestParams; +} + +export interface IRequestParams { + auth?: IHttpAuthentication; + /** + * Timeout in milliseconds to wait for response + */ + timeout?: number; + agent?: https.Agent; +} + +export interface IHdfsFileStatus { + type: 'FILE' | 'DIRECTORY'; + pathSuffix: string; +} + +export interface IHdfsClient { + readdir(path: string, callback: (err: Error, files: any[]) => void): void; + + /** + * Create readable stream for given path + * + * @method createReadStream + * @fires Request#data + * @fires WebHDFS#finish + * + * @param {String} path + * @param {Object} [opts] + * + * @returns {Object} + */ + createReadStream (path: string, opts?: object): fs.ReadStream; + + /** + * Create writable stream for given path + * + * @example + * + * var WebHDFS = require('webhdfs'); + * var hdfs = WebHDFS.createClient(); + * + * var localFileStream = fs.createReadStream('/path/to/local/file'); + * var remoteFileStream = hdfs.createWriteStream('/path/to/remote/file'); + * + * localFileStream.pipe(remoteFileStream); + * + * remoteFileStream.on('error', function onError (err) { + * // Do something with the error + * }); + * + * remoteFileStream.on('finish', function onFinish () { + * // Upload is done + * }); + * + * @method createWriteStream + * @fires WebHDFS#finish + * + * @param {String} path + * @param {Boolean} [append] If set to true then append data to the file + * @param {Object} [opts] + * + * @returns {Object} + */ + createWriteStream(path: string, append?: boolean, opts?: object): fs.WriteStream; + + /** + * Make new directory + * + * @method mkdir + * + * @param {String} path + * @param {String} [mode=0777] + * @param {Function} callback + * + * @returns {Object} + */ + mkdir (path: string, callback: Function): void; + mkdir (path: string, mode: string, callback: Function): void; + + /** + * Delete directory or file path + * + * @method unlink + * + * @param {String} path + * @param {Boolean} [recursive=false] + * @param {Function} callback + * + * @returns {Object} + */ + rmdir (path: string, recursive: boolean, callback: Function): void; + + /** + * Check file existence + * Wraps stat method + * + * @method stat + * @see WebHDFS.stat + * + * @param {String} path + * @param {Function} callback + * + * @returns {Object} + */ + exists (path: string, callback: Function): boolean; +} + +export class FileSourceFactory { + private static _instance: FileSourceFactory; + + public static get instance(): FileSourceFactory { + if (!FileSourceFactory._instance) { + FileSourceFactory._instance = new FileSourceFactory(); + } + return FileSourceFactory._instance; + } + + public createHdfsFileSource(options: IHdfsOptions): IFileSource { + options = options && options.host ? FileSourceFactory.removePortFromHost(options) : options; + let requestParams: IRequestParams = options.requestParams ? options.requestParams : {}; + if (requestParams.auth) { + // TODO Remove handling of unsigned cert once we have real certs in our Knox service + let agentOptions = { + host: options.host, + port: options.port, + path: constants.hdfsRootPath, + rejectUnauthorized: false + }; + let agent = new https.Agent(agentOptions); + requestParams['agent'] = agent; + } + return new HdfsFileSource(webhdfs.createClient(options, requestParams)); + } + + // remove port from host when port is specified after a comma or colon + private static removePortFromHost(options: IHdfsOptions): IHdfsOptions { + // determine whether the host has either a ',' or ':' in it + options = this.setHostAndPort(options, ','); + options = this.setHostAndPort(options, ':'); + return options; + } + + // set port and host correctly after we've identified that a delimiter exists in the host name + private static setHostAndPort(options: IHdfsOptions, delimeter: string): IHdfsOptions { + let optionsHost: string = options.host; + if (options.host.indexOf(delimeter) > -1) { + options.host = options.host.slice(0, options.host.indexOf(delimeter)); + options.port = optionsHost.replace(options.host + delimeter, ''); + } + return options; + } +} + +export class HdfsFileSource implements IFileSource { + constructor(private client: IHdfsClient) { + } + + public enumerateFiles(path: string): Promise { + return new Promise((resolve, reject) => { + this.client.readdir(path, (error, files) => { + if (error) { + reject(error.message); + } else { + let hdfsFiles: IFile[] = files.map(file => { + let hdfsFile = file; + return new File(File.createPath(path, hdfsFile.pathSuffix), hdfsFile.type === 'DIRECTORY'); + }); + resolve(hdfsFiles); + } + }); + }); + } + + public mkdir(dirName: string, remoteBasePath: string): Promise { + return new Promise((resolve, reject) => { + let remotePath = joinHdfsPath(remoteBasePath, dirName); + this.client.mkdir(remotePath, (err) => { + if (err) { + reject(err); + } else { + resolve(undefined); + } + }); + }); + } + + public createReadStream(path: string): fs.ReadStream { + return this.client.createReadStream(path); + } + + public readFile(path: string, maxBytes?: number): Promise { + return new Promise((resolve, reject) => { + let remoteFileStream = this.client.createReadStream(path); + if (maxBytes) { + remoteFileStream = remoteFileStream.pipe(meter(maxBytes)); + } + let data = []; + let error = undefined; + remoteFileStream.on('error', (err) => { + error = err.toString(); + if (error.includes('Stream exceeded specified max')) { + error = `File exceeds max size of ${bytes(maxBytes)}`; + } + reject(error); + }); + + remoteFileStream.on('data', (chunk) => { + data.push(chunk); + }); + + remoteFileStream.once('finish', () => { + if (!error) { + resolve(Buffer.concat(data)); + } + }); + }); + } + + public readFileLines(path: string, maxLines: number): Promise { + return new Promise((resolve, reject) => { + let lineReader = readline.createInterface({ + input: this.client.createReadStream(path) + }); + + let lineCount = 0; + let lineData: string[] = []; + let errorMsg = undefined; + lineReader.on('line', (line: string) => { + lineCount++; + lineData.push(line); + if (lineCount >= maxLines) { + resolve(Buffer.from(lineData.join(os.EOL))); + lineReader.close(); + } + }) + .on('error', (err) => { + errorMsg = utils.getErrorMessage(err); + reject(errorMsg); + }) + .on('close', () => { + if (!errorMsg) { + resolve(Buffer.from(lineData.join(os.EOL))); + } + }); + }); + } + + public writeFile(localFile: IFile, remoteDirPath: string): Promise { + return new Promise((resolve, reject) => { + let fileName = fspath.basename(localFile.path); + let remotePath = joinHdfsPath(remoteDirPath, fileName); + + let writeStream = this.client.createWriteStream(remotePath); + + let readStream = fs.createReadStream(localFile.path); + readStream.pipe(writeStream); + + let error: string | Error = undefined; + + // API always calls finish, so catch error then handle exit in the finish event + writeStream.on('error', (err => { + error = err; + reject(error); + })); + writeStream.on('finish', (location) => { + if (!error) { + resolve(location); + } + }); + }); + } + + public delete(path: string, recursive: boolean = false): Promise { + return new Promise((resolve, reject) => { + this.client.rmdir(path, recursive, (error) => { + if (error) { + reject(error); + } else { + resolve(undefined); + } + }); + }); + } + + public exists(path: string): Promise { + return new Promise((resolve, reject) => { + this.client.exists(path, (result) => { + resolve(result); + }); + }); + } +} diff --git a/extensions/mssql/src/objectExplorerNodeProvider/hdfsCommands.ts b/extensions/mssql/src/objectExplorerNodeProvider/hdfsCommands.ts new file mode 100644 index 0000000000..eb80d8593a --- /dev/null +++ b/extensions/mssql/src/objectExplorerNodeProvider/hdfsCommands.ts @@ -0,0 +1,437 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import * as vscode from 'vscode'; +import * as sqlops from 'sqlops'; +import * as fs from 'fs'; +import * as fspath from 'path'; +import * as clipboardy from 'clipboardy'; +import * as nls from 'vscode-nls'; +const localize = nls.loadMessageBundle(); + +import { ApiWrapper } from '../apiWrapper'; +import { Command, ICommandViewContext, ProgressCommand, ICommandObjectExplorerContext } from './command'; +import { IHdfsOptions, HdfsFileSource, File, IFile, joinHdfsPath, FileSourceFactory } from './fileSources'; +import { HdfsProvider, FolderNode, FileNode, HdfsFileSourceNode } from './hdfsProvider'; +import { IPrompter, IQuestion, QuestionTypes } from '../prompts/question'; +import * as constants from '../constants'; +import * as LocalizedConstants from '../localizedConstants'; +import * as utils from '../utils'; +import { Connection } from './connection'; +import { AppContext } from '../appContext'; +import { TreeNode } from './treeNodes'; +import { MssqlObjectExplorerNodeProvider } from './objectExplorerNodeProvider'; + +function getSaveableUri(apiWrapper: ApiWrapper, fileName: string, isPreview?: boolean): vscode.Uri { + let root = utils.getUserHome(); + let workspaceFolders = apiWrapper.workspaceFolders; + if (workspaceFolders && workspaceFolders.length > 0) { + root = workspaceFolders[0].uri.fsPath; + } + // Cannot preview with a file path that already exists, so keep looking for a valid path that does not exist + if (isPreview) { + let fileNum = 1; + let fileNameWithoutExtension = fspath.parse(fileName).name; + let fileExtension = fspath.parse(fileName).ext; + while (fs.existsSync(fspath.join(root, fileName))) { + fileName = `${fileNameWithoutExtension}-${fileNum}${fileExtension}`; + fileNum++; + } + } + return vscode.Uri.file(fspath.join(root, fileName)); +} + +export async function getNode(context: ICommandViewContext |ICommandObjectExplorerContext, appContext: AppContext): Promise { + let node: T = undefined; + if (context && context.type === constants.ViewType && context.node) { + node = context.node as T; + } else if (context && context.type === constants.ObjectExplorerService) { + let oeProvider = appContext.getService(constants.ObjectExplorerService); + if (oeProvider) { + node = await oeProvider.findNodeForContext(context.explorerContext); + } + } else { + throw new Error(LocalizedConstants.msgMissingNodeContext); + } + return node; +} + +export class UploadFilesCommand extends ProgressCommand { + + constructor(prompter: IPrompter, appContext: AppContext) { + super('hdfs.uploadFiles', prompter, appContext); + } + + protected async preExecute(context: ICommandViewContext | ICommandObjectExplorerContext, args: object = {}): Promise { + return this.execute(context, args); + } + + async execute(context: ICommandViewContext | ICommandObjectExplorerContext, ...args: any[]): Promise { + try { + let folderNode = await getNode(context, this.appContext); + const allFilesFilter = localize('allFiles', 'All Files'); + let filter = {}; + filter[allFilesFilter] = '*'; + if (folderNode) { + let options: vscode.OpenDialogOptions = { + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: true, + openLabel: localize('lblUploadFiles', 'Upload'), + filters: filter + }; + let fileUris: vscode.Uri[] = await this.apiWrapper.showOpenDialog(options); + if (fileUris) { + let files: IFile[] = fileUris.map(uri => uri.fsPath).map(this.mapPathsToFiles()); + await this.executeWithProgress( + async (cancelToken: vscode.CancellationTokenSource) => this.writeFiles(files, folderNode, cancelToken), + localize('uploading', 'Uploading files to HDFS'), true, + () => this.apiWrapper.showInformationMessage(localize('uploadCanceled', 'Upload operation was canceled'))); + if (context.type === constants.ObjectExplorerService) { + let objectExplorerNode = await sqlops.objectexplorer.getNode(context.explorerContext.connectionProfile.id, folderNode.getNodeInfo().nodePath); + await objectExplorerNode.refresh(); + } + } + } + } catch (err) { + this.apiWrapper.showErrorMessage(localize('uploadError', 'Error uploading files: {0}', utils.getErrorMessage(err))); + } + } + + private mapPathsToFiles(): (value: string, index: number, array: string[]) => File { + return (path: string) => { + let isDir = fs.lstatSync(path).isDirectory(); + return new File(path, isDir); + }; + } + + private async writeFiles(files: IFile[], folderNode: FolderNode, cancelToken: vscode.CancellationTokenSource): Promise { + for (let file of files) { + if (cancelToken.token.isCancellationRequested) { + // Throw here so that all recursion is ended + throw new Error('Upload canceled'); + } + if (file.isDirectory) { + let dirName = fspath.basename(file.path); + let subFolder = await folderNode.mkdir(dirName); + let children: IFile[] = fs.readdirSync(file.path) + .map(childFileName => joinHdfsPath(file.path, childFileName)) + .map(this.mapPathsToFiles()); + this.writeFiles(children, subFolder, cancelToken); + } else { + await folderNode.writeFile(file); + } + } + } +} +export class MkDirCommand extends ProgressCommand { + + constructor(prompter: IPrompter, appContext: AppContext) { + super('hdfs.mkdir', prompter, appContext); + } + + protected async preExecute(context: ICommandViewContext | ICommandObjectExplorerContext, args: object = {}): Promise { + return this.execute(context, args); + } + + async execute(context: ICommandViewContext | ICommandObjectExplorerContext, ...args: any[]): Promise { + try { + let folderNode = await getNode(context, this.appContext); + + if (folderNode) { + let fileName: string = await this.getDirName(); + if (fileName && fileName.length > 0) { + await this.executeWithProgress( + async (cancelToken: vscode.CancellationTokenSource) => this.mkDir(fileName, folderNode, cancelToken), + localize('makingDir', 'Creating directory'), true, + () => this.apiWrapper.showInformationMessage(localize('mkdirCanceled', 'Operation was canceled'))); + if (context.type === constants.ObjectExplorerService) { + let objectExplorerNode = await sqlops.objectexplorer.getNode(context.explorerContext.connectionProfile.id, folderNode.getNodeInfo().nodePath); + await objectExplorerNode.refresh(); + } + } + } + } catch (err) { + this.apiWrapper.showErrorMessage(localize('uploadError', 'Error uploading files: {0}', utils.getErrorMessage(err))); + } + } + + private async getDirName(): Promise { + return await this.prompter.promptSingle({ + type: QuestionTypes.input, + name: 'enterDirName', + message: localize('enterDirName', 'Enter directory name'), + default: '' + }).then(confirmed => confirmed); + } + + private async mkDir(fileName, folderNode: FolderNode, cancelToken: vscode.CancellationTokenSource): Promise { + let subFolder = await folderNode.mkdir(fileName); + } +} + +export class DeleteFilesCommand extends Command { + + constructor(private prompter: IPrompter, appContext: AppContext) { + super('hdfs.deleteFiles', appContext); + } + + protected async preExecute(context: ICommandViewContext |ICommandObjectExplorerContext, args: object = {}): Promise { + return this.execute(context, args); + } + + async execute(context: ICommandViewContext |ICommandObjectExplorerContext, ...args: any[]): Promise { + try { + let node = await getNode(context, this.appContext); + if (node) { + // TODO ideally would let node define if it's deletable + // TODO also, would like to change this to getNodeInfo as OE is the primary use case now + let treeItem = await node.getTreeItem(); + let oeNodeToRefresh: sqlops.objectexplorer.ObjectExplorerNode = undefined; + if (context.type === constants.ObjectExplorerService) { + let oeNodeToDelete = await sqlops.objectexplorer.getNode(context.explorerContext.connectionProfile.id, node.getNodeInfo().nodePath); + oeNodeToRefresh = await oeNodeToDelete.getParent(); + } + switch (treeItem.contextValue) { + case constants.HdfsItems.Folder: + await this.deleteFolder(node); + break; + case constants.HdfsItems.File: + await this.deleteFile(node); + break; + default: + return; + } + if (oeNodeToRefresh) { + await oeNodeToRefresh.refresh(); + } + } else { + this.apiWrapper.showErrorMessage(LocalizedConstants.msgMissingNodeContext); + } + } catch (err) { + this.apiWrapper.showErrorMessage(localize('deleteError', 'Error deleting files {0}', err)); + } + } + + private async confirmDelete(deleteMsg: string): Promise { + return await this.prompter.promptSingle({ + type: QuestionTypes.confirm, + message: deleteMsg, + default: false + }).then(confirmed => confirmed); + } + + private async deleteFolder(node: FolderNode): Promise { + if (node) { + let confirmed = await this.confirmDelete(localize('msgDeleteFolder', 'Are you sure you want to delete this folder and its contents?')); + if (confirmed) { + // TODO prompt for recursive delete if non-empty? + await node.delete(true); + } + } + } + + private async deleteFile(node: FileNode): Promise { + if (node) { + let confirmed = await this.confirmDelete(localize('msgDeleteFile', 'Are you sure you want to delete this file?')); + if (confirmed) { + await node.delete(); + } + } + } +} + +export class SaveFileCommand extends ProgressCommand { + + constructor(prompter: IPrompter, appContext: AppContext) { + super('hdfs.saveFile', prompter, appContext); + } + + protected async preExecute(context: ICommandViewContext | ICommandObjectExplorerContext, args: object = {}): Promise { + return this.execute(context, args); + } + + async execute(context: ICommandViewContext | ICommandObjectExplorerContext, ...args: any[]): Promise { + try { + let fileNode = await getNode(context, this.appContext); + if (fileNode) { + let defaultUri = getSaveableUri(this.apiWrapper, fspath.basename(fileNode.hdfsPath)); + let fileUri: vscode.Uri = await this.apiWrapper.showSaveDialog({ + defaultUri: defaultUri + }); + if (fileUri) { + await this.executeWithProgress( + async (cancelToken: vscode.CancellationTokenSource) => this.doSaveAndOpen(fileUri, fileNode, cancelToken), + localize('saving', 'Saving HDFS Files'), true, + () => this.apiWrapper.showInformationMessage(localize('saveCanceled', 'Save operation was canceled'))); + } + } else { + this.apiWrapper.showErrorMessage(LocalizedConstants.msgMissingNodeContext); + } + } catch (err) { + this.apiWrapper.showErrorMessage(localize('saveError', 'Error saving file: {0}', err)); + } + } + + private async doSaveAndOpen(fileUri: vscode.Uri, fileNode: FileNode, cancelToken: vscode.CancellationTokenSource): Promise { + await fileNode.writeFileContentsToDisk(fileUri.fsPath, cancelToken); + await this.apiWrapper.executeCommand('vscode.open', fileUri); + } +} +export class PreviewFileCommand extends ProgressCommand { + public static readonly DefaultMaxSize = 30 * 1024 * 1024; + + constructor(prompter: IPrompter, appContext: AppContext) { + super('hdfs.previewFile', prompter, appContext); + } + + protected async preExecute(context: ICommandViewContext | ICommandObjectExplorerContext, args: object = {}): Promise { + return this.execute(context, args); + } + + async execute(context: ICommandViewContext | ICommandObjectExplorerContext, ...args: any[]): Promise { + try { + let fileNode = await getNode(context, this.appContext); + if (fileNode) { + await this.executeWithProgress( + async (cancelToken: vscode.CancellationTokenSource) => { + let contents = await fileNode.getFileContentsAsString(PreviewFileCommand.DefaultMaxSize); + let doc = await this.openTextDocument(fspath.basename(fileNode.hdfsPath)); + let editor = await this.apiWrapper.showTextDocument(doc, vscode.ViewColumn.Active, false); + await editor.edit(edit => { + edit.insert(new vscode.Position(0, 0), contents); + }); + }, + localize('previewing', 'Generating preview'), + false); + } else { + this.apiWrapper.showErrorMessage(LocalizedConstants.msgMissingNodeContext); + } + } catch (err) { + this.apiWrapper.showErrorMessage(localize('previewError', 'Error previewing file: {0}', err)); + } + } + + private async openTextDocument(fileName: string): Promise { + let docUri: vscode.Uri = getSaveableUri(this.apiWrapper, fileName, true); + if (docUri) { + docUri = docUri.with({ scheme: constants.UNTITLED_SCHEMA }); + return await this.apiWrapper.openTextDocument(docUri); + } else { + // Can't reliably create a filename to save as so just use untitled + let language = fspath.extname(fileName); + if (language && language.length > 0) { + // trim the '.' + language = language.substring(1); + } + return await this.apiWrapper.openTextDocument({ + language: language + }); + } + } +} +export class CopyPathCommand extends Command { + public static readonly DefaultMaxSize = 30 * 1024 * 1024; + + constructor(appContext: AppContext) { + super('hdfs.copyPath', appContext); + } + + protected async preExecute(context: ICommandViewContext | ICommandObjectExplorerContext, args: object = {}): Promise { + return this.execute(context, args); + } + + async execute(context: ICommandViewContext | ICommandObjectExplorerContext, ...args: any[]): Promise { + try { + let node = await getNode(context, this.appContext); + if (node) { + let path = node.hdfsPath; + clipboardy.writeSync(path); + } else { + this.apiWrapper.showErrorMessage(LocalizedConstants.msgMissingNodeContext); + } + } catch (err) { + this.apiWrapper.showErrorMessage(localize('copyPathError', 'Error copying path: {0}', err)); + } + } +} + +/** + * The connect task is only expected to work in the file-tree based APIs, not Object Explorer + */ +export class ConnectTask { + constructor(private hdfsProvider: HdfsProvider, private prompter: IPrompter, private apiWrapper: ApiWrapper) { + + } + + async execute(profile: sqlops.IConnectionProfile, ...args: any[]): Promise { + if (profile) { + return this.createFromProfile(profile); + } + return this.createHdfsConnection(); + } + + private createFromProfile(profile: sqlops.IConnectionProfile): Promise { + let connection = new Connection(profile); + if (profile.providerName === constants.mssqlClusterProviderName && connection.host) { + // TODO need to get the actual port and auth to be used since this will be non-default + // in future versions + this.hdfsProvider.addHdfsConnection( { + protocol: 'https', + host: connection.host, + port: connection.knoxport, + user: connection.user, + path: 'gateway/default/webhdfs/v1', + requestParams: { + auth: { + user: connection.user, + pass: connection.password + } + } + }); + } + return Promise.resolve(undefined); + } + + private addConnection(options: IHdfsOptions): void { + let display: string = `${options.user}@${options.host}:${options.port}`; + this.hdfsProvider.addConnection(display, FileSourceFactory.instance.createHdfsFileSource(options)); + } + + private async createHdfsConnection(profile?: sqlops.IConnectionProfile): Promise { + let questions: IQuestion[] = [ + { + type: QuestionTypes.input, + name: constants.hdfsHost, + message: localize('msgSetWebHdfsHost', 'HDFS URL and port'), + default: 'localhost:50070' + }, + { + type: QuestionTypes.input, + name: constants.hdfsUser, + message: localize('msgSetWebHdfsUser', 'User Name'), + default: 'root' + }]; + + let answers = await this.prompter.prompt(questions); + if (answers) { + let hostAndPort: string = answers[constants.hdfsHost]; + let parts = hostAndPort.split(':'); + let host: string = parts[0]; + let port: string = parts.length > 1 ? parts[1] : undefined; + let user: string = answers[constants.hdfsUser]; + + + let options: IHdfsOptions = { + host: host, + port: port, + user: user + }; + this.addConnection(options); + } + } +} diff --git a/extensions/mssql/src/objectExplorerNodeProvider/hdfsProvider.ts b/extensions/mssql/src/objectExplorerNodeProvider/hdfsProvider.ts new file mode 100644 index 0000000000..83aabb70e7 --- /dev/null +++ b/extensions/mssql/src/objectExplorerNodeProvider/hdfsProvider.ts @@ -0,0 +1,366 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import * as sqlops from 'sqlops'; +import * as vscode from 'vscode'; +import * as fspath from 'path'; +import * as fs from 'fs'; +import * as nls from 'vscode-nls'; +const localize = nls.loadMessageBundle(); + +import { ApiWrapper } from '../apiWrapper'; +import * as Constants from '../constants'; +import { IFileSource, IHdfsOptions, HdfsFileSource, IFile, File, FileSourceFactory } from './fileSources'; +import { CancelableStream } from './cancelableStream'; +import { TreeNode } from './treeNodes'; +import * as utils from '../utils'; +import { IFileNode } from './types'; + +export interface ITreeChangeHandler { + notifyNodeChanged(node: TreeNode): void; +} +export class TreeDataContext { + + constructor(public extensionContext: vscode.ExtensionContext, public changeHandler: ITreeChangeHandler) { + + } +} + +export class HdfsProvider implements vscode.TreeDataProvider, ITreeChangeHandler { + static readonly NoConnectionsMessage = 'No connections added'; + static readonly ConnectionsLabel = 'Connections'; + + private connections: ConnectionNode[]; + private _onDidChangeTreeData = new vscode.EventEmitter(); + private context: TreeDataContext; + + constructor(extensionContext: vscode.ExtensionContext, private vscodeApi: ApiWrapper) { + this.connections = []; + this.context = new TreeDataContext(extensionContext, this); + } + + public get onDidChangeTreeData(): vscode.Event { + return this._onDidChangeTreeData.event; + } + + getTreeItem(element: TreeNode): vscode.TreeItem | Thenable { + return element.getTreeItem(); + } + + getChildren(element?: TreeNode): vscode.ProviderResult { + if (element) { + return element.getChildren(false); + } else { + return this.connections.length > 0 ? this.connections : [MessageNode.create(HdfsProvider.NoConnectionsMessage, element)]; + } + } + + addConnection(displayName: string, fileSource: IFileSource): void { + if (!this.connections.find(c => c.getDisplayName() === displayName)) { + this.connections.push(new ConnectionNode(this.context, displayName, fileSource)); + this._onDidChangeTreeData.fire(); + } + } + + addHdfsConnection(options: IHdfsOptions): void { + let displayName = `${options.user}@${options.host}:${options.port}`; + let fileSource = FileSourceFactory.instance.createHdfsFileSource(options); + this.addConnection(displayName, fileSource); + } + + notifyNodeChanged(node: TreeNode): void { + this._onDidChangeTreeData.fire(node); + } +} + +export abstract class HdfsFileSourceNode extends TreeNode { + constructor(protected context: TreeDataContext, protected _path: string, protected fileSource: IFileSource) { + super(); + } + + public get hdfsPath(): string { + return this._path; + } + + public get nodePathValue(): string { + return this.getDisplayName(); + } + + getDisplayName(): string { + return fspath.basename(this._path); + } + + public async delete(recursive: boolean = false): Promise { + await this.fileSource.delete(this.hdfsPath, recursive); + // Notify parent should be updated. If at top, will return undefined which will refresh whole tree + (this.parent).onChildRemoved(); + this.context.changeHandler.notifyNodeChanged(this.parent); + } + public abstract onChildRemoved(): void; +} + +export class FolderNode extends HdfsFileSourceNode { + private children: TreeNode[]; + protected _nodeType: string; + constructor(context: TreeDataContext, path: string, fileSource: IFileSource, nodeType?: string) { + super(context, path, fileSource); + this._nodeType = nodeType ? nodeType : Constants.HdfsItems.Folder; + } + + private ensureChildrenExist(): void { + if (!this.children) { + this.children = []; + } + } + + public onChildRemoved(): void { + this.children = undefined; + } + + async getChildren(refreshChildren: boolean): Promise { + if (refreshChildren || !this.children) { + this.ensureChildrenExist(); + try { + let files: IFile[] = await this.fileSource.enumerateFiles(this._path); + if (files) { + // Note: for now, assuming HDFS-provided sorting is sufficient + this.children = files.map((file) => { + let node: TreeNode = file.isDirectory ? new FolderNode(this.context, file.path, this.fileSource) + : new FileNode(this.context, file.path, this.fileSource); + node.parent = this; + return node; + }); + } + } catch (error) { + this.children = [MessageNode.create(localize('errorExpanding', 'Error: {0}', utils.getErrorMessage(error)), this)]; + } + } + return this.children; + } + + getTreeItem(): vscode.TreeItem | Promise { + let item = new vscode.TreeItem(this.getDisplayName(), vscode.TreeItemCollapsibleState.Collapsed); + // For now, folder always looks the same. We're using SQL icons to differentiate remote vs local files + item.iconPath = { + dark: this.context.extensionContext.asAbsolutePath('resources/light/Folder.svg'), + light: this.context.extensionContext.asAbsolutePath('resources/light/Folder.svg') + }; + item.contextValue = this._nodeType; + return item; + } + + getNodeInfo(): sqlops.NodeInfo { + // TODO handle error message case by returning it in the OE API + // TODO support better mapping of node type + let nodeInfo: sqlops.NodeInfo = { + label: this.getDisplayName(), + isLeaf: false, + errorMessage: undefined, + metadata: undefined, + nodePath: this.generateNodePath(), + nodeStatus: undefined, + nodeType: this._nodeType, + nodeSubType: undefined, + iconType: 'Folder' + }; + return nodeInfo; + } + + public async writeFile(localFile: IFile): Promise { + return this.runChildAddAction(() => this.writeFileAsync(localFile)); + } + + private async writeFileAsync(localFile: IFile): Promise { + await this.fileSource.writeFile(localFile, this._path); + let fileNode = new FileNode(this.context, File.createPath(this._path, File.getBasename(localFile)), this.fileSource); + return fileNode; + } + + public async mkdir(name: string): Promise { + return this.runChildAddAction(() => this.mkdirAsync(name)); + } + + private async mkdirAsync(name: string): Promise { + await this.fileSource.mkdir(name, this._path); + let subDir = new FolderNode(this.context, File.createPath(this._path, name), this.fileSource); + return subDir; + } + + private async runChildAddAction(action: () => Promise): Promise { + let node = await action(); + await this.getChildren(true); + if (this.children) { + // Find the child matching the node. This is necessary + // since writing can add duplicates. + node = this.children.find(n => n.nodePathValue === node.nodePathValue) as T; + this.context.changeHandler.notifyNodeChanged(this); + } else { + // Failed to retrieve children from server so something went wrong + node = undefined; + } + return node; + } +} + +export class ConnectionNode extends FolderNode { + + constructor(context: TreeDataContext, private displayName: string, fileSource: IFileSource) { + super(context, '/', fileSource, Constants.HdfsItems.Connection); + } + + getDisplayName(): string { + return this.displayName; + } + + public async delete(): Promise { + throw new Error(localize('errDeleteConnectionNode', 'Cannot delete a connection. Only subfolders and files can be deleted.')); + } + + async getTreeItem(): Promise { + let item = await super.getTreeItem(); + item.contextValue = this._nodeType; + return item; + } +} + +export class FileNode extends HdfsFileSourceNode implements IFileNode { + + constructor(context: TreeDataContext, path: string, fileSource: IFileSource) { + super(context, path, fileSource); + } + + public onChildRemoved(): void { + // do nothing + } + + getChildren(refreshChildren: boolean): TreeNode[] | Promise { + return []; + } + + getTreeItem(): vscode.TreeItem | Promise { + let item = new vscode.TreeItem(this.getDisplayName(), vscode.TreeItemCollapsibleState.None); + item.iconPath = { + dark: this.context.extensionContext.asAbsolutePath('resources/dark/file_inverse.svg'), + light: this.context.extensionContext.asAbsolutePath('resources/light/file.svg') + }; + item.contextValue = Constants.HdfsItems.File; + return item; + } + + + getNodeInfo(): sqlops.NodeInfo { + // TODO improve node type handling so it's not tied to SQL Server types + let nodeInfo: sqlops.NodeInfo = { + label: this.getDisplayName(), + isLeaf: true, + errorMessage: undefined, + metadata: undefined, + nodePath: this.generateNodePath(), + nodeStatus: undefined, + nodeType: Constants.HdfsItems.File, + nodeSubType: this.getSubType(), + iconType: 'FileGroupFile' + }; + return nodeInfo; + } + + public async getFileContentsAsString(maxBytes?: number): Promise { + let contents: Buffer = await this.fileSource.readFile(this.hdfsPath, maxBytes); + return contents ? contents.toString('utf8') : ''; + } + + public async getFileLinesAsString(maxLines: number): Promise { + let contents: Buffer = await this.fileSource.readFileLines(this.hdfsPath, maxLines); + return contents ? contents.toString('utf8') : ''; + } + + public writeFileContentsToDisk(localPath: string, cancelToken?: vscode.CancellationTokenSource): Promise { + return new Promise((resolve, reject) => { + let readStream: fs.ReadStream = this.fileSource.createReadStream(this.hdfsPath); + let writeStream = fs.createWriteStream(localPath, { + encoding: 'utf8' + }); + let cancelable = new CancelableStream(cancelToken); + cancelable.on('error', (err) => { + reject(err); + }); + readStream.pipe(cancelable).pipe(writeStream); + + let error: string | Error = undefined; + + writeStream.on('error', (err) => { + error = err; + reject(error); + }); + writeStream.on('finish', (location) => { + if (!error) { + resolve(vscode.Uri.file(localPath)); + } + }); + }); + } + + private getSubType(): string { + if (this.getDisplayName().toLowerCase().endsWith('.jar') || this.getDisplayName().toLowerCase().endsWith('.py')) { + return Constants.HdfsItemsSubType.Spark; + } + + return undefined; + } +} + +export class MessageNode extends TreeNode { + static messageNum: number = 0; + + private _nodePathValue: string; + constructor(private message: string) { + super(); + } + + public static create(message: string, parent: TreeNode): MessageNode { + let node = new MessageNode(message); + node.parent = parent; + return node; + } + + private ensureNodePathValue(): void { + if (!this._nodePathValue) { + this._nodePathValue = `message_${MessageNode.messageNum++}`; + } + } + + public get nodePathValue(): string { + this.ensureNodePathValue(); + return this._nodePathValue; + } + + public getChildren(refreshChildren: boolean): TreeNode[] | Promise { + return []; + } + + public getTreeItem(): vscode.TreeItem | Promise { + let item = new vscode.TreeItem(this.message, vscode.TreeItemCollapsibleState.None); + item.contextValue = Constants.HdfsItems.Message; + return item; + } + + + getNodeInfo(): sqlops.NodeInfo { + let nodeInfo: sqlops.NodeInfo = { + label: this.message, + isLeaf: false, + errorMessage: undefined, + metadata: undefined, + nodePath: this.generateNodePath(), + nodeStatus: undefined, + nodeType: Constants.HdfsItems.Message, + nodeSubType: undefined, + iconType: 'MessageType' + }; + return nodeInfo; + } +} diff --git a/extensions/mssql/src/objectExplorerNodeProvider/objectExplorerNodeProvider.ts b/extensions/mssql/src/objectExplorerNodeProvider/objectExplorerNodeProvider.ts new file mode 100644 index 0000000000..9e77cf27de --- /dev/null +++ b/extensions/mssql/src/objectExplorerNodeProvider/objectExplorerNodeProvider.ts @@ -0,0 +1,347 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import * as sqlops from 'sqlops'; +import * as vscode from 'vscode'; +import * as nls from 'vscode-nls'; +const localize = nls.loadMessageBundle(); + +import * as UUID from 'vscode-languageclient/lib/utils/uuid'; +import { ProviderBase } from './providerBase'; +import { Connection } from './connection'; +import * as utils from '../utils'; +import { TreeNode } from './treeNodes'; +import { ConnectionNode, TreeDataContext, ITreeChangeHandler } from './hdfsProvider'; +import { IFileSource } from './fileSources'; +import { AppContext } from '../appContext'; +import * as constants from '../constants'; + +const outputChannel = vscode.window.createOutputChannel(constants.providerId); +interface IEndpoint { + serviceName: string; + ipAddress: string; + port: number; +} + +export class MssqlObjectExplorerNodeProvider extends ProviderBase implements sqlops.ObjectExplorerNodeProvider, ITreeChangeHandler { + public readonly supportedProviderId: string = constants.providerId; + private sessionMap: Map; + private expandCompleteEmitter = new vscode.EventEmitter(); + + constructor(private appContext: AppContext) { + super(); + + this.sessionMap = new Map(); + this.appContext.registerService(constants.ObjectExplorerService, this); + } + + handleSessionOpen(session: sqlops.ObjectExplorerSession): Thenable { + return new Promise((resolve, reject) => { + if (!session) { + reject('handleSessionOpen requires a session object to be passed'); + } else { + resolve(this.doSessionOpen(session)); + } + }); + } + + private async doSessionOpen(sessionInfo: sqlops.ObjectExplorerSession): Promise { + let connectionProfile = await sqlops.objectexplorer.getSessionConnectionProfile(sessionInfo.sessionId); + if (!connectionProfile) { + return false; + } else { + let credentials = await sqlops.connection.getCredentials(connectionProfile.id); + let serverInfo = await sqlops.connection.getServerInfo(connectionProfile.id); + if (!serverInfo || !credentials || !serverInfo.options) { + return false; + } + let endpoints: IEndpoint[] = serverInfo.options[constants.clusterEndpointsProperty]; + if (!endpoints || endpoints.length === 0) { + return false; + } + let index = endpoints.findIndex(ep => ep.serviceName === constants.hadoopKnoxEndpointName); + if (index === -1) { + return false; + } + + let connInfo: sqlops.connection.Connection = { + options: { + 'host': endpoints[index].ipAddress, + 'groupId': connectionProfile.options.groupId, + 'knoxport': endpoints[index].port, + 'user': 'root', //connectionProfile.options.userName cluster setup has to have the same user for master and big data cluster + 'password': credentials.password, + }, + providerName: constants.mssqlClusterProviderName, + connectionId: UUID.generateUuid() + }; + + let connection = new Connection(connInfo); + connection.saveUriWithPrefix(constants.objectExplorerPrefix); + let session = new Session(connection, sessionInfo.sessionId); + session.root = new RootNode(session, new TreeDataContext(this.appContext.extensionContext, this), sessionInfo.rootNode.nodePath); + this.sessionMap.set(sessionInfo.sessionId, session); + return true; + } + } + + expandNode(nodeInfo: sqlops.ExpandNodeInfo, isRefresh: boolean = false): Thenable { + return new Promise((resolve, reject) => { + if (!nodeInfo) { + reject('expandNode requires a nodeInfo object to be passed'); + } else { + resolve(this.doExpandNode(nodeInfo, isRefresh)); + } + }); + } + + private async doExpandNode(nodeInfo: sqlops.ExpandNodeInfo, isRefresh: boolean = false): Promise { + let session = this.sessionMap.get(nodeInfo.sessionId); + let response = { + sessionId: nodeInfo.sessionId, + nodePath: nodeInfo.nodePath, + errorMessage: undefined, + nodes: [] + }; + + if (!session) { + // This is not an error case. Just fire reponse with empty nodes for example: request from standalone SQL instance + this.expandCompleteEmitter.fire(response); + return false; + } else { + setTimeout(() => { + + // Running after promise resolution as we need the Ops Studio-side map to have been updated + // Intentionally not awaiting or catching errors. + // Any failure in startExpansion should be emitted in the expand complete result + // We want this to be async and ideally return true before it completes + this.startExpansion(session, nodeInfo, isRefresh); + }, 10); + } + return true; + } + + private async startExpansion(session: Session, nodeInfo: sqlops.ExpandNodeInfo, isRefresh: boolean = false): Promise { + let expandResult: sqlops.ObjectExplorerExpandInfo = { + sessionId: session.uri, + nodePath: nodeInfo.nodePath, + errorMessage: undefined, + nodes: [] + }; + try { + let node = await session.root.findNodeByPath(nodeInfo.nodePath, true); + if (node) { + expandResult.errorMessage = node.getNodeInfo().errorMessage; + let children = await node.getChildren(true); + if (children) { + expandResult.nodes = children.map(c => c.getNodeInfo()); + } + } + } catch (error) { + expandResult.errorMessage = utils.getErrorMessage(error); + } + this.expandCompleteEmitter.fire(expandResult); + } + + refreshNode(nodeInfo: sqlops.ExpandNodeInfo): Thenable { + // TODO #3815 implement properly + return this.expandNode(nodeInfo, true); + } + + handleSessionClose(closeSessionInfo: sqlops.ObjectExplorerCloseSessionInfo): void { + this.sessionMap.delete(closeSessionInfo.sessionId); + } + + findNodes(findNodesInfo: sqlops.FindNodesInfo): Thenable { + // TODO #3814 implement + let response: sqlops.ObjectExplorerFindNodesResponse = { + nodes: [] + }; + return Promise.resolve(response); + } + + registerOnExpandCompleted(handler: (response: sqlops.ObjectExplorerExpandInfo) => any): void { + this.expandCompleteEmitter.event(handler); + } + + notifyNodeChanged(node: TreeNode): void { + this.notifyNodeChangesAsync(node); + } + + private async notifyNodeChangesAsync(node: TreeNode): Promise { + try { + let session = this.getSessionForNode(node); + if (!session) { + this.appContext.apiWrapper.showErrorMessage(localize('sessionNotFound', 'Session for node {0} does not exist', node.nodePathValue)); + } else { + let nodeInfo = node.getNodeInfo(); + let expandInfo: sqlops.ExpandNodeInfo = { + nodePath: nodeInfo.nodePath, + sessionId: session.uri + }; + await this.refreshNode(expandInfo); + } + } catch (err) { + outputChannel.appendLine(localize('notifyError', 'Error notifying of node change: {0}', err)); + } + } + + private getSessionForNode(node: TreeNode): Session { + let rootNode: DataServicesNode = undefined; + while (rootNode === undefined && node !== undefined) { + if (node instanceof DataServicesNode) { + rootNode = node; + break; + } else { + node = node.parent; + } + } + if (rootNode) { + return rootNode.session; + } + // Not found + return undefined; + } + + async findNodeForContext(explorerContext: sqlops.ObjectExplorerContext): Promise { + let node: T = undefined; + let session = this.findSessionForConnection(explorerContext.connectionProfile); + if (session) { + if (explorerContext.isConnectionNode) { + // Note: ideally fix so we verify T matches RootNode and go from there + node = session.root; + } else { + // Find the node under the session + node = await session.root.findNodeByPath(explorerContext.nodeInfo.nodePath, true); + } + } + return node; + } + + private findSessionForConnection(connectionProfile: sqlops.IConnectionProfile): Session { + for (let session of this.sessionMap.values()) { + if (session.connection && session.connection.isMatch(connectionProfile)) { + return session; + } + } + return undefined; + } +} + +export class Session { + private _root: RootNode; + constructor(private _connection: Connection, private sessionId?: string) { + } + + public get uri(): string { + return this.sessionId || this._connection.uri; + } + + public get connection(): Connection { + return this._connection; + } + + public set root(node: RootNode) { + this._root = node; + } + + public get root(): RootNode { + return this._root; + } +} + +class RootNode extends TreeNode { + private children: TreeNode[]; + constructor(private _session: Session, private context: TreeDataContext, private nodePath: string) { + super(); + } + + public get session(): Session { + return this._session; + } + + public get nodePathValue(): string { + return this.nodePath; + } + + public getChildren(refreshChildren: boolean): TreeNode[] | Promise { + if (refreshChildren || !this.children) { + this.children = []; + let dataServicesNode = new DataServicesNode(this._session, this.context, this.nodePath); + dataServicesNode.parent = this; + this.children.push(dataServicesNode); + } + return this.children; + } + + getTreeItem(): vscode.TreeItem | Promise { + throw new Error('Not intended for use in a file explorer view.'); + } + + getNodeInfo(): sqlops.NodeInfo { + let nodeInfo: sqlops.NodeInfo = { + label: localize('rootLabel', 'Root'), + isLeaf: false, + errorMessage: undefined, + metadata: undefined, + nodePath: this.generateNodePath(), + nodeStatus: undefined, + nodeType: 'sqlCluster:root', + nodeSubType: undefined, + iconType: 'folder' + }; + return nodeInfo; + } +} + +class DataServicesNode extends TreeNode { + private children: TreeNode[]; + constructor(private _session: Session, private context: TreeDataContext, private nodePath: string) { + super(); + } + + public get session(): Session { + return this._session; + } + + public get nodePathValue(): string { + return this.nodePath; + } + + public getChildren(refreshChildren: boolean): TreeNode[] | Promise { + if (refreshChildren || !this.children) { + this.children = []; + let hdfsNode = new ConnectionNode(this.context, localize('hdfsFolder', 'HDFS'), this.createHdfsFileSource()); + hdfsNode.parent = this; + this.children.push(hdfsNode); + } + return this.children; + } + + private createHdfsFileSource(): IFileSource { + return this.session.connection.createHdfsFileSource(); + } + + getTreeItem(): vscode.TreeItem | Promise { + throw new Error('Not intended for use in a file explorer view.'); + } + + getNodeInfo(): sqlops.NodeInfo { + let nodeInfo: sqlops.NodeInfo = { + label: localize('dataServicesLabel', 'Data Services'), + isLeaf: false, + errorMessage: undefined, + metadata: undefined, + nodePath: this.generateNodePath(), + nodeStatus: undefined, + nodeType: 'dataservices', + nodeSubType: undefined, + iconType: 'folder' + }; + return nodeInfo; + } +} \ No newline at end of file diff --git a/extensions/mssql/src/objectExplorerNodeProvider/providerBase.ts b/extensions/mssql/src/objectExplorerNodeProvider/providerBase.ts new file mode 100644 index 0000000000..7dd137c3c9 --- /dev/null +++ b/extensions/mssql/src/objectExplorerNodeProvider/providerBase.ts @@ -0,0 +1,15 @@ + + +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import * as constants from '../constants'; + +export abstract class ProviderBase { + public readonly providerId: string = constants.mssqlClusterProviderName; + public handle: number; +} diff --git a/extensions/mssql/src/objectExplorerNodeProvider/treeNodes.ts b/extensions/mssql/src/objectExplorerNodeProvider/treeNodes.ts new file mode 100644 index 0000000000..9f0776d7b5 --- /dev/null +++ b/extensions/mssql/src/objectExplorerNodeProvider/treeNodes.ts @@ -0,0 +1,78 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +'use strict'; + +import * as sqlops from 'sqlops'; +import * as vscode from 'vscode'; +import { ITreeNode } from './types'; + +type TreeNodePredicate = (node: TreeNode) => boolean; + +export abstract class TreeNode implements ITreeNode { + private _parent: TreeNode = undefined; + + public get parent(): TreeNode { + return this._parent; + } + + public set parent(node: TreeNode) { + this._parent = node; + } + + public generateNodePath(): string { + let path = undefined; + if (this.parent) { + path = this.parent.generateNodePath(); + } + path = path ? `${path}/${this.nodePathValue}` : this.nodePathValue; + return path; + } + + public findNodeByPath(path: string, expandIfNeeded: boolean = false): Promise { + let condition: TreeNodePredicate = (node: TreeNode) => node.getNodeInfo().nodePath === path || node.getNodeInfo().nodePath.startsWith(path); + let filter: TreeNodePredicate = (node: TreeNode) => path.startsWith(node.getNodeInfo().nodePath); + return TreeNode.findNode(this, condition, filter, true); + } + + public static async findNode(node: TreeNode, condition: TreeNodePredicate, filter: TreeNodePredicate, expandIfNeeded: boolean): Promise { + if (!node) { + return undefined; + } + + if (condition(node)) { + return node; + } + + let nodeInfo = node.getNodeInfo(); + if (nodeInfo.isLeaf) { + return undefined; + } + + // TODO #3813 support filtering by already expanded / not yet expanded + let children = await node.getChildren(false); + if (children) { + for (let child of children) { + if (filter && filter(child)) { + let childNode = await this.findNode(child, condition, filter, expandIfNeeded); + if (childNode) { + return childNode; + } + } + } + } + return undefined; + } + + /** + * The value to use for this node in the node path + */ + public abstract get nodePathValue(): string; + + abstract getChildren(refreshChildren: boolean): TreeNode[] | Promise; + abstract getTreeItem(): vscode.TreeItem | Promise; + + abstract getNodeInfo(): sqlops.NodeInfo; +} diff --git a/extensions/mssql/src/objectExplorerNodeProvider/types.d.ts b/extensions/mssql/src/objectExplorerNodeProvider/types.d.ts new file mode 100644 index 0000000000..9d6863b005 --- /dev/null +++ b/extensions/mssql/src/objectExplorerNodeProvider/types.d.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * 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 sqlops from 'sqlops'; + +/** + * A tree node in the object explorer tree + * + * @export + * @interface ITreeNode + */ +export interface ITreeNode { + getNodeInfo(): sqlops.NodeInfo; + getChildren(refreshChildren: boolean): ITreeNode[] | Promise; +} + +/** + * A HDFS file node. This is a leaf node in the object explorer tree, and its contents + * can be queried + * + * @export + * @interface IFileNode + * @extends {ITreeNode} + */ +export interface IFileNode extends ITreeNode { + getFileContentsAsString(maxBytes?: number): Promise; +} \ No newline at end of file diff --git a/extensions/mssql/src/prompts/question.ts b/extensions/mssql/src/prompts/question.ts new file mode 100644 index 0000000000..3b8f600589 --- /dev/null +++ b/extensions/mssql/src/prompts/question.ts @@ -0,0 +1,68 @@ + +'use strict'; + +import vscode = require('vscode'); + +export class QuestionTypes { + public static get input(): string { return 'input'; } + public static get password(): string { return 'password'; } + public static get list(): string { return 'list'; } + public static get confirm(): string { return 'confirm'; } + public static get checkbox(): string { return 'checkbox'; } + public static get expand(): string { return 'expand'; } +} + +// Question interface to clarify how to use the prompt feature +// based on Bower Question format: https://github.com/bower/bower/blob/89069784bb46bfd6639b4a75e98a0d7399a8c2cb/packages/bower-logger/README.md +export interface IQuestion { + // Type of question (see QuestionTypes) + type: string; + // Name of the question for disambiguation + name: string; + // Message to display to the user + message: string; + // Optional placeHolder to give more detailed information to the user + placeHolder?: any; + // Optional default value - this will be used instead of placeHolder + default?: any; + // optional set of choices to be used. Can be QuickPickItems or a simple name-value pair + choices?: Array; + // Optional validation function that returns an error string if validation fails + validate?: (value: any) => string; + // Optional pre-prompt function. Takes in set of answers so far, and returns true if prompt should occur + shouldPrompt?: (answers: {[id: string]: any}) => boolean; + // Optional action to take on the question being answered + onAnswered?: (value: any) => void; + // Optional set of options to support matching choices. + matchOptions?: vscode.QuickPickOptions; +} + +// Pair used to display simple choices to the user +export interface INameValueChoice { + name: string; + value: any; +} + +// Generic object that can be used to define a set of questions and handle the result +export interface IQuestionHandler { + // Set of questions to be answered + questions: IQuestion[]; + // Optional callback, since questions may handle themselves + callback?: IPromptCallback; +} + +export interface IPrompter { + promptSingle(question: IQuestion, ignoreFocusOut?: boolean): Promise; + /** + * Prompts for multiple questions + * + * @returns {[questionId: string]: T} Map of question IDs to results, or undefined if + * the user canceled the question session + */ + prompt(questions: IQuestion[], ignoreFocusOut?: boolean): Promise<{[questionId: string]: any}>; + promptCallback(questions: IQuestion[], callback: IPromptCallback): void; +} + +export interface IPromptCallback { + (answers: {[id: string]: any}): void; +} diff --git a/extensions/mssql/src/typings/refs.d.ts b/extensions/mssql/src/typings/refs.d.ts index 9d285234a4..0bbbbb3b5e 100644 --- a/extensions/mssql/src/typings/refs.d.ts +++ b/extensions/mssql/src/typings/refs.d.ts @@ -4,4 +4,5 @@ *--------------------------------------------------------------------------------------------*/ /// +/// /// \ No newline at end of file diff --git a/extensions/mssql/src/utils.ts b/extensions/mssql/src/utils.ts index 3ce734f3c3..0f87bd117c 100644 --- a/extensions/mssql/src/utils.ts +++ b/extensions/mssql/src/utils.ts @@ -4,6 +4,8 @@ *--------------------------------------------------------------------------------------------*/ 'use strict'; +import * as sqlops from 'sqlops'; + import * as path from 'path'; import * as crypto from 'crypto'; import * as os from 'os'; @@ -169,3 +171,15 @@ export function verifyPlatform(): Thenable { return Promise.resolve(true); } } + +export function getErrorMessage(error: Error | string): string { + return (error instanceof Error) ? error.message : error; +} + +export function isObjectExplorerContext(object: any): object is sqlops.ObjectExplorerContext { + return 'connectionProfile' in object && 'isConnectionNode' in object; +} + +export function getUserHome(): string { + return process.env.HOME || process.env.USERPROFILE; +} diff --git a/extensions/mssql/yarn.lock b/extensions/mssql/yarn.lock index 2b0e6587ef..995c165927 100644 --- a/extensions/mssql/yarn.lock +++ b/extensions/mssql/yarn.lock @@ -8,6 +8,15 @@ agent-base@4, agent-base@^4.1.0: dependencies: es6-promisify "^5.0.0" +ajv@^6.5.5: + version "6.7.0" + resolved "https://registry.yarnpkg.com/ajv/-/ajv-6.7.0.tgz#e3ce7bb372d6577bb1839f1dfdfcbf5ad2948d96" + dependencies: + fast-deep-equal "^2.0.1" + fast-json-stable-stringify "^2.0.0" + json-schema-traverse "^0.4.1" + uri-js "^4.2.2" + applicationinsights@1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/applicationinsights/-/applicationinsights-1.0.1.tgz#53446b830fe8d5d619eee2a278b31d3d25030927" @@ -16,10 +25,42 @@ applicationinsights@1.0.1: diagnostic-channel-publishers "0.2.1" zone.js "0.7.6" +arch@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/arch/-/arch-2.1.1.tgz#8f5c2731aa35a30929221bb0640eed65175ec84e" + +asn1@~0.2.3: + version "0.2.4" + resolved "https://registry.yarnpkg.com/asn1/-/asn1-0.2.4.tgz#8d2475dfab553bb33e77b54e59e880bb8ce23136" + dependencies: + safer-buffer "~2.1.0" + +assert-plus@1.0.0, assert-plus@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/assert-plus/-/assert-plus-1.0.0.tgz#f12e0f3c5d77b0b1cdd9146942e4e96c1e4dd525" + +asynckit@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79" + +aws-sign2@~0.7.0: + version "0.7.0" + resolved "https://registry.yarnpkg.com/aws-sign2/-/aws-sign2-0.7.0.tgz#b46e890934a9591f2d2f6f86d7e6a9f1b3fe76a8" + +aws4@^1.8.0: + version "1.8.0" + resolved "https://registry.yarnpkg.com/aws4/-/aws4-1.8.0.tgz#f0e003d9ca9e7f59c7a508945d7b2ef9a04a542f" + base64-js@0.0.8: version "0.0.8" resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-0.0.8.tgz#1101e9544f4a76b1bc3b26d452ca96d7a35e7978" +bcrypt-pbkdf@^1.0.0: + version "1.0.2" + resolved "https://registry.yarnpkg.com/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz#a4301d389b6a43f9b67ff3ca11a3f6637e360e9e" + dependencies: + tweetnacl "^0.14.3" + bl@^1.0.0: version "1.2.2" resolved "https://registry.yarnpkg.com/bl/-/bl-1.2.2.tgz#a160911717103c07410cef63ef51b397c025af9c" @@ -46,6 +87,10 @@ buffer-fill@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/buffer-fill/-/buffer-fill-1.0.0.tgz#f8f78b76789888ef39f205cd637f68e702122b2c" +buffer-stream-reader@^0.1.1: + version "0.1.1" + resolved "https://registry.yarnpkg.com/buffer-stream-reader/-/buffer-stream-reader-0.1.1.tgz#ca8bf93631deedd8b8f8c3bb44991cc30951e259" + buffer@^3.0.1: version "3.6.0" resolved "https://registry.yarnpkg.com/buffer/-/buffer-3.6.0.tgz#a72c936f77b96bf52f5f7e7b467180628551defb" @@ -54,16 +99,47 @@ buffer@^3.0.1: ieee754 "^1.1.4" isarray "^1.0.0" +caseless@~0.12.0: + version "0.12.0" + resolved "https://registry.yarnpkg.com/caseless/-/caseless-0.12.0.tgz#1b681c21ff84033c826543090689420d187151dc" + +clipboardy@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/clipboardy/-/clipboardy-1.2.3.tgz#0526361bf78724c1f20be248d428e365433c07ef" + dependencies: + arch "^2.1.0" + execa "^0.8.0" + +combined-stream@^1.0.6, combined-stream@~1.0.6: + version "1.0.7" + resolved "https://registry.yarnpkg.com/combined-stream/-/combined-stream-1.0.7.tgz#2d1d24317afb8abe95d6d2c0b07b57813539d828" + dependencies: + delayed-stream "~1.0.0" + commander@~2.8.1: version "2.8.1" resolved "https://registry.yarnpkg.com/commander/-/commander-2.8.1.tgz#06be367febfda0c330aa1e2a072d3dc9762425d4" dependencies: graceful-readlink ">= 1.0.0" -core-util-is@~1.0.0: +core-util-is@1.0.2, core-util-is@~1.0.0: version "1.0.2" resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.2.tgz#b5fd54220aa2bc5ab57aab7140c940754503c1a7" +cross-spawn@^5.0.1: + version "5.1.0" + resolved "https://registry.yarnpkg.com/cross-spawn/-/cross-spawn-5.1.0.tgz#e8bd0efee58fcff6f8f94510a0a554bbfa235449" + dependencies: + lru-cache "^4.0.1" + shebang-command "^1.2.0" + which "^1.2.9" + +dashdash@^1.12.0: + version "1.14.1" + resolved "https://registry.yarnpkg.com/dashdash/-/dashdash-1.14.1.tgz#853cfa0f7cbe2fed5de20326b8dd581035f6e2f0" + dependencies: + assert-plus "^1.0.0" + "dataprotocol-client@github:Microsoft/sqlops-dataprotocolclient#0.2.15": version "0.2.15" resolved "https://codeload.github.com/Microsoft/sqlops-dataprotocolclient/tar.gz/a2cd2db109de882f0959f7b6421c86afa585f460" @@ -130,6 +206,10 @@ decompress@^4.2.0: pify "^2.3.0" strip-dirs "^2.0.0" +delayed-stream@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/delayed-stream/-/delayed-stream-1.0.0.tgz#df3ae199acadfb7d440aaae0b29e2272b24ec619" + diagnostic-channel-publishers@0.2.1: version "0.2.1" resolved "https://registry.yarnpkg.com/diagnostic-channel-publishers/-/diagnostic-channel-publishers-0.2.1.tgz#8e2d607a8b6d79fe880b548bc58cc6beb288c4f3" @@ -140,6 +220,13 @@ diagnostic-channel@0.2.0: dependencies: semver "^5.3.0" +ecc-jsbn@~0.1.1: + version "0.1.2" + resolved "https://registry.yarnpkg.com/ecc-jsbn/-/ecc-jsbn-0.1.2.tgz#3a83a904e54353287874c564b7549386849a98c9" + dependencies: + jsbn "~0.1.0" + safer-buffer "^2.1.0" + end-of-stream@^1.0.0: version "1.4.1" resolved "https://registry.yarnpkg.com/end-of-stream/-/end-of-stream-1.4.1.tgz#ed29634d19baba463b6ce6b80a37213eab71ec43" @@ -160,6 +247,38 @@ eventemitter2@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/eventemitter2/-/eventemitter2-5.0.1.tgz#6197a095d5fb6b57e8942f6fd7eaad63a09c9452" +execa@^0.8.0: + version "0.8.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-0.8.0.tgz#d8d76bbc1b55217ed190fd6dd49d3c774ecfc8da" + dependencies: + cross-spawn "^5.0.1" + get-stream "^3.0.0" + is-stream "^1.1.0" + npm-run-path "^2.0.0" + p-finally "^1.0.0" + signal-exit "^3.0.0" + strip-eof "^1.0.0" + +extend@^3.0.0, extend@~3.0.2: + version "3.0.2" + resolved "https://registry.yarnpkg.com/extend/-/extend-3.0.2.tgz#f8b1136b4071fbd8eb140aff858b1019ec2915fa" + +extsprintf@1.3.0: + version "1.3.0" + resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05" + +extsprintf@^1.2.0: + version "1.4.0" + resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.4.0.tgz#e2689f8f356fad62cca65a3a91c5df5f9551692f" + +fast-deep-equal@^2.0.1: + version "2.0.1" + resolved "https://registry.yarnpkg.com/fast-deep-equal/-/fast-deep-equal-2.0.1.tgz#7b05218ddf9667bf7f370bf7fdb2cb15fdd0aa49" + +fast-json-stable-stringify@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/fast-json-stable-stringify/-/fast-json-stable-stringify-2.0.0.tgz#d5142c0caee6b1189f87d3a76111064f86c8bbf2" + fd-slicer@~1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e" @@ -178,6 +297,18 @@ file-type@^6.1.0: version "6.2.0" resolved "https://registry.yarnpkg.com/file-type/-/file-type-6.2.0.tgz#e50cd75d356ffed4e306dc4f5bcf52a79903a919" +forever-agent@~0.6.1: + version "0.6.1" + resolved "https://registry.yarnpkg.com/forever-agent/-/forever-agent-0.6.1.tgz#fbc71f0c41adeb37f96c577ad1ed42d8fdacca91" + +form-data@~2.3.2: + version "2.3.3" + resolved "https://registry.yarnpkg.com/form-data/-/form-data-2.3.3.tgz#dcce52c05f644f298c6a7ab936bd724ceffbf3a6" + dependencies: + asynckit "^0.4.0" + combined-stream "^1.0.6" + mime-types "^2.1.12" + fs-constants@^1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/fs-constants/-/fs-constants-1.0.0.tgz#6be0de9be998ce16af8afc24497b9ee9b7ccd9ad" @@ -189,6 +320,16 @@ get-stream@^2.2.0: object-assign "^4.0.1" pinkie-promise "^2.0.0" +get-stream@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/get-stream/-/get-stream-3.0.0.tgz#8e943d1358dc37555054ecbe2edb05aa174ede14" + +getpass@^0.1.1: + version "0.1.7" + resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa" + dependencies: + assert-plus "^1.0.0" + graceful-fs@^4.1.10: version "4.1.15" resolved "https://registry.yarnpkg.com/graceful-fs/-/graceful-fs-4.1.15.tgz#ffb703e1066e8a0eeaa4c8b80ba9253eeefbfb00" @@ -197,6 +338,17 @@ graceful-fs@^4.1.10: version "1.0.1" resolved "https://registry.yarnpkg.com/graceful-readlink/-/graceful-readlink-1.0.1.tgz#4cafad76bc62f02fa039b2f94e9a3dd3a391a725" +har-schema@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/har-schema/-/har-schema-2.0.0.tgz#a94c2224ebcac04782a0d9035521f24735b7ec92" + +har-validator@~5.1.0: + version "5.1.3" + resolved "https://registry.yarnpkg.com/har-validator/-/har-validator-5.1.3.tgz#1ef89ebd3e4996557675eed9893110dc350fa080" + dependencies: + ajv "^6.5.5" + har-schema "^2.0.0" + http-proxy-agent@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/http-proxy-agent/-/http-proxy-agent-2.1.0.tgz#e4821beef5b2142a2026bd73926fe537631c5405" @@ -204,6 +356,14 @@ http-proxy-agent@^2.1.0: agent-base "4" debug "3.1.0" +http-signature@~1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/http-signature/-/http-signature-1.2.0.tgz#9aecd925114772f3d95b65a60abb8f7c18fbace1" + dependencies: + assert-plus "^1.0.0" + jsprim "^1.2.2" + sshpk "^1.7.0" + https-proxy-agent@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/https-proxy-agent/-/https-proxy-agent-2.2.1.tgz#51552970fa04d723e04c56d04178c3f92592bbc0" @@ -227,16 +387,70 @@ is-stream@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/is-stream/-/is-stream-1.1.0.tgz#12d4a3dd4e68e0b79ceb8dbc84173ae80d91ca44" +is-typedarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/is-typedarray/-/is-typedarray-1.0.0.tgz#e479c80858df0c1b11ddda6940f96011fcda4a9a" + isarray@^1.0.0, isarray@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + +isstream@~0.1.2: + version "0.1.2" + resolved "https://registry.yarnpkg.com/isstream/-/isstream-0.1.2.tgz#47e63f7af55afa6f92e1500e690eb8b8529c099a" + +jsbn@~0.1.0: + version "0.1.1" + resolved "https://registry.yarnpkg.com/jsbn/-/jsbn-0.1.1.tgz#a5e654c2e5a2deb5f201d96cefbca80c0ef2f513" + +json-schema-traverse@^0.4.1: + version "0.4.1" + resolved "https://registry.yarnpkg.com/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz#69f6a87d9513ab8bb8fe63bdb0979c448e684660" + +json-schema@0.2.3: + version "0.2.3" + resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" + +json-stringify-safe@~5.0.1: + version "5.0.1" + resolved "https://registry.yarnpkg.com/json-stringify-safe/-/json-stringify-safe-5.0.1.tgz#1296a2d58fd45f19a0f6ce01d65701e2c735b6eb" + +jsprim@^1.2.2: + version "1.4.1" + resolved "https://registry.yarnpkg.com/jsprim/-/jsprim-1.4.1.tgz#313e66bc1e5cc06e438bc1b7499c2e5c56acb6a2" + dependencies: + assert-plus "1.0.0" + extsprintf "1.3.0" + json-schema "0.2.3" + verror "1.10.0" + +lru-cache@^4.0.1: + version "4.1.5" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-4.1.5.tgz#8bbe50ea85bed59bc9e33dcab8235ee9bcf443cd" + dependencies: + pseudomap "^1.0.2" + yallist "^2.1.2" + make-dir@^1.0.0: version "1.3.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-1.3.0.tgz#79c1033b80515bd6d24ec9933e860ca75ee27f0c" dependencies: pify "^3.0.0" +mime-db@~1.37.0: + version "1.37.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.37.0.tgz#0b6a0ce6fdbe9576e25f1f2d2fde8830dc0ad0d8" + +mime-types@^2.1.12, mime-types@~2.1.19: + version "2.1.21" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.21.tgz#28995aa1ecb770742fe6ae7e58f9181c744b3f96" + dependencies: + mime-db "~1.37.0" + minimist@0.0.8: version "0.0.8" resolved "https://registry.yarnpkg.com/minimist/-/minimist-0.0.8.tgz#857fcabfc3397d2625b8228262e86aa7a011b05d" @@ -255,6 +469,16 @@ ms@^2.1.1: version "2.1.1" resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.1.tgz#30a5864eb3ebb0a66f2ebe6d727af06a09d86e0a" +npm-run-path@^2.0.0: + version "2.0.2" + resolved "https://registry.yarnpkg.com/npm-run-path/-/npm-run-path-2.0.2.tgz#35a9232dfa35d7067b4cb2ddf2357b1871536c5f" + dependencies: + path-key "^2.0.0" + +oauth-sign@~0.9.0: + version "0.9.0" + resolved "https://registry.yarnpkg.com/oauth-sign/-/oauth-sign-0.9.0.tgz#47a7b016baa68b5fa0ecf3dee08a85c679ac6455" + object-assign@^4.0.1: version "4.1.1" resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" @@ -273,10 +497,22 @@ os-tmpdir@~1.0.2: version "1.0.2" resolved "https://registry.yarnpkg.com/os-tmpdir/-/os-tmpdir-1.0.2.tgz#bbe67406c79aa85c5cfec766fe5734555dfa1274" +p-finally@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/p-finally/-/p-finally-1.0.0.tgz#3fbcfb15b899a44123b34b6dcc18b724336a2cae" + +path-key@^2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/path-key/-/path-key-2.0.1.tgz#411cadb574c5a140d3a4b1910d40d80cc9f40b40" + pend@~1.2.0: version "1.2.0" resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50" +performance-now@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b" + pify@^2.3.0: version "2.3.0" resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c" @@ -299,7 +535,27 @@ process-nextick-args@~2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.0.tgz#a37d732f4271b4ab1ad070d35508e8290788ffaa" -readable-stream@^2.3.0, readable-stream@^2.3.5: +pseudomap@^1.0.2: + version "1.0.2" + resolved "https://registry.yarnpkg.com/pseudomap/-/pseudomap-1.0.2.tgz#f052a28da70e618917ef0a8ac34c1ae5a68286b3" + +psl@^1.1.24: + version "1.1.31" + resolved "https://registry.yarnpkg.com/psl/-/psl-1.1.31.tgz#e9aa86d0101b5b105cbe93ac6b784cd547276184" + +punycode@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-1.4.1.tgz#c0d5a63b2718800ad8e1eb0fa5269c84dd41845e" + +punycode@^2.1.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/punycode/-/punycode-2.1.1.tgz#b58b010ac40c22c5657616c8d2c2c02c7bf479ec" + +qs@~6.5.2: + version "6.5.2" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" + +readable-stream@^2.1.4, readable-stream@^2.3.0, readable-stream@^2.3.5: version "2.3.6" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.6.tgz#b11c27d88b8ff1fbe070643cf94b0c79ae1b0aaf" dependencies: @@ -311,10 +567,39 @@ readable-stream@^2.3.0, readable-stream@^2.3.5: string_decoder "~1.1.1" util-deprecate "~1.0.1" -safe-buffer@^5.1.1, safe-buffer@~5.1.0, safe-buffer@~5.1.1: +request@^2.74.0: + version "2.88.0" + resolved "https://registry.yarnpkg.com/request/-/request-2.88.0.tgz#9c2fca4f7d35b592efe57c7f0a55e81052124fef" + dependencies: + aws-sign2 "~0.7.0" + aws4 "^1.8.0" + caseless "~0.12.0" + combined-stream "~1.0.6" + extend "~3.0.2" + forever-agent "~0.6.1" + form-data "~2.3.2" + har-validator "~5.1.0" + http-signature "~1.2.0" + is-typedarray "~1.0.0" + isstream "~0.1.2" + json-stringify-safe "~5.0.1" + mime-types "~2.1.19" + oauth-sign "~0.9.0" + performance-now "^2.1.0" + qs "~6.5.2" + safe-buffer "^5.1.2" + tough-cookie "~2.4.3" + tunnel-agent "^0.6.0" + uuid "^3.3.2" + +safe-buffer@^5.0.1, safe-buffer@^5.1.1, safe-buffer@^5.1.2, safe-buffer@~5.1.0, safe-buffer@~5.1.1: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" +safer-buffer@^2.0.2, safer-buffer@^2.1.0, safer-buffer@~2.1.0: + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + seek-bzip@^1.0.5: version "1.0.5" resolved "https://registry.yarnpkg.com/seek-bzip/-/seek-bzip-1.0.5.tgz#cfe917cb3d274bcffac792758af53173eb1fabdc" @@ -336,6 +621,40 @@ semver@^5.3.0: mkdirp "^0.5.1" tmp "^0.0.33" +shebang-command@^1.2.0: + version "1.2.0" + resolved "https://registry.yarnpkg.com/shebang-command/-/shebang-command-1.2.0.tgz#44aac65b695b03398968c39f363fee5deafdf1ea" + dependencies: + shebang-regex "^1.0.0" + +shebang-regex@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/shebang-regex/-/shebang-regex-1.0.0.tgz#da42f49740c0b42db2ca9728571cb190c98efea3" + +signal-exit@^3.0.0: + version "3.0.2" + resolved "https://registry.yarnpkg.com/signal-exit/-/signal-exit-3.0.2.tgz#b5fdc08f1287ea1178628e415e25132b73646c6d" + +sshpk@^1.7.0: + version "1.16.0" + resolved "https://registry.yarnpkg.com/sshpk/-/sshpk-1.16.0.tgz#1d4963a2fbffe58050aa9084ca20be81741c07de" + dependencies: + asn1 "~0.2.3" + assert-plus "^1.0.0" + bcrypt-pbkdf "^1.0.0" + dashdash "^1.12.0" + ecc-jsbn "~0.1.1" + getpass "^0.1.1" + jsbn "~0.1.0" + safer-buffer "^2.0.2" + tweetnacl "~0.14.0" + +stream-meter@^1.0.4: + version "1.0.4" + resolved "https://registry.yarnpkg.com/stream-meter/-/stream-meter-1.0.4.tgz#52af95aa5ea760a2491716704dbff90f73afdd1d" + dependencies: + readable-stream "^2.1.4" + string_decoder@~1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" @@ -348,6 +667,10 @@ strip-dirs@^2.0.0: dependencies: is-natural-number "^4.0.1" +strip-eof@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/strip-eof/-/strip-eof-1.0.0.tgz#bb43ff5598a6eb05d89b59fcd129c983313606bf" + tar-stream@^1.5.2: version "1.6.2" resolved "https://registry.yarnpkg.com/tar-stream/-/tar-stream-1.6.2.tgz#8ea55dab37972253d9a9af90fdcd559ae435c555" @@ -374,6 +697,23 @@ to-buffer@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/to-buffer/-/to-buffer-1.1.1.tgz#493bd48f62d7c43fcded313a03dcadb2e1213a80" +tough-cookie@~2.4.3: + version "2.4.3" + resolved "https://registry.yarnpkg.com/tough-cookie/-/tough-cookie-2.4.3.tgz#53f36da3f47783b0925afa06ff9f3b165280f781" + dependencies: + psl "^1.1.24" + punycode "^1.4.1" + +tunnel-agent@^0.6.0: + version "0.6.0" + resolved "https://registry.yarnpkg.com/tunnel-agent/-/tunnel-agent-0.6.0.tgz#27a5dea06b36b04a0a9966774b290868f0fc40fd" + dependencies: + safe-buffer "^5.0.1" + +tweetnacl@^0.14.3, tweetnacl@~0.14.0: + version "0.14.5" + resolved "https://registry.yarnpkg.com/tweetnacl/-/tweetnacl-0.14.5.tgz#5ae68177f192d4456269d108afa93ff8743f4f64" + unbzip2-stream@^1.0.9: version "1.3.1" resolved "https://registry.yarnpkg.com/unbzip2-stream/-/unbzip2-stream-1.3.1.tgz#7854da51622a7e63624221196357803b552966a1" @@ -381,10 +721,28 @@ unbzip2-stream@^1.0.9: buffer "^3.0.1" through "^2.3.6" +uri-js@^4.2.2: + version "4.2.2" + resolved "https://registry.yarnpkg.com/uri-js/-/uri-js-4.2.2.tgz#94c540e1ff772956e2299507c010aea6c8838eb0" + dependencies: + punycode "^2.1.0" + util-deprecate@~1.0.1: version "1.0.2" resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" +uuid@^3.3.2: + version "3.3.2" + resolved "https://registry.yarnpkg.com/uuid/-/uuid-3.3.2.tgz#1b4af4955eb3077c501c23872fc6513811587131" + +verror@1.10.0: + version "1.10.0" + resolved "https://registry.yarnpkg.com/verror/-/verror-1.10.0.tgz#3a105ca17053af55d6e270c1f8288682e18da400" + dependencies: + assert-plus "^1.0.0" + core-util-is "1.0.2" + extsprintf "^1.2.0" + vscode-extension-telemetry@^0.0.15: version "0.0.15" resolved "https://registry.yarnpkg.com/vscode-extension-telemetry/-/vscode-extension-telemetry-0.0.15.tgz#685c32f3b67e8fb85ba689c1d7f88ff90ff87856" @@ -412,6 +770,24 @@ vscode-languageserver-types@3.5.0: version "3.5.0" resolved "https://registry.yarnpkg.com/vscode-languageserver-types/-/vscode-languageserver-types-3.5.0.tgz#e48d79962f0b8e02de955e3f524908e2b19c0374" +vscode-nls@2.0.2: + version "2.0.2" + resolved "https://registry.yarnpkg.com/vscode-nls/-/vscode-nls-2.0.2.tgz#808522380844b8ad153499af5c3b03921aea02da" + +webhdfs@^1.1.1: + version "1.2.0" + resolved "https://registry.yarnpkg.com/webhdfs/-/webhdfs-1.2.0.tgz#c41b08ae33944a0220863bfd4b6719b9aaec1d37" + dependencies: + buffer-stream-reader "^0.1.1" + extend "^3.0.0" + request "^2.74.0" + +which@^1.2.9: + version "1.3.1" + resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" + dependencies: + isexe "^2.0.0" + wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" @@ -420,6 +796,10 @@ xtend@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.1.tgz#a5c6d532be656e23db820efb943a1f04998d63af" +yallist@^2.1.2: + version "2.1.2" + resolved "https://registry.yarnpkg.com/yallist/-/yallist-2.1.2.tgz#1c11f9218f076089a47dd512f93c6699a6a81d52" + yauzl@^2.4.2: version "2.10.0" resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9" diff --git a/src/sql/parts/objectExplorer/common/objectExplorerService.ts b/src/sql/parts/objectExplorer/common/objectExplorerService.ts index 87a1c67de0..cfa3e6f307 100644 --- a/src/sql/parts/objectExplorer/common/objectExplorerService.ts +++ b/src/sql/parts/objectExplorer/common/objectExplorerService.ts @@ -26,6 +26,10 @@ export const SERVICE_ID = 'ObjectExplorerService'; export const IObjectExplorerService = createDecorator(SERVICE_ID); +export interface NodeExpandInfoWithProviderId extends sqlops.ObjectExplorerExpandInfo { + providerId: string; +} + export interface IObjectExplorerService { _serviceBrand: any; @@ -45,13 +49,15 @@ export interface IObjectExplorerService { onSessionDisconnected(handle: number, sessionResponse: sqlops.ObjectExplorerSession); - onNodeExpanded(handle: number, sessionResponse: sqlops.ObjectExplorerExpandInfo); + onNodeExpanded(sessionResponse: NodeExpandInfoWithProviderId); /** * Register a ObjectExplorer provider */ registerProvider(providerId: string, provider: sqlops.ObjectExplorerProvider): void; + registerNodeProvider(expander: sqlops.ObjectExplorerNodeProvider): void; + getObjectExplorerNode(connection: IConnectionProfile): TreeNode; updateObjectExplorerNodes(connectionProfile: IConnectionProfile): Promise; @@ -82,15 +88,18 @@ export interface IObjectExplorerService { * For Testing purpose only. Get the context menu actions for an object explorer node. */ getNodeActions(connectionId: string, nodePath: string): Thenable; + + getSessionConnectionProfile(sessionId: string): sqlops.IConnectionProfile; } interface SessionStatus { nodes: { [nodePath: string]: NodeStatus }; connection: ConnectionProfile; + expandNodeTimer?: number; } interface NodeStatus { - expandEmitter: Emitter; + expandEmitter: Emitter; } export interface ObjectExplorerNodeEventArgs { @@ -103,6 +112,14 @@ export interface NodeInfoWithConnection { nodeInfo: sqlops.NodeInfo; } +export interface TopLevelChildrenPath { + providerId: string; + supportedProviderId: string; + groupingId: number; + path: string[]; + providerObject: sqlops.ObjectExplorerNodeProvider | sqlops.ObjectExplorerProvider; +} + export class ObjectExplorerService implements IObjectExplorerService { public _serviceBrand: any; @@ -111,6 +128,8 @@ export class ObjectExplorerService implements IObjectExplorerService { private _providers: { [handle: string]: sqlops.ObjectExplorerProvider; } = Object.create(null); + private _nodeProviders: { [handle: string]: sqlops.ObjectExplorerNodeProvider[]; } = Object.create(null); + private _activeObjectExplorerNodes: { [id: string]: TreeNode }; private _sessions: { [sessionId: string]: SessionStatus }; @@ -129,6 +148,7 @@ export class ObjectExplorerService implements IObjectExplorerService { this._activeObjectExplorerNodes = {}; this._sessions = {}; this._providers = {}; + this._nodeProviders = {}; this._onSelectionOrFocusChange = new Emitter(); } @@ -166,7 +186,7 @@ export class ObjectExplorerService implements IObjectExplorerService { /** * Gets called when expanded node response is ready */ - public onNodeExpanded(handle: number, expandResponse: sqlops.ObjectExplorerExpandInfo) { + public onNodeExpanded(expandResponse: NodeExpandInfoWithProviderId) { if (expandResponse.errorMessage) { error(expandResponse.errorMessage); @@ -189,28 +209,43 @@ export class ObjectExplorerService implements IObjectExplorerService { /** * Gets called when session is created */ - public onSessionCreated(handle: number, session: sqlops.ObjectExplorerSession) { - let connection: ConnectionProfile = undefined; - let errorMessage: string = undefined; - if (this._sessions[session.sessionId]) { - connection = this._sessions[session.sessionId].connection; + public onSessionCreated(handle: number, session: sqlops.ObjectExplorerSession): void { + this.handleSessionCreated(session); + } - if (session && session.success && session.rootNode) { - let server = this.toTreeNode(session.rootNode, null); - server.connection = connection; - server.session = session; - this._activeObjectExplorerNodes[connection.id] = server; - } else { - errorMessage = session && session.errorMessage ? session.errorMessage : - nls.localize('OeSessionFailedError', 'Failed to create Object Explorer session'); - error(errorMessage); + private async handleSessionCreated(session: sqlops.ObjectExplorerSession): Promise { + try { + let connection: ConnectionProfile = undefined; + let errorMessage: string = undefined; + if (this._sessions[session.sessionId]) { + connection = this._sessions[session.sessionId].connection; + + if (session && session.success && session.rootNode) { + let server = this.toTreeNode(session.rootNode, null); + server.connection = connection; + server.session = session; + this._activeObjectExplorerNodes[connection.id] = server; + } + else { + errorMessage = session && session.errorMessage ? session.errorMessage : + nls.localize('OeSessionFailedError', 'Failed to create Object Explorer session'); + error(errorMessage); + } + // Send on session created about the session to all node providers so they can prepare for node expansion + let nodeProviders = this._nodeProviders[connection.providerName]; + if (nodeProviders) { + let promises: Thenable[] = nodeProviders.map(p => p.handleSessionOpen(session)); + await Promise.all(promises); + } + } + else { + warn(`cannot find session ${session.sessionId}`); } - } else { - warn(`cannot find session ${session.sessionId}`); + this.sendUpdateNodeEvent(connection, errorMessage); + } catch (error) { + warn(`cannot handle the session ${session.sessionId} in all nodeProviders`); } - - this.sendUpdateNodeEvent(connection, errorMessage); } /** @@ -291,7 +326,7 @@ export class ObjectExplorerService implements IObjectExplorerService { let provider = this._providers[providerId]; if (provider) { TelemetryUtils.addTelemetry(this._telemetryService, TelemetryKeys.ObjectExplorerExpand, { refresh: 0, provider: providerId }); - this.expandOrRefreshNode(provider, session, nodePath).then(result => { + this.expandOrRefreshNode(providerId, session, nodePath).then(result => { resolve(result); }, error => { reject(error); @@ -301,7 +336,8 @@ export class ObjectExplorerService implements IObjectExplorerService { } }); } - private callExpandOrRefreshFromProvider(provider: sqlops.ObjectExplorerProvider, nodeInfo: sqlops.ExpandNodeInfo, refresh: boolean = false) { + + private callExpandOrRefreshFromProvider(provider: sqlops.ObjectExplorerProviderBase, nodeInfo: sqlops.ExpandNodeInfo, refresh: boolean = false) { if (refresh) { return provider.refreshNode(nodeInfo); } else { @@ -310,7 +346,7 @@ export class ObjectExplorerService implements IObjectExplorerService { } private expandOrRefreshNode( - provider: sqlops.ObjectExplorerProvider, + providerId: string, session: sqlops.ObjectExplorerSession, nodePath: string, refresh: boolean = false): Thenable { @@ -320,29 +356,60 @@ export class ObjectExplorerService implements IObjectExplorerService { let newRequest = false; if (!self._sessions[session.sessionId].nodes[nodePath]) { self._sessions[session.sessionId].nodes[nodePath] = { - expandEmitter: new Emitter() + expandEmitter: new Emitter() }; newRequest = true; } - self._sessions[session.sessionId].nodes[nodePath].expandEmitter.event(((expandResult) => { - if (expandResult && !expandResult.errorMessage) { - resolve(expandResult); + let provider = this._providers[providerId]; + if (provider) { + let resultMap: Map = new Map(); + let allProviders: sqlops.ObjectExplorerProviderBase[] = [provider]; + + let nodeProviders = this._nodeProviders[providerId]; + if (nodeProviders) { + nodeProviders = nodeProviders.sort((a, b) => a.group.toLowerCase().localeCompare(b.group.toLowerCase())); + allProviders.push(...nodeProviders); } - else { - reject(expandResult ? expandResult.errorMessage : undefined); - } - if (newRequest) { - delete self._sessions[session.sessionId].nodes[nodePath]; - } - })); - if (newRequest) { - self.callExpandOrRefreshFromProvider(provider, { - sessionId: session.sessionId, - nodePath: nodePath - }, refresh).then(result => { - }, error => { - reject(error); + + self._sessions[session.sessionId].nodes[nodePath].expandEmitter.event((expandResult) => { + if (expandResult && expandResult.providerId) { + resultMap.set(expandResult.providerId, expandResult); + } else { + console.log('OE provider returns empty result or providerId'); + } + + // When get all responses from all providers, merge results + if (resultMap.size === allProviders.length) { + resolve(self.mergeResults(allProviders, resultMap, nodePath)); + + // Have to delete it after get all reponses otherwise couldn't find session for not the first response + if (newRequest) { + delete self._sessions[session.sessionId].nodes[nodePath]; + } + } }); + if (newRequest) { + allProviders.forEach(provider => { + TelemetryUtils.addTelemetry(this._telemetryService, TelemetryKeys.ObjectExplorerExpand, { refresh: 0, provider: providerId }); + self.callExpandOrRefreshFromProvider(provider, { + sessionId: session.sessionId, + nodePath: nodePath + }, refresh).then(isExpanding => { + if (!isExpanding) { + // The provider stated it's not going to expand the node, therefore do not need to track when merging results + let emptyResult: sqlops.ObjectExplorerExpandInfo = { + errorMessage: undefined, + nodePath: nodePath, + nodes: [], + sessionId: session.sessionId + }; + resultMap.set(provider.providerId, emptyResult); + } + }, error => { + reject(error); + }); + }); + } } } else { reject(`session cannot find to expand node. id: ${session.sessionId} nodePath: ${nodePath}`); @@ -350,11 +417,48 @@ export class ObjectExplorerService implements IObjectExplorerService { }); } + private mergeResults(allProviders: sqlops.ObjectExplorerProviderBase[], resultMap: Map, nodePath: string): sqlops.ObjectExplorerExpandInfo { + let finalResult: sqlops.ObjectExplorerExpandInfo; + let allNodes: sqlops.NodeInfo[] = []; + let errorNode: sqlops.NodeInfo = { + nodePath: nodePath, + label: 'Error', + errorMessage: '', + nodeType: 'folder', + isLeaf: true, + nodeSubType: '', + nodeStatus: '', + metadata: null + }; + + for (let provider of allProviders) { + if (resultMap.has(provider.providerId)) { + let result = resultMap.get(provider.providerId); + if (result) { + if (!result.errorMessage) { + finalResult = result; + allNodes = allNodes.concat(result.nodes); + } else { + errorNode.errorMessage += provider.providerId + 'returns ' + result.errorMessage + ' '; + } + } + } + } + if (finalResult) { + if (errorNode.errorMessage && errorNode.errorMessage.length > 0) { + allNodes = allNodes.concat([errorNode]); + } + + finalResult.nodes = allNodes; + } + return finalResult; + } + public refreshNode(providerId: string, session: sqlops.ObjectExplorerSession, nodePath: string): Thenable { let provider = this._providers[providerId]; if (provider) { TelemetryUtils.addTelemetry(this._telemetryService, TelemetryKeys.ObjectExplorerExpand, { refresh: 1, provider: providerId }); - return this.expandOrRefreshNode(provider, session, nodePath, true); + return this.expandOrRefreshNode(providerId, session, nodePath, true); } return Promise.resolve(undefined); } @@ -369,7 +473,8 @@ export class ObjectExplorerService implements IObjectExplorerService { sessionId: session.sessionId, nodes: [], nodePath: nodePath, - errorMessage: undefined + errorMessage: undefined, + providerId: providerId }); } }); @@ -377,6 +482,14 @@ export class ObjectExplorerService implements IObjectExplorerService { let provider = this._providers[providerId]; if (provider) { + let nodeProviders = this._nodeProviders[providerId]; + if (nodeProviders) { + for (let nodeProvider of nodeProviders) { + nodeProvider.handleSessionClose({ + sessionId: session ? session.sessionId : undefined + }); + } + } return provider.closeSession({ sessionId: session ? session.sessionId : undefined }); @@ -392,6 +505,12 @@ export class ObjectExplorerService implements IObjectExplorerService { this._providers[providerId] = provider; } + public registerNodeProvider(nodeProvider: sqlops.ObjectExplorerNodeProvider): void { + let nodeProviders = this._nodeProviders[nodeProvider.supportedProviderId] || []; + nodeProviders.push(nodeProvider); + this._nodeProviders[nodeProvider.supportedProviderId] = nodeProviders; + } + public dispose(): void { this._disposables = dispose(this._disposables); } @@ -528,7 +647,7 @@ export class ObjectExplorerService implements IObjectExplorerService { } /** - * For Testing purpose only. Get the context menu actions for an object explorer node + * For Testing purpose only. Get the context menu actions for an object explorer node */ public getNodeActions(connectionId: string, nodePath: string): Thenable { return this.getTreeNode(connectionId, nodePath).then(node => { @@ -552,6 +671,10 @@ export class ObjectExplorerService implements IObjectExplorerService { return treeNode; } + public getSessionConnectionProfile(sessionId: string): sqlops.IConnectionProfile { + return this._sessions[sessionId].connection.toIConnectionProfile(); + } + private async setNodeExpandedState(treeNode: TreeNode, expandedState: TreeItemCollapsibleState): Promise { treeNode = await this.getUpdatedTreeNode(treeNode); let expandNode = this.getTreeItem(treeNode); diff --git a/src/sql/platform/connection/common/connectionManagement.ts b/src/sql/platform/connection/common/connectionManagement.ts index 274934be0f..ba7cdffbb4 100644 --- a/src/sql/platform/connection/common/connectionManagement.ts +++ b/src/sql/platform/connection/common/connectionManagement.ts @@ -256,6 +256,13 @@ export interface IConnectionManagementService { */ getActiveConnectionCredentials(profileId: string): { [name: string]: string }; + /** + * Get the ServerInfo for a connected connection profile + * @param {string} profileId The id of the connection profile to get the password for + * @returns ServerInfo + */ + getServerInfo(profileId: string): sqlops.ServerInfo; + /** * Get the connection string for the provided connection ID */ diff --git a/src/sql/platform/connection/common/connectionManagementService.ts b/src/sql/platform/connection/common/connectionManagementService.ts index 9a346e9610..7dc26bca2e 100644 --- a/src/sql/platform/connection/common/connectionManagementService.ts +++ b/src/sql/platform/connection/common/connectionManagementService.ts @@ -1367,6 +1367,17 @@ export class ConnectionManagementService extends Disposable implements IConnecti return credentials; } + public getServerInfo(profileId: string): sqlops.ServerInfo { + let profile = this._connectionStatusManager.findConnectionByProfileId(profileId); + if (!profile) { + return undefined; + } + + let serverInfo = profile.serverInfo; + + return serverInfo; + } + /** * Get the connection string for the provided connection ID */ diff --git a/src/sql/sqlops.d.ts b/src/sql/sqlops.d.ts index f0dec6d5c3..a32fb35e20 100644 --- a/src/sql/sqlops.d.ts +++ b/src/sql/sqlops.d.ts @@ -21,6 +21,8 @@ declare module 'sqlops' { export function registerObjectExplorerProvider(provider: ObjectExplorerProvider): vscode.Disposable; + export function registerObjectExplorerNodeProvider(provider: ObjectExplorerNodeProvider): vscode.Disposable; + export function registerTaskServicesProvider(provider: TaskServicesProvider): vscode.Disposable; export function registerFileBrowserProvider(provider: FileBrowserProvider): vscode.Disposable; @@ -101,6 +103,13 @@ declare module 'sqlops' { */ export function getCredentials(connectionId: string): Thenable<{ [name: string]: string }>; + /** + * Get ServerInfo for a connectionId + * @param {string} connectionId The id of the connection + * @returns ServerInfo + */ + export function getServerInfo(connectionId: string): Thenable; + /** * Interface for representing a connection when working with connection APIs */ @@ -150,6 +159,13 @@ declare module 'sqlops' { */ export function findNodes(connectionId: string, type: string, schema: string, name: string, database: string, parentObjectNames: string[]): Thenable; + /** + * Get connectionProfile from sessionId + * *@param {string} sessionId The id of the session that the node exists on + * @returns {IConnectionProfile} The IConnecitonProfile for the session + */ + export function getSessionConnectionProfile(sessionId: string): Thenable; + /** * Interface for representing and interacting with items in Object Explorer */ @@ -347,6 +363,10 @@ declare module 'sqlops' { * The Operating System version string of the machine running the instance. */ osVersion: string; + /** + * options for all new server properties. + */ + options: {}; } export interface DataProvider { @@ -1163,22 +1183,41 @@ declare module 'sqlops' { nodes: NodeInfo[]; } - export interface ObjectExplorerProvider extends DataProvider { - createNewSession(connInfo: ConnectionInfo): Thenable; - + export interface ObjectExplorerProviderBase extends DataProvider { expandNode(nodeInfo: ExpandNodeInfo): Thenable; refreshNode(nodeInfo: ExpandNodeInfo): Thenable; - closeSession(closeSessionInfo: ObjectExplorerCloseSessionInfo): Thenable; - findNodes(findNodesInfo: FindNodesInfo): Thenable; + registerOnExpandCompleted(handler: (response: ObjectExplorerExpandInfo) => any): void; + } + + export interface ObjectExplorerProvider extends ObjectExplorerProviderBase { + createNewSession(connInfo: ConnectionInfo): Thenable; + + closeSession(closeSessionInfo: ObjectExplorerCloseSessionInfo): Thenable; + registerOnSessionCreated(handler: (response: ObjectExplorerSession) => any): void; registerOnSessionDisconnected?(handler: (response: ObjectExplorerSession) => any): void; + } - registerOnExpandCompleted(handler: (response: ObjectExplorerExpandInfo) => any): void; + export interface ObjectExplorerNodeProvider extends ObjectExplorerProviderBase { + /** + * The providerId for whichever type of ObjectExplorer connection this can add folders and objects to + */ + readonly supportedProviderId: string; + + /** + * Optional group name used to sort nodes in the tree. If not defined, the node order will be added in order based on provider ID, with + * nodes from the main ObjectExplorerProvider for this provider type added first + */ + readonly group?: string; + + handleSessionOpen(session: ObjectExplorerSession): Thenable; + + handleSessionClose(closeSessionInfo: ObjectExplorerCloseSessionInfo): void; } // Admin Services interfaces ----------------------------------------------------------------------- diff --git a/src/sql/sqlops.proposed.d.ts b/src/sql/sqlops.proposed.d.ts index 94dfb730b0..4add87dc42 100644 --- a/src/sql/sqlops.proposed.d.ts +++ b/src/sql/sqlops.proposed.d.ts @@ -1240,6 +1240,7 @@ declare module 'sqlops' { AgentServicesProvider = 'AgentServicesProvider', CapabilitiesProvider = 'CapabilitiesProvider', DacFxServicesProvider = 'DacFxServicesProvider', + ObjectExplorerNodeProvider = 'ObjectExplorerNodeProvider', } export namespace dataprotocol { diff --git a/src/sql/workbench/api/common/sqlExtHostTypes.ts b/src/sql/workbench/api/common/sqlExtHostTypes.ts index bfe5df9976..63485a607d 100644 --- a/src/sql/workbench/api/common/sqlExtHostTypes.ts +++ b/src/sql/workbench/api/common/sqlExtHostTypes.ts @@ -288,6 +288,7 @@ export enum DataProviderType { AgentServicesProvider = 'AgentServicesProvider', CapabilitiesProvider = 'CapabilitiesProvider', DacFxServicesProvider = 'DacFxServicesProvider', + ObjectExplorerNodeProvider = 'ObjectExplorerNodeProvider', } export enum DeclarativeDataType { @@ -320,6 +321,17 @@ export enum AzureResource { Sql = 1 } +export interface ServerInfoOption { + isBigDataCluster: boolean; + clusterEndpoints: ClusterEndpoint; +} + +export interface ClusterEndpoint { + serviceName: string; + ipAddress: string; + port: number; +} + export class SqlThemeIcon { static readonly Folder = new SqlThemeIcon('Folder'); diff --git a/src/sql/workbench/api/node/extHostConnectionManagement.ts b/src/sql/workbench/api/node/extHostConnectionManagement.ts index 27d0451229..6699be5ab4 100644 --- a/src/sql/workbench/api/node/extHostConnectionManagement.ts +++ b/src/sql/workbench/api/node/extHostConnectionManagement.ts @@ -32,6 +32,10 @@ export class ExtHostConnectionManagement extends ExtHostConnectionManagementShap return this._proxy.$getCredentials(connectionId); } + public $getServerInfo(connectionId: string): Thenable { + return this._proxy.$getServerInfo(connectionId); + } + public $openConnectionDialog(providers?: string[], initialConnectionProfile?: sqlops.IConnectionProfile, connectionCompletionOptions?: sqlops.IConnectionCompletionOptions): Thenable { return this._proxy.$openConnectionDialog(providers, initialConnectionProfile, connectionCompletionOptions); } diff --git a/src/sql/workbench/api/node/extHostDataProtocol.ts b/src/sql/workbench/api/node/extHostDataProtocol.ts index 69698d053d..386f5d78cf 100644 --- a/src/sql/workbench/api/node/extHostDataProtocol.ts +++ b/src/sql/workbench/api/node/extHostDataProtocol.ts @@ -132,6 +132,12 @@ export class ExtHostDataProtocol extends ExtHostDataProtocolShape { return rt; } + $registerObjectExplorerNodeProvider(provider: sqlops.ObjectExplorerNodeProvider): vscode.Disposable { + let rt = this.registerProvider(provider, DataProviderType.ObjectExplorerNodeProvider); + this._proxy.$registerObjectExplorerNodeProvider(provider.providerId, provider.supportedProviderId, provider.group, provider.handle); + return rt; + } + $registerProfilerProvider(provider: sqlops.ProfilerProvider): vscode.Disposable { let rt = this.registerProvider(provider, DataProviderType.ProfilerProvider); this._proxy.$registerProfilerProvider(provider.providerId, provider.handle); @@ -342,20 +348,28 @@ export class ExtHostDataProtocol extends ExtHostDataProtocolShape { return this._resolveProvider(handle).createNewSession(connInfo); } + public $createObjectExplorerNodeProviderSession(handle: number, session: sqlops.ObjectExplorerSession): Thenable { + return this._resolveProvider(handle).handleSessionOpen(session); + } + public $expandObjectExplorerNode(handle: number, nodeInfo: sqlops.ExpandNodeInfo): Thenable { - return this._resolveProvider(handle).expandNode(nodeInfo); + return this._resolveProvider (handle).expandNode(nodeInfo); } public $refreshObjectExplorerNode(handle: number, nodeInfo: sqlops.ExpandNodeInfo): Thenable { - return this._resolveProvider(handle).refreshNode(nodeInfo); + return this._resolveProvider (handle).refreshNode(nodeInfo); } public $closeObjectExplorerSession(handle: number, closeSessionInfo: sqlops.ObjectExplorerCloseSessionInfo): Thenable { return this._resolveProvider(handle).closeSession(closeSessionInfo); } + public $handleSessionClose(handle: number, closeSessionInfo: sqlops.ObjectExplorerCloseSessionInfo): void { + return this._resolveProvider(handle).handleSessionClose(closeSessionInfo); + } + public $findNodes(handle: number, findNodesInfo: sqlops.FindNodesInfo): Thenable { - return this._resolveProvider(handle).findNodes(findNodesInfo); + return this._resolveProvider(handle).findNodes(findNodesInfo); } public $onObjectExplorerSessionCreated(handle: number, response: sqlops.ObjectExplorerSession): void { @@ -366,8 +380,8 @@ export class ExtHostDataProtocol extends ExtHostDataProtocolShape { this._proxy.$onObjectExplorerSessionDisconnected(handle, response); } - public $onObjectExplorerNodeExpanded(handle: number, response: sqlops.ObjectExplorerExpandInfo): void { - this._proxy.$onObjectExplorerNodeExpanded(handle, response); + public $onObjectExplorerNodeExpanded(providerId: string, response: sqlops.ObjectExplorerExpandInfo): void { + this._proxy.$onObjectExplorerNodeExpanded(providerId, response); } // Task Service diff --git a/src/sql/workbench/api/node/extHostObjectExplorer.ts b/src/sql/workbench/api/node/extHostObjectExplorer.ts index 8017bc877a..3012829041 100644 --- a/src/sql/workbench/api/node/extHostObjectExplorer.ts +++ b/src/sql/workbench/api/node/extHostObjectExplorer.ts @@ -34,6 +34,10 @@ export class ExtHostObjectExplorer implements ExtHostObjectExplorerShape { public $getNodeActions(connectionId: string, nodePath: string): Thenable { return this._proxy.$getNodeActions(connectionId, nodePath); } + + public $getSessionConnectionProfile(sessionId: string): Thenable { + return this._proxy.$getSessionConnectionProfile(sessionId); + } } class ExtHostObjectExplorerNode implements sqlops.objectexplorer.ObjectExplorerNode { diff --git a/src/sql/workbench/api/node/mainThreadConnectionManagement.ts b/src/sql/workbench/api/node/mainThreadConnectionManagement.ts index 1d5d7a8387..ac0c06eda4 100644 --- a/src/sql/workbench/api/node/mainThreadConnectionManagement.ts +++ b/src/sql/workbench/api/node/mainThreadConnectionManagement.ts @@ -55,6 +55,9 @@ export class MainThreadConnectionManagement implements MainThreadConnectionManag return Promise.resolve(this._connectionManagementService.getActiveConnectionCredentials(connectionId)); } + public $getServerInfo(connectionId: string): Thenable { + return Promise.resolve(this._connectionManagementService.getServerInfo(connectionId)); + } public async $openConnectionDialog(providers: string[], initialConnectionProfile?: IConnectionProfile, connectionCompletionOptions?: sqlops.IConnectionCompletionOptions): Promise { let connectionProfile = await this._connectionDialogService.openDialogAndWait(this._connectionManagementService, { connectionType: 1, providers: providers }, initialConnectionProfile); diff --git a/src/sql/workbench/api/node/mainThreadDataProtocol.ts b/src/sql/workbench/api/node/mainThreadDataProtocol.ts index ffa34f79d2..893503f8ff 100644 --- a/src/sql/workbench/api/node/mainThreadDataProtocol.ts +++ b/src/sql/workbench/api/node/mainThreadDataProtocol.ts @@ -15,7 +15,7 @@ import { ICapabilitiesService } from 'sql/platform/capabilities/common/capabilit import { IQueryManagementService } from 'sql/platform/query/common/queryManagement'; import * as sqlops from 'sqlops'; import { IMetadataService } from 'sql/platform/metadata/common/metadataService'; -import { IObjectExplorerService } from 'sql/parts/objectExplorer/common/objectExplorerService'; +import { IObjectExplorerService, NodeExpandInfoWithProviderId } from 'sql/parts/objectExplorer/common/objectExplorerService'; import { IScriptingService } from 'sql/platform/scripting/common/scriptingService'; import { IAdminService } from 'sql/workbench/services/admin/common/adminService'; import { IJobManagementService } from 'sql/platform/jobManagement/common/interfaces'; @@ -231,6 +231,7 @@ export class MainThreadDataProtocol implements MainThreadDataProtocolShape { public $registerObjectExplorerProvider(providerId: string, handle: number): TPromise { const self = this; this._objectExplorerService.registerProvider(providerId, { + providerId: providerId, createNewSession(connection: sqlops.ConnectionInfo): Thenable { return self._proxy.$createObjectExplorerSession(handle, connection); }, @@ -251,6 +252,32 @@ export class MainThreadDataProtocol implements MainThreadDataProtocolShape { return undefined; } + public $registerObjectExplorerNodeProvider(providerId: string, supportedProviderId: string, group: string, handle: number): TPromise { + const self = this; + this._objectExplorerService.registerNodeProvider( { + supportedProviderId: supportedProviderId, + providerId: providerId, + group: group, + expandNode(nodeInfo: sqlops.ExpandNodeInfo): Thenable { + return self._proxy.$expandObjectExplorerNode(handle, nodeInfo); + }, + refreshNode(nodeInfo: sqlops.ExpandNodeInfo): Thenable { + return self._proxy.$refreshObjectExplorerNode(handle, nodeInfo); + }, + findNodes(findNodesInfo: sqlops.FindNodesInfo): Thenable { + return self._proxy.$findNodes(handle, findNodesInfo); + }, + handleSessionOpen(session: sqlops.ObjectExplorerSession): Thenable { + return self._proxy.$createObjectExplorerNodeProviderSession(handle, session); + }, + handleSessionClose(closeSessionInfo: sqlops.ObjectExplorerCloseSessionInfo): void { + return self._proxy.$handleSessionClose(handle, closeSessionInfo); + } + }); + + return undefined; + } + public $registerTaskServicesProvider(providerId: string, handle: number): TPromise { const self = this; this._taskService.registerProvider(providerId, { @@ -474,8 +501,9 @@ export class MainThreadDataProtocol implements MainThreadDataProtocolShape { this._objectExplorerService.onSessionDisconnected(handle, sessionResponse); } - public $onObjectExplorerNodeExpanded(handle: number, expandResponse: sqlops.ObjectExplorerExpandInfo): void { - this._objectExplorerService.onNodeExpanded(handle, expandResponse); + public $onObjectExplorerNodeExpanded(providerId: string, expandResponse: sqlops.ObjectExplorerExpandInfo): void { + let expandInfo: NodeExpandInfoWithProviderId = Object.assign({ providerId: providerId }, expandResponse); + this._objectExplorerService.onNodeExpanded(expandInfo); } //Tasks handlers diff --git a/src/sql/workbench/api/node/mainThreadObjectExplorer.ts b/src/sql/workbench/api/node/mainThreadObjectExplorer.ts index 45c8ad0d91..31d4104c1b 100644 --- a/src/sql/workbench/api/node/mainThreadObjectExplorer.ts +++ b/src/sql/workbench/api/node/mainThreadObjectExplorer.ts @@ -82,4 +82,8 @@ export class MainThreadObjectExplorer implements MainThreadObjectExplorerShape { public $getNodeActions(connectionId: string, nodePath: string): Thenable { return this._objectExplorerService.getNodeActions(connectionId, nodePath); } + + public $getSessionConnectionProfile(sessionId: string): Thenable { + return Promise.resolve(this._objectExplorerService.getSessionConnectionProfile(sessionId)); + } } diff --git a/src/sql/workbench/api/node/sqlExtHost.api.impl.ts b/src/sql/workbench/api/node/sqlExtHost.api.impl.ts index bba313b99c..ad9d212066 100644 --- a/src/sql/workbench/api/node/sqlExtHost.api.impl.ts +++ b/src/sql/workbench/api/node/sqlExtHost.api.impl.ts @@ -120,6 +120,9 @@ export function createApiFactory( getCredentials(connectionId: string): Thenable<{ [name: string]: string }> { return extHostConnectionManagement.$getCredentials(connectionId); }, + getServerInfo(connectionId: string): Thenable { + return extHostConnectionManagement.$getServerInfo(connectionId); + }, openConnectionDialog(providers?: string[], initialConnectionProfile?: sqlops.IConnectionProfile, connectionCompletionOptions?: sqlops.IConnectionCompletionOptions): Thenable { return extHostConnectionManagement.$openConnectionDialog(providers, initialConnectionProfile, connectionCompletionOptions); }, @@ -160,6 +163,9 @@ export function createApiFactory( }, getNodeActions(connectionId: string, nodePath: string): Thenable { return extHostObjectExplorer.$getNodeActions(connectionId, nodePath); + }, + getSessionConnectionProfile(sessionId: string): Thenable { + return extHostObjectExplorer.$getSessionConnectionProfile(sessionId); } }; @@ -238,12 +244,20 @@ export function createApiFactory( } provider.registerOnExpandCompleted((response: sqlops.ObjectExplorerExpandInfo) => { - extHostDataProvider.$onObjectExplorerNodeExpanded(provider.handle, response); + extHostDataProvider.$onObjectExplorerNodeExpanded(provider.providerId, response); }); return extHostDataProvider.$registerObjectExplorerProvider(provider); }; + let registerObjectExplorerNodeProvider = (provider: sqlops.ObjectExplorerNodeProvider): vscode.Disposable => { + provider.registerOnExpandCompleted((response: sqlops.ObjectExplorerExpandInfo) => { + extHostDataProvider.$onObjectExplorerNodeExpanded(provider.providerId, response); + }); + + return extHostDataProvider.$registerObjectExplorerNodeProvider(provider); + }; + let registerTaskServicesProvider = (provider: sqlops.TaskServicesProvider): vscode.Disposable => { provider.registerOnTaskCreated((response: sqlops.TaskInfo) => { extHostDataProvider.$onTaskCreated(provider.handle, response); @@ -335,6 +349,7 @@ export function createApiFactory( registerFileBrowserProvider, registerMetadataProvider, registerObjectExplorerProvider, + registerObjectExplorerNodeProvider, registerProfilerProvider, registerRestoreProvider, registerScriptingProvider, diff --git a/src/sql/workbench/api/node/sqlExtHost.protocol.ts b/src/sql/workbench/api/node/sqlExtHost.protocol.ts index 3069487015..d3fb541d9b 100644 --- a/src/sql/workbench/api/node/sqlExtHost.protocol.ts +++ b/src/sql/workbench/api/node/sqlExtHost.protocol.ts @@ -128,6 +128,10 @@ export abstract class ExtHostDataProtocolShape { $findNodes(handle: number, findNodesInfo: sqlops.FindNodesInfo): Thenable { throw ni(); } + $createObjectExplorerNodeProviderSession(handle: number, sessionInfo: sqlops.ObjectExplorerSession): Thenable { throw ni(); } + + $handleSessionClose(handle: number, closeSessionInfo: sqlops.ObjectExplorerCloseSessionInfo): void { throw ni(); } + /** * Tasks */ @@ -507,6 +511,7 @@ export interface MainThreadDataProtocolShape extends IDisposable { $registerQueryProvider(providerId: string, handle: number): TPromise; $registerProfilerProvider(providerId: string, handle: number): TPromise; $registerObjectExplorerProvider(providerId: string, handle: number): TPromise; + $registerObjectExplorerNodeProvider(providerId: string, supportedProviderId: string, group: string, handle: number): TPromise; $registerMetadataProvider(providerId: string, handle: number): TPromise; $registerTaskServicesProvider(providerId: string, handle: number): TPromise; $registerFileBrowserProvider(providerId: string, handle: number): TPromise; @@ -526,7 +531,7 @@ export interface MainThreadDataProtocolShape extends IDisposable { $onQueryMessage(handle: number, message: sqlops.QueryExecuteMessageParams): void; $onObjectExplorerSessionCreated(handle: number, message: sqlops.ObjectExplorerSession): void; $onObjectExplorerSessionDisconnected(handle: number, message: sqlops.ObjectExplorerSession): void; - $onObjectExplorerNodeExpanded(handle: number, message: sqlops.ObjectExplorerExpandInfo): void; + $onObjectExplorerNodeExpanded(providerId: string, message: sqlops.ObjectExplorerExpandInfo): void; $onTaskCreated(handle: number, sessionResponse: sqlops.TaskInfo): void; $onTaskStatusChanged(handle: number, sessionResponse: sqlops.TaskProgressInfo): void; $onFileBrowserOpened(handle: number, response: sqlops.FileBrowserOpenedParams): void; @@ -548,6 +553,7 @@ export interface MainThreadConnectionManagementShape extends IDisposable { $getActiveConnections(): Thenable; $getCurrentConnection(): Thenable; $getCredentials(connectionId: string): Thenable<{ [name: string]: string }>; + $getServerInfo(connectedId: string): Thenable; $openConnectionDialog(providers: string[], initialConnectionProfile?: sqlops.IConnectionProfile, connectionCompletionOptions?: sqlops.IConnectionCompletionOptions): Thenable; $listDatabases(connectionId: string): Thenable; $getConnectionString(connectionId: string, includePassword: boolean): Thenable; @@ -717,6 +723,7 @@ export interface MainThreadObjectExplorerShape extends IDisposable { $findNodes(connectionId: string, type: string, schema: string, name: string, database: string, parentObjectNames: string[]): Thenable; $refresh(connectionId: string, nodePath: string): Thenable; $getNodeActions(connectionId: string, nodePath: string): Thenable; + $getSessionConnectionProfile(sessionId: string): Thenable; } export interface ExtHostModelViewDialogShape { diff --git a/src/sqltest/parts/connection/objectExplorerService.test.ts b/src/sqltest/parts/connection/objectExplorerService.test.ts index a58fe43eb5..9067a9c529 100644 --- a/src/sqltest/parts/connection/objectExplorerService.test.ts +++ b/src/sqltest/parts/connection/objectExplorerService.test.ts @@ -8,7 +8,7 @@ import { ObjectExplorerProviderTestService } from 'sqltest/stubs/objectExplorerP import { TestConnectionManagementService } from 'sqltest/stubs/connectionManagementService.test'; import { ConnectionProfile } from 'sql/platform/connection/common/connectionProfile'; import { ConnectionProfileGroup } from 'sql/platform/connection/common/connectionProfileGroup'; -import { ObjectExplorerService } from 'sql/parts/objectExplorer/common/objectExplorerService'; +import { ObjectExplorerService, NodeExpandInfoWithProviderId } from 'sql/parts/objectExplorer/common/objectExplorerService'; import { NodeType } from 'sql/parts/objectExplorer/common/nodeType'; import { TreeNode, TreeItemCollapsibleState, ObjectExplorerCallbacks } from 'sql/parts/objectExplorer/common/treeNode'; @@ -32,12 +32,13 @@ suite('SQL Object Explorer Service tests', () => { let objectExplorerSession: sqlops.ObjectExplorerSession; let objectExplorerFailedSession: sqlops.ObjectExplorerSession; let objectExplorerCloseSessionResponse: sqlops.ObjectExplorerCloseSessionResponse; - let objectExplorerExpandInfo: sqlops.ObjectExplorerExpandInfo; - let objectExplorerExpandInfoRefresh: sqlops.ObjectExplorerExpandInfo; + let objectExplorerExpandInfo: NodeExpandInfoWithProviderId; + let objectExplorerExpandInfoRefresh: NodeExpandInfoWithProviderId; let sessionId = '1234'; let failedSessionId = '12345'; let numberOfFailedSession: number = 0; let serverTreeView: TypeMoq.Mock; + const providerId = 'MSSQL'; setup(() => { @@ -105,14 +106,16 @@ suite('SQL Object Explorer Service tests', () => { sessionId: sessionId, nodes: [NodeInfoTable1, NodeInfoTable2], errorMessage: '', - nodePath: objectExplorerSession.rootNode.nodePath + nodePath: objectExplorerSession.rootNode.nodePath, + providerId: providerId }; objectExplorerExpandInfoRefresh = { sessionId: sessionId, nodes: [NodeInfoTable1, NodeInfoTable3], errorMessage: '', - nodePath: objectExplorerSession.rootNode.nodePath + nodePath: objectExplorerSession.rootNode.nodePath, + providerId: providerId }; let response: sqlops.ObjectExplorerSessionResponse = { sessionId: objectExplorerSession.sessionId @@ -126,9 +129,8 @@ suite('SQL Object Explorer Service tests', () => { sqlOEProvider.callBase = true; let onCapabilitiesRegistered = new Emitter(); - let sqlProvider = { - providerId: 'MSSQL', + providerId: providerId, displayName: 'MSSQL', connectionOptions: [ { @@ -279,10 +281,10 @@ suite('SQL Object Explorer Service tests', () => { resolve(failedResponse); })); sqlOEProvider.setup(x => x.expandNode(TypeMoq.It.isAny())).callback(() => { - objectExplorerService.onNodeExpanded(1, objectExplorerExpandInfo); + objectExplorerService.onNodeExpanded(objectExplorerExpandInfo); }).returns(() => TPromise.as(true)); sqlOEProvider.setup(x => x.refreshNode(TypeMoq.It.isAny())).callback(() => { - objectExplorerService.onNodeExpanded(1, objectExplorerExpandInfoRefresh); + objectExplorerService.onNodeExpanded(objectExplorerExpandInfoRefresh); }).returns(() => TPromise.as(true)); sqlOEProvider.setup(x => x.closeSession(TypeMoq.It.isAny())).returns(() => TPromise.as(objectExplorerCloseSessionResponse)); @@ -539,7 +541,8 @@ suite('SQL Object Explorer Service tests', () => { sessionId: sessionId, nodes: [], errorMessage: '', - nodePath: table1NodePath + nodePath: table1NodePath, + providerId: providerId }; serverTreeView.setup(x => x.isExpanded(TypeMoq.It.isAny())).returns(treeNode => { return treeNode === connection || treeNode.nodePath === table1NodePath; @@ -549,7 +552,7 @@ suite('SQL Object Explorer Service tests', () => { objectExplorerService.onSessionCreated(1, objectExplorerSession); objectExplorerService.resolveTreeNodeChildren(objectExplorerSession, objectExplorerService.getObjectExplorerNode(connection)).then(childNodes => { sqlOEProvider.setup(x => x.expandNode(TypeMoq.It.isAny())).callback(() => { - objectExplorerService.onNodeExpanded(1, tableExpandInfo); + objectExplorerService.onNodeExpanded(tableExpandInfo); }).returns(() => TPromise.as(true)); let tableNode = childNodes.find(node => node.nodePath === table1NodePath); objectExplorerService.resolveTreeNodeChildren(objectExplorerSession, tableNode).then(() => { @@ -596,7 +599,8 @@ suite('SQL Object Explorer Service tests', () => { sessionId: sessionId, nodes: [], errorMessage: '', - nodePath: table1NodePath + nodePath: table1NodePath, + providerId: providerId }; serverTreeView.setup(x => x.isExpanded(TypeMoq.It.isAny())).returns(treeNode => { return treeNode.nodePath === table1NodePath; @@ -606,7 +610,7 @@ suite('SQL Object Explorer Service tests', () => { objectExplorerService.onSessionCreated(1, objectExplorerSession); objectExplorerService.resolveTreeNodeChildren(objectExplorerSession, objectExplorerService.getObjectExplorerNode(connection)).then(childNodes => { sqlOEProvider.setup(x => x.expandNode(TypeMoq.It.isAny())).callback(() => { - objectExplorerService.onNodeExpanded(1, tableExpandInfo); + objectExplorerService.onNodeExpanded(tableExpandInfo); }).returns(() => TPromise.as(true)); objectExplorerService.resolveTreeNodeChildren(objectExplorerSession, childNodes.find(node => node.nodePath === table1NodePath)).then(() => { // If I check whether the table is expanded, the answer should be yes @@ -630,11 +634,12 @@ suite('SQL Object Explorer Service tests', () => { sessionId: sessionId, nodes: [], errorMessage: '', - nodePath: table1NodePath + nodePath: table1NodePath, + providerId: providerId }; // Set up the OE provider so that the second expand call expands the table sqlOEProvider.setup(x => x.expandNode(TypeMoq.It.is(nodeInfo => nodeInfo.nodePath === table1NodePath))).callback(() => { - objectExplorerService.onNodeExpanded(1, tableExpandInfo); + objectExplorerService.onNodeExpanded(tableExpandInfo); }).returns(() => TPromise.as(true)); serverTreeView.setup(x => x.setExpandedState(TypeMoq.It.isAny(), TypeMoq.It.is(state => state === TreeItemCollapsibleState.Expanded))).returns(treeNode => { if (treeNode instanceof ConnectionProfile) { diff --git a/src/sqltest/parts/dashboard/widgets/propertiesWidget.component.test.ts b/src/sqltest/parts/dashboard/widgets/propertiesWidget.component.test.ts index 75ea600a32..baaea659f8 100644 --- a/src/sqltest/parts/dashboard/widgets/propertiesWidget.component.test.ts +++ b/src/sqltest/parts/dashboard/widgets/propertiesWidget.component.test.ts @@ -58,6 +58,7 @@ suite('Dashboard Properties Widget Tests', () => { serverEdition: undefined, azureVersion: undefined, osVersion: undefined, + options: {}, }; let databaseInfo = { diff --git a/src/sqltest/stubs/connectionManagementService.test.ts b/src/sqltest/stubs/connectionManagementService.test.ts index 2b0fa2619c..2f70c3cfa3 100644 --- a/src/sqltest/stubs/connectionManagementService.test.ts +++ b/src/sqltest/stubs/connectionManagementService.test.ts @@ -254,6 +254,10 @@ export class TestConnectionManagementService implements IConnectionManagementSer return undefined; } + getServerInfo(profileId: string): sqlops.ServerInfo { + return undefined; + } + getConnectionString(connectionId: string): Thenable { return undefined; }