diff --git a/extensions/sql-database-projects/src/controllers/projectController.ts b/extensions/sql-database-projects/src/controllers/projectController.ts index 894866f0c6..43688b14ef 100644 --- a/extensions/sql-database-projects/src/controllers/projectController.ts +++ b/extensions/sql-database-projects/src/controllers/projectController.ts @@ -56,11 +56,11 @@ export class ProjectsController { } } - const newProject = new Project(projectFile.fsPath); + let newProject: Project; try { // Read project file - await newProject.readProjFile(); + newProject = await Project.openProject(projectFile.fsPath); this.projects.push(newProject); // Update for round tripping as needed @@ -69,29 +69,29 @@ export class ProjectsController { // Read datasources.json (if present) const dataSourcesFilePath = path.join(path.dirname(projectFile.fsPath), constants.dataSourcesFileName); - newProject.dataSources = await dataSources.load(dataSourcesFilePath); - } - catch (err) { - if (err instanceof dataSources.NoDataSourcesFileError) { - // TODO: prompt to create new datasources.json; for now, swallow + try { + newProject.dataSources = await dataSources.load(dataSourcesFilePath); } - else { - this.projects = this.projects.filter((e) => { return e !== newProject; }); - throw err; + catch (err) { + if (err instanceof dataSources.NoDataSourcesFileError) { + // TODO: prompt to create new datasources.json; for now, swallow + } + else { + throw err; + } } - } - try { this.refreshProjectsTree(); await this.focusProject(newProject); } catch (err) { // if the project didnt load - remove it from the list of open projects this.projects = this.projects.filter((e) => { return e !== newProject; }); + throw err; } - return newProject; + return newProject!; } public async focusProject(project?: Project): Promise { @@ -633,36 +633,70 @@ export class ProjectsController { * prompting the user for a name, file path location and extract target */ public async importNewDatabaseProject(context: IConnectionProfile | any): Promise { - let model = {}; // TODO: Refactor code try { - let profile = this.getConnectionProfileFromContext(context); - //TODO: Prompt for new connection addition and get database information if context information isn't provided. + const model: ImportDataModel | undefined = await this.getModelFromContext(context); - let connectionId; - if (profile) { - model.database = profile.databaseName; - connectionId = profile.id; + if (!model) { + return; // cancelled by user } - else { - const connection = await this.apiWrapper.openConnectionDialog(); - if (!connection) { - return; - } - connectionId = connection.connectionId; + model.projName = await this.getProjectName(model.database); + let newProjFolderUri = (await this.getFolderLocation()).fsPath; + model.extractTarget = await this.getExtractTarget(); + model.version = '1.0.0.0'; - // use database that was connected to - if (connection.options['database']) { - model.database = connection.options['database']; - } + const newProjFilePath = await this.createNewProject(model.projName, Uri.file(newProjFolderUri), true); + + model.filePath = path.dirname(newProjFilePath); + + if (model.extractTarget === mssql.ExtractTarget.file) { + model.filePath = path.join(model.filePath, model.projName + '.sql'); // File extractTarget specifies the exact file rather than the containing folder + } + + const project = await Project.openProject(newProjFilePath); + + await this.importApiCall(model); // Call ExtractAPI in DacFx Service + let fileFolderList: string[] = model.extractTarget === mssql.ExtractTarget.file ? [model.filePath] : await this.generateList(model.filePath); // Create a list of all the files and directories to be added to project + + await project.addToProject(fileFolderList); // Add generated file structure to the project + await this.openProject(Uri.file(newProjFilePath)); + } + catch (err) { + this.apiWrapper.showErrorMessage(utils.getErrorMessage(err)); + } + } + + public async getModelFromContext(context: any): Promise { + let model = {}; + + let profile = this.getConnectionProfileFromContext(context); + let connectionId, database; + //TODO: Prompt for new connection addition and get database information if context information isn't provided. + + if (profile) { + database = profile.databaseName; + connectionId = profile.id; + } + else { + const connection = await this.apiWrapper.openConnectionDialog(); + + if (!connection) { + return undefined; + } + + connectionId = connection.connectionId; + + // use database that was connected to + if (connection.options['database']) { + database = connection.options['database']; } // choose database if connection was to a server or master if (!model.database || model.database === constants.master) { const databaseList = await this.apiWrapper.listDatabases(connectionId); - let database = (await this.apiWrapper.showQuickPick(databaseList.map(dbName => { return { label: dbName }; }), + database = (await this.apiWrapper.showQuickPick(databaseList.map(dbName => { return { label: dbName }; }), { canPickMany: false, placeHolder: constants.extractDatabaseSelection @@ -671,68 +705,13 @@ export class ProjectsController { if (!database) { throw new Error(constants.databaseSelectionRequired); } - - model.database = database; - model.serverId = connectionId; } - - // Get project name - let newProjName = await this.getProjectName(model.database); - if (!newProjName) { - throw new Error(constants.projectNameRequired); - } - model.projName = newProjName; - - // Get extractTarget - let extractTarget: mssql.ExtractTarget = await this.getExtractTarget(); - model.extractTarget = extractTarget; - - // Get folder location for project creation - let newProjUri = await this.getFolderLocation(model.extractTarget); - if (!newProjUri) { - throw new Error(constants.projectLocationRequired); - } - - // Set project folder/file location - let newProjFolderUri; - if (extractTarget === mssql.ExtractTarget['file']) { - // Get folder info, if extractTarget = File - newProjFolderUri = Uri.file(path.dirname(newProjUri.fsPath)); - } else { - newProjFolderUri = newProjUri; - } - - // Check folder is empty - let isEmpty: boolean = await this.isDirEmpty(newProjFolderUri.fsPath); - if (!isEmpty) { - throw new Error(constants.projectLocationNotEmpty); - } - // TODO: what if the selected folder is outside the workspace? - model.filePath = newProjUri.fsPath; - - //Set model version - model.version = '1.0.0.0'; - - // Call ExtractAPI in DacFx Service - await this.importApiCall(model); - // TODO: Check for success - - // Create and open new project - const newProjFilePath = await this.createNewProject(newProjName as string, newProjFolderUri as Uri, false); - const project = await this.openProject(Uri.file(newProjFilePath)); - - //Create a list of all the files and directories to be added to project - let fileFolderList: string[] = await this.generateList(model.filePath); - - // Add generated file structure to the project - await project.addToProject(fileFolderList); - - //Refresh project to show the added files - this.refreshProjectsTree(); - } - catch (err) { - this.apiWrapper.showErrorMessage(utils.getErrorMessage(err)); } + + model.database = database; + model.serverId = connectionId; + + return model; } private getConnectionProfileFromContext(context: IConnectionProfile | any): IConnectionProfile | undefined { @@ -745,7 +724,7 @@ export class ProjectsController { return (context).connectionProfile ? (context).connectionProfile : context; } - private async getProjectName(dbName: string): Promise { + private async getProjectName(dbName: string): Promise { let projName = await this.apiWrapper.showInputBox({ prompt: constants.newDatabaseProjectName, value: `DatabaseProject${dbName}` @@ -753,6 +732,10 @@ export class ProjectsController { projName = projName?.trim(); + if (!projName) { + throw new Error(constants.projectNameRequired); + } + return projName; } @@ -794,52 +777,36 @@ export class ProjectsController { return extractTarget; } - private async getFolderLocation(extractTarget: mssql.ExtractTarget): Promise { - let selectionResult; - let projUri; + private async getFolderLocation(): Promise { + let projUri: Uri; - if (extractTarget !== mssql.ExtractTarget.file) { - selectionResult = await this.apiWrapper.showOpenDialog({ - canSelectFiles: false, - canSelectFolders: true, - canSelectMany: false, - openLabel: constants.selectString, - defaultUri: this.apiWrapper.workspaceFolders() ? (this.apiWrapper.workspaceFolders() as WorkspaceFolder[])[0].uri : undefined - }); - if (selectionResult) { - projUri = (selectionResult as Uri[])[0]; - } - } else { - // Get filename - selectionResult = await this.apiWrapper.showSaveDialog( - { - defaultUri: this.apiWrapper.workspaceFolders() ? (this.apiWrapper.workspaceFolders() as WorkspaceFolder[])[0].uri : undefined, - saveLabel: constants.selectString, - filters: { - 'SQL files': ['sql'], - 'All files': ['*'] - } - } - ); - if (selectionResult) { - projUri = selectionResult as unknown as Uri; - } + const selectionResult = await this.apiWrapper.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + openLabel: constants.selectString, + defaultUri: this.apiWrapper.workspaceFolders() ? (this.apiWrapper.workspaceFolders() as WorkspaceFolder[])[0].uri : undefined + }); + + if (selectionResult) { + projUri = (selectionResult as Uri[])[0]; + } + else { + throw new Error(constants.projectLocationRequired); } return projUri; } - private async isDirEmpty(newProjFolderUri: string): Promise { - return (await fs.readdir(newProjFolderUri)).length === 0; - } - - private async importApiCall(model: ImportDataModel): Promise { + public async importApiCall(model: ImportDataModel): Promise { let ext = this.apiWrapper.getExtension(mssql.extension.name)!; const service = (await ext.activate() as mssql.IExtension).dacFx; const ownerUri = await this.apiWrapper.getUriForConnection(model.serverId); await service.importDatabaseProject(model.database, model.filePath, model.projName, model.version, ownerUri, model.extractTarget, TaskExecutionMode.execute); + + // TODO: Check for success; throw error } /** diff --git a/extensions/sql-database-projects/src/models/project.ts b/extensions/sql-database-projects/src/models/project.ts index 276ca1b564..ccc533d645 100644 --- a/extensions/sql-database-projects/src/models/project.ts +++ b/extensions/sql-database-projects/src/models/project.ts @@ -37,6 +37,16 @@ export class Project { this.projectFileName = path.basename(projectFilePath, '.sqlproj'); } + /** + * Open and load a .sqlproj file + */ + public static async openProject(projectFilePath: string): Promise { + const proj = new Project(projectFilePath); + await proj.readProjFile(); + + return proj; + } + /** * Reads the project setting and contents from the file */ diff --git a/extensions/sql-database-projects/src/test/project.test.ts b/extensions/sql-database-projects/src/test/project.test.ts index e82150884b..621f989407 100644 --- a/extensions/sql-database-projects/src/test/project.test.ts +++ b/extensions/sql-database-projects/src/test/project.test.ts @@ -26,8 +26,7 @@ describe('Project: sqlproj content operations', function (): void { }); it('Should read Project from sqlproj', async function (): Promise { - const project: Project = new Project(projFilePath); - await project.readProjFile(); + const project: Project = await Project.openProject(projFilePath); // Files and folders should(project.files.filter(f => f.type === EntryType.File).length).equal(4); @@ -48,8 +47,7 @@ describe('Project: sqlproj content operations', function (): void { }); it('Should add Folder and Build entries to sqlproj', async function (): Promise { - const project: Project = new Project(projFilePath); - await project.readProjFile(); + const project = await Project.openProject(projFilePath); const folderPath = 'Stored Procedures'; const filePath = path.join(folderPath, 'Fake Stored Proc.sql'); @@ -58,8 +56,7 @@ describe('Project: sqlproj content operations', function (): void { await project.addFolderItem(folderPath); await project.addScriptItem(filePath, fileContents); - const newProject = new Project(projFilePath); - await newProject.readProjFile(); + const newProject = await Project.openProject(projFilePath); should(newProject.files.find(f => f.type === EntryType.Folder && f.relativePath === convertSlashesForSqlProj(folderPath))).not.equal(undefined); should(newProject.files.find(f => f.type === EntryType.File && f.relativePath === convertSlashesForSqlProj(filePath))).not.equal(undefined); @@ -71,8 +68,7 @@ describe('Project: sqlproj content operations', function (): void { it('Should add Folder and Build entries to sqlproj with pre-existing scripts on disk', async function (): Promise { projFilePath = await testUtils.createTestSqlProjFile(baselines.newProjectFileBaseline); - const project: Project = new Project(projFilePath); - await project.readProjFile(); + const project = await Project.openProject(projFilePath); let list: string[] = await testUtils.createListOfFiles(path.dirname(projFilePath)); @@ -84,8 +80,7 @@ describe('Project: sqlproj content operations', function (): void { it('Should throw error while adding Folder and Build entries to sqlproj when a file/folder does not exist on disk', async function (): Promise { projFilePath = await testUtils.createTestSqlProjFile(baselines.newProjectFileBaseline); - const project = new Project(projFilePath); - await project.readProjFile(); + const project = await Project.openProject(projFilePath); let list: string[] = []; let testFolderPath: string = await testUtils.createDummyFileStructure(true, list, path.dirname(projFilePath)); @@ -98,8 +93,7 @@ describe('Project: sqlproj content operations', function (): void { it('Should choose correct master dacpac', async function (): Promise { projFilePath = await testUtils.createTestSqlProjFile(baselines.newProjectFileBaseline); - const project = new Project(projFilePath); - await project.readProjFile(); + const project = await Project.openProject(projFilePath); let uri = project.getSystemDacpacUri(constants.masterDacpac); let ssdtUri = project.getSystemDacpacSsdtUri(constants.masterDacpac); @@ -121,8 +115,7 @@ describe('Project: sqlproj content operations', function (): void { it('Should choose correct msdb dacpac', async function (): Promise { projFilePath = await testUtils.createTestSqlProjFile(baselines.newProjectFileBaseline); - const project = new Project(projFilePath); - await project.readProjFile(); + const project = await Project.openProject(projFilePath); let uri = project.getSystemDacpacUri(constants.msdbDacpac); let ssdtUri = project.getSystemDacpacSsdtUri(constants.msdbDacpac); @@ -144,8 +137,7 @@ describe('Project: sqlproj content operations', function (): void { it('Should throw error when choosing correct master dacpac if invalid DSP', async function (): Promise { projFilePath = await testUtils.createTestSqlProjFile(baselines.newProjectFileBaseline); - const project = new Project(projFilePath); - await project.readProjFile(); + const project = await Project.openProject(projFilePath); project.changeDSP('invalidPlatform'); await testUtils.shouldThrowSpecificError(async () => await project.getSystemDacpacUri(constants.masterDacpac), constants.invalidDataSchemaProvider); @@ -153,8 +145,7 @@ describe('Project: sqlproj content operations', function (): void { it('Should add database references correctly', async function (): Promise { projFilePath = await testUtils.createTestSqlProjFile(baselines.newProjectFileBaseline); - const project = new Project(projFilePath); - await project.readProjFile(); + const project = await Project.openProject(projFilePath); should(project.databaseReferences.length).equal(0, 'There should be no datbase references to start with'); await project.addSystemDatabaseReference(SystemDatabase.master); @@ -178,8 +169,7 @@ describe('Project: sqlproj content operations', function (): void { it('Should not allow adding duplicate database references', async function (): Promise { projFilePath = await testUtils.createTestSqlProjFile(baselines.newProjectFileBaseline); - const project = new Project(projFilePath); - await project.readProjFile(); + const project = await Project.openProject(projFilePath); should(project.databaseReferences.length).equal(0, 'There should be no database references to start with'); await project.addSystemDatabaseReference(SystemDatabase.master); @@ -220,8 +210,7 @@ describe('Project: round trip updates', function (): void { async function testUpdateInRoundTrip(fileBeforeupdate: string, fileAfterUpdate: string, testTargets: boolean, testReferences: boolean): Promise { projFilePath = await testUtils.createTestSqlProjFile(fileBeforeupdate); - const project: Project = new Project(projFilePath); - await project.readProjFile(); + const project = await Project.openProject(projFilePath); if (testTargets) { await testUpdateTargetsImportsRoundTrip(project); diff --git a/extensions/sql-database-projects/src/test/projectController.test.ts b/extensions/sql-database-projects/src/test/projectController.test.ts index b1c5721c60..923c8bcb14 100644 --- a/extensions/sql-database-projects/src/test/projectController.test.ts +++ b/extensions/sql-database-projects/src/test/projectController.test.ts @@ -189,8 +189,7 @@ describe('ProjectsController: project controller operations', function (): void await projController.delete(projTreeRoot.children.find(x => x.friendlyName === 'UpperFolder')!.children[0] /* LowerFolder */); - proj = new Project(proj.projectFilePath); - await proj.readProjFile(); // reload edited sqlproj from disk + proj = await Project.openProject(proj.projectFilePath); // reload edited sqlproj from disk // confirm result should(proj.files.length).equal(1, 'number of file/folder entries'); // lowerEntry and the contained scripts should be deleted @@ -208,8 +207,7 @@ describe('ProjectsController: project controller operations', function (): void await projController.exclude(projTreeRoot.children.find(x => x.friendlyName === 'UpperFolder')!.children[0] /* LowerFolder */); - proj = new Project(proj.projectFilePath); - await proj.readProjFile(); // reload edited sqlproj from disk + proj = await Project.openProject(proj.projectFilePath); // reload edited sqlproj from disk // confirm result should(proj.files.length).equal(1, 'number of file/folder entries'); // LowerFolder and the contained scripts should be deleted @@ -362,6 +360,7 @@ describe('ProjectsController: import operations', function (): void { it('Should show error when no target information provided', async function (): Promise { testContext.apiWrapper.setup(x => x.showInputBox(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve('MyProjectName')); + testContext.apiWrapper.setup(x => x.showOpenDialog(TypeMoq.It.isAny())).returns(() => Promise.resolve([vscode.Uri.file('fakePath')])); testContext.apiWrapper.setup(x => x.showQuickPick(TypeMoq.It.isAny())).returns(() => Promise.resolve(undefined)); testContext.apiWrapper.setup(x => x.showErrorMessage(TypeMoq.It.isAny())).returns((s) => { throw new Error(s); }); @@ -390,18 +389,55 @@ describe('ProjectsController: import operations', function (): void { await testUtils.shouldThrowSpecificError(async () => await projController.importNewDatabaseProject({ connectionProfile: mockConnectionProfile }), constants.projectLocationRequired); }); - it('Should show error when selected folder is not empty', async function (): Promise { - const testFolderPath = await testUtils.createDummyFileStructure(); + it('Should set model filePath correctly for ExtractType = File and not-File.', async function (): Promise { + const projectName = 'MyProjectName'; + let folderPath = await testUtils.generateTestFolderPath(); - testContext.apiWrapper.setup(x => x.showInputBox(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve('MyProjectName')); - testContext.apiWrapper.setup(x => x.showQuickPick(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve({ label: constants.objectType })); - testContext.apiWrapper.setup(x => x.showOpenDialog(TypeMoq.It.isAny())).returns(() => Promise.resolve([vscode.Uri.file(testFolderPath)])); - testContext.apiWrapper.setup(x => x.workspaceFolders()).returns(() => undefined); - testContext.apiWrapper.setup(x => x.showErrorMessage(TypeMoq.It.isAny())).returns((s) => { throw new Error(s); }); + testContext.apiWrapper.setup(x => x.showInputBox(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve(projectName)); + testContext.apiWrapper.setup(x => x.showQuickPick(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve({ label: constants.file })); + testContext.apiWrapper.setup(x => x.showOpenDialog(TypeMoq.It.isAny())).returns(() => Promise.resolve([vscode.Uri.file(folderPath)])); - const projController = new ProjectsController(testContext.apiWrapper.object, new SqlDatabaseProjectTreeViewProvider()); + let importPath; - await testUtils.shouldThrowSpecificError(async () => await projController.importNewDatabaseProject({ connectionProfile: mockConnectionProfile }), constants.projectLocationNotEmpty); + let projController = TypeMoq.Mock.ofType(ProjectsController, undefined, undefined, testContext.apiWrapper.object, new SqlDatabaseProjectTreeViewProvider()); + projController.callBase = true; + + projController.setup(x => x.importApiCall(TypeMoq.It.isAny())).returns(async (model) => { importPath = model.filePath; }); + + await projController.object.importNewDatabaseProject({ connectionProfile: mockConnectionProfile }); + should(importPath).equal(vscode.Uri.file(path.join(folderPath, projectName, projectName + '.sql')).fsPath, `model.filePath should be set to a specific file for ExtractTarget === file, but was ${importPath}`); + + // reset for counter-test + importPath = undefined; + folderPath = await testUtils.generateTestFolderPath(); + testContext.apiWrapper.setup(x => x.showQuickPick(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve({ label: constants.schemaObjectType })); + + await projController.object.importNewDatabaseProject({ connectionProfile: mockConnectionProfile }); + should(importPath).equal(vscode.Uri.file(path.join(folderPath, projectName)).fsPath, `model.filePath should be set to a folder for ExtractTarget !== file, but was ${importPath}`); + }); + + it('Should establish Import context correctly for ObjectExplorer and palette launch points', async function (): Promise { + // test welcome button and palette launch points (context-less) + let mockDbSelection = 'FakeDatabase'; + + testContext.apiWrapper.setup(x => x.listDatabases(TypeMoq.It.isAny())).returns(() => Promise.resolve([])); + testContext.apiWrapper.setup(x => x.showQuickPick(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve({ label: mockDbSelection })); + testContext.apiWrapper.setup(x => x.openConnectionDialog(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve({ + providerName: 'MSSQL', + connectionId: 'BA5EBA11-C0DE-5EA7-ACED-BABB1E70A575', + options: {} + })); + + let projController = new ProjectsController(testContext.apiWrapper.object, new SqlDatabaseProjectTreeViewProvider()); + + let result = await projController.getModelFromContext(undefined); + + should(result).deepEqual({database: mockDbSelection, serverId: 'BA5EBA11-C0DE-5EA7-ACED-BABB1E70A575'}); + + // test launch via Object Explorer context + testContext.apiWrapper.reset(); + result = await projController.getModelFromContext(mockConnectionProfile); + should(result).deepEqual({database: 'My Database', serverId: 'My Id'}); }); }); @@ -437,8 +473,7 @@ describe('ProjectsController: add database reference operations', function (): v it('Should return the correct system database', async function (): Promise { const projController = new ProjectsController(testContext.apiWrapper.object, new SqlDatabaseProjectTreeViewProvider()); const projFilePath = await testUtils.createTestSqlProjFile(baselines.openProjectFileBaseline); - const project: Project = new Project(projFilePath); - await project.readProjFile(); + const project = await Project.openProject(projFilePath); testContext.apiWrapper.setup(x => x.showQuickPick(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve({ label: constants.master })); let systemDb = await projController.getSystemDatabaseName(project); diff --git a/extensions/sql-database-projects/src/test/testUtils.ts b/extensions/sql-database-projects/src/test/testUtils.ts index 10240fcb1d..bdc0f5d348 100644 --- a/extensions/sql-database-projects/src/test/testUtils.ts +++ b/extensions/sql-database-projects/src/test/testUtils.ts @@ -32,10 +32,7 @@ export async function createTestSqlProjFile(contents: string, folderPath?: strin } export async function createTestProject(contents: string, folderPath?: string): Promise { - const proj = new Project(await createTestSqlProjFile(contents, folderPath)); - await proj.readProjFile(); - - return proj; + return await Project.openProject(await createTestSqlProjFile(contents, folderPath)); } export async function createTestDataSources(contents: string, folderPath?: string): Promise {