diff --git a/extensions/sql-database-projects/src/common/constants.ts b/extensions/sql-database-projects/src/common/constants.ts index 85400a64cd..9b4c3dd184 100644 --- a/extensions/sql-database-projects/src/common/constants.ts +++ b/extensions/sql-database-projects/src/common/constants.ts @@ -14,6 +14,7 @@ const localize = nls.loadMessageBundle(); export const dataSourcesFileName = 'datasources.json'; export const sqlprojExtension = '.sqlproj'; export const sqlFileExtension = '.sql'; +export const publishProfileExtension = '.publish.xml'; export const openApiSpecFileExtensions = ['yaml', 'yml', 'json']; export const schemaCompareExtensionId = 'microsoft.schema-compare'; export const master = 'master'; @@ -485,6 +486,7 @@ export const ImportElements = localize('importElements', "Import Elements"); export const ProjectReferenceNameElement = localize('projectReferenceNameElement', "Project reference name element"); export const ProjectReferenceElement = localize('projectReferenceElement', "Project reference"); export const DacpacReferenceElement = localize('dacpacReferenceElement', "Dacpac reference"); +export const PublishProfileElements = localize('publishProfileElements', "Publish profile elements"); /** Name of the property item in the project file that defines default database collation. */ export const DefaultCollationProperty = 'DefaultCollation'; diff --git a/extensions/sql-database-projects/src/common/utils.ts b/extensions/sql-database-projects/src/common/utils.ts index dc9341d863..e6839b03bb 100644 --- a/extensions/sql-database-projects/src/common/utils.ts +++ b/extensions/sql-database-projects/src/common/utils.ts @@ -771,3 +771,13 @@ export function isValidBasename(name?: string): boolean { export function isValidBasenameErrorMessage(name?: string): string { return getDataWorkspaceExtensionApi().isValidBasenameErrorMessage(name); } + +/** + * Checks if the provided file is a publish profile + * @param fileName filename to check + * @returns True if it is a publish profile, otherwise false + */ +export function isPublishProfile(fileName: string): boolean { + const hasPublishExtension = fileName.trim().toLowerCase().endsWith(constants.publishProfileExtension); + return hasPublishExtension; +} diff --git a/extensions/sql-database-projects/src/controllers/projectController.ts b/extensions/sql-database-projects/src/controllers/projectController.ts index 18361ed0d3..163c69b95a 100644 --- a/extensions/sql-database-projects/src/controllers/projectController.ts +++ b/extensions/sql-database-projects/src/controllers/projectController.ts @@ -1444,7 +1444,7 @@ export class ProjectsController { if (fileOrFolder) { // use relative path and not tree paths for files and folder - const allFileEntries = project.files.concat(project.preDeployScripts).concat(project.postDeployScripts).concat(project.noneDeployScripts); + const allFileEntries = project.files.concat(project.preDeployScripts).concat(project.postDeployScripts).concat(project.noneDeployScripts).concat(project.publishProfiles); // trim trailing slash since folders with and without a trailing slash are allowed in a sqlproj const trimmedUri = utils.trimChars(utils.getPlatformSafeFileEntryPath(utils.trimUri(fileOrFolder.projectFileUri, fileOrFolder.fileSystemUri)), '/'); diff --git a/extensions/sql-database-projects/src/dialogs/publishDatabaseDialog.ts b/extensions/sql-database-projects/src/dialogs/publishDatabaseDialog.ts index 18dbf3f561..92a36d12ef 100644 --- a/extensions/sql-database-projects/src/dialogs/publishDatabaseDialog.ts +++ b/extensions/sql-database-projects/src/dialogs/publishDatabaseDialog.ts @@ -947,6 +947,9 @@ export class PublishDatabaseDialog { this.profileUsed = true; this.publishProfileUri = filePath; + + await this.project.addPublishProfileToProjFile(filePath.fsPath); + void vscode.commands.executeCommand(constants.refreshDataWorkspaceCommand); //refresh data workspace to load the newly added profile to the tree }); return saveProfileAsButton; diff --git a/extensions/sql-database-projects/src/models/project.ts b/extensions/sql-database-projects/src/models/project.ts index 762e798904..26cc229e35 100644 --- a/extensions/sql-database-projects/src/models/project.ts +++ b/extensions/sql-database-projects/src/models/project.ts @@ -46,6 +46,7 @@ export class Project implements ISqlProject { private _isSdkStyleProject: boolean = false; // https://docs.microsoft.com/en-us/dotnet/core/project-sdk/overview private _outputPath: string = ''; private _configuration: Configuration = Configuration.Debug; + private _publishProfiles: FileProjectEntry[] = []; public get dacpacOutputPath(): string { return path.join(this.outputPath, `${this._projectFileName}.dacpac`); @@ -111,6 +112,10 @@ export class Project implements ISqlProject { return this._configuration; } + public get publishProfiles(): FileProjectEntry[] { + return this._publishProfiles; + } + private projFileXmlDoc: Document | undefined = undefined; constructor(projectFilePath: string) { @@ -153,6 +158,9 @@ export class Project implements ISqlProject { this._databaseReferences = this.readDatabaseReferences(); this._importedTargets = this.readImportedTargets(); + // get publish profiles specified in the sqlproj + this._publishProfiles = this.readPublishProfiles(); + // find all SQLCMD variables to include try { this._sqlCmdVariables = utils.readSqlCmdVariables(this.projFileXmlDoc, false); @@ -468,7 +476,7 @@ export class Project implements ISqlProject { const noneItems = itemGroup.getElementsByTagName(constants.None); for (let n = 0; n < noneItems.length; n++) { const includeAttribute = noneItems[n].getAttribute(constants.Include); - if (includeAttribute) { + if (includeAttribute && !utils.isPublishProfile(includeAttribute)) { noneDeployScripts.push(this.createFileProjectEntry(includeAttribute, EntryType.File)); } } @@ -505,6 +513,34 @@ export class Project implements ISqlProject { return noneRemoveScripts; } + /** + * + * @returns all the publish profiles (ending with *.publish.xml) specified as in the sqlproj + */ + private readPublishProfiles(): FileProjectEntry[] { + const publishProfiles: FileProjectEntry[] = []; + + for (let ig = 0; ig < this.projFileXmlDoc!.documentElement.getElementsByTagName(constants.ItemGroup).length; ig++) { + const itemGroup = this.projFileXmlDoc!.documentElement.getElementsByTagName(constants.ItemGroup)[ig]; + + // find all publish profile scripts to include + try { + const noneItems = itemGroup.getElementsByTagName(constants.None); + for (let n = 0; n < noneItems.length; n++) { + const includeAttribute = noneItems[n].getAttribute(constants.Include); + if (includeAttribute && utils.isPublishProfile(includeAttribute)) { + publishProfiles.push(this.createFileProjectEntry(includeAttribute, EntryType.File)); + } + } + } catch (e) { + void window.showErrorMessage(constants.errorReadingProject(constants.PublishProfileElements, this.projectFilePath)); + console.error(utils.getErrorMessage(e)); + } + } + + return publishProfiles; + } + private readDatabaseReferences(): IDatabaseReferenceProjectEntry[] { const databaseReferenceEntries: IDatabaseReferenceProjectEntry[] = []; @@ -949,18 +985,19 @@ export class Project implements ISqlProject { } public async exclude(entry: FileProjectEntry): Promise { - const toExclude: FileProjectEntry[] = this._files.concat(this._preDeployScripts).concat(this._postDeployScripts).concat(this._noneDeployScripts).filter(x => x.fsUri.fsPath.startsWith(entry.fsUri.fsPath)); + const toExclude: FileProjectEntry[] = this._files.concat(this._preDeployScripts).concat(this._postDeployScripts).concat(this._noneDeployScripts).concat(this._publishProfiles).filter(x => x.fsUri.fsPath.startsWith(entry.fsUri.fsPath)); await this.removeFromProjFile(toExclude); this._files = this._files.filter(x => !x.fsUri.fsPath.startsWith(entry.fsUri.fsPath)); this._preDeployScripts = this._preDeployScripts.filter(x => !x.fsUri.fsPath.startsWith(entry.fsUri.fsPath)); this._postDeployScripts = this._postDeployScripts.filter(x => !x.fsUri.fsPath.startsWith(entry.fsUri.fsPath)); this._noneDeployScripts = this._noneDeployScripts.filter(x => !x.fsUri.fsPath.startsWith(entry.fsUri.fsPath)); + this._publishProfiles = this._publishProfiles.filter(x => !x.fsUri.fsPath.startsWith(entry.fsUri.fsPath)); } public async deleteFileFolder(entry: FileProjectEntry): Promise { // compile a list of folder contents to delete; if entry is a file, contents will contain only itself - const toDeleteFiles: FileProjectEntry[] = this._files.concat(this._preDeployScripts).concat(this._postDeployScripts).concat(this._noneDeployScripts).filter(x => x.fsUri.fsPath.startsWith(entry.fsUri.fsPath) && x.type === EntryType.File); + const toDeleteFiles: FileProjectEntry[] = this._files.concat(this._preDeployScripts).concat(this._postDeployScripts).concat(this._noneDeployScripts).concat(this._publishProfiles).filter(x => x.fsUri.fsPath.startsWith(entry.fsUri.fsPath) && x.type === EntryType.File); const toDeleteFolders: FileProjectEntry[] = this._files.filter(x => x.fsUri.fsPath.startsWith(entry.fsUri.fsPath) && x.type === EntryType.Folder); await Promise.all(toDeleteFiles.map(x => fs.unlink(x.fsUri.fsPath))); @@ -1165,6 +1202,24 @@ export class Project implements ISqlProject { return this.getCollectionProjectPropertyValue(constants.DatabaseSource); } + /** + * Adds publish profile to the project + * + * @param relativeFilePath Relative path of the file + */ + public async addPublishProfileToProjFile(absolutePublishProfilePath: string): Promise { + const relativePublishProfilePath = (utils.trimUri(Uri.file(this.projectFilePath), Uri.file(absolutePublishProfilePath))); + + // Update sqlproj XML + + const fileEntry = this.createFileProjectEntry(relativePublishProfilePath, EntryType.File); + this._publishProfiles.push(fileEntry); + + await this.addToProjFile(fileEntry, constants.None); + + return fileEntry; + } + public createFileProjectEntry(relativePath: string, entryType: EntryType, sqlObjectType?: string, containsCreateTableStatement?: boolean): FileProjectEntry { let platformSafeRelativePath = utils.getPlatformSafeFileEntryPath(relativePath); return new FileProjectEntry( @@ -1176,7 +1231,8 @@ export class Project implements ISqlProject { } private findOrCreateItemGroup(containedTag?: string, prePostScriptExist?: { scriptExist: boolean; }): Element { - let outputItemGroup = undefined; + let outputItemGroup: Element[] = []; // "None" can have more than one ItemGroup, for "None Include" (for pre/post deploy scripts and publish profiles), "None Remove" + let returnItemGroup; // search for a particular item goup if a child type is provided if (containedTag) { @@ -1184,25 +1240,45 @@ export class Project implements ISqlProject { for (let ig = 0; ig < this.projFileXmlDoc!.documentElement.getElementsByTagName(constants.ItemGroup).length; ig++) { const currentItemGroup = this.projFileXmlDoc!.documentElement.getElementsByTagName(constants.ItemGroup)[ig]; - // if we find the tag, use the ItemGroup if (currentItemGroup.getElementsByTagName(containedTag).length > 0) { - outputItemGroup = currentItemGroup; - break; + outputItemGroup.push(currentItemGroup); } } } // if none already exist, make a new ItemGroup for it - if (!outputItemGroup) { - outputItemGroup = this.projFileXmlDoc!.createElement(constants.ItemGroup); - this.projFileXmlDoc!.documentElement.appendChild(outputItemGroup); + if (outputItemGroup.length === 0) { + returnItemGroup = this.projFileXmlDoc!.createElement(constants.ItemGroup); + this.projFileXmlDoc!.documentElement.appendChild(returnItemGroup); if (prePostScriptExist) { prePostScriptExist.scriptExist = false; } + } else { // if item group exists and containedTag = None, read the content to find None Include with publish profile + if (containedTag === constants.None) { + for (let ig = 0; ig < outputItemGroup.length; ig++) { + const itemGroup = outputItemGroup[ig]; + + // find all none include scripts specified in the sqlproj + const noneItems = itemGroup.getElementsByTagName(constants.None); + for (let n = 0; n < noneItems.length; n++) { + let noneIncludeItem = noneItems[n].getAttribute(constants.Include); + if (noneIncludeItem && utils.isPublishProfile(noneIncludeItem)) { + returnItemGroup = itemGroup; + break; + } + } + } + if (!returnItemGroup) { + returnItemGroup = this.projFileXmlDoc!.createElement(constants.ItemGroup); + this.projFileXmlDoc!.documentElement.appendChild(returnItemGroup); + } + } else { + returnItemGroup = outputItemGroup[0]; // Return the first item group that was found, to match prior implementation + } } - return outputItemGroup; + return returnItemGroup; } private async addFileToProjFile(filePath: string, xmlTag: string, attributes?: Map): Promise { @@ -1220,6 +1296,8 @@ export class Project implements ISqlProject { void window.showInformationMessage(constants.deployScriptExists(xmlTag)); xmlTag = constants.None; // Add only one pre-deploy and post-deploy script. All additional ones get added in the same item group with None tag } + } else if (xmlTag === constants.None) { // Add publish profiles with None tag + itemGroup = this.findOrCreateItemGroup(xmlTag); } else { if (this.isSdkStyleProject) { @@ -1260,7 +1338,7 @@ export class Project implements ISqlProject { itemGroup.appendChild(newFileNode); } - private async removeFileFromProjFile(path: string): Promise { + private async removeFileFromProjFile(path: string): Promise {//TODO: publish profile const fileNodes = this.projFileXmlDoc!.documentElement.getElementsByTagName(constants.Build); const preDeployNodes = this.projFileXmlDoc!.documentElement.getElementsByTagName(constants.PreDeploy); const postDeployNodes = this.projFileXmlDoc!.documentElement.getElementsByTagName(constants.PostDeploy); diff --git a/extensions/sql-database-projects/src/models/tree/projectTreeItem.ts b/extensions/sql-database-projects/src/models/tree/projectTreeItem.ts index d80d3cf77d..3111aa511f 100644 --- a/extensions/sql-database-projects/src/models/tree/projectTreeItem.ts +++ b/extensions/sql-database-projects/src/models/tree/projectTreeItem.ts @@ -65,7 +65,8 @@ export class ProjectRootTreeItem extends BaseProjectTreeItem { let treeItemList = this.project.files .concat(this.project.preDeployScripts) .concat(this.project.postDeployScripts) - .concat(this.project.noneDeployScripts); + .concat(this.project.noneDeployScripts) + .concat(this.project.publishProfiles); for (const entry of treeItemList) { if (entry.type !== EntryType.File && entry.relativePath.startsWith(RelativeOuterPath)) { diff --git a/extensions/sql-database-projects/src/test/baselines/baselines.ts b/extensions/sql-database-projects/src/test/baselines/baselines.ts index b0a1da1d8c..d97089338d 100644 --- a/extensions/sql-database-projects/src/test/baselines/baselines.ts +++ b/extensions/sql-database-projects/src/test/baselines/baselines.ts @@ -40,6 +40,7 @@ export let openSdkStyleSqlProjectWithFilesSpecifiedBaseline: string; export let openSdkStyleSqlProjectWithGlobsSpecifiedBaseline: string; export let openSdkStyleSqlProjectWithBuildRemoveBaseline: string; export let openSdkStyleSqlProjectNoProjectGuidBaseline: string; +export let openSqlProjectWithAdditionalPublishProfileBaseline: string; const baselineFolderPath = __dirname; @@ -77,6 +78,7 @@ export async function loadBaselines() { openSdkStyleSqlProjectWithGlobsSpecifiedBaseline = await loadBaseline(baselineFolderPath, 'openSdkStyleSqlProjectWithGlobsSpecifiedBaseline.xml'); openSdkStyleSqlProjectWithBuildRemoveBaseline = await loadBaseline(baselineFolderPath, 'openSdkStyleSqlProjectWithBuildRemoveBaseline.xml'); openSdkStyleSqlProjectNoProjectGuidBaseline = await loadBaseline(baselineFolderPath, 'openSdkStyleSqlProjectNoProjectGuidBaseline.xml'); + openSqlProjectWithAdditionalPublishProfileBaseline = await loadBaseline(baselineFolderPath, 'openSqlProjectWithAdditionalPublishProfileBaseline.xml'); } async function loadBaseline(baselineFolderPath: string, fileName: string): Promise { diff --git a/extensions/sql-database-projects/src/test/baselines/openSqlProjectBaseline.xml b/extensions/sql-database-projects/src/test/baselines/openSqlProjectBaseline.xml index 8e0a321c0d..08548741d2 100644 --- a/extensions/sql-database-projects/src/test/baselines/openSqlProjectBaseline.xml +++ b/extensions/sql-database-projects/src/test/baselines/openSqlProjectBaseline.xml @@ -105,6 +105,11 @@ + + + + + diff --git a/extensions/sql-database-projects/src/test/baselines/openSqlProjectWithAdditionalPublishProfileBaseline.xml b/extensions/sql-database-projects/src/test/baselines/openSqlProjectWithAdditionalPublishProfileBaseline.xml new file mode 100644 index 0000000000..3c7e532944 --- /dev/null +++ b/extensions/sql-database-projects/src/test/baselines/openSqlProjectWithAdditionalPublishProfileBaseline.xml @@ -0,0 +1,117 @@ + + + + Debug + AnyCPU + TestProjectName + 2.0 + 4.1 + {BA5EBA11-C0DE-5EA7-ACED-BABB1E70A575} + Microsoft.Data.Tools.Schema.Sql.Sql150DatabaseSchemaProvider + Database + + + TestProjectName + TestProjectName + 1033, CI + BySchemaAndSchemaType + True + v4.5 + CS + Properties + False + True + True + + + bin\Release\ + $(MSBuildProjectName).sql + False + pdbonly + true + false + true + prompt + 4 + + + bin\Debug\ + $(MSBuildProjectName).sql + false + true + full + false + true + true + prompt + 4 + + + 11.0 + + True + 11.0 + + + + + + + + + + + + + + + + + + + + + + + + + + + + + MyProdDatabase + $(SqlCmdVar__1) + + + MyBackupDatabase + $(SqlCmdVar__2) + + + + + False + master + + + False + master + + + + + + + + + + + + + + + + + + + + diff --git a/extensions/sql-database-projects/src/test/baselines/openSqlProjectWithAdditionalSqlCmdVariablesBaseline.xml b/extensions/sql-database-projects/src/test/baselines/openSqlProjectWithAdditionalSqlCmdVariablesBaseline.xml index 173e4f2b62..372a7cb90a 100644 --- a/extensions/sql-database-projects/src/test/baselines/openSqlProjectWithAdditionalSqlCmdVariablesBaseline.xml +++ b/extensions/sql-database-projects/src/test/baselines/openSqlProjectWithAdditionalSqlCmdVariablesBaseline.xml @@ -109,6 +109,11 @@ + + + + + diff --git a/extensions/sql-database-projects/src/test/project.test.ts b/extensions/sql-database-projects/src/test/project.test.ts index d9c2ede984..67dbd443aa 100644 --- a/extensions/sql-database-projects/src/test/project.test.ts +++ b/extensions/sql-database-projects/src/test/project.test.ts @@ -66,6 +66,12 @@ describe('Project: sqlproj content operations', function (): void { should(project.postDeployScripts.find(f => f.type === EntryType.File && f.relativePath === 'Script.PostDeployment1.sql')).not.equal(undefined, 'File Script.PostDeployment1.sql not read'); should(project.noneDeployScripts.find(f => f.type === EntryType.File && f.relativePath === 'Script.PreDeployment2.sql')).not.equal(undefined, 'File Script.PostDeployment2.sql not read'); should(project.noneDeployScripts.find(f => f.type === EntryType.File && f.relativePath === 'Tables\\Script.PostDeployment1.sql')).not.equal(undefined, 'File Tables\\Script.PostDeployment1.sql not read'); + + // Publish profiles + should(project.publishProfiles.length).equal(3); + should(project.publishProfiles.find(f => f.type === EntryType.File && f.relativePath === 'TestProjectName_1.publish.xml')).not.equal(undefined, 'Profile TestProjectName_1.publish.xml not read'); + should(project.publishProfiles.find(f => f.type === EntryType.File && f.relativePath === 'TestProjectName_2.publish.xml')).not.equal(undefined, 'Profile TestProjectName_2.publish.xml not read'); + should(project.publishProfiles.find(f => f.type === EntryType.File && f.relativePath === 'TestProjectName_3.publish.xml')).not.equal(undefined, 'Profile TestProjectName_3.publish.xml not read'); }); it('Should read Project with Project reference from sqlproj', async function (): Promise { @@ -1592,6 +1598,30 @@ describe('Project: add SQLCMD Variables', function (): void { }); }); +describe('Project: add publish profiles', function (): void { + before(async function (): Promise { + await baselines.loadBaselines(); + }); + + after(async function (): Promise { + await testUtils.deleteGeneratedTestFolder(); + }); + + it('Should update .sqlproj with new publish profiles', async function (): Promise { + projFilePath = await testUtils.createTestSqlProjFile(baselines.openProjectFileBaseline); + const project = await Project.openProject(projFilePath); + should(Object.keys(project.publishProfiles).length).equal(3); + + // add a new publish profile + await project.addPublishProfileToProjFile(path.join(projFilePath, 'TestProjectName_4.publish.xml')); + + should(Object.keys(project.publishProfiles).length).equal(4); + + const projFileText = (await fs.readFile(projFilePath)).toString(); + should(projFileText).equal(baselines.openSqlProjectWithAdditionalPublishProfileBaseline.trim()); + }); +}); + describe('Project: properties', function (): void { before(async function (): Promise { await baselines.loadBaselines();