Add support for adding dacpac references in sql database projects (#10684)

* add dacpacs

* able to add reference to appropriate master dacpac

* able to add reference to a dacpac

* Add a few tests

* fix tests

* fix wording

* fix adding reference to same database

* add project tests

* fix test for windows

* addressing comments

* Adding another test

* update tests

* fix build error
This commit is contained in:
Kim Santiago
2020-06-05 17:53:21 -07:00
committed by GitHub
parent 52a12f0d51
commit 6e3d960d1d
18 changed files with 311 additions and 6 deletions

View File

@@ -109,6 +109,7 @@ const indentationFilter = [
'!extensions/sql-database-projects/src/test/baselines/*.xml',
'!extensions/sql-database-projects/src/test/baselines/*.json',
'!extensions/sql-database-projects/src/test/baselines/*.sqlproj',
'!extensions/sql-database-projects/BuildDirectory/SystemDacpacs/**',
'!extensions/big-data-cluster/src/bigDataCluster/controller/apiGenerated.ts',
'!extensions/big-data-cluster/src/bigDataCluster/controller/clusterApiGenerated2.ts',
'!resources/linux/snap/electron-launch'

View File

@@ -113,6 +113,11 @@
"command": "sqlDatabaseProjects.importDatabase",
"title": "%sqlDatabaseProjects.importDatabase%",
"category": "%sqlDatabaseProjects.displayName%"
},
{
"command": "sqlDatabaseProjects.addDatabaseReference",
"title": "%sqlDatabaseProjects.addDatabaseReference%",
"category": "%sqlDatabaseProjects.displayName%"
}
],
"menus": {
@@ -226,6 +231,11 @@
"when": "view == sqlDatabaseProjectsView",
"group": "3_dbProjects_newItem@9"
},
{
"command": "sqlDatabaseProjects.addDatabaseReference",
"when": "view == sqlDatabaseProjectsView",
"group": "4_dbProjects_addDatabaseReference"
},
{
"command": "sqlDatabaseProjects.close",
"when": "view == sqlDatabaseProjectsView",

View File

@@ -21,6 +21,8 @@
"sqlDatabaseProjects.importDatabase": "Import New Database Project",
"sqlDatabaseProjects.addDatabaseReference": "Add Database Reference",
"sqlDatabaseProjects.Settings": "Database Projects",
"sqlDatabaseProjects.netCoreInstallLocation": "Full Path to .Net Core SDK on the machine."
}

View File

@@ -14,6 +14,10 @@ export const sqlFileExtension = '.sql';
export const schemaCompareExtensionId = 'microsoft.schema-compare';
export const sqlDatabaseProjectExtensionId = 'microsoft.sql-database-projects';
export const mssqlExtensionId = 'microsoft.mssql';
export const dacpac = 'dacpac';
export const master = 'master';
export const MicrosoftDatatoolsSchemaSqlSql = 'Microsoft.Data.Tools.Schema.Sql.Sql';
export const databaseSchemaProvider = 'DatabaseSchemaProvider';
// UI Strings
@@ -26,7 +30,13 @@ export const sqlDatabaseProject = localize('sqlDatabaseProject', "SQL database p
export const yesString = localize('yesString', "Yes");
export const noString = localize('noString', "No");
export const extractTargetInput = localize('extractTargetInput', "Target for extraction:");
export const selectFileFolder = localize('selectFileFolder', "Select");
export const selectString = localize('selectString', "Select");
export const addDatabaseReferenceInput = localize('addDatabaseReferenceInput', "Add database reference for:");
export const databaseReferenceLocation = localize('databaseReferenceLocation', "Database location");
export const databaseReferenceSameDatabase = localize('databaseReferenceSameDatabase', "Same database");
export const databaseReferenceDifferentDabaseSameServer = localize('databaseReferenceDifferentDabaseSameServer', "Different database, same server");
export const databaseReferenceDatabaseName = localize('databaseReferenceDatabaseName', "Database name");
export const dacpacFiles = localize('dacpacFiles', "dacpac Files");
export function newObjectNamePrompt(objectType: string) { return localize('newObjectNamePrompt', 'New {0} name:', objectType); }
// Deploy dialog strings
@@ -63,6 +73,11 @@ export const extractTargetRequired = localize('extractTargetRequired', "Target i
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 const updateProjectForRoundTrip = localize('updateProjectForRoundTrip', "To build this project, Azure Data Studio needs to update targets and references. If the project is created in SSDT, it will continue to work in both tools. Do you want Azure Data Studio to update the project?");
export const databaseReferenceTypeRequired = localize('databaseReferenceTypeRequired', "Database reference type is required for adding a reference to a database");
export const dacpacFileLocationRequired = localize('dacpacFileLocationRequired', "Dacpac file location is required for adding a reference to a database");
export const databaseLocationRequired = localize('databaseLocation', "Database location is required for adding a reference to a database");
export const databaseNameRequired = localize('databaseNameRequired', "Database name is required for adding a reference to a different database");
export const invalidDataSchemaProvider = localize('invalidDataSchemaProvider', "Invalid DSP in .sqlproj file");
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); }
@@ -91,6 +106,10 @@ export const Condition = 'Condition';
export const PackageReference = 'PackageReference';
export const Version = 'Version';
export const PrivateAssets = 'PrivateAssets';
export const ArtifactReference = 'ArtifactReference';
export const SuppressMissingDependenciesErrors = 'SuppressMissingDependenciesErrors';
export const DatabaseVariableLiteralValue = 'DatabaseVariableLiteralValue';
export const DSP = 'DSP';
// SqlProj File targets
export const NetCoreTargets = '$(NETCoreTargetsPath)\\Microsoft.Data.Tools.Schema.SqlTasks.targets';

