diff --git a/extensions/sql-database-projects/package.json b/extensions/sql-database-projects/package.json index ff041db2de..e5d8ce3da4 100644 --- a/extensions/sql-database-projects/package.json +++ b/extensions/sql-database-projects/package.json @@ -39,14 +39,14 @@ } ], "menus": { - "commandPalette": [ - { - "command": "sqlDatabaseProjects.new" - }, - { - "command": "sqlDatabaseProjects.open" - } - ] + "commandPalette": [ + { + "command": "sqlDatabaseProjects.new" + }, + { + "command": "sqlDatabaseProjects.open" + } + ] }, "views": { "explorer": [ @@ -59,9 +59,11 @@ } }, "dependencies": { - "vscode-nls": "^3.2.1" + "vscode-nls": "^3.2.1", + "xml2js": "^0.4.23" }, "devDependencies": { + "@types/xml2js": "^0.4.5", "tslint": "^5.8.0", "typescript": "^2.6.1" } diff --git a/extensions/sql-database-projects/package.nls.json b/extensions/sql-database-projects/package.nls.json index 888c5b8a03..b9ebb7ff74 100644 --- a/extensions/sql-database-projects/package.nls.json +++ b/extensions/sql-database-projects/package.nls.json @@ -3,5 +3,5 @@ "sqlDatabaseProjects.description": "Design and deploy SQL database schemas", "sqlDatabaseProjects.new": "New Database Project", "sqlDatabaseProjects.open": "Open Database Project", - "title.projectsView": "SQL Database Projects" + "title.projectsView": "Projects" } diff --git a/extensions/sql-database-projects/src/common/constants.ts b/extensions/sql-database-projects/src/common/constants.ts index 24eeb3291c..08e2da0e3f 100644 --- a/extensions/sql-database-projects/src/common/constants.ts +++ b/extensions/sql-database-projects/src/common/constants.ts @@ -15,10 +15,14 @@ export const dataSourcesFileName = 'datasources.json'; export const noOpenProjectMessage = localize('noProjectOpenMessage', "No open database project"); export const projectNodeName = localize('projectNodeName', "Database Project"); export const dataSourcesNodeName = localize('dataSourcesNodeName', "Data Sources"); -export const foundDataSourcesFile = localize('foundDataSourcesFile', "Found {0}: ", dataSourcesFileName); // TODO: remove once datasources.json is actually getting removed. +export const sqlConnectionStringFriendly = localize('sqlConnectionStringFriendly', "SQL connection string"); // Error messages export const multipleSqlProjFiles = localize('multipleSqlProjFilesSelected', "Multiple .sqlproj files selected; please select only one."); export const noSqlProjFiles = localize('noSqlProjFilesSelected', "No .sqlproj file selected; please select one."); export const noDataSourcesFile = localize('noDataSourcesFile', "No {0} found", dataSourcesFileName); +export const missingVersion = localize('missingVersion', "Missing 'version' entry in {0}", dataSourcesFileName); +export const unrecognizedDataSourcesVersion = localize('unrecognizedDataSourcesVersion', "Unrecognized version: "); +export const unknownDataSourceType = localize('unknownDataSourceType', "Unknown data source type: "); +export const invalidSqlConnectionString = localize('invalidSqlConnectionString', "Invalid SQL connection string"); diff --git a/extensions/sql-database-projects/src/common/promise.ts b/extensions/sql-database-projects/src/common/promise.ts deleted file mode 100644 index 21d0bee37e..0000000000 --- a/extensions/sql-database-projects/src/common/promise.ts +++ /dev/null @@ -1,26 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the Source EULA. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -/** - * Deferred promise - */ -export class Deferred { - promise: Promise; - resolve!: ((value?: T | PromiseLike) => void); - reject!: ((reason?: any) => void); - - constructor() { - this.promise = new Promise((resolve, reject) => { - this.resolve = resolve; - this.reject = reject; - }); - } - - then(onfulfilled?: (value: T) => TResult | Thenable, onrejected?: (reason: any) => TResult | Thenable): Thenable; - then(onfulfilled?: (value: T) => TResult | Thenable, onrejected?: (reason: any) => void): Thenable; - then(onfulfilled?: (value: T) => TResult | Thenable, onrejected?: (reason: any) => TResult | Thenable): Thenable { - return this.promise.then(onfulfilled, onrejected); - } -} diff --git a/extensions/sql-database-projects/src/common/utils.ts b/extensions/sql-database-projects/src/common/utils.ts index 1ca5b993dd..2dcf94181e 100644 --- a/extensions/sql-database-projects/src/common/utils.ts +++ b/extensions/sql-database-projects/src/common/utils.ts @@ -3,7 +3,45 @@ * Licensed under the Source EULA. See License.txt in the project root for license information. *--------------------------------------------------------------------------------------------*/ - +import * as vscode from 'vscode'; +/** + * Consolidates on the error message string + */ export function getErrorMessage(error: Error | string): string { return (error instanceof Error) ? error.message : error; } + +/** + * removes any leading portion shared between the two URIs from outerUri. + * e.g. [@param innerUri: 'this\is'; @param outerUri: '\this\is\my\path'] => 'my\path' + * @param innerUri the URI that will be cut away from the outer URI + * @param outerUri the URI that will have any shared beginning portion removed + */ +export function trimUri(innerUri: vscode.Uri, outerUri: vscode.Uri): string { + let innerParts = innerUri.path.split('/'); + let outerParts = outerUri.path.split('/'); + + while (innerParts.length > 0 && outerParts.length > 0 && innerParts[0].toLocaleLowerCase() === outerParts[0].toLocaleLowerCase()) { + innerParts = innerParts.slice(1); + outerParts = outerParts.slice(1); + } + + return outerParts.join('/'); +} + +/** + * Trims any character contained in @param chars from both the beginning and end of @param input + */ +export function trimChars(input: string, chars: string): string { + let output = input; + + let i = 0; + while (chars.includes(output[i])) { i++; } + output = output.substr(i); + + i = 0; + while (chars.includes(output[output.length - i - 1])) { i++; } + output = output.substring(0, output.length - i); + + return output; +} diff --git a/extensions/sql-database-projects/src/controllers/databaseProjectTreeItem.ts b/extensions/sql-database-projects/src/controllers/databaseProjectTreeItem.ts deleted file mode 100644 index ec0370ef2e..0000000000 --- a/extensions/sql-database-projects/src/controllers/databaseProjectTreeItem.ts +++ /dev/null @@ -1,24 +0,0 @@ -/*--------------------------------------------------------------------------------------------- - * Copyright (c) Microsoft Corporation. All rights reserved. - * Licensed under the Source EULA. See License.txt in the project root for license information. - *--------------------------------------------------------------------------------------------*/ - -export class SqlDatabaseProjectItem { - label: string; - readonly isFolder: boolean; - readonly parent?: SqlDatabaseProjectItem; - children: SqlDatabaseProjectItem[] = []; - - constructor(label: string, isFolder: boolean, parent?: SqlDatabaseProjectItem) { - this.label = label; - this.isFolder = isFolder; - this.parent = parent; - } - - public createChild(label: string, isFolder: boolean): SqlDatabaseProjectItem { - let child = new SqlDatabaseProjectItem(label, isFolder, this); - this.children.push(child); - - return child; - } -} diff --git a/extensions/sql-database-projects/src/controllers/databaseProjectTreeViewProvider.ts b/extensions/sql-database-projects/src/controllers/databaseProjectTreeViewProvider.ts index 839c329cd4..fc9b728f3f 100644 --- a/extensions/sql-database-projects/src/controllers/databaseProjectTreeViewProvider.ts +++ b/extensions/sql-database-projects/src/controllers/databaseProjectTreeViewProvider.ts @@ -4,38 +4,34 @@ *--------------------------------------------------------------------------------------------*/ import * as vscode from 'vscode'; -import { promises as fs } from 'fs'; -import * as path from 'path'; import * as constants from '../common/constants'; -import { SqlDatabaseProjectItem } from './databaseProjectTreeItem'; +import { BaseProjectTreeItem, MessageTreeItem } from '../models/tree/baseTreeItem'; +import { ProjectRootTreeItem } from '../models/tree/projectTreeItem'; +import { Project } from '../models/project'; -export class SqlDatabaseProjectTreeViewProvider implements vscode.TreeDataProvider { - private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter(); - readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; +/** + * Tree view for database projects + */ +export class SqlDatabaseProjectTreeViewProvider implements vscode.TreeDataProvider { + private _onDidChangeTreeData: vscode.EventEmitter = new vscode.EventEmitter(); + readonly onDidChangeTreeData: vscode.Event = this._onDidChangeTreeData.event; - private roots: SqlDatabaseProjectItem[] = []; + private roots: BaseProjectTreeItem[] = []; constructor() { this.initialize(); } private initialize() { - this.roots = [new SqlDatabaseProjectItem(constants.noOpenProjectMessage, false)]; + this.roots = [new MessageTreeItem(constants.noOpenProjectMessage)]; } - public getTreeItem(element: SqlDatabaseProjectItem): vscode.TreeItem { - return { - label: element.label, - collapsibleState: element.parent === undefined - ? vscode.TreeItemCollapsibleState.Expanded - : element.isFolder - ? vscode.TreeItemCollapsibleState.Collapsed - : vscode.TreeItemCollapsibleState.None - }; + public getTreeItem(element: BaseProjectTreeItem): vscode.TreeItem { + return element.treeItem; } - public getChildren(element?: SqlDatabaseProjectItem): SqlDatabaseProjectItem[] { + public getChildren(element?: BaseProjectTreeItem): BaseProjectTreeItem[] { if (element === undefined) { return this.roots; } @@ -43,78 +39,19 @@ export class SqlDatabaseProjectTreeViewProvider implements vscode.TreeDataProvid return element.children; } - public async openProject(projectFiles: vscode.Uri[]) { - if (projectFiles.length > 1) { // TODO: how to handle opening a folder with multiple .sqlproj files? - vscode.window.showErrorMessage(constants.multipleSqlProjFiles); - return; - } - - if (projectFiles.length === 0) { + public load(projects: Project[]) { + if (projects.length === 0) { vscode.window.showErrorMessage(constants.noSqlProjFiles); return; } - let directoryPath = path.dirname(projectFiles[0].fsPath); - console.log('Opening project directory: ' + directoryPath); + let newRoots: BaseProjectTreeItem[] = []; - let newRoots: SqlDatabaseProjectItem[] = []; - - newRoots.push(await this.constructDataSourcesTree(directoryPath)); - newRoots.push(await this.constructProjectTree(directoryPath)); + for (const proj of projects) { + newRoots.push(new ProjectRootTreeItem(proj)); + } this.roots = newRoots; this._onDidChangeTreeData.fire(); } - - private async constructProjectTree(directoryPath: string): Promise { - let projectsNode = await this.constructFileTreeNode(directoryPath, undefined); - - projectsNode.label = constants.projectNodeName; - - return projectsNode; - } - - private async constructFileTreeNode(entryPath: string, parentNode: SqlDatabaseProjectItem | undefined): Promise { - let stat = await fs.stat(entryPath); - - let output = parentNode === undefined - ? new SqlDatabaseProjectItem(path.basename(entryPath), stat.isDirectory()) - : parentNode.createChild(path.basename(entryPath), stat.isDirectory()); - - if (stat.isDirectory()) { - let contents = await fs.readdir(entryPath); - - for (const entry of contents) { - await this.constructFileTreeNode(path.join(entryPath, entry), output); - } - - // sort children so that folders come first, then alphabetical - output.children.sort((a: SqlDatabaseProjectItem, b: SqlDatabaseProjectItem) => { - if (a.isFolder && !b.isFolder) { return -1; } - else if (!a.isFolder && b.isFolder) { return 1; } - else { return a.label.localeCompare(b.label); } - }); - } - - return output; - } - - private async constructDataSourcesTree(directoryPath: string): Promise { - let dataSourceNode = new SqlDatabaseProjectItem(constants.dataSourcesNodeName, true); - - let dataSourcesFilePath = path.join(directoryPath, constants.dataSourcesFileName); - - try { - let connections = await fs.readFile(dataSourcesFilePath, 'r'); - - // TODO: parse connections.json - - dataSourceNode.createChild(constants.foundDataSourcesFile + connections.length, false); - } - catch { - dataSourceNode.createChild(constants.noDataSourcesFile, false); - } - - return dataSourceNode; - } } diff --git a/extensions/sql-database-projects/src/controllers/mainController.ts b/extensions/sql-database-projects/src/controllers/mainController.ts index d4bbf1f825..1a3b1cd50e 100644 --- a/extensions/sql-database-projects/src/controllers/mainController.ts +++ b/extensions/sql-database-projects/src/controllers/mainController.ts @@ -8,6 +8,7 @@ import * as nls from 'vscode-nls'; import { SqlDatabaseProjectTreeViewProvider } from './databaseProjectTreeViewProvider'; import { getErrorMessage } from '../common/utils'; +import { ProjectsController } from './projectController'; const SQL_DATABASE_PROJECTS_VIEW_ID = 'sqlDatabaseProjectsView'; @@ -19,9 +20,11 @@ const localize = nls.loadMessageBundle(); export default class MainController implements vscode.Disposable { protected _context: vscode.ExtensionContext; protected dbProjectTreeViewProvider: SqlDatabaseProjectTreeViewProvider = new SqlDatabaseProjectTreeViewProvider(); + protected projectsController: ProjectsController; public constructor(context: vscode.ExtensionContext) { this._context = context; + this.projectsController = new ProjectsController(this.dbProjectTreeViewProvider); } public get extensionContext(): vscode.ExtensionContext { @@ -38,24 +41,28 @@ export default class MainController implements vscode.Disposable { private async initializeDatabaseProjects(): Promise { // init commands vscode.commands.registerCommand('sqlDatabaseProjects.new', () => { console.log('"New Database Project" called.'); }); - vscode.commands.registerCommand('sqlDatabaseProjects.open', async () => { this.openProjectFolder(); }); + vscode.commands.registerCommand('sqlDatabaseProjects.open', async () => { this.openProjectFromFile(); }); // init view - this.dbProjectTreeViewProvider = new SqlDatabaseProjectTreeViewProvider(); - this.extensionContext.subscriptions.push(vscode.window.registerTreeDataProvider(SQL_DATABASE_PROJECTS_VIEW_ID, this.dbProjectTreeViewProvider)); } - public async openProjectFolder(): Promise { + /** + * Prompts the user to select a .sqlproj file to open + * TODO: define behavior once projects are automatically opened from workspace + */ + public async openProjectFromFile(): Promise { try { let filter: { [key: string]: string[] } = {}; filter[localize('sqlDatabaseProject', "SQL database project")] = ['sqlproj']; - let file = await vscode.window.showOpenDialog({ filters: filter }); + let files: vscode.Uri[] | undefined = await vscode.window.showOpenDialog({ filters: filter }); - if (file) { - await this.dbProjectTreeViewProvider.openProject(file); + if (files) { + for (const file of files) { + await this.projectsController.openProject(file); + } } } catch (err) { diff --git a/extensions/sql-database-projects/src/controllers/projectController.ts b/extensions/sql-database-projects/src/controllers/projectController.ts new file mode 100644 index 0000000000..afab57015e --- /dev/null +++ b/extensions/sql-database-projects/src/controllers/projectController.ts @@ -0,0 +1,43 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import { Project } from '../models/project'; +import { SqlDatabaseProjectTreeViewProvider } from './databaseProjectTreeViewProvider'; +import * as path from 'path'; +import * as constants from '../common/constants'; +import * as dataSources from '../models/dataSources/dataSources'; + +/** + * Controller for managing project lifecycle + */ +export class ProjectsController { + private projectTreeViewProvider: SqlDatabaseProjectTreeViewProvider; + + projects: Project[] = []; + + constructor(projTreeViewProvider: SqlDatabaseProjectTreeViewProvider) { + this.projectTreeViewProvider = projTreeViewProvider; + } + + public async openProject(projectFile: vscode.Uri) { + console.log('Loading project: ' + projectFile.fsPath); + + // Read project file + const newProject = new Project(projectFile.fsPath); + await newProject.readProjFile(); + this.projects.push(newProject); + + // Read datasources.json (if present) + const dataSourcesFilePath = path.join(path.dirname(projectFile.fsPath), constants.dataSourcesFileName); + newProject.dataSources = await dataSources.load(dataSourcesFilePath); + + this.refreshProjectsTree(); + } + + public refreshProjectsTree() { + this.projectTreeViewProvider.load(this.projects); + } +} diff --git a/extensions/sql-database-projects/src/models/dataSources/dataSourceJson.ts b/extensions/sql-database-projects/src/models/dataSources/dataSourceJson.ts new file mode 100644 index 0000000000..eb69200946 --- /dev/null +++ b/extensions/sql-database-projects/src/models/dataSources/dataSourceJson.ts @@ -0,0 +1,26 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +/** + * JSON format for datasources.json + */ +interface DataSourceFileJson { + version: string; + datasources: DataSourceJson[]; +} + +/** + * JSON format for a datasource entry in datasources.json + */ +interface DataSourceJson { + name: string; + type: string; + version: string; + + /** + * contents for concrete datasource implementation + */ + data: string; +} diff --git a/extensions/sql-database-projects/src/models/dataSources/dataSources.ts b/extensions/sql-database-projects/src/models/dataSources/dataSources.ts new file mode 100644 index 0000000000..bc13b46b96 --- /dev/null +++ b/extensions/sql-database-projects/src/models/dataSources/dataSources.ts @@ -0,0 +1,71 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { promises as fs } from 'fs'; +import * as constants from '../../common/constants'; +import { SqlConnectionDataSource } from './sqlConnectionStringSource'; + +/** + * Abstract class for a datasource in a project + */ +export abstract class DataSource { + public name: string; + public abstract get type(): string; + public abstract get friendlyName(): string; + + constructor(name: string) { + this.name = name; + } +} + +/** + * parses the specified file to load DataSource objects + */ +export async function load(dataSourcesFilePath: string): Promise { + let fileContents; + + try { + fileContents = await fs.readFile(dataSourcesFilePath); + } + catch (err) { + throw new Error(constants.noDataSourcesFile); + } + + const rawJsonContents = JSON.parse(fileContents.toString()); + + if (rawJsonContents.version === undefined) { + throw new Error(constants.missingVersion); + } + + const output: DataSource[] = []; + + // TODO: do we have a construct for parsing version numbers? + switch (rawJsonContents.version) { + case '0.0.0': + const dataSources: DataSourceFileJson = rawJsonContents as DataSourceFileJson; + + for (const source of dataSources.datasources) { + output.push(createDataSource(source)); + } + + break; + default: + throw new Error(constants.unrecognizedDataSourcesVersion + rawJsonContents.version); + } + + return output; +} + +/** + * Creates DataSource object from JSON + */ +function createDataSource(json: DataSourceJson): DataSource { + switch (json.type) { + case SqlConnectionDataSource.type: + return SqlConnectionDataSource.fromJson(json); + default: + throw new Error(constants.unknownDataSourceType + json.type); + } +} diff --git a/extensions/sql-database-projects/src/models/dataSources/sqlConnectionStringSource.ts b/extensions/sql-database-projects/src/models/dataSources/sqlConnectionStringSource.ts new file mode 100644 index 0000000000..fdd4427602 --- /dev/null +++ b/extensions/sql-database-projects/src/models/dataSources/sqlConnectionStringSource.ts @@ -0,0 +1,55 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import { DataSource } from './dataSources'; +import * as constants from '../../common/constants'; + +/** + * Contains information about a SQL connection string data source` + */ +export class SqlConnectionDataSource extends DataSource { + readonly connectionString: string; + readonly connectionStringComponents: { [id: string]: string } = {}; + + public static get type() { + return 'sql_connection_string'; + } + + public get type(): string { + return SqlConnectionDataSource.type; + } + + public get friendlyName(): string { + return constants.sqlConnectionStringFriendly; + } + + constructor(name: string, connectionString: string) { + super(name); + + // TODO: do we have a common construct for connection strings? + this.connectionString = connectionString; + + for (const component of this.connectionString.split(';')) { + const split = component.split('='); + + if (split.length !== 2) { + throw new Error(constants.invalidSqlConnectionString); + } + + this.connectionStringComponents[split[0]] = split[1]; + } + } + + public static fromJson(json: DataSourceJson): SqlConnectionDataSource { + return new SqlConnectionDataSource(json.name, (json.data as unknown as SqlConnectionDataSourceJson).connectionString); + } +} + +/** + * JSON structure for a SQL connection string data source + */ +interface SqlConnectionDataSourceJson { + connectionString: string; +} diff --git a/extensions/sql-database-projects/src/models/project.ts b/extensions/sql-database-projects/src/models/project.ts new file mode 100644 index 0000000000..c36ccc614c --- /dev/null +++ b/extensions/sql-database-projects/src/models/project.ts @@ -0,0 +1,91 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as xml2js from 'xml2js'; +import * as path from 'path'; +import { promises as fs } from 'fs'; +import { DataSource } from './dataSources/dataSources'; + +/** + * Class representing a Project, and providing functions for operating on it + */ +export class Project { + public projectFile: string; + public files: ProjectEntry[] = []; + public dataSources: DataSource[] = []; + + constructor(projectFilePath: string) { + this.projectFile = projectFilePath; + } + + /** + * Reads the project setting and contents from the file + */ + public async readProjFile() { + let projFileContents = await fs.readFile(this.projectFile); + + const parser = new xml2js.Parser({ + explicitArray: true, + explicitCharkey: false, + explicitRoot: false + }); + + let result; + + try { + result = await parser.parseStringPromise(projFileContents.toString()); + } + catch (err) { + vscode.window.showErrorMessage(err); + return; + } + + // find all folders and files to include + + for (const itemGroup of result['ItemGroup']) { + if (itemGroup['Build'] !== undefined) { + for (const fileEntry of itemGroup['Build']) { + this.files.push(this.createProjectEntry(fileEntry.$['Include'], EntryType.File)); + } + } + + if (itemGroup['Folder'] !== undefined) { + for (const folderEntry of itemGroup['Folder']) { + this.files.push(this.createProjectEntry(folderEntry.$['Include'], EntryType.Folder)); + } + } + } + } + + private createProjectEntry(relativePath: string, entryType: EntryType): ProjectEntry { + return new ProjectEntry(vscode.Uri.file(path.join(this.projectFile, relativePath)), entryType); + } +} + +/** + * Represents an entry in a project file + */ +export class ProjectEntry { + /** + * Absolute file system URI + */ + uri: vscode.Uri; + type: EntryType; + + constructor(uri: vscode.Uri, type: EntryType) { + this.uri = uri; + this.type = type; + } + + public toString(): string { + return this.uri.path; + } +} + +export enum EntryType { + File, + Folder +} diff --git a/extensions/sql-database-projects/src/models/tree/baseTreeItem.ts b/extensions/sql-database-projects/src/models/tree/baseTreeItem.ts new file mode 100644 index 0000000000..9d5ac1def1 --- /dev/null +++ b/extensions/sql-database-projects/src/models/tree/baseTreeItem.ts @@ -0,0 +1,54 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as path from 'path'; + +/** + * Base class for an item that appears in the ADS project tree + */ +export abstract class BaseProjectTreeItem { + uri: vscode.Uri; + parent?: BaseProjectTreeItem; + + constructor(uri: vscode.Uri, parent?: BaseProjectTreeItem) { + this.uri = uri; + this.parent = parent; + } + + abstract get children(): BaseProjectTreeItem[]; + + abstract get treeItem(): vscode.TreeItem; + + public get root() { + let node: BaseProjectTreeItem = this; + + while (node.parent !== undefined) { + node = node.parent; + } + + return node; + } +} + +/** + * Leaf tree item that just displays text for messaging purposes + */ +export class MessageTreeItem extends BaseProjectTreeItem { + private message: string; + + constructor(message: string, parent?: BaseProjectTreeItem) { + super(vscode.Uri.file(path.join(parent?.uri.path ?? 'Message', message)), parent); + this.message = message; + } + + public get children(): BaseProjectTreeItem[] { + return []; + } + + public get treeItem(): vscode.TreeItem { + return new vscode.TreeItem(this.message, vscode.TreeItemCollapsibleState.None); + } +} diff --git a/extensions/sql-database-projects/src/models/tree/dataSourceTreeItem.ts b/extensions/sql-database-projects/src/models/tree/dataSourceTreeItem.ts new file mode 100644 index 0000000000..ddc0e6f6d5 --- /dev/null +++ b/extensions/sql-database-projects/src/models/tree/dataSourceTreeItem.ts @@ -0,0 +1,85 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as path from 'path'; +import { BaseProjectTreeItem, MessageTreeItem } from './baseTreeItem'; +import * as constants from '../../common/constants'; +import { ProjectRootTreeItem } from './projectTreeItem'; +import { DataSource } from '../dataSources/dataSources'; +import { SqlConnectionDataSource } from '../dataSources/sqlConnectionStringSource'; + +/** + * Folder for containing DataSource nodes in the tree + */ +export class DataSourcesTreeItem extends BaseProjectTreeItem { + private dataSources: DataSourceTreeItem[] = []; + + constructor(project: ProjectRootTreeItem) { + super(vscode.Uri.file(path.join(project.uri.path, constants.dataSourcesNodeName)), project); + + this.construct(); + } + + private construct() { + for (const dataSource of (this.parent as ProjectRootTreeItem).project.dataSources) { + this.dataSources.push(constructDataSourceTreeItem(dataSource, this)); + } + } + + public get children(): BaseProjectTreeItem[] { + return this.dataSources; + } + + public get treeItem(): vscode.TreeItem { + return new vscode.TreeItem(this.uri, vscode.TreeItemCollapsibleState.Collapsed); + } +} + +abstract class DataSourceTreeItem extends BaseProjectTreeItem { } + +/** + * Tree item representing a SQL connection string data source + */ +export class SqlConnectionDataSourceTreeItem extends DataSourceTreeItem { + private dataSource: SqlConnectionDataSource; + + constructor(dataSource: SqlConnectionDataSource, dataSourcesNode: DataSourcesTreeItem) { + super(vscode.Uri.file(path.join(dataSourcesNode.uri.path, dataSource.name)), dataSourcesNode); + this.dataSource = dataSource; + } + + public get treeItem(): vscode.TreeItem { + let item = new vscode.TreeItem(this.uri, vscode.TreeItemCollapsibleState.Collapsed); + item.label = `${this.dataSource.name} (${this.dataSource.friendlyName})`; + + return item; + } + + /** + * SQL connection string components, displayed as key-value pairs + */ + public get children(): BaseProjectTreeItem[] { + const result: MessageTreeItem[] = []; + + for (const comp of Object.keys(this.dataSource.connectionStringComponents).sort()) { + result.push(new MessageTreeItem(`${comp}: ${this.dataSource.connectionStringComponents[comp]}`, this)); + } + + return result; + } +} + +/** + * Constructs a new TreeItem for the specific given DataSource type + */ +export function constructDataSourceTreeItem(dataSource: DataSource, dataSourcesNode: DataSourcesTreeItem): DataSourceTreeItem { + switch (dataSource.type) { + case SqlConnectionDataSource.type: + return new SqlConnectionDataSourceTreeItem(dataSource as SqlConnectionDataSource, dataSourcesNode); + default: + throw new Error(constants.unknownDataSourceType + dataSource.type); // TODO: elegant handling of unknown dataSource type instead of failure + } +} diff --git a/extensions/sql-database-projects/src/models/tree/fileFolderTreeItem.ts b/extensions/sql-database-projects/src/models/tree/fileFolderTreeItem.ts new file mode 100644 index 0000000000..f26038d9a7 --- /dev/null +++ b/extensions/sql-database-projects/src/models/tree/fileFolderTreeItem.ts @@ -0,0 +1,73 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as path from 'path'; +import { BaseProjectTreeItem } from './baseTreeItem'; +import { ProjectRootTreeItem } from './projectTreeItem'; +import { Project } from '../project'; + +/** + * Node representing a folder in a project + */ +export class FolderNode extends BaseProjectTreeItem { + public fileChildren: { [childName: string]: (FolderNode | FileNode) } = {}; + public fileSystemUri: vscode.Uri; + + constructor(folderPath: vscode.Uri, parent: FolderNode | ProjectRootTreeItem) { + super(fsPathToProjectUri(folderPath, parent.root as ProjectRootTreeItem), parent); + this.fileSystemUri = folderPath; + } + + public get children(): BaseProjectTreeItem[] { + return Object.values(this.fileChildren).sort(); + } + + public get treeItem(): vscode.TreeItem { + return new vscode.TreeItem(this.uri, vscode.TreeItemCollapsibleState.Expanded); + } + + public get project(): Project { + return (this.parent).project; + } +} + +/** + * Node representing a file in a project + */ +export class FileNode extends BaseProjectTreeItem { + public fileSystemUri: vscode.Uri; + + constructor(filePath: vscode.Uri, parent: FolderNode | ProjectRootTreeItem) { + super(fsPathToProjectUri(filePath, parent.root as ProjectRootTreeItem), parent); + this.fileSystemUri = filePath; + } + + public get children(): BaseProjectTreeItem[] { + return []; + } + + public get treeItem(): vscode.TreeItem { + return new vscode.TreeItem(this.uri, vscode.TreeItemCollapsibleState.None); + } +} + +/** + * Converts a full filesystem URI to a project-relative URI that's compatible with the project tree + */ +function fsPathToProjectUri(fileSystemUri: vscode.Uri, projectNode: ProjectRootTreeItem): vscode.Uri { + const projBaseDir = path.dirname(projectNode.project.projectFile); + let localUri = ''; + + if (fileSystemUri.fsPath.startsWith(projBaseDir)) { + localUri = fileSystemUri.fsPath.substring(projBaseDir.length); + } + else { + vscode.window.showErrorMessage('Project pointing to file outside of directory'); + throw new Error('Project pointing to file outside of directory'); + } + + return vscode.Uri.file(path.join(projectNode.uri.path, localUri)); +} diff --git a/extensions/sql-database-projects/src/models/tree/projectTreeItem.ts b/extensions/sql-database-projects/src/models/tree/projectTreeItem.ts new file mode 100644 index 0000000000..34e7cc7e5c --- /dev/null +++ b/extensions/sql-database-projects/src/models/tree/projectTreeItem.ts @@ -0,0 +1,100 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + * Licensed under the Source EULA. See License.txt in the project root for license information. + *--------------------------------------------------------------------------------------------*/ + +import * as vscode from 'vscode'; +import * as path from 'path'; +import { DataSourcesTreeItem } from './dataSourceTreeItem'; +import { BaseProjectTreeItem } from './baseTreeItem'; +import * as fileTree from './fileFolderTreeItem'; +import { Project, ProjectEntry, EntryType } from '../project'; +import * as utils from '../../common/utils'; + +/** + * TreeNode root that represents an entire project + */ +export class ProjectRootTreeItem extends BaseProjectTreeItem { + dataSourceNode: DataSourcesTreeItem; + fileChildren: { [childName: string]: (fileTree.FolderNode | fileTree.FileNode) } = {}; + project: Project; + + constructor(project: Project) { + super(vscode.Uri.parse(path.basename(project.projectFile)), undefined); + + this.project = project; + this.dataSourceNode = new DataSourcesTreeItem(this); + + this.construct(); + } + + public get children(): BaseProjectTreeItem[] { + const output: BaseProjectTreeItem[] = []; + output.push(this.dataSourceNode); + + // sort children so that folders come first, then alphabetical + const sortedChildren = Object.values(this.fileChildren).sort((a: (fileTree.FolderNode | fileTree.FileNode), b: (fileTree.FolderNode | fileTree.FileNode)) => { + if (a instanceof fileTree.FolderNode && !(b instanceof fileTree.FolderNode)) { return -1; } + else if (!(a instanceof fileTree.FolderNode) && b instanceof fileTree.FolderNode) { return 1; } + else { return a.uri.fsPath.localeCompare(b.uri.fsPath); } + }); + + return output.concat(sortedChildren); + } + + public get treeItem(): vscode.TreeItem { + return new vscode.TreeItem(this.uri, vscode.TreeItemCollapsibleState.Expanded); + } + + /** + * Processes the list of files in a project file to constructs the tree + */ + private construct() { + for (const entry of this.project.files) { + const parentNode = this.getEntryParentNode(entry); + + let newNode: fileTree.FolderNode | fileTree.FileNode; + + switch (entry.type) { + case EntryType.File: + newNode = new fileTree.FileNode(entry.uri, parentNode); + break; + case EntryType.Folder: + newNode = new fileTree.FolderNode(entry.uri, parentNode); + break; + default: + throw new Error(`Unknown EntryType: '${entry.type}'`); + } + + parentNode.fileChildren[path.basename(entry.uri.path)] = newNode; + } + } + + /** + * Gets the immediate parent tree node for an entry in a project file + */ + private getEntryParentNode(entry: ProjectEntry): fileTree.FolderNode | ProjectRootTreeItem { + const relativePathParts = utils.trimChars(utils.trimUri(vscode.Uri.file(this.project.projectFile), entry.uri), '/').split('/').slice(0, -1); // remove the last part because we only care about the parent + + if (relativePathParts.length === 0) { + return this; // if nothing left after trimming the entry itself, must been root + } + + let current: fileTree.FolderNode | ProjectRootTreeItem = this; + + for (const part of relativePathParts) { + if (current.fileChildren[part] === undefined) { + current.fileChildren[part] = new fileTree.FolderNode(vscode.Uri.file(path.join(path.dirname(this.project.projectFile), part)), current); + } + + if (current.fileChildren[part] instanceof fileTree.FileNode) { + return current; + } + else { + current = current.fileChildren[part] as fileTree.FolderNode | ProjectRootTreeItem; + } + } + + return current; + } +} diff --git a/extensions/sql-database-projects/yarn.lock b/extensions/sql-database-projects/yarn.lock index 251921e017..1440fb8059 100644 --- a/extensions/sql-database-projects/yarn.lock +++ b/extensions/sql-database-projects/yarn.lock @@ -18,6 +18,18 @@ esutils "^2.0.2" js-tokens "^4.0.0" +"@types/node@*": + version "13.1.2" + resolved "https://registry.yarnpkg.com/@types/node/-/node-13.1.2.tgz#fe94285bf5e0782e1a9e5a8c482b1c34465fa385" + integrity sha512-B8emQA1qeKerqd1dmIsQYnXi+mmAzTB7flExjmy5X1aVAKFNNNDubkavwR13kR6JnpeLp3aLoJhwn9trWPAyFQ== + +"@types/xml2js@^0.4.5": + version "0.4.5" + resolved "https://registry.yarnpkg.com/@types/xml2js/-/xml2js-0.4.5.tgz#d21759b056f282d9c7066f15bbf5c19b908f22fa" + integrity sha512-yohU3zMn0fkhlape1nxXG2bLEGZRc1FeqF80RoHaYXJN7uibaauXfhzhOJr1Xh36sn+/tx21QAOf07b/xYVk1w== + dependencies: + "@types/node" "*" + ansi-styles@^3.2.1: version "3.2.1" resolved "https://registry.yarnpkg.com/ansi-styles/-/ansi-styles-3.2.1.tgz#41fbb20243e50b12be0f04b8dedbf07520ce841d" @@ -192,6 +204,11 @@ resolve@^1.3.2: dependencies: path-parse "^1.0.6" +sax@>=0.6.0: + version "1.2.4" + resolved "https://registry.yarnpkg.com/sax/-/sax-1.2.4.tgz#2816234e2378bddc4e5354fab5caa895df7100d9" + integrity sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw== + semver@^5.3.0: version "5.7.1" resolved "https://registry.yarnpkg.com/semver/-/semver-5.7.1.tgz#a954f931aeba508d307bbf069eff0c01c96116f7" @@ -254,3 +271,16 @@ wrappy@1: version "1.0.2" resolved "https://registry.yarnpkg.com/wrappy/-/wrappy-1.0.2.tgz#b5243d8f3ec1aa35f1364605bc0d1036e30ab69f" integrity sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8= + +xml2js@^0.4.23: + version "0.4.23" + resolved "https://registry.yarnpkg.com/xml2js/-/xml2js-0.4.23.tgz#a0c69516752421eb2ac758ee4d4ccf58843eac66" + integrity sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug== + dependencies: + sax ">=0.6.0" + xmlbuilder "~11.0.0" + +xmlbuilder@~11.0.0: + version "11.0.1" + resolved "https://registry.yarnpkg.com/xmlbuilder/-/xmlbuilder-11.0.1.tgz#be9bae1c8a046e76b31127726347d0ad7002beb3" + integrity sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA==