diff --git a/extensions/sql-database-projects/package.json b/extensions/sql-database-projects/package.json index d60d040821..c83cc38005 100644 --- a/extensions/sql-database-projects/package.json +++ b/extensions/sql-database-projects/package.json @@ -69,6 +69,16 @@ "title": "%sqlDatabaseProjects.newScript%", "category": "%sqlDatabaseProjects.displayName%" }, + { + "command": "sqlDatabaseProjects.newPreDeploymentScript", + "title": "%sqlDatabaseProjects.newPreDeploymentScript%", + "category": "%sqlDatabaseProjects.displayName%" + }, + { + "command": "sqlDatabaseProjects.newPostDeploymentScript", + "title": "%sqlDatabaseProjects.newPostDeploymentScript%", + "category": "%sqlDatabaseProjects.displayName%" + }, { "command": "sqlDatabaseProjects.newTable", "title": "%sqlDatabaseProjects.newTable%", @@ -157,6 +167,14 @@ "command": "sqlDatabaseProjects.newScript", "when": "false" }, + { + "command": "sqlDatabaseProjects.newPreDeploymentScript", + "when": "false" + }, + { + "command": "sqlDatabaseProjects.newPostDeploymentScript", + "when": "false" + }, { "command": "sqlDatabaseProjects.newTable", "when": "false" @@ -253,6 +271,16 @@ { "command": "sqlDatabaseProjects.newScript", "when": "view == sqlDatabaseProjectsView && viewItem == databaseProject.itemType.project || viewItem == databaseProject.itemType.folder", + "group": "3_dbProjects_newItem@7" + }, + { + "command": "sqlDatabaseProjects.newPreDeploymentScript", + "when": "view == sqlDatabaseProjectsView && viewItem == databaseProject.itemType.project || viewItem == databaseProject.itemType.folder", + "group": "3_dbProjects_newItem@8" + }, + { + "command": "sqlDatabaseProjects.newPostDeploymentScript", + "when": "view == sqlDatabaseProjectsView && viewItem == databaseProject.itemType.project || viewItem == databaseProject.itemType.folder", "group": "3_dbProjects_newItem@9" }, { diff --git a/extensions/sql-database-projects/package.nls.json b/extensions/sql-database-projects/package.nls.json index 70089a4e90..6e7c9092a0 100644 --- a/extensions/sql-database-projects/package.nls.json +++ b/extensions/sql-database-projects/package.nls.json @@ -15,6 +15,8 @@ "sqlDatabaseProjects.exclude": "Exclude from project", "sqlDatabaseProjects.newScript": "Add Script", + "sqlDatabaseProjects.newPreDeploymentScript": "Add Pre-Deployment Script", + "sqlDatabaseProjects.newPostDeploymentScript": "Add Post-Deployment Script", "sqlDatabaseProjects.newTable": "Add Table", "sqlDatabaseProjects.newView": "Add View", "sqlDatabaseProjects.newStoredProcedure": "Add Stored Procedure", diff --git a/extensions/sql-database-projects/resources/templates/newTsqlPostDeployScriptTemplate.sql b/extensions/sql-database-projects/resources/templates/newTsqlPostDeployScriptTemplate.sql new file mode 100644 index 0000000000..92821e34d0 --- /dev/null +++ b/extensions/sql-database-projects/resources/templates/newTsqlPostDeployScriptTemplate.sql @@ -0,0 +1 @@ +-- This file contains SQL statements that will be executed after the build script. diff --git a/extensions/sql-database-projects/resources/templates/newTsqlPreDeployScriptTemplate.sql b/extensions/sql-database-projects/resources/templates/newTsqlPreDeployScriptTemplate.sql new file mode 100644 index 0000000000..f7b93f5b78 --- /dev/null +++ b/extensions/sql-database-projects/resources/templates/newTsqlPreDeployScriptTemplate.sql @@ -0,0 +1 @@ +-- This file contains SQL statements that will be executed before the build script. diff --git a/extensions/sql-database-projects/src/common/constants.ts b/extensions/sql-database-projects/src/common/constants.ts index 02c1b96902..3baa4b7c49 100644 --- a/extensions/sql-database-projects/src/common/constants.ts +++ b/extensions/sql-database-projects/src/common/constants.ts @@ -127,12 +127,12 @@ export function folderAlreadyExists(filename: string) { return localize('folderA export function invalidInput(input: string) { return localize('invalidInput', "Invalid input: {0}", input); } export function unableToCreatePublishConnection(input: string) { return localize('unableToCreatePublishConnection', "Unable to construct connection: {0}", input); } export function circularProjectReference(project1: string, project2: string) { return localize('cicularProjectReference', "Circular reference from project {0} to project {1}", project1, project2); } - 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); } export function unableToPerformAction(action: string, uri: string) { return localize('unableToPerformAction', "Unable to locate '{0}' target: '{1}'", action, uri); } export function unableToFindObject(path: string, objType: string) { return localize('unableToFindFile', "Unable to find {1} with path '{0}'", path, objType); } +export function deployScriptExists(scriptType: string) { return localize('deployScriptExists', "A {0} script already exists. The new script will not be included in build.", scriptType); } // Action types export const deleteAction = localize('deleteAction', 'Delete'); @@ -149,6 +149,8 @@ export const scriptFriendlyName = localize('scriptFriendlyName', "Script"); export const tableFriendlyName = localize('tableFriendlyName', "Table"); export const viewFriendlyName = localize('viewFriendlyName', "View"); export const storedProcedureFriendlyName = localize('storedProcedureFriendlyName', "Stored Procedure"); +export const preDeployScriptFriendlyName = localize('preDeployScriptFriendlyName', "Script.PreDeployment"); +export const postDeployScriptFriendlyName = localize('postDeployScriptFriendlyName', "Script.PostDeployment"); // SqlProj file XML names export const ItemGroup = 'ItemGroup'; diff --git a/extensions/sql-database-projects/src/controllers/mainController.ts b/extensions/sql-database-projects/src/controllers/mainController.ts index 2be3d3059a..7dabed1e1d 100644 --- a/extensions/sql-database-projects/src/controllers/mainController.ts +++ b/extensions/sql-database-projects/src/controllers/mainController.ts @@ -63,6 +63,8 @@ export default class MainController implements vscode.Disposable { vscode.commands.registerCommand('sqlDatabaseProjects.importDatabase', async (profile: azdata.IConnectionProfile) => { await this.projectsController.importNewDatabaseProject(profile); }); vscode.commands.registerCommand('sqlDatabaseProjects.newScript', async (node: BaseProjectTreeItem) => { await this.projectsController.addItemPromptFromNode(node, templates.script); }); + vscode.commands.registerCommand('sqlDatabaseProjects.newPreDeploymentScript', async (node: BaseProjectTreeItem) => { await this.projectsController.addItemPromptFromNode(node, templates.preDeployScript); }); + vscode.commands.registerCommand('sqlDatabaseProjects.newPostDeploymentScript', async (node: BaseProjectTreeItem) => { await this.projectsController.addItemPromptFromNode(node, templates.postDeployScript); }); vscode.commands.registerCommand('sqlDatabaseProjects.newTable', async (node: BaseProjectTreeItem) => { await this.projectsController.addItemPromptFromNode(node, templates.table); }); vscode.commands.registerCommand('sqlDatabaseProjects.newView', async (node: BaseProjectTreeItem) => { await this.projectsController.addItemPromptFromNode(node, templates.view); }); vscode.commands.registerCommand('sqlDatabaseProjects.newStoredProcedure', async (node: BaseProjectTreeItem) => { await this.projectsController.addItemPromptFromNode(node, templates.storedProcedure); }); diff --git a/extensions/sql-database-projects/src/controllers/projectController.ts b/extensions/sql-database-projects/src/controllers/projectController.ts index 22208f39de..810731e3b4 100644 --- a/extensions/sql-database-projects/src/controllers/projectController.ts +++ b/extensions/sql-database-projects/src/controllers/projectController.ts @@ -353,7 +353,7 @@ export class ProjectsController { throw new Error(constants.fileAlreadyExists(path.parse(absoluteFilePath).name)); } - const newEntry = await project.addScriptItem(relativeFilePath, newFileText); + const newEntry = await project.addScriptItem(relativeFilePath, newFileText, itemType.type); await vscode.commands.executeCommand(constants.vscodeOpenCommand, newEntry.fsUri); @@ -630,7 +630,7 @@ export class ProjectsController { 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'; + const suggestedName = itemType.friendlyName.replace(/\s+/g, '') + '1'; const itemObjectName = await vscode.window.showInputBox({ prompt: constants.newObjectNamePrompt(itemType.friendlyName), diff --git a/extensions/sql-database-projects/src/models/project.ts b/extensions/sql-database-projects/src/models/project.ts index 33909e279e..bdc26eb9e2 100644 --- a/extensions/sql-database-projects/src/models/project.ts +++ b/extensions/sql-database-projects/src/models/project.ts @@ -9,6 +9,7 @@ import * as constants from '../common/constants'; import * as utils from '../common/utils'; import * as xmlFormat from 'xml-formatter'; import * as os from 'os'; +import * as templates from '../templates/templates'; import { Uri, window } from 'vscode'; import { promises as fs } from 'fs'; @@ -215,7 +216,7 @@ export class Project { * @param relativeFilePath Relative path of the file * @param contents Contents to be written to the new file */ - public async addScriptItem(relativeFilePath: string, contents?: string): Promise { + public async addScriptItem(relativeFilePath: string, contents?: string, itemType?: string): Promise { const absoluteFilePath = path.join(this.projectFolderPath, relativeFilePath); if (contents) { @@ -230,9 +231,23 @@ export class Project { } const fileEntry = this.createProjectEntry(relativeFilePath, EntryType.File); - this.files.push(fileEntry); - await this.addToProjFile(fileEntry); + let xmlTag; + switch (itemType) { + case templates.preDeployScript: + xmlTag = constants.PreDeploy; + this.preDeployScripts.push(fileEntry); + break; + case templates.postDeployScript: + xmlTag = constants.PostDeploy; + this.postDeployScripts.push(fileEntry); + break; + default: + xmlTag = constants.Build; + this.files.push(fileEntry); + } + + await this.addToProjFile(fileEntry, xmlTag); return fileEntry; } @@ -335,7 +350,7 @@ export class Project { return new ProjectEntry(Uri.file(path.join(this.projectFolderPath, platformSafeRelativePath)), relativePath, entryType); } - private findOrCreateItemGroup(containedTag?: string): any { + private findOrCreateItemGroup(containedTag?: string, prePostScriptExist?: { scriptExist: boolean; }): any { let outputItemGroup = undefined; // search for a particular item goup if a child type is provided @@ -356,16 +371,32 @@ export class Project { if (!outputItemGroup) { outputItemGroup = this.projFileXmlDoc.createElement(constants.ItemGroup); this.projFileXmlDoc.documentElement.appendChild(outputItemGroup); + if (prePostScriptExist) { + prePostScriptExist.scriptExist = false; + } } return outputItemGroup; } - private addFileToProjFile(path: string) { - const newFileNode = this.projFileXmlDoc.createElement(constants.Build); - newFileNode.setAttribute(constants.Include, utils.convertSlashesForSqlProj(path)); + private addFileToProjFile(path: string, xmlTag: string) { + let itemGroup; - this.findOrCreateItemGroup(constants.Build).appendChild(newFileNode); + if (xmlTag === constants.PreDeploy || xmlTag === constants.PostDeploy) { + let prePostScriptExist = { scriptExist: true }; + itemGroup = this.findOrCreateItemGroup(xmlTag, prePostScriptExist); + if (prePostScriptExist.scriptExist === true) { + 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 { + itemGroup = this.findOrCreateItemGroup(xmlTag); + } + + const newFileNode = this.projFileXmlDoc.createElement(xmlTag); + newFileNode.setAttribute(constants.Include, utils.convertSlashesForSqlProj(path)); + itemGroup.appendChild(newFileNode); } private removeFileFromProjFile(path: string) { @@ -512,10 +543,10 @@ export class Project { } } - private async addToProjFile(entry: ProjectEntry) { + private async addToProjFile(entry: ProjectEntry, xmlTag?: string) { switch (entry.type) { case EntryType.File: - this.addFileToProjFile(entry.relativePath); + this.addFileToProjFile(entry.relativePath, xmlTag ? xmlTag : constants.Build); break; case EntryType.Folder: this.addFolderToProjFile(entry.relativePath); diff --git a/extensions/sql-database-projects/src/templates/templates.ts b/extensions/sql-database-projects/src/templates/templates.ts index 199cd79795..31ebc9f52e 100644 --- a/extensions/sql-database-projects/src/templates/templates.ts +++ b/extensions/sql-database-projects/src/templates/templates.ts @@ -16,6 +16,8 @@ export const table: string = 'table'; export const view: string = 'view'; export const storedProcedure: string = 'storedProcedure'; export const folder: string = 'folder'; +export const preDeployScript: string = 'preDeployScript'; +export const postDeployScript: string = 'postDeployScript'; // Object maps @@ -45,7 +47,9 @@ export async function loadTemplates(templateFolderPath: string) { loadObjectTypeInfo(script, constants.scriptFriendlyName, templateFolderPath, 'newTsqlScriptTemplate.sql'), loadObjectTypeInfo(table, constants.tableFriendlyName, templateFolderPath, 'newTsqlTableTemplate.sql'), loadObjectTypeInfo(view, constants.viewFriendlyName, templateFolderPath, 'newTsqlViewTemplate.sql'), - loadObjectTypeInfo(storedProcedure, constants.storedProcedureFriendlyName, templateFolderPath, 'newTsqlStoredProcedureTemplate.sql') + loadObjectTypeInfo(storedProcedure, constants.storedProcedureFriendlyName, templateFolderPath, 'newTsqlStoredProcedureTemplate.sql'), + loadObjectTypeInfo(preDeployScript, constants.preDeployScriptFriendlyName, templateFolderPath, 'newTsqlPreDeployScriptTemplate.sql'), + loadObjectTypeInfo(postDeployScript, constants.postDeployScriptFriendlyName, templateFolderPath, 'newTsqlPostDeployScriptTemplate.sql') ]); for (const scriptType of scriptTypes) { diff --git a/extensions/sql-database-projects/src/test/project.test.ts b/extensions/sql-database-projects/src/test/project.test.ts index 792a91aaf1..1170158bd7 100644 --- a/extensions/sql-database-projects/src/test/project.test.ts +++ b/extensions/sql-database-projects/src/test/project.test.ts @@ -7,6 +7,7 @@ import * as should from 'should'; import * as path from 'path'; import * as sinon from 'sinon'; import * as baselines from './baselines/baselines'; +import * as templates from '../templates/templates'; import * as testUtils from './testUtils'; import * as constants from '../common/constants'; @@ -152,7 +153,7 @@ describe('Project: sqlproj content operations', function (): void { project.changeDSP(TargetPlatform.SqlAzureV12.toString()); uri = project.getSystemDacpacUri(constants.masterDacpac); ssdtUri = project.getSystemDacpacSsdtUri(constants.masterDacpac); - should.equal(uri.fsPath, Uri.parse(path.join('$(NETCoreTargetsPath)', 'SystemDacpacs', 'AzureV12',constants.masterDacpac)).fsPath); + should.equal(uri.fsPath, Uri.parse(path.join('$(NETCoreTargetsPath)', 'SystemDacpacs', 'AzureV12', constants.masterDacpac)).fsPath); should.equal(ssdtUri.fsPath, Uri.parse(path.join('$(DacPacRootPath)', 'Extensions', 'Microsoft', 'SQLDB', 'Extensions', 'SqlServer', 'AzureV12', 'SqlSchemas', constants.masterDacpac)).fsPath); }); @@ -231,6 +232,58 @@ describe('Project: sqlproj content operations', function (): void { await testUtils.shouldThrowSpecificError(async () => await project.addDatabaseReference(Uri.parse('test.dacpac'), DatabaseReferenceLocation.sameDatabase), constants.databaseReferenceAlreadyExists); should(project.databaseReferences.length).equal(2, 'There should be two database references after trying to add a reference to test.dacpac again'); }); + + it('Should add pre and post deployment scripts as entries to sqlproj', async function (): Promise { + projFilePath = await testUtils.createTestSqlProjFile(baselines.newProjectFileBaseline); + const project: Project = await Project.openProject(projFilePath); + + const folderPath = 'Pre-Post Deployment Scripts'; + const preDeploymentScriptFilePath = path.join(folderPath, 'Script.PreDeployment1.sql'); + const postDeploymentScriptFilePath = path.join(folderPath, 'Script.PostDeployment1.sql'); + const fileContents = ' '; + + await project.addFolderItem(folderPath); + await project.addScriptItem(preDeploymentScriptFilePath, fileContents, templates.preDeployScript); + await project.addScriptItem(postDeploymentScriptFilePath, fileContents, templates.postDeployScript); + + const newProject = await Project.openProject(projFilePath); + + should(newProject.preDeployScripts.find(f => f.type === EntryType.File && f.relativePath === convertSlashesForSqlProj(preDeploymentScriptFilePath))).not.equal(undefined, 'File Script.PreDeployment1.sql not read'); + should(newProject.postDeployScripts.find(f => f.type === EntryType.File && f.relativePath === convertSlashesForSqlProj(postDeploymentScriptFilePath))).not.equal(undefined, 'File Script.PostDeployment1.sql not read'); + }); + + it('Should show information messages when adding more than one pre/post deployment scripts to sqlproj', async function (): Promise { + const stub = sinon.stub(window, 'showInformationMessage').returns(Promise.resolve()); + + projFilePath = await testUtils.createTestSqlProjFile(baselines.newProjectFileBaseline); + const project: Project = await Project.openProject(projFilePath); + + const folderPath = 'Pre-Post Deployment Scripts'; + const preDeploymentScriptFilePath = path.join(folderPath, 'Script.PreDeployment1.sql'); + const postDeploymentScriptFilePath = path.join(folderPath, 'Script.PostDeployment1.sql'); + const preDeploymentScriptFilePath2 = path.join(folderPath, 'Script.PreDeployment2.sql'); + const postDeploymentScriptFilePath2 = path.join(folderPath, 'Script.PostDeployment2.sql'); + const fileContents = ' '; + + await project.addFolderItem(folderPath); + await project.addScriptItem(preDeploymentScriptFilePath, fileContents, templates.preDeployScript); + await project.addScriptItem(postDeploymentScriptFilePath, fileContents, templates.postDeployScript); + + await project.addScriptItem(preDeploymentScriptFilePath2, fileContents, templates.preDeployScript); + should(stub.calledWith(constants.deployScriptExists(constants.PreDeploy))).be.true(`showInformationMessage not called with expected message '${constants.deployScriptExists(constants.PreDeploy)}' Actual '${stub.getCall(0).args[0]}'`); + + await project.addScriptItem(postDeploymentScriptFilePath2, fileContents, templates.postDeployScript); + should(stub.calledWith(constants.deployScriptExists(constants.PostDeploy))).be.true(`showInformationMessage not called with expected message '${constants.deployScriptExists(constants.PostDeploy)}' Actual '${stub.getCall(0).args[0]}'`); + + const newProject = await Project.openProject(projFilePath); + + should(newProject.preDeployScripts.find(f => f.type === EntryType.File && f.relativePath === convertSlashesForSqlProj(preDeploymentScriptFilePath))).not.equal(undefined, 'File Script.PreDeployment1.sql not read'); + should(newProject.postDeployScripts.find(f => f.type === EntryType.File && f.relativePath === convertSlashesForSqlProj(postDeploymentScriptFilePath))).not.equal(undefined, 'File Script.PostDeployment1.sql not read'); + should(newProject.noneDeployScripts.length).equal(2); + should(newProject.noneDeployScripts.find(f => f.type === EntryType.File && f.relativePath === convertSlashesForSqlProj(preDeploymentScriptFilePath2))).not.equal(undefined, 'File Script.PreDeployment2.sql not read'); + should(newProject.noneDeployScripts.find(f => f.type === EntryType.File && f.relativePath === convertSlashesForSqlProj(postDeploymentScriptFilePath2))).not.equal(undefined, 'File Script.PostDeployment2.sql not read'); + + }); }); describe('Project: round trip updates', function (): void { @@ -239,7 +292,7 @@ describe('Project: round trip updates', function (): void { }); it('Should update SSDT project to work in ADS', async function (): Promise { - await testUpdateInRoundTrip( baselines.SSDTProjectFileBaseline, baselines.SSDTProjectAfterUpdateBaseline, true, true); + await testUpdateInRoundTrip(baselines.SSDTProjectFileBaseline, baselines.SSDTProjectAfterUpdateBaseline, true, true); }); it('Should update SSDT project with new system database references', 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 6c3d3ba76c..8155705552 100644 --- a/extensions/sql-database-projects/src/test/projectController.test.ts +++ b/extensions/sql-database-projects/src/test/projectController.test.ts @@ -243,6 +243,25 @@ describe('ProjectsController', function (): void { should(await exists(scriptEntry.fsUri.fsPath)).equal(true, 'script is supposed to still exist on disk'); }); + + it('Should be able to add pre deploy and post deploy script', async function (): Promise { + const preDeployScriptName = 'PreDeployScript1.sql'; + const postDeployScriptName = 'PostDeployScript1.sql'; + + const projController = new ProjectsController(new SqlDatabaseProjectTreeViewProvider()); + const project = await testUtils.createTestProject(baselines.newProjectFileBaseline); + + sinon.stub(vscode.window, 'showInputBox').resolves(preDeployScriptName); + should(project.preDeployScripts.length).equal(0, 'There should be no pre deploy scripts'); + await projController.addItemPrompt(project, '', templates.preDeployScript); + should(project.preDeployScripts.length).equal(1, `Pre deploy script should be successfully added. ${project.preDeployScripts.length}, ${project.files.length}`); + + sinon.restore(); + sinon.stub(vscode.window, 'showInputBox').resolves(postDeployScriptName); + should(project.postDeployScripts.length).equal(0, 'There should be no post deploy scripts'); + await projController.addItemPrompt(project, '', templates.postDeployScript); + should(project.postDeployScripts.length).equal(1, 'Post deploy script should be successfully added'); + }); }); describe('Publishing and script generation', function (): void { diff --git a/extensions/sql-database-projects/src/test/templates.test.ts b/extensions/sql-database-projects/src/test/templates.test.ts index 9229b6ae48..d5de3a9732 100644 --- a/extensions/sql-database-projects/src/test/templates.test.ts +++ b/extensions/sql-database-projects/src/test/templates.test.ts @@ -23,7 +23,7 @@ describe('Templates: loading templates from disk', function (): void { // check expected counts - const numScriptObjectTypes = 4; + const numScriptObjectTypes = 6; should(templates.projectScriptTypes().length).equal(numScriptObjectTypes); should(Object.keys(templates.projectScriptTypes()).length).equal(numScriptObjectTypes);