Automatically add intermediate folders for SQL project items. (#16332)

* Automatically add intermediate folders for SQL project items.

While using the SQL database projects through the API, I noticed that project may end up in somewhat inconsistent state, where files will be added to the project, but their parent folders will not. This in turn resulted in failure to remove these folders from project - they will show up in the UI tree, but deleting them will cause an error. In order to align with how Visual Studio manages the projects, this change will ensure that all intermediate folders are present in the project, when new files or folders are added.

While this change improves project "correctness" when accessing it through SQL projects extension APIs, there is still a possibility that someone will open an "incorrect" previously created project. This change does not address it and folder removal may still fail.

* Update the code to never throw on duplicate items when adding files and folders to project.

After a conversation with the sqlproj owners, we agreed that there are no scenarios that would prompt us to throw an error, if duplicate item is being added to the project. Ultimately, the goal of such a request would be to have an item in the project file, which is already present, therefore the call becomes a no-op.

This allowed me to simplify the new code that was ensuring all intermediate folders are present in the project when adding files and folders.
This commit is contained in:
Alexander Ivanov
2021-08-03 09:49:11 -07:00
committed by GitHub
parent 052cb54199
commit b35e78a07f
6 changed files with 279 additions and 137 deletions

View File

@@ -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<void> {
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<void> {
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<void> {
it('Should ignore duplicate file/folder entries in new sqlproj', async function (): Promise<void> {
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<void> {
it('Should ignore duplicate file entries in existing sqlproj', async function (): Promise<void> {
// 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<void> {
it('Should not overwrite existing files', async function (): Promise<void> {
// 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<void> {
it('Should not add folders outside of the project folder', async function (): Promise<void> {
// 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<void> {
// 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<void> {
// 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<void> {
// 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<void> {
// 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<void> {
// 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' }]);
});
});

View File

@@ -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);