Database Projects - Add existing file (#18066)

* Add existing file to sqlproj

* Address PR comments

* Fix failing test

* Add convertSlashesForSqlProj to test failure
This commit is contained in:
Z Chen
2022-02-14 15:04:57 -08:00
committed by GitHub
parent 1dd7e93063
commit 9e574ae602
7 changed files with 187 additions and 5 deletions

View File

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

View File

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

View File

@@ -651,6 +651,29 @@ export class ProjectsController {
}
}
public async addExistingItemPrompt(treeNode: dataworkspace.WorkspaceTreeItem): Promise<void> {
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<void> {
const node = context.element as BaseProjectTreeItem;
const project = this.getProjectFromContext(node);

View File

@@ -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<FileProjectEntry> {
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<void> {
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<string, string>): Promise<void> {
// 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<Element>): boolean {
/**
* Deletes a node from the project file similar to <Compile Include="{includeString}" />
* @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 <Compile Remove="{includeString}" />
* @returns True when a node has been removed, false otherwise.
*/
private removeNode(includeString: string, nodes: HTMLCollectionOf<Element>, undoRemove: boolean = false): boolean {
// Default function behavior removes nodes like <Compile Include="..." />
// However when undoRemove is true, this function removes <Compile Remove="..." />
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: <Build Remove="Table1.sql" />
* @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<void> {
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<void> {
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

View File

@@ -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<void> {
// 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('<Build Include="test.sql" />')).equal(true, projFileText);
should(projFileText.includes('<None Include="foo\\test.txt" />')).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<void> {
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('<Build Include="test.sql" />')).equal(false, projFileText);
// Exclude this file, verify the <Build Remove=...> 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('<Build Remove="test.sql" />')).equal(true, projFileText);
// Add the file back, verify the <Build Remove=...> is no longer there
await project.addExistingItem(sqlFile);
projFileText = (await fs.readFile(projFilePath)).toString();
should(projFileText.includes('<Build Remove="test.sql" />')).equal(false, projFileText);
should(projFileText.includes('<Build Include="test.sql" />')).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 <None Include=...>
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('<None Include="test.txt" />')).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(`<Build Include="${externalFileRelativePath}" />`)).equal(true, projFileText);
});
});
describe('Project: add SQLCMD Variables', function (): void {