diff --git a/extensions/sql-database-projects/package.json b/extensions/sql-database-projects/package.json index 628488035f..c53283c058 100644 --- a/extensions/sql-database-projects/package.json +++ b/extensions/sql-database-projects/package.json @@ -106,6 +106,11 @@ "title": "%sqlDatabaseProjects.newItem%", "category": "%sqlDatabaseProjects.displayName%" }, + { + "command": "sqlDatabaseProjects.addExistingItem", + "title": "%sqlDatabaseProjects.addExistingItem%", + "category": "%sqlDatabaseProjects.displayName%" + }, { "command": "sqlDatabaseProjects.newFolder", "title": "%sqlDatabaseProjects.newFolder%", @@ -240,6 +245,10 @@ "command": "sqlDatabaseProjects.newItem", "when": "false" }, + { + "command": "sqlDatabaseProjects.addExistingItem", + "when": "false" + }, { "command": "sqlDatabaseProjects.newFolder", "when": "false" @@ -327,10 +336,15 @@ "group": "2_dbProjects_newMain@1" }, { - "command": "sqlDatabaseProjects.newFolder", + "command": "sqlDatabaseProjects.addExistingItem", "when": "view == dataworkspace.views.main && viewItem == databaseProject.itemType.project || viewItem == databaseProject.itemType.folder", "group": "2_dbProjects_newMain@2" }, + { + "command": "sqlDatabaseProjects.newFolder", + "when": "view == dataworkspace.views.main && viewItem == databaseProject.itemType.project || viewItem == databaseProject.itemType.folder", + "group": "2_dbProjects_newMain@3" + }, { "command": "sqlDatabaseProjects.newTable", "when": "view == dataworkspace.views.main && viewItem == databaseProject.itemType.project || viewItem == databaseProject.itemType.folder", diff --git a/extensions/sql-database-projects/package.nls.json b/extensions/sql-database-projects/package.nls.json index 52e45ce2f9..1091db9746 100644 --- a/extensions/sql-database-projects/package.nls.json +++ b/extensions/sql-database-projects/package.nls.json @@ -24,9 +24,9 @@ "sqlDatabaseProjects.newStoredProcedure": "Add Stored Procedure", "sqlDatabaseProjects.newExternalStreamingJob": "Add External Streaming Job", "sqlDatabaseProjects.newItem": "Add Item...", + "sqlDatabaseProjects.addExistingItem": "Add Existing Item...", "sqlDatabaseProjects.newFolder": "Add Folder", - "sqlDatabaseProjects.addDatabaseReference": "Add Database Reference", "sqlDatabaseProjects.openContainingFolder": "Open Containing Folder", "sqlDatabaseProjects.editProjectFile": "Edit .sqlproj File", diff --git a/extensions/sql-database-projects/src/common/constants.ts b/extensions/sql-database-projects/src/common/constants.ts index b4640b16a3..57fbef079d 100644 --- a/extensions/sql-database-projects/src/common/constants.ts +++ b/extensions/sql-database-projects/src/common/constants.ts @@ -81,6 +81,7 @@ export const noString = localize('noString', "No"); export const noStringDefault = localize('noStringDefault', "No (default)"); export const okString = localize('okString', "Ok"); export const selectString = localize('selectString', "Select"); +export const selectFileString = localize('selectFileString', "Select File"); export const dacpacFiles = localize('dacpacFiles', "dacpac Files"); export const publishSettingsFiles = localize('publishSettingsFiles', "Publish Settings File"); export const file = localize('file', "File"); diff --git a/extensions/sql-database-projects/src/controllers/mainController.ts b/extensions/sql-database-projects/src/controllers/mainController.ts index bb2886649f..51d1299dc6 100644 --- a/extensions/sql-database-projects/src/controllers/mainController.ts +++ b/extensions/sql-database-projects/src/controllers/mainController.ts @@ -78,6 +78,7 @@ export default class MainController implements vscode.Disposable { vscode.commands.registerCommand('sqlDatabaseProjects.newStoredProcedure', async (node: WorkspaceTreeItem) => { return this.projectsController.addItemPromptFromNode(node, templates.storedProcedure); }); vscode.commands.registerCommand('sqlDatabaseProjects.newExternalStreamingJob', async (node: WorkspaceTreeItem) => { return this.projectsController.addItemPromptFromNode(node, templates.externalStreamingJob); }); vscode.commands.registerCommand('sqlDatabaseProjects.newItem', async (node: WorkspaceTreeItem) => { return this.projectsController.addItemPromptFromNode(node); }); + vscode.commands.registerCommand('sqlDatabaseProjects.addExistingItem', async (node: WorkspaceTreeItem) => { return this.projectsController.addExistingItemPrompt(node); }); vscode.commands.registerCommand('sqlDatabaseProjects.newFolder', async (node: WorkspaceTreeItem) => { return this.projectsController.addFolderPrompt(node); }); vscode.commands.registerCommand('sqlDatabaseProjects.addDatabaseReference', async (node: WorkspaceTreeItem) => { return this.projectsController.addDatabaseReference(node); }); diff --git a/extensions/sql-database-projects/src/controllers/projectController.ts b/extensions/sql-database-projects/src/controllers/projectController.ts index 16b7a12767..172f9991f4 100644 --- a/extensions/sql-database-projects/src/controllers/projectController.ts +++ b/extensions/sql-database-projects/src/controllers/projectController.ts @@ -651,6 +651,29 @@ export class ProjectsController { } } + public async addExistingItemPrompt(treeNode: dataworkspace.WorkspaceTreeItem): Promise { + const project = this.getProjectFromContext(treeNode); + + const uris = await vscode.window.showOpenDialog({ + canSelectFiles: true, + canSelectFolders: false, + canSelectMany: false, + openLabel: constants.selectString, + title: constants.selectFileString + }); + + if (!uris) { + return; // user cancelled + } + + try { + await project.addExistingItem(uris[0].fsPath); + this.refreshProjectsTree(treeNode); + } catch (err) { + void vscode.window.showErrorMessage(utils.getErrorMessage(err)); + } + } + public async exclude(context: dataworkspace.WorkspaceTreeItem): Promise { const node = context.element as BaseProjectTreeItem; const project = this.getProjectFromContext(node); diff --git a/extensions/sql-database-projects/src/models/project.ts b/extensions/sql-database-projects/src/models/project.ts index c480a6645f..325ab9b41b 100644 --- a/extensions/sql-database-projects/src/models/project.ts +++ b/extensions/sql-database-projects/src/models/project.ts @@ -712,6 +712,36 @@ export class Project implements ISqlProject { return fileEntry; } + /** + * Adds a file to the project, and saves the project file + * + * @param filePath Absolute path of the file + */ + public async addExistingItem(filePath: string): Promise { + const exists = await utils.exists(filePath); + if (!exists) { + throw new Error(constants.noFileExist(filePath)); + } + + // Check if file already has been added to sqlproj + const normalizedRelativeFilePath = utils.convertSlashesForSqlProj(path.relative(this.projectFolderPath, filePath)); + const existingEntry = this.files.find(f => f.relativePath.toUpperCase() === normalizedRelativeFilePath.toUpperCase()); + if (existingEntry) { + return existingEntry; + } + + // Ensure that parent folder item exist in the project for the corresponding file path + await this.ensureFolderItems(path.relative(this.projectFolderPath, path.dirname(filePath))); + + // Update sqlproj XML + const fileEntry = this.createFileProjectEntry(normalizedRelativeFilePath, EntryType.File); + const xmlTag = path.extname(filePath) === constants.sqlFileExtension ? constants.Build : constants.None; + await this.addToProjFile(fileEntry, xmlTag); + this._files.push(fileEntry); + + return fileEntry; + } + 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)); await this.removeFromProjFile(toExclude); @@ -939,6 +969,10 @@ export class Project implements ISqlProject { } private async addFileToProjFile(filePath: string, xmlTag: string, attributes?: Map): Promise { + + // delete Remove node if a file has been previously excluded + await this.undoExcludeFileFromProjFile(xmlTag, filePath); + let itemGroup; if (xmlTag === constants.PreDeploy || xmlTag === constants.PostDeploy) { @@ -969,7 +1003,7 @@ export class Project implements ISqlProject { // don't need to add an entry if it's already included by a glob pattern // unless it has an attribute that needs to be added, like external streaming job which needs it so it can be determined if validation can run on it - if (attributes?.size === 0 && currentFiles.find(f => f.relativePath === utils.convertSlashesForSqlProj(filePath))) { + if ((!attributes || attributes.size === 0) && currentFiles.find(f => f.relativePath === utils.convertSlashesForSqlProj(filePath))) { return; } @@ -1040,12 +1074,22 @@ export class Project implements ISqlProject { throw new Error(constants.unableToFindObject(path, constants.fileObject)); } - private removeNode(includeString: string, nodes: HTMLCollectionOf): boolean { + /** + * Deletes a node from the project file similar to + * @param includeString Path of the file that matches the Include portion of the node + * @param nodes The collection of XML nodes to search from + * @param undoRemove When true, will remove a node similar to + * @returns True when a node has been removed, false otherwise. + */ + private removeNode(includeString: string, nodes: HTMLCollectionOf, undoRemove: boolean = false): boolean { + // Default function behavior removes nodes like + // However when undoRemove is true, this function removes + const xmlAttribute = undoRemove ? constants.Remove : constants.Include; for (let i = 0; i < nodes.length; i++) { const parent = nodes[i].parentNode; if (parent) { - if (nodes[i].getAttribute(constants.Include) === utils.convertSlashesForSqlProj(includeString)) { + if (nodes[i].getAttribute(xmlAttribute) === utils.convertSlashesForSqlProj(includeString)) { parent.removeChild(nodes[i]); // delete ItemGroup if this was the only entry @@ -1064,6 +1108,18 @@ export class Project implements ISqlProject { return false; } + /** + * Delete a Remove node from the sqlproj, ex: + * @param xmlTag The XML tag of the node (Build, None, PreDeploy, PostDeploy) + * @param relativePath The relative path of the previously excluded file + */ + private async undoExcludeFileFromProjFile(xmlTag: string, relativePath: string): Promise { + const nodes = this.projFileXmlDoc!.documentElement.getElementsByTagName(xmlTag); + if (await this.removeNode(relativePath, nodes, true)) { + await this.serializeToProjFile(this.projFileXmlDoc!); + } + } + private async addFolderToProjFile(folderPath: string): Promise { if (this.isSdkStyleProject) { // if there's a folder entry for the folder containing this folder, remove it from the sqlproj because the folder will now be diff --git a/extensions/sql-database-projects/src/test/project.test.ts b/extensions/sql-database-projects/src/test/project.test.ts index 9692ade1d3..b37f8fd979 100644 --- a/extensions/sql-database-projects/src/test/project.test.ts +++ b/extensions/sql-database-projects/src/test/project.test.ts @@ -5,6 +5,7 @@ import * as should from 'should'; import * as path from 'path'; +import * as os from 'os'; import * as sinon from 'sinon'; import * as baselines from './baselines/baselines'; import * as templates from '../templates/templates'; @@ -826,6 +827,38 @@ describe('Project: sqlproj content operations', function (): void { { type: EntryType.Folder, relativePath: 'foo\\bar\\' }, { type: EntryType.File, relativePath: 'foo\\bar\\test.sql' }]); }); + + it('Should handle adding existing items to project', async function (): Promise { + // Create new sqlproj + projFilePath = await testUtils.createTestSqlProjFile(baselines.newProjectFileBaseline); + const projectFolder = path.dirname(projFilePath); + + // Create 2 new files, a sql file and a txt file + const sqlFile = path.join(projectFolder, 'test.sql'); + const txtFile = path.join(projectFolder, 'foo', 'test.txt'); + await fs.writeFile(sqlFile, ''); + await fs.mkdir(path.dirname(txtFile)); + await fs.writeFile(txtFile, ''); + + const project: Project = await Project.openProject(projFilePath); + + // Add them as existing files + await project.addExistingItem(sqlFile); + await project.addExistingItem(txtFile); + + // Validate files should have been added to project + should(project.files.length).equal(3, 'Three entries are expected in the project'); + should(project.files.map(f => ({ type: f.type, relativePath: f.relativePath }))) + .containDeep([ + { type: EntryType.Folder, relativePath: 'foo\\' }, + { type: EntryType.File, relativePath: 'test.sql' }, + { type: EntryType.File, relativePath: 'foo\\test.txt' }]); + + // Validate project file XML + const projFileText = (await fs.readFile(projFilePath)).toString(); + should(projFileText.includes('')).equal(true, projFileText); + should(projFileText.includes('')).equal(true, projFileText); + }); }); describe('Project: sdk style project content operations', function (): void { @@ -1397,6 +1430,60 @@ describe('Project: sdk style project content operations', function (): void { should(project.projectGuid).not.equal(undefined); should(projFileText.includes(constants.ProjectGuid)).equal(true); }); + + it('Should handle adding existing items to project', async function (): Promise { + projFilePath = await testUtils.createTestSqlProjFile(baselines.openSdkStyleSqlProjectBaseline); + const projectFolder = path.dirname(projFilePath); + + // Create a sql file inside project root + const sqlFile = path.join(projectFolder, 'test.sql'); + await fs.writeFile(sqlFile, ''); + + const project: Project = await Project.openProject(projFilePath); + + // Add it as existing file + await project.addExistingItem(sqlFile); + + // Validate it has been added to project + should(project.files.length).equal(1, 'Only one entry is expected in the project'); + const sqlFileEntry = project.files.find(f => f.type === EntryType.File && f.relativePath === 'test.sql'); + should(sqlFileEntry).not.equal(undefined); + + // Validate project XML should not have changed as the file falls under default glob + let projFileText = (await fs.readFile(projFilePath)).toString(); + should(projFileText.includes('')).equal(false, projFileText); + + // Exclude this file, verify the is added + await project.exclude(sqlFileEntry!); + should(project.files.length).equal(0, 'Project should not have any files remaining.'); + projFileText = (await fs.readFile(projFilePath)).toString(); + should(projFileText.includes('')).equal(true, projFileText); + + // Add the file back, verify the is no longer there + await project.addExistingItem(sqlFile); + projFileText = (await fs.readFile(projFilePath)).toString(); + should(projFileText.includes('')).equal(false, projFileText); + should(projFileText.includes('')).equal(false, projFileText); + + // Now create a txt file and add it to sqlproj + const txtFile = path.join(projectFolder, 'test.txt'); + await fs.writeFile(txtFile, ''); + await project.addExistingItem(txtFile); + + // Validate the txt file is added as + should(project.files.find(f => f.type === EntryType.File && f.relativePath === 'test.txt')).not.equal(undefined); + projFileText = (await fs.readFile(projFilePath)).toString(); + should(projFileText.includes('')).equal(true, projFileText); + + // Test with a sql file that's outside project root + const externalSqlFile = path.join(os.tmpdir(), `Test_${new Date().getTime()}.sql`); + const externalFileRelativePath = convertSlashesForSqlProj(path.relative(projectFolder, externalSqlFile)); + await fs.writeFile(externalSqlFile, ''); + await project.addExistingItem(externalSqlFile); + should(project.files.find(f => f.type === EntryType.File && f.relativePath === externalFileRelativePath)).not.equal(undefined); + projFileText = (await fs.readFile(projFilePath)).toString(); + should(projFileText.includes(``)).equal(true, projFileText); + }); }); describe('Project: add SQLCMD Variables', function (): void {