diff --git a/extensions/mssql/src/contracts.ts b/extensions/mssql/src/contracts.ts index debbae1a44..a16087e95f 100644 --- a/extensions/mssql/src/contracts.ts +++ b/extensions/mssql/src/contracts.ts @@ -421,6 +421,7 @@ export enum TaskExecutionMode { script = 1, executeAndScript = 2, } + export interface ExportParams { databaseName: string; packageFilePath: string; @@ -442,6 +443,7 @@ export interface ExtractParams { applicationName: string; applicationVersion: string; ownerUri: string; + extractTarget?: mssql.ExtractTarget; taskExecutionMode: TaskExecutionMode; } diff --git a/extensions/mssql/src/dacfx/dacFxService.ts b/extensions/mssql/src/dacfx/dacFxService.ts index aa8eb58947..2b7344f914 100644 --- a/extensions/mssql/src/dacfx/dacFxService.ts +++ b/extensions/mssql/src/dacfx/dacFxService.ts @@ -55,7 +55,18 @@ export class DacFxService implements mssql.IDacFxService { } public extractDacpac(databaseName: string, packageFilePath: string, applicationName: string, applicationVersion: string, ownerUri: string, taskExecutionMode: azdata.TaskExecutionMode): Thenable { - const params: contracts.ExtractParams = { databaseName: databaseName, packageFilePath: packageFilePath, applicationName: applicationName, applicationVersion: applicationVersion, ownerUri: ownerUri, taskExecutionMode: taskExecutionMode }; + const params: contracts.ExtractParams = { databaseName: databaseName, packageFilePath: packageFilePath, applicationName: applicationName, applicationVersion: applicationVersion, ownerUri: ownerUri, extractTarget: mssql.ExtractTarget.dacpac, taskExecutionMode: taskExecutionMode }; + return this.client.sendRequest(contracts.ExtractRequest.type, params).then( + undefined, + e => { + this.client.logFailedRequest(contracts.ExtractRequest.type, e); + return Promise.resolve(undefined); + } + ); + } + + public importDatabaseProject(databaseName: string, targetFilePath: string, applicationName: string, applicationVersion: string, ownerUri: string, extractTarget: mssql.ExtractTarget, taskExecutionMode: azdata.TaskExecutionMode): Thenable { + const params: contracts.ExtractParams = { databaseName: databaseName, packageFilePath: targetFilePath, applicationName: applicationName, applicationVersion: applicationVersion, ownerUri: ownerUri, extractTarget: extractTarget, taskExecutionMode: taskExecutionMode }; return this.client.sendRequest(contracts.ExtractRequest.type, params).then( undefined, e => { diff --git a/extensions/mssql/src/mssql.d.ts b/extensions/mssql/src/mssql.d.ts index e3c60a6e0b..798e71fe27 100644 --- a/extensions/mssql/src/mssql.d.ts +++ b/extensions/mssql/src/mssql.d.ts @@ -319,10 +319,20 @@ export interface SchemaCompareOpenScmpResult extends azdata.ResultStatus { //#endregion //#region --- dacfx +export const enum ExtractTarget { + dacpac = 0, + file = 1, + flat = 2, + objectType = 3, + schema = 4, + schemaObjectType = 5 +} + export interface IDacFxService { exportBacpac(databaseName: string, packageFilePath: string, ownerUri: string, taskExecutionMode: azdata.TaskExecutionMode): Thenable; importBacpac(packageFilePath: string, databaseName: string, ownerUri: string, taskExecutionMode: azdata.TaskExecutionMode): Thenable; extractDacpac(databaseName: string, packageFilePath: string, applicationName: string, applicationVersion: string, ownerUri: string, taskExecutionMode: azdata.TaskExecutionMode): Thenable; + importDatabaseProject(databaseName: string, targetFilePath: string, applicationName: string, applicationVersion: string, ownerUri: string, extractTarget: ExtractTarget, taskExecutionMode: azdata.TaskExecutionMode): Thenable; deployDacpac(packageFilePath: string, databaseName: string, upgradeExisting: boolean, ownerUri: string, taskExecutionMode: azdata.TaskExecutionMode, sqlCommandVariableValues?: Record): Thenable; generateDeployScript(packageFilePath: string, databaseName: string, ownerUri: string, taskExecutionMode: azdata.TaskExecutionMode, sqlCommandVariableValues?: Record): Thenable; generateDeployPlan(packageFilePath: string, databaseName: string, ownerUri: string, taskExecutionMode: azdata.TaskExecutionMode): Thenable; @@ -356,6 +366,7 @@ export interface ExtractParams { applicationName: string; applicationVersion: string; ownerUri: string; + extractTarget?: ExtractTarget; taskExecutionMode: azdata.TaskExecutionMode; } diff --git a/extensions/sql-database-projects/package.json b/extensions/sql-database-projects/package.json index 59ae6b1b16..c71d8358e3 100644 --- a/extensions/sql-database-projects/package.json +++ b/extensions/sql-database-projects/package.json @@ -15,6 +15,7 @@ "activationEvents": [ "onCommand:sqlDatabaseProjects.new", "onCommand:sqlDatabaseProjects.open", + "onCommand:sqlDatabaseProjects.importDatabase", "workspaceContains:**/*.sqlproj" ], "main": "./out/extension", @@ -107,6 +108,11 @@ "command": "sqlDatabaseProjects.schemaCompare", "title": "%sqlDatabaseProjects.schemaCompare%", "category": "%sqlDatabaseProjects.displayName%" + }, + { + "command": "sqlDatabaseProjects.importDatabase", + "title": "%sqlDatabaseProjects.importDatabase%", + "category": "%sqlDatabaseProjects.displayName%" } ], "menus": { @@ -154,7 +160,8 @@ "when": "false" }, { - "command": "sqlDatabaseProjects.import" + "command": "sqlDatabaseProjects.import", + "when": "false" }, { "command": "sqlDatabaseProjects.properties", @@ -163,6 +170,9 @@ { "command": "sqlDatabaseProjects.schemaCompare", "when": "false" + }, + { + "command": "sqlDatabaseProjects.importDatabase" } ], "view/item/context": [ @@ -226,6 +236,13 @@ "when": "view == sqlDatabaseProjectsView", "group": "9_dbProjectsLast" } + ], + "objectExplorer/item/context": [ + { + "command": "sqlDatabaseProjects.importDatabase", + "when": "connectionProvider == MSSQL && nodeType && nodeType == Database && mssql:engineedition != 11", + "group": "export" + } ] }, "views": { diff --git a/extensions/sql-database-projects/package.nls.json b/extensions/sql-database-projects/package.nls.json index ac6a6658f2..30ce9f5431 100644 --- a/extensions/sql-database-projects/package.nls.json +++ b/extensions/sql-database-projects/package.nls.json @@ -19,6 +19,7 @@ "sqlDatabaseProjects.newItem": "Add Item...", "sqlDatabaseProjects.newFolder": "Add Folder", + "sqlDatabaseProjects.importDatabase": "Import New Database Project", "sqlDatabaseProjects.Settings": "Database Projects", "sqlDatabaseProjects.netCoreInstallLocation": "Full Path to .Net Core SDK on the machine." diff --git a/extensions/sql-database-projects/resources/templates/newSqlProjectTemplate.xml b/extensions/sql-database-projects/resources/templates/newSqlProjectTemplate.xml index a7225684cb..6ebef84f7c 100644 --- a/extensions/sql-database-projects/resources/templates/newSqlProjectTemplate.xml +++ b/extensions/sql-database-projects/resources/templates/newSqlProjectTemplate.xml @@ -55,10 +55,10 @@ - - - + + + diff --git a/extensions/sql-database-projects/src/common/apiWrapper.ts b/extensions/sql-database-projects/src/common/apiWrapper.ts index 4a229d6f90..159260de54 100644 --- a/extensions/sql-database-projects/src/common/apiWrapper.ts +++ b/extensions/sql-database-projects/src/common/apiWrapper.ts @@ -123,6 +123,10 @@ export class ApiWrapper { return vscode.window.showInputBox(options, token); } + public showSaveDialog(options: vscode.SaveDialogOptions): Thenable { + return vscode.window.showSaveDialog(options); + } + public listDatabases(connectionId: string): Thenable { return azdata.connection.listDatabases(connectionId); } diff --git a/extensions/sql-database-projects/src/common/constants.ts b/extensions/sql-database-projects/src/common/constants.ts index 40d0af5354..81f163b08d 100644 --- a/extensions/sql-database-projects/src/common/constants.ts +++ b/extensions/sql-database-projects/src/common/constants.ts @@ -10,8 +10,7 @@ const localize = nls.loadMessageBundle(); // Placeholder values export const dataSourcesFileName = 'datasources.json'; export const sqlprojExtension = '.sqlproj'; - -// Commands +export const sqlFileExtension = '.sql'; export const schemaCompareExtensionId = 'microsoft.schema-compare'; export const sqlDatabaseProjectExtensionId = 'microsoft.sql-database-projects'; export const mssqlExtensionId = 'microsoft.mssql'; @@ -24,6 +23,8 @@ export const dataSourcesNodeName = localize('dataSourcesNodeName', "Data Sources export const sqlConnectionStringFriendly = localize('sqlConnectionStringFriendly', "SQL connection string"); export const newDatabaseProjectName = localize('newDatabaseProjectName', "New database project name:"); export const sqlDatabaseProject = localize('sqlDatabaseProject', "SQL database project"); +export const extractTargetInput = localize('extractTargetInput', "Target for extraction:"); +export const selectFileFolder = localize('selectFileFolder', "Select"); export function newObjectNamePrompt(objectType: string) { return localize('newObjectNamePrompt', 'New {0} name:', objectType); } // Deploy dialog strings @@ -55,10 +56,15 @@ export const unknownDataSourceType = localize('unknownDataSourceType', "Unknown export const invalidSqlConnectionString = localize('invalidSqlConnectionString', "Invalid SQL connection string"); export const projectNameRequired = localize('projectNameRequired', "Name is required to create a new database project."); export const projectLocationRequired = localize('projectLocationRequired', "Location is required to create a new database project."); +export const projectLocationNotEmpty = localize('projectLocationNotEmpty', "Current project location is not empty. Select an empty folder for precise extraction."); +export const extractTargetRequired = localize('extractTargetRequired', "Target information for extract is required to import database to project."); export const schemaCompareNotInstalled = localize('schemaCompareNotInstalled', "Schema compare extension installation is required to run schema compare"); export const buildDacpacNotFound = localize('buildDacpacNotFound', "Dacpac created from build not found"); export function projectAlreadyOpened(path: string) { return localize('projectAlreadyOpened', "Project '{0}' is already opened.", path); } export function projectAlreadyExists(name: string, path: string) { return localize('projectAlreadyExists', "A project named {0} already exists in {1}.", name, path); } +export function noFileExist(fileName: string) { return localize('noFileExist', "File {0} doesn't exist", fileName); } +export function cannotResolvePath(path: string) { return localize('cannotResolvePath', "Cannot resolve path {0}", path); } + export function mssqlNotFound(mssqlConfigDir: string) { return localize('mssqlNotFound', "Could not get mssql extension's install location at {0}", mssqlConfigDir); } export function projBuildFailed(errorMessage: string) { return localize('projBuildFailed', "Build failed. Check output pane for more details. {0}", errorMessage); } export function unexpectedProjectContext(uri: string) { return localize('unexpectedProjectContext', "Unable to establish project context. Command invoked from unexpected location: {0}", uri); } diff --git a/extensions/sql-database-projects/src/common/utils.ts b/extensions/sql-database-projects/src/common/utils.ts index eb480195db..e591805f62 100644 --- a/extensions/sql-database-projects/src/common/utils.ts +++ b/extensions/sql-database-projects/src/common/utils.ts @@ -51,6 +51,25 @@ export function trimChars(input: string, chars: string): string { return output; } +/** + * Checks if the folder or file exists @param path path of the folder/file +*/ +export async function exists(path: string): Promise { + try { + await fs.access(path); + return true; + } catch (e) { + return false; + } +} + +/** + * Convert camelCase input to PascalCase + */ +export function toPascalCase(input: string): string { + return input.charAt(0).toUpperCase() + input.substr(1); +} + /** * get quoted path to be used in any commandline argument * @param filePath @@ -76,15 +95,3 @@ export function getSafeNonWindowsPath(filePath: string): string { filePath = filePath.split('\\').join('/').split('"').join(''); return '"' + filePath + '"'; } - -/** - * Checks if the folder or file exists @param path path of the folder/file -*/ -export async function exists(path: string): Promise { - try { - await fs.access(path); - return true; - } catch (e) { - return false; - } -} diff --git a/extensions/sql-database-projects/src/controllers/mainController.ts b/extensions/sql-database-projects/src/controllers/mainController.ts index 492d4fe526..42841bfa5f 100644 --- a/extensions/sql-database-projects/src/controllers/mainController.ts +++ b/extensions/sql-database-projects/src/controllers/mainController.ts @@ -3,6 +3,7 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ +import * as azdata from 'azdata'; import * as templates from '../templates/templates'; import * as constants from '../common/constants'; import * as path from 'path'; @@ -61,6 +62,8 @@ export default class MainController implements Disposable { this.apiWrapper.registerCommand('sqlDatabaseProjects.newItem', async (node: BaseProjectTreeItem) => { await this.projectsController.addItemPromptFromNode(node); }); this.apiWrapper.registerCommand('sqlDatabaseProjects.newFolder', async (node: BaseProjectTreeItem) => { await this.projectsController.addFolderPrompt(node); }); + this.apiWrapper.registerCommand('sqlDatabaseProjects.importDatabase', async (profile: azdata.IConnectionProfile) => { await this.projectsController.importNewDatabaseProject(profile); }); + // init view this.extensionContext.subscriptions.push(this.apiWrapper.registerTreeDataProvider(SQL_DATABASE_PROJECTS_VIEW_ID, this.dbProjectTreeViewProvider)); diff --git a/extensions/sql-database-projects/src/controllers/projectController.ts b/extensions/sql-database-projects/src/controllers/projectController.ts index e17b156fc8..5b3534a2d9 100644 --- a/extensions/sql-database-projects/src/controllers/projectController.ts +++ b/extensions/sql-database-projects/src/controllers/projectController.ts @@ -11,9 +11,9 @@ import * as utils from '../common/utils'; import * as UUID from 'vscode-languageclient/lib/utils/uuid'; import * as templates from '../templates/templates'; -import { TaskExecutionMode } from 'azdata'; +import { Uri, QuickPickItem, WorkspaceFolder, extensions, Extension } from 'vscode'; +import { IConnectionProfile, TaskExecutionMode } from 'azdata'; import { promises as fs } from 'fs'; -import { Uri, QuickPickItem, extensions, Extension } from 'vscode'; import { ApiWrapper } from '../common/apiWrapper'; import { DeployDatabaseDialog } from '../dialogs/deployDatabaseDialog'; import { Project } from '../models/project'; @@ -22,9 +22,20 @@ import { FolderNode } from '../models/tree/fileFolderTreeItem'; import { IDeploymentProfile, IGenerateScriptProfile } from '../models/IDeploymentProfile'; import { BaseProjectTreeItem } from '../models/tree/baseTreeItem'; import { ProjectRootTreeItem } from '../models/tree/projectTreeItem'; +import { ImportDataModel } from '../models/api/import'; import { NetCoreTool, DotNetCommandOptions } from '../tools/netcoreTool'; import { BuildHelper } from '../tools/buildHelper'; +// TODO: use string enums +export enum ExtractTarget { + dacpac = 0, + file = 1, + flat = 2, + objectType = 3, + schema = 4, + schemaObjectType = 5 +} + /** * Controller for managing project lifecycle */ @@ -269,7 +280,7 @@ export class ProjectsController { // TODO: file already exists? const newFileText = this.macroExpansion(itemType.templateScript, { 'OBJECT_NAME': itemObjectName }); - const relativeFilePath = path.join(relativePath, itemObjectName + '.sql'); + const relativeFilePath = path.join(relativePath, itemObjectName + constants.sqlFileExtension); const newEntry = await project.addScriptItem(relativeFilePath, newFileText); @@ -336,5 +347,218 @@ export class ProjectsController { return treeNode instanceof FolderNode ? utils.trimUri(treeNode.root.uri, treeNode.uri) : ''; } + /** + * Imports a new SQL database project from the existing database, + * prompting the user for a name, file path location and extract target + */ + public async importNewDatabaseProject(context: any): Promise { + let model = {}; + + // TODO: Refactor code + try { + let profile = context ? context.connectionProfile : undefined; + //TODO: Prompt for new connection addition and get database information if context information isn't provided. + if (profile) { + model.serverId = profile.id; + model.database = profile.databaseName; + } + + // 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); + 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)); + } + } + + private async getProjectName(dbName: string): Promise { + let projName = await this.apiWrapper.showInputBox({ + prompt: constants.newDatabaseProjectName, + value: `DatabaseProject${dbName}` + }); + + projName = projName?.trim(); + + return projName; + } + + private mapExtractTargetEnum(inputTarget: any): mssql.ExtractTarget { + if (inputTarget) { + switch (inputTarget) { + case 'File': return mssql.ExtractTarget['file']; + case 'Flat': return mssql.ExtractTarget['flat']; + case 'ObjectType': return mssql.ExtractTarget['objectType']; + case 'Schema': return mssql.ExtractTarget['schema']; + case 'SchemaObjectType': return mssql.ExtractTarget['schemaObjectType']; + default: throw new Error(`Invalid input: ${inputTarget}`); + } + } else { + throw new Error(constants.extractTargetRequired); + } + } + + private async getExtractTarget(): Promise { + let extractTarget: mssql.ExtractTarget; + + let extractTargetOptions: QuickPickItem[] = []; + + let keys: string[] = Object.keys(ExtractTarget).filter(k => typeof ExtractTarget[k as any] === 'number'); + + // TODO: Create a wrapper class to handle the mapping + keys.forEach((targetOption: string) => { + if (targetOption !== 'dacpac') { //Do not present the option to create Dacpac + let pascalCaseTargetOption: string = utils.toPascalCase(targetOption); // for better readability + extractTargetOptions.push({ label: pascalCaseTargetOption }); + } + }); + + let input = await this.apiWrapper.showQuickPick(extractTargetOptions, { + canPickMany: false, + placeHolder: constants.extractTargetInput + }); + let extractTargetInput = input?.label; + + extractTarget = this.mapExtractTargetEnum(extractTargetInput); + + return extractTarget; + } + + private async getFolderLocation(extractTarget: mssql.ExtractTarget): Promise { + let selectionResult; + let projUri; + + if (extractTarget !== mssql.ExtractTarget.file) { + selectionResult = await this.apiWrapper.showOpenDialog({ + canSelectFiles: false, + canSelectFolders: true, + canSelectMany: false, + openLabel: constants.selectFileFolder, + 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.selectFileFolder, + filters: { + 'SQL files': ['sql'], + 'All files': ['*'] + } + } + ); + if (selectionResult) { + projUri = selectionResult as unknown as Uri; + } + } + + return projUri; + } + + private async isDirEmpty(newProjFolderUri: string): Promise { + return (await fs.readdir(newProjFolderUri)).length === 0; + } + + private 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); + } + + /** + * Generate a flat list of all files and folder under a folder. + */ + public async generateList(absolutePath: string): Promise { + let fileFolderList: string[] = []; + + if (!await utils.exists(absolutePath)) { + if (await utils.exists(absolutePath + constants.sqlFileExtension)) { + absolutePath += constants.sqlFileExtension; + } else { + await this.apiWrapper.showErrorMessage(constants.cannotResolvePath(absolutePath)); + return fileFolderList; + } + } + + const files = [absolutePath]; + do { + const filepath = files.pop(); + + if (filepath) { + const stat = await fs.stat(filepath); + + if (stat.isDirectory()) { + fileFolderList.push(filepath); + (await fs + .readdir(filepath)) + .forEach((f: string) => files.push(path.join(filepath, f))); + } + else if (stat.isFile()) { + fileFolderList.push(filepath); + } + } + + } while (files.length !== 0); + + return fileFolderList; + } + //#endregion } diff --git a/extensions/sql-database-projects/src/models/api/import.ts b/extensions/sql-database-projects/src/models/api/import.ts new file mode 100644 index 0000000000..476d522604 --- /dev/null +++ b/extensions/sql-database-projects/src/models/api/import.ts @@ -0,0 +1,18 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { ExtractTarget } from '../../../../mssql'; + +/** + * Data model to communicate for Import API + */ +export interface ImportDataModel { + serverId: string; + database: string; + projName: string; + filePath: string; + version: string; + extractTarget: ExtractTarget; +} diff --git a/extensions/sql-database-projects/src/models/project.ts b/extensions/sql-database-projects/src/models/project.ts index 251e93436e..efea4b61e6 100644 --- a/extensions/sql-database-projects/src/models/project.ts +++ b/extensions/sql-database-projects/src/models/project.ts @@ -6,6 +6,7 @@ import * as path from 'path'; import * as xmldom from 'xmldom'; import * as constants from '../common/constants'; +import * as utils from '../common/utils'; import { Uri } from 'vscode'; import { promises as fs } from 'fs'; @@ -60,7 +61,12 @@ export class Project { */ public async addFolderItem(relativeFolderPath: string): Promise { const absoluteFolderPath = path.join(this.projectFolderPath, relativeFolderPath); - await fs.mkdir(absoluteFolderPath, { recursive: true }); + + //If folder doesn't exist, create it + let exists = await utils.exists(absoluteFolderPath); + if (!exists) { + await fs.mkdir(absoluteFolderPath, { recursive: true }); + } const folderEntry = this.createProjectEntry(relativeFolderPath, EntryType.Folder); this.files.push(folderEntry); @@ -70,14 +76,23 @@ export class Project { } /** - * Writes a file to disk, adds that file to the project, and writes it to disk + * 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 */ - public async addScriptItem(relativeFilePath: string, contents: string): Promise { + public async addScriptItem(relativeFilePath: string, contents?: string): Promise { const absoluteFilePath = path.join(this.projectFolderPath, relativeFilePath); - await fs.mkdir(path.dirname(absoluteFilePath), { recursive: true }); - await fs.writeFile(absoluteFilePath, contents); + + if (contents) { + await fs.mkdir(path.dirname(absoluteFilePath), { recursive: true }); + await fs.writeFile(absoluteFilePath, contents); + } + + //Check that file actually exists + let exists = await utils.exists(absoluteFilePath); + if (!exists) { + throw new Error(constants.noFileExist(absoluteFilePath)); + } const fileEntry = this.createProjectEntry(relativeFilePath, EntryType.File); this.files.push(fileEntry); @@ -145,6 +160,29 @@ export class Project { await fs.writeFile(this.projectFilePath, xml); } + + /** + * Adds the list of sql files and directories to the project, and saves the project file + * @param absolutePath Absolute path of the folder + */ + public async addToProject(list: string[]): Promise { + + for (let i = 0; i < list.length; i++) { + let file: string = list[i]; + const relativePath = utils.trimChars(utils.trimUri(Uri.file(this.projectFilePath), Uri.file(file)), '/'); + + if (relativePath.length > 0) { + let fileStat = await fs.stat(file); + + if (fileStat.isFile() && file.toLowerCase().endsWith(constants.sqlFileExtension)) { + await this.addScriptItem(relativePath); + } + else if (fileStat.isDirectory()) { + await this.addFolderItem(relativePath); + } + } + } + } } /** diff --git a/extensions/sql-database-projects/src/test/baselines/newSqlProjectBaseline.xml b/extensions/sql-database-projects/src/test/baselines/newSqlProjectBaseline.xml index 17fd60610b..7f2fb66c3e 100644 --- a/extensions/sql-database-projects/src/test/baselines/newSqlProjectBaseline.xml +++ b/extensions/sql-database-projects/src/test/baselines/newSqlProjectBaseline.xml @@ -55,10 +55,10 @@ - - - + + + diff --git a/extensions/sql-database-projects/src/test/baselines/openSqlProjectBaseline.xml b/extensions/sql-database-projects/src/test/baselines/openSqlProjectBaseline.xml index 1ee9075d77..d0995e66f3 100644 --- a/extensions/sql-database-projects/src/test/baselines/openSqlProjectBaseline.xml +++ b/extensions/sql-database-projects/src/test/baselines/openSqlProjectBaseline.xml @@ -52,8 +52,12 @@ True 11.0 - - + + + + + + diff --git a/extensions/sql-database-projects/src/test/project.test.ts b/extensions/sql-database-projects/src/test/project.test.ts index 37af69aec3..3cb064c4d8 100644 --- a/extensions/sql-database-projects/src/test/project.test.ts +++ b/extensions/sql-database-projects/src/test/project.test.ts @@ -54,4 +54,17 @@ describe('Project: sqlproj content operations', function (): void { should (newFileContents).equal(fileContents); }); + + 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(); + + let list: string[] = await testUtils.createListOfFiles(path.dirname(projFilePath)); + + await project.addToProject(list); + + should(project.files.filter(f => f.type === EntryType.File).length).equal(11); // txt file shouldn't be added to the project + should(project.files.filter(f => f.type === EntryType.Folder).length).equal(3); // 2folders + default Properties folder + }); }); diff --git a/extensions/sql-database-projects/src/test/projectController.test.ts b/extensions/sql-database-projects/src/test/projectController.test.ts index 50a94bfe74..e3b5a6c943 100644 --- a/extensions/sql-database-projects/src/test/projectController.test.ts +++ b/extensions/sql-database-projects/src/test/projectController.test.ts @@ -6,11 +6,13 @@ import * as should from 'should'; import * as path from 'path'; import * as os from 'os'; +import * as azdata from 'azdata'; import * as vscode from 'vscode'; import * as TypeMoq from 'typemoq'; import * as baselines from './baselines/baselines'; import * as templates from '../templates/templates'; import * as testUtils from './testUtils'; +import * as constants from '../common/constants'; import { SqlDatabaseProjectTreeViewProvider } from '../controllers/databaseProjectTreeViewProvider'; import { ProjectsController } from '../controllers/projectController'; @@ -23,6 +25,23 @@ import { IDeploymentProfile, IGenerateScriptProfile } from '../models/IDeploymen let testContext: TestContext; +// Mock test data +const mockConnectionProfile: azdata.IConnectionProfile = { + connectionName: 'My Connection', + serverName: 'My Server', + databaseName: 'My Database', + userName: 'My User', + password: 'My Pwd', + authenticationType: 'SqlLogin', + savePassword: false, + groupFullName: 'My groupName', + groupId: 'My GroupId', + providerName: 'My Server', + saveProfile: true, + id: 'My Id', + options: undefined as any +}; + describe('ProjectsController: project controller operations', function (): void { before(async function (): Promise { testContext = createContext(); @@ -126,3 +145,64 @@ describe('ProjectsController: project controller operations', function (): void }); }); }); + +describe('ProjectsController: import operations', function (): void { + it('Should create list of all files and folders correctly', async function (): Promise { + const testFolderPath = await testUtils.createDummyFileStructure(); + + const projController = new ProjectsController(testContext.apiWrapper.object, new SqlDatabaseProjectTreeViewProvider()); + const fileList = await projController.generateList(testFolderPath); + + should(fileList.length).equal(15); // Parent folder + 2 files under parent folder + 2 directories with 5 files each + }); + + it('Should error out for inaccessible path', async function (): Promise { + let testFolderPath = await testUtils.generateTestFolderPath(); + testFolderPath += '_nonExistant'; // Modify folder path to point to a non-existant location + + const projController = new ProjectsController(testContext.apiWrapper.object, new SqlDatabaseProjectTreeViewProvider()); + + await testUtils.shouldThrowSpecificError(async () => await projController.generateList(testFolderPath), constants.cannotResolvePath(testFolderPath)); + }); + + it('Should show error when no project name provided', async function (): Promise { + for (const name of ['', ' ', undefined]) { + testContext.apiWrapper.reset(); + testContext.apiWrapper.setup(x => x.showInputBox(TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve(name)); + testContext.apiWrapper.setup(x => x.showErrorMessage(TypeMoq.It.isAny())).returns((s) => { throw new Error(s); }); + + const projController = new ProjectsController(testContext.apiWrapper.object, new SqlDatabaseProjectTreeViewProvider()); + await testUtils.shouldThrowSpecificError(async () => await projController.importNewDatabaseProject(mockConnectionProfile), constants.projectNameRequired, `case: '${name}'`); + } + }); + + 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.showQuickPick(TypeMoq.It.isAny())).returns(() => Promise.resolve(undefined)); + testContext.apiWrapper.setup(x => x.showErrorMessage(TypeMoq.It.isAny())).returns((s) => { throw new Error(s); }); + + const projController = new ProjectsController(testContext.apiWrapper.object, new SqlDatabaseProjectTreeViewProvider()); + await testUtils.shouldThrowSpecificError(async () => await projController.importNewDatabaseProject(mockConnectionProfile), constants.extractTargetRequired); + }); + + it('Should show error when no location provided with ExtractTarget = File', async function (): Promise { + 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: 'File'})); + testContext.apiWrapper.setup(x => x.showSaveDialog(TypeMoq.It.isAny())).returns(() => Promise.resolve(undefined)); + testContext.apiWrapper.setup(x => x.showErrorMessage(TypeMoq.It.isAny())).returns((s) => { throw new Error(s); }); + + const projController = new ProjectsController(testContext.apiWrapper.object, new SqlDatabaseProjectTreeViewProvider()); + await testUtils.shouldThrowSpecificError(async () => await projController.importNewDatabaseProject(mockConnectionProfile), constants.projectLocationRequired); + }); + + it('Should show error when no location provided with ExtractTarget = SchemaObjectType', async function (): Promise { + 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())).returns(() => Promise.resolve({label: 'SchemaObjectType'})); + testContext.apiWrapper.setup(x => x.showOpenDialog(TypeMoq.It.isAny())).returns(() => Promise.resolve(undefined)); + testContext.apiWrapper.setup(x => x.workspaceFolders()).returns(() => undefined); + testContext.apiWrapper.setup(x => x.showErrorMessage(TypeMoq.It.isAny())).returns((s) => { throw new Error(s); }); + + const projController = new ProjectsController(testContext.apiWrapper.object, new SqlDatabaseProjectTreeViewProvider()); + await testUtils.shouldThrowSpecificError(async () => await projController.importNewDatabaseProject(mockConnectionProfile), constants.projectLocationRequired); + }); +}); diff --git a/extensions/sql-database-projects/src/test/testUtils.ts b/extensions/sql-database-projects/src/test/testUtils.ts index 648a4222cd..562212f1c4 100644 --- a/extensions/sql-database-projects/src/test/testUtils.ts +++ b/extensions/sql-database-projects/src/test/testUtils.ts @@ -55,3 +55,65 @@ async function createTestFile(contents: string, fileName: string, folderPath?: s return filePath; } +/** + * TestFolder directory structure + * - file1.sql + * - folder1 + * -file1.sql + * -file2.sql + * -file3.sql + * -file4.sql + * -file5.sql + * - folder2 + * -file1.sql + * -file2.sql + * -file3.sql + * -file4.sql + * -file5.sql + * - file2.txt + * + * @param createList Boolean specifying to create a list of the files and folders been created + * @param list List of files and folders that are been created + */ +export async function createDummyFileStructure(createList?: boolean, list?: string[], testFolderPath?: string): Promise { + testFolderPath = testFolderPath ?? await generateTestFolderPath(); + + let filePath = path.join(testFolderPath, 'file1.sql'); + await fs.open(filePath, 'w'); + if (createList) { + list?.push(testFolderPath); + list?.push(filePath); + } + + for (let dirCount = 1; dirCount <= 2; dirCount++) { + let dirName = path.join(testFolderPath, `folder${dirCount}`); + await fs.mkdir(dirName, { recursive: true }); + if (createList) { + list?.push(dirName); + } + + for (let fileCount = 1; fileCount <= 5; fileCount++) { + let fileName = path.join(dirName, `file${fileCount}.sql`); + await fs.open(fileName, 'w'); + if (createList) { + list?.push(fileName); + } + } + } + + filePath = path.join(testFolderPath, 'file2.txt'); + await fs.open(filePath, 'w'); + if (createList) { + list?.push(filePath); + } + + return testFolderPath; +} + +export async function createListOfFiles(filePath?: string): Promise { + let fileFolderList: string[] = []; + + await createDummyFileStructure(true, fileFolderList, filePath); + + return fileFolderList; +} diff --git a/extensions/sql-database-projects/src/test/utils.test.ts b/extensions/sql-database-projects/src/test/utils.test.ts new file mode 100644 index 0000000000..de28347e6c --- /dev/null +++ b/extensions/sql-database-projects/src/test/utils.test.ts @@ -0,0 +1,30 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as should from 'should'; +import * as path from 'path'; +import {createDummyFileStructure} from './testUtils'; +import {toPascalCase, exists} from '../common/utils'; + +describe('Tests for conversion within PascalCase and camelCase', function (): void { + it('Should generate PascalCase from camelCase correctly', async () => { + should(toPascalCase('')).equal(''); + should(toPascalCase('camelCase')).equal('CamelCase'); + should(toPascalCase('camel.case')).equal('Camel.case'); + }); +}); + +describe('Tests to verify exists function', function (): void { + it('Should determine existance of files/folders', async () => { + let testFolderPath = await createDummyFileStructure(); + + should(await exists(testFolderPath)).equal(true); + should(await exists(path.join(testFolderPath, 'file1.sql'))).equal(true); + should(await exists(path.join(testFolderPath, 'folder2'))).equal(true); + should(await exists(path.join(testFolderPath, 'folder4'))).equal(false); + should(await exists(path.join(testFolderPath, 'folder2','file4.sql'))).equal(true); + should(await exists(path.join(testFolderPath, 'folder4','file2.sql'))).equal(false); + }); +}); diff --git a/src/sql/workbench/contrib/dataExplorer/browser/extensionActions.ts b/src/sql/workbench/contrib/dataExplorer/browser/extensionActions.ts index e544fc6014..84c2e77b1f 100644 --- a/src/sql/workbench/contrib/dataExplorer/browser/extensionActions.ts +++ b/src/sql/workbench/contrib/dataExplorer/browser/extensionActions.ts @@ -17,6 +17,7 @@ export const IMPORT_COMMAND_ID = 'dataExplorer.flatFileImport'; export const SCHEMA_COMPARE_COMMAND_ID = 'dataExplorer.schemaCompare'; export const GENERATE_SCRIPTS_COMMAND_ID = 'dataExplorer.generateScripts'; export const PROPERTIES_COMMAND_ID = 'dataExplorer.properties'; +export const IMPORT_DATABASE_COMMAND_ID = 'dataExplorer.importDatabase'; // Data Tier Wizard @@ -98,3 +99,13 @@ CommandsRegistry.registerCommand({ return commandService.executeCommand('adminToolExtWin.launchSsmsMinPropertiesDialog', objectExplorerContext); } }); + +// Import Database +CommandsRegistry.registerCommand({ + id: IMPORT_DATABASE_COMMAND_ID, + handler: (accessor, args: TreeViewItemHandleArg) => { + const commandService = accessor.get(ICommandService); + let connectedContext: azdata.ConnectedContext = { connectionProfile: args.$treeItem.payload }; + return commandService.executeCommand('sqlDatabaseProjects.importDatabase', connectedContext); + } +}); diff --git a/src/sql/workbench/contrib/dataExplorer/browser/extensions.contribution.ts b/src/sql/workbench/contrib/dataExplorer/browser/extensions.contribution.ts index c86d475dd2..6f7b74f00d 100644 --- a/src/sql/workbench/contrib/dataExplorer/browser/extensions.contribution.ts +++ b/src/sql/workbench/contrib/dataExplorer/browser/extensions.contribution.ts @@ -4,7 +4,7 @@ *--------------------------------------------------------------------------------------------*/ import { MenuRegistry, MenuId } from 'vs/platform/actions/common/actions'; -import { DATA_TIER_WIZARD_COMMAND_ID, PROFILER_COMMAND_ID, IMPORT_COMMAND_ID, SCHEMA_COMPARE_COMMAND_ID, GENERATE_SCRIPTS_COMMAND_ID, PROPERTIES_COMMAND_ID } from 'sql/workbench/contrib/dataExplorer/browser/extensionActions'; +import { DATA_TIER_WIZARD_COMMAND_ID, PROFILER_COMMAND_ID, IMPORT_COMMAND_ID, SCHEMA_COMPARE_COMMAND_ID, GENERATE_SCRIPTS_COMMAND_ID, PROPERTIES_COMMAND_ID, IMPORT_DATABASE_COMMAND_ID } from 'sql/workbench/contrib/dataExplorer/browser/extensionActions'; import { ContextKeyExpr, ContextKeyRegexExpr } from 'vs/platform/contextkey/common/contextkey'; import { MssqlNodeContext } from 'sql/workbench/services/objectExplorer/browser/mssqlNodeContext'; import { mssqlProviderName } from 'sql/platform/connection/common/constants'; @@ -38,10 +38,22 @@ MenuRegistry.appendMenuItem(MenuId.DataExplorerContext, { MssqlNodeContext.EngineEdition.notEqualsTo(DatabaseEngineEdition.SqlOnDemand.toString())) }); +// Import Database +MenuRegistry.appendMenuItem(MenuId.DataExplorerContext, { + group: 'export', + order: 8, + command: { + id: IMPORT_DATABASE_COMMAND_ID, + title: localize('importDatabase', "Import New Database Project") + }, + when: ContextKeyExpr.and(MssqlNodeContext.NodeProvider.isEqualTo(mssqlProviderName), + MssqlNodeContext.NodeType.isEqualTo(NodeType.Database), MssqlNodeContext.EngineEdition.notEqualsTo(DatabaseEngineEdition.SqlOnDemand.toString())) +}); + // Profiler MenuRegistry.appendMenuItem(MenuId.DataExplorerContext, { group: 'profiler', - order: 8, + order: 9, command: { id: PROFILER_COMMAND_ID, title: localize('profiler', "Launch Profiler") @@ -50,22 +62,10 @@ MenuRegistry.appendMenuItem(MenuId.DataExplorerContext, { MssqlNodeContext.NodeType.isEqualTo(NodeType.Server), MssqlNodeContext.EngineEdition.notEqualsTo(DatabaseEngineEdition.SqlOnDemand.toString())) }); -// Flat File Import -MenuRegistry.appendMenuItem(MenuId.DataExplorerContext, { - group: 'import', - order: 10, - command: { - id: IMPORT_COMMAND_ID, - title: localize('flatFileImport', "Import Wizard") - }, - when: ContextKeyExpr.and(MssqlNodeContext.NodeProvider.isEqualTo(mssqlProviderName), - MssqlNodeContext.NodeType.isEqualTo(NodeType.Database)) -}); - // Schema Compare MenuRegistry.appendMenuItem(MenuId.DataExplorerContext, { group: 'export', - order: 9, + order: 10, command: { id: SCHEMA_COMPARE_COMMAND_ID, title: localize('schemaCompare', "Schema Compare") @@ -74,10 +74,22 @@ MenuRegistry.appendMenuItem(MenuId.DataExplorerContext, { MssqlNodeContext.NodeType.isEqualTo(NodeType.Database), MssqlNodeContext.EngineEdition.notEqualsTo(DatabaseEngineEdition.SqlOnDemand.toString())) }); +// Flat File Import +MenuRegistry.appendMenuItem(MenuId.DataExplorerContext, { + group: 'import', + order: 11, + command: { + id: IMPORT_COMMAND_ID, + title: localize('flatFileImport', "Import Wizard") + }, + when: ContextKeyExpr.and(MssqlNodeContext.NodeProvider.isEqualTo(mssqlProviderName), + MssqlNodeContext.NodeType.isEqualTo(NodeType.Database)) +}); + // Generate Scripts Action MenuRegistry.appendMenuItem(MenuId.DataExplorerContext, { group: 'z-AdminToolExt@1', - order: 11, + order: 12, command: { id: GENERATE_SCRIPTS_COMMAND_ID, title: localize('generateScripts', "Generate Scripts...") @@ -90,7 +102,7 @@ MenuRegistry.appendMenuItem(MenuId.DataExplorerContext, { // Properties Action MenuRegistry.appendMenuItem(MenuId.DataExplorerContext, { group: 'z-AdminToolExt@2', - order: 12, + order: 13, command: { id: PROPERTIES_COMMAND_ID, title: localize('properties', "Properties") @@ -102,7 +114,7 @@ MenuRegistry.appendMenuItem(MenuId.DataExplorerContext, { MenuRegistry.appendMenuItem(MenuId.DataExplorerContext, { group: 'z-AdminToolExt@2', - order: 12, + order: 13, command: { id: PROPERTIES_COMMAND_ID, title: localize('properties', "Properties")