diff --git a/extensions/sql-database-projects/src/common/constants.ts b/extensions/sql-database-projects/src/common/constants.ts index 36dcc60bfb..f47ac005d0 100644 --- a/extensions/sql-database-projects/src/common/constants.ts +++ b/extensions/sql-database-projects/src/common/constants.ts @@ -559,3 +559,9 @@ export function jsonParseError(error: string, line: number, column: number) { re export const moreInformation = localize('moreInformation', "More Information"); export const addPackageReferenceMessage = localize('addPackageReferenceMessage', 'To use SQL bindings, ensure your Azure Functions project has a reference to {0}', sqlExtensionPackageName); export const addSqlBindingPackageError = localize('addSqlBindingPackageError', 'Error adding Sql Binding extension package to project'); +export const failedToGetConnectionString = localize('failedToGetConnectionString', 'An error occurred generating the connection string for the selected connection'); +export const connectionProfile = localize('connectionProfile', 'Select a connection profile'); +export const userConnectionString = localize('userConnectionString', 'Enter connection string'); +export const selectConnectionString = localize('selectConnectionString', 'Select SQL connection string method'); +export const selectConnectionError = (err?: any) => err ? localize('selectConnectionError', "Failed to set connection string app setting: {0}", utils.getErrorMessage(err)) : localize('unableToSetConnectionString', "Failed to set connection string app setting"); + diff --git a/extensions/sql-database-projects/src/dialogs/addSqlBindingQuickpick.ts b/extensions/sql-database-projects/src/dialogs/addSqlBindingQuickpick.ts index d7d70bad5e..588db90f23 100644 --- a/extensions/sql-database-projects/src/dialogs/addSqlBindingQuickpick.ts +++ b/extensions/sql-database-projects/src/dialogs/addSqlBindingQuickpick.ts @@ -15,6 +15,8 @@ import { TelemetryActions, TelemetryReporter, TelemetryViews } from '../common/t export async function launchAddSqlBindingQuickpick(uri: vscode.Uri | undefined, packageHelper: PackageHelper): Promise { TelemetryReporter.sendActionEvent(TelemetryViews.SqlBindingsQuickPick, TelemetryActions.startAddSqlBinding); + const vscodeMssqlApi = await utils.getVscodeMssqlApi(); + if (!uri) { // this command only shows in the command palette when the active editor is a .cs file, so we can safely assume that's the scenario // when this is called without a uri @@ -113,16 +115,16 @@ export async function launchAddSqlBindingQuickpick(uri: vscode.Uri | undefined, return; } - let existingSettings: (vscode.QuickPickItem & { isCreateNew?: boolean })[] = []; + let existingSettings: (vscode.QuickPickItem)[] = []; if (settings?.Values) { existingSettings = Object.keys(settings.Values).map(setting => { return { label: setting - } as vscode.QuickPickItem & { isCreateNew?: boolean }; + } as vscode.QuickPickItem; }); } - existingSettings.unshift({ label: constants.createNewLocalAppSettingWithIcon, isCreateNew: true }); + existingSettings.unshift({ label: constants.createNewLocalAppSettingWithIcon }); let sqlConnectionStringSettingExists = existingSettings.find(s => s.label === constants.sqlConnectionStringSetting); while (!connectionStringSettingName) { @@ -136,7 +138,7 @@ export async function launchAddSqlBindingQuickpick(uri: vscode.Uri | undefined, return; } - if (selectedSetting.isCreateNew) { + if (selectedSetting.label === constants.createNewLocalAppSettingWithIcon) { const newConnectionStringSettingName = await vscode.window.showInputBox( { title: constants.enterConnectionStringSettingName, @@ -151,36 +153,78 @@ export async function launchAddSqlBindingQuickpick(uri: vscode.Uri | undefined, continue; } - const newConnectionStringValue = await vscode.window.showInputBox( - { - title: constants.enterConnectionString, - ignoreFocusOut: true, - value: 'Server=localhost;Initial Catalog={db_name};User ID=sa;Password={your_password};Persist Security Info=False', - validateInput: input => input ? undefined : constants.valueMustNotBeEmpty + // show the connection string methods (user input and connection profile options) + const listOfConnectionStringMethods = [constants.connectionProfile, constants.userConnectionString]; + while (true) { + const selectedConnectionStringMethod = await vscode.window.showQuickPick(listOfConnectionStringMethods, { + canPickMany: false, + title: constants.selectConnectionString, + ignoreFocusOut: true + }); + if (!selectedConnectionStringMethod) { + // User cancelled + return; } - ) ?? ''; - if (!newConnectionStringValue) { - // go back to select setting quickpick if user escapes from inputting the value in case they changed their mind - continue; - } - - try { - const success = await azureFunctionsUtils.setLocalAppSetting(path.dirname(projectUri.fsPath), newConnectionStringSettingName, newConnectionStringValue); - if (success) { - connectionStringSettingName = newConnectionStringSettingName; + let connectionString: string = ''; + if (selectedConnectionStringMethod === constants.userConnectionString) { + // User chooses to enter connection string manually + connectionString = await vscode.window.showInputBox( + { + title: constants.enterConnectionString, + ignoreFocusOut: true, + value: 'Server=localhost;Initial Catalog={db_name};User ID=sa;Password={your_password};Persist Security Info=False', + validateInput: input => input ? undefined : constants.valueMustNotBeEmpty + } + ) ?? ''; + } else { + // Let user choose from existing connections to create connection string from + let connectionUri: string = ''; + const connectionInfo = await vscodeMssqlApi.promptForConnection(true); + if (!connectionInfo) { + // User cancelled return to selectedConnectionStringMethod prompt + continue; + } + try { + // TO DO: https://github.com/microsoft/azuredatastudio/issues/18012 + connectionUri = await vscodeMssqlApi.connect(connectionInfo); + } catch (e) { + // display an mssql error due to connection request failing and go back to prompt for connection string methods + console.warn(e); + continue; + } + try { + connectionString = await vscodeMssqlApi.getConnectionString(connectionUri, false); + } catch (e) { + // failed to get connection string for selected connection and will go back to prompt for connection string methods + console.warn(e); + void vscode.window.showErrorMessage(constants.failedToGetConnectionString); + continue; + } + } + if (connectionString) { + try { + const success = await azureFunctionsUtils.setLocalAppSetting(path.dirname(projectUri.fsPath), newConnectionStringSettingName, connectionString); + if (success) { + // exit both loops and insert binding + connectionStringSettingName = newConnectionStringSettingName; + break; + } else { + void vscode.window.showErrorMessage(constants.selectConnectionError()); + } + } catch (e) { + // display error message and show select setting quickpick again + void vscode.window.showErrorMessage(constants.selectConnectionError(e)); + continue; + } } - } catch (e) { - // display error message and show select setting quickpick again - void vscode.window.showErrorMessage(utils.getErrorMessage(e)); } + } else { // If user cancels out of this or doesn't want to overwrite an existing setting // just return them to the select setting quickpick in case they changed their mind - } else { connectionStringSettingName = selectedSetting.label; } } - } else { // if no AF project was found or there's more than one AF functions project in the workspace, // ask for the user to input the setting name diff --git a/extensions/sql-database-projects/src/test/dialogs/addSqlBindingQuickpick.test.ts b/extensions/sql-database-projects/src/test/dialogs/addSqlBindingQuickpick.test.ts index 095822d571..f9a2cb41a8 100644 --- a/extensions/sql-database-projects/src/test/dialogs/addSqlBindingQuickpick.test.ts +++ b/extensions/sql-database-projects/src/test/dialogs/addSqlBindingQuickpick.test.ts @@ -11,7 +11,7 @@ import * as utils from '../../common/utils'; import * as constants from '../../common/constants'; import * as azureFunctionUtils from '../../common/azureFunctionsUtils'; -import { createContext, TestContext } from '../testContext'; +import { createContext, TestContext, createTestCredentials } from '../testContext'; import { launchAddSqlBindingQuickpick } from '../../dialogs/addSqlBindingQuickpick'; import { PackageHelper } from '../../tools/packageHelper'; @@ -29,6 +29,7 @@ describe('Add SQL Binding quick pick', () => { it('Should show error if the file contains no Azure Functions', async function (): Promise { sinon.stub(utils, 'getAzureFunctionService').resolves(testContext.azureFunctionService.object); + sinon.stub(utils, 'getVscodeMssqlApi').resolves(testContext.vscodeMssqlIExtension.object); const spy = sinon.spy(vscode.window, 'showErrorMessage'); testContext.azureFunctionService.setup(x => x.getAzureFunctions(TypeMoq.It.isAny())).returns(async () => { return Promise.resolve({ @@ -46,6 +47,7 @@ describe('Add SQL Binding quick pick', () => { it('Should show error if adding SQL binding was not successful', async function (): Promise { sinon.stub(utils, 'getAzureFunctionService').resolves(testContext.azureFunctionService.object); + sinon.stub(utils, 'getVscodeMssqlApi').resolves(testContext.vscodeMssqlIExtension.object); const spy = sinon.spy(vscode.window, 'showErrorMessage'); testContext.azureFunctionService.setup(x => x.getAzureFunctions(TypeMoq.It.isAny())).returns(async () => { return Promise.resolve({ @@ -54,6 +56,7 @@ describe('Add SQL Binding quick pick', () => { azureFunctions: ['af1', 'af2'] }); }); + //failure since no AFs are found in the project sinon.stub(azureFunctionUtils, 'getAFProjectContainingFile').resolves(undefined); const errormsg = 'Error inserting binding'; testContext.azureFunctionService.setup(x => x.addSqlBinding(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(async () => { @@ -77,5 +80,55 @@ describe('Add SQL Binding quick pick', () => { should(spy.calledOnce).be.true('showErrorMessage should have been called exactly once'); should(spy.calledWith(errormsg)).be.true(`showErrorMessage not called with expected message '${errormsg}' Actual '${spy.getCall(0).args[0]}'`); }); -}); + it('Should show error connection profile does not connect', async function (): Promise { + sinon.stub(utils, 'getAzureFunctionService').resolves(testContext.azureFunctionService.object); + sinon.stub(utils, 'getVscodeMssqlApi').resolves(testContext.vscodeMssqlIExtension.object); + let connectionCreds = createTestCredentials(); + + sinon.stub(azureFunctionUtils, 'getAFProjectContainingFile').resolves(vscode.Uri.file('testUri')); + testContext.azureFunctionService.setup(x => x.getAzureFunctions(TypeMoq.It.isAny())).returns(async () => { + return Promise.resolve({ + success: true, + errorMessage: '', + azureFunctions: ['af1'] + }); + }); + + // Mocks connect call to mssql + let error = new Error('Connection Request Failed'); + testContext.vscodeMssqlIExtension.setup(x => x.connect(TypeMoq.It.isAny(), undefined)).throws(error); + + // Mocks promptForConnection + testContext.vscodeMssqlIExtension.setup(x => x.promptForConnection(true)).returns(() => Promise.resolve(connectionCreds)); + let quickpickStub = sinon.stub(vscode.window, 'showQuickPick'); + // select Azure function + quickpickStub.onFirstCall().resolves({ label: 'af1' }); + // select input or output binding + quickpickStub.onSecondCall().resolves({ label: constants.input }); + + // give object name + let inputBoxStub = sinon.stub(vscode.window, 'showInputBox').onFirstCall().resolves('dbo.table1'); + + // select connection profile + quickpickStub.onThirdCall().resolves({ label: constants.createNewLocalAppSettingWithIcon }); + + // give connection string setting name + inputBoxStub.onSecondCall().resolves('SqlConnectionString'); + + // select connection profile method + quickpickStub.onCall(3).resolves({ label: constants.connectionProfile }); + + await launchAddSqlBindingQuickpick(vscode.Uri.file('testUri'), packageHelper); + + // should go back to the select connection string methods + should(quickpickStub.callCount === 5); + should(quickpickStub.getCall(4).args).deepEqual([ + [constants.connectionProfile, constants.userConnectionString], + { + canPickMany: false, + ignoreFocusOut: true, + title: constants.selectConnectionString + }]); + }); +}); diff --git a/extensions/sql-database-projects/src/test/testContext.ts b/extensions/sql-database-projects/src/test/testContext.ts index 4ed6449fa1..90fadab60e 100644 --- a/extensions/sql-database-projects/src/test/testContext.ts +++ b/extensions/sql-database-projects/src/test/testContext.ts @@ -15,6 +15,9 @@ export interface TestContext { dacFxService: TypeMoq.IMock; azureFunctionService: TypeMoq.IMock; outputChannel: vscode.OutputChannel; + vscodeMssqlIExtension: TypeMoq.IMock + dacFxMssqlService: TypeMoq.IMock; + schemaCompareService: TypeMoq.IMock; } export const mockDacFxResult = { @@ -137,6 +140,137 @@ export class MockAzureFunctionService implements vscodeMssql.IAzureFunctionsServ getAzureFunctions(_: string): Thenable { return Promise.resolve(mockGetAzureFunctionsResult); } } +export const mockDacFxMssqlOptionResult: vscodeMssql.DacFxOptionsResult = { + success: true, + errorMessage: '', + deploymentOptions: { + ignoreTableOptions: false, + ignoreSemicolonBetweenStatements: false, + ignoreRouteLifetime: false, + ignoreRoleMembership: false, + ignoreQuotedIdentifiers: false, + ignorePermissions: false, + ignorePartitionSchemes: false, + ignoreObjectPlacementOnPartitionScheme: false, + ignoreNotForReplication: false, + ignoreLoginSids: false, + ignoreLockHintsOnIndexes: false, + ignoreKeywordCasing: false, + ignoreIndexPadding: false, + ignoreIndexOptions: false, + ignoreIncrement: false, + ignoreIdentitySeed: false, + ignoreUserSettingsObjects: false, + ignoreFullTextCatalogFilePath: false, + ignoreWhitespace: false, + ignoreWithNocheckOnForeignKeys: false, + verifyCollationCompatibility: false, + unmodifiableObjectWarnings: false, + treatVerificationErrorsAsWarnings: false, + scriptRefreshModule: false, + scriptNewConstraintValidation: false, + scriptFileSize: false, + scriptDeployStateChecks: false, + scriptDatabaseOptions: false, + scriptDatabaseCompatibility: false, + scriptDatabaseCollation: false, + runDeploymentPlanExecutors: false, + registerDataTierApplication: false, + populateFilesOnFileGroups: false, + noAlterStatementsToChangeClrTypes: false, + includeTransactionalScripts: false, + includeCompositeObjects: false, + allowUnsafeRowLevelSecurityDataMovement: false, + ignoreWithNocheckOnCheckConstraints: false, + ignoreFillFactor: false, + ignoreFileSize: false, + ignoreFilegroupPlacement: false, + doNotAlterReplicatedObjects: false, + doNotAlterChangeDataCaptureObjects: false, + disableAndReenableDdlTriggers: false, + deployDatabaseInSingleUserMode: false, + createNewDatabase: false, + compareUsingTargetCollation: false, + commentOutSetVarDeclarations: false, + blockWhenDriftDetected: false, + blockOnPossibleDataLoss: false, + backupDatabaseBeforeChanges: false, + allowIncompatiblePlatform: false, + allowDropBlockingAssemblies: false, + dropConstraintsNotInSource: false, + dropDmlTriggersNotInSource: false, + dropExtendedPropertiesNotInSource: false, + dropIndexesNotInSource: false, + ignoreFileAndLogFilePath: false, + ignoreExtendedProperties: false, + ignoreDmlTriggerState: false, + ignoreDmlTriggerOrder: false, + ignoreDefaultSchema: false, + ignoreDdlTriggerState: false, + ignoreDdlTriggerOrder: false, + ignoreCryptographicProviderFilePath: false, + verifyDeployment: false, + ignoreComments: false, + ignoreColumnCollation: false, + ignoreAuthorizer: false, + ignoreAnsiNulls: false, + generateSmartDefaults: false, + dropStatisticsNotInSource: false, + dropRoleMembersNotInSource: false, + dropPermissionsNotInSource: false, + dropObjectsNotInSource: false, + ignoreColumnOrder: false, + doNotDropObjectTypes: [], + excludeObjectTypes: [] + } +}; + +export class MockDacFxMssqlService implements vscodeMssql.IDacFxService { + public exportBacpac(_: string, __: string, ___: string, ____: vscodeMssql.TaskExecutionMode): Thenable { return Promise.resolve(mockDacFxResult); } + public importBacpac(_: string, __: string, ___: string, ____: vscodeMssql.TaskExecutionMode): Thenable { return Promise.resolve(mockDacFxResult); } + public extractDacpac(_: string, __: string, ___: string, ____: string, _____: string, ______: vscodeMssql.TaskExecutionMode): Thenable { return Promise.resolve(mockDacFxResult); } + public createProjectFromDatabase(_: string, __: string, ___: string, ____: string, _____: string, ______: vscodeMssql.ExtractTarget, _______: vscodeMssql.TaskExecutionMode): Thenable { return Promise.resolve(mockDacFxResult); } + public deployDacpac(_: string, __: string, ___: boolean, ____: string, _____: vscodeMssql.TaskExecutionMode, ______?: Record): Thenable { return Promise.resolve(mockDacFxResult); } + public generateDeployScript(_: string, __: string, ___: string, ____: vscodeMssql.TaskExecutionMode, ______?: Record): Thenable { return Promise.resolve(mockDacFxResult); } + public generateDeployPlan(_: string, __: string, ___: string, ____: vscodeMssql.TaskExecutionMode): Thenable { return Promise.resolve(mockDacFxResult); } + public getOptionsFromProfile(_: string): Thenable { return Promise.resolve(mockDacFxMssqlOptionResult); } + public validateStreamingJob(_: string, __: string): Thenable { return Promise.resolve(mockDacFxResult); } +} + +export class MockSchemaCompareService implements vscodeMssql.ISchemaCompareService { + schemaCompareGetDefaultOptions(): Thenable { + throw new Error('Method not implemented.'); + } +} + +export class MockVscodeMssqlIExtension implements vscodeMssql.IExtension { + sqlToolsServicePath: string = ''; + dacFx: vscodeMssql.IDacFxService; + schemaCompare: vscodeMssql.ISchemaCompareService; + azureFunctions: vscodeMssql.IAzureFunctionsService; + + constructor() { + this.dacFx = new MockDacFxMssqlService; + this.schemaCompare = new MockSchemaCompareService; + this.azureFunctions = new MockAzureFunctionService; + } + promptForConnection(_?: boolean): Promise { + throw new Error('Method not implemented.'); + } + connect(_: vscodeMssql.IConnectionInfo, __?: boolean): Promise { + throw new Error('Method not implemented.'); + } + listDatabases(_: string): Promise { + throw new Error('Method not implemented.'); + } + getDatabaseNameFromTreeNode(_: vscodeMssql.ITreeNodeInfo): string { + throw new Error('Method not implemented.'); + } + getConnectionString(__: string, ___?: boolean): Promise { + throw new Error('Method not implemented.'); + } +} + export function createContext(): TestContext { let extensionPath = path.join(__dirname, '..', '..'); @@ -170,6 +304,9 @@ export function createContext(): TestContext { }, dacFxService: TypeMoq.Mock.ofType(MockDacFxService), azureFunctionService: TypeMoq.Mock.ofType(MockAzureFunctionService), + vscodeMssqlIExtension: TypeMoq.Mock.ofType(MockVscodeMssqlIExtension), + dacFxMssqlService: TypeMoq.Mock.ofType(MockDacFxMssqlService), + schemaCompareService: TypeMoq.Mock.ofType(MockSchemaCompareService), outputChannel: { name: '', append: () => { }, @@ -205,3 +342,41 @@ export const mockConnectionProfile: azdata.IConnectionProfile = { connectionName: 'My Connection Name' } }; + +export function createTestCredentials(): vscodeMssql.IConnectionInfo { + const creds: vscodeMssql.IConnectionInfo = { + server: 'my-server', + database: 'my_db', + user: 'sa', + password: '12345678', + email: 'test-email', + accountId: 'test-account-id', + port: 1234, + authenticationType: 'test', + azureAccountToken: '', + expiresOn: 0, + encrypt: false, + trustServerCertificate: false, + persistSecurityInfo: false, + connectTimeout: 15, + connectRetryCount: 0, + connectRetryInterval: 0, + applicationName: 'vscode-mssql', + workstationId: 'test', + applicationIntent: '', + currentLanguage: '', + pooling: true, + maxPoolSize: 15, + minPoolSize: 0, + loadBalanceTimeout: 0, + replication: false, + attachDbFilename: '', + failoverPartner: '', + multiSubnetFailover: false, + multipleActiveResultSets: false, + packetSize: 8192, + typeSystemVersion: 'Latest', + connectionString: '' + }; + return creds; +}