diff --git a/extensions/mssql/src/contracts.ts b/extensions/mssql/src/contracts.ts index 0b25952351..debbae1a44 100644 --- a/extensions/mssql/src/contracts.ts +++ b/extensions/mssql/src/contracts.ts @@ -449,6 +449,7 @@ export interface DeployParams { packageFilePath: string; databaseName: string; upgradeExisting: boolean; + sqlCommandVariableValues?: Record; ownerUri: string; taskExecutionMode: TaskExecutionMode; } @@ -456,6 +457,7 @@ export interface DeployParams { export interface GenerateDeployScriptParams { packageFilePath: string; databaseName: string; + sqlCommandVariableValues?: Record; ownerUri: string; taskExecutionMode: TaskExecutionMode; } diff --git a/extensions/mssql/src/dacfx/dacFxService.ts b/extensions/mssql/src/dacfx/dacFxService.ts index a5c9a6bd62..aa8eb58947 100644 --- a/extensions/mssql/src/dacfx/dacFxService.ts +++ b/extensions/mssql/src/dacfx/dacFxService.ts @@ -65,8 +65,8 @@ export class DacFxService implements mssql.IDacFxService { ); } - public deployDacpac(packageFilePath: string, targetDatabaseName: string, upgradeExisting: boolean, ownerUri: string, taskExecutionMode: azdata.TaskExecutionMode): Thenable { - const params: contracts.DeployParams = { packageFilePath: packageFilePath, databaseName: targetDatabaseName, upgradeExisting: upgradeExisting, ownerUri: ownerUri, taskExecutionMode: taskExecutionMode }; + public deployDacpac(packageFilePath: string, targetDatabaseName: string, upgradeExisting: boolean, ownerUri: string, taskExecutionMode: azdata.TaskExecutionMode, sqlCommandVariableValues?: Record): Thenable { + const params: contracts.DeployParams = { packageFilePath: packageFilePath, databaseName: targetDatabaseName, upgradeExisting: upgradeExisting, sqlCommandVariableValues: sqlCommandVariableValues, ownerUri: ownerUri, taskExecutionMode: taskExecutionMode }; return this.client.sendRequest(contracts.DeployRequest.type, params).then( undefined, e => { @@ -76,8 +76,8 @@ export class DacFxService implements mssql.IDacFxService { ); } - public generateDeployScript(packageFilePath: string, targetDatabaseName: string, ownerUri: string, taskExecutionMode: azdata.TaskExecutionMode): Thenable { - const params: contracts.GenerateDeployScriptParams = { packageFilePath: packageFilePath, databaseName: targetDatabaseName, ownerUri: ownerUri, taskExecutionMode: taskExecutionMode }; + public generateDeployScript(packageFilePath: string, targetDatabaseName: string, ownerUri: string, taskExecutionMode: azdata.TaskExecutionMode, sqlCommandVariableValues?: Record): Thenable { + const params: contracts.GenerateDeployScriptParams = { packageFilePath: packageFilePath, databaseName: targetDatabaseName, sqlCommandVariableValues: sqlCommandVariableValues, ownerUri: ownerUri, taskExecutionMode: taskExecutionMode }; return this.client.sendRequest(contracts.GenerateDeployScriptRequest.type, params).then( undefined, e => { diff --git a/extensions/mssql/src/mssql.d.ts b/extensions/mssql/src/mssql.d.ts index 0a0e62825a..e3c60a6e0b 100644 --- a/extensions/mssql/src/mssql.d.ts +++ b/extensions/mssql/src/mssql.d.ts @@ -323,8 +323,8 @@ export interface IDacFxService { exportBacpac(databaseName: string, packageFilePath: string, ownerUri: string, taskExecutionMode: azdata.TaskExecutionMode): Thenable; importBacpac(packageFilePath: string, databaseName: string, ownerUri: string, taskExecutionMode: azdata.TaskExecutionMode): Thenable; extractDacpac(databaseName: string, packageFilePath: string, applicationName: string, applicationVersion: string, ownerUri: string, taskExecutionMode: azdata.TaskExecutionMode): Thenable; - deployDacpac(packageFilePath: string, databaseName: string, upgradeExisting: boolean, ownerUri: string, taskExecutionMode: azdata.TaskExecutionMode): Thenable; - generateDeployScript(packageFilePath: string, databaseName: string, ownerUri: string, taskExecutionMode: azdata.TaskExecutionMode): Thenable; + deployDacpac(packageFilePath: string, databaseName: string, upgradeExisting: boolean, ownerUri: string, taskExecutionMode: azdata.TaskExecutionMode, sqlCommandVariableValues?: Record): Thenable; + generateDeployScript(packageFilePath: string, databaseName: string, ownerUri: string, taskExecutionMode: azdata.TaskExecutionMode, sqlCommandVariableValues?: Record): Thenable; generateDeployPlan(packageFilePath: string, databaseName: string, ownerUri: string, taskExecutionMode: azdata.TaskExecutionMode): Thenable; } diff --git a/extensions/sql-database-projects/src/common/constants.ts b/extensions/sql-database-projects/src/common/constants.ts index e1833b535c..40d0af5354 100644 --- a/extensions/sql-database-projects/src/common/constants.ts +++ b/extensions/sql-database-projects/src/common/constants.ts @@ -10,7 +10,8 @@ const localize = nls.loadMessageBundle(); // Placeholder values export const dataSourcesFileName = 'datasources.json'; export const sqlprojExtension = '.sqlproj'; -export const initialCatalogSetting = 'Initial Catalog'; + +// Commands export const schemaCompareExtensionId = 'microsoft.schema-compare'; export const sqlDatabaseProjectExtensionId = 'microsoft.sql-database-projects'; export const mssqlExtensionId = 'microsoft.mssql'; @@ -59,6 +60,8 @@ export const buildDacpacNotFound = localize('buildDacpacNotFound', "Dacpac creat export function projectAlreadyOpened(path: string) { return localize('projectAlreadyOpened', "Project '{0}' is already opened.", path); } export function projectAlreadyExists(name: string, path: string) { return localize('projectAlreadyExists', "A project named {0} already exists in {1}.", name, path); } export function mssqlNotFound(mssqlConfigDir: string) { return localize('mssqlNotFound', "Could not get mssql extension's install location at {0}", mssqlConfigDir); } +export function projBuildFailed(errorMessage: string) { return localize('projBuildFailed', "Build failed. Check output pane for more details. {0}", errorMessage); } +export function unexpectedProjectContext(uri: string) { return localize('unexpectedProjectContext', "Unable to establish project context. Command invoked from unexpected location: {0}", uri); } // Project script types @@ -73,3 +76,10 @@ export const ItemGroup = 'ItemGroup'; export const Build = 'Build'; export const Folder = 'Folder'; export const Include = 'Include'; + +// SQL connection string components +export const initialCatalogSetting = 'Initial Catalog'; +export const dataSourceSetting = 'Data Source'; +export const integratedSecuritySetting = 'Integrated Security'; +export const userIdSetting = 'User ID'; +export const passwordSetting = 'Password'; diff --git a/extensions/sql-database-projects/src/controllers/mainController.ts b/extensions/sql-database-projects/src/controllers/mainController.ts index b2961317b8..492d4fe526 100644 --- a/extensions/sql-database-projects/src/controllers/mainController.ts +++ b/extensions/sql-database-projects/src/controllers/mainController.ts @@ -50,7 +50,7 @@ export default class MainController implements Disposable { this.apiWrapper.registerCommand('sqlDatabaseProjects.properties', async (node: BaseProjectTreeItem) => { await this.apiWrapper.showErrorMessage(`Properties not yet implemented: ${node.uri.path}`); }); // TODO this.apiWrapper.registerCommand('sqlDatabaseProjects.build', async (node: BaseProjectTreeItem) => { await this.projectsController.buildProject(node); }); - this.apiWrapper.registerCommand('sqlDatabaseProjects.deploy', async (node: BaseProjectTreeItem) => { await this.projectsController.deploy(node); }); + this.apiWrapper.registerCommand('sqlDatabaseProjects.deploy', async (node: BaseProjectTreeItem) => { await this.projectsController.deployProject(node); }); this.apiWrapper.registerCommand('sqlDatabaseProjects.schemaCompare', async (node: BaseProjectTreeItem) => { await this.projectsController.schemaCompare(node); }); this.apiWrapper.registerCommand('sqlDatabaseProjects.import', async (node: BaseProjectTreeItem) => { await this.projectsController.import(node); }); diff --git a/extensions/sql-database-projects/src/controllers/projectController.ts b/extensions/sql-database-projects/src/controllers/projectController.ts index 75d6799526..e17b156fc8 100644 --- a/extensions/sql-database-projects/src/controllers/projectController.ts +++ b/extensions/sql-database-projects/src/controllers/projectController.ts @@ -3,22 +3,25 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ -import * as path from 'path'; import * as constants from '../common/constants'; import * as dataSources from '../models/dataSources/dataSources'; +import * as mssql from '../../../mssql'; +import * as path from 'path'; import * as utils from '../common/utils'; import * as UUID from 'vscode-languageclient/lib/utils/uuid'; import * as templates from '../templates/templates'; -import { Uri, QuickPickItem } from 'vscode'; +import { TaskExecutionMode } from 'azdata'; +import { promises as fs } from 'fs'; +import { Uri, QuickPickItem, extensions, Extension } from 'vscode'; import { ApiWrapper } from '../common/apiWrapper'; +import { DeployDatabaseDialog } from '../dialogs/deployDatabaseDialog'; import { Project } from '../models/project'; import { SqlDatabaseProjectTreeViewProvider } from './databaseProjectTreeViewProvider'; -import { promises as fs } from 'fs'; +import { FolderNode } from '../models/tree/fileFolderTreeItem'; +import { IDeploymentProfile, IGenerateScriptProfile } from '../models/IDeploymentProfile'; import { BaseProjectTreeItem } from '../models/tree/baseTreeItem'; import { ProjectRootTreeItem } from '../models/tree/projectTreeItem'; -import { FolderNode } from '../models/tree/fileFolderTreeItem'; -import { DeployDatabaseDialog } from '../dialogs/deployDatabaseDialog'; import { NetCoreTool, DotNetCommandOptions } from '../tools/netcoreTool'; import { BuildHelper } from '../tools/buildHelper'; @@ -114,28 +117,82 @@ export class ProjectsController { } public closeProject(treeNode: BaseProjectTreeItem) { - const project = this.getProjectContextFromTreeNode(treeNode); + const project = ProjectsController.getProjectFromContext(treeNode); this.projects = this.projects.filter((e) => { return e !== project; }); this.refreshProjectsTree(); } - public async buildProject(treeNode: BaseProjectTreeItem): Promise { + /** + * Builds a project, producing a dacpac + * @param treeNode a treeItem in a project's hierarchy, to be used to obtain a Project + * @returns path of the built dacpac + */ + public async buildProject(treeNode: BaseProjectTreeItem): Promise; + /** + * Builds a project, producing a dacpac + * @param project Project to be built + * @returns path of the built dacpac + */ + public async buildProject(project: Project): Promise; + public async buildProject(context: Project | BaseProjectTreeItem): Promise { + const project: Project = ProjectsController.getProjectFromContext(context); + // Check mssql extension for project dlls (tracking issue #10273) await this.buildHelper.createBuildDirFolder(); - const project = this.getProjectContextFromTreeNode(treeNode); const options: DotNetCommandOptions = { commandTitle: 'Build', workingDirectory: project.projectFolderPath, argument: this.buildHelper.constructBuildArguments(project.projectFilePath, this.buildHelper.extensionBuildDirPath) }; - await this.netCoreTool.runDotnetCommand(options); + try { + await this.netCoreTool.runDotnetCommand(options); + + return path.join(project.projectFolderPath, 'bin', 'Debug', `${project.projectFileName}.dacpac`); + } + catch (err) { + this.apiWrapper.showErrorMessage(constants.projBuildFailed(utils.getErrorMessage(err))); + return undefined; + } } - public deploy(treeNode: BaseProjectTreeItem): void { - const project = this.getProjectContextFromTreeNode(treeNode); - const deployDatabaseDialog = new DeployDatabaseDialog(this.apiWrapper, project); + /** + * Builds and deploys a project + * @param treeNode a treeItem in a project's hierarchy, to be used to obtain a Project + */ + public async deployProject(treeNode: BaseProjectTreeItem): Promise; + /** + * Builds and deploys a project + * @param project Project to be built and deployed + */ + public async deployProject(project: Project): Promise; + public async deployProject(context: Project | BaseProjectTreeItem): Promise { + const project: Project = ProjectsController.getProjectFromContext(context); + let deployDatabaseDialog = this.getDeployDialog(project); + + deployDatabaseDialog.deploy = async (proj, prof) => await this.executionCallback(proj, prof); + deployDatabaseDialog.generateScript = async (proj, prof) => await this.executionCallback(proj, prof); + deployDatabaseDialog.openDialog(); + + return deployDatabaseDialog; + } + + public async executionCallback(project: Project, profile: IDeploymentProfile | IGenerateScriptProfile): Promise { + const dacpacPath = await this.buildProject(project); + + if (!dacpacPath) { + return undefined; // buildProject() handles displaying the error + } + + const dacFxService = await ProjectsController.getDaxFxService(); + + if (profile as IDeploymentProfile) { + return await dacFxService.deployDacpac(dacpacPath, profile.databaseName, (profile).upgradeExisting, profile.connectionUri, TaskExecutionMode.execute, profile.sqlCmdVariables); + } + else { + return await dacFxService.generateDeployScript(dacpacPath, profile.databaseName, profile.connectionUri, TaskExecutionMode.execute, profile.sqlCmdVariables); + } } public async schemaCompare(treeNode: BaseProjectTreeItem): Promise { @@ -145,7 +202,7 @@ export class ProjectsController { await this.buildProject(treeNode); // start schema compare with the dacpac produced from build - const project = this.getProjectContextFromTreeNode(treeNode); + const project = ProjectsController.getProjectFromContext(treeNode); const dacpacPath = path.join(project.projectFolderPath, 'bin', 'Debug', `${project.projectFileName}.dacpac`); // check that dacpac exists @@ -160,12 +217,12 @@ export class ProjectsController { } public async import(treeNode: BaseProjectTreeItem) { - const project = this.getProjectContextFromTreeNode(treeNode); + const project = ProjectsController.getProjectFromContext(treeNode); await this.apiWrapper.showErrorMessage(`Import not yet implemented: ${project.projectFilePath}`); // TODO } public async addFolderPrompt(treeNode: BaseProjectTreeItem) { - const project = this.getProjectContextFromTreeNode(treeNode); + const project = ProjectsController.getProjectFromContext(treeNode); const newFolderName = await this.promptForNewObjectName(new templates.ProjectScriptType(templates.folder, constants.folderFriendlyName, ''), project); if (!newFolderName) { @@ -180,7 +237,7 @@ export class ProjectsController { } public async addItemPromptFromNode(treeNode: BaseProjectTreeItem, itemTypeName?: string) { - await this.addItemPrompt(this.getProjectContextFromTreeNode(treeNode), this.getRelativePath(treeNode), itemTypeName); + await this.addItemPrompt(ProjectsController.getProjectFromContext(treeNode), this.getRelativePath(treeNode), itemTypeName); } public async addItemPrompt(project: Project, relativePath: string, itemTypeName?: string) { @@ -223,6 +280,30 @@ export class ProjectsController { //#region Helper methods + public getDeployDialog(project: Project): DeployDatabaseDialog { + return new DeployDatabaseDialog(this.apiWrapper, project); + } + + private static getProjectFromContext(context: Project | BaseProjectTreeItem) { + if (context instanceof Project) { + return context; + } + + if (context.root instanceof ProjectRootTreeItem) { + return (context.root).project; + } + else { + throw new Error(constants.unexpectedProjectContext(context.uri.path)); + } + } + + private static async getDaxFxService(): Promise { + const ext: Extension = extensions.getExtension(mssql.extension.name)!; + + await ext.activate(); + return (ext.exports as mssql.IExtension).dacFx; + } + private macroExpansion(template: string, macroDict: Record): string { const macroIndicator = '@@'; let output = template; @@ -239,20 +320,6 @@ export class ProjectsController { return output; } - private getProjectContextFromTreeNode(treeNode: BaseProjectTreeItem): Project { - if (!treeNode) { - // TODO: prompt for which (currently-open) project when invoked via command pallet - throw new Error('TODO: prompt for which project when invoked via command pallet'); - } - - if (treeNode.root instanceof ProjectRootTreeItem) { - return (treeNode.root as ProjectRootTreeItem).project; - } - else { - throw new Error('Unable to establish project context. Command invoked from unexpected location: ' + treeNode.uri.path); - } - } - private async promptForNewObjectName(itemType: templates.ProjectScriptType, _project: Project): Promise { // TODO: ask project for suggested name that doesn't conflict const suggestedName = itemType.friendlyName.replace(new RegExp('\s', 'g'), '') + '1'; diff --git a/extensions/sql-database-projects/src/dialogs/deployDatabaseDialog.ts b/extensions/sql-database-projects/src/dialogs/deployDatabaseDialog.ts index a35029dcda..463daeb25d 100644 --- a/extensions/sql-database-projects/src/dialogs/deployDatabaseDialog.ts +++ b/extensions/sql-database-projects/src/dialogs/deployDatabaseDialog.ts @@ -4,14 +4,16 @@ *--------------------------------------------------------------------------------------------*/ import * as azdata from 'azdata'; +import * as vscode from 'vscode'; import * as constants from '../common/constants'; + import { Project } from '../models/project'; -import { DataSource } from '../models/dataSources/dataSources'; import { SqlConnectionDataSource } from '../models/dataSources/sqlConnectionStringSource'; import { ApiWrapper } from '../common/apiWrapper'; +import { IDeploymentProfile, IGenerateScriptProfile } from '../models/IDeploymentProfile'; interface DataSourceDropdownValue extends azdata.CategoryValue { - dataSource: DataSource; + dataSource: SqlConnectionDataSource; database: string; } @@ -30,6 +32,11 @@ export class DeployDatabaseDialog { private connection: azdata.connection.Connection | undefined; private connectionIsDataSource: boolean | undefined; + private toDispose: vscode.Disposable[] = []; + + public deploy: ((proj: Project, profile: IDeploymentProfile) => any) | undefined; + public generateScript: ((proj: Project, profile: IGenerateScriptProfile) => any) | undefined; + constructor(private apiWrapper: ApiWrapper, private project: Project) { this.dialog = azdata.window.createModelViewDialog(constants.deployDialogName); this.deployTab = azdata.window.createTab(constants.deployDialogName); @@ -39,12 +46,12 @@ export class DeployDatabaseDialog { this.initializeDialog(); this.dialog.okButton.label = constants.deployDialogOkButtonText; this.dialog.okButton.enabled = false; - this.dialog.okButton.onClick(async () => await this.deploy()); + this.toDispose.push(this.dialog.okButton.onClick(async () => await this.deployClick())); this.dialog.cancelButton.label = constants.cancelButtonText; let generateScriptButton: azdata.window.Button = azdata.window.createButton(constants.generateScriptButtonText); - generateScriptButton.onClick(async () => await this.generateScript()); + this.toDispose.push(generateScriptButton.onClick(async () => await this.generateScriptClick())); generateScriptButton.enabled = false; this.dialog.customButtons = []; @@ -53,6 +60,9 @@ export class DeployDatabaseDialog { azdata.window.openDialog(this.dialog); } + private dispose(): void { + this.toDispose.forEach(disposable => disposable.dispose()); + } private initializeDialog(): void { this.initializeDeployTab(); @@ -104,17 +114,77 @@ export class DeployDatabaseDialog { }); } - private async deploy(): Promise { - // TODO: hook up with build and deploy + public async getConnectionUri(): Promise { // if target connection is a data source, have to check if already connected or if connection dialog needs to be opened + let connId: string; + + if (this.dataSourcesRadioButton?.checked) { + const dataSource = (this.dataSourcesDropDown!.value! as DataSourceDropdownValue).dataSource; + + const connProfile: azdata.IConnectionProfile = { + serverName: dataSource.server, + databaseName: dataSource.database, + connectionName: dataSource.name, + userName: dataSource.username, + password: dataSource.password, + authenticationType: dataSource.integratedSecurity ? 'Integrated' : 'SqlAuth', + savePassword: false, + providerName: 'MSSQL', + saveProfile: true, + id: dataSource.name + '-dataSource', + options: [] + }; + + if (dataSource.integratedSecurity) { + connId = (await azdata.connection.connect(connProfile, false, false)).connectionId; + } + else { + connId = (await azdata.connection.openConnectionDialog(undefined, connProfile)).connectionId; + } + } + else { + if (!this.connection) { + throw new Error('Connection not defined.'); + } + + connId = this.connection?.connectionId; + } + + return await azdata.connection.getUriForConnection(connId); } - private async generateScript(): Promise { - // TODO: hook up with build and generate script - // if target connection is a data source, have to check if already connected or if connection dialog needs to be opened + public async deployClick(): Promise { + const profile: IDeploymentProfile = { + databaseName: this.getTargetDatabaseName(), + upgradeExisting: true, + connectionUri: await this.getConnectionUri(), + sqlCmdVariables: this.project.sqlCmdVariables + }; + + await this.deploy!(this.project, profile); + + this.dispose(); azdata.window.closeDialog(this.dialog); } + public async generateScriptClick(): Promise { + const profile: IGenerateScriptProfile = { + databaseName: this.getTargetDatabaseName(), + connectionUri: await this.getConnectionUri() + }; + + if (this.generateScript) { + await this.generateScript!(this.project, profile); + } + + this.dispose(); + azdata.window.closeDialog(this.dialog); + } + + private getTargetDatabaseName(): string { + return this.targetDatabaseTextBox?.value ?? ''; + } + public getDefaultDatabaseName(): string { return this.project.projectFileName; } @@ -193,13 +263,13 @@ export class DeployDatabaseDialog { private createDataSourcesDropdown(view: azdata.ModelView): azdata.FormComponent { let dataSourcesValues: DataSourceDropdownValue[] = []; - this.project.dataSources.forEach(dataSource => { - const dbName: string = (dataSource as SqlConnectionDataSource).getSetting(constants.initialCatalogSetting); + this.project.dataSources.filter(d => d instanceof SqlConnectionDataSource).forEach(dataSource => { + const dbName: string = (dataSource as SqlConnectionDataSource).database; const displayName: string = `${dataSource.name}`; dataSourcesValues.push({ displayName: displayName, name: dataSource.name, - dataSource: dataSource, + dataSource: dataSource as SqlConnectionDataSource, database: dbName }); }); @@ -221,7 +291,7 @@ export class DeployDatabaseDialog { } private setDatabaseToSelectedDataSourceDatabase(): void { - if ((this.dataSourcesDropDown!.value).database) { + if ((this.dataSourcesDropDown!.value)?.database) { this.targetDatabaseTextBox!.value = (this.dataSourcesDropDown!.value).database; } } diff --git a/extensions/sql-database-projects/src/models/IDeploymentProfile.ts b/extensions/sql-database-projects/src/models/IDeploymentProfile.ts new file mode 100644 index 0000000000..26ff4bcd58 --- /dev/null +++ b/extensions/sql-database-projects/src/models/IDeploymentProfile.ts @@ -0,0 +1,17 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +export interface IDeploymentProfile { + databaseName: string; + connectionUri: string; + upgradeExisting: boolean; + sqlCmdVariables?: Record; +} + +export interface IGenerateScriptProfile { + databaseName: string; + connectionUri: string; + sqlCmdVariables?: Record; +} diff --git a/extensions/sql-database-projects/src/models/dataSources/sqlConnectionStringSource.ts b/extensions/sql-database-projects/src/models/dataSources/sqlConnectionStringSource.ts index 197b56d745..aa1ce86df3 100644 --- a/extensions/sql-database-projects/src/models/dataSources/sqlConnectionStringSource.ts +++ b/extensions/sql-database-projects/src/models/dataSources/sqlConnectionStringSource.ts @@ -25,6 +25,27 @@ export class SqlConnectionDataSource extends DataSource { return constants.sqlConnectionStringFriendly; } + public get server(): string { + return this.getSetting(constants.dataSourceSetting); + } + + public get database(): string { + return this.getSetting(constants.initialCatalogSetting); + } + + public get integratedSecurity(): boolean { + return this.getSetting(constants.integratedSecuritySetting).toLowerCase() === 'true'; + } + + public get username(): string { + return this.getSetting(constants.userIdSetting); + } + + public get password(): string { + // TODO: secure password storage; https://github.com/microsoft/azuredatastudio/issues/10561 + return this.getSetting(constants.passwordSetting); + } + constructor(name: string, connectionString: string) { super(name); @@ -38,12 +59,12 @@ export class SqlConnectionDataSource extends DataSource { throw new Error(constants.invalidSqlConnectionString); } - this.connectionStringComponents[split[0]] = split[1]; + this.connectionStringComponents[split[0].toLocaleLowerCase()] = split[1]; } } public getSetting(settingName: string): string { - return this.connectionStringComponents[settingName]; + return this.connectionStringComponents[settingName.toLocaleLowerCase()]; } public static fromJson(json: DataSourceJson): SqlConnectionDataSource { diff --git a/extensions/sql-database-projects/src/models/project.ts b/extensions/sql-database-projects/src/models/project.ts index 89a535df4f..251e93436e 100644 --- a/extensions/sql-database-projects/src/models/project.ts +++ b/extensions/sql-database-projects/src/models/project.ts @@ -19,6 +19,7 @@ export class Project { public projectFileName: string; public files: ProjectEntry[] = []; public dataSources: DataSource[] = []; + public sqlCmdVariables: Record = {}; public get projectFolderPath() { return path.dirname(this.projectFilePath); diff --git a/extensions/sql-database-projects/src/test/datasource.test.ts b/extensions/sql-database-projects/src/test/datasource.test.ts index 93d6c60a9a..cc492ab5b8 100644 --- a/extensions/sql-database-projects/src/test/datasource.test.ts +++ b/extensions/sql-database-projects/src/test/datasource.test.ts @@ -22,9 +22,9 @@ describe('Data Sources: DataSource operations', function (): void { should(dataSourceList[0].name).equal('Test Data Source 1'); should(dataSourceList[0].type).equal(sql.SqlConnectionDataSource.type); - should((dataSourceList[0] as sql.SqlConnectionDataSource).getSetting('Initial Catalog')).equal('testDb'); + should((dataSourceList[0] as sql.SqlConnectionDataSource).database).equal('testDb'); should(dataSourceList[1].name).equal('My Other Data Source'); - should((dataSourceList[1] as sql.SqlConnectionDataSource).getSetting('Integrated Security')).equal('False'); + should((dataSourceList[1] as sql.SqlConnectionDataSource).integratedSecurity).equal(false); }); }); diff --git a/extensions/sql-database-projects/src/test/project.test.ts b/extensions/sql-database-projects/src/test/project.test.ts index 89e8e78eb0..37af69aec3 100644 --- a/extensions/sql-database-projects/src/test/project.test.ts +++ b/extensions/sql-database-projects/src/test/project.test.ts @@ -19,7 +19,7 @@ describe('Project: sqlproj content operations', function (): void { }); beforeEach(async () => { - projFilePath = await testUtils.createTestSqlProj(baselines.openProjectFileBaseline); + projFilePath = await testUtils.createTestSqlProjFile(baselines.openProjectFileBaseline); }); it('Should read Project from sqlproj', async function (): Promise { diff --git a/extensions/sql-database-projects/src/test/projectController.test.ts b/extensions/sql-database-projects/src/test/projectController.test.ts index caa675064e..50a94bfe74 100644 --- a/extensions/sql-database-projects/src/test/projectController.test.ts +++ b/extensions/sql-database-projects/src/test/projectController.test.ts @@ -17,6 +17,9 @@ import { ProjectsController } from '../controllers/projectController'; import { promises as fs } from 'fs'; import { createContext, TestContext } from './testContext'; import { Project } from '../models/project'; +import { DeployDatabaseDialog } from '../dialogs/deployDatabaseDialog'; +import { ApiWrapper } from '../common/apiWrapper'; +import { IDeploymentProfile, IGenerateScriptProfile } from '../models/IDeploymentProfile'; let testContext: TestContext; @@ -27,43 +30,99 @@ describe('ProjectsController: project controller operations', function (): void await baselines.loadBaselines(); }); - it('Should create new sqlproj file with correct values', async function (): Promise { - const projController = new ProjectsController(testContext.apiWrapper.object, new SqlDatabaseProjectTreeViewProvider()); - const projFileDir = path.join(os.tmpdir(), `TestProject_${new Date().getTime()}`); + describe('Project file operations and prompting', function (): void { + it('Should create new sqlproj file with correct values', async function (): Promise { + const projController = new ProjectsController(testContext.apiWrapper.object, new SqlDatabaseProjectTreeViewProvider()); + const projFileDir = path.join(os.tmpdir(), `TestProject_${new Date().getTime()}`); - const projFilePath = await projController.createNewProject('TestProjectName', vscode.Uri.file(projFileDir), 'BA5EBA11-C0DE-5EA7-ACED-BABB1E70A575'); + const projFilePath = await projController.createNewProject('TestProjectName', vscode.Uri.file(projFileDir), 'BA5EBA11-C0DE-5EA7-ACED-BABB1E70A575'); - let projFileText = (await fs.readFile(projFilePath)).toString(); + let projFileText = (await fs.readFile(projFilePath)).toString(); - should(projFileText).equal(baselines.newProjectFileBaseline); - }); + should(projFileText).equal(baselines.newProjectFileBaseline); + }); - it('Should load Project and associated DataSources', async function (): Promise { - // setup test files - const folderPath = await testUtils.generateTestFolderPath(); - const sqlProjPath = await testUtils.createTestSqlProj(baselines.openProjectFileBaseline, folderPath); - await testUtils.createTestDataSources(baselines.openDataSourcesBaseline, folderPath); - - const projController = new ProjectsController(testContext.apiWrapper.object, new SqlDatabaseProjectTreeViewProvider()); - - const project = await projController.openProject(vscode.Uri.file(sqlProjPath)); - - should(project.files.length).equal(9); // detailed sqlproj tests in their own test file - should(project.dataSources.length).equal(2); // detailed datasources tests in their own test file - }); - - it('Should return silently when no object name provided', async function (): Promise { - for (const name of ['', ' ', undefined]) { - testContext.apiWrapper.reset(); - testContext.apiWrapper.setup(x => x.showInputBox(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve(name)); - testContext.apiWrapper.setup(x => x.showErrorMessage(TypeMoq.It.isAny())).returns((s) => { console.log('we throwin'); throw new Error(s); }); + it('Should load Project and associated DataSources', async function (): Promise { + // setup test files + const folderPath = await testUtils.generateTestFolderPath(); + const sqlProjPath = await testUtils.createTestSqlProjFile(baselines.openProjectFileBaseline, folderPath); + await testUtils.createTestDataSources(baselines.openDataSourcesBaseline, folderPath); const projController = new ProjectsController(testContext.apiWrapper.object, new SqlDatabaseProjectTreeViewProvider()); - const project = new Project('FakePath'); - should(project.files.length).equal(0); - await projController.addItemPrompt(new Project('FakePath'), '', templates.script); - should(project.files.length).equal(0, 'Expected to return without throwing an exception or adding a file when an empty/undefined name is provided.'); - } + const project = await projController.openProject(vscode.Uri.file(sqlProjPath)); + + should(project.files.length).equal(9); // detailed sqlproj tests in their own test file + should(project.dataSources.length).equal(2); // detailed datasources tests in their own test file + }); + + it('Should return silently when no SQL object name provided in prompts', async function (): Promise { + for (const name of ['', ' ', undefined]) { + testContext.apiWrapper.reset(); + testContext.apiWrapper.setup(x => x.showInputBox(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve(name)); + testContext.apiWrapper.setup(x => x.showErrorMessage(TypeMoq.It.isAny())).returns((s) => { throw new Error(s); }); + + const projController = new ProjectsController(testContext.apiWrapper.object, new SqlDatabaseProjectTreeViewProvider()); + const project = new Project('FakePath'); + + should(project.files.length).equal(0); + await projController.addItemPrompt(new Project('FakePath'), '', templates.script); + should(project.files.length).equal(0, 'Expected to return without throwing an exception or adding a file when an empty/undefined name is provided.'); + } + }); + }); + + describe('Deployment and deployment script generation', function (): void { + it('Deploy dialog should open from ProjectController', async function (): Promise { + let opened = false; + + let deployDialog = TypeMoq.Mock.ofType(DeployDatabaseDialog); + deployDialog.setup(x => x.openDialog()).returns(() => { opened = true; }); + + let projController = TypeMoq.Mock.ofType(ProjectsController); + projController.callBase = true; + projController.setup(x => x.getDeployDialog(TypeMoq.It.isAny())).returns(() => deployDialog.object); + + await projController.object.deployProject(new Project('FakePath')); + should(opened).equal(true); + }); + + it('Callbacks are hooked up and called from Deploy dialog', async function (): Promise { + const projPath = path.dirname(await testUtils.createTestSqlProjFile(baselines.openProjectFileBaseline)); + await testUtils.createTestDataSources(baselines.openDataSourcesBaseline, projPath); + const proj = new Project(projPath); + + const deployHoller = 'hello from callback for deploy()'; + const generateHoller = 'hello from callback for generateScript()'; + + let holler = 'nothing'; + + let deployDialog = TypeMoq.Mock.ofType(DeployDatabaseDialog, undefined, undefined, new ApiWrapper(), proj); + deployDialog.callBase = true; + deployDialog.setup(x => x.getConnectionUri()).returns(async () => 'fake|connection|uri'); + + let projController = TypeMoq.Mock.ofType(ProjectsController); + projController.callBase = true; + projController.setup(x => x.getDeployDialog(TypeMoq.It.isAny())).returns(() => deployDialog.object); + projController.setup(x => x.executionCallback(TypeMoq.It.isAny(), TypeMoq.It.is((_): _ is IDeploymentProfile => true))).returns(async () => { + holler = deployHoller; + return undefined; + }); + + projController.setup(x => x.executionCallback(TypeMoq.It.isAny(), TypeMoq.It.is((_): _ is IGenerateScriptProfile => true))).returns(async () => { + holler = generateHoller; + return undefined; + }); + + let dialog = await projController.object.deployProject(proj); + await dialog.deployClick(); + + should(holler).equal(deployHoller, 'executionCallback() is supposed to have been setup and called for Deploy scenario'); + + dialog = await projController.object.deployProject(proj); + await dialog.generateScriptClick(); + + should(holler).equal(generateHoller, 'executionCallback() is supposed to have been setup and called for GenerateScript scenario'); + }); }); }); diff --git a/extensions/sql-database-projects/src/test/testUtils.ts b/extensions/sql-database-projects/src/test/testUtils.ts index 2ae53a5ec8..648a4222cd 100644 --- a/extensions/sql-database-projects/src/test/testUtils.ts +++ b/extensions/sql-database-projects/src/test/testUtils.ts @@ -10,6 +10,7 @@ import * as constants from '../common/constants'; import { promises as fs } from 'fs'; import should = require('should'); import { AssertionError } from 'assert'; +import { Project } from '../models/project'; export async function shouldThrowSpecificError(block: Function, expectedMessage: string, details?: string) { let succeeded = false; @@ -26,10 +27,14 @@ export async function shouldThrowSpecificError(block: Function, expectedMessage: } } -export async function createTestSqlProj(contents: string, folderPath?: string): Promise { +export async function createTestSqlProjFile(contents: string, folderPath?: string): Promise { return await createTestFile(contents, 'TestProject.sqlproj', folderPath); } +export async function createTestProject(contents: string, folderPath?: string): Promise { + return new Project(await createTestSqlProjFile(contents, folderPath)); +} + export async function createTestDataSources(contents: string, folderPath?: string): Promise { return await createTestFile(contents, constants.dataSourcesFileName, folderPath); }