diff --git a/extensions/sql-database-projects/src/common/constants.ts b/extensions/sql-database-projects/src/common/constants.ts index 428ff81f9d..88fa110086 100644 --- a/extensions/sql-database-projects/src/common/constants.ts +++ b/extensions/sql-database-projects/src/common/constants.ts @@ -195,7 +195,7 @@ export const invalidDataSchemaProvider = localize('invalidDataSchemaProvider', " export const invalidDatabaseReference = localize('invalidDatabaseReference', "Invalid database reference in .sqlproj file"); export const databaseSelectionRequired = localize('databaseSelectionRequired', "Database selection is required to create a project from a database"); export const databaseReferenceAlreadyExists = localize('databaseReferenceAlreadyExists', "A reference to this database already exists in this project"); -export const ousiderFolderPath = localize('outsideFolderPath', "Items with absolute path outside project folder are not supported. Please make sure the paths in the project file are relative to project folder."); +export const outsideFolderPath = localize('outsideFolderPath', "Items with absolute path outside project folder are not supported. Please make sure the paths in the project file are relative to project folder."); export const parentTreeItemUnknown = localize('parentTreeItemUnknown', "Cannot access parent of provided tree item"); export const prePostDeployCount = localize('prePostDeployCount', "To successfully build, update the project to have one pre-deployment script and/or one post-deployment script"); export const invalidProjectReload = localize('invalidProjectReload', "Cannot access provided database project. Only valid, open database projects can be reloaded."); @@ -207,8 +207,6 @@ export function fileOrFolderDoesNotExist(name: string) { return localize('fileOr export function cannotResolvePath(path: string) { return localize('cannotResolvePath', "Cannot resolve path {0}", path); } export function fileAlreadyExists(filename: string) { return localize('fileAlreadyExists', "A file with the name '{0}' already exists on disk at this location. Please choose another name.", filename); } export function folderAlreadyExists(filename: string) { return localize('folderAlreadyExists', "A folder with the name '{0}' already exists on disk at this location. Please choose another name.", filename); } -export function fileAlreadyAddedToProject(filepath: string) { return localize('fileAlreadyAddedToProject', "A file with the path '{0}' has already been added to the project", filepath); } -export function folderAlreadyAddedToProject(folderpath: string) { return localize('folderAlreadyAddedToProject', "A folder with the path '{0}' has already been added to the project", folderpath); } export function invalidInput(input: string) { return localize('invalidInput', "Invalid input: {0}", input); } export function invalidProjectPropertyValue(propertyName: string) { return localize('invalidPropertyValue', "Invalid value specified for the property '{0}' in .sqlproj file", propertyName); } export function unableToCreatePublishConnection(input: string) { return localize('unableToCreatePublishConnection', "Unable to construct connection: {0}", input); } @@ -312,6 +310,12 @@ export const NETFrameworkAssembly = 'Microsoft.NETFramework.ReferenceAssemblies' export const VersionNumber = '1.0.0'; export const All = 'All'; +/** + * Path separator to use within SqlProj file for `Include`, `Exclude`, etc. attributes. + * This matches Windows path separator, as expected by SSDT. + */ +export const SqlProjPathSeparator = '\\'; + // Profile XML names export const targetDatabaseName = 'TargetDatabaseName'; export const targetConnectionString = 'TargetConnectionString'; diff --git a/extensions/sql-database-projects/src/common/utils.ts b/extensions/sql-database-projects/src/common/utils.ts index 54a7168782..5e7b95fd5c 100644 --- a/extensions/sql-database-projects/src/common/utils.ts +++ b/extensions/sql-database-projects/src/common/utils.ts @@ -38,7 +38,7 @@ export function trimUri(innerUri: vscode.Uri, outerUri: vscode.Uri): string { if (path.isAbsolute(outerUri.path) && innerParts.length > 0 && outerParts.length > 0 && innerParts[0].toLowerCase() !== outerParts[0].toLowerCase()) { - throw new Error(constants.ousiderFolderPath); + throw new Error(constants.outsideFolderPath); } while (innerParts.length > 0 && outerParts.length > 0 && innerParts[0].toLocaleLowerCase() === outerParts[0].toLocaleLowerCase()) { @@ -71,6 +71,18 @@ export function trimChars(input: string, chars: string): string { return output; } +/** + * Ensures that folder path terminates with the slash. + * By default SSDT-style slash (`\`) is used. + * + * @param path Folder path to ensure trailing slash for. + * @param slashCharacter Slash character to ensure is present at the end of the path. + * @returns Path that ends with the given slash character. + */ +export function ensureTrailingSlash(path: string, slashCharacter: string = constants.SqlProjPathSeparator): string { + return path.endsWith(slashCharacter) ? path : path + slashCharacter; +} + /** * Checks if the folder or file exists @param path path of the folder/file */ @@ -123,13 +135,13 @@ export function getPlatformSafeFileEntryPath(filePath: string): string { } /** - * Standardizes slashes to be "\\" for consistency between platforms and compatibility with SSDT + * 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 { return filePath.includes('/') - ? filePath.split('/').join('\\') + ? filePath.split('/').join(constants.SqlProjPathSeparator) : filePath; } diff --git a/extensions/sql-database-projects/src/models/project.ts b/extensions/sql-database-projects/src/models/project.ts index 48ce59f352..4a623b51e3 100644 --- a/extensions/sql-database-projects/src/models/project.ts +++ b/extensions/sql-database-projects/src/models/project.ts @@ -306,80 +306,63 @@ 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, doNotThrowOnDuplicate?: boolean): Promise { - const absoluteFolderPath = path.join(this.projectFolderPath, relativeFolderPath); - const normalizedRelativeFolderPath = utils.convertSlashesForSqlProj(relativeFolderPath); + public async addFolderItem(relativeFolderPath: string): Promise { + const folderEntry = await this.ensureFolderItems(relativeFolderPath); - // check if folder already has been added to sqlproj - const existingEntry = this.files.find(f => f.relativePath.toUpperCase() === normalizedRelativeFolderPath.toUpperCase()); - if (existingEntry) { - if (!doNotThrowOnDuplicate) { - throw new Error(constants.folderAlreadyAddedToProject((relativeFolderPath))); - } - - return existingEntry; + if (folderEntry) { + return folderEntry; + } else { + throw new Error(constants.outsideFolderPath); } - - // 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(normalizedRelativeFolderPath, EntryType.Folder); - this._files.push(folderEntry); - - await this.addToProjFile(folderEntry); - return folderEntry; } /** * 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, doNotThrowOnDuplicate?: boolean): Promise { + public async addScriptItem(relativeFilePath: string, contents?: string, itemType?: string): Promise { const absoluteFilePath = path.join(this.projectFolderPath, relativeFilePath); - // check if file already exists if content was passed to write to a new file - if (contents !== undefined && contents !== '' && await utils.exists(absoluteFilePath)) { - throw new Error(constants.fileAlreadyExists(path.parse(absoluteFilePath).name)); + if (contents) { + // Create the file if contents were passed in and file does not exist yet + await fs.mkdir(path.dirname(absoluteFilePath), { recursive: true }); + + try { + await fs.writeFile(absoluteFilePath, contents, { flag: 'wx' }); + } catch (error) { + if (error.code === 'EEXIST') { + // Throw specialized error, if file already exists + throw new Error(constants.fileAlreadyExists(path.parse(absoluteFilePath).name)); + } + + throw error; + } + } else { + // If no contents were provided, then check that file already exists + let exists = await utils.exists(absoluteFilePath); + if (!exists) { + throw new Error(constants.noFileExist(absoluteFilePath)); + } } + // Ensure that parent folder item exist in the project for the corresponding file path + await this.ensureFolderItems(path.relative(this.projectFolderPath, path.dirname(absoluteFilePath))); + + // Check if file already has been added to sqlproj const normalizedRelativeFilePath = utils.convertSlashesForSqlProj(relativeFilePath); - // check if file already has been added to sqlproj 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 - if (contents) { - await fs.mkdir(path.dirname(absoluteFilePath), { recursive: true }); - await fs.writeFile(absoluteFilePath, contents); - } - - // check that file exists - let exists = await utils.exists(absoluteFilePath); - if (!exists) { - throw new Error(constants.noFileExist(absoluteFilePath)); - } - - // update sqlproj XML + // Update sqlproj XML const fileEntry = this.createFileProjectEntry(normalizedRelativeFilePath, EntryType.File); let xmlTag; @@ -1033,10 +1016,10 @@ 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[], doNotThrowOnDuplicate?: boolean): Promise { + public async addToProject(list: Uri[]): 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); @@ -1053,9 +1036,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, undefined, undefined, doNotThrowOnDuplicate); + await this.addScriptItem(relativePath); } else if (fileStat.isDirectory()) { - await this.addFolderItem(relativePath, doNotThrowOnDuplicate); + await this.addFolderItem(relativePath); } } } @@ -1106,6 +1089,56 @@ export class Project implements ISqlProject { return firstPropertyElement.childNodes[0].data; } + + /** + * Adds all folders in the path to the project and saves the project file, if provided path is under the project folder. + * If path is outside the project folder, then no action is taken. + * + * @param relativeFolderPath Relative folder path to add folders from. + * @returns Project entry for the last folder in the path, if path is under the project folder; otherwise `undefined`. + */ + private async ensureFolderItems(relativeFolderPath: string): Promise { + const absoluteFolderPath = path.join(this.projectFolderPath, relativeFolderPath); + const normalizedProjectFolderPath = path.normalize(this.projectFolderPath); + + // Only add folders within the project folder. When adding files outside the project folder, + // they should be copied to the project root and there will be no additional folders to add. + if (!absoluteFolderPath.toUpperCase().startsWith(normalizedProjectFolderPath.toUpperCase())) { + return; + } + + // If folder doesn't exist, create it + await fs.mkdir(absoluteFolderPath, { recursive: true }); + + // Add project file entries for all folders in the path. + // SSDT expects all folders to be explicitly listed in the project file, so we construct + // folder paths for all intermediate folders and ensure they are present in the project as well. + // We do not use `path.relative` here, because it may return '.' if paths are the same, + // but in our case we actually want an empty string, that will result in an empty segments + // array and nothing will be added. + const relativePath = utils.convertSlashesForSqlProj(absoluteFolderPath.substring(normalizedProjectFolderPath.length)); + const pathSegments = utils.trimChars(relativePath, ' \\').split(constants.SqlProjPathSeparator); + let folderEntryPath = ''; + let folderEntry: FileProjectEntry | undefined; + + // Add folder items for all segments, including the requested folder itself + for (let segment of pathSegments) { + if (segment) { + folderEntryPath += segment + constants.SqlProjPathSeparator; + folderEntry = + this.files.find(f => utils.ensureTrailingSlash(f.relativePath.toUpperCase()) === folderEntryPath.toUpperCase()); + + if (!folderEntry) { + // If there is no item for the folder - add it + folderEntry = this.createFileProjectEntry(folderEntryPath, EntryType.Folder); + this.files.push(folderEntry); + await this.addToProjFile(folderEntry); + } + } + } + + return folderEntry; + } } /** diff --git a/extensions/sql-database-projects/src/sqldbproj.d.ts b/extensions/sql-database-projects/src/sqldbproj.d.ts index 93400bae5d..ade0f51f58 100644 --- a/extensions/sql-database-projects/src/sqldbproj.d.ts +++ b/extensions/sql-database-projects/src/sqldbproj.d.ts @@ -45,30 +45,26 @@ 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[], doNotThrowOnDuplicate?: boolean): Promise; + addToProject(list: vscode.Uri[]): 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, doNotThrowOnDuplicate?: boolean): Promise; + addFolderItem(relativeFolderPath: string): 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, doNotThrowOnDuplicate?: boolean): Promise; + addScriptItem(relativeFilePath: string, contents?: string, itemType?: string): 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 adb533873d..e45361ea83 100644 --- a/extensions/sql-database-projects/src/test/project.test.ts +++ b/extensions/sql-database-projects/src/test/project.test.ts @@ -13,7 +13,7 @@ import * as constants from '../common/constants'; import { promises as fs } from 'fs'; import { Project, EntryType, SystemDatabase, SystemDatabaseReferenceProjectEntry, SqlProjectReferenceProjectEntry } from '../models/project'; -import { exists, convertSlashesForSqlProj, trimChars, trimUri } from '../common/utils'; +import { exists, convertSlashesForSqlProj } from '../common/utils'; import { Uri, window } from 'vscode'; import { IDacpacReferenceSettings, IProjectReferenceSettings, ISystemDatabaseReferenceSettings } from '../models/IDatabaseReferenceSettings'; import { SqlTargetPlatform } from 'sqldbproj'; @@ -99,7 +99,7 @@ describe('Project: sqlproj content operations', function (): void { it('Should add Folder and Build entries to sqlproj', async function (): Promise { const project = await Project.openProject(projFilePath); - const folderPath = 'Stored Procedures'; + const folderPath = 'Stored Procedures\\'; const scriptPath = path.join(folderPath, 'Fake Stored Proc.sql'); const scriptContents = 'SELECT \'This is not actually a stored procedure.\''; @@ -582,43 +582,7 @@ describe('Project: sqlproj content operations', function (): void { }); - 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)); - - // 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'); - await project.addToProject([existingFolderUri]); - - // 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 { + it('Should ignore duplicate file/folder entries in new sqlproj', async function (): Promise { projFilePath = await testUtils.createTestSqlProjFile(baselines.newProjectFileBaseline); const project: Project = await Project.openProject(projFilePath); const fileList = await testUtils.createListOfFiles(path.dirname(projFilePath)); @@ -632,7 +596,7 @@ describe('Project: sqlproj content operations', function (): void { 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)) + should(await project.addToProject([existingFolderUri])) .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'); @@ -645,7 +609,7 @@ describe('Project: sqlproj content operations', function (): void { 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)) + should(await project.addToProject([existingFileUri])) .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'); @@ -658,12 +622,12 @@ describe('Project: sqlproj content operations', function (): void { 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)) + should(await project.addToProject([existingFileUri])) .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 { + it('Should ignore duplicate file entries in existing sqlproj', async function (): Promise { // Create new sqlproj projFilePath = await testUtils.createTestSqlProjFile(baselines.newProjectFileBaseline); const fileList = await testUtils.createListOfFiles(path.dirname(projFilePath)); @@ -680,53 +644,186 @@ describe('Project: sqlproj content operations', function (): void { project = await Project.openProject(projFilePath); // Try adding the same file to the project again - const fileRelativePath = trimChars(trimUri(Uri.file(projFilePath), existingFileUri), '/'); - await testUtils.shouldThrowSpecificError(async () => await project.addToProject([existingFileUri]), constants.fileAlreadyAddedToProject(fileRelativePath)); + await project.addToProject([existingFileUri]); }); - it('Should ignore duplicate file entries in existing sqlproj if requested', async function (): Promise { + it('Should not overwrite existing files', 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 + // Add a file entry to the project with explicit content 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); + const relativePath = path.relative(path.dirname(projFilePath), existingFileUri.fsPath); + await testUtils.shouldThrowSpecificError( + async () => await project.addScriptItem(relativePath, 'Hello World!'), + `A file with the name '${path.parse(relativePath).name}' already exists on disk at this location. Please choose another name.`); }); - it('Project entry relative path should not change after round-trip', async function (): Promise { + it('Should not add folders outside of the project folder', 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); + + // Try adding project root folder itself - this is silently ignored + await project.addToProject([Uri.file(path.dirname(projFilePath))]); + should.equal(project.files.length, 0, 'Nothing should be added to the project'); + + // Try adding a parent of the project folder + await testUtils.shouldThrowSpecificError( + async () => await project.addToProject([Uri.file(path.dirname(path.dirname(projFilePath)))]), + 'Items with absolute path outside project folder are not supported. Please make sure the paths in the project file are relative to project folder.', + 'Folders outside the project folder should not be added.'); + }); + + it('Project entry relative path should not change after reload', async function (): Promise { + // Create new sqlproj + projFilePath = await testUtils.createTestSqlProjFile(baselines.newProjectFileBaseline); + const projectFolder = path.dirname(projFilePath); + + // Create file under nested folders structure + const newFile = path.join(projectFolder, 'foo', 'test.sql'); + await fs.mkdir(path.dirname(newFile), { recursive: true }); + await fs.writeFile(newFile, ''); 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]); + await project.addToProject([Uri.file(newFile)]); // 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; + let fileEntry = project.files.find(f => f.relativePath.endsWith('test.sql')); + + should.exist(fileEntry, 'Entry for the file should be added to project'); + let originalRelativePath = ''; + if (fileEntry) { + originalRelativePath = fileEntry.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'); + // Validate that relative path of the file entry matches the original + // There will be additional folder + should(project.files.length).equal(2, 'Two entries are expected in the loaded project'); + + fileEntry = project.files.find(f => f.relativePath.endsWith('test.sql')); + should.exist(fileEntry, 'Entry for the file should be present in the project after reload'); + if (fileEntry) { + should(fileEntry.relativePath).equal(originalRelativePath, 'Relative path should match after reload'); + } + }); + + it('Intermediate folders for file should be automatically added to project', async function (): Promise { + // Create new sqlproj + projFilePath = await testUtils.createTestSqlProjFile(baselines.newProjectFileBaseline); + const projectFolder = path.dirname(projFilePath); + + // Create file under nested folders structure + const newFile = path.join(projectFolder, 'foo', 'bar', 'test.sql'); + await fs.mkdir(path.dirname(newFile), { recursive: true }); + await fs.writeFile(newFile, ''); + + // Open empty project + let project: Project = await Project.openProject(projFilePath); + + // Add a file to the project + await project.addToProject([Uri.file(newFile)]); + + // Validate that intermediate folders were added to the 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.Folder, relativePath: 'foo\\bar\\' }, + { type: EntryType.File, relativePath: 'foo\\bar\\test.sql' }]); + }); + + it('Intermediate folders for folder should be automatically added to project', async function (): Promise { + // Create new sqlproj + projFilePath = await testUtils.createTestSqlProjFile(baselines.newProjectFileBaseline); + const projectFolder = path.dirname(projFilePath); + + // Create nested folders structure + const newFolder = path.join(projectFolder, 'foo', 'bar'); + await fs.mkdir(newFolder, { recursive: true }); + + // Open empty project + let project: Project = await Project.openProject(projFilePath); + + // Add a file to the project + await project.addToProject([Uri.file(newFolder)]); + + // Validate that intermediate folders were added to the project + should(project.files.length).equal(2, 'Two 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.Folder, relativePath: 'foo\\bar\\' }]); + }); + + it('Should not add duplicate intermediate folders to project', async function (): Promise { + // Create new sqlproj + projFilePath = await testUtils.createTestSqlProjFile(baselines.newProjectFileBaseline); + const projectFolder = path.dirname(projFilePath); + + // Create file under nested folders structure + const newFile = path.join(projectFolder, 'foo', 'bar', 'test.sql'); + await fs.mkdir(path.dirname(newFile), { recursive: true }); + await fs.writeFile(newFile, ''); + + const anotherNewFile = path.join(projectFolder, 'foo', 'bar', 'test2.sql'); + await fs.writeFile(anotherNewFile, ''); + + // Open empty project + let project: Project = await Project.openProject(projFilePath); + + // Add a file to the project + await project.addToProject([Uri.file(newFile)]); + await project.addToProject([Uri.file(anotherNewFile)]); + + // Validate that intermediate folders were added to the project + should(project.files.length).equal(4, 'Four 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.Folder, relativePath: 'foo\\bar\\' }, + { type: EntryType.File, relativePath: 'foo\\bar\\test.sql' }, + { type: EntryType.File, relativePath: 'foo\\bar\\test2.sql' }]); + }); + + it('Should not add duplicate intermediate folders to project when folder is explicitly added', async function (): Promise { + // Create new sqlproj + projFilePath = await testUtils.createTestSqlProjFile(baselines.newProjectFileBaseline); + const projectFolder = path.dirname(projFilePath); + + // Create file under nested folders structure + const newFile = path.join(projectFolder, 'foo', 'bar', 'test.sql'); + await fs.mkdir(path.dirname(newFile), { recursive: true }); + await fs.writeFile(newFile, ''); + + const explicitIntermediateFolder = path.join(projectFolder, 'foo', 'bar'); + await fs.mkdir(explicitIntermediateFolder, { recursive: true }); + + // Open empty project + let project: Project = await Project.openProject(projFilePath); + + // Add file and folder to the project + await project.addToProject([Uri.file(newFile), Uri.file(explicitIntermediateFolder)]); + + // Validate that intermediate folders were added to the 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.Folder, relativePath: 'foo\\bar\\' }, + { type: EntryType.File, relativePath: 'foo\\bar\\test.sql' }]); }); }); diff --git a/extensions/sql-database-projects/src/test/projectController.test.ts b/extensions/sql-database-projects/src/test/projectController.test.ts index 3bc73651a3..17d6b0ae2f 100644 --- a/extensions/sql-database-projects/src/test/projectController.test.ts +++ b/extensions/sql-database-projects/src/test/projectController.test.ts @@ -192,7 +192,7 @@ describe('ProjectsController', function (): void { // confirm result should(proj.files.length).equal(1, 'number of file/folder entries'); // lowerEntry and the contained scripts should be deleted - should(proj.files[0].relativePath).equal('UpperFolder'); + should(proj.files[0].relativePath).equal('UpperFolder\\'); should(proj.preDeployScripts.length).equal(0); should(proj.postDeployScripts.length).equal(0); should(proj.noneDeployScripts.length).equal(0); @@ -253,7 +253,7 @@ describe('ProjectsController', function (): void { // confirm result should(proj.files.length).equal(1, 'number of file/folder entries'); // LowerFolder and the contained scripts should be deleted - should(proj.files[0].relativePath).equal('UpperFolder'); // UpperFolder should still be there + should(proj.files[0].relativePath).equal('UpperFolder\\'); // UpperFolder should still be there should(proj.preDeployScripts.length).equal(0); should(proj.postDeployScripts.length).equal(0); should(proj.noneDeployScripts.length).equal(0);