View File

@@ -64,6 +64,8 @@ export default class MainController implements Disposable {
this.apiWrapper.registerCommand('sqlDatabaseProjects.importDatabase', async (profile: azdata.IConnectionProfile) => { await this.projectsController.importNewDatabaseProject(profile); });
this.apiWrapper.registerCommand('sqlDatabaseProjects.addDatabaseReference', async (node: BaseProjectTreeItem) => { await this.projectsController.addDatabaseReference(node); });
// init view
this.extensionContext.subscriptions.push(this.apiWrapper.registerTreeDataProvider(SQL_DATABASE_PROJECTS_VIEW_ID, this.dbProjectTreeViewProvider));

View File

@@ -16,7 +16,7 @@ import { IConnectionProfile, TaskExecutionMode } from 'azdata';
import { promises as fs } from 'fs';
import { ApiWrapper } from '../common/apiWrapper';
import { DeployDatabaseDialog } from '../dialogs/deployDatabaseDialog';
import { Project } from '../models/project';
import { Project, DatabaseReferenceLocation } from '../models/project';
import { SqlDatabaseProjectTreeViewProvider } from './databaseProjectTreeViewProvider';
import { FolderNode } from '../models/tree/fileFolderTreeItem';
import { IDeploymentProfile, IGenerateScriptProfile } from '../models/IDeploymentProfile';
@@ -292,6 +292,118 @@ export class ProjectsController {
this.refreshProjectsTree();
}
/**
* Adds a database reference to the project
* @param treeNode a treeItem in a project's hierarchy, to be used to obtain a Project
*/
public async addDatabaseReference(context: Project | BaseProjectTreeItem): Promise<void> {
const project = ProjectsController.getProjectFromContext(context);
try {
// choose if reference is to master or a dacpac
const databaseReferenceType = await this.getDatabaseReferenceType();
// if master is selected, we know which dacpac needs to be added
if (databaseReferenceType === constants.master) {
await project.addMasterDatabaseReference();
} else {
// get other information needed to add a reference to the dacpac
const dacpacFileLocation = await this.getDacpacFileLocation();
const databaseLocation = await this.getDatabaseLocation();
if (databaseLocation === DatabaseReferenceLocation.differentDatabaseSameServer) {
const databaseName = await this.getDatabaseName(dacpacFileLocation);
await project.addDatabaseReference(dacpacFileLocation, <DatabaseReferenceLocation>databaseLocation, databaseName);
} else {
await project.addDatabaseReference(dacpacFileLocation, <DatabaseReferenceLocation>databaseLocation);
}
}
} catch (err) {
this.apiWrapper.showErrorMessage(utils.getErrorMessage(err));
}
}
private async getDatabaseReferenceType(): Promise<string> {
let databaseReferenceOptions: QuickPickItem[] = [
{
label: constants.master
},
{
label: constants.dacpac
}
];
let input = await this.apiWrapper.showQuickPick(databaseReferenceOptions, {
canPickMany: false,
placeHolder: constants.addDatabaseReferenceInput
});
if (!input) {
throw new Error(constants.databaseReferenceTypeRequired);
}
return input.label;
}
private async getDacpacFileLocation(): Promise<Uri> {
let fileUris = await this.apiWrapper.showOpenDialog(
{
canSelectFiles: true,
canSelectFolders: false,
canSelectMany: false,
defaultUri: this.apiWrapper.workspaceFolders() ? (this.apiWrapper.workspaceFolders() as WorkspaceFolder[])[0].uri : undefined,
openLabel: constants.selectString,
filters: {
[constants.dacpacFiles]: ['dacpac'],
}
}
);
if (!fileUris || fileUris.length === 0) {
throw new Error(constants.dacpacFileLocationRequired);
}
return fileUris[0];
}
private async getDatabaseLocation(): Promise<DatabaseReferenceLocation> {
let databaseReferenceOptions: QuickPickItem[] = [
{
label: constants.databaseReferenceSameDatabase
},
{
label: constants.databaseReferenceDifferentDabaseSameServer
}
];
let input = await this.apiWrapper.showQuickPick(databaseReferenceOptions, {
canPickMany: false,
placeHolder: constants.databaseReferenceLocation
});
if (input === undefined) {
throw new Error(constants.databaseLocationRequired);
}
const location = input?.label === constants.databaseReferenceSameDatabase ? DatabaseReferenceLocation.sameDatabase : DatabaseReferenceLocation.differentDatabaseSameServer;
return location;
}
private async getDatabaseName(dacpac: Uri): Promise<string | undefined> {
const dacpacName = path.parse(dacpac.toString()).name;
let databaseName = await this.apiWrapper.showInputBox({
prompt: constants.databaseReferenceDatabaseName,
value: `${dacpacName}`
});
if (!databaseName) {
throw new Error(constants.databaseNameRequired);
}
databaseName = databaseName?.trim();
return databaseName;
}
//#region Helper methods
public getDeployDialog(project: Project): DeployDatabaseDialog {
@@ -497,7 +609,7 @@ export class ProjectsController {
canSelectFiles: false,
canSelectFolders: true,
canSelectMany: false,
openLabel: constants.selectFileFolder,
openLabel: constants.selectString,
defaultUri: this.apiWrapper.workspaceFolders() ? (this.apiWrapper.workspaceFolders() as WorkspaceFolder[])[0].uri : undefined
});
if (selectionResult) {
@@ -508,7 +620,7 @@ export class ProjectsController {
selectionResult = await this.apiWrapper.showSaveDialog(
{
defaultUri: this.apiWrapper.workspaceFolders() ? (this.apiWrapper.workspaceFolders() as WorkspaceFolder[])[0].uri : undefined,
saveLabel: constants.selectFileFolder,
saveLabel: constants.selectString,
filters: {
'SQL files': ['sql'],
'All files': ['*']

View File

@@ -133,6 +133,57 @@ export class Project {
return fileEntry;
}
/**
* Set the compat level of the project
* Just used in tests right now, but can be used later if this functionality is added to the UI
* @param compatLevel compat level of project
*/
public changeDSP(compatLevel: string): void {
const newDSP = `${constants.MicrosoftDatatoolsSchemaSqlSql}${compatLevel}${constants.databaseSchemaProvider}`;
this.projFileXmlDoc.getElementsByTagName(constants.DSP)[0].childNodes[0].nodeValue = newDSP;
}
/**
* Adds reference to the appropriate master dacpac to the project
*/
public async addMasterDatabaseReference(): Promise<void> {
const uri = this.getMasterDacpac();
this.addDatabaseReference(uri, DatabaseReferenceLocation.differentDatabaseSameServer, constants.master);
}
public getMasterDacpac(): Uri {
// check for invalid DSP
if (this.projFileXmlDoc.getElementsByTagName(constants.DSP).length !== 1 || this.projFileXmlDoc.getElementsByTagName(constants.DSP)[0].childNodes.length !== 1) {
throw new Error(constants.invalidDataSchemaProvider);
}
let dsp: string = this.projFileXmlDoc.getElementsByTagName(constants.DSP)[0].childNodes[0].nodeValue;
// get version from dsp, which is a string like Microsoft.Data.Tools.Schema.Sql.Sql130DatabaseSchemaProvider
// remove part before the number
let version: any = dsp.substring(constants.MicrosoftDatatoolsSchemaSqlSql.length);
// remove DatabaseSchemaProvider
version = version.substring(0, version.length - constants.databaseSchemaProvider.length);
// make sure version is valid
console.error(Object.values(TargetPlatform));
if (!Object.values(TargetPlatform).includes(version)) {
throw new Error(constants.invalidDataSchemaProvider);
}
return Uri.parse(path.join('$(NETCoreTargetsPath)', 'SystemDacpacs', version, 'master.dacpac'));
}
/**
* Adds reference to a dacpac to the project
* @param uri Uri of the dacpac
* @param databaseName name of the database
*/
public async addDatabaseReference(uri: Uri, databaseLocation: DatabaseReferenceLocation, databaseName?: string): Promise<void> {
let databaseReferenceEntry = new DatabaseReferenceProjectEntry(uri, databaseLocation, databaseName);
await this.addToProjFile(databaseReferenceEntry);
}
public createProjectEntry(relativePath: string, entryType: EntryType): ProjectEntry {
return new ProjectEntry(Uri.file(path.join(this.projectFolderPath, relativePath)), relativePath, entryType);
}
@@ -177,6 +228,26 @@ export class Project {
this.findOrCreateItemGroup(constants.Folder).appendChild(newFolderNode);
}
private addDatabaseReferenceToProjFile(entry: DatabaseReferenceProjectEntry): void {
const referenceNode = this.projFileXmlDoc.createElement(constants.ArtifactReference);
referenceNode.setAttribute(constants.Condition, constants.NetCoreCondition);
referenceNode.setAttribute(constants.Include, entry.fsUri.fsPath);
let suppressMissingDependenciesErrorNode = this.projFileXmlDoc.createElement(constants.SuppressMissingDependenciesErrors);
let falseTextNode = this.projFileXmlDoc.createTextNode('False');
suppressMissingDependenciesErrorNode.appendChild(falseTextNode);
referenceNode.appendChild(suppressMissingDependenciesErrorNode);
if (entry.databaseLocation === DatabaseReferenceLocation.differentDatabaseSameServer) {
let databaseVariableLiteralValue = this.projFileXmlDoc.createElement(constants.DatabaseVariableLiteralValue);
let databaseTextNode = this.projFileXmlDoc.createTextNode(entry.name);
databaseVariableLiteralValue.appendChild(databaseTextNode);
referenceNode.appendChild(databaseVariableLiteralValue);
}
this.findOrCreateItemGroup().appendChild(referenceNode);
}
private async updateImportedTargetsToProjFile(condition: string, projectAttributeVal: string, oldImportNode?: any): Promise<any> {
const importNode = this.projFileXmlDoc.createElement(constants.Import);
importNode.setAttribute(constants.Condition, condition);
@@ -213,6 +284,8 @@ export class Project {
break;
case EntryType.Folder:
this.addFolderToProjFile(entry.relativePath);
case EntryType.DatabaseReference:
this.addDatabaseReferenceToProjFile(<DatabaseReferenceProjectEntry>entry);
}
await this.serializeToProjFile(this.projFileXmlDoc);
@@ -270,7 +343,33 @@ export class ProjectEntry {
}
}
/**
* Represents a database reference entry in a project file
*/
class DatabaseReferenceProjectEntry extends ProjectEntry {
constructor(uri: Uri, public databaseLocation: DatabaseReferenceLocation, public name?: string) {
super(uri, '', EntryType.DatabaseReference);
}
}
export enum EntryType {
File,
Folder
Folder,
DatabaseReference
}
export enum DatabaseReferenceLocation {
sameDatabase,
differentDatabaseSameServer
}
export enum TargetPlatform {
Sql90 = '90',
Sql100 = '100',
Sql110 = '110',
Sql120 = '120',
Sql130 = '130',
Sql140 = '140',
Sql150 = '150',
SqlAzureV12 = 'AzureV12'
}

View File

@@ -12,6 +12,7 @@ export let openProjectFileBaseline: string;
export let openDataSourcesBaseline: string;
export let SSDTProjectFileBaseline: string;
export let SSDTProjectAfterUpdateBaseline: string;
export let dacpacReferencesProjectFileBaseline: string;
const baselineFolderPath = __dirname;
@@ -21,6 +22,7 @@ export async function loadBaselines() {
openDataSourcesBaseline = await loadBaseline(baselineFolderPath, 'openDataSourcesBaseline.json');
SSDTProjectFileBaseline = await loadBaseline(baselineFolderPath, 'SSDTProjectBaseline.xml');
SSDTProjectAfterUpdateBaseline = await loadBaseline(baselineFolderPath, 'SSDTProjectAfterUpdateBaseline.xml');
dacpacReferencesProjectFileBaseline = await loadBaseline(baselineFolderPath, 'dacpacReferencesSqlProjectBaseline.xml');
}
async function loadBaseline(baselineFolderPath: string, fileName: string): Promise<string> {

View File

@@ -7,10 +7,12 @@ import * as should from 'should';
import * as path from 'path';
import * as baselines from './baselines/baselines';
import * as testUtils from './testUtils';
import * as constants from '../common/constants';
import { promises as fs } from 'fs';
import { Project, EntryType } from '../models/project';
import { Project, EntryType, TargetPlatform } from '../models/project';
import { exists } from '../common/utils';
import { Uri } from 'vscode';
let projFilePath: string;
@@ -82,6 +84,32 @@ describe('Project: sqlproj content operations', function (): void {
await testUtils.shouldThrowSpecificError(async () => await project.addToProject(list), `ENOENT: no such file or directory, stat \'${nonexistentFile}\'`);
});
it('Should choose correct master dacpac', async function(): Promise<void> {
projFilePath = await testUtils.createTestSqlProjFile(baselines.newProjectFileBaseline);
const project = new Project(projFilePath);
await project.readProjFile();
let uri = project.getMasterDacpac();
should.equal(uri.fsPath, Uri.parse(path.join('$(NETCoreTargetsPath)', 'SystemDacpacs', '130', 'master.dacpac')).fsPath);
project.changeDSP(TargetPlatform.Sql150.toString());
uri = project.getMasterDacpac();
should.equal(uri.fsPath, Uri.parse(path.join('$(NETCoreTargetsPath)', 'SystemDacpacs', '150', 'master.dacpac')).fsPath);
project.changeDSP(TargetPlatform.SqlAzureV12.toString());
uri = project.getMasterDacpac();
should.equal(uri.fsPath, Uri.parse(path.join('$(NETCoreTargetsPath)', 'SystemDacpacs', 'AzureV12', 'master.dacpac')).fsPath);
});
it('Should throw error when choosing correct master dacpac if invalid DSP', async function(): Promise<void> {
projFilePath = await testUtils.createTestSqlProjFile(baselines.newProjectFileBaseline);
const project = new Project(projFilePath);
await project.readProjFile();
project.changeDSP('invalidPlatform');
await testUtils.shouldThrowSpecificError(async () => await project.getMasterDacpac(), constants.invalidDataSchemaProvider);
});
});
describe('Project: round trip updates', function (): void {

View File

@@ -227,6 +227,36 @@ describe('ProjectsController: import operations', function (): void {
});
});
describe('ProjectsController: add database reference operations', function (): void {
it('Should show error when no reference type is selected', async function (): Promise<void> {
testContext.apiWrapper.setup(x => x.showQuickPick(TypeMoq.It.isAny(), TypeMoq.It.isAny(), 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.addDatabaseReference(new Project('FakePath')), constants.databaseReferenceTypeRequired);
});
it('Should show error when no file is selected', async function (): Promise<void> {
testContext.apiWrapper.setup(x => x.showQuickPick(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve({ label: constants.dacpac }));
testContext.apiWrapper.setup(x => x.showOpenDialog(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.addDatabaseReference(new Project('FakePath')), constants.dacpacFileLocationRequired);
});
it('Should show error when no database name is provided', async function (): Promise<void> {
testContext.apiWrapper.setup(x => x.showQuickPick(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns(() => Promise.resolve({ label: constants.dacpac }));
testContext.apiWrapper.setup(x => x.showOpenDialog(TypeMoq.It.isAny())).returns(() => Promise.resolve([vscode.Uri.file('FakePath')]));
testContext.apiWrapper.setup(x => x.showQuickPick(TypeMoq.It.isAny())).returns(() => Promise.resolve({ label: constants.databaseReferenceDifferentDabaseSameServer }));
testContext.apiWrapper.setup(x => x.showInputBox(TypeMoq.It.isAny(), 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.addDatabaseReference(new Project('FakePath')), constants.databaseNameRequired);
});
});
describe('ProjectsController: round trip feature with SSDT', function (): void {
it('Should show warning message for SSDT project opened in Azure Data Studio', async function (): Promise<void> {
testContext.apiWrapper.setup(x => x.showWarningMessage(TypeMoq.It.isAny(), TypeMoq.It.isAny(), TypeMoq.It.isAny())).returns((s) => { throw new Error(s); });