diff --git a/extensions/sql-database-projects/src/common/constants.ts b/extensions/sql-database-projects/src/common/constants.ts index a01be69b2e..61d1abdae5 100644 --- a/extensions/sql-database-projects/src/common/constants.ts +++ b/extensions/sql-database-projects/src/common/constants.ts @@ -41,6 +41,7 @@ export const databaseReferenceSameDatabase = localize('databaseReferenceSameData export const databaseReferenceDifferentDabaseSameServer = localize('databaseReferenceDifferentDabaseSameServer', "Different database, same server"); export const databaseReferenceDatabaseName = localize('databaseReferenceDatabaseName', "Database name"); export const dacpacFiles = localize('dacpacFiles', "dacpac Files"); +export const publishSettingsFiles = localize('publishSettingsFiles', "Publish Settings File"); export const systemDatabase = localize('systemDatabase', "System Database"); export function newObjectNamePrompt(objectType: string) { return localize('newObjectNamePrompt', 'New {0} name:', objectType); } @@ -61,6 +62,11 @@ export const connectionRadioButtonLabel = localize('connectionRadioButtonLabel', export const selectConnectionRadioButtonsTitle = localize('selectconnectionRadioButtonsTitle', "Specify connection from:"); export const dataSourceDropdownTitle = localize('dataSourceDropdownTitle', "Data source"); export const noDataSourcesText = localize('noDataSourcesText', "No data sources in this project"); +export const loadProfileButtonText = localize('loadProfileButtonText', "Load Profile..."); +export const profileWarningText = localize('profileWarningText', "⚠Warning: Only database name and SQLCMD variables are able to be loaded from a profile at this time"); +export const sqlCmdTableLabel = localize('sqlCmdTableLabel', "SQLCMD Variables"); +export const sqlCmdVariableColumn = localize('sqlCmdVariableColumn', "Variable"); +export const sqlCmdValueColumn = localize('sqlCmdValueColumn', "Value"); // Error messages @@ -146,6 +152,9 @@ export const NETFrameworkAssembly = 'Microsoft.NETFramework.ReferenceAssemblies' export const VersionNumber = '1.0.0'; export const All = 'All'; +// Profile XML names +export const targetDatabaseName = 'TargetDatabaseName'; + // SQL connection string components export const initialCatalogSetting = 'Initial Catalog'; export const dataSourceSetting = 'Data Source'; diff --git a/extensions/sql-database-projects/src/common/utils.ts b/extensions/sql-database-projects/src/common/utils.ts index e591805f62..d3f73e44b7 100644 --- a/extensions/sql-database-projects/src/common/utils.ts +++ b/extensions/sql-database-projects/src/common/utils.ts @@ -5,6 +5,7 @@ import * as vscode from 'vscode'; import * as os from 'os'; +import * as constants from '../common/constants'; import { promises as fs } from 'fs'; /** @@ -95,3 +96,20 @@ export function getSafeNonWindowsPath(filePath: string): string { filePath = filePath.split('\\').join('/').split('"').join(''); return '"' + filePath + '"'; } + +/** + * Read SQLCMD variables from xmlDoc and return them + * @param xmlDoc xml doc to read SQLCMD variables from. Format must be the same that sqlproj and publish profiles use + */ +export function readSqlCmdVariables(xmlDoc: any): Record { + let sqlCmdVariables: Record = {}; + for (let i = 0; i < xmlDoc.documentElement.getElementsByTagName(constants.SqlCmdVariable).length; i++) { + const sqlCmdVar = xmlDoc.documentElement.getElementsByTagName(constants.SqlCmdVariable)[i]; + const varName = sqlCmdVar.getAttribute(constants.Include); + + const varValue = sqlCmdVar.getElementsByTagName(constants.DefaultValue)[0].childNodes[0].nodeValue; + sqlCmdVariables[varName] = varValue; + } + + return sqlCmdVariables; +} diff --git a/extensions/sql-database-projects/src/controllers/projectController.ts b/extensions/sql-database-projects/src/controllers/projectController.ts index f681cf39cf..a5080afe4a 100644 --- a/extensions/sql-database-projects/src/controllers/projectController.ts +++ b/extensions/sql-database-projects/src/controllers/projectController.ts @@ -10,6 +10,7 @@ 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 * as xmldom from 'xmldom'; import { Uri, QuickPickItem, WorkspaceFolder, extensions, Extension } from 'vscode'; import { IConnectionProfile, TaskExecutionMode } from 'azdata'; @@ -19,7 +20,7 @@ import { DeployDatabaseDialog } from '../dialogs/deployDatabaseDialog'; import { Project, DatabaseReferenceLocation, SystemDatabase, TargetPlatform } from '../models/project'; import { SqlDatabaseProjectTreeViewProvider } from './databaseProjectTreeViewProvider'; import { FolderNode } from '../models/tree/fileFolderTreeItem'; -import { IDeploymentProfile, IGenerateScriptProfile } from '../models/IDeploymentProfile'; +import { IDeploymentProfile, IGenerateScriptProfile, PublishSettings } from '../models/IDeploymentProfile'; import { BaseProjectTreeItem } from '../models/tree/baseTreeItem'; import { ProjectRootTreeItem } from '../models/tree/projectTreeItem'; import { ImportDataModel } from '../models/api/import'; @@ -193,6 +194,7 @@ export class ProjectsController { deployDatabaseDialog.deploy = async (proj, prof) => await this.executionCallback(proj, prof); deployDatabaseDialog.generateScript = async (proj, prof) => await this.executionCallback(proj, prof); + deployDatabaseDialog.readPublishProfile = async (profileUri) => await this.readPublishProfile(profileUri); deployDatabaseDialog.openDialog(); @@ -216,6 +218,27 @@ export class ProjectsController { } } + public async readPublishProfile(profileUri: Uri): Promise { + const profileText = await fs.readFile(profileUri.fsPath); + const profileXmlDoc = new xmldom.DOMParser().parseFromString(profileText.toString()); + + // read target database name + let targetDbName: string = ''; + let targetDatabaseNameCount = profileXmlDoc.documentElement.getElementsByTagName(constants.targetDatabaseName).length; + if (targetDatabaseNameCount > 0) { + // if there is more than one TargetDatabaseName nodes, SSDT uses the name in the last one so we'll do the same here + targetDbName = profileXmlDoc.documentElement.getElementsByTagName(constants.targetDatabaseName)[targetDatabaseNameCount - 1].textContent; + } + + // get all SQLCMD variables to include from the profile + let sqlCmdVariables = utils.readSqlCmdVariables(profileXmlDoc); + + return { + databaseName: targetDbName, + sqlCmdVariables: sqlCmdVariables + }; + } + public async schemaCompare(treeNode: BaseProjectTreeItem): Promise { // check if schema compare extension is installed if (this.apiWrapper.getExtension(constants.schemaCompareExtensionId)) { diff --git a/extensions/sql-database-projects/src/dialogs/deployDatabaseDialog.ts b/extensions/sql-database-projects/src/dialogs/deployDatabaseDialog.ts index cd0f3cb7f3..af5e59ff20 100644 --- a/extensions/sql-database-projects/src/dialogs/deployDatabaseDialog.ts +++ b/extensions/sql-database-projects/src/dialogs/deployDatabaseDialog.ts @@ -28,15 +28,19 @@ export class DeployDatabaseDialog { private targetDatabaseTextBox: azdata.InputBoxComponent | undefined; private connectionsRadioButton: azdata.RadioButtonComponent | undefined; private dataSourcesRadioButton: azdata.RadioButtonComponent | undefined; + private loadProfileButton: azdata.ButtonComponent | undefined; + private sqlCmdVariablesTable: azdata.TableComponent | undefined; private formBuilder: azdata.FormBuilder | undefined; private connection: azdata.connection.Connection | undefined; private connectionIsDataSource: boolean | undefined; + private profileSqlCmdVars: Record | undefined; private toDispose: vscode.Disposable[] = []; public deploy: ((proj: Project, profile: IDeploymentProfile) => any) | undefined; public generateScript: ((proj: Project, profile: IGenerateScriptProfile) => any) | undefined; + public readPublishProfile: ((profileUri: vscode.Uri) => any) | undefined; constructor(private apiWrapper: ApiWrapper, private project: Project) { this.dialog = azdata.window.createModelViewDialog(constants.deployDialogName); @@ -87,6 +91,21 @@ export class DeployDatabaseDialog { this.tryEnableGenerateScriptAndOkButtons(); }); + this.loadProfileButton = this.createLoadProfileButton(view); + this.sqlCmdVariablesTable = view.modelBuilder.table().withProperties({ + title: constants.sqlCmdTableLabel, + data: this.convertSqlCmdVarsToTableFormat(this.project.sqlCmdVariables), + columns: [ + { + value: constants.sqlCmdVariableColumn + }, + { + value: constants.sqlCmdValueColumn, + }], + width: 400, + height: 400 + }).component(); + this.formBuilder = view.modelBuilder.formContainer() .withFormItems([ { @@ -100,6 +119,10 @@ export class DeployDatabaseDialog { { title: constants.databaseNameLabel, component: this.targetDatabaseTextBox + }, + { + title: constants.profileWarningText, + component: this.loadProfileButton } ] } @@ -110,6 +133,15 @@ export class DeployDatabaseDialog { width: '100%' }); + // add SQLCMD variables table if the project has any + if (Object.keys(this.project.sqlCmdVariables).length > 0) { + this.formBuilder.insertFormItem({ + title: constants.sqlCmdTableLabel, + component: this.sqlCmdVariablesTable + }, + 6); + } + let formModel = this.formBuilder.component(); await view.initializeModel(formModel); }); @@ -160,11 +192,12 @@ export class DeployDatabaseDialog { } public async deployClick(): Promise { + const sqlCmdVars = this.getSqlCmdVariablesForDeploy(); const profile: IDeploymentProfile = { databaseName: this.getTargetDatabaseName(), upgradeExisting: true, connectionUri: await this.getConnectionUri(), - sqlCmdVariables: this.project.sqlCmdVariables + sqlCmdVariables: sqlCmdVars }; this.apiWrapper.closeDialog(this.dialog); @@ -174,10 +207,11 @@ export class DeployDatabaseDialog { } public async generateScriptClick(): Promise { + const sqlCmdVars = this.getSqlCmdVariablesForDeploy(); const profile: IGenerateScriptProfile = { databaseName: this.getTargetDatabaseName(), connectionUri: await this.getConnectionUri(), - sqlCmdVariables: this.project.sqlCmdVariables + sqlCmdVariables: sqlCmdVars }; this.apiWrapper.closeDialog(this.dialog); @@ -189,6 +223,18 @@ export class DeployDatabaseDialog { this.dispose(); } + private getSqlCmdVariablesForDeploy(): Record { + // get SQLCMD variables from project + let sqlCmdVariables = { ...this.project.sqlCmdVariables }; + + // update with SQLCMD variables loaded from profile if there are any + for (const key in this.profileSqlCmdVars) { + sqlCmdVariables[key] = this.profileSqlCmdVars[key]; + } + + return sqlCmdVariables; + } + public getTargetDatabaseName(): string { return this.targetDatabaseTextBox?.value ?? ''; } @@ -344,6 +390,64 @@ export class DeployDatabaseDialog { return clearButton; } + private createLoadProfileButton(view: azdata.ModelView): azdata.ButtonComponent { + let loadProfileButton: azdata.ButtonComponent = view.modelBuilder.button().withProperties({ + label: constants.loadProfileButtonText, + title: constants.loadProfileButtonText, + ariaLabel: constants.loadProfileButtonText, + width: '120px' + }).component(); + + loadProfileButton.onDidClick(async () => { + const fileUris = await this.apiWrapper.showOpenDialog( + { + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: false, + defaultUri: vscode.Uri.parse(this.project.projectFolderPath), + filters: { + [constants.publishSettingsFiles]: ['publish.xml'] + } + } + ); + + if (!fileUris || fileUris.length === 0) { + return; + } + + if (this.readPublishProfile) { + const result = await this.readPublishProfile(fileUris[0]); + (this.targetDatabaseTextBox).value = result.databaseName; + this.profileSqlCmdVars = result.sqlCmdVariables; + const data = this.convertSqlCmdVarsToTableFormat(this.getSqlCmdVariablesForDeploy()); + + (this.sqlCmdVariablesTable).updateProperties({ + data: data + }); + + // add SQLCMD Variables table if it wasn't there before + if (Object.keys(this.project.sqlCmdVariables).length === 0) { + this.formBuilder?.insertFormItem({ + title: constants.sqlCmdTableLabel, + component: this.sqlCmdVariablesTable + }, + 6); + } + } + }); + + return loadProfileButton; + } + + private convertSqlCmdVarsToTableFormat(sqlCmdVars: Record): string[][] { + let data = []; + for (let key in sqlCmdVars) { + data.push([key, sqlCmdVars[key]]); + } + + return data; + } + // only enable Generate Script and Ok buttons if all fields are filled private tryEnableGenerateScriptAndOkButtons(): void { if (this.targetConnectionTextBox!.value && this.targetDatabaseTextBox!.value diff --git a/extensions/sql-database-projects/src/models/IDeploymentProfile.ts b/extensions/sql-database-projects/src/models/IDeploymentProfile.ts index 26ff4bcd58..9ddf9561da 100644 --- a/extensions/sql-database-projects/src/models/IDeploymentProfile.ts +++ b/extensions/sql-database-projects/src/models/IDeploymentProfile.ts @@ -15,3 +15,8 @@ export interface IGenerateScriptProfile { connectionUri: string; sqlCmdVariables?: Record; } + +export interface PublishSettings { + databaseName: string; + sqlCmdVariables: Record; +} diff --git a/extensions/sql-database-projects/src/models/project.ts b/extensions/sql-database-projects/src/models/project.ts index 63a4e3d791..3823d2f5c8 100644 --- a/extensions/sql-database-projects/src/models/project.ts +++ b/extensions/sql-database-projects/src/models/project.ts @@ -64,13 +64,7 @@ export class Project { } // find all SQLCMD variables to include - for (let i = 0; i < this.projFileXmlDoc.documentElement.getElementsByTagName(constants.SqlCmdVariable).length; i++) { - const sqlCmdVar = this.projFileXmlDoc.documentElement.getElementsByTagName(constants.SqlCmdVariable)[i]; - const varName = sqlCmdVar.getAttribute(constants.Include); - - const varValue = sqlCmdVar.getElementsByTagName(constants.DefaultValue)[0].childNodes[0].nodeValue; - this.sqlCmdVariables[varName] = varValue; - } + this.sqlCmdVariables = utils.readSqlCmdVariables(this.projFileXmlDoc); // find all database references to include for (let r = 0; r < this.projFileXmlDoc.documentElement.getElementsByTagName(constants.ArtifactReference).length; r++) { diff --git a/extensions/sql-database-projects/src/test/baselines/baselines.ts b/extensions/sql-database-projects/src/test/baselines/baselines.ts index 9ff492927c..1f54144e53 100644 --- a/extensions/sql-database-projects/src/test/baselines/baselines.ts +++ b/extensions/sql-database-projects/src/test/baselines/baselines.ts @@ -19,6 +19,7 @@ export let SSDTUpdatedProjectAfterSystemDbUpdateBaselineWindows: string; export let SSDTUpdatedProjectAfterSystemDbUpdateBaseline: string; export let SSDTProjectBaselineWithCleanTarget: string; export let SSDTProjectBaselineWithCleanTargetAfterUpdate: string; +export let publishProfileBaseline: string; const baselineFolderPath = __dirname; @@ -35,6 +36,7 @@ export async function loadBaselines() { SSDTUpdatedProjectAfterSystemDbUpdateBaseline = await loadBaseline(baselineFolderPath, 'SSDTUpdatedProjectAfterSystemDbUpdateBaseline.xml'); SSDTProjectBaselineWithCleanTarget = await loadBaseline(baselineFolderPath, 'SSDTProjectBaselineWithCleanTarget.xml'); SSDTProjectBaselineWithCleanTargetAfterUpdate = await loadBaseline(baselineFolderPath, 'SSDTProjectBaselineWithCleanTargetAfterUpdate.xml'); + publishProfileBaseline = await loadBaseline(baselineFolderPath, 'publishProfileBaseline.publish.xml'); } async function loadBaseline(baselineFolderPath: string, fileName: string): Promise { diff --git a/extensions/sql-database-projects/src/test/baselines/publishProfileBaseline.publish.xml b/extensions/sql-database-projects/src/test/baselines/publishProfileBaseline.publish.xml new file mode 100644 index 0000000000..1c6750dcc2 --- /dev/null +++ b/extensions/sql-database-projects/src/test/baselines/publishProfileBaseline.publish.xml @@ -0,0 +1,15 @@ + + + + True + targetDb + DatabaseProject1.sql + 1 + + + + MyProdDatabase + $(SqlCmdVar__1) + + + diff --git a/extensions/sql-database-projects/src/test/projectController.test.ts b/extensions/sql-database-projects/src/test/projectController.test.ts index 5caecd4c51..11bcfb9107 100644 --- a/extensions/sql-database-projects/src/test/projectController.test.ts +++ b/extensions/sql-database-projects/src/test/projectController.test.ts @@ -117,6 +117,7 @@ describe.skip('ProjectsController: project controller operations', function (): const deployHoller = 'hello from callback for deploy()'; const generateHoller = 'hello from callback for generateScript()'; + const profileHoller = 'hello from callback for readPublishProfile()'; let holler = 'nothing'; @@ -131,6 +132,13 @@ describe.skip('ProjectsController: project controller operations', function (): holler = deployHoller; return undefined; }); + projController.setup(x => x.readPublishProfile(TypeMoq.It.isAny())).returns(async () => { + holler = profileHoller; + return { + databaseName: '', + sqlCmdVariables: {} + }; + }); projController.setup(x => x.executionCallback(TypeMoq.It.isAny(), TypeMoq.It.is((_): _ is IGenerateScriptProfile => true))).returns(async () => { holler = generateHoller; @@ -146,6 +154,22 @@ describe.skip('ProjectsController: project controller operations', function (): await dialog.generateScriptClick(); should(holler).equal(generateHoller, 'executionCallback() is supposed to have been setup and called for GenerateScript scenario'); + + dialog = await projController.object.deployProject(proj); + await projController.object.readPublishProfile(vscode.Uri.parse('test')); + + should(holler).equal(profileHoller, 'executionCallback() is supposed to have been setup and called for ReadPublishProfile scenario'); + }); + + it('Should read database name and SQLCMD variables from publish profile', async function (): Promise { + await baselines.loadBaselines(); + let profilePath = await testUtils.createTestFile(baselines.publishProfileBaseline, 'publishProfile.publish.xml'); + const projController = new ProjectsController(testContext.apiWrapper.object, new SqlDatabaseProjectTreeViewProvider()); + + let result = await projController.readPublishProfile(vscode.Uri.parse(profilePath)); + should(result.databaseName).equal('targetDb'); + should(Object.keys(result.sqlCmdVariables).length).equal(1); + should(result.sqlCmdVariables['ProdDatabaseName']).equal('MyProdDatabase'); }); }); }); diff --git a/extensions/sql-database-projects/src/test/testUtils.ts b/extensions/sql-database-projects/src/test/testUtils.ts index 4715c2d555..10240fcb1d 100644 --- a/extensions/sql-database-projects/src/test/testUtils.ts +++ b/extensions/sql-database-projects/src/test/testUtils.ts @@ -49,7 +49,7 @@ export async function generateTestFolderPath(): Promise { return folderPath; } -async function createTestFile(contents: string, fileName: string, folderPath?: string): Promise { +export async function createTestFile(contents: string, fileName: string, folderPath?: string): Promise { folderPath = folderPath ?? await generateTestFolderPath(); const filePath = path.join(folderPath, fileName);