diff --git a/extensions/sql-database-projects/src/common/constants.ts b/extensions/sql-database-projects/src/common/constants.ts index 362b843c23..16c50f993a 100644 --- a/extensions/sql-database-projects/src/common/constants.ts +++ b/extensions/sql-database-projects/src/common/constants.ts @@ -5,6 +5,7 @@ import * as nls from 'vscode-nls'; import { SqlTargetPlatform } from 'sqldbproj'; +import * as utils from '../common/utils'; const localize = nls.loadMessageBundle(); @@ -101,7 +102,7 @@ export const connectionRadioButtonLabel = localize('connectionRadioButtonLabel', export const dataSourceDropdownTitle = localize('dataSourceDropdownTitle', "Data source"); export const noDataSourcesText = localize('noDataSourcesText', "No data sources in this project"); export const loadProfilePlaceholderText = localize('loadProfilePlaceholderText', "Load profile..."); -export const profileReadError = localize('profileReadError', "Could not load the profile file."); +export const profileReadError = (err: any) => localize('profileReadError', "Error loading the publish profile. {0}", utils.getErrorMessage(err)); export const sqlCmdTableLabel = localize('sqlCmdTableLabel', "SQLCMD Variables"); export const sqlCmdVariableColumn = localize('sqlCmdVariableColumn', "Name"); export const sqlCmdValueColumn = localize('sqlCmdValueColumn', "Value"); @@ -110,7 +111,8 @@ export const profile = localize('profile', "Profile"); export const selectConnection = localize('selectConnection', "Select connection"); export const server = localize('server', "Server"); export const defaultUser = localize('default', "default"); -export const selectProfile = localize('selectProfile', "Select publish profile to load"); +export const selectProfileToUse = localize('selectProfileToUse', "Select publish profile to load"); +export const selectProfile = localize('selectProfile', "Select Profile"); export const dontUseProfile = localize('dontUseProfile', "Don't use profile"); export const browseForProfile = localize('browseForProfile', "Browse for profile"); export const chooseAction = localize('chooseAction', "Choose action"); diff --git a/extensions/sql-database-projects/src/common/utils.ts b/extensions/sql-database-projects/src/common/utils.ts index 88afe8e271..9b77014ce8 100644 --- a/extensions/sql-database-projects/src/common/utils.ts +++ b/extensions/sql-database-projects/src/common/utils.ts @@ -11,6 +11,7 @@ import * as path from 'path'; import * as glob from 'fast-glob'; import * as dataworkspace from 'dataworkspace'; import * as mssql from '../../../mssql'; +import * as vscodeMssql from 'vscode-mssql'; import { promises as fs } from 'fs'; /** @@ -251,6 +252,20 @@ export function getDataWorkspaceExtensionApi(): dataworkspace.IExtension { return extension.exports; } +export type IDacFxService = mssql.IDacFxService | vscodeMssql.IDacFxService; + +export async function getDacFxService(): Promise { + if (getAzdataApi()) { + let ext = vscode.extensions.getExtension(mssql.extension.name) as vscode.Extension; + const api = await ext.activate(); + return api.dacFx; + } else { + let ext = vscode.extensions.getExtension(vscodeMssql.extension.name) as vscode.Extension; + const api = await ext.activate(); + return api.dacFx; + } +} + /* * Returns the default deployment options from DacFx */ diff --git a/extensions/sql-database-projects/src/controllers/projectController.ts b/extensions/sql-database-projects/src/controllers/projectController.ts index c1ea0c84ae..f735afde87 100644 --- a/extensions/sql-database-projects/src/controllers/projectController.ts +++ b/extensions/sql-database-projects/src/controllers/projectController.ts @@ -26,7 +26,7 @@ import { ProjectRootTreeItem } from '../models/tree/projectTreeItem'; import { ImportDataModel } from '../models/api/import'; import { NetCoreTool, DotNetCommandOptions } from '../tools/netcoreTool'; import { BuildHelper } from '../tools/buildHelper'; -import { PublishProfile, load } from '../models/publishProfile/publishProfile'; +import { readPublishProfile } from '../models/publishProfile/publishProfile'; import { AddDatabaseReferenceDialog } from '../dialogs/addDatabaseReferenceDialog'; import { ISystemDatabaseReferenceSettings, IDacpacReferenceSettings, IProjectReferenceSettings } from '../models/IDatabaseReferenceSettings'; import { DatabaseReferenceTreeItem } from '../models/tree/databaseReferencesTreeItem'; @@ -39,8 +39,6 @@ import { launchPublishDatabaseQuickpick } from '../dialogs/publishDatabaseQuickp const maxTableLength = 10; -export type IDacFxService = mssql.IDacFxService | mssqlVscode.IDacFxService; - /** * Controller for managing lifecycle of projects */ @@ -242,9 +240,9 @@ export class ProjectsController { if (utils.getAzdataApi()) { let publishDatabaseDialog = this.getPublishDialog(project); - publishDatabaseDialog.publish = async (proj, prof) => await this.publishProjectCallback(proj, prof); - publishDatabaseDialog.generateScript = async (proj, prof) => await this.publishProjectCallback(proj, prof); - publishDatabaseDialog.readPublishProfile = async (profileUri) => await this.readPublishProfileCallback(profileUri); + publishDatabaseDialog.publish = async (proj, prof) => this.publishProjectCallback(proj, prof); + publishDatabaseDialog.generateScript = async (proj, prof) => this.publishProjectCallback(proj, prof); + publishDatabaseDialog.readPublishProfile = async (profileUri) => readPublishProfile(profileUri); publishDatabaseDialog.openDialog(); @@ -279,7 +277,7 @@ export class ProjectsController { const tempPath = path.join(os.tmpdir(), `${path.parse(dacpacPath).name}_${new Date().getTime()}${constants.sqlprojExtension}`); await fs.copyFile(dacpacPath, tempPath); - const dacFxService = await this.getDaxFxService(); + const dacFxService = await utils.getDacFxService(); let result: mssql.DacFxResult; telemetryProps.profileUsed = (settings.profileUsed ?? false).toString(); @@ -349,17 +347,6 @@ export class ProjectsController { return result; } - public async readPublishProfileCallback(profileUri: vscode.Uri): Promise { - try { - const dacFxService = await this.getDaxFxService(); - const profile = await load(profileUri, dacFxService); - return profile; - } catch (e) { - vscode.window.showErrorMessage(constants.profileReadError); - throw e; - } - } - public async schemaCompare(treeNode: dataworkspace.WorkspaceTreeItem): Promise { try { // check if schema compare extension is installed @@ -740,7 +727,7 @@ export class ProjectsController { const streamingJobDefinition: string = (await fs.readFile(node.element.fileSystemUri.fsPath)).toString(); - const dacFxService = await this.getDaxFxService(); + const dacFxService = await utils.getDacFxService(); const actionStartTime = new Date().getTime(); const result: mssql.ValidateStreamingJobResult = await dacFxService.validateStreamingJob(dacpacPath, streamingJobDefinition); @@ -831,21 +818,6 @@ export class ProjectsController { } } - public async getDaxFxService(): Promise { - if (utils.getAzdataApi()) { - const ext: vscode.Extension = vscode.extensions.getExtension(mssql.extension.name)!; - const extensionApi = await ext.activate(); - return extensionApi.dacFx; - } else { - const ext: vscode.Extension = vscode.extensions.getExtension(mssql.extension.name)!; - const extensionApi = await ext.activate(); - return extensionApi.dacFx; - } - - } - - - private async promptForNewObjectName(itemType: templates.ProjectScriptType, _project: Project, folderPath: string, fileExtension?: string): Promise { const suggestedName = itemType.friendlyName.replace(/\s+/g, ''); let counter: number = 0; diff --git a/extensions/sql-database-projects/src/dialogs/publishDatabaseDialog.ts b/extensions/sql-database-projects/src/dialogs/publishDatabaseDialog.ts index b81f884587..c14a59787c 100644 --- a/extensions/sql-database-projects/src/dialogs/publishDatabaseDialog.ts +++ b/extensions/sql-database-projects/src/dialogs/publishDatabaseDialog.ts @@ -622,6 +622,7 @@ export class PublishDatabaseDialog { export function promptForPublishProfile(defaultPath: string): Thenable { return vscode.window.showOpenDialog( { + title: constants.selectProfile, canSelectFiles: true, canSelectFolders: false, canSelectMany: false, diff --git a/extensions/sql-database-projects/src/dialogs/publishDatabaseQuickpick.ts b/extensions/sql-database-projects/src/dialogs/publishDatabaseQuickpick.ts index 3f71668a11..5e0b58d9d5 100644 --- a/extensions/sql-database-projects/src/dialogs/publishDatabaseQuickpick.ts +++ b/extensions/sql-database-projects/src/dialogs/publishDatabaseQuickpick.ts @@ -7,6 +7,7 @@ import * as vscode from 'vscode'; import * as constants from '../common/constants'; import { IGenerateScriptSettings, IPublishSettings } from '../models/IPublishSettings'; import { Project } from '../models/project'; +import { PublishProfile, readPublishProfile } from '../models/publishProfile/publishProfile'; import { promptForPublishProfile } from './publishDatabaseDialog'; /** @@ -15,21 +16,57 @@ import { promptForPublishProfile } from './publishDatabaseDialog'; export async function launchPublishDatabaseQuickpick(project: Project): Promise { // 1. Select publish settings file (optional) - // TODO@chgagnon: Hook up to dacfx service - const browseProfileOption = await vscode.window.showQuickPick( - [constants.dontUseProfile, constants.browseForProfile], - { title: constants.selectProfile, ignoreFocusOut: true }); - if (!browseProfileOption) { - return; - } + // Create custom quickpick so we can control stuff like displaying the loading indicator + const quickPick = vscode.window.createQuickPick(); + quickPick.items = [{ label: constants.dontUseProfile }, { label: constants.browseForProfile }]; + quickPick.ignoreFocusOut = true; + quickPick.title = constants.selectProfileToUse; + const profilePicked = new Promise((resolve, reject) => { + quickPick.onDidHide(() => { + // If the quickpick is hidden that means the user cancelled or another quickpick came up - so we reject + // here to be able to complete the promise being waited on below + reject(); + }); + quickPick.onDidChangeSelection(async items => { + if (items[0].label === constants.browseForProfile) { + const locations = await promptForPublishProfile(project.projectFolderPath); + if (!locations) { + // Clear items so that this event will trigger again if they select the same item + quickPick.selectedItems = []; + quickPick.activeItems = []; + // If the user cancels out of the file picker then just return and let them choose another option + return; + } + let publishProfileUri = locations[0]; + try { + // Show loading state while reading profile + quickPick.busy = true; + quickPick.enabled = false; + const profile = await readPublishProfile(publishProfileUri); + resolve(profile); + } catch (err) { + // readPublishProfile will handle displaying an error if one occurs + // Clear items so that this event will trigger again if they select the same item + quickPick.selectedItems = []; + quickPick.activeItems = []; + quickPick.busy = false; + quickPick.enabled = true; - // let publishSettingsFile: vscode.Uri | undefined; - if (browseProfileOption === constants.browseForProfile) { - const locations = await promptForPublishProfile(project.projectFolderPath); - if (!locations) { - return; - } - // publishSettingsFile = locations[0]; + } + } else { + // Selected no profile so just continue on + resolve(undefined); + } + }); + }); + quickPick.show(); + let publishProfile: PublishProfile | undefined = undefined; + try { + publishProfile = await profilePicked; + } catch (err) { + // User cancelled or another quickpick came up and hid the current one + // so exit the flow. + return; } // 2. Select connection @@ -83,8 +120,9 @@ export async function launchPublishDatabaseQuickpick(project: Project): Promise< // 4. Modify sqlcmd vars - // TODO@chgagnon: Concat ones from publish profile - let sqlCmdVariables = Object.assign({}, project.sqlCmdVariables); + // If a publish profile is provided then the values from there will overwrite the ones in the + // project file (if they exist) + let sqlCmdVariables = Object.assign({}, project.sqlCmdVariables, publishProfile?.sqlCmdVariables); if (Object.keys(sqlCmdVariables).length > 0) { // Continually loop here, allowing the user to modify SQLCMD variables one @@ -123,7 +161,7 @@ export async function launchPublishDatabaseQuickpick(project: Project): Promise< sqlCmdVariables[sqlCmd.key] = newValue; } } else if (sqlCmd.isResetAllVars) { - sqlCmdVariables = Object.assign({}, project.sqlCmdVariables); + sqlCmdVariables = Object.assign({}, project.sqlCmdVariables, publishProfile?.sqlCmdVariables); } else if (sqlCmd.isDone) { break; } diff --git a/extensions/sql-database-projects/src/models/publishProfile/publishProfile.ts b/extensions/sql-database-projects/src/models/publishProfile/publishProfile.ts index 4e6a86381b..3c97aa1cf3 100644 --- a/extensions/sql-database-projects/src/models/publishProfile/publishProfile.ts +++ b/extensions/sql-database-projects/src/models/publishProfile/publishProfile.ts @@ -8,11 +8,10 @@ import * as constants from '../../common/constants'; import * as utils from '../../common/utils'; import * as mssql from '../../../../mssql'; import * as vscodeMssql from 'vscode-mssql'; +import * as vscode from 'vscode'; import { promises as fs } from 'fs'; -import { Uri } from 'vscode'; import { SqlConnectionDataSource } from '../dataSources/sqlConnectionStringSource'; -import { IDacFxService } from '../../controllers/projectController'; // only reading db name, connection string, and SQLCMD vars from profile for now export interface PublishProfile { @@ -24,10 +23,21 @@ export interface PublishProfile { options?: mssql.DeploymentOptions | vscodeMssql.DeploymentOptions; } +export async function readPublishProfile(profileUri: vscode.Uri): Promise { + try { + const dacFxService = await utils.getDacFxService(); + const profile = await load(profileUri, dacFxService); + return profile; + } catch (e) { + vscode.window.showErrorMessage(constants.profileReadError(e)); + throw e; + } +} + /** * parses the specified file to load publish settings */ -export async function load(profileUri: Uri, dacfxService: IDacFxService): Promise { +export async function load(profileUri: vscode.Uri, dacfxService: utils.IDacFxService): Promise { const profileText = await fs.readFile(profileUri.fsPath); const profileXmlDoc = new xmldom.DOMParser().parseFromString(profileText.toString()); @@ -67,17 +77,26 @@ async function readConnectionString(xmlDoc: any): Promise<{ connectionId: string const connectionProfile = dataSource.getConnectionProfile(); try { + const azdataApi = utils.getAzdataApi(); if (dataSource.integratedSecurity) { - const connection = await utils.getAzdataApi()!.connection.connect(connectionProfile, false, false); - connId = connection.connectionId; + if (azdataApi) { + const connection = await utils.getAzdataApi()!.connection.connect(connectionProfile, false, false); + connId = connection.connectionId; + } else { + // TODO@chgagnon - hook up VS Code MSSQL + } server = dataSource.server; username = constants.defaultUser; } else { - const connection = await utils.getAzdataApi()!.connection.openConnectionDialog(undefined, connectionProfile); - connId = connection.connectionId; - server = connection.options['server']; - username = connection.options['user']; + if (azdataApi) { + const connection = await utils.getAzdataApi()!.connection.openConnectionDialog(undefined, connectionProfile); + connId = connection.connectionId; + server = connection.options['server']; + username = connection.options['user']; + } else { + // TODO@chgagnon - hook up VS Code MSSQL + } } targetConnection = `${server} (${username})`; diff --git a/extensions/sql-database-projects/src/test/projectController.test.ts b/extensions/sql-database-projects/src/test/projectController.test.ts index 7628f773ff..4d6f78fba8 100644 --- a/extensions/sql-database-projects/src/test/projectController.test.ts +++ b/extensions/sql-database-projects/src/test/projectController.test.ts @@ -15,6 +15,7 @@ import * as templates from '../templates/templates'; import * as testUtils from './testUtils'; import * as constants from '../common/constants'; import * as mssql from '../../../mssql'; +import * as utils from '../common/utils'; import { SqlDatabaseProjectTreeViewProvider } from '../controllers/databaseProjectTreeViewProvider'; import { ProjectsController } from '../controllers/projectController'; @@ -23,7 +24,6 @@ import { createContext, TestContext, mockDacFxResult, mockConnectionProfile } fr import { Project, reservedProjectFolders, SystemDatabase, FileProjectEntry, SystemDatabaseReferenceProjectEntry } from '../models/project'; import { PublishDatabaseDialog } from '../dialogs/publishDatabaseDialog'; import { IPublishSettings, IGenerateScriptSettings } from '../models/IPublishSettings'; -import { exists } from '../common/utils'; import { ProjectRootTreeItem } from '../models/tree/projectTreeItem'; import { FolderNode, FileNode } from '../models/tree/fileFolderTreeItem'; import { BaseProjectTreeItem } from '../models/tree/baseTreeItem'; @@ -198,10 +198,10 @@ describe('ProjectsController', function (): void { should(proj.postDeployScripts.length).equal(0); should(proj.noneDeployScripts.length).equal(0); - should(await exists(scriptEntry.fsUri.fsPath)).equal(false, 'script is supposed to be deleted'); - should(await exists(preDeployEntry.fsUri.fsPath)).equal(false, 'pre-deployment script is supposed to be deleted'); - should(await exists(postDeployEntry.fsUri.fsPath)).equal(false, 'post-deployment script is supposed to be deleted'); - should(await exists(noneEntry.fsUri.fsPath)).equal(false, 'none entry pre-deployment script is supposed to be deleted'); + should(await utils.exists(scriptEntry.fsUri.fsPath)).equal(false, 'script is supposed to be deleted'); + should(await utils.exists(preDeployEntry.fsUri.fsPath)).equal(false, 'pre-deployment script is supposed to be deleted'); + should(await utils.exists(postDeployEntry.fsUri.fsPath)).equal(false, 'post-deployment script is supposed to be deleted'); + should(await utils.exists(noneEntry.fsUri.fsPath)).equal(false, 'none entry pre-deployment script is supposed to be deleted'); }); it('Should delete database references', async function (): Promise { @@ -259,10 +259,10 @@ describe('ProjectsController', function (): void { should(proj.postDeployScripts.length).equal(0); should(proj.noneDeployScripts.length).equal(0); - should(await exists(scriptEntry.fsUri.fsPath)).equal(true, 'script is supposed to still exist on disk'); - should(await exists(preDeployEntry.fsUri.fsPath)).equal(true, 'pre-deployment script is supposed to still exist on disk'); - should(await exists(postDeployEntry.fsUri.fsPath)).equal(true, 'post-deployment script is supposed to still exist on disk'); - should(await exists(noneEntry.fsUri.fsPath)).equal(true, 'none entry pre-deployment script is supposed to still exist on disk'); + should(await utils.exists(scriptEntry.fsUri.fsPath)).equal(true, 'script is supposed to still exist on disk'); + should(await utils.exists(preDeployEntry.fsUri.fsPath)).equal(true, 'pre-deployment script is supposed to still exist on disk'); + should(await utils.exists(postDeployEntry.fsUri.fsPath)).equal(true, 'post-deployment script is supposed to still exist on disk'); + should(await utils.exists(noneEntry.fsUri.fsPath)).equal(true, 'none entry pre-deployment script is supposed to still exist on disk'); }); it('Should delete folders with excluded items', async function (): Promise { @@ -287,9 +287,9 @@ describe('ProjectsController', function (): void { // Confirm result should(proj.files.some(x => x.relativePath === 'UpperFolder')).equal(false, 'UpperFolder should not be part of proj file any more'); - should(await exists(scriptEntry.fsUri.fsPath)).equal(false, 'script is supposed to be deleted from disk'); - should(await exists(lowerFolder.projectUri.fsPath)).equal(false, 'LowerFolder is supposed to be deleted from disk'); - should(await exists(upperFolder.projectUri.fsPath)).equal(false, 'UpperFolder is supposed to be deleted from disk'); + should(await utils.exists(scriptEntry.fsUri.fsPath)).equal(false, 'script is supposed to be deleted from disk'); + should(await utils.exists(lowerFolder.projectUri.fsPath)).equal(false, 'LowerFolder is supposed to be deleted from disk'); + should(await utils.exists(upperFolder.projectUri.fsPath)).equal(false, 'UpperFolder is supposed to be deleted from disk'); }); it('Should reload correctly after changing sqlproj file', async function (): Promise { @@ -426,8 +426,7 @@ describe('ProjectsController', function (): void { builtDacpacPath = await testUtils.createTestFile(fakeDacpacContents, 'output.dacpac'); return builtDacpacPath; }); - - projController.setup(x => x.getDaxFxService()).returns(() => Promise.resolve(testContext.dacFxService.object)); + sinon.stub(utils, 'getDacFxService').resolves(testContext.dacFxService.object); const proj = await testUtils.createTestProject(baselines.openProjectFileBaseline); diff --git a/extensions/sql-database-projects/src/test/publishProfile.test.ts b/extensions/sql-database-projects/src/test/publishProfile.test.ts index 32631d7868..84671b700b 100644 --- a/extensions/sql-database-projects/src/test/publishProfile.test.ts +++ b/extensions/sql-database-projects/src/test/publishProfile.test.ts @@ -11,9 +11,8 @@ import * as TypeMoq from 'typemoq'; import * as baselines from './baselines/baselines'; import * as testUtils from './testUtils'; import * as constants from '../common/constants'; -import { ProjectsController } from '../controllers/projectController'; import { TestContext, createContext, mockDacFxOptionsResult } from './testContext'; -import { load } from '../models/publishProfile/publishProfile'; +import { load, readPublishProfile } from '../models/publishProfile/publishProfile'; let testContext: TestContext; @@ -81,10 +80,9 @@ describe('Publish profile tests', function (): void { it('Should throw error when connecting does not work', async function (): Promise { await baselines.loadBaselines(); let profilePath = await testUtils.createTestFile(baselines.publishProfileIntegratedSecurityBaseline, 'publishProfile.publish.xml'); - const projController = new ProjectsController(); sinon.stub(azdata.connection, 'connect').throws(new Error('Could not connect')); - await testUtils.shouldThrowSpecificError(async () => await projController.readPublishProfileCallback(vscode.Uri.file(profilePath)), constants.unableToCreatePublishConnection('Could not connect')); + await testUtils.shouldThrowSpecificError(async () => await readPublishProfile(vscode.Uri.file(profilePath)), constants.unableToCreatePublishConnection('Could not connect')); }); }); diff --git a/extensions/sql-database-projects/src/typings/vscode-mssql.d.ts b/extensions/sql-database-projects/src/typings/vscode-mssql.d.ts index 8ed816c6ea..d0fc4f7b64 100644 --- a/extensions/sql-database-projects/src/typings/vscode-mssql.d.ts +++ b/extensions/sql-database-projects/src/typings/vscode-mssql.d.ts @@ -13,7 +13,7 @@ declare module 'vscode-mssql' { export const enum extension { - name = 'Microsoft.mssql' + name = 'ms-mssql.mssql' } /** @@ -45,7 +45,7 @@ declare module 'vscode-mssql' { validateStreamingJob(packageFilePath: string, createStreamingJobTsql: string): Thenable; } - export enum TaskExecutionMode { + export const enum TaskExecutionMode { execute = 0, script = 1, executeAndScript = 2