diff --git a/extensions/admin-tool-ext-win/package.json b/extensions/admin-tool-ext-win/package.json index 389bf23b23..6cdaf2a3fa 100644 --- a/extensions/admin-tool-ext-win/package.json +++ b/extensions/admin-tool-ext-win/package.json @@ -1,61 +1,75 @@ { - "name": "admin-tool-ext-win", - "displayName": "Database Admin Tool Extensions for Windows", - "description": "Adds additional Windows-specific functionality to Azure Data Studio", - "version": "0.0.1", - "publisher": "Microsoft", - "preview": true, - "license": "https://raw.githubusercontent.com/Microsoft/azuredatastudio/master/LICENSE.txt", - "icon": "images/sqlserver.png", - "aiKey": "AIF-5574968e-856d-40d2-af67-c89a14e76412", - "engines": { - "vscode": "^1.30.1", - "sqlops": "*" - }, - "activationEvents": [ - "*" - ], - "main": "./out/main", - "repository": { - "type": "git", - "url": "https://github.com/Microsoft/azuredatastudio.git" - }, - "extensionDependencies": [ - "Microsoft.mssql" - ], - "contributes": { - "commands": [ - { - "command": "adminToolExtWin.launchSsmsServerPropertiesDialog", - "title": "%adminToolExtWin.launchSsmsServerPropertiesDialog%", - "category": "AdminToolExtWin" - } - ], - "menus": { - "commandPalette": [ - { - "command": "adminToolExtWin.launchSsmsServerPropertiesDialog", - "when": "false" - } - ], - "objectExplorer/item/context": [ - { - "command": "adminToolExtWin.launchSsmsServerPropertiesDialog", - "when": "isWindows && connectionProvider == MSSQL && nodeType && nodeType == Server", - "group": "AdminToolExtWin" - } - ] - }, - "outputChannels": [ - "admin-tool-ext-win" - ] - }, - "dependencies": { - "service-downloader": "github:anthonydresser/service-downloader#0.1.5", - "vscode-extension-telemetry": "^0.0.15", - "vscode-nls": "^3.2.1" - }, - "devDependencies": { - "vscode": "1.0.1" - } -} + "name": "admin-tool-ext-win", + "displayName": "%adminToolExtWin.displayName%", + "description": "%adminToolExtWin.description%", + "version": "0.0.1", + "publisher": "Microsoft", + "preview": true, + "license": "https://raw.githubusercontent.com/Microsoft/azuredatastudio/master/LICENSE.txt", + "icon": "images/sqlserver.png", + "aiKey": "AIF-5574968e-856d-40d2-af67-c89a14e76412", + "engines": { + "vscode": "^1.30.1", + "sqlops": "*" + }, + "activationEvents": [ + "*" + ], + "main": "./out/main", + "repository": { + "type": "git", + "url": "https://github.com/Microsoft/azuredatastudio.git" + }, + "extensionDependencies": [ + "Microsoft.mssql" + ], + "contributes": { + "commands": [ + { + "command": "adminToolExtWin.launchSsmsMinPropertiesDialog", + "title": "%adminToolExtWin.propertiesMenuItem%", + "category": "AdminToolExtWin" + }, + { + "command": "adminToolExtWin.launchSsmsMinGswDialog", + "title": "%adminToolExtWin.launchGswMenuItem%", + "category": "AdminToolExtWin" + } + ], + "menus": { + "commandPalette": [ + { + "command": "adminToolExtWin.launchSsmsMinPropertiesDialog", + "when": "false" + }, + { + "command": "adminToolExtWin.launchSsmsMinGswDialog", + "when": "false" + } + ], + "objectExplorer/item/context": [ + { + "command": "adminToolExtWin.launchSsmsMinGswDialog", + "when": "isWindows && connectionProvider == MSSQL && nodeType && nodeType == Database", + "group": "z-AdminToolExt@1" + }, + { + "command": "adminToolExtWin.launchSsmsMinPropertiesDialog", + "when": "isWindows && connectionProvider == MSSQL && nodeType && nodeType =~ /^(Server|Database|Table|Column|Index|Statistic|View|ServerLevelLogin|ServerLevelServerRole|ServerLevelCredential|ServerLevelServerAudit|ServerLevelServerAuditSpecification|StoredProcedure|ScalarValuedFunction|TableValuedFunction|AggregateFunction|Synonym|Assembly|UserDefinedDataType|UserDefinedType|UserDefinedTableType|Sequence|User|DatabaseRole|ApplicationRole|Schema|SecurityPolicy|ServerLevelLinkedServer)$/", + "group": "z-AdminToolExt@2" + } + ] + }, + "outputChannels": [ + "admin-tool-ext-win" + ] + }, + "dependencies": { + "service-downloader": "github:anthonydresser/service-downloader#0.1.5", + "vscode-extension-telemetry": "^0.0.15", + "vscode-nls": "^3.2.1" + }, + "devDependencies": { + "vscode": "1.0.1" + } +} \ No newline at end of file diff --git a/extensions/admin-tool-ext-win/package.nls.json b/extensions/admin-tool-ext-win/package.nls.json index f64fa0e48e..18f0889122 100644 --- a/extensions/admin-tool-ext-win/package.nls.json +++ b/extensions/admin-tool-ext-win/package.nls.json @@ -1,3 +1,6 @@ { - "adminToolExtWin.launchSsmsServerPropertiesDialog": "Properties" + "adminToolExtWin.displayName": "Database Administration Tool Extensions for Windows", + "adminToolExtWin.description": "Adds additional Windows-specific functionality to Azure Data Studio", + "adminToolExtWin.propertiesMenuItem": "Properties", + "adminToolExtWin.launchGswMenuItem": "Generate Scripts..." } \ No newline at end of file diff --git a/extensions/admin-tool-ext-win/src/main.ts b/extensions/admin-tool-ext-win/src/main.ts index 05f219eaaf..d277a09435 100644 --- a/extensions/admin-tool-ext-win/src/main.ts +++ b/extensions/admin-tool-ext-win/src/main.ts @@ -9,7 +9,7 @@ import * as azdata from 'azdata'; import * as vscode from 'vscode'; import { IConfig, ServerProvider } from 'service-downloader'; import { Telemetry } from './telemetry'; -import * as utils from './utils'; +import { doubleEscapeSingleQuotes, backEscapeDoubleQuotes, getConfiguration } from './utils'; import { ChildProcess, exec } from 'child_process'; const baseConfig = require('./config.json'); @@ -17,6 +17,43 @@ const localize = nls.loadMessageBundle(); let exePath: string; let runningProcesses: Map = new Map(); + +interface SmoMapping { + action: string; + urnName: string; +} + +const nodeTypeToUrnNameMapping: { [oeNodeType: string]: SmoMapping } = { + 'Database': { action: 'sqla:Properties@Microsoft.SqlServer.Management.Smo.Database', urnName: 'Database' }, + 'Server': { action: 'sqla:Properties@Microsoft.SqlServer.Management.Smo.Server', urnName: 'Server' }, + 'ServerLevelServerAudit': { action: 'sqla:AuditProperties', urnName: 'Audit' }, + 'ServerLevelCredential': { action: 'sqla:Properties@Microsoft.SqlServer.Management.Smo.Credential', urnName: 'Credential' }, + 'ServerLevelServerRole': { action: 'sqla:ManageServerRole', urnName: 'Role' }, + 'ServerLevelServerAuditSpecification': { action: 'sqla:ServerAuditSpecificationProperties', urnName: 'ServerAuditSpecification' }, + 'ServerLevelLinkedServer': { action: 'sqla:Properties@Microsoft.SqlServer.Management.Smo.LinkedServer', urnName: 'LinkedServer' }, + 'Table': { action: 'sqla:Properties@Microsoft.SqlServer.Management.Smo.Table', urnName: 'Table' }, + 'View': { action: 'sqla:Properties@Microsoft.SqlServer.Management.Smo.View', urnName: 'View' }, + 'Column': { action: 'sqla:Properties@Microsoft.SqlServer.Management.Smo.Column', urnName: 'Column' }, + 'Index': { action: 'sqla:IndexProperties', urnName: 'Index' }, + 'Statistic': { action: 'sqla:Properties@Microsoft.SqlServer.Management.Smo.Statistic', urnName: 'Statistic' }, + 'StoredProcedure': { action: 'sqla:Properties@Microsoft.SqlServer.Management.Smo.StoredProcedure', urnName: 'StoredProcedure' }, + 'ScalarValuedFunction': { action: 'sqla:Properties@Microsoft.SqlServer.Management.Smo.UserDefinedFunction', urnName: 'UserDefinedFunction' }, + 'TableValuedFunction': { action: 'sqla:Properties@Microsoft.SqlServer.Management.Smo.UserDefinedFunction', urnName: 'UserDefinedFunction' }, + 'AggregateFunction': { action: 'sqla:Properties@Microsoft.SqlServer.Management.Smo.UserDefinedFunction', urnName: 'UserDefinedFunction' }, + 'Synonym': { action: 'sqla:Properties@Microsoft.SqlServer.Management.Smo.Synonym', urnName: 'Synonym' }, + 'Assembly': { action: 'sqla:Properties@Microsoft.SqlServer.Management.Smo.SqlAssembly', urnName: 'SqlAssembly' }, + 'UserDefinedDataType': { action: 'sqla:Properties@Microsoft.SqlServer.Management.Smo.UserDefinedDataType', urnName: 'UserDefinedDataType' }, + 'UserDefinedType': { action: 'sqla:Properties@Microsoft.SqlServer.Management.Smo.UserDefinedType', urnName: 'UserDefinedType' }, + 'UserDefinedTableType': { action: 'sqla:Properties@Microsoft.SqlServer.Management.Smo.UserDefinedTableType', urnName: 'UserDefinedTableType' }, + 'Sequence': { action: 'sqla:SequenceProperties', urnName: 'Sequence' }, + 'User': { action: 'sqla:Properties@Microsoft.SqlServer.Management.Smo.User', urnName: 'User' }, + 'DatabaseRole': { action: 'sqla:Properties@Microsoft.SqlServer.Management.Smo.DatabaseRole', urnName: 'Role' }, + 'ApplicationRole': { action: 'sqla:Properties@Microsoft.SqlServer.Management.Smo.ApplicationRole', urnName: 'ApplicationRole' }, + 'Schema': { action: 'sqla:Properties@Microsoft.SqlServer.Management.Smo.Schema', urnName: 'Schema' }, + 'SecurityPolicy': { action: 'sqla:Properties@Microsoft.SqlServer.Management.Smo.SecurityPolicy', urnName: 'SecurityPolicy' }, + 'ServerLevelLogin': { action: 'sqla:Properties@Microsoft.SqlServer.Management.Smo.Login', urnName: 'Login' }, +}; + // Params to pass to SsmsMin.exe, only an action and server are required - the rest are optional based on the // action used. Exported for use in testing. export interface LaunchSsmsDialogParams { @@ -36,8 +73,8 @@ export async function activate(context: vscode.ExtensionContext): Promise let config: IConfig = JSON.parse(JSON.stringify(baseConfig)); config.installDirectory = path.join(context.extensionPath, config.installDirectory); - config.proxy = utils.getConfiguration('http').get('proxy'); - config.strictSSL = utils.getConfiguration('http').get('proxyStrictSSL') || true; + config.proxy = getConfiguration('http').get('proxy'); + config.strictSSL = getConfiguration('http').get('proxyStrictSSL') || true; const serverdownloader = new ServerProvider(config); const installationStart = Date.now(); @@ -52,9 +89,9 @@ export async function activate(context: vscode.ExtensionContext): Promise } else { throw new Error('Could not find SsmsMin.exe after downloading'); } - // Add the command now that we have the exePath to run the tool with - context.subscriptions.push( - vscode.commands.registerCommand('adminToolExtWin.launchSsmsServerPropertiesDialog', handleLaunchSsmsServerPropertiesDialogCommand)); + + // Register the commands now that we have the exePath to run the tool with + registerCommands(context); Telemetry.sendTelemetryEvent('startup/ExtensionStarted', { installationTime: String(installationComplete - installationStart), @@ -75,16 +112,52 @@ export async function deactivate(): Promise { runningProcesses.forEach(p => exec('taskkill /pid ' + p.pid + ' /T /F')); } +/** + * Registers extension commands with command subsystem + * @param context The context used to register the commands + */ +function registerCommands(context: vscode.ExtensionContext): void { + context.subscriptions.push( + vscode.commands.registerCommand('adminToolExtWin.launchSsmsMinPropertiesDialog', handleLaunchSsmsMinPropertiesDialogCommand)); + context.subscriptions.push( + vscode.commands.registerCommand('adminToolExtWin.launchSsmsMinGswDialog', handleLaunchSsmsMinGswDialogCommand)); +} + /** * Handler for command to launch SSMS Server Properties dialog * @param connectionId The connection context from the command */ -function handleLaunchSsmsServerPropertiesDialogCommand(connectionContext?: azdata.ObjectExplorerContext) { - if (connectionContext && connectionContext.connectionProfile) { - launchSsmsDialog( - /*action*/'sqla:Properties@Microsoft.SqlServer.Management.Smo.Server', - /*connectionProfile*/connectionContext.connectionProfile); +async function handleLaunchSsmsMinPropertiesDialogCommand(connectionContext?: azdata.ObjectExplorerContext): Promise { + if (!connectionContext) { + console.error('No ConnectionContext provided for handleLaunchSsmsMinPropertiesDialogCommand'); + return; } + + let nodeType: string; + if (connectionContext.isConnectionNode) { + nodeType = 'Server'; + } + else if (connectionContext.nodeInfo) { + nodeType = connectionContext.nodeInfo.nodeType; + } + else { + console.error(`Could not determine NodeType for handleLaunchSsmsMinPropertiesDialogCommand with context ${connectionContext}`); + return; + } + + launchSsmsDialog( + nodeTypeToUrnNameMapping[nodeType].action, + connectionContext); +} + +/** + * Handler for command to launch SSMS "Generate Script Wizard" dialog + * @param connectionId The connection context from the command + */ +async function handleLaunchSsmsMinGswDialogCommand(connectionContext?: azdata.ObjectExplorerContext): Promise { + launchSsmsDialog( + 'GenerateScripts', + connectionContext); } /** @@ -93,29 +166,49 @@ function handleLaunchSsmsServerPropertiesDialogCommand(connectionContext?: azdat * @param params The params used to construct the command * @param urn The URN to pass to SsmsMin */ -function launchSsmsDialog(action: string, connectionProfile: azdata.IConnectionProfile, urn?: string) { +async function launchSsmsDialog(action: string, connectionContext: azdata.ObjectExplorerContext): Promise { if (!exePath) { vscode.window.showErrorMessage(localize('adminToolExtWin.noExeError', 'Unable to find SsmsMin.exe.')); return; } + if (!connectionContext.connectionProfile) { + console.error(`No ConnectionProfile provided from connectionContext : ${connectionContext}`); + return; + } + + let oeNode: azdata.objectexplorer.ObjectExplorerNode; + // Server node is a Connection node and so doesn't have the NodeInfo + if (connectionContext.isConnectionNode) { + oeNode = undefined; + } + else if (connectionContext.nodeInfo && connectionContext.nodeInfo.nodeType && connectionContext.connectionProfile) { + oeNode = await azdata.objectexplorer.getNode(connectionContext.connectionProfile.id, connectionContext.nodeInfo.nodePath); + } + else { + console.error(`Could not determine Object Explorer node from connectionContext : ${connectionContext}`); + return; + } + + let urn: string = await buildUrn(connectionContext.connectionProfile.serverName, oeNode); + Telemetry.sendTelemetryEvent('LaunchSsmsDialog', { 'action': action }); let params: LaunchSsmsDialogParams = { action: action, - server: connectionProfile.serverName, - database: connectionProfile.databaseName, - password: connectionProfile.password, - user: connectionProfile.userName, - useAad: connectionProfile.authenticationType === 'AzureMFA', + server: connectionContext.connectionProfile.serverName, + database: connectionContext.connectionProfile.databaseName, + password: connectionContext.connectionProfile.password, + user: connectionContext.connectionProfile.userName, + useAad: connectionContext.connectionProfile.authenticationType === 'AzureMFA', urn: urn }; let args = buildSsmsMinCommandArgs(params); // This will be an async call since we pass in the callback let proc: ChildProcess = exec( - /*command*/`"${exePath}" ${args}`, - /*options*/undefined, + /*command*/ `"${exePath}" ${args}`, + /*options*/ undefined, (execException, stdout, stderr) => { // Process has exited so remove from map of running processes runningProcesses.delete(proc.pid); @@ -145,10 +238,34 @@ function launchSsmsDialog(action: string, connectionProfile: azdata.IConnectionP * @param params The params used to build up the command parameter string */ export function buildSsmsMinCommandArgs(params: LaunchSsmsDialogParams): string { - return `${params.action ? '-a "' + params.action.replace(/"/g, '\\"') + '"' : ''}\ -${params.server ? ' -S "' + params.server.replace(/"/g, '\\"') + '"' : ''}\ -${params.database ? ' -D "' + params.database.replace(/"/g, '\\"') + '"' : ''}\ -${params.useAad !== true && params.user ? ' -U "' + params.user.replace(/"/g, '\\"') + '"' : ''}\ + return `${params.action ? '-a "' + backEscapeDoubleQuotes(params.action) + '"' : ''}\ +${params.server ? ' -S "' + backEscapeDoubleQuotes(params.server) + '"' : ''}\ +${params.database ? ' -D "' + backEscapeDoubleQuotes(params.database) + '"' : ''}\ +${params.useAad !== true && params.user ? ' -U "' + backEscapeDoubleQuotes(params.user) + '"' : ''}\ ${params.useAad === true ? ' -G' : ''}\ -${params.urn ? ' -u "' + params.urn.replace(/"/g, '\\"') + '"' : ''}`; +${params.urn ? ' -u "' + backEscapeDoubleQuotes(params.urn) + '"' : ''}`; } + +/** + * Builds the URN string for a given ObjectExplorerNode in the form understood by SsmsMin + * @param serverName The name of the Server to use for the Server segment + * @param node The node to get the URN of + */ +export async function buildUrn(serverName: string, node: azdata.objectexplorer.ObjectExplorerNode): Promise { + let urnNodes: string[] = []; + while (node) { + // Server is special since it's a connection node - always add it as the root + if (node.nodeType === 'Server') { + break; + } + else if (node.metadata && node.nodeType !== 'Folder') { + // SFC URN expects Name and Schema to be separate properties + let urnSegment = node.metadata.schema && node.metadata.schema !== '' ? + `${nodeTypeToUrnNameMapping[node.nodeType].urnName}[@Name='${doubleEscapeSingleQuotes(node.metadata.name)}' and @Schema='${doubleEscapeSingleQuotes(node.metadata.schema)}']` : + `${nodeTypeToUrnNameMapping[node.nodeType].urnName}[@Name='${doubleEscapeSingleQuotes(node.metadata.name)}']`; + urnNodes = [urnSegment].concat(urnNodes); + } + node = await node.getParent(); + } + return [`Server[@Name='${doubleEscapeSingleQuotes(serverName)}']`].concat(urnNodes).join('/'); +} \ No newline at end of file diff --git a/extensions/admin-tool-ext-win/src/test/stubs.ts b/extensions/admin-tool-ext-win/src/test/stubs.ts new file mode 100644 index 0000000000..b0aeecc0ae --- /dev/null +++ b/extensions/admin-tool-ext-win/src/test/stubs.ts @@ -0,0 +1,68 @@ +/*--------------------------------------------------------------------------------------------- + * 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 azdata from 'azdata'; +import * as vscode from 'vscode'; + +/** + * Helper stub class for mocking ExtHostObjectExplorerNode + */ +export class ExtHostObjectExplorerNodeStub implements azdata.objectexplorer.ObjectExplorerNode { + // Stub properties + private parent: azdata.objectexplorer.ObjectExplorerNode; + + // Base properties + public connectionId: string; + public nodePath: string; + public nodeType: string; + public nodeSubType: string; + public nodeStatus: string; + public label: string; + public isLeaf: boolean; + public metadata: azdata.ObjectMetadata; + public errorMessage: string; + + constructor(nodeName: string, nodeSchema: string, nodeType, parent: azdata.objectexplorer.ObjectExplorerNode) { + this.parent = parent; + this.nodeType = nodeType; + this.metadata = { metadataType: undefined, metadataTypeName: undefined, name: nodeName, schema: nodeSchema, urn: undefined }; + } + + isExpanded(): Thenable { + throw new Error('Method not implemented'); + } + + setExpandedState(expandedState: vscode.TreeItemCollapsibleState): Thenable { + throw new Error('Method not implemented'); + } + + setSelected(selected: boolean, clearOtherSelections: boolean = undefined): Thenable { + throw new Error('Method not implemented'); + } + + getChildren(): Thenable { + throw new Error('Method not implemented'); + } + + getParent(): Thenable { + return Promise.resolve(this.parent); + } + + refresh(): Thenable { + throw new Error('Method not implemented'); + } + + /** + * + * @param nodeName Helperfunction to create a node that is a child of this one + * @param nodeSchema The schema to give the child node + * @param nodeType The type of node this should be + */ + createChild(nodeName: string, nodeSchema: string, nodeType: string): ExtHostObjectExplorerNodeStub { + return new ExtHostObjectExplorerNodeStub(nodeName, nodeSchema, nodeType, this); + } +} \ No newline at end of file diff --git a/extensions/admin-tool-ext-win/src/test/utils.test.ts b/extensions/admin-tool-ext-win/src/test/utils.test.ts index 51b5fdc0f9..2f086aa884 100644 --- a/extensions/admin-tool-ext-win/src/test/utils.test.ts +++ b/extensions/admin-tool-ext-win/src/test/utils.test.ts @@ -8,11 +8,13 @@ import * as should from 'should'; import 'mocha'; -import * as extensionMain from '../main'; +import { buildSsmsMinCommandArgs, buildUrn, LaunchSsmsDialogParams } from '../main'; +import { doubleEscapeSingleQuotes, backEscapeDoubleQuotes } from '../utils'; +import { ExtHostObjectExplorerNodeStub } from './stubs'; describe('buildSsmsMinCommandArgs Method Tests', () => { it('Should be built correctly with all params and UseAAD as false', function (): void { - let params: extensionMain.LaunchSsmsDialogParams = { + const params: LaunchSsmsDialogParams = { action: 'myAction', server: 'myServer', database: 'myDatabase', @@ -21,12 +23,12 @@ describe('buildSsmsMinCommandArgs Method Tests', () => { useAad: false, urn: 'Server\\Database\\Table' }; - let args = extensionMain.buildSsmsMinCommandArgs(params); + const args = buildSsmsMinCommandArgs(params); should(args).equal('-a "myAction" -S "myServer" -D "myDatabase" -U "user" -u "Server\\Database\\Table"'); }); it('Should be built correctly with all params and UseAAD as true', function (): void { - let params: extensionMain.LaunchSsmsDialogParams = { + const params: LaunchSsmsDialogParams = { action: 'myAction', server: 'myServer', database: 'myDatabase', @@ -35,13 +37,13 @@ describe('buildSsmsMinCommandArgs Method Tests', () => { useAad: true, urn: 'Server\\Database\\Table' }; - let args = extensionMain.buildSsmsMinCommandArgs(params); + const args = buildSsmsMinCommandArgs(params); // User is omitted since UseAAD is true should(args).equal('-a "myAction" -S "myServer" -D "myDatabase" -G -u "Server\\Database\\Table"'); }); it('Should be built correctly and names escaped correctly', function (): void { - let params: extensionMain.LaunchSsmsDialogParams = { + const params: LaunchSsmsDialogParams = { action: 'myAction\'"/\\[]tricky', server: 'myServer\'"/\\[]tricky', database: 'myDatabase\'"/\\[]tricky', @@ -50,18 +52,87 @@ describe('buildSsmsMinCommandArgs Method Tests', () => { useAad: true, urn: 'Server\\Database[\'myDatabase\'\'"/\\[]tricky\']\\Table["myTable\'""/\\[]tricky"]' }; - let args = extensionMain.buildSsmsMinCommandArgs(params); + const args = buildSsmsMinCommandArgs(params); // User is omitted since UseAAD is true should(args).equal('-a "myAction\'\\"/\\[]tricky" -S "myServer\'\\"/\\[]tricky" -D "myDatabase\'\\"/\\[]tricky" -G -u "Server\\Database[\'myDatabase\'\'\\"/\\[]tricky\']\\Table[\\"myTable\'\\"\\"/\\[]tricky\\"]"'); }); it('Should be built correctly with only action and server', function (): void { - let params: extensionMain.LaunchSsmsDialogParams = { + const params: LaunchSsmsDialogParams = { action: 'myAction', server: 'myServer' }; - let args = extensionMain.buildSsmsMinCommandArgs(params); + const args = buildSsmsMinCommandArgs(params); should(args).equal('-a "myAction" -S "myServer"'); }); }); + +const serverName = 'My\'Server'; +const escapedServerName = doubleEscapeSingleQuotes(serverName); +const dbName = 'My\'Db'; +const escapedDbName = doubleEscapeSingleQuotes(dbName); +const dbSchema = 'db\'sch'; +const escapedDbSchema = doubleEscapeSingleQuotes(dbSchema); +const tableName = 'My\'Table'; +const escapedTableName = doubleEscapeSingleQuotes(tableName); +const tableSchema = 'tbl\'sch'; +const escapedTableSchema = doubleEscapeSingleQuotes(tableSchema); + +describe('buildUrn Method Tests', () => { + it('Urn should be correct with just server', async function (): Promise { + should(await buildUrn(serverName, undefined)).equal(`Server[@Name=\'${escapedServerName}\']`); + }); + + it('Urn should be correct with Server and only Databases folder', async function (): Promise { + const leafNode: ExtHostObjectExplorerNodeStub = + new ExtHostObjectExplorerNodeStub('Databases', undefined, 'Folder', undefined); + should(await buildUrn(serverName, leafNode)).equal(`Server[@Name='${escapedServerName}']`); + }); + + it('Urn should be correct with Server and Database node', async function (): Promise { + const leafNode: ExtHostObjectExplorerNodeStub = + new ExtHostObjectExplorerNodeStub('Databases', undefined, 'Folder', undefined) + .createChild(dbName, dbSchema, 'Database'); + should(await buildUrn(serverName, leafNode)).equal( + `Server[@Name='${escapedServerName}']/Database[@Name='${escapedDbName}' and @Schema='${escapedDbSchema}']`); + }); + + it('Urn should be correct with Multiple levels of Nodes', async function (): Promise { + const rootNode: ExtHostObjectExplorerNodeStub = + new ExtHostObjectExplorerNodeStub('Databases', undefined, 'Folder', undefined) + .createChild(dbName, dbSchema, 'Database') + .createChild('Tables', undefined, 'Folder') + .createChild(tableName, tableSchema, 'Table'); + should(await buildUrn(serverName, rootNode)).equal( + `Server[@Name='${escapedServerName}']/Database[@Name='${escapedDbName}' and @Schema='${escapedDbSchema}']/Table[@Name='${escapedTableName}' and @Schema='${escapedTableSchema}']`); + }); +}); + +describe('doubleEscapeSingleQuotes Method Tests', () => { + it('Should return original string if no single quotes', function (): void { + const testString: string = 'MyTestString'; + const ret = doubleEscapeSingleQuotes(testString); + should(ret).equal(testString); + }); + + it('Should return escaped original string if it contains single quotes', function (): void { + const testString: string = 'MyTestString\'\'WithQuotes'; + const ret = doubleEscapeSingleQuotes(testString); + should(ret).equal('MyTestString\'\'\'\'WithQuotes'); + }); +}); + +describe('backEscapeDoubleQuotes Method Tests', () => { + it('Should return original string if no double quotes', function (): void { + const testString: string = 'MyTestString'; + const ret = backEscapeDoubleQuotes(testString); + should(ret).equal(testString); + }); + + it('Should return escaped original string if it contains double quotes', function (): void { + const testString: string = 'MyTestString\"\"WithQuotes'; + const ret = backEscapeDoubleQuotes(testString); + should(ret).equal('MyTestString\\"\\"WithQuotes'); + }); +}); diff --git a/extensions/admin-tool-ext-win/src/utils.ts b/extensions/admin-tool-ext-win/src/utils.ts index 5d0497763a..de896de80b 100644 --- a/extensions/admin-tool-ext-win/src/utils.ts +++ b/extensions/admin-tool-ext-win/src/utils.ts @@ -40,3 +40,21 @@ export function getConfiguration(extensionName?: string, resource?: vscode.Uri | } return vscode.workspace.getConfiguration(extensionName, resource as vscode.Uri); } + +/** + * Escapes all single-quotes (') by prefixing them with another single quote ('') + * ' => '' + * @param value The string to escape + */ +export function doubleEscapeSingleQuotes(value: string): string { + return value.replace(/'/g, '\'\''); +} + +/** + * Escape all double-quotes (") by prefixing them with a \ + * " => \" + * @param value The string to escape + */ +export function backEscapeDoubleQuotes(value: string): string { + return value.replace(/"/g, '\\"'); +} \ No newline at end of file diff --git a/src/sql/workbench/api/node/extHostObjectExplorer.ts b/src/sql/workbench/api/node/extHostObjectExplorer.ts index 2a49d281a8..9c57ebf418 100644 --- a/src/sql/workbench/api/node/extHostObjectExplorer.ts +++ b/src/sql/workbench/api/node/extHostObjectExplorer.ts @@ -8,7 +8,6 @@ import { IMainContext } from 'vs/workbench/api/common/extHost.protocol'; import { ExtHostObjectExplorerShape, SqlMainContext, MainThreadObjectExplorerShape } from 'sql/workbench/api/node/sqlExtHost.protocol'; import * as azdata from 'azdata'; import * as vscode from 'vscode'; -import { entries } from 'sql/base/common/objects'; export class ExtHostObjectExplorer implements ExtHostObjectExplorerShape { @@ -41,7 +40,7 @@ export class ExtHostObjectExplorer implements ExtHostObjectExplorerShape { } } -class ExtHostObjectExplorerNode implements azdata.objectexplorer.ObjectExplorerNode { +export class ExtHostObjectExplorerNode implements azdata.objectexplorer.ObjectExplorerNode { public connectionId: string; public nodePath: string; public nodeType: string; @@ -74,11 +73,21 @@ class ExtHostObjectExplorerNode implements azdata.objectexplorer.ObjectExplorerN } getParent(): Thenable { - let parentPathEndIndex = this.nodePath.lastIndexOf('/'); - if (parentPathEndIndex === -1) { - return Promise.resolve(undefined); + // Object nodes have a name like . in the nodePath - we can't use label because + // that may have additional display information appended to it. Items without metadata are nodes + // such as folders that don't correspond to actual objects and so just use the label + let nodePathName = this.metadata ? + `${this.metadata.schema ? this.metadata.schema + '.' : ''}${this.metadata.name}` : + this.label; + + // -1 to remove the / as well + let parentPathEndIndex: number = this.nodePath.lastIndexOf(nodePathName) - 1; + if (parentPathEndIndex < 0) { + // At root node + Promise.resolve(undefined); } - return this._proxy.$getNode(this.connectionId, this.nodePath.slice(0, parentPathEndIndex)).then(nodeInfo => nodeInfo ? new ExtHostObjectExplorerNode(nodeInfo, this.connectionId, this._proxy) : undefined); + return this._proxy.$getNode(this.connectionId, this.nodePath.slice(0, parentPathEndIndex)).then( + nodeInfo => nodeInfo ? new ExtHostObjectExplorerNode(nodeInfo, this.connectionId, this._proxy) : undefined); } refresh(): Thenable { diff --git a/src/sqltest/workbench/api/extHostObjectExplorer.test.ts b/src/sqltest/workbench/api/extHostObjectExplorer.test.ts new file mode 100644 index 0000000000..f1f4ae12d3 --- /dev/null +++ b/src/sqltest/workbench/api/extHostObjectExplorer.test.ts @@ -0,0 +1,111 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as azdata from 'azdata'; +import * as assert from 'assert'; +import * as TypeMoq from 'typemoq'; + +import { MainThreadObjectExplorerShape } from 'sql/workbench/api/node/sqlExtHost.protocol'; +import { ExtHostObjectExplorerNode } from 'sql/workbench/api/node/extHostObjectExplorer'; + +const nodes: { [nodeName: string]: azdata.NodeInfo } = +{ + 'Server1': { + nodePath: 'MyServer', + nodeStatus: '', + nodeSubType: '', + nodeType: 'Server', + isLeaf: false, + label: 'MyServer', + metadata: undefined, + errorMessage: '' + }, + 'DatabasesFolder': { + nodePath: 'MyServer/Databases', + nodeStatus: '', + nodeSubType: '', + nodeType: 'Folder', + isLeaf: false, + label: 'Databases', + metadata: undefined, + errorMessage: '' + }, + 'Database1': { + nodePath: 'MyServer/Databases/MyDatabase', + nodeStatus: '', + nodeSubType: '', + nodeType: 'Database', + isLeaf: false, + label: 'MyDatabase', + metadata: undefined, + errorMessage: '' + }, + 'Database2': { + nodePath: 'MyServer/Databases/My/TrickyDatabase', + nodeStatus: '', + nodeSubType: '', + nodeType: 'Database', + isLeaf: false, + label: 'My/TrickyDatabase', + metadata: undefined, + errorMessage: '' + }, + 'TablesFolder': { + nodePath: 'MyServer/Databases/My/TrickyDatabase/Tables', + nodeStatus: '', + nodeSubType: '', + nodeType: 'Folder', + isLeaf: false, + label: 'Tables', + metadata: undefined, + errorMessage: '' + } +}; + +suite('ExtHostObjectExplorer Tests', () => { + let mockProxy: TypeMoq.Mock; + suiteSetup(() => { + mockProxy = TypeMoq.Mock.ofInstance({ + $getNode: (connectionId: string, nodePath?: string): Thenable => undefined + }); + + mockProxy.setup(p => + p.$getNode(TypeMoq.It.isAny(), TypeMoq.It.isAny())) + .returns((connectionId, nodePath) => { + return Promise.resolve(nodes[Object.keys(nodes).find(key => + nodes[key].nodePath === nodePath)]); + }); + }); + + suite('ExtHostObjectExplorerNode', () => { + let extHostObjectExplorerNode: ExtHostObjectExplorerNode; + suite('getParent', () => { + test('Should return undefined if no parent', async () => { + extHostObjectExplorerNode = new ExtHostObjectExplorerNode(nodes['Server1'], 'connectionId', mockProxy.object); + assert.equal(await extHostObjectExplorerNode.getParent(), undefined); + }); + + test('should return root with direct descendent of root', async () => { + extHostObjectExplorerNode = new ExtHostObjectExplorerNode(nodes['DatabasesFolder'], 'connectionId', mockProxy.object); + assert.equal((await extHostObjectExplorerNode.getParent()).nodePath, nodes['Server1'].nodePath); + }); + + test('should return correct parent with further descendent of root', async () => { + extHostObjectExplorerNode = new ExtHostObjectExplorerNode(nodes['Database1'], 'connectionId', mockProxy.object); + assert.equal((await extHostObjectExplorerNode.getParent()).nodePath, nodes['DatabasesFolder'].nodePath); + }); + + test('should return correct parent with node having / in its name', async () => { + extHostObjectExplorerNode = new ExtHostObjectExplorerNode(nodes['Database2'], 'connectionId', mockProxy.object); + assert.equal((await extHostObjectExplorerNode.getParent()).nodePath, nodes['DatabasesFolder'].nodePath); + }); + + test('should return correct parent with parent node having / in its name', async () => { + extHostObjectExplorerNode = new ExtHostObjectExplorerNode(nodes['TablesFolder'], 'connectionId', mockProxy.object); + assert.equal((await extHostObjectExplorerNode.getParent()).nodePath, nodes['Database2'].nodePath); + }); + }); + }); +});