diff --git a/extensions/sql-database-projects/src/common/constants.ts b/extensions/sql-database-projects/src/common/constants.ts index 91d08e59bd..b12343ec06 100644 --- a/extensions/sql-database-projects/src/common/constants.ts +++ b/extensions/sql-database-projects/src/common/constants.ts @@ -118,6 +118,7 @@ export function fileAlreadyExists(filename: string) { return localize('fileAlrea export function folderAlreadyExists(filename: string) { return localize('folderAlreadyExists', "A folder with the name '{0}' already exists on disk at this location. Please choose another name.", filename); } export function invalidInput(input: string) { return localize('invalidInput', "Invalid input: {0}", input); } export function unableToCreatePublishConnection(input: string) { return localize('unableToCreatePublishConnection', "Unable to construct connection: {0}", input); } +export function circularProjectReference(project1: string, project2: string) { return localize('cicularProjectReference', "Circular reference from project {0} to project {1}", project1, project2); } 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); } @@ -166,6 +167,8 @@ export const DatabaseVariableLiteralValue = 'DatabaseVariableLiteralValue'; export const DSP = 'DSP'; export const Properties = 'Properties'; export const RelativeOuterPath = '..'; +export const ProjectReference = 'ProjectReference'; +export const TargetConnectionString = 'TargetConnectionString'; // SqlProj File targets export const NetCoreTargets = '$(NETCoreTargetsPath)\\Microsoft.Data.Tools.Schema.SqlTasks.targets'; diff --git a/extensions/sql-database-projects/src/controllers/projectController.ts b/extensions/sql-database-projects/src/controllers/projectController.ts index e624a82b05..fc34c803da 100644 --- a/extensions/sql-database-projects/src/controllers/projectController.ts +++ b/extensions/sql-database-projects/src/controllers/projectController.ts @@ -16,7 +16,7 @@ import * as vscode from 'vscode'; import * as azdata from 'azdata'; import { promises as fs } from 'fs'; import { PublishDatabaseDialog } from '../dialogs/publishDatabaseDialog'; -import { Project, DatabaseReferenceLocation, SystemDatabase, TargetPlatform, ProjectEntry, reservedProjectFolders } from '../models/project'; +import { Project, DatabaseReferenceLocation, SystemDatabase, TargetPlatform, ProjectEntry, reservedProjectFolders, SqlProjectReferenceProjectEntry } from '../models/project'; import { SqlDatabaseProjectTreeViewProvider } from './databaseProjectTreeViewProvider'; import { FolderNode, FileNode } from '../models/tree/fileFolderTreeItem'; import { IPublishSettings, IGenerateScriptSettings } from '../models/IPublishSettings'; @@ -47,11 +47,15 @@ export class ProjectsController { this.projectTreeViewProvider.load(this.projects); } - public async openProject(projectFile: vscode.Uri, focusProject: boolean = true): Promise { + public async openProject(projectFile: vscode.Uri, focusProject: boolean = true, isReferencedProject: boolean = false): Promise { for (const proj of this.projects) { if (proj.projectFilePath === projectFile.fsPath) { - vscode.window.showInformationMessage(constants.projectAlreadyOpened(projectFile.fsPath)); - return proj; + if (!isReferencedProject) { + vscode.window.showInformationMessage(constants.projectAlreadyOpened(projectFile.fsPath)); + return proj; + } else { + throw new Error(constants.projectAlreadyOpened(projectFile.fsPath)); + } } } @@ -62,6 +66,17 @@ export class ProjectsController { newProject = await Project.openProject(projectFile.fsPath); this.projects.push(newProject); + // open any reference projects (don't need to worry about circular dependencies because those aren't allowed) + const referencedProjects = newProject.databaseReferences.filter(r => r instanceof SqlProjectReferenceProjectEntry); + for (const proj of referencedProjects) { + const projUri = vscode.Uri.file(path.join(newProject.projectFolderPath, proj.fsUri.fsPath)); + try { + await this.openProject(projUri, false, true); + } catch (e) { + vscode.window.showErrorMessage(e.message === constants.projectAlreadyOpened(projUri.fsPath) ? constants.circularProjectReference(newProject.projectFileName, proj.databaseName) : e.message); + } + } + // Update for round tripping as needed await this.updateProjectForRoundTrip(newProject); diff --git a/extensions/sql-database-projects/src/models/project.ts b/extensions/sql-database-projects/src/models/project.ts index 93c41aabae..3e18af5d99 100644 --- a/extensions/sql-database-projects/src/models/project.ts +++ b/extensions/sql-database-projects/src/models/project.ts @@ -23,7 +23,7 @@ export class Project { public files: ProjectEntry[] = []; public dataSources: DataSource[] = []; public importedTargets: string[] = []; - public databaseReferences: DatabaseReferenceProjectEntry[] = []; + public databaseReferences: IDatabaseReferenceProjectEntry[] = []; public sqlCmdVariables: Record = {}; public get projectFolderPath() { @@ -91,11 +91,24 @@ export class Project { throw new Error(constants.invalidDatabaseReference); } - let nameNodes = references[r].getElementsByTagName(constants.DatabaseVariableLiteralValue); - let name = nameNodes.length === 1 ? nameNodes[0].childNodes[0].nodeValue : undefined; - this.databaseReferences.push(new DatabaseReferenceProjectEntry(Uri.parse(filepath), name ? DatabaseReferenceLocation.differentDatabaseSameServer : DatabaseReferenceLocation.sameDatabase, name)); + const nameNodes = references[r].getElementsByTagName(constants.DatabaseVariableLiteralValue); + const name = nameNodes.length === 1 ? nameNodes[0].childNodes[0].nodeValue : undefined; + this.databaseReferences.push(new DacpacReferenceProjectEntry(Uri.file(filepath), name ? DatabaseReferenceLocation.differentDatabaseSameServer : DatabaseReferenceLocation.sameDatabase, name)); } } + + // find project references + const projectReferences = this.projFileXmlDoc.documentElement.getElementsByTagName(constants.ProjectReference); + for (let r = 0; r < projectReferences.length; r++) { + const filepath = projectReferences[r].getAttribute(constants.Include); + if (!filepath) { + throw new Error(constants.invalidDatabaseReference); + } + + const nameNodes = projectReferences[r].getElementsByTagName(constants.Name); + const name = nameNodes[0].childNodes[0].nodeValue; + this.databaseReferences.push(new SqlProjectReferenceProjectEntry(Uri.file(utils.getPlatformSafeFileEntryPath(filepath)), name)); + } } public async updateProjectForRoundTrip() { @@ -284,7 +297,7 @@ export class Project { * @param databaseName name of the database */ public async addDatabaseReference(uri: Uri, databaseLocation: DatabaseReferenceLocation, databaseName?: string): Promise { - let databaseReferenceEntry = new DatabaseReferenceProjectEntry(uri, databaseLocation, databaseName); + let databaseReferenceEntry = new DacpacReferenceProjectEntry(uri, databaseLocation, databaseName); await this.addToProjFile(databaseReferenceEntry); } @@ -359,7 +372,7 @@ export class Project { throw new Error(constants.unableToFindObject(path, constants.folderObject)); } - private addDatabaseReferenceToProjFile(entry: DatabaseReferenceProjectEntry): void { + private addDatabaseReferenceToProjFile(entry: IDatabaseReferenceProjectEntry): void { // check if reference to this database already exists if (this.databaseReferenceExists(entry)) { throw new Error(constants.databaseReferenceAlreadyExists); @@ -374,7 +387,7 @@ export class Project { } referenceNode.setAttribute(constants.Include, entry.pathForSqlProj()); - this.addDatabaseReferenceChildren(referenceNode, entry.name); + this.addDatabaseReferenceChildren(referenceNode, entry.sqlCmdName); this.findOrCreateItemGroup(constants.ArtifactReference).appendChild(referenceNode); this.databaseReferences.push(entry); @@ -383,12 +396,12 @@ export class Project { let ssdtReferenceNode = this.projFileXmlDoc.createElement(constants.ArtifactReference); ssdtReferenceNode.setAttribute(constants.Condition, constants.NotNetCoreCondition); ssdtReferenceNode.setAttribute(constants.Include, (entry).ssdtPathForSqlProj()); - this.addDatabaseReferenceChildren(ssdtReferenceNode, entry.name); + this.addDatabaseReferenceChildren(ssdtReferenceNode, entry.sqlCmdName); this.findOrCreateItemGroup(constants.ArtifactReference).appendChild(ssdtReferenceNode); } } - private databaseReferenceExists(entry: DatabaseReferenceProjectEntry): boolean { + private databaseReferenceExists(entry: IDatabaseReferenceProjectEntry): boolean { const found = this.databaseReferences.find(reference => reference.fsUri.fsPath === entry.fsUri.fsPath) !== undefined; return found; } @@ -479,7 +492,7 @@ export class Project { this.addFolderToProjFile(entry.relativePath); break; case EntryType.DatabaseReference: - this.addDatabaseReferenceToProjFile(entry); + this.addDatabaseReferenceToProjFile(entry); break; // not required but adding so that we dont miss when we add new items } @@ -567,9 +580,18 @@ export class ProjectEntry { /** * Represents a database reference entry in a project file */ -export class DatabaseReferenceProjectEntry extends ProjectEntry { - constructor(uri: Uri, public databaseLocation: DatabaseReferenceLocation, public name?: string) { + +export interface IDatabaseReferenceProjectEntry extends ProjectEntry { + databaseName: string; + sqlCmdName?: string | undefined; +} + +export class DacpacReferenceProjectEntry extends ProjectEntry implements IDatabaseReferenceProjectEntry { + sqlCmdName: string | undefined; + + constructor(uri: Uri, public databaseLocation: DatabaseReferenceLocation, name?: string) { super(uri, '', EntryType.DatabaseReference); + this.sqlCmdName = name; } public get databaseName(): string { @@ -577,9 +599,10 @@ export class DatabaseReferenceProjectEntry extends ProjectEntry { } } -class SystemDatabaseReferenceProjectEntry extends DatabaseReferenceProjectEntry { - constructor(uri: Uri, public ssdtUri: Uri, public name: string) { +class SystemDatabaseReferenceProjectEntry extends DacpacReferenceProjectEntry { + constructor(uri: Uri, public ssdtUri: Uri, name: string) { super(uri, DatabaseReferenceLocation.differentDatabaseSameServer, name); + this.sqlCmdName = name; } public pathForSqlProj(): string { @@ -593,6 +616,19 @@ class SystemDatabaseReferenceProjectEntry extends DatabaseReferenceProjectEntry } } +export class SqlProjectReferenceProjectEntry extends ProjectEntry implements IDatabaseReferenceProjectEntry { + projectName: string; + + constructor(uri: Uri, name: string) { + super(uri, '', EntryType.DatabaseReference); + this.projectName = name; + } + + public get databaseName(): string { + return this.projectName; + } +} + export enum EntryType { File, Folder, diff --git a/extensions/sql-database-projects/src/models/publishProfile/publishProfile.ts b/extensions/sql-database-projects/src/models/publishProfile/publishProfile.ts index 72a69fcc1b..08de8a33f2 100644 --- a/extensions/sql-database-projects/src/models/publishProfile/publishProfile.ts +++ b/extensions/sql-database-projects/src/models/publishProfile/publishProfile.ts @@ -57,8 +57,8 @@ async function readConnectionString(xmlDoc: any): Promise<{ connectionId: string let targetConnectionString: string = ''; let connId: string = ''; - if (xmlDoc.documentElement.getElementsByTagName('TargetConnectionString').length > 0) { - targetConnectionString = xmlDoc.documentElement.getElementsByTagName('TargetConnectionString')[0].textContent; + if (xmlDoc.documentElement.getElementsByTagName(constants.targetConnectionString).length > 0) { + targetConnectionString = xmlDoc.documentElement.getElementsByTagName(constants.TargetConnectionString)[0].textContent; const dataSource = new SqlConnectionDataSource('temp', targetConnectionString); const connectionProfile = dataSource.getConnectionProfile(); diff --git a/extensions/sql-database-projects/src/models/tree/databaseReferencesTreeItem.ts b/extensions/sql-database-projects/src/models/tree/databaseReferencesTreeItem.ts index 9028276c62..b85ef9797e 100644 --- a/extensions/sql-database-projects/src/models/tree/databaseReferencesTreeItem.ts +++ b/extensions/sql-database-projects/src/models/tree/databaseReferencesTreeItem.ts @@ -10,7 +10,7 @@ import * as constants from '../../common/constants'; import { BaseProjectTreeItem } from './baseTreeItem'; import { ProjectRootTreeItem } from './projectTreeItem'; import { IconPathHelper } from '../../common/iconHelper'; -import { DatabaseReferenceProjectEntry } from '../../models/project'; +import { IDatabaseReferenceProjectEntry } from '../../models/project'; /** * Folder for containing references nodes in the tree @@ -44,7 +44,7 @@ export class DatabaseReferencesTreeItem extends BaseProjectTreeItem { } export class DatabaseReferenceTreeItem extends BaseProjectTreeItem { - constructor(private reference: DatabaseReferenceProjectEntry, referencesTreeItem: DatabaseReferencesTreeItem) { + constructor(private reference: IDatabaseReferenceProjectEntry, referencesTreeItem: DatabaseReferencesTreeItem) { super(vscode.Uri.file(path.join(referencesTreeItem.uri.path, reference.databaseName)), referencesTreeItem); } diff --git a/extensions/sql-database-projects/src/test/baselines/baselines.ts b/extensions/sql-database-projects/src/test/baselines/baselines.ts index f390168888..e23b700ce8 100644 --- a/extensions/sql-database-projects/src/test/baselines/baselines.ts +++ b/extensions/sql-database-projects/src/test/baselines/baselines.ts @@ -18,6 +18,7 @@ export let SSDTProjectBaselineWithCleanTarget: string; export let SSDTProjectBaselineWithCleanTargetAfterUpdate: string; export let publishProfileIntegratedSecurityBaseline: string; export let publishProfileSqlLoginBaseline: string; +export let openProjectWithProjectReferencesBaseline: string; const baselineFolderPath = __dirname; @@ -33,6 +34,7 @@ export async function loadBaselines() { SSDTProjectBaselineWithCleanTargetAfterUpdate = await loadBaseline(baselineFolderPath, 'SSDTProjectBaselineWithCleanTargetAfterUpdate.xml'); publishProfileIntegratedSecurityBaseline = await loadBaseline(baselineFolderPath, 'publishProfileIntegratedSecurityBaseline.publish.xml'); publishProfileSqlLoginBaseline = await loadBaseline(baselineFolderPath, 'publishProfileSqlLoginBaseline.publish.xml'); + openProjectWithProjectReferencesBaseline = await loadBaseline(baselineFolderPath, 'openSqlProjectWithProjectReferenceBaseline.xml'); } async function loadBaseline(baselineFolderPath: string, fileName: string): Promise { diff --git a/extensions/sql-database-projects/src/test/baselines/openSqlProjectWithProjectReferenceBaseline.xml b/extensions/sql-database-projects/src/test/baselines/openSqlProjectWithProjectReferenceBaseline.xml new file mode 100644 index 0000000000..82b0dc2f84 --- /dev/null +++ b/extensions/sql-database-projects/src/test/baselines/openSqlProjectWithProjectReferenceBaseline.xml @@ -0,0 +1,109 @@ + + + + Debug + AnyCPU + TestProjectWithReferenceName + 2.0 + 4.1 + {BA5EBA11-C0DE-5EA7-ACED-BABB1E70A575} + Microsoft.Data.Tools.Schema.Sql.Sql130DatabaseSchemaProvider + Database + + + TestProjectName + TestProjectName + 1033, CI + BySchemaAndSchemaType + True + v4.5 + CS + Properties + False + True + True + + + bin\Release\ + $(MSBuildProjectName).sql + False + pdbonly + true + false + true + prompt + 4 + + + bin\Debug\ + $(MSBuildProjectName).sql + false + true + full + false + true + true + prompt + 4 + + + 11.0 + + True + 11.0 + + + + + + + + + + + + + + + + + + + + + + + + + + MyProdDatabase + $(SqlCmdVar__1) + + + MyBackupDatabase + $(SqlCmdVar__2) + + + + + False + master + + + False + master + + + + + TestProjectName + {f9008554-068f-4f91-979a-58bd1f7c8f6e} + True + False + TestProjectName + + + + + + diff --git a/extensions/sql-database-projects/src/test/project.test.ts b/extensions/sql-database-projects/src/test/project.test.ts index 177dcb0bf0..cb86565c5a 100644 --- a/extensions/sql-database-projects/src/test/project.test.ts +++ b/extensions/sql-database-projects/src/test/project.test.ts @@ -10,7 +10,7 @@ import * as testUtils from './testUtils'; import * as constants from '../common/constants'; import { promises as fs } from 'fs'; -import { Project, EntryType, TargetPlatform, SystemDatabase, DatabaseReferenceLocation } from '../models/project'; +import { Project, EntryType, TargetPlatform, SystemDatabase, DatabaseReferenceLocation, DacpacReferenceProjectEntry, SqlProjectReferenceProjectEntry } from '../models/project'; import { exists, convertSlashesForSqlProj } from '../common/utils'; import { Uri } from 'vscode'; @@ -45,6 +45,20 @@ describe('Project: sqlproj content operations', function (): void { // should only have one database reference even though there are two master.dacpac references (1 for ADS and 1 for SSDT) should(project.databaseReferences.length).equal(1); should(project.databaseReferences[0].databaseName).containEql(constants.master); + should(project.databaseReferences[0] instanceof DacpacReferenceProjectEntry).equal(true); + }); + + it('Should read Project with Project reference from sqlproj', async function (): Promise { + projFilePath = await testUtils.createTestSqlProjFile(baselines.openProjectWithProjectReferencesBaseline); + const project: Project = await Project.openProject(projFilePath); + + // Database references + // should only have two database references even though there are two master.dacpac references (1 for ADS and 1 for SSDT) + should(project.databaseReferences.length).equal(2); + should(project.databaseReferences[0].databaseName).containEql(constants.master); + should(project.databaseReferences[0] instanceof DacpacReferenceProjectEntry).equal(true); + should(project.databaseReferences[1].databaseName).containEql('TestProjectName'); + should(project.databaseReferences[1] instanceof SqlProjectReferenceProjectEntry).equal(true); }); it('Should add Folder and Build entries to sqlproj', async function (): Promise { diff --git a/extensions/sql-database-projects/src/test/projectController.test.ts b/extensions/sql-database-projects/src/test/projectController.test.ts index 4d5fc58e42..42738256e1 100644 --- a/extensions/sql-database-projects/src/test/projectController.test.ts +++ b/extensions/sql-database-projects/src/test/projectController.test.ts @@ -87,6 +87,21 @@ describe('ProjectsController', function (): void { should(project.dataSources.length).equal(2); // detailed datasources tests in their own test file }); + it('Should load both project and referenced project', async function (): Promise { + // setup test projects + const folderPath = await testUtils.generateTestFolderPath(); + await fs.mkdir(path.join(folderPath, 'proj1')); + await fs.mkdir(path.join(folderPath, 'ReferencedProject')); + + const sqlProjPath = await testUtils.createTestSqlProjFile(baselines.openProjectWithProjectReferencesBaseline, path.join(folderPath, 'proj1')); + await testUtils.createTestSqlProjFile(baselines.openProjectFileBaseline, path.join(folderPath, 'ReferencedProject')); + + const projController = new ProjectsController(new SqlDatabaseProjectTreeViewProvider()); + await projController.openProject(vscode.Uri.file(sqlProjPath)); + + should(projController.projects.length).equal(2, 'Referenced project should have been opened when the project referencing it was opened'); + }); + it('Should not keep failed to load project in project list.', async function (): Promise { const folderPath = await testUtils.generateTestFolderPath(); const sqlProjPath = await testUtils.createTestSqlProjFile('empty file with no valid xml', folderPath);