diff --git a/extensions/sql-database-projects/src/common/utils.ts b/extensions/sql-database-projects/src/common/utils.ts index aeb530aa7d..94452e36c7 100644 --- a/extensions/sql-database-projects/src/common/utils.ts +++ b/extensions/sql-database-projects/src/common/utils.ts @@ -110,18 +110,24 @@ function getQuotedNonWindowsPath(filePath: string): string { * Get safe relative path for Windows and non-Windows Platform * This is needed to read sqlproj entried created on SSDT and opened in MAC * '/' in tree is recognized all platforms but "\\" only by windows + * + * @param filePath Path to the file or folder. */ export function getPlatformSafeFileEntryPath(filePath: string): string { - const parts = filePath.split('\\'); - return parts.join('/'); + return filePath.includes('\\') + ? filePath.split('\\').join('/') + : filePath; } /** * Standardizes slashes to be "\\" for consistency between platforms and compatibility with SSDT + * + * @param filePath Path to the file of folder. */ export function convertSlashesForSqlProj(filePath: string): string { - const parts = filePath.split('/'); - return parts.join('\\'); + return filePath.includes('/') + ? filePath.split('/').join('\\') + : filePath; } /** diff --git a/extensions/sql-database-projects/src/models/project.ts b/extensions/sql-database-projects/src/models/project.ts index 57e1bb8d2d..2a0a36a67a 100644 --- a/extensions/sql-database-projects/src/models/project.ts +++ b/extensions/sql-database-projects/src/models/project.ts @@ -307,22 +307,31 @@ export class Project implements ISqlProject { /** * Adds a folder to the project, and saves the project file * @param relativeFolderPath Relative path of the folder + * @param doNotThrowOnDuplicate + * Flag that indicates whether duplicate entries should be ignored or throw an error. If flag is set to `true` and + * item already exists in the project file, then existing entry will be returned. */ - public async addFolderItem(relativeFolderPath: string): Promise { + public async addFolderItem(relativeFolderPath: string, doNotThrowOnDuplicate?: boolean): Promise { const absoluteFolderPath = path.join(this.projectFolderPath, relativeFolderPath); + const normalizedRelativeFolderPath = utils.convertSlashesForSqlProj(relativeFolderPath); // check if folder already has been added to sqlproj - if (this.files.find(f => f.relativePath.toLowerCase() === relativeFolderPath.toLowerCase())) { - throw new Error(constants.folderAlreadyAddedToProject((relativeFolderPath))); + const existingEntry = this.files.find(f => f.relativePath.toUpperCase() === normalizedRelativeFolderPath.toUpperCase()); + if (existingEntry) { + if (!doNotThrowOnDuplicate) { + throw new Error(constants.folderAlreadyAddedToProject((relativeFolderPath))); + } + + return existingEntry; } - //If folder doesn't exist, create it + // If folder doesn't exist, create it let exists = await utils.exists(absoluteFolderPath); if (!exists) { await fs.mkdir(absoluteFolderPath, { recursive: true }); } - const folderEntry = this.createFileProjectEntry(relativeFolderPath, EntryType.Folder); + const folderEntry = this.createFileProjectEntry(normalizedRelativeFolderPath, EntryType.Folder); this._files.push(folderEntry); await this.addToProjFile(folderEntry); @@ -333,8 +342,12 @@ export class Project implements ISqlProject { * Writes a file to disk if contents are provided, adds that file to the project, and writes it to disk * @param relativeFilePath Relative path of the file * @param contents Contents to be written to the new file + * @param itemType Type of the project entry to add. This maps to the build action for the item. + * @param doNotThrowOnDuplicate + * Flag that indicates whether duplicate entries should be ignored or throw an error. If flag is set to `true` and + * item already exists in the project file, then existing entry will be returned. */ - public async addScriptItem(relativeFilePath: string, contents?: string, itemType?: string): Promise { + public async addScriptItem(relativeFilePath: string, contents?: string, itemType?: string, doNotThrowOnDuplicate?: boolean): Promise { const absoluteFilePath = path.join(this.projectFolderPath, relativeFilePath); // check if file already exists if content was passed to write to a new file @@ -342,9 +355,16 @@ export class Project implements ISqlProject { throw new Error(constants.fileAlreadyExists(path.parse(absoluteFilePath).name)); } + const normalizedRelativeFilePath = utils.convertSlashesForSqlProj(relativeFilePath); + // check if file already has been added to sqlproj - if (this.files.find(f => f.relativePath.toLowerCase() === relativeFilePath.toLowerCase())) { - throw new Error(constants.fileAlreadyAddedToProject((relativeFilePath))); + const existingEntry = this.files.find(f => f.relativePath.toUpperCase() === normalizedRelativeFilePath.toUpperCase()); + if (existingEntry) { + if (!doNotThrowOnDuplicate) { + throw new Error(constants.fileAlreadyAddedToProject((relativeFilePath))); + } + + return existingEntry; } // create the file if contents were passed in @@ -360,7 +380,7 @@ export class Project implements ISqlProject { } // update sqlproj XML - const fileEntry = this.createFileProjectEntry(relativeFilePath, EntryType.File); + const fileEntry = this.createFileProjectEntry(normalizedRelativeFilePath, EntryType.File); let xmlTag; switch (itemType) { @@ -553,7 +573,11 @@ export class Project implements ISqlProject { public createFileProjectEntry(relativePath: string, entryType: EntryType, sqlObjectType?: string): FileProjectEntry { let platformSafeRelativePath = utils.getPlatformSafeFileEntryPath(relativePath); - return new FileProjectEntry(Uri.file(path.join(this.projectFolderPath, platformSafeRelativePath)), relativePath, entryType, sqlObjectType); + return new FileProjectEntry( + Uri.file(path.join(this.projectFolderPath, platformSafeRelativePath)), + utils.convertSlashesForSqlProj(relativePath), + entryType, + sqlObjectType); } private findOrCreateItemGroup(containedTag?: string, prePostScriptExist?: { scriptExist: boolean; }): any { @@ -981,8 +1005,9 @@ export class Project implements ISqlProject { /** * Adds the list of sql files and directories to the project, and saves the project file * @param list list of files and folder Uris. Files and folders must already exist. No files or folders will be added if any do not exist. + * @param doNotThrowOnDuplicate Flag that indicates whether duplicate entries should be ignored or throw an error. */ - public async addToProject(list: Uri[]): Promise { + public async addToProject(list: Uri[], doNotThrowOnDuplicate?: boolean): Promise { // verify all files/folders exist. If not all exist, none will be added for (let file of list) { const exists = await utils.exists(file.fsPath); @@ -999,9 +1024,9 @@ export class Project implements ISqlProject { const fileStat = await fs.stat(file.fsPath); if (fileStat.isFile() && file.fsPath.toLowerCase().endsWith(constants.sqlFileExtension)) { - await this.addScriptItem(relativePath); + await this.addScriptItem(relativePath, undefined, undefined, doNotThrowOnDuplicate); } else if (fileStat.isDirectory()) { - await this.addFolderItem(relativePath); + await this.addFolderItem(relativePath, doNotThrowOnDuplicate); } } } diff --git a/extensions/sql-database-projects/src/sqldbproj.d.ts b/extensions/sql-database-projects/src/sqldbproj.d.ts index 44ce2c1da0..daea839fd9 100644 --- a/extensions/sql-database-projects/src/sqldbproj.d.ts +++ b/extensions/sql-database-projects/src/sqldbproj.d.ts @@ -37,21 +37,29 @@ declare module 'sqldbproj' { /** * Adds the list of sql files and directories to the project, and saves the project file * @param list list of files and folder Uris. Files and folders must already exist. No files or folders will be added if any do not exist. + * @param doNotThrowOnDuplicate Flag that indicates whether duplicate entries should be ignored or throw an error. */ - addToProject(list: vscode.Uri[]): Promise; + addToProject(list: vscode.Uri[], doNotThrowOnDuplicate?: boolean): Promise; /** * Adds a folder to the project, and saves the project file * @param relativeFolderPath Relative path of the folder + * @param doNotThrowOnDuplicate + * Flag that indicates whether duplicate entries should be ignored or throw an error. If flag is set to `true` and + * item already exists in the project file, then existing entry will be returned. */ - addFolderItem(relativeFolderPath: string): Promise; + addFolderItem(relativeFolderPath: string, doNotThrowOnDuplicate?: boolean): Promise; /** * Writes a file to disk if contents are provided, adds that file to the project, and writes it to disk * @param relativeFilePath Relative path of the file * @param contents Contents to be written to the new file + * @param itemType Type of the project entry to add. This maps to the build action for the item. + * @param doNotThrowOnDuplicate + * Flag that indicates whether duplicate entries should be ignored or throw an error. If flag is set to `true` and + * item already exists in the project file, then existing entry will be returned. */ - addScriptItem(relativeFilePath: string, contents?: string, itemType?: string): Promise; + addScriptItem(relativeFilePath: string, contents?: string, itemType?: string, doNotThrowOnDuplicate?: boolean): Promise; /** * Adds a SQLCMD variable to the project diff --git a/extensions/sql-database-projects/src/test/project.test.ts b/extensions/sql-database-projects/src/test/project.test.ts index aa63235054..ec346c7fc0 100644 --- a/extensions/sql-database-projects/src/test/project.test.ts +++ b/extensions/sql-database-projects/src/test/project.test.ts @@ -570,25 +570,151 @@ describe('Project: sqlproj content operations', function (): void { }); - it('Should not allow adding duplicate file/folder entries in sqlproj', async function (): Promise { + it('Should not allow adding duplicate file/folder entries in new sqlproj by default', async function (): Promise { projFilePath = await testUtils.createTestSqlProjFile(baselines.newProjectFileBaseline); const project: Project = await Project.openProject(projFilePath); const fileList = await testUtils.createListOfFiles(path.dirname(projFilePath)); - // verify first entry in list is a folder - const existingFolderUri = fileList[0]; + // 1. Add a folder to the project + const existingFolderUri = fileList[2]; const folderStats = await fs.stat(existingFolderUri.fsPath); - should(folderStats.isDirectory()).equal(true, 'First entry in fileList should be a folder'); + should(folderStats.isDirectory()).equal(true, 'Third entry in fileList should be a subfolder'); await project.addToProject([existingFolderUri]); - const folderRelativePath = trimChars(trimUri(Uri.file(projFilePath), existingFolderUri), ''); - testUtils.shouldThrowSpecificError(async () => await project.addToProject([existingFolderUri]), constants.folderAlreadyAddedToProject(folderRelativePath)); - // verify duplicate file can't be added - const existingFileUri = fileList[1]; - const fileStats = await fs.stat(existingFileUri.fsPath); + // Try adding the folder to the project again + const folderRelativePath = trimChars(trimUri(Uri.file(projFilePath), existingFolderUri), ''); + await testUtils.shouldThrowSpecificError(async () => await project.addToProject([existingFolderUri]), constants.folderAlreadyAddedToProject(folderRelativePath)); + + // 2. Add a file to the project + let existingFileUri = fileList[1]; + let fileStats = await fs.stat(existingFileUri.fsPath); should(fileStats.isFile()).equal(true, 'Second entry in fileList should be a file'); + await project.addToProject([existingFileUri]); + + // Try adding the file to the project again + let fileRelativePath = trimChars(trimUri(Uri.file(projFilePath), existingFileUri), '/'); + await testUtils.shouldThrowSpecificError(async () => await project.addToProject([existingFileUri]), constants.fileAlreadyAddedToProject(fileRelativePath)); + + // 3. Add a file from subfolder to the project + existingFileUri = fileList[3]; + fileStats = await fs.stat(existingFileUri.fsPath); + should(fileStats.isFile()).equal(true, 'Fourth entry in fileList should be a file'); + await project.addToProject([existingFileUri]); + + // Try adding the file from subfolder to the project again + fileRelativePath = trimChars(trimUri(Uri.file(projFilePath), existingFileUri), '/'); + await testUtils.shouldThrowSpecificError(async () => await project.addToProject([existingFileUri]), constants.fileAlreadyAddedToProject(fileRelativePath)); + }); + + it('Should ignore duplicate file/folder entries in new sqlproj if requested', async function (): Promise { + projFilePath = await testUtils.createTestSqlProjFile(baselines.newProjectFileBaseline); + const project: Project = await Project.openProject(projFilePath); + const fileList = await testUtils.createListOfFiles(path.dirname(projFilePath)); + + // 1. Add a folder to the project + const existingFolderUri = fileList[2]; + const folderStats = await fs.stat(existingFolderUri.fsPath); + should(folderStats.isDirectory()).equal(true, 'Third entry in fileList should be a subfolder'); + + const folderEntry = await project.addToProject([existingFolderUri]); + should(project.files.length).equal(1, 'New folder entry should be added to the project'); + + // Add the folder to the project again + should(await project.addToProject([existingFolderUri], true)) + .equal(folderEntry, 'Original folder entry should be returned when adding same folder for a second time'); + should(project.files.length).equal(1, 'No new entries should be added to the project when adding same folder for a second time'); + + // 2. Add a file to the project + let existingFileUri = fileList[1]; + let fileStats = await fs.stat(existingFileUri.fsPath); + should(fileStats.isFile()).equal(true, 'Second entry in fileList should be a file'); + + let fileEntry = await project.addToProject([existingFileUri]); + should(project.files.length).equal(2, 'New file entry should be added to the project'); + + // Add the file to the project again + should(await project.addToProject([existingFileUri], true)) + .equal(fileEntry, 'Original file entry should be returned when adding same file for a second time'); + should(project.files.length).equal(2, 'No new entries should be added to the project when adding same file for a second time'); + + // 3. Add a file from subfolder to the project + existingFileUri = fileList[3]; + fileStats = await fs.stat(existingFileUri.fsPath); + should(fileStats.isFile()).equal(true, 'Fourth entry in fileList should be a file'); + + fileEntry = await project.addToProject([existingFileUri]); + should(project.files.length).equal(3, 'New file entry should be added to the project'); + + // Add the file from subfolder to the project again + should(await project.addToProject([existingFileUri], true)) + .equal(fileEntry, 'Original file entry should be returned when adding same file for a second time'); + should(project.files.length).equal(3, 'No new entries should be added to the project when adding same file for a second time'); + }); + + it('Should not allow adding duplicate file entries in existing sqlproj by default', async function (): Promise { + // Create new sqlproj + projFilePath = await testUtils.createTestSqlProjFile(baselines.newProjectFileBaseline); + const fileList = await testUtils.createListOfFiles(path.dirname(projFilePath)); + + let project: Project = await Project.openProject(projFilePath); + + // Add a file to the project + let existingFileUri = fileList[3]; + let fileStats = await fs.stat(existingFileUri.fsPath); + should(fileStats.isFile()).equal(true, 'Fourth entry in fileList should be a file'); + await project.addToProject([existingFileUri]); + + // Reopen existing project + project = await Project.openProject(projFilePath); + + // Try adding the same file to the project again const fileRelativePath = trimChars(trimUri(Uri.file(projFilePath), existingFileUri), '/'); - testUtils.shouldThrowSpecificError(async () => await project.addToProject([existingFileUri]), constants.fileAlreadyAddedToProject(fileRelativePath)); + await testUtils.shouldThrowSpecificError(async () => await project.addToProject([existingFileUri]), constants.fileAlreadyAddedToProject(fileRelativePath)); + }); + + it('Should ignore duplicate file entries in existing sqlproj if requested', async function (): Promise { + // Create new sqlproj + projFilePath = await testUtils.createTestSqlProjFile(baselines.newProjectFileBaseline); + const fileList = await testUtils.createListOfFiles(path.dirname(projFilePath)); + + let project: Project = await Project.openProject(projFilePath); + + // Add a file to the project + let existingFileUri = fileList[3]; + let fileStats = await fs.stat(existingFileUri.fsPath); + should(fileStats.isFile()).equal(true, 'Fourth entry in fileList should be a file'); + await project.addToProject([existingFileUri]); + + // Reopen existing project + project = await Project.openProject(projFilePath); + + // Try adding the same file to the project again + await project.addToProject([existingFileUri], true); + }); + + it('Project entry relative path should not change after round-trip', async function (): Promise { + // Create new sqlproj + projFilePath = await testUtils.createTestSqlProjFile(baselines.newProjectFileBaseline); + const fileList = await testUtils.createListOfFiles(path.dirname(projFilePath)); + + let project: Project = await Project.openProject(projFilePath); + + // Add a file to the project + let existingFileUri = fileList[3]; + let fileStats = await fs.stat(existingFileUri.fsPath); + should(fileStats.isFile()).equal(true, 'Fourth entry in fileList should be a file'); + await project.addToProject([existingFileUri]); + + // Store the original `relativePath` of the project entry + should(project.files.length).equal(1, 'An entry should be created in the project'); + const originalRelativePath = project.files[0].relativePath; + + // Reopen existing project + project = await Project.openProject(projFilePath); + + // Try adding the same file to the project again + should(project.files.length).equal(1, 'Single entry is expected in the loaded project'); + should(project.files[0].relativePath).equal(originalRelativePath, 'Relative path should match after a round-trip'); }); });